把 RESTful API 聊透,再用 PHP 撸一个能直接用的接口,这事儿我琢磨了挺久。网上讲 RESTful 的文章一大把,但大多数要么停留在概念层面,把"URL 长得好看 + 返回 JSON"当成 RESTful 的全部;要么直接甩给你一个 Laravel 项目,看完也不知道框架帮你做了什么、你自己该做什么。这篇文章打算换个路子:先把 REST 的核心思想拆开揉碎,讲清楚每一个约束到底在解决什么问题;然后带你把路由、控制器、验证、错误处理、CORS 这些环节用原生 PHP 8 代码逐个落地。整篇代码可以直接复制到本地跑起来,跟着走一遍,你不仅能看懂 RESTful API 的门道,还能亲手撸出一个五脏俱全的接口服务。无论你是刚接触接口开发的初级工程师,还是被各种 REST 规范绕晕的老手,这篇应该都能给你一点实在的东西。
1. 别把 RESTful 当成"URL 风格指南"
很多人对 RESTful API 的理解是"接口地址要长得漂亮",比如 /getUserInfo 改成 /users/1,把动词换成名词,把下划线去掉,就觉得"我 RESTful 了"。这个误解太普遍了,得先把这件事掰清楚。
1.1 REST 到底是什么,它解决了什么问题
REST 的全称是 Representational State Transfer,中文常翻译成"表现层状态转移"。这个名字太拗口了,如果非要用一句话说清楚,我的理解是:REST 是一组设计约束,它规定客户端和服务器之间应该怎样交换资源的状态。这里的"资源"是核心概念——用户、订单、商品、文章,这些东西都叫资源。而"表现层"是说服务器返回给客户端的不是资源本身,而是资源的一种"表示",比如 JSON 就是用户这个资源的一种表现格式。"状态转移"指的是客户端通过 HTTP 方法去改变服务器端资源的状态:GET 读取、POST 创建、PUT 整体更新、DELETE 删除,这就是所谓的"转移"。
明白了这层含义你就知道,RESTful 的重点根本不在于 URL 长什么样。URL 当然重要,但更核心的是你有没有用 HTTP 协议本来就提供的那套语义。HTTP 协议里有 GET、POST、PUT、PATCH、DELETE 这些方法,每个方法都有它原本的定义;有 200、201、400、404、500 这些状态码,每个状态码都有它原本的含义。REST 的约束就是让你别另起炉灶,别把"成功失败"统统塞进 response body 里用 { "code": 0 } 来表示,而是踏踏实实地把 HTTP 协议的能力用对、用满。
如果还是觉得抽象,可以拿餐厅点菜打比方。饭店有菜单(资源列表)、有单个菜品(具体资源),你点菜用"来一份宫保鸡丁"(创建订单),吃完了让服务员撤盘(删除订单)。HTTP 方法就是你跟服务员说的话,状态码就是服务员给你的回应——"201 Created"表示菜做好了端上来了,"404 Not Found"表示这个菜今天没有,"429 Too Many Requests"表示后厨忙不过来了你先等等。一个设计良好的 RESTful API,就应该像一家训练有素、服务规范的餐厅,不需要你猜"服务员说'不好意思'到底是没菜了还是汤洒了",因为每一个响应都有明确的语义。
1.2 六大约束逐个拆解:哪些该学,哪些别死磕
REST 架构风格由 Roy Fielding 在他的博士论文中提出,论文里规定了六大约束。我逐个说一下,哪些是必须守住的底线,哪些在现实项目里可以灵活处理。
约束一:客户端-服务器分离。 客户端只关心界面展示,服务器只关心数据存储和业务逻辑。这个约束我们在做 API 时天然就满足了——前端用 Vue、React 做页面,后端提供 JSON 接口,两边互不干涉。它背后的好处是两端可以独立演进,后端把接口从 PHP 换成了 Go,只要接口契约不变,前端根本不需要动。
约束二:无状态(Stateless)。 这是新手最容易忽略的一条。它要求服务器不保存客户端的会话状态,每个请求都得自包含——身份认证信息通过 Header 传过来,分页参数通过 Query 传过来,服务器不记忆"上次这个用户看到第几页了"。这样做的好处是服务器可以随意横向扩展,任何一个节点都能处理任意请求,因为不需要共享 Session。对应到 PHP 里,就是要谨慎使用 $_SESSION。PHP 天然是"请求结束即释放"的模型,用 $_SESSION 表面上省事,但一旦以后要水平扩容你就得做分布式会话同步,麻烦得很。正确做法是把用户身份放在 token 里(JWT 或简单的签名串),无状态地把认证信息随请求带走。
约束三:可缓存。 HTTP 协议本身是支持缓存的,REST 要求服务器明确标示哪些响应可以被缓存。比如一个商品的详情接口,商品信息不常变,就可以在响应头里加 Cache-Control: public, max-age=3600,这样客户端和中间缓存节点就知道这个响应可以缓存一小时。很多 PHP 项目不做缓存标注,其实挺可惜。合理利用 HTTP 缓存能显著减轻服务器压力,这个后面实操部分会用具体例子说。
约束四:统一接口。 这是 REST 最核心的一条,也是"URL 风格指南"派误解最多的地方。统一接口要求四个子约束:资源标识(通过 URI 定位资源)、资源表示(客户端通过修改资源的表示来操作资源)、自描述消息(请求和响应里要携带足够的元数据,比如 Content-Type、Accept)、以及 HATEOAS(Hypermedia As The Engine Of Application State,即响应中要带有相关操作的链接,引导客户端下一步可以做什么)。前三个子约束我们在工程中必须遵守,第四个 HATEOAS 在内部 API 中一般不强制,因为内部 API 的使用者是自己的前端团队,不是开放的第三方开发者。但你得知道有这么回事,免得以后对接开放平台时抓瞎。
约束五:分层系统。 客户端不需要知道它连接的是最终服务器还是中间代理。这意味着你可以在客户端和服务器之间加网关、加负载均衡、加缓存层,而客户端感知不到。这个约束对我们做工程的意义是:API 的地址应该是一个逻辑地址,而不是某台具体物理机的地址。你在设计 API 的时候思维上就要留出这层弹性空间。
约束六:按需代码(Code on Demand)。 服务器可以返回一段可执行代码(比如 Java Applet、JavaScript)给客户端,让客户端扩展功能。这个约束在现实中用得非常少,因为它引入了安全风险,你基本可以忽略它。
综合来看,真正决定 REST 成色的,是"无状态""统一接口""可缓存"这三条。什么"RESTful 必须返回 JSON""RESTful 必须用名词复数形式的 URL"都是表面的东西,约束背后的目的才是根。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工具选型:用原生 PHP 还是框架,我为什么这么选
做 API 开发的工具选择无非几条路:完全裸写 PHP 脚本、用微型框架、用全栈框架。每条路都有它的适用场景,我先把利弊摊开,再说我的选择。
2.1 裸写 PHP 和框架的边界在哪里
裸写 PHP 的意思是,不引入任何框架代码,直接 $_SERVER['REQUEST_METHOD'] 配合 $_GET、$_POST 拿参数,然后层层 if-else 判断 URL,匹配到对应逻辑就输出 JSON。这种方式的项目我之前见过不少,代码看着倒也直白,但随着接口数量增长到二三十个以后,路由逻辑、参数校验、错误处理这些代码就会纠缠在一起,改动一个接口往往要牵扯好几个文件,维护成本直线上升。
用全栈框架(Laravel、Symfony)则走向另一个极端。框架帮你把路由、中间件、依赖注入、ORM、验证器、测试全都安排好了,开发效率确实高。但框架的"重"体现在两个地方:一是框架本身有一定的学习成本,新手很容易被"门面"和"服务容器"这些概念挡住;二是框架的很多能力在纯 API 场景下是用不到的,你引入几十个类文件,就为了用一个路由分发功能,总觉得有点杀鸡用牛刀。
微型框架(Slim、Lumen、Phalcon 的微应用模式)是折中方案。它只做路由分发和请求/响应抽象,不强制你绑定 ORM,也不给你整套 MVC。对于一个不需要页面渲染、只输出 JSON 的 API 项目来说,微型框架的体量刚刚好——轻、快、上手成本低,路由和中间件的表达能力也远超手写 if-else。
2.2 我选 Lumen + 原生 PHP 思路的具体方案
现实项目里我的选择通常是:核心逻辑用原生 PHP 风格写清楚,路由和请求解析借助 Lumen(Laravel 的轻量版),这样两头的好处都占了。可能有人觉得这样"不纯",都用了 Lumen 干嘛不干脆用全 Laravel?我的理由很简单:Lumen 默认就砍掉了一些 API 用不上的功能,比如 session、视图渲染,启动速度比 Laravel 快不少;同时它对已有的 Laravel 开发者来说零学习成本,能用 PHP 的地方依然能自由写。
不过在这篇博文里,我决定纯用原生 PHP 手写一个最小路由。原因不是"原生更高级",而是作为一个教学示例,原生代码能把"路由分发""参数绑定""错误处理"这些环节的每一行逻辑都暴露在你面前。你亲手写过一遍之后,再去看 Lumen 或 Laravel 的路由源码,思路会清晰得多,因为你已经知道它们背后在干什么了。所谓"框架是一层封装",封装不可怕,可怕的是你不知道封装了什么。
如果你要在实际生产环境里使用,我的建议是:团队里都是老手、追求开发速度,直接用 Lumen 或 Slim;如果你是想彻底搞懂 RESTful API 的每个细节,跟着这篇文章用原生代码走一遍,收获会更大。两种路我都走过,下面给的示例代码是"原生实现版",生产项目可以在理解同等逻辑后平滑迁移到框架写法。
3. 核心实现:从零手写一个 RESTful API
这一章进入实操。我会带你写一个图书管理系统的 RESTful API,资源是 books,包含常见的增删改查和列表、搜索功能。所有代码基于 PHP 8.1+ 语法,因为 PHP 8 在类型系统、属性、联合类型上的改进对写 API 非常友好,8.3 又补了不少兜底特性,比如只读属性深拷贝修正、随机数生成改进等。
3.1 环境准备和目录结构
先确认本地环境。我用的是 PHP 8.3 + Nginx,你如果用的是 Apache 或 PHP 内置开发服务器(php -S localhost:8000)也完全可以。需要打开的扩展是 pdo_sqlite,因为我不想让示例依赖 MySQL,用 SQLite 一个文件搞定数据存储,你把代码复制到任何一个 PHP 环境都能直接跑。
项目目录我这样规划:
code复制restful-demo/
├── public/
│ └── index.php # 前端控制器(单入口)
├── src/
│ ├── Router.php # 路由分发核心
│ ├── Request.php # 请求封装
│ ├── Response.php # 响应封装
│ ├── Database.php # PDO 单例封装
│ ├── controllers/
│ │ └── BookController.php
│ └── helpers.php # 工具函数
├── storage/
│ └── database.sqlite # 数据库文件
└── composer.json
为什么用单入口(public/index.php)?因为 PHP 内置开发服务器或 Nginx 的 try_files 重写规则会把所有请求交给 index.php,由路由分发决定谁来处理。单入口的好处是全局可以统一做 CORS、统一异常捕获、统一启动流程。这在 RESTful API 的设计里非常关键——如果你还是用 book.php、user.php 这种多脚本方式,等于把路由逻辑散落在各个文件里,违背了"统一接口"的灵活性和可维护性。
先写 composer.json,在 PSR-4 自动加载支持下,我们可以在需要的文件顶部用 require_once 或者干脆手动加载。为了减少依赖,这次不用 Composer 的 autoload,直接手写一个简单的 spl_autoload_register(),逻辑一目了然。
php复制spl_autoload_register(function (string $class): void {
$prefix = 'App\\';
if (str_starts_with($class, $prefix)) {
$path = __DIR__ . '/../src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($path)) {
require $path;
}
}
});
这个自动加载的意思很直白:命名空间里 App\ 后面的部分,对应 src/ 目录下的路径。App\Controllers\BookController 就去加载 src/Controllers/BookController.php。没有用 Composer 的 autoload,是因为这个项目本身没有第三包,自己写一个更轻,也更能让新手看清类是怎么被加载进来的。
3.2 路由分发:用 PHP 8 的 match 表达式写得干净利落
路由分发是 API 的入口,它的职责是:接收一个请求,解析出 HTTP 方法和路径,找到对应的控制器方法去调用。我见过太多手写路由的例子,清一色的 if ($method === 'GET' && $path === '/users') { ... } else if (...),这种写法到了一百个路由时就非常痛苦。PHP 8 的 match 表达式是改写利器,它不仅能匹配值,还能匹配条件表达式,让路由表变得像配置一样清晰。
先写一个 Router 类:
php复制namespace App;
class Router
{
private array $routes = [];
private Request $request;
private Response $response;
public function __construct(Request $request, Response $response)
{
$this->request = $request;
$this->response = $response;
}
public function add(string $method, string $pattern, callable $handler): void
{
$this->routes[$method][] = ['pattern' => $pattern, 'handler' => $handler];
}
public function dispatch(): Response
{
$path = $this->request->getPath();
$method = $this->request->getMethod();
foreach ($this->routes[$method] ?? [] as $route) {
$params = $this->matchPath($route['pattern'], $path);
if ($params !== null) {
return call_user_func($route['handler'], $this->request, $this->response, ...$params);
}
}
return $this->response->json(['error' => 'Not Found'], 404);
}
private function matchPath(string $pattern, string $path): ?array
{
$pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $pattern);
$pattern = '#^' . $pattern . '$#';
if (preg_match($pattern, $path, $matches)) {
return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
}
return null;
}
}
这个 Router 的精髓在 matchPath:它把 {id} 这种风格的占位符转成正则命名分组,然后用 preg_match 匹配并提取参数值。比如 /books/{id} 匹配 /books/42 时,$matches['id'] 就是 '42',返回给 dispatch 后作为参数传给控制器方法。
需要注意的是,array_filter 用了 ARRAY_FILTER_USE_KEY,意思是只保留字符串键,因为 preg_match 返回的 $matches 里既有索引数字键(完整匹配和分组),又有字符串键(命名分组)。我们只要命名分组。
3.3 请求和响应封装:把 HTTP 语义穿在身上
RESTful API 对请求和响应的要求是"自描述"——客户端发出的请求要携带足够的元数据,服务器返回的响应要携带明确的状态码和正确的 Content-Type。
Request 封装这几点:
php复制namespace App;
class Request
{
private array $query;
private array $body;
private array $headers;
public function __construct()
{
$this->query = $_GET;
$this->headers = $this->parseHeaders();
$this->body = $this->parseBody();
}
private function parseHeaders(): array
{
$headers = [];
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5)))));
$headers[$name] = $value;
}
}
return $headers;
}
private function parseBody(): array
{
$contentType = $this->headers['Content-Type'] ?? '';
$raw = file_get_contents('php://input');
if (str_contains($contentType, 'application/json')) {
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
parse_str($raw, $data);
return is_array($data) ? $data : [];
}
public function getPath(): string
{
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($uri, PHP_URL_PATH);
return rtrim($path, '/') ?: '/';
}
public function getMethod(): string
{
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
// 支持 _method 覆盖,便于在某些环境下模拟 PUT/DELETE
if ($method === 'POST' && isset($this->body['_method'])) {
$method = strtoupper($this->body['_method']);
}
return $method;
}
public function input(string $key, mixed $default = null): mixed
{
return $this->body[$key] ?? $this->query[$key] ?? $default;
}
public function all(): array
{
return array_merge($this->query, $this->body);
}
public function header(string $name): ?string
{
return $this->headers[$name] ?? null;
}
}
parseBody 这里有个细节值得注意:如果客户端发送的是 Content-Type: application/json,我们用 json_decode 解析原始请求体;如果是传统的 application/x-www-form-urlencoded,则用 parse_str。方法覆盖的做法是为了兼容某些不能直接发 PUT/DELETE 的客户端,比如一些老旧表单工具。
Response 封装则要解决"怎么把数据变成 JSON 响应"和"怎么设置 HTTP 状态码"这两个基本问题:
php复制namespace App;
class Response
{
public function json(mixed $data, int $statusCode = 200, array $headers = []): Response
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
foreach ($headers as $name => $value) {
header("$name: $value");
}
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $this;
}
}
这个封装相当克制,没有引入什么花哨的"统一响应结构",因为 RESTful API 本身就不需要 { "code": 0, "data": {...} } 这种套壳。JSON 本身就是资源的表现形式,状态码就是结果的表达,一层 HTML 包一层 data 反而多此一举。关于"要不要统一返回 code",我强烈的观点是:如果你的接口要服务自己的前端,直接返回资源本身;如果对接第三方开放平台,可以用 code 做业务错误码,但 HTTP 状态码依然要正确。两者不冲突,但很多人用"统一结构"掩盖了 HTTP 状态码的错误使用,这是不对的方向。
3.4 控制器与数据库层:参数校验是 API 的守门员
接下来写 BookController,先看数据库封装。这个 Database 类很简单,就是一个 PDO 单例:
php复制namespace App;
use PDO;
class Database
{
private static ?PDO $instance = null;
public static function connection(): PDO
{
if (self::$instance === null) {
$dsn = 'sqlite:' . __DIR__ . '/../storage/database.sqlite';
self::$instance = new PDO($dsn);
self::$instance->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$instance->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
self::$instance->exec('CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
price REAL NOT NULL,
created_at TEXT DEFAULT (datetime(\'now\'))
)');
}
return self::$instance;
}
}
表的字段设计很简单:id、title、author、price、created_at。AUTOINCREMENT 让 SQLite 自动维护自增主键。注意 PDO::ATTR_ERRMODE 设置为 ERRMODE_EXCEPTION,这样所有 SQL 异常都会抛出 PDOException,我们在更上层统一捕获处理。
然后写 BookController:
php复制namespace App\Controllers;
use App\Database;
use App\Request;
use App\Response;
use PDO;
class BookController
{
public function index(Request $request, Response $response): Response
{
$db = Database::connection();
$page = max(1, (int) $request->input('page', 1));
$perPage = min(50, max(1, (int) $request->input('per_page', 10)));
$where = '';
$params = [];
if ($search = $request->input('q')) {
$where = ' WHERE title LIKE :like';
$params[':like'] = '%' . $search . '%';
}
$countStmt = $db->prepare('SELECT COUNT(*) FROM books' . $where);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$offset = ($page - 1) * $perPage;
$stmt = $db->prepare('SELECT * FROM books' . $where . ' ORDER BY id DESC LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
$books = $stmt->fetchAll();
return $response->json([
'data' => $books,
'meta' => [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => (int) ceil($total / $perPage),
],
]);
}
public function show(Request $request, Response $response, int $id): Response
{
$db = Database::connection();
$stmt = $db->prepare('SELECT * FROM books WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$book = $stmt->fetch();
if (!$book) {
return $response->json(['error' => 'Book not found'], 404);
}
return $response->json($book);
}
public function store(Request $request, Response $response): Response
{
$data = $request->all();
$errors = $this->validateBook($data, isUpdate: false);
if (!empty($errors)) {
return $response->json(['errors' => $errors], 422);
}
$db = Database::connection();
$stmt = $db->prepare('INSERT INTO books (title, author, price) VALUES (:title, :author, :price)');
$stmt->execute([
':title' => htmlspecialchars($data['title'], ENT_QUOTES, 'UTF-8'),
':author' => htmlspecialchars($data['author'], ENT_QUOTES, 'UTF-8'),
':price' => (float) $data['price'],
]);
$id = (int) $db->lastInsertId();
$stmt = $db->prepare('SELECT * FROM books WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$book = $stmt->fetch();
return $response->json($book, 201, ['Location' => '/books/' . $id]);
}
public function update(Request $request, Response $response, int $id): Response
{
$db = Database::connection();
$stmt = $db->prepare('SELECT * FROM books WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$book = $stmt->fetch();
if (!$book) {
return $response->json(['error' => 'Book not found'], 404);
}
$data = $request->all();
$errors = $this->validateBook($data, isUpdate: true);
if (!empty($errors)) {
return $response->json(['errors' => $errors], 422);
}
$stmt = $db->prepare('UPDATE books SET title = :title, author = :author, price = :price WHERE id = :id');
$stmt->execute([
':title' => htmlspecialchars($data['title'], ENT_QUOTES, 'UTF-8'),
':author' => htmlspecialchars($data['author'], ENT_QUOTES, 'UTF-8'),
':price' => (float) $data['price'],
':id' => $id,
]);
$stmt = $db->prepare('SELECT * FROM books WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$updatedBook = $stmt->fetch();
return $response->json($updatedBook);
}
public function destroy(Request $request, Response $response, int $id): Response
{
$db = Database::connection();
$stmt = $db->prepare('DELETE FROM books WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $response->json(null, 204);
}
private function validateBook(array $data, bool $isUpdate): array
{
$errors = [];
if ($isUpdate && !isset($data['title']) && !isset($data['author']) && !isset($data['price'])) {
$errors['fields'] = 'At least one field is required for update.';
return $errors;
}
if (isset($data['title'])) {
if (!is_string($data['title']) || mb_strlen($data['title']) < 2 || mb_strlen($data['title']) > 100) {
$errors['title'] = 'Title must be a string between 2 and 100 characters.';
}
} elseif (!$isUpdate) {
$errors['title'] = 'Title is required.';
}
if (isset($data['author'])) {
if (!is_string($data['author']) || mb_strlen($data['author']) < 2 || mb_strlen($data['author']) > 50) {
$errors['author'] = 'Author must be a string between 2 and 50 characters.';
}
} elseif (!$isUpdate) {
$errors['author'] = 'Author is required.';
}
if (isset($data['price'])) {
if (!is_numeric($data['price']) || (float) $data['price'] < 0) {
$errors['price'] = 'Price must be a non-negative number.';
}
} elseif (!$isUpdate) {
$errors['price'] = 'Price is required.';
}
return $errors;
}
}
这段代码里有两个细节我想单独强调。
第一个是 index 方法中的分页参数处理。per_page 做了 min(50, max(1, ...)) 的限制,防止客户端一口气要一万条记录把数据库打爆。分页响应里用 meta 字段把 total、page、per_page、total_pages 都告诉客户端,前端不需要额外发请求就能知道一共多少页。
第二个是 store 方法返回 201 Created 状态码,并且在 Location 响应头里写出了新创建资源的访问地址。这是 RESTful 语义里非常标准和讲究的做法——创建成功不是简单返回一个 200,而是用 201 明确告诉客户端"新资源已在服务器端创建",并且给出新资源的 URI。很多 PHP 项目万年不变地返回 200 + JSON,这不是什么大错误,但少了那层语义上的精确性。
create 和 store 的字段校验是分开的——创建时需要 title、author、price 必填;更新时则使用"部分更新"逻辑,客户端传哪个字段就更新哪个字段,至少要传一个字段。这个设计对应了 PUT(整体替换)和 PATCH(部分更新)的细微差别。严格说 PUT 应该整体替换,PATCH 做部分更新,但现实项目中很多团队直接用 PUT 兼做部分更新。我这里用的是 PATCH 风格的宽松更新,更符合实际调用方的使用习惯,同时方法名叫 update。
3.5 集成入口 index.php:把碎片拼成服务
有了 Request、Response、Router、BookController,现在把它们串起来。public/index.php 是唯一对外暴露的入口:
php复制declare(strict_types=1);
require __DIR__ . '/../src/helpers.php';
spl_autoload_register(function (string $class): void {
$prefix = 'App\\';
if (str_starts_with($class, $prefix)) {
$path = __DIR__ . '/../src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($path)) {
require $path;
}
}
});
use App\Database;
use App\Request;
use App\Response;
use App\Router;
// 启用 CORS
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// 初始化数据库
Database::connection();
// 错误与异常统一接管
set_exception_handler(function (Throwable $e) {
$response = new Response();
$response->json(['error' => $e->getMessage()], 500);
});
$request = new Request();
$response = new Response();
$router = new Router($request, $response);
$router->add('GET', '/books', [new BookController(), 'index']);
$router->add('GET', '/books/{id}', [new BookController(), 'show']);
$router->add('POST', '/books', [new BookController(), 'store']);
$router->add('PUT', '/books/{id}', [new BookController(), 'update']);
$router->add('DELETE', '/books/{id}', [new BookController(), 'destroy']);
$router->dispatch();
CORS 的处理是跨域接口的命门,这里必须先说清楚。前端项目如果跑在 http://localhost:5173(Vite 开发服务器),API 跑在 http://localhost:8000,这两个端口不同,浏览器就会发起跨域请求。跨域请求分为简单请求和预检请求:GET、POST(Content-Type 为 form-urlencoded、multipart/form-data、text/plain)是简单请求,直接发;POST 带 application/json、PUT、DELETE、PATCH 都会触发 OPTIONS 预检。所以路由表里没显式写 OPTIONS,但 index.php 在最前面就拦截并返回 204,就是为了让预检请求迅速通过。
我见过不少跨域踩坑是因为 Access-Control-Allow-Headers 漏了 Authorization,导致前端带了 token 却被浏览器拦截。这里把 Content-Type, Authorization, X-Requested-With 都白名单了,一般业务场景够用。更精细的做法是把 Access-Control-Allow-Origin 配置成具体域名而不是 *,生产环境务必这样做,否则等于开放了所有网站的跨域访问权限。
还有 set_exception_handler 这个细节。它把所有的 Throwable 统一收口成一个 JSON 500 响应,避免 PHP 直接输出 HTML 错误页。但生产环境你不能这么裸奔,直接把 $e->getMessage() 回给客户端是危险的,它会泄露 SQL 语句和文件路径。实际项目里建议在这里记录错误日志,返回一个通用的 "Internal Server Error",把具体错误信息留在日志里。示例代码把 message 输出来是为了方便本地调试,你部署到公网前务必改掉。
3.6 接口测试:用 curl 把每个方法打一遍
接口写完不测等于没写。用 PHP 内置服务器跑一下:
bash复制cd restful-demo/public
php -S localhost:8000
然后 curl 一个接口创建图书:
bash复制curl -i -X POST http://localhost:8000/books \
-H "Content-Type: application/json" \
-d '{"title":"PHP 8 高手之路","author":"张三","price":59.9}'
期望返回:
http复制HTTP/1.1 201 Created
Location: /books/1
Content-Type: application/json; charset=utf-8
{"id":1,"title":"PHP 8 高手之路","author":"张三","price":59.9,"created_at":"2025-01-15 10:00:00"}
再试 GET 列表:
bash复制curl -i http://localhost:8000/books?page=1&per_page=10
curl -i http://localhost:8000/books/1
修改图书:
bash复制curl -i -X PUT http://localhost:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"title":"PHP 8 高手之路(第二版)","author":"张三","price":79.9}'
删除图书:
bash复制curl -i -X DELETE http://localhost:8000/books/1
删除成功后应该是 204 No Content,响应体为空。如果删不存在的书,返回 404。在校验上做点手脚,比如传 price: -1,返回 422 和 errors 详情。把这几个接口全部验证通过,你的第一个原生 PHP RESTful API 就算落地了。
4. 实战中的坑:状态码、错误处理、csrf、方案取舍
代码跑通只是第一步,真实生产环境里你会遇到各种"文档里没有"的问题。我把自己踩过和见过的坑整理出来,按常见程度排序,希望能帮你少走弯路。
4.1 HTTP 状态码用错是最大的"不 RESTful"
很多 API 无论成功失败都返回 200,然后靠响应体里的 code 字段区分:{"code": 0} 是成功,{"code": 20001} 是参数错误。这个做法不能说完全错,但它掩盖了一个严重问题——下游的 HTTP 层无法根据状态码做缓存、重试、告警。比如监控系统看到 500 就知道出故障了要报警,而你全返回 200,监控完全失效。再比如 Nginx 对上游返回 500 可以做故障剔除,对 200 就无从下手。
常见的状态码使用规则我整理成了一张表:
| 状态码 | 含义 | 使用场景 |
|---|---|---|
| 200 OK | 请求成功 | GET 获取资源、PUT 完全更新成功 |
| 201 Created | 资源创建成功 | POST 新增资源,应带 Location 头 |
| 204 No Content | 成功但无响应体 | DELETE 删除成功、PUT 整体更新成功(多数团队用 200) |
| 400 Bad Request | 请求格式错误 | JSON 解析失败、缺少必填字段 |
| 401 Unauthorized | 未认证 | 缺少 token、token 过期 |
| 403 Forbidden | 已认证但无权限 | 用户访问不属于自己的资源 |
| 404 Not Found | 资源不存在 | 请求了不存在的 ID、路由不存在 |
| 405 Method Not Allowed | 方法不允许 | GET /books/1 可以,但 DELETE /books/1 被禁用 |
| 409 Conflict | 资源冲突 | 创建重复数据、版本冲突 |
| 422 Unprocessable Entity | 业务校验失败 | 字段长度、格式、范围不合法 |
| 429 Too Many Requests | 频率超限 | 接口限流 |
| 500 Internal Server Error | 服务器内部错误 | 未捕获异常 |
| 503 Service Unavailable | 服务不可用 | 数据库连接不上、依赖服务挂了 |
记住一个原则:状态码是给机器看的,响应体是给人看的。机器先根据状态码决定走哪条分支,人再根据响应体里的 error 信息诊断具体问题。两者配合得当,接口才谈得上"自描述"。
4.2 参数校验是守门员,不是事后诸葛亮
我在 code review 时经常看到类似的代码:
php复制if ($_POST['email']) {
// 直接插入数据库
}
问题很明显:客户端没传 email 时,$_POST['email'] 是未定义索引,PHP 8 会抛出 Warning 并返回 null,如果表结构里 email 是非空字段,数据库层面就会抛异常,然后落入 500。你本来应该返回 422 的参数错误,结果变成了 500。
正确的姿势是像我在 BookController 里写的那样,先校验、收集所有错误、一次性返回。注意是"收集所有错误",不是"遇到第一个错误就 return"。原因很简单:前端表单校验需要一个字段一个字段地提示错误,如果你只返回第一个错误,用户改完再提交又看到第二个错误,体验非常差。
另外校验时要注意类型问题。$data['price'] 从 JSON 解析出来可能是 float、int、甚至字符串 "59.9",所以我在 validateBook 里用 is_numeric 判断,而不是 is_float。写 API 的人要习惯一件事:客户端传过来的永远是不可信的字符串或混合类型,你需要主动转换、主动约束。
4.3 CORS 的常见天坑:预检请求和自定义 Header
跨域的问题在前后端分离的项目里太常见了。最经典的一个坑是:前端用 fetch 发 Content-Type: application/json 的 POST 请求,浏览器先发一个 OPTIONS 预检,后端没有处理 OPTIONS,直接返回 404/405,前端报错"Access to fetch ... has been blocked by CORS policy"。原因很简单,预检请求被后端拒了,浏览器认为目标资源不支持跨域,就不发真正的 POST 了。
解决方案就是我前面 index.php 里写的:在最外层判断 REQUEST_METHOD === 'OPTIONS',直接返回 204,并带上允许的跨域响应头。注意这里必须在路由分发之前处理,因为预检请求根本不含业务路径信息。
还有一个坑是自定义请求头。如果前端在 headers 里加了 X-Requested-With: XMLHttpRequest 或 Authorization: Bearer ...,而后端的 Access-Control-Allow-Headers 里没包含这些,浏览器也会拦截响应。统一把常见的几个 header 加入白名单能解决大部分问题。
另外如果你要支持 Cookie 跨域,Access-Control-Allow-Origin 不能是 *,必须是具体域名,并且要加 Access-Control-Allow-Credentials: true。这是初学者最容易忽略的点,很多人在 localhost 上测试没问题,一上生产环境在跨域 Cookie 上卡半天。
4.4 CSRF 防护在 REST API 中还要不要做
传统表单提交时代,CSRF(跨站请求伪造)是大敌。攻击者诱导用户访问恶意页面,页面里的表单自动向你的网站 POST 数据,浏览器自动带上 Cookie,服务器认为是合法用户操作。所以传统 PHP 框架要求每个表单里放 csrf token。
但是做 RESTful API 时,如果你采用 token 认证(Authorization: Bearer token),而不依赖 Cookie 会话,CSRF 的风险就大大降低了。因为跨域请求带不上 Authorization 头——浏览器不会自动把自定义头加到跨域请求里,除非服务器明确允许。如果你为了让前端省事,把 token 存在 Cookie 里自动附加,那 CSRF 的风险就回来了,这时候倒真有必要做 CSRF Token 校验。
所以我的建议是:API 项目优先用 Authorization Header 传 token,CSRF 防护可以不做或看情况;如果因为某些原因必须用 Cookie 存凭证,那 CSRF 防护就一定要做。这个问题不算特别高大上,但我见过不少中级工程师在这上面混淆。
5. 常用工具和效率技巧
写 API 的过程里,有几个工具和技巧能显著提升开发效率。这里挑最实用的几个说说。
5.1 IDE 选型和调试工具配合
PHP 开发绕不开 IDE。JetBrains 的 PhpStorm 是 PHP 圈子里的标准工具,对 PHP 8 的语法支持最完整,包括 match 表达式、enum、readonly property 的自动补全和重构。如果你还没有 license,VS Code 配上 PHP Intelephense 插件也能获得不错的开发体验,尤其是 Intelephense 免费版就支持代码解析、跳转定义、引用查找这些核心功能,对一个 API 项目足够用了。
调试方面,最朴素也最有效的方式是看请求日志和响应日志。我在 Response 类里没有写日志逻辑,但实际项目中你肯定会需要。建议给 Request 和 Response 各加一个日志输出:记录请求方法、路径、耗时、状态码。从 PHP 8.2 开始,内置了 PHP_SAPI 相关的改进,在 CLI server 下输出日志非常灵活。还有一个实用技巧是开发阶段用 error_log() 配合 tail -f 实时查看 PHP 错误,这比断点调试在某些场景下更快速。
5.2 Postman 之外,curl 命令其实最高效
Postman 是很流行的 API 测试工具,图形化界面适合做完整测试用例。但我个人在快速调试时更依赖 curl 命令,因为它可以直接跑在终端里、复现方便、也没有图形界面切换带来的时间损耗。分享几个我常用的姿势。
用 curl 带 JSON 请求体:
bash复制curl -i -X POST http://localhost:8000/books \
-H "Content-Type: application/json" \
-H "Authorization: Bearer TOKEN" \
-d '{"title":"Test","author":"Tester","price":19.9}'
只看响应头:
bash复制curl -I http://localhost:8000/books/1
跟踪重定向和耗时:
bash复制curl -w "\nTime total: %{time_total}s\n" http://localhost:8000/books
上传文件(multipart):
bash复制curl -F "file=@/path/to/file.pdf" http://localhost:8000/upload
如果你把整套测试写好放在一个 .sh 脚本里,每次改动完跑一遍脚本,回归成本几乎为零。Postman 更强大,但 curl 更直接,两者不冲突,顺手就好。
5.3 PHP 8 新特性在 REST API 里的应用价值
PHP 8 到 8.3 是近几年 PHP 语言更新最密集的时期,其中不少特性和 API 开发关系很紧密。
match 表达式在路由分发里已经演示过,它能替代大量 if/elseif 分支,可读性和安全性都更好。str_contains()、str_starts_with()、str_ends_with() 这三个字符串函数也特别实用,在 CORS 检查、路由匹配、MIME 类型判断里经常用到。
readonly 属性(PHP 8.1+)特别适合定义 DTO(数据传输对象)。比如你要定义一个创建书籍的命令对象,可以直接写:
php复制class CreateBookCommand
{
public function __construct(
public readonly string $title,
public readonly string $author,
public readonly float $price,
) {}
}
这样这个对象的字段在初始化之后就不能再被修改,防止业务逻辑里不小心改掉已经校验过的数据。
枚举(enum)在 PHP 8.1 中引入,API 的状态、类型这类定值字段用 enum 表示非常舒服。比如图书状态:
php复制enum BookStatus: string
{
case Available = 'available';
case Borrowed = 'borrowed';
}
客户端传 available 或 borrowed,服务端塞进 enum 做类型约束,比裸字符串可靠得多。
命名参数(PHP 8.0+)配合默认参数值,让构造函数或方法调用更可读。比如 Response::json 可以设计成 json(data, status: 404, headers: ['X-Error' => 'true']),调用方不用记住参数的顺序。
用这么多年 PHP 我的体会是,语言本身在快速变现代。8.x 系列带来的类型系统增强让 PHP 写大型 API 项目时越来越有"安全感"。如果你还停留在 PHP 7 时代的写法,升级到 8.x 后你会发现代码量少 20% 不止,Bug 也会少很多。
6. API 安全加固:认证、限流、日志
往深了走一层,把 API 从"能跑"推向"能上生产",还有三件事绕不开:认证、限流、日志。
6.1 认证方案选择:从 Basic Auth 到 JWT
最简单的认证方案是 HTTP Basic Auth,每个请求带上 Authorization: Basic base64(user:password),服务器解码后比对。这种方案适合内部测试,因为密码等于明文传输(虽然 HTTP 层通常有 TLS 保护),不适合面向公网的接口。
目前 API 项目使用最广的是 JWT(JSON Web Token)。JWT 的思路是:用户在登录接口用用户名密码换签名后的 token,之后每次请求在 Authorization 头带上 Bearer token,服务器验签确认用户身份。JWT 的优势在于无状态——服务器不需要存储 session,验签过程是纯计算。这在分布式部署时就体现出好处:任意节点都能独立完成认证。
PHP 里实现 JWT 不需要自己写算法,Firebase 的 firebase/php-jwt 是社区广泛使用的库,用 Composer 装一下就能用。生产项目建议直接用库,不建议手写 HMAC-SHA256 签名,虽然能写,但边界情况太多,容易出安全漏洞。
认证的另一个细节是 token 过期策略。JWT 一旦签发,在过期时间之前都是有效的,服务端无法主动作废。如果用户注销或修改密码后旧 token 仍有效,就需要额外的黑名单机制。简单方案是把 JWT 的 jti(JWT ID)存到 Redis 里标记失效时间,稍微复杂但可控。
6.2 限流:防止接口被爬和被打
限流是 API 网关的常见功能,但小型项目直接用应用层代码也能做。思路很简单:以用户 IP 或 token 为维度,统计单位时间内的请求次数,超过阈值则返回 429 Too Many Requests。
用 Redis 做滑动窗口计数最方便:
php复制$key = 'rate_limit:' . $ip . ':' . time() / 60;
$count = $redis->incr($key);
if ($count === 1) {
$redis->expire($key, 60);
}
if ($count > 120) {
// 返回 429
}
这个方案的意思是每分钟每个 IP 最多 120 次请求。用 Redis INCR 的原子性确保并发下不会多放行请求。
如果团队没有 Redis,SQLite 或 MySQL 也能实现,只是吞吐量不如 Redis。不要把限流做成硬编码,限流阈值要可配置,否则不同业务方有不同的需求,你会频繁改代码。
6.3 日志体系:请求日志是排查问题的第一工具
日志在 REST API 运维中至关重要。你需要知道谁在什么时候调用了哪个接口、传了哪些参数、返回了什么状态码、耗时多久、有没有异常。这些信息在排查问题、优化性能、审计合规时都有用。
一个简单的日志格式建议:
code复制[2025-01-15 10:00:01] ips 192.168.1.10 method=POST path=/books status=201 time=12.5ms params={"title":"PHP 8 高手之路","author":"张三","price":59.9}
记录请求体和响应体会让日志变大,但排查问题时极其有用。你可以在开发环境记录完整 body,生产环境只记录关键字段或脱敏后的数据,尤其是涉及密码、token 的字段不能原样落盘。
PHP 里记录日志可以用 error_log(),也可以引入 Monolog 库。用 Monolog 的好处是可以分级、多通道输出——INFO 写一份、ERROR 单独发告警,用起来方便很多。
7. 从"能用"到"好用":RESTful API 的进阶拷问
接口写完、安全加固做完,服务能上线了。但 RESTful API 的讲究不止于此,还有几个会让你的接口从"能用"变"好用"的细节。
7.1 分页、过滤、排序、字段选择,一个都不能少
列表接口是最容易被忽视的"门面"。GET /books 如果直接返回全部图书,数据量一旦大了,响应体膨胀、网络传输变慢、前端渲染卡顿,这些都是问题。所以列表接口必须支持分页、过滤、排序和字段选择。
分页参数我已经实现了 page 和 per_page。过滤是 ?q 关键词。排序可以加 ?sort=price&order=desc。字段选择可以加 ?fields=id,title,price,服务端只返回这 3 个字段,减少流量。
有人可能觉得这些功能很麻烦,但一旦你的 API 被多个客户端(Web、App、数据报表)使用,你会发现统一的过滤和分页参数能省掉很多"专门给某个客户端写一个定制接口"的脏活。
7.2 用 HTTP 缓存还是应用层缓存,边界要分清
HTTP 缓存处理的是"客户端能不能复用昨天的响应"这个问题。比如图书列表页,如果你觉得 5 分钟内数据不会变,可以在响应头加 Cache-Control: public, max-age=300。浏览器和中间网关(CDN)会自动缓存,客户端 5 分钟内的重复请求直接命中缓存,根本不会到达你的 PHP 代码。
应用层缓存处理的是"服务器端能不能少查一次数据库"这个问题。比如热门图书详情,用 Redis 缓存 book:1 的 JSON 序列化结果,TTL 设置 30 分钟,数据库压力显著减少。
两者的边界在于:HTTP 缓存对客户端透明,还能减少网络传输;应用层缓存只减少服务器计算。推荐顺序是先用 HTTP 缓存,再用应用层缓存。不要一上来就 Redis 一把梭,HTTP 能解决的事没必要多引入一个中间件。
7.3 版本管理:URL 路径版本是内网 API 的首选,Header 版本更适合开放平台
API 上线后总有需要改接口逻辑的时候。如果改动不兼容旧客户端,直接原地修改会导致老客户端照旧调用却收到错误数据。所以大型 API 有版本管理的惯例。
常见的版本策略有两种。URL 路径版本:/v1/books、/v2/books,最直观、后兼容性最强; Header 版本:Accept: application/vnd.example.v2+json,不污染 URL,但是对客户端要求较高、调试不方便。
内网 API 我用 /v1、/v2 多一点,因为简单直白、容易在 Nginx 里做流量切分。面向第三方开放平台,一般用 Header 版本或兼容双版本。版本管理不是为了炫技,而是为了在接口演进时不让调用方"血流成河"。
8. 几种常见故障的排查实录
最后分享几个真实遇到过的故障案例,每个都带排查思路,比单纯列错误码实用得多。
8.1 问题一:GET 请求返回 404,但 POST 同样路径正常
现象是 GET /books/1 返回 404,POST /books 正常。我一开始以为是路由写错,检查了好几遍发现 GET 的路由注册明明在。后来用 curl 直接打发现请求 URL 被 Nginx 重写规则吞了。排查链路是:前端 404 -> curl 看响应体 -> Nginx access log 里看到请求其实没到 PHP。原因在于 Nginx 的 try_files 只对 GET 请求做伪静态重写,POST 请求自然被转发到 PHP-FPM,GET 某些路径没匹配到重写规则就被 Nginx 当成文件不存在返回 404。解决方法是统一 Nginx 的 location 配置:
nginx复制location / {
try_files $uri $uri/ /index.php?$query_string;
}
这个坑特别容易出现在用了 Vue Router history 模式或者 API 与前端共用一个 Nginx 的场景里。检查顺序是:先 curl 到本机看返回,再看 Nginx access log 确认请求是否到达后端,最后确认 rewrite 规则。
8.2 问题二:DELETE 接口返回 200,但第二次删除同样的 ID 返回 404
这是幂等性设计没做好。DELETE /books/42 第一次删掉了返回 204,第二次再删同样的 ID 时,资源已经不存在,返回 404 是对的。但有些团队为了"让客户端安心",不管存不存在都返回 200,结果前端根本分不清到底是删成功了还是压根没有这个资源。我的建议是接受 404 的合理存在——删除不存在的资源返回 404 是标准语义,只要客户端代码处理了 404 分支即可。
8.3 问题三:POST JSON 数据一直进不了数据库
前端用 fetch 发 Content-Type: application/json 的 POST,后端 $_POST 一直拿不到数据。这个坑很常见,原因在于 PHP 默认只解析 application/x-www-form-urlencoded 和 multipart/form-data 的请求体,JSON 请求体不会自动填充到 $_POST。解决的两种路径:一是前端把请求头改成 form-urlencoded(不推荐,它限制了嵌套结构);二是后端从 php://input 里读原始 JSON 再解码,这就是我在 Request 类里做的事。很多新手在这个问题上卡一两个小时,严格说这不是 bug,而是对 PHP 请求体解析机制不够熟悉。
8.4 问题四:数据库写入缓慢,接口超时
有一次生产环境的图书列表接口突然从 50ms 变 500ms,排查后发现是 SQLite 数据库文件太大,且没有建索引,SELECT * FROM books WHERE title LIKE '%xxx%' 全表扫描,数据到一定量级后越来越慢。后来把模糊搜索改成全文索引(FTS5)或用 MySQL 的 FULLTEXT 索引,问题解决。对于这类问题,最直接的排查方法是看 SQL 执行计划和慢查询日志,不要靠猜。
8.5 问题五:Authorization 头丢失
用 Nginx + PHP-FPM 时,$_SERVER['HTTP_AUTHORIZATION'] 可能为空。这是因为 PHP-FPM 默认不把 Authorization 头传给 fastcgi_params。解决方法是给 Nginx 加一行:
nginx复制fastcgi_param HTTP_AUTHORIZATION $http_authorization;
这个字段在 Nginx 的默认 fastcgi_params 文件里经常没有,需要手动补。还有一个备选方案是在 PHP 代码里从 getallheaders() 拿,但在 Nginx/FastCGI 下也未必靠谱。最好的做法是从 Nginx 层配置解决,一劳永逸。
9. 写在最后的体会
把这套原生 PHP 的 RESTful API 完整写了一遍后,说实话我对框架提供的"便利"有了更清楚的认识。很多人以为用 Laravel、Django 写 API 是理所当然的,框架帮你做好的路由、请求解析、响应标准化,其本质就是这一套代码的工程化封装。你理解了底层逻辑后,用框架时不会再"知其然不知其所以然"。
在实际项目里,我并不建议你用我手写的 Router 直接上生产——Lumen、Laravel、Slim 这些框架在安全性、性能、生态上都更成熟。但这篇代码的价值在于,它能帮你把"那一层封装"掀开,看到底下真正在发生什么。
如果你原本对 RESTful API 的理解停留在"URL 要好看、要返回 JSON"这个层面,希望这篇文章能让你看到 RESTful 背后更完整的图景:HTTP 方法是动词、URI 是资源名、状态码是结果、参数校验是纪律、CORS 是基础设施、日志和限流是运维的良心。把这几个维度打通之后,你写出来的接口质量和排查问题的能力都会有一个明显的跨越。
最后分享一个小习惯:每写完一个 API 端点,我都会用 curl 按"正常请求、参数错误、资源不存在、方法不允许、未认证"五个方向各打一遍,确认状态码符合预期再提交代码。这个习惯帮我挡掉了很多肉眼看不到的边界问题。你也不妨试试。
