blog/content/posts/2025-01-12/index.md

12 KiB
Raw Blame History

author draft categories date tags keywords title relpermalink url decription
usbharu true
技術
2025-01-12T14:08:58+09:00
Kotlin
ActivityPub
Kotlin
ActivityStreams
ActivityVocabulary
Kotlin
ActivityPub
Kotlin
ActivityStreams
ActivityVocabulary
ActivityStreamsをKotlinでいい感じに扱いたい(デシリアライズ編) posts/2025-01-12/ posts/2025-01-12/ ActivityStreamsをKotlinでいい感じに扱いたい(デシリアライズ編)

ActivityPubで使われてるActivityStreamsをKotlinでいい感じに扱いたくて、試行錯誤すること数ヶ月。ようやくPoC的なコードが動いたので記事になりました。

usbharu/activity-streams-serialization at 55ea9a0c858ce4f9be27bc85088a471a627240c0

ActivityStreamsの簡単な解説

ActivityStreamsは主に2つの仕様を組み合わせて作られています。

JSON-LD

JSONでXMLみたいなことをするやつで、検索するとカスSEO業者のクソ記事しか出てこないので理解に苦労しました。XMLの真似事してるだけあってRDFだかなんだか色々出てきます。雑に言うとJSONに共通の型をつけて、URIみたいなので管理する仕組みです。

Activity Vocabulary

ActivityStreamsのJSON-LDのプロパティとかを色々決めてるやつで、ここに書いてあるやつを実装したら(Activity Streamsとしては)9割ぐらい完成です。残り1割はなんか書いてないけど必要だったりするやつです。

ActivityStreamsの実例

JSON-LD Playgroundにある例をそのまま持ってきますが、

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "@type": "Create",
  "actor": {
    "@type": "Person",
    "@id": "acct:sally@example.org",
    "name": "Sally"
  },
  "object": {
    "@type": "Note",
    "content": "This is a simple note"
  },
  "published": "2015-01-25T12:34:56Z"
}

というJSONがあったとして、これをJSON-LDのExpandedな形に変形すると

[
  {
    "@type": [
      "https://www.w3.org/ns/activitystreams#Create"
    ],
    "https://www.w3.org/ns/activitystreams#actor": [
      {
        "@id": "acct:sally@example.org",
        "@type": [
          "https://www.w3.org/ns/activitystreams#Person"
        ],
        "https://www.w3.org/ns/activitystreams#name": [
          {
            "@value": "Sally"
          }
        ]
      }
    ],
    "https://www.w3.org/ns/activitystreams#object": [
      {
        "@type": [
          "https://www.w3.org/ns/activitystreams#Note"
        ],
        "https://www.w3.org/ns/activitystreams#content": [
          {
            "@value": "This is a simple note"
          }
        ]
      }
    ],
    "https://www.w3.org/ns/activitystreams#published": [
      {
        "@type": "http://www.w3.org/2001/XMLSchema#dateTime",
        "@value": "2015-01-25T12:34:56Z"
      }
    ]
  }
]

こうなります。ActivityPub実装してるときに最初にぶち当たる壁って大体署名で次がJSONの形が自由すぎて意味が分からん!!だと思うんですが(n=1)、JSON-LDでちゃんと拡張してあげると決まった形になります。@typeが配列って時点でほとんどのJSONシリアライザが使えないということがわかると思います。

実装時の苦労ポイント

やっぱりJSONが自由すぎる問題

JSON-LDになってこれで自由なJSON問題は解決とか思ってたら次の壁にぶち当たります。そう、各プロパティに入ってくる型がめっちゃ自由です。例えばObject型のattributedToプロパティ、W3Cの勧告みるとObjectLinkだよ!って書いてあるんですが要はNoteかもしれないしPersonかもしれないしCreateかもしれないしLinkObject両方かもしれないってことです。なんならActivityStreams意外のJSON-LDの型も含まれてるかもしれません。

KotlinでJSONをシリアライズ・デシリアライズするときって大体data class作ってそこにいい感じにマッピングすると思うんですが、これだとカスタムシリアライザーが大変なことになります(1敗)。ここまでしてもまだ完全にはデシリアライズできないんので、そもそも仕組みを変える必要がありそうです。

JSONシリアライザに依存しすぎる問題

たとえ完璧なカスタムシリアライザーが出来上がったとしても、それはとんでもないクソデカコードでJSONシリアライザーの実装にべったり依存してるでしょう。新しく連合したいプロパティができるたびにカスタムシリアライザーをいじらないといけないなんてゴメンです。

独自プロパティ生やすのめんどくさすぎ問題

ActivityPub実装に使いたいので、独自のプロパティを簡単に生やせる機能は必須です。いま実際に送りつけあってるJSONみると何やねんこれ見たいなプロパティがいっぱい生えてます。fediverse/fep: Fediverse Enhancement Proposals - Codeberg.orgとか見ると「何お前?」みたいなのありますね。

HTTP/HTTPS問題

http://から始まるURIとhttps://から始まるURIを区別しないでほしいんですが、普通のJSONシリアライザってそこまで器用なこと多分できないのでこれもカスタムシリアライザー作る必要があります。

実装

上の問題を全部簡単に解決する方法、それはもうTypeScriptを使うデータの実体はMapに格納し、getter/setterでMapにアクセスするぐらいしか無いでしょう。Mapなら独自プロパティも格納し放題ですし、型のことも考えなくて済みます。これもうJavaScriptじゃね?

アノテーションと様々な黒魔術(リフレクション、動的プロキシ、動的バイトコード生成)を組み合わせて実現することも考えましたが、そのへんはよくわかんなかったので無理でした。そのうち勉強しときます。


interface JsonLd {
    var json: JsonNode
    val jsonObject: JsonObject
        get() {
            require(json.isObject)
            return json as JsonObject
        }
    var type: List<String>
        get() {
            return jsonObject[Properties.TYPE]?.asArray()?.mapNotNull { it.asStringLiteralOrNull()?.value }
                ?: return emptyList()
        }
        set(value) {
            return jsonObject.setOrRemove(Properties.TYPE, value.map { JsonString(it) }.toJsonArray())
        }
    var id: URI?
        get() {
            val string = jsonObject.obtain(ID)?.asStringLiteralOrNull() ?: return null
            return URI.create(string.value)
        }
        set(value) {
            jsonObject.setOrRemove(ID, value?.toString()?.let { JsonString(it) })
        }

    fun getAsObjectOrLink(id: String): List<ObjectOrLink> {
        val jsonNode = jsonObject.obtain(id) ?: return emptyList()
        return jsonNode.asArray().map { ObjectFactory.factory(it) as ObjectOrLink }
    }

    fun setAsObjectOrLink(id: String, `object`: List<ObjectOrLink>) {
        jsonObject.setOrRemove(
            id,
            `object`.map { ObjectFactory.toJsonNode(it) }.toJsonArray()
        )
    }
}

これは全てのJSONの基底となるJsonLdクラスで、jsonObjectがMapだと思ってOKです。こんな感じで生えているべきプロパティをinterfaceにgetter/setterとともに定義し、デフォルト実装を書いておきます。利用時は必要なインターフェースを全て実装したクラスを定義して、ObjectFactory.factory(json)で作成します。

inline fun <reified T : JsonLd> JsonLd.asTypeOfNull(type: String): T? {
    if (this.type.contains(type).not()) {
        return null
    }
    return this as? T
}

前述の通りtypeは配列なのでtypeに変換したい型が含まれていることを確認した後キャストします。

abstract class AbstractActivityStream : Activity, JsonLd, Object, ObjectOrLink, CollectionPage, Collection,
    OrderedCollection, OrderedCollectionPage, Image, ImageOrLink, UriOrLink, Link, Note {
}

//このjsonはJsonLdインターフェースのjson
class DefaultActivityStream(override var json: JsonNode) : AbstractActivityStream() {
    
}

object ObjectFactory {
    fun factory(jsonNode: JsonNode): JsonLd {
        return DefaultActivityStream(jsonNode) //ここを独自プロパティを生やしたinterfaceを定義したクラスなどに差し替えることで対応する。
    }

    fun toJsonNode(jsonLd: JsonLd): JsonNode {
        return jsonLd.json
    }
}

このやる気のないObjectFactoryはとりあえず作ってそのまま放置されてるやつで、そのうち直します。

実装の方針としてListはnon-nullable、その他はnullableで定義しました。あとMutableListじゃなくてListで定義してますがMutableListでやっても行けそうなので今後変えていくかも。

現時点での使い方


@Test
    fun name() {

        val jsonElement = Json.parseToJsonElement(
            """
         [
  {
    "https://www.w3.org/ns/activitystreams#content": [
      {
        "@value": "I am fine."
      }
    ],
    "@id": "http://www.test.example/notes/1",
    "https://www.w3.org/ns/activitystreams#replies": [
      {
        "https://www.w3.org/ns/activitystreams#items": [
          {
            "https://www.w3.org/ns/activitystreams#content": [
              {
                "@value": "I am glad to hear it."
              }
            ],
            "https://www.w3.org/ns/activitystreams#inReplyTo": [
              {
                "@id": "http://www.test.example/notes/1"
              }
            ],
            "https://www.w3.org/ns/activitystreams#summary": [
              {
                "@value": "A response to the note"
              }
            ],
            "@type": [
              "https://www.w3.org/ns/activitystreams#Note"
            ]
          }
        ],
        "https://www.w3.org/ns/activitystreams#totalItems": [
          {
            "@type": "http://www.w3.org/2001/XMLSchema#nonNegativeInteger",
            "@value": 1
          }
        ],
        "@type": [
          "https://www.w3.org/ns/activitystreams#Collection"
        ]
      }
    ],
    "https://www.w3.org/ns/activitystreams#summary": [
      {
        "@value": "A simple note"
      }
    ],
    "@type": [
      "https://www.w3.org/ns/activitystreams#Note"
    ]
  }
]
        """.trimIndent()
        )
        val convert = KotlinxSerializationImpl.convert(jsonElement)
        println(convert)

        val factory = ObjectFactory.factory(convert.asArray()[0])
        val note = factory.asTypeOfNull<Note>(Type.NOTE) ?: return
        println(note.content.getAsMap()["default"])
        println(note.summary)
        println(note.id)
        println(note.replies)
        println(note.replies?.totalItems)
        println(note.replies?.items)
        note
            .replies
            ?.items
            .orEmpty()
            .mapNotNull {
                it.asTypeOfNull<Note>(Type.NOTE)
            }
            .map {
                println(it.summary)
                println(it.type)
                println(it.content)
                it.inReplyTo.map {
                    println(it.id)
                }
            }

    }

出力例

println(note.content.getAsMap()["default"])

I am fine.

println(note.summary)

[LangString(language=null, value=A simple note)]

次に向けて

  • デシリアライズがある程度できたのでシリアライズの実験もしたい
  • ObjectFactoryがうんこなのでなんとかしたい