使用 GraalVM 构建 Java Native Image

最近收到了一个小需求，每天定时向一个班级群发布高考倒计时图片，本着瞎折腾的原则，想顺便试下 GraalVM 的使用。

项目代码使用 Java 编写，先打包成 jar 再通过 GraalVM Native Image 生成原生镜像。整体功能很简单，先生成图片再通过企业微信 Webhook 地址发出去。

1. Native Image 的构建过程

项目依赖一个人畜无害的 JSON 库：

Copy
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20230227</version>
</dependency>

主要代码是这样的

Copy
package com.example;

import org.json.JSONObject;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.Month;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.Base64;

public class App
{
public static void main( String[] args ) throws IOException, FontFormatException, NoSuchAlgorithmException, URISyntaxException, InterruptedException {
LocalDate gkDate = LocalDate.of(2024, Month.JUNE, 7);
LocalDate now = LocalDate.now(ZoneId.of("Asia/Shanghai"));
long between = ChronoUnit.DAYS.between(now, gkDate);
if (between < 0) {
return;
}
String word1 = String.valueOf(between);
String word2 = "天";
System.out.println("当前时间"+ now +"距离高考"+ between +"天");
InputStream bgImageFile = App.class.getClassLoader().getResourceAsStream("bg.png");
InputStream fontFile = App.class.getClassLoader().getResourceAsStream("LXGWWenKaiLite-Bold.ttf");
BufferedImage image = ImageIO.read(bgImageFile);
Graphics graphics = image.getGraphics();
Font font0 = Font.createFont(Font.TRUETYPE_FONT, fontFile).deriveFont(Font.BOLD,250);
Font font1 = font0.deriveFont(Font.BOLD,90);
graphics.setColor(Color.RED);
drawCenteredString(graphics,word1,new Rectangle(246,956,578,308),font0);
graphics.setColor(Color.BLACK);
drawCenteredString(graphics,word2,new Rectangle(246,1236,578,150),font1);
graphics.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image,"png",baos);
image.flush();
baos.flush();
byte[] byteArray = baos.toByteArray();
baos.close();
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update(byteArray);
byte[] digest = md5.digest();
String md5str = encodeHexString(digest);
Base64.Encoder encoder = Base64.getEncoder();
String s1 = encoder.encodeToString(byteArray);
JSONObject jsonObject = new JSONObject();
jsonObject.put("msgtype","image");
JSONObject imageJson = new JSONObject();
imageJson.put("base64",s1);
imageJson.put("md5",md5str);
jsonObject.put("image",imageJson);
HttpClient httpClient = HttpClient.newBuilder().build();
HttpRequest httpRequest = HttpRequest.newBuilder().POST(HttpRequest.BodyPublishers.ofString(jsonObject.toString()))
.header("Content-Type", "application/json")
.uri(new URI("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=put-your-key-here")).build();
HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
System.out.println("服务返回"+httpResponse.body());
}

/**
* Draw a String centered in the middle of a Rectangle.
*
* @param g The Graphics instance.
* @param text The String to draw.
* @param rect The Rectangle to center the text in.
*/
public static void drawCenteredString(Graphics g, String text, Rectangle rect, Font font) {
// Get the FontMetrics
FontMetrics metrics = g.getFontMetrics(font);
// Determine the X coordinate for the text
int x = rect.x + (rect.width - metrics.stringWidth(text)) / 2;
// Determine the Y coordinate for the text (note we add the ascent, as in java 2d 0 is top of the screen)
int y = rect.y + ((rect.height - metrics.getHeight()) / 2) + metrics.getAscent();
// Set the font
g.setFont(font);
// Draw the String
g.drawString(text, x, y);
}

public static String encodeHexString(byte[] byteArray) {
StringBuffer hexStringBuffer = new StringBuffer();
for (int i = 0; i < byteArray.length; i++) {
hexStringBuffer.append(byteToHex(byteArray[i]));
}
return hexStringBuffer.toString();
}

public static String byteToHex(byte num) {
char[] hexDigits = new char[2];
hexDigits[0] = Character.forDigit((num >> 4) & 0xF, 16);
hexDigits[1] = Character.forDigit((num & 0xF), 16);
return new String(hexDigits);
}
}

添加一下打 Jar 包的 maven 配置项

Copy
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<archive>
<manifest>
<mainClass>
com.example.App
</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</execution>
</executions>
</plugin>

那么按说就能打 native image 了？不！
GraalVM Native Image 对反射、JNI、资源访问等机制的使用存在限制，而在上面的代码中使用了 getResourceAsStream 并引用了 imageIO 这个属于桌面模块的类，因此避免不了 JNI 的使用，这里需要添加额外的配置文件：

Copy
resources
├── bg.png
├── LXGWWenKaiLite-Bold.ttf
└── META-INF
└── native-image
└── com
└── example
├── jni-config.json
├── native-image.properties
├── reflect-config.json
└── resource-config.json

native-image.properties 文件提供了构建时需要的参数

Copy
Args = -H:ResourceConfigurationResources=${.}/resource-config.json -H:JNIConfigurationResources=${.}/jni-config.json -H:ReflectionConfigurationResources=${.}/reflect-config.json

通过下面的命令可以让 GraalVM 帮助生成剩下的 3 个配置文件

Copy
/opt/bellsoft/liberica-vm-23.0.1-openjdk17/bin/java -agentlib:native-image-agent=config-output-dir=./native-image/
-jar gkdjs-service-1.0-SNAPSHOT-jar-with-dependencies.jar

经过整理就可以继续打包了。

Copy
/opt/bellsoft/liberica-vm-23.0.1-openjdk17/bin/native-image -jar ./gkdjs-service-1.0-SNAPSHOT-jar-with-dependencies.jar --enable-http --enable-https --no-fallback

最终生成的内容有：

Copy
gkdjs-service-1.0-SNAPSHOT-jar-with-dependencies 可执行文件
libawt_headless.so
libawt.so
libawt_xawt.so
libfontmanager.so
libfreetype.so
libjavajpeg.so
libjava.so
libjvm.so
liblcms.so

直接执行第一个就可以了，可执行文件有 50 多兆，希望空间能换出时间来。

2. 镜像打包与发布

写个 Dockerfile

Copy
FROM debian:bookworm-slim
WORKDIR app
RUN apt update && apt install -y xvfb
RUN apt install -y fontconfig

COPY target/gkdjs-service-1.0-SNAPSHOT-jar-with-dependencies app
COPY target/lib*.so ./
ENTRYPOINT /app/app

如果没有用到桌面和字体相关内容是不需要 xvfb 和 fontconfig 的，然后生成镜像后直接运行就可以了。
关于定时需要注意一点，loginctl enable-linger $user 这样可以让当前用户始终登录，要不然退出了 crontab 就不执行了。

这是我在 XLog 中发布的第一篇博文，希望大家能够喜欢。💖