我在大廠做 CR——如何體系化防控空指標異常

木宛城主發表於2024-10-21

大家好,我是木宛哥,今天和大家分享下——程式碼 CR 時針對惱人的空指標異常(NullPointerException)如何做到體系化去防控;

什麼是空指標異常

從記憶體角度看,物件的例項化需要在堆記憶體中分配空間。如果一個物件沒有被建立,那也就沒有分配記憶體,當應用程式訪問空物件時,實際上是訪問一個“無效”的記憶體區域,從而導致系統丟擲異常。

我們在 Java 程式設計時,空指標異常是一個常見的執行時錯誤,嚴重甚至會導致程序退出。所以這也是為什麼我們要在 CR 時如此重視它的原因。

CR 我們要做什麼

木宛哥認為 CR 應該重點關注三點:

  • 業務邏輯正確性
  • 程式碼的可讀性和可維護性
  • 程式碼的健壯性和穩定性

OK,再回過頭來看,針對空指標異常,在 CR 時,更多要從程式碼的健壯性和穩定性切入,可分為:

  • 防禦性去杜絕空指標異常的出現(大多是訪問了不存在的物件)
  • 對三方框架或者 JDK 認知不完善導致的潛在空指標異常發生(更多靠評審參與者經驗分享)

防禦性程式設計

防禦性程式設計是非常有必要的,一方面可以提高系統穩定性和健壯性,另一方面可以形成比較好的程式碼規範;同時也是非常重要的思想,每個人都會如此去實踐,在 CR 時針對空指標異常的防禦是 common sense;例如:

1.防禦使用了未初始化的物件:

MyObject obj = null;
if (obj!= null){
    obj.someMethod();
}

2.防禦使用了物件沒有初始化的欄位;

class MyClass {
   String name;
}
MyClass obj = new MyClass();
if (StringUtils.isNotBlank(obj.getName())){
    // do something
}

3.防禦當呼叫方法返回 null 後,試圖對返回的物件呼叫其方法或屬性:

MyObject obj = getMyObject(); // 假設返回 null
if (obj != null) {
    obj.someMethod();
}

4.防禦訪問了集合中不存在的元素:

  • Map.get() 方法返回 null
  • Queue 的方法如 poll() 或 peek() 返回 null
Map<String ,String> dummyMap = new HashMap<>();
String value = dummyMap.get("key");
if (org.apache.commons.lang3.StringUtils.isNotBlank(value)){
    // do something
}

三方框架或 JDK 使用不當引發空指標異常提前排雷

這一類更多是三方框架或 JDK 的內部機制不清楚導致的踩坑,只有踩了這種類,

才會恍然大悟:“哦,原來這樣啊,下回得注意了”;

所以針對這類問題,更多需要評審參與人的經驗去發現,需要團隊去共創,共建知識體系,例如:在團隊空間維護“ TOP 100 踩坑記”等等;

在上篇文章《為什麼建議使用列舉來替換布林值》中,木宛哥提到過 Booleannull 時產生的第三種結果,易造成 if 條件判斷拆箱引發空指標問題,今天再繼續分享其他:

1.三目運算子拆箱空指標問題

int var1 = 20;
Integer var2 = null;
boolean condition = true;
// 三目運算子拆箱問題,發生 NullPointerException
System.out.println(condition ? var2 : var1);

這裡:conditiontrue,所以三目運算子選擇了 var2(即 null)。即: var2Integer 型別)賦值給 numInteger 型別)。理論上在這裡應該是 num 被賦值為 null

但在 Java 中,三目運算子的返回型別需要透過型別來推導:

  • 如果 var1int 型別,而 var2Integer 型別,三目運算子會將它們的型別推導合併,令返回值為 int 型別。
  • 這意味著,如果 conditiontrue,則會嘗試將 var2null)拆箱成 int。由於 null 不能拆箱成 int,因此會丟擲 NullPointerException

這類典型的問題更多需要在 CR 時提前暴露出來,保證一致的引數型別來避免拆箱;

2.日誌列印使用 fastjson 序列化時造成的空指標問題

大部分程式設計師程式設計開發習慣,喜歡列印引數到日誌裡,但有時候一個不起眼的 log.info 列印日誌有可能導致介面異常;

如下列印日誌,結果 fastjson 序列化異常,發生 NullPointerException

@Test
public void testJSONString() {
    Employee employee = new Employee("jack", 100);
    //fastjson 序列化異常,發生 NullPointerException
    LoggerUtil.info(logger,"{}",JSON.toJSONString(employee));
}

static class Employee {

    private EmployeeId employeeId;
    private String name;
    private Integer salary;

    public Employee(String name, Integer salary) {
        this.name = name;
        this.salary = salary;
    }

    public String getName() {
        return name;
    }

    public Integer getSalary() {
        return salary;
    }

    public String getEmployeeId() {
        return this.employeeId.getId();
    }
}

static class EmployeeId {
    private String id;
    public String getId() {
        return id;
    }
    public void setId(String id) {
        this.id = id;
    }
}

原因在於 fastjson 使用 JSON.toJSONString(employee) 序列化成 JSON 時,底層實際透過解析 get 開頭方法來識別屬性,即:呼叫 get 方法獲取屬性的 value 值;上述程式碼:employeeIdnull ,但序列化時執行了 getEmployeeId 引發的空指標異常;

所以:特別是大家在實踐 DDD 的時候,因為領域模型往往是充血模型,不僅有資料還包含了行為,對於行為可能習慣有 get 開頭命名,要特別重視在列印領域模型時序列化問題;

3.對 Stream 流操作認知不完善導致的空指標異常

如果 Stream 流中存在空值,需要非常小心。

例如,如果第一個元素恰好為 nullfindFirst() 將丟擲 NullPointerException。這是因為 findFirst() 返回一個 Optional,而 Optional 不能包含空值。

Arrays.asList(null, 1, 2).stream().findFirst();//發生 NullPointerException

max()min()reduce(),也表現出類似的行為。如果 null 是最終結果,則會丟擲異常。

List<Integer> list = Arrays.asList(null, 1, 2);
var comparator = Comparator.<Integer>nullsLast(Comparator.naturalOrder());
System.out.println(list.stream().max(comparator));//發生 NullPointerException

再例如:我們在使用 Stream 流式程式設計時,如果流包含 null,可以轉換為 toList()toSet()

然而,toMap() 要注意, 不允許空值(允許空Key):

Employee employee1 = new Employee("Jack", 10000);
Employee employee2 = new Employee(null, 10000);
//toMap的Value不能為空,此處異常
Map<Integer, String> salaryMap = Arrays.asList(employee1, employee2)
    .stream()
    .collect(Collectors.toMap(Employee::getSalary, Employee::getName));

以及:groupingBy() 不允許空 Key:

Employee employee1 = new Employee("Jack", 10000);
Employee employee2 = new Employee(null, 10000);

//groupingBy的Key不能為空,此處拋異常
Map<String, List<Employee>> result = Stream.of(employee1, employee2)
    .collect(Collectors.groupingBy(Employee::getName));

可見在流中使用了空物件存在許多陷阱;所以,在 CR 時,要重點關注 Stream 流的資料來源,避免在流中存在 null,不確定的話建議用 filter(Objects::nonNull) 將它們過濾掉。

再談空指標防控手段

上一章更多還是從防禦空指標去解問題,但能保證每個人都是認知一樣嗎,同時在 CR 時也會有漏網之魚;下面程式碼我想每個人都會這樣去避免空指標,但難免在某個加班到凌晨的日子,腦袋一抽筋寫反了:(

if("DEFAULT".equals(var)){
    //do something
}

所以,在這一章,木宛哥從資料來源切入,回答:“能否資料天生就是存在非空的、方法天生就是不會返回 null”?

從程式角度來看,是合理的;許多變數永遠不包含 null,許多方法也永遠不返回 null。我們可以分別稱它們為“非空變數”和“非空方法”(NonNull);

其他變數和方法在某些情況下可能會包含或返回 null,它們稱為“可空”(Nullable);

基於這個理論,在解空指標問題時,提供了另一種方式解法:

  • 資料來自三方系統,控制權不在我們,故:不可信任,需要做好防禦程式設計;
  • 資料來自自身,控制權在我們,控制資料建立即非空,故:可信任;如下:

儘可能遮蔽 null 值

對輸入值進行校驗——在公共方法和建構函式中。需在每個 set 欄位的入口處新增 Objects.requireNonNull() 呼叫。requireNonNull() 方法會在其引數為 null 時丟擲 NullPointerException

public class Employee {
    public Employee(String name, Integer salary) {
        this.name = Objects.requireNonNull(name);
        this.salary = Objects.requireNonNull(salary);
    }
}

這樣做有助於在入口處遮蔽 null 值的寫入

如果你的方法接受集合作為輸入,也可以在方法入口遍歷該集合以確保它不包含 null 值:

public void check(Collection<String> data) { 
    data.forEach(Objects::requireNonNull);
}

注:此處需要視具體集合的大小以及評估效能損耗;

同樣的型別場景,不詳細舉例了:

  • 當你的型別是集合時,返回一個空容器,而不是返回 null,可以避免消費方出現空指標異常;
  • 使用列舉常量來替換 Boolean 來避免拆箱引入的空指標異常
  • 非法資料狀態,直接短路丟擲異常而不是返回 null

善用靜態分析工具來輔助

介紹兩個重要的註解:@Nullable@NotNull 註解:

  • @Nullable 註解意味著預期被註釋的變數可能包含null,或者被註釋的方法可能返回null
  • @NotNull 註釋意味著預期的值絕不是null。併為靜態分析提供了提示

這類註解,可以在靜態分析工具實時分析潛在的異常;

interface Processor {
    @NotNull 
    String getNotNullValue();
    
    @Nullable
    String getNullable();
    
    public void process() {
        //此處警告:條件永遠為假,不用多次一舉
        if (getNotNullValue() == null) { 
            //do something
        } 
        //此處警告:trim() 呼叫可能導致 NullPointerException
        System.out.println(getNullable().trim()); 
    }
}

再談使用 Optional 替代 null 的一些注意事項

為了避免使用 null ,一些開發者傾向於使用 Optional 型別。可以將 Optional 想象成一個盒子,它要麼是空的,要麼包含一個非 null 的值:

獲取Optional物件有三種標準方式:

  • Optional.empty() —— 獲取一個空的 Optional
  • Optional.of(value) —— 獲取一個非空的 Optional,如果值為 null 則丟擲NullPointerException
  • Optional.ofNullable(value) —— 如果值為 null 則獲取一個空的 Optional,否則獲取一個包含值的非空 Optional

使用 Optional 來預防空指標,大問題沒有,但有幾個細節需要注意

1.勿濫用 ofNullable

一些開發者喜歡在所有地方使用 ofNullable(),因為它被認為是更安全的,它從不丟擲異常。但不能濫用,如果你已經知道你的值永遠不會為null,最好使用 Optional.of()。在這種情況下,如果你看到一個異常,你會立即知道錯了並且修復;

2.Optional 造成的程式碼可讀性降低

如下程式碼獲取員工地址,雖然簡潔,但可讀性很差,對於巢狀特別深的情況下,我還是不建議使用 Opinional,畢竟程式碼除了給自己看還得讓別人也一眼明白意圖

String employeeAddress = Optional.ofNullable(employee)
        .map(Employee::getAddress)
        .map(Address::getStreet)
        .map(Street::getNo)
        .map(No::getNumber).orElseThrow(() -> new IllegalArgumentException("非法引數"));

事後的異常監控

事前禁止寫入 null,事中防禦性程式設計空指標異常,但真的高枕無憂了嗎?

未必,事後所以建立一套好的異常告警機制是非常重要的;

我建議針對關鍵字:NullPointerException 做單獨的日誌採集,同時配上相應的告警級別:理論上出現 1 次空指標異常就應該介入定位;

當然,特別是在釋出週期內,如果 N 分鐘內出現超過 M 次空指標異常那就肯定要快速定位和回滾了;

寫在最後

歡迎關注我的公眾號:程式設計啟示錄,第一時間獲取最新訊息;

微信 公眾號
image image

相關文章