前端控制器

3.3 version
维护中的版本

目前为止,我们的程序还过于简单,因为它只有一个页面。为了加点调料进去,我们疯狂一下,再做一个说“goodbye”的页面:

1
2
3
4
5
6
7
8
9
10
// framework/bye.php
require_once __DIR__.'/vendor/autoload.php';
 
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
 
$request = Request::createFromGlobals();
 
$response = new Response('Goodbye!');
$response->send();

如你所见,上面代码中很多都与我们写的第一个页面完全相同。我们提取出这部分代码并在页面间共享。代码共享,听起来是个不错的计划,可以创建我们第一个“真正”的框架。

以PHP方式来重构(代码),差不多就是“创建一个包容文件”:

1
2
3
4
5
6
7
8
// framework/init.php
require_once __DIR__.'/vendor/autoload.php';
 
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
 
$request = Request::createFromGlobals();
$response = new Response();

我们看看(在两个页面中的)实际运行情况:

1
2
3
4
5
6
7
// framework/index.php
require_once __DIR__.'/init.php';
 
$input = $request->get('name', 'World');
 
$response->setContent(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')));
$response->send();

再来是“Goodbye”页面:

1
2
3
4
5
// framework/bye.php
require_once __DIR__.'/init.php';
 
$response->setContent('Goodbye!');
$response->send();

我们的确把共享代码给转移到一个“中心地带(central place)”,但它并不像一个很好的抽象层,不是吗?对于所有页面,我们仍要保留send()方法,页面也不像个模板,我们始终不能正确地测试代码。

还有,添加一个新页,意味着我们必须创建一个新的PHP脚本,其名字通过URL(http://127.0.0.1:4321/bye.php)完全暴露给末级用户:PHP脚本名称与客户端URL之间是直接的映射关系。这是因为,派发的请求(request)直接依赖于服务器。为了提高灵活性,也许把这种派遣转移到我们的代码中是个好主意。这可以通过把所有客户端请求发送(routing)到一个独立PHP脚本来轻松实现。

把一个单一PHP脚本暴露给(exposing)末级用户是一个被称之为前端控制器的设计模式。

这样一个脚本可能像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// framework/front.php
require_once __DIR__.'/vendor/autoload.php';
 
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
 
$request = Request::createFromGlobals();
$response = new Response();
 
$map = array(
    '/hello' => __DIR__.'/hello.php',
    '/bye'   => __DIR__.'/bye.php',
);
 
$path = $request->getPathInfo();
if (isset($map[$path])) {
    require $map[$path];
} else {
    $response->setStatusCode(404);
    $response->setContent('Not Found');
}
 
$response->send();

那么新的hello.php脚本应该是下面这样:

1
2
3
// framework/hello.php
$input = $request->get('name', 'World');
$response->setContent(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')));

front.php脚本中,$map用相对应的PHP脚本路径(paths)关联了URL路径。

作为一个奖励,如果客户端在请求一个“没有定义在URL映射中”的路径时,我们返回一个自定义的404页;现在你可以控制你的网站了。

为了能访问页面,从现在起你必须使用front.php

  • http://127.0.0.1:4321/front.php/hello?name=Fabien

  • http://127.0.0.1:4321/front.php/bye

/hello/bye就是页面的路径(paths)

多数web server,比如Apache或nginx,都可以对请求的URLs进行重写(rewrite),这样就能去掉前端控制器的脚本(名称)了,进而你键入http://127.0.0.1:4321/hello?name=Fabien即可,看上去好多了。

其中的小魔法在于Request::getPathInfo()方法的使用,通过移除前端控制器的“脚本名称”连同其子目录(如果有必要的话,参考上面的灯炮tip)——它将返回请求的路径部分。

你毋须设置web server来测试代码。只需替换掉$request = Request::createFromGlobals();,改以$request = Request::create('/hello?name=Fabien');这种,其中的参数就是你打算模拟的“URL路径”。

现在的服务器已经可以通过相同的脚本(front.php)来访问所有页面了,我们还可以进一步从web根目录中移除所有无关的PHP文件以增加安全性:

1
2
3
4
5
6
7
8
9
10
11
example.com
├── composer.json
├── composer.lock
├── src
│   └── pages
│       ├── hello.php
│       └── bye.php
├── vendor
│   └── autoload.php
└── web
    └── front.php

现在,重新配置你的web server根目录指向web/,其他所有文件将不再能从客户端访问到。

要在浏览器中测试这种改变(http://127.0.0.1:4321/front.php/hello?name=Fabien),运行PHP内置的server:

1
$  php -S 127.0.0.1:4321 -t web/ web/front.php

为了让这个新结构得以运行,你不得不在不同的PHP文件中调整一些路径。这些改变作为练习留给读者完成。

在每个页面中最后一个“重复性”的东西是调用setContent()。我们可以将全部页面转换成“模板”,只需打出(echoing)内容部分再直接从前端控制器脚本中调用setContent()即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// example.com/web/front.php
 
// ...
 
$path = $request->getPathInfo();
if (isset($map[$path])) {
    ob_start();
    include $map[$path];
    $response->setContent(ob_get_clean());
} else {
    $response->setStatusCode(404);
    $response->setContent('Not Found');
}
 
// ...

同时hello.php的脚本要被转换成一个模板:

1
2
3
4
<!-- example.com/src/pages/hello.php -->
<?php $name = $request->get('name', 'World') ?>
 
Hello <?php echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>

我们这个框架的第一个版本现已齐活:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
 
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
 
$request = Request::createFromGlobals();
$response = new Response();
 
$map = array(
    '/hello' => __DIR__.'/../src/pages/hello.php',
    '/bye'   => __DIR__.'/../src/pages/bye.php',
);
 
$path = $request->getPathInfo();
if (isset($map[$path])) {
    ob_start();
    include $map[$path];
    $response->setContent(ob_get_clean());
} else {
    $response->setStatusCode(404);
    $response->setContent('Not Found');
}
 
$response->send();

添加一个新页面将是如下两步的过程:在映射中添加一个入口,然后在src/pages/中创建一个模板。在模板中,通过$request变量取得Request内容,然后通过$response变量可以调整响应头。

如果你决定止步于此,你也许还能通过“把URL映射提取到一个配置文件中”来强化你的框架。

本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。

登录symfonychina 发表评论或留下问题(我们会尽量回复)