Kodokon kodokon.com

Архитектура MVC своими руками

Напиши полноценный микрофреймворк MVC - роутер на регулярных выражениях, контроллеры с внедрением зависимостей, представления через буфер вывода - чтобы понять, что фреймворки делают за тебя.

11 мин · 3 вопросов

Открыть этот урок в Kodokon

Понять фреймворк - значит уметь написать маленький. Любая современная архитектура MVC опирается на фронт-контроллер: единственный 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_start, затем ob_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. Какова роль фронт-контроллера?
    • Быстрее отдавать статические файлы
    • Проверять права пользователя перед каждой страницей
    • Быть единственной точкой входа приложения: каждый запрос переписывается на него, а затем делегируется роутеру