代碼整潔的大前提
代碼大部分時候是用來維護的,而不是用來實現功能的
這個原則適用於大部分的工程。咱們的代碼,一方面是編譯好讓機器執行,完成功能需求;另外一方面,是寫給身邊的隊友和本身看的,須要長期維護,並且大部分項目都不是朝生夕死的短命鬼。
大部分狀況下,若是不能寫出清晰好看的代碼,可能本身一時爽快,後續維護付出的代價和成本將遠高於你的想象。
對清晰好看代碼的追求精神,比全部的技巧都要重要。
優秀的代碼大部分是能夠自描述的,好於文檔和註釋
當你翻看不少開源代碼時,會發現註釋甚至比咱們本身寫的項目都少,可是卻能看的很舒服。當讀完源碼時,不少功能設計就都清晰明瞭了。經過仔細斟酌的方法命名、清晰的流程控制,代碼自己就能夠拿出來看成文檔使用,並且它永遠不會過時。
相反,註釋不能讓寫的爛的代碼變的更好。若是別人只能依靠註釋讀懂你的代碼的時候,你必定要反思代碼出現了什麼問題(固然,這裏不是說你們不要寫註釋了)。
說下比較適合寫註釋的兩種場景:
- public interface,向別人明確發佈你功能的語義,輸入輸出,且不須要關注實現。
- 功能容易有歧義的點,或者涉及比較深層專業知識的時候。好比,若是你寫一個客戶端,各類config參數的含義等。
設計模式只是手段,代碼清晰纔是目的
以前見過一些所謂「高手」的代碼都比較抽象,各類工廠、各類繼承。想找到一個實現老是要山路十八彎,一個工程裏大部分的類是抽象類或者接口,找不到一兩句實現的代碼,整個讀起代碼來很不暢。我跟他聊起來的時候,他的主要立場是:保留合適的擴展點,克服掉全部的硬編碼。
其實在我看來,也許他的代碼被「過分設計」了。首先必需要認可的是,在同一個公司工做的同事,水平是良莠不齊的。不管你用瞭如何高大上的設計,若是大多數人都不能理解你的代碼或者讀起來很費勁的話,其實這是一個失敗的設計。
當你的系統內大部分抽象只有一個實現的時候,要好好思考一下,是否是設計有點過分了,清晰永遠是第一準則。
代碼整潔的常見手段
記住原則後,咱們開始進入實踐環節,先來看下有哪些促成clean code的常見手段。
code review
不少大公司會用git的pull request機制來作code review。咱們重點應該review什麼?是代碼的格式、業務邏輯仍是代碼風格?我想說的是,凡是能經過機器檢查出來的事情,無需經過人。好比換行、註釋、方法長度、代碼重複等。除了基本功能需求的邏輯合理沒有bug外,咱們更應該關注代碼的設計與風格。好比,一段功能是否是應該屬於一個類、是否是有不少類似的功能能夠抽取出來複用、代碼太過冗長難懂等等。
我我的很是推崇集體code review,由於不少時候,組裏相對高級的工程師可以一眼發現代碼存在較大設計缺陷,提出改進意見或者重構方式。咱們能夠在整個小組內造成一個好的文化傳承和風格統一,而且很大程度上培養了你們對clean code的熱情。
勤於重構
好的代碼,通常都不是一撮而就的。即便一開始設計的代碼很是優秀,隨着業務的快速迭代,也可能被改的面目全非。
爲了不重構帶來的負面影響(delay需求或者帶來bug),咱們須要作好如下的功課:
① 掌握一些常見的「無痛」重構技巧,這在下文會有具體講解。
② 小步快跑,不要企圖一口吃成個胖子。改一點,測試一點,一方面減小代碼merge的痛苦,另外一方面減小上線的風險。
③ 創建自動化測試機制,要作到即便代碼改壞了,也能保證系統最小核心功能的可用,而且保證本身修改的部分被測試覆蓋到。
④ 熟練掌握IDE的自動重構功能。這些會很大程度上減小咱們的體力勞動,避免犯錯。
靜態檢查
如今市面上有不少代碼靜態檢查的工具,也是發現bug和風格很差的比較容易的方式。能夠與發佈系統作集成,強制把主要問題修復掉才能夠上線。目前美團點評技術團隊內部的研發流程中已經廣泛接入了Sonar質量管理平臺。
多讀開源代碼和身邊優秀同窗的代碼
感謝開源社區,爲咱們提供了這麼好的學習機會。不管是JDK的源碼,仍是經典的Netty、Spring、Jetty,仍是一些小工具如Guava等,都是clean code的典範。多多學習,多多反思和總結,必有收益。
代碼整潔的常見技巧
前面的內容都屬於熱身,讓你們有個總體宏觀的認識。下面終於進入乾貨環節了,我會分幾個角度講解編寫整潔代碼的常見技巧和誤區。
通用技巧
單一職責
這是整潔代碼的最重要也是最基本的原則了。簡單來說,大到一個module、一個package,小到一個class、一個method乃至一個屬性,都應該承載一個明確的職責。要定義的東西,若是不能用一句話描述清楚職責,就把它拆掉。
咱們平時寫代碼時,最容易犯的錯誤是:一個方法幹了好幾件事或者一個類承載了許多功能。
先來聊聊方法的問題。我的很是主張把方法拆細,這是複用的基礎。若是方法幹了兩件事情,頗有可能其中一個功能的其餘業務有差異就很差重用了。另外語義也是不明確的。常常看到一個get()方法裏面居然修改了數據,這讓使用你方法的人情何以堪?若是不點進去看看實現,可能就讓程序陷入bug,讓測試陷入麻煩。
再來聊聊類的問題。咱們常常會看到「又臭又長」的service/biz層的代碼,裏面有幾十個方法,幹什麼的都有:既有增刪改查,又有業務邏輯的聚合。每次找到一個方法都費勁。不屬於一個領域或者一個層次的功能,就不要放到一塊兒。
咱們team在code review中,最常被批評的問題,就是一個方法應該歸屬於哪一個類。
優先定義總體框架
我寫代碼的時候,比較喜歡先去定義總體的框架,就是寫不少空實現,來把總體的業務流程穿起來。良好的方法簽名,用入參和出參來控制流程。這樣可以避免陷入業務細節沒法自拔。在腦海中先定義清楚流程的幾個階段,併爲每一個階段找到合適的方法/類歸屬。
這樣作的好處是,閱讀你代碼的人,不管讀到什麼深度,均可以清晰地瞭解每一層的職能,若是不care下一層的實現,徹底能夠跳過不看,而且方法的粒度也會恰到好處。
簡而言之,我比較推崇寫代碼的時候「廣度優先」而不是「深度優先」,這和我讀代碼的方式是一致的。固然,這件事情跟我的的思惟習慣有必定的關係,可能對抽象思惟能力要求會更高一些。若是開始寫代碼的時候這些不夠清晰,起碼要經過不斷地重構,使代碼達到這樣的成色。
清晰的命名
老生常談的話題,這裏不展開講了,可是必需要mark一下。有的時候,我思考一個方法命名的時間,比寫一段代碼的時間還長。緣由仍是那個邏輯:每當你寫出一個相似於"temp"、"a"、"b"這樣變量的時候,後面每個維護代碼的人,都須要用幾倍的精力才能理順。
而且這也是代碼自描述最重要的基礎。
避免過長參數
若是一個方法的參數長度超過4個,就須要警戒了。一方面,沒有人可以記得清楚這些函數的語義;另外一方面,代碼的可讀性會不好;最後,若是參數很是多,意味着必定有不少參數,在不少場景下,是沒有用的,咱們只能構造默認值的方式來傳遞。
解決這個問題的方法很簡單,通常狀況下咱們會構造paramObject。用一個struct或者一個class來承載數據,通常這種對象是value object,不可變對象。這樣,能極大程度提升代碼的可複用性和可讀性。在必要的時候,提供合適的build方法,來簡化上層代碼的開發成本。
避免過長方法和類
一個類或者方法過長的時候,讀者老是很崩潰的。簡單地把方法、類和職責拆細,每每會有立竿見影的成效。以類爲例,拆分的維度有不少,常見的是橫向/縱向。例如,若是一個service,處理的是跟一個庫表對象相關的全部邏輯,橫向拆分就是根據業務,把創建/更新/修改/通知等邏輯拆到不一樣的類裏去;而縱向拆分,指的是
把數據庫操做/MQ操做/Cache操做/對象校驗等,拆到不一樣的對象裏去,讓主流程儘可能簡單可控,讓同一個類,表達儘可能同一個維度的東西。
讓相同長度的代碼段表示相同粒度的邏輯
這裏想表達的是,儘可能多地去抽取private方法,讓代碼具備自描述的能力。舉個簡單的例子
public void doSomeThing(Map params1,Map params2){
Do1 do1 = getDo1(params1);
Do2 do2 = new Do2();
do2.setA(params2.get("a"));
do2.setB(params2.get("b"));
do2.setC(params2.get("c"));
mergeDO(do1,do2);
}
private void getDo1(Map params1);
private void mergeDo(do1,do2){...};複製代碼
相似這種代碼,在業務代碼中隨處可見。獲取do1是一個方法,merge是一個方法,但獲取do2的代碼卻在主流程裏寫了。這種代碼,流程越長,讀起來越累。不少人讀代碼的邏輯,是「廣度優先」的。先讀懂主流程,再去看細節。相似這種代碼,若是可以把構造do2的代碼,提取一個private 方法,就會舒服不少。
面向對象設計技巧
貧血與領域驅動
不得不認可,Spring已經成爲企業級Java開發的事實標準。而大部分公司採用的三層/四層貧血模型,已經讓咱們的編碼習慣,變成了面向DAO而不是面向對象。
缺乏了必要的模型抽象和設計環節,使得代碼冗長,複用程度比較差。每次擼代碼的時候,從mapper擼起,好像已經成爲不成文的規範。
好處是上手簡單,學習成本低。可是每次都不能重用,而後面對兩三千行的類看着眼花的時候,個人心是很痛的。關於領域驅動的設計模式,本文不會展開去講。迴歸面向對象,仍是跟你們share一些比較好的code技巧,可以在一個通用的框架下,儘可能好的寫出漂亮可重用的code。
我的認爲,一個好的系統,必定離不開一套好的模型定義。梳理清楚系統中的核心模型,清楚的定義每一個方法的類歸屬,不管對於代碼的可讀性、可交流性,仍是和產品的溝通,都是有莫大好處的。
爲每一個方法找到合適的類歸屬,數據和行爲儘可能要在一塊兒
若是一個類的全部方法,都是在操做另外一個類的對象。這時候就要仔細想想類的設計是否合理了。理論上講,面向對象的設計,主張數據和行爲在一塊兒。這樣,對象之間的結構纔是清晰的,也能減小不少沒必要要的參數傳遞。
不過這裏面有一個要討論的方法:service對象。若是操做一個對象數據的全部方法都創建在對象內部,可能使對象承載了不少並不屬於它自己職能的方法。
例如,我定義一個類,叫作person,。這個類有不少行爲,好比:吃飯、睡覺、上廁所、生孩子;也有不少字段,好比:姓名、年齡、性格。
很明顯,字段從更大程度上來說,是定義和描述我這我的的,但不少行爲和個人字段並不相關。上廁所的時候是不會關心我是幾歲的。若是把全部關於人的行爲所有在person內部承載,這個類必定會膨脹的不行。
這時候就體現了service方法的價值,若是一個行爲,沒法明確屬於哪一個領域對象,牽強地融入領域對象裏,會顯得很不天然。這時候,無狀態的service能夠發揮出它的做用。但必定要把握好這個度,迴歸本質,咱們要把屬於每一個模型的行爲合理的去劃定歸屬。
警戒static
static方法,本質上來說是面向過程的,沒法清晰地反饋對象之間的關係。雖然有一些代碼實例(本身實現單例或者Spring託管等)的無狀態方法能夠用static來表示,但這種抽象是淺層次的。說白了,若是咱們全部調用static的地方,都寫上import static,那麼全部的功能就由類本身在承載了。
讓我畫一個類圖?尷尬了……畫不出來。
而單例的膨脹,很大程度上也是貧血模型帶來的反作用。若是對象自己有血有肉,就不須要這麼多無狀態方法。
static真正適用的場景:工具方法,而不是業務方法。
巧用method object
method object是大型重構的經常使用技巧。當一段邏輯特別複雜的代碼,充斥着各類參數傳遞和是非因果判斷的時候,我首先想到的重構手段是提取method object。所謂method object,是一個有數據有行爲的對象。依賴的數據會成爲這個對象的變量,全部的行爲會成爲這個對象的內部方法。利用成員變量代替參數傳遞,會讓代碼簡潔清爽不少。而且,把一段過程式的代碼轉換成對象代碼,爲不少面向對象編程纔可使用的繼承/封裝/多態等提供了基礎。
舉個例子,上文引用的代碼若是用method object表示大概會變成這樣
class DoMerger{
map params1;
map params2;
Do1 do1;
Do2 do2;
public DoMerger(Map params1,Map params2){
this.params1 = params1;
this.params2 = parmas2;
}
public void invoke(){
do1 = getDo1();
do2 = getDo2();
mergeDO(do1,do2);
}
private Do1 getDo1();
private Do2 getDo2();
private void mergeDo(){
print(do1+do2);
}
}複製代碼
面向接口編程
面向接口編程是不少年來你們造成的共識和最佳實踐。最先的理論是便於實現的替換,但如今更顯而易見的好處是避免public方法的膨脹。一個對外publish的接口,必定有明確的職責。要判斷每個public方法是否應該屬於同一個interface,是很容易的。
整個代碼基於接口去組織,會很天然地變得很是清晰易讀。關注實現的人才去看實現,不是嘛?
正確使用繼承和組合
這也是個在業界被討論過好久的問題,也有不少論調。最新的觀點是組合的使用通常狀況下比繼承更爲靈活,尤爲是單繼承的體系裏,因此傾向於使用組合
,不然會讓子類承載不少不屬於本身的職能。
我的對此觀點持保留意見,在我經歷過的代碼中,有一個小規律,我分析一下。
protected abstract 這種是最值得使用繼承的,父類保留擴展點,子類擴展,沒什麼好說的。
protected final 這種方法,子類是隻能使用不能修改實現的。通常有兩種狀況:
① 抽象出主流程不能被修改的,然而通常狀況下,public final更適合這個職能。若是隻是流程的一部分,須要思考這個流程的類歸屬,大部分變成public組合到其餘類裏是更合適的。
② 父類是抽象類沒法直接對外提供服務,又不但願子類修改它的行爲,這種大多數狀況下屬於工具方法,比較適合用另外一個領域對象來承載並用組合的方式來使用。
protected 這種是有爭議的,是父類有默認實現但子類能夠擴展的。凡有擴展可能的,使用繼承更理想一些。不然,定義成final並考慮成組合。
綜上所述,我的認爲繼承更多的是爲擴展提供便利,爲複用而存在的方法最好使用組合的方式。固然,更爲大的原則是明確每一個方法的領域劃分。
代碼複用技巧
模板方法
這是我用得最多的設計模式了。每當有兩個行爲相似但又不徹底相同的代碼段時,我老是會想到模板方法。提取公共流程和可複用的方法到父類,保留不一樣的地方做爲abstract方法,由不一樣的子類去實現。
並在合適的時機,pull method up(複用)或者 pull method down(特殊邏輯)。
最後,把不屬於流程的、但可複用的方法,判斷是否是屬於基類的領域職責,再使用繼承或者組合的方法,爲這些方法找到合適的安家之處。
extract method
不少複用的級別沒有這麼大,也許只是幾行相同的邏輯被copy了好幾回,何不嘗試提取方法(private)。又能明確方法行爲,又能作到代碼複用,何樂不爲?
責任鏈
常常看到這樣的代碼,一連串相似的行爲,只是數據或者行爲不同。如一堆校驗器,若是成功怎麼樣、失敗怎麼樣;或者一堆對象構建器,各去構造一部分數據。碰到這種場景,我老是喜歡定義一個通用接口,入參是完整的要校驗/構造的參數,
出參是成功/失敗的標示或者是void。而後有不少實現器分別實現這個接口,再用一個集合把這堆行爲串起來。最後,遍歷這個集合,串行或者並行的執行每一部分的邏輯。
這樣作的好處是:
① 不少通用的代碼能夠在責任鏈原子對象的基類裏實現;
② 代碼清晰,開閉原則,每當有新的行爲產生的時候,只須要定義行的實現類並添加到集合裏便可;
③ 爲並行提供了基礎。
爲集合顯式定義它的行爲
集合是個有意思的東西,本質上它是個容器,但因爲泛型的存在,它變成了能夠承載全部對象的容器。不少非集合的類,咱們能夠定義清楚他們的邊界和行爲劃分,可是裝進集合裏,它們卻都變成了一個樣子。不停地有代碼,各類循環集合,作一些類似的操做。
其實不少時候,能夠把對集合的操做顯示地封裝起來,讓它變得更有血有肉。
例如一個Map,它可能表示一個配製、一個緩存等等。若是全部的操做都是直接操做Map,那麼它的行爲就沒有任何語義。第一,讀起來就必需要深刻細節;第二,若是想從獲取配置讀取緩存的地方加個通用的邏輯,例如打個log什麼的,你能夠想象是多麼的崩潰。
我的提倡的作法是,對於有明確語義的集合的一些操做,尤爲是全局的集合或者被常用的集合,作一些封裝和抽象,如把Map封裝成一個Cache類或者一個config類,再提供GetFromCache這樣的方法。
總結
本文從clean code的幾個大前提出發,而後提出了實踐clean code的一些手段,重點放在促成clean code的一些經常使用編碼和重構技巧。
固然,這些只表明筆者本人的一點點感悟。好的代碼,最最須要的,仍是你們不斷追求卓越的精神。歡迎你們一塊兒探索交流這個領域,爲clean code提供更多好的思路與方法。
做者簡介
王燁,如今是美團點評旅遊後臺研發組的工程師,以前曾經在百度、去哪兒和優酷工做過,專一Java後臺開發。對於網絡編程和併發編程具備濃厚的興趣,曾經作過一些基礎組件,也翻過一些源碼,屬於比較典型的宅男技術控。期待可以與更多知己,在coding的路上並肩前行~