前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >阿里华为等大厂架构师如何解决空指针问题

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

作者头像
JavaEdge
发布2022-11-30 15:33:40
1.2K0
发布2022-11-30 15:33:40
举报
文章被收录于专栏:JavaEdge

前言

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 即可,即设置该字段的值。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2020-12-29,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • NPE事发场景
  • 修复NPE
    • Integer 判空
      • String V.S 字面量
        • 不支持 null 的容器
          • 级联调用
          • POJO字段的null是什么意义?
            • 案例
            • NPE原因
              • DTO字段null的含义
                • POJO中的字段有默认值
                  • 字符串格式化时可能会把null值格式化为null字符串
                    • DTO和Entity共用POJO
                      • 数据库字段允许保存null
                      • MySQL中有关NULL的三个坑
                      • 数据库空指针异常
                      相关产品与服务
                      云数据库 SQL Server
                      腾讯云数据库 SQL Server (TencentDB for SQL Server)是业界最常用的商用数据库之一,对基于 Windows 架构的应用程序具有完美的支持。TencentDB for SQL Server 拥有微软正版授权,可持续为用户提供最新的功能,避免未授权使用软件的风险。具有即开即用、稳定可靠、安全运行、弹性扩缩等特点。
                      领券
                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档