在 GraalVM 上运行 Javalin(总大小 22MB)

原文:https://javalin.io/2018/09/27/javalin-graalvm-example.html

Oracle 的 GraalVM 允许提前 (AOT) 编译 JVM 应用程序。这意味着,编译器不会运行 JVM 进程来执行应用程序,而是构建原生二进制文件。它是如何工作的?从非常高的层次来看,一个基本的运行时(称为 SubstrateVM)会被编译到二进制文件以及实际的应用程序中。听起来有点像 Go,它也包含一个小型运行时,例如用于垃圾收集。在本文中,我将展示如何使用 GraalVM 原生编译构建一个小型示例 RESTful Web 服务。该示例应用程序是用 Java 编写的。为什么有人会对 JVM 应用程序的原生编译感兴趣?在 E.ON 的日常工作中,遗憾的是我仍然需要与 Java 应用程序打交道。我们的技术栈完全是云原生的——我们几乎所有的东西都在 Kubernetes 上运行。我们的应用程序是“典型的” 12 因素应用程序。在这样的 dockerized 环境中,我认为原生编译之所以有趣主要有三个原因。

GraalVM AOT 编译可能会改善这种情况。我预计启动时间几乎是即时的,不需要 JVM,并且应用程序大小会显著减小。GraalVM 有一些严重的限制,因为 JVM 的几个特性不能很好地与静态编译配合使用。完整列表可以在这里找到。文档在这里非常清楚:动态类加载现在和将来都不受支持。相反,编译器会分析代码并将所有需要的类编译到二进制文件中。与反射结合,这成为当前 Java 生态系统的噩梦。许多库和框架使用反射来动态实例化类。GraalVM 不能很好地处理这个问题,在许多情况下必须提供额外的编译器配置。原因之一是,对 Class.forName() 的调用可能基于运行时信息。一个非常简单的例子:

if (someVariable) {
    Class.forName("SomeClazz")
    ...
}

由于 someVariable 的值在编译时是未知的,因此编译器无法知道是否要包含“SomeClazz”。更不用说它只是一个字符串,编译器必须在编译时在类路径上搜索此类。如果编译器决定包含此类,它就会这样做,如果找不到该类则抛出错误。这很好。但是,这只是尽力而为。无法保证在编译时包含所有必需的类 - 这意味着类可能会丢失,并且在实例化它们时会抛出运行时错误。还有更多限制,有关完整参考,请参阅 GraalVM 的文档。作为概念证明,我正在寻找一个没有过度使用反射的休息库。显然它不是 spring boot - 我选择了javalin.io。它只是 Jetty 之上的一个休息库,仅此而已。

入门

虽然我建议在 Docker 中执行构建,但在本地安装 GraalVM 也非常有帮助。我使用sdkman,它可以简化 JDK 的管理。如果您尚未安装 sdkman:

curl -s "https://get.sdkman.io" | bash

安装 GraalVM JDK:

sdk install java 1.0.0-rc-16-grl && sdk use java 1.0.0-rc-16-grl

让我们从一个非常简单的 Hello World 开始:

public class Main {
    public static void main(String[] args) {
        Test t = new Test();
        t.setSomeValue("Hello World!");
        Javalin app = Javalin.create().start(7000);
        app.get("/", ctx -> ctx.json(t));
    }
}

此外,我们一定不要忘记声明必要的依赖项。我们必须包含 Jackson,因为它会在运行时加载(呃)。对于 SLF4J 绑定,情况也类似,Javalin 建议使用 slf4j-simple。

compile group: 'io.javalin', name: 'javalin', version: '2.2.0'
compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.9.6'
compile group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.25'
compile group: 'org.graalvm', name: 'graal-sdk', version: '1.0.0-rc6'

此外,我们需要构建一个包含我们应用程序的所有类和 jar 的 fat jar。

task fatJar(type: Jar) {
    manifest {
        attributes 'Implementation-Title': 'Gradle Jar File Example',
                'Implementation-Version': version,
                'Main-Class': 'de.nerden.samples.graal.Main'
    }
    baseName = project.name + '-all'
    from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } }
    with jar
}

到目前为止还没有什么特别的。为了构建原生应用程序可执行文件,GraalVM 提供了工具native-image。我们来尝试一下:

j0e@thinkpad  ~/projects/graal-javalin  master ● ? ⍟1  native-image -jar ./build/libs/graal-javalin-all-1.0-SNAPSHOT.jar
Build on Server(pid: 28578, port: 34643)*
[graal-javalin-all-1.0-SNAPSHOT:28578]    classlist:   2,977.05 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        (cap):     963.06 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        setup:   1,663.57 ms
[ForkJoinPool-3-worker-3] INFO org.eclipse.jetty.util.log - Logging initialized @5682ms to org.eclipse.jetty.util.log.Slf4jLog
[graal-javalin-all-1.0-SNAPSHOT:28578]   (typeflow):  10,510.28 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]    (objects):   6,598.95 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]   (features):     110.60 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]     analysis:  17,612.10 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]     universe:     859.27 ms
error: unsupported features in 8 methods
Detailed message:
Error: Unsupported method sun.nio.ch.InheritedChannel.soType0(int) is reachable: Native method. If you intend to use the Java Native Interface (JNI), specify -H:+JNI and see also -H:JNIConfigurationFiles=<path> (use -H:+PrintFlags for details)
To diagnose the issue, you can add the option --report-unsupported-elements-at-runtime. The unsupported element is then reported at run time when it is accessed the first time.
...
...

好的,我们需要 -H:+JNI 这个标志。这个很简单,只需在命令中添加这个标志,问题就解决了:

j0e@thinkpad  ~/projects/graal-javalin  master ● ? ⍟1  native-image -jar ./build/libs/graal-javalin-all-1.0-SNAPSHOT.jar -H:+JNI
Build on Server(pid: 28578, port: 34643)
[graal-javalin-all-1.0-SNAPSHOT:28578]    classlist:     753.67 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        (cap):     528.63 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        setup:     776.76 ms
[ForkJoinPool-15-worker-0] INFO org.eclipse.jetty.util.log - Logging initialized @616692ms to org.eclipse.jetty.util.log.Slf4jLog
[graal-javalin-all-1.0-SNAPSHOT:28578]   (typeflow):   5,934.19 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]    (objects):   6,646.13 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]   (features):      83.06 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]     analysis:  13,491.56 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]     universe:     519.25 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]      (parse):   2,360.81 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]     (inline):   3,674.24 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]    (compile):  15,925.13 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]      compile:  22,729.43 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        image:   1,426.49 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        write:     280.71 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]      [total]:  40,064.13 ms

编译显然是成功的。但运行之后,问题就开始了:

j0e@thinkpad  ~/projects/graal-javalin  master ● ? ⍟1  ./graal-javalin-all-1.0-SNAPSHOT                          ✔  3369500:53:54
[main] INFO io.javalin.Javalin -
 _________________________________________
|        _                  _ _           |
|       | | __ ___   ____ _| (_)_ __      |
|    _  | |/ _` \ \ / / _` | | | '_ \     |
|   | |_| | (_| |\ V / (_| | | | | | |    |
|    \___/ \__,_| \_/ \__,_|_|_|_| |_|    |
|_________________________________________|
|                                         |
|    https://javalin.io/documentation     |
|_________________________________________|
-------------------------------------------------------------------
Missing dependency 'Slf4j simple'. Add the dependency.

pom.xml:

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>
    <version>1.7.25</version>
</dependency>

build.gradle:

compile "org.slf4j:slf4j-simple:1.7.25"
-------------------------------------------------------------------
Visit https://javalin.io/documentation#logging if you need more help
[main] INFO io.javalin.Javalin - Starting Javalin ...
[main] ERROR io.javalin.Javalin - Failed to start Javalin
java.lang.IllegalArgumentException: Class org.eclipse.jetty.servlet.ServletMapping[] is instantiated reflectively but was never registered. Register the class by using org.graalvm.nativeimage.RuntimeReflection
        at java.lang.Throwable.<init>(Throwable.java:265)
        at java.lang.Exception.<init>(Exception.java:66)
        at java.lang.RuntimeException.<init>(RuntimeException.java:62)
        at java.lang.IllegalArgumentException.<init>(IllegalArgumentException.java:52)
        at com.oracle.svm.core.genscavenge.graal.AllocationSnippets.checkDynamicHub(AllocationSnippets.java:162)
        at org.eclipse.jetty.util.ArrayUtil.addToArray(ArrayUtil.java:91)
        at org.eclipse.jetty.servlet.ServletHandler.addServletWithMapping(ServletHandler.java:907)
        at org.eclipse.jetty.servlet.ServletContextHandler.addServlet(ServletContextHandler.java:462)
        at io.javalin.core.util.JettyServerUtil.initialize(JettyServerUtil.kt:71)
        at io.javalin.Javalin.start(Javalin.java:136)
        at io.javalin.Javalin.start(Javalin.java:103)
        at de.nerden.samples.graal.Main.main(Main.java:10)
        at com.oracle.svm.core.JavaMainWrapper.run(JavaMainWrapper.java:163)

反射不起作用。这并不奇怪,但却暴露了 GraalVM 的根本弱点:即使应用程序编译通过,它也无法保证其正常运行。

要解决这个问题,我们必须告诉 GraalVM 类 ServletMapping 必须包含在二进制文件中。由于它是反射,并且没有代码的“正常”部分,所以它没有检测到它。有两种方法可以实现这一点:基于代码和基于 JSON 配置。我测试了这两种方法,我想我更喜欢 JSON 格式,但最终它并不重要。在你的项目中添加一个包含以下内容的文件:

[
  {
    "name": "[Lorg.eclipse.jetty.servlet.ServletMapping;",
    "allDeclaredFields": true,
    "allPublicFields": true,
    "allDeclaredMethods": true,
    "allPublicMethods": true
  },
  {
    "name": "org.slf4j.impl.StaticLoggerBinder",
    "allDeclaredFields": true,
    "allPublicFields": true,
    "allDeclaredMethods": true,
    "allPublicMethods": true
  },
  {
    "name": "com.fasterxml.jackson.databind.ObjectMapper",
    "allDeclaredFields": true,
    "allPublicFields": true,
    "allDeclaredMethods": true,
    "allPublicMethods": true
  },
  {
    "name": "de.nerden.samples.graal.Test",
    "allDeclaredFields": true,
    "allPublicFields": true,
    "allDeclaredMethods": true,
    "allPublicMethods": true
  }
]

注意特殊符号[Lorg.eclipse.jetty.servlet.ServletMapping;。这是必要的,因为在本例中,一个 ServletMapping 对象数组正在通过反射实例化。此外,我还添加了 slf4j 和 Jackson 类,以便它们在运行时被找到。在这两种情况下,由于反射不起作用,都会引发运行时错误。此外,我们还必须将自己的类添加到反射列表中。如果不这样做,执行请求时将抛出以下神秘异常:

[qtp1024494636-165] WARN io.javalin.core.ExceptionMapper - Uncaught exception
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class de.nerden.samples.graal.Test and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)
        at java.lang.Throwable.<init>(Throwable.java:265)
        at java.lang.Exception.<init>(Exception.java:66)
        at java.io.IOException.<init>(IOException.java:58)
        at com.fasterxml.jackson.core.JsonProcessingException.<init>(JsonProcessingException.java:33)
        at com.fasterxml.jackson.databind.JsonMappingException.<init>(JsonMappingException.java:237)
        at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.<init>(InvalidDefinitionException.java:38)
        at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77)
        at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1191)
        at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:312)
        at com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.failForEmpty(UnknownSerializer.java:71)
        at com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.serialize(UnknownSerializer.java:33)
        at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider._serialize(DefaultSerializerProvider.java:480)
        at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:319)
        at com.fasterxml.jackson.databind.ObjectMapper._configAndWriteValue(ObjectMapper.java:3905)
        at com.fasterxml.jackson.databind.ObjectMapper.writeValueAsString(ObjectMapper.java:3219)
        at io.javalin.json.JavalinJackson.toJson(JavalinJackson.kt:26)
        at io.javalin.json.JavalinJson$toJsonMapper$1.map(JavalinJson.kt:28)
        at io.javalin.json.JavalinJson.toJson(JavalinJson.kt:32)
        at io.javalin.Context.json(Context.kt:510)
        at de.nerden.samples.graal.Main.lambda$main$0(Main.java:11)
        at de.nerden.samples.graal.Main$$Lambda$925/1179449634.handle(Unknown Source)
        at io.javalin.security.SecurityUtil.noopAccessManager(SecurityUtil.kt:22)
        at io.javalin.Javalin$$Lambda$928/1713301975.manage(Unknown Source)
        at io.javalin.Javalin.lambda$addHandler$0(Javalin.java:485)
        at io.javalin.Javalin$$Lambda$931/1107122283.handle(Unknown Source)
        at io.javalin.core.JavalinServlet$service$2$1.invoke(JavalinServlet.kt:48)
        at io.javalin.core.JavalinServlet$service$2$1.invoke(JavalinServlet.kt:20)
        at io.javalin.core.JavalinServlet$service$1.invoke(JavalinServlet.kt:145)
        at io.javalin.core.JavalinServlet$service$2.invoke(JavalinServlet.kt:43)
        at io.javalin.core.JavalinServlet.service(JavalinServlet.kt:109)
        at io.javalin.core.util.JettyServerUtil$initialize$httpHandler$1.doHandle(JettyServerUtil.kt:59)
        at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:203)
        at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:473)
        at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:1564)
        at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:201)
        at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1242)
        at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:144)
        at org.eclipse.jetty.server.handler.HandlerList.handle(HandlerList.java:61)
        at org.eclipse.jetty.server.handler.StatisticsHandler.handle(StatisticsHandler.java:174)
        at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:132)
        at org.eclipse.jetty.server.Server.handle(Server.java:503)
        at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:364)
        at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:260)
        at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:305)
        at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:103)
        at org.eclipse.jetty.io.ChannelEndPoint$2.run(ChannelEndPoint.java:118)
        at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:765)
        at org.eclipse.jetty.util.thread.QueuedThreadPool$2.run(QueuedThreadPool.java:683)
        at java.lang.Thread.run(Thread.java:748)
        at com.oracle.svm.core.posix.thread.PosixJavaThreads.pthreadStartRoutine(PosixJavaThreads.java:238)

原因是:Jackson 使用反射来编组/解组 JSON。只要正确配置,它就可以正常工作。

你可以自己尝试一下!docker run --net=host birdy/graal-javalin

执行示例调用:curl localhost:7000

j0e@thinkpad  ~/projects/graal-javalin  master ? ⍟2  curl localhost:7000                                         ✔  3370701:15:12
{"abc":"LOL"}%

太棒了!而且启动速度很快,无需 JVM!

那么,从我的 3 点批评来看,结果如何?

-rwxr-xr-x 1 j0e users  22M Sep 24 01:38 graal-javalin

这大约是 Go 二进制文件的 1/3 大小。这完全可以接受,并且几乎与 Go 二进制文件的大小相当。请注意,在 JDK 9 中,文件大小可能会更小。所以我认为这里的优势是存在的,但可能不会很大。

总的来说,GraalVM 很酷。只是感觉像个肮脏的 hack。我真的很讨厌 GraalVM 总是在编译时无法预测的运行时错误(如果我错了,请纠正我)。我不确定这是否是 Java 的未来,但它真的有这样的未来吗?;) 值得注意的是,一些库/框架的作者正在积极投入时间支持 GraalVM。事实上,micronaut.io 现在已经兼容了:https://github.com/graemerocher/micronaut-graal-experiments。

完整代码(包括 Dockerfile)可在 GitHub 上找到:https://github.com/birdayz/graal-javalin。

此外,我制作了一个 Docker 镜像,您可以将其用作基础镜像来构建仅包含静态可执行文件的容器,类似于对 Go 应用程序的操作。

FROM birdy/graalvm:latest
WORKDIR /tmp/build
ENV GRADLE_USER_HOME /tmp/build/.gradle

ADD . /tmp/build
RUN ./gradlew build fatJar
RUN native-image -jar /tmp/build/build/libs/graal-javalin-all-1.0-SNAPSHOT.jar -H:ReflectionConfigurationFiles=reflection.json -H:+JNI \
  -H:Name=graal-javalin --static --delay-class-initialization-to-runtime=io.javalin.json.JavalinJson


FROM scratch
COPY --from=0 /tmp/build/graal-javalin /
ENTRYPOINT ["/graal-javalin"]

此 Dockerfile 使用 Docker 多阶段构建。有两个容器:一个仅用于构建,另一个是最终输出容器,其中仅包含应用程序。