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

322 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
author: usbharu
draft: true
categories:
- 技術
date: 2025-01-12T14:08:58+09:00
tags:
- Kotlin
- ActivityPub
- Kotlin
- ActivityStreams
- ActivityVocabulary
keywords:
- Kotlin
- ActivityPub
- Kotlin
- ActivityStreams
- ActivityVocabulary
title: ActivityStreamsをKotlinでいい感じに扱いたい(デシリアライズ編)
relpermalink: posts/2025-01-12/
url: posts/2025-01-12/
decription: ActivityStreamsをKotlinでいい感じに扱いたい(デシリアライズ編)
---
ActivityPubで使われてる[ActivityStreams](https://www.w3.org/TR/activitystreams-core/)をKotlinでいい感じに扱いたくて、試行錯誤すること数ヶ月。ようやくPoC的なコードが動いたので記事になりました。
[usbharu/activity-streams-serialization at 55ea9a0c858ce4f9be27bc85088a471a627240c0](https://github.com/usbharu/activity-streams-serialization/tree/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にある例をそのまま持ってきますが、
```json
{
"@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な形に変形すると
```json
[
{
"@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の勧告みると`Object`か`Link`だよ!って書いてあるんですが要は`Note`かもしれないし`Person`かもしれないし`Create`かもしれないし`Link`と`Object`両方かもしれないってことです。なんならActivityStreams意外のJSON-LDの型も含まれてるかもしれません。
KotlinでJSONをシリアライズ・デシリアライズするときって大体data class作ってそこにいい感じにマッピングすると思うんですが、これだとカスタムシリアライザーが大変なことになります([1敗](https://github.com/usbharu/Hideout/blob/1f2feb6668fe9e0d4c22ae0928315a329c9e56ad/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/ObjectDeserializer.kt))。ここまでしてもまだ完全にはデシリアライズできないんので、そもそも仕組みを変える必要がありそうです。
### JSONシリアライザに依存しすぎる問題
たとえ完璧なカスタムシリアライザーが出来上がったとしても、それはとんでもないクソデカコードでJSONシリアライザーの実装にべったり依存してるでしょう。新しく連合したいプロパティができるたびにカスタムシリアライザーをいじらないといけないなんてゴメンです。
### 独自プロパティ生やすのめんどくさすぎ問題
ActivityPub実装に使いたいので、独自のプロパティを簡単に生やせる機能は必須です。いま実際に送りつけあってるJSONみると何やねんこれ見たいなプロパティがいっぱい生えてます。[fediverse/fep: Fediverse Enhancement Proposals - Codeberg.org](https://codeberg.org/fediverse/fep)とか見ると「何お前?」みたいなのありますね。
### HTTP/HTTPS問題
`http://`から始まるURIと`https://`から始まるURIを区別しないでほしいんですが、普通のJSONシリアライザってそこまで器用なこと多分できないのでこれもカスタムシリアライザー作る必要があります。
## 実装
上の問題を全部簡単に解決する方法、それはもう~~TypeScriptを使う~~データの実体はMapに格納し、getter/setterでMapにアクセスするぐらいしか無いでしょう。Mapなら独自プロパティも格納し放題ですし、型のことも考えなくて済みます。これもうJavaScriptじゃね?
アノテーションと様々な黒魔術(リフレクション、動的プロキシ、動的バイトコード生成)を組み合わせて実現することも考えましたが、そのへんはよくわかんなかったので無理でした。そのうち勉強しときます。
```kotlin
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)`で作成します。
```kotlin
inline fun <reified T : JsonLd> JsonLd.asTypeOfNull(type: String): T? {
if (this.type.contains(type).not()) {
return null
}
return this as? T
}
```
前述の通り`type`は配列なのでtypeに変換したい型が含まれていることを確認した後キャストします。
```kotlin
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でやっても行けそうなので今後変えていくかも。
## 現時点での使い方
```kotlin
@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`がうんこなのでなんとかしたい