От Web до Desktop за 2 недели: технология Electron на практике

4e84955713a5213945b43cda03c42996.jpg

Если у вас есть компьютер и вы используете его по назначению, то скорее всего вы так или иначе работали с приложениями на Electron (даже если об этом не знали).

Меня зовут Сергей Володин, я руковожу командой разработки VK WorkMail. Расскажу, как на основе Electron мы за две недели создали PoC кроссплатформенного настольного приложения Почты, что узнали о технологии и к каким выводам пришли.

Для начала немного контекста. Наша команда занимается продуктовой разработкой VK WorkMail — корпоративного почтового сервиса, доступного по модели SaaS и в виде On-Premise решения. Продукт существует как мобильное приложение для Android/iOS, в web-версии, а также как один из элементов суперприложения VK Teams. У нас единая кодовая база с Почтой Mail.ru и VK Почтой.

Мы работаем на B2B-рынке и время от времени получаем от наших клиентов запросы на настольное приложение. Это не что-то вроде «Хотим отдельную иконку, а не браузер», а запрос на вполне серьёзную функциональность: возможность работать оффлайн, локальную выгрузку или удаление писем с сервера. Другими словами — запрос на создание Thick Client, или толстого клиента.

Что значит толстый клиент?

Сначала нужно договориться о понятиях. Большинство web-приложений работают по принципу тонкого клиента (Thin Client). Подобный подход представляет из себя классическое клиент-серверное взаимодействие:

fa0ae45c81acedb0d528fa0bfea4e15f.png

Клиент, то есть интерфейс или фронтенд, шлёт запросы на сервер и получает необходимые для работы данные.

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

Как мы пришли к идее завернуть наше web-приложение в Electron?

В компании уже проводили расчет трудозатрат на запуск полноценного сервиса с оффлайн-функциональностью. Мы рассматривали offline-first подход в web-версии клиента с применением IndexedDB в качестве хранилища и Service Worker как средство кеширования статики. Однако мы пришли к выводу, что потребуется проводить значительный рефакторинг всего клиентского кода с потенциальными трудозатратами в человеко-год.

Более того, сделать по-настоящему настольное приложение силами одного лишь фронтенда не получится. Всё дело в ограниченности браузера при работе с локальной файловой системой.

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

Размышляя об этой задаче, мы решили посмотреть на неё с другой стороны: можно ли решить нашу проблему, вообще не трогая логику web-клиента? Ведь мы не меняем интерфейс, пользователь видит все те же кнопки и визуальные компоненты. Функциональность, которая нам требуется, это совсем не про UI, а про локальную, то есть не серверную, бизнес-логику. Для наглядности размышлений, посмотрим на схему толстого клиента. Отмечу, что речь именно про клиент, поэтому серверная часть будет показана в упрощенном виде:

13d9b5af4730d8b1e5fcf82d826d07ff.png

Здесь мы разделяем клиент на две функциональные части:

  • 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-приложение:

4026dd8287e86e6b04d165adfef6ee2a.png

Когда пользователь заходит на страницу, отправляется 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-сервер для разработки функциональности:

d45aef4108d68ab063dc172e4ae78c5f.png

Разработчик запускает dev-server (у нас это webpack), который перед тем как исполнить серверный шаблон ходит за данными на сервер, а затем результат в виде HTML возвращается в браузер. Мы решили взять эту схему для Electron, но за серверными данными ходить уже в локальный Go-модуль. А всё остальное-то у нас уже есть!

Финальное техническое решение

Итак, объединив всё наше предыдущее проектирование мы пришли к следующей схеме:

5b29e51bfa5a4af3101effdfccd0ad77.png

У нас есть процесс 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, безусловно, одна из технологий, которые вносят вклад во всё это.

© Habrahabr.ru