Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

JavaウェブアプリプロジェクトにJavaScript/TypeScriptなどの静的アセットをどう配置するか

以前のJavaウェブアプリ開発では、JavaScriptをはじめとした静的アセットはsrc/main/webappディレクトリに配置するのが普通だった。そこに置くことでmaven-war-pluginのようなビルドシステムが.warファイルの中に突っ込んでくれる。この挙動は今でも変わらないが、src/main/webappディレクトリに静的アセットを直接置くにはいくつかの問題がある:

  1. TypeScriptコンパイラやBabelのような、静的アセットに事前処理を施す手法が普通になった。
    • src/main/webappに処理後のリソースを置くようにもできるが、mvn cleanなどで処理後のリソースが削除されるようにする手間を考えるとtargetディレクトリ直下にあるwebappDirectoryに置くのが無難と思われる。
    • よってsrc/main/webappには事前処理を必要としないアセットのみを置くことになるが、次に挙げる理由からそれも必要なくなってきている。
  2. フロントエンド開発が複雑化し、サーバサイドと同一プロジェクト・モジュールで開発することが難しくなった。
    • ここで言うフロントエンドはサーバからのレスポンスを受け取ってユーザ向けにインタフェースを提供する部分。HTML5アプリだったりモバイルアプリだったりする。
    • フロントエンド開発で使うビルドツールやライブラリ、development workflowは必ずしもJavaのそれと一致しない。例えばブラウザやOSのプレビュー版が出たときにフロントエンドだけテストを回すといったことができればより高速に検証できる。またJavaScriptライブラリはJavaライブラリ以上にこまめなバージョンアップが必要となるケースが多い(脆弱性対応とか、OSアップデート追随とか)ため、フロントエンドを独立に更新していけるプロジェクト体制を整えることが必要になる。
    • frontend-maven-pluginを使ってJavaのビルドツールに統合することは可能だが、そもそも開発者が異なるケースではあまり意味がない。OpenAPIやgRPC、GraphQLなどのインタフェース定義をフロントエンドとサーバサイドの間に入れて独立に開発していく体制を組むほうが良い。
  3. 静的アセットはTomcatのようなサーブレットコンテナではなく、Amazon S3のようなストレージサービスやCDNによって配信することが好ましい場合が多い。
    • 例えばGoogle Cloudのドキュメントでは、アプリによる静的アセットの配信をstraightforwardだが欠点のある手法として紹介している。

この問題を解決するために、大きく分けて3つの手法を紹介する。

1. 同一プロジェクト内で2つのビルドツールを併用する

サーバサイド開発にはMaven/Gradleを、フロントエンド開発にはnpm/Yarnを利用するが、プロジェクトは分割しない手法。

この手法1.を採る場合、アセットは .war.jar に同梱する形が最もやりやすい。例えばSpringだと同梱されているリソースをStatic Resourcesとして配信可能。懸念されるサーブレットコンテナの負荷低減は、HTTPレスポンスのキャッシュやCDNによって実現することになる。

1.1. サーバサイドをMaven/Gradleで、フロントエンドをMaven/Gradleで包んだnpm/Yarnで開発するケース

サーバサイドとフロントエンドを同一のビルドライフサイクルに乗せるため、frontend-maven-plugin/frontend-gradle-pluginを使って、Maven/Gradleからnpm/Yarnを実行することになる。フロントエンド開発の複雑さがあまりない場合、Java開発者がフロントエンド開発も兼ねる場合に重宝する。例えばsrc/main/typescriptにTypeScriptを、src/main/sassにSassを置くような体制。

プロジェクトルートにpom.xmlsettings.gradleを置いてフロントエンド用モジュールをMavenのサブモジュール/Gradleのサブプロジェクトとして扱うこともできるが、そこまでやるならば次に挙げる手法2で充分であろう。

なおフロントエンドの成果物をMaven Repositoryにzipしてデプロイし、他プロジェクトから利用するということを以前試したことがあったのだが、zip/unzipを多用し性能が出ないのでおすすめしない。普通にファイルコピーで済ませるために、利用者(サーバサイド)と同一プロジェクト(Gitリポジトリ)に置くようにしたほうが良い。

1.2. サーバサイドをYarnで包んだMaven/Gradleで、フロントエンドをYarnで開発するケース

逆にYarnを主、Maven/Gradleを従とする手法。サーバサイドが小さいときは使えるかもしれないが、そこまでするなら手法3が素直。

2. サーバサイドとフロントエンドでプロジェクトを分ける

各モジュールの開発技法において、自由度を確保することを意識した手法。それぞれ異なる開発者が請け負う場合にはこういった形を取ることが多いのでは。Gitリポジトリを分けるかmonorepoにするかという点も考慮が必要だが、自分の開発経験(小型中心)だと分割の必要性を感じたケースは無いので、ここではmonorepoという前提をおいて考える。またフロントエンド=ウェブアプリとし、モバイルアプリ開発については触れない。

サーバサイドとフロントエンドのつなぎ目には、OpenAPIなどのAPI定義手法を利用する。サーバサイドとフロントエンドを突合する部分(CD、プロビジョニングなど)が複雑化する可能性はある(例えばspring-boot-vue-exampleではserverとclientのデプロイにスクリプトを使っている)が、多くの場合でさほど問題ないのでは。プロジェクト構成例は以下の通り:

interface/
  openapi.yml

server/
  src/
  pom.xml // 最終成果物は .war, executable .jar あるいはコンテナ

frontend/
  src/
  package.json // 最終成果物は .zip あるいはコンテナ

docker-compose.yml // あるいはTerraformとかCloudFormationとか?
README.md

この場合、フロントエンドの成果物はサーバサイドの成果物に含めない。またフロントエンド開発にサーバサイドの挙動をmockする必要があるが、Open APIならmock serverを利用できるし、その他のAPI定義手法でも似たものがあるはず。

3. サーバサイドをNode.js化するという選択肢

これはタイトルに全く沿わない手法であるが、真面目にありがちな話だと感じている。つまりJavaJavaScriptという異なる言語を同一プロジェクトで使用することが複雑さを生むならば、その原因を取り除けないだろうか?ということである。

そもそもNode.jsが発展しTypeScriptのような型を使った開発も可能な現代において、サーバサイドとフロントエンドで異なる言語を使うモチベーションがどの程度あるだろうか?

サーバサイドをNode.jsにすれば、プロジェクトの複雑さを除くことができる。性能に直結する非同期処理もCompletableFutureRxJava、spring-webfluxで使われるReactorに比べればJavaScriptasync/awaitの方が簡潔になるし、何よりJavaScriptは言語自身が「メインスレッドをブロックしない」ことを前提に設計されているので、うっかりblocking処理をサーバ内に書くことを避けやすい。 もちろんJavaの方がやりやすいこともたくさんある。またJVMの進展は非常に目覚ましいものがある。運用の知見など積み直しになるものもあるため即置き換えとは行かないし、する必要もない。結局道具は適材適所なので、Javaエンジニアだと自分を狭く定義するのではなくて、JavaもNode.jsもTypeScriptも学んで使いこなせばいいし、KotlinやDartのような新しい言語とそのコミュニティにも目を向けていければ良いのだと思う。

まとめ

小型のプロジェクトでは手法1.1、専任のフロントエンド開発者がいる場合は手法2を使うことをまず検討すると良いだろう。