Kengo's blog

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

「ドメイン駆動設計入門」付録のGradle向け解釈

ITエンジニア本大賞2021で紹介されていた「ドメイン駆動設計入門」(以下、本書と呼ぶ)が、DDDを学ぶ上でわかりやすかったです。一応他のDDD本も数冊読んではいたのですが、どうしてもユビキタス言語や境界づけられたコンテキストなど”場合による”ものが頻出してしまい、結局どうすればいいんだ……となって手が動きにくかったのです。よくわからない概念の上にさらにわからない概念を積み重ねるので、どんどん混乱する感覚がありました。

反面、本書では副題の通りボトムアップ形式を採っているため、実際にどう手を動かせば良いのか細かく確認できました。また不明点を最小化しながら進むだけでなく、その概念を導入することでどんな問題が解決されるのかが実例をもって明示されており、私のような独学派にはとてもありがたかったです。

さて本書ではC#を使っています。Javaとたいして変わらないとはいえ細かいところは違ってきますので、ここでは付録で紹介されたソリューション(プロジェクト)構築方法をGradleユーザ向けに解釈したものを紹介します。なおウェブアプリケーションフレームワークとしてspring-bootを利用するものとしますが、他のフレームワークでも大差ないはずです。

サブプロジェクトによる分割

Mavenではサブモジュール、Gradleではサブプロジェクトを使ってプロジェクトを分割します。本書ではプロジェクト分割の方針を2つ紹介していますが、ここでは簡単のためすべてのレイヤが固有サブプロジェクトを持つ構成を説明します。

domainサブプロジェクトには値オブジェクトやエンティティ、ドメインサービス、仕様やリポジトリインタフェースを配置します。ほぼPOJOで済むため、依存はspring-contextのようなアノテーションのみとなるでしょう。

ドメインオブジェクトの振る舞いの多くがここに配置されるため、Javadocやunit testを優先的に充実させる必要があるでしょう。また equals()hashCode()を手書きするとコードやテストカバレッジの見通しが悪くなることから、Immutablesのようなコードジェネレータによって対策することも考えられます。

infrastrutureサブプロジェクトはdomainサブプロジェクトに含まれるリポジトリインタフェースの実装を配置します。このサブプロジェクトはJDBCドライバのような依存先ミドルウェア固有の依存を持ちます。productionで利用する実装に加え、オンメモリで動作する実装も別途用意することで、unit testを書きやすくできます。

このサブプロジェクトではクラスをpublicにする必要はありません。Springが自動的に@Repositoryで修飾されたクラスを見つけてインスタンスを作るためです。他サブプロジェクトのunit testから呼び出す必要がある場合のみ、publicにすると良いでしょう。

applicationサブプロジェクトはdomainサブプロジェクトとinfrastructureサブプロジェクトの双方に依存します。アプリケーションサービスによるユースケースの記述をメインとするサブプロジェクトです。

本書の方針に従うならば、このサブプロジェクトに含まれるクラスやメソッドのAPIにはドメインオブジェクトを露出させないことが望ましいと言えます。このため他サブプロジェクトには api configuration ではなく implementation configuration を使って依存します。これによりドメインオブジェクトの露出をコンパイルエラーとして発見できる可能性が上がります。詳細は後述します。

presentationサブプロジェクトはapplicationサブプロジェクトにのみ依存します。後述するGradleの機能により、presentationサブプロジェクト内ではドメインオブジェクトやRepositoryの実装に触れない状態を担保できます。MockMvcなどを使うことでunit testが重くなり、またこのサブプロジェクトをビルドしている際はGradleのworkerが空きがちなので、maxParallelForksオプションでテストを並列実行することの恩恵が大きいでしょう。

apiとimplementationの使い分け

ここまでで2つ、詳細を後述すると述べたことがあります:

  1. presentationサブプロジェクトに対するドメインオブジェクトの露出をコンパイルエラーとして発見する方法
  2. presentationサブプロジェクト内ではドメインオブジェクトやRepositoryの実装に触れない状態を担保する方法

これらは表現こそ異なりますがひとつの課題を示しています。すなわち「ドメインオブジェクトをpresentationサブプロジェクトに露出させたくない」です。 もちろん依存先のpublicフィールドや戻り値がドメインオブジェクトでないことをArchUnitなどで確認しても良いですが、Gradleならこれらをコンパイルエラーとして発見する方法があります。

API and implementation separationがこの課題に対する解決になります。これはAPIとして外部に露出している依存と、そうではない内部利用している依存とを明確に分けて定義するものです。つまりサブプロジェクトが内部利用している依存は、そのサブプロジェクトの利用者に対して開示する必要はないという考えです。

これにより、applicationサブプロジェクトが依存しているdomainサブプロジェクトやinfrastructureサブプロジェクトを、presentationサブプロジェクトのコンパイル時クラスパスに含めずプロジェクトをビルドすることができます。 依存に色を付ける必要があるという意味で手間は増えますが、そもそもAPIとして露出している依存は減らすべき(JLBP-2)という話もありますので”依存がAPIとして露出しているのか?”を意識することは有用です。Gradle公式ドキュメントによるとコンパイルのパフォーマンス改善も期待できるケースがあるそうです。

例えば以上のサブプロジェクトの依存関係を図示すると、以下のようになります。

f:id:eller:20210131174527p:plain
com.savvasdalkitsis.module-dependency-graph によって生成

api configurationを使うのは1箇所だけです。infrastructureサブプロジェクトに含まれるクラスはdomainサブプロジェクトに含まれるリポジトリインタフェースを実装しており、またドメインオブジェクトをその引数や戻り値に使っているため、api configurationを使って依存します。その他はすべて implementation configurationが使えます。

補足: アセット生成を独立したサブプロジェクトの責務とする理由

前出の図に含まれているfrontendサブプロジェクトは、HTMLやJSなどのアセットを生成するためのものです。ドメイン駆動からは外れますが解説します。

このサブプロジェクトだけ若干特殊で、npmやyarnのプロジェクトをfrontend-gradle-pluginを通じてビルドする作りになります。ReactやVueの開発をGradleで無理やり実現するのではなくnpmあるいはyarnを呼び出す形を取ることで、JavaScript開発の知見を無理なく導入するとともにフロントエンド開発者にGradleやJavaの学習を強要する必要性を減らせます。

ちなみにビルド性能の向上も期待できます。これはfrontend-gradle-pluginのタスク、特にinstallFrontendタスクが重いことと、Gradleは異なるプロジェクトに属するタスクだけparallel buildできることから、説明できます。なおプロジェクトと同数以上のworkerがいないと性能が出ないので、--max-workersオプションを使うかorg.gradle.workers.maxプロパティを使って明示的にワーカー数を引き上げておくことが望ましいです。以下に手元の環境でhyperfineした結果を貼っておきます:

Benchmark #分割前: ./gradlew clean build
  Time (mean ± σ):     28.356 s ±  2.624 s    [User: 1.419 s, System: 0.145 s]
  Range (min … max):   25.914 s … 33.624 s    10 runs

Benchmark #分割後 (4 workers): ./gradlew clean build
  Time (mean ± σ):     27.161 s ±  3.987 s    [User: 1.388 s, System: 0.137 s]
  Range (min … max):   23.202 s … 33.721 s    10 runs

Benchmark #分割後 (5 workers): ./gradlew clean build --max-workers=5
  Time (mean ± σ):     24.442 s ±  2.916 s    [User: 1.321 s, System: 0.135 s]
  Range (min … max):   21.799 s … 31.388 s    10 runs

まとめ

ドメイン駆動設計入門」付録のGradle向け解釈を述べました。本書は私のような、ドメイン駆動を手を動かしつつ独学したい方におすすめします。

本投稿が本書の内容を実践する上でGradleユーザの参考になれば幸いです。またGitHubに私の書いたプロジェクトを置いてありますので、具体的にプロジェクト設定を見てみたい方は参照ください。

技術書「JavaのビルドとCIのキホン」を公開しました

zenn.dev がホットなのでフォロワーの皆様にアンケートを取った結果、「JavaのビルドとCIのキホン」が5票を獲得したので書きました。

書籍はこちらです。

zenn.dev

zenn.dev上では約26266文字と言われてるんですが、いや流石にそんなに書いてないはず。まえがきと付録を除いて6章構成です。 初心者向けを意図していますが、経験者でないとわからない論理的飛躍というか、結果ありきの書き方になっているところが残っているかもしれません。しばらくは継続的に更新します。

マイクロサービス時代のアプリケーションサーバ実装について

4年前の下書きが出てきたので、供養のために置いておきます。


SpringOne Platform 2016KeynoteとSessionを、特にProject Reactor周りについて確認した。

これらに最近考えていたことを加えて、マイクロサービスやサーバサイドリアクティブについて(利点や必要性は一旦論点から外したうえで)実現するためのあるべき論をざっくり整理したい。プライベートプロジェクトで検証済みではあるがチーム開発でも通用するかは不明である。

なお簡単のためRxJava用語のみ記述するが、Project Reactor等の用語で置き換えても良い(SingleMono, ObservableFlux)。

あるべき論

APIサーバ&BFF共通

  • Repository(DAO)
    • 境界を超えないデータストアに対するアクセスを担うレイヤのこと。
    • Repositoryのメソッドは非同期I/Oを呼び出すことが期待される。よってSingleあるいはObservableを返すべき。
    • 戻り値がない場合(例えば更新処理)でも、内部の処理が正常に終了したかどうかを表現するために Single<Void> を返すべき。
    • 非同期I/Oを呼び出さないことが明らかな場合のみ、同期的にインスタンスを返しても良い。
  • Service(ビジネスロジック
    • ServiceのメソッドはDAOを通じて、あるいは境界外へアクセスするクライアントを通じて非同期I/Oを呼び出すことが期待される。よってSingleあるいはObservableを返すべき。
    • 戻り値がない場合(例えばジョブキューにジョブを登録する場合)でも、内部の処理が正常に終了したかどうかを表現するために Single<Void> を返すべき。
    • Repositoryから渡されたSingle/Observableのエラー処理(ログ出力等)は、
      • その Single/Observable がControllerから利用されるのであれば、Service内で行っても行わなくても良い。
      • その Single/Observable がControllerから利用されないのであれば、Service内で行う。
    • Serviceは冪等性を保つ必要がある。UUID version 1でID採番する場合などは、処理結果にランダム失敗した性が含まれるので扱いに注意する(Eventual Consistencyを意識する)。
  • Client(境界外アクセス)
    • RestTemplate等を直接呼び出す実装が多く見受けられるが、サーキットブレーカーや監視や自動テスト容易性を考えると一枚抽象化を入れた方が良いと思われる。
    • 各マイクロサービス提供者がEntityと共にユーザに提供する。Serviceから呼び出され、HTTP等によるRPCを行う。よってSingleあるいはObservableを返すべき。
    • コレオグラフィ優先採用するならば、基本的には使用しない方が良い。
  • EventBus
    • Serviceによって購読ないしpublishされる。
  • Controller
    • モデルとしてSingleObservableをテンプレートエンジンに渡す。テンプレートエンジンの機能に応じて、SingleObservableを変換するかもしれない。CompletableFutureあたりが有望か。
    • 通信先サービスが正常動作しなかった場合、CircuitBreakerが落ちている場合のUIも考えておく。

BFFサーバ の実装

WebAPIを多数コールして1つのレスポンスにまとめるには、大きく分けて2つ実装パターンが考えられる。

  1. サーバ内ですべてのサービスからのレスポンスを束ね、1枚のHTMLページやJSONデータをつくり上げる
  2. サービスから逐次受け取ったデータをクライアントに横流しして、クライアント側で組み立てる(Server-sent Event, WebSocket, Big pipe etc.)
    • 2020年夏時点ではJSON listをうまく扱うJS手法はまだなさそう?Streams APIの安定化を待つ必要があるのでは。

完全な2だとBFFを作る意味が無いので、1を主体として見た目に影響の薄い部分で2のような遅延処理をを採用することが多いはず。しかし1のレスポンスを束ねる部分で各サービスに線形的に問い合わせてはレイテンシ低下が容易に発生するため、非同期I/Oを使った並行問い合わせが必要になる。

サーバ間連携

  • メッセージキューには永続性が必要
    • コレオグラフィを志向してサービスを実装するためには「イベントを永続化してサブスクライバが何度も読みに行く」か「サービスを冪等にしてパブリッシャが何度もリトライする」かのどちらかになる。後者はパブリッシャがイベント発火によって行われる処理を知っている必要があるので、基本的には前者が好ましいと思われる(Webアプリだとパブリッシャが何度もリトライするような書き方だとユーザへのレスポンスが遅れるのも痛い)。
      • 2020年追記:一見意味不明だが、ここで言うサブスクライバとパブリッシャはサービス単位を指している(同一JVM内にあるインスタンスを比較しているわけではない)と思われる。前者はイベントが永続化することで、同じイベントを何度も聞きに行くケースのこと。複数種類のサブスクライバがいたり、サブスクライバが常時起動でなかったりするとこの必要性が生じるはず。後者は非同期処理を依頼する、例えば送信処理や投機的実行のケースで事実上リアルタイム性を求めているケースのこと。当時使ってたVertxがデフォルト設定で利用するhazelcastのイメージに引っ張られてそう。
    • このため、キューに記録されたメッセージは1回以上必ず読まれること、つまりロストがないことを保証したい。そのためにはキューに永続性が必要。
  • サブスクライバごとにイベントの既読管理を行う
    • コレオグラフィを採用するならば、1イベントにサブスクライバが多数いることも予想されるため、メッセージ(イベント)がConsumeされたかどうかはサブスクライバごとに管理する必要がある。
    • 例えばKafkaなら、イベント種別=Topic、サブスクライバ=Consumer Groupとすることでサブスクライバごとの管理が可能。

備考

RxJava v2, reactive-streams そして Java9 Flow API について

現状では気にしないので良さそうな印象。

operatorすら用意されていないシンプルなreactive-streamsが、それだけ見ていれば詳細実装を気にしないで良いレベルのインタフェースになることは当面ないだろうし、そもそもそこを目指していないように見受けられる。ライブラリ提供者にとっては各プラットフォームを公平にサポートするための良いインタフェースになるだろうが、サービス開発者にとってはRxJavaやProject Reactorに直接依存・利用したほうがコードもシンプルになるし利用できる機能も多く便利なはず。

参考:

CompletableFutureの何が嬉しいか、そしてReactorへ……

DBにクエリを2つ投げて合成する処理

例えばユーザーIDから所有するリソースを検索してその情報を返すAPI Stream<Resource> find(long userId) を実装するとします。Resourceは大量に帰ってくる可能性があるので、List<Resource>ではなくStream<Resource>として扱います。実装はどうなるでしょうか:

// blocking API (wrong)
try (UserDao userDao = daoFactory.createUserDao();
    ResourceDao resourceDao = daoFactory.createResourceDao();) {
  Stream<Long> resourceIds = userDao.search(userId); // ページング処理を隠蔽
  return resourceIds.map(resourceId -> {
    Resource resource = resourceDao.find(resourceId);
    return resource;
  }); // Stream<Result>
}

これは正常に動作しません。メソッドを抜ける前にtry-with-finallyブロックがDAOを閉じてしまうからです。 呼び出し元がStream<Resource>を閉じてくれることを期待して、以下のようなコードになるでしょう:

// blocking API
UserDao userDao = daoFactory.createUserDao();
ResourceDao resourceDao = daoFactory.createResourceDao();
Stream<Long> resourceIds = userDao.search(userId); // ページング処理を隠蔽

// Streamが閉じられたらDAOも閉じる
resourceIds.onClose(userDao::close);
resourceIds.onClose(resourceDao::close);

return resourceIds.map(resourceId -> {
  Resource resource = resourceDao.find(resourceId);
  return resource;
}); // Stream<Result>

このように処理を遅延するコードはFutureでは書けません。Java8で登場したCompletableFutureを使うことになります。例えばUserResourceがひとつだけ結びつく状態なら以下のように書けます:

// CompletableFuture
UserDao userDao = daoFactory.createUserDao();
ResourceDao resourceDao = daoFactory.createResourceDao();
CompletableFuture<Long> future = userDao.search(userId).whenComplete( ... );

return future.thenApply(resourceId -> {
  Resource resource = resourceDao.find(resourceId);
  return resource;
}).whenComplete( ... ); // CompletableFuture<Result>

が、Streamのように複数の値を返すモノに使うのは複雑な工夫が必要ですし、やりようによってはすべての値を一度にオンメモリに乗せてしまいます。 私の知る限りではReactorフレームワークやRxJavaの採用がこの問題へのスマートな解になります。例えばFlux#using()を使うと以下のように書けます:

// Reactor
Flux<Long> resourceIds = Flux.using(daoFactory::createUserDao, userDao -> {
  return userDao.search(userId);
}, UserDao::close);

return resourceIds.flatMap(resourceId -> {
  Mono<Resource> resource = Mono.using(daoFactory::createResourceDao, resourceDao -> {
    resourceDao.find(resourceId);
  }, ResourceDao::close);
  return resource; // Flux<Resource>
});

関連記事

blog.kengo-toda.jp

Javaライブラリを配布する際のログ周りにおける配慮と実践 2020

この記事は、2013年に書いた記事を現状に合わせてアップデートするものです。結論から言うと、当時から id:miyakawa_taku さんがおっしゃっていた「APIは依存に含めて良い」を支持するものです。あるいは無難にバージョン 1.7.30 を使っておきましょう。

blog.kengo-toda.jp

slf4j-apiに1.7, 1.8, 2.0の3バージョンが生まれた

現在slf4j-apiには3つのバージョンが存在します。現在のSLF4Jエコシステムを考える上では、これらの違いを抑える必要があります:

1.7.x
Java 1.5から利用できるバージョンです。安定版にこだわるなら未だこのバージョンを使う必要があります。
1.8.x
JPMS(a.k.a. jigsaw)を採用しServiceLoaderを使ってBindingを呼び出すようになったバージョンです。Java 1.6が必要です。alpha0が出てから3年以上経ちますが、未だbeta4です*1。作業用ブランチも残っていないので、1.8は安定しないまま2.0開発に移ったものと思われます。
2.0.x
このバージョンからはJava 8が必要です。Fluent Logging APIが実装され、ログの書き方の選択肢が増えました。新しい実装はinterfaceのdefault methodを使って実装されているため、slf4j-api単体で変更が完結しており、binding側は変更なく利用できます。

複数バージョンの存在を踏まえ、ライブラリ配布者は何を気にするべきか

前出の情報を紐解くと、3バージョン間に眠る2つの差が見えてきます:

  1. APIの差。slf4j-api 2.0.x には従来存在しなかったメソッドが増えている。これに依存したライブラリのユーザは、slf4j-api 2.0.xを利用しなければ実行時に NoSuchMethodError が出るかもしれない。
  2. binding選択手法の差。slf4j-api 1.7.x と1.8.x以降では実装を決定するための手法が異なるため、apiとbindingのバージョンは揃える必要がある。

このうち2については、アプリケーションをビルドする開発者に責任があります。実行時にCLASSPATHにどのバージョンのAPIとbindingを含めるか、彼らに決定権があるからです。のでライブラリ提供者としては、彼らに選択肢と必要な情報を提供しつつ、1に取り組む必要があります。

考慮すべきはライブラリでFluent Logging APIを使っているかどうか、です。このAPIに依存していないなら、実行時にslf4j-apiのどのバージョンを利用しても問題ありません。

もし使っているなら、その旨をユーザに伝えて実行時にもslf4j-api 2.0.xを使ってもらう必要があります。そしてそのための最もsemanticな方法が、compileスコープで依存することです。ライブラリが2.0.xに依存しているにも関わらずに古いバージョンで依存が上書きされてしまった場合、ビルドツールによっては警告を発してくれます。Gradleの場合はもっと細かな依存指定も可能です。

よって、現時点では slf4j-api には compile スコープで依存することが望ましいと思います。bindingは引き続きprovidedあるいはtestスコープが望ましいでしょう。

どのバージョンを使うべきか

安定版にこだわるなら1.7.xを、JPMSを使いたいなら1.8.xか2.0.xを、Fluent Logging APIを使いたいなら2.0.xを選ぶことになるでしょう。

1.8.xと2.0.xはJPMSに準拠しており、モジュールを利用したビルドを行っているプロジェクトでも安心して使うことができます。ただどちらも安定版が出ていない点には注意が必要です。

2020-08-07 追記: 1.8を選択する積極的な理由がないことを以下の記事に記載しました。 SLF4Jの1.8は安定版が出ないので2.0を使おうという話 - Kengo's blog

マイナーケース:ライブラリとしても実行可能ファイルとしても配布する場合

私がメンテに参加しているSpotBugsは、spotbugs-gradle-pluginやspotbugs-maven-pluginなどから利用されるライブラリとしての性質と、CUIGUIで実行される実行ファイルとしての性質とを持ち合わせています。内部的にはDistribution Pluginで配布用パッケージを作成しています。

この場合、Maven Centralにアップロードする pom.xml にはSLF4J bindingを依存として追加しない一方で、distribution pluginの対象にはSLF4J bindingを追加する必要があります。

runtimeOnly 依存を使うことが素直な解決になるでしょう。configurations.runtimeClasspath を配布用ファイルに入れる形になります。

ただSpotBugsの場合はEclipse用依存を別に管理している(Eclipse用のruntimeClasspathにはSLF4J bindingを入れたくない)ので、以下のようにconfigurationを自前で定義しています

plugins {
    id 'distribution'
}
configurations {
  slf4jBinding
}
dependencies {
  implementation 'org.slf4j:slf4j-api:1.8.0-beta4'
  logBinding ('org.apache.logging.log4j:log4j-slf4j18-impl:2.13.3') {
    exclude group: 'org.slf4j'
  }
}
distributions {
  main {
    contents {
      from ([configurations.compileClasspath, configurations.slf4jBinding]) {
        into 'lib'
        include '**/*.jar'
      }
    }
  }
}

*1:newsページにはbeta5が公開された旨が記載されていますが、Maven Centralには来ていませんし、タグも残っていません

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を使うことをまず検討すると良いだろう。

Javaプロジェクトにおけるリリース周りの手法あれこれ

考慮する点

成果物のデプロイ

ビルドの成果物(artifct)をアップロードすること。アップロードと公開は分けて考えることに注意。デプロイ先にはいくつか候補がある:

  1. GitHub Packages (旧GitHub Package Registry)
  2. Maven Central Repository
  3. Docker HubなどのDocker Registry

GitHub Packagesはコンテナも.jarもまとめて置けるが、コミュニティ標準の場所ではないので利用する際にひと手間必要になる。プライベートプロジェクトの場合は積極利用することになりそう。FOSSなら基本的にMaven Centralに置くことになる*1。プロジェクトによっては.jarファイルとしてではなくコンテナとしてデプロイすることもあるだろう。

リリースノートの作成

CHANGELOG.mdsrc/site以下のファイルを手動でメンテナンスする手法だけではなく、コミットコメントやPRからリリースノートを作成する手法もある。

  1. 手動管理
  2. CHANGELOG.md などファイルの自動生成
  3. GitHub Releasesによるリリースノート自動生成

特に開発者向けであれば、CHANGELOG.mdではなくGitHub Releasesを使うことで、変更内容の伝達を自動化できる。例えばDependabotは依存先の更新を提案する際にリリースノートの内容をPRに含めてくれる。ファイルの頒布が必要な場合でも、GitHub Releasesも合わせて使ったほうが良い。

バージョンの管理

利用者の利便性のため、Semantic Versioning 2.0を使うことが前提になる。ただ混乱を避ける意味でもバージョン 0.x.x の利用は避けたほうが良いかもしれない

  1. 手動管理
  2. 自動的に決定する

バージョンを決める作業自体は自動化が進んでいて、Conventional Commitsから生成するものがsemantic-releaseだけでなく様々あるので使ったほうが良い。私はプラグイン機構がしっかりしているのでsemantic-releaseを使っている。

プロジェクトのバージョンをpom.xmlbuild.gradle, gradle.propertiesといったファイルで管理するのが普通だが、最近はGitのTagを見るものもある。Tagを見ることで「ファイルに書いてあるバージョンをインクリメントする」ためのコミットが不要になるのが利点。例えばDraft Releaseを本Releaseに昇格させる(ブランチの最新コミットにタグを打つ)ことで公開処理を回す(tagイベントに紐付いたワークフローを回す)処理がシンプルになる。のだが、SNAPSHOT運用と相性が悪いのと、ファイルに書かれたversionの信頼性がなくなるのとで、あまりJavaプロジェクト向きではないと考えている。

各手法について

Maven (maven-release-plugin)

mvn release:prepare && mvn release:performを実行するだけで必要な処理が完結する。Maven公式の機能で完結し、歴史も長いのでノウハウも蓄積されている。

シンプルだが、Tag作成をリリースの起点にできない(Tag打ちがプラグインによって行われる)ために自動化には向かない。多くの場合、リリース用の(Jenkins)Jobを作って手動で蹴るような運用になるのでは?pom.xmlの更新とGitへのpushがプラグインによって行われることもあり、自動化の際はプラグインによるコミットメッセージに[skip ci]を含めたり設定を変更したりといった工夫が必要になる。

Maven (maven-deploy-plugin)

Mavenでリリースを自動化する場合はmaven-deploy-pluginによるデプロイ手法を採ることになる。特にmaven-versions-pluginのsetゴールと組み合わせることが多いはず。

リリースに使用するバージョンをTag名などを経由してnewVersionプロパティに渡してversions:setを実行、deploy:deployでデプロイした後に再度versions:setでSNAPSHOTバージョンに変えてGitにpushする運用。release-drafterが使えるのでリリースノート管理もしやすい。

この場合でも、Tagが打たれたrevisionのpom.xmlには実際にリリースされたversionとは異なる値が書いてあるはず。versionの信頼性という点で問題が残る。

Maven (maven-semantic-release)

version mismatchが気になる場合は、semantic-releaseの利用を検討する。これはmasterブランチにpushされた変更をすべてリリースするという手法である。

バージョンの決定はsemantic-release本体が、リリースノートの作成は@semantic-release/github@semantic-release/release-notes-generator@semantic-release/changelogが、GitのcommitやTag打ちやpushは@semantic-release/gitが行うことになる。すべてのプロセスが自動化されるので、運用の負担は最も低くなる。またversionの信頼性も確保できる。

Conventional Commits(厳密にはAngularのルール)の利用を全開発者に強制しなければならないこと、Javaプロジェクトなのにpackage.jsonを使わなければならないこと、masterブランチを常にリリース可能にすることが許容できるならばこの手法が最善と思われる。

ライブラリはともかくアプリやサービスだとPR毎リリースが受け入れられないことはあると思うが、こちらのFAQに目を通した上でも必要と判断されるなら、リリースを意図的なタイミングでトリガする手法を取り入れることもできる。

Gradle (gradle-semantic-release-plugin)

semantic-releaseはGradleでも使える。詳細は以前の投稿を参照。

Jib

GitHub repoにBuild container images for your Java applicationsと書いてあるように、Javaアプリをコンテナ化するときに使えるツール。Maven/Gradle両対応。デフォルトでもコンテナのレイヤをうまく分けたり、warプロジェクトならJettyを同梱したりしてくれる。

依存を必要としないのはありがたいが、dockerひとつ追加するだけならあまり苦ではない(GitHub Actionsだとデフォルトで付いてくる)のでそこまでの利点という感じではない。ドキュメントにちょいちょいKubernetesとの組み合わせ方について言及されているので、Kubernetes使いにとってはやりやすいところがあるのかも。

Docker

MavenやGradleを使ってデプロイするのではなく、CIサービスでdockerコマンドを実行する。Maven/Gradleはpackage/assembleするまでが責務で、デプロイはdocker pushで行う運用になる。前述の通りdockerコマンドの導入は多くのCIサービスでfirst classのサポートを受けているので、セットアップは非常に楽。最近はmulti-stage buildもあるので最終成果物も充分に小さくしやすい。

一見やりやすいが、開発作業中にdockerコマンドが実行されない可能性をはらんでいる。これは開発者が触るものがリリースされるものと異なるものになるということで、微妙なリスクになる。開発作業中もmvnw/gradlewではなくdockerコマンドを使うようにする(付随する課題も解決する)か、コンテナにデプロイされたアプリを使うE2Eテストを早期に統合すると良いかもしれない。

なおイメージを小さくするのに jlink を試してみたいのだが、現状ではalpineで動くOpenJDK(Portolaプロジェクト)が公開されてない?ようで、2018年5月時点で有効だった手法が使えなくなっている。のでalpineイメージをベースにするためにはDockerfileがけっこう複雑かつ管理の難しい状態になってしまうのが難点。この辺の情報はおそらくこの2018年11月時点のブログ記事が最新。

docker-compose

コンテナの利用をもう一歩進めて、データストアやHTTPサーバもコンテナ化してアプリとの依存関係やそのバージョンをdocker-compose.ymlで管理する。 ここまで行くとより良いデプロイ手段というよりは、より良いプロビジョニングやポータブルな実行環境の方を実現したい段階だと思われる。

注意することはDockerを単体で利用する場合と同様だが、アプリのコンテナが依存するコンテナ(データストアなど)をアプリをビルドするたびにビルドするような運用にならないように注意する必要がある。具体的には、ADDするファイルを最小限にすることで、スキーマ定義やマイグレーションスクリプトが変更されたときなど、必要なときだけ依存するコンテナがビルドされるようにする。

追記 2019-12-12

buildpacks.ioでHerokuで使えるbuildpackを標準化しようとしているらしい。

speakerdeck.com

speakerdeck.com

*1:Maven Centralも十分に早いしHTTPSもサポートされたので、個人的にはJCenter使うモチベーションがない