DDD实践案例:引入事件驱动与中间件机制来实现后台管理功能
一、引言
在当前的电子商务平台中,用户下完订单之后,然后店家会在后台看到客户下的订单,然后店家可以对客户的订单进行发货操作。此时客户会在自己的订单状态看到店家已经发货。从上面的业务逻辑可以看出,当用户下完订单之后,店家或管理员可以对客户订单进行跟踪和操作。上一专题我们已经实现创建订单的功能,则接下来自然就是后台管理功能的实现了。所以在这一专题中将详细介绍如何在网上书店案例中实现后台管理功能。
二、后台管理中的权限管理的实现
后台管理中,首先需要实现的自然就是权限管理了,因为要进行商品管理等操作的话,则必须对不同的用户指定的不同角色,然后为不同角色指定不同的权限。这样才能确保普通用户不能进行一些后台操作。
然而角色和权限的赋予一般都是由系统管理员来操作。所以在最开始创建一个管理员用户,之后就可以以管理员的账号进行登录来进行后台操作的管理,包括添加角色,为用户分配角色、添加用户等操作。
这里就牵涉到一个权限管理的问题了。系统如何针对不同用户的全新进行管理呢?
其权限管理一个实现思路其实如下:
- 不同角色可以看到不同的链接,只有指定权限的用户才可以看到与其对应权限的操作。如只有管理员才可以添加用户和为用户赋予权限,而卖家只能对消费者订单的处理和对自己商店添加商品等操作。
从上面的描述可以发现,权限管理的实现主要包括两部分:
- 为不同用户指定不同的链接显示。如管理员可以看到后台管理的所有链接:包括角色管理,商品管理,用户管理、订单管理,商品分类管理,而卖家只能看到订单管理,商品管理和商品类别管理等。其实现就是为这些链接的生成指定不同的权限,只有达到权限用户才进行生成该链接
- 既然要为不同用户指定不同的权限,则首先要获得用户的权限,然后根据用户的权限来动态生成对应的链接。
有了上面的思路,下面就让我们一起为网上书店案例加入权限管理的功能:
首先,我在Layout.cshtml页面加入指定权限的链接,具体的代码如下所示:
<table width="996" border="0" cellspacing="0" cellpadding="0" align="center"> <tr> <td height="607" valign="top"> <table width="996" border="0" cellspacing="0" cellpadding="0"> <tr> <td width="300" height="55" class="logo"></td> <td width="480" class="menu"> <ul class="sf-menu"> <li>@Html.ActionLink("首页", "Index", "Home")</li> @if (User.Identity.IsAuthenticated) { <li>@Html.ActionLink("我的", "Manage", "Account") <ul> <li>@Html.ActionLink("订单", "Orders", "Home")</li> <li>@Html.ActionLink("账户", "Manage", "Account")</li> <li>@Html.ActionLink("购物车", "ShoppingCart", "Home")</li> </ul> </li> } @if (User.Identity.IsAuthenticated) { <li>@Html.ActionLinkWithPermission("管理", "Administration", "Admin", PermissionKeys.Administrators | PermissionKeys.Buyers | PermissionKeys.SalesReps) <ul> <li>@Html.ActionLinkWithPermission("销售订单管理", "Orders", "Admin", PermissionKeys.Administrators | PermissionKeys.SalesReps)</li> <li>@Html.ActionLinkWithPermission("商品分类管理", "Categories", "Admin", PermissionKeys.Administrators | PermissionKeys.Buyers)</li> <li>@Html.ActionLinkWithPermission("商品信息管理", "Products", "Admin", PermissionKeys.Administrators | PermissionKeys.Buyers)</li> <li>@Html.ActionLinkWithPermission("用户账户管理", "UserAccounts", "Admin", PermissionKeys.Administrators)</li> <li>@Html.ActionLinkWithPermission("用户角色管理", "Roles", "Admin", PermissionKeys.Administrators)</li> </ul> </li> } <li>@Html.ActionLink("关于", "About", "Home") <ul> <li>@Html.ActionLink("Online Store 项目", "About", "Home")</li> <li>@Html.ActionLink("联系方式", "Contact", "Home")</li> </ul> </li> </ul> </td> <td width="216" class="menu"> @{Html.RenderAction("_LoginPartial", "Layout");} </td> </tr> </table> <table width="100%" border="0" cellspacing="0" cellpadding="0"> <tr> <td width="100%" height="10px" /> </tr> </table> <table width="996" border="0" cellspacing="0" cellpadding="0"> <tr> <td> <img src="/images/header.jpg" alt="" width="996" height="400" border="0"></td> </tr> </table> <table width="996" border="0" cellspacing="0" cellpadding="0"> <tr align="left" valign="top"> <td width="202" height="334"> @{Html.RenderAction("CategoriesPartial", "Layout");} </td> <td width="20"> </td> <td width="774"> <table width="774" border="0" cellspacing="0" cellpadding="0"> <tr> @(MvcSiteMap.Instance.Navigator()) </tr> <tr> <td> @RenderBody() </td> </tr> </table> </td> </tr> </table> <table width="996" border="0" cellspacing="0" cellpadding="0"> <tr> <td> <img src="/images/footer.jpg" alt="" width="996" height="5"></td> </tr> <tr> <td height="76"> <table width="996" border="0" cellspacing="0" cellpadding="0" align="center"> <tr> <td width="329" height="78" align="right"></td> <td width="14"> </td> <td width="653"><span class="style7">@Html.ActionLink("主页", "Index", "Home") | @Html.ActionLink("所有分类", "Category", "Home", null, null) | @Html.ActionLink("我的账户", "Account", "Account") | @Html.ActionLink("联系我们", "Contact", "Home") | @Html.ActionLink("关于本站", "About", "Home")</span><br> 版权所有 © 2014-2015, Online Store, 保留所有权利。 </td> </tr> </table> </td> </tr> </table> </td> </tr> </table>
上面红色加粗部分就是设置不同角色的不同权限。其中ActionLinkWithPermission是一个扩展方法,其具体实现就是获得登陆用户的角色,然后把用户的角色与当前的需要权限进行比较,如果相同,则通过HtmlHelper.GenerateLink方法来生成对应的链接。该方法的实现代码如下所示:
public static MvcHtmlString ActionLinkWithPermission(this HtmlHelper helper, string linkText, string action, string controller, PermissionKeys required) { if (helper == null || helper.ViewContext == null || helper.ViewContext.RequestContext == null || helper.ViewContext.RequestContext.HttpContext == null || helper.ViewContext.RequestContext.HttpContext.User == null || helper.ViewContext.RequestContext.HttpContext.User.Identity == null) return MvcHtmlString.Empty; using (var proxy = new UserServiceClient()) { var role = proxy.GetRoleByUserName(helper.ViewContext.RequestContext.HttpContext.User.Identity.Name); if (role == null) return MvcHtmlString.Empty; var keyName = role.Name; var permissionKey = (PermissionKeys)Enum.Parse(typeof(PermissionKeys), keyName); // 通过用户的角色和对应对应的权限进行与操作 // 与结果等于用户角色时,表示用户角色与所需要的权限一样,则创建对应权限的链接 return (permissionKey & required) == permissionKey ? MvcHtmlString.Create(HtmlHelper.GenerateLink(helper.ViewContext.RequestContext, helper.RouteCollection, linkText, null, action, controller, null, null)) : MvcHtmlString.Empty; } }
通过上面的代码,我们就已经完成了权限管理的实现了。
三、后台管理中商品管理的实现
如果你是管理员的话,这样你就可以进入后台页面对商品、用户、订单等进行管理了。在上面我们已经完成了权限管理的实现。接下来,我们可以用一个管理员账号登陆之后,你可以看到管理员对应的权限。这里我直接在数据库中添加了一条管理员账号,其账号信息是admin,密码也是admin。下面我就这个账号后看到的界面如下图所示:
从上图可以看出,后台管理包括销售订单管理、商品类别管理、商品信息管理等。这些都是一些类似的实现,都是一些增、删、改功能的实现。这里就是商品信息管理为例来介绍下。点击商品信息管理后,将可以看到所有商品列表,在该页面可以进商品进行添加、修改和删除等操作。其实现主要是通过应用服务来调用仓储来实现商品的信息的持久化罢了,下面就具体介绍下商品添加功能的实现。因为商品的添加需要首先把上传的图片先添加到服务器上的Images文件夹下,然后通过控制器来调用ProductService的CreateProducts方法来把商品保存到数据库中。
首先是图片上传功能的实现,其实现代码如下所示:
[HandleError] public class AdminController : ControllerBase { #region Common Utility Actions // 保存图片到服务器指定目录下 [NonAction] private void SaveFile(HttpPostedFileBase postedFile, string filePath, string saveName) { string phyPath = Request.MapPath("~" + filePath); if (!Directory.Exists(phyPath)) { Directory.CreateDirectory(phyPath); } try { postedFile.SaveAs(phyPath + saveName); } catch (Exception e) { throw new ApplicationException(e.Message); } } // 图片上传功能的实现 [HttpPost] public ActionResult Upload(HttpPostedFileBase fileData, string folder) { var result = string.Empty; if (fileData != null) { string ext = Path.GetExtension(fileData.FileName); result = Guid.NewGuid()+ ext; SaveFile(fileData, Url.Content("~/Images/Products/"), result); } return Content(result); } }
图片上传成功之后,接下来点击保存按钮则把商品进行持久化到数据库中。其实现逻辑主要是调用商品仓储的实现类来完成商品的添加。主要的实现代码如下所示:
[HandleError] public class AdminController : ControllerBase { [HttpPost] [Authorize] public ActionResult AddProduct(ProductDto product) { using (var proxy = new ProductServiceClient()) { if (string.IsNullOrEmpty(product.ImageUrl)) { var fileName = Guid.NewGuid() + ".png"; System.IO.File.Copy(Server.MapPath("~/Images/Products/ProductImage.png"), Server.MapPath(string.Format("~/Images/Products/{0}", fileName))); product.ImageUrl = fileName; } var addedProducts = proxy.CreateProducts(new List<ProductDto> { product }.ToArray()); if (product.Category != null && product.Category.Id != Guid.Empty.ToString()) proxy.CategorizeProduct(new Guid(addedProducts[0].Id), new Guid(product.Category.Id)); return RedirectToSuccess("添加商品信息成功!", "Products", "Admin"); } } } // 商品服务的实现 public class ProductServiceImp : ApplicationService, IProductService { public List<ProductDto> CreateProducts(List<ProductDto> productsDtos) { return PerformCreateObjects<List<ProductDto>, ProductDto, Product>(productsDtos, _productRepository); } }
到此,我们已经完成了商品添加功能的实现,下面让我们看看商品添加的具体效果如何。添加商品页面:
点击保存更改按钮后,则进行商品的添加,添加成功后界面效果:
四、后台管理中发货操作和确认收货的实现
当消费者创建订单之后,然后卖家或管理员可以通过订单管理页面来对订单进行发货处理操作。以通知购买者该商品已发货了。在当前的电子商务网站中,除了更新订单的状态外,还会发邮件或短信通知购买者。为了保证这两个操作同时完成,此时需要将这两个放在同一个事务中进行提交。
这里为了使系统有更好地可扩展性,采用了基于消息队列和事件驱动的方式来完成发货操作。在看具体实现代码之前,我们先来分析下实现思路:
- 卖家或管理员在订单管理页面,点击发货按钮后,此时相当于订单的状态进行了更新,从已付款状态到已发货状态。这里当然你可以采用传统的方式来实现,即调用订单仓储来更新对应订单的状态。但是这样的实现方式,邮件发送操作可能会嵌套在应用服务层了。这样的设计显然不适合扩展。所以这里采用基于事件驱动和消息队列方式来改进这种方式。
- 首先,当商家点击发货操作,此时会产生一个发货事件;
- 接着由注册的领域事件处理程序进行对该领域事件处理,处理逻辑主要是更新订单的状态和更新时间;
- 然后再将该事件发布到EventBus,EventBus中保存了一个队列来存放事件,发布操作的实现就是往该队列插入一个待处理的事件;
- 最后在EventBus中的Commit方法中对队列中的事件进行出队列操作,通过事件聚合类来获得对应事件处理器来对出队列的事件进行处理。
- 事件聚合器通过Unity注入(应用)事件的处理器。在EventAggregator类中定义_eventHandlers来保存所有(应用)事件的处理器,在EventAggregator的构造函数中通过调用其Register方法把对应的事件处理器添加到_eventHandlers字典中。然后在EventBus中的Commit方法中通过找到EventAggregator中的Handle方法来触发事件处理器来处理对应事件,即发出邮件通知。这里事件聚合器起到映射的功能,映射应用事件到对应的事件处理器来处理。
通过上面的分析可以发现,发货操作和收货操作都涉及2类事件,一类是领域事件,另一类处于应用事件,领域事件的处理由领域事件处理器来处理,而应用事件的处理不能定义在领域层,所以我们这里新建了一个应用事件处理层,叫OnlineStore.Events.Handlers,已经新建了一个对EventBus支持的层,叫OnlineStore.Events。经过上面的分析,实现发货操作和收货操作是不是有点清晰了呢?如果不是的话也没关系,我们可以结合下面具体的实现代码再来理解下上面分析的思路。因为收货操作和发货操作的实现非常类似,这里只贴出发货操作实现的主要代码进行演示。
首先是AdminController中DispatchOrder操作的实现:
public ActionResult DispatchOrder(string id) { using (var proxy = new OrderServiceClient()) { proxy.Dispatch(new Guid(id)); return RedirectToSuccess(string.Format("订单 {0} 已成功发货!", id.ToUpper()), "Orders", "Admin"); } }
接下来便是OrderService中Dispatch方法的实现:
public void Dispatch(Guid orderId) { using (var transactionScope = new TransactionScope()) { var order = _orderRepository.GetByKey(orderId); order.Dispatch(); _orderRepository.Update(order); RepositorytContext.Commit(); _eventBus.Commit(); transactionScope.Complete(); } }
下面是Order实体类中Dispatch方法的实现:
/// <summary> /// 处理发货。 /// </summary> public void Dispatch() { // 处理领域事件 DomainEvent.Handle<OrderDispatchedEvent>(new OrderDispatchedEvent(this) { DispatchedDate = DateTime.Now, OrderId = this.Id, UserEmailAddress = this.User.Email }); }
接下来便是领域事件中Handle方法的实现了,其实现逻辑就是获得所有已注册的领域事件处理器,然后分别事件处理器进行调用。具体的实现代码如下所示:
public static void Handle<TDomainEvent>(TDomainEvent domainEvent) where TDomainEvent : class, IDomainEvent { // 找到对应的事件处理器来对事件进行处理 var handlers = ServiceLocator.Instance.ResolveAll<IDomainEventHandler<TDomainEvent>>(); foreach (var handler in handlers) { if (handler.GetType().IsDefined(typeof(HandlesAsynchronouslyAttribute), false)) Task.Factory.StartNew(() => handler.Handle(domainEvent)); else handler.Handle(domainEvent); } }
对应OrderDispatchedEventHandler类中Handle方法的实现如下所示:
// 发货事件处理器 public class OrderDispatchedEventHandler : IDomainEventHandler<OrderDispatchedEvent> { private readonly IEventBus _bus; public OrderDispatchedEventHandler(IEventBus bus) { _bus = bus; } public void Handle(OrderDispatchedEvent @event) { // 获得事件源对象 var order = @event.Source as Order; // 更新事件源对象的属性 if (order == null) return; order.DispatchedDate = @event.DispatchedDate; order.Status = OrderStatus.Dispatched; // 这里把领域事件认为是一种消息,推送到EventBus中进行进一步处理。 _bus.Publish<OrderDispatchedEvent>(@event); } }
从上面代码中可以发现,领域事件处理器中只是简单更新订单状态的状态为Dispatched和更新订单发货时间,之后就把该事件继续发布到EventBus中进一步进行处理。EventBus类的具体实现代码如下所示:
// 领域事件处理器只是对事件对象的状态进行更新 // 后续的事件处理操作交给EventBus进行处理 // 本案例中EventBus主要处理的任务就是发送邮件通知, // 在EventBus一般处理应用事件,而领域事件处理器一般处理领域事件 public class EventBus : DisposableObject, IEventBus { public EventBus(IEventAggregator aggregator) { this._aggregator = aggregator; // 获得EventAggregator中的Handle方法 _handleMethod = (from m in aggregator.GetType().GetMethods() let parameters = m.GetParameters() let methodName = m.Name where methodName == "Handle" && parameters != null && parameters.Length == 1 select m).First(); } public void Publish<TMessage>(TMessage message) where TMessage : class, IEvent { _messageQueue.Value.Enqueue(message); _committed.Value = false; } // 触发应用事件处理器对事件进行处理 public void Commit() { while (_messageQueue.Value.Count > 0) { var evnt = _messageQueue.Value.Dequeue(); var evntType = evnt.GetType(); var method = _handleMethod.MakeGenericMethod(evntType); // 调用应用事件处理器来对应用事件进行处理 method.Invoke(_aggregator, new object[] { evnt }); } _committed.Value = true; } }
其EventAggregator类的实现如下所示:
至于确认收货操作的实现也是类似,大家可以自行参考Github源码进行实现。到此,我们商品发货和确认收货的功能就实现完成了。此时,我们解决方案已经调整为:
经过本专题后,我们网上书店案例的业务功能都完成的差不多了,后面添加的一些功能都是附加功能,例如分布式缓存的支持、分布式消息队列的支持以及面向切面编程的支持等功能。既然业务功能都完成的差不多了,下面让我们具体看看发货操作的实现效果吧。
首先是销售订单管理首页,在这里可以看到所有用户的订单状态。具体效果如下图示所示:
点击上图的发货按钮后便可以完成商品发货操作,此时创建该订单的用户邮箱中会收到一份发货邮件通知,具体实现效果截图如下所示:
其确认收货操作实现的效果与发货操作的效果差不多,这里就不一一截图了,大家可以自行到github上下载源码进行运行查看。
五、总结
到这里,该专题的介绍的内容就结束。本专题主要介绍后台管理中权限管理的实现、商品管理、类别管理、角色管理、用户角色管理和订单管理等功能。正如上面所说的,到此,本网上书店的DDD案例一些业务功能都实现的差不多了,接下来需要完善的功能主要是一些附加功能,这些功能主要是为了提高网站的可扩展性和可伸缩性。这些主要包括缓存的支持、分布式消息队列的支持以及AOP的支持。在下一个专题将介绍分布式缓存和分布式消息队列的支持,请大家继续关注。
本专题的所有源码下载:https://github.com/lizhi5753186/OnlineStore_Second/