iT邦幫忙

2024 iThome 鐵人賽

DAY 16
0
Software Development

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

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

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20240930/20089358b0KPyl4KJA.png


DI 這個詞應該在軟體工程圈中有些過程式的人,應該多多少少都有聽過,但很多情況下我聽到說 DI 的好處就是好測試,所以這一篇文章我打算來理一下,到底 DI 的好處到底是什麼呢 ? 並且還有和一些常聽到的名詞 IoC、DIP 的關係是啥 ~

什麼是依賴注入 ( DI:Dependency Injection ) 呢 ?

簡單的說就是用注入的方式,來具備鬆耦合 ( loosely coupled )

如果以最簡單的範例來看,大概就長的如下,就是依賴的東西,用注入的方式 :

class CourseService {
    private logger: ILogger;

    constructor(logger: ILogger) {  //    <--------------- 注入
        this.logger = logger;
    }

    public getCourses() {
        this.logger.log('Fetching courses...');
        return ['Course 1', 'Course 2', 'Course 3'];
    }
}

---
// 使用的方式
const logger = new Logger()
const courseService = new CourseService(logger);

那 DI 有什麼好處呢 ?

有什麼好處呢 ? 為什麼我不能直接在 constructor 裡直接 new 一個 logger 呢 ?

好處 1. 好測試 ( 準確的說是 unit test )

這個應該就是每個人都會想到的第一個好處理,假設我們現在準備要測試以下的程式碼 :

interface ILogger {
    log(message: string): void;
}

class GcpLogger implements ILogger {
    log(message: string): void {
        ....
    }
}

class CourseService {
    private logger: ILogger;

    constructor(logger: ILogger) { 
        this.logger = logger;
    }

    public getCourses() {
        this.logger.log('Fetching courses...');
        return ['Course 1', 'Course 2', 'Course 3'];
    }
}

const logger = new GcpLogger()
const courseService = new CourseService(logger);

但我們測試時,就是不想要送到 GCP,所以通常會改成如下:

import { CourseService } from './course.service';
import { ILogger } from './logger.service';

describe('CourseService', () => {
    let courseService: CourseService;
    let logger: ILogger;

    beforeEach(() => {
        logger = {
            log: jest.fn(),
        };
        courseService = new CourseService(logger);
    });

    it('should return a list of courses', () => {
        const courses = courseService.getCourses();
        expect(courses).toEqual(['Course 1', 'Course 2', 'Course 3']);
    });
});

好處 2. 可維護性

可以明確的知道這個 servcie 的職責與依賴關係

以下面程式碼為範例來看,我們很明確的知道,這個 service 依賴 Logger。

class CourseService {
    private logger: ILogger;

    constructor(logger: ILogger) {
        this.logger = logger;
    }

    public getCourses() {
        this.logger.log('Fetching courses...');
        return ['Course 1', 'Course 2', 'Course 3'];
    }
}

但這樣就算寫成如下,不是也是一樣 ?

import { LoggerService } from './logger.service';

class CourseService {
    private logger: ILogger;

    constructor() {
        this.logger = new LoggerService();
    }

    getCourses(name: string): void {
       this.logger.log('Fetching courses...');
       return ['Course 1', 'Course 2', 'Course 3'];
    }
}
  • 直接依賴了 LoggerService 的具體實作,也就是說如果改寫了 LoggerService 就可能 CourseService 也要改寫,例如 LoggerService constructor 需要帶 config 參數之類的。
  • 而且也很難測試。

好處 3. 可擴展性

就是在擴展時,不會影響到原程式碼

這裡有兩個範例,首先第一個是如果只有 courseService 要用 consoleLoggerService,而 userService 要用 fileLoggerService,則只要如下修改就好。

重點在於我完全沒修改到 courseService 與 userService~

import { ILogger } from './logger.interface';

class ConsoleLoggerService implements ILogger {
    log(message: string): void {
        console.log(`ConsoleLoggerService: ${message}`);
    }
}

class FileLoggerService implements ILogger {
    log(message: string): void {
        console.log(`FileLoggerService: ${message}`);
    }
}
//------------------------------------------------------

class CourseService {
    private logger: ILogger;

    constructor(ILogger: logger) {
        this.logger = logger;
    }

    getCourses(name: string): void {
       this.logger.log('Fetching courses...');
       return ['Course 1', 'Course 2', 'Course 3'];
    }
}

class UserService {
    private logger: ILogger;

    constructor(ILogger: logger) {
        this.logger = logger;
    }

    createUser(name: string): void {
       this.logger.log('create user...');
    }
}

// 使用的地方
const consoleLogger = new ConsoleLoggerService()
const courseService = new CourseService(consoleLogger);

const fileLogger = new FileLoggerService()
const userService = new UserService(fileLogger)

所有的東西都要用 DI ?

根據《依賴注入 : 原理、實作與設計模式》這本書中提中的,這個答案是 不一定。

這題我在開發時,也有想過這件事,我什麼東西都要用 DI 嗎 ? 那是不是我的 import 只會剩下 interface 或 constant 而以 ? 這裡怎麼想都怪怪對吧 ?

根據上面那本書中提到的,他會將依賴關係分成以下兩個 :

  • Stable Dependencies : 就是可以不用 DI
  • Volatile Dependencies : 建議用 DI

書中列的穩定性 ( stable 就是可以不用的 ) 有以下的特點 :

  1. 類別或模組不需要另外準備。
  2. 即使版本更新也不會導致運作異常。
  3. 程式的演算法或行為模式必須要是可預期的結果。
  4. 你不會想要將這些類別或模組,用其他的類別或模組,進行替換、包裝、裝飾或是中介攔截。

不過我自已認真說 ~ 有點難理解與定義呢 ~ 所以我自已是這樣判斷 :

  1. 我自已寫的,並且是需要實體化的,那我正常情況下會用 DI。
  2. 因為我在寫 unit 前,就已經先想到會需要 mock 的就也一定會用 DI。
  3. 比較純函式庫類型的,我就不太會用,例如 utils 或是 lodash ( 有人有將 lodash 進行 DI 過嗎 ? )
  4. 目前整個專案主要在用的 framework 的套件,例如 node 的 nest 相關,我也不太會用。
  5. DTO、Domain Model。

這只是純個人的想法 ~ 沒有一定是對 ~ 參考就好 ~

然後接下來,我還有找到一些會讓我有這個疑問的原因,那就是monkey patch

傳統上應該有些人認為因為 DI 是因為講 c# 或 java 這類靜態語言所以才需要使用,但在一起動態語言上,我們可以使用 moneky patch (指非常簡單的方式猴子般地修改現有程式碼)的方式直接修改一個物件的內容,這也才產生,我當初的疑惑。

然後下面也有兩篇也有談到相關的主題 :

Module Requiring vs Dependency Injection in Javascript
Monkey Patching vs. Simple Dependency Injection in modern Python

其中第二篇文章有提到,雖然在一些動態語言上,可以使用 monkey patch 的方式來讓我們輕鬆修改 object 然後測試,但還是建議使用 DI,主要的原因在於可能會有以下的問題 :

  • 如果過度使用,會使程式碼難以理解和維護,因為運行時的行為可能與靜態代碼不一致。
  • 如果沒有謹慎使用,可能會導致未預料的錯誤,因為任何人都可以隨時修改函數的行為。

整體來說我還是讚成使用 DI 來進行依賴注入。


DI、IoC、DIP 的關係到底是啥 ?

1. DI 算是 IoC ( Inversion Of Control ) 的一種實現

IoC 根據 Martin Fowler 的這一篇文章《 Inversion Of Control 》 來說,主要的概念如下 :

控制反轉的主要的想法是將控制流從應用程序轉移到框架,讓框架來調用應用程序的代碼,而不是應用程序自己控制什麼時候調用哪些方法。這種模式的多樣性體現在不同的實現技術上,如依賴注入、事件訂閱等。

上面有點文謅謅的,簡單的說就是 :

實體化依賴物件的方向,從我們自已控制產生,轉移到框架上。

事實上就是我們這個的範例 :

// 非 IoC
class UserService {
    private userRepository: UserRepository;

    constructor() {
        // 直接在類中創建依賴
        this.userRepository = new UserRepository(); // = <--------- 我自已決定啥時產物件
    }

    getUser(id: number): string {
        return this.userRepository.getUserById(id);
    }
}


// IoC
class UserService {
    private userRepository: UserRepository;

    // 依賴由外部傳入,實現 IoC
    constructor(userRepository: UserRepository) {  //  <----------- 由框架(IoC 容器)決定
        this.userRepository = userRepository;
    }

    getUser(id: number): string {
        return this.userRepository.getUserById(id);
    }
}

2. IoC 與 DIP 的反轉是兩件事,但 DIP 又需要 IoC

簡單複習一下 DIP,如下,詳細的請回頭看看這篇文章 ~ Day-03 : 設計原則之 SOLID - ISP、DIP

  • IoC ( Inversion Of Control ): 實體化依賴物件的流程
  • DIP ( Dependency Inversion Principle ): 高層次的模組不應該依賴於低層次的模組,兩者都應該依賴於抽象介面。抽象不要依賴細節,細節要依賴於抽象。

然後他們兩個重點都是反轉,但兩個是指不同的東西。

IoC 的反轉是指依賴物件實體化的方向,如下圖。

https://ithelp.ithome.com.tw/upload/images/20240930/20089358vM5IuTrBd0.png

DIP 反轉重點在依賴關係,如下圖。

https://ithelp.ithome.com.tw/upload/images/20240917/20089358uOLlRmJTC3.png

最後為什麼會說他們又互相需要呢 ?

因為 DIP 這句兩者都應該依賴於抽象介面,那這樣高階模組實際上要用時,要如何使用呢 ? 因為 new 不出來,所以只能請外部產,而不是自已產。


小結

這篇文章中我們理了一下以下幾個重點 :

  • 什麼是依賴注入 ( DI:Dependency Injection ) 呢 ?
  • 那 DI 有什麼好處呢 ?
  • 所有的東西都要用 DI ?
  • DI、IoC、DIP 的關係到底是啥 ?

事實上就已經將整個 DI 體系的基本理論概念都已經理的差不多了 ~ 這些都是前人挖掘很久解耦合的方法與實作都很常見的東西 ~ 基本上在探索軟體工程維護性的東西一定必經的一條路呢 ~


上一篇
Day-15: Domain Model 實務上面對的困境之 DDD Trilemma
下一篇
Day-17: DI 的設計模式與臭臭的味道
系列文
一個好的系統之好維護基本篇 ( 馬克版 )30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言