Getting rid of boilerplate code in Protocol Buffers 2

If you are developing enterprise applications and not only, you are probably already familiar with the Protocol Buffers serialization protocol from Google. In this article, let's talk about its second version. And that he forces us to write a lot of boilerplate code, with which we will fight.



Protobuff is a great thing - you describe the composition of your API in a .proto file, consisting of primitives, and you can generate source code for different platforms - for example, a server in Java and a client in C #, or vice versa. Since most often this is an API for external systems, it is more logical to make it immutable, and this code itself generates a standard generator for Java.



Let's consider an example:



syntax = "proto2";

option java_multiple_files = true;
package org.example.api;

message Person { //     
  required int32 id = 1; // ,  
  required string name = 2; // ,  
  optional int32 age = 3; // ,  
}


As a result, we get a class with the following interface:



public interface PersonOrBuilder extends
    // @@protoc_insertion_point(interface_extends:org.example.api.Person)
    com.google.protobuf.MessageOrBuilder {


  boolean hasId();
  int getId();

  boolean hasName();
  java.lang.String getName();
  com.google.protobuf.ByteString getNameBytes();

  boolean hasAge();
  int getAge();
}


Note that primitives are used throughout (which is efficient for serialization and performance). But the age field is optional, but the primitive always has a default value. This is what amazes a bunch of boilerplate code that we will be fighting with.



Integer johnAge = john.hasAge() ? john.getAge() : null;


But I really want to write:



Integer johnAge = john.age().orElse(null); //  age() -  Optional<Integer>


Protocol Buffers has a plugins extension mechanism and can be written in Java, which is what we'll do.



What is a protobuf plugin?



This is an executable file that reads a PluginProtos.CodeGeneratorRequest object from the standard input stream, generates a PluginProtos.CodeGeneratorResponse from the standard input stream and writes it to the standard output stream.



public static void main(String[] args) throws IOException {
        PluginProtos.CodeGeneratorRequest codeRequest = PluginProtos.CodeGeneratorRequest.parseFrom(System.in);
        PluginProtos.CodeGeneratorResponse codeResponse;
        try {
            codeResponse = generate(codeRequest);
        } catch (Exception e) {
            codeResponse = PluginProtos.CodeGeneratorResponse.newBuilder()
                    .setError(e.getMessage())
                    .build();
        }
        codeResponse.writeTo(System.out);
    }


Let's take a closer look at what we can generate?



PluginProtos.CodeGeneratorResponse contains the PluginProtos.CodeGeneratorResponse.File collection.

Each "file" is a new class that we generate ourselves. It consists of:



String name; //  ,          package
String content; //    
String insertionPoint; //  


The most important thing for writing plugins - we don't have to regenerate all the classes - we can supplement the existing classes using insertionPoint . If we return to the generated interface above, we will see there:



 // @@protoc_insertion_point(interface_extends:org.example.api.Person)


it is in these places that we can add our code. Thus, we will not be able to add to an arbitrary section of the class. We will build on this. How can we solve this problem? We can make our new interface with a default method -
public interface PersonOptional extends PersonOrBuilder {
  default Optional<Integer> age() {
    return hasAge() ? Optional.of(getAge()) : Optional.empty();
  }
}


and for the Person class, add the implementation of not only PersonOrBuilder, but also PersonOptional



Code to generate the interface we need
@Builder
public class InterfaceWriter {

    private static final Map<DescriptorProtos.FieldDescriptorProto.Type, Class<?>> typeToClassMap = ImmutableMap.<DescriptorProtos.FieldDescriptorProto.Type, Class<?>>builder()
            .put(TYPE_DOUBLE, Double.class)
            .put(TYPE_FLOAT, Float.class)
            .put(TYPE_INT64, Long.class)
            .put(TYPE_UINT64, Long.class)
            .put(TYPE_INT32, Integer.class)
            .put(TYPE_FIXED64, Long.class)
            .put(TYPE_FIXED32, Integer.class)
            .put(TYPE_BOOL, Boolean.class)
            .put(TYPE_STRING, String.class)
            .put(TYPE_UINT32, Integer.class)
            .put(TYPE_SFIXED32, Integer.class)
            .put(TYPE_SINT32, Integer.class)
            .put(TYPE_SFIXED64, Long.class)
            .put(TYPE_SINT64, Long.class)
            .build();

    private final String packageName;
    private final String className;
    private final List<DescriptorProtos.FieldDescriptorProto> fields;

    public String getCode() {
        List<MethodSpec> methods = fields.stream().map(field -> {
            ClassName fieldClass;
            if (typeToClassMap.containsKey(field.getType())) {
                fieldClass = ClassName.get(typeToClassMap.get(field.getType()));
            } else {
                int lastIndexOf = StringUtils.lastIndexOf(field.getTypeName(), '.');
                fieldClass = ClassName.get(field.getTypeName().substring(1, lastIndexOf), field.getTypeName().substring(lastIndexOf + 1));
            }

            return MethodSpec.methodBuilder(field.getName())
                    .addModifiers(Modifier.DEFAULT, Modifier.PUBLIC)
                    .returns(ParameterizedTypeName.get(ClassName.get(Optional.class), fieldClass))
                    .addStatement("return has$N() ? $T.of(get$N()) : $T.empty()", capitalize(field.getName()), Optional.class, capitalize(field.getName()), Optional.class)
                    .build();
        }).collect(Collectors.toList());

        TypeSpec generatedInterface = TypeSpec.interfaceBuilder(className + "Optional")
                .addSuperinterface(ClassName.get(packageName, className + "OrBuilder"))
                .addModifiers(Modifier.PUBLIC)
                .addMethods(methods)
                .build();

        return JavaFile.builder(packageName, generatedInterface).build().toString();
    }
}




Now let's return from the plugin the code that needs to be generated



 PluginProtos.CodeGeneratorResponse.File.newBuilder() //     InsertionPoint,       
                    .setName(String.format("%s/%sOptional.java", clazzPackage.replaceAll("\\.", "/"), clazzName))
.setContent(InterfaceWriter.builder().packageName(clazzPackage).className(clazzName).fields(optionalFields).build().getCode())
                    .build();

PluginProtos.CodeGeneratorResponse.File.newBuilder()
                            .setName(String.format("%s/%s.java", clazzPackage.replaceAll("\\.", "/"), clazzName))
                            .setInsertionPoint(String.format("message_implements:%s.%s", clazzPackage, clazzName)) //     -  message -     
                            .setContent(String.format(" %s.%sOptional, ", clazzPackage, clazzName))
                            .build(),


How are we going to use our new plugin? - via maven, add and configure our plugin:



<plugin>
                <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>0.6.1</version>
                <extensions>true</extensions>
                <configuration>
                    <pluginId>java8</pluginId>
                    <protoSourceRoot>${basedir}/src/main/proto</protoSourceRoot>
                    <protocPlugins>
                        <protocPlugin>
                            <id>java8</id>
                            <groupId>org.example.protobuf</groupId>
                            <artifactId>optional-plugin</artifactId>
                            <version>1.0-SNAPSHOT</version>
                            <mainClass>org.example.proto2plugin.OptionalPlugin</mainClass>
                        </protocPlugin>
                    </protocPlugins>
                </configuration>
            </plugin>


But you can also run it from the console - there is one feature to run not only our plugin, but before that you need to call the standard java compiler (but you need to create an executable file - protoc-gen-java8 (in my case, just a bash script).



protoc -I=./src/main/resources/ --java_out=./src/main/java/  --java8_out=./src/main/java/ ./src/main/resources/example.proto 


The source code can be viewed here .



All Articles