C# 生成的代码指南

准确描述 Protocol Buffer 编译器如何为使用 proto3 语法的协议定义生成 C# 代码。

在阅读本文档之前,您应该先阅读 proto2 语言指南proto3 语言指南版本语言指南

编译器调用

当使用 --csharp_out 命令行标志调用 Protocol Buffer 编译器时,它会生成 C# 输出。--csharp_out 选项的参数是您希望编译器写入 C# 输出的目录,不过编译器也可能会根据其他选项在指定目录下创建子目录。编译器为每个输入的 .proto 文件创建一个源文件,默认扩展名为 .cs,但可通过编译器选项进行配置。

C# 特定选项

您可以使用 --csharp_opt 命令行标志向 Protocol Buffer 编译器提供更多 C# 选项。支持的选项如下:

  • file_extension:设置生成代码的文件扩展名。默认为 .cs,但一个常见的替代方案是 .g.cs,以表示该文件包含生成的代码。

  • base_namespace:指定此选项后,生成器会为生成的源代码创建一个与生成类的命名空间相对应的目录层次结构,并使用该选项的值来指明命名空间的哪一部分应被视为输出目录的“基础”。例如,使用以下命令行:

    protoc --proto_path=bar --csharp_out=src --csharp_opt=base_namespace=Example player.proto
    

    其中 player.protocsharp_namespace 选项为 Example.Game,Protocol Buffer 编译器会生成一个文件 src/Game/Player.cs。此选项通常对应 Visual Studio 中 C# 项目的默认命名空间选项。如果指定了该选项但值为空,则将使用生成文件中完整的 C# 命名空间来构建目录层次结构。如果完全未指定该选项,则生成的文件将直接写入由 --csharp_out 指定的目录,而不会创建任何层次结构。

  • internal_access:指定此选项后,生成器会创建具有 internal 访问修饰符的类型,而不是 public

  • serializable:指定此选项后,生成器会向生成的消息类添加 [Serializable] 特性。

可以通过逗号分隔来指定多个选项,如下例所示:

protoc --proto_path=src --csharp_out=build/gen --csharp_opt=file_extension=.g.cs,base_namespace=Example,internal_access src/foo.proto

文件结构

输出文件的名称由 .proto 文件名转换而来,具体方法是将其转换为 Pascal-case(驼峰式命名),并将下划线视作单词分隔符。因此,例如,名为 player_record.proto 的文件将生成一个名为 PlayerRecord.cs 的输出文件(文件扩展名可以通过 --csharp_opt 指定,如上所示)。

就公共成员而言,每个生成的文件都采用以下形式。(此处未显示实现细节。)

namespace [...]
{
  public static partial class [... descriptor class name ...]
  {
    public static FileDescriptor Descriptor { get; }
  }

  [... Enums ...]
  [... Message classes ...]
}

namespace 是根据 proto 的 package 推断出来的,使用的转换规则与文件名相同。例如,一个 proto 包名为 example.high_score 会生成一个命名空间为 Example.HighScore。您可以使用 csharp_namespace 文件选项来覆盖特定 .proto 文件的默认生成命名空间。

每个顶层枚举和消息都会导致一个枚举或类被声明为命名空间的成员。此外,总是会为文件描述符生成一个静态分部类 (static partial class)。这用于基于反射的操作。描述符类的名称与文件名相同,不带扩展名。但是,如果存在同名的消息(这种情况很常见),描述符类将被放置在一个嵌套的 Proto 命名空间中,以避免与消息冲突。

作为所有这些规则的一个例子,考虑 Protocol Buffers 中提供的 timestamp.proto 文件。timestamp.proto 的精简版如下:

edition = "2023";

package google.protobuf;
option csharp_namespace = "Google.Protobuf.WellKnownTypes";

message Timestamp { ... }

生成的 Timestamp.cs 文件具有以下结构:

namespace Google.Protobuf.WellKnownTypes
{
  namespace Proto
  {
    public static partial class Timestamp
    {
      public static FileDescriptor Descriptor { get; }
    }
  }

  public sealed partial class Timestamp : IMessage<Timestamp>
  {
    [...]
  }
}

消息

给定一个简单的消息声明:

message Foo {}

Protocol Buffer 编译器会生成一个名为 Foo 的 sealed partial 类,该类实现了 IMessage<Foo> 接口,如下所示,附带成员声明。更多信息请参见内联注释。

public sealed partial class Foo : IMessage<Foo>
{
  // Static properties for parsing and reflection
  public static MessageParser<Foo> Parser { get; }
  public static MessageDescriptor Descriptor { get; }

  // Explicit implementation of IMessage.Descriptor, to avoid conflicting with
  // the static Descriptor property. Typically the static property is used when
  // referring to a type known at compile time, and the instance property is used
  // when referring to an arbitrary message, such as during JSON serialization.
  MessageDescriptor IMessage.Descriptor { get; }

  // Parameterless constructor which calls the OnConstruction partial method if provided.
  public Foo();
  // Deep-cloning constructor
  public Foo(Foo);
  // Partial method which can be implemented in manually-written code for the same class, to provide
  // a hook for code which should be run whenever an instance is constructed.
  partial void OnConstruction();

  // Implementation of IDeepCloneable<T>.Clone(); creates a deep clone of this message.
  public Foo Clone();

  // Standard equality handling; note that IMessage<T> extends IEquatable<T>
  public override bool Equals(object other);
  public bool Equals(Foo other);
  public override int GetHashCode();

  // Converts the message to a JSON representation
  public override string ToString();

  // Serializes the message to the protobuf binary format
  public void WriteTo(CodedOutputStream output);
  // Calculates the size of the message in protobuf binary format
  public int CalculateSize();

  // Merges the contents of the given message into this one. Typically
  // used by generated code and message parsers.
  public void MergeFrom(Foo other);

  // Merges the contents of the given protobuf binary format stream
  // into this message. Typically used by generated code and message parsers.
  public void MergeFrom(CodedInputStream input);
}

请注意,所有这些成员总是存在的;optimize_for 选项不会影响 C# 代码生成器的输出。

嵌套类型

消息可以声明在另一个消息内部。例如:

message Foo {
  message Bar {
  }
}

在这种情况下——或者如果消息包含嵌套枚举——编译器会生成一个嵌套的 Types 类,然后在 Types 类中再生成一个 Bar 类,所以完整的生成代码将是:

namespace [...]
{
  public sealed partial class Foo : IMessage<Foo>
  {
    public static partial class Types
    {
      public sealed partial class Bar : IMessage<Bar> { ... }
    }
  }
}

尽管中间的 Types 类有些不便,但它是必需的,以处理常见场景:嵌套类型在消息中有一个对应的字段。否则,你最终会在同一个类中嵌套一个属性和一个同名的类型——这在 C# 中是无效的。

字段

Protocol Buffer 编译器为消息中定义的每个字段生成一个 C# 属性。该属性的具体性质取决于字段的性质:它的类型,以及它是单一字段、重复字段还是映射字段。

单一字段

任何单一字段都会生成一个读/写属性。如果为 stringbytes 字段指定了 null 值,则会产生 ArgumentNullException 异常;从未显式设置过的字段获取值将返回空字符串或 ByteString。消息字段可以设置为 null 值,这实际上是清空该字段。这不等同于将该值设置为空的消息类型实例。

重复字段

每个重复字段都会生成一个类型为 Google.Protobuf.Collections.RepeatedField<T> 的只读属性,其中 T 是字段的元素类型。在大多数情况下,它的行为类似于 List<T>,但它有一个额外的 Add 重载,允许一次性添加一个项目集合。这在对象初始化器中填充重复字段时很方便。此外,RepeatedField<T> 直接支持序列化、反序列化和克隆,但这通常由生成的代码使用,而不是由手动编写的应用程序代码使用。

重复字段不能包含 null 值,即使是消息类型也不行,但下面解释的可空包装器类型除外。

映射字段

每个映射字段都会生成一个类型为 Google.Protobuf.Collections.MapField<TKey, TValue> 的只读属性,其中 TKey 是字段的键类型,TValue 是字段的值类型。在大多数情况下,它的行为类似于 Dictionary<TKey, TValue>,但它有一个额外的 Add 重载,允许一次性添加另一个字典。这在对象初始化器中填充重复字段时很方便。此外,MapField<TKey, TValue> 直接支持序列化、反序列化和克隆,但这通常由生成的代码使用,而不是由手动编写的应用程序代码使用。映射中的键不允许为 null;如果对应的单一字段类型支持 null 值,则值可以为 null。

Oneof 字段

oneof 中的每个字段都有一个单独的属性,就像常规的单一字段一样。但是,编译器还会生成一个额外的属性来确定 oneof 中设置了哪个字段,以及一个枚举和一个用于清除 oneof 的方法。例如,对于这个 oneof 字段定义:

oneof avatar {
  string image_url = 1;
  bytes image_data = 2;
}

编译器将生成以下公共成员:

enum AvatarOneofCase
{
  None = 0,
  ImageUrl = 1,
  ImageData = 2
}

public AvatarOneofCase AvatarCase { get; }
public void ClearAvatar();
public string ImageUrl { get; set; }
public ByteString ImageData { get; set; }

如果一个属性是当前的 oneof“case”,获取该属性将返回为该属性设置的值。否则,获取该属性将返回该属性类型的默认值——一个 oneof 中一次只能设置一个成员。

设置 oneof 的任何构成属性都会改变所报告的 oneof 的“case”。与常规的单一字段一样,您不能将类型为 stringbytes 的 oneof 字段设置为 null 值。将消息类型的字段设置为 null 等同于调用 oneof 特定的 Clear 方法。

包装器类型字段

大多数知名类型不影响代码生成,但包装器类型(如 StringWrapperInt32Wrapper)会改变属性的类型和行为。

所有对应 C# 值类型的包装器类型(如 Int32WrapperDoubleWrapperBoolWrapper)都映射到 Nullable<T>,其中 T 是相应的不可空类型。例如,一个类型为 DoubleValue 的字段会生成一个类型为 Nullable<double> 的 C# 属性。

类型为 StringWrapperBytesWrapper 的字段会生成类型为 stringByteString 的 C# 属性,但默认值为 null,并且允许将 null 设置为属性值。

对于所有包装器类型,重复字段中不允许使用 null 值,但允许作为映射条目的值。

枚举

给定一个枚举定义,如下所示:

enum Color {
  COLOR_UNSPECIFIED = 0;
  COLOR_RED = 1;
  COLOR_GREEN = 5;
  COLOR_BLUE = 1234;
}

Protocol Buffer 编译器将生成一个名为 Color 的 C# 枚举类型,其值集与之一致。枚举值的名称会被转换,以使其对 C# 开发者更具惯用性:

  • 如果原始名称以枚举名称本身的大写形式开头,则该部分将被移除。
  • 结果会被转换为帕斯卡命名法(Pascal case)。

因此,上面的 Color proto 枚举将变成以下 C# 代码:

enum Color
{
  Unspecified = 0,
  Red = 1,
  Green = 5,
  Blue = 1234
}

这种名称转换不影响消息的 JSON 表示中使用的文本。

请注意,.proto 语言允许多个枚举符号具有相同的数值。具有相同数值的符号是同义词。这些在 C# 中以完全相同的方式表示,即多个名称对应于相同的数值。

非嵌套枚举会导致生成一个作为新命名空间成员的 C# 枚举;嵌套枚举会导致在与该枚举嵌套的消息相对应的类中的 Types 嵌套类中生成一个 C# 枚举。

服务(Services)

C# 代码生成器完全忽略服务。