Реализация MVP DSL движка с AST, кодогенерацией и динамической компиляцией

В данной статье описывается подход автора к решению конкретной технической задачи. Автор не утверждает, что предложенное решение является единственно возможным, оптимальным или наиболее элегантным. Это лишь один из вариантов реализации, представленный в ознакомительных целях, а не руководство к обязательному применению.
Целеполагание:
В данной статье я демонстрирую свой фреймворк для динамически компилируемых функций по принципу задания некоторой формулы для вычислений с параметрами: "x + (cos(y) + log(2, pi))" и преобразования выражений в лямбда функцию: Map[String, Any] => { ... } или (x: Any, y: Any) => { ... }.
Хеш-таблица (далее - Map) задана для доступа к переменным по имени в качестве удобного универсального интерфейса. Все функции, литералы и константы, с которыми работает двигло, задаются пользователем. Реализация осуществляется посредством кодогенерации.
Таким образом, весь цикл от некого выражения до готовой лямбды будет следующим: expression -> AST -> ScalaCode → Class Loading -> lambda.
Expressionесмь некоторое арифметическое выражение.
(требования к нему рассмотрим далее)ASTесмь структура абстрактного синтаксического дерева.
(требования к нему рассмотрим далее)ScalaCodeесмь непосредственно исходный код на языке Scala, сгенерированный на основеAST.Class Loadingесмь загрузка скомпилированного кода вjvmи создание обьектов функций.Lambdaесмь готовая к использованию объект-функция, полученная в результате компиляции сгенерированного кода.
Требования к выражениям:
Выражение есмь не что иное, как строка String, подчиняющаяся определённым правилам. Рассмотрим произвольное выражение, подобное тому, что было приведено выше:
x + (cos(y) + log(2, pi)).
Как и любая другая строка, оно состоит из символов, но сами по себе эти символы не несут смысловой нагрузки и не являются логическими единицами языка выражений. Поэтому далее будем рассматривать строку не как совокупность символов, а как совокупность токенов - лексических единиц, несущих определённую смысловую и структурную нагрузку.
Приведённое выражение состоит из следующих токенов:
x- параметр выражения+- оператор сложения(и)- группирующие символы, задающие порядок вычисленийcos- имя функцииy- параметр, передаваемый функцииcoslog- имя функции2- числовой литерал, передаваемый функцииlogpi- имя константы, передаваемой функцииlog,- разделитель аргументов функции
Токены разделим на несколько видов: параметры (в примере x, y), функции и бинарные функции (+, cos, log), литералы (2), константы (pi), разделители (,) и скобки (()).
На данном этапе, и в следующей моей статье про выражения, нам нужно всё перечисленное. Парсинг выражений в AST древе будет рассмотрен в последующей статье (или статьях).
Т.к. литералы, функции и константы задаёт пользователь, определим API для него в виде классов и классов аннотаций.
//Java
// Способ задать имя для спецификаций (каждый токен имеет имя)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Name {
String value();
}
//scala
// Спецификация для литерала
trait LiteralSpec {
// Поскольку токен-литерал, тоесмь строка "2.2", "true", "hello world",
// необходимо сопоставлять токен с реальным обьектом scala,
// что и отражает данная спецификация.
def get(): String => Any
}
trait FunctionSpec {
// Логика аналогичная, только функция теперь работает с набором переменных.
def get(): List[Any] => Any
}
trait ConstantSpec {
// По анологии.
def get(): () => Any
}
// Примеры задания собственных спецификаций:
@Name(name = "Double")
final class DoubleLiteral extends LiteralSpec {
override def get(): String => Any = _.toDouble
}
@LiteralMeta(name = "Text")
final class TextLiteral extends LiteralSpec {
override def get(): String => String = identity
}
@Name("E")
class EulerConstant extends ConstantSpec {
def get(): () => Any = () => Math.E
}
@Name("+")
final class SumarFuncion extends FunctionSpec {
// Пока что "движок" не типизируемый, и потому без возможности перегрузок
// это временная реализация
override def get(): List[Any] => Any =
args =>
args(0) match {
case a: Int =>
args(1) match {
case b: Int => a + b
case _ => throw new IllegalArgumentException("Se esperaba un Int")
}
case _ => throw new IllegalArgumentException("Se esperaba un Int")
}
}
Таким образом, спецификации отделяют описание конкретных компонентов от механики обработки выражений. Движок может найти компонент по имени и получить его реализацию, но не обязан заранее знать внутреннюю логику каждого литерала, функции или константы. Благодаря этому набор поддерживаемых компонентов можно расширять, добавляя новые спецификации.
Примечание: представленный код находится в модуле api, а реализации - в модулях constants, literals, functions соответственно.
Про требования к AST:
AST состоит из узлов, каждый из которых соотносится с токенами выражений.
Токены () и , определяют порядок операций, и потому для них не требуется отдельных типов узлов, так как порядок задаётся иерархичной структурой дерева.
Определим абстракции для описания AST:
package pico.calc.framework.core
sealed trait AstNode
// Тут name это имя, с которым сопоставляется аннотация @Name
// В ParamNode name - имя переменной.
final case class LiteralNode(name: String, value: String) extends AstNode
final case class FunctionNode(name: String, ijos: List[AstNode]) extends AstNode
final case class ParamNode(name: String) extends AstNode
final case class ConstantNode(name: String) extends AstNode"Компилятор":
Движку, во время выполнения программы, необходимо динамически компилировать сгенерированный ScalaCode в исполняемые объекты Scala.
Для этого используется компилятор dotty, который позволяет преобразовать исходный код в байткод JVM. Компиляция выполняется непосредственно в рантайме, когда исходное выражение уже преобразовано в код Scala.
Результатом является скомпилированный класс, который затем загружается в JVM и может быть использован для создания исполняемого объекта. В нашем случае этот объект представляет собой функцию, принимающую параметры выражения и возвращающую результат вычисления.
Код компилятора находится в модуле compiler, и для его работы необходимо подключить библиотеку с dotty.
lazy val compilerModule = project
.in(file("compiler"))
.settings(
commonSettings,
name := "compiler",
libraryDependencies +=
"org.scala-lang" %% "scala3-compiler" % scalaVersion.value
)Расмотрим компонет InMemoryScalaDriver для выполнения компиляции сгенерированного Scala-кода в RAM. Он настраивает компилятор, передаёт ему исходный код и сохраняет результат компиляции только в VirtualDirectory.
package pico.calc.framework.compiler
import dotty.tools.dotc.Driver
import dotty.tools.io.{VirtualDirectory, VirtualFile}
import java.nio.charset.StandardCharsets
// Драйвер компилятора dotty, выполняющий компиляцию в памяти.
// Позволяет компилировать сгенерированный код без записи исходников и
// результатов компиляции на диск, устраняя необходимость в правах на запись
// в файловую систему для хранения этих данных.
private[compiler] object InMemoryScalaDriver extends Driver {
// Разрешаем компиляцию без передачи исходников через стандартный CLI.
override protected def sourcesRequired: Boolean = false
// Компилируем исходный код с заданным classpath, возвращается каталог с байткодом.
def compile(
source: String,
classpath: String
): VirtualDirectory =
// Аргументы запуска компилятора dotty.
val args = Array("-classpath", classpath)
// Инициализируем контекст компилятора с указанными настройками.
setup(args, initCtx) match
case None =>
throw CompilationError(
"Could not initialize Scala compiler"
)
case Some((_, initialContext)) =>
// Создаём виртуальный каталог для результата, не записывая классы на диск.
val output = new VirtualDirectory("pico-memory-output")
val context = initialContext.fresh
context.setSetting(context.settings.outputDir, output)
// Представляем исходный код как виртуальный файл в кодировке UTF-8.
val sourceFile = new VirtualFile(
"GeneratedSource.scala",
source.getBytes(StandardCharsets.UTF_8)
)
// Запускаем компиляцию исходного файла.
val reporter =
doCompile(
newCompiler(using context),
List(sourceFile)
)(using context)
// Прерываем процесс, если компилятор обнаружил ошибки.
if reporter.hasErrors then
throw CompilationError(
"Scala compilation failed; see compiler diagnostics above"
)
// Возвращаем каталог с результатами для последующей загрузки классов.
output
}Компонент ниже отвечает за загрузку скомпилированного класса в JVM:
Получает исходный код и полное имя класса
Передаёт код компилятору
Извлекает байт-код из
VirtualDirectoryСоздаёт собственный
ClassLoader, который определяет класс непосредственно из массива байтов
Результат - объект Class[?].
Поиск .class виртуальных файлов выполняется рекурсивно, в виртуальной директории.
package pico.calc.framework.compiler
import dotty.tools.io.{AbstractFile, VirtualDirectory}
object ScalaCompiler {
def compile(source: String, className: String): Class[?] = {
// Проверяем, что исходный код и имя класса не пусты.
require(source.trim.nonEmpty, "Source code must not be empty")
require(className.trim.nonEmpty, "Class name must not be empty")
// Компилируем исходный код в памяти используя текущий classpath.
val output = InMemoryScalaDriver.compile(
source = source,
classpath = System.getProperty("java.class.path")
)
// Определяем родительский загрузчик классов.
val parent =
Option(Thread.currentThread().getContextClassLoader)
.getOrElse(getClass.getClassLoader)
// Находим скомпилированный файл класса в результате компиляции.
val classFile = findClassFile(output)
.getOrElse {
throw new CompilationError("No compiled class found in memory")
}
// Читаем байт-код класса и закрываем поток после чтения.
val input = classFile.input
val bytecode =
try
input.readAllBytes()
finally
input.close()
// Создаём загрузчик определяющий класс из полученного байт-кода.
val classLoader = new ClassLoader(parent) {
override protected def findClass(name: String): Class[?] = {
// Разрешаем загрузку только запрошенного класса.
if (name != className) {
throw new ClassNotFoundException(name)
}
// Регистрируем класс в JVM на основе массива байтов.
defineClass(name, bytecode, 0, bytecode.length)
}
}
// Загружаем класс и возвращаем объект Class.
classLoader.loadClass(className)
}
// Рекурсивно ищем первый скомпилированный файл .class,
// в том числе во вложенных директориях пакетов.
private def findClassFile(directory: VirtualDirectory): Option[AbstractFile] =
directory.iterator
.map {
case subdirectory: VirtualDirectory =>
findClassFile(subdirectory)
case file if file.name.endsWith(".class") =>
Some(file)
case _ =>
None
}
.collectFirst { case Some(file) => file }
}Примечание: в этом проекте, все, что связанно с компиляцией, лежит в модуле compiler.
Кодогенерация:
Для кодогенерации необходимо реализовать диспетчеры спецификаций.
Пока их реализация будет достаточно простой, но в дальнейшем она будет расширяться по мере усложнения логики. Диспaтчеры предназначены для работы со спецификациями, определяемыми пользователем.
// пока что диспатчерезации происходит только по имени @Name
// остальные диспатчеры полностью аналогичны
object FunctionDispatcher {
def apply(specs: Set[FunctionSpec]): String => Option[FunctionSpec] = {
val specMap: Map[String, FunctionSpec] = specs
.map { spec =>
val meta = spec.getClass.getAnnotation(classOf[Name])
meta.value() -> spec
}
.toMap
name => specMap.get(name)
}
}Опишем архитектуру через разделение ответственности: диспатчеры разрешают имена пользовательских спецификаций, а CodeGenHelper предоставляет кодогенератору единый интерфейс для их получения и анализа AST.
// вспомогательный класс для кодогенерации
object CodeGenHelper:
// набор подготовленных вспомогательных с замкнутыми диспатчерами
final case class Helpers(
resolveLiteral: String => LiteralSpec,
resolveFunction: String => FunctionSpec,
resolveConstant: String => ConstantSpec,
getImports: AstNode => Set[Class[?]],
getAllLiteralSpecs: AstNode => Set[LiteralSpec],
getAllFunctionSpecs: AstNode => Set[FunctionSpec],
getAllConstantSpecs: AstNode => Set[ConstantSpec]
)
def apply(
literalDispatcher: String => Option[LiteralSpec],
functionDispatcher: String => Option[FunctionSpec],
constantDispatcher: String => Option[ConstantSpec]
): Helpers =
// Функции для резолвинга спецификаций
def resolveLiteral(name: String): LiteralSpec = ...
def resolveFunction(name: String): FunctionSpec = ...
def resolveConstant(name: String): ConstantSpec = ...
// Получение всех импортов и спецификаций
def getImports(ast: AstNode): Set[Class[?]] = ...
def getAllLiteralSpecs(ast: AstNode): Set[LiteralSpec] = ...
def getAllFunctionSpecs(ast: AstNode): Set[FunctionSpec] = ...
def getAllConstantSpecs(ast: AstNode): Set[ConstantSpec] = ...
Helpers(
resolveLiteral = resolveLiteral,
resolveFunction = resolveFunction,
resolveConstant = resolveConstant,
getImports = getImports,
getAllLiteralSpecs = getAllLiteralSpecs,
getAllFunctionSpecs = getAllFunctionSpecs,
getAllConstantSpecs = getAllConstantSpecs
)
В следующем блоке кода будет реализован генератор исходного кода Scala, который преобразует AST в класс Formula, способный вычислять выражение по переданным параметрам.
Архитектура разделяет анализ AST и генерацию кода: CodeGenHelper собирает необходимые спецификации и зависимости, а ScalaCodeGenerator использует полученные данные для построения исходного кода.
Пока рассмотрим работу на примере литералов:
Сбор спецификаций.
getAllLiteralSpecs(ast)находит все спецификации литералов, используемые в выражении.Подготовка зависимостей.
getImports(ast)собирает импорты, необходимые для генерации исходного кода.Генерация преобразователей. Для каждой спецификации создаётся поле с функцией
String=>Any. Эта функция получается вызовомget()у экземпляра спецификации и преобразует текстовое представление литерала в соответствующее значение.Собранные поля и сгенерированное выражение помещаются в класс
Formula, реализующий функциюMap[String, Any] => Any. Методapplyпринимает параметры и вычисляет выражение.
Таким образом, генератор не обязан знать внутреннюю логику каждого типа литерала: он использует спецификации, предоставляющие необходимое преобразование. При этом создание преобразователей вынесено в поля класса, поэтому они инициализируются при создании Formula, а не генерируются заново при каждом вызове apply.
package pico.calc.framework.core
// Class генерирующий код на scala на основе ast
object ScalaCodeGenerator:
def apply(
helpers: CodeGenHelper.Helpers
): AstNode => String =
// Генерирует код самих узлов из AST для тела функции apply
def generateExpression(ast: AstNode): String =
ast match
case LiteralNode(name, value) =>
val specValName = toValName(
helpers.resolveLiteral(name).getClass.getSimpleName
)
s"""$specValName.apply("${value}")"""
case FunctionNode(name, args) =>
val specValName = toValName(helpers.resolveFunction(name).getClass.getSimpleName)
val generatedArgs =
args.map(generateExpression).mkString(", ")
s"$specValName.apply(List[Any]($generatedArgs))"
case ConstantNode(name) =>
val specValName = toValName(helpers.resolveConstant(name).getClass.getSimpleName)
s"$specValName.apply()"
case ParamNode(name) =>
f"params(\"${name}\")"
ast =>
// Формирует все необходимые импорты
val imports =
helpers.getImports(ast)
.map(cls => s"import ${cls.getCanonicalName}")
.mkString("\n")
// Формирует Литералы для использования в узлах
val literalVals =
helpers.getAllLiteralSpecs(ast)
.map { spec =>
val className = spec.getClass.getSimpleName
val valName = toValName(className)
s"val $valName: String => Any = new $className().get()"
}
.mkString("\n")
// Формирует функции для использования в узлах
val functionVals =
helpers.getAllFunctionSpecs(ast)
.map { spec =>
val className = spec.getClass.getSimpleName
val valName = toValName(className)
s"val $valName: List[Any] => Any = new $className().get()"
}
.mkString("\n")
// Формирует Константы для использования в узлах
val constantVals = helpers.getAllConstantSpecs(ast)
.map { spec =>
val className = spec.getClass.getSimpleName
val valName = toValName(className)
s"val $valName: () => Any = new $className().get()"
}
.mkString("\n")
// Код тела функции apply
val body = generateExpression(ast)
// Возвращем весь код по шаблону
s"""package pico.calc.framework.generated
|$imports
|
|class Formula extends (Map[String, Any] => Any):
| $literalVals
| $functionVals
| $constantVals
|
| override def apply(params: Map[String, Any]): Any =
| $body
|""".stripMargin
// Получает имя переменной в camalCase
private def toValName(className: String): String = ...
Под конец остается только связать все воедино:
// Компилирует код в Map[String, Any] => Any
object Compiler {
def apply(code: String): Map[String, Any] => Any = {
val clazz = ScalaCompiler.compile(
source = code,
className = "pico.calc.framework.generated.Formula"
)
clazz
.getDeclaredConstructor()
.newInstance()
.asInstanceOf[Map[String, Any] => Any]
}
}
// собирает код и компилит
object ExpressionCompiler {
def apply(
literals: Set[LiteralSpec],
functions: Set[FunctionSpec],
constants: Set[ConstantSpec]
)(ast: AstNode): Map[String, Any] => Any = {
val literalDispatcher = LiteralDispatcher(literals)
val functionDispatcher = FunctionDispatcher(functions)
val constantDispatcher = ConstantDispatcher(constants)
val helpers = CodeGenHelper(literalDispatcher, functionDispatcher, constantDispatcher)
val source = ScalaCodeGenerator(helpers)(ast)
Compiler(source)
}
}Заключение:
на данном этапе все устроено так, что перед методом apply() генерируются все необходимые функции из спецификаций, и через них же происходит вызов.
Это не самый оптимальный путь, но это MVP и оно работает.
В последующих статьях будет разобрано как делать более сложную и оптимальную кодогенерацию, как протипизировать спецификации и как усложнить диспатчерезацию для перегрузки методов.
P.S. код можно посмотреть тут версия 1.0.0
P.P.S. у меня есть аккаунт на забр карьере
P.P.P.S есть аккаунт в инстаграмм
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.