Создание сообщений protobuf для приложений .NET

Авторы: Джеймс Ньютон-Кинг (James Newton-King) и Марк Рендл (Mark Rendle)

gRPC использует protobuf в качестве языка определения интерфейса (IDL). Protobuf IDL — это не зависящий от языка формат для указания сообщений, отправляемых и получаемых службами gRPC. Сообщения Protobuf определяются в .proto файлах. В этом документе объясняется, как концепции protobuf соотносятся с .NET.

Сообщения Protobuf

Сообщения — это основной объект для обмена данными в protobuf. Они концептуально схожи с классами .NET.

syntax = "proto3";

option csharp_namespace = "Contoso.Messages";

message Person {
    int32 id = 1;
    string first_name = 2;
    string last_name = 3;
}  

В предыдущем определении сообщения указывается три поля в качестве пар "имя — значение". Как и свойства типов .NET, каждое поле имеет имя и тип. Типом поля может быть скалярный тип значения protobuf, например int32, или другое сообщение.

В инструкции по стилю protobufunderscore_separated_names рекомендуется использовать для имен полей. Новые сообщения protobuf, созданные для приложений .NET, должны следовать рекомендациям по стилю protobuf. Инструментарий .NET автоматически создает типы .NET, использующие стандарты именования .NET. Например, поле protobuf first_name формирует свойство .NET FirstName.

Помимо имени, каждое поле в определении сообщения имеет уникальный номер. Номера полей используются для задания полей при сериализации сообщения в protobuf. Сериализация небольшого числа выполняется быстрее, чем сериализация всего имени поля. Поскольку номера полей указывают на поле, важно соблюдать осторожность при их изменении. Дополнительные сведения об изменении сообщений protobuf см. в статье Управление версиями gRPC Services.

При создании приложения средство Protobuf создает типы .NET из .proto файлов. Сообщение Person создает класс .NET:

public class Person
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

Дополнительные сведения о сообщениях protobuf см. в разделе Руководство по языку protobuf.

Скалярные типы значений

Protobuf поддерживает ряд собственных скалярных типов значений. В следующей таблице перечислены все типы с эквивалентными им типами в C#.

Тип protobuf Тип C#
double double
float float
int32 int
int64 long
uint32 uint
uint64 ulong
sint32 int
sint64 long
fixed32 uint
fixed64 ulong
sfixed32 int
sfixed64 long
bool bool
string string
bytes ByteString

Скалярные значения всегда имеют значение по умолчанию. Им невозможно задать значение null. Это ограничение действует для string и ByteString, которые являются классами C#. Значением по умолчанию string является пустое строковое значение, а для ByteString по умолчанию используется пустое байтовое значение. При попытке задать для них значение null возникает ошибка.

Типы оболочек, допускающие значение NULL, могут использоваться для поддержки значений NULL.

Дата и время

Собственные скалярные типы не предоставляют значения даты и времени, что эквивалентно DateTimeOffset, DateTime и TimeSpan в .NET. Эти типы можно задавать с помощью некоторых из расширений хорошо известных типов Protobuf. Эти расширения обеспечивают поддержку создания кода и среды выполнения для сложных типов полей в поддерживаемых платформах.

В следующей таблице показаны типы даты и времени.

Тип .NET Хорошо известный тип Protobuf
DateTimeOffset google.protobuf.Timestamp
DateTime google.protobuf.Timestamp
TimeSpan google.protobuf.Duration
syntax = "proto3";

import "google/protobuf/duration.proto";  
import "google/protobuf/timestamp.proto";

message Meeting {
    string subject = 1;
    google.protobuf.Timestamp start = 2;
    google.protobuf.Duration duration = 3;
}  

Созданные свойства в классе C# не являются типами даты и времени .NET. Свойства используют классы Timestamp и Duration в пространстве имен Google.Protobuf.WellKnownTypes. Эти классы предоставляют методы для преобразования в DateTimeOffset, DateTime и TimeSpan.

// Create Timestamp and Duration from .NET DateTimeOffset and TimeSpan.
var meeting = new Meeting
{
    Time = Timestamp.FromDateTimeOffset(meetingTime), // also FromDateTime()
    Duration = Duration.FromTimeSpan(meetingLength)
};

// Convert Timestamp and Duration to .NET DateTimeOffset and TimeSpan.
var time = meeting.Time.ToDateTimeOffset();
var duration = meeting.Duration?.ToTimeSpan();

Примечание.

Тип Timestamp работает с временем в формате UTC. Значения DateTimeOffset всегда имеют нулевое смещение, а свойство DateTime.Kind всегда имеет значение DateTimeKind.Utc.

Типы, допускающие значения NULL

При создании кода protobuf для C# используются собственные типы, например int для int32. Поэтому значения всегда включаются и не могут быть null.

Для значений, требующих явного задания null, например при использовании int? в коде C#, хорошо известные типы Protobuf включают оболочки, которые компилируются в типы C#, допускающие значение NULL. Чтобы использовать их, импортируйте wrappers.proto в файл .proto, как в следующем коде.

syntax = "proto3";

import "google/protobuf/wrappers.proto";

message Person {
    // ...
    google.protobuf.Int32Value age = 5;
}

Типы wrappers.proto не представлены в созданных свойствах. Protobuf автоматически сопоставляет их с соответствующими типами .NET, допускающими значение NULL, в сообщениях C#. Например, поле google.protobuf.Int32Value создает свойство int?. Свойства ссылочного типа, такие как string и ByteString, не изменяются, за исключением того, что значение null может быть назначено им без ошибок.

В следующей таблице приведен полный список типов оболочек с эквивалентным им типом C#.

Тип C# Оболочка хорошо известного типа
bool? google.protobuf.BoolValue
double? google.protobuf.DoubleValue
float? google.protobuf.FloatValue
int? google.protobuf.Int32Value
long? google.protobuf.Int64Value
uint? google.protobuf.UInt32Value
ulong? google.protobuf.UInt64Value
string google.protobuf.StringValue
ByteString google.protobuf.BytesValue

Байт

Двоичные полезные данные поддерживаются в Protobuf со скалярным типом значений bytes. Созданное свойство в C# использует ByteString как тип свойства.

Используйте ByteString.CopyFrom(byte[] data) для создания экземпляра из массива байтов:

var data = await File.ReadAllBytesAsync(path);

var payload = new PayloadResponse();
payload.Data = ByteString.CopyFrom(data);

Доступ к данным ByteString осуществляется напрямую с помощью ByteString.Span или ByteString.Memory. Или вызовите ByteString.ToByteArray(), чтобы преобразовать экземпляр обратно в массив байтов:

var payload = await client.GetPayload(new PayloadRequest());

await File.WriteAllBytesAsync(path, payload.Data.ToByteArray());

Десятичные знаки

Protobuf изначально не поддерживает тип .NET decimal, просто double и float. В проекте Protobuf ведется обсуждение возможности добавления к хорошо известным типам стандартного десятичного типа с платформенной поддержкой языков и инфраструктур, поддерживающих эту возможность. Однако к настоящему моменту еще ничего не реализовано.

Можно создать определение сообщения, представляющее тип decimal, которое подходит для безопасной сериализации между клиентами и серверами .NET. Однако разработчикам на других платформах следует понимать, какой формат используется, и реализовать свою собственную обработку.

Создание настраиваемого десятичного типа для protobuf

package CustomTypes;

// Example: 12345.6789 -> { units = 12345, nanos = 678900000 }
message DecimalValue {

    // Whole units part of the amount
    int64 units = 1;

    // Nano units of the amount (10^-9)
    // Must be same sign as units
    sfixed32 nanos = 2;
}

Поле nanos представляет значения из 0.999_999_999 в -0.999_999_999. Например, значение decimal1.5m будет представлено как { units = 1, nanos = 500_000_000 }. Именно поэтому в поле nanos в этом примере используется тип sfixed32, который более эффективно кодируется, чем int32 для больших значений. Если поле units имеет отрицательное значение, поле nanos также должно быть отрицательным.

Примечание.

Для кодирования значений decimal в виде байтовых строк доступны дополнительные алгоритмы. Алгоритм, используемый DecimalValue:

  • несложный в эксплуатации;
  • не затрагивается обратным порядком байтов или прямым порядком байтов в разных платформах;
  • поддерживает десятичные числа в диапазоне от положительного 9,223,372,036,854,775,807.999999999 до отрицательного 9,223,372,036,854,775,808.999999999 с максимальной точностью до девяти десятичных разрядов, что не является полным диапазоном для decimal.

Преобразование между этим типом и типом BCL decimal может быть реализовано в C# следующим образом:

namespace CustomTypes
{
    public partial class DecimalValue
    {
        private const decimal NanoFactor = 1_000_000_000;
        public DecimalValue(long units, int nanos)
        {
            Units = units;
            Nanos = nanos;
        }

        public static implicit operator decimal(CustomTypes.DecimalValue grpcDecimal)
        {
            return grpcDecimal.Units + grpcDecimal.Nanos / NanoFactor;
        }

        public static implicit operator CustomTypes.DecimalValue(decimal value)
        {
            var units = decimal.ToInt64(value);
            var nanos = decimal.ToInt32((value - units) * NanoFactor);
            return new CustomTypes.DecimalValue(units, nanos);
        }
    }
}

Предыдущий код:

  • Добавляет разделяемый класс для DecimalValue. Разделяемый класс объединяется с DecimalValue, полученным из файла .proto. Созданный класс объявляет свойства Units и Nanos.
  • Содержит неявные операторы для преобразования между типами DecimalValue и BCL decimal.

Коллекции

Списки

Списки в protobuf указываются с помощью ключевого слова префикса repeated в поле. В следующем примере показано, как создать список.

message Person {
    // ...
    repeated string roles = 8;
}

В созданном коде поля repeated представлены универсальным типом Google.Protobuf.Collections.RepeatedField<T>.

public class Person
{
    // ...
    public RepeatedField<string> Roles { get; }
}

RepeatedField<T> реализует IList<T>. Поэтому можно использовать запросы LINQ или преобразовать их в массив или список. У свойств RepeatedField<T> нет открытого метода задания. Элементы должны быть добавлены в существующую коллекцию.

var person = new Person();

// Add one item.
person.Roles.Add("user");

// Add all items from another collection.
var roles = new [] { "admin", "manager" };
person.Roles.Add(roles);

Словари

Тип .NET IDictionary<TKey,TValue> представлен в protobuf с помощью map<key_type, value_type>.

message Person {
    // ...
    map<string, string> attributes = 9;
}

В созданном коде .NET поля map представлены универсальным типом Google.Protobuf.Collections.MapField<TKey, TValue>. MapField<TKey, TValue> реализует IDictionary<TKey,TValue>. Как и свойства repeated, свойства map не имеют открытого метода задания. Элементы должны быть добавлены в существующую коллекцию.

var person = new Person();

// Add one item.
person.Attributes["created_by"] = "James";

// Add all items from another collection.
var attributes = new Dictionary<string, string>
{
    ["last_modified"] = DateTime.UtcNow.ToString()
};
person.Attributes.Add(attributes);

Неструктурированные и условные сообщения

Protobuf — это формат обмена сообщениями на основе контракта. Сообщения приложения, включая поля и типы, должны быть указаны в файлах .proto при сборке приложения. Проектирование на основе контракта Protobuf отлично подходит для обеспечения содержимого сообщения, но может ограничивать сценарии, в которых не требуется строгий контракт:

  • Сообщения с неизвестными полезными данными. Например, сообщение с полем, которое может содержать любое сообщение.
  • Условные сообщения. Например, служба gRPC может возвращать сообщение об успехе или ошибке.
  • Динамические значения. Например, сообщение с полем, которое содержит неструктурированную коллекцию значений, аналогично формату JSON.

Для поддержки этих сценариев Protobuf предлагает языковые функции и типы.

Любое

Тип Any позволяет использовать сообщения в качестве внедренных типов без их .proto определения. Чтобы использовать тип Any, импортируйте any.proto.

import "google/protobuf/any.proto";

message Status {
    string message = 1;
    google.protobuf.Any detail = 2;
}
// Create a status with a Person message set to detail.
var status = new ErrorStatus();
status.Detail = Any.Pack(new Person { FirstName = "James" });

// Read Person message from detail.
if (status.Detail.Is(Person.Descriptor))
{
    var person = status.Detail.Unpack<Person>();
    // ...
}

Oneof

Поля oneof — это функция языка. Компилятор обрабатывает ключевое слово oneof при формировании класса сообщения. Использование oneof для указания ответного сообщения, которое может возвращать Person или Error, может выглядеть следующим образом.

message Person {
    // ...
}

message Error {
    // ...
}

message ResponseMessage {
  oneof result {
    Error error = 1;
    Person person = 2;
  }
}

Поля в наборе oneof должны иметь уникальные номера полей в общем объявлении сообщения.

При использовании oneof созданный код C# включает перечисление, указывающее, какое из полей было задано. Можно проверить перечисление, чтобы узнать, какое поле задается. Поля, которые не заданы, возвращают null или значение по умолчанию вместо создания исключения.

var response = await client.GetPersonAsync(new RequestMessage());

switch (response.ResultCase)
{
    case ResponseMessage.ResultOneofCase.Person:
        HandlePerson(response.Person);
        break;
    case ResponseMessage.ResultOneofCase.Error:
        HandleError(response.Error);
        break;
    default:
        throw new ArgumentException("Unexpected result.");
}

Значение

Тип Value представляет динамически типизированное значение. Это может быть либо null, число, строка, логическое значение, словарь значений (Struct), либо список значений (ValueList). Value — это хорошо известный тип Protobuf, использующий описанную выше функцию oneof. Чтобы использовать тип Value, импортируйте struct.proto.

import "google/protobuf/struct.proto";

message Status {
    // ...
    google.protobuf.Value data = 3;
}
// Create dynamic values.
var status = new Status();
status.Data = Value.ForStruct(new Struct
{
    Fields =
    {
        ["enabled"] = Value.ForBool(true),
        ["metadata"] = Value.ForList(
            Value.ForString("value1"),
            Value.ForString("value2"))
    }
});

// Read dynamic values.
switch (status.Data.KindCase)
{
    case Value.KindOneofCase.StructValue:
        foreach (var field in status.Data.StructValue.Fields)
        {
            // Read struct fields...
        }
        break;
    // ...
}

Использование Value напрямую может быть подробным. Альтернативный способ использования Value — встроенная поддержка Protobuf для сопоставления сообщений с JSON. Типы JsonFormatter и JsonWriter protobuf можно использовать с любым сообщением protobuf. Value особенно хорошо подходит для преобразования в формат JSON и обратно.

Вот эквивалент предыдущего кода в формате JSON:

// Create dynamic values from JSON.
var status = new Status();
status.Data = Value.Parser.ParseJson(@"{
    ""enabled"": true,
    ""metadata"": [ ""value1"", ""value2"" ]
}");

// Convert dynamic values to JSON.
// JSON can be read with a library like System.Text.Json or Newtonsoft.Json
var json = JsonFormatter.Default.Format(status.Data);
var document = JsonDocument.Parse(json);

Дополнительные ресурсы