Добавляем Starlark в приложение на Go
- вторник, 25 июля 2023 г. в 00:00:25
Starlark (ранее известный как Skylark) - питоноподобный язык, изначально разработанный для системы сборки Bazel, со временем выбравшийся за её пределы через интерпретаторы для Go и Rust.
Язык располагает к использованию в роли инструмента конфигурации, однако, благодаря хорошо написанному интерпретатору на Go и детальному описанию спецификации, его можно использовать в виде встроенного в приложение языка программирования - например, когда вы хотите дать пользователю повзаимодействовать с объектом логики приложения, но не хотите постоянно плодить сущности под почти одинаковые кейсы через сотню настроечных параметров (мой случай).
Достаточно полных туториалов в процессе работы у меня найти не удалось, поэтому появилась идея написать небольшой материал по этой теме. В статье пройдемся по работе с Starlark в Go, от простейшего запуска скрипта до добавления нового встроенного типа.
Я не настоящий разработчик, только притворяюсь пишу пет-проект в свободное время, поэтому в тексте могут встречаться не совсем корректные определения. Обо всех косяках, очепятках и прочих возможных ошибках прошу сообщать, буду исправляться.
Для начала работы достаточно сделать две вещи - написать исходник скрипта и выполнить его:
# hello.star
print("Hello from Starlark script")
// main.go
package main
import "go.starlark.net/starlark"
func main() {
_, err := starlark.ExecFile(&starlark.Thread{}, "hello.star", nil, nil)
if err != nil {
panic(err)
}
}
ExecFile загружает исходный файл (или исходный код, если третий аргумент функции не nil
), парсит и выполняет код в указанном треде, после чего возвращает словарь с объектами глобальной области видимости скрипта - переменными, функциями. Глобальная область видимости замораживается, что означает, что любые попытки изменения глобальных определений будут вызывать ошибку выполнения.
Запускаем, получаем:
$ go run main.go
Hello from Starlark script
Хорошее начало, однако, пользы от этого мало - всё дело в том, что скрипт стоит рассматривать не как отдельную программу, а как встраиваемый модуль, функции которого мы собираемся вызывать по необходимости.
Попробуем иначе:
# hello.star
def hello():
print("Hello from Starlark script")
Повторно запускаем код на Go и... ничего не происходит (ожидаемо), поскольку мы лишь объявили функцию, но не обратились к ней. А значит, нужно вызвать её из основной программы:
// main.go
package main
import "go.starlark.net/starlark"
func main() {
// выделим поток, в котором будем загружать и выполнять скрипт
thread := &starlark.Thread{}
globals, err := starlark.ExecFile(thread, "hello.star", nil, nil)
if err != nil {
panic(err)
}
_, err = starlark.Call(thread, globals["hello"], nil, nil)
if err != nil {
panic(err)
}
}
Результат:
$ go run main.go
Hello from Starlark script
globals
это тот самый упомянутый ранее словарь с глобальными переменными и функциями. Через Call мы вызываем нашу функцию hello()
по имени, получая её из словаря. Третьим и четвертым аргументом в функцию можно передать позиционные и именованные аргументы.
"Из коробки" Starlark имеет одиннадцать встроенных типов (и ещё несколько из доступных модулей, но сейчас не о них):
None, аналог nil
, используемый тогда, когда нужно выразить отсутствие значения
Boolean, логическое True
или False
Integer, целое число, тип объединяет знаковые и беззнаковые целые числа
Float, число с плавающей точкой
String, строка в UTF-8
List, лист, изменяемая последовательность значений
Tuple, кортеж, как лист, только неизменяемый (но содержащиеся в кортеже значения можно изменять)
Dictionary, словарь "ключ-значение", в качестве ключей поддерживаются только хэшируемые типы
Set, множество, под капотом использует хэш-таблицу, поэтому требование к значениям то же, что к ключам в словаре; Go-специфичный тип, использование которого требует установки специального флага
Function, функции, определенные в коде Starlark
Built-in function, отдельный тип для функций (или методов, о чём позже) реализованных в Go
Со стороны Go все типы обязаны реализовывать интерфейс Value, не считая специфичных для типов интерфейсов, таких как Callable для функций - эта информация пригодится при написании собственных типов.
Итак, попробуем передать что-нибудь в нашу функцию:
# hello.star
def hello(message):
print(message)
// main.go
package main
import "go.starlark.net/starlark"
func main() {
thread := &starlark.Thread{}
globals, err := starlark.ExecFile(thread, "hello.star", nil, nil)
if err != nil {
panic(err)
}
// здесь готовим позиционные аргументы для вызываемой функции
args := starlark.Tuple{
starlark.String("Hello from Golang"),
}
_, err = starlark.Call(thread, globals["hello"], args, nil)
if err != nil {
panic(err)
}
}
Результат:
$ go run main.go
Hello from Golang
Таким образом можно передавать любое разумное количество аргументов в вызываемую функцию. Стоит отметить, что попытка передачи большего или меньшего числа аргументов является ошибкой времени выполнения скрипта - если задали в сигнатуре три аргумента, три и передаём.
Попробуем получить что-то из нашей функции. Напишем сложение чисел в качестве простейшего примера:
# hello.star
def sum(x, y):
return x + y
// main.go
package main
import "go.starlark.net/starlark"
func main() {
thread := &starlark.Thread{}
globals, err := starlark.ExecFile(thread, "hello.star", nil, nil)
if err != nil {
panic(err)
}
// здесь готовим позиционные аргументы для вызываемой функции
args := starlark.Tuple{
starlark.MakeInt(42),
starlark.MakeInt(451),
}
result, err := starlark.Call(thread, globals["sum"], args, nil)
if err != nil {
panic(err)
}
print(result.String()) // распечатаем результат
}
Запускаем:
$ go run main.go
493
В переменной reslut
хранится какой-то результат вызванной функции. Сейчас получить значение вышло переводом Value
в строку, однако в реальном использовании понадобится выполнить приведение интерфейса к нужному типу. В качестве примера:
func toGoValue(starValue starlark.Value) (any, error) {
switch v := starValue.(type) {
case starlark.String:
return string(v), nil
case starlark.Bool:
return bool(v), nil
case starlark.Int: // int, uint both here
if value, ok := v.Int64(); ok {
return value, nil
}
if value, ok := v.Uint64(); ok {
return value, nil
}
return nil, errors.New("unknown starlark Int representation")
case starlark.Float:
return float64(v), nil
case *starlark.List:
slice := []any{}
iter := v.Iterate()
defer iter.Done()
var starValue starlark.Value
for iter.Next(&starValue) {
goValue, err := toGoValue(starValue)
if err != nil {
return nil, err
}
slice = append(slice, goValue)
}
return slice, nil
case *starlark.Dict:
datamap := make(map[string]any, v.Len())
for _, starKey := range v.Keys() {
goKey, ok := starKey.(starlark.String)
if !ok { // datamap key must be a string
return nil, fmt.Errorf("datamap key must be a string, got %v", starKey.String())
}
// since the search is based on a known key,
// it is expected that the value will always be found
starValue, _, _ := v.Get(starKey)
goValue, err := toGoValue(starValue)
if err != nil {
return nil, err
}
datamap[goKey.String()] = goValue
}
return datamap, nil
default:
return nil, fmt.Errorf("%v is not representable as datamap value", starValue.Type())
}
}
Небольшое замечание: в приведенном выше коде стоит обратить внимание на работу с итератором - когда он больше не нужен, требуется явно вызывать Done()
.
Интерпретатор Starlark позволяет расширять язык, добавляя новые типы - достаточно реализовать интерфейс Value.
Представим, что у нас есть тип, пусть это будет пользователь, пример синтетический:
type User struct {
name string
mail *mail.Address
}
// конструктор пригодится позже
func NewUser(name, address string) (*User, error) {
mail, err := mail.ParseAddress(address)
if err != nil {
return nil, err
}
if len(name) == 0 {
return nil, errors.New("name required")
}
return &User{name: name, mail: mail}, nil
}
func (u *User) Rename(newName string) {
u.name = newName
}
func (u *User) ChangeMail(newMail string) error {
mail, err := mail.ParseAddress(newMail)
if err != nil {
return err
}
u.mail = mail
return nil
}
func (u *User) Name() string {
return u.name
}
func (u *User) Mail() string {
return u.mail.String()
}
и мы хотим сделать его доступным в Starlark. Для этого понадобится тип-обёртка с соответствующими методами:
var _ starlark.Value = &StarlarkUser{}
type StarlarkUser struct {
user *User
}
func (e *StarlarkUser) String() string {
return fmt.Sprintf("name: %v, mail: %v", e.user.Name(), e.user.Mail())
}
func (e *StarlarkUser) Type() string {
return "user"
}
// для упрощения, не будем заморачиваться с реализацией методов ниже
func (e *StarlarkUser) Freeze() {}
func (e *StarlarkUser) Truth() starlark.Bool {
return len(e.user.Name()) > 0 && len(e.user.Mail()) > 0
}
func (e *StarlarkUser) Hash() (uint32, error) {
return 0, errors.New("not hashable")
}
МетодыString()
, Type()
и Truth()
нужны для использования типа в встроенных в язык функциях str()
, type()
и bool()
(помимо этого, второй несёт информацию о типе), Hash()
используется для хэширования значения для использования в хэш-мапе в словарях и сетах, а Freeze()
нужен для замораживания объекта (как видно, гарантия неизменяемости объекта после замораживания глобальной области видимости лежит целиком на реализации типа).
Такой тип уже можно как-то использовать, попробуем:
# user.star
def user_info(user):
print(type(user)) # напечатает тип
print(user) # напечатает строковое представление объекта
args := starlark.Tuple{
&StarlarkUser{
&User{
name: "John",
mail: &mail.Address{
Name: "John",
Address: "John@gmail.com",
},
},
},
}
_, err = starlark.Call(thread, globals["user_info"], args, nil)
if err != nil {
panic(err)
}
Результат:
user
name: John, mail: "John" <John@gmail.com>
У нас получился полноценный тип, однако, с ним нельзя выполнять никаких операций - для их реализации нужно поддержать соответствующие интерфейсы, например, HasUnary, HasBinary и т.д., чего мы в рамках этой статьи делать конечно же не будем, а то текста и так уже много, а впереди ещё создание встроенных функций и методов.
Тут как раз пригодится конструктор NewUser()
. Встроенные функции реализуются через Builtin:
// тело функции
func newUser(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var name, mail string
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &name, &mail); err != nil {
return starlark.None, err
}
user, err := NewUser(name, mail)
if err != nil {
return starlark.None, err
}
return &StarlarkUser{user: user}, nil
}
func main() {
thread := &starlark.Thread{}
// собираем наши встраиваемые функции, которые затем передаются в ExecFile()
builtins := starlark.StringDict{
"newUser": starlark.NewBuiltin("newUser", newUser),
}
globals, err := starlark.ExecFile(thread, "user.star", nil, builtins)
if err != nil {
panic(err)
}
_, err = starlark.Call(thread, globals["user_info"], nil, nil)
if err != nil {
panic(err)
}
}
Появилось несколько новых вещей. Во-первых, встраиваемые функции должны соответствовать типу func(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error)
.
Во-вторых, встраиваемые функции, а точнее, все предопределенные объекты, собираются в словарь и передаются в ExecFile()
, чтобы сделать их доступными для кода Starlark.
В-третьих, для распаковки аргументов можно использовать UnpackPositionalArgs - в ней будут выполнены проверки количества и типов переданных аргументов.
Пробуем вызвать:
# user.star
def user_info():
user = newUser("John", "john@gmail.com")
print(type(user))
print(user)
$ go run main.go
user
name: John, mail: <john@gmail.com>
Работает! Стоит отметить, что таким образом можно передавать не только функции, но и любые другие объекты, тип которых реализует Value
- например, можно передать заранее собранный набор констант, которые могут пригодиться в встраиваемом коде.
Добавление "методов" к кастомным объектам реализуется схожим образом, через Buitlin
, при этом, тип, имеющий методы, должен реализовывать интерфейс HasAttrs.
Но сначала подготовим наши методы:
func userName(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 0); err != nil {
return starlark.None, err
}
// получаем ресивер, приводим к нужному типу и работаем уже с ним
name := b.Receiver().(*StarlarkUser).user.Name()
return starlark.String(name), nil
}
func userRename(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var name string
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &name); err != nil {
return starlark.None, err
}
b.Receiver().(*StarlarkUser).user.Rename(name)
return starlark.None, nil
}
Механика работы методов отличается от функций тем, что из передаваемого Builtin
извлекается ресивер, приводится к нужному типу, после чего над ним выполняются необходимые манипуляции.
Поглядывая на реализацию встроенных типов в библиотеке интерпретатора, собираем наши методы в словарь:
var userMethods = map[string]*starlark.Builtin{
"name": starlark.NewBuiltin("name", userName),
"rename": starlark.NewBuiltin("rename", userRename),
}
И реализуем HasAttrs
:
func (e *StarlarkUser) Attr(name string) (starlark.Value, error) {
b, ok := userMethods[name]
if !ok {
return nil, nil // нет такого метода
}
return b.BindReceiver(e), nil
}
func (e *StarlarkUser) AttrNames() []string {
names := make([]string, 0, len(userMethods))
for name := range userMethods {
names = append(names, name)
}
sort.Strings(names)
return names
}
BindReceiver()
создает новый Builtin
, который несет в себе переданное значение, к которому мы получаем доступ в методе.
Пробуем:
# user.star
def user_info():
user = newUser("John", "john@gmail.com")
user.rename("Jim")
print(user.name())
$ go run main.go
Jim
Вот таким не очень хитрым образом у нас получилось добавить методы к нашему кастомному типу.
У Starlark есть несколько встроенных модулей, которые могут быть полезными, вот некоторые из них:
math - модуль с математическими функциями и парой констант
time - функции и типы для работы с временем
Для загрузки модулей есть специальная функция load, однако просто так её использовать не выйдет - загрузка модулей выполняется функцией Load() в потоке, и по умолчанию её нет, а значит, нужно её реализовать. Расширим наш изначальный поток:
import (
"go.starlark.net/lib/math"
"go.starlark.net/lib/time"
)
thread := &starlark.Thread{
Load: func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
switch module {
case "math.star":
return starlark.StringDict{
"math": math.Module,
}, nil
case "time.star":
return starlark.StringDict{
"time": time.Module,
}, nil
default:
return nil, fmt.Errorf("no such module: %v", module)
}
},
}
Функция будет вызываться на каждый load()
в коде. Попробуем вывести текущее время:
# modules.star
load("time.star", "time")
print(time.now()) # выведет текущее время
Второй (и последующие) аргументы load()
определяют импортируемые литералы, при этом, начинающиеся с _
не импортируются. В случае реализованных на Go модулей импортируется структура, через поля которой мы и обращаемся к функциям и константам.
Можно писать модули и на Starlark, а для удобства воспользоваться расширением starlarkstruct, чтобы работа с нашим кастомным модулем не отличалась от работы с встроенными:
builtins := starlark.StringDict{
"newUser": starlark.NewBuiltin("newUser", newUser),
"struct": starlark.NewBuiltin("struct", starlarkstruct.Make),
}
Поддержим загрузку файлов в функции загрузки модулей:
thread := &starlark.Thread{
Load: func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
switch module {
case "math.star":
return starlark.StringDict{
"math": math.Module,
}, nil
case "time.star":
return starlark.StringDict{
"time": time.Module,
}, nil
default: // внешний модуль, загружаем из файла
script, err := os.ReadFile(module)
if err != nil {
return nil, err
}
entries, err := starlark.ExecFile(thread, module, script, builtins)
if err != nil {
return nil, err
}
return entries, nil
}
},
}
Определим модуль:
# myModule.star
def hello():
print("hello from module")
# создаем структуру с экспортируемыми литералами
myModule = struct(
hello = hello
)
И воспользуемся им в основном коде:
load("myModule.star", "myModule")
myModule.hello() # выведет hello from module
Благодаря тому, что мы определили в модуле одноименную структуру с литералами, модуль очень просто импортируется и используется.
В моём случае встраивание Starlark в приложение позволило дать пользователям инструмент для гибкой конфигурации и добавления кастомной логики в некоторые контролируемые этапы с работы событиями. Буду рад, если этот материал окажется полезен и вам, а Starlark, возможно, займёт достойное место в вашем коде.