1. 引言:为什么需要 MVC
在写第一个 ThinkPHP 项目时,很多人的代码会变成这样:一个控制器方法里既接收参数、又查数据库、又拼 HTML,最后 echo 出去。功能能跑,但一旦需求变化,改动就像拆炸弹。
MVC 设计模式要解决的正是这个问题------把「数据怎么来」「数据怎么算」「数据怎么显示」拆成三块互不越界的职责。ThinkPHP 从 3.x 到 6.x/8.x 一直以 MVC 为骨架,理解这套分层,是读懂框架源码和写出可维护业务代码的前提。
本文从 MVC 的工作原理讲起,逐层拆解 ThinkPHP 中的 Model、View、Controller,再补上实际项目里绕不开的 Service 层,最后用一个购物车示例把整条链路完整走通。
2. MVC 设计模式的工作原理
MVC 把应用拆成三个角色:
- Model(模型):承载数据结构与业务规则,负责与数据库交互,不关心界面长什么样。
- View(视图):负责把数据渲染成用户可见的界面,不做业务判断。
- Controller(控制器):接收请求、调度模型、选择视图,是三者之间的协调者。
一次典型请求的流转过程如下:
核心思想是关注点分离:控制器不写 SQL,模型不输出 HTML,视图不查数据库。这样每一层都能独立测试、独立替换。
需要说明的是,MVC 是一种「思想」,不同框架的落地方式不同。ThinkPHP 在经典 MVC 之上,通常还会引入 Service 层来承载跨模型的复杂业务,这一点后文会专门展开。
3. ThinkPHP 的分层结构总览
ThinkPHP 6/8 的应用目录大致如下:
text
app/
├── controller/ # 控制器层
├── model/ # 模型层
├── service/ # 服务层(业务封装,非框架强制)
├── validate/ # 验证器
├── middleware/ # 中间件
└── view/ # 视图模板(使用模板引擎时)
config/ # 配置文件
route/ # 路由定义
public/index.php # 入口文件
各层职责边界可以简单概括为:
| 层级 | 职责 | 不该做的事 |
|---|---|---|
| Controller | 接收参数、校验、调用服务、返回响应 | 写复杂 SQL、拼 HTML |
| Model | 数据映射、查询、关联、字段处理 | 处理 HTTP 请求、渲染页面 |
| Service | 编排多个模型、事务、复杂业务规则 | 直接输出响应 |
| View | 模板渲染、数据展示 | 查询数据库、写业务逻辑 |
各层之间的调用关系可以直观地表示为:
4. 模型层(Model)------数据与业务逻辑处理
4.1 定义模型
ThinkPHP 的模型继承 think\Model,一个模型对应一张表:
php
<?php
namespace app\model;
use think\Model;
class Goods extends Model
{
// 指定表名(不写则按类名自动推断为 goods)
protected $name = 'goods';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'price' => 'float',
'stock' => 'integer',
'created_at' => 'datetime',
];
// 关联分类
public function category()
{
return $this->belongsTo(Category::class, 'category_id');
}
}
4.2 常用查询与写入
php
<?php
use app\model\Goods;
// 查询单条
$goods = Goods::find(1);
// 条件查询
$list = Goods::where('stock', '>', 0)
->order('id', 'desc')
->paginate(10);
// 新增
$goods = Goods::create([
'name' => '机械键盘',
'price' => 399.00,
'stock' => 100,
]);
// 更新
$goods->price = 359.00;
$goods->save();
4.3 模型里该放什么
模型适合放与单条数据强相关的逻辑,例如:
- 字段获取器/修改器(如价格格式化、密码加密)
- 关联关系定义
- 数据范围(scope)封装
- 单表内的简单业务方法,如
isOnSale()
跨表、跨模型的编排逻辑不建议塞进模型,否则模型会迅速膨胀成「上帝类」,这正是 Service 层存在的理由。
模型层的职责边界可以用下图概括:
5. 视图层(View)------模板渲染与数据展示
5.1 控制器向视图传值
ThinkPHP 使用 think\facade\View 渲染模板:
php
<?php
namespace app\controller;
use app\BaseController;
use app\model\Goods;
use think\facade\View;
class GoodsController extends BaseController
{
public function index()
{
$list = Goods::where('stock', '>', 0)->select();
return View::fetch('goods/index', [
'list' => $list,
'title' => '商品列表',
]);
}
}
对应模板文件 app/view/goods/index.html:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{$title}</title>
</head>
<body>
<h1>{$title}</h1>
<ul>
{volist name="list" id="item"}
<li>
{$item.name} ------ ¥{$item.price}
<a href="/cart/add?id={$item.id}">加入购物车</a>
</li>
{/volist}
</ul>
</body>
</html>
5.2 模板常用语法
- 变量输出:
{$name} - 循环:
{volist name="list" id="item"}...{/volist} - 条件:
{if condition="$item.stock gt 0"}...{/if} - 默认值:
{$name|default='未命名'} - 包含:
{include file="public/header" /}
5.3 视图层的边界
视图只做展示,不要在模板里查数据库或写复杂判断 。如果发现模板里出现大量 {if} 嵌套,通常说明数据应该在控制器或服务层提前整理好。
另外,如果项目是前后端分离的,View 层可以退化为「返回 JSON」,此时控制器直接 return json($data) 即可,MVC 中的 V 由前端框架承担。
视图层的数据流向可以这样理解:
6. 控制器层(Controller)------请求接收与响应返回
6.1 基础控制器
php
<?php
namespace app\controller;
use app\BaseController;
use app\Request;
class CartController extends BaseController
{
public function add(Request $request)
{
// 接收参数
$goodsId = $request->param('id/d');
$num = $request->param('num/d', 1);
// 参数校验
if (!$goodsId || $num < 1) {
return json(['code' => 400, 'msg' => '参数错误']);
}
// 调用服务层
$result = app(\app\service\CartService::class)->add($goodsId, $num);
return json(['code' => 0, 'msg' => 'ok', 'data' => $result]);
}
}
6.2 控制器的职责清单
控制器应该只做这几件事:
- 接收并过滤请求参数
- 调用验证器做参数校验
- 调用 Service 或 Model 完成业务
- 组织响应(视图或 JSON)
不该做的事:写复杂 SQL、写事务、拼装大段 HTML、处理跨模块业务规则。
控制器的处理流程可以归纳为一条固定链路:
6.3 响应类型
php
<?php
// 返回 JSON
return json(['code' => 0, 'data' => $data]);
// 返回模板
return View::fetch('cart/index', ['list' => $list]);
// 重定向
return redirect('/cart/index');
// 下载文件
return download('/path/to/file.zip', 'file.zip');
7. 服务层(Service)------复杂业务逻辑的封装
7.1 为什么需要 Service
当业务涉及多个模型、需要事务、需要复用(控制器、命令行、定时任务都要调用)时,把逻辑放在控制器里会导致重复代码,放在模型里又会让模型职责不清。Service 层就是这中间的缓冲带。
7.2 一个典型的 Service
php
<?php
namespace app\service;
use app\model\Cart;
use app\model\Goods;
use think\facade\Db;
use think\exception\ValidateException;
class CartService
{
/**
* 加入购物车
*/
public function add(int $goodsId, int $num): array
{
$goods = Goods::find($goodsId);
if (!$goods) {
throw new ValidateException('商品不存在');
}
if ($goods->stock < $num) {
throw new ValidateException('库存不足');
}
return Db::transaction(function () use ($goods, $num) {
$cart = Cart::where('goods_id', $goods->id)->find();
if ($cart) {
$cart->num += $num;
$cart->save();
} else {
$cart = Cart::create([
'goods_id' => $goods->id,
'num' => $num,
'price' => $goods->price,
]);
}
return $cart->toArray();
});
}
/**
* 计算购物车总价
*/
public function total(): float
{
$items = Cart::with('goods')->select();
$total = 0.0;
foreach ($items as $item) {
$total += $item->price * $item->num;
}
return round($total, 2);
}
}
7.3 Service 的调用方式
推荐通过容器获取,便于替换和测试:
php
<?php
$cartService = app(\app\service\CartService::class);
$cartService->add(1, 2);
也可以在 app/provider.php 中做依赖绑定,进一步解耦。
Service 层在整体架构中的位置与依赖方向如下:
8. 购物车 MVC 完整走通流程
下面把前面各层串起来,完整走一遍「加入购物车」的请求链路。
8.1 数据表设计
sql
CREATE TABLE `goods` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`price` decimal(10,2) NOT NULL DEFAULT '0.00',
`stock` int NOT NULL DEFAULT '0',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `cart` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`goods_id` int unsigned NOT NULL,
`num` int NOT NULL DEFAULT '1',
`price` decimal(10,2) NOT NULL DEFAULT '0.00',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_goods` (`goods_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
8.2 路由定义
php
<?php
// route/app.php
use think\facade\Route;
Route::get('goods', 'GoodsController/index');
Route::post('cart/add', 'CartController/add');
Route::get('cart', 'CartController/index');
8.3 请求链路
8.4 购物车列表页
控制器:
php
<?php
namespace app\controller;
use app\BaseController;
use app\service\CartService;
use think\facade\View;
class CartController extends BaseController
{
public function index(CartService $cartService)
{
$list = \app\model\Cart::with('goods')->select();
$total = $cartService->total();
return View::fetch('cart/index', [
'list' => $list,
'total' => $total,
]);
}
}
模板 app/view/cart/index.html:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>购物车</title>
</head>
<body>
<h1>我的购物车</h1>
<table border="1" cellpadding="8">
<tr>
<th>商品</th>
<th>单价</th>
<th>数量</th>
<th>小计</th>
</tr>
{volist name="list" id="item"}
<tr>
<td>{$item.goods.name}</td>
<td>¥{$item.price}</td>
<td>{$item.num}</td>
<td>¥{$item.price * $item.num}</td>
</tr>
{/volist}
<tr>
<td colspan="3">合计</td>
<td>¥{$total}</td>
</tr>
</table>
</body>
</html>
8.5 各层职责回顾
| 环节 | 所在层 | 具体动作 |
|---|---|---|
接收 id、num |
Controller | $request->param() |
| 校验参数合法性 | Controller / Validate | 判断非空、类型 |
| 查询商品、校验库存 | Service | 调用 Goods::find() |
| 写入购物车 | Service + Model | 事务内 create() / save() |
| 计算总价 | Service | 遍历汇总 |
| 渲染页面 | View | 模板循环输出 |
这样拆分后,如果以后要加「购物车商品数量上限」「优惠券抵扣」,只需要改 Service,控制器和视图基本不动。
9. 常见误区与最佳实践
误区一:控制器里直接写 SQL。 短期省事,长期无法复用,也无法做单元测试。
误区二:模型里塞满业务。 模型应聚焦数据本身,跨模型编排交给 Service。
误区三:视图里做业务判断。 模板中的 {if} 越多,说明数据准备越不到位。
实践建议:
- 控制器保持「薄」,一个方法尽量不超过 30 行
- Service 方法单一职责,命名体现业务语义,如
addToCart()、checkout() - 涉及多表写入必须用事务
- 参数校验统一交给验证器,不要散落在各处
- 前后端分离项目可省略 View 层,但分层思想不变
10. 总结
MVC 的价值不在于「三个文件夹」,而在于职责边界。ThinkPHP 提供了 Model、View、Controller 的基础设施,而 Service 层是工程实践中自然生长出来的一层,用来承接控制器与模型之间的复杂业务。
把这条链路走通一次,再回头看框架文档里的「模型关联」「模板引擎」「中间件」,会发现它们都只是在这套分层上做的增强。真正的入门,是从「知道有这几层」变成「知道什么代码该放哪一层」。