golang

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

  • вторник, 25 июля 2023 г. в 00:00:25
https://habr.com/ru/articles/749842/
Жаворонок, картинка утащена с Википедии
Жаворонок, картинка утащена с Википедии

Что за птица?

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