1. 内容整体设计与思路拆解1.1 为什么第37天讲 MVC先把话说在前面MVC 不是什么高深莫测的架构它就是 Web 开发里最老牌、最经典的一套代码组织思路。到今天为止各种框架换了多少代Spring MVC、Ruby on Rails、Django、Laravel、Express 里都能看到它的影子。你哪怕不用框架、原生写 PHP 或者 Node.js只要页面和数据逻辑分开写其实已经在往 MVC 的路子上走了。标题里这个“Day37”更像是一个学习路线上的节点。前面如果已经写过接口、写过页面、操作过数据库那这个节点就特别合适把过去散落的代码重新归类搞清楚哪块归哪块管。如果你刚入门还没写过完整项目我建议先拿本教程里的一个小 Demo 跑通再回来看这篇感受会完全不同。这一篇我们要做的事很明确先吃透 MVC 三个角色的职责边界然后不依赖任何框架手写一个能跑起来的简易 MVC 框架。为什么强调手写因为你只有亲手拆过一遍轮子才知道框架替你省了多少事也才能够在框架出问题时不至于一脸懵地瞎猜。1.2 从“一堆文件”到“三层结构”很多刚开始写 Web 项目的同学代码组织往往是这样的一个 index.php 或者 app.js 里数据库查询写在前面HTML 拼接在中间乱七八糟的工具函数丢在后面一个文件几百行甚至上千行。最痛苦的不是写的时候而是三个月之后你自己回来看都不记得某个变量是干嘛的。MVC 要解决的就是这种混乱。它把应用程序拆成三层角色全称核心职责MModel模型负责数据。操作数据库、处理业务规则、校验数据都算 Model 的事VView视图负责展示。把数据渲染成 HTML、JSON、XML只做输出不做逻辑CController控制器负责调度。接收请求调用 Model 拿数据再决定让哪个 View 去渲染一句话总结Controller 是大脑Model 是仓库View 是门面。三者各司其职代码才能越写越清晰。当然纯理论说一万遍不如上手跑一遍。接下来我们就用原生 PHP 从零开始搭一个微型 MVC 框架全程不引入任何依赖你只要本地有 PHP 环境和一个小型数据库哪怕是 SQLite 都行就能完整跟着做下来。2. 环境准备与前置知识2.1 需要准备的工具手写 MVC 框架最怕的就是环境折腾半天还没开始写正题。我的建议是先把这些备齐PHP 8.1 以上版本7.4 也行但强类型特性用起来没那么爽Composer其实不用也行但建议装上后面扩展方便一个 Web 服务器内置的php -S开发服务器就完全够用没必要上 Nginx / Apache数据库建议先用 SQLite零配置、单文件跑通逻辑之后再换 MySQL我实测下来PHP 自带的开发服务器是最省心的php -S localhost:8080 -t public用这个命令启动之后所有请求都会先进public/index.php正好适合做前端控制器。2.2 路由的基本思想在动手写框架之前得先把“路由”这个概念讲透。你访问一个 URL比如http://localhost:8080/index.php?controlleruseractionshowid1这种写法虽然能用但丑而且不好扩展。RESTful 一点的现代框架路由长这样http://localhost:8080/user/show/1我们要做的就是把后面这段user/show/1解析出来拆成三部分控制器名、方法名、参数。这就叫路由解析。在框架内部我们会维护一张路由表把 URL 里面的路径映射到具体的ControllerMethod上。这一步做到了后面所有请求都会以统一的入口进来框架才算真正立起来。2.3 目录结构设计动手写代码之前先把项目结构定下来。我的设计是这样my-mvc/ ├── app/ │ ├── controllers/ │ ├── models/ │ ├── views/ │ └── core/ ├── config/ ├── public/ │ └── index.php └── .htaccessApache 环境下用app/controllers/放控制器统一继承BaseControllerapp/models/放数据模型统一继承BaseModelapp/views/放视图模板推荐用原生 PHP 做模板引擎app/core/放框架核心类Router、Controller、Model、View、Databasepublic/是唯一对外可见的目录其他目录都封闭起来安全性好很多这样设计的好处是开发的时候你闭着眼都知道某段代码该放哪上线之后也只用把public目录设置为站点根目录其他文件外人根本访问不到。3. 框架核心代码实现3.1 前端控制器入口文件先写入口文件它是所有请求的必经之路?php // public/index.php declare(strict_types1); const ROOT_PATH dirname(__DIR__); spl_autoload_register(function (string $class): void { $prefix App\\; $baseDir ROOT_PATH . /app/; if (strpos($class, $prefix) 0) { $relativeClass substr($class, strlen($prefix)); $file $baseDir . str_replace(\\, /, $relativeClass) . .php; if (file_exists($file)) { require $file; } } }); require ROOT_PATH . /app/core/Router.php; require ROOT_PATH . /app/core/Request.php; require ROOT_PATH . /app/core/Response.php; use App\Core\Router; use App\Core\Request; $request new Request(); $router new Router(); // 这里加载路由配置后续会把具体规则抽到 routes.php 里 $router-add(GET, /user/show/{id}, UserControllershow); try { $route $router-dispatch($request-method(), $request-path()); $controllerName App\\Controllers\\ . $route[controller]; $actionName $route[action]; $controller new $controllerName(); if (!method_exists($controller, $actionName)) { throw new RuntimeException(Action not found: {$actionName}); } $response call_user_func_array([$controller, $actionName], $route[params]); if ($response instanceof Response) { $response-send(); } else { echo $response; } } catch (Throwable $e) { http_response_code(500); echo 500 Internal Server Error: . htmlspecialchars($e-getMessage()); }这里用了自动加载省去每个文件都手动 require 的麻烦。入口文件只干一件事解析请求分发给控制器拿到结果直接输出。这就是 MVC 里的“前端控制器”模式。注意生产环境不建议把异常详情直接打到页面上这样会泄露框架内部结构。演示阶段为了排查方便先这样处理上线前要换成日志记录加友好报错页面。3.2 路由系统实现路由是框架的“翻译官”我们要把/user/show/1这种路径转成一个数组[controller UserController, action show, params [1]]。?php // app/core/Router.php namespace App\Core; class Router { private array $routes []; public function add(string $method, string $path, string $handler): void { $this-routes[] [ method $method, path $path, handler $handler, ]; } public function dispatch(string $method, string $uri): array { foreach ($this-routes as $route) { if ($route[method] ! $method) { continue; } $pattern preg_replace(/\{(\w)\}/, (?P$1[^/]), $route[path]); $pattern #^ . $pattern . $#; if (preg_match($pattern, $uri, $matches)) { $params array_filter($matches, is_string, ARRAY_FILTER_USE_KEY); [$controller, $action] explode(, $route[handler]); return [ controller $controller, action $action, params $params, ]; } } throw new RuntimeException(No route found for . $method . . $uri); } }核心技巧是这行正则替换$pattern preg_replace(/\{(\w)\}/, (?P$1[^/]), $route[path]);它会把{id}自动变成(?Pid[^/])也就是说你的路由规则里想定义几个参数都行代码闭着眼睛都能适配。这就是“约定优于配置”的雏形你只需要像写自然语言一样声明路径规则自动生成。3.3 请求与响应封装有了路由还得让控制器用起来顺手。Request 类就是干这个的?php // app/core/Request.php namespace App\Core; class Request { public function method(): string { return $_SERVER[REQUEST_METHOD] ?? GET; } public function path(): string { $uri $_SERVER[REQUEST_URI] ?? /; $path parse_url($uri, PHP_URL_PATH); $path / . trim($path, /); return $path / ? / : rtrim($path, /); } public function query(string $key, $default null) { return $_GET[$key] ?? $default; } public function input(string $key, $default null) { return $_POST[$key] ?? $default; } }Response 类也不复杂关键在于统一出口以后想加缓存头、跳转、JSON 序列化都方便?php // app/core/Response.php namespace App\Core; class Response { private int $statusCode 200; private array $headers []; private string $content ; public function setStatus(int $code): self { $this-statusCode $code; return $this; } public function setHeader(string $name, string $value): self { $this-headers[$name] $value; return $this; } public function setContent(string $content): self { $this-content $content; return $this; } public function json(array $data): self { $this-setHeader(Content-Type, application/json); $this-setContent(json_encode($data, JSON_UNESCAPED_UNICODE)); return $this; } public function send(): void { http_response_code($this-statusCode); foreach ($this-headers as $name $value) { header({$name}: {$value}); } echo $this-content; } }我故意把json()做成了返回$this的形式这样在控制器里就能链式调用return $response-json([data $list]);3.4 基础控制器与模型BaseController 在设计上保持极简注册一个 View 实例控制器通过$this-view()来渲染模板。?php // app/core/BaseController.php namespace App\Core; abstract class BaseController { protected View $view; public function __construct() { $this-view new View(); } protected function view(string $template, array $data []): Response { return $this-view-render($template, $data); } }View 类很简单它其实是个“模板加载器”?php // app/core/View.php namespace App\Core; class View { private string $viewPath ROOT_PATH . /app/views/; public function render(string $template, array $data []): Response { extract($data, EXTR_SKIP); ob_start(); require $this-viewPath . $template . .php; $content ob_get_clean(); $response new Response(); $response-setContent($content); return $response; } }这里用到了两个 PHP 内置函数extract()把关联数组变成变量ob_start()搭配ob_get_clean()把模板文件的内容捕获到缓冲区而不是直接打印。这两个函数是简化模板渲染的大杀器原理其实就是“输出缓冲”你只要记得缓冲区里的一切最终会被我们打包成 Response 再统一输出。BaseModel 这里我做得更狠——直接只用 PDO 做一层薄封装。为什么不用复杂的 ORM因为我们要拆解原理ORM 反而把 SQL 的细节藏起来了?php // app/core/BaseModel.php namespace App\Core; use PDO; abstract class BaseModel { protected PDO $db; public function __construct() { $this-db new PDO(sqlite: . ROOT_PATH . /database.sqlite); $this-db-setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } protected function query(string $sql, array $params []): \PDOStatement { $stmt $this-db-prepare($sql); $stmt-execute($params); return $stmt; } protected function fetchAll(string $sql, array $params []): array { return $this-query($sql, $params)-fetchAll(PDO::FETCH_ASSOC); } protected function fetch(string $sql, array $params []): ?array { $row $this-query($sql, $params)-fetch(PDO::FETCH_ASSOC); return $row false ? null : $row; } protected function execute(string $sql, array $params []): bool { return $this-query($sql, $params)-rowCount() 0; } }SQLite 的 PDO 连接就一句话的事连配 置文件都省了。但要注意如果换成 MySQL连接字符串就得改成这样$this-db new PDO(mysql:host127.0.0.1;dbnamedbname;charsetutf8mb4, root, password);其余代码不用动因为 BaseModel 的上层接口fetch/fetchAll/execute和具体数据库无关。4. 核心业务用户模块实战4.1 搭建数据表理论讲完直接上一个完整的用户模块走一遍“请求 → 控制器 → 模型 → 视图”的完整链路。先初始化数据库?php // 自动建表的脚本只跑一次建议放到 install.php $db new PDO(sqlite: . __DIR__ . /database.sqlite); $db-setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $db-exec( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ); echo 数据库初始化成功;4.2 用户模型 UserModel模型层的规则是只负责数据逻辑不关心页面展示。这里我写一个 UserModel?php // app/models/UserModel.php namespace App\Models; use App\Core\BaseModel; class UserModel extends BaseModel { public function all(): array { return $this-fetchAll(SELECT id, name, email, created_at FROM users ORDER BY id DESC); } public function find(int $id): ?array { return $this-fetch(SELECT id, name, email, created_at FROM users WHERE id ?, [$id]); } public function create(string $name, string $email): bool { $email filter_var($email, FILTER_VALIDATE_EMAIL); if ($email false) { throw new \InvalidArgumentException(邮箱格式不正确); } return $this-execute( INSERT INTO users (name, email) VALUES (?, ?), [$name, $email] ); } public function update(int $id, string $name, string $email): bool { return $this-execute( UPDATE users SET name ?, email ? WHERE id ?, [$name, $email, $id] ); } public function delete(int $id): bool { return $this-execute(DELETE FROM users WHERE id ?, [$id]); } }你发现没有这里的方法全部用“动作命名”all、find、create、update、delete一看就知道这个模型能干什么比直接写一串 SQL 可读性高多了。这就是把数据操作收敛到模型层最直观的收益。4.3 用户控制器 UserController控制器是调度中心它的任务就是接收用户输入、调用模型拿数据、把数据交给视图去渲染。注意控制器里不应该出现直接拼 SQL 或者拼 HTML 的行为。?php // app/controllers/UserController.php namespace App\Controllers; use App\Core\BaseController; use App\Core\Request; use App\Core\Response; use App\Models\UserModel; class UserController extends BaseController { private UserModel $userModel; public function __construct() { parent::__construct(); $this-userModel new UserModel(); } public function index(): Response { $users $this-userModel-all(); return $this-view(user/list, [users $users]); } public function show(Request $request, int $id): Response { $user $this-userModel-find($id); if ($user null) { $response new Response(); $response-setStatus(404); $response-setContent(用户不存在); return $response; } return $this-view(user/show, [user $user]); } public function create(Request $request): Response { return $this-view(user/create); } public function store(Request $request): Response { $name $request-input(name, ); $email $request-input(email, ); try { $this-userModel-create($name, $email); $response new Response(); $response-setStatus(302); $response-setHeader(Location, /user/index); return $response; } catch (\InvalidArgumentException $e) { $response new Response(); $response-setStatus(400); $response-setContent($e-getMessage()); return $response; } } public function edit(Request $request, int $id): Response { $user $this-userModel-find($id); if ($user null) { $response new Response(); $response-setStatus(404); $response-setContent(用户不存在); return $response; } return $this-view(user/edit, [user $user]); } public function update(Request $request, int $id): Response { $name $request-input(name, ); $email $request-input(email, ); $this-userModel-update($id, $name, $email); $response new Response(); $response-setStatus(302); $response-setHeader(Location, /user/show/ . $id); return $response; } public function destroy(Request $request, int $id): Response { $this-userModel-delete($id); $response new Response(); $response-setStatus(302); $response-setHeader(Location, /user/index); return $response; } }我特别留意了一件事show、edit、update、destroy都要求传$id所以它们的路由定义里一定要有{id}这个占位符。注册路由的时候、写模板链接的时候少传一个都会直接 404这个非常容易踩坑。4.4 视图模板视图里最重要的原则只做输出不做业务。哪怕是一个if判断我都尽量保持简单复杂的计算全部丢给控制器提前算好。用户列表模板?php // App/views/user/list.php ? !DOCTYPE html html head meta charsetUTF-8 title用户列表/title style body { font-family: sans-serif; padding: 2rem; } table { border-collapse: collapse; width: 100%; max-width: 800px; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } th { background-color: #f4f4f4; } a { margin-right: 8px; } /style /head body h1用户列表/h1 a href/user/create新增用户/a table thead tr thID/th th姓名/th th邮箱/th th创建时间/th th操作/th /tr /thead tbody ?php if (empty($users)): ? trtd colspan5暂无用户/td/tr ?php else: ? ?php foreach ($users as $user): ? tr td? (int)$user[id] ?/td td? htmlspecialchars($user[name], ENT_QUOTES, UTF-8) ?/td td? htmlspecialchars($user[email], ENT_QUOTES, UTF-8) ?/td td? htmlspecialchars($user[created_at], ENT_QUOTES, UTF-8) ?/td td a href/user/show/? (int)$user[id] ?查看/a a href/user/edit/? (int)$user[id] ?编辑/a a href/user/destroy/? (int)$user[id] ? onclickreturn confirm(确定删除?)删除/a /td /tr ?php endforeach; ? ?php endif; ? /tbody /table /body /html注意两个细节第一所有用户输出的地方都套了htmlspecialchars()这是防 XSS 的基本底线第二id我做了强转(int)即使传入一个奇怪的字符串也不会出问题。新增表单模板?php // App/views/user/create.php ? form methodpost action/user/store label姓名/label input typetext namename required label邮箱/label input typeemail nameemail required button typesubmit提交/button /form4.5 路由注册最后把路由表补全让整个应用转起来// 在 public/index.php 里添加路由 $router new Router(); $router-add(GET, /user/index, UserControllerindex); $router-add(GET, /user/create, UserControllercreate); $router-add(POST, /user/store, UserControllerstore); $router-add(GET, /user/show/{id}, UserControllershow); $router-add(GET, /user/edit/{id}, UserControlleredit); $router-add(POST, /user/update/{id}, UserControllerupdate); $router-add(POST, /user/destroy/{id}, UserControllerdestroy);到这里一个能增删改查的完整 MVC 应用就成型了。你启动php -S localhost:8080 -t public然后打开浏览器访问/user/index就能看到整个流程在跑。5. 常见问题与排查技巧实录5.1 我踩过的坑响应头已发送导致跳转失败第一次写 Response 类的时候我在header()调用前不小心 echo 了一个空格结果 302 跳转直接失效。经验教训是Response 所有的头信息必须在任何输出之前设置。在入口文件里send()方法做完header()之后才 echo 内容这个顺序绝对不能反。如果你用的是内置开发服务器遇到这类问题先检查有没有 BOM 头、有没有多余空格这是非常隐蔽的问题。5.2 路由匹配不到URL 尾斜杠坑我一开始的path()方法没有做尾斜杠处理结果/user/show/1/和/user/show/1匹配不上。解决办法就是$path / . trim($path, /); $path $path / ? / : rtrim($path, /);注意顺序去掉头尾斜杠之后如果根路径就补一个/回去否则parse_url会返回空字符串直接 404。5.3 模型查询结果是 null 还是空数组find()方法我特意设计成“查不到就返回 null”而all()是“查不到就返回空数组”。这个区分意义很大控制器里可以用 null来判断资源是否存在从而决定是渲染 404 还是正常页面。场景返回值控制器处理列表为空[]视图里显示“暂无数据”单条记录不存在null返回 4045.4 绝对路径与相对路径混乱刚开始写模板里的链接时我用了/user/show/1这种绝对路径所以没问题。如果你用了相对路径比如user/show/1那么当下级目录出现时链接就会指向错误的位置。建议统一使用根路径开头的绝对路径这样无论当前页面在哪个层级都不会出错。5.5 表单重复提交与刷新问题用POST /user/store新增用户后如果直接刷新页面浏览器会提示“是否重新提交表单”这是原生 HTML 表单的问题。我的解决思路是 PRG 模式Post/Redirect/Get// store 方法末尾 $response new Response(); $response-setStatus(302); $response-setHeader(Location, /user/index); return $response;先 POST 处理数据然后 302 跳转到 GET 页面这样用户刷新时就只会刷新 GET 请求不会重复写入数据。这个模式在所有 Web 框架里几乎都是标配。5.6 原生 SQL 注入风险虽然我们用 PDO 预处理已经基本规避了注入风险但如果你习惯用字符串拼接 SQL迟早出事// 危险写法千万不要学 $sql SELECT * FROM users WHERE id . $_GET[id];预处理的关键在于SQL 结构和数据是分开发送的值永远不会被当成 SQL 代码执行。在 BaseModel 里推荐全部走prepare()execute()的方式。6. 框架的局限与下一步扩展方向6.1 简易框架有哪些不足既然是手写 Demo我首先要坦率承认这个框架非常“玩具”。它没有中间件机制认证、日志、跨域这些通用问题都得在每个控制器里自己重复写没有依赖注入容器每个控制器里手动new模型管理起来会越来越累路由不支持正则规则、没有缓存模板没有布局继承每个页面都必须重复写头部尾部 HTML。这些在平时开发时感受不明显一旦项目真正上线、访问量上来都会变成瓶颈。但也正因为这样手写框架是最适合理解 MVC 本质的教材。你可以看到路由、请求、响应、模型、视图、控制器每个环节都是“看得见、摸得着”的代码没有任何黑盒。6.2 如果想升级优先级排序如果你不满足于这个 Demo想往工程化方向走我个人建议按这个顺序扩展第一加入依赖注入容器。让控制器不用手动new依赖而是由容器自动创建并注入代码的可测试性会大幅提升。第二把路由抽成独立文件比如config/routes.php支持注解或属性路由。这样新增一个功能就不再需要改动入口文件符合“开闭原则”。第三引入中间件机制。认证、日志、CSRF 防护都可以做成中间件挂在路由上按需启用既省代码又安全。第四模板引擎增强。哪怕不引入第三方库你也可以手动实现模板继承layout留下content区块子视图填充内容。PHP 的ob_start嵌套其实就能做到。6.3 和其他框架的对照学完手写框架再看那些工业级框架你会有一种“原来如此”的顿悟感。以某个流行的 PHP 框架为例它的核心就是一个手写框架工业级框架Router手动匹配正则自带路由缓存、资源路由、别名路由Request读取超全局变量PSR-7 标准接口可测试、可替换BaseModel封装 PDOEloquent ORM 等支持模型关联、迁移View手动加载模板编译型模板引擎带组件与布局系统没有中间件全局/路由级中间件管道式处理对照完你会明白框架本身并不是魔法只是在你手写的这套骨架上把每一个环节都做深做透了而已。6.4 实际项目中使用 MVC 的几个建议根据我自己的经验MVC 设计模式本身解决不了所有问题但它给团队协作带来的收益是实实在在的给新来的同事分任务时你只需要说“你去改 Model 层的 UserModel加一个统计方法”对方就能精准定位。代码 Review 时改动范围一目了然视图层改了就是调样式模型层改了就是动数据逻辑控制器改了就是改调度规则。这种“位置即职责”的好处只有项目代码上到一定规模之后才体会得到。另一个建议是即使你不打算自己做框架也值得在项目里刻意练习一次 MVC 分层。比如先在小项目里用原生代码写再切换到框架开发心态会完全不一样。框架里的“自动路由”“依赖注入”“模型关联”这些高级功能你在手写版本里找到了对应位置学起来就不容易迷路。7. 手动实现 MVC 的关键收获回到 Day37 这个节点手动实现简易 MVC 框架最有价值的并不是那一堆能跑通的代码而是对分层思想的彻底理解。之前写项目数据库语句、页面 HTML、逻辑判断混在一起代码像一碗浆糊。分清楚 Model、View、Controller 之后最大的变化是大脑负担变轻了打开一个文件你不需要同时记住数据怎么查、页面怎么渲染、请求怎么分发只需要专注于当前这一层要做的事。我自己的体会是手写框架至少有三件事是文档上学不到的第一路由正则解析的细节比如占位符转具名分组不通读源码根本不知道框架帮你处理了多少边界第二输出缓冲在模板渲染里的妙用多一个空格都会破坏响应头顺序第三控制器如何组织代码才能既保持简洁、又不把业务逻辑漏到视图里这个小项目就是最好的练习场。如果你接着往下学手头这个项目的代码别丢试着给它加一个登录功能、加一个布局模板、加一个简单的容器每加一个功能你对 MVC 的掌控力都会更上一层。你可能永远不会在生产环境里再用这个手写框架但你没白写因为接下来无论你用哪个框架都会比没写过的人多一层“知其所以然”的底气。