从三层架构说起,谈谈对历史项目的小改造

项目背景说明

最近接手一个 “老” 项目的需求修改,项目整体基于 .net core 3.1 平台,以传统的三层架构为基础构建。了解需求后,逐步对原有项目框架进行大概的了解,主要是熟悉一些框架的开发规范,基本工具类库的使用,然后尝试修改业务需求以达到客户方的要求。

刚开始了解业务需求,先大概的了解了一些直观可视化的界面操作和基本菜单目录结构,此时还没来得及看项目框架代码,需求大概清楚后,开始对后端项目框架查看,随之逐步使用。这不使用不知道,一使用吓一跳,针对目前遇到的几个问题,先在这里列举部分:.

  1. 1. 通常情况下 BLL 层依赖引用 DAL 层,该项目中刚好与之相反;

  2. 2. 单表的 CRUD 操作基本被封装到基类中(BaseController、BaseBll 和 BaseDal),对多条件查询的提供方法不太灵活(不好用),从基类可以看出,各层职责混淆,并且封装的操作 DB 的基本方法返回值都为 void 。比如在修改一个相对简单的需求,在业务整合的时候,使用框架封装提供的方法反而更麻烦;

  3. 3. 在 BLL 层的具体方法中,看到一个功能大概是批量列表操作的方法,方法内竟然多次循环操作 DB(此处不多说,自己体会)。听同事说,这个 项目刚上线就存在内存泄漏 的情况(感到惊讶!)。

  4. 4. 还有同事在做需求业务修改时,发现 (单体项目)同库环境多表之间的关联操作没有走数据库事务处理,而是直接采用应用程序服务依赖处理(我还没接触到这类需求,暂时不太清楚怎么设计的)。

这个项目唯一的好处就是 模块名称映射,业务模块的命名在前端、服务端再到数据库名称和实体表之间都保持较好的映射规则,这是欣慰的地方。

目前所了解或者听闻的就大概这些,基于以上的这些问题,在不破坏现有的框架编码体系的前提下,又要满足自己修改业务需求时编写代码的顺手体验(专业描述叫 渐进式修改,说难听一点叫偷梁换柱),仔细看了下 DAL(数据访问层)的基本代码,幸好预留了一个 IDbContext 对象,于是基于该对象提供的信息(解析该对象)并引入顺手的 orm 操作(符合框架的扩展),决定不使用框架内置的封装方法。

产生了该想法,说干就干,本文将从以下几个方面来谈谈项目改造和相关的框架说明:

  1. 1. 回顾经典的三层架构(单体项目中常用);

  2. 2. 第三方 DI 框架 Autofac 的使用(项目中看到应用,顺便了解学习);

  3. 3. 解析 DAL 层预留的 IDbContext 对象(在本项目中是 EF 提供的上下文对象),引入更轻量级的 ORM(此处使用 FreeSql);

  4. 4. 最后基于三层架构,简单快速的搭建一个单体项目的框架雏形(实操);

说明:在原有的项目中,目的是跳出框架封装的基本 CRUD 操作方法(不太好用),主要改造点是解析 DAL 层的 IDbContext 对象并引入轻量化的 FreeSql 操作。

三层架构的理解

因为项目业务环境,基本上都是单体三层架构为主,三层架构想必大家都不陌生,在回顾之前我们先来了解生活中一个常见且形象的应用场景,以商场购买猪肉为例,先看下面的图:

从三层架构说起,谈谈对历史项目的小改造

商场购买猪肉

通过上图,我们可以很清晰直观的了解到,猪从猪圈里面逐步变成商品被流通市场 的大概过程,并且每一个过程中 职责分工比较明确,那么对应的在我们计算机程序设计中,也是可以依据此来抽象划分各个模块的,如下图所示:

从三层架构说起,谈谈对历史项目的小改造

三层架构

从上图中,我们按照【猪 => 工厂 => 商品】的模式抽象,形成了 职责明确的各个模块层,由于项目初期起步规模不大,各个模块组合的单体项目可以部署同一个服务器环境,也可以安装上图分离环境部署。

说明:此处不讨论架构设计,只是针对目前接手业务需求的项目回顾一下基本的三层架构,具体的服务资源部署可以依据公司的业务规模,考虑经济成本且满足当下使用需求即可。个人见解,架构是业务驱动的,而不是被过渡的设计。

上面的架构图中,分别标注了三种角色类型:

  • • 客户端(Client),用于向【服务端应用程序】发起请求;

  • • 应用服务层(App Server),用于接收 Client 的请求,经过一系列的数据处理,响应相应的反馈结果;

  • • 数据服务层(Data Server),主要用于存储应用系统的基础数据和相关业务处理的数据。在该层通常为了减缓 DB 的 I/O 直接交互,通常会引入一个缓存组件(单体环境通常内存缓存,或者分布式部署环境的 Redis)提升应用系统的性能。

这里我们重点说下应用服务层(App Server),分别包含以下几个职责模块:

  • • UI 层,接收 Client 的请求,承担展示页面直观的视觉效果和数据校验等相关工作。通常包括:winfrom/wpf/.aspx/.cshtml/.html 等。在前后端分离的项目中,相对前端应用程序来说,后端提供的 webapi/controller 层即代表该层。

  • • BLL 层,接收 Client 的数据后,通常情况下使用 IBLL 定义接口规范,然后在 BLL 层实现相应的业务逻辑处理方法(依赖 IDAL 层提供的数据),比如方法或者服务的整合等。

  • • DAL 层,提供对 DB 数据库的访问操作(数据源相关环节交涉),通常情况也在 IDAL 层定义接口规范,然后在 DAL 层实现对应的数据访问操作方法(比如单表的 CRUD 操作)。通常该层会借助一些 ORM 辅助类库,比如:ADO.NET、DbHelper/SqlHelper、FreeSql 、EF/EF Core、Dapper 等。

  • • Model 层(数据模型的载体),为了更佳细化的分类规制,此处暂时考虑分三类模型,分别是 BaseEntity、BusinessEntity 和 ViewEntity

  • • Common 层(通用类库和工具助手),该层有自定义封装整合的,也有依赖外部 Nuget 包封装处理的,依据项目业务情况按需获取组装。

基于上面的架构图,一个单体环境下基本的框架雏形就可以搭建了,但具体落地项目还需考虑以下几点细节和原则(包括但不限于):

  1. 1. 系统开发的 命名规范,建议各个业务模块,在数据库表设计、前后端应用程序里面一一映射,这样可以很直观、方便的上手;

  2. 2. 请求入口处 参数合法性的基础校验(必备常识),无论前端部分还是服务端部分,参数的校验还是很有必要性的。无效的参数,可能会导致程序的异常;

  3. 3. 统一的入参格式,比如请求参数 JSON 格式化,遵循 HTTP/RESTful API 风格;

  4. 4. 统一的数据响应载体,对比原生的数据格式返回,很多情况下的 null 结果无法确定接口在业务意义的成功或失败。

  5. 5. 统一的异常处理机制和数据格式,通常采用 AOP 思想全局异常捕获(ExceptionFilterAttribute 异常过滤器),数据信息推荐 JSON 格式化记录;

  6. 6. 系统日志的记录(数据建议 JSON 格式化),通常情况下会在框架层面采用 AOP 方式获取用户在系统中操作的全生命周期数据记录,也可提供日志写入方法,在关键业务逻辑处精细化的记录逻辑操作信息。从一定方面可以起到还原 “真相” 的保障;

  7. 7. 整体遵循 单一职责原则SRP:Singleresponsibilityprinciple),该点也是最难做到的理想化指导原则,在编写业务方法的时候,一个方法尽量做到功能单一,比如复杂的业务处理,可以使用每个相关的单一方法整合;

  8. 8. 依赖抽象,不应依赖具体实现,这也是 开-闭原则OCP:Open - Close Principle)的体现。比如:Controller => IBLL 接口规范 / BLL 具体实现 => IDAL 接口规范 / DAL 具体实现。还有在框架中无处不在的 DI 应用;

说明:此处只是列举部分比较常见或者基本必备的点,框架设计还有很多细节考虑,这里不再详细论述。

任何框架无论封装的如何优秀,关键还是在于局中 “玩游戏” 的开发者,框架只是提供了基本的开发规则,要大家共同的遵循,这其中最难的就是团队小伙伴达成一致的思维认知和共识。约定优于配置,任何框架不可能面面俱到,过渡设计框架务必会失去部分灵活性(物极必反的道理想必大家都知道),个人建议框架架构设计应该以 业务为驱动、技术为主导、约定和思想共识为辅助、开发规范为底线 这几个方面加强。

Autofac 基本概述

Autofac 官方地址 => https://autofac.org/

了解到项目的基本情况后,由于项目是 .net core 3.1 平台构建的,与老平台的 .netfx 相比,变化最大有以下几点(这里唠嗑一下):

  1. 1. 框架平台的福利:开源、跨平台、高性能(看怎么使用,比如批量列表操作直接多次循环 DB 操作,这样的玩法神仙框架也无解);

  2. 2. 无处不在的 DependencyInjection(DI,依赖注入),最直观的使用体验就是解放了了传统的 new实例化对象;

  3. 3. 灵活的 Middleware(中间件)和透明的 HTTP Pipeline (http 管道)机制;

这里只说下 DI 依赖注入,在基本简单的注入方面 asp.net core 框架中默认提供的 DI 很方便,但在有些场景下就没有第三方的 DI 更佳灵活方便了。

默认 DI 框架

  • • Microsoft.Extensions.DependencyInjection.Abstractions(容器的抽象)

  • • Microsoft.Extensions.DependencyInjection(容器的具体实现)

当你要在自己的程序中使用时,使用nuget包管理工具安装 Microsoft.Extensions.DependencyInjection 包即可。

老牌第三方 DI 框架 Autofac

  • • Autofac.Extensions.DependencyInjection

当然这里还有其他第三方 DI 框架,这里不再一一列举,感兴趣的小伙伴自行查看相关资料。

Autofac 基本知识点

这里先简单的回顾下 Autofac 的基本知识点 :

  1. 1. Autofac 框架 DI 生命周期

  2. 2. Autofac 框架 DI 注入方式

  3. 3. Autofac 在 asp.net core 框架中的应用(简单提下,预留在下面的项目改造中描述)。

Autofac 框架 DI 的生命周期

  • • InstancePerDependency(瞬时的),与 Microsoft DI 容器中的 Transient 类似,每次调用都会生成新的实例,这种模式也是 Autofac 的默认模式

  • • SingleInstance(单例),和 Microsoft DI 容器中的 Singleton 类似,所有的调用均返回同一个实例;

  • • InstancePerLifetimeScope(作用域),与 Microsoft DI 容器中的 Scoped 类似,它在一个作用域中获取的实例相同,在 asp.net core 中常用(大多数情况下推荐使用);

  • • InstancePerMatchingLifetimeScope (匹配作用域),与 InstancePerLifetimeScope 类似,但是它支持对实例共享进行更精细的控制;

  • • InstancePerRequest (每次请求一个实例),在老旧的 asp.net webform 和 asp.net mvc 中使用,此处不再介绍;

  • • InstancePerOwned (Owned 隐式关系类型创建了一个新的嵌套生命周期Scope)

  • • ThreadScope (线程作用域),代表每个线程的一个实例,对于多线程场景,必须非常小心,不要在派生线程下释放父作用域。如果您生成了线程,然后释放了父作用域,那么可能会陷入一个糟糕的情况,即无法解析组件。

// 创建容器对象实例
var builder = new ContainerBuilder();

// 1、InstancePerDependency(瞬时的)
// 注册 Worker 类为 InstancePerDependency(瞬时的),每次获取都会生成新的实例
builder.RegisterType<Worker>().InstancePerDependency();
// 如果不指定与 InstancePerDependency(默认的模式)相同
builder.RegisterType<Worker>();

// 2、SingleInstance(单例)
// 注册 Worker 类为 SingleInstance(单例),每次获取均返回同一个实例
builder.RegisterType<Worker>().SingleInstance();

// 3、InstancePerLifetimeScope(作用域)
// 注册 Worker 类为 InstancePerLifetimeScope(作用域),
// 在同一个作用域中获得的是相同实例,在不同作用域获得的是不同实例
builder.RegisterType<Worker>().InstancePerLifetimeScope();
// InstancePerLifetimeScope(作用域)实例对象验证
using(var scope1 = container.BeginLifetimeScope())
{
  for(var i = 0; i < 100; i++)
  {
    // 在 scope1 中获取的 Worker 都是同一个实例
    var w1 = scope1.Resolve<Worker>();
  }
}

using(var scope2 = container.BeginLifetimeScope())
{
  for(var i = 0; i < 100; i++)
  {
    // 在 scope2 中获取的 Worker 都是同一个实例
    // 在 scope2 中获取的 Worker 实例和 scope1 中获取的 Worker 实例不相同,因为他们是两个不同的作用域
    var w2 = scope2.Resolve<Worker>();
  }
}

using(var scope3 = container.BeginLifetimeScope())
{
  var w3 = scope3.Resolve<Worker>();
  using(var scope4 = scope3.BeginLifetimeScope())
  {
    // w3 和 w4 是不同的实例,因为他们是在不同的作用域中请求的
    var w4 = scope4.Resolve<Worker>();
  }
}

var w5 = container.Resolve<Worker>();
using(var scope5 = container.BeginLifetimeScope())
{
  // w5 和 w6 不同
  // Scope 是一个生命周期范围,如果从 Scope 中解析一个 InstancePerLifetimeScope 服务,
  // 该实例将在 Scope 的持续时间内存在,并且实际上是一个单例
  // 它将在容器的生命周期内被保存,以防止其他对象试图从容器解析 Worker
    
  // 解释:注册为 InstancePerLifetimeScope 的服务,在每个 Scope 中请求类似于请求单例,
  // 在这个单例的生命周期于 Scope 的生命周期相同,在 Scope 中请求对应实例则返回对应的单例,
  // 这样就避免冲突,每个Scope请求的都是自己的实例
  var w6 = scope5.Resolve<Worker>();
}

// 4、InstancePerMatchingLifetimeScope (匹配作用域)
// 当您创建一个嵌套(多层级)的生命周期 Scope 时,您可以 “标记” 或 “命名” 该 Scope。
// 每个匹配标记的生命周期 Scope 作用域内最多只有一个与给定名称匹配的服务实例,包括嵌套的生命周期Scope。
// 这允许您创建一种“作用域单例”,在这种单例中,其他嵌套的生命周期作用域可以共享组件的实例,而无需声明全局共享实例。
builder.RegisterType<Worker>().InstancePerMatchingLifetimeScope("my-request");
// 创建标记的作用域Scope
using(var scope1 = container.BeginLifetimeScope("my-request"))
{
  for(var i = 0; i < 100; i++)
  {
    var w1 = scope1.Resolve<Worker>();
    using(var scope2 = scope1.BeginLifetimeScope())
    {
      var w2 = scope2.Resolve<Worker>();

      // w1 和 w2 的实例总是相同的,因为使用 InstancePerMatchingLifetimeScope 且为指定标记的Scope,
      // 嵌套的生命周期作用域可以共享实例,它实际上在标记作用域中是一个单例
    }
  }
}

// 创建另一个标记的作用域 Scope
using(var scope3 = container.BeginLifetimeScope("my-request"))
{
  for(var i = 0; i < 100; i++)
  {
    // w3和w1/w2是不同的实例,因为他们是两个不同的生命周期,虽然它们的标记相同
    // InstancePerMatchingLifetimeScope 依然是在不同的生命周期作用域创建新的实例
    var w3 = scope3.Resolve<Worker>();
    using(var scope4 = scope3.BeginLifetimeScope())
    {
      var w4 = scope4.Resolve<Worker>();
      // w3和w4是相同的实例,因为使用 InstancePerMatchingLifetimeScope 且为指定标记的 Scope
      // 嵌套的生命周期作用域可以共享实例
      // w3和w4是同样的实例,w1和w2是同样的实例,但是w1/w2和w3/w4是不同的实例,因为他们是两个不同的作用域 Scope
    }
  }
}

// 注意:不能在标记不匹配的生命周期 Scope 中获取 InstancePerMatchingLifetimeScope 中标记的实例会抛出异常。
using(var noTagScope = container.BeginLifetimeScope())
{
   // 在没有正确命名(标记)的生命周期Scope的情况下试图解析每个匹配生命周期Scope的组件,会抛出异常
  var fail = noTagScope.Resolve<Worker>();
}

// 5、InstancePerOwned
// Owned隐式关系类型创建了一个新的嵌套生命周期Scope。
// 可以使用每个拥有的实例注册将依赖关系限定到拥有的实例,简单讲就是将依赖注入限定到对应泛型实例
builder.RegisterType<MessageHandler>();
builder.RegisterType<ServiceForHandler>().InstancePerOwned<MessageHandler>();
using(var scope = container.BeginLifetimeScope())
{
  // MessageHandler 依赖 ServiceForHandler,它们的生命周期处于scope(当前Scope的名称)下的子生命周期范围内
  var h1 = scope.Resolve<Owned<MessageHandler>>();
  // 但是 InstancePerOwned 的实例需要自己处理,所以这里需要手动释放
  h1.Dispose();
}

// 6、ThreadScope (线程作用域)
builder.RegisterType<MyThreadScopedComponent>().InstancePerLifetimeScope();
var container = builder.Build();
void ThreadStart()
{
  using (var scope = container.BeginLifetimeScope())
  {
    // 从容器的一个子作用域解析服务
    var thisThreadsInstance = scope.Resolve<MyThreadScopedComponent>();
    // 还可以创建嵌套的作用域
    using(var unitOfWorkScope = scope.BeginLifetimeScope())
    {
        var anotherService = unitOfWorkScope.Resolve();
    }
  }
}

Autofac 框架 DI 注入方式

  • • RegisterType,类型注入

  • • RegisterInstance,实例注入

  • • Lambda 表达式注入

  • • Property 属性注入

  • • RegisterGeneric,泛型注入

  • • 多种类型注入

  • • 条件注入(Autofac 4.4+ 引入)

var builder = new ContainerBuilder();

// 1、RegisterType 类型注入
// 可以通过泛型的方式直接注册对应的类型
builder.RegisterType<ConsoleLogger>();
// 也可以通过 typeof 运算符得到对应的类型作为参数提供给 RegisterType 方法,这种方式在注册泛型类型时非常有用
builder.RegisterType(typeof(ConfigReader));
// 通过 UsingConstructor 指定【构造函数】中传递的类型,以确定使用与之对应参数的构造函数实例化对象
builder.RegisterType<MyComponent>()
       .UsingConstructor(typeof(ILogger), typeof(IConfigReader));

// 2、实例注入
// 预先得到一个实例
var output = new StringWriter();
// 通过 RegisterInstance 将实例注入到容器中,在通过容器获取 TextWriter 的实例时就会获得到 output 这个实例
builder.RegisterInstance(output).As<TextWriter>();
// Autofac会自己管理实例的生命周期,如果注册为瞬时的,那么这个实例在获取一次后就会被调用其对应的Dispose方法,
// 如果希望自己控制对象的生命周期,在注入时需要跟上 ExternallyOwned() 方法
builder.RegisterInstance(output)
       .As<TextWriter>()
       .ExternallyOwned(); // 使用 ExternallyOwned 方法告知 Autofac 这个被注入的实例对象的生命周期由自己掌控,不需要自动调用 Dispose 方法
// 将一个单例实例注入到容器中,其他被注入的对象就可以直接获取到这个单例,Autofac也不会释放这个单例
builder.RegisterInstance(MySingleton.Instance).ExternallyOwned();

// 3、Lambda 表达式注入
// 反射是一种很好的创建依赖注入的方式,但是有时候需要注入的对象并不是使用简单的无参构造函数实例化一个对象,
// 它还需要一些其他的参数或者动作来得到一个对应的实例,这时候可以使用Lambda表达式注入。

// 在容器中注入 A,但是 A 不是使用无参构造函数获得实例的,它使用从 Autofac 中取出的一个 B 对象实例作为参数,调用需要一个 B 对象实例的构造函数
builder.Register(c => new A(c.Resolve<B>()));
// 这里的c是一个IComponentContext对象,通过 IcomponentContext 对象可以从Autofac容器中解析出相应的对象,然后作为实参提供给A对象的构造函数

// Lambda表达式对复杂参数的注入非常有用
// 有时候构造函数并不是简单的一个固定参数,而可能是一个变化的情况,如果没有这种方式,可能就需要复杂的配置文件才能完成对应的功能
builder.Register(c => new UserSession(DateTime.Now.AddMinutes(30)));

// 4、Property 属性注入
// 相比上面直接在构造时给属性赋值,Autofac 有更优雅的属性注入。
// 给 A 实例的 MyB 属性赋一个从容器中解析出来的 B 实例
builder.Register(c => new A(){ MyB = c.ResolveOptional<B>() });

// 属性注入在以下情况特别有用
// 通过参数值选择实现能够提供一种运行时选择,它不仅仅是最开始时的参数决定的,这个参数在运行时也是可以改变以返回不同的实现
builder.Register<CreditCard>(
  (c, p) => {
      var accountId = p.Named<string>("accountId");
      if (accountId.StartsWith("9"))
      {
        return new GoldCard(accountId);
      }
      else
      {
        return new StandardCard(accountId);
      }
  });
// 此处推荐使用工厂模式,通过传入工厂委托的方式获取不同的实例。

// 5、RegisterGeneric 泛型注入
// 通常情况 IoC 容器都支持泛型注入,Autofac 支持以特别的语法强调特别的泛型,它的优先级比默认的泛型高,但是性能没有默认的泛型好,因为不能缓存。
// IoC 容器获取 IRepository<T> 类型的实例时容器会返回一个 NHibernateRepository<T> 实例
builder.RegisterGeneric(typeof(NHibernateRepository<>))
       .As(typeof(IRepository<>))
       .InstancePerLifetimeScope();

// 6、多种类型注入
// 有时候一个实例对应很多接口,通过不同的接口请求到的实例都是同一个实例,那么可以指定实例对应的类型
// 注意:要注册多个接口,那么具体类必须实现继承的这些接口。
// 通过 ILogger 和 ICallInterceptor 得到的都是 CallLogger 实例
builder.RegisterType<CallLogger>()
       .As<ILogger>()
       .As<ICallInterceptor>();

// 多种类型注入,涉及到多个服务注入的选择
// 如果一个类型进行了多个实例的注册,Autofac 默认以最后一次注入的为准
// 请求ILogger实例容器返回 ConsoleLogger 实例
builder.RegisterType<ConsoleLogger>().As<ILogger>();
// 请求 ILogger 实例容器返回 FileLogger 实例
builder.RegisterType<FileLogger>().As<ILogger>();
// 最终请求ILogger实例容器返回的是FileLogger实例,Autofac 默认以最后的为准

// 对于上面的情况,如果需要手动指定默认的,而不是使用最后一个,可以使用 PreserveExistingDefaults() 修饰
builder.RegisterType<ConsoleLogger>().As<ILogger>().PreserveExistingDefaults();
builder.RegisterType<FileLogger>().As<ILogger>(); // 此时该注入就无效了
// 最终请求 ILogger 实例容器返回的是 ConsoleLogger 实例,因为使用了PreserveExistingDefaults()修饰

// 7、条件注入
// 条件注入在 Autofac4.4 引入,使用 4.4 以后的版本可以使用
// 大多数情况下,如果一个类型注入了多个实现,使用PreserveExistingDefaults()手动指定就够了,但是有时候这还不够,这时候就可以使用条件注入

// 请求 IService 接口得到 ServiceA 
builder.RegisterType<ServiceA>()
       .As<IService>();
// 请求 IService 接口得到 ServiceB
builder.RegisterType<ServiceB>()
       .As<IService>()
       // 仅当 IService 没有注册过才会注册
       .IfNotRegistered(typeof(IService));
// 最后请求 IService 获得的实例是 ServiceA

builder.RegisterType<HandlerA>()
       .AsSelf()
       .As<IHandler>()
       // 注册 HandlerA 在 HandlerB 之前,所以检查会认为没有注册
       // 最后这条注册语句会成功执行
       .IfNotRegistered(typeof(HandlerB));
builder.RegisterType<HandlerB>()
       // 注册自己的类型,即 HandlerB
       .AsSelf()
       .As<IHandler>();
builder.RegisterType<HandlerC>()
       // 注册自己的类型,即 HandlerC
       .AsSelf()
       .As<IHandler>()
       // 不会执行,因为 HandlerB 已经注册了
       .IfNotRegistered(typeof(HandlerB));

// 注册 IManager
builder.RegisterType<Manager>()
       .As<IManager>()
      // 仅当IService和HandlerB都注册了对应服务时才会执行
       .OnlyIf(reg =>
         reg.IsRegistered(new TypedService(typeof(IService))) &&
         reg.IsRegistered(new TypedService(typeof(HandlerB))));

项目改造

说明:此处项目改造并非是在原来项目中改造,这里只是基于三层架构的理解,快速搭建一个基本的基础框架。这里尽量描述部分细节改造,注重思路循序渐进的理解。

此处环境基于 .net6 平台,项目准备工作或改造步骤:

  1. 1. 三层架构基础框架搭建(展示项目结构);

  2. 2. CommonHelper 层单例对象构造器创建;

  3. 3. DbHelper 层使用单例构造器封装 IFreeSql 对象;

  4. 4. WebAPI 层【Startup.cs + Program.cs】改造;

  5. 5. WebAPI 层 Swagger 配置使用;

  6. 6. WebAPI 层 Autofac 在 asp.net core 中的应用;

  7. 7. WebAPI 层请求入参的校验(FluentValidation);

  8. 8. 结构化日志记录 Serilog;

  9. 9. AutoMapper 的使用(DTO 数据模型转换);

在实际项目应用中,使用到的 nuget 依赖包可能不止这些(还有其他未列举的),依据项目实际使用情况引入,合理封装到对应项目层中使用即可。

说明:其中第 5、7、8、9 点此处不做详述,这些类库的使用自行查阅相关资料。

  • • FluentValidation 是一个非常流行的构建强类型验证规则的 .NET 库,这里不做讲述,可以参考该文章:https://www.cnblogs.com/lwqlun/p/10311945.html

  • • Serilog 是 .NET 中著名的结构化日志类库。

  • • Swagger 在 asp.net core 中默认集成 Swashbuckle.AspNetCore。

项目框架目录结构

依据架构图规划,搭建项目框架和依赖关系目录结构如下:

从三层架构说起,谈谈对历史项目的小改造
框架目录结构

CommonHelper 层

  • • 单例对象构造器,SingletonConstructor

namespace Jeff.Mes.Common;

/// <summary>
/// 单例对象构造器
/// </summary>
/// <typeparam name="T"></typeparam>
public class SingletonConstructor<T> where T : class, new()
{
    private static T? _Instance;
    private readonly static object _lockObj = new();

    /// <summary>
    /// 获取单例对象的实例
    /// </summary>
    /// <returns></returns>
    public static T GetInstance()
    {
        if (_Instance != null) return _Instance;
        lock (_lockObj)
        {
            if (_Instance == null)
            {
                var item = System.Activator.CreateInstance<T>();
                System.Threading.Interlocked.Exchange(ref _Instance, item);
            }
        }
        return _Instance;
    }
}

DbHelper 层

说明:该层可以使用工厂模式,封装支持更多的 DB 类型,此处主要是描述下 FreeSql 对象的单例模式构建。这里推荐下 FreeSql 轻量级 ORM,支持多种关系型 DB,切换数据源方便,基本保持一致的使用体验,遵循 MIT 协议开源,上手简单方便,性能也不差,测试用例覆盖较全面。

该层可以添加 dbconfig.json 配置文件,考虑相对安全,该文件配置的字符串连接信息,可以使用密文编码(比如:base64编码,或者采用其他加密方式生产的密文)。

• dbconfig.json 配置文件内容,此处依据自己的喜好定义,注意对敏感信息适当的安全考虑。

{
 "ConnectionStrings": {
    "Development": {
      "DbType": "SqlServer",
      "ConnectionString": ""
    },
    "Production": {
      "DbType": "SqlServer",
      "ConnectionString": ""
    }
  }
}
  • • 使用 SingletonConstructor 构建 FreeSqlHelper

using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using FreeSql;
using Jeff.Mes.Common;

namespace Jeff.Mes.DbHelper;

/// <summary>
/// 【Singleton 单例模式】构建 freesql 对象
/// </summary>
public sealed class FreeSqlHelper : SingletonConstructor<FreeSqlHelper>
{
    //连接字符串作为 key,存储构建的 IFreeSql 对象的字典集合
    private readonly static Dictionary<string, IFreeSql> _FreeDic = new();

    /// <summary>
    /// 构建 freesql 对象
    /// </summary>
    /// <param name="dbType">DB类型</param>
    /// <param name="connStr">连接字符串</param>
    /// <returns>IFreeSql</returns>
    public IFreeSql? FreeBuilder(string dbType, string connStr)
    {
        if (string.IsNullOrWhiteSpace(dbType) || string.IsNullOrWhiteSpace(connStr))
        {
            return default;
        }

        bool hasKey = _FreeDic.ContainsKey(connStr);
        if (hasKey)
        {
            return _FreeDic[connStr];
        }

        IFreeSql fsql;
        string myDbType = dbType.Contains('.') ? dbType.Substring(dbType.LastIndexOf('.') + 1) : dbType;
        switch (myDbType)
        {
            case "MySql":
                fsql = new FreeSqlBuilder()
                    .UseConnectionString(DataType.MySql, connStr)
                    .UseAutoSyncStructure(false) //自动同步实体结构到数据库
                    .Build(); //请务必定义成 Singleton 单例模式
                break;

            default:
                fsql = new FreeSqlBuilder()
                   .UseConnectionString(DataType.SqlServer, connStr)
                   .UseAutoSyncStructure(false) //自动同步实体结构到数据库
                   .Build(); //请务必定义成 Singleton 单例模式
                break;
        }

        bool isAdd = _FreeDic.TryAdd(connStr, fsql);
        if (isAdd)
        {
            return fsql;
        }
        else
        {
            fsql.Dispose();
            return _FreeDic[connStr];
        }
    }

    public IFreeSql? FreeBuilder(DataType dbType, string connStr) 
    {
        if (string.IsNullOrWhiteSpace(connStr))
        {
            return default;
        }

        /*
        bool hasKey = _FreeDic.ContainsKey(connStr);
        if (hasKey)
        {
            return _FreeDic[connStr];
        }*/

        bool isOk = _FreeDic.TryGetValue(connStr, out IFreeSql? fsql);
        if (isOk)
        {
            return fsql;
        }

        fsql = new FreeSqlBuilder()
                    .UseConnectionString(dbType, connStr)
                    .UseAutoSyncStructure(false) //自动同步实体结构到数据库
                    .Build(); //请务必定义成 Singleton 单例模式 

        bool isAdd = _FreeDic.TryAdd(connStr, fsql);
        if (isAdd)
        {
            return fsql;
        }
        else
        {
            fsql.Dispose();
            return _FreeDic[connStr];
        }
    }

    public (bool isOk, IFreeSql? fsql) GetFreeSql(DataType dbType, string connStr) 
    {
        bool isOk = _FreeDic.TryGetValue(connStr, out IFreeSql? fsql);
        if (!isOk) 
        {
           fsql = FreeBuilder(dbType, connStr);
           isOk = fsql != null;
        }
        return (isOk, fsql ?? default);
    }

    /// <summary>
    /// 反射获取【IDbContext】对象信息
    /// </summary>
    /// <typeparam name="T">IDbContext</typeparam>
    /// <param name="t">IDbContext</param>
    /// <returns>Dictionary(string,string)</returns>
    public static Dictionary<string, string> GetProperties<T>(T t) where T : class
    {
        Type type = t.GetType();
        var sb = new StringBuilder();
        foreach (PropertyInfo property in type.GetProperties().OrderBy(p => p.Name))
        {
            object? obj = property.GetValue(t, null);
            if (obj == null)
            {
                continue;
            }
            else
            {
                if (string.IsNullOrWhiteSpace(obj.ToString()))
                {
                    continue;
                }
            }
            sb.Append(property.Name + "=");

            if (property.PropertyType.IsGenericType)
            {
                var listVal = property.GetValue(t, null) as IEnumerable<object>;
                if (listVal == null) continue;
                foreach (var item in listVal)
                {
                    sb.Append(GetProperties(item));
                }
            }
            else if (property.PropertyType.IsArray)
            {
                var listVal = property.GetValue(t, null) as IEnumerable<object>;
                if (listVal == null) continue;
                foreach (var item in listVal)
                {
                    sb.Append(GetProperties(item));
                }
            }
            else
            {
                sb.Append(property.GetValue(t, null));
                sb.Append("&");
            }
        }

        var dic = new Dictionary<string, string>();
        var sbArray = sb.ToString().Trim('&').Split('&');
        foreach (var item in sbArray)
        {
            if (item.Contains('='))
            {
                int count = Regex.Matches(item, "=").Count;
                if (count <= 1)
                {
                    var itemArray = item.Split('=');
                    dic.Add(itemArray[0], itemArray[1]);
                }
                else
                {
                    int index = item.IndexOf('=');
                    string key = item.Substring(0, index);
                    string val = item.Substring(index + 1);
                    dic.Add(key, val);
                }
            }
        }
        return dic;
    }
}

在原始项目中解析 IDbContext 对象的方法就是 GetProperties() ,该方法借助【反射 + 递归】获取上下文对象,对于 FreeSql 对象实例而言,只需获取 db 的连接字符串信息即可快速使用,而 DB 字符串连接信息就存储于 IDbContext 对象中。

原始项目 Dal 层中预留的代码:

 public class OrderDal : BaseDal<OrderInfo>, IOrderDal
 {
     public OrderDal(IDbContext dbContext, ILoggerFactory loggerFactory) : base(dbContext, loggerFactory) 
     {}
 }

在项目改造之前,该 Dal 层中需要引入相应 DB 的 FreeSql 包,如下格式:

  • • FreeSql.Provider.Xxx(Xxx 是数据库类型名称)

添加 nuget 依赖包后,原始项目 Dal 层改造后的代码如下:

public class OrderDal : BaseDal<OrderInfo>, IOrderDal
{
    private readonly IDbContext _IDbContext;
    
    public OrderDal(IDbContext dbContext, ILoggerFactory loggerFactory) : base(dbContext, loggerFactory) 
    {
        _IDbContext = dbContext;
    }

    public (IDbContext dbContext, IFreeSql fsql) GetDbContext()
    {
        /*
        ((Xxx.Framework.Dal.EFDbContext)_IDbContext).ConnStr
        ((Microsoft.EntityFrameworkCore.DbContext)ss).Database.ProviderName
        */

        //- Database {Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade} 
        DatabaseFacade dbFacade = _IDbContext.GetProperty<Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade>("Database");
        var dic = FreeSqlHelper.GetProperties(_IDbContext);
        dic.Add("DbType", dbFacade.ProviderName);
        IFreeSql fsql = FreeSqlHelper.GetInstance().FreeBuilder(dic["DbType"], dic["ConnStr"]);

        return (_IDbContext, fsql);
    }
}

在 IOrderDal.cs 文件中添加如下代码:

public interface IOrderDal : IBaseDal<OrderInfo>,IDependency
{
    /// <summary>
    /// 获取 DB 上下文对象,并构建 IFreeSql 实例
    /// </summary>
    /// <returns></returns>
    public (IDbContext dbContext, IFreeSql fsql) GetDbContext();
}

在 BLL 层中方法中的调用,代码如下:

public class OrderBll : BaseBll<OrderInfo>, IOrderBll
{
    private readonly IOrderDal _orderDal;
    public OrderBll(IOrderDal orderDal)
    {
       _orderDal = orderDal; //构造函数注入 IOrderDal 
    }

    public (bool state, List<OrderInfo> orderInfos) Test()
    {
        var (dbContext, fsql) =  _ppsOrderDal.GetDbContext();
        // 此处获取到构建的 fsql 对象,即可使用。
    }
}  

有了 FreeSql 的引入,从此在 BLL层就可以方便的编写操作业务方法了,不再局限于框架内提供的方法。此处利于 Dal 层预留的 IDbContext 进行扩展,不修改上层项目的玩法,该怎么用还是怎么用。

WebApi 层

在 WebApi 中添加 Nuget 包:

  • • Autofac.Extensions.DependencyInjection v8.0.0

  • • Swashbuckle.AspNetCore v6.4.0

接下来逐步改造以下两点:

  • • MiniAPI 改造【Startup.cs + Program.cs】模式;

  • • 在 asp.net core 中使用 Autofac

首先新建 Startup.cs 文件,添加如下代码:

using Autofac;
using Autofac.Extensions.DependencyInjection;
using Jeff.Mes.WebApi.Modules;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Jeff.Mes.WebApi;

public class Startup
{
    public IConfiguration Configuration;
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    // Add services to the container. 注册服务到 Ioc 容器
    public void RegisterServices(IServiceCollection services, IHostBuilder host)
    {
        #region 在 host 中注册 Autofac
        host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
        host.ConfigureContainer<ContainerBuilder>(builder =>
        {
            builder.RegisterModule<AutofacModule>(); //此处编写相关服务&属性的注入 
        });
        services.Replace(ServiceDescriptor.Transient<IControllerActivator, ServiceBasedControllerActivator>());
        #endregion

        host.ConfigureAppConfiguration((hostContext, config) => {
            var env = hostContext.HostingEnvironment;
            string path = Path.Combine(env.ContentRootPath, "Configuration");
            config.SetBasePath(path)
                  .AddJsonFile(path: "appsettings.json", optional: false, reloadOnChange: true)
                  .AddJsonFile(path: $"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);
        });

        services.AddControllers();
        // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
        services.AddEndpointsApiExplorer();
        services.AddSwaggerGen();
    }

    // Configure the HTTP request pipeline. 配置 HTTP 请求管道(中间件管道即中间件委托链)
    public void SetupMiddlewares(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseSwagger();
            app.UseSwaggerUI();
        }
        
        app.UseRouting();
        app.UseAuthorization();

        app.UseEndpoints(endpoints => {
            endpoints.MapControllers();
            endpoints.MapGet("/env", async context =>
            {
                // 获取环境变量信息
                await context.Response.WriteAsync(
                    $"EnvironmentName:{env.EnvironmentName},IsDevelopment:{env.IsDevelopment()}"
                );
            });
        });
    }
}

其次修改 Program.cs 文件中的代码,修改如下:

namespace Jeff.Mes.WebApi;

public class Program
{
    public static async Task Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        var startup = new Startup(builder.Configuration);

        var services = builder.Services;
        var host =  builder.Host;
        startup.RegisterServices(services, host);

        var app = builder.Build();
        var env = builder.Environment;
        startup.SetupMiddlewares(app, env);

        await app.RunAsync();
    }
}

接下来在项目中新建 Modules 文件夹,分别存放如下文件:

  • • AutowiredAttribute.cs,该文件用于标记属性注入的特性,和 AutowiredPropertySelector.cs 文件搭配使用;

  • • AutowiredPropertySelector.cs,该文件主要用于自定义属性的选择器;

  • • AutofacModule.cs,该文件主要定义 Autofac 中业务类的注入方式;

此处主要描述 Autofac 在 asp.net core 项目中的使用,同时还自定义了特性标记的属性注入模式。

首先新建 Modules 文件夹,用于存放上面的 .cs 文件,各文件的完整代码分别如下:

1、AutowiredAttribute.cs 文件代码:

namespace Jeff.Mes.WebApi.Modules;

[AttributeUsage(AttributeTargets.Property)]
public class AutowiredAttribute : Attribute
{ }

2、AutowiredPropertySelector.cs 文件代码:

using Autofac.Core;
using System.Reflection;

namespace Jeff.Mes.WebApi.Modules;

public class AutowiredPropertySelector : IPropertySelector
{ 
    // 属性注入
    public bool InjectProperty(PropertyInfo propertyInfo, object instance)
    {
        // 自定义属性特性标记 [Autowired] 的才生效
        return propertyInfo.CustomAttributes.Any(it => it.AttributeType == typeof(AutowiredAttribute));
    }
}

3、AutofacModule.cs 文件代码:

using Autofac;
using Jeff.Mes.Bll.BLL;
using Jeff.Mes.Bll.IBLL;
using Microsoft.AspNetCore.Mvc;

namespace Jeff.Mes.WebApi.Modules;

public class AutofacModule : Module
{
    protected override void Load(ContainerBuilder builder)
    {
        // The generic ILogger<TCategoryName> service was added to the ServiceCollection by ASP.NET Core.
        // It was then registered with Autofac using the Populate method. All of this starts
        // with the `UseServiceProviderFactory(new AutofacServiceProviderFactory())` that happens in Program and registers Autofac
        // as the service provider.

        #region 此处编写服务(BaseServiceRegister)的注册规则
        // ValuesService 构造函数有参
        builder.Register(c => new ValuesService(c.Resolve<ILogger<ValuesService>>(), c.Resolve<IConfiguration>()))
            .As<IValuesService>()
            .InstancePerLifetimeScope();

        /*
        // ValuesService 构造函数无参
        builder.Register<ValuesService>().As<IValuesService>(); //不声明生命周期默认是瞬态。
        */

        builder.Register(c => new ValuesService(c.Resolve<ILogger<ValuesService>>(), c.Resolve<IConfiguration>()))
          .As<IValuesService>()
          .InstancePerLifetimeScope();

        builder.RegisterType<UserService>()
            .As<IUserService>()
            .InstancePerLifetimeScope();
        builder.RegisterType<UserService>()
            .As<IUserService>()
            .PropertiesAutowired()
            .InstancePerLifetimeScope();
        builder.Register(c => new UserService())
            .As<IUserService>()
            .PropertiesAutowired()
            .InstancePerLifetimeScope();

        //builder.RegisterType<OrdersService>().As<IOrdersService>().InstancePerLifetimeScope();
        builder.Register(c => new OrdersService(c.Resolve<ILogger<OrdersService>>(), c.Resolve<IConfiguration>()))
          .As<IOrdersService>()
          .InstancePerLifetimeScope();

        #endregion

        #region 此处编写属性(PropertiesAutowired)的注册规则
        var controllerBaseType = typeof(ControllerBase);

        // 说明:以下 1、2 两种方式等效。

        // 1、获取所有控制器类型并使用属性注入
        var controllersTypesInAssemblyAll = typeof(Startup).Assembly.GetExportedTypes()
            .Where(type => typeof(ControllerBase).IsAssignableFrom(type)).ToArray();
        builder.RegisterTypes(controllersTypesInAssemblyAll).PropertiesAutowired();

        // 2、自定义特性并使用属性注入
        /*
        builder.RegisterAssemblyTypes(typeof(Program).Assembly)
            .Where(type => controllerBaseType.IsAssignableFrom(type) && type != controllerBaseType)
            .PropertiesAutowired(new AutowiredPropertySelector());
        */

        // 2.1 从 Startup 程序集中筛选出 ControllerBase 派生类程序集(ControllerBase 类中的程序集,并且排除本身 ControllerBase)
        var controllersTypesInAssembly = typeof(Startup).Assembly.GetExportedTypes()
            .Where(type => controllerBaseType.IsAssignableFrom(type) && type != controllerBaseType).ToArray();
        // 2.2 注册筛选出的 ControllerBase 派生类的程序集,使用自定义特性标注的属性注入
        builder.RegisterTypes(controllersTypesInAssembly)
             .PropertiesAutowired(new AutowiredPropertySelector());
        #endregion
    }
}

接下来(习惯性)新增一个 Configuration 文件夹,用于存放 appsettings.json 相关配置文件。因为上面的项目中我们在 host.ConfigureAppConfiguration 方法中调整了文件路径。

其他项目层没啥好讲的,从架构图中即可看出对应的功能职责,到这里遵循架构图规则搭建的基础项目结构就基本完成了,该文章主要目的是相对于原始项目做一个对照,重新梳理了一遍经典的三层架构,方便初学人员有一个基础的模型参照。

总结

在项目实战开发中,理解三层架构并遵循该架构合理化的搭建项目基础框架是必要保障,而不是 DAL 层项目去依赖 BLL 层项目(ಥ_ಥ ),每一层尽量做到职责分明,各司其职,各个项目层之间协调配合,项目层之间的调用应该依赖抽象而非具体实现,该项目框架的基本雏形就搭建完毕了,有了良好的地基,里面很多细节的装修就相对方便了,比如:很多 nuget 依赖包的使用,自行查看相关文档。该文章的目的是提供一个基本的模型参照,理解思路然后逐步按需完善部分框架细节,并应用到实践中才会有更深的体会和记忆。