通过 PHP 原生代码实现 HTTP 路由器

引言

上篇教程学院君给大家演示了如何基于 Symfony 的 HTTP Foundation 组件封装 HTTP 请求和响应类,今天,我们在此基础上编写简单的 HTTP 路由器实现。

这里的路由器和计算机网络中的路由器不是一个东西,但是原理类似,都是用于对网络请求进行分发,不同之处在于前者是对进入 Web 应用中的用户请求通过请求路径和方法进行分发,后者是对不同主机之间的网络请求通过 IP 地址和端口号进行分发。

回到 Web 应用的 HTTP 路由器这个正题,我们上面提到,这个路由器可以通过 URL 请求路径和 HTTP 请求方法对用户请求进行分发,然后通过事先注册的特定业务代码对请求进行处理,最后返回响应给客户端。

以博客应用为例,路由分发和请求处理逻辑目前都杂糅在入口文件 index.php 中:

// 路由分发,通过 Request 对象示例获取路径信息进行匹配
if ($request->getPath() == '/') {
    $albums = $connection->table('albums')->selectAll();
    include __DIR__  . "/../views/home.php";
} elseif ($request->getPath() == 'album') {
    $id = intval($request->get('id'));
    if (empty($id)) {
        echo '请指定要访问的专辑 ID';
        exit();
    }
    $album = $connection->table('albums')->select($id);
    $posts = $connection->table('posts')->selectByWhere(['album_id' => $id]);
    include __DIR__  . '/../views/album.php';
} elseif ($request->getPath() == 'post') {
    $id = intval($request->get('id'));
    if (empty($id)) {
        echo '请指定要访问的文章 ID';
        exit();
    }
    $post = $connection->table('posts')->select($id);
    $printer = $container->resolve(\App\Printer\PrinterContract::class);
    if ($container->resolve('app.editor') == 'markdown') {
        $post['content'] = $printer->driver('markdown')->render($post['text']);
    } else {
        $post['content'] = $printer->render($post['html']);
    }
    $pageTitle = $post['title'] . ' - ' . $container->resolve('app.name');
    $album = $connection->table('albums')->select($post['album_id']);
    include __DIR__  . '/../views/post.php';
} else {
    // 改为通过 Response 对象发送重定向响应
    $response = new \App\Http\Response('', 301, ['Location' => '/']);
    $response->prepare($request)->send();
}

现在我们要拆分出独立的路由器类来注册路由并处理请求。

编写 Route 类

在此之前,我们先创建一个独立的路由类 Route 来表示每个路由,在 app/http 目录下新建 Route.php 并初始化代码如下:

<?php
namespace App\Http;

class Route
{
    public $methods;
    public $uri;
    public $action;
    public $params;

    public function __construct($methods, $uri, $action)
    {
        $this->methods = $methods;
        $this->uri = $uri;
        $this->action = $action;
    }
}

Route 类中,我们定义了四个属性:

  • $methods:表示该路由支持的请求方法,例如 GET、POST、PUT、DELETE;
  • $uri:表示该路由匹配的 URL 请求路径,比如 //album/post
  • $action:表示路由匹配成功后对应的处理逻辑,可以是匿名函数,也可以是控制器方法;
  • $params:表示请求路径中的路由参数(注意不是查询字符串中的请求参数)。

然后,我们定义了一个构造函数来初始化 Route 对象。

编写 Router 类

有了 Route 类之后,接下来,我们就可以基于这个 Route 类来编写路由注册和分发代码,我们将这些业务逻辑都封装到独立的路由器类 Router 中。在 app/http 目录下新建 Router.php,并初始化代码如下:

<?php
namespace App\Http;

class Router
{
    protected $routes = [];

    public function register($methods, $uri, $callback)
    {
        if (isset($this->routes[$uri])) {
            return;
        }
        if (is_string($methods)) {
            $methods = [$methods];
        }
        $route = new Route($methods, $uri, $callback);
        $this->routes[$uri] = $route;
    }

    public function dispatch(Request $request)
    {
        $path = $request->getPath();
        if (!isset($this->routes[$path])) {
            // 未定义路由重定向到首页
            $response = new Response('', 301, ['Location' => '/']);
            $response->prepare($request)->send();
            exit();
        }
        $route = $this->routes[$path];
        if (!in_array(strtolower($request->getMethod()), $route->methods)) {
            throw new \Exception('HTTP 请求方法不正确');
        }
        $callback = $route->action;
        if (is_callable($callback)) {
            call_user_func($callback, $request);
        } elseif (is_string($callback) && strpos($callback, '@') !== FALSE) {
            // @todo 为控制器方法路由预留
        } else {
            throw new \Exception('无效的路由回调');
        }
    }
}

Router 类中,定义了一个 $routes 数组属性来存放应用注册的所有路由实例,然后定义了 register 方法来注册路由,以及 dispatch 方法实现路由分发,注册路由的实现比较简单,我们重点来看路由分发的实现。

首先,我们通过 $request->getPath() 获取请求路径,然后判断该请求路径是否有与之匹配的路由注册过,如果没有注册对应路由,则跳转到首页作为兜底,否则获取对应路由的处理器(请求方法需要和注册路由匹配),如果是匿名回调函数的话,则直接执行该匿名函数,如果是控制器方法的话,则调用对应的控制器方法(暂时留空,等待下篇教程实现控制器时编写),如果都不是,则抛出异常。

注册路由

接下来,我们在 app/routes/web.php 中编写路由注册逻辑:

<?php
$router = new \App\Http\Router();

$store = $container->resolve(\App\Store\StoreContract::class);
$connection = $store->newConnection();
$request = $container->resolve('request');

$router->register('get', '/', function () use ($container, $connection) {
    $albums = $connection->table('albums')->selectAll();
    include __DIR__  . "/../../views/home.php";
});

$router->register('get', 'album', function () use ($container, $request, $connection) {
    $id = intval($request->get('id'));
    if (empty($id)) {
        echo '请指定要访问的专辑 ID';
        exit();
    }
    $album = $connection->table('albums')->select($id);
    $posts = $connection->table('posts')->selectByWhere(['album_id' => $id]);
    include __DIR__  . '/../../views/album.php';
});

$router->register('get', 'post', function () use ($container, $request, $connection) {
    $id = intval($request->get('id'));
    if (empty($id)) {
        echo '请指定要访问的文章 ID';
        exit();
    }
    $post = $connection->table('posts')->select($id);
    $printer = $container->resolve(\App\Printer\PrinterContract::class);
    if ($container->resolve('app.editor') == 'markdown') {
        $post['content'] = $printer->driver('markdown')->render($post['text']);
    } else {
        $post['content'] = $printer->render($post['html']);
    }
    $pageTitle = $post['title'] . ' - ' . $container->resolve('app.name');
    $album = $connection->table('albums')->select($post['album_id']);
    include __DIR__  . '/../../views/post.php';
});

return $router;

这里,我们以匿名回调函数的方式注册之前定义在 index.php 中的路由,并将对应的请求处理代码搬到匿名函数实现代码中。

重构 index.php

最后,重构入口文件 index.php 代码如下:

<?php
require_once __DIR__ . '/../vendor/autoload.php';
// 启动应用
$container = require_once __DIR__ . '/../app/bootstrap.php';

// 获取全局请求示例
$request = \App\Http\Request::capture();
$container->bind('request', $request);

// 注册路由
$router = require_once __DIR__ . '/../app/routes/web.php';

// 路由分发、处理请求、返回响应
$router->dispatch($request);

至此,我们就完成了简单的 HTTP 路由器实现,将原来面向过程风格的代码转化为基于 HTTP 路由器的、面向对象风格实现,代码可读性更强,可以很直观地看出路由注册和分发这两个步骤。

运行 composer dump-auto 让新增类自动加载机制生效,通过 http://localhost:9000 可以正常访问应用,代表代码重构成功:

-w1172

下篇教程,学院君将给大家演示如何编写控制器并基于控制器方法作为路由分发处理器。

上一篇: 基于 Symfony 组件封装 HTTP 请求响应类

下一篇: 通过 PHP 原生代码实现 HTTP 控制器