着重记录容易忽视的一部分规约,改正自己的编程习惯。完整版发布地址请移步这里查看。

一、编程规约

(一) 命名风格

  1. 【强制】抽象类命名使用 Abstract 或 Base 开头;异常类命名使用 Exception 结尾;测试类命名以它要测试的类的名称开始,以 Test 结尾。
  2. 【强制】POJO 类中的任何布尔类型的变量,都不要加is前缀,否则部分框架解析会引起序列化错误。
  3. 各层命名规约:

    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. 【强制】采用 4 个空格缩进,禁止使用 tab 字符。
    说明:如果使用 tab 缩进,必须设置 1 个 tab 为 4 个空格。IDEA 设置 tab 为 4 个空格时,请勿勾选 Use tab character
  2. 【强制】IDE 的 text file encoding 设置为 UTF-8; IDE 中文件的换行符使用 Unix 格式,不要使用 Windows 格式。

(四)OOP规约

  1. 【强制】Object的equals方法容易抛空指针异常,应使用常量或确定有值的对象来调用equals。推荐使用 java.util.Objects#equals(JDK7 引入的工具类)。
  2. 【强制】所有整型包装类对象之间值的比较,全部使用equals方法比较。
    说明:对于 Integer var = ? 在-128 至 127 之间的赋值,Integer 对象是在 IntegerCache.cache 产生, 会复用已有对象,这个区间内的 Integer 值可以直接使用==进行判断,但是这个区间之外的所有数据,都 会在堆上产生,并不会复用已有对象,这是一个大坑,推荐使用 equals 方法进行判断。
  3. 【强制】浮点数之间的等值判断,基本数据类型不能用==来比较,包装数据类型不能用 equals 来判断。

    说明:浮点数采用“尾数+阶码”的编码方式,类似于科学计数法的“有效数字+指数”的表示方式。二进制无法精确表示大部分的十进制小数。

    反例:

    float a = 1.0f - 0.9f; 
    float b = 0.9f - 0.8f;
    if (a == b) {
         // 预期进入此代码快,执行其它业务逻辑 
         // 但事实上 a==b 的结果为 false
    }
    Float x = Float.valueOf(a); 
    Float y = Float.valueOf(b); 
    if (x.equals(y)) {
         // 预期进入此代码快,执行其它业务逻辑
         // 但事实上 equals 的结果为 false
    }

    正例:

    //  (1)指定一个误差范围,两个浮点数的差值在此范围之内,则认为是相等的。
    float a = 1.0f - 0.9f;
    float b = 0.9f - 0.8f;
    float diff = 1e-6f;
    if (Math.abs(a - b) < diff) {
        System.out.println("true");
    }
    // 使用 BigDecimal 来定义值,再进行浮点数的运算操作。
    BigDecimal a = new BigDecimal("1.0");
    BigDecimal b = new BigDecimal("0.9");
    BigDecimal c = new BigDecimal("0.8");
    
    BigDecimal x = a.subtract(b);
    BigDecimal y = b.subtract(c);
    
    if (x.equals(y)) {
        System.out.println("true");
    }
  4. 【强制】定义数据对象 DO 类时,属性类型要与数据库字段类型相匹配。

    正例:数据库字段的 bigint 必须与类属性的 Long 类型相对应。

    反例:某个案例的数据库表 id 字段定义类型 bigint unsigned,实际类对象属性为 Integer,随着 id 越来 越大,超过 Integer 的表示范围而溢出成为负数。

  5. 【强制】禁止使用构造方法 BigDecimal(double)的方式把 double 值转化为 BigDecimal 对象。

    优先推荐入参为 String 的构造方法,或使用 BigDecimal 的 valueOf 方法,此方法内部其实执行了 Double 的 toString,而 Double 的 toString 按 double 的实际能表达的精度对尾数进行了截断。

  6. 所有的 POJO 类属性和 RPC 方法的返回值和参数必须使用包装数据类型。

    包装数据类型 的 null 值,能够表示额外的信息,如:远程调用失败,异常退出。

(五)日期时间

  1. 【强制】日期格式化时,传入 pattern 中表示年份统一使用小写的 y。

    日期格式化时,yyyy 表示当天所在的年,而大写的 YYYY 代表是 week in which year(JDK7 之后 引入的概念),意思是当天所在的周属于的年份,一周从周日开始,周六结束,只要本周跨年,返回的 YYYY 就是下一年。正确格式为"yyyy-MM-dd HH:mm:ss"

  2. 【强制】获取当前毫秒数:System.currentTimeMillis(); 而不是 new Date().getTime()。

    说明:如果想获取更加精确的纳秒级时间值,使用 System.nanoTime 的方式。在 JDK8 中,针对统计时间 等场景,推荐使用 Instant 类。

  3. 【强制】不允许在程序任何地方中使用:1)java.sql.Date2)java.sql.Time3) java.sql.Timestamp

    说明:第 1 个不记录时间,getHours()抛出异常;第 2 个不记录日期,getYear()抛出异常;第 3 个在构造 方法 super((time/1000)*1000),fastTime 和 nanos 分开存储秒和纳秒信息。java.util.Date.after(Date)进行时间比较时,当入参是 java.sql.Timestamp 时,会触发 JDK BUG(JDK9 已修复),可能导致比较时的意外结果。

  4. 【强制】不要在程序中写死一年为365天,避免在公历闰年时出现日期转换错误或程序逻辑错误。

    正例:

    // 获取今年的天数
    int daysOfThisYear = LocalDate.now().lengthOfYear();
    // 获取指定某年的天数
    LocalDate.of(2011, 1, 1).lengthOfYear();
    
    // 【推荐】避免公历闰年2月问题。闰年的2月份有29天,一年后的那一天不可能是2月29日。

(六)集合处理

  1. 【强制】关于hashCode和equals的处理,遵循如下规则:

    1) 只要重写 equals,就必须重写 hashCode。

    2) 因为 Set 存储的是不重复的对象,依据 hashCode 和 equals 进行判断,所以 Set 存储的对象必必须重写这两个方法。

    3) 如果自定义对象作为 Map 的键,那么必须覆写 hashCode 和 equals。

  2. 【强制】判断所有集合内部的元素是否为空,使用isEmpty()方法,而不是size()==0的方式。

    说明:前者的时间复杂度为 O(1),而且可读性更好。

  3. 【强制】在使用java.util.stream.Collectors类的toMap()方法转为Map集合时,一定要使用含有参数类型为 BinaryOperator,参数名为 mergeFunction 的方法,否则当出现相同 key 值时会抛出 IllegalStateException 异常。

    说明:参数 mergeFunction 的作用是当出现 key 重复时,自定义对 value 的处理策略。

    List<Pair<String, Double>> pairArrayList = new ArrayList<>(3);
    pairArrayList.add(new Pair<>("version", 6.19)); 
    pairArrayList.add(new Pair<>("version", 10.24)); 
    pairArrayList.add(new Pair<>("version", 13.14));
    Map<String, Double> map = pairArrayList.stream().collect(
        // 生成的 map 集合中只有一个键值对:{version=13.14} 
        Collectors.toMap(Pair::getKey, Pair::getValue, (v1, v2) -> v2));
  4. 【强制】在使用java.util.stream.Collectors类的toMap()方法转为Map集合时,一定要注意当 value 为 null 时会抛 NPE 异常。
  5. 【强制】使用Map的方法keySet()/values()/entrySet()返回集合对象时,不可以对其进行添加元素操作,否则会抛出 UnsupportedOperationException 异常。
  6. 【强制】使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组。

    示例:

    List<String> list = new ArrayList<>(2);
    list.add("guan");
    list.add("bao");
    String[] array = list.toArray(new String[0]);
    // 直接使用 toArray 无参方法存在问题,此方法返回值只能是 Object[]类,若强转其它类型数组将出现 ClassCastException 错误。

    说明:使用 toArray 带参方法,数组空间大小的 length,

    1) 等于 0,动态创建与 size 相同的数组,性能最好。

    2) 大于 0 但小于 size,重新创建大小等于 size 的数组,增加 GC 负担。

    3) 等于 size,在高并发情况下,数组创建完成之后,size 正在变大的情况下,负面影响与 2 相同。

    4) 大于 size,空间浪费,且在 size 处插入 null 值,存在 NPE 隐患。

  7. 【强制】在使用 Collection 接口任何实现类的 addAll()方法时,都要对输入的集合参数进行NPE 判断。
  8. 【强制】使用工具类 Arrays.asList()把数组转换成集合时,不能使用其修改集合相关的方法,它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常。

    说明:asList 的返回对象是一个 Arrays 内部类,并没有实现集合的修改方法。Arrays.asList 体现的是适配器模式,只是转换接口,后台的数据仍是数组。

  9. 【强制】泛型通配符<? extends T>来接收返回的数据,此写法的泛型集合不能使用 add 方法,而<? super T>不能使用 get 方法,两者在接口调用赋值的场景中容易出错。

    说明:扩展说一下 PECS(Producer Extends Consumer Super)原则:第一、频繁往外读取内容的,适合用<? extends T>。第二、经常往里插入的,适合用<? super T>

  10. 【强制】不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator方式,如果并发操作,需要对 Iterator 对象加锁。
  11. 【推荐】集合初始化时,指定集合初始值大小。

    正例:initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意负载因子(即 loader factor)默认为0.75,如果暂时无法确定初始值大小,请设置为 16(即默认值)。

    反例:HashMap 需要放置 1024 个元素,由于没有设置容量初始大小,随着元素不断增加,容量 7 次被迫扩大,resize 需要重建 hash 表。当放置的集合元素个数达千万级别时,不断扩容会严重影响性能。

  12. 【推荐】使用 entrySet 遍历 Map 类集合 KV,而不是 keySet 方式进行遍历。

    说明:keySet 其实是遍历了 2 次,一次是转为 Iterator 对象,另一次是从 hashMap 中取出 key 所对应的 value。而 entrySet 只是遍历了一次就把 key 和 value 都放到了 entry 中,效率更高。如果是 JDK8,使用 Map.forEach 方法。

  13. 【推荐】高度注意 Map 类集合 K/V 能不能存储 null 值的情况,如下表格:
集合类KeyValueSuper说明
Hashtable不允许为 null不允许为 nullDictionary线程安全
ConcurrentHashMap不允许为 null不允许为 nullAbstractMap锁分段技术(JDK8:CAS)
TreeMap不允许为 null允许为 nullAbstractMap线程不安全
HashMap允许为 null允许为 nullAbstractMap线程不安全

(七)并发处理

  1. 【强制】线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。

    说明:线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

  2. 【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

    Executors 返回的线程池对象的弊端如下:

    1) FixedThreadPoolSingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

    2) CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

  3. 【强制】必须回收自定义的 ThreadLocal 变量,尤其在线程池场景下,线程经常会被复用, 如果不清理自定义的 ThreadLocal 变量,可能会影响后续业务逻辑和造成内存泄露等问题。 尽量在代理中使用 try-finally 块进行回收。
  4. 【强制】高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。
  5. 【强制】对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造成死锁。

(八)控制语句

  1. 【强制】当 switch 括号内的变量类型为 String 并且此变量为外部参数时,必须先进行 null 判断。
  2. 【强制】在高并发场景中,避免使用”等于”判断作为中断或退出的条件。

    说明:如果并发控制没有处理好,容易产生等值判断被“击穿”的情况,使用大于或小于的区间判断条件来代替。

    反例:判断剩余奖品数量等于 0 时,终止发放奖品,但因为并发处理错误导致奖品数量瞬间变成了负数,这样的话,活动无法终止。

  3. 【推荐】除常用方法(如 getXxx/isXxx)等外,不要在条件判断中执行其它复杂的语句,将复杂逻辑判断的结果赋值给一个有意义的布尔变量名,以提高可读性。
  4. 【推荐】循环体中的语句要考量性能,以下操作尽量移至循环体外处理,如定义对象、变量、 获取数据库连接,进行不必要的 try-catch 操作(这个 try-catch 是否可以移至循环体外)。
  5. 【推荐】接口入参保护,这种场景常见的是用作批量操作的接口。

(十)其他

  1. 【强制】在使用正则表达式时,利用好其预编译功能,可以有效加快正则匹配速度。
  2. 【强制】避免用 Apache Beanutils 进行属性的 copy。

    说明:Apache BeanUtils 性能较差,可以使用其他方案比如 Spring BeanUtils, Cglib BeanCopier,注意均是浅拷贝。

  3. 【强制】注意 Math.random() 这个方法返回是 double 类型,注意取值的范围 0≤x<1(能够 取到零值,注意除零异常),如果想获取整数类型的随机数,不要将 x 放大 10 的若干倍然后 取整,直接使用 Random 对象的 nextInt 或者 nextLong 方法。
  4. 【推荐】任何数据结构的构造或初始化,都应指定大小,避免数据结构无限增长吃光内存。

二、异常日志

(三) 日志规约

  1. 【强制】对于 trace/debug/info 级别的日志输出,必须进行日志级别的开关判断。

    示例:

    // 如果判断为真,那么可以输出 trace 和 debug 级别的日志。虽然在 debug 方法体内有过此判断,但是参数可能会进行字符串拼接运算或者如下面的 getName() 造成无谓的方法调用开销
    if (logger.isDebugEnabled()) {
    logger.debug("Current ID is: {} and name is: {}", id, getName());
    }
  2. 【强制】异常信息应该包括两类信息:案发现场信息和异常堆栈信息。如果不处理,那么通过 关键字 throws 往上抛出。

    logger.error(各类参数或者对象 toString() + "_" + e.getMessage(), e);
  3. 【强制】日志打印时禁止直接用 JSON 工具将对象转换成 String。

    说明:如果对象里某些 get 方法被重写,存在抛出异常的情况,则可能会因为打印日志而影响正常业务流程的执行。

    正例:打印日志时仅打印出业务相关属性值或者调用其对象的 toString()方法。

三、单元测试

  1. 【强制】单元测试中不准使用 System.out 来进行人肉验证,必须使用 assert 来验证。
  2. 【推荐】和数据库相关的单元测试,可以设定自动回滚机制,不给数据库造成脏数据。或者对
    单元测试产生的数据有明确的前后缀标识。

四、安全规约

  1. 【强制】用户敏感数据禁止直接展示,必须对展示数据进行脱敏。
  2. 【强制】用户输入的SQL参数严格使用参数绑定或者METADATA字段值限定,防止SQL注入, 禁止字符串拼接 SQL 访问数据库。
  3. 【强制】用户请求传入的任何参数必须做有效性验证。

    忽略参数校验可能导致:

    • page size 过大导致内存溢出
    • 恶意 order by 导致数据库慢查询
    • 缓存击穿
    • SSRF
    • 任意重定向
    • SQL 注入,Shell 注入,反序列化注入
    • 正则输入源串拒绝服务 ReDoS
  4. 【强制】在使用平台资源,譬如短信、邮件、电话、下单、支付,必须实现正确的防重放的机 制,如数量限制、疲劳度控制、验证码校验,避免被滥刷而导致资损。

五、MySQL数据库

(一)建表规约

  1. 【强制】表达是与否概念的字段,必须使用is_xxx的方式命名,数据类型是 tinyint unsigned(1表示是,0表示否)。

    注意:POJO类中的布尔类型变量,都不要加is前缀,所以,需要在<reaultMap>中设置从isxxxxxx的关联关系。

  2. 【强制】表名不使用复数名词。
  3. 【强制】主键索引名为pk_字段名;唯一索引名为uk_字段名;普通索引名则为idx_字段名。

    说明:pk_primary key;uk_unique key;idx_index 的简称。

  4. 【强制】如果存储的字符串长度几乎相等,使用char定长字符串类型。
  5. 【强制】varchar是可变长字符串,不预先分配存储空间,长度不要超过5000,如果存储长度 大于此值,定义字段类型为 text,独立出来一张表,用主键来对应,避免影响其它字段索引效 率。
  6. 【强制】表必备三字段:id,gmt_create,gmt_modified
  7. 【推荐】单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表。
  8. 【参考】合适的字符存储长度,不但节约数据库表空间、节约索引存储,更重要的是提升检索 速度。

(二)索引规约

  1. 【强制】业务上具有唯一特性的字段,即使是组合字段,也必须建成唯一索引。

    说明:不要以为唯一索引影响了 insert 速度,这个速度损耗可以忽略,但提高查找速度是明显的;另外, 即使在应用层做了非常完善的校验控制,只要没有唯一索引,根据墨菲定律,必然有脏数据产生。

  2. 【强制】超过三个表禁止join。需要join的字段,数据类型保持绝对一致;多表关联查询时, 保证被关联的字段需要有索引。
  3. 【强制】在varchar字段上建立索引时,必须指定索引长度,没必要对全字段建立索引,根据 实际文本区分度决定索引长度。

(三)SQL 语句

  1. 【强制】不要使用count(列名)count(常量)来替代count(*)count(*)SQL92定义的标准统计行数的语法,跟数据库无关,跟 NULL 和非 NULL 无关。

    说明:count(*)会统计值为 NULL 的行,而 count(列名)不会统计此列为 NULL 值的行。

  2. 【强制】当某一列的值全是NULL时,count(col)的返回结果为0,但sum(col)的返回结果为 NULL,因此使用 sum()时需注意 NPE 问题。

    说明:可以使用如下方式避免 sum 的 NPE 问题: SELECT IFNULL(SUM(column), 0) FROM table;

  3. 【强制】使用ISNULL()来判断是否为NULL值。
  4. 【强制】不得使用外键与级联,一切外键概念必须在应用层解决。

    说明:(概念解释)学生表中的 student_id 是主键,那么成绩表中的 student_id 则为外键。如果更新学 生表中的 student_id,同时触发成绩表中的 student_id 更新,即为级联更新。外键与级联更新适用于单机 低并发,不适合分布式、高并发集群;级联更新是强阻塞,存在数据库更新风暴的风险;外键影响数据库 的插入速度。

  5. 【强制】禁止使用存储过程,存储过程难以调试和扩展,更没有移植性。
  6. 【推荐】in 操作能避免则避免,若实在避免不了,需要仔细评估 in 后边的集合元素数量,控制在 1000 个之内。
  7. 【参考】TRUNCATE TABLEDELETE 速度快,且使用的系统和事务日志资源少,但 TRUNCATE 无事务且不触发 trigger,有可能造成事故,故不建议在开发代码中使用此语句。

(四)ORM映射

  1. 【强制】在表查询中,一律不要使用 * 作为查询的字段列表,需要哪些字段必须明确写明。

    说明:1)增加查询分析器解析成本。2)增减字段容易与 resultMap 配置不一致。3)无用字段增加网络 消耗,尤其是 text 类型的字段。

  2. 【强制】POJO类的布尔属性不能加is,而数据库字段必须加is_,要求在resultMap中进行 字段与属性之间的映射。
  3. 【强制】不要用resultClass当返回参数,即使所有类属性名与数据库字段一一对应,也需要定义<resultMap>;反过来,每一个表也必然有一个<resultMap>与之对应。

    说明:配置映射关系,使字段与 DO 类解耦,方便维护。

  4. 【推荐】不要写一个大而全的数据更新接口。执行SQL时, 不要更新无改动的字段,一是易出错;二是效率低;三是增加 binlog 存储。
  5. 【参考】@Transactional事务不要滥用。事务会影响数据库的QPS,另外使用事务的地方需 要考虑各方面的回滚方案,包括缓存回滚、搜索引擎回滚、消息补偿、统计修正等。

六、工程结构

(一)应用分层

  • Web 层:主要是对访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。
  • Service 层:相对具体的业务逻辑服务层。
  • DAO 层:数据访问层,与底层 MySQL、Oracle、Hbase、OB 等进行数据交互。

【参考】分层领域模型规约:

  • DO(Data Object):此对象与数据库表结构一一对应,通过 DAO 层向上传输数据源对象。
  • DTO(Data Transfer Object):数据传输对象,Service 或 Manager 向外传输的对象。
  • BO(Business Object):业务对象,可以由 Service 层输出的封装业务逻辑的对象。
  • Query:数据查询对象,各层接收上层的查询请求。注意超过 2 个参数的查询封装,禁止使用 Map 类 来传输。
  • VO(View Object):显示层对象,通常是 Web 向模板渲染引擎层传输的对象。

(三)服务器

  1. 【推荐】高并发服务器建议调小TCP协议的time_wait超时时间。

    说明:操作系统默认 240 秒后,才会关闭处于 time_wait 状态的连接,在高并发访问下,服务器端会因为 处于 time_wait 的连接数太多,可能无法建立新的连接,所以需要在服务器上调小此等待值。在 linux 服务器上请通过变更/etc/sysctl.conf 文件去修改该缺省值(秒):net.ipv4.tcp_fin_timeout = 30

  2. 【推荐】给 JVM 参数设置-XX:+HeapDumpOnOutOfMemoryError 参数,让 JVM 碰到 OOM

    场景时输出 dump 信息。

  3. 【推荐】在线上生产环境,JVM的XmsXmx设置一样大小的内存容量,避免在 GC 后调整 堆大小带来的压力。

七、设计规约

  1. 【推荐】谨慎使用继承的方式来进行扩展,优先使用聚合/组合的方式来实现。
  2. 【推荐】系统设计阶段,根据依赖倒置原则,尽量依赖抽象类与接口,有利于扩展与维护。
  3. 【推荐】系统设计阶段,共性业务或公共行为抽取出来公共模块、公共配置、公共类、公共方 法等,在系统中不出现重复代码的情况。

八、名词解释

  • ORM(Object Relation Mapping): 对象关系映射,对象领域模型与底层数据之间的转换,本 文泛指 iBATIS, mybatis 等框架。
  • NPE(java.lang.NullPointerException): 空指针异常。
  • OOM(Out Of Memory): 源于java.lang.OutOfMemoryError,当 JVM 没有足够的内存来为对象分配空间并且垃圾回收器也无法回收空间时,系统出现的严重状况。
  • 一方库:本工程内部子项目模块依赖的库(jar 包)。
  • 二方库:公司内部发布到中央仓库,可供公司内部其它应用依赖的库(jar 包)。
  • 三方库:公司之外的开源库(jar 包)。