go-bpm

Генератор кода

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-gen
go generate ./internal/gen     # или go generate ./... — сгенерирует всё в модуле

Можно и без директивы: go tool go-bpm-gen из любой папки модуля.

Флаги#

ФлагПо умолчаниюЧто делает
-process<корень модуля>/processпапка с BPMN и DMN
-output<корень модуля>/internal/bpmgenпапка сгенерированного кода, внутри Go-модуля
-checktrueпосле генерации собрать код (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

Что происходит при запуске#

  1. Читаются все *.bpmn и *.dmn из -process (рекурсивно).
  2. Удаляются сгенерированные файлы прошлого запуска — с заголовком // Code generated by go-bpm-gen. DO NOT EDIT.. Поэтому не остаются условия и скрипты, которых больше нет в BPMN.
  3. Каждый процесс проверяется: неподдерживаемые элементы, ветвления без шлюза, переходы без условий — ошибка генерации, а не сбой экземпляра в рантайме. Переходы без <bpmn:incoming>/<bpmn:outgoing> восстанавливаются по sequence flow.
  4. Пишется код процессов, условий, скриптов и решений; создаются недостающие заготовки.
  5. Проверяется, что все переменные из выражений есть в модели данных. Если нет — генерация останавливается со списком полей, которые нужно добавить.
  6. С -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.QueuejobType с числом попыток и обработчиком — в engine.New
bpmgen.Decisions map[string]stringXML 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, для которого забыли перегенерировать код, или правка в сгенерированном файле.

Предложить правку на GitHub