關於領域模型轉換的那些事兒

[TOC]


應用分層&分層領域模型爲什麼重要?

我們在軟件開發設計及開發過程中,習慣將軟件橫向拆分爲幾個層。比如常見的三層架構:表現層(VIEW/UI)、業務邏輯層(SERVICE/BAL)、數據訪問層(DAO/DAL)。如下圖:
關於領域模型轉換的那些事兒

那應用系統爲什麼要分層呢?其實主要是解決以下幾個問題:

  1. 第一是解耦:

    有一句計算機名言:軟件的所有問題都可以通過增加一層來解決。當系統越大,團隊越多,需求變化越快時,越需要保證程序之間的依賴關係越少。而分層/面向接口編程,會使我們在應對變化時越容易。

  2. 第二是簡化問題:

    當我們想不明白從用戶操作一直到數據落盤整個過程的交互情況時,我們應該換種方式思考。想想各層應該提供哪些支持,通過對各層分工的明確定義,複雜問題就變成了如何將各層功能組合起來的“積木搭建”。

  3. 第三是降低系統維護與升級成本:

    這裏體現了面向接口編程的優勢。我們抽象出數據訪問層後,只需要保證對外提供的接口不變,底層數據庫使用Oracle還是MySql,上層結構是感知不到的。

  4. 第四是邏輯複用/代碼複用:

    通過分層,明確定義各層職責,再也不會出現系統中多個地方查詢同一個數據庫表的代碼。因爲查詢某個數據庫表的工作只會由一個數據訪問層類來統一提供。

  5. 第五提高團隊開發效率:

    如果開發團隊很多,通過分層和接口定義。各團隊只需要遵循接口標準/開發規範,就可以並行開發。有一個形容比較貼切:分層化相當於把軟件橫向切幾刀,模塊化相當於把軟件縱向切幾刀。

在《阿里巴巴Java開發手冊》中,對應用分層的建議是這樣的:
關於領域模型轉換的那些事兒

  • 開放接口層:可直接封裝Service方法暴露成RPC接口;通過Web封裝成http接口;進行網關安全控制/流量控制等。
  • 終端顯示層:各個端的模版渲染並執行顯示的層。當前主要是velocity渲染,JS渲染,JSP渲染,移動端展示等。
  • Web層:主要是對訪問控制進行轉發,各類基本參數校驗,或者不復用的業務簡單處理等。
  • Service層:相對集體的業務邏輯服務層。
  • Manager層:通用業務處理層,它有如下特徵:
    1. 對第三方平臺封裝的層,預處理返回結果及轉化異常信息。
    2. 對Service層通用能力的下沉,如緩存方案/中間件通用處理。
    3. 與DAO層交互,對多個DAO的組合複用。
  • DAO層:數據訪問層,與底層MySQL、Oracle、HBase等進行數據交互。
  • 外部接口或第三方平臺:包括其他部門RPC開放接口,基礎平臺,其他公司的HTTP接口。

以上的層級只是在原來三層架構的基礎上進行了細分,而這些細分的層級僅僅是爲了滿足業務的需要。千萬不要爲了分層而分層。

過多的層會增加系統的複雜度和開發難度。因爲應用被細分爲多個層次,每個層關注的點不同。所以在這基礎上,抽象出不同的領域模型。也就是我們常見的DTO,DO等等。其本質的目的還是爲了達到分層解耦的效果。


典型的領域模型都有哪些?

以上我們簡單瞭解了分層的重要性,那麼隨着分層引入的典型領域模型都有哪些?我們還是來看看《阿里開發手冊》提供的分層領域模型規約參考:

  • DO(Data Object):此對象與數據庫表結構一一對應,通過DAO層想上傳輸數據源對象。
  • DTO(Data Transfer Object):數據傳輸對象,Service或Manager向外傳輸的對象。
  • BO(Business Object):業務對象,由Service層輸出的封裝業務邏輯的對象。
  • AO(Application Object):應用對象,在Web層與Service層之間抽象的複用對象模型,極爲貼近展示層,複用度不高。
  • VO(View Object):顯示層對象,通常是Web向模版渲染引擎層傳輸的對象。
  • Query:數據查詢對象,各層接收上層的查詢請求。注意超過2個參數的查詢封裝,禁止使用Map類來傳輸。

各個領域模型在分層上的傳輸關係大概是這樣:
關於領域模型轉換的那些事兒

在給出的參考中並沒有對模型對象進行非常明確的劃分,特別是對BO、AO、DTO的界限不是非常明確。這也是因爲系統處理的業務不同、複雜度不同導致的。所以在設計系統分層和建模的時候,需要綜合考慮實際應用場景。

數據在上傳下達的過程中就會出現轉換的工作,可能有些小夥伴會覺得麻煩,爲什麼要弄出這麼多O?轉來轉去的多累!

在這裏我舉個例子,比如你查詢自己網上購物的訂單,可能會在網頁上看到這樣的信息:
關於領域模型轉換的那些事兒

其中包含:訂單編號,下單日期,店鋪名稱,用戶信息,總金額,支付方式,訂單狀態還有一個訂單商品明細的集合。

對終端顯示層來說,這些信息是可以封裝成一個VO對象的。因爲顯示層的關注點就是這些信息。爲了方便顯示層展示,我們可以將所有屬性都弄成字符串類型。如下示例,可以看到,除了訂單id外,都是String類型:

public class OrderVO {
    /**
     * 訂單id
     */
    Long orderId;

    /**
     * 下單日期
     */
    String orderDate;

    /**
     * 總金額
     */
    String totalMoney;

    /**
     * 支付方式
     */
    String paymentType;

    /**
     * 訂單狀態
     */
    String orderStatus;

    /**
     * 商鋪名稱
     */
    String shopName;

    /**
     * 用戶名稱
     */
    String userName;

    /**
     * 訂單商品明細集合
     */
    List<ProductVO> orderedProducts;
}

再來看看對於業務邏輯層來說,它關心的是什麼呢?顯然跟顯示層關注的不一樣,它更加關注的是內部的邏輯關係。如下示例:

public class OrderVO {
    /**
     * 訂單id
     */
    Long orderId;

    /**
     * 下單日期
     */
    Date orderDate;

    /**
     * 總金額
     */
    BigDecimal totalMoney;

    /**
     * 支付方式
     */
    PaymentType paymentType;

    /**
     * 訂單狀態
     */
    OrderStatus orderStatus;

    /**
     * 商鋪信息
     */
    ShopDTO shopInfo;

    /**
     * 用戶信息
     */
    UserDTO userInfo;

    /**
     * 訂單商品明細集合
     */
    List<ProductDTO> orderedProducts;
}

從如上代碼可以看到,下單日期使用的Date類型,金額使用BigDecimal,支付方式和訂單狀態使用枚舉值表示,商鋪名稱和用戶名稱變成了商鋪信息/用戶信息對象,明細集合中的商品也變成了DTO類型的對象。

在業務邏輯層面,更多的是關注由多種信息組合而成的關係。因爲它在系統中起到信息傳遞的作用,所以它攜帶的信息也是最多的。

那我們再來看看數據持久層,上面也提到了,數據持久層與數據庫是一一對應的關係,而上一層的訂單信息其實可以拆解爲多個持久層對象,其中包含:訂單持久層對象(OrderDO),商鋪持久層對象(ShopDO),用戶持久層對象(UserDO)還有一堆的商品持久層對象(ProductDO)。相信通過描述大家也可以理解具體的拆分方法了。

回過頭來想想,如果我們一路拿着最開始的OrderVO對象來操作,當我們想要將它持久化時,會遇到多少坑就可想而知了。所以分層/拆分的本質還是簡化我們思考問題的方式,各層只關注自己感興趣的內容。


模型轉換需要注意的問題是啥?

可這樣的拆分確實增加了許多工作量,不同模型之間轉來轉去的確實頭疼。那就讓我們來梳理一下,在模型轉換時都需要注意哪些問題。在進行不同領域對象轉換時,有些問題是需要我們考慮的。
關於領域模型轉換的那些事兒

例如,上面這兩個不同的模型在轉換時,我們就需要考慮一些問題:

  1. 原對象和目標對象相同屬性的類型不一樣,有的是Date,有的是BigDecimal,還有的是枚舉
  2. 屬性的名稱也不一樣
  3. 集合類屬性中的泛型也不一樣
  4. 能不能只複製一部分屬性
  5. 能不能自定義轉換邏輯
  6. 嵌套對象是深拷貝還是淺拷貝

這麼多需要考慮的地方,咱們要怎麼處理,才能優雅的進行模型轉換呢?


常見的模型轉換方法瞭解下!

這裏我調研了大概有10種方法,有些使用起來比較複雜就沒有下大力氣去深入研究,如果有感興趣的小夥伴,可以自行深入研究下。

做爲測試和講解的案例,咱們就以上面說到的OrderDTO轉OrderVO爲例,來說說下面的各種方法。源對象OrderDTO大體結構是這樣的:

{
    "orderDate":1570558718699,
    "orderId":201909090001,
    "orderStatus":"CREATED",
    "orderedProducts":[
        {
            "price":799.990000000000009094947017729282379150390625,
            "productId":1,
            "productName":"吉他",
            "quantity":1
        },
        {
            "price":30,
            "productId":2,
            "productName":"變調夾",
            "quantity":1
        }
    ],
    "paymentType":"CASH",
    "shopInfo":{
        "shopId":20000101,
        "shopName":"樂韻商鋪"
    },
    "totalMoney":829.990000000000009094947017729282379150390625,
    "userInfo":{
        "userId":20100001,
        "userLevel":2147483647,
        "userName":"尼古拉斯趙四"
    }
}

我們期待轉換完的OrderVO對象是這樣的:

{
    "orderDate":"2019-10-09 15:49:24.619",
    "orderStatus":"CREATED",
    "orderedProducts":[
        {
            "productName":"吉他",
            "quantity":1
        },
        {
            "productName":"變調夾",
            "quantity":1
        }
    ],
    "paymentType":"CASH",
    "shopName":"樂韻商鋪",
    "totalMoney":"829.99",
    "userName":"尼古拉斯趙四"
}

先來看第一種方法:
也是最簡單粗暴的方法,直接通過Set/Get方式來進行人肉賦值。代碼我就不貼了,相信大家都會。

說一說它的優缺點:
優點:直觀,簡單,執行速度快
缺點:屬性過多的時候,人容易崩潰,代碼顯得臃腫不好複用

第二種:FastJson:
利用序列化和反序列化,這裏我們採用先使用FastJson的toJSONString的方法將原對象序列化爲字符串,再使用parseObject方法將字符串反序列化爲目標對象。

// JSON.toJSONString將對象序列化成字符串,JSON.parseObject將字符串反序列化爲OderVO對象
orderVO = JSON.parseObject(JSON.toJSONString(orderDTO), OrderVO.class);

轉換後的結果如下:

// 目標對象
{
    "orderDate":"1570558718699",
    "orderId":201909090001,
    "orderStatus":"CREATED",
    "orderedProducts":[
        {
            "productName":"吉他",
            "quantity":1
        },
        {
            "productName":"變調夾",
            "quantity":1
        }
    ],
    "paymentType":"CASH",
    "totalMoney":"829.990000000000009094947017729282379150390625"
}

可以看到轉換後的數據格式有幾個問題:

  • 日期不符合我們的要求
  • 金額也有問題
  • 最嚴重的是,當屬性名不一樣時,不會進行復制

這就是第二種使用JSON處理,好像也不能滿足我們的要求

第三種,Apache工具包PropertyUtils工具類,代碼如下:

PropertyUtils.copyProperties(orderVO, orderDTO);

轉換代碼看着很簡單,但是轉換過程會報錯:

java.lang.IllegalArgumentException: Cannot invoke com.imooc.demo.OrderVO.setTotalMoney on bean class 'class com.imooc.demo.OrderVO' - argument type mismatch - had objects of type "java.math.BigDecimal" but expected signature "java.lang.String"

轉換結果:

// 目標對象
{
    "orderId":201909090001
}

缺點:

  • 屬性類型不一樣,報錯
  • 不能部分屬性複製
  • 得到的目標對象部分屬性成功(這點很要命,部分成功,部分失敗!)

第四種,Apache工具包BeanUtils工具類,代碼如下:

BeanUtils.copyProperties(orderVO, orderDTO);

轉換後的結果是這樣:

// 目標對象
{
    "orderDate":"Wed Oct 09 02:36:25 CST 2019",
    "orderId":201909090001,
    "orderStatus":"CREATED",
    "orderedProducts":[
        {
            "price":799.990000000000009094947017729282379150390625,
            "productId":1,
            "productName":"吉他",
            "quantity":1
        },
        {
            "price":30,
            "productId":2,
            "productName":"變調夾",
            "quantity":1
        }
    ],
    "paymentType":"CASH",
    "totalMoney":"829.990000000000009094947017729282379150390625"
}

缺點:

  • 日期不符合要求
  • 屬性名不一樣時不復制
  • 目標對象中的商品集合變成了DTO的對象,這是因爲List的泛型被擦除了,而且是淺拷貝,所以造成這種現象。

第五種,Spring封裝BeanUtils工具類,代碼如下:

// 對象屬性轉換,忽略orderedProducts字段
BeanUtils.copyProperties(orderDTO, orderVO, "orderedProducts");

在忽略了部分屬性後,轉換結果就只剩下:

// 目標對象
{
    "orderId":201909090001
}

apache的BeanUtils和spring的BeanUtils中拷貝方法的原理都是先用jdk中 java.beans.Introspector類的getBeanInfo()方法獲取對象的屬性信息及屬性get/set方法,接着使用反射(Methodinvoke(Object obj, Object... args))方法進行賦值。

前面五種都不能滿足我們的需要,其實想想也挺簡單。對象轉換本來就很複雜,人工不介入很難做到完美轉換。

第六種,cglib工具包BeanCopier:

cglib的BeanCopier採用了不同的方法:它不是利用反射對屬性進行賦值,而是直接使用ASM的MethodVisitor直接編寫各屬性的get/set方法生成class文件,然後進行執行。

使用方法如下,註釋寫的很清楚。我們通過自定義的轉換器來處理Date轉String的操作:

// 構造轉換器對象,最後的參數表示是否需要自定義轉換器
BeanCopier beanCopier = BeanCopier.create(orderDTO.getClass(), orderVO.getClass(), true);

// 轉換對象,自定義轉換器處理特殊字段
beanCopier.copy(orderDTO, orderVO, (value, target, context) -> {
    // 原始數據value是Date類型,目標類型target是String
    if (value instanceof Date) {
        if ("String".equals(target.getSimpleName())) {
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
            return sdf.format(value);
        }
    }
    // 未匹配上的字段,原值返回
    return value;
});

轉換結果如下,對於我們自定義處理的屬性可以完美支持,其他未處理的屬性就不行了:

// 目標對象
{
    "orderDate":"2019-10-09 03:07:13.768",
    "orderId":201909090001
}

優缺點:

  • 字節碼技術,速度快
  • 提供自己自定義轉換邏輯的方式
  • 轉換邏輯自己寫,比較複雜,繁瑣
  • 屬性名稱相同,類型不同,不會拷貝(原始類型和包裝類型也被視爲類型不同)

第七種,Dozer框架:

注意,這已經不是一個工具類了,而是框架。使用以上類庫雖然可以不用手動編寫get/set方法,但是他們都不能對不同名稱的對象屬性進行映射。在定製化的屬性映射方面做得比較好的就是Dozer了。

Dozer支持簡單屬性映射、複雜類型映射、雙向映射、隱式映射以及遞歸映射。可使用xml或者註解進行映射的配置,支持自動類型轉換,使用方便。但Dozer底層是使用reflect包下Field類的set(Object obj, Object value)方法進行屬性賦值,執行速度上不是那麼理想。代碼示例:

// 創建轉換器對象,強烈建議創建全局唯一的,避免不必要的開銷
DozerBeanMapper mapper = new DozerBeanMapper();

// 加載映射文件
mapper.addMapping(TransferTest.class.getResourceAsStream("/mapping.xml"));

// 轉換
orderVO = mapper.map(orderDTO, OrderVO.class);

使用方式很簡單,關鍵在於配置:

<mappings xmlns="http://dozer.sourceforge.net"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://dozer.sourceforge.net
  http://dozer.sourceforge.net/schema/beanmapping.xsd">

    <!-- 一組類映射關係 -->
    <mapping>
        <!-- 類A和類B -->
        <class-a>com.imooc.demo.OrderDTO</class-a>
        <class-b>com.imooc.demo.OrderVO</class-b>

        <!-- 一組需要映射的特殊屬性 -->
        <field>
            <a>shopInfo.shopName</a>
            <b>shopName</b>
        </field>

        <!-- 將嵌套對象中的某個屬性值映射到目標對象的指定屬性上 -->
        <field>
            <a>userInfo.userName</a>
            <b>userName</b>
        </field>

        <!-- 將Date對象映射成指定格式的日期字符串 -->
        <field>
            <a>orderDate</a>
            <b date-format="yyyy-MM-dd HH:mm:ss.SSS">orderDate</b>
        </field>

        <!-- 自定義屬性轉化器 -->
        <field custom-converter="com.imooc.demo.DozerCustomConverter">
            <a>totalMoney</a>
            <b>totalMoney</b>
        </field>

        <!-- 忽略指定屬性 -->
        <field-exclude>
            <a>orderId</a>
            <b>orderId</b>
        </field-exclude>
    </mapping>
</mappings>

在配置文件中對特殊屬性進行了特殊定義,轉換結果符合我們的要求:

// 目標對象
{
    "orderDate":"2019-10-09 15:49:24.619",
    "orderStatus":"CREATED",
    "orderedProducts":[
        {
            "productName":"吉他",
            "quantity":1
        },
        {
            "productName":"變調夾",
            "quantity":1
        }
    ],
    "paymentType":"CASH",
    "shopName":"樂韻商鋪",
    "totalMoney":"829.99",
    "userName":"尼古拉斯趙四"
}

Dozer支持自定義轉換器,如下示例:

public class DozerCustomConverter implements CustomConverter {

    @Override
    public Object convert(Object destination, Object source, Class<?> destClass, Class<?> sourceClass) {
        // 如果原始屬性爲BigDecimal類型
        if (source instanceof BigDecimal) {
            // 目標屬性爲String類型
            if ("String".equals(destClass.getSimpleName())) {
                return String.valueOf(((BigDecimal) source).doubleValue());
            }
        }
        return destination;
    }
}

它的特點如下:

  • 支持多種數據類型自動轉換(雙向的)
  • 支持不同屬性名之間轉換
  • 支持三種映射配置方式(註解方式,API方式,XML方式)
  • 支持配置忽略部分屬性
  • 支持自定義屬性轉換器
  • 嵌套對象深拷貝

第八種,MapStruct框架:
基於JSR269的Java註解處理器,通過註解配置映射關係,在編譯時自動生成接口實現類。類似於Lombok的原理一樣,所以在執行速度上和Setter、Getter差不多。我目前個人使用較多的是MapStruct和BeanCopier,後期有空會單獨寫一篇文章介紹MapStruct的使用。

第九種,Orika框架:
支持在代碼中註冊字段映射,通過javassist類庫生成Bean映射的字節碼,之後直接加載執行生成的字節碼文件。

第十種,ModelMapper框架:
基於反射原理進行賦值或者直接對成員變量賦值。相當於是BeanUtils的進階版


其他幾種框架就沒有深入研究了。但看使用情況應該都能滿足實際場景的要求。介紹的這些轉換方法中,在性能上基本遵循:手動賦值 > cglib > 反射 > Dozer > 序列化。

在實際項目中,需要綜合使用上述方法進行模型轉換。比如較低層的DO,因爲涉及到的嵌套對象少,改動也少,所以可以使用BeanUtils直接轉。如果是速度、穩定優先的系統,還是乖乖使用Set、Get實現吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章