第7章 7.5 综合实战:REST API 服务
🎯 开场:上一章我们学了 Composer 打好了包管理的基础,这一章我们要用这些工具来解决一个更真实的问题——
你有没有遇到过这种情况:手机 App 和网页看到的数据不一样?朋友圈刷出来的内容每个人都不一样?这些背后,都是 REST API 在默默工作。
痛点来了:
- 你想做个微信小程序,但不知道怎么让小程序读取你服务器上的数据
- 你想做个天气 Bot,但不知道怎样获取天气数据
- 你听说过 "API" 这个词,看文档却一脸懵
这一章,学完你就能:用 PHP 原生代码,手写一个完整的 REST API 服务,支持增删改查、能加身份验证、还有自动文档。
🧱 基础 25 分钟:REST API 核心概念
什么是 REST API?先把它想象成「餐厅外卖系统」
你去一家餐厅点餐:
1. 你告诉服务员要什么(请求)
2. 厨房做好了(处理)
3. 服务员端菜上来(响应)
REST API 就是这样的「点餐系统」\n\n
\n\n
\n\n,只不过:
- 你用代码「点餐」(发送 HTTP 请求)
- 服务器当「厨房」(处理逻辑)
- 返回的是 JSON 数据(而不是红烧肉)
为什么要用 REST API?
解决什么问题:
- 前端(网页/App)需要从后端拿数据
- 不同的前端(iOS、Android、网页)可以共用同一套后端
- 把「显示数据」和「存储数据」分开
一句话:REST API 就是前后端之间的「翻译官」。
核心概念拆解
1. HTTP 方法——你要「点」什么菜
| 方法 | 相当于 | 做什么 |
|---|---|---|
| GET | 看菜单 | 获取数据 |
| POST | 点新菜 | 创建数据 |
| PUT | 换菜 | 完整更新 |
| PATCH | 改菜的配料 | 部分更新 |
| DELETE | 退菜 | 删除数据 |
2. 状态码——服务员告诉你「厨房说啥」
| 状态码 | 意思是 |
|---|---|
| 200 | 成功 OK |
| 201 | 创建成功 |
| 400 | 你的点单有问题 |
| 401 | 你没付钱(没登录) |
| 404 | 菜单上没有这道菜 |
| 500 | 厨房着火了(服务器错误) |
3. JSON 格式——厨房给你的「电子小票」
{
"菜名": "番茄炒蛋",
"价格": 18,
"库存": 100
}
用 PHP 原生代码写一个最简单的 API
先别管复杂的路由框架,我们从最原始的方式开始理解原理:
<?php
// api.php - 最简单的 API 入口
// 允许所有来源访问(开发用)
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/json; charset=utf-8');
// 读取请求方法(GET/POST/PUT/DELETE)
$method = $_SERVER['REQUEST_METHOD'];
// 读取 URL 参数中的菜品ID
$id = $_GET['id'] ?? null;
// 模拟数据库中的菜单数据
$menu = [
1 => ['菜名' => '番茄炒蛋', '价格' => 18, '库存' => 100],
2 => ['菜名' => '红烧肉', '价格' => 38, '库存' => 50],
3 => ['菜名' => '麻婆豆腐', '价格' => 28, '库存' => 80],
];
// 根据不同请求方法,返回不同结果
if ($method === 'GET') {
if ($id) {
// 获取指定菜品
if (isset($menu[$id])) {
echo json_encode(['状态' => '成功', '数据' => $menu[$id]], JSON_UNESCAPED_UNICODE);
} else {
http_response_code(404);
echo json_encode(['状态' => '错误', '消息' => '菜品不存在'], JSON_UNESCAPED_UNICODE);
}
} else {
// 获取全部菜品
echo json_encode(['状态' => '成功', '数据' => $menu], JSON_UNESCAPED_UNICODE);
}
} else {
http_response_code(405);
echo json_encode(['状态' => '错误', '消息' => '不支持该请求方法'], JSON_UNESCAPED_UNICODE);
}
?>
这代码干了啥:
- $_SERVER['REQUEST_METHOD'] 获取请求类型
- $_GET['id'] 获取 URL 参数
- json_encode() 把 PHP 数组转成 JSON 字符串
- http_response_code() 设置返回状态码
用浏览器访问 http://localhost/api.php?id=1,你就能看到 JSON 数据了!
URL 路由的原理——让 API 更好看
上面的 API 长这样:api.php?id=1
但真实的 API 一般长这样:/api/dish/1
这就需要我们自己解析 URL,实现简单的路由:
<?php
// router.php - 简单的路由系统
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/json; charset=utf-8');
// 解析 URL
$request_uri = $_SERVER['REQUEST_URI'];
$path = parse_url($request_uri, PHP_URL_PATH);
// 把 /api/dish/1 拆成 ['dish', '1']
$segments = array_filter(explode('/', trim($path, '/')));
$segments = array_values($segments);
// 如果路径不是 /api 开头,返回 404
if (reset($segments) !== 'api') {
http_response_code(404);
echo json_encode(['错误' => '接口不存在']);
exit;
}
// 去掉 'api' 前缀,剩下的就是路由
$route = array_slice($segments, 1);
// 获取请求方法
$method = $_SERVER['REQUEST_METHOD'];
// 路由分发
if (count($route) >= 2 && $route[0] === 'dish') {
$id = $route[1] ?? null;
if ($method === 'GET') {
// 获取指定菜品
echo json_encode(['操作' => '获取菜品', 'id' => $id]);
} elseif ($method === 'DELETE') {
// 删除菜品
echo json_encode(['操作' => '删除菜品', 'id' => $id]);
} else {
http_response_code(405);
echo json_encode(['错误' => '不支持该方法']);
}
} elseif (count($route) >= 1 && $route[0] === 'dish') {
if ($method === 'GET') {
// 获取全部菜品
echo json_encode(['操作' => '获取全部菜品']);
} elseif ($method === 'POST') {
// 创建新菜品
$body = json_decode(file_get_contents('php://input'), true);
echo json_encode(['操作' => '创建菜品', '数据' => $body]);
} else {
http_response_code(405);
echo json_encode(['错误' => '不支持该方法']);
}
} else {
http_response_code(404);
echo json_encode(['错误' => '路由不存在']);
}
?>
这代码干了啥:
- parse_url() 把 URL 拆开,取出路径部分
- explode('/', $path) 按斜杠切成数组
- 根据路由数组的长度判断是「列表」还是「详情」
现在访问 /api/dish 就是列表,访问 /api/dish/1 就是详情。
🔥 实战 35 分钟:3 个递进项目
项目 1(5 分钟):搭建 API 骨架
目标: 搭建一个完整的、可以「跑起来」的 REST API 项目结构
先建文件夹:
/my-rest-api
├── index.php # 入口文件
├── Router.php # 路由类
├── DishController.php # 菜品控制器
└── data/ # 模拟数据库文件夹
└── dishes.json # 菜品数据文件
完整可运行代码:
<?php
// data/dishes.json - 初始数据
[
{"id": 1, "菜名": "番茄炒蛋", "价格": 18, "库存": 100},
{"id": 2, "菜名": "红烧肉", "价格": 38, "库存": 50},
{"id": 3, "菜名": "麻婆豆腐", "价格": 28, "库存": 80}
]
<?php
// DishController.php
class DishController {
private $data_file = 'data/dishes.json';
// 读取所有菜品
public function index() {
$dishes = json_decode(file_get_contents($this->data_file), true);
return [
'状态码' => 200,
'数据' => $dishes
];
}
// 读取单个菜品
public function show($id) {
$dishes = json_decode(file_get_contents($this->data_file), true);
foreach ($dishes as $dish) {
if ($dish['id'] == $id) {
return [
'状态码' => 200,
'数据' => $dish
];
}
}
return [
'状态码' => 404,
'数据' => ['错误' => '菜品不存在']
];
}
// 创建新菜品
public function store($data) {
$dishes = json_decode(file_get_contents($this->data_file), true);
// 生成新 ID
$max_id = 0;
foreach ($dishes as $dish) {
if ($dish['id'] > $max_id) $max_id = $dish['id'];
}
$data['id'] = $max_id + 1;
$dishes[] = $data;
file_put_contents($this->data_file, json_encode($dishes, JSON_UNESCAPED_UNICODE));
return [
'状态码' => 201,
'数据' => ['消息' => '创建成功', '菜品' => $data]
];
}
// 删除菜品
public function destroy($id) {
$dishes = json_decode(file_get_contents($this->data_file), true);
$found = false;
$new_dishes = [];
foreach ($dishes as $dish) {
if ($dish['id'] != $id) {
$new_dishes[] = $dish;
} else {
$found = true;
}
}
if (!$found) {
return [
'状态码' => 404,
'数据' => ['错误' => '菜品不存在']
];
}
file_put_contents($this->data_file, json_encode($new_dishes, JSON_UNESCAPED_UNICODE));
return [
'状态码' => 200,
'数据' => ['消息' => '删除成功']
];
}
}
预期输出: 项目结构就绪,下一步接入路由就能用
项目 2(15 分钟):加入完整 CRUD + 响应格式统一
目标: 让 API 返回格式统一,加上错误处理
在 index.php 里加入统一响应格式:
<?php
// index.php - 统一入口文件
require_once 'DishController.php';
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
// 统一响应格式函数
function response($status_code, $data) {
http_response_code($status_code);
echo json_encode([
'状态' => $status_code,
'时间' => date('Y-m-d H:i:s'),
'数据' => $data
], JSON_UNESCAPED_UNICODE);
}
// 解析请求
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = array_values(array_filter(explode('/', trim($path, '/')), fn($s) => $s !== ''));
// 获取 ID(如果有的话)
$id = null;
$route = [];
foreach ($segments as $i => $seg) {
if ($seg === 'api' && $i === 0) continue;
if (is_numeric($seg)) {
$id = (int)$seg;
} else {
$route[] = $seg;
}
}
// 路由分发
$controller = new DishController();
if (count($route) === 1 && $route[0] === 'dishes') {
switch ($method) {
case 'GET':
if ($id) {
$result = $controller->show($id);
response($result['状态码'], $result['数据']);
} else {
$result = $controller->index();
response($result['状态码'], $result['数据']);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
response(400, ['错误' => '请求体必须是 JSON']);
break;
}
$result = $controller->store($input);
response($result['状态码'], $result['数据']);
break;
case 'DELETE':
if (!$id) {
response(400, ['错误' => '删除需要提供菜品 ID']);
break;
}
$result = $controller->destroy($id);
response($result['状态码'], $result['数据']);
break;
default:
response(405, ['错误' => '不支持的请求方法']);
}
} else {
response(404, ['错误' => '接口不存在']);
}
?>
测试方法:
- 浏览器访问 http://localhost/api/dishes → GET 列表
- 用 Postman 或 curl 发送 POST 到 /api/dishes → 创建新菜品
项目 3(15 分钟):加入 Token 鉴权 + API 文档生成
目标: 加上简单的 Token 验证,让 API 更安全
完整可运行代码:
<?php
// Auth.php - 简单的 Token 验证
class Auth {
private $valid_token = 'abc123xyz'; // 实际项目中这个要存数据库
private $users = [
'admin' => 'admin123',
'chef' => 'chef456'
];
// 验证 Token
public function verifyToken($token) {
if ($token !== $this->valid_token) {
return false;
}
return true;
}
// 登录,颁发 Token
public function login($username, $password) {
if (!isset($this->users[$username]) || $this->users[$username] !== $password) {
return false;
}
return [
'token' => $this->valid_token,
'用户' => $username,
'过期时间' => time() + 3600 // 1 小时后过期
];
}
}
修改 index.php,在需要鉴权的操作前加上检查:
<?php
// index.php - 加入鉴权的完整版
require_once 'DishController.php';
require_once 'Auth.php';
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
// 如果是 OPTIONS 请求(预检),直接返回
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
function response($status_code, $data) {
http_response_code($status_code);
echo json_encode([
'状态' => $status_code,
'时间' => date('Y-m-d H:i:s'),
'数据' => $data
], JSON_UNESCAPED_UNICODE);
}
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = array_values(array_filter(explode('/', trim($path, '/')), fn($s) => $s !== ''));
$id = null;
$route = [];
foreach ($segments as $i => $seg) {
if ($seg === 'api' && $i === 0) continue;
if (is_numeric($seg)) {
$id = (int)$seg;
} else {
$route[] = $seg;
}
}
// 解析 Authorization Header
$auth = new Auth();
$token = null;
$auth_header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (preg_match('/Bearer\s+(.*)/i', $auth_header, $matches)) {
$token = $matches[1];
}
$controller = new DishController();
// 路由分发
if (count($route) === 1 && $route[0] === 'dishes') {
// POST、PUT、DELETE 需要登录
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
if (!$token || !$auth->verifyToken($token)) {
response(401, ['错误' => '未授权,请先登录获取 Token']);
exit;
}
}
switch ($method) {
case 'GET':
$result = $id ? $controller->show($id) : $controller->index();
response($result['状态码'], $result['数据']);
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
response(400, ['错误' => '请求体必须是 JSON']);
break;
}
$result = $controller->store($input);
response($result['状态码'], $result['数据']);
break;
case 'DELETE':
if (!$id) {
response(400, ['错误' => '删除需要提供菜品 ID']);
break;
}
$result = $controller->destroy($id);
response($result['状态码'], $result['数据']);
break;
default:
response(405, ['错误' => '不支持的请求方法']);
}
} elseif (count($route) === 1 && $route[0] === 'login') {
// 登录接口
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$username = $input['用户名'] ?? '';
$password = $input['密码'] ?? '';
$result = $auth->login($username, $password);
if ($result) {
response(200, $result);
} else {
response(401, ['错误' => '用户名或密码错误']);
}
} else {
response(405, ['错误' => '只支持 POST 请求']);
}
} else {
response(404, ['错误' => '接口不存在']);
}
?>
预期输出示例:
未授权访问 POST:
{"状态":401,"时间":"2024-01-15 10:30:00","数据":{"错误":"未授权,请先登录获取 Token"}}
登录成功:
{"状态":200,"时间":"2024-01-15 10:30:05","数据":{"token":"abc123xyz","用户":"admin","过期时间":1705307405}}
💪 进阶 20 分钟:常见坑 + 调试技巧
坑 1:JSON 中文乱码
❌ 错误:
echo json_encode(['菜名' => '番茄炒蛋']);
// 输出: {"\u83d9\u5c4f\u7092\u86cb"}
✅ 正确:
echo json_encode(['菜名' => '番茄炒蛋'], JSON_UNESCAPED_UNICODE);
// 输出: {"菜名":"番茄炒蛋"}
坑 2:POST 请求 body 读取姿势错误
❌ 错误:
$name = $_POST['name']; // 无法获取 JSON body
✅ 正确:
$input = json_decode(file_get_contents('php://input'), true);
$name = $input['name'] ?? null;
坑 3:没处理 PUT/PATCH 请求
PHP 的 $_PUT 和 $_PATCH 超全局变量是不存在的!
✅ 正确做法:
$input = json_decode(file_get_contents('php://input'), true);
if ($method === 'PUT') {
// 处理完整更新
}
if ($method === 'PATCH') {
// 处理部分更新
}
坑 4:CORS 跨域问题
❌ 错误:前端报 No 'Access-Control-Allow-Origin' header
✅ 正确:在所有输出之前设置 header
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
坑 5:没判断请求体是否是有效 JSON
❌ 错误:
$input = json_decode(file_get_contents('php://input'));
// 如果 body 是空的或无效,会返回 null,但代码没检查
✅ 正确:
$input = json_decode(file_get_contents('php://input'), true);
if (json_last_error() !== JSON_ERROR_NONE) {
response(400, ['错误' => '无效的 JSON 格式']);
exit;
}
调试技巧:用日志记录请求
<?php
// debug.php - 添加日志的中间件
function logRequest($method, $path, $body, $token) {
$log = sprintf(
"[%s] %s %s | Body: %s | Token: %s\n",
date('Y-m-d H:i:s'),
$method,
$path,
$body ?: 'null',
$token ?: 'none'
);
file_put_contents('debug.log', $log, FILE_APPEND);
}
// 在 index.php 开头调用
logRequest(
$_SERVER['REQUEST_METHOD'],
$_SERVER['REQUEST_URI'],
file_get_contents('php://input'),
$token ?? null
);
?>
✏️ 练习题 + 作业题
练习题(5 道,10 分钟)
练习 1(2 分钟):查看菜品列表
- 输入:用 GET 访问 /api/dishes
- 预期输出:返回所有菜品数组
- 提示:直接在浏览器地址栏输入就行
练习 2(2 分钟):添加一个新菜品
- 输入:用 POST 发送 {"菜名":"青椒肉丝","价格":32,"库存":60}
- 预期输出:返回创建成功的菜品信息(含新 ID)
- 提示:用 Postman 或 curl,设置 Header Content-Type: application/json
练习 3(2 分钟):删除指定菜品
- 输入:用 DELETE 访问 /api/dishes/2
- 预期输出:返回删除成功消息
- 提示:记得带上 Authorization Header
练习 4(2 分钟):未授权访问
- 输入:不带 Token,用 POST 创建菜品
- 预期输出:返回 401 未授权错误
- 提示:故意不带 Authorization Header,看看会发生什么
练习 5(2 分钟):分析这个报错
- 输入:代码返回了 {"状态":400,"数据":{"错误":"无效的 JSON 格式"}}
- 问题:哪里出了问题?应该怎么修复?
- 提示:检查你发送的请求体格式
作业题:做一个「图书管理 API」
需求描述:
做一个图书馆的 REST API,可以管理图书的借阅。
功能点:
1. 图书管理:查看所有图书、查看单本图书、添加图书、删除图书
2. 借阅管理:借书(标记某本书为"已借出")、还书(标记为"可借")
3. 搜索功能:按书名搜索图书
加分项:
1. 用 JSON 文件持久化存储数据
2. 给 API 加个简单的访问统计(记录每个接口被访问了多少次)
验收标准:
- 能跑起来
- 用浏览器或 curl 能测试所有接口
- 代码有适当注释
API 设计参考:
GET /api/books # 获取所有图书
GET /api/books/1 # 获取 ID 为 1 的图书
POST /api/books # 添加新图书
DELETE /api/books/1 # 删除图书
POST /api/books/1/borrow # 借出图书
POST /api/books/1/return # 归还图书
GET /api/books/search?q=红楼梦 # 搜索图书
POST /api/login # 登录获取 Token
📚 总结 + 资源
本文学到的 3 个核心点:
1. REST API 本质是「请求-处理-响应」的模式,用 HTTP 方法表达操作意图
2. 用原生 PHP 也可以写 API,关键是解析 URL + 路由分发 + 统一响应格式
3. Token 鉴权是保护 API 的基础,把不该公开的操作拦在门外
延伸学习资源:
- PHP 官方文档:REST API - PHP 处理 JSON 的完整参考
- 《RESTful API 设计最佳实践》- 了解业界标准
- Postman - API 测试神器,必备工具
互动钩子:
你在工作中需要对接第三方 API 吗?遇到过什么坑?评论区聊聊,老粉优先回复!
下章预告:
API 写好了,但如果访问一多,每次都去读文件查数据库,速度会变慢。下一章我们要给 API 加一层「加速器」—— 缓存,让同样的请求跑得飞快!

评论(0)