PHP8-MVC-高级教程-四-

38 阅读25分钟

PHP8 MVC 高级教程(四)

原文:Pro PHP 8 MVC

协议:CC BY-NC-SA 4.0

九、测试我们的框架

在前一章中,我们构建了一个服务定位器和依赖注入容器,这样我们就可以共享框架的各个部分,而无需多余的样板文件。

在这一章中,我们将学习如何测试新的代码,以及如何构建我们的应用和测试,从而使我们投入的时间获得最大的收益。

在章节之间…

我想快速介绍一下自上一章以来我所做的一些改变。依赖注入容器是一个强大的工具,因为它减少了代码的重复,使构建框架代码变得更加容易。

使用容器,我能够将验证代码移动到一个新的提供者:

namespace Framework\Provider;

use Framework\App;
use Framework\Validation\Manager;
use Framework\Validation\Rule\RequiredRule;
use Framework\Validation\Rule\EmailRule;
use Framework\Validation\Rule\MinRule;

class ValidationProvider
{
    public function bind(App $app)
    {
        $app->bind('validator', function($app) {
            $manager = new Manager();

            $this->bindRules($app, $manager);

            return $manager;
        });
    }

    private function bindRules(App $app, Manager $manager)
    {
        $app->bind('validation.rule.required', fn() => new RequiredRule());
        $app->bind('validation.rule.email', fn() => new EmailRule());
        $app->bind('validation.rule.min', fn() => new MinRule());

        $manager->addRule('required', $app->resolve('validation.rule.required'));
        $manager->addRule('email', $app->resolve('validation.rule.email'));
        $manager->addRule('min', $app->resolve('validation.rule.min'));
    }
}

这是来自framework/Provider/ValidationProvider.php

我喜欢将单个规则绑定到容器上(就像上一章我们对视图引擎所做的那样),因为它们可以扩展、修饰和重新配置。

例如,我们可以解决电子邮件验证规则,改变它的工作方式,或者覆盖它以提供一些新的逻辑。如果没有办法把它从容器中取出来,那就不那么简单了。

由于我们改变了路由(使用容器来解析处理程序),我也可以删除每个路由动作构造器方法。我不会展示所有这些变化,但它们类似于以下内容:

namespace App\Http\Controllers\Products;

use App\Models\Product;
use Framework\Routing\Router;

class ShowProductController
{
    // protected Router $router;

    // public function __construct(Router $router)
    // {
    //     $this->router = $router;
    // }

    public function handle(Router $router)
    {
        $parameters = $router->current()->parameters();

        $product = Product::find((int) $parameters['product']);

        return view('products/view', [
            'product' => $product,
            'orderAction' => $router->route('order-product', ['product' => $product->id]),
            'csrf' => csrf(),
        ]);
    }
}

这是来自app/Http/Controllers/Products/ShowProductcontroller.php

这些是对代码库的唯一更改。您是否尝试过将验证管理器配置转移到它自己的提供者?对我来说这是一次有趣的经历…

为什么我们要测试我们的代码?

在我们研究测试的实用性之前,重要的是要考虑我们为什么要测试。测试有多种形式,毫无疑问,你已经在做一种形式的测试了。

最常见的是在编码时保持浏览器或终端标签打开,并定期刷新浏览器或运行与编码内容相关的脚本。

这是手工测试,没有什么特别的问题。如果这是你唯一做的测试,你可能会错过一些重要的东西:

  • 你还记得你需要测试的所有东西吗?

  • 你的测试有效率吗?

  • 您是否有最新的文档向团队中的其他人解释什么需要测试?

如果没有我们将在本章中探讨的那种测试,这些事情是很难实现的。

我们将要探索的这种测试叫做自动化测试。通过设计,它解决了前面列出的每个问题。自动化测试是当你写代码来测试你的其他代码时发生的事情,这些代码可以在尽可能少的交互和尽可能多的系统上运行。

不同类型的测试

围绕不同种类的测试有很多信息和困惑。我们将探讨一对夫妇,我将把他们称为“单元”和“集成”

这两者之间的主要区别是“单元”测试针对的是很小一部分代码,并且尽可能少地依赖于其他部分。另一方面,“集成”测试是关于测试一些东西,就像我们手工做的一样。如果您是测试新手,那么这可能会有点混乱。

在相关的地方,我一定会描述我们正在编写什么样的测试,以及为什么它们是那种测试。总是知道或关心测试的种类并不是非常重要,但是当与其他开发人员谈论测试时,知道一些术语是有帮助的。

我们将要测试的方法

我们已经写了相当多的代码,所以还有很多要测试。在这一章中,我将检查一些我想测试的东西,剩下的留给你自己去测试。以下是我希望我们涵盖的内容:

  1. 测试验证库,以确保返回正确的消息,并在适当的时候引发错误

  2. 测试路由库,以确保路由被适当地分派,并且从容器中解析依赖性

  3. 测试那各种嗖!网站页面正常工作,注册和登录页面正常工作

这个列表可能看起来很小,但是有大量的工作要做。和第六章一样,我们将会看到存在哪些流行的测试库,以及为什么把我们的时间集中在为它们构建测试和助手上是明智的,而不是重新发明一个完整的测试库。

我们开始吧!

把这一切放在一起

测试是一个很大的话题,但是大多数测试分三步进行:

  1. 设置测试开始时的条件

  2. 计算预期的结果是什么

  3. 运行您希望生成预期结果的代码,并将结果与预期进行比较

每一个好的测试框架都会让这些事情变得更容易。我们可以用代码描述这些步骤,如下所示:

// tests use framework classes...
require __DIR__ . '/../vendor/autoload.php';

// validation manager uses $_SESSION...
session_start();

use Framework\Validation\Manager;
use Framework\Validation\Rule\EmailRule;
use Framework\Validation\ValidationException;

class ValidationTest
{
    protected Manager $manager;

    public function setUp()
    {
        $this->manager = new Manager();
        $this->manager->addRule('email', new EmailRule());
    }

    public function testInvalidEmailValuesFail()
    {
        $this->setUp();

        $expected = ['email' => ['email should be an email']];

        try {
            $this->manager->validate(['email' => 'foo'], ['email' => ['email']]);
        }
        catch (Throwable $e) {
            assert($e instanceof ValidationException, 'error should be thrown');
            assert($e->getErrors()['email'] === $expected['email'], 'messages should match');
            return;
        }

        throw new Exception('validation did not fail');
    }

    public function testValidEmailValuesPass()
    {
        $this->setUp();

        try {
            $this->manager->validate(['email' => 'foo@bar.com'], ['email' => ['email']]);
        }
        catch (Throwable $e) {
            throw new Exception('validation did failed');
            return;
        }

    }
}

$test = new ValidationTest();
$test->testInvalidEmailValuesFail();
$test->testValidEmailValuesPass();

print 'All tests passed' . PHP_EOL;

这是来自tests/ValidationTest.php

这里发生了很多事情,所以让我们把它分成更小的部分:

  • 我们有两个测试,第一个确保验证器在无效邮件通过时抛出一个ValidationException,第二个确保有效邮件不会触发邮件验证异常。

  • 两个测试都期望一个验证Manager,并添加了email规则,这是我们在setUp方法中设置的。

  • 第一个测试明确声明了预期,即ValidationException中会有一个电子邮件错误。

  • 第二个测试隐式地声明了期望,即当有效的电子邮件地址被验证时不会发生错误。

  • 我称之为可接受的测试代码库的一小部分,使这成为一个单元测试。

你将会看到的一件事是,测试通常会比它所测试的代码花费更多的代码。那是因为好的测试测试的不仅仅是“快乐之路”好的测试还需要测试广泛的故障条件…

“快乐之路”是一个短语,意思是通过一个接口或一段代码的路径,其中接口或代码被完全按照预期使用。

addIntegerToInteger这样的方法可能需要两个整数,并返回两个整数的和,所以“快乐的路径”是当有人用两个整数调用它并希望这两个数字相加时。

用两个字符串调用它不是“好方法”,期望该方法执行乘法也不是。

有些事情我们可以留给静态分析工具去做——比如输入是正确的类型。其他的事情对于静态分析来说更难解决,而这些肯定是我们应该在测试中涉及的事情。

正如我所说的,每一个好的测试框架都会使这些步骤变得更容易。他们会做一些有用的事情,比如自动加载你的框架和应用代码,而不需要你调用require

他们将确保像setUp这样的方法在每次测试前运行。他们将运行测试方法(通常寻找前缀,就像我们用testX添加的一样),这样我们就不需要创建新的测试类实例并手动调用这些方法。

一些测试框架会使分离代码单元、进行测试或者创建虚假的依赖变得更加容易。

正如我们所了解的,对于我们为数据库的库添加的控制台命令,有些东西不值得我们花费时间去构建。

如果你喜欢从头开始创建你自己的测试库,那就去做吧!看看 PHPUnit 做了什么,并从我们刚刚看到的代码中进行推断。即使我们从使用 PHPUnit 开始,我们也有大量的工作要做,比如允许来自测试的请求通过路由,然后检查响应。

让我们安装 PHPUnit 并设置它需要运行的配置文件:

composer require --dev phpunit/phpunit

PHPUnit 的配置文件是一个 XML 文件,它定义了运行哪些测试文件,以及其他内容:

<phpunit
    backupGlobals="true"
    bootstrap="vendor/autoload.php"
    colors="true"
>
    <testsuites>
        <testsuite name="Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

这是来自phpunit.xml

这是完整配置的精简版: 3。XML 配置文件–PHPUnit 9。5 手动。通过指定tests目录,PHPUnit 将查找以Test.php结尾的文件(默认情况下)。

PHPUnit 附带了一个类,该类提供了一些断言助手方法。我们可以扩展该类并删除一些现有的样板文件:

// tests use framework classes...
// require __DIR__ . '/../vendor/autoload.php';

// validation manager uses $_SESSION...
session_start();

use Framework\Validation\Manager;
use Framework\Validation\Rule\EmailRule;
use Framework\Validation\ValidationException;

class ValidationTest extends \PHPUnit\Framework\TestCase
{
    protected Manager $manager;

    public function testInvalidEmailValuesFail()
    {
        $manager = new Manager();
        $manager->addRule('email', new EmailRule());

        $expected = ['email' => ['email should be an email']];

        try {
            $manager->validate(['email' => 'foo'], ['email' => ['email']]);
        }
        catch (Throwable $e) {
            assert($e instanceof ValidationException, 'error should be thrown');
            assert($e->getErrors()['email'] === $expected['email'], 'messages should match');
            return;
        }

        throw new Exception('validation did not fail');
    }

    public function testValidEmailValuesPass()
    {
        $manager = new Manager();
        $manager->addRule('email', new EmailRule());

        try {
            $manager->validate(['email' => 'foo@bar.com'], ['email' => ['email']]);
        }
        catch (Throwable $e) {
            throw new Exception('validation did failed');
            return;
        }
    }
}

// $test = new ValidationTest();
// $test->testInvalidEmailValuesFail();
// $test->testValidEmailValuesPass();

// print 'All tests passed' . PHP_EOL;

这是来自tests/ValidationTest.php

TestCase类自动调用setUp,测试运行器自动调用所有前缀为testX的方法。

这些测试方法仍然非常混乱,因为我们在处理异常。我们只需要第一个方法中的异常,但是也许我们可以进一步精简代码。

不过,在此之前,让我们先进行测试:

vendor/bin/phpunit

PHPUnit 没有显示测试失败,但也没有显示成功。它有方法表明当某些代码运行时会出现异常,但是它没有一种简单的方法来查看该异常包含的内容(就异常消息和嵌套数据而言)。

让我们创建一个框架助手类,使检查异常更容易:

namespace Framework\Testing;

use Closure;
use Exception;
use PHPUnit\Framework\TestCase as BaseTestCase;
use Throwable;

class TestCase extends BaseTestCase
{
    protected function assertExceptionThrown(Closure $risky, string $exceptionType)
    {
        $result = null;
        $exception = null;

        try {
            $result = $risky();
            $this->fail('exception was not thrown');
        }
        catch (Throwable $e) {
            $actualType = $e::class;

            if ($actualType !== $exceptionType) {
                $this->fail("exception was {$actualType}, but expected {$exceptionType}");
            }

            $exception = $e;
        }

        return [$exception, $result];
    }
}

这是来自framework/Testing/TestCase.php

这个新的assertExceptionThrown运行一个函数并记录抛出的异常和方法的结果。如果没有抛出异常,正在运行的测试将失败。

$result只有在没有抛出预期的异常时才有用。它只是为了帮助调试,以防有风险的闭包没有产生期望的异常。

这使得testValidEmailValuesPass更加干净:

public function testInvalidEmailValuesFail()
{
    $expected = ['email' => ['email should be an email']];

    // try {

    // }
    // catch (Throwable $e) {
    //     assert($e instanceof ValidationException, 'error should be thrown');
    //     assert($e->getErrors()['email'] === $expected['email'], 'messages should match');
    //     return;
    // }

    [ $exception ] = $this->assertExceptionThrown(
        fn() => $this->manager->validate(['email' => 'foo'], ['email' => ['email']]),
        ValidationException::class,
    );

    $this->assertEquals($expected, $exception->getErrors());

    // throw new Exception('validation did not fail');
}

这是来自tests/ValidationTest.php

我们还使用 PHPUnit 的assertEquals方法来比较完整的期望值和异常中返回的错误的完整数组。让我们添加一个 Composer 脚本来更快地运行测试:

"scripts": {
    "serve": "php -S 127.0.0.1:8000 -t public",
    "test": "vendor/bin/phpunit"
},

这是来自composer.json

现在,我们可以用composer test运行测试。结果看起来超级酷,但目前只有一个测试通过了。

img/299823_2_En_9_Figa_HTML.jpg

用 PHPUnit 运行测试

让我们将下一个测试改为使用 PHPUnit 的断言。我们不妨只检查validate方法的返回值:

public function testValidEmailValuesPass()
{
    // try {
    //     $this->manager->validate(['email' => 'foo@bar.com'], ['email' => ['email']]);
    // }
    // catch (Throwable $e) {
    //     throw new Exception('validation did failed');
    //     return;
    // }

    $data = $this->manager->validate(['email' => 'foo@bar.com'], ['email' => ['email']]);
    $this->assertEquals($data['email'], 'foo@bar.com');
}

这是来自tests/ValidationTest.php

为我们已经拥有的另外两个验证规则添加测试是很好的,但是它们看起来将与这些非常相似。也许你可以把它们加在章节之间?(轻推和眨眼。)

测试 HTTP 请求

让我们继续测试 routes 加载正确的页面,没有错误。我们可以通过实例化路由来做到这一点(就像我们刚刚为验证而做的单元测试),但我认为这是向应用发出请求的好机会。

对于设置,我们需要启动应用。然后,我们可以伪造对它的 HTTP 请求:

use Framework\App;

class RoutingTest extends Framework\Testing\TestCase
{
    protected App $app;

    public function setUp(): void
    {
        parent::setUp();

        $this->app = App::getInstance();
        $this->app->bind('paths.base', fn() => __DIR__ . '/../');
    }

    public function testHomePageIsShown()
    {
        $_SERVER['REQUEST_METHOD'] = 'GET';
        $_SERVER['REQUEST_URI'] = '/';

        ob_start();
        $this->app->run();
        $html = ob_get_contents();
        ob_end_clean();

        $expected = 'Take a trip on a rocket ship';

        $this->assertStringContainsString($expected, $html);
    }
}

这是来自tests/RoutingTest.php

路由使用REQUEST_METHODREQUEST_URI来计算选择哪条路由。我们可以输入自己的值——就像这些值是从浏览器发送的一样——路由将选择归属路由。

我们可以伪造更复杂的请求,比如我们想测试验证错误消息是否被正确显示:

public function testRegistrationErrorsAreShown()
{
    $_SERVER['REQUEST_METHOD'] = 'POST';
    $_SERVER['REQUEST_URI'] = '/register';
    $_SERVER['HTTP_REFERER'] = '/register';

    $_POST['email'] = 'foo';
    $_POST['csrf'] = csrf();

    $expected = 'email should be an email';

    $this->assertStringContainsString($expected, $this->app->run());
}

这是来自tests/RoutingTest.php

可惜,这是行不通的。从理论上来说,应该是这样,因为我们在为注册动作伪造表单提交的理想条件。问题是应用已经在发送消息头,可能与启动会话有关。

img/299823_2_En_9_Figb_HTML.jpg

邮件头已发送…

PHP 不喜欢我们在任何文本已经发送到浏览器或终端之后再发送标题。这个问题的解决方案并不简单。

我们基本上需要开始将我们的响应包装在响应对象中,我们可以检查这些对象以查看它们包含的内容,而无需将它们的文本内容发送到浏览器或终端。

让我们创建这个新的Response类,并将其绑定到容器:

namespace Framework\Http;

use InvalidArgumentException;

class Response
{
    const REDIRECT = 'REDIRECT';
    const HTML = 'HTML';
    const JSON = 'JSON';

    private string $type = 'HTML';
    private ?string $redirect = null;
    private mixed $content = '';
    private int $status = 200;
    private array $headers = [];

    public function content(mixed $content = null): mixed
    {
        if (is_null($content)) {
            return $this->content;
        }

        $this->content = $content;

        return $this;
    }

    public function status(int $code = null): int|static
    {
        if (is_null($code)) {
            return $this->code;
        }

        $this->code = $code;

        return $this;
    }

    public function header(strign $key, string $value): static
    {
        $this->headers[$key] = $value;
        return $this;
    }

    public function redirect(string $redirect = null): mixed
    {
        if (is_null($redirect)) {
            return $this->redirect;
        }

        $this->redirect = $redirect;
        $this->type = static::REDIRECT;
        return $this;
    }

    public function json(mixed $content): static
    {
        $this->content = $content;
        $this->type = static::JSON;
        return $this;
    }

    public function type(string $type = null): string|static
    {
        if (is_null($type)) {
            return $this->type;
        }

        $this->type = $type;

        return $this;
    }

    public function send(): string
    {
        foreach ($this->headers as $key => $value) {
            header("{$key}: {$value}");
        }

        if ($this->type === static::HTML) {
            header('Content-Type: text/html');
            http_response_code($this->status);
            print $this->content;
        }

        if ($this->type === static::JSON) {
            header('Content-Type: application/json');
            http_response_code($this->status);
            print json_encode($this->content);
        }

        if ($this->type === static::REDIRECT) {
            header("Location: {$this->redirect}");
            http_response_code($this->code);
        }

        throw new InvalidArgumentException("{$this->type} is not a recognised type");
    }
}

这是来自framework/Http/Response.php

我认为支持三种最常见的响应类型是个好主意:HTML、JSON 和重定向响应。这里,我们遵循以前遵循的模式,即将 getters 和 setters 混合在一起。这种情况下特别有效,因为我们在每个控制器中发送响应。

这个类和我们之前采用的方法之间的一个重要区别是,没有任何东西被自动发送到浏览器。我们必须调用send方法来实现这一点。

让我们将这个类绑定到容器,这样我们就可以在应用的其余部分使用它:

namespace Framework\Provider;

use Framework\App;
use Framework\Http\Response;

class ResponseProvider
{
    public function bind(App $app)
    {
        $app->bind('response', function($app) {
            return new Response();
        });
    }
}

这是来自framework/Provider/ResponseProvider.php

你可能在想:“坚持住。回应不是一种服务。我们为什么要将它绑定为服务?”

它不是,这是一种奇怪的传递方式,但它是我们希望能够重用的东西,所以我们在应用的生命周期中添加到现有的响应对象。

现在,我们可以使用response绑定来包装所有响应:

public function run()
{
    if (session_status() !== PHP_SESSION_ACTIVE) {
        session_start();
    }

    $basePath = $this->resolve('paths.base');

    $this->configure($basePath);
    $this->bindProviders($basePath);

    return $this->dispatch($basePath);
}

private function dispatch(string $basePath): Response
{
    $router = new Router();

    $this->bind(Router::class, fn() => $router);

    $routes = require "{$basePath}/app/routes.php";
    $routes($router);

    $response = $router->dispatch();

    if (!$response instanceof Response) {
        $response = $this->resolve('response')->content($response);
    }

    return $response;
}

这是来自framework/App.php

如果控制器返回的不是这个响应类,我们可以假设这个值应该是发送到浏览器的 HTML。最后,我们应该更改public.php入口点,以便它发送响应:

$app = \Framework\App::getInstance();
$app->bind('paths.base', fn() => __DIR__ . '/../');
$app->run()->send();

这是来自public/index.php

花点时间启动服务器(你可以用composer serve来完成),确保你仍然可以看到所有的页面。

我们还可以重构重定向助手来使用这种新的重定向方法:

if (!function_exists('response')) {
    function response()
    {
        return app('response');
    }
}

if (!function_exists('redirect')) {
    function redirect(string $url)
    {
        return response()->redirect($url);
    }
}

这是来自framework/helpers.php

测试也需要更新以使用这种新的run方法:

public function testHomePageIsShown()
{
    $_SERVER['REQUEST_METHOD'] = 'GET';
    $_SERVER['REQUEST_URI'] = '/';

    $expected = 'Take a trip on a rocket ship';

    $this->assertStringContainsString($expected, $this->app->run()->content());
}

public function testRegistrationErrorsAreShown()
{
    $_SERVER['REQUEST_METHOD'] = 'POST';
    $_SERVER['REQUEST_URI'] = '/register';
    $_SERVER['HTTP_REFERER'] = '/register';

    $_POST['email'] = 'foo';
    $_POST['csrf'] = csrf();

    $expected = 'email should be an email';

    $this->assertStringContainsString($expected, $this->app->run()->content());
}

这是来自tests/RoutingTest.php

有了这个新的抽象,错误就不再是在内容之后发送标题了。

img/299823_2_En_9_Figc_HTML.jpg

新的错误

现在的问题是重定向没有被发送到终端。对于如何检查测试中的响应,我们需要更聪明一点。让我们创建一个 response decorator 类,它可以在响应中“跟随重定向”,而无需向终端或浏览器发送任何头或内容:

namespace Framework\Testing;

use Framework\App;
use Framework\Http\Response;

class TestResponse
{
    private Response $response;

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

    public function isRedirecting(): bool
    {
        return $this->response->type() === Response::REDIRECT;
    }

    public function redirectingTo(): ?string
    {
        return $this->response->redirect();
    }

    public function follow(): static
    {
        while ($this->isRedirecting()) {
            $_SERVER['REQUEST_METHOD'] = 'GET';
            $_SERVER['REQUEST_URI'] = $this->redirectingTo();
            $this->response = App::getInstance()->run();
        }

        return $this;
    }

    public function __call(string $method, array $parameters = []): mixed
    {
        return $this->response->$method(...$parameters);
    }
}

这是来自framework/Testing/TestResponse.php

就我们测试的内容而言,这也使我们的注册错误测试更加清晰:

public function testRegistrationErrorsAreShown()
{
    $_SERVER['REQUEST_METHOD'] = 'POST';
    $_SERVER['REQUEST_URI'] = '/register';
    $_SERVER['HTTP_REFERER'] = '/register';

    $_POST['email'] = 'foo';
    $_POST['csrf'] = csrf();

    $response = new TestResponse($this->app->run());

    $this->assertTrue($response->isRedirecting());
    $this->assertEquals('/register', $response->redirectingTo());

    $response->follow();

    $this->assertStringContainsString('email should be an email', $response->content());
}

这是来自tests/RoutingTest.php

这些测试几乎向应用发出完整的 HTTP 请求,是集成测试的例子。我们不只是在测试一个类或方法:我们在测试完整的控制器功能、重定向和存储在会话中的错误。

这是一种不太集中的测试,但它在短时间内覆盖了很多领域。最好的测试套件是单元测试和集成测试的健康结合。

我们的测试现在应该通过了!

img/299823_2_En_9_Figd_HTML.jpg

测试通过

测试浏览器交互

我想谈的最后一个主题是测试网站,就像我们使用浏览器一样(但仍然是自动化过程)。这类测试往往很脆弱,因为它们依赖于 HTML 的结构,但它们是测试所有 JavaScript 代码工作正常的唯一方法…

为了实现这一目标,我们将引入另一个优秀的依赖项——Symfony Panther。它是 PHP 和浏览器之间的桥梁:

composer require --dev symfony/panther
composer require --dev dbrekelmans/bdi
vendor/bin/bdi detect drivers

这些命令安装 Panther 和一个推荐的浏览器驱动程序。Panther 提供了一些助手,用于与浏览器交互。它基于脸书的网络驱动库。有了 Panther,我们可以做各种很酷的事情:

use Facebook\WebDriver\WebDriverBy;
use Framework\Testing\TestCase;
use Symfony\Component\Panther\Client;

class BrowserTest extends TestCase
{
    public function testLoginForm()
    {
        $client = Client::createFirefoxClient();
        $client->request('GET', 'http://127.0.0.1:8000/register');

        $client->waitFor('.log-in-button');
        $client->executeScript("document.querySelector('.log-in-button').click()");

        $client->waitFor('.log-in-errors');
        $element = $client->findElement(WebDriverBy::className('log-in-form'));

        $this->assertStringContainsString('password is required', $element->getText());
    }
}

这是来自tests/BrowserTest.php

如果你使用 ChromeDriver,而不是 GeckoDriver,你应该使用createChromeClient方法。这些方法启动一个测试浏览器,代码使用它与页面的 HTML 进行交互。

浏览器测试通常是一系列的步骤,在这些步骤中,我们等待东西变得可用,然后使用它们(通过点击、键入等)。在本例中,我们等待一个登录按钮,单击它,然后确保显示错误消息。

这不是严格地测试 JavaScript 功能,但是如果你需要这样做,那么你可以这样做。

确保将所有这些助手类添加到您的登录表单的 HTML 中:

<form
  method="post"
  action="{{ $logInAction }}"
  class="flex flex-col w-full space-y-4 max-w-xl log-in-form"
>
  @if(isset($_SESSION['login_errors']))
  <ol class="list-disc text-red-500 log-in-errors">
    @foreach($_SESSION['login_errors'] as $field => $errors) @foreach($errors as
    $error)
    <li>{{ $error }}</li>
    @endforeach @endforeach
  </ol>
  @endif
  <!-- ... -->
  <button
    type="submit"
    class="bg-indigo-500 rounded-lg p-2 text-white log-in-button"
  >
    Log in
  </button>
</form>

这是来自resources/views/users/register.advanced.php

如果您希望看到测试浏览器工作,您可以使用一个特殊的环境变量来运行测试:

PANTHER_NO_HEADLESS=1 composer test

如果你使用的是正确的浏览器客户端,并且你已经把所有的东西都编码好了,你应该会看到 Chromium 或者 Firefox 启动并进入你网站的注册页面。

您还应该看到测试浏览器单击 login 按钮,并看到错误消息作为结果出现。

我推荐看一下 Symfony Panther 文档和脸书 WebDriver 文档,以了解更多关于这些库如何工作的信息。

警告

这是旋风般的一章。关于测试,关于支撑我们的应用和框架代码以便更容易测试,有太多东西需要学习。

在我们的框架中有很多我们可以改进的地方,使它更容易测试。例如,我们需要在测试文件中启动会话,这并不好。我们将在接下来的章节中修正这些问题,但是不要让框架中需要改进的地方阻止你尝试测试。

要在你的头脑中巩固这一知识,有很多事情要做:

  • 如果能完成测试验证、路由和表单交互就太好了。

  • 如果我们有某种 HTTP 请求抽象,测试请求会更容易,就像我们有 HTTP 响应抽象一样。

  • 我们如何在测试套件开始时自动启动网站服务器,并在所有测试运行后停止它?

专业人士如何做事

每个好的和流行的框架都有测试助手,几乎所有的都在幕后使用 PHPUnit。这是业内测试的标准。

这并不是说您不能或不应该尝试构建自己的替代方案,但这确实意味着您可以依赖这个可爱的开源库。

Laravel 有更多的测试工具,但是测试 HTTP 响应的底层方法是相同的。Laravel 的 HTTP 类建立在 Symfony 的 HTTP 类之上。

这意味着在 Symfony 中工作的代码和方法很可能在 Laravel 应用中工作,没有什么大惊小怪的。这也意味着对这些类的理解是必不可少的,并且可以在用两种框架编写的应用之间转移。

有许多社区支持的库和方法可以向浏览器发送不同种类的响应,比如 RSS、XML 和流响应。

我们已经看到了 Panther,但是 Laravel 有一个类似的浏览器测试库,叫做 Dusk。在框架之外,它不是超级可移植的,但是如果您在 Laravel 应用中进行浏览器测试,那么使用它是一种享受。

摘要

在这一章中,我们学习了很多关于测试的知识。随着我们构建更多的框架,这是我们应该继续做的事情,但这可能是一个陡峭的学习曲线。

在下一章,我们将开始用基于驱动程序的库来填充我们的框架,以做一些有用的事情(比如处理会话和文件系统)。

十、配置、缓存、会话、文件系统

既然我们已经解决了测试和服务地点的问题,现在我们已经进入了旅程的最后阶段。在这一章中,我们将制定一个更好的加载配置的方法。

我们还将为缓存、会话管理和文件系统访问添加驱动程序。我们将关注每个技术领域的一到两个驱动程序,但我们将建立一个良好的基础,在此基础上您可以添加自己的驱动程序。

自上次以来有什么变化?

在我们进入这一章的主要内容之前,我想回顾一下我在这两章之间做了哪些修改。在前一章快结束时,我设置了一些挑战,这是我为解决这些挑战所做的工作的总结…

我开始清理一些已经转移到Model类的数据库配置。这是我开始之前的样子:

public function getConnection(): Connection
{
    if (!isset($this->connection)) {
        $factory = new Factory();

        $factory->addConnector('mysql', function($config) {
            return new MysqlConnection($config);
        });

        $factory->addConnector('sqlite', function($config) {
            return new SqliteConnection($config);
        });

        $config = require basePath() . 'config/database.php';

        $this->connection = $factory->connect($config[$config['default']]);
    }

    return $this->connection;
}

这是来自framework/Database/Model.php

我认为最好在提供者中进行配置:

namespace Framework\Provider;

use Framework\App;
use Framework\Database\Factory;
use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Connection\SqliteConnection;

class DatabaseProvider
{
    public function bind(App $app): void
    {
        $app->bind('database', function($app) {
            $factory = new Factory();
            $this->addMysqlConnector($factory);
            $this->addSqliteConnector($factory);

            $config = $this->config($app);

            return $factory->connect($config[$config['default']]);
        });
    }

    private function config(App $app): array
    {
        $base = $app->resolve('paths.base');
        $separator = DIRECTORY_SEPARATOR;

        return require "{$base}{$separator}config/database.php";
    }

    private function addMysqlConnector($factory): void
    {
        $factory->addConnector('sqlite', function($config) {
            return new SqliteConnection($config);
        });
    }

    private function addSqliteConnector($factory): void
    {
        $factory->addConnector('mysql', function($config) {
            return new MysqlConnection($config);
        });
    }
}

这是来自framework/Provider/DatabaseProvider.php

这意味着任何需要预配置数据库连接的东西都可以直接从容器中访问它。我们可以显著缩短模型代码:

public function getConnection(): Connection
{
    if (!isset($this->connection)) {
        $this->connection = app('database');
    }

    return $this->connection;
}

这是来自framework/Database/Model.php

我把剩下的时间花在了添加自动化测试套件上。我为注册表单的验证添加了浏览器测试,并为剩余的验证规则添加了单元测试。

我不喜欢在一个终端选项卡中保持服务器运行,而在另一个选项卡中运行浏览器测试,所以我想出了一种方法让浏览器测试在运行时“引导”服务器。

为了实现这一点,我必须将composer serve命令重构为一个框架命令:

namespace Framework\Support\Command;

use InvalidArgumentException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;

class ServeCommand extends Command
{
    protected static $defaultName = 'serve';

    private Process $process;

    protected function configure()
    {
        $this
            ->setDescription('Starts a development server')
            ->setHelp('You can provide an optional host and port, for the development server.')
            ->addOption('host', null, InputOption::VALUE_REQUIRED)
            ->addOption('port', null, InputOption::VALUE_REQUIRED);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $base = app('paths.base');
        $host = $input->getOption('host') ?: env('APP_HOST', '127.0.0.1');
        $port = $input->getOption('port') ?: env('APP_PORT', '8000');

        if (empty($host) || empty($port)) {
            throw new InvalidArgumentException('APP_HOST and APP_PORT both need values');
        }

        $this->handleSignals();
        $this->startProcess($host, $port, $base, $output);

        return Command::SUCCESS;
    }

    private function command(string $host, string $port, string $base): array
    {
        $separator = DIRECTORY_SEPARATOR;

        return [
            PHP_BINARY,
            "-S",
            "{$host}:{$port}",
            "{$base}{$separator}server.php",
        ];
    }

    private function handleSignals(): void
    {
        pcntl_async_signals(true);

        pcntl_signal(SIGTERM, function($signal) {
            if ($signal === SIGTERM) {
                $this->process->signal(SIGKILL);
                exit;
            }
        });
    }

    private function startProcess(string $host, string $port, string $base, OutputInterface $output): void
    {
        $this->process = new Process($this->command($host, $port, $base), $base);
        $this->process->setTimeout(PHP_INT_MAX);

        $this->process->start(function($type, $buffer) use ($output) {
            $output->write("<info>{$buffer}</info>");
        });

        $output->writeln("Serving requests at http://{$host}:{$port}");

        $this->process->wait();
    }
}

这是来自framework/Support/Command/ServeCommand.php

这个命令封装了运行 PHP 开发服务器的代码,但是它不是指向一个公共文件夹,而是指向一个server.php文件。这是对public/index.php的代理:

$path = __DIR__;
$separator = DIRECTORY_SEPARATOR;
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));

if (is_file("{$path}{$separator}public{$separator}{$uri}")) {
    return false;
}

require_once "{$path}{$separator}public{$separator}index.php";

这是来自server.php

也在用信号做一些有趣的事情。有一个 PHP 扩展,默认情况下与 PHP 一起安装,可以用来拦截许多中断信号。

我实现了一个信号监听器,这样我就可以在命令停止时优雅地停止 PHP 开发服务器。这是我从 Cal Evans 的 信令 PHP 中学来的一招。

使用这个 serve 命令,我可以添加一个 PHPUnit 扩展,它在测试之前启动服务器,在测试运行之后停止服务器:

namespace Framework\Testing;

use PHPUnit\Runner\BeforeFirstTestHook;
use PHPUnit\Runner\AfterLastTestHook;
use Symfony\Component\Process\Process;

final class ServerExtension implements BeforeFirstTestHook, AfterLastTestHook
{
    private Process $process;
    private bool $startedServer = false;

    private function startServer()
    {
        if ($this->serverIsRunning()) {
            $this->startedServer = false;
            return;
        }

        $this->startedServer = true;

        $base = app('paths.base');
        $separator = DIRECTORY_SEPARATOR;

        $this->process = new Process([
            PHP_BINARY,
            "{$base}{$separator}command.php",
            "serve"
        ], $base);

        $this->process->start(function($type, $buffer) {
            print $buffer;
        });
    }

    private function serverIsRunning()
    {
        $connection = @fsockopen(
            env('APP_HOST', '127.0.0.1'),
            env('APP_PORT', '8000'),
        );

        if (is_resource($connection)) {
            fclose($connection);
            return true;
        }

        return false;
    }

    private function stopServer()
    {
        if ($this->startedServer) {
            $this->process->signal(SIGTERM);
        }
    }

    public function executeBeforeFirstTest(): void
    {
        $this->startServer();
    }

    public function executeAfterLastTest(): void
    {
        $this->stopServer();
    }
}

这是来自framework/Testing/ServerExtension.php

在这里,我们看到 SIGTERM 信号被发送到 serve 命令。如果没有它(和信号处理),就有测试运行结束时服务器没有关闭的风险。有点奇怪,但事情就是这样发生的。

img/299823_2_En_10_Figa_HTML.jpg

处理信号

还有其他一些小变化,但这些是需要了解的重要变化。现在,我想我们已经准备好进入这一章的内容了!

更好的配置管理

我们一直使用有限的配置数据,通常使用require语句。我认为,通过实现以下目标,我们可以做得更好:

  1. 根据需要缓存配置

  2. 抽象加载配置文件的文件系统细节

可能有很多方法可以存储和加载配置(例如,从数据库或第三方服务),但我们将保持简单。让我们将文件系统配置文件访问转移到一个中心类,并将其绑定到容器。

Laravel 使用了一种点符号,我希望我们朝着这个方向发展。配置查找采用config('database.default')的形式,这会导致config/database.php文件被加载,第一个点之后的所有内容都用于遍历嵌套数组。

例如,config('database.mysql.username')正在从config/database.php加载['mysql']['username']。这不是太多的工作要复制…

让我们从Config类开始:

namespace Framework\Support;

use Framework\App;

class Config
{
    private array $loaded = [];

    public function get(string $key, mixed $default = null): mixed
    {
        $segments = explode('.', $key);
        $file = array_shift($segments);

        if (!isset($this->loaded[$file])) {
            $base = App::getInstance()->resolve('paths.base');
            $separator = DIRECTORY_SEPARATOR;

            $this->loaded[$file] = (array) require "{$base}{$separator}config{$separator}{$file}.php";
        }

        if ($value = $this->withDots($this->loaded[$file], $segments)) {
            return $value;
        }

        return $default;
    }

    private function withDots(array $array, array $segments): mixed
    {
        $current = $array;

        foreach ($segments as $segment) {
            if (!isset($current[$segment])) {
                return null;
            }

            $current = $current[$segment];
        }

        return $current;
    }
}

这是来自framework/Support/Config.php

我们首先将$key解构为我们想要的配置值。第一部分是文件名,其余部分用于嵌套查找。

withDots是一种遍历嵌套数组的迭代方法,但是递归方法也同样有效。

这需要绑定到容器,以便更容易使用:

namespace Framework\Provider;

use Framework\App;
use Framework\Support\Config;

class ConfigProvider
{
    public function bind(App $app): void
    {
        $app->bind('config', function($app) {
            return new Config();
        });
    }
}

这是来自framework/Provider/ConfigProvider.php

ConfigProvider类添加到config/providers.php中,以便加载应用。它应该是第一个条目,以便后续的提供者可以访问新的配置抽象…

我们现在可以清理框架中使用配置的各个部分。下面是清理数据库提供程序代码的方法:

public function bind(App $app): void
{
    $app->bind('database', function($app) {
        $factory = new Factory();
        $this->addMysqlConnector($factory);
        $this->addSqliteConnector($factory);

        // $config = $this->config($app);
        $config = $app->resolve('config')->get('database');

        return $factory->connect($config[$config['default']]);
    });
}

// private function config(App $app): array
// {
//     $base = $app->resolve('paths.base');
//     $separator = DIRECTORY_SEPARATOR;

//     return require "{$base}{$separator}config/database.php";
// }

这是来自framework/Provider/DatabaseProvider.php

我们可以通过创建一个配置助手来做得更好:

if (!function_exists('config')) {
    function config(string $key, mixed $default = null): mixed
    {
        return app('config')->get($key, $default);
    }
}

这是来自framework/helpers.php

最终的数据库提供程序代码如下所示:

public function bind(App $app): void
{
    $app->bind('database', function($app) {
        $factory = new Factory();
        $this->addMysqlConnector($factory);
        $this->addSqliteConnector($factory);

        $config = config('database');

        return $factory->connect($config[$config['default']]);
    });
}

这是来自framework/Provider/DatabaseProvider.php

除了使配置更容易使用之外,这种抽象还使配置管理更有效——因为配置文件只加载一次。

现在我们有了这个,我们可以毫不费力地使用越来越多的配置文件(用于缓存、会话和文件系统)!

躲藏

我能想到许多不同的缓存提供者,但大多数都涉及第三方服务或与 web 服务器并行运行的服务器。让我们实现对以下几种更简单的缓存提供者的支持:

  1. 内存缓存(也就是我们用来缓存配置的那种)

  2. 文件系统缓存

  3. 快取记忆体

    Memcache 是一个与 web 服务器并行运行的服务器,这意味着我们需要安装它来运行这个缓存驱动程序。如果你在安装时遇到问题,那么你可以跳过这个特殊的“驱动程序”…

我们需要一个与数据库连接类似的工厂设置:

namespace Framework\Cache;

use Closure;
use Framework\Cache\Driver\Driver;
use Framework\Cache\Exception\DriverException;

class Factory
{
    protected array $drivers;

    public function addDriver(string $alias, Closure $driver): static
    {
        $this->drivers[$alias] = $driver;
        return $this;
    }

    public function connect(array $config): Driver
    {
        if (!isset($config['type'])) {
            throw new DriverException('type is not defined');
        }

        $type = $config['type'];

        if (isset($this->drivers[$type])) {
            return $this->drivers$type;
        }

        throw new DriverException('unrecognised type');
    }
}

这是来自framework/Cache/Factory.php

DriverExceptionRuntimeException的空子类。缓存配置文件如下所示:

return [
    'default' => 'memory',
    'memory' => [
        'type' => 'memory',
        'seconds' => 31536000,
    ],
];

这是来自config/cache.php

目前这真的很简单,但是随着我们增加额外的驱动程序,会变得更加复杂。31536000秒是 1 年,我们将使用它作为默认的缓存到期值。然而,Driver界面更有趣一些:

namespace Framework\Cache\Driver;

interface Driver
{
    /**
     * Tell if a value is cached (still)
     */
    public function has(string $key): bool;

    /**
     * Get a cached value
     */
    public function get(string $key, mixed $default = null): mixed;

    /**
     * Put a value into the cache, for an optional number of seconds
     */
    public function put(string $key, mixed $value, int $seconds = null): static;

    /**
     * Remove a single cached value
     */
    public function forget(string $key): static;

    /**
     * Remove all cached values
     */
    public function flush(): static;
}

这是来自framework/Cache/Driver/Driver.php

我们可以通过将这些方法签名中的每一个连接到内部数组来创建内存中的驱动程序:

namespace Framework\Cache\Driver;

class MemoryDriver implements Driver
{
    private array $config = [];
    private array $cached = [];

    public function __construct(array $config)
    {
        $this->config = $config;
    }

    public function has(string $key): bool
    {
        return isset($this->cached[$key]) && $this->cached[$key]['expires'] > time();
    }

    public function get(string $key, mixed $default = null): mixed
    {
        if ($this->has($key)) {
            return $this->cached[$key]['value'];
        }

        return $default;
    }

    public function put(string $key, mixed $value, int $seconds = null): static
    {
        if (!is_int($seconds)) {
            $seconds = (int) $this->config['seconds'];
        }

        $this->cached[$key] = [
            'value' => $value,
            'expires' => time() + $seconds,
        ];

        return $this;
    }

    public function forget(string $key): static
    {
        unset($this->cached[$key]);
        return $this;
    }

    public function flush(): static
    {
        $this->cached = [];
        return $this;
    }
}

这是来自framework/Cache/Driver/MemoryDriver.php

hasget方法看起来类似于我们对Config类所做的,只是增加了一个expires键。当有人告诉我们一个值要缓存多少秒时,我们将这些秒加到 unix 时间戳上。我们可以将其与 unix 时间戳(将来)进行比较,以确定该值是否应该过期。

让我们将它连接到一个提供者中,这样我们就可以快速地使用它:

namespace Framework\Provider;

use Framework\App;
use Framework\Cache\Factory;
use Framework\Cache\Driver\MemoryDriver;

class CacheProvider
{
    public function bind(App $app): void
    {
        $app->bind('cache', function($app) {
            $factory = new Factory();
            $this->addMemoryDriver($factory);

            $config = config('cache');

            return $factory->connect($config[$config['default']]);
        });
    }

    private function addMemoryDriver($factory): void
    {
        $factory->addDriver('memory', function($config) {
            return new MemoryDriver($config);
        });
    }
}

这是来自framework/Provider/CacheProvider.php

我们可以利用这一点来存储不太可能经常改变的数据:

$cache = app('cache');
$products = Product::all();

$productsWithRoutes = array_map(function ($product) use ($router) {
    $key = "route-for-product-{$product->id}";

    if (!$cache->has($key)) {
        $cache->put($key, $router->route('view-product', ['product' => $product->id]));
    }

    $product->route = $cache->get($key);

    return $product;
}, $products);

return view('home', [
    'products' => $productsWithRoutes,
]);

这是来自app/Http/Controllers/ShowHomePageController.php

在这个例子中,我们可以一次性计算出每个产品的路线,并将其存储在缓存中。

不要忘记将CacheProvider类添加到config/providers.php中,这样就可以加载应用了。

在使用 PHP 开发服务器时,内存驱动程序有点没用,因为在每个页面返回到浏览器后,内存都会被清除。这对于测试目的或者在每次请求后都不清除内存的环境中非常有用。

让我们添加下一个驱动程序,其中缓存的值存储在文件系统中:

namespace Framework\Cache\Driver;

use Framework\App;

class FileDriver implements Driver
{
    private array $config = [];
    private array $cached = [];

    public function __construct(array $config)
    {
        $this->config = $config;
    }

    public function has(string $key): bool
    {
        $data = $this->cached[$key] = $this->read($key);

        return isset($data['expires']) and $data['expires'] > time();
    }

    private function path(string $key): string
    {
        $base = $this->base();
        $separator = DIRECTORY_SEPARATOR;
        $key = sha1($key);

        return "{$base}{$separator}{$key}.json";
    }

    private function base(): string
    {
        $base = App::getInstance()->resolve('paths.base');
        $separator = DIRECTORY_SEPARATOR;

        return "{$base}{$separator}storage{$separator}framework{$separator}cache";
    }

    private function read(string $key)
    {
        $path = $this->path($key);

        if (!is_file($path)) {
            return [];
        }

        return json_decode(file_get_contents($path), true);
    }

    public function get(string $key, mixed $default = null): mixed
    {
        if ($this->has($key)) {
            return $this->cached[$key]['value'];
        }

        return $default;
    }

    public function put(string $key, mixed $value, int $seconds = null): static
    {
        if (!is_int($seconds)) {
            $seconds = (int) $this->config['seconds'];
        }

        $data = $this->cached[$key] = [
            'value' => $value,
            'expires' => time() + $seconds,
        ];

        return $this->write($key, $data);
    }

    private function write(string $key, mixed $value): static
    {
        file_put_contents($this->path($key), json_encode($value));
        return $this;
    }

    public function forget(string $key): static
    {
        unset($this->cached[$key]);

        $path = $this->path($key);

        if (is_file($path)) {
            unlink($path);
        }

        return $this;
    }

    public function flush(): static
    {
        $this->cached = [];

        $base = $this->base();
        $separator = DIRECTORY_SEPARATOR;

        $files = glob("{$base}{$separator}*.json");

        foreach ($files as $file){
            if (is_file($file)) {
                unlink($file);
            }
        }

        return $this;
    }
}

这是来自framework/Cache/Driver/FileDriver.php

这里,我们保留了内部缓存数组的概念,但这样做只是为了减少多次读取同一个文件的次数。缓存值及其到期时间保存在 JSON 文件中。

这不是一个有效的缓存驱动程序,所以它只在没有更好的替代驱动程序可用的情况下,或者在只有缓存的值比多个文件系统读取和写入花费更长时间的情况下才真正有用。

让我们扩展这个文件系统驱动程序的配置,以及我们将要添加的 Memcache 驱动程序:

return [
    'default' => 'memcache',
    'memory' => [
        'type' => 'memory',
        'seconds' => 31536000,
    ],
    'file' => [
        'type' => 'file',
        'seconds' => 31536000,
    ],
    'memcache' => [
        'type' => 'memcache',
        'host' => '127.0.0.1',
        'port' => 11211,
        'seconds' => 31536000,
    ],
];

这是来自config/cache.php

最终的缓存驱动程序使用 Memcache:

namespace Framework\Cache\Driver;

use Memcached;

class MemcacheDriver implements Driver
{
    private array $config = [];
    private Memcached $memcache;

    public function __construct(array $config)
    {
        $this->config = $config;

        $this->memcache = new Memcached();
        $this->memcache->addServer($config['host'], $config['port']);
    }

    public function has(string $key): bool
    {
        return $this->memcache->get($key) !== false;
    }

    public function get(string $key, mixed $default = null): mixed
    {
        if ($value = $this->memcache->get($key)) {
            return $value;
        }

        return $default;
    }

    public function put(string $key, mixed $value, int $seconds = null): static
    {
        if (!is_int($seconds)) {
            $seconds = (int) $this->config['seconds'];
        }

        $this->memcache->set($key, $value, time() + $seconds);
        return $this;
    }

    public function forget(string $key): static
    {
        $this->memcache->delete($key);
        return $this;
    }

    public function flush(): static
    {
        $this->memcache->flush();
        return $this;
    }
}

这是来自framework/Cache/MemcacheDriver.php

这比文件系统驱动程序实现起来要快得多,因为 Memcache 在后台完成了大量的文件系统和序列化操作。

唯一棘手的是,Memcache实例是在构造函数中创建的,通过 getter 和 setter 或者构造函数注入(wink)可能会更好。

让我们将这两个新驱动程序添加到提供程序中:

public function bind(App $app): void
{
    $app->bind('cache', function($app) {
        $factory = new Factory();
        $this->addFileDriver($factory);
        $this->addMemcacheDriver($factory);
        $this->addMemoryDriver($factory);

        $config = config('cache');

        return $factory->connect($config[$config['default']]);
    });
}

private function addFileDriver($factory): void
{
    $factory->addDriver('file', function($config) {
        return new FileDriver($config);
    });
}

private function addMemcacheDriver($factory): void
{
    $factory->addDriver('memcache', function($config) {
        return new MemcacheDriver($config);
    });
}

private function addMemoryDriver($factory): void
{
    $factory->addDriver('memory', function($config) {
        return new MemoryDriver($config);
    });
}

这是来自framework/Provider/CacheProvider.php

花几分钟时间切换默认的缓存提供者,从memoryfile再到memcache。我觉得有趣的是,在使用非常不同的技术(在引擎盖下),改变一个配置变量的情况下,系统工作得很好。

会议

我们已经使用了会话,但是让我们在一个相似的因素/驱动安排中形式化代码。我们将继续支持本地会话管理——通过更好的初始化和简洁的getput方法。

虽然可以创建和使用其他会话驱动程序,但是考虑到我们在本章已经看到的内容,这个练习会变得有点乏味。如果你觉得这样做有挑战性,我建议尝试在章节之间添加额外的会话驱动。

让我们创建另一个工厂和相应的驱动程序接口:

namespace Framework\Session;

use Closure;
use Framework\Session\Driver\Driver;
use Framework\Session\Exception\DriverException;

class Factory
{
    protected array $drivers;

    public function addDriver(string $alias, Closure $driver): static
    {
        $this->drivers[$alias] = $driver;
        return $this;
    }

    public function connect(array $config): Driver
    {
        if (!isset($config['type'])) {
            throw new DriverException('type is not defined');
        }

        $type = $config['type'];

        if (isset($this->drivers[$type])) {
            return $this->drivers$type;
        }

        throw new DriverException('unrecognised type');
    }
}

这是来自framework/Session/Factory.php

这与缓存工厂类完全相同。想起来了。也许这是一个很好的抽象候选——一个这些库可以重用的通用类…

该接口不同于我们在缓存库中使用的接口:

namespace Framework\Session\Driver;

interface Driver
{
    /**
     * Tell if a value is session
     */
    public function has(string $key): bool;

    /**
     * Get a session value
     */
    public function get(string $key, mixed $default = null): mixed;

    /**
     * Put a value into the session
     */
    public function put(string $key, mixed $value): static;

    /**
     * Remove a single session value
     */
    public function forget(string $key): static;

    /**
     * Remove all session values
     */
    public function flush(): static;
}

这是来自framework/Session/Driver/Driver.php

主要区别在于会话方法不关心到期时间。如果过期是受管理的,那么它应该是配置的一部分,并在会话启动时设置。

本机会话驱动程序如下所示:

namespace Framework\Session\Driver;

class NativeDriver implements Driver
{
    private array $config = [];

    public function __construct(array $config)
    {
        $this->config = $config;

        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }
    }

    public function has(string $key): bool
    {
        $prefix = $this->config['prefix'];
        return isset($_SESSION["{$prefix}{$key}"]);
    }

    public function get(string $key, mixed $default = null): mixed
    {
        $prefix = $this->config['prefix'];

        if (isset($_SESSION["{$prefix}{$key}"])) {
            return $_SESSION["{$prefix}{$key}"];
        }

        return $default;
    }

    public function put(string $key, mixed $value): static
    {
        $prefix = $this->config['prefix'];
        $_SESSION["{$prefix}{$key}"] = $value;
        return $this;
    }

    public function forget(string $key): static
    {
        $prefix = $this->config['prefix'];
        unset($_SESSION["{$prefix}{$key}"]);
        return $this;
    }

    public function flush(): static
    {
        foreach (array_keys($_SESSION) as $key) {
            if (str_starts_with($key, $prefix)) {
                unset($_SESSION[$key]);
            }
        }

        return $this;
    }
}

这是来自framework/Session/Driver/NativeDriver.php

需要指出的一点是,我们存储的会话变量带有以可配置前缀为前缀的键。我们这样做是为了让框架可以与其他可能也在会话中存储值的库共存,而不存在键冲突的可能性。

因为我们要将对session_start的调用转移到这个类,所以我们可以将它从App类中移除:

public function run()
{
    // if (session_status() !== PHP_SESSION_ACTIVE) {
    //     session_start();
    // }

    $basePath = $this->resolve('paths.base');

    $this->configure($basePath);
    $this->bindProviders($basePath);

    return $this->dispatch($basePath);
}

这是来自framework/App.php

现在,我们需要会话配置文件和将它绑定到容器的提供者:

return [
    'default' => 'native',
    'native' => [
        'type' => 'native',
        'prefix' => 'framework_',
    ],
];

这是来自config/session.php

namespace Framework\Provider;

use Framework\App;
use Framework\Session\Factory;
use Framework\Session\Driver\NativeDriver;

class SessionProvider
{
    public function bind(App $app): void
    {
        $app->bind('session', function($app) {
            $factory = new Factory();
            $this->addNativeDriver($factory);

            $config = config('session');

            return $factory->connect($config[$config['default']]);
        });
    }

    private function addNativeDriver($factory): void
    {
        $factory->addDriver('native', function($config) {
            return new NativeDriver($config);
        });
    }
}

这是来自framework/Provider/SessionProvider.php

这看起来像是抽象的另一个候选,因为它基本上与CacheProvider类相同…

不要忘记将SessionProvider类添加到config/providers.php中,这样就可以加载应用了。

这意味着我们现在可以从任何地方使用会话,而不需要每次都引导它:

app('session')->put(
    'hits', app('session')->get('hits', 0) + 1
);

文件系统

本章中我们要看的最后一个库是用于文件系统的。我们可以使用文件系统做很多事情:

  1. 加载与模板相关的文件

  2. 加载国际化文件,以显示特定于区域设置的 UI 标签

  3. 存储图像、视频和音频文件等应用资产

还有各种我们可以存储东西的地方——各种可以称为文件系统的系统:

  1. 本地服务器文件系统

  2. 基于云的对象商店,像 S3GFS

  3. 基础设施服务,如 FTP、??、SFTP 和 ??

我们可以着手构建一些这样的驱动程序,但是我认为这是一个很好的机会来看看在我们自己的 API 中“包装”一个现有的文件系统库会涉及到什么。

我们将使用一个名为 Flysystem 的库,但我们将通过自己的镜头来呈现它。

让我们使用

composer require league/flysystem

现在,让我们创建另一个工厂,使用 Flysystem 自带的所有驱动程序:

namespace Framework\Filesystem;

use Closure;
use Framework\Filesystem\Driver\Driver;
use Framework\Filesystem\Exception\DriverException;

class Factory
{
    protected array $drivers;

    public function addDriver(string $alias, Closure $driver): static
    {
        $this->drivers[$alias] = $driver;
        return $this;
    }

    public function connect(array $config): Driver
    {
        if (!isset($config['type'])) {
            throw new DriverException('type is not defined');
        }

        $type = $config['type'];

        if (isset($this->drivers[$type])) {
            return $this->drivers$type;
        }

        throw new DriverException('unrecognised type');
    }
}

这是来自framework/Filesystem/Factory.php

更多相同的…

让我们为文件系统驱动程序创建一个配置文件:

return [
    'default' => 'local',
    'local' => [
        'type' => 'local',
        'path' => __DIR__ . '/../storage/app',
    ],
    's3' => [
        'type' => 's3',
        'key' => '',
        'secret' => '',
        'token' => '',
        'region' => '',
        'bucket' => '',
    ],
    'ftp' => [
        'type' => 'ftp',
        'host' => '',
        'root' => '',
        'username' => '',
        'password' => '',
    ],
];

这是来自config/filesystem.php

代替接口,我们可以使用一个抽象类来定义驱动程序的签名。这是因为我们实际上没有实现它们的任何功能,我们只是实例化了 Flysystem 驱动程序:

namespace Framework\Filesystem\Driver;

use League\Flysystem\Filesystem;

abstract class Driver
{
    protected Filesystem $filesystem;

    public function __construct(array $config)
    {
        $this->filesystem = $this->connect($config);
    }

    abstract protected function connect(array $config): Filesystem;

    public function list(string $path, bool $recursive = false): iterable
    {
        return $this->filesystem->listContents($path, $recursive);
    }

    public function exists(string $path): bool
    {
        return $this->filesystem->fileExists($path);
    }

    public function get(string $path): string
    {
        return $this->filesystem->read($path);
    }

    public function put(string $path, mixed $value): static
    {
        $this->filesystem->write($path, $value);
        return $this;
    }

    public function delete(string $path): static
    {
        $this->filesystem->delete($path);
        return $this;
    }
}

这是来自framework/Filesystem/Driver/Driver.php

我通过研究 Flysystem 文档构建了这些方法。每个 Flysystem 方法都“包装”在一个方法中,该方法与我们用其他库创建的模式相匹配。

每个驱动程序需要实现的唯一方法是抽象的connect方法。在LocalDriver类中是这样的:

namespace Framework\Filesystem\Driver;

use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;

class LocalDriver extends Driver
{
    protected function connect()
    {
        $adapter = new LocalFilesystemAdapter($this->config['path']);
        $this->filesystem = new Filesystem($adapter);
    }
}

这是来自framework/Filesystem/Driver/LocalDriver.php

记住,我们还需要一个在容器中绑定这些类的提供者:

namespace Framework\Provider;

use Framework\App;
use Framework\Filesystem\Factory;
use Framework\Filesystem\Driver\LocalDriver;

class FilesystemProvider
{
    public function bind(App $app): void
    {
        $app->bind('filesystem', function($app) {
            $factory = new Factory();
            $this->addLocalDriver($factory);

            $config = config('filesystem');

            return $factory->connect($config[$config['default']]);
        });
    }

    private function addLocalDriver($factory): void
    {
        $factory->addDriver('local', function($config) {
            return new LocalDriver($config);
        });
    }
}

这是来自framework/Provider/FilesystemProvider.php

最后,需要将这个提供者添加到config/providers.php中。该文件在本章的过程中不断发展,因此最终的提供者配置文件如下所示:

return [
    // load config first, so the rest can use it...
    \Framework\Provider\ConfigProvider::class,

    \Framework\Provider\CacheProvider::class,
    \Framework\Provider\DatabaseProvider::class,
    \Framework\Provider\FilesystemProvider::class,
    \Framework\Provider\ResponseProvider::class,
    \Framework\Provider\SessionProvider::class,
    \Framework\Provider\ValidationProvider::class,
    \Framework\Provider\ViewProvider::class,
];

这是来自config/providers.php

现在可以从应用中的任何地方访问文件系统抽象,如下例所示:

if (!app('filesystem')->exists('hits.txt')) {
    app('filesystem')->put('hits.txt', '');
}

app('filesystem')->put(
    'hits.txt',
    (int) app('filesystem')->get('hits.txt', 0) + 1,
);

警告

我们以创纪录的速度创建了缓存、会话和文件系统库。还有几件事要做,可以让这些变得更好:

  • 我们只创建了一个会话驱动程序。如果我们有更多的驱动程序,这将会很酷,但它肯定会涉及到使用内置的会话驱动方法来做好…

  • 我们只“包装”了一个 Flysystem 适配器——在本地驱动程序中。使用我们设置的配置和我们使用的模式,你认为你可以添加 S3 和 FTP 支持吗?

  • 到最后,很明显这些类中的一些可以重用——特别是工厂和提供者类。并非所有的工厂都是相同的(例如,数据库工厂),也并非所有的提供者都是相同的(例如,验证提供者)。对于非常相似的工厂和提供商,这可以减少我们需要维护的代码量…

  • 我们所有的配置都是无类型的和未经检查的。我们对配置值的结构和存在做了许多假设,所以在这里增加一些安全性是有用的。

  • 在其他库中重用其中的一些库会很好,比如重用文件系统库来支持基于文件的会话存储。你准备好迎接挑战了吗?

摘要

在本章中,我们创建了一个有用的配置抽象,然后用它来实现一些关键的框架组件。大多数流行的框架都包括这些组件以及其他一些组件。

在接下来的一章中,我们将会实现更多的,因为我们一起完成了我们的时间。试着在下一章之前完成一些挑战,这样你对这些组件的知识就会增长。