aspose template, trackVars bugfix

This commit is contained in:
Andreas Isler
2026-07-07 10:28:57 +02:00
parent f5f495567c
commit aaef7116f8
6 changed files with 111 additions and 171 deletions
@@ -1,42 +0,0 @@
package com.customer.work.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.flowable.eventregistry.api.EventRegistry;
import org.flowable.eventregistry.api.model.EventPayloadTypes;
import org.flowable.eventregistry.api.runtime.EventPayloadInstance;
import org.flowable.eventregistry.impl.runtime.EventInstanceImpl;
import org.flowable.eventregistry.impl.runtime.EventPayloadInstanceImpl;
import org.flowable.eventregistry.model.EventPayload;
import org.flowable.task.service.delegate.DelegateTask;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collection;
@Service
public class TaskListenerService {
private final EventRegistry eventRegistry;
private final ObjectMapper objectMapper;
public TaskListenerService(EventRegistry eventRegistry, ObjectMapper objectMapper) {
this.eventRegistry = eventRegistry;
this.objectMapper = objectMapper;
}
public void throwTaskListenerEvent(String rootCaseId, String eventKey, DelegateTask task, String message) {
ObjectNode jsonTask = objectMapper.createObjectNode();
jsonTask.put("modelId", task.getTaskDefinitionKey());
jsonTask.put("assignee", task.getAssignee());
jsonTask.put("name", task.getName());
Collection<EventPayloadInstance> instances = new ArrayList<>();
instances.add(new EventPayloadInstanceImpl(EventPayload.correlation("caseId", EventPayloadTypes.STRING), rootCaseId));
instances.add(new EventPayloadInstanceImpl(new EventPayload("taskListenerTask", EventPayloadTypes.JSON), jsonTask));
instances.add(new EventPayloadInstanceImpl(new EventPayload("taskListenerMessage", EventPayloadTypes.STRING), message));
EventInstanceImpl event = new EventInstanceImpl(eventKey, instances, task.getTenantId());
eventRegistry.sendSystemEventOutbound(event);
}
}
@@ -1,24 +0,0 @@
package com.customer.work.service;
import org.flowable.engine.RuntimeService;
import org.flowable.task.service.delegate.DelegateTask;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component("taskProcessStarter")
public class TaskProcessStarter {
@Autowired
private RuntimeService runtimeService;
public void start(String processKey, DelegateTask task) {
Map<String, Object> vars = new HashMap<>(task.getVariables()); // carry over existing vars
vars.put("originTaskId", task.getId());
vars.put("originTaskName", task.getName());
runtimeService.startProcessInstanceByKey(processKey, vars);
}
}
@@ -9,12 +9,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.flowable.cmmn.api.CmmnRuntimeService;
import org.flowable.cmmn.api.runtime.CaseInstance;
import org.flowable.cmmn.api.runtime.PlanItemInstance;
import org.flowable.cmmn.engine.CmmnEngineConfiguration;
import org.flowable.cmmn.engine.impl.persistence.entity.CaseInstanceEntity;
import org.flowable.common.engine.api.delegate.event.FlowableEngineEntityEvent;
import org.flowable.common.engine.api.delegate.event.FlowableEvent;
import org.flowable.common.engine.api.delegate.event.FlowableEventListener;
import org.flowable.engine.ProcessEngineConfiguration;
import org.flowable.engine.RuntimeService;
import org.flowable.engine.delegate.DelegateExecution;
import org.flowable.engine.impl.persistence.entity.ExecutionEntity;
@@ -23,7 +18,6 @@ import org.flowable.engine.runtime.ProcessInstance;
import org.flowable.variable.api.delegate.VariableScope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -33,29 +27,19 @@ import java.util.List;
import java.util.Map;
/**
* General-purpose Flowable variable utility bean.
* Usable in BPMN process and CMMN case backend expressions without any scope parameter:
* ${varUtils.get('order.customer.name')}
* ${varUtils.track('order.customer.name,order.total,status')}
* Root variables are supported.
* How the scope is resolved without a parameter
* -----------------------------------------------
* The bean registers itself as a Flowable event listener on both engines.
* Before a service task expression is evaluated, Flowable fires events on the
* same thread that allow us to capture the current variable scope:
* BPMN — PROCESS_STARTED fires a FlowableEngineEntityEvent.
* The execution is retrieved via event.getEntity().
* CMMN — CASE_STARTED fires a FlowableEngineEntityEvent.; Instead, when a plan
* The case instance is retrieved via event.getEntity().
* General-purpose Flowable variable utility bean for tracking variable changes.
* Usable in BPMN process and CMMN case backend expressions:
* ${varUtils.trackVars('root.snapshot', 'order.customer.name,order.total,status')}
* ${varUtils.trackVars(execution, 'root.snapshot', 'status')} — BPMN, explicit scope
* ${varUtils.trackVars(planItemInstance, 'root.snapshot', 'status')} — CMMN, explicit scope
* Variable paths use dot notation; a leading "root." prefix addresses the root
* instance of a nested case/process hierarchy.
*/
@Component("varUtils")
public class VarUtils implements FlowableEventListener, SmartInitializingSingleton {
public class VarUtils {
private static final Logger LOGGER = LoggerFactory.getLogger(VarUtils.class);
private static final ThreadLocal<VariableScope> SCOPE = new ThreadLocal<>();
private static final ObjectMapper MAPPER;
static {
MAPPER = new ObjectMapper();
@@ -63,72 +47,12 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
}
@Autowired
private ProcessEngineConfiguration processEngineConfiguration;
@Autowired
private CmmnEngineConfiguration cmmnEngineConfiguration;
@Autowired
private RuntimeService runtimeService;
@Autowired
private CmmnRuntimeService cmmnRuntimeService;
// -------------------------------------------------------------------------
// Listener registration — runs after all beans are ready
// -------------------------------------------------------------------------
@Override
public void afterSingletonsInstantiated() {
if (processEngineConfiguration != null) {
processEngineConfiguration.getEventDispatcher().addEventListener(this);
LOGGER.debug("varUtils registered on BPMN event dispatcher");
}
if (cmmnEngineConfiguration != null) {
cmmnEngineConfiguration.getEventDispatcher().addEventListener(this);
LOGGER.debug("varUtils registered on CMMN event dispatcher");
}
}
// -------------------------------------------------------------------------
// FlowableEventListener — bind / unbind the scope ThreadLocal
// -------------------------------------------------------------------------
@Override
public void onEvent(FlowableEvent event) {
if (event instanceof FlowableEngineEntityEvent entityEvent) {
String typeName = event.getType().name();
Object entity = entityEvent.getEntity();
// BPMN execution
if (entity instanceof ExecutionEntity execution) {
switch (typeName) {
case "PROCESS_STARTED"
-> SCOPE.set(execution);
case "PROCESS_CANCELLED",
"PROCESS_COMPLETED",
"PROCESS_COMPLETED_WITH_ERROR_END_EVENT",
"PROCESS_COMPLETED_WITH_ESCALATION_END_EVENT",
"PROCESS_COMPLETED_WITH_TERMINATE_END_EVENT"
-> SCOPE.remove();
}
return;
}
// CMMN case instance itself
if (entity instanceof CaseInstanceEntity caseInstance) {
if ("CASE_STARTED".equals(typeName))
SCOPE.set(caseInstance);
else if ("CASE_ENDED".equals(typeName))
SCOPE.remove();
}
}
}
@Override public boolean isFailOnException() { return false; }
@Override public boolean isFireOnTransactionLifecycleEvent() { return false; }
@Override public String getOnTransaction() { return null; }
// -------------------------------------------------------------------------
// Public API — called from Flowable expressions
// -------------------------------------------------------------------------
@@ -136,39 +60,41 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
/**
* Checks which of the given comma-separated variable paths changed since the
* last call and returns a JSON array describing each change.
* ${varUtils.trackVars('root.snapshot', 'root.myString, status')}
* {@code snapshotPath} is a dot-notation path (supports the {@code root.} prefix)
* pointing to where the previous-values snapshot is stored and updated.
* Each subsequent call compares against the previous snapshot and updates it.
* Example call:
* ${varUtils.trackVars(self, 'root.snapshot', 'root.status, status')}
* Example return value:
* [{"path":"root.myString","oldValue":"a","newValue":"b"}]
* [{"path":"root.status","oldValue":"a","newValue":"b"}, {"path":"status","oldValue":"c","newValue":"d"}]
*/
public ArrayNode trackVars(String snapshotPath, String variablePathsCsv) {
public ArrayNode trackVars(VariableScope currentScope, String snapshotPath, String variablePathsCsv) {
// Check parameters
// Reject blank parameters early — report "no changes" instead of failing,
// so a misconfigured expression cannot break the surrounding process/case.
if (snapshotPath == null || snapshotPath.isBlank() || variablePathsCsv == null || variablePathsCsv.isBlank()) {
LOGGER.debug("{}.trackVars: empty parameters", getClass().getName());
return MAPPER.createArrayNode();
}
// Get current scope
VariableScope currentScope = getCurrentScope();
// Without a resolvable scope there is nothing to read from or write to.
if (currentScope == null) {
LOGGER.debug("{}.trackVars: currentScope not found", getClass().getName());
return MAPPER.createArrayNode();
}
// Create paths list
// Split the CSV into individual variable paths, dropping blanks and whitespace.
List<String> paths = Arrays.stream(variablePathsCsv.split(","))
.map(String::trim).filter(s -> !s.isEmpty()).toList();
// Load the previous values (persisted as a JSON string variable at
// snapshotPath) to compare the current values against.
Map<String, JsonNode> oldSnapshot = loadSnapshot(currentScope, snapshotPath);
Map<String, JsonNode> newSnapshot = new HashMap<>();
ArrayNode changes = MAPPER.createArrayNode();
// Loop paths list
// For every tracked path: read the current value, normalize it so it
// compares consistently with the reloaded snapshot, and record a change
// entry whenever old != new.
for (String path : paths) {
JsonNode newValue = toJson(readVariableFromPath(currentScope, path));
JsonNode newValue = normalize(toJson(readVariableFromPath(currentScope, path)));
newSnapshot.put(path, newValue);
JsonNode oldValue = oldSnapshot.getOrDefault(path, MAPPER.nullNode());
@@ -181,6 +107,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
}
}
// Persist the current values as the reference snapshot for the next call.
saveSnapshot(currentScope, snapshotPath, newSnapshot);
return changes;
@@ -190,10 +117,18 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
// Path resolution
// -------------------------------------------------------------------------
/**
* Reads a value via a dot-notation path, e.g. "order.customer.name".
* The first segment names a Flowable variable; the remaining segments navigate
* into that value (maps, lists/arrays by index, JsonNodes, POJOs — see
* {@link #getNestedVariable}). Returns null when any segment cannot be resolved.
*/
private Object readVariableFromPath(VariableScope scope, String path) {
String[] segments = path.split("\\.", -1);
if (segments.length < 1) return null;
// A leading "root." switches to the root instance of the surrounding
// case/process hierarchy before resolving the variable.
int startIndex = 0;
if ("root".equals(segments[0])) {
if (segments.length < 2) return null;
@@ -202,6 +137,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
}
if (scope == null) return null;
// Read the top-level variable, then walk the remaining segments into it.
Object currentValue = scope.getVariable(segments[startIndex]);
for (int i = startIndex + 1; i < segments.length; i++) {
if (currentValue == null) return null;
@@ -210,10 +146,18 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
return currentValue;
}
/**
* Writes a value via a dot-notation path (same syntax as
* {@link #readVariableFromPath}). A single-segment path sets a plain Flowable
* variable; a nested path mutates the container inside the top-level variable
* and writes that variable back so the engine persists the change.
*/
private void writeVariableToPath(VariableScope scope, String path, Object value) {
String[] segments = path.split("\\.", -1);
if (segments.length < 1) return;
// A leading "root." redirects the write to the root instance of the
// surrounding case/process hierarchy.
VariableScope targetScope;
String[] varSegments;
if ("root".equals(segments[0])) {
@@ -249,6 +193,11 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
targetScope.setVariable(topVar, topValue);
}
/**
* Sets one key on a mutable container (Map or ObjectNode). Anything else —
* including POJOs, which are read-only for this bean — is rejected with a
* warning so a bad path never breaks the calling expression.
*/
@SuppressWarnings("unchecked")
private boolean setNestedValue(Object parent, String key, Object value, String path) {
if (parent instanceof Map map) {
@@ -268,20 +217,27 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
// Scope resolution
// -------------------------------------------------------------------------
private VariableScope getCurrentScope() {
VariableScope scope = SCOPE.get();
if (scope == null) {
LOGGER.error("{}.getCurrentScope called without an active Flowable scope", getClass().getName());
}
return scope;
}
/**
* Climbs from the given scope to the root VariableScope of the surrounding
* case/process hierarchy, following call-activity parents (BPMN) and
* parent/callback links (CMMN) across engine boundaries.
*/
private VariableScope getRootScope(VariableScope scope) {
if (scope instanceof DelegateExecution ex) return findBpmnRootScope(ex.getProcessInstanceId());
if (scope instanceof PlanItemInstance pii) return findCmmnRootScope(pii.getCaseInstanceId());
if (scope instanceof CaseInstance ci) return findCmmnRootScope(ci.getId());
return null;
}
/**
* Finds the root scope starting from a BPMN process instance:
* 1. started by a call activity → recurse into the calling process instance,
* 2. started from a CMMN plan item (callback) → continue climbing in the case,
* 3. otherwise this process instance is itself the root.
* NOTE: the returned object is a detached query result used as VariableScope;
* its variable access only works because expression evaluation runs inside an
* active Flowable command context.
*/
private VariableScope findBpmnRootScope(String processInstanceId) {
if (processInstanceId == null || runtimeService == null) return null;
try {
@@ -309,6 +265,13 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
}
}
/**
* Finds the root scope starting from a CMMN case instance:
* 1. sub-case → recurse into the parent case,
* 2. started from a BPMN call/task (callback) → continue climbing in the process,
* 3. otherwise this case instance is itself the root.
* Same detached-query-result caveat as {@link #findBpmnRootScope}.
*/
private VariableScope findCmmnRootScope(String caseInstanceId) {
if (caseInstanceId == null || cmmnRuntimeService == null) return null;
try {
@@ -331,7 +294,16 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
}
}
/**
* Resolves one path segment against a value: list/array index, map key,
* JsonNode field (scalars are unwrapped to plain Java values), and finally
* POJO access via getX()/isX() getters or a declared field.
*/
private static Object getNestedVariable(Object obj, String segment) {
// Empty segments (e.g. from "a..b" or a trailing dot) cannot address anything.
if (segment == null || segment.isEmpty()) return null;
// Lists and arrays are addressed by numeric index, e.g. "items.0.name".
if (obj instanceof List<?> list) {
try {
int i = Integer.parseInt(segment);
@@ -345,6 +317,9 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
} catch (NumberFormatException ignored) {}
}
if (obj instanceof Map<?, ?> map) return map.get(segment);
// JsonNode: unwrap scalar fields to plain Java values so they compare and
// serialize the same way as values read from Map/POJO variables.
if (obj instanceof JsonNode jn) {
JsonNode node = jn.get(segment);
if (node == null || node.isNull()) return null;
@@ -355,6 +330,9 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
if (node.isDouble()) return node.asDouble();
return node;
}
// Fallback for POJOs: try bean getters first, then a declared field
// (climbing the class hierarchy). Read-only — writes reject POJO parents.
String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1);
try {
return obj.getClass().getMethod("get" + cap).invoke(obj);
@@ -370,6 +348,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
return null;
}
/** Looks up a declared field by name, walking up the class hierarchy. */
private static java.lang.reflect.Field findField(Class<?> c, String name) {
while (c != null && c != Object.class) {
try { return c.getDeclaredField(name); }
@@ -382,6 +361,12 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
// Snapshot persistence
// -------------------------------------------------------------------------
/**
* Loads the previous-values snapshot: reads the variable at {@code path}
* (persisted as a JSON string by {@link #saveSnapshot}) and parses it back
* into a path → value-node map. Returns an empty map when there is no
* snapshot yet (first call) or it is unreadable.
*/
private Map<String, JsonNode> loadSnapshot(VariableScope scope, String path) {
Object raw = readVariableFromPath(scope, path);
if (raw == null) return new HashMap<>();
@@ -398,6 +383,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
}
}
/** Persists the snapshot map as a JSON string variable at {@code path}. */
private void saveSnapshot(VariableScope scope, String path, Map<String, JsonNode> snapshot) {
try {
writeVariableToPath(scope, path, MAPPER.writeValueAsString(snapshot));
@@ -410,9 +396,25 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
// Helpers
// -------------------------------------------------------------------------
/** Wraps any Java value into a JsonNode (null-safe). */
private static JsonNode toJson(Object value) {
if (value == null) return MAPPER.nullNode();
if (value instanceof JsonNode jn) return jn;
return MAPPER.valueToTree(value);
}
/**
* Serializes and re-parses a node so its value types match what
* {@link #loadSnapshot} produces when reading the persisted snapshot back
* (e.g. a Long 5 read from a variable and an Integer 5 parsed from the
* snapshot JSON both become the same numeric node). Without this, unchanged
* numeric values would be reported as changes on every call.
*/
private static JsonNode normalize(JsonNode node) {
try {
return MAPPER.readTree(MAPPER.writeValueAsString(node));
} catch (Exception e) {
return node;
}
}
}
@@ -34,8 +34,12 @@ flowable.platform.enable-latest-form-definition-lookup=true
# Server URL for REST calls
baseUrl=http://localhost:8105
flowable.security.oauth2.post-logout-redirect-url=http://localhost:8105
flowable.security.basic-auth.username=admin
flowable.security.basic-auth.password=test
# Email: Fake SMTP
flowable.mail.server.host=localhost
flowable.mail.server.port=2525
+1 -1
View File
@@ -26,7 +26,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>21</java.version>
<com.flowable.platform.version>2025.2.05</com.flowable.platform.version>
<com.flowable.platform.version>2025.2.07</com.flowable.platform.version>
<liquibase.skip>false</liquibase.skip>
</properties>