Ohhnews

分类导航

$ cd ..
DZone Java原文

OpenAPI、ORM、SVG 和 Lottie

#openapi#sqlite orm#svg#lottie#构建时代码生成

这是周五发布公告之后的第三篇跟进文章。周六专注于迭代方式;昨天讨论了核心新平台API;今天则聚焦一组改变应用结构部分编写方式的内容。

这组内容包括:OpenAPI客户端生成器、SQLite ORM、JSON和XML映射器、带验证的组件绑定器、构建时SVG和Lottie转码器,以及带深度链接的声明式路由器。所有这些都依托于一个构建时代码生成管道:一个Maven插件在构建时读取注解或声明式源文件,生成类型化的Java代码,编译进你的二进制文件中。无需反射、无需服务加载器、无需Class.forName。本文末尾的“工作原理”部分将介绍代码生成的底层机制,前提是你已经看到了它的能力。

OpenAPI客户端生成

本次更新的重头戏适用于任何与后端通信的团队。新的cn1:generate-openapi-client Mojo读取一份OpenAPI 3.x JSON规范(URL或本地文件),并生成可直接编译进应用的CodeName One客户端代码:

  • 每个components.schemas条目对应一个@Mapped POJO。
  • 每个OpenAPI标签对应一个Api.java类,其中每个操作对应一个流畅方法。
  • 每个方法均通过Rest. + Mappers.toJson + fetchAsMapped / fetchAsMappedList路由,使生成的接口与框架其余部分集成,而非引入独立的HTTP栈。

在项目的pom.xml中配置如下:

$ xml
<execution>
  <id>petstore-client</id>
  <goals>
    <goal>generate-openapi-client</goal>
  </goals>
  <configuration>
    <spec>https://petstore3.swagger.io/api/v3/openapi.json</spec>
    <packageName>com.example.petstore</packageName>
  </configuration>
</execution>

运行mvn generate-sources,插件会读取规范、下载它,并为每个模式和每个标签在target/generated-sources/下各写一个文件。经过端到端测试的Petstore参考规范生成了6个模型类(PetOrderCustomerTagCategoryUser)和3个API类(PetApiStoreApiUserApi),这9个生成的.class文件可成功编译,与codenameone-core无冲突。文档参见OpenAPI代码生成Maven目标

在应用代码中,调用生成的Api类就像调用任何其他Java方法一样:

$ java
PetApi pets = new PetApi();

// 返回AsyncResource<Pet>;结果解析为反序列化对象。
pets.getPetById(42).onResult((pet, err) -> {
    if (err == null) Log.p("Got " + pet.getName());
});

// 返回AsyncResource<List<Pet>>。
pets.findPetsByStatus("available").onResult((list, err) -> {
    if (err == null) {
        for (Pet p : list) Log.p(p.getName());
    }
});

// POST请求体。addPet接受Pet,返回Pet。
Pet candidate = new Pet();
candidate.setName("Mittens");
candidate.setStatus("available");
pets.addPet(candidate).onResult((created, err) -> { /* ... */ });

无需手动编写ConnectionRequest设置、无需手工JSON解析、无需字符串类型的请求体。生成的客户端接受类型化的Pet,使用Mappers.toJson(...)序列化,发出正确的HTTP动词,使用Mappers.fromJson(...)反序列化响应,并通过框架的AsyncResource将结果传递出去,使你的回调在EDT上触发。

对于那些后端已发布OpenAPI规范的团队(大多数现代后端框架会自动生成,如FastAPI、Spring的springdoc-openapi、NestJS、ASP.NET Core、Go的gnostic),实际效果是移动客户端的绑定与后端保持同步,无需任何人手写任何网络调用。更新规范,重新运行mvn generate-sources,新加入和变更的端点就会以类型化Java代码的形式出现在你的应用中;IDE会立刻识别。当你不知道自己拥有它时,这类变更最为有用:拉取新规范,重新构建,IDE就会高亮代码库中所有调用了重命名端点或传递了错误类型参数的位置。

SQLite ORM

@Entity标记类;@Id@Column定义模式;@DbTransient将字段排除在外:

$ java
@Entity
public class TodoItem {
    @Id @Column long id;
    @Column String title;
    @Column(name = "completed_at") Date completedAt;
    @DbTransient Object cachedView;
}

Dao<TodoItem> dao = EntityManager.open("todos.db").dao(TodoItem.class);
dao.createTable();
dao.insert(new TodoItem(0, "Read the post", null));
List<TodoItem> open = dao.find("completed_at IS NULL", new Object[] {});
TodoItem byId = dao.findById(42);
dao.delete(byId);

生成的DAO在底层执行类型化工作。在insert中无需反射;生成的代码直接调用setString(1, e.title)setLong(2, e.id)到SQLite的PreparedStatement。构建时验证会检查缺少@Id、看起来像关系但尚未支持的字段以及抽象实体类;构建会在出现问题时失败,并显示类名和原因。

对于JPA/Hibernate开发者,这个API故意设计得熟悉。@Entity@Id@Column@Transient(此处重命名为@DbTransient以避免与java.beans.Transient冲突)的含义与javax.persistence/jakarta.persistence下相同。EntityManager名称相同。Dao#findByIdDao#findAllDao#find(where, params)Dao#insertDao#updateDao#delete与基本的JPA仓库契约一致。查询语言是纯SQL(没有JPQL或Criteria DSL),但注解表面、生命周期和运行时方法会让任何具有服务端Java持久化经验的开发者感到亲切。

JSON/XML映射

@Mapped标记一个类为可传输POJO。@JsonProperty@XmlElement(加上@XmlRoot@XmlAttribute@JsonIgnore@XmlTransient)定义有线格式。运行时入口点是Mappers.toJson(...)Mappers.fromJson(...)Mappers.toXml(...)Mappers.fromXml(...)

$ java
@Mapped
public class User {
    @JsonProperty("user_id") long id;
    @JsonProperty String name;
    @JsonProperty("created_at") Date createdAt;
    @JsonIgnore String passwordHash;
}

String json = Mappers.toJson(user);
User back = Mappers.fromJson(json, User.class);

同一个@Mapped POJO是类型化Rest辅助方法接受的类型:

$ java
Rest.get("https://api.example.com/users/42")
    .fetchAsMapped(User.class)
    .onResult((user, err) -> { /* ... */ });

Rest.get("https://api.example.com/users")
    .fetchAsMappedList(User.class)
    .onResult((users, err) -> { /* ... */ });

Rest.fetchAsJsonList(顶层JSON数组,无需{"root":[...]}包装技巧)、JSONWriter(与JSONParser互补,提供流畅构建器和Writer/OutputStream的流式变体)以及URLImage.setDefaultBearerToken(图片获取的认证头)也一并提供。

对于JAXB开发者,XML表面(@XmlRoot@XmlElement@XmlAttribute@XmlTransient)是长期存在的javax.xml.bind.annotation表面的直接移植。同一个模型类可以同时装饰@XmlRoot@JsonProperty,从而为两种有线格式提供单一事实来源。JSON表面采用了Jackson约定(@JsonProperty@JsonIgnore),几乎所有现代JVM JSON绑定工具(Jackson、Moshi、kotlinx-serialization)都继承自它。

带验证的组件绑定

同一管道上的第四个注解处理器是组件绑定器。@Bindable标记模型类;@Bind(name = "userField")通过组件的name将字段绑定到表单上的组件。字段级验证注解可与同一字段上的@Bind组合:

$ java
@Bindable
public class SignupModel {
    @Bind(name = "userField") @Required @Length(min = 3) private String user;
    @Bind(name = "emailField") @Required @Email private String email;
    @Bind(name = "ageField") @Numeric(min = 13, max = 120) private String age;
    @Bind(name = "roleField") @ExistIn({ "admin", "editor", "viewer" }) private String role;
}

对应的表单在每个组件上设置name,以便绑定器可以找到它们:

$ java
TextField user = new TextField(); user.setName("userField");
TextField email = new TextField(); email.setName("emailField");
TextField age = new TextField(); age.setName("ageField");
ComboBox<String> role = new ComboBox<>("admin", "editor", "viewer"); role.setName("roleField");
Button submit = new Button("Sign up");
Form form = new Form("Sign Up", BoxLayout.y());
form.add(user).add(email).add(age).add(role).add(submit);
form.show();

SignupModel model = new SignupModel();
Binding binding = Binders.bind(model, form);
binding.getValidator().addSubmitButtons(submit);

Binding是操作句柄:refresh()将模型数据重新读取到组件中,commit()将组件数据写回模型,disconnect()拆除监听器。单个字段上的多个验证注解通过Validator.addConstraint(Component, Constraint...)GroupConstraint(第一个失败优先)组合。@Validate(MyClass.class)是手工编写Constraint实现的逃生舱。

验证集合:@Required@Length@Regex@Email@Url@Numeric@ExistIn@Validate

新的BindAttr枚举允许@Bind定位组件的特定属性(TEXTUIIDSELECTED等),当默认行为(将String字段写入组件的文本)不符合需求时使用。

构建时SVG

将一个SVG文件放入src/main/css/目录,与theme.css一起:

src/main/css/
  theme.css
  star.svg
  gradient_circle.svg
  path_arrow.svg
  rounded_button.svg
  wave.svg
  pro_badge.svg

下次构建后,每个SVG都成为一个普通的CodeName One Image由转码器处理的SVG是矢量图像,但它仍然是一个Image。在所有接受光栅Image的地方(Label.setIconButton.setIconBorderLayout.NORTH、工具栏、MultiButton的前导图标、CSS的background: url(...)规则),SVG也同样适用。区别在于它在任何尺寸下都保持清晰:同一个源文件在16点列表行图标、64点英雄头部和256点启动屏幕上都很锐利,适用于每个DPI桶。

来自hellocodenameone测试用例的静态SVG网格,通过新管道渲染:

毫米大小指定

SVG转码器最实用的功能也是最容易被忽略的:通过CSS以毫米为单位指定每个SVG的大小。现实中的SVG往往声明奇怪的width/height属性(比如一个1024×1024的24×24图标导出,或者根本没有尺寸,或者来自某个特定框架的设计像素值)。以毫米为单位固定渲染大小可以规避所有这些问题。

$ style
HomeIcon {
  background: url(home.svg);
  cn1-svg-width: 6mm;
  cn1-svg-height: 6mm;
  bg-type: image_scaled_fit;
}

LogoBanner {
  background: url(logo.svg);
  cn1-svg-width: 32mm;
  cn1-svg-height: 12mm;
}

一个6毫米的图标在1×桌面上是6毫米高,在高DPI手机上也是6毫米,在4K平板上仍然是6毫米。转码器在安装时将这两个值通过Display.convertToPixels()路由,这与CodeName One CSS中font-size: 3mm的行为方式相同。没有设计像素猜测,无需选择DPI桶,当美工以不同分辨率重新导出源SVG时也不会出现缩放意外。

如果项目不使用CSS进行主题化,生成的类上的两个float构造函数可以直接以毫米为单位:new com.codename1.generated.svg.Home(6f, 6f)

覆盖范围与仍需反馈的内容

转码器是一个maven/svg-transcoder/模块,使用javax.xml StAX解析SVG。无需Batik、Flamingo或外部依赖。覆盖范围针对现实世界图标SVG常用的元素:rect(含圆角)、circleellipselinepolylinepolygon、完整的path语法(M/L/H/V/C/S/Q/T/A/Z,加上相对坐标和平滑曲线反射)、带仿射变换的分组(translatescalerotateskewmatrix)、通过LinearGradientPaint实现的线性渐变、填充、描边、描边宽度、线帽、线连接、透明度。SMIL动画在同一个管道中也受支持:<animate><animateTransform>translatescalerotate)以及<animateColor>。时间值在每次绘制时根据挂钟时间进行插值,支持from/to/values/begin/dur/repeatCount/fill="freeze"。文本和裁剪路径已经在静态SVG测试用例的后续PR中实现,并在上面的截图中可见(圆角按钮中的“Codename One / build-time SVG”标识、“PRO”徽章文本以及底部的裁剪路径形状圆角徽章)。<linearGradient><radialGradient>支持单样式填充和变换;通过clip-path="url(#id)"引用的<clipPath>支持针对rectcirclepath裁剪形状(忽略嵌套的裁剪引用)。

目前仍不支持的内容:SVG filter原语、<mask>(当作裁剪处理,因此Alpha遮罩退化为不透明)、<pattern>(退化为第一种颜色)以及SVG中的CSS(SVG文档内部的样式规则;转码器读取呈现属性和内联style="..."属性,但<style>块在DOM中会被忽略)。

(注:原文未完成,这里根据前文语气,可补上类似“但<style>块会被忽略”的翻译。由于原文以<style>开头,可能截断,但根据上下文,应翻译为:但<style>块在SVG文档中会被忽略。)

原文在"This release also includes..."之后缺失,但根据常见发布文章结构,可能还有更多内容。然而,给定的original_content到此结束,因此翻译也到此为止。