Компилируем Kotlin в Runtime

    Привет, хабр!


    Я думаю, что зачастую вы видели задачи, которые могли бы быть с легкостью решены, если генерировать и выполнять код сразу в рантайме. Этот подход может облегчить жизнь, когда есть желание оптимизировать место с помощью кодогенерации. Однако из-за того, что вы поставляете библиотеку, готовый код станет вам известен только при запуске приложения (чтобы процедура сборки не усложнялась).


    В этой статье я хочу показать способ генерировать код в процессе выполнения программы, а потом сразу же запускать его. По сути, мы будем использовать компилятор Kotlin для jsr233.


    В качестве задачи можно взять относительно популярную вещь — мы сделаем некоторое подобие AOP для разбора ответов от базы данных. Таким образом, программист сможет разметить атрибутами свой код, а мы сгенерим и скомпилируем рабочий оптимизированный код сразу в runtime. Однако на самом деле, область применения подобной тактики намного шире: можно делать программируемую конфигурацию, можно оптимизировать существующий код (за счет замены условий на заранее подсчитанные значения, которые уже не изменятся). Ну и, конечно же, можно избежать копипаста даже в тех случаях, когда выразительности языка недостаточно, чтобы выделить обобщенный кусок кода.


    Весь код доступен на GitHub, для запуска необходима Java 11. В статье я привожу сокращенные варианты кода, без логов, диагностики и т.д.


    Задача


    Прежде всего: если вы хотите решить именно эту задачу самостоятельно у себя в проекте, то это будет, скорее всего, велосипедом. Сначала лучше всего присмотреться к Hibernate или Spring Data, которые уже умеют всё это делать.


    Что хочется получить: возможность обвесить класс аттрибутами таким образом, чтобы «некий преобразователь результата из SQL в наш класс» смог бы вытащить результаты из запроса.


    Как пример таких аттрибутов:


    data class DbUser(
        @SqlMapping(columnName = "name")
        val name: UserName,
        @SqlMapping(columnName1 = "user_email_name", columnName2 = "user_email_domain")
        val email: Email
    )

    Как известно, чтобы разобрать ответ из базы, в Spring JDBC рекомендуется использовать ResultSet.


    Если вытаскивать строку из колонки, в интерфейсе есть как минимум два метода:


    String getString(int columnIndex) throws SQLException;
    
    String getString(String columnLabel) throws SQLException;

    А потому усложним задачу:


    1. Для большого набора данных обращение к колонкам должно быть строго по индексу (из-за скорости: для того, чтобы вытащить значение по имени колонки, необходимо сначала сравнить строки, а только потом забрать значение по индексу, следовательно работа с функцией getString(String columnLabel) требует NxM ненужных сравнений строк как минимум, где N/M — число строк/столбцов соответственно).
    2. Ради сохранения более-менее высокой скорости работы нельзя использовать reflection. Отсюда следствие — необходимо обойтись без BeanPropertyRowMapper или аналогов, которые, по сути, работают со скоростью динамического кода.
    3. Свойства могут быть не только примитивами, вроде String, Int, но и со сложными типами. Например, c самописным NonEmptyText (у класса одно поле String, которое не может быть пустым)

    Как я уже говорил, хотелось бы выделить решение в библиотеку, а значит в момент компиляции мы не можем знать обо всех классах. А хотелось бы делать разбор ответа из базы в стиле:


    fun extractData(rs: ResultSet): List<DbUser> {
          val queryMetadata = rs.metaData
          val queryColumnCount = queryMetadata.columnCount
          val mapperColumnCount = 3
    
          require(queryColumnCount == mapperColumnCount)
    
          val columnIndex0 = rs.findColumn("name")
          val columnIndex1 = rs.findColumn("user_email_name")
          val columnIndex2 = rs.findColumn("user_email_domain")
          val result = mutableListOf<DbUser>()
          while (rs.next()) {
              result.add(
                  DbUser(
                      name = UserName(rs.getValue(columnIndex0)),
                      email = Email(EmailName(rs.getValue(columnIndex1)), EmailDomain(rs.getValue(columnIndex2)))
                  )
              )
          }
          return result
       }
    }

    Еще раз напоминаю: не стоит это решение сразу использовать в своем проекте. Даже если у вас есть задача разбора значений из базы, даже если есть желание работать напрямую с Spring JDBC, полученного результата можно добиться без кодогенерации, выразительности языков Java/Kotlin полностью хватает для этого. Домашнее задание — по-быстрому представить, как это сделать (естественно, без reflection и т.д.).


    Выполняем Kotlin Script


    Сейчас есть два наиболее простых способа скомпилировать Kotlin в процессе выполнения: это использовать напрямую Kotlin Compiler или же воспользоваться оберткой для jsr233. Первый способ позволяет компилировать сразу несколько файлов, имеет больше возможностей для расширения, однако он немного сложнее. Второй способ позволяет просто выполнить один файл, добавив типов в текущий Class Loader. Очевидно, что он не до конца безопасный, то есть запускать необходимо только доверенный код (кстати, Kotlin Script Compiler наоборот запускает код в отдельном Class Loader'е, правда, при настройках по-умолчанию ему никто не мешает запустить подпроцесс с произвольным кодом).


    Перво-наперво определимся с интерфейсом. Не стоит генерировать новый код на каждый Sql запрос, поэтому сгенерируем его для каждого объекта, который мы будем читать из базы. Как вариант, интерфейс может быть:


    interface ResultSetMapper<TMappingType> : ResultSetExtractor<List<TMappingType>>
    
    interface DynamicResultSetMapperFactory {
        fun <TMappingType : Any> createForType(clazz: KClass<TMappingType>): ResultSetMapper<TMappingType>
    }
    
    inline fun <reified TMappingType : Any> DynamicResultSetMapperFactory.createForType(): ResultSetMapper<TMappingType> {
        return createForType(TMappingType::class)
    }

    inline метод необходим для того, чтобы иметь возможность работать с методом так, как будто это настоящий generic. С помощью такого дополнения создавать ResultSetMapper можно в виде кода: return mapperFactory.createMapper<MyClass>().


    ResultSetMapper наследует стандартный интерфейс из Spring:


    @FunctionalInterface
    public interface ResultSetExtractor<T> {
        @Nullable
        T extractData(ResultSet rs) throws SQLException, DataAccessException;
    }

    Реализация фабрики отвечает за то, чтобы по аннотациям класса сгенерировать код, а потом отправить его в компилятор. Получаем исходник следующего рода:


    override fun <TMappingType : Any> createForType(clazz: KClass<TMappingType>): ResultSetMapper<TMappingType> {
        val sourceCode = getMapperSourceCode(clazz) // генерит код для компиляции
    
        return compiler.compile(sourceCode) // компилирует
    }

    Нам необходимо вернуть ResultSetMapper<TMappingType>. Желательно, чтобы результирующий тип был не generic'ом и содержал все необходимые типы. Тогда у JVM будет больше знаний о коде, а значит и больше возможных оптимизаций. И, следовательно, компилировать мы будет код вида:


    object : ResultSetMapper<DbUser> { // объект-синглтон, реализующий интерфейс
       override fun extractData(rs: java.sql.ResultSet): List<DbUser> {
          /* generated code */
       }
    }

    Для реализации компилятора нам необходимы три вещи:


    1. Добавить необходимые зависимости в classpath
    2. Передать информацию в Java, как следует скомпилировать скрипт
    3. С помощью ScriptEngineManager выполнить код (то есть вернуть готовый объект для работы с Sql)

    Для первого пункта добавляем следующие зависимости в gradle:


    implementation(kotlin("reflect"))
    implementation(kotlin("script-runtime"))
    implementation(kotlin("compiler-embeddable"))
    implementation(kotlin("script-util"))
    implementation(kotlin("scripting-compiler-embeddable"))

    Для второго пункта необходимо добавить файл "src/main/resources/META-INF/services/javax.script.ScriptEngineFactory" cо следующим содержимым:


    org.jetbrains.kotlin.script.jsr223.KotlinJsr223JvmLocalScriptEngineFactory

    Теперь остался последний пункт — запускать скрипт в runtime:


    fun <TResult> compile(sourceCode: String): TResult {
        val scriptEngine = ScriptEngineManager()
    
        val factory = scriptEngine.getEngineByExtension("kts").factory // JVM знает, что для расширения kts необходимо использовать KotlinJsr223JvmLocalScriptEngineFactory
    
        val engine = factory.scriptEngine as KotlinJsr223JvmLocalScriptEngine
    
        @Suppress("UNCHECKED_CAST")
        return engine.eval(sourceCode) as TResult
    }

    Подготавливаем модель


    Как я описал выше, мы хотим усложнить себе задачу и генерировать код не только для примитивных типов, но и для самописных. А значит, сначала лучше разобраться с моделью данных.


    Представим, что мы стараемся писать строго типизированный код, который постарается не допускать некорректных данных как можно раньше. Следовательно:


    1. Вместо поля userName: String у нас будет userName: UserName, где класс UserName содержит лишь одно значение.
    2. UserName не может быть пустым, а значит необходимо проверить это значение на входе.
    3. Подобных классов может быть много, а значит, логично было бы выделить подобную логику в общее место.

    Как один из способов, реализовать подобное поведение можно следующим образом:


    Создаем класс NonEmptyText, который содержит необходимое поле и проверки в конструкторе:


    abstract class NonEmptyText(val value: String) {
        init {
            require(value.isNotBlank()) {
                "Empty text is prohibited for ${this.javaClass.simpleName}. Actual value: $this"
            }
        }
    
        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (javaClass != other?.javaClass) return false
    
            other as NonEmptyText
    
            if (value != other.value) return false
    
            return true
        }
    
        override fun hashCode(): Int {
            return value.hashCode()
        }
    
        override fun toString(): String {
            return value
        }
    }

    Создаем также способ создания этого класса:


    interface NonEmptyTextConstructor<out TResult : NonEmptyText> {
        fun create(value: String): TResult
    }

    С помощью этих примитивов мы можем приступить к классу UserName:


    class UserName(value: String) : NonEmptyText(value) {
        companion object : NonEmptyTextConstructor<UserName> {
            override fun create(value: String) = UserName(value)
        }
    }

    Итак, здесь у нас есть UserName, который строго типизирован. И заодно companion object содержит в себе еще способ создания экземпляров этого типа, т.е. кроме конструктора, создать UserName можно теперь как:


    UserName.create("123")

    Теперь мы можем передавать конструктор типа в другие функции, то есть для метода вида fun <TValue> createText(input: String?, constructor: NonEmptyTextConstructor<TValue>): TValue? вызов будет createText("123", UserName), что довольно интуитивно. В некотором смысле, это похоже на type classes, однако с учетом возможностей JVM.


    Email мы определим следующим образом:


    class EmailUser(value: String) : NonEmptyText(value) {
        companion object : NonEmptyTextConstructor<EmailUser> {
            override fun create(value: String) = EmailUser(value)
        }
    }
    
    class EmailDomain(value: String) : NonEmptyText(value) {
        companion object : NonEmptyTextConstructor<EmailDomain> {
            override fun create(value: String) = EmailDomain(value)
        }
    }
    
    data class Email(val user: EmailUser, val domain: EmailDomain)

    Разделение его на два типа довольно искусственно, чаще всего в реальной жизни это не имеет смысла. В статье основной смысл подобного в том, чтобы читать из базы данных один объект (то есть — электронную почту), который хранится в двух колонках. Далеко не каждый ORM такое умеет.


    Далее создаем тип пользователя. Именно его мы будем получать из базы данных:


    data class DbUser(val name: UserName, val email: Email)

    Для того, чтобы сгенерировать код работы с базой данных, необходимо:


    1. Определить, в каких колонках находятся какие значения. То есть для поля name необходимо определить имя одной колонки.
    2. Для поля email необходимо определить имена двух колонок.
    3. Определить как-то способ чтения данных из базы (мы ведь понимаем, что даже строки можно читать по-разному).

    Если у нас есть соотношение «одна колонка — один тип», то чтение данных из базы можно выразить в виде простого интерфейса:


    interface SingleValueMapper<out TValue> {
        fun getValue(resultSet: ResultSet, columnIndex: Int): TValue
    }

    Таким образом, в процессе разбора результата запроса можно сделать следующее:


    1. Один раз узнать, какой индекс отвечает за какую колонку
    2. Для каждой строчки — необходимо вызвать getValue для каждой ячейки.
    3. Для каждой строчки — собрать объект из результатов пункта "2".

    Как я уже говорил выше, допустим, что в проекте много типов, которые представляются как «непустая строка». А значит для них можно сделать общий mapper:


    abstract class NonEmptyTextValueMapper<out TResult : NonEmptyText>(
            private val textConstructor: NonEmptyTextConstructor<TResult>
    ) : SingleValueMapper<TResult> {
        override fun getValue(resultSet: ResultSet, columnIndex: Int): TResult {
            return textConstructor.create(resultSet.getString(columnIndex))
        }
    }

    Как видно, мы как бы передали конструктор внутрь этого класса. Дальше мы можем сравнительно легко и просто штамповать уже mapper'ы для конкретных классов:


    object UserNameMapper : NonEmptyTextValueMapper<UserName>(UserName) // этот объект может преобразовать значение из колонки в UserName

    К сожалению, в Kotlin я не придумал, как выразить mapper в виде метода-расширения, то есть extension type. В Scala этого можно добиться с помощью implicit, хоть они и не самые явные.


    Однако, как я уже говорил выше, у нас есть сложный тип — Email. И он требует две колонки. А значит интерфейс выше для него не подходит. Как вариант — можно создать еще один:


    interface DoubleValuesMapper<out TValue> {
        fun getValue(resultSet: ResultSet, columnIndex1: Int, columnIndex2: Int): TValue
    }

    Здесь на вход приходят две колонки, однако результирующий объект только один. Это как раз то, что нам и надо, хотя таких интерфейсов придется нагенерить на каждую разную численность параметров.


    Теперь можно сделать комбинированный mapper, который будет что-то вроде:


    abstract class TwoMappersValueMapper<out TResult, TParameter1, TParameter2>(
            private val parameterMapper1: SingleValueMapper<TParameter1>,
            private val parameterMapper2: SingleValueMapper<TParameter2>
    ) : DoubleValuesMapper<TResult> {
        override fun getValue(resultSet: ResultSet, columnIndex1: Int, columnIndex2: Int): TResult {
            return create(
                    parameterMapper1.getValue(resultSet, columnIndex1),
                    parameterMapper2.getValue(resultSet, columnIndex2)
            )
        }
    
        abstract fun create(parameter1: TParameter1, parameter2: TParameter2): TResult
    }

    И далее Email можно читать следующим образом:


    object EmailUserMapper : NonEmptyTextValueMapper<EmailUser>(EmailUser)
    object EmailDomainMapper : NonEmptyTextValueMapper<EmailDomain>(EmailDomain)
    
    object EmailMapper : TwoMappersValueMapper<Email, EmailUser, EmailDomain>(EmailUserMapper, EmailDomainMapper) {
        override fun create(parameter1: EmailUser, parameter2: EmailDomain): Email {
            return Email(parameter1, parameter2)
        }
    }

    Осталось только сделать самую малость — определиться с аннотациями и сделать генерацию кода:


    @Target(AnnotationTarget.VALUE_PARAMETER)
    @MustBeDocumented
    annotation class SingleMappingValueAnnotation(
            val constructionClass: KClass<out SingleValueMapper<*>>, // mapper требует только одного поля ...
            val columnName: String                                   // ... а значит только одна колонка
    )
    
    @Target(AnnotationTarget.VALUE_PARAMETER)
    @MustBeDocumented
    annotation class DoubleMappingValuesAnnotation(
            val constructionClass: KClass<out DoubleValuesMapper<*>>, // mapper требует только уже два поля ...
            val columnName1: String,                                  // ... а значит две колонки
            val columnName2: String
    )

    Генерация кода по аннотациям.


    Сначала определимся, какой код мы хотим видеть. Я пришёл к следующему, который соответствует всем изначальным критериям:


    object : ResultSetMapper<DbUser> {
       override fun extractData(rs: java.sql.ResultSet): List<DbUser> {
          val queryMetadata = rs.metaData
          val queryColumnCount = queryMetadata.columnCount
          val mapperColumnCount = 3
    
          require(queryColumnCount == mapperColumnCount) {
              val queryColumns = (0..queryColumnCount).joinToString { queryMetadata.getColumnName(it) }
              "Sql query has invalid columns: $mapperColumnCount is expected, however $queryColumnCount is returned. " +
                  "Query has: $queryColumns. Mapper has: name, user_email_name, user_email_domain"
          }
    
          val columnIndex0 = rs.findColumn("name")
          val columnIndex1 = rs.findColumn("user_email_name")
          val columnIndex2 = rs.findColumn("user_email_domain")
          val result = mutableListOf<DbUser>()
          while (rs.next()) {
              val name = UserNameMapper.getValue(rs, columnIndex0)
              val email = EmailMapper.getValue(rs, columnIndex1, columnIndex2)
    
              val rowResult = DbUser(
                  name = name,
                  email = email
              )
              result.add(rowResult)
          }
          return result
       }
    }

    Так как код генерится монолитом (переменные сначала определяются, а потом используются), разобьем на блоки хотя бы идею:


    1. На входе у нас N колонок, которые участвуют в разных mapper'ах. А значит необходимы переменные для всех них (одни и те же колонки могут использоваться в разных mapper'ах).
    2. В самом начале нам необходимо проверить, что нам пришло из базы данных. Если число колонок не совпадает с тем, что нам требуется, то лучше сразу бросить исключением с деталями: что нам надо и что пришло на самом деле.
    3. Так как курсоры из Sql работают по схеме while(rs.next()) { do }, создаем изменяемый список. В идеале ему можно сразу задать размер, если мы знаем, сколько на самом деле строк вернется из базы.
    4. На каждой итерации цикла нам надо прочесть значения всех полей, а потом создать объект.

    В итоге получается код вида:


    private fun <TMappingType : Any> getMapperSourceCode(clazz: KClass<TMappingType>): String {
        return buildString {
            val className = clazz.qualifiedName!!
            val resultSetClassName = ResultSet::class.java.name
    
            val singleConstructor = clazz.constructors.single()
            val parameters = singleConstructor.parameters
    
            val annotations = parameters.flatMap { it.annotations.toList() }
    
            val columnNames = annotations.flatMap { getColumnNames(it) }.toSet()
            val columnNameToVariable = columnNames.mapIndexed { index, name -> name to "columnIndex$index" }.toMap()
    
            appendln("""
    import com.github.imanushin.ResultSetMapper
    object : com.github.imanushin.ResultSetMapper<$className> {
       override fun extractData(rs: $resultSetClassName): List<$className> {
          val queryMetadata = rs.metaData
          val queryColumnCount = queryMetadata.columnCount
          val mapperColumnCount = ${columnNameToVariable.size}
          require(queryColumnCount == mapperColumnCount) {
              val queryColumns = (0..queryColumnCount).joinToString { queryMetadata.getColumnName(it) }
              "Sql query has invalid columns: \${'$'}mapperColumnCount is expected, however \${'$'}queryColumnCount is returned. " +
                  "Query has: \${'$'}queryColumns. Mapper has: ${columnNames.joinToString()}"
          }
    
    """)
    
            columnNameToVariable.forEach { (columnName, variableName) ->
                appendln("      val $variableName = rs.findColumn(\"$columnName\")")
            }
    
            appendln("""
           val result = mutableListOf<$className>()
           while (rs.next()) {
    """)
    
            parameters.forEach { parameter ->
                fillParameterConstructor(parameter, columnNameToVariable)
            }
    
            appendln("          val rowResult = $className(")
            appendln(
                    parameters.joinToString("," + System.lineSeparator()) { parameter ->
                        "              ${parameter.name} = ${parameter.name}"
                    }
            )
    
            appendln("""
              )
              result.add(rowResult)
          }
          return result
       }
    }
    """)
            }
        }
    
    private fun StringBuilder.fillParameterConstructor(parameter: KParameter, columnNameToVariable: Map<String, String>) {
        append("              val ${parameter.name} = ")
        // please note: double or missing annotations aren't covered here
        parameter.annotations.forEach { annotation ->
            when (annotation) {
                is DoubleMappingValuesAnnotation ->
                    appendln("${annotation.constructionClass.qualifiedName}.getValue(" +
                            "rs, " +
                            "${columnNameToVariable[annotation.columnName1]}, " +
                            "${columnNameToVariable[annotation.columnName2]})"
                    )
                is SingleMappingValueAnnotation ->
                    appendln("${annotation.constructionClass.qualifiedName}.getValue(" +
                            "rs, " +
                            "${columnNameToVariable[annotation.columnName]})"
                    )
            }
        }
    }

    Зачем же нам всё это?


    Как видно, генерить код для выполнения сразу в runtime не просто, а очень просто. На создание проекта у меня ушло всего лишь несколько часов (плюс на статью еще пара), однако у нас уже вполне рабочий код, который читает данные из базы достаточно эффективно. Более того, за счет того, что мы контролируем кодогенерацию, мы можем добавить интернирование объектов, замеры производительности и еще море улучшений и оптимизаций. И, что самое главное, мы всегда знаем результирующий код, который в итоге выполнится.


    Kotlin DSL также может быть применен для программируемой конфигурации: если вы достаточно любите своих пользователей, чтобы не заставлять их делать конфигурации в виде json/xml/yaml, вы можете дать DSL, в котором и будете определять конфигурацию. Посмотрите, например, на TeamCity Build DSL — вы можете натурально запрограммировать билд, сделать условие/цикл (чтобы не копировать 10 раз один и тот же шаг), у вас есть подсказки в IDE. Ведь в программе всё равно нужна только модель конфигурации, и далеко не важно, как она создавалась.


    Не все задумки можно выразить в самом языке. И часто не хочется заниматься копипастом, который к тому же трудно проверить. И здесь тоже нам на помощь может прийти генерация кода: если реализацию можно выразить с помощью аннотаций/конфигурации, так почему бы её не сделать в общем виде, скрыв за интерфейсом? Подобный подход будет удобен и для JIT, который получит код со всеми готовыми типами, вместо обобщенного, где нет возможностей сделать оптимизации из-за недостатка знаний о вызовах.


    Однако самое важное я уже написал в начале статьи: несмотря на все возможные преимущества, пожалуйста, сначала оцените, насколько будет действительно полезно решение с генерацией и выполнением кода в runtime. Вполне может так получиться, что код с reflection работает с достаточной производительностью, а значит нет никакого смысла использовать не самые популярные техники и усложнять проект.


    Ссылки:


    1. Код из статьи
    2. Примеры для Kotlin DSL
    3. Библиотека, позволяющая создавать строго-типизированное представление для генерации кода
    Технологический Центр Дойче Банка
    Компания

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое