GASHIRA-BLOG

[Tauri] フロントからinvoke呼び出しで、コマンドのkeyを元に型の補完を行う

公開日: 2024年12月30日

最終更新日: 2024年12月30日

はじめに

普段フロントの実装をしているエンジニアがTauriのフロントを実装しています。

Tauriの実装にてフロントからRustの呼び出しを実装した時の対応を実装していたのですが、invokeはfetchのように扱えるので、型の推論ができるようにしました。

まだ課題が残っているので、もう少し詰めますが大まかな処理の流れができたのでその忘備録です。

前提

Tauriは色々な機能をフロント側に提供しています。

ドキュメントを見るにフロント側からSQLの実行もできるようですが、今回はREST Apiのように、RustでSQLiteを使ってデータのCRUDを行い、フロント側ではデータを受け取って描画に専念する形での実装となります。

できるようになったこと

  • 型を定義することで、コマンド(key)を元に引数、戻り値が決まる状態

できてないこと

  • Rustのinvoke定義を元に型の自動生成

課題

  • 環境

  • Tauri 2.0

  • rustc: 1.82.0 (f6e511eec 2024-10-15)

  • node: v18.17.1

目次

  1. Tauriのinvokeについて
  2. TypeScriptで型をつける実装
  3. 実際に使うための課題(と対応)
  4. おわりに

Tauriのinvokeについて

https://v2.tauri.app/reference/javascript/api/namespacecore/#invoke

invokeの型は以下のように定義されていて、ずいぶんシンプルなものになってます。

declare function invoke<T>(
	cmd: string,
	args?: InvokeArgs,
	options?: InvokeOptions
): Promise<T>;

invokeのジェネリクスに渡した型が返り値に設定されるようですね。

今までのREST APIの開発では OpenAPI generatorを使用して型定義をしてあげるとスムーズに型が出力できてスキーマベースで実装できますが、tauriはまだそういったものは無さそうなので自分で型定義して使い勝手を上げたいと思います。

TypeScriptで型をつける実装

今回作るinvokeに対する定義の考え方(ルール)

今回はinvokeをドメインごとにまとめて、その中でコマンドを作るので domain > command の形をルールとして定義しています。

なので、以下のサンプルは Folder ドメインを実装するために定義したものになります。

Folderドメインには create_folder delete_folder update_folder get_memo_list の4つのコマンドを定義する予定です。

(余談ですが、いざ実装したらコマンドがドメインに不足してたりしたので、あくまでサンプルだと思ってください)

画面側でimportするためのファイル

src/util/invoke/index.ts

import { invoke } from "@tauri-apps/api/core";
// Folderドメインの定義
import { FolderKeys, FolderInvokes } from "./Folder";

type InvokeKeys = FolderKeys;

// invoke定義ファイルを作成する時に定義する型
export type InvokeBase<K extends string, Key extends K, T extends object, R> = {
  key: Key;
  props: T;
  return: R;
};

type InvokeTypes = FolderInvokes;

// 実際使用する時の関数
export const customInvoke = async <K extends InvokeKeys>(
  key: K,
  props: Extract<InvokeTypes, { key: K }>["props"],
  options?: { isMock?: boolean }
) => {
  return await invoke<Extract<InvokeTypes, { key: K }>["return"]>(key, props);
};

各invokeの定義ファイル

src/util/invoke/Folder.ts

import { InvokeBase } from "./index";

// コマンドkeyの定数
export const FOLDER_KEYS = {
  GET_FOLDERS: "get_folders",
  CREATE_FOLDER: "create_folder",
  DELETE_FOLDER: "delete_folder",
  UPDATE_FOLDER: "update_folder",
  GET_MEMO_LIST: "get_memo_list",
} as const;

export type FolderKeys = (typeof FOLDER_KEYS)[keyof typeof FOLDER_KEYS];

export type FolderInvokes = CreateFolder | DeleteFolder | UpdateFolder | GetMemoList;

// keyのUnionを定義して、それを使用したinvoke定義を作る
type CreateFolder = InvokeBase<
  FolderKeys,
	FOLDER_KEYS.CREATE_FOLDER,
  {
    name: string;
  },
  null
>;

// ...中略...

type GetMemoList = InvokeBase<
  FolderKeys,
	FOLDER_KEYS.GET_MEMO_LIST,
  {
    folder_id: string;
  },
  {
    id: number;
    title: string;
    content: string;
    folder_id: string;
  }[]
>;

この2つのファイルを実装することで以下の呼び出しができました。

import { customInvoke } from "@/utils/invoke";

customInvoke(
  "get_memo_list",
  { folderId: "1" }
).then((response) => {
  // ここに処理を記載
});

実装した時のポイント

型がブレないようにInvokeBaseをその他ファイルで読み込めるように作成しています。

export type InvokeBase<K extends string, Key extends K, T extends object, R> = {
  key: Key;
  props: T;
  return: R;
};

これで、以下のような型設定の定義ができました。

type CreateFolder = InvokeBase<
  FolderKeys, // string の union型を渡して第二引数で推測できるように & Keyを変更したらエラーになるように設定
  "create_folder",
  {
    name: string;
  },
  null
>;

あとは、実際に使用するときにコマンド名の補完とコマンド名を元にprops,returnの型が保管されるようになりました。

customInvoke(
  "get_memo_list",
  { folderId: "1" }
).then((response) => {
  // ここに処理を記載
});

実際に使うための課題(と対応)

実際に実装を進めていって色々な課題が出てきています。

対応を行なったもの、今後検討するものがあるので簡単に内容を残します。

  • 取得した情報のキャッシュ戦略

    • Tanstack queryを使用して解決
  • モックの取り扱い

    • viteの --mode で mock を指定してモックと切り替え
  • Rustの実装と連動した型情報の出力

    • typeshareを使ってSchemeの出力くらいはできないか検討する

需要がありそうだったらそのうち書くかもしれません。

おわりに

invokeは普段フロントを実装しているエンジニアとしては難しくないので、フロントの実装はWEBを実装する感覚で進めていっても今のところ問題は起きていません。

このままTauriで実装完了まで走り切りたい気持ちです。