Предыстория
Привет, Хабр. В начале зимы 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 лучшим выбором?
- Потратьте неделю на поиск альтернатив и взвесьте все плюсы и минусы
Спасибо Юле Курди за замечательные иллюстрации!
Комментарии (29)
danial72
22.06.2017 17:10+1На самом деле подошёл период защиты диплома, сессия, сбор документов для магистратуры, военные сборы, и переезд в другую страну.
Какая у вас интересная судьба
FenixFly
22.06.2017 20:39+1Спасибо, очень понравилась статья. Единственное, что не дает покоя — размер программы в 150 мегабайт.
Zo0m3R
22.06.2017 20:39Отличная статья, несколько заметок оставил себе «на будущее», тоже игрался с большинством из этих продуктов.
Вопрос из чистого любопытства — как много времени ушло на реализацию?
П.С. Согласен с предыдущим комментатором,Какая у вас интересная судьба
wasd171
22.06.2017 21:05Сложно ответить, если честно. По моим ощущениям большая часть работы была проделана в 1.5 месяца до релиза, до этого больше игрался.
Chikey
22.06.2017 23:09Зачем пилить под десктоп то что отлично работало бы в вебе?
wasd171
23.06.2017 00:47Сейчас приложение эмулирует официальное приложения Тиндера на андроиде, браузерный JS в целях безопасности не позволяет устанавливать некоторые заголовки запроса, так что сервер в любом случае был бы нужен. Конечно, можно было бы делать запросы на сервере и просто отображать данные в браузере, но для меня клиент для чатов всё-таки ассоциируется с отдельным приложением, да и просто было интересно поработать с Electron. В принципе сейчас рендеринг и логика практически не связаны друг с другом, так что перенести это в чистый веб должно быть довольно просто.
chilicoder
22.06.2017 23:23Количество зависимостей для такого небольшого приложения удивило. Нашлось место даже jquery.
А redux в зависимостях зачем остался?wasd171
23.06.2017 00:42jquery тащит за собой emojionearea, если подскажете другой способ реализации формы ввода со смайликами, то я буду очень рад, самому это не очень нравится
redux нужен как зависимость react-apollo, они используют его для хранилища
Fen1kz
23.06.2017 02:01А зачем у вас в package.json jquery?
wasd171
23.06.2017 02:26Автор emojionearea в любой момент мог поместить jquery в peerDependencies (где этой библиотеке, кстати, и место), я хотел быть уверенным, что ничего не сломается при обновлении + с переходом на electron-forge потерялась возможность использовать webpack.ProvidePlugin и самым простым оказалось объявить jQuery глобальной переменной
На мой взгляд, если уж импортировать библиотеку в своём коде, то она должна быть явно прописана в зависимостях.
Leopotam
23.06.2017 09:17electron-forge при использовании typescript вроде как тащит исходники в конечный продукт без транспиляции и гонит их в js в момент старта (для этого нужен electron-compile в dependencies). Как-то плохо похоже на будущее электрона в плане быстродействия или я просто не нашел механизма принудительной транспиляции при сборке?
wasd171
23.06.2017 10:23Не совсем. Они патчат механизм загрузки модулей, при загрузке модуля проверяя, есть ли он в кэше. Когда приложение собирается, все модули транспилируются. Они тащат исходники, потому что для ноды нужно чтобы при импорте файла он существовал в файловой системе, но в целях оптимизации можно все исходники при финальной сборке делать файлами нулевого размера.
Leopotam
23.06.2017 12:03Действительно, работает, но выглядит это все ущербно. Проблемы:
- Необходимость наличия файлов сорцов, пусть даже и пустых. Как минимум странно. Причем этот косяк был подтвержден еще в январе 2016 с фразой «будет исправлено в следующей версии». Воз и ныне там.
- react-typescript темплейт мертвый — не собирается конечное приложение, проблема в неправильном использовании пакетов разработки в dependencies, да и вообще — куча лишнего. После выкидывания половины пакетов и реорганизации зависимостей — рабочий, но отношение к официальным темплейтам настораживает.
- Тайпинги мертвые. @types/electron устарел, теперь они идут вместе с пакетом electron, т.е. приходится лезть во внутренности electron-prebuilt-compile/node_modules/electron за актуальными тайпингами и пришивать их через reference path. Ставить оригинальный electron нельзя — будет конфликт с патченным electron-compile.
- Требование electron-compile даже если все было транспилировано в кеш, а это сразу +5мб.
- Странаня привязка к фиксированной версии electron-compile / electron-prebuilt-compile, неудобно апдейтить автоматом.
В результате проще иметь 3 зависимости в devDependencies: electron, typescript, electron-packager и прикрутить в скрипты вызов tsc и electron-packager. Что я делаю не так?wasd171
23.06.2017 12:13+1В целом вы правы, возможно мне хотелось большей лаконичности после electron-react-boilerplate. С electron-forge удобно что они берут на себя не только транспиляцию, но и создание установщиков, их подпись и даже релиз на гитхабе.
В планах попробовать fuse-box, чистый typescript не очень удобен, поскольку я ещё импортирую .graphql файлы (хотя, конечно, их не так много и можно перегнать их в .ts)
Leopotam
23.06.2017 12:14Ах да, забыл — отладка main-потока проблематична, потому что запуск делается через подхаченный электрон.
Вообще, интересная идея, но как-то саппорт подкачал. :(
За fuse-box спасибо, посмотрю.
chilicoder
23.06.2017 14:46как насчет попробовать реатовский врапер над emojione?
wasd171
23.06.2017 14:52Знаю про него, но не совсем решает проблему поля ввода. Я просто гоняю сам emojione чтобы сгенерировать html сообщений со смайликами и вставляю его через dangerouslySetInnerHTML (заодно и преобразование текст -> html производится не на лету, а один раз)
amakhrov
23.06.2017 07:39wasd171
Возможно, я просто туплю — но я не понял, а кто реализует graphql-сервер в вашем подходе для преобразования клиентских запросов к виду, понятному для Tinder API? Или Tinder уже предоставляет api в формате graphql?wasd171
23.06.2017 10:26В Electron есть 2 типа процессов — main (node.js) и renderer (Chrome). В main процессе реализован GraphQL API, который транслируется в запросы, понятные Tinder API при помощи tinder-modern
kellas
23.06.2017 13:02Пишу нечто похоже по архитектуре.
Использую gunjs + leveldb. Gun поднимает веб-сокет на бакенде и клиент к нему подключается. Этот подход понравился тем что можно без труда перенести приложение в веб, т.к. там не юзается ipc и вообще ничего что как-то может зависеть от electron. Т.е. у меня сейчас оно так и билдится, есть билд для веб, клиент + сервер, есть всё вместе внутри electron.
wasd171
23.06.2017 13:59Тут ipc используется чисто как транспорт, будет легко поменять на websockets, например. А внутри электрона вы поднимаете локальный сервер? Можно было бы конечно прикрутить koa/express и слушать запросы как обычно, но я испытываю внутренний дискомфорт при идее, что это забивает локальные порты типа localhost:5000.
MzMz
А вообще в курсе, что в 2017-м к любому смартфону и планшету можно подключить клавиатуру по bluetooth?
ivan386
И через OTG.
edge790
и по юсб. лет 5 назад тоже работало с андроидом...
wasd171
Ну всё-таки согласитесь, что либо нужна отдельная bluetooth-клавиатура (которой у меня нет), либо требуется как-то подключать клавиатуру ноутбука к телефону, уж тогда проще было бы поставить BlueStacks.
Да и чего уж там, писать это приложение было довольно весело :)