☰
ASP.NET Core实战避坑指南:从功能切片到连接池与部署
2026/10/18 2:14:37 网站建设 项目流程

哥本哈士奇(aspnetx)这个ID,跟着我在技术群里混了好些年。很多人乍一看以为是个都市名的谐音,再看一眼才反应过来是只撒欢的狗,就会心一笑。ID一半是在自嘲——我确实像哈士奇,看到新技术就上头,撒手就没影;另一半是认真的——aspnetx,ASP.NET乘一个未知的x,意思是我喜欢把框架里那些文档没写透的地方,一点一点拆开来看。这篇内容就是我这些年做ASP.NET Core项目攒下的实战经验切片,从项目结构、依赖注入、中间件,到一次真实的线上事故复盘和部署上线的完整经历。如果你是刚入门的.NET开发者,可以把它当成一份避坑地图;如果你已经带过几个上了规模的项目,也许能在里面找到几个平时没太在意的细节。

先说清楚这篇不打算讲什么:不讲基础语法,不讲框架功能清单,也不复读官方文档。我写的是那些“文档里不会写、但你真的会遇到”的问题,以及我踩过之后怎么爬出来的过程。每个问题我都尽量说清楚背后的原理,而不只是给一段能跑的代码。项目里那些看起来不起眼的决策——比如目录怎么切、依赖怎么注册、中间件怎么排、连接池怎么配——恰恰决定了系统上线之后是稳还是炸。

1. 先聊聊“哥本哈士奇”这个ID,以及它怎么变成了我的技术符号

1.1 从老ASP.NET到ASP.NET Core,一次并不轻松的转身

我最早接触.NET是老ASP.NET Web Forms那一代,页面生命周期、ViewState、服务端控件,那套东西在当时确实让开发变得“像拖控件一样简单”。但项目一大就很痛苦:页面和逻辑纠缠在一起,测试基本靠手动点,性能一上来就抓瞎。后来MVC出现,才终于把关注点分离开,但也仅仅是“分得更清楚”,离好维护还有相当距离。

真正让我下定决心转到ASP.NET Core的,是几个硬指标:跨平台部署、高性能、内置依赖注入、统一的中间件管道。当时抱着哈士奇式的好奇心开始试水,结果第一版程序就栽了跟头——在Windows上好好的,放到Linux服务器上直接502,后来才发现是没有配置反向代理、也没监听对端口。这种“看着文档都懂、一上线就废”的体验,我经历过太多次。但也正是这些跟头,逼着我养成了追根究底的习惯。

1.2 这篇经验串适合哪些人,读完能拿到什么

我把这篇定位成“实战经验切片”,不是新手教程,但新手也能从中受益。你已经会写基本的Controller和Service,但项目一膨胀就觉得维护困难,那第2章的功能切片思路会给你很大启发。你用依赖注入只是“接口配实现”就完事,没想过作用域和生命周期的问题,那第3章就是为你准备的。你在项目里被线上事故折腾过,但还没沉淀出排查方法,第5章的复盘链路值得反复看。

所以这篇适用的场景大概是:你在做ASP.NET Core项目,或者即将接手一个已上规模的系统。你不满足于“能跑”,你想知道为什么有些系统跑着跑着就出诡异问题,想知道怎么在架构层面避免这些坑。读完以后,我希望你能带走几样东西:一套更抗改动的项目结构思路,几个依赖注入的雷区名单,一条中间件编排顺序的判断逻辑,以及一份连接池事故的完整排查清单。这些都是我拿时间和线上的代价换来的,性价比很高。

2. 项目结构那关:三层架构换成功切片,维护成本真的降了

2.1 三层架构的问题到底出在哪

我刚带一个项目时,用的是标准的三层架构:Controller层、Service层、Repository层,每层几十个文件,目录看起来非常规整。但做了几个迭代之后,别扭的感觉就来了。改一个订单功能,我至少要打开Controller、Service、Repository、Entity四个文件;很多时候还得在十几个长得差不多的Service里找到底哪个是订单的。最离谱的是,跨模块的共享代码绕来绕去,一个订单模块的修改可能要牵动权限、日志、通知三个模块,回归测试范围越滚越大。

现在回头看,问题不在“分层”本身,而在于这种切法是把代码按技术层级切,没有按业务变化切。三层架构面对十几个模块时,所有横向文件堆在同一层里,你根本不知道哪个Repository属于哪个业务,只能靠命名猜。当初微软那套分层模板是为“一个数据库对应一套CRUD”设计的,业务稍微复杂就开始变形。项目的维护成本,其实在一开始的结构决策里就注定了。

2.2 功能切片到底是什么,它和三层架构的本质区别

所谓功能切片,也叫垂直切片,核心就一句话:一个功能一个目录。把Controller、消息请求、处理逻辑、校验器、数据访问全部放在同一个文件夹下,让这个功能相关的所有代码在同一个可见范围内。

我重构过一个中型的后台系统,结构长这样:

src/App/ Features/ Orders/ CreateOrder/ CreateOrderRequest.cs CreateOrderHandler.cs CreateOrderValidator.cs OrderCreatedEvent.cs GetOrders/ GetOrdersQuery.cs GetOrdersHandler.cs Shared/ OrderStatus.cs OrderNumberGenerator.cs Products/ ... Customers/ ...

和三层架构对比一下就很明显:三层架构里找代码是“按角色找”,功能切片是“按业务找”。前者适合早期框架自动生成代码的阶段,后者真正适合业务逻辑密集、变更频繁的中后期。比如订单的创建逻辑里,校验、落库、发事件这三件事,在切片结构里都待在同一个目录,改起来只需要看一处;在三层架构里,校验可能在Service里,落库在Repository里,发事件又被扔到某个事件总线里,搜索成本极高。

2.3 我把一个老项目切成功能片段的实操步骤

第一步,识别业务功能边界。这个不需要什么高级分析,直接看路由就行:/orders、/products、/customers,这些就是天然的边界。如果路由特别粗粒度,那就先拆路由,再拆代码。

第二步,建一个Features目录,把Controller以及跟它相关的Service、Repository按功能搬进去。这一步机械但容易出错,因为有些Repository被多个Controller共用,搬的时候会牵一发动全身。

第三步,把真正跨功能共享的代码抽到Features/Shared里,或者单独提到一个独立的基础设施项目。这里要注意一个度:不是所有代码都需要共享,比如订单模块内部用到的专用枚举、专用异常,就应该留在订单目录里,不要因为“以后可能复用”就提前抽出去。

第四步,把那些只服务一个功能的“万能Service”直接拆成数据处理类或命令处理器。如果某个Service里面只有两个方法,而且只被一个Controller调用,那它就没有继续存在的必要。

我在重构时还引入了一个轻量级的命令/查询模式,没有上重型框架,就是简单的接口加处理器注册。收益是每个功能的入口非常清晰,测试可以直接构造请求体调用处理器,不需要启动整个Web服务器。缺点是文件数量会变多,每个文件却都很小。有人看着多了几十个类会觉得“变复杂了”,实际上恰恰相反——每个类的责任边界更清楚了。

2.4 哪些项目不建议上来就切片

功能切片不是银弹。如果项目是个极简API,总共三五个接口,两层结构就够了,硬切反而增加认知负担。如果团队全是新人,还没建立起领域概念,切片会让他们“不知道文件该放哪”。我的判断标准很简单:项目里是不是已经出现了一半以上的功能都要改同一层文件的现象?如果是,切片收益很大;如果不是,保持简单的分层挺好。

顺便说一个我在重构时总结的经验:改变结构最怕的是“边改边加需求”。我是在一个功能迭代相对空档的窗口期做的这次重构,先用一到两周把结构和测试理清楚,再开始加新功能。如果业务一直高速迭代的同时强行重组结构,很容易改出新的问题,最后都不知道是哪个变更引入的回归。

3. 依赖注入:构造函数爆炸、作用域泄漏,还有随手new出来的那个雷

3.1 内置DI用起来顺手,但三大坑一个比一个阴

ASP.NET Core内置了依赖注入容器,几乎每个项目都在用。但正因为“拿来就能用”,很多人会忽略生命周期和作用域问题。我在代码评审里见过大量“能跑,但一上线就出诡异问题”的写法,最常见的有三类。

第一类是构造函数爆炸。一个服务类的构造函数里塞七八个参数,满眼都是抽象接口,看起来解耦很彻底,实际是把复杂度从方法体挪到了组装现场。出现这种代码,基本可以断定这个服务兼职太多,它既在写业务、又在发通知、还在访问数据库。我的做法很简单:把大服务按职责拆成几个小服务,每个小服务只干一件事。接口数量不值得炫耀,那是坏味道的信号。

第二类是单例依赖了Scoped服务,这种情况最阴险。典型场景:一个后台任务服务注册成了Singleton,为了访问数据库,往里头注入IServiceScopeFactory,然后手动CreateScope拿DbContext,但忘了释放Scope。后果就是DbContext的生命周期被拉长成“整个进程级”,数据库连接借了不还,高峰期连接池迅速被耗尽,数据读写还会出现串号的诡异问题。B用户在自己的请求里,可能读到了A用户留下的未提交数据,这种错在测试环境极难复现。

// 错误示范:Singleton服务里手动创建Scope但没释放 public class SyncService : ISyncService { private readonly IServiceScopeFactory _scopeFactory; public SyncService(IServiceScopeFactory scopeFactory) { _scopeFactory = scopeFactory; } public void RunSync() { var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); // 没有释放 scope,db 的生命周期被拉长成了单例 } }

正确写法是把Scope放进using块,或者干脆每处理一个任务就新建一个作用域、处理完立刻释放。

第三类是随手new。很多同事为了“省几行代码”,直接在业务类里new出来一个Repository或者Service。问题在于,这个被new出来的对象内部往往又依赖了注入好的DbContext或其它服务,等于绕过了容器,把依赖链重新拉回手动装配状态。单元测试的时候又没法替换Mock对象,每次测试都得连真的数据库。这种代码的修复不复杂,把new出的对象改成构造函数注入就行,但找出来需要一点耐心。

3.2 怎么快速发现依赖注入环境里的问题

我整理了一个检查清单,每次接手项目都会照着过一遍:

  • 检查所有注册成Singleton的服务,看它们有没有直接依赖Scoped服务。如果有,要么改成作用域工厂手动建Scope且用完即释放,要么重新设计任务拆分,别让单例长期持有一个随请求变化的对象。
  • 全局搜索代码里的“new”关键字,看看new出来的对象是不是业务服务类;如果是,再看它内部有没有依赖容器管理的服务。有则必然是隐患。
  • 检查异步后台任务里是否有ServiceProvider的引用长期存在。一旦存在,基本可以断定会造成作用域泄漏。

这个清单不复杂,但能拦下绝大多数依赖注入相关的线上事故。我在多个项目里都靠它提前排掉了雷。

3.3 一个我验证过的检查习惯:启动时把注册表打印出来

依赖注入的坑之所以难发现,是因为代码在编译期看起来完全正常。要更早地发现生命周期不一致的问题,我有一个很土但有效的办法:在服务启动时临时加一段代码,遍历容器里已经注册的所有ServiceDescriptor,把ServiceType、ImplementationType和Lifetime打印到启动日志里,人工扫一遍。Singleton依赖Transient通常没问题,但Singleton依赖Scoped就要引起警觉。

foreach (var descriptor in services) { logger.LogInformation( "{Service} -> {Impl} [{Lifetime}]", descriptor.ServiceType.Name, descriptor.ImplementationType?.Name, descriptor.Lifetime); }

这个办法不需要装额外的诊断工具,准备上线前跑一次,基本能看清整个容器装配的真实情况。依赖注入不是目的,目的是把对象的创建和装配交给容器,让业务代码只表达“我需要什么”。只要记住生命周期一致性这条红线,很多问题都能提前规避,而不是等到线上暴雷才回头查。

4. 中间件管道的正确顺序:日志、异常、鉴权,先谁后谁真的会影响事故

4.1 中间件就像俄罗斯套娃,顺序就是执行顺序

ASP.NET Core的请求处理模型,核心是中间件管道。它的运作方式像一个俄罗斯套娃:每个中间件都决定“自己处理完,要不要把请求传给下一个”。因此,中间件的注册顺序,严格决定了每个请求的执行顺序。这个“顺序即执行顺序”的特性,很容易被人忽略,一忽略,线上就会出现各种玄学。

我最开始写中间件时,把异常处理放得特别靠后,结果一个深层服务抛出来的异常,直接绕过统一异常处理,用户看到的是默认的500错误页,日志里还只有半截访问记录。把异常处理挪到管道最前面之后,所有未捕获异常都会在入口被拦截,后面的日志和响应才有意义。这个调整没有改任何业务代码,只是把注册顺序换了一下,线上错误提示立刻变得友好了。

这里给出我实际项目里验证过的推荐编排顺序:

  1. UseExceptionHandler或者UseDeveloperExceptionPage,放在最前面。
  2. UseHttpsRedirection,把HTTP请求重定向到HTTPS。
  3. UseStaticFiles,静态文件直接返回,不进业务管道。
  4. UseRouting,加载路由定义。
  5. UseCors,跨域策略注册。
  6. UseAuthentication,认证。
  7. UseAuthorization,授权。
  8. 自定义业务中间件,比如日志、追踪ID、租户识别。
  9. UseEndpoints或者MapControllers,终结点执行。

这个顺序背后的逻辑可以概括成一句话:异常处理要最早,因为出现异常时你要确保响应能正常产生;静态文件要在路由之前,因为静态资源根本不需要走业务逻辑;鉴权必须放在路由之后、终结点之前,因为只有到了具体的端点,你才知道该按什么策略去判断权限。

4.2 一个高频中间件场景:给每个请求发一张“追踪ID”

自定义中间件里,我使用频率最高、也最推荐每个人都做一个的,是“追踪ID中间件”。作用很简单:请求进来时生成一个Guid,放进HttpContext.Items,之后的每一步日志都带上这个ID。一旦用户反馈“我那条数据怎么没了”,通过时间范围和用户ID,我就能把整条链路的日志串起来。

它的注册位次很有讲究。若放在路由之前,所有后续中间件都能拿到同一个追踪ID;若放在路由之后、终结点之前,那路由匹配产生的日志就追踪不到了。项目里日志链路不完整,很多时候就是中间件放的位置不对。

一个简化的实现大致长这样:

public class TraceIdMiddleware { private readonly RequestDelegate _next; public TraceIdMiddleware(RequestDelegate next) => _next = next; public async Task InvokeAsync(HttpContext context) { var traceId = Guid.NewGuid().ToString("N"); context.Items["TraceId"] = traceId; context.Response.Headers["X-Trace-Id"] = traceId; await _next(context); } }

这个中间件放在异常处理之后、路由之前,再配合结构化日志把TraceId打进去,排查问题的时候效率会提高一个档次。

4.3 Use、Run、Map别搞混

自定义中间件一多,一个高频错误就会出现:在中间件里忘掉调用_下一环节。我踩过一次,在一个租户识别中间件里少写了一个await next(),结果所有页面返回空白,排查了很久才发现是请求被中间件截断了。

Use是“处理完传给下一个”,Run是“这里是终点,不再传”,Map是按路径条件分流。大多数业务中间件用Use就够了。还有,自定义中间件的注册顺序和代码本身的先后编译顺序无关,只跟你把它添加到管道的先后有关。这个认知理不清,日志会出现明明中间件写在前面,但没有先执行的情况——当然这是我看错了代码的位置,它注册的是另一个管道分支。

最后提一句,CORS和鉴权的关系很容易被搞反。CORS是在服务端决定“允不允许浏览器跨域读取我的响应”,鉴权是“这个用户有没有资格访问我的资源”,两者是不同维度的两件事。如果CORS被放到鉴权之后,某些预检请求就无法正确返回,前端明明配了跨域却总是报错。这种问题不会让服务崩溃,但会让前端同事暴躁到想打人。

5. 那次连接池耗尽:一次线上事故复盘,我的数据库访问配置错在哪

5.1 完整排查链路:高峰期数据库不忙,连接却没了

我负责的一个内部后台系统,某天高峰期突然大面积接口超时。性能指标一打开,数据库的CPU和内存都不高,但活跃连接数已经逼近数据库上限,错误日志里开始刷“连接池已满,请求超时”。

当时我第一反应是“数据库扛不住了”,但指标显示不是。接着怀疑是某条SQL锁表,翻了慢查询日志,也没有特别夸张的语句。再看应用侧,发现有一个后台任务服务,每秒轮询一次队列,一旦队列里有任务就创建新Scope去处理。问题出在创建的Scope没有用using包住,任务处理完,Scope和里面的DbContext并没有立即释放。每轮轮询都new一个Scope,仿佛会释放,实际都被进程“挂”着,连接池里的连接就这么被一点一点借光,且永不归还。

修复方案说出来很普通:把Scope的创建和释放包进using块,或者改成任务级的事务边界,处理完立刻释放。改完之后连接数立刻回落到正常水位。但真正有价值的不是修复本身,而是这个事故揭示的一个核心认知:DbContext本身是轻量对象,创建成本低,但它的数据库连接是从连接池借来的,用完必须还。任何让DbContext活过业务请求的写法,本质上都是“借了不还”。

5.2 连接池配置的正确姿势:Max Pool Size不是越大越好

排查过程中我顺手检查了连接字符串,发现团队用的是默认配置,什么都没显式设置。默认的最大连接数对单个服务足够,但多实例部署时要重新算。我一般会在连接串里显式写清楚这些参数:

Server=localhost;Database=App;User Id=sa;Password=xxx;Max Pool Size=200;Min Pool Size=5;Connection Timeout=30

Max Pool Size不是越大越好,它和数据库端最大连接数、并发事务数量、锁竞争都有关系。我设置时会先算一笔账:每个实例预期的并发请求数,乘以实例总数,再留20%左右的冗余,这是最高连接数的一个合理起点。比如三个实例,单个实例并发峰值稳定在50左右,那Max Pool Size设200就够,设800反而可能把数据库压垮。

连接池耗尽这类事故,根子通常不在参数,而在代码的生命周期设计。参数只是最后一道防线。

5.3 从这次事故里沉淀出的检查习惯

这次事故之后,我给自己定了几条铁律:

  • 代码评审时,凡是看到IServiceScopeFactory,都停下来问一声:这个Scope多久释放?不释放就不许合代码。
  • 依赖数据库的服务,从不在构造函数里直接拿DbContext实例,而是通过注入作用域拿。
  • 线上增加连接池和活跃连接数的监控指标,超过阈值就告警,而不是等用户报障。
  • 每次修完线上事故,把根因、现象、解决步骤写进团队的排障手册,而不是只改代码。

其中最后一条看起来最“虚”,但长期价值最大。事故排障很多时候是“痛苦的重复”和“高级的重复”之间的差别。写手册的人未必记得所有细节,但下次同类问题出现时,排查时间可能从半天缩到半小时。

6. 部署上线:从裸跑Kestrel到容器化,中间那几次翻车

6.1 裸跑Kestrel的岁月:systemd托管与那几个低级错误

项目早期,我在服务器上直接运行发布产物,再用systemd托管进程。这套方案本身没问题,但很容易栽在细节上。最经典的是忘记设置ASPNETCORE_URLS,默认只监听localhost,外网访问直接拒绝;再就是静态文件权限不对,图片加载不出来;最离谱的是有人把环境变量设成了Development,线上跑着开发配置,连接串都指向本地数据库。

那段时期,每回上线都像开盲盒,本地好好的,线上各种报错。后来我强制规定:所有配置必须通过环境变量覆盖,开发环境的数据不要硬编码进配置仓库,线上连接串一律从部署平台的环境变量里读取。这套规则立下来,环境差异导致的低级错误终于消停了。

6.2 容器化的第一版:多阶段构建与最小镜像

再往后我把部署切到了容器化。多阶段构建的Dockerfile是基础配置:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY . . RUN dotnet publish -c Release -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/publish . ENV ASPNETCORE_ENVIRONMENT=Production EXPOSE 8080 ENTRYPOINT ["dotnet", "App.dll"]

第一版容器化也踩了不少坑,最典型的是:镜像构建完一启动,容器显示“运行中”,但应用其实在崩溃重启。原因是没加健康检查,编排工具只看到进程还在,就认为服务正常。后来我在镜像里加了一个健康检查接口,并配合编排工具的探针来确认“服务的就绪”状态。镜像小不小、层多不多,这些都是优化项;健康检查才是保命项。

如果只是单服务部署,docker-compose已经够用。下面是一个经过验证的简化配置:

services: app: build: . ports: - "8080:8080" environment: - ASPNETCORE_ENVIRONMENT=Production - ConnectionStrings__App=Server=db;Database=App;User Id=sa;Password=${DB_PASSWORD};Max Pool Size=200 depends_on: - db db: image: postgres:16 environment: POSTGRES_PASSWORD: ${DB_PASSWORD}

6.3 容器化之后的新坑:数据保护密钥、环境一致性和服务编排

容器化之后环境一致性大幅提升,但有些问题反而被放大了。一个特别容易被忽略的是数据保护密钥。ASP.NET Core的Cookie、防伪标记、部分缓存加密,都依赖数据保护API。默认情况下密钥存在本地文件系统里,多实例部署时,每个实例会生成各自的密钥,用户登录状态就会出现“这台机器有效、换一台就掉线”的诡异现象。

解决方向是显式配置数据保护密钥的存储位置,让所有实例共享同一个密钥仓库,比如持久化到网络存储或数据库。这个问题在单机时代几乎不会遇到,容器化一上多副本,马上就暴露出来。

对于刚从裸跑切换到容器的团队,我的建议是别急着上规模很大的编排系统,先用Compose把单服务跑稳,把镜像构建、配置注入、健康检查、日志收集这几件事做扎实,再考虑扩展。上层的复杂度是下层简单性堆出来的。基础设施不稳时,编排只会让事故范围更大,而不会让系统更可靠。

最后说一个我个人坚持了很久的小习惯:每次修完一个线上事故,都会把根因、现象和解法写进团队的排障手册。代码会改,记忆会淡,手册不会骗人。这大概是“aspnetx”这两个字母对我最真实的意义——x不是交叉路口,是不确定。而一个开发者的成长路径,往往就是由一个个“处理好的不确定”拼出来的。项目结构按业务功能切,依赖注入时刻检查生命周期,中间件顺序想清楚再排,数据库连接用完必须放,部署前先处理好配置和密钥。这几条看起来都不难,但我每一次线上翻车,几乎都能追到其中一条。希望这篇“哈士奇式”的经验积累,能帮你少拆几次家。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询