Как перестать искать ошибки в YAML глазами: пишем валидатор для Go
- суббота, 26 сентября 2026 г. в 00:00:19
Когда я начинал писать go-yamlvalidator, никакой большой идеи за этим не было. Мне нужен был нормальный способ проверять YAML-конфиг.
Я разрабатываю EasyP — инструмент для работы с Protobuf-контрактами, зависимостями и генерацией кода. Когда у пользователей что-то не работало, они часто писали нам в чат EasyP и присылали свои YAML-конфиги. В итоге нам самим приходилось глазами искать лишний отступ, неправильную вложенность, опечатку в ключе или ещё какую-нибудь мелочь.
Причём конфиг часто выглядел почти нормально:
generate: plugins: - name: go out: ./gen
Перед out не хватает всего одного пробела.
Можно ошибиться и в другую сторону:
generate: plugins: - name: go out: ./gen
Здесь пробелов уже слишком много.
В длинном конфиге, присланном в чат, такие вещи приходится буквально выискивать глазами. YAML parser ошибку найдёт, но хотелось сразу показывать пользователю конкретное место в файле, а не разбирать его конфиг вручную.
Были и ошибки другого рода:
generate: plugin: - name: go out: ./gen
Этот YAML уже синтаксически валиден. Только EasyP ожидает plugins, а пользователь написал plugin.
Есть и ошибки, которые становятся понятны только если знать схему конфига: обязательного поля нет, значение имеет неправильный тип, указаны две взаимоисключающие настройки.
Мне хотелось проверять всё это сразу после чтения файла и выдавать ошибку с нормальным путём, строкой и колонкой.
Так появилась первая версия go-yamlvalidator.
Можно было делать yaml.Unmarshal в Go-структуру, а потом валидировать получившийся объект.
Мне этот вариант не подходил.
Я хотел проверять именно исходный файл. Если с конфигом что-то не так, важно знать, где находится проблемное значение в YAML и что именно там написал пользователь.
Ещё мне не хотелось жёстко связывать схему конфига с Go-структурами. В YAML вполне могут быть произвольные map, несколько допустимых форм одного значения, условные поля и зависимости между ними.
Поэтому библиотека работает с yaml.Node.
У него уже есть структура документа и координаты каждого элемента в исходном YAML.
Первая версия строилась вокруг FieldSchema.
Например, хотим конфиг такого вида:
name: backend port: 8080
Схема для него:
schema := &yamlvalidator.FieldSchema{ Type: yamlvalidator.TypeMap, AllowedKeys: map[string]*yamlvalidator.FieldSchema{ "name": { Type: yamlvalidator.TypeString, Required: true, }, "port": { Type: yamlvalidator.TypeInt, }, }, }
Дальше компилируем её:
validator, err := yamlvalidator.CompileFieldSchema(schema) if err != nil { return err } result := validator.ValidateBytes(data)
Если пользователь напишет:
name: backend prt: 8080
библиотека увидит неизвестный ключ.
Если:
port: value: 8080
то обнаружит, что вместо integer пришёл map.
Если убрать обязательный name, будет отдельная ошибка для отсутствующего поля.
Постепенно у FieldSchema появились nullable-поля, несколько допустимых типов, массивы, произвольные свойства и зависимости между полями.
Например, можно запретить одновременное использование file и url:
schema.MutuallyExclusive = []string{"file", "url"}
Или описать два допустимых способа авторизации:
schema.OneOfRequired = [][]string{ {"token"}, {"username", "password"}, }
То есть проверять можно уже не только типы отдельных значений, но и структуру конфига целиком.
В какой-то момент стало понятно, что FieldSchema постепенно превращается в собственный небольшой язык описания схем.
А для этой задачи уже существует JSON Schema.
Я не стал писать свой JSON Schema engine. Внутри используется готовая реализация, а go-yamlvalidator занимается связкой между JSON Schema и исходным YAML.
Например, берём обычную JSON Schema:
{ "type": "object", "properties": { "name": { "type": "string" }, "port": { "type": "integer", "minimum": 1 } }, "required": ["name"], "additionalProperties": false }
Компилируем:
schema, err := yamlvalidator.CompileJSONSchema(schemaData) if err != nil { return err } validator := yamlvalidator.NewValidator(schema) result := validator.ValidateBytes(yamlData)
А проверяем ей обычный YAML:
name: backend port: 8080
Или, например:
name: backend port: wrong
JSON Schema увидит нарушение типа, а библиотека сможет привязать эту ошибку обратно к строке port в YAML.
Внутри это выглядит примерно так:
YAML | v yaml.Node | v JSON data model | v JSON Schema engine
JSON Schema работает с моделью данных: object, array, string, number, boolean и null. Поэтому YAML можно привести к этой модели, сохранив рядом исходные yaml.Node.
Когда JSON Schema сообщает об ошибке по пути:
["plugins", "0", "name"]
библиотека находит соответствующий элемент исходного YAML и знает его строку и колонку.
Именно эта связка для меня была основной причиной не использовать JSON Schema validator напрямую.
Схемой можно описать далеко не любую прикладную проверку, поэтому в библиотеке есть custom validators.
Для частых случаев уже есть готовые.
Например, enum:
valuevalidator.EnumValidator{ Allowed: []string{"dev", "stage", "prod"}, }
Или regexp:
valuevalidator.RegexValidator{ Pattern: regexp.MustCompile(`^[a-z0-9-]+$`), }
Их можно просто добавить к полю:
"environment": { Type: yamlvalidator.TypeString, Validators: []yamlvalidator.ValueValidator{ valuevalidator.EnumValidator{ Allowed: []string{"dev", "stage", "prod"}, }, }, },
Если готового validator нет, можно написать свой:
type MyValidator struct{} func (MyValidator) Validate( node *yaml.Node, path string, ctx *yamlvalidator.ValidationContext, ) { if node.Value == "forbidden" { ctx.AddError(yamlvalidator.ValidationError{ Level: yamlvalidator.LevelError, Path: path, Line: node.Line, Column: node.Column, Message: "value is forbidden", }) } }
Отдельный интерфейс есть и для проверки ключей map.
После появления JSON Schema мне всё равно хотелось иметь возможность добавлять проверки на Go.
Самый простой вариант — собственный format.
Например:
format := yamlvalidator.JSONSchemaFormat{ Name: "module-name", Validate: func(value any) error { name, ok := value.(string) if ok && !validModuleName(name) { return errors.New("invalid module name") } return nil }, }
После регистрации его можно использовать прямо в JSON Schema:
{ "type": "string", "format": "module-name" }
Для более сложной логики есть custom keywords.
Например, можно добавить свой keyword:
{ "x-require-property": "name" }
а его поведение определить на Go.
Есть поддержка и более сложных extensions, где значение custom keyword само содержит вложенные JSON Schema.
При этом типы конкретного JSON Schema engine наружу не торчат: extension API принадлежит самой библиотеке.
Поддерживаются и обычные ссылки между JSON Schema.
Например:
{ "$ref": "common.json" }
Если schemas лежат в памяти, их можно передать через Resources.
Если нужно читать их с диска, можно создать file resolver:
resolver, err := yamlvalidator.NewJSONSchemaFileResolver(schemaDir)
и передать его при компиляции:
schema, err := yamlvalidator.CompileJSONSchemaWithOptions( schemaData, yamlvalidator.JSONSchemaCompileOptions{ Resolver: resolver, }, )
Сам CompileJSONSchema произвольные файлы с диска не читает.
У CLI чуть более привычное поведение. Если рядом лежат:
schema.json common.json
то относительный:
{ "$ref": "common.json" }
будет работать автоматически в пределах директории schema-файла.
Одной строки ошибки со временем стало мало.
Сейчас у diagnostic есть код, путь, координаты и дополнительные структурированные данные:
type ValidationError struct { Code string SchemaPath string Path string Line int Column int Message string Got string Expected string Details any }
За счёт этого один и тот же результат можно нормально показать в CLI или использовать, например, для подсветки ошибки в редакторе.
Для пользователя это в итоге выглядит примерно так:
config.yaml:17:5 unknown key "plugin" did you mean "plugins"?
А для кода остаются отдельные Code, Path, Line, Column и Details.
Мне не хотелось называть поддержку «JSON Schema», если она работает только на нескольких выбранных примерах.
Поэтому библиотека прогоняется через официальный JSON Schema Test Suite.
Сейчас результаты core tests такие:
draft-04: 618 / 618 draft-06: 841 / 841 draft-07: 929 / 929 2019-09: 1261 / 1261 2020-12: 1301 / 1301 total: 4950 / 4950
Также проходят 3884 применимых optional tests.
Один optional test draft-04 исключён отдельно: он проверяет различие между 1 и 1.0 именно как между типами host language, тогда как используемый JSON Schema engine рассматривает целые числа математически.
Для меня после этого вопрос «насколько здесь настоящая JSON Schema» в целом закрылся.
Всё началось с конфигов, где иногда нужно было минуту смотреть на несколько строк YAML, чтобы понять, что перед out не хватает одного пробела.
Теперь можно описать схему напрямую в Go:
schema := &yamlvalidator.FieldSchema{ Type: yamlvalidator.TypeMap, AllowedKeys: map[string]*yamlvalidator.FieldSchema{ "name": { Type: yamlvalidator.TypeString, Required: true, }, }, }
либо взять обычную JSON Schema:
schema, err := yamlvalidator.CompileJSONSchema(schemaData)
а дальше валидировать тот же YAML с сохранением нормальных ошибок по исходному файлу.
Есть вложенные структуры, массивы, неизвестные и обязательные поля, зависимости между настройками, custom validators, JSON Schema extensions и $ref.
И в итоге пользователь больше не должен присылать нам конфиг в чат, чтобы мы глазами искали в нём два лишних пробела.
Он должен сразу увидеть, где именно ошибся.
Репозиторий:
github.com/Yakwilik/go-yamlvalidator
Установка:
go get github.com/Yakwilik/go-yamlvalidator@v1.0.0