Initial commit
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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.engine.delegate.DelegateExecution;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Flowable Spring Boot component for tracking process variable changes across calls.
|
||||
*
|
||||
* Usage in Flowable expression:
|
||||
* ${variableTracker.track(execution, 'order.customer.name,order.total,status')}
|
||||
*
|
||||
* Returns a JSON string with an array of changed variables, each entry containing:
|
||||
* { "path": "order.customer.name", "oldValue": "Alice", "newValue": "Bob" }
|
||||
*
|
||||
* State (the snapshot of previous values) is stored as a process variable keyed by
|
||||
* SNAPSHOT_VAR_PREFIX + the provided paths string, so independent track() calls
|
||||
* with different path sets do not interfere with each other.
|
||||
*/
|
||||
@Component("variableTracker")
|
||||
public class VariableTrackerComponent {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(VariableTrackerComponent.class);
|
||||
private static final String SNAPSHOT_VAR_PREFIX = "__vartracker__";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public VariableTrackerComponent() {
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
this.objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks which of the given variable paths changed since the last call and returns
|
||||
* a JSON array describing each change.
|
||||
*
|
||||
* @param execution the current Flowable execution context
|
||||
* @param pathsCsv comma-separated variable paths, e.g. "order.total,status,user.address.city"
|
||||
* @return JSON string — an array of { path, oldValue, newValue } objects for every changed path
|
||||
*/
|
||||
public String track(DelegateExecution execution, String pathsCsv) {
|
||||
if (pathsCsv == null || pathsCsv.isBlank()) {
|
||||
return "[]";
|
||||
}
|
||||
|
||||
List<String> paths = Arrays.stream(pathsCsv.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
|
||||
String snapshotKey = SNAPSHOT_VAR_PREFIX + pathsCsv.replaceAll("\\s+", "");
|
||||
Map<String, JsonNode> previousSnapshot = loadSnapshot(execution, snapshotKey);
|
||||
Map<String, JsonNode> currentSnapshot = new HashMap<>();
|
||||
|
||||
ArrayNode changes = objectMapper.createArrayNode();
|
||||
|
||||
for (String path : paths) {
|
||||
JsonNode currentValue = resolvePathToJson(execution, path);
|
||||
currentSnapshot.put(path, currentValue);
|
||||
|
||||
if (previousSnapshot.isEmpty()) {
|
||||
// First call — record baseline, no changes reported
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode previousValue = previousSnapshot.getOrDefault(path, objectMapper.nullNode());
|
||||
if (!jsonEquals(previousValue, currentValue)) {
|
||||
ObjectNode change = objectMapper.createObjectNode();
|
||||
change.put("path", path);
|
||||
change.set("oldValue", previousValue);
|
||||
change.set("newValue", currentValue);
|
||||
changes.add(change);
|
||||
}
|
||||
}
|
||||
|
||||
saveSnapshot(execution, snapshotKey, currentSnapshot);
|
||||
|
||||
try {
|
||||
return objectMapper.writeValueAsString(changes);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to serialize changes to JSON", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Variable path resolution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves a dot-separated path against the execution's variables and returns
|
||||
* the result as a JsonNode. The first segment is the top-level process variable
|
||||
* name; subsequent segments navigate into the object graph.
|
||||
*/
|
||||
private JsonNode resolvePathToJson(DelegateExecution execution, String path) {
|
||||
String[] segments = path.split("\\.", -1);
|
||||
Object current = execution.getVariable(segments[0]);
|
||||
|
||||
for (int i = 1; i < segments.length; i++) {
|
||||
if (current == null) {
|
||||
return objectMapper.nullNode();
|
||||
}
|
||||
current = getProperty(current, segments[i]);
|
||||
}
|
||||
|
||||
return toJsonNode(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a named property from an object. Supports Maps, Jackson ObjectNodes,
|
||||
* and plain Java objects (via public getter or public field).
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Object getProperty(Object obj, String property) {
|
||||
if (obj instanceof Map<?, ?> map) {
|
||||
return map.get(property);
|
||||
}
|
||||
if (obj instanceof ObjectNode on) {
|
||||
JsonNode node = on.get(property);
|
||||
return node != null ? node : null;
|
||||
}
|
||||
if (obj instanceof JsonNode jn) {
|
||||
JsonNode node = jn.get(property);
|
||||
return node != null ? node : null;
|
||||
}
|
||||
// Reflection: try getter first, then field
|
||||
try {
|
||||
String getter = "get" + Character.toUpperCase(property.charAt(0)) + property.substring(1);
|
||||
Method method = obj.getClass().getMethod(getter);
|
||||
return method.invoke(obj);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
String getter = "is" + Character.toUpperCase(property.charAt(0)) + property.substring(1);
|
||||
Method method = obj.getClass().getMethod(getter);
|
||||
return method.invoke(obj);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
java.lang.reflect.Field field = findField(obj.getClass(), property);
|
||||
if (field != null) {
|
||||
field.setAccessible(true);
|
||||
return field.get(obj);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
LOGGER.warn("Could not resolve property '{}' on {}", property, obj.getClass().getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
private java.lang.reflect.Field findField(Class<?> clazz, String name) {
|
||||
while (clazz != null && clazz != Object.class) {
|
||||
try {
|
||||
return clazz.getDeclaredField(name);
|
||||
} catch (NoSuchFieldException e) {
|
||||
clazz = clazz.getSuperclass();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Snapshot persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, JsonNode> loadSnapshot(DelegateExecution execution, String key) {
|
||||
Object raw = execution.getVariable(key);
|
||||
if (raw == null) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
try {
|
||||
String json = raw instanceof String s ? s : objectMapper.writeValueAsString(raw);
|
||||
Map<String, Object> flat = objectMapper.readValue(json,
|
||||
objectMapper.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
||||
Map<String, JsonNode> result = new HashMap<>();
|
||||
for (Map.Entry<String, Object> entry : flat.entrySet()) {
|
||||
result.put(entry.getKey(), toJsonNode(entry.getValue()));
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Could not load variable tracker snapshot for key '{}': {}", key, e.getMessage());
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private void saveSnapshot(DelegateExecution execution, String key, Map<String, JsonNode> snapshot) {
|
||||
try {
|
||||
execution.setVariable(key, objectMapper.writeValueAsString(snapshot));
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Could not save variable tracker snapshot for key '{}'", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private JsonNode toJsonNode(Object value) {
|
||||
if (value == null) {
|
||||
return objectMapper.nullNode();
|
||||
}
|
||||
if (value instanceof JsonNode jn) {
|
||||
return jn;
|
||||
}
|
||||
return objectMapper.valueToTree(value);
|
||||
}
|
||||
|
||||
private boolean jsonEquals(JsonNode a, JsonNode b) {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
return a.equals(b);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user