Ты только что получил задачу перенести сервис на gRPC - и думаешь, что это просто «взял protobuf, описал контракты, запустил кодогенерацию«? Так думал и я. До тех пор, пока не столкнулся с тем, что decimal в protobuf попросту не существует, nullable int превращается в нечто с флагом HasValue вместо привычного int?, а два enum в одном файле могут убить сборку из‑за конфликта имён значений - и компилятор скажет тебе об этом совсем не очевидно.»
Эта статья - выжимка боли и практики из реального проекта по переносу REST и WCF на gRPC, где моделей было больше сотни: с наследованием, дженериками, decimal, DateTime, object, nullable и совпадающими именами классов из разных пространств имён. Здесь не будет воды - только конкретные проблемы и конкретные решения. Обновлено под protoc v34.1 и.NET 10.
TL;DR;
Контракты - используй.proto‑файлы, а не protobuf‑net; один класс = один файл + package
Nullable для примитивов - optional int не даёт int?, нужны google/protobuf/wrappers.proto
Nullable для ссылочных типов и enum - кодогенератор не различает optional и без него; enum получает HasXxx/ClearXxx вместо Nullable<T>
Decimal - типа нет, используй DecimalValue по рецепту Microsoft или money.proto; избегай implicit operator
DateTime - используй google/protobuf/timestamp.proto; DateTime требует предварительного.ToUniversalTime()
Наследование - заменяется композицией; два подхода: от дочерних к родительским (читаемее) или от родительских к дочерним (удобнее при работе с базовым классом)
Дженерики - три стратегии: специализированные сообщения (безопаснее), oneof (компромисс), google.protobuf.Any (гибко, но без type safety)
Enum - имена значений должны быть уникальны в рамках всего файла; первое значение обязано быть 0; используй префикс <ИмяEnum>_<ИмяЗначения>
Object - четыре всадника апокалипсиса: Any, oneof, google.protobuf.Value/Struct, ручная сериализация в bytes/string
Описание контрактов: protobuf или не protobuf?
Проблема
gRPC говорит нам, что контракты могут быть описаны не только через protobuf. Я сейчас говорю про конкретную библиотеку protobuf-net. В нашей команде мы сравнивали protobuf и protobuf-net (.NET библиотека, которая позволяет добавить атрибуты вместо описания контрактов).
У нас было строгое требование, что обязательно должен быть contract-first, protobuf-net так может предоставить вам готовый protobuf контракт, но это не тоже самое, что иметь строго зафиксированные контракты в одном файле, чтобы другие команды могли использовать их и генерировать себе клиентов, особенно если они на других ЯП.
Решение
Рекомендуется использовать описание контрактов через proto-файлы.
Много ĸлассов в protobuf
Как я говорил, наша задача была переписать модели на Grpc, методов было немного, но количество классов переваливало за 100-ню. Изначально я писал все модели в одном proto-файле, но это длилось недолго, а именно до первых конфликтов имен. Затем я попытался группировать файлы с message по нейспейсам/запросам/возможно еще как-то, но ни к чему хорошему это не привело.
Решения
Один класс = один proto-файл (рекомендуется) + обязательно используем
package(аналог namespace в мире protobuf) для разграничения имёнОднако, если количество классов по объему не столько большое как у нас, то можете разбить их по использованию в один файл
Подробнее можно почитать тут
Также полезно разбивать proto-файлы моделей и сервисов по папкам (например, Protos/Models/ и Protos/Services/ ), у вас получится примерно такой .csproj:
<ItemGroup> <!-- Модели: не привязаны ни к Server, ни к Client --> <Protobuf Include="Protos\Models\**\*.proto"> <GrpcServices>None</GrpcServices> <Access>Public</Access> <ProtoCompile>True</ProtoCompile> <ProtoRoot>Protos</ProtoRoot> <!-- Корень откуда будут смотреть ваши import --> </Protobuf> <!-- Сервисы --> <Protobuf Include="Protos\Services\**\*.proto"> <GrpcServices>Server</GrpcServices> <!-- Both - если вам нужен и клиент и сервер --> <Access>Public</Access> <ProtoCompile>True</ProtoCompile> <ProtoRoot>Protos</ProtoRoot> <CompileOutputs>True</CompileOutputs> </Protobuf> </ItemGroup>
Самая полезная статья по Grpc.Tools здесь
Nullable для примитивов
Если значимое поле, например int32 SomeDataOptional, помечено как optional, то в сгенерированном классе не будет int? SomeDataOptional { get; set; }. Будет int SomeDataOptional { get; set; }, но будет свойство HasSomeData, которое обозначает, было ли передано данное поле или нет. Если обратиться к значению - там будет значение по умолчанию - это же значимый тип. Костыль, что сказать.

Решение - google/protobuf/wrappers.proto. При использовании оберток (wrappers) будет именно то, что нужно: nullable‑тип в сгенерированном коде.

Если описать свои обертки - будут созданы классы для этих полей, то есть ссылочный тип. Это уже не то, что ожидалось, об этом ниже.
Nullable для ссылочных типов
Для ссылочных типов (например, вложенных message ) ĸодогенератор не видит различий между optional и без него - оба варианта дадут свойство без ?.

Решение
Рекомендую все равно указывать optional для полей, которые могут быть null. Это важно для будущей логики кодогенерации и клиентов на других языках.
Nullable для перечислений (enum)
Ситуация аналогична значимым типам: optional enum добавит свойство HasXXX и метод ClearXXX , но никак не Nullable<EnumType>.

Решение
Подход | Результат |
| EnumType + HasXxx / ClearXxx |
Обёртĸа message EnumWrapper { | Да, это как с ссылочными типами, но иного выхода я не нашел. Тут также стоит указывать optional при использовании такого wrapper, следуя рекомендациям из прошлого правила |
Выбирайте на свое усмотрение.
Decimal
В protobuf нет типа decimal. Прямые альтернативы - double и float , но они не подходят для финансовых вычислений из-за потери точности.
Решение
google/protobuf/money.proto - содержит сумму и валюту (строĸа).
DecimalValue по рецепту Microsoft
Важно: избегайте implicit operator для ĸонвертации, таĸ ĸаĸ это приводит ĸ NullReferenceException при DecimalValue == null. Используйте явные методы-расширения:
public static class DecimalValueExtension { private const decimal NanoFactor = 1_000_000_000; public static decimal ToDecimal(this DecimalValue value) => value.Units + value.Nanos / NanoFactor; public static decimal? ToNullableDecimal(this DecimalValue? value) => value is null ? null : value.ToDecimal(); public static DecimalValue ToDecimalValue(this decimal value) { var units = decimal.ToInt64(value); var nanos = decimal.ToInt32((value - units) * NanoFactor); return new DecimalValue { Units = units, Nanos = nanos }; } public static DecimalValue? ToNullableDecimalValue(this decimal? value) => value is null ? null : value.Value.ToDecimalValue(); }
DateTime и DateTimeOffset
В protobuf также нет встроенного типа для работы с датой/временем.
Решение
Для работы с этими значениями используется google/protobuf/timestamp.proto, для C# у него имеется расширения:
Для Timestamp: ToDateTime() и ToDateTimeOffset()
В Timestamp:
Из DateTime: Для того, чтобы преобразовать DateTime в Timestamp необходимо сначала привести его к UTC, воспользовавшись методом
ToUniversalTime(), а затем вызвать методToTimestamp()Из DateTimeOffset: работает с
.ToTimestamp()напрямую, без предварительных преобразований.

Наследование и абстраĸтные ĸлассы
В protobuf нет наследования, но есть композиция (и oneof, но об этом чуть позже). Мы нашли два варианта, как можно обойти наследование, оба построены на композиции, а вы что ожидали? В первом случае мы описываем от дочерних к родительским, и во втором, от родительских к дочерним - да есть и плюсы и минусы.
Исходная C#-иерархия для примера
[abstract] class Base { public int Id { get; set; } } class User : Base { public string Name { get; set; } } class UserAdditionalInfo : User { public DateTimeOffset BDay { get; set; } } class Role : Base { public int Priority { get; set; } public string RoleName { get; set; } } class UseBaseClass { public Base Base { get; set; } }
Решение №1: от дочерних к родительским (рекомендуется)
syntax = "proto3"; message Base { int32 id = 1; } message User { Base base = 1; string name = 2; } message UserAdditionalInfo { User base = 1; google.protobuf.Timestamp b_day = 2; } message Role { Base base = 1; int32 priority = 2; string role_name = 3; } // Нужен только если есть метод/класс, работающий с базовым типом message BaseChildrens { oneof type { User user = 1; UserAdditionalInfo user_additional_info = 2; Role role = 3; } } message UseBaseClass { BaseChildrens base = 1; }
Метод от дочерних к родительским - если встретится работа с базовым или абстрактным классом, то придется сделать дополнительный message, который будет объединять все дочерние и под-дочерние и под-под-...
Решение №2: от родительсĸих ĸ дочерним
syntax = "proto3"; // Если будет метод, который принимает базовый/абстрактный класс, то можно передать его message Base { int32 id = 1; oneof child { User user = 2; Role role = 3; } } message User { string name = 1; oneof child { UserAdditionalInfo user_additional_info = 2; } } message UserAdditionalInfo { google.protobuf.Timestamp b_day = 1; } message Role { int32 priority = 1; string role_name = 2; } message UseBaseClass { Base base = 1; // все так же используется одна из реализаций }
Сравнение
Решение №1: дочерние → родительсĸие | Решение №2: родительсĸие → дочерние | |
Читаемость иерархии | ✅ Лучше | ❌ Хуже |
Работа с базовым ĸлассом | ⚠️ Требуется BaseChildrens, если нужна работа с базовым классом | ✅ Удобно, уже все есть |
Generic, aka шаблонные типы
Записки в процессе работы с generic
Дорогой дневник, мне не подобрать слов чтобы описать боль и унижение которые я испытал сегодня, моя жизнь поломана навсегда...
В примере имеем шаблонный класс BaseEntity, в коде имеется применение с тремя разными типами. Есть три способа, как работать с ними, начнем с самой темной лошадки.
C#-исходниĸ
class BaseEntity<T> { public T Id { get; set; } } class User : BaseEntity<int> { // ... } class Role : BaseEntity<long> { // ... } class BaseEntityWrapper { public BaseEntity<string> BaseEntity { get; set; } }
Решение №1: специализированные сообщения (рекомендуется)
Задай себе вопрос, ты знаешь во что превращаются дженерики?
Создать отдельное сообщение для каждой конкретной реализации generic. Да, это больно, да, это тяжело, надо пройти через отрицание к принятию.
syntax = "proto3"; message BaseEntity_Int { int32 id = 1; } message BaseEntity_Long { int64 id = 1; } message BaseEntity_String { string id = 1; } message User { BaseEntity_Int base = 1; // подход "от дочерних к родительским" } message Role { BaseEntity_Long base = 1; } message BaseEntityWrapper { BaseEntity_String base_entity = 1; // здесь не наследование }
Решение №2: полиморфизм через объединение (oneof)
message BaseEntity { oneof id { int32 id_int32 = 1; int64 id_int64 = 2; string id_string = 3; } } message BaseEntityWrapper { BaseEntity base_entity = 1; }
При работе с oneof надо проверять на соответствие каждому возможному типу перед тем как записать значение.
var proto = new BaseEntity(); if (entity is BaseEntity<int> intEntity) proto.IdInt32 = intEntity.Id; else if (entity is BaseEntity<long> longEntity) proto.IdInt64 = longEntity.Id; else if (entity is BaseEntity<string> strEntity) proto.IdString = strEntity.Id;
Решение №3: полная динамика (google.protobuf.Any)
Any позволяет заполнить любой тип, как это делает object, подробнее тут.
import "google/protobuf/any.proto"; message BaseEntityWrapper { google.protobuf.Any base_entity = 1; }
Чтобы упаковать такое сообщение, надо вызвать метод упаковки Any.Pack, но перед распаковкой надо проверить тип значения, которое лежит в нем.
// Pack var userEntity = new UserEntity { Id = 42 }; var wrapper = new BaseEntityWrapper { BaseEntity = Any.Pack(userEntity) }; // Unpack if (wrapper.BaseEntity.Is(UserEntity.Descriptor)) { var user = wrapper.BaseEntity.Unpack<UserEntity>(); }
Сравнение
Подход | Type Safety | Гибкость |
Специализированные сообщения | ✅ Высокая | ❌ Низкая |
| ⚠️ Средняя | ⚠️ Средняя |
| ❌ Низкая | ✅ Высокая |
Enum: именование и нулевое значение
Проблема №1: ĸонфлиĸт имён значений
В protobuf значения enum используют C++ scoping rules - имена значений должны быть униĸальны в рамĸах всего паĸета (файла), а не тольĸо внутри одного enum. Пример ниже вызовет ошибку.
syntax = "proto3"; enum PositionTypes { Up = 1; Down = 2; // злодей № 1 !!! Both = 3; } enum GraphMovementTypes { Upper = 0; Down = 1; // злодей № 2 !!! - не обязательно должны совпадать значения None = 2; }
Решение
Именовать значения по правилу <ИмяEnum>_<ИмяЗначения>
syntax = "proto3"; enum PositionTypes { PositionTypes_Up = 0; PositionTypes_Down = 10; PositionTypes_Both = 20; } enum GraphMovementTypes { GraphMovementTypes_Upper = 0; GraphMovementTypes_Down = 1; }
По итогу у вас значения enum не будут содержать имени самого enum

Об этом правиле также говориться на сайте документации, используйте именно такой подход для именования значений перечислений.
Проблема 2: enum в protobuf всегда начинается с 0
По правилам protobuf3 первое значение enum обязано быть 0. При переносе legacy-ĸода, где нумерация начинается с 1, не смещайте существующие значения - добавьте <ИмяEnum>_Undefined = 0.
Object
Я нашел 4 основных способа, как работать с этим демоном, конечно же я про них расскажу, но с единственной оговоркой - у нас на бекенде object поле никак не обрабатывалось, оно приходило и уходило, поэтому мы приняли просто решение заставить стерилизовать значения клиентов и выбрали string.
Решение №1: google.protobuf.Any
Да снова он, это буквально прямой аналог object, клади всё, что хочешь.
import "google/protobuf/any.proto"; message Container { google.protobuf.Any value = 1; }
Работу с ним вы уже видели выше, но вот вам пример, как узнать что за тип данных пришел
// Pack var container = new Container(); container.Value = Any.Pack(new UserEntity { Id = 1 }); // Unpack - нужно знать тип заранее if (container.Value.Is(UserEntity.Descriptor)) { var user = container.Value.Unpack<UserEntity>(); } // Unpack - через type_url если тип неизвестен var typeUrl = container.Value.TypeUrl; // "type.googleapis.com/UserEntity"
Кстати говоря, можно сделать, чтобы значение передавалось со схемой, тогда клиент сможет парсить её самостоятельно.
Решение №2: union через oneof
Подойдет только для случаев, если набор возможных типов известен заранее.
message DynamicValue { oneof value { int32 int_val = 1; int64 long_val = 2; double double_val = 3; string str_val = 4; bool bool_val = 5; bytes bytes_val = 6; } } message Container { DynamicValue value = 1; }
// Запись var val = new DynamicValue(); val.StrVal = "hello"; // object str = "hello" val.IntVal = 42; // object num = 42 // Чтение var result = val.ValueCase switch { DynamicValue.ValueOneofCase.StrVal => (object)val.StrVal, DynamicValue.ValueOneofCase.IntVal => (object)val.IntVal, DynamicValue.ValueOneofCase.DoubleVal => (object)val.DoubleVal, _ => null };
Решение №3: JSON-подобная структура google.protobuf.Value
Встроенный тип для динамических данных, аналог JsonElement. Поддерживает: null, bool, double, string, list, struct (map).
import "google/protobuf/struct.proto"; message Container { google.protobuf.Value value = 1; // любой скалярный тип google.protobuf.Struct config = 2; // аналог Dictionary<string, object> google.protobuf.ListValue tags = 3; // аналог List<object> }
// Value — скаляры var container = new Container(); container.Value = Value.ForString("hello"); container.Value = Value.ForNumber(3.14); container.Value = Value.ForBool(true); container.Value = Value.ForNull(); // Struct — как Dictionary<string, object> container.Config = new Struct(); container.Config.Fields["name"] = Value.ForString("Alice"); container.Config.Fields["age"] = Value.ForNumber(30); container.Config.Fields["roles"] = Value.ForList( Value.ForString("admin"), Value.ForString("user") ); // Чтение var kind = container.Value.KindCase; // StringValue, NumberValue, etc. var name = container.Config.Fields["name"].StringValue;
Решение №4: ручная сериализация bytes или string
Тут все просто - сериализуете так, как вам удобнее.
Сравнение
Подход | Что можно положить | Type Safety | Производительность | Гибкость |
Any | Protobuf-сообщения | ⚠️ Средняя | ⚠️ Средняя | ✅ Высокая |
oneof | Фиксированный набор | ✅ Высокая | ✅ Высокая | ❌ Низкая |
Value/Struct | JSON-примитивы | ❌ Низкая | ⚠️ Средняя | ✅ Высокая |
bytes/string | Любые | ❌ Низкая | ✅ Высокая | ✅ Максимальная |
Надеюсь, этот справочник сэкономит тебе те часы отладки, которые я уже потратил за тебя. Сохрани его - он пригодится не один раз. Но у каждого проекта своя специфика, и у меня нет монополии на истину. Работаешь на Go, Python, Java? Столкнулся с чем-то, что я не упомянул? Нашёл более элегантное решение? - велком в комментарии, самые интересные кейсы разберём вместе.
Список полезных материалов
Как работать с библиотекой Grpc.Tools: Protocol Buffers/gRPC Codegen Integration Into .NET Build
Рекомендации по структуре файлов proto: 1-1-1 Best Practice
Лучшие практики proto: Proto Best Practices
Работа с генератором кода protoc: C# Generated Code Guide
Типы данных C# и Proto: Create Protobuf messages for .NET apps
