DTO – 服務實現中的核心數據
在一個Web服務的實現中,我們常常需要訪問數據庫,並將從數據庫中所取得的數據顯示在用戶頁面中。這樣做的一個問題是:用於在用戶頁面上展示的數據和從數據庫中取得的數據常常具有較大區別。在這種情況下,我們常常需要向服務端發送多個請求才能將用於在頁面中展示的數據湊齊。
一個解決該問題的方法就是根據不同需求使用不同的數據表現形式。在一個服務實現中較為常見的數據表現形式有MO(Model Object,在有些上下文中也被稱為VO,Value Object)和DTO(Data Transfer Object)。MO用來表示從數據庫中讀取的數據,而DTO則用來表示在網絡上所傳輸的數據。
在本文中,我們將討論如何在一個Web服務的實現中使用DTO及MO,並會對其它一些相關數據表現形式,如View Model等進行簡單地介紹。
Why DTO?
無論是桌面應用還是Web服務,其內部的數據表現都是非常重要的。在一個初學者了解一個系統的時候,其首先需要了解整個系統中的各個組件的作用,然後再了解系統中的Workflow,即在執行業務邏輯時各個組件是如何協同工作的。在了解了這兩部分之後,該初學者需要做的事情就是詳細地梳理一遍數據是如何在整個系統中流動的,即是整理並理解數據流(Dataflow)的過程。而在真正理解了數據流後,該初學者才具有了在系統中開發的能力。
整理數據流的過程是一個逐步細化的過程:從鑒別數據結構到該數據結構中的每個屬性到底是如何使用的。在整個數據流中,任何一個屬性值的改變都可能會導致數據的處理方式發生變化。
在整理數據流的時候我們要做什麽樣的事情呢?首先我們需要鑒別出到底哪些數據會在各個組件之間進行傳送,在傳送過程中進行了什麽樣的轉化,這些數據是如何構建出來的,又由它構建了哪些數據,最終這些數據是否被持久化到了本地存儲中等等。
而在整理數據流的過程中,數據的轉化常常是最難理解的部分。一個數據類型的定義常常與其運行環境有關。例如在一個電子商務網站中,一個表示商品的類Product可能包含了該商品的所有信息:商品的名稱,品牌,詳細介紹,價格等。在用戶使用電腦瀏覽器瀏覽的時候,這些信息都將被顯示在頁面上。但是在用戶使用手機進行瀏覽時,我們就需要考慮如何為這些手機用戶節省流量的問題。一種節省用戶手機流量的方法就是首先顯示商品的簡略信息,並在用戶決定查看商品的詳細介紹時再從服務端下載商品的詳細信息。在這種情況下,包含商品所有信息的類Product將不再是適合傳輸的數據結構。
而問題不僅僅出在需要將數據結構拆分的情況下,更可能出現在數據合並的情況中。例如網頁的UE為了提高用戶體驗,要求在產品頁面中直接將該商品品牌的詳細信息顯示在頁面中。在這種情況下,我們就需要在表示商品的類Product中添加一個記錄該商品品牌的域brand。但是在數據庫中,表示商品的類Product可能僅僅記錄了商品品牌的ID。因此在業務邏輯中,我們就需要將Product和其對應的Brand合並在一起。
甚至說,我們可以將事情弄得更復雜一些:
在上面的圖中,我們展示了數據在一個系統中可能存在的多種不同表現形式。在圖片的中央位置的是一個服務器,多種客戶端都將從它那裏獲得產品信息。就像前面所說,為了節省客戶端的流量,服務端向移動客戶端所發送的數據將是產品信息在服務端中的簡略版本。而在一個瀏覽器訪問該產品的時候,表示商品品牌的信息將內嵌在產品信息之中,以提供更好的用戶體驗。除了與客戶端通訊,服務端之間也可能產生信息的交換。在該交換過程中,表示產品的Product以及表示品牌的Brand則彼此獨立地在服務端之間傳遞。而就一個運行在遠端的Agent而言,其可能僅僅需要一個Product的ID來監控產品在生產制作方面的狀態。
而這一切數據都應當從系統的數據庫中得到。數據庫中的數據不可能同時存儲並維護這一系列數據結構,因此在一個復雜的系統中,數據庫中的數據表示與系統中所傳輸的數據之間常常是不同的數據結構。常見的情況則是將其分為兩類:一類用來訪問數據庫,在系統中表現數據庫中所記錄的數據,叫MO,即Model Object;另一類用來在網絡中傳輸,叫DTO,即Data Transfer Object。
服務中的DTO和MO
在了解了我們為什麽需要DTO和MO等數據的不同表示之後,就讓我們來看看這些數據表示在一個Web服務中是如何工作的。
先讓我們從最簡單的Web服務分層開始說起。一個最簡單的Web服務主要分為數據訪問層(DAL),業務邏輯層以及表現層三個部分。其中表現層是運行在客戶端的,而其它兩個層次則運行在服務端。當數據從DAL層讀取出來的時候,其所記錄的數據與數據庫中所記錄的數據是一致的,因此它們就是我們這篇文章中討論的MO。而在傳輸給客戶端的時候,這些數據可能會和MO不同,因此其為DTO:
現在就讓我們放大一下數據訪問層,來看看數據訪問層中MO所在的位置:
首先要強調的是,實現數據訪問層的方式有很多種,而上圖所展示的僅僅是一種基於Repository模式的實現。通過Repository來實現DAL是一種最為常見的數據訪問層實現方式。就像上圖所展示的那樣,在一個基於Repository模式的實現中,數據訪問層將擁有一系列Repository實例。這些Repository實例依賴於系統所使用的ORM來將數據庫中的數據轉化為Java類實例。這些Java類實例實際上就是在該數據訪問層所提供給業務邏輯層的MO。
而DTO則用於在服務與客戶之間以及服務和服務之間進行數據的傳遞。在這些傳遞過程中,對DTO的需求可能是多種多樣的:
上面的圖片展示了一段Product這種類型的DTO在服務端和客戶端以及服務端之間進行交互的過程。在該流程中,所需要傳遞的DTO並不相同:在使用瀏覽器讀取和保存有關Product的信息時,兩者的數據表現形式可能會有一些細微的差別。而在保存完畢後,服務可能會將新的Product作為負載來向其它服務器發送請求,而此時所使用的Product的表示又可能與前兩種略有差別。如果為這些細微的差別定義很多不同的DTO,那麽系統對數據的管理可能會遇到一系列麻煩。例如在一個復雜的系統中,DTO可能會按照下面的方式在系統中流轉:
在上圖中,我們展示了一個DTO在依次流轉過多個服務的情況。如果在DTO依次傳遞的過程中使用了不同的DTO表示,那麽一個服務所需要的DTO可能和另一個服務中所擁有的DTO並不匹配。這便是DTO反過來會影響到架構設計的一個最簡單的例子,卻也是DTO管理中最常見的問題,那就是DTO的表現形式過多。如果為所有的不同需求都創建一個DTO,那麽一個概念所對應的DTO可能多達5,6種,非常難於管理。這種管理上的困難常常存在於如何指定某個服務所需要使用的DTO種類,以及在更改DTO時需要同時修改一系列DTO的情況中。
為了防止DTO由於不同的需求而衍生出過多的種類,服務實現中常常允許DTO中的數據包含一些冗余。
逐步添加你的DTO
那麽我們該如何向系統中添加DTO呢?答案是,根據情況決定。在項目的一開始,數據庫中所存儲的數據與頁面所需要顯示的數據常常是一致的,因此在這種情況下,我們並不需要DTO的幫助。而在所需要的數據和數據庫所記錄的數據不再一樣的時候,我們就需要考慮是否需要在項目中添加DTO了。這時軟件開發人員就需要問自己:這種所需要的數據與數據庫中的數據不一致的情況是否常常出現?如果答案是“是”,那麽我們就需要開始著手準備添加對DTO的支持。
在系統中添加DTO主要有以下幾部分工作需要完成:
- 添加DTO類。
- 添加從MO到DTO的轉化邏輯。
- 將原本對MO的使用轉換為對DTO的使用。
相信讀者最先註意到的就是第三點。可以想象到的是,如果將整個系統的MO替換成DTO,那麽它的影響面將會非常大,而且非常容易出錯。因此在一個大型項目中,我們常常需要預先判斷DTO的必要性,進而盡早地添加DTO。
讓我們回過頭來看看第一個任務應該如何完成。在一個系統中,DTO常常用來傳輸數據,因此其自身往往不帶有任何邏輯。這也便是這些DTO常常被定義成JavaBean的原因。以JavaBean的形式來定義DTO帶來了一個巨大的好處,那就是很多第三方類庫都提供了生成JavaBean的功能。在這種情況下,軟件開發人員只需要通過一系列描述性語言來描述這些DTO即可。這其中最常用的便是JAXB。
在使用JAXB時,軟件開發人員只需要在.xsd文件中編寫一系列描述性信息:
<xsd:complexType name="Address"> <xsd:sequence> <xsd:element name="name" type="xsd:string"/> <xsd:element name="street" type="xsd:string"/> <xsd:element name="city" type="xsd:string"/> <xsd:element name="state" type="xsd:string"/> </xsd:sequence> <xsd:attribute name="country" type="xsd:NMTOKEN" fixed="US"/> </xsd:complexType>
那麽在JAXB運行完畢後,相應的Java類型就將被生成:
@XmlAccessorType(AccessType.FIELD) @XmlType(name = "Address", propOrder = { "name", "street", "city", "state" }) public class Address { protected String name; protected String street; …… @XmlAttribute @XmlJavaTypeAdapter(CollapsedStringAdapter.class) protected String country; public String getName() { return name; } public void setName(String value) { this.name = value; } public String getStreet() { return street; } public void setStreet(String value) { this.street = value; } …… public String getCountry() { if (country == null) { return "US"; } else { return country; } } public void setCountry(String value) { this.country = value; } }
是不是很簡單?在知道了如何創建一個DTO之後,我們就需要考慮如何將MO轉化成為DTO。當然,這依然有第三方工具可以幫助我們完成這個事情。一個較為著名的工具就是Dozer。使用Dozer也很簡單,在它的配置文件裏面標明需要相互轉換的兩個類型即可:
1 <mapping> 2 <class-a>com.ambergarden.egoods.mo.Address</class-a> 3 <class-b>com.ambergarden.edoods.dto.Address</class-b> 4 </mapping>
在運行時,Dozer會使用反射來對這兩個類型中的各個同名屬性進行匹配並賦值。如果兩個類型中擁有不同名的屬性,那麽軟件開發人員可以顯式地指定相互匹配的屬性:
1 <mapping> 2 <class-a>com.ambergarden.egoods.mo.Address</class-a> 3 <class-b>com.ambergarden.edoods.dto.Address</class-b> 4 <field> 5 <a>name</a> 6 <b>owner</b> 7 </field> 8 </mapping>
除此之外,Dozer還支持非常多的轉換功能,在這裏我們便不一一進行介紹了。
在有這些工具的輔助下,為系統添加DTO已經變得簡單多了。在對DTO的日常維護中,我們可能需要添加一些新的DTO,或者更改已有的DTO。在這種情況下,我們只需要更改對DTO進行描述的文件並更新Dozer的配置文件即可。當然,如果在Dozer中使用了自定義轉換邏輯,那麽軟件開發人員還需要更新相應的轉換邏輯。
貧血的DTO
DTO中只包含數據,並沒有包含任何行為。“這我知道”,或許你會說。
但是千萬不要大意。這常常會導致你陷入貧血模型的陷阱中。在服務端的業務邏輯實現以及客戶端的頁面邏輯中,我們有時需要指定對這些數據的操作邏輯。從面向對象設計的角度來說,某些邏輯實際上就應該定義在這些類型中。但是由於DTO本身沒有定義這些邏輯,因此我們需要在這些類型之外定義它們,例如在一個Helper類中為這些類型定義一系列輔助函數。
一個最簡單的示例就是對數據有效性的檢查。例如在一個Person類中,我們使用一個整型數據記錄了該人物的年齡:
1 class Person { 2 private int age; 3 …… 4 }
那麽在業務邏輯中,我們就需要檢查該域是否被設置為負數。由於DTO是使用工具自動生成的,因此這些檢查邏輯無法放在該DTO類中。作為一種變通方式,我們需要寫一個輔助類來完成該功能。但隨著這種需求越來越多,對這些輔助功能的管理將越來越困難。此時你就將完全陷入到貧血模型的陷阱中。
也就是說,DTO的主要職責是為了傳輸數據,但它並不擅長,甚至是不適合在業務邏輯中表示一個復雜概念。一個復雜概念常常與一些可重用的復雜邏輯關聯,但這正是DTO所不能辦到的。
為了解決這個問題,我們可以在服務端添加一個業務邏輯表現,即BO(Business Object)。在這種情況下,MO將不會直接轉化為DTO,而是轉化為BO。在所有業務處理完畢並需要將數據發送給客戶的時候,BO將轉化為DTO以進行傳輸。
而在客戶端,我們同樣可以引入一層新的更適合於頁面邏輯的數據表現。這種數據表現被稱為VM(ViewModel),即為了表觀展示所定義的模型。有時候,有些類庫提供了更為簡單的方法,例如YUI和ExtJS所提供的Mixin功能。
當然,在添加這些數據展現形式之前,軟件開發人員需要仔細考量添加這些模型所需要的工作量和所帶來效益之間的平衡。
DTO – 服務實現中的核心數據