[TOC]
應用分層&分層領域模型爲什麼重要?
我們在軟件開發設計及開發過程中,習慣將軟件橫向拆分爲幾個層。比如常見的三層架構:表現層(VIEW/UI)、業務邏輯層(SERVICE/BAL)、數據訪問層(DAO/DAL)。如下圖:
那應用系統爲什麼要分層呢?其實主要是解決以下幾個問題:
-
第一是解耦:
有一句計算機名言:軟件的所有問題都可以通過增加一層來解決。當系統越大,團隊越多,需求變化越快時,越需要保證程序之間的依賴關係越少。而分層/面向接口編程,會使我們在應對變化時越容易。
-
第二是簡化問題:
當我們想不明白從用戶操作一直到數據落盤整個過程的交互情況時,我們應該換種方式思考。想想各層應該提供哪些支持,通過對各層分工的明確定義,複雜問題就變成了如何將各層功能組合起來的“積木搭建”。
-
第三是降低系統維護與升級成本:
這裏體現了面向接口編程的優勢。我們抽象出數據訪問層後,只需要保證對外提供的接口不變,底層數據庫使用Oracle還是MySql,上層結構是感知不到的。
-
第四是邏輯複用/代碼複用:
通過分層,明確定義各層職責,再也不會出現系統中多個地方查詢同一個數據庫表的代碼。因爲查詢某個數據庫表的工作只會由一個數據訪問層類來統一提供。
- 第五提高團隊開發效率:
如果開發團隊很多,通過分層和接口定義。各團隊只需要遵循接口標準/開發規範,就可以並行開發。有一個形容比較貼切:分層化相當於把軟件橫向切幾刀,模塊化相當於把軟件縱向切幾刀。
在《阿里巴巴Java開發手冊》中,對應用分層的建議是這樣的:
- 開放接口層:可直接封裝Service方法暴露成RPC接口;通過Web封裝成http接口;進行網關安全控制/流量控制等。
- 終端顯示層:各個端的模版渲染並執行顯示的層。當前主要是velocity渲染,JS渲染,JSP渲染,移動端展示等。
- Web層:主要是對訪問控制進行轉發,各類基本參數校驗,或者不復用的業務簡單處理等。
- Service層:相對集體的業務邏輯服務層。
- Manager層:通用業務處理層,它有如下特徵:
- 對第三方平臺封裝的層,預處理返回結果及轉化異常信息。
- 對Service層通用能力的下沉,如緩存方案/中間件通用處理。
- 與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對象來操作,當我們想要將它持久化時,會遇到多少坑就可想而知了。所以分層/拆分的本質還是簡化我們思考問題的方式,各層只關注自己感興趣的內容。
模型轉換需要注意的問題是啥?
可這樣的拆分確實增加了許多工作量,不同模型之間轉來轉去的確實頭疼。那就讓我們來梳理一下,在模型轉換時都需要注意哪些問題。在進行不同領域對象轉換時,有些問題是需要我們考慮的。
例如,上面這兩個不同的模型在轉換時,我們就需要考慮一些問題:
- 原對象和目標對象相同屬性的類型不一樣,有的是Date,有的是BigDecimal,還有的是枚舉
- 屬性的名稱也不一樣
- 集合類屬性中的泛型也不一樣
- 能不能只複製一部分屬性
- 能不能自定義轉換邏輯
- 嵌套對象是深拷貝還是淺拷貝
這麼多需要考慮的地方,咱們要怎麼處理,才能優雅的進行模型轉換呢?
常見的模型轉換方法瞭解下!
這裏我調研了大概有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方法,接着使用反射(Method
的invoke(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實現吧。