連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index
ビルドは通る。レビューも通る。
なのに本番だけ落ちる。しかも自分の端末では再現しない。
考えるのを後回しにして、csprojにプロジェクト参照を追加し、共通ライブラリのCommonに便利メソッドを増やす。
次の改修で、そこが火種になる。
抜けやすいのは「依存の向き」「境界」「配布単位」が揃っていないこと。
このページでは、.NET 8 / WinFormsで依存管理を壊れにくくする型をまとめる。
まず言葉を置く(ここで詰まらない)
- 依存の向き: どのプロジェクトが、どのプロジェクトを参照してよいかの方向
- 境界(Contracts): 役割の境目。Interface/DTO/Resultなど「約束」だけを置く場所
- 配布単位: 端末に展開する単位。フォルダ丸ごと入替できる形が強い
このページのゴール
- 参照トラブルを「どこで死んでいるか」で分類し、調査対象を最短で確定する
- 依存の向き(UI -> Application -> Domain)を決め、相互参照を消す
- 境界を「契約(Interface/DTO/Result)」で切り、共有プロジェクトで泥団子を作らない
- 参照方式とバージョン管理を揃え、端末差/人差を減らす
- 配布単位を決め、残骸と欠落を潰す
- 起動時に「何がロードされたか」を記録し、切り分けを速くする
結論: 依存は「向き」と「境界」と「配布単位」で決まる
依存管理が運用で壊れる原因は、実装の巧拙よりも「依存の向き」「境界の曖昧さ」「配布単位の曖昧さ」に寄る。
| 観点 | 壊れやすい状態 | 壊れにくい状態 |
|---|---|---|
| 依存の向き | UIが何でも参照する/相互参照がある | UI -> Application -> Domain の片方向 |
| 境界 | 共有プロジェクトに何でも入る | 契約(Interface/DTO)を境界にする |
| 配布単位 | exeフォルダにdllを雑に同梱/手作業で足し引き | 「同梱するもの」を決めて検証する |
| バージョン | ローカル参照が混在/参照元不明 | PackageReference等で一貫管理 |
| 実行時解決 | 何がロードされたか不明 | 起動時に Name/Version/Location を残す |
依存解決は地雷原。踏むと実行時に落ちる。
似ているトラブルを先に切り分ける
参照の崩れ方は大きく2系統に分かれる。
- 構築時(restore/build)で死ぬ: NuGet競合/依存解決経路/SDK差
- 実行時(run)で死ぬ: 配布残骸/同名別dll/動的ロード
構築時の止血はE02にまとめてある。
E02: 参照が壊れる NuGet競合の止血と恒久対策
ここでは「構造(向き/境界/配布単位)」を揃えて、そもそも運用トラブルを起こしにくくする型を扱う。
まず分類: 参照の崩れは3つの層で起きる
| 層 | どこに出る | 代表的な症状 | 確定情報(これだけ取る) | 一撃の止血 |
|---|---|---|---|---|
| restore(依存解決) | ビルド出力/CIログ | NUxxxx, 端末差で依存解決結果が変わる |
dotnet list package --include-transitive / obj/project.assets.json
|
lock導入/中央管理/キャッシュ掃除 |
| build(コンパイル) | ビルド出力/CIログ | CS1705等の参照Version差 | 失敗プロジェクトと参照先Version | Version統一/参照方式統一 |
| run(実行時) | アプリログ/イベントログ | FileLoadException/MissingMethod/特定端末だけ落ちる | ロードdllの Name/Version/Location | 配布フォルダ全消し→成果物丸ごと展開 |
FileLoadException は「dllは見つかったが、ロード時に条件(署名/バージョン/依存関係など)が合わず読み込めない」系の例外。
MissingMethodException は「コンパイル時に見えていたメソッドが、実行時にロードされたdllには存在しない」時に出る。
どちらも「ビルドは通るのに実行時だけ壊れる」代表で、配布残骸/同名別dll/Version差が原因になりやすい。
「どこで死んでいるか」と「何が選ばれたか」を先に確定させると、迷子にならない。
掟: 今回守るルール
- 参照の向きを決め、相互参照を作らない
理由: 依存が循環すると、原因と影響が入れ替わり、修正が別の場所へ波及しやすい。 - 境界は「契約」で切り、共有プロジェクトで泥団子を作らない
理由: 共通化は速いが、運用トラブルが出た時の震源地になりやすい。 - 参照方式とバージョン管理を揃え、参照元不明を消す
理由: 同名別dllや端末差は「混在」から始まる。 - 配布単位を決め、配布は丸ごと入替を原則にする(上書き禁止)
理由: 古いdll残骸と欠落が、実行時トラブルの主犯になりやすい。 - 起動時にロード情報を記録し、切り分けを速くする
理由: 証拠が残るだけで、再現しないが再現可能になる。
実装の要点
1) 依存の向きを決める「最小分割」
最小でも次の3層に分ける。WinFormsでも効く。
| プロジェクト | 役割 | 参照してよいもの |
|---|---|---|
| App.UI | WinForms/画面/入力 | App.Application のみ |
| App.Application | ユースケース/手続き | App.Domain + App.Infrastructure(必要最小限) |
| App.Domain | 業務ルール/モデル | 原則何も参照しない |
失敗例はここで起きる。
- UIがInfrastructureを直参照してDB/HTTPを叩く
- Common/Utilsに業務と技術が混ざり、全員が参照する
依存が連鎖する前に、参照してよい方向を決める。
2) 境界は「契約(Contracts)」で切る
境界に置くのは契約だけにする。
- Interface(Repository/Serviceの抽象)
- DTO(画面とUseCase間の入力/出力)
- Result型(成功/失敗の表現)
逆に、境界に置くと崩れやすいもの。
- ロガー実装
- 設定読み込み
- DB接続
- WinFormsの型(Control/Form)
契約は薄く、実装は奥へ。後でDLLに切り出せる境界になる。
3) 参照方式とバージョン管理を揃える
参照の崩れは、参照方式の混在で再現性が消える。
- PackageReferenceを基本に寄せる
- ローカル参照(HintPath)は最終手段にする
- Version指定は散らさず、中央に寄せる
中央管理(例: Directory.Packages.props)
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageVersion Include="System.Text.Json" Version="8.0.0" />
</ItemGroup>
</Project>
各プロジェクトはVersionを書かずに参照する。
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="System.Text.Json" />
</ItemGroup>
依存解決結果を揺らさない(例: packages.lock.json)
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
CIはlocked-modeで依存解決結果の揺れを拒否する。
# 例: CI
dotnet restore --locked-mode
中央管理とlockは、端末差の起点を塞ぐ。
4) 配布単位を決めて、残骸と欠落を潰す
運用で壊れる多くは「配布物の揺れ」。
- 配布物はCIで作った成果物だけを基準にする
- 配布は「フォルダ丸ごと入替」を原則にする(上書き禁止)
- 例外的に上書きになるなら、入替前に配布フォルダを全消しする
dotnet publishの出力フォルダは、そのまま配布単位にできる。
dotnet publish -c Release -r win-x64 --self-contained false
「配布物に含めるdll一覧」を決めたいなら、成果物にmanifest(ファイル一覧)を同梱しておくと強い。
- 欠落: 起動前に検知できる
- 残骸: 入替運用が崩れていることが分かる
5) 起動時にロード情報を記録する(切り分けを速くする証拠)
実行時トラブルの切り分けは、最後はこれに収束する。
- Name
- Version
- Location(どこからロードされたか)
using System.Diagnostics;
using System.Reflection;
using System.Text;
static class AssemblyProbe
{
public static string DumpLoadedAssemblies()
{
var asmList = AppDomain.CurrentDomain.GetAssemblies()
.OrderBy(a => a.GetName().Name, StringComparer.OrdinalIgnoreCase);
var sb = new StringBuilder();
foreach (var a in asmList)
{
var n = a.GetName();
var loc = SafeLocation(a);
var fv = SafeFileVersion(loc);
sb.AppendLine($"{n.Name}\t{n.Version}\t{fv}\t{loc}");
}
return sb.ToString();
}
private static string SafeLocation(Assembly a)
{
try { return a.Location; }
catch { return "(dynamic)"; }
}
private static string SafeFileVersion(string location)
{
if (string.IsNullOrWhiteSpace(location) || location == "(dynamic)") return "-";
try { return FileVersionInfo.GetVersionInfo(location).FileVersion ?? "-"; }
catch { return "-"; }
}
}
起動時に1回だけ出す。
ログに「Name/Version/Location」が揃うだけで、トラブル端末の切り分け速度が上がる。
6) 止血テンプレ(証拠取り -> 復旧 -> 恒久)
| 手順 | 目的 | 具体策 |
|---|---|---|
| 証拠取り | 誤ったdllを拾っていないか確定 | 起動時ログで Location/Version を採取 |
| 止血 | トラブル端末を復旧 | 配布フォルダを全消し→成果物を丸ごと展開 |
| 恒久 | 運用で揺れない仕組み | CI成果物固定/配布スクリプト化/manifest検証 |
採用判断基準: いつ使う/使わない
| 判断 | 使う | 使わない/注意 |
|---|---|---|
| 3層分割(UI/Application/Domain) | 長寿命の業務アプリ、改修が続く | 小規模PoCでは過剰になり得る |
| 中央管理 + lock | 複数端末/複数メンバー/CI運用 | 単独開発でも後で効く(導入コストは小) |
| 丸ごと入替配布 | 手作業配布が残る/端末差が出る | 自動更新があるなら更新機構側で担保 |
| 起動時アセンブリログ | 実行時だけ落ちる事象が出た/出そう | ログ出力制約が強い環境では代替(イベントログ等) |
| プラグイン/動的ロード | 機能追加頻度が高い/差し替えが必要 | 依存解決が複雑化する(隔離設計が必要) |
.NET Framework 4.8で差が出やすい所(必要な場合のみ)
- bindingRedirect(app.config)が絡むと、参照Version差は「設定」で発生し得る
- GACや既存の探査パスの影響で、想定外のdllを拾うケースがある
- 動的解決は AppDomain.AssemblyResolve が主戦場になる
判例(OK/NG)
| 観点 | OK例 | NG例 | 理由(困ること) | レビューで見る所 |
|---|---|---|---|---|
| 参照の向き | UI -> UseCase -> Domain | UI -> Infrastructure直参照 | UI変更や環境差が業務ロジックへ波及 | UIプロジェクトの参照一覧 |
| 境界 | ContractsにInterface/DTOだけ | Commonに業務/技術/設定が混在 | 依存が全方向に広がり震源地化 | Common/Sharedの中身 |
| 参照方式 | PackageReferenceで統一 | HintPath/ローカルdll混在 | 参照元不明、同名別dll混入 | csprojのReference記述 |
| Version管理 | Directory.Packages.propsで中央管理 | 各csprojでVersionがバラバラ | 端末差と依存解決差が起きる | Version指定の分散 |
| 配布 | publish成果物を丸ごと入替 | 上書きコピーで運用 | 古いdll残骸が残る | 配布手順/スクリプト |
| 観測性 | 起動時にName/Version/Locationを記録 | 本番で拾ったdllが不明 | 再現しないで時間が溶ける | 起動ログの有無 |
レビュー観点
| 観点 | ありがちな見落とし | 困り方 | 指摘コメント例(直球禁止) |
|---|---|---|---|
| 参照の向き | 画面からInfraのクラスをnewしている | 特定端末差で動作が変わる | 依存方向が逆転しており、差し替え余地が無い構造に見える |
| 共通化 | Utils/Commonに何でも入れる | 小変更で全体が崩れる | 共通に寄せた結果、境界が消えて依存が拡散しているように見える |
| 参照方式 | 1プロジェクトだけローカルdll参照 | 同名別dll混入、実行時だけ落ちる | 参照元が追えず、配布時に混在が起きる懸念がある |
| Version管理 | Version指定が散在 | 依存解決結果が端末で揺れる | Versionの宣言が分散しており、解決結果が環境差で変わり得る |
| 配布 | 手作業でdllを足し引き | 欠落/残骸で端末差が出る | 配布単位が揺れるため、再現性が担保できないように見える |
| 観測性 | 例外はあるがロード元が無い | 原因特定が遅れる | 実行時に拾った実体(Location/Version)が残らず、切り分けが難しくなる |
禁書庫A: 逆引き(症状→原因→対策)
| 症状 | ありがちな原因 | 切り分け(見る場所) | 最短の対処 | 再発防止(ルール化) |
|---|---|---|---|---|
| ある端末だけ起動直後に落ちる | 配布残骸(古いdllが残る) | 起動時のName/Version/Location | 配布フォルダ全消し→成果物丸ごと展開 | 丸ごと入替を原則化(上書き禁止) |
| 特定画面だけMissingMethod | 同名別dllを拾う/Version差 | ロードdllのLocationとFileVersion | 正しいdllだけに揃える(混在排除) | 参照方式統一(ローカル参照排除) |
| 社内配布手順変更後に一部機能だけ壊れる | 配布単位が揺れる(欠落) | publish成果物と配布先の差分 | 欠落dllを復旧 | manifest検証/配布スクリプト化 |
| restoreは通るが端末差が出る | nuget.config/source/キャッシュ差 |
dotnet list package / assets.json |
キャッシュ掃除→restore | 中央管理 + lockで固定 |
| CIは通るが手元だけ落ちる | SDK/VS差/ローカル設定差 | global.json/SDKバージョン | SDK固定/ビルド環境統一 | CI成果物を基準にする運用 |
| プラグイン追加後に落ちる | 依存解決経路が複数 | AssemblyLoadContext/Resolving | 依存を同梱ルールに揃える | Contracts分離 + プラグイン隔離 |
迷った時の一手(決め打ちで証拠を取る)
- restore/buildで死ぬ: まず「どこで死んでいるか」と「何が選ばれたか」(E02)
- runで死ぬ: Location/Versionを記録して、配布残骸を疑う
禁書庫B: 早見表(決め打ちで読む)
| 状況 | 先に確定するもの | 次にやる |
|---|---|---|
| 端末差で落ちる | 起動時のName/Version/Location | 配布フォルダ全消し→成果物丸ごと展開 |
| ビルドが通らない | NUxxxx/CS1705の種別 | restore/buildログから分岐(E02) |
| 何が入っているか不明 | publish成果物一覧(=配布単位) | CI成果物固定 + 丸ごと入替 |
| 依存が増え続ける | 依存の向き/境界の契約 | 3層分割 + Contracts化 |