Генератор кода
go-bpm-gen читает BPMN и DMN из process/ и пишет Go-код, который исполняет движок. Часть файлов генератор
пересоздаёт при каждом запуске, часть — заготовки, которые создаются один раз и дальше принадлежат вам.
Установка и запуск#
Генератор — команда того же модуля, что и движок. Удобнее всего подключить его как tool:
go get -tool github.com/nicolas-vp/go-bpm/cmd/go-bpm-genи вызывать через go generate:
// internal/gen/generate.go (пакет может быть любым)
package gen
//go:generate go tool go-bpm-gengo generate ./internal/gen # или go generate ./... — сгенерирует всё в модулеМожно и без директивы: go tool go-bpm-gen из любой папки модуля.
Флаги#
| Флаг | По умолчанию | Что делает |
|---|---|---|
-process | <корень модуля>/process | папка с BPMN и DMN |
-output | <корень модуля>/internal/bpmgen | папка сгенерированного кода, внутри Go-модуля |
-check | true | после генерации собрать код (go build) |
Корень модуля — ближайшая папка с go.mod вверх от текущей. Пути по умолчанию считаются от него, поэтому
генератор работает из любой папки модуля, в том числе из go:generate, который запускает команду в папке пакета.
Явно заданные -process/-output считаются от текущей папки. Так сделано в демо:
// examples/demo/generate.go
//go:generate go run github.com/nicolas-vp/go-bpm/cmd/go-bpm-gen -process process -output bpmgenЧто происходит при запуске#
- Читаются все
*.bpmnи*.dmnиз-process(рекурсивно). - Удаляются сгенерированные файлы прошлого запуска — с заголовком
// Code generated by go-bpm-gen. DO NOT EDIT.. Поэтому не остаются условия и скрипты, которых больше нет в BPMN. - Каждый процесс проверяется: неподдерживаемые элементы, ветвления без шлюза, переходы без условий — ошибка
генерации, а не сбой экземпляра в рантайме. Переходы без
<bpmn:incoming>/<bpmn:outgoing>восстанавливаются по sequence flow. - Пишется код процессов, условий, скриптов и решений; создаются недостающие заготовки.
- Проверяется, что все переменные из выражений есть в модели данных. Если нет — генерация останавливается со списком полей, которые нужно добавить.
- С
-checkсобирается папка вывода: ошибки в скриптах, условиях, рукописных методах и обработчиках видны сразу.
Генерируйте в целевом проекте, не копируйте сгенерированный код из другого: путь импорта генератор берёт из go.mod
проекта, в который пишет.
Раскладка#
process/
order.bpmn, pricing.dmn ваши процессы и решения (подпапки допустимы)
bpmn-gen.go ⚙ вшивает BPMN и DMN через go:embed
internal/bpmgen/
bpm-gen.go ⚙ Processes(svc), Decisions
queue-gen.go ⚙ Queues(svc): jobType всех процессов и их обработчики
model/data_model.go ✎ DataModel — переменные процессов
services/services.go ✎ Services — зависимости методов и обработчиков
handlers/<job_type>.go ✎ обработчик service task, один на jobType
<процесс>/ пакет процесса, имя — id процесса в snake_case
flow-gen.go ⚙ Deployment(svc): схема процесса и его подпроцессов
cond_<id шлюза>.go ⚙ условие exclusive gateway
method_<id задачи>.go ✎ task — ваш метод
⚙ scriptTask, businessRuleTask — генерируется
decision_<id решения>.go ⚙ решение DMN, которое вызывает процесс⚙ — пересоздаётся при каждом запуске, править нельзя. ✎ — создаётся, только если файла нет; дальше это ваш код, генератор его не трогает.
model/иservices/создаются, только если в папке нет ни одного.go— их можно разнести по нескольким файлам.- Заготовки
handlers/иmethod_*.goсоздаются для новых jobType и задач. Если удалить пустую заготовку, генератор создаст новую. - Файлы с заголовком от старого генератора (
pg-bpm-gen) считаются рукописными.
Что экспортирует сгенерированный код#
| Символ | Для чего |
|---|---|
bpmgen.Processes(svc) map[string]bpm.Process | все процессы: схема и BPMN — в engine.New и engine.NewHistoryReader |
bpmgen.Queues(svc) []bpm.Queue | jobType с числом попыток и обработчиком — в engine.New |
bpmgen.Decisions map[string]string | XML DMN по id решения — в engine.NewHistoryReader, для показа таблиц в консоли |
Модель данных#
model.DataModel — переменные процессов в виде Go-структуры. С ней работают методы, обработчики, условия шлюзов,
скрипты и решения DMN.
type DataModel struct {
Amount float64 `json:"amount"`
Category string `json:"category"`
Client *Client `json:"client"`
Items []Item `json:"items"`
Created time.Time `json:"created"`
Offer Offer `json:"offer"`
}
type Client struct {
Age int `json:"age"`
Address Address `json:"address"`
}Как переменная находит поле#
- По тегу
json: переменнаяamount— поле с тегомjson:"amount". Без тега — поле с именемamount, совпадение точное, как уencoding/json. - Путь через точку — вложенные структуры пакета
model:client.address.city→data.Client.Address.City. Указатель на структуру разыменовывается автоматически (nil — паника, проверяйте в условииclient != nil && client.age > 18). - Тип не из модели — остаток пути пишется как есть:
created.Year()→data.Created.Year(). - Встроенные (embedded) структуры без тега поднимают свои поля; поле верхнего уровня важнее.
Недостающие поля#
Если выражение использует переменную, которой нет в модели, генератор собирает все такие переменные со всех процессов и останавливается одной ошибкой:
переменных нет в модели данных model, добавьте поля:
model.DataModel:
Category string `json:"category"`
Fee float64 `json:"fee"`
Offer Offer `json:"offer"` // структура модели
Urgent bool `json:"urgent"`
используются в:
решение Offer (pricing.dmn)
процесс OrderApproval: бизнес-правило CalcOffer
...Строки можно копировать в модель как есть. Тип выведен из места использования (amount > 1000 — int,
amount >= 1.5 — float64, == "x" — string) — меняйте на нужный. Вложенные структуры подсказываются по шагам:
сначала Offer Offer, на следующем запуске — поля Offer.
Генератор модель не правит: это ваш файл.
Чтение и запись#
- Перед методом переменные читаются в модель через JSON (
json.Unmarshal). Не тот тип (строка в полеint) — ошибка метода, шаг повторяется. Значит, неверные данные при старте процесса проявятся на первом методе. - После метода все поля модели пишутся поверх переменных, в том числе нулевые. Если процесс запущен без
amount, а метод его не трогал, после метода в переменных будет"amount": 0. omitempty— поле в нулевом значении переменную не меняет. Ставьте его, если отсутствие и ноль различаются по смыслу, или используйте указатель.- Переменные, которых нет в модели (
approvedиз формы,errorCode), сохраняются нетронутыми.
Одна модель на все процессы#
Модель общая для всех процессов сервиса: переменная с одним именем — одно поле одного типа. Если в двух процессах
amount — разные вещи, переименуйте одну из переменных. Группируйте данные процесса во вложенные структуры
(order.amount, loan.amount), чтобы модель не превращалась в свалку полей.
Сервисы#
services.Services — ваши зависимости: клиенты баз, API, очередей. Заполняется при старте и замыкается во все методы
и обработчики:
// internal/bpmgen/services/services.go
type Services struct {
Orders OrderRepository
Mailer Mailer
Payment PaymentClient
}
type Mailer interface {
Send(ctx context.Context, to, subject, body string) error
}svc := &services.Services{Orders: repo, Mailer: smtp, Payment: bank}
e, err := engine.New(q, cfg, bpmgen.Processes(svc), bpmgen.Queues(svc))Поля удобно делать интерфейсами: в тестах методов подставляются заглушки. Движок о сервисах не знает — типы проверяет компилятор.
Методы task#
func CheckOrder(ctx context.Context, svc *services.Services, data *model.DataModel) error- Синхронно, в шаге процесса и его транзакции; может выполниться повторно, если шаг повторяется.
- Меняйте
data— изменения уйдут в переменные. - Возвращайте
errorдля повтора,bpm.NewError(code, msg)— для граничного события ошибки. - Слушайте
ctx: по нему работает граничный таймер иAttemptTimeout. - Без внешних побочных эффектов — для них service task.
Тест метода — обычный юнит-тест:
func TestCheckOrder(t *testing.T) {
data := &model.DataModel{Amount: 500}
if err := CheckOrder(context.Background(), &services.Services{}, data); err != nil {
t.Fatal(err)
}
if !data.Checked {
t.Error("заказ не отмечен проверенным")
}
if err := CheckOrder(context.Background(), &services.Services{}, &model.DataModel{}); err == nil {
t.Error("пустой заказ прошёл проверку")
}
}Обработчики service task#
func NotifyClient(ctx context.Context, svc *services.Services, job bpm.Job, data *model.DataModel) error- Асинхронно, отдельной задачей очереди, до
retriesпопыток. - Один обработчик на jobType во всех процессах; кто вызвал —
job.ProcessName,job.ElementId. - Идемпотентность: ключ —
job.InstanceUid + job.ElementId. Например, передавайте его во внешний API как idempotency key. job.LastAttempt— последняя попытка: можно, например, записать алерт.- Если jobType встречается в нескольких процессах с разными
retries, берётся максимальный.
Изменения процесса#
| Что изменили в BPMN | Что сделает генератор |
|---|---|
| Добавили задачу | создаст заготовку метода или обработчика |
| Удалили задачу | удалит её из схемы; рукописный method_*.go останется — удалите сами |
| Переименовали id задачи | новая заготовка с новым именем; перенесите код из старого файла и удалите его |
| Удалили jobType | обработчик в handlers/ останется — удалите сами |
| Удалили процесс | удалит пакет; если в нём есть рукописные файлы, оставит папку и предупредит |
| Поменяли условие, скрипт, таблицу DMN | перепишет сгенерированный код |
Старая заготовка, которую больше не вызывает схема, компилируется и не мешает, но вводит в заблуждение — удаляйте.
Экземпляры в пути при изменении процесса#
Изменение процесса затрагивает и экземпляры, которые уже выполняются: токен хранит только id текущего элемента и переменные. После деплоя:
- безопасно добавлять элементы, менять условия, таблицы и код методов;
- опасно удалять или переименовывать элементы, на которых стоят токены (ждущие user task, service task, таймеры):
такой экземпляр упадёт с ошибкой
process "X": element "Y" not found. Сначала дождитесь, пока такие экземпляры пройдут элемент, или оставьте старый элемент до этого.
Если процессы нужно менять несовместимо, а старые экземпляры доживать по старой схеме, используйте KindPrefix:
старая и новая версии сервиса работают на одной базе с разными префиксами очереди (см.
Запуск → несколько версий).
Ошибки генерации#
| Сообщение | Что делать |
|---|---|
переменных нет в модели данных model, добавьте поля: ... | добавьте поля в model/ |
пути по умолчанию считаются от go.mod, запустите генератор внутри Go-модуля ... | запускайте в модуле или задайте оба флага |
boundary event X: граничные события поддерживаются только у task, scriptTask и serviceTask | перенесите событие или поменяйте тип задачи |
boundary event X: непрерывающие события (cancelActivity=false) не поддерживаются | сделайте событие прерывающим |
длительность "P1M" не в формате ISO 8601 ... | PT30S, PT5M, P1DT2H; без лет и месяцев |
выражение "..." не разбирается как Go | исправьте синтаксис условия или скрипта |
ошибки компилятора с путём internal/bpmgen/... | скрипт, условие или ваш метод не компилируется — путь укажет файл |
| конфликт имён (два id дают одно имя функции) | переименуйте id элемента |
inclusiveGateway X не поддерживается (и другие элементы) | замените элемент поддерживаемым, см. BPMN → что не поддерживается |
task X: несколько исходящих переходов (...), разветвление делается шлюзом | поставьте exclusive или parallel gateway после задачи |
X: условие на переходе F проверяет только exclusive gateway | перенесите условие на выход exclusive gateway |
exclusiveGateway X: у перехода F нет условия, и он не default flow | добавьте условие или сделайте переход default |
X: переходы в <bpmn:outgoing> (...) не совпадают с sequence flow ... | XML правили руками: уберите <bpmn:outgoing>/<bpmn:incoming> или исправьте их |
sequence flow F: элемент "X" не найден в процессе | переход ведёт к удалённому или неподдерживаемому элементу |
нет start event, несколько start event (...) | у процесса и подпроцесса должен быть ровно один обычный start event |
subProcess X: вложенный подпроцесс Y не поддерживается | вынесите внутренний подпроцесс в отдельный процесс и call activity |
task X: циклы и multi-instance (...) не поддерживаются | уберите маркер цикла; повтор — через шлюз и обратный переход |
Генерация в CI#
Проверяйте, что сгенерированный код совпадает с BPMN:
go generate ./... && git diff --exit-codeТак в репозиторий не попадёт BPMN, для которого забыли перегенерировать код, или правка в сгенерированном файле.