0 前言
null,表示无引用指向或没有指针,若操作该变量会引发空指针异常,即NullPointerException,NPE。
当线上发生该异常,说明代码健壮性不足,如何才能避免NPE?NPE虽烦,但易定位,关键在null到底意味啥:
- client给server一个null,是其本意就想给个空值,还是根本没提供值?
- DB字段的NULL值,是否有特殊含义?写SQL需要注意啥?
1 NPE事发场景
- 参数是Integer等包装类,自动拆箱时
- 字符串比较
- 如ConcurrentHashMap不支持K/V为null的容器
- A对象含B对象,通过A对象的字段获得B对象后,没判空B就调用B的方法
- 方法或其它服务返回的List不是空而是null,没有判空就直接调用List的方法
入参test:由0、1构成,长度为4的字符串,第几位为1就代表第几个参数为null,以此控制wrongMethod方法的4个入参,模拟各种NPE:
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
输出:
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));
确实提示该行NPE:
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.NullPointerException] with root cause
java.lang.NullPointerException: null
at com.javaedge.nullvalue.npe.NpeController.bad(NpeController.java:42)
at com.javaedge.nullvalue.npe.NpeController.bad(NpeController.java:24)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
...
但无法再精确定位到底因何NPE,有很多可能:
- 入参Integer拆箱为int时
- 入参的两个字符串任意一个为null
- null加入ConcurrentHashMap
就这?我打断点看下入参不就行?
但实际项目,NPE通常极端案例下才出现,自测都难复现。排查生产NPE,打断点不现实,可能有人会:
- 拆分代码,详细看清每个 npe 产生过程
- 加日志
但对生产,都很麻烦。
咋快速知道入参,精确定位NPE谁引起?
2 修复NPE
最简单的先判空后操作,但只能让异常不再出现,还是要找到NPE源头:
- 入参:进一步分析入参合理性
- bug:NPE不一定单纯程序bug,可能还涉及业务属性和接口调用规范
Demo只考虑判空修复。若先判空后处理,你肯定if/else。这既增加代码量又降低易读性,请用Java8 Optional类消除此类if/else,一行代码判空处理。
Integer判空
使用Optional.ofNullable构造Optional:
public static <T> Optional<T> ofNullable(T value) {
return value == null ? empty() : of(value);
}
public static<T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}
public static <T> Optional<T> of(T value) {
return new Optional<>(value);
}
再用 orElse() 把null替换为默认值:
public T orElse(T other) {
return value != null ? value : other;
}
3 String V.S 字面量
字面量放前,如:
"200".equals(s)
即使s null也不会NPE。
对俩都可能null的String equals,可用 Objects.equals 判空:
public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}
4 不支持 null 的容器
ConcurrentHashMap K/V都禁止null,那就别存!
5 级联调用
如:
myService.getFooService().foo().equals("OK")
需判空:
- myService
- getFooService()的返回值
- foo()返回的字符串
对good()返回的List,由于不能确认其是否为null,所以在调用size方法前,可:
- Optional.ofNullable包装返回值
- .orElse(Collections.emptyList()) 实现在List==null时获得空List
- 最后 size()
return Optional
.ofNullable(good(test.charAt(0) == '1' ? null : new MyService(),
test.charAt(1) == '1' ? null : 1,
test.charAt(2) == '1' ? null : "Java!",
test.charAt(3) == '1' ? null : "Java!"))
.orElse(Collections.emptyList()).size();
就不会NPE。
但若修改4个入参都不为null,最后日志中也无OK。
why?BarService的bar方法不是返回了OK吗?
FooService中的barService字段为null。
使用判空或Optional避免NPE,不一定是最佳方案,空指针没出现可能隐藏了更深Bug。因此,解决NPE,还要真正具体案例具体分析,处理时也并不只是判断非空然后进行正常业务流程,还要考虑为空的时候是应该抛异常、设默认值还是记录日志。
6 POJO字段null歧义
相比判空避免空指针异常,更易错的是null定位。对于程序,null就是指针无任何指向,而结合业务逻辑情况复杂得多,需考虑:
- DTO字段null啥意义?是客户端没传给这个字段?
- 为避免NPE,DTO的字段要设默认值吗?
- 若DB实体中的字段有null,通过数据访问框架保存数据是否会覆盖DB中的既有数据?
6.1 案例
同时扮演DTO和数据库Entity:
@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接口更新用户数据,再直接把客户端在RequestBody中使用JSON传过来的User对象,通过JPA更新到数据库,最后返回保存到数据库的数据:
public User updateNickname(@RequestBody User user) {
user.setNickname(String.format("guest%s", user.getName()));
return userRepository.save(user);
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}
先在DB初始化用户 【age=36、name=JavaEdge、create_date=2020年1月4日、nickname NULL】:
id | age | create_date | name | nickname |
---|---|---|---|---|
1 | 36 | 2020-01-04 09:58:11.000000 | JavaEdge | (NULL) |
再cURL测试该接口,传入一个id=1、name=null的JSON字符串,期望把ID为1的用户姓名设置为空,接口返回的结果和数据库中记录一致:
id | age | create_date | name | nickname |
---|---|---|---|---|
1 | (NULL) | 2020-01-05 02:01:03.784000 | (NULL) | guestnull |
问题:
- 调用方只希望重置用户名,但age也被置null
- nickname是用户类型加姓名,若name重置为null,访客用户的昵称应是guest,而非guestnull
- 用户的创建时间原来是1月4日,更新了用户信息后变为了1月5日
6.2 DTO字段null的含义
JSON到DTO的反序列化,null描述歧义:客户端不传某属性或传null,该属性在DTO中都是null。这带来歧义,对于更新请求:
- 不传,说明客户端不想更新该属性,应维持DB原值
- 传null,说明客户端想重置该属性
因为Java的null就是没有数据,无法区分这两种case,所以本例中的age属性也被置null,可用Optional解决该问题。
6.3 POJO中的字段有默认值
如果客户端不传值,就会赋值为默认值,导致创建时间也被更新到 DB。
6.4 字符串格式化时可能将null值格式化为"null"字符串
如昵称设置,只进行简单的字符串格式化,存入数据库变为guestnull。显然不合理,还需判断。
6.5 DTO和Entity共用POJO
对昵称的设置是程序控制,不应把它们暴露在DTO,否则易把客户端随意设置的值更新到DB。
创建时间最好让DB置当前时间,不用程序控制,可在字段置columnDefinition(可选,生成列的DDL时使用的SQL片段。默认为生成的SQL以创建推断类型的列)实现。
6.6 数据库字段允许保存null
进一步增加出错可能性和复杂度。若数据真正落地时也支持NULL,可能就有NULL、空字符串和字符串null三态。但若所有属性都有默认值,则简单点。
至此,对DTO和Entity进行拆分修正:
createDate默认值CURRENT_TIMESTAMP,由DB生成创建时间。
使用Hibernate的 @DynamicUpdate 实现更新SQL的动态生成,实现只更新修改后的字段,不过要先查询一次实体,让Hibernate可“跟踪”实体属性的当前状态,以确保有效。
@Data
public class UserDTO {
private Long id;
/**
* 以区分用户不传数据 or 故意传null
*/
private Optional<String> name;
/**
* 以区分用户不传数据 or 故意传null
*/
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_TIMESTAMP")
private Date createDate;
}
定义接口,以便对更新操作进行更精细化的处理。参数校验:
- 对传入UserDTO和ID属性先判空,若为空,抛IllegalArgumentException
- 根据id从DB查询出实体后判空,若为空,抛IllegalArgumentException
由于DTO已用Optional区分客户端不传值和传null值,则业务逻辑实现就可按客户端意图来分别实现:
- 若不传值,则Optional本身为null,直接跳过Entity字段的更新,动态生成的SQL也不包含该列
- 若传了值,进一步判断传的是不是null
下面,根据分别对姓名、年龄和昵称更新:
- 姓名,我们认为客户端传null是希望把姓名重置为空,允许这样的操作,使用Optional.orElse一键把空转换为空字符串
- 年龄,我们认为如果客户端希望更新年龄,须传一个有效年龄,年龄不存在重置操作,可用Optional.orElseThrow在值为空时抛IllegalArgumentException
- 昵称,因数据库中姓名不可能为null,可安心将昵称置guest加上数据库取出来的姓名
@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的用户姓名置空:
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两个字段:
Hibernate: update user_entity set name=?, nickname=? where id=?
为测试使用Optional是否可以有效区分JSON中没传属性还是传了null,在JSON中设个null的age,结果是正确得到了年龄不能为空的错误提示:
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"}%
7 MySQL的NULL大坑
数据库表字段允许存NULL,除了让我们困惑,还易有大坑。
7.1 环境
实体:
@Data
@Entity
public class User {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
private Long score;
程序启动时,往实体初始化一条数据,id是自增列自动设置的1,score是NULL:
@Autowired
private UserRepository userRepository;
@PostConstruct
public void init() {
userRepository.save(new User());
}
测试如下用例,看结合数据库中的null值可能会出现的坑:
- sum统计一个只有NULL值的列的总和
- select记录数量,count用一个允许NULL的字段,如COUNT(score)
- 使用=NULL条件查询字段值为NULL的记录,如score=null条件
@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的记录,查询条件失效
7.2 原因
- MySQL中sum函数没统计到任何记录时,会返回null而非0,可用IFNULL函数把null转换为0
- MySQL中count(字段)不统计null值,COUNT(*)才是统计所有记录数量的正确方式
- MySQL中诸如=、<、>这样的算数比较操作符和NULL比较的结果总是NULL,就显得无任何比较意义了,需用IS NULL、IS NOT NULL或 ISNULL()比较
7.3 修正SQL
@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含义和降级逻辑
- 服务端开发,要对入参进行前置判断,提前挡掉服务端不可接受的空值,同时在整个业务逻辑过程中进行完善的空值处理
8 数据库NPE
Incorrect DECIMAL value: ‘0’ for column xxx
数据表定义时 decimal 类型,但 java 代码传时默认值写成""
,造成插入数据时报错,其实空时传 null 即可,即设置该字段的值。
本文已收录在Github,关注我,紧跟本系列专栏文章,咱们下篇再续!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。