领域驱动设计实战案例(二):实现产品上下文领域层、仓储与应用服务层、接口与测试
DDD实战进阶第一波:开发一般业务的大健康行业直销系统
一、实现产品上下文领域层
从这篇文章开始,我们根据前面的DDD理论与DDD框架的约束,正式进入直销系统案例的开发。
先简单讲下业务方面的需求:产品SPU与产品SKU,产品SPU主要是产品的名字和相关描述,产品SKU包括产品SPU的多个规格,每个规格有不同的价格与PV值。从我们对DDD概念的理解,产品SPU与产品SKU属于同一个聚合,产品SPU是聚合根。
产品上下文主要实现产品的上架功能,为了实现上架功能,我们首先要实现产品上下文的领域POCO模型与领域逻辑,我们将产品的POCO模型与领域逻辑建立到一个叫Product.Domain的项目中。
产品SPU领域对象POCO代码:
public partial class ProductSPU : IAggregationRoot
{ [Key] public Guid Id { get; set; } public string Code { get; set; } public string ProductSPUName { get; set; } public string ProductSPUDes { get; set; } public List<ProductSKU> ProductSKUS { get; set; } }
产品SKU领域对象POCO代码:
public partial class ProductSKU : IEntity
{ public ProductSKU() { } [Key] public Guid Id { get; set; } public string Code { get; set; } public string Spec { get; set; } public Unit Unit { get; set; } public decimal PV { get; set; } public decimal DealerPrice { get; set; } public byte[] Image { get; set; } public Guid ProductSPUId { get; set; } public string ProductSPUName { get; set; } }
从上面代码可以看到,ProductSPU从聚合根接口继承,ProductSKU从实体接口继承,ProductSPU包含了一个ProductSKU的集合(也就是引用),这就代表它们同属一个聚合,在具体使用EF Core做持久化时,会作为一个事务统一持久化。
领域对象除了包含自身的属性,也应该包括自身的业务逻辑,产品上架的功能比较简单,业务逻辑也比较简单,主要就是如何生成整个领域对象,以及聚合根与实体业务标识符Code的生成规则。
产品SPU领域对象业务逻辑代码:
public partial class ProductSPU
{ public ProductSPU CreateProductSPU(Guid id,string spuname,string spudesc,List<ProductSKU> productskus) { this.Id = id; this.Code = "Code " + spuname; this.ProductSPUName = spuname; this.ProductSKUS = productskus; this.ProductSPUDes = spudesc; return this; } }
产品SKU领域对象业务逻辑代码:
public partial class ProductSKU
{ public ProductSKU CreateProductSKU(string productspuname,Guid productspuid, byte[] image,decimal dealerprice,decimal pv,string unit,string spec) { this.Id = Guid.NewGuid(); this.ProductSPUId = productspuid; this.Code = "Code " + productspuname + spec; this.ProductSPUName = productspuname; this.Image = image; this.DealerPrice = dealerprice; this.PV = pv; switch (unit) { case "盒": this.Unit = Unit.盒; break; case "包": this.Unit = Unit.包; break; case "瓶": this.Unit = Unit.瓶; break; } this.Spec = spec; return this; } }
我将领域对象的属性与领域对象的逻辑分到不同的cs文件中,便于不同职责人开发与管理,而且采用相同的名称空间和Partial关键字。
Product.Domain除了要实现领域逻辑之外,还要定义ProductSPU的仓储接口、通过EF Core定义产品上下文与数据库上下文之间的映射关系。
仓储接口定义:
public interface IProductRepository
{ void CreateProduct<T>(T productspu) where T : class, IAggregationRoot; }
从上面可以看到,这个接口其实就是定义了将ProductSPU这个聚合根持久化到数据库与的接口。
产品上下文与数据库上下文映射关系:
1.因为映射关系使用EF Core实现,未来可能被替换掉,所以先定义一个产品上下文接口:
public interface IProductContext
{ }
2.EF Core映射实现
public class ProductEFCoreContext:DbContext,IProductContext
{ public DbSet<ProductSPU> ProductSPU { get; set; } public DbSet<ProductSKU> ProductSKU { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionBuilder) { optionBuilder.UseSqlServer("数据库连接字符串"); } }
3.使用EF Core工具生成数据库脚本并更新数据库,在生成脚本时,需要编辑项目文件,并采用EF Core Tools命令生成,这里就不细讲EF Core技术方面的内容。
到这里,我们就基本实现了产品上下文的领域层,可以看到领域层主要是领域逻辑,定义了一个仓储接口,将数据库技术解耦,当然要定义领域对象与数据库之间的映射关系,否则用例无法完成真正对领域对象的持久化。
二、实现产品上下文仓储与应用服务层
前面我们完成了产品上下文的领域层,已经有了关于产品方面的简单领域逻辑,我们接着来实现产品上下文关于仓储持久化与应用层的用例如何来协调领域逻辑与仓储持久化。
首先大家需要明确的是,产品上下文的领域逻辑是系统的核心,它不应该依赖仓储,而仓储应该要依赖领域层,这样仓储才可以把领域逻辑执行完后,才可能将领域对象持久化到数据库中,这一点与传统的架构有本质的区别。
一般我们会在解决方案中建立一个项目,这个项目就是包含了所有聚合的仓储实现,具体不同上下文的仓储实现,可以在这个项目下建立不同的文件夹。
1.产品上下文仓储实现:
public class ProductEFCoreRepository : IProductRepository
{ private readonly DbContext context; public ProductEFCoreRepository(DbContext context) { this.context = context; } public void CreateProduct<T>(T productspu) where T:class,IAggregationRoot { var productdbcontext = this.context as ProductEFCoreContext; var productspunew = productspu as ProductSPU; try { productdbcontext.ProductSPU.Add(productspunew); } catch(Exception error) { throw error; } } }
上面的代码有几个要注意的方面:
a.首先会从产品的仓储接口做继承,通过EF Core的机制,实现了仓储接口的CreateProduct方法。
b.使用了产品上下文的EF Core数据访问上下文ProductEFCoreContext完成了Productspu的数据库预添加。
c.上一个说法中,可能大家有两个疑惑,一是为什么不使用productdbcontext标记ProductSPU为Added状态,而是使用.Add方法,二是为什么只是完成了添加状态,而不再后续调用Commit或SaveChange方法真正持久化到数据库中?首先,因为未来持久化要将这个聚合中的ProductSPU聚合根与ProductSKU实体作为一个整体持久化到数据库中,而Added状态只能将当前聚合根作为添加状态,而不能同时将引用的ProductSKU对象作为添加状态,所以不能使用Added状态而使用.Add方法;其次仓储实现聚合提交时,只进行数据库预添,是因为协调领域逻辑与仓储的应用服务层用例可能涉及到多个聚合,所以可能要同时调用多个领域对象的业务逻辑,多个仓储,完成后,将多聚合作为一个整体事务做提交,所以真正的提交应该放到应用服务层更合适,而不是仓储层。
2.产品上架应用服务层实现:
应用服务层实际就是完成用例,通过应用服务层调用领域逻辑,然后通过应用服务层调用仓储,最后应用服务层做真正的提交,这样就把职责分的非常清楚,也在领域逻辑不依赖仓储的前提下,完成了整个用例和持久化。
a.首先我们在产品上下文的应用服务层项目中,建立需要添加的产品SPU与对应产品SKU的DTO对象
public class AddProductSPUDTO
{ public string SPUName { get; set; } public string SPUDesc { get; set; } public List<string> SKUSpecs { get; set; } public List<string> SKUUnits { get; set; } public List<decimal> SKUDealerPrices { get; set; } public List<byte[]> SKUImages { get; set; } public List<decimal> SKUPvs { get; set; } }
b.建立一个上架产品的用例服务,协调领域逻辑与仓储完成用例
public class AddProductSPUUseCase:BaseAppSrv
{ private readonly IRepository irepositorycontext; private readonly IProductRepository iproductrepository; public AddProductSPUUseCase(IRepository irepositorycontext,IProductRepository iproductrepository) { this.irepositorycontext = irepositorycontext; this.iproductrepository = iproductrepository; } public ResultEntity<bool> AddProduct(AddProductSPUDTO addproductspudto) { var productspuid = Guid.NewGuid(); var productskus = new List<ProductSKU>(); for(int i = 0; i < addproductspudto.SKUSpecs.Count; i++) { var productsku = new ProductSKU().CreateProductSKU(addproductspudto.SPUName, productspuid, addproductspudto.SKUImages[i], addproductspudto.SKUDealerPrices[i], addproductspudto.SKUPvs[i], addproductspudto.SKUUnits[i], addproductspudto.SKUSpecs[i]); productskus.Add(productsku); } var productspu = new ProductSPU().CreateProductSPU(productspuid, addproductspudto.SPUName, addproductspudto.SPUDesc, productskus); try { using (irepositorycontext) { iproductrepository.CreateProduct(productspu); irepositorycontext.Commit(); } return GetResultEntity(true); } catch(Exception error) { throw error; } } }
BaseAppSrv是你要定义的一个类,它的GetResultEntity方法功能是完成用例后后,返回接口层的数据格式,这个数据格式会进一步通过接口层返回给前端,返回的数据格式就是ResultEntity<T>,这两个部分大家可以自己去实现,也可以参考我的微信公众号中的课程。
三、实现产品上下文接口与测试
我们介绍了如何将创建产品的领域逻辑与产品的持久化仓储通过上架产品的用例组织起来,完成了一个功能。在实际的项目中,多种前端的形态比如PC Web、微信小程序、原生APP等要调用后端的功能,通常要将后端的功能包装成RESTFUL风格,这样前端就可以使用Http Get或Post方式调用后端的功能,现在我们先来完成后端的Asp.net Core WebApi,通过WebApi将上架产品的功能暴露出去。
实现上下产品接口:
[Produces("application/json")]
[Route("api/Product")] public class ProductController : Controller { ServiceLocator servicelocator = new ServiceLocator(); [HttpPost] [Route("AddProduct")] public ResultEntity<bool> AddProduct([FromBody] AddProductSPUDTO addproductspudto) { var result = new ResultEntity<bool>(); var productdbcontext = servicelocator.GetService<IProductContext>(); var irepository = servicelocator.GetService<IRepository>(new ParameterOverrides { { "context", productdbcontext } }); var iproductrepository=servicelocator.GetService<IProductRepository>(new ParameterOverrides { { "context", productdbcontext } }); var addproductspuusecase = new AddProductSPUUseCase(irepository,iproductrepository); try { result = addproductspuusecase.AddProduct(addproductspudto); result.IsSuccess = true; result.Count = 1; result.Msg = "上架产品成功!"; } catch(Exception error) { result.ErrorCode = 100; result.Msg = error.Message; } return result; } }
1.首先大家看到接口层是非常薄的一层,它并不包含业务逻辑和数据访问,它只是初始化一些对象,然后完成应用服务的调用,返回前端所需要的格式的对象。
2.产品数据访问上下文、仓储接口、产品上下文仓储接口等需要通过依赖注入框架来获取特定的实现类,依赖注入框架可以采用Asp.net Core自带的,也可以采用Unity等框架。这里略去了依赖注入框架的具体实现,可以在公众号内查看。
3.如果在调用应用服务可能抛出异常时,需要详细指明每个catch与抛出的内容。
当后端接口完成后,作为后端开发人员,我们需要写单元测试来完成对后端接口的调用,并尝试得到期望的结果。我们在这里采用MSTest,你也可以使用XUnit。
上架产品单元测试:
HttpClient httpclient;
[TestMethod] public void AddProductTest() { httpclient = new HttpClient(); var addproductspudto = new AddProductSPUDTO(); addproductspudto.SPUName = "XXX石榴露"; addproductspudto.SPUDesc = "XXX精华石榴露,用于养生"; addproductspudto.SKUSpecs = new List<string>(); addproductspudto.SKUSpecs.Add("每瓶50毫升"); addproductspudto.SKUSpecs.Add("每瓶100毫升"); addproductspudto.SKUUnits = new List<string>(); addproductspudto.SKUUnits.Add("瓶"); addproductspudto.SKUUnits.Add("瓶"); addproductspudto.SKUPvs = new List<decimal>(); addproductspudto.SKUPvs.Add(120); addproductspudto.SKUPvs.Add(300); addproductspudto.SKUDealerPrices = new List<decimal>(); addproductspudto.SKUDealerPrices.Add(3000); addproductspudto.SKUDealerPrices.Add(4000); var fs = new FileStream(@"c:\test.jpg", FileMode.Open, FileAccess.Read); var imgbytes = new byte[fs.Length]; fs.Read(imgbytes, 0, Convert.ToInt32(fs.Length)); fs.Close(); addproductspudto.SKUImages = new List<byte[]>(); addproductspudto.SKUImages.Add(imgbytes); addproductspudto.SKUImages.Add(imgbytes); string json = JsonConvert.SerializeObject(addproductspudto); HttpContent httpcontent = new StringContent(json); httpcontent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); var response = httpclient.PostAsync("http://localhost:2209/api/Product/AddProduct", httpcontent).Result; var responsevalue = response.Content.ReadAsStringAsync().Result; var responsemsg = JsonConvert.DeserializeObject<ResultEntity<bool>>(responsevalue).Msg; Assert.AreEqual("上架产品成功!", responsemsg); }
[TestMethod]
public void AddProductTest() { httpclient = new HttpClient(); var addproductspudto = new AddProductSPUDTO(); addproductspudto.SPUName = "XXX面膜"; addproductspudto.SPUDesc = "XXX面膜,用于护肤"; addproductspudto.SKUSpecs = new List<string>(); addproductspudto.SKUSpecs.Add("每盒5张"); addproductspudto.SKUSpecs.Add("每盒10张"); addproductspudto.SKUUnits = new List<string>(); addproductspudto.SKUUnits.Add("盒"); addproductspudto.SKUUnits.Add("盒"); addproductspudto.SKUPvs = new List<decimal>(); addproductspudto.SKUPvs.Add(200); addproductspudto.SKUPvs.Add(350); addproductspudto.SKUDealerPrices = new List<decimal>(); addproductspudto.SKUDealerPrices.Add(5000); addproductspudto.SKUDealerPrices.Add(8000); var fs = new FileStream(@"c:\test1.jpg", FileMode.Open, FileAccess.Read); var imgbytes = new byte[fs.Length]; fs.Read(imgbytes, 0, Convert.ToInt32(fs.Length)); fs.Close(); addproductspudto.SKUImages = new List<byte[]>(); addproductspudto.SKUImages.Add(imgbytes); addproductspudto.SKUImages.Add(imgbytes); string json = JsonConvert.SerializeObject(addproductspudto); HttpContent httpcontent = new StringContent(json); httpcontent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); var response = httpclient.PostAsync("http://localhost:2209/api/Product/AddProduct", httpcontent).Result; var responsevalue = response.Content.ReadAsStringAsync().Result; var responsemsg = JsonConvert.DeserializeObject<ResultEntity<bool>>(responsevalue).Msg; Assert.AreEqual("上架产品成功!", responsemsg); }
有了单元测试,我们后端开发人员就可以验证是否后端接口与整个用例是否是正常的,另外单元测试也可以作为每日自动构建的一部分。