diff --git a/grails-encoder/build.gradle b/grails-encoder/build.gradle index 28d635bde1d..90f2d601d66 100644 --- a/grails-encoder/build.gradle +++ b/grails-encoder/build.gradle @@ -39,6 +39,7 @@ dependencies { implementation platform(project(':grails-bom')) api project(':grails-core') + implementation 'com.github.ben-manes.caffeine:caffeine' api 'org.apache.groovy:groovy' api 'org.apache.groovy:groovy-json' api 'org.springframework:spring-web', { @@ -63,4 +64,11 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') -} \ No newline at end of file +} + +tasks.withType(Test).configureEach { + systemProperties System.properties.findAll { key, value -> key.toString().startsWith('grails.codec.benchmark.') } + if (Boolean.getBoolean('grails.codec.benchmark.enabled')) { + testLogging.showStandardStreams = true + } +} diff --git a/grails-encoder/src/main/groovy/org/grails/encoder/CodecMetaClassSupport.groovy b/grails-encoder/src/main/groovy/org/grails/encoder/CodecMetaClassSupport.groovy index f304048bc99..1ba93d76b11 100644 --- a/grails-encoder/src/main/groovy/org/grails/encoder/CodecMetaClassSupport.groovy +++ b/grails-encoder/src/main/groovy/org/grails/encoder/CodecMetaClassSupport.groovy @@ -18,6 +18,11 @@ */ package org.grails.encoder +import java.util.concurrent.ConcurrentHashMap + +import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine + import groovy.transform.CompileStatic import org.codehaus.groovy.runtime.GStringImpl import org.codehaus.groovy.runtime.NullObject @@ -38,6 +43,9 @@ class CodecMetaClassSupport { static final Object[] EMPTY_ARGS = [] static final String ENCODE_AS_PREFIX = 'encodeAs' static final String DECODE_PREFIX = 'decode' + private static final Cache> REGISTERED_META_METHODS = Caffeine.newBuilder() + .weakKeys() + .build() /** * Adds "encodeAs*" and "decode*" metamethods for given codecClass @@ -101,14 +109,14 @@ class CodecMetaClassSupport { } } - addMetaMethod(targetMetaClasses, encodeMethodName, encoderClosure) + addMetaMethod(targetMetaClasses, encodeMethodName, encoderClosure, cacheLookup, codecFactory) if (codecFactory.encoder) { - addAliasMetaMethods(targetMetaClasses, codecFactory.encoder.codecIdentifier.codecAliases, encodeMethodNameClosure, encoderClosure) + addAliasMetaMethods(targetMetaClasses, codecFactory.encoder.codecIdentifier.codecAliases, encodeMethodNameClosure, encoderClosure, cacheLookup, codecFactory) } - addMetaMethod(targetMetaClasses, decodeMethodName, decoderClosure) + addMetaMethod(targetMetaClasses, decodeMethodName, decoderClosure, cacheLookup, codecFactory) if (codecFactory.decoder) { - addAliasMetaMethods(targetMetaClasses, codecFactory.decoder.codecIdentifier.codecAliases, decodeMethodNameClosure, decoderClosure) + addAliasMetaMethods(targetMetaClasses, codecFactory.decoder.codecIdentifier.codecAliases, decodeMethodNameClosure, decoderClosure, cacheLookup, codecFactory) } } @@ -130,12 +138,18 @@ class CodecMetaClassSupport { @CompileStatic private addAliasMetaMethods(List targetMetaClasses, Set aliases, Closure methodNameClosure, Closure methodClosure) { + addAliasMetaMethods(targetMetaClasses, aliases, methodNameClosure, methodClosure, false, null) + } + + @CompileStatic + private addAliasMetaMethods(List targetMetaClasses, Set aliases, Closure methodNameClosure, Closure methodClosure, + boolean cacheLookup, CodecFactory codecFactory) { aliases?.each { String aliasName -> - addMetaMethod(targetMetaClasses, methodNameClosure(aliasName), methodClosure) + addMetaMethod(targetMetaClasses, methodNameClosure(aliasName), methodClosure, cacheLookup, codecFactory) } } - private String resolveCodecName(CodecFactory codecFactory) { + private static String resolveCodecName(CodecFactory codecFactory) { codecFactory.encoder?.codecIdentifier?.codecName ?: codecFactory.decoder?.codecIdentifier?.codecName } @@ -152,8 +166,71 @@ class CodecMetaClassSupport { } protected void addMetaMethod(List targetMetaClasses, String methodName, Closure closure) { + addMetaMethod(targetMetaClasses, methodName, closure, false, null) + } + + protected void addMetaMethod(List targetMetaClasses, String methodName, Closure closure, boolean cacheLookup, CodecFactory codecFactory) { targetMetaClasses.each { ExpandoMetaClass emc -> - emc."${methodName}" << closure + if (!cacheLookup) { + emc."${methodName}" << closure + } + else { + synchronized (emc) { + if (shouldRegisterMetaMethod(emc, methodName, codecFactory)) { + emc."${methodName}" << closure + } + } + } + } + } + + @CompileStatic + private static boolean shouldRegisterMetaMethod(ExpandoMetaClass emc, String methodName, CodecFactory codecFactory) { + MetaMethodRegistrationKey key = registrationKey(emc, methodName) + registeredMetaMethodKeys(codecFactory).add(key) || emc.getMetaMethod(methodName, EMPTY_ARGS) == null + } + + @CompileStatic + private static MetaMethodRegistrationKey registrationKey(ExpandoMetaClass emc, String methodName) { + new MetaMethodRegistrationKey(emc.getTheClass().getName(), methodName) + } + + @CompileStatic + private static Set registeredMetaMethodKeys(CodecFactory codecFactory) { + REGISTERED_META_METHODS.get(codecFactory) { CodecFactory ignored -> + Collections.newSetFromMap(new ConcurrentHashMap()) + } + } + + @CompileStatic + private static class MetaMethodRegistrationKey { + + private final String targetClassName + private final String methodName + + MetaMethodRegistrationKey(String targetClassName, String methodName) { + this.targetClassName = targetClassName + this.methodName = methodName + } + + @Override + boolean equals(Object other) { + if (this.is(other)) { + return true + } + if (!(other instanceof MetaMethodRegistrationKey)) { + return false + } + + MetaMethodRegistrationKey otherKey = (MetaMethodRegistrationKey) other + targetClassName == otherKey.targetClassName && + methodName == otherKey.methodName + } + + @Override + int hashCode() { + int result = targetClassName.hashCode() + 31 * result + methodName.hashCode() } } } diff --git a/grails-encoder/src/test/groovy/org/grails/encoder/CodecMetaClassBenchmarkSpec.groovy b/grails-encoder/src/test/groovy/org/grails/encoder/CodecMetaClassBenchmarkSpec.groovy new file mode 100644 index 00000000000..fdcb25cef6a --- /dev/null +++ b/grails-encoder/src/test/groovy/org/grails/encoder/CodecMetaClassBenchmarkSpec.groovy @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.encoder + +import java.util.concurrent.atomic.AtomicInteger + +import groovy.lang.MetaClassRegistryChangeEventListener +import groovy.transform.CompileStatic +import groovy.transform.TypeCheckingMode +import org.codehaus.groovy.runtime.InvokerHelper + +import grails.util.GrailsMetaClassUtils +import spock.lang.IgnoreIf +import spock.lang.Specification + +@IgnoreIf({ !Boolean.getBoolean('grails.codec.benchmark.enabled') }) +class CodecMetaClassBenchmarkSpec extends Specification { + + private static final int REGISTRATION_ITERATIONS = Integer.getInteger('grails.codec.benchmark.registrationIterations', 10_000) + private static final int ENCODE_ITERATIONS = Integer.getInteger('grails.codec.benchmark.encodeIterations', 1_000_000) + private static final int ENCODE_WARMUP_ITERATIONS = Integer.getInteger('grails.codec.benchmark.encodeWarmupIterations', 100_000) + private static final boolean NEW_FACTORY_EACH_REGISTRATION = Boolean.getBoolean('grails.codec.benchmark.newFactoryEachRegistration') + + void 'benchmark codec metaclass registration'() { + expect: + runBenchmark() + } + + @CompileStatic + private static boolean runBenchmark() { + GroovySystem.metaClassRegistry.removeMetaClass(CodecBenchmarkTarget) + CodecMetaClassSupport support = new CodecMetaClassSupport() + BenchmarkCodecFactory codecFactory = new BenchmarkCodecFactory() + List targetMetaClasses = [GrailsMetaClassUtils.getExpandoMetaClass(CodecBenchmarkTarget)] + AtomicInteger changeEvents = new AtomicInteger() + MetaClassRegistryChangeEventListener listener = { changeEvents.incrementAndGet() } as MetaClassRegistryChangeEventListener + GroovySystem.metaClassRegistry.addMetaClassRegistryChangeEventListener(listener) + try { + long registrationStart = System.nanoTime() + for (int i = 0; i < REGISTRATION_ITERATIONS; i++) { + CodecFactory registrationFactory = NEW_FACTORY_EACH_REGISTRATION ? new BenchmarkCodecFactory() : codecFactory + support.configureCodecMethods(registrationFactory, true, targetMetaClasses) + } + long registrationNanos = System.nanoTime() - registrationStart + + CodecBenchmarkTarget target = new CodecBenchmarkTarget('a&bd') + for (int i = 0; i < ENCODE_WARMUP_ITERATIONS; i++) { + encode(target) + } + long encodeStart = System.nanoTime() + Object encoded = null + for (int i = 0; i < ENCODE_ITERATIONS; i++) { + encoded = encode(target) + } + long encodeNanos = System.nanoTime() - encodeStart + + println "registrationIterations=${REGISTRATION_ITERATIONS}" + println "newFactoryEachRegistration=${NEW_FACTORY_EACH_REGISTRATION}" + println "registrationNanos=${registrationNanos}" + println "registrationNanosPerOp=${registrationNanos / REGISTRATION_ITERATIONS}" + println "metaClassChangeEvents=${changeEvents.get()}" + println "encodeAsBenchmarkMetaMethods=${countEncodeAsBenchmarkMetaMethods(targetMetaClasses)}" + println "encodeIterations=${ENCODE_ITERATIONS}" + println "encodeNanos=${encodeNanos}" + println "encodeNanosPerOp=${encodeNanos / ENCODE_ITERATIONS}" + println "lastEncoded=${encoded}" + true + } finally { + GroovySystem.metaClassRegistry.removeMetaClassRegistryChangeEventListener(listener) + GroovySystem.metaClassRegistry.removeMetaClass(CodecBenchmarkTarget) + } + } + + @CompileStatic(TypeCheckingMode.SKIP) + private static Object encode(CodecBenchmarkTarget target) { + InvokerHelper.invokeMethod(target, 'encodeAsBenchmark', null) + } + + @CompileStatic(TypeCheckingMode.SKIP) + private static int countEncodeAsBenchmarkMetaMethods(List targetMetaClasses) { + targetMetaClasses.sum { ExpandoMetaClass emc -> + emc.metaMethods.count { it.name == 'encodeAsBenchmark' } + } as int + } + + private static class CodecBenchmarkTarget { + + private final String value + + CodecBenchmarkTarget(String value) { + this.value = value + } + + @Override + String toString() { + value + } + } + + private static class BenchmarkCodecFactory implements CodecFactory { + + private final BenchmarkEncoder encoder = new BenchmarkEncoder() + + @Override + Encoder getEncoder() { + encoder + } + + @Override + Decoder getDecoder() { + null + } + } + + private static class BenchmarkEncoder implements Encoder { + + private final CodecIdentifier codecIdentifier = new DefaultCodecIdentifier('Benchmark') + + @Override + CodecIdentifier getCodecIdentifier() { + codecIdentifier + } + + @Override + Object encode(Object o) { + o?.toString()?.replace('&', '&')?.replace('<', '<')?.replace('>', '>') + } + + @Override + void markEncoded(CharSequence string) { + } + + @Override + boolean isSafe() { + true + } + + @Override + boolean isApplyToSafelyEncoded() { + false + } + } +} diff --git a/grails-encoder/src/test/groovy/org/grails/encoder/CodecMetaClassSupportSpec.groovy b/grails-encoder/src/test/groovy/org/grails/encoder/CodecMetaClassSupportSpec.groovy new file mode 100644 index 00000000000..23538093fb7 --- /dev/null +++ b/grails-encoder/src/test/groovy/org/grails/encoder/CodecMetaClassSupportSpec.groovy @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.encoder + +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit + +import org.codehaus.groovy.runtime.InvokerHelper + +import grails.util.GrailsMetaClassUtils +import spock.lang.Specification + +class CodecMetaClassSupportSpec extends Specification { + + void cleanup() { + GroovySystem.metaClassRegistry.removeMetaClass(CodecMetaClassSupportSpecTarget) + } + + void 'cached codec registration is idempotent for the same codec factory'() { + given: + CodecMetaClassSupport support = new CodecMetaClassSupport() + BenchmarkCodecFactory codecFactory = new BenchmarkCodecFactory() + List targetMetaClasses = [GrailsMetaClassUtils.getExpandoMetaClass(CodecMetaClassSupportSpecTarget)] + + when: + support.configureCodecMethods(codecFactory, true, targetMetaClasses) + Object firstResult = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBenchmark', null) + codecFactory.encoder = new BenchmarkEncoder('|') + support.configureCodecMethods(codecFactory, true, targetMetaClasses) + Object secondResult = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBenchmark', null) + + then: + firstResult == 'a&b' + secondResult == 'a&b' + } + + void 'cached codec registration is idempotent for decoder and aliases'() { + given: + CodecMetaClassSupport support = new CodecMetaClassSupport() + BenchmarkCodecFactory codecFactory = new BenchmarkCodecFactory() + List targetMetaClasses = [GrailsMetaClassUtils.getExpandoMetaClass(CodecMetaClassSupportSpecTarget)] + + when: + support.configureCodecMethods(codecFactory, true, targetMetaClasses) + Object encodedAlias = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBench', null) + Object decoded = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'decodeBenchmark', null) + Object decodedAlias = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'decodeBench', null) + support.configureCodecMethods(codecFactory, true, targetMetaClasses) + + then: + encodedAlias == 'a&b' + decoded == 'a&b' + decodedAlias == 'a&b' + } + + void 'cached codec registration is atomic for concurrent same factory registration'() { + given: + CodecMetaClassSupport support = new CodecMetaClassSupport() + BenchmarkCodecFactory codecFactory = new BenchmarkCodecFactory() + List targetMetaClasses = [GrailsMetaClassUtils.getExpandoMetaClass(CodecMetaClassSupportSpecTarget)] + CountDownLatch start = new CountDownLatch(1) + ExecutorService executor = Executors.newFixedThreadPool(8) + List> futures = (1..8).collect { + executor.submit({ + start.await() + support.configureCodecMethods(codecFactory, true, targetMetaClasses) + null + } as Callable) + } + + when: + start.countDown() + futures*.get(10L, TimeUnit.SECONDS) + + then: + InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBenchmark', null) == 'a&b' + InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'decodeBenchmark', null) == 'a&b' + + cleanup: + executor.shutdownNow() + } + + void 'cached codec registration re-adds methods after metaclass replacement'() { + given: + CodecMetaClassSupport support = new CodecMetaClassSupport() + BenchmarkCodecFactory codecFactory = new BenchmarkCodecFactory() + + when: + support.configureCodecMethods(codecFactory, true, [GrailsMetaClassUtils.getExpandoMetaClass(CodecMetaClassSupportSpecTarget)]) + Object firstResult = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBenchmark', null) + GroovySystem.metaClassRegistry.removeMetaClass(CodecMetaClassSupportSpecTarget) + support.configureCodecMethods(codecFactory, true, [GrailsMetaClassUtils.getExpandoMetaClass(CodecMetaClassSupportSpecTarget)]) + Object secondResult = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBenchmark', null) + + then: + firstResult == 'a&b' + secondResult == 'a&b' + } + + void 'cached codec registration keeps distinct codec factories isolated'() { + given: + CodecMetaClassSupport support = new CodecMetaClassSupport() + List targetMetaClasses = [GrailsMetaClassUtils.getExpandoMetaClass(CodecMetaClassSupportSpecTarget)] + + when: + support.configureCodecMethods(new BenchmarkCodecFactory('&'), true, targetMetaClasses) + Object firstResult = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBenchmark', null) + support.configureCodecMethods(new BenchmarkCodecFactory('|'), true, targetMetaClasses) + Object secondResult = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBenchmark', null) + + then: + firstResult == 'a&b' + secondResult == 'a|b' + } + + void 'cached codec registration remains correct after unrelated factory churn'() { + given: + CodecMetaClassSupport support = new CodecMetaClassSupport() + List targetMetaClasses = [GrailsMetaClassUtils.getExpandoMetaClass(CodecMetaClassSupportSpecTarget)] + BenchmarkCodecFactory originalCodecFactory = new BenchmarkCodecFactory() + + when: + support.configureCodecMethods(originalCodecFactory, true, targetMetaClasses) + (1..1500).each { + support.configureCodecMethods(new EncoderOnlyCodecFactory(), true, targetMetaClasses) + } + support.configureCodecMethods(originalCodecFactory, true, targetMetaClasses) + + then: + InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBenchmark', null) == 'a&b' + } + + void 'non-cached codec registration keeps development reload behavior'() { + given: + CodecMetaClassSupport support = new CodecMetaClassSupport() + BenchmarkCodecFactory codecFactory = new BenchmarkCodecFactory() + List targetMetaClasses = [GrailsMetaClassUtils.getExpandoMetaClass(CodecMetaClassSupportSpecTarget)] + + when: + support.configureCodecMethods(codecFactory, false, targetMetaClasses) + Object firstResult = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBenchmark', null) + codecFactory.encoder = new BenchmarkEncoder('|') + support.configureCodecMethods(codecFactory, false, targetMetaClasses) + Object secondResult = InvokerHelper.invokeMethod(new CodecMetaClassSupportSpecTarget('a&b'), 'encodeAsBenchmark', null) + + then: + firstResult == 'a&b' + secondResult == 'a|b' + } + + private static class CodecMetaClassSupportSpecTarget { + + private final String value + + CodecMetaClassSupportSpecTarget(String value) { + this.value = value + } + + @Override + String toString() { + value + } + } + + private static class BenchmarkCodecFactory implements CodecFactory { + + private BenchmarkEncoder encoder + private final BenchmarkDecoder decoder = new BenchmarkDecoder() + + BenchmarkCodecFactory(String ampersandReplacement = '&') { + this.encoder = new BenchmarkEncoder(ampersandReplacement) + } + + @Override + Encoder getEncoder() { + encoder + } + + @Override + Decoder getDecoder() { + decoder + } + } + + private static class EncoderOnlyCodecFactory implements CodecFactory { + + private final BenchmarkEncoder encoder = new BenchmarkEncoder() + + @Override + Encoder getEncoder() { + encoder + } + + @Override + Decoder getDecoder() { + null + } + } + + private static class BenchmarkEncoder implements Encoder { + + private final CodecIdentifier codecIdentifier = new DefaultCodecIdentifier('Benchmark', 'Bench') + private final String ampersandReplacement + + BenchmarkEncoder(String ampersandReplacement = '&') { + this.ampersandReplacement = ampersandReplacement + } + + @Override + CodecIdentifier getCodecIdentifier() { + codecIdentifier + } + + @Override + Object encode(Object o) { + o?.toString()?.replace('&', ampersandReplacement) + } + + @Override + void markEncoded(CharSequence string) { + } + + @Override + boolean isSafe() { + true + } + + @Override + boolean isApplyToSafelyEncoded() { + false + } + } + + private static class BenchmarkDecoder implements Decoder { + + private final CodecIdentifier codecIdentifier = new DefaultCodecIdentifier('Benchmark', 'Bench') + + @Override + CodecIdentifier getCodecIdentifier() { + codecIdentifier + } + + @Override + Object decode(Object o) { + o?.toString()?.replace('&', '&') + } + } +} diff --git a/grails-test-suite-uber/src/test/groovy/org/grails/commons/DefaultGrailsCodecClassTests.groovy b/grails-test-suite-uber/src/test/groovy/org/grails/commons/DefaultGrailsCodecClassTests.groovy index eea6cba1f62..9fed69dd5e8 100644 --- a/grails-test-suite-uber/src/test/groovy/org/grails/commons/DefaultGrailsCodecClassTests.groovy +++ b/grails-test-suite-uber/src/test/groovy/org/grails/commons/DefaultGrailsCodecClassTests.groovy @@ -18,6 +18,8 @@ */ package org.grails.commons +import org.codehaus.groovy.runtime.GStringImpl +import org.codehaus.groovy.runtime.InvokerHelper import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -37,6 +39,9 @@ class DefaultGrailsCodecClassTests { @AfterEach protected void tearDown() { + [String, GStringImpl, StringBuffer, StringBuilder, Object].each { Class targetClass -> + GroovySystem.metaClassRegistry.removeMetaClass(targetClass) + } ExpandoMetaClass.disableGlobally() } @@ -55,6 +60,17 @@ class DefaultGrailsCodecClassTests { assertEquals "encoded", codecClass.encoder.encode("stuff") assertEquals "decoded", codecClass.decoder.decode("stuff") } + + @Test + void testConfigureCodecMethodsRegistersDynamicMethodsThroughPublicCodecClass() { + def codecClass = new DefaultGrailsCodecClass(CodecWithClosuresCodec) + + codecClass.configureCodecMethods() + codecClass.configureCodecMethods() + + assertEquals 'encoded', InvokerHelper.invokeMethod('stuff', 'encodeAsCodecWithClosures', null) + assertEquals 'decoded', InvokerHelper.invokeMethod('stuff', 'decodeCodecWithClosures', null) + } } class CodecWithClosuresCodec {