為 .NET 應用程式建立 Protobuf 訊息

作者:James Newton-KingMark 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),或另一則訊息。

Protobuf 樣式指南建議使用 underscore_separated_names 作為欄位名稱。 為 .NET 應用程式建立的新 Protobuf 訊息,應遵循 Protobuf 樣式指導方針。 .NET 工具會自動產生使用 .NET 命名標準的 .NET 型別。 例如,first_name Protobuf 欄位會產生 FirstName .NET 屬性。

除了名稱之外,訊息定義中的每個欄位都有一個唯一的編號。 在訊息序列化為 Protobuf 時,會使用欄位編號來識別欄位。 序列化少量數字比序列化整個欄位名稱更快。 由於欄位編號會識別欄位,因此在變更欄位時請務必小心。 如需變更 Protobuf 訊息的相關資訊,請參閱 gRPC 服務版本設定

建置應用程式時,Protobuf 工具會從 .proto 檔案產生 .NET 型別。 訊息 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。 此條件約束包含 stringByteString,也就是 C# 類別。 string 預設為空字串值,ByteString 預設為空位元組值。 請嘗試進行設定,使 null 擲回錯誤。

可為 Null 的包裝函式型別,可用來支援 Null 值。

日期和時間

原生純量型別不提供日期和時間值,相當於 .NET 的 DateTimeOffsetDateTimeTimeSpan。 您可以使用一些 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 日期和時間型別。 屬性會使用 Google.Protobuf.WellKnownTypes 命名空間中的 TimestampDuration 類別。 這些類別提供從 DateTimeOffsetDateTimeTimeSpan 轉換和轉換為這些型別的方法。

// 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 的類型

C# 的 Protobuf 程式碼產生過程會使用原生型別,例如 int32int。 因此,其中必定會包含這些值,而且不能是 null

對於需要明確 null 的值 (例如在 C# 程式碼中使用 int?),Protobuf 的已知型別會包含編譯為可為 Null C# 型別的包裝函式。 若要使用,請將 wrappers.proto 匯入您的 .proto 檔案,如下列程式碼所示:

syntax = "proto3";

import "google/protobuf/wrappers.proto";

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

wrappers.proto 型別不會在產生的屬性中公開。 Protobuf 會自動將這些型別對應至 C# 訊息中適當的 .NET 可為 Null 型別。 例如,google.protobuf.Int32Value 欄位會產生 int? 屬性。 參考型別屬性 (如 stringByteString) 保持不變,但可以將 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

Bytes

Protobuf 支援具有 bytes 純量實值型別的二進位承載。 C# 中產生的屬性使用 ByteString 作為屬性型別。

使用 ByteString.CopyFrom(byte[] data) 從位元組陣列建立新的執行個體:

var data = await File.ReadAllBytesAsync(path);

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

使用 ByteString.SpanByteString.Memory 直接存取 ByteString 資料。 或者,呼叫 ByteString.ToByteArray() 以將執行個體轉換回位元組陣列:

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

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

小數位數

Protobuf 本身不支援 .NET decimal 型別,僅支援 doublefloat。 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 所使用的演算法:

  • 很容易理解。
  • 不受不同平台上的 big-endian 或 little-endian 影響。
  • 支援從正 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 新增部分類別。 此部分類別與從 .proto 檔案產生的 DecimalValue 結合。 產生的類別會宣告 UnitsNanos 屬性。
  • 具有用來在 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> 屬性沒有公用 setter。 項目應新增至現有集合。

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 屬性沒有公用 setter。 項目應新增至現有集合。

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 指定可傳回 PersonError 的回應訊息,如下所示:

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 是使用先前討論的 oneof 功能的 Protobuf 已知型別。 若要使用 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。 Protobuf 的 JsonFormatterJsonWriter 型別,可與任何 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);

其他資源