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 四大核心概念:资源、表述、状态转移、统一接口
- 资源(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),有效期宜短(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
net9.0
enable
// 2. 领域模型与 DTO(Models/Book.cs)
namespace BookApi.Models;
///
/// 领域实体:永不直接暴露给客户端
///
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; } // 乐观锁
}
///
/// 创建请求 DTO
///
public sealed record CreateBookRequest(
string Title,
string Author,
string? Isbn,
decimal Price,
string Genre,
DateTime? PublishedAt);
///
/// 更新请求 DTO(全量,对应 PUT)
///
public sealed record UpdateBookRequest(
string Title,
string Author,
string? Isbn,
decimal Price,
string Genre,
DateTime PublishedAt,
bool IsAvailable);
///
/// 部分更新 DTO(对应 PATCH)
///
public sealed record PatchBookRequest
{
public string? Title { get; set; }
public string? Author { get; set; }
public decimal? Price { get; set; }
public bool? IsAvailable { get; set; }
}
///
/// 响应 DTO
///
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
{
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 Ok(T data, string message = "success") =>
new() { Code = 0, Message = message, Data = data };
public static ApiResponse 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
{
public List 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> GetAllAsync(string? genre, decimal? minPrice,
decimal? maxPrice, string? sort, int page, int size, CancellationToken ct);
Task CountAsync(string? genre, decimal? minPrice, decimal? maxPrice, CancellationToken ct);
Task GetByIdAsync(Guid id, CancellationToken ct);
Task AddAsync(Book book, CancellationToken ct);
Task UpdateAsync(Book book, CancellationToken ct); // false = 乐观锁冲突
Task DeleteAsync(Guid id, CancellationToken ct);
Task 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 _store = new();
private static int _seq = 0;
public InMemoryBookRepository(IEnumerable? seeds = null)
{
if (seeds != null)
foreach (var b in seeds) _store[b.Id] = b;
}
public Task> 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>(q.Skip((page - 1) * size).Take(size).ToList());
}
public Task 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 GetByIdAsync(Guid id, CancellationToken ct) =>
Task.FromResult(_store.TryGetValue(id, out var b) ? b : null);
public async Task 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 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 DeleteAsync(Guid id, CancellationToken ct) =>
Task.FromResult(_store.TryRemove(id, out _));
public Task ExistsByIsbnAsync(string isbn, CancellationToken ct) =>
Task.FromResult(_store.Values.Any(b => b.Isbn == isbn));
private static IEnumerable ApplySort(IEnumerable 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 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? errors = null)
{
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/json; charset=utf-8";
var body = ApiResponse 原文地址: https://www.cveoy.top/t/topic/qHVH 著作权归作者所有。请勿转载和采集!