Skip to main content

7.9 — 7. Keyed Services (.NET 8)

Summary

Trước .NET 8, container .NET không có cách chính thức để nói "cho tôi IEmailService, bản SendGrid". Mọi người phải tự chế Func<string, IEmailService> hoặc đổi sang Autofac. .NET 8 thêm keyed services: AddKeyedScoped<IEmailService, SendGridEmailService>("sendgrid") rồi lấy ra bằng [FromKeyedServices("sendgrid")]. Điều đáng nói là giới hạn của nó: khoá phải là hằng số lúc biên dịch trong attribute, nên chọn implementation theo dữ liệu lúc chạy vẫn cần IKeyedServiceProvider. Và IEnumerable<T> thường không thấy service có khoá — một bất ngờ khó chịu nếu bạn đang dùng mẫu tiêm cả tập.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Đăng ký và lấy ra service theo khoá.
  • Chọn implementation theo dữ liệu lúc chạy mà không dùng service locator.
  • Giải thích vì sao keyed service không xuất hiện trong IEnumerable<T>.
  • Biết khi nào keyed service là sai công cụ.

Nội dung bài học​

7.9.1 — Cách cũ trước .NET 8​

services.AddScoped<SmtpEmailService>();
services.AddScoped<SendGridEmailService>();

services.AddScoped<Func<string, IEmailService>>(sp => key => key switch
{
"smtp" => sp.GetRequiredService<SmtpEmailService>(),
"sendgrid" => sp.GetRequiredService<SendGridEmailService>(),
_ => throw new ArgumentException($"Không biết provider: {key}")
});

Chạy được, nhưng: khoá không kiểm tra được lúc biên dịch, lỗi chỉ hiện lúc chạy, và mỗi lần thêm provider phải sửa cả switch.

7.9.2 — Keyed services​

services.AddKeyedScoped<IEmailService, SmtpEmailService>("smtp");
services.AddKeyedScoped<IEmailService, SendGridEmailService>("sendgrid");

services.AddKeyedSingleton<ICache, RedisCache>("distributed");
services.AddKeyedSingleton<ICache, MemoryCache>("local");

Lấy ra bằng attribute:

public class NotificationService(
[FromKeyedServices("sendgrid")] IEmailService primary,
[FromKeyedServices("smtp")] IEmailService fallback,
ILogger<NotificationService> logger)
{
public async Task SendAsync(string to, string subject, string body, CancellationToken ct)
{
try
{
await primary.SendAsync(to, subject, body, ct);
}
catch (EmailServiceException ex)
{
logger.LogWarning(ex, "SendGrid lỗi, chuyển sang SMTP");
await fallback.SendAsync(to, subject, body, ct);
}
}
}

Attribute này dùng được ở cả controller action, minimal API handler và tham số của InvokeAsync trong middleware.

Khoá không nhất thiết là string — bất kỳ object nào có Equals và GetHashCode đúng, nên enum là lựa chọn tốt hơn:

public enum EmailProvider { Smtp, SendGrid }

services.AddKeyedScoped<IEmailService, SendGridEmailService>(EmailProvider.SendGrid);

enum cho bạn kiểm tra lúc biên dịch và đổi tên an toàn — thứ mà "sendgrid" không có.

7.9.3 — Chọn theo dữ liệu lúc chạy​

Attribute cần hằng số lúc biên dịch, nên khi khoá đến từ dữ liệu (cấu hình của tenant, tham số request), dùng IKeyedServiceProvider:

public class TenantEmailService(IServiceProvider serviceProvider, ITenantContext tenant)
{
public Task SendAsync(string to, string subject, string body, CancellationToken ct)
{
var service = serviceProvider.GetRequiredKeyedService<IEmailService>(tenant.EmailProvider);
return service.SendAsync(to, subject, body, ct);
}
}

Đây về hình thức là service locator, thứ bài 7.10 khuyên tránh. Khác biệt nằm ở phạm vi: lớp này chỉ resolve một kiểu duy nhất đã biết, và việc chọn khoá là nghiệp vụ thật sự của nó. Đó không phải một lớp giấu mọi phụ thuộc trong _sp.

Vẫn nên gói lại sau một interface hẹp để phần còn lại của hệ thống không thấy IServiceProvider:

public interface IEmailServiceResolver { IEmailService Resolve(EmailProvider provider); }

7.9.4 — Ba điều cần biết​

1. IEnumerable<T> không thấy service có khoá.

services.AddScoped<IValidator, EmailValidator>();
services.AddKeyedScoped<IValidator, PhoneValidator>("phone");

public class Foo(IEnumerable<IValidator> validators) { } // chi co EmailValidator

Service có khoá và không khoá nằm ở hai không gian tách biệt. Muốn lấy cả tập có khoá thì phải GetKeyedServices<T>(key) — nghĩa là nhiều service cùng một khoá.

2. KeyedService.AnyKey để bắt mọi khoá.

services.AddKeyedScoped<IEmailService, DefaultEmailService>(KeyedService.AnyKey);

Bất kỳ khoá nào chưa có đăng ký riêng đều rơi vào đây — hữu ích cho fallback, nhưng cũng che mất lỗi gõ sai khoá.

3. Biết khoá mình được resolve bằng.

public class EmailService([ServiceKey] string key) { }

7.9.5 — Khi nào không dùng keyed services​

Keyed services giải quyết "nhiều implementation, chọn một". Nhưng có ba tình huống công cụ khác tốt hơn:

Tình huốngDùng gì
Cần chạy hết các implementation (validator, handler)IEnumerable<T>, không khoá — bài 7.6
Implementation chọn theo môi trường, cố định lúc khởi độngif (env.IsDevelopment()) trong Program.cs — đơn giản hơn nhiều
Mỗi implementation tự biết nó xử lý được gìStrategy pattern: IEnumerable<IPaymentStrategy> với CanHandle(type)

Trường hợp cuối đáng nói thêm:

public interface IPaymentStrategy
{
bool CanHandle(PaymentMethod method);
Task<PaymentResult> PayAsync(PaymentRequest request, CancellationToken ct);
}

public class PaymentService(IEnumerable<IPaymentStrategy> strategies)
{
public Task<PaymentResult> PayAsync(PaymentRequest request, CancellationToken ct)
=> strategies.FirstOrDefault(s => s.CanHandle(request.Method))
?.PayAsync(request, ct)
?? throw new NotSupportedException($"Không hỗ trợ {request.Method}");
}

Ở đây implementation tự khai báo nó xử lý được gì, thay vì nơi gọi phải biết khoá. Thêm phương thức thanh toán mới = thêm một lớp và một dòng đăng ký, không đụng vào PaymentService. Với logic chọn phức tạp hơn một khoá, mẫu này rõ ràng hơn keyed services.

7.9.6 — Rà lại code của bạn​

Danh sách rà soát keyed services

  • •Khoá dùng enum hoặc const, không phải chuỗi rải rác trong code.
  • •Không dùng keyed services cho trường hợp cần chạy hết mọi implementation.
  • •Biết rằng IEnumerable<T> không nhìn thấy service có khoá.
  • •Chọn theo dữ liệu lúc chạy được gói sau một interface hẹp, không lộ IServiceProvider.
  • •Lựa chọn theo môi trường dùng if trong Program.cs thay vì keyed services.
  • •Logic chọn phức tạp hơn một khoá thì dùng strategy pattern với CanHandle.
  • •KeyedService.AnyKey chỉ dùng có chủ đích, không để che lỗi gõ sai khoá.

Bài tập áp dụng​

Bài 1 — Chuyển factory sang keyed services​

Lấy một factory dạng Func<string, IFoo> đang có trong dự án và viết lại bằng keyed services với enum làm khoá. So sánh số dòng và số chỗ phải sửa khi thêm một cài đặt.

Tiêu chí hoàn thành: bạn nêu được lợi ích và một hạn chế của keyed services so với factory tự viết.

Gợi ý và lời giải — Bài 1

Gợi ý. Trước .NET 8, container tích hợp không hỗ trợ nhiều cài đặt phân biệt theo khoá, nên mọi người tự viết factory. Từ .NET 8 thì không cần nữa.

Lời giải — trước:

public delegate INotificationSender NotificationSenderFactory(string kenh);

builder.Services.AddScoped<EmailSender>();
builder.Services.AddScoped<SmsSender>();
builder.Services.AddScoped<ZaloSender>();

builder.Services.AddScoped<NotificationSenderFactory>(sp => kenh => kenh switch
{
"email" => sp.GetRequiredService<EmailSender>(),
"sms" => sp.GetRequiredService<SmsSender>(),
"zalo" => sp.GetRequiredService<ZaloSender>(),
_ => throw new ArgumentOutOfRangeException(nameof(channel))
});

// Dùng
public class NotifyService(NotificationSenderFactory factory)
{
public Task SendAsync(string channel, string content, CancellationToken ct)
=> factory(channel).SendAsync(content, ct);
}

Sau:

public enum Channel { Email, Sms, Zalo }

builder.Services.AddKeyedScoped<INotificationSender, EmailSender>(Channel.Email);
builder.Services.AddKeyedScoped<INotificationSender, SmsSender>(Channel.Sms);
builder.Services.AddKeyedScoped<INotificationSender, ZaloSender>(Channel.Zalo);

// Dùng — chọn lúc chạy
public class NotifyService(IServiceProvider sp)
{
public Task SendAsync(Channel channel, string content, CancellationToken ct)
=> sp.GetRequiredKeyedService<INotificationSender>(channel).SendAsync(content, ct);
}

// Hoặc chọn lúc biên dịch, khi biết trước cần kênh nào
public class WelcomeService([FromKeyedServices(Channel.Email)] INotificationSender sender) { }

So sánh:

Factory tự viếtKeyed services
Số dòng đăng ký93
Thêm một kênh mớiSửa 2 chỗ: đăng ký + nhánh switchThêm 1 dòng
Khoá là enumPhải tự ánh xạ từ chuỗiHỗ trợ sẵn
Quên thêm nhánhLỗi lúc chạyLỗi lúc chạy

Dùng enum làm khoá thay vì chuỗi là cải thiện đáng kể: nó loại bỏ lỗi gõ sai, và cho phép switch vét cạn ở những chỗ khác trong hệ thống như bài 4.9 trình bày.

Hạn chế của keyed services. Cách dùng IServiceProvider ở trên đưa Service Locator quay lại — đúng thứ mà bài 7.10 cảnh báo. Chữ ký NotifyService(IServiceProvider sp) không cho biết lớp này cần gì.

Đây là một đánh đổi có thật, và cách giảm nhẹ là bọc việc tra cứu lại:

public interface INotificationSenderResolver
{
INotificationSender Lay(Channel channel);
}

internal sealed class NotificationSenderResolver(IServiceProvider sp) : INotificationSenderResolver
{
public INotificationSender Lay(Channel channel)
=> sp.GetRequiredKeyedService<INotificationSender>(kenh);
}

// Giờ chữ ký nói đúng sự thật
public class NotifyService(INotificationSenderResolver resolver) { }

Việc tra cứu bị giới hạn trong một lớp hạ tầng nhỏ, còn tầng nghiệp vụ khai báo phụ thuộc tường minh. Đây là mẫu nên dùng khi cần chọn cài đặt theo dữ liệu lúc chạy.

Khi nào [FromKeyedServices] là đủ. Khi bạn biết lúc biên dịch cần cài đặt nào — ví dụ WelcomeService luôn gửi email. Lúc đó không cần tra cứu gì, và chữ ký vẫn hoàn toàn tường minh.

Bài 2 — Keyed services vô hình với IEnumerable​

Đăng ký một IValidator không khoá và một IValidator có khoá, tiêm IEnumerable<IValidator> và đếm số phần tử.

Tiêu chí hoàn thành: bạn ghi lại đúng con số và nêu được hậu quả của nó trong một hệ thống thật.

Gợi ý và lời giải — Bài 2

Gợi ý. Keyed và không khoá là hai không gian đăng ký tách biệt. Hãy thử và xem con số.

Lời giải.

var s = new ServiceCollection();
s.AddSingleton<IValidator, V1>(); // KHÔNG khoá
s.AddKeyedSingleton<IValidator, V2>("dac-biet"); // CÓ khoá
var p = s.BuildServiceProvider();

Console.WriteLine($"IEnumerable thấy: {p.GetServices<IValidator>().Count()} phần tử");
Console.WriteLine($"GetKeyedService : {p.GetRequiredKeyedService<IValidator>("dac-biet").Ten}");

Kết quả đo thật trên .NET 9:

IEnumerable<IValidator> thấy: 1 phần tử -> V1-khong-khoa
GetKeyedService("dac-biet") : V2-co-khoa
GetRequiredService (không khoá): V1-khong-khoa

Chỉ 1 phần tử. Đăng ký có khoá hoàn toàn vô hình với GetServices<T>() và với IEnumerable<T> tiêm qua constructor.

Hậu quả trong hệ thống thật. Mẫu "chạy tất cả validator" rất phổ biến:

public class OrderHandler(IEnumerable<IValidator<Order>> validators)
{
public async Task<Result> HandleAsync(Order o, CancellationToken ct)
{
foreach (var v in validators) // chỉ chạy validator KHÔNG khoá
{
var result = await v.ValidateAsync(o, ct);
if (!result.IsValid) return Result.Fail(result.Error);
}
// ...
}
}

Nếu ai đó đăng ký một validator bằng AddKeyedScoped — có thể vì sao chép từ một chỗ khác — validator đó không bao giờ chạy. Không lỗi, không cảnh báo, chỉ đơn giản là quy tắc nghiệp vụ không được áp dụng.

Đây là loại lỗi đặc biệt nguy hiểm vì nó im lặng và vì nó ảnh hưởng tới tính đúng đắn nghiệp vụ, không phải tới kỹ thuật.

Lấy tất cả cài đặt có khoá. Không có API nào lấy "mọi khoá" trực tiếp. Phải liệt kê khoá rồi lấy từng cái:

var allSenders = Enum.GetValues<Channel>()
.Select(k => sp.GetRequiredKeyedService<INotificationSender>(k))
.ToList();

Hoặc đăng ký cả hai cách khi bạn cần cả hai kiểu truy cập:

builder.Services.AddScoped<EmailSender>();
builder.Services.AddKeyedScoped<INotificationSender>(Channel.Email, (sp, _) => sp.GetRequiredService<EmailSender>());
builder.Services.AddScoped<INotificationSender>(sp => sp.GetRequiredService<EmailSender>());

Cách này hơi dài dòng nhưng cho phép vừa GetServices vừa GetKeyedService, và vẫn chỉ có một thể hiện vì cả hai đều trỏ về EmailSender đã đăng ký.

Ba điều nữa cần biết về keyed services:

  1. Khoá so sánh bằng Equals. Dùng enum, string, hoặc record đều được. Dùng class thường thì gặp đúng vấn đề ở bài 1.6.
  2. KeyedService.AnyKey lấy bất kỳ khoá nào — hữu ích cho mẫu decorator áp dụng cho mọi cài đặt.
  3. Không phải container nào cũng hỗ trợ. Nếu dự án dùng Autofac hay một container khác, hãy kiểm tra trước khi chuyển sang keyed services.

Khi nào không nên dùng keyed services. Khi chỉ có một cài đặt — thêm khoá lúc đó chỉ làm code khó đọc mà không giải quyết gì. Và khi bạn cần chạy tất cả thay vì chọn một — lúc đó IEnumerable<T> với đăng ký không khoá mới là công cụ đúng.

Bài 3 — So sánh keyed services với mẫu chiến lược​

Cài một hệ thống thanh toán hỗ trợ 3 phương thức, một lần bằng keyed services và một lần bằng mẫu chiến lược với CanHandle. Thêm phương thức thứ 4 và đếm số file phải sửa ở mỗi cách.

Tiêu chí hoàn thành: bạn nêu được tiêu chí chọn giữa hai cách, dựa trên cách chọn cài đặt chứ không dựa trên số dòng.

Gợi ý và lời giải — Bài 3

Gợi ý. Khác biệt cốt lõi: với keyed services, người gọi biết trước cần cài đặt nào. Với CanHandle, chính cài đặt tự quyết định nó có xử lý được hay không.

Lời giải — cách 1, keyed services:

public enum PaymentMethod { CreditCard, BankTransfer, EWallet }

builder.Services.AddKeyedScoped<IPaymentGateway, CreditCardGateway>(PaymentMethod.CreditCard);
builder.Services.AddKeyedScoped<IPaymentGateway, BankTransferGateway>(PaymentMethod.BankTransfer);
builder.Services.AddKeyedScoped<IPaymentGateway, EWalletGateway>(PaymentMethod.EWallet);

public class PaymentService(IPaymentGatewayResolver resolver)
{
public Task<Result> PayAsync(Order o, PaymentMethod method, CancellationToken ct)
=> resolver.Get(method).PayAsync(o, ct);
}

Cách 2, chiến lược với CanHandle:

public interface IPaymentGateway
{
bool CanHandle(Order o);
Task<Result> PayAsync(Order o, CancellationToken ct);
}

public class CreditCardGateway : IPaymentGateway
{
public bool CanHandle(Order o) => o.Method == PaymentMethod.CreditCard && o.Total <= 50_000_000m;
// ...
}

builder.Services.AddScoped<IPaymentGateway, CreditCardGateway>();
builder.Services.AddScoped<IPaymentGateway, BankTransferGateway>();
builder.Services.AddScoped<IPaymentGateway, EWalletGateway>();

public class PaymentService(IEnumerable<IPaymentGateway> gateways)
{
public Task<Result> PayAsync(Order o, CancellationToken ct)
{
var gateway = gateways.FirstOrDefault(g => g.CanHandle(o))
?? throw new InvalidOperationException("Không có cổng thanh toán phù hợp");
return gateway.PayAsync(o, ct);
}
}

Thêm phương thức thứ 4:

Keyed servicesChiến lược CanHandle
Thêm lớp cài đặt1 file mới1 file mới
Thêm giá trị enum1 file sửaKhông cần
Thêm dòng đăng ký1 dòng1 dòng
Tổng file sửa21

Nhưng số file không phải tiêu chí chọn. Tiêu chí thật là ai quyết định dùng cài đặt nào:

Keyed servicesChiến lược CanHandle
Ai chọnNgười gọi, qua khoáChính cài đặt, qua điều kiện
Điều kiện chọnMột giá trị đơn giảnLogic tuỳ ý trên dữ liệu
Nhìn code biết cài đặt nào chạyCó — theo khoáKhông — phải đọc mọi CoTheXuLy
Hai cài đặt cùng nhậnKhông xảy raCó thể — phụ thuộc thứ tự đăng ký
Không cài đặt nào nhậnLỗi rõ ràng từ containerPhải tự xử lý

Chọn keyed services khi tiêu chí chọn là một giá trị đơn giản mà người gọi đã biết: loại tài liệu, mã quốc gia, tên nhà cung cấp. Ưu điểm lớn nhất là nhìn vào khoá là biết ngay cài đặt nào sẽ chạy.

Chọn CanHandle khi điều kiện phụ thuộc vào nhiều thuộc tính của dữ liệu, như ví dụ trên: thẻ tín dụng chỉ xử lý được đơn dưới 50 triệu. Khoá đơn giản không diễn đạt được điều kiện này.

Cái bẫy của CanHandle — thứ tự quyết định kết quả. Với FirstOrDefault, cài đặt đăng ký trước sẽ thắng nếu cả hai cùng nhận. Điều đó biến thứ tự đăng ký thành một phần của logic nghiệp vụ, mà lại nằm ở Program.cs chứ không nằm cùng với nghiệp vụ.

Nếu buộc dùng CanHandle, hãy làm thứ tự thành tường minh:

public interface IPaymentGateway
{
int DoUuTien { get; } // thấp hơn được xét trước
bool CoTheXuLy(Order o);
}

var gw = gateways.OrderBy(g => g.DoUuTien).FirstOrDefault(g => g.CoTheXuLy(o));

Lúc này thứ tự nằm trong chính cài đặt, không phụ thuộc vào cách đăng ký.

Tự kiểm tra​

Frequently asked questions

Keyed services giải quyết vấn đề gì?

Trước .NET 8, container .NET không có cách chính thức để nói cho tôi interface này nhưng là implementation cụ thể kia, nên mọi người phải tự chế Func factory hoặc đổi sang Autofac. Keyed services cho phép đăng ký nhiều implementation cùng interface theo khoá và lấy ra bằng FromKeyedServices.

Nên dùng string hay enum làm khoá?

Enum, vì khoá có thể là bất kỳ object nào có Equals và GetHashCode đúng. Enum cho bạn kiểm tra lúc biên dịch và đổi tên an toàn, còn chuỗi thì gõ sai chỉ phát hiện lúc chạy.

Chọn implementation theo dữ liệu lúc chạy thì làm sao?

Attribute cần hằng số lúc biên dịch nên phải dùng GetRequiredKeyedService từ IServiceProvider. Về hình thức đó là service locator, nhưng khác ở chỗ lớp này chỉ resolve một kiểu duy nhất đã biết và việc chọn khoá là nghiệp vụ thật của nó. Nên gói lại sau một interface hẹp để phần còn lại của hệ thống không thấy IServiceProvider.

Vì sao IEnumerable<T> không thấy service có khoá?

Vì service có khoá và không khoá nằm ở hai không gian tách biệt trong container. Muốn lấy cả tập có khoá thì phải dùng GetKeyedServices với một khoá cụ thể, nghĩa là nhiều service cùng chung một khoá.

Khi nào không nên dùng keyed services?

Ba trường hợp. Khi cần chạy hết mọi implementation như validator hay handler thì dùng IEnumerable không khoá. Khi implementation chọn theo môi trường và cố định lúc khởi động thì một câu if trong Program.cs đơn giản hơn. Và khi logic chọn phức tạp hơn một khoá thì dùng strategy pattern.

Strategy pattern hơn keyed services ở điểm nào?

Implementation tự khai báo nó xử lý được gì qua phương thức CanHandle, thay vì nơi gọi phải biết khoá. Thêm một trường hợp mới chỉ cần thêm một lớp và một dòng đăng ký, không đụng vào lớp điều phối. Với logic chọn phức tạp thì mẫu này rõ ràng hơn.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Dùng enum làm khoá, không dùng chuỗi rải rác.
  2. IEnumerable<T> không thấy service có khoá — hai không gian tách biệt.
  3. Cần chạy hết thì dùng IEnumerable; chọn phức tạp thì dùng strategy. Keyed services chỉ cho "chọn một theo khoá".

Tham khảo​

Điều hướng​

Bài liên quan​