DI & IOC - 觀念解說,何謂依賴(耦合)&控制反轉&注入

何謂依賴(耦合)&控制反轉&相依性注入? DI & IoC 觀念解說

  • 耦合性(依賴性):或許你不知道什麼是高耦合,但你我卻常遇到高耦合。例如:底層模組改有變動,導致後續要調整很高階模組的程式。
  • IoC:控制反轉(Inverse of Control, IoC)是一種設計模式,用來降低程式耦合。
  • DI:相依性注入(Dependency Injection)則是實作IoC的一種方式

先來看一下範例,假如我們有個MailLog類別,可以幫我們發送Mail Log。目前只要再登入時發送登入Log紀錄給管理員就好了,程式碼輕輕鬆鬆就寫好了

    public class MailLog
    {
        public string SendMailLog()
        {
            return $"send mail log";
        }
    }
    public class AuthController : ControllerBase
    {
        public async Task Login()
        {
            //登入邏輯...

            var log = new MailLog();
            log.SendMailLog();
        }
    }

何謂依賴(耦合)?

如果有天,主管有個新需求。訂單完成後. 發送mail給客戶後,也要發送Log紀錄給管理員。這好像也沒什麼大不了,複製. 貼上五分鐘搞定。

    public class OrderController : ControllerBase
    {
        public async Task Order()
        {
			//下訂單邏輯...        
        

            var log = new MailLog();
            log.SendMailLog();
        }
    }
    public class MailController : ControllerBase
    {
        public async Task Mail()
        {
			//Mail發送邏輯...           
        

            var log = new MailLog();
            log.SendMailLog();
        }
    }

 

如果又有一天,主管有個新需求。我不想要在收到Mail Log了,我想要改存成文字檔Log,有看再去看。問題雖然不難,但是要改動的地方就多了。

首先要先寫一個TxtLog類別

    public class TxtLog
    {
        public string SaveTxtLog()
        {
            return $"save txt log";
        }
    }

 

接著要把AuthController. MailController. OrderController裡的MailLog類別換成TxtLog類別,其呼叫的方法名稱也要由SendMailLog()換成SaveTxtLog()有關的程式通通換掉。
        public async Task Login()
        {
            //登入邏輯...

            //var log = new MailLog();
            //log.SendMailLog();

            var log = new TxtLog();
            log.SaveTxtLog();
        }

如果又有一天,主管有個新需求。我不想要把Log另文成TXT文字檔了,我想要改存在DB裡面,在搭配精美的UI查詢頁面,就能更方便調閱Log了。除了要另外再寫一個DbLog類別,Controller原本用到TxtLog的地方也全部都要改成DbLog。

假如目前只有AuthController. MailController. OrderController會用到log記錄功能,那問題是不大。但如果今天系統成長快速,已經有了100多個Controller,裡面又有不少Action需要做Log紀錄,那就不再適合複製貼上法了。

 

會造成如此牽一髮而動全身的情形就是因為依賴(耦合)性再搞怪,底層模組(MailLog. TxtLog. DbLog)一但變動,高層模組(xxxxController)也被迫跟著要進行異動,這種情形表示高層模組太依賴底層模組了,造成程式的高依賴(耦合)性。用上面的範例來解釋一下何謂依賴:

今天會造成需要一直修改程式的原因是因為我們的高層模組太過於依賴一個單一類別了。

依賴MailLog用來寫Log
依賴TxtLog用來寫Log
依賴DbLog用來寫Log

寫Log這件事本身有很多方法,但我們把各個方法寫成單一類別(MailLog, TxtLog, DbLog),導致高層模組一次只能依賴於單一類別來執行寫Log這個行為,只要想換其他方法寫Log,就要再修改程式。

對於依賴的解釋,這篇文章解釋的蠻不錯的:依賴反向原則 (Dependency-Inversion Principle, DIP)


依賴反轉原則

依賴反轉原則(Dependency inversion principle,DIP)有下列特性:

  1. 高層次的模組不應該依賴於低層次的模組,兩者都應該依賴於抽象介面。
  2. 抽象介面不應該依賴於具體實現。
  3. 而具體實現則應該依賴於抽象介面。

 

高層次的模組不應該依賴於低層次的模組,兩者都應該依賴於抽象介面。

看到第一點就知道範例程式高階模組直接依賴了低階模組,已經不符合依賴反轉原則了。從範例程式我們可以發現,我們太依賴單一類別來做事了,應該把焦點放在實際行為上。我們從MailLog換到TxtLog再換到DbLog,其最主要的目的都是寫Log這件事。

看到第一點就知道範例程式高階模組直接依賴了低階模組,已經不符合依賴反轉原則了。從範例程式我們可以發現,我們太依賴單一類別來做事了,應該把焦點放在實際行為上,我們從MailLog換到TxtLog再換到DbLog,其最主要的目的都是寫Log這件事

兩者都應該依賴於抽象介面,這句話看起來是有看沒有懂,但我在網路上看到一張圖,我覺得很適合用來解釋這句話。

DIP-electricity-ex
source:https://notfalse.net/1/dip (依賴反向原則 (Dependency-Inversion Principle, DIP))

是不是簡單明瞭,使用者(高階模組)不用去管我們電力來源是風力, 火力, 核電(低階模組)…我們只需要透過插座(介面),即可完成我們的最終目的-取得電源。試想,今天如果家裡用電是由核電廠直接牽線到家裡的話,那改天核電廠如果廢掉了,是不是家裡的線路(低階模組的方法)也要全部重新更換。

如果是是透過介面來取得實作,而不直接依賴低階模組的話,是不是就能斷開其中關係,完成解藕的第一步了。

範例

把上面提到的 [高階模組]. [低階模組]. [依賴]. [控制反轉] 這些名詞,透過一個簡單的程式範例做一個整合!
我(高階模組)午餐的選擇有 中餐廳(低階模組). 西餐廳(低階模組). 法式餐廳(低階模組),我如果去(依賴)中餐廳,就只能吃中式料理,如果去(依賴)西餐廳,就只能吃西餐…因為我太依賴了單一餐廳(高階模組依賴低階模組),造成我如果要吃其他料理時,就要改一次程式,把餐廳類別換掉。
public class Main
{
	var me = new People();
	me.吃();
}
public class Peaple
{
	public void 吃()
	{
		中餐廳 餐廳 = new 中餐廳();
		餐廳.提供料理();
		
		//西餐廳 餐廳 = new 西餐廳();
		//餐廳.提供料理();
		
		//法式餐廳 餐廳 = new 法式餐廳();
		//餐廳.提供料理();
	}
}
public class 中餐廳
{
	public void 提供料理()
	{
		console.writeline("糖醋魚...");
	}
}
public class 西餐廳
{
	public void 提供料理()
	{
		console.writeline("菲力牛排...");
	}	
}
public class 法式餐廳
{
	public void 提供料理()
	{
		console.writeline("烤蝸牛...");
	}
}

 

看完上面的程式,你可能會想說改程式就改程式阿,反正也只要改一行而已。那我們在擴充一下程式,我們在 [PostpartumCare] 跟 [BaiBai]這2個高階類裡面分別也new了一個[中餐廳]的物件。但如果我今天要把中餐廳換成西餐廳的話,就算我把類別宣告的名稱跟方法名稱都取一樣,也至少要再多改2行程式:

  1. 類別[PostpartumCare]下的 中餐廳 餐廳 = new 中餐廳() => 西餐廳 餐廳 = new 西餐廳();
  2. 類別[BaiBai]下的 中餐廳 餐廳 = new 中餐廳() => 西餐廳 餐廳 = new 西餐廳();

因為高階類別依賴單一特定低階類別,所以當要替換成西式料理時,就要修改高階類別的程式。當程式越來越龐大時,如果有100個高階類別分別都各自依賴到低階模組時,那就要改100個地方。況且現在還是我們把類別宣告的名稱跟方法名稱都取一樣。不然就要改到更多地方。

public class Main
{
	var me = new People();
	me.吃();
	
	var postPartumCare= new PostpartumCare();
	postPartumCare.在家坐月子();
	
	var bai = new BaiBai();
	bai.拜天公();
}
public class PostpartumCare
{
	public void 在家坐月子()
	{
		中餐廳 餐廳 = new 中餐廳();
		餐廳.提供料理();
		
		//西餐廳 餐廳 = new 西餐廳();
		//餐廳.提供料理();
		
		//法式餐廳 餐廳 = new 法式餐廳();
		//餐廳.提供料理();		
	}
}
public class BaiBai
{
	public void 拜天公()
	{
		中餐廳 餐廳 = new 中餐廳();
		餐廳.提供料理();
		
		//西餐廳 餐廳 = new 西餐廳();
		//餐廳.提供料理();
		
		//法式餐廳 餐廳 = new 法式餐廳();
		//餐廳.提供料理();		
	}
}

 

如果有熊貓外送把所有低階模組的行為統整為一個介面(Interface)。依賴關係就可以從我主動去依賴單一店家,變成依賴熊貓外送(IoC控制反轉),熊貓外送再提供(DI注入)我需要的餐點即可。這樣就解除(解耦)了我與單一種料理之間的依賴了。
我們分別修改一下高階模組[People].[PostpartumCare].[BaiBai],把程式改成使用注入的方式來解除對低階模組[中餐廳]的依賴,那之後不管有多少高階模組使用到低階模組的方法時,都不用再修改程式了,只要在DI容器變更要實作的類別即可。
所謂的反轉並不是由原本的高階模組依賴於低階模組反轉成低階模組依賴於高階模組,而是從原本的高階模組依賴於低階模組變成高階模組依賴於介面

首先要設定DI容器的對應,這邊以.Net(Core)來舉例的話,由於已經有內建DI,所以不用再另外安裝DI套件,直接在Program.cs設定即可。如果是.Net Framework 4.8要使用DI的話,請參考這篇文章使用Unity.MVC讓ASP.NET Framework4.8實現DI注入

//Program.cs
builder.Services.AddScoped<IFoodPanda, 中餐廳>();
public class Peaple
{
	private readonly IFoodPanda _foodPandaService;

	public Peaple(
		IFoodPanda foodPandaService
	)
	{
		_foodPandaService = foodPandaService;
	}

	public void 吃()
	{
		_foodPandaService.提供料理();
	}
}
public class PostpartumCare
{

	private readonly IFoodPanda _foodPandaService;

	public Peaple(
		IFoodPanda foodPandaService
	)
	{
		_foodPandaService = foodPandaService;
	}

	public void 在家坐月子()
	{
		_foodPandaService.提供料理();	
	}
}
public class BaiBai
{

	private readonly IFoodPanda _foodPandaService;

	public Peaple(
		IFoodPanda foodPandaService
	)
	{
		_foodPandaService = foodPandaService;
	}

	public void 拜天公()
	{
		_foodPandaService.提供料理();	
	}
}
public interface IFoodPanda
{
	public void 提供料理();
}
public class 中餐廳: IFoodPanda
{
	public void 提供料理()
	{
		console.writeline("糖醋魚...");
	}
}
public class 西餐廳: IFoodPanda
{
	public void 提供料理()
	{
		console.writeline("菲力牛排...");
	}
}
public class 法式餐廳: IFoodPanda
{
	public void 提供料理()
	{
		console.writeline("烤蝸牛...");
	}
}

 

如果我們今天要改吃西餐的話,就直接修改DI容器裡面的對應就好了,這樣[西餐廳]這個低階模組就會從建構子注入到高階模組了,不需要由高階模組依賴[西餐廳]來呼叫裡面的[提供料理()]
//Program.cs
builder.Services.AddScoped<IFoodPanda, 西餐廳>();

 

DI & IoC總結:

IoC可以解除高階模組對低階模組的依賴,並透過DI把低階模組注入高階模組來實踐IoC。

記得2022年面試時,面試官曾經問過我一個問題:

面:你覺得用DI的好處是什麼?

我:可以減少要改的程式碼的數量.

面:那我如果把類別的名稱跟方法都取名成一樣的話,不是也可以減少很多要改的程式嗎? 

(*就像我前面的範例程式中所有中餐廳. 西餐廳. 法式餐廳的實例名稱都取名為餐廳,類別方法也都取名為提供料理())

我:這樣可讀性會比較差….

2025的我會這樣回答:

我:
使用IoC & DI的好處可以讓程式的擴充更方便。
1.只要在DI容器裡面把要實作的底層類別替換掉的話,就可以不用改到高階類別的程式碼。
2.寫單元測試時,可以直接透過DI注入測試的MockService,更方便進行單元測試。

Ref: