:從一次訂單服務(wù)重構(gòu)看胖接口拆分與設(shè)計邊界)
1. 一次改一個方法炸一片調(diào)用方的重構(gòu)現(xiàn)場這事發(fā)生在我前司一個中臺服務(wù)里印象特別深。當(dāng)時有個OrderService接口里面塞了二十多個方法從創(chuàng)建訂單、取消訂單、支付、退款到查詢明細、導(dǎo)出報表再到運營后臺的審核、風(fēng)控標記全在一個接口里。我們十幾個團隊各自截取自己需要的部分在調(diào)用看起來共享接口很規(guī)范直到有人改了其中一個方法的簽名。那個方法叫queryOrderItems(Long orderId)本來只給訂單詳情頁用。改的人想給它加一個分頁參數(shù)于是把簽名改成了queryOrderItems(Long orderId, int page, int size)。結(jié)果一編譯全公司三十多處調(diào)用同時報錯因為所有依賴OrderService的代碼都直接看到了這個變化。其中至少七八處只是拿這個接口做依賴注入實際上根本沒用過queryOrderItems。他們被迫跟著改或者臨時包一層適配。這就是接口隔離原則Interface Segregation PrincipleISP要解決的問題。它說得很直白客戶端不應(yīng)該被迫依賴它不使用的方法。當(dāng)年Robert C. Martin提出這一條的時候針對的是胖接口帶來的耦合放到今天反而更值得重視——我們有了Spring Boot、微服務(wù)、各種RPC框架接口的粒度直接決定了改一個東西要不要拉動一群人開會。如果你正在學(xué)Java設(shè)計模式或者準備設(shè)計模式期末考試這條原則很多人會背定義但真正理解它為什么存在、什么時候該下手拆、拆到什么程度為止才是筆試和面試里拉開差距的地方。這篇文章我把自己的踩坑經(jīng)歷、拆分過程和判斷標準完整寫出來按我的習(xí)慣先講事故再講原理最后給能直接抄的實踐。1.1 事故背后的代碼結(jié)構(gòu)當(dāng)時那個OrderService大概長這樣public interface OrderService { void createOrder(Order order); void cancelOrder(Long orderId); void payOrder(Long orderId, PaymentMethod method); void refundOrder(Long orderId); ListOrderItem queryOrderItems(Long orderId); void exportOrders(ExportRequest request); void auditOrder(Long orderId); void markFraudOrder(Long orderId); }注意看這里payOrder和auditOrder完全不是一種性質(zhì)的方法。前者是C端用戶在交易鏈路里的核心動作每秒可能幾百次調(diào)用后者是運營在后臺偶爾點擊的管理操作一天調(diào)用幾十次。它們的變化頻率不同、調(diào)用方不同、出問題的影響面也不同卻因為都和訂單有關(guān)被塞進了同一個接口。網(wǎng)上搜接口隔離原則的時候最常見的描述是用多個專門的接口優(yōu)于一個臃腫的總接口這句話沒錯但光記住這句話沒用。你得能識別出哪些方法屬于同一種變化哪些方法只是碰巧屬于同一個業(yè)務(wù)領(lǐng)域。前者應(yīng)該放一起后者應(yīng)該拆開。1.2 為什么大家一起改會變成災(zāi)難回到那次事故。讓我把事情說得更細改方法的人只通知了自己團隊因為他覺得我就改一個查詢方法跟別人有什么關(guān)系。但Java接口的編譯期強約束讓所有引用它的類都暴露了。你以為是局部修改編譯器告訴你這是全量修改。這種問題在動態(tài)語言里不一定會暴露但在強類型語言里會被放大。所以你會發(fā)現(xiàn)接口隔離原則在Java生態(tài)里討論得特別多因為語言特性決定了接口一旦變更所有依賴方都要感知。你沒法裝作沒看見。更深層的問題是這種大接口會讓團隊失去安全修改的勇氣。后來我們統(tǒng)計了一下導(dǎo)致那次重構(gòu)的原因很簡單詳情頁的數(shù)據(jù)量漲了不分頁不行。可就是這樣一個合理的需求因為接口設(shè)計不合理演變成了跨團隊協(xié)同問題。我用一句話總結(jié)當(dāng)時的局面接口越大改接口的顧慮就越多顧慮越多大家就越傾向于不打理接口結(jié)果接口越來越大。這是個惡性循環(huán)。2. 接口隔離原則到底在隔離什么先糾正三個常見誤解接口隔離原則的原文是Clients should not be forced to depend on methods they do not use.直譯過來就是客戶端不應(yīng)被迫依賴它們不使用的方法。這句話關(guān)鍵在客戶端三個字。它不是站在實現(xiàn)類的角度說你實現(xiàn)了很多方法所以你要拆而是站在調(diào)用方的角度說你只用到其中兩個方法我就只讓你看到這兩個方法。隔離的不是方法而是依賴關(guān)系。2.1 誤解一接口隔離就是把大接口拆成小接口很多文章只講了一半。拆成小接口只是手段真正的目的是讓不同客戶端之間的依賴互不可見。如果只是把二十個方法拆成五個接口但所有客戶端依然注入具體的實現(xiàn)類那隔離效果等于零因為客戶端還是能看到實現(xiàn)類的全部方法。正確的做法是讓客戶端面向自己需要的接口編程。什么時候拆、拆多細取決于客戶端有沒有被迫依賴它不使用的方法。如果沒有這種被迫一個接口里有十個方法也不算違反ISP。舉個例子一個通訊錄工具類內(nèi)部封裝了本地存儲和網(wǎng)絡(luò)同步對外暴露了addContact、deleteContact、syncContacts三個方法。假設(shè)只有一個頁面使用它而且三個方法都會用到那它雖然是三個功能也不違反ISP。反過來如果有一個模塊只做網(wǎng)絡(luò)同步卻被強制看到了本地數(shù)據(jù)庫的增刪改那就算只有一個方法也是違反ISP。2.2 誤解二接口隔離就是單一職責(zé)原則這是我在面試里最常聽到的混淆。單一職責(zé)原則SRP針對的是類和模塊說的是一個類應(yīng)該只有一個引起它變化的原因解決的是內(nèi)聚性問題。接口隔離原則針對的是客戶端與契約之間的關(guān)系解決的是耦合性問題。兩者有關(guān)系但不完全是一回事。一個類可能已經(jīng)符合SRP內(nèi)部只處理訂單金額計算但它暴露的接口可能非常寬——比如把金額計算、日志、緩存全部塞在一個接口方法里反過來一個類可能同時干了訂單校驗和庫存扣減兩件事不太符合SRP但它對客戶端暴露的是兩個小接口客戶端按需依賴這在ISP層面是達標的。更準確地說SRP關(guān)注的是為什么這個類會變ISP關(guān)注的是客戶端看到了什么。2.3 誤解三接口隔離只針對Java的interface關(guān)鍵字接口隔離里面的接口指的是任何形式的調(diào)用契約不只是Java里的interface。一個開放給前端調(diào)用的REST API路徑集合是一種接口一個Spring的Service類對外公開的public方法是一種接口一個消息隊列里的Topic及消息結(jié)構(gòu)也可以理解為一種接口。接口隔離的核心思想是你對外暴露的能力應(yīng)該是按使用場景分組而不是按內(nèi)部實現(xiàn)分組。這一點在微服務(wù)架構(gòu)里尤其明顯。很多人做微服務(wù)拆分時按數(shù)據(jù)庫表來切服務(wù)訂單庫就拆出訂單服務(wù)用戶庫里就拆出用戶服務(wù)。結(jié)果訂單服務(wù)暴露出來的接口五花八門既有C端查詢又有后臺審核還有定時任務(wù)用到的批量掃描。這就是把接口隔離這個理念忘到了腦后——服務(wù)邊界是有了但契約粒度仍然混亂。3. 用訂單系統(tǒng)做一次完整拆分從胖接口到按調(diào)用方劃分的獨立契約光講理論沒意思我把前面提到的訂單系統(tǒng)完整拆一遍。你把這個過程跑通比看十篇定義都管用。3.1 原始設(shè)計到底問題出在哪假設(shè)訂單系統(tǒng)的使用方主要有四類C端交易鏈路下單、支付、退款、查訂單運營后臺審核異常訂單、導(dǎo)出報表數(shù)據(jù)同步任務(wù)批量拉取訂單定時同步到數(shù)據(jù)倉庫客服工作臺查詢訂單狀態(tài)、修改備注原始設(shè)計把所有這些都放進了OrderService?,F(xiàn)在我要做接口隔離不是直接把OrderService按訂單、支付、審核這種業(yè)務(wù)切片來拆而是先問一個問題哪幾組方法的調(diào)用方集合高度重疊而且變化頻率接近答案很明顯。C端用戶永遠不會調(diào)auditOrder運營后臺不應(yīng)該觸發(fā)payOrder數(shù)據(jù)同步任務(wù)只需要批量查詢而完全不需要任何寫操作。所以按照調(diào)用方來分至少可以拆成四組。3.2 拆分按調(diào)用方依賴的最小集合切分我是這樣拆的public interface OrderWriteService { void createOrder(Order order); void cancelOrder(Long orderId); } public interface OrderPaymentService { void payOrder(Long orderId, PaymentMethod method); void refundOrder(Long orderId); } public interface OrderQueryService { OrderDetailVO getOrderDetail(Long orderId); ListOrderItem queryOrderItems(Long orderId, int page, int size); void exportOrders(ExportRequest request); } public interface OrderAdminService { void auditOrder(Long orderId, AuditDecision decision); void markFraudOrder(Long orderId); }然后具體的訂單服務(wù)實現(xiàn)類依然可以一個類頂四個接口Service public class OrderServiceImpl implements OrderWriteService, OrderPaymentService, OrderQueryService, OrderAdminService { // 每個方法的具體實現(xiàn)不變 }注意這個細節(jié)實現(xiàn)類的數(shù)量沒有發(fā)生變化變的是客戶端能看到的視角。同樣一個業(yè)務(wù)對象在C端控制器里注入的是OrderWriteService和OrderPaymentService在運營后臺控制器里注入的是OrderAdminService在數(shù)據(jù)導(dǎo)出模塊里注入的是OrderQueryService。RestController RequestMapping(/api/orders) public class OrderController { private final OrderWriteService orderWriteService; private final OrderPaymentService orderPaymentService; public OrderController(OrderWriteService orderWriteService, OrderPaymentService orderPaymentService) { this.orderWriteService orderWriteService; this.orderPaymentService orderPaymentService; } // 這里只能調(diào)用下單、取消、支付、退款看不到審核和導(dǎo)出方法 }將來如果有人改了queryOrderItems的分頁邏輯C端控制器完全無感因為它的依賴里根本沒有OrderQueryService。這就是隔離帶來的直接效果。3.3 拆分后測試和Mock發(fā)生了什么變化這個收益很多人沒意識到但實際價值非常大。在拆分之前單元測試里要mock一個OrderService大概長這樣OrderService orderService mock(OrderService.class); when(orderService.payOrder(anyLong(), any())).thenReturn(paymentResult);看起來也沒啥但你要知道OrderService有二十多個方法每新增一個方法所有測試樁類都要跟著適配。尤其在用Mockito做mock()的時候雖然Mockito不強制實現(xiàn)所有方法但如果代碼里用了Spring的MockBean并且測試上下文會去創(chuàng)建實現(xiàn)類新方法一旦依賴了未初始化的組件測試啟動就會爆。拆分之后mock對象變得非常小OrderPaymentService paymentService mock(OrderPaymentService.class); when(paymentService.payOrder(anyLong(), any())).thenReturn(paymentResult);測試需要構(gòu)造的前置條件更少理解成本更低新人接手時能更快搞清楚這個測試到底在驗證什么。接口越小測試樁越小測試樁越小寫測試的意愿就越強。這是接口隔離原則帶來的一個很現(xiàn)實的紅利。4. 接口隔離真正帶來的三筆收益可控的變更、可驗證的行為、可并行的團隊很多人覺得接口隔離是個代碼潔癖層面的原則不遵守也不影響系統(tǒng)運行。這是大錯特錯。我在實際項目里體會到的收益可以歸納成三筆賬。4.1 變更影響面被鎖死在接口邊界內(nèi)軟件維護成本的大頭在變更不在初始開發(fā)。一個接口里方法越多涉及的業(yè)務(wù)領(lǐng)域越多它被改動的概率就越大改動時波及的范圍也越大。拿我經(jīng)歷過的一個例子來說。有一次產(chǎn)品要求給訂單詳情頁加一個預(yù)計送達時間這本身只涉及查詢鏈路。由于我們把OrderQueryService單獨拆了出來后端只需要在OrderQueryService和對應(yīng)實現(xiàn)類里動刀C端控制器的依賴注入都不需要改。部署的時候我只需要確認查詢服務(wù)兼容舊接口其他團隊完全不用跟著發(fā)版。如果你把這一點放到微服務(wù)之間就更能體會了服務(wù)A給服務(wù)B提供的接口如果是一個大而全的訂單服務(wù)接口B只是在用其中兩個字段但A因為自己的需求擴展了接口入?yún)⒒蚍祷刂礏就要被迫回歸測試甚至因為序列化變化直接報錯。這不是技術(shù)問題是契約設(shè)計問題。4.2 mock、stub變小帶來測試意愿提升第二筆收益是測試體驗。在大型項目里測試代碼的維護成本有時候比業(yè)務(wù)代碼還高。胖接口會導(dǎo)致兩個典型問題一是mock配置特別長。因為接口方法太多你為了走通一條測試路徑要when掉很多你不關(guān)心的方法否則某些分支會把調(diào)用打到null對象上。二是測試意圖模糊。一個測試里同時出現(xiàn)了payOrder、exportOrders、auditOrder的mock讀測試代碼的人會疑惑我到底在測什么模塊按調(diào)用方拆小接口后每個測試的依賴面變得非常聚焦。我的習(xí)慣是如果一個測試需要mock超過三個方法就會懷疑被測單元的依賴設(shè)計是不是太粗了。接口隔離做得好這個數(shù)字通常不會失控。4.3 團隊間耦合降低發(fā)布節(jié)奏不再互相拖累第三筆收益最容易被忽略但長期價值最大。大接口意味著大團隊的共享邊界。多人同時在一個接口上修改合并沖突只是表面現(xiàn)象真正的痛點是互相等待——A團隊要加一個方法得跟B團隊商量會不會影響他們的實現(xiàn)B團隊不敢隨便重構(gòu)因為不知道C團隊依賴了哪個方法。UI設(shè)計里有個詞叫認知負荷同樣適用于接口設(shè)計。一個調(diào)用方必須理解二十個方法才能正確使用其中一個這個接口就是在給團隊增加認知負荷。拆分之后新成員接手某個模塊時只需要閱讀他關(guān)心的那個小接口學(xué)習(xí)成本直接砍掉一半以上。5. 別把隔離做成碎片化什么時候不該拆以及怎么判斷接口隔離原則講得太多會有人走向另一個極端一個方法一個接口到處是三五行的細碎接口調(diào)用方為了完成一個業(yè)務(wù)流程要注入五六個依賴。這不是隔離這是碎片化。5.1 接口碎片化的典型癥狀碎片化的接口長什么樣我在代碼評審里見過這樣的設(shè)計public interface OrderCreator { void createOrder(Order order); } public interface OrderCanceller { void cancelOrder(Long orderId); } public interface OrderPayer { void payOrder(Long orderId, PaymentMethod method); } public interface OrderRefunder { void refundOrder(Long orderId); }然后每個調(diào)用方都要注入四個接口public class OrderFacade { private final OrderCreator creator; private final OrderCanceller canceller; private final OrderPayer payer; private final OrderRefunder refunder; // 構(gòu)造函數(shù)注入四個依賴 }你發(fā)現(xiàn)問題了嗎OrderFacade雖然注入了四個小接口但它依然是一個同時依賴下單、取消、支付、退款的客戶端。拆分接口并沒有消除它的寬依賴只是把一個大接口換成了四個小接口的排列組合。從依賴數(shù)量來說甚至更差了。所以判斷接口隔離做得好不好不是看接口數(shù)量多了還是少了而是看每個客戶端的依賴是否剛好等于它需要的最小集合。如果拆分之后客戶端還是需要把多個接口組裝起來才能干活那就要考慮是不是該用一個聚合接口。5.2 判斷該不該拆的四條標準我總結(jié)了自己的四條判斷標準基本能覆蓋日常開發(fā)里大部分情況第一有沒有實現(xiàn)類被迫拋異常。如果存在UnsupportedOperationException或者某個方法返回Collections.emptyList()、null來應(yīng)付調(diào)用這就是違反ISP的最強信號。說明接口里混入了一部分特定實現(xiàn)類用不到的能力。第二方法的調(diào)用方是否有明顯分組。你把接口里的方法列出來給每個方法標注它的調(diào)用方。如果出現(xiàn)好幾組調(diào)用方完全不相交的情況說明接口應(yīng)該拆。注意這里說的是完全不相交如果兩個接口的調(diào)用方高度重疊拆了反而增加復(fù)雜度。第三方法的變化頻率是否一致。接口里有些方法一年都不變有些方法一個月改三次這兩類方法放在一起會讓雙方都難受。穩(wěn)定方法會因為不穩(wěn)定方法的變動而被重新評估不穩(wěn)定方法又會被穩(wěn)定方法的兼容性要求拖住。變化頻率不同的方法應(yīng)該進入不同的接口。第四外部是否真的需要看到它。有的方法只是內(nèi)部實現(xiàn)細節(jié)比如狀態(tài)流轉(zhuǎn)的中間步驟、緩存刷新的輔助方法壓根不應(yīng)該出現(xiàn)在對外接口里。這種情況不是拆分接口而是直接把它們降為private或包內(nèi)方法。5.3 替代方案適配器、默認方法、分層接口如果暫時沒有權(quán)限大改接口或者歷史包袱太重有幾個過渡方案可以用。適配器模式是拆接口的平替。當(dāng)調(diào)用方只能使用一個窄接口的方法但底層實現(xiàn)是胖接口時可以寫一個適配器把窄需求映射到胖實現(xiàn)上。這種方式不改造底層但能讓新代碼先依賴干凈的小接口。Java接口的default方法也可以用來平滑過渡。你想在胖接口里加一個新的業(yè)務(wù)能力但不想讓現(xiàn)有實現(xiàn)類全部實現(xiàn)可以給這個方法提供默認實現(xiàn)甚至默認實現(xiàn)直接拋異常等需要的實現(xiàn)類去覆蓋。這算是一種先占位、后拆分的策略但要注意別把default方法用成常態(tài)否則接口會越來越膨脹。分層接口是我個人比較喜歡的一種做法。定義一層基礎(chǔ)接口里面只放真正通用的方法再定義面向不同場景的擴展接口。比如public interface OrderBaseService { Order getOrderById(Long orderId); } public interface OrderPaymentService extends OrderBaseService { void payOrder(Long orderId, PaymentMethod method); void refundOrder(Long orderId); } public interface OrderAdminService extends OrderBaseService { void auditOrder(Long orderId, AuditDecision decision); void markFraudOrder(Long orderId); }這樣公共的讀方法只維護一份不同客戶端通過繼承各自的小接口獲得能力也是個典型的平衡方案。6. 在團隊里落地接口隔離原則的三條實戰(zhàn)建議最后說點能直接帶進團隊的東西。如果你認可這個原則但不知道從哪下手這三條是我實踐下來最有效的路徑。6.1 讓接口從調(diào)用方長出來而不是從實現(xiàn)類推出來大部分違反ISP的接口是怎么產(chǎn)生的通常是這樣程序員先寫了一個OrderServiceImpl發(fā)現(xiàn)方法挺多然后右鍵Refactor把它抽成了一個OrderService接口。這種做法是從實現(xiàn)類推導(dǎo)接口結(jié)果必然是接口跟實現(xiàn)類一樣胖。正確的思路應(yīng)該是反過來先有調(diào)用方的需求再有接口。你寫C端控制器的時候發(fā)現(xiàn)自己需要一個支付訂單的能力于是定義一個OrderPaymentService接口只包含payOrder然后讓實現(xiàn)類實現(xiàn)它。第二個調(diào)用方需要審核訂單于是定義OrderAdminService接口再讓同一個實現(xiàn)類實現(xiàn)它。接口的粒度是由外部需求決定的不是由內(nèi)部實現(xiàn)決定的。這個順序看起來只是思考方式的差異實際效果天差地別。從需求出發(fā)的接口天然就是按調(diào)用方分組的。6.2 評審時重點盯三類信號代碼評審是最容易落地接口隔離原則的地方。我每次看到新的接口或者大接口改動會重點看三類信號第一接口方法數(shù)量。不是說超過十個就一定不行但如果一個接口方法非常多我會本能地問一句這個接口的調(diào)用方真的都需要這些方法嗎如果回答不上來往往說明接口已經(jīng)膨脹了。第二實現(xiàn)類里有沒有空實現(xiàn)或異常實現(xiàn)??吹絫hrow new UnsupportedOperationException()我會立刻想到接口隔離而不是包容它。同樣的看到某個方法直接return null;也會去追究是不是接口設(shè)計的問題。第三依賴注入的字段數(shù)量。如果一個類注入了超過四五個不同的Service我會懷疑它的職責(zé)是否過重同時也會看這些Service是不是被拆得太碎了導(dǎo)致本來一個接口能表達的場景被拆成了拼圖。這三類信號不需要任何工具人肉就能識別門檻很低效果很好。6.3 演進式拆分不要一次到位跟著變化拆最后一條建議是心態(tài)層面的。你不必為了遵守設(shè)計模式而一次性把所有大接口全部拆完。過度追求設(shè)計完美往往會在接口還沒穩(wěn)定的時候就把結(jié)構(gòu)搞復(fù)雜。我的做法是跟著變化拆當(dāng)某個胖接口因為新增需求即將被改動時趁這個機會把與改動點無關(guān)的方法剝離出去當(dāng)某個實現(xiàn)類開始寫UnsupportedOperationException時把它逼到不得不拋異常的方法拆到另一個接口里當(dāng)某個調(diào)用方為了一個方法不得不升級依賴時重建一個只屬于它的窄接口。一年下來你會發(fā)現(xiàn)系統(tǒng)的接口結(jié)構(gòu)會自動趨向合理而且不會有那種整建制重構(gòu)的陣痛期。我見過太多團隊想一步到位重構(gòu)系統(tǒng)結(jié)果重構(gòu)期間業(yè)務(wù)需求不斷涌入改造被迫中斷最后接口比以前更亂。演進式拆分雖然慢但每一步都是在真實需求驅(qū)動下進行的做出來的接口更經(jīng)得起考驗。接口隔離原則和其他SOLID原則一樣都不是考場上的填空題而是在每一次需求變更、每一次代碼評審、每一次接口設(shè)計里反復(fù)驗證出來的判斷力。我自己在實際項目里體會最深的一點是它不能讓你寫出更炫酷的代碼但能讓你在三個月后、半年后、甚至換了一波同事之后依然有底氣地改某一個接口而不擔(dān)心半夜被線上告警叫醒。這種安全感值得你為它多花一點設(shè)計時間。