RESTful 接口:它并非标准或协议,而是一种【架构风格】

〇、前言

本文将详细介绍什么是 RESTful 接口,以及这一架构风格的各个方面的表现和适用场景,最后通过简单的示例项目来展示其主要特点。

RESTful 基于 HTTP + JSON 的通用接口,适合对外 API、Web 和移动端调用,调试方便、生态成熟。另外关于 gRPC、WebSocket 等等通信模式都各有所长,后续将会逐个进行详解。

一、RESTful 是什么?

1.1 关于 REST

REST(Representational State Transfer,表征状态转移)由 Roy Fielding 在 2000 年的博士论文中提出。它并非标准或协议,而是一种架构风格,用于约束客户端与服务端如何交互。

其底层依托于 HTTP 协议本身的设计哲学------HTTP 的 GET/POST/PUT/DELETE、状态码、Cache-Control 等机制,正是为 REST 量身定制的。

REST 的六个约束条件:

约束 含义 实践意义
客户端-服务端(Client-Server) 关注点分离,两端可独立演进 前后端分离、多端复用同一 API
无状态(Stateless) ⭐ 每个请求必须包含处理所需的全部信息,服务端不保存会话 不用 Session,用 Token;服务天然可水平扩展
可缓存(Cacheable) 响应必须声明自己是否可缓存 Cache-Control、ETag,降低服务器压力
统一接口(Uniform Interface) ⭐ 资源标识 + 操作自描述 + 超媒体 URI 规范、HTTP 方法语义化、HATEOAS
分层系统(Layered System) 客户端不知道中间经过了几层代理 网关、负载均衡、CDN、限流层
按需代码(Code on Demand,可选) 服务端下发代码给客户端执行 如 JS,实际很少用

注意:无状态和统一接口是 REST 的灵魂。违反这两条,接口就退化为"用 HTTP 当传输协议的 RPC"。

一个关键区分:RESTful ≠ 用了 JSON 的 HTTP 接口。

复制代码
❌ POST /api/getUserList      → 这是 RPC 风格,只是跑在 HTTP 上
✅ GET  /api/v1/users?page=1  → 这才是 RESTful

1.2 四大核心概念:资源、表述、状态转移、统一接口

  1. 资源(Resource) :一切皆资源------用户、订单、库存、甚至"搜索结果"这种虚拟集合。资源通过 URI 唯一标识。
  2. 表述(Representation) :资源在网上传输的具体形态(JSON / XML / HTML / Protobuf)。同一资源可有多种表述,靠 Accept / Content-Type 协商。
  3. 状态转移(State Transfer) :客户端通过获取资源的表述,连同其中的链接,决定下一步能做什么------应用状态由客户端持有并驱动。
  4. 统一接口 :用固定的几个动词操作所有资源,而不是为每个动作造一个新 URL。

1.3 URI 设计规范(最容易被吐槽的部分)

黄金法则:URI 里只有名词,动词交给 HTTP 方法。

复制代码
✅ 正确
GET    /api/v1/users              # 用户列表
GET    /api/v1/users/123          # 单个用户
POST   /api/v1/users              # 创建用户
PUT    /api/v1/users/123          # 全量更新
PATCH  /api/v1/users/123          # 部分更新
DELETE /api/v1/users/123          # 删除
GET    /api/v1/users/123/orders   # 子资源

❌ 错误
GET    /getUserList               # 动词
POST   /createUser                # 动词
GET    /user                      # 单数(集合应复数)
GET    /users/getById?id=123      # 动词+参数
POST   /updateUser                # 用 POST 干 PUT 的活
GET    /Users                     # 大写
GET    /user_profiles             # 下划线
GET    /users/123/orders/456/items/789/reviews  # 嵌套过深

设计规范细则:

  • 名词复数 :集合永远用复数 /users、/orders。

  • 小写 + 连字符 :/user-profiles,不要驼峰或下划线。

  • 层级 ≤ 2 层 :超过两层可读性崩塌,深层资源应单独提供顶级入口(如:直接提供 /orders/{id})。

  • URI 不暴露内部实现 :避免 /db/users、/mysql/query。

  • 版本号放在路径前缀 :/api/v1/...(最直观),或 Header Accept: application/vnd.myapp.v2+json(更纯粹)。切忌混用 。

  • 无法抽象成资源的"动作"怎么办? 用 POST + 动词子资源,且要克制:

    POST /users/123/activate # 激活用户
    POST /orders/456/cancel # 取消订单
    POST /payments/refund # 退款

  • 过滤/排序/搜索是查询上下文,不是路径 :/api/v1/users?department=IT&sort=-createdAt&page=1&size=20,而非 /users/byDepartment/IT。

  • JSON 字段用 camelCase (C# 中 System.Text.Json 默认即如此):{"userId":1,"userName":"张三"}。

1.4 HTTP 方法:语义、安全性与幂等性

方法 语义 安全 幂等 典型返回
GET 读取资源 ✅ ✅ 200
HEAD 只取响应头 ✅ ✅ 200(无 body)
POST 创建 / 触发动作 ❌ ❌ 201 + Location
PUT 全量替换(客户端需传完整对象) ❌ ✅ 200 / 204
PATCH 部分更新(只传变更字段) ❌ 视具体操作而定 200
DELETE 删除 ❌ ✅ 204(后续再删可返 404)
OPTIONS 探询支持的方法(CORS 预检) ✅ ✅ 200

幂等性(Idempotency)是重试安全的基石 :同一个请求发 N 次,与服务端状态的变化等同于发 1 次。

  • GET/PUT/DELETE 天然幂等;POST/PATCH 默认不幂等。
  • 对非幂等操作(如:支付、下单),若需支持客户端重试,必须引入人工幂等 :客户端携带 Idempotency-Key 请求头,服务端以该 Key 做去重记录,命中则直接返回首次结果。
  • PATCH 若语义是"把字段设为某值"则幂等;若是"计数器 +1"则不幂等------设计时需明确文档说明。

1.5 状态码:让 HTTP 替你说话

**最大的反模式:所有请求都返回 200,然后在 body 里用 code 字段区分成败。**这等于丢弃了 HTTP 语义,网关、监控、重试策略全部失效。

分类 状态码 场景
2xx 成功 200 OK 查询、更新成功
201 Created 创建成功,配合 Location 头
204 No Content 删除成功 / 无返回体
206 Partial Content 范围请求(断点续传、分片下载)
3xx 重定向 304 Not Modified 配合 ETag/If-None-Match,缓存命中
4xx 客户端错 400 Bad Request 参数缺失、JSON 格式错误
401 Unauthorized 未认证------Token 缺失或过期,去登录
403 Forbidden 已认证但无权限
404 Not Found 资源不存在(注意:也可对无权限者返回 404 以防信息泄露)
405 Method Not Allowed 该资源不支持此方法
409 Conflict 冲突(如用户名重复、乐观锁版本号不符)
422 Unprocessable Entity 语义校验失败(推荐用于参数校验)
429 Too Many Requests 触发限流
5xx 服务端错 500 Internal Server Error 未预期的内部异常
502 Bad Gateway / 503 Service Unavailable 上游故障 / 服务过载降级

易混点辨析 :401 = "你是谁?";403 = "我知道你是谁,但你不行";404 = "东西不存在"。

1.6 统一响应体与错误处理

复制代码
// 【成功响应】
{
  "code": 0,
  "message": "success",
  "data": {
    "id": 123,
    "name": "百年孤独",
    "author": "加西亚·马尔克斯",
    "price": 59.00
  }
}
// 【错误响应】(结构必须固定,客户端才能统一解析)
{
  "code": 42201,
  "message": "请求参数校验失败",
  "errors": [
    { "field": "price", "message": "价格必须大于 0" }
  ],
  "path": "/api/v1/books",
  "timestamp": "2026-11-09T08:30:00Z",
  "requestId": "req_abc123"   // 便于日志追踪
}
// 【分页响应】
{
  "code": 0,
  "data": {
    "items": [...],
    "page": 1,
    "pageSize": 20,
    "total": 156,
    "totalPages": 8
  }
}

注意:大数据量场景建议使用 游标分页 (cursor + hasMore),避免深分页导致的 OFFSET 性能问题。

全局异常处理原则:

  • 业务异常 → 映射到对应的 4xx 状态码。
  • 未预期异常 → 500,绝不把堆栈信息返回给客户端 (防信息泄露)。
  • 所有异常入口统一收口(中间件或全局过滤器),保证错误形状一致。

1.7 查询能力:分页 / 过滤 / 排序 / 字段裁剪

复制代码
GET /api/v1/books?genre=fiction&minPrice=30&maxPrice=100&sort=-publishedAt&page=1&size=20&fields=id,name,price
  • 分页 :page(从 1 起)、size(设上限,如 max=100,防止全表拖垮)。
  • 排序 :sort=field / sort=-field 表示降序;必须有白名单 ,否则存在 SQL 注入风险。
  • 过滤 :简单场景用平铺参数;复杂场景可用 filter[status]=pending 风格。
  • 字段选择(Field Selection) :fields=id,name 减少 payload,移动端尤其需要。
  • 永远不要返回无界列表 ------不加 limit 的列表接口是线上事故的常见源头。

1.8 版本控制策略

方式 示例 评价
URL 路径 /api/v1/users ✅ 最直观、易调试、易灰度,业界主流
Accept Header Accept: application/vnd.app.v1+json ✅ 更纯粹,但调试不便
Query 参数 /api/users?version=1 ⚠️ 不推荐,容易被代理忽略

纪律 :遵循语义化版本(SemVer)。向后兼容的变更绝不升主版本------新增可选字段、新增端点属于兼容变更;删除字段、修改字段类型、改变语义则必须升大版本,并保留旧版本至少一个生命周期(通常 6~12 个月),同时给出明确的废弃(Deprecation)时间表。

1.9 安全设计

  1. 传输层 :强制 HTTPS(TLS 1.2+),禁用 HTTP 明文。
  2. 认证 :JWT Bearer Token(Authorization: Bearer <token>),有效期宜短(15~30 分钟)+ Refresh Token 轮换;高敏感场景使用 mTLS 双向认证。
  3. 授权 :RBAC / ABAC,服务端必须二次校验权限------绝不能仅靠前端隐藏按钮 。
  4. 输入校验 :所有入参在服务端校验(类型、长度、范围、枚举、正则),防范 SQL 注入、XSS、命令注入。
  5. 限流与防刷 :按 IP / User / API Key 维度限流,返回 429。
  6. 敏感数据 :密码 bcrypt/argon2 加盐哈希,绝不明文存储;日志脱敏(身份证、手机号、Token)。
  7. CORS :精确配置允许的 Origin,避免使用 *(携带凭证时协议禁止通配)。
  8. 幂等键 / 防重放 :涉及资金的操作必须配备 nonce 与时间窗校验。
  9. 响应头加固 :配置 X-Content-Type-Options、X-Frame-Options、Strict-Transport-Security。

1.10 缓存与性能

  • Cache-Control :public, max-age=300(可缓存)或 no-store(敏感数据)。
  • ETag / If-None-Match :实现 304 协商缓存,节省带宽。
  • GET 才是可缓存的 :POST/PUT/DELETE 响应默认不可缓存。
  • 压缩 :启用 Gzip/Brotli。
  • N+1 查询 :列表接口避免循环查库,使用批量加载或 JOIN。
  • DTO 隔离 :禁止将数据库实体直接暴露为响应模型(防止泄露内部字段、引发序列化循环引用及过度发布漏洞)。

1.11 HATEOAS(成熟度模型 Level 3)

响应中附带相关链接,使客户端能够"自发现",从而与服务端解耦:

复制代码
{
  "id": 123,
  "name": "百年孤独",
  "_links": {
    "self":   { "href": "/api/v1/books/123" },
    "author": { "href": "/api/v1/authors/45" },
    "update": { "href": "/api/v1/books/123", "method": "PUT" },
    "delete": { "href": "/api/v1/books/123", "method": "DELETE" }
  }
}

现实工程中完全实现 HATEOAS 的项目不多(成本高、前端生态支持弱),但保留 _links 的思想 (如分页里的 next/prev、详情里的关联资源链接)极具实用价值。

1.12 文档与契约

  • OpenAPI 3.0(Swagger) :作为单一事实来源(Single Source of Truth),先写契约再写代码(Design-First),随后自动生成文档、Mock Server 及客户端 SDK。
  • 契约测试 :利用 Pact 等工具保障消费者与提供者之间的兼容性。
  • 变更日志(Changelog) 与弃用公告 :API 也是产品,需要有发布说明。
  • 工具链参考:Swashbuckle(ASP.NET Core)、Scalar / Redoc(文档 UI)、Postman / Apifox(调试)。

二、REST vs RPC vs GraphQL 简单对比

REST RPC(gRPC/Feign) GraphQL
思维模型 资源 过程/函数调用 图 + 按需查询
传输 HTTP + JSON HTTP/2 + Protobuf HTTP + JSON
优点 通用、可缓存、可被浏览器直接调用、生态成熟 强类型、性能高、流式支持好 一次请求拿够数据、无过度/不足获取
缺点 版本迭代笨重、N+1、over-fetching 不可缓存、不适合公网开放 API 缓存难、复杂度转移到后端、Query 成本不可控
适用 对外开放 API、前后端协作 内部微服务互调 BFF 层、复杂前台产品

结论:对外公开 API 首选 REST;内部高性能调用选 gRPC;前端聚合层考虑 GraphQL/BFF。 三者可以共存,不必非此即彼。

三、C# 实现:ASP.NET Core 9 最小 API 图书管理

下面是一个可直接运行的完整示例,涵盖:Minimal API、依赖注入、DTO 隔离、参数校验、全局异常处理中间件、统一响应包装、分页排序过滤、版本控制、Swagger 以及内存仓储。
点击查看:测试项目全部代码

复制代码
 // 1. 项目文件 BookApi.csproj
// 创建命令:dotnet new web -n BookApi --framework net9.0
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
  </ItemGroup>
</Project>

// 2. 领域模型与 DTO(Models/Book.cs)
namespace BookApi.Models;

/// <summary>
/// 领域实体:永不直接暴露给客户端
/// </summary>
public sealed class Book
{
    public Guid Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;
    public string? Isbn { get; set; }
    public decimal Price { get; set; }
    public string Genre { get; set; } = string.Empty;
    public DateTime PublishedAt { get; set; }
    public bool IsAvailable { get; set; } = true;
    public long Version { get; set; }      // 乐观锁
}

/// <summary>
/// 创建请求 DTO
/// </summary>
public sealed record CreateBookRequest(
    string Title,
    string Author,
    string? Isbn,
    decimal Price,
    string Genre,
    DateTime? PublishedAt);

/// <summary>
/// 更新请求 DTO(全量,对应 PUT)
/// </summary>
public sealed record UpdateBookRequest(
    string Title,
    string Author,
    string? Isbn,
    decimal Price,
    string Genre,
    DateTime PublishedAt,
    bool IsAvailable);

/// <summary>
/// 部分更新 DTO(对应 PATCH)
/// </summary>
public sealed record PatchBookRequest
{
    public string? Title { get; set; }
    public string? Author { get; set; }
    public decimal? Price { get; set; }
    public bool? IsAvailable { get; set; }
}

/// <summary>
/// 响应 DTO
/// </summary>
public sealed record BookResponse(
    Guid Id,
    string Title,
    string Author,
    string? Isbn,
    decimal Price,
    string Genre,
    DateTime PublishedAt,
    bool IsAvailable);

// 3. 统一响应包装(Common/ApiResponse.cs)
namespace BookApi.Common;

public sealed class ApiResponse<T>
{
    public int Code { get; set; }
    public string Message { get; set; } = string.Empty;
    public T? Data { get; set; }
    public string? RequestId { get; set; }
    public DateTime Timestamp { get; set; } = DateTime.UtcNow;

    public static ApiResponse<T> Ok(T data, string message = "success") =>
        new() { Code = 0, Message = message, Data = data };

    public static ApiResponse<T> Fail(int code, string message) =>
        new() { Code = code, Message = message };
}

public sealed class ErrorDetail
{
    public string Field { get; set; } = string.Empty;
    public string Message { get; set; } = string.Empty;
}

public sealed class PagedResult<T>
{
    public List<T> Items { get; set; } = new();
    public int Page { get; set; }
    public int PageSize { get; set; }
    public int Total { get; set; }
    public int TotalPages => PageSize > 0 ? (int)Math.Ceiling(Total / (double)PageSize) : 0;
}

// 4. 仓储层(Repositories/IBookRepository.cs)
using BookApi.Models;

namespace BookApi.Repositories;

public interface IBookRepository
{
    Task<IReadOnlyList<Book>> GetAllAsync(string? genre, decimal? minPrice,
        decimal? maxPrice, string? sort, int page, int size, CancellationToken ct);
    Task<int> CountAsync(string? genre, decimal? minPrice, decimal? maxPrice, CancellationToken ct);
    Task<Book?> GetByIdAsync(Guid id, CancellationToken ct);
    Task<Book> AddAsync(Book book, CancellationToken ct);
    Task<bool> UpdateAsync(Book book, CancellationToken ct);   // false = 乐观锁冲突
    Task<bool> DeleteAsync(Guid id, CancellationToken ct);
    Task<bool> ExistsByIsbnAsync(string isbn, CancellationToken ct);
}

// 5. 内存实现(Repositories/InMemoryBookRepository.cs)
using System.Collections.Concurrent;
using BookApi.Models;

namespace BookApi.Repositories;

public sealed class InMemoryBookRepository : IBookRepository
{
    private readonly ConcurrentDictionary<Guid, Book> _store = new();
    private static int _seq = 0;

    public InMemoryBookRepository(IEnumerable<Book>? seeds = null)
    {
        if (seeds != null)
            foreach (var b in seeds) _store[b.Id] = b;
    }

    public Task<IReadOnlyList<Book>> GetAllAsync(string? genre, decimal? minPrice,
        decimal? maxPrice, string? sort, int page, int size, CancellationToken ct)
    {
        var q = _store.Values.AsEnumerable();
        if (!string.IsNullOrWhiteSpace(genre))
            q = q.Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase));
        if (minPrice.HasValue) q = q.Where(b => b.Price >= minPrice.Value);
        if (maxPrice.HasValue) q = q.Where(b => b.Price <= maxPrice.Value);

        var field = string.IsNullOrWhiteSpace(sort) ? "PublishedAt" : sort.TrimStart('-');
        var desc = !string.IsNullOrEmpty(sort) && sort.StartsWith('-');
        var dir = desc ? System.ComponentModel.ListSortDirection.Descending
                       : System.ComponentModel.ListSortDirection.Ascending;
        q = ApplySort(q, field, dir);

        return Task.FromResult<IReadOnlyList<Book>>(q.Skip((page - 1) * size).Take(size).ToList());
    }

    public Task<int> CountAsync(string? genre, decimal? minPrice, decimal? maxPrice, CancellationToken ct)
    {
        var q = _store.Values.AsEnumerable();
        if (!string.IsNullOrWhiteSpace(genre))
            q = q.Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase));
        if (minPrice.HasValue) q = q.Where(b => b.Price >= minPrice.Value);
        if (maxPrice.HasValue) q = q.Where(b => b.Price <= maxPrice.Value);
        return Task.FromResult(q.Count());
    }

    public Task<Book?> GetByIdAsync(Guid id, CancellationToken ct) =>
        Task.FromResult(_store.TryGetValue(id, out var b) ? b : null);

    public async Task<Book> AddAsync(Book book, CancellationToken ct)
    {
        book.Id = Guid.NewGuid();
        book.Version = Interlocked.Increment(ref _seq);
        _store[book.Id] = book;
        return await Task.FromResult(book);
    }

    public Task<bool> UpdateAsync(Book book, CancellationToken ct)
    {
        var ok = _store.AddOrUpdate(book.Id,
            _ => book,
            (_, existing) => existing.Version == book.Version ? book : existing);
        return Task.FromResult(ok.Version == book.Version);
    }

    public Task<bool> DeleteAsync(Guid id, CancellationToken ct) =>
        Task.FromResult(_store.TryRemove(id, out _));

    public Task<bool> ExistsByIsbnAsync(string isbn, CancellationToken ct) =>
        Task.FromResult(_store.Values.Any(b => b.Isbn == isbn));

    private static IEnumerable<Book> ApplySort(IEnumerable<Book> src, string field,
        System.ComponentModel.ListSortDirection dir) => field.ToLower() switch
    {
        "title"     => dir == System.ComponentModel.ListSortDirection.Ascending
                        ? src.OrderBy(b => b.Title) : src.OrderByDescending(b => b.Title),
        "price"     => dir == System.ComponentModel.ListSortDirection.Ascending
                        ? src.OrderBy(b => b.Price) : src.OrderByDescending(b => b.Price),
        "publishedat" or "published_at"
                    => dir == System.ComponentModel.ListSortDirection.Ascending
                        ? src.OrderBy(b => b.PublishedAt) : src.OrderByDescending(b => b.PublishedAt),
        _ => src
    };
}

// 6. 全局异常处理中间件(Middleware/ExceptionHandlingMiddleware.cs)
using System.Net;
using System.Text.Json;
using BookApi.Common;

namespace BookApi.Middleware;

public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
    private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

    public async Task InvokeAsync(HttpContext ctx)
    {
        try
        {
            await next(ctx);
        }
        catch (BusinessException ex)
        {
            await WriteError(ctx, (int)ex.StatusCode, ex.Code, ex.Message, ex.Errors);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "未预期的服务器异常");
            await WriteError(ctx, 500, 50000, "服务器内部错误,请稍后重试");
        }
    }

    private async Task WriteError(HttpContext ctx, int status, int code, string message,
        List<ErrorDetail>? errors = null)
    {
        ctx.Response.StatusCode = status;
        ctx.Response.ContentType = "application/json; charset=utf-8";
        var body = ApiResponse<object>.Fail(code, message);
        body.RequestId = ctx.TraceIdentifier;
        if (errors != null && errors.Count > 0)
        {
            // 借用 Data 承载错误明细(生产环境可单独建 ErrorResponse 类型)
            body.Data = new { errors };
        }
        await ctx.Response.WriteAsync(JsonSerializer.Serialize(body, JsonOpts));
    }
}

// ------ 业务异常基类 ------
public class BusinessException(string message, int code, HttpStatusCode statusCode, List<ErrorDetail>? errors = null)
    : Exception(message)
{
    public int Code { get; } = code;
    public HttpStatusCode StatusCode { get; } = statusCode;
    public List<ErrorDetail>? Errors { get; } = errors;
}

public sealed class NotFoundException(Guid id, string resource)
    : BusinessException($"{resource}[{id}] 不存在", 40401, HttpStatusCode.NotFound);

public sealed class ConflictException(string message, int code = 40901)
    : BusinessException(message, code, HttpStatusCode.Conflict);

public sealed class ValidationException(List<ErrorDetail> errors)
    : BusinessException("请求参数校验失败", 42201, HttpStatusCode.UnprocessableEntity, errors);

// 7. 主程序(Program.cs)
using System.ComponentModel.DataAnnotations;
using System.Net;
using BookApi.Common;
using BookApi.Middleware;
using BookApi.Models;
using BookApi.Repositories;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// 服务注册
builder.Services.AddSingleton<IBookRepository, InMemoryBookRepository>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi(options =>
{
    options.AddDocumentTransformer((doc, ctx, ct) =>
    {
        doc.Info = new() { Title = "图书管理 API", Version = "v1", Description = "RESTful 示例项目" };
        return Task.CompletedTask;
    });
});

var app = builder.Build();

app.UseMiddleware<ExceptionHandlingMiddleware>();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();                 // /openapi/v1.json
    app.MapSwaggerUi();              // /swagger-ui  (.NET 9 内置)
}

// 种子数据
var repo = app.Services.GetRequiredService<IBookRepository>();
await SeedAsync(repo);

// ===== 路由组:版本前缀 =====
var api = app.NewVersionedApi("Books");
var v1  = api.MapGroup("/api/v1/books")
             .WithTags("Books")
             .WithOpenApi();

// ---------- GET /api/v1/books  列表(分页+过滤+排序+字段裁剪)----------
v1.MapGet("/", async ([AsParameters] BookQuery query, IBookRepository repo, CancellationToken ct) =>
{
    query.Page = Math.Max(1, query.Page);
    query.Size = Math.Clamp(query.Size, 1, 100);

    var items = await repo.GetAllAsync(query.Genre, query.MinPrice, query.MaxPrice,
        query.Sort, query.Page, query.Size, ct);
    var total = await repo.CountAsync(query.Genre, query.MinPrice, query.MaxPrice, ct);

    return ApiResponse<PagedResult<BookResponse>>.Ok(new PagedResult<BookResponse>
    {
        Items = items.Select(MapToResponse).ToList(),
        Page = query.Page,
        PageSize = query.Size,
        Total = total
    });
})
.WithSummary("获取图书列表")
.Produces<ApiResponse<PagedResult<BookResponse>>>(StatusCodes.Status200OK);

// ---------- GET /api/v1/books/{id}  单个 ----------
v1.MapGet("/{id:guid}", async (Guid id, IBookRepository repo, CancellationToken ct) =>
{
    var book = await repo.GetByIdAsync(id, ct)
               ?? throw new NotFoundException(id, nameof(Book));
    return ApiResponse<BookResponse>.Ok(MapToResponse(book));
})
.WithSummary("根据 ID 获取图书")
.Produces<ApiResponse<BookResponse>>(StatusCodes.Status200OK)
.Produces<ApiResponse<object>>(StatusCodes.Status404NotFound);

// ---------- POST /api/v1/books  创建 ----------
v1.MapPost("/", async ([FromBody][Required] CreateBookRequest req,
    IBookRepository repo, CancellationToken ct) =>
{
    Validate(req);

    if (!string.IsNullOrWhiteSpace(req.Isbn) && await repo.ExistsByIsbnAsync(req.Isbn, ct))
        throw new ConflictException($"ISBN {req.Isbn} 已存在");

    var book = new Book
    {
        Title = req.Title.Trim(),
        Author = req.Author.Trim(),
        Isbn = req.Isbn?.Trim(),
        Price = Math.Round(req.Price, 2),
        Genre = req.Genre.Trim(),
        PublishedAt = req.PublishedAt ?? DateTime.UtcNow,
        IsAvailable = true
    };

    var created = await repo.AddAsync(book, ct);
    return Results.Created($"/api/v1/books/{created.Id}",
        ApiResponse<BookResponse>.Ok(MapToResponse(created)));
})
.WithSummary("创建图书")
.Produces<ApiResponse<BookResponse>>(StatusCodes.Status201Created)
.Produces<ApiResponse<object>>(StatusCodes.Status409Conflict)
.Produces<ApiResponse<object>>(StatusCodes.Status422UnprocessableEntity);

// ---------- PUT /api/v1/books/{id}  全量替换 ----------
v1.MapPut("/{id:guid}", async (Guid id, [FromBody][Required] UpdateBookRequest req,
    IBookRepository repo, CancellationToken ct) =>
{
    Validate(req);
    var existing = await repo.GetByIdAsync(id, ct)
                   ?? throw new NotFoundException(id, nameof(Book));

    var updated = existing with
    {
        Title = req.Title.Trim(),
        Author = req.Author.Trim(),
        Isbn = req.Isbn?.Trim(),
        Price = Math.Round(req.Price, 2),
        Genre = req.Genre.Trim(),
        PublishedAt = req.PublishedAt,
        IsAvailable = req.IsAvailable,
        Version = existing.Version          // 乐观锁比对
    };

    if (!await repo.UpdateAsync(updated, ct))
        throw new ConflictException("数据已被他人修改,请刷新后重试", 40902);

    return ApiResponse<BookResponse>.Ok(MapToResponse(updated));
})
.Produces<ApiResponse<BookResponse>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);

// ---------- PATCH /api/v1/books/{id}  部分更新 ----------
v1.MapPatch("/{id:guid}", async (Guid id, [FromBody] PatchBookRequest req,
    IBookRepository repo, CancellationToken ct) =>
{
    var existing = await repo.GetByIdAsync(id, ct)
                   ?? throw new NotFoundException(id, nameof(Book));

    if (req.Title != null)     existing = existing with { Title = req.Title.Trim() };
    if (req.Author != null)    existing = existing with { Author = req.Author.Trim() };
    if (req.Price.HasValue)    existing = existing with { Price = Math.Round(req.Price.Value, 2) };
    if (req.IsAvailable.HasValue) existing = existing with { IsAvailable = req.IsAvailable.Value };

    await repo.UpdateAsync(existing, ct);   // 简化:此处省略乐观锁重试
    return ApiResponse<BookResponse>.Ok(MapToResponse(existing));
})
.Produces<ApiResponse<BookResponse>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

// ---------- DELETE /api/v1/books/{id}  删除 ----------
v1.MapDelete("/{id:guid}", async (Guid id, IBookRepository repo, CancellationToken ct) =>
{
    if (!await repo.DeleteAsync(id, ct))
        throw new NotFoundException(id, nameof(Book));

    return Results.NoContent();   // 204 No Content
})
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);

app.Run();

// ===== 辅助方法 =====
static BookResponse MapToResponse(Book b) =>
    new(b.Id, b.Title, b.Author, b.Isbn, b.Price, b.Genre, b.PublishedAt, b.IsAvailable);

static void Validate(object model)
{
    var ctx = new ValidationContext(model);
    var results = new List<ValidationResult>();
    if (!Validator.TryValidateObject(model, ctx, results, true))
        throw new ValidationException(results
            .Select(r => new ErrorDetail { Field = string.Join(",", r.MemberNames), Message = r.ErrorMessage! })
            .ToList());
}

async Task SeedAsync(IBookRepository repo)
{
    var seeds = new[]
    {
        new Book { Title = "百年孤独", Author = "加西亚·马尔克斯", Isbn = "978-7-5442-5399-4",
                   Price = 59.00m, Genre = "fiction", PublishedAt = new DateTime(1967,6,5), IsAvailable = true },
        new Book { Title = "深入理解计算机系统", Author = "Randal E. Bryant", Isbn = "978-7-111-54493-7",
                   Price = 139.00m, Genre = "tech", PublishedAt = new DateTime(2015,11,1), IsAvailable = true },
        new Book { Title = "人类简史", Author = "尤瓦尔·赫拉利", Isbn = "978-7-5086-4735-9",
                   Price = 68.00m, Genre = "history", PublishedAt = new DateTime(2014,11,1), IsAvailable = false },
    };
    foreach (var b in seeds) await repo.AddAsync(b, CancellationToken.None);
}

record BookQuery
{
    [FromQuery(Name = "genre")]     public string? Genre { get; set; }
    [FromQuery(Name = "minPrice")]  public decimal? MinPrice { get; set; }
    [FromQuery(Name = "maxPrice")]  public decimal? MaxPrice { get; set; }
    [FromQuery(Name = "sort")]      public string? Sort { get; set; }
    [FromQuery(Name = "page")]      public int Page { get; set; } = 1;
    [FromQuery(Name = "size")]      public int Size { get; set; } = 20;
}

// 8. 运行与验证
dotnet run
# 访问 http://localhost:5000/swagger-ui 查看交互式文档

# 创建
curl -X POST http://localhost:5000/api/v1/books \
  -H "Content-Type: application/json" \
  -d '{"title":"三体","author":"刘慈欣","isbn":"978-7-5366-9293-0","price":38.00,"genre":"scifi","publishedAt":"2008-01-01"}'
# → 201 Created,Location: /api/v1/books/{id}

# 列表 + 过滤 + 分页
curl "http://localhost:5000/api/v1/books?genre=scifi&sort=-price&page=1&size=10"

# 部分更新
curl -X PATCH http://localhost:5000/api/v1/books/{id} \
  -H "Content-Type: application/json" -d '{"price":42.00,"isAvailable":true}'

# 删除
curl -X DELETE http://localhost:5000/api/v1/books/{id}   # → 204 No Content

# 错误示例:ISBN 冲突
# → 409 Conflict {"code":40901,"message":"ISBN xxx 已存在"}
# 错误示例:ID 不存在
# → 404 Not Found {"code":40401,"message":"Book[{id}] 不存在"}

四、小小的总结

RESTful 并非一套需要严格遵循的标准或协议,而是一种根植于 HTTP 设计哲学的架构风格。其灵魂在于无状态与统一接口两大约束------前者让服务天然可水平扩展,后者用"资源 + 动词"的模型替代了为每个动作臆造 URL 的做法。围绕这一核心,本文系统梳理了 RESTful 接口的工程实践要点:URI 设计坚持"只有名词、复数小写、层级不超两层"的黄金法则;HTTP 方法严格区分语义与幂等性,幂等性是重试安全的基石;状态码应真实反映请求结果,而非用统一的 200 包裹业务成败;配套还需建立结构固定的统一响应体与全局异常处理机制,并在分页、过滤、排序、版本控制、安全(HTTPS、JWT、RBAC、输入校验)、缓存等方面形成规范,使接口既能被网关、监控与客户端正确理解,又具备良好的性能与可维护性。

从技术选型的视角看,RESTful 与 RPC、GraphQL 各有疆域:**REST 通用、可缓存、生态成熟,最适合对外开放 API 与前后端协作;gRPC 等 RPC 方案强类型、高性能,适合内部微服务互调;GraphQL 则以按需查询见长,适合 BFF 聚合层与复杂前台产品。**三者并非相互替代,而是可以共存互补。归根结底,掌握 RESTful 的关键不在于机械套用规范,而在于理解其"以资源为中心、让 HTTP 替你说话"的设计思想,并据此在真实业务场景中做出克制而合理的取舍,从而设计出清晰、安全、易演进的高质量接口。

相关推荐
小兔子1 小时前
dict.get 的默认值每次都会求值,哪怕键本来就在
后端
斯维赤1 小时前
限流、熔断、降级,你真的分得清吗?
java·后端·架构
肌肉娃子1 小时前
红队攻防测试报告 — wechat-e.cfd
后端
陈随易1 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
海螺_AIHCC1 小时前
SSE 流式返回中文变问号,问题不在编码
后端
panbook1 小时前
从零实现一个时序数据库(一):用 Rust、Arrow 和 Parquet 跑通写入与 SQL 查询
后端
Zan1 小时前
SQLite + SQLx 实战:为什么已经发布的数据库迁移文件,绝对不能再修改?
数据库·后端·面试
微步AICRM2 小时前
一物一码验真的并发链路设计:码段管理、四级关联与异常判定
后端·架构
陈随易2 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员