mirror of
https://github.com/halo-dev/docs.git
synced 2025-10-20 17:54:01 +00:00
docs: update documentations for 2.21 (#502)
Signed-off-by: Ryan Wang <i@ryanc.cc>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
---
|
||||
title: 与自定义模型交互
|
||||
description: 了解如何通过代码的方式操作数据
|
||||
---
|
||||
|
||||
Halo 提供了两个类用于与自定义模型对象交互 `ExtensionClient` 和 `ReactiveExtensionClient`。
|
||||
|
||||
它们提供了对自定义模型对象的增删改查操作,`ExtensionClient` 是阻塞式的用于后台任务如控制器中操作数据,而 `ReactiveExtensionClient` 返回值都是 Mono 或 Flux 是反应式非阻塞的,它们由 [reactor](https://projectreactor.io/) 提供。
|
||||
|
||||
```java
|
||||
public interface ReactiveExtensionClient {
|
||||
|
||||
// 已经过时,建议使用 listBy 或 listAll 代替
|
||||
<E extends Extension> Flux<E> list(Class<E> type, Predicate<E> predicate,
|
||||
Comparator<E> comparator);
|
||||
|
||||
// 已经过时,建议使用 listBy 或 listAll 代替
|
||||
<E extends Extension> Mono<ListResult<E>> list(Class<E> type, Predicate<E> predicate,
|
||||
Comparator<E> comparator, int page, int size);
|
||||
|
||||
<E extends Extension> Flux<E> listAll(Class<E> type, ListOptions options, Sort sort);
|
||||
|
||||
<E extends Extension> Mono<ListResult<E>> listBy(Class<E> type, ListOptions options,
|
||||
PageRequest pageable);
|
||||
|
||||
/**
|
||||
* Fetches Extension by its type and name.
|
||||
*
|
||||
* @param type is Extension type.
|
||||
* @param name is Extension name.
|
||||
* @param <E> is Extension type.
|
||||
* @return an optional Extension.
|
||||
*/
|
||||
<E extends Extension> Mono<E> fetch(Class<E> type, String name);
|
||||
|
||||
Mono<Unstructured> fetch(GroupVersionKind gvk, String name);
|
||||
|
||||
<E extends Extension> Mono<E> get(Class<E> type, String name);
|
||||
|
||||
/**
|
||||
* Creates an Extension.
|
||||
*
|
||||
* @param extension is fresh Extension to be created. Please make sure the Extension name does
|
||||
* not exist.
|
||||
* @param <E> is Extension type.
|
||||
*/
|
||||
<E extends Extension> Mono<E> create(E extension);
|
||||
|
||||
/**
|
||||
* Updates an Extension.
|
||||
*
|
||||
* @param extension is an Extension to be updated. Please make sure the resource version is
|
||||
* latest.
|
||||
* @param <E> is Extension type.
|
||||
*/
|
||||
<E extends Extension> Mono<E> update(E extension);
|
||||
|
||||
/**
|
||||
* Deletes an Extension.
|
||||
*
|
||||
* @param extension is an Extension to be deleted. Please make sure the resource version is
|
||||
* latest.
|
||||
* @param <E> is Extension type.
|
||||
*/
|
||||
<E extends Extension> Mono<E> delete(E extension);
|
||||
}
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
如果你想在插件中根据 name 参数查询获取到 Person 自定义模型的数据,则可以这样写:
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public PersonService {
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
Mono<Person> getPerson(String name) {
|
||||
return client.fetch(Person.class, name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
或者使用阻塞式 Client
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public PersonService {
|
||||
private final ExtensionClient client;
|
||||
|
||||
Optional<Person> getPerson(String name) {
|
||||
return client.fetch(Person.class, name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意:非阻塞线程中不能调用阻塞式方法。
|
||||
|
||||
我们建议你更多的使用响应式的 `ReactiveExtensionClient` 去替代 `ExtensionClient`。
|
||||
|
||||
### 查询 {#query}
|
||||
|
||||
`ReactiveExtensionClient` 提供了两个方法用于查询数据,`listBy` 和 `listAll`。
|
||||
|
||||
`listBy` 方法用于分页查询数据,`listAll` 方法用于查询所有数据,它们都需要一个 `ListOptions` 参数,用于传递查询条件:
|
||||
|
||||
```java
|
||||
public class ListOptions {
|
||||
private LabelSelector labelSelector;
|
||||
private FieldSelector fieldSelector;
|
||||
}
|
||||
```
|
||||
|
||||
其中 `LabelSelector` 用于传递标签查询条件,`FieldSelector` 用于传递字段查询条件。
|
||||
|
||||
`FieldSelector` 支持比自动生成的 APIs 中更多的查询条件,可以通过 `run.halo.app.extension.index.query.QueryFactory` 来构建。
|
||||
|
||||
```java
|
||||
ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.and(
|
||||
QueryFactory.equal("name", "test"),
|
||||
QueryFactory.equal("age", 18)
|
||||
))
|
||||
.build();
|
||||
```
|
||||
|
||||
支持的查询条件如下:
|
||||
|
||||
| 方法 | 说明 | 示例 |
|
||||
| ---------------------------- | ---------------- | ----------------------------------------------------------------------------- |
|
||||
| equal | 等于 | equal("name", "test"), name 是字段名,test 是字段值 |
|
||||
| equalOtherField | 等于其他字段 | equalOtherField("name", "otherName"), name 是字段名,otherName 是另一个字段名 |
|
||||
| notEqual | 不等于 | notEqual("name", "test") |
|
||||
| notEqualOtherField | 不等于其他字段 | notEqualOtherField("name", "otherName") |
|
||||
| greaterThan | 大于 | greaterThan("age", 18) |
|
||||
| greaterThanOtherField | 大于其他字段 | greaterThanOtherField("age", "otherAge") |
|
||||
| greaterThanOrEqual | 大于等于 | greaterThanOrEqual("age", 18) |
|
||||
| greaterThanOrEqualOtherField | 大于等于其他字段 | greaterThanOrEqualOtherField("age", "otherAge") |
|
||||
| lessThan | 小于 | lessThan("age", 18) |
|
||||
| lessThanOtherField | 小于其他字段 | lessThanOtherField("age", "otherAge") |
|
||||
| lessThanOrEqual | 小于等于 | lessThanOrEqual("age", 18) |
|
||||
| lessThanOrEqualOtherField | 小于等于其他字段 | lessThanOrEqualOtherField("age", "otherAge") |
|
||||
| in | 在范围内 | in("age", 18, 19, 20) |
|
||||
| and | 且 | and(equal("name", "test"), equal("age", 18)) |
|
||||
| or | 或 | or(equal("name", "test"), equal("age", 18)) |
|
||||
| between | 在范围内 | between("age", 18, 20), 包含 18 和 20 |
|
||||
| betweenExclusive | 在范围内 | betweenExclusive("age", 18, 20), 不包含 18 和 20 |
|
||||
| betweenLowerExclusive | 在范围内 | betweenLowerExclusive("age", 18, 20), 不包含 18,包含 20 |
|
||||
| betweenUpperExclusive | 在范围内 | betweenUpperExclusive("age", 18, 20), 包含 18,不包含 20 |
|
||||
| startsWith | 以指定字符串开头 | startsWith("name", "test") |
|
||||
| endsWith | 以指定字符串结尾 | endsWith("name", "test") |
|
||||
| contains | 包含指定字符串 | contains("name", "test") |
|
||||
| all | 指定字段的所有值 | all("age") |
|
||||
|
||||
在 `FieldSelector` 中使用的所有字段都必须添加为索引,否则会抛出异常表示不支持该字段。关于如何使用索引请参考 [自定义模型使用索引](./extension.md#using-indexes)。
|
||||
|
||||
可以通过 `and` 和 `or` 方法组合和嵌套查询条件:
|
||||
|
||||
```java
|
||||
import static run.halo.app.extension.index.query.QueryFactory.and;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.equal;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.greaterThan;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.or;
|
||||
|
||||
Query query = and(
|
||||
or(equal("dept", "A"), equal("dept", "B")),
|
||||
or(equal("age", "19"), equal("age", "18"))
|
||||
);
|
||||
ListOptions.builder()
|
||||
.fieldQuery(query)
|
||||
.build();
|
||||
```
|
||||
|
||||
### 构建 ListOptions
|
||||
|
||||
ListOptions 提供了 `builder` 方法用于构建查询条件,`fieldQuery` 方法用于传递字段查询条件,`labelSelector` 方法用于传递标签查询条件。
|
||||
|
||||
```java
|
||||
ListOptions.builder()
|
||||
.labelSelector()
|
||||
.eq("key-1", "value-1")
|
||||
.end()
|
||||
.fieldQuery(QueryFactory.equal("key-2", "value-2"))
|
||||
.build();
|
||||
```
|
||||
|
||||
- `labelSelector` 之后使用 `end` 方法结束标签查询条件的构建。
|
||||
- `andQuery` 和 `orQuery` 用于组合多个 `FieldSelector` 查询条件。
|
||||
|
||||
### 排序
|
||||
|
||||
`listBy` 和 `listAll` 方法都支持传递 `Sort` 参数,用于传递排序条件。
|
||||
|
||||
```java
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
||||
Sort.by(Sort.Order.asc("metadata.name"))
|
||||
```
|
||||
|
||||
通过 `Sort.by` 方法可以构建排序条件,`Sort.Order` 用于指定排序字段和排序方式,`asc` 表示升序,`desc` 表示降序。
|
||||
|
||||
排序中使用的字段必须是添加为索引的字段,否则会抛出异常表示不支持该字段。关于如何使用索引请参考 [自定义模型使用索引](./extension.md#using-indexes)。
|
||||
|
||||
### 分页
|
||||
|
||||
`listBy` 方法支持传递 `PageRequest` 参数,用于传递分页条件。
|
||||
|
||||
```java
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
|
||||
PageRequestImpl.of(1, 10);
|
||||
|
||||
PageRequestImpl.of(1, 10, Sort.by(Sort.Order.asc("metadata.name"));
|
||||
|
||||
PageRequestImpl.ofSize(10);
|
||||
```
|
||||
|
||||
通过 `PageRequestImpl.of` 方法可以构建分页条件,具有两个参数的方法用于指定页码和每页数量,具有三个参数的方法用于指定页码、每页数量和排序条件。
|
||||
|
||||
`ofSize` 方法用于指定每页数量,页码默认为 1。
|
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: 获取扩展
|
||||
description: 了解如何在插件中使用 `ExtensionGetter` 获取扩展
|
||||
---
|
||||
|
||||
`ExtensionGetter` 用于获取和管理 Halo 或其他插件提供的扩展。它提供了多种方法来根据扩展点获取扩展,确保插件能够灵活地集成和使用各种扩展功能。
|
||||
|
||||
`ExtensionGetter` 接口的定义如下:
|
||||
|
||||
```java
|
||||
public interface ExtensionGetter {
|
||||
|
||||
/**
|
||||
* Get only one enabled extension from system configuration.
|
||||
*
|
||||
* @param extensionPoint is extension point class.
|
||||
* @return implementation of the corresponding extension point. If no configuration is found,
|
||||
* we will use the default implementation from application context instead.
|
||||
*/
|
||||
<T extends ExtensionPoint> Mono<T> getEnabledExtension(Class<T> extensionPoint);
|
||||
|
||||
/**
|
||||
* Get the extension(s) according to the {@link ExtensionPointDefinition} queried
|
||||
* by incoming extension point class.
|
||||
*
|
||||
* @param extensionPoint extension point class
|
||||
* @return implementations of the corresponding extension point.
|
||||
* @throws IllegalArgumentException if the incoming extension point class does not have
|
||||
* the {@link ExtensionPointDefinition}.
|
||||
*/
|
||||
<T extends ExtensionPoint> Flux<T> getEnabledExtensions(Class<T> extensionPoint);
|
||||
|
||||
/**
|
||||
* Get all extensions according to extension point class.
|
||||
*
|
||||
* @param extensionPointClass extension point class
|
||||
* @param <T> type of extension point
|
||||
* @return a bunch of extension points.
|
||||
*/
|
||||
<T extends ExtensionPoint> Flux<T> getExtensions(Class<T> extensionPointClass);
|
||||
}
|
||||
```
|
||||
|
||||
包含以下方法:
|
||||
|
||||
1. `getEnabledExtension(Class<T> extensionPoint)`: 获取一个在扩展设置中已启用的扩展。如果没有找到对应配置,将使用 Halo 中的默认扩展,如果 Halo 没有提供默认实现则找到一个由**已启用插件**提供的可用扩展。
|
||||
2. `getEnabledExtensions(Class<T> extensionPoint)`: 根据传入的扩展点类获取所有已启用扩展。如果没有在扩展设置页面配置过则会返回所有可用的扩展。
|
||||
3. `getExtensions(Class<T> extensionPointClass)`: 获取所有与扩展点类相关的扩展,无论是否在扩展设置中启用它。
|
||||
|
||||
:::tip Note
|
||||
使用 `getEnabledExtension` 方法或者 `getEnabledExtensions` 方法取决于扩展点声明的 `type` 是 `SINGLETON` 还是 `MULTI_INSTANCE`。
|
||||
|
||||
通过使用 `ExtensionGetter`,开发者可以轻松地在插件中访问和管理各种扩展点,提升插件的功能和灵活性。
|
||||
|
||||
如果想了解 Halo 提供的扩展点请参考:[扩展点](../../extension-points/server/index.md)。
|
||||
:::
|
||||
|
||||
### 示例
|
||||
|
||||
如果你想在插件中获取已启用的搜索引擎扩展,可以使用 `ExtensionGetter` 来获取:
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SearchService {
|
||||
private final ExtensionGetter extensionGetter;
|
||||
|
||||
Mono<SearchEngine> getSearchEngine() {
|
||||
return extensionGetter.getEnabledExtension(SearchEngine.class)
|
||||
}
|
||||
}
|
||||
```
|
@@ -0,0 +1,690 @@
|
||||
---
|
||||
title: 自定义模型
|
||||
description: 了解什么是自定义模型及如何创建
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
Halo 自定义模型是参考自 [Kubernetes CRD](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) 的一种灵活可扩展的数据存储和使用方式,旨在为插件开发者提供自定义数据支持。
|
||||
自定义模型遵循 [OpenAPI v3](https://spec.openapis.org/oas/v3.1.0),便于开发者在插件中存储、读取和操作自定义数据。
|
||||
详情请参考 [自定义模型设计](https://github.com/halo-dev/rfcs/tree/main/extension)。
|
||||
|
||||
### 示例 {#person-extension-example}
|
||||
|
||||
以下是一个典型的自定义模型代码示例:
|
||||
|
||||
```java
|
||||
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,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 创建自定义模型步骤 {#create-extension}
|
||||
|
||||
创建一个自定义模型需要以下三个步骤:
|
||||
|
||||
1. **继承 `AbstractExtension` 类**:创建一个类继承 `run.halo.app.extension.AbstractExtension`。
|
||||
2. **使用 `GVK` 注解**:通过 `GVK` 注解定义自定义模型的基本信息,包括 group、version、kind 等。
|
||||
3. **注册自定义模型**:在插件的 `start()` 生命周期方法中注册自定义模型。
|
||||
|
||||
```java
|
||||
@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` 的值。
|
||||
|
||||
### 自定义模型定义结构
|
||||
|
||||
一个自定义模型通常包含以下几部分:
|
||||
|
||||
- `apiVersion`:标识 API 版本,由 `GVK` 注解的 `group` 和 `version` 组合而成。
|
||||
- `kind`:标识自定义模型类型。
|
||||
- `metadata`:[Metadata](#metadata) 类型,用于存储模型的元数据,如名称、创建时间。
|
||||
- `spec`:声明自定义模型对象的期望状态。它是声明式的,用户只需要声明期望状态,实际状态由具体的控制器来维护,最终达到用户期望的状态。
|
||||
- `status`:描述自定义模型对象资源的实际状态。
|
||||
|
||||
`apiVersion`、`kind` 和 `metadata` 已包含在 `AbstractExtension` 类中,开发者只需关注 `spec` 和 `status` 即可。
|
||||
|
||||
#### Metadata
|
||||
|
||||
自定义模型的 Metadata 包含以下属性:
|
||||
|
||||
- `name`: 用于标识自定义模型的名称。
|
||||
- `creationTimestamp`: 用于标识自定义模型的创建时间,无法修改,只在创建时自动生成。
|
||||
- `version`: 用于标识自定义模型的数据乐观锁版本,无法修改,由更新时自动填充,如果更新时指定了 `version` 且与当前 `version` 不一致则会更新失败。
|
||||
- `deletionTimestamp`: 用于标识自定义模型的删除时间,表示此自定义模型对象被声明为删除,此时仍然可以通过 API 访问到此对象,参考 [自定义模型对象生命周期](../../../core/framework.md#extension-lifecycle)
|
||||
- `finalizers`: 用于标识终结器,它是一个字符串集合,用于标识自定义模型对象是否可回收,参考 [自定义模型对象生命周期](../../../core/framework.md#extension-lifecycle)
|
||||
- `labels`: 用于标识自定义模型的标签,它是一个字符串键值对集合,用于标识自定义模型对象的标签,可以通过标签来查询自定义模型对象。
|
||||
- `annotations`: 用于存放扩展信息,它是一个字符串键值对集合,用于存放自定义模型对象的扩展信息。
|
||||
|
||||
## 声明自定义模型对象 {#declare-extension-object}
|
||||
|
||||
在创建了自定义模型之后,可以通过在插件项目的 `src/main/resources/extensions` 目录下编写 `yaml` 文件来声明自定义模型对象。示例如下:
|
||||
|
||||
```yaml
|
||||
apiVersion: my-plugin.halo.run/v1alpha1
|
||||
kind: Person
|
||||
metadata:
|
||||
name: fake-person
|
||||
spec:
|
||||
name: halo
|
||||
age: 18
|
||||
gender: male
|
||||
```
|
||||
|
||||
该目录下所有的 `yaml` 文件中声明的自定义模型对象都会**在插件启动后被创建/更新**,文件名是任意的,只需根据 `kind` 和 `apiVersion` 确定类型。
|
||||
基于这个特性,开发者可以将一些**初始化资源**的声明放在这个目录下,以便在插件启动时自动创建。但需要注意的是,如果资源如配置等能被用户修改,则不应该放在这个目录下,因为这些资源会在插件启动时被强制覆盖。
|
||||
|
||||
## 校验自定义模型对象 {#validate-extension-object}
|
||||
|
||||
Halo 使用 [OpenAPI v3](https://spec.openapis.org/oas/v3.1.0) 标准来定义自定义模型。
|
||||
OpenAPI 规范定义了自定义模型的数据结构、字段属性及其校验规则,然后将其转换为 JSON Schema,注册到 Halo 的 SchemeManager 中。
|
||||
|
||||
使用 `@Schema` 注解可以为自定义模型的字段添加校验规则,`@Schema` 是 OpenAPI 提供的一个注解,通过这个注解,我们可以在生成的 OpenAPI 文档中展示字段的详细信息(如名称、描述、类型、是否必填等),同时也可以对字段进行一定的校验,比如限制字段的最大长度、最小值、格式等。
|
||||
|
||||
### 基本用法
|
||||
|
||||
`@Schema` 注解中有许多可用的属性,用来对字段进行更加细致的校验和文档说明。下面是一些常用的属性:
|
||||
|
||||
- description:字段的描述信息,用于在文档中展示。
|
||||
- example:字段的示例值。
|
||||
- requiredMode:是否必填字段。
|
||||
- minLength:字符串字段的最小长度。
|
||||
- maxLength:字符串字段的最大长度。
|
||||
- minimum:数值字段的最小值。
|
||||
- maximum:数值字段的最大值。
|
||||
- format:字段的格式,常用于指定日期、时间、邮箱等特殊格式。
|
||||
|
||||
例如,如果我们有一个电子邮件字段,并且想要校验它的格式,可以这样定义:
|
||||
|
||||
```java
|
||||
@Schema(description = "用户电子邮箱", example = "user@example.com", format = "email")
|
||||
private String email;
|
||||
```
|
||||
|
||||
当用户向 API 提交一个自定义模型对象时,Halo 会根据自定义模型中定义的 OpenAPI `@Schema` 注解对对象进行以下几个步骤的校验:
|
||||
|
||||
1. **基本结构校验**:验证对象的字段结构是否符合定义的 OpenAPI 模式,例如字段类型是否正确、是否存在必填字段等。
|
||||
2. **字段约束校验**:针对特定字段的约束条件(如最小值、最大长度、正则表达式等)进行校验,确保字段值符合条件。
|
||||
3. **成功或失败**:如果校验通过,Halo 接受并存储该对象;如果校验失败,会返回详细的错误信息,说明哪些字段不符合要求
|
||||
|
||||
参考示例 [Person](#person-extension-example)。
|
||||
|
||||
## 使用索引 {#using-indexes}
|
||||
|
||||
为了让插件可以方便的定义自定义模型定义,而不需要考虑操作数据库表的细节且可以切换存储介质如可以使用 `MySQL`,`PostgreSQL`,`H2` 等数据库来来作为存储介质,数据存储使用 `byte[]` 的形式,这使得无法利用数据库的原生索引来提高查询效率。
|
||||
|
||||
Halo 提供了一套索引机制,开发者可以通过注册自定义模型时声明索引来提高查询效率。
|
||||
|
||||
示例:
|
||||
|
||||
```java
|
||||
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](#extension-apis)。
|
||||
|
||||
:::tip Note
|
||||
|
||||
- 索引是一种存储数据结构,可提供对数据集中字段的高效查找。
|
||||
- 索引将自定义模型中的字段映射到数据库行,以便在查询特定字段时不需要完整的扫描。
|
||||
- 查询数据之前,必须对需要查询的字段创建索引。
|
||||
- 索引可以包含一个或多个字段的值,可以包含唯一值或重复值。索引中的值按照索引中的顺序进行排序。
|
||||
- 索引可以提高查询性能,但会占用额外的存储空间,因为它们需要存储索引字段的副本。索引的大小取决于字段的数据类型和索引的类型,因此,创建索引时应该考虑存储成本和性能收益。
|
||||
:::
|
||||
|
||||
## 命名规范
|
||||
|
||||
### `metadata.name` {#naming-spec-for-metadata-name}
|
||||
|
||||
`metadata.name` 是自定义模型对象的唯一标识名,需遵循以下规则:
|
||||
|
||||
- 不超过 253 个字符。
|
||||
- 只能包含小写字母、数字和 `-`,且以字母或数字开头和结尾。
|
||||
|
||||
### `labels` {#naming-spec-for-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` {#naming-spec-for-annotations}
|
||||
|
||||
`annotations` 是一个字符串键值对集合,用于存放扩展信息,命名规则与 `labels` 相同。
|
||||
|
||||
可以使用 `metadata.annotations` 存放一些额外的信息,如 JSON 数据、配置信息等。
|
||||
|
||||
## 自定义模型 API {#extension-apis}
|
||||
|
||||
定义并注册自定义模型后,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**:标签选择器,用于筛选特定标签的对象。详见 [标签选择器参数规则](#label-selector-query-params)。
|
||||
- **fieldSelector**:字段选择器,用于筛选注册为索引的字段。详见 [字段选择器参数规则](#field-selector-query-params)。
|
||||
|
||||
示例:
|
||||
|
||||
```shell
|
||||
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 不仅只是简单的数据操作,你可以通过定义[控制器](../../../core/framework.md#controller) 来实现对数据的业务逻辑处理。
|
||||
|
||||
自定义模型控制器是专门为自定义模型设计的,它允许用户通过自定义逻辑来响应自定义模型对象的变化,执行自动化操作,从而扩展这组自动生成 APIs 的功能。
|
||||
|
||||
自定义模型控制器通常会:
|
||||
|
||||
- 监控自定义模型的变化:当某个自定义模型的对象被创建、更新或删除时,控制器会被触发,读取该对象的状态信息。
|
||||
- 执行特定的业务逻辑:根据自定义模型的状态和需求,控制器可以执行某些动作,如创建或删除其他资源,或调用外部系统进行处理。
|
||||
- 维护资源的期望状态:控制器的目标是确保自定义模型的状态符合期望状态,维护资源的稳定性。
|
||||
|
||||
参考 [自定义模型控制器](../../../core/framework.md#controller) 文档。
|
||||
|
||||
### 选择器参数规则
|
||||
|
||||
#### 标签选择器 {#label-selector-query-params}
|
||||
|
||||
`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 的自定义模型对象。
|
||||
|
||||
#### 字段选择器 {#field-selector-query-params}
|
||||
|
||||
`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](https://docs.spring.io/spring-framework/reference/web/webflux-functional.html) 的 `Functional Endpoints` 来编写轻量级自定义 APIs:
|
||||
|
||||
```java
|
||||
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 段,如果超过则应当以参数的形式传递或改进路径设计,否则无法适应角色模板的规则,参考 [角色模板](../../security/role-template.md#resource-rules)。
|
||||
|
||||
### 自定义 API 的 Group 规则 {#custom-api-group-spec}
|
||||
|
||||
为了确保插件定义的自定义 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,可以定义如下:
|
||||
|
||||
```java
|
||||
// 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();
|
||||
```
|
||||
|
||||
### CustomEndpoint 接口
|
||||
|
||||
根据 [自定义 API 的 Group 规则](#custom-api-group-spec) 约定,开发者需要在自定义 API 的路径中包含 `console.api.<group>`,这样会导致 API 路径变得冗长。
|
||||
|
||||
为了简化 API 路径写法,Halo 提供了 `run.halo.app.core.extension.endpoint.CustomEndpoint` 接口,开发者可以通过实现该接口来定义自定义 APIs:
|
||||
|
||||
```java
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RequestPredicates;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
|
||||
@Component
|
||||
public class PersonEndpoint implements CustomEndpoint {
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
return RouterFunctions.route()
|
||||
.GET("/persons/{name}",
|
||||
RequestPredicates.accept(APPLICATION_JSON), this::getPerson)
|
||||
// more routes
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getPerson(ServerRequest request) {
|
||||
return ServerResponse.ok().bodyValue("Hello, " + request.pathVariable("name"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return new GroupVersion("console.api.my-plugin.halo.run", "v1alpha1");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
CustomEndpoint 接口包含以下两个方法:
|
||||
|
||||
- `endpoint()`:定义自定义 API 的路由。
|
||||
- `groupVersion()`:定义自定义 API 的 group 和 version。
|
||||
|
||||
实现了 `CustomEndpoint` 接口的类需要添加 `@Component` 注解,Halo 会自动扫描并注册这些自定义 APIs。
|
||||
注册时会根据 `groupVersion()` 方法返回的 group 和 version 作为 `endpoint()` 中定义路由的前缀以简化路径写法。
|
||||
|
||||
本章节相关技术栈参考文档:
|
||||
|
||||
- [Reactor 3 Reference Guide](https://projectreactor.io/docs/core/release/reference/)
|
||||
- [Webflux](https://docs.spring.io/spring-framework/reference/web/webflux.html)。
|
||||
|
||||
### 带注解的 MVC 控制器写法
|
||||
|
||||
如果开发者习惯使用 Spring MVC 的注解风格,也可以使用 `@Controller`、`@RequestMapping` 等注解来定义自定义 APIs:
|
||||
|
||||
唯一的区别是是需要在 MVC 控制器添加 `@ApiVersion` 注解,**没有此注解的 MVC 控制器类无法被注册路由**。
|
||||
|
||||
示例:
|
||||
|
||||
```java
|
||||
@ApiVersion("my-plugin.halo.run/v1alpha1")
|
||||
@RequestMapping("/persons")
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class PersonController {
|
||||
private final PersonService personService;
|
||||
|
||||
@GetMapping("/{name}")
|
||||
public Mono<Person> getPerson(@PathVariable("name") String name) {
|
||||
return personService.getPerson(name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个写法定义的路由与 `CustomEndpoint` 接口的写法是等价的,`@ApiVersion` 等价于 `CustomEndpoint` 接口的 `groupVersion()` 方法。
|
||||
|
||||
参考 [Spring Framework Web](https://docs.spring.io/spring-framework/reference/web/webflux/new-framework.html)
|
||||
|
||||
### 自定义 API 查询参数定义
|
||||
|
||||
以 Person 自定义模型为例,列表查询 API 的查询参数可能包括关键词、排序、分页等,可以通过定义一个 DTO 类来封装查询参数:
|
||||
|
||||
```java
|
||||
@Data
|
||||
public class PersonQuery {
|
||||
private String keyword;
|
||||
private Integer page;
|
||||
private Integer size;
|
||||
private String sort;
|
||||
}
|
||||
```
|
||||
|
||||
但排序、分页、标签查询和字段查询等参数通常是通用的,因此 Halo 提供了 `run.halo.app.extension.router.SortableRequest` 类来封装这些参数,开发者可以直接继承该类来定义额外查询参数:
|
||||
|
||||
```java
|
||||
public class PersonQuery extends SortableRequest {
|
||||
|
||||
public PersonQuery(ServerWebExchange exchange) {
|
||||
super(exchange);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ListOptions toListOptions() {
|
||||
return super.toListOptions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageRequest toPageRequest() {
|
||||
return super.toPageRequest();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- toListOptions():将查询参数转换为 `ReactiveExtensionClient` 的 list 查询所需参数。
|
||||
- toPageRequest():将查询参数转换为 `ReactiveExtensionClient` 的 list 查询所需 page 参数,此方法通常不需要覆盖。
|
||||
|
||||
当需要添加额外的查询参数时,只需在 `PersonQuery` 类中添加对应的字段即可。
|
||||
|
||||
```java
|
||||
public class PersonQuery extends SortableRequest {
|
||||
|
||||
public PersonQuery(ServerWebExchange exchange) {
|
||||
super(exchange);
|
||||
}
|
||||
|
||||
public String getKeyword() {
|
||||
return queryParams.getFirst("keyword");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ListOptions toListOptions() {
|
||||
return ListOptions.builder(super.toListOptions())
|
||||
.fieldQuery(QueryFactory.or(
|
||||
QueryFactory.equal("metadata.name", getKeyword()),
|
||||
QueryFactory.contains("spec.name", getKeyword())
|
||||
))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然后使用它:
|
||||
|
||||
```java
|
||||
final ReactiveExtensionClient client;
|
||||
|
||||
public Mono<ListResult<Person>> list(ServerRequest request) {
|
||||
var query = new PersonQuery(request.exchange());
|
||||
return client.listBy(Person.class, query.toListOptions(), query.toPageRequest());
|
||||
}
|
||||
```
|
||||
|
||||
参考 [ReactiveExtensionClient](./extension-client.md#query)。
|
||||
|
||||
### 使用 Java Bean Validation {#using-java-bean-validation}
|
||||
|
||||
对于自定义 API 的请求体,开发者可以使用 [Java Bean Validation](https://beanvalidation.org/) 来校验请求体参数,可以减少手动校验的代码量。
|
||||
|
||||
Bean Validation 为应用程序提供了一种通过约束声明和元数据的通用验证方式。
|
||||
要使用它,你可以在域模型属性上使用声明性验证约束进行注解,然后在运行时强制执行这些约束。它包含内置的约束,你还可以定义自己的自定义约束。
|
||||
|
||||
以下示例,展示了一个包含两个属性的简单 PersonParam 模型:
|
||||
|
||||
```java
|
||||
public class PersonParam {
|
||||
private String name;
|
||||
private int age;
|
||||
}
|
||||
```
|
||||
|
||||
Bean Validation 允许您像以下示例所示那样声明约束:
|
||||
|
||||
```java
|
||||
public class PersonParam {
|
||||
|
||||
@NotNull
|
||||
@Size(max=64)
|
||||
private String name;
|
||||
|
||||
@Min(0)
|
||||
private int age;
|
||||
}
|
||||
```
|
||||
|
||||
要启用 Bean Validation,需要在插件项目中添加一个配置类,如下所示:
|
||||
|
||||
```java
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
|
||||
@Configuration
|
||||
public class PluginConfig {
|
||||
|
||||
@Bean
|
||||
public LocalValidatorFactoryBean validator() {
|
||||
return new LocalValidatorFactoryBean();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然后注入 `Validator` 实例:
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PersonEndpoint implements CustomEndpoint {
|
||||
// step 1: 注入 Validator 实例
|
||||
private final Validator validator;
|
||||
|
||||
// 省略其他代码
|
||||
|
||||
private Mono<ServerResponse> updatePerson(ServerRequest request) {
|
||||
return request.bodyToMono(PersonParam.class)
|
||||
// step 3: 调用 validate 方法
|
||||
.doOnNext(person -> validate(person, request.exchange()))
|
||||
.flatMap(person -> ServerResponse.ok().bodyValue(person));
|
||||
}
|
||||
|
||||
// step 2: 校验请求体参数
|
||||
private void validate(PersonParam person, ServerWebExchange exchange) {
|
||||
var bindResult = validate(person, "person", validator, exchange);
|
||||
if (bindResult.hasErrors()) {
|
||||
throw new RequestBodyValidationException(bindResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将此工具方法添加到你的插件中
|
||||
public static BindingResult validate(Object target, String objectName,
|
||||
Validator validator, ServerWebExchange exchange) {
|
||||
BindingResult bindingResult = new BeanPropertyBindingResult(target, objectName);
|
||||
try {
|
||||
// 由于 Halo 可以在登录时设置用户语言环境
|
||||
// 设置当前请求的 Locale 使得校验错误信息的语言可以根据请求的 Locale 返回
|
||||
LocaleContextHolder.setLocaleContext(exchange.getLocaleContext());
|
||||
validator.validate(target, bindingResult);
|
||||
return bindingResult;
|
||||
} finally {
|
||||
LocaleContextHolder.resetLocaleContext();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
参考文档:
|
||||
|
||||
- [RequestBodyValidationException](https://github.com/halo-dev/halo/blob/25086ee3e63f0c8b6ed380140a068c44404ef2b2/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java)
|
||||
- [Bean Validation](https://beanvalidation.org/)
|
||||
- [Spring Validation](https://docs.spring.io/spring-framework/reference/core/validation/beanvalidation.html)
|
||||
|
||||
## API 文档
|
||||
|
||||
Halo 会自动生成 OpenAPI 文档,包括自动生成的 `CRUD` APIs 和自定义 APIs。
|
||||
|
||||
API 文档可以通过访问 `/swagger-ui.html` 查看,例如:`http://localhost:8090/swagger-ui.html`。
|
||||
|
||||
API 文档会根据 [自定义 API 的 Group 规则](#custom-api-group-spec)被划分到不同的分组,方便开发者和生成 API Client:
|
||||
|
||||
- `Aggregated API V1alpha1`:所有 APIs 都会被聚合到这个分组中。
|
||||
- `Extension API V1alpha1`:自动生成的所有 `CRUD` API。
|
||||
- `Console API V1alpha1`:Console 端使用的自定义 API。
|
||||
- `User-center API V1alpha1`:个人中心使用的自定义 API。
|
||||
- `Public API V1alpha1`:提供给主题端使用的自定义 API。
|
||||
|
||||
参考 [Swagger Config](http://localhost:8090/v3/api-docs/swagger-config)
|
||||
|
||||
为了能让自定义 API 能够被 Swagger 文档展示,开发者定义 Functional Endpoints 时需要 SpringDoc 包装过的 `SpringdocRouteBuilder` 来代替 `RouterFunctions`。
|
||||
|
||||
```java
|
||||
public class PersonEndpoint implements CustomEndpoint {
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
final var tag = "PersonV1alpha1Console";
|
||||
return SpringdocRouteBuilder.route()
|
||||
.GET("/persons", this::getPersons,
|
||||
builder -> builder.operationId("ListPersons")
|
||||
.description("List all persons")
|
||||
.tag(tag)
|
||||
.response(responseBuilder()
|
||||
.implementation(ListResult.generateGenericClass(Person.class))
|
||||
)
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中 builder 用于设置 API 文档的元数据:
|
||||
|
||||
- operationId:操作 ID,建议首字母大写,驼峰命名,生成 API Client 时将以此为方法名的一部分。
|
||||
- tag:标签,用于分组 API,建议使用 `{自定义模型Kind}{自定义模型Version}{作用域}` 的格式,例如 `PersonV1alpha1Console`,Console 表示其在 Console 端使用。
|
||||
|
||||
关于生成 API Client 文档参考 [Devtools 生成 API Client](../../basics/devtools.md#how-to-generate-api-client)
|
||||
|
||||
由于 SpringDoc 缺少对 `SpringdocRouteBuilder` 的文档介绍,开发者可参考示例来使用。
|
||||
|
||||
- [PostEndpoint](https://github.com/halo-dev/halo/blob/25086ee3e63f0c8b6ed380140a068c44404ef2b2/application/src/main/java/run/halo/app/core/endpoint/console/PostEndpoint.java)
|
||||
- [AttachmentEndpoint](https://github.com/halo-dev/halo/blob/25086ee3e63f0c8b6ed380140a068c44404ef2b2/application/src/main/java/run/halo/app/core/attachment/endpoint/AttachmentEndpoint.java#L48)
|
||||
- [UserConnectionEndpoint](https://github.com/halo-dev/halo/blob/25086ee3e63f0c8b6ed380140a068c44404ef2b2/application/src/main/java/run/halo/app/core/endpoint/uc/UserConnectionEndpoint.java#L55)
|
||||
- [构建查询参数](https://github.com/halo-dev/halo/blob/25086ee3e63f0c8b6ed380140a068c44404ef2b2/application/src/main/java/run/halo/app/content/PostQuery.java#L97)
|
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: 为主题提供数据
|
||||
description: 了解如何为主题提供更多获取和使用数据的方法。
|
||||
---
|
||||
|
||||
当你在插件中创建了自己的自定义模型时,你可能需要在主题模板中使用这些数据。或者,你提供一些额外的数据,以便主题可以使用它们,你可以通过创建一个自定义的 `finder` 来实现这一点。
|
||||
|
||||
## 创建一个 Finder
|
||||
|
||||
首先,你需要创建一个 `interface`,并在其中定义你需要提供给主题获取数据的方法,方法的返回值可以是 `Mono` 或 `Flux` 类型,例如:
|
||||
|
||||
```java
|
||||
public interface LinkFinder {
|
||||
Mono<LinkVo> get(String linkName);
|
||||
|
||||
Flux<LinkVo> listAll();
|
||||
}
|
||||
```
|
||||
|
||||
然后写一个实现类,实现这个 `interface`,并在类上添加 `@Finder` 注解,例如:
|
||||
|
||||
```java
|
||||
import run.halo.app.theme.finders.Finder;
|
||||
|
||||
@Finder("myPluginLinkFinder")
|
||||
public class LinkFinderImpl implements LinkFinder {
|
||||
@Override
|
||||
public Mono<LinkVo> get(String linkName) {
|
||||
// ...
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<LinkVo> listAll() {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`@Finder` 注解的值是你在主题中使用的名称,例如,你可以在主题中使用 `myPluginLinkFinder.get('a-link-name')` 来获取数据,`myPluginLinkFinder` 就是你在 `@Finder` 注解中定义的名称。
|
||||
|
||||
## Finder 命名
|
||||
|
||||
为了避免与其他插件的 `finder` 名称冲突,建议在 `@Finder` 注解中添加一个你插件名称的前缀作为 `finder` 名称且名称需要是驼峰式的,不能包含除了 `_` 之外的其他特殊字符。
|
||||
|
||||
例如,你的插件名称是 `my_plugin`,你需要为主题提供一个获取链接的 `finder`,那么你可以这样定义 `@Finder` 注解:
|
||||
|
||||
```java
|
||||
@Finder("myPluginLinkFinder")
|
||||
```
|
||||
|
||||
## 使用 Finder
|
||||
|
||||
在主题中,你可以通过 `finder` 名称和方法名及对应的参数来获取数据,例如:
|
||||
|
||||
```html
|
||||
<div th:text="${myPluginLinkFinder.listAll()}">
|
||||
</div>
|
||||
```
|
||||
|
||||
模板语法参考:[Thymeleaf](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax)。
|
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: 登录增强
|
||||
description: 了解如何在登录时如何允许 Halo 做登录逻辑的增强切入。
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
在 Halo 中,插件可以实现多种登录方式,例如 LDAP、第三方登录等。然而,灵活的登录方式也带来了以下问题:
|
||||
|
||||
1. 登录逻辑难以统一:例如登录成功后需要进行额外处理,这需要插件自行实现。
|
||||
2. Halo 或其他插件无法知晓登录状态:无法记录登录日志等额外处理。
|
||||
3. 新增安全特性适配:Halo 增加了新安全特性,插件需要适配才能使用,如在记住我机制中需要在登录成功后设置 remember-me cookie。
|
||||
|
||||
为了解决这些问题,Halo 提供了登录增强机制,插件可以在登录成功或失败时调用登录增强器,使 Halo 可以执行额外的处理逻辑。随着 Halo 的版本更新,这些逻辑也会更新,而插件无需做任何修改。
|
||||
|
||||
### 登录增强器
|
||||
|
||||
Halo 提供了一个 LoginHandlerEnhancer 的 Bean,插件可以通过依赖注入的方式在合适的位置调用该 Bean 的方法,以便 Halo 可以在登录成功或失败后执行逻辑切入。
|
||||
|
||||
```java
|
||||
public interface LoginHandlerEnhancer {
|
||||
|
||||
/**
|
||||
* Invoked when login success.
|
||||
*
|
||||
* @param exchange The exchange.
|
||||
* @param successfulAuthentication The successful authentication.
|
||||
*/
|
||||
Mono<Void> onLoginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication);
|
||||
|
||||
/**
|
||||
* Invoked when login fails.
|
||||
*
|
||||
* @param exchange The exchange.
|
||||
* @param exception the reason authentication failed
|
||||
*/
|
||||
Mono<Void> onLoginFailure(ServerWebExchange exchange, AuthenticationException exception);
|
||||
}
|
||||
```
|
||||
|
||||
例如在用户密码登录的处理器中,可以这样调用登录增强器:
|
||||
|
||||
```java
|
||||
public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandler,
|
||||
ServerAuthenticationFailureHandler {
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
|
||||
AuthenticationException exception) {
|
||||
var exchange = webFilterExchange.getExchange();
|
||||
return loginHandlerEnhancer.onLoginFailure(exchange, exception)
|
||||
.then(handleFailure(exchange, exception));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
|
||||
Authentication authentication) {
|
||||
return loginHandlerEnhancer.onLoginSuccess(webFilterExchange.getExchange(), authentication)
|
||||
.then(handleSuccess(webFilterExchange.getExchange(), authentication);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
设备管理、记住我等机制都依赖于登录增强器。插件开发者可以通过在合适的时机调用登录增强器来实现这些功能,确保插件与 Halo 的安全特性无缝集成。
|
@@ -0,0 +1,475 @@
|
||||
---
|
||||
title: 发送和订阅通知
|
||||
description: 了解如何在插件中发送和订阅通知。
|
||||
---
|
||||
|
||||
Halo 的通知功能提供了事件驱动的消息提醒机制,让用户能够及时获取系统内的关键事件。
|
||||
开发者可以根据需求定义事件类型和通知方式(如站内消息、邮件等),并支持个性化的推送策略,提升用户体验和系统可扩展性。
|
||||
|
||||
通知系统通过事件机制将关键消息推送给用户。开发者可以自定义通知类型、消息格式和推送方式,主要应用于以下场景:
|
||||
|
||||
- 用户互动:如文章评论、点赞等;
|
||||
- 订单和流程提醒:如订单创建、处理完成等;
|
||||
- 内容更新:如文章发布、系统公告等。
|
||||
|
||||
## 通知系统工作流程
|
||||
|
||||
下图展示了 Halo 通知功能的工作流程,包括事件声明、订阅查找、通知发送等关键步骤。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[开发者声明 ReasonType] --> B[业务代码创建 Reason]
|
||||
B --> C[查找匹配的 Subscription]
|
||||
C --> D{用户订阅了事件吗?}
|
||||
D -- 是 --> E[获取用户通知偏好]
|
||||
E --> F[查找通知模板]
|
||||
F --> G[查找对应的 Notifier]
|
||||
G --> H[发送通知]
|
||||
D -- 否 --> I[结束,无需通知]
|
||||
H --> J[用户接收通知]
|
||||
J --> K[用户查看/处理通知]
|
||||
Z[用户订阅事件] --> D
|
||||
```
|
||||
|
||||
1. 声明事件类型:开发者首先需要声明通知事件的类型 (ReasonType),指定事件的名称、描述和事件所需的属性。
|
||||
2. 插件触发事件:当插件中的某个业务操作发生时,插件需要触发相应的事件。
|
||||
3. 创建 Reason 实例:Halo 根据发送的事件创建一个 Reason 实例,表示具体的事件信息,步骤 2 和 3 可以合并为`业务代码创建 Reason`。
|
||||
4. 订阅查找:Halo 通知中心会根据事件的类型和属性查找匹配的订阅。
|
||||
5. 通知发送:如果找到匹配的订阅,系统根据用户的偏好设置通过不同的通知器(如站内消息、邮件等)发送通知。
|
||||
6. 用户接收和处理通知:用户通过预设的通知渠道接收事件通知,并可以在系统中查看或处理这些通知。
|
||||
|
||||
## 通知事件
|
||||
|
||||
在 Halo 通知系统中,`ReasonType` 和 `Reason` 是两个核心自定义模型,用于定义通知事件的类别和具体的事件实例。
|
||||
理解它们的字段对于开发者扩展通知功能至关重要。
|
||||
|
||||
下面将详细说明这两个模型的字段及其作用。
|
||||
|
||||
### ReasonType 模型
|
||||
|
||||
`ReasonType` 是用于定义通知事件类别的模型。每个 `ReasonType` 都代表一类特定的事件,例如文章评论、新文章发布等。通过 `ReasonType`,系统可以了解该事件的特定属性和数据结构。
|
||||
用户个人中心的通知设置页面会根据 `ReasonType` 的名称和描述展示事件类型和通知方式。
|
||||
|
||||
#### ReasonType 字段说明
|
||||
|
||||
| 字段名 | 类型 | 是否必填 | 说明 |
|
||||
| ------------------ | -------- | -------- | --------------------------------------------------- |
|
||||
| `apiVersion` | `string` | 是 | API 版本号,定义为 `notification.halo.run/v1alpha1` |
|
||||
| `kind` | `string` | 是 | 自定义资源类型,必须为 `ReasonType` |
|
||||
| `metadata.name` | `string` | 是 | 事件类别的唯一标识名称,例如 `comment` |
|
||||
| `spec.displayName` | `string` | 是 | 事件类别的展示名称,用户界面中显示的事件名称 |
|
||||
| `spec.description` | `string` | 否 | 事件类别的描述,说明此事件的用途和含义 |
|
||||
| `spec.properties` | `array` | 是 | 此事件包含的属性字段,用于定义该类事件应携带的数据 |
|
||||
|
||||
#### spec.properties 字段
|
||||
|
||||
`properties` 字段用于定义该通知事件需要传递的参数或属性。每个属性都是一个对象,通常包含以下字段:
|
||||
|
||||
| 字段名 | 类型 | 是否必填 | 说明 |
|
||||
| ------------- | --------- | -------- | -------------------------------------------------------- |
|
||||
| `name` | `string` | 是 | 属性的名称,表示事件数据中的某个字段 |
|
||||
| `type` | `string` | 是 | 属性的数据类型,例如 `string`、`boolean` 等,仅用于描述 |
|
||||
| `description` | `string` | 否 | 对该属性的描述,说明其在事件中的作用 |
|
||||
| `optional` | `boolean` | 否 | 该属性是否为可选字段,默认值为 `false`,`false` 表示必填 |
|
||||
|
||||
`properties.type` 字段仅用于文档性目的,不会在运行时进行数据类型检查。有了此描述,便于编写通知模板时使用正确的数据类型。
|
||||
|
||||
**示例:** 声明评论事件的 ReasonType
|
||||
|
||||
```yaml
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: ReasonType
|
||||
metadata:
|
||||
name: comment
|
||||
spec:
|
||||
displayName: "评论事件"
|
||||
description: "用户在文章上收到评论时触发。"
|
||||
properties:
|
||||
- name: postName
|
||||
type: string
|
||||
description: "文章的名称。"
|
||||
- name: commenter
|
||||
type: string
|
||||
description: "评论者用户名。"
|
||||
- name: content
|
||||
type: string
|
||||
description: "评论内容。"
|
||||
```
|
||||
|
||||
在这个示例中,`ReasonType` 定义了一个评论事件,该事件包括三种属性:`postName`(文章名)、`commenter`(评论者)和 `content`(评论内容),这些属性在触发事件时将传递给通知系统。
|
||||
|
||||
这一类型的资源声明非常适合放在插件的 `resources` 目录下,以便插件安装时自动创建。参考 [声明自定义模型对象](./extension.md#declare-extension-object)
|
||||
|
||||
### Reason 模型
|
||||
|
||||
`Reason` 模型用于描述具体的事件实例,它是 `ReasonType` 的一个实例化,包含触发该事件时的具体数据。
|
||||
`Reason` 通常在某个事件发生时创建,例如某篇文章收到评论时生成一个 `Reason`,记录具体的评论信息。
|
||||
|
||||
#### Reason 字段说明
|
||||
|
||||
| 字段名 | 类型 | 是否必填 | 说明 |
|
||||
| ----------------- | -------- | -------- | -------------------------------------------------------------- |
|
||||
| `apiVersion` | `string` | 是 | API 版本号,定义为 `notification.halo.run/v1alpha1` |
|
||||
| `kind` | `string` | 是 | 自定义资源类型,必须为 `Reason` |
|
||||
| `metadata.name` | `string` | 是 | 该事件实例的唯一标识名称,通常自动生成 |
|
||||
| `spec.reasonType` | `string` | 是 | 引用的 `ReasonType` 名称,表示该事件实例属于哪个事件类型 |
|
||||
| `spec.author` | `string` | 是 | 事件的触发者或创建者,通常为用户或系统的标识符 |
|
||||
| `spec.subject` | `object` | 是 | 事件的主题,指向该事件所涉及的具体对象(如文章、评论等) |
|
||||
| `spec.attributes` | `object` | 是 | 包含事件具体数据的键值对,内容与 `ReasonType` 中定义的属性一致 |
|
||||
|
||||
#### spec.subject 字段
|
||||
|
||||
`subject` 字段描述了与该事件相关的主体对象,例如,评论事件中的文章对象。`subject` 通常包含以下字段:
|
||||
|
||||
| 字段名 | 类型 | 是否必填 | 说明 |
|
||||
| ------------ | -------- | -------- | ------------------------------------------------------- |
|
||||
| `apiVersion` | `string` | 是 | 主题对象的 API 版本号,例如 `content.halo.run/v1alpha1` |
|
||||
| `kind` | `string` | 是 | 主题对象的类型,例如 `Post` 表示文章 |
|
||||
| `name` | `string` | 是 | 主题对象的唯一标识,通常是对象的名称或 ID |
|
||||
| `title` | `string` | 是 | 主题对象的标题,通常是人类可读的名称 |
|
||||
| `url` | `string` | 否 | 主题对象的访问链接或详情页面的 URL |
|
||||
|
||||
参考 [Reason 自定义模型](https://github.com/halo-dev/halo/blob/0d1a0992231fd5e66a65b4e9d426d3f373b1903f/api/src/main/java/run/halo/app/core/extension/notification/Reason.java)
|
||||
|
||||
#### spec.attributes 字段
|
||||
|
||||
`attributes` 字段用于存储该事件实例的具体数据。每个键值对表示一个 `ReasonType` 中定义的属性和其对应的值。
|
||||
|
||||
**示例:** 创建评论事件的 Reason
|
||||
|
||||
```yaml
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: Reason
|
||||
metadata:
|
||||
name: comment-123
|
||||
spec:
|
||||
reasonType: comment
|
||||
author: "访客"
|
||||
subject:
|
||||
apiVersion: 'content.halo.run/v1alpha1'
|
||||
kind: Post
|
||||
name: 'post-456'
|
||||
title: 'Halo 系统介绍'
|
||||
url: 'https://example.com/archives/456'
|
||||
attributes:
|
||||
postName: "Halo 系统介绍"
|
||||
commenter: "访客"
|
||||
content: "这是一篇非常有帮助的文章!"
|
||||
```
|
||||
|
||||
在这个示例中,`Reason` 表示具体的评论事件。
|
||||
它关联了 `comment` 这一 `ReasonType`,并提供了详细的事件信息,包括文章(subject)的标识信息以及评论的内容(attributes)。
|
||||
|
||||
通过 ReasonType 和 Reason,开发者可以定义和管理各种事件,并在事件发生时触发相应的通知逻辑。
|
||||
|
||||
## 通知模板
|
||||
|
||||
在 Halo 系统中,通知模板用于定义每种通知类型的展示格式和内容结构。每当触发某个通知事件时,系统会根据事件的类型选择相应的通知模板,并将事件的属性嵌入到模板中生成最终的通知内容。如果未定义通知模板,则系统无法确定通知的具体格式和内容,这可能导致通知发送失败。因此,定义通知模板是实现通知功能的关键步骤。
|
||||
|
||||
### 通知模板的基本结构
|
||||
|
||||
通知模板是通过 `NotificationTemplate` 自定义模型定义的。
|
||||
每个模板指定了与某个事件类型 (ReasonType) 关联的内容格式,包括通知的标题、正文内容等。
|
||||
模板内容可以使用属性占位符,以便在通知生成时自动填充事件属性。Halo 支持使用 [Thymeleaf](https://www.thymeleaf.org/) 模板引擎进行内容渲染。
|
||||
|
||||
#### 通知模板字段说明
|
||||
|
||||
| 字段名 | 类型 | 是否必填 | 说明 |
|
||||
| -------------------------------- | -------- | -------- | --------------------------------------------------- |
|
||||
| `apiVersion` | `string` | 是 | API 版本号,定义为 `notification.halo.run/v1alpha1` |
|
||||
| `kind` | `string` | 是 | 自定义资源类型,必须为 `NotificationTemplate` |
|
||||
| `metadata.name` | `string` | 是 | 模板的唯一标识名称 |
|
||||
| `spec.reasonSelector.reasonType` | `string` | 是 | 关联的事件类型 (`ReasonType`) 名称 |
|
||||
| `spec.reasonSelector.language` | `string` | 是 | 模板语言,固定写为 `default` |
|
||||
| `spec.template.title` | `string` | 是 | 通知的标题模板,支持占位符 |
|
||||
| `spec.template.rawBody` | `string` | 是 | 通知的正文模板,应当是纯文本,支持占位符 |
|
||||
| `spec.template.htmlBody` | `string` | 是 | 通知的正文模板,格式为 HTML 的模板,支持占位符 |
|
||||
|
||||
#### 定义通知模板
|
||||
|
||||
定义通知模板时,开发者需要指定模板的 `reasonSelector`,用于与事件类型关联,并在 `template` 中定义通知标题和内容的格式。
|
||||
|
||||
`spec.reasonSelector.language` 字段用于指定模板的语言,设计支持多语言,但目前 Halo 没有提供保存用户语言偏好的入口,因此只能使用 `default`。
|
||||
|
||||
模板内容支持纯文本和 HTML 格式,建议开发者两种内容都提供,以适应不同的通知渠道。比如邮件通知需要 HTML 格式,而短信通知则仅支持纯文本。
|
||||
|
||||
**示例:** 定义评论事件的通知模板
|
||||
|
||||
假设我们需要为“新评论”事件定义一个通知模板,该模板包括事件的标题和正文内容:
|
||||
|
||||
```yaml
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: NotificationTemplate
|
||||
metadata:
|
||||
name: template-new-comment-on-post
|
||||
spec:
|
||||
reasonSelector:
|
||||
reasonType: new-comment-on-post
|
||||
language: default
|
||||
template:
|
||||
title: "你的文章 [(${subject.title})] 收到了一条新评论"
|
||||
rawBody: |
|
||||
评论者 [(${author.name})] 评论了您的文章 [(${subject.title})],内容如下:
|
||||
[(${props.comment})]
|
||||
htmlBody: |
|
||||
<p>评论者 <strong>[(${author.name})]</strong> 评论了您的文章 <a href="[(${subject.url})]">[(${subject.title})]</a>,内容如下:</p>
|
||||
<p>[(${props.comment})]</p>
|
||||
```
|
||||
|
||||
参考 [Halo 默认通知模板 YAML](https://github.com/halo-dev/halo/blob/0d1a0992231fd5e66a65b4e9d426d3f373b1903f/application/src/main/resources/extensions/notification-templates.yaml)
|
||||
|
||||
#### 通知模板设计小技巧与最佳实践
|
||||
|
||||
##### 合理使用占位符
|
||||
|
||||
在通知模板中,使用占位符来动态插入事件数据是提高模板灵活性的关键。占位符的设计应遵循以下规则:
|
||||
|
||||
- 使用清晰的命名:占位符应具有明确的含义,例如 `[(${quoteReplyName})]` 表示引用回复的名称,让代码更具可读性,建议使用 `CamelCase` 命名规范,不要使用嵌套对象作为变量如 `subject.title`。
|
||||
- 避免嵌套复杂表达式:为了避免通知生成的内容过于复杂,建议在模板中尽量使用简单的占位符。复杂逻辑应在事件触发时处理,尽量保持模板的简洁。
|
||||
- 确保属性完整性:占位符应与 ReasonType 中定义的属性一致,避免由于属性缺失导致的通知发送错误。
|
||||
|
||||
##### 提供多种格式的内容
|
||||
|
||||
在 Halo 中,不同的通知渠道可能支持不同格式的内容,建议模板中同时提供纯文本和 HTML 格式,以便适应各类通知渠道需求。
|
||||
|
||||
- 纯文本格式:适合即时通讯类应用消息和短信通知,尽量简洁明了,关注重点信息。
|
||||
- HTML 格式:适合富文本展示渠道,如邮件通知。HTML 模板可使用简单的样式和链接,帮助用户更好地理解通知内容。
|
||||
- 适配不同渠道的内容:对于可能发送到多渠道的通知,可以在不同格式中包含适合该渠道的具体内容,如 HTML 中使用 `<a>` 标签提供链接,而纯文本只展示简洁的链接地址。
|
||||
- 如果在模板中需要用到日期,建议提前将其格式化为带时区的日期字符串,避免在模板中使用复杂的日期格式化。
|
||||
|
||||
##### 模板语法
|
||||
|
||||
Halo 使用 Thymeleaf 模板引擎来渲染通知模板,开发者可以在模板中使用 Thymeleaf 的语法来处理模板中的逻辑和数据。
|
||||
|
||||
- 对于纯文本如标题和 `rawBody`,使用 Thymeleaf 的 [Textual syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#textual-syntax) 语法来引用变量和表达式。
|
||||
其取值格式为:`[(${expression})]`,例如 `[(${title})]`。
|
||||
- 对于 HTML 内容如 `htmlBody`,使用 Thymeleaf 的 [Standard syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-syntax) 语法。
|
||||
其取值格式为:`${expression}`,例如 `${title}`。
|
||||
|
||||
#### 模板渲染与发送
|
||||
|
||||
在通知事件触发时,Halo 通知中心会查找与该事件 `ReasonType` 匹配的 `NotificationTemplate`,并将事件数据填充到模板中生成最终通知内容。
|
||||
没有定义模板的事件将无法发送通知,因此为每个 `ReasonType` 定义模板是保证通知发送成功的前提。
|
||||
|
||||
可以有多个相同的 `reasonSelector` 绑定到同一个事件类型,比如 `reasonType=new-comment-on-post`且 `language=default` 的模板存在多个,Halo 会**选择最近创建的模板**。
|
||||
根据这个特点,**插件或主题开发者可以提供自己的通知模板以覆盖默认的通知模板**。
|
||||
|
||||
在生成通知内容时,系统还会提供一些额外的全局属性(如 `site.title` 等),这些属性可以在模板中直接使用:
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| ------------------------ | -------- | ---------------------------- |
|
||||
| `site.title` | `string` | 站点标题 |
|
||||
| `site.subtitle` | `string` | 站点副标题 |
|
||||
| `site.logo` | `string` | 站点 Logo URL |
|
||||
| `site.url` | `string` | 站点外部访问地址 |
|
||||
| `subscriber.displayName` | `string` | 订阅者显示名称 |
|
||||
| `subscriber.id` | `string` | 订阅者唯一标识符 |
|
||||
| `unsubscribeUrl` | `string` | 退订地址,用于取消订阅的链接 |
|
||||
|
||||
## 触发通知事件
|
||||
|
||||
在 Halo 系统中,通知功能的核心是通过触发特定的事件,生成相应的 Reason 实例,然后通过系统的通知机制将该事件通知给订阅者。
|
||||
Halo 提供了 `NotificationReasonEmitter` 接口,开发者可以通过它轻松触发通知事件,将业务逻辑与通知机制结合起来。
|
||||
|
||||
### 工作机制
|
||||
|
||||
`NotificationReasonEmitter` 的作用是简化事件触发和通知的处理流程,它的主要职责是:
|
||||
|
||||
- 接收业务事件的参数,生成 `Reason` 实例。
|
||||
- 将 `Reason` 实例与 `ReasonType` 进行匹配,触发事件。
|
||||
- 通过 Halo 的通知系统,将事件推送给订阅了该事件的用户。
|
||||
|
||||
定义参考 [NotificationReasonEmitter](../../basics/server/object-management.md#notificationreasonemitter)
|
||||
|
||||
- `reasonType`:事件类型的名称,对应于 `ReasonType` 的 `metadata.name` 字段。
|
||||
- `reasonData`:事件数据的构建器,用于构建 `Reason` 实例的属性。
|
||||
|
||||
Reason 数据的构建器有以下属性:
|
||||
|
||||
```java
|
||||
public class ReasonPayloadBuilder {
|
||||
private Reason.Subject subject;
|
||||
private UserIdentity author;
|
||||
private Map<String, Object> attributes;
|
||||
}
|
||||
```
|
||||
|
||||
- `subject`:事件的主体对象,参考 `Reason` 中的 `spec.subject` 字段。
|
||||
- `author`:事件的触发者,通常是用户或系统的标识符,这是一个 `UserIdentity` 对象,如果作者是匿名的则需要传递邮箱地址来构造,比如评论者可能没有关联具体用户而仅仅是邮箱地址。
|
||||
- `attributes`:事件的具体数据,包含了 `ReasonType` 中定义的属性和对应的值,如果必填字段没有传递则会抛出异常。
|
||||
|
||||
使用场景举例:
|
||||
|
||||
- 当用户在博客上发表文章时,触发“新文章发布”事件,通知订阅了该事件的用户。
|
||||
- 当用户在文章上发表评论时,触发“评论回复”事件,通知文章作者或订阅了评论的用户。
|
||||
- 当管理员处理插件订单时,触发“订单创建”事件,通知管理员新订单的详情。
|
||||
|
||||
### 示例 {#reason-emitter-example}
|
||||
|
||||
假设我们需要在用户发布的文章时有新评论时,向订阅了“文章有新评论”事件的用户发送通知。可以通过 `NotificationReasonEmitter` 实现如下逻辑。
|
||||
|
||||
第一步:在你的插件代码中注入 `NotificationReasonEmitter`。
|
||||
|
||||
```java
|
||||
import run.halo.app.notification.NotificationReasonEmitter;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CommentEventListener {
|
||||
|
||||
private final NotificationReasonEmitter notificationReasonEmitter;
|
||||
}
|
||||
```
|
||||
|
||||
第二步:有新评论被创建时触发
|
||||
|
||||
```java
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
|
||||
@Async
|
||||
@EventListener(CommentCreatedEvent.class)
|
||||
public void onNewComment(CommentCreatedEvent event) {
|
||||
Comment comment = event.getComment();
|
||||
Ref subjectRef = comment.getSpec().getSubjectRef();
|
||||
Post post = client.fetch(Post.class, subjectRef.getName()).orElseThrow();
|
||||
// 评论的主体是文章,因此构建文章的主体对象
|
||||
var reasonSubject = Reason.Subject.builder()
|
||||
.apiVersion(post.getApiVersion())
|
||||
.kind(post.getKind())
|
||||
.name(subjectRef.getName())
|
||||
.title(post.getSpec().getTitle())
|
||||
.url(postUrl)
|
||||
.build();
|
||||
// new-comment-on-post 用于表示 ReasonType 的 metadata.name
|
||||
notificationReasonEmitter.emit("new-comment-on-post",
|
||||
builder -> {
|
||||
// 定义了一个类型用于保存评论事件的数据,避免 map 的 key 写错
|
||||
var attributes = CommentOnPostReasonData.builder()
|
||||
.postName(subjectRef.getName())
|
||||
.postOwner(post.getSpec().getOwner())
|
||||
.postTitle(post.getSpec().getTitle())
|
||||
.postUrl(postUrl)
|
||||
.commenter(owner.getDisplayName())
|
||||
.content(comment.getSpec().getContent())
|
||||
.commentName(comment.getMetadata().getName())
|
||||
.build();
|
||||
// 将 CommentOnPostReasonData 转换为事件所需的 Map 类型数据
|
||||
builder.attributes(toAttributeMap(attributes))
|
||||
.author(identityFrom(owner))
|
||||
.subject(reasonSubject);
|
||||
}).block();
|
||||
}
|
||||
|
||||
public static <T> Map<String, Object> toAttributeMap(T data) {
|
||||
Assert.notNull(data, "Reason attributes must not be null");
|
||||
return JsonUtils.mapper().convertValue(data, new TypeReference<>() {
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
参考 [Halo 评论事件触发](https://github.com/halo-dev/halo/blob/0d1a0992231fd5e66a65b4e9d426d3f373b1903f/application/src/main/java/run/halo/app/content/comment/CommentNotificationReasonPublisher.java#L89)
|
||||
|
||||
## 订阅通知
|
||||
|
||||
在 Halo 系统中,用户可以通过订阅通知来接收感兴趣的事件,并且可以使用表达式对事件进行过滤,确保只接收到符合条件的通知。
|
||||
通过这种方式,用户可以精确订阅某些事件,并避免被无关的事件打扰。
|
||||
订阅机制支持根据事件属性、主体对象和事件发起人来过滤事件,并使用 SpEL(Spring Expression Language)编写过滤表达式。
|
||||
|
||||
### 订阅通知的工作机制
|
||||
|
||||
通知订阅机制允许用户订阅各种类型的事件(如文章发布、评论回复等),并且支持通过表达式过滤事件。
|
||||
|
||||
订阅系统可以通过以下三种根对象来筛选感兴趣的事件:
|
||||
|
||||
- props:事件属性的根对象,表示事件携带的具体数据。例如,在评论回复事件中,属性可以是 `{repliedOwner: 'guqing'}`,可以通过 `props.repliedOwner` 来访问 `repliedOwner` 属性。
|
||||
- subject:事件的主体对象,表示事件所关联的核心对象,字段同 `Reason` 中的 `spec.subject` 字段。例如,在“新评论”事件中,subject 是评论所属的文章。
|
||||
- author:事件的发起人,表示触发事件的用户标识符,字符串类型。例如,在评论事件中,发起人是评论者。
|
||||
|
||||
通过这些对象,订阅者可以编写表达式来对事件进行过滤。
|
||||
|
||||
表达式的结果必须为布尔值,用于判断当前事件是否符合订阅者的条件。
|
||||
|
||||
### 表达式过滤规则
|
||||
|
||||
使用 [SpEL](https://docs.spring.io/spring-framework/reference/core/expressions.html) 规范,开发者可以通过以下方式过滤事件:
|
||||
|
||||
- props:访问事件的属性。例如,`props.repliedOwner == 'guqing'` 用于筛选事件属性中 repliedOwner 为 guqing 的事件。
|
||||
- subject:访问事件主体的属性。例如,`subject.kind == 'Post'` 用于筛选主体为 Post 的事件。
|
||||
- author:访问事件的发起人。例如,`author == 'guqing'` 用于筛选 guqing 发起的评事件。
|
||||
|
||||
### 订阅方式
|
||||
|
||||
目前 Halo 没有提供用户界面来订阅通知,订阅均是通过事件发起前由开发者为用户创建的订阅对象来实现的。
|
||||
因此,开发者需要在插件中实现订阅逻辑,为用户创建订阅对象,需要注意以下几点:
|
||||
|
||||
1. 订阅对象的创建需要在用户订阅事件前完成,否则用户无法接收到通知。
|
||||
2. 哪些用户能够接收到通知需要开发者谨慎考虑,确保用户订阅的事件是符合其需求的,避免将无关的事件推送给用户。
|
||||
3. 在创建订阅时,同样的逻辑反复执行不会重复创建订阅,因此可以在每次事件触发前订阅。
|
||||
|
||||
Halo 提供 `NotificationCenter` Bean 来帮助开发者创建订阅对象和取消订阅对象。
|
||||
|
||||
参考 [NotificationCenter Bean](../../basics/server/object-management.md#notificationcenter)
|
||||
|
||||
`subscribe` 方法用于订阅事件,`unsubscribe` 方法用于取消订阅事件,第一个参数是订阅者,第二个参数是感兴趣的事件。
|
||||
|
||||
#### Subscription.Subscriber
|
||||
|
||||
`Subscription.Subscriber` 具有以下属性:
|
||||
|
||||
| 字段名 | 类型 | 是否必填 | 说明 |
|
||||
| ------ | -------- | -------- | -------------------------------------------------------- |
|
||||
| `name` | `string` | 是 | 订阅者的用户名,或通过 `UserIdentity` 构建的匿名订阅标识 |
|
||||
|
||||
#### Subscription.InterestReason
|
||||
|
||||
`Subscription.InterestReason` 具有以下属性:
|
||||
|
||||
| 字段名 | 类型 | 是否必填 | 说明 |
|
||||
| ------------ | -------- | -------- | -------------------------------------------------------------------- |
|
||||
| `reasonType` | `string` | 是 | 感兴趣的事件类型的名称,对应于 `ReasonType` 的 `metadata.name` 字段 |
|
||||
| `expression` | `string` | 是 | 订阅事件的过滤表达式,用于过滤感兴趣的事件避免用户接收到不相关的通知 |
|
||||
|
||||
参考 [Subscription 自定义模型](https://github.com/halo-dev/halo/blob/0d1a0992231fd5e66a65b4e9d426d3f373b1903f/api/src/main/java/run/halo/app/core/extension/notification/Subscription.java)
|
||||
|
||||
### 示例 {#subscribe-example}
|
||||
|
||||
以下是一个通过 Java 代码实现订阅文章“新评论”通知的示例。该示例展示了如何使用表达式来筛选事件,只接收特定文章下的评论通知。
|
||||
|
||||
```java
|
||||
// step 0: 依赖注入 NotificationCenter
|
||||
private final NotificationCenter notificationCenter;
|
||||
|
||||
Mono<Void> subscribeNewCommentOnPostNotification(String username) {
|
||||
// step1: 创建订阅者对象
|
||||
var subscriber = new Subscription.Subscriber();
|
||||
subscriber.setName(username); // 设置订阅者的用户名
|
||||
|
||||
// step2: 创建感兴趣的事件类型对象
|
||||
var interestReason = new Subscription.InterestReason();
|
||||
// 设置订阅的事件类型为“新评论”
|
||||
interestReason.setReasonType(NEW_COMMENT_ON_POST);
|
||||
|
||||
// step3: 使用表达式过滤事件,props 是事件属性的根对象
|
||||
// 只接收由指定用户创建的文章下的评论事件
|
||||
interestReason.setExpression("props.repliedOwner == '%s'".formatted(username));
|
||||
|
||||
// step4: 订阅事件
|
||||
return notificationCenter.subscribe(subscriber, interestReason);
|
||||
}
|
||||
```
|
||||
|
||||
在这个示例中,我们通过以下步骤实现了订阅:
|
||||
|
||||
1. 创建订阅者对象:通过 `Subscription.Subscriber()` 实例化一个订阅者对象,并为其设置订阅者的用户名。
|
||||
2. 定义感兴趣的事件类型:使用 `Subscription.InterestReason()` 定义感兴趣的事件类型,这里使用 `NEW_COMMENT_ON_POST` 表示订阅“新评论”事件。
|
||||
3. 使用表达式过滤事件:通过表达式 `props.repliedOwner == '%s'`,订阅者只会接收到`被评论者/回复者`是他们自己的评论通知。
|
||||
4. 执行订阅操作:调用 `notificationCenter.subscribe(subscriber, interestReason)` 方法完成订阅操作。
|
||||
|
||||
参考 [Halo 评论订阅](https://github.com/halo-dev/halo/blob/0d1a0992231fd5e66a65b4e9d426d3f373b1903f/application/src/main/java/run/halo/app/core/reconciler/CommentReconciler.java#L70)
|
||||
|
||||
## 通知器
|
||||
|
||||
开发者可以通过 Halo 的通知器机制扩展通知的发送方式。
|
||||
|
||||
参考 [通知器扩展点](../../extension-points/server/notifier.md)
|
@@ -0,0 +1,365 @@
|
||||
---
|
||||
title: 编写控制器
|
||||
description: 了解如何为自定义模型编写控制器
|
||||
---
|
||||
|
||||
控制器是 Halo 的关键组件,它们负责对每个自定义模型对象进行操作,协调所需状态和当前状态,参考: [控制器概述](../../../core/framework.md#controller)。
|
||||
|
||||
控制器通常在具有一般事件序列的控制循环中运行:
|
||||
|
||||
1. 观察:每个控制器将被设计为观察一组自定义模型对象,例如文章的控制器会观察文章对象,插件的控制器会观察插件自定义模型对象等。
|
||||
2. 比较:控制器将对象配置的期望状态与其当前状态进行比较,以确定是否需要更改,例如插件的 `spec.enabled` 为 `true`,而插件的当前状态是未启动,则插件控制器会处理启动插件的逻辑。
|
||||
3. 操作:控制器将根据比较的结果执行相应的操作,以确保对象的实际状态与其期望状态一致,例如插件期望启动,插件控制器会处理启动插件的逻辑。
|
||||
3. 重复:上述所有步骤都由控制器重复执行直到与期望状态一致。
|
||||
|
||||
这是一个描述控制器作用的例子:房间里的温度自动调节器。
|
||||
|
||||
当你设置了温度,告诉了温度自动调节器你的期望状态(Desired State)。
|
||||
房间的实际温度是当前状态(Current State)。通过对设备的开关控制,温度自动调节器让其当前状态接近期望状态,未到达期望状态则继续调节,直到达到期望状态。
|
||||
|
||||
在 Halo 中控制器的运行部分已经有一个默认实现,你只需要编写控制器的调谐的逻辑也就是 [控制器概述](../../../core/framework.md#controller) 中的所说的 Reconciler 即可。
|
||||
|
||||
## 编写 Reconciler
|
||||
|
||||
Reconciler 是控制器的核心,它是一个接口,你需要实现它的 `reconcile()` 方法,该方法接收一个 `Reconciler.Request` 对象,它包含了当前自定义模型对象的名称,你可以通过它来获取自定义模型对象的当前状态和期望状态,然后编写调谐的逻辑。
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class PostReconciler implements Reconciler<Reconciler.Request> {
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new Post())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
以上是一个简单的 Reconciler 实现,它实现了 `reconcile()` 方法,然后在 `setupWith()` 方法中将其通过 `ControllerBuilder` 构建为一个控制器并指定了
|
||||
它要观察的自定义模型对象为`Post`,当文章自定义模型对象发生变化时,`reconcile()` 方法就会被调用,从 `Request request` 参数中你可以获得当前发生变化的文章自定义模型对象的名称,然后你就可以通过名称来查询到自定义模型对象进行调谐了。
|
||||
|
||||
### 构建控制器
|
||||
|
||||
`setupWith()` 方法用于根据当前类的 `reconcile` 方法构建控制器,你可以通过 `ControllerBuilder` 提供的方法来构建并定制控制器:
|
||||
|
||||
```java
|
||||
public class ControllerBuilder {
|
||||
private final String name;
|
||||
private Duration minDelay;
|
||||
private Duration maxDelay;
|
||||
private final Reconciler<Reconciler.Request> reconciler;
|
||||
private Supplier<Instant> nowSupplier;
|
||||
private Extension extension;
|
||||
private ExtensionMatcher onAddMatcher;
|
||||
private ExtensionMatcher onDeleteMatcher;
|
||||
private ExtensionMatcher onUpdateMatcher;
|
||||
private ListOptions syncAllListOptions;
|
||||
private boolean syncAllOnStart = true;
|
||||
private int workerCount = 1;
|
||||
}
|
||||
```
|
||||
|
||||
- `name`:控制器的名称,用于标识控制器。
|
||||
- `minDelay`:控制器的最小延迟,用于控制控制器的最小调谐间隔,默认为 5 毫秒。
|
||||
- `maxDelay`:控制器的最大延迟,用于控制控制器的最大调谐间隔,默认为 1000 秒。
|
||||
- `reconciler`:控制器的调谐器,用于执行调谐逻辑,你需要实现 `Reconciler` 接口。
|
||||
- `nowSupplier`:用于获取当前时间的供应商,用于控制器的时间戳,默认使用 `Instant.now()` 获取当前时间。
|
||||
- `extension`:控制器要观察的自定义模型对象。
|
||||
- `onAddMatcher`:用于匹配添加事件的匹配器,当自定义模型对象被创建时会触发。
|
||||
- `onDeleteMatcher`:用于匹配删除事件的匹配器,当自定义模型对象被删除时会触发。
|
||||
- `onUpdateMatcher`:用于匹配更新事件的匹配器,当自定义模型对象被更新时会触发。
|
||||
- `syncAllListOptions`:用于同步所有自定义模型对象的查询条件,仅当 `syncAllOnStart` 为 `true` 时生效。
|
||||
- `syncAllOnStart`:是否在控制器启动时同步所有自定义模型对象,默认为 `true`,可以配合 `syncAllListOptions` 使用以缩小需要同步的对象范围避免不必要的同步,例如只同步某个用户创建的文章或者某个固定名称的 ConfigMap 对象。如果你的控制器不需要同步所有对象,可以将其设置为 `false`。
|
||||
- `workerCount`:控制器的工作线程数,用于控制控制器的并发度,如果你的控制器需要处理大量的对象,可以将其设置为大于 1 的值,以提高控制器的处理能力,但需要注意的是并发度越高,系统的负载也会越高。这里的并发度是指控制器的并发度,但是每个控制器还是单线程执行的。
|
||||
|
||||
#### ExtensionMatcher
|
||||
|
||||
`onAddMatcher/onUpdateMatcher/onDeleteMatcher` 都是 `ExtensionMatcher` 类型,用于决定当自定义模型对象发生变化时是否触发控制器:
|
||||
|
||||
```java
|
||||
public interface ExtensionMatcher {
|
||||
boolean match(Extension extension);
|
||||
}
|
||||
```
|
||||
|
||||
这里`match` 方法的 `Extension` 参数类型与 `ControllerBuilder` 中的 `extension` 类型始终是一致的,因此可以直接通过强制类型转换来得到需要的类型。
|
||||
|
||||
比如我们想要观察文章对象,但是只想观察文章对象中 `visible` 字段为 `PUBLIC` 的文章,可以这样
|
||||
|
||||
```java
|
||||
public class PostReconciler implements Reconciler<Reconciler.Request> {
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
// 只想观察 VisibleEnum.PUBLIC 的文章
|
||||
ExtensionMatcher extensionMatcher = extension -> {
|
||||
var post = (Post) extension;
|
||||
return VisibleEnum.PUBLIC.equals(post.getSpec().getVisible());
|
||||
};
|
||||
return builder
|
||||
.extension(new Post())
|
||||
.onAddMatcher(extensionMatcher)
|
||||
.onUpdateMatcher(extensionMatcher)
|
||||
.onDeleteMatcher(extensionMatcher)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 控制启动时同步的范围
|
||||
|
||||
如果想要在控制器启动时控制同步对象的范围,可以通过 `syncAllListOptions` 和 `syncAllOnStart` 来实现,例如只同步某个用户创建的文章:
|
||||
|
||||
```java
|
||||
public class PostReconciler implements Reconciler<Reconciler.Request> {
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new Post())
|
||||
.syncAllListOptions(ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.owner", "guqing"))
|
||||
.build()
|
||||
)
|
||||
.syncAllOnStart(true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reconciler 的返回值
|
||||
|
||||
`reconcile()` 方法的返回值是一个 `Result` 对象,它包含了调谐的结果,你可以通过它来告诉控制器是否需要重试,如果需要重试则控制器会在稍后再次调用 `reconcile()` 方法,而这个过程会一直重复,直到 `reconcile()` 方法返回成功为止,这个过程被称之为调谐循环(Reconciliation Loop)。
|
||||
|
||||
```java
|
||||
record Result(boolean reEnqueue, Duration retryAfter) {}
|
||||
```
|
||||
|
||||
`Result` 对象包含了两个属性:reEnqueue 和 retryAfter,reEnqueue 用于标识是否需要重试,retryAfter 用于标识重试的时间间隔,如果 reEnqueue 为 true 则会在 retryAfter 指定的时间间隔后再次调用 `reconcile()` 方法,如果 reEnqueue 为 false 则不会再次调用 `reconcile()` 方法。
|
||||
|
||||
在没有特殊需要时,`retryAfter` 可以不指定,控制器会有一套默认的重试策略。
|
||||
|
||||
如果直接返回 `null` 则会被视为成功,效果等同于返回 `new Result(false, null)`。
|
||||
|
||||
### Reconciler 的异常处理
|
||||
|
||||
当 `reconcile()` 方法抛出异常时,控制器会将异常记录到日志中,然后会将 `Request request` 对象重新放入队列中,等待下次调用 `reconcile()` 方法,这个过程会一直重复,直到 `reconcile()` 成功,对于默认重试策略,每次重试间隔会越来越长,直到达到最长间隔后不再增加。
|
||||
|
||||
## 控制器示例
|
||||
|
||||
本章节将通过一个简单的示例来演示如何编写控制器。
|
||||
|
||||
### 场景:事件管理系统
|
||||
|
||||
创建一个名为”EventTracker“的自定义模型,用于管理和追踪组织内的各种事件。这些事件可以是会议、研讨会、社交聚会或任何其他类型的组织活动。
|
||||
“EventTracker“自定义模型将提供一个框架,用于记录事件的详细信息,如时间、地点、参与者和状态。
|
||||
|
||||
由于这里的重点是控制器,因此我们将忽略自定义模型的详细信息,只关注控制器的实现,一个可能的“EventTracker”数据结构如下:
|
||||
|
||||
```yaml
|
||||
apiVersion: tracker.halo.run/v1alpha1
|
||||
kind: EventTracker
|
||||
metadata:
|
||||
name: event-tracker-1
|
||||
spec:
|
||||
eventName: "Halo Meetup"
|
||||
eventDate: "2024-01-20T12:00:00Z"
|
||||
location: "Chengdu"
|
||||
participants: ["@sig-doc", "@sig-console", "@sig-halo"]
|
||||
description: "Halo Meetup in Chengdu"
|
||||
status:
|
||||
phase: "Planned" # Planned, Ongoing, Completed
|
||||
participants: []
|
||||
conditions:
|
||||
- type: "Invalid"
|
||||
status: "True"
|
||||
reason: "InvalidEventDate"
|
||||
message: "Event date is invalid"
|
||||
```
|
||||
|
||||
业务逻辑处理:
|
||||
|
||||
1. 事件创建:
|
||||
|
||||
- 当新的 EventTracker 资源被创建时,控制器需验证所有必要字段的存在和格式正确性。
|
||||
- 初始化事件状态为 Planned。
|
||||
|
||||
2. 事件更新:
|
||||
|
||||
- 检查 eventDate、location 和 participants 字段的变更。
|
||||
- 如果接近事件日期,自动更新状态为 Ongoing。
|
||||
|
||||
3. 状态管理:
|
||||
|
||||
- 根据当前日期和事件日期自动管理 phase 字段。
|
||||
- 当事件日期过去时,将状态更新为 Completed。
|
||||
4. 数据验证和完整性:
|
||||
- 确保所有输入数据的格式正确且合理。
|
||||
- 如有不一致或缺失的重要信息,记录警告或错误。
|
||||
5. 事件提醒和通知:
|
||||
- 在事件状态改变或临近事件日期时发送通知。
|
||||
6. 清理和维护:
|
||||
- 对于已完成的事件,提供自动清理机制,例如在事件结束后一定时间内删除资源。
|
||||
|
||||
首先实现 EventTracker 控制器的协调循环主体,通过依赖注入 `ExtensionClient` 可以用于获取当前变更的对象:
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EventTrackerReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private final ExtensionClient client;
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
// ...
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new EventTracker())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然后在 `reconcile()` 方法中根据 `EventTracker` 对象的状态来执行响应的操作,确保执行逻辑是是幂等的,这意味着即使多次执行相同操作,结果也应该是一致的。
|
||||
|
||||
```java
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
|
||||
// 获取到当前变更的 EventTracker 对象
|
||||
// 1. 检查必要字段的存在和格式正确性
|
||||
// 2. 初始化事件状态为 Planned
|
||||
if (eventTracker.getStatus() == null) {
|
||||
eventTracker.setStatus(new EventTracker.Status());
|
||||
}
|
||||
var status = eventTracker.getStatus();
|
||||
if (status.getPhase() == null) {
|
||||
status.setPhase(EventTracker.Phase.PLANNED);
|
||||
}
|
||||
|
||||
var eventName = eventTracker.getSpec().getEventName();
|
||||
if (StringUtils.isBlank(eventName)) {
|
||||
Condition condition = Condition.builder()
|
||||
.type("Invalid")
|
||||
.reason("InvalidEventName")
|
||||
.message("Event name is invalid")
|
||||
.status(ConditionStatus.FALSE)
|
||||
.lastTransitionTime(Instant.now())
|
||||
.build();
|
||||
status.getConditions().addAndEvictFIFO(condition);
|
||||
}
|
||||
|
||||
client.update(eventTracker);
|
||||
});
|
||||
return new Result(false, null);
|
||||
}
|
||||
```
|
||||
|
||||
上述,我们通过 `client.fetch()` 方法获取到了当前变更的 `EventTracker` 对象,然后根据 `EventTracker` 对象的状态来执行响应的操作,例如初始化事件状态为 Planned,检查必要字段的存在和格式正确性等,但需要注意控制器的执行是异步的,如果我们通过 `EventTracker` 的 API 来创建或更改了一个 `EventTracker` 对象,那么 API 会在控制器执行之前返回结果,这意味着在用户界面看到的结果可能不是最新的,并且可能会在稍后更新。
|
||||
|
||||
对于上述校验 `eventName` 的逻辑只是保证后续的执行是可靠的,如果有些字段是必须的,那么我们可以通过 `@Schema` 注解来标注,为了让控制器中校验字段失败的信息能够呈现到用户界面,我们通过向 `status.conditions` 中添加了一条 Condition 记录来用于记录这个事件,再用户界面可以展示这个 Condition 记录的信息以让用户知晓。
|
||||
|
||||
最后,我们通过 `client.update()` 方法来更新 `EventTracker` 对象,这个过程就是将实际状态回写到 `EventTracker` 对象并应用到数据库中,这样就完成了一次调谐。
|
||||
|
||||
当 `EventTracker` 对象发生变更时,控制器也会被执行,这时我们可以根据 `EventTracker` 对象的状态来执行响应的操作,例如检查和更新 `eventDate`、`location` 和 `participants` 字段的变更,如果接近事件日期,自动更新状态为 Ongoing。
|
||||
|
||||
```java
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
|
||||
// ...此处省略之前的逻辑
|
||||
if (isApproach(eventTracker.getSpec().getEventDate())) {
|
||||
status.setPhase(EventTracker.Phase.ONGOING);
|
||||
sendNotification(eventTracker, "Event is ongoing");
|
||||
}
|
||||
});
|
||||
return new Result(false, null);
|
||||
}
|
||||
```
|
||||
|
||||
这里我们通过 `isApproach()` 方法来表示判断是否接近事件日期,如果接近则更新状态为 Ongoing,并使用 `sendNotification` 来发送发送通知。
|
||||
|
||||
> 为了简化示例,我们省略了 `isApproach()` 和 `sendNotification` 方法的实现。
|
||||
|
||||
还可以根据 `spec.participants` 字段来解析参与者信息,然后将其添加到 `status.participants` 中,这样就可以在用户界面看到参与者信息了。
|
||||
|
||||
```java
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
|
||||
// ...此处省略之前的逻辑
|
||||
var participants = eventTracker.getSpec().getParticipants();
|
||||
resolveParticipants(participants).forEach(status::addParticipant);
|
||||
});
|
||||
return new Result(false, null);
|
||||
}
|
||||
```
|
||||
|
||||
### 使用 Finalizers
|
||||
|
||||
`Finalizers` 允许控制器实现异步预删除钩子。假设您为正在实现的 API 类型的每个对象创建了一个外部资源,例如存储桶,并且您希望在从 Halo 中删除相应对象
|
||||
时清理外部资源,您可以使用终结器来删除外部资源资源。
|
||||
|
||||
比如 `EventTracker` 对象被删除时,我们需要删除 `EventTracker` 对象记录的日志,这时我们可以通过 `Finalizers` 来实现。
|
||||
|
||||
首先我们需要在 `reconcile()` 的开头判断 `EventTracker` 对象的 `metadata.deletionTimestamp` 是否存在,如果存在则表示 `EventTracker` 对象被删除了,
|
||||
这时我们就可以执行清理操作。
|
||||
|
||||
```java
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
|
||||
if (ExtensionOperator.isDeleted(eventTracker)) { // 1. 判断是否被删除
|
||||
// 2. 调用 removeFinalizers 方法移除终结器(稍后会说明)
|
||||
ExtensionUtil.removeFinalizers(eventTracker.getMetadata(), Set.of(FINALIZER_NAME));
|
||||
// 3. 执行清理操作
|
||||
cleanUpLogsForTracker(eventTracker);
|
||||
// 4. 更新 EventTracker 对象将变更应用到数据库中
|
||||
client.update(eventTracker);
|
||||
// 5. return 避免执行后续逻辑
|
||||
return;
|
||||
}
|
||||
// ...此处省略之前的逻辑
|
||||
});
|
||||
return new Result(false, null);
|
||||
}
|
||||
```
|
||||
|
||||
1. `ExtensionOperator.isDeleted` 方法是 Halo 提供的工具方法,用于判断对象是否被删除,它会判断 `metadata.deletionTimestamp` 是否存在,如果存在则表示对象被标记删除了。
|
||||
关于自定义模型对象的删除可以参考:[自定义模型对象生命周期](../../../core/framework.md#extension-lifecycle)
|
||||
2. `ExtensionUtil.removeFinalizers` 方法是 Halo 提供的工具方法,用于移除对象的终结器,它接收两个参数,第一个参数是对象的元数据,第二个参数是要移除的终结器名称集合,它来自 `run.halo.app.extension.ExtensionUtil`。
|
||||
3. `cleanUpLogsForTracker` 方法是我们自己实现的,这里的示例是用于清理 `EventTracker` 对象记录的日志,你可以根据自己的业务需求来实现,如清理外部资源等。
|
||||
|
||||
经过上述步骤,我们只是写了移除终结器但是发现没有添加终结器的逻辑,添加终结器的逻辑需要在判断删除之后,`metadata.finalizers` 是一个字符串集合,用于标识对象是否可回收,如果 `metadata.finalizers` 不为空则表示对象不可回收,否则表示对象可回收,我们可以通过 `ExtensionUtil.addFinalizers` 方法来添加终结器。
|
||||
|
||||
最佳实践是,一个控制器最多添加一个终结器,名称为了防止冲突可以使用当前业务的 `group/终结器名称` 来命名,例如 `tracker.halo.run/finalizer`,例如在 Halo 中文章的控制器使用了一个终结器,但可能插件也会定义一个文章控制器来扩展文章的业务,那么根据最佳实践命名终结器可以避免冲突。
|
||||
|
||||
```java
|
||||
private static final String FINALIZER_NAME = "tracker.halo.run/finalizer";
|
||||
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
|
||||
if (ExtensionOperator.isDeleted(eventTracker)) {
|
||||
// ... 省略删除逻辑
|
||||
}
|
||||
// 添加终结器
|
||||
ExtensionUtil.addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME));
|
||||
// ...此处省略之前的逻辑
|
||||
// 会在更新时将终结器的变更写入到数据库中
|
||||
client.update(eventTracker);
|
||||
});
|
||||
}
|
||||
```
|
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: 静态资源代理
|
||||
description: 了解如何使用静态资源代理来访问插件中的静态资源
|
||||
---
|
||||
|
||||
插件中的静态资源如图片等如果想被外部访问到,需要放到 `src/main/resources` 目录下,并通过创建 `ReverseProxy` 自定义模型对象来进行静态资源代理访问。
|
||||
|
||||
例如 `src/main/resources` 下的 `static` 目录下有一张 `halo.jpg`:
|
||||
|
||||
1. 首先需要在 `src/main/resources/extensions` 下创建一个 `yaml`,文件名可以任意。
|
||||
2. 声明 `ReverseProxy` 对象如下:
|
||||
|
||||
```yaml
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: ReverseProxy
|
||||
metadata:
|
||||
# 为了避免与其他插件冲突,推荐带上插件名称前缀
|
||||
name: my-plugin-fake-reverse-proxy
|
||||
rules:
|
||||
- path: /res/**
|
||||
file:
|
||||
directory: static
|
||||
# 如果想代理 static 下所有静态资源则省略 filename 配置
|
||||
filename: halo.jpg
|
||||
```
|
||||
|
||||
插件启动后会根据 `/plugins/{plugin-name}/assets/**` 规则生成访问路径,
|
||||
因此该 `ReverseProxy` 的访问路径为:`/plugins/my-plugin/assets/res/halo.jpg`。
|
||||
|
||||
- `rules` 下可以添加多组规则。
|
||||
- `path` 为路径前缀。
|
||||
- `file` 表示访问文件系统,目前暂时仅支持这一种。
|
||||
- `directory` 表示要代理的目标文件目录,它相对于 `src/main/resources/` 目录。
|
||||
- `filename` 表示要代理的目标文件名。
|
||||
|
||||
`directory` 和 `filename` 都是可选的,但必须至少有一个被配置。
|
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: 获取插件配置
|
||||
description: 了解如何获取插件定义的设置表单对应的配置数据,以及如何在插件中使用配置数据。
|
||||
---
|
||||
|
||||
插件的 `plugin.yaml` 中允许配置 `settingName` 和 `configMapName` 字段,用于定义插件的个性化设置。
|
||||
本文介绍如何获取插件定义的设置表单对应的配置数据,以及如何在插件中使用配置数据。
|
||||
|
||||
## 概述
|
||||
|
||||
Halo 提供了两个 Bean 用于获取插件配置数据:`SettingFetcher` 和 `ReactiveSettingFetcher`,分别用于同步和异步获取配置数据。
|
||||
|
||||
以 `ReactiveSettingFetcher` 为例,提供了以下方法:
|
||||
|
||||
```java
|
||||
public interface ReactiveSettingFetcher {
|
||||
|
||||
<T> Mono<T> fetch(String group, Class<T> clazz);
|
||||
|
||||
@NonNull
|
||||
Mono<JsonNode> get(String group);
|
||||
|
||||
@NonNull
|
||||
Mono<Map<String, JsonNode>> getValues();
|
||||
}
|
||||
```
|
||||
|
||||
- `fetch` 方法用于获取指定分组的配置数据,并将其转换为指定的 Java 类型。
|
||||
- `get` 方法用于获取指定分组的配置数据,返回 `JsonNode` 类型。
|
||||
- `getValues` 方法用于获取所有配置数据,返回 `Map<String, JsonNode>` 类型,其中键为分组名称,值为配置对象。
|
||||
|
||||
`ReactiveSettingFetcher` 和 `SettingFetcher` 底层都对配置数据进行了缓存,以提高性能,并且在配置变更时会自动刷新缓存,所以直接调用这些方法即可获取最新的配置数据。
|
||||
|
||||
## 监听配置变更
|
||||
|
||||
当用户修改插件配置时,可以通过监听 `PluginConfigUpdatedEvent` 事件,执行相应的操作。`PluginConfigUpdatedEvent` 包含了配置变更前后的数据,使插件能够对变化做出响应。
|
||||
|
||||
```java
|
||||
public class PluginConfigUpdatedEvent extends ApplicationEvent {
|
||||
private final Map<String, JsonNode> oldConfig;
|
||||
private final Map<String, JsonNode> newConfig;
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 定义设置表单
|
||||
|
||||
假设插件定义了一个名为 `setting-seo` 的设置表单,其中包含了 `blockSpiders`、`keywords` 和 `description` 三个字段:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: setting-seo
|
||||
spec:
|
||||
forms:
|
||||
- group: seo
|
||||
label: SEO 设置
|
||||
formSchema:
|
||||
- $formkit: checkbox
|
||||
name: blockSpiders
|
||||
label: "屏蔽搜索引擎"
|
||||
value: false
|
||||
- $formkit: textarea
|
||||
name: keywords
|
||||
label: "站点关键词"
|
||||
- $formkit: textarea
|
||||
name: description
|
||||
label: "站点描述"
|
||||
```
|
||||
|
||||
### 配置 plugin.yaml
|
||||
|
||||
在 `plugin.yaml` 中配置 `settingName` 和 `configMapName` 字段:
|
||||
|
||||
```yaml
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: Plugin
|
||||
metadata:
|
||||
name: fake-plugin
|
||||
spec:
|
||||
displayName: "Fake Plugin"
|
||||
# ...
|
||||
configMapName: setting-seo-configmap
|
||||
settingName: setting-seo
|
||||
```
|
||||
|
||||
### 定义值类
|
||||
|
||||
为了方便使用,定义一个值类存储配置数据:
|
||||
|
||||
```java
|
||||
public record SeoSetting(boolean blockSpiders, String keywords, String description) {
|
||||
public static final String GROUP = "seo";
|
||||
}
|
||||
```
|
||||
|
||||
### 获取配置数据
|
||||
|
||||
通过依赖注入 `ReactiveSettingFetcher` 并使用 `fetch(group, type)` 方法查询配置:
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SeoService {
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
public Mono<Void> checkSeo() {
|
||||
return settingFetcher.fetch(SeoSetting.GROUP, SeoSetting.class)
|
||||
.doOnNext(seoSetting -> {
|
||||
if (seoSetting.blockSpiders()) {
|
||||
// do something
|
||||
}
|
||||
})
|
||||
.then();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 监听配置变更
|
||||
|
||||
通过监听 `PluginConfigUpdatedEvent` 事件来处理配置变更:
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class SeoConfigListener {
|
||||
@EventListener
|
||||
public void onConfigUpdated(PluginConfigUpdatedEvent event) {
|
||||
if (event.getNewConfig().containsKey(SeoSetting.GROUP)) {
|
||||
// do something
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
通过以上示例,可以看到如何使用 `ReactiveSettingFetcher` 获取配置数据,并通过监听 `PluginConfigUpdatedEvent` 来处理配置变更事件,确保系统能及时响应配置的变化。
|
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: 在插件中提供主题模板
|
||||
description: 了解如何为主题扩充模板。
|
||||
---
|
||||
|
||||
当你在插件中创建了自己的自定义模型后,你可能需要在主题端提供一个模板来展示这些数据,这一般有两种方式:
|
||||
|
||||
1. 插件规定模板名称,由主题选择性适配,如瞬间插件提供了 `/moments` 的路由渲染 `moment.html` 模板,主题可以选择性的提供 `moment.html` 模板来展示瞬间数据。
|
||||
2. 插件提供默认模板,当主题没有提供对应的模板时,使用默认模板,主题提供了对应的模板时,使用主题提供的模板。
|
||||
|
||||
## 创建一个模板
|
||||
|
||||
首先,你需要在插件的 `resources` 目录下创建一个 `templates` 目录,然后在 `templates` 目录下提供你的模板,例如:
|
||||
|
||||
```text
|
||||
├── templates
|
||||
│ ├── moment.html
|
||||
```
|
||||
|
||||
然后提供一个路由用于渲染这个模板,例如:
|
||||
|
||||
```java
|
||||
import run.halo.app.theme.TemplateNameResolver;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class MomentRouter {
|
||||
private final TemplateNameResolver templateNameResolver;
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> momentRouterFunction() {
|
||||
return route(GET("/moments"), this::renderMomentPage).build();
|
||||
}
|
||||
|
||||
Mono<ServerResponse> renderMomentPage(ServerRequest request) {
|
||||
// 或许你需要准备你需要提供给模板的默认数据,非必须
|
||||
var model = new HashMap<String, Object>();
|
||||
model.put("moments", List.of());
|
||||
return templateNameResolver.resolveTemplateNameOrDefault(request.exchange(), "moments")
|
||||
.flatMap(templateName -> ServerResponse.ok().render(templateName, model));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
使用 `TemplateNameResolver` 来解析模板名称,如果主题提供了对应的模板,那么就使用主题提供的模板,否则使用插件提供的模板,如果直接返回模板名称,那么只会使用主题提供的模板,如果主题没有提供对应的模板,那么会抛出异常。
|
||||
|
||||
## 模板片段
|
||||
|
||||
如果你的默认模板不止一个,你可能需要通过模板片段来抽取一些公共的部分,例如,你的插件提供了一个 `moment.html` 模板,你可能需要抽取一些公共的部分,例如头部、尾部等,你可以这样做:
|
||||
|
||||
```text
|
||||
├── templates
|
||||
│ ├── moment.html
|
||||
│ ├── fragments
|
||||
│ │ ├── layout.html
|
||||
```
|
||||
|
||||
然后定义一个 `layout.html` 模板,例如:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html th:fragment="layoutHtml(content)">
|
||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title th:text="${title}">Moment</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<th:block th:replace="${content}" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
那么使用 `layout.html` 模板中提供的 `fragment` 时,你需要这样做:
|
||||
|
||||
```html
|
||||
<div th:replace="~{plugin:plugin-moment:fragments/layout :: layoutHtml(content = ~{::content})}">
|
||||
<th:block th:fragment="content"> Hello World </th:block>
|
||||
</div>
|
||||
```
|
||||
|
||||
`plugin:plugin-moment:fragments/layout` 即为使用 `layout.html` 模板的路径,必须以 `plugin:<your-plugin-name>:`前缀作为开头,`fragments/layout` 为模板相对于 `resources/templates` 的路径,`<your-plugin-name>` 即为你的插件名称。
|
||||
|
||||
**总结:**
|
||||
|
||||
1. 定义模板片段时与主题端定义模板片段时一样
|
||||
2. 使用模板片段时,必须以 `plugin:<your-plugin-name>:` 前缀作为开头,后跟模板相对于 `resources/templates` 的路径,例如 `plugin:plugin-moment:fragments/layout`,`plugin-moment` 即为你的插件名称,`fragments/layout` 为模板相对于 `resources/templates` 的路径。
|
||||
|
||||
参考:[Thymeleaf 模板片段](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#including-template-fragments)
|
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: 实现 WebSocket
|
||||
description: 了解在插件中如何实现 WebSocket。
|
||||
---
|
||||
|
||||
从 Halo 2.15.0 版本开始,核心提供了 WebSocketEndpoint 接口,其主要目的是为了方便插件实现 WebSocket 功能。
|
||||
|
||||
插件只需要实现这个接口,并添加 `@Component` 注解,WebSocket 实现将会在插件启动后生效,插件卸载后,该实现也会随之删除。
|
||||
|
||||
在插件中实现 WebSocket 的代码样例如下:
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyWebSocketEndpoint implements WebSocketEndpoint {
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseApiVersion("my-plugin.halowrite.com/v1alpha1");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String urlPath() {
|
||||
return "/resources";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebSocketHandler handler() {
|
||||
return session -> {
|
||||
var messages = session.receive()
|
||||
.map(message -> {
|
||||
var payload = message.getPayloadAsText();
|
||||
return session.textMessage(payload.toUpperCase());
|
||||
});
|
||||
return session.send(messages);
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当插件安装成功后,可以通过路径 `/apis/my-plugin.halowrite.com/v1alpha1/resources` 访问。示例如下:
|
||||
|
||||
```bash
|
||||
websocat --basic-auth admin:admin ws://127.0.0.1:8090/apis/my-plugin.halowrite.com/v1alpha1/resources
|
||||
```
|
||||
|
||||
需要注意的是,插件中实现的 WebSocket 相关的 API 仍然受当前权限系统约束。
|
Reference in New Issue
Block a user