Статьи

Метапрограммирование во время компиляции в Groovy, часть 2

Как мы все знаем, если вы хотите быть крутым (или лучше сказать отличным) парнем в эти дни, вы должны начать играть с метапрограммированием во время компиляции. Вы хотите начать? Есть несколько мест, чтобы прочитать об этом. Вы можете купить Groovy в Action 2 и прочитать главу 9 или прочитать этот и предыдущие посты — пошаговые руководства. Лично я бы посоветовал вам сделать оба:

Метапрограммирование во время компиляции включает в себя написание специальных классов, которые расширяют Groovy-компилятор, позволяя вам преобразовывать абстрактное синтаксическое дерево вашей программы. Существует два типа преобразований AST: локальные и глобальные. Локальные преобразования могут быть применены к методам, классам, полям, используя обычные аннотации Java. Глобальные трансформации разные. Вы не указываете элемент, который вы хотите преобразовать явно. Вместо этого вы просто добавляете jar, содержащий скомпилированное глобальное преобразование и файл с некоторой метаинформацией Преобразование будет применено ко всем исходным модулям, которые вы будете компилировать. Быть неявным глобальным преобразованием может быть запутанным и опасным. Вы можете не знать, что используются глобальные преобразования. Кроме того, их сложнее проверить. Я бы посоветовал вам пойти с местным, если это возможно в вашем случае.

Я изо всех сил пытался создать идею трансформации для этого поста. Этот пример должен был быть более или менее полезным, и в то же время он должен быть максимально простым. То, что я решил написать, — это запись записи преобразования обо всех вызовах методов. Он будет хранить имя метода, все значения аргумента и отметку времени. Это очень просто, но не сложно расширить его, чтобы сделать его полезным инструментом.

Давайте начнем с написания тестов …

Написать тесты

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 — это вспомогательный класс, который может компилировать кусок кода с вашим преобразованием. CallRecoder — это класс, в котором мы храним всю информацию о вызовах методов:

class CallRecorder {

    static calls = []

    static synchronized record(String className, String methodName, Object ... args){
        calls << [className: className, method: methodName, args: args*.inspect(), date: new Date()]
    }
}

Еще один тест для выяснения случая, когда мы вызываем метод с аргументами:

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"
}

Создать объект спецификации

class CallRecorderTransformationSpecification {

    boolean shouldSkipTransformation(SourceUnit unit) {...} 
    void markUnitAsProcessed(SourceUnit unit){...}
}

Перемещение всей проверки в объект спецификации позволяет нам уменьшить количество кода поддержки в самом преобразовании. Я всегда советую вам сделать это. Даже если твоя трансформация выглядит тривиально. Обеспечение ясности намерений является высшим приоритетом, когда мы настраиваем сам компилятор.

Создать класс, реализующий 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())
    }
}

Я использую самую раннюю фазу компиляции здесь — КОНВЕРСИЯ. Мое понимание того, что происходит на каждом этапе компиляции, не является исчерпывающим, но я пытаюсь следовать этому очень простому правилу: «Если вы хотите манипулировать Groovy-кодом, используйте как можно более раннюю фазу. Если вы хотите манипулировать сгенерированным кодом Java (включая сгенерированные методы получения и установки), используйте самую последнюю возможную фазу ».

ASTTransformation — очень запутанный интерфейс, так как он используется как локальными, так и глобальными преобразованиями и используется по-разному. Локальные преобразования обращаются к AST с помощью astNodes. В то время как глобальные преобразования используют sourceUnit.ast.

Как видно из фрагмента кода выше, преобразование берет все методы из всех классов и добавляет к ним вызов метода CallRecorder.record.

Создать AstFactory

Я предпочитаю не создавать AST внутри моего класса преобразования, так как код становится грязным. Вместо этого я поместил всю черную магию в специальный класс 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
    }
}

Как видите, я использую AstBuilder.buildFromString для создания фрагмента AST, содержащего обязательный оператор. На мой взгляд, этот способ создания AST лучше для тех, кто еще не умеет писать преобразования AST. Вам не нужно изучать новый API. Единственное, что вам нужно сделать, это создать строку с небольшим количеством кода Groovy и передать ее сборщику.

Вот и все. Это преобразование может показаться слишком упрощенным, но оно показывает все фрагменты, которые вам придется написать, чтобы заставить его работать. Если вы чувствуете, что у вас есть силы, вы можете потратить некоторое время на гораздо более интересные вещи, такие как написание инструментов покрытия кода, профилировщиков и инструментов анализа кода. Глобальные преобразования идеально подходят для такого рода вещей.

GitHub Repository

 

С http://victorsavkin.com/post/4733504178/compile-time-metaprogramming-in-groovy-part-2