Предыстория

Привет, Хабр. В начале зимы 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.
Да и чего уж там, писать это приложение было довольно весело :)