软件项目的设计是其编写时环境的产物。 随着环境的变化,明智的做法是退后一步,考虑旧的想法是否仍然是好的设计。 如果不是,您可能会错过增强功能、简化、新的自由度,甚至项目的生存。
对于 .NET 开发人员来说,这是一个相关的建议,他们的依赖项会不断更新,或者正在为 .NET 5 做准备。Fixie 项目在 .NET Core 的早期采用阶段灵活应对外部环境时,就面临着这个现实。Fixie 是一个开源 .NET 测试框架,类似于 NUnit 和 xUnit,侧重于 开发者人体工程学 和自定义。 它是在 .NET Core 之前开发的,并且为了响应平台更新而经历了几次重大设计改革。
问题:可靠的程序集加载
.NET 测试项目往往感觉很像一个库:一堆没有可见入口点的类。 假设像 Fixie 的 Visual Studio Test Explorer 插件这样的测试运行器将加载您的测试程序集,使用反射来查找其中的所有测试,并调用测试以收集结果。 与常规库不同,测试项目与常规控制台应用程序有一些相似之处
- 测试项目的依赖项应该像任何可执行文件一样,可以从它们自己的构建输出文件夹中自然加载。
- 当运行多个测试项目时,测试项目 A 的已加载程序集应与测试项目 B 的已加载程序集分开。
- 当被测系统依赖于 App.config 文件时,它应该在测试运行时使用测试项目本地的文件。
我将这些行为称为“三大要素”。 “三大要素”是如此自然,以至于您很少需要说出来。 测试项目应该类似于控制台可执行文件:它应该能够拥有依赖项,它不应与为另一个项目加载的程序集冲突,并且每个项目都应尊重其自己的专用配置文件。 我们认为这一切都是理所当然的。 天空是蓝色的,水是湿的,并且在测试运行时必须遵守“三大要素”。
Fixie v1:为“三大要素”而设计
“三大要素”为 .NET 测试框架带来了巨大的问题:主运行进程(例如 Visual Studio Test Explorer)与测试项目的构建输出文件夹相去甚远。 加载测试项目并运行它的最自然尝试将导致“三大要素”全部失败。
Fixie 早期 alpha 版本在程序集加载方面很幼稚:测试运行器 .exe 将加载一个测试项目并运行简单的测试,但是一旦测试尝试运行另一个程序集中的代码(例如被测应用程序中的代码),它就会失败。 默认情况下,它会在测试运行器附近搜索程序集,而不是在测试项目的构建输出文件夹附近。
一旦我们解决了这个问题,使用该测试运行器运行多个测试项目将导致运行时冲突,例如当每个测试项目引用同一库的不同版本时。
当我们解决了这个问题后,测试运行器将无法在正确的配置文件中查找,错误地认为测试运行器的配置文件是要使用的文件。
在旧的 .NET Framework 时代,“三大要素”的解决方案以 AppDomain 的形式出现。 AppDomain 是一项相当古老且现在已弃用的技术。 Fixie v1 是在 AppDomain 是主要解决方案(并且没有看到弃用迹象)来解决“三大要素”时开发的。 在这些情况下,使用 AppDomain 来解决“三大要素”是理想的设计,尽管使用起来有点令人沮丧。 简而言之,它们允许单个测试运行器划分出一些加载程序集的区域,这些区域之间具有严格的通信边界。

Test Explorer 插件及其自身的依赖项(例如 Mono.Cecil)位于一个 AppDomain 中。 测试程序集及其依赖项位于第二个 AppDomain 中。 痛苦的序列化边界允许请求跨越鸿沟,而不会有混合加载程序集的风险。
AppDomain 允许您将每个测试项目的构建输出文件夹标识为该测试项目的配置文件和依赖项的所在地。 您可以成功地将测试项目的文件夹加载到测试运行器进程中,调用它,并在满足“三大要素”要求的同时获得测试结果。
然后 .NET Core 出现了。 突然,AppDomain 成为了一个古老且已弃用的概念,它根本不会继续存在于 .NET Core 世界中。
环境已经发生了翻天覆地的变化。
Fixie v2:适应 .NET Core 危机
起初,这似乎是 Fixie 项目的末日。 整个设计都依赖于 AppDomain,如果这个新奇的 .NET Core 东西能够幸存下来,Fixie 将无法回答“三大要素”。 绝望。 关门大吉。 删除存储库。
在这些绝望的时刻,我们犯了一个典型的软件开发错误:将解决方案与需求混淆。 实际的需求(“三大要素”)没有改变。 围绕设计的环境发生了变化:AppDomain 不再可用。 当人们犯了将解决方案与需求混淆的错误时,他们可能会加倍下注,更紧地抓住方向盘,并在试图强制其解决方案继续工作时胡乱折腾。
相反,我们需要认识到显而易见的事实:我们有熟悉的需求,但有新的环境,现在是时候抛弃旧设计,采用新的设计,在新环境下满足相同的需求。 一旦我们允许自己回到绘图板,解决方案就很明确了
“三大要素”让您的“库”测试项目感觉像一个控制台应用程序。 那么,如果您的测试项目是一个控制台应用程序呢?
控制台应用程序已经具有从正确的文件夹加载依赖项的有意义的概念,这与另一个应用程序的依赖项不同,同时尊重其自己的配置文件。 测试运行器不再是混合中唯一的进程。 相反,测试运行器的任务是以自己的进程启动测试项目,并与其通信以收集结果。 我们用进程间通信换取了 AppDomain,从而产生了新的设计,该设计在 .NET Framework 和 .NET Core 项目的上下文中都能满足所有原始要求。

这种设计使项目得以生存,并使我们能够在那些不确定的年份中为两个平台提供服务,当时不确定哪个平台将在长期内生存。 但是,维护对两个世界的支持变得越来越痛苦,尤其是在每次 Visual Studio 次要版本发布时都保持 Visual Studio Test Explorer 插件的活力。 每个 Fixie 次要版本都涉及大量的用例来进行回归测试,并且道路上的每一个新的小颠簸都使创新停滞不前。
最重要的是,微软开始显示出明显的迹象,表明它正在放弃 .NET Framework:旧的 Framework 不再跟上 .NET Standard、ASP.NET 或 C# 的发展。 .NET Framework 将继续存在,但很快就会被淘汰。
环境再次发生了变化。
Fixie v3:拥抱 One .NET
Fixie v3 是一项正在进行中的工作,我们计划在 .NET 5 到达后不久发布。 .NET 5 是 .NET Framework 与 .NET Core 开发路线的解决方案,最终实现了 One .NET。 我们没有与之抗争,而是遵循微软的发展:Fixie v3 将不再在 .NET Framework 上运行。 删除 .NET Framework 支持使我们能够删除许多旧的、缓慢的实现细节,并大大简化了我们必须为每个版本考虑的回归测试场景。 它还使我们能够重新考虑我们的设计。
“三大要素”要求仅略有变化:.NET Core 取消了与您的可执行文件紧密相关的 App.config 文件的概念,而是依赖于更基于约定的配置。 Fixie 的所有程序集加载要求仍然存在。 更重要的是,围绕设计的环境发生了根本性的变化:我们不再局限于使用 .NET Framework 和 .NET Core 中都可用的类型。
通过承诺减少对 .NET Framework 的支持,我们获得了新的自由度来使系统现代化。
.NET 的 AssemblyLoadContext 是 AppDomain 的远房表亲。 它不适用于 .NET Framework,因此以前对我们来说不是一个选择。 AssemblyLoadContext 允许您为一个程序集及其自身的依赖项设置一个专用的加载区域,而不会污染周围的进程,并且不受限于原始进程自身的程序集文件夹。 换句话说,它提供了 AppDomain 的“将此程序集文件夹加载到一边”行为,而没有令人沮丧的 AppDomain 怪癖。
我们定义了 TestAssemblyLoadContext 的概念,即一个测试程序集文件夹所需的小型程序集加载区域
class TestAssemblyLoadContext : AssemblyLoadContext
{
readonly AssemblyDependencyResolver resolver;
public TestAssemblyLoadContext(string testAssemblyPath)
=> resolver = new AssemblyDependencyResolver(testAssemblyPath);
protected override Assembly? Load(AssemblyName assemblyName)
{
// Reuse the Fixie.dll already loaded in the containing process.
if (assemblyName.Name == "Fixie")
return null;
var assemblyPath = resolver.ResolveAssemblyToPath(assemblyName);
if (assemblyPath != null)
return LoadFromAssemblyPath(assemblyPath);
return null;
}
...
}
有了这个类,我们可以安全地从正确的文件夹加载测试程序集及其所有依赖项。 测试运行器可以直接使用加载的程序集,知道加载工作不会污染测试运行器自身的依赖项
var assemblyName = new AssemblyName(Path.GetFileNameWithoutExtension(assemblyPath));
var testAssemblyLoadContext = new TestAssemblyLoadContext(assemblyPath);
var assembly = testAssemblyLoadContext.LoadFromAssemblyName(assemblyName);
// Use System.Reflection.* against `assembly` to find and run test methods...
我们又回到了原点:Fixie v3 Visual Studio 插件使用 TestAssemblyLoadContext 在进程中加载测试程序集,类似于 Fixie v1 插件使用 AppDomain 的方式。 核心 Fixie.dll 程序集只需要加载一次。 最重要的是,我们消除了所有进程间通信,同时利用了新环境所允许的最佳功能。

始终进行设计
当您使用任何长期存在的系统时,您的一些维护痛苦实际上是外部环境已经改变的线索。 如果您的环境正在改变,请退后一步并重新考虑您的设计。 您是否将您的解决方案误认为是您的需求? 将您的需求与您的解决方案分开阐明,并查看您的环境是否暗示了一个新的甚至令人兴奋的方向。
2 条评论