教你在不使用框架的状况下也能写出现代化 PHP 代码

file

我为大家准备了一个富有挑战性的事情。接下来大家将以 框架的方式开启一个项目之旅。php

首先声明, 这篇并不是又臭又长的反框架裹脚布文章。也不是推销 非原创 思想 。毕竟, 咱们还将在接下来的开发之旅中使用其余框架开发者编写的辅助包。我对这个领域的创新也是持无可非议的态度。html

这无关他人,而是关乎己身。做为一名开发者,它将有机会让你成长。前端

也许无框架开发令你受益不浅的地方就是,能够从底层运做的层面中汲取丰富的知识。抛却依赖神奇的,帮你处理没法调试和没法真正理解的东西的框架,你将清楚的看到这一切是如何发生的。nginx

颇有可能下一份工做中,你并不能随心因此地选择框架开拓新项目。现实就是,在不少高价值,关键业务的 PHP 工做中均使用现有应用。 而且该应用程序是否构建在当前使人舒爽的 Laravel 或 Symfony 等流行框架中,亦或是陈旧过期的 CodeIgniter 或者 FuelPHP 中,更有甚者它可能普遍出如今使人沮丧的  “面向包含体系结构” 的传统的 PHP 应用 之中,因此框架开发会在未来你所面临的任何 PHP 项目中助你一臂之力。laravel

上古时代, 由于 某些系统 不得不解释分发 HTTP 请求,发送 HTTP 响应,管理依赖关系,无框架开发就是痛苦的鏖战。缺少行业标准必然意味着,框架中的这些组件高度耦合 。若是你从无框架开始,你终将难逃自建框架的命运。git

时至今日,幸好有 PHP-FIG 完成全部的自动加载和交互工做,无框架开发并不是让你白手起家。各色供应商都有这么多优秀的可交互的软件包。把他们组合起来容易得超乎你的想象!github

PHP 是如何工做的?

在作其余事以前,搞清楚 PHP 如何与外界沟通是很是重要的。web

PHP 以请求 / 响应为周期运行服务端应用程序。与你的应用程序的每一次交互——不管是来自浏览器,命令行仍是 REST API ——都是做为请求进入应用程序的。 当接收到请求之后:数据库

  1. 程序开始启动;
  2. 开始处理请求;
  3. 产生响应;
  4. 接着,响应返回给产生请求的相应客户端;
  5. 最后程序关闭。

每个 请求都在重复以上的交互。编程

前端控制器

用这些知识把本身武装起来之后,就能够先从咱们的前端控制器开始编写程序了。前端控制器是一个 PHP 文件,它处理程序的每个请求。控制器是请求进入程序后遇到的第一个 PHP 文件,而且(本质上)也是响应走出你应用程序所通过的最后一个文件。

咱们使用经典的 Hello, world! 做为例子来确保全部东西都正确链接上,这个例子由 PHP 的内置服务器  驱动。在你开始这样作以前,请确保你已经安装了 PHP7.1 或者更高版本。

建立一个含有 public 目录的项目,而后在该目录里面建立一个index.php 文件,文件里面写入以下代码:

<?php
declare(strict_types=1);

echo 'Hello, world!';

注意,这里咱们声明了使用严格模式 —— 做为最佳实践,你应该在应用程序的 每一个 PHP 文件的开头 都这样作。由于对从你后面来的开发者来讲类型提示对 调试和清晰的交流意图很重要 。

使用命令行(好比 macOS 的终端)切换到你的项目目录并启动 PHP 的内置服务器。

php -S localhost:8080 -t public/

如今,在浏览器中打开 http://localhost:8080/ 。是否是成功地看到了 "Hello, world!" 输出?

很好。接下来咱们能够开始进入正题了!

自动加载与第三方包

当你第一次使用 PHP 时,你可能会在你的程序中使用 includes 或 requires 语句来从其余 PHP 文件导入功能和配置。 一般,咱们会避免这么干,由于这会使得其余人更难以遵循你的代码路径和理解依赖在哪里。这让调试成为了一个 真正的 噩梦。

解决办法是使用自动加载(autoloading)。 自动加载的意思是:当你的程序须要使用一个类, PHP 在调用该类的时候知道去哪里找到并加载它。虽然从 PHP 5 开始就可使用这个特性了, 可是得益于 PSR-0 ( 自动加载标准,后来被 PSR-4 取代),其使用率才开始有真正的提高。

咱们能够编写本身的自动加载器来完成任务,可是因为咱们将要使用的管理第三方依赖的  Composer 已经包含了一个完美的可用的自动加载器,那咱们用它就好了。

确保你已经在你的系统上 安装 了 Composer。而后为此项目初始化 Composer:

composer init

这条命令经过交互式引导你建立 composer.json 配置文件。 一旦文件建立好了,咱们就能够在编辑器中打开它而后向里面写入 autoload 字段,使他看起来像这个样子(这确保了自动加载器知道从哪里找到咱们项目中的类):

{
    "name": "kevinsmith/no-framework",
    "description": "An example of a modern PHP application bootstrapped without a framework.",
    "type": "project",
    "require": {},
    "autoload": {
        "psr-4": {
            "ExampleApp\\": "src/"
        }
    }
}

如今为此项目安装 composer,它引入了依赖(若是有的话),并为咱们建立好了自动加载器:

composer install

更新 public/index.php 文件来引入自动加载器。在理想状况下,这将是你在程序当中使用的少数『包含』语句之一。

<?php
declare(strict_types=1);

require_once dirname(__DIR__) . '/vendor/autoload.php';

echo 'Hello, world!';

此时若是你刷新浏览器,你将不会看到任何变化。由于自动加载器没有修改或者输出任何数据,因此咱们看到的是一样的内容。让咱们把 Hello, world! 这个例子移动到一个已经自动加载的类里面看看它是如何运做的。

在项目根目录建立一个名为 src 的目录,而后在里面添加一个叫 HelloWorld.php 的文件,写入以下代码:

<?php
declare(strict_types=1);

namespace ExampleApp;

class HelloWorld
{
    public function announce(): void
    {
        echo 'Hello, autoloaded world!';
    }
}

如今到 public/index.php 里面用  HelloWorld 类的 announce 方法替换掉 echo 语句。

// ...

require_once dirname(__DIR__) . '/vendor/autoload.php';

$helloWorld = new \ExampleApp\HelloWorld();
$helloWorld->announce();

刷新浏览器查看新的信息!

什么是依赖注入?

依赖注入是一种编程技术,每一个依赖项都供给它须要的对象,而不是在对象外得到所需的信息或功能。

举个例子,假设应用中的类方法须要从数据库中读取。为此,你须要一个数据库链接。经常使用的技术就是建立一个全局可见的新链接。

class AwesomeClass
{
    public function doSomethingAwesome()
    {
        $dbConnection = return new \PDO(
            "{$_ENV['type']}:host={$_ENV['host']};dbname={$_ENV['name']}",
            $_ENV['user'],
            $_ENV['pass']
        );

        // Make magic happen with $dbConnection
    }
}

可是这样作显得很乱,它把一个并不是属于这里的职责置于此地---建立一个 数据库链接对象  检查凭证 , 还有 处理一些链接失败的问题---它会致使应用中出现 大量  重复代码。若是你尝试对这个类进行单元测试,会发现根本不可行。这个类和应用环境以及数据库高度耦合。

相反,为什么不一开始就搞清楚你的类须要什么?咱们只须要首先将 “PDO” 对象注入该类便可。

class AwesomeClass
{
    private $dbConnection;

    public function __construct(\PDO $dbConnection)
    {
        $this->dbConnection = $dbConnection;
    }

    public function doSomethingAwesome()
    {
        // Make magic happen with $this->dbConnection
    }
}

这样更简洁清晰易懂,且更不易产生 Bug。经过类型提示和依赖注入,该方法能够清楚准确地声明它要作的事情,而无需依赖外部调用去获取。在作单元测试的时候,咱们能够很好地模拟数据库链接,并将其传入使用。

依赖注入容器 是一个工具,你能够围绕整个应用程序来处理建立和注入这些依赖关系。容器并不须要可以使用依赖注入技术,但随着应用程序的增加并变得更加复杂,它将大有裨益。

咱们将使用 PHP 中最受欢迎的 DI 容器之一:名副其实的 PHP-DI(值得推荐的是它文档中的 依赖注入另解 可能会对读者有所帮助)

依赖注入容器

如今咱们已经安装了 Composer ,那么安装 PHP-DI 就垂手可得了,咱们继续回到命令行来搞定它。

composer require php-di/php-di

修改 public/index.php 用来配置和构建容器。

// ...

require_once dirname(__DIR__) . '/vendor/autoload.php';

$containerBuilder = new \DI\ContainerBuilder();
$containerBuilder->useAutowiring(false);
$containerBuilder->useAnnotations(false);
$containerBuilder->addDefinitions([
    \ExampleApp\HelloWorld::class => \DI\create(\ExampleApp\HelloWorld::class)
]);

$container = $containerBuilder->build();

$helloWorld = $container->get(\ExampleApp\HelloWorld::class);
$helloWorld->announce();

没啥大不了的。它还是一个单文件的简单示例,你很容易能看清它是怎么运行的。

迄今为止, 咱们只是在 配置容器 ,因此咱们必须 显式地声明依赖关系 (而不是使用 自动装配 或 注解),而且从容器中检索 HelloWorld 对象。

小贴士:自动装配在你开始构建应用程序的时候是一个很不错的特性,可是它隐藏了依赖关系,难以维护。 颇有可能在接下里的岁月里, 另外一个开发者在不知情的情况下引入了一个新库,而后就造就了多个库实现一个单接口的局面,这将会破坏自动装配,致使一系列让接手者很容易忽视的的不可见的问题。

尽可能 引入命名空间,能够增长代码的可读性。

<?php
declare(strict_types=1);

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use function DI\create;

require_once dirname(__DIR__) . '/vendor/autoload.php';

$containerBuilder = new ContainerBuilder();
$containerBuilder->useAutowiring(false);
$containerBuilder->useAnnotations(false);
$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)
]);

$container = $containerBuilder->build();

$helloWorld = $container->get(HelloWorld::class);
$helloWorld->announce();

如今看来,咱们好像是把之前已经作过的事情再拿出来小题大作。

毋需烦心,当咱们添加其余工具来帮助咱们引导请求时,容器就有用武之地了。它会在适当的时机下按需加载正确的类。

中间件

若是把你的应用想象成一个洋葱,请求从外部进入,到达洋葱中心,最后变成响应返回出去。那么中间件就是洋葱的每一层。它接收请求而且能够处理请求。要么把请求传递到更里层,要么向更外层返回一个响应(若是中间件正在检查请求不知足的特定条件,好比请求一个不存在的路由,则可能发生这种状况)。

若是请求经过了全部的层,那么程序就会开始处理它并把它转换为响应,中间件接收到响应的顺序与接收到请求的顺序相反,而且也能对响应作修改,而后再把它传递给下一个中间件。

下面是一些中间件用例的闪光点:

  • 在开发环境中调试问题
  • 在生产环境中优雅的处理异常
  • 对传入的请求进行频率限制
  • 对请求传入的不支持资源类型作出响应
  • 处理跨域资源共享(CORS)
  • 将请求路由到正确的处理类

那么中间件是实现这些功能的惟一方式吗?固然不是。可是中间件的实现使得你对请求 / 响应这个生命周期的理解更清晰。这也意味着你调试起来更简单,开发起来更快速。

咱们将从上面列出的最后一条用例,也就是路由,当中获益。

路由

路由依靠传入的请求信息来肯定应当由哪一个类来处理它。(例如 URI  /products/purple-dress/medium 应该被  ProductDetails::class类接收处理,同时 purple-dress 和 medium 做为参数传入)

在范例应用中,咱们将使用流行的 FastRoute 路由,基于 PSR-15兼容的中间件实现

中间件调度器

为了让咱们的应用能够和 FastRoute 中间件---以及咱们安装的其余中间件协同工做---咱们须要一个中间件调度器。

PSR-15是为中间件和调度器定义接口的中间件标准(在规范中又称“请求处理器”),它容许各式各样的中间件和调度器互相交互。咱们只需选择兼容 PSR-15 的调度器,这样就能够确保它能和任何兼容 PSR-15 的中间件协同工做。

咱们先安装一个 Relay 做为调度器。

composer require relay/relay:2.x@dev

并且根据 PSR-15 的中间件标准要求实现可传递 兼容 PSR-7 的 HTTP 消息, 咱们使用 Zend Diactoros 做为 PSR-7 的实现。

composer require zendframework/zend-diactoros

咱们用 Relay 去接收中间件。

// ...

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use Relay\Relay;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;

// ...

$container = $containerBuilder->build();

$middlewareQueue = [];

$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

咱们在第 16 行使用 ServerRequestFactory::fromGlobals()  把 建立新请求的必要信息合并起来 而后把它传给 Relay。 这正是 Request 进入咱们中间件堆栈的起点。

如今咱们继续添加 FastRoute 和请求处理器中间件。 ( FastRoute 肯定请求是否合法,究竟可否被应用程序处理,而后请求处理器发送 Request 到路由配置表中已注册过的相应处理程序中)

composer require middlewares/fast-route middlewares/request-handler

而后咱们给 Hello, world! 处理类定义一个路由。咱们在此使用 /hello 路由来展现基本 URI 以外的路由。

// ...

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use FastRoute\RouteCollector;
use Middlewares\FastRoute;
use Middlewares\RequestHandler;
use Relay\Relay;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;
use function FastRoute\simpleDispatcher;

// ...

$container = $containerBuilder->build();

$routes = simpleDispatcher(function (RouteCollector $r) {
    $r->get('/hello', HelloWorld::class);
});

$middlewareQueue[] = new FastRoute($routes);
$middlewareQueue[] = new RequestHandler();

$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

为了能运行,你还须要修改 HelloWorld 使其成为一个可调用的类, 也就是说 这里类能够像函数同样被随意调用.

// ...

class HelloWorld
{
    public function __invoke(): void
    {
        echo 'Hello, autoloaded world!';
        exit;
    }
}

(注意在魔术方法 __invoke() 中加入exit;。 咱们只需1秒钟就能搞定--只是不想让你遗漏这个事)

如今打开 http://localhost:8080/hello ,开香槟吧!

万能胶水

睿智的读者可能很快看出,虽然咱们仍旧囿于配置和构建 DI 容器的藩篱之中,容器如今实际上对咱们毫无用处。调度器和中间件在没有它的状况下也同样运做。

那它什么时候才能发挥威力?

嗯,若是---在实际应用程序中老是如此---HelloWorld类具备依赖关系呢?

咱们来说解一个简单的依赖关系,看看究竟发生了什么。

// ...

class HelloWorld
{
    private $foo;

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

    public function __invoke(): void
    {
        echo "Hello, {$this->foo} world!";
        exit;
    }
}

刷新浏览器..

WOW!

看下这个 ArgumentCountError.

发生这种状况是由于 HelloWorld 类在构造的时候须要注入一个字符串才能运行,在此以前它只能等着。  正是容器要帮你解决的痛点。
咱们在容器中定义该依赖关系,而后将容器传给 RequestHandler 去 解决这个问题.

// ...

use Zend\Diactoros\ServerRequestFactory;
use function DI\create;
use function DI\get;
use function FastRoute\simpleDispatcher;

// ...

$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)
        ->constructor(get('Foo')),
    'Foo' => 'bar'
]);

$container = $containerBuilder->build();

// ...

$middlewareQueue[] = new FastRoute($routes);
$middlewareQueue[] = new RequestHandler($container);

$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

嗟夫!当刷新浏览器的时候, "Hello, bar world!"将映入你的眼帘!

正确地发送响应

是否还记得我以前提到过的位于 HelloWorld 类中的 exit 语句?

当咱们构建代码时,它可让咱们简单粗暴的得到响应,可是它绝非输出到浏览器的最佳选择。这种粗暴的作法给 HelloWorld 附加了额外的响应工做---其实应该由其余类负责的---它会过于复杂的发送正确的头部信息和 状态码,而后马上退出了应用,使得 HelloWorld 以后 的中间件也无机会运行了。

记住,每一个中间件都有机会在 Request 进入咱们应用时修改它,而后 (以相反的顺序) 在响应输出时修改响应。 除了 Request 的通用接口, PSR-7 一样也定义了另一种 HTTP 消息结构,以辅助咱们在应用运行周期的后半部分之用:Response。(若是你想真正了解这些细节,请阅读 HTTP 消息以及什么让 PSR-7 请求和响应标准如此之好。)

修改 HelloWorld 返回一个 Response

// ...

namespace ExampleApp;

use Psr\Http\Message\ResponseInterface;

class HelloWorld
{
    private $foo;

    private $response;

    public function __construct(
        string $foo,
        ResponseInterface $response
    ) {
        $this->foo = $foo;
        $this->response = $response;
    }

    public function __invoke(): ResponseInterface
    {
        $response = $this->response->withHeader('Content-Type', 'text/html');
        $response->getBody()
            ->write("<html><head></head><body>Hello, {$this->foo} world!</body></html>");

        return $response;
    }
}

而后修改容器给 HelloWorld 提供一个新的 Response 对象。

// ...

use Middlewares\RequestHandler;
use Relay\Relay;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;

// ...

$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)
        ->constructor(get('Foo'), get('Response')),
    'Foo' => 'bar',
    'Response' => function() {
        return new Response();
    },
]);

$container = $containerBuilder->build();

// ...

若是你如今刷新页面,会发现一片空白。咱们的应用正在从中间件调度器返回正确的 Response 对象,可是... 肿么回事?

它啥都没干,就这样。

咱们还须要一件东西来包装下:发射器。发射器位于应用程序和 Web 服务器(Apache,nginx等)之间,将响应发送给发起请求的客户端。它实际上拿到了 Response 对象并将其转化为 服务端 API 可理解的信息。

好消息! 咱们已经用来封装请求的 Zend Diactoros 包一样也内置了发送 PSR-7 响应的发射器。

值得注意的是,为了举例,咱们只是对发射器的使用小试牛刀。虽然它们可能会更复杂点,真正的应用应该配置成自动化的流式发射器用来应对大量下载的状况, Zend 博客展现了如何实现它

修改 public/index.php ,用来从调度器那里接收 Response ,而后传给发射器。

// ...

use Relay\Relay;
use Zend\Diactoros\Response;
use Zend\Diactoros\Response\SapiEmitter;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;

// ...

$requestHandler = new Relay($middlewareQueue);
$response = $requestHandler->handle(ServerRequestFactory::fromGlobals());

$emitter = new SapiEmitter();
return $emitter->emit($response);

刷新浏览器,业务恢复了!此次咱们用了一种更健壮的方式来处理响应。

以上代码的第 15 行是咱们应用中请求/响应周期结束的地方,同时也是 web 服务器接管的地方。

总结

如今你已经得到了现代化的 PHP 代码。 仅仅 44 行代码,在几个被普遍使用,通过全面测试和拥有可靠互操做性的组件的帮助下,咱们就完成了一个现代化 PHP 程序的引导。它兼容 PSR-4, PSR-7PSR-11 以及 PSR-15,这意味着你可使用本身选择的其余任一供应商对这些标准的实现,来构建本身的 HTTP 消息, DI 容器,中间件,还有中间件调度器。

咱们深刻理解了咱们决策背后使用的技术和原理,但我更但愿你能明白,在没有框架的状况下,引导一个新的程序是多么简单的一件事。或许更重要的是,我但愿在有必要的时候你能更好的把这些技术运用到已有的项目中去。

你能够在 这个例子的 GitHub 仓库 上免费 fork 和下载它。

若是你正在寻找更高质量的解耦软件包资源,我衷心推荐你看看 Aura, 了不得的软件包联盟, Symfony 组件, Zend Framework 组件Paragon 计划的聚焦安全的库, 还有这个 关于 PSR-15 中间件的清单.

若是你想把这个例子的代码用到生产环境中, 你可能须要把路由和 容器定义 分离到它们各自的文件里面,以便未来项目复杂度提高的时候更好维护。我也建议 实现 EmitterStack 来更好的处理文件下载以及其余的大量响应。

若是有任何问题,疑惑或者建议,请 给我留言

更多现代化 PHP 知识,请前往 Laravel / PHP 知识社区
相关文章
相关标签/搜索