《开发实战》11 | 空值处理:分不清楚的null和恼人的空指针

11 | 空值处理:分不清楚的null和恼人的空指针

修复和定位恼人的空指针问题

NullPointerException 是 Java 代码中最常见的异常,最可能出现的场景归为以下5 种:

  1. 参数值是 Integer 等包装类型,使用时因为自动拆箱出现了空指针异常;
  2. 字符串比较出现空指针异常;
  3. 诸如 ConcurrentHashMap 这样的容器不支持 Key 和 Value 为 null,强行 put null 的Key 或 Value 会出现空指针异常;
  4. A 对象包含了 B,在通过 A 对象的字段获得 B 之后,没有对字段判空就级联调用 B 的方法出现空指针异常;
  5. 方法或远程服务返回的 List 不是空而是 null,没有进行判空就直接调用 List 的方法出现空指针异常。

有时候线上的空指针异常是很难排查的,因为是不能打断点的,往往是将代码进行拆分,或者添加日志。
可以考虑使用Arthas 简单易用功能强大,可以定位出大多数的 Java 生产问题。
如果是分支复杂的业务逻辑,你需要再借助 stack 命令来查看 wrongMethod 方法的调用栈,并配合 watch 命令查看各方法的入参,就可以很方便地定位到空指针的根源了
**定位到空指针异常后,我们需要探讨这是入参问题还是bug **:
如果是来源于入参,还要进一步分析入参是否合理等;
如果是来源于 Bug,那空指针不一定是纯粹的程序 Bug,可能还涉及业务属性和接口调用规范等。

修复方式,一般都是使用else-if,我们也可以使用 Java 8 的 Optional 类。
使用判空方式或 Optional 方式来避免出现空指针异常,不一定是解决问题的最好方式,空指针没出现可能隐藏了更深的 Bug。
解决空指针异常,还是要真正 case by case 地定位分析案例,然后再去做判空处理,而处理时也并不只是判断非空然后进行正常业务流程这么简单,同样需要考虑为空的时候是应该出异常、设默认值还是记录日志等

POJO 中属性的 null 到底代表了什么?

需要考虑:

  1. DTO 中字段的 null 到底意味着什么?是客户端没有传给我们这个信息吗?
  2. 既然空指针问题很讨厌,那么 DTO 中的字段要设置默认值么?
  3. 如果数据库实体中的字段有 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 {}

数据库中初始化一个用户,age=36、name=zhuye、create_date=2020 年 1 月4 日、nickname 是 NULL:
image.png

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

原因

  1. 明确 DTO 种 null 的含义。对于 JSON 到 DTO 的反序列化过程,null 的表达是有歧义的,客户端不传某个属性,或者传 null,这个属性在 DTO 中都是 null。
  2. POJO 中的字段有默认值。如果客户端不传值,就会赋值为默认值,导致创建时间也被更新到了数据库中。
  3. 注意字符串格式化时可能会把 null 值格式化为 null 字符串。比如上面的 guestnull
  4. DTO 和 Entity 共用了一个 POJO。
  5. 数据库字段允许保存 null,会进一步增加出错的可能性和复杂度。

解决:DTO 和 Entity 进行拆分

  1. UserDto 中只保留 id、name 和 age 三个属性,且 name 和 age 使用 Optional 来包装,以区分客户端不传数据还是故意传 null。
  2. 在 UserEntity 的字段上使用 @Column 注解,把数据库字段 name、nickname、age和 createDate 都设置为 NOT NULL,并设置 createDate 的默认值为CURRENT_TIMESTAMP,由数据库来生成创建时间。
  3. 使用 Hibernate 的 @DynamicUpdate 注解实现更新 SQL 的动态生成,实现只更新修改后的字段,不过需要先查询一次实体,让 Hibernate 可以“跟踪”实体属性的当前状态,以确保有效。
@Data
public class UserDto {
  private Long id;
  private Optional name;
  private Optional 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

可能出现的坑:

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

得到的结果,分别是 null、0 和空 List
原因是:

  1. MySQL 中 sum 函数没统计到任何记录时,会返回 null 而不是 0,可以使用 IFNULL函数把 null 转换为 0;
  2. MySQL 中 count 字段不统计 null 值。COUNT(*) 才是统计所有记录数量的正确方式。
  3. MySQL 中 =NULL 并不是判断条件而是赋值,对 NULL 进行判断只能使用 IS NULL 或者 IS NOT NULL。

修改sql:

  1. SELECT IFNULL(SUM(score),0) FROM user
  2. SELECT COUNT(*) FROM user
  3. SELECT * FROM user WHERE score IS NULL

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

你可能感兴趣的:(Java业务开发案例,数据库,sql,java)