A significant amount of what Tapestry does is meta programming: code that modifies other code. Generally, we're talking about adding behavior to component classes, which are transformed as they are loaded into memory. The meta-programming is the code that sees all those annotations on methods and fields, and rebuilds the classes so that everything works at runtime.
Unlike AspectJ, Tapestry does all of its meta-programming at runtime. This fits in better with live class reloading, and also allows for loaded libraries to extend the meta-programming that's built-in to the framework.
All the facilities Tapestry has evolved to handle meta-programming make it easy to add new features. For example, I was doing some work with the Heartbeat enviromental object. Heartbeat allows you to schedule part of your behavior for "later". First off, why would you need this?
A simple example is the relationship between a Label component and a form control component such as TextField. In your template, you may use the two together:
<t:label for="email"/> <t:textfield t:id="email"/>
The for
parameter there is not a simple string, it is a component id. You can see that in the source for the Label component:
@Parameter(name = "for", required = true, allowNull = false, defaultPrefix = BindingConstants.COMPONENT) private Field field;
Why does for="email"
match agains the email component, and not some property of the page named email
? That's what the defaultPrefix
annotation attribute does: it says "pretend there's a component:
prefix on the binding unless the programmer supplies an explicit prefix."
So you'd think that would wrap it up, we just need to do the following in the Label code:
writer.element("label", "for", field.getClientId());
Right? Just ask the field for its client-side id and now all is happy.
Alas, that won't work. The Label component renders before the TextField, and the clientId
property is not set until the TextField renders. What we need to do is wait until they've both rendered, and then fill in the for
attribute after the fact.
That's where Heartbeat comes in. A Heartbeat represents a container such as a Loop or a Form. A Heartbeat starts, and accumulates deferred commands. When the Heartbeat ends, the deferred commands are executed. Also, Heartbeats can nest.
Using the Heartbeat, we can wait until the end of the current heartbeat after both the Label and the TextField have rendered and then get an accurate view of the field's client-side id. Since Tapestry renders a DOM (not a simple text stream) we can modify the Label's DOM Element after the fact.
Without the meta-programming, it looks like this:
@Environmental private Heartbeat heartbeat; private Element labelElement; boolean beginRender(MarkupWriter writer) { final Field field = this.field; decorator.beforeLabel(field); labelElement = writer.element("label"); resources.renderInformalParameters(writer); Runnable command = new Runnable() { public void run() { String fieldId = field.getClientId(); labelElement.forceAttributes("for", fieldId, "id", fieldId + "-label"); decorator.insideLabel(field, labelElement); } }; heartbeat.defer(command); return !ignoreBody; }
See, we've gotten the active Heartbeat instance for this request and we provide a command, as a Runnable. We capture the label's Element in an instance variable, and force the values of the for
(and id
) attributes. Notice all the steps: inject the Heartbeat environmental, create the Runnable, and pass it to defer()
.
So where does the meta-programming come in? Well, since Java doesn't have closures, it has a pattern of using component methods for the same function. Following that line of reasoning, we can replace the Runnable instance with a method call that has special semantics, triggered by an annotation:
private Element labelElement; boolean beginRender(MarkupWriter writer) { final Field field = this.field; decorator.beforeLabel(field); labelElement = writer.element("label"); resources.renderInformalParameters(writer); updateAttributes(); return !ignoreBody; } @HeartbeatDeferred private void updateAttributes() { String fieldId = field.getClientId(); labelElement.forceAttributes("for", fieldId, "id", fieldId + "-label"); decorator.insideLabel(field, labelElement); }
See what's gone on here? We invoke updateAttributes
, but because of this new annotation, @HeartbeatDeferred, the code doesn't execute immediately, it waits for the end of the current heartbeat.
What's more surprising is how little code is necessary to accomplish this. First, the new annotation:
@Target(ElementType.METHOD) @Retention(RUNTIME) @Documented @UseWith( { COMPONENT, MIXIN, PAGE }) public @interface HeartbeatDeferred { }
The @UseWith annotation is for documentation purposes only, to make it clear that this annotation is for use with components, pages and mixins ... but can't be expected to work elsewhere, such as in services layer objects.
Next we need the actual meta-programming code. Component meta-programming is accomplished by classes that implement the ComponentClassTransformationWorker interface.
public class HeartbeatDeferredWorker implements ComponentClassTransformWorker { private final Heartbeat heartbeat; private final ComponentMethodAdvice deferredAdvice = new ComponentMethodAdvice() { public void advise(final ComponentMethodInvocation invocation) { heartbeat.defer(new Runnable() { public void run() { invocation.proceed(); } }); } }; public HeartbeatDeferredWorker(Heartbeat heartbeat) { this.heartbeat = heartbeat; } public void transform(ClassTransformation transformation, MutableComponentModel model) { for (TransformMethod method : transformation.matchMethodsWithAnnotation(HeartbeatDeferred.class)) { deferMethodInvocations(method); } } void deferMethodInvocations(TransformMethod method) { validateVoid(method); validateNoCheckedExceptions(method); method.addAdvice(deferredAdvice); } private void validateNoCheckedExceptions(TransformMethod method) { if (method.getSignature().getExceptionTypes().length > 0) throw new RuntimeException( String .format( "Method %s is not compatible with the @HeartbeatDeferred annotation, as it throws checked exceptions.", method.getMethodIdentifier())); } private void validateVoid(TransformMethod method) { if (!method.getSignature().getReturnType().equals("void")) throw new RuntimeException(String.format( "Method %s is not compatible with the @HeartbeatDeferred annotation, as it is not a void method.", method.getMethodIdentifier())); } }
It all comes down to method advice. We can provide method advice that executes around the call to the annotated method.
When advice is triggered, it does not call invocation.proceed()
immediately, to continue on to the original method. Instead, it builds a Runnable command that it defers into the Heartbeat. When the command is executed, the invocation finally does proceed and the annotated method finally gets invoked.
That just leaves a bit of configuration code to wire this up. Tapestry uses a chain-of-command to identify all the different workers (theres more than a dozen built in) that get their chance to transform component classes. Since HeartbeatDeferredWorker is part of Tapestry, we need to extend contributeComponentClassTransformWorker()
in TapestryModule:
public static void contributeComponentClassTransformWorker( OrderedConfiguration<ComponentClassTransformWorker> configuration { ... configuration.addInstance("HeartbeatDeferred", HeartbeatDeferredWorker.class, "after:RenderPhase"); }
Meta-programming gives you the ability to change the semantics of Java programs and eliminate boiler-plate code while you're at it. Because Tapestry is a managed environment (it loads, transforms and instantiates the component classes) it is a great platform for meta-programming. Whether your concerns are security, caching, monitoring, parallelization or something else entirely, Tapestry gives you the facilities to you need to move Java from what it is to what you would like it to be.
1 comment:
Great example of meta programming and very clarifying about the role of Heartbeat as well
Post a Comment