智能客户端

使用 NHibernate 和 Rhino 服务总线构建分布式应用程序 - 第 2 部分

Oren Eini

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 的体系结构相比,运用本文概括的做法将会生成更易于处理的应用程序。

Oren Eini*(笔名 Ayende Rahien)是多个开源项目(其中包括 NHibernate 和 Castle)的活跃成员,也是很多其他工具(包括 Rhino Mocks、NHibernate Query Analyzer 和 Rhino Commons)的开发者。Eini 还负责 NHibernate 的可视化调试程序 NHibernate Profiler (nhprof.com) 的开发。您可以访问他的博客 ayende.com/Blog。*