对外接口的单一真源。始终原地更新到当前契约。涵盖四类对外出口:① 跨插件只读 API ② 存储后端 SPI ③ Prometheus 导出端点 ④ 游戏内命令。
本文档忠于当前代码现状:方法签名、参数名、返回类型、权限节点、配置键均以源码与
config.yml为准。PRD/README 中标注"规划中"而代码尚未实现的能力不构成对外契约;Web 面板与代理子服 ping/路由已在 v0.2.0 交付。
- 形态:ServerProbe 是 Bukkit/BungeeCord 插件 + 库 API,不是 REST 服务。只读 API 与存储 SPI 以 Java 接口形式发布在
api模块(纯 Java + Lombok、零 Kotlin metadata,任意 Kotlin/Java 版本可编译依赖,ADR-13),Java 8 可直接调用;数据出口另有 Prometheus 文本端点与游戏内命令。 - 获取方式:跨插件消费方统一走
api模块的静态门面top.wcpe.mc.plugin.serverprobe.api.ServerProbeAPI.read(),无需了解容器内部细节。旧门面top.wcpe.mc.plugin.serverprobe.ServerProbeApi已废弃。 - 只读边界:读取 API 仅暴露查询,不暴露运行控制能力;存储 SPI 的受控替换注册见第 4 节,不属于读取 API。
- 平台语义:快照/画像携带
platform字段(BUKKIT/BUNGEE/VELOCITY)。三端均已启用(VELOCITY自 v0.3.0 起提供采集与 MCP 控制面,见 velocity-platform)。代理端(BUNGEE/VELOCITY)无世界/TPS/MSPT 概念,对应字段为 null;服务端无代理拓扑,proxy字段为 null。桥治理执行器(kick/list 类)按 ADR-016 只在 Bukkit 子服端与 Bungee 代理端二选一实现,Velocity 侧不提供,收到此类指令由BridgeRoutes返回明确的失败回执。 - 线程/调用时机:
- 调用方应在 ServerProbe 容器就绪后调用(如自身
ENABLE/ACTIVE之后);过早调用得到 null 属正常,稍后重试即可。 - 读内存快照/聚合的方法(
latestSnapshot、recentSnapshots、recentSnapshotsSince、aggregated、lastStartupProfile、lastStartupComparisonSummary)为轻量内存操作,可在主线程安全调用,不触发采集、不读盘。 - 可能读盘的方法(
historyStartupProfiles,以及 SPI 的readStartupProfiles/readHistory)宜在异步上下文调用,避免阻塞主线程。
- 调用方应在 ServerProbe 容器就绪后调用(如自身
- 版本演进:落盘对象自带
schemaVersion(MetricSnapshot当前 = 1;StartupProfile当前 = 4,FR7 = 4,M5 = 3,A3 = 2,M1 = 1),新增字段均带默认值,旧档反序列化降级为默认值,保持向后兼容。
- 门面容错:
ServerProbeAPI.read()恒返回非 null 的稳定委派代理(永不 null、可缓存为字段),内部逐方法runCatching兜底,绝不向调用方抛出。探针未就绪时各方法返回各自的空值(null / 空列表),调用方据此降级即可——不要写?: return,那个分支永远不会成立。 - 查询方法的空语义(逐方法详见第 3 节):
- 返回可空单值的方法,在"尚无数据"时返回 null(如
latestSnapshot()、lastStartupProfile()、lastStartupComparisonSummary())。 - 返回列表的方法,在无数据或不支持时返回空列表,不返回 null。
aggregated(windowSize)恒返回非空对象;窗口内无快照时windowSampleCount == 0,单项不可计算时对应字段为 null。
- 返回可空单值的方法,在"尚无数据"时返回 null(如
- SPI 默认方法的向后兼容:
MetricStore的 M2 扩面方法(readStartupProfiles、readHistory、批量appendHistory)均为 Kotlin 接口默认方法,默认实现为"空读 / 批量退化为逐条",旧实现无需改动即向后兼容。 - Prometheus 端点错误码:见第 5 节(401/403/500)。
接口:top.wcpe.mc.plugin.serverprobe.api.ProbeReadApi
门面:top.wcpe.mc.plugin.serverprobe.api.ServerProbeAPI
val api = ServerProbeAPI.read() // 恒非 null,可缓存为字段
val snapshot = api.latestSnapshot() ?: return // 尚无任何采样ServerProbeAPI.read(): ProbeReadApi —— 恒返回稳定委派代理,探针后加载也生效;未就绪时各方法返回空值而非抛异常。
| 方法 | 返回 | 语义 / 边界 |
|---|---|---|
latestSnapshot(): MetricSnapshot? |
最新一份指标快照 | 尚无任何采样时为 null。内存操作,可在主线程调用。 |
recentSnapshots(limit: Int): List<MetricSnapshot> |
最近若干份快照 | 无数据时为空列表。顺序(倒序/正序)由实现约定。 |
recentSnapshotsSince(sinceMs: Long): List<MetricSnapshot> |
内存近期缓冲中 timestampMs >= sinceMs 的快照 |
仅在内存缓冲上按时间下界筛选,不采集、不读盘;可回看跨度受缓冲容量限制(早于缓冲最旧一份的数据需经 SPI 读历史归档)。无满足项为空列表。 |
aggregated(windowSize: Int): AggregatedMetrics |
对最近 windowSize 份快照的跨快照聚合 |
恒非空。windowSize 非正按无数据处理;窗口内无快照时 windowSampleCount == 0;单项不可计算(无 TPS 样本、速率差分样本不足)时该字段为 null。内存操作,不读盘。 |
lastStartupProfile(): StartupProfile? |
最近一次启动画像 | 无历史记录时为 null。 |
historyStartupProfiles(limit: Int): List<StartupProfile> |
历史归档启动画像(由新到旧,至多 limit 份) |
可能读盘(本地文件后端遍历归档目录逐份反序列化),宜异步调用;limit 非正、无可用后端或无归档时为空列表。 |
historySnapshots(sinceMs: Long, untilMs: Long, limit: Int): List<MetricSnapshot> |
历史归档指标快照(FR-26,闭区间时间范围,由新到旧,至多 limit 条) |
可能读盘(本地文件后端定位日期范围内历史文件逐行解析),宜异步调用;默认实现空列表保兼容;limit 非正或无数据时为空列表。与 recentSnapshotsSince 区别:本方法读落盘历史(可超出内存缓冲跨度),后者仅筛内存缓冲、不读盘。 |
lastStartupComparisonSummary(): String? |
最近一次启动相对上一次的对比摘要(单行人类可读) | 由启动监听器在就绪时算出并写入内存;首次启动或无上一份画像时为 null。 |
包路径:top.wcpe.mc.plugin.serverprobe.api.model.{metrics,startup,player,diagnostics}.*(按用途分为四个子包)。下表仅列契约消费方最常用的字段;完整字段与口径以各类 KDoc 为准。
MetricSnapshot(快照核心单元)
| 字段 | 类型 | 说明 |
|---|---|---|
schemaVersion |
Int |
落盘格式版本(当前 = 1)。 |
timestampMs |
Long |
采样时刻(epoch 毫秒)。 |
serverId |
String |
实例标识(配置 server-name 或自动生成),恒有值。 |
platform |
ProbePlatform |
BUKKIT / BUNGEE / VELOCITY。 |
jvm |
JvmMetrics |
JVM 指标,恒非空。 |
server |
ServerMetrics? |
服务器指标;代理端为 null。 |
proxy |
ProxyMetrics? |
代理端指标;服务端为 null(默认 null)。 |
JvmMetrics(全平台通用):heapUsedBytes/heapCommittedBytes/heapMaxBytes(无上限为 -1)、nonHeap* 同款、memoryPools: List<MemoryPoolMetric>、gcYoungCount/gcYoungTimeMs/gcOldCount/gcOldTimeMs、gcCollectors: List<GcCollectorMetric>、threadCount/daemonThreadCount/peakThreadCount/deadlockedThreadCount、loadedClassCount/totalLoadedClassCount、processCpuLoad/systemCpuLoad(0.0–1.0,-1.0 表示当前 JDK 不提供)、uptimeMs/startTimeMs、jvmArgs: List<String>。
ServerMetrics(仅 Bukkit 端):tick: TickSample、onlinePlayers、maxPlayers、uptimeMs、worlds: List<WorldMetrics>?(未采集到或历史档不含时为 null)、pingDistribution: List<PingBucket>?(在线玩家 ping 分桶,FR2.4,无人/不支持时 null)、observedRegions: List<ObservedRegionMetrics>?(Folia 已观测 region 明细,FR-12,非 Folia/无数据时 null)、observedRegionWorlds: List<ObservedRegionWorldMetrics>?(按世界汇总,FR-12)。
TickSample:tps1m/tps5m/tps15m、msptAvg/msptP95/msptP99(均 Double?,null 表示 N/A,如 Folia 无全局 TPS)、source: TickSampleSource。
WorldMetrics:name、loadedChunks、entityCount、tileEntityCount(后两者在 Folia 受限时为 -1,表示 N/A)、entitiesByType: Map<String, Int>?(未开启分类统计或 Folia 受限时为 null)。
ProxyMetrics(仅代理端):totalOnline: Int、backends: List<BackendServer>(BackendServer = name + online)。
web配置键(web.enabled/host/port/token/allowed-ips,默认关闭仅本机)与metrics.*同属端点类配置,详见config.yml注释与 OPERATIONS;本文不重复。
AggregatedMetrics(聚合结果,字段除 windowSampleCount 外均可空,null = 本窗口不可计算):windowSampleCount: Int、tpsAvg、msptP95、msptP99、gcYoungRatePerSec、gcOldRatePerSec、gcYoungTimeRatePerSec、gcOldTimeRatePerSec(后六者均 Double?)。
StartupProfile(启动画像):基础字段 schemaVersion、serverId、platform、mcVersion、jvmStartTimeMs、totalMs、phaseTimings、pluginTimings、worldTimings、jvmArgs、createdAtMs。Incision 可选字段:incisionEnabled 表示本次启动是否请求采集,incisionActive 表示目标切点已实际执行,incisionPluginEnableTimings 仅在实际采集时提供逐插件精确启用耗时;默认关闭或未命中有效切点时 incisionActive=false 且列表为 null。启动 agent 增强字段(仅 -javaagent:plugins/ServerProbe.jar 挂载时有值,否则 agentAttached = false 且其余为 null):agentAttached、premainNanos、agentPluginLoadTimings、agentPluginEnableTimings、libraryTimings、mainThreadHotspots、timelineEvents、threadStacks、configTimings、eventTimings、commandTimings、sampleIntervalMs、httpCalls。
接口:top.wcpe.mc.plugin.serverprobe.api.store.MetricStore
定义启动画像与指标历史的持久化契约。默认且唯一内置实现 = 本地文件(JSON / JSONL,原子写入、可配滚动与保留);探针自身不内置、不依赖任何数据库。本接口作为扩展点预留,第三方可实现 DB/远程等后端进行替换。
| 方法 | 语义 | 默认实现 / 兼容 |
|---|---|---|
saveStartupProfile(profile: StartupProfile) |
保存一份启动画像(每次启动一份)。 | 抽象,必须实现。 |
lastStartupProfile(): StartupProfile? |
读取最近一次启动画像(用于与本次对比);无历史时为 null。 | 抽象,必须实现。 |
appendHistory(snapshot: MetricSnapshot) |
追加一条指标历史(聚合后行式追加)。 | 抽象,必须实现。 |
readStartupProfiles(limit: Int): List<StartupProfile> |
读历史归档启动画像(由新到旧,至多 limit 份);limit 非正返回空。可能读盘,宜异步。 |
默认返回空列表(M2 扩面),旧实现无需覆盖即兼容。 |
readHistory(sinceMs: Long, untilMs: Long, limit: Int): List<MetricSnapshot> |
读 timestampMs ∈ [sinceMs, untilMs](闭区间)的历史快照,至多 limit 条;limit 非正返回空。可能读盘,宜异步。 |
默认返回空列表(M2 扩面),旧实现无需覆盖即兼容。 |
appendHistory(snapshots: List<MetricSnapshot>) |
批量追加指标历史。 | 默认逐条调用 appendHistory(it)(M2 扩面);DB/远程后端可覆盖为单事务/单请求批量写入。 |
第三方对接 DB/远程后端时,先实现 MetricStore:三个抽象方法必须实现;三个带默认实现的 M2 扩面方法按需覆盖(覆盖 readStartupProfiles/readHistory 以提供历史回读能力,覆盖批量 appendHistory 以降低写入开销)。不覆盖即保持“空读 / 批量退化为逐条”语义,不破坏既有 SPI 与既有调用。
在 ServerProbe 已启用后,通过运行期插件 jar 中的 top.wcpe.mc.plugin.serverprobe.ServerProbeStorageApi 安装实例:
val registration = ServerProbeStorageApi.install(store)
// 插件禁用或不再需要替换时:
registration.close()install 同一时刻只接受一个第三方存储;已有替换实例时抛出 IllegalStateException,不会静默覆盖。返回 MetricStoreRegistration,关闭当前注册后立即恢复内置 LocalFileMetricStore;陈旧注册句柄不会移除后来安装的实例。未安装第三方实例时,全部存储调用始终委派内置本地文件实现。
实现:top.wcpe.mc.plugin.serverprobe.core.prometheus.PrometheusExporter / MetricsHttpHandler / PrometheusTextFormatter
基于 JDK 自带 com.sun.net.httpserver.HttpServer(零第三方依赖)起轻量 HTTP 服务,在 /metrics 路径以 Prometheus exposition format(text/plain; version=0.0.4; charset=utf-8)暴露最新一份指标快照。
- 路径:
/metrics。 - 双端独立:Bukkit 与 BungeeCord 各为独立进程,各起一个端点,各自按本端
config.yml配置。同机部署两端须配置不同port。 - 端点默认关闭(
metrics.enabled = false)。
| 键 | 默认 | 说明 |
|---|---|---|
metrics.enabled |
false |
是否开启 /metrics 端点。 |
metrics.host |
127.0.0.1 |
绑定地址。默认仅本机回环;远程抓取改 0.0.0.0,但务必同时设 token 或白名单。 |
metrics.port |
9940 |
监听端口。 |
metrics.token |
""(空) |
鉴权 token。空 = 不启用 token 鉴权(仅靠 IP 白名单)。 |
metrics.allowed-ips |
[127.0.0.1] |
允许访问的来源 IP 白名单(与 token 叠加生效)。仅精确 IP 匹配,不支持 CIDR;IPv6 回环需另填 ::1。 |
请求需同时通过两道校验,任一不过即拒绝且不泄露细节:
- token:
metrics.token非空时,要求请求头Authorization: Bearer <token>完全匹配,否则 401 Unauthorized;token 为空时跳过此校验,仅靠 IP 白名单兜底。 - IP 白名单:来源 IP 不在
metrics.allowed-ips内则 403 Forbidden。
服务端处理异常兜底返回 500 Internal Server Error。鉴权拒绝/错误响应体仅含极简文案,不含任何配置/路径/token 信息。
请求示例:
GET /metrics HTTP/1.1
Authorization: Bearer <token>
- 统一前缀
serverprobe_。 - 每条时间序列附带两个公共 label:
serverId(实例标识)、platform(BUKKIT/BUNGEE/VELOCITY)。 - 时间单位一律转换为秒(
*_seconds/*_seconds_total)。 - 不可用字段不导出时间序列:数值哨兵 -1(如
heapMaxBytes、世界各计数)、-1.0(CPU 占用率)、可空字段 null(如 Folia 下 TPS/MSPT)对应行整行跳过,而非导出占位值。
覆盖维度(去前缀短名,实际名带 serverprobe_ 前缀):
- JVM:
heap_used_bytes、heap_committed_bytes、heap_max_bytes、nonheap_used_bytes、nonheap_committed_bytes、nonheap_max_bytes、memory_pool_used_bytes/memory_pool_max_bytes(labelpool)、gc_count_total/gc_time_seconds_total(labelgc,counter)、gc_young_count_total/gc_young_time_seconds_total/gc_old_count_total/gc_old_time_seconds_total(counter)、threads/threads_daemon/threads_peak/threads_deadlocked、classes_loaded(gauge)/classes_loaded_total(counter)、process_cpu_load/system_cpu_load、uptime_seconds。 - 服务器(仅服务端):
tps(labelwindow= 1m/5m/15m)、mspt_seconds(labelquantile= avg/p95/p99)、players_online、players_max、server_uptime_seconds、world_loaded_chunks/world_entities/world_tile_entities(labelworld)。 - 启动画像(FR-25,仅内存存在最近一次启动画像时导出;代理端无画像不导出):
startup_total_seconds、startup_plugin_seconds(labelplugin,与/probe startup的慢插件榜同源同截:来源经SlowPluginRanking择优——Incision 精确 > agent 实测 > 日志解析近似——并同样截断到startup.top-n条)、startup_world_seconds(labelworld)。 - 代理端(仅代理端):
proxy_players_online、proxy_backend_players_online(labelbackend)。
端点仅暴露最新快照的瞬时值;历史趋势由 Prometheus 抓取时间序列自身承载。 Web 面板(FR-04 子能力,v0.4.0 起四页:总览 / 启动画像 / 历史趋势 / 网络取证——后者为唯一展示完整 IP 与白名单载荷的页面,经鉴权 + IP 白名单访问),默认关闭,见 PRD FR-04。
实现:top.wcpe.mc.plugin.serverprobe.command.ProbeCommand
主命令 /probe,权限 serverprobe.command(无权限提示"你没有权限使用该命令")。全部子命令只读,取值来自 ProbeReadApi 的内存快照/聚合,无阻塞 IO。无参 /probe 输出帮助(列出全部子命令)。
| 子命令 | 作用 | 权限 | 备注 |
|---|---|---|---|
health |
总体概览:TPS(1m)/MSPT(avg)/堆已用·最大/CPU/在线人数/运行时长。 | serverprobe.command.health |
服务器维度字段在代理端为 N/A。尚无采样时提示采集中。 |
startup |
最近一次启动画像:总时长、慢插件 Top-N、各世界耗时、与上次对比;挂载 agent 后追加库下载/主线程热点/配置·事件·命令耗时 Top-N。 | serverprobe.command.startup |
无画像时提示"尚无启动画像"。Top-N 取 startup-top-n(默认 5)。agent 增强段需 -javaagent。 |
tps |
TPS(1/5/15 分钟)与 MSPT(avg/p95/p99),并附近 N 份快照的聚合补充行(FR-03)。 | serverprobe.command.tps |
字段 null(Folia/不可用)显示 N/A;代理端无此指标。聚合窗口取 aggregation.window(默认 12)。 |
gc |
GC(young/old 的 count/timeMs)+ 堆/非堆/各内存池。 | serverprobe.command.gc |
JVM 指标全平台通用,代理端同样可用。 |
world |
各世界:已加载区块数、实体数、方块实体数(FR-02)。 | serverprobe.command.world |
代理端无此指标;worlds 未采样时提示采集中;Folia 受限项(-1)显示 N/A。 |
proxy |
代理端总在线 + 各子服在线明细、子服 RTT/可达性、玩家路由与每玩家 ping。 | serverprobe.command.proxy |
仅代理端有数据;在服务端提示"此为服务端,请在 BungeeCord 执行"。 |
proxy |
代理端总在线、各子服在线与 ping/可达性、玩家路由、每玩家 ping(FR2.5)。 | serverprobe.command.proxy |
仅代理端有数据;在服务端提示"此为服务端,请在 BungeeCord 执行"。子服 ping 由后台周期任务异步采集并缓存(collectPeriodTicks 周期,单次超时 3s),collect 只读缓存不阻塞主线程;尚未测到时 pingMs=-1、reachable=false。BungeeCord 与 Velocity 两端均有实现。 |
flamegraph |
由最近启动画像导出自包含 HTML(火焰图 + 时间线),输出到 data/flamegraph/。 |
serverprobe.command.flamegraph |
需挂载启动 agent(-javaagent:plugins/ServerProbe.jar);未挂载时提示启用方式。 |
http |
回看最近的对外网络外呼(插件/方法/URL/响应码/耗时/触发处),倒序展示。 | serverprobe.command.http |
需挂载启动 agent且外呼监控开启方有数据;展示条数固定 20(HTTP_DISPLAY_LIMIT)。缓冲为空时按 agent 是否挂载给出不同提示。 |
另有
serverprobe.alert权限(非/probe子命令):游戏内告警通道据此向在线 OP 或持该权限的玩家推送告警(仅 Bukkit 端),见config.yml的alert段。
接口:top.wcpe.mc.plugin.serverprobe.api.bridge.BusinessHost
门面:top.wcpe.mc.plugin.serverprobe.api.ServerProbeAPI.businessHost()
第三方插件自注册业务域(FR-37 后探针不再内置任何业务实现)。门面返回稳定代理,可缓存为字段;探针未就绪时注册得到失效句柄、查询返回降级值,绝不抛异常。
// onEnable:缓存一次,探针后加载也依然有效
BusinessHost host = ServerProbeAPI.businessHost();
Registration handle = host.register(new MyProvider()); // close() 幂等,按实例比对| 方法 | 返回 | 语义 / 边界 |
|---|---|---|
register(BusinessProvider) |
Registration |
同域重复注册以最新为准,旧句柄立即失效(REPLACED)。provider 为 null / domain 格式非法(须匹配 ^[a-z0-9-]{1,32}$) / 动作名重复时返回失效句柄(INVALID)并记 ERROR。 |
registerLegacy(LegacyBusinessProvider) |
Registration |
注册 FR-37 形态的旧契约 Provider(裸 Map manifest + 双字符串 dispatch)。内部经 LegacyBusinessProviderAdapter 适配,该域在 manifest 里标 specDeclared=false。 |
unregister(BusinessProvider) |
boolean |
按实例比对注销:仅当该域仍指向此实例才移除(插件热重载的经典坑)。经 registerLegacy 注册的域须用返回句柄 close()(包装实例无法从外部取得)。 |
domains() |
Set<String> |
当前已注册域,不可变、按字典序。 |
manifest() |
Map<String, Object> |
聚合信封 {"apiVersion":1,"domains":{...}},见 §7.5。单域 manifest() 抛异常时该域呈现为 {"error": "..."}。 |
dispatch(ActionRequest) |
ActionResult |
强类型入口(推荐)。带 requestId 与 Principal;requestId 为空时由探针补齐。可能阻塞至多 bridge.dispatch.timeout-ms,禁止在主线程调用。 |
dispatchAsync(ActionRequest) |
CompletableFuture<ActionResult> |
异步强类型派发;超时也以失败结果完成,而非异常完成。 |
dispatch(domain, action, payloadJson) |
BridgeCommandResult |
兼容入口:等价于无 requestId/无 Principal 的请求,错误码与诊断信息在这一步丢失。 |
dispatchAsync(domain, action, payloadJson) |
CompletableFuture<BridgeCommandResult> |
兼容入口的异步版本。 |
healthOf(domain) |
DomainStatus |
该域健康状态(只读探针缓存)。不调用 Provider、不做 IO、不派发、不新建线程。未注册域返回 registered=false 的快照,不报错。 |
public interface BusinessProvider {
String domain(); // 小写字母/数字/连字符,1-32
DomainManifest manifest(); // 强类型能力清单
ActionResult dispatch(ActionRequest request);
default ProviderHealth health() { return ProviderHealth.unknown(); }
}| 类型 | 关键字段 |
|---|---|
DomainManifest |
apiVersion(当前 = 1)、domain、displayName、description、actions: List<ActionSpec>、attributes: Map<String,String>(逃生舱,平台不解释、原样透传)。 |
ActionSpec |
name、description、risk(READ/WRITE/DESTRUCTIVE,未声明按 WRITE)、idempotent、inputSchema/outputSchema(JSON Schema 文本,可为空)、errorCodes(该动作可能返回的业务错误码)。 |
ActionRequest |
domain、action、payloadJson、requestId(平台保证非空)、principal。 |
Principal |
operator、operatorId、nodeId、reason(操作者上下文,探针原样透传,不解释)。 |
ActionResult |
success、outputJson、errorCode(机器可读,面板据此分流)、errorMessage(人可读)、metadata(诊断信息,不影响兼容)。 |
risk 必须诚实声明:标 READ 却写数据会让下游的风险控制(二次确认)彻底失效。
注册期校验(spec §7):
| 校验项 | 失败行为 |
|---|---|
domain 格式(^[a-z0-9-]{1,32}$) |
拒绝注册(失效句柄,INVALID),记 ERROR |
apiVersion 不支持 |
不拒绝:降级为 legacy 规格(specDeclared=false),记 WARN |
| 动作名重复 | 拒绝注册(失效句柄,INVALID),记 ERROR |
risk 未声明 |
按 WRITE 生效,记 WARN |
errorCodes 为空 |
允许(表示未声明) |
旧契约向后兼容:FR-37 形态的 Provider 改 implements LegacyBusinessProvider(一词之差),用 registerLegacy(...) 注册即可继续工作;其域标 specDeclared=false,risk 一律 WRITE、idempotent=false、schema 与错误码为空。
BusinessProvider 新增 default ProviderHealth health() ,默认返回 ProviderHealth.unknown() —— 旧实现零改动即兼容。
- 在探针的独立刷新线程(
serverprobe-health-probe)上被调用,不在命令线程上;仍应快速返回,不要做网络/磁盘 IO。 - 探针只等
bridge.health.timeout-ms(默认 1000ms):超时记DEGRADED("health() 超时")并进入隔离窗口bridge.health.quarantine-ms;抛异常记DEGRADED("health() 抛出异常:<类名>");返回null记DEGRADED("health() 返回 null")。任何分支都不影响其它域。 - 等级:
AVAILABLE/UNAVAILABLE/INITIALIZING/DEGRADED/UNKNOWN。要报告"自何时起如此"用ProviderHealth.of(status, reason, detailJson, sinceMs);简写工厂(available()等)会把sinceMs填为构造时刻,unknown()恒为 0。 - 探针不替 Provider 推断
sinceMs;运维要"观测时间"读回执里的refreshedAtMs(探针采集时刻)。
| 字段 | 类型 | 说明 |
|---|---|---|
domain |
String |
业务域。 |
registered |
boolean |
当前是否有 Provider 注册(getRegistered(),布尔访问器一律 get 前缀)。 |
health |
ProviderHealth |
Provider 自报值;未刷新过为 unknown()。 |
reasonOverride |
String |
探针补充的原因(如 Provider 未注册 / Provider 已注销),非空时优先呈现。 |
refreshedAtMs |
long |
探针采集 health 的时刻;未刷新为 0。 |
lastFailure |
FailureInfo? |
最近一次 dispatch 失败(已脱敏限长);从未失败为 null。 |
dispatchTotal / dispatchFailed / lastCostMs |
long |
派发总数 / 失败数 / 最近耗时。 |
quarantined |
boolean |
是否处于隔离窗口内(getQuarantined())。 |
manifestDigest |
String? |
FR-40 规格指纹(规范化 JSON 的 sha256 前 16 位十六进制小写);无指纹为 null。 |
lastDispatchAt |
long |
FR-40 最近一次 dispatch 的完成时刻;从未派发为 0。 |
dispatchCount / failureCount |
long |
FR-40 同 dispatchTotal / dispatchFailed 的另一读口(取值恒等,历史命名保留)。 |
degradedReason |
String? |
FR-40 降级原因的呈现口径(探针补充优先、退而用自报);健康时为 null。 |
available |
boolean |
FR-40 等价于 registered && health == AVAILABLE。 |
manifestDigest 的三个要点:
- 注册时算一次并缓存,查询路径绝不再调
Provider.manifest()——那会占住命令槽,且让"只读快照"失效。 - 归一化后再取指纹:动作按名称排序、对象键(
attributes/errorCodes)按字典序、去除空白差异。因此"同一份语义内容"在任何声明顺序下都得到同一个值。 - null 表示没有可比对指纹:未注册域、或走旧契约适配的域(其清单由适配器合成,算出的指纹没有可比性)。不是空指纹。
dispatchCount/failureCount/lastDispatchAt是运行期指标,重启归零,不是持久统计。
只读健康缓存快照。payload 可选 {"domain":"economy"}(不传则全量:已注册域 + 保留窗口内的已注销域)。整段查询不调 dispatch、不做 IO、不新建线程,域数上限 bridge.health.max-entries,每域 reason ≤ reason-max-chars、detailJson ≤ detail-max-chars(存储时即截断)。
{
"domains": {
"economy": {
"registered": true, "health": "AVAILABLE", "reason": "", "detailJson": "",
"sinceMs": 1730000000000, "refreshedAtMs": 1730000000500, "quarantined": false,
"available": true, "degradedReason": null,
"manifestDigest": "a3f2b8c1d4e5f607", "lastDispatchAt": 1730001234000,
"dispatchCount": 1523, "failureCount": 7,
"lastFailure": {"message": "外部插件未就绪", "timestampMs": 1729999990000, "consecutiveFailures": 2},
"dispatch": {"total": 1523, "failed": 7, "lastCostMs": 3}
}
},
"refresh": {"inFlight": false, "stalled": false, "quarantined": ["activity"], "lastSweepAtMs": 1730000001000, "skippedThisSweep": []},
"timestamp": 1730000001000
}lastFailure从未失败时整个字段缺席(不是空对象)。degradedReason/manifestDigest为 null 时字段存在、值为null(与lastFailure的"缺席"不同):它们是每域恒定存在的槽位。- 0 个域 →
domains为空对象,整段仍success=true(不是错误)。 refresh.stalled=true表示 probe 线程被某个卡死的health()占住:其余域沿用上次缓存(查询仍成功)、本轮后续域列在refresh.skippedThisSweep。这是已知取舍(Java 无法强杀线程),代价是"其它域的健康值不再新鲜",收益是线程数恒定、命令通道完全不受影响。- 单域超时/异常/返回 null 只把该域记为
DEGRADED,整段回执仍success=true。
相关配置(bridge.health.*,均单域有界):refresh-interval-ms(10000)、timeout-ms(1000)、quarantine-ms(60000)、retain-after-unregister-ms(300000)、max-entries(256)、reason-max-chars(512)、detail-max-chars(2048)。
保留元域 jbis + 动作 manifest:返回全部已注册业务域的能力清单。它不派发到任何 Provider,只汇总 manifest。
output 是如下信封(顶层 apiVersion 是信封协议版本,不是某个域的):
{
"apiVersion": 1,
"domains": {
"activity": {
"displayName": "活动系统",
"description": "...",
"specDeclared": true,
"actions": [
{
"name": "start",
"description": "开启活动",
"risk": "WRITE",
"idempotent": true,
"inputSchema": "{\"type\":\"object\"}",
"outputSchema": null,
"errorCodes": ["ACTIVITY_NOT_FOUND", "ALREADY_RUNNING"]
}
],
"attributes": {}
}
}
}specDeclared=false表示该域规格不可信:来自旧契约适配,或apiVersion不受支持被降级。此时动作清单可能不完整,risk/idempotent是探针的保守默认值(WRITE/false),面板不应据此渲染精确表单。inputSchema/outputSchema未声明时为null(不是空串);errorCodes: []表示未声明。- 单域
manifest()抛异常时该域呈现为{"error": "..."},不影响其它域。 - 兼容性:旧消费方读
domains仍然有效,新增的apiVersion/specDeclared是纯增量。
接口:top.wcpe.mc.plugin.serverprobe.api.bridge.BusinessEventSink
门面:ServerProbeAPI.eventSink()(稳定代理,可缓存;探针未就绪时一律返回 false,emitReliable 的 seq 重载默认退化为忽略 seq)
| 方法 | 语义 |
|---|---|
emit(domain, dedupKey, topic, fields) |
可丢:队列满即丢并计数。不占 seq 序列。 |
emitReliable(domain, dedupKey, topic, fields) |
不可丢:探针自动分配 seq。dedupKey 必须非空,为空直接 false 且不降级为可丢。 |
emitReliable(domain, dedupKey, topic, fields, seq) |
不可丢 + 调用方指定 seq(补发专用)。seq <= 0 返回 false。 |
boolean 只能回答「有没有受理」,而不受理的原因决定了调用方该做什么。需要区分时用下面两个方法,
它们返回 EmitReceipt(带原因、可重试标记、序号):
| 方法 | 语义 |
|---|---|
emitReceipt(domain, dedupKey, topic, fields) |
同 emit,但返回 EmitReceipt。 |
emitReliableReceipt(domain, dedupKey, topic, fields) |
同 emitReliable,并回传探针分配的 seq/seqEpoch,业务侧可拿它做本地水位线。 |
EmitReceipt receipt = ServerProbeAPI.eventSink()
.emitReliableReceipt("economy", "bill-8891", "economy.bill", fields);
if (!receipt.isAccepted()) {
if (receipt.isRetryable()) {
// 通道饱和:退避后重试,事件本身没丢
} else {
// 参数类失败或探针未就绪:重试无用,记日志并按业务策略处理
}
}
long watermark = receipt.getSeq(); // 可靠通道才有值,> 0EmitReceipt.Reason 的取值与含义:
| 原因 | 可重试 | 含义与建议处置 |
|---|---|---|
ACCEPTED |
否 | 已受理(含“已落盘待回放”)。 |
REJECTED_SATURATED |
是 | 队列满且落盘也接不下。退避后重试。 |
PROBE_UNAVAILABLE |
否 | 探针未就绪/已卸载。插件先于探针加载是常态,静默忽略即可。 |
INVALID_DOMAIN |
否 | 域名空或格式错,调用方 bug。 |
MISSING_DEDUP_KEY |
否 | 可靠事件缺 dedupKey,调用方 bug;不会降级为可丢。 |
INVALID_SEQUENCE |
否 | 调用方指定的 seq <= 0。 |
只有 REJECTED_SATURATED 可重试。把永久失败当成可重试,回灌循环会陷入“退避→重试→再退避”的死循环。
兼容性:emit / emitReliable 的 boolean 形式行为完全不变;emitReceipt / emitReliableReceipt
是接口的 default 方法,第三方既有实现升级后无需改动。未覆写时它们从参数反推原因——只能分辨“空域”
与“缺去重键”两种必败情形,其余一律保守归为 REJECTED_SATURATED(宁可多试一次,也不误报永久失败)。
seq 的语义边界:
- per-domain 独立递增,不是全局单调;不同域互不干扰。
- 严格递增、不跳号;起始 1,重启后从持久化的
max + 1续接(方案 B)。 - 可丢事件不占序列:否则可丢事件一被丢弃,可靠序列里就会留下永远补不上的空洞。
- 补发保持原 seq:落盘缓冲的行里连同位点一起写入(
type \t event \t domain \t topic \t requestId \t seq \t seqEpoch \t payload),回放时原样发出。FR-41 之前的 6 字段旧行仍可读出(位点为 0)。 - 持久化丢失时从
(当前时间戳毫秒 << 20)起跳并递增seqEpoch,保证不与旧值冲突。 - 调用方指定的
seq不推进探针的分配序列;若它大于当前上限则把上限抬高到seq + 1(避免后续序号倒退)。
事件帧(payload)新增两个字段,只在有值时出现:
{
"type": "event", "event": "activity_change", "instance": "s1",
"timestamp": 1730000000000, "domain": "activity", "dedupKey": "e-8891",
"seq": 1024, "seqEpoch": 1,
"data": {"...": "..."}
}seqEpoch 递增只发生在序号语义确实重置时(持久化丢失、long 溢出前重置)。正常重启不递增
——下游看到同一 seqEpoch 就能确信「序号仍可跨重启比对」;seqEpoch 变了则需全量对账。
探针只保证 seq/seqEpoch 准确,不规定业务载荷里放什么字段(那是业务台与插件的约定)。
状态文件:落盘目录下的 seq.state(与 spill.jsonl 同目录,bridge.line.spill-enabled=false 时不存在):
# ServerProbe 事件序号状态(制表符分隔,勿手工编辑)
epoch 1
activity 1024
economy 7
接口:top.wcpe.mc.plugin.serverprobe.api.reload.ConfigReloadRegistry
门面:top.wcpe.mc.plugin.serverprobe.api.ServerProbeAPI.configReloads()
JM 经 core.config_reload 下发路径后,探针只通知真正关心这份配置的插件,并如实回传结果。
探针不推断插件是否真的应用了新配置(reload 成功 ≠ 配置生效);需要强保证请用重启。
// onEnable:注册监听器(门面返回稳定代理,探针后加载也可用)
Registration handle = ServerProbeAPI.configReloads().register(new MyReloadListener());
// onDisable:注销(幂等;按实例比对,不会误删热重载后的新实例)
handle.close();| 方法 | 返回 | 语义 / 边界 |
|---|---|---|
register(ConfigReloadListener) |
Registration |
注册即校验声明:非法条目(越界、含 NUL、空串)被丢弃并 WARN;全部非法 → 失效句柄 INVALID + "路径越界";声明空集合 → 失效句柄 INVALID + "未声明关注路径"。不静默成功。探针未就绪 → 失效句柄 PROBE_UNAVAILABLE,不抛异常。 |
| 成员 | 语义 |
|---|---|
Collection<String> watchedPaths() |
关心的路径:精确(plugins/Foo/config.yml)或目录前缀(末尾斜杠,plugins/Foo/)。路径用 / 分隔、相对工作目录、必须在 plugins/ 子树内。每次派发都会被调用(可在运行期改关注项),必须轻量。 |
ConfigReloadResult onConfigReload(ConfigReloadEvent) |
重载并返回结果。绝不抛异常;抛出的会被兜住并转为 PLUGIN_THREW(丢失可读原因);返回 null 视为失败。 |
线程约定:回调在 serverprobe-reload-worker 池的 daemon 线程上触发,不在主线程、不在桥读线程。
需要主线程的插件自行切回,但总耗时不得超过 bridge.reload.timeout-ms(默认 5000ms);
超时后探针只标记 TIMEOUT,不强杀你的任务(Java 无法强杀线程),该次重载自生自灭。
| 类型 | 要点 |
|---|---|
ConfigReloadEvent |
getMatchedPaths() 本次命中本监听器的路径(已排序);getRequestedPaths() 本次请求全部路径(全量时为空);getFullReload() 是否全量。静态工厂 of(matched, requested) / full(matched)。 |
ConfigReloadResult |
工厂 ok() / ok(detailJson) / failed(code, message[, detailJson])。读口 getOk()/getCode()(成功时 null,本类唯一允许 null 的读口)/getMessage()/getDetailJson();自报事实 getAppliedMd5()(Optional<String>)、getAppliedVersion()、getAppliedCount()(OptionalInt,empty 表示未上报、0 是合法事实),配套写口 withAppliedMd5/Version/Count。布尔访问器一律 get 前缀(ADR-13)。 |
ConfigReloadErrorCode |
FILE_NOT_FOUND / PARSE_ERROR / PLUGIN_REJECTED / PLUGIN_THREW / TIMEOUT / PATH_ESCAPE / IN_FLIGHT / STUCK / INTERNAL_ERROR。BEST_EFFORT 不是错误码——无人认领的路径计入回执的 bestEffort(算成功)。只有 TIMEOUT 触发熔断。 |
payload:{"paths":["plugins/Foo/config.yml", "plugins/Bar/"]};空数组或省略 paths = 全量
(向所有已注册监听器各通知一次,matchedPaths 为其声明集合)。单次上限
bridge.reload.max-paths-per-request(默认 32),超限整请求拒绝(不静默截断)。
{
"success": true,
"reloaded": ["plugins/Foo/config.yml"],
"bestEffort": ["plugins/Bar/config.yml"],
"failed": [
{"path": "plugins/Baz/config.yml", "code": "PARSE_ERROR", "message": "第 12 行缩进错误"}
],
"applied": {"plugins/Foo/config.yml": {"md5": "...", "version": "...", "count": 12}},
"costMs": 37,
"error": ""
}- 整体成败判定式(唯一权威):
success = failed.isEmpty() && error.isEmpty()。 即使全部是bestEffort也算成功;任一失败则整体失败,但reloaded仍如实列出成功的部分。 reloaded至少一个监听器返回ok()的路径;bestEffort没有任何监听器认领的路径(算成功,单列);failed任一监听器失败/超时/越界/在途/卡死。同一路径既有成功又有失败 → 归入failed(严格口径)。applied为自报事实,未上报的字段整个缺席(不写 0);三个字段都没上报时该路径不出现在applied里。- 请求级错误(路径数超限、payload 非法)不产生
failed条目,只写error+success=false。
失败时的传输层形态:命令回执的信封是
{success, output, error}。 成功时output是本节的完整 JSON;失败时两者都有——output仍是完整回执 (含reloaded/bestEffort/failed/applied明细),error是逐条带path/code/message的可读摘要(并注明"已成功 N 项"),success=false。 这由BridgeCommandResult.ofFailure(output, error)承载——ok(output)/fail(error)两个既有工厂互斥,单靠它们会让失败时丢掉机器可读明细。面板应优先读output,error仅供直接展示与告警文案。
相关配置(bridge.reload.*):timeout-ms(5000)、max-paths-per-request(32)、worker-pool-size(8)、
stuck-after-ms(30000)、circuit-breaker.enabled(true)/failure-window-ms(60000)/
open-duration-ms(60000)/max-open-duration-ms(600000)/failure-threshold(1)/max-entries(256)。
协调池容量不在此段:它是 bridge.line.reload-pool-size(4)。