Slim源码阅读笔记

以前读过 PHP - The Right Way 一文, 还翻译过其中的 The Baiscs 一节 (译文见 这里)。

前两周读了 Modern PHP - New Features and Good Practices 一书 (读书笔记见这里), 甚是不错。

这篇文档和这本书的作者都是Josh Lockhart, 他写了一个Web框架Slim,文档与书籍内容的精华都体现在这个框架中, 所以个人觉得这个框架值得一读。

Slim的设计与实现都非常精简易懂,其对请求的主处理流程如下图所示:

slim

其中的核心概念包括:IoC容器、中间件、路由匹配等。

IoC容器

IoC,为Inversion of Control的缩写,中文翻译为“控制反转” - 是一种解决组件间依赖关系、配置和生命周期的设计模式,其最常见的实现方式为:依赖注入(DI)- 当系统/应用需要使用某个依赖组件时,容器根据配置信息构建一个组件对象返回给系统/应用。

对于Web框架来说,基于Ioc容器可以将框架的功能拆解成多个组件,按需配置调用。

Slim的IoC容器类为:\Slim\Helper\Set,以单例request为例,当调用request对象时$app->request,先触发Slim类的魔术方法__get,其实现如下所示:

public function __get($name)
{
    return $this->container->get($name);
}

而容器类Set的get方法实现如下:

/**
 * Get data value with key
 * @param  string $key     The data key
 * @param  mixed  $default The value to return if data key does not exist
 * @return mixed           The data value, or the default value
 */
public function get($key, $default = null)
{
    if ($this->has($key)) {
        $isInvokable = is_object($this->data[$this->normalizeKey($key)]) && method_exists($this->data[$this->normalizeKey($key)], '__invoke');

        // 注意这里的$this,在初始化组件时,将当前容器对象作为参数传入
        // 从这里可以看到,容器里可以存放普通的配置信息(如settings),也可以存放组件配置
        return $isInvokable ? $this->data[$this->normalizeKey($key)]($this) : $this->data[$this->normalizeKey($key)];
    }

    return $default;
}

我们再来看看单例组件的实现,以request为例:

// 注册单例组件request
$this->container->singleton('request', function ($c) {
    // request组件的实例化依赖于environment组件,
    // 而environment组件包含了$_SERVER以及进程标准输入的数据
    return new \Slim\Http\Request($c['environment']);
});

其中singleton方法的实现如下所示:

/**
 * Ensure a value or object will remain globally unique
 * @param  string   $key   The value or object name
 * @param  \Closure $value The closure that defines the object
 * @return mixed
 */
public function singleton($key, $value)
{
    $this->set($key, function ($c) use ($value) {
        // 静态对象
        static $object;

        if (null === $object) {
            $object = $value($c);
        }

        return $object;
    });
}

中间件

Slim中的中间件分两种:应用级中间件、路由级中间件。

应用中间件基于Rack协议实现,可以在应用对象调用之前或之后检查、分析、或修改应用环境变量、请求对象、响应对象。

每个中间件类都继承自抽象类Middleware,且需要实现其抽象方法call。所有注册的中间件组成一个中间件栈,其结构类似于一个洋葱,先注册的中间件在里层,后注册的在外层,最里层的是应用对象自身,请求从外到里逐层进行处理,任何一层都可以根据条件直接响应请求或递归调用往里一层/下一个中间件。

以中间件SessionCookie与MethodOverride为例,其call方法实现如下所示:

// 中间件SessionCookie
public function call()
{
    // 加载session数据
    $this->loadSession();
    // 调用下一个中间件
    $this->next->call();
    // 保存session数据
    $this->saveSession();
}

// 中间件MethodOverride
public function call()
{
    $env = $this->app->environment();
    if (isset($env['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
        // Header commonly used by Backbone.js and others
        $env['slim.method_override.original_method'] = $env['REQUEST_METHOD'];
        $env['REQUEST_METHOD'] = strtoupper($env['HTTP_X_HTTP_METHOD_OVERRIDE']);
    } elseif (isset($env['REQUEST_METHOD']) && $env['REQUEST_METHOD'] === 'POST') {
        // HTML Form Override
        $req = new \Slim\Http\Request($env);
        // $this->settings['key'] 默认为_METHOD
        $method = $req->post($this->settings['key']);
        if ($method) {
            $env['slim.method_override.original_method'] = $env['REQUEST_METHOD'];
            $env['REQUEST_METHOD'] = strtoupper($method);
        }
    }
    // 调用下一个中间件
    $this->next->call();
}

路由级中间件可以是任何可被调用的东西(is_callable返回true)。在相关路由的回调触发之前,会逐个调用绑定到这个路由的所有路由级中间件,代码实现如下所示:

// 路由类Route的dispatch方法:
/**
 * Dispatch route
 *
 * This method invokes the route object's callable. If middleware is
 * registered for the route, each callable middleware is invoked in
 * the order specified.
 *
 * @return bool
 */
public function dispatch()
{
    // 逐个调用绑定的路由级中间件,以当前路由对象作为参数传入
    foreach ($this->middleware as $mw) {
        call_user_func_array($mw, array($this));
    }

    // 调用路由回调,并将通过路由模式从URL中正则匹配到的参数传入回调
    $return = call_user_func_array($this->getCallable(), array_values($this->getParams()));
    return ($return === false) ? false : true;
}

路由匹配

先来看看Slim应用对象的call方法:

/**
 * Call
 *
 * This method finds and iterates all route objects that match the current request URI.
 */
public function call()
{
    try {
        if (isset($this->environment['slim.flash'])) {
            $this->view()->setData('flash', $this->environment['slim.flash']);
        }
        $this->applyHook('slim.before');
        ob_start();
        $this->applyHook('slim.before.router');
        $dispatched = false;
        // 路由匹配
        $matchedRoutes = $this->router->getMatchedRoutes($this->request->getMethod(), $this->request->getResourceUri());
        // 逐个路由分发执行
        foreach ($matchedRoutes as $route) {
            try {
                $this->applyHook('slim.before.dispatch');
                $dispatched = $route->dispatch();
                $this->applyHook('slim.after.dispatch');
                if ($dispatched) {
                    break;
                }
            } catch (\Slim\Exception\Pass $e) {
                continue;
            }
        }
        // 当路由的回调抛出非Pass异常时,则会响应404
        // 这貌似不好吧?
        if (!$dispatched) {
            $this->notFound();
        }
        $this->applyHook('slim.after.router');
        $this->stop();
    } catch (\Slim\Exception\Stop $e) {
        $this->response()->write(ob_get_clean());
    } catch (\Exception $e) {
        if ($this->config('debug')) {
            throw $e;
        } else {
            try {
                $this->response()->write(ob_get_clean());
                $this->error($e);
            } catch (\Slim\Exception\Stop $e) {
                // Do nothing
            }
        }
    }
}

其中用于路由匹配的Router类的getMatchedRoutes方法实现如下所示:

/**
 * Return route objects that match the given HTTP method and URI
 * @param  string               $httpMethod   The HTTP method to match against
 * @param  string               $resourceUri  The resource URI to match against
 * @param  bool                 $reload       Should matching routes be re-parsed?
 * @return array[\Slim\Route]
 */
public function getMatchedRoutes($httpMethod, $resourceUri, $reload = false)
{
    if ($reload || is_null($this->matchedRoutes)) {
        $this->matchedRoutes = array();
        foreach ($this->routes as $route) {
            // 如果当前请求的HTTP方法不被当前route支持且不是ANY,则跳过
            if (!$route->supportsHttpMethod($httpMethod) && !$route->supportsHttpMethod("ANY")) {
                continue;
            }

            // 否则继续匹配环境变量PATH_INFO
            if ($route->matches($resourceUri)) {
                $this->matchedRoutes[] = $route;
            }
        }
    }

    return $this->matchedRoutes;
}

其中用于PATH_INFO匹配的Route类的matches方法实现如下所示:

/**
 * Matches URI?
 *
 * Parse this route's pattern, and then compare it to an HTTP resource URI
 * This method was modeled after the techniques demonstrated by Dan Sosedoff at:
 *
 * http://blog.sosedoff.com/2009/09/20/rails-like-php-url-router/
 *
 * @param  string $resourceUri A Request URI
 * @return bool
 */
public function matches($resourceUri)
{
    //Convert URL params into regex patterns, construct a regex for this route, init params
    // preg_replace_callback — 执行一个正则表达式搜索并且使用一个回调进行替换
    $patternAsRegex = preg_replace_callback(
        '#:([\w]+)\+?#',
        array($this, 'matchesCallback'),
        // 括号中的部分表示可选
        // 如:/archive(/:year(/:month(/:day)))
        str_replace(')', ')?', (string)$this->pattern)
    );
    // 即使pattern最后有斜杠/,对于URL来说也是可选的
    if (substr($this->pattern, -1) === '/') {
        $patternAsRegex .= '?';
    }

    $regex = '#^' . $patternAsRegex . '$#';

    // 大小写不敏感
    if ($this->caseSensitive === false) {
        $regex .= 'i';
    }

    //Cache URL params' names and values if this route matches the current HTTP request
    // 正则匹配
    if (!preg_match($regex, $resourceUri, $paramValues)) {
        return false;
    }
    foreach ($this->paramNames as $name) {
        if (isset($paramValues[$name])) {
            if (isset($this->paramNamesPath[$name])) {
                $this->params[$name] = explode('/', urldecode($paramValues[$name]));
            } else {
                $this->params[$name] = urldecode($paramValues[$name]);
            }
        }
    }

    return true;
}

其中正则搜索替换的回调方法matchesCallback的实现如下所示:

/**
 * Convert a URL parameter (e.g. ":id", ":id+") into a regular expression
 * @param  array $m URL parameters
 * @return string       Regular expression for URL parameter
 */
protected function matchesCallback($m)
{
    $this->paramNames[] = $m[1];
    if (isset($this->conditions[$m[1]])) {
        return '(?P<' . $m[1] . '>' . $this->conditions[$m[1]] . ')';
    }
    if (substr($m[0], -1) === '+') {
        $this->paramNamesPath[$m[1]] = 1;

        return '(?P<' . $m[1] . '>.+)';
    }

    return '(?P<' . $m[1] . '>[^/]+)';
}

实践

上周,基于Slim框架开发了一个RSS聚合小应用,见这里。前端也尝试使用了Vue.js。