Deno KVは分散型SNS Nostrの夢を見るか?
TL; DR
Deno KV が思ってたよりもずっと凄そうな感じだったので、自分が最近ハマっている分散型SNS Nostrのバックエンドでいい感じに使えないか妄想してみました。
Deno KVが面白い
Node.jsの生みの親であるライアン・ダール氏が、Node.jsにおける反省を活かして新たに生み出したJavaScript実行環境、Deno。
近年は、npm モジュールの直接インポートのサポートなどによる相互運用性の向上を受け、にわかに利用が広がっている印象があります。
Deno KVは、そんなDenoに標準機能として組み込まれているキーバリューストアです。
最初にその名前を見かけた際は、そのそっけない印象から、ブラウザのLocalStorageやRedisといった、単純な文字列をキーとして値を保存できるくらいのものなのかな?という先入観を持っていました。
しかし、ある日ひょんなことからマニュアルに目を通してみたところ、想像していたよりもずっと高機能で驚かされました。
特に、以下の特徴に目を惹かれました。
-
階層的キー: Deno KVのキーは単純な文字列ではなく、文字列・数値・バイナリ・ブール値からなる配列である。キーの完全一致による値の取得(
get
)に加え、キーのprefixが一致する値をまとめて取得する操作(list
)が用意されている。
これを応用すると、以下のようなことが可能となる。-
擬似的な複数「テーブル」の実現: 例えば、ブログの記事とコメントの情報を保存したいとする。このとき記事データのキーを
["article", <記事ID>]
、コメントデータのキーを["comment", <コメントID>]
としておく。すると、すべての記事データに共通のキーprefixである["article"]
を指定してまとめてデータを取得することで、すべての記事一覧を取得できる。コメントについても同様にできる。 -
セカンダリインデックス: 先ほどの例で、ある記事に紐づくすべてのコメント一覧を効率よく取得できるようにするには、各コメントデータに対して記事IDを基準とするセカンダリインデックスを用意することになる。これは、
["article-comment", <記事ID>, <コメントID>]
というキーでコメントデータを冗長的に保存することで実現できる。ある記事のコメント一覧は、["article-comment", <記事ID>]
というキーprefixを用いて取得すればいい。
-
擬似的な複数「テーブル」の実現: 例えば、ブログの記事とコメントの情報を保存したいとする。このとき記事データのキーを
-
楽観ロックによるアトミックなデータ更新: Deno KVではデフォルトで各キーの値のバージョンが記録される。「データ更新のコミット時に更新対象のキーのバージョンが変化していないかをチェックし、バージョンの変化があれば最初からやり直す」という、楽観ロックに基づくトランザクション処理のためのAPIが標準で用意されている。単一のデータ
-
ゼロコンフィグで使える: これだけの機能を持つデータベースを特別な設定なしで利用できる
ここに挙げた以外の特徴については以下の記事によくまとまっていますので、ぜひ目を通してみてください。
さて、Deno KVの豊富な機能を知った筆者はこう考えました。「これだけの機能があれば、Nostrのリレーサーバも実装できるんじゃないだろうか…?」
分散型SNS Nostrとは
Nostrは、分散型SNSのためのプロトコルのひとつであり、その上に成り立つSNSを指す言葉でもあります。
NIPs(Nostr Implementation Possibilities)が定める仕様に則っていさえいれば、その上で何でも好きなものを作れるフリーダムさに惹かれたエンジニアたちが、日々いろいろなモノを生み出す場となっています。
Nostrの全体感について詳しく知りたい場合は以下の記事をご参照いただくとして、ここからはNostrプロトコルの構成要素について簡単に説明していきます。
Nostrプロトコルの基本は、「クライアントとリレーサーバがイベントをやりとりする」。それだけです。
イベントとは、NostrというSNS上で起きるすべての出来事、あるいは実体・モノ(具体的には、投稿やリアクションなど)を表す、以下の構造を持つデータです。
{
"id": <イベント内容の SHA-256 ハッシュ値(16進数文字列)>,
"pubkey": <イベント発行者の公開鍵(16進数文字列)>,
"kind": <イベントが表す対象を示す値(整数)>,
"content": <イベントのメインの内容(文字列)>,
"tags": [
[<タグ名>, <タグの値>, <タグの追加データ>...],
...
],
"created_at": <イベント発行時刻(unixtime・秒単位)>,
"sig": <イベント発行者の秘密鍵による、イベント内容に対するデジタル署名(16進数文字列)>,
}
クライアントはリレーサーバからタイムラインなどの表示に必要となるイベントを取得することになるのですが、このときフィルタと呼ばれる取得したいイベントの条件を指定するクエリを用います。
このフィルタによって指定できる条件は、おおよそ以下のようなものです。
-
ids
: イベントのid
-
kinds
: イベントのkindの値。言い換えると、イベントの種類の指定(例: 投稿、リアクション、etc.) -
authors
: イベント発行者。イベントのpubkey
にマッチする -
since
,until
: イベントの発行時刻(created_at
)の範囲の指定 - 以上の条件の組み合わせ(AND条件)
なお、このリレーサーバは一般的なWebアプリケーションにおけるサーバとは異なり、アプリケーションロジックを担いません。この意味で、リレーサーバはアプリケーションサーバというよりも、単なる「Nostrイベントのデータベース」と呼ぶべきものです。
フィルタの表現力がSQLのそれと比較して非常に限られていることと、イベントをデータベースのレコードとみなしたとき基本的にレコードの内容の上書きが発生しないこと[1]から、リレーサーバの実装には必ずしも高度なRDBMSを必要としない、ということがいえます。
実際、今日よく利用されているNostrリレーサーバ実装のひとつであるstrfryは、シンプルなキーバリューストアであるLMDBをベースに実装されています。ただし、LMDB上でセカンダリインデックスを実現するためのレイヤーを自前で作り込むなどの苦労が垣間見えます。
さて、ここまでくれば、先ほどの「(Deno KVに)これだけの機能があれば、Nostrのリレーサーバも実装できるんじゃないだろうか…?」という言葉の意味もわかってきたのではないでしょうか?
Deno KVベースのNostrリレーの設計
先ほども名前を挙げたKVSベースのリレーサーバ strfry の設計を借りると、設計方針は以下のようになります。
- 新規イベントの挿入時に、イベントの
id
,pubkey
(発行者),kind
およびpubkey
とkind
の組をキーとするインデックスを用意する- 各インデックスのキーには、イベントの
created_at
(発行時刻)を付加しておく(この理由については後述)
- 各インデックスのキーには、イベントの
- フィルタにマッチするイベントの探索は、以下の2段階で行う
- フィルタに含まれる条件のうち、最もイベントの探索空間が小さくなるのが期待できるインデックスを選択してイベントを絞り込む
- 1.で絞り込まれた各イベントを順に見ていき、改めてフィルタの条件に完全にマッチするかどうか判定する
インデックスのキーにcreated_at
を付加するのは、これによって「リレーサーバは原則としてイベントをcreated_at
の新しい順に返却しなければならない」という要件を上手く満たすことができるためです。
このようにキーを工夫することにより、イベント探索手順の1.でイベントを絞り込むと自動的にイベントがcreated_at
の順に並ぶので、あとは順番にイベントを舐めていってフィルタにマッチ次第それを返すだけで上記の要件を達成できる、という寸法です。一旦候補のイベントをすべて取得してからソートする必要がなくなるのです。
さて、Deno KVでは、値を保存する際に構造化複製アルゴリズム(v8.serialize()
)を適用することで、任意のJavaScriptの値をそのまま保存できるしくみになっています。しかし、このシリアライズ処理はさまざまな構造の値を扱うために内部処理が複雑であることが予想され、パフォーマンス上の懸念があります。また、シリアライズ後のデータサイズはJSON文字列化と同等とされており、最適とはいえません。
NostrリレーにはNostrイベントという単一のデータ構造のみを保存できれば十分であることを鑑みると、flatbuffersのような事前定義されたスキーマに基づくバイナリシリアライズ形式を活用することで、より高いパフォーマンスとデータ効率を実現できる可能性があります[2]。
おわりに
Deno KV も Nostr も、この先が非常に楽しみな技術だと思っています。両者のこれからのさらなる発展を祈って結びの言葉とします。
Discussion