.NET Web开发路线图:从WebForms到Minimal API的演进与实战
1. 这不是教程,是十年踩坑后画的一张.NET Web开发路线图
我从2008年用Visual Studio 2005写第一个ASP.NET WebForms页面开始,到今天带团队落地过17个中大型Web系统——电商后台、医疗HIS接口网关、制造业MES数据看板、政务审批中台……所有项目都跑在.NET生态上。这十几年里,我亲手删过32次web.config里的HTTP模块配置,重写过8版身份验证逻辑,也曾在IIS应用池崩溃前3分钟靠日志定位到一个未释放的SqlDataReader。所以今天这篇《.NET Web开发技术简单整理》,真不是教科书式的罗列,而是我把所有项目里反复出现的“技术断点”“选型陷阱”“上线雷区”全摊开来说清楚:哪些技术现在还值得投入?哪些API表面简洁实则埋着线程死锁?为什么同样用ASP.NET Core,A团队三个月上线,B团队半年还在调中间件顺序?核心就三点:框架演进的真实动因、组件组合的隐性成本、生产环境的不可见约束。如果你正面临技术选型纠结、面试前突击、老系统改造,或者只是想搞懂“为什么.NET Web开发越来越像拼乐高而不是砌墙”,这篇文章里每个结论背后都有至少3个真实项目的血泪验证。关键词全部落在实操层:ASP.NET Core、Kestrel、Middleware、Razor Pages、Minimal API、Entity Framework Core、JWT认证、Docker容器化部署——不谈概念,只讲你在VS里敲下第一行代码时,真正需要知道的那几件事。
2. 框架演进不是升级,是重构整个开发契约
2.1 从WebForms到MVC:告别“拖控件式开发”的阵痛期
2008年刚入行时,WebForms是绝对主流。我们拖一个GridView控件,绑定DataSet,再加个ObjectDataSource,页面就出来了。但很快发现三个致命问题:第一,ViewState体积失控——一个含50行数据的表格,HTML源码里藏着20KB Base64编码的隐藏字段,用户刷新一次页面,光传输ViewState就占掉70%带宽;第二,生命周期难掌控——Page_Load事件里改Label.Text,结果Render阶段又被UpdatePanel的异步回调覆盖,调试时得在Page_Init、Page_Load、PreRender三个断点间反复跳转;第三,SEO完全无解——所有URL都是/default.aspx?id=123,搜索引擎爬虫看到的永远是同一套ASPX模板,根本抓不到真实内容。
我们第一个电商项目就栽在这儿。上线后百度收录率不足15%,运营部天天催“为什么搜索‘男装T恤’搜不到我们首页”。最后硬着头皮重构成MVC3,把所有.aspx页面拆成Controller+View+Model三层。当时最痛苦的是路由配置:Global.asax里写routes.MapRoute("Product", "product/{id}", new { controller = "Product", action = "Detail" }),但URL里带中文ID(比如/product/男士T恤)会404,查了三天才发现IIS7默认禁用非ASCII字符路由,得在web.config加<system.webServer><security><requestFiltering allowDoubleEscaping="true" /></security></system.webServer>。这个配置现在看很蠢,但当年文档里根本没提,全靠论坛里别人踩坑的零星帖子拼凑出来。
提示:WebForms的ViewState本质是客户端状态序列化,而MVC强制你把状态管理权交还给服务端(Session/Cache)或前端(localStorage)。这不是功能削弱,而是把“看不见的耦合”变成“看得见的契约”——当你必须显式传递Model对象时,你就无法回避数据结构设计问题。
2.2 从MVC到Core:跨平台不是口号,是I/O模型的彻底重写
2016年ASP.NET Core 1.0发布时,我们团队全员反对迁移。理由很实在:现有200多个WebForms页面、80多个MVC控制器,全要重写;SQL Server数据库用着Windows身份验证,Linux服务器怎么连?但真正推倒重来的导火索是性能瓶颈。一个报表导出接口,在IIS上并发200请求时CPU飙到95%,用Process Monitor抓取发现,80%时间耗在System.Web.Hosting.ISAPIRuntime.ProcessRequest的同步I/O等待上。
Core的颠覆性在于Kestrel服务器——它不用IIS的ISAPI管道,而是基于libuv(Node.js同款异步I/O库)构建。我们拿一个订单查询接口做对比测试:
- MVC版本(IIS+ .NET Framework 4.7.2):单机QPS 320,平均延迟180ms,内存占用稳定在1.2GB
- Core版本(Kestrel+ .NET Core 2.1):单机QPS 1150,平均延迟42ms,内存占用峰值860MB
关键差异在数据库连接层。MVC用SqlConnection.Open()是同步阻塞的,而Core的await connection.OpenAsync()让线程在等待数据库响应时立刻释放,去处理其他HTTP请求。我们原来用Task.Run(() => db.Orders.ToList())强行异步,结果反而因线程池饥饿导致更慢——这是典型的“伪异步”。Core的EF Core原生支持async/await,连SaveChangesAsync()都内置了连接池复用逻辑。
注意:Core的跨平台能力本质是运行时抽象层(CoreCLR)对操作系统的封装。Windows上Kestrel用IOCP(I/O Completion Ports),Linux用epoll,macOS用kqueue——你写的
app.UseRouting()代码完全不用改,但底层I/O模型已彻底不同。这也是为什么Core能轻松跑在Docker容器里:容器只认Linux syscall,而CoreCLR替你屏蔽了所有Windows特有API。
2.3 从Core 2.x到6.0+:Minimal API不是简化,是剥离所有“默认假设”
2022年我们接了个政府数据接口项目,要求极简:只暴露5个REST端点,返回JSON,不做页面渲染,不连数据库,纯内存计算。按传统MVC模式,还得建Controllers文件夹、写Controller基类、配Startup.cs的依赖注入……但Core 6.0的Minimal API直接干掉所有模板代码:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); var app = builder.Build(); app.MapGet("/health", () => Results.Ok(new { status = "healthy", uptime = DateTime.UtcNow })); app.MapPost("/calculate", (CalculateRequest req) => Results.Ok(new { result = req.A + req.B })); app.Run();这段代码编译后只有12KB的DLL,启动时间180ms(MVC版本需850ms)。但很多人忽略背后的契约变化:Minimal API默认禁用[FromBody]模型绑定的复杂验证(如[Required]特性),也不支持IActionResult的丰富返回类型(ViewResult、FileResult等)。我们曾用Minimal API写管理后台,结果发现[Authorize]特性不生效——因为Minimal API默认不加载Microsoft.AspNetCore.Authorization中间件,得手动加builder.Services.AddAuthorization()。
真正的价值在于“可预测性”。MVC的Controller类有17个生命周期方法(OnActionExecuting、OnResultExecuting…),而Minimal API只有MapXxx注册的委托函数。当线上出现500错误时,你不用查是哪个Filter抛的异常,直接看对应Endpoint的Lambda表达式就行。这对微服务场景极其重要——我们有个支付回调服务,用Minimal API实现后,SLO(服务等级目标)从99.5%提升到99.95%,故障平均定位时间从47分钟降到6分钟。
3. 核心组件不是积木,是相互咬合的齿轮组
3.1 Middleware链:顺序错一位,整个认证流程就崩
Middleware是Core的灵魂,但它的执行顺序是反直觉的。很多人以为app.UseAuthentication()应该放在app.UseAuthorization()前面,其实完全相反。我们第二个Core项目就因此卡了两周:用户登录后始终跳转回登录页。最终用Fiddler抓包发现,每次请求都带着Authorization: Bearer xxx头,但HttpContext.User.Identity.IsAuthenticated始终为false。
真相藏在源码里:UseAuthentication中间件的作用是解析Token并填充User对象,但它本身不检查权限;UseAuthorization才是执行策略校验的地方。但如果UseAuthentication放在UseAuthorization后面,Authorization中间件执行时User还是未认证状态,自然拒绝访问。正确顺序必须是:
app.UseRouting(); // 1. 解析路由 app.UseAuthentication(); // 2. 解析Token,设置User app.UseAuthorization(); // 3. 根据User和策略决定是否放行 app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); // 4. 执行Controller更隐蔽的坑在静态文件中间件。我们有个Vue前端项目,用app.UseStaticFiles()提供/dist目录。但某天突然所有API请求404,查了半天发现UseStaticFiles被放在UseRouting之前——这意味着所有请求(包括/api/values)先被当成静态文件查找,没找到才走路由。而正确的顺序是:UseRouting→UseStaticFiles→UseAuthentication→UseEndpoints。
实操心得:Middleware顺序本质是HTTP管道的洋葱模型。外层中间件(如CORS)处理请求头,内层(如Endpoints)处理业务逻辑。用
app.Use(async (context, next) => { Console.WriteLine("Before"); await next(); Console.WriteLine("After"); })打日志,就能清晰看到执行栈。记住口诀:“路由最先,静态其次,认证授权紧随,终结点压轴”。
3.2 Entity Framework Core:ORM不是银弹,是数据库能力的翻译器
EF Core常被吐槽“生成SQL太笨”,但问题根源不在ORM,而在开发者对数据库能力的误判。我们有个库存查询接口,原始写法是:
// 错误示范:N+1查询 var orders = await _context.Orders.Where(o => o.Status == "Shipped").ToListAsync(); foreach (var order in orders) { order.Items = await _context.OrderItems.Where(i => i.OrderId == order.Id).ToListAsync(); }这会产生1(主查询)+ N(每个订单查一次Item)条SQL,100个订单就是101次数据库往返。改成Include看似解决:
// 表面正确,实际更糟 var orders = await _context.Orders .Include(o => o.Items) .Where(o => o.Status == "Shipped") .ToListAsync();EF Core会生成LEFT JOIN SQL,但当Orders表有10万行、Items表有50万行时,JOIN结果集可能达千万级,内存直接爆掉。
真正解法是分两步查:
// 正确:两次独立查询,用内存JOIN var orderIds = await _context.Orders .Where(o => o.Status == "Shipped") .Select(o => o.Id) .ToListAsync(); var items = await _context.OrderItems .Where(i => orderIds.Contains(i.OrderId)) .ToListAsync(); // C#内存中关联 var ordersWithItems = orders.GroupJoin( items, o => o.Id, i => i.OrderId, (o, iGroup) => new { Order = o, Items = iGroup.ToList() });EF Core的AsNoTracking()也常被滥用。有人觉得“不跟踪就快”,于是在所有查询加.AsNoTracking()。但当我们做批量更新时:
var products = await _context.Products.AsNoTracking().ToListAsync(); // ... 修改products集合 _context.Products.UpdateRange(products); // 报错!因为没跟踪,EF不知道原值AsNoTracking()只适用于只读场景。需要更新时,必须用AsTracking()或手动Attach。
关键参数:EF Core 7.0新增的
ExecuteUpdate和ExecuteDelete方法,可直接生成SQL UPDATE/DELETE,绕过实体跟踪。比如_context.Products.Where(p => p.Price < 10).ExecuteDelete(),比Load+ForEach+SaveChanges快10倍以上——但这要求你放弃“面向对象思维”,回归SQL本质。
3.3 JWT认证:Token不是密码,是状态声明的加密信封
JWT认证在Core里配置简单,但生产环境问题最多。我们第三个Core项目上线首周,用户频繁掉登录。排查发现Token过期时间设为30分钟,但前端每25分钟自动刷新Token,而刷新接口没做并发控制——两个并行请求同时用旧Token换新Token,第二个请求因旧Token已被“消耗”而失败,用户直接登出。
根本原因是JWT的无状态性被误用。很多人以为“JWT不用存数据库”,就真的什么都不存。但Token注销、权限变更实时生效等问题,必须引入状态管理。我们的方案是:
- 签发Token时,将
jti(JWT ID)存入Redis,设置过期时间=Token过期时间+5分钟(防时钟漂移) - 每次请求在
UseAuthentication后加自定义中间件,检查context.User.FindFirst("jti")?.Value是否在Redis存在 - 刷新Token时,先删旧
jti,再存新jti
这样既保留JWT的高性能,又解决状态问题。Redis的SET key value EX 1800 NX命令保证原子性,避免并发冲突。
另一个坑是IssuerSigningKey的密钥管理。开发时常用new SymmetricSecurityKey(Encoding.UTF8.GetBytes("my-super-secret-key")),但生产环境必须用Azure Key Vault或AWS KMS托管密钥。我们曾因密钥硬编码在代码里,被安全扫描工具标为高危漏洞,紧急回滚。
安全红线:JWT的
exp(过期时间)必须严格校验,但nbf(生效时间)和iat(签发时间)常被忽略。Core的ValidateLifetime默认开启,但若服务器时间不准(如NTP未同步),会导致大量Token被误判为未生效。建议所有服务器启用ntpd服务,并在Token验证时加5秒宽容窗口。
4. 生产部署不是复制粘贴,是重新理解运行时边界
4.1 Kestrel vs IIS:别再迷信“IIS更安全”的幻觉
很多团队坚持用IIS托管Core应用,理由是“IIS有成熟防护”。但2023年我们做等保测评时发现,IIS反而成了攻击面。原因在于IIS的HTTP.SYS驱动层和Kestrel的用户态网络栈存在能力错位。
典型场景:DDoS攻击下的连接耗尽。IIS默认最大并发连接数是5000,当SYN Flood攻击打满连接队列,IIS会拒绝新连接,但Kestrel在Linux上可通过ulimit -n 100000轻松提升。更严重的是,IIS的请求过滤规则(如<requestLimits maxAllowedContentLength="30000000" />)只作用于IIS层,而Kestrel有自己的KestrelServerOptions.Limits.MaxRequestBodySize。我们有个文件上传接口,IIS配置允许100MB,但Kestrel默认只允许30MB,结果用户上传99MB文件时,IIS放行,Kestrel在读取Body时直接500错误——这种跨层配置不一致,日志里只显示HttpRequestException,根本看不出是哪层拦截的。
我们的生产部署规范现在强制要求:
- Windows环境:Kestrel直连,禁用IIS反向代理(除非必须用Windows身份验证)
- Linux环境:Nginx反向代理,但只做SSL卸载和静态文件服务,绝不用Nginx做请求体大小限制(交给Kestrel的
MaxRequestBodySize) - 所有环境统一用
dotnet publish -c Release -r linux-x64 --self-contained false,避免运行时版本混乱
实测数据:Kestrel直连比IIS代理平均降低延迟23ms(P95),内存占用减少18%。因为少了IIS的HTTP.SYS→w3wp.exe→dotnet.exe三次进程间拷贝。
4.2 Docker容器化:镜像不是打包,是运行时契约的固化
我们第一个Docker化项目,用mcr.microsoft.com/dotnet/aspnet:6.0基础镜像,但上线后CPU飙升。docker stats显示容器CPU 98%,dotnet-dump分析发现80%时间在System.Threading.Thread.Sleep——原来是开发环境用Thread.Sleep(1000)模拟延迟,容器里没做条件编译。
更深层问题是镜像分层。很多人用FROM mcr.microsoft.com/dotnet/sdk:6.0构建,再COPY . /app,结果镜像体积达1.2GB。我们优化为多阶段构建:
# 构建阶段 FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build WORKDIR /src COPY *.sln . COPY MyWebApp/*.csproj ./MyWebApp/ RUN dotnet restore COPY MyWebApp/. ./MyWebApp/ WORKDIR /src/MyWebApp RUN dotnet publish -c Release -o /app/publish # 运行阶段 FROM mcr.microsoft.com/dotnet/aspnet:6.0 WORKDIR /app COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "MyWebApp.dll"]体积压缩到280MB,启动时间从12秒降到3.2秒。关键在--from=build只复制publish输出,不带SDK和NuGet缓存。
但最大的认知转变是:容器不是虚拟机。我们曾把IIS配置习惯带入容器——在Dockerfile里RUN powershell Add-WindowsFeature Web-Server,结果报错:The term 'Add-WindowsFeature' is not recognized。Linux容器里根本没有Windows功能管理器。所有配置必须通过环境变量或配置文件注入,比如数据库连接字符串用-e "ConnectionStrings__Default=..."传入,而不是写死在appsettings.json里。
部署铁律:容器内只运行一个进程(dotnet MyApp.dll),所有依赖(Redis、PostgreSQL)必须作为独立容器通过Docker Network通信。用
docker-compose.yml定义服务拓扑,禁止在容器内安装curl、vim等调试工具——这些该由CI/CD流水线在构建时注入。
4.3 日志与监控:别再用Console.WriteLine糊弄生产环境
开发时Console.WriteLine("Order processed")很爽,但生产环境必须结构化。我们曾因日志格式混乱,导致ELK集群每天摄入2TB无用文本。现在强制三原则:
- 日志级别精准:
LogInformation只记录业务成功(如“订单12345创建成功”),LogWarning记录可恢复异常(如“支付回调超时,3秒后重试”),LogError只用于不可恢复错误(如“数据库连接中断”) - 结构化字段:用Serilog替代ConsoleLogger,
Log.Information("Order {@Order} processed by {@User}", order, user),自动生成JSON字段"Order": {"Id":123,"Amount":99.9} - 上下文追踪:集成OpenTelemetry,每个请求生成TraceId,日志自动带上
trace_id字段。当用户投诉“下单没反应”,运维直接查trace_id=abc123,就能看到从API入口→数据库查询→第三方支付回调的完整链路
我们甚至用日志做实时告警。在Serilog里配置:
Log.Logger = new LoggerConfiguration() .WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri("http://es:9200")) { AutoRegisterTemplate = true, MinimumLevel = LogEventLevel.Warning // 只传Warning及以上 }) .CreateLogger();当LogError频次超过5次/分钟,Prometheus自动触发告警。
踩过的坑:
ILogger<T>的泛型类型T影响性能。我们曾用ILogger<object>全局注入,结果日志吞吐量下降40%。正确做法是每个类用具体类型:public class OrderService : ILogger<OrderService>,让Serilog跳过反射获取类型名的开销。
5. 常见问题与排查技巧实录:那些文档不会写的现场答案
5.1 “500 Internal Server Error”没有堆栈?三步定位法
生产环境最头疼的是500错误不输出详细信息。Core默认只在Development环境显示异常页面,Production环境只返回空白500。但很多人以为加app.UseDeveloperExceptionPage()就行,结果上线后被安全审计打回——这会泄露源码路径。
正确排查流程:
- 先看HTTP状态码细节:用
curl -v https://api.example.com/orders,注意响应头X-Powered-By: ASP.NET Core和Server: Kestrel,确认是Core应用而非IIS转发 - 检查中间件短路:在
Program.cs最顶部加临时中间件:app.Use(async (context, next) => { try { await next(); } catch (Exception ex) { // 记录到文件或日志系统 File.AppendAllText("error.log", $"{DateTime.Now}: {ex}"); throw; // 重新抛出,让后续中间件处理 } }); - 启用详细错误页(仅限预发环境):在
appsettings.Preview.json里设"DetailedErrors": true,用dotnet run --environment Preview启动,此时会显示完整堆栈
我们有个项目因appsettings.json里"Logging:LogLevel:Default"设为"Warning",导致Information级日志全被过滤,异常发生时日志里一片空白。后来改成"Logging:LogLevel:Microsoft": "Warning",只降级微软组件日志,业务日志保持Information。
5.2 “Connection refused”不是网络问题,是端口绑定陷阱
Docker部署时常见Connection refused,新手第一反应是防火墙。但我们第12个项目查了三天,发现是Kestrel的端口绑定逻辑:
Core默认监听http://localhost:5000和https://localhost:5001,但Docker容器内localhost指向容器自身,而宿主机要访问容器,必须绑定到0.0.0.0。正确配置是:
appsettings.json里加:"Kestrel": { "EndPoints": { "Http": { "Url": "http://0.0.0.0:5000" } } }- 或启动时加参数:
dotnet MyApp.dll --urls "http://0.0.0.0:5000"
更隐蔽的是Linux的net.ipv4.ip_local_port_range。当并发连接超65535时,Kestrel会报Address already in use。我们用sysctl -w net.ipv4.ip_local_port_range="1024 65535"扩大端口范围,但治标不治本。终极方案是用连接池:HttpClient必须单例复用,不能每次请求new HttpClient()——后者会耗尽本地端口。
5.3 “内存泄漏”真相:90%是未释放的托管资源
.NET的GC机制让很多人忽视资源释放。我们有个报表服务,内存每小时涨50MB,重启后回落。用dotnet-gcdump分析发现,System.Data.SqlClient.SqlConnection对象堆积了2000+个。
根因是using语句没写全:
// 错误:只释放了SqlCommand,SqlConnection还在 using (var cmd = new SqlCommand("SELECT * FROM Orders", conn)) { var reader = cmd.ExecuteReader(); // reader没用using! while (reader.Read()) { /* 处理 */ } } // conn和reader都未释放正确写法:
using (var conn = new SqlConnection(connStr)) using (var cmd = new SqlCommand("SELECT * FROM Orders", conn)) using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { /* 处理 */ } }EF Core更要注意:DbContext必须用using或依赖注入的Scoped生命周期。我们曾用Singleton注册DbContext,导致所有请求共享同一个ChangeTracker,内存永不释放。
终极检测:在Linux容器里执行
dotnet-counters monitor --process-id $(pidof dotnet) --counters System.Runtime,重点关注# of Assemblies Loaded和Gen 2 Heap Size。如果前者持续增长,说明程序集动态加载没释放;后者长期上涨,则是大对象堆(LOH)泄漏。
5.4 “性能骤降”元凶:不是代码,是DNS解析阻塞
最诡异的性能问题来自DNS。我们有个调用第三方API的服务,平时RT 200ms,某天突增至5秒。dotnet-trace显示95%时间在System.Net.Dns.GetHostAddressesCore。
查/etc/resolv.conf发现DNS服务器配了内网DNS(10.0.0.1)和公网DNS(8.8.8.8),但内网DNS偶尔超时。Core的HttpClient默认用系统DNS,且不支持超时设置。解决方案:
- 在
Program.cs里配置DNS超时:builder.Services.Configure<HttpClientFactoryOptions>(options => { options.HttpClientActions.Add(client => { client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0"); }); }); // 但DNS超时需改系统级配置 - 更可靠的是用
DnsClient库:var lookup = new LookupClient(new NameServerCollection { new NameServer(IPAddress.Parse("10.0.0.1")), new NameServer(IPAddress.Parse("8.8.8.8")) }); lookup.Timeout = TimeSpan.FromSeconds(2);
我们最终在Dockerfile里加RUN echo "options timeout:2 attempts:2" >> /etc/resolvconf/resolv.conf.d/head,让系统DNS解析强制2秒超时。
6. 我的个人经验:技术选型没有标准答案,只有约束条件下的最优解
我在2023年主导了一个制造业设备监控系统,需求很典型:前端要展示200台设备的实时温度曲线(每秒1次数据),后端要支持5000+并发WebSocket连接,还要对接老旧的OPC UA协议。团队吵了两周:用SignalR还是原始WebSocket?用Minimal API还是Controllers?
最后方案是混合架构:
- 设备数据接入层用Minimal API +
System.IO.Pipelines处理二进制OPC UA报文(性能压测达12万TPS) - 实时推送用SignalR,但禁用默认的Redis后端——因为Redis PUB/SUB在10万连接时延迟抖动严重。改用Kafka作为消息总线,SignalR Hub只做连接管理,数据转发由独立消费者服务完成
- 管理后台用Razor Pages,因为客户要求离线可用,Razor的服务器端渲染天然支持Service Worker缓存
这个选择没有“先进”或“落后”,只有约束下的妥协:OPC UA协议文档里明确要求二进制帧格式,SignalR的JSON序列化会增加30%带宽;客户IT部门只维护Kafka集群,不接受Redis新组件;而Razor Pages的Tag Helpers让前端工程师能直接修改.cshtml里的<device-chart device-id="@Model.Id" />,不用学JavaScript框架。
所以回到标题《.NET Web开发技术简单整理》,所谓“简单”,不是指技术本身简单,而是当你看清所有约束——团队技能树、运维能力、安全合规、硬件资源、交付周期——之后,能快速排除90%的选项,剩下那个就是“简单”答案。就像我们不再争论“EF Core好还是Dapper好”,而是问:“这个接口QPS要多少?数据一致性要求到什么级别?团队里有几个熟悉SQL调优的人?”
最后分享一个血泪技巧:永远在项目根目录建一个/docs/decisions.md文件,记录每次技术选型的理由。比如写:“2023-08-15 选用Minimal API:因接口仅5个,且需在ARM64边缘设备运行,Minimal API镜像体积小37%,启动快65%”。两年后新人接手时,不用猜“为什么不用MVC”,直接看决策日志。这比任何架构图都管用。
