• .NET 8新特性之KeyedService
  • 发布于 2个月前
  • 257 热度
    0 评论
简介
.NET 8 在 Preview 7 中引入了 KeyedService 支持,以后我们可以方便支持按 name 来获取 service 了,有些情况下就不用自己创建一个 factory 了。

例子
GetStarted
来看使用一个基本的使用示例:
var serviceCollection = new ServiceCollection();
serviceCollection.AddKeyedSingleton<IUserIdProvider, EnvironmentUserIdProvider>("env");
serviceCollection.AddKeyedSingleton<IUserIdProvider, NullUserIdProvider>("");
// 堆代码 duidaima.com
using var services = serviceCollection.BuildServiceProvider();
var userIdProvider = services.GetRequiredKeyedService<IUserIdProvider>("");
Console.WriteLine(userIdProvider.GetUserId());

var envUserIdProvider = services.GetRequiredKeyedService<IUserIdProvider>("env");
Console.WriteLine(envUserIdProvider.GetUserId());

file interface IUserIdProvider
{
    string GetUserId();
}
file sealed class EnvUserIdProvider: IUserIdProvider
{
    public string GetUserId() => Environment.MachineName;
}
file sealed class NullUserIdProvider: IUserIdProvider
{
    public string GetUserId() => "(null)";
}
输出结果如下:
(null)
WEIHANLI-SURFACE

AnyKey

serviceKey 有一个特殊的存在 KeyedService.AnyKey 我们可以用这个来捕获未注册的 serviceKey,示例如下:

var serviceCollection = new ServiceCollection();
serviceCollection.AddKeyedSingleton<IUserIdProvider, NullUserIdProvider>(KeyedService.AnyKey);

using var services = serviceCollection.BuildServiceProvider();
var userIdProvider = services.GetRequiredKeyedService<IUserIdProvider>("");
Console.WriteLine(userIdProvider.GetUserId());

var envUserIdProvider = services.GetRequiredKeyedService<IUserIdProvider>("env");
Console.WriteLine(envUserIdProvider.GetUserId());
可以看到我们注册服务的时候使用的是 KeyedService.AnyKey, 获取服务的时候并没有使用这个 key 使用的是未经注册的 serviceKey 。
输出结果如下:
(null)
(null)
可以看到这两个 serviceKey 拿到的 service 并没有报错,使用了 AnyKey 注册的服务。那他们两个会是同一个对象吗还是两个对象呢,我们可以很简单地进行一下验证
Console.WriteLine("userIdProvider == envUserIdProvider ?? {0}", userIdProvider == envUserIdProvider);
输出结果如下:
userIdProvider == envUserIdProvider ?? False
由此可以看到实际每个 serviceKey 是一个对象,不同的 serviceKey  是不同的对象。serviceKey 还有一个特殊情况,目前的 API 里 KeyedService 相关的 API 里 serviceKey 是允许为 null 的,但是实际上当 serviceKey 为 null 时它就不是一个 keyed service 了,我个人觉得这个 API 的设计是有些问题的,不应该允许 null,来看一个示例:
var nullUserIdProvider = services.GetRequiredKeyedService<IUserIdProvider>(null);
Console.WriteLine(nullUserIdProvider.GetUserId());
输出结果如下:
 System.InvalidOperationException: No service for type 'Net8Sample.<__Script>FE1DBF3BE6F8384813B223E3EAA03DBABDC4153F95C5B3EBB0E0807E84E7C20E4__IUserIdProvider' has been registered.
         at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetRequiredKeyedService(Type serviceType, Object serviceKey)
         at Microsoft.Extensions.DependencyInjection.ServiceProviderKeyedServiceExtensions.GetRequiredKeyedService[T](IServiceProvider provider, Object serviceKey)
可以看到当 serviceKey 为 null 时,实际并不会像之前一样使用 AnyKey 对应的服务,会直接报错,如果使用 keyedService 则不应该使用 null 作为 serviceKey 。

另外如果我们注册 keyed service 的时候使用 null 作为 serviceKey,实际相当于注册了一个非 keyed service,比如说这两种注册方式是等价的
serviceCollection.AddKeyedSingleton<IUserIdProvider, NullUserIdProvider>(null);
serviceCollection.AddSingleton<IUserIdProvider, NullUserIdProvider>();
我们在获取服务的时候都可以使用 GetRequiredService<IUserIdProvider>() 来获取服务示例,目前使用 GetRequiredKeyedService<IUserIdProvider>(null) 也是可以的

ServiceKey in constructor
在构造方法中可以使用 ServiceKeyAttribute 来在构造方法中获取注册的 serviceKey,我们来看一个示例:
var serviceCollection = new ServiceCollection();    
serviceCollection.AddKeyedTransient<MyNamedService>(KeyedService.AnyKey);
using var services = serviceCollection.BuildServiceProvider();
Console.WriteLine(services.GetRequiredKeyedService<MyNamedService>("Foo").Name);
Console.WriteLine(services.GetRequiredKeyedService<MyNamedService>("Hello").Name);

file sealed class MyNamedService
{
    public MyNamedService([ServiceKey]string name)
    {
        Name = name;
    }

    public string Name { get; }
}
我们使用 KeyedService.AnyKey 来注册服务,在构造方法里获取 serviceKey 输出结果如下:
Foo
Hello
可以看到我们输出的结果正确反映了我们实际期望的 serviceKey

这里需要注意的是我们需要保证 constructor 中的 serviceKey 类型和获取服务时的类型应该是一致的,否则会有异常,比如:
Console.WriteLine(services.GetRequiredKeyedService<MyNamedService>(123).Name);
这样会导致下面的异常:
System.InvalidOperationException: The type of the key used for lookup doesn't match the type in the constructor parameter with the ServiceKey attribute.
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateArgumentCallSites(ServiceIdentifier serviceIdentifier, Type implementationType, CallSiteChain callSiteChain, ParameterInfo[] parameters, Boolean throwIfCallSiteNotFound)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateConstructorCallSite(ResultCache lifetime, ServiceIdentifier serviceIdentifier, Type implementationType, CallSiteChain callSiteChain)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.TryCreateExact(ServiceDescriptor descriptor, ServiceIdentifier serviceIdentifier, CallSiteChain callSiteChain, Int32 slot)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.TryCreateExact(ServiceIdentifier serviceIdentifier, CallSiteChain callSiteChain)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateCallSite(ServiceIdentifier serviceIdentifier, CallSiteChain callSiteChain)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.GetCallSite(ServiceIdentifier serviceIdentifier, CallSiteChain callSiteChain)
   at Microsoft.Extensions.DependencyInjection.ServiceProvider.CreateServiceAccessor(ServiceIdentifier serviceIdentifier)
   at System.Collections.Concurrent.ConcurrentDictionary`2.GetOrAdd(TKey key, Func`2 valueFactory)
   at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(ServiceIdentifier serviceIdentifier, ServiceProviderEngineScope serviceProviderEngineScope)
   at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetKeyedService(Type serviceType, Object serviceKey)
   at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetRequiredKeyedService(Type serviceType, Object serviceKey)
   at Microsoft.Extensions.DependencyInjection.ServiceProviderKeyedServiceExtensions.GetRequiredKeyedService[T](IServiceProvider provider, Object serviceKey)
serviceKey 是 object 类型,所以我们是可以用任意类型的,比如说下面这个示例:
var serviceCollection = new ServiceCollection();
serviceCollection.AddKeyedTransient<MyKeyedService>(KeyedService.AnyKey);
using var services = serviceCollection.BuildServiceProvider();

Console.WriteLine(services.GetRequiredKeyedService<MyKeyedService>(new Category()
{
    Id = 1,
    Name = "test"
}).Name);
将会输出 test

Scoped Sevice
目前对于 scoped service 的支持是有些问题的,使用 scoped service 使用会发生异常
var serviceCollection = new ServiceCollection();    
serviceCollection.AddKeyedScoped<IUserIdProvider, NullUserIdProvider>("");
using var services = serviceCollection.BuildServiceProvider();

using var scope = services.CreateScope();
var newId = scope.ServiceProvider.GetRequiredKeyedService<IIdGenerator>("").NewId();
Console.WriteLine(newId);
会看到下面这样的一个异常:
System.InvalidOperationException: This service provider doesn't support keyed services.
   at Microsoft.Extensions.DependencyInjection.ServiceProviderKeyedServiceExtensions.GetRequiredKeyedService(IServiceProvider provider, Type serviceType, Object serviceKey)
   at Microsoft.Extensions.DependencyInjection.ServiceProviderKeyedServiceExtensions.GetRequiredKeyedService[T](IServiceProvider provider, Object serviceKey)
   at Net8Sample.KeyedServiceSample.ScopedSample()
基于此,如果在 aspnetcore 里基于 HttpContext.RequestServices 去获取 keyedService 的话都会有这样的一个异常,因为 HttpContext.RequestServices 也是一个 scoped service provider

感兴趣的可以尝试一下下面的示例,看看两个 API 的 response:
var builder = WebApplication.CreateBuilder();
builder.Services.AddKeyedSingleton<IIdGenerator, GuidIdGenerator>("guid");
var app = builder.Build();
app.Map("/id0", ([FromKeyedServices("guid")]IIdGenerator idGenerator) 
    => Result.Success<string>(idGenerator.NewId()));
app.Map("/id", (HttpContext httpContext) =>
{
    var idGenerator = httpContext.RequestServices.GetRequiredKeyedService<IIdGenerator>("guid");
    return Result.Success<string>(idGenerator.NewId());
});
await app.RunAsync();
主要原因是 ScopedServiceProvider 没有实现 IKeyedServiceProvider, 已经有 PR 修复了这个问题,在 RC1 版本中应该会发布,应该会够修复这个问题

其它
我们也可以结合 Options 来方便的实现基于 options 的 named service,示例如下:
var serviceCollection = new ServiceCollection();

serviceCollection.Configure<TotpOptions>(x =>
{
    x.Salt = "1234";
});
serviceCollection.AddKeyedTransient<ITotpService, TotpService>(KeyedService.AnyKey, 
    (sp, key)=>
    new TotpService(sp.GetRequiredService<IOptionsMonitor<TotpOptions>>()
        .Get(key is string name ? name : Options.DefaultName)));

using var services = serviceCollection.BuildServiceProvider();
var totpService = services.GetRequiredKeyedService<ITotpService>(string.Empty);
Console.WriteLine("Totp1: {0}", totpService.GetCode("Test1234"));
var totpService2 = services.GetRequiredKeyedService<ITotpService>("test");
Console.WriteLine("Totp2: {0}", totpService2.GetCode("Test1234"));
输出结果如下:
Totp1: 356934
Totp2: 626994
总体上来说,感觉解决了一些 named service 的一些痛点,可惜的是还有一些 bug,不过目前是预览版还能接受,正式版只要能够正常使用就可以。另外觉得 serviceKey 可以为 null 觉得有些不合理,既然是 keyedService 那应该就不允许为 null 如果为 null 了就不是 keyedSevice 了。

用户评论