Kengo's blog

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

エコシステムにビルドツールがたくさんあるのは悪いことではない

JavaやNodeJSには多数のビルドツールがあります。ものによってはビルドツールではなくタスクランナーとかワークフローとか名前が付いてるかもしれませんが些細なことです、ここでは以下のようなツールのことをまとめてビルドツールと呼びます:

一方で言語公式のビルドツールを用意している言語もあります。これによってプロジェクトごとに異なる技術を学ぶ必要性が減りますし、一貫性のある開発体験を得ることができます。javac javadoc のような単純なコマンドしか提供しないJavaとは異なる方針を言語として持っていることは明らかでしょう。

では言語のエコシステムにビルドツールがたくさんあることはモダンではなく不便なのでしょうか?そんなことはないだろうというのが自分の考えです。もちろん欠点がないとは言いませんが、以下に私見を述べます:

プロジェクトによってビルドツールに求められる役割は異なるため、きめ細かな選択肢を選べる

例えばプログラマが若干名のプロジェクトでは、コンパイルやテストが一箇所にまとまっていてフットワーク良く改善を回せていけることが望ましいでしょう。複数リポジトリやサブプロジェクトを作る必要性もまだ薄いでしょうし、そこまで統制について考えることもありません。自分なら開発が活発でパフォーマンスも良いGradleを選択することになると思います。

一方で何百人ものプログラマが関与するプロジェクトでは、ビルドツールやワークフローについても統制を考えるケースが出てきます。 mvn test を実行したらテスト実行結果が必ずJUnitのXML形式で target/surefire-reports/TEST-*.xml に吐き出されなければならないとか、Reproducible Buildsに準拠するとか、developブランチにマージしたらSonarQubeを実行せよとか、ビルドするにはJava 8を使わなければならないとか、そういったベースとなる要求をすべてのプロジェクトに守らせることでリポジトリ横断的な品質改善に役立てたりするわけです。

今だとこういった要求もGradleで満たせそうですが、7年くらい前?に自分が似た状況にあったときは、Mavenのparent projectによる制約の中央管理とバージョン管理が非常にマッチしました。DSLがないので自由度が低く、統制側としては考慮すべきことが減るというのもあります。中央管理する以上は各リポジトリの困りごとをきちんと拾い上げる姿勢は必要になりますが、その工数を考慮してもMavenに軍配が上がることはあるでしょう。

ビルドツールの思想に種類があることを学べる

そもそもこうした違いはどこから生じるのでしょうか。モダンな技術を使って開発された新しいビルドツールは常にレガシーなものよりも優れているべきではないのでしょうか?実はそうではなく、むしろ最もレガシーなApache Antと最もモダンなGradleはかなり近い特徴があります。

Apache AntとGradleはタスクを繋いで有向非巡回グラフ(DAG)を作るという発想で作られています。テストはテストケースのコンパイルに依存し、テストケースのコンパイルは実装のコンパイルに依存し、実装のコンパイルはアノテーションを使ったコード生成に依存する……といったタスクの間の依存関係を明示することで、タスクを並列実行したり不要なタスクの準備を省いたりして高速化ができるのです:

graph LR;
  annotation-processing --> compile --> testCompile --> test --> check --> build;
  compile --> jar --> assemble --> build;

特定のタスクだけ実行する・特定のタスクだけ除外するといった操作も簡単に行なえます。プロジェクト固有のタスクや概念を導入することも容易ですが、一方でタスク実行時に必要な入力がすでに生成されているかどうかを管理するため、タスクの入出力を宣言したり、タスクが依存するタスクを明記する必要があります。DAGをメンテナンスする責任をユーザが負い、それを前提にタスクの内部実装を気にせずに済むようになっています。

Mavenはビルドライフサイクルという概念があり、すべてのプロジェクトはこのライフサイクルに従うことを期待されています。ビルドライフサイクルをゼロから作ることも可能ですが、かなり重い作業です。

ビルドライフサイクルにはフェーズが定義されており、このフェーズにプラグインのゴールを紐付けることで、どのようなプロジェクトでも同じビルドライフサイクルで臨んだ結果を得られるようにしています:

graph LR;
  subgraph compile
    compiler:compile
  end
  subgraph test-compile
    compiler:testCompile
  end
  subgraph test
    surefire:test
  end
  subgraph package
    jar:jar
  end
  compile --> test-compile --> test --> package

そのフェーズに入った時点で以前のフェーズはすべて完了していると信じられるため、ゴールの入力がすでに生成されているかを気にする必要は比較的薄いでしょう。フェーズ内で実行する処理に依存関係がある場合、 compiler:compile ゴールがアノテーションプロセッシングとコンパイルの両方を行うように、ひとつのゴールにまとめてしまうことで単純化します。

一方でやはり柔軟性には欠けます。ライフサイクルの一部だけ実行したい場合、例えばテストを再実行してレポートを生成する場合など、必要なプラグインのゴールを特定してそれを直接実行しなければなりません。逆にテスト以外のすべてを実行する場合も、プラグインの実装を理解して -DskipTests オプションを指定するといったことも必要です。依存先のゴールを自動的に推定・実行することもないためゴールの入力が不正になることも多く、昔は「とにかく mvn clean してやりなおす」ということもよくやっていました。おそらく多くのMavenプロジェクトでは、開発時の混乱を避けるためにREADME.mdCONTRIBUTING.mdにこういうときはこうするというコマンド一覧が載っていると思います。

長くなりましたが、すべての状況にマッチするツールが存在しないのは、ツールの根底にある思想によって適した現場がそれぞれ異なるからだと考えられます。これらの思想そのものは20年以上変化していない時代の荒波に揉まれたものですので、一長一短はあれど使い所が合えば価値の高いものだと言えます。ビルドツールの多様性は、それすなわち言語の活用幅の広さだということなのでしょう。

単に歴史が長いのでビルドツールが数多く生まれてきた

特にJavaは言語としての歴史が長いので、多くのビルドツールが作成され検討されてきたという側面はあると思います。例えばApache Antを使っているfb-contribは2005年からあります。当時からJavaのビルドツールが成長せず、Antだけでここまで来れたかというと、ちょっと考えられませんね。最近(と言っても8年前ですが)Java Moduleにネイティブで対応するビルドツールも提案されていたりして、今でも新しい形が模索されています。

それで言うと今はひとつしかビルドツールを備えていない言語も、もしかしたら今後はビルドツールが2つ3つと増えてくるかもしれませんね。どんなニーズにもひとつのツールチェインで応えようとすると収集つかないこともありそうなので。

とはいえ新しいツールに乗り換えたほうがいいこともある

古いビルドツールをずっと使い続けると技術革新の恩恵を得にくいみたいなところはありますので、ビルドツールをモダンなものに変えていく努力はしたほうが良いことはあります。これからも活発に変更を入れていくプロジェクトであれば特に、更新が活発なビルドツールに移行したほうが良いでしょう、私もFindBugsをSpotBugsにforkするときはAntとMavenを使っていたプロジェクトをGradleで書き換えるという経験をしました

異なる思想を持つツールに移行する場合は両方のツールに詳しくないと思わぬところで失敗するなんてこともあるので、一時的に有識者に手伝ってもらうことも検討しましょう。私もビルドツール移行の副業を受け付けております(唐突な宣伝):

youtrust.jp

まとめ

エコシステムにビルドツールがたくさんあることは悪いことではありません。キャッチアップが大変とか、コミュニティの知見が分散してしまうとかはもちろんあるのですが、コミュニティの抱えるプロジェクトの多様性を担保し、歴史あるプロジェクトと新鋭気鋭なプロジェクトとが同居する上でとても重要な貢献をしています。

キャッチアップコストが気になる場合はREADMEを整備するとか情報の多い新しめのツールに乗り換えるとか、自衛策を取ることもできます。最初からビルドツールがひとつだったら払わなくて済むコストでは確かにあるのですが、言語の歴史と実績に思いを馳せていただければと思います。

Maven4の動向メモ

Maven 4は2年近く前から開発されていて、最近alphaバージョンがリリースされています。自分がMavenを使うことは殆ど無いとは思うのですが、傾向だけでも把握しておきたくてリリースノートやメーリングリストに潜ってみました。

ざっと見た感じ、Mavenでマルチモジュールビルドをしている人や、Maven向けにプラグインや拡張を提供している人は動向を追ったほうが良さそうです。既にGradleに移行した人はあまり気にしなくて良いでしょう。

マルチモジュール周りの改善

以下のサイトで解説されています。他にも親POMのバージョンを記入しなくて済むとか、依存解決周りの改善が多そうです。

maarten.mulders.it

プロジェクト定義に変更を加えつつ、古いMavenからも使えるようにしたい

MNG-6656動画で基本設計が説明されているようです。POM 4.0.0の配布を継続しつつ、ビルド時には新しいプロジェクトオブジェクトモデルを使いたいようですね。ただXSDファイルはまだ https://maven.apache.org/xsd/ では公開されてなさそうでした。

Gradleが配布時にPOM 4.0.0に加えてメタデータを添付しているのと逆で、POM 4.0.0の定義に収まるように情報を削ぎ落として互換性を確保する感じと理解しました。POM 4.0.0という枠を維持するということは、例えばGradleがcompileをapiとimplementationに分けてABIの概念を持ち込んだような破壊的な変更はあまり考えていなさそうですが、少なくとも新機能を入れることはやりやすくなるでしょう。

Maven Wrapper

4.0で wrapper ライフサイクルが導入され、 mvn wrapper:wrapper ではなく mvn wrapper で実行できるようになるようです。 Maven Wrapper 3.1.1 のドキュメントに以下の記載がありました:

wrapper:wrapper is the default goal, invoked during the wrapper phase in Maven 4. It downloads and unpacks the maven-wrapper distribution,

ちなみに「3.7.0から入る」という古い情報もありますが、リリースノートによると3.7.0は廃止になって3.7の新機能は4.0に入れることになったそうです。

とはいえMaven 3には wrapper ライフサイクルが無いだけで wrapper プラグインの公式化は完了しています。よって mvn wrapper:wrapper がMaven3でも既に通りますので、実用上の問題はないと思います。

古い”API”のサポート中止

Maven 3.0 またはこれよりも古いバージョンのAPIを利用したプラグインのサポートをやめたい、という話が出ているようです。

github.com

個人的にはMavenにAPIなんて今までなかったよねという話がちょっと面白かったです。maven-plugin-apiというパッケージは存在するのですが、ちょっと複雑なことをするとすぐに実装詳細であるmaven-coreに依存する必要性が出てくるという話ですね。

実際alphaリリースには既存のプラグインが動かなくなる問題も報告されていて、publicなクラスを積極的に削除しているのは間違いないようです。プラグインや拡張を提供している人は、alphaはともかくbetaとかRelease Candidateとかが出てきたタイミングで動作確認をしたほうが良いかもしれません。

--add-exportsをMaven/Gradleで使う

--add-exports なんてオプションは使わないに越したことはないのですが、依存先ライブラリの都合でどうしても必要という私のような人のためのメモ。

ポイントは javac だけでなく javadoc ないし java (テスト実行)コマンドに対するオプション提供も必要という点です。Gradle用のサンプルプロジェクトMaven用のサンプルプロジェクトもあります。

コンパイラ用の設定

注意点として、--add-exports オプションは --release オプションと同時に使えません

Maven3

  <build>
    <plugins>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.9.0</version>
        <configuration>
          <source>11</source>
          <target>11</target>
          <encoding>${project.build.sourceEncoding}</encoding>
          <forceJavacCompilerUse>true</forceJavacCompilerUse>
          <showWarnings>true</showWarnings>
          <compilerArgs>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
          </compilerArgs>

Gradle

// build.gradle.kts
val exportsArgs = listOf(
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
)

tasks.withType<JavaCompile>() {
    sourceCompatibility = "11"
    targetCompatibility = "11"
    options.compilerArgs.addAll(exportsArgs)
}

javadoc用の設定

いろいろとオプションを指定する手法が提供されていますが、以下のように<additionalOptions>を使う方法での動作を確認しました。内部的にはOptionファイルを経由してjavadocコマンドのオプションを設定しています。

Maven

  <build>
    <plugins>
      <plugin>
      <artifactId>maven-javadoc-plugin</artifactId>
      <version>3.3.1</version>
      <configuration>
        <additionalOptions>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
        </additionalOptions>
      </configuration>
    </plugin>

Gradle

一気にややこしくなります。 options.addStringOption()では動作しないので、Maven同様にOptionファイルを経由しての指定が望ましいです。

// build.gradle.kts
val addExportsFile = file("$buildDir/tmp/javadoc/add-exports.txt")
val createJavadocOptionFile by tasks.registering {
    outputs.file(addExportsFile)
    doLast {
        addExportsFile.printWriter().use { writer ->
            exportsArgs.chunked(2).forEach {
                writer.println("${it[0]}=${it[1]}")
            }
        }
    }
}
tasks {
    withType<Javadoc> {
        dependsOn(createJavadocOptionFile)
        options.optionFiles(addExportsFile)
    }
}

テスト実行

Maven

surefireプラグインJVMをforkするなら、forkするJVM側にオプションを設定します。そうではない場合、以下のような .mvn/jvm.config ファイルでMavenを実行するJVM自体に設定を行います。

--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED

Gradle

jvmArgs プロパティを経由して設定します。このプロパティは標準でnullなので、ビルドスクリプトの他の箇所で設定していないなら .addAll() ではなく = を使って設定します。

// build.gradle.kts
tasks {
    test {
        useJUnit()
        jvmArgs = exportsArgs
    }

GitPodでJavaプロジェクトを開発する

GitHub Codespacesがなかなか個人向けに来ないので、changelog.comで宣伝していたGitPodを試しています。 どうも公式のJava向けの説明が古いようで、既にDeprecatedになっているtheiaを前提としているため、調べたことをメモしておきます。

最新のJavaを使う

普通に gitpod/workspace-full イメージ内でJavaを起動すると、Zuluの11が使われていることがわかります:

$ java --version
openjdk 11.0.12 2021-07-20 LTS
OpenJDK Runtime Environment Zulu11.50+19-CA (build 11.0.12+7-LTS)
OpenJDK 64-Bit Server VM Zulu11.50+19-CA (build 11.0.12+7-LTS, mixed mode)

SDKMANやhomebrewが入っているので好きなバージョンを入れてもいいですが、ワークスペースを立ち上げる度に実行するのは面倒なので、 azul/zulu-openjdk のようなイメージを使ってしまうのが楽でいいと思います。

# .gitpod.yml
image: azul/zulu-openjdk:16

Mavenの依存をダウンロードしておく

Mavenの場合、dependency:go-offlineプラグインや依存をすべてダウンロードできます。 これをイメージ作成時に実行しておくのが良さそうです。

# .gitpod.yml
image: azul/zulu-openjdk:16

tasks:
  - init: ./mvnw -B dependency:go-offline

Gradleの依存をダウンロードしておく

Gradleには標準的な手法がないので、単にビルドを回しておきます。

# .gitpod.yml
image: azul/zulu-openjdk:16

tasks:
  - init: ./gradlew build

Extensionを導入する

Java向けExtensionを3つ入れて様子を見ています。

# .gitpod.yml
vscode:
  extensions:
    - redhat.java
    - vscjava.vscode-java-dependency
    - vscjava.vscode-java-debug

ポートを開けておく

Spring Framework標準の8080ポートを開けておく場合は ports の設定 で足ります。 が、URLの取得にgpコマンドが必要なので azul/zulu-openjdk ではなく gitpod/workspace-full をベースとしたイメージを用意する必要があります。URLの決定ロジックがいまのところ非常に単純なのでなくてもなんとかなりそうではありますが、一応。

# .gitpod.yml
image:
  file: .gitpod.Dockerfile
ports:
  - port: 8080
# .gitpod.Dockerfile
FROM gitpod/workspace-full
RUN bash -c ". /home/gitpod/.sdkman/bin/sdkman-init.sh && sdk install java 16.0.2-zulu"

バッジを付ける

バッジはDiscourseに落ちています。 README.md とかに貼っておくと、Contributorの敷居が下がって良いんじゃないでしょうか。

community.gitpod.io

最近キャッチアップしているもの 2020-05

いろいろ手を出しすぎてごちゃごちゃしてきているので、頭の中を整理する目的でここに書き出す。

reproducible build

Mavenのメーリスで話題に出ることがあり知った。特別新しい概念ではないけどある種のunlearningであり、ビルド職人は見といて損ないやつ。

reproducible-builds.org

端的に言うと、誰がどこでビルドしても同じアーティファクトができるようにしようという話。実はJavaのビルドツールでアーティファクト(主に.jarファイル)を作ると、そのビルド結果は常にバイナリ等価とは限らない。ビルドした日付がMETA-INFに入ったり、同梱されたファイルはすべて同じなのにZIPに突っ込む順番が違ったりと、ビルドした時刻や環境によって異なる結果が生まれることがある。詳細はDZoneの記事を参照。

なぜこれが着目されているかは公式サイトに書かれているが、つまるところ「アーティファクトソースコードの対応」を検証する術として期待されている。
従来は「本当にこれが公式に配布されているファイルかどうか」はchecksumや署名で確認できていたが、それ以前にバージョン v1.0.0VCSv1.0.0 タグから本当にビルドされたのかを検証することができなかった。ので例えばビルドとリリースのプロセスを攻撃することで、悪意あるコードが含まれた v1.0.0 がリリースされる可能性を検証するコストが高かった(CHANGELOGやRelease Notesではなくバイトコードを読む必要がある)。これがreproducible-buildにより、手元で v1.0.0 をチェックアウトしてビルドした結果と配布物を突き合わせることで攻撃の可能性をまず検証できるようになる。

これがunlearningだと考える理由は、ビルドツールのデフォルト設定だと達成されないから。つまりコミュニティで可搬性のあるプラクティスと考えられてきた常識を疑い変えていくフェーズに今はある。まぁ落ち着いて考えればビルドした日付をアーティファクトに埋める必要性など皆無なわけだが、昔は「このJenkinsジョブが作った.jarだ」ということを明確化するためにファイル指紋に加えてユーザ名やらホスト名やらいろいろMETA-INFに組み込んでいたような気がする。

なおMavenだとプラグインが提供されているので、これをプロジェクトに適用すればよい。Gradleのもあるけど活発ではないので、カバーされていない問題もあるかもしれない。Gradle公式には特にまとまった情報はなく、依存先のバージョン固定に関するドキュメントにさらっと書いてあるくらい。

visual testing (GUI regression testing)

Seleniumを使ったGUIテストについては従来からやってきたが、主なテスト対象はアプリの挙動だった。これに加えてUIの崩れについてもテスト対象とする動きがある。JS界隈・CSS界隈は動きが速く、それでいて多様な環境で動作しなければならないということで、互換性維持の難度がもともと高い。これがUIとなると解像度についても考える必要性があり、余計に複雑化する。例えば最新のブラウザでベンダプレフィックスがサポートされなくなったり、flexboxの挙動が変わったりしたときに、レガシーブラウザでの挙動を変えること無く新しいコードに実装を置き換えていく必要があるが、これを手動でやっていると大変。

visual testing自体は7年前に試行錯誤しているブログがあるくらい仕組みは簡単で、スクリーンショットを撮っておいて保存、次回実行時に比較するというもの。なおまだ名前が定着していないらしく、visual testingとかGUI regression testingとか、みんな好きに呼んでいる。SaaSベンダーやFOSSプロジェクトの比較をするならawesome-regression-testingが役に立つ。

Seleniumを使ったGUIテストが安定かつ高速に回せる環境がすでにあれば、導入自体は難しくない。ブラウザやOSのベータ版でテストを回す仕組みとか、失敗したテストだけを再実行する仕組みとか、テストを複数ノードに分散して実行する仕組みとかがあれば強みを活かしやすい。percyのペーパーによるとUIの変化が確認されたケースの96%はapproveされたようなので、visual testingが失敗したら無条件にマージさせない的なPR運用ではなく、開発者に判断材料を提供する仕組みとして運用する必要性がありそう。

ここまで書いてみて、なぜここ最近でSaaS/PaaS界隈が盛り上がってきたのかがよくわかっていないことに気づいた。BrowserStackのリリースノートによると実デバイスによる機能を提供したのが2019年2月AWS Device Farmに至ってはSeleniumテストの実行をサポートしたのが2020年1月ということで、最近の動きであることは間違いなさそう。自分の直面している課題に最適なのは疑い無いので構わないが。

cross-functional team

従来の「組織の役割を単純化して相互連携によって業務を進める組織設計」の課題を解決する手法としての「単一チームに複数の役割をつめこむ組織設計」と認識している。チームができる意思決定を増やし、組織の壁を超える頻度を減らし、ビジネスのagilityを上げることを目的とする。縦割り(silos)の弊害を解消するために全員をひとまとめにしようというアイデアと考えるとわかりやすいが、チームリーダーの責務が大きく変わるので導入は慎重にしたほうがいい。

cross-functional teamの背景にはcontinuous deployment, DevOps, servant management, capability modelといったトレンドがあると理解している。「マネジメントが理想を描いてメンバーを従える」独裁型と違い、「ミッションを明確化し、専門家がミッション達成のために必要なもの全てを提供するために腐心する」調整型のマネジメントになる。従来型の組織ではチームの長はメンバーと専門性を共有していることが多かったが、cross-functionalチームではそうではないので、独裁のしようがない。これがcross-functional teamとservant managementそしてcapability modelが切り離せないと自分が感じる理由。ただ今まさにaccelerateを読んでいるので、この辺の意見は今後変わるかもしれない。

まだよくわかってないのがチームの小ささ(two-pizzas team)とバス係数の両立。cross-functionalとは言えどもチームは充分小さく活発に議論ができなければならないはずで、例えばDX Criteriaでは5-12名の規模を目安としている。バス係数を考慮し各roleを2名以上とすると、どんなに頑張っても入れられるrole数は2−4個に制限されるはずで、専門性の細分化が進むシステム開発において企画から運用まですべてのプロセスで必要になるすべての専門性をチームに入れることは不可能だとわかる。のでcross-functional teamを採用する組織においても、ある程度の縦割りは残るだろう。その「残し方」として著名なのはSREだが、他にもセキュリティやUXなど組織横断的に統制をかけてやるべき活動については専門のチームが残ると予想する。その際はcross-functional teamとは異なる解決策が必要で、それがSREではerror budgetになる。silosの壁を壊すには「同じ問題を解決する1つのチームなんだ!」的精神論ではなくて、仕組みまで落とし込むことが肝要。

ではどういったroleをcross-functional teamに入れられるかというと、すぐに思いつくのがテスト周りの責務。テストの知見を持ったエンジニアを企画段階から参画させやすくなり、手戻りを減らし保守性を向上すると期待できる。testabilityに配慮したコードかどうかをPRレビューで見てもらえるし、組織としてPOVがひとつ増えることのメリットが大きいはず。Opsも同様。

ちょっと脱線するけどテストはビルドエンジニアのキャリアパスとしてもわりとアリだと思っていて、自動テストの重要性がどんどん上がっている昨今、テストをどうCD pipelineに組み込むか・高速に終えるか・安く実行するかというビルドエンジニアならではの貢献ポイントが多々あるように見受けられる。もちろん別々のroleとしてチームに配置してもいいんだけど、GoogleのTEの例にあるように、1人が知識を持ち合わせてその掛け算で問題解決を図るのは十分可能なレベルだと思う。

Product Management

Strategic Management, Visionary Leadership, Project Management (PjM)とやってきて、いざProduct Management (PdM)に首を突っ込んでいるのだが。今のところは「プロダクトを形作る組織の全体像を把握して、課題に優先度を付けて解決する」という解釈しか持てていない。

すえなみさんのツイートに共感した。

PjMとPdMに限らず、最近はアジャイルウォーターフォールにも差が対して無いんじゃないかみたいな気持ちになってきていて、学習の振り子が振り切った感じがする。そのうち逆側に振れると思うけど(比較対象表とか書き出すやつ)。

考察:Reactive Workflowが生まれた背景とその狙い

人に説明するのがスムーズにできなさそうなので、理論武装というか順序立てて話すためにこの記事をまとめる。

対象

TL;DR

  1. 状況やベストプラクティスが目まぐるしく変わる現代において、すぐに変化できるソフトウェアを保つこと・ヒトの手をできるだけ空けることが重要。
  2. かつてIaaSがAPIを提供し環境管理の多くを自動化したように、各種サービスがAPIやWebhookを通じてDevelopment Workflowの多くを自動化してきている。
  3. 多くの視点や知見を活かすcross functionalチームによる共同開発を支えるために、コラボレーションを助ける仕組みも必要。

前置き:課題解決手法としての生産性向上

我々はなぜ生産性向上を目指すのか? それは生産性が十分ではないという課題意識や危機感を持っているからだろう。では、なぜ課題意識を持ったのか? 機能の実装が進まないとか、バグが多いとか、手戻りが多いとか、本質的でない業務が多いとか、現場によって多くの理由があると思われる。そしてそれを深堀りする前に他者とざっくり課題意識を共有するための言葉として「生産性を向上しないとね」といった表現が用いられる。 言い換えれば、生産性向上というのは現場の抱える課題に対する抽象的な解決策である。ので「生産性を向上するには」などと議論しても、地に足をつけた議論にはならない。「なぜ生産性が低いのか」を掘り下げて、その解決策を見出す必要がある。

ここで重要なのは「なぜ低いのか」は比較対象を知らなければ説明できないことだ。それが他のチームが実際に回している開発なのか、スケジュールから逆算した「理想生産性」なのかは課題ではない。そうした「あるべき姿」が共有されてはじめて比較ができ、KPI(モノサシ)が生まれ、議論できるようになる。これは開発手法や組織、プロセスなど、どこに課題がある場合でも同じだ。

これはこのブログで繰り返し出てきているproblem solvingの考え方であり、真新しいものではない。単に課題の設定と共有をきちんとしようという話である。課題が設定されてはじめて、課題解決の進捗や施策の妥当性を評価できる。Development Workflowに手を入れる前に、本当に必要なものがそれで手に入るのか確認する必要がある。

Development Workflow

私はDevelopment Workflowは自分の専門というわけではなく、またウォッチャーを自任できるほど時間をかけてトレンドを追ってもない。いちエンジニアの理解であり何かを代表するものではないことを、はじめに改めて強調しておく。

またここでDevelopment WorkflowというのはCI, CD, DevOpsといった文脈で出てくるパイプライン処理、特に確認や承認や手動テストという非自動化処理をも含めた一連の流れを指している。GitHub ActionsCircle CIでは単にWorkflowと呼んでいるが、ここでは業務向けワークフローシステムと区別する目的でDevelopment Workflowと呼ぶ。Development Workflowの「終点」は成果物のデプロイだけでなく、チームへのフィードバック=学習機会の提供であることも多い。

かつてDevelopment Workflowはどのようなものだったか

自分が10年前にどういうDevelopment Workflowを組んでいたかというと、概ね以下の通りだったように記憶している(便宜上HudsonをJenkins*1と表記):

  • trunk, branches, tagsを備えたSubversionによるコードのバージョン管理(ブランチ切り替えコストが高い)
  • 設定画面を利用したJenkinsジョブの作成・設定
  • ブランチを指定して実行するJenkinsジョブを開発者に対する公開
  • プラグインによるJenkinsの機能拡張
  • Mavenの親pomファイルを利用したプロジェクト管理
  • LANに閉じた開発環境
  • 環境構築や開発作業の手順書をWiki等でメンテ(.jarファイルの手動配置、テスト手順、IDE設定方法など)

自動化されているのはジョブと呼ばれる単発作業のみで、いつどのように実行するかは主にヒトに委ねられていた。出荷作業をするにはこのジョブをこういったパラメータで実行すること、という手順書が必要だったのである。VCS上での変更を知るにはpollingを必要としていたし、他にもマスターサーバの責務は多くある程度の性能を要した。また結果をヒトに通知する手法はメールが主体だったので、何かを強制することは難しかった。

当時はまだビルド職人という言葉があったが、それは成果物の作成を特定の環境や個人でしか行えなかったり、ジョブ実行時間が非常に長かったりしたことを反映している。実際ビルド環境を可搬にすることはまだ難しく、また手順書が必要な現場も多かったのではと推測される。

近年Development Workflow周りで見られる変化

逆に、当時に無くここ10年で開発され普及したものを思いつく限り列記する:

  • ブランチを利用しやすいVCS(Git)
  • 画面による設定ではなく、VCSによって管理されたファイルよる設定
  • CIパイプライン
  • インクリメンタルビルド
  • 複数ノードにおけるテストのparallel実行
  • プロジェクトごとに利用するJDKを切り替える仕組み
    • rvm はあったようなので、他言語には実行環境をプロジェクトローカルのファイルで指定する仕組みはあった?
    • 開発環境にはあらかじめ複数のJDKを入れておくことで、Toolchainを使って使うものを指定する仕組みはあった(どの程度普及していたのかは不明)
  • DisposableでReproducibleな、頻繁に壊して作りなおせる環境
    • Vagrant Sahara pluginが出たのが2011年
    • Boxenが出たのが2012年
    • Dockerが出たのが2013年
    • ChefとPuppet, EC2にAMIは10年前既にあったので、自前でコード書いて実現する手はあったと思う(自分はやった記憶がない)
  • CLIから実行できる、設定がVCSで共有可能で環境非依存なコードフォーマッター
    • gofmt, rustfmt, Prettier, Spotlessなど
    • コードフォーマットが個人の環境・設定に依存する必要性をなくし、差分レビューを容易にした
  • チケット中心主義の緩和
    • PRで変更を提案し、その中で議論・修正できるようになった
    • 不具合も再現するテストをPRで送り、同一PR内で修正しマージできる(チケットによる報告も残ってはいる)
    • GitHub Releasesなどでタグに対して情報を残せるのでリリース作業もチケット不要
    • チケットを作って議論を尽くしてからコードを書くケースはもちろん残っているが、関連作業の記録・集約のためにまずチケットを作るというフローはほぼ不要になった
    • チケット管理システムの責務から変更の記録が外れ、要件の記録に注力できるようになった
  • 他システムとの連携を前提としたAPIやWebHook、アクセストーク
  • 運用を肩代わりし本質に注力させてくれるSaaS
  • ベンダ間で統一されたインタフェース(WebDriver, JS, CSSなど)

多様にわたるが、その多くがビルド時間の短縮や環境管理を楽にすること、システムを”繋ぐ”ことに貢献するものである。

10年前と比較して、どのようなニーズの変化があったのか

技術の進展というのはあとから振り返ると「なんで今までなかったんだっけ?」と感じるものが多いが、Development Workflowを構成するサービスの成長にも同じことを感じる。 例えば10年前はヒトに使われることを前提にGUIを通じて提供していた機能の多くが、サービスから使われることを前提にWebAPIやVCSで管理されたファイルによって使えるようになった。しかしGUICUIどちらが技術的難易度が高いかと言うと、一般的にGUIだろう。GUIを提供できるがCUIはできない、というケースはあまりないはずだ。

ではなぜ10年前はGUIを中心としていたのか?それは(特にJenkinsについては)利用者に対するハードルを下げた面もあると思うが、手実行することがあまり問題にならなかった、設定や運用をオープンにし誰でも触れるようにするモチベーションが高くなかったことが要因として挙げられる。もっと言えば、当時は1日に数回のビルドで十分にビジネスを回せたのだ。DevOpsの草分けとなったFlickrの発表がちょうど10年ちょっと前*2だし、おそらく当時はまだ多くの組織で「開発と運用の対立」「出荷前検証の重視」が残っていたはずだ。Flickrを始めとした多くの組織で、インフラのコード化や共有されたVCSリポジトリがいかに生産性へ貢献するか、注目された頃とも考えられる。

つまり10年前と今の大きな違いは、開発における試行錯誤の速度だ。自動テスト、継続的デプロイ、カオスエンジニアリング、コンテナなどの様々な技術が生まれたことで、ソフトウェアがより素早く変化するものになっている。またソフトウェア開発の現場に経験主義の考えが強く根付いていることや、スタートアップとソフトウェアの相互の結びつきから、ソフトウェアが素早く変化できることがビジネスで有利に働くと強く信じられるようにもなっている。DevOpsやSREが生まれた背景もそれを裏付けているように思う。

速度が違うとは、どういうことか。例えば10年前の私に「Gradle Pluginの開発でテストに使うGradleのバージョンを増やしたい、なぜこんなことをJenkins管理者にお願いしなければならないのか」と言ったところで、「え、メール一本だし別にいいじゃん、何気にしてるの?」と不思議がられるに違いない。今はこれもPRひとつで実現可能だが、当時はそうではなかった。
他の例では、Mavenのバグを回避するためにバージョンを上げるとして、Jenkins管理者にお願いしてCI環境のバージョンを上げるだけでなく、各開発者の使っているMavenのバージョンまで上げるにはメールでバージョンを上げるよう依頼しつつmaven-enforcer-pluginで意図しないバージョンでビルドした場合に落ちるようにする必要があるだろう。さらに複数プロジェクトで実施するにはすべてのプロジェクトのpom.xmlを書き換えるか、あらかじめ親pomを作成して各プロジェクトに依存させ、maven-enforcer-pluginの設定を親pomに加えてprivate maven repositoryにdeployしてから各プロジェクトのpom.xmlを書き換える必要がある。文が長くてわかりにくいが、とりあえず面倒なプロセスが必要ということが伝われば良い。これもMaven Wrapperを使うようにすればPRひとつで済む。ミドルウェアについても同様で、Dockerfileなりdocker-compose.ymlなりAnsibleなり、何らかのファイルを少し変えるだけで意図したバージョンが確実に使われるようにできる。

こうして製品ないし開発環境を変えることがPRひとつでできるようになっただけでなく、マージ前のプロセスの簡素化・短縮化も図られるようになった。Tracでチケットを作って手元で対応するパッチを作ってReview Boardでレビューして問題なければパッチを適用するという複数のシステムを行き来する方法から、検証済みマージやPull Requestといったひとつのシステムで完結する方法へと変化した。またPre-merge build自体の実行時間も各種技術によって短縮されたし、ミドルウェアを使った統合テストも使い捨て環境を作りやすくなったので実行したいときに他のテストとの衝突を気にせず実行できるようになった。気にすることが減るというのは、Pre-merge buildでいろんなことを自動的に確認・検証したいというニーズを満たす上で必要だ。統合テストを回して互換性が壊れていないことを確認したいのに、DBが他のテストで使われているので待つ必要がある、なんて事態は避けたいのだ。

10年前と今の「生産性」を比べたときに、この開発プロセスの違いは大きな差を生むに違いない。

自動化、有機的連携、そしてリアクティブワークフロー

別の表現をすると、10年前は省力化のために作業の自動化が推奨され、それで充分に競争力を生んでいた。その後、自動化された作業を有機的に繋ぎ合わせるようになり、CIパイプラインが登場した*3。その後でWebAPIやWebHook等の整備により、PRやcron以外でもパイプラインを自動発火することが増え、リリース作業やデプロイ、ドキュメント整備など多様な使い方をされるようになったと言えるだろう。 近年は脆弱性対応の観点から、ライブラリのリリースや脆弱性の公開をイベントとして使えるようになってきている*4が、これも従来なら開発者が各ベンダからの情報をRSSSNS等を使ってウォッチする必要があった。

さてジョブの並列実行・並行実行ならびに有機的連帯についてはPipelineやWorkflowという用語が存在するが、このヒトの指示による実行ではなくイベントに応じた実行を意識した組み方については特に固有名詞がないように思う。イベントを受け取りステートレスな要素をつなぎ合わせて実行する様がリアクティブシステムの考え方に近いことから、ここではリアクティブワークフロー(Reactive Workflow)と呼ぶ。

なぜリアクティブワークフローが好ましいのか、その説明はReactive Manifestoが流用可能だ。使いたいときにすぐ使え(Responsible)、ジョブがステートレスなため壊れても再実行でほぼ対処でき(Resilient)、ビジネス上の要請あるいは技術的な必要性から生まれる突発的な負荷に対応する(Elastic)。また各ジョブの入出力がコミットハッシュやパラメータなど不変なものがほとんどであることと、ワークフロー基盤がサーバーレスアーキテクチャで構成されることが多いことから、メッセージ駆動のメリットも享受できると思われる。

おそらくユーザ(開発者)としてはResponsibleであることが最も重要だ。JenkinsのマスターやエージェントをLAN内に構築していた(サーバーレスでなかった)ころは、資源をうまく使うように夜間に定例処理を回したり週末にテストを回したりといった工夫が必要だった。これはリアクティブワークフローを組み、資源の管理をSaaSあるいはIaaSに押し付けることで、必要なときに必要なだけ実行し即結果を受け取れるようになった。Elasticであることも合わさって、開発者が本質的でない気遣いを排し(ビルド並列化などによって)開発速度を上げることに貢献できるわけだ。

こうしてみると、コンテナ(ジョブはコンテナ内で走ることがある)やサーバーレスといった新技術によって最近のDevlopment Workflowも支えられているのだなと感じる。

コラボレーションの重要性

近年の開発手法、例えばSREやDevOps, Lean, cross-functional teamについて紐解くと、個人の役割を明確化・細分化すると同時に組織のサイロ化を防ぐことに多大な関心を持っていることが伺える。著名なのがError Budgetで、Reliabilityの実現を目的としたSREと多様なデプロイを多数行う(ことによるプロダクトの改善)を目的とした開発という一見相反する行動原理を持つ役割をうまく「同じ課題を解決するチーム」へと仕立て上げ、サイロ化を防いでいる。詳しい話は書籍に譲り割愛する:

他にもプロダクトマネジメントトライアングルでも、立場が異なるヒトをいかに連帯させ機能させるかという関心ごとについて説いているが、その議論の中心はProductという誰もが共有しているモノにある(と私は解釈した)。デザイナーは開発者の生み出す価値をユーザーに届けるためにどうデザインすべきか、経営は開発資源を効率的に使うためにどうマネジメントすべきか、開発者はユーザに製品の魅力を伝えるためにどうコミュニティを築くべきなのか。そのすべてのコラボレーションが製品というOutputを通じてOutcomeを生み出す。開発、ビジネス、ユーザのどれを取っても独立しておらず、すべてがProductや他のロールと繋がっている。プロダクトマネジメントはヒトのコラボレーションを認めてはじめてできるものだ。

もうひとつ、面白い観点にロックスターエンジニアというやつがある。有能なエンジニアはそうでないエンジニアの10倍、あるいは27倍の生産性を持っているというold termだ。これもたぶん昔は説得力のある概念だったのだろうが、近年では全く使われなくなっている。代わりにtechnology raderでは10x teamという表現を紹介していて、素晴らしいoutputが素晴らしいチームによって生まれるとしている。これはGoogleが研究で明らかにしたチームやマネジメントの重要性によっても肯定されていると言えよう。

つまり近年のソフトウェア開発の現場では、個人を育てること以上にチームを育てることに関心を持っている。個人を育て組織を開発することで、ビジネス上の競争力を効率的に高めていけると信じているわけだ。 チームを育てるには様々な手法があるだろうが、成長が内省によって生まれることを考えればBlameless Postmortemのような事実と向き合い理性的な議論を通じてより良い姿を模索する活動には一定の価値があると思われる。失敗時に限らず日頃から反省の機会を持つこと、Scrumで言うSprint Retrospectiveのような定例イベントも助けになるだろう。

さてこの手のイベントをやってみるとわかるのが、準備のコストが高いことだ。Postmortemのためには事実をリアルタイムに記録しなければならないし、ベロシティを測るには日々チケット消化の状況を記録する必要がある。自分とは違う専門性を持つチームメイトに向けて資料を整理することもあるし、わかりやすくするためにグラフを整理することもあるだろう。定期的なイベントの準備をすべて人力でやっていては、継続的にコストを払うことになる。

Development Workflowはこのコストを下げることができる。人手を使って証跡を残していた運用を、自動的にログを残す運用に変えられる。ChatOpsやGitOpsが最もイメージしやすいだろう。 また自動化やSaaSなどによって、相手の専門性に合わせたビューやファイル形式で情報を提供するコストも下げられる。例えばデザイナの要請を受けてCSSを変更したときに、それが既存ページにどういった影響を及ぼすか、percyのようなGUI regression testingが整っていればスクリーンショットを取ること無くPR一本で説明できる。

まとめ

とりとめのないままに書き下した結果、ひどいことになってしまったが。

結局は「cross-functional teamとして高速度で成果を出す」という時代の要請に自動化やコンテナ、サーバーレスといった技術が応えた形がいまのリアクティブワークフローなのだと思う。機械学習や業務改革の文脈で「機械ができることは機械にやらせて、ヒトはヒトにしかできないことをやろう」と言われるが、開発プロセスにそれを適用したものがこれだ。 事実と向き合い考えることこそが、ヒトにしかできないことだ。事実(ログ)の集約や解析、テストやビルドといった開発作業、ライブラリの更新や脆弱性報告の精査といった定例作業はすべてリアクティブワークフローにやらせる。私達は上がってきたデータをもとに考え、議論し、内省し、立てた仮説をもとに次のPRを作る。Gitにpushすれば機会が変更の妥当性をまず検証してくれるので、ヒトは既知の問題が無いことを前提に議論を尽くすことができる。タブ文字かスペースか、括弧の位置はどこにするかなんて議論をPRでする時代ではもう無いのだ。

もちろんここで議論したのはある種の理想形で、すべてのプロジェクトがこれを満たせるとは限らない。私が見ているFOSSプロジェクトにも、まだ手動で色々とやらなければリリースすらできないものもある。また10年前でも既にリアクティブワークフローを回していたところもあるかもしれない。

*1:このエントリではJenkinsが「古いWorkflowシステム」の代名詞として使われている節があるが、それは単に筆者個人の経験がそうだっただけで、Jenkinsというプロダクトがレガシーというわけではない。Cloudbees Flowなどを参照

*2:この点で、本考察はここ10年ではなく13〜15年とかで切ったほうが多くを学べるのかもしれないが、その頃は私が技術的情報収集をしていなかったのでよくわからない

*3:時期はよくわからないが後発のJenkins 2.0が2016年4月なので2014〜2015くらいか?

*4:GitHubこれとか、試してないけどSonatypeのこれとかjFrogのこれとか

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