天天看點

Java 值對象探讨與實踐

原創 于豔平(少琛) 淘系技術  3月30日

值類型與值對象

我們都知道,Java 語言中的類型分為兩種:基本類型(primitive type)和引用類型(reference type),這不僅是語言層面的特性,也由 JVM 内在實作支援[1]。

其中,基本類型指是的 8 種基本的數值類型:boolean、byte、char、int、short、long、float、double;而引用類型,指的是對程式中建立的對象的引用,可以了解為指向對象的指針或句柄。Java 号稱一切皆是對象,很可惜,這并不是事實,基本類型就不是對象。

那麼,值類型又是什麼呢?

在你編寫程式時,是否經常會遇到一些需要表達數值或其它類型值的場景?比如複數、向量、顔色值、坐标點、時間、日期等。這些值通常無法用基本類型來表達,一則它可能是多個屬性構成,二則針對值的一些操作或邏輯我們希望跟資料封裝在一起,比如向量的點乘、叉乘、取模等。但如果使用對象來表達同樣也會産生很多問題:

▐  相等性比較

對這些對象的比較是有意義的,但是預設情況下 Java 對象比較的是位址,是以直接比較的結果通常不是我們期待的行為:

Java 值對象探讨與實踐

▐  可變性

對引用類型的指派、方法傳參等會生成多個引用,這些引用都指向同一個對象。這在一些情況下是沒有問題的,但在某些場景下可能導緻對象發生預期之外的變化。如:

Java 值對象探讨與實踐

上面的 case 比較簡單,隻要對 Date 的特性有些了解就不會犯這樣的錯誤。但如果對象經過多次傳遞,使用的位置離建立的位置很遠的話,我們就未必能這麼謹慎了。這種問題,Martin Flower 稱之為 aliasing bug[2]。

▐  性能

上面兩點其實都容易解決,隻是每個實作需要寫很多樣闆代碼。需要比較的對象隻要重寫 

equals()

 和 

hashCode

 方法即可;對于可變性問題,可以将對象設計為不可變對象,在修改時傳回一個深拷貝副本來供用戶端操作。滿足上述兩種條件的對象,我們可以稱之為值對象。

那麼,通過“對象”來實作我們對這種資料結構的訴求,是否是最好的方式呢?

我們知道,Java 中的對象通常是配置設定在堆上,通過引用來進行操作,不過這不是必然的。JVM 有一項技術叫逃逸分析[3],可以在運作時分析出一個方法中建立的對象是否會逃逸到方法或線程外部,如果沒有逃逸,可以進而執行一些編譯優化,比如棧上配置設定、同步消除、标量替換等。如果一個對象被配置設定到棧上,就意味着當方法結束後就會自動銷毀,省去了 GC 的開銷,這對于優化應用記憶體占用和 GC 停頓時間來說,無疑是個好消息;而标量替換意味着壓根就不會建立對象,相關資料被替換成基本類型資料直接配置設定到棧上,不僅省去了對象操作相關開銷,也更利于 CPU 高速緩存或寄存器進行優化。

對于值對象來說,一般極少有共享的需求,假如能直接在棧上進行配置設定,那麼将省去對象的存儲、通路和 GC 的成本,對程式性能非常有利。不過進行逃逸分析也是有成本的,如果在語言層面直接支援的話,就可以進一步減少編譯時分析的開銷。不過,目前 Java 語言還做不到這一點。

當一門程式設計語言為上述類型的資料結構提供内在支援時,該類型可稱之為值類型。而對于滿足上述訴求的執行個體,無論是基于值類型實作還是普通對象類型實作,我們都可以稱之為值對象。

不同程式設計語言對值類型的支援

▐  Java

上面已經說過,Java 語言層面原生并不支援值類型。不過,它提供了許多具有值類型特點的類,比如:8個基本類型對應的封裝類、String、BigDecimal 等,這些類的共同特點之一就是不可變性,同時也都對比較操作做了實作,是以都可看作值對象。另外一個應該設計為不可變、但實際可變的類是 java.util.Date 類,也因為如此,Date 類飽受诟病。在 Java 8 中官方正式推出新的 時間/日期 API,試圖取代 Date 相關接口,這些新的類全部被設計成了不可變類。

對于Java 是否應該從語言層面支援值類型的讨論由來已久,比如這篇JEP提案[4]早在 2012 時就提議支援值對象;oracle 論壇上的這篇部落格[5]也對如何實作值對象做了探讨。最近有兩篇提案,一個提出了 Primitive Object[6]的概念,可算是值類型的一種實作;另外一篇提議基于Primitive Object統一基本類型與對象類型[7]。不過,這兩個提案仍處于 

Submitted

 階段(JEP 提案從提出到釋出的流程有幾個階段,可以看 這裡[8] Process states 一節),能否被采納、實作乃至釋出到正式版本,還是未知之數。

▐  C++

C++ 中沒有值對象這一概念,不過在建立對象時,允許開發者選擇在堆上還是在棧上建立。比如下面的示例代碼,直接通過 

A a;

 的方式建立的對象是配置設定在棧上的,而通過 

new A();

 的方式建立的對象配置設定在堆上,并且傳回一個指向該對象的指針。在棧上建立的對象在函數執行結束時會自動銷毀。

更進一步,對 A 類型的對象進行指派(34行)或方法傳參(38行)時,會産生一次拷貝操作,生成一個新的對象,新對象的作用域分别為目前函數和被調函數,相應函數執行結束時也會被銷毀。而對指針類型的對象進行指派(43行)和方法傳參(45行)時,盡管建立了新的指針對象,新的指針仍然指向相同的對象。

可見 C++ 中對類類型和指針類型的使用,分别具有值類型和引用類型的一些特點。

Java 值對象探讨與實踐

▐  C#

C# 語言中是明确的提出了值類型[9]這一概念的,struct 就是一種值類型。MSDN文檔中說明:“預設情況下,在配置設定中,通過将實參傳遞給方法并傳回方法結果來複制變量值。” 在指派操作時,也同樣會對對象進行拷貝。如下面的代碼所示,我們可以看到将 p1 指派給 p2,p2 修改狀态後,p1 中的資料仍然保持不變。

另外,在 C# 中值類型是配置設定在棧上的,值類型與引用類型之間可以進行轉化,稱之為裝箱和拆箱,上面的 Java Primitive Object 提案似乎也借鑒了 C# 的設計思想。

Java 值對象探讨與實踐

▐  其它語言

其它程式設計語言對值類型的支援不盡相同。以函數式程式設計為例,大多數函數式程式設計語言中變量都是不可變的,是以在函數式語言中定義的資料結構都可看作是值類型。

DDD 中的值對象

盡管 Java 并沒有對值對象提供語言層面的類型支援,但這并不妨礙我們在自己的代碼中建立事實上的值對象。實際上值對象[10]的定義可以并不僅限于類似向量、顔色值、坐标點這樣一些使用範圍。Martin Flower 認為,值對象在程式設計中的作用被極大的忽視了,善于值對象可以非常有效的簡化你的系統代碼;Vaughn Vernon 在《實作領域驅動設計》一書中甚至說,我們應該盡量使用值對象模組化而不是實體對象。實際上,當提到“值對象”這個概念時,最常見的就是在 DDD(領域驅動設計)這個上下文中。

Eric Evans 在《領域驅動設計 軟體核心複雜性應對之道》一書中提出了實體(Enity)與值對象(Value Object)的概念。Vaughn Vernon 在《實作領域驅動設計》中做了進一步闡述。

在 DDD 中,實體代表具有個性特征或需要區分不同個體的對象,它具有唯一辨別和可變性。對于實體對象,我們首要考慮的并不是其屬性,而是能代表其本質特征的唯一辨別,無論對象屬性如何變化,它都是同一個對象,它的生命周期具有連續性,甚至對對象進行持久化存儲然後基于存儲來重建對象,它仍然是同一個對象的延續。

而值對象,它通常是一些屬性的集合,是對對象的度量和描述。值對象應該是不可變的,當度量和描述改變時,可以用另外一個值對象替換。值可以跟其它值對象進行相等性比較。

可以看到,在 DDD 中的值對象的定義跟我們上面的描述非常相似。《實作領域驅動設計》對于值對象的闡述非常詳盡,想要進一步了解的可以閱讀該書第 6 章内容。

使用值對象的好處

因為值對象通常設計為不可變對象,是以值對象的好處首先就是不可變對象的好處。另外在支援值類型的語言中,值對象的建立、操作、銷毀會有更好的性能。

▐  線程安全

在 Java 程式設計語言中,出現線程安全問題的必要條件有兩個:對象狀态被多個線程共享;對象狀态可變。是以解決線程安全問題的思路也主要從幾個方向出發:無狀态;狀态不可變;不共享狀态;通過同步機制來序列化對象狀态的通路。

而不可變對象狀态是不變的,是以是線程安全的,可以放心應用到并發環境中,無需額外的同步機制在多個線程中共享。

▐  避免 Alias Bug

Aliasing bug 的概念上文已經講過,主要是指多個對象的引用被分享到多個環境中後,在某個環境的改動會導緻從另外一個環境中看到預期之外的變化。

最近我們的項目中就遇到這樣一個 bug,某個對象會被緩存到本地記憶體中,取出對象後,傳回給 UI 層的某個屬性值需要根據請求環境做一些判斷與變更,由于未做防禦性拷貝,導緻變化污染了緩存對象,後面的請求出現錯誤的結果。

而不可變對象不允許修改屬性值,任何狀态的變化必須通過建立副本來實作,是以可以有效的避免該類 bug。

▐  簡化邏輯複雜程度

  • 任何使用到值對象的地方,它的狀态始終是合法的。通常不可變對象會在建立時進行自校驗,是以一旦建立完成,它始終處于合法有效的狀态之中,沒有任何行為能使破壞它的一緻性狀态。
  • 可以安全的共享給其它對象、其它線程,而不用擔心狀态發生變化,簡化了代碼維護者對流程、邏輯的了解。
  • 可以作為構件簡化其它對象的狀态管理。當其它對象使用不可變對象作為其構件時,由于不可變對象自身狀态不變,使得它在被傳入和擷取時不需要進行防禦性拷貝,簡化了對象狀态的跟蹤。

▐  使你的設計更清晰

值對象與基礎類型資料相比,富含業務語義,在任何使用到它的地方,其含義一看便知。它還可以封裝跟資料相關的業務邏輯,避免為了複用代碼而建立 util 類,更符合面向對象的思想。

▐  可比較、可以被集合類使用

相信這一點不需要再說明了。

值對象 Java 實踐

那麼,如何在我們的代碼中建立不可變對象呢?我們分為部分内容來講,第一部分是指導思想,第二部分是如何進行實踐。

▐  值對象建立指南

  • 建立不可變對象

在 《Effective Java 第三版》 第 17 條 最小化可變性一節中,将不可變類的設計歸納為五條原則:

  • 不要提供修改對象狀态的方法
  • 確定這個類不能被繼承
  • 把所有屬性設定為 final
  • 把所有的屬性設定為 private
  • 確定對任何可變元件的互斥通路

第 2、3、4 點很容易了解。對第 1 點,也就是說對任何涉及狀态變更的操作,都不能直接修改原始對象的狀态,而是通過建立對象的副本,比如下面對複數對象的“加”操作:

Java 值對象探讨與實踐
Java 值對象探讨與實踐

對于第 2 點,確定類不能被繼承,除了将類設為 final,還有一種方式是将構造方法設為 private,并向外提供靜态工廠方法來建立執行個體。

Java 值對象探讨與實踐

而第 5 點的意思是,“如果你的類有任何引用可變對象的屬性,請確定該類的用戶端無法獲得 對這些對象的引用”。舉例而言,下面的 Period 類,盡管滿足上面的 1~4 點,但由于其狀态變量中包含了引用對象,引用對象通過構造方法與通路方法與外界共享,導緻它的狀态也會發生變化(第 7 行、第 10 行):

Java 值對象探讨與實踐

一個解決方案是,不使用 Date 對象,而是使用 Java 8 中提供的 LocalDate 對象,該對象是不可變的。另一種方案,在引用共享的位置對對象進行拷貝。

由此可以延伸出:

  • 盡可能使用不可變對象作為建構對象的元件;
  • 必要時對構造方法參數和方法傳回值進行防禦性拷貝:(第 6、7、14、18 行)
Java 值對象探讨與實踐

這裡還要注意幾點:

  • 進行防禦性拷貝應在參數檢查之前執行,以避免參數檢查可拷貝期間受其它線程對參數更改的影響。
  • 必要時,對實作 serializable 接口的類進行反序列化重寫 readObject 方法,以避免位元組碼攻擊。對于這一點,簡單來講就是由于 Java 對象的反序列預設通過 readObject 方法重建對象,而不會調用我們提供的構造方法,這使得攻擊者可以通過修改位元組碼資料,進而繞開構造方法中的參數校驗的防禦性拷貝。具體可以看 《Effective Java 第三版》 第 88 條 保護性的編寫 readObject 方法。
  • 當構造方法參數過多時,可以借助 builder 設計模式

這一點可參照《Effective Java 第三版》 第 2 條。這裡不展開了。

  • 盡可能重用執行個體

由于不變對象在修改資料時會進行拷貝,是以它的一個主要問題就是可能會建立過多的對象,這會帶來性能問題。一個方案是,對可能會經常用到的對象提供公共的靜态 final 常量。這一點,既可以通過公共的常量字段來實作,也可以通過靜态工廠方法來實作。

  • 相等性判斷

需要重寫 equals() 和 hashCode() 方法。至于為什麼以及如何實作,相信大家都知道了,就不展開講了。

  • 建立即合法

這一點也很好了解,既然值對象是不可變的,那麼建立完成之後沒有任何方法可以改變的狀态,是以必須在構造時進行必要的合法性校驗,使建立出來的對象滿足其所有的不變性條件(Invariants)。

▐  如何實作

  • 手寫代碼

有了指導思想,如何實作其實就一目了然了。隻不過,要實作不可變對象,需要建立大量的樣闆代碼,比如 equals() 和 hashCode() 方法的重寫、builder 模式的建立等等。這些重複代碼不僅寫起來費力,而且會使類的核心業務邏輯隐藏在大量的樣闆代碼中,降低了類的可讀性。是以,最好實作方式還是借且代碼生成工具。

  • 基于代碼生成工具
(i) lombok @value 注解

lombok 庫的 @value 注解可以很友善的幫我們生成一個不可變的值對象類型。如:

Java 值對象探讨與實踐

如果我們使用 Intellij IDEA 工具,并且安裝了 lombok 插件,可以在源代碼處 右鍵 -> Refactor -> Delombok -> All lombok annotations,來檢視 lombok 注解處理器處理過後生成的位元組碼對應的源代碼大概是什麼樣子。

Java 值對象探讨與實踐

這裡有一點需要注意,lombok 工具對于引用類型不會幫我們做防禦性拷貝,是以假如我們的構成元件包含可變對象,需要我們自己去做防禦性拷貝。做法很簡單,隻要提供我們自己的構造方法和 get 方法,lombok 就不會再幫我們生成對應的方法。

Java 值對象探讨與實踐

如果我們要對參數進行合法性校驗,也同樣需要提供自定義的構造方法,在構造方法中添加校驗邏輯。

(ii) lombok @Builder 注解

lombok 的 @Builder 注解非常強大,可以應用在類上、構造方法上,也可以應用在靜态工廠方法上。在建構時未傳入的參數為該類型的預設值。同樣的,如果你需要校驗,可提供自定義的全參數構造方法。

Java 值對象探讨與實踐

上面我們提到過,對值對象的執行個體盡可能的重用。如果我們使用靜态工廠方法,就可以實作這一點:

Java 值對象探讨與實踐

注意我們把 @Builder 注解放在了 

of()

 靜态工廠方法上面,同時将構造方法設為 private。通過檢視生成的代碼,發現 builder 的 

build()

 方法直接調用了該工廠方法。

(iii) lombok @With 注解

@Value 注解會将生成的類設為不可變,如果我們需要修改對象的狀态,怎麼辦?上面說過,修改狀态需要建立拷貝。使用 @With 注解可以很友善的做到這一點。

Java 值對象探讨與實踐
(iv) 與 mapstruct 配合使用

在進行領域驅動設計時,我們經常會在不同的層或者子產品之間使用不同的對象,比如持久化層使用跟資料庫紀錄進行映射的 DO 對象,而在領域層使用更具有業務意義的領域對象。如何在對象之間進行屬性的拷貝呢?可以有很多種選擇,我最常用的是 mapstruct 工具,該工具非常強大,不僅支援不同名稱、不同類型字段的映射,還可以使用表達式、方法調用等。

對于它我們不做過多介紹,有興趣可以看這裡[11]。

在進行屬性拷貝時,通常基于無參構造函數建立對象,然後設定對應屬性。但是上面的類,我們在實作不可變特性時,不再提供無參構造函數。如何讓 mapstruct 支援這種類呢?恭喜你,隻要加了 @Builder 注解,什麼都不需要做,mapstruct 已經内置提供了對 lombok @Builder 注解的支援。

至于使用其它手段的屬性拷貝,我暫時沒有去了解,熟悉的同學可以參與讨論。

(v) json 反序列化

我們知道,當使用 json 反序列化工具生成自定義類型的執行個體時,通常也是使用該類型的預設無參構造方法。假如沒有該構造方法,運作時就會抛出異常。但是,我們不希望提供該構造方法來破壞對象的不可變性。怎麼辦呢?

這裡又要祭出 lombok 的另一法寶,@Jacksonized 注解。加上這一注解後,我們的不可變對象就可以被 jackson json 庫順利的建立出來了(需要跟 @Builder 一起使用)。其實這個注解沒什麼複雜之處,能實作這點得益于 jackson json 庫本身對 builder 模式的支援,@Jacksonized 注解隻是按照 jackson json 的相關要求生成相關的 builder 類和方法而已。目前 fastjson 庫似乎不支援使用 builder 模式來建立對象,不知道後面有沒有相關的計劃。

總結

本文通過一些簡單的案例讨論了值類型與值對象的概念,并且探讨了不同語言對值類型的支援情況。然後對于在 Java 語言中如何建立值對象給出了一些指導原則,并介紹了一些可用于快速實作值對象的工具。值對象的使用是一種非常有用的程式設計技巧,可以使我們的業務語義更加清晰,并有效的簡化代碼邏輯的複雜程度。是以,建議大家在自己的代碼中多嘗試使用值對象,相信在這個過程中必然更有更深刻的認識和感受。

相關連結

[1].https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.2

[2].

https://martinfowler.com/bliki/AliasingBug.html?spm=ata.21736010.0.0.1bf49431yt4uP0

[3]. 

https://zhuanlan.zhihu.com/p/94568794?spm=ata.21736010.0.0.1bf49431yt4uP0

[4]. 

http://openjdk.java.net/jeps/169?spm=ata.21736010.0.0.1bf49431yt4uP0[5].

[5].https://blogs.oracle.com/jrose/value-types-in-the-vm?spm=ata.21736010.0.0.1bf49431yt4uP0

[6].https://bugs.openjdk.java.net/browse/JDK-8251554?spm=ata.21736010.0.0.1bf49431yt4uP0

[7].https://bugs.openjdk.java.net/browse/JDK-8259731?spm=ata.21736010.0.0.1bf49431yt4uP0

[8].https://openjdk.java.net/jeps/1?spm=ata.21736010.0.0.1bf49431yt4uP0

[9].https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/builtin-types/value-types?spm=ata.21736010.0.0.1bf49431yt4uP0

[10].https://martinfowler.com/bliki/ValueObject.html?spm=ata.21736010.0.0.1bf49431yt4uP0

[11].https://mapstruct.org/?spm=ata.21736010.0.0.1bf49431yt4uP0