Skip to content
Go back

.NET Keyed Services:一个接口多实现时的依赖注入方案

一个接口三个实现。INotificationSender,有邮件发送器、短信发送器、推送发送器。用户选择怎么被通知,你的代码要把选择变成正确的对象。

于是你写了个工厂。工厂里有个 switch

public class NotificationFactory
{
    private readonly IServiceProvider _provider;

    public NotificationFactory(IServiceProvider provider) => _provider = provider;

    public INotificationSender Create(string channel) => channel switch
    {
        "email" => _provider.GetRequiredService<EmailSender>(),
        "sms"   => _provider.GetRequiredService<SmsSender>(),
        "push"  => _provider.GetRequiredService<PushSender>(),
        _ => throw new ArgumentException($"Unknown channel: {channel}")
    };
}

这能工作,我也在线上跑过。但它有味道:每加一个渠道,你要改两个地方——注册和 switch。容器已经知道怎么构建所有三个;你只是在它上面又手写了一张查询表。而且你必须注册具体类型(EmailSender 而不是 INotificationSender),到处泄露实现细节。

从 .NET 8 起,你根本不需要工厂。DI 容器可以持有同一个接口的多个实现,每个打上 key,然后把对的那个递给你。这叫 keyed services,它删掉了一整类样板代码。

Keyed Services 是什么

一个普通注册是按类型键控的:要 INotificationSender,拿到为它注册的那一个实现。注册两个,后一个覆盖前一个——前一个被静默遮蔽。

键控注册增加第二个维度:类型加上 key。你把 INotificationSender 注册三次,每次在不同 key 下,它们共存。解析时你同时要类型和 key。

key 可以是任何对象——string、enum、任何有合理等值判断的东西。string 读起来好,但 enum 给你编译期安全,所以 key 集合固定时我优先用 enum。

注册

你熟悉的方法都有键控版本:AddKeyedSingletonAddKeyedScopedAddKeyedTransient。第一个参数传 key:

builder.Services.AddKeyedScoped<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedScoped<INotificationSender, SmsSender>("sms");
builder.Services.AddKeyedScoped<INotificationSender, PushSender>("push");

这就是全部注册。没有工厂、没有 switch、不注册具体类型。每个实现是容器可以构建的真正的 INotificationSender,自己的依赖正常注入:

public class SmsSender : INotificationSender
{
    private readonly ITwilioClient _twilio;
    private readonly ILogger<SmsSender> _logger;

    // 构造函数注入照常工作 —— 键控注册不改变这个类怎么拿到自己的依赖
    public SmsSender(ITwilioClient twilio, ILogger<SmsSender> logger)
    {
        _twilio = twilio;
        _logger = logger;
    }

    public Task SendAsync(string to, string message, CancellationToken ct) =>
        _twilio.SendSmsAsync(to, message, ct);
}

已知 Key 解析

当 key 在调用点是固定的——这个服务总是发邮件——用 [FromKeyedServices] 直接注入:

public class WelcomeEmailService
{
    private readonly INotificationSender _sender;

    public WelcomeEmailService(
        [FromKeyedServices("email")] INotificationSender sender)
    {
        _sender = sender;
    }

    public Task SendWelcome(string address, CancellationToken ct) =>
        _sender.SendAsync(address, "Welcome aboard!", ct);
}

同样的属性在 Minimal API handler 里最清爽:

app.MapPost("/welcome", (
    [FromKeyedServices("email")] INotificationSender sender,
    WelcomeRequest request,
    CancellationToken ct) =>
{
    return sender.SendAsync(request.Email, "Welcome aboard!", ct);
});

工厂消失了。属性本身就是查找。

运行时 Key 解析

更有趣的场景是 key 直到请求进来才知道——用户在设置里选了 "sms"。你不能用属性做这个,需要动态解析。注入 IServiceProvider 并调用 GetRequiredKeyedService

public class NotificationDispatcher
{
    private readonly IServiceProvider _provider;

    public NotificationDispatcher(IServiceProvider provider) => _provider = provider;

    public Task Dispatch(string channel, string to, string message, CancellationToken ct)
    {
        // channel 在运行时来自用户偏好:"email" | "sms" | "push"
        var sender = _provider.GetRequiredKeyedService<INotificationSender>(channel);
        return sender.SendAsync(to, message, ct);
    }
}

这是对工厂 switch 的诚实替代。容器现在是查询表,加第四个渠道只加一行注册——别的什么都不动。如果未知 key 应该返回 null 而不是抛异常,用 GetKeyedService(可空)代替 GetRequiredKeyedService

值得说明的一点:注入 IServiceProvider 并从中解析是服务定位器模式,人们有理由对它保持警惕。这里的区别在于范围。你不是在代码库各处为任意类型伸手进容器——你在一个地方、为一个接口、做一次键控查找,key 确实直到运行时才知道。这是合理的用法。如果 key 在编译期就已知,用属性。

同时获取全部实现

有时候你要全部实现而不是某一个——把消息扇出到用户启用的每个渠道。注入键控可枚举:

// 注入所有键控的 INotificationSender 注册
public class BroadcastService
{
    private readonly IEnumerable<INotificationSender> _all;

    public BroadcastService(
        [FromKeyedServices(KeyedService.AnyKey)] IEnumerable<INotificationSender> all)
    {
        _all = all;
    }

    public Task BroadcastAll(string to, string message, CancellationToken ct) =>
        Task.WhenAll(_all.Select(s => s.SendAsync(to, message, ct)));
}

KeyedService.AnyKey 相当于”匹配任意 key”,拿到的就是所有键控注册的实现集合。

什么时候真该用

不是每个”多实现”问题都需要键控 DI。值得用的场景:

不值得的场景:如果你永远只有一个实现,键控服务是在无理由增加仪式。如果选择逻辑复杂——不止是按 key 选,比如有权重或回退——把逻辑放在一个真正的策略类里比让 key 过载更清楚。键控 DI 替代的是查找,不是你的业务规则

几点注意

总结

Keyed Services 是一个小特性,悄悄去掉了大多数 .NET 代码库都带着的一种模式:那个你每加一个实现就得编辑一次的带 switch 的工厂。把每个实现注册在一个 key 下,key 固定时通过属性注入,运行时选择时从 provider 解析,让容器去做它本来就能做的查询表。

我的使用经验法则:如果你在写一个唯一职责是把值映射到注册类型的工厂,keyed services 替你做。如果工厂包含真正的决策逻辑,保留它——但它仍然可以通过 key 而不是具体类型来解析选项。

参考


Tags


Previous

Git 2.55 更新亮点:MIDX 增量重打包、git history fixup 与更多

Next

HttpClient DNS 问题:PooledConnectionLifetime 与 SocketsHttpHandler