如何使用数据转换器(Data Transformers)

3.4 版本
维护中的版本

数据转换器(data transformers)用于把某个字段的数据给转换成“能够在表单中显示”的格式(并且在提交时反向进行)。很多内置的字段类型已经用上了它。例如,DateType 字段类型在input文本框中被渲染成 yyyy-MM-dd 格式。在内部,一个 data transformer 会把字段的 DateTime 初始值(PHP对象)给转换成 yyyy-MM-dd 字符串以完成表单输出,然后会在提交后恢复成一个 DateTime 对象。

Caution

当一个表单字段设置了 inherit_data 选项时,data transformer 将不会作用到这一字段。

简单的例子:将用户输入的字符串Tags转换为一个数组 

假设你有一个 Task 表单,包含了一个 tags(标签)的 text 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/AppBundle/Form/Type/TaskType.php
namespace AppBundle\Form\Type;
 
use AppBundle\Entity\Task;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\TextType;
 
// ...
class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('tags', TextType::class);
    }
 
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => Task::class,
        ));
    }
 
    // ...
}

tags 在内部被存储为一个数组,但是显示给用户的是一个简单的以逗号分隔的字符串,这令它们更易编辑。

这是一个对 tags 字段施以一个自定义的 data transformer 的 绝好 机会。使用 CallbackTransformer 方法是这么做的最简单方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/AppBundle/Form/Type/TaskType.php
namespace AppBundle\Form\Type;
 
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
// ...
 
class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('tags', TextType::class);
 
        $builder->get('tags')
            ->addModelTransformer(new CallbackTransformer(
                function ($tagsAsArray) {
                    // transform the array to a string
                    // 将数组转换为字符串
                    return implode(', ', $tagsAsArray);
                },
                function ($tagsAsString) {
                    // transform the string back to an array
                    // 将字符串转回数组
                    return explode(', ', $tagsAsString);
                }
            ))
        ;
    }
 
    // ...
}

CallbackTransformer 类以两个回调函数作为参数。第一个将原始值转化为一个能在表单中进行输出的格式。第二个函数做了相反的事情:它把提交后的值转换为你在代码中需要使用的格式。

Tip

addModelTransformer() 方法接受 任何 实现了 0DataTransformerInterface 接口的对象 - 这样你能创建自己的类,而不是把所有的逻辑都放进表单类(见下一小节)。

通过略微改变格式,你也可以在刚刚添加字段时添加 data transformer:

1
2
3
4
5
6
7
use Symfony\Component\Form\Extension\Core\Type\TextType;
 
$builder->add(
    $builder
        ->create('tags', TextType::class)
        ->addModelTransformer(...)
);

较难的例子:将Issue Number转换成Isuse Entity 

比如说你有一个 Task entity 和一个Issue entity 的 many-to-one (多对一) 映射关系(即每个 Task 针对它所关联 Issue 有一个可选的外键)。添加一个带有全部可能的 issues 的 listbox(框列表),最终会导致列表 长且加载费时。取而代之的是,你决定添加一个 textbox,用户可以简单键入 issue 的 number。

起点是,像通常那样设置一个 text 字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/AppBundle/Form/Type/TaskType.php
namespace AppBundle\Form\Type;
 
use AppBundle\Entity\Task;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
 
// ...
class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('description', TextareaType::class)
            ->add('issue', TextType::class)
        ;
    }
 
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => Task::class,
        ));
    }
 
    // ...
}

很好的开始!如果你止步于此并提交表单,你的 Task 的 issue 属性会是一个字符串 (例如 55 )。怎样才能在提交时把它转换成一个 Issue entity 呢?

创建Transformer 

你应该像之前那样使用 CallbackTransformer。但是由于它有点复杂,创建一个全新 transformer 将会使得 TaskType 表单类更加简单。

创建一个 IssueToNumberTransformer 类:它负责对 Issue 对象 和 issue 编号相互转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// src/AppBundle/Form/DataTransformer/IssueToNumberTransformer.php
namespace AppBundle\Form\DataTransformer;
 
use AppBundle\Entity\Issue;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
 
class IssueToNumberTransformer implements DataTransformerInterface
{
    private $em;
 
    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }
 
    /**
     * Transforms an object (issue) to a string (number).
     * 把一个对象(issue)转换成一个字符串(number)
     * @param  Issue|null $issue
     * @return string
     */
    public function transform($issue)
    {
        if (null === $issue) {
            return '';
        }
 
        return $issue->getId();
    }
 
    /**
     * Transforms a string (number) to an object (issue).
     * 把一个字符串(number)转换成一个对象(issue)
     * @param  string $issueNumber
     * @return Issue|null
     * @throws TransformationFailedException if object (issue) is not found.
     */
    public function reverseTransform($issueNumber)
    {
        // no issue number? It's optional, so that's ok
        if (!$issueNumber) {
            return;
        }
 
        $issue = $this->em
            ->getRepository(Issue::class)
            // query for the issue with this id
            ->find($issueNumber)
        ;
 
        if (null === $issue) {
            // causes a validation error
            // this message is not shown to the user
            // see the invalid_message option
            throw new TransformationFailedException(sprintf(
                'An issue with number "%s" does not exist!',
                $issueNumber
            ));
        }
 
        return $issue;
    }
}

就像第一个例子,transformer 有两个方法。transform() 负责将你代码中的数据转换为一个可在 form 表单中输出的格式(如:一个 Issue entity对象转换为自身的 id 字符串)。reverseTransform() 方法正好相反:它把已提交的数据转换回你的代码所需要的数据(如:把一个 id转换回 Issue 对象)。

若要引发一个验证错误,可抛出 TransformationFailedException。但是你传入这个异常中的信息不会显示给用户。你要用 invalid_message 来设置这个消息(见下文)。

Note

null 被传递到 transform() 方法时,你的 transformer 应该返回一个和它要转换成的类型相等的值(例如:一个空字符串,整型0,或是浮点0.0)。

使用Transformer 

接下来,你需要在 TaskType 中使用 IssueToNumberTransformer 对象,并把它添加到 issue 字段。没问题!只要添加一个 __construct() 方法并对新类进行 type-hint 类型提示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/AppBundle/Form/Type/TaskType.php
namespace AppBundle\Form\Type;
 
use AppBundle\Form\DataTransformer\IssueToNumberTransformer;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
 
// ...
class TaskType extends AbstractType
{
    private $transformer;
 
    public function __construct(IssueToNumberTransformer $transformer)
    {
        $this->transformer = $transformer;
    }
 
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('description', TextareaType::class)
            ->add('issue', TextType::class, array(
                // validation message if the data transformer fails
                // 若转换失败则给出验证(失败)信息
                'invalid_message' => 'That is not a valid issue number',
            ));
 
        // ...
 
        $builder->get('issue')
            ->addModelTransformer($this->transformer);
    }
 
    // ...
}

这就可以了!因为你使用了 autowire 以及 autoconfigure,Symfony 将自动获知要为你的 TaskType 传入一个 IssueToNumberTransformer 实例。

Tip

更多关于把表单类型定义为服务的信息,参考 注册表单类型为服务

现在,你可以轻松使用你 TaskType

1
2
3
4
// e.g. in a controller somewhere / 如,在控制器中的某处
$form = $this->createForm(TaskType::class, $task);
 
// ...

酷,你搞定了!用户将能够输入一个 issue 编号到文本字段中,而它会被转换回一个 Issue 对象。这意味着,在成功提交后,表单组件将会向 Task::setIssue() 传入一个真正的 Issue 对象而不是 issue number。

如果 issue 没有被找到的话,会创建该字段的一个表单error,可以用 invalid_message 字段选项来控制错误信息。

Caution

当你添加一个 transformer 时要特别小心。例如,下面代码是 错误 的,因为 transformer 将作用于整个表单而不是仅这个字段:

1
2
3
4
5
// THIS IS WRONG - TRANSFORMER WILL BE APPLIED TO THE ENTIRE FORM
// 错误 -  transformer 会被应用于整个表单
// see above example for correct code / 参考上例中的正确写法
$builder->add('issue', TextType::class)
    ->addModelTransformer($transformer);

创建一个可复用的issue_selector字段 

在上面的例子中,你对一个普通的 text 字段实施了转换。但如果你要大量的此种转换,最好 创建一个自定义的表单类型,它可以自动完成之。

首先,创建一个自定义的字段类型类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/AppBundle/Form/IssueSelectorType.php
namespace AppBundle\Form;
 
use AppBundle\Form\DataTransformer\IssueToNumberTransformer;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
 
class IssueSelectorType extends AbstractType
{
    private $transformer;
 
    public function __construct(IssueToNumberTransformer $transformer)
    {
        $this->transformer = $transformer;
    }
 
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->addModelTransformer($this->transformer);
    }
 
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'invalid_message' => 'The selected issue does not exist',
        ));
    }
 
    public function getParent()
    {
        return TextType::class;
    }
}

好!这会像一个 text 字段(getParent())那样执行和输出,但它会自动拥有 data transformer 并且 给了 invalid_message 选项一个很好的默认值。

一旦你用了 autowire 以及 autoconfigure,便可以立即使用表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/AppBundle/Form/Type/TaskType.php
namespace AppBundle\Form\Type;
 
use AppBundle\Form\DataTransformer\IssueToNumberTransformer;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
// ...
 
class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('description', TextareaType::class)
            ->add('issue', IssueSelectorType::class)
        ;
    }
 
    // ...
}

Tip

若你并未使用 autowireautoconfigure,参考 如何创建自定义的表单类型 以了解如何配置新的 IssueSelectorType

关于Model(模型)和View(视图)的Transformers 

上面的例子中,transformer 是作为 “Model” 转换器来使用的。实际上,有两种不同类型的转换器,以及三种不同类型的底层数据。

DataTransformersTypes

在任何表单中,三种不同类型的数据是:

  1. Model data - 这是用在你的程序中的格式中的数据 (如,一个 Issue 对象)。如果你调用 Form::getData()Form::setData(),你正在处理的就是 "model" data.
  2. Norm Data - 这是你的数据的标准化版本,常与你的 "model" data 相同 (尽管配合中并不是)。一般不直接使用。
  3. View Data - 这个格式(中的数据)被用于填充表单字段自身。用户提交数据时也要用到此格式。当你调用 Form::submit($data) 时,$data 处于 "view" data format 下。

这两种不同类型的 transformer 帮助我们互相转换上述类型的数据:

Model transformers:
  • transform(): "model data" => "norm data"
  • reverseTransform(): "norm data" => "model data"
View transformers:
  • transform(): "norm data" => "view data"
  • reverseTransform(): "view data" => "norm data"

使用那种转换器取决于你的实际情况。

如果你想使用 view transformer,调用 addViewTransformer

为什么用Model Transformers? 

本例中,是一个text 字段类型,在 “norm” 和 “view” 格式中,文本字段总是预期一个简单的标量值。因此,最合适的转换器就是 model transformer(它在 norm 格式 - 字符型的 issue编号 - 转换为 model 格式 - issue 对象 之间进行转换)。

(不同类型)转换器之间的区别是微妙的,你应该始终思考某个字段的 ‘norm’ 数据应该是什么。举例来说,text 字段的“norm”数据是一个字符串,但是date字段的则是一个 DataTime 对象。

Tip

一个通用规则是,规范化的数据(the normalized data)应当包含尽可能多的信息。

本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。

登录symfonychina 发表评论或留下问题(我们会尽量回复)