ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ABP框架集成ASP.NET Core:模块化、依赖注入与生命周期实践

ABP框架集成ASP.NET Core:模块化、依赖注入与生命周期实践 1. 为什么说模块系统是ABP接入ASP.NET Core的命脉我这几年处理过不少历史包袱很重的.NET项目其中有一个典型的单体应用六七个业务子系统挤在一个ASP.NET Core Web项目里Startup.cs膨胀到700多行各种中间件、服务注册、配置读取全部堆在里面。后来我们决定引入ABP框架做重构第一件事就是把模块化这件事弄明白。ABP框架和ASP.NET Core之间最核心的粘合剂就是集成模块机制。你可以把ABP的每个模块想象成一个独立的自助式服务舱它自己声明需要什么依赖、自己注册自己的服务、自己配置自己的管道、自己安排启动和关闭时要执行的动作。而ASP.NET Core原本提供的Startup.cs本质上是一个单点的引导入口所有业务系统都要在这个入口里挤一圈。ABP的模块系统做的事情就是把这个单点入口拆解成按业务边界划分的若干小入口再按依赖关系有序拼装起来。1.1 一个老.NET开发者第一次接触ABP时的困惑我第一次看ABP的代码时最不习惯的就是找不到一个标准的Startup了。Program.cs里就是标准的两三行public class Program { public static void Main(string[] args) { CreateHostBuilder(args).Build().Run(); } public static IHostBuilder CreateHostBuilder(string[] args) Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder { webBuilder.UseStartupStartup(); }); }看起来跟普通ASP.NET Core项目没区别但点进Startup就变了public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddApplicationMyProjectWebModule(); } public void Configure(IApplicationBuilder app) { app.InitializeApplication(); } }真正的逻辑全部下沉到了MyProjectWebModule这个模块类里Startup只是一个瘦到不能再瘦的传声筒。这种设计初看多此一举但当你的项目里有十几个模块各自需要注册DbContext、配置AutoMapper、添加自定义中间件、注册后台任务时你就会意识到它的价值每个模块的ConfigureServices只服务自己模块之间通过DependsOn显式声明依赖既不会漏初始化也不会互相踩踏。1.2 模块与ASP.NET Core启动管线的映射关系ABP模块系统和ASP.NET Core不是两套独立的东西而是同一套启动管线上的两层视角。ABP把Startup的服务注册和管道配置两件事拆成了模块内的方法调用ASP.NET Core生命节点ABP模块对应方法常用场景ConfigureServices(IServiceCollection)ConfigureServices(ServiceConfigurationContext)注册服务、配置选项、添加DbContext管道构建前OnPreApplicationInitialization()配置尚未完全就绪时的前置准备例如注册自定义约束管道构建中OnApplicationInitialization()添加中间件、初始化数据库种子数据、映射端点管道构建后OnPostApplicationInitialization()通知第三方系统、启动后台轮询应用关闭OnApplicationShutdown()释放资源、持久化缓存、记录关闭日志这个映射关系一旦吃透你在任何模块里都能快速定位这件事应该放在哪个方法里写。我见过很多人把数据库种子逻辑塞在ConfigureServices里结果运行时报错还找不到原因——因为ConfigureServices阶段IServiceProvider还没完全构建根本不该去解析服务实例。2. 模块依赖链DependsOn是怎么决定启动顺序的ABP的模块集成有一个很容易被忽略但极其重要的设计模块的启动顺序不是你写在代码里的先后顺序而是通过DependsOn特性递归推导出来的。看个简单例子[DependsOn(typeof(AbpAspNetCoreModule))] public class MyApplicationModule : AbpModule { }当ABP启动时它会先去检查AbpAspNetCoreModule如果它还有自己的依赖就继续递归直到把所有依赖模块按依赖深度排好序再逐个执行它们的生命周期方法。这个机制保证了被依赖的模块永远先初始化永远不会出现A模块要用B模块的服务但B还没注册这种问题。2.1 模块依赖的传递性与隐性依赖这里我强调一个经验依赖是会传递的但ABP不会替你传递多余的引用。假设你的Application模块依赖了Domain模块Web模块依赖了Application模块那么Web模块的依赖集合里其实已经包含Domain的能力了。所以你在写[DependsOn]时只需要声明直接依赖就够了不用把整条依赖链手写一遍。不过在实际项目里我发现很多人为了保险会写上多余的依赖这本身不报错但会让模块加载图变得混乱排查问题时容易产生误导。另一个容易被忽略的点是DependsOn里声明的模块必须是你项目实际引用的程序集。如果程序集引用没加编译期不一定报错因为特性传的是Type但运行时会直接抛出模块加载异常而且报错信息比较隐晦类似An error occurred during the initialization of the module这类。遇到这种问题先检查程序集引用和模块类上的DependsOn是否一一对应是最快的定位路径。2.2 模块间的服务覆盖规则既然有依赖链就存在同一个服务被多个模块注册的情况。ABP默认的注册规则是后注册覆盖先注册这个顺序严格依赖模块初始化顺序。举个例子如果ModuleB依赖ModuleA两个模块都注册了IFooService的不同实现那么ModuleB的实现会胜出。这给我们用集成模块替换默认实现提供了入口。我做多租户改造的时候就是用这种方式创建了一个自定义模块在依赖默认模块之后重新注册了租户解析器避免了修改框架源码。但这里务必记得覆盖注册和依赖注入的TryAdd语义不同ABP用的是普通Add所以顺序就是胜负手改动模块依赖关系时一定要留意服务覆盖的连锁反应。3. 集成ASP.NET Core MVC/API时模块系统做了什么手脚ABP对ASP.NET Core MVC的集成是很多人真正感受到框架威力的地方。但它不是把MVC原封不动地拿过来而是做了一套约定式改造。理解这些改造才能在自定义时不被绕晕。3.1 应用服务自动变成API控制器ABP把应用服务Application Service自动暴露为API接口。你写一个这样的服务public interface IBookAppService : IApplicationService { TaskListBookDto GetListAsync(); } public class BookAppService : ApplicationService, IBookAppService { public TaskListBookDto GetListAsync() { // ... } }模块初始化后客户端就可以直接GET /api/app/book拿到数据。这个能力的实现原理是ABP在OnApplicationInitialization阶段向ASP.NET Core的MVC管线注册了约定式控制器conventional controllers它会扫描继承了IApplicationService接口的类型把它们动态包装成API控制器并生成路由。这里有个关键点动态API的约定不是强行的。如果某个应用服务加了[RemoteService(false)]它就会被排除在自动API之外完全作为本地服务使用。我在一个混合架构项目里就是靠这个特性让部分服务保持内部调用、不暴露接口而另外的服务作为开放API给前端用。这个RemoteService特性还可以细化控制应用到方法级别。3.2 请求管线的四件套异常过滤、审计、结果包装、校验集成模块默认会在MVC管线上挂几类过滤器异常过滤所有未捕获异常被统一转成RemoteServiceErrorResponse格式返回不再裸抛异常堆栈。审计日志自动记录请求的method、url、execution time、parameters默认对GET以外的方法记录参数。结果包装Controller返回的裸对象被包装成{ success: true, result: {...}, errors: null }这种统一格式方便前端统一处理。参数校验自动触发DataAnnotation和自定义验证器的校验。这四件套本身是好东西前提是你要知道它们存在且可配置。我遇到过最典型的事故项目里引入ABP后原有MVC页面Controller返回的PartialViewResult突然多了一层包装页面渲染全乱了。原因就是默认结果包装对非API路由也生效了。解法有二要么在Controller上加[RemoteService(false)]要么在模块里通过ConventionalControllers的选项配置只对特定前缀的路由启用。3.3 与ASP.NET Core MVC PDF导出的融合问题拿今年很多人问的ASP.NET Core MVC里怎么做PDF导出举例ABP集成模块在这里有几个微妙约束值得注意。在传统MVC项目中PDF导出无非就是在Controller里生成一个PDF文件并返回FileResult。但在ABP里如果你在App Service里做了一个导出方法public async Taskbyte[] ExportBooksPdfAsync() { var content await _bookManager.GetExportContentAsync(); return _pdfRenderer.Render(content); }前端调用时得到的不是文件而是被包装过的{ result: JVBERi0xLjQ... }这样一串Base64字节码。不是说不能处理而是当前端期望的是直接下载时这种交互就要专门设计。我后来的做法是单独建一个ExportController让它继承AbpController在这个Controller里直接返回FileResult同时给方法加[RemoteService(false)]让ABP不参与包装逻辑。这样既保留了模块内服务的注入能力又不破坏文件下载体验。如果你希望保持App Service的通用性也可以用流式响应或者自定义结果过滤器来统一处理。但我的建议是导出文件这类非JSON响应尽量不要走约定式API单独开一个Controller反而更可控。ABP集成模块的设计并不是要让你抛弃ASP.NET Core MVC的任何能力而是要你懂得在每个模块边界上做取舍。4. 业务能力落进模块PDF导出模块的完整集成记录这一节我把前面提到的PDF导出从零到一、完完整整走一遍演示一个业务模块是怎么拆、怎么挂、怎么和ASP.NET Core的MVC底板协同工作。4.1 先按ABP套路拆层ABP推荐的模块拆分方式是一个业务能力可以拆成Domain领域逻辑、Application应用服务、Web/API接口暴露三个模块。我搞PDF导出时建立的是Books.Domain、Books.Application、Books.Web三个模块项目。Books.Application模块依赖Books.Domain同时声明了对PDF渲染库的依赖[DependsOn( typeof(BooksDomainModule), typeof(AbpAspNetCoreModule) )] public class BooksApplicationModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddScopedIPdfRenderer, QuestPdfRenderer(); } }这里把IPdfRenderer抽象在Application模块里具体实现我用的QuestPDF也在这一层注册。之所以不把渲染实现下沉到Domain层是因为PDF渲染属于偏应用的技术细节不是核心领域逻辑。这个分层原则帮助我在后续替换渲染库时不用碰Domain层任何代码。4.2 依赖注入与拦截器的边界条件ABP对类库里的服务方法和App Service方法默认会启用动态代理从而让UnitOfWork、审计、权限等拦截逻辑自动生效。但动态代理依赖继承和virtual方法这对第三方库类比如PDF渲染器通常不成立因为第三方类经常是sealed或者方法非虚。实测下来把PDF渲染器注册为IPdfRenderer后我在App Service里直接调用它是没有问题的但如果想在这个渲染器内部也实现UnitOfWork自动回滚那就不行了——它不是被ABP管理生命周期的应用服务ABP不会为它生成代理。这个边界想清楚能省掉很多排查时间。4.3 在Web模块里注册自定义路由Books.Web模块负责把MVC路由和静态文件资源接入主项目。它的核心代码通常长这样public class BooksWebModule : AbpModule { public override void OnApplicationInitialization(ApplicationInitializationContext context) { var app context.GetApplicationBuilder(); var env context.GetEnvironment(); app.UseAuthentication(); app.UseAuthorization(); app.UseConfiguredEndpoints(endpoints { endpoints.MapControllerRoute(default, {controllerHome}/{actionIndex}/{id?}); }); } }这个UseConfiguredEndpoints会兼容ABP自己注册的约定式API路由。如果模块里还有自定义的MVC控制器只要它们能被ApplicationPart正常发现在这个阶段都会被挂上。遇到过的问题Books.Web项目里的Controller没被主Web项目引用导致UseConfiguredEndpoints后Controller无法访问报404。解决方法是把Books.Web的主程序集加入ApplicationPart或直接让主项目引用该模块并声明DependsOn。5. 数据层集成UnitOfWork与EF Core的隐藏联动ABP集成ASP.NET Core的过程中另一块重量级能力是数据访问的集成。它默认使用EF Core但又是在EF Core之上做了一层UnitOfWork包装。不理解这层包装事务就很容易出莫名问题。5.1 一个请求一个工作单元ABP的UnitOfWork默认和一个ASP.NET Core请求绑定。你进入某个App Service方法时ABP会在方法执行前开启工作单元在方法正常结束时提交事务在抛异常时自动回滚。这看起来就像传统三层架构里的一个业务方法一个事务但ABP的巧妙在于它是通过拦截器实现的所以即使你的服务方法里什么都没写开启-提交/回滚的流程也会自动发生。在EF Core的集成模块里ABP把SaveChanges的调用也托管给了UnitOfWork。默认策略是工作单元结束时自动调用SaveChangesAsync。同一个请求内的多次数据库操作共享同一个DbContext实例。这个策略的效果是你在多个服务方法里调用多个仓储它们不会各自开启连接而是在同一个连接和事务里工作。对性能提升和一致性保障都很明显。5.2 模块里的DbContext注册方法EF Core集成模块的典型写法是[DependsOn(typeof(AbpEntityFrameworkCoreModule))] public class MyEntityFrameworkCoreModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddAbpDbContextMyDbContext(options { options.AddDefaultRepositories(); }); context.Services.AddDbContextMyDbContext(options { options.UseSqlServer( context.Services.GetConnectionString(Default) ); }); } }注意AddAbpDbContext和AddDbContext都会注册同一个MyDbContext但职责完全不同。前者负责给ABP的仓储系统提供上下文访问入口后者是标准的EF Core注册方法负责真正的连接配置。二者缺一不可。我踩过的一个低级坑是在ConfigureServices里用Configuration.GetConnectionString直接读连接串但换成ABP的context.Services.GetConnectionString(Default)才发现有多数据源、连接字符串加密解密等额外处理。ABP的GetConnectionString扩展方法会走配置系统的那套完整链路这也是集成模块帮你兜底的地方。5.3 事务边界为什么会意外扩大有个细节特别容易让人困惑UnitOfWork默认会在进入第一个受管服务方法时开启。如果你在某个Controller Action里直接调用仓储而不通过App ServiceABP会为整个Action也开启一个UnitOfWork。所以会出现这种情况你只是在一个GET请求里做了查询日志里却显示开了事务。这个问题严格来说不算Bug但会让行为不符合直觉。想精确控制事务边界就用IUnitOfWorkManager显式开启using (var uow _unitOfWorkManager.Begin()) { await _bookRepository.InsertAsync(book); await uow.CompleteAsync(); }在复杂业务里显式控制工作单元比依赖隐式规则更稳妥。尤其是你需要在同一个事务里协调两个不同模块的仓储时隐式规则几乎不可控显式声明才是正解。6. 集成过程中的三起真实事故与排查链路最后分享三个我在实际项目中遇到的ABP与ASP.NET Core集成事故。每一个都不是框架的Bug而是对集成模块理解不到位导致的梳理出来给大家做个对照参考。6.1 事故一模块依赖缺失导致的404不是路由问题某次升级我新增了一个Books.Web模块把它加进了主模块的DependsOn但忘了把Books.Web.csproj的项目引用加进去。编译没任何问题运行起来页面能打开但所有书籍相关API全部404。当时的排查链路很费时间先查了路由配置发现没问题又查了Controller的程序集加载发现Books.Web的程序集根本没有被MVC的ApplicationPart扫到。最后回头检查依赖才发现DependsOn里声明的类型虽然编译通过了但运行时程序集并未被加载模块的初始化步骤压根没执行。这个事故之后我给自己定了一条规矩任何模块变更都先检查项目引用和DependsOn是否成对出现。少一个后面全是诡异问题。6.2 事故二方法没声明virtualUnitOfWork静默失效有一次业务反馈保存数据偶尔不完整我查了很久发现是一个服务方法在循环里调用仓储插入了两条数据第一条成功、第二条抛异常但数据库里第一条居然还在。按ABP的UnitOfWork规则应该整体回滚才对。仔细排查才发现这个方法是从别的类通过接口调用的不假但方法本身没有标virtual。C#的代理机制对非虚方法无法拦截ABP的UnitOfWork拦截器根本没被触发于是数据库操作变成了自动提交模式。修复方法就是在方法上加上public virtual修饰。这也是为什么ABP官方文档一直强调public virtual方法不是随口说说的约定而是代理拦截的硬性要求。如果你的App Service方法不打算被拦截就明确接受它不受UnitOfWork管理这个后果。6.3 事故三换PDF库时动态API跟着消失前面提过我在导出PDF时把渲染器实现从旧库换成了QuestPDF。替换完本地调试一切正常但部署到测试环境后导出接口直接404。查了很久发现测试环境没有编译Books.Application模块的依赖换了新库之后模块加载顺序变了某个依赖模块被跳过了初始化导致约定式API路由没注册上。这个案子的教训是模块对第三方库的依赖变更不仅是依赖注入的问题还可能影响模块依赖链的解析。升级第三方库、新增依赖时必须把模块加载日志打开确认所有DependsOn声明的模块都正常初始化再放行测试。7. 模块集成的设计取舍与我的个人体会做了几个ABP项目之后我对集成模块的理解早就超越了把类放对位置这个层面。它本质上是一套针对ASP.NET Core应用的可组合架构方案每个模块既是一个独立的服务容器也是一个独立的管道片段同时还是一个独立的应用程序集。我个人的选择倾向是这样的如果项目只有一两个业务模块且不会扩展用不用ABP都无所谓普通ASP.NET Core的Startup结构完全够用。如果项目有多个业务域、需要租户化、需要后台任务、需要审计和权限体系ABP集成模块的收益会非常明显。如果你主导的团队里成员水平参差不齐ABP的约定式API、自动注册、自动事务能减少大量重复代码和人为失误。最后分享一个团队落地时的操作建议初用ABP不要一上来就自定义各种模块行为先按官方默认约定跑通一个完整请求——从浏览器请求到数据库返回——再逐步替换和定制。我见过太多人第一周就自定义了一堆模块管线和过滤器结果排查问题时连官方行为都捋不清。框架集成这种事先把默认链路吃透再谈个性改造才是稳的路子。
返回列表