Добавляем Starlark в приложение на Go

от автора

Жаворонок, картинка утащена с Википедии

Жаворонок, картинка утащена с Википедии

Что за птица?

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, возможно, займёт достойное место в вашем коде.


ссылка на оригинал статьи https://habr.com/ru/articles/749842/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *