Definicja procesu (4) – anulowanie zadań
Dzisiejszy artykuł będzie o sposobach anulowania zadań w procesie – to znaczy o tym, jak spowodować aby jakieś zadanie które jest w trakcie realizacji zostało anulowane po wystąpieniu określonego zdarzenia. Może nie jest to oczywiste na pierwszy rzut oka, ale taka funkcjonalność jest bardzo często potrzebna. Jednym z takich przypadków jest decyzja post factum (deferred choice), omówiona w tym artykule. Deferred choice jest jednak dość szczególnym przypadkiem stosowanym w określonej sytuacji, a w NGinn mamy do dyspozycji trochę więcej konstrukcji z anulowaniem.
Jako przykład na dziś weźmy sobie taki scenariusz ‘biznesowy’:
Do firmy wpływają reklamacje od klientów. Każda reklamacja powinna zostać obsłużona w ciągu 30 dni. Reklamacja jest wciągana do systemu ‘Trouble Ticketing’ i w tym systemie przypisywana do odpowiedniej jednostki organizacyjnej. Chcemy aby system automatycznie przypominał tym ludziom że zbliża się termin na rozpatrzenie reklamacji – na przykład poprzez wysłanie wiadomości email na 10 dni przed terminem rozpatrzenia, potem na 3 dni przed terminem no i w momencie upływu terminu rozpatrzenia – wtedy z kopią do szefa działu obsługi reklamacji. Kiedy reklamacja zostanie rozpatrzona (niezależnie od tego z jakim wynikiem), system powinien zaprzestać wysyłania e-maili z eskalacjami.
Jest to prosty przykład i można go zaimplementować na wiele różnych sposobów – na przykład tak:

Proces reklamacji
W momencie startu tego procesu wykonywane jest zadanie ‘fork’ – nie robi ono nic oprócz uruchomienia czterech równoległych ścieżek w procesie. Pierwsza ścieżka kończy się zadaniem ‘Obsłuż reklamację’ – jest to zlecenie obsługi reklamacji przekazywane do Działu Obsługi Reklamacji. Pozostałe trzy ścieżki odpowiadają za eskalacje. Na każdej z nich jest zadanie ‘Timeout’ odliczające odpowiedni okres czasu (odpowiednio 20, 27 i 30 dni od daty startu), po upływie tego czasu uruchamiane są zadania eskalacyjne wysyłające odpowiedniej treści email.
Bez użycia anulowania ścieżki te wykonują się niezależnie od siebie, czyli eskalacje będą wysyłane nawet gdy zadanie ‘Obsłuż reklamację’ się zakończy. Aby przerwać wysyłanie eskalacji po zakończeniu obsługi reklamacji wprowadzamy przejścia anulujące, oznaczone na rysunku czerwoną przerywaną linią. Każde takie przejście po zakończeniu zadania ‘Obsłuż reklamację’ zabiera wszystkie tokeny z miejsca do którego prowadzi (o ile te tokeny tam są) – w tym przypadku chodzi o miejsca e1, e2 i e3. Zabranie tokenów z miejsc e1, e2 i e3 oznacza że jeśli któreś z zadań ‘Timeout’ jest aktywne to straci ono wymagany token na wejściu i zostanie anulowane. Oczywiście jeśli któryś z Timeoutów już się wykonał to zjadł token ze swojego wejścia, wtedy przejście anulujące nie będzie miało żadnego efektu. Osiągamy w ten sposób swój cel – po zakończeniu zadania ‘Obsłuż reklamację’, jeśli były jakieś oczekujące eskalacje to zostaną anulowane a proces będzie się mógł zakończyć bo już nie będzie tokenów poza miejscem końcowym.
Definicja naszego procesu wygląda tak:
<?xml version="1.0" encoding="windows-1250"?>
<process version="1" name="Reklamacje" xmlns="http://www.nginn.org/WorkflowDefinition.1_0.xsd" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<dataTypes>
</dataTypes>
<variables>
<variable name="v1" type="int" dir="In" required="true" />
</variables>
<body>
<places>
<place id="start" type="Start" />
<place id="end" type="End" />
<place id="e1" />
<place id="e2" />
<place id="e3" />
</places>
<tasks>
<task id="T0" type="Empty" splitType="AND" label="fork">
</task>
<task id="T1" type="Manual" label="Obsluz reklamacje">
<parameters>
</parameters>
</task>
<task id="TE1" type="Timer" label="Timeout - 10 dni">
<parameters>
<param variable="ExpirationDate"><expr>DateTime.Now.AddDays(20)</expr></param>
</parameters>
</task>
<task id="TE2" type="Timer" label="Timeout - 3 dni">
<parameters>
<param variable="ExpirationDate"><expr>DateTime.Now.AddDays(27)</expr></param>
</parameters>
</task>
<task id="TE3" type="Timer" label="Timeout">
<parameters>
<param variable="ExpirationDate"><expr>DateTime.Now.AddDays(30)</expr></param>
</parameters>
</task>
<task id="ESK1" label="Eskalacja 1" type="SendNotification" splitType="AND">
</task>
<task id="ESK2" label="Eskalacja 2" type="SendNotification" splitType="AND">
</task>
<task id="ESK3" label="Eskalacja 3" type="SendNotification" splitType="AND">
</task>
</tasks>
<flows>
<flow from="start" to="T0" />
<flow from="T0" to="T1" />
<flow from="T0" to="e1" />
<flow from="T0" to="e2" />
<flow from="T0" to="e3" />
<flow from="T1" to="e1" cancelling="true" />
<flow from="T1" to="e2" cancelling="true" />
<flow from="T1" to="e3" cancelling="true" />
<flow from="e1" to="TE1" />
<flow from="e2" to="TE2" />
<flow from="e3" to="TE3" />
<flow from="TE1" to="ESK1" />
<flow from="TE2" to="ESK2" />
<flow from="TE3" to="ESK3" />
<flow from="ESK1" to="end" />
<flow from="ESK2" to="end" />
<flow from="ESK3" to="end" />
<flow from="T1" to="end" />
</flows>
</body>
</process>
Przejścia anulujące są zapisane w sekcji ‘flows’, to te z atrybutem cancelling=”true”. Mam nadzieję że ten przykład odpowiednio ilustruje przeznaczenie przejść anulujących.
Dzisiejszy przykład pokazuje jeszcze jedną fajną rzecz. Wyobraźmy sobie że zamiast zadania ‘Obsłuż reklamację’ mamy tam cały podproces obsługi reklamacji – z etapami, przechodzeniem przez różne działy, wysyłaniem pism itp. Widać wtedy, że gdy chcemy do takiego procesu dodać eskalacje możemy to zrobić w ogóle w niego nie ingerując – procedura eskalacyjna jest zupełnie zewnętrzna w stosunku do procesu nią objętego.
Zadania atomowe i blokowe. Składnia definicji procesu (3)

Jakiś czas temu próbowałem krótko wyjaśnić z czego składa się proces w NGinn. Dziś kolejne przybliżenie tego tematu – zajmiemy się konstruowaniem prostego procesu. Najprostszy proces w NGinn to taki który nic nie robi. Ale nawet żeby nic nie robić to trzeba jednak trochę się napracować.
Zaczniemy od tego że każdy proces musi mieć miejsce startowe oraz miejsce końcowe (lub kilka miejsc końcowych). A ponieważ nie można łączyć miejsca z miejscem, to pomiędzy nimi musi znaleźć się jakieś zadanie. Nasz najprostszy proces można podziwiać obok.
Definicja tego procesu to następujący XML:
<?xml version="1.0" encoding="utf-8"?>
<process version="1" name="Proc" xmlns="http://www.nginn.org/WorkflowDefinition.1_0.xsd">
<body>
<places>
<place id="start" type="Start" />
<place id="end" type="End" />
</places>
<tasks>
<task id="T1" type="Empty">
</task>
</tasks>
<flows>
<flow from="start" to="T1" />
<flow from="T1" to="end" />
</flows>
</body>
</process>
Mamy tu sekcję ‘body’ zawierającą kolejno:
- miejsca (sekcja ‘places’)
- zadania (sekcja ‘tasks’)
- połączenia (sekcja ‘flows’)
Ważne jest to, że zarówno miejsca jak i zadania mają atrybut ‘id’ unikalnie identyfikujący miejsce/zadanie w procesie. Teraz rozbudujmy ten przykład:

Przykład 2
Jest to proces zawierający trzy zadania, z czego jedno jest tzw zadaniem blokowym (composite), czyli takim które zawiera wewnątrz podproces. Definicja tego procesu wygląda tak:
<?xml version="1.0" encoding="utf-8"?>
<process version="1" name="Proc" xmlns="http://www.nginn.org/WorkflowDefinition.1_0.xsd">
<body>
<places>
<place id="start" type="Start" />
<place id="end" type="End" />
</places>
<tasks>
<task id="T1" label="30 min" type="Timer">
<parameters>
<param variable="DelayAmount"><expr>TimeSpan.FromSeconds(30)</expr></param>
</parameters>
</task>
<task id="T2" type="Empty">
</task>
<composite id="T3" label="">
<body>
<places>
<place id="T3_start" type="Start" />
<place id="T3_end" type="End" />
</places>
<tasks>
<task id="T31" type="Timer">
<parameters>
<param variable="DelayAmount"><expr>TimeSpan.FromMinutes(1)</expr></param>
</parameters>
</task>
</tasks>
<flows>
<flow from="T3_start" to="T31" />
<flow from="T31" to="T3_end" />
</flows>
</body>
</composite>
</tasks>
<flows>
<flow from="start" to="T1" />
<flow from="T1" to="T2" />
<flow from="T1" to="T3" />
<flow from="T2" to="end" />
<flow from="T3" to="end" />
</flows>
</body>
</process>
W tym procesie występuje kilka istotnych szczegółów. Przede wszystkim, w sekcji ‘tasks’ pojawiło się zadanie ‘composite’ – to jest właśnie zadanie blokowe. Wewnętrzna struktura tego zadania jest taka sama jak procesu, czyli zawiera sekcję ‘body’ a w niej miejsca, zadania i połączenia. Oczywiście dalsze zagnieżdżanie jest możliwe – nie ma ograniczenia na głębokość zagnieżdżenia zadań blokowych. W odróżnieniu od blokowych, zadania nie zawierające innych zadań określamy mianem atomowych (tj niepodzielnych) – są to te definiowane poprzez ‘task’.
No właśnie, druga sprawa to task typu ‘Timer’. Jest to zadanie odliczające podany okres czasu. Dla tego zadania podane jest to w parametrze o nazwie ‘DelayAmount’ a wartość parametru jest w formie wyrażenia wyliczanego przed uruchomieniem zadania. Każdy typ zadania w NGinn posiada określony zestaw parametrów – wszystkie są ustawiane w ten sam sposób.
Teraz pora dołożyć do naszego procesu jakieś dane. Robi się to za pomocą sekcji ‘variables’ – omawianej już wcześniej w tym wpisie. Zatem dołóżmy parę zmiennych do naszego procesu oraz zadań, a potem zdefiniujmy mapowania między zmiennymi procesu a zmiennymi zadań.
<process version="1" name="Proc" xmlns="http://www.nginn.org/WorkflowDefinition.1_0.xsd">
<variables>
<variable name="userName" type="string" dir="In" required="true" />
<variable name="accountId" type="string" dir="Out" required="true" />
</variables>
<body>
<places>
<place id="start" type="Start" />
<place id="end" type="End" />
</places>
<tasks>
<task id="T1" label="30 min" type="Timer">
<parameters>
<param variable="DelayAmount"><expr>TimeSpan.FromSeconds(30)</expr></param>
</parameters>
</task>
<task id="T2" type="Empty">
<variables>
<variable name="v1" type="string" dir="In" required="true" />
<variable name="v2" type="string" dir="Out" required="true" />
</variables>
<inputBindings>
<bind variable="v1"><sourceVar>userName</sourceVar></bind>
</inputBindings>
<outputBindings>
<bind variable="accountId"><expr>data.v2 + '_XYZ'</expr></bind>
</outputBindings>
</task>
Dalszą część definicji obciąłem – jest taka sama jak wyżej. Mamy tu zdefiniowane dwie zmienne procesu – zmienną wejściową userName typu string, oraz zmienną wyjściową accountId też typu string. Kolejny ważny szczegół to zmienne zadania ‘T1′ – ma ono dwie zmienne, wejściową v1 oraz wyjściową v2. Dodatkowo, dla tego zadania są zdefiniowane mapowania pomiędzy jego zmiennymi a zmiennymi procesu. Mapowanie wejściowe (inputBinding) mówi że zmienna v1 otrzyma kopię zmiennej userName z procesu – odbędzie się to przy starcie zadania T1. Po zakończeniu zadania T1 natomiast zmienna v2 (wyjściowa) zostanie przedłużona o napis ‘_XYZ’ i przypisana do zmiennej accountId w procesie – mówi o tym mapowanie danych wyjściowych (outputBinding).
Widzimy zatem jak działa wymiana danych – używamy mapowania danych wejściowych aby ustalić wartości zmiennych wejściowych zadania przy jego starcie, a po zakończeniu zadania wykorzystujemy mapowanie zmiennych wyjściowych na zmienne procesu lub zadania nadrzędnego. To działa rekurencyjnie – jeśli mamy zadanie blokowe to pod-zadania w nim zawarte muszą mapować swoje zmienne na zmienne zadania blokowego, a nie na zmienne procesu – po prostu występuje pełna izolacja danych w zadaniach i nie można sięgać do zmiennych innych zadań. Jedyna dozwolona wymiana danych odbywa się poprzez mapowania i dotyczy wyłącznie zmiennych zadania nadrzędnego lub procesu jeśli działamy bezpośrednio na poziomie procesu.
Kolejnym krokiem powinno być zbadanie jakie rodzaje zadań mamy do dyspozycji w NGinn. Będzie to opisane w kolejnych artykułach, gdyż w tej chwili zestaw zadań w NGinn nie jest jeszcze kompletny. Postaram się też wymyślić jakiś bliższy rzeczywistości przykład procesu do którego stworzenia wykorzystamy nowo poznane zadania.
Definiowanie procesu (2) – rozwidlenia i złączenia
W dzisiejszym odcinku kilka kolejnych elementów języka opisu procesów NGinn – a mianowicie rozwidlenia i złączenia ścieżek w procesie, czyli narzędzia do wprowadzania zawiłości tam gdzie mogło być prosto. Jako źródło przykładów wykorzystamy proces przedstawiony na poniższym schemacie:

Schemat procesu
Od razu zaznaczam że proces tu przedstawiony nie ma żadnego głębszego sensu i nie jest oparty na żadnym wziętym z życia przykładzie (dlatego zadania mają klarowne nic nie mówiące nazwy). Łatwo się zorientować gdzie proces ma początek a gdzie koniec oraz że nie ma żadnych zadań blokowych. Miał być prosty i jest prosty.
Rozwidlenia i złączenia
Rozwidlenie (dalej nazywane “split” na cześć miasta w Chorwacji) to miejsce w którym pojedyncza ścieżka w procesie dzieli się na dwie lub więcej ścieżek. Podział taki następuje na wyjściu z zadania, tzn miejscem rozwidlenia jest zadanie. Mamy 3 podstawowe rodzaje rozwidleń:
- AND
- XOR
- OR
W definicji procesu każde zadanie ma atrybut ’splitType’ mogący przyjąć jedną z powyższych wartości. Domyślnie (gdy go nie ma) przyjmowany jest AND.
Złączenie (dalej nazywane join) to miejsce spotkania się kilku ścieżek w procesie, przy czym chodzi o spotkanie się na wejściu tego samego zadania. I znowu, mamy 3 rodzaje złączeń, dokładnie takie same jak wymienione wyżej rodzaje rozwidleń, czyli AND, XOR i OR. Nie jest to przypadek, gdyż jeśli zaczniemy konstruować proces okaże się że najczęściej do rozwidlenia typu AND pasuje złączenie typu AND, to samo dla typów XOR i OR.
Uwaga: mamy jeszcze jeden typ rozwidlenia – specjalny przypadek nazywany ‘deferred choice‘, który zasłużył sobie na osobny paragraf niżej.
XOR – czyli punkt decyzyjny
Rozwidlenie typu ‘XOR’ oznacza wybór dokładnie jednego z kilku wariantów. Na rysunku oznaczane jest pustym w środku rombem.

XOR-Split
Mamy tutaj rozwidlenie na zadaniu T1 rozdzielające nam ścieżkę na 3 warianty prowadzące do zadań T1_1, T1_2 i T1_4. Podczas wykonania procesu, w zależności od spełnienia określonych warunków, zostanie wybrana dokładnie jedna ze ścieżek i proces będzie kontynuowany w zadaniu T1_1 albo T1_2, albo T1_4. Dla programistów: jest to taka konstrukcja ’switch’ albo seria if – else if – end.
Warunki do spełnienia (nazywane warunkami wejściowymi – Input Condition) są przypisywane do każdej ze ścieżek, z wyjątkiem ostatniej – ona nie musi mieć warunku gdyż jest ścieżką domyślną, wybieraną wtedy gdy żadna inna nie zostanie wybrana. A jak stwierdzić która ścieżka jest pierwsza a która ostatnia? Widać to dopiero w definicji procesu – zgodnie z porządkiem w którym są umieszczone przejścia w definicji (można też jawnie podać porządek) – za chwilę to sobie zobaczymy.
Należy pamiętać o jednej rzeczy – w sieciach Petriego nie można łączyć zadania z zadaniem bezpośrednio. Po drodze musi być miejsce (place). Tutaj te miejsca też są, tylko że niewidoczne. Można przyjąć że w połowie każdej z trzech widocznych na rysunku strzałek jest place, czyli tak naprawdę pojedyncze przejście ma dwa etapy. Ma to znaczenie gdy obserwuje się XOR-split w działaniu. Otóż zadziałanie jednego z przejść XOR-splitu powoduje umieszczenie tokenu w miejscu do którego to przejście prowadzi. Dla pozostałych wariantów token nie będzie wyprodukowany. A oto jak wygląda definicja naszego XOR-splita:
<flow from="T1" to="T1_1" label="Yes"> <inputCondition>data.answer == 'Yes'</inputCondition> </flow> <flow from="T1" to="T1_2" label="No"> <inputCondition>data.answer == 'Yes'</inputCondition> </flow> <flow from="T1" to="T1_4" label="Not sure"/>
Widzimy trzy strzałki (flow), dwie z nich zawierają inputCondition – czyli wyrażenie które będzie sprawdzane w momencie napotkania XOR-split. Sprawdzane będą w kolejności występowania i pierwszy spełniony warunek wygeneruje token na wyjściu. Gdy żaden nie będzie spełniony token pojawi się w ostatnim przejściu, czyli tym oznaczonym ‘Not sure’.
A teraz zbadajmy złączenie typu XOR, czyli XOR-join

XOR-join
Mamy tutaj złączenie trzech ścieżek w jedną, prowadzącą do zadania T1_E. Złączenie typu XOR oznaczane jest pustym w środku rombem z wchodzącą strzałką. Ta konstrukcja oznacza, że w momencie pojawienia się tokenu na którejkolwiek ze ścieżek wchodzących zadanie T1_E zostanie uruchomione.
No dobra, można zapytać, a co jeśli token pojawi się na dwóch lub wszystkich trzech ścieżkach jednocześnie?
Po pierwsze, jeśli podziału dokonaliśmy za pomocą XOR-split, to token pojawi się na dokładnie jednej ścieżce i problemu nie ma. No ale jeśli podział był robiony np AND-splitem, to mamy tokeny na wszystkich ścieżkach. W takiej sytuacji XOR-join spowoduje uruchomienie zadania T1_E trzykrotnie, za każdym razem wykonanie tego zadania będzie zjadać jeden token z wejścia aż wyczerpią się wszystkie tokeny. Zwykle takie zachowanie nie jest prawidłowe dlatego należy zwracać uwagę aby XOR-join miał zawsze dokładnie jeden token na wejściu.
AND – ścieżki równoległe
Rozwidlenie typu ‘AND’ powoduje wykonanie wszystkich ścieżek które się za nim znajdują, przy czym są one wykonywane równolegle, niezależnie od siebie.

AND-split
Na rysunku z prawej jest narysowany AND-split prowadzący z zadania T0 do zadań T1 i T2. Nie ma on żadnego dodatkowego symbolu na strzałce i jest to domyślny rodzaj rozgałęzienia w NGinn (czyli jeśli nie podamy w definicji procesu rodzaju rozgałęzienia to zostanie przyjęty AND).
W tym przypadku zakończenie realizacji zadania T0 powoduje wygenerowanie tokenów na wszystkich wychodzących z niego ścieżkach, co oznacza że zadania T1 i T2 będą mogły zostać uruchomione. Od tej pory przebieg procesu ma dwie niezależne odnogi. Odnogi te pozostaną niezależne do momentu ich złączenia za pomocą AND-join, widocznego na rysunku:

AND-join
Tutaj zadania T1_E i T2_E należące do równoległych ścieżek łączą się na wejściu do zadania T0_E. Taki zapis oznacza że zadanie T0_E nie może się rozpocząć do momentu zakończenia zarówno zadania T1_E jak i T2_E. Jeśli zakończy się tylko jedno z tych zadań, system zaczeka na zakończenie drugiego i dopiero wtedy uruchomi zadanie T0_E. Zasada działania AND-join jest taka jak w sieci Petriego – zadanie może zostać uruchomione dopiero wtedy, gdy we wszystkich jego miejscach wejściowych znajduje się jakiś token.
Po zakończeniu zadania T0_E z każdego z jego miejsc wejściowych zostanie zjedzony jeden token, w tym przypadku będą to dwa tokeny. Jeśli teraz na wyjściu z T0_E pojawi się jeden token, to dokonaliśmy scalenia dwóch ścieżek z powrotem w jedną.
OR – wybór wielokrotny
Rozgałęzienie typu ‘OR’ jest wersją pośrednią między AND i XOR. Oznacza ono że może zostać wybrane kilka wariantów spośród wszystkich dostępnych.

OR-split
Mamy z prawej OR-split na zadaniu T2 i trzy ścieżki wyjściowe oznaczone ‘Pretty’, ‘Loud’ i ‘Tall’. OR-split jest oznaczany rombem z przekątnymi. Każda ze ścieżek w OR-split ma zdefiniowany warunek (input condition), przy czym, w odróżnieniu od XOR, ostatnia ścieżka również zawiera warunek.
Efekt wykonania OR-split jest taki, ze token pojawia się na każdej ze ścieżek wyjściowych dla której spełniony jest warunek. System wymaga aby zawsze przynajmniej dla jednej ścieżki warunek był spełniony, jeśli żaden nie zostanie spełniony w procesie zostanie zgłoszony błąd. Jeśli wybrane zostanie kilka ścieżek będą one wykonywane równolegle.
Choć OR-split wydaje się bardzo fajnym, uniwersalnym narzędziem łączącym to co najlepsze w AND i XOR to powinien być stosowany rozważnie, tylko tam gdzie to niezbędne. Jest tak dlatego że komplikuje on logikę procesu i utrudnia wykrycie ewentualnych błędów. Ponieważ nie wiadomo ile ścieżek wyjściowych się uaktywni po OR-join, nie można tych ścieżek łączyć za pomocą AND-join. XOR – join też jest niewskazany po OR-split bo jego działanie jest nieoczekiwane dla kilku tokenów na wejściu. Czyli zostaje nam OR-join.

OR-join
OR-join jest oznaczany na schemacie rombem z przekątnymi umieszczonym na końcu strzałki. Jest to bardzo ciekawy przypadek złączenia, mówi on mniej-więcej: Poczekaj na wszystkie tokeny które mogą się pojawić na wejściu do zadania T2_E i dopiero wtedy uruchom zadanie T2_E. Czyli NGinn musi się jakoś domyślić ile tokenów może się pojawić na wejściu T2_E i czy są to już wszystkie tokeny które mogły tam dotrzeć, czy też należy czekać na nadejście kolejnych. Ponieważ tokeny mogą przychodzić na wejście OR-join z dowolnych miejsc w procesie (tzn z takich miejsc z których istnieje droga) to NGinn realizując OR-join musi brać pod uwagę nie tylko tokeny na wejściu do zadania T2_E, ale również stan pozostałych tokenów w sieci i jeśli stwierdzi że albo w sieci nie ma już więcej tokenów, albo te co są nie mają szans dotrzeć na wejście T2_E, dopiero wtedy uruchomi T2_E.
Uwaga: w obecnej wersji systemu użycie OR-join wymaga również podania listy miejsc z których tokeny mogą dotrzeć do OR-join. W przyszłości system automatycznie wyznaczy sobie taką listę.
Deferred choice – metoda faktów dokonanych
Czasami nie jesteśmy w stanie na etapie projektowania procesu powiedzieć która ścieżka powinna zostać wybrana. Może być tak wtedy, gdy nie znamy kryteriów według których określona ścieżka powinna zostać wybrana, lub gdy wybór jest dyktowany czynnikiem zewnętrznym w stosunku do naszego procesu. Mam nadzieję że zaraz to wyjaśnię.

Deferred choice
Mamy tu miejsce p0, z którego wychodzą dwa zadania – T_V1 i T_V2. Każde z tych zadań do uruchomienia wymaga jednego tokenu – tego który znajduje się w p0. Czyli oba zadania mogą być uruchomione jeśli tylko token tam się pojawi. Natomiast tylko jedno z zadań może się zakończyć – zakończenie tego zadania oznacza skonsumowanie tokenu z p0, a wtedy drugie zadanie straci możliwość wykonania. NGinn w takim przypadku anuluje to zadanie.
Jakie jest praktyczne zastosowanie deferred choice? Po pierwsze możemy zaoferować kilka ścieżek do wyboru – wtedy prezentowane są wszystkie możliwości, a wybór jednej z nich powoduje anulowanie pozostałych. Nie wnikamy w to jakie są kryteria wyboru określonej ścieżki.
Drugim typowym przypadkiem jest implementacja ograniczenia czasowego (timeout) na wykonanie określonego zadania. Weźmy przypadek pisma do urzędu. Urzędnik ma powiedzmy 30 dni na odpowiedź na to pismo. Po upływie 30 dni chcemy uruchomić jakąś procedurę eskalacyjną. W naszym przykładzie niech T_V1 będzie zadaniem ‘Odpowiedz na pismo obywatela X’, zaś T_V2 – zegarem (zadanie typu ‘timer’) odliczającym 30 dni. W momencie wejścia tokenu do p0 startują oba zadania: urzędnik dostaje zlecenie obsłużenia pisma, zaś timer zaczyna odliczanie 30 dni. Jeśli urzędnik zdąży zakończyć zadanie przed upływem 30 dni, w momencie zakończenia zadania timer zostaje anulowany. Jeśli nie zdąży to zakończy się zadanie z zegarem i zostanie anulowane jego zlecenie. Inaczej niż na tym rysunku, za zadaniem T_V2 (timer) możemy umieścić procedurę eskalacji – np wysłanie powiadomienia do kierownika jednostki albo zlecenie wykonania telefonu do klienta z informacją ze jego pismo jest nieważne i musi zostać złożone ponownie
Pętla

Pętla - XOR
Oto przykład realizacji pętli za pomocą XOR-split oraz XOR-join. Startujemy w zadaniu T1_2, po jego wykonaniu mamy XOR-split, prowadzący do zadania T1_3 oraz T1_E. Z zadania T1_3 wracamy do zadania T1_2. W ten sposób stworzyliśmy pętlę. Warunek wyjścia z pętli umieszczamy w wyjściu z zadania T2. Zwykle zadanie T2 jest tylko punktem decyzyjnym, zaś właściwe ‘ciało’ pętli to zadanie T1_3. Można oczywiście jeszcze krócej i zwięźlej – wyjście z zadania T1_2 można skierować bezpośrednio do tego samego zadania, wtedy T1_2 będzie ciałem pętli a po jego zakończeniu będzie sprawdzany warunek wyjścia z pętli – czyli znajoma pętla repeat…until.
Uwaga: zastosowanie AND-split w pętli stworzy nam generator tokenów – czasami może być przydatny, ale zwykle to błąd.
Na tym przykładzie zakończymy temat podstawowych struktur sterujących w NGinn. Do omówienia zostały bardziej skomplikowane konstrukcje, czyli obsługa błędów, anulowanie zadań, zadania blokowe i podprocesy. Wszystko to w kolejnych odcinkach.
Definicja procesu – podstawowe komponenty (1)
Definicja procesu w NGinn jest oparta na sieci Petriego – tutaj można trochę o niej poczytać. Zatem mamy trzy podstawowe elementy konstrukcyjne sieci Petriego:
- miejsca (place) – na diagramie przedstawione jako kółeczka
W procesach rozróżniamy trzy podstawowe rodzaje miejsc: miejsce startowe (w którym proces bądź podproces się zaczyna), miejsce pośrednie (czyli takie wewnątrz procesu) oraz miejsce końcowe w którym proces się kończy. Proces może mieć tylko jedno miejsce startowe i dowolną ilość miejsc pośrednich i końcowych. Miejsca końcowe nie mogą mieć połączeń wychodzących.
Podczas wykonania procesu miejsca przechowują tokeny (żetony) – czyli takie znaczniki wędrujące przez nasz model procesu pokazujące w którym miejscu proces się znajduje, czyli jaki jest jego aktualny status. W momencie wykonania zadania tokeny z jego miejsc wejściowych są zabierane, a pojawiają się nowe tokeny w miejscach docelowych tego zadania. - tranzycje – w naszej nomenklaturze znane jako zadania (task) – na diagramie rysowane w formie prostokątów:
Zadania są podstawowym budulcem procesu – odpowiadają one za wykonanie określonych akcji. W NGinn występuje kilkanaście rodzajów zadań, odpowiedzialnych np za: wysyłkę i odbieranie powiadomień, komunikację z aplikacjami zewnętrznymi, zlecanie zadań użytkownikom, odliczanie czasu itd. Dokładniejsze omówienie zadań pojawi się w kolejnych odcinkach.
Zadanie może zostać uruchomione wyłącznie jeśli posiada odpowiednią liczbę tokenów na wejściu (czyli w miejscach z których prowadzą strzałki do tego zadania). Jeśli zadanie się wykona, zjada tokeny wejściowe i produkuje tokeny na wyjściu (czyli w miejscach do których prowadzą strzałki z tego zadania). Liczba wymaganych tokenów na wejściu oraz tych produkowanych na wyjściu zależą od rodzaju synchronizacji (join) lub rozwidlenia (split) skonfigurowanego dla tego zadania (to też będzie dokładniej omówione w kolejnych odcinkach). W oryginalnej definicji sieci Petriego strzałki mogą łączyć wyłącznie miejsce z zadaniem (czyli na jednym końcu musi być miejsce a na drugim zadanie). W NGinn dozwolone są również połączenia zadanie – zadanie, w takim przypadku aby zachować ‘kompatybilność’ z sieciami Petriego system wstawia pomiędzy tak połączone zadania niejawne miejsce (nie jest ono rysowane na diagramach). - krawędzie – u nas znane jako połączenia albo przejścia albo strzałki (flow). Na diagramie występują w formie strzałek łączących miejsca i zadania:

Połączenia określają nam zależności między zadaniami w procesie. Jeśli połączenia prowadzą od miejsca do zadania, są to tzw połączenia wejściowe zadania. Określają one w których miejscach muszą pojawić się żetony aby zadanie mogło zostać uruchomione. Połączenia prowadzące od zadania do miejsca albo do innego zadania to połączenia wyjściowe – określają one miejsca w których zostaną wyprodukowane tokeny po zakończeniu zadania. Czyli połączenia definiują nam możliwe ścieżki wędrówki tokenów od miejsca startowego aż do miejsc końcowych.
W kolejnych odcinkach przyjrzymy się sposobom łączenia miejsc i zadań tak, aby uzyskać pożądane przebiegi procesów.
Obsługa danych w NGinn
Można powiedzieć że definicja procesu składa się z dwóch części. Pierwszą częścią jest jego logika, czyli zadania wchodzące w skład procesu i powiązania między nimi. Drugą częścią są dane – czyli proces = logika + dane. Jak wygląda obsługa danych w NGinn?
Zacznijmy od podstaw. Co to są te ‘dane’? W naszym przypadku są to informacje na których operuje proces i zadania wchodzące w jego skład. Aby uruchomić jakikolwiek proces w NGinn należy przekazać mu odpowiednie dane wejściowe. Następnie proces się wykonuje, przetwarza te dane i po zakończeniu może nam zwrócić rezultaty, czyli dane wyjściowe. Czyli w ‘życiu’ procesu są dwa momenty wymiany danych – start i zakończenie – i nie interesuje nas co z danymi dzieje się pomiędzy nimi.
A co z zadaniami? Aby zachować przejrzystość projektu, z zadaniami jest dokładnie tak samo. Aby uruchomić zadanie należy mu przekazać dane wejściowe, po zakończeniu zwróci nam dane wyjściowe. Praktyczny efekt jest taki, że interfejs wymiany danych z zadaniami i z procesami jest dokładnie taki sam dla wszystkich rodzajów zadań i z naszego punktu widzenia zadania w procesie można dowolnie podmieniać o ile mają taką samą strukturę danych we/wy. Tak samo w miejsce zadania można wstawić inny proces. Czyli można powiedzieć że wyłącznie struktura danych wejściowych i wyjściowych określa nam interfejsy między zadaniami.
Są też dalsze implikacje przyjętej struktury. Skoro dane wymieniamy tylko przy starcie i zakończeniu zadania, to znaczy że zadanie do swojego działania nie może wymagać innych danych niż te które otrzymało na wejściu. Po prostu nie ma innej możliwości otrzymania danych i może korzystać wyłącznie ze swoich danych wejściowych. Nie muszę chyba dodawać co to oznacza: nie ma żadnego współdzielenia danych między zadaniami – każde z nich ma swoje własne dane i nie może wpływać na dane w innych zadaniach ani w nadrzędnym procesie.
A jak to wygląda praktycznie?
Przede wszystkim, dane reprezentujemy w postaci zmiennych. Czyli struktura danych dla zadania lub procesu to zestaw zmiennych.
Każda zmienna ma przypisany kierunek wymiany danych: są zmienne wejściowe (In), wyjściowe (Out), dwukierunkowe (InOut) oraz lokalne (Local). Zmienne lokalne nie są brane pod uwagę przy wymianie danych ze światem zewnętrznym, ale spełniają tę samą funkcję co zmienne lokalne w klasycznym programowaniu – tymczasowo przechowują dane.
Oprócz kierunku zmienna ma nazwę (unikalną w ramach zadania/procesu do którego należy) oraz typ.
Wśród typów danych mamy proste typy wbudowane (tzn takie których nie trzeba definiować):
- string
- int
- bool
- DateTime
- double
oraz typy złożone, definiowane na potrzeby konkretnych procesów:
- typy wyliczeniowe (enum) – możemy tworzyć własne, nazwane enumeracje
- struktury (rekordy), czyli zestawy pól typu nazwa->wartość – odpowiednik ’struct’ w językach programowania. Pola w rekordzie mogą być dowolnego typu spośród opisanych tutaj, w szczególności mogą być rekordem – czyli definicje typów mogą być rekurencyjne
No dobra, to czego nam jeszcze brakuje do szczęścia? Tablic, brakuje nam zmiennych mogących przyjmować wiele wartości tego samego typu. W NGinn jest to rozwiązane tak, że każdą zmienną możemy oznaczyć jako typ tablicowy – czyli mając zmienną int możemy powiedzieć że to tablica, wtedy przyjmuje ona wiele wartości typu int.
Ok, to chyba czas pokazać jak wygląda to od strony języka opisu procesu:
<variables> <variable name="UserName" type="string" dir="In" required="true" isArray="false" /> <variable name="BirthYear" type="int" dir="In" required="true" isArray="false" /> <variable name="PhoneNumbers" type="string" dir="In" required="true" isArray="true" /> <variable name="RegistrationId" type="string" dir="Out" /> </variables>
Sekcja ‘variables’ występuje w definicji procesu oraz w definicji zadania, w obu przypadkach ma dokładnie tę samą postać pokazaną wyżej. Mam nadzieję że nie ma potrzeby opisywania co znaczy ten xml – wszystko powinno być jasne po przeczynaniu tekstu powyżej.
No dobra, to wiemy jak zdefiniować zmienne w procesie oraz w zadaniu. W kolejnych postach dowiemy się jak zrobić, żeby zmienne z procesu trafiły do zadania, a po zakończeniu zadania aby wróciły do procesu i zaktualizowały jego dane. Będzie też artykuł o definiowaniu własnych typów danych. A potem jeszcze mnóstwo innych informacji.
Script.Net czy Boo?
Jakiś czas temu do ‘oskryptowywania’ procesów zacząłem używać języka Script.Net którego autorem jest ukraiński programista – Petro Protsyk. Script.Net jest prostym w użyciu skryptem szczególnie nadającym się do wbudowywania w aplikacje, tylko że wersje na których pracowałem ciągle pozostawiały wiele do życzenia jeśli chodzi o jakość. Ilość błędów przekreślała produkcyjne użycie. Dlatego zacząłem patrzeć na język Boo – to już nie jest skrypt tylko język kompilowany, jednak pozwalający na łatwą dynamiczną kompilację i posiadający bardzo przyjazną skryptową składnię. Za pomocą Boo zrobiłem prosty moduł reguł biznesowych oraz view engine dla ASP.Net MVC pozwalającą generować dynamiczny JSON i generalnie jestem wielkim zwolennikiem tego języka, więc przyjąłem że w 2.0 Nginn Boo będzie używany również do programowania procesów. Implementacja tego jest jednak trudna, a w dodatku Petro Protsyk ostatnio wypuścił nową wersję Script.Net więc widać że chłopak się zajmuje swoim projektem i zmierza do wersji stabilnej – w rezultacie mam dylemat który język wybrać. Jak to bywa w informatyce, gdy nie wiadomo które rozwiązanie wybrać najczęściej wybiera się oba na raz i robi się system pluginów pozwalający użyć albo jednego albo drugiego, ale ja chciałbym uniknąć wykonywania podwójnej roboty. Na razie potestuję więc tego Script.Neta.
NGinn 2.0 – kilka szczegółów
Od czasu ostatnich wieści na temat NGinn upłynęło trochę czasu. Wieści nie było głównie dlatego że nie miałem czasu zajmować się blogiem. Ale zajmowałem się NGinn, szczególnie koncepcją drugiej wersji języka i silnika BPM. Tak, powstaje druga wersja NGinn mimo tego że pierwsza nie miała żadnego wydania – uznałem że nie ma sensu kontynuować NGinn v1.0 z uwagi na potrzebę silnego przerabiania frameworku w celu doimplementowania nowych funkcji które w międzyczasie przyszły mi do głowy. Dlatego na bazie NGinn 1.0 jeszcze raz zdefiniowałem język i pracuję nad nową wersją silnika NGinn v 2.0. Dziś kilka informacji o tym co będzie w wersji 2.
Po pierwsze, nowy diagram, żeby było o czym dyskutować:

Różnica jest już w wyglądzie schematu, bo używam nowego narzędzia do diagramowania – jest to pakiet ‘aiSee’ firmy AbsInt. Generuje fajne grafy i mimo tego że pakiet zawiera trochę bugów i czasami jest narowisty, to i tak uważam że jest lepszy niż darmowy graphviz (w sumie aiSee też jest darmowy dla niekomercyjnych zastosowań). No, ale nowy generator obrazków to żaden przełom, więc idźmy dalej
Dość istotnym rozszerzeniem w stosunku do v1.0 jest dodanie podzadań złożonych (composite task), czyli takich podprocesów w wersji inline. Na obrazku jest to zadanie w przerywanej prostokątnej ramce, w środku zawiera taki mniejszy proces – i o to w sumie chodzi. Do czego jest nam potrzebne takie zadanie – otóż można bez niego żyć, ale co to za życie… Głównie chodzi o obsługę błędów – dla takiego zadania możemy globalnie obsłużyć błędy, podobnie jak w strukturalnych językach używając bloku try-catch. Zadanie złożone to taki wielki blok ‘try’, zaś doczepione do niego przejście z dwiema czerwonymi kreseczkami (double slash) reprezentuje blok ‘catch’ – czyli handler błędu. Działa to tak, że jeśli w trakcie realizacji podprocesu z zadania złożonego wystąpi jakiś błąd, to podproces ten zostanie zakończony i zostanie uruchomiona obsługa błędu.
Ważna rzecz- zadania złożone można dowolnie zagnieżdżać – czyli możemy mieć wielopoziomową obsługę błędów zupełnie jak w zagnieżdżonych blokach try-catch. Ale nie tylko do obsługi błędów przydają się te zadania. Możemy na przykład bardzo uprościć strukturę grafu jeśli opisujemy ‘przejścia anulujące’ – czyli połączenia które powodują usuwanie tokenów z procesu. Na rysunku jest to czerwona kropkowana strzałeczka z zadania T3 do miejsca po_T4. Mówi ona ‘po zakończeniu T3 usuń wszystkie tokeny z miejsca po_T4 i anuluj wszystkie zadania które wymagają tokenu w miejsciu po_T4′. Używając zadania blokowego możemy anulować całą podgrupę zadań jednym połączeniem anulującym zamiast prowadzić takie połączenie do każdego zadania z grupy indywidualnie.
Kolejna rzecz to obsługa kompensacji – podobnie jak w ‘profesjonalnych’ (tych droższych
komercyjnych systemach BPM-owych. Kompensacje to zadania wykonywane w celu anulowania skutków wykonania innych zadań. Będą zrealizowane podobnie jak obsługa błędów, ale to dopiero w NGInn v2.5
Jest jeszcze parę zastosowań zadań blokowych, ale o tym innym razem.
Co jeszcze? Możliwość kończenia procesu w wielu miejscach końcowych ( w NGinn 1.0 musiało być pojedyncze miejsce końcowe). Dochodzą też nowe zadania – ale o tym w kolejnych artykułach.
Jeśli chodzi o implementację silnika NGinn to w v 2.0 kompletnie zmienił się jego projekt. W 1.0 proces był pojedynczym ‘dokumentem’ scalającym w sobie stan wszystkich zadań, dane i historię. W v 2.0 postanowiłem maksymalnie ‘usamodzielnić’ zadania. Każde zadanie jest niezależnym bytem i może istnieć w oderwaniu od pozostałych. Oddzielnie przechowuje się jego stan – czyli stan procesu nie jest już jednym dokumentem a zestawem wielu dokumentów. Za koordynację takich samodzielnych zadań odpowiadają zadania blokowe – czyli te o których mowa była wyżej. Tak naprawdę to proces jest teraz takim dużym zadaniem blokowym, które może zawierać albo inne zadania blokowe, albo zadania atomowe (czyli te które faktycznie coś robią) – i tak dalej – dowolnie można to zagnieżdżać. Dlaczego taka decyzja – otóż autonomiczna obsługa zadań zwiększa nam stopień współbieżności w systemie, można jednocześnie aktualizować stan wielu zadań w tym samym procesie, łatwiej również zapanować nad rozmiarem danych opisujących zadania. Szybciej działa zapis i odczyt stanu pojedynczego zadania niż całego procesu – a zwykle interesuje nas dostęp do pojedynczego zadania i lepiej gdy nie musimy wczytywać w tym celu stanu całego procesu.
Witaj świecie!
Witaj w WordPress. To jest twój pierwszy wpis. Zmień go lub usuń, a potem zacznij blogowanie!