lexicon設計テクニック

@yamarten.bsky.social

lexicon設計テクニック

atproto固有のAPI策定tipsを思いつくままに書き散らしたやつ。

APIレスポンスへのrecord埋め込み

クライアント主導でサービスを機能拡張する方法として、XRPC APIのレスポンスの一部をrecordから取得する、という方法がある。余分なフィールドが増える分にはlexicon更新は不要だし、単に埋め込むだけならappviewの追加対応も必要無い。1

実例としては、一部のbskyクライアントがviaフィールドでpostにクライアント名を埋め込んでいるのがよく知られている。 また、Blueskyはprofileもrecordを埋め込むべきだったと反省している

lexicon的には3種類の埋め込み方がある。

  • refでrecordスキーマを参照する
    • 一番素直な方法だが公式lexiconでは実例が無い。というか今の公式実装だと対応してないので実用できない。2
    • unionも同様。こちらはrefと違ってrecordを参照できることが明記されているが、実装上は対応してない点は同じ。
  • refでフィールドのスキーマを参照する
    • recordの一部だけ取ってくる場合に有効。bskyではgetServicesがこれ。
    • lexicon上でrecordを使うことが明示されない点には注意。lexiconレベルで要請したいならdescriptionに書いた方がいい。
  • unknownを使う

色々なデータ置き場

利用者個人の情報を保管する場所は4つある。どの情報をどこに置くかはサービス設計で重要になる。

  • repository
    • 標準的な公開データ置場。JSONで表せるユーザコンテンツは基本ここ。bskyならpostとかfollowとか。
    • relay(firehose)に乗るのはここだけ。逆に言うと通常はrelay経由でappviewに届くので、ラグが生じる点は要注意。
    • 他アカウントに対する指示を(全appview共通で)行いたい場合もここ。blockとかthreadgateがこのパターン。
  • blobstore
    • その名の通りバイナリデータ置場。公開情報。bskyだと画像をここに置いている。
    • 基本はrepositoryとセットだが、実体は別の場所に保管されているかもしれないし、relayによって中継されない。
    • アップロード可能なサイズや種類がPDSによって異なるため、環境依存性は高め。
  • preferences API
    • getPreferences/putPreferencesはbsky APIだが、公式実装ではappviewに繋がっておらず、PDSで管理される。
    • appviewや他アカウントからは見えないため、クライアント設定を複数端末で共有する場合などに便利。bskyだと購読フィード設定とか。
    • 将来的にはより使い易い形でbsky lexiconに依存しない形で再設計する展望があるようなので、それを見込んで当面はこのAPIに依存してもよさそう。
  • appview
    • 専用APIを作ってappview側でデータを保持する。扱いをappviewの自由にできるのが強みであり弱み。
    • 下手にユーザコンテンツをここに置いてしまうと他appviewから参照できず、ロックインを招く。APIの挙動設定程度にとどめておくのが良い。bskyだとミュート設定などがこれ。
    • ただし、公開範囲を自由に決められる強みから、chat lexicon(DM)ではこれが使われている。将来的には別の限定公開の仕組みを作るとされているが、実現は遠そう。

あとは当然クライアントローカルという選択肢もあるが、atproto特有の話は無いため割愛。

逆参照をrecordで持たない

例えば、followの逆であるfollower recordは作らないようにすべき。

特に、必ずペアで存在する必要があるような場合は設計を見直した方がいい。repositoryを越えたペアだとそれぞれ異なる主体が管理しているので簡単に壊れるし、何かされたことをクライアントが検知してrecord追加する、というのは無理がある。repository内であってもクライアントの裁量になってしまう。

APライクなフォローリクエストと承認のような形ならありえるが、その場合もリクエストだけ消える/書き換えられる可能性があることには留意すること。

recordはrepository所有者のアクションによってのみ作成・編集されるべき、とも言い換えられる。

それでも逆参照を得たい場合はappviewのAPIを挟むのがセオリーになるが、厳密性を求めると特定のappviewに依存せざるを得なくなったりする。どうしても整合させたい場合は割り切ってrecordに入れるのを諦める(muteのようにappviewに直接突っ込む)のも一案。

データの共同所有は無理

bskyで言えばモデレーションリストあたりは複数アカウントから共同編集したくなるが、atprotoではそれは無理と思った方がいい。データ(record)の所有者をはっきりさせる必要があるし、repositoryの編集権限は細かく分割できないため、特定のrecordのみ編集できる権限委譲などもできない。3

限定公開コンテンツが実装された暁には、その実現手段として「複数アカウントで共同所有するデータ」が登場する可能性はあるが、現時点では勘定に入れるべきではない。仮に実装されてもrecordの形をしているとは限らない。

rkey揃えてメタデータ外付け

同じrepositoryのrecordに外からメタデータを付けたい場合、元record→メタデータrecordの参照をどうするかが問題になる。例えばthreadgateはpostとは別recordになっているが、「このpostのリプライ制限を変更したい」みたいな場合、appviewに頼らずクライアントだけでthreadgateのat-uriを得たい。

元recordにリンク追加しても良いが、管理外のlexiconだったりすると独自フィールドを作ることになり、あまり積極的にやりたくはない。そもそも相互参照はなるべく避けたい。

これの対策として、メタデータのrkeyを元recordと揃えるというルールにして運用で解決する方法がある。threadgateは実際にこの方法を取っている。この場合、at-uriのcollectionを切り替えるだけで相互に変換が可能。

ただし、例えばlistに対するlistitemのように、1:Nの関係ではこの手法は使えない。

型を固定したrecord参照

recordやAPIでrecordを参照したい場合、最もよく使われるのはstrongRefだろう。次点で生のat-uriか。しかし、これらはrecordであればなんでも参照できる。at-uriに至ってはrecordである必要すら無く、アカウントだったりcollectionだったりするかもしれない。

具体例を挙げるなら、例えばrepostする対象がpostではなくfollowだったりblockだったりするかもしれない。これはこれで役立つ場合があって、例えばlikeはカスタムフィードにも使われていたりするが、これを厳密に固定したい場合もあるだろう。

一案として、例えば以下のようにat-uriを分割することで、collectionを固定することができる。ついでにhandleの利用やrkeyが無いat-uriも禁止できてお得。

    "postRef": {
      "type": "object",
      "required": [ "did", "rkey" ],
      "properties": {
        "did": { "type": "string", "format": "did" },
        "collection": { "type": "string", "const": "app.bsky.feed.post" },
        "rkey": { "type": "string", "format": "tid" },
        "cid": { "type": "string", "format": "cid" }
      }
    }

at-identifierの検証方法

at-identifierはatprotoアカウントを指すことが期待されるが、形式的にはDIDまたはhandleの形でさえあればいいため、本当にアカウントかは分からない。無関係のドメインやDIDかもしれないし、PDSを指すDIDかもしれない。

それがrepositoryを持つようなアカウントであることを厳密に指定することはlexiconの範囲ではできないため、動的な検証が必要になる。具体的には、DIDドキュメントを取得して、service#atproto_pdsがあることを確認すればいい。

削除済みのアカウント等や偽DIDも弾きたい場合は、実際に対象PDSのエンドポイント(describeRepoあたり)を叩く必要がある。

open union

unionにはclosedというフィールドがあり、デフォルトではfalseとなっている。これをopen unionと呼んだりする。

open unionは、objectであればrefsに無い型をとってもよい仕様になっている点に注意が必要。これを防ぐためにはclosedをtrueにすればよいが、closed unionは、将来に渡ってrefsが固定されることを意味する。基本はopenなままappviewで追加検証を入れるのがセオリーだろう。

公式lexiconだと、closed unionを使っているのはapplyWritesのみ。

unionとref

unionrefのように参照をとることができるが、unionrefを代替できるわけではない。refに最も近いのは1つだけvariantを持つclosed unionだが、違いが2つある。

1つ目は、unionの値は必ず$typeを持たなければいけないこと。これはまあどうでもいい。重要なのは2つ目、unionではobject様の型(スキーマ)しか参照できないということ。

例えば以下のようなlexiconはrefでしか書けない。

{
  "lexicon": 1,
  "id": "test.defs",
  "defs": {
    "sample": {
      "type": "object",
      "properties": {
        "value": { "type": "ref", "ref": "#limitedstr" }
      }
    },
    "limitedstr": {
      "type": "string",
      "maxLength": 100,
      "default": "placeholder"
    }
  }
}

これだけだとあまり嬉しさが伝わらないかもしれないが、enumを使うstringなど、重めの型定義を多用する場合に力を発揮する。

unionとunknown

unknownrefsを持たないopen unionのようなものだと説明されることがある。ただ、仕様上は全く一緒というわけでもない。

具体的には、union$typeを持つ必要があるが、unknownにはそのような制約は無い。4

とはいえその差が影響する場面は稀と思われる。unknownの使い所としては、lexicon上に定義が無くて適切な$typeを示せないデータも許す場合か、真に値に非依存な場合くらいだろうか。

tokenの使い道

tokenは型名文字列のみを値にもつシングルトンのような型だが、型として使う場面はほぼ無い。

unionに使うことはできないので、そのフィールドは値の有無しか見られない。そもそもconst付きstringで代替できる。

ではどのような場面で使うかといえば、文字列定数に説明を付けたい場合に便利。つまりコメントとしての使い方で、型定義自体はあまり意味が無い。

実際、公式ドキュメントではknownValuesに入れる文字列としてtokenを使っているが、knownValuesの文字列とtokenは仕様上は紐付いていない。単にcom.example.greenというtokenの値と偶然一致する文字列をstateknownValuesで使っているだけ。それでも、おそらく多くの人は型のdescriptionを見に行って理解の助けにするだろうと思うので、意味が無いわけではない。

Footnotes

  1. 個人的には独自フィールド作るなら、多少の使い難さを呑んででもNSID付けるなり拡張っぽい名前にするなりしてほしいところだが。この辺りのアイディアは$ext仕様拡張フィールド仕様提案も参照。

  2. 2024/10/05現在、recordへのrefを使うPRが出ている。マージされるかはさておき、できる方針ではあると見てよさそう。

  3. あるいはADX仕様であれば、UCANで編集権限を絞って委譲するようなこともできたかもしれないが、今からその方向に戻れる可能性は低いだろう。

  4. 以前の仕様ではunknownの指す型はオブジェクトに限定されていなかったため、そこもunionとの差異だったが、実装上はunknownもオブジェクトしか許していなかった。後のPRで実装寄りの仕様に修正されたため、unionとの差分は$typeのみになった。

yamarten.bsky.social
山貂

@yamarten.bsky.social

一般atprotoオタク

atproto関連の資料等はlinkat参照
https://linkat.blue/yamarten.bsky.social

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)