人人都能看懂的全栈开发教程——数据校验

人人都能看懂的全栈开发教程——数据校验

Chris Yue No Comment
Posts

在老项目里,我们已经接触过数据校验了,还记得我们通过前端和后端都检查过任务的内容是否为空吗?但当时的检查还是过于简单,对于用户的字符类型输入,总是要明确能输入的字符的长度范围,虽然这属于产品经验,但如果产品忽略了这些检查,还是应该提醒他们加上。

因为我们数据库的任务内容长度已经限制了 255 个字符了,所以我们的最大长度也就定为最长 255 个字符。

记得在之前的老项目里,我们用 PHP 做了很简单的检查:

if (empty($_POST['content'])) {
    die('任务内容不能为空');
}

这个代码实际有两个问题,我们来先说 die 的问题。在 PHP 中,die (或者 exit)函数,会停止执行后面的一切代码,在框架代码中,强烈建议不要这么做,目前几乎所有的框架,都可以大致分成『初始化框架』,『运行业务代码』,『收尾工作』三个步骤,我们自己写得代码都是集中在运行业务代码阶段,而收尾工作可能还有一些比较重要的事情要做,比如搜集或保存日志等,甚至我们会通过『事件』机制,针对收尾阶段做一些扩展的功能,但如果你负责的代码使用了 die,那可能别人的业务都能享受到这些扩展的功能,就你这不块代码不行,如果不对用法加以限制,die 可能会出现在项目任何一个地方,真因为它除了问题,还很难找原因,所以除非是临时代码,在框架里最好不要用 die 或者 exit

第二个问题是,用户可能输入的,就是空格,实际上也是什么都没有输入。一般来说对于字符串类型的输入,都是会先做空白删除处理的,PHP 里也很简单,使用 trim 函数即可做到。

不过在我们引入了 Symfony 框架之后,我们还有更方便的选择。下面我们来看看如何做。

很明显,用户输入检查是一个业务问题,所以相关代码,我们应当放在 domain 里。我们先创建 domain/TaskManager.php 文件,并且输入下面的代码:

<?php

namespace Domain;

use Domain\Entity\Task;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class TaskManager
{
    private $repository;

    private $validator;

    public function __construct(TaskRepositoryInterface $repository, ValidatorInterface $validator)
    {
        $this->repository = $repository;
        $this->validator = $validator;
    }

    public function save(Task $task): void
    {
        $errors = $this->validator->validate($task);
        if (count($errors) > 0) {
            throw new ValidationException($errors);
        }

        $this->repository->save($task);
    }
}

TaskManager 依赖两个接口,其中一个是 TaskRepositoryInterface,表示用来存储任务数据的『任务仓库』,另外一个是 Symfony 提供的 Symfony\Component\Validator\Validator\ValidatorInterface,一个专门用来检查类属性的『校验工具』。

ValidatorInterface 的用法也很简单,只用将要校验的对象作为 validate 方法的参数即可,如果对象数据不满足校验的要求,会返回一个 Symfony\Component\Validator\ConstraintViolationListInterface 对象,这个对象表示校验出来的错误集合,我们可以通过 count 函数来返回有多少个错误。另外,为了让显示页面能拿到错误,做信息展示,我们创建了 ValidationException 异常类:

<?php

namespace Domain;

use Symfony\Component\Validator\ConstraintViolationListInterface;

class ValidationException extends \Exception
{
    private $errors;

    public function __construct(ConstraintViolationListInterface $errors)
    {
        $this->errors = $errors;

        parent::__construct('Validation failed');
    }

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

知道了用什么校验,接下来了解如何写校验的规则。Symfony 的 Validator 工具也是可以用 XML,YAML,以及 Annotation 来配置校验规则。ORM 那章有聊过使用 Annotation 来写配置的好处,实际上 Validator 是否使用 Annotation 的道理也一样。因为我们的校验规则本身就是业务规则,所以跟同样属于业务数据的 Domain\Entity\Task 类放一起不但没有问题,而且因为在一个文件里,如果要增加或者删除什么字段,改起来反而还更方便了。我们将对任务内容的检查规则,添加到 Task 类:

<?php

namespace Domain\Entity;

// 这一句不能少
use Symfony\Component\Validator\Constraints as Assert;

class Task
{
    /**
     * @Assert\NotBlank(message="任务内容不能为空")
     * @Assert\Length(max=255, maxMessage="任务内容不能超过 255 个字符")
     */
    private $content;

    ...
}

我觉得配置应该看起来挺直白的,就不用再解释了。读者如果有兴趣,可以通过官方文档了解 Symfony Validator 提供的所有检查种类以及用法。

业务代码写完了,记得将单元测试补充上:

<?php

namespace Test\Domain;

use Domain\Entity\Task;
use Domain\TaskManager;
use Domain\TaskRepositoryInterface;
use Domain\ValidationException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;

class TaskManagerTest extends TestCase
{
    public function testSaveFailed()
    {
        $task = $this->prophesize(Task::class);
        $repository = $this->prophesize(TaskRepositoryInterface::class);
        $validator = $this->prophesize(ValidatorInterface::class);
        $violations = $this->prophesize(ConstraintViolationListInterface::class);

        $repository->save($task)->shouldNotBeCalled();
        $validator->validate($task)->shouldBeCalledOnce()->willReturn($violations);
        $violations->count()->shouldBeCalledOnce()->willReturn(1);

        $taskManager = new TaskManager(
            $repository->reveal(),
            $validator->reveal()
        );

        $this->expectException(ValidationException::class);

        $taskManager->save($task->reveal());
    }

    public function testSave()
    {
        $task = $this->prophesize(Task::class);
        $repository = $this->prophesize(TaskRepositoryInterface::class);
        $validator = $this->prophesize(ValidatorInterface::class);
        $violations = $this->prophesize(ConstraintViolationListInterface::class);

        $repository->save($task)->shouldBeCalledOnce();
        $validator->validate($task)->shouldBeCalledOnce()->willReturn($violations);
        $violations->count()->shouldBeCalledOnce()->willReturn(0);

        $taskManager = new TaskManager(
            $repository->reveal(),
            $validator->reveal()
        );

        $taskManager->save($task->reveal());
    }
}

当我们执行所有的测试时,会发现 UserManagerTest 现在也有报错,不过很容易看出是因为我们之前修改了 UserRepositoryInterface 接口之后导致的,因为跟本篇主题无关所以我就不演示代码了,大家可以自行尝试修复,改不好也没关系,文章最后提到的项目里的代码也可以参考。从这里我们也能窥见使用单元测试的好处,虽然我们还没有代码真正用上 UserManager::login 方法,但是我们已经通过测试知道此方法需要更新了。

另外关于单元测试这里再多提一点,大家可能已经发现,到目前为止,只针对业务代码做了测试,这里要解释一下,并不是说非业务代码就不能做单元测试,实际上所有的类都可以做单元测试,例子里没有写一是因为我觉得不用举那么多例子,二是在精力有限的情况,我们其实可以去选择测试相对更核心的功能(比如业务代码,毕竟这个项目选择了以业务为核心)。

一切没问题,我们就可以在控制器里使用 TaskManager 类了。更新 src/Controller/TaskController.php 代码如下:

<?php

namespace App\Controller;

use Domain\Entity\Task;
use Domain\TaskManager;
use Domain\ValidationException;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;

class TaskController extends AbstractController
{
    ...

    /**
     * 此方法即是表单页面,也处理表单提交,因为显示和处理表单的
     * 代码比较类似,所以就写在了一块儿,当然分开也是可以的
     *
     * @Route("/task/new", methods={"GET", "POST"})
     */
    public function new(Request $request, TaskManager $taskManager)
    {
        // 通过 createFormBuilder 创建的『表单建造器』(即 form bulder)
        // 创建了一个名叫 content 的表字段
        // 创建出来的 form 对象,即可以用来显示表单,也可以用来处理表单
        $form = $this->createFormBuilder()->add('content', TextareaType::class)->getForm();
        // 尝试处理请求,如果是提交表单请求,则 form 将会尝试从请求里读用户提交的数据并与自身绑定,如果不是表单提交请求则什么都不干
        $form->handleRequest($request);

        $errors = null;
        // 只有提交表单的时候 form 的 isSubmitted 才返回 true
        if ($form->isSubmitted()) {
            $formData = $form->getData(); // 取 form 绑定的用户提交数据
            $task = new Task($this->getUser(), $formData['content'] ?? '');
            try {
                $taskManager->save($task);
                // app_task_index 是『路由名』,一个路由名和一个地址对应
                // 后面会对路由的概念做一个简单的解释
                // 这句的意思是如果保存 task 没问题,则自动跳转首页
                return $this->redirectToRoute('app_task_index');
            } catch (ValidationException $ex) {
                $errors = $ex->getErrors();
            }
        }

        return $this->render('task/new.html.twig', [
            'form' => $form->createView(),
            'errors' => $errors,
        ]);
    }
}

将我们的 templates/task/new.html.twig 模板也改成使用 Symfony Form 的方式:

{% extends 'base.html.twig' %}

{% block body %}
    {{ form_start(form) }}
        {% if (errors) %}
            <ul class="border error">
                {% for error in errors %}
                    <li>{{ error.message }}</li>
                {% endfor %}
            </ul>
        {% endif %}

        {{ form_widget(form.content, {'attr': {'class': 'border input'}}) }}
        <input type="submit" value="提交" class="border btn">
    {{ form_end(form) }}
{% endblock body %}

{# 为了好测试后台数据校验功能,原项目里的 JS 代码就不复制过来了 #}

另外,为了让错误信息的显示更美观,我们还加了一个 CSS class .error,在 public/main.css 里我们来定义这个 class:

.error {
    background: #b06601;;
    width: 256px;
    font-size: 15px;
    list-style: none;
    color: #ffd36b;
    border: 1px solid #c77e19;
}

为了方便,我们依然在首页模板 templates/task/index.html.twig 添加新增任务页的链接,但这次写法有点不同:

{% extends 'base.html.twig' %}

{% block body %}
    {# 之前忘记了加 class 现在补上 #}
    <ul class="tasks">
        {% for task in tasks %}
            <li>{{ task.content }}</li>
        {% endfor %}
    </ul>
    {# path 是 twig 里的一个函数,参数也是 Symfony 的路由名 #}
    <a href="{{ path('app_task_new') }}" class="border btn">New</a>
{% endblock body %}

到这里大家应该接触两次『路由』的概念了,我不打算对路由功能做详细的介绍,但读者也不用担心,大家依然能在后面的章节里慢慢体会路由的更多用法。这里只提一下使用路由的目的:像之前的老项目,所有的访问地址都写死在模板里,如果地址要做调整,那么所有用到被调整的地址的模板代码都需要改,不但繁琐还容易漏,但如果我们使用路由系统,用一个路由名去指代地址,改地址就会变得很简单,模板不用再改任何地方,只要路由名对应的地址变了所有模板的地址都会自动更新。

还记得控制器里的路径配置的写法吗?实际上现在所有配置都忽略了路由名参数 name,如果不指定的话,默认就是 app_控制器名_方法名。如果嫌默认路由名太长,也可以指定名字,比如首页:

    /**
     * @Route("/", methods={"GET"}, name="home")
     */

如果项目的路径太多记不住了,可以通过下面的命令列出所有的路由名:

bin/console debug:route

另外,当前访问的页面的路由名,就显示在 Symfony 最下方的调试工具条的左二位置。

说了太多路由,我们继续测试数据校验功能。点击新增按钮,这个时候发现 CSS 样式并没有加载。原因也很简单,我们一开始在 base.html.twig 里引用 main.css 的时候,路径写的是『相对路径』,什么是相对路径呢?现在新增任务的页面地址是 /task/new,那么 main.css 这个路径,就是相对于父目录地址来说的,也就是 /task/,所以最后浏览器识别的路径是 /task/main.css,这个地址当然是不对的。解决办法也很简单,使用『绝对路径』就可以直接忽略父目录,直接从根目录开始,绝对目录用法也很简单,以 / 开头就行,即把 CSS 文件路径改成 /main.css

修复完我们之前遗留的问题,我们终于可以看到正常的新增任务页面了。我们现在做两个测试,第一个测试是,我们只输入空格,会发现报错,虽然我们并没有做字符串空格处理,但 Symfony Form 会自动将用户提交内容做 trim 操作。第二个测试,我们故意输入超过 255 个字节的内容(为了好测试,我们也可以临时将校验规则里的 255 改成一个比较小的数字),也会发现错误的提醒,并且错误提示就是我们配置的内容。

文章快到结尾了,我觉得有个要点还是需要请读者注意的。实际上,如果一开始选择直接在 Symfony 这个框架上搭建项目,我们的代码还可以写得更简单。现在看 Symfony Form 和 Symfony Validation 两个组件好像没啥关联,但它们默认就是组合起来使用的,按 Symfony 默认使用方法,代码还会更少一点点,而且还能用到我们熟悉的 make 命令来自动创建表单(用 bin/console make:form 命令,有兴趣可以试试),但问题是,我们已经采纳了业务驱动的开发方式,这种方式要求 Symfony Validator 需要『粘』在我们的业务代码里,而不是 Symfony Form 里。为了坚持 DDD 的做法,我们不得不放弃工具本身提供的便利。可能在座的读者,有的以前也看过一些开发教程,它们可能都能很完美的从自己的某个角度解决某一类问题,但真实世界的项目不会像书上说得那么简单,真实的项目你可以从很多角度去看待它,不同的角度所产生的解决方式之间往往会有冲突,需要取舍。面对我们现在遇到的具体问题,我的建议是,开发方式角度优于工具角度,理由也很简单,按工具角度作为开发方向,那很容易被工具牵着鼻子走,再说,一个项目可能用到不只一种工具,万一某几个工具本身开始打架了,那不又得纠结半天。

可能有些 Symfony 新手读者并不太能感受到我说的 Symfony Form 结合 Symfony Validation 一起使用的问题,如果是这样建议读一读官方相关教程。假如大家对直接在 Symfony 框架上搭建项目感兴趣,我也会更新我以前的教程到 Symfony 5,或者重新写一个新的。

本章节完整代码见这里

人人都能看懂的全栈开发教程——数据校验 by Chris Yue is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

微信赞赏码

写作累,服务器还越来越贵
求分担,祝愿好人一生平安
天使打赏人

发表评论

65 + = 66