介绍

如果你不熟悉 PHP 语言,那本教程不适合你。本教程面向那些掌握了 PHP 基础知识并对面向对象编程有所了解的人。

你应当至少听说过 SOLID。如果你对其不熟悉,在开始这个教程之前的现在,是熟悉这些原则的好时机。

我看到很多人进入 Stack Overflow 的 PHP 聊天室询问某框架好不好。大多情况下,答案是,他们只需要用原生 PHP 而非框架去构建他们的应用。但是很多人对此不知所措,不知道从哪儿开始入手。

因此我的目标是,提供一种可以给人们以指引的简单方法。在大多数情况下,框架没有意义,并且在一些第三方组件的帮助下,从头开始编写一个应用要比人们想象的要容易得多。

这个教程是为 PHP 7.0 或更高版本编写的。如果你正在使用旧版本,请在开始操作之前升级它。我推荐你使用当前的稳定版本

那让我们立即开始第一部分。


目录

  1. 前端控制器
  2. Composer
  3. 错误处理程序
  4. HTTP
  5. 路由
  6. 分派到一个类
  7. 控制反转
  8. 依赖注入器
  9. 模板
  10. 动态页面
  11. 页面菜单
  12. 前端

1、前端控制器

前端控制器是你应用的一个单一入点。

首先,为你的项目创建一个空目录。你还需要一个入点,用来接收所有请求。这意味着你必须创建一个名为 index.php 的文件。

通常做法是直接将 index.php 放到项目根目录中。有些框架也会这样做。让我来解释一下为什么你不应该这样做。

文件 index.php 是一个起点,因此它必须存在于 WEB 服务器的目录中。这意味着 WEB 服务器可以访问所有的子目录。如果你设置无误,还是能够阻止它访问应用程序文件所在子目录的。

但是有时事情并不按计划进行。如果出现某些问题,并且你的文件是按照之前所说那样创建的,那你的整个应用程序源代码就可能会暴露给访客。我不必解释为什么这不是一件好事。

所以替代做法是,在你的项目文件夹中再创建一个名为 public 的文件夹。同时也是为你的应用创建一个名为 src 的文件夹的好时机,同样放在项目的根目录。

现在你可以在 public 文件夹中创建 index.php 了。要记得,你不想在此文件中暴露任何东西,因此只需在其中放入如下代码:

<?php declare(strict_types = 1);

require __DIR__ . '/../src/Bootstrap.php';

__DIR__ 是一个“魔术常量”含有文件夹路径。利用它,你可以确保 require 始终使用相同的相对路径关联所用文件。否则,如果你从另一个文件夹调用 index.php,它将无法找到该文件。

declare(strict_types = 1); 设定当前文件为 严格类型。在本教程中,我们将在所有 PHP 文件中使用它。这意味着你不能硬把一个整数作为参数传给需要字符串的方法。如果你不使用严格模式,它会自动转换成所需类型。而使用严格模式,如果它是错误类型将会抛出异常(Exception)。

Bootstrap.php 将会是一个把你的应用联结在一起的文件。我们很快就会谈到它。

公共文件夹 public 的其余部分留作存放公共资源文件之用(如 Javascript 文件和样式表文件)。

现在进入你的 src 文件夹并创建一个含有如下内容的名为 Bootstrap.php 的新文件:

<?php declare(strict_types = 1);

echo 'Hello World!';

现在让我们看看是否所有都设置正确。打开一个控制台并定位到项目的 public 文件夹。然后输入 php -S localhost:8000 并回车。这将会启动 PHP 内置的 WEB 服务器,你可以通过在浏览器中输入 http://localhost:8000 来访问你的页面。你现在应该可以看到“Hello World!”消息。

如果出现了错误,回过头并尝试解决它。如果你只看到空白页面,请检查运行服务器的控制台窗口是否有错误。

现在是时候提交你的进度了。如果你没有准备好使用 Git,那现在就创建一个存储库。这不是一个 Git 教程,所以我不会详细介绍。不过使用版本控制应该是一个习惯,即便只是这样一个教程项目。

有些编辑器和 IDE 会把它们的私有文件放入你的项目文件夹中。如果是这样的话,在你的项目根目录下创建一个 .gitignre 文件以排除这些文件或文件夹。下面是一个 PHPStorm 的例子:

.idea/

2、Composer

Composer 是一个 PHP 依赖管理器。

你不使用某个框架,并不意味着当你想做某事时都必须重新发明轮子。通过 Composer,你可以为应用安装第三方库。

如果你尚未安装 Composer,去访问网站并安装它。你可以去 Packagist 为你的项目寻找 Composer 包。

在你的项目根文件夹下创建一个新的文件,名为 composer.json。这个 Composer 配置文件会被用来配置你的项目及其依赖项。它必须是一个有效的 JSON 格式,否则 Composer 将会出错。

将如下内容添加到这个配置文件中:

{
    "name": "Project name",
    "description": "Your project description",
    "keywords": ["Your keyword", "Another keyword"],
    "license": "MIT",
    "authors": [
        {
            "name": "Your Name",
            "email": "your@email.com",
            "role": "Creator / Main Developer"
        }
    ],
    "require": {
        "php": ">=7.0.0"
    },
    "autoload": {
        "psr-4": {
            "Example\\": "src/"
        }
    }
}

autoload 部分,你可以看到我正在为项目使用 Example 命名空间。你可以使用适合你项目的任何名称,但是从现在开始我会始终在示例中使用 Example 命名空间。你只需在自己的代码中用你的命名空间替换它。

打开一个新的控制台窗口并定位到你项目根文件夹。然后运行 composer update.

Composer 创建了一个用来锁定你所用依赖项的 composer.lock 文件以及 vendor 文件夹。

composer.lock 提交到版本控制中是项目的通用良好实践。它允许持续测试工具(譬如 Travis CI)针对你正在开发的版本完全相同的库运行测试。它还允许项目的其它人使用版本完全相同的库,即它可以从源头消除“在我的机器上工作”问题。

话虽如此,你并不希望把所有依赖项的实际源代码都放到 Git 存储库中。所以让我们在 .gitignore 文件中添加一条规则:

vendor/

现在你成功为所创建的项目打下了基础。


3、错误处理程序

错误处理程序允许你在代码出错时自定义产生的结果。

一个承载大量调试信息的详尽错误页面在开发期间大有帮助。因此你应用的第一个包将解决此事。

我喜欢 filp/whoops,所以我将展示如何为你的项目安装该软件包。如果你更喜欢其它包,随意安装哪个。这是脱离框架的编程之美,你可以完全掌控项目。

另一款替代软件包是:PHP-Error

要安装一个新软件包,打开 composer.json 并把软件包添加到 require 部分。它现在应该是这样:

"require": {
    "php": ">=7.0.0",
    "filp/whoops": "~2.1"
},

现在在你的控制台上执行 composer update,软件包就会被安装。

* 译者注:安装新软件时也可以直接使用命令 composer require "filp/whoops:~2.1"

但是你还不能立刻使用它。PHP 不知道去哪里找到这个类的文件。为此你需要一个自动加载器,理想选择是 PSR-4 自动加载器。Composer 已经为你解决了这个问题,所以你只需要在 Bootstrap.php 中添加一行 require __DIR__ . '/../vendor/autoload.php'; 即可。

重要提示: 永远不要在生产环境中显示任何错误信息。一份堆栈追踪或者甚至只是一条简单的错误信息都可以帮助某些人访问你的系统。取而代之的是,始终显示一个用户友好的错误页面,然后向你自己发送一份邮件、写入日志或其它类似的方式。这样,只有你才可以在生产环境中看到这些错误信息。

但是在开发环境下这没有意义——你想要一个详尽的错误页面。解决方法是在代码中进行环境切换。当前你可以将其设置为 development

接下来,当错误处理程序注册后,抛出一个 Exception 来测试是否一切工作正常。你的 Bootstrap.php 文件内容现在看起来如下所示:

<?php declare(strict_types = 1);

namespace Example;

require __DIR__ . '/../vendor/autoload.php';

error_reporting(E_ALL);

$environment = 'development';

/**
* Register the error handler
*/
$whoops = new \Whoops\Run;
if ($environment !== 'production') {
    $whoops->pushHandler(new \Whoops\Handler\PrettyPageHandler);
} else {
    $whoops->pushHandler(function($e){
        echo 'Todo: Friendly error page and send an email to the developer';
    });
}
$whoops->register();

throw new \Exception;

你现在应该可以看到一个错误页面,突出显示了抛出异常的代码行。如果没有,回过头调试,直到它能如期运行。现在也是再次提交代码的好时机。


4、HTTP

PHP 已经内置了几种方法,可以更轻松地使用 HTTP。比如“超全局变量”就包含了一些请求信息。

如果你只是想运行一个小型脚本这足够用,它们也不难维护。不过,如果你想要编写整洁、可维护且遵循 SOLID 原则的代码,你将需要一个具有良好的面向对象接口的类,你可以在应用中使用它。

再说一遍,你不必重新发明轮子,只需要安装一个包。我决定使用我自己写的 HTTP 组件,因为我不喜欢现有的一些组件,但是你不不必这样做。

其它替代方案:Symfony HttpFoundationNette HTTP ComponentAura Websabre/http

在这个教程中我将会使用我自己编写的 HTTP 组件,但是毫无疑问你可以使用任何你喜欢的包。你只需自己调整教程中代码即可。

再一次编辑 composer.json 文件添加新组件,然后运行 composer update 命令:

  "require": {
    "php": ">=7.0.0",
    "filp/whoops": "~2.1",
    "patricklouys/http": "~1.4"
  },

现在你可以在 Bootstrap.php 文件的错误处理程序代码下方添加以下代码(不要忘了移除之前故意抛出异常的代码):

$request = new \Http\HttpRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
$response = new \Http\HttpResponse;

这些设置让你能在别的类中使用 RequestResponse 对象,以获取请求数据并向浏览器发送响应。

要实际发回一些东西,你还需要在 Bootstrap.php 文件末尾添加以下代码片段:

foreach ($response->getHeaders() as $header) {
    header($header, false);
}

echo $response->getContent();

这些代码会把响应数据发送到浏览器。如果不这么做,就不会有任何反应,因为 Response 对象仅存储数据。而其它大多数 HTTP 组件会用不同的方式处理,类会把数据作为副作用发送回浏览器,如果你使用其它组件要记得这一点。

函数 header() 的第二个参数是 false,不然已存在的消息头将会被覆盖。

现在它只是给浏览器返回了一个状态码为 200 的空响应;改变这点,要在之前的代码片段之中(就是指 foreach 语句上方)添加以下代码:

$content = '<h1>Hello World</h1>';
$response->setContent($content);

如果你想要尝试 404 错误,则使用以下代码:

$response->setContent('404 - Page not found');
$response->setStatusCode(404);

要记住,响应对象只会存储数据,所以如果你在发送响应前设置了多个状态码,则只有最后一个会生效。

我会在后面的部分向你展示如何使用组件的不同功能。在此期间,如果你想要了解某些工作原理,可随意阅读“文档”或者源代码。


5、路由

路由依靠你设置的规则分派到不同的处理程序。

根据你目前的设置,无所谓使用什么 URL 访问应用,它都会返回相同的响应结果。因此让我们来解决这个问题。

在这个教程中我将使用 FastRoute。但是与以往一样,你可以选择你喜欢的包。

一些替代软件包:symfony/Routing, Aura.Router, fuelphp/routing, Klein

现在你知道如何使用 Composer 安装软件包了,所以我会留给你自行操作。

现在添加这些代码块到你的 Bootstrap.php 文件中,要添加到上一章中显示 Hello World 消息代码的下方。

$dispatcher = \FastRoute\simpleDispatcher(function (\FastRoute\RouteCollector $r) {
    $r->addRoute('GET', '/hello-world', function () {
        echo 'Hello World';
    });
    $r->addRoute('GET', '/another-route', function () {
        echo 'This works too';
    });
});

$routeInfo = $dispatcher->dispatch($request->getMethod(), $request->getPath());
switch ($routeInfo[0]) {
    case \FastRoute\Dispatcher::NOT_FOUND:
        $response->setContent('404 - Page not found');
        $response->setStatusCode(404);
        break;
    case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
        $response->setContent('405 - Method not allowed');
        $response->setStatusCode(405);
        break;
    case \FastRoute\Dispatcher::FOUND:
        $handler = $routeInfo[1];
        $vars = $routeInfo[2];
        call_user_func($handler, $vars);
        break;
}

在代码的第一部分,你为应用注册了一个可用的路由。在代码的第二部分,调度器被调用,并执行了 switch 语句中合适的部分。

对于非常小的应用这样组织没问题,但是一旦你开始添加几条路由,你的 Bootstrap.php 文件将很快变得杂乱无章。所以让我们将这些代码移动到一个单独的文件。

src/ 文件夹中创建一个名为 Route.php 的文件。它应该如下所示:

<?php declare(strict_types = 1);

return [
    ['GET', '/hello-world', function () {
        echo 'Hello World';
    }],
    ['GET', '/another-route', function () {
        echo 'This works too';
    }],
];

现在让我们利用 Routes.php 文件重写路由调度器部分。

$routeDefinitionCallback = function (\FastRoute\RouteCollector $r) {
    $routes = include('Routes.php');
    foreach ($routes as $route) {
        $r->addRoute($route[0], $route[1], $route[2]);
    }
};

$dispatcher = \FastRoute\simpleDispatcher($routeDefinitionCallback);

这已经是一个改进,但是现在所有的处理程序代码都在 Routes.php 文件里。这并不是最好做法,所以让我们在下一部分中解决它。

不要忘了在每一章最后提交你的改动。


6、分派到一个类

在本教程中我们不会实行“MVC (Model-View-Controller)”。MVC 在 PHP 中无论如何都无法正确实现,至少不是最初设想的方式。如果你想了解更多相关信息,可阅读“A Beginner’s Guide To MVC”(译文)以及后续的文章。

所以忘掉 MVC,然后让我们来关心“关注点分离(separation of concerns)”。

我们需要为处理请求的类起一个描述性名称。在这个教程中我将使用 Controllers,因为它对有框架背景的人来说更亲切。你也可以将其命名为 Handlers

src/ 文件夹中创建一个名为 Controller 的新文件夹。在这个文件夹中我们将存放所有控制器类。在那里,创建一个名为 Homepage.php 文件。

<?php declare(strict_types = 1);

namespace Example\Controllers;

class Homepage
{
    public function show()
    {
        echo 'Hello World';
    }
}

只有在类的命名空间与文件路径相匹配,并且文件名等于类名的情况下,自动加载器才会正常工作。

现在让我们改进 hello world 路由,以便它调用你新写的类方法而不是闭包。将你的 Routes.php 代码改成如下所示:

return [
    ['GET', '/', ['Example\Controllers\Homepage', 'show']],
];

你现在正在传递一个数组,而不只是调用一个函数。第一个值是类名完整的命名空间,第二个值是你想要调用的方法名。

想要让其正常运行,你还必须微调一下 Bootstrap.php 中的路由部分。

case \FastRoute\Dispatcher::FOUND:
    $className = $routeInfo[1][0];
    $method = $routeInfo[1][1];
    $vars = $routeInfo[2];

    $class = new $className;
    $class->$method($vars);
    break;

如此,现在你实例化了一个对象并在其上调用方法,而不只是仅仅调用一个方法。

现在,如果你访问 http://localhost:8000/,一些都应当正常运行。如果没有,回过头去调试。当然不要忘记提交你的改动。


7、控制反转

在上一部分,你创建了一个控制器类,然后使用 echo 生成输出。但是不要忘了我们还有个好用的面向对象 HTTP 抽象。但是现在它在你编写的类中还不能使用。

使用“控制反转(inversion of control)”是明智的选择。这意味着,你只需要向类请求所需要的对象,而不是让类负责去创建。这些工作由“依赖注入”完成。

如果现在感觉有点儿复杂,不要担心。只需要跟着教程走,一旦你知道它是如何实现的,就会理解了。

更改你的 Homepage.php 控制器的代码如下:

<?php declare(strict_types = 1);

namespace Example\Controllers;

use Http\Response;

class Homepage
{
    private $response;

    public function __construct(Response $response)
    {
        $this->response = $response;
    }

    public function show()
    {
        $this->response->setContent('Hello World');
    }
}

注意我们在文件顶部“导入(importing)”了 Http\Response。这意味着你无论何时在这个文件中使用 Response,它都将解析为完全限定名(fully qualified name)

在构造函数中,我们现在明确的请求了 Http\Response。在这种情况下,Http\Response 是一个接口(interface)。因此任何实现接口的类都会被注入。请查看 类型提示(type hinting)接口(interfaces) 以供参考。

现在代码会导致出错,因为我们还没有注入任何东西。所以让我们在 Bootstrap.php 文件中处理这个问题,我们在发现路由时进行分派:

$class = new $className($response);
$class->$method($vars);

Http\HttpResponse 对象实现了 Http\Response 接口,所以它符合约定并可以被使用。

现在所有一切应该能再次正常运行了。但是如果你遵循这个例子,所有你按照这种方式实例化的对象将会被注入同样的对象。这当然不好,所以让我们在下一部分来解决这个问题。


8、依赖注入器

依赖注入器解析你所编写类的依赖关系,并确保当类被实例化时注入正确的对象

我只能推荐一款注入器:Auryn。遗憾的是,所有我所知道的替代品都在他们的文档和示例中使用了“服务器定位反模式(service locator antipattern)”。

安装 Auryn 包并在 src/ 文件夹中创建一个名为 Dependencies.php 的文件。然后添加以下内容:

<?php declare(strict_types = 1);

$injector = new \Auryn\Injector;

$injector->alias('Http\Request', 'Http\HttpRequest');
$injector->share('Http\HttpRequest');
$injector->define('Http\HttpRequest', [
    ':get' => $_GET,
    ':post' => $_POST,
    ':cookies' => $_COOKIE,
    ':files' => $_FILES,
    ':server' => $_SERVER,
]);

$injector->alias('Http\Response', 'Http\HttpResponse');
$injector->share('Http\HttpResponse');

return $injector;

在继续之前,请确保你已理解 aliasshare 以及 define 正在做什么。你可以通过 Auryn 文档阅读与之相关的信息。

之所以共享 HTTP 对象,是因为向一个对象添加内容然后再返回另外一个对象没有多大意义。因此,通过共享它,您始终可以获取相同的实例。

别名(alias)允许你使用类型提示接口而不是类名。这使得切换实现非常容易,不必回过头修改所有使用旧实现的类。

当然你的 Bootstrap.php 也需要改动。在你使用 new 调用设置 $request$response 之前。现在将其切换到注入器,以便我们在任何地方使用这些对象的相同实例。

$injector = include('Dependencies.php');

$request = $injector->make('Http\HttpRequest');
$response = $injector->make('Http\HttpResponse');

其他必须改动的部分是路由的调度。之前你的代码如下所示:

$class = new $className($response);
$class->$method($vars);

现在将其改成如下所示代码:

$class = $injector->make($className);
$class->$method($vars);

现在所有的控制器构造函数依赖项都将自动被 Auryn 解析。

回到你的 Homepage.php 控制器,然后将其更改如下:

<?php declare(strict_types = 1);

namespace Example\Controllers;

use Http\Request;
use Http\Response;

class Homepage
{
    private $request;
    private $response;

    public function __construct(Request $request, Response $response)
    {
        $this->request = $request;
        $this->response = $response;
    }

    public function show()
    {
        $content = '<h1>Hello World</h1>';
        $content .= 'Hello ' . $this->request->getParameter('name', 'stranger');
        $this->response->setContent($content);
    }
}

正如你所看到的,现在类有两个依赖。试一试使用带参数的 GET 方法访问这个页面,像这样:http://localhost:8000/?name=Arthur%20Dent

恭喜,你现在成功的为你的应用奠定了基础。


9、模板

对于 PHP 来说模板引擎不是必须的,因为语言本身就可以搞定。但是模板可以让类似值转义这些事情变得更容易。

关于这点可以阅读 ircmaxell 的文章 “论模板On Templating)” 快速了解。也请读一下这篇文章Simple PHP Template Engine),关于这个主题的不同观点。我对这一主题没有强烈的观点,所以哪种方法对你来说效果更好,由你自己决定。

在这篇教程中,我们将使用 Mustache 的PHP 实现。所以在继续之前先安装那个包(composer require mustache/mustache)。

另一个知名的替代品是 Twig

现在去看一看 Engine 类的源代码。你可以看到,该类没有实现接口。

你可以只针对具体的类使用类型提示。但是这种方式的问题是会导致你创建紧耦合。

换句话说,你所用的 Engine 代码会和 Mustache 包耦合在一起。如果你想要改变实现就会遇到问题。可能你想要切换到 Twig,可能你想要编写你自己的类,也可能你想要为 Engine 添加功能。这是不可能的,除非回过头改动所有紧耦合的代码。

我们想要松耦合。我们将针对接口使用类型提示,而不是类或实现。所以如果你需要另一个实现,你只需要在你的新类中实现那个接口,然后注入新类。

我们将会使用“适配器模式”,而不是编辑包的代码。这听起来比实际上复杂得多,所以只需要照做。

首先让我们定义我们想要的接口。记住“接口隔离原则”。这意味着我们希望使每个接口尽可能小,而不是拥有大量方法的大型接口。如果有必要,一个类可以扩展多个接口。

那么我们的模版引擎实际上需要做什么呢?现在我们其实只需要一个简单的 render 方法。在你的 src/ 文件夹中创建一个名为 Template 的新文件夹,你可以把所有模板相关的文件都放在这里面。

在其中创建一个新的接口 Renderer.php,如下所示:

<?php declare(strict_types = 1);

namespace Example\Template;

interface Renderer
{
    public function render($template, $data = []) : string;
}

注:函数中的“: string”是 PHP7 新增的特性,被称为“返回值类型声明(Return type declarations)”(类似参数的“类型声明(type declaration)”)作用是指定函数返回值的类型。弱模式下类型不一致会强制自动转换,强模式下会抛错。

现在已经解决了这些问题,让我们为 Mustache 创建实现。在同一个文件夹里创建 MustacheRenderer.php 文件,内容如下:

<?php declare(strict_types = 1);

namespace Example\Template;

use Mustache_Engine;

class MustacheRenderer implements Renderer
{
    private $engine;

    public function __construct(Mustache_Engine $engine)
    {
        $this->engine = $engine;
    }

    public function render($template, $data = []) : string
    {
        return $this->engine->render($template, $data);
    }
}

如你所见,适配器非常简单。虽然原始类有很多种方法,我们的适配器却十分简单,只有添加了一个接口。

当然,我们还必须在我们的 Dependencies.php 中添加一个定义,否则当你为接口进行提示时,注入器不知道它必须注入哪个实现。在内容中添加这一行:

$injector->alias('Example\Template\Renderer', 'Example\Template\MustacheRenderer');

现在在你的 Homepage 控制器中添加新的依赖项,如下所示:

<?php declare(strict_types = 1);

namespace Example\Controllers;

use Http\Request;
use Http\Response;
use Example\Template\Renderer;

class Homepage
{
    private $request;
    private $response;
    private $renderer;

    public function __construct(
        Request $request,
        Response $response,
        Renderer $renderer
    ) {
        $this->request = $request;
        $this->response = $response;
        $this->renderer = $renderer;
    }

...

我们还必须重写 show 方法。请注意,虽然我们仅传递了一个简单的数组(array),Mustache 却为你提供了传递视图上下文对象的选项。我们将在稍后讨论这个问题,现在让我们尽量保持简单。

    public function show()
    {
        $data = [
            'name' => $this->request->getParameter('name', 'stranger'),
        ];
        $html = $this->renderer->render('Hello ', $data);
        $this->response->setContent($html);
    }

现在在你的浏览器中快速查看一下是否一切正常。默认情况下,Mustache 使用了一个简单的字符处理程序。但是我们想要的却是模板文件,因此让我们回头修改一下它。

做这样的修改,我们需要传递一个 options 数组到 Mustache_Engine 构造函数。因此让我们回到 Dependencies.php 文件并添加如下代码:

$injector->define('Mustache_Engine', [
    ':options' => [
        'loader' => new Mustache_Loader_FilesystemLoader(dirname(__DIR__) . '/templates', [
            'extension' => '.html',
        ]),
    ],
]);

我们传递了一个 options 数组,因为我们想要使用的后缀名是 .html 而不是默认的后缀名。为什么?因为其它模板语言使用了类似的语法,如果我们决定更换成其它模板语言,那么我们将不必重命名所有的模板文件。

在你的项目根目录创建一个名为 templates 的文件夹。在那创建一个名为 Homepage.html 的文件。文件内容应该如下所示:

<h1>Hello World</h1>
Hello 

现在你可以回到你的 Homepage 控制器,然后更改 render 那一行为 $html = $this->renderer->render('Homepage', $data);

在你的浏览器中浏览 Hello World 页面确保一切运行正常。和往常一样,不要忘了提交你的改动。


10、动态页面

到现在为止我们只有一个没有什么功能的静态页面。只有一个 Hello World 页面不是很有用,因此让我们更进一步,添加一些真正的功能到我们的应用里。

我们的第一个功能将是从 markdown 文件生成的动态页面。

创建一个 Page 控制器,内容如下:

<?php declare(strict_types = 1);

namespace Example\Controllers;

class Page
{
    public function show($params)
    {
        var_dump($params);
    }
}

创建完成后,添加以下路由:

['GET', '/{slug}', ['Example\Controllers\Page', 'show']],

现在尝试访问几个新 URL,如 http://localhost:8000/testhttp://localhost:8000/hello。如你所见,每次调用 Page 控制器,$params 数组就会接收页面的 slug 参数。

让我们从创建几个页面开始。我们还不会使用数据库,因此在你项目的根目录创建一个名为 pages 的新文件夹。在其中添加几个后缀名为 .md 的文件,然后在其中添加一些文本。比如 page-one.md 含有如下内容:

This is a page.

现在我们将必须编写一些代码来读取特定文档并显示其内容。把所有代码都扔进 Page 控制器看起来很诱人。但是要记得“关注点分离(separation of concerns)。这是好时机,很有可能我们也需要在应用程序的其它地方读取这些页面(如管理区域)。

因此让我们把该功能放入单个的类。我们今后很可能会从文件切换到数据库,因此让我们再次使用一个接口,使我们的页面阅读器与真正的实现解藕。

在你的 src 文件夹,创建一个名为 Page 的新文件夹。在其中我们将存放所有与页面相关的类相。添加一个名为 PageReader.php 的新文件,内容如下:

<?php declare(strict_types = 1);

namespace Example\Page;

interface PageReader
{
    public function readBySlug(string $slug) : string;
}

关于实现,创建一个名为 FilePageReader.php 文件。文件看起来如下所示:

<?php declare(strict_types = 1);

namespace Example\Page;

use InvalidArgumentException;

class FilePageReader implements PageReader
{
    private $pageFolder;

    public function __construct(string $pageFolder)
    {
        $this->pageFolder = $pageFolder;
    }

    public function readBySlug(string $slug) : string
    {
        return 'I am a placeholder';
    }
}

如你所见,我们引入了页面文件夹的路径作为构造函数的参数。这使得类变得灵活,如果你决定移动文件或为该类编写单元测试,我们可以通过构造函数参数轻松更改其位置。

你也可以把页面相关的文件放进它自己的包中,然后在不同的应用中重用它。因为我们没有对其紧耦合,所以显得非常灵活。

这样就可以了。让我们为页面在 templates 文件夹中创建一个名为 Page.html 的模板文件。现在只在里面添加 {{ content }}

将如下代码添加到你的 Dependencies.php 文件中,以便应用程序知道为我们的新接口注入哪个实现。我们也要在那里定义 pageFolder

$injector->define('Example\Page\FilePageReader', [
    ':pageFolder' => __DIR__ . '/../pages',
]);

$injector->alias('Example\Page\PageReader', 'Example\Page\FilePageReader');
$injector->share('Example\Page\FilePageReader');

现在回到 Page 控制器,将 show 方法修改如下:

public function show($params)
{
    $slug = $params['slug'];
    $data['content'] = $this->pageReader->readBySlug($slug);
    $html = $this->renderer->render('Page', $data);
    $this->response->setContent($html);
}

为了使其运行,我们需要注入一个 ResponseRenderer 和 一个 PageReader。我将此留给你作为练习。记得使用 use 调用所有正确的命名空间。使用 Homepage 控制器作为参考。

你让一切都正常运行了吗?

如果没有,你的控制器开头应该如下所示:

<?php declare(strict_types = 1);

namespace Example\Controllers;

use Http\Response;
use Example\Template\Renderer;
use Example\Page\PageReader;

class Page
{
    private $response;
    private $renderer;
    private $pageReader;

    public function __construct(
        Response $response,
        Renderer $renderer,
        PageReader $pageReader
    ) {
        $this->response = $response;
        $this->renderer = $renderer;
        $this->pageReader = $pageReader;
    }
...

至此一切顺利,现在让我们的 FilePageReader 做一些真正的事。

我们需要能够传达找不到页面。为此我们能够创建一个以后可以捕获的自定义异常。在你的 src/Page 文件夹创建一个 InvalidPageException.php 文件,内容如下:

<?php declare(strict_types = 1);

namespace Example\Page;

use Exception;

class InvalidPageException extends Exception
{
    public function __construct($slug, $code = 0, Exception $previous = null)
    {
        $message = "No page with the slug `$slug` was found";
        parent::__construct($message, $code, $previous);
    }
}

然后在 FilePageReader 文件中 readBySlug 方法的末尾添加如下代码:

$path = "$this->pageFolder/$slug.md";

if (!file_exists($path)) {
    throw new InvalidPageException($slug);
}

return file_get_contents($path);

现在如果你浏览一个不存在的页面,你应该看到一个 InvalidPageException。如果文件存在,你应该看到内容。

当然向用户显示无效 URL 的异常并不合理。因此让我们捕获这个异常,然后显示一个 404 错误。

去你的 Page 控制器,然后重构 show 方法,让其看起来如下所示:

public function show($params)
{
    $slug = $params['slug'];

    try {
        $data['content'] = $this->pageReader->readBySlug($slug);
    } catch (InvalidPageException $e) {
        $this->response->setStatusCode(404);
        return $this->response->setContent('404 - Page not found');
    }

    $html = $this->renderer->render('Page', $data);
    $this->response->setContent($html);
}

确保你在文件的顶部使用 use 语句导入了 InvalidPageException

尝试访问不同的 URL 来检查是否一切按预期正常运行。如果出错了,回头调试直到正常运行。

然后和往常一样,不要忘了提交你的改动。


11、页面菜单

现在我们已经制作了几个不错的动态页面。但是没人能找到它们。

现在让我们来解决这个问题。在这一章中我们将要为所有页面创建一个带链接的菜单。

当我们有了菜单,我们希望能够在多个页面上重用相同的代码。我们可以创建一个独立的文件,然后每次都包含它,但是还有一种更好的解决方案。

拥有可以扩展其它模板的模板更加实用,比如布局。这样我们能够在单个文件中拥有所有布局相关的代码,而不必在每一个模板中包含页头和页脚文件。

我们使用的 Mustache 实现对此并不支持。我们可以编写代码来解决这个问题,但这样不仅会耗费时间还会引入一些 BUG。或许我们应该切换到已经支持这样做并且经过充分测试过的库,比如 Twig

现在你可能想知道为什么我们不从一开始就使用 Twig。因为这是个很好的示例来说明为什么使用接口以及编写松耦合代码是个好主意。就像在真实世界中,需求突然改变,现在我们的代码需要适应。

还记得在第 9 章你是如何创建 MustacheRenderer 的吗?这一次,我们创建一个 TwigRenderer,来实现同样的接口。

不过在开始之前,请使用 Composer 安装最新版本的 Twig(composer require "twig/twig:~1.0")。

然后在你的 src/Template 创建一个名为 TwigRenderer.php 文件,内容如下:

<?php declare(strict_types = 1);

namespace Example\Template;

use Twig_Environment;

class TwigRenderer implements Renderer
{
    private $renderer;

    public function __construct(Twig_Environment $renderer)
    {
        $this->renderer = $renderer;
    }

    public function render($template, $data = []) : string
    {
        return $this->renderer->render("$template.html", $data);
    }
}

如你所见,在 render 函数调用中添加了 .html。这是因为 Twig 默认情况下不会添加文件后缀,否则你必须在每次调用时指定它。像这样做之后,你能像使用 Mustache 一样使用它。

添加如下代码到你的 Dependencies.php 文件:

$injector->delegate('Twig_Environment', function () use ($injector) {
    $loader = new Twig_Loader_Filesystem(dirname(__DIR__) . '/templates');
    $twig = new Twig_Environment($loader);
    return $twig;
});

我们使用 delegate 来让函数负责创建类,而不是只定义依赖项。这在将来会很有用。

现在你能够把别名 RendererMustacheRender 切换到 TwigRenderer。现在默认会使用 Twig 而不是 Mustache。

如果你在浏览器中查看该网站,现在一切应该像之前一样运行正常。现在让我们开始创建真正的菜单。

一开始我们只是发送了一个硬编码的数组到模板。前往你的 Homepage 控制器,并如下这样更改你的 $data 数组:

$data = [
    'name' => $this->request->getParameter('name', 'stranger'),
    'menuItems' => [['href' => '/', 'text' => 'Homepage']],
];

在你的 Homepage.html 文件顶部添加如下代码:

{% for item in menuItems %}
    <a href="{{ item.href }}">{{ item.text }}</a><br>
{% endfor %}

现在如果你在浏览器中刷新首页,你将会看到一个链接。

菜单在首页生效了,但是我们想让其在所有页面中生效。我们可以将其复制到所有的模板文件中,但那样做是个馊主意。到时候,如果改动了一些东西,你将不得不去改动所有的文件。

因此我们将会使用一个可供所有模板使用的布局模板。

在你的 templates 文件夹中创建一个 Layout.html 文件,内容如下:

{% for item in menuItems %}
    <a href="{{ item['href'] }}">{{ item['text'] }}</a><br>
{% endfor %}
<hr>
{% block content %}
{% endblock %}

然后将你的 Homepage.html 文件改动如下:

{% extends "Layout.html" %}
{% block content %}
    <h1>Hello World</h1>
    Hello {{ name }}
{% endblock %}

以及将你的 Page.html 文件改动如下:

{% extends "Layout.html" %}
{% block content %}
    {{ content }}
{% endblock %}

如果如现在刷新主页,你应该看到一个菜单。但是如果你访问子页面,除了 <hr> 分割线,却不见菜单。

问题的原因是我们只把 menuItems 传给了首页。在所有页面上重复这样做有点单调乏味,如果有所改动将会有增加很多工作。因此让我们下一步来解决这个问题。

我们可以创建一个可供所有模板使用的全局变量,但是在这里不是一个好主意。我们将在未来添加此网站的不同部分,比如管理区,我们将会有不同的菜单。

因此我们将在前端使用一个自定义的 Renderer。首先我们创建一个空的接口,以扩展已存在的 Renderer 接口。

<?php declare(strict_types = 1);

namespace Example\Template;

interface FrontendRenderer extends Renderer {}

通过扩展它,我们说任何实现 FrontendRenderer 接口的类都能够在需要 Renderer 的地方被使用。而不是相反,因为 FrontendRenderer 能够拥有更多功能,只要它仍然匹配 Renderer 接口。

现在,我们当然还需要一个实现新接口的类。

<?php declare(strict_types = 1);

namespace Example\Template;

class FrontendTwigRenderer implements FrontendRenderer
{
    private $renderer;

    public function __construct(Renderer $renderer)
    {
        $this->renderer = $renderer;
    }

    public function render($template, $data = []) : string
    {
        $data = array_merge($data, [
            'menuItems' => [['href' => '/', 'text' => 'Homepage']],
        ]);
        return $this->renderer->render($template, $data);
    }
}

如你所见,我们在这个类中依赖于 Renderer 。这个类是 Renderer 的包装器,并将 menuItems 添加到所有的 $data 数组中。

当然我们还需要向 Dependencies.php 文件中添加另外一个别名。

$injector->alias('Example\Template\FrontendRenderer', 'Example\Template\FrontendTwigRenderer');

现在前往你的控制器,把所有 Renderer 的引用改成 FrontendRenderer。确保你在顶部的 use 语句和构造函数中也做了更改。

还要从 Homepage 控制器删除如下这一行:

'menuItems' => [['href' => '/', 'text' => 'Homepage']],

完成后,你应该在首页和子页面上看到菜单。

现在一切应该正常运行,但是在 FrontendTwigRenderer 定义菜单并不合理。因此让我们重构它,将其移动到它自己的类中。

菜单现在是在数组中定义的,但这很可能会在未来发生变化。可能你想要在数据库中定义,或者,甚至你想要根据可用的页面动态生成它。我们没有这方面的信息,很多事可能会在未来发生变化。

因此让我们在这些做正确的事情,仍然从一个接口开始。但是,首先在 src 目录中创建一个新文件夹,存放与菜单相关的文件。Menu 听起来像个合理的名字,不是吗?

<?php declare(strict_types = 1);

namespace Example\Menu;

interface MenuReader
{
    public function readMenu() : array;
}

我们的非常简单的实现看起来如下所示:

<?php declare(strict_types = 1);

namespace Example\Menu;

class ArrayMenuReader implements MenuReader
{
    public function readMenu() : array
    {
        return [
            ['href' => '/', 'text' => 'Homepage'],
        ];
    }
}

这只是一个可以让例子进行下去的临时解决方案。我们稍后还会再次处理它。

在我们继续之前,让我们编辑依赖文件,以确保我们的应用在请求接口时使用的是哪个实现,

return 语句之上添加如下代码:

$injector->alias('Example\Menu\MenuReader', 'Example\Menu\ArrayMenuReader');
$injector->share('Example\Menu\ArrayMenuReader');

现在你需要更改 FrontendTwigRenderer 类中硬编码的数组,以使其使用我们的新 MenuReader。在不看下面的解决方案前自己尝试一下。

你完成了还是卡住了?或者你只是偷懒了?没关系,这里有一个可用的解决方案:

<?php declare(strict_types = 1);

namespace Example\Template;

use Example\Menu\MenuReader;

class FrontendTwigRenderer implements FrontendRenderer
{
    private $renderer;
    private $menuReader;

    public function __construct(Renderer $renderer, MenuReader $menuReader)
    {
        $this->renderer = $renderer;
        $this->menuReader = $menuReader;
    }

    public function render($template, $data = []) : string
    {
        $data = array_merge($data, [
            'menuItems' => $this->menuReader->readMenu(),
        ]);
        return $this->renderer->render($template, $data);
    }
}

一切仍然正常运行吗?太棒了。提交你所有改动然后继续下一章。


12、前端

我不知道你,但是我不喜欢为一个看起来是二十年前的网站而工作。所以让我们来改进我们小应用的外观。

这不是一个前端教程,所以我们将只使用 pure 然后到此为止。

首先我们需要修改 Layout.html 模板。我不希望在 HTML 和 CSS 上浪费你的时间,所以我将只提供供你复制粘贴的代码:

<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Example</title>
        <link rel="stylesheet" href="http://yui.yahooapis.com/pure/0.6.0/pure-min.css">
        <link rel="stylesheet" href="/css/style.css">
    </head>
    <body>
        <div id="layout">
            <div id="menu">
                <div class="pure-menu">
                    <ul class="pure-menu-list">
                        {% for item in menuItems %}
                            <li class="pure-menu-item"><a href="{{ item['href'] }}" class="pure-menu-link">{{ item['text'] }}</a></li>
                        {% endfor %}
                    </ul>
                </div>
            </div>
            <div id="main">
                <div class="content">
                    {% block content %}
                    {% endblock %}
                </div>
            </div>
        </div>
    </body>
</html>

你将还需要一些自定义 CSS。我们想要这个文件公开访问。所以我们需要把它放在哪里?当然是在 public 文件夹。

但是为了保持文件有点组织,现在里面添加一个名为 css 的文件夹,然后创建一个 style.css 文件,内容如下:

body {
    color: #777;
}

#layout {
    position: relative;
    padding-left: 0;
}

#layout.active #menu {
    left: 150px;
    width: 150px;
}

#layout.active .menu-link {
    left: 150px;
}

.content {
    margin: 0 auto;
    padding: 0 2em;
    max-width: 800px;
    margin-bottom: 50px;
    line-height: 1.6em;
}

.header {
    margin: 0;
    color: #333;
    text-align: center;
    padding: 2.5em 2em 0;
    border-bottom: 1px solid #eee;
}

.header h1 {
    margin: 0.2em 0;
    font-size: 3em;
    font-weight: 300;
}

.header h2 {
    font-weight: 300;
    color: #ccc;
    padding: 0;
    margin-top: 0;
}

#menu {
    margin-left: -150px;
    width: 150px;
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    z-index: 1000;
    background: #191818;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
}

#menu a {
    color: #999;
    border: none;
    padding: 0.6em 0 0.6em 0.6em;
}

#menu .pure-menu,
#menu .pure-menu ul {
    border: none;
    background: transparent;
}

#menu .pure-menu ul,
#menu .pure-menu .menu-item-divided {
    border-top: 1px solid #333;
}

#menu .pure-menu li a:hover,
#menu .pure-menu li a:focus {
    background: #333;
}

#menu .pure-menu-selected,
#menu .pure-menu-heading {
    background: #1f8dd6;
}

#menu .pure-menu-selected a {
    color: #fff;
}

#menu .pure-menu-heading {
    font-size: 110%;
    color: #fff;
    margin: 0;
}

.header,
.content {
    padding-left: 2em;
    padding-right: 2em;
}

#layout {
    padding-left: 150px; /* left col width "#menu" */
    left: 0;
}
#menu {
    left: 150px;
}

.menu-link {
    position: fixed;
    left: 150px;
    display: none;
}

#layout.active .menu-link {
    left: 150px;
}

现在如果你再看一下网站,看起来应该更好一些了。稍后你可自行随意进一步改进它的外观。现在让我们继续这个教程。

每当你需要公开提供资源或文件时,只要将其放入你的 public 文件夹即可。对于所有类似 JavaScript 文件、CSS 文件、图片以及更多类型的资源,你也需要这样做。

到现在为止一切都还好,但是如果我们的访客能够看到他们所在的页面就更好了。

当然我们需要菜单有不止一个页面。在这个教程中,我将只使用先前创建的 page-one.md。但是你可随意再添加几个页面。

回到 ArrayMenuReaner 添加你的新页面到数组。它看看起来应该如下所示:

return [
    ['href' => '/', 'text' => 'Homepage'],
    ['href' => '/page-one', 'text' => 'Page One'],
];

To be continued…

未完待续……

恭喜。你走了这么远。

我希望你能一步一步照着教程做,而不是跳着章节看。

如果你从教程中学到一些东西,我感激你的星标。对我来说这是了解人们是否真正阅读这些文字的唯一途径。

因为这个教程如此受欢迎,它启发我写了一本书。这本书是这个教程的更新版本,包含更多内容。点击下面这个链接购买(有可阅读的样章)。

Professional PHP: Building maintainable and secure applications

感谢你的关注。

Patrick


原文:Create a PHP application without a framework