Если у вас есть компьютер и вы используете его по назначению, то скорее всего вы так или иначе работали с приложениями на Electron (даже если об этом не знали).
Меня зовут Сергей Володин, я руковожу командой разработки VK WorkMail. Расскажу, как на основе Electron мы за две недели создали PoC кроссплатформенного настольного приложения Почты, что узнали о технологии и к каким выводам пришли.
Для начала немного контекста. Наша команда занимается продуктовой разработкой VK WorkMail — корпоративного почтового сервиса, доступного по модели SaaS и в виде On-Premise решения. Продукт существует как мобильное приложение для Android/iOS, в web-версии, а также как один из элементов суперприложения VK Teams. У нас единая кодовая база с Почтой Mail.ru и VK Почтой.
Мы работаем на B2B-рынке и время от времени получаем от наших клиентов запросы на настольное приложение. Это не что-то вроде «Хотим отдельную иконку, а не браузер», а запрос на вполне серьёзную функциональность: возможность работать оффлайн, локальную выгрузку или удаление писем с сервера. Другими словами — запрос на создание Thick Client, или толстого клиента.
Что значит толстый клиент?
Сначала нужно договориться о понятиях. Большинство web-приложений работают по принципу тонкого клиента (Thin Client). Подобный подход представляет из себя классическое клиент-серверное взаимодействие:
Клиент, то есть интерфейс или фронтенд, шлёт запросы на сервер и получает необходимые для работы данные.
Но если говорить про работу с почтой в оффлайне или, как в нашем случае, работу с данными на устройстве пользователя, то нам необходим толстый клиент, который обеспечивает необходимую функциональность независимо от работы сервера.
Как мы пришли к идее завернуть наше web-приложение в Electron?
В компании уже проводили расчет трудозатрат на запуск полноценного сервиса с оффлайн-функциональностью. Мы рассматривали offline-first подход в web-версии клиента с применением IndexedDB в качестве хранилища и Service Worker как средство кеширования статики. Однако мы пришли к выводу, что потребуется проводить значительный рефакторинг всего клиентского кода с потенциальными трудозатратами в человеко-год.
Более того, сделать по-настоящему настольное приложение силами одного лишь фронтенда не получится. Всё дело в ограниченности браузера при работе с локальной файловой системой.
Одна из главных функций настольного почтового сервиса — локальная архивация писем. Приложение должно уметь выгружать письма в виде архива или файла, а также читать и предоставлять пользователю полноценный доступ к ним. Формально можно загружать такие файлы в браузер, парсить и раскладывать в IndexDB. Но поскольку архивы могут быть большого размера, это заметно снизит производительность. Другое дело — иметь полноценный доступ к файловой системе.
Размышляя об этой задаче, мы решили посмотреть на неё с другой стороны: можно ли решить нашу проблему, вообще не трогая логику web-клиента? Ведь мы не меняем интерфейс, пользователь видит все те же кнопки и визуальные компоненты. Функциональность, которая нам требуется, это совсем не про UI, а про локальную, то есть не серверную, бизнес-логику. Для наглядности размышлений, посмотрим на схему толстого клиента. Отмечу, что речь именно про клиент, поэтому серверная часть будет показана в упрощенном виде:
Здесь мы разделяем клиент на две функциональные части:
UI. По-сути, просто наше web-приложение, тонкий клиент. То есть интерфейс, который отвечает за отрисовку и запрашивает необходимые данные через запросы.
Local business logic (далее LBL). Здесь уже интереснее. Если в тонком клиенте за данными мы ходим напрямую на сервер, то в толстом клиенте — в специальный модуль LBL. Это может быть специальный процесс или отдельный модуль программы. Важно то, что он имеет у себя данные и отвечает за их отправку в UI. Также он отвечает за синхронизацию с сервером.
Как видно на этой схеме, если сервер недоступен, то клиент всё ещё сможет спокойно работать с данными. Более того, это будет не просто режим «только чтение», клиент также сможет совершать и более сложные действия с точки зрения бизнес-логики. Например, написать новое письмо или создать фильтр для писем. И как только почтовый сервис получит доступ к серверу, действия будут синхронизированы.
Оперируя изложенной выше логикой, мы пришли к идее провести эксперимент по упаковке web-приложения в оболочку для толстого клиента. Мы решили проверить, сможем ли использовать технологии, подходы и компоненты из web-версии и серверной части для создания полноценной настольной версии VK WorkMail. Суть эксперимента — быстро проверить гипотезу. Поэтому мы выделили на него сравнительно мало ресурсов: две человеко-недели (одна фронтендера и одна бэкендера). В конечном итоге нам было необходимо получить PoC (Proof of Concept) или, если угодно, MVP настольного приложения VK WorkMail.
Проектирование и выбор технологий
Перед тем как приступать непосредственно к коду, мы провели сравнительный анализ нескольких подходов. Взяли Native UI, Qt Framework и Electron и занесли их в таблицу.
Технология | Примени-мость для web | Кроссплат-форменность | Потребление памяти | Занимает на диске | Производи-тельность | Время на разработку и сопро-вождение |
---|---|---|---|---|---|---|
Native UI | - | - | мало | мало | идеально | очень много |
QT | - | + | средне (~ 130 Мб) | средне (~ 70,8 Мб) | хорошо | много |
Electron | + | + | много (от 200 Мб до гигабайтов) | много (не меньше 150 Мб) | медленно | немного |
Native UI, возможно, один из лучших вариантов с точки зрения пользовательского опыта. Но наш сервис по умолчанию должен быть кроссплатформенным, а Native UI предполагает разработку отдельной реализации для каждой ОС, то есть как минимум трёх: MacOS, Windows, Linux. Другая проблема заключается в том, что UI VK WorkMail — это довольно сложный с точки зрения дизайн-системы проект. Интерфейс содержит в себе много кастомных компонентов, которые придётся реализовать нативно, что также замедлит разработку.
Следующий вариант — Qt. На бумаге выглядит хорошим и сбалансированным решением для создания настольного приложения. Однако нам в любом случае пришлось бы создать с нуля ещё одну версию приложения и поддерживать её параллельно с web-версией. Очевидно, это не слишком вписывается в концепцию нашего эксперимента.
Поэтому наш взор неизбежно пал на Electron. Кроссплатформенный, с открытым исходным кодом и построенный на web-технологиях. А также медленный (в сравнении с нативными приложениями) и требовательный по ресурсам (в два и более раза по сравнению с тем же Qt). Даже беря во внимание возможные недостатки, в нашей ситуации это был фактически единственный вариант создать настольное приложение командой web-разработчиков.
Для более подробного погружения в тему выбора технологий для создания настольного приложения и более подробного их сравнения советую ознакомиться со статьёй «Почему Electron это необходимое зло» на английском языке.
Выбор технологии для LBL-компонента
Если с UI всё более менее понятно — там будут использованы технологии из нашей web-версии, — то с LBL всё интереснее. Это, без сомнений, сердце нашего технического решения. С одной стороны, Electron way — это реализация функциональности внутри процесса Electron, то есть де-факто в NodeJs. Однако в таком случае нам придётся писать всю необходимую логику (возможно, дублирующую её же с сервера) заново, силами JS-программистов.
Есть также другой подход, часто применяемый в настольной разработке: вынесение кроссплатформенной core-части, отвечающей за бизнес-логику (то, что я называю LBL в этой статье) в отдельный модуль и процесс. Такой путь актуален при разработке интерфейса с Native UI-подходом, поскольку в этом случае необходимо использовать свой фреймворк под каждую ОС. Часть, отвечающая за бизнес-логику, пишется один раз и в дальнейшем используется вновь.
Так вот к чему я это всё? У нас уже есть серверная часть Почты, написанная на Go. Поскольку перед нами сейчас стоит задача написать модуль, который будет выполнять различную бизнес-логику на клиенте, то было бы неплохо использовать код сервера.
Мы решили пойти по второму пути и писать LBL-компонент на Go. Принимая это решение, руководствовались следующим:
Нас привлекала потенциальная возможность использовать серверный код Почты. «Зачем переписывать бизнес-логику работы с письмами на JS, если она уже написана на сервере?»
Также это возможность распараллелить разработку, пока фронтендеры будут заниматься настройкой Electron, бэкендеры — писать LBL. С точки зрения использования ресурсов, это казалось более правильным путём в full-stack команде, которой я руковожу.
При разработке LBL нас ждали сюрпризы и неожиданности, но к ним мы вернёмся чуть позже.
Проектирование UI-компонента
Итак, мы выбрали Electron ради использования наработок нашей web-версии Почты. Давайте посмотрим в общих чертах, как работает наше (а вообще говоря, практически любое) web-приложение:
Когда пользователь заходит на страницу, отправляется get-запрос, который принимает фронтенд-сервер. Внутри сервера есть SSR-сервис, который отвечает за генерирование HTML для пользователя, у нас это NodeJS. Для того, чтобы сгенерировать уникальный HTML с пользовательскими данными, необходимо получить серверные переменные, которые мы получаем в специальном сервисе Config. Далее возвращаем пользователю HTML, в котором вшиты необходимые пользовательские данные, конфиги и т.д. Там же уже вшиты ссылки на разные статические файлы, необходимые для работы приложения: наши любимые JS-бандлы, CSS, картинки и много чего ещё.
В контексте Почты необходимо упомянуть, что мы используем архитектуру микрофронтендов, то есть большинство кусков приложения подгружается по мере необходимости во время работы приложения. Другими словами, когда пользователь открывает VK WorkMail у него грузится необходимый код для отображения списка писем. Далее, если пользователь хочет написать письмо, создать папку или зайти в адресную книгу, то ему асинхронно подгружаются JS/CSS-бандлы из нашего CDN с необходимыми UI и функциональностью.
Собственно, нашей основной задачей на фронте было засунуть всё это великолепие в Electron. Статичные файлы нужно было положить внутрь приложения, локально, и тогда они начали бы подгружаться как прежде, но уже не из CDN, а из файловой системы. Но вот с генерированием HTML с серверными переменными — вопрос. Electron way — это сделать относительно статичный HTML и вынести логику получения всех необходимых данных на клиентскую часть. Но это означало бы значительный рефакторинг core-части приложения, и для PoC за пару недель это не подходит.
Немногим позже мы поняли, что, по сути, уже запускаем наше приложение локально со всем SSR и прочим, когда стартуем dev-сервер для разработки функциональности:
Разработчик запускает dev-server (у нас это webpack), который перед тем как исполнить серверный шаблон ходит за данными на сервер, а затем результат в виде HTML возвращается в браузер. Мы решили взять эту схему для Electron, но за серверными данными ходить уже в локальный Go-модуль. А всё остальное-то у нас уже есть!
Финальное техническое решение
Итак, объединив всё наше предыдущее проектирование мы пришли к следующей схеме:
У нас есть процесс Electron, он отвечает за запуск приложения, интеграцию с ОС и прочие вещи, важные для настольного приложения. Вместе с этим процессом запускается и NodeJS-сервер (SSR Service) на localhost, и который поднимается внутри Electron. Также во время старта он форкает процесс и запускает Go-сервис, который в свою очередь уже работает с локальной базой (SQL-lite) и синхронихируется с сервером по HTTP. Также на файловой системе у нас лежат разные статичные asset’ы, которые нужны для работы приложения и запросы за которыми будут проходить через NodeJS-сервер, поднятый внутри Electron.
Разработка PoC
Мы не будем подробно разбирать каждую строчку кода, но постараемся кратко подсветить самые интересные и основные моменты в хронологическом порядке.
Разработка основной части Electron
Разработка началась с создания чистого репозитория. Мы завели в нем директорию packages
, в которую планировали положить все необходимые asset’ы для работы приложения: серверные шаблоны, статичные, собранные и минифицированные JS, CSS, изображения и так далее. Далее по README Electron:
npm i electron electron-builder
Затем создаём index.js
и погнали (в примерах может быть не совсем валидный код ради удобочитаемости и подчёркивания главных моментов):
const {app, BrowserWindow, shell} = require('electron');
const config = require('./config');
const {spawn} = require('child_process');
const server = require('./server');
function createWindow() {
const mainWindow = new BrowserWindow({
width: config.defaultWidth,
height: config.defaultHeight,
center: true,
});
// запускаем гошный локальный АПИ
spawn(`./${config.goModulePath}`, ['-config', `${config.goModulePathConfig}`], {stdio: 'inherit', cwd: __dirname})
// запускаем статик-сервер для генерации HTML и раздачи статики
server.run(config.port, () => {
mainWindow.loadURL(`http://localhost:${config.port}/`);
// открываем ссылки (например внутри писем), в дефолтном браузере, а не в электроне
mainWindow.webContents.setWindowOpenHandler(({url}) => {
if (url.startsWith(config.fileProtocol)) {
return {action: 'allow'};
}
shell.openExternal(url);
return {action: 'deny'};
});
});
}
app.whenReady().then(createWindow);
В принципе, этого кода более чем достаточно, чтобы открыть окно на Electron и увидеть белый экран. В дальнейшем появятся нюансы открытия и закрытия окон в MacOS и прочие «доработки», сосредотачиваться на которых в этой статье нет смысла.
Авторизация
Как только мы запустили окно Electron, вскрылась проблема, которую мы не учли при проектировании — авторизация. В web-версии VK WorkMail используется классический вариант с куками, но в настольной версии это не будет работать. У нас есть мобильные приложения, которые работают на сессионных токенах, и мы решили использовать их. Однако есть нюанс: мобильные приложения имеют собственные формы логина. Мы не стали делать новую форму в PoC, а выбрали другое решение, которое оказалось даже более приятным с точки зрения пользовательского опыта: прокинули авторизацию из браузера в наше приложение через OAuth. Схема такая: пользователь нажимает в Electron кнопку авторизации, у него открывается окно браузера, и если он в нём уже был авторизован, пользователь просто нажимает кнопку «Войти» у нужного ящика, и приложение всё остальное делает за него. Получилось примерно вот так:
Вы можете задаться вопросом, как получилось пробросить информацию из браузера в настольное приложение? Основная хитрость тут в том, что redirect_url на который перенаправляет OAuth-провайдер, должен вести на localhost на нужный порт или на специальную схему, которую слушает, в нашем случае, LBL и уже принимает токен авторизации.
Также вам может стать интересно, как LBL сообщает UI, что ему нужно перезагрузить страницу и что авторизация уже есть. Для этого мы при открытии браузера открываем SSE-соединение (Server Side Events) с UI в LBL и ждём события, что авторизация получена. Обратить внимание на SSE-подход, потому что мало кто знает, что существуют альтернативы вебсокетам для получения запросов и событий с сервера. Вот код:
const link = document.querySelector('#auth-link');
link.addEventListener('click', (e) => {
e.preventDefault();
// генерируем случайную строку для OAuth state параметра
const state = (Date.now() * Math.random()).toString(16);
// открываем соединение с Go модулем LBL
const eventSource = new window.EventSource(`http://localhost:${config.goModulePort}/subscribe_oauth?state=${state}`);
// добавляем обработчики для работы с eventSource
eventSource.addEventListener('message', (event) => {
console.log('eventSource: new message', event.data);
if (event.data === 'success') {
eventSource.close();
location.reload();
}
});
eventSource.addEventListener('error', (event) => {
console.log('error', event);
});
eventSource.addEventListener('open', () => {
console.log('eventSource: open connection');
window.open(link.href += state, '_blanc');
});
});
Если вы хотите больше узнать про OAuth, то рекомендую почитать эту статью.
Сбор всех статичных asset’ов (JS, CSS и изображений) в файловую систему
После того, как вопросы с авторизацией и исполнением серверного шаблона были решены, потребовалось перенести все необходимые для работы приложения файлы на локальную файловую систему. Настроить Node Static Server, чтобы отдавать файлы с файловой системы, — это не проблема. Но собирать десятки файлов и класть их локально вручную — не самое интересное занятие. А лень, как известно, двигатель прогресса. Поэтому мы воспользовались концепцией, распространённой в web-приложениях с service worker’ом: загрузкой статичных asset’ов, когда их нет в кеше. Я добавил такой код в свой express-сервер:
app.use('/static', (req, res, next) => {
//проверяем есть ли файл уже в файловой системе
const fileName = `${STATIC_DIR}/${req.path}`;
// синхронно ходить на fs плохо, но этот код вырезается из production сборки
if (!fs.existsSync(fileName)) {
log('file not exists', req.path);
const extname = path.extname(fileName);
proxy.request(
{
url: `https://${config.staticUrl}/${req.path}`,
encoding: binaryMap[extname] ? null : 'utf8', // важно не текстовые форматы правильно кодировать
req,
},
(err, result, body) => {
console.log(result);
fse.outputFileSync(fileName, body, binaryMap[extname] ? 'binary' : 'utf8');
next();
})
} else {
return next();
}
});
app.use('/static', express.static(STATIC_DIR));
За одно открытие VK WorkMail я собрал в файловой системе все необходимые файлы, а в production этот app.use
уже не пошёл.
Адаптация интерфейса под работу в «одной вкладке»
Далее, когда мы наконец смогли авторизоваться и отрисовать наше приложение, важно понимать, что пользовательский опыт использования браузерного приложения сильно отличается от настольного. В браузере совершено нормально в процессе работы открывать новые вкладки, переходить на совершено другой сайт по ссылке из вашего приложения, оставаясь в той же вкладке. Когда же мы переносим Почту в настольное приложение, необходимо адаптировать интерфейс под работу «в одной вкладке». Мне повезло, что Почта уже умеет интегрироваться в суперприложение для рабочих коммуникаций VK Teams с помощью WebView, поэтому я просто использовал режим работы Почты оттуда. Подробнее узнать про эту адаптацию можно из доклада на VK Tech Talks моего коллеги Дениса Романюка.
Разработка LBL-модуля на Go
Для PoC было решено в LBL-модуле поддержать полностью read-режим работы, а также поддержать одно write-действие: локальное изменение флажков у писем и их синхронизацию с сервером при наличии сети. То есть мы не поддерживали все функции сервиса локально, а только полностью поддержали чтение и одно из действий, требующих изменение данных с сервера. Этого достаточно для проверки всех наших гипотез и проведения эксперимента.
В качестве базы для локального хранения писем мы взяли SQLite. В качестве веб-фреймворка — Echo, создали в нём отдельные обработчики для взаимодействия с UI Electron и один общий, который обрабатывает запросы к серверу Почты.
Результат разработки
Мы сделали PoC настольного почтового толстого клиента за две недели: одну неделю фронтендер создавал всю обвязку на Electron и в части с UI, и ещё неделю бэкендер писал модуль на Go.
У нас получилось повторно использовать интерфейс web-версии Почты, при этом с помощью общения с LBL в Go мы смогли в оффлайне предоставить чтение писем и изменение состояния флажков на письмах.
Выводы об R&D
Первоочередной целью для нас было проверить, возможно ли создать настольное приложение на существующей кодовой базе web-приложения. И мы в хорошем смысле поражены, как легко это получилось с помощью Electron. На вопрос: «Использовали бы вы Electron для создания production ready настольного приложения?» мы ответим — да. Эта технология определённо снижает порог входа в разработку настольных приложений.
Однако с написанием LBL-модуля на Go всё оказалось не так хорошо.
Во-первых, идея была в том, что мы сможем использовать какую-то логику из нашей серверной кодовой базы на Go, но, как оказалось, практически ничего применить не получилось, бизнес-логика в толстом клиенте сильно отличается от таковой на сервере.
Во-вторых, отдельный процесс увеличивает риск возникновения потенциальных проблем и багов, которые будет сложно устранить. Что делать, если в Go-модуле произошла паника? Нужно в JS внутри Electron это обрабатывать и перезапускать процесс. Также есть разные нюансы с тем, как операционные системы поступают с процессом приложения при сворачивании или закрытии окна (особенно Mac OS), и вводя дополнительный процесс, которым нужно вручную управлять, мы умножаем сложность работы с этим.
Ну и наконец межпроцессное взаимодействие. Нам нужно общаться между UI, Electron (Node) и LBL. И реализация взаимодействия между Node и Go по любым сетевым протоколам может значительно влиять на производительность.
В итоге, если бы мы делали production ready-решение, то писали бы бизнес-логику и работу с локальной базой данных внутри NodeJS-процесса Electron.
Напоследок хочу сказать пару слов о фронтенд- и web-разработке. Технологии и требования к приложениям сменяются так быстро, что устойчивые и выверенные практики их создания не успевают появляться. Тем не менее я с предвкушением ожидаю будущее фронтенд- и web-разработки.
Web-приложения запускаются как с браузеров на компьютере и телефоне, так и на Smart TV, чайниках, зубных щётках и телескопов. И Electron, безусловно, одна из технологий, которые вносят вклад во всё это.