自定义模型
概述
Halo 自定义模型是参考自 Kubernetes CRD 的一种灵活可扩展的数据存储和使用方式,旨在为插件开发者提供自定义数据支持。 自定义模型遵循 OpenAPI v3,便于开发者在插件中存储、读取和操作自定义数据。 详情请参考 自定义模型设计。
示例
以下是一个典型的自定义模型代码示例:
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupKind;
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@GVK(group = "my-plugin.halo.run",
version = "v1alpha1",
kind = "Person",
plural = "persons",
singular = "person")
public class Person extends AbstractExtension {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private Spec spec;
@Data
@Schema(name = "PersonSpec")
public static class Spec {
@Schema(description = "The description on name field", maxLength = 100)
private String name;
@Schema(description = "The description on age field", maximum = "150", minimum = "0")
private Integer age;
@Schema(description = "The description on gender field")
private Gender gender;
private Person otherPerson;
}
public enum Gender {
MALE, FEMALE,
}
}
创建自定义模型步骤
创建一个自定义模型需要以下三个步骤:
- 继承
AbstractExtension
类:创建一个类继承run.halo.app.extension.AbstractExtension
。 - 使用
GVK
注解:通过GVK
注解定义自定义模型的基本信息,包括 group、version、kind 等。 - 注册自定义模型:在插件的
start()
生命周期方法中注册自定义模型。
@Autowired
private SchemeManager schemeManager;
@Override
public void start() {
schemeManager.register(Person.class);
}
GVK
注解详解
- group:表示自定义模型所属的组,通常采用域名形式,建议使用你的组织或公司拥有的子域名。例如
widget.mycompany.com
。 - version:API 的版本,通常用于与 group 组合形成
apiVersion
,例如api.halo.run/v1alpha1
。 - kind:标识自定义模型的类型,即资源的 REST 表示形式。
- plural/singular:自定义资源的复数和单数名称,用于在 API 路径中标识资源类型。
- singular: 必须全部小写,通常是将
kind
的值转换为小写作为singular
的值。 - plural:自定义资源在
/apis/<group>/<version>/.../<plural>
下提供,必须为全部小写,通常是将kind
的值转换为小写并转为复数形式作为plural
的值。
- singular: 必须全部小写,通常是将
自定义模型定义结构
一个自定义模型通常包含以下几部分:
apiVersion
:标识 API 版本,由GVK
注解的group
和version
组合而成。kind
:标识自定义模型类型。metadata
:Metadata 类型,用于存储模型的元数据,如名称、创建时间。spec
:声明自定义模型对象的期望状态。它是声明式的,用户只需要声明期望状态,实际状态由具体的控制器来维护,最终达到用户期望的状态。status
:描述自定义模型对象资源的实际状态。
apiVersion
、kind
和 metadata
已包含在 AbstractExtension
类中,开发者只需关注 spec
和 status
即可。
Metadata
自定义模型的 Metadata 包含以下属性:
name
: 用于标识自定义模型的名称。creationTimestamp
: 用于标识自定义模型的创建时间,无法修改,只在创建时自动生成。version
: 用于标识自定义模型的数据乐观锁版本,无法修改,由更新时自动填充,如果更新时指定了version
且与当前version
不一致则会更新失败。deletionTimestamp
: 用于标识自定义模型的删除时间,表示此自定义模型对象被声明为删除,此时仍然可以通过 API 访问到此对象,参考 自定义模型对象生命周期finalizers
: 用于标识终结器,它是一个字符串集合,用于标识自定义模型对象是否可回收,参考 自定义模型对象生命周期labels
: 用于标识自定义模型的标签,它是一个字符串键值对集合,用于标识自定义模型对象的标签,可以通过标签来查询自定义模型对象。annotations
: 用于存放扩展信息,它是一个字符串键值对集合,用于存放自定义模型对象的扩展信息。
声明自定义模型对象
在创建了自定义模型之后,可以通过在插件项目的 src/main/resources/extensions
目录下编写 yaml
文件来声明自定义模型对象。示例如下:
apiVersion: my-plugin.halo.run/v1alpha1
kind: Person
metadata:
name: fake-person
spec:
name: halo
age: 18
gender: male
该目录下所有的 yaml
文件中声明的自定义模型对象都会在插件启动后被创建/更新,文件名是任意的,只需根据 kind
和 apiVersion
确定类型。
基于这个特性,开发者可以将一些初始化资源的声明放在这个目录下,以便在插件启动时自动创建。但需要注意的是,如果资源如配置等能被用户修改,则不应该放在这个目录下,因为这些资源会在插件启动时被强制覆盖。
校验自定义模型对象
Halo 使用 OpenAPI v3 标准来定义自定义模型。 OpenAPI 规范定义了自定义模型的数据结构、字段属性及其校验规则,然后将其转换为 JSON Schema,注册到 Halo 的 SchemeManager 中。
使用 @Schema
注解可以为自定义模型的字段添加校验规则,@Schema
是 OpenAPI 提供的一个注解,通过这个注解,我们可以在生成的 OpenAPI 文档中展示字段的详细信息(如名称、描述、类型、是否必填等),同时也可以对字段进行一定的校验,比如限制字段的最大长度、最小值、格式等。
基本用法
@Schema
注解中有许多可用的属性,用来对字段进行更加细致的校验和文档说明。下面是一些常用的属性:
- description:字段的描述信息,用于在文档中展示。
- example:字段的示例值。
- requiredMode:是否必填字段。
- minLength:字符串字段的最小长度。
- maxLength:字符串字段的最大长度。
- minimum:数值字段的最小值。
- maximum:数值字段的最大值。
- format:字段的格式,常用于指定日期、时间、邮箱等特殊格式。
例如,如果我们有一个电子邮件字段,并且想要校验它的格式,可以这样定义:
@Schema(description = "用户电子邮箱", example = "user@example.com", format = "email")
private String email;
当用户向 API 提交一个自定义模型对象时,Halo 会根据自定义模型中定义的 OpenAPI @Schema
注解对对象进行以下几个步骤的校验:
- 基本结构校验:验证对象的字段结构是 否符合定义的 OpenAPI 模式,例如字段类型是否正确、是否存在必填字段等。
- 字段约束校验:针对特定字段的约束条件(如最小值、最大长度、正则表达式等)进行校验,确保字段值符合条件。
- 成功或失败:如果校验通过,Halo 接受并存储该对象;如果校验失败,会返回详细的错误信息,说明哪些字段不符合要求
参考示例 Person。
使用索引
为了让插件可以方便的定义自定义模型定义,而不需要考虑操作数据库表的细节且可以切换存储介质如可以使用 MySQL
,PostgreSQL
,H2
等数据库来来作为存储介质,数据存储使用 byte[]
的形式,这使得无法利用数据库的原生索引来提高查询效率。
Halo 提供了一套索引机制,开发者可以通过注册自定义模型时声明索引来提高查询效率。
示例:
import static run.halo.app.extension.index.IndexAttributeFactory.multiValueAttribute;
import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute;
@Override
public void start() {
schemeManager.register(Moment.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.tags")
// multiValueAttribute 用于得到一个返回多个值的索引函数
.setIndexFunc(multiValueAttribute(Moment.class, moment -> {
var tags = moment.getSpec().getTags();
return tags == null ? Set.of() : tags;
}))
// simpleAttribute 用于得到一个返回单个值的索引函数,可以返回 null
indexSpecs.add(new IndexSpec()
.setName("spec.owner")
.setIndexFunc(
simpleAttribute(Moment.class, moment -> moment.getSpec().getOwner())));
);
}
IndexSpec
用于声明索引项,它包含以下属性:
- name:索引名称,在同一个自定义模型的索引中必须唯一,一般建议使用字段路径作为索引名称,例如
spec.slug
。 - order:对索引值的排序方式,支持
ASC
和DESC
,默认为ASC
。 - unique:是否唯一索引,如果为
true
则索引值必须唯一,如果创建自定义模型对象时检测到此索引字段有重复值则会创建失败。 - indexFunc:索引函数,用于获取索引值,接收当前自定义模型对象,返回一个索引值,索引值必须是字符串任意类型,如果不是字符串类型则需要自己转为字符串,可以使用
IndexAttributeFactory
提供的静态方法来创建indexFunc
:simpleAttribute()
:用于得到一个返回单个值的索引函数,例如moment -> moment.getSpec().getSlug()
。multiValueAttribute()
:用于得到一个返回多个值的索引函数,例如moment -> moment.getSpec().getTags()
。
当注册自定义模型时声明了索引,Halo 会在插件启动时构建索引,在构建索引期间插件处于未启动状态。
Halo 默认会为每个自定义模型建立以下几个索引,因此不需要为下列字段再次声明索引:
metadata.name
创建唯一索引metadata.labels
metadata.creationTimestamp
metadata.deletionTimestamp
创建了索引的字段可以在查询时使用 fieldSelector
参数来查询,参考 自定义模型 API。
- 索引是一种存储数据结构,可提供对数据集中字段的高效查找。
- 索引将自定义模型中的字段映射到数据库行,以便在查询特定字段时不需要完整的扫描。
- 查询数据之前,必须对需要查询的字段创建索引。
- 索引可以包含一个或多个字段的值,可以包含唯一值或重复值。索引中的值按照索引中的顺序进行排序。
- 索引可以提高查询性能,但会占用额外的存储空间,因为它们需要存储索引字段的副本。索引的大小取决于字段的数据类型和索引的类型,因此,创建索引时应该考虑存储成本和性能收益。
命名规范
metadata.name
metadata.name
是自定义模型对象的唯一标识名,需遵循以下规则:
- 不超过 253 个字符。
- 只能包含小写字母、数 字和
-
,且以字母或数字开头和结尾。
labels
labels
是一个字符串键值对集合,用于标识模型的标签,格式为 <prefix>/<name>
。例如,halo.run/post-slug
。遵循以下规则:
- 前缀是可选的,通常是反向的域名表示形式,用于避免键名冲突。
- 名称必须是合法的 DNS 标签,最多 63 个字符,且以字母数字字符开头和结尾。
建议保持标签的命名简洁易懂,在整个项目中保持一致性,不包含敏感信息。
需要注意的是,metadata.labels
被用于通过标签查询自定义模型对象。它会被自动创建索引,因此使用时需谨慎,避免索引过多导致性能问题,对于不需要索引的额外字段,可以使用 metadata.annotations
。
labels 命名规范
前缀规则:
- 如果 label 用于特定于一个组织的资源,建议使用一个前缀,如
plugin.halo.run/plugin-name
。 - 前缀必须是一个有效的 DNS 子域名(参考 metadata.name),且最多可包含 253 个字符。
- 保留了不带前缀的 label 键以及特定前缀(如 halo.run),因此插件不可使用。
名称规则:
- 名称必须是合法的 DNS 标签,最多可包含 63 个字符。
- 必须以字母数字字符开头和结尾。
- 可以包含
-
、.
、_
和字母数字
字符。
通用规范:
- 避免使用容易引起混淆或误解的键名。
- 尽量保持简洁明了,易于理解。
- 使用易于记忆和识别的单词或缩写。
一致性和清晰性:
- 在整个项目或组织中保持一致的命名约定。
- labels 应直观地反映其代表的信息或用途。
- 不要在 labels 中包含敏感信息,例如用户凭据或个人识别信息。
annotations
annotations
是一个字符串键值对集合,用于存放扩展信息,命名规则与 labels
相同。
可以使用 metadata.annotations
存放一些额外的信息,如 JSON 数据、配置信息等。
自定义模型 API
定义并注册自定义模型后,Halo 会根据 GVK
注解自动生成一组 CRUD
APIs。
生成 APIs 的规则为:/apis/<group>/<version>/<extension>/{extensionname}/<subextension>
例如,Person
自定义模型将有以下 APIs:
GET /apis/my-plugin.halo.run/v1alpha1/persons
:列出所有对象。GET /apis/my-plugin.halo.run/v1alpha1/persons/{name}
:根据名称查询对象。POST /apis/my-plugin.halo.run/v1alpha1/persons
:创建对象。PUT /apis/my-plugin.halo.run/v1alpha1/persons/{name}
:更新对象。DELETE /apis/my-plugin.halo.run/v1alpha1/persons/{name}
:删除对象。
其中,列出所有对象的 API 支持以下参数:
- page:页码,从 1 开始。
- size:每页的数据量。
- sort:排序字段,格式为
字段名,排序方式
,例如name,desc
,可传递多个排序字段,排序使用的字段必须是注册为索引的字段。 - labelSelector:标签选择器,用于筛选特定标签的对象。详见 标签选择器参数规则。
- fieldSelector:字段选择器,用于筛选注册为索引的字段。详见 字段选择器参数规则。
示例:
GET /apis/my-plugin.halo.run/v1alpha1/persons?page=1&size=10&sort=name,desc&labelSelector=name=halo&fieldSelector=spec.slug=halo
表示查询 metadata.labels
中 name
的值等于 halo
且 spec.slug
的值等于 halo
的自定义模型对象,并按照 name
字段降序排序,查询第 1 页,每页 10 条数据。
自定义模型 API 业务逻辑
自动生成的 CRUD
APIs 不仅只是简单的数据操作,你可以通过定义控制器 来实现对数据的业务逻辑处理。
自定义模型控制器是专门为自定义模型设计的,它允许用户通过自定义逻辑来响应自定义模型对象的变化,执行自动化操作,从而扩展这组自动生成 APIs 的功能。
自定义模型控制器通常会:
- 监控自定义模型的变化:当某个自定义模型的对象被创建、更新或删除时,控制器会被触发,读取该对象的状态信息。
- 执行特定的业务逻辑:根据自定义模型的状态和需求,控制器可以执行某些动作,如创建或删除其他资源,或调用外部系统进行处理。
- 维护资源的期望状态:控制器的目标是确保自定义模型的状态符合期望状态,维护资 源的稳定性。
参考 自定义模型控制器 文档。
选择器参数规则
标签选择器
labelSelector
:标签选择器,用于筛选特定标签的对象,支持以下操作符:
=
表示等于,例如labelSelector=name=halo
表示查询metadata.labels
中name
的值等于halo
的自定义模型对象。!=
表示不等于,例如labelSelector=name!=halo
表示查询metadata.labels
中name
的值不等于halo
的自定义模型对象。!
表示不存在 key,例如labelSelector=!name
表示查询metadata.labels
不存在name
这个 key 的自定义模型对象。存在检查
表示查询存在 key 的对象,例如labelSelector=name
表示查询metadata.labels
存在name
这个 key 的自定义模型对象。
字段选择器
fieldSelector
:字段选择器,格式与 labelSelector
类似,但需要确保对应的字段是注册为索引的字段。
例如 fieldSelector=spec.name=slug
表示查询 spec.slug
的值等于 halo
的自定义模型对象。
支持的操作符有 =
、!=
和 in
:
=
表示等于,例如fieldSelector=spec.slug=halo
表示查询spec.slug
的值等于halo
的自定义模型对象。!=
表示不等于,例如fieldSelector=spec.slug!=halo
表示查询spec.slug
的值不等于halo
的自定义模型对象。in
表示在集合中,例如fieldSelector=spec.slug=(halo,halo2)
表示查询spec.slug
的值为halo
或halo2
的自定义模型对象。
自定义 API
对于自动生成的 CRUD
APIs 不能满足的场景,开发者可以通过定义自定义 API 来扩展功能。
推荐使用 Spring Webflux 的 Functional Endpoints
来编写轻量级自定义 APIs:
RouterFunction<ServerResponse> route = route()
.GET("/persons/{name}", accept(APPLICATION_JSON), this::getPerson)
.POST("/persons", this::createPerson)
.build();
- HandlerFunction:用于处理请求,接收
ServerRequest
并返回ServerResponse
。 - RouterFunction:将请求路由到相应的处理函数。
这样开发者可以灵活定义符合业务需求的 APIs,方便地扩展插件的功能。
自定义 APIs 与自动生成的 APIs 一样,都应该遵循以下规范:
/apis/<group>/<version>/<extension>/{extensionname}/<subextension>
路径不超过 7 段,如果超过则应当以参数的形式传递或改进路径设计,否则无法适应角色模板的规则,参考 角色模板。
自定义 API 的 Group 规则
为了确保插件定义的自定义 APIs 不与其他插件的 APIs / 自动生成的 APIs
冲突,Halo 约定通过不同的 group 来区分,遵循以下规则:
- 在 Console 端使用的自定义 API 的 group 规则遵循
console.api.<group>
。 - 在个人中心使用的自定义 API 的 group 规则遵循
uc.api.<group>
,例如uc.api.my-plugin.halo.run
。 - 为主题端提供的公开的自定义 API 的 group 规则建议为
api.<group>
,例如api.my-plugin.halo.run
。
其中 <group>
为自定义模型的 GVK
注解中的 group
。
例如,Person
自定义模型需要提供一个在 Console 使用的自定义 API,可以定义如下:
// my-plugin.halo.run 为 Person 自定义模型的 group
// console.api. 为 Console 端自定义 API 的 group 前缀
RouterFunction<ServerResponse> route = route()
.GET("/apis/console.api.my-plugin.halo.run/v1alpha1/persons/{name}",
accept(APPLICATION_JSON), this::getPerson)
.build();