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,關注我,緊跟本系列專欄文章,咱們下篇再續!
- ?? 魔都架構師 | 全網30W技術追隨者
- ?? 大廠分布式系統/數據中臺實戰專家
- ?? 主導交易系統百萬級流量調優 & 車聯網平臺架構
- ?? AIGC應用開發先行者 | 區塊鏈落地實踐者
- ?? 以技術驅動創新,我們的征途是改變世界!
- ?? 實戰干貨:編程嚴選網
本文由博客一文多發平臺 OpenWrite 發布!