In my last post, I mentioned the idea of writing my own serverless wrapper, and immediately dismissed it. But as is so often the case, the idea hung around in my brain, an itch I really wanted to scratch. So over the past couple of days I've been looking at various parts of serverless functions (specifically with AWS Lambda), and how server frameworks work behind the scenes. I saw mention of a new web/API framework called Pellet, which doesn't do a great deal yet, but its very simplicity is a benefit as I work out how such systems work.
But first, I needed to better understand how AWS Lambda functions work, and so I've been experimenting with creating pure functions using the AWS Lambda API directly. From the samples on the AWS documentation I've managed to write a basic Lambda function. I've used the most generic RequestStreamHandler interface, as it seems to be the most powerful, and other frameworks like Kotless also start with this. Stripping the Kotless code to the bare minimum, we have something like:
override fun handleRequest(input: InputStream, output: OutputStream,
@Suppress("UNUSED_PARAMETER") any: Context?) {
val response: HttpResponse? = try {
runBlocking {
// GET THE JSON REQUEST FROM AWS INPUT STREAM
val json = input.bufferedReader().use { it.readText() }
if (json.contains("Scheduled Event")) {
// IS IT A SCHEDULED EVENT - in this case, Kotless is using AWS EventBridge
// to "keep warm" the lambda function - essentially poking it every 5
// minutes so the Lambda doesn't go to sleep
// .... code cropped ....
}
// ELSE, ASSUME IT'S A PROPER HTTP REQUEST
logger.info("Request is HTTP Event")
val request = JSON.parse(AwsHttpRequest.serializer(), json)
// FINALLY, PASS THE REQUEST TO THE KTOR ENGINE TO PROCESS
// - for Ktor routes, authentication, etc etc.
val call = KotlessCall(engine.application, request.toRequest())
engine.pipeline.execute(call)
// BUILD THE RESPONSE
call.response.toHttp()
}
} catch (e: Throwable) {
logger.error("Error occurred during handle of request and was not caught", e)
null
}
// IF THERE'S A RESPONSE, SEND IT TO AWS OUTPUT STREAM
if (response != null) {
output.write(JSON.bytes(HttpResponse.serializer(), response))
} else {
logger.info("Got null response")
}
// AND WE'RE DONE
logger.info("Ended handling request")
}
The key lines from Kotless are:
val call = KotlessCall(engine.application, request.toRequest())
engine.pipeline.execute(call)
Having read the request from AWS Lambda, which is most likely a JSON string coming via AWS API Gateway, Kotless converts the request into something that Ktor can handle, and executes a call to Ktor. Ktor is responsible for parsing the route (e.g. interpreting the request path /users/{id}/name
to fetch the name for a user with the given id
, for instance), checking authentication, and doing whatever else Ktor does.
From that, and from comparing it with the code from Pellet, we can determine that a lot of a web/API framework like Ktor or Pellet simply isn't needed when running from an AWS Lambda environment. Strip away all the HTTP protocols, the need for an engine like Netty or Tomcat, stop worrying about performance and throughput. From that, I started to wonder what Ktor or Pellet actually provide.
Well, for a start, they provide a nicer development environment, a nicer way of coding. When your API handles dozens of routes, the simplicity of the Ktor approach beats all the JSON wrangling and endless when
statements you'd need to write in pure AWS Lambda land.
For instance, something like this:
// Ktor
get("/users/{id}/name") {
val user: User? = db.getUserById(call.parameters["id"])
call.respond(user.name)
}
Is surely nicer than something like:
// AWS Lambda RequestStreamHandler
val json = input.bufferedReader().use { it.readText() }
val request = JSON.parse(AwsHttpRequest.serializer(), json)
val requestPath = request.path
val parsedRequestPathComponents = parseRequestPath(requestPath)
when (parsedRequestPathComponents[0]) {
"users" -> {
val id = Integer.parseInt(parsedRequestPathComponents[1])
// and so on and so on....
val user: User? = db.getUserById(id)
// ... now serialise the User back to Json
// then write it back to the AWS Output Stream...
}
// and repeat for other routes...
}
There's a lot of boilerplate code in the AWS Lambda approach, a lot of code duplication, a lot of extra hand-holding required. Ktor, Pellet, and others like http4k all abstract and handle this away, to a greater or lesser extent.
One downside of the Ktor/Kotless approach is the sheer size of the code it generates - the simplest Ktor route, a Hello World message, requires a 30Mb+ JAR file uploaded to AWS. I wonder how much of that is because of the bundling of unnecessary parts of the Ktor engine? It got me thinking if I could take a smaller project, like Pellet, strip out all the parts that I don't need and just keep the routing, etc, could I make a smaller, AWS Lambda focused Web/API "serverless server"?
I'd still need to find a way to tie everything together - something Ktor and Osiris do with Terraform, and http4k does through Pulumi, but maybe I can be a bit more opinionated. Maybe I only target AWS Lambda, rather than a range of serverless cloud providers? Maybe I come up with my own set of sensible defaults, which suite just my needs?
Further thought, and more experiments, required.