如果你已经编写.NET应用程序有一段时间了,可能会觉得自己“已经掌握了依赖注入(DI)”,或者认为运行时开关是永远不会用到的冷门功能。我曾经也这么想……直到我遇到了各种bug、深夜系统中断,以及看起来像睡眠不足的浣熊写的测试代码。
这5个技巧只有经过多年实际.NET应用程序开发才能领悟。依赖注入和运行时中那些细小且常被忽视的调节旋钮,能帮你避免糟糕的意外,提供更清晰的可测试代码,并在不增加额外负担的情况下使应用程序更健壮。让我们开始吧!
依赖注入最容易犯的错误之一是将应用程序与特定的DI容器紧密耦合。你一开始可能用的是Microsoft.Extensions.DependencyInjection,并愉快地在各处使用ServiceCollection。然后有一天,老板要求你尝试Autofac,或者你想与使用LightInject的团队共享一个库。突然间,你陷入了困境。
相反,应该基于DependencyInjection.Abstractions(IServiceCollection、IServiceProvider)进行构建,而不是依赖具体的容器。
不好的做法(与框架紧密耦合):
// 你的库代码直接依赖于Microsoft.Extensions
var services = new ServiceCollection();
services.AddTransient<MyService>();
更好的做法(解耦):
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMyLibrary(this IServiceCollection services)
{
services.AddTransient<MyService>();
return services;
}
}
现在你的库只暴露一个IServiceCollection扩展。使用者可以使用任何与这些抽象集成的容器。这是一个小的设计选择,但它使你的代码可移植,并与其他团队友好协作。
.NET中的配置功能非常出色,但如何消费配置很重要。大多数开发者获取IOptions<T>就觉得完事了。问题是IOptions<T>在启动时就被冻结了。如果你的配置在生产环境中发生变化(比如通过appsettings重新加载),你的服务将永远看不到更新。
这就是IOptionsMonitor<T>的用武之地。它提供实时更新。
使用IOptions(静态,无更新):
public class MyService
{
private readonly MySettings _settings;
public MyService(IOptions<MySettings> options)
{
_settings = options.Value;
}
public void Run() => Console.WriteLine(_settings.Message);
}
使用IOptionsMonitor(动态,响应重新加载):
public class MyService
{
private readonly IOptionsMonitor<MySettings> _options;
public MyService(IOptionsMonitor<MySettings> options)
{
_options = options;
}
public void Run() => Console.WriteLine(_options.CurrentValue.Message);
}
现在,如果在应用程序运行时更新了appsettings.json,你的服务会自动获取更改。这对于功能标志、连接字符串或可能在无需重新部署情况下更改的节流值非常有用。
依赖注入错误就像地雷:直到运行时才会发现。我已经数不清有多少次应用程序启动正常,但在首次访问某个控制器时崩溃,只是因为某个服务没有正确注册。
你可以使用ValidateOnBuild来避免这种情况。它强制DI容器在启动时验证依赖关系图。
var serviceProvider = new ServiceCollection()
.AddTransient<IMyService, MyService>()
.BuildServiceProvider(new ServiceProviderOptions
{
ValidateOnBuild = true,
ValidateScopes = true
});
如果有问题(比如缺少依赖项),你会在应用程序启动时就知道,而不是在给客户演示到一半的时候才发现。这应该是生产级应用程序中默认启用的设置之一。
每个大型.NET应用程序最终都需要运行时切换。也许你正在推出新的序列化器,但希望保留一个开关以便回退到旧版本。或者某个功能在本地运行良好,但需要在生产环境中快速关闭。
.NET实际上有一个内置的开关系统:AppContext.SetSwitch。
定义开关:
AppContext.SetSwitch("MyApp.UseNewSerializer", true);
if (AppContext.TryGetSwitch("MyApp.UseNewSerializer", out bool useNew))
{
Console.WriteLine(useNew ? "Using new serializer" : "Using old serializer");
}
这些开关也可以通过runtimeconfig.json或环境变量设置,这使得运维团队无需接触代码就能轻松切换。它们不像完整的功能标志系统那么花哨,但对于轻量级的运行时决策来说,它们是完美的。
时间是最难测试的东西之一。如果你的代码调用DateTime.UtcNow或DateTimeOffset.Now,你基本上就被锁定在“实时”上了。想要单元测试一个依赖于时间的算法,而不像个傻瓜一样等待,几乎是不可能的。
.NET 8给了我们一个礼物:TimeProvider。它抽象了系统时钟,让你可以在测试中注入一个模拟的时钟。
没有TimeProvider(难以测试):
public class TokenService
{
public bool IsExpired(DateTime expiry) => DateTime.UtcNow > expiry;
}
使用TimeProvider(可测试):
public class TokenService
{
private readonly TimeProvider _timeProvider;
public TokenService(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public bool IsExpired(DateTime expiry) =>
_timeProvider.GetUtcNow() > expiry;
}
在生产环境中,你传递TimeProvider.System。在测试中,你可以创建一个自定义的FakeTimeProvider并控制时钟。这个小小的改变使得基于时间的逻辑验证起来不那么痛苦。
.NET的覆盖面非常广,很容易错过这些虽小但强大的功能。抽象帮助你保持可移植性。监视器让你的配置保持活力。验证让你避免运行时爆炸。开关让你无需重新部署就能快速切换。而TimeProvider终于让时间测试不再需要丑陋的变通方法。
这些不是花哨的功能,但它们是区分一个勉强存活的代码库和一个在生产环境中蓬勃发展的代码库的关键工具。如果你还没有使用它们,未来的你(和你的测试套件)会感谢你的。