Compile-Time Metaprogramming in Groovy, Part 2
Join the DZone community and get the full member experience.
Join For FreeAs we all know if you want to be a cool (or it’d better to say a groovy) guy these days you have to start playing with compile-time metaprogramming. Do you want to start? There are a few places to read about it. You can buy Groovy in Action 2 and read Chapter 9 or you can read this and previous posts - step by step tutorials. Personally, I’d advice you to do both:
Compile-time metaprogramming involves writing special classes that extend Groovy compiler allowing you to transform an abstract syntax tree of your program. There are two types of AST transformations: local and global. Local transformations can be applied to methods, classes, fields using regular Java annotations. Global transformations are different. You don’t specify an element you want to transform explicitly. Instead, you just add a jar containing a compiled global transformation and a file with some meta information. The transformation will be applied to all the source units you will compile. Being implicit global transformations can be confusing and dangerous. You may be unaware that global transformations are used. In addition, it is harder to test them. I’d advice you to go with local one if it’s possible in your case.
I was struggling trying to create an idea of a transformation for this post. The example should have been more or less useful and, at the same time, it should have been as simple as possible. What I’ve decided to write is a transformation recording information about all method calls. It will store a method name, all argument values and a time stamp. It’s very simple but it’s not hard to extend it to make it a useful tool.
Let’s start with writing tests…
Write tests
def 'should record a method call without arguments'() { setup: def transform = new CallRecorderTransformation() def clazz = new TransformTestHelper(transform, CONVERSION).parse ''' class Service { def method(){ } } ''' when: def service = clazz.newInstance() service.method() then: CallRecorder.calls.size() == 1 def recorderCall = CallRecorder.calls.first() recorderCall.className == 'Service' recorderCall.method == 'method' recorderCall.args.size() == 0 recorderCall.date != null }
TransformTestHelper is a helper class that can compile a chunk of code with your transformation. CallRecoder is a class where we store all the information about method calls:
class CallRecorder { static calls = [] static synchronized record(String className, String methodName, Object ... args){ calls << [className: className, method: methodName, args: args*.inspect(), date: new Date()] } }
One more test to clarify a case when we call a method with arguments:
def 'should record a method call with arguments'() { setup: def transform = new CallRecorderTransformation() def clazz = new TransformTestHelper(transform, CONVERSION).parse ''' class Service { def method(a,b){ } } ''' when: def service = clazz.newInstance() service.method(INT_ARG, STR_ARG) then: def recorderCall = CallRecorder.calls.first() recorderCall.args.size() == 2 recorderCall.args[0] == INT_ARG.inspect() recorderCall.args[1] == STR_ARG.inspect() where: INT_ARG = 1 STR_ARG = "aaa" }
Create a specification object
class CallRecorderTransformationSpecification { boolean shouldSkipTransformation(SourceUnit unit) {...} void markUnitAsProcessed(SourceUnit unit){...} }
Moving all validation to a specification object allows us to reduce the amount of support code in the transformation itself. I’d always advice you to do it. Even if your transformation looks trivial. Making the intent clearer is the highest priority when we adjust the compiler itself.
Create a class implementing ASTTransformation
@GroovyASTTransformation(phase = CONVERSION) class CallRecorderTransformation implements ASTTransformation{ private specification = new CallRecorderTransformationSpecification() void visit(ASTNode[] astNodes, SourceUnit sourceUnit) { if(specification.shouldSkipTransformation(sourceUnit)) return getAllMethodsInUnit(sourceUnit).each { addMethodCallTraceStatement it } specification.markUnitAsProcessed sourceUnit } private getAllMethodsInUnit(sourceUnit) { sourceUnit.ast.classes.methods.flatten() } private addMethodCallTraceStatement(method) { def ast = new CallRecorderAstFactory(method) def exprList = [ast.createStatement(), method.code] method.code = new BlockStatement(exprList, new VariableScope()) } }
I’m using the earliest possible compilation phase here - CONVERSION. My understanding of what happens during each compilation phase isn’t comprehensive but I’m trying to follow this very simple rule: “If you want to manipulate Groovy code use the earliest phase possible. If you want to manipulate generated Java code (including generated getters and setter) use the latest phase possible”.
ASTTransformation is a very confusing interface as it is used by both local and global transformations and it is used differently. Local transformations access AST using astNodes. Whereas global transformations use sourceUnit.ast.
As you can see from the chunk of code above the transformation takes all the methods from all the classes and adds CallRecorder.record method call to all of them.
Create an AstFactory
I prefer not to create AST inside my transformation class as the code gets messy. Instead, I put all the black magic inside a special class AstFactory:
@TupleConstructor(includeFields = true) class CallRecorderAstFactory { private MethodNode method Statement createStatement() { def className = method.declaringClass.nameWithoutPackage createCallRecordStatement className, method.name, getParameterNames(method) } private getParameterNames(method) { method.parameters.toList().name } private createCallRecordStatement(className, methodName, parameters) { def statement = createStringWithStatement(className, methodName, parameters) def ast = new AstBuilder().buildFromString CONVERSION, true, statement ast[0].statements[0] } private createStringWithStatement(className, methodName, parameters) { def res = "victorsavkin.sample2.CallRecorder.record '${className}', '${methodName}'" if(parameters){ res += ", ${parameters.join(',')}" } res } }
As you can see, I’m using AstBuilder.buildFromString to construct a piece of AST containing a required statement. In my view, this way of creating AST is better for those who aren’t proficient in writing AST transformation yet. You don’t have to learn a new API. The only thing you have to do is building a string with a chink of Groovy code and pass it to the builder.
That’s all. This transformation may look oversimplified, but it shows all the pieces you will have to write to make it work. If you feel that you have the power you can spend some time on much cooler stuff such as writing code coverage tools, profilers and code analysis tools. Global transformations are perfect for such kind of stuff.
From http://victorsavkin.com/post/4733504178/compile-time-metaprogramming-in-groovy-part-2
Opinions expressed by DZone contributors are their own.
Comments