.Net Core应用框架Util介绍(六)
前面介绍了Util是如何封装以降低Angular应用的开发成本。
现在把关注点移到服务端,本文将介绍分层架构各构造块及基类,并对不同层次的开发人员应如何进行业务开发提供一些建议。
Util分层架构介绍
为了控制业务逻辑复杂性,Util引入了DDD分层架构,这意味着如果你想使用DDD,Util会为你提供一些基础设施帮助。
如果你对DDD不感兴趣,同样可以使用你所熟悉的类似三层架构等方式来编码,这并不会造成影响。
表现层构造块
由于目前的趋势是前后端分离,所以这里的表现层是指Api这一层,页面操作前面已经讨论过了。
Api控制器应该足够简单,将请求委托给应用服务,并把应用服务返回的结果发回客户端。
表现层的职责
- 收集数据
- 响应结果
控制器基类
- WebApiControllerBase
Api控制器顶级基类,所有派生控制器将具备异常处理,错误日志,跟踪日志等功能。
提供Success方法,以特定格式为客户端响应成功消息。
提供Fail方法,以特定格式为客户端响应失败消息。
将路由设置为标准约定:api/[controller]。
- QueryControllerBase
查询控制器基类,配合查询服务,用于解决常规查询需求,可通过Id进行单个对象查找,也可查找列表或分页列表结果集。
对于更复杂的查询需求,应直接从WebApiControllerBase派生,手工编码将会更加灵活。
- CrudControllerBase
Crud控制器基类,配合Crud服务,用于解决简单的单表增删改查需求。
对于有一定业务逻辑的操作,特别是多表操作,不建议使用该基类。
- TreeControllerBase
树型操作控制器基类,配合树型服务,用于解决常规树型操作需求。
对于不同的树型控件,可能具有一些差别,需要为该控件提供专用控制器,比如PrimeNg的树型表格使用PrimeTreeControllerBase。
树型控制器的抽象还需要增强,我会在接下来Ng-Zorro的封装中进一步完善这个基类。
过滤器
- 跟踪日志过滤器 – TraceLogAttribute
这个过滤器会在每个请求的进入和退出时记录跟踪日志,以方便你了解控制器是否被执行,以及相关参数。
WebApiControllerBase已经设置了该特性。
- 错误日志过滤器 – ErrorLogAttribute
这个过滤器会在发生异常时记录错误日志。
WebApiControllerBase已经设置了该特性。
- 异常处理过滤器 – ExceptionHandlerAttribute
这个过滤器会在发生异常时设置返回的响应为特定消息格式,以方便客户端进行处理。
WebApiControllerBase已经设置了该特性。
- 防止重复提交过滤器 – AntiDuplicateRequestAttribute
如果你希望用户提交表单时,不要在提交过程中重复发送请求,一般的做法是使用客户端脚本禁止他这样做,另一个选择是使用该过滤器,这提供了服务端的检测,当你的客户端脚本无法奏效时使用该过滤器。
使用该过滤器的一个限制是,你需要使用Identity或Identity Server登录,从而能够让Util知道当前用户的UserId,从而只对当前用户进行检测。
- Html生成过滤器 – HtmlAttribute
当你需要把Razor页面的Html保存到一个静态Html文件中时使用它。
它是Util Angular TagHelper封装的基础设施之一,这样可以保证在发布时可以通过AOT预编译的方式来打包Angular应用。
应用层构造块
应用层的职责
- 集成领域层和基础设施层,为表现层提供简单清晰的API
- 处理应用逻辑,比如事务控制,导出Excel等
应用服务接口及基类
- IService
应用服务顶级接口,这是一个空接口,它派生自IScopeDependency,这会导致所有应用服务接口和相关实现类的Ioc绑定关系被自动装配,并且生命周期为每个请求一个实例。
- ServiceBase
应用服务顶级基类,它提供了日志操作和当前用户会话的属性。
对于希望使用三层架构的同学,这个类就是你进行业务处理的主要场所。
针对某个应用场景,你用一个方法接收需要的参数,这个参数就是Dto。
你将在这个方法中处理所有业务逻辑。
这是一种面向过程的业务处理方法,称为事务脚本。
事务脚本的优势是不需要学习,是个人就能写。
对于普通水平的团队,事务脚本通常是最佳选择。
使用事务脚本也可以写出健壮的业务代码,这里的关键不是架构,而是重构。
如果你的方法很长,那么你可以提取多个子方法,并且命名不要太随意,代码中不要有复杂的嵌套循环和嵌套条件,这对于普通业务通常就足够了。
关于重构的心得,我会用一篇专文来分享。
- IQueryService
查询服务接口,用于解决常规查询需求,包括分页查询等。
- QueryServiceBase
查询服务基类,默认采用Ef Core进行查询。
你需要重写CreateQuery方法,以提供查询条件。我会在未来版本中提供高级查询,自动解析客户端设置的查询条件,以省掉这个步骤。
对于更加复杂的查询,你可以完全重写接口上定义的方法,使用SqlQuery来完成查询,这允许你使用Lambda和Sql字符串混合方式来编写查询,提供了更高的灵活性。
关于查询相关的主题,我会在下一篇介绍。
一旦你发现IQueryService定义的方法与你的查询需求不匹配时,请直接从IService和ServiceBase派生。
- ICrudService
Crud服务接口,它用来解决简单的单表增删改查需求。
- CrudServiceBase
Crud服务基类,它来自多年前的一个业务处理基类。
Crud就是增删改查,当我们面向数据库编程时,所有操作都是Crud。
但是当我们与客户交流时,通常使用业务上的概念和术语进行讨论。
那么,我们代码中的方法应该使用Add,Update这样的名称,还是业务术语,比如预订Booking。显而易见,业务名称会更容易理解。
使用ICrudService和CrudServiceBase会形成Crud风格的Api,对于一些简单场景没有什么问题,但对于复杂的场景会导致代码机械化和混乱。
提供这个类的初衷是为了配合代码生成器快速扫荡简单场景。
CrudServiceBase提供了大量虚方法供你重写,比如CreateBeforeAsync,当这些简单场景有一些额外逻辑时,可以重写这些方法提供自定义逻辑。
当有业务逻辑需要处理时,应该直接从IService和ServiceBase派生,手工编码,这通常会更快速和健壮,因为没有什么束缚着你。
如果你确实希望使用CrudServiceBase进行复杂的业务操作,也是有办法的,这需要一些技巧,不过很容易把人引入歧途,所以不打算在这里介绍,有需要可以在群里讨论。
- ITreeService
树型服务接口,用于解决常规树型操作需求。
- TreeServiceBase
树型服务基类。
应用服务的粒度
你可以在一个服务中包含大量的方法,比如一个模块的所有方法,也可以在一个服务中只包含一个方法,仅处理一个场景。
这里的技巧是根据方法复杂度来规划。
对于一般的场景,将一个聚合相关的所有操作放在一个服务中。
如果足够简单,你也可以把一个模块的所有操作放到一个服务中,这可以简化客户端操作,我曾经使用过这种方式,不过已经抛弃。
如果你的业务逻辑主要在应用服务中编写,当业务场景比较复杂时,可以考虑让这个服务仅包含一个操作,这样可以获得一定的清晰度。
应用服务开发注意事项
- 注意提交工作单元。
应用服务的一个核心任务是控制事务,当提交工作单元时,工作单元会发起一个数据库事务,并把所有变更写入数据库。
提交工作单元的方式有两种,一种方式是注入工作单元接口,并调用CommitAsync方法。另一种方式是使用UnitOfWorkAttribute特性。
使用UnitOfWorkAttribute特性方式提交工作单元,除了看上去更加高大上以外,主要一个好处是支持作用域,比如A服务调用了B服务的B1方法和C服务的C2方法,而B1和C2方法都提交了工作单元,那么如果希望A服务能够控制工作单元的提交,使用UnitOfWorkAttribute特性就可以做到,分别在每个方法上添加[UnitOfWork]特性,只有最外层的特性生效。
这是基于Ncc开源社区AspectCore这个AOP项目提供的作用域做到的。
ICrudService使用了UnitOfWorkAttribute特性,所以你可以通过组合多个Crud服务来开发更复杂的服务,但这并不是我推荐的方式,业务逻辑尽量手工编写,这样才能清晰可控。
对于上述两种工作单元提交方式,我推荐显式调用CommitAsync方法,原因是UnitOfWorkAttribute特性是在方法完成后提交,如果你要在提交之后进行某些操作,比如写一个成功日志,就会很麻烦。
ICommitAfter接口用于在使用UnitOfWorkAttribute特性提交之后进行处理,当然也可以使用发布订阅事件的方式进行处理。
应用服务讨论
- 你应该在Api控制器中调用多个应用服务吗?
在大多数情况下,不应该调用多个应用服务。
不过你有足够的理由,并且控制器中代码没有变得很复杂,也是可以的。
- 可以将控制器和应用服务合并吗?
经常听到一些讨论,控制器和应用服务都是很薄的一层,那么是否应该进行合并。
如果你使用应用服务来编写业务逻辑,那么不应该进行合并。
如果你的业务逻辑高度内聚到领域层,是否进行合并则根据自己的喜好。
我总是保持控制器和应用服务的分离,虽然这两个类都没多少代码,但职责更加清晰。
- 应用服务可以调用其它应用服务吗?
可以,但尽量不要这样做。
刚才已经说到应用服务的方法会提交工作单元,调用其它应用服务并不是这么方便。
调用其它应用服务是为了复用某些逻辑,你可以将业务逻辑抽取到领域服务中,将多个领域服务注入应用服务来复用逻辑。
注意:本文讨论的都是单体应用,分布式应用不在讨论范围。
Dto(Data Transfer Object )
Dto是一种参数对象,它接收来自外界的数据,并传递到应用服务以供处理,当应用服务处理完毕,也会创建用于响应的数据对象,将结果返回调用端。
高大上的术语容易让初学者胆寒,Dto就是其中之一。
我经常发现一些初学者搞不清楚Dto究竟是什么东西,一个参数对象而已,但使用它确实有一些技巧。
Dto的粒度
虽然只是一个简单的参数对象,但怎样定义参数也需要经验。
你可以把几个表,或者说几个实体的属性塞进一个Dto参数对象中,这样你在很多界面都可以复用它,这让你能够节省大量力气,但损失了API清晰度。
如果你的同事使用Swagger这样的工具来查看Api,看到参数对象上的几十上百个属性,会是什么表情,他应该设置哪些属性呢?
你可以为某个界面或接口创建专门的参数对象,他需要设置的参数属性将一目了然的呈现出来,这时候Dto实际上承载了ViewModel的职责,这是一个专用的参数对象。
如果你为每个界面或接口创建专用Dto,那么工作量也将翻N倍。
这里的关键在于,你需要在Api清晰度和工作量之间进行平衡。
一个简单的原则,如果这个参数对象是你自己使用,尽量粗粒度,省的是力气。如果给你同事使用,创建细粒度的专用Dto,以免挨骂。
Dto的方向
如果Dto参数对象只用于请求,那么我们可以约定参数名以Request结尾。
如果Dto参数对象只用于响应,那么可以约定参数名以Response结尾。
如果Dto参数对象既用于请求,又用来响应,那么可以约定参数名以Dto结尾。
这并不是什么标准,只是惯用的一些命名约定。
Dto基类
一个参数对象还需要什么基类?确实如此。
不过有时候需要进行一些泛型约束,这些接口约束主要在ICrudService中使用。
对于自定义服务,完全不需要它们。
- IRequest
代表请求参数。
继承自IValidation,这意味着请求参数可以调用Validate方法进行验证。
- RequestBase
请求参数基类,实现了Validate方法,这让你可以直接验证参数属性上的DataAnnotations,比如[Required]。
Util引入的一个大坑是该类添加了[DataContract]特性,数据契约是WCF年代引入的,我一直沿用至今。
Json.Net支持这个特性,意味着该类中的所有属性必须添加[DataMember]才能够序列化到Json中。
Util代码生成模板已经为Dto属性正确添加了[DataMember]特性,但如果手工在Dto中添加属性且没有添加[DataMember]特性,就会导致客户端无法接收该数据。
对于这个问题,我可能会在未来某个版本删除Dto基类上的[DataContract]特性。
- IResponse
代表响应参数。
- IDto
代表请求和响应参数。
- DtoBase
继承自RequestBase,并拥有一个Id属性。
Dto讨论
- 你真的需要Dto吗?
一些开发人员告诉我,Dto太麻烦,而且他们的实体只有属性,也就是个参数对象,没必要再搞个Dto转来转去。
这似乎很有道理,对于简单的应用,这可能是合适的。
但稍大一点的应用都包含前端,管理后端,App等客户端,将实体作为参数并不方便,很多时候需要添加额外属性,或根据界面定制参数,实体无法满足需求,这种情况下Dto就是必须的。
如果使用充血的领域模型,对象的循环引用非常常见,比如实体与策略对象互相持有引用,又或者聚合根与内部实体互相持有引用,订单Order与订单项OrderItem就是一个例子,在这种情况下,直接使用实体作为参数将导致Json序列化失败。
Dto在大多数情况下都是有价值的,应该作为项目标配。
- 你需要在Asp.Net Core Mvc项目中创建ViewModel对象吗?
一般情况下不需要,Dto已经充当了ViewModel的职责。
不过如果你在某些情况下有需求,也是可以的,以自己方便为主。
查询参数
你可能奇怪,怎么查询又弄了个专门的参数对象,使用Dto不行吗?
如果你专门为查询创建了定制的Dto,这当然可行,不过我们把名称再调整一下,会让这个参数对象的含义更加清晰。
Util对查询参数的命名约定是以Query结尾,其他开发人员看见就知道这是一个查询参数了。
与Dto相比,查询参数有一些自己的特点:
- 不具有复杂的嵌套结构。Dto可能很复杂,内部可能包含其它的Dto,但查询参数一定只包含简单属性,你需要什么查询条件就加上去。
- 另一方面,Util在查询Api上提供了一个叫WhereIfNotEmpty的方法,能够自动帮你判断参数值是否为空值,如果是空值,这个条件不会添加上去,这省去了你的一个判断。这就要求你的参数值必须可空,查询参数对象的一个关键要求是所有属性必须可空,否则你就不应该使用WhereIfNotEmpty方法。
领域层构造块
当你使用事务脚本这种面向过程的方法来进行业务开发时,你的关注点主要是数据库的表和字段,你会想办法向这些表和字段填充数据,至于业务逻辑,你会胡乱的拼凑以实现功能。
面向对象的开发方法,通过寻找业务关键概念和操作,并将它们映射到代码,这会让代码更加容易理解,看代码就是聊业务。
业务概念大多变成实体,实体的职责决定它拥有哪些属性和方法。
如果实体只包含属性,没有方法,那么只是一个参数对象,没多少用。
对于一个业务场景,通常由多个实体互相协作完成,这导致一个复杂的操作被多个对象分解,每一个部分都变得简单。
协作的实体构成了领域模型,实体成为业务逻辑开发的中心。
实体内部可能包含其它实体和值对象,外层实体称为聚合根,不论实体还是值对象,都是封装核心业务逻辑的场所。
使用领域模型的方式开发业务逻辑,具有易理解,易复用,易测试等特点,不过它的毛病也不小,就是学习成本高,上手困难,普通开发团队不打算下苦功夫看书,随便找几篇博客看看就轻易尝试的结果只不过是三层变种,发挥不出什么威力,不如直接三层来得简单,这也是DDD在一般团队无法落地的原因。
领域层的职责
- 所有业务逻辑都内聚到这里。
对于没打算下苦功夫的同学,忽略它,把你的业务逻辑直接写到应用服务中。
领域层接口和基类
- DomainBase
领域对象顶级基类。
Validate等相关方法提供了领域对象的验证机制。
GetChanges方法用于比较两个领域对象的属性变更情况,当需要了解某些属性发生变化进行处理时使用,目前需要配合代码生成器来检测属性变更。
ToString方法被重写,用于输出领域对象的状态信息,通常用于日志记录。
- EntityBase
实体基类。
实体是具有标识的对象,它用来跟踪某些事物的生命周期。
实体具有一个Id属性,Id就是标识,它是只读的,你只有在初始化构造函数时才能给它赋值,因为标识不能随意修改。
Util在实体上添加了一个Init方法,它用来初始化这个实体,默认情况下会生成Guid初始化Id属性,你也可以重写这个方法添加自定义初始化操作,这个Init方法应该在添加到仓储前调用,不要在修改时调用它。
如果你采用三层架构,那么不需要这个构造。
- AggregateRoot
聚合根基类。
最外层的实体就是聚合根,它内部还可以包含其它实体和值对象。
不过建议初学者不要随便将其它实体放到聚合根中,容易导致很多问题,比如由于内部实体没有仓储无法全局访问导致业务操作困难,或性能低下,或经常并发冲突导致业务失败,尽量保持聚合根小巧。
聚合根包含一个叫Version的属性,它非常重要,它是用来做并发更新异常处理的乐观离线锁。
当两个人同时编辑一条记录时,第一个人提交了修改,第二个人的提交将覆盖前面的修改,这将导致前面的数据丢失,这种情况称为并发更新异常。
Ef Core提供了乐观离线锁来防止这种情况发生,你只需要在表中加上Version字段,继承AggregateRoot基类,Util会正确处理Ef Core对不同数据库在乐观离线锁上的差异。
如果你采用三层架构,你的所有表都是聚合根,一个表对应一个聚合根。你也可以尝试把一些方法写在它里面,或者完全只有属性。
- ValueObjectBase
值对象基类。
值对象是没有标识的领域对象。
值对象关注的是属性特征,实体关注这个东西究竟是“谁”。
值对象将一些高度相关而分散的属性打包成一个概念整体,并将相关的业务逻辑封装起来。
值对象的一个最佳实践是不变性,不可变性意味着值对象所有属性的Set访问器都被Private,要修改它的唯一方法是重新new一个,整体替换它。这样做的好处是可以安全的在多个类或方法共享这个值对象。
不过在实践中,不可变的值对象在表现层等位置使用不便,需要为它添加类似Dto的辅助参数对象,这进一步增加了工作量,有时候我会使用可变对象以减轻对象定义和转换的工作。
如果你采用三层架构,不需要这个构造。
- TreeEntityBase
树型实体基类。
树型实体包含父标识ParentId和物化路径Path属性,用于操作树型结构。
InitPath方法用来初始化物化路径,物化路径是”父Id,Id”这样的格式。
GetParentIdsFromPath方法可以从物化路径中将所有父标识提取出来。
注意,虽然命名为Entity,它其实是一个聚合根,不应该在它内部再加入其它实体,因为树型已经很复杂了。
- IRepository
仓储接口。
仓储是聚合根的一个模拟集合。它用来对某个聚合根进行数据操作。
仓储的价值是可用来封装聚合根特定的数据操作,这要求你的仓储必须是具体化的,而不是一个抽象的泛型接口或泛型基类。
当你进行复杂业务操作时,当有复杂的数据操作需求,可以在仓储上定义特定的方法,从而减轻业务逻辑开发的负担,并让数据操作更加内聚。
另外,仓储的一个巨大价值是给单元测试提供援助,特别是特定方法,让单元测试的模拟变得简单。
一个值得注意的问题是,仓储的接口放在领域层,仓储的实现放到基础设施层,或叫数据访问层也行。
仓储接口提供了常用的数据访问操作,当开发一些简单业务模块,就不需要手工编写特定方法了。
- ICompactRepository
配合Po(持久化对象,后续会进行介绍)使用的仓储接口。
该接口的一个特点是没有任何和Lambda相关的方法,具体原因在后续介绍。
Po会导致项目变得更加复杂,如果你不是真的搞懂或需要,强烈建议不要使用它,请忽视这个接口。
- IDomainService
领域服务接口。
领域服务接口是一个空接口,它从IScopeDependency派生,这样你就不需要为领域服务配置Ioc。
领域服务的使用要点是不要在它里面编写业务逻辑,业务逻辑应该写到实体和值对象中,领域服务仅用来调度聚合根,封装聚合根的交互过程,任何业务应该由聚合根来完成。
应该注意到,如果你的业务逻辑主要写在领域服务,而聚合根里只有属性,那么这只是三层的变种。
领域服务并不是必须的,在简单的情况下可以降低一点要求,把聚合根的交互放到应用服务中,不过如果需要对聚合根的交互过程复用,就必须封装到领域服务。
对于使用三层架构的同学,领域服务同样有用,如果你需要复用一些逻辑,可以考虑使用领域服务封装一些细粒度的服务,再注入到应用服务来组装完成功能。
领域服务不应该提交事务,这个工作放到应用服务来完成,所以应用服务组装领域服务比应用服务调用其它应用服务更加简单易用。
- IEventBus
事件总线接口,它可以用来发送领域事件。
当有某些业务发生变化时,可以发布领域事件,这样可以得到松耦合的设计,当有需求变化时可以在不修改原代码的情况下进行处理。
Util支持两种事件类型。
一种是简单内存事件,订阅端的事件处理器与发送端处于同一个进程中。
另一种是消息事件,通过消息队列发送给另一个系统。
如果使用IEventBus,两种类型的事件可能被同时触发,具体依赖事件的类型。
- ISimpleEventBus
基于内存的简单事件总线接口。
它仅将事件发送给同一进程的事件处理器。
内存事件总线是由Ioc来实现的,在系统启动时扫描所有事件处理器并添加到Ioc中,发布事件时从Ioc获取所有匹配的事件处理器,并直接调用它的处理方法。
内存事件总线的实现主要参考自Nop开源商城。
- IMessageEventBus
基于消息的事件总线接口。
它仅将事件发送给消息队列。
消息事件总线,目前集成封装了Ncc开源社区的Cap项目,Cap不仅提供了消息事件总线的功能,还是分布式事务的解决方案。
当将Cap作为分布式事务解决方案时,需要手工using,并把Ef的事务传递给Cap,这导致工作单元被破坏,你的关注点被转移到数据库的事务中,另外无法使用Ioc来管理Ef工作单元这一最佳实践。
[Route("~/ef/transaction")] public IActionResult EntityFrameworkWithTransaction([FromServices]AppDbContext dbContext) { using (var trans = dbContext.Database.BeginTransaction(_capBus, autoCommit: true)) { _capBus.Publish("xxx.services.show.time", DateTime.Now); } return Ok(); }
Util对Cap和Ef进行了封装,你将以更加直观的方式来使用Cap和Ef,不再需要using。
- IEventHandler
内存事件处理器接口。
泛型参数为事件类型,只会接收到特定事件。
内存事件处理器所在的文件可以放在项目中的任何位置,建议统一放到某些目录,以免难以查找,形成隐患。
- Event
内存事件基类。
- MessageEvent
消息事件基类。
当使用MessageEvent作为事件或事件基类时,使用IEventBus能触发内存事件处理器并发送到消息队列。
最佳实践
下面分享一些我目前认为的最佳实践。
实体文件的拆分
实体通常包含大量属性,所以我会把实体的文件分成两个,一个用来放属性,另一个用来放它的方法。
Util的约定是实体名.Base.cs文件用来放置属性,比如Application.Base.cs。
实体名.cs文件用来放置方法,比如Application.cs,并且我会将Application.Base.cs文件嵌套在Application.cs文件中。
乱78糟的属性容易让人心烦,这样处理以后,编写业务逻辑就会心情舒畅很多。
如果实体非常简单,那么就不需要分开两个文件,一个文件即可。
私有化重要属性
如果方法操作的属性具有Public的Set访问修饰符,那么其它开发人员就会绕过你的方法直接给它赋值,如果你担心这种情况,需要把Set设置成Private。
领域对象的映射方法
领域模型很难掌握的其中一个原因是对象结构异常灵活,一旦业务复杂,对象结构与表结构将有很大不同。
而大部分开发人员都习惯了一个表就是一个类,而这个类也就相当于聚合根,所以业务稍微复杂就会不知所措,设计变得复杂,开发困难,大部分精力都放在了数据库的操作上,因为操作那些表非常麻烦。
要想使用领域模型的开发方法,需要加强面向对象的设计水平,同时需要了解一些对象映射的技巧。
面向对象的表设计,有一个简单的原则,对象尽量细粒度,但表的设计尽量粗粒度,很多时候需要逆范式设计。
这个道理很简单,如果表很少,那么你就能把更多精力放在对象的业务操作上,否则你大部分时候都在考虑该如何连表,另外表连接太多,你的精力又转移到该如何优化性能上。
当然并不是说数据库设计就不要范式,这样又会导致大量的数据冗余和更新困难,这需要做好平衡。
类的层次关系,也就是继承体系,可以帮助我们通过多态来使用设计模式。
对于类层次关系映射,我经常使用单表继承,也就是把整个类继承体系放进一张表里,这让表变得非常简单,注意力被高度集中在对象结构中,而不是连表操作上。
聚合根内部的值对象如果是单个的,可以使用嵌入值映射,将值对象的属性存储到聚合根所在表的多个列中。
聚合根内部的值对象如果是集合,使用序列化映射,存储Json字符串到聚合根所在表的单一列中。使用Json序列化会导致查询困难,应尽量少查或不查,或使用数据库提供的Json查询方法,另一种办法是创建专用查询数据库,不一定使用关系数据库,可以使用像MongoDB这样的NoSql数据库来查询。
对领域模型进行单元测试
如果你的业务复杂并且很关键,出现逻辑Bug会导致重大损失,那么单元测试就是你的救命稻草。
如果你对已经想到的所有需求进行了有效的单元测试,那么这些需求发现Bug的机会将非常渺茫。
当发现Bug时,通过添加单元测试可以永久性修复它,如果没有单元测试做保障,你可能会一石激起千层浪,一个Bug引发更多Bug,这非常常见。
使用领域模型的开发方法,与三层架构的事务脚本相比,它更容易进行单元测试。因为业务高度内聚到领域层,而领域层没有外部依赖,所以它更好模拟和测试。
要想真正实施单元测试,最好的方法是进行TDD。
如果项目已经开发结束,在后面补单元测试,一般都不靠谱,因为人有惰性,业务都做完了谁还愿意写呢。另外在项目开发完成后,代码耦合高,可能无法进行单元测试,只能做集成测试。
使用TDD可以获得很高的测试覆盖率,你想到的需求大部分分支情况可能都被覆盖到,质量必然有保障。
当然单元测试不是天上掉馅饼,它写起来不容易,对开发人员的设计和抽象能力有要求,另外测试时需要做大量的前置准备,比如设置和模拟依赖数据,非常麻烦,工作量很大。当需求变更时,有大量测试被抛弃和重写,他是质量的保障,也是一个包袱。
值得注意的是,不要对Crud进行单元测试,这是大炮打蚊子,用代码生成器来干这个活,可以获得相同的质量,但效率高10万倍。
对于大部分中小团队,只应该对核心业务逻辑进行单元测试,其它的手工测试即可。
在领域模型中实施设计模式
我发现使用领域模型的另一个优点是使用设计模式更加简单自然。
在三层架构中使用设计模式,你能切换的只有服务,这具有相当大的限制。
当我们使用领域模型,情况大有不同。
你可以切换领域服务以获得不同的行为。
你还可以建立实体的类层次关系结构,这些实体提供不同的行为。
更进一步,你可以使用专门的模式对象,比如策略对象,把策略对象传递到实体,实体上的方法委托到策略对象上。
在一些较复杂的业务中,策略对象和状态对象具有强大的杀伤力,数百个if else被封装到10几个状态和策略对象中,从而将面条式的业务代码迅速整理得干干净净。
基础设施层构造块
基础设施层的职责
- 为其它层提供支持
毫无疑问,最重要的支持就是提供操作数据库的能力。
除了数据库操作以外,还包括发短信,发邮件等。
甚至像Util这样的Helper和基类等辅助工具,也是基础设施这个范围。
如果没有特别的需求,你就把它当成数据访问层。
基础设施层的接口和基类
- IUnitOfWork
工作单元接口。
Ef Core的DbContext实现了工作单元。
关于工作单元,大多开发人员都把它当成事务,或事务的包装器。
但为什么DbContext的提交叫SaveChanges,而不是Commit呢?
如果理解了这个问题,就容易理解工作单元的概念,它是对象的数据操作记录器,并且在提交时开启一个事务把所有记录下来的变更写入数据库。
工作单元对使用面向对象的方式开发业务逻辑具有深远的影响。
很多标榜Orm的SqlHelper都试图与Ef Core进行功能比较,甚至性能比较,这是很可笑的。
是不是真正的Orm,首先看它有没有实现健壮的工作单元,而不是简单的对象映射。
如果实现了对象与数据库表的简单映射就叫Orm,那么我们可以用SqlHelper + AutoMapper花上半小时就能开发一个Orm了。
Ef Core在实现工作单元上进行了大量的工作,性能自然有损耗,但对于业务操作有非常大的帮助。
这里要吐槽一下,很多人觉得Ef Core不好用,根本原因是面向对象尚未入门,以数据操作思维来使用面向对象的基础设施,很明显水土不服。
虽然说领域模型需要尽量持久化无关,但使用好的基础设施,可以提升大量的生产力,并且将关注点转移到面向对象的业务操作上来。
关于Ef Core的一些最佳实践,后续开专文来讨论。
- UnitOfWork
工作单元基类。
将Ef Core的DbContext注入到UnitOfWork,并添加其它基础功能。
UnitOfWork添加了Ef Core日志功能,这对于使用Ef Core非常重要,你如果不看它生成的Sql,很可能产生严重的性能问题。
UnitOfWork添加的另一个功能是自动扫描并加载Ef Core映射配置类。
注意,Util为每种数据库都提供了UnitOfWork基类,你需要根据使用的数据库选择继承的基类,它们的命名空间不同,类名相同。
- RepositoryBase
仓储实现的基类。
Ef Core的DbContext实现了仓储模式,所以我们只需要把DbContext注入进来,再包装一些常用方法即可解决战斗。
前面已经说过,将仓储作为单独的构造块,而不是直接使用DbContext,是需要用仓储来提供单元测试支持和封装特定数据操作的功能。
- AggregateRootMap
聚合根映射基类。
你需要将类和表之间有差异的表名或列名进行映射配置。
AggregateRootMap屏蔽了Ef Core对不同数据库的乐观离线锁设置的差异。
注意,Util为每种数据库都提供了AggregateRootMap基类,你需要根据使用的数据库选择继承的基类,它们的命名空间不同,类名相同。
- EntityMap
实体映射基类。
注意,Util为每种数据库都提供了EntityMap基类,你需要根据使用的数据库选择继承的基类,它们的命名空间不同,类名相同。
- PersistentObjectBase
持久化对象基类。
Po就是持久化对象,它是另一种参数对象。
在一般情况下,我们直接使用Ef Core操作实体,不过这会导致实体与持久化产生一些耦合。
在大部分情况下,可以通过Ef Core将复杂实体与数据库表建立映射关系,不过有些映射方式Ef Core并不支持,比如序列化映射。
当你进行序列化映射时,需要在实体上添加一个字符串属性,再定义一个对象属性,然后使用这个对象属性,再让Ef Core操作这个字符串属性,这样来回转换。
我最早是在这里发现了问题,那个字符串属性污染了实体,这让领域模型变得不够纯,对于像我这样有代码洁癖的人,这并不是一个小问题。
不过我还是这样忍受了好几年,直到我从Ef6迁移到Ef Core。
Ef Core作为.Net Core平台的关键数据访问技术,虽然经过多年努力,始终比Ef6差一大截。特别是之前用得很溜的一些映射技术,比如嵌入值映射,也就是复杂类型映射,在Ef Core用起来非常蹩脚,而多对多这样的重要映射,盼了很多年也没有更新上来。
我开始寻找其它方案,很多年前我就听过Po的大名,不过一直没有使用它,怕麻烦。
我开始尝试使用Po,所谓Po,就是与数据库表相对应的一个对象,与表结构完全一样。
这样,你的实体就可以随便设计了,等到需要持久化的时候,将实体转成Po,再把Po丢给Ef Core去保存,由于Po与表完全对应,所以不需要使用Ef Core的任何高级映射功能。
我发现手工处理高级映射反而更加简单清晰,也不用再看Ef Core的脸色做事。
不过Po和Dto完全不同,引入Po并不是将它与实体转换一下就完事,它大大增加了架构的复杂性。
这里的问题在于Ef Core操作的对象发生了变化,之前Ef Core直接操作实体,而现在却操作的是Po。
另外Po不应该放到领域层,它处于基础设施层,那么实体还是需要仓储来操作,Po又由谁来操作呢?
这需要引入一个新的构造,它就是存储器Store。
- IStore
存储器接口。
存储器用于操作Po。
使用Store这个词,是因为微软很多数据操作使用它。
- StoreBase
存储器基类。
将Ef Core的DbContext注入进来实现所有操作。
- CompactRepositoryBase
配合Po使用的仓储基类。
将存储器注入到CompactRepositoryBase中完成操作。
对于Po使用的仓储,一个关键问题是不能有任何Lambda操作,因为DbContext操作Po,仓储操作的是实体,如果仓储包含Lambda操作,将无法传递给DbContext,因为实体不在DbContext中配置。
这样一来,领域层还是实体和仓储进行业务操作,Po,存储器位于基础设施层,互不干扰,为使用Po打下良好的基础。
有更好的设计方案,还请诸大神指教。
讨论
- 你需要使用Po吗?
劝你别用,徒增烦恼,不过当你领悟它,并真的需要时,它会让你的领域模型变得更加纯净,复杂映射也更加游刃有余。
小结
本文简要介绍了Util在业务逻辑开发方面的一些分层构造块,你不需要全部使用,如果你发现这个构造没什么用,并且你也没有打算持续学习,扔掉它就是最好的选择。
本文基本没有例子,你可以看看Demo并自己练习,更完善的教程还没有计划,需要Util框架更加完善后进行。
未完待续,查询的封装和使用将在下篇介绍。
写文需要动力,请大家多多支持,点下推荐,Github点下星星。
Util应用框架交流一群: 24791014
Util应用框架交流二群: 184097033
Util应用框架地址:https://github.com/dotnetcore/util