Моделирование BPMN
Справочник: какие элементы BPMN понимает go-bpm, как их настроить в Camunda Modeler, что из них генерируется и как они исполняются. Принципы исполнения — в Принципах работы, код — в Генераторе.
Чем рисовать#
Camunda Modeler (desktop) или demo.bpmn.io (без
расширений Camunda). При создании файла выбирайте Camunda 8 — go-bpm читает атрибуты zeebe:*: тип service task,
скрипты, решения, вызовы процессов. У user task дополнительно понимаются атрибуты Camunda 7 (camunda:*).
Файлы кладутся в process/ (подпапки допустимы): *.bpmn — процессы, *.dmn — решения. Другие файлы генератор
не читает.
Общие правила#
- Процесс —
bpmn:process. Егоid— имя процесса в API, консоли иStartProcess(OrderApproval),name— название в консоли («Согласование заказа»). В одном файле может быть несколько процессов (пулы). - Id процессов уникальны во всех файлах. Id элементов уникальны внутри процесса.
- Id элементов становятся именами Go-функций:
CheckOrder→CheckOrder,Activity_0shk46q→Activity0shk46q,Gateway_1nptppd→gateway1nptppd. Понятные id делают код читаемым — задавайте их в моделере (поле ID). - Id процесса становится именем пакета:
OrderApproval→order_approval. Нельзя называть процессhandlers,model,services— эти папки заняты. - Переходы — sequence flow с
sourceRefиtargetRef.<bpmn:incoming>/<bpmn:outgoing>внутри узлов (моделер пишет их всегда) необязательны, но если они есть, то должны совпадать с sequence flow — иначе ошибка генерации. Порядок<bpmn:outgoing>у шлюза — порядок проверки условий. - Переменные в выражениях (условия, скрипты, DMN) — поля
DataModel. Путь через точкуclient.address.city— вложенные структуры модели. Если поля нет, генератор остановится и подскажет, какое поле добавить.
Сводная таблица#
| Элемент | Поддержка | Как исполняется |
|---|---|---|
| Start event (none) | ✓ | начало процесса |
| End event (none) | ✓ | конец процесса или подпроцесса |
| Sequence flow, в том числе с условием | ✓ | переход |
| Exclusive gateway | ✓ | одна ветка: условия по порядку, иначе default flow |
| Parallel gateway | ✓ | разветвление во все ветки; слияние ждёт все входящие |
| Task | ✓ | ваш Go-метод, синхронно |
Script task (zeebe:script) | ✓ | сгенерированный Go-код, синхронно |
Service task (zeebe:taskDefinition) | ✓ | ваш обработчик, асинхронно через очередь, с повторами |
Business rule task (zeebe:calledDecision) | ✓ | сгенерированное решение DMN, синхронно |
| User task | ✓ | ждёт человека, API задач |
| Intermediate timer catch event | ✓ | ждёт timeDuration |
| Boundary timer event (прерывающий) | ✓ | у task, scriptTask, serviceTask |
| Boundary error event (прерывающий) | ✓ | у task, scriptTask, serviceTask |
| Embedded sub-process | ✓ | отдельный дочерний экземпляр |
Call activity (zeebe:calledElement) | ✓ | экземпляр другого процесса |
| Всё остальное | ✗ | см. что не поддерживается |
События начала и конца#
Обычные (none) start и end event. У процесса и у подпроцесса ровно один start event. End event может быть несколько: экземпляр завершается на первом, до которого дошёл токен. Start event с таймером, сообщением или сигналом и end event с terminate, error или message — ошибка генерации.
Несведённые параллельные ветки, которые идут к разным end event, завершат экземпляр на первом из них: движок не считает токены. Сводите параллельные ветки слиянием перед концом.
Sequence flow и условия#
Условие задаётся на исходящем переходе exclusive gateway: панель свойств → Condition → Condition expression. Условия на переходах из других элементов (conditional flow из задачи) — ошибка генерации: ставьте перед ветками шлюз. По той же причине задача или событие могут иметь только один исходящий переход — разветвление делается шлюзом.
<bpmn:sequenceFlow id="Flow_big" sourceRef="IsBig" targetRef="Approve">
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=amount > 1000</bpmn:conditionExpression>
</bpmn:sequenceFlow>Условие переводится в Go-выражение над полями модели. Ведущий = (так пишет моделер Camunda 8) отбрасывается.
| В BPMN | В Go | Примечание |
|---|---|---|
amount > 1000 | data.Amount > 1000 | сравнения == != < <= > >= |
amount = 55 | data.Amount == 55 | одиночное = FEEL — равенство |
status == "new" | data.Status == "new" | строки — в двойных кавычках |
approved | data.Approved | bool-поле само по себе условие |
a > 1 and b < 2 | data.A > 1 && data.B < 2 | and, or FEEL или &&, || Go |
not(approved) | !(data.Approved) | |
client.age >= 18 | data.Client.Age >= 18 | путь — вложенные структуры модели |
len(items) > 0 | len(data.Items) > 0 | встроенные функции Go остаются вызовами |
Типы проверяет компилятор: поле int, а в условии 1.5 — ошибка на генерации. FEEL-функции (contains(),
date(), string length()) не переводятся — пишите их Go-эквиваленты или выносите логику в метод.
Exclusive gateway#
- Условия проверяются по порядку исходящих переходов, побеждает первое истинное.
- Default flow — ветка, если ни одно условие не подошло (в моделере: ключ на переходе → «Default flow»). Если default нет и ничего не подошло — ошибка шага, экземпляр упадёт после повторов. Ставьте default всегда.
- Каждый выход шлюза — либо с условием, либо default. Выход без условия, который не default, — ошибка генерации: токен по нему не пошёл бы никогда.
- Генерируется
cond_<id шлюза>.go:
// gatewayApproved возвращает id следующего элемента после Gateway_approved
func gatewayApproved(data *model.DataModel) string {
// в процессе: approved
if data.Approved {
return "Flow_approved"
}
return "Flow_rework"
}Exclusive gateway без условий (один выход или слияние веток) — просто проход.
Parallel gateway#
- Разветвление (один вход, несколько выходов): токен идёт во все ветки; каждая получает копию переменных.
- Слияние (несколько входов): ждёт, пока придут все входящие ветки, объединяет их переменные и идёт дальше. Число ожидаемых веток — число входящих переходов.
- Слияние и разветвление сразу (несколько входов и выходов): дождаться всех веток и разойтись во все выходы.
Если ветки меняют одну и ту же переменную, после слияния останется значение одной из них. Пишите в ветках разные переменные.
Ветки из одних синхронных задач (task, scriptTask) выполняются в одном шаге друг за другом. Настоящий параллелизм —
у веток с service task: обработчики выполняются отдельными задачами очереди, одновременно (до TaskWorkers на
jobType).
Task — ваш метод#
Обычная задача (Task, без типа). Генерируется заготовка method_<id>.go, которую вы дописываете:
// CheckOrder - метод задачи CheckOrder «Проверить заказ»
func CheckOrder(ctx context.Context, svc *services.Services, data *model.DataModel) error {
if data.Amount <= 0 {
return fmt.Errorf("сумма заказа должна быть положительной: %v", data.Amount)
}
data.Checked = true
return nil
}- Выполняется синхронно, внутри шага процесса, в транзакции шага.
data— переменные процесса в вашей модели; изменения полей пишутся обратно в переменные.svc— ваши сервисы (клиенты баз, API), см. Генератор → сервисы.- Ошибка — шаг откатывается и повторяется (до
SystemMaxAttempts, по умолчанию 10). bpm.NewError(code, message)— бизнес-ошибка, её ловит граничное событие ошибки.- Метод может выполниться несколько раз (повтор шага). Подходит для расчётов и чтения. Внешние действия с побочными эффектами (платёж, письмо) делайте в service task.
- Долгий метод занимает воркер системной очереди (их
SystemWorkers, по умолчанию 4) и ограниченAttemptTimeout(30 с). Долгое — в service task.
Script task — код в схеме#
Панель свойств → Implementation: FEEL expression / Script. Два режима.
С resultVariable — выражение, результат пишется в поле модели:
<bpmn:scriptTask id="Greet">
<bpmn:extensionElements>
<zeebe:script expression="=name + "!"" resultVariable="greeting" />
</bpmn:extensionElements>
</bpmn:scriptTask>data.Greeting = data.Name + "!"Здесь каждое имя — поле модели, иначе генератор потребует его добавить.
Без resultVariable — код Go, вставляется в тело метода как есть:
<zeebe:script expression="=fmt.Println("Hey")" />func Activity0shk46q(ctx context.Context, svc *services.Services, data *model.DataModel) error {
fmt.Println("Hey")
return nil
}В коде в поля переводятся только имена, которые есть в модели; пакеты (fmt, strings) импортируются
автоматически. Метод скрипта генерируется — рукописный файл с тем же именем будет ошибкой.
Service task — асинхронный обработчик#
Панель свойств → Task definition: Type (jobType) и Retries.
<bpmn:serviceTask id="Notify" name="Уведомить клиента">
<bpmn:extensionElements>
<zeebe:taskDefinition type="notifyClient" retries="3" />
</bpmn:extensionElements>
</bpmn:serviceTask>Генерируется заготовка обработчика handlers/<job_type>.go:
func NotifyClient(ctx context.Context, svc *services.Services, job bpm.Job, data *model.DataModel) error {
// ...
data.Notified = true
return nil
}Как исполняется:
Токен приходит в задачу — в очередь ставится задача
bpm.task.notifyClient, шаг заканчивается (событиеactivated).Воркер очереди вызывает обработчик. Изменения
dataпишутся в переменные процесса.В той же транзакции, что завершает задачу, токен идёт дальше.
- Retries — число попыток. Нет атрибута или
0— одна попытка. Между попытками — растущая задержка. job:InstanceUid,ProcessName,BusinessKey,ElementId,JobType,LastAttempt(это последняя попытка).Headersвсегда пусты: заголовки задачи (zeebe:taskHeaders) генератор не читает.- Идемпотентность. Обработчик может быть вызван повторно, даже после успеха (упала транзакция завершения).
Ключ идемпотентности —
job.InstanceUid + job.ElementId. - Один jobType — один обработчик во всех процессах сервиса. Какой процесс вызвал —
job.ProcessName. - Ошибки: обычная — повтор;
bpm.NewError— граничное событие ошибки или падение без повторов, если события нет; попытки кончились — граничное событие ошибки без кода илиfailed. Подробнее — граничное событие ошибки. - Сколько задач одного jobType выполняется одновременно —
TaskWorkers(по умолчанию 1) на каждый экземпляр сервиса.
Business rule task — решение DMN#
Панель свойств → Implementation: DMN decision, Decision ID и Result variable.
<bpmn:businessRuleTask id="CalcDiscount" name="Рассчитать скидку">
<bpmn:extensionElements>
<zeebe:calledDecision decisionId="Discount" resultVariable="discount" />
</bpmn:extensionElements>
</bpmn:businessRuleTask>Метод задачи генерируется полностью: вызывает решение, записывает сработавшие правила в историю и кладёт результат
в поле resultVariable. Решение должно лежать в одном из *.dmn в process/. Всё о таблицах — в
Решениях DMN.
User task — задача человека#
Токен останавливается и ждёт, пока задачу выполнят через API или консоль. Настройки — панель свойств → Assignment и Form.
<bpmn:userTask id="Approve" name="Согласовать заявку">
<bpmn:extensionElements>
<zeebe:assignmentDefinition candidateGroups="managers, admins" />
<zeebe:formDefinition formKey="approve-form" />
</bpmn:extensionElements>
</bpmn:userTask>
<bpmn:userTask id="Fix" name="Исправить заявку">
<bpmn:extensionElements>
<zeebe:assignmentDefinition assignee="=initiator" />
</bpmn:extensionElements>
</bpmn:userTask>| Настройка | Camunda 8 (zeebe:) | Camunda 7 (camunda:) |
|---|---|---|
| Исполнитель | assignmentDefinition assignee | атрибут camunda:assignee |
| Кандидаты-пользователи | assignmentDefinition candidateUsers | camunda:candidateUsers |
| Кандидаты-группы | assignmentDefinition candidateGroups | camunda:candidateGroups |
| Форма | formDefinition formKey или formId | camunda:formKey |
Атрибуты Camunda 7 важнее, если заданы оба варианта.
Значения:
- литерал:
managers,alice; - список — через запятую:
managers, admins; - переменная процесса —
=initiator,${initiator}или#{initiator}, путь через точку:=order.owner. Переменная может быть строкой или списком строк (для кандидатов).
Другие выражения (${a + b}, FEEL-списки =["a","b"]) — ошибка генерации. Переменные назначения не обязаны быть
полями DataModel.
Если переменная не вычислилась (её нет, она не строка, у исполнителя несколько значений), значение пропускается
с предупреждением user task assignment skipped в логе, а задача создаётся без него. Без исполнителя и без
кандидатов задачу может взять любой. Так процесс не застревает из-за данных.
formKey движок не интерпретирует: это метка, по которой ваш фронтенд выбирает форму.
Как работать с задачами — HTTP API → задачи и Принципы → задачи пользователей.
Таймер#
Промежуточное событие-таймер (Intermediate catch event → Timer): токен ждёт заданное время.
<bpmn:intermediateCatchEvent id="Wait">
<bpmn:timerEventDefinition>
<bpmn:timeDuration xsi:type="bpmn:tFormalExpression">PT3S</bpmn:timeDuration>
</bpmn:timerEventDefinition>
</bpmn:intermediateCatchEvent>- Только Duration в ISO 8601:
PT30S,PT5M,PT1H30M,P1D,P1DT2H. Годы и месяцы (P1M) не поддерживаются — их длина непостоянна. Формат Go (2s) — ошибка. timeDate(в момент времени) иtimeCycle(повторение) не поддерживаются.- Таймер — отложенная задача очереди: не занимает воркер, переживает перезапуск сервиса. Точность — около секунды (интервал опроса очереди).
Граничные события#
Событие на краю задачи. Поддерживаются прерывающие (cancelActivity по умолчанию true) граничные события
таймера и ошибки у task, scriptTask и serviceTask. Когда событие срабатывает, задача прерывается
(в истории — terminated), токен идёт по исходящему переходу события.
Граничный таймер#
- Service task: обработчик и таймер соревнуются. Кто первый закончил — того и ветка, результат проигравшего отбрасывается. Обработчик, который уже выполняется, не прерывается — его результат просто не используется.
- Task и scriptTask: таймер — это
context.WithTimeoutна метод. Сработает, только если метод слушаетctx(передаёт его в HTTP-клиент, базу,selectнаctx.Done()). Результат метода после дедлайна отбрасывается.
Граничное событие ошибки#
Ловит бизнес-ошибку задачи:
return bpm.NewError("LIMIT_EXCEEDED", "превышен лимит клиента")<bpmn:error id="Error_limit" name="Лимит превышен" errorCode="LIMIT_EXCEEDED" />
...
<bpmn:boundaryEvent id="OnLimit" attachedToRef="Pay">
<bpmn:errorEventDefinition errorRef="Error_limit" />
</bpmn:boundaryEvent>- Событие с
errorRefловит ошибку с этим кодом, событие безerrorRef— любую. - Код и текст ошибки кладутся в переменные
errorCodeиerrorMessage— их видно в истории и можно использовать дальше в процессе (добавьте поля в модель). - Task/scriptTask: пойманная
bpm.Error— переход по событию в том же шаге. Непойманнаяbpm.Errorи обычная ошибка — повтор шага. - Service task: пойманная
bpm.Error— переход по событию. Непойманнаяbpm.Error— задача падает сразу, без повторов. Обычная ошибка повторяется доretries, а после последней попытки её ловит событие без кода, если оно есть.
Подпроцесс#
Встроенный sub-process: группа элементов со своим start и end event внутри процесса. Справа на схеме — call activity (жирная рамка), о нём в следующем разделе.
- Исполняется как отдельный дочерний экземпляр с типом
<процесс>.<id подпроцесса>; в консоли родитель и ребёнок связаны ссылками. - Получает переменные родителя; по окончании переменные возвращаются в родителя.
- Граничные события у подпроцесса и подпроцесс внутри подпроцесса не поддерживаются (ошибка генерации). Глубокую вложенность выносите в отдельный процесс и call activity.
Call activity#
Вызов другого процесса как подпрограммы. Панель свойств → Called element: Process ID, флажок Propagate all child variables.
<bpmn:callActivity id="Activity_1d5xa2z" name="Некий подпроцесс">
<bpmn:extensionElements>
<zeebe:calledElement processId="testSubProcess" propagateAllChildVariables="false" />
</bpmn:extensionElements>
</bpmn:callActivity>- Вызванный процесс — отдельный экземпляр со ссылкой на родителя, наследует бизнес-ключ.
- Получает копию переменных родителя.
propagateAllChildVariables="true"(по умолчанию) — переменные ребёнка возвращаются в родителя;"false"— родитель продолжает со своими прежними переменными.- Вызываемый процесс должен быть в том же сервисе (в
process/). Маппинг входов и выходов (zeebe:ioMapping) не поддерживается.
Что не поддерживается#
Генератор отвергает всё неподдерживаемое с ошибкой, которая называет элемент: процесс, который сгенерировался, не упадёт в рантайме из-за непонятого элемента.
Элементы:
- inclusive gateway, event-based gateway, complex gateway;
- manual task, send task, receive task;
- промежуточные throw events, message и signal catch events, event sub-process;
- подпроцесс внутри подпроцесса, transaction, ad-hoc sub-process;
- multi-instance и циклы (маркер у задачи или подпроцесса);
- несколько start event, start event с таймером, сообщением или сигналом;
- end event с terminate, error, message, escalation.
Настройки поддерживаемых элементов:
- несколько исходящих переходов у задачи или события, условие на переходе не из exclusive gateway;
- выход exclusive gateway без условия, который не default flow;
<bpmn:incoming>/<bpmn:outgoing>, которые не совпадают с sequence flow, и sequence flow к несуществующему элементу;- непрерывающие граничные события (
cancelActivity="false"); - граничные события у подпроцесса, call activity, user task и business rule task;
- граничные события message, signal, escalation, compensation;
- таймеры
timeDateиtimeCycle, длительность не в ISO 8601 или с годами и месяцами; errorRefна несуществующую ошибку;- выражения назначения user task сложнее переменной;
- business rule task с job worker (
zeebe:taskDefinition) или безdecisionId/resultVariable.
Игнорируются без ошибки (расширения внутри extensionElements): zeebe:ioMapping (входы и выходы задач),
zeebe:taskHeaders, execution listeners. Документация, лейны, текстовые аннотации и объекты данных не исполняются и
генерации не мешают.
FEEL-функции в выражениях не переводятся — на них упадёт сборка сгенерированного кода.