Kodokon kodokon.com

手工打造的 MVC 架构

亲手写一个完整的微型 MVC 框架 - 一个正则路由器、注入式控制器、带缓冲的视图 - 从而理解框架都替你做了些什么。

11 分钟 · 3 题

在 Kodokon 中打开本课

理解一个框架,意味着知道如何写一个小的框架。每一种现代 MVC 架构都建立在前端控制器(front controller)之上:一个单独的 index.php 接收所有请求(服务器会把每个 URL 都重写到它),然后一个路由器把 HTTP 方法和路径匹配到某个处理器。我们的路由器会把像 /posts/{id} 这样的模式转换成带命名捕获组的正则表达式:接着 preg_match 会直接填好 $found['id'],无需手动切分 URL。

PHP
<?php
declare(strict_types=1);

final class Router
{
    private array $routes = [];

    public function add(
        string $method,
        string $pattern,
        callable $handler
    ): void {
        $regex = preg_replace(
            '#\{(\w+)\}#',
            '(?P<$1>[^/]+)',
            $pattern
        );
        $this->routes[] = [
            $method, "#^$regex$#", $handler
        ];
    }

    public function dispatch(
        string $method,
        string $uri
    ): mixed {
        foreach ($this->routes as $route) {
            [$m, $re, $handler] = $route;
            if ($m !== $method) {
                continue;
            }
            if (preg_match($re, $uri, $found) === 1) {
                return $handler($found);
            }
        }
        http_response_code(404);
        return 'Page not found';
    }
}
模式 /posts/{id} 会变成 #^/posts/(?P<id>[^/]+)$#。

控制器负责编排:它读取参数,查询模型 - 唯一被允许与数据库对话的一层 - 并挑选一个视图。依赖通过构造函数传入:这是一种不借助容器的依赖注入,而把属性提升为 readonly 则保证它们在构造之后绝不会改变。当资源不存在时,模型会返回一个明确的 null 而不是 false:这样控制器就能据此决定返回 404。

PHP
<?php
declare(strict_types=1);

final class PostModel
{
    public function __construct(
        private readonly PDO $pdo
    ) {}

    public function find(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM posts WHERE id = :id'
        );
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row === false ? null : $row;
    }
}

final class PostController
{
    public function __construct(
        private readonly PostModel $posts
    ) {}

    public function show(array $params): string
    {
        $id = (int) $params['id'];
        $post = $this->posts->find($id);
        if ($post === null) {
            http_response_code(404);
            return render('not-found');
        }
        return render('post', ['post' => $post]);
    }
}
render 函数会在下一段代码里定义。

视图是一个普通的 PHP 文件,只负责显示那些已经为它准备好的变量。两个内部机制让这成为可能:extract 会把数据数组转换成局部变量,而输出缓冲(先 ob_startob_get_clean)会捕获输出,把它作为字符串返回,而不是立刻发送出去 - 这对于组合嵌套模板、或在渲染之后再决定响应头都是不可或缺的。

PHP
<?php
declare(strict_types=1);

function render(
    string $view,
    array $data = []
): string {
    extract($data, EXTR_SKIP);
    ob_start();
    require __DIR__ . "/views/$view.php";
    return (string) ob_get_clean();
}

$pdo = new PDO('sqlite:blog.db');
$controller = new PostController(
    new PostModel($pdo)
);

$router = new Router();
$router->add(
    'GET',
    '/posts/{id}',
    fn (array $p): string => $controller->show($p)
);

$path = parse_url(
    $_SERVER['REQUEST_URI'],
    PHP_URL_PATH
);

echo $router->dispatch(
    $_SERVER['REQUEST_METHOD'],
    (string) $path
);
完整的前端控制器:渲染、装配与分发。

知识检测

确认你已牢记本课的重点内容。

  1. 在这个路由器里,转换模式 /posts/{id} 会产生什么?
    • 一个带命名捕获组的正则表达式,之后可以通过 $found['id'] 访问
    • 一个由 URL 各段组成的关联数组
    • 一次逐段进行的纯字符串比较
  2. render 函数为什么要使用 ob_start?
    • 为了在发送之前压缩 HTML 输出
    • 为了捕获视图的输出并把它作为字符串返回,而不是立刻发送给浏览器
    • 为了阻止视图执行 PHP 代码
    • 为了把视图的渲染结果缓存到磁盘上
  3. 前端控制器的作用是什么?
    • 更快地提供静态文件
    • 在每个页面之前检查用户的权限
    • 作为应用程序的单一入口点:每个请求都被重写到它,然后再委托给路由器