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を使う
- 一番自由度が高いゆえに一番困るやつ。拡張を予期させるのが利点と言えなくもないか。
- bskyだとgetTimeline等がこれで、拡張性だけでなくlexiconのバージョン互換性を意識した結果らしい。
色々なデータ置き場
利用者個人の情報を保管する場所は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
union
はref
のように参照をとることができるが、union
でref
を代替できるわけではない。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
unknown
はrefs
を持たないopen unionのようなものだと説明されることがある。ただ、仕様上は全く一緒というわけでもない。
具体的には、union
は$type
を持つ必要があるが、unknown
にはそのような制約は無い。4
とはいえその差が影響する場面は稀と思われる。unknown
の使い所としては、lexicon上に定義が無くて適切な$type
を示せないデータも許す場合か、真に値に非依存な場合くらいだろうか。
tokenの使い道
token
は型名文字列のみを値にもつシングルトンのような型だが、型として使う場面はほぼ無い。
union
に使うことはできないので、そのフィールドは値の有無しか見られない。そもそもconst
付きstring
で代替できる。
ではどのような場面で使うかといえば、文字列定数に説明を付けたい場合に便利。つまりコメントとしての使い方で、型定義自体はあまり意味が無い。
実際、公式ドキュメントではknownValues
に入れる文字列としてtoken
を使っているが、knownValues
の文字列とtoken
は仕様上は紐付いていない。単にcom.example.green
というtoken
の値と偶然一致する文字列をstate
のknownValues
で使っているだけ。それでも、おそらく多くの人は型のdescriptionを見に行って理解の助けにするだろうと思うので、意味が無いわけではない。
Footnotes
-
個人的には独自フィールド作るなら、多少の使い難さを呑んででもNSID付けるなり拡張っぽい名前にするなりしてほしいところだが。この辺りのアイディアは旧
$ext
仕様や拡張フィールド仕様提案も参照。 ↩ -
2024/10/05現在、recordへのrefを使うPRが出ている。マージされるかはさておき、できる方針ではあると見てよさそう。 ↩
-
以前の仕様では
unknown
の指す型はオブジェクトに限定されていなかったため、そこもunion
との差異だったが、実装上はunknown
もオブジェクトしか許していなかった。後のPRで実装寄りの仕様に修正されたため、union
との差分は$type
のみになった。 ↩