REST API:从入门到放弃再到爱不释手
REST API:从入门到放弃再到爱不释手
说实话,我最近翻看了一下自己几年前写的接口代码,那叫一个惨不忍睹啊。GET、POST随便用,接口响应格式五花八门,URL设计完全是想到哪写到哪。要不是现在要给新同事交接,我都不好意思再看第二眼。
正好趁这个机会,我打算好好聊聊REST API这个话题。老实说,这个概念我也是用了好几年才真正开始理解,从一开始的完全不在意,到后来被各种规范搞得焦头烂额,再到现在算是找到了一个相对舒服的实践方式。
先别急着规范,聊聊REST是怎么来的
很多教程一上来就给你甩一堆规范,什么HTTP动词要合理使用啊,URL要符合REST风格啊...但说实话,如果不知道这些规范背后的思考,很容易就走到另一个极端:把REST当成紧箍咒,写个接口前还得战战兢兢地翻文档。
REST这个概念是Roy Fielding在他2000年的博士论文里提出来的。要问我为啥知道这个,因为当年被面试官问到REST是什么的时候答不上来,回去恶补了一通(笑)。
说到Roy Fielding,那可不是一般人,他是HTTP 1.0和1.1规范的主要作者之一,Apache HTTP Server项目的联合创始人。所以当他提出REST的时候,某种程度上是在说:"伙计们,HTTP协议的设计者在这儿,让我告诉你们这个协议最适合怎么用。"
HTTP方法,我们真的用对了吗?
讲真,我现在看到有些代码里面,所有请求都用POST方法,就忍不住想吐槽。但转念一想,我以前不也是这么干的吗?
说说我之前的心路历程:
阶段1: 只用GET和POST
- GET:获取数据
- POST:其他所有操作
阶段2: 知道了PUT和DELETE,但感觉麻烦
- "反正POST也能用,何必搞这么复杂?"
阶段3: 被迫用RESTful(公司规范)
- 怒搜各种HTTP方法的区别
- 开始机械地套用规则
阶段4: 突然开窍
- 原来HTTP方法是在描述这个请求的语义!
- 不同方法有不同的特性和约束!
现在再来看HTTP的主要方法:
GET:安全且幂等,就是说调用多少次结果都一样,而且不会修改服务器的数据。这不就是天然适合做数据查询吗?
POST:非安全非幂等,每次调用都可能产生新的资源或者修改数据。创建新订单、发送消息这类操作,用POST再合适不过。
PUT:幂等但不安全。这个特性特别适合更新操作,比如更新用户信息,不管调用几次,最终结果都是一样的。
DELETE:幂等但不安全。删除操作嘛,资源不存在了就是不存在了,多删几次也没啥影响。
说到这里,不知道你有没有感觉到,HTTP方法的设计其实暗含了一些很巧妙的约束?这些约束可不是瞎定的,都是为了让我们的API更可靠、更易用。
URL设计:那些年踩过的坑
天啊,一说到URL设计,我就想起我之前写过的一些"杰作":
/api/getUserById?id=123
/api/createNewOrder
/api/doUpdateProduct
/api/user/deleteUserAction
看着这些URL,我都替自己脸红。不过既然是踩过坑,那就来分享一下我的心得。
首先,URL应该是描述资源的,而不是描述操作的。这句话我以前看了无数遍都不理解,直到有一天我突然开窍:
# 之前的思维方式:
GET /api/getUser/123 # 获取用户
POST /api/createUser # 创建用户
PUT /api/updateUser/123 # 更新用户
DELETE /api/deleteUser/123 # 删除用户
# 现在的思维方式:
GET /api/users/123 # 用户这个资源
POST /api/users # 用户这个资源
PUT /api/users/123 # 用户这个资源
DELETE /api/users/123 # 用户这个资源
你看,URI始终是/api/users
,变的只是HTTP方法。这就是所谓的"面向资源",而不是"面向动作"。
说实话,这个转变对我来说还挺不容易的。因为作为程序员,我们习惯了面向动作编程,比如getUser()
、createUser()
这样的方法名。但REST的思维方式是不一样的,它强调的是资源的状态转换。
状态码:比你想象的要重要
我之前的代码里,基本上都是用200表示成功,500表示失败,再加上一个自定义的状态码。现在想想,这简直是在浪费HTTP协议给我们提供的语义化能力。
HTTP状态码设计得有多精妙?我举个例子:
201 Created:
- 表示资源创建成功
- 最适合用在POST请求成功的响应上
- 最好带上新资源的URI
204 No Content:
- 表示请求成功,但不需要返回内容
- 最适合用在DELETE请求成功后
- 节省带宽,因为确实没啥要返回的
304 Not Modified:
- 配合缓存机制使用
- 告诉客户端可以继续使用缓存
- 超级节省带宽
409 Conflict:
- 比如并发更新冲突
- 比直接返回400要更明确
- 客户端看到这个状态码就知道怎么处理了
有意思的是,这些状态码之间还有一些潜规则:
- 2xx:请求成功,但成功的方式不同
- 3xx:需要客户端采取进一步操作
- 4xx:客户端出错,需要修正请求
- 5xx:服务器出错,需要修复服务器
说实话,合理使用HTTP状态码,不仅可以让API的语义更清晰,还能帮助客户端更好地处理异常情况。
到底要不要严格遵循REST?
这个问题我思考了很久。因为你去网上搜,会发现有很多关于"RESTful最佳实践"的文章,写得特别严格,弄得我一度对写API都产生了心理负担。
后来我想明白了,REST实际上是一种架构风格,不是一个严格的标准。就像写代码有"面向对象编程"的思想,但不是说你的代码里每个东西都必须是对象。
我现在的实践方式是:
理解REST的核心思想:
- 资源导向而不是动作导向
- 利用HTTP协议的特性
- 无状态通信
- 统一接口
但不死板遵循每一条规范:
- URL是否一定要用名词?不一定
- 是否一定要用标准的HTTP方法?看情况
- 是否一定要用对应的状态码?酌情处理
实战经验分享
说说我在实际项目中遇到的一些情况吧。
1. 批量操作怎么设计?
这个问题困扰了我很久。比如批量删除用户,严格按REST的话,应该是发多个DELETE请求,但这样显然不够优雅。
我现在的处理方式:
# 批量删除
DELETE /api/users?ids=1,2,3
# 批量更新
PUT /api/users/batch
{
"users": [
{"id": 1, "name": "张三"},
{"id": 2, "name": "李四"}
]
}
2. 复杂查询怎么设计?
一开始我特别纠结要不要把所有查询条件都放在URL里。后来发现,当查询条件复杂到一定程度,用GET就显得很不优雅了。
现在的解决方案:
# 简单查询用GET
GET /api/users?status=active&role=admin
# 复杂查询用POST
POST /api/users/search
{
"status": "active",
"createTime": {
"start": "2023-01-01",
"end": "2023-12-31"
},
"tags": ["vip", "新客户"],
"sort": [
{"field": "createTime", "order": "desc"},
{"field": "id", "order": "asc"}
]
}
3. 版本控制
关于API版本控制,我尝试过几种方式:
# URL中的版本号
/api/v1/users
# Header中的版本号
Accept: application/vnd.myapp.v1+json
# 参数中的版本号
/api/users?version=1
最后还是选择了URL中的版本号,原因很简单:
- 直观,一眼就能看出来用的哪个版本
- 调试方便,直接在浏览器就能测试
- 方便做负载均衡和路由
4. 错误处理
这个我改过好几版,现在用的是这种格式:
{
"code": "USER_NOT_FOUND",
"message": "用户不存在",
"details": {
"userId": "123",
"trace": "xxx"
},
"timestamp": "2023-12-06T10:00:00Z"
}
为什么要这么设计:
- code:机器可读的错误码,方便客户端做不同处理
- message:人类可读的错误信息
- details:详细的错误信息,方便调试
- timestamp:错误发生的时间,对排查问题很有帮助
安全性考虑
说实话,光设计API格式是不够的,安全性也很重要。分享几个我踩过的坑:
1. CORS配置
最开始我们的CORS配置是这样的:
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "*");
response.setHeader("Access-Control-Allow-Headers", "*");
后来发现这样配置太危险了,改成了更严格的版本:
response.setHeader("Access-Control-Allow-Origin", "https://api.myapp.com");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
2. 认证与授权
JWT用了一段时间后,我总结出了几点经验:
- 不要把敏感信息放在JWT里
- 设置合理的过期时间
- 使用refresh token机制
- 考虑token的撤销机制
3. 限流措施
一开始没做限流,结果有一次被爬虫爬到差点把服务器搞挂。后来加了几层保护:
- IP级别限流
- 用户级别限流
- 接口级别限流
- 业务级别限流
实现方式用的是Redis + Lua脚本,大概是这样:
local times = redis.call('incr', KEYS[1])
if times == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end
if times > tonumber(ARGV[2]) then
return 0
end
return 1
性能优化
说到REST API的性能优化,我这里有几个实践经验:
1. 合理使用缓存
HTTP缓存机制其实很强大,但很多人(包括以前的我)都没用好。几个关键的Header:
Cache-Control: max-age=3600
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT
2. 压缩响应
开启GZIP压缩是最简单的优化手段之一:
response.setHeader("Content-Encoding", "gzip");
但要注意,不是所有响应都适合压缩,比如:
- 已经压缩过的文件(图片、视频等)
- 小于1kb的响应
- 实时性要求高的数据
3. 分页优化
分页查询是个经常被忽视的性能瓶颈。我之前就犯过一个错误,用offset+limit做分页:
SELECT * FROM users LIMIT 1000000, 10
这种方式在offset很大的时候性能极差,后来改成了基于上一页最后一条记录的ID来查询:
SELECT * FROM users WHERE id > last_id LIMIT 10
REST的替代方案
说实话## REST的替代方案
说实话,REST也不是万能的。这几年我也在关注一些其他的API设计方案,各有各的特点。
GraphQL
最开始看到GraphQL的时候,我觉得这玩意儿也太复杂了。但是在做了一个需要大量定制化查询的项目后,我才真正理解它的价值。
比如这个场景:
query {
user(id: "123") {
name
orders(last: 5) {
id
amount
products {
name
price
}
}
}
}
用REST的话,你可能需要:
- 先请求用户信息
/api/users/123
- 再请求订单列表
/api/users/123/orders?limit=5
- 再请求每个订单的产品信息
/api/orders/{id}/products
这种情况下,GraphQL的优势就很明显了。不过说实话,GraphQL也有它的问题:
- 学习曲线陡峭
- 缓存策略复杂
- 安全性需要额外考虑
- 监控和调试不如REST方便
gRPC
讲真,如果是做微服务内部调用,我现在更倾向于用gRPC。原因很简单:性能好,类型安全,代码生成方便。
来看个proto文件的例子:
service UserService {
rpc GetUser (GetUserRequest) returns (User) {}
rpc CreateUser (CreateUserRequest) returns (User) {}
rpc UpdateUser (UpdateUserRequest) returns (User) {}
rpc DeleteUser (DeleteUserRequest) returns (google.protobuf.Empty) {}
}
message User {
string id = 1;
string name = 2;
string email = 3;
repeated string roles = 4;
}
但gRPC也有它的局限性:
- 不适合浏览器直接调用
- 调试不如REST直观
- 部署和运维要求较高
- 文档和工具链不如REST成熟
实战中的特殊场景处理
说说一些我在实际项目中遇到的特殊情况。
1. 文件上传下载
这个场景其实挺尴尬的,因为严格遵循REST的话,文件也是一种资源,但实际操作起来会遇到很多问题。
我现在的处理方式:
# 上传文件
POST /api/files
Content-Type: multipart/form-data
# 下载文件
GET /api/files/{id}/content
# 获取文件信息
GET /api/files/{id}
为什么要分开content和metadata?因为很多时候我们只需要文件信息,不需要实际内容。
2. 长时间运行的任务
有些操作可能需要运行很久,比如数据导入、报表生成等。这种情况下,同步API就不太合适了。
我的解决方案是使用异步任务模式:
# 提交任务
POST /api/tasks
{
"type": "REPORT_GENERATION",
"params": {
"startDate": "2023-01-01",
"endDate": "2023-12-31"
}
}
响应:
{
"taskId": "abc123",
"status": "PENDING",
"createTime": "2023-12-06T10:00:00Z"
}
# 查询任务状态
GET /api/tasks/abc123
{
"taskId": "abc123",
"status": "PROCESSING",
"progress": 45,
"createTime": "2023-12-06T10:00:00Z",
"updateTime": "2023-12-06T10:05:00Z"
}
3. 实时通知
REST是基于请求-响应模式的,不太适合处理实时通知的场景。我一般会结合WebSocket来处理:
# REST API用于管理订阅关系
POST /api/notifications/subscriptions
{
"type": "ORDER_STATUS",
"params": {
"orderId": "123"
}
}
# WebSocket用于推送实时消息
ws://api.myapp.com/ws/notifications
4. 导入导出
大数据量的导入导出也是个麻烦事,我一般这么处理:
# 创建导出任务
POST /api/exports
{
"type": "USER_REPORT",
"format": "EXCEL",
"filters": {
"startDate": "2023-01-01",
"endDate": "2023-12-31"
}
}
# 获取导出进度
GET /api/exports/{taskId}
# 下载导出文件
GET /api/exports/{taskId}/file
API文档:懒人的福音
说实话,我最讨厌的就是写API文档了。特别是改了代码还要同步改文档,经常改着改着就不同步了。
后来发现了Swagger(现在叫OpenAPI),简直是救星:
@Operation(summary = "获取用户信息")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "成功"),
@ApiResponse(responseCode = "404", description = "用户不存在")
})
@GetMapping("/users/{id}")
public User getUser(
@Parameter(description = "用户ID")
@PathVariable String id
) {
// ...
}
写在代码里的好处是:
- 代码即文档,永远保持同步
- IDE可以提供智能提示
- 可以自动生成在线文档
- 客户端代码可以自动生成
监控和调试
API设计得再好,如果出了问题不好调试那也是个灾难。分享几个实用的技巧:
1. 请求跟踪
在复杂系统中,一个API请求可能会调用多个服务。这时候就需要请求跟踪了:
X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
X-Trace-ID: 67890
2. 性能监控
我现在每个API都会收集这些指标:
- 响应时间
- 请求量
- 错误率
- 并发数
- 业务指标(比如订单量)
3. 日志最佳实践
{
"timestamp": "2023-12-06T10:00:00.123Z",
"level": "INFO",
"traceId": "550e8400-e29b-41d4-a716-446655440000",
"service": "user-service",
"method": "GET",
"path": "/api/users/123",
"duration": 45,
"statusCode": 200,
"userId": "admin",
"clientIp": "192.168.1.1",
"message": "Request completed"
}
写在最后
说实话,写这篇文章的时候我就在想,REST API这个话题真的是说不完。每个团队、每个项目可能都有自己的最佳实践,没有一种方案是完美的。
我现在的态度是:
- 理解原则背后的原因
- 根据实际情况灵活运用
- 保持一致性比完美更重要
- 不断学习和改进
对了,如果你觉得这篇文章有用,欢迎点赞转发。如果你有什么不同的想法或者实践经验,也欢迎在评论区分享(即将对网站集成)!