Demo Microservice
ecos-demo-app is a reference microservice of the Citeck platform, designed for developers who build their own microservices or integrate external systems with the platform. The application is written in Java using Spring Boot and covers the most common scenarios for working with the ECOS API.
This document is intended for developers (Java/Kotlin) who are getting started with the Citeck platform or want to understand common integration patterns.
The demo application implements the following platform mechanisms:
Records API — a custom RecordsDAO with in-memory data storage and a full set of CRUD operations;
Actions — server-side actions, including sending email notifications and creating child entities via action;
Commands — asynchronous messaging between microservices;
Jobs — scheduling regular task execution using the
@Scheduledannotation;Events — subscribing to platform events (record creation and modification) with support for transactional and asynchronous listeners;
External Tasks — executing BPMN process tasks in an external service;
BPMN process — a complete business process cycle with auto-start, a user task, and an external task.
The repository ecos-demo-app contains an application demonstrating Citeck’s capabilities.
You can get acquainted with:
custom RecordsDAO;
working with RecordsService;
business process with external task;
creating child associations.
See more about Citeck artifacts, applications.
When starting the application, a section «Demo type/Demo type» will appear in the left menu by default with two journals:
Demo type journal/Demo type journal — the main demo journal with a BPMN process and various demonstration actions. The scenario written below is based on this type.
in-memory demo type/Demo in-memory type — a journal with in-memory entities to demonstrate working with a custom RecordsDAO defined in ru.citeck.ecos.webapp.demo.records.DemoInMemRecordsDao. You can create/view/edit/delete records in this journal and see what has changed in DemoInMemRecordsDao.
Getting Started
If you are not familiar with the Citeck platform and want to run the software locally, we recommend you download the Dockerized version of Community demo. Details on installation.
The microservice is written using Spring Boot.
Note
The following applications from the Citeck deployment are required to run this application:
zookeeper;
rabbitmq;
ecos-model;
ecos-registry.
Running
Clone the repository to your development environment. To run the application, execute:
./mvnw spring-boot:run
If your IDE supports running Spring Boot applications directly, you can easily run the class ru.citeck.ecos.webapp.demo.EcosDemoApp without additional configuration.
Usage Scenario
Start ecos-demo-app.
In Citeck, click «Create/Create» in the top left corner.
Select «Demo type/Demo type» -> «Demo type/Demo type».
Enter the value «error» in the «Name/Name» field and click the «Save/Save» button. You should see an error from the transactional listener defined in ru.citeck.ecos.webapp.demo.events.DemoEcosEventListener.
Change the value of the «Name/Name» field to any other and fill in the remaining fields.
After creation, you will see information about the created record:
The status will be «New/New». This is defined in the defaultStatus property in the type configuration —
src/main/resources/eapps/artifacts/model/type/demo-type.ymlTask widgets will display an active task for the current user. The BPMN process is started because we have a process definition in
src/main/resources/eapps/artifacts/process/bpmn/demo-process.bpmn.xmlwith the flags ecos:enabled=”true” and ecos:autoStartEnabled=”true”.
Click the «Done/Done» button in the current task widget.
The task will disappear and an external task will be started — ru.citeck.ecos.webapp.demo.exttask.DemoExternalTask.
After about 5–10 seconds, you can refresh the browser tab and see the new status «Completed/Completed» and the filled field «Field generated in external task/Field generated in external task». At this point, the BPMN process completes.
You can click «Send demo email/Send demo email» to test the special action for sending an email.
Action class: ru.citeck.ecos.webapp.demo.actions.SendDemoEmailAction
Action definition:
src/main/resources/eapps/artifacts/ui/action/send-demo-email-action.ymlEmail template:
src/main/resources/eapps/artifacts/notification/template/demo-email.html.ftl.The result email can be found in mailhog (if you haven’t changed the default email settings) — http://localhost:8025/
After testing email sending, you can click «Create child entity/Create child entity» to test the ability to create related objects via an action.
Action definition:
src/main/resources/eapps/artifacts/ui/action/create-child-entity-action.yml
Description of Microservice Entities
Artifacts
The project artifacts are located in the folder .../src/main/resources/eapps/artifacts. The first two directory levels correspond to the artifact type. For example:
app/artifact-patch
model/type
notification/template
process/bpmn
ui/action, /form, /journal
More about Citeck artifacts
Classes
Record – an entity with a set of attributes and a record identifier (RecordRef).
Below is a simple example of a RecordsDAO with in-memory entity storage. This RecordsDAO demonstrates simple basic CRUD operations in the Records API and does not implement functions such as associations, content storage, permission checks, etc. See detailed description of CRUD operations
Link to Records in Git repository
Warning
Attention: All data will be lost after restarting the application. Do not use for production environment.
@Component
public class DemoInMemRecordsDao extends AbstractRecordsDao
implements RecordsQueryDao, RecordAttsDao, RecordMutateDao, RecordDeleteDao {
public static final String ID = "demo-inmem-data";
/**
* Создание простого хранилища для записей. Все данные будут потеряны после рестарта приложения.
*/
private final Map<String, SimpleDto> records = new ConcurrentHashMap<>();
/**
* Запрос Query records поддерживает только язык «предикатов».
* @param recordsQuery – параметры запроса, отправляемые с фронтенда
* @return найденные записи и информацию об общем количестве без пагинации
*/
@Nullable
@Override
public RecsQueryRes<?> queryRecords(@NotNull RecordsQuery recordsQuery) {
// О предикатах подробно можно прочитать по ссылке
// https://citeck-ecos.readthedocs.io/ru/latest/general/%D0%AF%D0%B7%D1%8B%D0%BA_%D0%BF%D1%80%D0%B5%D0%B4%D0%B8%D0%BA%D0%B0%D1%82%D0%BE%D0%B2.html
if (!PredicateService.LANGUAGE_PREDICATE.equals(recordsQuery.getLanguage())) {
return null;
}
Predicate predicate = recordsQuery.getQuery(Predicate.class);
QueryPage page = recordsQuery.getPage();
List<SimpleDto> fullResult = predicateService.filterAndSort(
records.values(),
predicate,
recordsQuery.getSortBy(),
page.getSkipCount(),
page.getMaxItems()
);
RecsQueryRes<SimpleDto> recsQueryRes = new RecsQueryRes<>();
recsQueryRes.setTotalCount(records.size());
recsQueryRes.setRecords(fullResult);
return recsQueryRes;
}
/**
* Получить данные рекорда по localId.
* @return сам рекорд или null
*/
@Nullable
@Override
public Object getRecordAtts(@NotNull String localId) {
return records.get(localId);
}
/**
* Создание или обновление рекорда.
* Если recordAtts.getId() пустая строка, то создается новый рекорд со сгенерированным localId
* @param recordAtts localId (String) и key-value map (ObjectData) с атрибутами сущности
* @return localId существующего или созданного рекорда.
*/
@NotNull
@Override
public String mutate(@NotNull LocalRecordAtts recordAtts) {
SimpleDto recToMutate;
if (recordAtts.getId().isEmpty()) { //создание
// Обычно в других не демо-версиях RecordsDao, когда getId() пустой,
// id можно указать в атрибутах, но здесь мы не реализуем эту логику.
// Вы можете просмотреть исходный код ru.citeck.ecos.records3.record.dao.impl.mem.InMemDataRecordsDao
// чтобы проверить правильную реализацию метода mutate.
recToMutate = new SimpleDto(UUID.randomUUID().toString());
} else {
recToMutate = records.get(recordAtts.getId()); // обновление
if (recToMutate == null) {
throw new IllegalArgumentException("Record with id " + recordAtts.getId() + " is not found");
}
recToMutate = new SimpleDto(recToMutate);
recToMutate.modified = Instant.now();
}
Json.getMapper().applyData(recToMutate, recordAtts.getAtts());
if (recToMutate.id.isBlank()) {
throw new IllegalArgumentException("Record id is empty after mutation. Atts: " + recordAtts);
}
records.put(recToMutate.id, recToMutate);
return recToMutate.id;
}
/**
* Удаление определенных рекордов.
* @param localId удаленной записи
* @return localId существующего или созданного рекорда.
*/
@NotNull
@Override
public DelStatus delete(@NotNull String localId) {
records.remove(localId);
return DelStatus.OK;
}
@NotNull
@Override
public String getId() {
return ID;
}
/**
* Создание DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class SimpleDto {
private String id;
private String textField;
private int numField;
private Instant created;
private Instant modified;
public SimpleDto(String id) {
this.id = id;
created = Instant.now();
modified = created;
}
public SimpleDto(SimpleDto other) {
this.id = other.id;
this.textField = other.textField;
this.numField = other.numField;
this.created = other.created;
this.modified = other.modified;
}
/**
* Данный метод вызывается, когда скаляр '?disp' загружается из рекорда.
* Подробно о скалярах https://citeck-ecos.readthedocs.io/ru/latest/general/ECOS_Records.html#id11
* 'getDisplayName' специальное имя в DTO для скаляра '?disp'
*/
public MLText getDisplayName() {
Map<Locale, String> nameData = new HashMap<>();
nameData.put(I18nContext.ENGLISH, "Demo in-mem '" + textField + "'");
nameData.put(I18nContext.RUSSIAN, "Демо in-mem '" + textField + "'");
return new MLText(nameData);
}
/**
* Данный метод вызывается, когда атрибут '_type' attribute загружается из рекорда.
* 'getEcosType' специальное имя в DTO для атрибута DTO for '_type' .
* Движок добавляет дополнительную логику для данного метода:
* Если результат метода EntityRef или строка, начинающаяся с 'emodel/type@', то результат будет EntityRef.valueOf(methodResult).
* Если результат метода строка и она не начинается с 'emodel/type@', то движок добавляет префикс 'emodel/type@' и возвращает результат EntityRef.valueOf.
*/
public String getEcosType() {
// type config: src/main/resources/eapps/artifacts/model/type/demo-inmem-type.yaml
return "demo-inmem-type";
}
/**
* Простой геттер для атрибута _created.
* Аннотация AttName требуется, чтобы изменить имя атрибута по умолчанию "created" на "_created".
* '_created' специальный мета атрибут для любого рекорда, который должен информировать, когда рекорд был создан.
*/
@AttName(RecordConstants.ATT_CREATED)
public Instant getCreated() {
return created;
}
/**
* Простой геттер для атрибута _modified.
* Аннотация AttName требуется, чтобы изменить имя атрибута по умолчанию "modified" на "_modified".
* '_modified' специальный мета атрибут для любого рекорда, который должен информировать, когда рекорд был изменен последний раз.
*/
@AttName(RecordConstants.ATT_MODIFIED)
public Instant getModified() {
return modified;
}
}
}
Actions - Citeck artifacts in json or yaml format with type ui/action. Link to Action in Git repository
Action artifacts are located in the folder …/src/main/resources/eapps/artifacts/ui/action
@Component
@RequiredArgsConstructor
public class SendDemoEmailAction extends AbstractRecordsDao implements ValueMutateDao<SendDemoEmailAction.ActionData> {
/**
* Идентификатор RecordsDAO. Используется для определения того, какой DAO должен обрабатывать запрос на мутацию. Это вторая половина EntityRef после'/' и до '@'
* Это 'send-demo-email' в составе ecos-demo-app/send-demo-email@ 629fbd31-788a-4232-9de9-d737e5b07795
* В запросах API этот идентификатор сочетается с appName называемым sourceId. Например: 'ecos-demo-app/send-demo-email'
*/
public static final String ID = "send-demo-email";
/**
* Шаблон email
* Загружается из src/main/resources/eapps/artifacts/notification/template/demo-email.html.ftl
*/
private static final EntityRef TEMPLATE_REF = EntityRef.create(
AppName.NOTIFICATIONS,
"template",
"demo-email"
);
private final NotificationService notificationService;
@Nullable
@Override
public Object mutate(@NotNull ActionData actionData) {
String currentUser = AuthContext.getCurrentUser();
EntityRef currentUserRef = AuthorityType.PERSON.getRef(currentUser);
String email = recordsService.getAtt(currentUserRef, "email").asText();
if (StringUtils.isBlank(email)) {
throw new RuntimeException("Current user doesn't have email. Please open user profile and change it");
}
// Дополнительные метаданные могут использоваться для добавления пользовательских данных при отправке уведомления.
// Шаблон уведомления может загружать любое значение из этих данных, используя '$' как префикс перед ключом
// Например:
// Template model = {"anyAliasWhichCanBeUsedInFtlTemplate": "$additionalStr"}
// Ftl template = "Some text ${anyAliasWhichCanBeUsedInFtlTemplate}"
// Result = "Some text additional-string-value"
Map<String, Object> additionalMeta = new HashMap<>();
additionalMeta.put("additionalStr", "additional-string-value");
// Переменные могут содержать простые скаляры (string/number/boolean/etc.) или ссылки на любые объекты в системе
// Например, мы добавляем сюда ссылку на текущего пользователя.
additionalMeta.put("additionalUserRef", EntityRef.create(AppName.EMODEL, "person", currentUser));
// Также можно использовать значения DTO, и шаблон может извлекать из них данные.
additionalMeta.put("actionData", actionData);
// NotificationService используется для ручной отправки уведомлений
// Шаблон уведомления определяет модель с атрибутами, которые следует загрузить из рекорда и additionalMeta
// Сервис работает следующим образом:
// 1. Загрузить список атрибутов, необходимый для предоставленного templateRef
// 2. Загрузить необходимые атрибуты из предоставленной записи и additionalMeta
// 3. Отправить команду с загруженными данными в приложение уведомлений через RabbitMQ
// Метод 'send' не ждет пока сообщение действительно будет отправлено
notificationService.send(new Notification.Builder()
.addRecipient(email)
.record(actionData.entityRef)
.notificationType(NotificationType.EMAIL_NOTIFICATION)
.additionalMeta(additionalMeta)
.templateRef(TEMPLATE_REF)
.build());
// с настройками по умолчанию отправленный email можно увидеть в mailhog - http://localhost:8025/
return null;
}
@NotNull
@Override
public String getId() {
return ID;
}
@Data
public static class ActionData {
private EntityRef entityRef;
private String comment;
}
}
On the frontend, the action is called as follows:
let rec = Records.getRecordToEdit('ecos-demo-app/send-demo-email@');
rec.att('entityRef', 'emodel/demo-type@629fbd31-788a-4232-9de9-d737e5b07795'); // any EntityRef
rec.att('comment', 'any comment');
await rec.save();
where
ecos-demo-app - appName
send-demo-email - RecordsDAO identifier. Used to determine which DAO should handle the mutation request (see SendDemoEmailAction.ID).
EntityRef - unique identifier of an entity in the Citeck system.
Commands in Citeck are mainly used for asynchronous messaging between applications.
Command Executor
In the service where the command is sent, an Executor must be implemented that will process the DTO. Link to Command Executor in Git repository
This demo executor covers the basic concepts. The command type will be calculated based on the CommandType annotation for the generic CommandExecutor type.
@Slf4j
@Component
public class DemoCommandExecutor implements CommandExecutor<DemoCommandExecutor.DemoCommandDto> {
public static final String TYPE = "demo-command";
@Nullable
@Override
public Object execute(DemoCommandDto demoCommandDto) {
log.info("Command received: " + demoCommandDto);
return null;
}
@Data
@CommandType(TYPE)
public static class DemoCommandDto {
private EntityRef entityRef;
private String comment;
}
}
The command itself
In the service from which we send the command request, we use CommandService to send the command. Link to Command in Git repository
@Component
@RequiredArgsConstructor
public class SendDemoCommandAction extends AbstractRecordsDao implements ValueMutateDao<SendDemoCommandAction.ActionData> {
public static final String ID = "send-demo-command";
private final CommandsService commandsService;
@Nullable
@Override
public Object mutate(@NotNull ActionData actionData) {
Map<String, Object> body = new HashMap<>();
body.put("entityRef", actionData.entityRef);
body.put("comment", actionData.comment);
// Command execution result you can see in logs
commandsService.execute(b -> {
b.withTargetApp("ecos-demo-app"); // эта команда отправляется в приложение
b.withBody(body); // body может быть любой объект Map или DTO
b.withType("demo-command"); // command executor будет выбран по этому типу
return Unit.INSTANCE;
});
return null;
}
/**
* переопределение DAO
*/
@NotNull
@Override
public String getId() {
return ID;
}
/**
* Фронтенд отправляет 2 атрибута и создается инстанс ActionData
*/
@Data
public static class ActionData {
private EntityRef entityRef;
private String comment;
}
}
Job allows scheduling one-time or regular execution of tasks. Link to Job in Git repository
@Slf4j
@Component
@RequiredArgsConstructor
public class SimpleAnnotatedJob {
private final AtomicInteger counter = new AtomicInteger();
private final RecordsService recordsService;
/**
* Задание будет выполнено как системное.
* @see Scheduled
*/
@Scheduled(fixedDelayString = "${ecos.demo.simple-annotated-job.delay}") // задержка настраивается в application.yml
void doSomeWork() {
RecsQueryRes<EntityRef> queryRes = recordsService.query(
RecordsQuery.create()
.withEcosType("demo-type") // Запрос всех записей с типом demo-type, у которых есть childEntities
// sourceId будет загружен из ecosType по умолчанию,
// но вы можете указать это явно
//.withSourceId(AppName.EMODEL + "/demo-type")
.withQuery(Predicates.notEmpty("childEntities"))
.withMaxItems(0) // Query for totalCount without records
.build()
);
log.info("Simple annotated job example #" + counter.incrementAndGet() +
". Demo records with children: " + queryRes.getTotalCount()); // вывод количества в лог
}
}
Events in Citeck allow changing the attribute composition needed by an event subscriber without modifying the event source.
Let’s consider creating an EventListener class. Link to Event in Git repository
@Slf4j
@Component
@RequiredArgsConstructor
public class DemoEcosEventListener {
private final EventsService eventsService;
@PostConstruct
public void init() {
Predicate filter = Predicates.and( // Задается фильтр для поиска
// Для транзакционных слушателей очень важна фильтрация по типу.
// чтобы избежать генерации ненужных событий.
Predicates.eq("typeDef.id", "demo-type"),
Predicates.contains("record.textField", "error")
);
eventsService.<UserCreatedOrUpdatedEventAtts>addListener(builder -> {
// Типы событий
//
// Часто используемые типы событий:
// ru.citeck.ecos.events2.type.RecordChangedEvent.TYPE ("record-changed")
// ru.citeck.ecos.events2.type.RecordDeletedEvent.TYPE ("record-deleted")
// ru.citeck.ecos.events2.type.RecordStatusChangedEvent.TYPE ("record-status-changed")
// ru.citeck.ecos.events2.type.RecordDraftStatusChangedEvent.TYPE ("record-draft-status-changed")
// ru.citeck.ecos.events2.type.RecordCreatedEvent.TYPE ("record-created")
// ru.citeck.ecos.events2.type.RecordContentChangedEvent.TYPE ("record-content-changed")
//
// Атрибуты для этих типов событий можно найти в классах выше.
builder.withEventType(RecordCreatedEvent.TYPE); // подписка на создание записи
// Класс данных определяет DTO с атрибутами, которые должны быть загружены из события и отправлены слушателю
builder.withDataClass(UserCreatedOrUpdatedEventAtts.class); // какие данные выбирать из события
// Транзакционный флаг дает слушателю следующие возможности:
// 1. Слушатель вызывается сразу после возникновения события
// 2. Если слушателю отправить ошибку, то транзакция будет отменена
// но у этого флага есть следующие недостатки:
// 1. Если приложение не запускается и произошло событие, транзакция всегда будет завершаться с ошибкой.
// 2. Если слушатель проделывает какую-то сложную работу, то отзывчивость системы будет хуже.
//
// Если выбрать transactional=false, то слушатель будет вызываться асинхронно
// после фиксации транзакции
builder.withTransactional(true);
// 'J' в конце имени метода означает 'Java'.
// Методы API без постфикса изначально предназначены для использования в Kotlin.
// withAction определяет метод, который должен вызываться при возникновении события.
builder.withActionJ(this::processCreatedOrUpdatedEvent);
// Фильтр проверяет любые данные в событии мгновенно, когда событие произошло.
// Если фильтр не соответствует, событие не будет создано.
builder.withFilter(filter);
return Unit.INSTANCE;
});
// Добавить слушателя к измененному событию
eventsService.<UserCreatedOrUpdatedEventAtts>addListener(builder -> {
builder.withEventType(RecordChangedEvent.TYPE);
builder.withDataClass(UserCreatedOrUpdatedEventAtts.class);
builder.withTransactional(true);
builder.withActionJ(this::processCreatedOrUpdatedEvent);
builder.withFilter(filter);
return Unit.INSTANCE;
});
}
private void processCreatedOrUpdatedEvent(UserCreatedOrUpdatedEventAtts event) { // вызов метода для получения инстанса класса, наполненного данными, которые можно фильтровать
log.warn("Process created or updated event for record " + event.entityRef + ". TextField: " + event.textField); // вывод полученных данных в лог
throw new RuntimeException("You can't write 'error' in text field. Current value: '" + event.textField + "'"); // вывод ошибки, препятствующей выполнению действий
}
@Data
public static class UserCreatedOrUpdatedEventAtts { // создание на сервере инстанса этого класса и наполнение его данными.
@AttName("record?id") // что создалось
private EntityRef entityRef;
@AttName("record.textField") // полученные данные поля textField
private String textField;
}
}
External tasks allow executing tasks using external systems.
Example of an external task for the demo BPMN process - link in Git repository
The business process artifact is located in the folder …/src/main/resources/eapps/artifacts/process/bpmn
@Slf4j
@Component
@RequiredArgsConstructor
@ExternalTaskSubscription("demo-ext-task")
public class DemoExternalTask implements ExternalTaskHandler {
private final RecordsService recordsService;
@Override
// Если вы обернете метод выполнения в RunInTransaction, то внешняя задача
// в процессе должен быть флаг asyncAfter, чтобы избежать ошибок транзакций
@RunInTransaction
@ExternalTaskRetry(retries = 10, retryTimeout = 10_000) // // настройка повторной обработки задачи, если в процессе обработки возникла техническая ошибка
public void execute(ExternalTask externalTask, ExternalTaskService externalTaskService) {
String documentRef = externalTask.getVariable("documentRef"); // получить ссылку на документ
log.info("External task for document: " + documentRef); // вывести в лог полученную ссылку на документ
String textField = recordsService.getAtt(documentRef, "textField").asText(); // получить данные поля textField
log.info("Text field: '" + textField + "'"); // вывести в лог полученные данные поля
/*
Вы можете использовать простую мутацию одного атрибута, используя метод mutateAtt
или используйте расширенный метод с RecordAtts. Например, обновить данные в поле extTaskField:
RecordAtts record = new RecordAtts(documentRef);
record.setAtt("extTaskField", "TextField: " + textField);
record.setAtt("otherAttribute", "otherValue");
recordsService.mutate(record);
*/
recordsService.mutateAtt(documentRef, "extTaskField", "TextField: " + textField);
// Здесь можно указать бизнес-ошибку. Эта ошибка должна быть правильно обработана в процессе
// externalTaskService.handleBpmnError(externalTask, "error-code", "error-message");
externalTaskService.complete(externalTask);
}
}
Building
To build a docker image with the microservice, execute the command:
./mvnw -Pprod clean package jib:dockerBuild -Djib.docker.image.tag=custom
After building, you can run the ecos-demo-app:custom container using docker.
Testing
To run your application’s tests, execute:
./mvnw clean test