PHP8-MVC-高级教程-五-

66 阅读18分钟

PHP8 MVC 高级教程(五)

原文:Pro PHP 8 MVC

协议:CC BY-NC-SA 4.0

十一、队列、日志、电子邮件

我们正处于代码的最后一章,我们已经走了很长一段路。继续上一章的主题,我们将通过创建另外三个库来结束我们的旅程:这次是为了排队缓慢的操作、记录错误和发送电子邮件。

当我们创建开发人员体验,使我们的框架在所有技能集的开发人员中受欢迎时,我们将查看一些很好的底层库来重新打包。

同时…

我想在各章之间回顾一下我所做的一些事情,清理一些框架和应用代码。有一些较小的变化,但有两个变化非常显著:

  1. 创建更好的异常处理系统

  2. 重构大约一半只提供基于驱动的工厂的提供者

更好的异常处理

在我们旅程的开始,我们创造了路由。其中一部分是处理路由调度时抛出的异常。后来,我们通过拦截和响应验证异常,添加到潜在的异常列表中。

这是代码的样子,直到我开始弄乱它:

public function dispatch()
{
    $paths = $this->paths();

    $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    $requestPath = $_SERVER['REQUEST_URI'] ?? '/';

    $matching = $this->match($requestMethod, $requestPath);

    if ($matching) {
        $this->current = $matching;

        try {
            return $matching->dispatch();
        }
        catch (Throwable $e) {
            if ($e instanceof ValidationException) {
                $_SESSION[$e->getSessionName()] = $e->getErrors();
                return redirect($_SERVER['HTTP_REFERER']);
            }

            if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'dev') {
                $whoops = new Run();
                $whoops->pushHandler(new PrettyPageHandler);
                $whoops->register();
                throw $e;
            }

            return $this->dispatchError();
        }
    }

    if (in_array($requestPath, $paths)) {
        return $this->dispatchNotAllowed();
    }

    return $this->dispatchNotFound();
}

这是来自framework/Routing/Router.php

有一堆不属于这里的东西。路由不应该知道会话或验证库。如果其中任何一项被禁用(例如,为了加快响应时间),那么路由就会中断。

路由也不应该关心我们在开发中如何呈现有用的错误消息。

我们把这些东西放在这里,因为我们没有更好的地方放它们,因为它们是作为路由过程的一部分被触发的。我们需要更好的解决方案…

我通过创建一个名为ExceptionHandler的新支持类来解决这个问题:

namespace Framework\Support;

use Framework\Validation\Exception\ValidationException;
use Throwable;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Run;

class ExceptionHandler
{
    public function showThrowable(Throwable $throwable)
    {
        if ($throwable instanceof ValidationException) {
            return $this->showValidationException($throwable);
        }

        if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'dev') {
            $this->showFriendlyThrowable($throwable);
        }
    }

    public function showValidationException(ValidationException $exception)
    {
        if ($session = session()) {
            $session->put($exception->getSessionName(), $exception->getErrors());
        }

        return redirect(env('HTTP_REFERER'));
    }

    public function showFriendlyThrowable(Throwable $throwable)
    {
        $whoops = new Run();
        $whoops->pushHandler(new PrettyPageHandler());
        $whoops->register();

        throw $throwable;
    }
}

这是来自framework/Support/ExceptionHandler.php

这个新类负责决定如何处理框架可能抛出的各种异常。我们还没怎么用过这种控制流策略,但至少现在我们有了更好的使用方法。

它分为两个主要部分:

  1. 计算出Throwable的类型

  2. 用它做一些事情——无论这意味着在开发中重定向或显示一个有用的错误页面

这已经是一个比将这些代码放入路由更好的解决方案,但是如果允许开发人员将他们自己的异常处理加入进来,效果会更好。这个想法是我在 Laravel 中看到的,他们为所有新应用中的这种处理提供了一个模板。

新的应用在其应用文件夹中带有这个句柄的子类。我用一个新的应用类复制了这种行为:

namespace App\Exceptions;

use Framework\Support\ExceptionHandler;
use Throwable;

class Handler extends ExceptionHandler
{
    public function showThrowable(Throwable $throwable)
    {
        // add in some reporting...

        return parent::showThrowable($throwable);
    }
}

这是来自app/Exceptions/Handler.php

这有几个好处:

  1. 开发人员可以添加他们自己的异常控制流:他们可以在路由中抛出异常(针对异常情况),并在一个中心位置计算出如何处理这些异常。

  2. 不可能为源自框架内部的异常情况添加定制的错误日志和处理。

为了让框架知道将这些异常发送给哪个处理程序,我们需要一些识别适当类的系统。我认为一个配置文件会有用:

return [
    'exceptions' => \App\Exceptions\Handler::class,
];

这是来自config/handlers.php

然后我们需要使用这个配置文件来发送异常:

public function dispatch()
{
    $paths = $this->paths();

    $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    $requestPath = $_SERVER['REQUEST_URI'] ?? '/';

    $matching = $this->match($requestMethod, $requestPath);

    if ($matching) {
        $this->current = $matching;

        try {
            return $matching->dispatch();
        }
        catch (Throwable $e) {
            $result = null;

            if ($handler = config('handlers.exceptions')) {
                $instance = new $handler();

                if ($result = $instance->showThrowable($e)) {
                    return $result;
                }
            }

            return $this->dispatchError();
        }
    }

    if (in_array($requestPath, $paths)) {
        return $this->dispatchNotAllowed();
    }

    return $this->dispatchNotFound();
}

这是来自framework/Routing/Router.php

使用这种模式,我们已经成功地从路由中移除了大多数异常处理细节。它仍然需要知道将异常发送到哪里,但是我们可以通过创建一个App::handleException方法来进一步删除这个细节,该方法执行相同的处理程序转发。

我没有走到最后一步,因为我认为这与客观的责任划分关系不大,而与个人偏好关系更大。

重构提供程序

在前一章中,我提到了有多少提供者和工厂使用相似的代码来获得相同的结果——特别是

  1. 创建了一个因子实例

  2. 增加了一些驱动因素

  3. 库的已解析配置

都使用完全相同的代码。仔细回顾之后,我意识到我们仍然需要所有的工厂类(因为类型提示),但是提供者可以从单个基础继承:

namespace Framework\Support;

use Framework\App;

abstract class DriverProvider
{
    final public function bind(App $app): void
    {
        $name = $this->name();
        $factory = $this->factory();
        $drivers = $this->drivers();

        $app->bind($name, function ($app) use ($name, $factory, $drivers) {
            foreach ($drivers as $key => $value) {
                $factory->addDriver($key, $value);
            }

            $config = config($name);

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

    abstract protected function name(): string;
    abstract protected function factory(): mixed;
    abstract protected function drivers(): array;
}

这是来自framework/Support/DriverProvider.php

现在,这个提供者的子类可以专注于提供一个兼容的工厂和要添加到其中的驱动程序列表,而不是重复这个模式。工厂界面如下所示:

namespace Framework\Support;

use Closure;

interface DriverFactory
{
    public function addDriver(string $alias, Closure $driver): static;
    public function connect(array $config): mixed;
}

这是来自framework/Support/DriverFactory.php

这意味着提供商可以精简,所有提供商看起来都像这样:

namespace Framework\Provider;

use Framework\Cache\Factory;
use Framework\Cache\Driver\FileDriver;
use Framework\Cache\Driver\MemcacheDriver;
use Framework\Cache\Driver\MemoryDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class CacheProvider extends DriverProvider
{
    protected function name(): string
    {
        return 'cache';
    }

    protected function factory(): DriverFactory
    {
        return new Factory();
    }

    protected function drivers(): array
    {
        return [
            'file' => function($config) {
                return new FileDriver($config);
            },
            'memcache' => function($config) {
                return new MemcacheDriver($config);
            },
            'memory' => function($config) {
                return new MemoryDriver($config);
            },
        ];
    }
}

这是来自framework/Provider/CacheProvider.php

那感觉好多了!我确信我们可以为本章中创建的库重用该模式。

排队等候

排队就是在请求/响应周期之外进行缓慢的操作。如果有人正在与您的网站交互,并要求做一些需要时间做的事情(如发送电子邮件或生成报告),您可以让他们等待处理完成,或者您可以在后台完成并在完成时通知他们。

通知是如何发生的完全是另一回事,但一种方法是给他们发电子邮件。我们将在本章末尾展示一个例子。

这种排队可以通过多种方式实现,从将工作指令存储在文本文件中,到将它们放入缓存中,再到使用专门设计的服务来简化消息的存储和检索。

img/299823_2_En_11_Figa_HTML.jpg

排队任务

让我们构建一个基于数据库的排队系统,包括以下主要组件:

  1. 一个数据库表和提供者/工厂库,用于将内容放入数据库表

  2. 从数据库中提取任务并运行它们的终端命令

这是我能想到的最简单的界面:

app('queue')->push(function($user) {
    // send a mail to the user...
}, $user);

对于这种方法来说,push是一个有趣的名字,因为它描述了将任务放入队列的过程。类似于 PHP 的 array_push 方法,我们会构建一个对应的shift方法。

为了促进这一功能,我们需要一个存储序列化参数(如$user)和序列化闭包的迁移:

use Framework\Database\Connection\Connection;

class CreateJobsTable
{
    public function migrate(Connection $connection)
    {
        $table = $connection->createTable('jobs');
        $table->id('id');
        $table->text('closure');
        $table->text('params');
        $table->int('attempts')->default(0);
        $table->bool('is_complete')->default(false);
        $table->execute();
    }
}

这是来自database/migrations/010_CreateJobsTable.php

我们稍后会看到这些领域中的内容。不过,在此之前,有一个问题需要解决,这是我对提供者所做的更改的后续。

migrate 命令是我们手动创建新连接的地方之一。我们可以通过调用app('database')来切换所有的手工工作,但是我们设置App类的方式意味着在我们尝试分派路由之前,不会加载任何提供者。

我们应该把这些步骤分开,这样测试和command.php就能够使用提供者为我们配置的所有依赖项,而不需要分派一个路由:

public function prepare(): static
{
    $basePath = $this->resolve('paths.base');

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

    return $this;
}

public function run(): Response
{
    return $this->dispatch($this->resolve('paths.base'));
}

这是来自framework/App.php

现在,我们可以更改 migrate 命令以使用已经配置好的数据库连接:

protected function execute(InputInterface $input, OutputInterface $output)
{
    $current = getcwd();
    $pattern = 'database/migrations/*.php';

    $paths = glob("{$current}/{$pattern}");

    if (count($paths) < 1) {
        $this->writeln('No migrations found');
        return Command::SUCCESS;
    }

    // $connection = $this->connection();
    $connection = app('database');

    if ($input->getOption('fresh')) {
        $output->writeln('Dropping existing database tables');

        $connection->dropTables();

        // $connection = $this->connection();
        $connection = app('database');
    }

    // ...rest of the migrate code
}

// private function connection(): Connection
// {
//     $factory = new Factory();

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

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

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

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

这是来自framework/Database/Command/MigrateCommand.php

现在我们需要一组可以使用的类,将作业放入这个表中,然后再取出来。因为我们希望“存储”闭包以供以后执行,所以我们需要一种方法来序列化它。闭包通常不允许被序列化,但是有一些低级的库可以做到这一点。让我们安装其中一个:

composer require opis/closure

序列化闭包有点神奇。没有什么可以阻止我们发送字符串类名和方法名,这样就可以在没有序列化的情况下调用方法。这样做可能会更简单,但我认为演示这个魔术会很有趣。

接下来,我们需要建立通常的提供者/管理者/驱动者系统,这样我们的框架将能够支持存储和检索这些闭包的多种方法:

namespace Framework\Provider;

use Framework\Queue\Factory;
use Framework\Queue\Driver\DatabaseDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class QueueProvider extends DriverProvider
{
    protected function name(): string
    {
        return 'queue';
    }

    protected function factory(): DriverFactory
    {
        return new Factory();
    }

    protected function drivers(): array
    {
        return [
            'database' => function($config) {
                return new DatabaseDriver($config);
            },
        ];
    }
}

这是来自framework/Provider/QueueProvider.php

这个提供程序依赖于一个工厂和一个数据库驱动程序。该工厂与我们的许多其他工厂一样:

namespace Framework\Queue;

use Closure;
use Framework\Queue\Driver\Driver;
use Framework\Queue\Exception\DriverException;
use Framework\Support\DriverFactory;

class Factory implements DriverFactory
{
    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/Queue/Factory.php

每个驱动程序都需要几个方法:

  1. 将单个任务推入队列的push方法

  2. 从队列中取出单个任务的方法

namespace Framework\Queue\Driver;

use Closure;
use Framework\Queue\Job;

interface Driver
{
    public function push(Closure $closure, ...$params): int;
    public function shift(): ?Job;
}

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

数据库驱动程序是有趣的地方!我们可以从实现这个接口的 shell 开始:

namespace Framework\Queue\Driver;

use Closure;
use Framework\Queue\Job;

class DatabaseDriver implements Driver
{
    public function push(Closure $closure, ...$params): int
    {
        // TODO
    }

    public function shift(): ?Job
    {
        // TODO
    }
}

这是来自framework/Queue/Driver/DatabaseDriver.php

让我们也制作一个数据库模型,用于存储和检索闭包和参数:

namespace Framework\Queue;

use Framework\Database\Model;

class Job extends Model
{
    public function getTable(): string
    {
        return config('queue.database.table');
    }
}

这是来自framework/Queue/Driver/DatabaseQueue.php

哦!我们应该用这个变量填充队列配置文件:

return [
    'default' => 'database',
    'database' => [
        'type' => 'database',
        'table' => 'jobs',
        'attempts' => 3,
    ],
];

这是来自config/queue.php

这个表名应该与我们在迁移中创建的表名相匹配。如果您更改了它,那么不要忘记在这里也进行更改。

使用这个模型和我们之前安装的库,我们可以序列化闭包和参数,并将它们存储在数据库中:

use Opis\Closure\SerializableClosure;

// ...

public function push(Closure $closure, ...$params): int
{
    $wrapper = new SerializableClosure($closure);

    $job = new Job();
    $job->closure = serialize($wrapper);
    $job->params = serialize($params);
    $job->attempts = 0;
    $job->save();

    return $job->id;
}

这是来自framework/Queue/Driver/DatabaseQueue.php

通常,当您将一个闭包传递给serialize时,您会看到一个错误。Opis 闭包SerializableClosure充当闭包的包装器,并使用反射将它们转换成可以存储为文本的东西。

我们传递给作业的参数仅限于可序列化的类型,无需特殊处理。例如,我们不能将Model实例或未包装的闭包作为参数传递。

相应的shift方法应该从 jobs 表中提取一个作业,以便可以尝试:

public function shift(): ?Job
{
    $attempts = config('queue.database.attempts');

    return Job::where('attempts', '<', $attempts)
        ->where('is_complete', false)
        ->first();
}

这是来自framework/Queue/Driver/DatabaseQueue.php

在这一点上,我们还需要做一些事情来使它正常工作。首先,我们需要一个终端命令来“处理”排队的作业。第二个是向Job模型添加助手,使其更容易运行,但我们很快就会谈到这一点…

终端命令可能是这样的:

namespace Framework\Queue\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Exception;

class WorkCommand extends Command
{
    protected static $defaultName = 'queue:work';

    protected function configure()
    {
        $this
            ->setDescription('Runs tasks that have been queued')
            ->setHelp('This command waits for and runs queued jobs');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->writeln('<info>Waiting for jobs.</info>');

        while(true) {
            if ($job = app('queue')->shift()) {
                try {
                    $job->run();

                    $output->writeln("<info>Completed {$job->id}</info>");

                    $job->is_complete = true;
                    $job->save();

                    sleep(1);
                }
                catch (Exception $e) {
                    $message = $e->getMessage();
                    $output->writeln("<error>{$message}</error>");

                    $job->attempts = $job->attempts + 1;
                    $job->save();
                }
            }
        }
    }
}

这是来自framework/Queue/Command/WorkCommand.php

这个终端命令意味着在等待新的作业添加到队列中时,它将持续运行。请注意,除了作为模型的工作形式之外,它对数据库一无所知。

或许,我们可以更进一步,制作一种不需要用数据库模型表示的Job对象。或者,我们可以探索“虚拟”模型的概念——拥有熟悉的模型方法但只存储在内存中的对象。类似于寿司

作为一种替代方法,我们可以使用存储库模式。这将给我们一个数据对象的内存表示,而不需要将它存储在数据库中。不过,这是一个与活动记录非常不同的设计。

注意我们如何期望在Job模型上有一个run方法:

public function run(): mixed
{
    $closure = unserialize($this->closure);
    $params = unserialize($this->params);

    return $closure(...$params);
}

这是来自framework/Queue/Job.php

一旦我们向app/comands.php添加了工作命令,那么我们应该能够添加和处理排队的作业了!

尝试对来自未知或潜在错误来源的数据进行反序列化时要小心。可以改变序列化的字符串,这样就可以注入和执行任意代码。换句话说,不要取消序列化用户提交的数据。

即使您信任序列化字符串的来源,它也可能因复杂性而被破坏。总是尝试序列化到一个明确定义的简单规范,或者从该规范序列化。类似于 JSON 的东西非常容易验证和解释。用 JSON 编码原始值时很难出错。

img/299823_2_En_11_Figb_HTML.jpg

运行排队作业

记录

任务排队带来的一个问题是很难知道什么时候失败或成功,因为所有这些都发生在请求/响应周期之外。如果您在自己的计算机上运行queue:work命令,这是没问题的,但是当它在远程服务器上运行时呢?

一个潜在的解决方案是引入一种在后台任务处理期间记录失败和成功的方法。

日志记录是另一个我们可以自己解决的问题,但是不值得我们从头开始实现它。在 PHP 中,已经有了关于日志库如何工作的奇妙的开放标准,比如 PSR-3

除此之外,还有一些实现了 PSR 3 的库,比如 Monolog (来自 Composer 的创作者)。

让我们在 Monolog 上构建我们的日志库:

composer require monolog/monolog

像往常一样,我们需要创建提供者+工厂+驱动程序+配置组合,从提供者开始:

namespace Framework\Provider;

use Framework\Logging\Factory;
use Framework\Logging\Driver\StreamDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class LoggingProvider extends DriverProvider
{
    protected function name(): string
    {
        return 'logging';
    }

    protected function factory(): DriverFactory
    {
        return new Factory();
    }

    protected function drivers(): array
    {
        return [
            'stream' => function($config) {
                return new StreamDriver($config);
            },
        ];
    }
}

这是来自framework/Provider/LoggingProvider.php

我们从一个StreamDriver开始(它将日志文件写入文件系统)。我不会用工厂实现来烦你——因为它和我们做的其他产品几乎完全一样。

可以在framework/Logging/Factory.php找到伐木工厂。

让我们跳到驱动程序界面:

namespace Framework\Logging\Driver;

interface Driver
{
    public function info(string $message): static;
    public function warning(string $message): static;
    public function error(string $message): static;
}

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

这只是 PSR-3 和独白支持的一个子集,但它们是最常见的日志消息类型。如果你需要,可以随意添加更多的方法…

最后,配置文件和StreamDriver:

return [
    'default' => 'stream',
    'stream' => [
        'type' => 'stream',
        'path' => __DIR__ . '/../storage/app.log',
        'name' => 'App',
        'minimum' => \Monolog\Logger::DEBUG,
    ],
];

这是来自config/logging.php

namespace Framework\Logging\Driver;

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

class StreamDriver implements Driver
{
    private array $config;
    private Logger $logger;

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

    public function info(string $message): static
    {
        $this->logger()->info($message);
        return $this;
    }

    private function logger()
    {
        if (!isset($this->logger)) {
            $this->logger = new Logger($this->config['name']);
            $this->logger->pushHandler(new StreamHandler($this->config['path'], $this->config['minimum']));
        }

        return $this->logger;
    }

    public function warning(string $message): static
    {
        $this->logger()->warning($message);
        return $this;
    }

    public function error(string $message): static
    {
        $this->logger()->error($message);
        return $this;
    }
}

这是来自framework/Logging/Driver/LoggingDriver.php

StreamDriver是 Monolog 的 StreamHandler 类的装饰器。它处理独白设置,用我们在config/logging.php中定义的配置变量填充它。

别忘了给config/providers.php加上LoggingProvider类,否则下一位代码就不行了。

我们现在可以在应用的任何地方记录失败和成功:

app('queue')->push(
    fn($name) => app('logging')->info("Hello {$name}"),
    'Chris',
);

app('logging')->info('Send a task into the background');

我建议在后台任务中详尽地记录日志,因为这将有助于发现代码中的错误,并在后台处理问题开始影响客户之前突出它们。

电子邮件

我们要做的最后一个库是发送电子邮件的库。发送电子邮件有几种不同的实用方法:

  1. 使用系统服务发送电子邮件,该服务与 web 服务器并行运行

  2. 通过第三方 API 发送邮件

我从个人经验中知道,建立一个可靠的邮件服务器是多么困难。电子邮件验证和安全领域发生了如此多的事情,除非你是配置专家,否则你的电子邮件很可能会进入垃圾邮件文件夹。

相比之下,有大量价格合理、配置专业的第三方电子邮件 API 可供选择。这个过程通常是调用一个带有目的电子邮件地址和电子邮件内容的 API:

curl -X POST
    https://mandrillapp.com/api/1.0/messages/send
    -H "Accept: application/json"
    -H "Content-Type: application/json"
    -d '{
            "key":"[API KEY]"
            "message": {
                "html": "<h1>Welcome to our website</h1>...",
                "text": "Welcome to our website...",
                "subject": "Registration Complete",
                "to": ["customer@domain.com"]
            }
        }'

这是来自山魈文档的。

我们可以用 PHP 复制这个,但是 Mailchimp 已经创建了一个 PHP 库来加速这个过程。用 PHP 请求发送相同的邮件看起来是这样的:

$client = new MailchimpTransactional\ApiClient();
$client->setApiKey('[API KEY]');

$client->messages->send([
    'message' => [
        'html' => '<h1>Welcome to our website</h1>...',
        'text' => 'Welcome to our website...',
        'subject' => 'Registration Complete',
        'to' => ['customer@domain.com'],
    ]
]);

邮戳有一个类似的 API,您也可以调用它来发送电子邮件:

curl -X POST
    "https://api.postmarkapp.com/email"
    -H "Accept: application/json"
    -H "Content-Type: application/json"
    -H "X-Postmark-Server-Token: [API KEY]"
    -d '{
            "From": "sender@domain.com",
            "To": "customer@domain.com",
            "Subject": "Registration Complete",
            "TextBody": "Welcome to our website...",
            "HtmlBody": "<h1>Welcome to our website</h1>...",
       }'

这是来自邮戳的文件

不出所料,邮戳也有一个 PHP 库,我们可以用来发送类似的电子邮件:

$client = new Postmark\PostmarkClient('[API KEY]');

$client->sendEmail(
    '[SENDER SIGNATURE]',
    'customer@domain.com',
    'Registration Complete',
    '<h1>Welcome to our website</h1>...',
    'Welcome to our website...'
);

还有电子邮件发送抽象(类似于 Flysystem 对文件系统的抽象),比如 SwiftMailer 。让我们遵循我们之前的策略:在 SwiftMailer 之上构建,但是使用我们更喜欢的 API。首先,我们需要安装 SwiftMailer:

composer require swiftmailer/swiftmailer

原来邮戳也有一个快捷邮件插件:

composer require wildbit/swiftmailer-postmark

接下来,让我们制作提供商+工厂+驱动程序+配置组合:

namespace Framework\Provider;

use Framework\Email\Factory;
use Framework\Email\Driver\PostmarkDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class EmailProvider extends DriverProvider
{
    protected function name(): string
    {
        return 'email';
    }

    protected function factory(): DriverFactory
    {
        return new Factory();
    }

    protected function drivers(): array
    {
        return [
            'postmark' => function($config) {
                return new PostmarkDriver($config);
            },
        ];
    }
}

这是来自framework/Provider/EmailProvider.php

看看工厂的样子。和我们以前做的一样。驱动程序包含了一系列可链接的方法,类似于我们对QueryBuilder所做的:

namespace Framework\Email\Driver;

interface Driver
{
    public function to(string $to): static;
    public function subject(string $subject): static;
    public function text(string $text): static;
    public function html(string $html): static;
    public function send(): void;
}

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

该接口的实现与我们实现的日志驱动程序的工作方式类似,但是有更多的验证:

namespace Framework\Email\Driver;

use Framework\Email\Exception\CompositionException;
use Postmark\Transport;
use Swift_Mailer;
use Swift_Message;

class PostmarkDriver implements Driver
{
    private array $config;
    private Swift_Mailer $mailer;
    private string $to;
    private string $subject;
    private string $text;
    private string $html;

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

    public function to(string $to): static
    {
        $this->to = $to;
        return $this;
    }

    public function subject(string $subject): static
    {
        $this->subject = $subject;
        return $this;
    }

    public function text(string $text): static
    {
        $this->text = $text;
        return $this;
    }

    public function html(string $html): static
    {
        $this->html = $html;
        return $this;
    }

    public function send(): void
    {
        if (!isset($this->to)) {
            throw new CompositionException('to required');
        }

        if (!isset($this->text) && !isset($this->html)) {
            throw new CompositionException('text or email required');
        }

        $fromName = $this->config['from']['name'];
        $fromEmail = $this->config['from']['email'];

        $subject = $this->subject ?? "Message from {$fromName}";

        $message = (new Swift_Message($subject))
            ->setFrom([$fromEmail => $fromName])
            ->setTo([$this->to]);

        if (isset($this->text) && !isset($this->html)) {
            $message->setBody($this->text, 'text/plain');
        }

        if (!isset($this->text) && isset($this->html)) {
            $message->setBody($this->html, 'text/html');
        }

        if (isset($this->text, $this->html)) {
            $message
                ->setBody($this->html, 'text/html')
                ->addPart($this->text, 'text/plain');
        }

        $this->mailer()->send($message);
    }

    private function mailer()
    {
        if (!isset($this->mailer)) {
            $transport = new Transport($this->config['token']);
            $this->mailer = new Swift_Mailer($transport);
        }

        return $this->mailer;
    }
}

这是来自framework/Email/Driver/PostmarkDriver.php

我们也许可以做更多的验证,以确保$config数组的格式是我们期望的格式,但是我将把它作为一个练习留给你。理想的配置文件应该是这样的:

return [
    'default' => 'postmark',
    'postmark' => [
        'type' => 'postmark',
        'token' => env('EMAIL_TOKEN'),
        'from' => [
            'name' => env('EMAIL_FROM_NAME'),
            'email' => env('EMAIL_FROM_EMAIL'),
        ],
    ]
];

这是来自config/email.php

这就是说,一旦我们将EmailProvider添加到config/providers.php中,我们应该能够轻松发送电子邮件:

app('queue')->push(
    fn($name) => app('email')
        ->to('cgpitt@gmail.com')
        ->text("Hello {$name}")
        ->send(),
    'Chris',
);

在这个例子中,我在一个队列任务中发送电子邮件。发送电子邮件通常是一个缓慢的过程,所以最好在 HTTP 请求/响应周期之外做这类事情。

警告

有很多事情我们可以花更多的时间去尝试。这里有一些你可能有兴趣尝试的:

  1. 我们只为每个库设置了一个驱动程序。想象一个使用 Redis 或亚马逊 SQS 存储消息的队列驱动程序或一个 Slack 日志驱动程序。

  2. 我们对许多配置文件结构做了假设。我们不能总是相信开发人员会遵循文档,所以我们应该帮助他们发现配置格式何时无效。

  3. 流行的框架喜欢将基于驱动的依赖关系转移到建议的依赖关系中,并记录了这种方法。通常,当你想在 Laravel 中发送电子邮件时,你还需要安装像wildbit/swiftmailer-postmark这样的特定于供应商的库。这是一个很好的模式,因为这意味着开发人员不会自动为他们不使用的驱动程序安装依赖项。

  4. 我们可以支持多个异常处理程序,而不是只支持一个,第一个返回响应的处理程序成为“赢家”

摘要

这是最后一章代码。我们已经学到了很多关于构建框架代码和构建一套可靠的库来使用的知识。

花些时间回顾一下你写的代码和你学到的东西。我将在下一章开始谈论我想对框架和应用做的一些最后的改变。

十二、发布您的代码

是时候说说我们的框架做出来之后会发生什么了。写代码只是开始。难的是让人们使用它,并保持它的新鲜和有用。

我认为,在我乐意将它投入使用之前,通过谈论我仍然希望对我们的框架和示例应用做些什么,将有助于构建这部分内容…

收尾

每个流行的框架都有一个初学者工具包。这些是您可以复制、克隆或安装的项目,以便了解代码是如何工作的。这些初学者工具包是新应用的起点。它们应该是有帮助的和容易理解的。

考虑到这一点,在向全世界发布之前,我想补充一些东西。

#1:有用的主页

让主页更有帮助。用户看到的第一页应该让他们放心,一切都设置正确,这样他们就可以开始开发了。应该有文档和其他有用资源的链接(比如托管和调试选项)。

img/299823_2_En_12_Figa_HTML.jpg

Laravel 的有用主页

这是你启动 Laravel 的开发服务器时看到的第一个东西。要看到这一点,您只需要三个命令:

composer create-project laravel/laravel my-new-project
cd my-new-project
php artisan serve

从一个新的框架开始可能是一项艰巨的任务,所以这有很大的不同。“让事情运转起来”不需要任何配置如果您想开始使用模型(或其他数据库功能),那么您需要设置一个数据库。

通过默认配置 SQLite,这可能会简单一点。那么新用户不需要配置任何东西就可以使用框架 80%的功能。

#2:更多功能示例

我们构建了如此多的功能,但在示例应用中展示的却很少。这里有一些使用会话和缓存的小例子。没别的了。

即使这些例子被注释掉了,显示做普通事情的适当时间和方式仍然是有用的。我们可以展示如何发送电子邮件或在队列中做一些事情,或者如何将文件放在远程文件系统中。

这就是教程和好的文档做了大量繁重工作的地方。说到这个…

#3:良好的文档

好的文档(或缺少文档)是其他开发人员对你的框架的第一个也是最大的抱怨。他们想知道如何使用这个框架,如何配置它,在哪里支持它,以及它的局限性是什么。

你的文档可以有很大的范围。Laravel 简洁明了,专注于功能的关键部分。Laravel 社区(以及一般的 PHP 社区)擅长以书面和视频教程的形式填补空白。

另一方面,Symfony 拥有大量的文档,有些人可能会发现这些文档在数量和具体性方面令人应接不暇。关于如何使用 Symfony 组件,我从未有过文档中没有回答的问题。

关键是要给出足够的信息,说明你期望组件如何被最常用,并有链接指向好的社区资源,以进行专门的使用、优化和扩展。

我使用的第一个 PHP 框架是 CodeIgniter。(在写作的时候)它仍然活蹦乱跳,对我的职业生涯影响很大。不是因为它有最好的代码或最大的社区,而是因为文档告诉了我学习如何使用这个框架所需要知道的一切。

img/299823_2_En_12_Figb_HTML.jpg

CodeIgniter 2 文档目录

最近的版本取消了“介绍”,但我想现在有更多的方法来了解这些主题。

#4:完成测试

开发人员的另一个大问题(也是理所当然的)是,当一个新的框架出现时,没有一个全面有效的测试套件。如果你想发布一个新的框架,确保你有 90–100%的功能被自动化测试覆盖。

你不需要测试第三方代码,但是你应该测试你连接和使用它的方式。

即使使用正确的工具,测试也可能是一门很难学的学科,但是这不是你想要的捷径。参加相关课程或阅读相关书籍:

#5:确保这是你想做的事情

构建一个框架很难,但这是旅程中最容易的部分。如果你做得好的话,多年来维护一个框架会占据你生活的大部分时间。

您最好从不同的框架中取出许多较小的部分,并将它们组合成对您的目的有帮助的东西。

或者,更好的是,自己学习如何使用一个流行的、受到良好支持的框架。这些我们会在后记里多讲。

使用 Packagist

在本书中,我们已经使用 Composer 安装了许多库。这些库都托管在一个名为 Packagist 的网站上。如果您希望您的框架(或单个库)可以通过 Composer 安装,您需要将它提交给 Packagist。

如果您还没有,请创建一个帐户。如果您使用 GitHub 托管您的代码,您也可以使用 GitHub 登录 Packagist。

img/299823_2_En_12_Figc_HTML.jpg

使用 GitHub 登录

您的库需要有一个定义良好的composer.json文件。我不想深入探讨这个问题的细节,因为它本身就是一门艺术。

如果你现在想知道如何发布你自己的包,那就去看看像 PHP 包开发这样的视频课程。它涵盖了关于composer.json规范的细节,以及如何很好地构建独立的 Composer 包。

完整的composer.json规范需要花相当多的时间来理解,您可以在 Composer 文档网站上找到它。

一旦你完成了一个,通过 Packagist 的提交表格提交。

img/299823_2_En_12_Figd_HTML.jpg

提交包

然后,你(或其他任何人)将能够通过一个composer require终端命令来安装你的库。这就是事情的全部。

摘要

如果你不记得这一章的其他内容,就让它这样吧:维护一个框架是一项艰苦的工作。构建一个框架是一个必经之路,也是了解它们如何工作的好方法,但是拥有一个框架是一个重大的决定。这不是一项容易的任务。

希望这一章已经让你看到了一些等待你的挑战和你应该考虑的事情。我会在这本书的后记中见到你。