Protobuf與Json的相互轉化


前言

最近的工作中開始使用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

Any 和 Oneof 是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));
    }
}
參考
 
 附:

protobuf-java-format包的坑,貌似這個包已經不維護了

使用了protobuf-java-format包將message對象轉換成json串。但最后發現轉換結果中值為0的字段全都不見了,排查了很久發現是protobuf-java包中的Message.getAllFields()方法不會返回與默認值相等的字段。

因此,調用Message.getAllFields()方法是無法返回所有字段的

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM