写好代码

goodCodeOrBadCode

包含内容

  • 编码规约
    • 命名规约
    • 代码风格
    • 注释规约
    • 常量规约
    • OOP规约
    • 集合注意
    • 参数校验
    • 异常处理
    • 前后端约定
  • 单元测试
  • 设计建议
  • 重构内容
  • 工具使用

编码规约

尽量给阅读的人一种如沐春风,赏心悦目的感觉。

命名之道

取一个特别合适的名字是一件非常有挑战的事情

大到项目名、模块名、包名、对外暴露的接口,小到类名、函数名、变量名、参数名,只要是写代码,我们就逃不过”起名字”这一关。命名的好坏,对于代码的可读性来说非常重要。除此之外,命名能力也体现了一个程序员的基本编程素养。

想要起一个能准确达意的名字,没有足够的积累,确实挺费劲。

实际上,命名这件事说难也不难,对于影响范围比较大的命名,比如包名、接口、类名,我们一定要反复斟酌、推敲。可以去 GitHub 上用相关的关键词联想搜索一下,看看类似的代码是怎么命名的。或者经常看看别人写的代码,慢慢就会有自己的词库。

命名的时候,我们一定要学会换位思考,假设自己不熟悉这块代码,从代码阅读者的角度去考量命名是否足够直观。

  1. 项目module命名,结合COLA尽量保持统一风格

  2. 包名统一使用小写。包名统一使用单数形式,但是类名如果有复数含义,类名可以使用复数形式。例:util包,StringUtils类;类似的可以参考spring框架中的一些命名习惯,同时建议在每一个包里面加入package-info文件来说明这个包里面的内容

  3. 命名风格

    1. 类名使用UpperCamelCase风格,但以下情形例外:DO/BO/DTO/VO/AO/ PO / UID 等。

    2. 方法名、参数名、成员变量、局部变量都统一使用lowerCamelCase风格

  4. 命名的长和短

    长的命名可以包含更多的信息,更能准确直观地表达意图,但是,如果函数、变量的命名很长,那由它们组成的语句就会很长。在代码列长度有限制的情况下,就会经常出现一条语句被分割成两行的情况,这其实会影响代码可读性。

    实际上,在足够表达其含义的情况下,命名当然是越短越好。但是,大部分情况下,短的命名都没有长的命名更能达意。所以,很多书籍或者文章都不推荐在命名时使用缩写。

    对于一些默认的、大家都比较熟知的词,比较推荐用缩写。这样一方面能让命名短一些,另一方面又不影响阅读理解,比如,sec 表示 second、str 表示 string、num 表示 number、doc 表示 document。

    除此之外,对于作用域比较小的变量,我们可以使用相对短的命名,比如一些函数内的临时变量。相反,对于类名这种作用域比较大的,更推荐用长的命名方式

  5. 增删改查动作命名

    1. 获取单个对象的方法用 get 做前缀。

    2. 获取多个对象的方法用 list 做前缀,复数结尾,如:listObjects。

    3. ;获取统计值的方法用 count 做前缀。

    4. 插入的方法用 save/insert 做前缀。

    5. 删除的方法用 remove/delete 做前缀。

    6. 修改的方法用 update 做前缀。

    7. 分页的方法用 page 做前缀。

  6. 分层领域对象命名

    1. 数据对象:xxxDO,xxx 即为数据表名。DO( Data Object)

    2. 数据传输对象:xxxDTO,xxx 为业务领域相关的名称。DTO( Data Transfer Object)

    3. 展示对象:xxxVO,xxx 一般为网页名称。VO( View Object)

    4. POJO 是 DO/DTO/BO/VO 的统称,禁止命名成 xxxPOJO。POJO( Plain Ordinary Java Object)

  7. 抽象类命名使用Abstract或Base开头;异常类命名使用Exception结尾;测试类 命名以它要测试的类的名称开始,以 Test 结尾。

  8. 接口类中的方法和属性不要加任何修饰符号(public 也不要加),保持代码的简洁性,并加上有效的 Javadoc 注释。尽量不要在接口里定义变量,如果一定要定义变量,确定与接口方法相关,并且是整个应用的基础常量。

  9. POJO类中的任何布尔类型的变量,建议都不要加is前缀,否则部分框架解析会引起序列化错误。

  10. 常量命名全部大写,单词间用下划线隔开,力求语义表达完整清楚,不要嫌名字长。

  11. 杜绝完全不规范的缩写,避免望文不知义。

    反例:AbstractClass“缩写”成 AbsClass;condition“缩写”成 condi;Function 缩写”成 Fu,此类 随意缩写严重降低了代码的可阅读性

代码风格

在团队、项目中保持风格统一,让代码像同一个人写出来的,整齐划一。这样能减少阅读干扰,提高代码的可读性。这才是我们在实际工作中想要实现的目标。

  1. 善用空行分割单元块

    对于比较长的函数,如果逻辑上可以分为几个独立的代码块,在不方便将这些独立的代码块抽取成小函数的情况下,为了让逻辑更加清晰,可以用总结性注释的方法之外,我们还可以使用空行来分割各个代码块。

    除此之外,在类的成员变量与函数之间、静态成员变量与普通成员变量之间、各函数之间、甚至各成员变量之间,我们都可以通过添加空行的方式,让这些不同模块的代码之间,界限更加明确。写代码就类似写文章,善于应用空行,可以让代码的整体结构看起来更加有清晰、有条理。

  2. 类中成员的排列顺序

    在类中,成员变量排在函数的前面。成员变量之间或函数之间,都是按照“先静态(静态函数或静态成员变量)、后普通(非静态函数或非静态成员变量)”的方式来排列的。

    除此之外,成员变量之间或函数之间,还会按照作用域范围从大到小的顺序来排列,先写 public 成员变量或函数,然后是 protected 的,最后是 private 的。

    实际上,还有另外一种排列习惯,那就是把有调用关系的函数放到一块。比如,一个 public 函数调用了另外一个 private 函数,那就把这两者放到一块。

  3. IDE 的 text file encoding 设置为 UTF-8; IDE 中文件的换行符使用 Unix 格式

统一codeStyle:https://github.com/google/styleguide/blob/gh-pages/intellij-java-google-style.xml

统一checkStyle: https://www.jianshu.com/p/0c917f4cac1e;https://checkstyle.sourceforge.io/

代码注释

注释的目的就是让代码更容易看懂。只要符合这个要求的内容,你就可以将它写到注释里。总结一下,注释的内容主要包含这样三个方面:做什么、为什么、怎么做。

  1. 函数和变量如果命名得好,确实可以不用再在注释中解释它是做什么的。对于有些比较复杂的类或者接口,我们可能还需要在注释中写清楚“如何用”;

  2. 对于逻辑比较复杂的代码或者比较长的函数,如果不好提炼、不好拆分成小的函数调用,那我们可以借助总结性的注释来让代码结构更清晰、更有条理。

  3. 注释太多和太少的问题,类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码的可读性。

  4. 代码修改的同时,注释也要进行相应的修改,尤其是参数、返回值、异常、核心逻辑 等的修改。

  5. 在类中删除未使用的任何字段、方法、内部类;在方法中删除未使用的任何参数声明 与内部变量

  6. 谨慎注释掉代码。在上方详细说明,而不是简单地注释掉。如果无用,则删除。 说明:代码被注释掉有两种可能性:1)后续会恢复此段代码逻辑。2)永久不用。前者如果没有备注信息, 难以知晓注释动机。后者建议直接删掉即可,假如需要查阅历史代码,登录代码仓库即可。

常量规约

  1. 不要使用一个常量类维护所有常量,要按常量功能进行归类,分开维护。

    缓存相关常量放在类 CacheConsts 下;系统配置相关常量放在类 SystemConfigConsts 下

  2. 不允许任何魔法值(即未经预先定义的常量)直接出现在代码中。

  3. 常量的复用层次有五层:跨应用共享常量、应用内共享常量、子工程内共享常量、包 内共享常量、类内共享常量,对于每种常量建议放到各自的包中

    1. 跨应用共享常量:通常是client模块中的 constant 目录下
    2. 应用内共享常量:通常是子模块中的 constant目录下。
    3. 子工程内部共享常量:即在当前子工程的 constant 目录下。
    4. 包内共享常量:即在当前包下单独的 constant 目录下。
    5. 类内共享常量:直接在类内部 private static final 定义。

OOP规约

  1. 避免通过一个类的对象引用访问此类的静态变量或静态方法,无谓增加编译器解析成本,直接用类名来访问即可

  2. 外部正在调用或者二方库依赖的接口,不允许修改方法签名,避免对接口调用方产生 影响。接口过时必须加@Deprecated 注解,并清晰地说明采用的新接口或者新服务是什么。

  3. 不能使用过时的类或方法。一般提供方都会说明新的实现是什么。

  4. Object的equals方法容易抛空指针异常,应使用常量或确定有值的对象来调equals,“test”.equals(object)

  5. 所有整型包装类对象之间值的比较,全部使用equals方法比较

  6. 任何货币金额,均以最小货币单位且整型类型来进行存储

  7. BigDecimal 的等值比较应使用 compareTo()方法,而不是 equals()方法

  8. 禁止使用构造方法 BigDecimal(double)的方式把 double 值转化为 BigDecimal 对象

    BigDecimal(double)存在精度损失风险,在精确计算或值比较的场景中可能会导致业务逻辑异常,优先推荐入参为 String 的构造方法,或使用 BigDecimal 的 valueOf 方法

  9. 关于基本数据类型和包装数据类型

    1. 所有的 POJO 类属性必须使用包装数据类型。
    2. RPC 方法的返回值和参数必须使用包装数据类型
    3. POJO 类属性没有初值是提醒使用者在需要使用时,必须自己显式地进行赋值,任何 NPE 问题,或 者入库检查,都由使用者来保证
    4. 数据库的查询结果可能是 null,因为自动拆箱,用基本数据类型接收有 NPE 风险
  10. 构造方法里面禁止加入任何业务逻辑,如果有初始化逻辑,请放在 init 方法中

  11. 禁止在POJO类中同时存在对应属性xxx和isXxx()和getXxx()方法

  12. getter/setter 方法中,不要增加业务逻辑,增加排查问题的难度,可以单独提供领域操作方法

  13. 类成员与方法访问控制从严

    1. 仅在本类使用,必须是 private
    2. 与子类共享,必须是 protected
    3. static 成员变量,考虑是否为 final

    任何类、方法、参数、变量,严控访问范围。过于宽泛的访问范围,不利于模块解耦。思考:如果 是一个 private 的方法,想删除就删除,可是一个 public 的 service 成员方法或成员变量,删除一下,不得手心冒点汗吗?变量像自己的小孩,尽量在自己的视线内,变量作用域太大,无限制的到处跑。

集合注意

重点是stream的使用注意

  1. 在使用java.util.stream.Collectors类的toMap()方法转为Map集合时,一定要使

    用含有参数类型为 BinaryOperator,参数名为 mergeFunction 的方法,否则当出现相同key

    值时会抛出 IllegalStateException 异常。

    // 正例:
    List<Pair<String, Double>> pairArrayList = new ArrayList<>(3); 
    pairArrayList.add(new Pair<>("version", 12.10)); 
    pairArrayList.add(new Pair<>("version", 12.19)); 
    pairArrayList.add(new Pair<>("version", 6.28));
    Map<String, Double> map = pairArrayList.stream().collect( 
      // 生成的 map 集合中只有一个键值对:{version=6.28} 
      Collectors.toMap(Pair::getKey, Pair::getValue, (v1, v2) -> v2));
    // 反例:
    String[] departments = new String[] {"iERP", "iERP", "EIBU"}; 
    // 抛出 IllegalStateException 异常
    Map<Integer, String> map = Arrays.stream(departments)
    .collect(Collectors.toMap(String::hashCode, str -> str));
    
  2. 使用java.util.stream.Collectors类的toMap()方法转为Map集合时,一定要注意当 value 为 null 时会抛 NPE 异常

    // 说明:在 java.util.HashMap 的 merge 方法里会进行如下的判断:
    if (value == null || remappingFunction == null) 
      throw new NullPointerException();
    // 反例:
    List<Pair<String, Double>> pairArrayList = new ArrayList<>(2); 
    pairArrayList.add(new Pair<>("version1", 8.3)); 
    pairArrayList.add(new Pair<>("version2", null));
    Map<String, Double> map = pairArrayList.stream().collect(
    // 抛出 NullPointerException 异常
    Collectors.toMap(Pair::getKey, Pair::getValue, (v1, v2) -> v2));
    
  3. Collections 类返回的对象,如:emptyList()/singletonList()等都是 immutable list,不可对其进行添加或者删除的操作;

  4. 利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 List 的 contains()进行遍历去重或者判断包含操作。

参数校验

结合校验的框架和MVC中的序列化和反序列化自定义注解等类似的方式,尽量将校验的逻辑和业务处理分离且使用方便

  1. 下列情形,需要进行参数校验:

    1. 调用频次低的方法。

    2. 执行时间开销很大的方法。此情形中,参数校验时间几乎可以忽略不计,但如果因为参数错误导致中间执行回退,或者错误,那得不偿失。

    3. 需要极高稳定性和可用性的方法。

    4. 对外提供的开放接口,不管是 RPC/API/HTTP 接口。

    5. 敏感权限入口。

  2. 下列情形,不需要进行参数校验:

    1. 极有可能被循环调用的方法。但在方法说明里必须注明外部参数检查。

    2. 底层调用频度比较高的方法。毕竟是像纯净水过滤的最后一道,参数错误不太可能到底层才会暴露问题。一般DAO 层与 Service 层都在同一个应用中,部署在同一台服务器中,所以 DAO 的参数校验,可以省略。

    3. 被声明成 private 只会被自己代码所调用的方法,如果能够确定调用方法的代码传入参数已经做过检 查或者肯定不会有问题,此时可以不校验参数。

前后端约定

  1. URL 路径不能使用大写,单词如果需要分隔,统一使用下划线

  2. 在前后端交互的 JSON 格式数据中,所有的 key 必须为小写字母开始的 lowerCamelCase 风格,符合英文表达习惯,且表意完整

  3. 对于需要使用超大整数的场景,服务端一律使用 String 字符串类型返回,禁止使用Long类型

  4. HTTP请求通过URL传递参数时,不能超过2048字节

  5. HTTP 请求通过 body 传递内容时,必须控制长度,超出最大长度后,后端解析会出错。说明:nginx 默认限制是 1MB,tomcat 默认限制为2MB,当确实有业务需要传较大内容时,可以通过调大服务器端的限制

  6. 在翻页场景中,用户输入参数的小于1,则前端返回第一页参数给后端;后端发现用户输入的参数大于总页数,直接返回最后一页

异常处理

  1. **catch时请分清稳定代码和非稳定代码,**稳定代码指的是无论如何不会出错的代码。 对于非稳定代码的 catch 尽可能进行区分异常类型,再做对应的异常处理;
  2. **捕获异常是为了处理它,不要捕获了却什么都不处理而抛弃之,如果不想处理它,请 将该异常抛给它的调用者。**最外层的业务使用者,必须处理异常,将其转化为用户可以理解的内容;
  3. 事务场景中,抛出异常被catch后,如果需要回滚,一定要注意手动回滚事务;
  4. 在调用 RPC、二方包、或动态生成类的相关方法时,捕捉异常必须使用 Throwable类来进行拦截。在字节码修改框架(比如:ASM)动态创建或修改类时,修改了相应的方法签名。这些情况,即使代码编译期是正确的,但在代码运行期时,会抛NoSuchMethodError;
  5. 防止NPE,是程序员的基本修养(使用 JDK8 的类来防止 NPE 问题),注意 NPE 产生的场景:
    1. 方法返回类型为基本数据类型,return包装数据类型的对象时,自动拆箱有可能产生 NPE;
    2. 数据库的查询结果可能为null,如果是经过框架处理,最好查看在各种情况下的框架的返回情况;
    3. 集合里的元素即使isNotEmpty,取出的数据元素也可能为null;
    4. 远程调用返回对象时,进行空指针判断,防止NPE;
    5. 对于Session中获取的数据,建议进行 NPE 检查,避免空指针;
    6. 链式调用 obj.getA().getB().getC();一连串调用,易产生 NPE。

单元测试

好的单元测试能够最大限度地规避线上故障

单元测试不仅是个质量工具,更是一个效率工具

  1. 好的单元测试必须遵守 AIR 原则。单元测试在线上运行时,感觉像空气(AIR)一样感觉不到,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点;
  2. 单元测试是可以重复执行的,不能受到外界环境的影响。使用Mock工具
  3. 对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级别,一般是方法级别;
  4. 核心业务、核心应用、核心模块的增量代码确保单元测试通过
  5. 对于边界值要保证单元测试通过;
  6. 保持单元测试之间的独立性,为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序;
  7. 对于不方便测试的代码在适当的时机做适当的重构,使代码变得可测,从而对书写不规范的代码进行优化;
  8. 为了更方便地进行单元测试,业务代码应避免以下情况:
    1. 构造方法中做的事情过多;
    2. 存在过多的外部依赖;
    3. 存在过多的条件语句;
    4. 多层条件语句建议使用卫语句、策略模式、状态模式等方式重构。

设计建议

宏观上的设计和细节上的设计

  1. 设计文档的作用是明确需求、理顺逻辑、后期维护,次要目的用于指导编码
  2. 底层数据结构的设计一定得经过评审。有缺陷的底层数据结构容易导致系统风险上升,可扩展性下降,重构成本也会因历史数据迁移和系统平滑过渡而陡然增加;
  3. 状态比较多,使用状态图来表达并且明确状态变化的各个触发条件;
  4. 系统中某个功能的调用链路上的涉及对象超过3个,使用时序图来表达个对象之间的调用关系并且明确各调用环节的输入与输出;
  5. 如果系统中模型类存在复杂的依赖关系,使用类图来表达并且明确类之间的关系;
  6. 类在设计与实现时要符合单一原则
    1. 单一原则最易理解却是最难实现的一条规则,随着系统演进,很多时候,忘记了类设计的初衷
  7. 可扩展性的本质是找到系统的变化点,并隔离变化点
    1. 世间众多设计模式其实就是一种设计模式即隔离变化点的模式

重构

重构时刻在进行,小跑前进,良好的单测是重构的保障

bad_code_smell

工具使用

将繁杂的工作交给工具吧

ali-check

SonarLint

写在最后

对于一个项目来说,保持一致性,不管是哪种风格,一致性,一致性,一致性,自己保持一致性相对容易,项目中就要和项目保持一致,项目有一致的风格,那就适当牺牲自己的偏好。

参考


> 可在下面留言(需要有 GitHub 账号)