
Что за птица?
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илиFalseInteger, целое число, тип объединяет знаковые и беззнаковые целые числа
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, возможно, займёт достойное место в вашем коде.
