wol-soft/php-workflow

将工作流粘合在一起

2.1.0 2022-04-21 09:03 UTC

This package is auto-updated.

Last update: 2024-08-29 14:48:53 UTC


README

Latest Version Minimum PHP Version Maintainability Build Status Coverage Status MIT License

php-workflow

从小块创建受控的工作流。

此库提供一组预定义的阶段,以便将您的流程粘合在一起。您实现小的自包含代码块,并定义执行顺序 - 其他所有事情将由执行控制完成。

附加功能:您将为每个执行的流程获得执行日志 - 如果您想查看发生了什么。

目录

工作流与流程

在我们开始使用库进行编码之前,让我们看看使用此库实现的流程可以做什么,以及流程不能做什么。

假设我们想通过在线商店销售商品。如果客户购买商品,他将经历购买商品的流程。此流程包含多个步骤。每个流程步骤都可以用此库实现的流程来表示。例如

  • 客户注册
  • 将商品添加到购物车
  • 结账购物车
  • ...

此库帮助您以结构化的方式实现流程步骤。它不控制流程流程。

现在我们知道了此库的目标用例。现在让我们安装库并开始编码。

安装

安装 php-workflow 的推荐方法是使用 Composer

$ composer require wol-soft/php-workflow

库的要求

  • 需要 PHP 7.4 或更高版本

示例工作流

首先,让我们看看一个代码示例。我们的示例将表示将歌曲添加到媒体播放器播放列表中的代码。通常,您会有一个控制器方法,该方法使用许多 if,返回,try-catch 块等粘合所有必要的步骤。现在让我们看看可能的流程定义

$workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist'))
    ->validate(new CurrentUserIsAllowedToEditPlaylistValidator())
    ->validate(new PlaylistAlreadyContainsSongValidator())
    ->before(new AcceptOpenSuggestionForSong())
    ->process(new AddSongToPlaylist())
    ->onSuccess(new NotifySubscribers())
    ->onSuccess(new AddPlaylistLogEntry())
    ->onSuccess(new UpdateLastAddedToPlaylists())
    ->onError(new RecoverLog())
    ->executeWorkflow();

此流程可能创建的执行日志如下(稍后将提供更多示例)

Process log for workflow 'AddSongToPlaylist':
Validation:
  - Check if the playlist is editable: ok
  - Check if the playlist already contains the requested song: ok
Before:
  - Accept open suggestions for songs which shall be added: skipped (No open suggestions for playlist)
Process:
  - Add the songs to the playlist: ok
    - Appended song at the end of the playlist
    - New playlist length: 2
On Success:
  - Notify playlist subscribers about added song: ok
    - Notified 5 users
  - Persist changes in the playlist log: ok
  - Update the users list of last contributed playlists: ok

Summary:
  - Workflow execution: ok
    - Execution time: 45.27205ms

现在,让我们检查到底发生了什么。您的流程的每个步骤都由一个代表步骤的类表示,该类实现了步骤。步骤可以用于多个流程(例如,CurrentUserIsAllowedToEditPlaylistValidator 可以用于每个修改播放列表的流程)。这些表示单个步骤的每个类都必须实现 \PHPWorkflow\Step\WorkflowStep 接口。在您调用 executeWorkflow 方法之前,不会执行任何步骤。

通过调用 executeWorkflow 方法,工作流引擎将被触发以从第一个使用的阶段开始执行。在我们的示例中,首先执行验证。如果所有验证都成功,则执行下一个阶段,否则取消工作流执行。

让我们更精确地看看通过before步骤的例子AcceptOpenSuggestionForSong的实现。以下是一些背景知识,以便理解我们的例子中所发生的事情:我们的应用程序允许用户为播放列表建议歌曲。如果播放列表的所有者将歌曲添加到已作为开放建议存在的播放列表中,则应接受建议而不是将歌曲添加到播放列表中,同时保持建议不变。现在,让我们面对实现,并使用一些内联注释来描述工作流程控制。

class AcceptOpenSuggestionForSong implements \PHPWorkflow\Step\WorkflowStep {
    /**
     * Each step must provide a description. The description will be used in the debug
     * log of the workflow to get a readable representation of an executed workflow 
     */
    public function getDescription(): string
    {
        return 'Accept open suggestions for songs which shall be added to a playlist';
    }

    /**
     * Each step will get access to two objects to interact with the workflow.
     * First the WorkflowControl object $control which provides methods to skip
     * steps, mark tests as failed, add debug information etc.
     * Second the WorkflowContainer object $container which allows us to get access
     * to various workflow related objects.
     */
    public function run(
        \PHPWorkflow\WorkflowControl $control,
        \PHPWorkflow\State\WorkflowContainer $container
    ) {
        $openSuggestions = (new SuggestionRepository())
            ->getOpenSuggestionsByPlaylistId($container->get('playlist')->getId());

        // If we detect a condition which makes a further execution of the step
        // unnecessary we can simply skip the further execution.
        // By providing a meaningful reason our workflow debug log will be helpful.
        if (empty($openSuggestions)) {
            $control->skipStep('No open suggestions for playlist');
        }

        foreach ($openSuggestions as $suggestion) {
            if ($suggestion->getSongId() === $container->get('song')->getId()) {
                if ((new SuggestionService())->acceptSuggestion($suggestion)) {
                    // If we detect a condition where the further workflow execution is
                    // unnecessary we can skip the further execution.
                    // In this example the open suggestion was accepted successfully so
                    // the song must not be added to the playlist via the workflow.
                    $control->skipWorkflow('Accepted open suggestion');
                }

                // We can add warnings to the debug log. Another option in this case could
                // be to call $control->failWorkflow() if we want the workflow to fail in
                // an error case.
                // In our example, if the suggestion can't be accepted, we want to add the
                // song to the playlist via the workflow.
                $control->warning("Failed to accept open suggestion {$suggestion->getId()}");
            }
        }

        // for completing the debug log we mark this step as skipped if no action has been
        // performed. If we don't mark the step as skipped and no action has been performed
        // the step will occur as 'ok' in the debug log - depends on your preferences :)
        $control->skipStep('No matching open suggestion');
    }
}

工作流容器

现在让我们更详细地看看WorkflowContainer,它帮助我们在工作流程步骤之间共享数据和对象。我们示例工作流程的相关对象是想要添加歌曲的User、要添加的歌曲的Song对象和Playlist对象。在我们执行工作流程之前,我们可以设置一个包含所有相关对象的WorkflowContainer

$workflowContainer = (new \PHPWorkflow\State\WorkflowContainer())
    ->set('user', Session::getUser())
    ->set('song', (new SongRepository())->getSongById($request->get('songId')))
    ->set('playlist', (new PlaylistRepository())->getPlaylistById($request->get('playlistId')));

工作流程容器提供了以下接口

// returns an item or null if the key doesn't exist
public function get(string $key)
// set or update a value
public function set(string $key, $value): self
// remove an entry
public function unset(string $key): self
// check if a key exists
public function has(string $key): bool

每个工作流程步骤可以定义要求,在步骤执行之前,工作流程容器中必须存在哪些条目。有关更多详细信息,请参阅所需容器值

除了通过字符串键设置和获取WorkflowContainer中的值之外,您还可以扩展WorkflowContainer并添加类型化属性/函数来以类型安全的方式处理值

class AddSongToPlaylistWorkflowContainer extends \PHPWorkflow\State\WorkflowContainer {
    public function __construct(
        public User $user,
        public Song $song,
        public Playlist $playlist,
    ) {}
}

$workflowContainer = new AddSongToPlaylistWorkflowContainer(
    Session::getUser(),
    (new SongRepository())->getSongById($request->get('songId')),
    (new PlaylistRepository())->getPlaylistById($request->get('playlistId')),
);

当我们通过executeWorkflow执行工作流程时,我们可以注入WorkflowContainer

$workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist'))
    // ...
    ->executeWorkflow($workflowContainer);

另一种可能的方法是在Prepare阶段(例如PopulateAddSongToPlaylistContainer)中定义一个步骤,该步骤填充自动注入的空WorkflowContainer对象。

阶段

在定义工作流程时,以下预定义阶段可用

  • 准备
  • 验证
  • 之前
  • 处理
  • 成功后
  • 出错
  • 之后

每个阶段都有一组定义的子阶段,可以在之后调用(例如,您可能跳过之前阶段)。在设置工作流程时,您的IDE将通过自动完成建议仅可能的下一步来支持您。每个工作流程必须至少包含一个位于处理阶段的步骤。

任何添加到工作流程的步骤都可能抛出异常。每个异常都将被捕获并像失败的步骤一样处理。如果在准备验证(请参阅该阶段详细信息)或之前阶段中的步骤失败,则工作流程失败,并且不会进一步执行。

任何步骤都可以通过WorkflowControl跳过或失败工作流程。如果在处理阶段已执行,并且任何后续步骤尝试失败或跳过整个工作流程,则它被视为失败/跳过的步骤。

现在让我们看看一些特定阶段的详细信息。

准备

此阶段允许您添加必须在触发任何验证或处理执行之前执行的步骤。步骤可能包含数据加载、获取工作流程相关的信号量等。

验证

此阶段允许您执行验证。有两种类型的验证:硬验证和软验证。工作流程的所有硬验证都将执行在软验证之前。如果硬验证失败,则工作流程将立即停止(例如,访问权限违规)。工作流程的所有软验证都将独立于其结果执行。所有失败的软验证都将收集在\PHPWorkflow\Exception\WorkflowValidationException中,如果任何软验证失败,则在验证阶段结束时抛出。

将验证附加到工作流程时,validate方法的第二个参数定义了验证是软验证还是硬验证。

$workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist'))
    // hard validator: if the user isn't allowed to edit the playlist
    // the workflow execution will be cancelled immediately
    ->validate(new CurrentUserIsAllowedToEditPlaylistValidator(), true)
    // soft validators: all validators will be executed
    ->validate(new PlaylistAlreadyContainsSongValidator())
    ->validate(new SongGenreMatchesPlaylistGenreValidator())
    ->validate(new PlaylistContainsNoSongsFromInterpret())
    // ...

在提供的示例中,任何软验证器都可能失败(例如,SongGenreMatchesPlaylistGenreValidator检查歌曲流派是否与播放列表匹配,PlaylistContainsNoSongsFromInterpret可能检查重复的艺术家)。抛出的WorkflowValidationException允许我们确定所有违规情况并设置相应的错误消息。如果所有验证器都通过,则执行下一阶段。

之前

此阶段允许您在知道工作流程执行有效的情况下执行准备步骤。这些步骤可能包括资源分配、过滤要处理的数据等。

处理

此阶段包含工作流程的主逻辑。如果任何步骤失败,则不会执行该过程阶段的后续步骤。

成功后

此阶段允许您定义如果所有Process阶段的步骤都成功执行,则应执行的步骤。例如,记录、通知、发送电子邮件等。

即使某些步骤失败,也将执行该阶段的所有步骤。所有失败的步骤将报告为警告。

出错

此阶段允许您定义如果Process阶段的任何步骤失败,则应执行的步骤。例如,记录、设置恢复数据等。

即使某些步骤失败,也将执行该阶段的所有步骤。所有失败的步骤将报告为警告。

之后

此阶段允许您在所有其他阶段执行后执行清理步骤。无论Process阶段是否成功执行,都将执行这些步骤。

即使某些步骤失败,也将执行该阶段的所有步骤。所有失败的步骤将报告为警告。

工作流控制

注入到每个步骤中的WorkflowControl对象提供了以下接口来与工作流程交互

// Mark the current step as skipped.
// Use this if you detect, that the step execution is not necessary
// (e.g. disabled by config, no entity to process, ...)
public function skipStep(string $reason): void;

// Mark the current step as failed. A failed step before and during the processing of
// a workflow leads to a failed workflow.
public function failStep(string $reason): void;

// Mark the workflow as failed. If the workflow is failed after the process stage has
// been executed it's handled like a failed step.
public function failWorkflow(string $reason): void;

// Skip the further workflow execution (e.g. if you detect it's not necessary to process
// the workflow). If the workflow is skipped after the process stage has been executed
// it's handled like a skipped step.
public function skipWorkflow(string $reason): void;

// Useful when using loops to cancel the current iteration (all upcoming steps).
// If used outside a loop, it behaves like skipStep.
public function continue(string $reason): void;

// Useful when using loops to break the loop (all upcoming steps and iterations).
// If used outside a loop, it behaves like skipStep.
public function break(string $reason): void;

// Attach any additional debug info to your current step.
// The infos will be shown in the workflow debug log.
public function attachStepInfo(string $info): void

// Add a warning to the workflow.
// All warnings will be collected and shown in the workflow debug log.
// You can provide an additional exception which caused the warning.
// If you provide the exception, exception details will be added to the debug log.
public function warning(string $message, ?Exception $exception = null): void;

嵌套工作流

如果您的某些步骤变得更加复杂,您可能想查看NestedWorkflow包装器,该包装器允许您将第二个工作流程作为您工作流程的步骤使用

$parentWorkflowContainer = (new \PHPWorkflow\State\WorkflowContainer())->set('parent-data', 'Hello');
$nestedWorkflowContainer = (new \PHPWorkflow\State\WorkflowContainer())->set('nested-data', 'World');

$workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist'))
    ->validate(new CurrentUserIsAllowedToEditPlaylistValidator())
    ->before(new \PHPWorkflow\Step\NestedWorkflow(
        (new \PHPWorkflow\Workflow('AcceptOpenSuggestions'))
            ->validate(new PlaylistAcceptsSuggestionsValidator())
            ->before(new LoadOpenSuggestions())
            ->process(new AcceptOpenSuggestions())
            ->onSuccess(new NotifySuggestor()),
        $nestedWorkflowContainer,
    ))
    ->process(new AddSongToPlaylist())
    ->onSuccess(new NotifySubscribers())
    ->executeWorkflow($parentWorkflowContainer);

每个嵌套工作流程都必须可执行(至少包含一个Process步骤)。

嵌套工作流程的调试日志将嵌入到主工作流程的调试日志中。

如您在示例中看到的,您可以将专门的WorkflowContainer注入到嵌套工作流程中。嵌套工作流程将获得访问合并的WorkflowContainer,该< strong>WorkflowContainer提供了您主工作流程容器和嵌套容器的所有数据和方法。如果向合并容器添加其他数据,则在嵌套工作流程执行完成后,主工作流程容器中将存在这些数据。例如,嵌套工作流程中使用的步骤的实现将有权访问nested-dataparent-data键。

循环

如果一次处理多个实体,您可能需要循环。一种方法是在单个步骤中设置循环以及循环中需要执行的所需逻辑。但如果需要执行循环中的多个步骤,您可能希望将步骤拆分成多个步骤。通过使用Loop类,您可以在循环中执行多个步骤。例如,假设我们的AddSongToPlaylist成为可以一次添加多个歌曲的AddSongsToPlaylist工作流程

$workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist'))
    ->validate(new CurrentUserIsAllowedToEditPlaylistValidator())
    ->process(
        (new \PHPWorkflow\Step\Loop(new SongLoop()))
            ->addStep(new AddSongToPlaylist())
            ->addStep(new ClearSongCache())
    )
    ->onSuccess(new NotifySubscribers())
    ->executeWorkflow($workflowContainer);

我们的过程步骤现在实现了一个由SongLoop类控制的循环。循环包含我们的两个步骤AddSongToPlaylistClearSongCacheSongLoop类的实现必须实现PHPWorkflow\Step\LoopControl接口。让我们看看一个示例实现

class SongLoop implements \PHPWorkflow\Step\LoopControl {
    /**
     * As well as each step also each loop must provide a description.
     */
    public function getDescription(): string
    {
        return 'Loop over all provided songs';
    }

    /**
     * This method will be called before each loop run.
     * $iteration will contain the current iteration (0 on first run etc)
     * You have access to the WorkflowControl and the WorkflowContainer.
     * If the method returns true the next iteration will be executed.
     * Otherwise the loop is completed.
     */
    public function executeNextIteration(
        int $iteration,
        \PHPWorkflow\WorkflowControl $control,
        \PHPWorkflow\State\WorkflowContainer $container
    ): bool {
        // all songs handled - end the loop
        if ($iteration === count($container->get('songs'))) {
            return false;
        }

        // add the current song to the container so the steps
        // of the loop can access the entry
        $container->set('currentSong', $container->get('songs')[$iteration]);

        return true;
    }
}

循环步骤可以包含嵌套工作流程,如果您需要更复杂的步骤。

您可以使用continuebreak方法在WorkflowControl对象上控制循环的流程。

默认情况下,如果步骤失败,循环会停止。您可以将 Loop 类的第二个参数($continueOnError)设置为 true,以在下一个迭代中继续执行。如果您启用此选项,失败的步骤不会导致工作流程失败。相反,将在进程日志中添加一个警告。对 failWorkflowskipWorkflow 的调用将始终取消循环(以及相应的工作流程),不受此选项的影响。

步骤依赖

每个步骤实现都可能将依赖项应用于步骤。通过定义依赖项,您可以设置在执行步骤之前要检查的验证规则(例如:在工作流程容器中必须提供哪些数据)。如果任何一个依赖项未满足,则步骤将不会执行,并被视为失败的步骤。

注意:由于该功能使用 属性,因此只有当您使用 PHP >= 8.0 时才可用。

必需的容器值

使用 \PHPWorkflow\Step\Dependency\Required 属性,您可以定义必须在提供的流程容器中存在的键。这些键相应地必须在初始流程中提供或由先前的步骤填充。除了键之外,您还可以提供值的类型(例如:string)。

要定义依赖项,只需注释提供的流程容器参数

public function run(
    \PHPWorkflow\WorkflowControl $control,
    // The key customerId must contain a string
    #[\PHPWorkflow\Step\Dependency\Required('customerId', 'string')]
    // The customerAge must contain an integer. But also null is accepted.
    // Each type definition can be prefixed with a ? to accept null.
    #[\PHPWorkflow\Step\Dependency\Required('customerAge', '?int')]
    // Objects can also be type hinted
    #[\PHPWorkflow\Step\Dependency\Required('created', \DateTime::class)]
    \PHPWorkflow\State\WorkflowContainer $container,
) {
    // Implementation which can rely on the defined keys to be present in the container.
}

支持以下类型:stringboolintfloatobjectarrayiterablescalar以及通过提供相应的 FQCN 的对象类型提示。

错误处理、日志记录和调试

executeWorkflow 方法返回一个 WorkflowResult 对象,该对象提供了以下方法来确定工作流程的结果

// check if the workflow execution was successful
public function success(): bool;
// check if warnings were emitted during the workflow execution
public function hasWarnings(): bool;
// get a list of warnings, grouped by stage
public function getWarnings(): array;
// get the exception which caused the workflow to fail
public function getException(): ?Exception;
// get the debug execution log of the workflow
public function debug(?OutputFormat $formatter = null);
// access the container which was used for the workflow
public function getContainer(): WorkflowContainer;
// get the last executed step
// (e.g. useful to determine which step caused a workflow to fail)
public function getLastStep(): WorkflowStep;

如上所述,在 Process 阶段之前失败的步骤的工作流程将被终止,否则将执行 Process 阶段以及所有下游阶段。

默认情况下,工作流程的执行在发生错误时抛出异常。抛出的异常将是一个 \PHPWorkflow\Exception\WorkflowException,它允许您通过 getWorkflowResult 方法访问 WorkflowResult 对象。

debug 方法提供了一个执行日志,包括所有已处理的步骤及其状态、附加数据以及所有警告和性能数据。

以下是我们示例工作流程的一些示例输出。

成功执行

Process log for workflow 'AddSongToPlaylist':
Validation:
  - Check if the playlist is editable: ok
  - Check if the playlist already contains the requested song: ok
Before:
  - Accept open suggestions for songs which shall be added: skipped (No open suggestions for playlist)
Process:
  - Add the songs to the playlist: ok
    - Appended song at the end of the playlist
    - New playlist length: 2
On Success:
  - Notify playlist subscribers about added song: ok
    - Notified 5 users
  - Persist changes in the playlist log: ok
  - Update the users list of last contributed playlists: ok

Summary:
  - Workflow execution: ok
    - Execution time: 45.27205ms

注意通过 WorkflowControlattachStepInfo 方法添加到 Process 阶段和 NotifySubscribers 步骤的附加数据。

失败的工作流程

Process log for workflow 'AddSongToPlaylist':
Validation:
  - Check if the playlist is editable: failed (playlist locked)

Summary:
  - Workflow execution: failed
    - Execution time: 6.28195ms

在此示例中,CurrentUserIsAllowedToEditPlaylistValidator 步骤抛出了一个包含消息 playlist locked 的异常。

跳过工作流程

Process log for workflow 'AddSongToPlaylist':
Validation:
  - Check if the playlist is editable: ok
  - Check if the playlist already contains the requested song: ok
Before:
  - Accept open suggestions for songs which shall be added: ok (Accepted open suggestion)

Summary:
  - Workflow execution: skipped (Accepted open suggestion)
    - Execution time: 89.56986ms

在此示例中,AcceptOpenSuggestionForSong 步骤找到了匹配的开放建议并成功接受建议。因此,将跳过进一步的工作流程执行。

自定义输出格式化器

可以通过实现 OutputFormat 接口来控制 debug 方法的输出。默认情况下,将返回执行字符串表示(就像示例输出一样)。

目前实现了以下附加格式化程序

测试

该库通过 PHPUnit 进行测试。

通过 composer update 安装库的依赖项后,您可以使用 ./vendor/bin/phpunit(Linux)或 vendor\bin\phpunit.bat(Windows)执行测试。测试名称已针对 --testdox 输出进行了优化。

如果您想测试工作流程,可以包含 PHPWorkflow\Tests\WorkflowTestTrait,它为测试类添加了简化断言工作流程结果的方法。以下方法被添加到您的测试类中

// assert the debug output of the workflow. See library tests for example usages
protected function assertDebugLog(string $expected, WorkflowResult $result): void
// provide a step which you expect to fail the workflow.
// example: $this->expectFailAtStep(MyFailingStep::class, $workflowResult);
protected function expectFailAtStep(string $step, WorkflowResult $result): void
// provide a step which you expect to skip the workflow.
// example: $this->expectSkipAtStep(MySkippingStep::class, $workflowResult);
protected function expectSkipAtStep(string $step, WorkflowResult $result): void

研讨会

可能您想尝试这个库,但缺乏一个简单的用例来测试库的功能。因此,这里提供了一个小型的研讨会,涵盖了库的大部分功能。实现以下任务(公平地说,这个任务没有使用库可能更容易实现,但库的设计是为了支持包含大量业务逻辑的大型工作流程),以了解如何使用该库进行编码。

此任务的数据输入是一个简单的数组,包含以下格式的个人列表

[
    'firstname' => string,
    'lastname' => string,
    'age' => int,
]

工作流程应执行以下步骤

  1. 检查列表是否为空。在这种情况下,直接结束工作流程
  2. 检查列表中是否包含18岁以下的个人。在这种情况下,工作流程应失败
  3. 确保每个first name和last name都被填写。如果检测到任何空字段,工作流程应失败
  4. 在处理列表之前,对first names和last names进行标准化(使用ucfirsttrim
    • 确保工作流程日志包含更改的数据集数量
  5. 处理列表。处理本身分为以下步骤
    1. 确保您选择的目录中包含从输入数据中每个年龄的CSV文件
      • 如果文件不存在,则创建一个新文件
      • 工作流程日志必须包含关于新文件的信息
    2. 将输入数据中的所有个人添加到相应的文件中
      • 工作流程日志必须显示添加到每个文件中的个人数量
  6. 如果所有个人都成功持久化,则创建所有文件的ZIP备份
  7. 如果发生错误,则回滚到最后一个现有的ZIP备份

如果您已经完成了工作流程的实现,请选择一个步骤并实现该步骤的单元测试。