〇、前言
本文将详细介绍什么是 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 四大核心概念:资源、表述、状态转移、统一接口
- 资源(Resource) :一切皆资源------用户、订单、库存、甚至"搜索结果"这种虚拟集合。资源通过 URI 唯一标识。
- 表述(Representation) :资源在网上传输的具体形态(JSON / XML / HTML / Protobuf)。同一资源可有多种表述,靠
Accept/Content-Type协商。 - 状态转移(State Transfer) :客户端通过获取资源的表述,连同其中的链接,决定下一步能做什么------应用状态由客户端持有并驱动。
- 统一接口 :用固定的几个动词操作所有资源,而不是为每个动作造一个新 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/...(最直观),或 HeaderAccept: 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 安全设计
- 传输层 :强制 HTTPS(TLS 1.2+),禁用 HTTP 明文。
- 认证 :JWT Bearer Token(
Authorization: Bearer <token>),有效期宜短(15~30 分钟)+ Refresh Token 轮换;高敏感场景使用 mTLS 双向认证。 - 授权 :RBAC / ABAC,服务端必须二次校验权限------绝不能仅靠前端隐藏按钮 。
- 输入校验 :所有入参在服务端校验(类型、长度、范围、枚举、正则),防范 SQL 注入、XSS、命令注入。
- 限流与防刷 :按 IP / User / API Key 维度限流,返回 429。
- 敏感数据 :密码 bcrypt/argon2 加盐哈希,绝不明文存储;日志脱敏(身份证、手机号、Token)。
- CORS :精确配置允许的 Origin,避免使用
*(携带凭证时协议禁止通配)。 - 幂等键 / 防重放 :涉及资金的操作必须配备 nonce 与时间窗校验。
- 响应头加固 :配置
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 替你说话"的设计思想,并据此在真实业务场景中做出克制而合理的取舍,从而设计出清晰、安全、易演进的高质量接口。