大規模クライアントアプリケーションプロジェクトを .NET 6 に移行した経験と判断

大規模クライアントアプリケーションプロジェクトを .NET 6 に移行した経験と判断

2年間の準備と、いくつかのアプリケーションプロジェクトの移行で自信がついた経験を経て、チーム最大のプロジェクトを .NET Framework 4.5 から .NET 6 に移行し始めました。

最終更新 2022/05/05 22:55
lindexi
読了目安 18 分
カテゴリ
.NET
タグ
.NET C#

2年間の準備、いくつかのアプリケーションプロジェクトを移行して自信をつけた経験を経て、先日、チームで最も大きなプロジェクトを .NET Framework 4.5 から .NET 6 へ移行し始めました。このプロジェクトは2016年から開発が始まり、最大50人以上の開発者が参加し、コードのMR数は1万を超え、チーム全体で誰一人としてプロジェクトの全機能を説明できる人はいません。このプロジェクトはチーム内の多数の基盤ライブラリを参照しており、多くの基盤ライブラリは長年メンテナンスされていません。このアプリケーションプロジェクトは現在約1000万人のユーザーを抱えており、移行プロセスでは多くの救済策も準備する必要があります。このような複雑なプロジェクトでは、当然多くのブラックテクノロジーを使って .NET 6 への移行を完了する必要があります。この記事では、このプロセスで私が陥った落とし穴、学んだ知識、そしてなぜそうしたのかについて説明します。

前置き

正確に言うと、私はこのプロセスでは実質的に最後の1マイルを担当していました。私の見積もりでは、このプロジェクトを .NET Framework 4.5 から .NET 6 に移行する工数は約1.5人年でした。私は5週間で完了したと言っていますが、実際にはそれ以前の準備作業は計算に入れていません。その準備作業には、主要な基盤ライブラリを dotnet core 対応に変更すること、dotnet core と dotnet framework の差異(.NET Remoting や WCF などの IPC の欠如など)を埋めること、ビルドプラットフォームとパッケージプラットフォームを更新して dotnet core のビルドとパッケージングをサポートすること、ソフトウェアの自動更新機能(OTA)を更新して複雑なカナリアリリース機能と .NET 6 環境のテストサポートを提供すること、エッジからコアへと段階的にアプリケーションプロジェクトを移行して経験を積むことなどが含まれます。

十分な準備、十分な勇気、そして良いタイミングがあり、チーム全体のサポートのもと、最後の1マイルの移行を開始しました。

実際、.NET Framework 4.5 から .NET 6 への最終的な変更の前に、チーム全体(私を含めて)がこれほど多くの落とし穴を埋める必要があるとは全く予想していませんでした。この巨大なプロジェクトがどれほど奇妙なブラックテクノロジーを使っているかは、誰も知りません。この記事を書いているとき、同僚に「おそらく世界中の他のチームが私たちの問題に遭遇することはないだろう」と言いました。

背景

2016年から開発が始まり、最大50人以上の開発者が参加しました。これらの開発者は決しておとなしい人たちではなく、何でも自分で作る必要がある開発者、あるものは他人が作ったものを絶対に使わない開発者、コードを書いてCCTVに出演した開発者、国家標準の策定に参加した開発者、1つのクラスに必ず風変わりなデザインパターンを詰め込む開発者、コードコメントに必ず大仏を入れる開発者、学んだブラックテクノロジーを必ず使う開発者、コードと人がどちらか1つ動けば良いと考える開発者、コードとコメントが全く別物であることを平気で言う開発者、コードコメントが漢文の開発者、コードコメントがすべて英語の開発者、コメントとドキュメントがコード量をはるかに超える開発者、中国語がまだ上手くない開発者、穴を掘るのが好きで自分で踏まなければならない開発者、何にでもログを追加する開発者、とてもかっこよくスーツを着てコードを書く開発者、女装してコードを書く開発者、コード内で可愛く振る舞う開発者、「この関数は私だけが呼べる」と言う開発者、同じロジックを必ず異なる方法で実装する開発者、走る戦車の上でエンジンを交換する開発者などがいます。

今回の移行プロセスでは、さらにいくつかの落とし穴を埋める必要がありました。そのうちの1つは、dotnet core には複数の Exe エントリを持つクライアントアプリケーションのベストプラクティスがないことです。これには、クライアントアプリケーションがランタイム環境を独立して管理する際に、複数の Exe の競合処理とインストール完了後のフォルダ容量のジレンマが含まれます。これもこの記事の重点です。

今回の移行にはいくつかの要件もありました。システム環境が満たされていることを確認した上で、システムへの依存を最小限に抑え、ユーザーのシステムにインストールされている dotnet ランタイムの影響を受けないようにすることです。また、将来的にプロダクトライン内の複数のアプリケーションでランタイムを共有することをサポートする必要がありますが、そのランタイムは他のチームや他の会社と共有しないようにして、改造されるのを防ぐために、いくつかの試行ロジックを追加する必要があります。最後に、使用する WPF のバージョンはカスタムが必要です。つまり、公式リリースバージョンに基づいて一部のロジックを変更し、特別な製品要件を満たす必要があります。

つまり、dotnet を再配布し、チームが完全に制御できるライブラリとして設定することを意味します。この変更後、.NET 6 に更新した後、dotnet フレームワーク(WPF フレームワークを含む)を完全に自主制御できるようになります。これにより、できることがさらに増え、実現できないことが減ります。

WPF をさらにカスタマイズするために、WPF フレームワークの位置づけを以前のアプリケーションランタイム層から基盤ライブラリ層に変更し、チーム内の基本コンポーネントなどの CBB と同じ位置づけにし、単なる基盤ライブラリとして存在させ、アーキテクチャ上では最下層の基盤ライブラリと同等にしました。

今回発生した問題は大きく2つに分類されます。1つはプロジェクト自体の複雑さに起因する問題、もう1つは dotnet に起因する問題です。この記事では dotnet に起因する問題のみを記録します。その多くは特別な要件によるカスタマイズに起因する問題です。

開発アーキテクチャ

以前のアプリケーション開発アーキテクチャでは、依存していた .NET Framework はシステムコンポーネントとして存在していました。システムコンポーネントはシステム環境の影響を受け、国内の奇妙な環境ではシステムコンポーネントが改造されたり破損したりするのが常です。.NET Framework を採用したアプリケーションは大きなカスタマーサポートコストがかかり、ユーザーの環境問題を解決する必要があります。ユーザー数が増えるにつれて、このカスタマーサポートコストも大きくなっています。これが、これだけ多くのリソースを投入してプロジェクトを更新する理由の1つです。

以前のアプリケーション開発アーキテクチャの階層は次のとおりです。

dotnet に更新した後、ランタイムはシステム層の上に配置されます。この設計により、システム環境の影響を減らし、多くのアプリケーション環境問題を解決できます。

上の図からわかるように、WPF はランタイムの一部として存在しますが、これはその後の WPF のカスタマイズには不利です。私の所属するチームは WPF を完全に制御し、WPF フレームワークを深くカスタマイズすることを目指しています。もちろん、チームにはその能力もあります。私も WPF フレームワークの公式開発者だからです。この深いカスタマイズは、カスタマイズの内容に応じて、一部はオープンソースになる予定です。

変更後の現在の開発アーキテクチャの階層は次のとおりです。

WPF をランタイムに含めず、基盤ライブラリの一部として配置します。計画では、製品ライン内の複数の製品プロジェクトは .NET ランタイムを共有し、個々の製品はそれぞれ WPF の負荷を持ち、基盤ライブラリとして使用します。

遭遇した問題

最後の1マイルの更新中に、dotnet core のメカニズムにベストプラクティスがないという問題に遭遇しました。

複数の AppHost エントリアプリケーションの依存関係問題

複数の Exe アプリケーションのクライアント依存関係問題は、そのメカニズムの問題の1つです。現在移行中のプロジェクトはマルチプロセスモデルのアプリケーションであり、多くの Exe が存在します。しかし、dotnet core には現在、複数の Exe 間でランタイムを完全に共有し、システムにインストールされているグローバルな dotnet ランタイムの影響を受けず、かつインストール完了後のフォルダ容量を考慮するためのベストプラクティスがありません。

問題点を以下に示します。

  • 複数の Exe ファイル間でランタイムを共有する方法。フォルダを共有せず、それぞれ独立して公開すると、出力フォルダの容量が非常に大きくなります。
  • 複数の Exe ファイルを同じフォルダに公開すると、同じ名前のアセンブリが互いに上書きされます。dotnet の参照依存戦略によれば、バージョンに互換性がない場合、FileLoadException エラーが発生します。
  • Program Files の共有グローバルアセンブリを使用できません。このフォルダの内容は他の会社のアプリケーションによって変更され破損する可能性があり、dotnet core の環境独立性の能力を利用できません。
  • Program Files の共有グローバルアセンブリを使用できません。チームは dotnet ランタイムをカスタマイズする予定であり(例えば WPF アセンブリをカスタマイズして、WPF の位置づけをランタイムから基盤ライブラリに変更する)、このカスタマイズが他のアプリケーションを汚染しないようにする必要があります。
  • ユーザーに配布するランタイムバージョンは安定版のみを選択する必要がありますが、開発者はより新しい SDK バージョンを使用します。開発ビルドで出力されるアセンブリは新しい SDK バージョンを参照するため、アプリケーションがロードするのがユーザーに配布したランタイムバージョンだけの場合、ビルドバージョンより低いためにエラーが発生します。
  • ユーザーに配布するランタイムバージョンは、カスタマイズバージョン(カスタム WPF アセンブリなど)を含みます。開発時にはカスタム WPF アセンブリを参照する必要がありますが、ユーザーに配布するランタイムバージョン(ビルドバージョンより低い)を参照することはできません。

また、dotnet core と dotnet framework では exe にメカニズムの変更があります。dotnet core の exe は単なる apphost であり、デフォルトでは IL データを含みません。一方、dotnet framework ではデフォルトで exe にはアプリケーションエントリと IL データアセンブリが含まれています。そのため、元の NuGet 配布にはサポートされていない部分がありましたが、幸いこの部分の落とし穴は解決しました。

しかし、AppHost のカスタマイズを行うと、必ず NuGet 配布と競合します。NuGet は統一された配布ロジックを行うため、NuGet パッケージに Exe ファイルが含まれている場合、その Exe ファイルの設定内容は特定のプロジェクトの要件に合わないことが確実です。

依存バージョン問題

.NET 6 では、依存関係の検索ロジックは .NET Framework とは異なります。.NET Framework では同じ名前の DLL が存在すればバージョン番号を無視します。しかし .NET 6 では、実際の DLL のバージョン番号が依存関係の参照 DLL のバージョン以上である必要があります。核心的な競合は、ユーザーに配布するランタイムフレームワークのバージョンと開発者が使用する SDK バージョンの差にあります。

なぜこのような差が生じるのでしょうか?理由は、開発者が使用する SDK は基本的に最新であり、ユーザーに配布するランタイムのバージョンは最新を使う勇気がないからです。

この差の問題を明確にするには、まず概念を整理する必要があります。

  • 開発者が使用する SDK バージョン: dotnet 公式の SDK バージョンで、多くの場合最新バージョン(例:6.0.3)を使用します。
  • ユーザー側のランタイムバージョン: ユーザーに配布するランタイムバージョンで、多くの場合比較的安定したバージョン(例:6.0.1)を使用します。
  • プライベートバージョン: フレームワークを再カスタマイズするために、例えば WPF フレームワークに独自のビジネスコードを追加するために、自分たちで配布するバージョン。このバージョンもユーザー側のランタイムバージョンとして使用されますが、安定した dotnet 公式リリースバージョンに基づいて変更されます。

.NET 6 に更新した後、私たちは dotnet を完全に制御できるようになり、独自のプライベート dotnet バージョン(WPF バージョンも含む)を使用できます。これにより、WPF フレームワークを十分にカスタマイズし、プロジェクト内でカスタマイズした WPF フレームワークを使用できます。

しかし、カスタマイズした WPF フレームワークを使用するには代償があります。ユーザーに配布するランタイムフレームワークのバージョンと開発者が使用する SDK バージョンの差の問題に直面します。配布バージョンがプライベートバージョンの場合、プライベートバージョンは開発者が使用する SDK のバージョンより必ず遅れます。開発者の SDK バージョンより遅れると、2つの問題があります。

  1. 開発者の SDK バージョンをソフトウェア実行時にロードするアセンブリとして選択した場合、プライベートバージョンのアセンブリがロードされないため、開発時にプライベートバージョンを使用できません。つまり、プライベートバージョンのデバッグが困難になり、プライベートバージョンの動作変更を開発時に処理できません。
  2. プライベートバージョンをソフトウェア実行時にロードするアセンブリとして選択した場合、プライベートバージョンのバージョン番号が開発者の SDK バージョンより低いため、開発者がビルドしたアセンブリが対応するバージョンを見つけられず、実行に失敗します。

現在の対処方法

現在の対処方法は、開発時にアプリケーションソフトウェアのエントリアセンブリに、カスタマイズ部分のアセンブリへの参照と、カスタマイズ部分のアセンブリの出力を追加することです。これにより、開発時にプライベートバージョンを使用できます。

サーバービルド時には、アプリケーションソフトウェアのエントリアセンブリがカスタマイズ部分のアセンブリを参照しないように設定し、ビルドされたすべてのアセンブリがカスタマイズ部分のアセンブリへの参照を含まないようにします。ビルド時には、カスタマイズ部分のアセンブリへの参照を runtime フォルダ内に配置し、AppHost から参照されるようにします。

ファイルの整理

コードファイルの整理

まず、カスタマイズ部分のアセンブリをコードリポジトリの Build\dotnet runtime\ フォルダに格納します。例えば、カスタム WPF フレームワークは Build\dotnet runtime\WpfLibraries\ フォルダに格納します。

次に、使用する dotnet ランタイムバージョンを Build\dotnet runtime\runtime\ フォルダに配置します。この runtime フォルダの構成はおおよそ以下のとおりです。

├─host
│  └─fxr
│      └─6.0.1
├─shared
│  ├─Microsoft.NETCore.App
│  │  └─6.0.9901
│  └─Microsoft.WindowsDesktop.App
│      └─6.0.9904
└─swidtag

次に、カスタマイズ部分のアセンブリを runtime フォルダに上書きします。

出力ファイルの整理

出力ファイルには、インストールパッケージがユーザー端末にインストールする際のインストール出力フォルダと、開発時の出力フォルダという2つの概念があります。これらの方法は異なります。

インストールパッケージがユーザー端末にインストールする際のインストール出力フォルダの例:C:\Program Files\Company\AppName\AppName_5.2.2.2268\

出力フォルダの構成はおおよそ以下のとおりです。

├─runtime
│  ├─host
│  │  └─fxr
│  │      └─6.0.1
│  ├─shared
│  │  ├─Microsoft.NETCore.App
│  │  │  └─6.0.9901
│  │  └─Microsoft.WindowsDesktop.App
│  │      └─6.0.9904
│  └─swidtag
├─runtimes
│  ├─win
│  │  └─lib
│  │      ├─netcoreapp2.0
│  │      ├─netcoreapp2.1
│  │      └─netstandard2.0
│  └─win-x86
│      └─native
├─Resource
│
│ AppHost.exe
│ AppHost.dll
│ AppHost.runtimeconfig.json
│ AppHost.deps.json
│
│ App1.exe
│ App1.dll
│ App1.runtimeconfig.json
│ App1.deps.json
│
└─Lib1.dll

なぜ Runtime ランタイムフォルダをアプリケーション内に含めるのでしょうか?以下の理由によります。

  • 複数の exe が存在するため、独立した公開は現実的ではありません。
  • 将来的にチーム内の複数のアプリケーションが1つのランタイムを共有する可能性があり、各アプリケーションがそれぞれランタイムを持つよりも合理的です。そのため、ランタイム Runtime を共通のフォルダに配置するのは合理的ですが、現在はまだ安定していないため、アプリケーション内でテストしています。
  • この Runtime フォルダにはカスタマイズされた内容が含まれており、dotnet 公式のものとは一部異なるため、グローバルインストールには適しません。

独立した公開も Program Files でのグローバルも適さないため、アプリケーション自身のフォルダに置くしかありません。アプリケーション自身のフォルダ内の Runtime フォルダを認識させるためには、AppHost ファイルをカスタマイズする必要があります。詳細は以下のブログを参照してください。

開発時の出力フォルダは開発者がデバッグに使用するためのもので、出力先は $(SolutionDir)bin\$(Configuration)\$(TargetFramework) フォルダです。例えば、Debug の dotnet 6 では bin\Debug\net6.0-windows フォルダに出力されます。出力フォルダの構成はおおよそ以下のとおりです。

├─runtimes
│  ├─win
│  │  └─lib
│  │      ├─netcoreapp2.0
│  │      ├─netcoreapp2.1
│  │      └─netstandard2.0
│  └─win-x86
│      └─native
├─Resource
│
│ AppHost.exe
│ AppHost.dll
│ AppHost.runtimeconfig.json
│ AppHost.deps.json
│
│ App1.exe
│ App1.dll
│ App1.runtimeconfig.json
│ App1.deps.json
│
│ PresentationCore.dll
│ PresentationCore.pdb
│ PresentationFramework.dll
│ PresentationFramework.pdb
│ ...
│ PresentationUI.dll
│ PresentationUI.pdb
│ System.Xaml.dll
│ System.Xaml.pdb
│ WindowsBase.dll
│ WindowsBase.pdb
│
└─Lib1.dll

開発時の出力フォルダには Runtime フォルダは含まれていませんが、カスタマイズしたアセンブリは出力フォルダに置かれています。例えば上記のカスタム WPF アセンブリです。これにより、開発時にはカスタマイズされたアセンブリ以外は SDK のアセンブリを使用できます。なぜそうするのかは、以下の理由を参照してください。

プロジェクトファイルの修正

エントリアセンブリで、カスタマイズ部分のアセンブリへの参照ロジックを追加します。例えば、カスタム WPF アセンブリ(Build\dotnet runtime\WpfLibraries\ フォルダ内の DLL)を参照し、出力します。

<ItemGroup>
    <Reference Include="$(SolutionDir)Build\dotnet runtime\WpfLibraries\*.dll"/>
    <ReferenceCopyLocalPaths Include="$(SolutionDir)Build\dotnet runtime\WpfLibraries\*.dll"/>
  </ItemGroup>

これにより、開発時にカスタマイズされたバージョンのアセンブリを参照し、出力して、カスタマイズバージョンを使用したデバッグが可能になります。

これは dotnet の SDK の機能で、ランタイムフレームに存在するアセンブリと同じ名前のアセンブリが参照されている場合、フレームのアセンブリよりも優先的に使用されます。上記のコードは、カスタム WPF アセンブリを使用して dotnet の SDK が持つバージョンを置き換えるための基本的なサポートです。

実際のリリース時には、サーバービルドでユーザーインストール後のフォルダ容量を減らすために、エントリアセンブリでカスタマイズバージョンのアセンブリを参照して出力するのではなく、runtime フォルダ内のバージョンだけを使用し、重複ファイルを減らすことを望みます。そのため、エントリアセンブリの参照コードを最適化し、サーバービルド時には出力しないように設定する必要があります。

実現方法は、サーバービルド時に msbuild パラメータでプロパティを設定し、プロジェクトファイルでそのプロパティを判断してサーバービルドかどうかを判断し、サーバービルドの場合はアセンブリを参照しないようにすることです。

<ItemGroup Condition=" '$(TargetFrameworkIdentifier)' != '.NETFramework' And $(DisableCopyCustomWpfLibraries) != 'true'">
    <Reference Include="$(SolutionDir)Build\dotnet runtime\WpfLibraries\*.dll"/>
    <ReferenceCopyLocalPaths Include="$(SolutionDir)Build\dotnet runtime\WpfLibraries\*.dll"/>
  </ItemGroup>

msbuild パラメータによるビルドの修正の詳細は後述します。

上記の方法には設計上の欠陥があります。開発者が使用するロジックと実際にユーザーが実行するロジックが異なります。しかし、これほど多くの問題を解決する他の方法は見つかりませんでした。

ビルドの修正

サーバービルドでは、msbuild に /p:DisableCopyCustomWpfLibraries=true パラメータを渡し、カスタム WPF フレームワークのバージョンを参照しないように設定します。

ビルド時には、Build\dotnet runtime\runtime\ フォルダからカスタマイズされたランタイムを出力フォルダにコピーする必要があります。

/// <summary>
/// 自己配布のランタイムを使用する場合、Build\dotnet runtime\runtime からコピー
/// </summary>
private void CopyDotNetRuntimeFolder()
{
    var runtimeTargetFolder = Path.Combine(BuildConfiguration.OutputDirectory, "runtime");
    var runtimeSourceFolder =
        Path.Combine(BuildConfiguration.BuildConfigurationDirectory, @"dotnet runtime\runtime");
    PackageDirectory.Copy(runtimeSourceFolder, runtimeTargetFolder);
}

つまり、エントリアセンブリがカスタム WPF フレームワークのバージョンを参照するのではなく、アプリケーションの実行時に runtime フォルダ内のものを参照するようにし、重複ファイルを減らします。

決定理由

上記の解決方法には複雑な決定があります。以下では、各決定の理由を説明します。

複数の Exe ファイル間でのランタイム共有の解決

複数の Exe ファイルが存在し、一部の Exe は他のフォルダ(Main フォルダなど)に配置されています。これらの Exe をすべて独立して公開すると、インストール出力フォルダの容量が大きく、重複ファイルも多く、ビルドにも時間がかかります。

解決方法は、AppHost のカスタマイズにより、すべての Exe がアプリケーション出力フォルダの runtime フォルダの内容をロードするようにすることです。これにより、複数の Exe ファイル間でランタイムを共有できます。

アプリケーション自身のフォルダ内の Runtime フォルダを認識させるために、AppHost ファイルをカスタマイズします。詳細は以下のブログを参照してください。

AppHost ファイルをカスタマイズして Runtime フォルダを認識させる以外に、2つ目の方法として、ファイル構成を変更することがあります。最外層を Main エントリアプリケーションフォルダとし、メインエントリの Exe ファイルとその依存関係およびランタイムのみを置き、他の Exe は内側のフォルダに置きます。内側のフォルダの Exe は外部から直接実行できず、外側のエントリ Exe から間接的に呼び出される必要があります。外側のエントリ Exe が内側のフォルダの Exe を起動する際に、環境変数を介して内側のフォルダの Exe の dotnet メカニズムに、最外層の Main エントリアプリケーションフォルダのランタイム内容を使用するよう伝えます。

しかし、今回の移行では2つ目の方法は選択しませんでした。根本的な理由は、多くの古くて境界的なロジックがあり、これらのロジックは非常に奇妙な呼び出し方法を持っているからです。元の Exe を内側のフォルダに移動すると、当然 Exe の相対パスが変更され、多くのビジネスモジュールが壊れる可能性があります。また、一部の Exe は他のアプリケーションソフトウェアから起動されるため、これも変更できません。これらの要件があるため、Runtime フォルダをより外側に配置し、AppHost ファイルを変更して、これらの実行可能プログラムファイル間でプライベートデプロイされた .NET ランタイムを共有することにしました。

カスタマイズバージョンのグローバル汚染の解決

dotnet ランタイムのカスタマイズ(例えば WPF アセンブリのカスタマイズにより、WPF アセンブリの位置づけをランタイムから基盤ライブラリに変更する)によるユーザーへの配布方法には2つあります。

  • アプリケーション自身に持たせる(アプリケーションの独立公開など)。
  • Program Files にグローバルインストールする。

他の会社のアプリケーションを汚染しないために、Program Files へのグローバルインストールはできません。そのため、アプリケーション自身に持たせるしかありません。

前述の通り、各 Exe の独立公開は適切ではないため、出力フォルダ内の runtime フォルダに配置するしかありません。

プラグインプロセスの呼び出し

AppData フォルダに配置されているプラグインプロセスがあり、アプリケーションのインストール出力フォルダ外にあります。このようなプラグインプロセスを呼び出してランタイムを使用させるには、プラグイン自身にランタイムを持たせる必要はありません。

実現方法は環境変数によるものです。dotnet では、プロセスの環境変数 DOTNET_ROOT に従ってランタイムを探します。

メインアプリケーションエントリの Program 起動時に、アプリケーション自身に環境変数を追加します。dotnet の Process 起動戦略によれば、現在のプロセスが Process を使って起動したプロセスは、現在のプロセスの環境変数を継承します。これにより、メインアプリケーションから起動されたプラグインプロセスは DOTNET_ROOT 環境変数を取得し、メインアプリケーションのランタイムを使用できます。

/// <summary>
/// 環境変数を追加して、呼び出された起動プロセスも自動的にランタイムを見つけられるようにする
/// </summary>
static void AddEnvironmentVariable()
{
    string key;
    if (Environment.Is64BitOperatingSystem)
    {
        // https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-environment-variables
        key = "DOTNET_ROOT(x86)";
    }
    else
    {
        key = "DOTNET_ROOT";
    }

    // 例えば、AppData に配置された独立プロセス(CEF プロセスなど)を呼び出す場合、ランタイムを見つけられる
    var runtimeFolder =
        Path.Combine(AppDomain.CurrentDomain.SetupInformation.ApplicationBase!, "runtime");
    Environment.SetEnvironmentVariable(key, runtimeFolder);
}

公式ドキュメントによると、x86 アプリケーションの場合は DOTNET_ROOT(x86) 環境変数を使用する必要があります。

詳細は dotnet 6 通过 DOTNET_ROOT 让调起的应用的进程拿到共享的运行时文件夹 を参照してください。

しかし、この方法には明確な欠点があります。これらのプラグインは単独では実行できません。単独で実行するとランタイムが見つからず失敗します。メインエントリプロセスまたは他のランタイムを取得したプロセスが環境変数を設定してプラグインを実行する必要があります。

この問題には解決方法もあります。グローバルな dotnet を汚染しない前提で、dotnet を自社の製品フォルダにインストールします。デフォルトの Program Files 内のアプリケーションフォルダレイアウトは C:\Program File\<会社名>\<製品名> の形式です。そのため、dotnet を1つの製品としてインストールすると、C:\Program File\<会社名>\dotnet のような構成になります。これにより、絶対パスを介して複数のアプリケーション間でこのランタイムを共有できます。

今回は C:\Program File\<会社名>\dotnet のフォルダレイアウトを採用しませんでした。理由は、現在使用している dotnet 管理方法と移行の過渡期であり、さらにプライベート WPF も成熟していないため、C:\Program File\<会社名>\dotnet の形式は考慮しませんでした。また、この形式では OTA ソフトウェア更新の問題や、更新中のロールバックエラーなど、より多くのリソース投入が必要です。ただし、この方法は最終的な形態として考えられます。

開発者の SDK バージョンがユーザーに配布するランタイムバージョンより高い問題への対処

問題:開発者の SDK バージョンがユーザーに配布するランタイムバージョンより高い場合、ビルドされた DLL はより高いバージョンの .NET アセンブリを参照するため、開発者が実行する際に対応するバージョンのアセンブリが見つからないというエラーが発生します。

App.config を記述しても無効なため、以前の方法で複数のバージョンを1つのバージョンに統合することはできません。現在も解決方法を模索中ですが、まだ見つかっていません。

試した解決方法は2つあります。1つ目は、開発者にユーザーのランタイムバージョンと同じ SDK をインストールさせ、global.json で特定のバージョンを設定することです。これは解決可能ですが、開発者が追加で SDK をインストールする必要があり、インストール方法はファイルを解凍するだけです。

1つ目の方法では、各開発者に古い SDK バージョンをインストールする必要があり、SDK を更新するたびに全員に再度インストールする必要があります。これは新しく参加する開発者にとって環境構築が面倒になります。また、dotnet の SDK は新しいバージョンがあると古いバージョンをインストールできません(プレビュー版を除く)。そのため、開発者の環境構築が複雑になります。これが現在1つ目の方法を使用していない理由です。

2つ目の方法:エントリアセンブリで WPF カスタムバージョンのアセンブリを参照するようにします。これにより、開発ビルド時に出力され、開発実行時に参照されます。リリース時には、runtime フォルダ内のコンテンツを使用し、同時に出力フォルダ内のコンテンツを削除します。

リリース時に runtime フォルダ内のコンテンツを使用し、出力フォルダ内のコンテンツを削除する理由は、ユーザー側のファイル容量を減らすためです。runtime フォルダ内のコンテンツと、エントリアセンブリと同じフォルダに保存されたカスタムバージョンのアセンブリファイルはまったく同じだからです。例えば、カスタム WPF アセンブリはリリース後に約30MBになり、重複ファイルはユーザー側で約30MBの余分な容量を消費しますが、インストールパッケージのサイズには影響しません。

2つ目の方法には欠点があります。WPF プライベートバージョンがリリースされたり、.NET バージョンが更新されるたびに、手動でファイルをコピーする必要があります。将来のバージョンでは NuGet 配布パッケージにすることを検討するかもしれません。

2つ目の方法では、出力フォルダ内のコンテンツを単純に削除することはできません。サーバーパッケージング時にエントリプロジェクトが参照を行わないようにする必要があります。そうしないと、deps.json ファイルが削除されたアセンブリを参照し、ソフトウェアの実行に失敗します。

以下は deps.json の設定でアセンブリを参照する例です。

"PresentationFramework/6.0.2.0": {
  "runtime": {
    "PresentationFramework.dll": {
      "assemblyVersion": "6.0.2.0",
      "fileVersion": "42.42.42.42424"
    }
  },
  "resources": {
    "cs/PresentationFramework.resources.dll": {
      "locale": "cs"
    },
    "de/PresentationFramework.resources.dll": {
      "locale": "de"
    },
    "es/PresentationFramework.resources.dll": {
      "locale": "es"
    },
    "fr/PresentationFramework.resources.dll": {
      "locale": "fr"
    },
    "it/PresentationFramework.resources.dll": {
      "locale": "it"
    },
    "ja/PresentationFramework.resources.dll": {
      "locale": "ja"
    },
    "ko/PresentationFramework.resources.dll": {
      "locale": "ko"
    },
    "pl/PresentationFramework.resources.dll": {
      "locale": "pl"
    },
    "pt-BR/PresentationFramework.resources.dll": {
      "locale": "pt-BR"
    },
    "ru/PresentationFramework.resources.dll": {
      "locale": "ru"
    },
    "tr/PresentationFramework.resources.dll": {
      "locale": "tr"
    },
    "zh-Hans/PresentationFramework.resources.dll": {
      "locale": "zh-Hans"
    },
    "zh-Hant/PresentationFramework.resources.dll": {
      "locale": "zh-Hant"
    }
  }
},

上記の問題を解決する方法は、前述の処理方法のように、開発者ビルドとサーバービルドで異なる参照関係を使用することです。

ユーザーがグローバルアセンブリをロードする問題への対処

背景

dotnet では、バージョン評価が行われ、Roll forward に基づく戦略ロジックが適用されます。デフォルトでは Minor 戦略を取ります。最初に AppHost に記録された Runtime フォルダを探し、次に Program Files の dotnet フォルダを探します。その中から適切なバージョン番号を選択します。アプリケーションが 6.0.1 でパッケージングされている場合、Program Files にユーザーがインストールした 6.0.3 のバージョンがあれば、Program Files の 6.0.3 バージョンが選択される可能性があります。

つまり、ユーザーの Program Files の 6.0.3 バージョンが破損している場合、アプリケーションは破損したファイルを使用することになります。

そのため、dotnet を使用して環境問題を処理するという目的を達成できません。

期待される動作は、ユーザー側で Program Files というグローバルアセンブリを自動的にロードせず、アプリケーション自身が持つ runtime フォルダのアセンブリを使用することです。

対処方法

アプリケーションの Runtime の dotnet フォルダのバージョン番号を十分に高くすることで、この問題を解決できます。

アプリケーションの Runtime の dotnet フォルダを 6.0.990x バージョンに変更します。最後の x は元の dotnet 公式の Minor バージョン番号に対応します。例えば、6.0.1 は 6.0.9901 に対応します。

Roll forward のロジックによれば、6.0.990x が最も高いバージョンと判断され、Program Files のグローバルアセンブリがロードされなくなります。

詳細は https://docs.microsoft.com/en-us/dotnet/core/versions/selection を参照してください。

デバッグ方法

Runtime フォルダのロードパスを変更する場合、デバッグが必要です。多くの開発者は SDK 環境をインストールしているため、自分のデバイスで適切にデバッグできません。理由は、自分の Runtime フォルダの設定が間違っている場合、AppHost がデフォルトで SDK 環境をロードしてしまうため、開発者のデバイスでは期待通りに動作するように見えるからです。

しかし、ユーザーのデバイスには環境がなかったり、破損していたりするため、アプリケーションは起動しません。

開発者のデバイスでデバッグする方法の1つは、環境変数を追加し、dotnet 標準の AppHost デバッグ方式を使用して、参照ロードの出力を表示することです。

テストしたいアプリケーションが App.exe ファイルの場合、コマンドプロンプトを開き、以下のコマンドを入力して、現在のコマンドプロンプトに環境変数を追加します(これにより開発環境を汚染しません)。

set COREHOST_TRACE=1
set COREHOST_TRACEFILE=host.txt

設定後、コマンドラインで App.exe ファイルを呼び出すと、App.exe ファイルはデバッグ情報を host.txt ファイルに出力します。

App.exe

デバッグ情報の内容の例は以下のとおりです。

--- The specified framework 'Microsoft.WindowsDesktop.App', version '6.0.0', apply_patches=1, version_compatibility_range=minor is compatible with the previously referenced version '6.0.0'.
--- Resolving FX directory, name 'Microsoft.WindowsDesktop.App' version '6.0.0'
Multilevel lookup is true
Searching FX directory in [C:\lindexi\App\App\runtime]
Attempting FX roll forward starting from version='[6.0.0]', apply_patches=1, version_compatibility_range=minor, roll_to_highest_version=0, prefer_release=1
'Roll forward' enabled with version_compatibility_range [minor]. Looking for the lowest release greater than or equal version to [6.0.0]
Found version [6.0.1]
Applying patch roll forward from [6.0.1] on release only
Inspecting version... [6.0.1]
Changing Selected FX version from [] to [C:\lindexi\App\App\runtime\shared\Microsoft.WindowsDesktop.App\6.0.1]
Searching FX directory in [C:\Program Files (x86)\dotnet]
Attempting FX roll forward starting from version='[6.0.0]', apply_patches=1, version_compatibility_range=minor, roll_to_highest_version=0, prefer_release=1
'Roll forward' enabled with version_compatibility_range [minor]. Looking for the lowest release greater than or equal version to [6.0.0]
Found version [6.0.1]
Applying patch roll forward from [6.0.1] on release only
Inspecting version... [3.1.1]
Inspecting version... [3.1.10]
Inspecting version... [3.1.20]
Inspecting version... [3.1.8]
Inspecting version... [5.0.0]
Inspecting version... [5.0.11]
Inspecting version... [6.0.1]
Inspecting version... [6.0.4]
Attempting FX roll forward starting from version='[6.0.0]', apply_patches=1, version_compatibility_range=minor, roll_to_highest_version=0, prefer_release=1
'Roll forward' enabled with version_compatibility_range [minor]. Looking for the lowest release greater than or equal version to [6.0.0]
Found version [6.0.1]
Applying patch roll forward from [6.0.1] on release only
Inspecting version... [6.0.4]
Inspecting version... [6.0.1]
Changing Selected FX version from [C:\lindexi\App\App\runtime\shared\Microsoft.WindowsDesktop.App\6.0.1] to [C:\Program Files (x86)\dotnet\shared\Microsoft.WindowsDesktop.App\6.0.4]
Chose FX version [C:\Program Files (x86)\dotnet\shared\Microsoft.WindowsDesktop.App\6.0.4]

--- から始まる部分は、各ロード(デスクトップなど)の読み込みを示しています。最初に読み込まれる検索フォルダは AppHost 内の設定に基づいており、これは 在多个可执行程序(exe)之间共享同一个私有部署的 .NET 运行时 - walterlv の方法で設定され、アプリケーションが最初に runtime フォルダの内容を探すようになっています(上記のファイル構成の通り)。

次に、dotnet 内で読み取られた Roll forward 戦略は minor の値で、次に runtime フォルダ内の 6.0.1 バージョンが見つかります。

'Roll forward' enabled with version_compatibility_range [minor]. Looking for the lowest release greater than or equal version to [6.0.0]
Found version [6.0.1]

最初に見つかった内容がデフォルトのランタイムフォルダとして設定されます。

Changing Selected FX version from [] to [C:\lindexi\App\App\runtime\shared\Microsoft.WindowsDesktop.App\6.0.1]

次に、C:\Program Files (x86)\dotnet フォルダの検索が続きます。

Searching FX directory in [C:\Program Files (x86)\dotnet]

グローバルフォルダで複数のバージョンが見つかり、それらをデフォルトのランタイムフォルダと比較して、最も適切なバージョンが選択されます。

上記のコードでは、6.0.4 がデフォルトの 6.0.1 よりも適切であると判断され、現在のランタイムフォルダが 6.0.4 のバージョンに変更されます。

Changing Selected FX version from [C:\lindexi\App\App\runtime\shared\Microsoft.WindowsDesktop.App\6.0.1] to [C:\Program Files (x86)\dotnet\shared\Microsoft.WindowsDesktop.App\6.0.4]

他に検索できるフォルダがないため、6.0.4 が使用するランタイムフォルダとして選択されます。

Chose FX version [C:\Program Files (x86)\dotnet\shared\Microsoft.WindowsDesktop.App\6.0.4]

この方法により、アプリケーションが期待通りにランタイムフォルダを見つけているかどうかを確認できます。

以上が、このアプリケーションの移行中に踏んだ落とし穴と、採用した決定です。皆様の移行の助けになれば幸いです。

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2026/04/22

各OSバージョンの.NETサポート状況(250707更新)

仮想マシンとテストマシンを使用して、各OSバージョンの.NETサポート状況を確認します。OSインストール後、対応するランタイムをインストールし、Stardustエージェントを実行できることを確認します(合格条件)。

続きを読む
同じカテゴリ / 同じタグ 2026/02/07

AOTの使用経験のまとめ

プロジェクト作成当初から、新機能を追加したり新しい構文を使用したりした場合には、すぐにAOT公開テストを実施するという良い習慣を身につけるべきです。

続きを読む