エンジニアのはしがき

プログラミングの日々の知見を書き連ねているブログです

Javaプロジェクトを参照してClean ArchitectureのD(Distance)値を出力させてみた

最近Kindleで「Clean Architecture」を読んでいます。 ボブおじさんことRobert C.Martin氏の有名な著書ですね。

この書籍の14章では「SAP = Stable Abstractions Principle(安定度・抽象度等価の原則)」という原則が紹介されるのですが、そこでは依存性管理の指標の1つとしてInstability, Abstractness, Distanceというパラメータが提示されています。 これらのパラメータはそれぞれ実際のソースコードを参照して計算式を使って算出することが可能なものになっています。

そこで、実際に手元にあるJavaプロジェクトのコードを参照して、これらのパラメータを算出・出力して適切な依存性管理ができているのかを分析してみると面白いのではと思い試してみました。

前提の話

まず、Instability, Abstractness, Distanceの定義について書籍の内容をまとめてみます。

  • Instability(I)
    • 算出方法:ファン・アウト ÷ (ファン・イン + ファン・アウト)
      • ※ファン・イン=コンポーネント内のクラスに依存している外部コンポーネント数。
      • ※ファン・アウト=コンポーネント内における外部クラスに依存しているクラス数。
      • 値は0~1の範囲をとる
        • 0は最も安定しているコンポーネントであることを示す
  • Abstractness(A)
    • 算出方法:A = Na ÷ Nc
      • ※Nc = コンポーネント内のクラスの総数
      • ※Na = コンポーネント内のabstract classとinterface
      • 値は0~1の範囲をとる
        • 0であればコンポーネントにabstract class, interfaceが一切含まれないことを示す
  • Distance(D)
    • 算出方法: D = | A + I - 1 |
    • 値は0~1の範囲をとる
      • 0であればコンポーネントが主系列(※後述)上にあることを示す

そしてこれらのパラメータの関係性を図にしたものが以下です。

縦軸がA、横軸がIになっており、斜めのラインが「主系列」と定義されており この主系列に近いほどコンポーネントにとって理想的な場所と言われています。

Dは0に近いほど主系列に近い...つまり依存性管理においてコンポーネントが望ましい状態ということを示しています。 逆に1に近いほど主系列から遠く、依存性管理においてコンポーネントが望ましくない状態であると言えるでしょう。

やりたいこと

さて前提の説明はここまでにして、今回の目的と実現したいことは以下です。

目的

  • 既存のJavaプロジェクトのI, A, Dの値を分析し、依存関係の改善が必要なコンポーネントはどの辺に存在するのかをあぶり出してみる
    • コードをどこからリファクタリングしていくのか考える時の助けになるかもしれない

実現したいこと

  • 既存のJavaプロジェクトの特定パッケージにおける依存関係をチェックし、I, A, Dの値をそれぞれ計算する
  • 計算したI, A, DについてCSVファイルに出力する

実際のソースコード

では分析したいJavaプロジェクトに追記をしていきます。今回はGradleでプロジェクト管理しているケースの実装となります。

なお今回はPerplexityくんにソースコード案を出してもらって手直しをしました。やりたいことが定まっててかつ小規模な実装をするときにAIは便利ですね~。

※余談ですがJDpendという分析ライブラリでも同様のことができるようなのですが、2020/7からメンテがされておらず今回は使用を断念...。 github.com

build.gradle.kts へ追記する

...
dependencies {
    implementation("io.github.classgraph:classgraph:4.8.172")
}
...
tasks.register<JavaExec>("runCleanArchitectureMetrics") {
    group = "application"
    description = "Runs the CleanArchitectureMetrics main class with specified arguments"

    // ここでsetする値は適宜完全修飾名に書き換えてください
    mainClass.set("your.package.CleanArchitectureMetrics")
    classpath = sourceSets["main"].runtimeClasspath

    args = providers.gradleProperty("appArgs").orNull?.split(" ") ?: emptyList()
}
...

CleanArchitectureMetrics.java を任意の場所に新規作成

package com.birdseyeapi.metrics;

import io.github.classgraph.*;
import java.io.FileWriter;
import java.io.IOException;
import java.util.*;

public class CleanArchitectureMetrics {

    public static void main(String[] args) throws IOException {
        if (args.length < 2) {
            throw new IllegalArgumentException(
                    "Invalid arguments. Usage: java CleanArchitectureMetrics <package> <output.csv>");
        }
        final String targetPackage = args[0];
        final String outputCsv = args[1];

        final Map<String, ClassInfo> classInfoMap = new HashMap<>();
        final Map<String, Set<String>> efferentMap = new HashMap<>();
        final Map<String, Set<String>> afferentMap = new HashMap<>();

        try (ScanResult scanResult = new ClassGraph()
                .enableAllInfo()
                .enableInterClassDependencies()
                .acceptPackages(targetPackage)
                .scan()) {

            for (final ClassInfo classInfo : scanResult.getAllClasses()) {
                classInfoMap.put(classInfo.getName(), classInfo);

                final Set<String> efferents = new HashSet<>();
                for (final ClassInfo dependency : classInfo.getClassDependencies()) {
                    if (!dependency.getPackageName().startsWith(targetPackage))
                        continue;
                    efferents.add(dependency.getName());
                }
                efferentMap.put(classInfo.getName(), efferents);

                for (final String dep : efferents) {
                    afferentMap.computeIfAbsent(dep, k -> new HashSet<>()).add(classInfo.getName());
                }
            }
        }

        final Map<String, List<ClassInfo>> packageClassMap = new HashMap<>();
        for (final ClassInfo ci : classInfoMap.values()) {
            packageClassMap.computeIfAbsent(ci.getPackageName(), k -> new ArrayList<>()).add(ci);
        }

        try (final FileWriter writer = new FileWriter(outputCsv)) {
            writer.write("ClassName,Abstractness,Instability,Distance\n");
            for (final String pkg : packageClassMap.keySet()) {
                final List<ClassInfo> classes = packageClassMap.get(pkg);
                final int total = classes.size();
                int abstractCount = 0;
                int interfaceCount = 0;

                for (final ClassInfo ci : classes) {
                    if (ci.isAbstract())
                        abstractCount++;
                    if (ci.isInterface())
                        interfaceCount++;
                }
                final double A = (double) (abstractCount + interfaceCount) / total;

                int efferent = 0;
                int afferent = 0;
                for (final ClassInfo ci : classes) {
                    final Set<String> eff = efferentMap.getOrDefault(ci.getName(), Collections.emptySet());
                    efferent += eff.size();
                    final Set<String> aff = afferentMap.getOrDefault(ci.getName(), Collections.emptySet());
                    afferent += aff.size();
                }
                final double I = (efferent + afferent) == 0 ? 0 : (double) efferent / (efferent + afferent);
                final double D = Math.abs(A + I - 1);

                for (final ClassInfo ci : classes) {
                    writer.write(String.format("%s,%.3f,%.3f,%.3f\n", ci.getName(), A, I, D));
                }
            }
        }
        System.out.println("CSV出力完了: " + outputCsv);
    }
}

コードの実行

計算対象とするパッケージ名と出力するCSVファイル名をargsに指定してあげると、CSVが出力されるはずです。

./gradlew runCleanArchitectureMetrics -PappArgs="com.example.yourpackage result.csv" --stacktrace

実行結果

さて出力されたCSVの中身を見てみましょう。

例えばこの結果を見ると、Distanceが最も1に近いSummarizeNewsはコンポーネントとして望ましくない状態とみなせるかもしれません。 Abstractnessを上げる(抽象化する)方向でリファクタリングする、という方向性は考えられそうですね。

このような形で、依存関係の設計上の問題をあぶり出す手段の1つとしてDistanceを活用してみても良いかもしれません。