mirror of
https://github.com/corda/corda.git
synced 2025-02-21 09:51:57 +00:00
Add experimental class building class (the 'carpenter').
This is intended for use in the serialisation framework.
This commit is contained in:
parent
bbbc4d9eaa
commit
8e1a48d935
@ -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')
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user