Add experimental class building class (the 'carpenter').

This is intended for use in the serialisation framework.
This commit is contained in:
Mike Hearn 2017-03-08 15:07:57 +01:00
parent bbbc4d9eaa
commit 8e1a48d935
3 changed files with 405 additions and 2 deletions

View File

@ -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')
}

View File

@ -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<String, Class<out Any?>>, val superclass: Schema? = null, val interfaces: List<Class<*>> = 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<String, Class<out Any?>> = (superclass?.fieldsIncludingSuperclasses() ?: emptyMap()) + LinkedHashMap(fields)
fun descriptorsIncludingSuperclasses(): Map<String, String> = (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<String, Class<*>>()
/** Returns a snapshot of the currently loaded classes as a map of full class name (package names+dots) -> class object */
val loaded: Map<String, Class<*>> = 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<Schema>()
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, "<init>", "(" + 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", "<init>", "()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, "<init>", "($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<out Any?>): 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)
}
}

View File

@ -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<Class<*>, 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)
}
}