以前读过 PHP - The Right Way 一文, 还翻译过其中的 The Baiscs 一节 (译文见 这里)。
前两周读了 Modern PHP - New Features and Good Practices 一书 (读书笔记见这里), 甚是不错。
这篇文档和这本书的作者都是Josh Lockhart, 他写了一个Web框架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。