เขียนไมโครเฟรมเวิร์ก MVC ที่สมบูรณ์ ทั้งเราเตอร์แบบ regex, คอนโทรลเลอร์ที่ฉีดพึ่งพา และวิวที่ใช้บัฟเฟอร์ เพื่อเข้าใจว่าเฟรมเวิร์กทำอะไรแทนคุณบ้าง
เปิดบทเรียนนี้ใน Kodokonการเข้าใจเฟรมเวิร์กหมายถึงการรู้วิธีเขียนเฟรมเวิร์กเล็ก ๆ ขึ้นมาเอง สถาปัตยกรรม MVC สมัยใหม่ทุกแบบตั้งอยู่บน front controller นั่นคือ index.php ไฟล์เดียวรับคำร้องขอทั้งหมด (เซิร์ฟเวอร์เขียน URL ทุกอันใหม่ให้ชี้มาที่มัน) จากนั้น เราเตอร์ (router) จะจับคู่เมท็อด HTTP และเส้นทางเข้ากับตัวจัดการ (handler) เราเตอร์ของเราแปลงรูปแบบอย่าง /posts/{id} ให้เป็นนิพจน์ทั่วไป (regular expression) พร้อม กลุ่มจับที่มีชื่อ (named capture group) จากนั้น preg_match จะเติมค่า $found['id'] โดยตรง โดยไม่ต้องแยก URL ด้วยมือ
<?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';
}
}คอนโทรลเลอร์ทำหน้าที่ควบคุมการทำงาน มันอ่านพารามิเตอร์ สอบถาม โมเดล (model) ซึ่งเป็นเลเยอร์เดียวที่ได้รับอนุญาตให้คุยกับฐานข้อมูล แล้วเลือกวิว การพึ่งพา (dependency) เข้ามาผ่านคอนสตรักเตอร์ นี่คือการฉีดพึ่งพา (dependency injection) โดยไม่มีคอนเทนเนอร์ และการเลื่อนคุณสมบัติให้เป็น readonly รับประกันว่ามันจะไม่มีวันเปลี่ยนแปลงหลังการสร้าง โมเดลจะคืนค่า null อย่างชัดเจนเมื่อทรัพยากรไม่มีอยู่ แทนที่จะเป็น false คอนโทรลเลอร์จึงสามารถตัดสินใจเรื่อง 404 ได้
<?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]);
}
}วิวคือไฟล์ PHP ธรรมดาที่ทำเพียงแสดงตัวแปรที่ถูกเตรียมไว้ให้แล้ว มีกลไกภายในสองอย่างที่ทำให้เป็นไปได้ extract เปลี่ยนอาร์เรย์ข้อมูลให้เป็นตัวแปรท้องถิ่น และ การบัฟเฟอร์เอาต์พุต (output buffering) (ob_start แล้วตามด้วย ob_get_clean) จะจับเอาต์พุตเพื่อคืนมันเป็นสายอักขระแทนที่จะส่งออกไปทันที ซึ่งขาดไม่ได้สำหรับการประกอบเทมเพลตซ้อนกันหรือการตัดสินใจเรื่องเฮดเดอร์หลังจากเรนเดอร์
<?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
);