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

Комментарии (3)


  1. nin-jin
    20.10.2022 18:12
    -4

    А чего Tree даже не упомянули? Вот пример на tree:

    io_mode \async
    
    http web_proxy
      listen_addr \127.0.0.1:8080
      
      process main command /
        \/usr/local/bin/awesome-app
        \server
      
      process mgmt command /
        \/usr/local/bin/awesome-app
        \mgmt

    Вместо оригинального HCL:

    io_mode = "async"
    
    service "http" "web_proxy" {
      listen_addr = "127.0.0.1:8080"
      
      process "main" {
        command = ["/usr/local/bin/awesome-app", "server"]
      }
    
      process "mgmt" {
        command = ["/usr/local/bin/awesome-app", "mgmt"]
      }
    }


    1. Desprit
      20.10.2022 18:53
      +7

      Предположу, что не упомянули по той причине, что без понятия что это вообще. А глядя сейчас на сниппет - и хорошо что не упомянули.


    1. Snaffi
      21.10.2022 18:06
      +1

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