在上一篇文章中,我們已經理清楚了 DI 後,接下來我們來理一下實務上有那些 DI 的設計模式並且與他們的優缺點。
Day-16: DI 是什麼?深入探討 IoC、DIP 的關聯性與好處
目前基本上應該是分以下幾種,然後在實務上很常搭配一起使用 :
然後我覺得《 依賴注入:原理、實作與設計模式》這一本書中的開頭我覺得有抓到這幾個方法的核心 :
在實務上通常 4 個會搭配來使用,而不是說只能選 1 個,但是最主要的應該就是下面第 1 個。
事實上 DI 容器就是組合根概念的實作,實際組合根在說啥我自已是覺得不算很重要。然後以 js 體系來說 nestjs 應該是我目前在 DI 容器上,支援最好的框架,以下為它的簡單範例。
沒用過的簡單說一下, appModule 你可以想成它就是組合根,然後可能在服務一啟動後,就會在那個根裡面產生了HelloService 實體化,然後就再將它注入到 constructor 中的 helloService,像我們很多地方 module 就是直接在 appModule imports 進去。
import { Module } from '@nestjs/common';
import { HelloService } from './hello.service';
import { HelloController } from './hello.controller';
@Module({
imports: [],
controllers: [HelloController],
providers: [HelloService],
})
export class AppModule {}
import { Controller, Get } from '@nestjs/common';
import { HelloService } from './hello.service';
@Controller('hello')
export class HelloController {
constructor(private readonly helloService: HelloService) {}
@Get()
getHello(): string {
return this.helloService.sayHello();
}
}
然後這裡順到說一下,nestjs 中它有所謂的Injection scopes
,也就是說 :
那個物件會根據 scopes 時間來決定何時產生,default 就是 single instance 在整個服務內。
其它還有REQUEST
與TRANSIENT
這兩種,第一種就是每一個 http 請求都是不同的物件,然後第二種就是每一次使用物件時,都是不同的。
但這三種都可以混這用,例如 userService 是 signle scope,然後 loggerService 是 transitent scope,那這樣每一次 userService 內使用 loggerService,都會有一個新的實例。
就是我們最常見的如下程式碼的範例,它事實上和上面的組合根在注入時差不多。
class UserService {
constructor(private readonly userRepository: UserRepository) {}
getUserById(id: number) {
const user = this.userRepository.findById(id);
if (!user) {
throw new Error('User not found');
}
return user;
}
}
//------------------------------------------------------------
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
const user = userService.getUserById(1);
當建構子被用來處理依賴了,那就不要在讓他做其它的事情。
範例程式碼如下,最容易理解就是根據 paymentMethod 不同所以可能會選用 linepay 或 tappay 等不用的 gateway,例如下面範例。
class OrderProcessor {
process(order: Order, paymentGateway: PaymentGateway) {
paymentGateway.pay(order);
}
}
接下來屬性注入是我比較少看過的用法,而且書中事實上也提到它是一種用起來很簡單,但是會有不少缺點的方法,等等我們壞味道時會說說。
class NotificationService {
public emailService!: EmailService;
sendEmail(recipient: string, message: string) {
this.emailService.send(recipient, message);
}
}
// 外部進行屬性注入
const notificationService = new NotificationService();
notificationService.emailService = new EmailService();
就是如下程式碼,一個 orderService 注入了 8 個 service,這個嚴格來說不算是 DI 的壞味道,而是依賴與 SRP 的壞味道了。
然後這裡兩個重構的建議 :
橫切關注點機制
,它是指在多個模塊重複出現的行為或邏輯,例如 log 就是。感覺之後可以開一篇文章來討論如何處理這種大類別。
經常一起被使用的類別應該放在一起,包含依賴的東西。但反過來說不被依賴的就不要包在一起。
class OrderService {
constructor(
private paymentService: PaymentService,
private notificationService: NotificationService,
private inventoryService: InventoryService,
private shippingService: ShippingService,
private discountService: DiscountService,
private taxService: TaxService,
private customerService: CustomerService,
private loggingService: LoggingService,
) {}
processOrder(orderId: string, productId: string) {
// 使用所有注入的服務來處理訂單
this.inventoryService; // 檢查庫存
this.paymentService; // 處理付款
this.shippingService; // 處理運輸
this.discountService; // 計算折扣
this.taxService; // 計算稅額
this.customerService; // 確認客戶資料
this.notificationService; // 發送通知
this.loggingService; // 記錄操作
}
}
class OrderService {
constructor(
private paymentService: PaymentService,
private loggingService: LoggingService, // 這個依賴從未使用
) {}
processOrder(orderId: string) {
this.paymentService.processPayment(orderId);
}
}
服務定位本身是一種設計模式,它的概念就很像是從 DI Container 中取出物件來直接用,而不是用注入的方式來取得物件,但是這個方法有幾個問題 :
整體來看還是使用建構子注入會比較佳。
class OrderService {
processOrder(orderId: string) {
const paymentService = ServiceLocator.get('PaymentService');
paymentService.processPayment(orderId);
}
}
例如 db 連線就是一個好範例,下面為範例程式碼,如果沒說誰知道要先執行 connect() 呢 ? 對吧 ?
// Bad
class DatabaseConnection {
private connected = false;
connect() {
console.log("Connecting to database...");
this.connected = true;
}
executeQuery(query: string) {
if (!this.connected) {
throw new Error("Database not connected! Call connect() first.");
}
console.log(`Executing query: ${query}`);
}
}
class OrderService {
constructor(private dbConnection: DatabaseConnection) {}
processOrder(orderId: string) {
// 這裡假設 dbConnection 已經初始化並連接好
this.dbConnection.executeQuery(`SELECT * FROM orders WHERE id = '${orderId}'`);
}
}
依賴注入在實作上,實際上有很多的方式,像本篇文章就有四種方式可以建立,但是在實務上通常還是建議可以用 framework 內建的 di 模式就用,別有事沒事就自已幹一套,自已寫起來很爽,但後人維護是真的累,然後這裡最建議就是組合根+建構子注入+Framework
為標配是最好的。
DI 在軟體工程史上,對整個品質方面都影響非常大,不論是在靈活性、可測試性、少耦合都有不少的幫助,因為這也是為什麼我會抓個 2 篇文章來專門寫寫 DI 相關的東西 ~
最後這篇文章還有探討一些在使用 DI 時常看到的壞味道,也希望可以在未來開發時,提醒我自已這些東西要注意。