Lập trình C# (C Sharp)

Kỹ thuật lập trình Dependency injection

Dependency injection (DI) là một kỹ thuật trong lập trình trong đó một đối tượng cung cấp những phụ thuộc (dependency - là đối tượng, dịch vụ, chức năng) của đối tượng khác. Injection - Bơm vào (tiêm vào) ám chỉ một phụ thuộc (đối tượng, dịch vụ) đưa vào đối tượng để đối tượng sử dụng nó.

Nhớ điều cốt lõi ở đây là một đối tượng được cung cấp các dịch vụ, chức năng (dependency) cứ thể mà sử dụng, chứ bản thân đối tượng không tự xây dựng các dịch vụ, chức năng đó, điều này làm cho nó độc lập với các đối tượng khác

Nói chung, các khái niệm về DI rất trừu tượng và khó hiểu! Ta mô tả chi tiết hơn một chút

Lớp Car có chức nằng (phương thức) Beep() - để phát ra tiếng còi xe, mà để phát ra tiếng còi - nó lại phù thuộc vào lớp Horn chuyên tạo ra tiếng còi - lúc đó ta nói lớp Car có một sự phụ thuộc (dependency - vào lớp Horn). Vậy muốn có đối tượng lớp Car phải cung cấp (tiêm - Injection - có mấy kiểu tiêm sẽ tìm hiểu ngay sau đây) cho nó một đối tượng (depenency) lớp Horn. Đó là kỹ thuật DI. Vẫn trừu tượng, hãy thực hiện cụ thể xây dựng và sử dụng Car, Horn trường hợp không dùng kỹ thuật DI và có dùng DI

Không áp dụng kỹ thuật DI

public class Horn {
    public void Beep() => Console.WriteLine("Beep - beep - beep ...");
}

public class Car {
    public void Beep()
    {
        Horn horn = new Horn();     // chức năng Beep xây dựng có định với Horn, tự tạo đối tượng horn
        horn.Beep();
    }
}

Code viết như trên khá thông dụng và vẫn chạy tốt. Bấm còi xe vẫn kêu Beep, beep ...

var car = new Car();
car.Beep();         // Beep - beep - beep ...

Nhưng code trên có một vấn đề là tính linh hoạt khi sử dụng. Chức năng Beep() của Car nó tự tạo ra đối tượng Horn và sử dụng nó - làm cho Car gắn cứng vào Horn.

Nếu lớp Horn sửa lại, ví dụ muốn khởi tạo Horn phải chỉ ra một tham số nào đó, ví dụ như độ lớn tiêng còi level

Source code (1)
public class Horn {
    int level;
    public Horn(int level) => this.level = level; // thêm khởi tạo level
    public void Beep() => Console.WriteLine($"(level {level}) Beep - beep - beep ...");
}

Việc thay đổi Horn làm cho Car không còn dùng được nữa, nếu muốn Car hoạt động cần sửa lại code của Car, ví dụ tại Beep sửa thành

Horn horn = new Horn(10);     // Khởi tạo với Horn với tham số level
horn.Beep();

Như vậy không sử dụng DI, việc thay đổi code của lớp này, dịch vụ này kéo theo phải cập nhật sửa đổi các đối tượng sử dụng nó. Điều này rất phức tạp nếu dự án code lớn lên. Hãy khắc phục nó bằng kỹ thuật DI

Source code (2)

Áp dụng kỹ thuật DI

Xây dựng lại ví dụ trên có sử dụng kỹ thuật DI xem sao:

public class Horn {
    public void Beep() => Console.WriteLine("Beep - beep - beep ...");
}

public class Car
{
    Horn horn;                                      // horn là một Dependecy của Car
    public Car(Horn horn) => this.horn = horn;      // horn được Inject (bơm vào) bằng hàm khởi tạo

    public void Beep()
    {
        horn.Beep();
    }
}

Bằng cách khai bảo Horn là một biến thành viên trong Car, Car đã có một dependency là đối tượng lớp Horn, dependency này không phải do Car tạo ra, nó được bơm vào (cung cấp) thông qua phương thức khởi tạo của nó. Cụ thể khi sử dụng

Horn horn = new Horn();

var car = new Car(horn);
car.Beep();                                         // Beep - beep - beep ...
Source code(3)

Kết quả gọi car.Beep(); có vẻ kết quả vẫn như trên, nhưng code mới này có một số lợi ích. Ví dụ, nếu sửa cập nhật lại Horn bằng cách sửa phương thức khởi tạo của nó, thì lớp Car không phải sửa gì!


public class Horn {
    int level;
    public Horn(int level)  => this.level = level; // thêm khởi tạo level
    public void Beep()      => Console.WriteLine($"(level {level}) Beep - beep - beep ...");
}

Horn horn = new Horn(20);

var car = new Car(horn);
car.Beep();                                        // (level 20) Beep - beep - beep ...

Như vậy, kỹ thuật DI giúp các đối tượng độc lập nhau một cách tối đa (lớp Car độc lập lớp Horn)

Source code(4)

Các kiểu Dependency injection

Cơ bản thì có 3 kiểu Inject

  • Inject thông qua phương thức khởi tạo: cung cấp các Dependency cho đối tượng thông qua hàm khởi tạo ( như đã thực hiện ở ví dụ trên)
  • Inject thông qua setter: tức các Dependency như là thuộc tính của lớp, sau đó inject bằng gán thuộc tính cho Depedency object.denpendency = obj;
  • Inject thông qua các Interface - áp dụng Interface cho các Dependency - nó có thể dùng setter, hàm tạo ...

Trong đó sử dụng Interface rất phổ biến vì tính linh hoạt, mềm dẻo, ví dụ có thể xây dựng giao diện IHorn áp dụng lớp Car ở trên.

public class Car
{
    IHorn horn;                                  // horn là một Dependecy của Car, triển khai từ Interface IHorn
    public Car(IHorn horn) => this.horn = horn;  // Inject từ hàm  tạo
    public void Beep() => horn.Beep();
}

Với cách triển hai DI với Interface như vậy, sử dụng Car rất linh hoạt và độc lập với nhiều loại đối tượng triển khai IHorn, ví dụ thử tạo ra hai loại còi

public class HeavyHorn : IHorn
{
    public void Beep() => Console.WriteLine("(kêu to lắm) BEEP BEEP BEEP ...");
}

public class LightHorn : IHorn
{
    public void Beep() => Console.WriteLine("(kêu bé lắm) beep  bep  bep ...");
}

Lúc này khi sử dụng, Car của bạn có dùng loại còi nào thì dùng - logic code giống nhau

Car car1 = new Car(new HeavyHorn());
car1.Beep();                             // (kểu to lắm) BEEP BEEP BEEP ...

Car car2 = new Car(new LightHorn());
car2.Beep();                             // (kểu bé lắm) beep  bep  bep ...
Source code(5)

Inject bằng phương thức khởi tạo nên tập trung vào đó, vì các thư viện DI hỗ trợ tốt


DI Container

Mục đích sử dụng DI, để tạo ra các đối tượng - biết được những lớp cần thiết để tạo đối tượng. Để quản lý, sử dụng các đối tượng trong ứng dụng .NET, có rất nhiều thư viện DI - Container như: Windsor, Unity Ninject, DependencyInjection ...

Trong đó DependencyInjection là DI Container mặc định của ASP.NET Core, phần này tìm hiểu về DI Container này.

Trước tiên phải đảm bảo tích hợp Package Microsoft.Extensions.DependencyInjection vào dự án

dotnet add package Microsoft.Extensions.DependencyInjection

Sau đó sử dụng namespace

using  Microsoft.Extensions.DependencyInjection;

ServiceCollection là lớp triển khai giao diện IServiceCollection nó có chức năng quản lý các dịch vụ (các đối tượng). Mỗi dịch vụ (đối tượng) quản lý trong ServiceCollection có thể thuộc loại ServiceLifetime (enum) nào đó nó sẽ quyết định dịch vụ được tạo ra như thế nào, cụ thể:

Scoped 1 Một bản thực thi (instance) của dịch vụ (Class) được tạo ra cho mỗi phạm vị
Singleton 0 Duy nhất một phiên bản thực thi (instance of class) được tạo
Transient 2 Một phiên bản của dịch vụ được tạo mỗi khi được yêu cầu

Một số phương thức của ServiceCollection

Phương thức Diễn giả
AddSingleton<Type, Type>() Thêm một dịch vụ kiểu Singleton: Type thứ nhất kiểu của dịch vụ, Type thứ 2 kiểu khi dịch vụ triển khai. Nếu 2 kiểu này giống nhau, dùng AddSingleton<Type>():
services.AddSingleton<IHorn,  HeavyHorn>();
Một dịch vụ kiểu IHorn được thêm vào, mà khi dịch vụ được yêu cầu nó tạo ra đối tượng kiểu HeavyHorn, do là Singleton chỉ một đối tượng của dịch vụ được tạo, nếu đã có yêu cầu sau trả về đối tượng lần trước tạo (gọi ra thế nào ở phần sau).
AddTransient<Type, Type>() Giống với AddSingleton<Type, Type>(), nhưng dịch vụ thuộc loại Transient, luôn tạo mới mỗi khi có yêu cầu lấy dịch vụ.
AddScoped<Type, Type>() Giống với AddSingleton<Type, Type>(), nhưng dịch vụ thuộc loại Scoped, phạm vi khác nhau tạo đối tượng khác nhau.
BuildServiceProvider() Tạo ra đối tượng lớp ServiceProvider, đối tượng này dùng để triệu gọi, tạo các dích vụ thiết lập ở trên.

Các phương thức AddSingleton, AddTransient, AddScoped còn có bản quá tải mà tham số là một callback delegate tạo đối tượng. Nó là cách triển khai pattern factory, ví dụ:

Một số phương thức của ServiceProvider

ServiceProvider được tạo ra bởi BuildServiceProvider() của ServiceCollection, nó có các phương thức

Phương thức Diễn giả
GetService<Type>() Lấy dịch vụ có kiểu Type - ví dụ
services.GetService<IHorn>()   // lấy đối tượng triển khai IHorn
GetRequiredService(Type) Lấy dịch vụ có kiểu Type - ví dụ
services.GetRequiredService(Car)   // lấy đối tượng kiểu Car
CreateScope() Tạo một phạm vi mới, thường dùng khi sử dụng những dịch vụ có sự ảnh hưởng theo Scoped

Để cụ thể hơn sử dụng DI, ta sẽ thực hành cho vài trường hợp

Sử dụng ServiceCollection cơ bản

abstract class  ABase {
    public void ShowInfo() => Console.WriteLine($"{this.GetType().Name}-{this.GetHashCode()}");
    public void NotifyCreate() => Console.WriteLine($"{this.GetType().Name} created");
}

class A:ABase {
    public A() => NotifyCreate();
}
class B :ABase {
    A dependency;
    public B(A dependency) {
         this.dependency = dependency;
         NotifyCreate();
    }
}

class C:  ABase {
    public C() => NotifyCreate();
} 

static void Main(string[] args)
{
        // Tạo và cấu hình ServiceCollection
        var services = new ServiceCollection(); 
        services.AddSingleton<A>();                             // Đăng ký dịch vụ A,  kiểu singleton
        services.AddTransient<B>();                             // Đăng ký dịch vụ B, kiểu transient
        services.AddScoped<C>();                                // Đăng ký dịch vụ C, kiểu scoped

        var serviceprovider = services.BuildServiceProvider();  // Tạo serviceprovider

        // SỬ DỤNG

        // Yêu cầu dịch vụ B, DI tự động tạo A và Inject vào B khi B khởi tạo
        B b = serviceprovider.GetService<B>();                  

        // Yêu cầu lại dịch vụ B: B đăng ký là transient, nên đối tượng thực sự tạo lại
        // Dịch vụ A do là singleton, nên nó không tạo lại, mà đã tạo rồi, nó sẽ Inject vào dịch vụ B mới
        b = serviceprovider.GetService<B>();                  

        // Yêu  cầu  A, A đã tạo nên nó trả về đối dịch vụ mà đã Inject vào B
        A a = serviceprovider.GetService<A>();               

        // Yêu cầu dịch vụ C, C là loại scoped
        C c = serviceprovider.GetService<C>();  
        
        // Yêu cầu C, C không tạo mới vì scoped không đổi
        c = serviceprovider.GetService<C>();            

        // Tạo scope mới
        Console.WriteLine("-----------New scope---------");
        using (var scope = serviceprovider.CreateScope())  {
            // Yêu  cầu  A, A đã tạo nên nó trả về đối dịch vụ mà đã Inject vào B, cho dù là scope mới
            a = scope.ServiceProvider.GetService<A>(); 
            // Yêu cầu C, C tạo mới vì scope mới (C kiểu scoped)
            c = scope.ServiceProvider.GetService<C>();    
        } 
}

Các lớp A, B, C ở trên đều kế thừa ABase, cung cấp phương thức cho biết khi đối tượng được tạo. Bạn thấy ServiceCollection cung cấp khả năng quản lý các đối tượng, tự động Inject đối tượng. Khi yêu cầu dịch vụ nào đó từ ServiceProvider, tùy vào cách thức đăng ký dịch vụ đó trong ServiceCollection mà đối tượng cho dịch vụ sẽ được tạo mới hay sử dụng đối tượng đã tạo trả về, nếu tạo mới nó cũng tự động Inject các dependency phù hợp. Kết quả chạy code là:

A created
B created
B created
C created
C created
Source code (7)

Sử dụng Factory đăng ký dịch vụ

Kỹ thuật factory để tạo ra đối tượng, đó là cung cấp phương thức chuyên dùng để tạo ra đối tượng. Phương thức đó có thể là một delegate.

Hàm factory có thể dùng để đăng ký đối tượng là hàm có 1 tham số kiểu ServiceProvide, và trả về đối tượng cần tạo. Ví dụ đây là một Factory thích hợp để đăng ký

MyClass myfactory(IServiceProvider serviceprovide)
{
    return obj;
}

Dùng factory để đăng ký dịch vụ, khi khởi tạo dịch vụ nào đó cần thực hiện một số tác vụ, hoặc không thể Inject một cách tự động, hoặc khởi tạo cần những tham số mà DI không giải quyết được

Ví dụ

class D: ABase {
    A dependency;
    int x;
    public D(A dependency, int x) {
         this.dependency = dependency;
         this.x = x;
         NotifyCreate();
    }
}

Dịch vụ D khởi tạo cần đối tượng A và một số nguyên. Vậy nếu đăng ký service dạng thông thường:

services.AddSingleton<D>();

Thì khi yêu cầu dịch vụ sẽ lỗi, vì dịch vụ không khởi tạo được do thiếu tham số - số nguyên.

Lúc này, có thể tạo ra một hàm Factory, ví dụ:

static D CreateDService(IServiceProvider serviceProvider) {
    return new D(serviceProvider.GetService<A>(), 123);
}

Hàm CreateDService là factory đúng cấu trúc trên - trả về đối tượng lớp D, nên có thể dùng để đăng ký dịch vụ D

services.AddSingleton<D>(CreateDService);

Lúc này có thể yêu cầu dịch vụ D mà không lỗi gì.

var d = serviceprovider.GetService<D>();

Cũng có thể dùng kỹ thuật biểu thức lambda để tạo luôn factory

services.AddSingleton<D>((ServiceProvider) =>  new D(ServiceProvider.GetService<A>(), 123));

Sử dụng Options khởi tạo dịch vụ trong DI

Khi DI khởi tạo các dịch vụ, chúng ta có thể thiết lập nó Inject các thiết lập Options vào dịch vụ, để hỗ trợ tính năng này cần thêm package Microsoft.Extensions.Options

dotnet add package Microsoft.Extensions.Options

Sử dụng namepace:

using Microsoft.Extensions.Options;

Trong thư viện này có giao diện IOptions, dùng để đưa các lớp thiết lập vào DI Container, các thiết lập này có thể được các dịch vụ dùng để khởi tạo. Giả sử có một lớp chứa thông tin thiết lập MyServiceOptions, nó dùng làm thao số khởi tạo cho lớp dịch vụ MyService:

public class MyServiceOptions  {
    public string data1 {set;  get;}
    public int data2 {set;  get;}
}

Do lớp thiết lập này sẽ đưa vào DI Container, nên khi triển khai nó sẽ sử dụng qua giao diện IOptions, nên để lớp triên khai tìm được thiết lập thì tham số khởi tạo truy cập đến lớp thiết lập này khai báo là IOptions<MyServiceOptions>


public class  MyService  {
    readonly string data1;
    readonly int data2;
    public MyService(IOptions<MyServiceOptions> options) // IOption làm tham số khởi tạo
    {
        data1 = options.Value.data1;                     // Truy cập đến đối tượng lớp MyServiceOption qua  Value
        data2 = options.Value.data2;
        Console.WriteLine("MyService created");
    }
    public void ShowData() => Console.WriteLine($"{data1} - {data2}");
}

Giờ đăng ký lớp MyServiceOptions là một dịch vụ Config trong DI Container, đối tượng dịch vụ nào mà khởi tạo có tham số kiểu IOption<MyServiceOptions> thì nó sẽ tự động lấy đối tượng này trong DI Container. Cách đăng ký là dùng phương thức Configure của ServiceCollection với tham số là một delegate:


services.Configure<T>(
    (T options)
    {
        // T là tên lớp chứa các thiết lập
        // Hãy thiết lập các giá trị cho options
    }
);

Cụ thể đăng ký MyServiceOptionsMyService vào ServiceCollection:


static void Main(string[] args)
{

    var services = new ServiceCollection();
    services.AddOptions();
    // Đăng ký Options
    services.Configure<MyServiceOptions>(
        (MyServiceOptions  options) => {
            options.data1 = "DATA1";
            options.data2 = 2;
        }
    );
    // Đăng ký dịch vụ
    services.AddTransient<MyService>();

    var serviceprovider = services.BuildServiceProvider();   // Tạo serviceprovider

    var myservice = serviceprovider.GetService<MyService>(); // yêu cầu dịch vụ MyService
    myservice.ShowData();

    // Kết quả chạy
    // MyService created
    // DATA1 - 2
}

Như vậy DI Container, đã giúp tạo Config (đối tượng MyServiceOptions) và Inject nó cho dịch vụ khi tạo (dịch vụ MyService)


Lưu ý 1: nếu muốn lấy đối tượng lớp MyServiceOptions trong DI Container, thì:

var config = serviceprovider.GetService<IOptions<MyServiceOptions>>()
MyServiceOptions myServiceOptions = config.Value;

Lưu ý 2: nếu muốn tạo trực tiếp đối tượng IOptions<MyServiceOptions>, dành cho trường hợp muốn tạo MyService trực tiếp không thông qua DI Container. Thì dùng phương thức Factory Options.Create(obj), ví dụ:

var opts = Options.Create(new MyServiceOptions() {
    data1 = "DATA-DATA-DATA-DATA-DATA",
    data2 = 12345
});
MyService myService = new MyService(opts);
myService.ShowData();
Source code (8)

Sử dụng cấu hình từ File cho DI Container

Ở ví dụ trên, các giá trị dữ liệu trong MyServiceOptions (như data1, data2) có thể lưu ở file sau đó nạp vào khi chương trình thực thi. Các file cấu hình này hỗ trợ nhiều định dạng như XML, INI, JSON ... (cần cài đặt gói tương ứng)

Trước tiên thêm package Microsoft.Extensions.Configuration và Microsoft.Extensions.Options.ConfigurationExtensions

dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Options.ConfigurationExtensions
        

Sau đó, muốn dùng định dạng nào thì thêm Package tương ứng, ví dụ dùng JSON:

dotnet add package Microsoft.Extensions.Configuration.Json

Sử dụng namespace

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;

Lớp ConfigurationBuilder, giúp nạp các cấu hình lưu trong file config, từ đó build ra đối tượng ConfigurationRoot, đối tượng này truy cập đến các cấu hình bằng chỉ toán tử chỉ số [key]

Giá sử cấu hình lưu tại file appsettings.json, thì nạp cấu hình đó để có được ConfigurationRoot

var configBuilder = new ConfigurationBuilder()
                       .SetBasePath(Directory.GetCurrentDirectory())      // file config ở thư mục hiện tại
                       .AddJsonFile("appsettings.json");                  // nạp config định dạng JSON
var configurationroot = configBuilder.Build();                            // Tạo configurationroot

Khi có đối tượng ConfigurationRoot, lấy một Section nào đó bằng phương thức GetSection(key), nó trả về đối tượng biểu diễn nút cấu hình (JSON), giá trị của nút truy cập bằng thuộc tính Value

Ví dụ, tạo file appsettings.json với nội dung

{
    "MyServiceOptions" : {
        "data1" : "ABCDE",
        "data2" : 123456
    },

    "Option2" : {
        "key1" : "Test",
        "Key2" : 789
    }
}

Truy cập config

var cf1 = configurationroot.GetSection("Option2").GetSection("key1").Value; // Test
var cf2 = configurationroot.GetSection("Option2").GetSection("key2").Value; // 789
var cf3 = configurationroot.GetSection("Option2").GetSection("key3").Value; // null, không tồn tại

Trong file JSON key ở gốc với tên MyServiceOptions cho biết section thiết lập cho đối lớp MyServiceOptions, và các key con trong nó tương ứng với thuộc tính của của lớp. Giờ sẽ thiết lập configurationroot đưa vào ServiceCollection

Trở lại ví dụ trên

static void Main(string[] args)
{
    // Đọc file config ứng dụng
    var configBuilder = new ConfigurationBuilder()
                           .SetBasePath(Directory.GetCurrentDirectory())
                           .AddJsonFile("appsettings.json");
    var configurationroot = configBuilder.Build();


    var services = new ServiceCollection();
    services.AddOptions();
    // Đưa Option của MyServiceOptions vào, lưu ý phải cài package: Microsoft.Extensions.Options.ConfigurationExtensions
    services.Configure<MyServiceOptions>(configurationroot.GetSection("MyServiceOptions"));
    // Đăng ký dịch vụ
    services.AddTransient<MyService>();

    var serviceprovider = services.BuildServiceProvider();   // Tạo serviceprovider

    var myservice = serviceprovider.GetService<MyService>(); // yêu cầu dịch vụ MyService
    myservice.ShowData(); 

    // Kết quả chạy: (config đã nạp và inject vào service)
    //  MyService created
    //  ABCDE - 123456
 }
Source code (9)
Đăng ký theo dõi ủng hộ kênh