Skip to content

Protobuf compiler and runtime for Kotlin

License

Notifications You must be signed in to change notification settings

marcparmet/protokt

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

protokt CircleCI

Protobuf compiler and runtime for Kotlin.

Supports only the Protocol Buffers language version 3.

Features

  • Clean data class generation
  • Oneof types handled as sealed classes
  • JavaDoc comments on generated code
  • Deprecation option pass-through to Kotlin's @Deprecated annotation
  • Protokt-specific options: non-null types, wrapper types, interface implementation, and more
  • Tight integration with Protobuf's Java library: usage of CodedInputStream and CodedOutputStream for best performance

Not yet implemented

  • Support for gRPC service and client generation (In progress!)
  • Kotlin native support (Looking for contributors)
  • Kotlin JS support (Looking for contributors)
  • Protobuf JSON support

See examples in testing.

Usage

Gradle

buildscript {
    dependencies {
        classpath 'com.toasttab.protokt:protokt-gradle-plugin:<version>'
    }
}

apply plugin: 'com.toasttab.protokt'

This will automatically download and install protokt, apply the Google protobuf plugin, and configure all the necessary boilerplate. By default it will also add protokt-core to the api scope of the project. You must explicitly choose to depend on protobuf-java or protobuf-javalite:

dependencies {
    "com.google.protobuf:protobuf-java:3.11.0"
}

or

dependencies {
    "com.google.protobuf:protobuf-javalite:3.11.0"
}

If your project is pure Kotlin you may run into the following error:

Execution failed for task ':compileJava'.
> error: no source files

To work around it, disable all JavaCompile tasks in the project:

tasks.withType(JavaCompile) {
    enabled = false
}

or:

compileJava.enabled = false

Generated Code

Generated code is placed in <buildDir>/generated-sources/main/protokt.

A simple example:

syntax = "proto3";

package com.protokt.sample;

message Sample {
  string sample_field = 1;
}

will produce:

/*
 * Generated by protokt. Do not modify.
 */

package com.protokt.sample

import com.toasttab.protokt.rt.*

@KtGeneratedMessage("com.protokt.sample.Sample")
data class Sample(
    val sampleField: String,
    val unknown: Map<Int, Unknown> = emptyMap()
) : KtMessage {
    @Suppress("UNUSED")
    constructor(
        sampleField: String = ""
    ) : this(
        sampleField,
        emptyMap()
    )

    override val messageSize by lazy { sizeof() }

    override fun serialize(serializer: KtMessageSerializer) {
        if (sampleField.isNotEmpty()) {
            serializer.write(Tag(10)).write(sampleField)
        }
        if (unknown.isNotEmpty()) {
            serializer.writeUnknown(unknown)
        }
    }

    private fun sizeof(): Int {
        var res = 0
        if (sampleField.isNotEmpty()) {
            res += sizeof(Tag(1)) + sizeof(sampleField)
        }
        res += unknown.entries.sumBy { it.value.sizeof() }
        return res
    }

    companion object Deserializer : KtDeserializer<Sample> {
        override fun deserialize(deserializer: KtMessageDeserializer): Sample {
            var sampleField = ""
            val unknown: MutableMap<Int, Unknown>? = null

            while (true) {
                when (deserializer.readTag()) {
                    0 ->
                        return Sample(
                            sampleField,
                            finishMap(unknown)
                        )
                    10 -> sampleField = deserializer.readString()
                    else -> unknown =
                        (unknown ?: mutableMapOf()).also {
                            processUnknown(deserializer, it)
                        }
                }
            }
        }
    }
}

Runtime Notes

Package

The Kotlin package of a generated file is the same as the protobuf package by default. It can be overridden with the (protokt.file).kotlin_package option:

syntax = "proto3";

import "protokt/protokt.proto";

package com.example;

option (protokt.file).kotlin_package = "com.package";

...

Message

Each protokt message implements the KtMessage interface. KtMessage defines the serialize() method and its overloads which can serialize to a byte array, a KtMessageSerializer, or an OutputStream.

Each protokt message has a companion object Deserializer that implements the KtDeserializer interface, which provides the deserialize() method and its overloads to construct an instance of the message from a byte array, a Java InputStream, or others.

In order to enjoy the full benefits of Kotlin data classes, byte arrays are wrapped in the protokt Bytes class, which provides appropriate equals() and hashCode() implementations.

Enums

Enum fields are generated as sealed classes with an integer value and name. They cannot be represented as Kotlin enum classes since Kotlin enum classes are closed and cannot represent unknown values. Protocol buffers require that unknown enum values are preserved for reserialization, so this compromise allows exhaustive case switching while allowing representation of unknown values.

sealed class PhoneType(
    override val value: Int,
    override val name: String
) : KtEnum {
    override fun equals(other: Any?) =
        other is PhoneType && other.value == value

    override fun hashCode() =
        value

    override fun toString() =
        name

    object MOBILE : PhoneType(0, "MOBILE")

    object HOME : PhoneType(1, "HOME")

    object WORK : PhoneType(2, "WORK")

    class UNRECOGNIZED(value: Int) : PhoneType(value, "UNRECOGNIZED")

    companion object Deserializer : KtEnumDeserializer<PhoneType> {
        override fun from(value: Int) =
            when (value) {
                0 -> MOBILE
                1 -> HOME
                2 -> WORK
                else -> UNRECOGNIZED(value)
            }
    }
}

Other Notes

  • optimize_for is ignored.
  • repeated fields are deserialized to Lists.
  • map fields are deserialized to Maps.
  • oneof fields are represented as data class subtypes of a sealed base class with a single property.

Extensions

See examples of each option in the options module. All protokt-specific options require importing protokt/protokt.proto in the protocol file.

Wrapper Types

Sometimes a field on a protobuf message corresponds to a concrete nonprimitive type. In standard protobuf the user would be responsible for this extra transformation, but the protokt wrapper type option allows specification of a converter that will automatically encode and decode custom types to protobuf primitives and well-known types. Some standard types are implemented in extensions.

Wrap a field by invoking the (protokt.property).wrap option:

message DateWrapperMessage {
  google.protobuf.Timestamp instant = 1 [
    (protokt.property).wrap = "java.time.Instant"
  ];
}

Converters implement the Converter interface:

interface Converter<S: Any, T: Any> {
    val wrapper: KClass<S>

    fun wrap(unwrapped: T): S

    fun unwrap(wrapped: S): T
}

and protokt will reference the converter's methods to wrap and unwrap from protobuf primitives:

object InstantConverter : Converter<Instant, Timestamp> {
    override val wrapper = Instant::class

    override fun wrap(unwrapped: Timestamp): Instant =
        Instant.ofEpochSecond(unwrapped.seconds, unwrapped.nanos.toLong())

    override fun unwrap(wrapped: Instant) =
        Timestamp(wrapped.epochSecond, wrapped.nano)
}
data class WrapperModel(
    val instant: java.time.Instant?,
    ...
) : KtMessage {
    ...
    override fun serialize(serializer: KtMessageSerializer) {
        if (instant != null) {
            serializer.write(Tag(42)).write(InstantConverter.unwrap(instant))
        }
        ...
    }

    override fun deserialize(deserializer: KtMessageDeserializer): WrapperModel {
        var instant: java.time.Instant? = null
        ...
        while (true) {
            when (deserializer.readTag()) {
                0 ->
                    return WrapperModel(
                        instant
                        ...
                    )
                8 -> instant =
                        InstantConverter.wrap(deserializer.readMessage(com.toasttab.protokt.Timestamp))
                ...
            }
        }
    }
}

Each converter must be registered in a META-INF/services/com.toasttab.protokt.ext.Converter classpath resource following the standard ServiceLoader convention.

Converters can also implement the OptimizedSizeofConverter interface adding sizeof(), which allows them to optimize the calculation of the wrapper's size rather than unwrap the object twice. For example, a UUID is always 16 bytes:

object UuidConverter : OptimizedSizeofConverter<UUID, ByteArray> {
    override val wrapper = UUID::class

    private val sizeofProxy = ByteArray(16)

    override fun sizeof(wrapped: UUID) =
        sizeof(sizeofProxy)

    override fun wrap(unwrapped: ByteArray): UUID {
        require(unwrapped.size == 16) {
            "UUID source must have size 16; had ${unwrapped.size}"
        }

        return ByteBuffer.wrap(unwrapped)
            .run { UUID(long, long) }
    }

    override fun unwrap(wrapped: UUID) =
        ByteBuffer.allocate(16)
            .putLong(wrapped.mostSignificantBits)
            .putLong(wrapped.leastSignificantBits)
            .array()
}

Rather than convert a UUID to a byte array both for size calculation and for serialization (which is what a naïve implementation would do), UuidConverter always returns the size of a constant 16-byte array.

If the wrapper type is in the same package as the generated protobuf message, then it does not need a fully-qualified name. Custom wrapper type converters can be in the same module as protobuf types that reference them. In order to use any wrapper type defined in extensions, the module must be included as a dependency:

dependencies {
    implementation 'com.toasttab.protokt:protokt-extensions:<version>'
}

Wrapper types that wrap protobuf messages are nullable. For example, java.time.Instant wraps the well-known type google.protobuf.Timestamp. They can be made non-nullable by using the non-null option described below.

Wrapper types that wrap protobuf primitives, for example java.util.UUID which wraps bytes, are not nullable and may present malformed inputs to converters when absent in deserialization. It is up to the converter to determine what behavior should be in these cases. To represent a nullable UUID use UuidValue defined in protokt-extensions-wrappers.

Wrapper types should be immutable.

Interface implementation

Messages

To avoid the need to create domain-specific objects from protobuf messages you can declare that a protobuf message implements a custom interface with properties and default methods.

package com.protokt.sample

interface Model {
    val id: String
}
package com.protokt.sample;

message ImplementsSampleMessage {
  option (protokt.class).implements = "Model";

  string id = 1;
}

If the wrapper interface is in the same package as the generated protobuf message, then it does not need a fully-qualified name. Wrapper interfaces cannot be used by protobuf messages in the same module that defines them; the dependency must be declared withprotoktExtensions in build.gradle:

dependencies {
    protoktExtensions project(':api-module')
}

Messages can also implement interfaces by delegating to one of their fields; in this case the delegated interface need not live in a separate module, as protokt requires no inspection of the implemented interface:

message ImplementsWithDelegate {
  option (protokt.class).implements = "Model2 by modelTwo";

  ImplementsModel2 model_two = 1 [
    (protokt.property).non_null = true
  ];
}

Note that the by clause references the field by its lower camel case name.

Oneof Fields

Oneof fields can declare that they implement an interface with the (protokt.oneof).implements option. Each possible field of the oneof must also implement the interface as a Message above. This allows access of common properties without a when statement that always ultimately extracts the same property.

Nonnull fields

If there is a message that has no meaning whatsoever when a particular field is missing, you can emulate proto2's required key word by using the (protokt.property).non_null option:

message Sample {
}

message NonNullSampleMessage {
  Sample non_null_sample = 1 [
    (protokt.property).non_null = true
  ];
}

Generated code will not have a nullable type so the field can be referenced without using Kotlin's !!.

Oneof fields can also be declared non-null:

message NonNullSampleMessage {
  oneof non_null_oneof {
    option (protokt.oneof).non_null = true;

    string message = 2;
  }
}

Note that deserialization of a message with a non-nullable field will fail if the message being decoded does not contain an instance of the required field.

BytesSlice

When reading messages that contain other serialized messages as bytes fields, protokt can keep a reference to the originating byte array to prevent a large copy operation on deserialization. This can be desirable when the wrapping message is a thin metadata shim and doesn't include much memory overhead:

message SliceModel {
  int64 version = 1;

  bytes encoded_message = 2 [
    (protokt.property).bytes_slice = true
  ];
}
IntelliJ integration

If IntelliJ doesn't automatically detect the generated files as source files, you may be missing the idea plugin. Apply the idea plugin to your Gradle project:

plugins {
    id 'idea'
}

Command line code generation

protokt$ ./gradlew assemble

protokt$ protoc \
    --plugin=protoc-gen-custom=protokt-codegen/build/install/protoc-gen-protokt/bin/protoc-gen-protokt \
    --custom_out=<output-directory> \
    -I<path-to-proto-file-containing-directory> \
    -Iprotokt-runtime/src/main/resources \
    <path-to-proto-file>.proto

For example, to generate files in protokt/foo from a file called test.proto located at protokt/test.proto:

protokt$ protoc \
    --plugin=protoc-gen-custom=protokt-codegen/build/install/protoc-gen-protokt/bin/protoc-gen-protokt \
    --custom_out=foo \
    -I. \
    -Iprotokt-runtime/src/main/resources \
    test.proto

Contribution

To enable rapid development of the code generator, the protobuf conformance tests have been compiled and included in the gradle-plugin-integration-testing project. They run on Mac OS 10.14+ and Ubuntu 16.04 x86-64 as part of normal Gradle builds.

When integration testing the Gradle plugin, note that after changing the plugin and republishing it to the integration repository, ./gradlew clean is needed to trigger regeneration of the protobuf files with the fresh plugin.

About

Protobuf compiler and runtime for Kotlin

Resources

License

Code of conduct

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Kotlin 98.7%
  • Shell 1.3%