DevGang
Авторизоваться

Переход от 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 и отправляет действие непосредственно основному процессу.

Там ранее зарегистрированный обработчик реагирует на входящее действие и обрабатывает его. Он соответствующим образом обновляет модель предметной области и сохраняет новое состояние в локальном хранилище данных.

Если все идет хорошо, ответное действие отправляется обратно по тому же каналу. Промежуточное ПО получает ответ и отправляет его как обычное действие. Это снова синхронизирует состояние внешнего интерфейса с состоянием домена.

См. следующую диаграмму для обзора этого процесса:

Межпроцессное взаимодействие между рендерером и основным процессом в UMLBoard.
Межпроцессное взаимодействие между рендерером и основным процессом в UMLBoard.

Может показаться немного странным расширять 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, перед отправкой.

См. следующую диаграмму для полного процесса вызова.

Вызов команды Tauri из процесса Webview.
Вызов команды Tauri из процесса Webview.

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 в наши варианты перечисления.

#React #TypeScript #Redux
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу