Dependency Injection đơn giản với Typescript

Tôi thích làm việc với các framework khi xây dựng các ứng dụng, chúng thường được thiết kế để đáp ứng nguyên tắc Dependency Injection. Gần đây tôi làm việc nhiều với Typescript, trong bài viết này tôi sẽ tự xây dựng cấu trúc hỗ trợ việc triển khai Dependency Injection với Typescript, tôi nghĩ việc này sẽ làm tôi hiểu sâu hơn về những framework mà tôi đang làm việc.

Hiểu cơ bản về Dependency Injection (DI)

Nếu bạn từng nghe về DI hoặc muốn thực sự hiểu về DI thì đây là tài liệu các bạn nên xem qua. Kể từ phần này, tôi sẽ không nói nhiều về việc “Là gì” mà sẽ nói về việc “Như thế nào”. Tôi cố gắng giữ khái niệm về DI là đơn giản nhất có thể:

Dependency injection is a technique whereby one object supplies the dependencies of another object.

Quote from Wiki

Điều này nghĩa là gì? Thay vì khởi tạo các đối tượng một cách thủ công (với từ khóa new),  thì sẽ có một phần của chương trình (thường được gọi là injector) chịu trách nhiệm khởi tạo các đối tượng. 😐

Hãy tưởng tượng, chúng ta có một đoạn code trông như thế này:

class FooClass {
}

class BarClass {
  foo: FooClass;
  
  constructor() {
    this.foo = new FooClass();
  }
}

class Foobar {
  foo: FooClass;
  bar: BarClass;
  
  constructor() {
    this.foo = new FooClass();
    this.bar = new BarClass();
  }
}

Đoạn code quá tệ với nhiều lý do, có sự phụ thuộc trực tiếp giữa các class, quá khó để chuyển đổi khi có yêu cầu, việc testing sẽ rất khó, đọc và mở rộng code trở nên khó khăn, việc tái sử dụng lại các thành phần cũng khó hơn…

DI là việc “inject” các hàm phụ thuộc của một class qua hàm constructor của nó, từ đó làm các vấn đề trên trở thành vô nghĩa:

class FooClass {
}

class BarClass {
  constructor(foo: FooClass) {
  }
}

class FoobarClass {
  constructor(foo: FooClass, bar: BarClass) {
  }
}

Đã khá hơn nhiều.

Như vậy khi cần tạo một instance của FoobarClass, chúng ta chỉ cần một khai báo như sau:

const foobar = new FoobarClass(new FooClass(), new BarClass(new FooClass()));

Vẫn chưa thực sự ổn!

Nếu chúng ta có một thứ chịu trách nhiệm cho việc khởi tạo các object – Injector, thì chúng ta đơn giản chỉ cần gọi:

const foobar = Injector.resolve<FoobarClass>(FoobarClass); // trả lại một instance của FoobarClass, với tất cả các đối tượng phụ thuộc thông qua constructor

Tuyệt vời!

Có nhiều lý do để chúng ta sử dụng DI, bao gồm đảm bảo khả năng testing, dễ dàng bảo trì, dễ đọc, …Một lần nữa, nếu bạn vẫn chưa thực sự nắm rõ lý do nên sử dụng DI, thì sau đây chúng ta sẽ đi vào ví dụ cụ thể.

Dependency injection với TypeScript

Trong bài viết này, tôi sẽ giới thiệu cách triển khai chức năng của một Injector, mà tôi sẽ hoàn toàn làm chủ nó (hạn chế dùng thư viện ngoài) và tất nhiên nó sẽ rất đơn giản. Trong trường hợp bạn đang tìm kiếm một giải pháp có sẵn cho vấn đề này, thì bạn có thể xem qua InversifyJS, một thư viện tuyệt vời, nó triển khai IoC container cho Typescript, tôi đã dùng thư viện này trong rất nhiều dự án.

Nhiệm vụ của chúng ta là xây dựng một Class Injector, class này giúp chúng ta tạo tạo ra các instance của các class với tất cả “dependencies” cần thiết. Chúng ta sẽ xây dựng một class decorator , tôi sẽ gọi nó là @Service (nếu bạn đã từng làm việc với Angular, thì nó tương đương với decorator @Injectable), nó sẽ biến các những class bình thường thành các class có tính chất đặc biệt, nới mà Injector có thể làm việc để trả lại các instance của class cho chúng ta.

Trước khi triển khai, thì có một số thứ tôi muốn giới thiệu về Typescript và DI:

Reflection và decorators

Chúng ta sẽ sử dụng package reflect-metadata để lấy thông tin reflection của một class trong lúc runtime ( reflection capabilities). Với package này, chúng ta có thể lấy được thông tin một class được triển khai như thế nào, khởi tạo ra sao…Ví dụ:

import 'reflect-metadata';

const Service = () : ClassDecorator => {
  return target => {
    console.log(Reflect.getMetadata('design:paramtypes', target));
  };
};

class BarClass {}

@Service()
class MyService {
  constructor(bar: BarClass, baz: string) {}
}

(Tôi không nói tới việc setup project như thế nào, mặc định các bạn có thể khởi tạo được một dự án sử dụng Typescript)

Khi chạy đoạn code trên, tôi sẽ nhận được:

[ [Function: BarClass], [Function: String] ]

Như vậy chúng ta đã biết được những parameter nào cần được truyền vào khi khởi tạo đối tượng MyService. Nhưng trong trường hợp này, chúng ta sẽ thấy khá bối rối rằng tại sao BarClass lại là một Function, tôi sẽ giải thích vấn đề này ở phần sau.

Quan trọng: Có một điều cực kỳ quan trọng rằng, nếu không có decorator thì class cũng sẽ không có bất kỳ metadata nào cả. Đây có thể là một lựa chọn khi thiết kế reflect-metadata của tác giả package, tôi cũng không hiểu lắm, nếu muốn thì bạn có thể đọc lý do ở đây.

Kiểu của tham số target khi triển khai một class decorator

Một trong những điều làm tôi khá rối khi bắt đầu làm việc với decorator là kiểu của đối tượng target.

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;

Function có vẻ là khá kỳ quặc, vì nó rõ ràng là một object thay vì một function. Nhưng đó là cách ngôn ngữ Javascript hoạt động, những class thực ra là những function đặc biệt. Hãy xem qua một chút:

Đoạn code

class Cat {
    constructor() {
        // the constructor
    }
    talk() {
        // a method
    }
}

sau khi biên dịch, sẽ được hiểu dưới dạng:

var Cat = /** @class */ (function () {
    function Cat() {
        // the constructor
    }
    Cat.prototype.talk = function () {
        // a method
    };
    return Cat;
}());

Tới đây thì chúng ta đã hiểu và sẽ làm việc với điều hiển nhiên target là một Function. Nhưng dùng kiểu Function thì quá chung chung, chúng ta không biết đang làm việc với đối tượng nào, chúng ta sẽ không định nghĩa Injector chỉ dùng cho một trường hợp cụ thể, chúng ta muốn nó có thể làm việc với mọi class. Và khi chúng ta muốn lấy instance của một class nào đó, chúng ta có thể lấy được instance với kiểu mà chúng ta mong muốn.

Chúng ta sẽ định nghĩa lại kiểu của target:

interface Type<T> {
  new(...args: any[]): T;
}

Type<T> có thể nói cho chúng ta biết instance target là đối tượng gì, hay nói cách khác: Chúng ta sẽ nhận được gì sau từ khóa new (T). Trở lại với @Service ở trên:

const Service = () : ClassDecorator => {
  return target => {
    // `target` trong trường hợp này là  `Type<Foo>`, không phải `Foo`
  };
};

Còn một vấn đề, ClassDecorator hiện tại trông như thế này:

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;

Nhưng giờ chúng ta đã biết được loại đối tượng là loại gì, để linh hoạt hơn và định nghĩa được một class decorator có sử dụng “generic”:

export type MyClassDecorator<T> = (target: T) => void;

Rắc rối từ việc phụ thuộc “lẫn nhau”

Tôi có một ví dụ như sau:

@Service()
class Cat {
  constructor(dog: Dog) {}
}

@Service()
class Dog {
  constructor(cat: Cat) {}
}

Chúng ta sẽ gặp lỗi ReferenceError, với thông báo:

ReferenceError: Dog is not defined

Lý do cho điều này khá rõ ràng: Dog không tồn tại tại thời điểm mà Typescript đang cố gắng lấy thông tin của class Cat. Tôi không muốn đi sâu vào vấn đề này ở đây, nếu bạn muốn hãy đọc qua tài liệu của Angular – forwardRef.

Xây dựng một Injector

Lý thuyết khá đủ rồi, giờ chúng ta sẽ xây dựng một Injector với chức năng đơn giản nhất.

Tôi sẽ sử dụng tất các lý thuyết ở trên, bắt đầu với decorator @Service.

@Service decorator

Chúng ta sẽ phải cài đặt decorator này ở tất cả các class, nếu không chúng ta sẽ không có thông tin metadata của class, từ đó không thể khởi tạo được đối tượng.

// src/core/decorators.ts
import { MyClassDecorator, Type } from "./types";

export const Service = (): MyClassDecorator<Type<object>> => {
  return (target: Type<object>) => {
    // làm gì đó với target, ví dụ: kiểm tra dữ liệu hay đẩy target vào Injector để lưu trữ
  };
};

Injector

Injector là class lưu trữ và trả lại instance của các class khi có yêu cầu. Nó có thể có khả năng lưu lại những instance đã được khởi tạo (caching), nhưng vì mục đích đơn giản, nên tôi cũng chỉ làm nó đơn giản hết mức có thể:

// src/core/injector.ts
export const Injector = new class {
  // Triển khai class Injector
};

Lý do tôi export Injector là một const thay vì một class (kiểu như export class Injector {...} ) vì Injector của chúng ta nên là một singleton. Nói các khác, chúng ta sẽ không bao giờ có 2 instance của Injector trong chương trình. Có nghĩa là mọi nơi trong chương trình, khi bạn gọi import Injector bạn chỉ có được một instance của nó.

Tiếp theo chúng ta cần triển khai nội dung của Injector: Một phương thức lấy được instance của một class với toàn bộ những class mà nó phụ thuộc:

import 'reflect-metadata';
import { Type } from "./types";

export const Injector = new class {
  // Lấy instance của một class
  getInstance<T>(target: Type<any>): T {
    // tokens are required dependencies, while injections are resolved tokens from the Injector
    // Lấy thông tin những đối tượng cần để tạo ra instance của target
    const dependencies: any[] = Reflect.getMetadata('design:paramtypes', target) || [];
    // Tạo ra các instance của những đối tượng cần thiết (new Foo(), new Bar() )
    const injections = dependencies.map(dependency => Injector.getInstance<any>(dependency));

    // Trả lại instance của target
    return new target(...injections);
  }
};

Đã xong. Đối tượng Injector của chúng ta đã sẵn sàng để xử lý các yêu cầu lấy instance. Hãy xem xét một ví dụ mà chúng ta sẽ sử dụng Injector:

// src/app.ts

import 'reflect-metadata';
import { Service } from "./decorators";
import { Injector } from "./injector";

@Service()
class Cat {
  talk() {
    console.log('Meooooo!');
  }
}

@Service()
class CatLover {
  public myCat: Cat;

  constructor(cat: Cat) {
    this.myCat = cat;
  }

  showName() {
    console.log('CatLover!');
  }

  showMyCat() {
    this.myCat.talk();
  }
}

@Service()
class App {
  public freeCat: Cat;
  public catLover: CatLover;

  constructor(cat: Cat, catLover: CatLover) {
    this.freeCat = cat;
    this.catLover = catLover;
  }
}

const appInstance = Injector.getInstance<App>(App);

// Sử dụng appInstance với typehint của IDE
appInstance.freeCat.talk();
appInstance.catLover.showName();
appInstance.catLover.showMyCat();
appInstance.catLover.myCat.talk();

Kết quả output:

➜ ts-node ./src/app.ts
Meooooo!
CatLover!
Meooooo!
Meooooo!

Điều đó có nghĩa Injector của chúng ta đã hoạt động! Yahoo!

Tổng kết

Bài viết chủ yếu nói về việc DI làm việc như thế nào (đừng nhầm lẫn DI với DI “tool”, Injector là một DI tool) và đưa cho bạn một cái nhìn thoáng qua về việc tự xây dựng một Injector để thực hiện DI một cách dễ dàng.

Như bạn thấy thì còn rất nhiều thứ còn phải làm, ví dụ như:

  • Quản lý lỗi phát sinh
  • Các đối tượng phụ thuộc chồng chéo nhau
  • Cache lại những instance đã xử lý
  • “Inject” nhiều hơn thay vì chỉ thông qua constructor

Nhưng cơ bản là các bạn nắm được cách Injector làm việc.

Nếu có bất kỳ điều gì với bài viết, các bạn có thể comment ngay ở dưới.