LoginSignup
8
4

More than 3 years have passed since last update.

ラムダ式をシリアライズして異なるJVM上で実行してみる

Last updated at Posted at 2019-12-18

Java Advent Calendar 2019 の19日目です。


Java8から導入されたラムダ式はシリアライズが可能で、先達の記事の通り様々な実装パターンがあります。

シリアライズ可能ということはデータストアにロジックを永続化、異なるJVMへの移送・実行が可能ということです。
一方で、JVMに閉じたリソースはどうしてもシリアライズできません。例えばjava.sql.Connectionjava.io.BufferedWriterといったI/Oに関するオブジェクトです。

ロジックを移送し、かつ移送先で任意のI/Oを注入できれば、ちょっとしたサーバレスアーキテクチャをつくれて面白いんじゃないのか?
本記事はそんな動機から調べてやってみた記録です。

FaaSのJavaへの対応状況

本題の前に、各クラウドサービスのFaaSがJavaにどれくらい対応しているのかを確認しておきましょう。
2018年7月現在で、AWS Lambda、IBM Cloud Functions、Azure FunctionsがJavaをサポートしています。
各Cloud Functions で使える言語比較(2018年7月) - Qiita

Amazon Lambdaについては、InputStream、OutputStreamをロジックに注入できます
個別データストアへのコネクションオブジェクトは、ロジック内で都度作成するしかなさそうです。
なお今月発表された情報で、RDS ProxyというRDSのコネクションプーリングの仕組みがプレビュー段階ですが利用可能となったので、JDBCについてはこの手間は近いうちに解消されるでしょう。
[速報]これでLambdaのコネプー問題も解決?!LambdaからRDS Proxyを利用できるようになりました(まだプレビュー) #reinvent

やってみた

試しに作るアプリは、ざっと次のような処理を行うものです。

  • クライアントはラムダ式をシリアライズし、ラムダ式の引数とともにHTTP-POSTする
    • ラムダ式はBase64文字列に変換する
    • ラムダ式と引数は一つのJSONに格納する。これをリクエストボディとする
  • サーバは受信したJSONをデシリアライズし、ラムダ式を実行、結果をクライアントに返す

ソフトウェア構成

  • 実行環境
    • AdoptOpenJDK 11.0.4
  • クライアントの依存モジュール
    • spring-boot-starter-web:2.2.0.RELEASE
  • サーバの依存モジュール
    • spring-boot-starter-web:2.2.0.RELEASE
    • spring-boot-starter-jdbc:2.2.0.RELEASE
    • HikariCP:3.4.1
    • h2database:1.4.199

準備

シリアライズ可能なFunctionインタフェイスSerializedFunctionを作ります。

SerializedFunction.java
public interface SerializedFunction<T, R> extends Function<T, R>, Serializable {
}

SerializedFunctionと、その引数を保持するRequestPayloadを作ります。

RequestPayload.java
public class RequestPayload {

    private String base64EncodedFunction;

    @JsonTypeInfo(use = Id.CLASS, include = As.PROPERTY)
    private Object parameter;

    public String getBase64EncodedFunction() {
        return this.base64EncodedFunction;
    }

    public Object getParameter() {
        return this.parameter;
    }

    private RequestPayload() {
    }

    @JsonCreator
    public RequestPayload(final @JsonProperty("base64EncodedFunction") String base64EncodedFunction,
            @JsonProperty("parameter") final Object parameter) {
        this.base64EncodedFunction = base64EncodedFunction;
        this.parameter = parameter;
    }
}

リソース注入が不要なラムダを実行

まずは、サーバサイドのリソースを特に必要とせず、ラムダ式と引数だけで実行可能なパターンを実行してみます。

クライアントサイド

クライアントサイドでsayHelloFunctionをシリアライズして、HTTP-POSTします。

LambdaRequestClient.java
SerializedFunction<String, String> sayHelloFunction = name -> "Hello, ".concat(name);

var payload = new RequestPayload(makeBase64EncodedLambda(sayHelloFunction), "lambda");
var requestBody = new ObjectMapper().writeValueAsString(payload);
var response = restTemplate.postForObject("http://localhost:8080/api/launch", requestBody, String.class);
LOG.info("response : {}", response);
/**
 * 指定されたラムダ式をBase64文字列に変換します。
 *
 * @param lambda ラムダ式
 * @return Base64文字列
 * @throws IOException 入出力例外
 */
private String makeBase64EncodedLambda(final Object lambda) throws IOException {
    return Base64.getEncoder().encodeToString(serialize(lambda));
}

/**
 * オブジェクトをシリアライズします。
 *
 * @param obj オブジェクト
 * @return シリアライズ後のバイト配列
 * @throws IOException 入出力例外
 */
private byte[] serialize(final Object obj) throws IOException {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(baos);
    try (baos; oos) {
        oos.writeObject(obj);
    }
    return baos.toByteArray();
}

サーバサイド

LambdaController.java
@RestController
@RequestMapping("/api")
public class LambdaController {

    @Autowired
    private LambdaLauncher launcher;

    @PostMapping(path = "/launchlambda")
    public Object launch(@RequestBody final String jsonBody) {
        try {
            return this.launcher.launch(jsonBody);
        } catch (IOException | ReflectiveOperationException e) {
            throw new HttpMessageNotReadableException("invalid request body");
        }
    }
}
LambdaLauncher.java
public Object launch(final String jsonRequest) throws ReflectiveOperationException, IOException {
    var request = new ObjectMapper().readValue(jsonRequest, RequestPayload.class);
    var function = deserialize(request.getFunction());
    return function.apply(request.getParameter());
}
private SerializedFunction<Object, Object> deserialize(final String base64EncodedFunction) throws IOException, ClassNotFoundException {
    var binary = Base64.getDecoder().decode(base64EncodedFunction);
    try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(binary))) {
        return (SerializedFunction<Object, Object>) ois.readObject();
    }
}

実行

クライアントからリクエストすると、ちゃんとラムダ式の実行結果が返ってきました。

ログ
[INFO ] response : Hello, lambda [main]

リソース注入するラムダを実行

次は、サーバサイドのリソースを注入するパターンです。
Spring Data JDBCのAutoConfigurationを利用して、H2DBへのDataSourceがあらかじめBeanに定義されるように構成します。

クライアントサイド

LambdaRequestClient.java
SerializedFunction<DataSource, String> getDatabaseVersionFunction = (@Autowired final DataSource ds) -> {
    String result = "unknown";
    try {
        try (var conn = ds.getConnection();
                var stmt = conn.prepareStatement("select VALUE FROM INFORMATION_SCHEMA.SETTINGS where name = 'info.VERSION'");
                var rs = stmt.executeQuery()) {
            if (rs.next()) {
                result = rs.getString("VALUE");
            }
        }
    } catch (SQLException e) {
        LOG.error("exception while executing query", e);
    }
    return "H2 Database version is ".concat(result);
};

var payload = new RequestPayload(makeBase64EncodedLambda(getDatabaseVersionFunction), null); // DataSourceはシリアライズ不可能なので、引数はnullとしている
var requestBody = new ObjectMapper().writeValueAsString(payload);
var response = restTemplate.postForObject("http://localhost:8080/api/launchlambda", requestBody, String.class);
LOG.info("response : {}", response);

H2DBにクエリを実行して結果を取得するラムダ式getDatabaseVersionFunctionとHTTPリクエストです。
SQLの実行結果を反映したレスポンスが返れば十分なので、H2DBには特にテーブルを作成していません。
ラムダ式内でDataSourceを利用するため、引数に定義して@AutoWiredを付けています。
アノテーションは実際何でも構わないのですが、型が合致するBeanがサーバサイドで注入されることを明示したいので@AutoWiredを使いました。
ただ、Springはさすがにラムダ式の引数にはDIしてくれないので、自力でDIするようにゴリゴリ書きます。

サーバサイド

長いですが一連の処理を貼ります。

LambdaLauncher.java
// ApplicationContextAwareをimplementする

public Object launch(final String jsonRequest) throws ReflectiveOperationException, IOException {
    var request = new ObjectMapper().readValue(jsonRequest, RequestPayload.class);
    var function = deserialize(request.getBase64EncodedFunction());
    var params = getLambdaParams(function);

    Object actualParameter = request.getParameter();
    for (var p : params) {
        var bean = resolveBean(p);
        if (bean != null) {
            actualParameter = bean;
            break;
        }
    }
    return function.apply(actualParameter);
}

/**
 * 指定されたラムダ式をデシリアライズします。
 * 
 * @param base64EncodedFunction Base64文字列でエンコードした{@link SerializedFunction}
 * @return SerializedFunction
 * @throws IOException 入出力例外
 * @throws ClassNotFoundException クラスが見つからない場合
 */
@SuppressWarnings("unchecked")
private SerializedFunction<Object, Object> deserialize(final String base64EncodedFunction) throws IOException, ClassNotFoundException {
    var binary = Base64.getDecoder().decode(base64EncodedFunction);
    try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(binary))) {
        return (SerializedFunction<Object, Object>) ois.readObject();
    }
}

/**
 * 指定されたラムダ式のパラメータを取得します。
 * 
 * @param function ラムダ式
 * @return Parameter[]
 * @throws ReflectiveOperationException リフレクション例外
 */
private Parameter[] getLambdaParams(final SerializedFunction<?, ?> function) throws ReflectiveOperationException {
    var serializedLambda = makeSerializedLambda(function);
    Class<?> capturingClass = Class.forName(serializedLambda.getCapturingClass().replace('/', '.'));
    return Arrays.stream(capturingClass.getDeclaredMethods())
            .filter(m -> m.getName().equals(serializedLambda.getImplMethodName()))
            .filter(m -> !m.isBridge()) // ignore bridge
            .findFirst()
            .orElseThrow(() -> new NoSuchMethodException("No such method " + serializedLambda.getImplMethodName()))
            .getParameters();
}

/**
 * 指定されたラムダ式の{@link SerializedLambda}を返します。
 *
 * @param lambda ラムダ式
 * @return SerializedLambda
 * @throws ReflectiveOperationException リフレクション例外
 */
private SerializedLambda makeSerializedLambda(final Object lambda) throws ReflectiveOperationException {
    var writeReplaceMethod = lambda.getClass().getDeclaredMethod("writeReplace");
    writeReplaceMethod.setAccessible(true);
    return (SerializedLambda) writeReplaceMethod.invoke(lambda);
}

/**
 * アノテーション付きパラメータに対応するBeanをApplicationContextから取得します。
 *
 * @param param ラムダ式の引数オブジェクト
 */
@SuppressWarnings("unchecked")
private <T> T resolveBean(final Parameter param) {
    String beanName = null;
    boolean autoWired = false;
    for (var an : param.getDeclaredAnnotations()) {
        if (an.annotationType() == Qualifier.class) {
            beanName = ((Qualifier) an).value();
        } else if (an.annotationType() == Autowired.class) {
            autoWired = true;
        }
    }
    if (!autoWired) {
        return null;
    }
    if (beanName == null) {
        if (param.getType() == ApplicationContext.class) {
            return (T) this.ctx;
        } else if (param.getType() != Object.class) {
            return (T) this.ctx.getBean(param.getType());
        }
    } else {
        return (T) this.ctx.getBean(beanName, param.getType());
    }
    return null;
}

実行

ログ
[INFO ] response :  H2 Database version is 1.4.199 (2019-03-13) [main]

期待通りの実行結果が返ってきました。

ところが、、

これまではEclipseで実行してきました。その後AdoptOpenJDK11でビルドしたモジュールで実行してみると、ラムダ内部のDataSource#getConnection()を実行する行でNPEが発生しました。なぜ?

[ERROR] Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.NullPointerException] with root cause [http-nio-8080-exec-2]
java.lang.NullPointerException: null
    at ksky8864.LambdaRequestClient.lambda$main$2eb00f57$1(LambdaRequestClient.java:26)
    at ksky8864.LambdaLauncher.launch(LambdaLauncher.java:42)
    at ksky8864.LambdaController.launch(LambdaController.java:22)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
# 以下略

あちこち原因を調べ、バイトコードの比較までやりました。
javap -l -v -pでクライアントのclassファイルを逆アセンブルしてみると、
compare_bytecodes_ejc_adoptopenjdk11_caption.PNG

え、、なんか情報消えてるやん。

画像で赤く囲ったRuntimeVisibleParameterAnnotationは、文字通りパラメータに付けられたアノテーションの情報を示す箇所ですが、AdoptOpenJDKでコンパイルした場合すっぽり無くなってしまいました。
NPEの原因は、ここが欠けて@AutoWiredの付いたパラメータを見つけられず、DataSourceの注入ができなかったのですね。

これまであまり意識してこなかったのですが、Eclipseのコンパイラはコンパイルエラーを起こした状態でも、途中まで気を利かせてclassファイルを生成してくれます。
AntでEclipseのECJ(Eclipse Compiler for Java)を使う - knjnameのブログ

Javaの言語仕様的にアノテーション情報を出すのが正しいか、それともECJの忖度が働いたのか…

さらに調べる

バージョン11のJava言語仕様によれば、アノテーションの保持スコープを決める@Retentionのところで次のような記述があります。

An annotation on the declaration of a local variable, or on the declaration of a formal parameter of a lambda expression, is never retained in the binary representation. In contrast, an annotation on the type of a local variable, or on the type of a formal parameter of a lambda expression, is retained in the binary representation if the annotation type specifies a suitable retention policy.
The Java® Language Specification - Chapter 9. Interfaces

ラムダ式の仮引数の宣言へのアノテーション(An annotation ... on the declaration of a formal parameter of a lambda expression)は、バイナリ表現(バイトコード)では保持されない(is never retained in the binary representation)とあります。後半の"the type of a formal parameter"は、仮引数の型パラメータに対するアノテーションを指す(でいいのかな?)ので、だめなのか。。

一方で、StackOverflowでこんな記述も見つけました。

(2) In response to the comment from @assylias

You can also try (@MyTypeAnnotation("Hello ") String s) -> System.out.println(s) although I have not managed to access the annotation value...

Yes, this is actually possible according to the Java 8 specification. But it is not currently possible to receive the type annotations of the formal parameters of lambda expressions through the Java reflection API, which is most likely related to this JDK bug: Type Annotations Cleanup.
java - Annotating the functional interface of a Lambda Expression - Stack Overflow

仮引数へのアノテーションはJava8の仕様上は可能だが、JDKのバグとの関係で今のところはリフレクションでアノテーション情報を取ることができない、という趣旨ですね。
やっぱりできるのか?どっちなんだい。

おわりに

結局のところ、アノテーションスキャンは断念して、リフレクションを駆使してラムダ式のパラメータとBeanの型を照合、パラメータに注入するといったロジックに変更して、この問題を回避しました。

しかし、言語仕様とコンパイラの溝をのぞいてしまったようで、夜も寝られません。なにか情報を持っている方はご一報くださると幸いです。

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4