修正 Java 中 Sonar 的「使 Transient 或 Serializable 為真」警告
1. 引言
在使用 Java 原生序列化並結合 SonarQube 程式碼評估工具時,有時會遇到「‘Serializable’類別中的欄位必須是‘transient’或‘Serializable’」( 規則鍵:java:S1948 )警告。這條規則是一條重要的安全屏障,它可以防止常見的執行階段錯誤: NotSerializableException.
在本教程中,我們將探討此警告的原因。此外,我們還將討論 Java 序列化的底層機制,以及解決此問題的最佳策略。
2. 理解序列化契約
要使 Java 物件可序列化,其類別必須實作java.io.Serializable介面。這是一個標記接口,它告訴 JVM 物件的狀態將被轉換為位元組流以便儲存或網路傳輸。
因此,該契約的基本規則是遞歸的:如果一個類別是可序列化的,那麼它所有非靜態和非瞬態的成員欄位也必須是可序列化的。如果序列化機制遇到一個未實作Serializable介面且未標記為transient字段,則該程序會在執行時失敗。 Sonar 會在靜態分析期間標記這些字段,幫助我們在程式碼運行之前捕獲此錯誤。
此外,需要記住的是,序列化不僅僅是關於頂層類別;它關乎整個物件圖。
3. 重現聲吶警告
我們來看一個觸發 java:s1948 警告的典型場景。首先,讓我們將SLF4J 依賴項新增到 pom.xml 檔案中:
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.17</version>
</dependency>
現在,我們建立一個User類,並將其儲存在分散式會話或快取中。類別也包含對另一個類別Address,但 Address 類別沒有實作Serializable介面。我們先從一個簡單的實作開始:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private Address address; // Sonar Warning: Make "address" transient or serializable
private final Logger logger = LoggerFactory.getLogger(User.class); // Sonar Warning
public User(String username, Address address) {
this.username = username;
this.address = address;
}
}
在這個例子中, User類別正確地實作了Serializable 。但是,如果Address類別在定義時沒有實作該接口,Sonar 會標記address欄位。同樣地,由於 SLF4J Logger沒有實作Serializable ,也會觸發警告。此外,如果我們嘗試序列化User的實例,JVM 會在運行時拋出NotSerializableException異常。這正是 Sonar 試圖幫助我們避免的情況。現在,讓我們來看看如何解決這個警告。
4. 使字段可序列化
最直接的解決方法是確保巢狀類別也實作了Serializable介面。當欄位代表物件狀態的核心部分且必須保留時,這是首選方法。因此,如果我們擁有嵌套物件的原始程式碼,只需更新類別定義即可:
public class Address implements Serializable {
private static final long serialVersionUID = 1L;
private String street;
private String city;
public Address(String street, String city) {
this.street = street;
this.city = city;
}
}
透過新增 `implements Serializable介面並提供 ` serialVersionUID ,我們滿足了契約要求。最佳實踐是始終包含serialVersionUID ,以確保反序列化過程中的相容性,尤其是在類別結構隨時間演變的情況下。
5. 使用static修改器
解決某些字段警告的另一種有效方法是將它們聲明為static 。在 Java 中,靜態欄位不會被序列化,因為它們屬於類別本身,而不是特定的實例。由於 JVM 會將它們從序列化過程中排除,因此 SonarQube 不會標記它們。
這是日誌記錄器和常數的標準解決方案:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger logger = LoggerFactory.getLogger(User.class); // No Warning
private String username;
private Address address;
// getters, setters ...
}
透過將日誌記錄器設為靜態,警告消失了,並且我們遵循了 Java 中常見的日誌記錄器聲明模式。這種方法非常適合類別的所有實例共享的字段,這類字段並不代表單個物件的唯一狀態。
6. 利用transient關鍵字
如果某個欄位是實例特有的,但不應該被序列化(例如對臨時快取的引用,或不可序列化的第三方物件),我們會使用transient關鍵字。這個修飾符告訴 JVM 在序列化過程中跳過該欄位:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private Address address;
private transient List<String> temporaryCache; // Warning Resolved
// getters and setters ...
}
我們需要注意的是,當物件被反序列化時,所有瞬態欄位都會被初始化為預設值:物件為null ,基本類型為0因此,如果物件在復原後需要某個瞬態欄位才能正常運作,我們就必須重新初始化它。實現這一點的方法是使用readObject方法:
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.temporaryCache = new ArrayList<>();
}
7. 處理框架依賴項
在像 Spring 這樣的現代 Java 框架中,當注入@Service或@Repository註解的@SessionScoped bean 時,經常會出現此警告。由於這些服務由容器管理且通常不可序列化,因此應將其標記為 transient:
@SessionScoped
public class UserPreferences implements Serializable {
@Autowired
private transient PreferenceService service; // Fixed by marking it with transient
}
Spring 會處理依賴注入,因此當 bean 從會話中恢復時,它會重新註入服務(前提是框架的代理機制支援此功能)。這不僅保證了會話作用域 bean 的可序列化性,也允許它們與無狀態服務互動。
此外,我們可能會遇到使用未實作Serializable第三方函式庫類別的情況。如果我們無法修改它們的原始程式碼,則主要有兩種選擇:
- 包裝物件:建立一個可序列化的 DTO,其中只包含必要的原始資料。
- 自訂序列化:對第三方物件使用
transient,並在序列化期間使用writeObject和readObject方法手動處理其狀態。
例如,如果我們有一個不可序列化的Metadata對象,我們可以將其狀態儲存為可序列化的Map ,並在反序列化時重建它。這樣既能確保領域模型的可序列化,又能繼續使用強大的第三方工具。
8. 結論
在本教程中,我們介紹如何處理 Sonar 警告「Make Transient or Serializable」。雖然該警告有助於確保 Java 應用程式的運行時穩定性,但它也可能造成混淆。因此,透過理解 Java 序列化的遞歸特性,我們可以根據欄位在應用程式中的作用選擇正確的修復方法。
如果欄位是物件狀態的核心部分,則實作Serializable 。日誌記錄器、常數和共享的類別級成員應使用static 。如果欄位是資源或臨時數據,則標記為transient 。如果要序列化無狀態服務或不打算持久化的第三方對象,則需要重構設計。
和往常一樣,程式碼可以在GitHub上找到。