[StrawberryFields]10+4種類のWebフレームワークでActivityPub実装 感想戦(JVM)

2023/09/11に公開

はじめに

https://zenn.dev/tkithrta/articles/78b203b30f689f

数年前に10種類のWebアプリケーションフレームワークでActivityPub実装をした際、コンテナの都合でJavaの実装を中止していたのですが、いい感じにWebアプリケーションフレームワークの知見もたまってきて、今年は多めの夏休みを取得する機会に恵まれたので挑戦することにしました。

それがこちらです。

https://gitlab.com/acefed/strawberryfields-spark
https://gitlab.com/acefed/strawberryfields-spring
https://gitlab.com/acefed/strawberryfields-ktor
https://gitlab.com/acefed/strawberryfields-play

途中夏休み前半戦が終わり仕事していたり、夏バテで土日に作業できなかったりしたのもありますが、なんと1ヶ月かかってしまいました!
かなり万全を期して挑戦したにもかかわらず、小さなうまくいかないことが重なりまくってこうなってしまったので、感想戦ということで振り返りをしていきたいと思います。

感想戦

すべてGitpodを使い開発しました。
MastodonとMisskeyもGitpodで建ててテストしています。

https://gitpod.new/

  • まずはJVM言語であるJava, Kotlin, Scalaに関する資料を用意しておく
    • とほほさんは神
      • ただJavaの記事は古かったので別の資料を用意した
    • ひしだまさんは神
      • Javaの使い方とあわせて、Scalaやビルドツールの使い方を補完できた
  • あわせてSparkjava, Spring Boot, Ktor, Play Frameworkの資料も用意しておく
    • 日本語の公式マニュアルがなく、もし日本語の公式マニュアルがあったとしても和訳が古いので英語の公式マニュアルを参考にする
  • HTTPリクエストクライアントのライブラリも必要になるので用意しておく
    • Java11以降であればJVMに標準でついてくるっぽいので優先的に使う
    • Sparkjava実装ではOkHttp3, Ktorではフレームワークについてくるものを使う
    • 一応Spring BootやPlay FrameworkにもフレームワークにHTTPリクエストクライアントがついてくるらしいけど難しそうなので今回はなしで
  • 暗号化ライブラリも用意しておく
    • JVMに標準でついてくる標準ライブラリでも何とかなりそうだったけど、Bouncy Castle公式マニュアルを用意(結局使わなかった)
  • dotenv用ライブラリも用意しておく
  • こう色々調べていくとjavadocがAPI調べる上で必須であることがわかったので用意しておく
  • コマンドのヘルプページを確認しておく
    • sdk(sdkman!), java, kotlin, scala
    • mvn, gradle, sbt
    • spring(インストールしたけど使わなかった。Spring Initializrを使った)

Spark Java 8

プロジェクトの作成にSpring Initializrを使いました。

https://start.spring.io/

  • Java 8とmavenでも動く実装を作る苦行
    • ありとあらゆる点で冗長なコードになる
    • とにかくクラス名 変数名 = new クラス名();を書く
  • 今までの常識が通用しない
    • セミコロン忘れてエラー
    • Stringをstringと書いてしまいエラー
    • Stringとは? Integer型とは? int型との違いとは?
      • どうやら値型と参照型の違い
      • Stringは参照型。TypeScriptやGoは値型
        • C言語でも似たような感じだったので理解はできるが、慣れない
        • 基本型、スカラーと呼ばれる言語もある
      • Objectも参照型。anyみたいなもの
        • プリミティブ型と呼ばれる言語もある
  • リストとマップを思ったように書けない
    • パッケージが必要でArrayListとHashMapをインスタンス化
    • ダイヤモンド演算子を使って型を省略
    • あり得ない量のメソッドを書く
  • Sparkjava自体は非常に簡単。慣れ親しんだreq, res引数のおかげ
    • ラムダ式のおかげでもある。JavaScriptのアロー関数みたいな
    • 後述するWebアプリケーションフレームワークのように設定ファイルやアノテーションが不要なので、プロパティとメソッドに集中できる
  • JSONを返すためにはライブラリが必要
    • 急遽JVM言語ごとにライブラリの選定
    • SparkjavaではGsonを使う。Ktorでも使う(後述)
    • Spring BootとPlay Frameworkでは内部でJacksonを使っているので使う
    • これもクラス名を理解してインスタンス作ってメソッドでうまいことやる
    • JSON返せてWebFingerやActivityPubのActorを取得できるようになった
  • コツがつかめてきた
    • List<String>とMap<String, String>とMap<String, Object>を組み合わせてJSONを作れるようにする
    • 困ったらjavadocのAPIドキュメント読んで、似たような感じのあたりをつける
    • とにかくメソッドを使うことになるので慣れる
    • ビルドしなくても一気に書けるようになった
  • OkHttpを使う
    • GETは簡単
    • POSTするときにMapでHeadersを設定するのが少し難しい
    • .headers(Headers.of(headers))で解決
    • Stack Overflowは神
  • HTTP Message Signaturesの一部を実装
    • PEM形式の秘密鍵からPEM形式の公開鍵を出力するのめっちゃ難しいな
    • あっこれ
    • Zennでやったやつだ!
      • DenoとTypeScriptでやったやつをJavaに書き換え
      • PKCS8のRSAモジュラスと公開指数取り出せば公開鍵になるらしい
      • JavaのPublickeyをエンコードするとDERを取り出せるのでBase64変換すればPEM形式の公開鍵になる
    • OpenSSLで出力したPEM形式の公開鍵と一致することを確認
      • Bouncy Castleなんていらんかったんや!
  • 全部組み合わせる
    • dotenv-javaは.envがないと起動すらしてくれないので.ignoreIfMissing()を追加
      • 環境変数が存在しないときに初期値を設定してくれる機能が地味に便利。PORTで使った
    • Javaは例外が発生してもよしなにやってくれないのでthrows Exceptionで逃がす
      • OkHttpの例外発生で困るやつ
      • new URI()をURI.create()にすれば例外が発生しないので楽
    • ifの条件が冗長になるのでモチベが下がるけど頑張る

この時点でとりあえずFollowとAccept Followは実装できたので次に進みました(残りは他実装で詰まったときや眠いときの息抜きとして実装)

https://gitlab.com/acefed/strawberryfields-spark/-/blob/main/src/main/java/io/gitlab/acefed/strawberryfieldsspark/StrawberryfieldsSparkApplication.java

Ktor Kotlin 1.8

プロジェクトの作成にKtor project Generatorを使いました。

https://start.ktor.io/

  • Java 17を使ってSpring Bootを書く予定だったが、Kotlinを使えばどのぐらいJavaの苦行を解消できるのか気になったので先にKtorをはじめる
  • JVMがJava 8でも動くので、レガシーな環境でモダンなJavaが書ける
    • varとvalを使い型やクラスを書かなくてよくなった
      • varはJava 11でも使える
    • ListとMapをlistOf(), mapOf()で書けるようになった
      • Java 11でも似たような機能が使える
    • セミコロン不要
    • ちゃんと==で比較できる
    • o.get("actor")をo["actor"]と書ける
    • Kotlin最高!
  • 早速細かい箇所がJavaと違うのでハマる
    • dataが予約語。StrawberryFieldsではHTTPリクエストクライアントのbody dataの変数名に使用していたので変数名をbodyに修正した
    • 型が若干異なる。Map<String, Object>はAnyにした
    • 一番難しいのはreturnの挙動
      • Ktorの場合return@getとかreturn@postとか書かないとEarly Returnできない
        • この時Kotlinのreturnはほんの序章にしか過ぎないことを気づいていなかった
    • String.join("", list)がlist.joinToString("")だったり
    • .getBytes()が.toByteArray()だったり
    • URI.create()も使えないのでnew URI()を省略したURI()を使う
  • 地味にKtorもムズい
    • 型が厳格。ContentTypeにString入れたりできない
    • call.requestやcall.respond()から色々呼び出す。callどっからきた
    • call.request.accept()がnullかもしれないので.contains()できない
      • 仕方なく.accept()!!使った
  • Ktor Clientがヤバい
    • 誰も使っていないので使い方が分からない
      • ドキュメントとAPIリファレンスを頑張って読む
      • SourcegraphでKtor Clientを使用しているソースコード検索する
      • おそらくPOSTするときにMapでHeadersを設定する方法がない
        • 仕方なく.forEach()で設定
      • 基本Coroutineで非同期処理を行うので関数にsuspendが必要
        • JavaScriptにおけるasync/awaitみたいなやつ。Goroutineの元ネタ
  • あとは慣れの問題
    • JVM言語なのでJavaで使えるパッケージがそのまま使える
      • HTTP Message Signaturesの一部を実装したやつはSparkjavaコピペしてきてKotlin用に少し修正しただけで動いた
      • dotenv-kotlinはdotenv-javaと使い方が同じ
      • KtorのJSON回りは難しかったのでGsonを使ってcall.respondText()
    • GradleもKotlinのScriptで書ける。Groovyについて検索する必要なし
    • 設定ファイルも使いやすく、今まで使ってきた中で一番マイクロフレームワークとフルスタックフレームワークの中間にいそうなWebアプリケーションフレームワークだった

Ktor, Kotlinで得られた知見を先に作ったSparkjavaに反映し、後回しにしていたSpring Bootの実装に着手しました。

https://gitlab.com/acefed/strawberryfields-ktor/-/blob/main/src/main/kotlin/io/gitlab/acefed/strawberryfieldsktor/StrawberryfieldsKtorApplication.kt

Spring Boot Java 17

プロジェクトの作成にSpring Initializrを使いました。

https://start.spring.io/

  • 少し前に公開されたSpring Boot 3(Spring Framework 6)はJava 17以上じゃないと動かないのでJava 17をインストール
    • ちなみにJavaのバージョン切り替えもsdkman!でやっている
  • Spring Bootもsdkman!でインストール
    • テンプレート作ってくれる機能ぐらいしかないので結局最後まで使わなかった
  • MavenではなくGradleを使用
    • Groovyなんもわからん
  • Spring FrameworkとSpring Bootの違い
    • よく分かりませんでした。とにかくSpring Frameworkにはたくさんのパッケージがあって、Spring Bootはその中の一部
  • Spring Frameworkの勘所
    • Spring Bootのアノテーションで全てを解決
    • アノテーションの依存関係について学ぶ
    • new ResponseEntity<>()で「何を返したか」が重要らしい
    • Stringを返すとText、Mapを返すとJSONになる
      • 内部でJacksonを使用している
    • Spring Bootのアノテーションで出来ることはかなり少ない
      • おそらくリダイレクトすらできない(Spring MVCが必要?)
        • headers.setLocation(URI.create("/"));を使った
  • Java 8とJava 17の違い
    • Zennの記事に助けられた
    • List.of()とMap.of()使えるのがメチャ便利
      • Kotlinで使えるアレに似たもの
      • ただMap.ofはいくつか制限があるみたいなのであまり使えない
        • Map.ofEntries()とentry()を使う
    • 標準ライブラリのHTTPリクエストクライアントを使う
      • これまたPOSTするときにMapでHeadersを設定できない。.forEach()を使う
        • ラムダ式を使うときれいに書ける
      • この方法でもHostヘッダーは設定できない。セキュリティの関係で制限されているらしい
        • System.setProperty("jdk.httpclient.allowRestrictedHeaders", "host");で対処
  • Spring Boot内部でもJacksonを使用しているので、GsonではなくJacksonを使う
    • APIが異なるがやっていることは同じ
    • ほぼ全ての処理をObjectMapperから行うので冗長に見えるかも
    • また日時が含まれているとエラーが発生することがある
      • objectMapper.registerModule(new JavaTimeModule());を使う

SparkjavaとKtorでやってきた内容とあわせて、GsonとJacksonの違いに慣れるところだけ注意すれば多くの箇所はコピペするだけで動くような感じでした。
ただググって出てくる情報が古かったり、他のSpring Frameworkの内容が含まれていたり、依存関係やAPIドキュメントの量も桁違いなので負荷は高めでした。

次のPlay FrameworkとScalaは事前準備に時間を要したので一旦夏休みが終わり、8月中旬のお盆休みぐらいに取りかかっています。

https://gitlab.com/acefed/strawberryfields-spring/-/blob/main/src/main/java/io/gitlab/acefed/strawberryfieldsspring/StrawberryfieldsSpringApplication.java

Play Framework Scala 2.13

プロジェクトの作成にGiter8のテンプレートを使いました。

$ sbt new playframework/play-scala-seed.g8
  • 恐ろしいアンチパターン
    • Early Returnが使えない
      • メソッド本体にある最後の式はメソッドの戻り値になります
      • Scalaにはreturnキーワードはありますが、めったに使われません
      • ???
      • ちょうどこの頃Rubyのreturnの話題で盛り上がっていたのでタイムリーだった
      • ちなみにcontinueとbreakはキーワードすらないらしい
        • breakは一応scala.util.control.Breaksから持ってこれる
      • やる気なくなってそのまま帰省した
      • じゃんたまを始めた
      • 帰ってきて何とかしようと思った
      • 最終的にif...else...if...elseのネストと条件式の改行を解禁して解決した(後述)
    • Scalaのバージョン
      • Scalaは2.12.18と2.13.11と3.3.0が最新
      • ???
      • sbtは2.12を使っており、Play Frameworkでは基本的に2.13を使っている
      • とりあえずPlay Frameworkにあわせて2.13を使うことにした(後述)
    • Play Framework 2.9の各バージョン
      • Scala 3 support, dropped Scala 2.12
        • Play Frameworkでは基本的にScala 2.13を使ったほうが良い
        • Scalaの初心者向け記事やPlay FrameworkのドキュメントがScala 3対応していないのもある
      • Java 17 support, dropped Java 8
        • ただJava 11もサポート終了する可能性が高いためJava 11からすぐJava 17移行できるようにする必要がある
      • sbtは1.9.0以降を推奨しており、この1.9.0は数ヶ月前にリリースされたばかり
        • 古かったり新しかったりするPlay Frameworkの世界
      • akka 2.6.xとakka-http 10.2.xからアップグレードするつもりはない
        • なぜならAkkaのライセンスががBUSL-1.1になったから
        • Apache Pekkoに移行する予定らしい?
      • まだPlay Framework 2.9はリリースされていないものの、これらを考慮してバージョンを決める必要があった
  • すべてが式になる
    • Scalaはifをはじめとしたすべての文が式
      • 関数型言語ではよくあることらしい。F#とか
      • Kotlinもifとwhenが式。tryも式っぽい
    • 式(Expression)と文(Statement)の違い
      • 値を返すか返さないか
      • 文は式になれないが、式は文になれる(式文)
      • なのでScalaのようにすべてが式であっても問題ない
      • ここら辺の話はScalaの資料ではなくJavaScript Primerを参考にした
  • returnからif elseへ
    • if式やelse if式を1行で書くとブロックが不要になるやつはScalaでも使えるため積極的に使う
      • 結果、elseに複数行の処理が集約されていくような構造に修正することになった
    • Early Returnを行うときもif文を1行で書くテクニックを多用していたため、違和感なくif elseへ移行できた
      • ただ若干ネストが深くなったように感じた。インデントが2スペースだから気にはならない
    • 少しでもブロックやif文を減らすため条件式の改行を解禁した
      • 条件式の改行は言語ごと、フォーマットごとにばらつきがあるためこれまで使用してこなかった
        • Goあたりは丸括弧を省略できるのでgo fmtかけると奇妙なことになる
        • JavaScriptもPrettier, dprint, deno fmt全部違う
        • ここら辺の書き方は今後ある程度統一したい
    • Play JSON
      • 内部でJacksonを使っているが別物だと思ったほうがよい
      • 型も異なるのでメソッドにJacksonの型があるとあわなくなる
        • なのでrequest.bodyで受け取るJSONはparse.tolerantTextにしたものをJacksonに入れる
        • Play Frameworkの型でうまく行かない時はStringを返すAPIを使うのが一番早い
        • ただJSONPathっぽく使える機能もあるので一長一短
  • 今回使わなかったsbt-dotenvの作りが悪い
    • PRIVATE_KEYのようなMultilineの環境変数を渡すと無限ループに陥る
    • Sourcegraphでsbt-dotenvを使用しているプロジェクトを確認しても解決しない
    • 仕方ないのでdotenv-javaを使うことにした
      • Play Frameworkやsbtのパッケージでうまくいかない時はJavaのパッケージを取ってくるのが一番早い
  • Play Frameworkのメリット
    • Kotlinと同様、ScalaもJVM言語なのでSpring Bootからコード取ってきて少し修正しただけで動くことが多い
    • sbt runコマンドを使えばホットデプロイ機能が有効な開発モードで起動するのでものすごく楽
      • いちいちビルドしなくてもScalaの難しい構文エラーや型エラーが分かるのが最高
      • 正直これなかったらやる気なくして詰んでた
    • フルスタックフレームワークだけどSpring Frameworkのような複雑さはない
      • play.apiとplay.api.mvcをインポートするだけである程度のことはできる。お好みでplay.api.libs.jsonも

色々ありましたが無事完成したので全体的なコードを調整した後、公開に必要なファイルを追加して、後述するDocker対応を行い、4種類まとめて2023/08/17頃に公開しました。

https://gitlab.com/acefed/strawberryfields-play/-/blob/main/app/controllers/HomeController.scala

Cloud Native Buildpacks Docker

  • とりあえずHerokuのCloud Native Buildpacksに対応する
    • 思ったより簡単にできた
    • ただPlay Frameworkの環境変数渡しが大変だった
      • application.confファイルより先に.envファイルを読み込ませることが実質不可能
      • そのためDockerの--env-fileで渡したときの動作とローカル環境で.env渡した時の動作が異なる
      • 諦めてローカル環境ではAPPLICATION_SECRETを別途設定することにした
  • Javaのコンテナは選択肢が多い
    • Spring Frameworkはコマンドから簡単にコンテナを生成できる
      • Paketo Buildpacksを使っているらしい
    • Ktorもコマンド簡単コンテナ
      • Jibを使っているらしい
    • Fat Jarという選択肢もある
      • ビルドしなくてもjava -jar app.jarで起動できるやつ
      • HerokuのSpring Bootはこれで起動できる
    • GraalVMとQuarkusでネイティブ実行可能ファイルも作れる
      • Tomcat, Jetty, Undertowあたりの影響を受けるため調査に時間を要する
  • やっぱりJavaのコンテナは難しいので残りの作業はあとでやる

HerokuのCloud Native Buildpacksに対応したため、Gitpod上に.envファイルを用意して以下のスクリプトを動かすだけで、前回作成した10種類と、今回作成した4種類をまとめてテストできるようになりました。

#!/bin/bash

strawberryfields=( express fastify flask fastapi lumen laravel sinatra rails gin django spark spring ktor play )
for i in "${strawberryfields[@]}"; do
  git clone "https://gitlab.com/acefed/strawberryfields-$i.git"
done
#!/bin/bash

brew install buildpacks/tap/pack
chmod 666 /var/run/docker.sock
docker kill $(docker ps -aq)
docker rm $(docker ps -aq)
docker rmi $(docker images -aq)
strawberryfields=( express fastify flask fastapi lumen laravel sinatra rails gin django spark spring ktor play )
for i in "${strawberryfields[@]}"; do
  pack build "strawberryfields-$i" -p "strawberryfields-$i" -B heroku/builder-classic:22
done
for i in "${!strawberryfields[@]}"; do
  gp ports visibility "$((8082+i)):public"
  docker run -d -p "$((8082+i)):$((8082+i))" --env-file=.env -e "PORT=$((8082+i))" "strawberryfields-${strawberryfields[i]}"
done
#!/bin/bash

strawberryfields=( express fastify flask fastapi lumen laravel sinatra rails gin django spark spring ktor play )
docker images | head -1
docker images | grep strawberryfields | sort
docker ps
for i in "${!strawberryfields[@]}"; do
  curl "$(gp url $((8082+i)))"
  echo
  curl -L "$(gp url $((8082+i)))/@a"
  echo
done

docker

おわりに

なかなか筆が乗らず、なんとこの記事を書くのも1ヶ月かかってしまいました!
まだまだ書きたいことはたくさんあるのでなにか追加するかもしれませんが、とりあえず振り返りを終えたので今後は14種類のソースコードを眺めて共通化したい箇所の共通化を行い、RFCやW3Cに可能な限り準拠するよう努め、他のActivityPub実装と互換性のある機能を増やし、ドキュメントをまとめたら、残りの.NETとActix Webの実装に取り組んで行きたいと思います。
これからもStrawberryFieldsをよろしくお願いします。

https://acefed.gitlab.io/strawberryfields

参考文献

Discussion