为什么需要统一响应格式

在前后端分离的开发模式中,API 接口的返回格式是否统一,直接关系到前端开发的效率和协作体验。如果后端每个接口都”各自为战”——有的返回裸数据,有的包了一层 { status, data },有的出错直接抛 500——前端就得为每个接口单独写解析逻辑,调用代码里到处都是 if (res.code === 0) { ... } else if (res.code === 200) { ... },维护起来非常痛苦。

统一响应格式的好处很直接:

  • 前端解析逻辑统一:所有接口返回 { code, message, data },前端只需写一套拦截器和类型定义。
  • 异常处理集中化:业务异常、系统异常都被包装成统一结构,前端无需区分 HTTP 状态码和业务码。
  • 文档与契约更清晰:Swagger 等工具能基于统一返回类型自动生成文档,前后端契约一目了然。
  • 日志与监控更方便:统一结构便于在网关或日志中间件中提取关键字段做埋点。

一个常见的统一响应结构长这样:

{
  "code": 200,        // 业务状态码,200 表示成功
  "message": "success", // 提示信息
  "data": { ... }      // 实际业务数据
}

实现方案对比:过滤器 vs 中间件

在 ASP.NET Core 中,实现统一响应格式通常有两条路:Action Filter(过滤器)Middleware(中间件)。它们各自适合不同的场景。

Action Filter 方案

过滤器作用于 MVC 管道内部,能拿到 ActionExecutedContext,可以直接读取和替换 Action 的返回值。优点是类型安全、能拿到 Action 描述信息;缺点是只能拦截进入 MVC 管道的请求,对于静态文件、健康检查、404 路由不到的请求,过滤器根本不会执行。

// 过滤器方案示例(简要)
public class UnifiedResultFilter : IActionFilter, IOrderedFilter
{
    public int Order => int.MaxValue - 10; // 尽量晚执行

    public void OnActionExecuting(ActionExecutingContext context) { }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        // 只包装成功且返回 ObjectResult 的情况
        if (context.Result is ObjectResult objectResult)
        {
            objectResult.Value = new
            {
                code = 200,
                message = "success",
                data = objectResult.Value
            };
        }
    }
}

Middleware 方案

中间件作用于更外层的请求管道,所有请求都会经过,包括未命中的路由、异常、甚至静态资源。它通过”窃取”响应流的方式,在响应写回客户端之前进行重新包装。这是目前业界更通用的做法,本文重点介绍。

对比项 Action Filter Middleware
作用范围 仅 MVC 管道 整个请求管道
异常处理 需配合 ExceptionFilter 可统一在中间件中处理
404/未路由请求 不包装 可包装
实现复杂度 简单、类型安全 需操作响应流,略复杂
推荐场景 仅需包装 Action 返回值 全局统一,含异常与 404

中间件实现完整代码

下面是一个生产可用的统一响应中间件实现,核心思路是:用 MemoryStream 替换原始响应流,等下游执行完后读取内容,判断是否需要包装,再写回原始流

using System.Text.Json;

/// <summary>
/// 统一响应格式中间件
/// 将所有 JSON 响应包装成 { code, message, data } 结构
/// </summary>
public class UnifiedResponseMiddleware
{
    private readonly RequestDelegate _next; // 下一个中间件委托

    public UnifiedResponseMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        // 保存原始响应流的引用,后续要恢复
        var originalBodyStream = context.Response.Body;

        // 用内存流替换响应流,以便读取下游写入的内容
        using var memoryStream = new MemoryStream();
        context.Response.Body = memoryStream;

        try
        {
            // 继续执行管道
            await _next(context);

            // 仅对 200 且 JSON 类型的响应进行包装
            if (context.Response.StatusCode == 200 &&
                context.Response.ContentType?.Contains("application/json") == true)
            {
                // 读取内存流中的响应内容
                memoryStream.Seek(0, SeekOrigin.Begin);
                var responseBody = await new StreamReader(memoryStream).ReadToEndAsync();
                var result = JsonSerializer.Deserialize<JsonElement>(responseBody);

                // 检查是否已经是统一格式(避免重复包装)
                if (!result.TryGetProperty("code", out _))
                {
                    // 包装成统一结构
                    var unifiedResponse = new
                    {
                        code = 200,
                        message = "success",
                        data = result
                    };
                    var json = JsonSerializer.Serialize(unifiedResponse);

                    // 恢复原始流并写入包装后的内容
                    context.Response.Body = originalBodyStream;
                    await context.Response.WriteAsync(json);
                    return;
                }
            }

            // 不需要包装的情况,把内存流内容原样拷回原始流
            memoryStream.Seek(0, SeekOrigin.Begin);
            await memoryStream.CopyToAsync(originalBodyStream);
        }
        catch (Exception ex)
        {
            // 异常统一包装为 500 错误响应
            context.Response.Body = originalBodyStream;
            context.Response.StatusCode = 500;
            context.Response.ContentType = "application/json";
            var errorResponse = JsonSerializer.Serialize(
                new { code = 500, message = ex.Message, data = (object)null });
            await context.Response.WriteAsync(errorResponse);
        }
        finally
        {
            // 始终恢复原始响应流,避免后续中间件写入失败
            context.Response.Body = originalBodyStream;
        }
    }
}

注册中间件时,建议放在管道靠前位置,确保能拦截到所有后续处理:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// 注册统一响应中间件,放在最前面
app.UseMiddleware<UnifiedResponseMiddleware>();

app.MapControllers();
app.Run();

异常处理统一包装

中间件的 catch 块已经能兜住大部分未处理异常,但生产环境通常还需要更细粒度的异常分类。可以引入自定义异常类型,配合一个独立的异常处理中间件:

/// <summary>
/// 业务异常,对应 HTTP 200 但 code 为业务错误码
/// </summary>
public class BusinessException : Exception
{
    public int Code { get; } // 业务错误码,如 40001
    public BusinessException(int code, string message) : base(message) => Code = code;
}

/// <summary>
/// 全局异常处理中间件,配合统一响应使用
/// </summary>
public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    public ExceptionHandlingMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (BusinessException bizEx)
        {
            // 业务异常:HTTP 200,业务码非 200
            context.Response.StatusCode = 200;
            context.Response.ContentType = "application/json";
            var resp = JsonSerializer.Serialize(new
            {
                code = bizEx.Code,
                message = bizEx.Message,
                data = (object)null
            });
            await context.Response.WriteAsync(resp);
        }
        catch (Exception ex)
        {
            // 系统异常:HTTP 500,统一提示
            context.Response.StatusCode = 500;
            context.Response.ContentType = "application/json";
            var resp = JsonSerializer.Serialize(new
            {
                code = 500,
                message = "服务器内部错误",
                data = (object)null
            });
            // 完整异常信息写入日志,不暴露给前端
            Console.WriteLine($"[Unhandled] {ex}");
            await context.Response.WriteAsync(resp);
        }
    }
}

注册顺序:异常处理中间件在前,统一响应中间件在后。这样业务异常被前者处理后会写入统一格式,而统一响应中间件检测到 code 字段存在就不再重复包装。

app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseMiddleware<UnifiedResponseMiddleware>();

使用效果对比

改造前的接口返回(裸数据):

[HttpGet("user/{id}")]
public User GetUser(int id) => _userService.Get(id);
// 实际响应:{ "id": 1, "name": "张三", "age": 28 }

改造后(自动包装):

{
  "code": 200,
  "message": "success",
  "data": { "id": 1, "name": "张三", "age": 28 }
}

改造前的异常(裸 500):

HTTP 500 Internal Server Error
System.NullReferenceException: Object reference not set...

改造后(统一异常包装):

{
  "code": 500,
  "message": "服务器内部错误",
  "data": null
}

业务异常示例:

[HttpGet("order/{id}")]
public Order GetOrder(int id)
{
    var order = _orderService.Get(id);
    if (order == null)
        throw new BusinessException(40401, "订单不存在");
    return order;
}
// 响应:{ "code": 40401, "message": "订单不存在", "data": null }

前端只需一套统一的拦截逻辑:

axios.interceptors.response.use(res => {
  const { code, message, data } = res.data;
  if (code === 200) return data;          // 成功直接取 data
  ElMessage.error(message);               // 业务错误弹提示
  return Promise.reject(new Error(message));
});

小结

统一响应格式是 API 设计的基础约定,用中间件实现比过滤器更全面——能覆盖异常、404、健康检查等所有出口。核心技巧是用 MemoryStream 截获响应流,在管道执行完毕后统一处理。配合异常处理中间件,可以让整个 API 层的输出从”各说各话”变成”统一口径”,前后端协作成本大幅降低。需要注意的是,对于文件下载、SSE 等非 JSON 响应,要在中间件里通过 ContentType 判断跳过包装,避免破坏原始流。