iT邦幫忙

2024 iThome 鐵人賽

DAY 17
0
Software Development

一個好的系統之好維護基本篇 ( 馬克版 )系列 第 17

Day-17: DI 的設計模式與臭臭的味道

  • 分享至 

  • xImage
  •  

同步至 medium

https://ithelp.ithome.com.tw/upload/images/20241001/20089358WR6kGevoIh.png

在上一篇文章中,我們已經理清楚了 DI 後,接下來我們來理一下實務上有那些 DI 的設計模式並且與他們的優缺點。

Day-16: DI 是什麼?深入探討 IoC、DIP 的關聯性與好處


DI 設計模式

目前基本上應該是分以下幾種,然後在實務上很常搭配一起使用 :

  1. 組合根 ( DI 容器 )
  2. 建構子注入
  3. 方法注入
  4. 屬性注入

然後我覺得《 依賴注入:原理、實作與設計模式》這一本書中的開頭我覺得有抓到這幾個方法的核心 :

  • 利用組合根來組合物件關聯。
  • 利用建構子注入來靜態定義對依賴關係的需求。
  • 利用方法注入在組合根之外的地方滿足依賴關係。
  • 利用屬性注入來定義額外的依賴需求。

在實務上通常 4 個會搭配來使用,而不是說只能選 1 個,但是最主要的應該就是下面第 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 在整個服務內。

其它還有REQUESTTRANSIENT這兩種,第一種就是每一個 http 請求都是不同的物件,然後第二種就是每一次使用物件時,都是不同的。

但這三種都可以混這用,例如 userService 是 signle scope,然後 loggerService 是 transitent scope,那這樣每一次 userService 內使用 loggerService,都會有一個新的實例。

2. 建構子注入

就是我們最常見的如下程式碼的範例,它事實上和上面的組合根在注入時差不多。

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);

當建構子被用來處理依賴了,那就不要在讓他做其它的事情。

3. 方法注入

範例程式碼如下,最容易理解就是根據 paymentMethod 不同所以可能會選用 linepay 或 tappay 等不用的 gateway,例如下面範例。

class OrderProcessor {
    process(order: Order, paymentGateway: PaymentGateway) {
        paymentGateway.pay(order);
    }
}

4. 屬性注入

接下來屬性注入是我比較少看過的用法,而且書中事實上也提到它是一種用起來很簡單,但是會有不少缺點的方法,等等我們壞味道時會說說。

class NotificationService {
    public emailService!: EmailService;

    sendEmail(recipient: string, message: string) {
        this.emailService.send(recipient, message);
    }
}

// 外部進行屬性注入
const notificationService = new NotificationService();
notificationService.emailService = new EmailService();

壞味道

1. 過度注入

就是如下程式碼,一個 orderService 注入了 8 個 service,這個嚴格來說不算是 DI 的壞味道,而是依賴與 SRP 的壞味道了。

然後這裡兩個重構的建議 :

  1. 可以根據方法內,那些有使用共同的 repository 然後拆成另一個 service,因為它們依賴是相同的,根據 CRP 原則 :
  2. 可以設定團隊規則,一個類別注入了 n 個東西後,就可以考慮討論拆分。
  3. 考慮使用裝飾者模式來減少數是,例如 logging 就可以拉出來,它主要是幫助我們建立橫切關注點機制,它是指在多個模塊重複出現的行為或邏輯,例如 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;   // 記錄操作
  }
}

2. 無用的依賴

class OrderService {
  constructor(
    private paymentService: PaymentService,
    private loggingService: LoggingService,  // 這個依賴從未使用
  ) {}

  processOrder(orderId: string) {
    this.paymentService.processPayment(orderId);
  }
}

3. 服務定位 (Service Locator) 反模式

服務定位本身是一種設計模式,它的概念就很像是從 DI Container 中取出物件來直接用,而不是用注入的方式來取得物件,但是這個方法有幾個問題 :

  • 沒有明確的依賴關係,因為嚴格來說這個類別是依賴 ServiceLocator。
  • 在測試時也會比較麻煩。

整體來看還是使用建構子注入會比較佳。

class OrderService {
  processOrder(orderId: string) {
    const paymentService = ServiceLocator.get('PaymentService');
    paymentService.processPayment(orderId);
  }
}

4. 不要寫出有時序耦合的注入 ( Temporal Coupling )

例如 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 時常看到的壞味道,也希望可以在未來開發時,提醒我自已這些東西要注意。


上一篇
Day-16: DI 是什麼?深入探討 IoC、DIP 的關聯性與好處
下一篇
Day-18: Typescript 編譯器守護者
系列文
一個好的系統之好維護基本篇 ( 馬克版 )30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言