Mercury version 3 is a toolkit for event-driven programming that is the foundation
for composable application.
At the platform level, composable architecture refers to loosely coupled platform services, utilities, and business applications. With modular design, you can assemble platform components and applications to create new use cases or to adjust for ever-changing business environment and requirements. Domain driven design (DDD), Command Query Responsibility Segregation (CQRS) and Microservices patterns are the popular tools that architects use to build composable architecture. You may deploy application in container, serverless or other means.
At the application level, a composable application means that an application is assembled from modular software components or functions that are self-contained and pluggable. You can mix-n-match functions to form new applications. You can retire outdated functions without adverse side effect to a production system. Multiple versions of a function can exist, and you can decide how to route user requests to different versions of a function. Applications would be easier to design, develop, maintain, deploy, and scale.
While you can write a composable application using event-driven programming, the best way to build a composable application is a declarative approach where event choreography of self-contained functions is performed by an event manager. Declarative approach for building composable applications is shown in:
Mercury v4: https://github.com/Accenture/mercury-composable
Documentation: https://accenture.github.io/mercury-composable/
Figure 1 - Composable application architecture
As shown in Figure 1, a minimalist composable application consists of three user defined components:
- Main modules that provides an entry point to your application
- One or more business logic modules (shown as "function-1" to "function-3" in the diagram)
- An event orchestration module to command the business logic modules to work together as an application
and a composable event engine that provides:
- REST automation
- An in-memory event system (aka "event loop")
- Local pub/sub system
Each application has an entry point. You may implement an entry point in a main application like this:
@MainApplication
public class MainApp implements EntryPoint {
public static void main(String[] args) {
AutoStart.main(args);
}
@Override
public void start(String[] args) {
// your startup logic here
log.info("Started");
}
}
For a command line use case, your main application ("MainApp") module would get command line arguments and send the request as an event to a business logic function for processing.
For a backend application, the MainApp is usually used to do some "initialization" or setup steps for your services.
Your user function module may look like this:
@PreLoad(route = "hello.simple", instances = 10)
public class SimpleDemoEndpoint implements TypedLambdaFunction<AsyncHttpRequest, Object> {
@Override
public Object handleEvent(Map<String, String> headers, AsyncHttpRequest input, int instance) {
// business logic here
return result;
}
}
Each function in a composable application should be implemented in the first principle of "input-process-output". It should be stateless and self-contained. i.e. it has no direct dependencies with any other functions in the composable application. Each function is addressable by a unique "route name" and you can use PoJo for input and output.
In the above example, the function is called "hello.simple". The input is an AsyncHttpRequest object, meaning that this function is a "Backend for Frontend (BFF)" module that is invoked by a REST endpoint.
When a function finishes processing, its output will be delivered to the next function.
Writing code in the first principle of "input-process-output" promotes Test Driven Development (TDD) because interface contact is clearly defined. Self-containment means code is more readable too.
A transaction can pass through one or more user functions. In this case, you can write a user function to receive request from a user, make requests to some user functions, and consolidate the responses before responding to the user.
Note that event orchestration is optional. In the most basic REST application, the REST automation system can send the user request to a function directly. When the function finishes processing, its output will be routed as a HTTP response to the user.
Event routing is done behind the curtain by the composable engine which consists of the REST automation service, an in-memory event system ("event loop") and an optional localized pub/sub system.
REST automation creates REST endpoints by configuration rather than code. You can define a REST endpoint like this:
- service: "hello.world"
methods: ['GET']
url: "/api/hello/world"
timeout: 10s
In this example, when a HTTP request is received at the URL path "/api/hello/world", the REST automation system will convert the HTTP request into an event for onward delivery to the user defined function "hello.world". Your function will receive the HTTP request as input and return a result set that will be sent as a HTTP response to the user.
For more sophisticated business logic, you can write a function to receive the HTTP request and do "event orchestration". i.e. you can do data transformation and send "events" to other user functions to process the request.
The composable engine encapsulates the Eclipse vertx event bus library for event routing. It exposes the "PostOffice" API for your orchestration function to send async or RPC events.
The in-memory event system is designed for point-to-point delivery. In some use cases, you may like to have a broadcast channel so that more than one function can receive the same event. For example, sending notification events to multiple functions. The optional local pub/sub system provides this multicast capability.
While REST is the most popular user facing interface, there are other communication means such as event triggers in a serverless environment. You can write a function to listen to these external event triggers and send the events to your user defined functions. This custom "adapter" pattern is illustrated as the dotted line path in Figure 1.
The first step is to build Mercury libraries from source. To simplify the process, you may publish the libraries to your enterprise artifactory.
mkdir sandbox
cd sandox
git clone https://github.com/Accenture/mercury.git
cd mercury
mvn clean install
The above sample script clones the Mercury open sources project and builds the libraries from source.
The pre-requisite is maven 3.8.6 and openjdk 1.8 or higher. We have tested mercury with Java version 1.8 to 19.
This will build the mercury libraries and the sample applications.
The platform-core
project is the foundation library for writing composable application.
Assuming you follow the suggested project directory above, you can run a sample composable application called "lambda-example" like this:
cd sandbox/mercury/examples/lambda-example
java -jar target/lambda-example-3.0.9.jar
You will find the following console output when the app starts
Exact API paths [/api/event, /api/hello/download, /api/hello/upload, /api/hello/world]
Wildcard API paths [/api/hello/download/{filename}, /api/hello/generic/{id}]
Application parameters are defined in the resources/application.properties file (or application.yml if you prefer).
When rest.automation=true
is defined, the system will parse the "rest.yaml" configuration for REST endpoints.
When REST automation is turned on, the system will start a lightweight non-blocking HTTP server. By default, it will search for the "rest.yaml" file from "/tmp/config/rest.yaml" and then from "classpath:/rest.yaml". Classpath refers to configuration files under the "resources" folder in your source code project.
To instruct the system to load from a specific path. You can add the yaml.rest.automation
parameter.
To select another server port, change the rest.server.port
parameter.
rest.server.port=8085
rest.automation=true
yaml.rest.automation=classpath:/rest.yaml
To create a REST endpoint, you can add an entry in the "rest" section of the "rest.yaml" config file like this:
- service: "hello.download"
methods: [ 'GET' ]
url: "/api/hello/download"
timeout: 20s
cors: cors_1
headers: header_1
tracing: true
The above example creates the "/api/hello/download" endpoint to route requests to the "hello.download" function. We will elaborate more about REST automation in Chapter-3.
A function is executed when an event arrives. You can define a "route name" for each function. It is created by a class implementing one of the following interfaces:
TypedLambdaFunction
allows you to use PoJo or HashMap as input and outputLambdaFunction
is untyped, but it will transport PoJo from the caller to the input of your functionKotlinLambdaFunction
is a typed lambda function using Kotlin suspend function
With the application started in a command terminal, please use a browser to point to: http://127.0.0.1:8085/api/hello/world
It will echo the HTTP headers from the browser like this:
{
"headers": {},
"instance": 1,
"origin": "20230324b709495174a649f1b36d401f43167ba9",
"body": {
"headers": {
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-ch-ua-mobile": "?0",
"accept-language": "en-US,en;q=0.9",
"sec-ch-ua-platform": "\"Windows\"",
"upgrade-insecure-requests": "1",
"sec-fetch-user": "?1",
"accept": "text/html,application/xhtml+xml,application/xml,*/*",
"sec-fetch-dest": "document",
"user-agent": "Mozilla/5.0 Chrome/111.0.0.0"
},
"method": "GET",
"ip": "127.0.0.1",
"https": false,
"url": "/api/hello/world",
"timeout": 10
}
}
The function is defined in the MainApp class in the source project with the following segment of code:
LambdaFunction echo = (headers, input, instance) -> {
log.info("echo #{} got a request", instance);
Map<String, Object> result = new HashMap<>();
result.put("headers", headers);
result.put("body", input);
result.put("instance", instance);
result.put("origin", platform.getOrigin());
return result;
};
// Register the above inline lambda function
platform.register("hello.world", echo, 10);
The Hello World function is written as an "inline lambda function". It is registered programmatically using
the platform.register
API.
The rest of the functions are written using regular classes implementing the LambdaFunction, TypedLambdaFunction and KotlinLambdaFunction interfaces.
Let's examine the SimpleDemoEndpoint
example under the "services" folder. It may look like this:
@PreLoad(route = "hello.simple", instances = 10)
public class SimpleDemoEndpoint implements TypedLambdaFunction<AsyncHttpRequest, Object> {
@Override
public Object handleEvent(Map<String, String> headers, AsyncHttpRequest input, int instance) {
// business logic here
}
}
The PreLoad
annotation assigns a route name to the Java class and registers it with an in-memory event system.
The instances
parameter tells the system to create a number of workers to serve concurrent requests.
Note that you don't need a lot of workers to handle a larger number of users and requests provided that your function can finish execution very quickly.
By default, functions are executed as "coroutine" unless you specify the KernelThreadRunner
annotation to tell
the system to run the function using kernel thread pool.
There are three function execution strategies (Kernel thread pool, coroutine and suspend function). We will explain the concept in Chapter-2
In a composable application, a function is designed using the first principle of "input-process-output".
In the "hello.simple" function, the input is an HTTP request expressed as a class of AsyncHttpRequest
.
You can ignore headers
input argument for the moment. We will cover it later.
The output is declared as "Object" so that the function can return any data structure using a HashMap or PoJo.
You may want to review the REST endpoint /api/simple/{task}/*
in the rest.yaml config file to see how it is
connected to the "hello.simple" function.
We take a minimalist approach for the rest.yaml syntax. The parser will detect any syntax errors. Please check application log to ensure all REST endpoint entries in rest.yaml file are valid.
Using the lambda-example as a template, let's create your first function by adding a function in the "services" package folder. You will give it the route name "my.first.function" in the "PreLoad" annotation.
Note that route name must use lower case letters and numbers separated by the period character.
@PreLoad(route = "my.first.function", instances = 10)
public class MyFirstFunction implements TypedLambdaFunction<AsyncHttpRequest, Object> {
@Override
public Object handleEvent(Map<String, String> headers, AsyncHttpRequest input, int instance) {
// your business logic here
return input;
}
}
To connect this function with a REST endpoint, you can declare a new REST endpoint in the rest.yaml like this:
- service: "my.first.function"
methods: [ 'GET' ]
url: "/api/hello/my/function"
timeout: 20s
cors: cors_1
headers: header_1
tracing: true
If you do not put any business logic, the above function will echo the incoming HTTP request object back to the browser.
Now you can examine the input HTTP request object and perform some data transformation before returning a result.
The AsyncHttpRequest class allows you to access data structure such as HTTP method, URL, path parameters, query parameters, cookies, etc.
When you click the "rebuild" button in IDE and run the "MainApp", the new function will be available in the
application. Alternatively, you can also do mvn clean package
to generate a new executable JAR and run the
JAR from command line.
To test your new function, visit http://127.0.0.1:8085/api/hello/my/function
Your function automatically uses an in-memory event bus. The HTTP request from the browser is converted to an event by the system for delivery to your function as the "input" argument.
The underlying HTTP server is asynchronous and non-blocking. i.e. it does not consume CPU resources while waiting for a response.
This composable architecture allows you to design and implement applications so that you have precise control of performance and throughput. Performance tuning is much easier.
You can assemble related functions in a single composable application, and it can be compiled and built into
a single "executable" for deployment using mvn clean package
.
The executable JAR is in the target folder.
Composable application is by definition cloud native. It is designed to be deployable using Kubernetes or serverless.
A sample Dockerfile for your executable JAR may look like this:
FROM adoptopenjdk/openjdk11:jre-11.0.11_9-alpine
EXPOSE 8083
WORKDIR /app
COPY target/your-app-name.jar .
ENTRYPOINT ["java","-jar","your-app-name.jar"]
Home | Chapter-2 |
---|---|
Table of Contents | Function Execution Strategy |