Software Design ~ Strategy Pattern のプロジェクト分割に関する再考

経緯

以前作成したそこそこ大きなライブラリ + アプリは Strategy Pattern を採用しており、フットプリントを大きくしないために、各 Strategy を個別 DLL に分割して動的ロードする仕組みにしている。
このアプリを Clean Architecture 化するにあたり、この Strategy DLL 分割の依存関係と動的ロードを改善できないか、再考してみた。

csproj 参照依存の整理

今は Strategy Loader 側 csproj に全 Strategy 側 csproj への <ProjectReference Include="..."> 参照を記述している。
この参照が必要になる理由と排除の仕方がわかったため、改善を図る。

// 現行のアセンブリ取得
var aseembly = Assembly.Load(new AssemblyName(stragegy.AssemblyName));

// 改善案のアセンブリ取得
var aseembly = Assembly.Load(stragegy.DllName));

現行は AssemblyName を通じてアセンブリ取得するのに対し、改善案は DLL 名を通じてアセンブリ取得する。
AssemblyName は .NET VM 水準での管理であるため、当然、Strategy 側の論理情報を必要とし、参照が必要になる。
対して DLL 名指定は、ファイルグロブでファイルを掴みに行くため、Strategy 側の論理情報を必要としない。
ただ、これはファイルグロブで掴めることが前提となるため、当然、物理環境への依存度を高めてしまう。

しかし、この案件はそもそもが DLL 分割しており、csproj で論理情報を先行取得していたとしても、物理的に DLL が存在しなければ同じく失敗するため、実はリスク水準は変わらない。
というわけで、DLL 名指定によるアセンブリ取得に切り替え、csproj の参照記述を排除することにした。

改善したいもう1つのポイント

改善したいポイントがもう1つある。が、実現できそうにない ...
それは、strategy.DllName の情報を、Strategy 側のクラス属性として定義したいということ。

いまは、appsettings.json に記述し、アプリ起動時の初期化において、それを読みこんで辞書を作っている。
この、appsettings.json とクラス定義が離れているのが気持ち悪い。
Strategy クラスに関するメタ情報なのでクラス属性として定義し、ソースジェネレータでビルド時に属性を収集して静的に辞書コードを生成する、というのが理想形。

ここには2つの矛盾があって、実現するのはかなり厄介である。

まず1点目。Strategy 側から見れば、外から自分を呼び出すための情報を、そもそも自分が内包している構造であるということ。本質的な矛盾。

2点目。ソースジェネレーターは csproj 横断で動かない。
プロジェクトの独立性を維持するためなのだが、この制約が煩わしい。もしやるなら、Strategy 側でソースジェネレーターを動かし、cs ファイルではなく json ファイルを出力し、これを Strategy Loader 側のソースジェネレーターで収集して辞書コードを生成する必要がある。そして、ここにはビルドの依存関係がある。加えて、ソースジェネレーターはファイル I/O にも制約があって外部情報をなかなか取り込ませてくれない可能性がある。

Chat GPT に訊くと、他にも T4 (Text Template Transformation Toolkit) を使う方法がある、と言うが、それは .NET の世界では obsolete な手法であるため、採用できず。
訊くたびに回答が揺れ動き、堂々巡りになるところを見ると、確立したうまい方法はないようだ。人間の知恵がまだまだ必要だ、というのを、こういうところに感じる。

Software Design ~ Clean Architecture

経緯

以前、そこそこ大きなライブラリ + アプリを作成し、いまもあるところで定常的に動かしている。
けっこう大がかりなメタプログラミングであり、構造には拘ったのだが、それはライブラリ面であって、アプリ面の構造には "いま一つ" 感があった。
いままで、独力で大型ライブラリは開発したことがあるが、大型アプリ開発へはプログラマー PG として参画したことがない (SE, PM, Tech Lead としてなら数えきれないほどある) ということが原因だとは思う。
それがアプリの成長を阻害している面があるため、リファクタリングを視野に入れてみた。

DDD (Domain-Driven Design) を意識して設計したはずだが、アプリの構造はうまくない。
まあ、Strategy Pattern で業務ロジックを差し替えられるようにしているのが、問題を複雑化させている要因ではある。
バイナリを呼び出してリフレクションでインスタンス生成する、動的バインディングを独自実装しているのが1つ癌であることは間違いない。
その他にも、リリース期限に迫られて手抜き実装してしまったところがアプリ面にはある。

問題を総括すると、依存関係の整理が上手くいっていないということであると考えられるため、Clean Architecture を意識して機能再配置を検討してみた。
改めて考えてみると、まあ、使うべき道具は DI (Dependency Injection) だとは思う。

Clean Architecture の要諦

Clean Architecture の考え方を突き詰めていくと、以下の 3 軸の向きに注目すべきだ、ということに行き着くように思う。

  1. 依存関係
    • 依存とはコードの参照方向のこと。固有性の強いものから汎用的なものを参照するように配置すべし。
  2. コントロールフロー
    • エントリーポイントにフローがあり、それ以外はモジュールとして設計すべし。
    • これは、モジュール間疎結合、ひいては、テスト容易性につながる。
  3. DI のレセプター / プロバイダーの関係
    • 固有実装を担う方をプロバイダー (= dependency to be injected) とすべし。
    • 制御の主導権は抽象側が持つべき。

ポイントは、この 3 軸は必ずしも同じ方向を向かない、ということだ。
そして、3 軸を考慮すると、機能配置 (名前空間定義・モジュール分割) は、ほぼ間違いなく、針の穴を通すがごとく、これしかないという形態に一意に決まる。

また、Clean Architecture は依存関係に注目した論理層分割であり、物理 (ビルド生成物) 分割とは別物であるということにも留意する必要がある。物理分割の基準は下記の 3 つ。

  1. 再利用性
  2. デプロイ単位分割
  3. 変更の局所性 (テストやビルドの高速化)

得られたインサイト

Clean Architecture は、依存関係を整理し、モジュール結合を開発者自身が積極的に管理・処理することが目的であるため、動的 (自動) バインディングやそれを前提とした過度なモジュール分割は可能な限り避けるべき。
バインディングを動的・自動 (ランタイム解決) にしてしまうと、テストも拡張も阻害される。気の利いた仕組みを作ったようでいて、自縄自縛になっている。

Insight ~ 共感志向と解決志向

共感志向と解決志向

ちょっとよいインサイトを得たため、記録しておく。

共感というものは、何の解決をも導かず、幸福から遠のく道である。
現代社会は "共感のインフレ" にあり、痛みから目を逸らさせる言葉が美徳のように消費されている。
幸福は、痛みを乗り越え、真理を見据える者に訪れる。

Programming w/ C# ~ ジェネリッククラスの型引数の取得

ジェネリッククラスの型引数の取得

シンプルな表現できわめてパワフルなコードが書けてしまった。

ある型がジェネリッククラス TGeneric<T1, T2 ...> に合致するかを検証し、合致する場合は型引数 T1, T2 ... を返す。isRecursive = true のときは、これを親クラスに遡って検証する。

Web に載っているような TDerived is TBase baseType というマッチングパターンや typeof(TDerived).IsAssignableTo(typeof(TBase)) という参考書的なコードでは、継承関係まで拾う検証をできない。
型引数のバリエーションが存在するジェネリッククラスは、簡単に型検証することができず、苦労するところ。

記述方法

こんな感じ。

// 定義 (拡張メソッド)
public static Type[]? GetGenericTypeArguments(this Type? type, Type genericType, bool isRecursive = false) =>
  (isRecursive, type) switch {
    (_    , { IsGenericType: true } t) when t.GetGenericTypeDefinition() == genericType => t.GetGenericArguments()                     , // 自クラスの検証
    (true , { BaseType: {} b      }  )                                                  => b.GetGenericTypeArguments(genericType, true), // 親クラスの検証 (再帰)
    _                                                                                   => null                                        , // 終了条件 : 自クラスが非ジェネリック、再帰で親クラスが null
  };

// 使い方
Type[]? typeArgs = targetType.GetGenericTypeArgument(typeof(TGeneric<,,>), isRecursive: true); // 合致する場合は new Type[] { typeof(T1), typeof(T2), typeof(T3) } が返る  

Programming w/ C# ~ IEqualityComparer<T> の記述

IEqualityComparer の記述

Dictionary のキーに文字列配列を使いたくて IEqualityComparer<T> を DI する。

記述方法

こんな感じ。

public class ExtDictionary<TValue> : Dictionary<string?[], TValue> {
  public ExtDictionary() : base(new HelperComparer()) {
  }

  private class HelperComparer : IEqualityComparer<string?[]> {
    public bool Equals(string?[]? a, string?[]? b) =>
      (a, b) switch {
        ({} x, {} y) => x.SequenceEqual(y, StringComparer.OrdinalIgnoreCase), // 大文字小文字区別なし
        (null, null) => true                                                ,
        _            => false                                               ,
      };

    public int GetHashCode(string?[] obj) =>
      obj?.Aggregate(17, (hash, curr) => unchecked((hash * 31) + (curr?.ToUpper().GetHashCode() ?? 0)) ) ?? 0;
  }
}

勘違いしていたこと

.GetHashCode() は、可能な限り C# が定義している各オブジェクトのハッシュコードとグローバルに整合させなければならないかと勘違いしていた。そのため、匿名オブジェクト化してハッシュコードを採るなど、できるだけ独自定義してしないようにしていたが、自分が定義する範囲で簡易版の "不" 一致性識別子として機能すれば問題なく、独自ルールに基づき定義してよかった。

さらに、ハッシュコードはオブジェクト参照 (アドレス番地、あるいは、そのハッシュコード値) を返すと勘違いしていた。IEqualityComparer<T> は、まずハッシュコードのマッチにより粗い一致検証をし、マッチしなければ false を返す、マッチすれば .Equals() の詳細な一致検証に進むという二段階で確認する。そのため、.Equals() 内部で .GetHashCode() を用いて一致性確認するのは本末転倒になる。

Programming w/ C# ~ 型フォワーディング

型フォワーディング

あるバイナリ (DLL) から別のバイナリに、クラスやインターフェースの定義を移しつつ、元のバイナリ参照を有効にしておく方法に型フォワーディングがある。
巷間にある解説記事が不親切で、説明が欠落しているところがあるため、ここに補足しておく。

UserLib (実体定義を含むバイナリ)

プロジェクトファイル
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
</Project>
プログラムファイル

ここに実体であるクラスやインターフェースを定義しておく。

namespace UserLib;

public class Tool {
  public Tool() {
  }
}

UserDom (形式定義とフォワーダーを含むバイナリ)

プロジェクトファイル

ここに実体定義を含むバイナリへの参照を記述しておく。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="../UserLib/UserLib.csproj" />
  </ItemGroup>
</Project>
プログラムファイル
namespace UserDom;

public class Logic {
}
フォワーダーファイル

ここに型フォワーダーを TypeForwardTo 属性で記述しておく。
ファイル名は AssemlyInfo.cs または TypeForwarders.cs とする。
しかし、それだけでは機能せず、加えて当該バイナリ内に形式定義を記述しておく必要がある。
このことに言及している記事が見当たらない。
形式定義には abstract 宣言、継承関係、制約等の付加情報は不要であり、型の存在だけ示せばよい。

using System.Runtime.CompilerServices;

[assembly: TypeForwardedTo(typeof(UserLib.Tool))]

namespace UserDom;
public class Tool {} // 形式定義 (空のクラス定義) が必要!

UserApp (形式定義を参照するバイナリ)

プロジェクトファイル

ここに形式定義を含むバイナリへの参照を記述しておく。
実体定義を含むバイナリへの参照の記述は不要である。(必要だったら、型フォワーディングの意味がない。)

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
<!--<ProjectReference Include="../UserLib/UserLib.csproj" />-->
    <ProjectReference Include="../UserDom/UserDom.csproj" />
  </ItemGroup>

</Project>
プログラムファイル

ここで形式定義を参照する。
実体定義を参照する必要はない。

namespace UserApp;

public class Program {
  public static int Main(string[] args) {
    var tool = new UserDom.Tool(); // 形式定義の参照

    Console.WriteLine($"Hello, World! -- {tool.GetType().Name}");

    return 0;
  }
}

留意点

以上の赤字部分が巷間の解説にない補足であるが、それに加えて、下記の点に留意する必要がある。

形式定義されたクラスを参照する実クラスを形式定義を含むバイナリ内に定義できないことがある。
なぜなら、形式定義を含むバイナリ内においては、形式定義と実体定義の両方が参照可能であり、競合を解決できないため。
特に、形式定義を含むバイナリ内ながら別の名前空間 (サブ名前空間 UserDom.DataModel など) を定義し、そこから形式定義クラス (Tool) を参照する場合に注意が必要である。その名前空間からは形式定義クラス (UserDom.Tool) も実体定義クラス (UserLib.Tool) も同時参照可能であるため、名前空間を指定せずクラス名 (Tool) だけを記述した場合は競合する。

Programming w/ C# ~ ちょっとおもしろいパターンマッチ記法の発見

発見

ちょっとおもしろいパターンマッチ記法を発見をした。

ネスト構造の任意データ型の文字列化

ネスト構造の任意データ型の要素1つ1つに再帰である処理を掛けてして文字列化したく、こんな処理を書いてみた。

public static IEnumerable<string> ToStringRecursive(this IEnumerable<object?> source) =>
  source
    .Select( x => x switch {
      IEnumerable<object?> seq => $"({seq.ToStringRecursive().Join(", ")})",
      _                        => x?.ToString() ?? "NULL", // 実際にはココにもう少し複雑な処理が入る
    } );

要素が単体ならば _ に落ちて x?.ToString() ?? "NULL" で処理する、要素がさらにシーケンスになっているならば再帰して1つの文字列にしたものを () で囲む、というつもり。

ところが、int 配列 new[] { 1, 2, 3 } をこれに掛けて処理しようとすると、IEnumerable<object?> にパターンマッチさせる意図に反して _ に落ち、int 配列型デフォルトのメソッド Type.ToString() が掛かって System.Int32[] という出力になってしまう。

Chat GPT 先生に聞いて調べてみると、IEnumerable<object?> の型引数 object? は何でも受けられる基底かと思いきや、なんと、ジェネリックの場合は参照型にしか効かず共変性が失われるのだとか。

そこで、値型・参照型無差別な IEnumerable にパターンマッチさせるにはジェネリックにしなければよいのだ、とこのように改善すると int 配列はうまく通る。

public static IEnumerable<string> ToStringRecursive(this IEnumerable<object?> source) =>
  source
    .Select( x => x switch {
      IEnumerable seq => $"({seq.Cast<object?>().ToStringRecursive().Join(", ")})",
      _               => x?.ToString() ?? "NULL",
    } );

new[] { 1, 2, 3 }.ToStringRecursive();

// Result 
// (1, 2, 3)

階層構造データのテスト

階層構造データもうまくいくぞ。よしよし。

new object[] { 1, 2, 3, new[] { 4, 5, 6 } }.ToStringRecursive();

// Result 
// (1, 2, 3, (4, 5, 6))

文字列を含めたテスト

文字列も要素に加えてみるか。あれ!? なんで X が一段階よけいに階層化されるんだ ... あっ!! そうか ...

new object[] { 1, 2, "X", new[] { 4, 5, 6 } }.ToStringRecursive();

// Result 
// (1, 2, (X), (4, 5, 6))

文字列 string 型は char[] とも解釈されうるから IEnumerable にひっかかってしまうのか ... 2文字以上で試してみるとやはり。

new object[] { 1, 2, "Test", new[] { 4, 5, 6 } }.ToStringRecursive();

// Result 
// (1, 2, (T, e, s, t), (4, 5, 6))

おもしろい記法

ではこう書かなければ。string 型のときは除くなんて、こんな形のパターンマッチ記法は初めて見た。

public static IEnumerable<string> ToStringRecursive(this IEnumerable<object?> source) =>
  source
    .Select( x => x switch {
      IEnumerable seq when x is not string => $"({seq.Cast<object?>().ToStringRecursive().Join(", ")})",
      _                                    => x?.ToString() ?? "NULL",
    } );

new object[] { 1, 2, "Test", new[] { 4, 5, 6 } }.ToStringRecursive();

// Result 
// (1, 2, Test, (4, 5, 6))