Bài 24: Router – Điều hướng request

Bài 23 đã xây dựng MVC cơ bản với router đơn giản. Bài này nâng cấp thành Router chuyên nghiệp — hỗ trợ HTTP methods (GET/POST/PUT/DELETE), dynamic parameters, route groups, middleware và named routes như Laravel. Router tốt giúp code dễ bảo trì, URL đẹp và RESTful.

1. Vấn đề với Router đơn giản

<?php
// Router đơn giản từ bài 23:
$routes = [
    'GET /'                    => 'SinhVienController@index',
    'GET /sinhvien/:id'        => 'SinhVienController@show',
    'POST /sinhvien/:id/delete' => 'SinhVienController@delete',
];

// Vấn đề:
// ❌ Phải viết 'GET ', 'POST ' trước mỗi pattern — dễ sai
// ❌ Không có route groups — lặp '/admin' trước mỗi route admin
// ❌ Không có middleware — không kiểm tra auth trước khi vào controller
// ❌ Không có named routes — thay đổi URL phải sửa khắp nơi
// ❌ Không hỗ trợ PUT, PATCH, DELETE thật sự (chỉ fake qua POST)
?>

2. Thiết kế Router Class

<?php
// File: app/Core/Router.php
namespace App\Core;

class Router {
    protected array $routes = [];
    protected array $namedRoutes = [];
    protected array $groupStack = [];
    
    /**
     * Register GET route
     */
    public function get(string $uri, $action): self {
        return $this->addRoute('GET', $uri, $action);
    }
    
    /**
     * Register POST route
     */
    public function post(string $uri, $action): self {
        return $this->addRoute('POST', $uri, $action);
    }
    
    /**
     * Register PUT route
     */
    public function put(string $uri, $action): self {
        return $this->addRoute('PUT', $uri, $action);
    }
    
    /**
     * Register PATCH route
     */
    public function patch(string $uri, $action): self {
        return $this->addRoute('PATCH', $uri, $action);
    }
    
    /**
     * Register DELETE route
     */
    public function delete(string $uri, $action): self {
        return $this->addRoute('DELETE', $uri, $action);
    }
    
    /**
     * Register route for multiple methods
     */
    public function match(array $methods, string $uri, $action): self {
        foreach ($methods as $method) {
            $this->addRoute(strtoupper($method), $uri, $action);
        }
        return $this;
    }
    
    /**
     * Register route for all HTTP methods
     */
    public function any(string $uri, $action): self {
        return $this->match(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], $uri, $action);
    }
    
    /**
     * Add route to collection
     */
    protected function addRoute(string $method, string $uri, $action): self {
        $uri = $this->applyGroupPrefix($uri);
        
        $route = [
            'method' => $method,
            'uri' => $uri,
            'action' => $action,
            'middleware' => $this->gatherMiddleware(),
        ];
        
        $this->routes[] = $route;
        
        return $this;
    }
    
    /**
     * Set name for last registered route
     */
    public function name(string $name): self {
        if (empty($this->routes)) {
            throw new \RuntimeException("Cannot name route: no routes defined");
        }
        
        $lastIndex = count($this->routes) - 1;
        $this->namedRoutes[$name] = $lastIndex;
        
        return $this;
    }
    
    /**
     * Define route group with shared attributes
     */
    public function group(array $attributes, callable $callback): void {
        $this->groupStack[] = $attributes;
        
        call_user_func($callback, $this);
        
        array_pop($this->groupStack);
    }
    
    /**
     * Apply group prefix to URI
     */
    protected function applyGroupPrefix(string $uri): string {
        $prefix = '';
        
        foreach ($this->groupStack as $group) {
            if (isset($group['prefix'])) {
                $prefix .= '/' . trim($group['prefix'], '/');
            }
        }
        
        $uri = '/' . trim($uri, '/');
        return $prefix ? rtrim($prefix . $uri, '/') : $uri;
    }
    
    /**
     * Gather middleware from group stack
     */
    protected function gatherMiddleware(): array {
        $middleware = [];
        
        foreach ($this->groupStack as $group) {
            if (isset($group['middleware'])) {
                $mw = is_array($group['middleware']) ? $group['middleware'] : [$group['middleware']];
                $middleware = array_merge($middleware, $mw);
            }
        }
        
        return $middleware;
    }
    
    /**
     * Dispatch request to matched route
     */
    public function dispatch(string $method, string $uri): mixed {
        // Method spoofing for PUT, PATCH, DELETE
        if ($method === 'POST' && isset($_POST['_method'])) {
            $method = strtoupper($_POST['_method']);
        }
        
        // Find matching route
        foreach ($this->routes as $route) {
            if ($route['method'] !== $method) {
                continue;
            }
            
            // Convert route pattern to regex
            $pattern = $this->convertToRegex($route['uri']);
            
            if (preg_match($pattern, $uri, $matches)) {
                // Extract parameters
                $params = array_filter($matches, fn($k) => !is_int($k), ARRAY_FILTER_USE_KEY);
                
                // Run middleware
                foreach ($route['middleware'] as $middleware) {
                    $this->runMiddleware($middleware, $params);
                }
                
                // Execute action
                return $this->executeAction($route['action'], $params);
            }
        }
        
        // No route matched
        throw new \RuntimeException("Route not found: {$method} {$uri}", 404);
    }
    
    /**
     * Convert route URI to regex pattern
     */
    protected function convertToRegex(string $uri): string {
        // Replace :param with named capture group
        $pattern = preg_replace('/:(\w+)/', '(?P<$1>[^/]+)', $uri);
        
        // Escape forward slashes
        $pattern = str_replace('/', '\/', $pattern);
        
        return '/^' . $pattern . '$/';
    }
    
    /**
     * Execute controller action
     */
    protected function executeAction($action, array $params): mixed {
        // Closure
        if ($action instanceof \Closure) {
            return call_user_func_array($action, [$params]);
        }
        
        // Controller@method string
        if (is_string($action) && str_contains($action, '@')) {
            [$controller, $method] = explode('@', $action);
            $controllerClass = "App\\Controllers\\{$controller}";
            
            if (!class_exists($controllerClass)) {
                throw new \RuntimeException("Controller not found: {$controllerClass}");
            }
            
            $instance = new $controllerClass();
            
            if (!method_exists($instance, $method)) {
                throw new \RuntimeException("Method not found: {$controllerClass}::{$method}");
            }
            
            return call_user_func_array([$instance, $method], [$params]);
        }
        
        throw new \RuntimeException("Invalid route action");
    }
    
    /**
     * Run middleware
     */
    protected function runMiddleware(string $middleware, array $params): void {
        $middlewareClass = "App\\Middleware\\{$middleware}";
        
        if (!class_exists($middlewareClass)) {
            throw new \RuntimeException("Middleware not found: {$middlewareClass}");
        }
        
        $instance = new $middlewareClass();
        
        if (!method_exists($instance, 'handle')) {
            throw new \RuntimeException("Middleware must have handle() method");
        }
        
        $instance->handle($params);
    }
    
    /**
     * Generate URL for named route
     */
    public function route(string $name, array $params = []): string {
        if (!isset($this->namedRoutes[$name])) {
            throw new \RuntimeException("Named route not found: {$name}");
        }
        
        $route = $this->routes[$this->namedRoutes[$name]];
        $uri = $route['uri'];
        
        // Replace parameters
        foreach ($params as $key => $value) {
            $uri = str_replace(":{$key}", $value, $uri);
        }
        
        return $uri;
    }
    
    /**
     * Get all registered routes
     */
    public function getRoutes(): array {
        return $this->routes;
    }
}
?>

3. Route Definition — Web Routes

<?php
// File: config/routes.php

use App\Core\Router;

$router = new Router();

// Simple routes
$router->get('/', 'HomeController@index')->name('home');

// Routes with parameters
$router->get('/sinhvien/:id', 'SinhVienController@show')->name('sinhvien.show');

// Multiple HTTP methods
$router->match(['GET', 'POST'], '/contact', 'ContactController@handle');

// Resource routes (RESTful)
$router->get('/sinhvien', 'SinhVienController@index')->name('sinhvien.index');
$router->get('/sinhvien/create', 'SinhVienController@create')->name('sinhvien.create');
$router->post('/sinhvien', 'SinhVienController@store')->name('sinhvien.store');
$router->get('/sinhvien/:id/edit', 'SinhVienController@edit')->name('sinhvien.edit');
$router->put('/sinhvien/:id', 'SinhVienController@update')->name('sinhvien.update');
$router->delete('/sinhvien/:id', 'SinhVienController@destroy')->name('sinhvien.destroy');

// Route groups with prefix
$router->group(['prefix' => 'admin'], function($router) {
    $router->get('/dashboard', 'Admin\DashboardController@index')->name('admin.dashboard');
    $router->get('/users', 'Admin\UserController@index')->name('admin.users');
});

// Route groups with middleware
$router->group(['middleware' => 'Auth'], function($router) {
    $router->get('/profile', 'ProfileController@show')->name('profile');
    $router->post('/profile', 'ProfileController@update')->name('profile.update');
});

// Nested groups
$router->group(['prefix' => 'admin', 'middleware' => 'Auth'], function($router) {
    $router->get('/settings', 'Admin\SettingsController@index');
    
    $router->group(['prefix' => 'users'], function($router) {
        $router->get('/', 'Admin\UserController@index');
        $router->get('/:id', 'Admin\UserController@show');
    });
});

// Closure routes (for simple logic)
$router->get('/test', function($params) {
    return view('test', ['message' => 'Hello from closure!']);
});

return $router;
?>

4. Middleware

<?php
// File: app/Middleware/Auth.php
namespace App\Middleware;

class Auth {
    public function handle(array $params): void {
        // Check if user is logged in
        if (!isset($_SESSION['user_id'])) {
            flash('error', 'Vui lòng đăng nhập để tiếp tục');
            redirect('/login');
        }
    }
}

// ---

// File: app/Middleware/Guest.php
namespace App\Middleware;

class Guest {
    public function handle(array $params): void {
        // Redirect logged-in users away from login/register
        if (isset($_SESSION['user_id'])) {
            redirect('/');
        }
    }
}

// ---

// File: app/Middleware/Admin.php
namespace App\Middleware;

class Admin {
    public function handle(array $params): void {
        if (!isset($_SESSION['user_id'])) {
            redirect('/login');
        }
        
        // Check if user is admin
        if (!isset($_SESSION['is_admin']) || !$_SESSION['is_admin']) {
            http_response_code(403);
            echo view('errors.403');
            exit;
        }
    }
}
?>

5. Method Spoofing — PUT/DELETE qua Form

<?php
// HTML Form với method spoofing
?>
<!-- Form DELETE -->
<form method="POST" action="/sinhvien/<?php echo $id; ?>">
    <input type="hidden" name="_method" value="DELETE">
    <button type="submit">Xóa</button>
</form>

<!-- Form PUT -->
<form method="POST" action="/sinhvien/<?php echo $id; ?>">
    <input type="hidden" name="_method" value="PUT">
    <input type="text" name="ho_ten" value="<?php echo e($sv['ho_ten']); ?>">
    <button type="submit">Cập nhật</button>
</form>

6. Named Routes — Tạo URL

<?php
// File: app/Helpers/functions.php (bổ sung)

/**
 * Generate URL from named route
 */
function route(string $name, array $params = []): string {
    global $router;
    
    if (!$router) {
        throw new \RuntimeException("Router not initialized");
    }
    
    $uri = $router->route($name, $params);
    $baseUrl = rtrim(config('app.url'), '/');
    
    return $baseUrl . $uri;
}

// ---

// Sử dụng trong view:
?>
<a href="<?php echo route('sinhvien.show', ['id' => $sv['id']]); ?>">
    Xem chi tiết
</a>

<a href="<?php echo route('sinhvien.edit', ['id' => $sv['id']]); ?>">
    Chỉnh sửa
</a>

<form method="POST" action="<?php echo route('sinhvien.destroy', ['id' => $sv['id']]); ?>">
    <input type="hidden" name="_method" value="DELETE">
    <button>Xóa</button>
</form>

7. RESTful Resource Routes

<?php
// File: app/Core/Router.php (thêm method)

/**
 * Register RESTful resource routes
 */
public function resource(string $name, string $controller): void {
    $this->get("/{$name}", "{$controller}@index")->name("{$name}.index");
    $this->get("/{$name}/create", "{$controller}@create")->name("{$name}.create");
    $this->post("/{$name}", "{$controller}@store")->name("{$name}.store");
    $this->get("/{$name}/:id", "{$controller}@show")->name("{$name}.show");
    $this->get("/{$name}/:id/edit", "{$controller}@edit")->name("{$name}.edit");
    $this->put("/{$name}/:id", "{$controller}@update")->name("{$name}.update");
    $this->delete("/{$name}/:id", "{$controller}@destroy")->name("{$name}.destroy");
}

// Sử dụng:
$router->resource('sinhvien', 'SinhVienController');
$router->resource('monhoc', 'MonHocController');

// Tự động tạo 7 routes cho mỗi resource
?>

8. Update Entry Point

<?php
// File: public/index.php (cập nhật)

// Initialize application
$configs = require __DIR__ . '/../bootstrap/init.php';

// Load router
$router = require CONFIG_PATH . '/routes.php';

// Make router available globally
$GLOBALS['router'] = $router;

// Get request info
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

// Remove base path
$basePath = dirname($_SERVER['SCRIPT_NAME']);
if ($basePath !== '/') {
    $uri = substr($uri, strlen($basePath));
}
$uri = '/' . trim($uri, '/');

// Dispatch
try {
    $response = $router->dispatch($method, $uri);
    
    // Output response
    if (is_string($response)) {
        echo $response;
    } elseif (is_array($response)) {
        header('Content-Type: application/json');
        echo json_encode($response, JSON_UNESCAPED_UNICODE);
    }
} catch (\RuntimeException $e) {
    if ($e->getCode() === 404) {
        http_response_code(404);
        echo view('errors.404');
    } else {
        if (config('app.debug')) {
            throw $e;
        }
        
        logMessage('ERROR', $e->getMessage());
        http_response_code(500);
        echo view('errors.500');
    }
} catch (\Exception $e) {
    if (config('app.debug')) {
        throw $e;
    }
    
    logMessage('ERROR', $e->getMessage());
    http_response_code(500);
    echo view('errors.500');
}
?>

9. Route List Command — Debug Routes

<?php
// File: routes-list.php (root directory)

require __DIR__ . '/bootstrap/init.php';

$router = require __DIR__ . '/config/routes.php';
$routes = $router->getRoutes();

echo "Registered Routes:\n";
echo str_repeat('-', 80) . "\n";
printf("%-8s %-30s %-30s %-20s\n", "METHOD", "URI", "ACTION", "MIDDLEWARE");
echo str_repeat('-', 80) . "\n";

foreach ($routes as $route) {
    $action = is_string($route['action']) ? $route['action'] : 'Closure';
    $middleware = implode(', ', $route['middleware']) ?: '-';
    
    printf(
        "%-8s %-30s %-30s %-20s\n",
        $route['method'],
        $route['uri'],
        $action,
        $middleware
    );
}

echo str_repeat('-', 80) . "\n";
echo "Total: " . count($routes) . " routes\n";

// Chạy: php routes-list.php
?>

10. Ví dụ Thực Tế — API Routes

<?php
// File: config/api-routes.php

use App\Core\Router;

$router = new Router();

// API prefix
$router->group(['prefix' => 'api/v1'], function($router) {
    
    // Public endpoints
    $router->get('/health', function() {
        return ['status' => 'ok', 'timestamp' => time()];
    });
    
    // Protected endpoints
    $router->group(['middleware' => 'ApiAuth'], function($router) {
        
        // Students API
        $router->get('/students', 'Api\StudentController@index');
        $router->get('/students/:id', 'Api\StudentController@show');
        $router->post('/students', 'Api\StudentController@store');
        $router->put('/students/:id', 'Api\StudentController@update');
        $router->delete('/students/:id', 'Api\StudentController@destroy');
        
        // Stats
        $router->get('/stats', 'Api\StatsController@index');
    });
});

return $router;
?>

Tóm tắt

  • ✅ Router class với fluent API: $router->get()->name()
  • ✅ HTTP methods: GET, POST, PUT, PATCH, DELETE
  • ✅ Dynamic parameters: :id, :slug tự động extract
  • ✅ Route groups: prefix, middleware tự động áp dụng
  • ✅ Middleware: Auth, Guest, Admin — chạy trước controller
  • ✅ Named routes: route('sinhvien.show', ['id' => 1])
  • ✅ Method spoofing: PUT/DELETE qua form POST với _method
  • ✅ Resource routes: resource() tạo 7 routes RESTful
  • ✅ Closure routes: logic đơn giản không cần controller

🎯 Bài tập thực hành

  1. Tích hợp Router vào dự án: Thay thế router đơn giản từ bài 23 bằng Router class mới. Chuyển tất cả routes sang cú pháp mới với named routes. Test CRUD sinh viên hoạt động bình thường.
  2. Xây dựng Admin Panel: Tạo route group /admin với middleware Admin. Thêm AdminController với dashboard, quản lý users. Kiểm tra middleware chặn người dùng thường, chỉ admin vào được.
  3. RESTful API: Tạo file config/api-routes.php với prefix /api/v1. Implement Api\SinhVienController trả về JSON. Test với Postman hoặc curl: GET /api/v1/students, POST /api/v1/students, PUT /api/v1/students/1, DELETE /api/v1/students/1.

Để lại bình luận

Email của bạn sẽ không được hiển thị.