构建坚如磐石的.NET Web API:从清洁架构到卓越开发者体验的完整指南

作者:微信公众号:【架构师老卢】
9-23 14:32
848

构建一个经得起时间考验的.NET Web API,不仅仅是将控制器和端点连接起来。您需要一个清晰的架构、分离关注点的模式、自文档化的接口、健壮的错误处理以及开发者友好的环境。在这份增强版指南中,我们将引导您从基本概念到高级技术——包含详细的解释、最佳实践的原理以及您可以立即采用的代码片段。

  1. 拥抱清洁架构 清洁架构是一种组织代码的方式,使得系统的不同部分职责清晰,并且依赖关系向内流动——从表示层到领域层。这种分离有助于团队在UI、业务逻辑或数据访问上独立工作,而不会相互干扰。

当有新成员加入项目时,他们可以立即识别出在哪里找到业务规则(领域层)、工作流(应用层)和HTTP端点(表示层)。更换数据库、升级框架或重构UI代码变成了只需更改某一层而无需重写其余部分的问题。您还会对单元测试充满信心,因为您的核心逻辑不直接依赖于ASP.NET Core或Entity Framework。

层级职责

+---------------------+
|    表示层/Presentation | ←→ HTTP, 控制器, 过滤器
+---------------------+
|   应用层/Application   | ←→ 用例, CQRS 接口
+---------------------+
|    领域层/Domain      | ←→ 实体, 值对象, 规则
+---------------------+
| 基础设施层/Infrastructure | ←→ EF Core, 外部服务, 文件I/O
+---------------------+
  • 表示层:这里是控制器、模型绑定和授权发生的地方。它将HTTP请求转换为应用请求(DTO或命令),并将响应映射回HTTP格式和状态码。
  • 应用层:包含用例逻辑。您定义接口(例如,仓储或外部API客户端),并编排领域对象以完成特定任务,如"创建订单"或"获取用户档案"。这里没有直接的数据库或框架调用。
  • 领域层:您的核心业务概念存在于此:实体、值对象、领域事件和业务规则。它对外部框架的依赖为零,确保您的业务逻辑与框架无关且高度可测试。
  • 基础设施层:实现应用层接口。这是您配置EF Core、HTTP客户端、文件存储和任何第三方库的地方。从SQL Server切换到MongoDB只需要在该层编写新的实现。
  1. 项目结构与代码示例 一致的解决方案布局可以降低任何人探索代码库的门槛。开发人员可以直接跳转到与其任务相关的文件夹——无论是添加新功能、修复错误还是编写测试。
src/
  MyApp.Domain/
    Entities/
    ValueObjects/
  MyApp.Application/
    Interfaces/
    Features/
      Products/
        Commands/
        Queries/
        DTOs/
  MyApp.Infrastructure/
    Data/
      MyAppDbContext.cs
    Repositories/
  MyApp.Api/
    Controllers/
    Middleware/
tests/
  MyApp.Application.Tests/
  MyApp.Api.IntegrationTests/
  • Domain 文件夹:存放纯业务对象,如 ProductOrder
  • Application 文件夹:按功能对用例进行分组,将命令、查询、处理程序和DTO放在一起。
  • Infrastructure:实现数据存储或调用外部服务的方式。
  • Api:将所有内容连接起来:依赖注入、中间件、控制器和程序启动。
  • Tests:镜像您的主结构,清晰地表明哪些测试覆盖了代码的哪一部分。

示例仓储接口和DTO

// MyApp.Application/Interfaces/IProductRepository.cs
public interface IProductRepository
{
    Task<IEnumerable<Product>> ListAsync(CancellationToken cancellationToken);
    Task<Product> GetByIdAsync(Guid id, CancellationToken cancellationToken);
    Task AddAsync(Product product, CancellationToken cancellationToken);
    Task UpdateAsync(Product product, CancellationToken cancellationToken);
    Task DeleteAsync(Guid id, CancellationToken cancellationToken);
}

// MyApp.Application/Features/Products/ProductDto.cs
public record ProductDto(Guid Id, string Name, decimal Price);

在此代码片段中,仓储接口定义了CRUD操作,而没有泄露EF Core的细节。ProductDto 精确地定义了API返回的数据形状,防止意外暴露内部领域状态。

  1. 使用 MediatR 实现 CQRS 命令查询职责分离(CQRS)是一种将读操作(查询)与写操作(命令)分离的模式。读操作侧重于快速高效地返回数据,而写操作侧重于强制执行业务规则和一致性。这种分离可以提高可伸缩性、清晰度和性能调优能力。

MediatR 通过将命令和查询对象路由到它们各自的进程内处理程序来将它们粘合在一起。您不是直接将仓储或服务注入控制器,而是向MediatR的中介器发送请求,让它调用正确的处理程序。

设置 MediatR

  1. 安装包:
    dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
    
  2. 在 Program.cs 中注册 MediatR:
    builder.Services.AddMediatR(cfg => 
     cfg.RegisterServicesFromAssembly(typeof(GetAllProductsHandler).Assembly));
    

示例:读端(查询)

// 查询定义
public record GetAllProductsQuery() : IRequest<IEnumerable<ProductDto>>;

// 处理程序实现
public class GetAllProductsHandler : IRequestHandler<GetAllProductsQuery,
                                     IEnumerable<ProductDto>>
{
    private readonly IProductRepository _repository;
    public GetAllProductsHandler(IProductRepository repository)
                                 => _repository = repository;

    public async Task<IEnumerable<ProductDto>> Handle(GetAllProductsQuery request,
                        CancellationToken ct)
    {
        var products = await _repository.ListAsync(ct);
        return products.Select(p => new ProductDto(p.Id, p.Name, p.Price));
    }
}

这里,查询对象建模了一个简单的"列出所有产品"请求。它的处理程序知道如何获取领域实体,将它们映射到DTO并返回。您的控制器只需要一行代码:await _mediator.Send(new GetAllProductsQuery());

示例:写端(命令)

// 命令定义
public record CreateProductCommand(string Name, decimal Price) : IRequest<Guid>;

// 处理程序实现
public class CreateProductHandler : IRequestHandler<CreateProductCommand, Guid>
{
    private readonly IProductRepository _repository;
    public CreateProductHandler(IProductRepository repository)
        => _repository = repository;
    public async Task<Guid> Handle(CreateProductCommand cmd, CancellationToken ct)
    {
        var product = new Product(cmd.Name, cmd.Price); // 强制执行领域不变性
        await _repository.AddAsync(product, ct);
        return product.Id;
    }
}

命令代表改变状态的意图。在编写代码时,您只专注于正确构建您的领域实体。验证、日志记录或事务行为可以作为MediatR管道行为添加——保持处理程序的专注和可测试性。

  1. 使用 Swagger 生成 API 文档 Swagger(OpenAPI)自动为您的API生成交互式文档。该文档既作为前端和后端团队之间的契约,也作为无需编写单独客户端即可测试端点的实时演练场。

当您集成 Swagger 时:

  • 开发者可以在一个地方查看所有可用的端点、必需参数和响应类型。
  • 质量保证和产品团队可以直接从浏览器手动测试场景。
  • 新团队成员通过交互式探索API界面可以更快地上手。

基本 Swagger 设置

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
   c.SwaggerDoc("v1", new() { Title = "MyApp API", Version = "v1" });
   var xmlPath = Path.Combine(AppContext.BaseDirectory,
       $"{Assembly.GetExecutingAssembly().GetName().Name}.xml");
   c.IncludeXmlComments(xmlPath);
});
  • AddEndpointsApiExplorer() 扫描您的控制器以获取端点元数据。
  • AddSwaggerGen() 注册Swagger生成服务,并允许您添加XML注释以获取更丰富的描述。

注解您的控制器 通过使用XML注释和响应属性装饰控制器操作,您可以确保Swagger显示清晰的描述和示例模型。

/// <summary>
/// 检索目录中的所有产品。
/// </summary>
/// <returns>产品列表。</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> GetAll()
{
    var dtos = await _mediator.Send(new GetAllProductsQuery());
    return Ok(dtos);
}
  • <summary> 标签显示为方法描述。
  • [ProducesResponseType] 阐明了可能的HTTP状态码和有效负载,改善了客户端错误处理和对SDK的自动生成。
  1. API 版本控制策略 随着API的发展,您将引入新字段、弃用旧行为,甚至重新排序响应负载。版本控制让您可以逐步推出更改,而不会破坏现有客户端。

常见版本控制方法 [图示:URI路径版本控制、查询字符串版本控制、请求头版本控制]

配置版本控制

builder.Services.AddApiVersioning(options =>
{
   options.DefaultApiVersion = new(1, 0);
   options.AssumeDefaultVersionWhenUnspecified = true;
   options.ReportApiVersions = true;
   options.ApiVersionReader = new UrlSegmentApiVersionReader();
});
builder.Services.AddVersionedApiExplorer(setup =>
{
   setup.GroupNameFormat = "'v'VVV";
   setup.SubstituteApiVersionInUrl = true;
});
  • DefaultApiVersion 设置当客户端未指定任何版本时的基线。
  • ReportApiVersions 返回类似 api-supported-versions 的头部,以便客户端知道有哪些版本可用。
  • IApiVersionDescriptionProvider 与Swagger集成,自动为每个版本生成单独的文档。

按版本装饰控制器

[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ProductsController : ControllerBase
{
    // GET api/v1/products
    [HttpGet]
    public IActionResult GetV1() => Ok(/* v1 有效负载 */);

    // GET api/v2/products
    [HttpGet, MapToApiVersion("2.0")]
    public IActionResult GetV2() => Ok(/* 包含额外字段的扩展 v2 有效负载 */);
}

客户端可以通过提升其URL版本段来选择加入新行为。同时,旧客户端继续访问v1而不受影响。

  1. 全局错误处理中间件 在复杂的API中,错误可能来自无效输入、缺失数据或未处理的异常。与其在每个控制器中到处使用try/catch块,不如使用中间件来捕获异常并将其转换为一致的JSON有效负载。

为什么需要全局错误处理?

  • 一致性:每个错误都遵循相同的JSON模式,使得客户端解析可预测。
  • 安全性:内部堆栈跟踪永远不会泄露给客户端。
  • 可维护性:在一个地方添加新的异常映射即可覆盖所有端点。

中间件实现

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public ExceptionHandlingMiddleware(RequestDelegate next) => _next = next;
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException ex)
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new
            {
                Title = "验证失败",
                Errors = ex.Errors
            });
        }
        catch (KeyNotFoundException)
        {
            context.Response.StatusCode = StatusCodes.Status404NotFound;
            await context.Response.WriteAsJsonAsync(new { Title = "资源未找到" });
        }
        catch (Exception)
        {
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            await context.Response.WriteAsJsonAsync(new
            {
                Title = "发生意外错误。",
                TraceId = context.TraceIdentifier
            });
        }
    }
}

在 Program.cs 中的路由之前注册此中间件:

app.UseMiddleware<ExceptionHandlingMiddleware>();
  1. 开发者体验(DX)增强 卓越的开发者体验(DX)可以减少摩擦、最小化上下文切换,并鼓励团队成员专注于价值而非工具。以下是一些关键实践:

EditorConfig + Roslyn 分析器 强制执行一致的风格并及早捕获错误。

dotnet add package Microsoft.CodeAnalysis.FxCopAnalyzers

热重载 监视代码更改并立即应用它们,而无需重新构建。

dotnet watch --project MyApp.Api

预提交钩子 在每次提交时自动运行 dotnet format 和单元测试,以防止不良代码进入仓库。

全局工具

  • dotnet-ef 用于数据库迁移
  • dotnet-format 用于代码清理
  • dotnet-reportgenerator 用于覆盖率指标

结构化日志记录和遥测 集成Serilog或Azure Application Insights。将关联ID附加到HTTP请求,以便您可以从API表面向下追踪问题直到数据库查询。

这些增强功能中的每一项都使开发人员能够充满信心和速度地编写、测试和交付功能。


通过使用清洁架构对代码进行分层、通过MediatR采用CQRS、使用Swagger进行文档记录、一致地处理版本和错误以及增强开发者工作流程,您将创建一个有弹性、可扩展的API界面。

您精通之路的下一步:

  • 探索使用Redis或内存存储的缓存策略。
  • 使用IdentityServer或Azure AD B2C实现OAuth2和OpenID Connect。
  • 使用OpenTelemetry添加分布式追踪以检测跨微服务的瓶颈。
  • 使用Polly引入弹性模式——断路器、重试、舱壁隔离。

掌握了这些基础知识和详细解释后,无论您的业务需求如何发展,维护和扩展您的.NET Web API都将变得轻松愉快。

相关留言评论
昵称:
邮箱:
阅读排行