為什么我們要基于接口而非實現(xiàn)編程?
如何解讀原則中的“接口”二字?
是否需要為每個類定義接口?
針對以上問題,下面我們來一個一個的聊一聊。
在軟件開發(fā)領(lǐng)域,遵循“面向接口編程而非面向?qū)崿F(xiàn)編程”的原則是提升代碼質(zhì)量的關(guān)鍵策略。這一原則強調(diào)的是,應(yīng)當(dāng)依賴于定義良好的接口,而不是具體的實現(xiàn)邏輯。這樣做的目的是為了提高代碼的靈活性和可維護性,降低因?qū)崿F(xiàn)變化而導(dǎo)致的修改成本。
為了讓你理解透徹,并真正掌握這條原則如何應(yīng)用,今天,我會結(jié)合一個有關(guān)圖片存儲的實戰(zhàn)案例來講解。除此之外,這條原則還很容易被過度應(yīng)用,比如為每一個實現(xiàn)類都定義對應(yīng)的接口。針對這類問題,我也會告訴你如何來做權(quán)衡,怎樣恰到好處地應(yīng)用這條原則。“基于接口而非實現(xiàn)編程”這條原則的英文描述是:“Program to an interface, not an implementation”。我們理解這條原則的時候,千萬不要一開始就與具體的編程語言掛鉤,局限在編程語言的“接口”語法中(比如 Java 中的 interface 接口語法)。這條原則最早出現(xiàn)于 1994 年 GoF 的《設(shè)計模式》這本書,它先于很多編程語言而誕生(比如 Java 語言),是一條比較抽象、泛化的設(shè)計思想。要真正領(lǐng)會這一原則,關(guān)鍵在于理解“接口”的含義。從本質(zhì)上來說,“接口”是一組規(guī)范或協(xié)議,它定義了功能提供者與使用者之間的交互方式。在不同的應(yīng)用背景下,“接口”可以有不同的含義,例如服務(wù)端與客戶端之間的通信接口、類庫提供的API,或者通信協(xié)議等。這些理解更偏向于高層次和抽象層面,與具體的編碼活動有一定的距離。然而,在具體的編碼實踐中,“依托抽象接口而非具體實現(xiàn)進行編程”中的“接口”可以被理解為編程語言中的接口或抽象類。正如我們之前提到的,這一原則能夠有效提升代碼品質(zhì),這是因為它實現(xiàn)了接口與實現(xiàn)的分離,將不穩(wěn)定的實現(xiàn)細(xì)節(jié)封裝起來,而對外提供穩(wěn)定的接口。當(dāng)實現(xiàn)發(fā)生變化時,依賴于接口的上層系統(tǒng)代碼基本上不需要做出修改,從而降低了系統(tǒng)的耦合度,增強了系統(tǒng)的可擴展性。實際上,“依托抽象接口而非具體實現(xiàn)進行編程”這一原則也可以表述為“依托抽象而非具體實現(xiàn)進行編程”。后者更能體現(xiàn)這一原則的核心意圖。在軟件開發(fā)過程中,需求的不斷變化是一個主要挑戰(zhàn),也是衡量代碼設(shè)計優(yōu)劣的一個重要標(biāo)準(zhǔn)。越高層次、越抽象、越不依賴于某一具體實現(xiàn)的設(shè)計,越能夠提升代碼的適應(yīng)性和靈活性,以應(yīng)對未來的需求變化。優(yōu)秀的代碼設(shè)計不僅能夠滿足當(dāng)前的需求,而且在將來需求發(fā)生變化時,也能夠在不破壞現(xiàn)有代碼結(jié)構(gòu)的前提下靈活適應(yīng)。而抽象是提升代碼的可擴展性、靈活性和可維護性的有效手段之一。為了將這一原則應(yīng)用到實際場景中,我們可以通過一個具體的案例來進行說明。假設(shè)在我們的系統(tǒng)中,有大量的圖片處理和存儲業(yè)務(wù)邏輯。處理后的圖片被上傳至阿里云。為了代碼的復(fù)用性,我們封裝了圖片存儲相關(guān)的邏輯,并創(chuàng)建了一個統(tǒng)一的AliyunImageStore類供整個系統(tǒng)使用。具體的代碼實現(xiàn)如下:public class AliyunImageStore {
// ...省略屬性、構(gòu)造函數(shù)等...
public void createBucketIfNotExisting(String bucketName) {
// ...創(chuàng)建存儲桶的代碼邏輯...
// ...失敗時拋出異常...
}
public String generateAccessToken() {
// ...生成訪問令牌...
}
public String uploadToAliyun(Image image, String bucketName, String accessToken) {
// ...上傳圖片至阿里云...
// ...返回圖片在阿里云上的地址(url)...
}
public Image downloadFromAliyun(String url, String accessToken) {
// ...從阿里云下載圖片...
}
}
// AliyunImageStore類的使用示例
public class ImageProcessingJob {
private static final String BUCKET_NAME = "ai_images_bucket";
// ...省略其他無關(guān)代碼...
public void process() {
Image image = ...; // 處理圖片,并封裝為Image對象
AliyunImageStore imageStore = new AliyunImageStore(/* 省略參數(shù) */);
imageStore.createBucketIfNotExisting(BUCKET_NAME);
String accessToken = imageStore.generateAccessToken();
imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken);
}
}
整個上傳流程包括三個步驟:創(chuàng)建存儲桶(可以理解為一個存儲目錄)、生成訪問令牌、攜帶訪問令牌上傳圖片至指定的存儲桶中。代碼實現(xiàn)簡潔明了,類中的方法定義清晰,使用起來也很方便,看起來似乎沒有問題,完全符合我們將圖片存儲在阿里云的業(yè)務(wù)需求。然而,在軟件開發(fā)的世界里,唯一不變的就是變化本身。隨著時間的推移,我們可能會建立自己的私有云,不再使用阿里云存儲圖片,而是將圖片存儲在自己的私有云上。面對這樣的需求變化,我們應(yīng)該如何調(diào)整代碼呢?我們需要重新設(shè)計實現(xiàn)一個存儲圖片到私有云的 PrivateImageStore 類,并用它替換掉項目中所有的 AliyunImageStore 類對象。這樣的修改聽起來并不復(fù)雜,只是簡單替換而已,對整個代碼的改動并不大。不過,我們經(jīng)常說,“細(xì)節(jié)中的魔鬼”。這句話在軟件開發(fā)中特別適用。實際上,剛剛的設(shè)計實現(xiàn)方式,就隱藏了很多容易出問題的“魔鬼細(xì)節(jié)”,我們一塊來看看都有哪些。新的 PrivateImageStore 類需要設(shè)計實現(xiàn)哪些方法,才能在盡量最小化代碼修改的情況下,替換掉 AliyunImageStore 類呢?這就要求我們必須將 AliyunImageStore 類中所定義的所有 public 方法,在 PrivateImageStore 類中都逐一定義并重新實現(xiàn)一遍。而這樣做就會存在一些問題,我總結(jié)了下面兩點。首先,AliyunImageStore 類中有些函數(shù)命名暴露了實現(xiàn)細(xì)節(jié)怎么辦?。
比如,uploadToAliyun() 和 downloadFromAliyun()。如果開發(fā)這個功能的同事沒有接口意識、抽象思維,那這種暴露實現(xiàn)細(xì)節(jié)的命名方式就不足為奇了,畢竟最初我們只考慮將圖片存儲在阿里云上。而我們把這種包含“aliyun”字眼的方法,照抄到 PrivateImageStore 類中,顯然是不合適的。如果我們在新類中重新命名 uploadToAliyun()、downloadFromAliyun() 這些方法,那就意味著,我們要修改項目中所有使用到這兩個方法的代碼,代碼修改量可能就會很大。
其次,將圖片存儲到阿里云的流程,跟存儲到私有云的流程,可能并不是完全一致的怎么辦?
比如,阿里云的圖片上傳和下載的過程中,需要生產(chǎn) access token,而私有云不需要 access token。一方面,AliyunImageStore 中定義的 generateAccessToken() 方法不能照抄到 PrivateImageStore 中;另一方面,我們在使用 AliyunImageStore 上傳、下載圖片的時候,代碼中用到了 generateAccessToken() 方法,如果要改為私有云的上傳下載流程,這些代碼都需要做調(diào)整。那這兩個問題該如何解決呢?解決這個問題的根本方法就是,在編寫代碼的時候,要遵從“基于接口而非實現(xiàn)編程”的原則,具體來講,我們需要做到下面這 3 點。
- 函數(shù)的命名不能暴露任何實現(xiàn)細(xì)節(jié)。比如,前面提到的 uploadToAliyun() 就不符合要求,應(yīng)該改為去掉 aliyun 這樣的字眼,改為更加抽象的命名方式,比如:upload()。
- 封裝具體的實現(xiàn)細(xì)節(jié)。比如,跟阿里云相關(guān)的特殊上傳(或下載)流程不應(yīng)該暴露給調(diào)用者。我們對上傳(或下載)流程進行封裝,對外提供一個包裹所有上傳(或下載)細(xì)節(jié)的方法,給調(diào)用者使用。
- 為實現(xiàn)類定義抽象的接口。具體的實現(xiàn)類都依賴統(tǒng)一的接口定義,遵從一致的上傳功能協(xié)議。使用者依賴接口,而不是具體的實現(xiàn)類來編程。
public interface ImageStore {
String upload(Image image, String bucketName);
Image download(String url);
}
public class AliyunImageStore implements ImageStore {
// ...省略屬性、構(gòu)造函數(shù)等...
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
String accessToken = generateAccessToken();
// ...上傳圖片至阿里云...
// ...返回圖片在阿里云上的地址(url)...
}
public Image download(String url) {
String accessToken = generateAccessToken();
// ...從阿里云下載圖片...
}
private void createBucketIfNotExisting(String bucketName) {
// ...創(chuàng)建存儲桶...
// ...失敗時拋出異常...
}
private String generateAccessToken() {
// ...生成訪問令牌...
}
}
// 上傳下載流程變化:私有云不需要訪問令牌
public class PrivateImageStore implements ImageStore {
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
// ...上傳圖片至私有云...
// ...返回圖片的url...
}
public Image download(String url) {
// ...從私有云下載圖片...
}
private void createBucketIfNotExisting(String bucketName) {
// ...創(chuàng)建存儲桶...
// ...失敗時拋出異常...
}
}
// ImageStore的使用示例
public class ImageProcessingJob {
private static final String BUCKET_NAME = "ai_images_bucket";
// ...省略其他無關(guān)代碼...
public void process() {
Image image = ...; // 處理圖片,并封裝為Image對象
ImageStore imageStore = new PrivateImageStore(...);
imagestore.upload(image, BUCKET_NAME);
}
}
除此之外,很多人在定義接口的時候,希望通過實現(xiàn)類來反推接口的定義。先把實現(xiàn)類寫好,然后看實現(xiàn)類中有哪些方法,照抄到接口定義中。如果按照這種思考方式,就有可能導(dǎo)致接口定義不夠抽象,依賴具體的實現(xiàn)。這樣的接口設(shè)計就沒有意義了。不過,如果你覺得這種思考方式更加順暢,那也沒問題,只是將實現(xiàn)類的方法搬移到接口定義中的時候,要有選擇性的搬移,不要將跟具體實現(xiàn)相關(guān)的方法搬移到接口中,比如 AliyunImageStore 中的 generateAccessToken() 方法。總結(jié)一下,我們在做軟件開發(fā)的時候,一定要有抽象意識、封裝意識、接口意識。在定義接口的時候,不要暴露任何實現(xiàn)細(xì)節(jié)。接口的定義只表明做什么,而不是怎么做。而且,在設(shè)計接口的時候,我們要多思考一下,這樣的接口設(shè)計是否足夠通用,是否能夠做到在替換具體的接口實現(xiàn)的時候,不需要任何接口定義的改動。根據(jù)以上內(nèi)容,你可能會有這樣的疑問:為了滿足這條原則,我是不是需要給每個實現(xiàn)類都定義對應(yīng)的接口呢?在開發(fā)的時候,是不是任何代碼都要只依賴接口,完全不依賴實現(xiàn)編程呢?
- 做任何事情都要講求一個“度”,過度使用這條原則,非得給每個類都定義接口,接口滿天飛,也會導(dǎo)致不必要的開發(fā)負(fù)擔(dān)。至于什么時候,該為某個類定義接口,實現(xiàn)基于接口的編程,什么時候不需要定義接口,直接使用實現(xiàn)類編程,我們做權(quán)衡的根本依據(jù),還是要回歸到設(shè)計原則誕生的初衷上來。只要搞清楚了這條原則是為了解決什么樣的問題而產(chǎn)生的,你就會發(fā)現(xiàn),很多之前模棱兩可的問題,都會變得豁然開朗。
- 前面我們也提到,這條原則的設(shè)計初衷是,將接口和實現(xiàn)相分離,封裝不穩(wěn)定的實現(xiàn),暴露穩(wěn)定的接口。上游系統(tǒng)面向接口而非實現(xiàn)編程,不依賴不穩(wěn)定的實現(xiàn)細(xì)節(jié),這樣當(dāng)實現(xiàn)發(fā)生變化的時候,上游系統(tǒng)的代碼基本上不需要做改動,以此來降低代碼間的耦合性,提高代碼的擴展性。
- 從這個設(shè)計初衷上來看,如果在我們的業(yè)務(wù)場景中,某個功能只有一種實現(xiàn)方式,未來也不可能被其他實現(xiàn)方式替換,那我們就沒有必要為其設(shè)計接口,也沒有必要基于接口編程,直接使用實現(xiàn)類就可以了。
- 除此之外,越是不穩(wěn)定的系統(tǒng),我們越是要在代碼的擴展性、維護性上下功夫。相反,如果某個系統(tǒng)特別穩(wěn)定,在開發(fā)完之后,基本上不需要做維護,那我們就沒有必要為其擴展性,投入不必要的開發(fā)時間。
- “基于接口而非實現(xiàn)編程”,這條原則的另一個表述方式,是“基于抽象而非實現(xiàn)編程”。后者的表述方式其實更能體現(xiàn)這條原則的設(shè)計初衷。我們在做軟件開發(fā)的時候,一定要有抽象意識、封裝意識、接口意識。越抽象、越頂層、越脫離具體某一實現(xiàn)的設(shè)計,越能提高代碼的靈活性、擴展性、可維護性。
- 我們在定義接口的時候,一方面,命名要足夠通用,不能包含跟具體實現(xiàn)相關(guān)的字眼;另一方面,與特定實現(xiàn)有關(guān)的方法不要定義在接口中。
- “基于接口而非實現(xiàn)編程”這條原則,不僅僅可以指導(dǎo)非常細(xì)節(jié)的編程開發(fā),還能指導(dǎo)更加上層的架構(gòu)設(shè)計、系統(tǒng)設(shè)計等。比如,服務(wù)端與客戶端之間的“接口”設(shè)計、類庫的“接口”設(shè)計。
該文章在 2024/4/19 18:03:39 編輯過