In Camunda Service Tasks can be implemented using so-called delegate expressions, which, in the context of CDI, resolve to @Named
annotated beans.
This task class has to implement the JavaDelegate
interface, which mandates a method named execute
, which receives a single argument with a DelegateExecution
instance. However there is the Dependency Inversion Principle (DIP), and this way our business code directly depends on two classes from Camunda. Can’t we do better?
What about providing an abstraction over the variable scopes and an interface of our own, tagging all our service classes? And afterward have implementation that binds these interfaces.
First of all, Camunda has a hard-coded instanceof
check, asserting the invoked Bean actually implements the interface like this:
Object delegate = expression.getValue(execution);
// ...
} else if (delegate instanceof JavaDelegate) {
Context.getProcessEngineConfiguration()
.getDelegateInterceptor()
.handleInvocation(new JavaDelegateInvocation((JavaDelegate) delegate, execution));
}
// ...
executeWithErrorPropagation(execution, callable);
(see here)
… so it’s not as simple as just leaving it away.
My next thought was to provide a @AroundConstruct
interceptor. However these do not allow to replace (or rather wrap) the constructed object. You delegate the call, upon the inner-most delegate call the object is created and then passed upwards. You may invoke methods on the newly created object, but there’s no API to replace it.
Enter CDI Extensions
The plan is after all simple
take the
@Named
service task implementations, register them with CDI, but do not actually register them under the namecreate proxy classes, that implement
JavaDelegate
, register these under the original namewhen that proxy is actually invoked, create an instance of the actual implementation and delegate to it
Observing ProcessAnnotatedType
Let’s assume our service tasks now implement the AcmeTask
interface, then we can observe on ProcessAnnotatedType
and check for instances of it like so:
<T> void processAnnotatedType(@Observes final ProcessAnnotatedType<T> pat) {
if (AcmeTask.class.isAssignableFrom( pat.getAnnotatedType().getJavaClass() ) {
log.info("Processing annotated type " + pat.getAnnotatedType().getJavaClass());
pat.setAnnotatedType(new AnnotatedTypeShadowNamedWrapper<>(pat.getAnnotatedType()));
// pat.veto(); // Prevent CDI from registering it
}
}
With pat.veto()
we could keep the bean from being registered at all. Yet this is not what we want, since in the end we still want to be able to instantiate these beans later on – and profit from the dependency injection functionality into these.
Instead we just hide the @Named
annotation from pat
. The AnnotatedTypeShadowNamedWrapper
is a lightweight wrapper, delegating all calls to the wrapped object, … just denying existence of that single annotation like so:
public static class AnnotatedTypeShadowNamedWrapper<T> implements AnnotatedType<T> {
private final AnnotatedType<T> delegate;
public AnnotatedTypeShadowNamedWrapper(final AnnotatedType<T> delegate) {
this.delegate = delegate;
}
@Override
public <A extends Annotation> A getAnnotation(final Class<A> annotationType) {
if (annotationType == Named.class) {
return null;
}
return delegate.getAnnotation(annotationType);
}
@Override
public Set<Annotation> getAnnotations() {
final Set<Annotation> annotations = new HashSet<>(delegate.getAnnotations());
annotations.removeIf(a -> a.annotationType().equals(Named.class));
return annotations;
}
@Override
public boolean isAnnotationPresent(final Class<? extends Annotation> annotationType) {
if (annotationType == Named.class) {
return false;
}
return delegate.isAnnotationPresent(annotationType);
}
// everything else is just passed through ...
}
Creating the Proxy Beans
Now that we’ve hidden the Named beans, we need to provide suitable replacements, that implement JavaDelegate
and are named.
To achieve this, we need to add some bookkeeping to the observer method above, that tracks the registered name and class instance. Next we can observe the AfterBeanDiscovery
event, which provides a means to register extra beans (one for each task), declaring its type, name and scope:
void afterBeanDiscovery(@Observes final AfterBeanDiscovery abd, final BeanManager bm) {
abd.addBean(new Bean<JavaDelegate>() {
@Override
public Class<?> getBeanClass() {
return JavaDelegate.class;
}
@Override
public Set<Annotation> getQualifiers() {
return Set.of(NamedLiteral.of("taskValidateStuff"));
}
@Override
public String getName() {
return "taskValidateStuff";
}
@Override
public Class<? extends Annotation> getScope() {
return Dependent.class;
}
… next we need to implement the create
method, which must create an instance of JavaDelegate
, every time the bean manager is asked for one. Since JavaDelegate
is an interface, we can just use good old Proxy.newProxyInstance
to get an instance and provide an InvocationHandler
that waits for the execute
call and delegates to our AcmeTask
. There we can also add a wrapper abstracting process engine access.
@SneakyThrows
@Override
public JavaDelegate create(final CreationalContext<JavaDelegate> ctx) {
final var baseInstance = (AcmeTask) bm.getReference(bm.resolve(bm.getBeans(clazz)), clazz, ctx);
return (JavaDelegate) Proxy.newProxyInstance(clazz.getClassLoader(),
new Class[] { JavaDelegate.class },
(InvocationHandler) (proxy, method, args) -> {
log.info("Intercepted method: " + method.getName());
if ("execute".equals(method.getName())) {
// args[0] has the DelegateExecution
baseInstance.execute( new DelegateExecutionWrapper( args[0] ));
}
return null;
});
}