Nowadays, Micro-services is trending and popular architecture to develop the big services. It brings a lot of advantages when comparing to Monolithic architecture.
There is a variety of ways to separate a business to multi domains for applying Microservice. Example, we have an E-commerce to order something we want. We can separate to 3 or 4 services:
- Order service
- Payment service
- Shipment service
Divide and conquer, it's always be like this. Some other services or 3rd-party wants to integrate with our system. They want to get customer information, order detail... or a lot of combination things. We have a pattern to do it is Aggregator. Aggregator stands between 3rd-party and our service, aggregate result before responding to client.
There are 4 APIs to get an order information:
- Request token.
- Using this token to query order.
- Using order id to query shipment detail and order detail.
The steps are:
- Step 1: Call Get token API. Cache result if needed.
- Step 2: Using token to query order.
- Step 3: Using token (step 1) and order id (step 2) to query shipment and order detail.
Some notice when doing this:
- Control cache of token.
- Build logic to call API in sequence or parallel in specific cases.
- If any API get an error, stop the flow immediately.
It's ok if the flow is small. But what happen if has flow like this?
What APIs can be executed in parallel, what must be executed in order?
Oktopus will help us on that. It supports on building the APIs layers, caching result, we just define the request, add annotation, execute and waiting for result. Great.
Under the hood, Oktopus using OkHttp to make a RESTful request to server and Jackson to serializer/deserializer JSON object.
If you want to use other Http client or JSON serializer, Oktopus provide the way to customize it. You just create your own ways and register with Oktopus config.
amaizeing-oktopus has been developed by Java 11 and deployed to maven central repository, so you only need to add following dependencies to the pom.xml.
SNAPSHOT version:
<dependency>
<groupId>io.github.amaizeing</groupId>
<artifactId>amaizeing-oktopus</artifactId>
<version>0.1.1</version>
</dependency>
Then, including below libraries version that you prefer:
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.32</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.5</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.9.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.5</version>
</dependency>
We go with the example above for getting order information:
- Get access token
- Get order information
- Get order detail (include many requests with different parameter)
- Get shipment information
Using Annotation to define the request with 4 mandatory:
- Http method: @Post(onSuccess = GetToken.ResponseBody.class)
- Endpoint: @OktopusRequestUrl
- Request header: @OktopusRequestHeader
- Request body: @OktopusRequestBody
In some case, we want to cache the result of request by user information. Just implement 2 methods with cache key and time to live with following annotations. Of course the time to live can be defined yourself or getting from body response. If so, we just add annotation @OktopusResponseBody before argument or even @OktopusRequestBody.
- Key: @OktopusCacheKey
- Time to live: @OktopusCacheTtl
@Post(onSuccess = GetToken.ResponseBody.class)
public class GetToken {
@OktopusRequestUrl
public String url(String url) {
return url;
}
@OktopusRequestHeader
public Map<String, String> headers(String requestId) {
return Map.of("X-Request-Id", requestId);
}
@OktopusRequestBody
public RequestBody requestBody(String userName, String password) {
return new RequestBody(userName, password);
}
@OktopusCacheKey
public String cacheKey(@OktopusRequestBody RequestBody tokenRequest) {
return tokenRequest.getUserName();
}
@OktopusCacheTtl(TimeUnit.SECONDS)
public long cacheTtl(@OktopusResponseBody ResponseBody tokenResponse) {
return tokenResponse.ttlInSeconds;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static final class RequestBody {
private String userName;
private String password;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static final class ResponseBody {
private String accessToken;
private long ttlInSeconds;
}
}
Now, get order will be depended on token request result. Just define a new variable with @DependOn.
@Get(onSuccess = GetOrder.Response.class)
public class GetOrder {
@OktopusDependOn(GetToken.class)
private GetToken.ResponseBody token;
@OktopusRequestUrl
public String url(String url) {
return url;
}
@OktopusRequestHeader
public Map<String, String> initHeader(String requestId) {
return Map.of("Authorization", "Bearer " + token.getAccessToken(),
"X-Request-Id", requestId);
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static final class Response {
private long orderId;
private String status;
private List<Long> orderDetailIds;
private List<Long> shipmentIds;
}
}
Next step, define Get order detail request which depended on token response and order response.
However, single order can include many order details. In this case, we can use annotation with singular or plural noun based on your need:
- Endpoint: @OktopusRequestUrl or @OktopusRequestUrls
- Request header: @OktopusRequestHeader or @OktopusRequestHeaders
- Request body: @OktopusRequestBody or @OktopusRequestBodies
The response type of those methods with plural annotation is Map of key and result. In this case, request header of all requests are the same so we can use singular annotation with @OktopusRequestHeader.
@Get(onSuccess = GetOrderDetail.Response.class)
public class GetOrderDetail {
@OktopusDependOn(GetToken.class)
private GetToken.ResponseBody tokenResponse;
@OktopusDependOn(GetOrder.class)
private GetOrder.Response orderResponse;
@OktopusRequestUrls
public Map<Long, String> urls(String baseUrl) {
final var orderDetailIds = orderResponse.getOrderDetailIds();
return orderDetailIds.stream()
.collect(Collectors.toMap(Function.identity(),
id -> baseUrl + orderResponse.getOrderId()));
}
@OktopusRequestHeader
public Map<String, String> initHeader(String requestId) {
return Map.of("Authorization", "Bearer " + tokenResponse.getAccessToken(),
"X-Request-Id", requestId);
}
@Data
public static final class Response {
private long orderId;
private long orderDetailId;
private String description;
}
}
Do the same thing with get order detail.
@Get(onSuccess = GetOrderShipment.Response.class)
public class GetOrderShipment {
@OktopusDependOn(GetToken.class)
private GetToken.ResponseBody tokenResponse;
@OktopusDependOn(GetOrder.class)
private GetOrder.Response orderResponse;
@OktopusRequestUrls
public Map<Long, String> urls(String baseUrl) {
final var orderDetailIds = orderResponse.getOrderDetailIds();
return orderDetailIds.stream()
.collect(Collectors.toMap(Function.identity(),
id -> baseUrl + orderResponse.getId()));
}
@OktopusRequestHeader
public Map<String, String> initHeader(String requestId) {
return Map.of("Authorization", "Bearer " + tokenResponse.getAccessToken(),
"X-Request-Id", requestId);
}
public static final class Response {
private long id;
private String driverName;
private String licensePlate;
private String phoneNumber;
}
}
Creating request flow and adding request into it, prepare to execute.
var requestId=UUID.randomUUID().toString();
var tokenRequest=OktopusRequest.on(GetToken.class)
.urlArgs("http://localhost:9090/login/")
.headersArgs(requestId)
.requestBodyArgs("dat.bui","123");
var orderRequest=OktopusRequest.on(GetOrder.class)
.headersArgs(requestId)
.urlArgs("http://localhost:9090/orders/1");
var orderDetailRequest=OktopusRequest.on(GetOrderDetail.class)
.headersArgs(requestId)
.urlArgs("http://localhost:9090/order-details/");
var orderShipmentRequest=OktopusRequest.on(GetOrderShipment.class)
.headersArgs(requestId)
.urlArgs("http://localhost:9090/shipments/");
var flow=OktopusFlow.register()
.append(tokenRequest)
.append(orderRequest)
.append(orderDetailRequest)
.append(orderShipmentRequest);
Oktopus will base on your defined request to build the request layer. With this example, we can have 3 layers:
- Layer 1: request token.
- Layer 2: request order.
- Layer 3: request order detail and shipment info.
Note:
- Requests in next layer will be executed after prev layer completed.
- Requests in single layer will be executed in parallel.
- If any request get error, the flow will try to stop executing.
Flow is async. The result of execute() is future error.
Future<Optional<RequestError>>err=flow.execute();
Finally, after executing without any error, we need to get response to aggregate the result before responding to client.
Optinal<RequestError> flowErr=err.get();
if(err.isPresent()){
log.error("Error on executing flow with request: {}",error.get().get().getRequest(),error.get().get().getException());
return;
}
final var tokenResponse=flow.getResponse(GetToken.class,GetToken.ResponseBody.class);
log.info("Token response: {}",tokenResponse);
final GetOrder.Response orderResponse=flow.getResponse(GetOrder.class);
log.info("Order response: {}",orderResponse);
final Map<Long, GetOrderDetail.Response>orderDetailResponses=flow.getResponse(GetOrderDetail.class);
orderDetailResponses.forEach((orderDetailId,response)->log.info("Order detail response: {}",response));
final Map<Long, GetOrderDetail.Response>shipmentResponses=flow.getResponse(GetOrderShipment.class);
shipmentResponses.forEach((shipmentId,response)->log.info("Shipment response: {}",response));
Finally, build the response based on business logic.