🗺

自作のWebアプリをLocal-firstなデスクトップアプリとして作り直した話(Elixir+ElmからRust+Scala.jsへ)

に公開

あらまし

初めまして、@marubinotto と申します。Elixir/Phoenix + Elm で作っていた自作の Web アプリケーションを、二年ぐらいかけて Rust/Tauri + Scala.js によるデスクトップアプリケーションに作り直しました。この記事では、もはや迷走とも呼べなくもない、長くて辛かったこの作り直しについて余すところなく紹介していきたいと思います。

アプリケーションの名前は Cotoami(コトアミ)といいます。一言で説明すれば、チャットやマイクロブログのように気軽に情報を投稿しつつ、それらをレゴブロックのように組み合わせて知識ベースを構築することができるソフトウェアです。情報を登録する障壁を極限まで下げつつ、それでも知識ベースとしては壮大に育てられるようにと考えて作りました。

まずは、その Web アプリケーション時代、旧 Cotoami がどんな感じのアプリケーションだったのか、以下がそのスクリーンショットになります。

これを以下のように作り直しました。

見た目だけでなんとなく同じものだとお分かり頂けると思うのですが、ソフトウェアの内部的な仕組みは全く変わってしまっています。そもそも作り直しに際して再利用したコードが一行もありません。数年かけて作ったソフトウェアを、別の構成でゼロから再構築するのはかなりの苦行だと途中で気がつきましたが、その時にはもう後に引けない状態になっていました。

以下は、旧 Cotoami の構成(技術スタック)です。

Elixir/Phoenix をバックエンドに、そして Elm をフロントエンドに利用した、Web アプリケーションとしては標準的な構成です。データベースとして RDBMS(PostgreSQL)だけなく、グラフデータベース(Neo4j)を利用しているのが少し特殊な部分かもしれません。

この構成で運用し続けるのは、主に以下の理由によって年々しんどくなっていました。

  • 不特定多数が所有するデータをサーバーで一括管理していること。
    • Web サービスに共通する問題ですが、個人での運用には限界がありますし、使う側も当然不安になります。
  • 依存システムが多く、個人ユーザーが自分専用の環境を構築するハードルが高い。
    • データベースとして、RDBMS (PostgreSQL) だけでなく、グラフデータベース (Neo4j) も運用する必要あり。
    • そもそも認証システムが個人利用を想定してない。
  • 依存ライブラリやフレームワーク、ランタイムのバージョンアップに追随し続ける必要があること。
    • 特に Heroku のような PaaS で運用していると、関連ソフトウェアのバージョンがすぐにサポート外になってしまう。
    • OS をアップグレードすると、使える Elixir のバージョンが変わってしまう。

そこで、できるだけ長く使えるようにするために、以下のように作り直して諸々の問題を解決しようと目論見ました。

新バージョンの主な特徴は以下のような感じです。

  • Tauri を利用したクロスプラットフォーム対応のデスクトップアプリケーション。
  • データベースはアプリケーションに埋め込んだ SQLite に一本化。
  • フロントエンドは、Elm っぽいフレームワークを Scala.js で自作。
  • スタンドアローンでの利用がデフォルトでありつつ、データベース同士をつなげて複数人での協業も可能。

この作り直しで、結果的にうまくいったところもあれば、失敗かなと思える部分もありました。新たに発生した悩ましい問題もあります。以下ではそれらの問題について一つ一つ振り返ってみたいと思います。

分散グラフデータベース

Web アプリケーションからデスクトップアプリケーションに作り直そうと思い立ったのは、より長く使ってもらうために、自分で作ったデータは自分で管理できるようにしたいと考えたのがきっかけでした。古き良きスタンドアローンアプリケーションであれば、データは自分のパソコンの中に保存されますし、アプリケーションの開発が終了してしまっても、プログラムのコピーを持っている限り使い続けることができます。

さらに、保存するデータの形式もより汎用的で長い寿命が見込めるものにしたいと考え、PostgreSQL + Neo4j と分散していた永続化ストレージを SQLite 一本に集約することにしました。SQLite のデータベースファイルは sqlite3 コマンドがあればその中身を気軽に見ることができますし、広範に普及しているので閲覧・編集に使えるアプリケーションは山のようにあります。さらには、SQLite はアメリカ議会図書館が推薦する永続化のフォーマットだという話もあります。

これで自分のデータは自分のもの、やったーというわけなのですが、一つ困ることがありました。それは他のユーザーと協業したい場合にどうするかという問題です。旧バージョンは Web アプリケーションなので、ユーザー同士のやり取りについて頭を悩ませる必要がそもそもありませんでした。さてどうしよう? そこで考えたのが「自分のデータは自分のもの(データの所有権)」と「コラボレーション」を両立させるための Database Networking という仕組みです。基本的に新 Cotoami は、個人向けのスタンドアローンアプリでオフラインでも使えますよと紹介しているのですが、実装に一番時間がかかっているのは実はこの Database Networking 機能なのです。現段階ではほとんど利用されてなさそうなので、せめてその解説だけも書いて成仏させよう、それがこの文書を書いている少なからぬ動機になっています。

Cotoami のデータベースは、投稿の単位である Coto(コト)と、それらを結びつける Ito(イト)による、グラフ構造のデータを収納しています。

Database Networking 機能を利用すると、このグラフの中に、リモートにある別のデータベースのグラフを、その一部として埋め込むことが可能になります。これを Cotoami ではデータベースの親子関係と呼んでいます(親が取り込まれる側・子が取り込む側)。取り込まれた親側のデータベースがさらに別の親を取り込むといった連鎖構造を作ることもできるので、複数のデータベースを組み合わせて、一つの大きな知識グラフを作ることができます。以下が Database Networking の仕組みを図式化したものです。

上の図にあるように、ネットワーク接続されたデータベースを Cotoami では「ノード(Node)」と呼んでいます。ノードは相互に WebSocket(WebSocket が利用できない環境では Server-Sent Events)で接続されます。Database Networking の一つの特徴として、データベースの親子関係と、ネットワーク上のクライアント・サーバー関係(client/server)を切り離している点が挙げられます。通常はサーバー側が親データベースになるケースがほとんどですが、その逆も実現できるようになっていて(サーバー側をバックアップにしたい場合など)、柔軟なネットワーク構成を実現できます。

背後では複数のデータベースにまたがるグラフ構造をシームレスに扱えるというのが Database Networking 機能の醍醐味です。この記事の最初でレゴブロックのように知識を組み立てると書きましたが、リモートデータベースの内容もローカルと同じように、自分のための文書を作るためのブロックとして利用できます。例えば、異なる親から取り込んだ文書のパーツを組み合わせて、自分だけが参照する新たな文書を作ることができます。

取り込まれた親グラフは子データベースの中に保存されて、その内容はネットワークが接続されている限りリアルタイムに同期されます。このリアルタイム同期によって、同じ親データベースを取り込んでいるユーザー同士ではチャットやグラフの編集を通してリアルタイムのコラボレーションが可能になるわけです。

親グラフに対する注釈

この仕組みが面白いのは、複数のユーザー間で共通のグラフ構造を持つことが可能になる一方で、各データベースのグラフ構造はそれぞれ必ず違うものになることです。つまり、各データベースはそれぞれ固有のローカルなグラフ構造を持ち、それは親側からは見えません。このことが可能にする機能の一例として、リモートデータベースに対する注釈機能があります。

上の図は、接続されている各データベースが、親側のグラフにローカルな Coto を繋げている様子です。例えば、組織で利用する場合を想定すると分かりやすいかもしれません。上図の A は組織全体のデータベース、B は各部署のデータベース、C は各個人のデータベースとして見ることができます。この場合、個人(C)が投稿できるのは直接の親である部署のデータベース(B)までで、組織全体のデータベース(A)に投稿することはできません。しかし、A や B のグラフを閲覧することはできますし、そのグラフに対する注釈を B あるいは C に投稿することはできます。B に投稿した場合は部署内の共有情報として、C に投稿した場合は個人的なメモになります。これが親子の階層構造を利用した注釈機能です。

データベース同期の仕組み

親子間でのデータの同期については、いわゆる State Machine Replication の手法を参考に実装しました。各データベースに変更履歴(Changelog)を持たせて、取り込む側はその履歴を正しい順番で実行することで、親グラフを自身の中に再現するという方法です。

親側から流れてくる変更履歴には、さらにその先の間接的につながっているデータベースのものが含まれている可能性があり、かつ同じデータベースと複数の経路で繋がっている場合は、同じ変更を複数回受け取る可能性があります。同じ変更を重複して実行しないよう、変更履歴には発生源となったデータベースの ID とそのデータベース内でのシリアルナンバーを持たせています。

今回の作り直しの中で、設計に失敗したかなと思う箇所はいくつもあるのですが、その中で最もダメージが大きかったものの一つが、この変更履歴に関する設計判断でした。アプリケーションをリリースして利用され始めた後では変更が困難な部分で、これまでのバージョンアップで何度か前方互換性を犠牲にせざるを得ない局面に遭遇しています。失敗の原因としては、State Machine Replication では状態機械が「決定的(deterministic)」でなければならない、つまり変更履歴を適用する際の処理に副作用があってはならないという原則があるのにそれを徹底できなかったこと(ノード間でデータのズレが発生してしまう)、変更履歴の永続化フォーマットになんとなく MessagePack を選んだ結果、データ構造の変更に脆くなってしまったことが挙げられます。

振り返って考えると、State Machine Replication への入力を「操作」とか「イベント」だと捉えてしまうと、この失敗が起こりやすいのではという気がしています。そうではなく、副作用も含めた「変更の内容」にフォーカスした方が良かったのかもしれません。MessagePack については単純に知識不足による安直な選択で、後方互換性を保ったままデータ構造を変更できる、Protocol Buffers などを選択すべきだったなと思います。

Local-first software

このふりかえり記事を書くために情報を整理していたとき、Local-first software という記事を偶然みつけて、自分のやりたかったことはこれじゃないかと膝を打ちました。あらゆるアプリケーションがクラウドから提供されるようになって、他の人との協業が容易になったり、異なるデバイスでシームレスに利用できるなどのメリットがある一方で、ユーザーが作ったデータの生殺与奪の権利はサービス事業者に握られてしまうというデメリットがありました。そこで、クラウドアプリケーションのメリットを維持したまま、データの所有権やコントロールをユーザー側に取り戻そうというのが Local-first の考え方で、以下の7つの理想が掲げられています。

  1. ユーザーが直接触れるデータは常にローカルにあるので遅延のない UI が期待できる。
  2. 異なるデバイスからシームレスに利用できること。
  3. オフラインでも利用できること(Offline First)。
  4. リアルタイムでスムーズな協業が可能であること。
  5. 作成したデータが末長く利用できること。
  6. セキュリティとプライバシーが確保されていること。
  7. データの所有権がユーザー側にあること。

今回作り直した Cotoami について、一つ一つ検討してみると、

  1. 閲覧に関してはローカルデータへのアクセスで統一できているが、リモートノードの更新に関してはサーバーに接続する必要あり(Local-first では共有データもオフラインで編集できることが理想)。
  2. 現状ではモバイルデバイスに対応していない(対応したい)。
  3. 1 と同じ理由で、リモートノードの更新はオフライン時に実施できない。その他は問題なし。
  4. 可能。リモートノードのイベントが WebSocket でリアルタイムに伝わる。
  5. ユーザーデータは SQLite のデータベースファイルとしてローカルに保存される。
  6. クラウドアプリで懸念されることは、基本的にスタンドアローンアプリでは問題にならないという話であるが、データを共有する段になると、エンドツーエンドの暗号化などを導入しない限りは、結局クラウドと同じ問題を抱えることになる。Cotoami の場合、Database Networking を利用した場合のセキュリティとプライバシーに関しては以前のバージョンと変わらず同じ問題を抱えていると言えるが、あくまで共有データだけの問題でユーザー個人のデータはローカルに閉じている。共有データについても不特定多数をターゲットにしていないのである程度は運用でカバーできるかもしれない。
  7. ユーザーデータはユーザーのデバイスに保存される。

すべての理想を実現するのはなかなかハードルが高いですが、欠けている部分で最も重要だと思われるのが、リモートノードに所属するデータの更新です。現状だとリモートノードに接続されていない限り、そのノードに対する投稿や更新は行えません(閲覧は可能)。このローカルからリモートへの同期というのが、Local-first software を実現する上で、最も技術的に難しい部分だと件の記事では説明されています。

データベース = ユーザー?

新バージョンの Cotoami はスタンドアローンでの利用を出発点として考えているので、旧バージョンに存在したいわゆる認証主体としての「ユーザー」という概念がありません。しかし、リモートノードに投稿する場合、「誰が」という情報がないとコミュニケーションが成り立ちません。他のユーザーと協業する場合はユーザー的なものがどうしても必要になりますが、Database Networking ではネットワークに参加する各ノード(データベース)が認証かつ行為の主体になります。

実際にノード同士を接続する場合は以下のような手順を踏みます。リモートにあるノードを親として接続したい場合、まず親ノードの所有者に接続元ノードの ID (UUID) を知らせて子ノードとして登録してもらい、その際に発行されたパスワードを受け取ります。接続元のノードは、親ノードの URL と受け取ったパスワードを使って接続を行います。

子ノードの所有者が親ノードに対して投稿した Coto の著者欄には、子ノード(データベース)の名前とアイコンが表示されます。

Cotoami では必ずローカルデータベースを経由して共有データにアクセスするので、これが自然な形だとは思うのですが、一方で Local-first の目標の一つである「2. 複数の異なるデバイスから利用できること」の実現に関しては若干ややこしいことになります。異なるデバイスからでも同じノードとしてアクセスさせるためには、複数のデバイス間でローカルデータベースを同期する何らかの仕組みを用意する必要があり、これが Local-first software 最大の難題です。同期処理の難しさもさることながら、エンドツーエンド暗号化などを駆使したとしても個人的なデータをネット上に送信するという気持ち悪さは払拭できないかもしれません。そのコストを払うぐらいなら、スタンドアローンの独立性を保つために認証主体のモビリティを犠牲にすることもやむなし、というのが現 Cotoami の妥協でした。

この妥協の結果、Cotoami ではデバイスが変わるとユーザー(ノード)が変わってしまうわけですが、その問題を部分的に解決するのが、次に紹介する Switch Node 機能です。

Switch Node という変態機能

ネットワークに参加しているデータベースを「ノード」と呼ぶ、という話はすでにしましたが、このノードには二つの形態があります。一つはデスクトップアプリケーションに内蔵されているノード、もう一つは UI を持たないサーバーとして稼働するノード(Cotoami Node Server)です。この後者のノードサーバーを、余計なコンセプトを導入せずに操作できるようにするにはどうしたらよいかと頭を捻って考え出したのが Switch Node という機能です。

二つのノードの間に親子のコネクションを作るとき、親側は子ノードに対する権限を設定することができます。子から親への投稿はデフォルトで許可されていますが、それ加えて Ito の編集(グラフの編集)を許可するか、Cotonoma(コトノマ: チャットルームみたいなものだと思って下さい)の作成を許可するかなどの細かい権限の他に、Owner権限という、子ノードにあらゆるオペレーションを許可する特権も存在します。この「Owner権限」を持つ子ノードが、親に対して可能なオペレーションの一つが Switch Node です。

デスクトップアプリケーションで開いているデータベースがリモートの親ノードに接続していて、かつその親に対するOwner権限を持っている場合、操作ノードを切り替えて、あたかもその親ノードがローカルノードになったかのように操作することができます。これを Switch Node と呼んでいます。以下の動画は、"Earth" というローカルノードから、操作ノードを "Sun" というリモートにあるノードに切り替える様子です。

先ほどの「デバイスが変わるとノードが変わってしまう問題」も、この機能を利用すれば解決できることがお分かり頂けると思います。専用のリモートノードを用意する必要がありますが、デバイスが変わっても同じノードに成り変わって共有データにアクセスすれば、同じ行為主体としてオペレーションを継続することができます。例えて言えばプロキシサーバーみたいなイメージです。

Tauri

今回、Web アプリをデスクトップアプリに作り替えるということで、フレームワークの選択肢としては Electron あるいは Tauri を想定していました。Database Networking など、バックエンドの処理が複雑になりそうだったので、Rust でバックエンドが書けることが魅力的に映ったこと、比較的新しく、軽快に動作し、コンパクトな配布ファイルを作れるということで Tauri を選択しましたが、一つのアプリケーションを作って配布するところまで経験すると、これはこれで辛いことが少なからずあるなという感想を持ちました。

WebView のつらみ

Electron と Tauri はいずれも Web フロントエンドの技術を流用してマルチプラットフォーム対応のデスクトップアプリケーションを作るためのフレームワークですが、レンダリングエンジンの選択に大きな違いがあります。Electron は Chromium という、マルチプラットフォームに対応したレンダリングエンジンをアプリケーションに同梱する方法を選択している一方で、Tauri は最近の OS に標準でインストールされている WebView というエンジンにレンダリングを「外注」する形式を取っています。

Electron と比較して、Tauri はレンダリングエンジンを同梱する必要がないので、配布ファイルが小さくなる上に、メモリーの消費量が Chromium と比較して小さくて済むというメリットがあります。ただしその反面、各 OS に用意されている WebView の差異に気を配らなければならなくなります。これは実質、異なるブラウザの差異に気を使わなければならない Web アプリケーションと同じ条件をデスクトップアプリの世界にも持ち込んだ形です。ある程度の標準的な仕様や共通の振る舞いは想定されていますし、時間と共に解決される問題も多いと楽観視できる部分もありますが、後述する Linux 環境での非互換性問題など、近い将来に解決されるとは思えない問題もあります。Chromium だけに気を配ればよい Electron と違って、このデメリットは想像以上に大きく感じられました。しかも、Cotoami では後述するように、世界地図の同梱という暴挙を振るっているので、Chromium 程度の配布サイズを気にしても仕方がないという事実もあったりします。

Linux 上での非互換性問題

この問題は Tauri の問題というよりも、Linux で WebView を利用したアプリケーションを作る場合に共通する悩ましい問題です。Linux でマルチプラットフォームを実現するのはほとんど無理なのではと思わされるぐらいに状況が入り組んでいます。

まずは、glibc の非互換性問題があります。Tauri v1 の公式ドキュメントには、Ubuntu 上で Linux 向けのビルドを作成する場合、リンクした glibc のバージョンが新しすぎると古いディストリビューションで動かなくなるので、できるだけ古いバージョンの Ubuntu でビルドした方がよいとのアドバイスがあります。しかし、アドバイスに従って古い Ubuntu でビルドすると、後述のさらに悩ましい WebKit2GTK の互換性問題によって、今度は新しい Ubuntu 上での動作に不具合が発生してしまうのです。

Linux 上の Tauri アプリケーションは、WebKit2GTK をレンダリングエンジンとして利用しています。そして最近の WebKit2GTK にはバージョン 4.0 と 4.1 の二つの系統があり、いずれかのバージョンでビルドしたアプリケーションは、もう片方の環境で動作させることができません。例えば、ここ最近の Ubuntu の状況を見てみると、

  • Ubuntu 20.04 - WebKit2GTK-4.0
  • Ubuntu 22.04 - WebKit2GTK-4.0, WebKit2GTK-4.1
  • Ubuntu 24.04 - WebKit2GTK-4.1

となっており、20.04 でビルドしたアプリケーションは 24.04 で動かず、その逆も同様という感じになっています。WebKit2GTK をバンドルした AppImage で配布すれば、環境側のバージョンに左右されないのではという話もありますが、20.04 でビルドしたアプリは AppImage で配布しても 24.04 上では正常に動作しないという報告もあり、この不具合は Cotoami の配布パッケージでも確認しています。

Tauri v1 は WebKit2GTK-4.0 のみをサポートするので、Ubuntu 24.04 のような 4.1 系の環境では基本的に動作せず、Tauri v2 では 4.0系を切り捨てて4.1系に移行しており、Ubuntu 24.04 より古いバージョンやその他多くの 4.0 系ディストリビューションで動作するパッケージを作るのが難しくなってしまいました。AppImage でも解決しない問題があったり、一方で Flatpak を使えばすべて解決するという話も見かけますが、実際に実現している例がどこにあるのか分からないといった感じで情報が錯綜していて、より多くのディストリビューションをサポートするための決定的な方法を見つけるのは容易ではない(少なくとも現段階で Tauri 公式では紹介されてない)という状況です。

Tauri アプリケーション開発者の、おそらくは多くの方々がこの問題に頭を悩ませていて、GitHub の Issues を覗くとその苦労を垣間見ることができます。問題のややこしさを実感したい人はぜひ覗いてみて下さい。

Servo という夢

上述の WebView に関する悩ましい問題を一挙に解決する最終兵器として期待されているのが、Servo という Rust 製の Web ブラウザエンジンです。資金不足で開発が停滞していた Mozilla の新世代ブラウザエンジンですが、2023 年に外部からの資金援助を得て再び開発が活発化、同時に Tauri に埋め込むための WebView 化のプロジェクトも発表されています。

実際に実用レベルまで持っていくのは並大抵ではなさそうですが、実現すれば Electron における Chromium と同等のものを獲得することになり、Tauri のメリットを残しつつ弱点を一掃するゲームチェンジャーになり得るプロジェクトだと思います。その時を夢見て、定期的に WPT Pass Rates を覗きに行く日々です(グラフをみる限り先は長そう)。

配布パッケージの署名と公証

Linux ほど非互換性の悪夢はないにしても、Windows や macOS では配布パッケージに署名と公証が要求されるという、別の悩ましい問題があります。個人の開発者で趣味的に小さく始めたいと考える人にとって、これらのプラットフォーム向けにアプリケーションを作って配布するのはなかなかハードルが高い時代になっています。筆者は普段 macOS を利用しているので、macOS 版についてはアップルにお布施して署名と公証を行っていますが、Windows に関してはそれ以上のコストと手間がかかるという話もあるようなので、今のところは署名なしで配布しています。利用者として Gatekeeper などの存在はもちろん知っていましたが、今回の作り直しで初めて悩ましい問題として意識するようになりました。署名と公証が必要な理由はもちろん理解できますが、Local-first の理想にあるような、プログラムを配布することで末長く利用してもらう前提はすでに成り立たなくなっているのだと実感しています。

Scala.js

Web フロントエンドの技術選定は悩ましい問題の一つです。選択肢が多く変化も速いこの分野で、新しいフレームワークにひたすら振り回されるのは避けたいという気持ちもあります。旧 Cotoami で利用していた Elm言語は純粋関数型プログラミングをフロントエンドの世界に持ち込んだ先駆者で、個人的なお気に入りだったのですが、もうかれこれ五年以上も新しいバージョンのリリースがありません

そんな中、仕事で Scala.js を使う機会があり、これで Elm と同じようなことが出来るのではないかと思って試作したのが、以下の Scalafui です。新 Cotoami の UI はこの Scalafui を使って作りました。

marubinotto/scalafui: Scalafui is an experimental implementation of the Elm Architecture in Scala.js

以下は、Scalafui アプリケーションの最も単純なコード例です。

object Main {

  //
  // MODEL
  //

  case class Model(messages: Seq[String] = Seq.empty, input: String = "")

  def init(url: URL): (Model, Cmd[Msg]) = (Model(), Cmd.none)

  //
  // UPDATE
  //

  sealed trait Msg
  case class Input(input: String) extends Msg
  case object Send extends Msg

  def update(msg: Msg, model: Model): (Model, Cmd[Msg]) =
    msg match {
      case Input(input) =>
        (
          model.modify(_.input).setTo(input),
          Cmd.none
        )

      case Send =>
        (
          model
            .modify(_.messages).using(_ :+ model.input)
            .modify(_.input).setTo(""),
          Cmd.none
        )
    }

  //
  // VIEW
  //

  def view(model: Model, dispatch: Msg => Unit): ReactElement =
    div(
      h1("Welcome to Scalafui!"),
      form(className := "message-input")(
        input(
          value := model.input,
          onInput := (e => dispatch(Input(e.target.value)))
        ),
        button(
          `type` := "submit",
          onClick := (e => {
            e.preventDefault()
            dispatch(Send)
          })
        )("Send")
      ),
      div(className := "messages")(
        model.messages.map(div(className := "message")(_))
      )
    )

  def main(args: Array[String]): Unit = {
    if (LinkingInfo.developmentMode) {
      hot.initialize()
    }

    Browser.runProgram(
      dom.document.getElementById("app"),
      Program(init, view, update)
    )
  }
}

フレームワークというのも大げさな単純な仕組みですが、ほぼ Elm と同じ感じでアプリケーションを組むことができます。しかも嬉しいことに、Elm では難しかったランタイム部分のカスタマイズがやり放題な上(自作なので当たり前ですが)、Scala 特有のコンパクトな記法で、Elm だと冗長になりがちな記述を短くスッキリ書くこともできます。Virtual DOM ライブラリには React を利用しているので、巷にある大量のコンポーネントを利用できるのも大きなメリットです。

Scalafui はすべて合わせても1000行に満たない単純な仕組みです。最低限必要なのは Scala.js 用の React ライブラリだけなので、Virtual DOM が廃れない限りは末長く使い続けることが出来るはずです。そして何より、開発者が仕組みを完全に把握できるというのは長い目で見るとアドバンテージになると思います。

世界地図

今回の作り直しで最も迷走の度合いが強い機能が、この世界地図の同梱です。Cotoami には Coto(コト)という情報の単位がありますが、これに位置情報を持たせて、地図上で位置関係を把握できるようにしたいというのがこの機能の出発点でした。

Cotoami には旧バージョンから、Coto 同士を結びつける Ito(イト)という仕組みがあります。この Ito による意味のつながりと、新バージョンで新たに導入した地図による位置関係によるつながり、この2種類のつながりを組み合わせることによって、より立体的な知識ベースを作れるのではないかと考えました。

さらに、Cotoami にとって重要なコンセプトに「発見」があります。その発見を支援するために、旧バージョンから「Knowledge Growth Cycle」という仕組みを提案しているのですが、これはあくまでも知識の材料となる情報がすでにあることを前提にしたものです。この材料をどうやって集めるかという問題を考えた時に、個人的な体験から「場所の移動(つまり旅)」が効果的なのではないかと考えるようになりました。

Knowledge Growth Cycle は、個人やチームの文脈上重要だと思われるトピックを浮き立たせて、そこから派生的に知識ベースを構築していくという手法ですが、これを場所という概念に応用すると、場所の移動が新たな場所の発見につながるような面白い仕組みが作れるのでないか、実はこの作り直しで本当にやりたかったことは、Database Networking とか Local-first などではなく、この旅の仕組みを作ることだったのです。

これまで経験のなかった地図機能を導入しようと意気込んだわけですが、通常、地図を利用するアプリケーションは、地図データをサーバーに置いてオンラインでアクセスさせるのが一般的だと思います。しかし、新 Cotoami ではスタンドアローンかつオフライン利用をサポートしたいと考えていたので、だったら地図を同梱するしかないと相成りました。しかしみなさん、Googleマップにあるような世界地図のデータってどのぐらいのサイズになるかご存知でしょうか?

例えば、OpenStreetMap の地図データを提供する OpenMapTiles のサイトを見てみると、比較的サイズを抑えられるベクトルタイルデータでも、全世界の地図データのサイズが 95.0 GiB となっています。こんなサイズのデータをアプリケーションに同梱して配布できるわけがないので、ここからなんとか配布できそうなサイズまで落とす方法を探す必要がありました。

調べてみると、地図データを小さくするためには大きく分けて以下の3つの方法があるようです。

  1. 含めるエリアを限定する。
  2. 高いズームレベルを削除する。
  3. 必要のないレイヤーを削除する。
    • 「レイヤー」は地図を構成する情報を種類や目的ごとに分けたもの(道路、建物、水域など)

デフォルトで同梱する地図のエリアを限定する訳にはいかないので、まずは最も効果的だと思われる 2 の「ズームレベルの削除」を試しました。ちなみに Cotoami では、Protomaps Basemaps という、PMTiles 形式の世界地図データを出力するプログラムをカスタマイズして同梱用の地図を生成しています。

以下のように、世界全体の地図でもズームレベルを落とすと一気にサイズが小さくなるのが分かります。

  1. デフォルト(ズームレベル 0 - 15)→ 約 120 GB
  2. ズームレベル 9 まで → 1.36 GB
  3. ズームレベル 8 まで → 478.8 MB
  4. ズームレベル 7 まで → 164.6 MB
  5. ズームレベル 6 まで → 40.9 MB

アプリケーションに同梱するサイズとしては、4, 5 あたりが良さそうですが、このレベルになると川が表示されなくなってしまうのが辛いところです。せめて主要な道路と川は残したいと考えて、3 の出力をあれこれカスタマイズした結果が現状の Cotoami に同梱されている地図データ(471.7 MB)です。こんなサイズのファイルを同梱しているので、配布パッケージのサイズを小さくできるという Tauri のメリットはほぼ意味のないものとなっています。

今となっては、なんでここまで「同梱」にこだわってしまったんだろうと思いますが、今後の改善点として、地図機能はオプショナルにして、いろんなバリエーションの地図を別途ダウンロードできるようにした方が良いのではと考えているところです。

WebAssembly によるプラグイン機構

これは当初の予定になかった機能ですが、Extism という Rust 製のフレームワークを利用して、WebAssembly によるプラグインの仕組みを実験的に導入しています。これが思いの外 Cotoami の仕組みと相性が良かったのは嬉しい誤算でした。ここでは現在提供している2つのプラグインについて簡単に紹介したいと思います。

ChatGPT Plugin

Cotoami は基本のインタフェースがチャット的なので、ChatGPT のような AI Chatbot と相性が良いです。#chatgpt と書いた Coto を投稿すると、ChatGPT プラグインがそこにつなげる形で返信をポストしてくれます。この返信も Coto なので、他の Coto と同様に知識グラフを作るための部品として利用することができます。

Cotoami ならではの機能として、既存の知識グラフにつなげる形で #chatgpt の投稿をすると、ChatGPT プラグインは有向グラフを遡って得られるすべての投稿を文脈として把握します。Cotoami では知識グラフを自由に編集できるので、ChatGPT に渡す文脈を自由に編集して準備することが可能になります。最近 LLM 界隈で提唱されている Context Engineering の一つの手段と言えるかもしれません。

以下がこの仕組みを図式化したのものです。

さらにこのプラグインは会話に参加しているメンバーの名前とその発言の対応を把握しているので、以下のように発言者に関係する質問にも答えることができます。例えば、複数人で議論した後に特定のメンバーの発言を要約させたり、誰の考えがもっとも合理的かを判断させたりといった用途が想定できます。

Geocoder Plugin

Geocoder Plugin は、Coto の中に #location で始まる行があると、その内容を場所の名前として Nominatim API に問い合わせを行い、得られた位置情報をその Coto に設定します。

おわりに

以上が、個人的な作り直しプロジェクトのふりかえりでした。しかし、この無駄に長い記事をここまで読んで頂けるとは... 本当に有り難いことです。もし気になっている方がいたらなのですが、この作り直しの成果は以下にあります。何らかの形で感想などをお寄せいただけると開発者にとっては望外の喜びです。特に Database Networking 機能を試してみたという方がおられましたら、ぜひフィードバックをお寄せ下さい。現実のユースケースにどこまで対応できるのかとても気になっています。

cotoami/cotoami-remake: Connect ideas and places in a knowledge graph. Designed for both private use and real-time collaboration through networked databases.

Discussion