Mối liên hệ giữa IoC và nguyên lý DIP trong SOLID
Mở đầu
- Trong bài viết trước chúng ta đã tìm hiểu về: SOLID trong lập trình hướng đối tượng OOP.
- Trong bài viết này chúng ta sẽ tìm hiểu chi tiết hơn về:
DIP (Dependency Inversion Priciple)
nguyên tắc thứ 5 trong SOLID. Bên cạnh đó là cách áp dụng thực tếDIP
thông qua kỹ thuậtIoC (Inversion of Control)
trong C#.
IoC là gì?
- IoC là viết tắt của Inversion of Control là nguyên lý về đảo ngược sự phụ thuộc được áp dụng ứng với nguyên tắc
DIP
trong SOLID. - Định nghĩa rằng: việc khởi tạo đối tượng không do chính lớp đó quyết định mà được đảo ngược - chuyển đổi sang một thành phần khác (container hoặc framework).
Không áp dụng IoC và vấn đề gặp phải
- Yêu cầu ban đầu: khi thực hiện đặt hàng sẽ gửi một thông báo đặt hàng thành công đến email của người dùng.
- Ví dụ một đoạn code không áp dụng IoC:
csharp
public class EmailService
{
public void Send(string to, string subject, string body)
{
Console.WriteLine($"Send email to {to} - {subject}: {body}");
}
}
public class OrderService
{
private readonly EmailService _emailService = new EmailService();
public void PlaceOrder()
{
_emailService.Send("user001@gmail.com", "Order", "Your order has been placed.");
}
}
Trong đoạn code trên:
- lớp
EmailService
sẽ có một phương thức gửi mail là hàmSend
. - lớp
OrderService
sẽ khởi tạo trực tiếp lớpEmailService
và thực hiện logic gửi mail trong hàmPlaceOrder
.
- Review đoạn code ổn về mặt logic và không có gì khác thường.
- Nhưng nếu lúc này yêu cầu logic được thay đổi và cập nhật thêm!
- Yêu cầu thay đổi: email lúc này sẽ được cấu hình và gửi thông qua server email riêng biệt.
- đoạn code sẽ được điều chỉnh lại để phù hợp với yêu cầu:
csharp
public class EmailService
{
public EmailService(string smtpServer)
{
_smtpServer = smtpServer;
}
public void Send(string to, string subject, string body)
{
Console.WriteLine($"Send email to {to} - {subject}: {body}");
}
}
Vấn đề gặp phải trong ví dụ trên:
lớp OrderService
trực tiếp khởi tạo EmailService
. Nếu lớp EmailService
có thay đổi logic, lớp OrderService
cũng sẽ phải chỉnh sửa lại code để không bị lỗi. Điều nãy dẫn việc bị phụ thuộc lẫn nhau gây ra khó bảo trì, mở rộng trong tương lai 😐️
IoC hoạt động như thế nào trong C#?
- Trong C#, IoC thường được thực hiện thông qua Dependency Injection (DI) – tức là tiêm các dependency vào lớp thông qua
constructor, method, hoặc property
.
ASP.NET Core
hỗ trợ IoC mặc định thông qua built-inDI container
.
- Ví dụ về cách cấu hình DI trong dự án
ASP.NET Core
csharp > Program.cs
builder.Services.AddTransient<OrderService>();
Phân loại vòng đời trong DI
- Tùy vào mục đính sử dụng mà cách tiêm dependency của service vào ứng dụng được chia làm 3 loại chính:
Singleton
,Transient
,Scoped
Thư viện hỗ trợ DI trong C#: Microsoft.Extensions.DependencyInjection
1. Singleton
Singleton
là cách tiêm mà thể hiện (instance) của một service chỉ được khởi tạo một lần duy nhất xuyên suốt quá trình chạy ứng dụng.- Cách tiêm này thường được sử dụng để:
ghi log, cấu hình hệ thống, lưu trữ bộ nhớ đệm (cache),...
- Ví dụ: sử dụng redis để lưu trữ bộ nhớ đệm cho ứng dụng
- Cài đặt thư viện redis:
csharp
dotnet add package StackExchange.Redis
- Tạo RedisService class:
csharp > RedisService.cs
using StackExchange.Redis;
using System;
using System.Threading.Tasks;
public class RedisService : IDisposable
{
private readonly ConnectionMultiplexer _redis;
private readonly IDatabase _db;
public RedisService(string connectionString)
{
_redis = ConnectionMultiplexer.Connect(connectionString);
_db = _redis.GetDatabase();
}
// Set value by key (string)
public async Task<bool> SetStringAsync(string key, string value)
{
return await _db.StringSetAsync(key, value);
}
// Get value by key (string)
public async Task<string?> GetStringAsync(string key)
{
return await _db.StringGetAsync(key);
}
// Remove key
public async Task<bool> RemoveKeyAsync(string key)
{
return await _db.KeyDeleteAsync(key);
}
public void Dispose()
{
_redis?.Dispose();
}
}
- Đăng ký RedisService cho ứng dụng
csharp > Program.cs
var redisConnectionString = "localhost:6379"; // your Redis connection string
builder.Services.AddSingleton<RedisService>(sp => new RedisService(redisConnectionString));
- Trong ví dụ trên RedisService chỉ được đăng ký một lần duy nhất khi ứng dụng khởi chạy.
- Khi service này được sử dụng trong ứng dụng sẽ đảm bảo được việc
chỉ có một thể hiện duy nhất và giúp duy trì kết nối xuyên suốt
đến redis service.
2. Transient
Transient
là cách tiêm mà thể hiện (instance) của một service sẽ được khởi tạo thông qua mỗi lần được gọi.- Cách tiêm này thường được sử dụng để:
khởi tạo các service nhanh, nhẹ; đảm bảo dữ liệu độc lập cho mỗi service được gọi
. - ví dụ: Một
BackgroundService - Singleton
gọiIJobProcessor - Transient
cho mỗi job → đảm bảo job nào cũng dùng processor mới.
- Tạo lớp
BackgroundService
,JobProcessor
và interfaceIJobProcessor
.
BackgroundService
sẽ tạo một thể hiện mới củaIJobProcessor
và thực thi sau mỗi 2s.
csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;
public interface IJobProcessor
{
Task ProcessAsync(int jobId);
}
public class JobProcessor : IJobProcessor
{
private readonly Guid _guid = Guid.NewGuid(); // để nhìn thấy khác nhau
private readonly ILogger<JobProcessor> _logger;
public JobProcessor(ILogger<JobProcessor> logger)
{
_logger = logger;
}
public Task ProcessAsync(int jobId)
{
_logger.LogInformation("Processing Job {JobId} with processor {Guid}", jobId, _guid);
return Task.CompletedTask;
}
}
public class JobBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider; // inject IServiceProvider
private readonly ILogger<JobBackgroundService> _logger;
public JobBackgroundService(IServiceProvider serviceProvider, ILogger<JobBackgroundService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
int jobId = 1;
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Starting job {JobId}", jobId);
// tạo scope để resolve Transient service
using (var scope = _serviceProvider.CreateScope())
{
var processor = scope.ServiceProvider.GetRequiredService<IJobProcessor>();
await processor.ProcessAsync(jobId);
}
jobId++;
await Task.Delay(2000, stoppingToken);
}
}
}
- Đăng ký DI
csharp > Program.cs
// Register
builder.Services.AddTransient<IJobProcessor, JobProcessor>();
builder.Services.AddHostedService<JobBackgroundService>();
Trong ví dụ trên:
-
JobBackgroundService (Singleton)
→ tạo một lần khi app start, chạy suốt vòng đời app. -
JobProcessor (Transient)
→ tạo mới mỗi khiJobBackgroundService
xử lý một job.
3. Scoped
Scoped
là cách tiêm mà thể hiện (instance) của một service sẽ được khởi tạo qua thông quamỗi request (http request)
hoặctrong phạm vi của một scope mới
.
1 http request tương đương với một scope mới
- Cách tiêm này thường được sử dụng để:
làm việc với DbContext (Entity Framework Core), các service cần chia sẻ DbContext nhằm đảo bảo tạo một kết nối đến database trong mỗi request
. - ví dụ: tạo một chức năng thêm người dùng vào database
1 Tạo lớp
UserService
sử dụngDbContext
và có phương thứcAddUserAsync
csharp > UserService.cs
public class UserService
{
private readonly AppDbContext _dbContext;
// DbContext được inject, và DbContext cũng là Scoped
public UserService(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task AddUserAsync(string name)
{
_dbContext.Users.Add(new User { Name = name });
await _dbContext.SaveChangesAsync();
}
public async Task<List<User>> GetUsersAsync()
{
return await _dbContext.Users.ToListAsync();
}
}
- Đăng ký DI cho
DbContext
vàUserService
csharp > Program.cs
// DbContext mặc định được đăng ký Scoped
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
// UserService cũng đăng ký Scoped
builder.Services.AddScoped<IUserService, UserService>();
Trong ví dụ trên:
AppDbContext là Scoped
: mỗi request có một DbContext riêng.UserService cũng là Scoped
: nó dùng lại cùng DbContext trong request đó.
Áp dụng IoC để giải quyết vấn đề gặp phải
- Để giải quyết vấn trong ví dụ ở trên khi lớp OrderService bị phụ thuộc vào lớp EmailService khi có logic thay đổi.
Áp dụng DI chúng ta có thể thiết kế:
-
OrderService
: thường xử lý logic liên quan đến một request đặt hàng → hay cần DB, transaction. Vì vậyOrderService
nên đăng ký Scoped. -
EmailService
: nhiệm vụ chỉ là gửi email (SMTP, SendGrid API, v.v…). Thường stateless (không giữ dữ liệu tạm), chỉ có client API để gửi mail nên đăng ký Singleton. Có thể tái sử dụng trong suốt vòng đời ứng dụng.
- Thêm interface IEmailService cho EmailSerivce
csharp
public interface IEmailService
{
void Send(string to, string subject, string body);
}
public class EmailService : IEmailService
{
private readonly string _smtpServer;
public EmailService(string smtpServer)
{
_smtpServer = smtpServer;
}
public void Send(string to, string subject, string body)
{
Console.WriteLine($"[{_smtpServer}] Send email to {to} - {subject}: {body}");
}
}
- Chỉnh sửa code cho
OrderService
, tiêmIEmailService
thông qua contructor
csharp
public class OrderService
{
private readonly IEmailService _emailService;
public OrderService(IEmailService emailService)
{
_emailService = emailService;
}
public void PlaceOrder()
{
_emailService.Send("user001@gmail.com", "Order", "Your order has been placed.");
}
}
- Đăng ký DI cho OrderService và EmailService
csharp > Program.cs
builder.Services.AddScoped<OrderService>();
builder.Services.AddSingleton<IEmailService>(sp =>
new EmailService("smtp.gmail.com")); // ví dụ hard code cho mail server
- Sau khi đã thay đổi code áp dụng
IoC
, lớpOrderService
không còn bị phụ thuộc vào các thay đổi trong lớpEmailService
😎
Kết luận
- Qua bài viết, chúng ta có thể thấy được tầm quan trọng của kỹ thuật
IoC
trong lập trình hướng đối tượng. - Bên cạnh đó là ứng dụng thực tế thông qua nguyên lý
DIP
trongSOLID
giúp giải quyết vấn đềtight-coupling
giữa các service với nhau.
All rights reserved