ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

手写ORM与ASP.NET Core WebAPI框架实战:从动态查询到中间件设计

2026/9/30 3:25:46 拓冰建站 浏览量
手写ORM与ASP.NET Core WebAPI框架实战:从动态查询到中间件设计 先说个结论手写ORM不是头脑发热而是被业务逼出来的。我之前接触过一个查询条件极其自由的后台系统用户能在界面上随意拼装筛选条件、排序规则和分组维度用EF Core的表达式树去翻译这种动态查询写起来非常痛苦每次都要和各种内部API搏斗。最后我索性在基于ASP.NET Core 5的项目里自己写了一个WebAPI框架ORM和依赖注入都自己实现算是把整个请求链路完全握在自己手里。这篇文章就把这套框架的构建思路、核心原理和踩坑过程完整梳理一遍代码示例以.NET 5时代的写法为主但思路和坑在任何版本的ASP.NET Core里都适用。适合谁看想了解手写ORM到底要解决什么问题、想搞清楚中间件和依赖注入在框架里是怎么配合运转的、或者被EF Core的动态查询折磨过的人都可以读下去。整篇东西不保证代码能直接复制粘贴就跑但能把“为什么要这么设计”和“实际运行时发生了什么”讲明白。1. 为什么我放着EF Core和Dapper不用非要自己动手写ORM这个话题每次一提起就会被质疑社区里成熟的ORM一抓一大把自己写一个不是重复造轮子吗我承认大部分场景下这种质疑是对的但也有一部分场景标准ORM的抽象反而成了负担。先把我当时的处境摆出来。1.1 EF Core的动态查询痛点到底有多痛EF Core本身是非常优秀的ORM但它的设计目标是“让开发者尽量少写SQL”为了达到这个目标它引入了巨大的表达式树翻译层。你写一句Where(x x.CategoryId id)背后要把lambda表达式拆开、解析成员访问、匹配数据库函数、生成参数化SQL这一套流程在日常CRUD里非常顺手。问题出在动态查询上。当查询条件由用户在页面上勾选、组合、甚至自定义字段名时你需要动态拼装表达式树。Expression.Equal(propertyExpr, constantExpr)这种写法偶尔用几次没问题但当你需要它支持几十个字段的任意组合、支持动态排序、支持分组后聚合时表达式树代码就会膨胀到难以维护。而且运行时的表达式树拼接很难提前发现问题往往是用户点了一下某个组合条件直接给你抛出一个“无法翻译”的异常这时你根本不知道是哪种字段类型、哪种运算符触发的。动态拼接正好撞在表达式树翻译的软肋上。调试这种问题你需要开编译日志去看EF到底生成了什么SQL然后再回头对照表达式树的构造过程效率极低。1.2 业务层的真实需求可控的SQL生成与细粒度缓存我当时的业务场景里有大量的报表类查询逻辑上需要动态拼SQL同时还要对查询结果做细粒度缓存。用EF Core当然也能做到比如FromSqlRaw配合模型或者直接拿RelationalCommandCache去操作底层命令但这些都是绕过EF的主路径相当于把一个复杂的系统切开只用它的一点边角料还要承受它主路径的额外开销。另一个被忽视的问题是批量操作。EF Core里做批量更新的常规方案是先查出来再逐条修改最后SaveChanges统一提交这个过程的快慢取决于变更跟踪器要处理的实体数量。对于需要定期执行夜间批处理的应用这个模式就显得过于啰嗦。我需要直接根据条件执行UPDATE返回受影响行数不想先把数据加载到内存再逐条比对。Dapper相比之下轻量得多SQL基本由你自己控制但它也仅仅是一个查询器没有实体映射缓存的缓存策略、没有完整的业务模式支撑该写SQL还是得写只是省了DataReader到对象的赋值代码。对于当时既要精细控制又不想完全裸写Ado.Net的项目来说我决定沿着Dapper的思路再往前走一步自己掌控映射和SQL生成的完整链路。1.3 手写ORM之前必须先想清楚的取舍如果你也想走这条路先别急着动手问自己三个问题。第一这个框架是给自己团队用还是准备开源出去只给自己用可以省略很多兼容性考虑比如不需要为MySQL、PostgreSQL、SQL Server同时维护方言只盯死一种数据库就行。我的项目当时就是固定SQL Server省了很多心。第二你能接受多少维护成本手写ORM不是一次性工作数据库版本升级、新功能需求出现、性能问题暴露都需要你来修。我的经验是前期最耗时的部分不是SQL生成而是缓存策略和并发安全。实体映射的元数据需要缓存查询命令的缓存需要处理参数化问题这些细节会吃掉大量业余时间。第三团队其他人能不能接手如果你的同事全都只会EF Core你辛辛苦苦写出一套私有ORM人家接手时第一反应大概率不是“哇好厉害”而是“这什么东西我要怎么改”。我之所以能推进下去是因为这个项目的技术决策由我做主且我有充足时间文档化。2. WebAPI框架的整体分层与启动链路设计定了手写ORM的基调之后整个WebAPI框架的骨架也顺势确定下来。我追求的不是最全的框架而是每个环节都能看穿、能改动的框架。实际项目里我采用了比较经典的四层结构再在API层之上挂了中间件管道。2.1 项目结构与请求流转路径整个解决方案包含四个主要项目对外暴露REST接口的API层负责参数校验、鉴权、请求与响应的结构统一承载业务规则的服务层不关心HTTP细节只处理业务对象的输入输出定义实体、仓储接口和核心业务对象的领域层实现仓储、DbContext和ORM核心的基础设施层。请求进来之后管道路径是中间件管道 控制器 参数模型绑定 应用服务 领域逻辑 基础设施中的仓储实现。因为依赖注入了容器所以这里的层与层之间全部通过接口解耦控制器只依赖服务接口服务只依赖仓储接口完全符合常规分层思想。2.2 自定义中间件管道的注册顺序为什么重要ASP.NET Core的请求管道本质是中间件的一个线性链表注册顺序决定执行顺序这算是最常被忽视的坑。我在框架中按下面这个顺序注册了一系列中间件异常捕获中间件放在最外层保证任何内层抛出的异常都能被统一处理并转化为符合约定的JSON错误响应请求日志中间件记录每次请求的路径、方法、耗时和状态码放在异常件之后是为了能捕获到异常响应认证与授权中间件确保只有携带合法Token的请求才能进入控制器API路由中间件负责把请求映射到对应的控制器ActionSwagger中间件只在开发或开启了Swagger开关的环境中启用。这样排列的背后逻辑是请求异常和日志信息必须在任何业务逻辑执行之前就被“包裹住”否则一旦控制器里抛出异常错误信息到不了统一处理的地方。另外认证放在路由之后其实也可以但放在路由之前的好处是对于没有匹配到任何路由的请求也走一遍认证再返回404避免暴露“这个路径到底存不存在”的信息。2.3 启动时的配置与注册代码结构启动代码我分成了两个文件一个负责配置服务容器一个负责组装中间件管道。网上很多教程把两件事混在一起写项目一复杂就乱。我习惯于严格区分这两个阶段并且每个注册方法都要求有明显前缀例如ConfigureAuthentication、ConfigureCors、ConfigureSwagger、ConfigureOrm。这样一眼能看出容器里有什么。Swagger的注册我尤其强调顺序。很多人的Swagger页面可以打开但一点接口就报not found /swagger/v1/swagger.json十有八九是把UseSwagger和UseSwaggerUI放在了错误的位置或者被路由前缀干扰了。这一点在后面的踩坑章节会专门展开。3. 手写ORM的核心原理从实体映射到SQL生成这一章节是整个框架的心脏。ORM的实质可以归纳为两个过程一是把实体对象变成表格行列的持久化表现二是把查询条件和操作意图变成SQL文本。所有手写ORM的复杂度都集中在这两个过程的边界条件上。3.1 实体的元数据映射与缓存手写ORM第一步就是定义实体的元数据映射方式。我用Attribute标注来完成基础映射[Table(dbo.OrderInfo)] public class Order { [Key] [Column(OrderId)] public int OrderId { get; set; } [Column(CustomerName)] public string CustomerName { get; set; } [IgnoreColumn] public string VirtualField { get; set; } }实体加载时ORM核心会通过反射扫描实体的公共属性把表名、列名、主键、可空性、数据类型等信息全部缓存到一个元数据字典里。这一部分不做缓存的话性能会非常难看。每次查询都做一次反射扫描在高并发下完全不可接受。我只在程序启动时、第一次访问某个实体时构建这个元数据后续全部从ConcurrentDictionary读取。这里有一个细节值得提醒属性名到列名的映射推荐显式区分“实体属性”和“数据库列名”。如果不区分一旦数据库里某列用了带下划线的命名风格而代码里用的是PascalCase每次手工对账都是灾难。显式映射虽然写起来啰嗦但可以彻底消除歧义。3.2 查询对象让动态条件变成结构化数据为了支持动态查询我设计了一个查询对象用于承载查询参数而不是让用户直接拼SQL字符串var query new QueryObjectOrder() .Where(x x.CustomerId customerId) .Where(x x.Status 1) .OrderBy(x x.CreateTime, desc: true) .Page(pageIndex, pageSize); var list _orderRepository.QueryList(query);QueryObject内部存放一个表达式条件和参数列表的集合。执行查询时ORM把这些表达式逐条翻译成SQL片段同时把所有参数值存到参数列表里最终交给ADO.NET执行时统一参数化。这种设计的好处在于业务层永远不接触SQL字符串安全性有了保障同时翻译逻辑只集中在一个地方排查动态查询的表现如何只需要看查询对象翻译器。3.3 SQL生成中的参数化与分页安全性翻译表达式成SQL核心工作是遍历表达式树。比如x x.CustomerId customerId等价于[CustomerId] p0并添加一个值为customerId的DbParameter。分页部分我直接针对SQL Server做了优化。数据库为SQL Server时分页SQL用ROW_NUMBER() OVER (ORDER BY ...)实现但我会先判断是否有明确的排序字段。没有排序字段的分页是逻辑不严谨的因为数据库返回顺序本身不确定分页结果可能是重复或缺失别把这个问题留给ORM使用者自己发现。参数化是所有SQL生成的红线。任何拼接进SQL的字符串如果来自用户输入并且没有经过参数化就是在给SQL注入留后门。我在设计查询对象时就强制所有比较值必须走参数列表根本没有直接拼接字符串的入口从结构上杜绝了最危险的写法。3.4 状态跟踪与事务的设计策略手写ORM很容易把EF Core里的状态跟踪也想做一遍比如实体的Added、Modified、Deleted状态然后根据状态差生成INSERT/UPDATE/DELETE语句。但我觉得这不是必选项反而会让ORM变得复杂。我自己的做法是简化实体只有两种存在形态一种是从数据库查出来的另一种是要写进去的。更新操作我采用“显式字段更新”模式而不是全字段更新。对于Update语句调用方传入哪些字段ORM就只更新哪些字段主键永远作为唯一过滤条件。这样既不会出现把不想改的字段覆盖掉的低级错误也给批量更新留出了空间。事务方面我没有在ORM层面实现特别复杂的分布式事务只提供了简单的IUnitOfWork接口。它准许你取一个连接对象在一个事务上下文里执行多次操作后统一提交或回滚。关键在于每个请求对应一个回话级别的UnitOfWork实例配合依赖注入的生命周期管理才能保证整个请求在一个数据库事务里。这个衔接点在后面讲中间件和依赖注入时会再次提到。4. 踩坑实录发布后Swagger 404的完整定位过程标题里提到的热搜词里有一条特别眼熟——发布WebAPI项目后提示not found /swagger/v1/swagger.json。这个坑我不仅在.NET 5时代踩过后来在更新版本的Visual Studio发布流程里也反复见过。如果你搜过这个问题并且试了一堆方案没解决大概率是没完整理解Swagger和Miniprofiler中间件的URL生成逻辑。把完整定位链路写下来。4.1 现象描述本地正常发布到部署环境立刻404本地开发环境里运行项目后访问/swagger能看到页面点“Try it out”也没问题。一旦通过发布向导部署到IIS打开Swagger UI界面能出来但界面请求/swagger/v1/swagger.json时直接返回404。这个现象我当时看到的第一反应是“文件没发布上去”。于是检查了发布目录wwwroot下文件齐全动态链接库也都在SwaggerUI相关的静态资源确实有。排除了静态文件缺失后我又怀疑是Swagger端点注册失败但本地同样一份代码完全正常这问题只出现在发布环境。4.2 排查IOS先确认Swagger端点在运行时是否注册要定位这个问题不能凭感觉猜。我先关闭了SwaggerUI中间件只在管道里保留UseSwagger然后直接在浏览器里访问/swagger/v1/swagger.json仍然404。这就说明问题不是UI静态资源而是后端端点本身就没正确响应。接着我加了自定义日志把中间件管道里经过的Request.Path打印出来。结果发现请求实际到达应用时路径变长了应用根路径不是/而是部署在IIS站点下一个虚拟目录里。假设站点是https://server/myapp/浏览器访问的实际上是https://server/myapp/swagger/v1/swagger.json但Swagger内部生成端点的路径却是基于应用根的/swagger/v1/swagger.json两者一拼接就变成了https://server/myapp/作为站点根再拼/swagger/v1/swagger.json其中的根路径计算出了问题。4.3 根因路径基址与路由前缀的双重影响ASP.NET Core应用部署在IIS虚拟目录下时应用内部通过IRouteBuilder或者IApplicationBuilder注册的中间件它感知到的路径是基于应用根后的相对路径。但Swagger文档生成时为了生成可访问的文档地址会使用当前请求基础地址。当你部署在虚拟目录下时这个基础地址往往已经包含了虚拟目录名导致文档URL重复拼接虚拟目录。解决方案很明确在配置SwaggerUI时显式指定RoutePrefix和文档端点路径。例如app.UseSwagger(c { c.RouteTemplate swagger/{documentName}/swagger.json; }); app.UseSwaggerUI(c { c.RoutePrefix swagger; c.SwaggerEndpoint(/swagger/v1/swagger.json, My API V1); });而部署在虚拟目录下时关键要处理好ASPNETCORE_APPL_PATH或者使用UsePathBase设置应用的路径基址。我在项目里加入了一个配置项允许在appsettings.json里设置PathBaseif (!string.IsNullOrEmpty(config[PathBase])) { app.UsePathBase(new PathString(config[PathBase])); }这样Swagger生成URL时会先基于配置的PathBase构造就不会再出现路径拼接错乱。4.4 顺带发现IIS发布目录里少了个东西排查Swagger过程中还有个意外发现发布出来的目录里如果ASP.NET Core ModuleANCM配置不当web.config里的hostingModel可能被设成OutOfProcess这时候静态文件和代理行为的处理方式和进程内模式有差异也可能影响Swagger静态资源的加载。我的建议是直接采用进程内托管。在csproj文件里显式配置PropertyGroup AspNetCoreHostingModelInProcess/AspNetCoreHostingModel /PropertyGroup这能减少一层代理转发不仅对Swagger更友好整体请求性能也有提升。这个问题和Swagger 404不一定直接相关但既然发布后总有一些莫名其妙的不良表现统一检查一遍没有坏处。5. 中间件里的依赖注入自己实现最小可用DI容器的思路标题里说“中间件已经实现了依赖注入”这一节介绍我做的这个DI容器。虽然ASP.NET Core原生自带的DI已经很好用但在某些中间件内部我还是选择用自己实现的一套小容器这样做的核心原因是为了让中间件可以在请求管道里创建“有作用域”的服务并且不污染全局容器。5.1 中间件内部直接new服务为什么不行很多人写自定义中间件时习惯在Invoke方法里直接new一个服务类出来用。从功能上讲这没有错误但你要是接下来需要在服务类里使用日志组件、数据库仓储就很别扭直接new出来的服务无法利用依赖注入。而如果在中间件构造函数里注入服务又涉及到生命周期问题——中间件在应用启动时构造一次它的构造函数依赖只能是单例如果请求级服务穿插进来强制从构造函数注入会直接抛异常。这就是为什么中间件里的依赖注入要单独处理。借助ASP.NET Core的IHttpContextAccessor访问请求上下文再由请求上下文解析当前作用域里的服务实例是一个更合理的方向。5.2 手写DI容器的核心三部曲我实现的小型依赖注入容器主要包括三部分。第一注册表。一个字典保存服务类型与实现类型或工厂方法的对应关系同时标记生命周期模式。对于临时生活周期每次解析都新建实例对于单例容器内保存唯一实例对于作用域Scope里保存实例。第二工厂构建。解析时检查构造函数选出参数类型都能从容器里解析出的构造函数递归实例化依赖。如果存在循环依赖则抛出异常。这一部分用到反射但只在首次解析时执行后续解析直接从缓存里取激活器保证性能。第三作用域管理。每个请求进来时中间件会创建一个新的Scope对象这个Scope内部维护该请求需要复用的实例列表。请求结束时Scope负责销毁实现了IDisposable的实例。public class MyScope { private readonly DictionaryType, object _scopedInstances new(); public T ResolveT() { return (T)Resolve(typeof(T)); } private object Resolve(Type serviceType) { /* 查找注册表创建或复用实例 */ } }5.3 和框架原生ServiceProvider的搭配方式这套小容器并没有完全替代ASP.NET Core自带的ServiceProvider。控制器、模型绑定、配置系统这些基础设施仍然依赖原生DI容器。自定义小容器只服务于我的ORM中间件、工作单元中间件和请求日志中间件。看起来是两套注入体系但它们在请求管道里的衔接经过了明确设计不互相干扰。框架启动时原生容器的单例服务里保留一套配置和日志组件引用自定义容器在初始化时读取这些引用并登记进自己的注册表。请求管道中的每次解析如果遇到原生容器注册的服务则直接调用原生容器解析否则才走自己的注册表。这个混合模式在当时很好地避免了对原生容器做大量扩展点的重写毕竟原生容器在.NET 5里已经支持IServiceProviderFactory真想完全替换也没必要更现实的目标是把自定义中间件内部的依赖管理掌握在可控范围内。5.4 生命周期不一致引发的血泪教训虽然设计上规避了问题但我一开始还是踩过一次很蠢的坑我在单例中间件里注入了一个Scoped服务。启动没有任何报错因为最初这个服务只在解析时被创建了一次然后被单例中间件长期持有。结果生产环境里出现了一个诡异现象所有请求共用了同一个工作单元的上下文第一个请求开启的事务没有释放后面的请求拿到的数据全是脏读。排查过程花了很多时间因为错误信息非常隐晦。后来我把所有接手管道的中间件实例生命周期全部设定为“每次请求创建一个新实例”或者在构造函数里禁止注入Scoped服务只允许在Invoke方法里通过作用域解析。这一点我建议所有手写中间件的人都认真对待别等线上出问题再回头反思。6. 这套框架跑了三个季度之后的真实体会该讲讲真心话了。这套框架从写出来到现在跑了三个多季度有收益也有代价。说出来可能对正在犹豫是否要自己造轮子的人有帮助。6.1 收益可控感和性能提升是真实的最直观的收益是性能。跳过EF Core的表达式树翻译和状态跟踪之后纯粹的数据读写性能有了明显提升。尤其在批量操作和复杂报表查询上因为SQL完全由我掌控可以根据查询场景手动微调甚至针对特定报表直接编写经过DBA验证的SQL模板由ORM负责填充条件参数。可调试性是第二份收益。以前用EF Core查一个出问题的查询第一件事是到处找怎么开启查询日志看到翻译出来的SQL还得反向对比表达式树。现在我自己写的ORM日志里直接能看到查询对象翻译前后的完整信息不透明的地方可以随时加断点确认这种掌控感用标准ORM很难获得。6.2 代价维护成本和团队学习曲线必须正视代价也相当明显。首先每一次数据库部分的新需求都要评估ORM核心需不需要扩展。比如后来要支持JSON字段映射花了三天调试序列化逻辑要支持表分区相关操作又要重新修改SQL生成器。这种改造都以“周”为时间单位计入项目进度。其次是团队上手成本。新来的同事对这套自研ORM不了解看代码的时候要先读一遍我的文档。我为此专门写了一份完整说明文档覆盖映射规则、查询对象用法、变更追踪规范、事务使用注意事项但这只是把学习曲线的长度从一篇文章变成一本小册子而已。6.3 最后给正在权衡的你几个建议第一别为了“自己写一个很酷”而写。如果没有动态查询这类把标准ORM逼到死角的需求直接EF Core或Dapper省下来的时间去做业务回报更高。第二如果决定写从最小可用版本开始先支持单一数据库、单一主键类型跑通增删改查和基础分页。不要一开始就设计分布式事务、多数据库方言、复杂状态跟踪项目会永远停留在“还没跑起来”的阶段。第三不管代码多顺手都要保留一条通往标准ORM的退路。我的做法是仓储接口全部抽象为公共接口如果真要替换成EF Core或者Dapper业务层代码可以不变。这条设计我现在回想起来是最值钱的。最近我开始把这个框架向上兼容迁移到.NET 8 LTS版本原本的中间件和ORM核心改动不大。迁移过程中边改边想如果当初从一开始就选了.NET 6或.NET 8很多后面踩的坑都能少一点。不过技术路线的选择本来就是如此在当时的版本条件制约下做了自认为最合理的决定然后持续演进就好。