實踐:從職責劃分到事務(wù)邊界的管理指南)
1. T3 Code到底是什么別把它當成多一個文件夾很多團隊一提到“T3 Code”——也就是三層架構(gòu)Three-Tier Architecture下的編碼實踐第一反應(yīng)就是Controller、Service、DAO各建一個包目錄分層就完事了。我在不少項目里見過這種理解結(jié)果是代碼確實分了三個文件夾但業(yè)務(wù)邏輯還是糊成一團Controller里塞了上千行Service里全是SQL拼串DAO層反倒成了擺設(shè)。這個現(xiàn)象太典型了所以我想先花點篇幅說清楚T3 Code到底在解決什么問題。三層架構(gòu)的核心從來不是目錄長什么樣而是一條依賴紀律。表現(xiàn)層只負責接收輸入和返回結(jié)果業(yè)務(wù)層只負責業(yè)務(wù)規(guī)則和流程編排數(shù)據(jù)層只負責跟數(shù)據(jù)庫打交道。每一層的代碼只能依賴下一層不能跨層調(diào)用、不能回頭依賴、更不能把職責互相滲透。換句話說這不是“多建幾個包”的事而是讓團隊在寫每一行代碼的時候都清楚地知道這行代碼屬于哪個層它該不該出現(xiàn)在這里。我最早接觸三層架構(gòu)是七八年前做傳統(tǒng)企業(yè)級項目的時候那時候還沒有Spring Boot用的是Spring MVC加MyBatis分層是強制性的。說實話當時沒覺得分層多厲害反而覺得麻煩一個簡單的增刪改查要寫三層類還要各寫各的方法代碼量翻倍。但后來接手了一個沒有分層的遺留系統(tǒng)一個方法里從頁面參數(shù)解析到JDBC連接全部干完改一個字段要順著調(diào)用鏈翻十幾個地方我才意識到三層架構(gòu)保護的不是寫代碼的人而是后面維護代碼的人也包括三個月后的自己。T3 Code適合誰參考我覺得主要兩類人。一類是剛?cè)胄小⒄趯WSpring Boot或者其他Web框架的同學你需要一個標準答案來建立代碼邊界感另一類是團隊里負責技術(shù)規(guī)范的人你需要在“過度設(shè)計”和“一把梭”之間找一條可落地的線。這篇文章會圍繞三層如何拆分、每層怎么寫出花、事務(wù)和異常怎么處理、以及我踩過的坑來展開盡量把實際項目里能碰到的問題都聊一遍。2. 每一層該怎么拆落代碼前先過一遍腦2.1 表現(xiàn)層只做翻譯不做計算表現(xiàn)層的核心職責是兩件事把用戶的輸入變成業(yè)務(wù)層能理解的對象把業(yè)務(wù)層的結(jié)果變成用戶能看的格式。我常跟團隊說一句話Controller里面不要出現(xiàn)if/else不要出現(xiàn)任何業(yè)務(wù)判斷不要出現(xiàn)new一個業(yè)務(wù)對象然后手動set一堆字段這種操作。如果一段邏輯在Controller里沒有三行以上大概率是放錯位置了。實際操作中Controller應(yīng)該做的事情非常機械接收HTTP請求解析參數(shù)做基礎(chǔ)的格式校驗比如必填項、長度限制、枚舉合法性調(diào)用一個Service方法傳參盡量用單個DTO或BO對象不要三五個離散參數(shù)滿天飛把Service返回的數(shù)據(jù)組裝成VO或Response對象返回給前端如果你發(fā)現(xiàn)Controller里開始出現(xiàn)“根據(jù)用戶類型判斷調(diào)哪個Service”、“計算某個金額再傳給Service”這類邏輯那就要警惕了。表現(xiàn)層一旦開始做業(yè)務(wù)決策后續(xù)前端需求一變改的就是Controller而Controller是暴露給外部的門面改動成本遠高于內(nèi)部層。2.2 業(yè)務(wù)層業(yè)務(wù)邏輯的家但不是萬能垃圾桶業(yè)務(wù)層是T3 Code里最復雜的一層也是團隊分歧最大的地方。我說一個常見的現(xiàn)象Service里所有方法都喜歡以save、update、delete命名方法體里就是一堆數(shù)據(jù)存取。這種寫法不是錯但它把業(yè)務(wù)層降級成了數(shù)據(jù)層的殼業(yè)務(wù)規(guī)則全散落在Controller或者SQL里。真正的業(yè)務(wù)層應(yīng)該負責三塊內(nèi)容業(yè)務(wù)規(guī)則比如下單時要校驗庫存、要計算優(yōu)惠、要判斷用戶等級流程編排先調(diào)哪個倉儲方法、后調(diào)哪個倉儲方法、是否需要事務(wù)事務(wù)邊界哪些操作必須同生共死哪些操作允許部分失敗舉個例子下單這個操作。數(shù)據(jù)層只需要提供“查庫存”“扣庫存”“創(chuàng)建訂單”“寫入訂單明細”這幾個原子能力。業(yè)務(wù)層要做的是先查庫存夠不夠夠則扣減再創(chuàng)建訂單主表和明細表最后返回訂單號。這個編排過程如果放在Controller里那多個入口Web端、App端、批量腳本各自實現(xiàn)一套邏輯必然漂移如果放在數(shù)據(jù)層里那數(shù)據(jù)層就被迫理解“下單”這個業(yè)務(wù)概念復用性也廢了。業(yè)務(wù)層的命名也是一個經(jīng)驗點。我一般不用save這種萬能動詞而是用業(yè)務(wù)動詞createOrder、cancelOrder、updateShippingAddress。這樣一眼就能看出這個方法是干嘛的也好寫單元測試。你想想測試人員看到一個createOrder方法很自然就能列出測試用例庫存不足、庫存剛好、重復提交、優(yōu)惠金額為負……但如果叫save還得翻方法體才知道它在干什么。2.3 數(shù)據(jù)層老老實實跟數(shù)據(jù)庫打交道數(shù)據(jù)層的職責最簡單也最容易跑偏只做數(shù)據(jù)的讀寫不做業(yè)務(wù)判斷。Repository或DAO里面就應(yīng)該是findById、selectByCondition、insert、updateStatus這類方法。我在實踐中的一個習慣是數(shù)據(jù)層的方法命名盡量以SQL語義來定而不是業(yè)務(wù)語義。比如不要叫checkInventoryAndDeduct而是拆成selectStockForUpdate和deductStock。為什么因為業(yè)務(wù)層可能需要“查庫存”而不一定要“扣庫存”比如預校驗場景。如果數(shù)據(jù)層把業(yè)務(wù)動作寫死在方法里業(yè)務(wù)層就被數(shù)據(jù)層的設(shè)計綁架了。還有一個容易忽略的點數(shù)據(jù)層的參數(shù)對象盡量使用專門的Query對象或DTO不要直接把業(yè)務(wù)層的BO往Repository里傳更不要傳實體類讓SQL去猜。一來是解耦二來是防止意外更新。我見過太多代碼在Repository里updateById(entity)然后entity里一個不小心帶了創(chuàng)建時間字段把創(chuàng)建時間也給改了。用專門的更新字段對象就能從結(jié)構(gòu)上避免這種低級事故。3. 把一個訂單模塊跑通完整實操記錄3.1 先定邊界再寫代碼很多人寫三層代碼容易陷入“先建包后寫類”的慣性但正確的姿勢是先定邊界。我拿一個最典型的訂單模塊舉例這個模塊在電商項目里幾乎人人都會碰到。開始編碼前我會先寫一份簡單的邊界清單貼在IDE的TODO里或者在接口文檔里寫清楚表現(xiàn)層接口POST /api/orders入?yún)橄聠握埱篌w出參為訂單號和總金額業(yè)務(wù)層動作校驗用戶狀態(tài)、校驗庫存、計算應(yīng)付金額、創(chuàng)建訂單、扣減庫存、記錄日志數(shù)據(jù)層原子操作查詢商品庫存、扣減庫存、插入訂單主表、插入訂單明細表、插入操作日志表這份清單不需要寫得多詳細但能保證寫代碼時每個方法都知道自己該放在哪一層。等這些邊界確定了再開始建類、寫方法思路會順暢很多。很多項目代碼亂根源不是不會分層而是跳過了這個“定邊界”的環(huán)節(jié)直接開寫最后哪里順手就寫在哪里。3.2 從用戶點擊到數(shù)據(jù)落庫一次完整請求是怎么穿過三層的咱們順著一次真實的請求走一遍。用戶在前端點了“提交訂單”前端POST一個JSON到/api/orders這個請求進入表現(xiàn)層。Controller先做一個基礎(chǔ)校驗用戶ID是否存在、商品列表是否為空、收貨地址是否填寫。注意這里只做“格式和必填”校驗不做“庫存夠不夠”這種業(yè)務(wù)校驗因為那屬于業(yè)務(wù)層的事。然后Controller調(diào)用訂單Service的createOrder(CreateOrderRequest request)方法。業(yè)務(wù)層開始干活根據(jù)用戶ID查詢用戶狀態(tài)如果被禁用直接拋異常遍歷商品列表逐一從數(shù)據(jù)層查詢庫存用數(shù)據(jù)庫行鎖或樂觀鎖保證一致性逐個判斷庫存是否充足不足則直接中斷并返回提示計算商品總價、優(yōu)惠金額、應(yīng)付金額生成訂單號組裝訂單主表和明細表對象調(diào)用數(shù)據(jù)層插入訂單再扣減庫存記錄一條操作日志事務(wù)就掛在業(yè)務(wù)層這個方法的Transactional上。也就是說從第一步到最后一步任何異常都會導致前面所有數(shù)據(jù)操作回滾。這一步是三層架構(gòu)里最簡單的部分但也是最關(guān)鍵的部分事務(wù)邊界只在業(yè)務(wù)層表現(xiàn)層不開啟事務(wù)數(shù)據(jù)層不自己提交事務(wù)。最后把訂單號、應(yīng)付金額和狀態(tài)封裝成VO返回給ControllerController包裝成標準的JSON響應(yīng)。整個流程下來每一層都只干自己的事Controller沒碰庫存Service沒拼SQLRepository沒寫任何if/else。3.3 各層之間傳什么DTO/VO/BO怎么區(qū)分這是T3 Code實踐中最讓人糾結(jié)的細節(jié)沒有之一。我見過一個項目里DTO、VO、DO、BO四處亂飛同樣的字段在四個類里各寫一遍還經(jīng)常漏字段。我的做法是不同層之間傳不同類型的對象但不要讓這個規(guī)則變成負擔。我常用的約定是這樣的表現(xiàn)層接收前端參數(shù)用DTO比如CreateOrderRequest它代表“外界想讓我做什么”表現(xiàn)層返回給前端的數(shù)據(jù)用VO比如OrderVO它代表“我做完之后你應(yīng)該看到什么”業(yè)務(wù)層內(nèi)部流轉(zhuǎn)的數(shù)據(jù)用BO比如OrderBO它代表“業(yè)務(wù)視角下的訂單全貌”數(shù)據(jù)層操作數(shù)據(jù)庫用實體類比如OrderDO或Query對象層與層之間的轉(zhuǎn)換放在哪里我推薦放在適配器里而不是業(yè)務(wù)方法內(nèi)部。比如業(yè)務(wù)層的createOrder接收DTO還是BO我的習慣是接收DTO因為Controller直接傳入省一層轉(zhuǎn)換內(nèi)部如果復雜再轉(zhuǎn)成BO。Repository接收BO還是DO我習慣是Repository自己把BO拆分成DO或參數(shù)對象不要讓業(yè)務(wù)層去組裝查詢參數(shù)。很多爭議其實都是“對象怎么命名”的表面問題本質(zhì)問題是邊界感。只要每個對象都有清晰的用途和轉(zhuǎn)換時機叫什么都行。但一旦你發(fā)現(xiàn)一個對象同時被前端、業(yè)務(wù)、數(shù)據(jù)三層使用那就是危險的信號了。3.4 代碼示例一張訂單業(yè)務(wù)核心鏈路這里我給出一個簡化版的Service方法骨架不是完整代碼但足夠展示業(yè)務(wù)層的編排感。以Java為例Transactional(rollbackFor Exception.class) public CreateOrderResponse createOrder(CreateOrderRequest request) { // 1. 查詢用戶信息 UserDO user userRepository.findById(request.getUserId()); if (user null || user.getStatus() UserStatus.DISABLED) { throw new BizException(用戶不存在或已被禁用); } // 2. 查詢商品庫存并做扣減此處用樂觀鎖或行鎖保證并發(fā)安全 ListItemQuery items request.getItems(); ListOrderItemBO orderItems new ArrayList(); BigDecimal totalAmount BigDecimal.ZERO; for (ItemQuery itemQuery : items) { ProductStockDO stock productRepository.findStockForUpdate( itemQuery.getSkuId()); if (stock.getAvailable() itemQuery.getQuantity()) { throw new BizException(商品庫存不足: itemQuery.getSkuId()); } productRepository.deductStock(itemQuery.getSkuId(), itemQuery.getQuantity()); OrderItemBO orderItem new OrderItemBO(); orderItem.setSkuId(itemQuery.getSkuId()); orderItem.setQuantity(itemQuery.getQuantity()); orderItem.setPrice(stock.getPrice()); orderItems.add(orderItem); totalAmount totalAmount.add(stock.getPrice() .multiply(BigDecimal.valueOf(itemQuery.getQuantity()))); } // 3. 計算優(yōu)惠并生成訂單 BigDecimal discount calculateDiscount(user, totalAmount); BigDecimal payableAmount totalAmount.subtract(discount); String orderNo generateOrderNo(); OrderDO order new OrderDO(); order.setOrderNo(orderNo); order.setUserId(user.getId()); order.setTotalAmount(totalAmount); order.setPayableAmount(payableAmount); order.setStatus(OrderStatus.CREATED); orderRepository.insert(order); orderItemRepository.batchInsert(order.getId(), orderItems); // 4. 記錄日志并返回 operationLogRepository.log(user.getId(), createOrder, orderNo); CreateOrderResponse response new CreateOrderResponse(); response.setOrderNo(orderNo); response.setPayableAmount(payableAmount); return response; }注意看這個結(jié)構(gòu)每一塊都和職責一一對應(yīng)沒有Controller的影子也沒有SQL拼接。這個方法的每一步你都可以單獨測試也方便在中間加日志和監(jiān)控。實際項目里還會有價格計算服務(wù)、優(yōu)惠策略服務(wù)但編排的核心思想就是這樣。4. 我踩過的坑和排查問題的幾條野路子4.1 最常見的坑業(yè)務(wù)邏輯往Controller里塞我見過一個真實案例。團隊里有個同事為了“快速上線”把所有校驗都寫在Controller里Service里只是一個空殼方法直接調(diào)用Repository。剛開始確實快因為少了參數(shù)傳遞和對象轉(zhuǎn)換。但后來需求變了同樣的下單接口App端要加一個“新人立減”活動Web端要加一個“企業(yè)采購”校驗。由于業(yè)務(wù)邏輯都在Controller里兩個入口只能各寫各的同一個下單規(guī)則出現(xiàn)了兩套實現(xiàn)。再后來邏輯對不上了前端A告訴用戶“可以下單”前端B卻報“庫存不足”搞得產(chǎn)品經(jīng)理以為系統(tǒng)有bug。處理辦法只有一個把Controller里的業(yè)務(wù)代碼全部搬到ServiceController瘦身成真正的“翻譯官”。搬的過程不算難但要有紀律——以后但凡有人想在Controller里加業(yè)務(wù)邏輯Code Review就要打回去重寫。4.2 事務(wù)到底該放在哪一層這又是一個高頻踩坑點。很多人寫了一個Service方法里面調(diào)用了兩個Repository方法但忘記加Transactional結(jié)果第一個插入成功、第二個插入失敗數(shù)據(jù)庫里留下了半個訂單。更隱蔽的是有人在Controller上加了Transactional雖然也能回滾但Controller變成事務(wù)入口點之后日志、監(jiān)控、異常處理全都亂了套而且Controller如果有多個方法一不小心就會把所有接口都包進事務(wù)性能直接崩。正確理解是事務(wù)是業(yè)務(wù)層的屬性因為只有業(yè)務(wù)層才知道哪些操作是一個“完整業(yè)務(wù)動作”。數(shù)據(jù)層的每個原子操作默認自動提交表現(xiàn)層完全不感知事務(wù)。當你使用Spring的聲明式事務(wù)時只要在業(yè)務(wù)方法上標注TransactionalSpring會幫你把連接綁定到線程上數(shù)據(jù)層多個操作共用同一個事務(wù)。提示Transactional默認只回滾運行時異常如果是checked exception要顯式指定rollbackFor Exception.class否則你會看到數(shù)據(jù)半提交的幽靈問題。這是團隊新人最容易掉進去的坑之一。4.3 排查“三層代碼不好調(diào)”的幾個實用技巧很多人說分層之后代碼難調(diào)——一個請求要跨越三個類打日志都費勁。我自己的排查習慣是這樣的第一在業(yè)務(wù)層的每個關(guān)鍵編排節(jié)點打日志包括入?yún)?、中間計算結(jié)果、出參。這比在Controller和Repository里都打日志更有效因為業(yè)務(wù)層才是整個流程的決策中心。第二把“請求唯一ID”貫穿三層從Controller入口生成一個traceId打印日志時帶上它這樣一次請求的所有日志都能串起來。第三遇到數(shù)據(jù)一致性問題時優(yōu)先看事務(wù)邊界是否掛對了方法而不是盯著SQL看。我還有一個野路子先在Repository里做一次SQL直查確認數(shù)據(jù)對不對再反推業(yè)務(wù)層邏輯是否出錯。這個方法聽起來簡單但能快速區(qū)分“數(shù)據(jù)本身有問題”和“業(yè)務(wù)規(guī)則算錯了”省去大量翻日志的時間。4.4 一張速查表三層代碼最常見問題清單問題現(xiàn)象可能原因排查方向Repository里出現(xiàn)if/else或業(yè)務(wù)判斷數(shù)據(jù)層被業(yè)務(wù)污染把判斷上移到業(yè)務(wù)層Controller里直接操作多個Service編排邏輯散落在表現(xiàn)層在Service里新增編排方法改了字段后前端報錯或數(shù)據(jù)錯亂多層共用同一個實體對象引入VO/DTO/BO并做轉(zhuǎn)換事務(wù)不生效部分寫了部分沒寫事務(wù)邊界放錯位置或異常類型未覆蓋檢查Transactional位置和rollbackFor一個接口改動導致另一個入口數(shù)據(jù)異常多入口各寫一套業(yè)務(wù)規(guī)則統(tǒng)一收斂到業(yè)務(wù)層單元測試難寫Mock太多Service依賴了具體實現(xiàn)類或SQL面向接口編程依賴抽象5. 什么情況下T3 Code不再適用聊了半天三層架構(gòu)的好處我也想聊聊它不適用的場景。不是所有項目都需要嚴格的三層代碼很多輕量級項目用三層反而是負擔。第一種情況是純CRUD管理后臺。如果只是簡單的表格增刪改查沒有復雜的業(yè)務(wù)規(guī)則和流程編排硬拆三層會多出大量無意義的轉(zhuǎn)換代碼。這種項目用Controller直接操作Repository甚至直接用MyBatis-Plus的Service接口反而更省事。第二種情況是腳本任務(wù)和定時任務(wù)它們本身沒有表現(xiàn)層直接從任務(wù)方法進入業(yè)務(wù)層即可沒必要為了“三層對稱”硬造一個Controller。第三種情況是簡單的讀多寫少查詢服務(wù)直接走Repository查數(shù)據(jù)返回即可套三層只會增加延遲和代碼量。我個人的判斷標準是如果一段業(yè)務(wù)邏輯未來會被多個入口復用或者有超過三步的流程編排那它就該有一個獨立的業(yè)務(wù)層方法。如果只是一次性的數(shù)據(jù)讀取就不要為了分層而分層。T3 Code是一種手段不是一個必須遵從的宗教教條。工具鏈上我建議配合單元測試來做分層守護每層都能獨立測試業(yè)務(wù)層用Mock數(shù)據(jù)層來測試規(guī)則數(shù)據(jù)層用真實數(shù)據(jù)庫做集成測試。分層不是為了讓代碼看起來高級而是為了降低維護成本。你能在不需要啟動Web容器的情況下把一個業(yè)務(wù)異常用例寫出來并跑通那分層就真正有了價值。這也是我判斷“T3 Code落地得好不好”的最直接標準。