第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\nSimple tech illustration expla\n\nAI comic creation scene, creat\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 加一层「加速器」—— 缓存,让同样的请求跑得飞快!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。