Предыстория
Привет. В начале зимы 2016 года я снова стал одинок. Спустя какое-то время я решил завести себе профиль в Tinder. Всё бы ничего, но постепенно стала накапливаться усталость из-за невозможности нормально печатать на физической клавиатуре. Мне виделось несколько решений этой проблемы:
- Смириться и продолжать использовать официальное приложение для смартфона
- Использовать BlueStacks с официальным приложением на Android
- Использовать существующие клиенты для десктопа (Tinder++)
- Написать свой
Первый вариант меня не устраивал из-за принципиального превосходства реальной клавиатуры над экранной. Второй вариант не подходил из-за того, что всё-таки это было бы приложение, не оптимизированное под десктоп. Третий вариант был всем хорош кроме дизайна, багов, и малой активности в репозитории. Позже Tinder++ получил письмо от юристов Tinder и проект был и вовсе свёрнут. Таким образом, лично для меня выбор был очевиден.
Старт
Прежде всего стоит отметить, что у Tinder нет открытого API, однако оно было вскрыто в 2014 году при помощи MITM. Этого оказалось вполне достаточно для написания клиента.
Пожалуй, единственным, что почти не претерпело изменений во время нелёгкой судьбы проекта, был Electron.
Мне не терпелось поиграться с React, поэтому была выбрана стандартная связка react + redux + redux-saga + immutable. К маю была написана первая версия, но возникли проблемы с моими кривыми руками архитектурой. Выяснилось, что для того, чтобы сделать redux быстрым требуется много ручной работы: мемоизация, shouldComponentUpdate, фабрики селекторов и тому подобное.
Также, пожалуй, не стоило каждый раз запрашивать всю историю и сливать её с существующим store при помощи Immutable.Map.mergeDeep
В любом случае, многословность redux и redux-saga стала утомлять меня. Моим постоянным впечатлением было, что библиотека борется со мной вместо того, чтобы помогать.
Я не хочу сказать, что redux — плохая библиотека. С эстетической точки зрения она очень элегантна. И если она позволяет вам писать хороший код, то это самое главное. Однако нельзя отрицать, что обычно с ней требуется попутно создавать очень много вспомогательного кода даже для простых вещей.
Итак, стиль redux не устраивал меня, а в лагах я винил его и react. Мне оставалось только одно.
Перестать писать
Конечно, заголовок слегка провокационный. На самом деле подошёл период защиты диплома, сессия, сбор документов для магистратуры, военные сборы, и переезд в другую страну. Но как только всё слегка устаканилось, я перешёл к следующему пункту.
Переписать всё №1
Новый стек включал в себя Inferno и MobX. Обе эти библиотеки обещали хорошую производительность при минимуме работы руками (как позже выяснилось, не совсем). В целом с ними было приятно работать, благодаря MobX код стал гораздо лаконичнее, но параллельно росли три проблемы.
Где хранить историю?
Первым очевидным решением было использования localStorage. Для этого я использовал замечательную библиотеку localForage. Однако JSON.stringify и JSON.parse при каждом сохранении и извлечении истории (а история сохранялась каждый раз заново целиком при каждом обновлении) не добавлял радости. Даже то, что теперь я запрашивал лишь обновления с сервера и сливал их с историей, не позволяло добиться желаемой производительности.
Следующим решением было использование IndexedDB, а для максимальной производительности была выбрана библиотека Dexie.js. Быстро выяснилось, что обновление лишь изменившихся данных существенно добавляет скорости, но лаги интерфейса всё ещё были заметны. Тогда я вынес всю работу с IndexedDB в WebWorker и вроде бы всё наладилось.
Как синхронизировать данные?
Для запроса к API Tinder необходимо устанавливать специальные заголовки для мимикрии под их Android-клиент. Из соображений безопасности браузерный JS не поддерживает такие трюки, так что все запросы выполнялись из main процесса Electron.
Таким образом, данные проходили следующий путь:
- Получение с сервера в main процессе и отправление в WebWorker
- Обработка, запись в IndexedDB, и отправление в renderer
- Запись в хранилища MobX, что обеспечивало обновление интерфейса
Это позволило добиться приемлемой производительности, но stores разрослись и каждый раз аккуратно сливать данные в IndexedDB, а затем и в MobX означало делать одну и ту же работу дважды руками. Кроме того, была и третья проблема.
Сырая инфраструктура Inferno
Inferno побеждает конкурентов по скорости почти во всех бенчмарках, но производительность разработчика не менее важна. Несмотря на существование inferno-compat, многие React-библиотеки всё равно не работали. С трудом получалось запустить material-ui, не подгружалась react-vistualized.
Решение о переходе
Конечно, большая часть отсутствующих вещей была довольна простой и легко писалась самостоятельно. Кое-где получалось завести React-библиотеки при помощи пары грязных хаков. Но в целом эта ситуация стала утомлять меня, как и ручная синхронизация базы данных и реактивного хранилища. Я старался вносить вклад в репозиторий Inferno, но надолго меня не хватило. Три процесса для такого простого приложения тоже казались перебором. Мне хотелось чего-нибудь декларативного и не требующего кучи кода для поддержки.
Переписать всё №2
На этот раз решение было более взвешенным. Нужна совместимость с React — просто используем React, подобные бенчмарки важны лишь если отображать тысячи элементов. Не нравится слишком много процессов — значит данные нужно хранить там же, откуда они и приходят, в main процессе. В целом нравится MobX и его преимущества, но с большими хранилищами становится не очень удобно работать — следовательно MobX остаётся в качестве менеджера локального состояния компонентов, а для глобальных данных используется что-то ещё.
Если вы прочитали заголовок статьи, то что-то ещё будет для вас очевидным. Разумеется, это GraphQL. В качестве клиента используется Apollo. Сперва решение покажется необычным, но призадумавшись, вы обнаружите много плюсов:
- Данные передаются не по сети, а через IPC, значит задержка практически отсутствует
- Apollo автоматически сливает данные в своём redux хранилище
- Декларативная подача данных к компонентам
- Готовые решения для сложных вещей вроде optimistic updates
Разумеется, в Apollo по умолчанию нет поддержки IPC, однако есть возможность создать свой сетевой интерфейс. Это очень просто:
import { ipcRenderer } from 'electron'
import { GRAPHQL } from 'shared/constants'
import uuid from 'uuid'
import { print } from 'graphql/language/printer'
export class ElectronInterface {
ipc
listeners = new Map()
constructor(ipc = ipcRenderer) {
this.ipc = ipc
this.ipc.on(GRAPHQL, this.listener)
}
listener = (event, args) => {
const { id, payload } = args
if (!id) {
throw new Error('Listener ID is not present!')
}
const resolve = this.listeners.get(id)
if (!resolve) {
throw new Error(`Listener with id ${id} does not exist!`)
}
resolve(payload)
this.listeners.delete(id)
}
printRequest(request) {
return {
...request,
query: print(request.query)
}
}
generateMessage(id, request) {
return {
id,
payload: this.printRequest(request)
}
}
setListener(request, resolve) {
const id = uuid.v1()
this.listeners.set(id, resolve)
const message = this.generateMessage(id, request)
this.ipc.send(GRAPHQL, message)
}
query = request => {
return new Promise(this.setListener.bind(this, request))
}
}
Далее приведён код обработки запросов в main процессе. Все фабрики создают методы класса ServerAPI.
Код для выполнения GraphQL запроса:
// @flow
import { ServerAPI } from './ServerAPI'
import { graphql } from 'graphql'
export default function callGraphQLFactory(instance: ServerAPI) {
return function callGraphQL(payload: any) {
const { query, variables, operationName } = payload
return graphql(
instance.schema,
query,
null,
instance,
variables,
operationName
)
}
}
Код для создания ответного сообщения:
// @flow
export default function generateMessage(id: string, res: any) {
return {
id,
payload: res
}
}
Код, обрабатывающий запрос и возвращающий данные:
// @flow
import { ServerAPI } from './ServerAPI'
import { GRAPHQL } from 'shared/constants'
type RequestArguments = {
id: string,
payload: any
}
export default function processRequestFactory(instance: ServerAPI) {
return async function processRequest(event: Event, args: RequestArguments) {
const { id, payload } = args
const res = await instance.callGraphQL(payload)
const message = instance.generateMessage(id, res)
if (instance.app.window !== null) {
instance.app.window.webContents.send(GRAPHQL, message)
}
}
}
И, наконец, в конструкторе создаём подписчик на сообщение:
import { ipcMain } from 'electron'
ipcMain.on(GRAPHQL, instance.processRequest)
Теперь при получении каждого обновлении оно записывается в базу данных NeDB, затем main процесс при помощи IPC шлёт в renderer процесс сообщение о необходимости перезапросить актуальные данные.
Дополнения
Навигация
Я очень долго не хотел использовать react-router. Дело в том, что я застал их масштабное переписывание API и не горел желанием наступать на те же грабли в очередной раз. Поэтому сперва я подключил router5 + самописное middleware, синхронизирующее состояние в MobX. Внутри Electron де-факто нет URL в привычном смысле, так что идея хранить состояние навигации в реактивном хранилище была отличной. Однако несмотря на то, что такая связка даёт вам полный контроль над навигацией, порой она требует слишком много лишнего кода.
Переход на react-router@v4 я совместил с частичным переходом с Flexbox на CSS Grid. Эти вещи будто созданы друг для друга. Похоже, что в этот раз у команды react-router действительно получилось!
Система сборки
Сперва я использовал webpack и electron-packager, но во время последнего крупного изменения перешёл на electron-forge. Насколько я понимаю, в будущем этот пакет станет стандартным решением для сборки и распространения приложений на Electron. Он включает в себя electron-packager для сборки и electron-compile, позволяющий транспилировать JS/TS, компилировать другие форматы (Less, Stylus, SCSS), и многое другое практически без конфигурации.
Результаты
При помощи GraphQL я избавился от большого количества моего кода (значит и от моих багов). Добавлять новые возможности в код стало гораздо проще. Я и приложение стали работать быстрее.
Я надеюсь, что этот подход поможет кому-нибудь в создании его приложений на Electron. Я планирую выделить реализацию GraphQL-over-IPC в отдельный npm пакет, чтобы её можно было удобно использовать.
Планы развития
К версии 2.0 мне хотелось бы
- Переписать на TypeScript хотя бы main процесс
- Добавить поиск по сообщениям и контактам
- Добавить возможность блокировки пользователя и редактирования своего профиля
Для интересующихся
- Декларативное лучше императивного
- Если вы уверены, что хотите переписать всё на X, то сперва подумайте:
- Стоит ли переписывать?
- Является ли X лучшим выбором?
- Потратьте неделю на поиск альтернатив и взвесьте все плюсы и минусы
Спасибо Юле Курди за замечательные иллюстрации!
Автор: wasd171