Summary: Dive into serverless with Clojure. We explore ways to use Clojure instead of Java to implement AWS Lambda functions. Following the official AWS documentation, we first implement “Hello Worlds” using Java which we then emulate in Clojure. Pure Java-only developers can skip the Clojure part, and Clojure developers may learn more about Java interop.
- What is AWS Lambda?
- Requirements
- Configure permissions
- Implement Lambda function handler
- Summary
- References
What is AWS Lambda?
AWS is a serverless computing platform that runs code in response to events and automatically manages the computing resources that the execution of the code requires 1. In other words, with AWS Lambda you can execute Clojure (or other languages) code without the hassle of setting up a server environment. Just assemble and upload a JAR file of your code, and you are ready to go.
The word “serverless” is a bit of a misnomer. There are still servers running in the background, but they are entirely hidden.
Requirements
You are going to need an account at AWS and have the AWS CLI tool installed. Please refer to the official documentation on how to do that.
Source code
Available as usual on Github2. Clone that or follow along coding everything yourself. The easiest way is to use leiningen new clojure-aws-lambda
with two source folders: src/clojure
and src/java
.
Configure permissions
Before we can execute Lambda functions, we need to set up the permissions and create a role using IAM. Following JSON grants a basic trust relationship with our lambda functions.
Using the AWS CLI we can create a new role:
aws iam create-role \
--role-name basic_lambda_role \
--assume-role-policy-document fileb://resources/trust_relationship.json
Observe the response:
ROLE arn:aws:iam::248689245702:role/basic_lambda_role 2018-10-01T14:39:50Z / AROAISQSNKXPE4G4F4GFE basic_lambda_role
ASSUMEROLEPOLICYDOCUMENT 2012-10-17
STATEMENT sts:AssumeRole Allow
PRINCIPAL lambda.amazonaws.com
AWS returns the ID of the role arn:aws:iam::248689245702:role/basic_lambda_role
. Keep note or use aws iam list-roles
to retrieve it later.
Next, we need to attach a policy (we use default policy provided by AWS, the ID is arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
)
aws iam attach-role-policy \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole \
--role-name basic_lambda_role
Implement Lambda function handler
The way to run Clojure on Lambda is to emulate the Java approach.
AWS supports two ways for creating a handler on the Java platform3:
- Implement the function method directly using plain Java 4 without using an interface.
- Implement the function by leveraging a predefined interface (
RequestHandler
orRequestStreamHandler
)5.
1. Implement directly without using an interface
1.1 Using plain Java
public class PlainJavaHandler {
public String myHandler(String input, Context context) {
return String.valueOf("Hello " + input.get("firstName") + " " + input.get("lastName"));
}
}
To create a new Lambda function, we need to build our project and upload a jar file containing all the dependencies. We are using Leiningen, the Clojure build tool, to compile all the Java and Clojure code.
To create the jar file, run lein uberjar
now.
This creates a new jar file target/clojure-aws-lambda-0.1.0-SNAPSHOT-standalone.jar
which we can upload to AWS. (Note: this jar contains the code for the handler for all of the Java and Clojure cases. In a real project, we would not pack everything into one jar file; we do it here for the sake of simplicity).
Create function using the AWS CLI (for a list of commands see 6)
aws lambda create-function \
--function-name PlainJavaHandler \
--runtime java8 \
--role arn:aws:iam::248689245702:role/basic_lambda_role \
--handler javahandler.PlainJavaHandler::myHandler \
--zip-file fileb://target/clojure-aws-lambda-0.1.0-SNAPSHOT-standalone.jar \
--region us-east-1
The name of the handler follows this schema: <package>::<method>
Note: use the proper values for region and add --profile <NAME>
in case you have created a dedicated user for lambda.
We are going to test this function using the following input:
"World"
We can use the invoke
command of the CLI to send the content of this file to the lambda function.
aws lambda invoke \
--function-name PlainJavaHandler \
--payload fileb://resources/string-input.txt \
out.txt
The response is captured in out.txt
.
$ cat out.txt
"Hello World!"
It works.
Direct approach Clojure version
Now let’s implement the same handler in Clojure. The Clojure code has to generate a class file with similar properties.
Ahead of time compiling
Clojure compiles all code on the fly into JVM bytecode which will be loaded dynamically. However, to execute the code on Lambda platform, we need to enable ahead-of-time compiling 7, so we can use the gen-class
macro 8 to create the Java classes from our Clojure code.
We enable AOT in our project definition using the :aot :all
keywords:
(defproject clojure-aws-lambda "0.1.0-SNAPSHOT"
:source-paths ["src/clojure"]
:java-source-paths ["src/java"]
:dependencies [[org.clojure/clojure "1.8.0"]
[com.amazonaws/aws-lambda-java-core "1.1.0"]]
:aot :all)
Now we can implement the Clojure code:
(ns clojurehandler.plain_clojure_handler
(:gen-class
:name "clojurehandler.PlainClojureHandler"
:methods [[myhandler [String com.amazonaws.services.lambda.runtime.Context] String]]))
(defn -myhandler [this s ctx]
(str "Hello" s "!"))
Notice the function signature. We need to pass in a self-reference this
due to how the Clojure compiler generates the Java classes.
We can now build and deploy the jar and create a new lambda function which we name PlainClojureHandler
:
aws lambda create-function \
--function-name PlainClojureHandler \
--runtime java8 \
--memory-size 256 \
--role arn:aws:iam::248689245702:role/basic_lambda_role \
--handler clojurehandler.PlainClojureHandler::myhandler \
--zip-file fileb://target/clojure-aws-lambda-0.1.0-SNAPSHOT-standalone.jar \
--region us-east-1
Note: To make this work, I needed to increase the memory-size
of the lambda function to 256 MB (the default memory size is 128mb, which leads to a java.lang.OutOfMemoryError: Metaspace
error).
Now we can invoke the function using our test file that contains the simple string:
aws lambda invoke \
--function-name PlainClojureHandler \
--payload fileb://resources/string-input.txt \
out.txt
The response is again captured in out.txt
$ cat out.txt
"Hello World!"
1.2 Implement using POJOs
Instead of using primitive input, we can use a POJO as request and response parameter.
package javahandler;
public class Request {
private String firstName;
private String lastName;
public Request(){}
public Request(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
/** ... getter and setter omitted ... **/
}
}
package javahandler;
public class Response {
private String hello;
public Response(){}
public Response(String hello) {
this.hello = hello;
}
/** getter and setter omitted.... **/
}
Now our handler function uses POJO classes in the method signature: Response myHandler(Request input, Context context)
:
package javahandler;
import com.amazonaws.services.lambda.runtime.Context;
public class POJOJavaHandler {
public Response myHandler(Request input, Context context) {
final Response response = new Response();
response.setHello("Hello " + input.getFirstName() + " " + input.getLastName() +"!");
return response;
}
}
With this in place, we can send JSON input to our lambda function that AWS will automatically (de-)serialise:
{
"firstName": "John",
"lastName": "Smith"
}
Creating a new function that we call POJOHJavaHandler
(just replace --function-name
and --handler
:
aws lambda create-function \
--function-name POJOJavaHandler \
--runtime java8 \
--role arn:aws:iam::248689245702:role/basic_lambda_role \
--handler javahandler.POJOJavaHandler::myHandler \
--zip-file fileb://target/clojure-aws-lambda-0.1.0-SNAPSHOT-standalone.jar \
--region us-east-1
Let’s test this function, sending the JSON to the function:
aws lambda invoke \
--function-name POJOJavaHandler \
--payload fileb://resources/test-input.json \
out.txt
Have a look at the response:
$ cat out.txt
{"hello":"Hello John Smith!"}
Note: we could have used JSON in the plain Java approach as well. However, AWS does not serialise JSON into a String. Instead, the input will be a Map<String, Object>
. As an exercise, you might want to try to modify the plain Java version and send the JSON file as input.
POJO Clojure version
The natural choice in Clojure to implement a POJO class should be using a defrecord
9.
(ns clojurehandler.pojo_clojure_handler)
(defrecord Request [firstname lastname])
(defrecord Response [hello])
(gen-class
:name "clojurehandler.POJOClojureHandler"
:methods [[myhandler [clojurehandler.pojo_clojure_handler.Request com.amazonaws.services.lambda.runtime.Context] clojurehandler.pojo_clojure_handler.Response]])
(defn -myhandler [this request ctx]
(Response. (str "Hello " (:firstname request) " " (:lastname request) "!")))
Unfortunately, though this compiles it won’t work. AWS uses Jackson to serialise JSON requests which requires the empty default constructor of our Request
and Response
classes. In the immutable world of Clojure, a defrecord
won’t generate such constructor for us. We can’t use them.
We could use gen-class
to generate the POJOs. However, that would feel awkward. We’d have to create getters and setters and so on. Instead, we can “cheat” here and use the Java POJOs that we have already defined. This does make sense as POJOs don’t belong to Clojure’s world.
(ns clojurehandler.pojo_clojure_handler
(:import (javahandler Request Response)))
(gen-class
:name "clojurehandler.POJOClojureHandler"
:methods [[myhandler [javahandler.Request com.amazonaws.services.lambda.runtime.Context] javahandler.Response]])
(defn -myhandler [this request ctx]
(Response. (str "Hello " (.getFirstName request) " " (.getLastName request) "!")))
(Note: I still had to fully qualify the Request
and Response
classes in gen-class
, thus actually not using the import of Request.)
Let’s create the function (remember to provide extra memory):
aws lambda create-function \
--function-name POJOClojureHandler \
--runtime java8 \
--memory 256 \
--role arn:aws:iam::248689245702:role/basic_lambda_role \
--handler clojurehandler.POJOClojureHandler::myhandler \
--zip-file fileb://target/clojure-aws-lambda-0.1.0-SNAPSHOT-standalone.jar \
--region us-east-1
aws lambda invoke \
--function-name POJOClojureHandler \
--payload fileb://resources/test-input.json \
out.txt
Have a look at the output to confirm it worked:
$ cat out.txt
{"hello":"Hello John Smith!"}
2. Implement a predefined interface
2.1 RequestHandler
interface
Instead of relying on the input and output parameter of our handler method, we can leverage the RequestHandler
interface of the AWS sdk:
package javahandler;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
public class RHJavaHandler implements RequestHandler<Request, Response> {
@Override
public Response handleRequest(Request input, Context context) {
final Response response = new Response();
response.setHello("Hello " + input.getFirstName() + " " + input.getLastName() +"!");
return response;
}
}
This does the same as the Java POJO example from above. When creating this function, we only need to specify the class name and can omit the function name: javahandler.RHJavanHandler
aws lambda create-function \
--function-name RHJavaHandler \
--runtime java8 \
--role arn:aws:iam::248689245702:role/basic_lambda_role \
--handler javahandler.RHJavaHandler \
--zip-file fileb://target/clojure-aws-lambda-0.1.0-SNAPSHOT-standalone.jar \
--region us-east-1
I’m skipping the test, re-use the command from above replacing the function name.
RequestHandler
Clojure version
The straightforward solution in Clojure should be using the gen-class
macro to implement the RequestHandler
interface. Like this:
(ns clojurehandler.rh-clojure-handler
(:import (javahandler Request Response)))
(gen-class
:name "clojurehandler.RHClojureHandler"
:implements [com.amazonaws.services.lambda.runtime.RequestHandler])
(defn -handleRequest [this request ctx]
(Response. (str "Hello " (.getFirstName request) " " (.getLastName request) "!")))
Unfortunately, at the time of writing (and to my knowledge) Clojure does not handle concrete type parameter of generic interfaces. Though the code compiles fine, deploying and running results in following error message: Class clojurehandler.RHClojureHandler does not implement RequestHandler with concrete type parameter
.
It seems we can’t follow this approach with Clojure. We could provide an implementation in Java with concrete type parameters and use that in Clojure, but this becomes again somewhat awkward (even more since solution 1.2 works in Clojure, we don’t need that interface). Let’s move on to the final approach, taking advantage of streaming.
2.2 RequestStreamHandler
interface
package javahandler;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestStreamHandler;import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
public class RSHJavaHandler implements RequestStreamHandler {
@Override
public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) throws IOException {
String name;
try (Scanner scanner = new Scanner(inputStream, StandardCharsets.UTF_8.name())) { name = scanner.useDelimiter("\\A").next();
}
outputStream.write(("Hello " + name + "!").getBytes());
}
}
This is the most flexible approach. We need to convert the inputstream and then write to the outputstream. Test this by sending the test input containing the string (string-input.txt
, though this will contain the quotationmarks in the result).
If we want to send JSON we need to do the deserialisation in the function method.
RequestStreamHandler
Clojure version
The Clojure version can be implemented straight away without issues:
(ns clojurehandler.rsh_clojure_handler
(:require [clojure.java.io :as io]))
(gen-class
:name "clojurehandler.RSHClojureHandler"
:implements [com.amazonaws.services.lambda.runtime.RequestStreamHandler])
(defn -handleRequest [this input output ctx]
(let [name (slurp input)]
(with-open [o (io/writer output)]
(.write o (str "Hello " name "!")))))
Summary
Using Clojure on AWS Lambda has its quirks. As demonstrated above, it is not always a straightforward process to replace Java with Clojure. The lacking support of concrete type parameters for generic interfaces and the immutability of types require some workarounds to use Clojure for implementing Lambda functions. The best ways are either implementing the RequestStreamHandler
(2.2) or using the direct approach with standard (1.1) or custom types (1.2).
Taking all together, it feels slightly forced to use Clojure, making it a valid point to say that it might not (yet!) be the time to use it on AWS Lambda. Even less so that it seems necessary to allocate additional memory to execute the bytecode generated by the Clojure compiler, generating additional cost.
For now, a more practical approach is to implement Lambda functions in Java and keep the backend in Clojure.