この記事は個人ブログのうち技術に関する箇所のみを抜粋した転載です。
なぜ僕たちはサーバレスでJavaを諦めTypescriptを採用したか -Junks, GC cannot sweep-
またブログには書いたのですが、諸事情により先に英語版が存在します。
こちらも書いたのは僕なので、剽窃などではないことはご了承ください。
[元記事]: Why we replaced Java with Typescript for Serverless in dev.to
はじめに
**サーバレス(serverless)**は昨今もっとも注目を集める設計手法の一つで、おそらく多くの開発者が大なり小なり自分のプロダクトに応用し始めているのではないでしょうか?
僕自身、完全にサーバレスに魅せられてしまい、昔ながらの自分でサーバやミドルウェアを管理しながら運用するみたいな世界には戻れる気がしません。
そもそもスケーリングや分散可能性をきちんと考えて設計されたアプリケーションであれば、旧来のサーバーアプリケーションの機能から受けられる恩恵も比較的少なくなりますし、サーバレスに切り替えるデメリットはそこまでありません。
最近は設計に関して相談された時は、必ずサーバレスの話題を出してみることにしています。
さて、サーバレスは既存の開発手法とは大きく異なるため、今持っている知識を刷新し、既存の手法や技術スタックを見直しながら開発していく必要があります。
見直しというからには、開発基盤として何の言語を使うかも、当然ながら見直さなくてはいけない技術スタックの対象になります。
タイトルにある通り、最終的に僕たちはTypeScriptを採用し、およそ一年半開発・メンテナンスを行ってきました。
そして一年半経った今、あくまで個人的な感想ではありますが、TypeScriptは僕たちが期待した以上に成果を出してくれました。
そこでこの記事では、以前使用していた言語にどんな問題があったのか、そしてなぜTypeScriptに切り替えたことでどんな恩恵があったのかをこの記事では解説していきたいと思います。
なぜJavaを諦めなくてはならなかったのか
さて、なぜTypeScriptを採用したかについて話す前に、まずなぜ以前使用していた非常に強力な言語であるJavaを諦めなくてはいけなかったかについてお話ししたいと思います。
先に述べておきますが、僕は結構なJava好きです。なんなら初めて触った言語もJavaでした。 JVMに関してもそれなりに勉強して、その神がかったランタイムの仕組みにかなり感銘を受けています。(てか多分作ったやつは神) なので、どこかの大学生のように**Javaがクソだとかレガシーだとか使い物にならんとか、この記事でそういうことを言うつもりは一切ありません。** また、そういったコメントもあまり嬉しくないです。あくまでサーバレスという仕組みにJavaがあまり合わなかっただけなので。 その点だけはご了承いただければ幸いです。
なぜJavaを諦めなくてはならなかったのか
さて、なぜTypeScriptを採用したかについて話す前に、まずなぜ以前使用していた非常に強力な言語であるJavaを諦めなくてはいけなかったかについてお話ししたいと思います。
僕たちのサービスでは、サーバサイドはサービス設立当時から基本的にJavaだけで書かれていました。
当然ながらすでにJavaには多くの利点があり、特に
- プラットフォームフリー
- よくできたJITコンパイル
- やばいGC
- よく構成された文法
- 静的型付け
- 関数型サポート(最近は特に)
- 多様なライブラリ
- 信頼できるコミュニティ(Oracleではなく、開発者の方)
などなど挙げればきりがありません。
しかし、AWS Lambda上でコードを試していて気づいたのですが、Javaはあまりサーバレスに向かないことがわかりました。
理由としては以下のことが挙げられます。
- JVMの起動オーバーヘッドが大きい
- Springフレームワークを使用してるとさらにエグくなる
- 最終的なパッケージアーカイブがでかすぎる(でかいのは100MB以上)
- 関数が増えてくるとWebフレームワークなしでリクエストを捌くのがきつくなる
- コンテナは30分程度しか走らないため、G1GCやJITなどのJavaの利点が生かせない
- Lambdaは基本的にEC2上に建てられたAmazon Linuxのコンテナで動くため、プラットフォームフリーは関係ない。 (欠点ではないけど)
上述の点は全てなかなかに厄介ですが、今回は特に厄介だった問題についてもう少し書いてみたいと思います。
Cold Startがまじで厄介
一番厄介だったのは、圧倒的にCold Startのオーバーヘッドです。おそらく多くの開発者の方々もこいつに悩まされているのではないかと思います。。。
僕たちはコンピューティング基盤としてAWS Lambdaを使っていたのですが、AWS Lambdaはユーザからのリクエストが来るたびに新しいコンテナを立ち上げます。
一度立ち上がってしまえば、しばらくは同じコンテナインスタンスを再利用してくれるのですが、初回起動時にはJavaのランタイムに加え、フレームワークで利用されるDIコンテナやWebコンテナなども全て初期化する必要があります。
さらに言えば、一つのコンテナで処理できるのはあくまで一つのリクエストのみで、複数のリクエストを処理することはできません。(内部で数百のリクエストスレッドをプーリングしてたとしても同じです。)
つまりどういうことかというと、もし複数のユーザがリクエストを同時に送ってきた場合、Lambdaは起動中のコンテナの他に、別のコンテナを起動しなくてはいけなくなるということです。
通常、僕たちはどの時間に具体的に何件のリクエストが同時に来るかを事前に予測することはできません。
つまり、何らかの仕組みを作ったとしても、事前に全てのLambdaをhot standbyさせることはできないのです。
これは必然的にユーザに数秒から10秒以上の待機時間を強制し、ユーザビリティを著しく下げることにつながります。
こんな感じでCold Startがえげつない事を理解した僕らは、これまでの数年かけて書かれた技術スタックを捨てて、他の言語を選択することを決めました。
なぜTypeScriptを選んだのか
めちゃくちゃ恥ずかしい話なのですが、正直Lambdaでサポートされている全ての言語をきちんと精査・比較して決めたわけではないのです。
ただ、正直な話、状況的にTypeScript以外の選択肢はなかったのです。
まず第一に、動的型付け言語は外しました。長期に渡ってスキルのバラバラな開発者によって保守・メンテ。拡張されるコードなので、動的型付けはあまり使いたくありません。
したがって、PythonとRubyに関してはかなり序盤で選択肢から外れました。
C#とGoに関しても、現在ほとんどのチームがJavaをメインに開発しているサービスなので、既存言語とあまりかけ離れた言語を使うと新規開発者のジョインが難しくなると判断し、今回は見送られました。
もちろん、昨今この二大言語は非常に注目度が高く、特にGolangに関しては徐々にシェアを伸ばしつつあるのは知っています。
しかし、急いでサーバレスに開発を移す必要があったため、僕たち自身のキャッチアップの時間も考慮し、見送らざるを得なかった感じでした。
TypeScriptの利点
という事で、僕たちはTypeScriptを使い始めたわけです。
TypeScriptの利点を挙げるとしたらこんな感じでしょうか?
- 静的型付け
- 小さいパッケージアーカイブ
- ほぼ0秒の起動オーバーヘッド
- JavaとJavaScriptの知識が再利用できる
- NodeJSのライブラリやコミュニティが使える
- JavaScriptと比べても関数型プログラミングがしやすい
- ClassとInterfaceにより構造化されたコードが描きやすい
長期に渡って運用・開発が行われるプロジェクトにおいて静的型付け言語がどれだけ大きな恩恵を与えるかは今更語るまでもありませんので、ここには書きません。
ここでは主に、TypeScriptのどういった点がサーバレス開発によく馴染んだかについて書いていきたいと思います。
静的型付け以外にもTypeScriptを使う利点は非常に大きく、
小さいパッケージと小さい起動オーバーヘッド
おそらくサーバレスでTypeScriptを使う利点という観点からいうとこれが一番大事だった気がします。(なにせ他のメリットはほぼTypeScript自体のメリットなので・・・)
先ほど触れた通り、JavaはJVM本体やフレームワークが利用するDI/Webコンテナなどの起動にかかるオーバヘッドが非常に大きいです。
加えて、Javaの性質上、AWS Lambdaで流すには以下の弱点があります。
マルチスレッドとそれを取り巻くエコシステム
マルチスレッドは非常に強力な機能であり、事実として僕たちはこの機能のおかげで多くのパフォーマンス問題を解決してきました。
JVM自体もガーベージコレクションやJITコンパイルにおいて、デフォルトでにマルチスレッドを活用してあの素晴らしいランタイムを実現してます。
(詳しくはG1GCやJIT Compileを参照)
しかし、起動時間単体で見ると、アプリケーションに使用する全てのスレッドを立て終わるまでに、100ミリ秒から数秒かかっていることがわかります。
この機能自体は旧来のいわゆるクラサバモデルでEC2上で動くアプリケーションならほぼ無視できるオーバーヘッドですが、LambdaなどのFaaS上で動くサーバレスアプリケーションでは決して無視できません。
TypeScriptはNodeJSベースであり、基本的にシングルスレッドです。非同期は別スレッドや別プロセスではなくあくまでジョブキュー、イベントループなどで管理されます。
したがって、ほとんどのライブラリやフレームワークは起動時にスレッド展開をする必要はありませんし、ランタイムを起動するためのオーバーヘッドもほとんどかかりません。
巨大なパッケージアーカイブ
サーバレスにおいてソースコードのパッケージアーカイブは、基本的に小さいに越したことはありません。
Lambdaのコンテナは起動時、AWSにより管理されたソースコード用のS3バケットからソースをダウンロードし、コンテナに展開します。
S3からのダウンロード時間は通常非常に短時間ですが、100MBや200MBとなると無視はできません。
NodeJSのパッケージは基本的にJavaに比べて小さくなります。
正直なんでそうなるかに関しては不勉強でわかっていないのですが、以下の理由が関係してるんじゃないかなと思ったりします。(もしこれやでっていうのをご存知の方はコメントで教えてください)
- Javaのフレームワークやライブラリは包括的なものも多く、本来使いたい機能に必要ない依存性を引き込んで来るが、JavaScriptは目的特化のライブラリが多く、必要最低限に依存を抑えられることが多い。
- JavaScript(NodeJS)は1ファイルに複数のmoduleを書くことができ、それでいてメンテもしやすいが、Javaにおけるメンテナンス性の重要なポイントはファイル分割とパッケージ管理のためソースが肥大化しやすい。
実際Javaで書いていた時は最大で200MB以上のパッケージができることもあったのですが、NodeJSに変えてからは35MB程度で済んでいます。
この巨大なパッケージアーカイブは、僕たちがSpringで書かれた旧来のコードを再利用しようとしたのが大きな原因なのですが、実際これらのいらないフレームワークを除いて最適化したコードでも、どうしても50MBは必要になってしまいました。
JavaScriptの知識やエコシステムを利用できる
僕たちもWeb開発者のため、基本的にフロントエンドも書きます。したがって、ある程度のJavaScriptやNodeJSに関する知識は蓄えていました。
jQuery全盛時代からReact/Vueのようなモダンフレームワークでの開発までを通じて、言語的な特徴はある程度抑えていましたし、どうやって書けばいいコードになるかもある程度理解してるつもりです。
TypeScriptはJavaScriptの拡張言語であり、最終的にはJavaScriptにトランスパイルされます。
多くの文法やイディオムはJavaScriptから受け継がれているので、実際それほど準備期間を要さずにサービス開発を始められました。
加えて、ほとんどのメジャなNodeJSのライブラリはTypeScriptに必要な型定義を提供しているので、NodeJSのエコシステムのメリットをそのまま享受できたのも非常に嬉しいポイントでした。
関数型の実装が非常にしやすい
昨今の技術トレンドを語る上で、関数型の台頭はなくして語ることはできません。
関数型の実装はその性質上、シンプルでテスト可能で危険性の低い安定したコードを書くのに大きく寄与します。
特にAWS Lambdaの場合、常に状態を外部化するコードが求められるため、状態や副作用を隔離する関数型の実装は非常に相性が良く、メンテもしやすくなります。
そもそも、jQueryの生みの親であるJohn ResigがJavaScriptニンジャの極意で語ったように、JavaScriptはそもそも関数型のプログラミングをある程度サポートしています。
JavaScriptにおいて関数は第1級オブジェクトであり、jQueryも実は関数型で書かれることを期待して作られています。
しかし一方で、動的型付け言語で関数型のコードを書こうとすると、時折非常にめんどくさい事になることがあります。
例えば、プリミティブ型だけで表現できる関数は非常に限られますし、返り値や引数にObjectを取るのは普通に結構危険です。
しかしTypeScriptでは引数や返り値に型を指定することができます。
加えて、以下のTypeScriptの機能は、僕達の書く関数の表現の幅を広げ、より安全でシンプルなコードを書くのに寄与してくれます。
- Type: 共通に使用される型をコンテクストに合わせて型付けできる。(stringとUserIdやPromiseとResponseなど)
- Interface/Class: Objectで表現されるの引数や返り値をコンテクストにあった型で表現できる。
- Enum: よもや語る必要もあるまい
- Readonly: 自分で作成した型をImmutableに出来る
- Generics: 関数のインターフェイスの表現の幅が広がる
TypeScriptは他にも関数型で書こうとした時に非常に便利な機能をいろいろ備えていますが、全てをここであげることはしません。(っていうか、結構JavaScript由来のものが多い)
関数型とTypeScriptに関する記事は今後どこかで書いていきたいなと思っています。
Javaで学んだBest Practiceを再利用できる
TypeScriptの文法を学ぶと、かなりJavaやScalaに似通った記述ができることに驚きます。
僕たちはそもそも、それなりの期間をJavaで開発してくる中で、Javaにおけるいいコードのお作法をある程度蓄積してきました。
ClassやInterfaceをどう設計すべきか、enumはどう使うと効率的か、Stream APIはどう書くと保守性が上がるかなど、蓄積してきたノウハウはそれなりに捨てがたいものがありました。
TypeScriptはインターフェイスやクラスに加えて、アクセスモディファイアやreadonly(Javaでいうfinalのプロパティ)をサポートしており、僕たちは割とさらっとJavaで育んだノウハウをそのまま導入することができました。
これにはオブジェクト指向的なベストプラクティスやデザインパターンなども含まれます。
(関数指向とオブジェクト指向は二律背反ではないので、プロジェクト内で同時に使用されても問題ないと考えています。個人的には。)
もし僕たちがやや文法が独特なPythonやRubyを採用していたとしたら、より品質の高いコードを書くためのプラクティスをどうこの言語に応用すべきかに多くの時間を費やすこになったことかと思います。(それも楽しいんですよ、知ってます、ただ時間がね。。。。)
当然ながら全ての設計やロジックをコピペしたわけではないですし、むしろ大半のコードを書き直ししました。
ただ、おおよその部分をスクラッチで書き直した割に、それなりの品質でそれなりの短期間で書き直しが終わったんだよということは特筆しておくべきかと思います。
結論
僕たちもまだまだTypeScriptに関しては初心者といっていいレベルでまだまだ勉強が必要ですが、すでにそのメリットは全力で享受しています。
今聞かれれば、Golangもいいなあとか、MicronautとGraalVMとかも面白そうだなあとか、もっと他の選択肢もあったかもなあとか考えたりもするのですが、現状TypeScriptには非常に満足しており、最善の選択肢の一つではないかと信じています。
もちろん、処理遅いけどバッチ処理どうすんねんとか、並行処理とか分散処理どうすんねんとか、ワークフローどう設計すんねんとか、API Gatewayのタイムアウトどうハンドルするねんとか、データの一貫性どう担保すんねんとか、サーバレスやTypeScriptに起因する問題にはたくさんぶち当たりました。
ただ、それはそれでギークとして非常に楽しく取り組んできて、すでにいくつかのこれが今の所best practiceじゃね?っていう方法もいくつか見つけました。(これはのちのち記事にしていきたい。)
もし今Javaでサーバレスに取り組んでいて、サーバレスくそやん、きついやん、やっぱ普通にサーバ欲しいわってなっている方がいたら、ぜひTypeScriptも試してみてください。想像する以上に生産性出るんじゃないかなぁって期待してます。
長文おつきあいいただきありがとうございました。何かコメントや訂正があればぜひお願いします。