代码整洁之道--企业级应用中的编码心得

·  阅读 533

1 日志

由于上线后的代码不能调试,因此良好、规范的日志记录能够帮助我们第一时间定位、解决线上问题。代码中若没有日志,那么一旦线上出现了问题,对于问题的定位是非常难以把控的。

但是在记录日志的时候,很自然的会想到,应该在什么时候进行日志的记录是比较好的呢?

从严格的角度上来讲,若你的代码中充斥着大量无用的日志,那么最直观的感受便是——代码不简洁,不漂亮,可读性将会下降。并且,运行时的每一行代码都需要占用内存资源,都需要 CPU 去进行解释,大量无用的日志也会耗费硬盘资源,因此,日志记录并不是一件随便并且可有可无的工作。

那么应该在什么时候进行日志的记录比较合适呢?

有必要的地方!何时、何处需要日志打点,需要自己进行考量(大量地输出无效日志,不利于系统性能提升,也不利于快速定位错误点。记录日志时请思考:这些日志真的有人看吗?看到这条日志你能做什么?能不能给问题排查带来好处?),但有两处我觉得必须要进行日志打点:

  1. 异常发生的地方:Java 代码中的运行时异常都可用 catch 进行捕获,由于日志的功能就是帮助记录并解决问题,因此当异常发生时,进行日志记录是十分有必要的(记录日志不光是记录 message,堆栈信息和相关输入参数也很重要);
  2. 耗时统计:需要对一段代码进行耗时统计的时候,比如代码中有定时任务,那么可能需要记录任务开始的时间,结束的时间以及完成整个任务的耗时。

P.S.:切面如果已经做好了日志记录(通常会将方法的入参,返回值,耗时等信息进行记录),那么在方法体内便不必重复进行日志记录。

2 入参校验

有效的入参校验可以有效减少程序出错(NPE),并节省 CPU 资源(假如一个方法接受了一个无效参数【空数组或空对象】,那么可能造成接下来整个方法的运行都是无效的,因为你同样会得到一个“空”【这里的空不特指空对象,也有可能是 null】的返回值)。

对于无效参数来说,只需要进行校验,然后同样返回一个“空”值就行了,但若是非法参数(主要是根据业务或现实情况来讲),比如一个代表年龄的入参,其值却是负数,这时应该直接抛出异常(这种异常通常需要自己定义),并在异常信息中记录相应的错误信息(年龄小于 0),然后在恰当的位置 catch 住并做合适的处理。

那么什么时候应该进行入参校验呢?我觉得每一个非 private 方法都应该考虑是否需要对其进行入参校验,但为了防止代码冗余,在进行入参校验时需要联系代码上下文,如果你能够确保入参正确,那就不必再进行入参校验,因此这也是 private 方法一般不需要入参校验的原因,private 方法的访问权限被控制在实现类当中,是非常可控的。

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

  • 执行时间开销很大的方法。此情形中,参数校验时间几乎可以忽略不计,但如果因为参数错误导致中间执行回退,或者错误,那得不偿失;
  • 需要极高稳定性和可用性的方法;
  • 对外提供的开放接口,不管是 RPC/API/HTTP 接口;
  • 敏感权限入口。

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

  • 极有可能被循环调用的方法。但在方法说明里必须注明外部参数检查要求;
  • 底层调用频度比较高的方法。毕竟是像纯净水过滤的最后一道,参数错误不太可能到底层才会暴露问题;
  • 被声明成 private 只会被自己代码所调用的方法,如果能够确定调用方法的代码传入参数已经做过检查或者肯定不会有问题,此时可以不校验参数。

一个类中若有多个 public 方法,都需要进行数行相同的参数校验操作,这个时候请抽取:private boolean checkParam(DTO dto) {...}。

3 try...catch块

很多刚入门的开发者容易滥用 try...catch。try...catch 的成本是很高的(为什么高倒没有研究过),滥用 try...catch 势必会导致性能下降。

在企业级应用中,一般都内建了最高层的异常处理机制(切面中的 try...catch 是否就是最高层的异常处理机制?),根本不需要到处写 try...catch 块,除非代码中某些地方需要特殊处理才用 catch。

避免 try...catch 滥用的同时,在使用中最忌讳的还有一点就是 catch 住异常之后什么都不做,如果未来程序出现了问题,那么对于问题的排查将是非常困难的(没有任何错误提示,没有错误日志,已经出现异常但程序还会继续往下执行...)。因此,当程序出现异常时,除非你明确知道该怎么处理(catch 后可以 throw 自定义异常,并记录日志),那么最好就什么都不要做,让问题暴露出来,直到异常被最顶层的方法捕获(一般在后端会使用切面 catch Exception,在前端也会进行异常处理,所以不必担心会将异常堆栈直接展示给用户),然后合理处理异常,包装异常信息并返回给客户端(让客户端可以知道服务端出现了什么问题并及时反馈,帮助服务端开发人员及时发现问题、处理问题)。

在使用 try...catch 时应注意在 catch 和 finally 中的语句要尽量简短、快速,并避免在 catch 中引发新的异常

总之一句话:try catch and log 然后根据情况决定 throw。

4 空对象&null

应该有一部分开发者在刚开始的时候会比较困惑,当异常发生时,应该返回空对象呢?还是返回空指针?可能有些人(比如我),为了尽量避免程序出现 NPE 问题,一直对 null 的使用抱有很大的排斥感,因此当异常发生时,一律按空对象、空集合、空 Map 返回。事实上,这样的做法乃是错误的。

对于集合与 Map,当发生异常时我们通常会返回空集合与空 Map,但如果你的返回值本身就是一个 Object,返回 null 才是正确的做法。对于服务的调用者来说,当返回值为空集合或空 Map 时,不管对方有没有判空(事实上每个服务调用者都应该对接口返回值进行非空判断),在使用的过程中不会引发下游服务出现异常。但如果是单个 Object,发生异常时本应该返回 null,但你依旧返回了一个空对象,那么这个空对象就有可能逃避下游服务中的非 null 判断,而让服务调用者误以为这是一个正常的对象。但由于空对象中的每一个非基本类型成员变量都会被默认初始化为 null,因此下游服务在使用的过程中就有可能出现 NPE 异常。所以,对于发生异常但返回值为单个 Object 的接口来说,如果你依旧选择返回一个空对象的话这将是一个非常危险的操作。

5 接口设计

在进行接口设计时,需要考虑未来上线后,接口的 TP99。为了尽量降低 TP99 的数值,应该首先考虑能否将接口设计为支持批量操作。不同于以往在校时开发的小型应用,你可以不考虑接口性能,可以频繁的进行数据库操作,只要完成功能即可。但在企业级应用中,各个服务间的接口调用是非常频繁的,并且接口之间的调用链也比较长,因此如何减少写库与读库的次数,如何减少远程调用的次数等等这些都是在接口设计时就要考虑清楚的,如果一个接口的 TP99 过高,那么便会对下游服务造成影响,最终很有可能会直接影响用户的使用体验。

除此之外,降低接口耗时还有一个很重要的方式,使用缓存。如果一个接口有可能被频繁调用,而接口中恰好又涉及到了数据库操作,没有缓存的话,对数据库的压力将是非常大的,其耗时也会增加,因此在接口设计之初,是否使用缓存也应该被列入考虑的范围之内。

但正如《程序员的自我修养》中所述,我们也勿需过度设计,一个接口是否应该被设计为支持批量操作,是否应该使用缓存,都需要进行考量,只在有必要的时候进行设计,这样不但有助于节省人力也会降低接口复杂度。

6 NPE

如果说 C/C++ 的段错误令人绝望,那 NullPointerException 同样是 Java 程序员最不想见到的异常之一。

正如《阿里巴巴 Java 开发手册》中所述:防止 NPE,是程序员的基本修养。

注意 NPE 的产生场景:

  • 自动拆箱有可能引发 NPE;
  • 数据库的查询结果可能为 null;
  • 集合中的元素可为 null,因此若不能确保集合中没有 null 元素,推荐在使用集合的时候对每一个元素进行非 null 判断或在使用之前对集合中的非 null 元素进行过滤。

除此之外,还有其他可能产生 NPE 的地方:使用List的addAll()方法请判空指针

7 编程规约

7.1 Service/DAO层方法命名规约

  1. 获取单个对象的方法用 get/query 作前缀;
  2. 获取多个对象的方法用 list/batchQuery 作前缀;
  3. 获取统计值的方法用 count 作前缀;
  4. 插入的方法用 save/insert 作前缀;
  5. 删除的方法用 remove/delete 作前缀;
  6. 修改的方法用 update 作前缀。

7.2 代码格式

单行字符数限制不超过 120 个,超出需要换行,换行时遵循如下原则:

  1. 第二行相对第一行缩进 4 个空格,从第三行开始,不再继续缩进;
  2. 运算符与下文一起换行;
  3. 方法调用的点符号与下文一起换行;
  4. 方法调用时,多个参数,需要换行时,在逗号后进行;
  5. 在括号前不要换行。

7.3 基本类型与包装类型

  1. 所有的 POJO 类属性必须使用包装数据类型;
  2. 所有方法的返回值必须使用包装数据类型;
  3. 所有方法的参数推荐使用基本类型;
  4. 所有局部变量推荐使用基本类型。

7.4 注释

  1. 类、类属性、类方法的注释必须使用 Javadoc 规范,使用 /** 内容 */ 格式,不得使用 // xxx 方式;
  2. 所有的抽象方法(包括接口中的方法)必须要用 Javadoc 注释、除了返回值、参数外,还必须指出该方法做什么事情,实现什么功能;
  3. 方法内部多行注释使用 /* */ 注释,注意与代码对齐;
  4. 所有的枚举类型字段必须要有注释,说明每个数据项的用途;
  5. 特殊注释标记:TODO【标记人,标记时间,预计处理时间】,FIXME【标记人,标记时间,预计处理时间】。

8 坑点总结

8.1 禁止在 foreach 循环里进行元素的 remove/add 操作

在阿里巴巴 Java 开发手册中,强制规定了不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。

在 foreach 循环中使用 remove/add 操作将会导致 fail-fast 机制,它是 Java 集合的一种错误检测机制,触发 fail-fast 机制将会抛出 ConcurrentModificationException,其主要目的是提醒用户当前集合可能发生了并发修改。

但这个设计并非完美,甚至可以说是一种缺陷,原因有二:

  1. 单线程环境下,在增强 for 循环中进行元素的 remove/add 操作也会触发 fail-fast 机制,这与其本身设计时的目的完全背道而驰;
  2. 多线程环境下,不在增强 for 循环中进行元素的 remove/add 操作,则不会触发 fail-fast 机制,同样,不符合最初的设计目的。

看来,只要我们搞懂 fail-fast 机制,就能明晰阿里开发手册中的这条规范以及产生上述两种缺陷的原因。

既然触发 fail-fast 机制的前提条件是增强 for 循环,那么首先需要搞懂增强 for 循环是什么。增强 for 循环是 Java 中的一种语法糖,反编译后可以发现其底层实现依赖 Iterator 以及 while 循环,举个栗子:

Iterator iterator = userNames.iterator();
do
{
    if(!iterator.hasNext())
        break;
    String userName = (String)iterator.next();
    if(userName.equals("Hollis"))
        userNames.remove(userName);
} while(true);
System.out.println(userNames);
复制代码

在调用 iterator.next() 时可能会引发 ConcurrentModificationException:

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
复制代码

那么什么时候 modCount 会与 expectedModCount 不等呢?给出答案之前需要先解释一下 modCount 以及 expectedModCount 的含义:

  1. modCount 是 ArrayList 中的一个成员变量,表示该集合实际被修改的次数;
  2. expectedModCount 是 ArrayList 中的一个内部类——Itr 中的成员变量。expectedModCount 表示这个迭代器期望该集合被修改的次数,其值是在 ArrayList.iterator 方法被调用的时候初始化的。只有通过迭代器对集合进行操作,该值才会改变。
class ArrayList{
    private int modCount;
    public void add();
    public void remove();
    private class Itr implements Iterator<E> {
        int expectedModCount = modCount;
    }
    public Iterator<E> iterator() {
        return new Itr();
    }
}
复制代码

知悉上述两点后,重新分析开始时举的栗子:

// 获取 Itr 内部类
Iterator iterator = userNames.iterator();
do
{
    if(!iterator.hasNext())
        break;
    // 调用 iterator.next 时会调用 checkForComodification()
    String userName = (String)iterator.next();
    if(userName.equals("Hollis"))
      	// 调用的是 ArrayList 的 remove 方法,只会修改 modCount 的值
        userNames.remove(userName);
} while(true);
System.out.println(userNames);
复制代码

关于 ArrayList 的 remove() 源码不再贴出。

看到这,答案应该已在大家的心中。简单总结一下,之所以会抛出 ConcurrentModificationException 异常,是因为在代码中使用了增强 for 循环,而在增强 for 循环中,集合遍历是通过 iterator 进行的,但是元素的 add/remove 却是直接使用集合类自己的方法。这就导致 iterator 在遍历的时候,会发现有一个元素在自己不知不觉的情况下就被删除/添加了,就会抛出一个异常,用来提示用户,可能发生了并发修改!

所以说这种设计本身乃是一种缺陷:

  1. 目的本是提醒用户可能发生了并发修改,结果在单线程环境下会抛出 ConcurrentModificationException;
  2. 不使用增强 for 循环的话,在多线程环境下集合即使发生了并发修改,也不会触发异常,异常没有起到应起的作用。

既然已经知道了这种缺陷及风险,那么使用 remove/add 的正确姿势应是怎样的呢?

有两种解决方案:

  1. 最佳:并发环境下使用 Java 并发包 (java.util.concurrent) 下的集合类,单线程环境下在增强 for 循环中使用 Iterator.remove() 或 Iterator.add();
  2. 扯淡:并发环境下加锁,保证对临界资源的正确修改,单线程环境下使用普通 for 循环进行 remove/add 操作(会引发一个新的问题,对集合遍历的同时,集合中的元素在不断改变,很恐怖)。

8.2 Collections.emptyList()会引发java.lang.UnsupportedOperationException

详见:Collections.emptyList()引发的java.lang.UnsupportedOperationException

因此,为了规避这种风险,我还是建议在返回空集合的时候,一律使用 com.google.common.collect 包下的类返回空集合,很香~(也不知道大家为什么会选择 Collections 这种方式)。

8.3 时刻注意避免申请较大的连续内存空间

如果不断申请较大的连续内存空间,容易导致内存溢出(OOM)。看了几篇文章,基本都在讲最大能申请多少的连续内存空间,并没有告诉我们申请多大的连续内存空间比较合适。虽然现在计算机的内存都不小,理论上可能申请到已有内存 50% 的连续空间都不在话下,但我还是建议,应尽量避免这种操作,并将申请的每个连续空间的大小控制在 2M 以内(个人建议,仅供参考)。

9 参考阅读

为什么说try catch隐藏了bug,try catch什么时候用?- 陈世丹的回答 - 知乎

《阿里巴巴 Java 开发手册》

为什么阿里巴巴禁止在 foreach 循环里进行元素的 remove/add 操作

使用List的addAll()方法请判空指针

Collections.emptyList()引发的java.lang.UnsupportedOperationException

分类:
后端
标签: