Dart How to define an array with mixed data type in gRPC

eye-catch Dart and Flutter

An array and map data type is useful. Is it possible to use them in gRPC? YES, it is. Let’s define them in Protocol Buffer and implement them in Dart.

Clone my GitHub repository if you want to try it yourself.

Proto file definition

We will implement 4 functions in this post.

  • Int array
  • Array that has int or string
  • Map data
  • Enum

The proto file definition looks like this.

syntax = "proto3";

option go_package = "pkg/api-test/grpc/apitest";

service TypesDef {
  rpc WithRepeatedInt64(WithRepeatedInt64Request)
      returns (WithRepeatedInt64Response) {}
  rpc WithRepeatedStringInt(WithRepeatedStringIntRequest)
      returns (WithRepeatedStringIntResponse) {}
  rpc WithMap(WithMapRequest) returns (WithMapResponse) {}
  rpc WithEnum(WithEnumRequestResponse) returns (WithEnumRequestResponse) {}
}

Each message definition will be shown in the following sections.

The following packages are imported in server/client.

// Server
import "package:dart_grpc/proto/types_def.pb.dart";
import "package:dart_grpc/proto/types_def.pbgrpc.dart" as rpc;
import "package:grpc/grpc.dart";
import 'package:fixnum/fixnum.dart' as $fixnum;

// Client
import 'dart:async';

import 'package:dart_grpc/proto/types_def.pbgrpc.dart' as rpc;
import 'package:fixnum/fixnum.dart' as $fixnum;

Int array

To make a property an array, repeated keyword needs to be added to the definition. Array is basically a reference. Therefore, it can be nullable.

message WithRepeatedInt64Request {
  repeated int64 int_array = 1;
  string type = 2;
}

message WithRepeatedInt64Response {
  repeated int64 int_array = 1;
}

Let’s see how to send int array request. As mentioned above, array type is nullable but hasXxxx() doesn’t exist for it. It’s even not nullable.

// Server
@override
Future<rpc.WithRepeatedInt64Response> withRepeatedInt64(
  ServiceCall ctx,
  rpc.WithRepeatedInt64Request request,
) async {
  print("--- withRepeatedInt64 ---");
  print("(value: ${request.intArray}, type: ${request.type})");

  final response = rpc.WithRepeatedInt64Response();
  if (request.type.isNotEmpty) {
    response.intArray.addAll([
      $fixnum.Int64(9),
      $fixnum.Int64(8),
      $fixnum.Int64(7),
    ]);
  }

  return response;
}

// Client
Future<void> withRepeatedInt64({String type = ""}) async {
  print("--- withRepeatedInt64 ---");
  final request = rpc.WithRepeatedInt64Request()..type = type;

  if (type == "number") {
    request.intArray.addAll([
      $fixnum.Int64(11),
      $fixnum.Int64(22),
    ]);
  }

  final result = await client.withRepeatedInt64(request);
  print("value: ${result.intArray}");
}

Let’s call the function with the following parameters.

await typeHandler.withRepeatedInt64();
await typeHandler.withRepeatedInt64(type: "number");

The int array data is sent to each other as expected.

make runClient            |$ make runServer 
--- withRepeatedInt64 --- |Server listening on port 8080...
value: []                 |--- withRepeatedInt64 ---
--- withRepeatedInt64 --- |(value: [], type: )
value: [9, 8, 7]          |--- withRepeatedInt64 ---
                          value: [11, 22], type: number)

It is impossible to differentiate not set and empty list

I didn’t differentiate between an empty list and not set because it’s impossible. intArray has only a getter but not a setter. Null can’t be set to the variable.

This is a restriction from Protocol Buffer. If you really need to distinguish the two, an additional parameter needs to be added to the message to determine if it’s not set.

For example, add optional bool isNotSet to the message. If it’s true, the empty list is handled as not set. If it’s false, hanlde the empty list as it is.

An array that has mixed data type

How can we define if an array has string or int? Let’s define another message with oneof keyword. Then, use the data type to the array.

message WithRepeatedStringIntRequest {
  repeated StringIntegerValue string_int_array = 1;
  string type = 2;
}

message WithRepeatedStringIntResponse {
  repeated StringIntegerValue string_int_array = 1;
}

message StringIntegerValue {
  oneof value {
    string text = 1;
    int64 number = 2;
  }
}

We have to be aware that oneof value accepts null.

Since the array property doesn’t have a setter, we have to set the value via a chain method. Let’s use for-loop to show the requested value one by one. Both server/client implementation looks similar.

// Server
@override
Future<rpc.WithRepeatedStringIntResponse> withRepeatedStringInt(
  ServiceCall ctx,
  rpc.WithRepeatedStringIntRequest request,
) async {
  print("--- withRepeatedStringInt ---");
  for (final value in request.stringIntArray) {
    print("value: ${value}");
    print("whichElement: ${value.whichValue()})");
    print("(hasNumber: ${value.hasNumber()}, value: ${value.number})");
    print("(hasText: ${value.hasText()}, value: ${value.text})");
  }

  final response = rpc.WithRepeatedStringIntResponse();
  if (request.type == "number") {
    response.stringIntArray.addAll([
      rpc.StringIntegerValue()..number = $fixnum.Int64(9),
      rpc.StringIntegerValue()..number = $fixnum.Int64(8),
      rpc.StringIntegerValue()..number = $fixnum.Int64(7),
    ]);
  } else if (request.type == "mix") {
    response.stringIntArray.addAll([
      rpc.StringIntegerValue()..number = $fixnum.Int64(9),
      rpc.StringIntegerValue()..text = "eight",
      rpc.StringIntegerValue()..number = $fixnum.Int64(7),
    ]);
  }

  return response;
}

// Client
Future<void> withRepeatedStringInt({String type = ""}) async {
  print("--- withRepeatedStringInt ---");
  final request = rpc.WithRepeatedStringIntRequest()..type = type;

  if (type == "number") {
    request.stringIntArray.addAll([
      rpc.StringIntegerValue()..number = $fixnum.Int64(11),
      rpc.StringIntegerValue()..number = $fixnum.Int64(22),
    ]);
  } else if (type == "mix") {
    request.stringIntArray.addAll([
      rpc.StringIntegerValue()..number = $fixnum.Int64(11),
      rpc.StringIntegerValue(),
      rpc.StringIntegerValue()..text = "dummy data 22",
    ]);
  }

  final result = await client.withRepeatedStringInt(request);
  for (final value in result.stringIntArray) {
    print("value: ${value}");
    print("whichElement: ${value.whichValue()})");
    print("(hasNumber: ${value.hasNumber()}, value: ${value.number})");
    print("(hasText: ${value.hasText()}, value: ${value.text})");
  }
}

The parameters to call the function are the following.

await typeHandler.withRepeatedStringInt();
await typeHandler.withRepeatedStringInt(type: "number");
await typeHandler.withRepeatedStringInt(type: "mix");

Let’s see the result. The first one doesn’t show anything because the list is empty. As you can see on the client side (left), int and string are mixed in the third call result.

$ make runClient                              | $ make runServer 
--- withRepeatedStringInt ---                 | Server listening on port 8080...
--- withRepeatedStringInt ---                 | --- withRepeatedStringInt ---
value: number: 9                              | 
                                              | --- withRepeatedStringInt ---
whichElement: StringIntegerValue_Value.number)| value: number: 11
(hasNumber: true, value: 9)                   | whichElement: StringIntegerValue_Value.number)
(hasText: false, value: )                     | (hasNumber: true, value: 11)
value: number: 8                              | (hasText: false, value: )
                                              | 
whichElement: StringIntegerValue_Value.number)| value: number: 22
(hasNumber: true, value: 8)                   | whichElement: StringIntegerValue_Value.number)
(hasText: false, value: )                     | (hasNumber: true, value: 22)
value: number: 7                              | (hasText: false, value: )
                                              | --- withRepeatedStringInt ---
whichElement: StringIntegerValue_Value.number)| value: number: 11
(hasNumber: true, value: 7)                   | whichElement: StringIntegerValue_Value.number)
(hasText: false, value: )                     | (hasNumber: true, value: 11)
--- withRepeatedStringInt ---                 | (hasText: false, value: )
value: number: 9                              | 
                                              | value: 
whichElement: StringIntegerValue_Value.number)| whichElement: StringIntegerValue_Value.notSet)
(hasNumber: true, value: 9)                   | (hasNumber: false, value: 0)
(hasText: false, value: )                     | (hasText: false, value: )
value: text: eight                            | 
                                              | value: text: dummy data 22
whichElement: StringIntegerValue_Value.text)  | whichElement: StringIntegerValue_Value.text)
(hasNumber: false, value: 0)                  | (hasNumber: false, value: 0)
(hasText: true, value: eight)                 | (hasText: true, value: dummy data 22)
value: number: 7                              |
                                              |
whichElement: StringIntegerValue_Value.number)|  
(hasNumber: true, value: 7)                   | 
(hasText: false, value: )                     | 

As mentioned above, the value accepts null. The second element of the request parameter is null. As you can see on the server side in the third call result, whichElement returns notSet. Since the array might contain null value, we should do the null check before consuming the value to make the app robust.

Create key-value pair by map

Protocol Buffer supports map type too. Specify the data type of the key and value. That’s it.

message WithMapRequest {
  map<string, int64> map_value = 1;
  string type = 2;
}
message WithMapResponse {
  map<string, int64> map_value = 1;
}

Map type doesn’t have hasXxxx() method as well. So it’s impossible to differentiate between not set and empty as mentioned above.

// Server
@override
Future<rpc.WithMapResponse> withMap(
  ServiceCall ctx,
  rpc.WithMapRequest request,
) async {
  print("--- withMap ---");
  print("value: ${request.mapValue.toString()}, "
      "length: ${request.mapValue.length}");

  if (request.mapValue.isEmpty) {
    return rpc.WithMapResponse();
  }

  return rpc.WithMapResponse()
    ..mapValue["first"] = $fixnum.Int64(11)
    ..mapValue["second"] = $fixnum.Int64(22);
}

// Client
Future<void> withMap({String type = ""}) async {
  print("--- withMap ---");
  final request = rpc.WithMapRequest();

  if (type.isNotEmpty) {
    request
      ..mapValue["first"] = $fixnum.Int64(111)
      ..mapValue["second"] = $fixnum.Int64(222)
      ..type = "empty";
  }

  final result = await client.withMap(request);
  print("value: ${result.mapValue}");
}

It’s easy to use. So there’s not much to say. Let’s call the function.

await typeHandler.withMap();
await typeHandler.withMap(type: "dummy type");

No surprise. It’s the expected result.

$ make runClient               | $ make runServer
--- withMap ---                | Server listening on port 8080...
value: {}                      | --- withMap ---
--- withMap ---                | value: {}, length: 0
value: {first: 11, second: 22} | --- withMap ---
                               | value: {first: 111, second: 222}, length: 2

Define enum value in protocol buffer

Enum can be used in Protocol Buffer too. It’s recommended that the property name contains the enum name. In this case, it’s DEVICE_STATE_XXX. The first entry should be UNSPECIFIED with ID 0.

enum DeviceState {
  DEVICE_STATE_UNSPECIFIED = 0;
  DEVICE_STATE_READY = 1;
  DEVICE_STATE_RUNNING = 2;
  DEVICE_STATE_STOP = 3;
  DEVICE_STATE_ABORTED = 4;
  DEVICE_STATE_COMPLETED = 5;
}

message WithEnumRequestResponse {
  DeviceState state = 1;
}

The usage is simple. It has also hasXxxx().

// Server
@override
Future<WithEnumRequestResponse> withEnum(
  ServiceCall call,
  WithEnumRequestResponse request,
) async {
  print("--- withEnum ---");
  print("(has: ${request.hasState()}, state: ${request.state})");

  final response = rpc.WithEnumRequestResponse();

  switch (request.state) {
    case rpc.DeviceState.DEVICE_STATE_READY:
      response.state = rpc.DeviceState.DEVICE_STATE_RUNNING;
      break;
    case rpc.DeviceState.DEVICE_STATE_RUNNING:
      response.state = rpc.DeviceState.DEVICE_STATE_COMPLETED;
      break;
    case rpc.DeviceState.DEVICE_STATE_STOP:
      response.state = rpc.DeviceState.DEVICE_STATE_ABORTED;
      break;
    default:
      response.state = rpc.DeviceState.DEVICE_STATE_READY;
      break;
  }

  return response;
}

// Client
Future<void> withEnum({rpc.DeviceState? state}) async {
  print("--- withEnum ---");
  final request = rpc.WithEnumRequestResponse();
  if (state != null) {
    request.state = state;
  }
  final result = await client.withEnum(request);
  print("value: ${result.state}");
}

Let’s call it.

await typeHandler.withEnum();
await typeHandler.withEnum(state: DeviceState.DEVICE_STATE_READY);
await typeHandler.withEnum(state: DeviceState.DEVICE_STATE_RUNNING);
await typeHandler.withEnum(state: DeviceState.DEVICE_STATE_STOP);

The Result.

$ make runClient              | $ make runServer
--- withEnum ---              | Server listening on port 8080...
value: DEVICE_STATE_READY     | --- withEnum ---
--- withEnum ---              | (has: false, state: DEVICE_STATE_UNSPECIFIED)
value: DEVICE_STATE_RUNNING   | --- withEnum ---
--- withEnum ---              | (has: true, state: DEVICE_STATE_READY)
value: DEVICE_STATE_COMPLETED | --- withEnum ---
--- withEnum ---              | (has: true, state: DEVICE_STATE_RUNNING)
value: DEVICE_STATE_ABORTED   | --- withEnum ---
                              | (has: true, state: DEVICE_STATE_STOP)

Related article

Comments

Copied title and URL