マルチプロジェクト構成リポジトリにおいて変更の影響を受けるプロジェクトを検出する

どうも、Repro Core Unit に所属している村上です。
Repro では現在、20 を超える Kafka Streams アプリケーションが稼働しています。
その中の半分くらいが Repro システムの共通基盤を構成する Kafka Streams アプリケーションであり、それらの運用は Repro Core が持つ責務の 1 つとなっています。

この共通基盤となる Kafka Streams アプリケーション群は、Gradle のマルチプロジェクト構成になっていてコードベースはモノレポで管理されています。

本稿では、この構成におけるデプロイ性の課題とそれに対するアプローチの話をします。

ディレクトリ構造

共通基盤のコードは以下のようなディレクトリ構成になっています。

.
├── Base
├── Deployments A
│   └── Kafka Streams Application A
├── Deployments B
│   ├── Kafka Streams Application B
│   ├── Kafka Streams Application C
│   └── Kafka Streams Application D
├── Deployments C
│   ├── Kafka Streams Application E
│   └── Kafka Streams Application F
├── Deployments D
│   └── Kafka Streams Application G
├── Deployments E
│   └── Kafka Streams Application H
├── Library A
└── Library B

Base は、すべての Kafka Streams アプリケーションから参照されていて、共通で使われる依存関係やロジック、Gradle Plugin、設定ファイルなどが定義されているサブプロジェクトです。

Deployments はデプロイを実行する単位で、1 つ以上の Kafka Streams アプリケーションを有するサブプロジェクトです。
基本的に共通の関心事を持つアプリケーションがまとめられていて、同じタイミングでデプロイされるようになっています。

Library は 一部の Kafka Streams アプリケーションで使用されるモジュールを定義したサブプロジェクトになります。

デプロイ性における課題

このディレクトリ構成では、互いに依存しないサブプロジェクト毎にデプロイを行えるため、全体としてデプロイしやすい構成になっています。
しかし、その一方で、サブプロジェクトを跨ぐ複数の Kafka Streams アプリケーションに影響するコードを変更したときに、一部のアプリケーションをデプロイし忘れるリスクもあります。

例えば、別々のサブプロジェクトに属するアプリケーション A と B が同じコードを参照していて、そのコードを変更したときに、アプリケーション A のみにデプロイしてアプリケーション B のデプロイを失念してしまうケースです。
後に、別のタイミングでアプリケーション B を改修してデプロイしたとき、以前デプロイされていなかった変更分によって問題が起きることもありえます。(実際にありました)
デプロイ後何か問題が生じた場合、そのデプロイ時に加えていた変更内容を疑うと思うので、なかなか過去のデプロイ漏れの可能性に意識が向きにくいと思います。
このような状況に陥ると原因特定が困難になるので、何かしらの防止策を講じておきたいと考えました。

課題に対するアプローチ

課題に対する対応方針として次のようなものが考えられます。

  • 改修によって影響を受けるアプリケーションのデプロイ漏れを極力防ぐ
  • デプロイ漏れの発生を検知できるようにする

どちらもサポートするのが理想ですが、まずは前者のデプロイ漏れ防止の施策に取り組みました。
ちなみに後者は、本番環境にデプロイされているコードと master ブランチの差分を検知する、などの施策になります。

デプロイ漏れ防止に関して、一番簡単なのはすべてのアプリケーションをデプロイするというソリューションですが、これはデプロイの敷居を上げてしまい、アジリティを下げる要因になりかねないので行いません。

デプロイをし忘れる要因の 1 つとして、改修内容の影響範囲を正しく把握できていないことが考えられるので、今回はプルリクエストの差分から影響するアプリケーションを抽出し、それをコメントに出力して可視化するアプローチをとりました。
レビュアーにとっても影響範囲を把握しやすくなるので、レビューの質の向上も期待してます。

実装

プルリクエストの差分から影響するアプリケーションを特定するには、クラスの依存を辿っていく必要があります。今回はそれを簡単に行えるよう ArchUnit を使用しました。

ArchUnitJava のパッケージ構成やクラス間の依存関係をチェックし、期待するルールに従っているかを検証できるアーキテクチャテストのためのライブラリです。
ArchUnit はクラスとその依存関係のグラフを構築することで、特定のクラスにアクセスしているクラスを判定できます。ArchUnit が提供する Core API はこのような依存関係を扱う機能を備えているので、テスト目的ではないですが今回の用途にマッチしていました。

そして、変更内容から影響するアプリケーションを抽出するためのステップは以下になります。この一連の処理は Gradle Task として実装しました。
(変更を検知する対象は Java コードのみなので、Gradle で管理されたライブラリの更新や設定ファイルの変更は対象外です)

  1. git diffで変更があったファイルパスを取得する
  2. ファイルパスから ArchUnit の JavaClass を見つける
  3. JavaClass の依存を辿り、影響するアプリケーションを抽出する

まず、変更があったファイルパスを取得します。

List<Path> changedFiles;
var process = new ProcessBuilder("git", "diff", "--name-only", "origin/master...HEAD").start();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
  changedFiles = reader
    .lines()
    .map(Path::of)
    .filter(path -> path.getParent() != null && !path.toString().contains("/test/"))
    .toList();
}

次に、そのファイルパスにマッチする ArchUnit の JavaClass インスタンスを見つけます。

簡略化してますが以下のようなイメージです。実際はもう少し厳密に判定してます。

var classes =
    new ClassFileImporter()
        .withImportOption(DO_NOT_INCLUDE_TESTS)
        .importPackages("com.example.streams")
        .stream()
        .filter(javaClass -> {
          var simpleName = javaClass.getSimpleName();
          return changedFiles.stream().anyMatch(path -> path.toString().contains(simpleName));
        });

最後に、JavaClass のインスタンスから Kafka Streams アプリケーションのクラスまでの依存を辿っていきます。 JavaClass#getDirectDependenciesToSelf を使うことで、そのクラスを参照しているクラスの一覧を取得できます。これを再帰的に辿っていきます。

終点となるアプリケーションクラスは、@ReproStreamsApplication という独自のアノテーションを付与することで判定できるようにしました。

var foundStreamsApps = new HashSet<JavaClass>();
var visited = new HashSet<JavaClass>();
classes.forEach(javaClass -> traverseDependencies(javaClass, visited, foundStreamsApps));
private static void traverseDependencies(
    JavaClass current, HashSet<JavaClass> visited, HashSet<JavaClass> streams) {
  if (visited.contains(current)) {
    return;
  }
  visited.add(current);

  if (current.isAnnotatedWith(ReproStreamsApplication.class)) {
    streams.add(current);
    return;
  }

  current
      .getDirectDependenciesToSelf()
      .forEach(dependency -> traverseDependencies(dependency.getOriginClass(), visited, streams));
}

影響するアプリケーションの JavaClass インスタンスを取得できたら、コメントする文字列をよしなに構築し、それを出力して処理は終了です。

あとは、CI から Gradle Task を実行し、出力された文字列をプルリクエストのコメントに貼っ付ければ完了です。
Github Actions での例は以下に貼っておきます。

on:
  pull_request:
    types:
      - opened
      - synchronize
jobs:
  affected_apps:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'corretto'
          cache: 'gradle'
      - name: Set a message about affected apps
        id: affected-apps
        run: |
          ./gradlew extractAffectedApps --args='build/affected_apps'
          CONTENT=$(cat build/affected_apps)
          echo -e "content<<EOF\n$CONTENT\nEOF" >> $GITHUB_OUTPUT
      - name: Find affected-apps comment
        uses: peter-evans/find-comment@v2
        id: fc-affected-apps
        with:
          issue-number: ${{ github.event.pull_request.number }}
          comment-author: 'github-actions[bot]'
          body-includes: Affected Application
      - name: Upsert PR comment for affected apps
        if: steps.affected-apps.outputs.content != '' || steps.fc-affected-apps.outputs.comment-id != ''
        uses: peter-evans/create-or-update-comment@v3
        with:
          comment-id: ${{ steps.fc-affected-apps.outputs.comment-id }}
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            ${{ steps.affected-apps.outputs.content }}
          edit-mode: replace

さいごに

今回は、変更があった Java Class への参照を辿っていって、影響がある Kafka Streams アプリケーションを抽出しました。
このアプリケーションの抽出プロセスは、たとえば build.gradle などの設定ファイルの変更も検知できるようにしたり、Java のメソッドやフィールドレベルの変更を厳密にチェックしたりすることで、さらに精度を向上させることはできます。
今のところ、精度向上に関しての必要性はそこまで感じていませんが、状況が変わり問題が発生した際には、別途検討していきたいと思います。