作为常年混迹在.NET圈子的开发者,最近我在为一个中小型制造企业重构他们的产品资料管理系统。甲方要求很直接:不要笨重的客户端,不要复杂的部署,只要能通过浏览器和接口方便地维护产品数据,并且能跟他们的电商网站对接。我最终选择了基于 ASP.NET Core MVC 8 架构的 WebAPI 方案,整套系统从立项到上线大概用了三周时间。这篇文章就把这次完整的实战过程拆解开,从框架选型、分层设计、核心接口实现到权限认证、性能调优,包括我踩过的坑和一些非常规的处理技巧,全部记录下来,希望能给正在做类似系统或者准备用 .NET 8 搞点事情的朋友一些参考。
很多人听到“ASP.NET Core MVC 8”可能第一反应是传统的服务端页面渲染那一套,但其实这个框架最大优势之一,就是它底层的 MVC 模式天然适合构建 RESTful 风格的 WebAPI。项目的落地方式是:后端暴露一套基于 JSON 的 HTTP 接口,前端管理界面用 Vue 单页应用负责渲染,这样做到了前后端完全解耦。这套产品管理系统最终实现了产品的创建、审批、上下架、多规格管理、历史版本追踪、批量导入导出,以及按品类和属性组合的复杂筛选。
1. 整体架构设计与项目初始化
1.1 为什么选 ASP.NET Core MVC 架构做 WebAPI
先解释一个容易混淆的点。很多人觉得 ASP.NET Core 里做接口,要么用极简的 Minimal API,要么用Controller。而项目名称里的“MVC”指的是这套系统的代码组织遵循了模型-视图-控制器模式,只不过这个“视图”从 Razor 页面变成了由前端框架消费的 JSON 数据。选用 Controller + 路由的方式,对于产品管理这种业务逻辑密集、接口数量多、资源关联复杂的系统来说,比 Minimal API 更有优势:每个控制器能按资源划分(ProductController、CategoryController、InventoryController),内置的模型绑定和模型校验机制可以直接把前端传过来的复杂 JSON 映射成强类型对象,不用像脚本语言那样逐个字段做防御性判断,这在接口数量达到三四十个以上时,维护成本差距非常明显。
还有一点,.NET 8 的 Web API 在 AOT 发布模式下依然支持 Controller,但 Minimal API 在 AOT 下表现更好。不过我们的产品系统因为要跑在客户的内网服务器上,不算极端追求冷启动速度,所以 Controller 方案的收益更大,换来的是更好的代码可读性以及团队新成员更容易上手的结构。
1.2 项目分层与解决方案结构
搭建这套系统我采用了一个经典的五层结构。并不是跟风“领域驱动设计”,而是针对产品管理这个实际业务场景做了一定裁剪:
ProductManager.sln ├── src/ │ ├── ProductManager.Api // WebAPI 入口与控制器层 │ ├── ProductManager.Application // 应用服务、DTO、接口定义 │ ├── ProductManager.Domain // 实体、领域枚举、业务规则 │ ├── ProductManager.Infrastructure // EF Core 数据访问、仓储实现、外部服务 │ └── ProductManager.Shared // 通用帮助类、异常处理、分页封装 └── tests/ └── ProductManager.UnitTests // 关键应用服务单元测试这个结构和网上大部分.NET项目模板类似,但有两点我特别想强调。第一,Domain 层绝对不引用 Infrastructure 层,所有数据访问都通过接口定义在 Application 层,而由 Infrastructure 层去实现。一旦以后要把 SQL Server 换成 PostgreSQL,或者是接入一个第三方产品数据源,只需要替换 Infrastructure 层的实现,API 层和 Domain 层的代码可以完全不动。第二,Shared 层是给所有上层共享的“工具人”,里面放的是 API 响应包装器、分页参数校验、日期时间处理这类无业务语义的逻辑。
1.3 初始化项目的几个关键操作
创建项目的时候,直接用 dotnet CLI 最省事。我习惯用命令而不是 Visual Studio 的向导,因为生成的内容干净且可控:
dotnet new webapi -n ProductManager.Api -f net8.0 dotnet new classlib -n ProductManager.Domain -f net8.0 dotnet new classlib -n ProductManager.Application -f net8.0 dotnet new classlib -n ProductManager.Infrastructure -f net8.0 dotnet new classlib -n ProductManager.Shared -f net8.0 dotnet new xunit -n ProductManager.UnitTests -f net8.0生成之后,需要把这些项目之间引用关系搭好。依次执行:
cd ProductManager.Api dotnet add reference ../ProductManager.Application ../ProductManager.Infrastructure ../ProductManager.Shared cd ../ProductManager.Application dotnet add reference ../ProductManager.Domain ../ProductManager.Shared cd ../ProductManager.Infrastructure dotnet add reference ../ProductManager.Application ../ProductManager.Shared注意,这里刻意没让 Api 层直接引用 Domain 层,因为 Api 层只通过 Application 层定义的应用服务接口与外界打交道,避免控制器直接操作实体对象,防止某些业务规则被绕过。这也让后面做单元测试时可以很方便地 mock 掉数据层。
2. 产品模型设计与核心存储方案
2.1 产品实体的字段设计思路
产品管理系统的核心是 Product 实体,但实际业务里一个产品没那么简单。它至少包含基本信息、销售信息、规格参数、图片和状态流转。使用 EF Core 的 Fluent API 配置实体关系,比堆 DataAnnotation 特性要灵活得多,尤其是在一个实体关联多个子表的情况下。
下面是我简化后的 Product 实体定义:
public class Product { public long Id { get; set; } public string ProductCode { get; set; } public string Name { get; set; } public string? Description { get; set; } public long CategoryId { get; set; } public decimal BasePrice { get; set; } public string Currency { get; set; } public ProductStatus Status { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } public string CreatedBy { get; set; } // 导航属性 public Category Category { get; set; } public ICollection<ProductSku> Skus { get; set; } public ICollection<ProductImage> Images { get; set; } public ICollection<ProductAttributeValue> AttributeValues { get; set; } }这里提一个需要特别留意的点:价格字段。如果你开发过电商或者进销存系统肯定有同感——钱的字段用 decimal 是铁律,但是做 API 返回 JSON 的时候,decimal 会被 System.Text.Json 序列化成 number 类型,有时候前端拿到会丢精度。为了避免这种严重的线上事故,我建议在 API 层加一个统一的 JSON 转换器,把 decimal 序列化为字符串,由前端再做解析,或者在前端约定好以“分”为单位的整数传输。这个项目里我选了后者,数据库里存 decimal,对外 DTO 里转成 long 类型以“分”为单位,这样既不存在浮点精度问题,支付和展示也方便。
产品状态机也是比较巧妙的设计。我用一个枚举表示 Draft、PendingApproval、Approved、Rejected、OnShelf、OffShelf,但有业务规则限制:只有 Approved 状态的产品才能上架,Draft 状态只能编辑不能删除。这个规则直接写在 Domain 层的一个静态方法里,比如:
public bool CanChangeStatus(ProductStatus target) { return (Status, target) switch { (ProductStatus.Draft, ProductStatus.PendingApproval) => true, (ProductStatus.PendingApproval, ProductStatus.Approved) => true, (ProductStatus.Approved, ProductStatus.OnShelf) => true, (ProductStatus.OnShelf, ProductStatus.OffShelf) => true, _ => false }; }2.2 EF Core 8 的数据库映射与迁移
EF Core 8 里面有些新特性确实很好用。比如复杂类型(Complex Types)的支持,我把“产品尺寸和重量”这类值对象直接映射成一个结构体字段,而不是单独建一张表。还有 JSON 列映射,产品参数这种带嵌套结构的属性,我直接映射成 SQL Server 的 nvarchar(max),同时通过 EF Core 的 JSON 序列化保存。不过这里要注意,SQL Server 本身有 JSON 类型支持,但 EF Core 提供的映射方式是在字段上设置转换器,这种方式在查询时不能很方便地对 JSON 内部字段进行过滤,如果你想用 JSON 内部字段做条件筛选,还是建议拆表。
迁移这块,我的做法是手动创建一条基线迁移。习惯上用 Package Manager Console 跑Add-Migration InitDb。不过团队协作时,我强烈建议把数据库结构和种子数据分开,用迁移里只放 Schema,种子数据放到一个单独的数据初始化器里,只在 Development 环境启动时执行。否则每次环境初始化都跑迁移里的种子数据,到了生产环境起来会非常慢。
3. 核心 WebAPI 接口实现与业务逻辑落地
3.1 统一响应格式与异常处理
产品管理系统的 API 不只是给内部管理后台用,还要提供给 ERP 拉取数据。所以接口的响应格式要有一个统一约定,不能一会儿返回裸对象,一会儿返回错误字符串。我定义了一个简单的 ApiResponse 包装:
public class ApiResponse<T> { public int Code { get; set; } public string Message { get; set; } public T Data { get; set; } }所有接口统一返回这个结构。正常情况 Code 为 0,业务异常 Code 为业务错误码(比如 10001 表示产品编码重复),系统异常 Code 为 500。这样做的好处是前端 axios 拦截器只需要判断 Code 是否为 0,就能决定走正常逻辑还是弹错误提示,不需要每个接口单独做错误处理逻辑。
伴随的这个是全局异常处理中间件。我在 .NET 8 里推荐用内置的IExceptionHandler接口,相比传统的UseExceptionHandler中间件实现,这个抽象更轻量,并且支持按异常类型注册多个处理器。我给系统分了以下几种异常处理策略:
BusinessException:返回对应的业务错误码和友好提示ValidationException:收集所有字段的校验错误,拼成格式化信息返回DbUpdateException:捕获唯一键冲突、外键约束错误,翻译成用户能看懂的话- 其他未捕获异常:记录完整堆栈,但只返回“系统繁忙,请稍后重试”,不暴露内部细节
这个处理机制一定要提前设计好。我第一次做这个系统的时候没有统一异常处理,控制器里到处是 try-catch,代码难看,而且有的异常没吞干净直接暴露了服务器内部错误,非常危险。所以强烈建议开发者在写第一个接口之前先把全局异常处理搭好。
3.2 产品创建与更新接口的幂等性设计
产品创建接口有个常见的坑——用户连续点击“保存”按钮导致重复提交。前端可以防抖,但是后端必须保证接口的幂等性。我在创建产品接口里引入了幂等键机制,前端每次渲染“新建产品”表单时,向后端申请一个Idempotency-Key放在 head 里,后端处理请求时检查这个 key 有没有被处理过,如果处理过就直接返回上一次的结果。
实现上很简单,用 Redis 存这个 key,value 是处理成功的响应内容,TTL 设置为 24 小时。核心逻辑:
public async Task<ApiResponse<ProductDto>> CreateProductAsync(CreateProductCommand cmd, string idempotencyKey) { var cached = await _cache.GetAsync<ApiResponse<ProductDto>>(idempotencyKey); if (cached != null) return cached; // 业务逻辑:检查编码唯一性、校验价格、插入数据库 var result = await DoCreateAsync(cmd); await _cache.SetAsync(idempotencyKey, result, TimeSpan.FromHours(24)); return result; }这样即便前端出现问题导致多次调用,后端也不会生成重复数据。注意这里 Redis 的操作和数据库写入不是原子的,但在这类管理系统中,重复提交造成的概率很低,配合异常处理里的唯一键捕获,基本能保证不产生脏数据。
3.3 复杂查询与分页的实用封装
产品列表页是最考验接口设计的一个点。一个产品可能有几百个 SKU,还有多个图片,直接全量返回不现实,所以必须分页。我用了一个通用的分页查询 DTO 和结果 DTO:
public class PagedRequest { public int PageIndex { get; set; } = 1; public int PageSize { get; set; } = 20; public string? Keyword { get; set; } public long? CategoryId { get; set; } public decimal? MinPrice { get; set; } public decimal? MaxPrice { get; set; } public ProductStatus? Status { get; set; } public string? SortBy { get; set; } public bool SortDesc { get; set; } } public class PagedResult<T> { public int TotalCount { get; set; } public int TotalPages { get; set; } public int PageIndex { get; set; } public int PageSize { get; set; } public List<T> Items { get; set; } }查询逻辑我建议用 IQueryable 的延迟执行特性和条件拼接。有个非常关键的点:分页时的 TotalCount 统计和列表数据查询,EF Core 在底层会生成两个 SQL,这没问题。但很多人容易忽略的是,分页后的 Items 里如果带了导航属性数据,N+1 查询问题就来了。解决方式是用 Include 的时候注意控制粒度,或者直接用投影成 DTO,只 select 需要展示的字段。比如列表页只需要显示主图和名称,就不要把全部图片集合加载出来。
我之前见过一个同事把产品的全部图片都序列化出来,每条记录返回几十KB的图片路径字符串,结果一个分页接口返回了快10MB的数据,前端卡到崩溃。这类问题在系统上线前很难被发现,因为测试环境数据量小,一旦生产环境产品多了就彻底暴露。所以列表接口一定只返回列表页需要的最小字段。
3.4 文件上传与图片处理的 API 实践
产品图片上传是绕不开的。这个系统里我做了两种场景:单个主图上传和批量相册上传。文件上传接口和后端 API 分开部署,图片存到服务器的指定目录,同时数据库里只记录图片的相对路径。
在实现上,我必须提醒几个文件上传的安全细节。第一,校验文件扩展名和 MIME 类型,防止上传可执行文件;第二,限制文件大小,我这边默认单个文件不超过 5MB;第三,文件名绝对不要用用户上传的原始名字,服务端用 GUID 重新生成文件名,避免路径遍历攻击。文件存储的目录结构按年/月/日分文件夹,这样图片数量大了以后,即便在 Windows 服务器上文件夹里文件过多导致访问变慢,也可以方便的迁移和归档。
图片处理方面我用了一个小巧的库实现缩略图生成。产品列表页需要 200x200 的缩略图,详情页需要 800 宽的展示图。在后端上传成功后直接生成三个尺寸的图片,分别存到 thumbnails 和 originals 目录下,这样客户端按需取用,不需要请求时再动态压缩,省掉了图片处理服务的内存消耗。
4. 认证授权与系统安全加固
4.1 基于 JWT 的认证方案与刷新令牌
产品管理系统即使部署在内网,也不能裸奔。认证我选择了 JWT,用标准的 Bearer Token 方案。.NET 8 里配置 JWT 认证非常成熟,主要工作集中在appsettings.json和 Program.cs 的注册代码里:
"Jwt": { "Issuer": "ProductManager", "Audience": "ProductManager.Client", "SecretKey": "a-very-long-random-string-at-least-32-characters", "ExpireMinutes": 120, "RefreshExpireDays": 7 }注册服务的代码:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidateAudience = true, ValidAudience = builder.Configuration["Jwt:Audience"], ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(1), ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"])) }; });JWT 本身是无状态的,但客户端拿到 token 之后如果用户被禁用,只要 token 没过期他依然能访问接口。所以我在 JWT 里加了用户 ID,同时写了一个中间件,每次请求时从数据库查询用户状态,如果已被禁用直接返回 403。考虑到内网系统 QPS 不高,这个开销可以接受,比引入分布式会话管理简单得多。
刷新令牌的实现,我选择把它存到数据库里,用 RefreshToken 表维护。用户在调用刷新接口时,服务端校验刷新令牌是否存在、有没有过期、有没有被吊销。刷新成功后,旧 RefreshToken 立即标记为已使用,并签发一组新的令牌。这样可以避免刷新令牌重放攻击。
4.2 基于策略的接口权限控制
产品管理系统的角色一般包括:运营(只能改内容)、审核(只能审核)、管理员(全权限)。传统的做法是在控制器 Action 上加[Authorize(Roles = "Admin")],但对于一些细粒度权限,角色判断就不够灵活了。
我用的是基于策略的授权。在 Program.cs 里注册权限策略:
builder.Services.AddAuthorization(options => { options.AddPolicy("ProductEdit", policy => policy.RequireClaim("permissions", "product.edit")); options.AddPolicy("ProductApprove", policy => policy.RequireClaim("permissions", "product.approve")); options.AddPolicy("ProductDelete", policy => policy.RequireClaim("permissions", "product.delete")); });然后在控制器里直接用[Authorize(Policy = "ProductEdit")]。用户登录时在后端根据角色动态计算权限列表放到 JWT 的 claims 里,这样改角色权限只需要修改权限映射表,不需要重新登录。
这种设计的优势在系统里很明显,因为产品管理经常出现“审核员只能审核自己负责品类的产品”这类规则。如果只靠 Role 做全局判断,根本无法表达这种数据范围限制,而用策略加自定义IAuthorizationHandler就能在授权时检查当前用户负责的品类范围,非常灵活。
4.3 敏感数据防护与操作日志
系统中的产品成本价、供应商价格属于敏感信息。对于不拥有“成本查看”权限的用户,就算他能调接口拿到详情,也应该被脱敏。这个我直接在应用服务层做了字段脱敏:返回 DTO 里的 CostPrice 字段,如果用户不在允许名单内,填入 0 或者 null,并标记一个 HasCostPermission = false 的标识。
操作日志这一块,我集成了一套简单的审计系统。用一个 ActionFilterAttribute 标记需要审计的接口,在请求完成之后异步记录请求者的 ID、请求的控制器和方法、参数摘要、响应状态码以及执行耗时。这里的参数摘要不能直接序列化整个请求体,因为密码、令牌这类敏感字段也会被写进日志,而且有些大对象序列化非常慢。我的做法是定义每个接口的审计快照 DTO,只挑重要的业务字段写入日志。
日志存储用的 SQL Server 的一个 AuditLog 表,索引按请求时间和用户 ID 建立。后续做问题排查和安全审计的时候非常有用。比如曾有一次产品被误删除,我查操作日志,直接定位到那个账号和一个可疑的批量导入操作,很快就还原了现场。
5. 部署发布与性能优化实战
5.1 使用 Docker 部署 .NET 8 应用的完整配置
虽然项目跑在客户内网,我还是坚持用 Docker 部署。因为现在客户服务器环境复杂,有的装的是 CentOS,有的居然还是 Windows Server 2012,手动装 .NET 8 运行时再跑 dotnet 命令,环境差异导致的坑太多了。用 Docker 可以保证“在我这台机器能跑,在客户那台也一定能跑”。
先写一个常规的多阶段构建 Dockerfile:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY ["src/ProductManager.Api/ProductManager.Api.csproj", "src/ProductManager.Api/"] COPY ["src/ProductManager.Application/ProductManager.Application.csproj", "src/ProductManager.Application/"] COPY ["src/ProductManager.Domain/ProductManager.Domain.csproj", "src/ProductManager.Domain/"] COPY ["src/ProductManager.Infrastructure/ProductManager.Infrastructure.csproj", "src/ProductManager.Infrastructure/"] COPY ["src/ProductManager.Shared/ProductManager.Shared.csproj", "src/ProductManager.Shared/"] RUN dotnet restore "src/ProductManager.Api/ProductManager.Api.csproj" COPY . . WORKDIR "/src/src/ProductManager.Api" RUN dotnet publish "ProductManager.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final WORKDIR /app EXPOSE 8080 COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "ProductManager.Api.dll"]这里有个小技巧,/p:UseAppHost=false是为了避免生成 Linux 原生的可执行文件,输出纯 DLL,这样镜像体积更小。容器内使用 8080 端口,而不是默认的 80,因为非 root 用户跑容器时 80 端口可能没有绑定权限。我在 docker-compose 里把宿主机的 5000 端口映射到容器的 8080。
生产环境的配置,我用环境变量覆盖 appsettings.json。比如连接字符串:
services: productmanager-api: image: productmanager-api:latest ports: - "5000:8080" environment: - ASPNETCORE_ENVIRONMENT=Production - ConnectionStrings__Default=Server=sqlserver;Database=ProductDb;User Id=sa;Password=xxx;Encrypt=True;TrustServerCertificate=True - Jwt__SecretKey=from-environment restart: unless-stopped看到ConnectionStrings__Default这种带双下划线的写法了吗,这是 ASP.NET Core 的环境变量配置约定,它会把__解释为层级关系,从而映射到ConnectionStrings:Default。在 Linux 环境变量里不能带冒号,所以都用双下划线代替。这一点对不熟悉配置优先级的人来说是很容易搞错的地方,记一下。
5.2 性能调优与缓存策略
产品管理系统的查询压力主要在列表页和详情页。详情页的数据相对稳定,适合缓存。我在应用服务层做了一层内存缓存,用 ProductId 作为 key,缓存整个 ProductDetailDto,过期时间设置为 5 分钟。这样同一时间大量用户访问同一个产品详情页的时候,绝大多数请求直接命中内存,数据库压力瞬间下降不少。
不过内存缓存有个明显的问题:如果产品资料被修改,缓存不失效就会展示旧数据。所以我在所有修改产品的应用服务方法里,统一调用一个缓存移除方法。为了不遗漏,我这里是基于一个简单的约定:所有以“UpdateProduct”“ChangeProduct”“DeleteProduct”开头的方法,都先去缓存移除对应 key。这个约定在代码审查的时候能看到,不会因为某个人漏写导致脏数据。
列表查询我用的是分页 SQL,EF Core 生成的查询性能可以接受。但如果账号体系里品类非常多,而且每个品类下有大量产品,建议对搜索结果加一个 Redis 缓存,key 为当前筛选条件和页码的哈希值。这里需要计算一下缓存穿透的风险:如果用户修改了产品,那与之相关的所有搜索缓存全部失效。方案是给每个产品维护一个修改时间戳,搜索缓存 key 里带上这个时间戳,如果该品类下任何产品有变动,整个品类的搜索缓存 key 就变了。这样调整的粒度可能大一些,但是实现简单,很实用。
5.3 健康检查与日志聚合
Docker 部署之后,需要一套健康检查机制。我在 Program.cs 里注册了 ASP.NET Core 自带的健康检查服务,并暴露/healthz端点。同时检查数据库连接和 Redis 连接状况:
builder.Services.AddHealthChecks() .AddDbContextCheck<ProductDbContext>() .AddRedis(builder.Configuration.GetConnectionString("Redis"));docker-compose 里也配置了 healthcheck:
healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"] interval: 30s retries: 3这样即便容器进程还在,但健康检查失败,Docker 也会自动重启容器。日志方面,控制台默认输出结构化日志,我用一个 Logstash 侧车容器把日志转发到 Elasticsearch,但客户环境基础设施有限,最终使用的是更轻量的方案:应用直接写 JSON 格式日志文件,每天一个文件,用脚本做归档。如果以后真的遇到需要集中检索日志的场景,再迁移到 ELK 也不迟。
6. 常见问题与排查实录
6.1 迁移与数据库版本不一致的翻车处理
有一次我在开发环境跑得好好的,一到测试环境就报数据库上下文模型和数据库不一致的错误。查了半天,发现测试环境的连接字符串指向的是一个旧数据库,它没有应用最新的迁移,而我又在启动时开启了MigrateAsync()自动迁移,但那次自动迁移因为脚本报错导致迁移表状态不一致。
现在的做法是:部署时不依赖自动迁移,而是由专门的发布步骤手动执行dotnet ef database update,并且在迁移脚本上加入了幂等性处理。如果团队项目,一定要把自动迁移放在单独的初始化任务里,不要放在应用启动路径中。生产环境绝不能每次启动都检查迁移,否则版本回滚时数据库结构可能会被意外更新。
6.2 JSON 循环引用导致的接口假死
产品实体里 Product 引用了 Category,Category 又导航到一个 Product 列表。如果在控制器里直接返回实体而忘了映射成 DTO,序列化时就会产生循环引用。在老版本 Newtonsoft.Json 里会抛异常,而新版 System.Text.Json 默认会抛出JsonException。但更诡异的是,如果用的还是旧版序列化设置,可能直接栈溢出把进程打死。
这个问题的根源是没遵守“API 层永远不直接返回领域实体”的铁律。我后来在 Api 层加了一个过滤器,直接在启动代码里把循环引用处理设置为忽略:
builder.Services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; });这个配置只是兜底,避免程序直接崩溃。但正确做法还是要确保所有返回类型都是 DTO,导航属性只保留需要暴露的 Id 和外键,绝不直接序列化整个对象图。
6.3 大文本字段导致的接口超时
产品详情经常包含很长的规格说明,数据库字段是 nvarchar(max),前端可以保存几万字的描述。有一次客服反馈“接口保存经常失败,转圈很久”,排查后发现是请求体太大,Kestrel 默认请求体大小限制是 30MB(默认没有限制?其实默认 Kestrel 是 30,000,000 字节),但我们的网关层还套了一层 nginx,client_max_body_size默认是 1MB,超过就返回 413。
解决方式是两层都改。nginx 里设置client_max_body_size 20m;,API 层在 Program.cs 里配置:
builder.Services.Configure<KestrelServerOptions>(options => { options.Limits.MaxRequestBodySize = 20 * 1024 * 1024; });同时把这个配置写入部署文档,后来再也没有因为大文本出过问题。
6.4 时区混乱导致的上下架时间错乱
产品管理系统里有定时上下架功能。数据库存的是 UTC 时间,后端返回时间也统一 UTC,前端浏览器把时间戳转成本地时间显示,正常情况下没有问题。但有一次客户说“产品明明设置今晚 11 点上架,结果早上 9 点就上架了”。
查了半天,发现定时任务是在服务器上跑的一个控制台程序,服务器系统时区是 UTC,但客户和产品经理都在东八区,他们直接按北京时间填写的配置,控制台读取后当成了 UTC 来执行。
这个问题的根治办法:存储统一用 DateTimeOffset,所有接口的输入时间都带时间偏移量,解析后统一换算成 UTC 再存库。定时任务执行计划时也用 DateTimeOffset.Now 与数据库中的 UTC 时间比较。前端提交秒杀时间、定时上架时间等,必须传带时区的 ISO8601 字符串,不能让后端自己去猜时间是什么时区。这个约定从一开始就要确定,不然后续排查成本极高。
7. 测试与质量保障
7.1 集成测试模拟真实 HTTP 请求
产品管理系统的接口比较多,如果全靠手动测,每次上线都是一种折磨。我搭了一个集成测试项目,基于WebApplicationFactory<Program>,它可以真实启动一个测试服务器,然后通过 HttpClient 向 API 发送请求。测试项目里写了一个基础类,专门用来获取 JWT Token:
public class TestBase : IClassFixture<WebApplicationFactory<Program>> { protected readonly WebApplicationFactory<Program> Factory; public TestBase(WebApplicationFactory<Program> factory) { Factory = factory.WithWebHostBuilder(builder => { builder.UseEnvironment("Testing"); builder.ConfigureServices(services => { // 替换为内存数据库 var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<ProductDbContext>)); services.Remove(descriptor); services.AddDbContext<ProductDbContext>(opt => opt.UseInMemoryDatabase("TestDb")); }); }); } }集成测试的价值在于可以在不打开 Swagger 的情况下,把产品的创建、修改、审批、上架这一整套流程跑一遍,任何一个环节出问题都能直观地看到接口返回了什么样的错误。尤其是像权限策略、幂等键、分页边界这种手动测试容易忽略的点,写成自动化用例之后每次回归都会跑,心里踏实很多。
7.2 接口文档与前端协作体验
在 .NET 8 里,Swagger 升级为 OpenAPI 文档生成方案,配合微软官方的Microsoft.AspNetCore.OpenApi包,可以直接通过dotnet add package Microsoft.AspNetCore.OpenApi安装运行。而且 .NET 8 还支持在构建时生成 OpenAPI 文档,效率比之前的 Swashbuckle 好很多。
接口文档我加了比较详细的 XML 注释,同时在每个控制器上标注了[Tags],方便前端同事根据模块筛选接口。对了,有一个调试技巧分享一下:如果想要接口文档里自动显示 JWT 授权的“Authorize”按钮,需要把安全方案定义配置到 OpenAPI 生成器里。有的新项目里忘了配,前端每次调试只能手动在请求头里塞 token,很麻烦。
8. 经验总结与延伸思考
8.1 项目交付后的性能观察与调优
系统上线运行了一个月之后,我导出了数据库统计和 API 访问日志。目前产品总数接近两万,SKU 接近八万,每天 API 请求量在五万次左右。接口平均响应时间在 60 到 120ms 之间,绝大多数请求都稳定在 200ms 以内。这在单机部署、没有做读写分离的情况下,性能表现是可接受的。
让我印象最深的是分页查询接口的性能。产品列表页默认按更新时间倒序分页,这个排序字段必须有索引,否则数据量达到一万以上,深度分页时 SQL 会越来越慢。我加了一个复合索引(Status, UpdatedAt DESC),然后配合Offset/Next游标分页替代了传统的OFFSET分页,在翻到几百页时性能依然稳定。如果将来数据量继续增长,可以按需再引入 Elasticsearch 做全文搜索,但目前的业务规模还不需要。
8.2 针对这套架构的优缺点复盘
从技术选型角度,ASP.NET Core MVC 8 WebAPI 的最大优势是生态成熟、内置功能全面、性能出色。团队里有人不熟悉 .NET,但通过 Swagger 文档和清晰的 DTO 设计,也能很快上手。缺点是什么?首先,基于 Controller 的样板代码确实比 Minimal API 多一点,如果团队人少任务急,代码量会显得冗余;其次,一旦使用 EF Core 作为主要数据访问,对 SQL 不熟悉的开发者容易写出低效的查询,比如没有正确使用 Include 导致 N+1 查询,或者没留意到某些写法在客户端执行而不是数据库执行。
还有一点必须承认,产品管理系统的业务逻辑远比“增删改查”复杂。状态机、审批流、规格模板、多语言字段,这些业务规则如果用传统三层架构硬写,领域层很容易变成一个“数据容器”。如果你预计项目后期业务规则会非常复杂,建议从一开始就引入命令查询职责分离,把写操作和读操作完全分开。这套系统已经这么设计了,只是没有做事件溯源,复合业务规则直接由应用服务编排,代码追起来还算直接。
8.3 给后来者的几条具体建议
如果让我重新做一次,或者你准备启动一个类似的项目,有几件事我认为必须从第一天就规划好:
第一,DTO 与实体的映射要尽早用上映射库。手写几十个 DTO 的赋值还能接受,但到几百个就会耗尽耐心。AutoMapper 虽然不少人不喜欢它“魔法”一样的隐式行为,但配好 Profile 之后,效率提升是显著的。如果不喜欢这类库,至少也要定义扩展方法或者static implicit operator做映射,别在控制器里手写一大堆赋值。
第二,接口幂等性、分页参数上限、文件上传大小限制这类“边缘防御”不要等上线再说。等数据量大或者被外部系统调用之后,你会感谢当初多写的几十行校验代码。我见过太多系统上线半年后因为一个超大分页参数导致数据库 CPU 飙升。
第三,日志记录的投入是最值得的。不需要一开始上分布式链路追踪,把 Serilog 或内置日志按结构化格式写好,带上请求 ID、用户 ID、耗时和关键业务参数,遇到问题能省一半排查时间。我是用ILogger<Program>记录操作日志,再配合一个简单的自定义中间件把 HttpContext 的 TraceId 串进去,虽然不如 OpenTelemetry 那么完整,但对付单机系统完全够了。
最后再说一句心里话。技术选型永远不是追求最新最炫,而是看它能不能贴合你当前团队的能力和业务场景。ASP.NET Core MVC 8 这套方案对我来说最大的价值,是它在提供高生产力的同时,没有限制住系统的演进空间。如果你刚好也准备做产品管理、订单中心、内容管理这类业务系统,完全可以参考这个项目的结构,把基础设施先搭牢,业务逻辑再慢慢填充,这个路径我实测下来是相当稳的。