lexicon設計テクニック

@yamarten.bsky.social

atproto固有のlexicon(API/データスキーマ)策定tipsを思いつくままに書き散らしたやつ。

公式で出ているガイドを先に読むことを推奨する。

2026/02: 初版(2024/10)からのアップデートを色々反映&トピック2つ追加

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

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

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

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

  • refrecordスキーマを参照する
    • 一番素直な方法だが公式lexiconでは実例が無い。unionも同様。
  • refでフィールドのスキーマを参照する
    • recordの一部だけ取ってくる場合に有効。bskyではgetServicesがこれ。
    • フィールドの型をobjectに切り出しておかないとできない。
    • 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だと登録フィード設定とか。
    • 将来的にはprivate dataの一種として統合されると思われるが、時期は未定。
  • appview
    • 専用APIを作ってappview側でデータを保持する。扱いをappviewの自由にできるのが強みであり弱み。
    • 下手にユーザコンテンツをここに置いてしまうと他appviewから参照できず、ロックインを招く。APIの挙動設定程度にとどめておくのが良い。bskyだとミュート設定などがこれ。
    • ただし、公開範囲を自由に決められる強みから、chat lexicon(DM)ではこれが使われている。将来的には別の限定公開の仕組みを作るとされているが、実現は遠そう。
    • BlueskyのCDN(リサイズ画像やストリーミング動画)のように、元データはPDSに置いた上で、appview側で変換したものをメインで使うことはよくあるだろう。この場合、パスがat-uriから一意に決まる構造にしておくと、他appviewでも流用しやすい。
  • プロトコル外
    • 敢えてatprotoの外(XRPCが喋れないサーバー等)に置くという選択肢もある。既存のリソースやプロトコルに対し、発見やメタデータ付与のためにatprotoを使うパターンで特に有用。
    • blobにも置き難いような巨大なデータや、閲覧環境を指定したい場合などに、参照だけrecordに入れることで、利用者によるセルフホストが可能になる。
    • standard.siteが指すatproto外のブログや、Tangledのknot(gitサーバー)はこれに該当する。

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

将来的には、おそらくpreferencesを置き換える形でNon-Public Content(personal-private, shared-private)が予告されている。登場は2026年中と意気込んでいるが、実用にはもう少しかかると見ておいた方が安全か。

逆参照をrecordで持たない

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

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

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

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

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

データの共同所有は無理

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

限定公開コンテンツ(shared-private)が実装された暁には、複数人からの編集も期待できるが、現時点では勘定に入れるべきではない。仮に実装されてもrecordとは区別して扱われる可能性が高いため、実現するなら所有者のクライアントが編集を検出して公開recordに反映といった形になるだろう。

weaverが目指しているように、recordは個々に持って、appview上でのみ共同編集しているように見せるのが穏当と思われる。

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

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

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

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

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

別collectionに分けること自体の利点としては、更新禁止の情報(post)と更新可能な情報(threadgate)を分けられることや、自分の管理下に無いcollectionにもメタデータが付けられること等が考えられる。

型を固定した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は、オブジェクト3であれば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ではオブジェクト様の型(スキーマ)しか参照できないということ。

例えば以下のような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など、重めの型定義を多用する場合に力を発揮する。実際、labelValueknownValue付きのstringとしてトップレベルで定義され、labelerPoliciesから参照されている。

余談だが、refで参照できるdefs直下の定義(record等も含む)を仕様ではnamed definitionsと呼ぶことがある。

unionとunknown

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

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

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

tokenの使い道

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

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

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

lexicon作ったらやっておきたいことリスト

開発中はそこまで気にしなくていい。

なお、schema recordはフィールドがソートされてしまうなど少し面倒もあるので、別途lexiconのjsonを公開しておくといい場合もある。Tangledならlexiconを公開しているアカウントと直接紐付けられるのでGitHubより見つけやすい。

Hopper(利用者いるのか?)用にビューアURLを提供するのも検討してみてほしい。

多言語対応

1つのrecordを多言語化したい場合、以下のように配列化するパターンが使われる。

"localized": {
    "type": "array",
    "items": {
        "type": "object",
        "required": ["lang", "content"],
        "properties": {
            "lang": {
                "type": "string",
                "format": "language"
            },
            "content": {
                "type": "string"
            },
        }
    }
}

recordが膨らむのを避けるために別recordに分けるのも手だが、rkeyで言語を判別するためにはanyにしなければならないのが難。

Footnotes

  1. 独自フィールドを作る際は、NSIDのような衝突しないフィールド名にすることが推奨されている。そのNSIDでlexicon作ればスキーマも明示できてお得。独自フィールドに関するアイディアは$ext仕様拡張フィールド仕様提案も参照。

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

  3. ここでいう「オブジェクト」はlexiconのobject型ではなく、JSONオブジェクトのような辞書・マップを指す。ただし、unionには$typeが必要なことから、lexiconで定義された型であることが期待される。(一方unknown$type不要のためなんでもいい)

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

yamarten.bsky.social
山貂

@yamarten.bsky.social

一般atprotoオタク
投稿中の「#1234」のような数字は原則的にGitHub上の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)