关于ProtoBuf枚举向前兼容问题解决方案

背景

proto来定义和后台通信的数据模型,并且很多地方使用到了proto的枚举(enum),但是这个枚举的向前兼容性不太好。例如
消息类型定义为:

message CCCBean{
  BEnum b = 1;
}

低版本里面的枚举只有

enum BEnum {
  a = 0;  b = 1;
}

但是随着业务发展高版本新增了c=2 和 d=3 的类型

enum BEnum {
  a = 0;  b = 1; c = 2;d = 3;
}

这个时候后台下发的数据里面带有BEnum = 3的情况的时候,就拿不到正确的枚举类型,切使用以下方式会抛出异常

例如我们模拟后台返回,枚举为3

val json = "{\"b\":3}"
val newJsonBuilder = TestPbMutile.CCCBean.newBuilder()
JsonFormat.parser().merge(json, newJsonBuilder)
val jsonPb = newJsonBuilder.build()
val value = jsonPb.bValue // 这种方式不会报错
val b = jsonPb.b
val enumB = jsonPb.b.number // 这一行会报错
image.png

总结以上分别两种取值方式
1、

jsonPb.bValue

2、

jsonPb.b.number
第一种不会报错,第二种会抛出异常

由此可以思考出以下解决方案,不调用 proto生成的java类的getEnum().number 方法

其中有思考方案如下

1.添加注解,标注废弃,在项目组内同步宣讲,避免大家踩坑

2.移除getEnum()方法

2.将getEnum()方法返回值从Enum类型改为int类型

经过思考得出

方案1在多人协同开发中没办法完全避免,总会有其他同学踩坑

方案2会让pb.toString()的时候会抛出异常 ,如下

image.png

最终实现方案3,将getEnum返回类型改为int

接下来方案3是实现流程

image.png

这里是jar脚本大致逻辑

image.png

介绍一下jar脚本中使用的框架

1、JavaParser 可以解析java文件,可以获取其中的类,方法,成员变量等等,也可以方便的添加删除修改代码,并且方便的覆盖掉原文件

依赖

implementation 'com.github.javaparser:javaparser-core:3.23.1'

所用主要功能

a、解析java文件内所有class,并输出名字
// ...
Path path = Paths.get(pbFileName);            
CompilationUnit outCu = StaticJavaParser.parse(path);
List<String> allClassname = ClassNameExtractor.extractFullClassNames(outCu);
// ...

public static List<String> extractFullClassNames(CompilationUnit cu) throws IOException, ParseException {

        List<String> fullClassNames = new ArrayList<>();
        VoidVisitorAdapter<List<String>> classNameCollector = new VoidVisitorAdapter<List<String>>() {
            @Override
            public void visit(ClassOrInterfaceDeclaration n, List<String> arg) {
                super.visit(n, arg);
                arg.add(getFullClassName(n));
            }

            @Override
            public void visit(EnumDeclaration n, List<String> arg) {
                super.visit(n, arg);
                arg.add(getFullClassName(n));

            }

            private String getFullClassName(TypeDeclaration<?> n) {
                String packageName = cu.getPackageDeclaration()
                        .map(PackageDeclaration::getNameAsString)
                        .orElse("");
                String oriClassName = n.getFullyQualifiedName()
                        .orElse(n.getNameAsString());
                oriClassName = oriClassName.replace(".", "$");
                if (!packageName.isEmpty()) {
                    oriClassName = packageName + "." + oriClassName.substring(packageName.length() + 1);
                }
                return oriClassName;
            }
        };

        classNameCollector.visit(cu, fullClassNames);

        return fullClassNames;
    }
b、遍历类里面的method,找出返回值为枚举的getXXXEnum方法。将其返回值设置为int,注意这里要注意oneOf关键字也会生成Enum需要被过滤掉,看源码发现oneOf生成的枚举实现于com.google.protobuf.Internal.EnumLite,而 filed为枚举生成的枚举实现于com.google.protobuf.ProtocolMessageEnum,可以用于区别
for (Method method : clazz.getMethods()) {
    // 将返回值是枚举类型的方法并且是getXXXEnum方法 改变返回值为int
    if (method.getReturnType().isEnum()&&method.getName().startsWith("get")) {
      // ...
      // 注意,这里过滤oneOf的情况
      boolean needJump = false;
      for (Class<?> anInterface : method.getReturnType().getInterfaces()) {
        if(enumLiteClass.getName().equals(anInterface.getName())){
          needJump = true;
          break;
        }
      }
      if (needJump){
        continue;
      }
      // 标记这个方法,需要被修改 !!!!
      //  ... 
    }
}
c、对method修改,并且覆盖写入原文件
for (MethodDeclaration method : classOrInterface.getMethods()) {
  // ...
  // 匹配到需要被修改的方法
  // 将返回枚举的方法。变成返回int                     
  method.setType(int.class);
  // 修改其方法体内容
  method.removeBody();
  Statement statement = StaticJavaParser.parseStatement("return " + method.getNameAsString() + "Value();");
  BlockStmt blockStmt = new BlockStmt();
  blockStmt.addStatement(statement);
  method.setBody(blockStmt);
  // 处理注释 和 添加注释
  String oldCommentStr = "";
  Comment oldComment = method.getComment().orElse(null);
  if (oldComment != null) {
    oldCommentStr = oldComment.toString()
      .replaceAll("/\\*\\*", "")
      .replaceAll("/\\*", "")
      .replaceAll("\\*", "")
      .replaceAll("/", "")
      .replaceAll("\\n", "")
      .replaceAll("\\*/", "");
  }
  method.setBlockComment("* \n\t\t* " + oldCommentStr + " \n\t\t* old return type is " + oldReturnType + " now change return type to int \n\t\t");
  // ...
}

// 覆盖写入文件
Files.write(path, outCu.toString().getBytes());

2、Compiler可以将java代码编译成class文件。可以让其被classLoader所解析。然后就可以通过class相关api获取类描述信息

依赖
implementation 'org.codehaus.groovy:groovy-eclipse-compiler:3.6.0-03'

主要功能

a、将java文件编译为class文件,并输出到对应目录
// ...
DynamicCompiler.compile(pbFileName, compilerPath);
// ...

    public static void compile(String sourceFilePath, String outputDirectoryPath) {
        File sourceFile = new File(sourceFilePath);
        File outputDirectory = new File(outputDirectoryPath);

        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);

        try {
            fileManager.setLocation(StandardLocation.CLASS_OUTPUT, Arrays.asList(outputDirectory));
        } catch (IOException e) {
            e.printStackTrace();
        }

        Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjectsFromFiles(Arrays.asList(sourceFile));
        JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, null, null, compilationUnits);

        task.call();

        try {
            fileManager.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

接下来对比执行了jar脚本之后pb生成的java类,生成类代码太多了,此处就不贴代码了,只罗列出修改点

注意,一个pb的message生成类会生成三个java类,接下来我举例说明

pb文件名为testPb.proto,内容为

message TestBean{
  int64 id = 1; 
  Sex sex = 2; 
}
enum Sex {
  man = 0;
  female = 1;
}

以下为Java伪代码,主要是解释生成类的关系

public final class TestPb {
    public interface TestBeanOrBuilder extends 
    com.google.protobuf.MessageOrBuilder {
      // <code>int64 id = 1;</code>
      long getId();
      // <code>.com.pb.test.Sex sex = 4;</code>
      int getSexValue();
      // <code>.com.pb.test.Sex sex = 4;</code>
      Sex getSex();
    }
    public  static final class TestBean extends
      com.google.protobuf.GeneratedMessageV3 implements
      TestBeanOrBuilder {
      // ...
       public static final class Builder extends
        com.google.protobuf.GeneratedMessageV3.Builder<Builder> implements
        com.pb.test.TestPb.TestBeanOrBuilder {
         // ...
         // ...
       }
      // ... 
    }
}

最外层类名为pb文件名,其中一个message就对应一个TestBeanOrBuilder接口 和一个 Bean类,且这个Bean类里面有一个内部类Builder类

以下三个类分别简称为 数据接口、Bean类,BeanBuilder类

1、原类会返回带枚举的值,新类返回值会变成int (包括 数据接口、Bean类,BeanBuilder类)

原类

/** 
* <code>.com.pb.test.Sex sex = 4;</code>
*/
public com.pb.test.TestPb.Sex getSex() {
  com.pb.test.TestPb.Sex result = com.pb.test.TestPb.Sex.valueOf(sex_);
  return result == null ? com.pb.test.TestPb.Sex.UNRECOGNIZED : result;
}

修改后的

//  <code>.com.pb.test.Sex sex = 4;<code>  
// old return type is com.pb.test.TestPb.Sex now change return type to int 
public int getSex() {
  return sex_;
}

2、BeanBuilder类添加 setEnum方法

原类

public  static final class TestBean extends
  com.google.protobuf.GeneratedMessageV3 implements
  TestBeanOrBuilder {
  // <code>.com.pb.test.Sex sex = 4;</code>
  public Builder setSexValue(int value) {
    sex_ = value;
    onChanged();
    return this;
  }
  // <code>.com.pb.test.Sex sex = 4;</code>
  public Builder setSex(com.pb.test.TestPb.Sex value) {
    if (value == null) {
      throw new NullPointerException();
    }
    sex_ = value.getNumber();
    onChanged();
    return this;
  }
    
}

修改类

public  static final class TestBean extends
  com.google.protobuf.GeneratedMessageV3 implements
  TestBeanOrBuilder {
  // <code>.com.pb.test.Sex sex = 4;</code>
  public Builder setSexValue(int value) {
    sex_ = value;
    onChanged();
    return this;
  }
  // <code>.com.pb.test.Sex sex = 4;</code>
  public Builder setSex(com.pb.test.TestPb.Sex value) {
    if (value == null) {
      throw new NullPointerException();
    }
    sex_ = value.getNumber();
    onChanged();
    return this;
  }
  // 新增方法    
  /* 为了兼容 将getEnum方法返回的enum的类型 改成了int,所以需要在build方法中添加对应的set方法*/
  public Builder setSex(int value) {
    sex_ = value;
    onChanged();
    return this;
  }
}

3、在静态代码块中 遍历所有filde找到类型为Enum的所有filde,他他们的类型改为int,并且设置defalutValue为0

static{
  //...
  //这一句就是初始化 getDescriptor()
  Descriptors.FileDescriptor.internalBuildGeneratedFileFrom(descriptorData, new Descriptors.FileDescriptor[] {}, assigner);

  //...
}

修改后的类

static{
  //...
  //这一句就是初始化 getDescriptor()
  Descriptors.FileDescriptor.internalBuildGeneratedFileFrom(descriptorData, new Descriptors.FileDescriptor[] {}, assigner);
  for (Descriptors.Descriptor messageType : getDescriptor().getMessageTypes()) {
    for (Descriptors.FieldDescriptor field : messageType.getFields()) {
      if (field.getType() == Descriptors.FieldDescriptor.Type.ENUM) {
        Class fieldClass = field.getClass();
        try {
          Field typeField = fieldClass.getDeclaredField("type");
          typeField.setAccessible(true);
          typeField.set(field, Descriptors.FieldDescriptor.Type.INT32);
          Field defaultValueField = fieldClass.getDeclaredField("defaultValue");
          defaultValueField.setAccessible(true);
          defaultValueField.set(field, 0);
        } catch (NoSuchFieldException e) {
          e.printStackTrace();
        } catch (IllegalAccessException e) {
          e.printStackTrace();
        }
      }
    }
  }
  //...
}

被修改的生成pb类,在运行过程中的表现

测试Pb类

message PbA{
  int64 id = 1;
  string name = 2;
  repeated string job = 3;
  Sex sex = 4;
  Sex sex_b_type = 5;
  Sex sex_c_type = 6;
  TypeA type = 7;

  enum TypeA {
    type1 = 0;
    type2 = 1;
    type3 = 2;
  }
}

enum Sex {
  man = 0;
  female = 1;
}

1、pb使用builder初始化和toString()方法,以及取值

image.png

2、pb和bytes相互转换

image.png

3、pb和json相互转换

image.png

可以看出来,修改之后的pb生成类,也可以满足日常开发中的业务功能,但是由于PB库很多使用了反射机制来访问。所以不排除有一些极少出情况的坑点。如果碰到了欢迎大家留言。

另外如果大家想要看源码可以去github获取源码我是传送门
主要逻辑都在proto和protoCusEnumCompiler两个模块里面
protoCusEnumCompiler负责生成修改pb生成类的jar逻辑
proto 则是存放pb文件和生成pb文件

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,039评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,223评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,916评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,009评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,030评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,011评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,934评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,754评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,202评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,433评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,590评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,321评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,917评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,568评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,738评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,583评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,482评论 2 352

推荐阅读更多精彩内容