- Halo Plugin 后端(Java/Gradle),含 DarkModePlugin 主类和测试 - Vue 3 + TypeScript 前端 UI,包含主题切换组件和设置页面 - 暗色模式 CSS 变量和覆盖样式(布局/编辑器/表单/滚动条等) - 设计文档和调查文档 - Halo 插件/主题开发 Agent Skills Co-Authored-By: Claude <noreply@anthropic.com>
16 KiB
Custom Extension (Data Model)
Halo uses a Kubernetes CRD-like system called Extension for custom data storage.
Source references (Halo main branch):
Docs Routing
This file captures the common model pattern. Verify exact APIs, query helpers, and reconciler contracts in the official docs before depending on a recent method or version-specific behavior.
| Need | Official docs |
|---|---|
| Extension model, indexes, query params | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/extension.md |
| ExtensionClient / ReactiveExtensionClient | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/extension-client.md |
| Object management basics | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/basics/server/object-management.md |
| Reconciler controllers | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/reconciler.md |
| Plugin API changelog for version gates | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-changelog.md |
When working from a local docs checkout, use the same paths under
docs/developer-guide/...; versioned docs live under
versioned_docs/version-2.25/....
Quick Index
- Basic model shape: Creating an Extension
- Registration: Registering in Lifecycle
- Generated endpoints: Auto-Generated CRUD APIs
- Indexes and queries: Indexes
- Query APIs: Querying Extensions
Creating an Extension
Three steps:
- Create a class extending
run.halo.app.extension.AbstractExtension - Annotate with
@GVK(group, version, kind, plural, singular) - Register in plugin
start()viaSchemeManager
Example
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
@Data
@EqualsAndHashCode(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 = "Name", maxLength = 100)
private String name;
@Schema(description = "Slug", maxLength = 100)
private String slug;
@Schema(description = "Age", maximum = "150", minimum = "0")
private Integer age;
@Schema(description = "Tags")
private List<String> tags;
@Schema(description = "Priority")
private Integer priority;
@Schema(description = "Pinned")
private Boolean pinned;
@Schema(description = "Publish time")
private Instant publishTime;
}
}
Registering in Lifecycle
@Component
public class MyPlugin extends BasePlugin {
@Autowired
private SchemeManager schemeManager;
@Override
public void start() {
schemeManager.register(Person.class);
}
@Override
public void stop() {
schemeManager.unregister(Scheme.buildFromType(Person.class));
}
}
GVK Annotation Fields
| Field | Description |
|---|---|
group |
Domain-style group, e.g., my-plugin.halo.run |
version |
API version, e.g., v1alpha1 |
kind |
Resource type name (PascalCase) |
plural |
REST plural path segment, lowercase (e.g., persons) |
singular |
Singular name, lowercase (e.g., person) |
Auto-Generated CRUD APIs
After registration, Halo automatically exposes:
GET /apis/{group}/{version}/{plural} # List
GET /apis/{group}/{version}/{plural}/{name} # Get by name
POST /apis/{group}/{version}/{plural} # Create
PUT /apis/{group}/{version}/{plural}/{name} # Update
DELETE /apis/{group}/{version}/{plural}/{name} # Delete
List endpoint supports:
| Param | Description |
|---|---|
page |
Page number (1-based) |
size |
Page size |
sort |
field,asc|desc. Must be an indexed field |
labelSelector |
Label filtering: key=value, key!=value, !key, key |
fieldSelector |
Indexed field filtering: field=value, field!=value, field=(a,b) |
Example:
GET /apis/my-plugin.halo.run/v1alpha1/persons?page=1&size=10&sort=metadata.name,desc&fieldSelector=spec.age=18
Declaring Extension Objects (YAML)
Place YAML files in src/main/resources/extensions/. They are created/updated on plugin startup.
apiVersion: my-plugin.halo.run/v1alpha1
kind: Person
metadata:
name: default-person
spec:
name: halo
slug: halo
age: 18
⚠️ Resources here are overwritten on every plugin start. Do NOT place user-modifiable config here.
Validation with @Schema
@Schema(description = "Email", format = "email")
private String email;
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, minLength = 1, maxLength = 50)
private String title;
Validation is applied on create/update automatically.
Indexes
Indexes improve query performance for fieldSelector and sort.
import java.time.Instant;
import java.util.Set;
import run.halo.app.extension.index.IndexSpecs;
@Override
public void start() {
schemeManager.register(Person.class, indexSpecs -> {
// Single-value index, can return null unless nullable(false) is configured
indexSpecs.add(IndexSpecs.<Person, String>single("spec.name", String.class)
.indexFunc(person -> person.getSpec().getName()));
// Multi-value index, returns a set of values
indexSpecs.add(IndexSpecs.<Person, String>multi("spec.tags", String.class)
.indexFunc(person -> {
var tags = person.getSpec().getTags();
return tags == null ? Set.of() : Set.copyOf(tags);
}));
// Index keys are not limited to String. Use Comparable types.
indexSpecs.add(IndexSpecs.<Person, Boolean>single("spec.pinned", Boolean.class)
.indexFunc(person -> person.getSpec().getPinned()));
indexSpecs.add(IndexSpecs.<Person, Integer>single("spec.priority", Integer.class)
.indexFunc(person -> person.getSpec().getPriority()));
indexSpecs.add(IndexSpecs.<Person, Instant>single("spec.publishTime", Instant.class)
.indexFunc(person -> person.getSpec().getPublishTime()));
// Optional builder flags from Halo 2.22+: unique and nullable.
indexSpecs.add(IndexSpecs.<Person, String>single("spec.slug", String.class)
.unique(true)
.nullable(false)
.indexFunc(person -> person.getSpec().getSlug()));
});
}
An index spec declares an index item. Prefer building it with
IndexSpecs.single(name, keyType) or IndexSpecs.multi(name, keyType).
Key details:
| Property | Description |
|---|---|
name |
Unique index name for this extension type, usually a field path |
keyType |
Index key type. Must implement Comparable, e.g. String, Boolean, Integer, Long, Instant |
indexFunc |
Function that extracts the indexed value from the extension |
unique |
Optional. Enforces unique index values when set to true |
nullable |
Optional. Allows null index values by default; set false for required keys |
Since Halo 2.22.0, IndexAttributeFactory.simpleAttribute(),
IndexAttributeFactory.multiValueAttribute(), and direct new IndexSpec()
construction are deprecated. Use IndexSpecs.single() and
IndexSpecs.multi() instead.
Built-in indexes (do not re-declare):
metadata.name(unique)metadata.labelsmetadata.creationTimestampmetadata.deletionTimestamp
Metadata Structure
Every extension has metadata:
| Field | Description |
|---|---|
metadata.name |
Unique ID, max 253 chars, lowercase alphanumeric + hyphens |
metadata.creationTimestamp |
Auto-set on create, immutable |
metadata.version |
Optimistic locking version. Mismatch on update = conflict |
metadata.deletionTimestamp |
Set when marked for deletion (before actual removal) |
metadata.finalizers |
Cleanup hooks. Extension not deleted until empty |
metadata.labels |
String key-value map. Auto-indexed. Use for querying |
metadata.annotations |
String key-value map. NOT indexed. Use for extra metadata |
GroupVersion & GroupVersionKind
Programmatically construct API identifiers:
// From strings
var gv = new GroupVersion("my-plugin.halo.run", "v1alpha1");
var gvk = GroupVersionKind.fromAPIVersionAndKind("my-plugin.halo.run/v1alpha1", "Person");
// From a @GVK-annotated class
var gvk = GroupVersionKind.fromExtension(Person.class);
// Parse from API version string
var gv = GroupVersion.parseAPIVersion("my-plugin.halo.run/v1alpha1");
Querying Extensions
Prefer the newer ReactiveExtensionClient query methods:
Flux<Person> people = client.listAll(Person.class, options, sort);
Mono<ListResult<Person>> page = client.listBy(Person.class, options, pageable);
Use these methods instead of the deprecated list(Class, Predicate, Comparator, ...)
overloads. Common query methods include:
| Method | Description |
|---|---|
listBy |
Page through matching data |
listNamesBy |
Page through matching names |
listAll |
Return all matching data |
listAllNames |
Return all matching names |
listTopNames |
Return top matching names |
countBy |
Count matching data |
ListOptions carries label and field conditions:
import static run.halo.app.extension.index.query.Queries.equal;
ListOptions options = ListOptions.builder()
.labelSelector()
.eq("env", "production")
.end()
.fieldQuery(equal("spec.pinned", true))
.build();
Call end() after labelSelector() to return to the ListOptions builder.
Use andQuery and orQuery when combining multiple field selector conditions
inside the builder.
Sorting and pagination are passed separately:
import org.springframework.data.domain.Sort;
import run.halo.app.extension.PageRequestImpl;
var sort = Sort.by(Sort.Order.asc("metadata.name"));
var pageable = PageRequestImpl.of(1, 10, sort);
client.listBy(Person.class, options, pageable);
client.listAll(Person.class, options, sort);
Fields used in fieldQuery or Sort must be indexed, otherwise Halo rejects
the query as unsupported. Query values should match the index keyType; Halo
uses conversion where possible, but incompatible values fail at query time.
Query DSL (Field Selectors)
Build typed queries for ListOptions field filtering:
import static run.halo.app.extension.index.query.Queries.*;
ListOptions.builder()
.fieldQuery(and(
equal("spec.pinned", true),
contains("spec.name", keyword),
greaterThan("spec.priority", 10)
))
.build();
QueryFactory is deprecated since Halo 2.22.0. Use Queries to build query
conditions. Negation can be built with either Queries.not(condition) or
condition.not().
Available operators include: empty, all, equal, notEqual,
greaterThan(field, value), greaterThan(field, value, inclusive),
lessThan(field, value), lessThan(field, value, inclusive), between,
in, isNull, contains, startsWith, endsWith, and, or, not,
labelExists, labelEqual, labelIn.
Use negation for operators that no longer have direct helper methods:
var isNotNull = isNull("metadata.deletionTimestamp").not();
var greaterThanOrEqual = greaterThan("spec.priority", 10, true);
var lessThanOrEqual = lessThan("spec.priority", 20, true);
var labelNotEqual = labelEqual("env", "production").not();
The HTTP fieldSelector parameter only supports the selector-style subset
(=, !=, and in, for example fieldSelector=spec.slug=(halo,halo2)).
For richer field and label conditions in Java code, use ListOptions with
Queries.
Extension Utilities
// Check deletion state
boolean deleted = ExtensionUtil.isDeleted(extension);
Predicate<ExtensionOperator> notDeleted = ExtensionOperator.isNotDeleted();
// Finalizer management
ExtensionUtil.addFinalizers(metadata, Set.of("my-plugin/finalizer"));
ExtensionUtil.removeFinalizers(metadata, Set.of("my-plugin/finalizer"));
// Default sort
Sort sort = ExtensionUtil.defaultSort(); // creationTimestamp desc, name asc
// Safe metadata access
Map<String, String> labels = MetadataUtil.nullSafeLabels(extension);
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(extension);
Naming Rules
- metadata.name: ≤253 chars,
[a-z0-9]([-a-z0-9]*[a-z0-9])? - labels keys: Optional prefix (DNS subdomain) + name (DNS label, ≤63 chars). Reserved: no-prefix keys and
halo.run/* - annotations keys: Same rules as labels, but not indexed