文章目录
- 简介
- Protobuf 语法规则
- Proto Editor
- 实现
- 创建窗口
- 定义类、字段
- 增删类
- 编辑字段
- 导入、导出Json文件
- 生成.proto文件
- 生成.bat文件
简介
在Socket
网络编程中,假如使用Protobuf
作为网络通信协议,需要了解Protobuf
语法规则、编写.proto
文件并通过编译指令将.proto
文件转化为.cs
脚本文件,本文介绍如何在Unity中实现一个编辑器工具来使开发人员不再需要关注这些语法规则、编译指令,以及更便捷的编辑和修改.proto
文件内容。工具已上传至SKFramework
框架Package Manager
中:
Protobuf 语法规则
在介绍工具之前先简单介绍protobuf的语法规则,以便更好的理解工具的作用,下面是一个proto文件的示例:
message AvatarProperty
{
required string userId = 1;
required float posX = 2;
required float posY = 3;
required float posZ = 4;
required float rotX = 5;
required float rotY = 6;
required float rotZ = 7;
required float speed = 8;
}
- 类通过
message
来声明,后面是类的命名 - 字段修饰符包含三种类型:
- required : 不可增加或删除的字段,必须初始化
- optional : 可选字段,可删除,可以不初始化
- repeated : 可重复字段(对应C#里面的List)
- 与C#的字段类型对应关系如下,查阅自官网
.proto Type | C# Type | Notes |
---|---|---|
double | double | |
float | float | |
int32 | int | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint32 instead. |
int64 | long | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint64 instead. |
uint32 | uint | Uses variable-length encoding. |
uint64 | ulong | Uses variable-length encoding. |
sint32 | int | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int32s. |
sint64 | long | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int64s. |
fixed32 | uint | Always four bytes. More efficient than uint32 if values are often greater than 228. |
fixed64 | ulong | Always eight bytes. More efficient than uint64 if values are often greater than 256. |
sfixed32 | int | Always four bytes. |
sfixed64 | long | Always eight bytes. |
bool | bool | |
string | string | A string must always contain UTF-8 encoded or 7-bit ASCII text, and cannot be longer than 232. |
bytes | ByteString | May contain any arbitrary sequence of bytes no longer than 232. |
- 标识号:示例中的1-8表示每个字段的标识号,并不是赋值。
每个字段都有唯一的标识号,这些标识符是用来在消息的二进制格式中识别各个字段的。[1,15]之内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为那些频繁出现的消息元素保留[1,15]之内的标识号。注:要为将来有可能添加的、频繁出现的标识号预留一些标识号,不可以使用其中的[19000-19999]标识号,Protobuf协议实现中对这些进行了预留。
Proto Editor
如图所示,工具包含以下功能:
New、Clear Message
:增加、删除message
类;
- 增加、删除、编辑
fields
字段(修饰符、类型、命名、分配标识号);
Import、Export Json File
:导入、导出json
文件(假如要修改一个已有的通信协议类,导入之前导出的Json文件再次编辑即可);
Generate Proto File
:生成.proto
文件;Create .bat
:生成.bat文件
(不再需要手动编辑编译指令)。
实现
创建窗口
- 继承
Editor Window
编辑器窗口类; Menu Item
添加打开窗口的菜单;
public class ProtoEditor : EditorWindow
{
[MenuItem("Multiplayer/Proto Editor")]
public static void Open()
{
GetWindow<ProtoEditor>("Proto Editor").Show();
}
}
定义类、字段
/// <summary>
/// 类
/// </summary>
public class Message
{
/// <summary>
/// 类名
/// </summary>
public string name = "New Message";
/// <summary>
/// 所有字段
/// </summary>
public List<Fields> fieldsList = new List<Fields>(0);
}
/// <summary>
/// 字段
/// </summary>
public class Fields
{
public ModifierType modifier;
public FieldsType type;
public string typeName;
public string name;
public int flag;
}
Modifer Type
:修饰符类型
/// <summary>
/// 修饰符类型
/// </summary>
public enum ModifierType
{
/// <summary>
/// 必需字段
/// </summary>
Required,
/// <summary>
/// 可选字段
/// </summary>
Optional,
/// <summary>
/// 可重复字段
/// </summary>
Repeated
}
Fields Type
:字段类型
这里只定义了我常用的几种类型,Custom用于自定义类型:
/// <summary>
/// 字段类型
/// </summary>
public enum FieldsType
{
Double,
Float,
Int,
Long,
Bool,
String,
Custom,
}
增删类
- 声明一个列表存储所有类
//存储所有类
private List<Message> messages = new List<Message>();
- 声明一个字典用于存储折叠栏状态(每个类可折叠查看)
//字段存储折叠状态
private readonly Dictionary<Message, bool> foldoutDic = new Dictionary<Message, bool>();
- 插入、删除
//滚动视图
scroll = GUILayout.BeginScrollView(scroll);
for (int i = 0; i < messages.Count; i++)
{
var message = messages[i];
GUILayout.BeginHorizontal();
foldoutDic[message] = EditorGUILayout.Foldout(foldoutDic[message], message.name, true);
//插入新类
if (GUILayout.Button("+", GUILayout.Width(20f)))
{
Message insertMessage = new Message();
messages.Insert(i + 1, insertMessage);
foldoutDic.Add(insertMessage, true);
Repaint();
return;
}
//删除该类
if (GUILayout.Button("-", GUILayout.Width(20f)))
{
messages.Remove(message);
foldoutDic.Remove(message);
Repaint();
return;
}
GUILayout.EndHorizontal();
}
GUILayout.EndScrollView();
- 底部新增、清空菜单:
GUILayout.BeginHorizontal();
//创建新的类
if (GUILayout.Button("New Message"))
{
Message message = new Message();
messages.Add(message);
foldoutDic.Add(message, true);
}
//清空所有类
if (GUILayout.Button("Clear Messages"))
{
//确认弹窗
if (EditorUtility.DisplayDialog("Confirm", "是否确认清空所有类型?", "确认", "取消"))
{
//清空
messages.Clear();
foldoutDic.Clear();
//重新绘制
Repaint();
}
}
GUILayout.EndHorizontal();
编辑字段
- 折叠栏为打开状态时,绘制该类具体的字段:
//如果折叠栏为打开状态 绘制具体字段内容
if (foldoutDic[message])
{
//编辑类名
message.name = EditorGUILayout.TextField("Name", message.name);
//字段数量为0 提供按钮创建
if (message.fieldsList.Count == 0)
{
if (GUILayout.Button("New Field"))
{
message.fieldsList.Add(new Fields(1));
}
}
else
{
for (int j = 0; j < message.fieldsList.Count; j++)
{
var item = message.fieldsList[j];
GUILayout.BeginHorizontal();
//修饰符类型
item.modifier = (ModifierType)EditorGUILayout.EnumPopup(item.modifier);
//字段类型
item.type = (FieldsType)EditorGUILayout.EnumPopup(item.type);
if (item.type == FieldsType.Custom)
{
item.typeName = GUILayout.TextField(item.typeName);
}
//编辑字段名
item.name = EditorGUILayout.TextField(item.name);
GUILayout.Label("=", GUILayout.Width(15f));
//分配标识号
item.flag = EditorGUILayout.IntField(item.flag, GUILayout.Width(50f));
//插入新字段
if (GUILayout.Button("+", GUILayout.Width(20f)))
{
message.fieldsList.Insert(j + 1, new Fields(message.fieldsList.Count + 1));
Repaint();
return;
}
//删除该字段
if (GUILayout.Button("-", GUILayout.Width(20f)))
{
message.fieldsList.Remove(item);
Repaint();
return;
}
GUILayout.EndHorizontal();
}
}
}
导入、导出Json文件
- 导出Json文件以及生成Proto文件之前都需要判断当前的编辑是否有效,从以下几个方面判断:
proto file name
:文件名编辑是否输入为空;message name
:类名编辑是否输入为空;- 自定义字段类型时,是否输入为空;
- 标识号是否唯一 。
为Message、Fields类添加有效性判断函数:
/// <summary>
/// 类
/// </summary>
public class Message
{
/// <summary>
/// 类名
/// </summary>
public string name = "New Message";
/// <summary>
/// 所有字段
/// </summary>
public List<Fields> fieldsList = new List<Fields>(0);
public bool IsValid()
{
bool flag = !string.IsNullOrEmpty(name);
for (int i = 0; i < fieldsList.Count; i++)
{
flag &= fieldsList[i].IsValid();
if (!flag) return false;
for (int j = 0; j < fieldsList.Count; j++)
{
if (i != j)
{
flag &= fieldsList[i].flag != fieldsList[j].flag;
}
if (!flag) return false;
}
}
return flag;
}
}
/// <summary>
/// 字段
/// </summary>
public class Fields
{
public ModifierType modifier;
public FieldsType type;
public string typeName;
public string name;
public int flag;
public Fields() { }
public Fields(int flag)
{
modifier = ModifierType.Required;
type = FieldsType.String;
name = "FieldsName";
typeName = "FieldsType";
this.flag = flag;
}
public bool IsValid()
{
return type != FieldsType.Custom || (type == FieldsType.Custom && !string.IsNullOrEmpty(typeName));
}
}
- 最终编辑有效性判断:
//编辑的内容是否有效
private bool ContentIsValid()
{
bool flag = !string.IsNullOrEmpty(fileName);
flag &= messages.Count > 0;
for (int i = 0; i < messages.Count; i++)
{
flag &= messages[i].IsValid();
if (!flag) break;
}
return flag;
}
- 导入、导出Json:
GUILayout.BeginHorizontal();
//导出Json
if (GUILayout.Button("Export Json File"))
{
if (!ContentIsValid())
{
EditorUtility.DisplayDialog("Error", "请按以下内容逐项检查:\r\n1.proto File Name是否为空\r\n2.message类名是否为空\r\n" +
"3.字段类型为自定义时 是否填写了类型名称\r\n4.标识号是否唯一", "ok");
}
else
{
//文件夹路径
string dirPath = Application.dataPath + workspacePath;
//文件夹不存在则创建
if (!Directory.Exists(dirPath))
Directory.CreateDirectory(dirPath);
//json文件路径
string filePath = dirPath + "/" + fileName + ".json";
if (EditorUtility.DisplayDialog("Confirm", "是否保存当前编辑内容到" + filePath, "确认", "取消"))
{
//序列化
string json = JsonMapper.ToJson(messages);
//写入
File.WriteAllText(filePath, json);
//刷新
AssetDatabase.Refresh();
}
}
}
//导入Json
if (GUILayout.Button("Import Json File"))
{
//选择json文件路径
string filePath = EditorUtility.OpenFilePanel("Import Json File", Application.dataPath + workspacePath, "json");
//判断路径有效性
if (File.Exists(filePath))
{
//读取json内容
string json = File.ReadAllText(filePath);
//清空
messages.Clear();
foldoutDic.Clear();
//反序列化
messages = JsonMapper.ToObject<List<Message>>(json);
//填充字典
for (int i = 0; i < messages.Count; i++)
{
foldoutDic.Add(messages[i], true);
}
//文件名称
FileInfo fileInfo = new FileInfo(filePath);
fileName = fileInfo.Name.Replace(".json", "");
//重新绘制
Repaint();
return;
}
}
GUILayout.EndHorizontal();
生成.proto文件
主要是字符串拼接工作:
//生成proto文件
if (GUILayout.Button("Generate Proto File"))
{
if (!ContentIsValid())
{
EditorUtility.DisplayDialog("Error", "请按以下内容逐项检查:\r\n1.proto File Name是否为空\r\n2.message类名是否为空\r\n" +
"3.字段类型为自定义时 是否填写了类型名称\r\n4.标识号是否唯一", "ok");
}
else
{
string protoFilePath = EditorUtility.SaveFilePanel("Generate Proto File", Application.dataPath, fileName, "proto");
if (!string.IsNullOrEmpty(protoFilePath))
{
StringBuilder protoContent = new StringBuilder();
for (int i = 0; i < messages.Count; i++)
{
var message = messages[i];
StringBuilder sb = new StringBuilder();
sb.Append("message " + message.name + "\r\n" + "{\r\n");
for (int n = 0; n < message.fieldsList.Count; n++)
{
var field = message.fieldsList[n];
//缩进
sb.Append(" ");
//修饰符
sb.Append(field.modifier.ToString().ToLower());
//空格
sb.Append(" ");
//如果是自定义类型 拼接typeName
switch (field.type)
{
case FieldsType.Int: sb.Append("int32"); break;
case FieldsType.Long: sb.Append("int64"); break;
case FieldsType.Custom: sb.Append(field.typeName); break;
default: sb.Append(field.type.ToString().ToLower()); break;
}
//空格
sb.Append(" ");
//字段名
sb.Append(field.name);
//等号
sb.Append(" = ");
//标识号
sb.Append(field.flag);
//分号及换行符
sb.Append(";\r\n");
}
sb.Append("}\r\n");
protoContent.Append(sb.ToString());
}
//写入文件
File.WriteAllText(protoFilePath, protoContent.ToString());
//刷新(假设路径在工程内 可以避免手动刷新才看到)
AssetDatabase.Refresh();
//打开该文件夹
FileInfo fileInfo = new FileInfo(protoFilePath);
Process.Start(fileInfo.Directory.FullName);
}
}
}
生成.bat文件
- 使用
OpenFolderPanel
打开protogen.exe
文件所在的文件夹,.bat
文件需要生成在该文件夹下:
- 获取proto文件夹下的所有
.proto
文件的名称,拼接编译指令:
//创建.bat文件
if (GUILayout.Button("Create .bat"))
{
//选择路径(protogen.exe所在的文件夹路径)
string rootPath = EditorUtility.OpenFolderPanel("Create .bat file(protogen.exe所在的文件夹)", Application.dataPath, string.Empty);
//取消
if (string.IsNullOrEmpty(rootPath)) return;
//protogen.exe文件路径
string protogenPath = rootPath + "/protogen.exe";
//不是protogen.exe所在的文件夹路径
if (!File.Exists(protogenPath))
{
EditorUtility.DisplayDialog("Error", "请选择protogen.exe所在的文件夹路径", "ok");
}
else
{
string protoPath = rootPath + "/proto";
DirectoryInfo di = new DirectoryInfo(protoPath);
//获取所有.proto文件信息
FileInfo[] protos = di.GetFiles("*.proto");
//使用StringBuilder拼接字符串
StringBuilder sb = new StringBuilder();
//遍历
for (int i = 0; i < protos.Length; i++)
{
string proto = protos[i].Name;
//拼接编译指令
sb.Append(rootPath + @"/protogen.exe -i:proto\" + proto + @" -o:cs\" + proto.Replace(".proto", ".cs") + "\r\n");
}
sb.Append("pause");
//生成".bat文件"
string batPath = $"{rootPath}/run.bat";
File.WriteAllText(batPath, sb.ToString());
//打开该文件夹
Process.Start(rootPath);
}
}
最终运行.bat
文件,就可以将.proto
文件转化为.cs
脚本文件: