11 | 空值处理:分不清楚的null和恼人的空指针
修复和定位恼人的空指针问题
NullPointerException 是 Java 代码中最常见的异常,最可能出现的场景归为以下5 种:
- 参数值是 Integer 等包装类型,使用时因为自动拆箱出现了空指针异常;
- 字符串比较出现空指针异常;
- 诸如 ConcurrentHashMap 这样的容器不支持 Key 和 Value 为 null,强行 put null 的Key 或 Value 会出现空指针异常;
- A 对象包含了 B,在通过 A 对象的字段获得 B 之后,没有对字段判空就级联调用 B 的方法出现空指针异常;
- 方法或远程服务返回的 List 不是空而是 null,没有进行判空就直接调用 List 的方法出现空指针异常。
有时候线上的空指针异常是很难排查的,因为是不能打断点的,往往是将代码进行拆分,或者添加日志。
可以考虑使用Arthas 简单易用功能强大,可以定位出大多数的 Java 生产问题。
如果是分支复杂的业务逻辑,你需要再借助 stack 命令来查看 wrongMethod 方法的调用栈,并配合 watch 命令查看各方法的入参,就可以很方便地定位到空指针的根源了
**定位到空指针异常后,我们需要探讨这是入参问题还是bug **:
如果是来源于入参,还要进一步分析入参是否合理等;
如果是来源于 Bug,那空指针不一定是纯粹的程序 Bug,可能还涉及业务属性和接口调用规范等。
修复方式,一般都是使用else-if,我们也可以使用 Java 8 的 Optional 类。
使用判空方式或 Optional 方式来避免出现空指针异常,不一定是解决问题的最好方式,空指针没出现可能隐藏了更深的 Bug。
解决空指针异常,还是要真正 case by case 地定位分析案例,然后再去做判空处理,而处理时也并不只是判断非空然后进行正常业务流程这么简单,同样需要考虑为空的时候是应该出异常、设默认值还是记录日志等
POJO 中属性的 null 到底代表了什么?
需要考虑:
- DTO 中字段的 null 到底意味着什么?是客户端没有传给我们这个信息吗?
- 既然空指针问题很讨厌,那么 DTO 中的字段要设置默认值么?
- 如果数据库实体中的字段有 null,那么通过数据访问框架保存数据是否会覆盖数据库中的既有数据?
User 的 POJO,同时扮演 DTO 和数据库 Entity 角色,包含用户 ID、姓名、昵称、年龄、注册时间等属性:
@Data
@Entity
public class User {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
private String name;
private String nickname;
private Integer age;
private Date createDate = new Date();
}
有一个 Post 接口用于更新用户数据,根据用户姓名自动设置一个昵称,昵称的规则是“用户类型 + 姓名”
@Autowired
private UserRepository userRepository;
@PostMapping("wrong")
public User wrong(@RequestBody User user) {
user.setNickname(String.format("guest%s", user.getName()));
return userRepository.save(user);
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {}
数据库中初始化一个用户,age=36、name=zhuye、create_date=2020 年 1 月4 日、nickname 是 NULL:
- 调用方只希望重置用户名,但 age 也被设置为了 null;
- nickname 是用户类型加姓名,name 重置为 null 的话,访客用户的昵称应该是guest,而不是 guestnull,重现了文首提到的那个笑点;
- 用户的创建时间原来是 1 月 4 日,更新了用户信息后变为了 1 月 5 日。
原因:
- 明确 DTO 种 null 的含义。对于 JSON 到 DTO 的反序列化过程,null 的表达是有歧义的,客户端不传某个属性,或者传 null,这个属性在 DTO 中都是 null。
- POJO 中的字段有默认值。如果客户端不传值,就会赋值为默认值,导致创建时间也被更新到了数据库中。
- 注意字符串格式化时可能会把 null 值格式化为 null 字符串。比如上面的 guestnull
- DTO 和 Entity 共用了一个 POJO。
- 数据库字段允许保存 null,会进一步增加出错的可能性和复杂度。
解决:DTO 和 Entity 进行拆分
- UserDto 中只保留 id、name 和 age 三个属性,且 name 和 age 使用 Optional 来包装,以区分客户端不传数据还是故意传 null。
- 在 UserEntity 的字段上使用 @Column 注解,把数据库字段 name、nickname、age和 createDate 都设置为 NOT NULL,并设置 createDate 的默认值为CURRENT_TIMESTAMP,由数据库来生成创建时间。
- 使用 Hibernate 的 @DynamicUpdate 注解实现更新 SQL 的动态生成,实现只更新修改后的字段,不过需要先查询一次实体,让 Hibernate 可以“跟踪”实体属性的当前状态,以确保有效。
@Data
public class UserDto {
private Long id;
private Optional<String> name;
private Optional<Integer> age;
}
@Data
@Entity
@DynamicUpdate
public class UserEntity {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String nickname;
@Column(nullable = false)
private Integer age;
@Column(nullable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIM
private Date createDate;
}
小心 MySQL 中有关 NULL 的三个坑
NULL 字段,和你着重说明 sum 函数、count 函数,以及 NULL 值条件可能踩的坑。
@Entity
@Data
public class User {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
private Long score;
}
往实体初始化一条数据,其 id 是自增列自动设置的 1,score 是NULL
可能出现的坑:
- 通过 sum 函数统计一个只有 NULL 值的列的总和,比如 SUM(score);
- SELECT SUM(score) FROM
user
- SELECT SUM(score) FROM
- select 记录数量,count 使用一个允许 NULL 的字段,比如 COUNT(score);
- SELECT COUNT(score) FROM
user
- SELECT COUNT(score) FROM
- 使用 =NULL 条件查询字段值为 NULL 的记录,比如 score=null 条件。
- SELECT * FROM
user
WHERE score=null
- SELECT * FROM
得到的结果,分别是 null、0 和空 List:
原因是:
- MySQL 中 sum 函数没统计到任何记录时,会返回 null 而不是 0,可以使用 IFNULL函数把 null 转换为 0;
- MySQL 中 count 字段不统计 null 值。COUNT(*) 才是统计所有记录数量的正确方式。
- MySQL 中 =NULL 并不是判断条件而是赋值,对 NULL 进行判断只能使用 IS NULL 或者 IS NOT NULL。
修改sql:
- SELECT IFNULL(SUM(score),0) FROM
user
- SELECT COUNT(*) FROM
user
- SELECT * FROM
user
WHERE score IS NULL
得到三个正确结果,分别为 0、1、[User(id=1, score=null)]