Ohhnews

分类导航

$ cd ..
foojay原文

我们并不想再造一个Java服务器:Codename One推出原生后端

#codename one#java后端#原生编译#性能基准#全栈开发

[LOADING...]

为什么要构建另一个 Java 服务器?

| 什么是 Codename One? Codename One 是一个开源框架,可以用单一的 Java 或 Kotlin 代码库构建原生 iOS、Android、桌面和 Web 应用。更多信息请访问 codenameone.com。

我们最不想做的事情就是再构建一个 Java 服务器框架。我们已经有了 Spring、Micronaut、Quarkus,以及足够填满会议日程的 HTTP 请求处理方式。

然而,我们认识一些 Java 开发者,他们在新项目中选择了 Node.js 或 Go。为什么?

答案各不相同,但有两个反复出现:体积和性能,尤其是在微服务和无服务器部署中;以及全栈开发。为一个请求而启动的进程,与持续运行一个月的服务器,有着不同的资源预算。对于全栈开发,Codename One 让应用和服务器共享实体类和验证规则。一份 REST 契约会生成应用客户端和服务器分发器,而相同的 ORM 注解会为本地 SQLite 和服务器数据库生成数据访问对象(DAO)。

我们在后端使用 GraalVM 已经有一段时间了。对于 Spring 开发来说,它是一个很棒的工具。但对于一个并不庞大的服务器,我们的 CI 也要花费大约 30 分钟。生成的二进制文件有数百 MB,进程使用数百 MB 的 RAM。这是我们的应用和构建流水线,但也是我们不得不付出的代价。

于是我们开始对 Go 做基准测试,看看能否做得更好。我们可以。更有趣的是,我们已经拥有了让小型服务器变得有用所需的大部分东西:原生 Java 运行时、生成的 REST 客户端、共享模型和 ORM。把这些部分组合在一起,就得到了我们在新项目中愿意使用的东西。

Codename One 现在有了实验性的原生后端。 编写一个 Java 控制器,开发时在 JVM 上运行它,部署时将其编译为原生可执行文件。数据库层支持 SQLite、PostgreSQL、MySQL 和 MariaDB。应用和后端可以共享业务规则、实体类和 API 契约。

本周即将发布

今天,我们将介绍原生后端及其背后的思考。在接下来的六天里,我们会逐一讲解共享技术栈以及本次发布的其他功能:

服务器是另一种受限设备

大多数 Java 服务器工作都从大型运行时开始,然后问其中有多少可以被移除。而我们是从手机上使用的运行时开始的。

ParparVM 将 Java 字节码转换为 C,然后编译并链接可达程序,生成原生可执行文件。它本来就必须关注启动时间、内存压力和代码体积。一旦你不再把服务器当作一台无限资源的机器,把这个运行时带到小型服务器上就是相当自然的举动。

我们的运行时针对 ARM 做了深度优化。多年在手机和 Apple Silicon 上运行,使该架构成为我们工作的核心,包括用于 UTF-8 转换的 NEON 路径和用于虚拟线程切换的 ARM64 汇编。随着 ARM 在服务器上变得越来越重要,例如 AWS Graviton 等产品,这项投资有了另一个获得回报的地方。

GraalVM 能让大型 Java 应用相对于其功能来说小很多。我们想从更低处开始:一个小的原生进程,随着你添加服务所需的行为而增长。

$ mermaid
flowchart LR
    Shared[Java 模型与业务规则] --> App[移动和桌面应用]
    Shared --> Server[Java 控制器与 ORM]
    Contract[共享 REST 契约] --> Client[生成的应用客户端]
    Contract --> Router[生成的服务器分发器]
    Client --> App
    Router --> Server
    Server --> Bytecode[Java 字节码]
    Bytecode --> C[ParparVM 生成的 C]
    C --> Binary[原生服务器可执行文件]
    Binary --> DB[(SQLite / PostgreSQL / MySQL)]

这也为全栈开发提供了一个不同的起点。一条验证规则可以是手机上也是服务器上的同一个 Java 方法。手机用它来提供即时反馈。服务器在存储数据之前再次运行它。共享规则消除了重复;在服务器上运行它则保留了信任边界。

我们与 Go 的对比测量

该后端指南中记录的对比在两个固定核心上以 64 个连接运行一个明文 HTTP 处理器。这些是三次交错运行的中位数。这里的 Go 参考实现是 fasthttp。

运行时每秒请求数中位延迟p99 延迟启动到首个连接
Codename One 原生,静态 musl 构建595,6100.090 ms0.249 ms0.77 ms
Codename One 原生,动态 glibc 构建547,7610.065 ms4.06 ms2.88 ms
Go 搭配 fasthttp496,2930.104 ms2.63 ms2.39 ms
JVM 上的同一 Java 处理器187,7450.260 ms1.60 ms82.5 ms

[LOADING...]

静态 ParparVM 服务器在此次运行中处理的请求比 Go 参考实现多出约 20%,并在不到一毫秒内接受首个连接。启动时间衡量的是进程本身,而不是云提供商的完整冷启动路径。

静态服务器二进制文件为 7.95 MB,记录的常驻内存为负载下 10 到 40 MB。动态链接构建为 3.19 MB,负载下使用 14 MB。Go 的静态二进制文件为 5.63 MB,使用 6.3 MB;JVM 处理器使用 190 MB。其 0.13 MB 的 JAR 还需要安装 Java 运行时。

对于小型服务来说,这些数字很有用。吞吐量告诉我们,我们可以与 Go 竞争。启动时间和占用空间则告诉我们,对于为每个进程付费的部署来说,这会在哪里变得有趣。

处理器很重要。这个明文路径会池化其响应,并且每个请求大约分配 0.1 字节。如果处理器为每个响应构造一个 map,就会给收集器带来更多工作。我们的构建会生成 DTO 写入器,直接把字段写入响应接收器,因此应用代码有一种实用的方式来避免那个中间 map。基准测试源码包含了不同的响应路径。

Java 25 的 AOT 缓存是另一种加快启动的实用方法,但它仍然运行在 HotSpot 上。缓存文件、原生可执行文件,以及 JAR 加上其运行时,是不同的部署产物。上表比较的是可工作的 HTTP 服务器;我们在 Spring 上的 CI 经历解释了为什么我们开始寻找其他方案。

一个 Java Hello World 有多小?

我们还想得到一个几乎不包含应用程序的基线:

$ java
public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello, world!");
    }
}

我们在运行 macOS 26.6.2 的 Apple M4 Max 上用三种方式构建了该程序。该表报告了每个运行时在五次预热启动之后进行 31 次交错启动的结果,文件已经在操作系统缓存中。耗时包括进程启动、打印、退出以及公共测量包装。主机并未与其他活动隔离:其记录的一分钟、五分钟和十五分钟负载平均值分别为 3.75、6.17 和 4.94。这些耗时描述的是那次运行;一台安静的机器可能会产生不同的结果。

构建需部署的文件中位耗时中位峰值 RSS
ParparVM,原生发布构建0.39 MB3.40 ms2.51 MB
GraalVM Native Image 21.0.118.18 MB4.91 ms8.40 MB
JDK 25,带 AOT 缓存和最小运行时55.54 MB18.02 ms40.68 MB

ParparVM 可执行文件为 392 KB。GraalVM 可执行文件为 8.18 MB。对于 Java 25,部署包括一个仅包含 java.base 的 jlink 运行时、Hello World JAR 以及其训练过的 AOT 缓存。三个可执行文件都以 ARM64 运行;两个原生构建都使用 -O3,其中 ParparVM 启用了 ThinLTO。

[LOADING...]

这就是我们说把服务器视为受限设备时所指的是更低起点。正如上面的服务器测量所示,添加 HTTP、TLS 和数据库支持会使二进制文件增大。从 392 KB 开始,还有很大的空间。

源码、命令和各项测量结果已包含在内,以便你重复该对比。这些是使用所示运行时版本进行的本地进程测量,与 Linux HTTP 实验以及我们 Spring 应用的 CI 时间分开。

从一个端点开始

我们努力让 API 对 Spring 开发者来说感到熟悉。你会认出 @RestController、@GetMapping,以及在普通 Java 方法上声明路由的思路。这些注解位于 Codename One 的包中,构建会生成路由代码。

一个新生成的 Codename One 项目包含一个 backend 模块。将这个类放在 backend/src/main/java/com/example/backend/Health.java:

$ java
package com.example.backend;

import com.codenameone.backend.annotations.GetMapping;
import com.codenameone.backend.annotations.RestController;

@RestController
public class Health {
    @GetMapping("/healthz")
    public String health() {
        return "ok";
    }
}

在项目根目录下运行:

$ bash
mvn -pl backend -Dcodename1.platform=backend cn1:backend

然后在另一个终端中调用它:

$ bash
curl http://localhost:8080/healthz
# ok

构建会生成路由器和入口点。无需配置应用服务器,也没有手写监听器需要保持存活。codename1.platform 属性会在 Maven 反应器中激活后端模块。

要进行原生打包,请使用:

$ bash
mvn -pl backend -Dcodename1.platform=backend cn1:backend-package

后端教程进一步介绍了设置、数据库代码和部署命令。

一个模型直达数据库

ORM 是在最初的后端工作之后到来的,它改变了你用这个首个版本可以构建什么。我们现在拥有连接池、可移植 SQL 参数、生成的数据访问对象、查询,以及在受支持数据库引擎上的事务。

共享实体使用与客户端 ORM 相同的注解:

$ java
import com.codename1.annotations.Column;
import com.codename1.annotations.Entity;
import com.codename1.annotations.Id;

@Entity(table = "reminders")
public class Reminder {
    @Id public long id;
    @Column(nullable = false) public String title;
    public boolean done;
}

在客户端,生成的 DAO 与本地 SQLite 配合工作。在后端模块中,生成的 DAO 使用服务器的数据源和 SQL 方言。控制器可以声明一个 EntityManager 构造器参数,生成的入口点会提供它。

$ java
Dao<Reminder> reminders = entities.dao(Reminder.class);
List<Reminder> open = reminders.query()
        .eq("done", Boolean.FALSE)
        .limit(20)
        .list();

该查询指定了一个 Java 字段。ORM 会解析其列,并为所选数据库绑定值。你不需要在每个 SQLite 查询旁边都写一个 PostgreSQL 分支。

这仍然是一个小型后端。关系使用显式外键字段,生产环境的模式迁移属于你的部署流程。但一个具有持久化对象、事务和共享应用契约的服务,已经是一个有用的起点。明天的从应用到 PostgreSQL 的一个 Java 模型将逐步构建该服务。

这将走向何方?

我们致力于让 Codename One 保持后端无关。如果 Spring、Node.js、Go 或其他服务适合你的应用,请继续使用它。你永远不必为了构建 Codename One 应用而采用我们的后端。这个选择始终属于你。

我们将后端视为我们已有能力的重要扩展。我们花了多年时间让 Java 在小型、受限环境中运行。一个需要快速启动并适应小型部署的服务,正属于这个世界。而且我们可以把集成能力一起带过来:共享模型和验证、生成的 REST 客户端和服务器分发器,以及基于相同实体定义构建的 DAO。这样应用团队需要重复编写并保持同步的代码就更少。

我们也在嵌入式 Linux 系统中看到了潜力:一个收集传感器读数的网关、设备内部的本地服务,或一个需要 API 但不想使用大型运行时的工业设备。一个小型 ARM64 可执行文件,具备 HTTP 和数据库访问能力,是这些应用的有用起点。相同的共享 Java 模型可以把该设备与其移动应用连接起来。

我们还希望我们的运行时能处理更高要求的工作负载。一台繁忙的服务器为我们提供了另一种发现对手机也重要的开销的方式,即使这两个目标平台需要不同的功能。

我们向后端添加了虚拟线程,因为数千个连接需要有地方等待。为每个等待连接分配自己的操作系统线程会带来栈和调度开销。给它一个可恢复的栈,可以让少量宿主线程继续服务其他连接。

这些是 ParparVM 自己的虚拟线程。在受支持的原生后端构建中,HTTP 服务器会自动选择它们。应用处理器可以保持普通的阻塞式风格,而套接字机制会挂起和恢复它们。

我们不会把那种服务器并发模型带到移动端。它对我们来说并不能解决有用的移动端工作负载,而且移动系统限制会使其实现更加困难。其他改进则能很好地迁移。后端工作暴露了收集器缓冲区在繁忙期增长,并在之后保留其峰值分配的问题。运行时现在会收缩这些缓冲区。当昨天的突发流量不再占用今天的内存时,手机也会受益。

这就是为什么我们想继续投资后端。它为开发者提供了一个有用的部署目标,让共享 Java 应用更容易构建,并让更多真实工作负载经过我们客户端应用所依赖的编译器和运行时。


本周发布的其他内容涵盖客户端 API、平台更新以及编译器本身。让我们从手机和浏览器中的加密存储开始。## 延伸到浏览器的保险库

当同一用户需要在手机和浏览器上访问自己的数据时,加密存储会变得很别扭。把加密密钥放在加密记录旁边,并不能解决这个问题。

新的 Vault API 会为每个保险库生成一个随机数据密钥,并存储该密钥的封装副本。在新设备上可以用密码解锁。已记住的设备可以通过其本地密钥机制重新打开。在受支持的浏览器中,通行密钥(passkey)可以在用户验证后派生出解包所需的材料。

VaultOptions options = new VaultOptions()
        .policy(UnlockPolicy.SESSION_ONLY)
        .autoLockAfter(5 * 60 * 1000);

Vault vault = Vault.named("notes").configure(options);
vault.enroll(password, options).ready(ok -> {
    vault.seal("note-7", noteBytes)
            .ready(sealed -> upload("note-7", sealed));
});

这里 password 是 char[],noteBytes 包含笔记内容,upload 是你应用的密文传输。同步服务器存储密封后的记录。它不需要保险库密码或数据密钥。

浏览器实现使用认证加密和不可提取的密钥句柄,并通过 WebAuthn PRF 路径实现通行密钥解锁。这使我们能够在一个过去常常忍不住把原始密钥交给 JavaScript 的目标平台上,提供有用的保护。周日的一个保险库,从你的手机到浏览器详细介绍了注册、同步以及如何选择解锁策略。PR #5821 包含该实现。

邀请必须挺过应用商店这一关

你为应用花了 100 美元广告费。钱花得值吗?点击量并不能告诉你,这些人是否安装了应用、使用了它,或者购买了任何东西。如果你无法追踪这条路径,就很容易继续为无效广告付费。

“邀请朋友”也有同样的归因问题。有人分享了一个链接,一位朋友安装了应用,而这位朋友最终可能成为付费客户。你想知道是哪次邀请带来了这项活动。已有不少创业公司就是围绕着保持这些关联不中断而建立起来的。

困难之处在于,要在每一次交接中携带邀请码。链接离开一个应用,打开浏览器,并可能在你的应用还没出现在对方手机上之前,先把接收者送经应用商店。让这个代码始终发挥作用,就像在多块场地上打排球,而各方对规则几乎没有共识。

Codename One 现在通过 com.codename1.analytics.invite 来处理这段旅程:

Invite invite = Invites.create(InviteRequest.create()
        .campaign("team-launch")
        .channel("share_sheet")
        .title("Join our team")
        .description("Use the app with us.")
        .build());
Invites.share(invite, "Come and try this with me");

Android 通过 Play 安装引荐来源传递该代码。在 iOS 上,生成的 App Clip 接收链接,并通过 App Group 将代码传给完整应用。已安装的应用则直接接收该链接。这些都是精确的代码交接,因此系统无需猜测哪次点击属于哪次安装。

当用户授予分析同意后,后续的转化和购买事件会携带该邀请的营销活动和渠道信息。你可以沿着一次邀请,越过安装环节,一直追踪到它为你的应用带来的活动。周一的邀请朋友最难的部分是安装介绍了 Java API 以及让这些交接得以实现的平台设置。实现位于 PR #5751。

Xcode 27,以及下一轮 iOS 工作

我们的构建器支持 Xcode 27。截至本次发布,它尚未部署到我们的云构建服务器上;我们正在等待 Apple 的更新,然后再决定如何推进这一推广。

一些开发者已经遇到与其构建设置中较旧的最低版本相关的提交问题。PR #5788 会读取所选 SDK 的部署下限,并按需提高生成的目标版本。PR #5855 还处理 iOS 27 的启动屏和场景生命周期要求。它会在所有 SDK 上拒绝三个已移除的提示项:ios.generateSplashScreens、ios.uiscene 和 ios.launchStoryboardName,无论它们的值是什么。重新构建前,请彻底删除这些提示项。

如果 Apple 以最低版本或启动配置为由拒绝你的提交,请告诉我们,并附上拒绝文本和构建提示项。我们提高了最低版本,但现有项目配置范围很广,因此你的报告很有用。

既然 iOS 27 已经发布,我们将在接下来几周里致力于设备主题保真度。我们还希望在 Apple 工具到位后,为 iPhone Duo 的折叠行为增加更深入的支持。周二的 Xcode 27:可能让发布止步的构建设置 解释了有哪些变化,以及在旧项目中需要检查什么。

桌面主题需要桌面行为

原生桌面主题可在深度实验模式下使用。JavaSE 可在 Windows 上选择 Fluent,在 macOS 上选择 Aqua,在 GNOME 上选择 Adwaita。原生 macOS 移植版也可以选择 Aqua。

codename1.arg.desktop.themeMode=native
codename1.arg.macos.themeMode=native

这些设置属于选择启用。原生 macOS 默认保留其当前的现代主题;JavaSE 保留其现有的桌面选择。在这项工作发展期间,我们会保留这些默认值。

这项工作不止于颜色。指针需要悬停行为,而这种行为不会泄漏到触摸输入中。桌面文本需要正确的字体度量。浅色和深色变体需要相同的组件词汇表,这样你的 CSS 覆盖在各平台上仍然有用。

周三的 桌面主题必须了解鼠标 展示了参考截图和可用控件。PR #5845 添加了这些主题和保真度测试。

ParparVM 编译 ParparVM

我们的翻译器现在已能自举。ParparVM 可以将其自身翻译器的 Java 代码翻译成原生可执行文件,而这个可执行文件可以执行另一次翻译。

这是朝着用 Codename One 构建 Codename One 迈出的一个有用里程碑。它也是一项要求苛刻的应用测试。翻译器要处理字节码、集合、字符串、文件、异常以及大量内存分配。在 HotSpot 之外运行它,发现了我们现有测试工作负载未曾暴露的 bug。

其中一个 bug 平凡得可爱:生成的 C 标签继承了 ASM 基于标识的名称。负的标识哈希可能会在 C 标签中放入一个减号。另一个 bug 代价高昂:常量池查找在每次插入时都会扫描一个不断增长的列表。自举既给了我们输出对比,也给了我们一个值得剖析的工作负载。

与 HotSpot 相比,仍然存在性能和内存差距,我们正在逐步解决。周四的 成为自己测试用例的 Java 编译器 追踪了来自 PR #5766 的修复和剩余工作。

三个你会注意到的小改动

路线可以为其停靠点命名。 PR #5853 为矢量地图添加地点标签和路线停靠点标签。配送路线可以标出“仓库”“客户”和“站点”,而不必让用户去推断哪个标记代表什么。

request.setOriginLabel("Warehouse")
        .addWaypoint(customerLocation, "Customer")
        .setDestinationLabel("Depot");

这里 request 是一个 RouteRequest,customerLocation 是一个 LatLng。这些标签是显示文本;它们不会改变发送给路线规划服务的坐标。

仓库需要围绕其构建的历史更少了。 PR #5820 移除了旧的 J2ME、BlackBerry 和 retrolambda 构建路径。这些已退役的目标不再需要分散我们对所维护移植版的注意力。

较旧的 SVG 项目会获得缺失的构建步骤。 在 SVG 转码器出现之前创建的项目,可能拥有图形资源,却缺少将其转换为绘图代码的 Maven 执行。PR #5778 会在构建期间检测到这一点,运行转码器,并将缺失的执行添加到 common/pom.xml,同时保留 pom.xml.bak。这是一种有意采取的主动修复,针对的是一种否则看起来像图片缺失的故障。

更多应用逻辑,更少需要维护的交接

本周将 Codename One 带入服务器端,同时不夺走你对后端的选择。它还让浏览器成为保存加密数据的更有用场所,让邀请跨越安装环节,并为原生构建迎接下一轮平台变化做好准备。

安全工作将这些部分连接起来。保险库可以拒绝设备无法提供的解锁策略。推荐链接可以携带精确代码,而无需对访客进行指纹识别。共享验证规则可以在服务器上再次运行,而服务器才是关键所在。构建器可以在有问题的启动配置到达 App Review 之前将其拒绝。

这正是我们继续推动默认安全编程的方向:将重复且安全敏感的工作移入我们在整个技术栈中维护的 API 和构建步骤。你仍然决定谁可以读取记录或兑换奖励。在这一过程中,你应该需要操心的平台管道更少,也就更不容易出错。
分享此页面

  • 链接已复制

发现错误,或有内容要补充?在 GitHub 上编辑此页面 [LOADING...]
作者

Shai Almog

作者、DevRel、博主、开源黑客、Java 摇滚明星、会议演讲者、讲师和企业家。

加入讨论