起因:小迪把 SQL 和 HTML 写在了一起
周三上午,小迪在写「文章详情页」。
她交上来的东西长这样:
// controller/article.php
if_get('/article/*', function ($article_id) {
$pdo = new PDO('mysql:host=127.0.0.1;dbname=blog', 'root', '123456');
$stmt = $pdo->prepare("select * from article where id = ?");
$stmt->execute([$article_id]);
$row = $stmt->fetch();
echo '<html><body>';
echo '<h1>' . htmlspecialchars($row['title']) . '</h1>';
echo '<div>' . $row['content'] . '</div>';
echo '</body></html>';
});
阿杰看了一眼,没说话。
老陈端着咖啡从后面路过,停下来:「这文件以后谁来改?」
「我改啊,有 bug 就改。」
「那我问你一个问题。」老陈把杯子放下,「三个月后,设计说标题要换成大字号带个副标题,你改哪儿?」
「改那个 echo 那行。」
「再问一个:产品说文章要改成软删除,查的时候得加条件,你改哪儿?」
「……也改这个文件。」
「再问一个:公司换数据库,或者要给文章加个『浏览量』字段,你改哪儿?」
小迪不说话了。
「你看,三种完全不同的改动,都落在同一个文件里。」老陈把杯子端起来,「这就是 MVC 要解决的事。」
先说结论:MVC 不是三个文件夹
「我知道 MVC。」小迪说,「就是 Model、View、Controller 三个文件夹嘛。」
「文件夹只是它留在磁盘上的痕迹。」老陈摇头,「你先别记那三个词,先记一句话:MVC 是按『改动原因』切的,不是按『代码种类』切的。」
他把小迪那段代码拆成三行问题:
| 你改它的原因 | 这块责任叫 |
|---|---|
| 表结构变了、字段变了、业务规则变了 | Model |
| 设计稿变了、排版变了、给用户看的东西变了 | View |
| 产品说这次请求该做什么变了(比如要加个校验、要跳转) | Controller |
「三种原因,互相之间没有因果关系。设计改字号,跟表里有没有 delete_time 一点关系都没有。既然如此,它们就不该待在同一个文件里。」
小迪点头:「所以 MVC 就是把会因为不同原因改动的东西分开。」
「对。而且这么分有个直接好处——你改字号的时候,不用担心碰到 SQL。」
为什么需要它:改动的影响半径
老陈在白板上画了两个圈。
左边那个圈里写「一个 300 行的文件」:
取数据库 → 判断业务 → 拼 HTML → 输出
「没有分层的时候,你敲任何一个回车,影响的都是这 300 行。你不知道你改的这一处,会不会把另外两百行里某个假设打破。」
右边那三个圈,分开:
Controller → Model → Controller → View
「分层以后,你改 View,影响半径就是 View 那一个文件。这才是分层的真正收益,不是『看起来干净』,是『改坏了不容易连带』。」
小迪:「那如果不分层,是不是就一定出事?」
「不一定。」老陈说,「你一个人写一个只有两个页面的小工具,不分层还快一点。分层的成本是前期多写几个文件,收益是三个月后的自己少骂人。」
阿杰这时候插了一句:「我补一个更土的判断标准——你看这行代码会不会因为不同的人来改。会,就该分。」
这套框架里的 MVC:三块在哪儿
老陈把项目目录投到屏幕上。
controller/ # C:页面路由(闭包)
controller_api/ # C:接口路由(闭包)
view/ # V:Blade 模板
domain/ # M:领域层
├── entity/ # 表结构 + 工厂方法 + 关系
├── dao/ # 取数
└── knowledge/ # 业务规则
frame/ # 框架核心库(ORM、DB、Cache、Queue、Blade、日志)
「这框架在自己的文档里第一句话就写明了:单层 MVC PHP 框架。所以不用猜它有没有 MVC,它就是。」
| 目录 | 负责什么 | |
|---|---|---|
| M | domain/entity/、domain/dao/、domain/knowledge/ |
数据长什么样、怎么取、规则是什么 |
| V | view/(自研 Blade) |
把已经算好的东西摆成页面 |
| C | controller/、controller_api/、controller_sse/ |
这次请求要做什么 |
小迪盯着表:「C 为什么有三个目录?」
「因为它们的输出格式不一样。页面入口只出 HTML,接口入口只出 JSON,SSE 入口是流。三个 C 共用同一个 M,只是把结果交给不同的出口。」老陈说,「这也是这套框架的一句话原则:入口决定响应格式。」
Model:不是一个文件夹,是三层
「先说 M,因为它最胖。」老陈说,「胖到还得再切。」
第一层:Entity,管表长什么样。
// domain/entity/order.php
class order extends entity
{
public $structs = [
'user_id' => 0,
'amount' => 0,
'status' => 'pending',
];
public static function create(int $user_id, float $amount): order
{
$order = parent::init();
$order->user_id = $user_id;
$order->amount = $amount;
return $order;
}
}
「注意两件事。一是类名就是表名,order 类对应 order 表——不用你另外声明表名。二是 create() 是统一的工厂方法,必填参数放前面。」
还有五个字段不用你声明,基类自己管:
| 字段 | 说明 |
|---|---|
id |
主键,通过 Redis INCR 生成 |
version |
乐观锁版本号,每次更新 +1 |
create_time / update_time |
创建、更新时间 |
delete_time |
软删除时间,null 表示未删除 |
「软删除这条就是刚才问你的那个问题——产品要加软删除,你只需要在 Entity 这一层理解它;Controller 和 View 完全不用知道有这回事。」
第二层:DAO,管怎么取数。
// domain/dao/order.php
class order_dao extends dao
{
protected $table_name = 'order';
protected $db_config_key = 'default';
}
用的时候不实例化,直接 dao():
$order = dao('order')->find($id);
$res = dao('order')->find_all_paginated_by_current_page_and_column(
$page, $size, ['status' => 'paid']
);
$orders = $res['list'];
$pagination = $res['pagination'];
「所有 SQL 都收在这一层。 你在 Controller 里看到 dao('order')->find($id),就知道它要查什么,但不用关心怎么查。」
第三层:Knowledge,管业务规则。
「比如『下单要判断库存、要算优惠、要写流水』这种一段逻辑同时动三张表的事——它既不是纯取数,也不该塞进 Controller,就下沉到 domain/knowledge/。」
「这三层的加载方式也不一样。」老陈切到 domain/ 目录:
// domain/autoload.php
spl_autoload_register(function ($class_name) {
$class_maps = [
'demo' => 'entity/demo.php',
'demo_dao' => 'dao/demo.php',
];
if (isset($class_maps[$class_name])) {
include __DIR__.'/'.$class_maps[$class_name];
}
});
// domain/load.php
include __DIR__.'/autoload.php';
// knowledge files
// include __DIR__.'/knowledge/demo.php';
「Entity 和 DAO 走 class map 自动加载,Knowledge 是显式 include。这个框架没有 Composer autoload,所以它用一张手写的映射表代替。 好处是你能一眼看完全项目有哪些类,坏处是新增类得跑一下 sh project/tool/classmap.sh。」
View:只负责把已经算好的东西摆上去
// view/order/detail.php
@include('layout/header')
<h1>{
{
$order->amount }}</h1>
@foreach ($items as $item)
<div>{
{
$item->name }}</div>
@endforeach
@include('layout/footer')
小迪:「这不是 PHP 文件吗?」
「是 PHP 文件,但不是原生 PHP 语法,是这套框架自研的轻量 Blade。」老陈说,「它只支持十来个指令:
{
{ $var }} 输出变量
{
{ $var or '默认值' }} 带默认值的输出
{
{
{ $var }}} 转义输出(防 XSS)
@if / @elseif / @else / @endif
@unless / @endunless
@foreach / @endforeach
@for / @endfor
@while / @endwhile
@include('layout/header') 引入子模板,自动继承变量
@php / @endphp 原生 PHP 块
{
{-- 注释 --}}
「没有 @extends、@section、@yield——布局用 @include 拼,不用继承。」
「它在实现上就是十个正则编译阶段,编译完的产物落在 view/blade/ 下面,文件名是 order-detail.blade.php(把斜杠换成横杠)。第一次访问编译一次,之后直接读缓存。」
「所以 V 这层的性能关键不是『模板有多快』,而是别让模板走进业务。你在模板里写一句 dao('user')->find(...),这层就该重开缓存了,而且这个页面再也没法被第二个入口复用。」
Controller:这个框架的 C 是闭包,不是类
「最后一层,也是最反直觉的一层。」老陈说,「这套框架里,没有 Controller 类。」
// controller/article.php
if_get('/article/*', function ($article_id) {
$article = dao('article')->find($article_id);
otherwise_error_code('USER_NOT_FOUND', $article->is_not_null());
return render('article/detail', [
'article' => $article,
]);
});
小迪:「这不就是个闭包吗?Controller 呢?」
「闭包就是 Controller。」老陈敲了敲白板,「框架文档原话是『路由即闭包,控制器即函数』。原因是它的加载方式就是这么直白,你看 public/index.php 最后几行:
include CONTROLLER_DIR.'/base.php';
not_found();
include 把 controller/ 下的文件读进来,文件里的 if_get(...) 在 include 那一刻就执行了;它拿当前请求路径去匹配,匹配上就跑闭包、然后 exit。」
「所以这里的『Controller』不是一个类,是这次请求命中到的那一个闭包。」
不过框架给 C 立了三条规矩:
| 规矩 | 原文意思 |
|---|---|
| 闭包保持简洁 | 长逻辑不写在路由里 |
| 不直接操作数据库 | 走 DAO 和 Entity,别在闭包里拼 SQL |
| 不在路由文件里封装函数 | 复杂逻辑下沉 domain/knowledge/ |
「你这三条合起来看,就是在保护上面那张表——C 只做编排,不做事。」
还有一条是硬契约:页面入口的闭包必须返回字符串。返回数组?框架直接抛错:
页面路由必须返回字符串(HTML),实际返回:array,请将该路由迁移到 controller_api/ 目录
「它不允许你的 C 在这里吐出 JSON。要出 JSON,去 controller_api/——那也是同一个 C 层,只是换了个出口。」
一条请求走一遍 MVC
老陈把三块连起来画了一遍:
nginx → public/index.php
→ bootstrap.php(加载 frame/ 核心库)
→ if_verify:把这次请求的闭包包进 unit_of_work
→ include controller/*.php ← C 登场
→ 命中闭包 → dao('article')->find() ← M 上场
→ return render('article/detail') ← V 渲染,返回字符串
→ echo 出去 → PHP-FPM → 浏览器
「这里有三个细节值得你看一眼。」
一、M 不认识 V,V 不认识 M。
「render() 收的是已经取好的数据,它不知道这些数据是从 MySQL 来的还是从 Redis 来的。反过来 DAO 也不知道数据最后会被渲染成 HTML 还是 JSON。两边的唯一交集是 Controller 手里的那个数组——这就是解耦的具体样子。」
二、render() 返回的是字符串,不是直接输出。
function render(string $view, array $args = [])
{
if (! empty($args)) {
extract($args);
}
$render = view_compiler();
ob_start();
include $render($view);
$echo = ob_get_contents();
ob_end_clean();
return $echo;
}
「ob_start 开缓冲、include 模板、把缓冲内容取出来、清掉缓冲、返回字符串。所以 V 层对外只暴露一件事:给我视图名和数据,还你一段 HTML。它不 exit,也不碰响应头——那些是 Controller 和框架的事。」
三、事务不属于 C,也不属于 M。
「还记得 if_verify 里那层 unit_of_work 吗?它是包在整个闭包外面的。所以你写业务的时候不用管什么时候提交——闭包跑完,框架扫描你改过的实体,生成 SQL、开事务、提交、校验乐观锁,全在后面。」
「这就是分层的好处最直观的一次体现: 事务这件事,加进来的时候你没改任何一层的代码。」
小迪的三个问题
「MVC 是不是每个框架都长一样?」
「不是。」老陈说,「这套框架的文档里有一整节在讲它故意砍掉了什么:没有 Service 层、没有 Repository 接口、没有 DI 容器、没有 DTO、没有事件总线。它只保留了 Entity / DAO / Knowledge / View / 路由闭包。」
「理由写在 README 里:那些中间层是为『人类要把大项目切成小块、跨长周期维护』设计的。但 AI 辅助编程下,一个文件里能看懂全部逻辑,分层越多,AI 多一层猜错路径的机会。」
「那为什么 Model 里还分三个目录?这不是也分层了吗?」
「因为这是同类责任里的细分,不是横向加了一层。」老陈说,「表结构、取数、规则,三样东西的改动原因还是不一样的——加个字段不用动 Knowledge,改个优惠规则不用动 Entity。它跟『Controller → Service → Repository』那种为抽象而抽象的层不一样。」
「Controller 里能写 if 吗?」
「能。但你得问一句这个 if 是什么性质的。」老陈画了两个方框:
if ($user->is_null()) { redirect('/login'); }—— 这次请求怎么走,这是 C 的活儿if ($stock >= $count) { ... }—— 业务规则,这属于 M
「分不清的时候用上次那条判定线:这个判断要是被别人绕过,出问题的是数据还是体验? 动数据的,往下沉。」
收尾
小迪把那段代码重写了。三块分开了:
// controller/article.php —— C:只做编排
if_get('/article/*', function ($article_id) {
$article = dao('article')->find($article_id);
otherwise_error_code('USER_NOT_FOUND', $article->is_not_null());
return render('article/detail', ['article' => $article]);
});
// domain/entity/article.php —— M:表结构
class article extends entity
{
public $structs = [
'title' => '',
'content' => '',
'author_id' => 0,
];
public static function create(string $title, string $content, int $author_id): article
{
$article = parent::init();
$article->title = $title;
$article->content = $content;
$article->author_id = $author_id;
return $article;
}
}
// view/article/detail.php —— V:只摆结果
@include('layout/header')
<h1>{
{
$article->title }}</h1>
<div>{
{
{
$article->content }}}</div>
@include('layout/footer')
小迪对着这三段看了一会儿:「所以之前那个 300 行的文件,现在是三个文件。」
「而且三种改动落在三个文件上。」老陈说,「设计改字号,只碰第三个;表加字段,只碰第二个;产品说没登录要跳走,只碰第一个。这就是 MVC,一句话:让三种改动互不打扰。」
阿杰在旁边摸鱼五分钟,抬头说了一句:「我以前的判断标准是『看这文件打开有多少行』,现在换成『看这文件会因为几种原因被改』——感觉比数字靠谱。」
「你这个标准可以留着。」老陈把咖啡喝完,「Model 管真相对不对,View 管好不好看,Controller 管这次要做什么。分不清一个东西该放哪儿,就问它是为了哪一种改动而生。」
附:我想改 X,去动哪个
| 我想改的东西 | 动哪一层 | 具体位置 |
|---|---|---|
| 加一个字段、改字段默认值 | Model | domain/entity/ |
| 加个软删除、加乐观锁行为 | Model | domain/entity/ 基类已内置,直接用 |
| 换查询条件、加一个筛选 | Model | domain/dao/ |
| 「下单要判断库存+算优惠+写流水」 | Model | domain/knowledge/ |
| 页面排版、样式、加个区块 | View | view/<模块>/ |
| 加一个页面、改跳转逻辑、改校验 | Controller | controller/<模块>.php |
| 加一个 JSON 接口 | Controller | controller_api/<模块>.php(路由以 /api/ 开头) |
| 加一个推送流 | Controller | controller_sse/<模块>.php |
| 加个数据库(跨库) | Model | DAO 的 db_config_key |
想自己走一遍?
git clone https://github.com/smarty-kiki/php-vibe-coding-frame my-project && cd my-project
sh project/tool/start_development_server.sh # 需要 Docker
打开 http://localhost 看到 hello world 之后,照着上面那条「一条请求走一遍 MVC」的链子走一遍:从 public/index.php 进,在 controller/base.php 找到那个命中 / 的闭包,再顺着 render('index/index') 找到 view/index/index.php。三个文件看完,MVC 就落地了。