ThinkPHP MVC 模式入门:从控制器到视图的完整实践

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(控制器):接收请求、调度模型、选择视图,是三者之间的协调者。

一次典型请求的流转过程如下:

flowchart LR A["用户发起请求"] --> B["路由解析"] B --> C["Controller 控制器"] C --> D["Model 模型层"] D --> E["数据库"] E --> D D --> C C --> F["View 视图层"] F --> G["返回 HTML 响应"]

核心思想是关注点分离:控制器不写 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 模板渲染、数据展示 查询数据库、写业务逻辑

各层之间的调用关系可以直观地表示为:

flowchart TD A["HTTP 请求"] --> B["Controller 控制器层"] B --> C["Service 服务层"] C --> D["Model 模型层"] D --> E["数据库"] E --> D D --> C C --> B B --> F["View 视图层"] F --> G["HTML 响应"] B -.-> H["JSON 响应(前后端分离)"]

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 层存在的理由。

模型层的职责边界可以用下图概括:

flowchart LR A[&#34;Model 模型&#34;] --> B[&#34;数据映射&#34;] A --> C[&#34;关联关系&#34;] A --> D[&#34;获取器/修改器&#34;] A --> E[&#34;数据范围 scope&#34;] A --> F[&#34;单表业务方法&#34;] G[&#34;跨模型编排&#34;] -.-> H[&#34;交给 Service 层&#34;]

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 由前端框架承担。

视图层的数据流向可以这样理解:

flowchart LR A[&#34;Controller 准备数据&#34;] --> B[&#34;View::fetch()&#34;] B --> C[&#34;模板引擎解析&#34;] C --> D[&#34;volist / if / 变量输出&#34;] D --> E[&#34;渲染 HTML&#34;] E --> F[&#34;返回浏览器&#34;]

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 控制器的职责清单

控制器应该只做这几件事:

  1. 接收并过滤请求参数
  2. 调用验证器做参数校验
  3. 调用 Service 或 Model 完成业务
  4. 组织响应(视图或 JSON)

不该做的事:写复杂 SQL、写事务、拼装大段 HTML、处理跨模块业务规则。

控制器的处理流程可以归纳为一条固定链路:

flowchart TD A[&#34;接收请求&#34;] --> B[&#34;参数过滤与校验&#34;] B --> C{&#34;校验通过?&#34;} C -->|&#34;否&#34;| D[&#34;返回错误响应&#34;] C -->|&#34;是&#34;| E[&#34;调用 Service / Model&#34;] E --> F[&#34;组织响应&#34;] F --> G[&#34;View 或 JSON&#34;]

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 层在整体架构中的位置与依赖方向如下:

flowchart TD A[&#34;Controller&#34;] --> B[&#34;Service&#34;] B --> C[&#34;Model A&#34;] B --> D[&#34;Model B&#34;] B --> E[&#34;事务 Db::transaction&#34;] C --> F[&#34;数据库&#34;] D --> F G[&#34;命令行 / 定时任务&#34;] --> B

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 请求链路

sequenceDiagram participant U as &#34;用户浏览器&#34; participant R as &#34;路由&#34; participant C as &#34;CartController&#34; participant S as &#34;CartService&#34; participant M as &#34;Model(Goods/Cart)&#34; participant DB as &#34;MySQL&#34; U->>R: &#34;POST /cart/add?id=1&num=2&#34; R->>C: &#34;分发到 add()&#34; C->>C: &#34;参数校验&#34; C->>S: &#34;add(goodsId, num)&#34; S->>M: &#34;Goods::find()&#34; M->>DB: &#34;SELECT&#34; DB-->>M: &#34;商品数据&#34; S->>S: &#34;库存校验 + 事务&#34; S->>M: &#34;Cart::create()/save()&#34; M->>DB: &#34;INSERT/UPDATE&#34; S-->>C: &#34;返回购物车数据&#34; C-->>U: &#34;JSON 响应&#34;

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 层是工程实践中自然生长出来的一层,用来承接控制器与模型之间的复杂业务。

把这条链路走通一次,再回头看框架文档里的「模型关联」「模板引擎」「中间件」,会发现它们都只是在这套分层上做的增强。真正的入门,是从「知道有这几层」变成「知道什么代码该放哪一层」。

相关推荐
凤山老林44 分钟前
Spring Boot 集成 TOTP 双因素认证与恢复码机制
java·spring boot·后端
vipxieliang1 小时前
Spring Boot 测试策略:单元测试、切片测试与集成测试
java·spring boot·后端
vipxieliang1 小时前
Go 1.24 工具链改进实战:工具依赖管理与 go vet 新分析器
后端·go
掘金者阿豪1 小时前
慢 SQL 优化别急着加索引:从订单分页查询聊聊 KES 执行计划
后端
涡能增压发动积1 小时前
AI 对一位老开发的副作用总结
后端
Kimi-学长1 小时前
PHP 爆出两个高危安全漏洞!CVE-2026-109618、CVE-2026-109619 修复方案与防护指南
开发语言·php
未秃头的程序猿1 小时前
同一个商户号,V2 免密代扣和 V3 分账的证书怎么共存?
java·后端·微信
众乐乐_20081 小时前
【实操解决】PHP5.4~PHP7.3的 CVE-2026-109618与CVE-2026-109619问题
安全·php
captain3761 小时前
Spring Boot的配置文件
java·spring boot·后端