接口使用的最佳時機
1. 引言
接口在系統設計中,以及代碼重構優化中,是一個不可或缺的工具,能夠幫助我們寫出可擴展,可維護性更強的程序。
在本文,我們將介紹什么是接口,在此基礎上,通過一個例子來介紹接口的優點。但是接口也不是任何場景都可以隨意使用的,我們會介紹接口使用的常見場景,同時也介紹了接口濫用可能帶來的問題,以及一些接口濫用的特征,幫助我們及早發現接口濫用的情況。
2. 什么是接口
接口是一種工具,在識別出系統中變化部分時,幫助從系統模塊中抽取出變化的部分,從而保證系統的穩定性,可維護性和可擴展性。接口充當了一種契約或規范,規定了類或模塊應該提供的方法和行為,而不關心具體的實現細節。
接口通常用于面向對象編程語言中,如 Java 和 Go 等。在這些語言中,類可以實現一個或多個接口,并提供接口定義的方法的具體實現。通過使用接口,我們可以編寫更靈活、可維護和可擴展的代碼,同時將系統中的變化隔離開來。
接口的實現在不同的編程語言中可能會有所不同。以下簡單展示接口在Java 和 Go 語言中的示例。在Go 語言中,接口是一組方法簽名的集合。實現接口時,類不需要顯式聲明實現了哪個接口,只要一個類型實現了接口中的所有方法,就被視為實現了該接口。
// 定義一個接口
type Shape interface {
Area() float64
Perimeter() float64
}
// 實現接口的類型
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
return 2 * math.Pi * c.Radius
}
在Java 語言中,接口使用 interface 定義,同時包含所有的方法簽名。類需要通過使用 implements 關鍵字來實現接口,并提供接口中定義的方法的具體實現。
// 定義一個接口
interface Shape {
double area();
double perimeter();
}
// 實現接口的類
class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public double perimeter() {
return 2 * Math.PI * radius;
}
}
上面示例展示了Java 和 Go語言中接口的定義方式以及接口的實現方式,雖然具體實現方式各不相同,但它們都遵循了相似的概念,接口用于定義規范和契約,實現類則提供方法的具體實現來滿足接口的要求。
3. 接口的優點
在識別出系統變化的部分后,接口能夠幫助我們將系統中變化的部分抽取出來,基于此能夠降低了模塊間的耦合度,能夠提高代碼的可維護性和代碼的模塊化程度,有助于創建更靈活、可擴展和易于維護的代碼。下面我們通過一個簡單的例子來進行說明,詳細討論這些好處。
3.1 初始需求
假設我們在構建一個商城系統,其中一個相對復雜且重要的模塊為商品價格的計算,計算購物車中各種商品的總價格。價格計算過程相對復雜,包括了基礎價格、折扣、運費的計算,然后每一塊內容都會有比較復雜的業務邏輯。
基于此設計了OrderProcessor結構體,其中的CalculateTotalPrice 實現商品價格的計算,設計了ShippingCalculator 來計算運費,同時還設計DiscountCalculator 來計算商品的折扣信息,通過這幾部分的交互配合,共同來完成商家價格的計算。

下面我們通過一段代碼來展示上面的計算流程:
type OrderProcessor struct {
discountCalculator DiscountCalculator
taxCalculator TaxCalculator
}
// 計算總價格
func (tpc OrderProcessor) CalculateTotalPrice(products []Product) float64 {
total := 0.0
for _, item := range cart {
// 獲取商品的基礎價格
basePrice := item.BasePrice
// 獲取適用于商品的折扣
discount := tpc.discountCalculator.CalculateDiscount(item)
// 計算運費
shippingCost := tpc.shippingCalculator.CalculateShippingCost(item)
// 計算商品的最終價格(基礎價格 - 折扣 + 稅費 + 運費)
finalPrice := basePrice - discount + shippingCost
total += finalPrice
}
return total
}
// 運費計算
type ShippingCalculator struct {}
func (sc ShippingCalculator) CalculateShippingCost(product Product) float64 {
return 0.0
}
// 折扣計算
type DiscountCalculator struct {}
func (dc DiscountCalculator) CalculateDiscount(product Product) float64 {
return 0.0
}
如果這里需求沒有發生變化,這個流程可以很好得運轉下去。假設這里需要根據商品的類型來應用不同的折扣,之后要怎么支持呢,可以對變化的部分抽取出一個接口,也可以不抽取,都可以支持,我們比較一下沒有使用接口和使用接口的兩種實現方式的區別。
3.2 不抽象接口
首先是不使用接口的實現,這里我們直接在DiscountCalculator 中疊加邏輯,支持不同類型商品的折扣:
type DiscountCalculator struct{}
func (dc DiscountCalculator) CalculateDiscount(product Product) float64 {
// 根據商品類型應用不同的折扣邏輯
switch product.Type {
case "TypeA":
return dc.calculateTypeADiscount(product)
case "TypeB":
return dc.calculateTypeBDiscount(product)
default:
return dc.calculateDefaultDiscount(product)
}
}
func (dc DiscountCalculator) calculateTypeADiscount(product Product) float64 {
// 計算 TypeA 商品的折扣
return product.BasePrice * 0.1 // 例如,假設 TypeA 商品有 10% 的折扣
}
func (dc DiscountCalculator) calculateTypeBDiscount(product Product) float64 {
// 計算 TypeB 商品的折扣
return product.BasePrice * 0.15 // 例如,假設 TypeB 商品有 15% 的折扣
}
func (dc DiscountCalculator) calculateDefaultDiscount(product Product) float64 {
// 默認折扣邏輯,如果商品類型未匹配到其他情況
return product.BasePrice // 默認不打折
}
在這里,我們計算商品折扣,直接使用DiscountCalculator 來實現,根據商品的類型應用不同的折扣邏輯。這里使用了 switch 語句來確定應該應用哪種折扣。這種實現方式雖然在一個類中處理了所有的邏輯,但它可能會導致 DiscountCalculator 類變得龐大且難以維護,特別是當折扣邏輯變得更加復雜或需要頻繁更改時。
3.3 抽象接口
下面我們給出一個使用接口的實現,將不同的折扣邏輯封裝到不同的實現中,以下是使用接口的示例實現:
type OrderProcessor struct {
// 計算商品價格,直接依賴接口
discountCalculator DiscountCalculatorInterface
taxCalculator TaxCalculator
shippingCalculator ShippingCalculator
}
// 定義折扣計算器接口
type DiscountCalculatorInterface interface {
CalculateDiscount(product Product) float64
}
// 定義一個具體的折扣計算器實現
type TypeADiscountCalculator struct{}
func (dc TypeADiscountCalculator) CalculateDiscount(product Product) float64 {
// 計算 TypeA 商品的折扣
return product.BasePrice * 0.1 // 例如,假設 TypeA 商品有 10% 的折扣
}
// 定義另一個具體的折扣計算器實現
type TypeBDiscountCalculator struct{}
func (dc TypeBDiscountCalculator) CalculateDiscount(product Product) float64 {
// 計算 TypeB 商品的折扣
return product.BasePrice * 0.15 // 例如,假設 TypeB 商品有 15% 的折扣
}
上述示例中,我們定義了一個 DiscountCalculatorInterface 接口以及兩個不同的折扣計算器實現:TypeADiscountCalculator 和 TypeBDiscountCalculator。 OrderProcessorWithInterface 結構體依賴于 DiscountCalculatorInterface 接口,這使得我們可以根據商品的類型輕松切換不同的折扣策略。
3.4 實現對比
下面我們通過比較上面兩種實現,探討在識別出系統的變化后,讓系統依賴一個接口,相對于依賴一個具體類的優點。
首先是對于系統的可擴展性,假設現在需要支持新的類型的折扣,如果引入了接口,只需實現新的折扣計算器并滿足相同的接口要求,就可以完成預期的功能。如果我們還是依賴一個具體的類,此時要么在DiscountCalculator 中通過if...else 疊加業務邏輯,相對于接口的引入,代碼的可擴展性相比接口的使用就大大降低了。
對于系統的可測試性,如果是定義了接口,我們不需要驗證其他DiscountCalculator 的實現,只需要驗證當前新增的處理器即可。如果是依賴一個具體的類,此時如果進行測試,就需要對所有分支進行覆蓋,很容易疏漏。其次,我們也可以輕松模擬不同的折扣計算器實現,驗證 OrderProcessor 的行為。
還有代碼可讀性和可維護性,接口提供了一種清晰的契約,我們可以將DiscountCalculator當作一個小的模塊,OrderProcessor通過接口與該模塊進行交互,這使得代碼更易于理解和維護,因為接口充當了文檔,明確了每個模塊的預期行為。
最后,通過接口的定義,OrderProcessor將不再依賴具體的類,而是依賴一個抽象層,降低了系統的耦合度,不再需要關注折扣的計算,讓折扣的計算變得更加靈活。
通過以上的討論,我們認為如果識別出了系統的變化后,該模塊可能存在多個不同方向的變化,應該盡量抽取出一個接口,這樣能夠提高系統的可擴展性,可測試性,代碼的可讀性以及可維護性都有一定程度的提高。
4. 何時使用接口
接口可以給我們帶來一系列的優點,如松耦合,隔絕變化,提高代碼的可擴展性等,但是濫用接口的話,反而會引入不必要的復雜性,并增加代碼的理解和維護成本。
有一個核心的準則,盡量支持依賴具體的類,而不是抽取接口,不要為了使用接口而創造不必要的抽象,這可能會使代碼變得混亂和難以理解。
如果真的使用接口,應該確定其在系統設計中起到促進松耦合和可維護性的作用,而不是增加復雜性。要在合適的場景下使用接口,并考慮接口設計的清晰性和可維護性。下面基于此,我們討論一些接口可能適用的場景。
4.1 系統中存在變化部分
系統中存在變化的部分是使用接口的最核心場景之一 。 使用接口可以將這些變化部分從系統的其他部分隔離開來,使系統更具靈活性和可維護性。這種設計允許我們將變化的部分抽取為一個單獨的模塊,在變化時,只需要對該模塊進行修改,而不必修改整個系統。接口充當了變化部分的契約,使不同的實現可以輕松地替換或添加,從而適應新的需求或變化的情況。
比如系統需要向用戶發送郵件,可能不同的運營商提供了不同的API,然后我們系統中需要支持多個不同的運營商,在不同場景下使用不同運營商的接口。
此時我們通過定義接口,系統通過與該接口進行交互即可,而不需要關心底層的實現細節。如果將來要添加新的郵件服務提供商,只需創建一個新的類并實現接口即可,而不需要修改現有的代碼。
這種方式使系統的變化部分與其余部分隔離開來,提高了系統的可維護性和可擴展性。此外,通過使用接口,我們可以創建模擬郵件發送器來驗證系統的行為,更容易進行單元測試。
4.2 類庫的可配置性
類庫對外擴展和提供可配置性也是接口使用的重要場景之一。當開發一個類庫或框架時,為了讓用戶能夠輕松地擴展和自定義其行為,可以通過接口提供一組可配置的擴展點。這些擴展點允許用戶提供自己的實現,以適應其特定需求。
舉例來說,一個日志庫可以定義一個接口 Logger,并允許用戶提供他們自己的 Logger 實現。用戶可以選擇使用默認的日志記錄實現,也可以創建一個自定義的實現,以將日志信息發送到不同的地方(例如文件、數據庫、遠程服務器等)。這種可配置性使用戶能夠根據其項目的要求自由選擇和調整庫的行為。
通過提供接口和可配置性,類庫或框架可以更具通用性和靈活性,使用戶能夠根據其特定的用例和需求來定制和擴展庫的功能,從而提高了庫的可用性和適用性。這種模塊化的設計方式有助于減少代碼的重復,促進了代碼的復用,同時也提供了更好的可擴展性和可維護性。
4.3 模塊間的交互
系統劃分不同模塊并使用接口來進行交互也是一個重要的場景。當將系統劃分為不同的模塊或組件時,使用接口定義模塊之間的契約和互動方式是一種良好的實踐。每個模塊可以實現所需的接口,并與其他模塊進行交互,這使得模塊之間的界限更加清晰,易于理解和維護。
使用接口可以降低模塊之間的耦合度。這意味著每個模塊不需要關心其他模塊的具體實現細節,只需要遵循接口定義的契約。這種模塊化的設計方式有助于將復雜的系統拆分為更小、更易管理的部分,并降低了系統開發和維護的復雜性。
4.4 單元測試的使用
在需要解除一個龐大的外部系統的依賴時。有時候我們并不是需要多個選擇,而是某個外部依賴過重,我們測試或其他場景可能會選擇 mock 一個外部依賴,以便降低測試系統的依賴。
比如依賴多個外部rpc,單元測試時需要屏蔽外部的依賴,此時就比較有必要使用接口,通過框架生成一個mock的實現,從而解除對外部的依賴。
5. 潛在的誤用和濫用
5.1 接口濫用帶來的問題
雖然接口在合適的場景中非常有用,但濫用接口可能會導致代碼變得復雜、難以理解和難以維護。引入過多的接口可能會增加系統的復雜性,使代碼難以理解。每個接口都需要額外的抽象和實現,這可能不是必要的。其次使用接口有時會引入額外的性能開銷,因為運行時需要進行接口解析。在性能敏感的應用中,這可能是一個問題。
最重要的一個問題,接口的目標是提供一種通用的抽象,給系統提供可配置項,但有時候過度一般化可能會導致不必要的復雜性。在某些情況下,直接使用具體的類可能更加簡單和清晰。
我們應該在確保接口是必要的情況下使用它們,以避免不必要的復雜性和耦合。接口的設計應該基于真正的需求和系統架構,而不是僅僅為了使用接口而使用接口。
5.2 如何識別接口是否濫用
對于識別接口是否濫用,可以通過下面幾個方面來檢查,如果滿足了下面的某一個條件,此時大概率就出現了接口濫用的情況。
是否過早的抽象,在引入該接口時,系統中是否足夠的不同實現來正當地支持這些接口。如果沒有的話,此時大概率過早接口的引入,增加了復雜性,而不帶來真正的好處。
是否所有類之間引入接口,無論是否有必要,在這種情況下,接口的數量可能會急劇增加,導致代碼難以理解和維護,可能還是存在一定濫用的情況。
如果接口經常發生變化,那么實現這些接口的類可能需要頻繁地進行修改,這會增加維護的難度,此時要么接口是不必要的,要么接口的設計是不合理的,需要重新設計。
總的來說, 我們需要確保真正需要接口時才引入它們。應該謹慎考慮每個接口的設計,確保它們具有明確的用途(如隔絕變化,模塊間交互的契約,方便單元測試),并且不引入不必要的復雜性。根據實際需求和系統架構來合理地使用接口,而不是為了使用接口而使用接口。
6. 總結
在本文,我們介紹了什么是接口,接口是一種契約,一種協議,用于模塊間的交互。
在此基礎上,通過一個例子來介紹接口的優點,了解到接口可以提高代碼的可擴展性,可維護性,以及降低系統之間的耦合度。
但是接口也不是任何場景都可以隨意使用的,我們會介紹接口使用的常見場景,包括隔絕系統的變化部分,以及一些類庫設計時對外提供配置項的場景。
最后我們還介紹了接口濫用可能帶來的問題,以及一些比較明顯的特征,幫助我們更早識別出系統設計的壞味道。
基于此,完成了對接口的完整介紹,希望對你有所幫助。

浙公網安備 33010602011771號