步骤 14: 利用表单接收反馈

是时候让我们的参会人员给出会议的反馈了。他们会通过 HTML 表单 来贡献他们的评论。

生成一个表单类型

Maker bundle 生成一个表单类:

  1. $ symfony console make:form CommentFormType Comment
  1. created: src/Form/CommentFormType.php
  2. Success!
  3. Next: Add fields to your form and start using it.
  4. Find the documentation at https://symfony.com/doc/current/forms.html

App\Form\CommentFormType 类为 App\Entity\Comment 这个实体类定义了一个表单:

src/App/Form/CommentFormType.php

  1. namespace App\Form;
  2. use App\Entity\Comment;
  3. use Symfony\Component\Form\AbstractType;
  4. use Symfony\Component\Form\FormBuilderInterface;
  5. use Symfony\Component\OptionsResolver\OptionsResolver;
  6. class CommentFormType extends AbstractType
  7. {
  8. public function buildForm(FormBuilderInterface $builder, array $options)
  9. {
  10. $builder
  11. ->add('author')
  12. ->add('text')
  13. ->add('email')
  14. ->add('createdAt')
  15. ->add('photoFilename')
  16. ->add('conference')
  17. ;
  18. }
  19. public function configureOptions(OptionsResolver $resolver)
  20. {
  21. $resolver->setDefaults([
  22. 'data_class' => Comment::class,
  23. ]);
  24. }
  25. }

form type 描述了绑定到模型的 表单字段。它把提交的表单数据转换为模型的类属性值。默认情况下,Symfony 会使用 Comment 实体的元数据来猜测每个字段的配置,比如 Doctrine 的元数据。例如,text 类型的表字段会渲染成一个 textarea 页面元素,因为该字段是数据库表里一个容纳较多文字的列。

展示表单

在控制器中创建表单,把它传入模板,从而将它展示给用户:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -2,7 +2,9 @@
  4. namespace App\Controller;
  5. +use App\Entity\Comment;
  6. use App\Entity\Conference;
  7. +use App\Form\CommentFormType;
  8. use App\Repository\CommentRepository;
  9. use App\Repository\ConferenceRepository;
  10. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  11. @@ -31,6 +33,9 @@ class ConferenceController extends AbstractController
  12. #[Route('/conference/{slug}', name: 'conference')]
  13. public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
  14. {
  15. + $comment = new Comment();
  16. + $form = $this->createForm(CommentFormType::class, $comment);
  17. +
  18. $offset = max(0, $request->query->getInt('offset', 0));
  19. $paginator = $commentRepository->getCommentPaginator($conference, $offset);
  20. @@ -39,6 +44,7 @@ class ConferenceController extends AbstractController
  21. 'comments' => $paginator,
  22. 'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
  23. 'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
  24. + 'comment_form' => $form->createView(),
  25. ]));
  26. }
  27. }

你绝不应该直接实例化一个表单类型,而是应该用控制器的 createForm() 方法。这个方法来自 AbstractController 基类,它让创建表单变得很容易。

当把表单传递给模板时,要使用 createView() 方法把数据转换成适合于模板的格式。

在模板中展示表单可以用 form 这个 Twig 函数:

patch_file

  1. --- a/templates/conference/show.html.twig
  2. +++ b/templates/conference/show.html.twig
  3. @@ -30,4 +30,8 @@
  4. {% else %}
  5. <div>No comments have been posted yet for this conference.</div>
  6. {% endif %}
  7. +
  8. + <h2>Add your own feedback</h2>
  9. +
  10. + {{ form(comment_form) }}
  11. {% endblock %}

在浏览器里刷新会议页面,你会注意到表单的每个字段都选用了合适的 HTML 元素(每个表单字段的类型是从模型中推断出来的):

步骤 14: 利用表单接收反馈 - 图1

form() 函数会根据表单类型里定义的所有信息来生成 HTML 的 form 元素。如果有文件上传,它还会在 <form> 标签里添加 enctype=multipart/form-data。此外,当提交的信息有错时,它还会负责显示错误消息。通过覆盖默认的模板,你可以定制表单的任何部分,但在该项目中我们不需这样做。

定制一个表单类型

虽然表单字段的配置是基于它们对应的模型字段,但你还是可以在表单类型的类中直接定制修改默认配置:

patch_file

  1. --- a/src/Form/CommentFormType.php
  2. +++ b/src/Form/CommentFormType.php
  3. @@ -4,20 +4,31 @@ namespace App\Form;
  4. use App\Entity\Comment;
  5. use Symfony\Component\Form\AbstractType;
  6. +use Symfony\Component\Form\Extension\Core\Type\EmailType;
  7. +use Symfony\Component\Form\Extension\Core\Type\FileType;
  8. +use Symfony\Component\Form\Extension\Core\Type\SubmitType;
  9. use Symfony\Component\Form\FormBuilderInterface;
  10. use Symfony\Component\OptionsResolver\OptionsResolver;
  11. +use Symfony\Component\Validator\Constraints\Image;
  12. class CommentFormType extends AbstractType
  13. {
  14. public function buildForm(FormBuilderInterface $builder, array $options)
  15. {
  16. $builder
  17. - ->add('author')
  18. + ->add('author', null, [
  19. + 'label' => 'Your name',
  20. + ])
  21. ->add('text')
  22. - ->add('email')
  23. - ->add('createdAt')
  24. - ->add('photoFilename')
  25. - ->add('conference')
  26. + ->add('email', EmailType::class)
  27. + ->add('photo', FileType::class, [
  28. + 'required' => false,
  29. + 'mapped' => false,
  30. + 'constraints' => [
  31. + new Image(['maxSize' => '1024k'])
  32. + ],
  33. + ])
  34. + ->add('submit', SubmitType::class)
  35. ;
  36. }

请注意我们增加了一个提交按钮(它允许我们在模板中继续使用 {{ form(comment_form) }} 这个简单的表达式)。

有一些字段无法去自动配置,比如 photoFilename 字段。Comment 实体只需要保存照片的文件名,但表单需要处理文件上传。为了处理这种情况,我们需要在表单中增加一个非 mapped 字段 photo:它不会被映射到 Comment 的任何属性。我们会手工管理它,以此来实现一些特别的逻辑(比如把上传的照片存储在磁盘上)。

为了演示定制功能,我们也修改了一些字段对应 label 标签的默认值。

图片约束是通过检查 mime 类型来实现的;加入 Mime 组件来使它工作:

  1. $ symfony composer req mime

步骤 14: 利用表单接收反馈 - 图2

验证模型

表单类型配置了表单在前端的渲染(借助于一些 HTML5 的验证机制)。这是生成的 HTML 表单:

  1. <form name="comment_form" method="post" enctype="multipart/form-data">
  2. <div id="comment_form">
  3. <div >
  4. <label for="comment_form_author" class="required">Your name</label>
  5. <input type="text" id="comment_form_author" name="comment_form[author]" required="required" maxlength="255" />
  6. </div>
  7. <div >
  8. <label for="comment_form_text" class="required">Text</label>
  9. <textarea id="comment_form_text" name="comment_form[text]" required="required"></textarea>
  10. </div>
  11. <div >
  12. <label for="comment_form_email" class="required">Email</label>
  13. <input type="email" id="comment_form_email" name="comment_form[email]" required="required" />
  14. </div>
  15. <div >
  16. <label for="comment_form_photo">Photo</label>
  17. <input type="file" id="comment_form_photo" name="comment_form[photo]" />
  18. </div>
  19. <div >
  20. <button type="submit" id="comment_form_submit" name="comment_form[submit]">Submit</button>
  21. </div>
  22. <input type="hidden" id="comment_form__token" name="comment_form[_token]" value="DwqsEanxc48jofxsqbGBVLQBqlVJ_Tg4u9-BL1Hjgac" />
  23. </div>
  24. </form>

在评论的邮箱字段,表单使用了 email 类型的 input 元素,而且在大多数字段上使用了 required 属性。请留意表单还包含了一个名为 _token 的隐藏字段,它会保护表单免受 CSRF 攻击)。

但如果表单提交绕过了 HTML 验证(比如,表单是通过一个类似 cURL 的 HTTP 客户端提交,数据就不会进行强制验证),那不合格的数据就会送达服务器。

Comment 数据模型上,我们需要增加一些用于验证的约束条件:

patch_file

  1. --- a/src/Entity/Comment.php
  2. +++ b/src/Entity/Comment.php
  3. @@ -4,6 +4,7 @@ namespace App\Entity;
  4. use App\Repository\CommentRepository;
  5. use Doctrine\ORM\Mapping as ORM;
  6. +use Symfony\Component\Validator\Constraints as Assert;
  7. /**
  8. * @ORM\Entity(repositoryClass=CommentRepository::class)
  9. @@ -21,16 +22,20 @@ class Comment
  10. /**
  11. * @ORM\Column(type="string", length=255)
  12. */
  13. + #[Assert\NotBlank]
  14. private $author;
  15. /**
  16. * @ORM\Column(type="text")
  17. */
  18. + #[Assert\NotBlank]
  19. private $text;
  20. /**
  21. * @ORM\Column(type="string", length=255)
  22. */
  23. + #[Assert\NotBlank]
  24. + #[Assert\Email]
  25. private $email;
  26. /**

处理表单

到目前为止,我们所写的代码足以用来展示表单。

现在我们应该在控制器中处理表单提交以及将它的信息在数据库中持久化:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -7,6 +7,7 @@ use App\Entity\Conference;
  4. use App\Form\CommentFormType;
  5. use App\Repository\CommentRepository;
  6. use App\Repository\ConferenceRepository;
  7. +use Doctrine\ORM\EntityManagerInterface;
  8. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  9. use Symfony\Component\HttpFoundation\Request;
  10. use Symfony\Component\HttpFoundation\Response;
  11. @@ -16,10 +17,12 @@ use Twig\Environment;
  12. class ConferenceController extends AbstractController
  13. {
  14. private $twig;
  15. + private $entityManager;
  16. - public function __construct(Environment $twig)
  17. + public function __construct(Environment $twig, EntityManagerInterface $entityManager)
  18. {
  19. $this->twig = $twig;
  20. + $this->entityManager = $entityManager;
  21. }
  22. #[Route('/', name: 'homepage')]
  23. @@ -35,6 +38,15 @@ class ConferenceController extends AbstractController
  24. {
  25. $comment = new Comment();
  26. $form = $this->createForm(CommentFormType::class, $comment);
  27. + $form->handleRequest($request);
  28. + if ($form->isSubmitted() && $form->isValid()) {
  29. + $comment->setConference($conference);
  30. +
  31. + $this->entityManager->persist($comment);
  32. + $this->entityManager->flush();
  33. +
  34. + return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
  35. + }
  36. $offset = max(0, $request->query->getInt('offset', 0));
  37. $paginator = $commentRepository->getCommentPaginator($conference, $offset);

当表单提交后,Comment 对象会按照提交的数据进行更新。

评论对应的会议要强制保持和 URL 里标识的会议一样(我们把会议字段从表单中移除了)。

如果表单数据验证失败,我们会展示页面,但这时表单会包含提交的数据以及错误消息,这样它们能展示给用户看。

试一下这个表单。它应该能运行良好,而且数据会被保存在数据库中(在管理后台检查下这个数据)。但这里还是有个问题:照片。现在还不能上传照片,因为我们还没有在控制器中处理它。

上传文件

上传的照片需要存储在本地磁盘上,而且前端页面要可以访问到它们,这样在会议页面就能找事这些照片。我们会把照片存储在 public/uploads/photos 目录下:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -9,6 +9,7 @@ use App\Repository\CommentRepository;
  4. use App\Repository\ConferenceRepository;
  5. use Doctrine\ORM\EntityManagerInterface;
  6. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  7. +use Symfony\Component\HttpFoundation\File\Exception\FileException;
  8. use Symfony\Component\HttpFoundation\Request;
  9. use Symfony\Component\HttpFoundation\Response;
  10. use Symfony\Component\Routing\Annotation\Route;
  11. @@ -34,13 +35,22 @@ class ConferenceController extends AbstractController
  12. }
  13. #[Route('/conference/{slug}', name: 'conference')]
  14. - public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
  15. + public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
  16. {
  17. $comment = new Comment();
  18. $form = $this->createForm(CommentFormType::class, $comment);
  19. $form->handleRequest($request);
  20. if ($form->isSubmitted() && $form->isValid()) {
  21. $comment->setConference($conference);
  22. + if ($photo = $form['photo']->getData()) {
  23. + $filename = bin2hex(random_bytes(6)).'.'.$photo->guessExtension();
  24. + try {
  25. + $photo->move($photoDir, $filename);
  26. + } catch (FileException $e) {
  27. + // unable to upload the photo, give up
  28. + }
  29. + $comment->setPhotoFilename($filename);
  30. + }
  31. $this->entityManager->persist($comment);
  32. $this->entityManager->flush();

为了管理照片上传,我们给每个文件一个随机的名字。然后,我们把上传的文件移动到目的地(那个照片目录)。最后,我们把文件名存储在 Comment 对象里。

注意到 show() 方法里的新参数了吗?$photoDir 是一个字符串,不是一个服务。Symfony 是如何知道要注入什么参数呢?Symfony 的服务容器除了存储服务外,也可以存储 参数。参数是一些用来帮助配置服务的标量。这些参数可以被显式地注入到服务中,也可以通过 绑定名字 来注入:

patch_file

  1. --- a/config/services.yaml
  2. +++ b/config/services.yaml
  3. @@ -10,6 +10,8 @@ services:
  4. _defaults:
  5. autowire: true # Automatically injects dependencies in your services.
  6. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
  7. + bind:
  8. + $photoDir: "%kernel.project_dir%/public/uploads/photos"
  9. # makes classes in src/ available to be used as services
  10. # this creates a service per class whose id is the fully-qualified class name

通过 bind 里的设置,当一个服务有名为 $photoDir 的参数时,Symfony 就会注入对应的值。

试着上传一个 PDF 文件,而不是图片。你应该会看到错误消息。目前页面设计很难看,但别担心,再过几个步骤我们会去处理网站设计,到时一切都会变好看的。对于这些表单,我们会修改一行配置来给所有元素设置样式。

调试表单

当表单提交后出了些问题,使用 Symfony 分析器的 “Form” 面板。它会告诉你有关表单的信息,它的全部选项,提交的数据以及它们在内部是如何转换的。如果表单包含了错误,这些错误也会被列出来。

典型的表单工作流像是这样:

  • 页面上展示表单;
  • 用户通过 POST 请求提交表单;
  • 服务器把用户重定向到一个新页面或原来的页面。

当请求成功提交后,你如何查看探查器里的信息呢?由于页面被立刻重定向了,我们再也见不到 web 排错工具栏里的 POST 请求了。没问题,在重定向到的页面里,在 “200” 状态码的绿色区域悬浮鼠标,你会看到一个 “302” 重定向,它带有一个指向页面分析信息的链接(在括号中)。

步骤 14: 利用表单接收反馈 - 图3

点击那个链接,可以打开那个 POST 请求的分析页,然后进入 “Form” 面板:

  1. $ rm -rf var/cache

步骤 14: 利用表单接收反馈 - 图4

在管理后台中显示上传的照片

现在后台只是显示照片的文件名,但我们想要看到真实的照片:

patch_file

  1. --- a/src/Controller/Admin/CommentCrudController.php
  2. +++ b/src/Controller/Admin/CommentCrudController.php
  3. @@ -9,6 +9,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
  4. use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
  5. use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
  6. use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
  7. +use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
  8. use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
  9. use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
  10. use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter;
  11. @@ -45,7 +46,9 @@ class CommentCrudController extends AbstractCrudController
  12. yield TextareaField::new('text')
  13. ->hideOnIndex()
  14. ;
  15. - yield TextField::new('photoFilename')
  16. + yield ImageField::new('photoFilename')
  17. + ->setBasePath('/uploads/photos')
  18. + ->setLabel('Photo')
  19. ->onlyOnIndex()
  20. ;

在 Git 仓库中排除上传的照片

先不要提交!我们可不想把上传的图片存放进 Git 仓库。在 .gitignore 文件中添加 /public/uploads 目录:

patch_file

  1. --- a/.gitignore
  2. +++ b/.gitignore
  3. @@ -1,3 +1,4 @@
  4. +/public/uploads
  5. ###> symfony/framework-bundle ###
  6. /.env.local

在生产服务器上存储上传的文件

最后一步是在生产服务器上存储上传的文件。为什么我们需要做一些特殊处理?因为出于各种原因,大多数现代云平台都使用只读容器。SymfonyCloud 也不例外。

在 Symfony 项目中,并不是所有的组成部分都是只读的。当构建容器的时候,我们尽可能尝试生成尽量多的缓存(在缓存预热阶段),但是 Symfony 仍然需要在某些地方可写,比如用户缓存、日志、会话数据(如果会话是存储在文件系统中的话)和其它更多地方。

看一下 .symfony.cloud.yaml 文件,这里已经有针对 var/ 目录的可写 挂载点var/ 目录是 Symfony 唯一要写入数据的地方(缓存、日志……)。

现在我们为上传的照片创建一个新的挂载点:

patch_file

  1. --- a/.symfony.cloud.yaml
  2. +++ b/.symfony.cloud.yaml
  3. @@ -36,6 +36,7 @@ web:
  4. mounts:
  5. "/var": { source: local, source_path: var }
  6. + "/public/uploads": { source: local, source_path: uploads }
  7. hooks:
  8. build: |

现在你可以部署代码,之后照片就会存储在 public/uploads/ 目录,和本地版本一样。

深入学习


This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.