DI等於介面? 誤會可大了! — 1

Z-xuan Hong
12 min readMay 19, 2022

--

前幾天在靠北工程師看到一則貼文,內容大致如下:

抱怨公司的專案,整體架構上使用過多的介面,每個類別都有一個介面,假設N個類別,就有2*N個元件需要維護,過度複雜。

當爭議的議題出現時,可想像留言區通常會多方激烈論戰,觀點非常多,有人說DI就是這樣、有人說Java就是這樣、有人說Spring就是這樣、也有人說介面不等於DI等等。

看到大家的辯論,讓我意識到大部分開發者對介面與DI的概念有一定程度的誤解,因此想撰寫一篇文章來跟大家闡述介面與DI之間的關係。(當然這只是我開發多年與看書消化的觀點,也不一定正確,如果有任何疑問,歡迎下方留言多多討論)

此文章總共有三篇,每篇皆嘗試釐清一個概念,此篇為第一篇:

  • 了解介面所要解決的問題與使用時機 (1)。
  • 了解DI所要解決的問題與使用時機 (2)。
  • 了解為什麼DI不等於介面、介面不等於DI (3)。

介面

在我們了解介面解決甚麼問題之前,我們需要先知道介面是什麼。

介面在Java中,指的是針對API定義一系列的行為規格,在介面中我們只會看到行為,不會看到屬性、私有方法。

下面程式碼為介面的宣告,我們只專注在行為規格 ,而不是屬性、私有方法等實作細節

public interface IVoucherRepo
{
List<VoucherRow> FindVouchers();

}

下面為類別的宣告,我們可以看到類別的屬性、私有方法、行為。

public class VoucherRepo : IVoucherRepo
{
private
readonly Context _context = ...

public VoucherRepo()
{
}

public List<VoucherRow> FindVouchers()
{
// ...
}
}

看到這邊讀者應該有個了解,就是: 介面只有規格、類別包含規格與實作 (不管是abstract class或者是concrete class)

  • 介面只定義規格: 意思是只定義使用者角度的行為,不會針對這些行為的實作作出任何假設。
  • 類別包含規格與實作: 意思是在定義使用者角度行為的同時,也會描述類別是如何實作這些行為。

瞭解上述的概念後,接下來就要來解釋介面在解決甚麼問題了,大致上能列出四點,下面會依序用範例程式碼一一介紹。

  • 封裝shared dependency。
  • 封裝volatile dependency。
  • 封裝multiple implementations。
  • 遵守depedency inversion principle。

封裝shared dependency

所謂的shared dependency指的是在測試中被多個測試共享且會讓測試之間互相影響的dependency,常常是導致測試不穩定的原因。

最常見的例子為使用資料庫的測試,由於資料庫是out-of-process dependency,所有使用到資料庫的測試都會影響到同一份資料庫,造成測試之間沒辦法有效隔離。

如下面的測試案例,我們能看到兩個測試,分別為: Create_User_Mars、Create_User_Ted,實際上執行時,兩個測試會有一個錯誤,因為一旦第一個測試成功加入一個使用者,那麼另外一個測試的Assert就會出錯 (變成2不是1)。

[Test]
public void Create_User_Mars()

{
Database.CreateUser("Mars");
Assert.AreEqual(Database.Users().Count(), 1);
}

[Test]
public void Create_User_Ted()

{
Database.CreateUser("Ted");
Assert.AreEqual(Database.Users().Count(), 1);
}

為了解決上述的問題,我們通常會針對shared dependency設計一份介面規格,並且在測試中使用Fake, Mock物件去替換他,如此一來便可以降低測試不穩定的問題。

如下面的測試案例,由於我們針對Database替換成Fake物件,測試不會有不穩定的問題。

備註: 此介面設計為示範用 (有header interface問題),實際上設計界面時,我們應要提升更高的抽象層次,像是使用Repository、DAO等模式來設計介面。

public interface Database
{
void CreateUser(string name);
List<User> Users();
}

public class FakeDatabase: Database
{
private List<User> users = new List<User>();
public void CreateUser(string name)
{
users.Add(new User(name));
}

public List<User> Users()
{
return users;
}
}

[Test]
public void Create_User_Mars()
{
var db = new FakeDatabase();
db.CreateUser("Mars");
Assert.AreEqual(db.Users().Count, 1);
}

[Test]
public void Create_User_Ted()
{
var db = new FakeDatabase();
db.CreateUser("Mars");
Assert.AreEqual(db.Users().Count, 1);
}

封裝volatile dependency

所謂的volatile dependency指的是非決定性、耦合環境的依賴,由於非決定性、耦合外部環境,會造成測試不穩定與setup困難等問題。

最常見的例子為程式碼直接使用時間、產生亂數,會造成測試在不同環境產生不一樣的結果,導致測試不穩定。

如下面程式範例,我們可以看到ServiceA直接耦合DateTime.Now,導致測試每次執行結果不一樣。

public class ServiceA
{
public doSomething()
{
createdOn = DateTime.Now;
// ....
}
}

為了解決上面的問題,我們會針對volatile dependency設計界面規格好讓我們能在測試中替換成Fake、Mock物件,如此一來便可以降低測試不穩定的問題。

如下面的測試案例,我們針對DateTime.Now設計DateTimeProvider介面,並且使用Stub來消除測試不穩定問題。

public interface DateTimeProvider
{
DateTime Now();
}

public class ServiceA
{
private DateTimeProvider dateTimeProvider;
public ServiceA(DateTimeProvider dateTimeProvider)
{
this.dateTimeProvider = dateTimeProvider;
}

public void doSomething()
{
createdOn = this.dateTimeProvider.Now();
// ...
}
}

public class DateTimeProviderStub: DateTimeProvider
{
public DateTime Now()
{
return new DateTime(); // return canned value
}
}

[Test]
public void doSomething()
{
var stub = new DateTimeProviderStub();

var a = new ServiceA(stub);

a.doSomething();

// assertion
}

封裝multiple implementations

所謂的multiple implementation是指有多個相似的行為,但實作上有很大的不同。

如下面程式範例,我們可以看到有英雄可以發動攻擊,但使用者需要用switch case來分辨英雄的職業,好讓他知道此英雄可以發動什麼樣的攻擊。

public class HeroAttack
{
public void Attack(Hero hero)
{
var isArcher = hero.getType() == 1;
var isWizard = hero.getType() == 2;
if (isArcher)
{
hero.shoot();
} else if (isWizard)
{
hero.magic();
}

}
}

為解決上面switch case混雜的問題,我們針對相似行為擁有不同實作的邏輯定義介面,使用英雄的客戶端只期望英雄攻擊,不需要知道他的職業與如何攻擊(射箭、魔法)。

public interface Hero
{
void Attack();
}

public class Archer: Hero
{
public void Attack()
{
// shoot()
}
}

public class Wizard: Hero
{
public void Attack()
{
// magic()
}
}
public class HeroAttack
{
public void Attack(Hero hero)
{
hero.Attack();
}
}

遵守dependency inversion principle

所謂的dependency inversion principle概念很簡單,就是讓高階模組與低階模組耦合抽象,並且抽象應該由高階模組定義與控管。

我還是說人話好了,為了讓大家方便理解,我們直接進入範例吧。

如下面程式範例,我們有個CreateUserUsecase,他接收建立學生所需要的資料,並建立學生物件同時使用MySqlRepo儲存剛建立的學生物件。

public class CreateStudentUsecase
{
private readonly MySqlRepo mySqlRepo;

CreateStudentUsecase(MySqlRepo mySqlRepo)
{
this.mySqlRepo = mySqlRepo;
}

public void Execute(CreateStudentInput input)
{
Student student = new Student(input.getName(), input.getAge());
this.mySqlRepo.add(student);

}
}

上述程式會有一些問題:

  • CreateStudentUsecase直接耦合MySqlRepo,沒辦法單元測試。
  • 當我們要替換資料庫時 (雖然現實不太可能),需要修改CreateStudentUsecase,替換成不同的Repo。
  • CreateStudentUsecase負責高階的指揮邏輯、MySqlRepo負責低階的persistence邏輯,我們不希望低階模組實作的改變 (ex: 換資料庫)去影響高階模組。
  • 高階模組通常代表使用者行為、低階模組通常代表實作上的決策,我們希望修改的原因是因為使用者行為的變動所導致,不是低階模組實作上的決策去影響高階模組。

綜合上述原因只有一段話: 商業邏輯比儲存邏輯高階,這兩個概念有不同變化的頻率,我們不希望商業邏輯被儲存邏輯影響,因此需要使用介面來隔離這兩個不同概念。

如下面我們定義介面來達到DIP的效果,從此當低階模組決策變動時,我們不用修改高階模組,只要替換不同實作的低階模組即可。反之,當高階模組需要新的API時,我們需要修改所有實作介面的低階模組,這樣的依賴關係才是我們想要的。

高階決策影響低階決策,而不是反過來。

public interface StudentRepo
{
void add(Student student);
}

public class MySqlRepo: StudentRepo
{
public void add(Student student)
{
// ...
}
}

public class AnotherRepo: StudentRepo
{
public void add(Student student)
{
// ...
}
}

public class CreateStudentUsecase
{
private readonly StudentRepo studentRepo
;

CreateStudentUsecase(StudentRepo studentRepo)
{
this.studentRepo = studentRepo;
}

public void Execute(CreateStudentInput input)
{
Student student = new Student(input.getName(), input.getAge());
this.studentRepo.add(student);
}
}

濫用介面

在實際專案開發中,只要介面的使用不是為了解決上述問題,大部分都可以稱之為濫用介面。

濫用介面造成的問題:

  • 增加無意義的間接層,每個間接層都應該要負擔某種程度的複雜度。
  • 因為無意義的介面跟類別變動頻率相同,所以當類別修改時、介面也需要修改,造成Cascade Change。只有當介面與類別有不一樣的變動頻率 (DIP變動、多種實作、Shared、Volatile),介面的使用才算合理。

我們要了解,介面也是一種元件,同時也需要人力去維護,如果有100個類別,並且不多思考就幫每個類別開介面就會產生200個元件,你想維護100個元件,還是200個元件呢?

最後,程式碼是種負資產,越少越好,用越少的元件達到一樣的效果才能幫公司、自己獲得最大利益。

結論

  • 了解介面是用來定義API規格,不帶上任何實作細節。
  • 了解介面解決shared dependency、volatile dependency、multiple implementations、dependency inversion principle等問題
  • 了解濫用介面造成的後果為增加無意義的間階層、過多元件、Cascade Change。

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

熱愛軟體開發、物件導向、自動化測試、框架設計,擅長透過軟工幫助企業達到成本最小化、價值最大化。單元測試企業內訓、導入請私訊信箱:t107598042@ntut.org.tw。

No responses yet