Ненормальный GraphQL в Electron или как я писал десктопный клиент для Tinder

Предыстория


фрустрация и решение


Привет, Хабр. В начале зимы 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 — плохая библиотека. С эстетической точки зрения она очень элегантна. И если она позволяет вам писать хороший код, то это самое главное. Однако нельзя отрицать, что обычно с ней требуется попутно создавать очень много вспомогательного кода даже для простых вещей.


Итак, стиль redux не устраивал меня, а в лагах я винил его и react. Мне оставалось только одно.


Перестать писать


боль и печаль


Конечно, заголовок слегка провокационный. На самом деле подошёл период защиты диплома, сессия, сбор документов для магистратуры, военные сборы, и переезд в другую страну. Но как только всё слегка устаканилось, я перешёл к следующему пункту.


Переписать всё №1


Новый стек включал в себя Inferno и MobX. Обе эти библиотеки обещали хорошую производительность при минимуме работы руками (как позже выяснилось, не совсем). В целом с ними было приятно работать, благодаря MobX код стал гораздо лаконичнее, но параллельно росли три проблемы.


Где хранить историю?


изначальный путь данных


Первым очевидным решением было использования localStorage. Для этого я использовал замечательную библиотеку localForage. Однако JSON.stringify и JSON.parse при каждом сохранении и извлечении истории (а история сохранялась каждый раз заново целиком при каждом обновлении) не добавлял радости. Даже то, что теперь я запрашивал лишь обновления с сервера и сливал их с историей, не позволяло добиться желаемой производительности.


путь данных с IndexedDB в WebWorker


Следующим решением было использование IndexedDB, а для максимальной производительности была выбрана библиотека Dexie.js. Быстро выяснилось, что обновление лишь изменившихся данных существенно добавляет скорости, но лаги интерфейса всё ещё были заметны. Тогда я вынес всю работу с IndexedDB в WebWorker и вроде бы всё наладилось.


Как синхронизировать данные?


полный путь данных с IndexedDB в WebWorker + MobX


Для запроса к API Tinder необходимо устанавливать специальные заголовки для мимикрии под их Android-клиент. Из соображений безопасности браузерный JS не поддерживает такие трюки, так что все запросы выполнялись из main процесса Electron.


Таким образом, данные проходили следующий путь:


  1. Получение с сервера в main процессе и отправление в WebWorker
  2. Обработка, запись в IndexedDB, и отправление в renderer
  3. Запись в хранилища MobX, что обеспечивало обновление интерфейса

Это позволило добиться приемлемой производительности, но stores разрослись и каждый раз аккуратно сливать данные в IndexedDB, а затем и в MobX означало делать одну и ту же работу дважды руками. Кроме того, была и третья проблема.


Сырая инфраструктура Inferno


разработчик против мамонта


Inferno побеждает конкурентов по скорости почти во всех бенчмарках, но производительность разработчика не менее важна. Несмотря на существование inferno-compat, многие React-библиотеки всё равно не работали. С трудом получалось запустить material-ui, не подгружалась react-vistualized.


Решение о переходе


Конечно, большая часть отсутствующих вещей была довольна простой и легко писалась самостоятельно. Кое-где получалось завести React-библиотеки при помощи пары грязных хаков. Но в целом эта ситуация стала утомлять меня, как и ручная синхронизация базы данных и реактивного хранилища. Я старался вносить вклад в репозиторий Inferno, но надолго меня не хватило. Три процесса для такого простого приложения тоже казались перебором. Мне хотелось чего-нибудь декларативного и не требующего кучи кода для поддержки.


Переписать всё №2


путь данных с GraphQL


На этот раз решение было более взвешенным. Нужна совместимость с 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


При помощи GraphQL я избавился от большого количества моего кода (значит и от моих багов). Добавлять новые возможности в код стало гораздо проще. Я и приложение стали работать быстрее.


Я надеюсь, что этот подход поможет кому-нибудь в создании его приложений на Electron. Я планирую выделить реализацию GraphQL-over-IPC в отдельный npm пакет, чтобы её можно было удобно использовать.


Планы развития


К версии 2.0 мне хотелось бы


  • Переписать на TypeScript хотя бы main процесс
  • Добавить поиск по сообщениям и контактам
  • Добавить возможность блокировки пользователя и редактирования своего профиля

Для интересующихся


GitHub
Сайт


Скриншот

скриншот


Выводы
  • Декларативное лучше императивного
  • Если вы уверены, что хотите переписать всё на X, то сперва подумайте:
    • Стоит ли переписывать?
    • Является ли X лучшим выбором?
    • Потратьте неделю на поиск альтернатив и взвесьте все плюсы и минусы

Спасибо Юле Курди за замечательные иллюстрации!

Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 30
  • +1
    Всё бы ничего, но постепенно стала накапливаться усталость из-за невозможности нормально печатать на физической клавиатуре.

    Первый вариант меня не устраивал из-за принципиального превосходства реальной клавиатуры над экранной.


    А вообще в курсе, что в 2017-м к любому смартфону и планшету можно подключить клавиатуру по bluetooth?
    • 0

      И через OTG.

      • 0

        и по юсб. лет 5 назад тоже работало с андроидом...

        • +3
          Ну всё-таки согласитесь, что либо нужна отдельная bluetooth-клавиатура (которой у меня нет), либо требуется как-то подключать клавиатуру ноутбука к телефону, уж тогда проще было бы поставить BlueStacks.

          Да и чего уж там, писать это приложение было довольно весело :)
        • 0
          Так есть же веб-интерфейс у тиндера. Только к нему VPN нужен с выходом из Швеции.
          • +1

            Начал делать приложение ещё до их анонса, а потом уже оставалось доделать совсем немного. Да и просто интересно было выпустить первый осмысленный продукт.

          • +1
            На самом деле подошёл период защиты диплома, сессия, сбор документов для магистратуры, военные сборы, и переезд в другую страну.

            Какая у вас интересная судьба
            • +1

              Спасибо, очень понравилась статья. Единственное, что не дает покоя — размер программы в 150 мегабайт.

              • +1

                Это бич приложений на Electron — там вместе с приложением упаковывается хром и node.js, если я правильно понимаю. Есть экспериментальные проекты типа Electrino, но по-моему там всё ещё в статусе беты.

              • 0
                Отличная статья, несколько заметок оставил себе «на будущее», тоже игрался с большинством из этих продуктов.

                Вопрос из чистого любопытства — как много времени ушло на реализацию?

                П.С. Согласен с предыдущим комментатором,
                Какая у вас интересная судьба
                • 0

                  Сложно ответить, если честно. По моим ощущениям большая часть работы была проделана в 1.5 месяца до релиза, до этого больше игрался.

                • 0
                  Зачем пилить под десктоп то что отлично работало бы в вебе?
                  • 0

                    Сейчас приложение эмулирует официальное приложения Тиндера на андроиде, браузерный JS в целях безопасности не позволяет устанавливать некоторые заголовки запроса, так что сервер в любом случае был бы нужен. Конечно, можно было бы делать запросы на сервере и просто отображать данные в браузере, но для меня клиент для чатов всё-таки ассоциируется с отдельным приложением, да и просто было интересно поработать с Electron. В принципе сейчас рендеринг и логика практически не связаны друг с другом, так что перенести это в чистый веб должно быть довольно просто.

                  • 0
                    Количество зависимостей для такого небольшого приложения удивило. Нашлось место даже jquery.

                    А redux в зависимостях зачем остался?
                    • 0

                      jquery тащит за собой emojionearea, если подскажете другой способ реализации формы ввода со смайликами, то я буду очень рад, самому это не очень нравится


                      redux нужен как зависимость react-apollo, они используют его для хранилища

                      • 0

                        А зачем у вас в package.json jquery?

                        • 0

                          Автор emojionearea в любой момент мог поместить jquery в peerDependencies (где этой библиотеке, кстати, и место), я хотел быть уверенным, что ничего не сломается при обновлении + с переходом на electron-forge потерялась возможность использовать webpack.ProvidePlugin и самым простым оказалось объявить jQuery глобальной переменной


                          На мой взгляд, если уж импортировать библиотеку в своём коде, то она должна быть явно прописана в зависимостях.

                          • 0
                            electron-forge при использовании typescript вроде как тащит исходники в конечный продукт без транспиляции и гонит их в js в момент старта (для этого нужен electron-compile в dependencies). Как-то плохо похоже на будущее электрона в плане быстродействия или я просто не нашел механизма принудительной транспиляции при сборке?
                            • 0

                              Не совсем. Они патчат механизм загрузки модулей, при загрузке модуля проверяя, есть ли он в кэше. Когда приложение собирается, все модули транспилируются. Они тащат исходники, потому что для ноды нужно чтобы при импорте файла он существовал в файловой системе, но в целях оптимизации можно все исходники при финальной сборке делать файлами нулевого размера.

                              • 0
                                Действительно, работает, но выглядит это все ущербно. Проблемы:
                                • Необходимость наличия файлов сорцов, пусть даже и пустых. Как минимум странно. Причем этот косяк был подтвержден еще в январе 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. Что я делаю не так?
                                • +1

                                  В целом вы правы, возможно мне хотелось большей лаконичности после electron-react-boilerplate. С electron-forge удобно что они берут на себя не только транспиляцию, но и создание установщиков, их подпись и даже релиз на гитхабе.


                                  В планах попробовать fuse-box, чистый typescript не очень удобен, поскольку я ещё импортирую .graphql файлы (хотя, конечно, их не так много и можно перегнать их в .ts)

                                  • 0
                                    Ах да, забыл — отладка main-потока проблематична, потому что запуск делается через подхаченный электрон.
                                    Вообще, интересная идея, но как-то саппорт подкачал. :(
                                    За fuse-box спасибо, посмотрю.
                        • 0
                          https://github.com/Reon90/emoji.js
                          • 0
                            как насчет попробовать реатовский врапер над emojione?
                            • 0

                              Знаю про него, но не совсем решает проблему поля ввода. Я просто гоняю сам emojione чтобы сгенерировать html сообщений со смайликами и вставляю его через dangerouslySetInnerHTML (заодно и преобразование текст -> html производится не на лету, а один раз)

                        • 0

                          wasd171
                          Возможно, я просто туплю — но я не понял, а кто реализует graphql-сервер в вашем подходе для преобразования клиентских запросов к виду, понятному для Tinder API? Или Tinder уже предоставляет api в формате graphql?

                          • 0

                            В Electron есть 2 типа процессов — main (node.js) и renderer (Chrome). В main процессе реализован GraphQL API, который транслируется в запросы, понятные Tinder API при помощи tinder-modern

                          • 0
                            Пишу нечто похоже по архитектуре.
                            Использую gunjs + leveldb. Gun поднимает веб-сокет на бакенде и клиент к нему подключается. Этот подход понравился тем что можно без труда перенести приложение в веб, т.к. там не юзается ipc и вообще ничего что как-то может зависеть от electron. Т.е. у меня сейчас оно так и билдится, есть билд для веб, клиент + сервер, есть всё вместе внутри electron.

                            • 0

                              Тут ipc используется чисто как транспорт, будет легко поменять на websockets, например. А внутри электрона вы поднимаете локальный сервер? Можно было бы конечно прикрутить koa/express и слушать запросы как обычно, но я испытываю внутренний дискомфорт при идее, что это забивает локальные порты типа localhost:5000.

                            • 0

                              Не заметил в статье мотивации писать именно под Electron. Что Вас сподвигло на такие подвиги и почему не использовали родной GUI, например, на Java?

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое