|
|
|
|
---
|
|
|
|
|
title: 编写控制器
|
|
|
|
|
description: 了解如何为自定义模型编写控制器
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
控制器是 Halo 的关键组件,它们负责对每个自定义模型对象进行操作,协调所需状态和当前状态,参考: [控制器概述](../../basics/framework.md#controller)。
|
|
|
|
|
|
|
|
|
|
控制器通常在具有一般事件序列的控制循环中运行:
|
|
|
|
|
|
|
|
|
|
1. 观察:每个控制器将被设计为观察一组自定义模型对象,例如文章的控制器会观察文章对象,插件的控制器会观察插件自定义模型对象等。
|
|
|
|
|
2. 比较:控制器将对象配置的期望状态与其当前状态进行比较,以确定是否需要更改,例如插件的 `spec.enabled` 为 `true`,而插件的当前状态是未启动,则插件控制器会处理启动插件的逻辑。
|
|
|
|
|
3. 操作:控制器将根据比较的结果执行相应的操作,以确保对象的实际状态与其期望状态一致,例如插件期望启动,插件控制器会处理启动插件的逻辑。
|
|
|
|
|
3. 重复:上述所有步骤都由控制器重复执行直到与期望状态一致。
|
|
|
|
|
|
|
|
|
|
这是一个描述控制器作用的例子:房间里的温度自动调节器。
|
|
|
|
|
|
|
|
|
|
当你设置了温度,告诉了温度自动调节器你的期望状态(Desired State)。
|
|
|
|
|
房间的实际温度是当前状态(Current State)。 通过对设备的开关控制,温度自动调节器让其当前状态接近期望状态,未到达期望状态则继续调节,直到达到期望状态。
|
|
|
|
|
|
|
|
|
|
在 Halo 中控制器的运行部分已经有一个默认实现,你只需要编写控制器的调谐的逻辑也就是 [控制器概述](../../basics/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` 参数中你可以获得当前发生变化的文章自定义模型对象的名称,然后你就可以通过名称来查询到自定义模型对象进行调谐了。
|
|
|
|
|
|
|
|
|
|
### 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` 是否存在,如果存在则表示对象被标记删除了。
|
|
|
|
|
关于自定义模型对象的删除可以参考:[自定义模型对象生命周期](../../basics/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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
```
|