spiff-arena/SpiffWorkflow/doc/bpmn/advanced.rst

417 lines
18 KiB
ReStructuredText

A More In-Depth Look at Some of SpiffWorkflow's Features
========================================================
Filtering Tasks
---------------
In our earlier example, all we did was check the lane a task was in and display
it along with the task name and state.
Let's take a look at a sample workflow with lanes:
.. figure:: figures/lanes.png
:scale: 30%
:align: center
Workflow with lanes
To get all the tasks that are ready for the 'Customer' workflow, we could
specify the lane when retrieving ready user tasks:
.. code:: python
ready_tasks = workflow.get_ready_user_tasks(lane='Customer')
If there were no tasks ready for the 'Customer' lane, you would get an empty list,
and of course if you had no lane that was labeled 'Customer' you would *always* get an
empty list.
We can also get a list of tasks by state.
We need to import the :code:`Task` object (unless you want to memorize which numbers
correspond to which states).
.. code:: python
from SpiffWorkflow.task import TaskState
To get a list of completed tasks
.. code:: python
tasks = workflow.get_tasks(TaskState.COMPLETED)
The tasks themselves are not particularly intuitive to work with. So SpiffWorkflow
provides some facilities for obtaining a more user-friendly version of upcoming tasks.
Logging
-------
Spiff provides several loggers:
- the :code:`spiff` logger, which emits messages when a workflow is initialized and when tasks change state
- the :code:`spiff.metrics` logger, which emits messages containing the elapsed duration of tasks
- the :code:`spiff.data` logger, which emits a message when task or workflow data is updated.
Log level :code:`INFO` will provide reasonably detailed information about state changes.
As usual, log level :code:`DEBUG` will probably provide more logs than you really want
to see, but the logs will contain the task and task internal data.
Data can be included at any level less than :code:`INFO`. In our example application,
we define a custom log level
.. code:: python
logging.addLevelName(15, 'DATA_LOG')
so that we can see the task data in the logs without fully enabling debugging.
The workflow runners take an `-l` argument that can be used to specify the logging level used
when running the example workflows.
Serialization
-------------
.. warning::
Serialization Changed in Version 1.1.7.
Support for pre-1.1.7 serialization will be dropped in a future release.
The old serialization method still works, but it is deprecated.
To migrate your system to the new version, see "Migrating between
serialization versions" below.
So far, we've only considered the context where we will run the workflow from beginning to end in one
setting. This may not always be the case, we may be executing the workflow in the context of a web server where we
may have a user request a web page where we open a specific workflow that we may be in the middle of, do one step of
that workflow and then the user may be back in a few minutes, or maybe a few hours depending on the application.
The :code:`BpmnWorkflowSerializer` class contains a serializer for a workflow containing only standard BPMN Tasks.
Since we are using custom task classes (the Camunda :code:`UserTask` and the DMN :code:`BusinessRuleTask`),
we'll need to supply serializers for those task specs as well.
Strictly speaking, these are not serializers per se: they actually convert the tasks into dictionaries of
JSON-serializable objects. Conversion to JSON is done only as the last step and could easily be replaced with some
other output format.
We'll need to configure a Workflow Spec Converter with our custom classes, as well as an optional
custom data converter.
.. code:: python
def create_serializer(task_types, data_converter=None):
wf_spec_converter = BpmnWorkflowSerializer.configure_workflow_spec_converter(task_types)
return BpmnWorkflowSerializer(wf_spec_converter, data_converter)
We'll call this from our main script:
.. code:: python
serializer = create_serializer([ UserTaskConverter, BusinessRuleTaskConverter ], custom_data_converter)
We first configure a workflow spec converter that uses our custom task converters, and then we create
a :code:`BpmnWorkflowSerializer` from our workflow spec and data converters.
We'll give the user the option of dumping the workflow at any time.
.. code:: python
filename = input('Enter filename: ')
state = serializer.serialize_json(workflow)
with open(filename, 'w') as dump:
dump.write(state)
We'll ask them for a filename and use the serializer to dump the state to that file.
To restore the workflow:
.. code:: python
if args.restore is not None:
with open(args.restore) as state:
wf = serializer.deserialize_json(state.read())
The workflow serializer is designed to be flexible and modular, and as such is a little complicated. It has
two components:
- a workflow spec converter (which handles workflow and task specs)
- a data converter (which handles workflow and task data).
The default workflow spec converter likely to meet your needs, either on its own, or with the inclusion of
:code:`UserTask` and :code:`BusinessRuleTask` in the :code:`camnuda` or :code:`spiff` and :code:`dmn` subpackages
of this library, and all you'll need to do is add them to the list of task converters, as we did above.
However, the default data converter is very simple, adding only JSON-serializable conversions of :code:`datetime`
and :code:`timedelta` objects (we make these available in our default script engine) and UUIDs. If your
workflow or task data contains objects that are not JSON-serializable, you'll need to extend ours, or extend
its base class to create one of your own.
To extend ours:
1. Subclass the base data converter
2. Register classes along with functions for converting them to and from dictionaries
.. code:: python
from SpiffWorkflow.bpmn.serializer.dictionary import DictionaryConverter
class MyDataConverter(DictionaryConverter):
def __init__(self):
super().__init__()
self.register(MyClass, self.my_class_to_dict, self.my_class_from_dict)
def my_class_to_dict(self, obj):
return obj.__dict__
def my_class_from_dict(self, dct):
return MyClass(**dct)
More information can be found in the class documentation for the
`default converter <https://github.com/sartography/SpiffWorkflow/blob/main/SpiffWorkflow/bpmn/serializer/bpmn_converters.py>`_
and its `base class <https://github.com/sartography/SpiffWorkflow/blob/main/SpiffWorkflow/bpmn/serializer/dictionary.py>`_
.
You can also replace ours entirely with one of your own. If you do so, you'll need to implement `convert` and
`restore` methods. The former should return a JSON-serializable representation of your workflow data; the
latter should recreate your data from the serialization.
If you have written any custom task specs, you'll need to implement task spec converters for those as well.
Task Spec converters are also based on the :code:`DictionaryConverter`. You should be able to use the
`BpmnTaskSpecConverter <https://github.com/sartography/SpiffWorkflow/blob/main/SpiffWorkflow/bpmn/serializer/bpmn_converters.py>`_
as a basis for your custom specs. It provides some methods for extracting attributes from Spiff base classes as well as
standard BPNN attributes from tasks that inherit from :code:`BMPNSpecMixin`.
The `Camunda User Task Converter <https://github.com/sartography/SpiffWorkflow/blob/main/SpiffWorkflow/camunda/serializer/task_spec_converters.py>`_
should provide a simple example of how you might create such a converter.
Migrating Between Serialization Versions
----------------------------------------
Old (Non-Versioned) Serializer
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Prior to Spiff 1.1.7, the serialized output did not contain a version number.
.. code:: python
old_serializer = BpmnSerializer() # the deprecated serializer.
# new serializer, which can be customized as described above.
serializer = BpmnWorkflowSerializer(version="MY_APP_V_1.0")
The new serializer has a :code:`get_version` method that will read the version
back out of the serialized json. If the version isn't found, it will return
:code:`None`, and you can then assume it is using the old style serializer.
.. code:: python
version = serializer.get_version(some_json)
if version == "MY_APP_V_1.0":
workflow = serializer.deserialize_json(some_json)
else:
workflow = old_serializer.deserialize_workflow(some_json, workflow_spec=spec)
If you are not using any custom tasks and do not require custom serialization, then you'll be able to
serialize the workflow in the new format:
.. code:: python
new_json = serializer.serialize_json(workflow)
However, if you use custom tasks or data serialization, you'll also need to specify workflow spec or data
serializers, as in the examples in the previous section, before you'll be able to serialize with the new serializer.
The code would then look more like this:
.. code:: python
from SpiffWorkflow.camunda.serializer import UserTaskConverter
old_serializer = BpmnSerializer() # the deprecated serializer.
# new serializer, with customizations
wf_spec_converter = BpmnWorkflowSerializer.configure_workflow_spec_converter([UserTaskConverter])
data_converter = MyDataConverter
serializer = BpmnWorkflowSerializer(wf_spec_converter, data_converter, version="MY_APP_V_1.0")
version = serializer.get_version(some_json)
if version == "MY_APP_V_1.0":
workflow = serializer.deserialize_json(some_json)
else:
workflow = old_serializer.deserialize_workflow(some_json, workflow_spec=spec)
new_json = serializer.serialize_json(workflow)
Because the serializer is highly customizable, we've made it possible for you to manage your own versions of the
serialization. You can do this by passing a version number into the serializer, which will be embedded in the
json of all workflows. This allows you to modify the serialization and customize it over time, and still manage
the different forms as you make adjustments without leaving people behind.
Versioned Serializer
^^^^^^^^^^^^^^^^^^^^
As we make changes to Spiff, we may change the serialization format. For example, in 1.1.8, we changed
how subprocesses were handled interally in BPMN workflows and updated how they are serialized. If you have
not overridden our version number with one of your own, the serializer will transform the 1.0 format to the
new 1.1 format.
If you've overridden the serializer version, you may need to incorporate our serialization changes with
your own. You can find our conversions in
`version_migrations.py <https://github.com/sartography/SpiffWorkflow/blob/main/SpiffWorkflow/bpmn/serializer/version_migration.py>`_
Custom Script Engines
---------------------
You may need to modify the default script engine, whether because you need to make additional
functionality available to it, or because you might want to restrict its capabilities for
security reasons.
.. warning::
The default script engine does little to no sanitization and uses :code:`eval`
and :code:`exec`! If you have security concerns, you should definitely investigate
replacing the default with your own implementation.
We'll cover a simple extension of custom script engine here. There is also an example of
a similar engine based on `RestrictedPython <https://restrictedpython.readthedocs.io/en/latest/>`_
included alongside this example.
The default script engine does not import any objects.
You could add functions or classes from the standard python modules or any code you've
implemented yourself. Your global environment can be passed in using the `default_globals`
argument when initializing the script engine. In our RestrictedPython example, we use their
`safe_globals` which prevents users from executing some potentially unsafe operations.
In our example models so far, we've been using DMN tables to obtain product information. DMN
tables have a **lot** of uses so we wanted to feature them prominently, but in a simple way.
If a customer was selecting a product, we would surely have information about how the product
could be customized in a database somewhere. We would not hard code product information in
our diagram (although it is much easier to modify the BPMN diagram than to change the code
itself!). Our shipping costs would not be static, but would depend on the size of the order and
where it was being shipped -- maybe we'd query an API provided by our shipper.
SpiffWorkflow is obviously **not** going to know how to make a call to **your** database or
make API calls to **your** vendors. However, you can implement the calls yourself and make them
available as a method that can be used within a script task.
We are not going to actually include a database or API and write code for connecting to and querying
it, but we can model our database with a simple dictionary lookup since we only have 7 products
and just return the same static info for shipping for the purposes of the tutorial.
.. code:: python
from collections import namedtuple
from SpiffWorkflow.bpmn.PythonScriptEngine import PythonScriptEngine
ProductInfo = namedtuple('ProductInfo', ['color', 'size', 'style', 'price'])
INVENTORY = {
'product_a': ProductInfo(False, False, False, 15.00),
'product_b': ProductInfo(False, False, False, 15.00),
'product_c': ProductInfo(True, False, False, 25.00),
'product_d': ProductInfo(True, True, False, 20.00),
'product_e': ProductInfo(True, True, True, 25.00),
'product_f': ProductInfo(True, True, True, 30.00),
'product_g': ProductInfo(False, False, True, 25.00),
}
def lookup_product_info(product_name):
return INVENTORY[product_name]
def lookup_shipping_cost(shipping_method):
return 25.00 if shipping_method == 'Overnight' else 5.00
additions = {
'lookup_product_info': lookup_product_info,
'lookup_shipping_cost': lookup_shipping_cost
}
CustomScriptEngine = PythonScriptEngine(scripting_additions=additions)
We pass the script engine we created to the workflow when we load it.
.. code:: python
return BpmnWorkflow(parser.get_spec(process), script_engine=CustomScriptEngine)
We can use the custom functions in script tasks like any normal function:
.. figure:: figures/custom_script_usage.png
:scale: 30%
:align: center
Workflow with lanes
And we can simplify our 'Call Activity' flows:
.. figure:: figures/call_activity_script_flow.png
:scale: 30%
:align: center
Workflow with lanes
To run this workflow:
.. code-block:: console
./run.py -p order_product -b bpmn/call_activity_script.bpmn bpmn/top_level_script.bpmn
It is also possible to completely replace `exec` and `eval` with something else, or to
execute or evaluate statements in a completely separate environment by subclassing the
:code:`PythonScriptEngine` and overriding `_execute` and `_evaluate`. We have examples of
executing code inside a docker container or in a celery task i this repo.
MultiInstance Notes
-------------------
**loopCardinality** - This variable can be a text representation of a
number - for example '2' or it can be the name of a variable in
task.data that resolves to a text representation of a number.
It can also be a collection such as a list or a dictionary. In the
case that it is a list, the loop cardinality is equal to the length of
the list and in the case of a dictionary, it is equal to the list of
the keys of the dictionary.
If loopCardinality is left blank and the Collection is defined, or if
loopCardinality and Collection are the same collection, then the
MultiInstance will loop over the collection and update each element of
that collection with the new information. In this case, it is assumed
that the incoming collection is a dictionary, currently behavior for
working with a list in this manner is not defined and will raise an error.
**Collection** This is the name of the collection that is created from
the data generated when the task is run. Examples of this would be
form data that is generated from a UserTask or data that is generated
from a script that is run. Currently the collection is built up to be
a dictionary with a numeric key that corresponds to the place in the
loopCardinality. For example, if we set the loopCardinality to be a
list such as ['a','b','c] the resulting collection would be {1:'result
from a',2:'result from b',3:'result from c'} - and this would be true
even if it is a parallel MultiInstance where it was filled out in a
different order.
**Element Variable** This is the variable name for the current
iteration of the MultiInstance. In the case of the loopCardinality
being just a number, this would be 1,2,3, . . . If the
loopCardinality variable is mapped to a collection it would be either
the list value from that position, or it would be the value from the
dictionary where the keys are in sorted order. It is the content of the
element variable that should be updated in the task.data. This content
will then be added to the collection each time the task is completed.
Example:
In a sequential MultiInstance, loop cardinality is ['a','b','c'] and elementVariable is 'myvar'
then in the case of a sequential multiinstance the first call would
have 'myvar':'a' in the first run of the task and 'myvar':'b' in the
second.
Example:
In a Parallel MultiInstance, Loop cardinality is a variable that contains
{'a':'A','b':'B','c':'C'} and elementVariable is 'myvar' - when the multiinstance is ready, there
will be 3 tasks. If we choose the second task, the task.data will
contain 'myvar':'B'.