+1

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ật IoC (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:

  1. lớp EmailService sẽ có một phương thức gửi mail là hàm Send.
  2. lớp OrderService sẽ khởi tạo trực tiếp lớp EmailService và thực hiện logic gửi mail trong hàm PlaceOrder.
  • 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-in DI 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
  1. Cài đặt thư viện redis:
csharp

dotnet add package StackExchange.Redis
  1. 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();
    }
}
  1. Đă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ọi IJobProcessor - Transient cho mỗi job → đảm bảo job nào cũng dùng processor mới.
  1. Tạo lớp BackgroundService, JobProcessor và interface IJobProcessor.
  • BackgroundService sẽ tạo một thể hiện mới của IJobProcessor 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);
        }
    }
}
  1. Đă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 khi JobBackgroundService 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 qua mỗi request (http request) hoặc trong 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ụng DbContext và có phương thức AddUserAsync
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();
    }
}
  1. Đăng ký DI cho DbContextUserService
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ậy OrderService 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.

  1. 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}");
    }
}

  1. Chỉnh sửa code cho OrderService, tiêm IEmailService 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.");
    }
}
  1. Đă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ớp OrderService không còn bị phụ thuộc vào các thay đổi trong lớp EmailService 😎

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 trong SOLID giúp giải quyết vấn đề tight-coupling giữa các service với nhau.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí