⚖️

【ソフトウェア設計】モジュールをどう分割するのか?

2024/02/25に公開

はじめに

前々回や、前回に引き続き、ソフトウェア設計の指針に関する話をしたいと思います。
関数やクラス、そしてサービスなどシステムの塊の単位をモジュールと呼び、モジュールを作る事で、認知負荷を下げ複雑性と戦うという話をしてきました。では、モジュールは「いつ」分割するのが良いでしょうか? また、他にも共通モジュールを不用意に作ってしまって苦労した人も多いのでは無いでしょうか? 今回はそのあたりの話をしていきます。

TL;DR

以下があればモジュール設計を見直す

  • 単純な要件/普段の利用に対して、タイプ量や約束事が多い
  • 共通モジュールが「使われ方」に依存する
  • モジュールの役割を一言で説明できない
  • コード管理や性能/データ整合性など利用に際してのペナルティが高い

分割 is NOT 正義 - FizzBuzz Enterprise Edition

複雑性を排除するためにモジュール分割をすることは重要ですが、闇雲にモジュールを増やしても複雑性がむしろ増してしまう事もあります。

その最たる例を紹介しましょう。FizzBuzzというコードがあります。1-100までの数字を表示して、3の倍数の場合はFizz, 5の場合数はBuzz, 15の倍数でFizzBuzzと標準出力をするものです。Javaでの単純に実装した場合は以下のようになるでしょう。

public class FizzBuzz {
    public static void main(String[] args) {
        for (int i = 1; i <= 100; i++) {
            System.out.println((i % 15 == 0) ? "FizzBuzz" :
                               (i % 3 == 0)  ? "Fizz" :
                               (i % 5 == 0)  ? "Buzz" : i);
        }
    }
}

これを様々なことを考慮して、モジュールに分割していきます。その極致ともいえるのがFizzBuzz Enterprise Editionというジョークコードです。EJB2を彷彿とさせる「えんたーぷらいず」な重厚に抽象化された実装のサンプルです。

public final class Main {
	public static void main(final String[] args) {
		final ApplicationContext context = new ClassPathXmlApplicationContext(Constants.SPRING_XML);
		final FizzBuzz myFizzBuzz = (FizzBuzz) context.getBean(Constants.STANDARD_FIZZ_BUZZ);
		final FizzBuzzUpperLimitParameter fizzBuzzUpperLimit = new DefaultFizzBuzzUpperLimitParameter();
		myFizzBuzz.fizzBuzz(fizzBuzzUpperLimit.obtainUpperLimitValue());

		((ConfigurableApplicationContext) context).close();
	}
}

public interface FizzBuzz {
	void fizzBuzz(int nFizzBuzzUpperLimit);
}
....

とてもじゃないですが全部載せきれないので起点となるコードを抜粋していますが、最初のコードと比べて何をしたいのか全く分かりいませんね。全体として89個のクラスがあります。明らかに認知的負荷が高いです。ほとんどがShallow Moduleばかりで意味のある抽象化を提供していないか、シンプルな要件に対して過剰に柔軟性を持たせた設計になっています。 「単に分割すれ良いというわけでは無い」 事が非常に理解しやすいサンプルです。

念のために言いますが、こうした重厚な設計が常に悪いわけではありません。複雑な機能要件/非能要件に対応するために必要なケースはあるでしょう。認知負荷を上げてでも得るべきトレードオフが成り立つ場面はあります。しかし、FizzBuzzは明らかに単純な要件であり、過剰な設計は認知負荷もタイプ量も増やすので、避けるべきです。

こうした事はフレームワークの歴史からも見て取れます。例えばJavaのWeb開発において、元々は分散システムの基盤として生み出されたため、透過的なリモート実行のために複雑なインタフェースを持っていたEJB2ですが、当時の要件が 「モノリスなWebアプリケーションを開発する」 というものだったので、常に不必要な重厚さと戦っていました。結果として、StrutsやSpring、あるいは別言語のRailsなど多くのより限定的な条件である分散しないシステムの開発に向いたFWが普及していきました。より高度に抽象化されてるのはEJB2ですが、当時の開発者にとって不要な要件に対する考慮が多かったのでインタフェースが複雑化する一因になっていました。世の中には 「最初から考慮しておかないと後からの追加/修正は困難」 というものもそれなりにあるので、見極めが難しいですが、あまり教科書的な理想の抽象化に捕らわれず、「認知負荷を下げることに繋がるか?」 を起点とし、むしろ上げてしまうケースは 「トレードオフは成り立つか?」 を強く意識する必要があります。

いつ分割、どうわけるのか?

では、メソッドやクラスをはじめとしたモジュールはいつ分割するべきでしょうか?
まず、複雑性の軽減に寄与するかが重要なポイントです。細分化を進めると、以下の問題が生じる可能性があります。

  • すべての部品を把握することが難しくなる
  • 部品を結びつける「グルーコード」が増加

これがどのくらい大変かは既にFizzBuzz Enterprise Editionで思い知ったのではないでしょうか。分割は部品が実際に独立している場合に効果が高いです。しかしながらデータフォーマット等、情報を共有している時には同一モジュールに配置することが妥当です。

また、I/Fをシンプルにできる場合には統合を検討すべきです。APoSDではJavaのI/Oを例にFileInputStreamとBufferedInputStreamを組み合わせて一つの機能として提供し、バッファリングをデフォルトオプションとして提供するのがベターと説明しています。現在はFilesで解決していますが、JavaのI/O周りは 「部品の組み合わせで何でも出来る」 という柔軟性を重視し過ぎた結果、「普段使いが常に大変」 という悪名高い設計になっていました。そのためAPoSDでは、通常使う振る舞いをデフォルトにして、レアなケースをオプションとすることで一つのモジュールにしI/Fをシンプルに維持することも出来ると説明しています。実装的にはコンストラクタやメソッドのオーバーロードを上手く使う、という事ですね。似た観点で 「正しいものを簡単にする(デフォルト操作にする)」 という考え方も重要です。人間は面倒くさがりなので、例えば例外処理とか文字コードとかタイムゾーンとか乱数とか暗号化強度とか、適切な考慮をして正しい実装をすると適切なライブラリを使っても行数が数行になったりして、面倒なこともあります。それよりは正しくなくても記述が簡単な方を選んでしまいがちです。ほとんどのケースでそれで良いのかもしれませんが、正しくないためエッジケースで問題が出やすいですし、何より間違った方法が広まってしまいます。これを防ぐためにも、「正しい動作をデフォルトにする」 というのは意外と重要な設計指針です。

その他にも「20行以上のメソッドは分割するべき」という視点もありますが、これは運用には注意が必要です。もちろん目安としては問題無いかもしれませんが、メソッドの分割はインターフェースの追加を意味し、結果として複雑性を増加させる可能性があります。システム全体がシンプルにならない場合には「20行以上のメソッドは分割すべき」といった機械的なルールを適用するべきではありませんし、シンプルなうちは50行でも100行でも問題はありません

メソッド分割に際しては、子メソッドを読む人は親メソッドについて知る必要がなく、親メソッドを読む人は子メソッドの実装を気にしなくて良い、というモジュール分割の原則を意識するべきですが、認知負荷と変更箇所の局所化のためにprivate methodレベルなら、もう少し気軽にやっても良い、と個人的には考えています。

また、コードリポジトリが別になるライブラリ化や、分散システムになるマイクロサービス化など、粒度の大きなモジュールに分割する時は覚悟が必要です。これらのケースではモジュールのバージョン管理や性能面等により分割に伴う運用コストが増大しがちです。時にはモジュール分割で下げた認知負荷以上の複雑性をプロダクトに導入することになります。こうしたトレードオフは良く考慮して設計が必要となります。

誤った共通化

複数個所に発生する処理をモジュール化し、複数の別モジュールから呼ぶ事を共通化と呼ぶこともあります。変更箇所を局所化し、修正の増大を防ぐことが出来るので複雑性に対するアプローチとしても良く使われる方法です。しかし、誤った共通化をしてしまい、保守性を大きく下げてしまうケースも良くあります。以下は、ECサイトにおけるポイント管理と購入処理の誤った共通化の例です。

public class Transaction {
    protected User user;
    protected double amount;

    public Transaction(User user, double amount) {
        this.user = user;
        this.amount = amount;
    }

    public void process() {
        // 共通のトランザクション処理
        System.out.println("Processing transaction for " + user.getName() + " amount: " + amount);
    }
}

public class PurchaseTransaction extends Transaction {
    public PurchaseTransaction(User user, double amount) {
        super(user, amount);
    }

    @Override
    public void process() {
        super.process();
        // 購入処理のロジック
        System.out.println("Processing purchase specifically for " + user.getName());
        // 在庫の減少、購入履歴の更新など
    }
}

public class PointTransaction extends Transaction {
    private int points;

    public PointTransaction(User user, int points) {
        super(user, 0); // ポイントトランザクションでは金額が直接関係ないため、0を設定
        this.points = points;
    }

    @Override
    public void process() {
        super.process();
        // ポイント加算または使用のロジック
        System.out.println("Processing points specifically for " + user.getName() + " points: " + points);
        // ユーザーポイントの更新など
    }
}

このコードは購入とポイント管理が一見似ているので親クラスを作りそれを共通処理としています。しかし、実際にそれは異なるユースケースの処理です。親クラスの修正にそれぞれの子クラスの振る舞いが常に影響されてしまい影響チェックが大変ですし、現時点でもPointTransactionamountを管理するわけじゃないので、コンストラクタの時点で無理やり対応させています。こうしたコードは密結合で保守性が低いものとなります。また、凝縮度が高いわけでもありません。

もちろん、こうした共通化の罠はクラスレベルのモジュールではなく、メソッドレベルのモジュールでも発生します。同じくECサイトで購入とポイントの処理を共通化したロジックです。

public void processTransaction(String transactionType, User user, double amount, Integer points) {
    // トランザクションの共通前処理
    preProcessTransaction(user, transactionType);
    if ("PURCHASE".equals(transactionType)) {
        // 購入処理
        System.out.println(user.getName() + " has made a purchase of " + amount + " dollars.");
        // 実際の購入処理ロジック(在庫減少、購入履歴の更新など)
    } else if ("POINT".equals(transactionType)) {
        // ポイント加算または使用処理
        if (points != null) {
            System.out.println(user.getName() + " has " + (points > 0 ? "earned " : "used ") + Math.abs(points) + " points.");
            // 実際のポイント加算または使用のロジック
        }
    }
    // トランザクションの共通後処理
    postProcessTransaction(user, transactionType, success);
}

たしかに共通の処理は前処理や後処理などありますが、PURCHASEPOINTかで振る舞いを分けてしまっています。しかも常にamountpointsを要求しますがトランザクションの種類によっては不要なので0とか適当な値を入れて無いといけないのも複雑性を増す要因になります。

共通化とは似た構造をまとめる事ではありません同じ役割をまとめる事です。役割はビジネス分析、モデリングから導くことが出来ます。似た構造の共通モジュールの作成は複雑性を下げるどころか上げてしまう悪い共通化です。なので、共通化出来そうと見つけても 「これは本当に同じ意味なのか? 偶々似てるだけか?」 を考える必要があります。また最初からすべてが分かるわけでもないので、初期の段階では共通ロジックだと考えていても、ビジネスへの理解が進んで例外処理が紛れ込んだ時点で必ず対策を打ってください。大きくなってからでは対応コストが増えるばかりです。

このようなケースではそもそも共通のモジュールにしないか、より適切な抽象モジュールを作成する必要があります。この場合は、ストラテジーパターンを適用するのが効果的です。

public class TransactionService {
    public void processTransaction(User user, Consumer<User> transactionConsumer) {
        preProcess(user);
        transactionConsumer.accept(user);
        postProcess(user);
    }
    // 省略
}

// 購入処理の例
double purchaseAmount = 100.0;
service.processTransaction(user, (User u) -> {
    // 購入処理
    System.out.println(u.getName() + " has made a purchase of $" + purchaseAmount);
    // 実際の購入処理ロジック(在庫減少、購入履歴の更新など)
});

// ポイント処理の例
Integer points = 200;
service.processTransaction(user, (User u) -> {
    // ポイント加算または使用処理
    if(points != null){
       System.out.println(u.getName() + " has earned " + points + " points");
    }
    // 実際のポイント加算または使用のロジック
});

かなり、シンプルになりましたね。不要な条件分岐もパラメータも必要ありません。実際のところコード量は修正後の方が少し増えているのですが、読みやすさは各段と上がっていてシンプルになったとも感じます。決して、誤った共通モジュールを見たら常に、こうした構造にしろ、という意味ではなく、共通モジュール化を辞めて個別モジュールを作る事を含めて状況に応じた選択肢を選ぶことが重要です。

誤った共通モジュールを避けるには複雑性とのトレードオフを常に意識することが重要です。以下が発生すると危険信号なので、共通モジュール化を辞めるないしは、既存の共通モジュールを見直す事を検討してください。

  • 共通モジュールが「使われ方」に依存する
  • モジュールの役割を一言で説明できない
  • コード管理や性能/データ整合性など利用に際してのペナルティが高い

共通モジュールが「使われ方」というのは例えばif文などで多くのフラグを処理して、異なる振る舞いをさせる時です。例えばさっきほどのファイルのI/Oでのバッファリング処理やあるいは文字コードの指定に関する話など、必ずしも 「if is 悪」 では無いですが、使われ方に依存していると中身を見ないと振る舞いが把握出来ず認知負荷が高いのです。たくさんの条件分岐で使われ方に依存した処理をすると 「共通」 ではない事が分かるでしょう。

また、一言でそのモジュールを説明できなくなっているのであれば、コードが類似してるだけで異なる役割が混合しているので、分割を検討する必要があります。これは機能追加や業務理解の結果で後からそうなることもあります。

他にも共通ライブラリとして一部機能を使っているけど、軽微な一部のみなのでそのためだけにそのライブラリのライフサイクル(脆弱性や他要件でのバージョンアップ)に付き合うのがつらい、というケースも非常に多く発生します。そうした場合は、同じコードの必要な部分を自分のプロダクトに入れたほうが良いケースも多いです。

同一コードの分散は変更箇所の増大につながりますが、モジュールの依存が増えることも同じく複雑性の導入に他ならないので、コードの分散を恐れ過ぎないず、誤った共通モジュール をしない、見つけたら早めに解消を心がけましょう。

まとめ

誤った共通化に関する話を含めて、いつモジュール分割をするのか、という点を説明しました。明確なメトリクスを見いだせてる訳ではないのですが、以下の点が発生したら危険信号として不適切なモジュール化の可能性を考慮し、設計の見直しを検討したいですね。

  • 単純な要件/普段の利用に対して、タイプ量や約束事が多い
  • 共通モジュールが「使われ方」に依存する
  • モジュールの役割を一言で説明できない
  • コード管理や性能/データ整合性など利用に際してのペナルティが高い

それでは、Happy Hacking!

Discussion