2022年01月22日の日記

Spring bootがつらい。本当にしんどい。

アノテーションやリフレクションを使ってごちゃごちゃやるやるライブラリは決定的に設計が間違ってると個人的には思う。

そもそもJavaやKotlinは型で縛る方向性の言語だと私は解釈している。型でできることを縛り、設計者が想定したこと以外のことは出来ないようにする。人間はミスをおかす。それは仕方ない。だからプログラムはなるべくわかりやすいエラーを吐かせるべきだ。それも実行時ではなくてコンパイル時に。実行してみて特定の条件になった場合でエラーを吐くのではなくて、コンパイル時に「こういうケースの時にこのコードは失敗しますよ」と警告なりエラーなりを出していて欲しい。色々意見はあると思うが、私はそういう方向性であってほしいと思うし、JavaやKotlinはそういう方向を目指している言語だと個人的にはそう解釈している。

しかしSpring Bootってそれと真逆の思想に行ってる気がするんですよね。JavaやKotlinから使うライブラリのくせに。

アノテーションやリフレクションを使ってコードを挟み込んだりするやり方を頻繁に使うと、設計者側は自分が設計したのでどう動くか分かるんでしょうけど、使ってる側はちんぷんかんぷんなんですよね。

想定外の動作になった時、もう調べる方法がググるか実行時のコードのスタックトレースから頑張って追っていくかしか無いんですよ。後者をやるとなるとブレークポイントを貼ってなんちゃらProxyだのinvokeだのdoDispatchだの情報量が皆無なメソッド名をかいくぐってそれらしいメソッドにまたブレークポイントを張り動作を観察する。しんどい。無理。そんなこと別にSpring Bootの専門家になりたいわけでもないのでやりたくない。だから永遠にStack Overflowや公式のドキュメントなどを検索することになる。これ本当しんどい。

今回、具体的に何があったかというと、Spring Bootが404エラーの時に返却するデフォルトのJSONメッセージを書き換えたかった、それだけなんですよね。

Spring Bootではこういう時にどうするかと言うと、まず @ControllerAdvice なるアノテーションを付けたコントローラーを用意する。その中で扱いたいExceptionのクラスを指定するんですよね。

[code lang=text]
@ControllerAdvice
class BadRequestExceptionHandler() {
@ExceptionHandler(NoHandlerFoundException::class)
fun handleNotFound(e: Exception, req: HttpServletRequest): ResponseEntity<String> {
....
}
}
[/code]

こうする。

まず、この設計が非常によろしく無いと思う。なぜかと言うと、この方法は

  • エラーハンドリングをするには @ControllerAdvice を付けたクラスを用意する
  • @ExceptionHandler を扱いたいメソッドに付与する
  • そのメソッドは (e: Exception, req: HttpServletRequest): ResponseEntity<Any?> みたいなシグネチャはとりあえず許されている(他にどのようなものが許されてどんなものが許されてないのかは知らない)
  • 要求されたパスに対応するハンドラーがController一式に無い場合、 NoHandlerFoundException がスローされる

という全部を知ってないといけないんですね。めっちゃ面倒くせえ。そう、アノテーションはなんというアノテーションを付けておくかを正確に知ってないと動かないし、間違ったアノテーションを貼ってしまっても多くの場合何の警告もエラーも出してくれないんですよね。たまに出してくれる場合もあるけど。

だからこれって「私がなんで怒ってるかわかる?」ゲームと一緒なんですよ。「なんで私が想定通りの動作をしないか分かる?」って聞かれてるようなもん。知るかっちゅうねん。

私が設計するならばこうしますね。

[code lang=text]
trait ControllerErrorHandler {
def handleError(e: Error): HttpResponse
}

class MyErrorHandler {
def handleError(e: Error): HttpResponse = e.type match {
case ErrorType.NotFound: HttpResponse(404, "not found")
case ErrorType.MethodNotAllowed: ....
.....
}
}

val controller = ControllerBuilder.build().addExceptionHandler(new MyHandler())
val server = HttpServer(controller)
val future = server.listen("0.0.0.0", 80)
[/code]

こうすれば、

  • 404をデフォルトの動作から書き換えたいなぁ
  • コントローラーのビルダーでなんか独自のハンドラを取らないかな
  • おっ、addExceptionHandler、これやな
  • 引数の型は ControllerErrorHandler か。なるほど
  • ControllerErrorHandlerはhandleErrorを実装すれば良いんだな
  • handleErrorはErrorをもらえるのか。Errorの中のtypeがエラーになった理由を簡潔に説明してそうだな
  • これは定数か。ErrorTypeを見れば入ってくるエラーが全部わかるな
  • 404になったときはErrorType.NotFoundだからこれが来た時に独自のErrorResponseを受け取ればいいんだな

となりますよね。そして実装してないエラータイプがあれば、ここのmatchでこいつが来た時に失敗しますよ、ってコンパイラが警告出してくれたりもできるわけですよ。私はこっちのほうが良いと思うな。なぜならばIDEの補完機能コンストラクタやメソッドのシグネチャで設計を読み取れるから。ドキュメントを読まなくていい。ドキュメントを充実させて利用者がそのドキュメントをちゃんと読むことはもちろん大事だが、90%くらいの人が同じ機能をつかうだろうと思われるところはいちいちドキュメントを見なくても類推できるようになってるほうが良いと思う。だって、ドキュメント読まない人って多いでしょ?ドキュメントを読まない人たちにドキュメントを読めっていうより、世の中の多数に合わせたほうが良いと思う。まああくまでも私の意見です。

で設計の話はおいといて、Spring Bootのコードを再掲:

[code lang=text]
@ControllerAdvice
class BadRequestExceptionHandler() {
@ExceptionHandler(NoHandlerFoundException::class)
fun handleNotFound(e: Exception, req: HttpServletRequest): ResponseEntity<String> {
....
}
}
[/code]

これを当時書いた時想定通りの動作にならなかったんですよ。なんで?

それはこれと別に、アプリケーションプロパティから

[code lang=text]
spring.mvc.throw-exception-if-no-handler-found=true
spring.resources.add-mappings=false
[/code]

の2つを設定しておかないと駄目らしいんですね。いやいやいやいや…

だってControllerAdviceでExceptionのクラス名を指定してハンドラをメソッドに振り分ける設計なんだから、当然全部のエラーがExceptionで帰ってくると思うじゃん?なんでコントローラーが見つからないときだけ別枠の扱いなんですかね。これもこういうプロパティがあるのを知ってないとわからない。SpringBootで指定できる設定項目ってめちゃくちゃ膨大でドキュメント読むのもしんどいんですよ。

2つ目の設定項目、add-mappingsがfalseとかいうのも意味がわからない。これが設定されていないとExceptionが飛んでくれないらしいが、なぜこのプロパティと関連があるのかさっぱり理解できない。おそらく、リソースへのマッピングを行う機能は対応するハンドラがないという例外をキャッチして動く機能なので例外を出しつつリソースへのマッピングを追加するということができない、という内部事情があるのかもしれない(わからない)。でもそんなことは普通に使ってたらわからない。

せめてドキュメントに「この設定項目がfalseになってないとこっちは有効になりませんよ」と注意書きを書いてくれてるならばまだ理解できるが、書いてない。書いてないからググってStackOverflowとかに書かれてる設定を入れてみたり削除してみたりして試行錯誤することになる。

その時はこれで上手く行った。

だが、こんどSpringBootのバージョンを上げたら上記のコードが動作しなくなってしまった。NoHandlerFoundExceptionが飛んでこない。

そんでこの原因について数時間、スタックトレースを追っていったりググったりして調査を重ねた。すると、@EnableWebMvc をつけろなどとissueに書いてあるのをようやく見つけた。付けたらまたExceptionが飛ぶようになった。

何がなんだかさっぱりわからない。私には理解できない。spring mvcへの依存性をbuild.gradleに記述してコンパイルして実行しても、これ付けないと動かないですよ!とかいう警告が出るわけでもない。こういうアノテーションを付けることが必要です、という情報を知ってないと使えない。扉の前に立って開けゴマ!って叫べばドアが開きますよ、と言われたみたいだ。合言葉を知らないと動かない。しかもEnableWebMvcって絶対に指定してなければ重要な機能がいくつも動かなくなるような名前なのが不安すぎる。私はこれまでこれを付けなくても動いてたんですけどね…。

そいうわけでServing Web Content with Spring MVCをもう一度読んでみる。EnableWebMvcというアノテーションについては記載がない。 @SpringBootApplication が付与されていれば @EnableAutoConfiguration が付与されているのと同等の効果があり、これが与えられていると例えば spring-webmvc などがクラスパス上に存在するだけでそれが使えるようになる、とは書かれている。ここにEnableWebMvcについての記載が無いのでおそらくこれは指定することは必須ではないのだろう。必須であったならまだ分かるんだけど、じゃあ私が体験した挙動はなんだったの?と思う。

とまあ、バージョン更新するたびにSpringBootってこういう事がこれまでにも沢山あったんですよ。マイグレーションガイドに書いてりゃ分かるが、細かいことはあんまり書いてない。で、やる度になんでこれ動かないの?って事が沢山ある。しんどい。もちろん、こういう事が皆無のライブラリというのは多分ない。私は経験したことがない。でもSpringBootってこういうのが多いんですよ。動かしてみないと挙動が変わったのがわからない。アップグレードして挙動が変わるような動作が組み込まれたのならば以前使用していたメソッドやクラスをDeprecatedにしてコンパイル時に「今後挙動が変わりますよ」って分かるようになっててほしい。でもなってない。

それでここまで書くと必ず、必ず、必ず、必ず言われるんです。「OSSなんだから文句言ってないでプルリク出したらいかがですか?」って。

プルリク!issue!出すとして、上記のような問題ってどう書けば良いんですか?私には書けない。書ける自信は無い。だって上記に書いたとおり、意味が分かってないんだもの。意味が分かってないっつうか、何が問題なのかも分かってないし、問題と言っていいのかどうかもわからない。何もわからないんですよ。

私がやりたかったことは「404の時に出てくるデフォルトのJSONのメッセージを書き換えたい」、ただそれだけなのに登場するのは「開けゴマ」とか「ウィンガーディアム・レビオーサ」みたいな魔法のアノテーションや設定項目ばかりでそれぞれに何の意味があるのかさっぱり分かってない。どこからどう手を付けて良いのかわからない。だから書くとしたら、

「404の時に出てくるデフォルトのJSONのメッセージを書き換えたい、ただそれだけをやりたいのに開けゴマとかウィンガーディアム・レビオーサとか意味がわからないです。なぜこれが必要なんですか?」

という書き方になるだろう。そしてそれを書くと決まって

「issueはFAQではありません」

と言われて即クローズされるのである。

それはそのとおりである。しかし他に書き方を知らんのである。

ではどうすべきか?

それは私がSpringBootの大魔法使いになって開けゴマもウィンガーディアム・レビオーサもそれが何の意味があるのか、どういう歴史的経緯があってそういう発音になっているのか、それを発音したとき内部ではどのような事が起こっているのか、そして本来はそれらがどうあるべきなのか、まで理解した上で適切な修正を書き、

「こうすべきではないですか?」

と提示して同じくほかの大魔法使いを、ううむ、そのとおりである。と唸らせる必要がある。ここまでやって初めてOSSプロジェクトに私の改善がマージされる。

いやあ無理ですわ。

と、こう書くと決まって、無理じゃねーよ、それがお前の仕事だろ、プログラマだろ。プログラマだったらOSSに貢献しろ。と言われるんですよ。

いやいやいやいや。そもそもSpring Bootを私は使いたくないんですよ。使いたくないライブラリの大魔法使いになるモチベーションは湧いてこない。OSSに貢献しろ過激派アニキだって好きなOSSプロダクトと好きじゃないOSSプロダクトがあるでしょ。好きじゃないやつを余暇の時間1日とか2日とかつかって調べてissue書こうと思います?思わんでしょ。じゃあそもそもなんでSpring Bootで開発してるの?と思われると思いますが、まあ色々事情がありまして…。主に私の責任ではあるんですけど。だからそこに文句を付けるつもりは無いんですけど。

それはおいといて、たとえSpring Bootの大魔法使いになったとしても、ジャンルがちょっと変わってAWSとかGCPとかScalaとかRustとかLinuxとかフロントエンド開発とかAndroidアプリ開発とかUnity開発になるとまた全然違う話になりますからね。魔法をつかってドラゴンをばったばったと倒していたヒゲの大魔道士が現代のアメリカ陸軍機械化歩兵師団で活躍できますか?活躍できるのはなろう小説の世界だけですよ。自分がどのジャンルで大魔法使いを目指すのか、あるいは目指さないのか、そのあたりはせめて自分で決めさせてください。

プログラマの仕事はOSSへの貢献じゃないですよね。理想論はとりあえず置いといて現実的にはそうですよね。自社のプロダクト開発をほっぽりだしてOSS開発を優先していいなんて会社がどれほどあります?そんなに無いでしょ。無いからOSSはフリーライドされてるとか言われてうまく行ってないんですよ。うまく行ってないところで理想論を振りかざして俺のように高潔な大魔道士として活躍すべきだとか言われても、給料貰って家族を養ってるサラリーマンとして「寒いわ」ってのが正直な感想なんですよね。

だから個人的にはOSSはそれぞれ稼いでる企業(AmazonとかAmazonとかAmazonとかAmazonとかGoogleとか)がスポンサーに付いてOSS開発者がそれだけで食っていける体制を整えるとか、そういう文化になるようにちょっとずつ寄せてくのが良いんじゃないかなと個人的には思いますね。でも現状はフリーライドに対抗するためにライセンス変えます→じゃあフォークして自社開発でいきますね、のケースをよく見るんですけど。まあそりゃそうですよね。成果物が全部公開されてて、それをフォークするのも自由で、今開発してる団体と交渉が決裂するたび面倒なことになる。だったら金かけるべきはOSS開発者じゃなくて自社に技術者をかかえて自分たちに都合の良いようにフォークして作っていこうってなりますよ。そうする体力のある会社ならばね。

じゃあどうすればいいの?分からん。私には分からん。解決策を持っていない。私ごときが解決策を提示できるならばとっくに世の中は上手く回ってるだろう。

私は先に言ったとおり別に高潔で崇高な考えをもった大魔道士ではなくて単なるサラリーマンなので、できることとできないことがありますわ。悪いけど。私がやるべき最善のことは、SpringBootから緩やかに撤退していく、これくらいしかないと思いますね。似たようなOSS資産の中から一番良いと思えるものを選ぶ・あるいはそれに乗り換える、という選択肢になっちゃうんですよね。

ということを考えてました。おわり。