步骤 19: 用 Workflow 进行决策

用 Workflow 进行决策

让模型拥有状态是很常见的。目前评论的状态只由垃圾信息检查器来决定。如果我们要加入更多的决策因素,那该怎么做?

在垃圾信息检查器判别之后,我们可能想要让网站管理员来管理所有评论。这个流程看上去可能是这样的:

  • 当用户提交评论时,我们把它的初始状态设为 submitted
  • 然后垃圾信息检查器来分析这条评论,把它的状态转为 potential_spamhamrejected 中的一个;
  • 如果评论不是 rejected 状态,那需要等待管理员根据它的内容质量来决定,是把它切换到 published 还是 rejected

实现这个逻辑并不复杂,但你可以想到,不断增加类似的规则会大幅提高复杂度。我们可以使用 Symfony 的 Workflow 组件,而不是自己实现它。

  1. $ symfony composer req workflow

描述工作流(Workflow)

可以在 config/packages/workflow.yaml 文件中描述评论的处理流程:

config/packages/workflow.yaml

  1. framework:
  2. workflows:
  3. comment:
  4. type: state_machine
  5. audit_trail:
  6. enabled: "%kernel.debug%"
  7. marking_store:
  8. type: 'method'
  9. property: 'state'
  10. supports:
  11. - App\Entity\Comment
  12. initial_marking: submitted
  13. places:
  14. - submitted
  15. - ham
  16. - potential_spam
  17. - spam
  18. - rejected
  19. - published
  20. transitions:
  21. accept:
  22. from: submitted
  23. to: ham
  24. might_be_spam:
  25. from: submitted
  26. to: potential_spam
  27. reject_spam:
  28. from: submitted
  29. to: spam
  30. publish:
  31. from: potential_spam
  32. to: published
  33. reject:
  34. from: potential_spam
  35. to: rejected
  36. publish_ham:
  37. from: ham
  38. to: published
  39. reject_ham:
  40. from: ham
  41. to: rejected

为了验证这个流程,可以生成一个示意图:

  1. $ symfony console workflow:dump comment | dot -Tpng -o workflow.png

../_images/workflow.png

注解

dot 命令是 Graphviz 工具的一部分。

使用工作流

在消息处理器里用工作流来代替当前逻辑:

patch_file

  1. --- a/src/MessageHandler/CommentMessageHandler.php
  2. +++ b/src/MessageHandler/CommentMessageHandler.php
  3. @@ -6,19 +6,28 @@ use App\Message\CommentMessage;
  4. use App\Repository\CommentRepository;
  5. use App\SpamChecker;
  6. use Doctrine\ORM\EntityManagerInterface;
  7. +use Psr\Log\LoggerInterface;
  8. use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
  9. +use Symfony\Component\Messenger\MessageBusInterface;
  10. +use Symfony\Component\Workflow\WorkflowInterface;
  11. class CommentMessageHandler implements MessageHandlerInterface
  12. {
  13. private $spamChecker;
  14. private $entityManager;
  15. private $commentRepository;
  16. + private $bus;
  17. + private $workflow;
  18. + private $logger;
  19. - public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository)
  20. + public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, LoggerInterface $logger = null)
  21. {
  22. $this->entityManager = $entityManager;
  23. $this->spamChecker = $spamChecker;
  24. $this->commentRepository = $commentRepository;
  25. + $this->bus = $bus;
  26. + $this->workflow = $commentStateMachine;
  27. + $this->logger = $logger;
  28. }
  29. public function __invoke(CommentMessage $message)
  30. @@ -28,12 +37,21 @@ class CommentMessageHandler implements MessageHandlerInterface
  31. return;
  32. }
  33. - if (2 === $this->spamChecker->getSpamScore($comment, $message->getContext())) {
  34. - $comment->setState('spam');
  35. - } else {
  36. - $comment->setState('published');
  37. - }
  38. - $this->entityManager->flush();
  39. + if ($this->workflow->can($comment, 'accept')) {
  40. + $score = $this->spamChecker->getSpamScore($comment, $message->getContext());
  41. + $transition = 'accept';
  42. + if (2 === $score) {
  43. + $transition = 'reject_spam';
  44. + } elseif (1 === $score) {
  45. + $transition = 'might_be_spam';
  46. + }
  47. + $this->workflow->apply($comment, $transition);
  48. + $this->entityManager->flush();
  49. +
  50. + $this->bus->dispatch($message);
  51. + } elseif ($this->logger) {
  52. + $this->logger->debug('Dropping comment message', ['comment' => $comment->getId(), 'state' => $comment->getState()]);
  53. + }
  54. }
  55. }

新的逻辑是这样:

  • 如果消息里的评论可以进行名为 accept 的状态迁移,就检查是否为垃圾信息;
  • 根据结果来选择要应用的状态迁移;
  • 调用 apply() 方法来更新评论,它会调用评论的 setState() 方法。
  • 调用 flush() 方法来把更新保存到数据库;
  • 重新派发信息来让工作流再次迁移状态。

因为我们还没有实现管理后台的验证,下次消费信息时,日志中会记录 “Dropping comment message”。

在下一章里我们会实现一个自动验证:

patch_file

  1. --- a/src/MessageHandler/CommentMessageHandler.php
  2. +++ b/src/MessageHandler/CommentMessageHandler.php
  3. @@ -50,6 +50,9 @@ class CommentMessageHandler implements MessageHandlerInterface
  4. $this->entityManager->flush();
  5. $this->bus->dispatch($message);
  6. + } elseif ($this->workflow->can($comment, 'publish') || $this->workflow->can($comment, 'publish_ham')) {
  7. + $this->workflow->apply($comment, $this->workflow->can($comment, 'publish') ? 'publish' : 'publish_ham');
  8. + $this->entityManager->flush();
  9. } elseif ($this->logger) {
  10. $this->logger->debug('Dropping comment message', ['comment' => $comment->getId(), 'state' => $comment->getState()]);
  11. }

执行 symfony server:log,然后在前端页面添加一个评论,看一下输出的一个个状态迁移。

在依赖注入容器中找到服务

当使用依赖注入时,我们通过接口类型提示或者有时使用具体的实现类名,在依赖注入容器中找到对应的服务。但当某个接口有多个实现类时,Symfony 无法猜出你需要的是哪个实现类。我们要明确指出需要哪个服务类。

在前面一节,我们刚刚遇到的 WorkflowInterface 注入,就是这样的一个例子。

当我们在构造函数中注入 WorkflowInterface 接口的任何实例时,Symfony 如何猜测要用哪一个工作流的实现呢?Symfony 使用基于参数名的约定:$commentStateMachine 指向 配置里的 comment 工作流(它的类型是 state_machine)。试着换成其它的参数名都会失败。

如果你不记得这个约定了,使用 debug:container 命令。查找所有包含了 “workflow” 关键词的服务:

  1. $ symfony console debug:container workflow
  2. Select one of the following services to display its information:
  3. [0] console.command.workflow_dump
  4. [1] workflow.abstract
  5. [2] workflow.marking_store.method
  6. [3] workflow.registry
  7. [4] workflow.security.expression_language
  8. [5] workflow.twig_extension
  9. [6] monolog.logger.workflow
  10. [7] Symfony\Component\Workflow\Registry
  11. [8] Symfony\Component\Workflow\WorkflowInterface $commentStateMachine
  12. [9] Psr\Log\LoggerInterface $workflowLogger
  13. >

注意选项 8Symfony\Component\Workflow\WorkflowInterface $commentStateMachine,它告诉你使用 $commentStateMachine 作为参数名有着特殊意义。

注解

正如在之前有一章里看到的那样,我们也可以用 debug:autowiring 命令:

  1. $ symfony console debug:autowiring workflow

深入学习


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