PHP8 MVC 高级教程(二)

原文:Pro PHP 8 MVC

协议:CC BY-NC-SA 4.0

五、构建验证器

是时候开始给我们的应用添加更多的结构了。我们一直非常关注框架代码——这并不是一件坏事——但这意味着我们忽略了开始构建一个合适的网站所需的一些基本东西。

大多数网站都有表格。让我们更容易地构建和使用它们来捕获用户输入!首先,我们需要制定一个替代方案,将所有应用代码保存在 routes 文件中…

什么是控制器?

我们已经讨论了 MVC 的大部分“视图”部分,我们将在下一章深入探讨模型部分。在本章中,我们将创建我们的第一个控制器。

有许多方法可以组织应用,并将普通代码与业务逻辑分开。在这上面花很多时间很诱人,但这不是本书或本章的目的。相反,我建议你看一看马蒂亚斯关于这个主题的书。

控制器更多地由它们不应该包含的代码定义,而不是由它们应该包含的代码定义——至少如果你问 Twitter 或 Reddit:

  • "控制器不应该包含应该在浏览器中显示的代码,比如 HTML 和 CSS . "

  • “控制器不应该包含处理数据库或文件系统的代码.”

这些建议可以带来更清晰的代码库,但它们无助于解释控制器中应该包含哪些代码。简单地说,控制器应该是应用深层部分之间的粘合剂。

就我们的框架和应用而言,它们是 HTTP 请求将被发送到的地方,并通过它们将响应发送回浏览器。

这与构建验证器有什么关系?

因为控制器处理请求数据并安排响应数据,所以它们是应该进行验证的地方。以下是我们将在验证库中构建的主要功能:

  • 定义验证函数的一些结构

  • 一些预建的验证函数

  • 一种给验证类简单命名的方法,这样它们就可以被快速使用

  • 针对请求数据运行一组验证规则的方法

  • 一种自定义验证错误信息的方法

一旦我们完成了,我们将看看一些流行的验证库和方法,这样我们就可以对那里有什么有一个感觉。

改进错误处理

在我们开始控制器和验证之前,我想解决一个问题,自从我们制造路由以来,我一直在努力解决这个问题。在Router->dispatch()方法中,我们捕捉异常并显示一个简单的错误页面。

我认为我们可以做一些更有用的东西,至少对于开发环境来说。让我们安装一个伟大的开源库来格式化错误响应:

composer require filp/whoops

然后,我们可以显示一个有用的堆栈跟踪,而不是显示上一个(相当无用的)错误页面:

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) {
            $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

现在我们的错误将更容易追查。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

更好的错误信息

不过,有一个问题。在生产环境中保留这样的东西通常不是一个好主意,因为它可能会暴露危及您的服务器、服务或用户的秘密。

我们需要添加一些东西来确保它只在开发环境中显示。我们可以根据 URL 过滤,但是有一个更好的解决方案!

让我们创建一个名为.env的文件,其中存储了环境名:

APP_ENV=dev

这是来自.env

将这个文件添加到.gitignore中是很重要的,这样存储在其中的任何秘密都不会被提交和推送到像 GitHub 这样的地方:

vendor
.env

这是来自.gitignore

想法是这样的:

  • 秘密保存在.env和特定于环境的事物中(比如环境的名称或类型)。

  • 没有共享或提交给源代码控制系统。

  • 当应用需要知道某个秘密或环境的名称或类型时,它会查看这个文件。

当然,我们可以创建一个模板,以便人们在他们的机器上设置应用时,知道他们需要什么秘密和环境细节:

APP_ENV=

这是来自.env.example

通常把这个文件叫做.env.example,这样文件名就暗示了这个文件就是一个例子。当你看到一个包含这个文件的项目时,你可以自信地认为这个项目需要一个.env文件中的秘密。

我们将环境称为“dev”,但是我们如何在路由中获得这个值呢?我们可以使用另一个很棒的开源库:

composer require vlucas/phpdotenv

这个库读取.env中的秘密,并将它们放入 PHP 环境中。我们需要在应用生命周期的开始“加载”这些秘密:

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

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();

$router = new Framework\Routing\Router();

$routes = require_once __DIR__ . '/../app/routes.php';
$routes($router);

print $router->dispatch();

这是来自public/index.php

createImmutable方法寻找一个.env文件,所以我们需要告诉它这个文件可能在哪个文件夹中。现在,我们可以在路由中看到我们的APP_ENV变量:

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 (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

通过此项检查,仅当APP_ENV=dev出现时,才会出现呜呜堆栈跟踪错误页面。我建议您将APP_ENV的默认值设置为其他值,这样使用您的应用(或框架)的人就不会看到这个错误页面。就安全而言,这是最安全的位置。

创建控制器

让我们将我们的路由闭包代码移到控制器中。首先,让我们制作一个新的主页控制器:

namespace App\Http\Controllers;

class ShowHomeController
{
    public function handle()
    {
        return view('home', ['number' => 42]);
    }
}

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

我喜欢冗长的名字,像这样。关于这样的后缀是否好,有一个有趣的讨论,但我更喜欢这样命名常见的事物:

  • 型号→事物(单数)→“产品”

  • 控制器→动词+事物+“控制器”→“ShowProductController”

  • 事件→事物+动词(过去式)→“product created”

根据我的经验,这种命名方案会导致更少的冲突和混乱。

现在,我们需要将路线从使用内联闭包改为使用这个控制器:

use App\Http\Controllers\ShowHomePageController;
use Framework\Routing\Router;

return function(Router $router) {
    $router->add(
        'GET', '/',
        [ShowHomePageController::class, 'handle'],
    );

    //...
};

这是来自app/routes.php

这不会马上奏效,原因有几个:

  • 我们已经在RouterRoute类中输入了callable

  • 即使我们不是,我们也不能像调用函数一样调用数组,除非第二项是可以静态调用的方法的名称。

让我们解决这两个问题,从Router类开始,我们移除了$handler的类型:

public function add(string $method, string $path, $handler): Route
{
    $route = $this->routes[] = new Route($method, $path, $handler);
    return $route;
}

这是来自framework/Routing/Router.php

现在,我们可以在Route类中做同样的事情:

public function __construct(string $method, string $path, $handler)
{
    $this->method = $method;
    $this->path = $path;
    $this->handler = $handler;
}

这是来自framework/Routing/Route.php

这只是问题的一半。我们还需要使Route->dispatch()方法能够处理非静态控制器方法:

public function dispatch()
{
    if (is_array($this->handler)) {
        [$class, $method] = $this->handler;
        return (new $class)->{$method}();
    }

    return call_user_func($this->handler);
}

这是来自framework/Routing/Route.php

这个数组引用技巧很巧妙。它让我们将数组分成几个命名的变量,这样我们就可以创建一个变量类并调用一个变量方法。

主页又开始工作了!让我们创建另一个控制器:

namespace App\Http\Controllers\Products;

class ListProductsController
{
    public function handle()
    {
        $parameters = $router->current()->parameters();
        $parameters['page'] ??= 1;

        $next = $router->route(
            'list-products', ['page' => $parameters['page'] + 1]
        );

        return view('products/list', [
            'parameters' => $parameters,
            'next' => $next,
        ]);
    }
}

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

这不会立即生效,因为我们不再能够访问局部变量$router。现在,让我们改变我们的Route->dispatch方法来接受一个类名或者一个已经创建的对象,这样我们就有可能提供一个已经被赋予路由的控制器对象:

public function dispatch()
{
    if (is_array($this->handler)) {
        [$class, $method] = $this->handler;

        if (is_string($class)) {
            return (new $class)->{$method}();
        }

        return $class->{$method}();
    }

    return call_user_func($this->handler);
}

这是来自framework/Routing/Route.php

我们假设如果第一项不是类名,它就是一个对象。情况可能并非如此,所以您可能需要对此进行更多的验证。

我不太担心,因为我们会回到这个问题上来,以这样一种方式重构依赖关系管理,当以这种方式创建路由时,您不需要或不想传递对象。

现在,我们可以这样定义路线:

$router->add(
    'GET', '/products/{page?}',
    [new ListProductsController($router), 'handle'],
)->name('list-products');

这是来自app/routes.php

但是,我们需要将路由存储在我们的控制器中:

namespace App\Http\Controllers\Products;

use Framework\Routing\Router;

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

    public function handle()
    {
        $parameters = $this->router->current()->parameters();
        $parameters['page'] ??= 1;

        $next = $this->router->route(
            'list-products', [
                'page' => $parameters['page'] + 1,
            ]
        );

        return view('products/list', [
            'parameters' => $parameters,
            'next' => $next,
        ]);
    }
}

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

我们可以制作的另外两个控制器用于显示单个产品和单个服务。它们很小,所以我不会展示代码。如果你不能解决这些问题,请查看项目资源库…

重构后的路由文件如下所示:

use App\Http\Controllers\ShowHomePageController;
use App\Http\Controllers\Products\ListProductsController;
use App\Http\Controllers\Products\ShowProductController;
use App\Http\Controllers\Services\ShowServiceController;
use App\Http\Controllers\Users\ShowRegisterFormController;
use Framework\Routing\Router;

return function(Router $router) {
    $router->add(
        'GET', '/',
        [ShowHomePageController::class, 'handle'],
    )->name('show-home-page');

    $router->errorHandler(
        404, fn() => 'whoops!'
    );

    $router->add(
        'GET', '/products/view/{product}',
        [new ShowProductController($router), 'handle'],
    )->name('view-product');

    $router->add(
        'GET', '/products/{page?}',
        [new ListProductsController($router), 'handle'],
    )->name('list-products');

    $router->add(
        'GET', '/services/view/{service?}',
        [new ShowServiceController($router), 'handle'],
    )->name('show-service');
};

这是来自app/routes.php

这比以前整洁多了,部分原因是我们删除了一些调试路径,但也因为它没有内联闭包。

我已经给所有的路线起了名字(并且安排了现有的名字,所以它们与控制器的名字一致)。您还将看到,我已经重构了视图,因此它们都使用相同的布局和引擎。你可以不做这些改变,但是我推荐你做!

创建表单

我们需要为 Whoosh 构建的东西之一是客户购买火箭的能力。他们将需要一个帐户,通过扩展,应用将需要一个注册页面。

让我们创建一个表单:

@extends('layout')
<h1 class="text-xl font-semibold mb-4">Register</h1>
<form
  method="post"
  action="{{ $router->route('show-register-form') }}"
  class="flex flex-col w-full space-y-4"
>
  <label for="name" class="flex flex-col w-full">
    <span class="flex">Name:</span>
    <input
      id="name"
      name="name"
      type="text"
      class="focus:outline-none focus:border-blue-300 border-b-2 border-gray-300"
      placeholder="Alex"
    />
  </label>
  <label for="email" class="flex flex-col w-full">
    <span class="flex">Email:</span>
    <input
      id="email"
      name="email"
      type="email"
      class="focus:outline-none focus:border-blue-300 border-b-2 border-gray-300"
      placeholder="alex.42@gmail.com"
    />
  </label>
  <label for="password" class="flex flex-col w-full">
    <span class="flex">Password:</span>
    <input
      id="password"
      name="password"
      type="password"
      class="focus:outline-none focus:border-blue-300 border-b-2 border-gray-300"
    />
  </label>
  <button
    type="submit"
    class="focus:outline-none focus:border-blue-500 focus:bg-blue-400 border-b-2 border-blue-400 bg-blue-300 p-2"
  >
    Register
  </button>
</form>

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

这里没什么可说的。它是一个表单中的三个表单字段,提交给一个名为register-user的路由。为此,我们需要创建几条新路线:

$router->add(
    'GET', '/register',
    [new ShowRegisterFormController($router), 'handle'],
)->name('show-register-form');

$router->add(
    'POST', '/register',
    [new RegisterUserController($router), 'handle'],
)->name('register-user');

这是来自app/routes.php

第一个控制器类似于我们以前制作的其他“只读”控制器,因为它只需要返回一个视图:

namespace App\Http\Controllers\Users;

use Framework\Routing\Router;

class ShowRegisterFormController
{
    protected Router $router;

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

    public function handle()
    {
        return view('users/register', [
            'router' => $this->router,
        ]);
    }
}

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

第二个是事情开始变得有趣的地方。我们需要在其中做以下事情:

  • 获取从表单发送的数据。

  • 检查它是否通过各种标准。

  • 如果没有通过标准,则返回错误。

  • 创建新的数据库记录。

  • 重定向至成功消息。

我们将在接下来的两章中学习数据库,所以现在我们可以假装这一部分。以下是其余部分:

namespace App\Http\Controllers\Users;

use Framework\Routing\Router;

class RegisterUserController
{
    protected Router $router;

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

    public function handle()
    {
        $data = validate($_POST, [
            'name' => ['required'],
            'email' => ['required', 'email'],
            'password' => ['required', 'min:10'],
        ]);

        // use $data to create a database record...

        $_SESSION['registered'] = true;

        return redirect($this->router->route('show-home-page'));
    }
}

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

该控制器需要一组额外的功能-validateredirect。让我们先解决redirect,因为这是两者中比较简单的一个:

if (!function_exists('redirect')) {
    function redirect(string $url)
    {
        header("Location: {$url}");
        exit;
    }
}

这是来自framework/helpers.php

有很多不同的方法来处理重定向,但我认为这是最干净的。从技术上讲,我们不需要将调用结果返回给redirect,但这有助于提醒我们redirect是一个“终结”动作。在我们呼叫redirect之后,在控制器中不会也不应该发生任何事情。

以这种方式设置标题并退出并不理想。应用最好能区分重定向和响应。当我们到第九章关于测试的时候,我们会为这个问题建立一个更好的解决方案。

现在我们需要研究validate方法。它应该创建一个“验证”服务类,我们可以向其中添加框架支持的可用验证方法。

这就是它的样子:

if (!function_exists('validate')) {
    function validate(array $data, array $rules)
    {
        static $manager;

        if (!$manager) {
            $manager = new Validation\Manager();

            // let's add the rules that come with the framework
            $manager->addRule('required', new Validation\Rule\RequiredRule());
            $manager->addRule('email', new Validation\Rule\EmailRule());
            $manager->addRule('min', new Validation\Rule\MinRule());
        }

        return $manager->validate($data, $rules);
    }
}

这是来自framework/helpers.php

这与我们用视图管理器所做的类似。如果这是第一次调用validate函数,那么我们设置验证管理器。

我们向它添加规则,比如requiredmin,这样人们就可以在他们的控制器中使用这些验证器,而不需要自己添加规则。

稍后,我们将学习如何允许其他人将他们自己的验证规则添加到系统中,但这将需要一个比我们目前拥有的更好的“记住”经理的结构。

在我们深入验证管理器类之前,让我们看看规则类是如何定义的。它们基于这个接口:

namespace Framework\Validation\Rule;

interface Rule
{
    public function validate(array $data, string $field, array $params);
    public function getMessage(array $data, string $field, array $params);
}

这是来自framework/Validation/Rule/Rule.php

每个规则都应该有一种方法来判断表单数据是通过还是失败,以及一种方法来返回相应的失败错误消息。然后,我们可以定义我们正在使用的每个规则:

namespace Framework\Validation\Rule;

class EmailRule implements Rule
{
    public function validate(array $data, string $field, array $params)
    {
        if (empty($data[$field])) {
            return true;
        }

        return str_contains($data[$field], '@');
    }

    public function getMessage(array $data, string $field, array $params)
    {
        return "{$field} should be an email";
    }
}

这是来自framework/Validation/Rule/EmailRule.php

EmailRule不应该要求表单中有任何数据(因为那是RequiredRule的工作)。这就是为什么如果没有任何数据,我们会返回一个成功的响应。

另一方面,如果数据存在,那么我们检查它是否包含一个“@”符号。我们可以做更复杂的检查来判断值看起来是否更像电子邮件地址,但它们几乎不能确保电子邮件地址有效。维护它们是一种痛苦。

如果你真的在乎用户提供一个有效的电子邮件地址,那就给他们发一封电子邮件,里面有一个链接,点击后可以验证他们的账户。

对于min规则,我们需要检查提供的表单值的长度是否至少与我们在声明有效性规则时传递的参数一样多。记住,我们将规则定义为'password' => ['required', 'min:10']。这意味着我们需要获取第一个参数,并在与密码的字符串长度进行比较之前检查是否提供了该参数:

namespace Framework\Validation\Rule;

use InvalidArgumentException;

class MinRule implements Rule
{
    public function validate(array $data, string $field, array $params)
    {
        if (empty($data[$field])) {
            return true;
        }

        if (empty($params[0])) {
            throw InvalidArgumentException('specify a min length');
        }

        $length = (int) $params[0];

        strlen($data[$field]) >= $length;
    }

    public function getMessage(array $data, string $field, array $params)
    {
        $length = (int) $params[0];

        return "{$field} should be at least {$length} characters";
    }
}

这是来自framework/Validation/Rule/EmailRule.php

MinRule类寻找长度参数,并确保所提供的数据至少一样长。如果参数丢失,我们可能会抛出一个异常,但这应该很容易自己解决:

namespace Framework\Validation\Rule;

class RequiredRule implements Rule
{
    public function validate(array $data, string $field, array $params)
    {
        return !empty($data[$field]);
    }

    public function getMessage(array $data, string $field, array $params)
    {
        return "{$field} is required";
    }
}

这是来自framework/Validation/Rule/RequiredRule.php

最后,RequiredRule类只检查表单字段是否为空。

好的,这些是我们需要的规则,但是我们如何利用它们呢?下面是验证管理器类:

namespace Framework\Validation;

use Framework\Validation\Rule\Rule;
use Framework\Validation\ValidationException;

class Manager
{
    protected array $rules = [];

    public function addRule(string $alias, Rule $rule): static
    {
        $this->rules[$alias] = $rule;
        return $this;
    }

    public function validate(array $data, array $rules): array
    {
        $errors = [];

        foreach ($rules as $field => $rulesForField) {
            foreach ($rulesForField as $rule) {
                $name = $rule;
                $params = [];

                if (str_contains($rule, ':')) {
                    [$name, $params] = explode(':', $rule);
                    $params = explode(',', $params);
                }

                $processor = $this->rules[$name];

                if (!$processor->validate($data, $field, $params)) {
                    if (!isset($errors[$field])) {
                        $errors[$field] = [];
                    }

                    array_push($errors[$field], $processor->getMessage($data, $field, $params));
                }
            }
        }

        if (count($errors)) {
            $exception = new ValidationException();
            $exception->setErrors($errors);
            throw $exception;
        }

        return array_intersect_key($data, $rules);
    }
}

这是来自framework/Validation/Manager.php

通过阅读代码,没有什么需要解释的。validate方法执行以下步骤:

  1. 对于每个字段,遍历规则。

  2. 对于每个规则,获取处理器(或Rule类)并通过其validate方法运行数据。

  3. 如果出现故障,获取处理器的消息并将其添加到$errors数组中。

  4. 如果有错误,抛出一个包含错误记录的异常。

  5. 或者,返回经过验证的表单值。

    这是我们需要回来重构的另一件事。使用这种ValidationException方法,我的目的是提供一个人们可以扩展/定制的异常处理程序,这样他们就可以对验证异常做出不同的反应。我们将在第九章回到这个话题。

这预先假定了一个ValidationException类:

namespace Framework\Validation;

use InvalidArgumentException;

class ValidationException extends InvalidArgumentException
{
    protected array $errors = [];

    public function setErrors(array $errors): static
    {
        $this->errors = $errors;
        return $this;
    }

    public function getErrors(): array
    {
        return $this->errors;
    }
}

这是来自framework/Validation/ValidationException.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) {
            if ($e instanceof ValidationException) {
                $_SESSION['errors'] = $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

如果截获的错误类型是验证异常,我们可以将错误消息存储在会话中,并重定向回提交表单的前一个页面。

我们不应该忘记启动会话,否则错误消息(以及前面的“注册”会话变量)将不会被存储:

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

session_start();

//...

这是来自public/index.php

我们还应该显示错误消息,这样客户就知道哪里出错了。我们可以在登记表上这样做:

@extends('layout')
<h1 class="text-xl font-semibold mb-4">Register</h1>
<form
  method="post"
  action="{{ $router->route('register-user') }}"
  class="flex flex-col w-full space-y-4"
>
  @if(isset($_SESSION['errors']))
  <ol class="list-disc text-red-500">
    @foreach($_SESSION['errors'] as $field => $errors) @foreach($errors as
    $error)
    <li>{{ $error }}</li>
    @endforeach @endforeach
  </ol>
  @endif //...
</form>

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

如果有错误,我们可以遍历它们并打印出每一个。通过一点点的格式化,我们可以让它们在客户面前脱颖而出。就这样,我们实现了可重用的表单验证!

保护我们的形式

如果不解决跨站请求伪造(或 CSRF) 的问题,我无法结束这一章。这是一个漏洞,迫使用户在他们不知情的情况下,在他们被认证的网站上做一些事情。

想象一下,我们建立了一个网站,客户可以在那里购买火箭。现在,想象一下有人在另一个网站上嵌入了一些 JavaScript(使用跨站点脚本),该网站将使用我们客户的认证会话来购买最大最好的火箭:所有这些都在我们客户不知情的情况下进行。

CSRF 保护通过强制经过身份验证的用户发起操作,使这变得更加困难。这种保护依赖于启动生成唯一令牌的操作的页面,以及表单提交以检查令牌是否与预期相符的页面。

让我们看看这在代码中是什么样子的:

if (!function_exists('csrf')) {
    function csrf()
    {
        $_SESSION['token'] = bin2hex(random_bytes(32));
        return $_SESSION['token'];
    }
}

if (!function_exists('secure')) {
    function secure()
    {
        if (!isset($_POST['csrf']) || !isset($_SESSION['token']) ||
!hash_equals($_SESSION['token'], $_POST['csrf'])) {
            throw new Exception('CSRF token mismatch');
        }
    }
}

这是来自framework/helpers.php

第一个函数创建一个令牌并将其存储在会话中。第二个检查令牌是否由表单提供,以及它是否与会话存储的令牌匹配。

我们应该用这个来保护每一个重要的控制器,最好是自动的。当我们开发我们的框架时,我们将学习我们可以使用的伟大模式——比如中间件——来达到这个目的。现在,我们可以手动添加安全性:

public function handle()
{
    secure();

    //...

    return redirect($this->router->route('show-home-page'));
}

这是来自app/Http/Controllers/Users/RegisterUserController.php

现在,如果我们提交表单,我们应该会看到抛出的异常。我们需要将令牌添加为隐藏字段:

@extends('layout')
<h1 class="text-xl font-semibold mb-4">Register</h1>
<form
  method="post"
  action="{{ $router->route('register-user') }}"
  class="flex flex-col w-full space-y-4"
>
  <input type="hidden" name="csrf" value="{{ csrf() }}" />
  //...
</form>

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

表单应该可以再次工作,但是这次更加安全。

专家是如何做到的

我们模仿 Laravel 使用的许多模式来建模我们的验证库。框架本身附带了许多内置的验证器,并且有一种明确的方法来扩展验证,以添加定制的验证功能:

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class Uppercase implements Rule
{
    public function passes($attribute, $value)
    {
        return strtoupper($value) === $value;
    }

    public function message()
    {
        return 'The :attribute must be uppercase.';
    }
}

// later...

use App\Rules\Uppercase;

$request->validate([
    'name' => ['required', 'string', new Uppercase],
]);

它还接受闭包作为验证规则,因此您甚至不需要跳出控制器来定义自己的验证规则:

$validator = Validator::make($request->all(), [
    'title' => [
        'required',
        'max:255',
        function ($attribute, $value, $fail) {
            if ($value === 'foo') {
                $fail($attribute.' is invalid.');
            }
        },
    ],
]);

这是 Laravel 让用户变得超级容易的领域之一。与构成框架的许多库一样,验证库可以在 Laravel 应用之外使用。

另一个伟大的验证库是尊重。这里,规则是使用更灵活的语法定义的:

use Respect\Validation\Validator as v;

$username = v::alnum()->noWhitespace()->length(1, 15);
$username->validate('assertchris'); // returns true

尊重有许多内置的规则,所以即使你想使用 Laravel 的验证库,你可能会发现一个验证规则的实现,尊重实现了,但 Laravel 没有。

处理验证错误

Laravel 最酷的特性之一是处理框架抛出的任何异常的扩展点。每个新应用都有一个异常Handler类:

namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;

class Handler extends ExceptionHandler
{
    /**
     * A list of the exception types that are not reported.
     *
     * @var array
     */
    protected $dontReport = [
        //
    ];

    /**
     * A list of the inputs that are never flashed for validation exceptions.
     *
     * @var array
     */
    protected $dontFlash = [
        'password',
        'password_confirmation',
    ];

    /**
     * Register the exception handling callbacks for the application.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

这通常是从app/Exceptions/Handler.php开始的。

当我们构建我们的框架时,我想构建一些类似的东西,以便我们添加到路由的验证异常处理代码有一个更合适的位置。

摘要

我们在这一章中讨论了很多。我们从添加更好的错误处理和环境秘密开始。接下来,我们将应用安排到控制器中,这样我们就不必费力地通过一个巨大的 routes 文件。

然后,我们添加验证规则和助手函数来运行它们。帮助器函数以这种方式和那种方式重定向。帮助器函数为我们的表单添加 CSRF 保护。

多么忙碌的一章!

在下一章,我们将开始在数据库中工作。我们将学习如何连接到各种引擎,以及如何读写它们。

六、构建数据库的库

在前一章中,我们构建了一个注册表单。在验证了数据之后,我们停止了将数据放入数据库。我们将在本章中介绍这一点!

我们将为后面的章节打下坚实的基础,创建连接多个数据库引擎并以安全的方式执行查询的代码。我们甚至会提供一个迁移系统。如果有些内容不熟悉,请不要担心,我们会稳步进行。

你需要访问 MySQL 数据库,本章的大部分代码才能运行。有关环境设置的详细信息,请参考第一章。

数据库的库是用来做什么的?

也许您认为数据库的库只对“读取和写入数据库”有用,但是它们可以有深度。首先,流行的数据库的库允许开发人员通过简单的配置更改来使用几种不同的数据库引擎。

管理 MySQL 数据库与管理 SQLite 数据库和管理 MS SQL Server 数据库有点不同。为执行数据库查询而编写的代码应该能够与多个数据库引擎一起工作,使用通用数据库的库的人不应该需要根据他们使用的数据库引擎编写不同的“查询”。

此外,查询应该是“安全的”,这意味着一个好的数据库的库需要对其中使用的数据进行转义,这样就很难做一些不安全的事情。如果可能,应该使用预准备语句,因为它们提供了性能和安全优势。

我们将建立一个拥有所有这些优势的数据库的库。

我们还将花一些时间来构建一个迁移助手,以便可以将数据库的结构提交给代码。严格地说,迁移并不是构建一个好的数据库的库的必要条件,但是它们是有用的,并且在相同的领域中。

我们应该添加的内容

让我们列举一下我们想要构建的重要特性,这样你就知道接下来会发生什么了。以下是我认为我们应该补充的内容:

  • 一个创建数据库“驱动程序”的工厂,该驱动程序特定于我们在简单配置中选择的引擎

  • 与我们关心的每个数据库引擎对话的不同“方言”

  • 执行安全数据库查询的模式(如获取、插入、更新和删除行)

  • 基本迁移,它将新表或表修改持久化到我们支持的每个数据库引擎

    我们不必使用工厂模式来连接数据库引擎,但是维护一个连接创建集中在一个类中的库要容易得多,这个类的工作就是建立这些连接。

编写数据库的库分为两部分:编写常用的查询,并将代码连接到底层驱动程序,这些驱动程序连接到引擎并执行查询。

对于等式的后半部分,我们将使用添加到 PHP v5 中的 PDO(或 PHP 数据对象)扩展。

数据库工作是 web 开发中最大的安全漏洞之一,所以我强烈推荐回顾一下 https://phptherightway.com/#databases 中概述的最佳实践。

与数据库通信

让我们从创建工厂类开始,我们将使用一个简单的配置格式来创建适当的数据库引擎驱动程序对象。我们需要

  • 创建新连接的工厂类

  • 表示到数据库引擎的连接并且可以生成和执行查询的连接类

工厂类如下所示:

namespace Framework\Database;

use Closure;
use Framework\Database\Connection\Connection;
use Framework\Database\Exception\ConnectionException;

class Factory
{
    protected array $connectors;

    public function addConnector(string $alias, Closure $connector): static
    {
        $this->connectors[$alias] = $connector;
        return $this;
    }

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

        $type = $config['type'];

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

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

这是来自framework/Database/Factory.php

这类似于我们以前做过的管理器,但它太瘦了,所以我不打算称它为管理器。这只是一个工厂。我们给它一个配置,提示我们想要连接的数据库引擎的类型,它将配置的其余部分传递给我们定义的初始化函数。

如果我们想要打开一个到 MySQL 数据库的新连接,我们可能想要使用类似如下的代码:

namespace App\Http\Controllers;

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

class ShowHomePageController
{
    public function handle()
    {
        $factory = new Factory();

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

        $connection = $factory->connect([
            'type' => 'mysql',
            'host' => '127.0.0.1',
            'port' => '3306',
            'database' => 'pro-php-mvc',
            'username' => 'root',
            'password' => '',
        ]);

        $product = $connection
            ->query()
            ->select()
            ->from('products')
            ->first();

        return view('home', [
            'number' => 42,
            'featured' => $product,
        ]);
    }
}

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

为了让这段代码工作,我们需要几个设计良好的类。我们需要的第一类是抽象不同数据库引擎的连接的类。也许是基于抽象类的东西:

namespace Framework\Database\Connection;

use Framework\Database\QueryBuilder\QueryBuilder;
use Pdo;

abstract class Connection
{
    /**
     * Get the underlying Pdo instance for this connection
     */
    abstract public function pdo(): Pdo;

    /**
     * Start a new query on this connection
     */
    abstract public function query(): QueryBuilder;
}

这是来自framework/Database/Connection/Connection.php

仔细想想,抽象类和具有多种特征的接口没有太大区别。在这种情况下,我可以想象想要向抽象连接类添加方法,这将自然地适合每个特定的数据库引擎连接。

我们可能看到的不同引擎之间的差异,应该用这个抽象类的子类来表示:

namespace Framework\Database\Connection;

use Framework\Database\QueryBuilder\MysqlQueryBuilder;
use InvalidArgumentException;
use Pdo;

class MysqlConnection extends Connection
{
    private Pdo $pdo;

    public function __construct(array $config)
    {
        [
            'host' => $host,
            'port' => $port,
            'database' => $database,
            'username' => $username,
            'password' => $password,
        ] = $config;

        if (empty($host) || empty($database) || empty($username)) {
            throw new InvalidArgumentException('Connection incorrectly configured');
        }

        $this->pdo = new Pdo("mysql:host={$host};port={$port};dbname={$database}", $username, $password);
    }

    public function pdo(): Pdo
    {
        return $this->pdo;
    }

    public function query(): MysqlQueryBuilder
    {
        return new MysqlQueryBuilder($this);
    }
}

这是来自framework/Database/Connection/MysqlConnection.php

MySQL 连接需要一些参数才能成功。我们可以使用数组析构语法将每个键分配给一个局部变量,然后在尝试建立新的连接之前检查它们是否存在。

每个连接都应该创建一个特定于同一引擎的新查询构建器。例如,SqliteConnection类将创建一个SqliteQueryBuilder:

namespace Framework\Database\Connection;

use Framework\Database\QueryBuilder\SqliteQueryBuilder;
use InvalidArgumentException;
use Pdo;

class SqliteConnection extends Connection
{
    private Pdo $pdo;

    public function __construct(array $config)
    {
        ['path' => $path] = $config;

        if (empty($path)) {
            throw new InvalidArgumentException('Connection incorrectly configured');
        }

        $this->pdo = new Pdo("sqlite:{$path}");
    }

    public function pdo(): Pdo
    {
        return $this->pdo;
    }

    public function query(): SqliteQueryBuilder
    {
        return new SqliteQueryBuilder($this);
    }
}

这是来自framework/Database/Connection/SqliteConnection.php

我们需要创建的第二类应该抽象出构建、准备和执行 SQL 查询的工作。同样,我们可以使用抽象库,因为大多数 SQL 语法都是通用的:

namespace Framework\Database\QueryBuilder;

use Framework\Database\Connection\Connection;
use Framework\Database\Exception\QueryException;
use Pdo;
use PdoStatement;

abstract class QueryBuilder
{
    protected string $type;
    protected string $columns;
    protected string $table;
    protected int $limit;
    protected int $offset;

    /**
     * Get the underlying Connection instance for this query
     */
    abstract public function connection(): Connection;

    /**
     * Fetch all rows matching the current query
     */
    public function all(): array
    {
        $statement = $this->prepare();
        $statement->execute();

        return $statement->fetchAll(Pdo::FETCH_ASSOC);
    }

    /**
     * Prepare a query against a particular connection
     */
    public function prepare(): PdoStatement
    {
        $query = '';

        if ($this->type === 'select') {
            $query = $this->compileSelect($query);
            $query = $this->compileLimit($query);
        }

        if (empty($query)) {
            throw new QueryException('Unrecognised query type');
        }

        return $this->connection->pdo()->prepare($query);
    }

    /**
     * Add select clause to the query
     */
    protected function compileSelect(string $query): string
    {
        $query .= " SELECT {$this->columns} FROM {$this->table}";

        return $query;
    }

    /**
     * Add limit and offset clauses to the query
     */
    protected function compileLimit(string $query): string
    {
        if ($this->limit) {
            $query .= " LIMIT {$this->limit}";
        }

        if ($this->offset) {
            $query .= " OFFSET {$this->offset}";
        }

        return $query;
    }

    /**
     * Fetch the first row matching the current query
     */
    public function first(): array
    {
        $statement = $this->take(1)->prepare();
        $statement->execute();

        return $statement->fetchAll(Pdo::FETCH_ASSOC);
    }

    /**
     * Limit a set of query results so that it's possible
     * to fetch a single or limited batch of rows
     */
    public function take(int $limit, int $offset = 0): static
    {
        $this->limit = $limit;
        $this->offset = $offset;

        return $this;
    }

    /**
     * Indicate which table the query is targeting
     */
    public function from(string $table): static
    {
        $this->table = $table;
        return $this;
    }

    /**
     * Indicate the query type is a "select" and remember
     * which fields should be returned by the query
     */
    public function select(string $columns = '*'): static
    {
        $this->type = 'select';
        $this->columns = $columns;

        return $this;
    }
}

这是来自framework/Database/QueryBuilder/QueryBuilder.php

对于这个查询构建器的第一个版本,我们只支持选择查询。我们会在进行的过程中建立这一点…

从数据库表中选择和限制结果的 SQL 语法在 MySQL 和 SQLite 中是相同的。这意味着我们可以拥有相对较轻的 MySQL 和 SQLite 子类:

namespace Framework\Database\QueryBuilder;

use Framework\Database\Connection\MysqlConnection;

class MysqlQueryBuilder extends QueryBuilder
{
    protected MysqlConnection $connection;

    public function __construct(MysqlConnection $connection)
    {
        $this->connection = $connection;
    }
}

这是来自framework/Database/QueryBuilder/MysqlQueryBuilder.php

namespace Framework\Database\QueryBuilder;

use Framework\Database\Connection\SqliteConnection;

class SqliteQueryBuilder extends QueryBuilder
{
    protected SqliteConnection $connection;

    public function __construct(SqliteConnection $connection)
    {
        $this->connection = $connection;
    }
}

这是来自framework/Database/QueryBuilder/SqliteQueryBuilder.php

这些子类除了确保工厂的类型安全之外没有什么作用,但是随着时间的推移,它们可以存储越来越多的特定于引擎的查询语法。

现在,如果我们创建一个临时的“products”表并向其中添加一条记录,我们应该看到这条记录被返回并存储在$product变量中。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

只有一行的临时产品表

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从数据库中提取的特色产品记录

概括地说,这是我们最初设计的数据库的库在显示主页的控制器中具有的代码:

$factory = new Factory();

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

$connection = $factory->connect([
    'type' => 'mysql',
    'host' => '127.0.0.1',
    'port' => '3306',
    'database' => 'pro-php-mvc',
    'username' => 'root',
    'password' => '',
]);

$product = $connection
    ->query()
    ->select()
    ->from('products')
    ->first();

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

如果您正在努力创建临时表,请在您选择的数据库编辑器中使用以下 SQL 语句:

CREATE TABLE `products` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

但是这种语法很难使用。如果我们有一个创建和修改数据库表的系统,那会好得多。让我们创建一个系统来实现这一点!

从终端运行命令

到目前为止,我们已经执行了其他人的命令。让我们建立一些我们自己的,这样我们就有一种方法来扩展我们的应用的管理功能,这些功能可以从终端上运行,并且有可能按时间表运行。

在第一章中,我们了解到可以用多种方式运行 PHP 脚本。其中一种方法是直接在终端中。这方面的基础是

  1. 接受来自已执行命令的输入

  2. 执行一项或多项任务

  3. 将输出发送回终端

让我们通过提取一个名称并打印出转换成大写的名称来尝试每一种方法:

  1. php command.php运行这个将打印“陌生人”

  2. php command.php Jeff运行这个将打印“JEFF”

$name = $argv[1] ?? 'stranger';
print strtoupper($name) . PHP_EOL;

当设计越来越复杂的终端命令时,我们经常要处理输入。我们可能希望验证输入或者允许可选输入的默认值。我们可能想要格式化输出,以利用系统颜色和字体变量。

所有这些都需要越来越多的定制代码或使用大型库。让我们安装 Symfony 的控制台库,为我们抽象这些细节:

composer require symfony/console

Symfony 控制台应用由两个主要部分组成。第一个是入口脚本——类似于public/index.php。第二个是一个或多个“命令”类。

入口脚本如下所示:

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

use Dotenv\Dotenv;
use Symfony\Component\Console\Application;

$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();

$application = new Application();

$commands = require __DIR__ . '/app/commands.php';

foreach ($commands as $command) {
    $application->add(new $command);
}

$application->run();

这是来自command.php

这是 Symfony 控制台官方文档中提供的示例的一个略微修改的形式。区别如下:

  1. 我们已经启用了在主应用中使用的 DotEnv 机密。

  2. 我们正在从另一个文件加载命令列表。

命令列表来自我们可以在app目录中定义的文件:

use App\Console\Commands\NameCommand;

return [
    NameCommand::class,
];

这是来自app/commands.php

像这样的文件背后的想法是,它提供了一种向终端脚本添加新命令的方法,而无需修改相同的脚本。你把终端脚本和框架一起分发是完全合理的,在里面可能会很吓人。

您不一定希望人们在那里挖来挖去,并潜在地破坏终端脚本的工作方式。这个文件为那些想在框架提供的命令之外添加他们自己的命令的人提供了一个相对来说牢不可破的体验。

Symfony 命令类如下所示:

namespace App\Console\Commands;

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

class NameCommand extends Command
{
    protected static $defaultName = 'name';

    protected $requireName = false;

    protected function configure()
    {
        $this
            ->setDescription('Prints the name in uppercase')
            ->setHelp('This command takes an optional name and returns it in uppercase. If no name is provided, "stranger" is used.')
            ->addArgument('name', $this->requireName ? InputArgument::REQUIRED : InputArgument::OPTIONAL, 'Optional name');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->writeln(strtoupper($input->getArgument('name') ?: 'Stranger'));

        return Command::SUCCESS;
    }
}

这是来自app/Console/Commands/NameCommand.php

每个命令有三个部分:

  1. $defaultName属性,确定终端中命令的名称

  2. configure方法,用于定义如何调用命令,如何描述自己,以及可以使用什么参数

  3. execute方法,命令的工作发生在这里

我已经添加了$requireName属性和一个例子,说明您的命令如何定义可选或必需的参数。

创建 Symfony 的控制台库提供的结构是可能的,但这可能很容易需要多个章节,收效甚微。

正如我们将在本书后面看到的,构建更大的框架和应用通常不是从头开始编写所有的代码,尤其是当它不是您试图实现的目标的重要部分时。

制定迁移命令

既然我们可以运行自己的命令,那么我们可以创建一个命令,将数据库代码结构保存到我们选择的数据库引擎中。我们将扩展我们的数据库的库,以允许创建数据库表。

让我们添加创建、修改和删除表的方法。将普通的 QueryBuilder 代码从迁移代码中分离出来可能会更好,所以让我们创建一组新的类来处理迁移:

namespace Framework\Database\Connection;

use Framework\Database\Migration\Migration;
use Framework\Database\QueryBuilder\QueryBuilder;
use Pdo;

abstract class Connection
{
    /**
     * Get the underlying Pdo instance for this connection
     */
    abstract public function pdo(): Pdo;

    /**
     * Start a new query on this connection
     */
    abstract public function query(): QueryBuilder;

    /**
     * Start a new migration to add a table on this connection
     */
    abstract public function createTable(string $table): Migration;
}

这是来自framework/Database/Connection/Connection.php

这个新方法应该启动一个新的“创建表”迁移。它是抽象的,所以每个连接都应该实现自己的版本:

namespace Framework\Database\Connection;

use Framework\Database\Migration\MysqlMigration;
use Framework\Database\QueryBuilder\MysqlQueryBuilder;
use InvalidArgumentException;
use Pdo;

class MysqlConnection extends Connection
{
    //...

    public function createTable(string $table): MysqlMigration
    {
        return new MysqlMigration($this, $table, 'create');
    }
}

这是来自framework/Database/Connection/MysqlConnection.php

类似于查询构建器,迁移将基于一个公共的抽象类。数据库迁移都是关于要添加到新表中或要在现有表中更改的不同字段类型:

namespace Framework\Database\Migration;

use Framework\Database\Connection\Connection;
use Framework\Database\Migration\Field\BoolField;
use Framework\Database\Migration\Field\DateTimeField;
use Framework\Database\Migration\Field\FloatField;
use Framework\Database\Migration\Field\IdField;
use Framework\Database\Migration\Field\IntField;
use Framework\Database\Migration\Field\StringField;
use Framework\Database\Migration\Field\TextField;

abstract class Migration
{
    protected array $fields = [];

    public function bool(string $name): BoolField
    {
        $field = $this->fields[] = new BoolField($name);
        return $field;
    }

    public function dateTime(string $name): DateTimeField
    {
        $field = $this->fields[] = new DateTimeField($name);
        return $field;
    }

    public function float(string $name): FloatField
    {
        $field = $this->fields[] = new FloatField($name);
        return $field;
    }

    public function id(string $name): IdField
    {
        $field = $this->fields[] = new IdField($name);
        return $field;
    }

    public function int(string $name): IntField
    {
        $field = $this->fields[] = new IntField($name);
        return $field;
    }

    public function string(string $name): StringField
    {
        $field = $this->fields[] = new StringField($name);
        return $field;
    }

    public function text(string $name): TextField
    {
        $field = $this->fields[] = new TextField($name);
        return $field;
    }

    abstract public function connection(): Connection;
    abstract public function execute(): void;
}

这是来自framework/Database/Migration/Migration.php

所有这些方法做的都差不多。所有重复的原因是为每个字段类型提供类型提示,以便开发工具可以在它们被错误使用时正确地分析和警告。

我不打算介绍所有的数据库引擎迁移实现,因为这些代码并不特别有趣。如果你很好奇,可以看看我没有提到的SqliteMigration类和Field子类…

这些字段中的每一个都基于一个抽象的Field类:

namespace Framework\Database\Migration\Field;

abstract class Field
{
    public string $name;
    public bool $nullable = false;

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

    public function nullable(): static
    {
        $this->nullable = true;
        return $this;
    }
}

这是来自framework/Database/Migration/Field/Field.php

我有点被这个架构撕裂了。一方面,所有可空的字段都可以在这里定义它们的nullable方法,这很酷——它节省了不必要的重复。另一方面,我不能定义一个人们可以用来指定默认列值的方法,因为我希望这些值是特定于类型的:

namespace Framework\Database\Migration\Field;

class BoolField extends Field
{
    public bool $default;

    public function default(bool $value): static
    {
        $this->default = $value;
        return $this;
    }
}

这是来自framework/Database/Migration/Field/BoolField.php

在这里,我们让default方法只接受布尔值,我们努力将其定义为Field 上的抽象方法,以定义Field上的无类型方法,然后可以从子类中正确类型化。

我们可以在Field上定义nullable方法,因为它不需要类型作为参数。

这两种方法(nullabledefault)都有另一个问题——不允许这些操作的字段需要特殊的异常处理:

namespace Framework\Database\Migration\Field;

use Framework\Database\Exception\MigrationException;

class IdField extends Field
{
    public function default()
    {
        throw new MigrationException('ID fields cannot have a default value');
    }
}

这是来自framework/Database/Migration/Field/IdField.php

这些字段本身不会对数据库产生很大影响。即使他们这样做了,他们也会遇到数据库引擎中的差异,这可能会导致另一个抽象级别(每个数据库引擎的字段)。

相反,我们的迁移类可以解释不同的字段类型:

namespace Framework\Database\Migration;

use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Exception\MigrationException;
use Framework\Database\Migration\Field\Field;
use Framework\Database\Migration\Field\BoolField;
use Framework\Database\Migration\Field\DateTimeField;
use Framework\Database\Migration\Field\FloatField;
use Framework\Database\Migration\Field\IdField;
use Framework\Database\Migration\Field\IntField;
use Framework\Database\Migration\Field\StringField;
use Framework\Database\Migration\Field\TextField;

class MysqlMigration extends Migration
{
    protected MysqlConnection $connection;
    protected string $table;
    protected string $type;

    public function __construct(MysqlConnection $connection, string $table, string $type)
    {
        $this->connection = $connection;
        $this->table = $table;
        $this->type = $type;
    }

    public function execute()
    {
        $fields = array_map(fn($field) => $this->stringForField($field), $this->fields);
        $fields = join(',' . PHP_EOL, $fields);

        $primary = array_filter($this->fields, fn($field) => $field instanceof IdField);
        $primaryKey = isset($primary[0]) ? "PRIMARY KEY (`{$primary[0]->name}`)" : '';

        $query = "
            CREATE TABLE `{$this->table}` (
                {$fields},
                {$primaryKey}
            ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
        ";

        $statement = $this->connection->pdo()->prepare($query);
        $statement->execute();
    }

    private function stringForField(Field $field): string
    {
        if ($field instanceof BoolField) {
            $template = "`{$field->name}` tinyint(4)";

            if ($field->nullable) {
                $template .= " DEFAULT NULL";
            }

            if ($field->default !== null) {
                $default = (int) $field->default;
                $template .= " DEFAULT {$default}";
            }

            return $template;
        }

        if ($field instanceof DateTimeField) {
            $template = "`{$field->name}` datetime";

            if ($field->nullable) {
                $template .= " DEFAULT NULL";
            }

            if ($field->default === 'CURRENT_TIMESTAMP') {
                $template .= " DEFAULT CURRENT_TIMESTAMP";
            } else if ($field->default !== null) {
                $template .= " DEFAULT '{$field->default}'";
            }

            return $template;
        }

        if ($field instanceof FloatField) {
            $template = "`{$field->name}` float";

            if ($field->nullable) {
                $template .= " DEFAULT NULL";
            }

            if ($field->default !== null) {
                $template .= " DEFAULT '{$field->default}'";
            }

            return $template;
        }

        if ($field instanceof IdField) {
            return "`{$field->name}` int(11) unsigned NOT NULL AUTO_INCREMENT";
        }

        if ($field instanceof IntField) {
            $template = "`{$field->name}` int(11)";

            if ($field->nullable) {
                $template .= " DEFAULT NULL";
            }

            if ($field->default !== null) {
                $template .= " DEFAULT '{$field->default}'";
            }

            return $template;
        }

        if ($field instanceof StringField) {
            $template = "`{$field->name}` varchar(255)";

            if ($field->nullable) {
                $template .= " DEFAULT NULL";
            }

            if ($field->default !== null) {
                $template .= " DEFAULT '{$field->default}'";
            }

            return $template;
        }

        if ($field instanceof TextField) {
            return "`{$field->name}` text";
        }

        throw new MigrationException("Unrecognised field type for {$field->name}");
    }
}

这是来自framework/Database/Migration/MysqlMigration.php

这段代码的大部分存在于stringForField方法中,所以让我们从那里开始。它接受一个Field(可以是任何一个Field子类,比如StringFieldBoolField)并生成 MySQL 兼容的语法来创建字段。

这不是一个详尽的参考。这段代码可能没有考虑到很多边缘情况,但是对于 80%的用例来说已经足够了。

字段类定义自己的语法会更好——以避免所有这些instanceof切换——但是不同引擎之间的语法不同。我们需要能同时理解所有引擎的字段,或者每个引擎一个字符串字段…

execute方法为每个字段调用stringForField,生成需要添加的字段的完整列表。它用 MySQL 版本的CREATE TABLE语句包装了这些。同样,我们可以做很多事情来扩展它:

  • 处理自定义字符集

  • 处理不同的 MySQL 表类型

  • 处理自定义自动编号偏移

您可以随意扩展它来处理您想要的任意多的这些内容。你有一个很好的起点!

您可以像这样使用这个迁移代码:

$orders = $connection->createTable('orders');
$orders->id('id');
$orders->int('quantity')->default(1);
$orders->float('price')->nullable();
$orders->bool('is_confirmed')->default(false);
$orders->dateTime('ordered_at')->default('CURRENT_TIMESTAMP');
$orders->text('notes');
$orders->execute();

让我们把它放在一个“迁移”文件中,这样我们就可以从命令行运行它(以及其他迁移):

use Framework\Database\Connection\Connection;

class CreateOrdersTable
{
    public function migrate(Connection $connection)
    {
        $table = $connection->createTable('orders');
        $table->id('id');
        $table->int('quantity')->default(1);
        $table->float('price')->nullable();
        $table->bool('is_confirmed')->default(false);
        $table->dateTime('ordered_at')->default('CURRENT_TIMESTAMP');
        $table->text('notes');
        $table->execute();
    }
}

这是来自database/migrations/001_CreateOrdersTable.php

我可以想象创建多个这样的文件,每个文件描述一个对数据库的更改。通过这种方式,我们可以跟踪数据库随时间的变化,并了解当所有迁移按顺序运行时它应该是什么样子。

我们应该创建一个新命令,并将其添加到应用知道的命令列表中。该命令需要

  1. 查找所有迁移文件。

  2. 打开到数据库的连接。

  3. “迁移”每个迁移文件,为其提供活动连接。

也许是这样的:

namespace Framework\Database\Command;

use Framework\Database\Factory;
use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Connection\SqliteConnection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class MigrateCommand extends Command
{
    protected static $defaultName = 'migrate';

    protected function configure()
    {
        $this
            ->setDescription('Migrates the database')
            ->setHelp('This command looks for all migration files and runs them');
    }

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

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

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

        $factory = new Factory();

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

        $connection = $factory->connect([
            'type' => 'mysql',
            'host' => '127.0.0.1',
            'port' => '3306',
            'database' => 'pro-php-mvc',
            'username' => 'root',
            'password' => '',
        ]);

        foreach ($paths as $path) {
            [$prefix, $file] = explode('_', $path);
            [$class, $extension] = explode('.', $file);

            require $path;

            $obj = new $class();
            $obj->migrate($connection);
        }

        return Command::SUCCESS;
    }
}

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

该命令在假设用户在同一个文件夹中时将运行command.php脚本的情况下运行:通过使用getcwd()函数。这个函数返回 PHP 正在运行的当前文件夹路径。

在此基础上,我们在相对于当前路径的database/migrations文件夹中寻找所有迁移文件。如果找不到,那么我们甚至不用费心去连接数据库。

如果有迁移要运行,我们可以打开一个连接,并将其传递给每个迁移类的migrate()方法。

这种方法的一个问题是它硬编码了使用 MySQL 的选择。我们真的需要一种定义“默认”连接的方法,这样我们就不需要对选择进行硬编码。

我们将在第十一章中构建一个健壮的配置解决方案,但是现在,我们可以使用稍微简单一点的东西:

return [
    'default' => 'mysql',
    'mysql' => [
        'type' => 'mysql',
        'host' => '127.0.0.1',
        'port' => '3306',
        'database' => 'pro-php-mvc',
        'username' => 'root',
        'password' => '',
    ],
    'sqlite' => [
        'type' => 'sqlite',
        'path' => __DIR__ . '/../database/database.sqlite',
    ],
];

这是来自config/database.php

现在,我们可以在任何需要数据库凭证的地方使用这个“配置”文件:

namespace App\Http\Controllers;

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

class ShowHomePageController
{
    public function handle()
    {
        $factory = new Factory();

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

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

        $config = require __DIR__ . '/../../../config/database.php';

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

        $product = $connection
            ->query()
            ->select()
            ->from('products')
            ->first();

        return view('home', [
            'number' => 42,
            'featured' => $product,
        ]);
    }
}

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

它仍然不完美——我们仍然必须每次都向工厂添加连接回调——但至少配置决定了要使用的理想连接器。我们将在第十章中提出一个更好的“建设”工厂的方法。

我们可以重构MigrateCommand类来使用类似的配置方法:

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();

    foreach ($paths as $path) {
        [$prefix, $file] = explode('_', $path);
        [$class, $extension] = explode('.', $file);

        require $path;

        $obj = new $class();
        $obj->migrate($connection);
    }

    return Command::SUCCESS;
}

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

在运行该命令之前,我们需要将其添加到已知命令列表中:

use App\Console\Commands\NameCommand;
use Framework\Database\Command\MigrateCommand;

return [
    MigrateCommand::class,
    NameCommand::class,
];

这是来自app/commands.php

改变表格

迁移不仅仅是为了创建表。他们还需要能够通过在应用需要时更改和删除列来修改表。

我们可以增加现有的迁移来支持这一点,从改变字段开始

/**
 * Start a new migration to add a table on this connection
 */
abstract public function alterTable(string $table): Migration;

这是来自framework/Database/Connection/Connection.php

…在不同的引擎子类中:

public function alterTable(string $table): MysqlMigration
{
    return new MysqlMigration($this, $table, 'alter');
}

这是来自framework/Database/Connection/MysqlConnection.php

我们还需要改变字段定义的方式,允许将它们添加到现有的表中,并允许对它们进行修改:

private function stringForField(Field $field): string
{
    $prefix = '';

    if ($this->type === 'alter') {
        $prefix = 'ADD';
    }

    if ($field->alter) {
        $prefix = 'MODIFY';
    }

    if ($field instanceof BoolField) {
        $template = "{$prefix} `{$field->name}` tinyint(4)";

        if ($field->nullable) {
            $template .= " DEFAULT NULL";
        }

        if ($field->default !== null) {
            $default = (int) $field->default;
            $template .= " DEFAULT {$default}";
        }

        return $template;
    }

    if ($field instanceof DateTimeField) {
        $template = "{$prefix} `{$field->name}` datetime";

        if ($field->nullable) {
            $template .= " DEFAULT NULL";
        }

        if ($field->default === 'CURRENT_TIMESTAMP') {
            $template .= " DEFAULT CURRENT_TIMESTAMP";
        } else if ($field->default !== null) {
            $template .= " DEFAULT '{$field->default}'";
        }

        return $template;
    }

    if ($field instanceof FloatField) {
        $template = "{$prefix} `{$field->name}` float";

        if ($field->nullable) {
            $template .= " DEFAULT NULL";
        }

        if ($field->default !== null) {
            $template .= " DEFAULT '{$field->default}'";
        }

        return $template;
    }

    if ($field instanceof IdField) {
        return "{$prefix} `{$field->name}` int(11) unsigned NOT NULL AUTO_INCREMENT";
    }

    if ($field instanceof IntField) {
        $template = "{$prefix} `{$field->name}` int(11)";

        if ($field->nullable) {
            $template .= " DEFAULT NULL";
        }

        if ($field->default !== null) {
            $template .= " DEFAULT '{$field->default}'";
        }

        return $template;
    }

    if ($field instanceof StringField) {
        $template = "{$prefix} `{$field->name}` varchar(255)";

        if ($field->nullable) {
            $template .= " DEFAULT NULL";
        }

        if ($field->default !== null) {
            $template .= " DEFAULT '{$field->default}'";
        }

        return $template;
    }

    if ($field instanceof TextField) {
        return "{$prefix} `{$field->name}` text";
    }

    throw new MigrationException("Unrecognised field type for {$field->name}");
}

这是来自framework/Database/Migration/MysqlMigration.php

唯一显著的变化是,当迁移是变更时,我们确定了每个字段定义的前缀(或者是ADD或者是MODIFY)。

类似地,我们需要重构execute方法,根据迁移是创建还是改变表来生成非常不同的查询:

public function execute()
{
    $fields = array_map(fn($field) => $this->stringForField($field), $this->fields);

    $primary = array_filter($this->fields, fn($field) => $field instanceof IdField);
    $primaryKey = isset($primary[0]) ? "PRIMARY KEY (`{$primary[0]->name}`)" : '';

    if ($this->type === 'create') {
        $fields = join(PHP_EOL, array_map(fn($field) => "{$field},", $fields));

        $query = "
            CREATE TABLE `{$this->table}` (
                {$fields}
                {$primaryKey}
            ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
        ";
    }

    if ($this->type === 'alter') {
        $fields = join(PHP_EOL, array_map(fn($field) => "{$field};", $fields));

        $query = "
            ALTER TABLE `{$this->table}`
            {$fields}
        ";
    }

    $statement = $this->connection->pdo()->prepare($query);
    $statement->execute();
}

这是来自framework/Database/Migration/MysqlMigration.php

字段之间也需要不同的分隔符:,用于创建查询,而;用于修改查询。SQLite 迁移类有类似的变化,但是它也限制改变列(因为 SQLite 不允许这种改变)。

最后,我们可以通过添加一个新的Migration方法来删除列:

abstract public function dropColumn(string $name): static;

这是来自framework/Database/Migration/Migration.php

我们希望这是抽象的,因为不同的引擎对删除列有自己的限制。例如,SQLite 不允许删除列,所以在这种情况下我们可以抛出一个异常。

从技术上讲,可以通过重新创建表并转移剩余的行数据来删除一列,但这很麻烦…

protected MysqlConnection $connection;
protected string $table;
protected string $type;
protected array $drops = [];

//...

public function dropColumn(string $name): static
{
    $this->drops[] = $name;
    return $this;
}

这是来自framework/Database/Migration/MysqlMigration.php

然后,我们需要允许将这些“丢弃”添加到变更查询中:

public function execute()
{
    $fields = array_map(fn($field) => $this->stringForField($field), $this->fields);

    $primary = array_filter($this->fields, fn($field) => $field instanceof IdField);
    $primaryKey = isset($primary[0]) ? "PRIMARY KEY (`{$primary[0]->name}`)" : '';

    if ($this->type === 'create') {
        $fields = join(PHP_EOL, array_map(fn($field) => "{$field},", $fields));

        $query = "
            CREATE TABLE `{$this->table}` (
                {$fields}
                {$primaryKey}
            ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
        ";
    }

    if ($this->type === 'alter') {
        $fields = join(PHP_EOL, array_map(fn($field) => "{$field};", $fields));
        $drops = join(PHP_EOL, array_map(fn($drop) => "DROP COLUMN `{$drop}`;", $this->drops));

        $query = "
            ALTER TABLE `{$this->table}`
            {$fields}
            {$drops}
        ";
    }

    $statement = $this->connection->pdo()->prepare($query);
    $statement->execute();
}

这是来自framework/Database/Migration/MysqlMigration.php

我们就到此为止吧。在这一章中我们已经取得了很多成就,是时候反思和实验了。

警告

这是一个很好的起点,但它不是防弹的。有许多方法可以改进我们在本章中构建的内容,并避免常见的错误情况:

  1. 通过支持更多的数据库引擎

  2. 通过扩展查询语法以允许分组和更多类型的条件以及“原始”查询片段

  3. 通过创建一个“迁移”数据库表来跟踪已经运行的迁移,这样我们就不会试图重新创建现有的表

  4. 通过添加“路径”助手,这样我们就不需要依赖getcwd()来寻找配置和迁移

  5. 通过形式化定义新连接器的接口

  6. 通过对更多的配置参数进行类型检查,以便我们在尝试使用它们进行连接之前确定数据类型和形状

  7. 通过验证迁移文件名或使类名推理更加健壮

在我们已经取得的成就之后,我会考虑所有这些有趣的后续步骤。继续尝试其中的一两个…

职业选手是如何做到的

我向您展示的大部分内容都受到了 Laravel 等框架的启发。Laravel 有一个广泛的数据库的库、迁移系统和其他好东西,使使用数据库成为一种愉快的体验。

它有几个不同的命令行工具,用于用虚拟数据播种数据库和从头开始运行所有迁移(因此,在再次运行迁移之前,您不必手动“清空数据库”)。

此外,Laravel 的迁移看起来像这样:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateUsersTable extends Migration
{
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('users');
    }
}

…其中,up()方法在迁移“正常”运行时运行,而down()方法在迁移反向或请求“全新”运行时运行。

其他数据库的库有不同的(定义迁移的非 PHP 方法)。在 Propel 中定义一个“表”如下所示:

<database name="bookstore" defaultIdMethod="native">
  <table name="book" description="Book Table">
    <column name="id" type="integer" primaryKey="true" autoIncrement="true" />
    <column name="title" type="varchar" required="true" primaryString="true" />
    <column name="isbn" required="true" type="varchar" size="24" phpName="ISBN" />
  </table>
</database>

这不是我最喜欢的定义表结构的方法,但是很有效。尽管如此,其他数据库的库(如 SilverStripe 中的那个)没有任何可见的迁移。在那里,您定义了“内联”表结构:

use SilverStripe\ORM\DataObject;

class Player extends DataObject
{
    private static $db = [
        'PlayerNumber' => 'Int',
        'FirstName' => 'Varchar(255)',
        'LastName' => 'Text',
        'Birthday' => 'Date'
    ];
}

执行的迁移遵循一组约定,它们可以执行破坏性的操作,如删除表或列。在修改内联表定义之前,您需要仔细研究它们的文档…

这些类型的框架通常有一小组包罗万象的“缓存”命令,用于构建和保存应用运行所需的一切。

摘要

在这一章中,我们做了许多繁重的工作。我们使用 PDO 构建了一个数据库的库,抽象了连接和查询过程。

我们添加了命令行支持,因此我们的框架可以开始定义在终端中启动的有用流程。我们还构建了一种在代码中定义数据库结构的方法,并将这些结构连接到命令行。

在下一章,我们将更进一步,在 PHP 对象中构建数据库行的表示。我们将构建自己的 ORM。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值