Конфигурирование приложений — это интересная тема. Мало того, что форматов конфигурации в сообществе инженеров много, ситуация осложняется тем, что выбор того или иного языка определяет, как вашим приложением будут пользоваться люди. Инженеры, которые будут выкладывать ваш бэкенд в абстрактную dev- или prod-среду, будут смотреть на ваше приложение как на чёрный ящик с одной лишь ручкой: механизмом настроек.
Я, как инженер, встречал удобные и не очень текстовые конфигурации: conf в Nginx, ini в systemd, JSON в VSCode… А также YAML. Он не стал новым словом в языках, но показал, какой красивой может быть конфигурация. Впрочем, сам по себе язык тупой как пробка: если вы попробуете писать на YAML что-то сложное, с переменными или циклами, то получится химера вроде Ansible. Или вроде манифестов Kubernetes, у которого диалект настолько переусложнён, что его приходится шаблонизировать с помощью Helm.
Да, как понятно из заголовка, я хочу поговорить про язык Terraform, но сначала…
Существующие решения
Какие вообще есть языки в мире open source:
ini, пришедший из Windows.
А также его духовный наследник TOML, отличающийся большей структурированностью и типами.
Java properties. Просто набор из строк ключ-значение.
dotenv. Тоже в каком-то смысле популярный язык конфигурации из ключ-значение.
JSON, который ещё можно встретить как язык конфигурации…
…и YAML, наследующий структуру и типы JSON в более читаемом формате, и предоставляющий несколько расширений для строк и блоков.
HOCON, интересный язык, который можно периодически встретить в мире Java.
XML. Боже упаси. Нет, нет и нет. Не используйте XML как язык конфигурации в 2022 году. Пожалуйста.
Starlark, который можно встретить в Bazel. Очень похож на Python.
conf, антиформат, под которым вообще может скрываться что угодно: от конфигов того же Nginx или какой-нибудь Icinga2 до чёрт знает чего. Грубо говоря, это универсальное расширение для DSL.
Groovy, который, вообще-то, полноценный язык программирования, но его можно применять как встраиваемый язык конфигурации, чем пользуется Jenkins и Gradle.
KotlinScript, который дальше Gradle и TeamCity я нигде не встречал.
И многие, многие другие.
А ещё есть HCL, язык, знакомый многим по Terraform.
Hashicorp Configuration Language
Так де-юре расшифровывается HCL, но на деле это больше, чем язык конфигурации: согласно официальному описанию, это инструментарий для создания своего языка, который одинаково хорошо парсится человеком и машиной. По синтаксису он больше похоже на DSL, поскольку структура конфигурации очень гибкая, может содержать блоки, переменные и вызовы функций.
Грамматика языка описана в этом документе, а, попросту говоря, код вдохновлён конфигурацией Nginx и выглядит так:
variable = “value” block { type = list(string) attribute = [123, 456] innerblock { key = “val” } } # more real example document “markdown” “example” { name = “example” content = file(“example.md”) }
Здесь видно следующее:
У языка есть все необходимые для жизни типы: числа, строки, списки и map-ы ключ-значение.
Данные структурированы в блоки, которые могут быть вложенными. В объявлении блоков может быть два дополнительных поля (type и id).
Можно вызывать функции.
Хорошо, с языком, в целом, разобрались. А как с ним работать из Go? На сцену приглашается фреймворк hashicorp/hcl! Он состоит из следующих библиотек:
Одноимённая библиотека hcl/v2 содержит примитивы и интерфейсы, общие для других библиотек:
Body(ветвь в дереве конфигурации),Diagnostic(структура сообщений от парсера языка) и другие.gohcl используется для преобразования
hcl.Bodyв структуры Go.hcldec — это высокоуровневый API для валидации и работы с интерфейсом
hcl.Body.hclparse содержит инструменты для разбора как «нативного» синтаксиса HCL, так и JSON HCL. Да, у любого HCL-кода есть и JSON-эквивалент.
Пакет hclsimple хорош для начала работы с фреймворком, в нём есть функции высокого порядка для парсинга файлов и байтов в те же структуры.
hclsyntax — это пакет с парсером, AST и другим низкоуровневым кодом.
hclwrite позволяет сгенерировать HCL-конфиг по спецификации и данным.
json — JSON-парсер HCL, о JSON-формате упоминалось чуть выше.
Пакет hclsimple
Итак. Есть hclsimple, идеальный для знакомства с системой. Попробуем написать тест?
import ( "os" "path" "testing" "github.com/hashicorp/hcl/v2/hclsimple" ) type Config1 struct { Name string `hcl:"name"` Count int64 `hcl:"count"` } func TestConfig1(t *testing.T) { content := []byte(` name = "Дороу" count = 64 `) var cfg Config1 err := hclsimple.Decode("test.hcl", content, nil, &cfg) if err != nil { t.Error(err) } if cfg.Name != "Дороу" { t.Errorf("cfg.name: expected 'Дороу', got %v", cfg.Name) } if cfg.Count != 64 { t.Errorf("cfg.count: expected 64, got %v", cfg.Count) } }
Что можно сказать об этом коде? Здесь есть hclsimple.Decode(), который принимает на вход имя файла (можно несуществующее) и байты, которые затем парсит в структуру. У функции есть ещё компаньон DecodeFile(), принимающий имя файла.
Что произойдёт, если Decode получит некорректное содержимое?
count = "Пашов нафих" === RUN TestConfig1 /home/igor/…/config_test.go:52: test.hcl:3,10-21: Unsuitable value type; Unsuitable value: a number is required
Конечно, мы получили сообщение об ошибке, но вот что интересно: HCL вывел позицию ошибки в файле, что крайне удобно при последующей диагностике неполадок.
Также в синтаксисе HCL допускается добавлять в объявления блока двух специальных полей (label): id и type. Пробуем:
type Document struct { Format string `hcl:"type,label"` Name string `hcl:"id,label"` Content string `hcl:"content"` } type Config2 struct { Docs []Document `hcl:"document,block"` } func TestConfig2(t *testing.T) { content := []byte(` document "markdown" "readme" { content = "this is readme" } document "rst" "development" { content = "dev process" } `) // …
Остальная часть теста остаётся той же.
Также можно описать в структуре только id:
type Folder struct { Name string `hcl:"id,label"` Items []string `hcl:"items"` } type Config3 struct { Docs []Document `hcl:"document,block"` Folders []Folder `hcl:"folder,block"` } func TestConfig3(t *testing.T) { content := []byte(` document "markdown" "readme.md" { content = "this is readme" } document "rst" "development.rst" { content = "dev process" } folder "project" { items = ["readme.md", "development.rst"] } `
Отлично! С блоками и примитивными типами разобрались
Выполнение выражений
Но бывает так, что примитивных типов мало, и хочется, например, обращаться к другим атрибутам других блоков, как в Terraform. Для этого есть тип поля hcl.Expression. Но для работы с ним придётся спуститься немного поглубже в фреймворк HCL, точнее, в его систему типов и их преобразования. Этими задачами занимается библиотека zclconf/go-cty. В общем, довольно лирики:
Код
import ( "testing" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsimple" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" ) type Document struct { Format string `hcl:"type,label" cty:"format"` Name string `hcl:"id,label" cty:"name"` Filename string `hcl:"filename" cty:"filename"` Content string `hcl:"content" cty:"content"` } type Folder struct { Name string `hcl:"id,label"` Items hcl.Expression `hcl:"items,attr"` ParsedItems []string } type Config3 struct { Docs []Document `hcl:"document,block"` Folders []Folder `hcl:"folder,block"` } func TestConfig3(t *testing.T) { content := []byte(` document "markdown" "readme" { filename = "readme.md" content = "this is readme" } document "rst" "development" { filename = "development.rst" content = "dev process" } folder "project" { items = [doc.readme.name, doc.development.name] } `) docType := cty.Object(map[string]cty.Type{ "format": cty.String, "filename": cty.String, "name": cty.String, "content": cty.String, }) ctx := hcl.EvalContext{ Variables: map[string]cty.Value{ "doc": cty.EmptyObjectVal, }, } var cfg Config3 err := hclsimple.Decode("docs.hcl", content, &ctx, &cfg) if err != nil { t.Error(err) } docs := make(map[string]Document) docMapType := make(map[string]cty.Type) for _, doc := range cfg.Docs { docs[doc.Name] = doc docMapType[doc.Name] = docType } ctx.Variables["doc"], err = gocty.ToCtyValue(docs, cty.Object(docMapType)) if err != nil { t.Error(err) } for _, folder := range cfg.Folders { val, diags := folder.Items.Value(&ctx) if diags.HasErrors() { t.Error(diags) } items := val.AsValueSlice() folder.ParsedItems = make([]string, 0, len(items)) for _, v := range items { folder.ParsedItems = append(folder.ParsedItems, v.AsString()) } } }
Что в коде нового:
В структуре
Documentпоявились тегиcty. Они нужны для функцииToCtyValue().Переменная
docType, в которой явно описываем поля и типы структурыDocument.В
FolderполеItemsтеперь типаhcl.Expression. Почему не[]hcl.Expression? Просто потому, что, во-первых, массив с переменными — это тоже выражение; во-вторых, десериализатор не сможет привести массив выражений к нужной структуре.В
EvalContextпоявилась пустая переменная doc типа объекта.Проходимся по десериализованным документам, создаём map-ы с именем и структурой документа, а также с именем и описанием типов структуры. Для чего перечислять тип документа в каждом элементе? Если мы хотим обращаться к данным через точку, то надо во всех местах ставить тип
cty.Object(). А так как у объектов атрибуты могут быть разных типов, то придётся указывать тип каждого документа.Записываем в переменную
docEvalContext-а результат преобразования структуры вcty.Value(функцияgocty.ToCtyValue()).Для выполнения выражения используется метод
Expression.Value(ctx). Контекст при желании может быть нулевым, но сейчас мы его заполнили переменной для того, чтобы в выражениях можно было обращаться к ним.Кастуем значение выражения к слайсу, каждый его элемент кастуем к строке, и только тогда добавляем в каталог.
Если в двух словах, то да, работа с переменными и выражениями в HCL трудна: надо проходить по конфигурации в несколько этапов, а если появляются графы зависимостей, как в Terraform, то становится вообще понуро! С другой стороны, легко это всё равно нельзя сделать, и вообще здорово, что фреймворк позволяет отложенно обрабатывать выражения, а не в один прогон, когда парсится файл.
Анализ выражений
К слову, если хочется не обрабатывать все переменные, а просто определить по выражению, к каким переменным идёт обращение, то есть метод Expression.Variables(). Он возвращает []hcl.Traversal, то есть массив из обращений к переменным. Traversal, в свою очередь, это путь поиска значения: в пути могут встречаться обращения к индексам, к ключам map-ы, к атрибутам структуры, и так далее.
Попробуем реализовать разбор []hcl.Traversal для частного случая с переменной doc:
Код
func TestConfig4(t *testing.T) { var cfg Config3 // … docRefs := make(map[string]int, 0) for _, f := range cfg.Folders { varRefs := f.Items.Variables() for _, varref := range varRefs { if varref.RootName() != "doc" { t.Errorf("%s is not a doc variable", varref.RootName()) } for lvl, it := range varref[1:] { switch value := it.(type) { case hcl.TraverseAttr: if lvl == 0 { if _, ok := docRefs[value.Name]; !ok { docRefs[value.Name] = 1 } } case hcl.TraverseIndex: t.Error("indexing operations not supported") } } } } docType := cty.Object(map[string]cty.Type{ "format": cty.String, "name": cty.String, "filename": cty.String, "content": cty.String, }) docVars := make(map[string]Document) docTypes := make(map[string]cty.Type) for _, doc := range cfg.Docs { for ref, _ := range docRefs { if ref == doc.Name { docVars[doc.Name] = doc docTypes[doc.Name] = docType break } } } var err error ctx.Variables["doc"], err = gocty.ToCtyValue(docVars, cty.Object(docTypes)) // дальше парсим как раньше }
Из интересного:
У
Config.Items (типhcl.Expression) вызываем методVariables(), который возвращает перечень обращений к переменным ([]hcl.Traversal, о котором говорилось ранее).Поскольку
Traversal— это псевдоним для[]Traverser, то по нему тоже можно итерироваться.Traverser— это общий интерфейс, поэтому в обходе цикла понадобится кастовать к конкретным структурам, то есть делатьswitch value := it.(type).У
Traverseтри реализации — этоTraverseAttr,TraverseIndex, а такжеTraverseSplat.Если переменная типа
doc.<name>.<attrs>, то записываемnameвdocRefs(это наше а-ля set) с исключением дубликатов.Проходимся по
cfg.Docs, если имя документа есть вdocRefs, то добавляем его в объект переменной.
Частичный парсинг
Потенциально можно пойти ещё дальше в сторону не просто разбора обращения к переменным, а ещё и в сторону частичного разбора конфига в целом:
func TestConfig4(t *testing.T) { // ... hclfile, diags := hclsyntax.ParseConfig(content, "docs.hcl", hcl.InitialPos) if diags.HasErrors() { t.Error(diags) } bc, _, _ := hclfile.Body.PartialContent(folderSchema) folder := bc.Blocks[0] if folder.Labels[0] != "project" { t.Errorf("folder name: expected %v, got %v", "project", folder.Labels[0]) } attrs, diags := folder.Body.JustAttributes() if diags.HasErrors() { t.Error(diags) } for key, _ := range attrs { if key != "items" { t.Errorf("folder attr: expected %v, got %v", "items", key) } } } var folderSchema = &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ { Type: "folder", LabelNames: []string{"name"}, }, }, Attributes: []hcl.AttributeSchema{ {Name: "items", Required: true}, }, }
Здесь через низкоуровневый
hclsyntax.ParseConfig() конфиг парсится вhcl.File.Это нужно для того, чтобы потом обратиться к его полю
Body(типhcl.Body) и получить частичный результат (hcl.BodyContent).Также
PartialContent()возвращает оставшуюся частьhcl.Bodyи «диагностику». Диагностика при частичном парсинге будет содержать ошибки, поэтому её мы игнорируем, как и тело, которое нам сейчас не нужно.PartialContent()принимает только один аргумент —hcl.BodySchema, схему документа для валидации.В конце берём первый попавшийся блок, смотрим на его первую метку, а также на атрибуты.
Функции
HCL, как уже упоминалось, поддерживает функции, а как их объявлять в контексте? Примерно следующим образом:
Код
func TestConfig5(t *testing.T) { content := []byte(` document "markdown" "readme" { filename = "readme.md" content = file("readme.md") } document "rst" "development" { filename = "development.rst" content = file("development.rst") } folder "project" { items = [doc.readme.name, doc.development.name] } `) ctx := hcl.EvalContext{ Functions: map[string]function.Function{ "file": FileFunc, }, } var cfg Config3 err := hclsimple.Decode("docs.hcl", content, &ctx, &cfg) if err != nil { t.Error(err) } for _, v := range cfg.Docs { if v.Name == "readme" && v.Content != "<readme.md content>" { t.Error("readme has content:", v.Content) } if v.Name == "development" && v.Content != "<development.rst content>" { t.Error("development has content:", v.Content) } } } var FileFunc = function.New(&function.Spec{ VarParam: nil, Params: []function.Parameter{ {Type: cty.String}, }, Type: func(args []cty.Value) (cty.Type, error) { return cty.String, nil }, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { filename := args[0].AsString() var err error // content, err := os.ReadFile(filename) content, err := []byte(fmt.Sprintf("<%s content>", filename)), nil if err != nil { return cty.NilVal, err } return cty.StringVal(string(content)), nil }, })
VarParam— это тип дляvarargs-аргумента функции. Если таковой у функции есть.Params— набор аргументов, которые принимает функция.Type— тип возвращаемого значения, можно вычислить на основе полученных аргументов.Impl— собственно, тело функции.os.ReadFileподменён фейковыми данными, но принцип работы понятен.
Также в библиотеке cty есть пакет functions.stdlib с уже готовыми функциями для работы с числами, массивами, строками и их форматированием, и другие.
В заключение
Hashicorp Configuration Language выделяется из других языков хорошим синтаксисом и одноимённым фреймворком. По функциональности язык можно сравнить можно со Starlark, Groovy и KotlinScript, однако в отличие от первого hashicorp/hcl умеет парсить код в несколько этапов и с разными подходами, а Groovy/Kotlin требуют свой компилятор в runtime. На мой взгляд, HCL — это отличное решение для сложной и декларативной конфигурации, или если вы пишете инструмент для DevOps, которые тоже любят описывать инфраструктуру в декларативной конфигурации.