One and Done [part 2]: Industry's adoption of self-contained JVM applications

One and Done [part 2]: Industry's adoption of self-contained JVM applications

The art of effortless AWS Lambda function development

Introduction

In the previous article, I briefly introduced tools that enable the creation of simple scripts using languages from the JVM family, focusing on two of them that offer much more: JBang and Scala-CLI. In this article, I would like to showcase a few examples of how these tools can be applied in the industry to simplify the maintenance of existing products and the development of new ones.

Exploring serverless Lambda functions

The aforementioned tools excel in creating simple scripts, command-line applications, basic web applications, and serverless Lambda functions. Let's focus on the latter proposition and examine a few examples.

Creating a Lambda function with JBang

Let's explore a simple JBang example by creating an AWS Lambda function using the freshly-released Java 17 runtime. This demonstrates how we can focus on the handler implementation without sophisticated tools like Maven, Gradle, or SBT, and easily build a deployment package. Dependencies and configurations are declared using directives in the file header.

//usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 17
//DEPS com.amazonaws:aws-lambda-java-core:1.2.2
//DEPS com.amazonaws:aws-lambda-java-events:3.11.1
//SOURCES model/Person.java

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
import java.util.Map;
import model.Person;

public class MyApp implements RequestHandler<Person, APIGatewayProxyResponseEvent> {
    @Override
    public APIGatewayProxyResponseEvent handleRequest(Person person, Context context) {
        context.getLogger().log("Request received: " + person + "\n");

        return new APIGatewayProxyResponseEvent()
                .withStatusCode(200)
                .withHeaders(Map.of("Content-Type", "text/plain"))
                .withBody("Hello " + person.name() + "!");
    }
}

In the example above, we observe an interesting aspect: importing model.Person (Notice also the use of the SOURCES directive). We're not limited to single-file applications and can import additional source files as needed. In Person.java, we feature the record as AWS officially supports Java 17, a blazing hot addition to the platform. The new runtime enables using records as request event models, deserializing them on-the-fly, simplifying implementation, and providing a more direct approach.

package model;

public record Person(String name, int age) {}

Building the JAR

Now, all that remains is to build the JAR, so we can then deploy it (sam deploy --guided) or test it locally (sam local invoke). In the CloudFormation template, we specify the path to Jar (CodeUri), runtime, and point to the handler method. Voilà!

jbang export fatjar MyApp.java

Our template.yml looks as follows.

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: An AWS Serverless Specification template describing your function.
Resources:
  HelloFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: MyApp-fatjar.jar
      Handler: 'MyApp::handleRequest'
      Runtime: java17
      Description: 'Hello Java 17 in the cloud'
      MemorySize: 1769
      Timeout: 15

Testing

Now, let's trigger the Lambda using SAM-CLI, and in response, we'll receive the expected JSON.

echo '{"name": "John Doe", "age": 44}' | sam local invoke --event - "HelloFunction"

Should show:

{
  "statusCode": 200,
  "headers": {
    "Content-Type": "text/plain"
  },
  "body": "Hello John Doe!"
}

A significant part of this paragraph focuses on testing the result using AWS, while the essential implementation was completed in the first step. It's that simple.

Creating a Lambda function with Scala-CLI

As described in the previous paragraph, Scala-CLI enables direct implementation of serverless functions and building deployment packages.

In this case, we don't have to place the record (case class) in a separate file, as Scala allows for multiple classes within a single source file. However, if the model were more complex, we could put it in a separate file and import it as in the previous example. Here, we don't need to use a directive to include the file, as was the case with JBang.

Our entire example application boils down to just one file.

//> using scala "3"
//> using jvm "17"
//> using repository "jitpack"
//> using dep "com.github.lambdaspot:aws-lambda-scala-bridge:0.1.5"
//> using dep "com.amazonaws:aws-lambda-java-core:1.2.2"
//> using dep "com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-core:2.23.0"
//> using dep "com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros:2.23.0"

import com.amazonaws.services.lambda.runtime.Context
import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec
import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker
import dev.lambdaspot.aws.lambda.core.*
import dev.lambdaspot.aws.lambda.events.ApiGatewayProxiedResponse

import scala.util.{Success, Try}

// AWS Lambda function handler
object MyApp extends AwsLambdaEntryPoint:
  override lazy val entryPoint =
    new AwsLambda[PersonDto, ApiGatewayProxiedResponse]:

      override def run(person: PersonDto, context: Context): Try[ApiGatewayProxiedResponse] =
        context.getLogger.log(s"Request received: $person\n")
        Success(
          ApiGatewayProxiedResponse(
            statusCode = 200,
            headers = Map("Content-Type" -> "text/plain"),
            body = Some(s"Hello ${person.name}!")
          )
        )

// Request object
final case class PersonDto(name: String, age: Int)
object PersonDto:
  given JsonValueCodec[PersonDto] = JsonCodecMaker.make

Building the JAR

Now, let's build the JAR. The above code uses Scala-CLI directives, available starting from 1.0.0-RC1.

scala-cli --power package MyApp.scala --assembly --preamble=false

Testing

All set, now we create the CloudFormation template, with only a minor difference compared to the previous paragraph. It concerns the path to the Jar and specifying the handler method.

      CodeUri: MyApp.jar
      Handler: 'MyApp::apply'

Let's now trigger the Lambda using SAM-CLI, following the same steps as in the previous paragraph. We can expect to receive the same JSON response.

Once again, the entire process of creating a serverless application is amazingly straightforward!

Digression: A few words about the aws-lambda-scala-bridge used in the above example. It is a micro-library, or rather a thin wrapper, created specifically for this blog post. It demonstrates how easy it is to create a bridge to use a different JSON serializer than the default Jersey. Here, we use Jsoniter-Scala, which outperforms all available serializers in the JVM, ranking among the top C++ ones. Learn more about Jsoniter-Scala here.

Creating a GraalVM Native Image AWS Lambda function

We will now focus on building a native executable file, which helps reduce the resource consumption of our application and mitigates the issue of Cold Starts. However, we must not forget that using tools like Scala-CLI or JBang, due to their simplicity, is an excellent way to experiment. In this example, we will use Scala-CLI to compile Java code into a native executable file.

Importantly, this example also demonstrates the ability to create simple, multi-file, self-contained applications. We are dealing here with GraalVM Native Image, which requires additional configurations, such as reflect-config.json, etc.

Here is the code for the native Java application, using Scala-CLI. The solution is based on Lambda runtime API.

//> using jvm "graalvm-java17:22.3.2"
//> using dep "com.amazonaws:aws-lambda-java-core:1.2.2"
//> using dep "com.amazonaws:aws-lambda-java-events:3.11.1"
//> using dep "com.amazonaws:aws-lambda-java-runtime-interface-client:2.3.2"
//> using mainClass "com.amazonaws.services.lambda.runtime.api.client.AWSLambda"
//> using resourceDir "../resources"

package helloworld;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.*;

public class MyApp implements RequestHandler<APIGatewayV2HTTPEvent, APIGatewayV2HTTPResponse> {
    @Override
    public APIGatewayV2HTTPResponse handleRequest(APIGatewayV2HTTPEvent event, Context context) {
        return APIGatewayV2HTTPResponse.builder()
                .withStatusCode(200)
                .withBody("Hello world!")
                .build();
    }
}

The above is a "Hello World" AWS Lambda function in Java. What is important is the resources directory, which contains the standard configurations for Native Image.

One surprising thing is the use of ../ instead of ./ when referring to resources. In the case of Scala sources, the latter works. This is also the case according to the documentation. I will create a GitHub issue to confirm whether this is intentional.

Building the executable

Alright, the application is ready. Now it's time to build the image. Do we need to install anything? GraalVM? Native Image?

JBang requires preliminary installation and configuration (GRAALVM_HOME, etc). In the case of Scala-CLI, everything happens automatically, and we just need to run the following command.

scala-cli --power package MyApp.java --native-image -o dist/native

The built executable will land in the dist directory with the name native.

If you're using Linux, the above is enough. If you're using macOS or Windows, you must utilize Docker to build a Linux-based native image. However, there is no need to create a Dockerfile manually, as you can also utilize Scala-CLI's Docker image for this purpose.

docker pull virtuslab/scala-cli
docker run --rm -v $(pwd)/MyApp.java:/MyApp.java -v $(pwd)/dist:/dist -v $(pwd)/resources:/resources virtuslab/scala-cli --power package --native-image -o dist/native /MyApp.java

You can find the files generated in the dist directory.

Testing

Now we need to zip (zip -j dist/package.zip dist/*) the bootstrap and executable file, and we're ready to go.

Testing an AWS Lambda function that uses a custom runtime locally requires some sophisticated steps. To conduct local testing, it is necessary to utilize the AWS Lambda Runtime Interface Emulator, which simulates the AWS Lambda environment and allows for testing of your native functions. We can always upload the zipped package to the cloud using AWS Lambda Console.

Next, invoke the function, and see the result:

{
  "statusCode": 200,
  "body": "Hello world!",
  "isBase64Encoded": false
}

The CloudFormation template stub will be as below. We can then use SAM-CLI and sam deploy --guided.

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: An AWS Serverless Specification template describing your function.
Resources:
  HelloFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: package.zip
      Handler: 'helloworld.MyApp::handleRequest'
      Runtime: provided.al2
      Description: 'Hello GraalVM Java in the cloud'
      MemorySize: 512
      Architectures:
        - x86_64
      Timeout: 3

JBang and Scala-CLI vs Maven

And that's it, so simple. When we compare it to something like Maven, we can see how difficult it is to navigate there. Look for yourself in the repository from which sam init generates a similar Hello World skeleton.

In the example from this article, everything is at our fingertips. The dist/bootstrap file explains how it works in AWS. Please see the necessary directive in the source code.

//> using mainClass "com.amazonaws.services.lambda.runtime.api.client.AWSLambda"

In the case of pom.xml, there is so much noise that you may not immediately notice it. The "main class" above, in conjunction with bootstrap, is crucial regarding the AWS Lambda Java Runtime Interface Client used in the above example.

Okay, we have it! One and done!

Streamlining bug reporting via Github Gists

Examples of using solutions such as Scala-CLI in the industry are plentiful. Another interesting example is the ability to directly run Github Gists. This is useful for quickly exchanging prototypes or discussing functionality between engineers. But it is also a perfect tool for reporting bugs. We can easily provide an example using a specific language version, or a specific JVM version, for instance:

scala-cli run --jvm adoptium:1.20.0.1 https://gist.github.com/baldram/b7a4617fc135f08141e3906ca9adf51f

With JBang:

jbang run https://gist.github.com/baldram/b7a4617fc135f08141e3906ca9adf51f

The above code is an example of integer overflow, and it will compile and run without any warnings.

Compiling project (Java)
Compiled project (Java)
The sum is: -2147483648

Code Examples

You can find the complete code examples from this article on GitHub here.

Conclusion

The article demonstrates how the tools discussed in the previous article have potential applications in the industry beyond education, prototyping, and experimentation. For instance, the article showcases serverless applications. Similarly, other simple applications, whether web-based or command-line tools, can benefit from the straightforward approach offered by JBang or Scala-CLI. These tools can effectively replace traditional build tools, such as Maven, Gradle, or SBT, simplifying routine tasks, reducing boilerplate code, and minimizing complexity, reducing the risk of errors.