REST API 风格
这一课你将能做到
- 按 REST 语义设计资源路由:URL 定资源、方法定动作、状态码定结果
- 用 PHP 原生实现一个任务清单(Todo)API:GET/POST/PUT/DELETE 全覆盖
- 统一 JSON 响应结构与错误处理,并用 curl 对每个接口做验收测试
为什么要学
REST 是 HTTP 世界的「通用普通话」:前端 Vue/React、小程序、第三方系统、开放平台,全都通过「资源 + 方法 + 状态码」这套语义交互。PHP 上一课已经会用 PDO 操作数据,这一课把数据能力包成规范接口,你就打通了「前端请求 → 路由分发 → 业务处理 → 数据落库 → 统一响应」的完整链路——这正是 Laravel/Symfony 框架做的事,只是框架帮你写了路由器和分发器。
人话:REST 就是「把数据库里的东西当资源,用 HTTP 方法当动词」:GET 读、POST 建、PUT 全量改、DELETE 删;状态码 200/201/204/400/404/422/500 各说各话,别一律 200。
先建立图像
任务清单 API 的路由表(本课要实现的全部接口):
GET /api/tasks 列出任务(支持 ?status=done 过滤) 200
POST /api/tasks 新建任务(请求体 JSON) 201
GET /api/tasks/{id} 查询单个任务 200 / 404
PUT /api/tasks/{id} 全量更新任务 200 / 404
DELETE /api/tasks/{id} 删除任务 204 / 404
约定:
URL 只有名词复数,动词交给 HTTP 方法
状态码表达结果,响应体统一 {"code": ..., "message": ..., "data": ...}
跟着做
步骤 1:路由分发器(前端控制器)
api.php——所有请求的唯一入口:
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *'); // 演示用 CORS
function respond(int $code, array $data = []): never {
http_response_code($code);
if ($code !== 204) { // 204 无响应体
echo json_encode($data + ['code' => $code], JSON_UNESCAPED_UNICODE);
}
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); // /api/tasks/3
// 从 /api/tasks/3 提取资源与 id
if (!str_starts_with($uri, '/api/tasks')) {
respond(404, ['message' => '未知资源']);
}
$id = null;
if (preg_match('#^/api/tasks/(\d+)$#', $uri, $m)) {
$id = (int) $m[1];
}
内置服务器支持路由脚本,让所有路径都进 api.php:php -S localhost:8000 api.php。
步骤 2:GET 列表与详情
<?php
// 沿用上一课的 $pdo 连接(真实代码 require db.php)
if ($method === 'GET' && $id === null) {
$status = $_GET['status'] ?? null; // 过滤参数走查询串
$sql = 'SELECT id, title, status, created_at FROM tasks';
$params = [];
if ($status !== null) {
$sql .= ' WHERE status = ?';
$params[] = $status;
}
$sql .= ' ORDER BY id DESC LIMIT 20';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
respond(200, ['data' => $stmt->fetchAll()]);
}
if ($method === 'GET' && $id !== null) {
$stmt = $pdo->prepare('SELECT * FROM tasks WHERE id = ?');
$stmt->execute([$id]);
$task = $stmt->fetch();
$task === false
? respond(404, ['message' => "任务 {$id} 不存在"])
: respond(200, ['data' => $task]);
}
步骤 3:POST 创建与 JSON 请求体
<?php
if ($method === 'POST') {
// $_POST 解析不了 JSON,必须读原始输入流
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body)) {
respond(400, ['message' => '请求体必须是合法 JSON']);
}
$title = trim((string) ($body['title'] ?? ''));
if ($title === '' || mb_strlen($title) > 50) {
respond(422, ['message' => 'title 必填且不超过 50 字']); // 校验失败 422
}
$stmt = $pdo->prepare('INSERT INTO tasks (title, status) VALUES (?, ?)');
$stmt->execute([$title, 'todo']);
respond(201, [ // 创建成功 201
'message' => '创建成功',
'data' => ['id' => (int) $pdo->lastInsertId()],
]);
}
步骤 4:PUT 更新与 DELETE 删除
<?php
if ($method === 'PUT' && $id !== null) {
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$title = trim((string) ($body['title'] ?? ''));
$status = in_array($body['status'] ?? '', ['todo', 'doing', 'done'], true)
? $body['status'] : 'todo'; // 枚举值必须白名单校验
if ($title === '') {
respond(422, ['message' => 'PUT 是全量更新,title 必填']);
}
$stmt = $pdo->prepare('UPDATE tasks SET title = ?, status = ? WHERE id = ?');
$stmt->execute([$title, $status, $id]);
$stmt->rowCount() === 0
? respond(404, ['message' => "任务 {$id} 不存在"])
: respond(200, ['message' => '更新成功']);
}
if ($method === 'DELETE' && $id !== null) {
$stmt = $pdo->prepare('DELETE FROM tasks WHERE id = ?');
$stmt->execute([$id]);
$stmt->rowCount() === 0
? respond(404, ['message' => "任务 {$id} 不存在"])
: respond(204); // 删除成功无响应体
}
respond(405, ['message' => '方法不被允许']);
步骤 5:curl 验收
curl -X POST localhost:8000/api/tasks -H "Content-Type: application/json" \
-d '{"title": "学习 REST"}' # 201 {"code":201,...,"data":{"id":1}}
curl "localhost:8000/api/tasks?status=todo" # 200 列表
curl localhost:8000/api/tasks/1 # 200 详情
curl -X PUT localhost:8000/api/tasks/1 -H "Content-Type: application/json" \
-d '{"title":"学习 REST","status":"done"}' # 200
curl -X DELETE localhost:8000/api/tasks/1 # 204
curl localhost:8000/api/tasks/999 # 404
讲清楚要点
- REST 的三根柱子:名词化 URI 定位资源、HTTP 方法表达动作、状态码表达结果。反模式是
POST /api/deleteTask——动词进 URL、语义全靠自定义。 - 状态码有分工:200 成功返回体、201 已创建、204 成功无返回体、400 请求格式错、401 未认证、403 已认证无权限、404 资源不存在、405 方法不支持、422 语义校验失败、500 服务端错误。一律 200 + 错误码是常见坏味道,会破坏 HTTP 缓存与网关监控。
- 幂等性是 PUT/DELETE 的灵魂:GET/PUT/DELETE 幂等(重复调用结果一致),POST 不幂等(重复提交建多条)。前端「防重复点击」的 Retry 设计、网关重试策略都建立在幂等语义上。
- 统一响应结构:外层固定 code/message/data,code 与 HTTP 状态码保持同向。业务错误信息放 message,分页等元信息放 data 或 meta——这是前后端协作的契约,比返回结构自由发挥省掉大量沟通成本。
你可能会懵的地方
- PUT 和 PATCH 的区别? PUT 是全量替换:客户端必须提交资源的完整表示,缺的字段会被覆盖;PATCH 是部分更新:只提交要改的字段。本课为简化用 PUT 承担了更新职责,规范做法是完整替换用 PUT、局部修改用 PATCH。
- 为什么 URL 用复数名词?
资源集合的语义:
GET /tasks是「任务集合」,GET /tasks/3是「集合中的第 3 个」。复数让集合与个体的 URI 天然一致,也让嵌套资源(/users/1/tasks)读起来自然。 - CORS 是什么,后端要做什么?
浏览器同源策略限制跨域 JS 请求,服务端通过响应头授权:
Access-Control-Allow-Origin(哪些来源可访问)、-Allow-Methods、-Allow-Headers;对非简单请求浏览器会先发 OPTIONS 预检,服务端需对 OPTIONS 返回 204 加 CORS 头。它只在浏览器生效,curl/Postman 不受限制。 - 版本号放 URL 还是请求头?
都可,工程上 URL 更直观:
/api/v1/tasks。大版本不兼容时新增 v2 而非改坏 v1,给客户端平滑迁移窗口。 - 分页参数怎么设计?
通用做法
?page=1&page_size=20(或游标?cursor=xxx),响应里带meta: {page, page_size, total};page_size 服务端设上限(如 100),防止一次拖全表。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
POST /api/delete?id=1 | 动词进 URL,语义混乱 | DELETE /api/tasks/1 |
| 所有响应都返回 200 | 监控、缓存、重试全部失灵 | 按语义返回 201/204/404/422 |
直接 echo json_encode($row) | 无结构契约,前端难处理 | 统一 code/message/data 外壳 |
用 $_POST 接 JSON 请求体 | $_POST 只解析表单编码 | 读 php://input 再 json_decode |
| DELETE 返回 200 + 大段数据 | 删除无返回体,204 即可 | 成功删返回 204 |
| PUT 只传部分字段 | 未传字段被覆盖成默认值 | PUT 传全量,或改用 PATCH 语义 |
卡点急救
php -S下访问 /api/tasks/3 返回 404 找不到文件:内置服务器默认按文件找。用路由脚本启动:php -S localhost:8000 api.php,所有路径交给 api.php 处理。- 前端跨域请求被浏览器拦截:服务端补 CORS 头;带 Cookie 时
Allow-Origin不能为*,要写具体域名并加Allow-Credentials: true。 - PUT 后数据没变也不报 404:
rowCount()对「值没变化的 UPDATE」返回 0。需要区分「存在但值相同」时,先 SELECT 确认资源存在,再判断更新结果。
面试常问
- REST 的核心原则有哪些? 资源化(URI 指向名词资源)、统一接口(HTTP 方法语义化且 GET/PUT/DELETE 幂等)、无状态(每个请求自包含,服务端不存会话上下文)、分层系统与可缓存。落地上表现为:名词复数 URI + 方法分动作 + 状态码表结果 + 统一 JSON 契约。
- PUT 和 PATCH 的语义差异?为什么 PUT 是幂等的? PUT 全量替换资源表示,无论调用多少次,资源最终状态相同,故幂等;PATCH 局部修改,结果取决于当前资源状态,重复调用可能叠加(如 PATCH 自增字段),不保证幂等。
- HTTP 状态码 401 与 403 的区别?422 与 400 的区别? 401 未认证(不知道你是谁),403 已认证但无权限(知道你是谁但不让你干);400 请求体语法/格式错误(JSON 解析失败),422 语法正确但语义校验失败(title 为空、status 非法)。选对状态码直接决定网关重试与监控告警的准确性。
自测三问
- 列出本课 5 个接口的「方法 + 路径 + 成功状态码」。
- 为什么 JSON API 要从
php://input读请求体?$_POST 拿不到什么? - GET、POST、PUT、DELETE 四个方法中谁不幂等?这对重试策略意味着什么?
小结一句话
REST = 名词 URI + 方法表动作 + 状态码表结果 + 统一 JSON 契约;PHP 原生百来行就能跑通一个合格 API,框架只是把这套骨架做得更顺手。
延伸
- PHP 官方手册:HTTP 相关函数
- 下一课:PHP 8 新特性——枚举、只读属性、纤程与 JIT。