Dart 轉換 json 中的 protobuf 結構

最近開始將 flutter app 的後端由 restful api 逐步轉成 grpc,轉換的過程中為了同時兼容舊的 api,在原本的 json 結構中替換並嵌入了 protobuf 結構。結果在 json 序列化時 (flutter packages pub run build_runner build) 出現了類似以下錯誤:

[INFO] Generating build script completed, took 569ms
[INFO] Reading cached asset graph completed, took 77ms
[INFO] Checking for updates since last build completed, took 640ms
[SEVERE] json_serializable on lib/models/plustwo.dart:

Expecting a `fromJson` constructor with exactly one positional parameter. The only extra parameters allowed are functions of the form `T Function(Object?) fromJsonT` where `T` is a type parameter of the target type.
package:client/proto/calculator.pb.dart:40:28
   ╷
40 │   factory PlusTwoIntResult.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
   │                            ^^^^^^^^
   ╵
[INFO] Running build completed, took 5.1s
[INFO] Caching finalized dependency graph completed, took 48ms
[SEVERE] Failed after 5.2s

先來看一下上述的資料結構。首先是 json class:

@JsonSerializable(explicitToJson: true)
class PlusTowIntResponseV1 {
  final int code;
  final PlusTwoIntResult data;

  PlusTowIntResponseV1({required this.code, required this.data});

  // json constructor and formatter
  factory PlusTowIntResponseV1.fromJson(Map<String, dynamic> json) =>
      _$PlusTowIntResponseV1FromJson(json);
  Map<String, dynamic> toJson() => _$PlusTowIntResponseV1ToJson(this);
}

其中,PlusTwoIntResult 是 protobuf 結構,它定義如下:

message PlusTwoIntResult {
    int64 x = 1;
    int64 y = 2;
    int64 z = 3;
}

運行 dart run build_runner build --delete-conflicting-outputs 時產生了本篇開頭的錯誤。

在 google 一番之後,找到了幾種解法。

在 JsonKey 中指定轉換 function

這個方式參考自 github 上的 Using protobuf with this package 這篇

做法是指定 fromJsontoJson 的轉換函數,例如:

PlusTwoIntResult protobufFromJson(String json) {
  return PlusTwoIntResult.fromJson(json);
}

String protobufToJson(PlusTwoIntResult PlusTwoIntResult) {
  return PlusTwoIntResult.create().writeToJson();
}

@JsonSerializable(explicitToJson: true)
class PlusTowIntResponseV1 {
  final int code;
  @JsonKey(fromJson: protobufFromJson, toJson: protobufToJson)
  final PlusTwoIntResult data;

  const PlusTowIntResponseV1({required this.code, required this.data});

  // json constructor and formatter
  factory PlusTowIntResponseV1.fromJson(Map<String, dynamic> json) =>
      _$PlusTowIntResponseV1FromJson(json);
  Map<String, dynamic> toJson() => _$PlusTowIntResponseV1ToJson(this);
}

這個方法對單純 key/value 結構可行。但是如果 protobuf 結構是在陣列裡就不行 (應該說就不能共用同一組 function)。

@JsonSerializable(explicitToJson: true)
class PlusTowIntResponseV2 {
  final int code;
  @JsonKey(fromJson: protobufFromJson, toJson: protobufToJson)
  final List<PlusTwoIntResult> data;

  const PlusTowIntResponseV2({required this.code, required this.data});

  // json constructor and formatter
  factory PlusTowIntResponseV2.fromJson(Map<String, dynamic> json) =>
      _$PlusTowIntResponseV2FromJson(json);
  Map<String, dynamic> toJson() => _$PlusTowIntResponseV2ToJson(this);
}

PlusTowIntResponseV2 是第二個 json class, 其中 data 欄位對應是 list 結構。如果重複使用同一組 convert function,就會出現以下錯誤:

[INFO] Generating build script completed, took 572ms
[INFO] Reading cached asset graph completed, took 77ms
[INFO] Checking for updates since last build completed, took 652ms
[SEVERE] json_serializable on lib/models/plustwo.dart:

Error with `@JsonKey` on the `data` field. The `toJson` function `protobufToJson` argument type `PlusTwoIntResult` is not compatible with field type `List<PlusTwoIntResult>`.
package:client/models/plustwo.dart:44:32
   ╷
44 │   final List<PlusTwoIntResult> data;
   │                                ^^^^
   ╵
[INFO] Running build completed, took 5.2s
[INFO] Caching finalized dependency graph completed, took 45ms
[SEVERE] Failed after 5.2s

也就是說,如果要用這種方法,必須要對不同的結構再增加不同的轉換函數。這樣太麻煩了,於是又找了第二種方式。

自定義 JsonConverter

json_annotation 套件中提供了 JsonConverter<T, S> 類型,這個類型定義了兩個子函數:fromJsontoJson 用來轉換數據。將方法一的 protobufFromJsonprotobufToJson 結合 JsonConverter 定義了新的轉換器類型:

class ResultConverter extends JsonConverter<PlusTwoIntResult, String> {
  const ResultConverter();

  @override
  PlusTwoIntResult fromJson(String json) {
    return PlusTwoIntResult.fromJson(json);
  }

  @override
  String toJson(PlusTwoIntResult object) {
    return object.writeToJson();
  }
}

然後在原本的 class 中以 annotation 的方式使用這個轉換器:

@JsonSerializable(explicitToJson: true)
@ResultConverter()
class PlusTowIntResponseV1 {
  final int code;
  final PlusTwoIntResult data;

  const PlusTowIntResponseV1({required this.code, required this.data});

  // json constructor and formatter
  factory PlusTowIntResponseV1.fromJson(Map<String, dynamic> json) =>
      _$PlusTowIntResponseV1FromJson(json);
  Map<String, dynamic> toJson() => _$PlusTowIntResponseV1ToJson(this);
}

@JsonSerializable(explicitToJson: true)
@ResultConverter()
class PlusTowIntResponseV2 {
  final int code;
  final List<PlusTwoIntResult> data;

  const PlusTowIntResponseV2({required this.code, required this.data});

  // json constructor and formatter
  factory PlusTowIntResponseV2.fromJson(Map<String, dynamic> json) =>
      _$PlusTowIntResponseV2FromJson(json);
  Map<String, dynamic> toJson() => _$PlusTowIntResponseV2ToJson(this);
}

這個方式 builder 就可以正成運作。

接下來我們來解析 api 返回的 data:

void v1plus(int x, int y) async {
  var url = Uri.http(apihost, '/v1/plus');
  var req = jsonEncode(PlusTwoIntRequest(x: x, y: y));

  var r = await http.post(url, body: req);
  var body = utf8.decode(r.bodyBytes);
  var resp = PlusTowIntResponseV1.fromJson(json.decode(body));
  print('${resp.data.x} + ${resp.data.y} = ${resp.data.z}');
}

利用上面的函數,呼叫後端 api,取得返回結果後再將 json string 解析成資料結構。看似很理想,但是運行時出現了以下的錯誤:

Unhandled exception:
type '_InternalLinkedHashMap<String, dynamic>' is not a subtype of type 'String' in type cast
#0      _$PlusTowIntResponseV1FromJson (package:client/models/plustwo.g.dart:25:59)
#1      new PlusTowIntResponseV1.fromJson (package:client/models/plustwo.dart:29:7)
#2      v1plus (package:client/client.dart:14:35)
<asynchronous suspension>

這個錯誤是因為前面將 ResultConverter 定義成 PlusTwoIntResultString 之間做轉換。但實際上 PlusTwoIntResult 是被解析成 _InternalLinkedHashMap<String, dynamic>

於是將 ResultConverter 改成:

class ResultConverter extends JsonConverter<PlusTwoIntResult, Map<String, dynamic>> {
  const ResultConverter();

  @override
  PlusTwoIntResult fromJson(Map<String, dynamic> json) {
    return PlusTwoIntResult.create()..mergeFromProto3Json(json);
  }

  @override
  Map<String, dynamic> toJson(PlusTwoIntResult object) {
    return object.writeToJsonMap();
  }
}

_InternalLinkedHashMap<String, dynamic> 是 builder 產生出來的東西,可以用 Map<String, dynamic> 替代。這樣,這就可以輕鬆將帶有 protobuf 結構的資料轉換成 json。

最後附上 demo project: https://github.com/maple52046/pbconverter。這個 project 提供了 restful api server (golang) 與 client (dart).


Troubleshoot

ResultConverterfromJson 中,我們是用 mergeFromProto3Json 將 map 轉換成資料結構。如果這裡改用 mergeFromJsonMap 就會出現以下錯誤:

Unhandled exception:
FormatException: Invalid radix-10 number (at character 1)
x
^

#0      int._handleFormatError (dart:core-patch/integers_patch.dart:131:5)
#1      int._parseRadix (dart:core-patch/integers_patch.dart:142:16)
#2      int._parse (dart:core-patch/integers_patch.dart:103:12)
#3      int.parse (dart:core-patch/integers_patch.dart:65:12)
#4      _mergeFromJsonMap (package:protobuf/src/protobuf/json.dart:100:55)
#5      GeneratedMessage.mergeFromJsonMap (package:protobuf/src/protobuf/generated_message.dart:285:5)
#6      ResultConverter.fromJson (package:client/utils/converter.dart:10:39)
#7      _$PlusTowIntResponseV1FromJson (package:client/models/plustwo.g.dart:26:12)
#8      new PlusTowIntResponseV1.fromJson (package:client/models/plustwo.dart:29:7)
#9      v1plus (package:client/client.dart:14:35)
<asynchronous suspension>

這個錯誤,我猜測是因為 protobuf 的 json 以字串的方式包裝 int,也就是 dart 從後端拿到的 json 格式是:

{
    "x": "1",
    "y": "2",
    "z": "3"
}

PlusTwoIntResult 的 json 格式理論上應該是:

{
    "x": 1,
    "y": 2,
    "z": 3
}

所以 dart 在轉換時才會去 call int.parse。這是我個人的猜測。解決方法其實 protobuf 已經幫我們做好了,就是改用 mergeFromProto3Json 即可。

參考資料