Не знаю, как на вас, а на меня в последнее время производят сильное впечатление статьи про новые 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); } }
