DEV Community

Yaroslav
Yaroslav

Posted on

Switching to binary data transfer has never been easier for DART developers.

Introducing

Protocol Buffers (Proto) offer substantial advantages over JSON and XML. They present a more condensed data representation, shrinking message size by up to 5 times compared to JSON. Furthermore, Proto excels in serialization and deserialization efficiency, leading to a 30-40% enhancement in data exchange rates over JSON. Additionally, Proto ensures robust data typing and schemas, simplifying development and bolstering data validation reliability. These attributes position Protocol Buffers as an efficient choice for data transmission.

Problem

Despite the initial impression that transitioning to binary data transfer via the buffer protocol is straightforward, it often involves complexities. A pivotal aspect of this transition is creating separate messages for each entity. Commonly, Data Transfer Objects (DTOs) are utilized to represent binary messages. It's essential to acknowledge that classes tailored for data transmission exclusively focus on data transfer logic, devoid of business logic. These classes may undergo evolution to accommodate new features or requirements, specifically tailored for data transmission purposes.

Solution

d2p_gen package πŸ’‘
To make this common process easier and reduce manual work, we've developed a special tool. This tool automatically generates protocol buffers and helps convert between different data transfer objects (DTOs). It also generates tests for you.

Here are some of the main features of this tool:

  • It automatically creates a "messages.g.proto" file with enum and data class definitions. You can use the "@ProtoGen" annotation to do this (union classes are also supported).
  • If a class doesn't have a constructor, it will start with an underscore if it's abstract and doesn't redirect. If any of its fields have the "async" type, they'll also be ignored.
  • The tool checks for any pre-existing protocol buffer dependencies and creates Dart classes for you based on the "messages.proto" file you provide.
  • Finally, it creates map classes that allow you to convert between the different models.Generating test data for mapper classes. The analyzer recursively goes through all AST nodes and makes up some fake data to test the methods in the unit tests.

Example

Consider a scenario where a class needs to be sent over the websocket.

  1. First, execute the following commands in your terminal:
dart pub add d2p_annotation
dart pub add dev:build_runner
dart pub add dev:d2p_gen
Enter fullscreen mode Exit fullscreen mode
  1. Place an annotation above the class you intend to pass:
@ProtoGen(createMappers: true)
class Foo {
  final String a;
  final int b;
  Foo({required this.a, required this.b});
}

Enter fullscreen mode Exit fullscreen mode

Based on this, the generated proto message will appear as follows:


syntax = "proto3";
package messages;
/*
  class: Foo
*/
  message DTOFoo {
    // String Foo.a
    string a = 1;
    // int Foo.b
    int32 b = 2;
  }

Enter fullscreen mode Exit fullscreen mode

The converter class will resemble the following:


/// Mapper that converts a DTO [DTOFoo] object
/// into a Model [Foo] and back.
abstract class $MapperFoo {
  /// Converts the model [Foo]
  /// to the DTO [DTOFoo].
  static Foo fromDTO(DTOFoo model) {
    try {
      return Foo(
        a: model.a,
        b: model.b,
      );
    } on FormatException catch (e, trace) {
      throw FormatException(
        '''Exception
      ${e.source}
      ${e.message}
      $trace''',
      );
    }
  }

  /// Converts the model [Foo]
  /// to the DTO [DTOFoo]
  static DTOFoo toDTO(Foo model) {
    try {
      return DTOFoo(
        a: model.a,
        b: model.b,
      );
    } on FormatException catch (e, trace) {
      throw FormatException(
        '''Exception
      ${e.source}
      ${e.message}
      $trace''',
      );
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Finally, tests will be generated for this mapper:

 group(r'Testing $MapperFoo methods', () {
// Test the toDTO method (which returns a DTO class)
    test(r'$MapperFoo.toDTO Output class Foo should be DTOFoo', () {
      // Arrange - Setup facts, Put Expected outputs or Initialize
      final model = Foo(
        a: '',
        b: 69,
      );

      // Act - Call the function that is to be tested
      final dto = $MapperFoo.toDTO(model);

      // Assert - Compare the actual result and expected result
      // Check if the output is of the expected type
      expect(
        dto,
        TypeMatcher<DTOFoo>(),
        reason: 'The output should be of type DTOFoo',
      );
// Check if the output is not null
      expect(
        dto,
        isNotNull,
        reason: 'The output must not be null',
      );
// Check if the output is not an exception
      expect(
        dto,
        isNot(isException),
        reason: 'The output must not be an exception',
      );
    });

// Test the fromDTO method (which returns a dart data class or enum)
    test(r'$MapperFoo.fromDTO Output class Foo should be Foo', () {
      // Arrange - Setup facts, Put Expected outputs or Initialize
      final dto = DTOFoo(
        a: 'O1LuzSNMlax',
        b: 19,
      );

      // Act - Call the function that is to be tested
      final model = $MapperFoo.fromDTO(dto);

      // Assert - Compare the actual result and expected result
      // Check if the output is of the expected type
      expect(
        model,
        TypeMatcher<Foo>(),
        reason: 'The output should be of type Foo',
      );
// Check if the output is not null
      expect(
        model,
        isNotNull,
        reason: 'The output must not be null',
      );
// Check if the output is not an exception
      expect(
        model,
        isNot(isException),
        reason: 'The output must not be an exception',
      );
    });
  });
Enter fullscreen mode Exit fullscreen mode

And finally, we need to implement message-passing through WebSockets. In REST, we can pass message types in the header each time, or use different endpoints. However, in WebSockets, after the upgrade protocol (handshake), we cannot pass headers during a session. And what kind of message is coming from all possible messages can only be found out after deserialization.

How to proceed? 🀫🀫

Very simply, we can use a map, where the key will help determine which of the mapped objects we need to use. All the data will be stored in bytes. To turn a DTO model into bytes, just call the writeToBuffer() method.

final _str = jsonEncode(
    <String, Uint8List>{
     'Foo': dtoFoo.writeToBuffer(),
        },
);
Enter fullscreen mode Exit fullscreen mode

πŸ‘ŒπŸ˜€ This is it! πŸ˜€πŸ‘Œ

Image description

Happy coding =) πŸ‘‹πŸΌπŸ‘‹πŸΌ

resources:
Gist with full example.
package: https://pub.dev/packages/d2p_gen

Top comments (0)