Protobuf与Json的相互转化

45次阅读

共计 11224 个字符,预计需要花费 29 分钟才能阅读完成。

前言

最近的工作中开始使用 Google 的 Protobuf 构建 REST API,按照现在使用的感觉,除了应为 Protobuf 的特性,接口被严格确定下来之外,暂时还么有感受到其他特别的好处。说是 Protobuf 比 Json 的序列化更小更快,但按照目前的需求,估计很就都没有还不会有这个性能的需要。既然是全新的技术,我非常地乐意学习。

在 MVC 的代码架构中,Protbuf 是 Controller 层用到的技术,为了能够将每个层进行划分,使得 Service 层的实现不依赖于 Protobuf,需要将 Protobuf 的实体类,这里称之为 ProtoBean 吧,转化为 POJO。在实现的过程中,有涉及到了 Protobuf 转 Json 的实现,因为有了这篇文章。而 ProtoBean 转 POJO 的讲解我会在另一篇,或者是几篇文章中进行讲解,因为会比较复杂。

这篇文章已经放了很久很久了,一直希望去看两个 JsonFormat 的实现。想看完了再写的,但还是先写出来吧,拖着挺累的。

为了读者可以顺畅地阅读,文章中涉及到地链接都会在最后给出,而不会在行文中间给出。

测试使用的 Protobuf 文件如下:

syntax = "proto3";

import "google/protobuf/any.proto";

option java_package = "io.gitlab.donespeak.javatool.toolprotobuf.proto";
package data.proto;

message OnlyInt32 {int32 int_val = 1;}

message BaseData {
    double double_val = 1;
    float float_val = 2;
    int32 int32_val = 3;
    int64 int64_val = 4;
    uint32 uint32_val = 5;
    uint64 uint64_val = 6;
    sint32 sint32_val = 7;
    sint64 sint64_val = 8;
    fixed32 fixed32_val = 9;
    fixed64 fixed64_val = 10;
    sfixed32 sfixed32_val = 11;
    sfixed64 sfixed64_val = 12;
    bool bool_val = 13;
    string string_val = 14;
    bytes bytes_val = 15;

    repeated string re_str_val = 17;
    map<string, BaseData> map_val = 18;
}

message DataWithAny {
    double double_val = 1;
    float float_val = 2;
    int32 int32_val = 3;
    int64 int64_val = 4;
    bool bool_val = 13;
    string string_val = 14;
    bytes bytes_val = 15;

    repeated string re_str_val = 17;
    map<string, BaseData> map_val = 18;

    google.protobuf.Any anyVal = 102;
}

可选择的工具

可以将 ProtoBean 转化为 Json 的工具有两个,一个是 com.google.protobuf/protobuf-java-util,另一个是com.googlecode.protobuf-java-format/protobuf-java-format,两个的性能和效果还有待对比。这里使用的是com.google.protobuf/protobuf-java-util,原因在于protobuf-java-format 中的JsonFormat 会将 Map 格式化为 {"key": "","value":""} 的对象列表,而protobuf-java-util 中的 JsonFormat 能够序列化为理想的 key-value 的结构。

<!-- https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java-util -->
<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java-util</artifactId>
    <version>3.7.1</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.googlecode.protobuf-java-format/protobuf-java-format -->
<dependency>
    <groupId>com.googlecode.protobuf-java-format</groupId>
    <artifactId>protobuf-java-format</artifactId>
    <version>1.4</version>
</dependency>

代码实现

import com.google.gson.Gson;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;

import java.io.IOException;

/**
 * 特别主要:* <ul>
 *  <li> 该实现无法处理含有 Any 类型字段的 Message</li>
 *  <li>enum 类型数据会转化为 enum 的字符串名 </li>
 *  <li>bytes 会转化为 utf8 编码的字符串 </li>
 * </ul>
 * @author Yang Guanrong
 * @date 2019/08/20 17:11
 */
public class ProtoJsonUtils {public static String toJson(Message sourceMessage)
            throws IOException {String json = JsonFormat.printer().print(sourceMessage);
        return json;
    }

    public static Message toProtoBean(Message.Builder targetBuilder, String json) throws IOException {JsonFormat.parser().merge(json, targetBuilder);
        return targetBuilder.build();}
}

对于一般的数据类型,如 int,double,float,long,string 都能够按照理想的方式进行转化。对于 protobuf 中的 enum 类型字段,会被按照 enum 的名称转化为 string。对于 bytes 类型的字段,则会转化为 utf8 类型的字符串。

Any 以及 Oneof

AnyOneof 是 protobuf 中比较特别的两个类型,如果尝试将含有 Oneof 字段转化为 json,是可以正常转化的,字段名为被赋值的 oneof 字段的名称。

而对于 Any 的处理,则会比较特别。如果直接转化,会得到类似如下的异常,无法找到 typeUrl 指定的类型。

com.google.protobuf.InvalidProtocolBufferException: Cannot find type for url: type.googleapis.com/data.proto.BaseData

    at com.google.protobuf.util.JsonFormat$PrinterImpl.printAny(JsonFormat.java:807)
    at com.google.protobuf.util.JsonFormat$PrinterImpl.access$900(JsonFormat.java:639)
    at com.google.protobuf.util.JsonFormat$PrinterImpl$1.print(JsonFormat.java:709)
    at com.google.protobuf.util.JsonFormat$PrinterImpl.print(JsonFormat.java:688)
    at com.google.protobuf.util.JsonFormat$PrinterImpl.printSingleFieldValue(JsonFormat.java:1183)
    at com.google.protobuf.util.JsonFormat$PrinterImpl.printSingleFieldValue(JsonFormat.java:1048)
    at com.google.protobuf.util.JsonFormat$PrinterImpl.printField(JsonFormat.java:972)
    at com.google.protobuf.util.JsonFormat$PrinterImpl.print(JsonFormat.java:950)
    at com.google.protobuf.util.JsonFormat$PrinterImpl.print(JsonFormat.java:691)
    at com.google.protobuf.util.JsonFormat$Printer.appendTo(JsonFormat.java:332)
    at com.google.protobuf.util.JsonFormat$Printer.print(JsonFormat.java:342)
    at io.gitlab.donespeak.javatool.toolprotobuf.ProtoJsonUtil.toJson(ProtoJsonUtil.java:12)
    at io.gitlab.donespeak.javatool.toolprotobuf.ProtoJsonUtilTest.toJson2(ProtoJsonUtilTest.java:72)
    ...

为了解决这个问题,我们需要手动添加 typeUrl 对应的类型,我是从 Tomer Rothschild 的文章《Protocol Buffers, Part 3 — JSON Format》找到的答案。找到之前可是苦恼了很久。事实上,在 print 方法的上方就显赫地写着该方法会因为没有 any 的 types 而抛出异常。

/**
* Converts a protobuf message to JSON format. Throws exceptions if there
* are unknown Any types in the message.
*/
public String print(MessageOrBuilder message) throws InvalidProtocolBufferException {...}

A TypeRegistry is used to resolve Any messages in the JSON conversion. You must provide a TypeRegistry containing all message types used in Any message fields, or the JSON conversion will fail because data in Any message fields is unrecognizable. You don’t need to supply a TypeRegistry if you don’t use Any message fields.

Class JsonFormat.TypeRegistry @JavaDoc

上面的实现无法处理得了 Any 类型的数据。需要自己添加 TypeRegirstry 才能进行转化。

@Test
public void toJson() throws IOException {
    // 可以为 TypeRegistry 添加多个不同的 Descriptor
    JsonFormat.TypeRegistry typeRegistry = JsonFormat.TypeRegistry.newBuilder()
        .add(DataTypeProto.BaseData.getDescriptor())
        .build();
    // usingTypeRegistry 方法会重新构建一个 Printer
    JsonFormat.Printer printer = JsonFormat.printer()
        .usingTypeRegistry(typeRegistry);

    String json = printer.print(DataTypeProto.DataWithAny.newBuilder()
        .setAnyVal(
            Any.pack(DataTypeProto.BaseData.newBuilder().setInt32Val(1235).build()))
        .build());

    System.out.println(json);
}

从上面的实现中,很容易会想到一个问题:对于一个 Any 类型的字段,必须先注册所有相关的 Message 类型,才能够正常地进行转化为 Json。同理,当我们使用 JsonFormat.parser().merge(json, targetBuilder); 时候,也必须先给 Printer 添加相关的 Message,这必然导致整个代码出现很多重复。

为了解决这个问题,我尝试直接从 Message 中取出所有的 Any 字段中值的 Message 的 Descriptor,然后再创建Printer,这样就可以得到一个通用的转化方法了。最后还是失败了。原本以为会卡在repeated 或者 map 的范型中,但最后发现这些都不是问题,至少在从 protoBean 转化为 json 中不会是问题。问题出在 Any 的设计本身无法实现这个需求。

简单地讲一下Any,Any 的源码不是很多,可以大概抽取部分代码如下:

public  final class Any 
    extends GeneratedMessageV3 implements AnyOrBuilder {

    // typeUrl_ 会是一个 java.lang.String 值
    private volatile Object typeUrl_;
    private ByteString value_;
    
    private static String getTypeUrl(String typeUrlPrefix, Descriptors.Descriptor descriptor) {return typeUrlPrefix.endsWith("/")
            ? typeUrlPrefix + descriptor.getFullName()
            : typeUrlPrefix + "/" + descriptor.getFullName();}

    public static <T extends com.google.protobuf.Message> Any pack(T message) {return Any.newBuilder()
            .setTypeUrl(getTypeUrl("type.googleapis.com",
                                message.getDescriptorForType()))
            .setValue(message.toByteString())
            .build();}

    public static <T extends Message> Any pack(T message, String typeUrlPrefix) {return Any.newBuilder()
            .setTypeUrl(getTypeUrl(typeUrlPrefix,
                                message.getDescriptorForType()))
            .setValue(message.toByteString())
            .build();}

    public <T extends Message> boolean is(Class<T> clazz) {T defaultInstance = com.google.protobuf.Internal.getDefaultInstance(clazz);
            return getTypeNameFromTypeUrl(getTypeUrl()).equals(defaultInstance.getDescriptorForType().getFullName());
    }

    private volatile Message cachedUnpackValue;

    @java.lang.SuppressWarnings("unchecked")
    public <T extends Message> T unpack(Class<T> clazz) throws InvalidProtocolBufferException {if (!is(clazz)) {throw new InvalidProtocolBufferException("Type of the Any message does not match the given class.");
        }
        if (cachedUnpackValue != null) {return (T) cachedUnpackValue;
        }
        T defaultInstance = com.google.protobuf.Internal.getDefaultInstance(clazz);
        T result = (T) defaultInstance.getParserForType().parseFrom(getValue());
        cachedUnpackValue = result;
        return result;
    }
    ...
}

从上面的代码中,我们可以很容易地看出,Any 类型的字段存储的是 Any 类型的 Message,与原本的 Message 值没有关系。而保存为 Any 之后,Any 会将其保存到 ByteString 的 value_ 中,并构建一个 typeUrl_,所以从一个 Any 对象中,我们是无法得知原本用于构造该 Any 对象的 Message 对象的类型是什么(typeUrl_ 只是给出了一个描述,无法用反射等方法得到原本的类类型)。在unpack 方法,实现用的方法是先用 class 构建出一个示例对象,在用 parseFrom 方法恢复原本的值。到这里我就特别好奇,为什么 Any 这个类就不能保存 value 原本的类类型进去呢?或者直接将 value 定义为 Message 对象也好呀,这样处理起来就会方便很多,而且也不会影响到序列化才对吧。要能够渗透设计者的意图,还有很多需要学习了解的地方。

写到最后,还是没有办法按照想法中那样,写出一个直接将 Message 转化为 json 的通用方法。虽然没法那么智能,那就手动将所有能够的 Message 都注册进去吧。

package io.gitlab.donespeak.javatool.toolprotobuf;

import com.google.protobuf.Descriptors;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;

import java.io.IOException;
import java.util.List;

public class ProtoJsonUtilV1 {

    private final JsonFormat.Printer printer;
    private final JsonFormat.Parser parser;

    public ProtoJsonUtilV1() {printer = JsonFormat.printer();
        parser = JsonFormat.parser();}

    public ProtoJsonUtilV1(List<Descriptors.Descriptor> anyFieldDescriptor) {JsonFormat.TypeRegistry typeRegistry = JsonFormat.TypeRegistry.newBuilder().add(anyFieldDescriptor).build();
        printer = JsonFormat.printer().usingTypeRegistry(typeRegistry);
        parser = JsonFormat.parser().usingTypeRegistry(typeRegistry);
    }

    public String toJson(Message sourceMessage) throws IOException {String json = printer.print(sourceMessage);
        return json;
    }

    public Message toProto(Message.Builder targetBuilder, String json) throws IOException {parser.merge(json, targetBuilder);
        return targetBuilder.build();}
}

通过 Gson 进行实现

在查找资料的过程中,还发现了一种通过 Gson 完成的转化方法。来自 Alexander Moses 的《Converting Protocol Buffers data to Json and back with Gson Type Adapters》。但我觉得他的这篇文章中有几点没有说对,一个是 protbuf 的插件现在还是有不错的,比如 Idea 就很容易找到,vscode 的也很容易搜到,eclipse 的可以用 protobuf-dt(这个 dt 会有点问题,有机会讲下)。文章写得很是清楚,我这里主要是将他的实现改成更加通用一点。

这个实现还是上面的JsonFormat,所以也没有支持 Any 的转化。如果想支持 Any,可以按照上面的代码进行修改,这里就不多做修改了。

package io.gitlab.donespeak.javatool.toolprotobuf;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParser;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;
import io.gitlab.donespeak.javatool.toolprotobuf.proto.DataTypeProto;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/**
 * @author Yang Guanrong
 * @date 2019/08/31 17:23
 */
public class ProtoGsonUtil {public static String toJson(Message message) {return getGson(message.getClass()).toJson(message);
    }

    public static <T extends Message> Message toProto(Class<T> klass, String json) {return getGson(klass).fromJson(json, klass);
    }

    /**
     * 如果这个方法要设置为 public 方法,那么需要确定 gson 是否是一个不可变对象,否则就不应该开放出去
     *
     * @param messageClass
     * @param <E>
     * @return
     */
    private static <E extends Message> Gson getGson(Class<E> messageClass) {GsonBuilder gsonBuilder = new GsonBuilder();
        Gson gson = gsonBuilder.registerTypeAdapter(DataTypeProto.OnlyInt32.class, new MessageAdapter(messageClass)).create();

        return gson;
    }

    private static class MessageAdapter<E extends Message> extends TypeAdapter<E> {

        private Class<E> messageClass;

        public MessageAdapter(Class<E> messageClass) {this.messageClass = messageClass;}

        @Override
        public void write(JsonWriter jsonWriter, E value) throws IOException {jsonWriter.jsonValue(JsonFormat.printer().print(value));
        }

        @Override
        public E read(JsonReader jsonReader) throws IOException {
            try {
                // 这里必须用范型 <E extends Message>,不能直接用 Message,否则将找不到 newBuilder 方法
                Method method = messageClass.getMethod("newBuilder");
                // 调用静态方法
                E.Builder builder = (E.Builder)method.invoke(null);

                JsonParser jsonParser = new JsonParser();
                JsonFormat.parser().merge(jsonParser.parse(jsonReader).toString(), builder);
                return (E)builder.build();} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {e.printStackTrace();
                throw new ProtoJsonConversionException(e);
            }
        }
    }

    public static void main(String[] args) {DataTypeProto.OnlyInt32 data = DataTypeProto.OnlyInt32.newBuilder()
            .setIntVal(100)
            .build();

        String json = toJson(data);
        System.out.println(json);

        System.out.println(toProto(DataTypeProto.OnlyInt32.class, json));
    }
}

参考

  • com.google.protobuf/protobuf-java-util @Github
  • com.googlecode.protobuf-java-format/protobuf-java-format @Github
  • Protocol Buffers, Part 3 — JSON Format
  • Converting Protocol Buffers data to Json and back with Gson Type Adapters
  • Any 源码 @Github
  • Any 官方文档 @Office

正文完
 0