由量變到質變 寫出高質量代碼

本文由於總結《阿里Java開發手冊》、《用友技術review手冊》及個人Java開發工作經驗,並結合這半年來的源碼閱讀經驗進行編寫。回顧那些寫過的 讀過的代碼,回顧自己。

第一章 基礎編碼規範

1.1 命名規範

  • 代碼中的命名均不能以下劃線或美元符號開始,也不能以下劃線或美元符號結束。
    tips:JDK動態代理生成的代理類 類名使用了$符號開頭,如$Proxy1。

  • 代碼中的命名嚴禁使用拼音與英文混合的方式,更不允許直接使用中文的方式。
    tips:正確的英文拼寫和語法可以讓閱讀者易於理解,避免歧義。注意,即使純拼音命名方式也要避免採用。alibaba,yonyou,Beijing等國際通用的名稱,可視同英文。
    在我們的財務相關模塊的工程代碼及數據庫表設計中,可以看到一些中文縮寫的命名方式,如:arap_djzb,arap是“應收應付”的英文縮寫,djzb是“單據主表”的漢語拼音首字母,zdr、shr、lrr都是漢語拼音首字母縮寫。當然,這些都是歷史包袱,經歷了這麼多年的代碼積累,很難從底層去修正咯,但在自己的實際編碼中要以史爲鑑,讓自己的代碼更加優雅規範,這也是同事是否尊重你的重要考量之一。

  • 類名使用UpperCamelCase——大駝峯風格,但以下情形例外: DO / BO / DTO/ VO / AO /
    PO / UID等。
    tips:合理的類名後綴能夠讓我們在開發中快速地找到自己想要的代碼,想看某個業務層就ctrl + shift + T搜索“XXXBO”,想看某展示層代碼 就搜索“XXXVO”。

  • 抽象類命名使用 Abstract 或 Base 開頭;異常類命名使用 Exception 結尾;測試類命名 以它要測試的類的名稱開始,以 Test 結尾。
    例如:Spring框架的AbstractApplicationContext和Mybatis框架的BaseExecutor都是抽象類。

  • 方法名、參數名、成員變量、局部變量都統一使用lowerCamelCase——小駝峯風格。

  • 常量命名全部大寫,單詞間用下劃線隔開,力求語義表達完整清楚,不要嫌名字長。
    tips:實際編碼中,有時確實會嫌某常量名太長,不便於使用。以後應該在語義完整清楚的情況下再考慮儘量縮短名稱長度。

  • 類型與中括號緊挨相連來表示數組。
    正例:定義整形數組 int[] arrayDemo;
    反例:在 main 參數中,使用 String args[]來定義。

  • POJO 類中布爾類型的變量,都不要加 is 前綴,否則部分框架解析會引起序列化錯誤。
    反例:定義爲基本數據類型 Boolean isDeleted 的屬性,它的方法也是 isDeleted(),RPC框架在反向解析的時候,“誤以爲”對應的屬性名稱是 deleted,導致屬性獲取不到,進而拋出異常。
    tips:我們的VO類中有很多is開頭的Boolean類型變量,如:DJZBHeaderVO中的isjszxzf(是否結算中心支付)字段。

  • 包名統一使用小寫,點分隔符之間有且僅有一個自然語義的英語單詞。包名統一使用 單數形式,但是類名如果有複數含義,類名可以使用複數形式。
    正例:應用工具類包名爲 com.alibaba.ai.util、類名爲 MessageUtils(此規則參考 spring 的框架結構)

  • 杜絕完全不規範的縮寫,避免望文不知義。
    反例:AbstractClass“縮寫”命名成 AbsClass;condition“縮寫”命名成 condi,此類隨 意縮寫嚴重降低了代碼的可閱讀性。

  • 爲了達到代碼自解釋的目標,任何自定義編程元素在命名時,使用盡量完整的單詞 組合來表達其意。
    正例:在 JDK 中,表達原子更新的類名爲:AtomicReferenceFieldUpdater。
    反例:變量 int a 的隨意命名方式。

  • 如果模塊、接口、類、方法使用了設計模式,在命名時需體現出具體模式。
    tips:將設計模式體現在名字中,有利於閱讀者快速理解架構設計理念。 如:Spring框架的BeanFactory(工廠模式)、JdkDynamicAopProxy(JDK動態代理模式)。

  • 接口類中的方法和屬性不要加任何修飾符號(public 也不要加),保持代碼的簡潔性,並加上有效的 Javadoc 註釋。儘量不要在接口裏定義變量,如果一定要定義變量,肯定是與接口方法相關,並且是整個應用的基礎常量。
    正例:接口方法簽名 void commit();
    接口基礎常量 String COMPANY = “alibaba”;
    反例:接口方法定義 public abstract void f();
    說明:JDK8 中接口允許有默認實現,那麼這個 default 方法,是對所有實現類都有價值的默認實現。

  • 接口和實現類的命名有兩套規則:
    【強制】對於 Service 和 DAO 類,基於 SOA 的理念,暴露出來的服務一定是接口,內部 的實現類用 Impl 的後綴與接口區別。
    正例:CacheServiceImpl 實現 CacheService 接口。 2)
    【推薦】 如果是形容能力的接口名稱,取對應的形容詞爲接口名(通常是–able 的形式)。
    正例:AbstractTranslator 實現 Translatable 接口。

  • 枚舉類名建議帶上 Enum 後綴,枚舉成員名稱需要全大寫,單詞間用下劃線隔開。
    說明:枚舉其實就是特殊的類,域成員均爲常量,且構造方法被默認強制是私有。
    正例:枚舉名字爲 ProcessStatusEnum 的成員名稱:SUCCESS / UNKNOWN_REASON。

  • 各層命名規約:
    A) Service/DAO 層方法命名規約
    1) 獲取單個對象的方法用 get 做前綴。
    2) 獲取多個對象的方法用 list 做前綴,複數形式結尾如:listObjects。
    3) 獲取統計值的方法用 count 做前綴。
    4) 插入的方法用 save/insert 做前綴。
    5) 刪除的方法用 remove/delete 做前綴。
    6) 修改的方法用 update 做前綴。
    B) 領域模型命名規約
    1) 數據對象:xxxDO,xxx 即爲數據表名。
    2) 數據傳輸對象:xxxDTO,xxx 爲業務領域相關的名稱。
    3) 展示對象:xxxVO,xxx 一般爲網頁名稱。
    4) POJO 是 DO/DTO/BO/VO 的統稱,禁止命名成 xxxPOJO。

1.2 常量定義

  • 不允許任何魔法值(意義不明的變量 / 常量)直接出現在代碼中。
    反例:
    String key = “Id#taobao_” + tradeId;
    cache.put(key, value);

  • 在 long 或者 Long 賦值時,數值後使用大寫的 L,不能是小寫的 l,小寫容易跟數字 1 混淆,造成誤解。
    說明:Long a = 2l; 寫的是數字的 21,還是 Long 型的 2?

  • 不要使用一個常量類維護所有常量,要按常量功能進行歸類,分開維護。
    說明:大而全的常量類,雜亂無章,使用查找功能才能定位到修改的常量,不利於理解和維護。
    正例:緩存相關常量放在類 CacheConsts 下;系統配置相關常量放在類 ConfigConsts 下。

  • 常量的複用層次有五層:跨應用共享常量、應用內共享常量、子工程內共享常量、包內共享常量、類內共享常量。
    1)跨應用共享常量:放置在二方庫中,通常是 client.jar 中的 constant 目錄下。
    2)應用內共享常量:放置在一方庫中,通常是子模塊中的 constant 目錄下。
    反例:易懂變量也要統一定義成應用內共享常量,兩位攻城師在兩個類中分別定義了表示 “是” 的變量。
    類 A 中:public static final String YES = “yes”;
    類 B 中:public static final String YES = “y”;
    A.YES.equals(B.YES),預期是 true,但實際返回爲 false,導致線上問題。
    3)子工程內部共享常量:即在當前子工程的 constant 目錄下。
    4) 包內共享常量:即在當前包下單獨的 constant 目錄下。
    5) 類內共享常量:直接在類內部 private static final 定義。

  • 如果變量值僅在一個固定範圍內變化用 enum 類型來定義。
    說明:如果存在名稱之外的延伸屬性應使用 enum 類型,下面正例中的數字就是延伸信息,表示一年中的第幾個季節。
    正例:

public enum SeasonEnum {
     SPRING(1), SUMMER(2), AUTUMN(3), WINTER(4);
     private int seq;     
     
     SeasonEnum(int seq){
         this.seq = seq;     
     } 
} 

1.3 代碼格式

代碼格式無非就是一些空格、換行、縮進的問題,沒必要死記,直接用開發工具(eclipse、IDEA)format一下即可,省時省力。

1.4 OOP 規約

  • 避免通過一個類的對象引用訪問此類的靜態變量或方法,增加編譯器解析成本。

  • 所有的覆寫方法,必須加@Override 註解。
    說明:getObject()與 get0bject()的問題。一個是字母的 O,一個是數字的 0,加@Override 可以準確判斷是否覆蓋成功。另外,如果在抽象類中對方法簽名(由方法名、參數的類型及順序 確定唯一的方法簽名)進行修改,其實現類會馬上編譯報錯。

  • 相同參數類型,相同業務含義,纔可以使用 Java 的可變參數,避免使用 Object。 說明:可變參數必須放置在參數列表的最後。(能用數組的就不要使用可變參數編程,可變參數在編譯時會被編譯成數組類型。可變參數能兼容數組類參數,但是數組類參數卻無法兼容可變參數。可變參數類型必須作爲參數列表的最後一項,且不能放在定長參數的前面。)

  • 正例:public List listUsers(String type, Long… ids) {…}

  • 外部正在調用或者二方庫依賴的接口,不允許修改方法簽名,避免對接口調用方產生影響。接口過時必須加@Deprecated 註解,並清晰地說明採用的新接口或者新服務是什麼。
    tips:
    一方庫:本工程範圍內,各個模塊和包之間的相互依賴。
    二方庫:引入的同一個公司內部的其他工程。
    三方庫:公司以外的其他依賴,比如apache,google等。

  • 不能使用過時的類或方法。
    說明:java.net.URLDecoder 中的方法 decode(String encodeStr) 這個方法已經過時,應該使用雙參數 decode(String source, String encode)。接口提供方既然明確是過時接口, 那麼有義務同時提供新的接口;作爲調用方來說,有義務去考證過時方法的新實現是什麼。

  • Object 的 equals 方法容易拋空指針異常,應使用常量或確定有值的對象來調用 equals。
    正例:“test”.equals(object);
    反例:object.equals(“test”);
    說明:推薦使用 java.util.Objects#equals(JDK7 引入的工具類)。個人認爲,當要比較兩個不確定的對象時,可以考慮使用這個類,如果只是想確定某個對象是否爲目標值,使用上面的方法並不差

  • 所有的相同類型的包裝類對象之間值的比較,全部使用 equals() 方法比較。
    說明:對於 Integer var = ? 在-128 至 127 範圍內的賦值,Integer 對象是在 IntegerCache.cache 產生,會複用已有對象,這個區間內的 Integer 值可以直接使用 == 進行判斷,但是這個區間之外的所有數據,都會在堆上產生,並不會複用已有對象,這是一個大坑, 推薦使用 equals() 方法進行判斷。

  • 關於基本數據類型與包裝數據類型的使用標準如下:
    1)【強制】所有的 POJO 類屬性必須使用包裝數據類型。
    2)【強制】RPC 方法的返回值和參數必須使用包裝數據類型。
    3)【推薦】所有的局部變量使用基本數據類型。
    說明:POJO 類屬性沒有初值是提醒使用者在需要使用時,必須自己顯式地進行賦值,任何 NPE 問題,或者入庫檢查,都由使用者來保證。
    正例:數據庫的查詢結果可能是 null,因爲自動拆箱,用基本數據類型接收有 NPE 風險。(不是很理解這個結論,ResultSet.getInt()等方法獲得的是基本數據類型,ORM映射時怎麼就拆箱咯?)
    反例:比如顯示成交總額漲跌情況,即正負 x%,x 爲基本數據類型,調用的 RPC 服務,調用不成功時,返回的是默認值,頁面顯示爲 0%,這是不合理的,應該顯示成中劃線。所以包裝數據類型的 null 值,能夠表示額外的信息,如:遠程調用失敗,異常退出。

  • 定義 DO/DTO/VO 等 POJO 類時,不要設定任何屬性默認值。
    反例:POJO 類的 gmtCreate 默認值爲 new Date(),但是這個屬性在數據提取時並沒有置入具 體值,在更新其它字段時又附帶更新了此字段,導致創建時間被修改成當前時間。

  • 序列化類新增屬性時,請不要修改 serialVersionUID 字段,避免反序列化失敗;如果完全不兼容升級,避免反序列化混亂,那麼請修改 serialVersionUID 值。
    說明:注意 serialVersionUID 不一致會拋出序列化運行時異常。

  • 構造方法裏面禁止加入任何業務邏輯,如果有初始化邏輯,請放在 init() 方法中。
    在很多client端的代碼中有看到這種編碼方式。

  • POJO 類必須寫 toString() 方法。使用 IDE 中的工具:source -> generate toString() 時,如果繼承了另一個 POJO 類,注意在前面加一下 super.toString()。 說明:在方法執行拋出異常時,可以直接調用 POJO 的 toString()方法打印其屬性值,便於排查問題。

  • 當一個類有多個構造方法,或者多個同名方法,這些方法應該按順序放置在一起, 便於閱讀。

  • 類內方法定義的順序依次是:公有方法或保護方法 > 私有方法 > getter/setter 方法。
    說明:公有方法是類的調用者和維護者最關心的方法,首屏展示最好;保護方法雖然只是子類 關心,也可能是“模板設計模式”下的核心方法;而私有方法外部一般不需要特別關心,是一個 黑盒實現;因爲承載的信息價值較低,所有 Service 和 DAO 的 getter/setter 方法放在類體 最後。

  • setter 方法中,參數名稱與類成員變量名稱一致,this.成員名 = 參數名。在 getter/setter 方法中,不要增加業務邏輯,增加排查問題的難度。

  • 循環體內,字符串的連接方式,使用 StringBuilder 的 append 方法進行擴展。

  • final 可以聲明類、成員變量、方法、以及本地變量,下列情況使用 final 關鍵字:
    1) 不允許被繼承的類,如:String 類。
    2) 不允許修改引用的域對象。
    3) 不允許被重寫的方法,如:POJO 類的 setter 方法。
    4) 不允許運行過程中重新賦值的局部變量,可以看到有些方法的形參中使用了final修飾。
    5) 避免上下文重複使用一個變量,使用 final 描述可以強制重新定義一個變量,方便更好 地進行重構。

  • 慎用 Object 的 clone 方法來拷貝對象。
    說明:對象的 clone 方法默認是淺拷貝,若想實現深拷貝需要重寫 clone 方法實現域對象的 深度遍歷式拷貝。

  • 類成員與方法訪問控制從嚴(合理使用Java的訪問修飾符):
    1) 如果不允許外部直接通過 new 來創建對象,那麼構造方法必須是 private。
    2) 工具類不允許有 public 或 default 構造方法。
    3) 類非 static 成員變量並且與子類共享,必須是 protected。
    4) 類非 static 成員變量並且僅在本類使用,必須是 private。
    5) 類 static 成員變量如果僅在本類使用,必須是 private。
    6) 若是 static 成員變量,考慮是否爲 final。
    7) 類成員方法只供類內部調用,必須是 private。
    8) 類成員方法只對繼承類公開,那麼限制爲 protected。 說明:任何類、方法、參數、變量,嚴控訪問範圍。過於寬泛的訪問範圍,不利於模塊解耦。
    思考:如果是一個 private 的方法,想刪除就刪除,可是一個 public 的 service 成員方法或成員變量,刪除一下,不得手心冒點汗嗎?變量像自己的小孩,儘量在自己的視線內,變量作 用域太大,無限制的到處跑,那麼你會擔心的。

1.5 集合處理

  • 關於 hashCode 和 equals 的處理,遵循如下規則:
    1) 只要重寫 equals,就必須重寫 hashCode。
    2) 因爲 Set 存儲的是不重複的對象,依據 hashCode 和 equals 進行判斷,所以 Set 存儲的 對象必須重寫這兩個方法。
    3) 如果自定義對象作爲 Map 的鍵,那麼必須重寫 hashCode 和 equals。
    說明:String 重寫了 hashCode 和 equals 方法,所以我們可以非常愉快地使用 String 對象 作爲 key 來使用。

  • ArrayList的subList結果不可強轉成ArrayList,否則會拋出ClassCastException 異常,即java.util.RandomAccessSubList cannot be cast to java.util.ArrayList。
    說明:subList 返回的是 ArrayList 的內部類 SubList,並不是 ArrayList 而是 ArrayList 的一個視圖,對於 SubList 子列表的所有操作最終會反映到原列表上。

  • 在 subList 場景中,高度注意對原集合元素的增加或刪除,均會導致子列表的遍歷、 增加、刪除產生 ConcurrentModificationException 異常。

  • 使用集合轉數組的方法,必須使用集合的 toArray(T[] array)方法,傳入的是類型完全一樣的數組,大小就是 list.size()。
    說明:使用 toArray 帶參方法,入參分配的數組空間不夠大時,toArray 方法內部將重新分配 內存空間,並返回新數組地址;如果數組元素個數大於實際所需,下標爲[ list.size() ] 的數組元素將被置爲 null,其它數組元素保持原值,因此最好將方法入參數組大小定義與集 合元素個數一致。

// 正例:   
List<String> list = new ArrayList<String>(2);      
list.add("guan");      
list.add("bao");       
String[] array = new String[list.size()];      
array = list.toArray(array);    
// 反例:直接使用 toArray 無參方法存在問題,此方法返回值只能是 Object[]類,
// 若強轉其它 類型數組將出現 ClassCastException 錯誤。 

// 這是我平時的寫法,初始化一個list.size()大小的數組似乎效率更好一些,如果數組的容量
// 比list小,原來的數組對象不會被使用,浪費系統資源
String[] strs = list.toArray(new String[0]);
  • 使用工具類 Arrays.asList()把數組轉換成集合時,不能使用其修改集合相關的方法,它的add/remove/clear 方法會拋出 UnsupportedOperationException 異常。 說明:asList() 的返回對象是一個 Arrays 的內部類ArrayList(而不是java.util.ArrayList),該內部類 並沒有實現集合的修改/刪除等方法。Arrays.asList 體現的是適配器模式,只是轉換接口,後臺的數據仍是數組。
String[] str = new String[] { "you", "wu" };     
List list = Arrays.asList(str);   
第一種情況:list.add("yangguanbao"); 運行時異常。   
第二種情況:str[0] = "gujin"; 那麼 list.get(0)也會隨之修改。 
  • 泛型通配符<? extends T>來接收返回的數據,此寫法的泛型集合不能使用 add() 方 法,而<? super T>不能使用 get() 方法,作爲接口調用賦值時易出錯。
// <? extends T>:上界通配符(Upper Bounds Wildcards)  
// <? super T>:下界通配符(Lower Bounds Wildcards)   
List<? extends C> list1; // list1 的元素的類型只能是 C 和 C 的子類。
List<? super C> list2; // list2 的元素的類型只能是 C 和 C 的父類。
// 簡單來說就是 <? extends C> 上界爲 C 類型範圍粗略理解爲 [C,+∞),
// 不允許添加除 null 的元素,獲取的元素類型是 C ;
// <? super C> 下界爲 C 類型範圍粗略理解爲 (-∞,C],允許添加 C 以及 C 的子類類型元素,
// 獲取的元素類型是 Object

// 擴展說一下 PECS(Producer Extends Consumer Super)原則。  
// 第一、頻繁往外讀取內容的,適合用<? extends T>。  
// 第二、經常往裏插入的,適合用<? super T>。 
  • 不要在 foreach 循環裏進行元素的 remove/add 操作。remove 元素請使用 Iterator 方式,如果併發操作,需要對 Iterator 對象加鎖。
// 正例:
List<String> list = new ArrayList<>();      
list.add("1");      
list.add("2");    
Iterator<String> iterator = list.iterator();    
while (iterator.hasNext()) {          
    String item = iterator.next();                   
    if (刪除元素的條件) {                          
        iterator.remove();                 
    }      
} 

// 反例:對比ArrayList的remove()和Iterator的remove()方法,可以找到其中的坑。
for (String item : list) {      
    if ("1".equals(item)) {          
        list.remove(item);          
    }      
} 
  • 在 JDK7 版本及以上,Comparator 實現類要滿足如下三個條件,不然 Arrays.sort(), Collections.sort() 會報 IllegalArgumentException 異常。 說明:三個條件如下 1) x,y 的比較結果和 y,x 的比較結果相反。 2) x>y,y>z,則 x>z。 3) x=y,則 x,z 比較結果和 y,z 比較結果相同。
// 反例:下例中沒有處理相等的情況,實際使用中可能會出現異常: 
new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
    	return o1.getId() > o2.getId() ? 1 : -1;
    }
};  
  • 集合泛型定義時,在 JDK7 及以上,使用 diamond 語法或全省略。
    說明:菱形泛型,即 diamond,直接使用<>來指代前邊已經指定的類型。
// 正例: 
// <> diamond 方式 
HashMap<String, String> userCache = new HashMap<>(16); 
// 全省略方式 
ArrayList<User> users = new ArrayList(10);
  • 集合初始化時,指定集合初始值大小。
    說明:HashMap 使用 HashMap(int initialCapacity) 初始化。
    正例:initialCapacity = (需要存儲的元素個數 / 負載因子) + 1。注意負載因子(即loader factor)默認爲 0.75,如果暫時無法確定初始值大小,請設置爲 16(即默認值)。
    反例:HashMap 需要放置 1024 個元素,由於沒有設置容量初始大小,隨着元素不斷增加,容量 7 次被迫擴大,resize() 需要重建 hash 表,嚴重影響性能。

  • 使用 entrySet 遍歷 Map 類集合 K-V,而不是 keySet 方式進行遍歷。
    說明:keySet 其實是遍歷了 2 次,一次是轉爲 Iterator 對象,另一次是從 hashMap 中取出 key 所對應的 value。而 entrySet 只是遍歷了一次就把 key 和 value 都放到了 entry 中,效率更高。如果是 JDK8,使用 Map.foreach() 方法。
    正例:values()返回的是 V 值集合,是一個 list 集合對象;keySet()返回的是 K 值集合,是 一個 Set 集合對象;entrySet()返回的是 K-V 值組合集合。

// 創建一個Map
Map<String, Object> infoMap = new HashMap<>();
infoMap.put("name", "Zebe");
infoMap.put("site", "www.zebe.me");
infoMap.put("email", "[email protected]");

// 傳統的Map迭代方式
for (Map.Entry<String, Object> entry : infoMap.entrySet()) {
    System.out.println(entry.getKey() + ":" + entry.getValue());
}

// JDK8的迭代方式
infoMap.forEach((key, value) -> {
    System.out.println(key + ":" + value);
});
  • 高度注意 Map 類集合 K-V 能不能存儲 null 值的情況,如下表格:
集合類 Key Value Super 說明
HashMap 允許爲 null 允許爲 null AbstractMap 線程不安全
ConcurrentHashMap 不允許爲 null 不允許爲 null AbstractMap 鎖分段技術(JDK8:CAS)
Hashtable 不允許爲 null 不允許爲 null Dictionary 線程安全
TreeMap 不允許爲 null 允許爲 null AbstractMap 線程不安全

反例: 由於 HashMap 的干擾,很多人認爲 ConcurrentHashMap 是可以置入 null 值,而事實上, 存儲 null 值時會拋出 NPE 異常。

  • 合理利用好集合的有序性(sort)和穩定性(order),避免集合的無序性(unsort)和不穩定性(unorder)帶來的負面影響。
    說明:有序性是指遍歷的結果是按某種比較規則依次排列的。穩定性指集合每次遍歷的元素次序是一定的。如:ArrayList 是 order/unsort;HashMap 是 unorder/unsort;TreeSet 是 order/sort。

  • 利用 Set 元素唯一的特性,可以快速對一個集合進行去重操作,避免使用 List 的 contains 方法進行遍歷、對比、去重操作。

1.6 併發處理

  • 獲取單例對象需要保證線程安全,其中的方法也要保證線程安全。
    說明:資源驅動類、工具類、單例工廠類都需要注意。

  • 創建線程或線程池時請指定有意義的線程名稱,方便出錯時回溯。

// 正例: 
public class TimerTaskThread extends Thread {
	public TimerTaskThread() {
		super.setName("TimerTaskThread");
		...
	}
} 
  • 線程資源必須通過線程池提供,不允許在應用中自行顯式創建線程。
    說明:使用線程池的好處是減少在創建和銷燬線程上所消耗的時間以及系統資源的開銷,解決資源不足的問題。如果不使用線程池,有可能造成系統創建大量同類線程而導致消耗完內存或者“過度切換”的問題。
    tips:我們的代碼中的很多線程都是自行顯式創建的,很少見到通過線程池進行統一管理的。

  • 線程池不允許使用 Executors 去創建,而是通過 ThreadPoolExecutor 的方式,這樣的處理方式讓寫的同學更加明確線程池的運行規則,規避資源耗盡的風險。
    說明:Executors 返回的線程池對象的弊端如下:
    1)FixedThreadPool 和 SingleThreadPool: 允許的請求隊列長度爲 Integer.MAX_VALUE,可能會堆積大量的請求,從而導致 OOM。
    2)CachedThreadPool 和 ScheduledThreadPool: 允許的創建線程數量爲 Integer.MAX_VALUE,可能會創建大量的線程,從而導致 OOM。

  • SimpleDateFormat 是線程不安全的類,一般不要定義爲 static 變量,如果定義爲 static,必須加鎖,或者使用 DateUtils 工具類。

// 正例:注意線程安全,使用 DateUtils。亦推薦如下處理: 
private static final ThreadLocal<DateFormat> df = new ThreadLocal<DateFormat>() {
	@Override
	protected DateFormat initialValue() {
		return new SimpleDateFormat("yyyy-MM-dd");
	}
};    
// 說明:如果是 JDK8 的應用,可以使用 Instant 代替 Date,LocalDateTime 代替 Calendar, 
// DateTimeFormatter 代替 SimpleDateFormat,
// 官方給出的解釋:simple beautiful strong immutable thread-safe。 
  • 高併發時,同步調用應該去考量鎖的性能損耗。能用無鎖數據結構,就不要用鎖;能鎖區塊,就不要鎖整個方法體;能用對象鎖,就不要用類鎖。
    說明:儘可能使加鎖的代碼塊工作量儘可能的小,避免在鎖代碼塊中調用 RPC 方法

  • 對多個資源、數據庫表、對象同時加鎖時,需要保持一致的加鎖順序,否則可能會造成死鎖。
    說明:線程一需要對錶 A、B、C 依次全部加鎖後纔可以進行更新操作,那麼線程二的加鎖順序也必須是 A、B、C,否則可能出現死鎖。

  • 併發修改同一記錄時,避免更新丟失,需要加鎖。要麼在應用層加鎖,要麼在緩存加鎖,要麼在數據庫層使用樂觀鎖,使用 version 作爲更新依據。
    說明:如果每次訪問衝突概率小於 20%,推薦使用樂觀鎖,否則使用悲觀鎖。樂觀鎖的重試次數不得小於 3 次。

  • 多線程並行處理定時任務時,Timer 運行多個 TimeTask 時,只要其中之一沒有捕獲拋出的異常,其它任務便會自動終止運行,使用 ScheduledExecutorService 則沒有這個問題。

  • 使用 CountDownLatch 進行異步轉同步操作,每個線程退出前必須調用 countDown 方法,線程執行代碼注意 catch 異常,確保 countDown 方法被執行到,避免主線程無法執行至 await 方法,直到超時才返回結果。
    說明:注意,子線程拋出異常堆棧,不能在主線程 try-catch 到。

  • 避免 Random 實例被多線程使用,雖然共享該實例是線程安全的,但會因競爭同一 seed 導致的性能下降。
    說明:Random 實例包括 java.util.Random 的實例或者 Math.random()的方式。
    正例:在 JDK7 之後,可以直接使用 API ThreadLocalRandom,而在 JDK7 之前,需要編碼保證每個線程持有一個實例。

  • 在併發場景下,通過雙重檢查鎖(double-checked locking)實現延遲初始化的優化問題隱患(指令重排會導致 雙檢鎖失效,產生隱患)(可參考 The “Double-Checked Locking is Broken” Declaration),推薦解決方案中較爲簡單一種(適用於 JDK5 及以上版本),將目標屬性聲明爲 volatile 型。

// 反例: 
class LazyInitDemo {
	private Helper helper = null;  
	public Helper getHelper() {
		if (helper == null) 
		synchronized(this) {
			if (helper == null)
				helper = new Helper();
			}
		return helper;
	}
	// other methods and fields...
}  
  • volatile 解決多線程內存不可見問題。對於一寫多讀,是可以解決變量同步問題, 但是如果多寫,同樣無法解決線程安全問題。
// 如果是 count++操作,使用如下類實現:   
AtomicInteger count = new AtomicInteger();
count.addAndGet(1);
// 如果是 JDK8,推 薦使用 LongAdder 對象,比 AtomicLong 性能更好(減少樂觀鎖的重試次數)。 
  • HashMap 在容量不夠進行 resize 時由於高併發可能出現死鏈,導致 CPU 飆升,在開發過程中可以使用其它數據結構或加鎖來規避此風險。

  • ThreadLocal 無法解決共享對象的更新問題,ThreadLocal 對象建議使用 static 修飾。這個變量是針對一個線程內所有操作共享的,所以設置爲靜態變量,所有此類實例共享此靜態變量 ,也就是說在類第一次被使用時裝載,只分配一塊存儲空間,所有此類的對象(只要是這個線程內定義的)都可以操控這個變量。

1.7 控制語句

  • 在一個 switch 塊內,每個 case 要麼通過 break/return 等來終止,要麼註釋說明程序將繼續執行到哪一個 case 爲止;在一個 switch 塊內,都必須包含一個 default 語句並且放在最後,即使空代碼。

  • 在 if/else/for/while/do 語句中必須使用大括號,即使只有一行代碼。

  • 在高併發場景中,避免使用“等於”判斷作爲中斷或退出的條件。
    說明:如果併發控制沒有處理好,容易產生等值判斷被“擊穿”的情況,使用大於或小於的區間判斷條件來代替。
    反例:判斷剩餘獎品數量等於 0 時,終止發放獎品,但因爲併發處理錯誤導致獎品數量瞬間變成了負數,這樣的話,活動無法終止。

  • 表達異常的分支時,少用 if-else 方式,這種方式可以改寫成:

if (condition) {
	...
	return obj;
}   
// 接着寫 else 的業務邏輯代碼;  
// 說明:如果非得使用 if()...else if()...else...方式表達邏輯,【強制】避免後續代碼維護困難,
// 		請勿超過 3 層。  
// 正例:超過 3 層的 if-else 的邏輯判斷代碼可以使用衛語句、策略模式、狀態模式等來實現,其中衛語句示例如下: 
public void today() {
	if (isBusy()) {
		System.out.println(“change time.);
		return;
	} 
	if (isFree()) {
		System.out.println(“go to travel.);
		return;
	} 
 	System.out.println(“stay at home to learn Alibaba Java Coding Guidelines.);
 	return;
} 
  • 除常用方法(如 getXxx/isXxx)等外,不要在條件判斷中執行其它複雜的語句,將複雜邏輯判斷的結果賦值給一個有意義的布爾變量名,以提高可讀性。 說明:很多 if 語句內的邏輯相當複雜,閱讀者需要分析條件表達式的最終結果,才能明確什麼樣的條件執行什麼樣的語句,那麼,如果閱讀者分析邏輯表達式錯誤呢?
// 正例: 
// 僞代碼如下 
final boolean existed = (file.open(fileName, "w") != null) && (...) || (...); 
if (existed) {
	... 
}   
// 反例:在我們的代碼中可以看到很多這種把複雜冗長的邏輯判斷寫在if語句中的
if ((file.open(fileName, "w") != null) && (...) || (...)) {
    ... 
} 
  • 循環體中的語句要考量性能,以下操作儘量移至循環體外處理,如:定義對象、變量、 獲取數據庫連接,進行不必要的 try-catch 操作(這個 try-catch 是否可以移至循環體外)。

  • 避免採用取反邏輯運算符。
    說明:取反邏輯不利於快速理解,並且取反邏輯寫法必然存在對應的正向邏輯寫法。
    正例:使用 if (x < 628) 來表達 x 小於 628。
    反例:使用 if (!(x >= 628)) 來表達 x 小於 628。

  • 接口入參保護(即,參數校驗),這種場景常見的是用作批量操作的接口。

  • 下列情形,需要進行參數校驗:
    1) 調用頻次低的方法。
    2) 執行時間開銷很大的方法。此情形中,參數校驗時間幾乎可以忽略不計,但如果因爲參數錯誤導致中間執行回退,或者錯誤,那得不償失。
    3) 需要極高穩定性和可用性的方法。
    4) 對外提供的開放接口,不管是 RPC/API/HTTP 接口。
    5) 敏感權限入口。

  • 下列情形,不需要進行參數校驗:
    1) 極有可能被循環調用的方法。但在方法說明裏必須註明外部參數檢查要求。
    2) 底層調用頻度比較高的方法。畢竟是像純淨水過濾的最後一道,參數錯誤不太可能到底層纔會暴露問題。一般 DAO 層與 Service 層都在同一個應用中,部署在同一臺服務器中,所以 DAO 的參數校驗,可以省略。
    3) 被聲明成 private 只會被自己代碼所調用的方法,如果能夠確定調用方法的代碼傳入參數已經做過檢查或者肯定不會有問題,此時可以不校驗參數。

1.8 註釋規約

註釋感覺是我們代碼規範的重災區咯,也是大家最容易忽略的地方。

  • 類、類屬性、類方法的註釋必須使用 Javadoc 規範,使用/** 內容 */格式,不得使用 // xxx 方式。 說明:在 IDE 編輯窗口中,Javadoc 方式會提示相關注釋,生成 Javadoc 可以正確輸出相應注 釋;在 IDE 中,工程調用方法時,不進入方法即可懸浮提示方法、參數、返回值的意義,提高閱讀效率。

  • 所有的抽象方法(包括接口中的方法)必須要用 Javadoc 註釋、除了返回值、參數、 異常說明外,還必須指出該方法做什麼事情,實現什麼功能。
    說明:對子類的實現要求,或者調用注意事項,請一併說明。

  • 所有的類都必須添加創建者和創建日期。

  • 方法內部單行註釋,在被註釋語句上方另起一行,使用 // 註釋。方法內部多行註釋 使用 /* */ 註釋,注意與代碼對齊。

  • 所有的枚舉類型字段必須要有註釋,說明每個數據項的用途。

  • 與其用“半吊子”英文來註釋,不如用中文註釋把問題說清楚。專有名詞與關鍵字保持英文原文即可。
    反例:“TCP 連接超時” 解釋成 “傳輸控制協議連接超時”,理解反而費腦筋。

  • 代碼修改的同時,註釋也要進行相應的修改,尤其是參數、返回值、異常、核心邏輯等的修改。
    說明:代碼與註釋更新不同步,就像路網與導航軟件更新不同步一樣,如果導航軟件嚴重滯後, 就失去了導航的意義。

  • 謹慎註釋掉代碼。在上方詳細說明,而不是簡單地註釋掉。如果無用,則刪除。
    說明:代碼被註釋掉有兩種可能性。
    1)後續會恢復此段代碼邏輯。
    2)永久不用。前者如果沒有備註信息,難以知曉註釋動機。後者建議直接刪掉(代碼倉庫保存了歷史代碼)。

  • 對於註釋的要求:
    第一、能夠準確反應設計思想和代碼邏輯;
    第二、能夠描述業務含義,使別的程序員能夠迅速瞭解到代碼背後的信息。完全沒有註釋的大段代碼對於閱讀者形同天書,註釋是給自己看的,即使隔很長時間,也能清晰理解當時的思路;註釋也是給繼任者看的,使其能夠快速接替自己的工作。

  • 好的命名、代碼結構是自解釋的,註釋力求精簡準確、表達到位。避免出現註釋的一個極端:過多過濫的註釋,代碼的邏輯一旦修改,修改註釋是相當大的負擔。

// 反例: 
// put elephant into fridge  
put(elephant, fridge);      
// 方法名 put,加上兩個有意義的變量名 elephant 和 fridge,已經說明了這是在幹什麼,
// 語義清晰的代碼不需要額外的註釋。 
  • 特殊註釋標記,請註明標記人與標記時間。注意及時處理這些標記,通過標記掃描, 經常清理此類標記。線上故障有時候就是來源於這些標記處的代碼。
    1) 待辦事宜(TODO):( 標記人,標記時間,[預計處理時間] )
    表示需要實現,但目前還未實現的功能。這實際上是一個 Javadoc 的標籤,目前的 Javadoc 還沒有實現,但已經被廣泛使用。只能應用於類,接口和方法(因爲它是一個 Javadoc 標籤)。
    2) 錯誤,不能工作(FIXME):( 標記人,標記時間,[預計處理時間] )
    在註釋中用 FIXME 標記某代碼是錯誤的,而且不能工作,需要及時糾正的情況。

1.9 其它

  • 在使用正則表達式時,利用好其預編譯功能,可以有效加快正則匹配速度。
    說明:不要在方法體內定義:Pattern pattern = Pattern.compile(“規則”);

  • velocity 調用 POJO 類的屬性時,建議直接使用屬性名取值即可,模板引擎會自動按規範調用 POJO 的 getXxx(),如果是 boolean 基本數據類型變量(boolean 命名不需要加 is 前綴),會自動調用isXxx()方法。
    說明:注意如果是 Boolean 包裝類對象,優先調用 getXxx()的方法。

  • 注意 Math.random() 這個方法返回是 double 類型,注意取值的範圍 0≤x<1(能夠 取到零值,注意除零異常),如果想獲取整數類型的隨機數,不要將 x 放大 10 的若干倍然後取整,直接使用 Random 對象的 nextInt 或者 nextLong 方法。

  • 獲取當前毫秒數 System.currentTimeMillis(); 而不是 new Date().getTime();
    說明:如果想獲取更加精確的納秒級時間值,使用 System.nanoTime()的方式。在 JDK8 中, 針對統計時間等場景,推薦使用 Instant 類。

  • 不要在視圖模板中加入任何複雜的邏輯。 說明:根據 MVC 理論,視圖的職責是展示,不要搶模型和控制器的活。

  • 任何數據結構的構造或初始化,都應指定大小,避免數據結構無限增長吃光內存。

  • 及時清理不再使用的代碼段或配置信息。
    說明:對於垃圾代碼或過時配置,堅決清理乾淨,避免程序過度臃腫,代碼冗餘。
    正例:對於暫時被註釋掉,後續可能恢復使用的代碼片斷,在註釋代碼上方,統一規定使用三個斜槓(///)來說明註釋掉代碼的理由。

第二章 異常與日誌規範

2.1 異常處理

  • Java 類庫中定義的可以通過預檢查方式規避的 RuntimeException 異常不應該通過 catch 的方式來處理,比如:NullPointerException,IndexOutOfBoundsException 等等。
    說明:無法通過預檢查的異常除外,比如,在解析字符串形式的數字時,不得不通過 catch NumberFormatException 來實現
    正例:if (obj != null) {…}
    反例:try { obj.method(); } catch (NullPointerException e) {…}

  • 異常不要用來做流程控制,條件控制。 說明:異常設計的初衷是解決程序運行中的各種意外情況,且異常的處理效率比條件判斷方式要低很多。

  • catch 時請分清穩定代碼和非穩定代碼,穩定代碼指的是無論如何不會出錯的代碼。 對於非穩定代碼的 catch 儘可能進行區分異常類型,再做對應的異常處理。
    說明:對大段代碼進行 try-catch,使程序無法根據不同的異常做出正確的應激反應,也不利於定位問題,這是一種不負責任的表現。
    正例:用戶註冊的場景中,如果用戶輸入非法字符,或用戶名稱已存在,或用戶輸入密碼過於簡單,在程序上作出分門別類的判斷,並提示給用戶。

  • 捕獲異常是爲了處理它,不要捕獲了卻什麼都不處理而拋棄之,如果不想處理它,請將該異常拋給它的調用者。最外層的業務使用者,必須處理異常,將其轉化爲用戶可以理解的內容。

  • 有 try 塊放到了事務代碼中,catch 異常後,如果需要回滾事務,一定要注意手動回滾事務。

  • finally 塊必須對資源對象、流對象進行關閉,有異常也要做 try-catch。
    說明:如果 JDK7 及以上,可以使用 try-with-resources 方式。

  • 不要在 finally 塊中使用 return。
    說明:finally 塊中的 return 返回後方法結束執行,不會再執行 try 塊中的 return 語句。

  • 捕獲異常與拋異常,必須是完全匹配,或者捕獲異常是拋異常的父類。
    說明:如果預期對方拋的是繡球,實際接到的是鉛球,就會產生意外情況。

  • 方法的返回值可以爲 null,不強制返回空集合,或者空對象等,必須添加註釋充分說明什麼情況下會返回 null 值。
    說明:本手冊明確防止 NPE 是調用者的責任。即使被調用方法返回空集合或者空對象,對調用者來說,也並非高枕無憂,必須考慮到遠程調用失敗、序列化失敗、運行時異常等場景返回 null 的情況。

  • 防止 NPE,是程序員的基本修養,注意 NPE 產生的場景:
    1)返回類型爲基本數據類型,return 包裝數據類型的對象時,自動拆箱有可能產生 NPE。
    反例:public int f() { return Integer 對象}, 如果爲 null,自動解箱拋 NPE。
    2) 數據庫的查詢結果可能爲 null。
    3) 集合裏的元素即使 isNotEmpty,取出的數據元素也可能爲 null。
    4) 遠程調用返回對象時,一律要求進行空指針判斷,防止 NPE。
    5) 對於 Session 中獲取的數據,建議 NPE 檢查,避免空指針。
    6) 級聯調用 obj.getA().getB().getC();一連串調用,易產生 NPE。
    正例:使用 JDK8 的 Optional 類來防止 NPE 問題。

  • 定義時區分 unchecked / checked 異常,避免直接拋出 new RuntimeException(), 更不允許拋出 Exception 或者 Throwable,應使用有業務含義的自定義異常。推薦業界已定義過的自定義異常,如:DAOException / ServiceException 等。

  • 對於公司外的 http/api 開放接口必須使用“錯誤碼”;而應用內部推薦異常拋出; 跨應用間 RPC 調用優先考慮使用 Result 方式,封裝 isSuccess()方法、“錯誤碼”、“錯誤簡短信息”。
    說明:關於 RPC 方法返回方式使用 Result 方式的理由。
    1)使用拋異常返回方式,調用方如果沒有捕獲到就會產生運行時錯誤。
    2)如果不加棧信息,只是 new 自定義異常,加入自己的理解的 error message,對於調用端解決問題的幫助不會太多。如果加了棧信息,在頻繁調用出錯的情況下,數據序列化和傳輸的性能損耗也是問題。

  • 避免出現重複的代碼(Don’t Repeat Yourself),即 DRY 原則。 說明:隨意複製和粘貼代碼,必然會導致代碼的重複,在以後需要修改時,需要修改所有的副本,容易遺漏。必要時抽取共性方法,或者抽象公共類,甚至是組件化。

// 正例:
// 一個類中有多個 public 方法,都需要進行數行相同的參數校驗操作,這個時候請抽取: 
private boolean checkParam(DTO dto) {...}

2.2 日誌規約

  • 應用中不可直接使用日誌系統(Log4j、Logback)中的 API,而應依賴使用日誌框架 SLF4J 中的API,使用門面模式的日誌框架,有利於維護和各個類的日誌處理方式統一。
import org.slf4j.Logger;  
import org.slf4j.LoggerFactory; 
 
private static final Logger logger = LoggerFactory.getLogger(Abc.class);  
  • 日誌文件至少保存 15 天,因爲有些異常具備以“周”爲頻次發生的特點。

  • 應用中的擴展日誌(如打點、臨時監控、訪問日誌等)命名方式:appName_logType_logName.log。 logType:日誌類型,如 stats/monitor/access 等;logName:日誌描述。這種命名的好處: 通過文件名就可知道日誌文件屬於什麼應用,什麼類型,什麼目的,也有利於歸類查找。
    正例:mppserver 應用中單獨監控時區轉換異常,如:mppserver_monitor_timeZoneConvert.log
    說明:推薦對日誌進行分類,如將錯誤日誌和業務日誌分開存放,便於開發人員查看,也便於通過日誌對系統進行及時監控。

  • 對 trace/debug/info 級別的日誌輸出,必須使用條件輸出形式或者使用佔位符的方式。
    說明:logger.debug("Processing trade with id: " + id + " and symbol: " + symbol); 如果日誌級別是 warn,上述日誌不會打印,但是會執行字符串拼接操作,如果 symbol 是對象, 會執行 toString()方法,浪費了系統資源,執行了上述操作,最終日誌卻沒有打印。

// 正例:
//(條件)建議採用如下方式 
if (logger.isDebugEnabled()) {
    logger.debug("Processing trade with id: " + id + " and symbol: " + symbol);
}
// 正例:(佔位符) 
logger.debug("Processing trade with id: {} and symbol : {} ", id, symbol);  
  • 避免重複打印日誌,浪費磁盤空間,務必在 log4j.xml 中設置 additivity=false。
<!-- 正例: -->
<logger name="com.taobao.dubbo.config" additivity="false">  
  • 異常信息應該包括兩類信息:案發現場信息和異常堆棧信息。如果不處理,那麼通過關鍵字 throws 往上拋出。
    正例:logger.error(各類參數或者對象 toString() + “_” + e.getMessage(), e);

  • 謹慎地記錄日誌。生產環境禁止輸出 debug 日誌;有選擇地輸出 info 日誌;如果使用 warn 來記錄剛上線時的業務行爲信息,一定要注意日誌輸出量的問題,避免把服務器磁盤撐爆,並記得及時刪除這些觀察日誌。
    說明:大量地輸出無效日誌,不利於系統性能提升,也不利於快速定位錯誤點。記錄日誌時請思考:這些日誌真的有人看嗎?看到這條日誌你能做什麼?能不能給問題排查帶來好處?

  • 可以使用 warn 日誌級別來記錄用戶輸入參數錯誤的情況,避免用戶投訴時,無所適從。如非必要,請不要在此場景打出 error 級別,避免頻繁報警。
    說明:注意日誌輸出的級別,error 級別只記錄系統邏輯出錯、異常或者重要的錯誤信息。

  • 儘量用英文來描述日誌錯誤信息,如果日誌中的錯誤信息用英文描述不清楚的話 使用中文描述即可,否則容易產生歧義。國際化團隊或海外部署的服務器由於字符集問題,【強制】 使用全英文來註釋和描述日誌錯誤信息。

第三章 數據庫規範

我們 to B 的業務主要使用的是Oracle和SQL server,去年參與適配了國產的華爲GaussDB及達夢DM數據庫。

3.1 建表規約

  • 臨時庫、表名必須以tmp爲前綴,如果是按照日期生成的,以日期爲後綴

  • 備份庫、表必須以bak爲前綴,如果是按照日期生成的,以日期爲後綴

  • 表達是與否概念的字段,必須使用 is_xxx 的方式命名,數據類型是 unsigned tinyint (1 表示是,0 表示否)。
    說明:任何字段如果爲非負數,必須是 unsigned。
    注意:POJO 類中的任何布爾類型的變量,都不要加 is 前綴,所以,需要在<resultMap>設置從is_xxx 到 Xxx 的映射關係。數據庫表示是與否的值,使用 tinyint 類型,堅持 is_xxx 的命名方式是爲了明確其取值含義與取值範圍。 正例:表達邏輯刪除的字段名 is_deleted,1 表示刪除,0 表示未刪除。
    tips:我們使用的是dr字段代表邏輯刪除,且POJO和布爾字段也未使用上述規範,這也與我們的JDBC框架有關,我們的JDBC框架是自己設計的,與mybatis等主流框架有很大不同。

  • 表名、字段名必須使用小寫字母或數字,禁止出現數字開頭,禁止兩個下劃線中間只出現數字。數據庫字段名的修改代價很大,因爲無法進行預發佈,所以字段名稱需要慎重考慮。
    說明:MySQL 在 Windows 下不區分大小寫,但在 Linux 下默認是區分大小寫。因此,數據庫名、表名、字段名,都不允許出現任何大寫字母,避免節外生枝。
    正例:aliyun_admin,rdc_config,level3_name
    反例:AliyunAdmin,rdcConfig,level_3_name

  • 表名不使用複數名詞。
    說明:表名應該僅僅表示表裏面的實體內容,不應該表示實體數量,對應於 DO 類名也是單數形式,符合表達習慣。

  • 禁用保留字,如 desc、range、match、delayed 等,請參考 MySQL 官方保留字。

  • 主鍵索引名爲 pk_字段名;唯一索引名爲 uk_字段名;普通索引名則爲 idx_字段名。
    說明:pk_ 即 primary key;uk_ 即 unique key;idx_ 即 index 的簡稱。

  • 小數類型爲 decimal,禁止使用 float 和 double。
    說明:float 和 double 在存儲的時候,存在精度損失的問題,很可能在值的比較時,得到錯誤的結果。如果存儲的數據範圍超過 decimal 的範圍,建議將數據拆成整數和小數分開存儲。

  • 如果存儲的字符串長度幾乎相等,使用 char 定長字符串類型。
    tips:公司這一點倒是做的比較規範。

  • varchar 是不定長字符串,不預先分配存儲空間,長度不要超過 5000,如果存儲長度大於此值,定義字段類型爲 text,獨立出來一張表,用主鍵來對應,避免影響其它字段索引效率。
    tips:Oracle的varchar最大長度爲4000,SQL server 8000,這是之前適配數據庫時踩過的坑。

  • 表必備三字段:id, gmt_create, gmt_modified。
    說明:其中 id 必爲主鍵,類型爲 bigint unsigned、單表時自增、步長爲 1。gmt_create、gmt_modified 的類型均爲 datetime 類型,前者現在時表示主動創建,後者過去分詞表示被動更新。

  • 表的命名最好是加上“業務名稱_表的作用”。 正例:alipay_task / force_project / trade_config

  • 庫名與應用名稱儘量一致。

  • 如果修改字段含義或對字段表示的狀態追加時,需要及時更新字段註釋。

  • 字段允許適當冗餘,以提高查詢性能,但必須考慮數據一致。冗餘字段應遵循:
    1)不是頻繁修改的字段。
    2)不是 varchar 超長字段,更不能是 text 字段。
    正例:商品類目名稱使用頻率高,字段長度短,名稱基本一成不變,可在相關聯的表中冗餘存儲類目名稱,避免關聯查詢。

  • 單錶行數超過 500 萬行或者單表容量超過 2GB,才推薦進行分庫分表。
    說明:如果預計三年後的數據量根本達不到這個級別,請不要在創建表時就分庫分表。

  • 合適的字符存儲長度,不但節約數據庫表空間、節約索引存儲,更重要的是提升檢索速度。
    正例:如下表,其中無符號值可以避免誤存負數,且擴大了表示範圍。

3.2 SQL語句

  • 不要使用 count(列名)或 count(常量)來替代 count(*),count(*)是 SQL92 定義的 標準統計行數的語法,跟數據庫無關,跟 NULL 和非 NULL 無關。
    說明:count(*)會統計值爲 NULL 的行,而 count(列名)不會統計此列爲 NULL 值的行

  • count(distinct col) 計算該列除 NULL 之外的不重複行數,注意 count(distinct col1, col2) 如果其中一列全爲 NULL,那麼即使另一列有不同的值,也返回爲 0

  • 當某一列的值全是 NULL 時,count(col)的返回結果爲 0,但 sum(col)的返回結果爲 NULL,因此使用 sum()時需注意 NPE 問題。
    正例:可以使用如下方式來避免 sum 的 NPE 問題:SELECT IF(ISNULL(SUM(g)),0,SUM(g)) FROM table;

  • 使用 ISNULL()來判斷是否爲 NULL 值。
    說明:NULL 與任何值的直接比較都爲 NULL。
    1) NULL<>NULL 的返回結果是 NULL,而不是 false。
    2) NULL=NULL 的返回結果是 NULL,而不是 true。
    3) NULL<>1 的返回結果是 NULL,而不是 true。

  • 在代碼中寫分頁查詢邏輯時,若 count 爲 0 應直接返回,避免執行後面的分頁語句。

  • 不得使用外鍵與級聯,一切外鍵概念必須在應用層解決。
    說明: 以學生和成績的關係爲例,學生表中的student_id是主鍵,那麼成績表中的student_id 則爲外鍵。如果更新學生表中的 student_id,同時觸發成績表中的 student_id 更新,即爲級聯更新。外鍵與級聯更新適用於單機低併發,不適合分佈式、高併發集羣;級聯更新是強阻塞,存在數據庫更新風暴的風險;外鍵影響數據庫的插入速度。

  • 禁止使用存儲過程,存儲過程難以調試和擴展,更沒有移植性。

  • 數據訂正(特別是刪除、修改記錄操作)時,要先 select,避免出現誤刪除,確認無誤才能執行更新語句。

  • in 操作能避免則避免,若實在避免不了,需要仔細評估 in 後邊的集合元素數量,控 制在 1000 個之內。

  • 如果有國際化需要,所有的字符存儲與表示,均以 utf-8 編碼,注意字符統計函數 的區別。
    說明:
    SELECT LENGTH(“輕鬆工作”); 返回爲 12
    SELECT CHARACTER_LENGTH(“輕鬆工作”); 返回爲 4
    如果需要存儲表情,那麼選擇 utf8mb4 來進行存儲,注意它與 utf-8 編碼的區別。

  • TRUNCATE TABLE 比 DELETE 速度快,且使用的系統和事務日誌資源少,但 TRUNCATE 無事務且不觸發 trigger,有可能造成事故,故不建議在開發代碼中使用此語句。
    說明:TRUNCATE TABLE 在功能上與不帶 WHERE 子句的 DELETE 語句相同。

3.3 ORM映射

我們的JDBC框架是自己研發的,之前也有看過Mybatis的源碼,兩者的設計及使用還是差別挺大的。

  • 在表查詢中,一律不要使用 * 作爲查詢的字段列表,需要哪些字段必須明確寫明。
    說明:
    1)增加查詢分析器解析成本。
    2)增減字段容易與 resultMap 配置不一致。
    3)無用字 段增加網絡消耗,尤其是 text 類型的字段。

  • POJO 類的布爾屬性不能加 is,而數據庫字段必須加 is_,要求在 resultMap 中進行字段與屬性之間的映射。
    說明:參見定義 POJO 類以及數據庫字段定義規定,在<resultMap>中增加映射,是必須的。 在 MyBatis Generator 生成的代碼中,需要進行對應的修改。

  • 不要用 resultClass 當返回參數,即使所有類屬性名與數據庫字段一一對應,也需要定義;反過來,每一個表也必然有一個 POJO 類與之對應。
    說明:配置映射關係,使字段與 DO 類解耦,方便維護。

  • sql.xml 配置參數使用:#{}, #param# 不要使用${} 此種方式容易出現 SQL 注入。

  • iBATIS 自帶的 queryForList(String statementName,int start,int size)不推 薦使用。
    說明:其實現方式是在數據庫取到statementName對應的SQL語句的所有記錄,再通過subList 取 start,size 的子集合。

// 正例:
Map<String, Object> map = new HashMap<>();    
map.put("start", start);    
map.put("size", size);    
  • 不允許直接拿 HashMap 與 Hashtable 作爲查詢結果集的輸出。
    說明:resultClass=”Hashtable”,會置入字段名和屬性值,但是值的類型不可控。

  • 更新數據表記錄時,必須同時更新記錄對應的 gmt_modified 字段值爲當前時間。

  • 不要寫一個大而全的數據更新接口。傳入爲 POJO 類,不管是不是自己的目標更新字段,都進行 update table set c1=value1,c2=value2,c3=value3; 這是不對的。執行 SQL 時,不要更新無改動的字段,一是易出錯;二是效率低;三是增加 binlog 存儲。

  • @Transactional 事務不要濫用。事務會影響數據庫的 QPS,另外使用事務的地方需要考慮各方面的回滾方案,包括緩存回滾、搜索引擎回滾、消息補償、統計修正等。

  • <isEqual>中的 compareValue 是與屬性值對比的常量,一般是數字,表示相等時帶上此條件;<isNotEmpty>表示不爲空且不爲 null 時執行;<isNotNull>表示不爲 null 值時 執行。

3.4 索引規範

  • 業務上具有唯一特性的字段,即使是多個字段的組合,也必須建成唯一索引。
    說明:不要以爲唯一索引影響了 insert 速度,這個速度損耗可以忽略,但提高查找速度是明顯的;另外,即使在應用層做了非常完善的校驗控制,只要沒有唯一索引,根據墨菲定律(只要有這個可能性 就一定會發生),必然有髒數據產生。

  • 超過三個表禁止 join。需要 join 的字段,數據類型必須絕對一致;多表關聯查詢時,保證被關聯的字段需要有索引。
    說明:即使雙表 join 也要注意表索引、SQL 性能。

  • 在 varchar 字段上建立索引時,必須指定索引長度,沒必要對全字段建立索引,根據實際文本區分度決定索引長度即可。
    說明:索引的長度與區分度是一對矛盾體,一般對字符串類型數據,長度爲 20 的索引,區分度會高達 90%以上,可以使用 count(distinct left(列名, 索引長度))/count(*)的區分度 來確定。

  • 頁面搜索嚴禁左模糊或者全模糊,如果需要請走搜索引擎來解決。
    說明:索引文件具有 B-Tree 的最左前綴匹配特性,如果左邊的值未確定,那麼無法使用此索引。

  • 如果有 order by 的場景,請注意利用索引的有序性。order by 最後的字段是組合索引的一部分,並且放在索引組合順序的最後,避免出現 file_sort 的情況,影響查詢性能。
    正例:where a=? and b=? order by c; 索引:a_b_c
    反例:索引中有範圍查找,那麼索引有序性無法利用,如:WHERE a>10 ORDER BY b; 索引 a_b 無法排序。

  • 利用覆蓋索引來進行查詢操作,避免回表。
    說明:如果一本書需要知道第 11 章是什麼標題,會翻開第 11 章對應的那一頁嗎?目錄瀏覽一下就好,這個目錄就是起到覆蓋索引的作用。
    正例:能夠建立索引的種類分爲主鍵索引、唯一索引、普通索引三種,而覆蓋索引只是一種查詢的一種效果,用 explain 的結果,extra 列會出現:using index。

  • 利用延遲關聯或者子查詢優化超多分頁場景。
    說明:MySQL 並不是跳過 offset 行,而是取 offset+N 行,然後返回放棄前 offset 行,返回 N 行,那當 offset 特別大的時候,效率就非常的低下,要麼控制返回的總頁數,要麼對超過特定閾值的頁數進行 SQL 改寫。
    正例:先快速定位需要獲取的 id 段,然後再關聯:
    SELECT a.* FROM 表1 a, ( select id from 表1 where 條件 LIMIT 100000,20 ) b where a.id=b.id

  • SQL 性能優化的目標:至少要達到 range 級別,要求是 ref 級別,如果可以是 consts 最好。
    說明:
    1)consts 單表中最多隻有一個匹配行(主鍵或者唯一索引),在優化階段即可讀取到數據。
    2)ref 指的是使用普通的索引(normal index)。
    3)range 對索引進行範圍檢索。
    反例:explain 表的結果,type=index,索引物理文件全掃描,速度非常慢,這個 index 級別比較range 還低,與全表掃描是小巫見大巫。

  • 建組合索引的時候,區分度最高的在最左邊。
    正例:如果 where a=? and b=? ,如果 a 列的幾乎接近於唯一值,那麼只需要單建 idx_a 索引即可。
    說明:存在非等號和等號混合時,在建索引時,請把等號條件的列前置。如:where c>? and d=? 那麼即使 c 的區分度更高,也必須把 d 放在索引的最前列,即索引 idx_d_c。

  • 防止因字段類型不同造成的隱式轉換,導致索引失效。

  • 創建索引時避免有如下極端誤解:
    1)寧濫勿缺。認爲一個查詢就需要建一個索引。
    2)寧缺勿濫。認爲索引會消耗空間、嚴重拖慢更新和新增速度。
    3)抵制惟一索引。認爲業務的惟一性一律需要在應用層通過“先查後插”方式解決。

安全規約

  • 隸屬於用戶個人的頁面或者功能必須進行權限控制校驗。
    說明:防止沒有做水平權限校驗就可隨意訪問、修改、刪除別人的數據,比如查看他人的私信 內容、修改他人的訂單。

  • 用戶敏感數據禁止直接展示,必須對展示數據進行脫敏。
    說明:中國大陸個人手機號碼顯示爲:158****9119,隱藏中間 4 位,防止隱私泄露。

  • 用戶輸入的 SQL 參數嚴格使用參數綁定或者 METADATA 字段值限定,防止 SQL 注入, 禁止字符串拼接 SQL 訪問數據庫。

  • 用戶請求傳入的任何參數必須做有效性驗證。
    說明:忽略參數校驗可能導致:
    1)page size 過大導致內存溢出;
    2)惡意 order by 導致數據庫慢查詢;
    3)任意重定向;
    4)SQL 注入;
    5)反序列化注入;
    6)正則輸入源串拒絕服務 ReDoS;
    說明:Java 代碼用正則來驗證客戶端的輸入,有些正則寫法驗證普通用戶輸入沒有問題,但是如果攻擊人員使用的是特殊構造的字符串來驗證,有可能導致死循環的結果。

  • 禁止向 HTML 頁面輸出未經安全過濾或未正確轉義的用戶數據。

  • 表單、AJAX 提交必須執行 CSRF 安全驗證。
    說明:CSRF(Cross-site request forgery)跨站請求僞造是一類常見編程漏洞。對於存在 CSRF 漏洞的應用/網站,攻擊者可以事先構造好 URL,只要受害者用戶一訪問,後臺便在用戶不知情的情況下對數據庫中用戶參數進行相應修改。

  • 在使用平臺資源,譬如短信、郵件、電話、下單、支付,必須實現正確的防重放的機制,如數量限制、疲勞度控制、驗證碼校驗,避免被濫刷而導致資損。
    說明:如註冊時發送驗證碼到手機,如果沒有限制次數和頻率,那麼可以利用此功能騷擾到其它用戶,並造成短信平臺資源浪費。

  • 發貼、評論、發送即時消息等用戶生成內容的場景必須實現防刷、文本內容違禁詞過濾等風控策略。

有被“讀過哪些知名的開源項目源碼?”這種問題所困擾過嗎?加入我們,一起通讀互聯網公司主流框架及中間件源碼,成爲強大的“源碼獵人”,目前開放的有 Spring 系列框架、Mybatis 框架、Netty 框架,及Redis中間件等,讓我們一起開拓新的領地,揭開這些源碼的神祕面紗。本項目主要用於記錄框架及中間件源碼的閱讀經驗、個人理解及解析,希望能夠使閱讀源碼變成一件更簡單有趣,且有價值的事情,抽空更新中…(如果本項目對您有幫助,請watch、star、fork 素質三連一波,鼓勵一下作者,謝謝)地址:
https://github.com/doocs/source-code-hunter

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