Hi all,
I came up with an idea for a convenient and safe remote procedure calling for Kotlin multiplatform. I’d like to share a PoC of a library and gather some feedback.
Description
It’s an RPC solution optimized for a case where we use 100% Kotlin (both client and server). Such assumption allows for some extra benefits described below. This approach is also optimized for cases where what goes “over the wire” is not that important and can remain an implementation detail. The goal is to type as little as possible, and have as much build-time safety as possible. So far I tested it with quickly implementing PoCs of two very simple services and I also noticed the speed of iteration is pretty nice.
Library: GitHub - krzema12/kotlin-rpc: Kotlin-centric, multiplatform-enabled approach to RPC.
Example: GitHub - krzema12/kotlin-rpc-example: Example for https://github.com/krzema12/kotlin-rpc
Features:
the service definition is described as a Kotlin interface. No new DSL like in gRPC or OpenAPI. It eliminates a need to learn another language, enables IDE support out of the box, allows reusing data types from Kotlin multiplatform libraries, and more
designed to provide build-time safety. Built on top of kotlinx.serialization used the right way, it won’t let the project compile in case some data types are not @Serializable
boilerplate-free. Uses code generation to hide all the repetitive stuff, yet the code is readable and inspectable so it’s clear what happens under the hood (no compiler plugin intentionally)
simple. Not super-generic and uber-customizable. Focuses to get your call from one side to the other, with minimal effort
non-blocking. Promotes coroutines by enforcing suspending functions in the interface.
(in the future) designed to provide clients for JS, Android, iOS and any other platform Kotlin allows. I also plan to support a use case where the medium is not HTTP, but Web workers: calling the worker from the main thread. For now, only the “server -JVM, client - JS” scenario is implemented
Areas of improvement:
integration is not super-easy. I tried to encapsulate it in a Gradle plugin, but failed to do so within reasonable time. I decided to ask for feedback even if integration is not simple. The task for the Gradle plugin is tracked as Gradle plugin · Issue #13 · krzema12/kotlin-rpc · GitHub
there’s a known problem with the moment where code generation occurs. Currently to overcome it, the service definition needs to be placed in a separate Gradle module and it has to be built explicitly first. I think I understand why it happens, but I don’t have a clear path how to fix it. Tracked as First building of API module doesn't compile generated code · Issue #7 · krzema12/kotlin-rpc · GitHub
exception serialization is buggy, see Problem with exception serialization with coroutines · Issue #29 · krzema12/kotlin-rpc · GitHub
lacks possibility to customize the client, like authentication, retry strategy…
…and probably many more. Definitely not ready to use in production, but ready to give it a try to get the overall look and feel.
Example with walk-through
Click to show
Based on GitHub - krzema12/kotlin-rpc-example: Example for https://github.com/krzema12/kotlin-rpc.
First, let’s see the project’s structure - it’s actually pretty simple. Omitting not important files, we have just 3 Kotlin files (API, server, and client) plus build logic that is also relevant here:
$ tree
.
├── api
│ ├── build.gradle.kts
│ └── src
│ └── commonMain
│ └── kotlin
│ └── TodoAppApi.kt
├── build.gradle.kts
└── src
├── jsMain
│ └── kotlin
│ └── client.kt
└── jvmMain
└── kotlin
└── server.kt
I’m omitting description of configuring the build system because it will likely become much simpler, and it’s already described in the README of GitHub - krzema12/kotlin-rpc: Kotlin-centric, multiplatform-enabled approach to RPC..
We start with defining the service interface (TodoAppApi.kt
). It contains an interface with two methods that are forced to be suspending, to promote non-blocking calls:
interface TodoAppApi {
suspend fun listTodos(): List<Todo>
suspend fun addToList(description: String): List<Todo>
}
Todo
has to be marked as @Serializable
, otherwise generating the code for this interface (either client or server) will fail and will make the api
module building fail as well:
@Serializable
data class Todo(
val description: String,
val isDone: Boolean,
val assignee: String? = null,
)
The structures can have arbitrary depth, as long as they’re serializable in the understanding of kotlinx.serialization.
Now, to to implement the server part, code generation provides us with such Ktor handlers, ready to be placed in routing
DSL part of Ktor. URL pieces are generated based on method names in the interface. POST is used for simplicity:
fun Routing.todoAppApiKtorHandlers(todoAppApiImpl: it.krzeminski.todoapp.api.TodoAppApi) {
route("api/") {
post("/addToList") {
val bodyAsString = call.receiveText()
val body = Json.decodeFromString<AddToListRequest>(bodyAsString)
try {
val implResponse = todoAppApiImpl.addToList(
description = body.description,
)
val implResponseWrapped = AddToListResponse(returnValue = implResponse)
val kotlinRpcResponse = KotlinRpcResponse(body = Json.encodeToString(AddToListResponse.serializer(), implResponseWrapped))
call.respond(HttpStatusCode.OK, Json.encodeToString(kotlinRpcResponse))
} catch (e: Exception) {
val kotlinRpcResponse = KotlinRpcResponse(exception = e.message)
call.respond(HttpStatusCode.InternalServerError, Json.encodeToString(kotlinRpcResponse))
}
}
post("/listTodos") {
try {
val implResponse = todoAppApiImpl.listTodos()
val implResponseWrapped = ListTodosResponse(returnValue = implResponse)
val kotlinRpcResponse = KotlinRpcResponse(body = Json.encodeToString(ListTodosResponse.serializer(), implResponseWrapped))
call.respond(HttpStatusCode.OK, Json.encodeToString(kotlinRpcResponse))
} catch (e: Exception) {
val kotlinRpcResponse = KotlinRpcResponse(exception = e.message)
call.respond(HttpStatusCode.InternalServerError, Json.encodeToString(kotlinRpcResponse))
}
}
}
}
@Serializable
data class AddToListRequest(
val description: kotlin.String,
)
@Serializable
data class AddToListResponse(
val returnValue: kotlin.collections.List<it.krzeminski.todoapp.api.Todo>,
)
@Serializable
data class ListTodosResponse(
val returnValue: kotlin.collections.List<it.krzeminski.todoapp.api.Todo>,
)
and it’s used from within Ktor DSL like this (server.kt
):
embeddedServer(Netty, port = 8080, host = "127.0.0.1") {
routing {
todoAppApiKtorHandlers(todoAppApiImpl) // Function generated by kotlin-rpc
}
}.start(wait = true)
where todoAppApiImpl
just implements the interface defined at the very beginning, as if there was no network calls. The whole burden of serialization and deserialization is hidden in todoAppApiKtorHandlers
. What’s exposed to the user is the very minimum.
From the JS client’s side (client.kt
), e.g. fetching TODOs is as simple as creating an instance of the client generated by kotlin-rpc:
class TodoAppApiJsClient(private val url: String, private val coroutineContext: CoroutineContext) : it.krzeminski.todoapp.api.TodoAppApi {
@Serializable
data class AddToListRequest(
val description: kotlin.String,
)
@Serializable
data class AddToListResponse(
val returnValue: kotlin.collections.List<it.krzeminski.todoapp.api.Todo>,
)
override suspend fun addToList(
description: kotlin.String,
): kotlin.collections.List<it.krzeminski.todoapp.api.Todo> {
val requestBody = AddToListRequest(
description,
)
val requestBodyAsString = Json.encodeToString(AddToListRequest.serializer(), requestBody)
val responseAsString = post("$url/api/addToList", requestBodyAsString)
val response = Json.decodeFromString<KotlinRpcResponse>(responseAsString)
if (response.exception != null) {
// For now, only top-level message is serialized.
val exceptionMessage = response.exception
throw RuntimeException(exceptionMessage)
}
val responseBody = Json.decodeFromString<AddToListResponse>(response.body ?: throw RuntimeException("Shouldn't happen - exception or body should be present!"))
return responseBody.returnValue
}
@Serializable
data class ListTodosResponse(
val returnValue: kotlin.collections.List<it.krzeminski.todoapp.api.Todo>,
)
override suspend fun listTodos(): kotlin.collections.List<it.krzeminski.todoapp.api.Todo> {
val requestBodyAsString = ""
val responseAsString = post("$url/api/listTodos", requestBodyAsString)
val response = Json.decodeFromString<KotlinRpcResponse>(responseAsString)
if (response.exception != null) {
// For now, only top-level message is serialized.
val exceptionMessage = response.exception
throw RuntimeException(exceptionMessage)
}
val responseBody = Json.decodeFromString<ListTodosResponse>(response.body ?: throw RuntimeException("Shouldn't happen - exception or body should be present!"))
return responseBody.returnValue
}
private suspend fun post(url: String, body: String): String {
return withContext(coroutineContext) {
val response = window.fetch(
url,
RequestInit(
"POST",
headers = json(
"Accept" to "application/json",
"Content-Type" to "application/json"
),
body = body
)
).await()
response.text().await()
}
}
}
with providing the server endpoint, and calling desired function declared in the interface:
with(TodoAppApiJsClient(url = "http://localhost:8080", coroutineContext)) {
launch {
val fetchedTodos = listTodos()
// do something with them
}
}
Again, serialization of the request and deserialization of the response is put in the generated code.
Feedback request
Please let me know what you think about the general idea, if it’s worth working on further, and if you would be keen to give it a try in some evening project. I’m especially interested about comparing it with gRPC, OpenAPI or other similar solutions. I barely touched these libraries and I don’t have real-life experience with them. However, I needed something simple and compatible with Kotlin from both frontend and backend side, hence the idea for the library.
Thanks,
Piotr