在 2010 年 7 月刊的《MSDN 雜志》中,我開始介紹為借閱圖書館構建智能客戶端應用程序 的過程。 我將該項目命名為 Alexandria,並決定使用 NHibernate 進行數據訪問,使用 Rhino 服務總線實現與服務器之間的可靠通信。
NHibernate (nhforge.org) 是一個對象關系映射 (O/RM) 框架,而 Rhino 服務總線 (github.com/rhino-esb/rhino-esb) 是構建在 Microsoft .NET Framework 上的開源服務總線 實施。 我恰巧參與了這兩個框架的深層開發工作,這樣就有機會利用我熟悉的技術來實施項目 ,同時為需要了解 NHibernate 和 Rhino 服務總線的開發人員提供一個工作范例。
在上一篇文章中,我介紹了智能客戶端應用程序的基本構造塊。 我設計了後端以及智能客 戶端應用程序和後端之間的通信模式。 此外,我還略微談到了如何管理事務和 NHibernate 會 話,如何使用和回復來自客戶端的消息,以及如何將所有內容融入引導程序。
在本期內容中,我將介紹在後端和智能客戶端應用程序之間發送數據的最佳做法,以及分布 式更改管理的模式。 在此過程中,我將介紹其余的實施細節,並為 Alexandria 應用程序提供 一個完整的客戶端。
您可以從 github.com/ayende/alexandria 下載示例解決方案。 該解決方案包含三部分: Alexandria.Backend 包含後端代碼;Alexandria.Client 包含前端代碼; Alexandria.Messages 包含在前兩者之間共享的消息定義。
沒有單一模型規則
在編寫分布式應用程序時,人們最常提出的問題之一是:如何將我的實體發送到客戶端應用 程序,然後在服務器端應用更改集?
如果這是您的問題,則您可能是在思考一種主要將服務器端作為數據存儲庫的模式。 如果 構建此類應用程序,則可以選擇使用一些技術來簡化此項任務(例如,采用 WCF RIA 服務和 WCF 數據服務)。 不過,使用迄今為止我所概括的體系結構類型時,對於在網絡上發送實體實 際上毫無作用。 事實上,Alexandria 應用程序對相同的數據使用了三種不同的模型,其中每 種模型分別最適合應用程序的不同部分。
後端的域模型用於查詢和事務性處理,適合與 NHibernate 一起使用。如需進一步優化,可 以拆分查詢和事務性處理職責。 消息模型表示網絡上的消息,包括與域實體非常接近的一些概 念(示例項目中的 BookDTO 是 Book 的數據克隆)。 在客戶端應用程序中,視圖模型(類似 於 BookModel 類)將進行優化以便綁定到 XAML 並處理用戶交互。
雖然乍看起來這三種模型(Book、BookDTO 和 BookModel)之間有許多共性,但事實上它們 具有不同的職責,這意味著如果嘗試將所有這些職責都融入一種模型,則會創建一個繁瑣、臃 腫且不通用的模型。 通過按一系列職責拆分模型,我可以使工作變得更簡單,因為我可以按其 自身的用途優化每種模型。
從概念性的角度來看,需要為每個用途創建單獨模型還有其他原因。 對象是數據和行為的 組合,但當您嘗試通過網絡發送對象時,則只能發送數據。 這會引出一些有趣的問題。 您會 將應在後端服務器上運行的業務邏輯放在何處? 如果將此邏輯放在實體中,則在客戶端執行它 時會發生什麼情況?
這種體系結構的最終結果就是您使用的不是真正的對象。 您使用的數據對象是只保存有數 據的對象,而業務邏輯則以針對對象數據運行的過程的方式駐留在其他位置。 由於這會導致邏 輯和代碼的分散,更加難以長期維護,因此不贊成這樣做。 無論您如何看待此問題,您都需要 在應用程序的不同部分中使用不同的模型,除非後端系統是簡單的數據存儲庫。 這自然又會引 出一個十分有趣的問題:您將如何處理更改?
針對更改集的命令
我允許用戶在 Alexandria 應用程序中執行的操作包括:將書籍添加到他們的隊列、在隊列 中對書籍進行重新排序以及從隊列中完全刪除書籍,如圖 1 中所示。 這些操作需要同時在前 端和後端反映出來。
圖 1 對用戶的書籍隊列可以執行的操作
我會嘗試通過以下方式實現這一點:序列化網絡上的實體,然後將修改後的實體發送回服務 器以保持持久性。 實際上,NHibernate 正好使用 session.Merge 方法明確支持此類方案。
不過,讓我們采用以下業務規則:當用戶將書籍從建議列表添加到其隊列時,即會從建議中 刪除該書籍並添加另一個建議。
設想一下,嘗試只使用上一個狀態和當前狀態(這兩個狀態之間的更改集)檢測將書籍從建 議列表移到隊列的操作。 雖然這在理論上是可行的,但實行起來非常困難。
我將此類體系結構稱為面向觸發器的編程。 與數據庫中的觸發器一樣,系統中基於更改集 的觸發器也是主要處理數據的代碼。 為了提供一些有意義的業務語義,您必須從更改集中提取 更改的含義,這需要靠智慧,還要有一點點運氣。
將包含邏輯的觸發器視為反模式是有理由的。 盡管適合某些操作(例如復制或純數據操作 ),但嘗試使用觸發器實現業務邏輯是一個很麻煩的過程,將導致系統難以維護。
在公開 CRUD 接口並且可通過 UpdateCustomer 等方法編寫業務邏輯的大多數系統中,都會 向您提供面向觸發器的編程作為默認選項(通常也是唯一選項)。 在未涉及重要業務邏輯(即 整個系統主要執行 CRUD)時,這種類型的體系結構會很有意義,但在多數應用程序中,它是不 適合的,因此不建議使用。
相反,顯式接口(例如,RemoveBookFromQueue 和 AddBookToQueue)能夠生成更易於理解 和思考的系統。 由於能夠在這種高級別交換信息,因此可以獲得極高的自由度,並且在以後可 以輕松修改。 畢竟,您無需確定系統中的某一功能在什麼位置基於由該功能處理的哪些數據。 系統將根據其體系結構准確地描述出這一情況所發生的位置。
Alexandria 中的實施遵循顯式接口原理;調用駐留在應用程序模型中的那些操作,如圖 2 中所示。 在此我將做幾件有趣的事情,下面讓我們按順序處理其中的每一件。
圖 2 在前端將書籍添加到用戶隊列
public void AddToQueue(BookModel book) {
Recommendations.Remove(book);
if (Queue.Any(x => x.Id == book.Id) == false)
Queue.Add(book);
bus.Send(
new AddBookToQueue {
UserId = userId, BookId = book.Id
},
new MyQueueQuery {
UserId = userId
},
new MyRecommendationsQuery {
UserId = userId
});
}
首先,我將直接修改應用程序模型以便立即反映出用戶的需求。 我之所以能夠這樣做是因 為,向用戶隊列添加書籍的操作肯定不會失敗。 此外,我還將從建議列表中刪除該書籍,因為 讓用戶隊列中的項同時出現在建議列表中沒有意義。
接下來,我向後端服務器發送一個消息批次,指示該服務器將書籍添加到用戶隊列,同時讓 我知道在執行此更改後用戶隊列和建議的狀況。 這是需要理解的重要概念。
如果能夠以此方式編寫命令和查詢,就意味著您無需在類似 AddBookToQueue 的命令中采用 特殊步驟來獲取用戶的已更改數據。 前端可以在同一消息批次中請求此數據,您只需使用現有 功能即可獲取此數據。
盡管我在內存中進行修改,但從後端服務器請求數據有兩個原因。 第一個原因是,後端服 務器可能會執行其他邏輯(例如查找此用戶的新建議),這會產生您在前端了解不到的修改。 另一個原因是,來自後端服務器的回復將使用當前狀態更新緩存。
斷開連接本地狀態管理
您可能會在圖 2 中發現一個與斷開連接的工作有關的問題。 我在內存中進行了修改,但在 從服務器收到回復前,緩存的數據不會反映這些更改。 如果在仍斷開連接期間重新啟動應用程 序,則應用程序將顯示已過期的信息。 在與後端服務器恢復通信後,消息將流到後端,而最終 狀態將解析為用戶預期的狀態。 但直到這時,應用程序才顯示有關用戶已在本地進行更改的信 息。
對於預計斷開連接時間較長的應用程序,請不要依賴消息緩存;而應實現一個在每次用戶操 作後立即保存的模型。
對於 Alexandria 應用程序,我擴展了緩存約定,使包含在命令和查詢消息批次(如圖 2 中所示)的所有信息立即過期。 這樣,我就不會擁有最新信息,而且在從後端服務器收到回復 之前重新啟動應用程序時,不會顯示錯誤信息。 就 Alexandria 應用程序而言,這就足夠了。
後端處理
既然您已了解該過程在前端的工作方式,下面讓我們從後端服務器的角度來看一下代碼。 您已經熟悉了我在上一篇文章中介紹過的查詢處理。 圖 3 顯示了用於處理命令的代碼。
圖 3 向用戶隊列添加書籍
public class AddBookToQueueConsumer :
ConsumerOf<AddBookToQueue> {
private readonly ISession session;
public AddBookToQueueConsumer(ISession session) {
this.session = session;
}
public void Consume(AddBookToQueue message) {
var user = session.Get<User>(message.UserId);
var book = session.Get<Book>(message.BookId);
Console.WriteLine("Adding {0} to {1}'s queue",
book.Name, user.Name);
user.AddToQueue(book);
}
}
實際的代碼極為繁瑣。 我加載了相關實體,然後對實體調用了一個方法來執行實際的任務 。 不過,它的重要性會超出您的想象。 我認為架構師的職責就是讓項目開發人員盡可能地處 理那些枯燥乏味的事情。 大多數業務問題都是繁瑣的,通過消除系統中的技術復雜性,您可以 讓開發人員將更多的時間花在繁瑣的業務問題上,而不是相關的技術問題上。
這在 Alexandria 上下文中意味著什麼? 我已經盡量將大部分業務邏輯集中到實體中,而 不是將業務邏輯分散在所有消息使用者中。 理論上,按以下模式使用消息:
加載處理消息所需的所有數據
對域實體調用單個方法以執行實際操作
此過程可確保域邏輯保留在域中。 至於該邏輯是什麼,將取決於您需要處理的方案。 通過 下面的代碼,您應該能夠了解我在使用 User.AddToQueue(book) 時是如何處理域邏輯的:
public virtual void AddToQueue(Book book) {
if (Queue.Contains(book) == false)
Queue.Add(book);
Recommendations.Remove(book);
// Any other business logic related to
// adding a book to the queue
}
您已經看到了一個前端邏輯和後端邏輯完全一致的示例。 現在,讓我們看一個兩者之間存 在差別的示例。 在前端從隊列中刪除書籍是十分簡單的(請參見圖 4)。 這個過程相當簡單 。 您在本地從隊列中刪除書籍(即從 UI 刪除),然後向後端發送一個消息批次,請求從隊列 刪除書籍,然後更新隊列和建議。
圖 4 從列隊中刪除書籍
public void RemoveFromQueue(BookModel book) {
Queue.Remove(book);
bus.Send(
new RemoveBookFromQueue {
UserId = userId,
BookId = book.Id
},
new MyQueueQuery {
UserId = userId
},
new MyRecommendationsQuery {
UserId = userId
});
}
在後端,按照圖 3 中所示的模式使用 RemoveBookFromQueue 消息,加載實體,然後調用 user.RemoveFromQueue(book) 方法:
public virtual void RemoveFromQueue(Book book) {
Queue.Remove(book);
// If it was on the queue, it probably means that the user
// might want to read it again, so let us recommend it
Recommendations.Add(book);
// Business logic related to removing book from queue
}
該行為在前端和後端之間有所不同。 在後端,我將刪除的書籍添加到建議中,而在前端並 沒有這樣做。 這種差異導致的結果是什麼?
當然,直接響應就是從隊列中刪除書籍,而當來自後端服務器的回復到達前端時,您就會看 到書籍已添加到建議列表中。 實際上,只有在您從隊列中刪除書籍而後端服務器處於關閉狀態 時,您才可能會注意到這一差異。
這不會出什麼錯誤,但當您實際需要從後端服務器進行確認以完成操作時會發生什麼情況?
復雜操作
當用戶希望在其隊列中添加、刪除或重新排序項時,很明顯這些操作絕不會失敗,因此您可 以允許應用程序立即接受該操作。 但對於編輯地址或更改信用卡之類的操作,除非從後端確認 成功,否則不能簡單地接受這些操作。
在 Alexandria 中,這分為四個階段實施。 這聽起來有點嚇人,但實際上十分簡單。 圖 5 顯示了可能的階段。
圖 5 需要確認的命令的四個可能階段
左上方的屏幕快照顯示了訂閱詳細信息的普通視圖。 這是 Alexandria 顯示已確認更改的 方式。 左下方的屏幕快照顯示了同一數據的編輯屏幕。 單擊此屏幕上的“保存”按鈕會出現 右上方所示的屏幕快照;這是 Alexandria 顯示未確認更改的方式。
換句話說,我將暫時接受更改,直到從服務器收到回復,指出更改已接受(從而重新回到左 上方屏幕)還是已拒絕(從而將該過程移動到右下方屏幕快照)。 該屏幕快照顯示從服務器收 到一個錯誤,並允許用戶糾正錯誤詳細信息。
可能與您想象的相反,該實施並不復雜。 我將在後端開始,然後向外移動。 圖 6 顯示了 處理此過程所需的後端代碼,這並不是什麼新內容。 在本文中我一直在做幾乎同樣的事情。 大多數條件命令功能(和復雜性)都存在於前端。
圖 6 更改用戶地址的後端處理
public void Consume(UpdateAddress message) {
int result;
// pretend we call some address validation service
if (int.TryParse(message.Details.HouseNumber, out result) ==
false || result % 2 == 0) {
bus.Reply(new UpdateDetailsResult {
Success = false,
ErrorMessage = "House number must be odd number",
UserId = message.UserId
});
}
else {
var user = session.Get<User>(message.UserId);
user.ChangeAddress(
message.Details.Street,
message.Details.HouseNumber,
message.Details.City,
message.Details.Country,
message.Details.ZipCode);
bus.Reply(new UpdateDetailsResult {
Success = true,
UserId = message.UserId
});
}
}
有一點與您以前看到的有所不同,此處我為該操作編寫了明確的成功/失敗代碼,而先前我 只是在單獨的查詢中請求刷新數據。 該操作可能 會失敗,因此我不僅需要了解該操作成功與 否,而且還需要了解它為何 失敗。
Alexandria 使用 Caliburn 框架來處理管理 UI 的多數繁瑣工作。 Caliburn (caliburn.codeplex.com) 是在很大程度上依賴約定的 WPF/Silverlight 框架,從而可以很容 易地通過應用程序模型構建多種應用程序功能,而不用在 XAML 代碼隱藏文件中編寫代碼。
正如您從示例代碼中看到的那樣,Alexandria UI 中的幾乎所有內容都是使用約定通過 XAML 聯系在一起的,這使您能夠簡單明了地理解 XAML 以及直接反映該 UI 的應用程序模型, 無需與它具有直接相關性。 這會生成簡單得多的代碼。
在圖 7 中,應該能夠了解如何通過 SubscriptionDetails 視圖模型實現這一點。 實際上 ,SubscriptionDetails 包含數據的兩個副本;一個副本保存在 Editable 屬性中,與編輯或 顯示未確認更改相關的所有視圖均會顯示該屬性。 另一個副本保存在 Details 屬性中,該屬 性用於保存未確認的更改。 每個模式都具有不同的視圖,並且每個模式將選擇要通過哪個屬性 顯示數據。
圖 7 在視圖模式之間切換以響應用戶輸入
public void BeginEdit() {
ViewMode = ViewMode.Editing;
Editable.Name = Details.Name;
Editable.Street = Details.Street;
Editable.HouseNumber = Details.HouseNumber;
Editable.City = Details.City;
Editable.ZipCode = Details.ZipCode;
Editable.Country = Details.Country;
// This field is explicitly ommitted
// Editable.CreditCard = Details.CreditCard;
ErrorMessage = null;
}
public void CancelEdit() {
ViewMode = ViewMode.Confirmed;
Editable = new ContactInfo();
ErrorMessage = null;
}
在 XAML 中,我使用 ViewMode 綁定為每個模式選擇適當的視圖。 也就是說,將模式切換 到編輯模式時,將會選擇 Views.SubscriptionDetails.Editing.xaml 視圖來顯示對象的編輯 屏幕。
不過,這就是您最感興趣的保存和確認過程。 下面是處理保存操作的過程:
public void Save() {
ViewMode = ViewMode.ChangesPending;
// Add logic to handle credit card changes
bus.Send(new UpdateAddress {
UserId = userId,
Details = new AddressDTO {
Street = Editable.Street,
HouseNumber = Editable.HouseNumber,
City = Editable.City,
ZipCode = Editable.ZipCode,
Country = Editable.Country,
}
});
}
在此我實際上只需做一件事,就是發送一條消息並將視圖切換到不可編輯視圖,後者帶有一 個指示尚未接受那些更改的標記。 圖 8 顯示了用於確認或拒絕的代碼。 總而言之,這就是用 極少量的代碼來實現此類功能,從而為將來實現類似功能奠定了基礎。
圖 8 繼續回復並處理結果
public class UpdateAddressResultConsumer :
ConsumerOf<UpdateAddressResult> {
private readonly ApplicationModel applicationModel;
public UpdateAddressResultConsumer(
ApplicationModel applicationModel) {
this.applicationModel = applicationModel;
}
public void Consume(UpdateAddressResult message) {
if(message.Success) {
applicationModel.SubscriptionDetails.CompleteEdit();
}
else {
applicationModel.SubscriptionDetails.ErrorEdit(
message.ErrorMessage);
}
}
}
//from SubscriptionDetails
public void CompleteEdit() {
Details = Editable;
Editable = new ContactInfo();
ErrorMessage = null;
ViewMode = ViewMode.Confirmed;
}
public void ErrorEdit(string theErrorMessage) {
ViewMode = ViewMode.Error;
ErrorMessage = theErrorMessage;
}
此外,您還需要考慮經典的請求/響應調用,例如搜索目錄等。 由於此類調用中的通信通過 單向消息來完成,因此需要更改 UI,指示等到後端服務器的響應到達之後再進行後台處理。 我將不再詳細重溫這一過程,但在示例應用程序中包含了用於執行該過程的代碼。
簽出
在本項目開始時,我首先指出了構建此類應用程序的目標以及預期面臨的難題。 我要解決 的主要難題有數據同步,有關分布式計算的謬論以及對偶爾連接的客戶端的處理。 回顧一下, 我想 Alexandria 在滿足我的目標並克服這些難題方面做得非常出色。
前端應用程序基於 WPF,並大量使用 Caliburn 約定來減少應用程序模型的實際代碼。 該 應用程序模型將綁定到 XAML 視圖以及一小部分調用該模型的前端消息使用者。
我介紹了如何處理單向消息傳送,如何在基礎結構層緩存消息,並且介紹了針對甚至需要後 端批准才能真正被視為完成的操作,如何允許執行斷開連接的工作。
在後端,我構建了基於消息的應用程序,該應用程序基於 Rhino 服務總線和 NHibernate。 我討論了如何管理會話和事務生存期,以及如何通過消息批次利用 NHibernate 一級緩存。 後 端的消息使用者將用作簡單查詢或用作域對象上適當方法的代理程序,大多數業務邏輯實際上 都位於此處。
強制使用顯式命令而不是簡單的 CRUD 接口可生成更清晰的代碼。 這使您可以輕松地更改 代碼,因為整個體系結構都將重點放在清楚地定義應用程序的每一部分的角色以及如何構建該 角色上。 最終成果是結構化非常合理並具有一系列清晰職責的產品。
很難在幾篇簡短的文章中羅列出針對完備的分布式應用程序體系結構的指南,尤其是在嘗試 同時引入多個新概念時更是如此。 我始終認為,與更為傳統的基於 RPC 或 CRUD 的體系結構 相比,運用本文概括的做法將會生成更易於處理的應用程序。