Protobuf как контракт: spec‑first валидация с protovalidate (часть 2)
- вторник, 20 января 2026 г. в 00:00:11
В первой части мы разобрали protoc-gen-validate и spec-first подход к валидации. Я обещал рассказать про protovalidateну и вот, держите :)
И самый первый вопрос конечно, а зачем вообще появился protovalidate, если PGV уже есть и работает?
Ах да, мини реклама моего телеграмм канала по Go && gRPC
К примеру, вас микросервисная архитектура, где бэкенд на 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?
Уже проверен в бою - используется в Kubernetes (для admission policies), Firebase Security Rules, Google Cloud IAM
Non-Turing complete - невозможно написать бесконечный цикл и положить сервер
Строго типизирован - ошибки ловятся на этапе компиляции выражения
Кросс-платформенный - официальные реализации для Go, Java, C++, TypeScript, Python
То есть одно и то же CEL-выражение гарантированно даст одинаковый результат на любой платформе
Давайте сравним, что происходит при сборке проекта.
.proto файл
↓
protoc --validate_out=...
↓
user.pb.validate.go (сгенерированный код)
↓
go build
↓
бинарник с нативным Go-кодом валидацииКогда вы вызываете req.Validate(), выполняется скомпилированный Go-код. Максимально быстро, но:
Надо хранить сгенерированные файлы (или генерировать на CI)
Каждый язык генерирует свой код со своими особенностями
.proto файл
↓
buf build (или protoc)
↓
дескриптор с CEL-выражениями в опциях полей
↓
runtime: CEL-движок читает опции и выполняет выражения
Когда вы вызываете validator.Validate(req):
Библиотека читает CEL-выражения из дескриптора сообщения
CEL-движок компилирует и выполняет эти выражения
Возвращает результат
Ключевое отличие: валидация происходит не в сгенерированном коде, а в интерпретаторе CEL, который одинаков везде.
deps:
- github.com/bufbuild/protovalidatesyntax = "proto3";
package example.user.v1;
import "buf/validate/validate.proto";
Примечание: buf/validate/validate.proto, а не validate/validate.proto как в PGV. Это важно - если перепутаете, будут ошибки компиляции.
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? Две причины:
Неймспейс buf — чтобы не конфликтовать с PGV, если оба используются в переходный период
Структура .field — более явное указание, что опция применяется к полю (есть ещё .message для правил на уровне сообщения)
Если у вас уже есть PGV, переход потребует:
Изменить импорты:
// Было
import "validate/validate.proto";
// Стало
import "buf/validate/validate.proto";
Изменить синтаксис правил:
// Было
[(validate.rules).string.email = true
// Стало
[(buf.validate.field).string.email = trueДобавить runtime-валидатор в Go-код
Протестировать! Некоторые edge cases могут работать по-другому
Да, на переходный период. Импорты не конфликтуют. Но в production лучше иметь один источник правды.
Критерий | PGV (protoc-gen-validate) | Protovalidate |
|---|---|---|
Подход | Кодогенерация | Runtime-интерпретация (CEL) |
Консистентность между языками | ❌ Разные плагины = разное поведение | ✅ Единый CEL-движок везде |
Кросс-полевая валидация | ❌ Не поддерживается | ✅ Нативная поддержка через CEL |
Условная валидация | ❌ Требует кастомных плагинов | ✅ |
Кастомные правила | ⚠️ Писать N раз (по плагину на язык) | ✅ Один раз в proto через CEL |
Артефакты сборки | ⚠️ Генерируемые | ✅ Только стандартные pb-файлы |
Производительность | ✅ Нативный скомпилированный код | ⚠️ ~5-15% overhead на валидацию |
Время старта | ✅ Мгновенно | ⚠️ ~10-50мс на компиляцию CEL |
Я на самом деле, учитывая всю мою неприязнь к команде buf.build, понимаю, на сколько эффективное решение реализовали в реалиях валидаций
P.S. Полные примеры кода - в репозитории
P.P.S. Вопросы? Пишите в канал, разберём (ну и просто буду благодарен подписке) :)