最新版本的 .NET 终于引入了对依赖注入容器的“键控服务”支持的概念。.NET 8 中的内置 DI 容器现在包括其他 DI 容器(如 Structuremap 和 Autofac)已经有一段时间的功能。
“键控”或“命名”注册是一种模式,其中依赖项不仅按其类型注册,还使用附加键注册。请看以下示例,该示例说明了键控服务在实践中的操作:
public interface IService {}
public class ServiceA: IService {}
public class ServiceB: IService {}
container.Register<IService, ServiceA>("keyA");
container.Register<IService, ServiceB>("keyB");
// You need to use a key to get a correct implementation
var myServiceA = container.Resolve<IService>("keyA");
var myServiceB = container.Resolve<IService>("keyB");
这个简短的介绍并没有完全显示新的 DI 键控功能的复杂性。有关更多信息,请阅读 Andrew Lock 的文章。在这篇文章中,我更愿意关注新 DI 模式的优点、缺点和隐藏效果。
因此,我们很快就会获得一种新的闪亮工具。我们真的需要它吗?
实际上,你没有。这只是对依赖项注册和解析机制的一个小补充。但是,它可能在一些标准用例中表现良好。让我给你一些场景,在这些场景中,键控依赖注入可能会派上用场。
键控服务可以管理功能切换或 A/B 测试,为不同的用户或用户组提供不同的功能集。
在下面的示例中,我实现了一个简单的随机生成器用于 A/B 测试。有两种实现:BehaviorA 和 _BehaviorB。我想对 50% 的控制器调用使用 BehaviorA,对另外 50% 的控制器调用_使用 BehaviorB。
// the startup class:
builder.Services.AddKeyedTransient<IBehavior, BehaviorA>(0);
builder.Services.AddKeyedTransient<IBehavior, BehaviorB>(1);
builder.Services.AddTransient<IBehavior>(serviceProvider =>
{
var number = new Random().Next(2);
return serviceProvider.GetRequiredKeyedService<IBehavior>(number);
});
[ApiController]
[Route("[controller]")]
public class ABTestingController : ControllerBase
{
private readonly IBehavior _behavior;
public ABTestingController(IBehavior behavior)
{
_behavior = behavior;
}
[HttpGet]
public string DoSomething()
{
return _behavior.DoSomething();
}
}
public interface IBehavior
{
string DoSomething();
}
public class BehaviorA
{
public string DoSomething()
{
return "A";
}
}
public class BehaviorB
{
public string DoSomething()
{
return "B";
}
}
诀窍是我注册了 IBehavior 三次。两次键控服务 — 键“0”和“1”。第三种是使用实现工厂的标准瞬态注册。ABTestingController 中也使用此函数。
这很方便,因为:
1) 属性中不允许使用动态值。我们不能使用这种模式:
public ABTestingController(
[FromKeyedServices(new Random().Next(2))] IKeyedServiceProvider keyedServiceProvider)
{
...
2)另一个原因是,完成测试后,您可以轻松更换出厂:
builder.Services.AddTransient<IBehavior>(serviceProvider =>
按班级:
builder.Services.AddTransient<IBehavior, BehaviorA>()
或者 BehaviorB,这取决于测试结果。
旁注:
单一责任原则。
您可能已经意识到,键控服务将有助于实现众所周知的(对许多人来说臭名昭著的)单一责任原则模式。
如果没有键控服务,则需要编写类似于以下内容的代码:
public class OverloadedBehavior : IBehavior
{
public string DoSomething()
{
var number = new Random().Next(2);
return number == 0 ? "A" : "B";
}
}
我知道这个例子是人为的,但想象一下 DoSomething 方法中更复杂的逻辑。键控服务似乎非常适合这个特定的用例。
键控服务可以管理应用和模块的不同部分或环境(如暂存和生产)的配置。该密钥用于查找相关配置。这类似于 A/B 测试,但在本例中,您利用来自环境变量的数据。
builder.Services
.AddKeyedTransient<IEmailSender, SmtpEmailSender>("production");
builder.Services
.AddKeyedTransient<IEmailSender, FakeEmailSender>("non-production");
builder.Services.AddTransient<IEmailSender>(serviceProvider =>
{
var env = serviceProvider.GetRequiredService<IHostingEnvironment>();
var key = env.IsDevelopment() ? "non-production" : "production";
return serviceProvider.GetRequiredKeyedService<IEmailSender>(key);
});
public interface IEmailSender
{
void SendEmail();
}
public class SmtpEmailSender : IEmailSender
{
public void SendEmail()
{
/*send a regular email*/
}
}
public class FakeEmailSender : IEmailSender
{
public void SendEmail()
{
/*do nothing*/
}
}
public EnvController(IEmailSender sender)
{
_sender = sender;
}
主要逻辑在于注册 IEmailSender。我使用了 IHostingEnvironment.IsDevelopment 属性,而不是生成随机数。
当您需要同一依赖项的不同生存期时,键控服务非常方便。实体框架 DbContext 的解析就是一个很好的例子。在复杂的应用程序中,可能需要具有不同生存期的 DbContext。键控服务允许您引入以下模式:
Services.AddTransient<EntityContext>();
services.AddKeyedScoped<EntityContext>("scoped");
public Controller1([FromKeyedServices("scoped")] EntityContext dbContext)
{
// scoped dbContext
}
public Controller2(EntityContext dbContext)
{
// transient dbContext
}
如果没有对键控服务的支持,则必须引入具有类似方法的 DBContextFactory,如下所示:
// DbContetxFactory has to be registered as scoped
public class DbContetxFactory
{
public EntityContext CreateTransientDbContext()
{
// returns a new transient instance
return new EntityContext // omitted for clarity
}
private EntityContext? _scopedDbContext;
public EntityContext CreateScopedDbContext()
{
// omitted for clarity
return _scopedDbContext ?? (_scopedDbContext = new ...)
}
}
同样,这是一个非常方便的模式。
利用键控服务最疯狂的方法可能是实体驱动的解析。实体驱动的解析涉及将密钥保存到数据库表中,并将其用于服务解析。在以下示例中,我们有两个支付处理器,Stripe 和 PayPal。
public class PayPalProcessor : IPaymentProcessor { /* … */ }
public class StripeProcessor : IPaymentProcessor { /* … */ }
builder.Services
.AddKeyedTransient<IPaymentProcessor, PayPalProcessor>("PayPal");
builder.Services
.AddKeyedTransient<IPaymentProcessor, StripeProcessor>("Stripe");
[ApiController]
[Route("[controller]")]
public class PaymentController : ControllerBase
{
private readonly IKeyedServiceProvider _keyedServiceProvider;
public PaymentController(IKeyedServiceProvider keyedServiceProvider)
{
_keyedServiceProvider = keyedServiceProvider;
}
[HttpGet]
public string ProcessPayment(int orderId)
{
var order = FetchOrder(orderId);
var payment= _keyedServiceProvider
.GetRequiredKeyedService<IPaymentProcessor>(order.TypeOfPayment);
var request= order.GetPaymentRequest();
payment.Process(request);
return "Payment processed";
}
付款处理器类型的键保存在数据库的 Order 表中,并在 FetchOrder 方法中获取。密钥(Stripe 和 PayPal 常量)在注册服务时使用。这里的主要问题是当数据库中的列包含与 Stripe 或 PayPal 不同的内容时。然后,应用引发运行时错误。
虽然这个想法可能看起来有点冒险和非常规,但它也有可能成为一种非常灵活的服务解决方式。
现在,每个人都可能对如何在代码库中使用键控服务有所了解。但在开始到处添加键控服务之前,让我们先看看它的缺点。
由于配置的潜在复杂性,刚接触该项目的开发人员可能会面临陡峭的学习曲线,尤其是在具有多个依赖项的大型项目中。驯服依赖注入容器被证明是一项相当具有挑战性的任务。使用 DI 配置的方式越多,应用就越复杂。
错误配置通常会导致运行时错误,而这些错误更难排除故障。如果密钥拼写错误或未注册密钥的相应依赖项,则此类错误会在运行时而不是编译期间发生。
让我们看一个例子。如果在注册或解析中打错字,.NET 8 将显示以下错误:
// registration:
builder.Services.AddKeyedTransient<IPaymentProcessor, StripeProcessor>("Stripe");
// typo in a capital letter
keyedServiceProvider.GetKeyedService<INotificationService>("stripe");
// the error:
Unhandled exception. System.InvalidOperationException:
No service for type 'IPaymentProcessor' has been registered.
该消息不完整,并提供了有关问题所在的错误误导性信息。IPaymentProcessor 类型的服务已注册,但使用不同的密钥。
依赖密钥(可以是字符串或其他基本类型)通常会损害类型安全性,并增加系统内出错的可能性。如果密钥拼写错误或未正确注册其相应的依赖项,则尤其如此,这可能会再次导致难以解决的运行时错误。例:
var b = serviceProvider
.GetRequiredKeyedService<IPaymentProcessor>("pay"+"pal");
以这种方式编写代码与误导性错误消息相结合会导致故障排除的噩梦。
像往常一样,人们很容易过分依赖依赖容器,将其用作依赖管理的包罗万象的解决方案。但是,这种方法可能会导致像服务定位器这样的(反)模式,最终导致难以维护的代码。这种潜在滥用的示例:
interface IHandler {}
class StandardOrderProcessor : IHandler{}
class VatExludedOrderProcessor : IHandler{}
class SaveOrderHandler : IHandler{}
// Then you can call it like:
serviceProvider.GetKeyedService<IHandler>("VatExludedOrderProcessor");
serviceProvider.GetKeyedService<IHandler>("SaveOrderHandler");
VatExludedOrderProcessor、SaveOrderHandler 和 StandardOrderProcessor 是完全不同的功能,所以我认为使用相同的接口(IHandler)是不好的。
首先,任何 .NET 对象都可以用作键,这可能会导致各种问题。我通常更喜欢使用经典字符串或枚举值。
使用字符串键而不将它们抽象为常量会在整个代码中传播“魔术字符串”。这可能很难维护并且容易出错。枚举值看起来更吸引人,但它们也有自己的一系列问题。使用枚举会引发一些问题,例如您需要一个大枚举还是几个单独的枚举?如果是这样,这些分离的枚举应该住在哪里?
而且,这只是冰山的冰。如果您计划使用大量密钥,则需要考虑它们的验证和解决重复性。例如,您能否猜测重写注册时会发生什么情况,如以下代码所示:
builder.Services
.AddKeyedTransient<IPaymentProcessor, PayPalProcessor>("PayPal");
builder.Services
.AddKeyedTransient<IPaymentProcessor, StripeProcessor>("Stripe");
// "PayPal" returns StripeProcessor.
builder.Services
.AddKeyedTransient<IPaymentProcessor, StripeProcessor>("PayPal");
app.Services
.GetKeyedService<IPaymentProcessor>("PayPal").Process(new Request());
.NET 8 调用此代码时,将获得后一种服务 (StripeProcessor)。遗憾的是,当前版本的 .NET 中没有内置的重复性验证。
运行时的依赖项解析可能会带来性能开销,尤其是在具有大量依赖项的键控容器中。在最近的测试中,我评估了使用键控服务的潜在性能影响。代码:
public class OrderProcessor : IOrderProcessor
{
public void Process()
{
}
}
public class StripeProcessor : IPaymentProcessor
{
public void Process(IRequest request)
{
}
}
public class PerfTests
{
private ServiceProvider _provider;
[GlobalSetup]
public void Setup()
{
var serviceCollection = new ServiceCollection();
serviceCollection
.AddKeyedTransient<IPaymentProcessor, StripeProcessor>("Stripe");
serviceCollection
.AddTransient<IOrderProcessor, OrderProcessor>();
_provider = serviceCollection.BuildServiceProvider();
}
[Benchmark]
public object Keyed() => _provider
.GetKeyedServices<IPaymentProcessor>("Stripe");
[Benchmark]
public object Normal() => _provider
.GetServices<StripeProcessor>();
}
结果是:
| Method | Mean | Error | StdDev |
|--------|-----------|----------|----------|
| Keyed | 101.83 ns | 1.951 ns | 1.825 ns |
| Normal | 11.15 ns | 0.264 ns | 0.247 ns |
在我的机器上,键控服务的性能比标准解析慢 9 倍。另一方面,性能下降以纳秒为单位,这对于大多数标准应用来说是可以接受的。然而,如果你在追逐毫秒,你可能需要担心。
确保整个代码库在注册和解析依赖项方面的一致性可能具有挑战性,尤其是在大型团队或项目中。当语言带来一个新的关键词时,这是一个类似的问题。你还记得 async/await 是什么时候首次引入的吗?它使我们的代码库成为遗产,并产生了逐步适应这种新模式的需求。
软件行业还很年轻。如果你想创新,你必须为积压工作中不断的不一致和技术债务票付出代价。
(联合国)幸运的是,一点也不。那里有更复杂的 DI 容器。例如,Ninject 不仅提供键控服务,还提供其他受约束的解析机制。您可以将属性或目标类用于更复杂的服务图。但是,Microsoft 在这里是保守的,并且不会每六个月向他们的 DI 容器添加一个新功能,这可能是件好事。
键控服务具有多个理想的用例,例如 A/B 测试或生命周期管理,但它们会带来额外的复杂性。所以,我建议你一定要尝试使用它,但在这样做时要小心。我仍然相信依赖关系的解析越复杂,应用程序就越复杂。