不必再写proto文件——ProtoUtils详解

Protobuf不易用

关于Protobuf协议的介绍可以参看我之前的日志一篇搞定Java序列化 中的protobuf篇
相对于其他数据格式如json、xml, protobuf有着数据量小,解析快的显著优势
但是这意味着开发者需要写proto文件,需要了解proto message的语法,并且目前IDE还不支持proto的智能提示,十分不便
基于此,我开发了一个ProtoUtils工程
(先放上GitHub地址

ProtoUtils的用途

ProtoUtils是一个Java工程,集成了写proto文件、proto转化为java类的功能
开发者不需要关注proto的写法,而是直接参照json的方式写java数据类,拷贝到这个工程里
然后配置好输出参数,运行后可以直接获得对接proto协议的java类,再拷贝回你希望使用的工程里

ProtoUtils的用法

  1. 把想要转化的java原生bean拷贝到这个工程
  2. 配置ProtoUtils.main方法
1
2
3
4
5
6
7
8
public static void main(String[] args) throws IOException {
String outerName = "ProtoBean"; //输出的类名
String packageName = "com.efan.proto"; //输出的包名
genProtoFile(packageName, outerName, ArrayProto.class, TestProto.class); //传入需要转化的java类,可以多个
String outputPath = packageName.replace(".", File.separator);
squareCompile(outputPath, outerName); //Square方式,方法数和代码量是Google的1/5
// googleCompile(outputPath, outerName); //Google方式
}
  1. 右键Run这个方法,会弹出cmd
  2. 转化成功按任意键会弹出最终java类所在的文件夹
  3. 将java类拷贝到你希望使用的工程

ProtoUtils的具体实现原理

大致思路是,递归地遍历java数据类,通过反射取得java数据类需要转化的变量名、变量类型以及其他信息
为不同的变量类型指定对应的写proto代码的方法,把proto代码输出到文本,再启动cmd命令,调用对应的jar包转化proto代码为java类
Talk is cheap,直接上code

递归遍历java数据类,通过反射获取变量信息

非枚举类将会写成一个message结构
tab方法是控制代码缩进的
static和transient变量是根据proto协议过滤掉的
而java语言特性使得内部类自动持有外部类的引用,这个外部类引用也要过滤掉

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
/**
* 非枚举类的转化
* @param beanClass 类
* @param step 第几层嵌套
*/
private static String cls2Proto(Class<?> beanClass, int step){
StringBuilder sb = new StringBuilder();
tab(sb, step);
//写message结构
sb.append("message ").append(beanClass.getSimpleName()).append(" {\n\n");
//遍历内部类
Class<?>[] classes = beanClass.getDeclaredClasses();
for (Class<?> cls: classes) {
//区分是枚举还是普通类
String string = cls.isEnum() ? enum2Proto(cls, step + 1)
: cls2Proto(cls, step + 1);
sb.append(string);
}
//遍历变量
Field[] fields = beanClass.getDeclaredFields();
TagRecorder tagRecorder = new TagRecorder();
for (Field field : fields){
//过滤static变量、transient变量、外部类引用
if (!isTransientOrStatic(field)
&& !isEnclosing(field, beanClass)){
String str = field2Proto(field, tagRecorder);
tab(sb, step + 1);
sb.append(str).append("\n");
}
}
tab(sb, step);
sb.append("}\n\n");
return sb.toString();
}

变量的转化

列表和数组类型用repeated修饰,普通变量用required或optional修饰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 变量转化
* @param field 变量
* @param tagRecorder 标记记录器
*/
private static String field2Proto(Field field, TagRecorder tagRecorder){
Class<?> cls = field.getType();
StringBuilder sb = new StringBuilder();
if (List.class.isAssignableFrom(cls)){
sb.append("repeated ").append(list2Proto(field));
} else if (isArray(cls)){
sb.append("repeated ").append(array2Proto(field));
} else {
sb.append(checkRequired(field)).append(getTypeString(cls));
}
int tagNumber = tagRecorder.tag(getTagNumber(field));
sb.append(" ").append(field.getName()).append(" = ").append(tagNumber).append(";\n");
return sb.toString();
}

然后是判断变量的类型,基本类型各有对应的proto数据类型,注意byte数组有对应的bytes类型
非基本类型直接取类名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private static String getTypeString(Class<?> cls){
if (cls.isAssignableFrom(Double.class)
|| cls.isAssignableFrom(double.class)) {
return "double";
} else if (cls.isAssignableFrom(Float.class)
|| cls.isAssignableFrom(float.class)) {
return "float";
} else if (cls.isAssignableFrom(Long.class)
|| cls.isAssignableFrom(long.class)) {
return "int64";
} else if (cls.isAssignableFrom(Integer.class)
|| cls.isAssignableFrom(int.class)) {
return "int32";
} else if (cls.isAssignableFrom(Boolean.class)
|| cls.isAssignableFrom(boolean.class)) {
return "bool";
} else if (cls.isAssignableFrom(String.class)) {
return "string";
} else if (cls.isAssignableFrom(byte[].class)) {
return "bytes";
} else {
return cls.getSimpleName();
}
}

proto数据代码有一个tag值的概念,是用来在二进制编码里唯一确认当前域的
可以由开发者自定义,默认情况变量从1开始增加,枚举从0开始增加
TagRecorder内部保存一个map,tag方法用于检索可用的tag值

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
private static class TagRecorder {
private int offset = 1; //初始偏移
private SparseArrayCompat<Boolean> record = new SparseArrayCompat<>();
/**
* 设置标记值
* @return 最终的标记值
*/
int tag(int num) {
if (num <= 0) {
// 传非正数表示自动从最小开始寻找空位
// 偏移直到没有被占用为止
while (record.get(offset, false) && offset < Integer.MAX_VALUE) {
offset++;
}
if (offset == Integer.MAX_VALUE) {
throw new IllegalStateException("tag reaches MAX Integer");
}
record.put(offset, true);
return offset;
} else if (record.get(num, false)) {
// 传正数则直接使用
throw new IllegalStateException("tag is duplicated");
} else {
record.put(num, true);
return num;
}
}
}

枚举类型的转化

java枚举类的变量当中有枚举常量数组,需要过滤掉

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
/**
* 枚举类的转化
* @param beanClass 枚举类
* @param step 第几层嵌套
*/
private static String enum2Proto(Class<?> beanClass, int step){
StringBuilder sb = new StringBuilder();
tab(sb, step);
//写 enum 结构
sb.append("enum ").append(beanClass.getSimpleName()).append(" {\n");
Field[] fields = beanClass.getDeclaredFields();
int tagNum = 0;
//遍历枚举,值从0开始
for (Field field : fields){
if (field.isEnumConstant()){ //排除value数组
tab(sb, step + 1);
//MON = 0;\n
sb.append(field.getName()).append(" = ").append(tagNum).append(";\n");
tagNum ++;
}
}
tab(sb, step);
sb.append("}\n\n");
return sb.toString();
}

生成proto文件

head方法是编写proto的头部属性
convert方法调用的就是前面的cls2Proto

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 生成Proto文件
* @param outerName 输出文件名
* @param classes 需要转化的java类
*/
private static void genProtoFile(String packageName, String outerName, Class<?>... classes) {
StringBuilder sb = new StringBuilder();
sb.append(Java2Proto.head(packageName, outerName));
for (Class<?> clazz : classes){
sb.append(Java2Proto.convert(clazz));
}
IOUtils.write(DIR_WORKSPACE + outerName + ".proto", sb.toString());
}

执行cmd转化

最后是通过cmd调用谷歌转化工具或者Square Wire工具把proto文件转化为Java类
执行cmd命令是把命令数组写成bat文件,然后调用Java的Runtime执行bat

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
/**
* 采用Square工具转Java
* @throws IOException
*/
private static void squareCompile(String outputPath, String outerName) throws IOException {
//square
runCmd("cd ./proto",
"java -jar -Dfile.encoding=UTF-8 wire-compiler-2.2.0-jar-with-dependencies.jar "
+ "--proto_path=. --java_out=./ "+ outerName + ".proto",
"pause",
"start" + "." + File.separator + outputPath,
"exit");
}
/**
* 采用谷歌工具转java
* @throws IOException
*/
private static void googleCompile(String outputPath, String outerName) throws IOException {
//google
runCmd("cd ./proto",
"protoc-java.exe --java_out=./ " + outerName + ".proto",
"pause",
"start" + "." + File.separator + outputPath,
"exit");
}
/**
* 多条命令生成bat并执行
* @param commands 命令数组
* @throws IOException
*/
private static void runCmd(String... commands) throws IOException {
File file = new File(DIR_WORKSPACE + "command.bat");
BufferedWriter writer = new BufferedWriter(new FileWriter(file));
for (String command : commands) {
writer.write(command);
writer.newLine();
}
writer.close();
Runtime.getRuntime().exec("cmd /c start " + file.getAbsolutePath());
}

结语

回头看ProtoUtils把写proto文件的流程自动化了,
开发者只需要像写json一样写好java数据类,然后跑一次这个工程,就能得到支持proto协议的java类
实际使用时我推荐Square的方式,因为谷歌的方式生成了太多太多太多(真的多所以说三遍)不必要的变量和方法,
而Android开发对于方法数量非常敏感,一不留神就会触发65535上限。
此外,目前我写的这个工程

  • 只支持基本的proto2
  • 不支持二维数组,不支持Map,
  • 不支持char和char数组
  • 不支持import x.proto
  • 不支持default(与proto3一致)
  • 枚举值一定从0开始(与proto3一致)
    乍一看很多功能缺失,实际上平时应用写绝大部分是普通数据类,完全够用了
    (其实是因为这些特性开发起来有点麻烦,等到确实需要的时候再动吧哈哈哈)

最后

放出这个工程的GitHub地址
欢迎大家讨论和优化代码!