AWS Lambda 做API + DynamoDB做数据库 + GraalVM增速 + Annotation Processor 代码生成

我们知道 Java (JIT - Just In Time 编译运行)比较耗内存,冷启动时间比Go / Python / NodeJs 要长。所以通常不是最佳的 Lambda / Serverless 语言。它最多的是用在长期待机处理大量请求的场景。但是Java这么好的生态以及工程性,不用在 Lambda上面太浪费了。Python和NodeJs之所以快是因为轻量,Go之所以快是因为AOT(有人说Go编译也很快)。那Java现在也有GraalVM可以做AOT (Ahead-Of-Time)编译,Java程序员是不是也迎来了Lambda计算的春天呢?想像一下,编写调试时候有JIT和Junit,发布的时候有AOT,挺美好的!

一般的数据库连接都是长连接,Lambda 虽然也可以有provisioned instance, 但那样还不如买vm. 所以长连接的高性能数据库是不用想了。也只能用DynamoDB这种基于HTTPs的数据库可以结合使用。
山哥做了很多调研和测试之后,发现 Java11 Corretto 那个runtime,用AWS SDK v2, 初次调用GetItem, 至少也要4秒多。有个外国老哥用 AWS SDK v1, 测出来的结果是11秒多。(这位外国老哥做了一个很好的开始并且文章写得图文并茂很专业。抄了他不少代码,其中有些代码不work了,也一一改正过来。https://arnoldgalovics.com/java-cold-start-aws-lambda-graalvm/)

AWS 官网和博客都有专门的文章教我们怎么减少jar 体积和获得更快的冷启动时间。山哥都试过了,收效不大,所以最终还是朝着GraalVM这个方向才取得了显著的成果。

  • https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/lambda-optimize-starttime.html
  • https://aws.amazon.com/cn/blogs/developer/tuning-the-aws-java-sdk-2-x-to-reduce-startup-time/

Milestone 1

在做了大量的调试之后,在Java11 Corretto Runtime上,已经无法再优化了,让我们称之为里程碑 1。

  • 初始运行时间:4 秒
  • 后续请求:200 ~ 400 毫秒
  • 最少运行内存 136 MB

结论: 如果用 Java Runtime, 更适合的是处理批量请求而不是交互性请求。

探索 Lambda 的Custom Runtime

学习阶段

通过研究AWS Lambda的文档,我们发现它需要的是一个boostrap的可执行文件。
比如可以用Shell来写Lambda

Example function.zip

.
├── bootstrap
├── function.sh

Custom Runtime 的原理是:

  • 用bootstrap来启动你的程序
  • 有任何的返回或者想报错,通过调用lambda提供的几个http API来实现。(可以用CURL来调用)

用 Java 实现阶段

设想:我们可以实现一个 Java 写的 runtime library, 帮忙去调用 lambda的那几个HTTP API, 那么你后续只要实现具体的业务代码,就可以比较轻松地打包成为一个可执行的 Jar了,然后再用GraalVM编译成为AOT,就有望节省冷启动时间。
实现:代码一抄一改,几分钟过去了.. Tada.. 上 Runtime 代码:https://gitee.com/gzten/aws-lambda-java-runtime

对于业务逻辑,我们做个简单的。

  • 启动器 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();
    }
}
  • 业务逻辑
    API gateway的参数获取部分代码还很烂,有待优化。
@Slf4j
public class IdentityRequestHandler implements RequestHandler {

    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 param = JsonUtil.fromJson(input, Map.class);
        String username;
        if (param.containsKey("httpMethod")) {
            if (param.get("httpMethod").equals("POST")) {
                username = ((Map)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;
    }

}

注意这个返回结果是这个样子的:

{
  "username": {
    "S": "sam"
  },
  "password": {
    "S": "are you okay"
  },
  "lastUpdateTime": {
    "N": "1638672322225"
  },
  "creationTime": {
    "N": "1638672322225"
  }
}

你需要另外写代码来转换成你想要的样子。

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配置文件的目录结构:
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一次性搞定,我们这里网络特殊,还是手工做比较快。

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

开另外一个窗口,开始反复的手工操作:

  • Step 1, �当我们把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是优化,默认是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
  • 指定函数名
  • 选择运行时为Amazon Linux 2自己引导
  • 设置角色,注意角色要有权限使用DynamoDB
  • All in one:
    Screen Shot 2022-02-02 at 7.13.39 PM.png
  • 上传代码并设置Handler 为 cn.gzten.lambda.dynamo.sample.IdentityRequestHandler::handleRequest::handleRequest不是必须,这里是跟非Custom Runtime的保持命名一致)
    Screen Shot 2022-02-02 at 7.17.07 PM.png

你可以用在线测试功能来测试它

Screen Shot 2022-02-02 at 7.20.19 PM.png


另外,要设置 API Gateway 来触发,那样在本地就可以用POSTMAN试了!

优化过的结果是:

  • 冷启动700毫秒到1.1秒 (128M,1024M大概600毫秒,没必要)
  • 连续发请求的情况下,100-200毫秒
  • 内存占用67M

工作效率提升篇 (Annotation Processor 代码生成)

我们发现DynamoDB的数据结构是Map, 这个很反人性,你要花很大的工夫去转成你想要的POJO。
AWS DynamoDB有个Enhanced Client, 可以通过注解的模式,帮你做了转换:
https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/examples-dynamodb-enhanced.html
山哥试验过后,发现在Java JIT下,用得挺爽的。可以一旦用GraalVM,你运行时就会得到这个报错:com.oracle.svm.core.jdk.UnsupportedFeatureError: Defining anonymous classes at runtime is not supported.
即使我们把所有的类做了reflect-config.json还是报这个错。那说明GraalVM对匿名类的支持是不足的而DynamoDB Enhanced Client 又用了这个,两害相权,忍痛放弃 这个Enhanced Client, 也节省了几M空间。那咋办呢?自己写工具类做转换吧!可控!

业务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);
    }

}


工具类,结合 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  Optional itemToBean(Map item, Class 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  Optional getItem(DynamoDbClient dynamo, Class 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 singleMapWithString(final String key, final String value) {
        return Collections.singletonMap(key, AttributeValue.builder().s(value).build());
    }
}

那么你就可以这样用:

Optional user = AppTableMapper. getItem(dynamoClient, AppUser.class, "my-user-name");

这已经不错了,但是在获取 tableName和Key的时候,用的是反射,就算不是反射,因为最终返回变String用了Json序列化,还是要在GraalVM那里注册reflect-config.json。

有没有办法做这两件事?

  • 自动生成Mapper方法,而避免用反射
  • 对于注解了@AppDynamoBean的类,因为要变JSON,那自动注册reflect-config.json而不是手动添加。

答案是有的!用Annotation Processor,在编译期生成一个AppUserDynamoBean,并且把AppUser注册到reflect-config.json
详情参看文章:https://www.jianshu.com/p/e6516affa2c1

最终形态 (AppUserDynamoBean是自动生成的,不用编写代码,根据AppUser的注解生成)

        Optional 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 环境变量:https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime

你可能感兴趣的:(AWS Lambda 做API + DynamoDB做数据库 + GraalVM增速 + Annotation Processor 代码生成)