golang

Protobuf как контракт: spec‑first валидация с protovalidate (часть 2)

  • вторник, 20 января 2026 г. в 00:00:11
https://habr.com/ru/articles/986422/

В первой части мы разобрали protoc-gen-validate и spec-first подход к валидации. Я обещал рассказать про protovalidateну и вот, держите :)

И самый первый вопрос конечно, а зачем вообще появился protovalidate, если PGV уже есть и работает?

Ах да, мини реклама моего телеграмм канала по Go && gRPC

Проблема, которую решает protovalidate

К примеру, вас микросервисная архитектура, где бэкенд на Go, ML-пайплайн на Python, а мобильный клиент генерирует код на swift и kotlin flutter и весь этот зоопарк общается через gRPC

И если используете PGV то просто добавляете правило:

message CreateUserRequest {
  string email = 1 [(validate.rules).[string.email](<http://string.email>) = true];
}

Вроде всё хорошо? А теперь проблема: на Go это правило проверяет email одним регулярным выражением, на Python другим, на Java третьим :D

Потому что для каждого языка существует отдельный плагин кодогенерации, и каждый из них - это буквально отдельный проект с отдельными мейнтейнерами

В итоге:

  • test@example проходит валидацию на одном языке и не проходит на другом

  • Баг, исправленный в Go-плагине, может жить в Python-плагине ещё полгода

  • Кастомные правила нужно писать N раз — по одному на каждый язык

И тут ребята из buf.build (у меня есть прекрасная отмазка, что это не нативная реклама buf, я делаю таки аналог! :D ) поняли что есть очень классное и оптимальное решение этой проблемы

Один язык правил на все платформы

Вместо генерации кода валидации для каждого языка, protovalidate использует CEL (прекрасная статья для понимания) - язык выражений от Google.

Почему именно CEL?

  1. Уже проверен в бою - используется в Kubernetes (для admission policies), Firebase Security Rules, Google Cloud IAM

  2. Non-Turing complete - невозможно написать бесконечный цикл и положить сервер

  3. Строго типизирован - ошибки ловятся на этапе компиляции выражения

  4. Кросс-платформенный - официальные реализации для Go, Java, C++, TypeScript, Python

То есть одно и то же CEL-выражение гарантированно даст одинаковый результат на любой платформе

Как это работает изнутри

Давайте сравним, что происходит при сборке проекта.

PGV (кодогенерация):

.proto файл
    ↓
protoc --validate_out=...
    ↓
user.pb.validate.go (сгенерированный код)
    ↓
go build
    ↓
бинарник с нативным Go-кодом валидации

Когда вы вызываете req.Validate(), выполняется скомпилированный Go-код. Максимально быстро, но:

  • Надо хранить сгенерированные файлы (или генерировать на CI)

  • Каждый язык генерирует свой код со своими особенностями

protovalidate (runtime):

.proto файл
    ↓
buf build (или protoc)
    ↓
дескриптор с CEL-выражениями в опциях полей
    ↓
runtime: CEL-движок читает опции и выполняет выражения

Когда вы вызываете validator.Validate(req):

  1. Библиотека читает CEL-выражения из дескриптора сообщения

  2. CEL-движок компилирует и выполняет эти выражения

  3. Возвращает результат

Ключевое отличие: валидация происходит не в сгенерированном коде, а в интерпретаторе CEL, который одинаков везде.

Практика: настраиваем protovalidate с нуля

Шаг 1: Зависимости в easyp.yaml


deps:
  - github.com/bufbuild/protovalidate

Шаг 2: Импорт в proto-файле

syntax = "proto3";
package example.user.v1;

import "buf/validate/validate.proto";

Примечание: buf/validate/validate.proto, а не validate/validate.proto как в PGV. Это важно - если перепутаете, будут ошибки компиляции.

Шаг 3: Добавляем правила

message CreateUserRequest {
  string email = 1 [(buf.validate.field).string.email = true];
}

Сравните с PGV:

// PGV (старый синтаксис)
string email = 1 [(validate.rules).string.email = true];

// protovalidate (новый синтаксис)
string email = 1 [(buf.validate.field).string.email = true];

Почему buf.validate.field вместо validate.rules? Две причины:

  1. Неймспейс buf — чтобы не конфликтовать с PGV, если оба используются в переходный период

  2. Структура .field — более явное указание, что опция применяется к полю (есть ещё .message для правил на уровне сообщения)

Что учесть при миграции

Если у вас уже есть PGV, переход потребует:

  1. Изменить импорты:

    // Было
    import "validate/validate.proto";
    
    // Стало
    import "buf/validate/validate.proto";
    
  2. Изменить синтаксис правил:

    // Было
    [(validate.rules).string.email = true
    
    // Стало
    [(buf.validate.field).string.email = true
  3. Добавить runtime-валидатор в Go-код

  4. Протестировать! Некоторые edge cases могут работать по-другому

Да, на переходный период. Импорты не конфликтуют. Но в production лучше иметь один источник правды.

Сравнительная таблица: PGV vs Protovalidate

Критерий

PGV (protoc-gen-validate)

Protovalidate

Подход

Кодогенерация

Runtime-интерпретация (CEL)

Консистентность между языками

❌ Разные плагины = разное поведение

✅ Единый CEL-движок везде

Кросс-полевая валидация

❌ Не поддерживается

✅ Нативная поддержка через CEL

Условная валидация

❌ Требует кастомных плагинов

has(), логические операторы в CEL

Кастомные правила

⚠️ Писать N раз (по плагину на язык)

✅ Один раз в proto через CEL

Артефакты сборки

⚠️ Генерируемые .pb.validate.* файлы

✅ Только стандартные pb-файлы

Производительность

✅ Нативный скомпилированный код

⚠️ ~5-15% overhead на валидацию

Время старта

✅ Мгновенно

⚠️ ~10-50мс на компиляцию CEL

Я на самом деле, учитывая всю мою неприязнь к команде buf.build, понимаю, на сколько эффективное решение реализовали в реалиях валидаций

P.S. Полные примеры кода - в репозитории

P.P.S. Вопросы? Пишите в канал, разберём (ну и просто буду благодарен подписке) :)