Не знаю, как на вас, а на меня в последнее время производят сильное впечатление статьи про новые Java-технологии — Graal, Truffle и все-все-все. Выглядит так, как будто раньше ты придумал язык, написал интерпретатор, порадовался какой язык хороший и погрустил, какой медленный, написал к нему нативный компилятор и/или JIT, а ведь нужен ещё отладчик… LLVM есть, и на том спасибо. После прочтения этой статьи сложилось (несколько гротескное) впечатление, что после написания интерпретатора специального вида работу можно, в принципе, и завершать. Ощущение, что теперь кнопка "Сделать зашибись" стала доступна и программистам-компиляторщикам. Нет, конечно, JIT-языки медленно стартуют, им нужно время на прогрев. Но, в конце концов, время и квалификация программиста тоже не бесплатные — в каком бы мире информационных технологий мы бы жили, если бы до сих пор писали всё на ассемблере? Нет, может, всё бы, конечно, и летало (это если программист грамотно инструкции разложил), но вот насчёт суммарной сложности активно используемых программ у меня есть некоторые сомнения...
В общем, я прекрасно понимаю, что в дилемме «затраченное программистом время vs идеальность полученного продукта ("ручная работа")» границу можно двигать до скончания веков, поэтому давайте сегодня просто попробуем воспользоваться традиционной библиотекой SQLite без подгрузки нативного кода в чистом виде. Будем использовать уже готовую truffle-реализацию языка для LLVM IR, зовущуюся Sulong.
Disclaimer: эту статью нужно рассматривать не как рассказ профи новичкам, а как своего рода лабораторную работу такого же новичка, который только пытается освоиться с технологией. И ещё: считать LLVM IR полностью платформенно-независимым нельзя.
Итак, нам нужно будет взять, собственно, исходники SQLite, написать связующий код на JavaScala (ну, извините...), а также раздобыть GraalVM с обвязкой и Clang (с его помощью мы скомпилируем SQLite в LLVM IR, который будем подгружать в наш код на Scala).
Сразу оговорюсь, что всё будет происходить на Ubuntu 18.04 LTS (64 bit). С Mac OS X больших проблем, хочется верить, тоже не возникнет, а вот есть ли Graal и все его необходимые компоненты под Windows, я не уверен. Впрочем, даже если сейчас нет, наверное, появятся потом.
Подготовка
- Качаем нашего подопытного кролика SQLite (на самом деле, в приложенном к статье репозитории уже всё есть).
- Читаем официальную статью SQLite In 5 Minutes Or Less. Поскольку SQLite в данном случае используется только в качестве примера, то как раз то, что нужно. How To Compile SQLite тоже пригодится.
- Скачиваем GraalVM Community Edition отсюда и распаковываем его. Я бы не рекомендовал поддаваться на провокации добавить его в
PATH
— зачем намnode
иlli
, идентичные натуральным? - Устанавливаем clang — в моём случае это Clang 6 из штатного репозитория Ubuntu
Также в моём тестовом проекте будет использоваться система сборки sbt. Для редактирования проекта лично я предпочитаю IntelliJ Idea Community со штатным Scala-плагином.
И вот тут лично у меня начались первые грабли: на сайте GraalVM сказано, что это просто каталог с JDK. Ну, раз так — то и в Идею его добавлю как простой JDK. «1.8» — сказала Идея. Хм… Странно. Заходим в консоль в каталог с Граалем, говорим bin/javac -version
— действительно 1.8. Ну восемь, так восемь — не страшно. Страшно то, что пакеты org.graal
и всё такое Идея не видит, а они нам понадобятся. Что же, идём в File -> Other Settings -> Default Project Structure...
, там в настройках JDK видим, что в Classpath лежат jar-файлы из jre/lib
и jre/lib/ext
. Все ли — не проверял. А вот что предположительно нам нужно:
trosinenko@trosinenko-pc:~/tmp/graal/graalvm-1.0.0-rc1/jre/lib$ find . -name '*.jar'
./truffle/truffle-dsl-processor.jar
./truffle/truffle-api.jar
./truffle/truffle-nfi.jar
./truffle/locator.jar
./truffle/truffle-tck.jar
./polyglot/polyglot-native-api.jar
./boot/graaljs-scriptengine.jar
./boot/graal-sdk.jar
./management-agent.jar
./rt.jar
./jsse.jar
./resources.jar
./jvmci/jvmci-hotspot.jar
./jvmci/graal.jar
./jvmci/jvmci-api.jar
./installer/installer.jar
./ext/cldrdata.jar
./ext/sunjce_provider.jar
./ext/nashorn.jar
./ext/sunec.jar
./ext/zipfs.jar
./ext/sunpkcs11.jar
./ext/jaccess.jar
./ext/localedata.jar
./ext/dnsns.jar
./jce.jar
./svm/builder/objectfile.jar
./svm/builder/svm.jar
./svm/builder/pointsto.jar
./svm/library-support.jar
./graalvm/svm-driver.jar
./graalvm/launcher-common.jar
./graalvm/sulong-launcher.jar
./graalvm/graaljs-launcher.jar
./charsets.jar
./jvmci-services.jar
./security/policy/unlimited/US_export_policy.jar
./security/policy/unlimited/local_policy.jar
./security/policy/limited/US_export_policy.jar
./security/policy/limited/local_policy.jar
Из итого листинга мы видим ещё некоторое количество подкаталогов, причём, судя по тому, что было добавлено для обычного JDK, ./security
нас не интересует. В таком случае, методом «"+"-развернул-каталог-shift-click-click, OK» добавим содержимое подкаталогов truffle
, polyglot
, boot
и graalvm
. Если что-то потом не найдётся — ещё добавим — дело-то житейское...
Создаём проект на Scala
Итак, кажется, Идею настроили. Попробуем создать sbt-проект. Собственно, подводных камней никаких нет, всё интуитивно, главное — не забыть указать наш новый JDK.
Теперь просто создаём новый scala-файл и копипастим творчески перерабатываем код, написанный в Polyglot reference в разделе Start Language Java
, кликнув в Target Language — LLVM.
Кстати, рекомендую обратить внимание на обилие других Start Language: JavaScript, R, Ruby и даже просто C, но это уже совсем другая история, которую я пока не читал...
object SQLiteTest {
val polyglot = Context.newBuilder().allowAllAccess(true).build()
val file: File = ???
val source = Source.newBuilder("llvm", file).build()
val cpart = polyglot.eval(source)
???
}
Не будем наследовать наш object
от App
или делать поля приватными — тогда к ним можно будет обращаться из Scala-консоли (её конфигурация уже добавлена в проект).
В итоге, мы почти (на целых 80%) перекатали пример аж из целых пяти содержательных строчек — самое время откинуться на спинку табуретки и почитать наконец что же мы понаписали Javadoc, тем более, что просто вызывать main()
как-то скучно, и вообще, наш модельный пример — SQLite, поэтому надо понять, что именно писать вместо пятой строки. Polyglot reference — это прекрасно, но нужна документация по API. Чтобы её найти, нужно походить по репозиторию, там есть readme, а в них — ссылки на Javadoc.
А пока смысл написанного нам ещё не ясен, спросим у JS Ответ на Главный Вопрос: выбираем в Идее конфигурацию Scala console, и...
scala> import org.graalvm.polyglot.Context
val polyglot = Context.newBuilder().allowAllAccess(true).build()
polyglot.eval("js", "6 * 7")
import org.graalvm.polyglot.Context
scala> polyglot: org.graalvm.polyglot.Context = org.graalvm.polyglot.Context@68e24e7
scala> res0: org.graalvm.polyglot.Value = 42
… ну, всё работает, Ответ есть. А Вопрос оставим в качестве упражнения читателю.
Вернёмся к коду примера. Переменная polyglot
содержит контекст, в котором живут разные языки — кто-то выключен, кто-то включён, а кто-то уже даже лениво инициализировался. В этом суровом мире даже для доступа к файлам надо просить разрешение, поэтому в примере мы просто отключаем ограничения с помощью allowAllAccess(true)
.
Далее мы создаём объект Source с нашим LLVM-биткодом. Мы указываем язык и файл, откуда загрузить этот "исходный код". Также можно использовать непосредственно строку с исходником (это мы уже видели), URL (в том числе, из ресурсов в JAR-файле), и просто экземпляр java.io.Reader
. Далее, мы вычисляем полученный source в контексте, и получаем Value. В соответствии с документацией на этот метод, мы никогда не получим null
, но существует Value
, которое представляет собой Null
. Но нам всё же нужно загрузить что-то конкретное, поэтому...
Собираем SQLite
… Think of SQLite not as a replacement for Oracle but as a replacement for fopen()
— Из About SQLite. Как видите, позволить запускать в GraalVM SQLite не было страшной ошибкой для разработчиков.
По советам из уже упоминавшейся части документации SQLite, а также инструкции Graal составим командную строку. Вот она:
clang -g -c -O1 -emit-llvm sqlite3.с
-DSQLITE_OMIT_LOAD_EXTENSION
-DSQLITE_THREADSAFE=0
-o ../../sqlite3.bc
Оптимизация хотя бы -O1
требуется для корректной работы кода внутри Sulong, -g
сохранит нам имена (по поводу этих двух, а также других опций подробнее читайте в документации), SQLITE_OMIT_LOAD_EXTENSION
мы используем, чтобы не зависеть от libdl.so
в нашем тестовом примере (как бы мы вообще это делали, с ходу не ясно), а поскольку с pthread линковаться непонятно как, да и зачем, то и thread safety отключаем (иначе при запуске оно завершится с ошибкой). Вот и всё.
Запускаем наш проект
Теперь у нас есть, что вписать во вторую строчку:
val file: File = new File("./sqlite3.bc")
Теперь мы можем вытащить необходимые функции из библиотеки:
val sqliteOpen = cpart.getMember("sqlite3_open")
val sqliteExec = cpart.getMember("sqlite3_exec")
val sqliteClose = cpart.getMember("sqlite3_close")
val sqliteFree = cpart.getMember("sqlite3_free")
И оно работает — осталось всего лишь вызвать их в правильном порядке — и всё! Ну, например, sqlite3_open
требует строку с именем файла и указатель на указатель на структуру (внутренности которой нас не интересуют от слова совсем). Хм… и как сформировать второй аргумент? Нужна функция создания указателей — наверное, она Sulong-специфична. Добавляем в Classpath sulong.jar
, перезапускаем sbt shell целиком. И ничего. Долго ли, коротко ли, не нашёл ничего умнее создать каталог lib
в корне проекта sbt (стандартный каталог для unmanaged jars) и выполнить в нём
find ../../graalvm-1.0.0-rc1/jre/languages/ -name '*.jar' -exec ln -s {} . ;
После sbt refresh компиляция завершилась успешно. Вот только не запускается ничего… Ладно, возвращаем Classpath на место. В общем, думал, допишу пятую строчку. Ну хорошо, перескажу Javadoc по каждой из пяти, получится небольшая статья, и все скажут: "У нас тут Твиттер что ли?"...
Прошло, наверное, часа три, а я всё пытался обернуть у функции sqlite3_open
второй аргумент...
В какой-то момент меня осенило: надо как в анекдоте: «Что же ты с "Войны и мира" начинаешь, почитай "Колобок" — как раз для твоего уровня»… Так sqlite3.c
временно был заменён на test.c
void f(int *x) {
*x = 42;
}
Потыкавшись ещё немного во всякие API преобразования типов разной степени приватности, я, мягко говоря, утомился. В голове остались одни анекдоты. Например такой: "iOS — интуитивно понятная система. Чтобы её понять, логика бессильна — нужна интуиция". И действительно, какой главный принцип GraalVM и вот этого всего — всё должно быть прозрачно и ненапряжно, поэтому надо отбросить малейший опыт работы с FFI и думать как разработчик удобной системы. Нам нужен контейнер с интом. Передаём new java.lang.Integer(0)
— запись по нулевому адресу. Но чему нас учили на азах C: разница между массивом и указателем на нулевой элемент весьма условна. Фактически, функция f
просто принимает массив интов и записывает в нулевой элемент значение. Пробуем:
scala> val x = Array(new java.lang.Integer(12))
x: Array[Integer] = Array(12)
scala> SQLiteTest.cpart.getMember("f").execute(x)
res0: org.graalvm.polyglot.Value = LLVMTruffleObject(null:0)
scala> x
res1: Array[Integer] = Array(42)
ТАДАМ!!!
Тут, казалось бы, быстро написать функцию query
и закончить на этом, но что ни передавай в качестве второго аргумента: ни Array(new Object)
, ни Array(Array(new Object))
— работать оно отказывается, ругаясь на strlen
внутри LLVM-биткода O_O (кстати, LLVM IR, в отличие от обычного машинного кода из so-ки вполне себе типизированный).
Ещё энное время спустя я перестал откидывать мысль о том, что просто передать в execute()
в качестве первого аргумента java.lang.String
и даже Array[Byte]
— это уж слишком интуитивно, и переделка нашей void f()
это подтвердила.
В итоге во встроенных биндингах Sulong-а (SQLiteTest.polyglot.getBindings("llvm")
) была найдена функция с многообещающим именем __sulong_byte_array_to_native
. Пробуем:
val str = SQLiteTest.polyglot.getBindings("llvm")
.getMember("__sulong_byte_array_to_native")
.execute("toc.db".getBytes)
val db = new Array[Object](1)
SQLiteTest.sqliteOpen.execute(str, db)
scala> str: org.graalvm.polyglot.Value = LLVMTruffleObject(null:139990504321152)
scala> db: Array[Object] = Array(null)
scala> res0: org.graalvm.polyglot.Value = 0
scala> val str = SQLiteTest.polyglot.getBindings("llvm")
.getMember("__sulong_byte_array_to_native")
.execute("toc123.db".getBytes)
SQLiteTest.sqliteOpen.execute(str, db)
str: org.graalvm.polyglot.Value = LLVMTruffleObject(null:139990517528064)
scala> res1: org.graalvm.polyglot.Value = 0
Работает!!! Ой, а почему с неправильным именем файла тоже работает?.. Затаив дыхание, смотрим в каталог проекта — а там уже лежит новенькая toc123.db
. Ура!
Итак, перепишем пример из документации по SQLite на Scala:
def query(dbFile: String, queryString: String): Unit = {
val filenameStr = toCString(dbFile)
val ptrToDb = new Array[Object](1)
val rc = sqliteOpen.execute(filenameStr, ptrToDb)
val db = ptrToDb.head
if (rc.asInt() != 0) {
println(s"Cannot open $dbFile: ${sqliteErrmsg.execute(db)}!")
sqliteClose.execute(db)
} else {
val zErrMsg = new Array[Object](1)
val execRc = sqliteExec.execute(db, toCString(queryString), ???, zErrMsg)
if (execRc.asInt != 0) {
val errorMessage = zErrMsg.head.asInstanceOf[Value]
assert(errorMessage.isString)
println(s"Cannot execute query: ${errorMessage.asString}")
sqliteFree.execute(errorMessage)
}
sqliteClose.executeVoid(db)
}
}
Вот только есть одна загвоздка — некий callback. Ну, когда никто не видит, студент-инженер описывает сердечник из дерева, а я попробую написать callback на JavaScript:
val callback = polyglot.eval("js",
"""function(unused, argc, argv, azColName) {
| print("argc = " + argc);
| print("argv = " + argv);
| print("azColName = " + azColName);
| return 0;
|}
""".stripMargin)
// ...
val execRc = sqliteExec.execute(db, toCString(queryString), callback, Int.box(0), zErrMsg)
И вот, что получаем:
io.github.trosinenko.SQLiteTest.query("toc.db", "select * from toc;")
argc = 5
argv = foreign {}
azColName = foreign {}
argc = 5
argv = foreign {}
azColName = foreign {}
argc = 5
argv = foreign {}
azColName = foreign {}
Ну, магии маловато. К тому же, оказывается, в случае ошибки в zErrMsg
лежит какой-то непонятный объект, сам в строку не конвертирующийся. Что же, соберём и загрузим ещё lib.bc
, а в его исходнике lib.c
напишем следующее:
#include <polyglot.h>
void *fromCString(const char *str) {
return polyglot_from_string(str, "UTF-8");
}
Почему polyglot_from_string
недоступен прямо через bindings, я не понял, поэтому вытащим так и сделаем обвязку:
val lib_fromCString = lib.getMember("fromCString")
def fromCString(ptr: Value): String = {
if (ptr.isNull)
"<null>"
else
lib_fromCString.execute(ptr).asString()
}
Ну, с возвратом сообщений об ошибках разобрались, а вот callback давайте всё же напишем на Scala:
val lib_copyToArray = lib.getMember("copy_to_array_from_pointers")
val callback = new ProxyExecutable {
override def execute(arguments: Value*): AnyRef = {
val argc = arguments(1).asInt()
val xargv = new Array[Long](argc)
val xazColName = new Array[Long](argc)
lib_copyToArray.execute(xargv, arguments(2))
lib_copyToArray.execute(xazColName, arguments(3))
(0 until argc) foreach { i =>
val name = fromCString(polyglot.asValue(xazColName(i) ^ 1))
val value = fromCString(polyglot.asValue(xargv(i) ^ 1))
println(s"$name = $value")
}
println("========================")
Int.box(0)
}
}
При этом в наш lib.c
добавим ещё такую магию перекладывания из сишного массива в Polyglot-овский:
void copy_to_array_from_pointers(void *arr, void **ptrs) {
int size = polyglot_get_array_size(arr);
for(int i = 0; i < size; ++i) {
polyglot_set_array_element(arr, i, ((uintptr_t)ptrs[i]) ^ 1);
}
}
Обратите внимание на указатель ^ 1 — нужно это потому, что кто-то слишком умный: а именно, polyglot_set_array_element
— это variadic-функция ровно с тремя аргументами, которая принимает и примитивные типы, и указатели на Polyglot values. В итоге, оно работает:
io.github.atrosinenko.SQLiteTest.query("toc.db", "select * from toc;")
name = sqlite3
type = object
status = 0
title = Database Connection Handle
uri = c3ref/sqlite3.html
========================
name = sqlite3_int64
type = object
status = 0
title = 64-Bit Integer Types
uri = c3ref/int64.html
========================
name = sqlite3_uint64
type = object
status = 0
title = 64-Bit Integer Types
uri = c3ref/int64.html
========================
...
Осталось добавить метод main
:
def main(args: Array[String]): Unit = {
query(args(0), args(1))
polyglot.close()
}
в котором, вообще-то, контекст нужно закрыть, но в самом объекте я этого не делал, поскольку после инициализации SQLiteTest
он нам, естественно, ещё нужен для Scala-консоли.
На этом я завершаю свой рассказ, а читателю предлагаю:
- Попробовать собрать это всё с помощью SubstrateVM в нативный бинарник, будто и не было тут никакой Scala
- (*) Сделать то же самое, но с profile guided optimization
Получившиеся в итоге файлы:
package io.github.atrosinenko
import java.io.File
import org.graalvm.polyglot.proxy.ProxyExecutable
import org.graalvm.polyglot.{Context, Source, Value}
object SQLiteTest {
val polyglot: Context = Context.newBuilder().allowAllAccess(true).build()
def loadBcFile(file: File): Value = {
val source = Source.newBuilder("llvm", file).build()
polyglot.eval(source)
}
val cpart: Value = loadBcFile(new File("./sqlite3.bc"))
val lib: Value = loadBcFile(new File("./lib.bc"))
val sqliteOpen: Value = cpart.getMember("sqlite3_open")
val sqliteExec: Value = cpart.getMember("sqlite3_exec")
val sqliteErrmsg: Value = cpart.getMember("sqlite3_errmsg")
val sqliteClose: Value = cpart.getMember("sqlite3_close")
val sqliteFree: Value = cpart.getMember("sqlite3_free")
val bytesToNative: Value = polyglot.getBindings("llvm").getMember("__sulong_byte_array_to_native")
def toCString(str: String): Value = {
bytesToNative.execute(str.getBytes())
}
val lib_fromCString: Value = lib.getMember("fromCString")
def fromCString(ptr: Value): String = {
if (ptr.isNull)
"<null>"
else
lib_fromCString.execute(ptr).asString()
}
val lib_copyToArray: Value = lib.getMember("copy_to_array_from_pointers")
val callback: ProxyExecutable = new ProxyExecutable {
override def execute(arguments: Value*): AnyRef = {
val argc = arguments(1).asInt()
val xargv = new Array[Long](argc)
val xazColName = new Array[Long](argc)
lib_copyToArray.execute(xargv, arguments(2))
lib_copyToArray.execute(xazColName, arguments(3))
(0 until argc) foreach { i =>
val name = fromCString(polyglot.asValue(xazColName(i) ^ 1))
val value = fromCString(polyglot.asValue(xargv(i) ^ 1))
println(s"$name = $value")
}
println("========================")
Int.box(0)
}
}
def query(dbFile: String, queryString: String): Unit = {
val filenameStr = toCString(dbFile)
val ptrToDb = new Array[Object](1)
val rc = sqliteOpen.execute(filenameStr, ptrToDb)
val db = ptrToDb.head
if (rc.asInt() != 0) {
println(s"Cannot open $dbFile: ${fromCString(sqliteErrmsg.execute(db))}!")
sqliteClose.execute(db)
} else {
val zErrMsg = new Array[Object](1)
val execRc = sqliteExec.execute(db, toCString(queryString), callback, Int.box(0), zErrMsg)
if (execRc.asInt != 0) {
val errorMessage = zErrMsg.head.asInstanceOf[Value]
println(s"Cannot execute query: ${fromCString(errorMessage)}")
sqliteFree.execute(errorMessage)
}
sqliteClose.execute(db)
}
}
def main(args: Array[String]): Unit = {
query(args(0), args(1))
polyglot.close()
}
}
#include <polyglot.h>
void *fromCString(const char *str) {
return polyglot_from_string(str, "UTF-8");
}
void copy_to_array_from_pointers(void *arr, void **ptrs) {
int size = polyglot_get_array_size(arr);
for(int i = 0; i < size; ++i) {
polyglot_set_array_element(arr, i, ((uintptr_t)ptrs[i]) ^ 1);
}
}
Автор: Анатолий Тросиненко