chevere/workflow

Workflow procedures for PHP

0.9.0 2024-02-12 15:35 UTC

README

🔔 Subscribe to the newsletter to don't miss any update regarding Chevere.

Chevere

Build Code size Apache-2.0 PHPStan Mutation testing badge

Quality Gate Status Maintainability Rating Reliability Rating Security Rating Coverage Technical Debt CodeFactor

Summary

A Workflow is a configurable stored procedure that will run one or more jobs. Jobs are independent from each other, but interconnected as you can pass response references between jobs. Jobs supports conditional running based on variables and previous job responses.

Installing

Workflow is available through Packagist and the repository source is at chevere/workflow.

composer require chevere/workflow

What it does?

The Workflow package provides tooling for defining an execution procedure based on the workflow pattern. Its purpose is to abstract logic instructions as units of interconnected independent jobs.

Instead of building a monolithic procedure, you define a Workflow made of jobs, enabling developers to easy test and maintain re-usable multi-purpose logic.

::: tip 💡 Workflow introduction Read Workflow for PHP at Rodolfo's blog for a compressive introduction to this package. :::

How to use

Workflow provides the following functions at the Chevere\Workflow namespace. Use these functions to define a Workflow, its variables and response references for named jobs.

Function Purpose
workflow Create workflow made of named jobs
sync Create synchronous blocking job
async Create asynchronous non-blocking job
variable Define workflow-level variable
response Define a job response reference
  • A Job is defined by its Action
  • Jobs are independent from each other, define shared variables using function variable()
  • Reference {job#A response} -> {job#B input} by using function response()

To produce logic with this package:

  1. Create a Workflow using function workflow
  2. Define jobs using function sync or async
  3. Run the Workflow using function run

Creating Workflow

To create a Workflow define its named Jobs.

A Job is created by passing an Action and its expected run arguments which can be raw values, Variables and/or Responses to another job's output.

The syntax for writing Workflow jobs require name for job's name, sync/async depending on job run method, and named parameter binding for each Action::main parameter.

<name>: <sync|async>(
    <action>,
    <parameter>: <variable|reference|raw>,
)

For example, for the given MyAction action:

use function Chevere\Action\Action;

class MyAction extends Action
{
    protected function main(string $foo, string $bar): array
    {
        return [];
    }
}

You will be able to write a Workflow like this:

use function Chevere\Workflow\sync;

workflow(
    greet: sync(
        new MyAction(),
        foo: variable('super'),
        bar: variable('taldo'),
    )
);

With synchronous jobs

Use function sync to create a synchronous job, which block execution until it gets resolved.

In the example below a Workflow describes an image uploading procedure.

use function Chevere\Workflow\sync;
use function Chevere\Workflow\response;
use function Chevere\Workflow\variable;
use function Chevere\Workflow\workflow;

workflow(
    user: sync(
        new GetUser(),
        request: variable('payload')
    ),
    validate: sync(
        new ValidateImage(),
        mime: 'image/png',
        file: variable('file')
    ),
    meta: sync(
        new GetMeta(),
        file: variable('file'),
    ),
    store: sync(
        new StoreFile(),
        file: variable('file'),
        name: response('meta', 'name'),
        user: response('user')
    ),
);
  • variable('payload') and variable('file') declares a Variable.
  • response('meta', 'name') and response('user') declares a Response reference.

The graph for this Workflow says that all jobs run one after each other as all jobs are defined using sync.

graph TD;
    user-->validate-->meta-->store;
$workflow->jobs()->graph()->toArray();
// contains
[
    ['user'],
    ['validate'],
    ['meta'],
    ['store']
];

To complete the example, here's how to Run the Workflow previously defined:

use function Chevere\Workflow\run;

run(
    $workflow,
    payload: $_REQUEST,
    file: '/path/to/file',
);

With asynchronous jobs

Use function async to create an asynchronous job, which runs in parallel non-blocking.

In the example below a Workflow describes an image creation procedure for multiple image sizes.

use function Chevere\Workflow\sync;
use function Chevere\Workflow\response;
use function Chevere\Workflow\variable;
use function Chevere\Workflow\workflow;

workflow(
    thumb: async(
        new ImageResize(),
        image: variable('image'),
        width: 100,
        height: 100,
        fit: 'thumb'
    ),
    medium: async(
        new ImageResize(),
        image: variable('image'),
        width: 500,
        fit: 'resizeByW'
    ),
    store: sync(
        new StoreFiles(),
        response('thumb', 'filename'),
        response('medium', 'filename'),
    ),
);
  • variable('image') declares a Variable.
  • response('thumb', 'filename') and response('medium', 'filename') declares a Response reference.

The graph for this Workflow says that thumb, medium and poster run non-blocking in parallel. Job store runs blocking (another node).

graph TD;
    thumb-->store;
    medium-->store;
    poster-->store;
$workflow->jobs()->graph()->toArray();
// contains
[
    ['thumb', 'medium', 'poster'],
    ['store']
];

To complete the example, here's how to Run the Workflow previously defined:

use function Chevere\Workflow\run;

run(
    workflow: $workflow,
    arguments: [
        'image' => '/path/to/file',
    ]
);

Variable

Use function variable to declare a Workflow variable. This denotes a variable which must be injected by at Workflow run layer.

use function Chevere\Workflow\variable;

variable('myVar');

Response

Use function response to declare a Job response reference to a response returned by a previous Job.

🪄 When using a response it will auto declare the referenced Job as dependency.

use function Chevere\Workflow\response;

response(job: 'task');

References can be also made on a response member identified by key.

use function Chevere\Workflow\response;

response(job: 'task', key: 'name');

Creating Job

The Job class defines an Action with arguments which can be passed passed "as-is", variable or response on constructor using named arguments.

Synchronous job

use function Chevere\Workflow\job;

sync(
    new SomeAction(),
    ...$argument
);

Asynchronous job

use function Chevere\Workflow\job;

async(
    new SomeAction(),
    ...$argument
);

Note: Actions must support serialization for being used on async jobs. For not serializable Actions as these interacting with connections (namely streams, database clients, etc.) you should use sync job.

Job variables and references

sync(
    new SomeAction(),
    context: 'public',
    role: variable('role'),
    userId: response('user', 'id'),
);

For the code above, argument context will be passed "as-is" (public) to SomeAction, arguments role and userId will be dynamic provided. When running the Workflow these arguments will be matched against the Parameters defined at the main method for SomeAction.

Conditional running

Method withRunIf enables to pass arguments of type Variable or Response for conditionally running a Job.

sync(
    new CompressImage(),
    file: variable('file')
)
    ->withRunIf(
        variable('compressImage'),
        response('SomeAction', 'doImageCompress')
    )

For the code above, all conditions must meet to run the Job and both variable compressImage and the reference SomeAction:doImageCompress must be true to run the job.

Dependencies

Use withDepends method to explicit declare previous jobs as dependencies. The dependent Job won't run until the dependencies are resolved.

job(new SomeAction())
    ->withDepends('myJob');

Running a Workflow

To run a Workflow use the run function by passing a Workflow and an array for its variables (if any).

use function Chevere\Workflow\run;

$run = run($workflow, ...$variables);

Use getResponse to retrieve a job response as a CastArgument object which can be used to get a typed response.

$string = $run->getResponse('myJob')->string();

Code Examples

Hello, world

Run live example: php demo/hello-world.php Rodolfo - view source

The basic example Workflow defines a greet for a given username. The job greet is a named argument and it takes the GreetAction plus its main method arguments. The run function is used to execute the Workflow.

use Chevere\Demo\Actions\Greet;
use function Chevere\Workflow\run;
use function Chevere\Workflow\sync;
use function Chevere\Workflow\variable;
use function Chevere\Workflow\workflow;

$workflow = workflow(
    greet: sync(
        new Greet(),
        username: variable('username'),
    ),
);
$run = run(
    $workflow,
    username: $argv[1] ?? 'World'
);
$greet = $run->getReturn('greet')->string();
echo <<<PLAIN
{$greet}

PLAIN;

The graph for this Workflow contains only the greet job.

$workflow->jobs()->graph()->toArray();
// contains
[
    ['greet'],
];

Image resize example (async)

Run live example: php demo/image-resize.php - view source

For this example Workflow defines an image resize procedure in two sizes. All jobs are defined as async, but as there are dependencies between jobs (see variable and response) the system resolves a suitable run strategy.

use Chevere\Demo\Actions\ImageResize;
use Chevere\Demo\Actions\StoreFile;
use function Chevere\Workflow\async;
use function Chevere\Workflow\response;
use function Chevere\Workflow\run;
use function Chevere\Workflow\variable;
use function Chevere\Workflow\workflow;

$workflow = workflow(
    thumb: async(
        new ImageResize(),
        file: variable('image'),
        fit: 'thumbnail',
    ),
    poster: async(
        new ImageResize(),
        file: variable('image'),
        fit: 'poster',
    ),
    storeThumb: async(
        new StoreFile(),
        file: response('thumb'),
        dir: variable('saveDir'),
    ),
    storePoster: async(
        new StoreFile(),
        file: response('poster'),
        dir: variable('saveDir'),
    )
);
$run = run(
    $workflow,
    image: __DIR__ . '/src/php.jpeg',
    saveDir: __DIR__ . '/src/output/',
);

The graph for the Workflow above shows that thumb and poster run async, just like storeThumb and storePoster but the store* jobs run after the first dependency level gets resolved.

graph TD;
    thumb-->storeThumb;
    poster-->storePoster;
$workflow->jobs()->graph()->toArray();
// contains
[
    ['thumb', 'poster'],
    ['storeThumb', 'storePoster']
];

Use function run to run the Workflow, variables are passed as named arguments.

use function Chevere\Workflow\run;

$run = run(
    $workflow,
    image: '/path/to/image-to-upload.png',
    savePath: '/path/to/storage/'
);

Use getReturn to retrieve a job response as a CastArgument object which can be used to get a typed response.

$thumbFile = $run->getReturn('thumb')->string();

Sync vs Async

Run live example: php demo/sync-vs-async.php - view source

For this example you can compare the execution time between synchronous and asynchronous jobs. The example fetches the content of three URLs using FetchUrl action.

use Chevere\Demo\Actions\FetchUrl;
use function Chevere\Workflow\async;
use function Chevere\Workflow\run;
use function Chevere\Workflow\sync;
use function Chevere\Workflow\variable;
use function Chevere\Workflow\workflow;

$sync = workflow(
    php: sync(
        new FetchUrl(),
        url: variable('php'),
    ),
    github: sync(
        new FetchUrl(),
        url: variable('github'),
    ),
    chevere: sync(
        new FetchUrl(),
        url: variable('chevere'),
    ),
);
$async = workflow(
    php: async(
        new FetchUrl(),
        url: variable('php'),
    ),
    github: async(
        new FetchUrl(),
        url: variable('github'),
    ),
    chevere: async(
        new FetchUrl(),
        url: variable('chevere'),
    ),
);
$variables = [
    'php' => 'https://www.php.net',
    'github' => 'https://github.com/chevere/workflow',
    'chevere' => 'https://chevere.org',
];
$time = microtime(true);
$run = run($sync, ...$variables);
$time = microtime(true) - $time;
echo "Time sync: {$time}\n";
$time = microtime(true);
$run = run($async, ...$variables);
$time = microtime(true) - $time;
echo "Time async: {$time}\n";

When running sync (blocking) jobs the execution time is higher than async (non-blocking) jobs. This is because async jobs run in parallel.

Time sync: 2.5507028102875
Time async: 1.5810508728027

Conditional jobs

Run live example: php demo/run-if.php - view source

For this example Workflow defines a greet for a given username, but only if a sayHello variable is set to true.

use Chevere\Demo\Actions\Greet;
use function Chevere\Workflow\run;
use function Chevere\Workflow\sync;
use function Chevere\Workflow\variable;
use function Chevere\Workflow\workflow;

/*
php demo/run-if.php Rodolfo
php demo/run-if.php
*/

$workflow = workflow(
    greet: sync(
        new Greet(),
        username: variable('username'),
    )->withRunIf(
        variable('sayHello')
    ),
);

Method withRunIf accepts one or more variable and response references. All conditions must be true at the same time for the job to run.

Debugging

When working with this package you may want to debug the Workflow to ensure that the jobs are declared as expected.

To debug a Workflow inspect the Jobs graph. It will show the job names and their dependencies for each execution level.

$workflow->jobs()->graph()->toArray();
[
    ['job1', 'job2'], // 1st level
    ['job3', 'job4'], // 2nd level
    ['job5'],         // 3rd level
];

For each level jobs will run in parallel, but the next level will run after the previous level gets resolved.

Note

For runtime debugging is strongly recommended to use a non-blocking debugger like xrDebug.

Testing

Workflow checks on variables, references and any other configuration so you don't have to worry about that.

Testing the Workflow itself is not necessary as it's just a configuration. What you need to test is the Workflow definition and their Jobs (Actions).

Testing Workflow

For testing a Workflow what you need to assert is the expected Workflow graph (execution order).

assertSame(
    $expectedGraph,
    $workflow->jobs()->graph()->toArray()
);

Testing Job

For testing a Job what you need to test is the Action that defines that given Job against the response from the Action main method.

$action = new MyAction();
assertSame(
    $expected,
    $action->main(...$arguments)
);

Documentation

Documentation is available at chevere.org/packages/workflow.

License

Copyright Rodolfo Berrios A.

This software is licensed under the Apache License, Version 2.0. See LICENSE for the full license text.

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.