Переход от Electron к Tauri
Межпроцессное взаимодействие — портирование системы сообщений приложения Electron на основе Typescript на Tauri и Rust.
TL;DR: Tauri — альтернатива Electron на основе Rust.
Однако перенос существующего приложения Electron на Tauri требует некоторой работы. В этом посте объясняется, как система сообщений UMLBoard, используемая для межпроцессного взаимодействия, может быть без особых усилий перенесена на Rust.
Многие люди согласятся с тем, что нативные приложения — если все сделано правильно — обеспечивают лучший пользовательский интерфейс и производительность по сравнению с гибридными или кроссплатформенными решениями.
Однако наличие отдельных приложений также означает поддержку разных баз кода, каждая из которых написана на своем языке.
Поддержание их всех в синхронизации — довольно большая работа для одного разработчика — обычно больше, чем мы планировали для наших побочных проектов.
Фреймворк Electron — это компромисс: он предоставляет удобный способ написания независимых от платформы приложений с использованием стека веб-технологий, с которым знакомо большинство людей. Кроме того, фреймворк очень зрелый и активно поддерживается большим сообществом.
Но тем не менее, это лишь компромисс:
- Его независимость от платформы связана с затратами на большие двоичные файлы и более высоким потреблением памяти по сравнению с нативными приложениями.
Возьмем, к примеру, бинарные файлы UMLBoard для macOS:
- Пакет универсальной платформы имеет общий размер 250 МБ — это огромное число для легкого инструмента для рисования...
Тем не менее, альтернативы с таким же уровнем зрелости, как у Electron, относительно редки. Однако Tauri — одна из этих альтернатив, которая выглядит очень многообещающе.
Первый взгляд на Tauri
Хотя Electron и Tauri имеют некоторые сходства — например, использование отдельных процессов для своего ядра и логики рендеринга — они следуют разным философиям в отношении размера пакета.
Вместо того, чтобы развертывать ваше приложение с полным интерфейсом браузера, Tauri полагается на встроенные веб-представления, предоставляемые базовыми операционными системами, что приводит к гораздо меньшим приложениям. Несмотря на это, Tauri использует Rust в качестве предпочтительного языка для своего основного процесса, что обеспечивает более высокую производительность по сравнению с серверной частью Electron node.js.
Хотя портирование UMLBoard с Electron на Rust не произойдет в одночасье, изучение того, как некоторые из его основных концепций могут быть переведены с TypeScript на Rust, все равно было бы интересно.
Следующий список содержит некоторые важные функции UMLBoard. Некоторые из них являются настоящими шоу-стопорами в случае, если они не работают.
Возможный порт должен был бы решить эти проблемы в первую очередь.
- [x] Перенос межпроцессного взаимодействия на Tauri
- [ ] Доступ к локальному хранилищу данных на основе документов с помощью Rust
- [ ] Проверка совместимости SVG различных веб-представлений
- [ ] Проверить, есть ли в Rust библиотека для автоматической компоновки графов
Оставшийся пост посвящен первому пункту:
Мы исследуем, как существующее межпроцессное взаимодействие UMLBoard может быть перенесено в Tauri. Другие темы могут быть предметом дальнейших статей.
Отправка сообщений между процессами Electron
Текущая реализация UMLBoard использует внешний интерфейс React с управлением состоянием Redux.
Каждое взаимодействие с пользователем отправляет действие, которое редьюсер переводит в изменение, приводящее к новому состоянию внешнего интерфейса.
Если, например, пользователь начинает редактировать имя классификатора, отправляется действие переименования классификатора. Редуктор классификатора реагирует на это действие и обновляет имя классификатора, вызывая повторную визуализацию компонента.
Пока это все стандартное поведение Redux.
Но UMLBoard делает еще один шаг вперед и использует ту же технику для отправки уведомлений основному процессу Electron.
Возьмем наш предыдущий пример, когда пользователь нажимает клавишу ENTER, отправляется действие renameClassifier, указывающее, что пользователь закончил редактирование.
Однако на этот раз действие обрабатывается пользовательским промежуточным программным обеспечением, а не редьюсером. Промежуточное ПО открывает канал IPC и отправляет действие непосредственно основному процессу.
Там ранее зарегистрированный обработчик реагирует на входящее действие и обрабатывает его. Он соответствующим образом обновляет модель предметной области и сохраняет новое состояние в локальном хранилище данных.
Если все идет хорошо, ответное действие отправляется обратно по тому же каналу. Промежуточное ПО получает ответ и отправляет его как обычное действие. Это снова синхронизирует состояние внешнего интерфейса с состоянием домена.
См. следующую диаграмму для обзора этого процесса:
Может показаться немного странным расширять Redux до основного процесса, но с точки зрения ленивого разработчика, у этого есть некоторые преимущества:
Поскольку оба механизма, Redux и IPC, полагаются на простые сериализуемые объекты JSON, все, что проходит через диспетчер Redux, также может проходить через канал IPC.
Это очень удобно, поскольку означает, что мы можем повторно использовать наши действия и их полезные данные без написания дополнительных преобразований данных или объектов DTO.
Нам также не нужно писать какую-либо пользовательскую логику диспетчеризации. Нам нужно только простое промежуточное ПО для подключения внешнего интерфейса Redux к каналу IPC.
Эта основанная на действиях система обмена сообщениями является основой процесса коммуникации UMLBoard, поэтому давайте посмотрим, как мы можем добиться этого в Tauri.
Портирование на Tauri
Для проверки концепции мы создадим небольшое демонстрационное приложение в Tauri. Приложение будет использовать интерфейс React/Redux с одним текстовым полем. Нажатие кнопки отправит изменения на серверную часть (процесс Tauri Core).
Нас интересует только взаимодействие между процессами, поэтому мы пропустим все отслеживание состояния в основном процессе. Вот почему наш метод Cancel ведет себя немного странно, так как всегда восстанавливает исходное имя класса. Но для доказательства нашей концепции этого должно быть достаточно.
В основном нам предстоит реализовать четыре задачи:
- Объявить Rust-эквиваленты наших действий Redux
- Отправка действий из Webview в основной процесс
- Обработка входящих действий в основном процессе
- Отправка ответных действий обратно в Webview
Пройдемся по реализации шаг за шагом.
1. Объявите Rust-эквиваленты действий Redux
Действия Redux — это простые объекты JSON со строкой, идентифицирующей их тип, и полем, содержащим их полезную нагрузку.
В Rust есть аналогичная концепция, которую мы могли бы использовать для имитации этого поведения, — тип Enum. Перечисления в Rust более мощные, чем в других языках, потому что они позволяют хранить дополнительные данные для каждого варианта.
Таким образом, мы могли бы определить наши действия Redux как одно перечисление, где каждый вариант представляет отдельный тип действия.
#[derive(Serialize, Deserialize, Display)]
#[serde(rename_all(serialize="camelCase", deserialize="camelCase"))]
#[serde(tag = "type", content = "payload")]
enum ClassiferAction {
// received from webview when user changed name
RenameClassifier(EditNameDto),
// user canceled name change operation
CancelClassifierRename,
// response action after successful change
ClassifierRenamed(EditNameDto),
// response action for cancel operation
ClassifierRenameCanceled(EditNameDto),
// error response
ClassifierRenameError
}
Чтобы преобразовать наше действие Redux в Rust Enum и наоборот, мы можем использовать макрос Rust serde
: мы указываем, что имя варианта должно быть сериализовано в поле типа, а его данные — в поле, называемое полезной нагрузкой. Это точно соответствует схеме, которую мы используем для определения наших действий Redux.
Но мы можем пойти еще дальше, используя ящик ts-rs. Эта библиотека может генерировать интерфейсы TypeScript для полезной нагрузки действий прямо из нашего кода на Rust. Для этого нам не нужно писать ни строчки кода на TypeScript.
Украшение нашей структуры Rust соответствующими макросами
#[derive(TS)]
#[ts(export, rename_all="camelCase")]
struct EditNameDto {
new_name: String
}
дает нам следующий автоматически сгенерированный интерфейс TypeScript для наших полезных данных действий:
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface EditNameDto { newName: string }
Хорошо, у нас есть правильные типы данных на обоих концах нашего канала связи, давайте теперь посмотрим, как мы можем отправлять данные между ними.
2. Отправка действий из Webview в основной процесс
Межпроцессное взаимодействие в Tauri осуществляется с помощью команд. Эти команды реализованы как функции Rust и могут быть вызваны в Webviews с помощью invoke API.
Одна проблема, с которой мы сталкиваемся, заключается в том, что Redux Toolkit генерирует тип для идентификации действия путем объединения имени слайса, где действие определено, с именем действия.
Таким образом, в нашем случае результирующий тип будет classifier/renameClassifier, а не просто renameClassifier. Эта первая часть, классификатор, также называется доменом, к которому принадлежит это действие.
К сожалению, это соглашение об именах не работает для Rust, так как это приведет к недопустимым именам для наших параметров Enum.
Мы можем избежать этого, отделив домен от типа действия и заключив все в дополнительный объект, IpcMessage, перед отправкой.
См. следующую диаграмму для полного процесса вызова.
3. Обработка входящих действий в основном процессе
На стороне сервера мы также должны определить структуру Rust для нашего IpcMessage. Поскольку мы еще не знаем конкретный тип полезной нагрузки, мы храним его в виде значения JSON и анализируем позже, когда это необходимо.
// data structure to store incoming messages
#[derive(Deserialize, Serialize)]
struct IpcMessage {
domain: String,
action: Value
}
Теперь мы можем определить сигнатуру метода для нашей команды Tauri. Наша функция ipc_message получит сообщение IpcMessage, каким-то образом обработает его и в конце вернет в качестве ответа другое сообщение IpcMessage.
#[tauri::command]
fn ipc_message(message: IpcMessage) -> IpcMessage {
// TODO: implement
}
Хорошо, но как будет выглядеть реальная реализация?
Функция должна взять домен из сообщения, посмотреть, зарегистрирован ли обработчик для этого домена, и если да, то вызвать соответствующий обработчик с действием, хранящимся внутри нашего IpcMessage. Поскольку позже у нас будет много разных доменов и обработчиков, имеет смысл свести к минимуму усилия по реализации, выделив общее поведение в отдельный трейт ActionHandler.
// trait that must be implemented by every domain service
pub trait ActionHandler {
// specifies the domain actions this trait can handle
type TAction: DeserializeOwned + Serialize + std::fmt::Display;
// the domain for which this handler is responsible
fn domain(&self) -> &str;
// must be implemented by derived structs
fn handle_action(&self, action: Self::TAction) ->
Result<Self::TAction, serde_json::Error>;
// boiler plate code for converting actions to and from json
fn receive_action(&self, json_action: Value) ->
Result<Value, serde_json::Error> {
// convert json to action
let incoming: Self::TAction = serde_json::from_value(json_action)?;
// call action specific handler
let response = self.handle_action(incoming)?;
// convert response to json
let response_json = serde_json::to_value(response)?;
Ok(response_json)
}
}
Черта использует шаблон проектирования TemplateMethod: receive_action указывает общий рабочий процесс для преобразования действия. Метод handle_action содержит фактическую логику для обработки определенного действия.
В нашем случае ClassifierService может отвечать за обработку всех действий классификатора домена:
// ClassifierService handles all classifier specific actions
struct ClassifierService {}
impl ClassifierService {
pub fn update_classifier_name(&self, new_name: &str) -> () {
/* TODO: implement domain logic here */
}
}
impl ActionHandler for ClassifierService {
type TActionType = ClassifierAction;
fn domain(&self) -> &str { CLASSIFIER_DOMAIN}
fn handle_action(&self, action: Self::TActionType) ->
Result<Self::TActionType, serde_json::Error> {
// here happens the domain logic
let response = match action {
ClassifierAction::RenameClassifier(data) => {
// update data store
self.update_classifier_name(&data.new_name);
ClassifierAction::ClassifierRenamed(data)
},
ClassifierAction::CancelClassifierRename =>
// user has canceled, return previous name
// here we just return an example text
ClassifierAction::ClassifierRenameCanceled(
EditNameDto { new_name: "Old Classname".to_string() }
)
, // if front end sends different actions, something went wrong
_ => ClassifierAction::ClassifierRenameError
};
Ok(response)
}
}
4. Отправка ответных действий обратно в Webview
Мы почти закончили. У нас есть подпись нашей команды Tauri и код, необходимый для обработки действия и генерации ответа. Если мы соединим все вместе, наша окончательная функция ipc_message может выглядеть следующим образом:
#[tauri::command]
fn ipc_message(message: IpcMessage) -> IpcMessage {
// This code is just for demonstration purposes.
// In a real scenario, this would be done during application startup.
let service = ClassifierService{};
let mut handlers = HashMap::new();
handlers.insert(service.domain(), &service);
// this is were our actual command begins
let message_handler = handlers.get(&*message.domain).unwrap();
let response = message_handler.receive_action(message.action).unwrap();
IpcMessage {
domain: message_handler.domain().toString(),
action: response
}
}
Обратите внимание, что код создания службы и регистрационный код предназначены только для демонстрационных целей. В реальном приложении мы вместо этого использовали бы управляемое состояние для хранения наших обработчиков действий во время запуска приложения.
Мы также опустили здесь обработку ошибок, чтобы сделать код простым. Тем не менее, есть довольно много сценариев, которые мы должны проверить, например, что должно произойти, если обработчик не найден, или как мы должны действовать, если синтаксический анализ действия в перечисление идет неправильно и т. д.
Заключение
Наша проверка концепции прошла успешно! Конечно, некоторые части реализации могут быть изменены, но портирование обмена сообщениями IPC UMLBoard с Electron/TypeScript на Tauri/Rust определенно выполнимо.
Перечисления Rust — это элегантный и типобезопасный способ реализации нашей системы сообщений. Нам нужно только убедиться, что потенциальные ошибки сериализации обрабатываются при преобразовании объектов JSON в наши варианты перечисления.