mtx2s’s blog

エンジニアリングをエンジニアリングする。

ArchUnitでアーキテクチャをテストする

ソフトウェアアーキテクチャには、依存関係のデザインという側面がある。その目的は多くの場合において、ソフトウェアの振る舞いに対する変更容易性を高めることではないだろうか。

ソフトウェアプロダクトは、そのライフサイクルを通して、繰り返し変更し続けられていく宿命にある。それがユーザーや顧客の要求であり、彼らの価値につながるからだ。そしてその提供には迅速さも求められる。依存関係のデザインは、これを実現するために組み込まれたソフトウェアの構造なのだ。

アーキテクトの悩みのひとつは、このような目的に基づいて自らがデザインしたソフトウェアの構造が、儚く崩れ去っていくことだ。アーキテクチャとは所詮はルールでしかない。開発チームが厳密にルールを守らない限り、望ましい構造を構築・維持することはできない。アーキテクチャは脆いのだ。それこそが技術的負債の一因でもある。

無償のJavaライブラリとして提供されているArchUnitは、そのルールをコードとして表現することを可能にした。テストオートメーションの一部としてビルドパイプラインに組み込めるということだ。これは、開発チームがデリバリするソフトウェアプロダクトの構造を、アーキテクトが描いたアーキテクチャに誘導し続ける大きな力となる。

私自身もまだ、ArchUnitの導入に向けた技術的な調査段階であるが、大いに可能性を感じている。本稿では、その過程で知ったArchUnitの機能について、ユーザーガイドを参考にしつつ紹介していく。

なお、本記事で対象としたArchUnitのバージョンは以下の通りとなる。

com.tngtech.archunit:archunit:1.0.0-rc1
com.tngtech.archunit:archunit-junit5:1.0.0-rc1

ドキュメントとしてのテストコード

現実問題として、人が増えれば増えるほど、アーキテクチャとして描いたビジョンの浸透は難しくなる。その上、ソフトウェアエンジニアのスキルレベルが玉石混交となりやすい。少数精鋭の時のようにはいかない。以前の記事でも書いたが、様々な要因で積み重なる技術的負債の中でもこういった背景による負債こそが問題だと私は考えている。

mtx2s.hatenablog.com

次の図は、マーティン・ファウラー(Martin Fowler)による技術的負債の発生理由に関する四象限(TechnicalDebtQuadrant)を日本語化したものだ。上述した負債は、この四象限の左下にあたる「無謀で無自覚な負債」にあたる。

ArchUnitによるテストコードがあれば、意図的か無自覚かに関わらず、「無謀な負債」の蓄積を抑止・軽減できる。ルールに沿わない構造を持つ変更は、ビルドを失敗させるからだ。その原因を作った開発者は、当然ながらどんなテストが失敗したのか調べることになる。そうすることで、アーキテクチャがどのように定義されているのか学ぶことにもなる。

ここで発揮されるのが、ArchUnitで定義されたルールの可読性だ。次の例のように、英語の文章としてそのまま読めるメソッドチェーンを形成する構造となっている。classes() は、ArchRuleDefinition クラスから import static したメソッドだ。

ArchRule myRule = classes()
    .that().resideInAPackage("..service..")
    .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");

日本語で言えば、「service パッケージに存在するクラスは、controller および service パッケージからのみアクセスされるべき」といったところか。"..service.." は、com.example.myapp.servicecom.example.myapp.service.customer などのパッケージ名にマッチするパターンだ。詳しくは PackageMatcherドキュメント(コメント)に記載されている。

ところで、ArchUnitに関わらず、テストコードは何が良くて何が駄目なのかを明確に表現するドキュメントとなるが、なぜ良くて、なぜ駄目なのかまでは示さない。テストコードはあくまでもwhatを示すもので、そのhowがプロダクションコードだと言えるだろう。一人で開発する分にはそれだけでも十分であるが、複数人で開発するならやはりwhyが必要だ。テストコードやプロダクションコードだけに頼らず、コメントなどでwhyをしっかり残すことも必要だろう。

基本ステップ

ArchUnitでのコーディングの基本ステップは次の通りだ。

  1. 検査対象とするクラス群をインポートする
  2. ルールを定義する
  3. ルールを使い、インポートしたクラス群を検査する
JavaClasses classes = new ClassFileImporter().importPackages("com.example.myapp");

ArchRule myRule = classes().that().resideInAPackage("..service..")
    .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");

myRule.check(classes);

この例では、パッケージ名を指定してインポートしているが、他にもURLやJarファイルを指定することもできる。これらのバリエーションについては、ClassFileImporter クラスのメソッドを眺めると把握できるだろう。いずれにしても、最終的には importLocations(Collection<Location>) メソッドが呼び出されるようだ。

ちなみに、JUnit5でのテストは次のように書ける。

@AnalyzeClasses(packages = "com.example.myapp")
public class ServiceTest {
    @ArchTest
    static final ArchRule service_only_be_accessed_by_controller_and_service =
            classes().that().resideInAPackage("..service..")
                .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
}

こちらも @AnalyzeClasses アノテーションに用意された属性を見てみると、packages 以外にも packagesOflocations などいくつかのインポート手段があるようだ。

どんなことができるのか

レイヤードアーキテクチャ(Layered Architecture)クリーンアーキテクチャ(Clean Architecture)といった数々のソフトウェアアーキテクチャパターンが示すように、それらが持つ構造は、責務に注目してソフトウェアを論理的あるいは物理的な境界で分離し、高凝集疎結合モジュールクラスコンポーネントを実現するものだ。このようなアーキテクトによる構造定義に基づいて、開発者はソフトウェアプロダクトを実装し、成長させていく。

たいていのプログラミング言語名前空間(namespace)を備えており、それをモジュールの単位として扱う。Javaで言えばパッケージ(package)がそれにあたる。ではモジュールたるパッケージに関する構造をArchUnitでどのように定義するのか、簡単な例をみてみる。

例1:foo パッケージ内のクラスは、bar パッケージ内のクラスに依存してはならない

noClasses().that().resideInAPackage("..foo..")
    .should().dependOnClassesThat().resideInAPackage("..bar..")

例2:foo パッケージ内のクラスに対するアクセスを、foo パッケージ自身と、bar パッケージに限定する

classes().that().resideInAPackage("..foo..")
    .should().onlyBeAccessed().byAnyPackage("..foo..", "..bar..")

例3:foo パッケージ内のクラスに対する依存を、foo パッケージ自身と、bar パッケージに限定する

classes().that().resideInAPackage("..foo..")
    .should().onlyHaveDependentClassesThat().resideInAnyPackage("..foo..", "..bar..")

この、例2と例3の違いは、「アクセス」と「依存」だ。例2では、フィールドやメソッド、コンストラクタに対するアクセスのみ禁止しているだけだが、例3では依存関係を作ること自体を禁じている。このような細やかな制御が可能である点も気が利いている。

もちろん、パッケージを指定したルールだけではなく、次の例のようにクラスを指定したルールを記述することもできる。

例4:Fooassignable なクラスに対する依存を、foo パッケージと、bar パッケージに限定する

classes().that().areAssignableTo(Foo.class)
    .should().onlyHaveDependentClassesThat().resideInAnyPackage("..foo..", "..bar..")

例5:名前が Foo で終わるクラスは、foo パッケージ内に入っていなければならない

classes().that().haveSimpleNameEndingWith("Foo")
    .should().resideInAPackage("foo")

以上のように、多くのルールは次の形式で定義される。

classes that ${PREDICATE} should ${CONDITION}

PREDICADEとCONDITIONのバリエーションについてはそれぞれ、ClassesThat インタフェース、ClassesShould インタフェースが持つメソッドを見ると良い。ここで例に挙げた表現がほんの一部でしかないことがわかる。

これらの例ではいずれも classes() を使ってクラスを対象にしたルールを表現していたが、次の例の methods() のようにメソッドを対象にしたり、フィールドやコンストラクタなども対象にすることができる。これらのバリエーションは、ArchRuleDefinition クラスのメソッドとしてまとまっている。

例:foo パッケージ内にあるクラスの public メソッドは、@Bar アノテーションが付けられていなければならない

methods().that().arePublic().and().areDeclaredInClassesThat().resideInAPackage("..foo..")
    .should().beAnnotatedWith(Bar.class);

個人的な印象として、Javaでの厳密なモジュール化(modulization)の実現は難しいと感じている。モジュールレベルでのカプセル化を実現するために、公開する振る舞いを制限しようにもアクセス修飾子だけでは不十分だったし、それを補おうとするとクラス同士の関係やコードが複雑になり過ぎてしまう。苦労して実現したとしても、モジュールユーザーによってコードレベルで破られることもある。いずれにしても、何らかの形でカプセル化が破られてしまうと、モジュールへの変更に対するモジュールユーザーへの影響が拡大してしまう。それらを防ぐ防御策はこれまでずっと、文書と規律という不確実な手段にゆだねられてきた。

ArchUnitは、カプセル化の堅持をテストコードによって保証する。規律に強制力が伴ったのだ。カプセル化を追求し過ぎることによる複雑化の軽減も期待できそうではないか。

定義済みの頻出ルール

ArchUnitの主要なAPIは、Core, Lang, Libraryの3つのレイヤーに分かれている。CoreレイヤーのAPIは、基本的な機能を提供している。LangレイヤーのAPIは、ここまで例に挙げたような、アーキテクチャを簡潔に定義するためのルール構文を提供している。そしてここで取り上げるLibraryレイヤーは、よく使われるであろうパターン化されたルールを簡単に扱うためのAPIを提供している。

例えば、非循環依存関係の原則(ADP, Acyclic Dependencies Principle)を徹底したいならば、次のように書くだけだ。

slices().matching("com.example.myapp.(*)..")
    .should().beFreeOfCycles()

この例では、com.example.myapp 配下におけるパッケージ間の循環依存を検出できる。slices() は、SlicesRuleDefinition クラスのメソッドを import static したものだ。

DependencyRules クラスの定数 NO_CLASSES_SHOULD_DEPEND_UPPER_PACKAGES も興味深い。これは、下位階層パッケージのクラスが上位階層パッケージに直接依存することを禁止する ArchRule だ。

他にも、GeneralCodingRules クラスや ProxyRules クラスにも定数やメソッドがいくつか定義されている。NO_CLASSES_SHOULD_USE_JODATIMEUSE_JODATIME など、細かいがなかなか気が利いている。

おそらく最も注目すべきなのは、典型的なアーキテクチャパターンが、Architectures クラスに定義されていることだろう。

例:レイヤードアーキテクチャ(Layered Architecture)

layeredArchitecture()
    .consideringAllDependencies()
    .layer("Controller").definedBy("..controller..")
    .layer("Service").definedBy("..service..")
    .layer("Persistence").definedBy("..persistence..")
    .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
    .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
    .whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service")

説明するまでもないコードだが、見ての通り3つのレイヤーを定義した上で、それぞれ閉鎖レイヤーにするか、開放レイヤーにするかといったレイヤーの分離を定義している。

例:オニオンアーキテクチャ(Onion Architecture)

onionArchitecture()
    .domainModels("com.myapp.domain.model..")
    .domainServices("com.myapp.domain.service..")
    .applicationServices("com.myapp.application..")
    .adapter("cli", "com.myapp.adapter.cli..")
    .adapter("persistence", "com.myapp.adapter.persistence..")
    .adapter("rest", "com.myapp.adapter.rest..");

こちらも見た通りだ。アプリケーションコア(application core)となるドメインモデル(domain model)ドメインサービス(domain service)アプリケーションサービス(application service)の3レイヤーを定義した上で、アダプタ(adapter)を定義している。

1.0.0-rcでのサポートは、レイヤードアーキテクチャとオニオンアーキテクチャの2つのみのようだが、今後の追加に期待できそうだ。

既存プロダクトへの導入時に起こりうる大量のルール違反

レガシーなソフトウェアプロダクトに対してArchUnitを使い始めれば、おそらく大量のルール違反が出てしまうだろう。そういったケースでの対応方法も用意されている。

そのひとつは、archunit_ignore_patterns.txt という名前のファイルをクラスパスに置くことだ。このファイル内に行区切りで記述された正規表現にマッチした違反は無視されるようになる。

例えば次のように書けば、com.example.myapp.foo.Foo クラスに関する全ての違反が無視され、失敗ではなく成功が報告されるようになる。

.*com\.example\.myapp\.foo\.Foo.*

ただこの方法では、無視された違反を反復的に改善していくといったアプローチが取りづらい。改善の都度、対象となる違反が検出されるように正規表現を書き換える必要があるからだ。

そこで、もうひとつの手段である FreezingArchRule を使う。FreezingArchRule は、報告された違反を ViolationStore に保存する。これによって、新たな違反のみを報告し、既知の違反は無視するようになる。そして、修正された違反は、ViolationStore から削除される。

FreezingArchRule の良いところは、ViolationStore での保存先としてテキストファイルを使用する点だ。このファイルをバージョン管理すれば、レガシーシステムの継続的な改善を追跡できるようになる。

使い方は次のように、対象とする ArchRuleFreezingArchRule でラップするだけだ。

ArchRule rule = FreezingArchRule.freeze(classes().should()..省略..);

ソフトウェアプロダクトを漸進的に成長させていけば、変化への適応や、学習による改善によって、ソフトウェアプロダクトの構造を変化させるべくルールを書き換えることもある。そのようなケースでも、FreezingArchRule が役立つだろう。書き換えたルールが大きな変更であれば、プロダクションコードを一気に書き換えることはできず、時間を書けて反復的に改善することになるからだ。これは、技術的負債の四象限の右下にあたる「慎重で無自覚な負債」だと言える。

アーキテクチャとは「変化させられないもの、変化させにくいもの」とされてきたが、この右下の象限が示すように、変化の必要性は生じる。進化的設計(evolutionary design)進化的アーキテクチャ(evolutionary architecture)には、これが背景にあるのだろう。繰り返し変更を加えるソフトウェアプロダクトに関わっていれば経験的に気付くように、アーキテクチャが変化しないことを前提にしてしまうと、そのソフトウェアプロダクトの変更容易性は絶対的にも相対的にも低下していくことになる。このような事態を能動的・積極的に回避する用途としても、ArchUnitの活用を検討したいところだ。

アーキテクチャメトリクスの計測

静的コード解析ツールで計測するコード品質に関するメトリクスと同様に、アーキテクチャにもメトリクスがある。例えば、書籍『Clean Architecture』では、著者ロバート・マーチン(Robert C. Martin)による次のメトリクスが紹介されている。

メトリクス 説明
ファン・イン(fan-in) 依存入力数。コンポーネント内のクラスに依存している外部のコンポーネントの数
ファン・アウト(fan-out) 依存出力数。コンポーネント内にある、外部のコンポーネントに依存しているクラスの数
I(Instability) 不安定さ。 I = FanOut \div \left(FanIn + FanOut\right)
A(Abstractness) 抽象度。 Naコンポーネント内の抽象クラスとインタフェースの総数、 Ncコンポーネント内のクラスの総数とした場合、 A = Na \div Nc
D(Distance) 距離。 D = | A + I - 1 |

ArchUnitでは次のように、ComponentDependencyMetrics  を使って簡単に計測できる。ここで、afferent couplingはファン・イン、efferent couplingはファン・アウトの旧名称だ。

Set<JavaPackage> packages = classes.getPackage("com.example.myapp").getSubpackages();
MetricsComponents<JavaClass> components = MetricsComponents.fromPackages(packages);
ComponentDependencyMetrics metrics = ArchitectureMetrics.componentDependencyMetrics(components);

int fanIn = metrics.getAfferentCoupling("com.example.myapp.component");
int fanOut = metrics.getEfferentCoupling("com.example.myapp.component");
double i = metrics.getInstability("com.example.myapp.component");
double a = metrics.getAbstractness("com.example.myapp.component");
double d = metrics.getNormalizedDistanceFromMainSequence("com.example.myapp.component");

その他にどのようなアーキテクチャメトリクスがArchUnitでサポートされているかは、ArchitectureMetrics クラスから辿ることができる。

テストの実行パフォーマンス

少し気になるのは、テスト対象とするクラスのインポートに関するパフォーマンスだ。これについてはまだ詳しくは調べていないが、インポートされたクラスのロケーションごとにキャッシュされるようだ。同一のURLやJarからインポートしたクラスであれば、テストクラス間で再利用されるということだろう。

ちなみにこれはデフォルトの挙動で、次のように CacheMode.PER_CLASS を指定してやることで、テストクラス単位でのキャッシュに切り替えることもできる。

@AnalyzeClasses(packages = "com.example.myapp", cacheMode = CacheMode.PER_CLASS)
public class FooTest {
    ...

いずれにしても、ArchUnitによるアーキテクチャの本格的な自動テストは、多少の実行時間を要しそうだ。ビルドパイプライン上では、通常のユニットテストやスモールテストより後ろのステージに配置すべきかもしれない。

継続的な変更容易性の獲得による進化

ArchUnitによって、ソフトウェアアーキテクチャとしてデザインされた構造は「テスト容易性(testability)」を手に入れた。そのインパクトは大きい。その恩恵は、開発者が書いたコードを検証することだけにとどまらない。

テストとはすなわち、フィードバックを得ることでもある。ArchUnitで書いたテストコードは、ソフトウェアプロダクトの構造に対するフィードバックループを形成し、そのサイクルを高速化するはずだ。TDDを思い起こすと分かるように、テストコードによる短期間のフィードバックは、実装だけでなく、設計自体の変更も促す。TDDほど短期間でなくとも、少なくともこのプロセスはテストファーストだと言える。構造に関するテストコードの存在は、構造設計そのものの変更も促すのではないだろうか。そしてそれが、「変化させられないもの、変化させにくいもの」とされてきたソフトウェアアーキテクチャに変更容易性をもたらす。

テキストや図でドキュメント化された開発ガイドラインをこまめにアップデートする組織はあまり聞かないが、コード化されたルールならどうだろうか。テストで検出されず、コードレビューで頻繁にコメントされるような指摘事項は、すぐにテストコードとして書いてしまえば良い。逆に、テストによって違反として検出されることが多いルールが、実は現状にそくしたものではなく、変更容易性を悪化させているのだと気付くこともあるだろう。このようにして追加、変更されたルールは、即座にテストスイートとして機能する。そしてまたフィードバックを得る。ドキュメントベースのガイドラインでは、そうはいかなかった。変更箇所を開発者が読んで理解しない限り、機能しないからだ。

テスト容易性を得たアーキテクチャはもはや、静的なものではなくなる。フィードバックループの中でアーキテクトと開発チームが絶えず学習し、その成果としてルールが見直されることで、継続的に変更容易性を獲得し続ける。これこそが、「進化的(evolutionaly)」あるいは「進化可能性、進化容易性(evolvability)」と呼べるものではないだろうか。