Recently, I received a small request to regularly post a college entrance examination countdown image to a class group every day. In the spirit of tinkering, I wanted to try using GraalVM.
The project code is written in Java, packaged into a jar, and then generates a native image using GraalVM Native Image. The overall functionality is quite simple: first, generate the image and then send it out via the WeChat Work Webhook address.
1. The Process of Building Native Image#
The project depends on a harmless JSON library:
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20230227</version>
</dependency>
The main code looks like this:
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 = "days";
System.out.println("Current time " + now + " is " + between + " days until the college entrance examination");
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("Service response: " + 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);
}
}
Add the Maven configuration for packaging the jar:
<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>
So, can we build the native image now? No!
GraalVM Native Image has restrictions on the use of reflection, JNI, resource access, etc., and in the code above, getResourceAsStream
is used and the ImageIO class, which belongs to the desktop module, is referenced. Therefore, JNI usage is unavoidable, and additional configuration files need to be added:
resources
├── bg.png
├── LXGWWenKaiLite-Bold.ttf
└── META-INF
└── native-image
└── com
└── example
├── jni-config.json
├── native-image.properties
├── reflect-config.json
└── resource-config.json
The native-image.properties file provides the parameters needed during the build.
Args = -H:ResourceConfigurationResources=${.}/resource-config.json -H:JNIConfigurationResources=${.}/jni-config.json -H:ReflectionConfigurationResources=${.}/reflect-config.json
The following command can help GraalVM generate the remaining three configuration files:
/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
After organizing, you can continue packaging.
/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
The final generated content includes:
gkdjs-service-1.0-SNAPSHOT-jar-with-dependencies executable file
libawt_headless.so
libawt.so
libawt_xawt.so
libfontmanager.so
libfreetype.so
libjavajpeg.so
libjava.so
libjvm.so
liblcms.so
You can directly execute the first one; the executable file is over 50 MB, hoping that space can buy time.
2. Image Packaging and Publishing#
Write a Dockerfile:
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
If desktop and font-related content is not used, xvfb and fontconfig are not needed. After generating the image, you can run it directly. Regarding scheduling, note that loginctl enable-linger $user
allows the current user to remain logged in; otherwise, the crontab will not execute after logging out.
This is my first blog post published on XLog, and I hope everyone enjoys it. 💖