我們知道 Java (JIT - Just In Time 編譯運行)比較耗內(nèi)存窟却,冷啟動時間比Go / Python / NodeJs 要長板熊。所以通常不是最佳的 Lambda / Serverless 語言。它最多的是用在長期待機處理大量請求的場景妥衣。但是Java這么好的生態(tài)以及工程性琉雳,不用在 Lambda上面太浪費了划乖。Python和NodeJs之所以快是因為輕量辆沦,Go之所以快是因為AOT(有人說Go編譯也很快)昼捍。那Java現(xiàn)在也有GraalVM可以做AOT (Ahead-Of-Time)編譯,Java程序員是不是也迎來了Lambda計算的春天呢肢扯?想像一下妒茬,編寫調(diào)試時候有JIT和Junit,發(fā)布的時候有AOT蔚晨,挺美好的乍钻!
一般的數(shù)據(jù)庫連接都是長連接,Lambda 雖然也可以有provisioned instance, 但那樣還不如買vm. 所以長連接的高性能數(shù)據(jù)庫是不用想了铭腕。也只能用DynamoDB這種基于HTTPs的數(shù)據(jù)庫可以結(jié)合使用银择。
山哥做了很多調(diào)研和測試之后,發(fā)現(xiàn) Java11 Corretto 那個runtime谨履,用AWS SDK v2, 初次調(diào)用GetItem, 至少也要4秒多欢摄。有個外國老哥用 AWS SDK v1, 測出來的結(jié)果是11秒多熬丧。(這位外國老哥做了一個很好的開始并且文章寫得圖文并茂很專業(yè)笋粟。抄了他不少代碼怀挠,其中有些代碼不work了,也一一改正過來害捕。https://arnoldgalovics.com/java-cold-start-aws-lambda-graalvm/)
AWS 官網(wǎng)和博客都有專門的文章教我們怎么減少jar 體積和獲得更快的冷啟動時間绿淋。山哥都試過了,收效不大尝盼,所以最終還是朝著GraalVM這個方向才取得了顯著的成果吞滞。
Milestone 1
在做了大量的調(diào)試之后,在Java11 Corretto Runtime上盾沫,已經(jīng)無法再優(yōu)化了裁赠,讓我們稱之為里程碑 1。
- 初始運行時間:4 秒
- 后續(xù)請求:200 ~ 400 毫秒
- 最少運行內(nèi)存 136 MB
結(jié)論: 如果用 Java Runtime, 更適合的是處理批量請求而不是交互性請求赴精。
探索 Lambda 的Custom Runtime
學(xué)習(xí)階段
通過研究AWS Lambda的文檔佩捞,我們發(fā)現(xiàn)它需要的是一個boostrap的可執(zhí)行文件。
比如可以用Shell來寫Lambda
Example function.zip
.
├── bootstrap
├── function.sh
Custom Runtime 的原理是:
- 用bootstrap來啟動你的程序
- 有任何的返回或者想報錯蕾哟,通過調(diào)用lambda提供的幾個http API來實現(xiàn)一忱。(可以用CURL來調(diào)用)
用 Java 實現(xiàn)階段
設(shè)想:我們可以實現(xiàn)一個 Java 寫的 runtime library, 幫忙去調(diào)用 lambda的那幾個HTTP API, 那么你后續(xù)只要實現(xiàn)具體的業(yè)務(wù)代碼,就可以比較輕松地打包成為一個可執(zhí)行的 Jar了谭确,然后再用GraalVM編譯成為AOT帘营,就有望節(jié)省冷啟動時間。
實現(xiàn):代碼一抄一改逐哈,幾分鐘過去了.. Tada.. 上 Runtime 代碼:https://gitee.com/gzten/aws-lambda-java-runtime
對于業(yè)務(wù)邏輯芬迄,我們做個簡單的。
- 啟動器 main 方法:
public class NativeDynamoDBApp {
public static void main(String[] args) {
new LambdaRuntime(Arrays.asList(
new RequestHandlerRegistration<>(new APIGatewayRequestHandler(), APIGatewayProxyRequestEvent.class, APIGatewayProxyResponseEvent.class),
new RequestHandlerRegistration<>(new IdentityRequestHandler(), String.class, APIGatewayProxyResponseEvent.class)
)).run();
}
}
- 業(yè)務(wù)邏輯
API gateway的參數(shù)獲取部分代碼還很爛鞠眉,有待優(yōu)化薯鼠。
@Slf4j
public class IdentityRequestHandler implements RequestHandler<String, APIGatewayProxyResponseEvent> {
private static final String tableName = "gzten.user";
private static final DynamoDbClient dynamo = DynamoDbClient.builder()
.region(Region.AP_EAST_1)
.credentialsProvider(EnvironmentVariableCredentialsProvider.create())
.httpClient(UrlConnectionHttpClient.builder().build())
.build();
@Override
public APIGatewayProxyResponseEvent handleRequest(String input, Context context) {
var resp = new APIGatewayProxyResponseEvent();
resp.setStatusCode(200);
resp.setIsBase64Encoded(false);
resp.setHeaders(Map.of("Content-Type", "application/json"));
log.info("Got input: {}", input);
Map<String, String> param = JsonUtil.fromJson(input, Map.class);
String username;
if (param.containsKey("httpMethod")) {
if (param.get("httpMethod").equals("POST")) {
username = ((Map<String, String>)JsonUtil.fromJson(param.get("body"), Map.class)).get("id");
} else {
String s = String.format("Not supported method: %s", param.get("httpMethod"));
log.info(s);
resp.setStatusCode(400);
resp.setBody(s);
return resp;
}
} else {
username = param.get("id");
}
var itemResp = dynamo.getItem(
GetItemRequest.builder()
.tableName(tableName)
.key(singleMapWithString("username", pathParam["id"]!!))
.build()
);
if (itemResp.hasItem()) {
String body = JsonUtil.toJson(itemResp.item());
log.info("Got the item from DynamoDB at {} as {}", LocalDateTime.now(), body);
resp.setBody(body);
} else {
String s = String.format("Not found object for id: %s", username);
resp.setStatusCode(400);
resp.setBody(s);
}
return resp;
}
}
注意這個返回結(jié)果是這個樣子的:
{
"username": {
"S": "sam"
},
"password": {
"S": "are you okay"
},
"lastUpdateTime": {
"N": "1638672322225"
},
"creationTime": {
"N": "1638672322225"
}
}
你需要另外寫代碼來轉(zhuǎn)換成你想要的樣子。
GraalVM 增速階段
一般的代碼GraalVM是可以編譯的械蹋,但如果用了反射出皇,就要在reflect-config.json里面注明反射的要求,GraalVM有個Agent可以幫你自動生成這些信息哗戈。參考文章
$GRAAL_HOME/bin/java -agentlib:native-image-agent=config-output-dir=/path/to/config-dir/ -jar your.jar
GraalVM配置文件的目錄結(jié)構(gòu):
META-INF/native-image/${groupId}/${artifactId}
Screen Shot 2022-02-02 at 6.46.55 PM.png
咱先在Mac/Windows上面把Jar編譯通過郊艘,再想辦法搞成Amazon Linux 2的版本。國外的人直接用Maven / Gradle一次性搞定唯咬,我們這里網(wǎng)絡(luò)特殊纱注,還是手工做比較快。
GraalVM安裝:
到這里下載:https://github.com/graalvm/graalvm-ce-builds/releases/tag/vm-22.0.0.2
- graalvm-ce-java11-darwin-amd64-22.0.0.2.tar.gz
-
native-image-installable-svm-java17-darwin-amd64-22.0.0.2.jar
然后安裝:
mkdir /opt/local/graalvm
cd /opt/local/graalvm
mv ~/Downloads/graalvm-ce-java11-darwin-amd64-22.0.0.2.tar.gz ./
tar -xzvf graalvm-ce-java11-darwin-amd64-22.0.0.2.tar.gz
mv ~/Downloads/native-image-installable-svm-java17-darwin-amd64-22.0.0.2.jar ./
export GRAAL_HOME=graalvm-ce-java11-22.0.0.2
# 本地安裝native image胆胰,在線裝太慢
$GRAAL_HOME/bin/gu install -L native-image-installable-svm-java17-darwin-amd64-22.0.0.2.jar
編譯命令:
$GRAAL_HOME/bin/native-image --verbose -jar aws-*.jar
注意
我們一般用蘋果或者Windows狞贱,AOT是直接編譯成平臺依賴的二進制代碼的,所以直接用GraalVM是不成的蜀涨,我們可以用Docker瞎嬉,裝一個AL2(Amazon Linux 2)的版本蝎毡,在里面編譯。
Docker 安裝:(先下載Linux amd64版的GraalVM氧枣,像上面Mac版那樣. 我們用的香港的Lambda目前只支持x86_64, 不支持ARM64指令集的Graviton2芯片)
# Default it is AMD64 version
docker pull amazon/aws-lambda-provided
# Prepare the graalvm
mkdir /opt/local/aws-lambda
cd /opt/local/aws-lambda
Download graalvm-ce-java11-linux-amd64-22.0.0.2.tar.gz from github
Download native-image-installable-svm-java11-linux-amd64-22.0.0.2.jar from github
tar -xzvf graalvm-ce-java11-linux-amd64-22.0.0.2.tar.gz
# Run docker
$ docker run --name graalvm-aws-lambda-j11 -v /opt/local/aws-lambda:/opt/local/aws-lambda -d amazon/aws-lambda-provided
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b0778d64e5ac amazon/aws-lambda-provided "/lambda-entrypoint.…" 1 mins ago Up 1 mins graalvm-aws-lambda-j11
然后可以交互運行:
# Go to shell with interactive mode
docker exec -it b0778d64e5ac bash
cd /opt/local/aws-lambda
export JAVA_HOME=/opt/local/aws-lambda/graalvm-ce-java11-22.0.0.2
export PATH=$JAVA_HOME/bin:$PATH
gu install -L native-image-installable-svm-java11-linux-amd64-22.0.0.2.jar
mkdir work
cd work
開另外一個窗口沐兵,開始反復(fù)的手工操作:
- Step 1, ?當(dāng)我們把Maven編譯出來Jar, Copy到Docker mount的文件夾里:
cd /opt/local/aws-lambda/work
cp ~/git/java/aws-lambda-java-dynamodb-native/sample/target/aws-lambda-java-dynamodb-native-sample-1.0.0.jar ./
- Step 2,Docker里運行命令編譯:
# -O3是優(yōu)化便监,默認(rèn)是1級扎谎,3級會慢些但是性能更好
native-image --verbose -jar aws-*.jar -O3 -H:Name=function
- Step 3,打包:
rm hey.zip && \
zip hey.zip function bootstrap
- Step 4, 上傳到AWS Lambda烧董,讓我們看看第一次如何Setup
- 指定函數(shù)名
- 選擇運行時為Amazon Linux 2自己引導(dǎo)
- 設(shè)置角色毁靶,注意角色要有權(quán)限使用DynamoDB
- All in one:
Screen Shot 2022-02-02 at 7.13.39 PM.png- 上傳代碼并設(shè)置Handler 為
cn.gzten.lambda.dynamo.sample.IdentityRequestHandler::handleRequest
(::handleRequest
不是必須,這里是跟非Custom Runtime的保持命名一致)
Screen Shot 2022-02-02 at 7.17.07 PM.png
你可以用在線測試功能來測試它
?
另外逊移,要設(shè)置 API Gateway 來觸發(fā)老充,那樣在本地就可以用POSTMAN試了!
優(yōu)化過的結(jié)果是:
- 冷啟動700毫秒到1.1秒 (128M螟左,1024M大概600毫秒啡浊,沒必要)
- 連續(xù)發(fā)請求的情況下,100-200毫秒
- 內(nèi)存占用67M
工作效率提升篇 (Annotation Processor 代碼生成)
我們發(fā)現(xiàn)DynamoDB的數(shù)據(jù)結(jié)構(gòu)是Map<String, AttributeValue>, 這個很反人性胶背,你要花很大的工夫去轉(zhuǎn)成你想要的POJO巷嚣。
AWS DynamoDB有個Enhanced Client, 可以通過注解的模式,幫你做了轉(zhuǎn)換:
https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/examples-dynamodb-enhanced.html
山哥試驗過后钳吟,發(fā)現(xiàn)在Java JIT下廷粒,用得挺爽的『烨遥可以一旦用GraalVM坝茎,你運行時就會得到這個報錯:com.oracle.svm.core.jdk.UnsupportedFeatureError: Defining anonymous classes at runtime is not supported.
即使我們把所有的類做了reflect-config.json還是報這個錯。那說明GraalVM對匿名類的支持是不足的而DynamoDB Enhanced Client 又用了這個暇番,兩害相權(quán)嗤放,忍痛放棄 這個Enhanced Client, 也節(jié)省了幾M空間。那咋辦呢壁酬?自己寫工具類做轉(zhuǎn)換吧次酌!可控!
業(yè)務(wù)POJO
package cn.gzten.lambda.dynamo.sample.data;
import cn.gzten.lambda.dynamo.annotation.DynamoBean;
import cn.gzten.lambda.dynamo.annotation.PartitionKey;
import com.fasterxml.jackson.annotation.*;
import lombok.Data;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Data
@ AppDynamoBean(tableName = "gzten.user")
public class AppUser {
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
@ AppDynamoKey
private String username;
private String password;
@JsonIgnore
private Long lastUpdateTime;
@JsonIgnore
private Long creationTime;
@JsonAlias("lastUpdateDateTime")
public String getLastUpdateDateTime() {
if (lastUpdateTime == null) {
return "";
}
LocalDateTime dateTime = Instant.ofEpochMilli(lastUpdateTime)
.atOffset(ZoneOffset.UTC)
.toZonedDateTime().toLocalDateTime();
return dateTime.format(DT_FMT);
}
@JsonAlias("creationDateTime")
public String getCreationDateTime() {
if (creationTime == null) {
return "";
}
LocalDateTime dateTime = Instant.ofEpochMilli(creationTime)
.atOffset(ZoneOffset.UTC)
.toZonedDateTime().toLocalDateTime();
return dateTime.format(DT_FMT);
}
}
工具類舆乔,結(jié)合 JsonAttributeValueUtil
package cn.gzten.lambda.dynamo.sample.util;
package cn.gzten.lambda.dynamo.util;
import cn.gzten.lambda.dynamo.annotation.AppDynamoBean;
import cn.gzten.lambda.dynamo.annotation.AppDynamoKey;
import cn.gzten.lambda.runtime.exception.AppServiceException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import java.lang.reflect.Field;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
public class AppTableMapper {
private static final ObjectMapper OBJECT_MAPPER = com.fasterxml.jackson.databind.json.JsonMapper.builder()
.configure(MapperFeature.USE_ANNOTATIONS, false)
.build();
public static <T> Optional<T> itemToBean(Map<String, AttributeValue> item, Class<T> clazz) {
if (item == null) {
return Optional.empty();
}
try {
return Optional.of(OBJECT_MAPPER.treeToValue(JsonAttributeValueUtil.fromAttributeValue(item), clazz));
} catch (JsonProcessingException e) {
throw new AppServiceException(e);
}
}
public static <T> Optional<T> getItem(DynamoDbClient dynamo, Class<T> clazz, String partitionKey) {
AppDynamoBean beanAnnotation;
try {
beanAnnotation = clazz.getDeclaredAnnotation(AppDynamoBean.class);
if (beanAnnotation == null) {
throw new AppServiceException("Your class provided is not annotated with @AppDynamoBean!");
}
} catch (NullPointerException e) {
throw new AppServiceException("Your class provided is not annotated with @AppDynamoBean!");
}
String tableName = beanAnnotation.tableName();
Field[] fields = clazz.getDeclaredFields();
String keyName = null;
for(Field field : fields) {
try {
var keyAnnotation = field.getDeclaredAnnotation(AppDynamoKey.class);
if (keyAnnotation != null) {
keyName = field.getName();
break;
}
} catch (NullPointerException e) {}
}
if (keyName == null) {
throw new AppServiceException("Your class provided has no key field with @AppDynamoKey!");
}
var resp = dynamo.getItem(GetItemRequest.builder()
.tableName(tableName)
.key(singleMapWithString(keyName, partitionKey)).build());
if (resp.hasItem()) {
return itemToBean(resp.item(), clazz);
} else {
return Optional.empty();
}
}
public static final Map<String, AttributeValue> singleMapWithString(final String key, final String value) {
return Collections.singletonMap(key, AttributeValue.builder().s(value).build());
}
}
那么你就可以這樣用:
Optional<AppUser> user = AppTableMapper. getItem(dynamoClient, AppUser.class, "my-user-name");
這已經(jīng)不錯了岳服,但是在獲取 tableName和Key的時候,用的是反射希俩,就算不是反射吊宋,因為最終返回變String用了Json序列化,還是要在GraalVM那里注冊reflect-config.json颜武。
有沒有辦法做這兩件事璃搜?
- 自動生成Mapper方法文兑,而避免用反射
- 對于注解了@AppDynamoBean的類,因為要變JSON腺劣,那自動注冊reflect-config.json而不是手動添加。
答案是有的因块!用Annotation Processor橘原,在編譯期生成一個AppUserDynamoBean,并且把AppUser注冊到reflect-config.json
詳情參看文章:http://www.reibang.com/p/e6516affa2c1
最終形態(tài) (AppUserDynamoBean是自動生成的涡上,不用編寫代碼趾断,根據(jù)AppUser的注解生成)
Optional<AppUser> user = AppUserDynamoBean.getItem(dynamo, username);
if (user.isPresent()) {
String body = JsonUtil.toJson(user.get());
log.info("Got the item from DynamoDB at {} as {}", LocalDateTime.now(), body);
resp.setBody(body);
} else {
String s = String.format("Not found object for id: %s", username);
resp.setStatusCode(400);
resp.setBody(s);
}
參考文檔:
Lambda 環(huán)境變量:https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime