В последние полтора года я работал над Atlas, инструментом управления схемами баз данных, который мы разрабатываем в Ariga. В рамках этой работы я работал над созданием инфраструктуры для Atlas DDL, языка определения данных, который является основой для декларативного стиля работы Atlas по управлению схемами баз данных.
Язык Atlas основан на HCL, инструментарии для создания языков конфигурации с аккуратной и простой информационной моделью и синтаксисом. HCL был создан в компании HashiCorp и используется в таких популярных инструментах, как Terraform и Nomad. Мы выбрали HCL в качестве основы для нашего языка конфигурации по нескольким причинам:
- Его базовый синтаксис ясен и краток, легко читается людьми и машинами.
- Популярный в Terraform и других проектах в области DevOps / Infrastructure-as-Code, мы решили, что он будет хорошо знаком практикам, которые являются одной из основных аудиторий для нашего инструмента.
- Он написан на языке Go, что делает его очень простым для интеграции с остальной частью нашей кодовой базы в Ariga.
- Он имеет большую поддержку для расширения базового синтаксиса до полноценного DSL с использованием функций, выражений и контекстных переменных.
PCL: язык конфигурации пиццы
В оставшейся части этого поста мы продемонстрируем, как создать базовый язык конфигурации с помощью HCL и Go. Чтобы сделать обсуждение занимательным, давайте представим, что мы создаем новый продукт PaC (Pizza-as-Code), который позволяет пользователям определять свою пиццу в простых конфигурационных файлах на основе HCL и отправлять их в виде заказов в ближайшую пиццерию.
Заказы и контакты
Давайте начнем создание нашего языка конфигурации PaC с того, что позволим пользователям определить, куда они хотят доставить свою пиццу и кто является голодным контактом, ожидающим прибытия пиццы. Мы стремимся к чему-то вроде:
contact {
name = "Sherlock Holmes"
phone = "+44 20 7224 3688"
}
address {
street = "221B Baker St"
city = "London"
country = "England"
}
Для захвата этой конфигурации мы определим структуру Go Order
с подструктурами для захвата Contact
и Address
:
type (
Order struct {
Contact *Contact `hcl:"contact,block"`
Address *Address `hcl:"address,block"`
}
Contact struct {
Name string `hcl:"name"`
Phone string `hcl:"phone"`
}
Address struct {
Street string `hcl:"street"`
City string `hcl:"city"`
Country string `hcl:"country"`
}
)
Кодовая база Go HCL содержит два пакета с достаточно высокоуровневым API для декодирования документов HCL в структуры Go: hclsimple
(GoDoc)
и gohcl
(GoDoc). Оба пакета полагаются на то, что пользователь предоставит теги полей Go struct для отображения полей из конфигурационного файла в поля struct.
Мы начнем пример с использования более простого из них, с удивительным
названием hclsimple
:
func TestOrder(t *testing.T) {
var o Order
if err := hclsimple.DecodeFile("testdata/order.hcl", nil, &o); err != nil {
t.Fatalf("failed: %s", err)
}
require.EqualValues(t, Order{
Contact: &Contact{
Name: "Sherlock Holmes",
Phone: "+44 20 7224 3688",
},
Address: &Address{
Street: "221B Baker St",
City: "London",
Country: "England",
},
}, o)
}
Размеры пиццы и начинки (использование статических значений)
Далее, давайте добавим возможность заказа реальной пиццы в наше PaC-приложение. Чтобы описать пиццу на нашем языке конфигурации, пользователи должны иметь возможность сделать
что-то вроде
pizza {
size = XL
count = 1
toppings = [
olives,
feta_cheese,
onions,
]
}
Обратите внимание, что для того, чтобы сделать наш API более явным, пользователи не передают строковые значения в поле size
или toppings
, а вместо этого они используют заранее определенные, статические идентификаторы (называемые «переменными» во внутреннем API HCL), такие как XL
или feta_cheese
.
Для поддержки такого поведения мы можем передать hcl.EvalContext
(GoDoc),
который предоставляет переменные и функции, которые должны быть использованы для оценки выражения.
Для создания этого контекста мы создадим вспомогательную функцию ctx()
:
func ctx() *hcl.EvalContext {
vars := make(map[string]cty.Value)
for _, size := range []string{"S", "M", "L", "XL"} {
vars[size] = cty.StringVal(size)
}
for _, topping := range []string{"olives", "onion", "feta_cheese", "garlic", "tomatoe"} {
vars[topping] = cty.StringVal(topping)
}
return &hcl.EvalContext{
Variables: vars,
}
}
Чтобы использовать ее, нам нужно добавить блок pizza
в нашу структуру верхнего уровня Order
:
type (
Order struct {
Contact *Contact `hcl:"contact,block"`
Address *Address `hcl:"address,block"`
Pizzas []*Pizza `hcl:"pizza,block"`
}
Pizza struct {
Size string `hcl:"size"`
Count int `hcl:"count,optional"`
Toppings []string `hcl:"toppings,optional"`
}
// ... More types ...
)
Вот наш блок pizza
, считанный с помощью ctx()
в действии:
func TestPizza(t *testing.T) {
var o Order
if err := hclsimple.DecodeFile("testdata/pizza.hcl", ctx(), &o); err != nil {
t.Fatalf("failed: %s", err)
}
require.EqualValues(t, Order{
Pizzas: []*Pizza{
{
Size: "XL",
Toppings: []string{
"olives",
"feta_cheese",
"onion",
},
},
},
}, o)
}
Сколько пицц заказать? (Использование функций в HCL)
Последняя загадка в любом заказе на доставку пиццы — это, конечно же, сколько пицц заказывать. Чтобы помочь нашим пользователям в решении этой загадки, давайте поднимем уровень нашего DSL и добавим функцию for_diners
, которая будет принимать количество посетителей и рассчитывать для пользователя, сколько пицц нужно заказать. Это будет выглядеть примерно так:
pizza {
size = XL
count = for_diners(3)
toppings = [
tomato
]
}
Основываясь на общепринятой эвристике, согласно которой следует заказывать по 3 ломтика на каждого посетителя и округлять в большую сторону, мы можем зарегистрировать следующую функцию в нашем EvalContext
:
func ctx() *hcl.EvalContext {
// .. Variables ..
// Define a the "for_diners" function.
spec := &function.Spec{
// Return a number.
Type: function.StaticReturnType(cty.Number),
// Accept a single input parameter, "diners", that is not-null number.
Params: []function.Parameter{
{Name: "diners", Type: cty.Number, AllowNull: false},
},
// The function implementation.
Impl: func (args []cty.Value, _ cty.Type) (cty.Value, error) {
d := args[0].AsBigFloat()
if !d.IsInt() {
return cty.NilVal, fmt.Errorf("expected int got %q", d)
}
di, _ := d.Int64()
neededSlices := di * 3
return cty.NumberFloatVal(math.Ceil(float64(neededSlices) / 8)), nil
},
}
return &hcl.EvalContext{
Variables: vars,
Functions: map[string]function.Function{
"for_diners": function.New(spec),
},
}
}
Тестирование функции for_diners
:
func TestDiners(t *testing.T) {
var o Order
if err := hclsimple.DecodeFile("testdata/diners.hcl", ctx(), &o); err != nil {
t.Fatalf("failed: %s", err)
}
// For 3 diners, we expect 2 pizzas to be ordered.
require.EqualValues(t, 2, o.Pizzas[0].Count)
}
Подведение итогов
С этими функциями, я думаю, мы можем закончить работу над этим прототипом первого в мире продукта Pizza-as-Code. Поскольку исходный код этих примеров доступен на GitHub под лицензией Apache 2.0
я искренне надеюсь, что кто-нибудь возьмет и построит эту штуку!
В этом посте мы рассмотрели некоторые базовые вещи, которые можно сделать для создания языка конфигурации для ваших пользователей с помощью HCL. Есть много других классных возможностей, которые мы встроили в язык Atlas (например, входные переменные, ссылки на блоки и полиморфизм блоков), так что если вам интересно почитать об этом подробнее, не стесняйтесь, пингуйте меня в Twitter.