From 8e1a48d935b65f7ae0e3937a5201312e80dec49f Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Wed, 8 Mar 2017 15:07:57 +0100 Subject: [PATCH] Add experimental class building class (the 'carpenter'). This is intended for use in the serialisation framework. --- experimental/build.gradle | 5 +- .../net/corda/carpenter/ClassCarpenter.kt | 275 ++++++++++++++++++ .../net/corda/carpenter/ClassCarpenterTest.kt | 127 ++++++++ 3 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 experimental/src/main/kotlin/net/corda/carpenter/ClassCarpenter.kt create mode 100644 experimental/src/test/kotlin/net/corda/carpenter/ClassCarpenterTest.kt diff --git a/experimental/build.gradle b/experimental/build.gradle index 8c55819c4d..42acd2995d 100644 --- a/experimental/build.gradle +++ b/experimental/build.gradle @@ -3,8 +3,6 @@ version '1.0-SNAPSHOT' apply plugin: 'kotlin' -sourceCompatibility = 1.5 - repositories { mavenLocal() mavenCentral() @@ -28,6 +26,9 @@ dependencies { compile project(':core') compile project(':finance') + // ObjectWeb Asm: a library for synthesising and working with JVM bytecode. + compile "org.ow2.asm:asm:5.0.4" + testCompile "junit:junit:$junit_version" testCompile project(':test-utils') } diff --git a/experimental/src/main/kotlin/net/corda/carpenter/ClassCarpenter.kt b/experimental/src/main/kotlin/net/corda/carpenter/ClassCarpenter.kt new file mode 100644 index 0000000000..7641b9c6ea --- /dev/null +++ b/experimental/src/main/kotlin/net/corda/carpenter/ClassCarpenter.kt @@ -0,0 +1,275 @@ +package net.corda.carpenter + +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes.* +import org.objectweb.asm.Type +import java.lang.Character.* +import java.util.* + +/** + * Any object that implements this interface is expected to expose its own fields via the [get] method, exactly + * as if `this.class.getMethod("get" + name.capitalize()).invoke(this)` had been called. It is intended as a more + * convenient alternative to reflection. + */ +interface SimpleFieldAccess { + operator fun get(name: String): Any? +} + +/** + * A class carpenter generates JVM bytecodes for a class given a schema and then loads it into a sub-classloader. + * The generated classes have getters, a toString method and implement a simple property access interface. The + * resulting class can then be accessed via reflection APIs, or cast to one of the requested interfaces. + * + * Additional interfaces may be requested if they consist purely of get methods and the schema matches. + * + * # Discussion + * + * This class may initially appear pointless: why create a class at runtime that simply holds data and which + * you cannot compile against? The purpose is to enable the synthesis of data classes based on (AMQP) schemas + * when the app that originally defined them is not available on the classpath. Whilst the getters and setters + * are not usable directly, many existing reflection based frameworks like JSON/XML processors, Swing property + * editor sheets, Groovy and so on can work with the JavaBean ("POJO") format. Feeding these objects to such + * frameworks can often be useful. The generic property access interface is helpful if you want to write code + * that accesses these schemas but don't want to actually define/depend on the classes themselves. + * + * # Usage notes + * + * This class is not thread safe. + * + * The generated class has private final fields and getters for each field. The constructor has one parameter + * for each field. In this sense it is like a Kotlin data class. + * + * The generated class implements [SimpleFieldAccess]. The get method takes the name of the field, not the name + * of a getter i.e. use .get("someVar") not .get("getSomeVar") or in Kotlin you can use square brackets syntax. + * + * The generated class implements toString() using Google Guava to simplify formatting. Make sure it's on the + * classpath of the generated classes. + * + * Generated classes can refer to each other as long as they're defined in the right order. They can also + * inherit from each other. When inheritance is used the constructor requires parameters in order of superclasses + * first, child class last. + * + * You cannot create boxed primitive fields with this class: fields are always of primitive type. + * + * Nullability information is not emitted. + * + * Each [ClassCarpenter] defines its own classloader and thus, its own namespace. If you create multiple + * carpenters, you can load the same schema with the same name and get two different classes, whose objects + * will not be interoperable. + * + * Equals/hashCode methods are not yet supported. + */ +class ClassCarpenter { + // TODO: Array types. + // TODO: Generics. + // TODO: Sandbox the generated code when a security manager is in use. + // TODO: Generate equals/hashCode. + // TODO: Support annotations. + // TODO: isFoo getter patterns for booleans (this is what Kotlin generates) + + /** + * A Schema represents a desired class. + */ + class Schema(val name: String, fields: Map>, val superclass: Schema? = null, val interfaces: List> = emptyList()) { + val fields = LinkedHashMap(fields) // Fix the order up front if the user didn't. + val descriptors = fields.map { it.key to Type.getDescriptor(it.value) }.toMap() + + fun fieldsIncludingSuperclasses(): Map> = (superclass?.fieldsIncludingSuperclasses() ?: emptyMap()) + LinkedHashMap(fields) + fun descriptorsIncludingSuperclasses(): Map = (superclass?.descriptorsIncludingSuperclasses() ?: emptyMap()) + LinkedHashMap(descriptors) + } + + class DuplicateName : RuntimeException("An attempt was made to register two classes with the same name within the same ClassCarpenter namespace.") + class InterfaceMismatch(msg: String) : RuntimeException(msg) + + private class CarpenterClassLoader : ClassLoader(Thread.currentThread().contextClassLoader) { + fun load(name: String, bytes: ByteArray) = defineClass(name, bytes, 0, bytes.size) + } + private val classloader = CarpenterClassLoader() + + private val _loaded = HashMap>() + + /** Returns a snapshot of the currently loaded classes as a map of full class name (package names+dots) -> class object */ + val loaded: Map> = HashMap(_loaded) + + private val String.jvm: String get() = replace(".", "/") + + /** + * Generate bytecode for the given schema and load into the JVM. The returned class object can be used to + * construct instances of the generated class. + * + * @throws DuplicateName if the schema's name is already taken in this namespace (you can create a new ClassCarpenter if you're OK with ambiguous names) + */ + fun build(schema: Schema): Class<*> { + validateSchema(schema) + // Walk up the inheritance hierarchy and then start walking back down once we either hit the top, or + // find a class we haven't generated yet. + val hierarchy = ArrayList() + hierarchy += schema + var cursor = schema.superclass + while (cursor != null && cursor.name !in _loaded) { + hierarchy += cursor + cursor = cursor.superclass + } + hierarchy.reversed().forEach { generateClass(it) } + return _loaded[schema.name]!! + } + + private fun generateClass(schema: Schema): Class<*> { + val jvmName = schema.name.jvm + // Lazy: we could compute max locals/max stack ourselves, it'd be faster. + val cw = ClassWriter(ClassWriter.COMPUTE_FRAMES or ClassWriter.COMPUTE_MAXS) + with(cw) { + // public class Name implements SimpleFieldAccess { + val superName = schema.superclass?.name?.jvm ?: "java/lang/Object" + val interfaces = arrayOf(SimpleFieldAccess::class.java.name.jvm) + schema.interfaces.map { it.name.jvm } + visit(52, ACC_PUBLIC + ACC_SUPER, jvmName, null, superName, interfaces) + generateFields(schema) + generateConstructor(jvmName, schema) + generateGetters(jvmName, schema) + if (schema.superclass == null) + generateGetMethod() // From SimplePropertyAccess + generateToString(jvmName, schema) + visitEnd() + } + val clazz = classloader.load(schema.name, cw.toByteArray()) + _loaded[schema.name] = clazz + return clazz + } + + private fun ClassWriter.generateFields(schema: Schema) { + for ((name, desc) in schema.descriptors) { + visitField(ACC_PROTECTED + ACC_FINAL, name, desc, null, null).visitEnd() + } + } + + private fun ClassWriter.generateToString(jvmName: String, schema: Schema) { + val toStringHelper = "com/google/common/base/MoreObjects\$ToStringHelper" + with(visitMethod(ACC_PUBLIC, "toString", "()Ljava/lang/String;", "", null)) { + visitCode() + // com.google.common.base.MoreObjects.toStringHelper("TypeName") + visitLdcInsn(schema.name.split('.').last()) + visitMethodInsn(INVOKESTATIC, "com/google/common/base/MoreObjects", "toStringHelper", "(Ljava/lang/String;)L$toStringHelper;", false) + // Call the add() methods. + for ((name, type) in schema.fieldsIncludingSuperclasses().entries) { + visitLdcInsn(name) + visitVarInsn(ALOAD, 0) // this + visitFieldInsn(GETFIELD, jvmName, name, schema.descriptorsIncludingSuperclasses()[name]) + val desc = if (type.isPrimitive) schema.descriptors[name] else "Ljava/lang/Object;" + visitMethodInsn(INVOKEVIRTUAL, toStringHelper, "add", "(Ljava/lang/String;$desc)L$toStringHelper;", false) + } + // call toString() on the builder and return. + visitMethodInsn(INVOKEVIRTUAL, toStringHelper, "toString", "()Ljava/lang/String;", false) + visitInsn(ARETURN) + visitMaxs(0, 0) + visitEnd() + } + } + + private fun ClassWriter.generateGetMethod() { + val ourJvmName = ClassCarpenter::class.java.name.jvm + with(visitMethod(ACC_PUBLIC, "get", "(Ljava/lang/String;)Ljava/lang/Object;", null, null)) { + visitCode() + visitVarInsn(ALOAD, 0) // Load 'this' + visitVarInsn(ALOAD, 1) // Load the name argument + // Using this generic helper method is slow, as it relies on reflection. A faster way would be + // to use a tableswitch opcode, or just push back on the user and ask them to use actual reflection + // or MethodHandles (super fast reflection) to access the object instead. + visitMethodInsn(INVOKESTATIC, ourJvmName, "getField", "(Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object;", false) + visitInsn(ARETURN) + visitMaxs(0, 0) + visitEnd() + } + } + + private fun ClassWriter.generateGetters(jvmName: String, schema: Schema) { + for ((name, type) in schema.fields) { + val descriptor = schema.descriptors[name] + with(visitMethod(ACC_PUBLIC, "get" + name.capitalize(), "()" + descriptor, null, null)) { + visitCode() + visitVarInsn(ALOAD, 0) // Load 'this' + visitFieldInsn(GETFIELD, jvmName, name, descriptor) + when (type) { + java.lang.Boolean.TYPE, Integer.TYPE, java.lang.Short.TYPE, java.lang.Byte.TYPE, TYPE -> visitInsn(IRETURN) + java.lang.Long.TYPE -> visitInsn(LRETURN) + java.lang.Double.TYPE -> visitInsn(DRETURN) + java.lang.Float.TYPE -> visitInsn(FRETURN) + else -> visitInsn(ARETURN) + } + visitMaxs(0, 0) + visitEnd() + } + } + } + + private fun ClassWriter.generateConstructor(jvmName: String, schema: Schema) { + with(visitMethod(ACC_PUBLIC, "", "(" + schema.descriptorsIncludingSuperclasses().values.joinToString("") + ")V", null, null)) { + visitCode() + // Calculate the super call. + val superclassFields = schema.superclass?.fieldsIncludingSuperclasses() ?: emptyMap() + visitVarInsn(ALOAD, 0) + if (schema.superclass == null) { + visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "", "()V", false) + } else { + var slot = 1 + for (fieldType in superclassFields.values) + slot += load(slot, fieldType) + val superDesc = schema.superclass.descriptorsIncludingSuperclasses().values.joinToString("") + visitMethodInsn(INVOKESPECIAL, schema.superclass.name.jvm, "", "($superDesc)V", false) + } + // Assign the fields from parameters. + var slot = 1 + superclassFields.size + for ((name, type) in schema.fields.entries) { + if (type.isArray) + throw UnsupportedOperationException("Array types are not implemented yet") + visitVarInsn(ALOAD, 0) // Load 'this' onto the stack + slot += load(slot, type) // Load the contents of the parameter onto the stack. + visitFieldInsn(PUTFIELD, jvmName, name, schema.descriptors[name]) + } + visitInsn(RETURN) + visitMaxs(0, 0) + visitEnd() + } + } + + // Returns how many slots the given type takes up. + private fun MethodVisitor.load(slot: Int, type: Class): Int { + when (type) { + java.lang.Boolean.TYPE, Integer.TYPE, java.lang.Short.TYPE, java.lang.Byte.TYPE, TYPE -> visitVarInsn(ILOAD, slot) + java.lang.Long.TYPE -> visitVarInsn(LLOAD, slot) + java.lang.Double.TYPE -> visitVarInsn(DLOAD, slot) + java.lang.Float.TYPE -> visitVarInsn(FLOAD, slot) + else -> visitVarInsn(ALOAD, slot) + } + return when (type) { + java.lang.Long.TYPE, java.lang.Double.TYPE -> 2 + else -> 1 + } + } + + private fun validateSchema(schema: Schema) { + if (schema.name in _loaded) throw DuplicateName() + fun isJavaName(n: String) = n.isNotBlank() && isJavaIdentifierStart(n.first()) && n.all(::isJavaIdentifierPart) + require(isJavaName(schema.name.split(".").last())) { "Not a valid Java name: ${schema.name}" } + schema.fields.keys.forEach { require(isJavaName(it)) { "Not a valid Java name: $it" } } + // Now check each interface we've been asked to implement, as the JVM will unfortunately only catch the + // fact that we didn't implement the interface we said we would at the moment the missing method is + // actually called, which is a bit too dynamic for my tastes. + val allFields = schema.fieldsIncludingSuperclasses() + for (itf in schema.interfaces) { + for (method in itf.methods) { + val fieldNameFromItf = when { + method.name.startsWith("get") -> method.name.substring(3).decapitalize() + else -> throw InterfaceMismatch("Requested interfaces must consist only of methods that start with 'get': ${itf.name}.${method.name}") + } + if (fieldNameFromItf !in allFields) + throw InterfaceMismatch("Interface ${itf.name} requires a field named ${fieldNameFromItf} but that isn't found in the schema or any superclass schemas") + } + } + } + + companion object { + @JvmStatic @Suppress("UNUSED") + fun getField(obj: Any, name: String): Any? = obj.javaClass.getMethod("get" + name.capitalize()).invoke(obj) + } +} diff --git a/experimental/src/test/kotlin/net/corda/carpenter/ClassCarpenterTest.kt b/experimental/src/test/kotlin/net/corda/carpenter/ClassCarpenterTest.kt new file mode 100644 index 0000000000..fdc780bc48 --- /dev/null +++ b/experimental/src/test/kotlin/net/corda/carpenter/ClassCarpenterTest.kt @@ -0,0 +1,127 @@ +package net.corda.carpenter + +import org.junit.Test +import kotlin.test.assertEquals + + +class ClassCarpenterTest { + interface DummyInterface { + val a: String + val b: Int + } + + val cc = ClassCarpenter() + + @Test + fun empty() { + val clazz = cc.build(ClassCarpenter.Schema("gen.EmptyClass", emptyMap(), null)) + assertEquals(0, clazz.declaredFields.size) + assertEquals(2, clazz.declaredMethods.size) // get, toString + assertEquals(0, clazz.declaredConstructors[0].parameterCount) + clazz.newInstance() // just test there's no exception. + } + + @Test + fun prims() { + val clazz = cc.build(ClassCarpenter.Schema("gen.Prims", mapOf( + "anIntField" to Int::class.javaPrimitiveType!!, + "aLongField" to Long::class.javaPrimitiveType!!, + "someCharField" to Char::class.javaPrimitiveType!!, + "aShortField" to Short::class.javaPrimitiveType!!, + "doubleTrouble" to Double::class.javaPrimitiveType!!, + "floatMyBoat" to Float::class.javaPrimitiveType!!, + "byteMe" to Byte::class.javaPrimitiveType!!, + "booleanField" to Boolean::class.javaPrimitiveType!! + ))) + assertEquals(8, clazz.declaredFields.size) + assertEquals(8, clazz.declaredConstructors[0].parameterCount) + assertEquals(10, clazz.declaredMethods.size) + val i = clazz.constructors[0].newInstance(1, 2L, 'c', 4.toShort(), 1.23, 4.56F, 127.toByte(), true) + assertEquals(1, clazz.getMethod("getAnIntField").invoke(i)) + assertEquals(2L, clazz.getMethod("getALongField").invoke(i)) + assertEquals('c', clazz.getMethod("getSomeCharField").invoke(i)) + assertEquals(4.toShort(), clazz.getMethod("getAShortField").invoke(i)) + assertEquals(1.23, clazz.getMethod("getDoubleTrouble").invoke(i)) + assertEquals(4.56F, clazz.getMethod("getFloatMyBoat").invoke(i)) + assertEquals(127.toByte(), clazz.getMethod("getByteMe").invoke(i)) + assertEquals(true, clazz.getMethod("getBooleanField").invoke(i)) + + val sfa = i as SimpleFieldAccess + assertEquals(1, sfa["anIntField"]) + assertEquals(2L, sfa["aLongField"]) + assertEquals('c', sfa["someCharField"]) + assertEquals(4.toShort(), sfa["aShortField"]) + assertEquals(1.23, sfa["doubleTrouble"]) + assertEquals(4.56F, sfa["floatMyBoat"]) + assertEquals(127.toByte(), sfa["byteMe"]) + assertEquals(true, sfa["booleanField"]) + } + + private fun genPerson(): Pair, Any> { + val clazz = cc.build(ClassCarpenter.Schema("gen.Person", mapOf( + "age" to Int::class.javaPrimitiveType!!, + "name" to String::class.java + ))) + val i = clazz.constructors[0].newInstance(32, "Mike") + return Pair(clazz, i) + } + + @Test + fun objs() { + val (clazz, i) = genPerson() + assertEquals("Mike", clazz.getMethod("getName").invoke(i)) + assertEquals("Mike", (i as SimpleFieldAccess)["name"]) + } + + @Test + fun `generated toString`() { + val (clazz, i) = genPerson() + assertEquals("Person{age=32, name=Mike}", i.toString()) + } + + @Test(expected = ClassCarpenter.DuplicateName::class) + fun duplicates() { + cc.build(ClassCarpenter.Schema("gen.EmptyClass", emptyMap(), null)) + cc.build(ClassCarpenter.Schema("gen.EmptyClass", emptyMap(), null)) + } + + @Test + fun `can refer to each other`() { + val (clazz1, i) = genPerson() + val clazz2 = cc.build(ClassCarpenter.Schema("gen.Referee", mapOf( + "ref" to clazz1 + ))) + val i2 = clazz2.constructors[0].newInstance(i) + assertEquals(i, (i2 as SimpleFieldAccess)["ref"]) + } + + @Test + fun superclasses() { + val schema1 = ClassCarpenter.Schema("gen.A", mapOf("a" to String::class.java)) + val schema2 = ClassCarpenter.Schema("gen.B", mapOf("b" to String::class.java), schema1) + val clazz = cc.build(schema2) + val i = clazz.constructors[0].newInstance("xa", "xb") as SimpleFieldAccess + assertEquals("xa", i["a"]) + assertEquals("xb", i["b"]) + assertEquals("B{a=xa, b=xb}", i.toString()) + } + + @Test + fun interfaces() { + val schema1 = ClassCarpenter.Schema("gen.A", mapOf("a" to String::class.java)) + val schema2 = ClassCarpenter.Schema("gen.B", mapOf("b" to Int::class.java), schema1, interfaces = listOf(DummyInterface::class.java)) + val clazz = cc.build(schema2) + val i = clazz.constructors[0].newInstance("xa", 1) as DummyInterface + assertEquals("xa", i.a) + assertEquals(1, i.b) + } + + @Test(expected = ClassCarpenter.InterfaceMismatch::class) + fun `mismatched interface`() { + val schema1 = ClassCarpenter.Schema("gen.A", mapOf("a" to String::class.java)) + val schema2 = ClassCarpenter.Schema("gen.B", mapOf("c" to Int::class.java), schema1, interfaces = listOf(DummyInterface::class.java)) + val clazz = cc.build(schema2) + val i = clazz.constructors[0].newInstance("xa", 1) as DummyInterface + assertEquals(1, i.b) + } +} \ No newline at end of file