Service và Dependency Injection trong Angular

Service và Dependency Injection trong Angular

1. Service trong Angular

  • Service bao gồm bất kỳ giá trị, chức năng hoặc tính năng nào mà ứng dụng cần.
  • Angular phân tách các component với các service để tăng tính module và khả năng tái sử dụng. Bằng cách tách chức năng liên quan đến view của một component khỏi các loại xử lý khác, bạn có thể làm cho các lớp component của mình gọn gàng và hiệu quả.
  • Lý tưởng nhất, công việc của một component chỉ nên là kích hoạt trải nghiệm người dùng. Một component phải trình bày các thuộc tính và phương thức (biến và function) để liên kết dữ liệu (binding data), để làm trung gian giữa view (được hiển thị bởi template - file .html) và logic ứng dụng (thường bao gồm một số khái niệm về model - file .ts).
  • Một component có thể ủy quyền các tác vụ nhất định cho các service, chẳng hạn như tìm nạp dữ liệu từ máy chủ, xác thực đầu vào của người dùng hoặc ghi trực tiếp vào console.
  • Sau khi định nghĩa các tác vụ nhất định trong một lớp service, bạn có thể cung cấp các service này cho các component thông qua việc dependency injection.

2. Dependency là gì?

Dependency là một đối tượng được inject vào component (object có thế là service, function, ... nào đó).

class AuthApiService {
  messagestring = 'internal';
  constructor() { }
  login() {
    this.message = 'Login success!';
  }
}
class LoginComponent {
  private authServiceAuthApiService;
  constructor() {}
}

Ở ví dụ trên ta thấy: Trong lớp LoginComponent có sự tồn tại của lớp AuthApiServiceLoginComponent dùng AuthApiService để thực hiện chức năng login. Do đó, ta nói lớp LoginComponent đang phụ thuộc vào lớp AuthApiService.Vậy nếu muốn sử dung property và method của class AuthApiService thì chúng ta phải khởi tạo ở đâu, vì nếu không khởi tạo thì chúng ta sẽ không thể sử dụng được property message và method login()? 👉 Chúng ta sẽ có 2 cách để khởi tạo:

    Cách 1: Khởi tạo đối tượng (instance) của class AuthApiService trong hàm constructor của class LoginComponent:

class LoginComponent {
  private authServiceAuthApiService;
  constructor() {
    this.authService = new AuthApiService();
  }
}

Với cách sẽ phát sinh vấn đề khi chúng ta muốn thay instance AuthApiService bằng một instance của một class khác - có cùng các phương thức và method đã sử dụng nhưng có thể các về logic xử lý - (và tệ hơn nữa nếu các instance của AuthApiService đang được sử dụng ở nhiều component khác thuộc nhiều module khác nhau nhưng chúng ta chỉ muốn thay đổi instance của class AuthApiService ở các component thuộc một module trong các module đó). Lúc nào bắt buộc chúng ta phải vào từng component và khởi tạo lại instance (tệ hơn là có thể phải viết lại component đó). Ngoài ra, việc test cũng trở nên khó khăn vì chúng ta khó thay đổi instance đó cho việc mock dữ liệu test.

    Cách 2: Tiêm (Inject) đối tượng (instance) của AuthApiService đã được khởi tạo sẵn từ bên ngoài vào lớp LoginComponent như một parameter của hàm constructor (constructor injection) thông qua Dependency Injection (DI) system của Angular.

3. Dependency Injection trong Angular

Dependency Injection (DI) được tích hợp sẵn trong framework Angular và được sử dụng ở mọi nơi để cung cấp cho các component mới với instance của các service hoặc những thứ khác mà chúng cần. Chúng ta có thể inject một service vào trong một component, cấp cho component đó quyền truy cập vào lớp service đó.
Decorator @Injectable() được dùng để xác định một class là một service của Angular. Decorator này cung cấp siêu dữ liệu cho phép Angular đưa nó vào một component như một phần phụ thuộc, chỉ ra rằng Angular có thể sử dụng class này trong hệ thống DI.
Trong Angular, DI bao gồm các thành phần chính sau:
  1. Injector là một object có chứa các API để chúng ta có thể lấy về các instances đã tạo hoặc tạo các instances của các phụ thuộc. Angular tạo một injector có phạm vi sử dụng trong toàn ứng dụng cho bạn trong quá trình khởi động và bổ sung các injector khi cần thiết. Bạn không cần phải tạo injector.
    • Injector tạo ra các phụ thuộc và duy trì một vùng chứa các instance phụ thuộc mà nó sẽ sử dụng lại nếu có thể.
    • Injector có trách nhiệm tạo đối tượng cung cấp service và inject chúng vào Consumer(Component, service...)
  2. DI Token là đinh danh duy nhất cho một Dependency . Chúng ta sử dụng DI Token khi chúng ta đăng ký dependency.
  3. Provider là một đối tượng quản lý danh sách các dependencies và token của nó, cung cấp cho injector biết cách để tạo ra một instance của dependency.
  4. Dependency là một object (có thể là function, một value thông thường hoặc một class) của một kiểu dữ liệu cần phải khởi tạo - được truyền vào như một tham số của hàm constructor trong component.
Đối với bất kỳ phần phụ thuộc nào bạn cần trong ứng dụng của mình, bạn phải đăng ký một provider với injector của ứng dụng để injector có thể sử dụng provider đó để tạo các instance mới. Đối với một service, provider thường là chính lớp service đó.
Với Dependency Injection, chúng ta có thể dễ dàng test, thay đổi linh động các phụ thuộc, dễ bảo trì code.
Một phần phụ thuộc không nhất thiết phải là một service.  Ví dụ: nó có thể là một hàm hoặc một giá trị.
Khi Angular tạo một instance mới của một lớp component, nó sẽ xác định các service hoặc các phụ thuộc khác mà component cần bằng cách xem xét các tham số của phương thức khởi tạo (constructor). Ví dụ, phương thức constructor của component có service AuthApiService.
constructor(private authApiService: AuthApiService) {}
  1. Khi Angular phát hiện ra rằng một component phụ thuộc vào một service, trước tiên nó sẽ kiểm tra xem injector có bất kỳ instance nào hiện có của service đó (AuthApiService) hay không.
  2. Nếu một instance của service được yêu cầu chưa tồn tại, injector tạo một instance bằng cách sử dụng provider đã đăng ký và thêm nó vào injector trước khi trả lại instance của service cho Angular.
  3. Khi tất cả các service được yêu cầu đã được khởi tạo và trả về, Angular có thể gọi hàm khởi tạo của component với instance của các service đó làm đối số.
  4. Quá trình inject service sẽ giống như thế này:
Lưu ý: Class Decorator sẽ được call sau khi mà bạn đã tạo xong class.

Decorator @Injectable

@Injectable được dùng để chỉ ra rằng class (có khai báo @Injectable) cần tiêm phụ thuộc (cần dependency injection).
Lưu ý: Tất cả các class có phụ thuộc đến các thành phần khác – như class LoginComponent ở trên – sẽ phải decorate bằng @Injectable() decorator.
Tuy nhiên, chúng ta sẽ không cần khai báo @Injectable() decorator nếu class đó đã có Angular decorators khác như @Component, @Pipe, @Directive, ... Bởi vì tất cả các decorator này là dạng con (subtype) của @Injectable.
Mặc dù, chúng ta không cần phải sử dụng @Injectable nếu class không có dependencies nào Nhưng nó sẽ là best practice để xác định một class là service bởi các lý do sau:
  1. Kiểm chứng trong tương lai (Future proofing): Không cần nhớ @Injectable () khi chúng ta thêm phụ thuộc sau này.
  2. Nhất quán (Consistency): Tất cả các service đều tuân theo các quy tắc giống nhau và chúng ta không phải thắc mắc tại sao lại thiếu decorator.

Cách sử dụng Dependency Injection trong Angular

  1. Tạo một dependency (service, hoặc component, ...) - ví dụ: AuthApiService
  2. Đăng ký dependency đó với provider (xem bên dưới)
  3. Truyền service vào phương thức khởi tạo (constructor) của component như một đối số.
    constructor(private authApiService: AuthApiService) {}

    hoặc

    // Trường hợp này có thể bỏ qua nếu phụ vào một class tự định nghĩa
    import { Inject } from '@angular/core';
    constructor(@Inject(AuthApiService) private authApiService: AuthApiService) {} 
    

Lưu ý:

Chúng ta sẽ dùng @Inject(...) khi các kiểu dữ liệu là primitive như boolean, string, number, ... hoặc các implicit dependencies như built-in browser APIs,... để báo cho Angular biết và chúng ta sẽ config các provider tương ứng:
import { InjectInjectableInjectionToken } from '@angular/core';
export const BROWSER_STORAGE = new InjectionToken<Storage>('Browser Storage', {
  providedIn: 'root',
  factory: () => localStorage
});
@Injectable({
  providedIn: 'root'
})
export class BrowserStorageService {
  constructor(@Inject(BROWSER_STORAGEpublic storageStorage) {}
  get(keystring) {
    return this.storage.getItem(key);
  }
  set(keystringvaluestring) {
    this.storage.setItem(keyvalue);
  }
  remove(keystring) {
    this.storage.removeItem(key);
  }
  clear() {
    this.storage.clear();
  }
}
Ví dụ khác:
@Component({
  ...,
  providers: [
    {provide: 'title'useValue: 'Login'}
  ]
})
class LoginComponent {
  constructor(@Inject('title'authService) {
    
  }
}

Đăng ký dependencies với provider

Bạn phải đăng ký ít nhất một provider của bất kỳ service nào bạn sẽ sử dụng. Provider có thể là một phần của siêu dữ liệu riêng của service, làm cho service đó khả dụng ở mọi nơi hoặc bạn có thể đăng ký provider với các module hoặc component cụ thể.
Bạn có thể đăng ký dependency với provider ở nhiều cấp độ khác nhau như trong @Injectable() cho chính service đó hoặc @NgModule() (providers array)  hoặc @Component()/@Directive (providers array).
1. Theo mặc định, lệnh Angular CLI ng generate service đăng ký một provider với bộ nạp gốc cho service của bạn bằng cách đưa siêu dữ liệu của provider vào decorator @Injectable().
@Injectable({
  providedIn: 'root'
})

Khi bạn cung cấp service ở level này, Angular sẽ tạo một instance duy nhất (singleton) cho suốt toàn bộ app (dùng chung một instance AuthApiService duy nhất và đưa nó vào bất kỳ lớp nào yêu cầu nó). Đăng ký provider trong siêu dữ liệu của @Injectable() cũng cho phép Angular tối ưu hóa ứng dụng bằng cách xóa service khỏi ứng dụng đã biên dịch nếu nó không được sử dụng, quá trình này được gọi là tree-shaking.

  • Siêu dữ liệu (metadata), providedIn: 'root' có nghĩa là chỉ có duy nhất 1 instance AuthApiService hiển thị trong toàn bộ ứng dụng. Điều này tương tự như việc bạn add AuthApiService vào providers của AppModule.
  • Trên thực tế, sử dụng providedIn là cách ưu tiên để cung cấp service trong một module.

2. Khi đăng ký một provider với một NgModule cụ thể thì tất cả các component trong NgModule đó sẽ sử dụng cùng một instance của service. Để đăng ký ở level này, hãy sử dụng thuộc tính providers của decorator @NgModule():Trong thực tế, chúng ta thường khai báo các services ở cấp độ Module để sử dụng xuyên suốt trong chương trình.

@NgModule({
  ...
  providers: [AuthApiService],
  ...
})

3. Khi bạn đăng ký một provider ở level component, bạn sẽ nhận được một instance mới của service với mỗi instance mới của component đó. Ở level component, hãy đăng ký một provider của service trong thuộc tính providers của siêu dữ liệu @Component():

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  providers: [AuthApiService]
})

Ghi đè provider (Override Provider)

providers: [AuthApiService]

Angular mở rộng giá trị providers của class provider syntax trên thành một object provider đầy đủ như sau:

providers: [{provide: AuthApiService, useClass: AuthApiService}]

  • Thuộc tính thứ nhất là provide đóng vai trò là một key để định vị giá trị phụ thuộc và định cấu hình injector. Đây là token để DI system map với token mà @Inject/@Injectable đã mô tả. Khi có token, DI system sẽ đọc key tiếp theo (thuộc tính thứ hai). Giá trị của thuộc tính này có thể là string hoặc một kiểu dữ liệu (có thể là class tự định nghĩa, ...).
    Bạn có thể xem nó là một cái tên provider để định danh cho instance service sẽ được truyền vào component, để angular biết được với tên đó thì sẽ truyền instance của class dependency nào cho component
  • Thuộc tính thứ hai là đối tượng định nghĩa một provider, đối tượng này nói cho injector biết cách tạo giá trị phụ thuộc. Khóa định nghĩa nhà cung cấp (provider-definition key) có thể là useClass, như trong ví dụ. Nó cũng có thể là useExisting, useValue hoặc useFactory. Mỗi khóa này cung cấp một loại phụ thuộc khác nhau.

Điều này giúp chúng ta thay đổi một class khác mà vẫn sử dụng token trên, chúng ta chỉ cần bảo DI class chúng ta cần mà không phải sửa token ở class cần phụ thuộc.

providers: [{provide: AuthApiService, useClass: AuthApiExtService}]

Do đó, khi muốn thay đổi logic xử lý của service như chuyển từ tính toán thông tin ở client sang call đến một external datasource (chẳng hạn như API, ...). Để tránh việc phải đi vào từng component và thay đổi service lần lượt thì ta sẽ tạo 1 service mới để override service trước đó, ta làm như sau:

import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class AuthApiService {
  constructor() { }
}

Tạo service AuthApiExtService:

import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class AuthApiExtService {
  constructor() { }
}

Bạn chỉ cần có thế và có thể tiến hành override, mà không cần sửa đổi lại code của các component đã sử dụng AuthApiService như sau:

@NgModule({
  ...
  providers: [
    {
      provide: AuthApiService,
      useClass: AuthApiExtService,
    },
  ],
  ...
})
export class AppModule {}

Hoặc có thể override vào @Injectable của service:

import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root',
useClass: AuthApiExtService,
})
export class AuthApiService {
  constructor() { }
}

Inject parent component to child component

Angular application là một component tree có dạng như sau.

Do Angular support DI đến tận level của từng Component, nên chúng ta hoàn toàn có thể inject parent component vào child component.
constructor(private parent: ParentComponent) { }
Override provider:
@Component({
  selector: 'app-bs-parent',
  templateUrl: './bs-parent.component.html',  styleUrls: ['./bs-parent.component.css'],  providers: [
    {
      provide: ParentComponent,
      useExisting: BsParentComponent
    }
  ]
})
export class BsParentComponent extends ParentComponent {
}

Provider in-depth

Trong các ví dụ trên, chúng ta đã sử dụng provider với cấu trúc của một object với các key provideuseClass như sau.
{ provide: AuthApiService, useClass: AuthApiService}
Ngoài cách trên chúng ta có thể sử dụng một số cách dưới đây:

Sử dụng value

Nếu bạn sử dụng token như sau, value sẽ được truyền vào thay vì tạo instance của class.
{
  provide: 'API_ENDPOINT',
  useValue: 'https://sanhangsalegiare.herokuapp.com'
}

Sử dụng alias

Có nhiều token có thể cùng sử dụng một token đã có.
{ provide: AuthApiService, useClass: AuthApiService },
{ provide: Server, useExisting: AuthApiService }
  • useClass: sẽ tạo ra một instance mới (tạo instance AuthApiService cho provider AuthApiService)
  • useExisting: giống như bí danh (alias) cho một provider đã đăng ký. Trường hợp sử dụng là cung cấp cùng một instance của provider với các khóa khác nhau. (Không tạo instance mới mà sử dụng lại instance AuthApiService đã được tạo trước đó với key 'Server').
StackOverflow - useExisting

Sử dụng factory

Khi bạn muốn trả về một value dựa vào một điều kiện đầu vào, hoặc bạn muốn mỗi lần gọi đến instance của token sẽ cho một instance khác nhau thì bạn sử dụng factory function như sau.
{
  provide: AuthApiService,
  useFactory: () => {
    return isExternal ? new AuthApiExtService() : new AuthApiService();
  }
}
Factory có thể có dependencies, lúc đó chúng ta sử dụng key deps:
{
  provide: LoginComponent,
  useFactory: (authService) => {
    return new LoginComponent(authService);
  },
  deps: [AuthApiService]
}
Khi có nhiều providers có cùng giá trị của key provide và không sử dụng config multi: true thì provider nào thêm vào sau cùng sẽ được sử dụng.
{ provide: 'API_ENDPOINT',  useValue: 'https://sanhangsalegiare.herokuapp.com' },
{ provide: 'API_ENDPOINT',  useValue: 'https://sanhangsalegiare.herokuapp.com/posts' }
Kết quả cuối cùng của API_ENDPOINT sẽ là https://sanhangsalegiare.herokuapp.com/posts

Multiple Provider

Trong trường hợp bạn muốn một token có thể có nhiều value, lúc này bạn có thể sử dụng multiple như sau:
{
  provide: 'API_ENDPOINT',
  useValue: 'https://sanhangsalegiare.herokuapp.com',
  multi: true
},
{
  provide: 'API_ENDPOINT',
  useValue: 'https://sanhangsalegiare.herokuapp.com/posts',
  multi: true
},
export class LoginComponent {
  constructor(@Inject('API_ENDPOINT') apiUrl) {
    console.log(apiUrl);
  }
}
Khi đó kết quả nhận được là một mảng các giá trị: ['https://sanhangsalegiare.herokuapp.com', 'https://sanhangsalegiare.herokuapp.com/posts']

Forward References

Forward References cho phép tham chiếu đến các tham chiếu chưa được xác định.
Sử dụng Forward References trong trường hợp bạn tạo một provider mà class bạn định nghĩa sau khi tạo provider:

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  providers: [
    NameService
  ]
})

export class AppComponent {
  constructor(nameServiceNameService) {
    nameService.getName();
  }
}

class NameService {
  getName () {
    console.log('Angular service!');
  }
}

Nếu chúng cố gắng chạy code này, chúng ta sẽ gặp lỗi sau:
ERROR in src/app/app.component.ts:9:5 - error TS2449: Class 'NameService' used before its declaration.
Cách giải quyết lúc này, chúng ta sẽ sử dụng Forward References như sau:

import { ComponentforwardRef } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  providers: [
    forwardRef(() => NameService)
  ]
})

export class AppComponent {
  constructor(nameServiceNameService) {
    nameService.getName();
  }
}

class NameService {
  getName () {
    console.log('Angular service!');
  }
}

Trường hợp này bạn thường gặp phải khi tạo custom validator directive cho form. Dưới đây là một đoạn code trích ra từ Angular Forms module để validate một field là required:
export const REQUIRED_VALIDATOR: Provider = {
  provide: NG_VALIDATORS,
  useExisting: forwardRef(() => RequiredValidator), 
  // hoặc useClass: forwardRef(() => CustomClass),
  multi: true
};

@Directive({
  selector: '',
  providers: [REQUIRED_VALIDATOR],
  host: {'[attr.required]': 'required ? "" : null'}
})
export class RequiredValidator implements Validator {
}
Như bạn có thể thấy, Angular khai báo provider cần sử dụng đến class khai báo ngay sau nó. Đây chính là lúc chúng ta cần đến Forward References.

Optional Dependencies

Để đánh dấu một dependency là optional – có cũng được, không có cũng được – chúng ta sử dụng @Optional decorator.
import { Optional } from '@angular/core';

export class OptionalClass {
  public log: LogService;
  constructor(@Optional() log: LogService) {
    // do something if log exist
    if (log) {
      this.log = log;
    }
  }
}
Ngoài ra, DI trong Angular còn một số kiến thức về Controlling Visibility với các decorator @SkipSelf, @Host hay @Self các bạn có thể vào trang document chính thức để tìm hiểu thêm.

Reference:

Đăng nhận xét

Mới hơn Cũ hơn