综合练习
这一课你将能做到
- 从零搭出结构规范的迷你博客后端:PSR-4 自动加载 + 前端控制器 + 分层类
- 把全路线知识拼进一个项目:Composer、PDO 预处理、Session 登录、统一 JSON 响应
- 用 curl 完成 API 验收,明确下一步学框架(Laravel/Symfony)的衔接点
为什么要学
前 11 课的知识都是散装积木。这一课把它们拼成一个能跑的完整项目——项目结构是工程能力的照妖镜:入口是否唯一、SQL 是否安全、职责是否分层。用原生把框架要做的事手动做一遍,之后学 Laravel 就是「认旧朋友」。
先建立图像
mini-blog/
├── public/index.php # 唯一入口(前端控制器)
├── src/
│ ├── Database.php # PDO 连接(每请求单例)
│ ├── Auth.php # Session 登录态 + CSRF
│ ├── PostRepository.php # 数据访问层(只管 SQL)
│ └── PostController.php # 控制器(校验→调仓储→响应)
├── composer.json
└── posts.sql
public 是「门面」:Web 根只指向它,src 里的类文件永远不会被 URL 直接访问——这是安全底线。
跟着做
步骤 1:初始化工程
composer.json 只需两块:"require": {"php": ">=8.2"} 与 "autoload": {"psr-4": {"App\\": "src/"}},改完执行 composer dump-autoload。
表 posts 五列:id 自增主键、title VARCHAR(80)、content TEXT、author VARCHAR(40)、created_at DATETIME,InnoDB 引擎 + utf8mb4 字符集。
步骤 2:Database 与 Auth
src/Database.php:静态属性缓存连接的请求级单例,配置沿用 PDO 课三件套——ERRMODE_EXCEPTION、FETCH_ASSOC、EMULATE_PREPARES=false。
src/Auth.php——登录态与 CSRF:
<?php
declare(strict_types=1);
namespace App;
final class Auth
{
public static function boot(): void
{
session_set_cookie_params(['httponly' => true, 'samesite' => 'Lax']);
session_start();
}
public static function login(string $user): void
{
$_SESSION['user'] = $user;
$_SESSION['csrf'] = bin2hex(random_bytes(16));
}
public static function user(): ?string { return $_SESSION['user'] ?? null; }
public static function csrf(): string { return $_SESSION['csrf'] ?? ''; }
}
步骤 3:数据访问层与控制器
src/PostRepository.php——只写 SQL,不掺路由与响应:
<?php
declare(strict_types=1);
namespace App;
final class PostRepository
{
public function __construct(private \PDO $pdo) {}
public function all(): array
{
return $this->pdo->query(
'SELECT id, title, author, created_at FROM posts ORDER BY id DESC LIMIT 20'
)->fetchAll();
}
public function create(string $title, string $content, string $author): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO posts (title, content, author) VALUES (?, ?, ?)'
);
$stmt->execute([$title, $content, $author]);
return (int) $this->pdo->lastInsertId();
}
public function delete(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM posts WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}
src/PostController.php——校验、鉴权、响应:
<?php
declare(strict_types=1);
namespace App;
final class PostController
{
public function __construct(private PostRepository $repo) {}
private function json(int $code, array $data = []): never
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
if ($code !== 204) {
echo json_encode(['code' => $code] + $data, JSON_UNESCAPED_UNICODE);
}
exit;
}
public function index(): void
{
$this->json(200, ['data' => $this->repo->all()]);
}
public function store(): void
{
if (Auth::user() === null) {
$this->json(401, ['message' => '请先登录']);
}
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$title = trim((string) ($body['title'] ?? ''));
$content = trim((string) ($body['content'] ?? ''));
if ($title === '' || mb_strlen($title) > 80 || $content === '') {
$this->json(422, ['message' => 'title 必填且 ≤80 字,content 必填']);
}
$this->json(201, ['data' => ['id' => $this->repo->create($title, $content, Auth::user())]]);
}
public function destroy(int $id): void
{
if (Auth::user() === null) {
$this->json(401, ['message' => '请先登录']);
}
$this->repo->delete($id)
? $this->json(204)
: $this->json(404, ['message' => '文章不存在']);
}
}
步骤 4:前端控制器组装一切
public/index.php——路由 + 装配 + 兜底:
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Auth;
use App\Database;
use App\PostController;
use App\PostRepository;
Auth::boot();
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$ctrl = new PostController(new PostRepository(Database::pdo()));
try {
if ($uri === '/api/login' && $method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true) ?? [];
if (($body['user'] ?? '') === 'leebf') {
Auth::login('leebf');
echo json_encode(['code' => 200, 'csrf' => Auth::csrf()]);
} else {
http_response_code(401);
echo '{"code":401,"message":"用户名错误"}';
}
exit;
}
if (preg_match('#^/api/posts/?$#', $uri)) {
$method === 'GET' && $ctrl->index();
$method === 'POST' && $ctrl->store();
}
if (preg_match('#^/api/posts/(\d+)$#', $uri, $m) && $method === 'DELETE') {
$ctrl->destroy((int) $m[1]);
}
http_response_code(405);
header('Content-Type: application/json; charset=utf-8');
echo '{"code":405,"message":"方法不支持"}';
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['code' => 500, 'message' => '服务器内部错误'], JSON_UNESCAPED_UNICODE);
error_log($e->getMessage());
}
步骤 5:curl 验收
php -S localhost:8000 -t public public/index.php
curl -c c.txt -X POST localhost:8000/api/login -d '{"user":"leebf"}'
curl -b c.txt -X POST localhost:8000/api/posts -H "Content-Type: application/json" \
-d '{"title":"毕业作品","content":"全路线知识点串烧"}' # 201
curl localhost:8000/api/posts # 200
curl -b c.txt -X DELETE localhost:8000/api/posts/1
讲清楚要点
- 前端控制器是框架的雏形:所有请求进 public/index.php,统一加载、路由、异常兜底,横切逻辑只写一次。Laravel 的
public/index.php → Kernel → 路由就是同一思想的完整实现。 - 分层依据是「变更频率」:Repository 只因 SQL 变而变,Controller 只因接口契约变而变。判断类放哪层,就问「它将来会因什么被修改」。
- 安全三道闸在此落地:SQL 注入(预处理全覆盖)、XSS(API 只出 JSON,渲染点转义)、越权(写操作查 Session + CSRF token)。
- 错误两级分权:业务错误(404/422)走正常分支;系统异常统一 catch、记日志、对外只给 500。
你可能会懵的地方
- 为什么 Database 用静态单例而 Repository 要构造器注入? 连接请求内全局唯一是合理约束,静态属性省资源;Repository 显式声明依赖,测试时可换内存库替身。别学 Java 把单例跨请求复用——PHP 做不到也不该做。
- 纯 API 场景还需要 CSRF token 吗?
用 JWT/Authorization 头时可省(浏览器不自动携带自定义头);本课是 Cookie + Session,浏览器同域自动带 Cookie,写操作必须校验 token,且用
hash_equals防时序攻击。 - 路由为什么要收口到唯一入口?
分散入口意味着鉴权、日志要在每个文件重复,且 src 类文件可能被直接访问。
-t public把流量收敛到 index.php,安全与维护同时受益。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 把 src/ 放进 Web 根目录 | 类文件/SQL 可被 URL 直接下载 | Web 根只指 public/,源码在外 |
| 每个文件自己 new PDO | 连接爆炸、配置散落 | Database 请求级单例统一发连接 |
| 500 响应里 echo 异常堆栈 | 泄露路径、SQL、配置 | 对外固定文案,详情进 error_log |
| 路由无兜底分支 | 未知路径裸奔白屏 | 兜底 405 + try/catch 500 |
卡点急救
Class "App\xxx" not found:先composer dump-autoload;核对命名空间与 src 目录、文件名大小写是否与类名一致。- curl 登录后仍 401:
-c c.txt存 Cookie 与-b c.txt带 Cookie 缺一不可;确认响应头有Set-Cookie: PHPSESSID。 php -S访问 /api/posts 找不到:-t public public/index.php两个参数都要给——前者定根目录,后者把未命中路径转给入口。
面试常问
- 什么是前端控制器模式?解决了什么问题? 所有请求汇聚到唯一入口脚本,由统一路由与内核分发,横切逻辑(鉴权、日志、异常)只实现一次;Laravel/Symfony/Spring MVC 的 DispatcherServlet 均是同构实现。
- 这个项目里你如何防 SQL 注入与 XSS? SQL 注入:全部输入经 PDO 预处理绑定,绝不拼接;XSS:API 只输出 JSON,HTML 渲染点统一 htmlspecialchars(ENT_QUOTES),配合 httponly + SameSite Cookie。
- 为什么 Repository 走构造器注入而不是全局 PDO? 构造器签名让依赖显式化、可测试;全局变量把依赖藏进实现,无法隔离测试,也与框架 DI 习惯脱节。
自测三问
- 画出请求流:curl 命中
POST /api/posts到返回 201 经过哪些类? - 写操作为什么同时校验 Session 与 CSRF token?
hash_equals换成===有什么风险? - 本项目迁到 Laravel 时,路由、容器、仓储、鉴权分别对应什么官方组件?
小结一句话
public/ 唯一入口 + 分层 src/ + Composer 自动加载 + 预处理 PDO + Session 鉴权,这个迷你项目是框架世界的完整预演——毕业即入学 Laravel。
延伸
- PHP 官方手册:语言参考索引
- 下一课:无——本路线完结,建议衔接 Laravel 框架路线或回到 Spring Boot 深化。