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 這篇
做法是指定 fromJson
與 toJson
的轉換函數,例如:
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>
類型,這個類型定義了兩個子函數:fromJson
與 toJson
用來轉換數據。將方法一的 protobufFromJson
與 protobufToJson
結合 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
定義成 PlusTwoIntResult
與 String
之間做轉換。但實際上 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
在 ResultConverter
的 fromJson
中,我們是用 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
即可。