.NET应用架构设计—重新认识分层架构(现代企业级应用分层架构核心设计要素)
阅读目录:
- 1.背景介绍
- 2.简要回顾下传统三层架构
- 3.企业级应用分层架构(现代分层架构的基本演变过程)
- 3.1.服务层中应用契约式设计来解决动态条件不匹配错误(通过契约式设计模式来将问题在线下暴露出来)
- 3.2.应用层中的应用控制器模式(通过控制器模式对象化应用层的职责)
- 3.3.业务层中的命令模式(事务脚本模式的设计模式运用,很好的隔离静态数据)
- 4.服务层作为SOA契约公布后DTO与业务层的DomainModel共用基本的原子类型
- 5.两种独立业务层职责设计方法(可以根据具体业务要求来搭配)
- 5.1.在应用层中的应用控制器中协调数据层与业务层的互动(业务层将绝对的独立)
- 5.2.将业务层直接依赖数据层的关系使用IOC思想改变数据层依赖业务层(业务层将绝对独立)(比较优雅)
- 6.总结
1.背景介绍
接触分层架构有段时间了,从刚开始的朦朦胧胧的理解到现在有一定深度的研究后,觉得有必要将自己的研究成果分享出来给大家,互相学习,也是对自己的一个总结。
我们每天面对的项目结构可以说几乎都是分层结构的,或者是基于传统三层架构演变过来的类似的分层结构,少不了业务层、数据层,这两个层是比较重要的设计点,看似这两个层是互相独立的,但是这两个层如何设计真的还有很多比较微妙的地方,本文将分享给大家我在工作中包括自己的研究中得出的比较可行的设计方法。
2.简要回顾下传统三层架构
其实这一节我本来不打算加的,关于传统三层架构我想大家都应该了解或者很熟悉了,但是为了使得本文的完整性,我还是简单的过一下三层架构,因为我觉得它可以使得我后面的介绍有连贯性。
传统三层架构指将一个系统按照不同的职责划分层三个基本的层来分离关注点,将一个复杂的问题分解成三个互相协作的单元来共同的完成一个大任务。
1.显示层:用来显示数据或从UI上获取数据;该层主要是用来处理数据显示和特效用的,不包括任何业务逻辑。
2.业务层:业务层包含了系统中所有的核心业务逻辑,不包括任何跟数据显示、数据存取相关的代码逻辑。
3.数据层:用来提供对具体的数据源引擎的访问,主要用来直接存取数据,不包括业务逻辑处理。
其实用文字描述这三个层的基本职责还很是比较容易的,但是不同的人如何理解并设计这三个层就形态各异了,反正我是看过很多各种各样的分层结构,各有各的特点,从某个角度讲都很不错,但是都显得有点乱,因为没有一个统一的架构模式来支撑,代码中充满了对分层架构的理解错位的地方,比如:经常看见将“事物脚本”模式和“表模块”模式混搭使用的,导致我最后都不知道把代码写在哪里,提取出来的代码也不知道该放到哪个对象里。
层虽简单但是要想运用的好不容易,毕竟我们还是站在一个比较高的层面比较笼统的层面来谈论分层结构的,一旦落实到代码上就完全不一样了,用不用接口来隔离各层,接口放在哪个层里,这些都是很微妙的,当然本文不是为了说明我所介绍的设计是多么的好,而是给大家一个可以参考的例子而已。
言归正传,三个层之间的调用要严格按照“上层只能调用直接下层,不能够越权,而下层也不能够调用自己的上层”,这是基本的层结构的调用约束,只有这样才能保证一个好的代码结构。显示层只能调用业务层,业务层也只能调用数据层,其实就是这么简单,当然具体的代码设计也可以大概归纳为两种,第一种是实例类或静态类直接调用;第二种是每个层之间加上接口来隔离每个层,使得测试、部署容易点,但是如果用的不好的话效果不大反而会增加复杂度,还不如直接使用静态类来的直接点,但是用静态类来设计业务类会使多线程操作很难实施,稍微不注意就会串值或报错。
3.企业级应用分层架构(现代分层架构的基本演变过程)
上节中我们基本了解了传统三层架构的类型和职责,本节我们来简单介绍一下现代企业应用分层架构的类型和职责。
随着企业应用的复杂度增加,在原有三层架构上逐渐演化出现在的面向企业级的分层架构,这种架构能很好的支持新的技术和代码上的最佳实践。
在传统的三层结构中的业务层之上多了一个应用层也可是说是服务层,该层是为了直接隔离显示层来调用业务层,因为现在的企业应用基本上都在往互联网方向发展,对业务逻辑的访问不会在是从进程内访问了,而是需要跨越网络来进行。
有了这一层之后会让原本显示层调用业务层的过程变得灵活很多,我们可以添加很多灵活性在里面,更为重要的是显示层和业务层两个独立的层要想完全独立是必须要有一个层来辅助和协调他们之间的互动的。在最早的三层架构的书籍中其实也提到了“服务层”来协调的作用,为什么我们很多的项目都不曾出现过,当我自己看到书上有讲解后才恍然大悟。(该部分可以参考:《企业应用架构模式》【马丁.福勒】;第二部分,第9章“服务层”)
图1:(逻辑分层)
应用层中包含了服务的设计部分,应用层的概念稍微大一点,里面不仅不含了服务还包含了很多跟服务不相关的应用逻辑,比如:记录LOG,协调基础设施的接入等等,就是将服务层放宽了理解。
图2:(项目结构分层)
在应用层中包含了我们上述所说的”服务“,将”服务层“放宽后形成了现在分层架构中至关重要的”应用层“。应用层将负责整体的协调”业务层“和”数据层“及“基础设施”,当然还会包括系统运行时环境相关的东西。
3.1.服务层中应用契约式设计来解决动态条件不匹配错误(通过契约式设计模式来将问题在线下暴露出来)
此设计方法主要是想将动态运行时条件不匹配错误在线下自动化回归测试时就暴露出来。因为服务层中的契约可能会面临着被修改的危险性,所以我们无法知道我们本次上线的契约中是否包含了不稳定的条件不匹配的危险。
利用契约式设计模式可以在调用时自动的执行契约发布方预先设定的契约检查器,契约检查器分为前置条件检查器和后置条件检查器;我们来看一个简单的例子;
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 using System.Threading.Tasks; 6 7 namespace CompanySourceSearch.Service.Contract 8 { 9 using CompanySourceSearch.ServiceDto.Request; 10 using CompanySourceSearch.ServiceDto.Response; 11 12 public interface ISearchComputer 13 { 14 GetComputerByComputerIdResponse GetComputerByComputerId(GetComputerByComputerIdRequest request); 15 } 16 }
在服务契约中我定义了一个用来查询企业中电脑资源的接口,好的设计原则就是不要直接暴露查询字段而是要将其封装起来。
1 namespace CompanySourceSearch.ServiceDto 2 { 3 public abstract class ContractCheckerBase 4 { 5 private Func<bool> checkSpecification; 6 public Func<bool> CheckSpecification 7 { 8 get 9 { 10 return this.checkSpecification; 11 } 12 private set 13 { 14 this.checkSpecification = value; 15 } 16 } 17 18 public void SetCheckSpecfication(Func<bool> checker) 19 { 20 CheckSpecification = checker; 21 } 22 23 public virtual bool RunCheck() 24 { 25 if (CheckSpecification != null) 26 return CheckSpecification(); 27 28 return false; 29 } 30 } 31 }
然后定义了一个用来表示契约检查器的基类,这里纯粹是为了演示目的,代码稍微简单点。服务契约的请求和响应都需要通过继承这个检查器类来实现自身的检查功能。
1 namespace CompanySourceSearch.ServiceDto.Request 2 { 3 public class GetComputerByComputerIdRequest : ContractCheckerBase 4 { 5 public long ComputerId { get; set; } 6 7 public GetComputerByComputerIdRequest() 8 { 9 this.SetCheckSpecfication(() => ComputerId > 0/*ComputerId>0的检查规则*/); 10 } 11 } 12 }
Request类在构造函数中初始化了检查条件为:ComputerId必须大于0。
1 namespace CompanySourceSearch.ServiceDto.Response 2 { 3 using CompanySourceSearch.ServiceDto; 4 5 public class GetComputerByComputerIdResponse : ContractCheckerBase 6 { 7 public List<ComputerDto> ComputerList { get; set; } 8 9 public GetComputerByComputerIdResponse() 10 { 11 this.SetCheckSpecfication(() => ComputerList != null && ComputerList.Count > 0); 12 } 13 } 14 }
同样Response类也在构造函数中初始化了条件检查器为:ComputerList不等于NULL并且Count要大于0。还是那句话例子是简单了点,但是设计思想很不错。
对前置条件检查器的执行可以放在客户端代理中执行,当然你也可以自行去执行。后置条件检查器其实在一般情况下是不需要的,如果你能保证你所测试的数据是正确的,那么作为自动化测试是应该需要的,当时维护一个自动化测试环境很不容易,所以如果你用后置条件检查器来检查数据动态变化的环境时是不太合适的。
3.2.应用层中的应用控制器模式(通过控制器模式对象化应用层的职责)
应用层设计的时候大部分情况下我们都喜欢使用静态类来处理,静态类有着良好的代码简洁性,而且还能带来一定的性能提升。但是从长远来考虑静态类存在一些潜在的问题,数据不能很好的隔离,重复代码不太好提取,单元测试不太好写。
为了能够在很长的一段时间内似的项目维护性很高的情况下还是建议将应用控制器使用实例类设计,这里我喜欢使用“应用控制器”来设计。它很形象的表达了协调前端和后端的职责,但是具体不处理业务逻辑,与MVC中的控制器很像。
1 namespace CompanySourceSearch.ApplicationController.Interface 2 { 3 using CompanySourceSearch.Service.Contract; 4 using CompanySourceSearch.ServiceDto.Response; 5 using CompanySourceSearch.ServiceDto.Request; 6 7 public interface ISearchComputerApplicationController 8 { 9 GetComputerByComputerIdResponse GetComputerByComputerId(GetComputerByComputerIdRequest request); 10 } 11 }
在应用控制器中我们定义了一个用来负责上述查询Computer资源的的控制器接口。
1 namespace CompanySourceSearch.ApplicationController 2 { 3 using CompanySourceSearch.ApplicationController.Interface; 4 using CompanySourceSearch.ServiceDto.Request; 5 using CompanySourceSearch.ServiceDto.Response; 6 7 public class SearchComputerApplicationController : ISearchComputerApplicationController 8 { 9 public GetComputerByComputerIdResponse GetComputerByComputerId(GetComputerByComputerIdRequest request) 10 { 11 throw new NotImplementedException(); 12 } 13 } 14 }
控制器实现类。这样可以很清晰的分开各个应用控制器,这样对服务实现来说是个很不错的提供者。
1 namespace CompanySourceSearch.ServiceImplement 2 { 3 using CompanySourceSearch.Service.Contract; 4 using CompanySourceSearch.ServiceDto.Response; 5 using CompanySourceSearch.ServiceDto.Request; 6 using CompanySourceSearch.ApplicationController.Interface; 7 8 public class SearchComputer : ISearchComputer 9 { 10 private readonly ISearchComputerApplicationController _searchComputerApplicationController; 11 12 public SearchComputer(ISearchComputerApplicationController searchComputerApplicationController) 13 { 14 this._searchComputerApplicationController = searchComputerApplicationController; 15 } 16 17 public GetComputerByComputerIdResponse GetComputerByComputerId(GetComputerByComputerIdRequest request) 18 { 19 return _searchComputerApplicationController.GetComputerByComputerId(request); 20 } 21 } 22 }
服务在使用的时候只需要使用IOC的框架将控制器实现直接注入进来就行了,当然这里你可以加上AOP用来记录各种日志。
通过将控制器按照这样的方式进行设计可以很好的进行单元测试和重构。
3.3.业务层中的命令模式(事务脚本模式的设计模式运用,很好的隔离静态数据)
在一般的企业应用中大部分的业务层都是使用"事务脚本"模式来设计,所以这里我觉得有个很不错的模式可以借鉴一下。但是很多事务脚本模式都是使用静态类来处理的,这一点和控制器使用静态类相似了,代码比较简单,使用方便。但是依然有着几个问题,数据隔离,不便于测试重构。
将事务脚本使用命令模式进行对象化,进行数据隔离,测试重构都很方便,如果你有兴趣实施TDD将是一个不错的结构。
1 namespace CompanySourceSearch.Command.Interface 2 { 3 using CompanySourceSearch.DomainModel; 4 5 public interface ISearchComputerTransactionCommand 6 { 7 List<Computer> FilterComputerResource(List<Computer> Computer); 8 } 9 }
事务命令控制器接口,定义了一个过滤Computer资源的接口。你可能看见了我使用到了一个DominModel的命名空间,这里面是一些跟业务相关的且通过不断重构抽象出来的业务单元(有关业务层的内容后面会讲)。
1 namespace CompanySourceSearch.Command 2 { 3 using CompanySourceSearch.Command.Interface; 4 5 public class SearchComputerTransactionCommand : CommandBase, ISearchComputerTransactionCommand 6 { 7 public List<DomainModel.Computer> FilterComputerResource(List<DomainModel.Computer> Computer) 8 { 9 throw new NotImplementedException(); 10 } 11 } 12 }
使用实例类进行业务代码的组装将是一个不会后悔的事情,这里我们定义了一个CommandBase类来做一些封装工作。
应用控制器同样和服务类一样使用IOC的方式使用业务命令对象。
1 namespace CompanySourceSearch.ApplicationController 2 { 3 using CompanySourceSearch.ApplicationController.Interface; 4 using CompanySourceSearch.ServiceDto.Request; 5 using CompanySourceSearch.ServiceDto.Response; 6 using CompanySourceSearch.Command.Interface; 7 8 public class SearchComputerApplicationController : ISearchComputerApplicationController 9 { 10 private readonly ISearchComputerTransactionCommand _searchComputerTransactionCommand; 11 public SearchComputerApplicationController(ISearchComputerTransactionCommand searchComputerTransactionCommand) 12 { 13 this._searchComputerTransactionCommand = searchComputerTransactionCommand; 14 } 15 16 public GetComputerByComputerIdResponse GetComputerByComputerId(GetComputerByComputerIdRequest request) 17 { 18 throw new NotImplementedException(); 19 } 20 } 21 }
到目前为止每个层之间坚持使用面向接口编程。
4.服务层作为SOA契约公布后DTO与业务层的DomainModel共用基本的原子类型
这里有个矛盾点需要我们平衡,当我们定义服务契约时会定义服务所使用的DTO,而在业务层中为了很好的凝聚业务模型我们也定义了部分领域模型或者准确点讲,在事务脚本模式的架构中我们是通过不断重构出来的领域模型,它封装了部分领域逻辑。所以当服务中的DTO与领域模型中的实体需要使用相同的原子类型怎么办?比如某个类型的状态等等。
如果纯粹的隔离两个层面,我们完全可以定义两套一模一样的原子类型来使用,但是这样会带来很多重复代码,难以维护。如果不定义两套那么又将这些共享的类型放在哪里比较合适,放在DTO中显示不合适,业务模型是不可能引用外面的东西的,如果放在领域模型中似乎也有点不妥。
这里我是采用将原子类型独立一个项目来处理的,可以类似于"CompanySourceSearch.DomainModel.ValueType"这样的一个项目,它只包含需要与DTO进行共享的原子值类型。
5.两种独立业务层职责设计方法(可以根据具体业务要求来搭配)
之前我们没有谈业务层的设计,这里我们重点讲一下业务层的设计包括与数据层的互操作。
从应用层开始考虑,当我们需要处理某个逻辑时从应用控制器开始可能就会认为直接进入到服务层了,然后服务层再去调用数据层,其实这只是设计的一种方式而已。这样的设计方式好处就是简单明了,实现起来比较方便。但是这种方法有个问题就是业务层始终还是依赖数据层的,业务层的变动依然会受到数据层的影响。还有一个问题就是如果这个时候你使用不是“事务脚本”模式来设计业务层的话也会自然而然的写成过程式代码,因为你将原本用来协调的应用控制器没有做到该做的事情,它其实是用来协调业务层和数据层的,我们并不一定非要在业务层中去调用数据层,而是可以将业务层需要的数据从控制器中获取好然后传入到业务层中去处理,这和直接在业务层中去调用数据层是差不多的,只不过是写代码的时候不能按照过程式的思路来写了。
不管我们是使用事务脚本模式还是表模块模式或者当下比较流行的领域模型模式,都可以使用这种方法进行设计。
5.1.在应用层中的应用控制器中协调数据层与业务层的互动(业务层将绝对的独立)
我们将在应用控制器中去调用数据层的方法拿到数据然后转换成领域模型进行处理。
namespace CompanySourceSearch.Database.Interface { using CompanySourceSearch.DatasourceDto; public interface IComputerTableModule { List<ComputerDto> GetComputerById(long cId); } }
我们使用"表入口“数据层模式来定义了一个用来查询Computer的方法。
1 namespace CompanySourceSearch.ApplicationController 2 { 3 using CompanySourceSearch.ApplicationController.Interface; 4 using CompanySourceSearch.ServiceDto.Request; 5 using CompanySourceSearch.ServiceDto.Response; 6 using CompanySourceSearch.Command.Interface; 7 using CompanySourceSearch.Database.Interface; 8 using CompanySourceSearch.DatasourceDto; 9 using CompanySourceSearch.Application.Common; 10 11 public class SearchComputerApplicationController : ISearchComputerApplicationController 12 { 13 private readonly ISearchComputerTransactionCommand _searchComputerTransactionCommand; 14 private readonly IComputerTableModule _computerTableModule; 15 public SearchComputerApplicationController(ISearchComputerTransactionCommand searchComputerTransactionCommand, 16 IComputerTableModule computerTableModule) 17 { 18 this._searchComputerTransactionCommand = searchComputerTransactionCommand; 19 this._computerTableModule = computerTableModule; 20 } 21 22 public GetComputerByComputerIdResponse GetComputerByComputerId(GetComputerByComputerIdRequest request) 23 { 24 var result = new GetComputerByComputerIdResponse(); 25 26 var dbComputer = this._computerTableModule.GetComputerById(request.ComputerId);//从数据源中获取Computer集合 27 var dominModel = dbComputer.ConvertToDomainModelFromDatasourceDto();//转换成DomainModel 28 29 var filetedModel = this._searchComputerTransactionCommand.FilterComputerResource(dominModel);//执行业务逻辑过滤 30 31 return result; 32 33 } 34 } 35 }
控制器中不直接调用业务层的方法,而是先获取数据然后执行转换在进行业务逻辑处理。这里需要澄清的是,此时我是将读写混合在一个逻辑项目里的,所以大部分的查询没有业务逻辑处理,直接转换成服务DTO返回即可。将读写放在一个项目可以共用一套业务逻辑模型。当然仅是个人看法。
这个是业务层将是完全独立的,我们可以对其进行充分的单元测试,包括迁移和公用,甚至你可以想着领域特定框架发展。
5.2.将业务层直接依赖数据层的关系使用IOC思想改变数据层依赖业务层(业务层将绝对独立)(比较优雅)
上面那种使用业务层和数据层的方式你也许觉得有点别扭,那么就换成使用本节的方式。
以往我们都是在业务层中调用数据层的接口来获取数据的,此时我们将直接依赖数据层,我们可以借鉴IOC思想,将业务层依赖数据层进行控制反转,让数据层依赖我们业务层,业务层提供依赖注入接口,让数据层去实现,然后在业务命令对象初始化的时候在动态的注入数据层实例。
如果你已经习惯了使用事物脚本模式来开发项目,没关系,你可以使用此模式来将数据层彻底的隔离出去,你也可以试着在应用控制器中帮你分担点事物脚本的外围功能。
6.总结
文章中分享了本人觉得到目前来说比较可行的企业应用架构设计方法,并不能说完全符合你的口味,但是可以是一个不错的参考,由于时间关系到此结束,谢谢大家。