javascript

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

  • пятница, 23 июня 2017 г. в 03:14:14
https://habrahabr.ru/post/331446/
  • ReactJS
  • Node.JS
  • JavaScript


Предыстория


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


Привет, Хабр. В начале зимы 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 лучшим выбором?
    • Потратьте неделю на поиск альтернатив и взвесьте все плюсы и минусы

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