自己学习Protobuf的整理

前言

        本篇文章是我自己在学习Protobuf时做的整理。如有错误,还望告知,谢谢。

环境

    Windows10
    Unity2022.3.8f1c1 il2Cpp .Net FrameWork
    Google.Protobuf 3.27.1

Protobuf文件写法的简单说明

        下列说明以protobuf csharp教程中的内容为例子说明。

        我们创建一个以.proto为后缀名的文件。并填入以下示例内容:

1
2
3
4
syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

所有的Protobuf文件都以syntax作为开头。syntax关键字用于指定.proto文件所使用的Protobuf语法版本。现在支持proto2proto3proto2proto3之间有一些区别,但是因为我项目使用的是proto3,所以下文大多以proto3为主。特别注意的是在.proto文件中如果你不注明所用的syntax是哪个,那么第一行必须是非空或非注释行。这是在语言指导中明确指出的,下面是文章原文:

1
The first line of the file specifies that you’re using proto3 syntax: if you don’t do this the protocol buffer compiler will assume you are using proto2. This must be the first non-empty, non-comment line of the file.

比如你的文件是下面这样的写法

1
2
3
4
5
6
// 我就是要注释
package tutorial;
message FirstLine
{
string test = 1;
}

那么你会得到FirstLine.proto:5:5: Expected "required", "optional", or "repeated".(FirstLine.proto是我的.proto文件名)的提示。既然这里展现出了message,那么这里我就将messagepackage一起讲述package等同于CSharp中的namespace,使用工具生成CSharp代码后,package会变成下面这样:

1
namespace tutorial

message就像是CSharp中Class的作用。而我们使用工具生成的代码就会像下面这样:

1
2
3
4
5
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class FirstLine : pb::IMessage<FirstLine>
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
, pb::IBufferMessage
#endif

message FirstLine中的string则是Protobuf支持的数据类型。当然Protobuf支持的数据类型也是很多的。

Protobuf文件支持的数据类型

        Protobuf所有支持的类型都在其官方文档——Language Guide (proto 3)中存在说明。(如果你是用proto 2,则其文档为:Language Guide (proto 2))

PS:以下英文部分翻译均为机翻,如果你觉得不好请查看上面的文档

  1. Scalar Value Types(标量值类型)
proto的类型 注释 c++类型 Java/Kotlin类型 Python类型 Go类型 Ruby类型 C#类型 Dart类型
double double double float *float64 Float double double
float float float float *float32 Float float float
int32 其使用变长编码,对负数进行编码效率低下。如果您的字段可能有负值,请使用sint32类型。 int32 int int int32 Fixnum or Bignum (as required) int *int32
int64 其使用变长编码,对负数进行编码效率低下。如果您的字段可能有负值,请使用sint32类型。 int64 long int/long *int64 Bignum long Int64
uint32 其使用变长编码。 uint32 int int/long *uint32 Fixnum or Bignum (as required) uint int
uint64 其使用变长编码。 uint64 long int/long *uint64 Bignum ulong Int64
sint32 其使用变长编码,是带符号整型值。它们比普通int32更有效地编码负数。 int32 int int int32 Fixnum or Bignum (as required) int *int32
sint64 其使用变长编码,是带符号整型值。它们比普通int64更有效地编码负数。 int64 long int/long *int64 Bignum long Int64
fixed32 其总是四个字节。如果值经常大于\(2^{28}\),则比uint32更有效。 uint32 int int/long *uint32 Fixnum or Bignum (as required) uint int
fixed64 其总是八个字节。如果值通常大于\(2^{56}\),则比uint64更有效。 uint64 long int/long *uint64 Bignum ulong Int64
sfixed32 其总是四个字节。 int32 int int *int32 Fixnum or Bignum (as required) int int
sfixed64 其总是八个字节。 int64 long int/long *int64 Bignum long Int64
bool bool boolean bool *bool TrueClass/FalseClass bool bool
string 字符串必须始终包含UTF-8编码或7位ASCII文本,且长度不能超过\(2^{32}\) string String unicode (Python 2) or str (Python 3) *string String (UTF-8) string String
bytes 可以包含不超过\(2^{32}\)的任意字节序列。 string ByteString bytes []byte String(ASCII-8BIT) ByteString List

上述表格上还有一些注解,但是我就不继续搬运了。有兴趣的朋友可以去看一下官方的文档

        除了上述的基本类型外,我们还可以通过其他的方式来使用一些数据结构。下面的示例就是使用repeated创建了一个字符串数组。

1
2
3
4
message Show 
{
repeated string strArray = 1;
}

我们还可以使用map生成类似字典一样的数据结构。示例如下

1
2
3
4
message Show 
{
map<string, int> dic = 1;
}

但是map是不能够被repeated,即你不能创建一个List<Dictionary<T,T>>这样的类型,对于Protobuf来说则是RepeatedField<MapField<T,T>>

1
2
3
4
5
syntax = "proto3";
message MapRepeat
{
repeated map<string,int32> test = 1;
}

上面的例子使用工具命令生成代码后会得到这样的提示:MapRepeat.proto:4:17: Field labels (required/optional/repeated) are not allowed on map fields.(MapRepeat.proto是我的.proto文件名)。而如果是下面这个例子:

1
2
3
4
5
syntax = "proto3";
message MapRepeat
{
map<string,repeated int32> test = 1;
}

那么你会得到下面的这样的提示:MapRepeat.proto:4:25: Expected ">".。除了这些之外map还有其他需要注意的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
原文:
Wire format ordering and map iteration ordering of map values are undefined, so you cannot rely on your map items being in a particular order.

When generating text format for a .proto, maps are sorted by key. Numeric keys are sorted numerically.

When parsing from the wire or when merging, if there are duplicate map keys the last key seen is used. When parsing a map from text format, parsing may fail if there are duplicate keys.

If you provide a key but no value for a map field, the behavior when the field is serialized is language-dependent. In C++, Java, Kotlin, and Python the default value for the type is serialized, while in other languages nothing is serialized.

No symbol FooEntry can exist in the same scope as a map foo, because FooEntry is already used by the implementation of the map.

译文:
映射值的连线格式排序和映射迭代排序是未定义的,因此不能依赖于映射项的特定顺序。

在为.proto生成文本格式时,映射按键排序。数字键按数字排序。

在从连接进行解析或合并时,如果存在重复的映射键,则使用最后看到的键。从文本格式解析映射时,如果存在重复键,解析可能会失败。

如果为映射字段提供键但没有值,则序列化字段时的行为依赖于语言。在c++、Java、Kotlin和Python中,该类型的默认值是序列化的,而在其他语言中则没有序列化。

符号FooEntry不能存在于map foo的同一作用域中,因为FooEntry已经被map的实现使用了。

        Protobuf也支持Enumerations(枚举)。比如下面这样例子

1
2
3
4
5
6
7
8
9
10
syntax = "proto3";
enum TestEnum
{
FirstTest = 0;
SecondTest = 1;
}
message Enumerations
{
TestEnum test = 1;
}

我们需要注意的是枚举的开始可以从0开始,且必须要有0值。除此之外0值对应的元素必须是第一个(这个是为了和Proto2协议兼容)。官方文档中也有特别的描述

1
2
3
4
5
6
7
原文:
There must be a zero value, so that we can use 0 as a numeric default value.
The zero value needs to be the first element, for compatibility with the proto2 semantics where the first enum value is the default unless a different value is explicitly specified.

软件译文:
必须有一个零值,以便我们可以使用0作为数字默认值。
为了与proto2语义兼容,零值需要是第一个元素,在proto2语义中,除非显式指定不同的值,否则第一个枚举值是默认值。

没有0值如:

1
2
3
4
5
enum TestEnum 
{
FirstTest = 1;
SecondTest = 2;
}

则你会得到这样的提示:Enumerations.proto:4:15: The first enum value must be zero for open enums.。有0值但0值所对应的元素不是第一个,如:

1
2
3
4
5
enum TestEnum 
{
FirstTest = 1;
SecondTest = 0;
}

我们得到的提示和上面一样。Protobuf关于枚举还有些其他的规定,比如其值必须在32比特的整型范围内。虽然我觉得枚举使用负值很奇怪,但是Protobuf官方对于枚举其实并没有说不能为负值只是不推荐。

1
2
3
4
原文:
Since enum values use varint encoding on the wire, negative values are inefficient and thus not recommended.
译文:
由于枚举值在网络上使用可变编码,因此负值是低效的,因此不建议使用。

我这里也展示一下负值枚举的生成:

1
2
3
4
5
6
syntax = "proto3";
enum TestEnum
{
FirstTest = 0;
SecondTest = -1;
}

生成代码的部分:

1
2
3
4
public enum TestEnum {
[pbr::OriginalName("FirstTest")] FirstTest = 0,
[pbr::OriginalName("SecondTest")] SecondTest = -1,
}

不过我还是觉得枚举是负数还是很奇怪啊。

        关于枚举,官方还提到一个重要的点,Protobuf生成的代码可能受到特定于语言的枚举数限制(一种语言的枚举数较低)。查看您计划使用的语言的限制。这也就是说明Protobuf生成的代码本身还是会受到语言的约束。而有关其更多信息,你若感兴趣则可以查阅Enum BehaviorEnumerations等文档。

        既然介绍完了Protobuf支持的数据类型,那么我们再回到第一个示例中。其中的import等同于CSharp中引入名空间,这样你就可以在本文件中使用其他文件里的消息定义。~~我觉得这里的packageimport和Java的语法及其类似。虽然我在网上搜索的时候是有查到import可以用绝对路径,但是我个人在使用的时候发现不行。这也许是我操作的问题。不过一般而言项目本身的东西本来就要放在一起。所以我觉得就算没掌握如何使用绝对路径也就没啥关系了。例子如下:

1
2
3
4
5
6
syntax = "proto3";
import "/TestOutput/ImportTestOther.proto";
message ImportTest
{
ImportTestOther info = 1;
}

具体ImportTestOther.proto中的内容我就不展示了,反正这是在ImportTestOther.proto下的定义。我设定的ImportTest所在文件路径为D:\Test,而ImportTestOther.proto是在D:\Test\TestOutput下。值得注意的是在Protobuf中地址文件的分隔符是使用'/'而Windows下的分隔符则为'\'。如果你的proto文件在其他的路径下,你也可以生成工具的命令中使用-I--proto_path--proto_path来添加查找的地址。

        如果你只是想写一个简单的Protobuf文件,那我觉得到这里就可以了。如果你想要了解关于Protobuf更多的特性和信息的话,我还是推荐你们去官方文档上查看具体的说明。

Google.Protobuf工具下载

        如果我们需要将.proto文件转换为我们想要的代码,那么我们必须要先下载Google.Protobuf工具。Google.Protobuf工具下载的GitHub地址如下:https://github.com/protocolbuffers/protobuf/releases。大家按照自己的需求下载对应的工具就好。

使用Google.Protobuf工具生成CSharp代码

        我个人下载的是protoc-27.1-win64,你可以在下载文件中找到bin文件夹,并在其下面有这protoc.exe文件。虽然我个人很希望Google能提供一个图形界面给我们使用,但是它并没有。所以我们只能使用命令窗口通过命令调用protoc.exe来转换我们的.proto文件。无论你喜欢使用cmd命令窗口还是PowerShell命令窗口,命令都是一样的。下面是生成.proto文件转换为CSharp文件的命令格式和对应的例子

1
2
3
4
格式:
protoc -I=$SRC_DIR --csharp_out=$DST_DIR $SRC_DIR/addressbook.proto
例子:
C:\protoc-27.1-win64\bin\protoc.exe -I=C:\Users\Administrator\Desktop\TestInput --csharp_out=C:\Users\Administrator\Desktop\TestOutput C:\Users\Administrator\Desktop\TestInput\Enumerations.proto

上面的格式是官方文档中提供的格式说明,addressbook.proto是官方提供的一个文件。其中protoc就是protoc.exe的位置,正如我例子中的C:\protoc-27.1-win64\bin\protoc.exe。如果你命令窗口的路径指到protoc.exe的位置,那么就只需要protoc(PowerShell窗口需要是.\\protoc)。$SRC_DIR表示的是你.proto文件所在的上层目录位置。就像例子中Enumerations.proto文件的位置在C:\Users\Administrator\Desktop\TestInput\Enumerations.proto,所以$SRC_DIR即为:C:\Users\Administrator\Desktop\TestInput--csharp_out=表示生成CSharp文件输出的位置为,即我们所需要填入的$DST_DIR。这里要特别注意一下$DST_DIR后面是有存在空格的。

在Windows下使用时需注意的点

  1. 不支持路径中带有中文

        无论是$SRC_DIR还是$DST_DIR,他们都不支持内有中文。或许是我电脑的问题,不过我还是希望后面Google能解决这个问题。现在我电脑运行下面的命令后

1
protoc.exe -I=C:\Users\Administrator\Desktop\TestInput --csharp_out=C:\Users\Administrator\Desktop\Protobuf各类文件\TestOutput C:\Users\Administrator\Desktop\TestInput\Enumerations.proto

便会出现这样的提示:

1
C:\Users\Administrator\Desktop\Protobuf各类文件\TestOutput/: No such file or directory

但是在我电脑上C:\Users\Administrator\Desktop\Protobuf各类文件\TestOutput文件夹是存在的。虽然$SRC_DIR$DST_DIR,他们都不支持内有中文。但是protoc.exe的路径中还是支持的。

  1. 路径中不能带有"()"

        这个也是刚好我自己在做测试项目的时候发现了,那时候我的文件名里有使用"(xxx)"来做同项目同名文件的区分。然后我就发现了这个错误。运行的命令如下:

1
C:\Users\Administrator\Desktop\protoc-27.1-win64\bin()\protoc.exe -I=C:\Users\Administrator\Desktop\TestInput --csharp_out=C:\Users\Administrator\Desktop\\TestOutput C:\Users\Administrator\Desktop\TestInput\Enumerations.proto

提示如下:

1
2
3
4
5
6
所在位置 行:1 字符: 72
+ ... dministrator\Desktop\protoc-27.1-win64\bin()\protoc ...
+ ~
“(”后面应为表达式。
+ CategoryInfo : ParserError: (:) [], ParentContainsErrorRecordException
+ FullyQualifiedErrorId : ExpectedExpression

包括$SRC_DIR$DST_DIR的路径中也不能存在"()"。

Unity中使用Google.Protobuf

        在我们将生成的代码拉入Unity前,我们要先将运行Google.Protobuf所需的dll导入进Unity。

Google.Protobuf的dll文件下载方案

方法一:

        protobuf项目有给出对应的工程,你可以用这工程自己构建出dll文件。

方法二:

        直接使用NuGet包管理器下载Google.Protobuf。在Vistual Studio中则在:项目->管理NuGet程序包。在NuGet界面搜索并下载Google.Protobuf。但是这样Unity中是不会加载由NuGet下载的dll。我个人在操作的时候,其包的位置在Unity的Asset文件同层次下的Packages文件夹中。当然你也可以在NuGet中找到包的位置。因为Google.Protobuf包的依赖关系,我们需要导入下面这些dll:

1
2
3
4
5
System.Runtime.CompilerServices.Unsafe.dll
System.Numerics.Vectors.dll
System.Memory.dll
System.Buffers.dll
Google.Protobuf.dll

我们使用包管理器下载下来的包是包含许多版本的dll。我是选择了使用了netstand2.0版本下的dll文件。特别要注意这些文件导入的版本要尽量一致。如果你不知道你自己要导入哪个版本的dll文件,你可以选择一个要导入的dll并将其所有的版本的dll依次导入一遍。那个dll在导入的时候不报错就用哪个,然后其他要导入的dll文件选择一样的版本就好。我个人建议先导入System.Runtime.CompilerServices.Unsafe.dll试试看,因为它不需要任何的依赖。

Google.Protobuf工具整合到Unity中

        一般而言,我们只会将.proto文件转换为一种语言。而Google自身的工具要使用命令窗口,我个人又觉得麻烦。所以我想着能否将Google.Protobuf工具整合到Unity中。当然这是可以的,我的想法比较简单。首先我们根据用户的输入来进行命令的拼接,然后我们使用CSharp代码来调用Windows下的命令窗口执行这个命令。具体代码的实现如下(这里为了防止调用命令窗口时Unity卡死,我这里导入了官方一个编辑器协程包——Editor Coroutines。大家在黏贴代码的时候,记得先使用Unity自带的Package Manager下载Editor Coroutines。):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
using System;
using System.Collections;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Unity.EditorCoroutines.Editor;
using UnityEditor;
using UnityEngine;
using static PlasticPipe.PlasticProtocol.Messages.NegotiationCommand;
using Debug = UnityEngine.Debug;

namespace CommonEditor
{
public class OutProtobuf : EditorWindow
{
[MenuItem("GameEditorTool/输出Protobuf")]
public static void ShowWindow()
{
EditorWindow.GetWindow(typeof(OutProtobuf));
}

[SerializeField]
private string protobufFilePath;
[SerializeField]
private string outputFilePath;
[SerializeField]
private string exePath;

private SerializedObject serObj;
private SerializedProperty protobufPathPro;
private SerializedProperty outputPathPro;
private SerializedProperty exePathPro;

private void OnEnable()
{
serObj = new SerializedObject(this);
protobufPathPro = serObj.FindProperty("protobufFilePath");
outputPathPro = serObj.FindProperty("outputFilePath");
exePathPro = serObj.FindProperty("exePath");
}

private void OnGUI()
{
serObj.Update();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PropertyField(protobufPathPro, new GUIContent("Protobuf文件位置"), true);
if(GUILayout.Button("选择文件"))
{
protobufFilePath = EditorUtility.OpenFilePanel("选择Protobuf文件", Application.dataPath, "proto");
}
EditorGUILayout.EndHorizontal();

EditorGUILayout.BeginHorizontal();
EditorGUILayout.PropertyField(outputPathPro, new GUIContent("Protobuf输出位置"), true);
if (GUILayout.Button("选择输出位置"))
{
outputFilePath = EditorUtility.OpenFolderPanel("选择Protobuf输出位置", Application.dataPath, "");
}
EditorGUILayout.EndHorizontal();

EditorGUILayout.BeginHorizontal();
EditorGUILayout.PropertyField(exePathPro, new GUIContent("Protobuf执行文件位置"), true);
if (GUILayout.Button("选择Protobuf执行文件位置"))
{
exePath = EditorUtility.OpenFilePanel("选择Protobuf的执行文件", Application.dataPath, "exe");
}
EditorGUILayout.EndHorizontal();
serObj.ApplyModifiedProperties();
if (GUILayout.Button("执行"))
{
if (string.IsNullOrEmpty(protobufFilePath) || Path.GetExtension(protobufFilePath) != ".proto")
{
EditorUtility.DisplayDialog("错误", "请选择正确的Protobuf文件", "ok");
return;
}

if (string.IsNullOrEmpty(outputFilePath) || !Directory.Exists(outputFilePath))
{
EditorUtility.DisplayDialog("错误", "请选择正确的Protobuf输出位置", "ok");
return;
}

if (string.IsNullOrEmpty(exePath) || Path.GetExtension(exePath) != ".exe")
{
EditorUtility.DisplayDialog("错误", "请选择正确的Protobuf文件", "ok");
return;
}
EditorCoroutineUtility.StartCoroutine(ExeOutProtobufAsync(), this);
ExeOutProtobufAsync();
}
}

private IEnumerator ExeOutProtobufAsync()
{
string folderPath = protobufFilePath.Substring(0, protobufFilePath.LastIndexOf('/'));
string command = $"{exePath} -I={folderPath} --csharp_out={outputFilePath} {protobufFilePath}";
command = command.Replace('/', '\\');
Debug.Log(command);
yield return new EditorWaitForSeconds(1f);

ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "powershell.exe", // 指定要运行的程序,这里是cmd.exe
UseShellExecute = false, // 不使用操作系统shell启动进程
RedirectStandardInput = true, // 重定向输入
RedirectStandardOutput = true, // 重定向输出
RedirectStandardError = true, // 重定向错误输出
CreateNoWindow = true, // 不创建新窗口
StandardErrorEncoding = Encoding.UTF8,
StandardInputEncoding = Encoding.UTF8,
StandardOutputEncoding = Encoding.UTF8,
};

using (Process process = new Process { StartInfo = startInfo })
{
// 启动进程
process.Start();
// 写入命令到cmd的输入流(如果需要多个命令,可以使用&连接)
process.StandardInput.WriteLine(command);
//process.StandardInput.AutoFlush = true;
//yield return new EditorWaitForSeconds(0.02f);
using (StreamWriter sw = process.StandardInput)
{
if (sw.BaseStream.CanWrite)
{
sw.WriteLine("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8");
sw.WriteLine(command); // 发送命令
sw.WriteLine("exit"); // 发送退出命令以关闭cmd窗口
}
}

// 读取cmd的输出
string output = process.StandardOutput.ReadToEnd();
string error = process.StandardError.ReadToEnd();
if (!string.IsNullOrEmpty(output))
Debug.Log(output); // 在Unity的控制台中打印输出

if (!string.IsNullOrEmpty(error))
Debug.LogError(error);

// 等待命令执行完成(如果需要)
// 注意:对于dir这样的命令,它通常不需要等待,因为它会立即返回
// 但如果你运行的是需要时间的命令,你可能需要等待或使用其他同步机制

// 读取命令的输出
// var outputTask = process.StandardOutput.ReadToEndAsync();
process.WaitForExit();

}

yield return new EditorWaitForSeconds(0.2f);

AssetDatabase.Refresh();
}
}
}

我有同事说不用编辑器协程也是可以的,但是我懒不想再去实验了。上面的代码一定会输出一个错误信息,当出现了错误信息一般就表示这个程序运行完了。但是这个错误信息并不是没有用处,如果你发现你没有正确生成代码,那么你再去看看误信息里面输出了啥。

碎碎念

        这又是一篇写了快一个月的文章,不过这次到不是我偷懒。而是我最近工作真的忙,本来就是在工作中遇到然后想着一边学习一边写文章记录的。我没想到他们压工作时间,压得这么狠。紧迫的项目时间根本没法让我有这样的“闲情雅致”。所以我只能记录一些我想写的东西,然后一点点去补上去。可是时间间隔太久了,有些我有想法写的东西最后也不得了之了。后面我就想懒一下写得差不多就好了,毕竟过了这么久现在也没什么激情想写它了。而且工作上关于Protobuf的知识也够用了,那我就更加没动力了。话说我六月份就更新了2篇技术文,不过我想7月份也没啥技术文好写了。毕竟我最近的确没遇到啥新玩意了。

资源地址

  1. protobuf项目地址: https://github.com/protocolbuffers/protobuf/releases

参考文章

  1. protobuf教程: https://protobuf.dev/getting-started/
  2. protobuf csharp教程: https://protobuf.dev/getting-started/csharptutorial/
  3. 通过一个完整例子彻底学会protobuf序列化原理: https://cloud.tencent.com/developer/article/1520442
  4. 【保姆级】Protobuf详解及入门指南: https://blog.csdn.net/aqin1012/article/details/136628117
  5. Google Protocol Buffers(Protobuf):入门指南、介绍和应用场景: https://blog.csdn.net/hj1993/article/details/130714731
  6. unity 中使用Google Protobuf的使用: https://blog.csdn.net/weixin_43298513/article/details/135462197