Nasdanika Graph module provides classes for visiting and processing graphs with two types of relationships between graph elements:
On the diagram below containment relationships are shown in bold black and connections in blue
Examples of such graphs:
The graph API has 3 interfaces:
<T> T accept(BiFunction<? super Element, Map<? extends Element, T>, T> visitor)
, which can be thought of as a hierarchical bottom-up reduce - the visitor function is invoked with an element being visited as its first argument and a map of element’s children to results returned by the visitor as the second argument. For leaf elements the second argument may be either an empty map or null. Depending on the map type used by implementations they may also need to implement equals()
and hashCode()
.Graph processing means associating some behavior with graph elements. That behavior (code execution) may modify the graph or perform other actions.
Examples of graph processing:
The above two examples may be combined - a documentation site might be generated from a system diagram. The diagram may be updated with statuses as part of the generation process and embedded to the home page. A click on a diagram element would navigate to an element documentation page, which may contain detailed status information pulled from tracking/monitoring systems during generation.
One form of graph processing is dispatching of graph elements to Java methods annotated with Handler annotation. The annotation takes a Spring boolean expression. Graph elements are passed to methods for which the expression is blank or evaluates to true.
Below is a code snippet from AliceBobHandlers class:
@Handler("getProperty('my-property') == 'xyz'")
public String bob(Node bob) {
System.out.println(bob.getLabel());
return bob.getLabel();
}
Below is a test method from TestDrawio.testDispatch() test method which dispatches to the above handler method:
Document document = Document.load(getClass().getResource("alice-bob.drawio"));
AliceBobHandlers aliceBobHandlers = new AliceBobHandlers();
Object result = document.dispatch(aliceBobHandlers);
System.out.println(result);
Dispatching is suitable for processing where processing logic for different graph elements does not need to access processing logic of other elements. An example of such logic would be updating diagram elements based on statuses retrieved from tracking/monitoring systems - each element is updated individually.
Processor package provides means for creating graph element processors and wiring them together so they can interact.
One area of where such functionality would be needed is executable diagrams. For example, a flow processor/simulator. Activity processors would need to pass control to connected activities via connection processors. Activity processors may also need to access facilities of their parent processors.
The below diagram shows interaction of two nodes via a connection. Connections are bi-directional - source processor may interact with the target processor and vice versa.
Some connections may be “pass-through” - just passing interactions without doing any processing. A pass-through connection is depicted below.
Graph element processors are wired together with handlers and endpoints:
An endpoint may be of the same type as a handler or a handler may be used as an endpoint. This might be the case if processing is performed sequentially in a single JVM.
Alternatively, an endpoint may be of different type than the handler it passes interactions to. For example:
Processors can also interact by looking up other processors in the processor registry. Endpoints are created by implementations
Processors are created in two steps:
createProcessor()
method. Client code creates processors by calling createProcessors()
method. This method return a registry - Map<Element,ProcessorInfo<P>>
. The registry allows the client code to interact with the handler/endpoint/processor wiring created from the graph.TestDrawio.testProcessor() method provides an example of using an anonymous implementation of NopEndpointProcessorFactory
for graph processing.
A good deal of processor creation logic is selection of a processor to create for a given graph element in a given situation/context and then “wiring” configuration to the processor. There are two processor factory classes and ReflectiveProcessorWirer class which make the selection/matching/wiring process easier.
ReflectiveProcessorFactoryProvider invokes methods annotated with Processor annotation to create processors.
SyncProcessorFactory is an example of reflective processor factory. Below is one of factory methods:
@Processor(
type = NodeAdapter.class,
value = "get() instanceof T(org.nasdanika.models.functionflow.FunctionFlow)")
public Object createFunctionFlowProcessor(
NodeProcessorConfig<?,?> config,
boolean parallel,
BiConsumer<Element,BiConsumer<ProcessorInfo<Object>,ProgressMonitor>> infoProvider,
Function<ProgressMonitor, Object> next,
ProgressMonitor progressMonitor) {
return new FunctionFlowProcessor();
}
CapabilityProcessorFactory uses the Nasdanika Capability Framework to delegate processor creation to capability factories. ReflectiveProcessorServiceFactory provides such a capability by collecting reflective targets from capability providers and then using ReflectiveProcessorFactoryProvider
mentioned above. This approach provides high level of decoupling between code which executes the graph and code which creates processors.
FunctionFlowTests executes a graph loaded from a Drawio diagram. It constructs a processor factory as shown below:
CapabilityLoader capabilityLoader = new CapabilityLoader();
CapabilityProcessorFactory<Object, BiFunction<Object, ProgressMonitor, Object>> processorFactory = new CapabilityProcessorFactory<Object, BiFunction<Object, ProgressMonitor, Object>>(
BiFunction.class,
BiFunction.class,
BiFunction.class,
null,
capabilityLoader);
SyncProcessorFactory
mentioned above is contributed by SyncCapabilityFactory:
@Override
public boolean canHandle(Object requirement) {
if (requirement instanceof ReflectiveProcessorFactoryProviderTargetRequirement) {
ReflectiveProcessorFactoryProviderTargetRequirement<?,?> targetRequirement = (ReflectiveProcessorFactoryProviderTargetRequirement<?,?>) requirement;
if (targetRequirement.processorType() == BiFunction.class) { // To account for generic parameters create a non-generic sub-interface binding those parameters.
ProcessorRequirement<?, ?> processorRequiremment = targetRequirement.processorRequirement();
if (processorRequiremment.handlerType() == BiFunction.class && processorRequiremment.endpointType() == BiFunction.class) {
return processorRequiremment.requirement() == null; // Customize if needed
}
}
}
return false;
}
@Override
public CompletionStage<Iterable<CapabilityProvider<Object>>> create(
ReflectiveProcessorFactoryProviderTargetRequirement<Object, BiFunction<Object, ProgressMonitor, Object>> requirement,
BiFunction<Object, ProgressMonitor, CompletionStage<Iterable<CapabilityProvider<Object>>>> resolver,
ProgressMonitor progressMonitor) {
return CompletableFuture.completedStage(Collections.singleton(CapabilityProvider.of(new SyncProcessorFactory())));
}
canHandle()
returns true if the factory can handle the requriement passed to it. create()
creates a new instance of SyncProcessorFactory
. Note, that create()
may request other capabilities. Say, an instsance of OpenAIClient to generate code using chat completions.
SyncCapabilityFactory
is registered in module-info.java:
exports org.nasdanika.models.functionflow.processors.targets.java.sync;
opens org.nasdanika.models.functionflow.processors.targets.java.sync to org.nasdanika.common; // For loading resources
provides CapabilityFactory with SyncCapabilityFactory;
Note that a package containing reflective factories and processors shall be opened to org.nasdanika.common
for reflection to work.
Processors created by the above factories are introspected for the following annotations:
java.util.function.Consumer
s of handlers.Element/Node/Connection configuration is declaratively “wired” to processors’ fields and methods. Configuration can also be wired imperatively. Declarative and imperative styles can be used together.
Below is an example of using @OutgoingEndpoint
annotation by StartProcessor:
public class StartProcessor implements BiFunction<Object, ProgressMonitor, Object> {
protected Collection<BiFunction<Object, ProgressMonitor, Object>> outgoingEndpoints = Collections.synchronizedCollection(new ArrayList<>());
@Override
public Object apply(Object arg, ProgressMonitor progressMonitor) {
Map<BiFunction<Object, ProgressMonitor, Object>, Object> outgoingEndpointsResults = new LinkedHashMap<>();
for (BiFunction<Object, ProgressMonitor, Object> e: outgoingEndpoints) {
outgoingEndpointsResults.put(e, e.apply(arg, progressMonitor));
}
return outgoingEndpointsResults;
}
@OutgoingEndpoint
public void addOutgoingEndpoint(BiFunction<Object, ProgressMonitor, Object> endpoint) {
outgoingEndpoints.add(endpoint);
}
}```