Kodokon kodokon.com

สถาปัตยกรรม MVC ที่สร้างขึ้นเอง

เขียนไมโครเฟรมเวิร์ก MVC ที่สมบูรณ์ ทั้งเราเตอร์แบบ regex, คอนโทรลเลอร์ที่ฉีดพึ่งพา และวิวที่ใช้บัฟเฟอร์ เพื่อเข้าใจว่าเฟรมเวิร์กทำอะไรแทนคุณบ้าง

11 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

การเข้าใจเฟรมเวิร์กหมายถึงการรู้วิธีเขียนเฟรมเวิร์กเล็ก ๆ ขึ้นมาเอง สถาปัตยกรรม MVC สมัยใหม่ทุกแบบตั้งอยู่บน front controller นั่นคือ index.php ไฟล์เดียวรับคำร้องขอทั้งหมด (เซิร์ฟเวอร์เขียน URL ทุกอันใหม่ให้ชี้มาที่มัน) จากนั้น เราเตอร์ (router) จะจับคู่เมท็อด HTTP และเส้นทางเข้ากับตัวจัดการ (handler) เราเตอร์ของเราแปลงรูปแบบอย่าง /posts/{id} ให้เป็นนิพจน์ทั่วไป (regular expression) พร้อม กลุ่มจับที่มีชื่อ (named capture group) จากนั้น 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>[^/]+)$#

คอนโทรลเลอร์ทำหน้าที่ควบคุมการทำงาน มันอ่านพารามิเตอร์ สอบถาม โมเดล (model) ซึ่งเป็นเลเยอร์เดียวที่ได้รับอนุญาตให้คุยกับฐานข้อมูล แล้วเลือกวิว การพึ่งพา (dependency) เข้ามาผ่านคอนสตรักเตอร์ นี่คือการฉีดพึ่งพา (dependency injection) โดยไม่มีคอนเทนเนอร์ และการเลื่อนคุณสมบัติให้เป็น 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 เปลี่ยนอาร์เรย์ข้อมูลให้เป็นตัวแปรท้องถิ่น และ การบัฟเฟอร์เอาต์พุต (output buffering) (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
);
front controller ที่สมบูรณ์: การเรนเดอร์ การเชื่อมต่อ และการส่งต่อ (dispatch)

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. ในเราเตอร์นี้ การแปลงรูปแบบ /posts/{id} ได้ผลลัพธ์เป็นอะไร?
    • นิพจน์ทั่วไปพร้อมกลุ่มจับที่มีชื่อ ซึ่งเข้าถึงได้ผ่าน $found['id']
    • อาร์เรย์แบบเชื่อมโยงของส่วนต่าง ๆ ใน URL
    • การเปรียบเทียบสายอักขระธรรมดา ทีละส่วน
  2. ทำไมฟังก์ชัน render จึงใช้ ob_start?
    • เพื่อบีบอัดเอาต์พุต HTML ก่อนส่งออกไป
    • เพื่อจับเอาต์พุตของวิวและคืนมันเป็นสายอักขระ แทนที่จะส่งไปยังเบราว์เซอร์ทันที
    • เพื่อป้องกันไม่ให้วิวรันโค้ด PHP
    • เพื่อแคชการเรนเดอร์ของวิวไว้บนดิสก์
  3. front controller มีบทบาทอะไร?
    • เสิร์ฟไฟล์แบบสถิตให้เร็วขึ้น
    • ตรวจสอบสิทธิ์ของผู้ใช้ก่อนทุกหน้า
    • เป็นจุดเข้าจุดเดียวของแอปพลิเคชัน คำร้องขอทุกรายการถูกเขียนใหม่ให้ชี้มาที่มัน แล้วมอบหมายต่อให้เราเตอร์