4.4.3 已知用途
.NET BCL包含一些环境上下文实现。
安全性是通过与每个线程相关联的System.Security.Principal.IPrincipal接口来解决的。您可以使用Thread.CurrentPrincipal访问器获取或设置线程的当前主体。
另一个基于TLS的环境上下文模拟了线程的当前区域性.Thread.CurrentCulture和Thread.CurrentUICulture允许您访问和修改当前操作的区域性上下文。如果没有显式提供,则许多格式API(例如,解析和转换值类型)会隐式使用当前的区域性。
追踪提供了一个普遍的 “背景”(环境上下文)的例子。 跟踪类并不与某个特定的线程相关联,而是真正在整个AppDomain*享。你可以用Trace.Write方法从任何地方写一条跟踪信息,并让它写到由Trace.Listeners属性配置的任何数量的TraceListeners。
4.4.4 示例:缓存货币
前面几节中的商务应用示例中的货币抽象是一个最友好的接口。每次你想转换货币时,你都要调用GetExchangeRateFor方法,该方法可能会在一些外部系统中查找汇率。这是一个灵活的API设计,因为如果你需要,你可以以接近实时的精度查询汇率,但在大多数情况下,这不是必要的,更可能成为一个性能瓶颈。
我在Lsiting 4.10中展示的基于SQL Server的实现,在你每次询问汇率的时候,肯定都会形成一个数据库查询。当应用程序显示一个购物篮时,购物篮里的每件物品都会被转换,这就导致对篮子里的每件物品进行数据库查询,尽管汇率不可能从第一件物品到最后一件物品发生变化。 最好的办法是将汇率缓存一段时间,这样应用程序就不需要在同一秒的几分之一时间内多次向数据库查询同一汇率。
根据对当前货币的重视程度,缓存超时可长可短:缓存一秒钟或几个小时。超时时间应该是可配置的。
为了确定何时过期缓存的货币,你需要知道自从货币被缓存后过去了多少时间,所以你需要访问当前时间。DateTime.UtcNow 似乎是一个内置的环境上下文,但它不是,因为你不能指定时间,只能查询它。
不能重新定义当前时间在生产应用中很少是一个问题,但在单元测试时可能是一个问题。
时间模拟
WorldWide Telescope允许你暂停时间或以不同的速度向前或向后移动时间。这模拟了夜空在不同时间的样子。
一般基于网络的应用程序不太可能需要修改当前时间的能力,而另一种类型的应用程序可以从这种能力中获益匪浅。
我曾经写过一个相当复杂的依赖于当前时间的仿真引擎。因为我总是使用测试驱动开发(TDD),我已经使用了当前时间的抽象,所以我可以注入不同于实际机器时间的DateTime实例。当我后来需要在模拟中把时间加快几个数量级时,这变成了一个巨大的优势。我所要做的就是注册一个加速时间的时间提供者,而整个模拟就立即加速了。
如果你想看看类似功能的效果,你可以看看WorldWideTelescope客户端应用程序,它允许你在加速时间内模拟夜空。下图是一个控件的屏幕截图,该控件允许你以不同的速度向前和向后运行时间。 我不知道这个特殊功能的开发者是否通过使用环境时间提供者来实现它,但这就是我想做的。
在示例商业应用程序的情况下,我希望在编写单元测试时能够控制时间,以便我能够验证缓存的货币是否正确过期。
TimeProvider
时间是一个相当普遍的概念(即使时间在宇宙的不同部分以不同的速度移动),所以我可以把它建模为一个普遍共享的资源。因为没有理由为每个线程设置单独的时间提供者,所以TimeProvider环境上下文是一个可写的Singleton,如以下列表所示。
Listing 4.12 TimeProvider 环境上下文
public abstract class TimeProvider {
private static TimeProvider current;
static TimeProvider() {
TimeProvider.current = new DefaultTimeProvider();//初始化默认的TimeProvider
}
public static TimeProvider Current {
get {
return TimeProvider.current;
}
set {
if (value == null) {
throw new ArgumentNullException("value");//保护条文
}
TimeProvider.current = value;
}
}
public abstract DateTime UtcNow { get; }//重要的部分
public static void ResetToDefault() {
TimeProvider.current = new DefaultTimeProvider();
}
}
TimeProvider类的目的是使你能够控制时间是如何传达给客户端的。正如表4.4所描述的,一个本地默认值是很重要的,所以你静态初始化该类以使用DefaultTimeProvider类(我很快会告诉你)。
Table 4.4的另一个条件是,你必须保证TimeProvider永远不会处于不一致的状态。当前字段决不允许是空的,所以保护条文保证这是不可能的。
所有这些都是脚手架,使TimeProvider可以从任何地方轻松访问。它存在的理由是它能够为代表当前时间的DateTime实例提供服务。我特意按照DateTime.UtcNow来模拟抽象属性的名称和签名。如果有必要,我还可以添加Now和Today这样的抽象属性,但在这个例子中我不需要它们。
有一个适当的、有意义的本地默认值是很重要的,幸运的是,在这个例子中不难想到,因为它应该简单地返回当前时间。这意味着,除非你明确地去指定一个不同的TimeProvider,任何使用TimeProvider.Current.UtcNow的客户端都会得到真实的当前时间。
在下面的清单中可以看到DefaultTimeProvider的实现。
Listing4.13 Default time provider
public class DefaultTimeProvider: TimeProvider {
public override DateTime UtcNow {
get {
return DateTime.UtcNow;
}
}
}
DefaultTimeProvider类从TimeProvider派生,以在客户端读取UtcNow属性时提供实时信息。
当CachingCurrency使用TimeProvider环境上下文来获取当前时间时,它将获取真正的当前时间,除非你专门为应用程序指定一个不同的TimeProvider–我只打算在我的单元测试中这样做。
缓存货币
为了实现这一目标,您需要将“适当的”货币实施付诸实施。
注意:装饰者模式是拦截的一个重要部分;我将在第二章中更详细地讨论它。
无需修改Listing 4.10中所示的现有SQL Server支持的Currency实现,而是将缓存包装起来并仅在缓存过期或不包含条目时才调用真实的实现。
你可能还记得第4.1.4节,CurrencyProvider是一个抽象的类,它可以返回货币实例。一个CachingCurrencyProvider实现了同样的基类,并且包装了一个包含的CurrencyProvider的功能。 每当它被要求提供一个货币时,它就会返回一个由包含的CurrencyProvider创建的货币,但被包裹在一个CachingCurrency中(见图4.10)。
提示:装饰器模式是确保关注点分离的最佳方法之一。
这种设计使我可以缓存任何货币实现,而不仅限于当前使用的基于SQLServer的实现。图4.12显示了CachingCurrency类的轮廓。
CachingCurrency使用构造器注入来获取应该缓存其交换率的“真实”实例。例如,CachingCurrency将其代码属性委托给内部Currency的Code属性。
CachingCurrency实现的有趣部分是其GetExchangeRateFor方法,在下面的清单中显示。
Listing 4.14 缓存汇率
private readonly Dictionary < string, CurrencyCacheEntry > cache;
public override decimal GetExchangeRateFor(string currencyCode) {
CurrencyCacheEntry cacheEntry;
if ((this.cache.TryGetValue(currencyCode, out cacheEntry)) && (!cacheEntry.IsExpired)) {
return cacheEntry.ExchangeRate;//如果有缓存则返回缓存
}
var exchangeRate = this.innerCurrency.GetExchangeRateFor(currencyCode);
var expiration = TimeProvider.Current.UtcNow + this.CacheTimeout;
this.cache[currencyCode] = new CurrencyCacheEntry(exchangeRate, expiration);//缓存汇率
return exchangeRate;
}
当客户询问汇率时,你首先拦截该调用,在缓存中查找货币代码。如果请求的货币代码有一个未过期的缓存条目,你就返回缓存的汇率,方法的其余部分被跳过。
只有当没有有效的缓存汇率时,你才调用innerCurrency来获得汇率。在你返回之前,你需要对它进行缓存。第一步是计算到期时间,这就是你使用TimeProvider环境上下文的地方,而不是更传统的DateTime.Now。计算好过期时间后,你就可以在返回结果前缓存该条目了。
计算一个缓存条目是否过期也是使用时间提供者环境上下文来完成的。
return TimeProvider.Current.UtcNow >= this.expiration;
CachingCurrency类在所有需要当前时间的地方使用TimeProvider环境上下文,所以编写一个精确控制时间的单元测试是可能的。
修改时间
在对CachingCurrency类进行单元测试时,你现在可以准确地控制时间的流逝,完全不考虑真实的系统时钟。 这使你能够编写确定性的单元测试,即使被测系统(SUT)依赖于当前时间的概念。 下一个列表显示了一个测试,它验证了即使SUT被要求提供四次汇率,也只有两次调用内部货币:在第一次调用时,以及在缓存过期时再次调用。
Listing 4.15单元测试货币是否已正确缓存和过期
[Fact]
public void InnerCurrencyIsInvokedAgainWhenCacheExpires() {
// Fixture setup
var currencyCode = "CHF";
var cacheTimeout = TimeSpan.FromHours(1);
var startTime = new DateTime(2009, 8, 29);
var timeProviderStub = new Mock < TimeProvider > ();
timeProviderStub.SetupGet(tp => tp.UtcNow).Returns(startTime);
TimeProvider.Current = timeProviderStub.Object;//设置TimeProvider环境上下文
var innerCurrencyMock = new Mock < Currency > ();
innerCurrencyMock.Setup(c => c.GetExchangeRateFor(currencyCode)).Returns(4.911 m).Verifiable();
var sut = new CachingCurrency(innerCurrencyMock.Object, cacheTimeout);
sut.GetExchangeRateFor(currencyCode);
sut.GetExchangeRateFor(currencyCode);
sut.GetExchangeRateFor(currencyCode);
timeProviderStub.SetupGet(tp => tp.UtcNow).Returns(startTime + cacheTimeout);
// Exercise system
sut.GetExchangeRateFor(currencyCode);
// Verify outcome //验证inner currency 是否调用正确
innerCurrencyMock.Verify(c => c.GetExchangeRateFor(currencyCode), Times.Exactly(2));
// Teardown (implicit)
}
术语提示:
下面的文字包含了一些单元测试的术语,我用斜体字强调了这些术语,但由于这不是一本关于单元测试的书,我将向你推荐xUnit Test Patterns 一书14,它是所有这些模式名称的来源。
在这个测试中,首先要做的一件事是设置一个TimeProvider Test Double,它将按照定义返回DateTime实例,而不是基于系统时钟。 在这个测试中,我使用了一个叫做Moq的动态模拟框架来定义UtcNow属性应该返回相同的DateTime,直到被告知不这样做。 定义后,这个Stub被注入到环境上下文中。
对GetExchangeRateFor的第一次调用应该调用CachingCurrency的内部货币,因为还没有任何东西被缓存,而接下来的两次调用应该返回缓存的值,因为根据TimeProviderStub,目前时间根本没有传递。
有了几个缓存的调用,现在是时候让时间前进了;你修改了TimeProviderStub,以返回一个刚好超过缓存超时的DateTime实例,并再次调用GetExchangeRateFor方法,期望它第二次调用内部Currency,因为原来的缓存条目现在应该已经过期了。
因为你希望内部的Currency被调用两次,你最终通过告诉内部的CurrencyMock,GetExchangeRateFor方法应该被调用两次来验证这一点。
环境上下文的许多危险之一是,一旦它被分配,它就会保持这种状态,直到再次修改,但由于其隐含的性质,这很容易被忘记。例如,在单元测试中,列表4.15中的测试所定义的行为就保持这样,除非明确地重置(我在Fixture Teardown中这样做)。这可能会导致微妙的错误(这次是在我的测试代码中),因为这将溢出并污染在该测试后执行的测试。
环境上下文在实现和使用上看起来很简单,但会导致许多难以定位的错误。 它是有用武之地的,但只有在没有更好的替代方案时才使用它。它就像辣根:对某些事情很好,但绝对不是普遍适用的。
4.4.5 相关模式
尽管环境上下文要求我们具有适当的局部默认值,但可以使用环境上下文来建模交叉切割关注点。
如果事实证明依赖毕竟不是一个交叉切割的问题,你应该改变DI策略。如果你仍然有一个局部默认值,你可以切换到属性注入,但否则,你必须切换到构造器注入。
4.5 概括
本章介绍的模式是DI的一个核心部分。在应用DI时,有许多细微的差别和精细的细节需要学习,但这些模式涵盖了回答这个问题的核心机制,即我如何注入我的依赖关系?
这些模式并不是可以互换的。在大多数情况下,你的默认选择应该是构造器注入,但在某些情况下,其他模式之一可以提供更好的选择。 图4.12显示了一个决策过程,可以帮助你决定一个合适的模式,但如果有疑问,请选择构造器注入,你永远不会因为这个选择而出大错。
首先要检查的是依赖是你所需要的,还是你已经拥有但希望传达给另一个合作者的东西。在大多数情况下,你可能需要依赖,但在加载项方案中,你可能希望向一个加载项传达当前的环境。每当依赖在不同的操作中可能有所不同时,方法注入就是一个很好的候选实现。
当依赖代表一个跨领域的问题时,最好的模式适合取决于沟通的方向。如果你只需要记录一些东西(例如,一个操作花了多长时间,或者传递了什么值),那么截断(我将在第九章讨论)是最合适的。如果你需要的答案已经包含在接口定义中,它也能很好地工作。缓存就是后一种使用截断的一个很好的例子。
如果您需要查询交叉依赖以获取原始接口中未包含的响应,则仅当您具有适当的本地默认值时才可以使用环境上下文,而不需要明确的配置就能适用于所有的客户端,你才能使用环境上下文。
当依赖不代表一个跨领域的问题时,本地默认值再次成为决定性因素,因为它可以使明确指定依赖成为可选项–如果没有指定覆盖的实现,默认就会被取代。 这种情况可以通过属性注入来有效实现。
在任何其他情况下,都适用构造器注入模式。如图4.12所示,构造器注入似乎是一个最后的模式,只有在其他所有情况下才会发挥作用。这只是部分正确,因为在大多数情况下,专门的模式并不适用,默认情况下,构造者注入是留在场上的模式。它很容易理解,而且比其他任何DI模式更容易实现。你可以只用构造器注入来构建整个应用程序,但了解其他模式可以帮助你在少数不适合的情况下明智地选择。
这一章包含了一个系统的目录,解释了你应该如何将依赖注入你的类中。 下一章将从相反的方向探讨DI,并看看如何不这样做。