Duke的咆哮語錄②:我求求你們跟我學一下代碼“分層”吧!

注:關於代碼的分層,事實上還沒有金科玉律的標準。所以本文所述的分層理念,僅僅是從筆者個人的開發生涯的理解上給出的。僅供參考。如果讀者有任何意見和想法,歡迎和我交流討論!

序言

在很久很久以前,昔々の時代,long long ago,那時候的js還沒有大放異彩,那時候的前端還沒有專門的工程師,甚至,我們的Java代碼還會直接寫在前端頁面裏。而所謂的Ajax、局部刷新,在當時也是不折不扣的“高端技巧”。在那時瀏覽器跟服務器的交互,除了文檔(jsp、html等)、靜態資源(css、圖片等)外基本上就沒別的了。用戶每次的請求,或許是超鏈接跳轉,或許是表單提交,都會導致頁面重刷。於是jsp的數量是非常非常多的,隨便一個簡單模塊的增刪改查就得有列表頁、新增頁、詳情頁等。所以有些年齡的Java程序員可能見過這種文件結構:

古董級Java Web項目

開發者在頁面上又要書寫Java邏輯訪問數據庫、加工數據,又要照顧頁面本身。所以寫出來的頁面代碼,很可能會有些奇怪:

奇怪的Java和JS代碼混雜的代碼片段

在剛上大學的時候,我甚至搞不清JS和JSP的區別。後來分別學習過兩者之後,我才知道兩者是八竿子打不到一塊的。可是,直到看到這種代碼,我才明白,原來JS和JSP也是可以水乳交融的,當年的我,果然還是太膚淺了……
……
……
……
個屁啊!

這種代碼是給生產項目程序員看的嗎?很容易人格分裂好嘛!

如果您看了這種代碼表示very good甚至有“極客風範”,那也許我們不在同一個頻道吧。

如大家所見,直接在jsp插入Java代碼的方式,或者在Servlet中打印HTML字符串的方式,是不折不扣的“反人類”設計。其負面作用遠不止代碼耦合無法複用、視圖和邏輯混亂不堪……所以從Java Web推出之始,所有人都在做的一件事就是——減負。

何爲“減負”?軟件工程裏有個詞叫“單一職責原則”,一個類只應該扮演一個角色。雖然你可以狡辯說jsp確實只扮演了“渲染視圖輸出”的角色,但事務的認知是分粒度的。過去的jsp/servlet解決方案,細粒度去看,不論是數據庫的訪問,還是事務的管理、還是數據的加工處理、還是身份驗證等,都塞在同一塊代碼裏,這是不僅不夠面向對象,甚至還能讓人看到面向過程的影子。想要邏輯清晰、代碼可控,我們就要抽象代碼成類,並給類賦予單一職責,將與頁面視圖渲染本身無關的部分從它身上剝離掉!

沿着這個話題下去,我們可以聊一聊所謂的MVC Web框架的實現思路。但跟本次我要咆哮的主題無關所以暫時打住話頭。我們還是聊軟件工程相關的。

垃圾代碼

上文提到了“單一職責原則”,除了它,你可能還聽說過“開閉原則”、“依賴倒轉原則”、“高內聚低耦合”等名詞。前人探索和思考散發出的智慧是我們應該充滿感激並辯證吸收的,而不是掛在嘴邊忘在心裏實踐背離。

是時候了,上代碼:

神代碼

從命名上來看,該代碼裝模作樣地依次實現了“Controller”層、“Service”層以及“Dao”層。

爲什麼我說是“裝模作樣”呢?

因爲這分層完全就是沒有參透代碼“分層”的目的。

“分層”是對邏輯的一種切分,也是一種抽象。 切分可以降低代碼的耦合,抽象可以提高代碼的複用。可以說這是面向對象的工程化軟件開發的無上法寶——雖然不能說是銀彈吧。

按照業務的複雜度和項目細分的粒度,一般情況下我們會把Java web項目分爲如下幾層:

  • 控制器層
  • 業務邏輯層
  • 數據訪問層
  • 通用數據模型層

到這裏,一般的老師或者課本就會照本宣科,講什麼“控制器層分發請求,業務邏輯層處理業務,數據訪問層和數據庫打交道”之類的話了。我相信初學web開發的同學很少有人能參透這些分工的目的和意義,反而云山霧繞不知所以。所以我打算換個角度跟你講,這樣分層的道理。

外賣系統和分層

我比較喜歡用類比去描述事物,這樣讀者可能看起來更有畫面感。要類比的話,把Java web項目比作訂外賣還是有些相似的。

控制器層

訂外賣的話,控制器,其實就是派單系統。我們通過APP訂了餐,派單系統要把單子遞給商家,然後商家把做好的餐送給我們。派單系統的主要職責是什麼呢?我們可以列舉一下:

  • 接收客戶訂單;
  • 驗證客戶的訂單是否正確,是不是忘了填食品名,是不是在全聚德點了“東京烤雞”等。如果填錯了就要告訴客戶填錯了;
  • 如果客戶訂單填寫正常,就要把單子派給商戶,並連帶訂單中的各種要求一併告知商家;
  • 如果商家表示我們打烊了,或者客戶點的天鵝肉昨天被癩蛤蟆偷吃了,或者大廚的寬油熱鍋燒着眉毛做不了了,這些信息會及時告訴派單系統這頓飯做不了了;
  • 如果商家的菜新鮮出爐,派單系統要負責告訴配送員妥當將食品送到客戶手裏;

這就是控制器的職責。驗證用戶輸入,分發給對應的邏輯處理器,妥善處理邏輯錯誤,及時返回數據給用戶。對於控制器來說,它關心的是:用戶傳遞了什麼,誰來處理它,有沒有發生錯誤,怎麼返回數據。至於具體的處理,也就是商家大廚具體怎麼做菜,則不是控制器所關心的。你見過美團外賣他家接了單後自己就咣咣炒菜給你送上來的嗎?這也就是爲什麼說不要讓Controller層搶活,導致service層無事可做的原因。一個外賣平臺不要跟大廚搶活幹好嘛!此外就是《咆哮①》講到的,控制器層應該是一切異常的終點。什麼意思呢?你要是用美團點餐的時候,商戶打烊了APP不通知你而是咔一下閃退了,你難道不窩火嗎?是一個道理。

業務邏輯層

接下來我們聊業務邏輯層。特別地,我們聊Spring下的業務邏輯層。業務邏輯即用戶點外賣環節的商家。對於簡單的系統,業務邏輯只需要一層就夠了。但更復雜的業務也許會再講業務邏輯分爲複雜業務和通用業務等子層。我習慣性將複雜業務稱爲“service”,通用業務稱爲“biz”。這就好比是麪館大廚師傅手下有若干個和麪的、拉麪的或煮湯的用於處理中間環節食材的助理師傅。和麪的師傅和拉麪的師傅就是通用業務層,而炒麪師傅煮麪師傅則是複雜業務層。總的來說,商家大廚的職責是:

  • 接收無誤的訂單信息,加工食材做飯,並將做好的飯妥投給派單系統指派的送餐師傅;
  • 大廚拿食材做飯就好,他不應該親自買菜。做硬菜的大師傅也不需要親自給蘿蔔雕花,那是手下助理師傅的活;
  • 如果下一層發生問題,如面用完了(食材問題)或和麪師傅傷者手了(中間環節出現問題),要及時報告派單系統這頓飯做不了了,以及做不了了的原因。而且要及時打掃現場,比如購置足量麪粉、及時送師傅就醫等,保證不會影響下一單生意。

如果把上述描述轉換成業務層的職責,那就是業務層應該接收已經通過控制器層校驗的數據。比如,對於經過對稱加密的報文,其解密工作和校驗工作就應該在Controller層完成,將無誤的明文信息傳入service層,而不是在service層執行類似的操作。

service層應該只將業務處理結果返回,而不應代替Controller層直接返回json或xml報文。 除了插入業務,我們可能希望service返回id給Controller,其餘只要不涉及多狀態的業務,也就是要麼調用成功要麼調用失敗的業務,將這種service方法的返回值設爲void也未嘗不可。有些同學會問那發生業務異常怎麼辦?這就是Java異常機制的作用了。你可以定義一個ServiceException,將錯誤信息填在message字段提供給Controller層使用就可以。不要忘了我們在Controller層是一定一定要try-catch住業務代碼以避免異常逃逸到Controller層的外部去的。當然對於某些一定要返回業務處理狀態的方法,一定不要簡單地用int整數返回0123去描述接口狀態。兩個方案,要麼使用String類型的返回值返回字符串常量,字面值即是返回的狀態,要麼使用枚舉,以枚舉項的名稱作爲狀態的解釋。直接使用數字作爲service返回值的,我真的想用我的阿姆斯特朗迴旋加速阿姆斯特朗炮把你的腦漿炸出來。即使你說你在方法上加過了0123具體意義的詳細的註釋我也不會放過你。因爲我在Controller層斷點調試時看到一個service返回了個整數的時候,我整個人就是懵逼的。還有就是,如果業務發生了異常,不論是不是被你catch到了,一定要throw新的異常,特別地應該是用自定義的運行時異常ServiceException出去。不然的話根據Spring默認的事務管理機制,沒有拋出異常事務就不會回滾,很有可能數據庫中就會混入不正確的值了!

那麼還有就是service層應該專注於處理數據,而不是和數據庫打交道。換句話說,不應該在service層直接操作數據庫。狹義的說,就是不要在service層寫SQL。那是數據訪問層的任務,service層不應該搶活。但是對於一些特殊業務,特別是跨越多個數據源的業務,因爲dao層原則上都只與自己責任內的數據源打交道,這時用一個通用業務層的實例去處理一些邊界狀態,可能就是不可避免的了。

還有就是,service層本身也應該對自己的行爲負責。對於下一級可能會發生的問題,service層也應該予以捕獲和處理。換句話說,將業務代碼中所有的異常捕獲並統一轉化爲ServiceException是一種良好的實踐,甚至極端一些,service層的代碼只應該拋出ServiceException或此類自定義異常。Controller層關心的只是業務的完成狀態,而不應該讓它直接瞭解到是SQL出了問題,還是文件系統出了問題。在Controller看來,這些都是業務發生了問題。一股腦把各類第三方庫的、Java SDK本身的亂七八糟的異常都甩手給Controller層,也是非常不妥的。

既然提到了異常,那就多嘴幾句。不論是Eclipse還是IDEA,都有一個非常SB的設計,那就是在程序員調用了聲明異常拋出的方法後,會標紅報錯,但程序員用自動修復時,默認的修復選項卻是將該異常聲明提升至本方法的聲明裏,而不是用try-catch包裹並處理。那些技術弟弟程序員,看到標紅代碼自然而然地就依賴IDE的自動修復功能,把一大堆一大堆的異常聲明傳染得到處都是。比如Controller上的throws Exception,這還算好的,我還見過方法體聲明瞭一大堆找不到文件的、找不到方法的、編碼異常的等五花八門的弟弟代碼。Java的強制處理聲明式異常是一個弊大於利的設計,希望各位弟弟早日學會try-catch。自然不用說的是,自定義的DaoExceptionServiceException,一定要繼承自RuntimeException,而不是Exception。至於弄不清楚兩者有什麼區別的弟弟,哥哥只能勸你善良……

數據訪問層

終於講到了初學者可能最直觀可感的部分,數據訪問層,data access layer,其對象一般稱爲DAO。層如其名,本層的關注點就是“數據”。這種數據,99%的場合便是來自數據庫。所以武斷地講DAO層就是直接和數據庫進行交互的層也不算錯。在外賣系統中,DAO層就像是倉庫管理員,管理各類新鮮食材。大廚要什麼,就告訴倉庫管理員取對應的東西來。而外賣派單系統只關心菜做得如何,對於菜是哪兒種的,在哪兒買的,則完全不關心——只要菜的味道是好的品質是好的那就夠了。更極端一些,即使不要倉庫管理員,全聚德的大廚直接去便宜坊訂一隻鴨子交給外賣系統,從理論上也是沒問題的。所以對於一些分佈式系統來說,甚至可以沒有DAO層。

從我給出的不太準確的解釋中,或多或少可以看出DAO層和Service層的區別,DAO層關心的是數據庫,而Service層關心的是數據的獲取、加工、處理。控制器層是不直接依賴數據訪問層的,數據訪問層本身也不應該指代具體的業務邏輯。直接依賴於數據訪問層的,只應該是業務邏輯層,但業務邏輯層也不僅僅只依賴數據訪問層才能獲取數據。分佈式系統裏Service層的數據可以通過web接口獲取,而不是查庫,當然有時這種獲取數據的對象也會被叫做DAO。

說起來貌似清晰直截,但實際上因爲概念和實踐上的一些問題,初學者還是很容易將兩者的分工搞混的。其源頭就是SQL本身具有一定的業務描述能力,而大部分業務,不論增刪改查,都不可避免地要與數據庫進行交互。所以從潛意識裏,“SQL = 業務”的認知就建立起來了。以插入爲例,儘管從分層的角度,該業務的實現應該是位於service層的save()方法,但本質上起業務作用的,卻是在DAO層執行的insert into xxx ...SQL語句。所以究竟是DAO層承擔了業務功能,還是Service層在承擔業務功能呢?

看問題要看全局。

誠然,上例是SQL的調用完成了“插入業務”,但如果是更復雜的例子呢?例如分別取出銷量前三的筆記本電腦、手機、顯示器的品牌,將其去重取並集後按字典倒序列舉。這種例子,還能簡單地通過一條SQL語句來實現嗎?有些“SQL=業務”主義教徒可能真的會寫出這種SQL然後說“完全沒問題”,但隨着業務的描述越來越複雜,對應的純SQL語句也會趨向於人力理解不能。這時候,更好的設計應該是設計一個“獲取某分類商品前三品牌列表”的DAO層方法,在sevice層調用3次,之後用Java集合操作去去重排序,合併成一個List返回給Controller層。從這個例子中,您就應該可以感受到,DAO方法本身並不是業務,但service方法則是。

我個人的解釋是,不論是極簡業務還是複雜業務,業務的描述都是service層的代碼。 對於極簡業務來說,例如不和其他模塊有耦合的簡單增刪改查業務,確實是全部依賴DAO去實現的業務;但大部分業務,其實現邏輯還是應該來自service層,DAO層中的代碼,是被service層業務代碼所依賴的一些關鍵代碼。DAO層的代碼應是普世的,如果有可能的話,可以被儘可能多的service層代碼複用的。儘管插入操作的業務代碼只依賴一行DAO層方法的調用,但它仍然是業務代碼,DAO層代碼則是實現該業務所依賴的部分。

通用數據模型層

儘管上文貼出的DAO層代碼,是直接通過JDBC操作的數據庫,但實際上大部分生產環境項目,還是會依賴某種ORM框架去實現DAO層代碼。ORM框架會將關係數據庫的表間關係映射爲Java的對象間關係,這也使得所謂的“通用數據模型”並不像傳統的pojo那麼純粹。特別是建立了雙向綁定的對象,或包含用戶登錄密碼這種敏感字段的數據,是不適合直接作爲最終結果讓Controller返回給用戶的。前者在JSON序列化時會發生循環引用導致棧溢出,後者則可能會導致敏感數據被抓包者利用。爲了解決這類問題,最好的做法就是構建DTO類。DTO全稱爲“Data Transformation Object”,即數據傳輸對象。那麼,DTO對象的構建時機應該是什麼時候呢?是DAO層還是Service層呢?

稍加思索,你就會發現,實際上所謂的“數據傳輸對象”本身所描述的,就是“業務”,是對數據集合進行投影操作的業務。

但生活中就是充滿各種變數,所以我們也要有不少變通,不是嗎?

如果不考慮數據庫的查詢效率,DAO層直接獲取某對象映射表的所有數據,組裝成對象集合提供給Service層,Service層再挑挑揀揀拼裝出業務所需的對象返回給Controller層,一切都是那麼優雅完美。

可問題就在於“查詢效率”是不可忽視的。特別是對擁有動輒數十數百字段的大庫表來說,獲取太多本不需要的數據肯定會影響效率,而且先全拉取再挑挑揀揀的邏輯在對象的數量級膨脹後,也會造成很大的內存壓力和GC壓力。所以不可避免地,需要從SQL的構建開始限制獲取字段的數量。這一步,就我個人的水平和能力所限,沒有太好的解決方案。在這裏總結幾種方法吧:

  • 讓DAO層特供返回DTO的方法。
    這是最容易想到的方案,直接在DAO中拼裝和返回需要的DTO。但這終究還是讓DAO層承擔了一部分Service層的功能,會使DTO層下潛爲DAO層的依賴。
  • 在Service中添加投影方案描述。
    這種方法依然讓DAO層返回Entity類對象,但通過投影描述,使得DAO層獲取數據時只會查詢需要的字段。這也意味着實體類的其他字段會置空。這種方式的實現方式較爲繁瑣,除非ORM框架本身提供相關支持,但Service層理論上又不能直接接觸ORM框架的API,所以也會比較蹩腳。此外,如果使用了@NotNull之類的驗證方案,也會因爲置空產生驗證通不過的問題。
  • 引入Biz層。
    我們定義DAO處理的是純粹的數據庫交互層,那與之相對的,我們也可以引入一種“不純粹”的數據庫交互層。我將其命名爲Biz層,“biz”即“business”,也是指“業務”。只不過這種業務是專供複雜業務使用的低級業務。開發者可以把一部分定位模糊的業務(特別是多數據源的或跨多表的,單純DAO不好描述的業務)下沉到該層裏。這一層可依賴DAO層也可以不依賴DAO層,自身也可以直接交互數據庫,但不具備事務管理的能力,且直接被Service層依賴。Biz層的引入是對Service/DAO分層先天貧血的一種補充和妥協。儘管它可能很好用,但也破壞了分層的初心,重新耦合了業務和數據訪問,增加了複雜度。故不宜過多使用。
  • 拆庫。
    根據單一職責原則,數據表本身要描述的信息,在設計時也應該是帶有強烈的目的性的。這也意味着,如果某個表的字段越多,就越意味着該數據表可能承擔了過多職責。例如產品表就不應該包含該產品生產廠家的老闆的姓名。與其考慮如何縮減查詢的字段,不如一開始就把表設計得足夠精煉,這樣即使查詢的時候帶上了三五個無用字段,一來不會產生太過分的性能浪費,二來開發者理解數據結構也會更容易,三來開發效率也會得到大大提高。合理的表結構設計還可以降低數據庫的存儲壓力,何樂而不爲呢?哦對,那些外包公司給政府單位幹low逼百年祖傳表結構的同行,不好意思,這條不適合您們,要怪,就怪你們公司拉不來優質項目吧。哦對,這種公司的代碼質量,貌似壓根不會有人意識到要看本文吧……

Duke的咆哮

本文我以爲會在2個小時內寫完,沒想到來來回回寫了足足10小時,整整一個週末的時間。我這個人吧,也算是個刀子嘴豆腐心的人。口口聲聲要“咆哮”,要“發泄”,到頭來還是苦口婆心講,像個小丑一樣考慮怎樣講能激起讀者的興趣,寓教於樂也得到收穫。心情愈發低落,但也希望本文能點亮些希望的種子吧。程序員都應該和垃圾代碼做鬥爭,併爲之奮鬥一生。那麼,至少,在這最後的環節,讓我痛痛快快咆哮一回吧!

讓我們回過頭再來看垃圾代碼:

神代碼

首先看Controller層,方法參數列表裏用@RequestBody註解了一個Subscribe類,由於沒有加DTO字樣的後綴,所以默認的推測,該類應該是一個entity。如果是使用JPA或Hibernate的話,直接在Controller裏使用entity作爲請求參數接收對象是危險的。因爲很可能有一些字段是不希望被用戶看到和提交的。但因爲entity類是對錶對應所有字段的映射,抓包者可以傳入意料之外的字段。開發者出於謹慎,就得對所有不應傳入的字段進行校驗,這會加大不少的開發成本吧。如果要請求的字段較多,更合理的方式是使用一個DTO對象作爲參數接收器。但我們分析上下文,這個請求一共只用了Subscribe類對象的兩個屬性iduserId。在這種場景下,您真的有必要硬生生拉上來一個對象嗎?我一般的慣例是,如果請求參數少於3個,爲了避免不必要的複雜度,可以使用直接列舉的參數列表或路徑參數(GET請求)或Map(POST請求且主體爲JSON)去接收。對於多於3個的參數,則建議構建專用的DTO去接收參數,以充分利用靜態語言的優勢。

所以,從第一行代碼開始,他就已經輸了。

不僅輸了,而且輸了兩次。

連我的IDEA都智能地報告說:後邊這句throws Exception就是一句逗比代碼。其逗比程度高過下面不寫try-catch的那種。沒有try-catch,好歹聲明異常拋出代表該方法有可能拋出異常,你try-catch處理了所有Exception,該方法理論上就不會拋任何異常了。這時候你throws聲明該方法會拋出異常,是蠢呢還是壞呢?還好這是在Controller裏,開發者不需要調用這個方法。如果是在其他場合,這種寫法就是在說,*雖然勞資什麼Exception都不會拋出,但我tm就是非得噁心你上層代碼try-catch一下哦,啦啦啦。*那我簡直要打死你好麼?

接下來,“Boolean flag = ...”。雖然Booleanboolean只有一個字母之差,但前者爲引用類型,後者爲原始類型。對於只有正誤兩個取值的布爾來說,雖然創建一個引用類型不會帶來多少性能損失,但這種地方用不對,只能說明開發者的Java功底就是狗屁。

再往後,整個代碼片段最靈異的代碼出現了。一個用於訂閱的業務方法,返回了數字!乍一看,應該是用數字來指代成功或失敗吧。但是並沒有方法註釋告訴我們哪個數字對應什麼狀態。一邊這麼想着,一邊讓我們打開Service層方法的實現。OK,反正已經預料到是這個屎結果了,service層是孤兒,沒爹沒媽,爲啥有它?因爲裝模作樣的在分層啊!

那麼,來到DAO層,最最最震驚我的代碼出現了!這個返回值,它的含義居然是SQL語句執行影響的行數!

行數!

行數!

行……數……

我tm沒看錯吧?

姑且不論這個嵌套着又查又更新的SQL有多SB,單單這個返回值,穿過DAO穿過Service像輸卵管中的小蝌蚪笨拙地滑入了Controller麻麻的懷抱的神奇操作,就已經把在下唬得屁滾尿流了!

那麼回過頭我們來看這段SQL:

UPDATE TM_SUBSCRIBER SET IFSUBSCRIBE = '1' WHERE ID = (
    select sub.ID from TM_SUBSCRIBER sub join TM_ONLINE_STUDY s 
        on sub.STUDYID = s.ID 
        where SUB.COMPANYID=? AND sub.DELETEFLAG='0'
        AND s.DELETEFLAG='0' AND s.id=?
)

槽點多得我都列舉不完!

兩個表的別名取的那麼有詩意subs貫徹譚浩強式命名指南暫且不提,您subSUB混用也罷,畢竟某些特定版本的特定SQL不區分別名大小寫。您兩個查詢變量一個天上一個地下被兩個刪除標識查詢限定隔開也罷,您能不能先解釋一下這段SQL的含義?

說人話:

先獲取某公司是否參加了某在線課程,然後如果參加了,就將其訂閱標識設爲true……

這TM什麼邏輯?!

感情某公司如果報名了該課程,你們不管三七二十一先在訂閱表裏插入該公司,等該公司點擊了“訂閱”你們再將“是否訂閱”置true?

難道……

不應該是當某公司點擊了訂閱按鈕後,往訂閱表裏插入一條包含課程ID和公司ID的記錄到訂閱表裏麼?

跪了,服了,哭了。

因爲TM要接手這種SB代碼人,是我!!!!!!!!!!!!!!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章