阿里华为等大厂架构师如何解决空指针问题

2022-11-30 15:33:40 浏览数 (1)

前言

null,表示没有引用指向或没有指针,若操作该变量会引发空指针异常,即NullPointerException,NPE。

当线上发生该异常时, 往往说明代码健壮性不足,到底如何才能避免NPE呢?

NPE虽烦,但易定位,关键在于null到底意味什么:

  • client给server一个null,是其本意就想给个空值,还是根本没提供值?
  • DB字段的NULL值,是否有特殊含义?写SQL需要注意啥?

NPE事发场景

  • 参数是Integer等包装类,自动拆箱时
  • 字符串比较
  • ConcurrentHashMap这种不支持K.V为null的容器
  • A对象含B对象,通过A对象的字段获得B对象后,没有判空B就调用B的方法
  • 方法或其它服务返回的List不是空而是null,没有判空就直接调用List的方法

入参test:由0、1构成,长度为4的字符串,第几位为1就代表第几个参数为null,以此控制wrongMethod方法的4个入参,模拟各种NPE:

代码语言:javascript复制
private List<String> bad(MyService myService, Integer i, String s, String t) {
    log.info("result {} {} {} {}",
            i   1,
            "OK".equals(s),
            s.equals(t),
            new ConcurrentHashMap<String, String>().put(null, null));
    
    if ("OK".equals(myService.getBarService().bar())) {
        log.info("OK");
    }
    return null;
}

@GetMapping("wrong")
public int wrong(@RequestParam(value = "test", defaultValue = "1111") String test) {
    return wrongMethod(test.charAt(0) == '1' ? null : new FooService(),
            test.charAt(1) == '1' ? null : 1,
            test.charAt(2) == '1' ? null : "OK",
            test.charAt(3) == '1' ? null : "OK").size();
}

class FooService {
    @Getter
    private BarService barService;

}

class BarService {
    String bar() {
        return "OK";
    }
}

bad一行日志记录模拟了4种NPE:

  • 对入参Integer i进行 1
  • 对入参String s进行比较,判断内容是否为"OK"
  • 对入参String s、t进行比较,判断是否相等
  • 对new出的ConcurrentHashMap进行put,Key和Value都设为null

输出:

确实提示该行NPE,但无法再精确定位到底因何NPE,有很多可能:

  • 入参Integer拆箱为int时
  • 入参的两个字符串任意一个为null
  • 把null加入ConcurrentHashMap

就这?我设置个断点看下入参不就知道了吗?

但在实际项目中,NPE通常在极其特殊条件下才会出现,自测时一般都难以复现。 若要排查生产环境出现的NPE,设置代码断点不现实,可能有的同学会:

  • 拆分代码,详细看清每个 npe 产生过程
  • 增加更多日志

但对于线上环境,这么做都很麻烦。

如何快速知道 bad方法的入参,从而精确定位NPE到底是哪个入参引起的呢?

修复NPE

解决NPE,最简单的就是先判空后操作。 不过,这只能让异常不再出现,还是要找到代码中NPE源于入参还是bug

  • 入参 进一步分析入参是否合理
  • bug NPE不一定是纯粹的程序bug,可能还涉及业务属性和接口调用规范

Demo只考虑了判空这种修复方式。若先判空后处理,大多数人会使用if/else。但这种方式既增加代码量又降低易读性,请使用Java8 Optional类消除此类if/else,一行代码进行判空和处理。

Integer 判空

使用Optional.ofNullable构造Optional

然后使用 orElse()null替换为默认值再后续操作

String V.S 字面量

把字面量放在前,比如"200".equals(s),这样即使s是null也不会出现NPE。

对俩个都可能为null的String的equals比较,可使用Objects.equals,帮你判空:

不支持 null 的容器

ConcurrentHashMap的K.V都不允许null,那就不要存null!

级联调用

形如

代码语言:javascript复制
myService.getFooService().foo().equals("OK")

需判空:

  • myService
  • getFooService()的返回值
  • foo()返回的字符串

对good()返回的List,由于不能确认其是否为null,所以在调用size方法前,可:

  • Optional.ofNullable包装返回值
  • .orElse(Collections.emptyList()) 实现在List==null时获得空List
  • 最后 size()

这就不会有NPE了。

但若修改4个入参都不为null,最后日志中也无OK。

why?BarService的bar方法不是返回了OK吗?

FooService中的barService字段为null。

使用判空或Optional避免NPE,不一定是最佳方案,空指针没出现可能隐藏了更深Bug。因此,解决NPE,还要真正具体案例具体分析,处理时也并不只是判断非空然后进行正常业务流程,还要考虑为空的时候是应该抛异常、设默认值还是记录日志。

POJO字段的null是什么意义?

相比判空避免空指针异常,更易错的是null的定位。对程序来说,null就是指针没有任何指向,而结合业务逻辑情况就复杂得多,需考虑:

  • DTO中字段的null到底意味着什么?是客户端没传给这个字段?
  • 既然空指针很讨厌,那么DTO中的字段要设默认值吗?
  • 若DB实体中的字段有null,那么通过数据访问框架保存数据是否会覆盖DB中的既有数据

案例

  • 同时扮演DTO和数据库Entity角色

Post接口更新用户数据,然后直接把客户端在RequestBody中使用JSON传过来的User对象通过JPA更新到数据库中,最后返回保存到数据库的数据

首先,在DB初始化一个用户,age=36、name=zhuye、create_date=2020年1月4日、nickname是NULL:

然后,使用cURL测试一下用户信息更新接口Post,传入一个id=1、name=null的JSON字符串,期望把ID为1的用户姓名设置为空,接口返回的结果和数据库中记录一致:

存在如下问题:

  • 调用方只希望重置用户名,但age也被设为了null
  • nickname是用户类型加姓名,name重置为null的话,访客用户的昵称应该是guest,而不是guestnull
  • 用户的创建时间原来是1月4日,更新了用户信息后变为了1月5日。

NPE原因

DTO字段null的含义

JSON到DTO的反序列化过程,null的描述有歧义: 客户端不传某个属性或传null,该属性在DTO中都是null。 这带来问题,对于更新请求:

  • 不传意味着客户端不想更新该属性,应维持DB原值
  • 传了null,说明客户端想重置该属性。因为Java中的null就是没有数据,无法区分这两种描述,所以本例中的age属性也被设置为null,可使用Optional解决该问题

POJO中的字段有默认值

如果客户端不传值,就会赋值为默认值,导致创建时间也被更新到 DB。

字符串格式化时可能会把null值格式化为null字符串

比如昵称的设置,只进行了简单的字符串格式化,存入数据库变为了guestnull。显然,这是不合理的,还需要进行判断。

DTO和Entity共用POJO

对于用户昵称的设置是程序控制的,我们不应该把它们暴露在DTO中,否则很容易把客户端随意设置的值更新到DB。 创建时间最好让DB设置为当前时间,不用程序控制,可通过在字段上设置columnDefinition实现。

数据库字段允许保存null

会进一步增加出错的可能性和复杂度。因为如果数据真正落地的时候也支持NULL,可能就有NULL、空字符串和字符串null三种状态。 如果所有属性都有默认值,问题会简单一点。

总结完,我们对DTO和Entity进行拆分修正:

createDate的默认值为CURRENT_TIMESTAMP,由DB生成创建时间。 使用Hibernate的**@DynamicUpdate**注解实现更新SQL的动态生成,实现只更新修改后的字段,不过需要先查询一次实体,让Hibernate可以“跟踪”实体属性的当前状态,以确保有效。

定义接口,以便对更新操作进行更精细化的处理。 参数校验:

  • 对传入的UserDTO和ID属性先判空,若为空,抛IllegalArgumentException
  • 根据id从DB查询出实体后判空,若为空,抛IllegalArgumentException

然后,由于DTO中已经巧妙使用了Optional来区分客户端不传值和传null值,那么业务逻辑实现上就可以按照客户端的意图来分别实现逻辑。如果不传值,那么Optional本身为null,直接跳过Entity字段的更新即可,这样动态生成的SQL就不会包含这个列;如果传了值,那么进一步判断传的是不是null。

下面,我们根据业务需要分别对姓名、年龄和昵称进行更新:

对于姓名,我们认为客户端传null是希望把姓名重置为空,允许这样的操作,使用Optional的orElse方法一键把空转换为空字符串即可。 对于年龄,我们认为如果客户端希望更新年龄就必须传一个有效的年龄,年龄不存在重置操作,可以使用Optional的orElseThrow方法在值为空的时候抛出IllegalArgumentException。 对于昵称,因为数据库中姓名不可能为null,所以可以放心地把昵称设置为guest加上数据库取出来的姓名。

代码语言:javascript复制
@PostMapping("right")
public UserEntity right(@RequestBody UserDto user) {
    if (user == null || user.getId() == null)
        throw new IllegalArgumentException("用户Id不能为空");

    UserEntity userEntity = userEntityRepository.findById(user.getId())
            .orElseThrow(() -> new IllegalArgumentException("用户不存在"));

    if (user.getName() != null) {
        userEntity.setName(user.getName().orElse(""));
    }
    userEntity.setNickname("guest"   userEntity.getName());
    if (user.getAge() != null) {
        userEntity.setAge(user.getAge().orElseThrow(() -> new IllegalArgumentException("年龄不能为空")));
    }
    return userEntityRepository.save(userEntity);
}
  • 若DB已有记录 id=1、age=36、create_date=2020年1月4日、name=java、nickname=guestjava:

使用相同的参数调用right接口,再来试试是否解决了所有问题。传入一个id=1、name=null的JSON字符串,期望把id为1的用户姓名设置为空:

代码语言:javascript复制
curl -H "Content-Type:application/json" -X POST -d '{ "id":1, "name":null}' http://localhost:45678/pojonull/right

{"id":1,"name":"","nickname":"guest","age":36,"createDate":"2020-01-04T11:09:20.000 0000"}%

新接口即可完美实现了仅重置name属性的操作,昵称也不再有null字符串,年龄和创建时间字段也没被修改。

Hibernate生成的SQL语句只更新了name和nickname两个字段:

代码语言:javascript复制
Hibernate: update user_entity set name=?, nickname=? where id=?

为测试使用Optional是否可以有效区分JSON中没传属性还是传了null,在JSON中设个null的age,结果是正确得到了年龄不能为空的错误提示:

代码语言:javascript复制
curl -H "Content-Type:application/json" -X POST -d '{ "id":1, "age":null}' http://localhost:45678/pojonull/right

{"timestamp":"2020-01-05T03:14:40.324 0000","status":500,"error":"Internal Server Error","message":"年龄不能为空","path":"/pojonull/right"}%

MySQL中有关NULL的三个坑

前面提到,数据库表字段允许存NULL除了会让我们困惑外,还容易有坑。这里我会结合NULL字段,和你着重说明sum函数、count函数,以及NULL值条件可能踩的坑。

  • 定义个实体

程序启动时,往实体初始化一条数据,其id是自增列自动设置的1,score是NULL:

然后,测试下面三个用例,来看看结合数据库中的null值可能会出现的坑:

通过sum函数统计一个只有NULL值的列的总和,比如SUM(score); select记录数量,count使用一个允许NULL的字段,比如COUNT(score); 使用=NULL条件查询字段值为NULL的记录,比如score=null条件。

代码语言:javascript复制
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    @Query(nativeQuery=true,value = "SELECT SUM(score) FROM `user`")
    Long wrong1();
    @Query(nativeQuery = true, value = "SELECT COUNT(score) FROM `user`")
    Long wrong2();
    @Query(nativeQuery = true, value = "SELECT * FROM `user` WHERE score=null")
    List<User> wrong3();
}

得到的结果,分别是null、0和空List。 显然,这三条SQL语句的执行结果和我们的期望不同:

  • 虽然记录的score都是NULL,但sum的结果应该是0才对
  • 虽然这条记录的score是NULL,但记录总数应该是1才对
  • 使用=NULL并没有查询到id=1的记录,查询条件失效。

原因是:

  • MySQL中sum函数没统计到任何记录时,会返回null而不是0,可以使用IFNULL函数把null转换为0
  • MySQL中count字段不统计null值,COUNT(*)才是统计所有记录数量的正确方式
  • MySQL中使用诸如=、<、>这样的算数比较操作符比较NULL的结果总是NULL,这种比较就显得没有任何意义,需要使用IS NULL、IS NOT NULL或 ISNULL()函数来比较。

修改一下SQL:

代码语言:javascript复制
@Query(nativeQuery = true, value = "SELECT IFNULL(SUM(score),0) FROM `user`")
Long right1();
@Query(nativeQuery = true, value = "SELECT COUNT(*) FROM `user`")
Long right2();
@Query(nativeQuery = true, value = "SELECT * FROM `user` WHERE score IS NULL")
List<User> right3();

可以得到三个正确结果,分别为0、1、[User(id=1, score=null)]。

  • 客户端的开发者,需要和服务端对齐字段null的含义以及降级逻辑
  • 服务端的开发者,需要对入参进行前置判断,提前挡掉服务端不可接受的空值,同时在整个业务逻辑过程中进行完善的空值处理

数据库空指针异常

代码语言:javascript复制
Incorrect DECIMAL value: ‘0’ for column xxx

数据表定义时 decimal 类型,但是 java 代码传时默认值写成了"",造成插入数据时报错,其实空时传 null 即可,即设置该字段的值。

0 人点赞