阿里華為等大廠架構師如何解決空指針問題

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 發布!

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,283評論 6 530
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 97,947評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,094評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,485評論 1 308
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,268評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,817評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,906評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,039評論 0 285
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,551評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,502評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,662評論 1 366
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,188評論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,907評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,304評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,563評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,255評論 3 389
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,637評論 2 370

推薦閱讀更多精彩內容