Исполнение пользовательского кода на GO
На самом деле это всё о смартконтрактах.
Но если вы не совсем представляете себе что такое смартконтракт, и вообще далеки от крипты, то что такое хранимая процедура в базе данных, представляете себе вполне. Пользователь создаёт кусочки кода, которые потом работают на нашем сервере. Пользователю удобно их писать и публиковать, а нам безопасно их исполнять. К сожалению безопасность нами ещё не разработана, так что сейчас я её описывать не буду, но несколько намёков дам.
Ещё мы пишем на Go, и его рантайм накладывает некоторые весьма специфические ограничения, главное из которых — по большому счёту мы не можем слинковаться с другим проектом, написанным не на го, это приведёт к остановке нашего рантайма каждый раз, когда мы исполняем сторонний код. Вообще у нас есть вариант использовать какой нибудь интерпретатор, для го мы нашли вполне вменяемый Lua и совсем невменяемый WASM, Но вот как то не хочется подсаживать клиентов на Lua, а с WASM сейчас по большому счёту больше проблем, чем выгоды, он в состоянии драфта, который обновляется каждый месяц, так что мы подождём, когда спецификация устаканится. Используем его как второй движок.
В результате продолжительных боёв с собственной совестью, было решено писать смартконтракты на GO. Дело в том, что если строить архитектуру исполнения компилированного GO кода, то придётся выносить это исполнение в отдельный процесс, как вы помните мы за безопасность, а вынос в отдельный процесс — это потеря производительности на IPC, хотя в дальнейшем, когда мы поняли объёмы исполняемого кода, стало даже как то приятно, что мы выбрали это решение. Всё дело в том, что оно масштабируемо, хотя и добавляет задержку на каждый отдельный вызов. Мы можем поднять много удалённых сред исполнения.
Немножечко ещё о принятых решениях, чтобы было понятно. Каждый смартконтракт состоит из двух частей, одна часть это код класса, а вторая — данные объекта, таким образом на одном и том же коде мы можем, один раз опубликовав код, создать множество контрактов, которые будут вести себя принципе одинаково, но с разными настройками, и с разным состоянием. Если рассказывать дальше — то это уже про блокчен и не тема данного повествования.
И так, мы исполняем GO
Мы решили пользоваться механизмом плагинов, который не то что бы готов и хорош. Он делает следующее, мы компилируем то что будет плагином специальным образом в разделяемую библиотеку, а потом её подгружаем, находим в ней символы и передаём туда исполнение. Но загвоздка в том, что у GO есть рантайм, и это почти мегабайт кода, а по умолчанию этот рантам тоже собирается в эту библиотеку, и мы имеем разкопипащеный рантайм везде. Но сейчас мы решили на это пойти, будучи уверенными что сможем победить это в будущем.
Делается всё просто, когда вы собираете свою библиотеку, вы собираете её с ключом — buildmode=plugin и получаете файл .so, который потом открываете
p, err := plugin.Open(path)
Ищете интересующий вас символ
symbol, err := p.Lookup(Method)
И теперь в зависимости от того переменная это или функция, вы либо вызываете его, либо используете как переменную.
Под капотом у этого механизма простой dlopen (3), мы подгружаем библиотечку, проверяем что она является плагином и отдаём обёртку над ней, при создании обёртки, все экспортируемые символы обёртываются в interface{} и запоминаются. Если это функция, то её надо привести к правильному типу функции и просто вызвать, если переменная — то работать как с переменной.
Главное помнить, что если символ это переменная — то она глобальная на весь процесс и нельзя её бездумно использовать.
Если в плагине был объявлен тип, то этот тип имеет смысл вынести в отдельный пакет, чтобы основной процесс мог работать с ним, например передавать как аргументы функций плагина. Это необязательно, можно не париться и использовать рефлексию.
Наши контракты представляют собой объекты соответствующего «класса», и в начале инстанс этого объекта хранился у нас в экспортируемой переменной, таким образом мы могли создать ещё одну такую же переменную
export, err := p.Lookup("EXPORT")
obj := reflect.New(reflect.ValueOf(export).Elem().Type()).Interface()
И уже внутрь этой локальной переменной, правильного типа, десериализовать состояние объекта. После того как объект восстановлен, мы можем вызывать на нём методы. После чего объект сериализуется и складывается обратно в хранилище, ура мы вызвали метод на контракте.
Если вам интересно как, но лень читать документацию, то
method := reflect.ValueOf(obj).MethodByName(Method)
res:= method.Call(in)
По середине надо ещё наполнить массив in, пустыми интерфейсами, содержащими правильный тип аргумента, если интересно, посмотрите сами как это делалось, исходники открыты, хотя найти это место в истории будет сложновато.
Вообщем всё у нас заработало, можно написать код с чем то вроде класса, положить его на блокчейн, создать контракт этого класса опять же на блокчейне, произвести вызов метода на нём и новое состояние контракта запишется обратно в блокчейн. Шикарно! Как создавать новый контракт имея на руках код? Очень просто, у нас есть функции конструкторы, которые возвращают свежесозданный объект, который и есть новый контракт. Пока что всё работает через рефлексию и пользователь должен писать
var EXPORT ContractType
Чтобы мы знали какой символ является представлением контракта, и собственно его и использовали в качестве шаблона.
Это нам не очень нравится. И мы ударились во все тяжкие.
Разбор
Во первых пользователь не должен писать ничего лишнего, а во вторых у нас есть идея, что взаимодействие контракта с контрактом должно быть простым, и тестироваться без поднятия блокчейна, блокчейн это медленно и сложно.
Поэтому мы решили обернуть контракт в обёртку, которая генерируется на основании контракта и шаблона обёртки, в принципе понятное решение. Во первых, обёртка создаёт нам экспортный объект, а во вторых подменяет библиотеку, с которой контракт собирается, когда пользователь пишет контракт, библиотека (foundation) используется тестовая с моками внутри, а когда контракт публикуется, она подменяется на боевую, которая работает собственно с блокчейном.
Для начала код надо разобрать и понять что у нас вообще есть, найти структуру, которая унаследована от BaseContract, чтобы вокруг неё генерить обёртку.
Делается это довольно просто, мы читаем файл с кодом в []byte, хотя парсер сам умеет читать файлы, хорошо где нибудь иметь тот текст, на который ссылаются все элементы AST, они ссылаются на номер байта в файле, и мы в дальнейшем хотим получать код структур как он есть, мы просто возьмём что то типа.
func (pf *ParsedFile) codeOfNode(n ast.Node) string {
return string(pf.code[n.Pos()-1 : n.End()-1])
}
Файл мы собственно парсим и получаем самый верхний узел AST, с которого будем производить обход файла.
fileSet = token.NewFileSet()
node, err := parser.ParseFile(fileSet, name, code, parser.ParseComments)
Далее мы обходим код начиная с верхнего узла, и в отдельную структуру собираем всё интересное.
for _, decl := range node.Decls {
switch d := decl.(type) {
case *ast.GenDecl:
…
case *ast.FuncDecl:
…
}
}
Decls, это уже разобранный в массив, список всего, что определено в файле, но это массив интерфейсов Decl, который не описывает собственно то что внутри, по этому каждый элемент надо приводить к конкретному типу, тут авторы языка отошли от своей идеи использования интерфейсов, интерфейс в go/ast это скорее базовый класс.
Нас интересуют узлы типов GenDecl и FuncDecl. GenDecl это определение переменной или типа, и нужно проверять что внутри именно тип, и ещё раз приводить уже к типу TypeDecl, с которым уже можно работать. FuncDecl попроще — это функция, а если у неё заполнено поле Recv, то это метод соответствующей структуры. Всё это добро мы собираем в удобное хранилище, потому что дальше мы используем text/template, а он не обладает большой выразительной мощью. Единственное, нам нужно отдельно запомнить, как называется тип данных, который унаследован от BaseContract, именно вокруг него мы и собираемся плясать.
Кодогенерация
И так, мы знаем все типы и функции, которые есть в нашем контракте и нам нужно уметь составлять из входящего имени метода и сериализованного массива аргументов, вызов метода на объекте. Но ведь в момент кодогенерации мы знаем всё устройство контракта, по этому мы складываем рядышком с нашим файлом контракта ещё один файл, с тем же самым именем пакета, в который запихиваем все необходимые импорты, типы уже определены в главном файле и ненужны.
А вот и главное, обёртки над функциями. Имя обёртки дополняется каким нибудь префиксом и теперь обёртку легко искать.
symbol, err := p.Lookup("INSMETHOD_" + Method)
wrapper, ok := symbol.(func(ph proxyctx.ProxyHelper, object []byte,
data []byte) (object []byte, result []byte, err error))
Каждая обёртка имеет одинаковую сигнатуру, так что когда мы будем вызывать её из главной программы, нам не нужны лишние рефлексии, единственное, что обёртки функций отличаются от обёрток методов, они не получают и не возвращают состояние объекта.
Что же у нас внутри обёртки?
Мы создаём массив пустых переменных, соответствующего аргументам функции, помещаем его в переменную типа массив интерфейсов, и десериализуем в неё аргументы, если мы метод, надо ещё сериализовывать состояние объекта, вообщем как то так:
{{ range $method := .Methods }}
func INSMETHOD_{{ $method.Name }}(ph proxyctx.ProxyHelper, object []byte, data []byte) ([]byte, []byte, error) {
self := new({{ $.ContractType }})
err := ph.Deserialize(object, self)
if err != nil {
return nil, nil, err
}
{{ $method.ArgumentsZeroList }}
err = ph.Deserialize(data, &args)
if err != nil {
return nil, nil, err
}
{{ if $method.Results }}
{{ $method.Results }} := self.{{ $method.Name }}( {{ $method.Arguments }} )
{{ else }}
self.{{ $method.Name }}( {{ $method.Arguments }} )
{{ end }}
state := []byte{}
err = ph.Serialize(self, &state)
if err != nil {
return nil, nil, err
}
{{ range $i := $method.ErrorInterfaceInRes }}
ret{{ $i }} = ph.MakeErrorSerializable(ret{{ $i }})
{{ end }}
ret := []byte{}
err = ph.Serialize([]interface{} { {{ $method.Results }} }, &ret)
return state, ret, err
}
{{ end }}
Внимательный читатель поинтересуется, а что такое проксихелпер? — это такой объект-комбайн, который нам ещё потребуется, а пока мы используем его способность сериализовывать и десериализовывать.
Ну и любой читает спросит «а вот эти ваши аргументы, они откуда?» Здесь тоже понятный ответ, да text/template звёзд с неба не хватает, по этому мы вычисляем эти строки в коде, а не в шаблоне.
method.ArgumentsZeroList содержит что то типа
var arg0 int = 0
Var arg1 string = "”
Var arg2 ackwardType = ackwardType{}
Args := []interface{}{&arg0, &arg1, &arg2}
А Arguments соответственно содержит «arg0, arg1, arg2»
Таким образом мы можем вызвать всё что нам хочется, с любой сигнатурой.
Но не любой ответ сможем сериализовать, дело в том, что сериализаторы работают с рефлексией, а она не даёт доступа к неэкспортированным полям структур, именно по этому у нас есть специальный метод проксихелпера, который берёт объект интерфейса error и создаёт из него объект типа foundation.Error, который от обычного отличается тем, что текст ошибки находится в нём в экспортируемом поле, и мы можем его сериализовать, хоть и с некоторой потерей.
Но если мы будем использовать кодогенерируюьщий стерилизатор, то даже это нам не потребуется, мы компилируемая в том же пакете, у нас есть доступ к неэкспортируемым полям.
А что если мы хотим вызвать контракт из контракта?
Вы не понимаете всей глубины проблемы, если считаете что вызвать контракт из контракта легко. Дело в том, что правильность другого контракта должен подтвердить консенсус и факт этого вызова нужно положить подписанным в блокчейн, вообщем просто скомпилироваться с другим контрактом и вызывать на его метод — не получится, хотя и очень хочется. Но мы же друзья программистов, по этому должны дать им возможность делать всё на прямую, а все хитрости спрятать под капотом системы. Таким образом, разработка контракта ведётся как бы с прямыми вызовами, и контракты дёргают друг друга прозрачно, но когда мы собираем контракт для публикации, мы вместо другого контракта подсовываем прокси, которая про контракт знает только его адрес и сигнатуры вызовов.
Как бы всё это организовать? — Придётся хранить другие контракты в специальной директории, которую наш генератор сможет опознать и для каждого импортируемого контракта создать прокси.
Тоесть если мы встретили
import "ContractsDir/ContractAddress"
Мы записываем его в список импортированных контрактов.
Кстати для этого уже не нужно знать исходный код контракта, достаточно только знать описание, которое мы уже собирали, таким образом, если мы где то опубликуем такое описание, а все вызовы будут идти через основную систему, то нам вообщем то всё равно, на каком языке написан другой контракт, если мы можем на нём вызывать методы, мы сможем написать для него заглушку на Go, которая будет выглядеть как пакет с контрактом, который можно вызывать на прямую. Планы наполеоновские, давайте приступим к реализации.
В принципе у нас уже есть метод проксихелпера, вот с такой сигнатурой:
RouteCall(ref Address, method string, args []byte) ([]byte, error)
Этот метод можно вызывать напрямую из контракта, он вызывает удалённый контракт, возвращает сериализованный ответ, который нам нужно разобрать и вернуть нашему контракту. Но нужно чтобы для пользователя всё выглядело как
ret := contractPackage.GetObject(Address).Method(arg1,arg2, …)
Приступим, во первых в прокси нужно перечислить все типы, которые используются в сигнатурах методов контракта, но как мы помним, мы для каждого узла AST можем взять его текстовое представление, вот и настало время этого механизма.
Далее нам нужно создать тип контракт, в принципе свой класс он уже знает, нужен только адрес.
type {{ .ContractType }} struct {
Reference Address
}
Далее, нам нужно как то реализовать функцию GetObject, которая по адресу на блокчейне вернёт инстанс прокси, которая умеет работает с этим контрактом, а для пользователя выглядит собственно как инстанс контракта.
func GetObject(ref Address) (r *{{ .ContractType }}) {
return &{{ .ContractType }}{Reference: ref}
}
Что интересно, метод GetObject в режиме пользовательской отладки это прямо метод структуры BaseContract, а тут нет, ничего нам не мешает, соблюдая SLA, делать так, как нам удобно. Теперь мы умеем создавать прокси контракт, методы которого мы контролируем. Осталось собственно создать методы.
{{ range $method := .MethodsProxies }}
func (r *{{ $.ContractType }}) {{ $method.Name }}( {{ $method.Arguments }} ) ( {{ $method.ResultsTypes }} ) {
{{ $method.InitArgs }}
var argsSerialized []byte
err := proxyctx.Current.Serialize(args, &argsSerialized)
if err != nil {
panic(err)
}
res, err := proxyctx.Current.RouteCall(r.Reference, "{{ $method.Name }}", argsSerialized)
if err != nil {
panic(err)
}
{{ $method.ResultZeroList }}
err = proxyctx.Current.Deserialize(res, &resList)
if err != nil {
panic(err)
}
return {{ $method.Results }}
}
{{ end }}
Тут та же история с построением списка аргументов, так как мы ленивые и храним именно ast.Node метода, то для вычислений требуется множество приведений типов, которые шаблоны не умеют, так что всё подготовленно заранее. С функциями всё серьёзно сложнее, и это тема другой статьи. Функции у нас это конструкторы объектов и там сделан большой упор на то как на самом деле в нашей системе создаются объекты, факт создания регистрируется на удалённом исполнителе, объект передаётся на другой исполнитель, там проверяется и собственно сохраняется, причём способов сохранить много, вообщем не зря эта область знаний называется крипта. А идея в принципе простая, обёртка, внутри которой хранится только адрес, и методы, которые сериализуют вызов и дёргают наш синглтон комбайн, который делает всё остальное. Мы не можем пользоваться переданным проксихелпером, потому что пользователь нам его не передал, по этому пришлось сделать его синглтоном.
Ещё одно ухищрение — на самом деле мы ещё используем контекст вызова, это такой объект, в котором хранится информация о том, кто когда, зачем, почему вызвал наш смартконтракт, на основании этой информации пользователем принимается решение можно ли вообще дать исполнить, а если можно то как.
Раньше мы контекст передавали просто, это было неэкспрртируемое поле в типе BaseContract с сеттером и геттером, причём сеттер разрешал установку поля только один раз, соответственно контекст устанавливался перед выполнением контракта, а пользователь мог его только читать. Но вот проблема, пользователь этот контекст только читает, если он делает вызов какой то системной функции, например проксивызов другого контракта, то этот проксивызов никакого контекста не получает, так как никто ему его не передаёт. И вот на сцену выходят goroutine local storage. Мы решили не писать свою, а воспользоваться github.com/tylerb/gls
Она позволяет задавать и брать контекст для текущей горутины. Таким образом, если внутри контракта не было создано горутин, мы просто перед запуском контракта выставляем контекст в gls, даём пользователю теперь уже не метод, а просто функцию.
func GetContext() *core.LogicCallContext {
return gls.Get("ctx").(*core.LogicCallContext)
}
И он ею счастливо пользуется, но ею же мы пользуемся например в RouteCall (), чтобы понять какой контракт сейчас кого-то вызывает.
Пользователь в принципе может создать горутину, но если он это сделает, то потеряется контекст, так что нам нужно что то с этим делать, например если пользователь использует ключевое слово go, то парсером надо такие вызовы обернуть в нашу обёртку, которая контекст запомнит, создаст горутину и восстановит в ней контекст, но это тема другой статьи.
Всё вместе
Нам принципе нравится как работает тулчейн языка GO, на самом деле это куча разных команд, которые делают что то одно, которые вместе исполняются, когда вы делаете go build, например. Мы решили сделать так же, одна команда кладёт во временную директорию файл контракта, вторая рядышком подкладывает обёртку для него и вызывает несколько раз третью, которая для каждого импортируемого контракта создаёт прокси, четвёртая всё это компилирует, пятая публикует на блокчейн. И есть одна команда, чтобы запускать их все в правильном порядке.
Ура, у нас теперь есть тулчейн и рантайм для запуска GO из GO. Осталось ещё много проблем, например нужно как то выгружать неиспользуемый код, нужно как то определять что оно подвисло и перезапусткать подвисший процесс, но всё это задачи, которые понятно как решать.
Да, конечно же написанный нами код не претендует на библиотечность, его нельзя напрямую использовать, но вот почитать пример работающей кодогенерации — это всегда здорово, в своё время мне его не хватало. Соответственно часть кодогенерации можно посмотреть в компиляторе, а то как это запускается в исполнятеле