步骤 25: 通过各种途径发布通知

留言本应用程序收集了关于会议的反馈。但在给予用户反馈方面,我们做得还不够好。

因为评论需要经过后台审核,用户很可能会不理解它们为什么没有立刻发布。用户甚至会认为出了技术故障而重新提交评论。在提交评论后给予用户反馈,这样就好很多了。

同理,当评论发布时,我们很可能也应该告知用户。我们向他们要了邮箱,所以最好可以利用这个信息。

通知用户的方式有很多。邮件可能是你想到的第一个媒介,但 web 应用内的通知也是一个。甚至我们可以考虑发送手机短信,以及在 Slack 或 Telegram 上发消息。我们有很多选项。

Symfony 的 Notifier 组件实现了很多通知策略:

  1. $ symfony composer req notifier

在浏览器中发送 web 应用的通知

作为第一步,当用户提交评论后,我们在浏览器里直接通知他们评论会经过审核:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -14,6 +14,8 @@ use Symfony\Component\HttpFoundation\File\Exception\FileException;
  4. use Symfony\Component\HttpFoundation\Request;
  5. use Symfony\Component\HttpFoundation\Response;
  6. use Symfony\Component\Messenger\MessageBusInterface;
  7. +use Symfony\Component\Notifier\Notification\Notification;
  8. +use Symfony\Component\Notifier\NotifierInterface;
  9. use Symfony\Component\Routing\Annotation\Route;
  10. use Twig\Environment;
  11. @@ -53,7 +55,7 @@ class ConferenceController extends AbstractController
  12. }
  13. #[Route('/conference/{slug}', name: 'conference')]
  14. - public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
  15. + public function show(Request $request, Conference $conference, CommentRepository $commentRepository, NotifierInterface $notifier, string $photoDir): Response
  16. {
  17. $comment = new Comment();
  18. $form = $this->createForm(CommentFormType::class, $comment);
  19. @@ -82,9 +84,15 @@ class ConferenceController extends AbstractController
  20. $this->bus->dispatch(new CommentMessage($comment->getId(), $context));
  21. + $notifier->send(new Notification('Thank you for the feedback; your comment will be posted after moderation.', ['browser']));
  22. +
  23. return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
  24. }
  25. + if ($form->isSubmitted()) {
  26. + $notifier->send(new Notification('Can you check your submission? There are some problems with it.', ['browser']));
  27. + }
  28. +
  29. $offset = max(0, $request->query->getInt('offset', 0));
  30. $paginator = $commentRepository->getCommentPaginator($conference, $offset);

通知器会用一个 通道发送 一个 通知接收者

一个通知有一个标题,一个可选的内容,还有一个重要级。

通知会根据它的重要级在一个或多个通道上发送。比如,你可以用手机短信发送紧急通知,用邮件发送常规通知。

对于浏览器内的通知,我们没有接收者。

浏览器内的通知在 通知 段内使用了 flash 消息。我们要更新会议的模板来展示它们:

patch_file

  1. --- a/templates/conference/show.html.twig
  2. +++ b/templates/conference/show.html.twig
  3. @@ -3,6 +3,13 @@
  4. {% block title %}Conference Guestbook - {{ conference }}{% endblock %}
  5. {% block body %}
  6. + {% for message in app.flashes('notification') %}
  7. + <div class="alert alert-info alert-dismissible fade show">
  8. + {{ message }}
  9. + <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
  10. + </div>
  11. + {% endfor %}
  12. +
  13. <h2 class="mb-5">
  14. {{ conference }} Conference
  15. </h2>

现在用户会收到通知,知道他们提交的评论会被审核:

步骤 25: 通过各种途径发布通知 - 图1

一个附加的好处是,当表单出现错误时,我们在网站顶部也会有一个漂亮的通知信息。

步骤 25: 通过各种途径发布通知 - 图2

小技巧

flash消息使用 HTTP 会话 系统来作为存储媒介。这样带来的后果是 HTTP 缓存无法使用了,因为必须开启会话系统来检查消息。

这也是为什么我们把消息的代码段放在了 show.html.twig 模板而不是主模板里,不然的话首页里的 HTTP 缓存就不可用了。

用邮件通知管理员

之前我们是通过 MailerInterface 接口来发邮件给管理员,通知他有人提交了评论,现在我们改为在消息处理器中使用 Notifier 组件:

patch_file

  1. --- a/src/MessageHandler/CommentMessageHandler.php
  2. +++ b/src/MessageHandler/CommentMessageHandler.php
  3. @@ -4,14 +4,14 @@ namespace App\MessageHandler;
  4. use App\ImageOptimizer;
  5. use App\Message\CommentMessage;
  6. +use App\Notification\CommentReviewNotification;
  7. use App\Repository\CommentRepository;
  8. use App\SpamChecker;
  9. use Doctrine\ORM\EntityManagerInterface;
  10. use Psr\Log\LoggerInterface;
  11. -use Symfony\Bridge\Twig\Mime\NotificationEmail;
  12. -use Symfony\Component\Mailer\MailerInterface;
  13. use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
  14. use Symfony\Component\Messenger\MessageBusInterface;
  15. +use Symfony\Component\Notifier\NotifierInterface;
  16. use Symfony\Component\Workflow\WorkflowInterface;
  17. class CommentMessageHandler implements MessageHandlerInterface
  18. @@ -21,22 +21,20 @@ class CommentMessageHandler implements MessageHandlerInterface
  19. private $commentRepository;
  20. private $bus;
  21. private $workflow;
  22. - private $mailer;
  23. + private $notifier;
  24. private $imageOptimizer;
  25. - private $adminEmail;
  26. private $photoDir;
  27. private $logger;
  28. - public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, MailerInterface $mailer, ImageOptimizer $imageOptimizer, string $adminEmail, string $photoDir, LoggerInterface $logger = null)
  29. + public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, NotifierInterface $notifier, ImageOptimizer $imageOptimizer, string $photoDir, LoggerInterface $logger = null)
  30. {
  31. $this->entityManager = $entityManager;
  32. $this->spamChecker = $spamChecker;
  33. $this->commentRepository = $commentRepository;
  34. $this->bus = $bus;
  35. $this->workflow = $commentStateMachine;
  36. - $this->mailer = $mailer;
  37. + $this->notifier = $notifier;
  38. $this->imageOptimizer = $imageOptimizer;
  39. - $this->adminEmail = $adminEmail;
  40. $this->photoDir = $photoDir;
  41. $this->logger = $logger;
  42. }
  43. @@ -62,13 +60,7 @@ class CommentMessageHandler implements MessageHandlerInterface
  44. $this->bus->dispatch($message);
  45. } elseif ($this->workflow->can($comment, 'publish') || $this->workflow->can($comment, 'publish_ham')) {
  46. - $this->mailer->send((new NotificationEmail())
  47. - ->subject('New comment posted')
  48. - ->htmlTemplate('emails/comment_notification.html.twig')
  49. - ->from($this->adminEmail)
  50. - ->to($this->adminEmail)
  51. - ->context(['comment' => $comment])
  52. - );
  53. + $this->notifier->send(new CommentReviewNotification($comment), ...$this->notifier->getAdminRecipients());
  54. } elseif ($this->workflow->can($comment, 'optimize')) {
  55. if ($comment->getPhotoFilename()) {
  56. $this->imageOptimizer->resize($this->photoDir.'/'.$comment->getPhotoFilename());

getAdminRecipients() 方法会返回通知器配置信息里的管理员接收者邮箱;现在来加入你自己的邮箱:

patch_file

  1. --- a/config/packages/notifier.yaml
  2. +++ b/config/packages/notifier.yaml
  3. @@ -13,4 +13,4 @@ framework:
  4. medium: ['email']
  5. low: ['email']
  6. admin_recipients:
  7. - - { email: [email protected] }
  8. + - { email: "%env(string:default:default_admin_email:ADMIN_EMAIL)%" }

现在创建 CommentReviewNotification 类:

src/Notification/CommentReviewNotification.php

  1. namespace App\Notification;
  2. use App\Entity\Comment;
  3. use Symfony\Component\Notifier\Message\EmailMessage;
  4. use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
  5. use Symfony\Component\Notifier\Notification\Notification;
  6. use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
  7. class CommentReviewNotification extends Notification implements EmailNotificationInterface
  8. {
  9. private $comment;
  10. public function __construct(Comment $comment)
  11. {
  12. $this->comment = $comment;
  13. parent::__construct('New comment posted');
  14. }
  15. public function asEmailMessage(EmailRecipientInterface $recipient, string $transport = null): ?EmailMessage
  16. {
  17. $message = EmailMessage::fromNotification($this, $recipient, $transport);
  18. $message->getMessage()
  19. ->htmlTemplate('emails/comment_notification.html.twig')
  20. ->context(['comment' => $this->comment])
  21. ;
  22. return $message;
  23. }
  24. }

EmailNotificationInterface 接口的 asEmailMessage() 方法是可选的,但它可以用来定制邮件。

使用消息通知器而非直接使用 mailer 来发送邮件的好处之一,就是它把通知和通知用到的“通道”解耦了。正如你能看到的,代码中没有地方显式地指出通知应该由邮件发送。

通道其实是根据通知的 重要级 (默认是 low)在 config/packages/notifier.yaml 中配置的。

config/packages/notifier.yaml

  1. framework:
  2. notifier:
  3. channel_policy:
  4. # use chat/slack, chat/telegram, sms/twilio or sms/nexmo
  5. urgent: ['email']
  6. high: ['email']
  7. medium: ['email']
  8. low: ['email']

我们讨论了 browseremail 通道。我们再来看看几个更好玩的通道。

和管理员聊天

说实话,我们都在等待积极的反馈,至少是建设性的反馈。如果有人提交的评论里有“很不错”或“很棒”这样的词,我们可能想要比其它评论更快地接受它们。

对于这样的消息,除了常规的邮件通知,我们也想要在类似 Slack 或 Telegram 这样的即时通信系统中收到通知。

为 Symfony 的消息通知器安装 Slack 支持:

  1. $ symfony composer req slack-notifier

作为第一步,用 Slack 访问令牌以及你要发送消息的 Slack 通道的标识来拼接 Slack 的 DSN:slack://ACCESS_TOKEN@default?channel=CHANNEL

由于访问令牌是敏感信息,要把 Slack 的 DSN 存储在机密存储中:

  1. $ symfony console secrets:set SLACK_DSN

在生产环境中也是一样:

  1. $ APP_ENV=prod symfony console secrets:set SLACK_DSN

启用 Chatter Slack 支持:

patch_file

  1. --- a/config/packages/notifier.yaml
  2. +++ b/config/packages/notifier.yaml
  3. @@ -1,7 +1,7 @@
  4. framework:
  5. notifier:
  6. - #chatter_transports:
  7. - # slack: '%env(SLACK_DSN)%'
  8. + chatter_transports:
  9. + slack: '%env(SLACK_DSN)%'
  10. # telegram: '%env(TELEGRAM_DSN)%'
  11. #texter_transports:
  12. # twilio: '%env(TWILIO_DSN)%'

更新 Notification 类,根据评论的文本内容把它发送到不同的通道(一个简单的正则表达式就能胜任):

patch_file

  1. --- a/src/Notification/CommentReviewNotification.php
  2. +++ b/src/Notification/CommentReviewNotification.php
  3. @@ -7,6 +7,7 @@ use Symfony\Component\Notifier\Message\EmailMessage;
  4. use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
  5. use Symfony\Component\Notifier\Notification\Notification;
  6. use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
  7. +use Symfony\Component\Notifier\Recipient\RecipientInterface;
  8. class CommentReviewNotification extends Notification implements EmailNotificationInterface
  9. {
  10. @@ -29,4 +30,15 @@ class CommentReviewNotification extends Notification implements EmailNotificatio
  11. return $message;
  12. }
  13. +
  14. + public function getChannels(RecipientInterface $recipient): array
  15. + {
  16. + if (preg_match('{\b(great|awesome)\b}i', $this->comment->getText())) {
  17. + return ['email', 'chat/slack'];
  18. + }
  19. +
  20. + $this->importance(Notification::IMPORTANCE_LOW);
  21. +
  22. + return ['email'];
  23. + }
  24. }

由于这稍微调整了邮件的设计,我们也改变了“常规”评论的重要级。

完成!提交一个内容包含 “awesome” 这个词的评论,你应该会在 Slack 里收到一个消息。

在邮件里,你可以实现 ChatNotificationInterface 接口来覆盖 Slack 消息的默认渲染:

patch_file

  1. --- a/src/Notification/CommentReviewNotification.php
  2. +++ b/src/Notification/CommentReviewNotification.php
  3. @@ -3,13 +3,18 @@
  4. namespace App\Notification;
  5. use App\Entity\Comment;
  6. +use Symfony\Component\Notifier\Bridge\Slack\Block\SlackDividerBlock;
  7. +use Symfony\Component\Notifier\Bridge\Slack\Block\SlackSectionBlock;
  8. +use Symfony\Component\Notifier\Bridge\Slack\SlackOptions;
  9. +use Symfony\Component\Notifier\Message\ChatMessage;
  10. use Symfony\Component\Notifier\Message\EmailMessage;
  11. +use Symfony\Component\Notifier\Notification\ChatNotificationInterface;
  12. use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
  13. use Symfony\Component\Notifier\Notification\Notification;
  14. use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
  15. use Symfony\Component\Notifier\Recipient\RecipientInterface;
  16. -class CommentReviewNotification extends Notification implements EmailNotificationInterface
  17. +class CommentReviewNotification extends Notification implements EmailNotificationInterface, ChatNotificationInterface
  18. {
  19. private $comment;
  20. @@ -31,6 +36,28 @@ class CommentReviewNotification extends Notification implements EmailNotificatio
  21. return $message;
  22. }
  23. + public function asChatMessage(RecipientInterface $recipient, string $transport = null): ?ChatMessage
  24. + {
  25. + if ('slack' !== $transport) {
  26. + return null;
  27. + }
  28. +
  29. + $message = ChatMessage::fromNotification($this, $recipient, $transport);
  30. + $message->subject($this->getSubject());
  31. + $message->options((new SlackOptions())
  32. + ->iconEmoji('tada')
  33. + ->iconUrl('https://guestbook.example.com')
  34. + ->username('Guestbook')
  35. + ->block((new SlackSectionBlock())->text($this->getSubject()))
  36. + ->block(new SlackDividerBlock())
  37. + ->block((new SlackSectionBlock())
  38. + ->text(sprintf('%s (%s) says: %s', $this->comment->getAuthor(), $this->comment->getEmail(), $this->comment->getText()))
  39. + )
  40. + );
  41. +
  42. + return $message;
  43. + }
  44. +
  45. public function getChannels(RecipientInterface $recipient): array
  46. {
  47. if (preg_match('{\b(great|awesome)\b}i', $this->comment->getText())) {

现在好多了,但我们再更进一步。如果可以直接在 Slack 里接受或拒绝一个评论,那岂不是太棒了?

更新通知,让它接受评审 URL,再在 Slack 消息里增加两个按钮:

patch_file

  1. --- a/src/Notification/CommentReviewNotification.php
  2. +++ b/src/Notification/CommentReviewNotification.php
  3. @@ -3,6 +3,7 @@
  4. namespace App\Notification;
  5. use App\Entity\Comment;
  6. +use Symfony\Component\Notifier\Bridge\Slack\Block\SlackActionsBlock;
  7. use Symfony\Component\Notifier\Bridge\Slack\Block\SlackDividerBlock;
  8. use Symfony\Component\Notifier\Bridge\Slack\Block\SlackSectionBlock;
  9. use Symfony\Component\Notifier\Bridge\Slack\SlackOptions;
  10. @@ -17,10 +18,12 @@ use Symfony\Component\Notifier\Recipient\RecipientInterface;
  11. class CommentReviewNotification extends Notification implements EmailNotificationInterface, ChatNotificationInterface
  12. {
  13. private $comment;
  14. + private $reviewUrl;
  15. - public function __construct(Comment $comment)
  16. + public function __construct(Comment $comment, string $reviewUrl)
  17. {
  18. $this->comment = $comment;
  19. + $this->reviewUrl = $reviewUrl;
  20. parent::__construct('New comment posted');
  21. }
  22. @@ -53,6 +56,10 @@ class CommentReviewNotification extends Notification implements EmailNotificatio
  23. ->block((new SlackSectionBlock())
  24. ->text(sprintf('%s (%s) says: %s', $this->comment->getAuthor(), $this->comment->getEmail(), $this->comment->getText()))
  25. )
  26. + ->block((new SlackActionsBlock())
  27. + ->button('Accept', $this->reviewUrl, 'primary')
  28. + ->button('Reject', $this->reviewUrl.'?reject=1', 'danger')
  29. + )
  30. );
  31. return $message;

现在就是来反向跟踪改变了。首先,更新消息处理器来通过评审 URL:

patch_file

  1. --- a/src/MessageHandler/CommentMessageHandler.php
  2. +++ b/src/MessageHandler/CommentMessageHandler.php
  3. @@ -60,7 +60,8 @@ class CommentMessageHandler implements MessageHandlerInterface
  4. $this->bus->dispatch($message);
  5. } elseif ($this->workflow->can($comment, 'publish') || $this->workflow->can($comment, 'publish_ham')) {
  6. - $this->notifier->send(new CommentReviewNotification($comment), ...$this->notifier->getAdminRecipients());
  7. + $notification = new CommentReviewNotification($comment, $message->getReviewUrl());
  8. + $this->notifier->send($notification, ...$this->notifier->getAdminRecipients());
  9. } elseif ($this->workflow->can($comment, 'optimize')) {
  10. if ($comment->getPhotoFilename()) {
  11. $this->imageOptimizer->resize($this->photoDir.'/'.$comment->getPhotoFilename());

正如你看到的那样,评审 URL 应该是评论消息的一部分,我们现在来加上它:

patch_file

  1. --- a/src/Message/CommentMessage.php
  2. +++ b/src/Message/CommentMessage.php
  3. @@ -5,14 +5,21 @@ namespace App\Message;
  4. class CommentMessage
  5. {
  6. private $id;
  7. + private $reviewUrl;
  8. private $context;
  9. - public function __construct(int $id, array $context = [])
  10. + public function __construct(int $id, string $reviewUrl, array $context = [])
  11. {
  12. $this->id = $id;
  13. + $this->reviewUrl = $reviewUrl;
  14. $this->context = $context;
  15. }
  16. + public function getReviewUrl(): string
  17. + {
  18. + return $this->reviewUrl;
  19. + }
  20. +
  21. public function getId(): int
  22. {
  23. return $this->id;

最后,更新控制器,让它生成评审 URL,然后把它传递给评论消息的构造函数:

patch_file

  1. --- a/src/Controller/AdminController.php
  2. +++ b/src/Controller/AdminController.php
  3. @@ -12,6 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
  4. use Symfony\Component\HttpKernel\KernelInterface;
  5. use Symfony\Component\Messenger\MessageBusInterface;
  6. use Symfony\Component\Routing\Annotation\Route;
  7. +use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  8. use Symfony\Component\Workflow\Registry;
  9. use Twig\Environment;
  10. @@ -47,7 +48,8 @@ class AdminController extends AbstractController
  11. $this->entityManager->flush();
  12. if ($accepted) {
  13. - $this->bus->dispatch(new CommentMessage($comment->getId()));
  14. + $reviewUrl = $this->generateUrl('review_comment', ['id' => $comment->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
  15. + $this->bus->dispatch(new CommentMessage($comment->getId(), $reviewUrl));
  16. }
  17. return $this->render('admin/review.html.twig', [
  18. --- a/src/Controller/ConferenceController.php
  19. +++ b/src/Controller/ConferenceController.php
  20. @@ -17,6 +17,7 @@ use Symfony\Component\Messenger\MessageBusInterface;
  21. use Symfony\Component\Notifier\Notification\Notification;
  22. use Symfony\Component\Notifier\NotifierInterface;
  23. use Symfony\Component\Routing\Annotation\Route;
  24. +use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  25. use Twig\Environment;
  26. class ConferenceController extends AbstractController
  27. @@ -82,7 +83,8 @@ class ConferenceController extends AbstractController
  28. 'permalink' => $request->getUri(),
  29. ];
  30. - $this->bus->dispatch(new CommentMessage($comment->getId(), $context));
  31. + $reviewUrl = $this->generateUrl('review_comment', ['id' => $comment->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
  32. + $this->bus->dispatch(new CommentMessage($comment->getId(), $reviewUrl, $context));
  33. $notifier->send(new Notification('Thank you for the feedback; your comment will be posted after moderation.', ['browser']));

解耦代码意味着在多个地方进行改动,但它让测试、理解和重用变得更容易。

再试一次,现在消息的处理已经很完善了:

../_images/slack-message.png

全面使用异步

我来解释下我们需要去解决的一个小问题。对于每个评论我们都会收到一封邮件和一个 Slack 消息。如果 Slack 消息出错了(错误的通道 id、错误的令牌等等),messenger 发出的消息会在丢弃前重试 3 次。但由于邮件先发出,所以我们会收到 3 封邮件,却没有任何 Slack 消息。解决该问题的一个方案就是像邮件一样异步发送 Slack 消息:

patch_file

  1. --- a/config/packages/messenger.yaml
  2. +++ b/config/packages/messenger.yaml
  3. @@ -20,3 +20,5 @@ framework:
  4. # Route your messages to the transports
  5. App\Message\CommentMessage: async
  6. Symfony\Component\Mailer\Messenger\SendEmailMessage: async
  7. + Symfony\Component\Notifier\Message\ChatMessage: async
  8. + Symfony\Component\Notifier\Message\SmsMessage: async

当一切变为异步时,消息就互相独立了。考虑到你可能会想要通过手机接收通知,我们也启用了手机短信的异步发送。

用邮件通知用户

最后一个任务是当评论通过审核后通知用户。你自己来实现这个功能,怎么样?

深入学习


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