Initial commit
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
package com.customer.work.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.flowable.cmmn.engine.CmmnEngineConfiguration;
|
||||
import org.flowable.common.engine.api.delegate.event.FlowableEntityEvent;
|
||||
import org.flowable.common.engine.api.delegate.event.FlowableEvent;
|
||||
import org.flowable.common.engine.api.delegate.event.FlowableEventListener;
|
||||
import org.flowable.common.engine.api.variable.VariableContainer;
|
||||
import org.flowable.engine.ProcessEngineConfiguration;
|
||||
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;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
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 — the current execution / plan-item instance is captured
|
||||
* automatically via a Flowable event listener registered on both engines.
|
||||
*
|
||||
* Expression examples:
|
||||
* ${varUtils.get('order.customer.name')}
|
||||
* ${varUtils.track('order.customer.name,order.total,status')}
|
||||
*
|
||||
* How the scope is resolved without a parameter
|
||||
* -----------------------------------------------
|
||||
* Flowable fires ACTIVITY_STARTED (BPMN) or PLAN_ITEM_INSTANCE_STARTED (CMMN)
|
||||
* on the same thread — and strictly before — the service-task expression is
|
||||
* evaluated. onEvent() stores the current VariableContainer in a ThreadLocal.
|
||||
* The ThreadLocal is cleared when the activity / plan item completes or is
|
||||
* cancelled, so it never leaks across tasks.
|
||||
*
|
||||
* Registration
|
||||
* ------------
|
||||
* SmartInitializingSingleton.afterSingletonsInstantiated() runs after all
|
||||
* Spring beans (including engine configurations) are fully initialised, so
|
||||
* addEventListener() is always called on a live event dispatcher.
|
||||
* Only the engine configurations that are present in the application context
|
||||
* are registered; the other one is silently skipped.
|
||||
*/
|
||||
@Component("varUtils")
|
||||
public class VarUtils implements FlowableEventListener, SmartInitializingSingleton {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(VarUtils.class);
|
||||
private static final String SNAPSHOT_PREFIX = "__vartracker__";
|
||||
|
||||
private static final ThreadLocal<VariableContainer> SCOPE = new ThreadLocal<>();
|
||||
|
||||
private static final ObjectMapper MAPPER;
|
||||
static {
|
||||
MAPPER = new ObjectMapper();
|
||||
MAPPER.registerModule(new JavaTimeModule());
|
||||
MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
||||
}
|
||||
|
||||
@Autowired(required = false)
|
||||
private ProcessEngineConfiguration processEngineConfiguration;
|
||||
|
||||
@Autowired(required = false)
|
||||
private CmmnEngineConfiguration cmmnEngineConfiguration;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Listener registration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@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 FlowableEntityEvent entityEvent)) return;
|
||||
Object entity = entityEvent.getEntity();
|
||||
if (!(entity instanceof VariableContainer vc)) return;
|
||||
|
||||
String eventName = event.getType().name();
|
||||
LOGGER.info("eventName={}", eventName);
|
||||
|
||||
switch (eventName) {
|
||||
case "CASE_STARTED",
|
||||
"PROCESS_STARTED",
|
||||
"ACTIVITY_STARTED",
|
||||
"PLAN_ITEM_INSTANCE_STARTED" -> SCOPE.set(vc);
|
||||
case "CASE_ENDED",
|
||||
"PROCESS_COMPLETED",
|
||||
"ACTIVITY_COMPLETED",
|
||||
"ACTIVITY_CANCELLED",
|
||||
"PLAN_ITEM_INSTANCE_COMPLETED",
|
||||
"PLAN_ITEM_INSTANCE_TERMINATED",
|
||||
"PLAN_ITEM_INSTANCE_SUSPENDED" -> SCOPE.remove();
|
||||
default -> { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public boolean isFailOnException() { return false; }
|
||||
@Override public boolean isFireOnTransactionLifecycleEvent() { return false; }
|
||||
@Override public String getOnTransaction() { return null; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API — called from Flowable expressions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the value at the given dot-separated path within the current
|
||||
* variable scope. Supports Maps, Lists (integer index), Jackson JsonNodes,
|
||||
* and POJOs (getter or field). Returns null for any missing segment.
|
||||
*
|
||||
* ${varUtils.get('order.customer.name')}
|
||||
* ${varUtils.get('order.lines.0.price')}
|
||||
*/
|
||||
public Object get(String path) {
|
||||
VariableContainer scope = currentScope("get");
|
||||
if (scope == null || path == null || path.isBlank()) return null;
|
||||
return resolvePath(scope, path.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks which of the given comma-separated variable paths changed since the
|
||||
* last call and returns a JSON array describing each change.
|
||||
*
|
||||
* ${varUtils.track('order.customer.name,order.total,status')}
|
||||
*
|
||||
* Returns [] on the first call (baseline snapshot recorded).
|
||||
* Each subsequent call compares against the previous snapshot and updates it.
|
||||
* The snapshot is stored inside the variable scope so it survives across tasks.
|
||||
*
|
||||
* Example return value:
|
||||
* [{"path":"order.total","oldValue":100,"newValue":150}]
|
||||
*/
|
||||
public String track(String pathsCsv) {
|
||||
VariableContainer scope = currentScope("track");
|
||||
if (scope == null || pathsCsv == null || pathsCsv.isBlank()) return "[]";
|
||||
|
||||
List<String> paths = Arrays.stream(pathsCsv.split(","))
|
||||
.map(String::trim).filter(s -> !s.isEmpty()).toList();
|
||||
|
||||
String snapshotKey = SNAPSHOT_PREFIX + pathsCsv.replaceAll("\\s+", "");
|
||||
Map<String, JsonNode> previous = loadSnapshot(scope, snapshotKey);
|
||||
Map<String, JsonNode> current = new HashMap<>();
|
||||
ArrayNode changes = MAPPER.createArrayNode();
|
||||
|
||||
for (String path : paths) {
|
||||
JsonNode value = toJson(resolvePath(scope, path));
|
||||
current.put(path, value);
|
||||
|
||||
if (previous.isEmpty()) continue; // first call — baseline only
|
||||
|
||||
JsonNode prev = previous.getOrDefault(path, MAPPER.nullNode());
|
||||
if (!prev.equals(value)) {
|
||||
ObjectNode change = MAPPER.createObjectNode();
|
||||
change.put("path", path);
|
||||
change.set("oldValue", prev);
|
||||
change.set("newValue", value);
|
||||
changes.add(change);
|
||||
}
|
||||
}
|
||||
|
||||
saveSnapshot(scope, snapshotKey, current);
|
||||
|
||||
try {
|
||||
return MAPPER.writeValueAsString(changes);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("varUtils.track: failed to serialize changes", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Object resolvePath(VariableContainer scope, String path) {
|
||||
String[] segments = path.split("\\.", -1);
|
||||
Object current = scope.getVariable(segments[0]);
|
||||
for (int i = 1; i < segments.length; i++) {
|
||||
if (current == null) return null;
|
||||
current = step(current, segments[i]);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Object step(Object obj, String segment) {
|
||||
if (obj instanceof List<?> list) {
|
||||
try {
|
||||
int i = Integer.parseInt(segment);
|
||||
return i >= 0 && i < list.size() ? list.get(i) : null;
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
if (obj instanceof Object[] arr) {
|
||||
try {
|
||||
int i = Integer.parseInt(segment);
|
||||
return i >= 0 && i < arr.length ? arr[i] : null;
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
if (obj instanceof Map<?, ?> map) return map.get(segment);
|
||||
if (obj instanceof JsonNode jn) {
|
||||
JsonNode node = jn.get(segment);
|
||||
if (node == null || node.isNull()) return null;
|
||||
if (node.isTextual()) return node.asText();
|
||||
if (node.isBoolean()) return node.asBoolean();
|
||||
if (node.isLong()) return node.asLong();
|
||||
if (node.isInt()) return node.asInt();
|
||||
if (node.isDouble()) return node.asDouble();
|
||||
return node;
|
||||
}
|
||||
// POJO: try getter (getX / isX) then field
|
||||
String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1);
|
||||
try { return obj.getClass().getMethod("get" + cap).invoke(obj); } catch (Exception ignored) {}
|
||||
try { return obj.getClass().getMethod("is" + cap).invoke(obj); } catch (Exception ignored) {}
|
||||
try {
|
||||
java.lang.reflect.Field f = findField(obj.getClass(), segment);
|
||||
if (f != null) { f.setAccessible(true); return f.get(obj); }
|
||||
} catch (Exception ignored) {}
|
||||
LOGGER.warn("varUtils: cannot resolve '{}' on {}", segment, obj.getClass().getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
private static java.lang.reflect.Field findField(Class<?> c, String name) {
|
||||
while (c != null && c != Object.class) {
|
||||
try { return c.getDeclaredField(name); }
|
||||
catch (NoSuchFieldException e) { c = c.getSuperclass(); }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Snapshot persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Map<String, JsonNode> loadSnapshot(VariableContainer scope, String key) {
|
||||
Object raw = scope.getVariable(key);
|
||||
if (raw == null) return new HashMap<>();
|
||||
try {
|
||||
String json = raw instanceof String s ? s : MAPPER.writeValueAsString(raw);
|
||||
Map<String, Object> flat = MAPPER.readValue(json,
|
||||
MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
||||
Map<String, JsonNode> result = new HashMap<>();
|
||||
flat.forEach((k, v) -> result.put(k, toJson(v)));
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("varUtils: could not load snapshot '{}': {}", key, e.getMessage());
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private static void saveSnapshot(VariableContainer scope, String key, Map<String, JsonNode> snapshot) {
|
||||
try {
|
||||
scope.setVariable(key, MAPPER.writeValueAsString(snapshot));
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("varUtils: could not save snapshot '{}'", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private VariableContainer currentScope(String method) {
|
||||
VariableContainer scope = SCOPE.get();
|
||||
if (scope == null) {
|
||||
LOGGER.error("varUtils.{}() called without an active Flowable scope — "
|
||||
+ "is VarUtils registered on the engine event dispatcher?", method);
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
private static JsonNode toJson(Object value) {
|
||||
if (value == null) return MAPPER.nullNode();
|
||||
if (value instanceof JsonNode jn) return jn;
|
||||
return MAPPER.valueToTree(value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user