Partie 1 : Communication interprocessus – portage d’un système de messagerie basé sur Typescript d’une application Electron vers Tauri et Rust.
TL ; RD : Tauri est une alternative Electron basée sur Rust. Cependant, le portage d’une application Electron existante vers Tauri nécessite du travail. Cet article explique comment le système de messagerie UMLBoard utilisé pour la communication inter-processus pourrait être porté sur Rust sans trop d’effort.
Beaucoup de gens conviendraient que les applications natives – si elles sont bien faites – offrent une meilleure expérience utilisateur et de meilleures performances par rapport aux solutions hybrides ou multiplateformes.
Cependant, avoir des applications distinctes signifie également conserver différentes bases de code, chacune écrite dans son propre langage. Les garder tous synchronisés représente beaucoup de travail pour un seul développeur – généralement plus que ce que nous avons prévu pour nos projets parallèles.
Les Électron framework est un compromis : il fournit un moyen pratique d’écrire des applications indépendantes de la plate-forme à l’aide d’une pile technologique basée sur le Web que la plupart des gens connaissent. De plus, le framework est très mature et activement soutenu par une large communauté.
Mais néanmoins, ce n’est qu’un compromis : son indépendance de la plate-forme s’accompagne du coût de fichiers binaires plus volumineux et d’une consommation de mémoire plus élevée par rapport aux applications natives. Prenez, par exemple, UMLBoard Fichiers binaires macOS : le package de plate-forme universelle se termine par une taille totale de 250 Mo – c’est un nombre énorme pour un poids léger outil de dessin…
Pourtant, les alternatives avec le même niveau de maturité qu’Electron sont relativement rares. Épreuvescependant, est l’une de ces alternatives qui semble très prometteuse.
Bien qu’Electron et Tauri partagent certaines similitudes – comme l’utilisation de processus distincts pour leur noyau et leur logique de rendu – ils suivent des philosophies divergentes concernant la taille du bundle.
Au lieu de déployer votre application avec une interface de navigateur complète, Tauri s’appuie sur les vues Web intégrées fournies par les systèmes d’exploitation sous-jacents, ce qui se traduit par des applications beaucoup plus petites. Malgré cela, Tauri utilise Rouiller comme langage de choix pour son processus Core, ce qui se traduit par de meilleures performances par rapport au backend node.js d’Electron.
Bien que le portage d’UMMLBoard d’Electron vers Rust ne se fasse pas du jour au lendemain, il serait toujours intéressant d’explorer comment certains de ses concepts de base pourraient être traduits de Typescript vers Rust.
La liste suivante contient certaines fonctionnalités cruciales d’UMLBoard. Certains d’entre eux sont de véritables casse-tête au cas où ils ne fonctionneraient pas. Un éventuel port devrait d’abord régler ces problèmes.
- Portage de la communication inter-processus vers Tauri (ce poste ici!).
- Accéder à un magasin de données local basé sur des documents avec Rust.
- Validez la compatibilité SVG des différentes Webviews.
- Vérifiez si Rust dispose d’une bibliothèque pour la mise en page automatique des graphiques.
Le post restant est dédié au premier point : nous étudierons comment la communication inter-processus existante d’UMLBoard pourrait être portée sur Tauri. Les autres sujets pourront faire l’objet d’articles ultérieurs.
Ok, mais assez dit, commençons !
L’implémentation actuelle de UMLBoard utilise un frontal React avec Redux gestion de l’état. Chaque interaction utilisateur distribue une action qu’un réducteur traduit en un changement entraînant un nouvel état frontal.
Si, par exemple, un utilisateur commence à modifier le nom d’un classificateur, un renommerClassifier l’action est expédiée. Les classificateur reducer réagit à cette action et met à jour le nom du classificateur, déclenchant un rendu du composant. Jusqu’à présent, tout cela est le comportement standard de Redux.
Mais UMLBoard
va même plus loin et utilise la même technique pour envoyer des notifications au processus principal d’Electron.
En reprenant notre exemple précédent, lorsque l’utilisateur appuie sur la touche ENTRÉE, un renameClassifier L’action est envoyée, indiquant que l’utilisateur a terminé l’édition.
Cette fois, cependant, l’action est traitée par un middleware personnalisé au lieu d’un réducteur. Le middleware ouvre un canal IPC et envoie l’action directement au processus principal. Là, un gestionnaire précédemment enregistré réagit à l’action entrante et la traite. Il met à jour le modèle de domaine en conséquence et conserve le nouvel état dans le magasin de données local.
Si tout se passe bien, une action de réponse est renvoyée sur le même canal. Le middleware reçoit la réponse et l’envoie comme une action normale. Cela permet de synchroniser à nouveau l’état frontal avec l’état du domaine.
Voir le schéma suivant pour un aperçu de ce processus :
Cela peut sembler un peu étrange d’étendre Redux au processus principal, mais du point de vue d’un développeur paresseux comme moi, cela présente certains avantages :
Étant donné que les deux mécanismes, Redux et IPC, reposent sur des objets JSON sérialisables simples, tout ce qui passe par un répartiteur Redux peut également passer par un canal IPC. Ceci est très pratique car cela signifie que nous pouvons réutiliser nos actions et leurs charges utiles sans écrire de conversions de données supplémentaires ou d’objets DTO. Nous n’avons pas non plus à écrire de logique de répartition personnalisée. Nous n’avons besoin que d’un middleware simple pour connecter le frontal Redux au canal IPC.
Ce système de messagerie basé sur l’action est l’épine dorsale de la communication de processus d’UMLBoard, alors voyons comment nous pouvons y parvenir dans Tauri…
Pour notre preuve de concept, nous allons créer une petite application de démonstration en Tauri. L’application utilisera un frontal React/Redux avec un seul champ de texte. Appuyer sur un bouton enverra les modifications au backend (processus principal de Tauri).
Nous ne nous intéressons qu’à la communication inter-processus, nous allons donc ignorer tout suivi d’état dans le processus principal (cela fera partie d’une future entrée de blog…). C’est pourquoi notre Annuler La méthode se comporte un peu bizarrement car elle restaurera toujours le nom de classe d’origine. Mais pour prouver notre concept, cela devrait être suffisant.
Nous devons essentiellement mettre en œuvre quatre tâches :
- Déclarer les équivalents Rust de nos actions Redux
- Envoi d’actions de Webview au processus principal
- Gestion des actions entrantes dans le processus Core
- Renvoi des actions de réponse à Webview
Passons en revue la mise en œuvre étape par étape.
1. Déclarez les équivalents Rust des actions Redux
Les actions Redux sont des objets JSON simples avec une chaîne identifiant leur taper et un champ contenant leur charge utile.
Rust a un concept similaire que nous pourrions utiliser pour imiter ce comportement, le Énumération taper. Les énumérations dans Rust sont plus puissantes que dans d’autres langages car elles permettent de stocker des données supplémentaires pour chaque variante.
De cette façon, nous pourrions définir nos actions Redux comme un seul Enum, où chaque variante représente un type d’action individuel.
#[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
}
Pour convertir notre action Redux en un Rust Enum et vice versa, nous pouvons utiliser Rust’s serde
macro : nous spécifions que le nom de la variante doit être sérialisé dans un type
champ et ses données dans un champ appelé payload
. Cela correspond précisément au schéma que nous utilisons pour définir nos actions Redux.
Mais on peut même aller plus loin en utilisant le ts-rs Caisse. Cette bibliothèque peut générer les interfaces TypeScript pour les charges utiles d’action directement à partir de notre code Rust. Nous n’avons pas besoin d’écrire une seule ligne de code TypeScript pour cela. C’est vraiment chouette !
Décorer notre structure Rust avec les macros appropriées
#[derive(TS)]
#[ts(export, rename_all="camelCase")]
struct EditNameDto {
new_name: String
}
nous donne l’interface Typescript générée automatiquement suivante pour nos charges utiles d’action :
// This file was generated by [ts-rs](
// Do not edit this file manually.
export interface EditNameDto { newName: string }
2. Envoi d’actions de Webview au processus Core
Ok, nous avons les bons types de données sur les deux bords de notre canal de communication, voyons maintenant comment nous pouvons envoyer des données entre eux.
La communication interprocessus dans Tauri se fait par commandes. Ces commandes sont implémentées en tant que fonctions Rust et peuvent être appelées dans les vues Web à l’aide de la invoquer API.
Un problème auquel nous sommes confrontés est que le Boîte à outils Redux génère le type d’identification d’une action en concaténant le nom de la tranche où l’action est définie avec le nom de l’action. Dans notre cas, le type résultant serait donc classificateur/renommerClassificateur au lieu de simplement renameClassifier
. Cette première partie, classifier
est aussi appelé le domain
auquel appartient cette action.
Malheureusement, cette convention de nommage ne fonctionne pas pour Rust, car cela entraînerait des noms invalides pour nos options Enum. Nous pouvons éviter cela en séparant le domaine du type d’action et en enveloppant le tout dans un objet supplémentaire, le IpcMessage
avant de soumettre.
Voir le diagramme suivant pour le processus d’appel complet.
3. Gestion des actions entrantes dans le processus Core
Du côté du backend, nous devons également définir une structure Rust pour notre IpcMessage
. Comme nous ne connaissons pas encore le type concret de la charge utile, nous la gardons stockée en tant que valeur JSON et l’analysons plus tard si nécessaire.
// data structure to store incoming messages
#[derive(Deserialize, Serialize)]
struct IpcMessage {
domain: String,
action: Value
}
Nous pouvons maintenant définir la signature de la méthode pour notre commande Tauri. Notre fonction, ipc_message
recevra un IpcMessage
le traite d’une manière ou d’une autre, et à la fin, renvoie un autre IpcMessage
comme réponse.
#[tauri::command]
fn ipc_message(message: IpcMessage) -> IpcMessage {
// TODO: implement
}
Ok, mais à quoi ressemblerait la mise en œuvre réelle ?
La fonction doit prendre le domaine du message, voir si un gestionnaire est enregistré pour ce domaine et si oui, appeler le gestionnaire correspondant avec l’action stockée dans notre IpcMessage
.
Étant donné que nous aurons de nombreux domaines et gestionnaires différents plus tard, il est logique de minimiser l’effort de mise en œuvre en extrayant le comportement commun dans un fichier séparé. ActionHandler
trait.
// 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)
}
}
Le trait utilise le TemplateMethod
modèle de conception : Le receive_action
spécifie le workflow général de conversion de l’action. Les handle_action
contient la logique réelle pour le traitement d’une action spécifique.
Dans notre cas, un ClassifierService
pourrait être responsable du traitement de toutes les actions du domaine classifier
:
// 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. Renvoi des actions de réponse à Webview
Nous avons presque terminé. Nous avons la signature de notre commande Tauri et le code dont nous avons besoin pour gérer une action et générer une réponse. Si nous collons tout ensemble, notre final ipc_message
fonction peut ressembler à l’extrait de code suivant :
#[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
}
}
Veuillez noter que la création de service et le code d’enregistrement sont uniquement à des fins de démonstration. Dans une application réelle, nous utiliserions plutôt un état géré pour stocker nos gestionnaires d’action lors du démarrage de l’application.
Nous avons également omis la gestion des erreurs ici pour garder le code simple. Cependant, il existe de nombreux scénarios que nous devrions vérifier, par exemple, ce qui devrait se passer si aucun gestionnaire n’est trouvé, ou comment devrions-nous procéder si l’analyse d’une action dans une énumération se passe mal, etc.
Notre preuve de concept a été un succès ! Bien sûr, certaines parties de l’implémentation peuvent être modifiées, mais le portage de la messagerie IPC d’UMLBoard d’Electron/Typescript vers Tauri/Rust est tout à fait gérable.
Les énumérations de Rust sont un moyen élégant et sûr d’implémenter notre système de messagerie. Nous devons seulement nous assurer que les erreurs de sérialisation potentielles sont gérées lors de la conversion des objets JSON dans nos variantes enum.
Dans le prochain article de cette série, nous essaierons d’utiliser une base de données locale basée sur des documents pour stocker notre modèle de domaine. J’espère que d’ici là, je comprendrai enfin comment fonctionne le vérificateur d’emprunt…
Quelle est votre opinion là-dessus ? Avez-vous déjà travaillé avec Tauri et Rust, et quelles ont été vos expériences ?
S’il vous plaît partagez vos pensées dans les commentaires ou via Twitter @UMLBoard.
Titre Image de Fond d’écran.
Le code source de ce projet est disponible sur GitHub.