Конфигурирование приложений — это интересная тема. Мало того, что форматов конфигурации в сообществе инженеров много, ситуация осложняется тем, что выбор того или иного языка определяет, как вашим приложением будут пользоваться люди. Инженеры, которые будут выкладывать ваш бэкенд в абстрактную 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()
. А так как у объектов атрибуты могут быть разных типов, то придётся указывать тип каждого документа.Записываем в переменную
doc
EvalContext
-а результат преобразования структуры в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, которые тоже любят описывать инфраструктуру в декларативной конфигурации.