@hile/micro 1.0.4 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +280 -47
- package/SKILL.md +448 -91
- package/dist/application.d.ts +14 -1
- package/dist/application.js +123 -8
- package/dist/registry.d.ts +5 -0
- package/dist/registry.js +38 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
# @hile/micro
|
|
2
2
|
|
|
3
|
-
基于 `@hile/message-loader` 与 `@hile/message-ws` 的轻量级 **WebSocket
|
|
3
|
+
基于 `@hile/message-loader` 与 `@hile/message-ws` 的轻量级 **WebSocket 微服务框架**。提供服务注册与发现、心跳保活、熔断、请求超时、自动重试、调用链路追踪等功能。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 架构分层
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
```
|
|
8
|
+
MessageLoader (路由) + MessageWs (请求/响应传输)
|
|
9
|
+
└── Server(WebSocket 服务底层)
|
|
10
|
+
├── Registry(注册中心)
|
|
11
|
+
└── Application(应用服务)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
| 组件 | 职责 |
|
|
15
|
+
|------|------|
|
|
16
|
+
| **Server** | WebSocket 监听、连接管理、消息路由。不关心注册中心 |
|
|
17
|
+
| **Client** | 远端 Server 的代理,提供 `request()` / `push()` 通信接口 |
|
|
18
|
+
| **Registry** | 注册中心。维护 namespace → 实例列表,心跳检测剔除死实例 |
|
|
19
|
+
| **Application** | 应用服务。集成注册发现、熔断、重试、追踪等功能 |
|
|
10
20
|
|
|
11
|
-
|
|
21
|
+
一个 `Application` 实例 **同时** 扮演 provider(`register` 暴露接口)和 consumer(`get` / `call` 调用其它服务)。
|
|
12
22
|
|
|
13
23
|
## 安装
|
|
14
24
|
|
|
@@ -16,34 +26,13 @@
|
|
|
16
26
|
pnpm add @hile/micro
|
|
17
27
|
```
|
|
18
28
|
|
|
19
|
-
依赖:`@hile/message-loader`、`@hile/message-ws`、`ws
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## 能做什么?
|
|
24
|
-
|
|
25
|
-
| 组件 | 作用 |
|
|
26
|
-
|------|------|
|
|
27
|
-
| `Server` | **底层协议**:监听 WebSocket;解析路径中的调用方地址与可选分段;对内用 `register`/`dispatch` 处理 `{ url, data }` |
|
|
28
|
-
| `Client` | 连到远端 `Server`,`request`/`push` 走 `MessageModem`,`dispose()` 会关闭底层连接 |
|
|
29
|
-
| `Registry` | **注册中心**:固定 namespace `'registry'`;实例上线/下线更新 namespace→地址集合;`/-/find` **随机** 返回其中一个地址 |
|
|
30
|
-
| `Application` | **基于 `Server` 的应用实现**:启动后连接 Registry;`register` 侧即 provider、`get` 侧即 consumer;`get(ns)` 查询并 **缓存** 到目标服务的 `Client`,断连后清空缓存 |
|
|
31
|
-
|
|
32
|
-
连接串格式(出站):
|
|
33
|
-
|
|
34
|
-
`ws://目标主机:端口/{宣告地址}/{本机监听端口}/{本机namespace}`
|
|
35
|
-
|
|
36
|
-
宣告地址:构造 `Server` / `Application` / `Registry` 时可通过 **`advertiseHost`** 显式传入;未传则使用 `getLocalIPv4()`。若二者皆无(例如无可用 IPv4),**构造阶段即抛错**。容器、多网卡或 CI 环境建议设置 `advertiseHost`(如 `127.0.0.1` 或 Pod IP)。
|
|
37
|
-
|
|
38
|
-
`Application` 还支持 **`registryLookupTimeoutMs`**(默认 `10000`),限制对 Registry `/-/find` 的响应等待时间。
|
|
39
|
-
|
|
40
|
-
出站 `connect` 默认 **5 秒**握手超时,超时报错 `Connection timeout`。
|
|
29
|
+
依赖:`@hile/message-loader`、`@hile/message-ws`、`ws`。
|
|
41
30
|
|
|
42
31
|
---
|
|
43
32
|
|
|
44
|
-
##
|
|
33
|
+
## 快速开始
|
|
45
34
|
|
|
46
|
-
### 1. Registry
|
|
35
|
+
### 1. 启动 Registry
|
|
47
36
|
|
|
48
37
|
```typescript
|
|
49
38
|
import { Registry } from '@hile/micro';
|
|
@@ -52,7 +41,9 @@ const registry = new Registry({ advertiseHost: '127.0.0.1' });
|
|
|
52
41
|
await registry.listen(9000);
|
|
53
42
|
```
|
|
54
43
|
|
|
55
|
-
### 2.
|
|
44
|
+
### 2. 启动 Provider(服务 A)
|
|
45
|
+
|
|
46
|
+
以下 `provider` 和 `consumer` 是两个不同进程(不同 namespace),**每个进程只需要一个 `Application` 实例**,同时扮演 provider 和 consumer:
|
|
56
47
|
|
|
57
48
|
```typescript
|
|
58
49
|
import { Application } from '@hile/micro';
|
|
@@ -70,7 +61,7 @@ provider.register('/charge', async ({ data }) => {
|
|
|
70
61
|
});
|
|
71
62
|
```
|
|
72
63
|
|
|
73
|
-
### 3.
|
|
64
|
+
### 3. 启动 Consumer 调用(服务 B)
|
|
74
65
|
|
|
75
66
|
```typescript
|
|
76
67
|
import { Application } from '@hile/micro';
|
|
@@ -83,38 +74,280 @@ const consumer = new Application({
|
|
|
83
74
|
|
|
84
75
|
await consumer.listen(9200);
|
|
85
76
|
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
console.log(result);
|
|
77
|
+
// call() = get() + request() + response() + 熔断 + 重试
|
|
78
|
+
const result = await consumer.call('payments', '/charge', { amount: 100 });
|
|
79
|
+
console.log(result); // { charged: true, amount: 100 }
|
|
90
80
|
```
|
|
91
81
|
|
|
92
|
-
### 关闭
|
|
82
|
+
### 4. 关闭
|
|
93
83
|
|
|
94
|
-
`listen` 返回的 teardown
|
|
84
|
+
`listen()` 返回的 teardown 函数关闭 WebSocketServer 并断开所有连接:
|
|
95
85
|
|
|
96
86
|
```typescript
|
|
97
87
|
const stop = await provider.listen(9100);
|
|
98
|
-
// ...
|
|
99
88
|
await stop();
|
|
100
89
|
```
|
|
101
90
|
|
|
102
91
|
---
|
|
103
92
|
|
|
104
|
-
##
|
|
93
|
+
## 核心功能
|
|
105
94
|
|
|
106
|
-
|
|
95
|
+
### 服务发现 (`get`)
|
|
107
96
|
|
|
108
|
-
|
|
97
|
+
按 namespace 从 Registry 获取一个远端 Client 并缓存:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
const client = await consumer.get('payments');
|
|
101
|
+
const { response } = client.request('/charge', { amount: 100 });
|
|
102
|
+
const result = await response();
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- 首次查询通过 Registry `/-/find` 获取地址
|
|
106
|
+
- 结果**缓存**在内存中(namespace → Client)
|
|
107
|
+
- 当 Client 断连时自动清理缓存,下次 `get` 重新发现
|
|
108
|
+
|
|
109
|
+
### 熔断器 (Circuit Breaker)
|
|
110
|
+
|
|
111
|
+
`call()` 自动跟踪每个 namespace 下各 peer 的调用失败。失败的 peer 被临时排除,**30 秒冷卻期**后自动恢复。
|
|
112
|
+
|
|
113
|
+
| 场景 | 行为 |
|
|
114
|
+
|------|------|
|
|
115
|
+
| 调用 peer A 失败 | A 加入排除列表(30s cooldown) |
|
|
116
|
+
| 再次调用该 namespace | Registry `/‑/find` 带上 `exclude`,返回其他 peer |
|
|
117
|
+
| 所有 peer 都被排除 | 重置排除列表,重新从所有 peer 中选择 |
|
|
118
|
+
| 调用成功 | 该 peer 从排除列表中移除 |
|
|
119
|
+
|
|
120
|
+
排除列表:
|
|
121
|
+
- 键:`${host}:${port}`
|
|
122
|
+
- 存储:`Map<namespace, Map<peerKey, openedAt>>`
|
|
123
|
+
- 冷卻期:`CB_COOLDOWN_MS = 30_000`(30 秒)
|
|
124
|
+
- 过期检查:`getActiveExcludes()` 在每次 `call()` 调用时执行
|
|
125
|
+
|
|
126
|
+
### 请求超时
|
|
127
|
+
|
|
128
|
+
每个请求都有超时控制:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// 构造时设置全局默认超时
|
|
132
|
+
const app = new Application({
|
|
133
|
+
namespace: 'svc',
|
|
134
|
+
registry: { host, port },
|
|
135
|
+
requestTimeoutMs: 10_000, // 默认 30000ms
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// 单次调用覆盖
|
|
139
|
+
await app.call('svc', '/api', data, 5_000); // 5s 超时
|
|
140
|
+
await app.call('svc', '/api', data, 1_000, 0); // 1s 超时, 不重试
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
超时触发时,底层 MessageModem 会向对端发送 **ABORT** 消息取消远程执行。
|
|
144
|
+
|
|
145
|
+
### 手动取消请求
|
|
146
|
+
|
|
147
|
+
使用 `get()` 拿到 Client 后,`request()` 返回的 `abort()` 可主动取消正在等待响应的请求:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
const client = await consumer.get('payments');
|
|
151
|
+
const { response, abort } = client.request('/charge', { amount: 100 });
|
|
152
|
+
|
|
153
|
+
// 例如 5 秒后主动取消
|
|
154
|
+
setTimeout(() => abort(), 5000);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const result = await response();
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// 超时或手动 abort 都会 reject
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- `abort()` 向对端发送 **ABORT 消息**,让远程 handler 提前终止
|
|
164
|
+
- 超时到期内部也会调用 `controller.abort()`,机制相同
|
|
165
|
+
- 适用场景:用户取消、页面卸载、竞态淘汰
|
|
166
|
+
|
|
167
|
+
### 自动重试
|
|
168
|
+
|
|
169
|
+
`call()` 默认 retries=1,失败后自动换 peer 重试:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
await app.call('svc', '/api', data); // 默认重试 1 次
|
|
173
|
+
await app.call('svc', '/api', data, 5000, 3); // 超时 5s, 重试 3 次
|
|
174
|
+
await app.call('svc', '/api', data, 5000, 0); // 超时 5s, 不重试
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
重试策略:失败 → `recordFailure`(peer 被排除)→ 递归 `call(retries-1)` → `getActiveExcludes` 排除已失败的 peer → Registry `/‑/find` 返回其他 peer。
|
|
178
|
+
|
|
179
|
+
### Correlation ID 链路追踪
|
|
180
|
+
|
|
181
|
+
`call()` 自动为每次调用注入唯一 `_correlationId`:
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
provider.register('/api', async ({ data }) => {
|
|
185
|
+
console.log(data._correlationId); // 自动注入的 UUID
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
行为规则:
|
|
190
|
+
|
|
191
|
+
| 入参 data | 结果 |
|
|
192
|
+
|-----------|------|
|
|
193
|
+
| `null / undefined` | 包装为 `{ _correlationId, data: null }` |
|
|
194
|
+
| 字符串 / 数字 | 包装为 `{ _correlationId, data: '原始值' }` |
|
|
195
|
+
| 数组 | 包装为 `{ _correlationId, data: [原始数组] }` |
|
|
196
|
+
| `{ value: 1 }` (无 `_correlationId`) | 扩展为 `{ value: 1, _correlationId: 'uuid' }` |
|
|
197
|
+
| `{ _correlationId: 'trace-1' }` | 保留已有 ID,**不覆盖** |
|
|
109
198
|
|
|
110
|
-
|
|
199
|
+
> **注意:** 原 data 对象不会被修改(使用浅拷贝 `{ ...data, _correlationId }`)。
|
|
111
200
|
|
|
112
|
-
|
|
201
|
+
### 健康检查
|
|
202
|
+
|
|
203
|
+
每个 `Application` 自动注册 `/-/health` 端点:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
// 通过 dispatch 调用(同进程内)
|
|
207
|
+
const health = await app.dispatch('/-/health', {});
|
|
208
|
+
// { status: 'ok', registry: true, uptime: 123.45, namespaces: ['payments'] }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
返回字段:
|
|
212
|
+
|
|
213
|
+
| 字段 | 类型 | 说明 |
|
|
113
214
|
|------|------|------|
|
|
114
|
-
|
|
|
115
|
-
| `
|
|
215
|
+
| `status` | `'ok'` | 固定值 |
|
|
216
|
+
| `registry` | `boolean` | 是否已连接 Registry |
|
|
217
|
+
| `uptime` | `number` | 进程启动时长(秒) |
|
|
218
|
+
| `namespaces` | `string[]` | 本地已缓存的 namespace 列表 |
|
|
219
|
+
|
|
220
|
+
### 缓存降级
|
|
221
|
+
|
|
222
|
+
当 Registry 不可用但本地仍有已缓存的 Client 连接时,`get()` 自动降级使用缓存:
|
|
223
|
+
|
|
224
|
+
| 场景 | 行为 |
|
|
225
|
+
|------|------|
|
|
226
|
+
| Registry 宕机 + 缓存有效 | 返回缓存 Client,继续服务 |
|
|
227
|
+
| Registry 宕机 + 缓存已过期 | 报错 |
|
|
228
|
+
| 全新 namespace + Registry 宕机 | 报错 |
|
|
229
|
+
| Registry 恢复 | 恢复正常查询 |
|
|
230
|
+
|
|
231
|
+
### Registry 心跳保活
|
|
232
|
+
|
|
233
|
+
- **Application** 每 10 秒向 Registry 推送 `/-/heartbeat`
|
|
234
|
+
- **Registry** 每 1 秒检查所有实例的心跳时间戳
|
|
235
|
+
- 超过 20 秒未收到心跳的实例被自动剔除并断开连接
|
|
236
|
+
|
|
237
|
+
### Registry 重连
|
|
238
|
+
|
|
239
|
+
当 Application 与 Registry 的连接断开时:
|
|
240
|
+
1. 立即尝试重连(`reconnectToRegistry`)
|
|
241
|
+
2. 若失败,3 秒后重试(`scheduleRegistryRetry`)
|
|
242
|
+
3. 若 `listen()` 返回的 teardown 已触发(`stopped = true`),停止重连
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## 连接协议
|
|
247
|
+
|
|
248
|
+
### 连接 URL 格式
|
|
249
|
+
|
|
250
|
+
出站 WebSocket URL:
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
ws://{targetHost}:{targetPort}/{announceHost}/{listenPort}/{namespace}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
- `announceHost`:构造时传入 `advertiseHost` 或自动获取的 IPv4
|
|
257
|
+
- `listenPort`:`listen(port)` 设置的端口
|
|
258
|
+
- `namespace`:构造时传入的 namespace 字符串
|
|
259
|
+
|
|
260
|
+
### 入站路径解析
|
|
261
|
+
|
|
262
|
+
`Server.onConnected` 将 URL 路径按 `/` 分割为:
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
[callerHost, callerPort, ...extras]
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
- `extras` 以 `/` 分段,Registry 用 `extras.join('/')` 作为 namespace
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## API 参考
|
|
273
|
+
|
|
274
|
+
### ApplicationProps
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
type ApplicationProps = {
|
|
278
|
+
namespace: string; // 本服务 namespace
|
|
279
|
+
registry: { host: string; port: number }; // Registry 地址
|
|
280
|
+
registryLookupTimeoutMs?: number; // /-/find 超时,默认 10000
|
|
281
|
+
requestTimeoutMs?: number; // 单次请求超时,默认 30000
|
|
282
|
+
} & { advertiseHost?: string }; // 出站宣告地址
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Application
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
class Application extends Server {
|
|
289
|
+
constructor(props: ApplicationProps);
|
|
290
|
+
|
|
291
|
+
// 启动监听,自动连接 Registry,启动心跳
|
|
292
|
+
listen(port: number): Promise<() => Promise<void>>;
|
|
293
|
+
|
|
294
|
+
// 获取 namespace 对应的远端 Client(缓存 + 自动发现)
|
|
295
|
+
get(namespace: string, exclude?: string[]): Promise<Client>;
|
|
296
|
+
|
|
297
|
+
// 一站式调用:get + request + response + 熔断 + 重试 + 追踪
|
|
298
|
+
call<T = any>(
|
|
299
|
+
namespace: string,
|
|
300
|
+
url: string,
|
|
301
|
+
data: any,
|
|
302
|
+
timeout?: number, // 请求超时(ms),默认 requestTimeoutMs
|
|
303
|
+
retries?: number, // 失败重试次数,默认 1
|
|
304
|
+
): Promise<T>;
|
|
305
|
+
|
|
306
|
+
// 注册路由(provider 侧)
|
|
307
|
+
register<T = any>(url: string, handler: (ctx) => Promise<T>): () => void;
|
|
308
|
+
|
|
309
|
+
// 同进程调用路由
|
|
310
|
+
dispatch(url: string, data: any): Promise<any>;
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Registry
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
class Registry extends Server {
|
|
318
|
+
constructor(props?: MicroServerProps);
|
|
319
|
+
listen(port: number): Promise<() => Promise<void>>;
|
|
320
|
+
onFind(): void; // 幂等地挂载 /-/find 路由
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Server
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
class Server extends MessageLoader {
|
|
328
|
+
constructor(namespace: string, props?: MicroServerProps);
|
|
329
|
+
listen(port: number): Promise<() => Promise<void>>;
|
|
330
|
+
setPort(port: number): this;
|
|
331
|
+
// 以下方法受保护:
|
|
332
|
+
protected connect(host: string, port: number, timeout?: number): Promise<Client>;
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Client
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
class Client extends MessageWs {
|
|
340
|
+
request(url: string, data: any, timeout?: number): { abort(): void; response<T>(): Promise<T> };
|
|
341
|
+
push(url: string, data: any, timeout?: number): void;
|
|
342
|
+
dispose(): void;
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## 与 Hile core 的关系
|
|
116
349
|
|
|
117
|
-
|
|
350
|
+
`@hile/micro` 不依赖 `@hile/core`,可与任意 Node.js 进程或在未来由 `defineService` 包装后接入 Hile 容器。
|
|
118
351
|
|
|
119
352
|
---
|
|
120
353
|
|
package/SKILL.md
CHANGED
|
@@ -1,163 +1,520 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: micro
|
|
3
|
-
description: Code generation and contribution rules for @hile/micro. Use when editing this package or when the user asks about @hile/micro patterns or
|
|
3
|
+
description: Code generation and contribution rules for @hile/micro. Use when editing this package or when the user asks about @hile/micro API, types, patterns, or features.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# @hile/micro
|
|
6
|
+
# @hile/micro — AI Skill Reference
|
|
7
7
|
|
|
8
|
-
本文档面向 **AI
|
|
8
|
+
本文档面向 **AI 编码模型**。在生成或修改 `@hile/micro` 代码前必读,保证与现有架构、API 约定、状态机、测试模式一致。
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
## 1. 架构总览
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- **`Server`**:实现服务的 **底层协议与运行时**——`WebSocketServer` + 路径约定;对内 `MessageLoader.register` / `dispatch` 完成 `{ url, data }` 路由。不包含注册中心逻辑。
|
|
17
|
-
- **`Registry`**:**注册中心**,固定 `namespace` 为 `'registry'`,维护「逻辑 namespace → 一组 `host:port`」;`/-/find` 在集合中 **随机** 返回一条地址(见 `selectRandomRegistryAddress`)。
|
|
18
|
-
- **`Application`**:**基于 `Server` 的应用侧实现**(`extends Server`);`listen(port)` 后自动 `connect` 到注册中心;`get(targetNamespace)` 向注册中心查询并 **缓存** 到目标服务的 `Client`,目标断连后删除缓存以便下次重新发现。
|
|
19
|
-
- **`Client`**:连接到远端 `Server`,`request(url, data)` / `push(url, data)` 将负载交给本端 `Server.dispatch`;`dispose()` 会移除监听并 **关闭底层 WebSocket**,避免 `listen` 关闭阶段挂住。
|
|
20
|
-
|
|
21
|
-
**应用模型**:`Application = provider + consumer`——同一实例既可 `register(...)`(对外提供能力),也可 `get(ns)` 再 `request`/`push`(消费其它 namespace)。文档示例常拆成两个进程分别演示 provider / consumer,API 层面仍是同一个类。
|
|
22
|
-
|
|
23
|
-
依赖链:
|
|
14
|
+
### 依赖链
|
|
24
15
|
|
|
25
16
|
```
|
|
26
|
-
|
|
27
|
-
└──
|
|
28
|
-
|
|
29
|
-
|
|
17
|
+
@hile/message-loader (路由: register/dispatch)
|
|
18
|
+
└── @hile/message-ws (WebSocket 请求/响应, MessageModem timeout/abort)
|
|
19
|
+
└── packages/micro
|
|
20
|
+
├── Server — WebSocket 监听 + 连接管理 + Client 生命周期
|
|
21
|
+
├── Client — 远端代理, request/push/dispose
|
|
22
|
+
├── Registry — 注册中心 (extends Server)
|
|
23
|
+
└── Application — 应用服务 (extends Server)
|
|
30
24
|
```
|
|
31
25
|
|
|
32
|
-
|
|
26
|
+
### 分层职责
|
|
33
27
|
|
|
34
|
-
|
|
28
|
+
| 类 | 文件 | 职责 | 关键约束 |
|
|
29
|
+
|----|------|------|---------|
|
|
30
|
+
| `Server` | `server.ts` | WebSocketServer 生命周期, 出入站连接, Client Map | 不感知 Registry |
|
|
31
|
+
| `Client` | `client.ts` | 远端 Server 的 WebSocket 会话代理 | `dispose()` 必须关闭底层 socket |
|
|
32
|
+
| `Registry` | `registry.ts` | namespace → Set\<host:port\>, 心跳检测, /-/find 随机返回 | `namespace` 固定 `'registry'` |
|
|
33
|
+
| `Application` | `application.ts` | 注册发现 + 熔断 + 重试 + 追踪 + 心跳 | `listen()` 后自动连 Registry |
|
|
35
34
|
|
|
36
|
-
###
|
|
35
|
+
### 应用模型
|
|
37
36
|
|
|
38
|
-
|
|
37
|
+
一个 `Application` 实例同时是 provider 和 consumer。不要创建两个类来区分角色。
|
|
39
38
|
|
|
40
|
-
```
|
|
41
|
-
|
|
39
|
+
```
|
|
40
|
+
Application:
|
|
41
|
+
└─ register(url, handler) → provider 侧
|
|
42
|
+
└─ get(namespace) → consumer 侧, 返回 Client
|
|
43
|
+
└─ call(namespace, url, data) → consumer 侧, get+request+熔断+重试 一站式
|
|
42
44
|
```
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
- 至少 **三段** 路径:`callerHost`、`callerPort` 以及后续 `extras`(可为空)。
|
|
47
|
-
- `extras` 以 `/` 分段,在 `events.emit('connect', client, extras)` 中交给业务;**Registry** 用 `extras.join('/')` 作为 **服务逻辑 namespace**。
|
|
46
|
+
---
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
## 2. 类型定义(代码生成时必须一致)
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
ws://{host}:{port}/${this.ipv4}/${this.port}/${this.namespace}
|
|
53
|
-
```
|
|
50
|
+
### Server (`server.ts`)
|
|
54
51
|
|
|
55
|
-
|
|
52
|
+
```typescript
|
|
53
|
+
export type MicroServerProps = MessageLoaderProps & {
|
|
54
|
+
advertiseHost?: string; // 缺省 getLocalIPv4(), 皆无则构造抛错
|
|
55
|
+
};
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
export class Server extends MessageLoader {
|
|
58
|
+
protected readonly clients = new Map<string, Client>(); // key: "host:port"
|
|
59
|
+
public readonly events = new EventEmitter(); // connect/disconnect
|
|
60
|
+
|
|
61
|
+
constructor(namespace: string, props?: MicroServerProps);
|
|
62
|
+
public listen(port: number): Promise<() => Promise<void>>; // 返回 teardown
|
|
63
|
+
public setPort(port: number): this;
|
|
64
|
+
protected connect(host: string, port: number, timeout?: number): Promise<Client>;
|
|
65
|
+
// 继承自 MessageLoader:
|
|
66
|
+
// register<T, E>(url, handler): () => void;
|
|
67
|
+
// dispatch(url, data, extras): Promise<any>;
|
|
68
|
+
}
|
|
69
|
+
```
|
|
58
70
|
|
|
59
|
-
|
|
60
|
-
- `Registry` 注册:`register<RegistryFindData>('/-/find', handler)`,请求体 `{ namespace: string }`。
|
|
71
|
+
### Client (`client.ts`)
|
|
61
72
|
|
|
62
|
-
|
|
73
|
+
```typescript
|
|
74
|
+
export class Client extends MessageWs {
|
|
75
|
+
public readonly host: string;
|
|
76
|
+
public readonly port: number;
|
|
77
|
+
public readonly events = new EventEmitter(); // connect/disconnect
|
|
78
|
+
|
|
79
|
+
request(url: string, data: any, timeout?: number): {
|
|
80
|
+
abort(): void;
|
|
81
|
+
response<T = any>(): Promise<T>; // 超时或 abort 时 reject
|
|
82
|
+
};
|
|
83
|
+
push(url: string, data: any, timeout?: number): void;
|
|
84
|
+
dispose(): void; // 关闭底层 WebSocket
|
|
85
|
+
}
|
|
86
|
+
```
|
|
63
87
|
|
|
64
|
-
|
|
88
|
+
### Registry (`registry.ts`)
|
|
65
89
|
|
|
66
90
|
```typescript
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
};
|
|
91
|
+
export interface RegistryFindData {
|
|
92
|
+
namespace: string;
|
|
93
|
+
exclude?: string[];
|
|
94
|
+
}
|
|
72
95
|
|
|
73
|
-
// registry.ts
|
|
74
96
|
export function parseAddressKey(key: string): RegistryAddress | undefined;
|
|
97
|
+
export function selectRandomRegistryAddress(keys: Iterable<string>): RegistryAddress | undefined;
|
|
75
98
|
|
|
76
|
-
export
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
export class Registry extends Server {
|
|
100
|
+
// heartbeat 常量:
|
|
101
|
+
// HEARTBEAT_INTERVAL = 1000 (1s 轮询)
|
|
102
|
+
// HEARTBEAT_TIMEOUT = 20000 (20s 未收到心跳则剔除)
|
|
103
|
+
constructor(props?: MicroServerProps);
|
|
104
|
+
listen(port: number): Promise<() => Promise<void>>;
|
|
105
|
+
onFind(): void; // 幂等,可重复调用
|
|
106
|
+
// 内部路由:
|
|
107
|
+
// /-/find — 按 namespace 随机返回地址 (支持 exclude)
|
|
108
|
+
// /-/heartbeat — 更新实例心跳时间戳
|
|
109
|
+
}
|
|
110
|
+
```
|
|
79
111
|
|
|
80
|
-
|
|
112
|
+
### Application (`application.ts`)
|
|
81
113
|
|
|
82
|
-
|
|
114
|
+
```typescript
|
|
83
115
|
export type ApplicationProps = {
|
|
84
116
|
namespace: string;
|
|
85
117
|
registry: RegistryAddress;
|
|
86
|
-
|
|
87
|
-
|
|
118
|
+
registryLookupTimeoutMs?: number; // /-/find 超时, 默认 10_000
|
|
119
|
+
requestTimeoutMs?: number; // 单次请求超时, 默认 30_000
|
|
88
120
|
} & MicroServerProps;
|
|
89
121
|
|
|
90
122
|
export class Application extends Server {
|
|
91
|
-
|
|
92
|
-
|
|
123
|
+
// 内部常量:
|
|
124
|
+
// HEARTBEAT_INTERVAL = 10000 (10s 向 Registry 推送心跳)
|
|
125
|
+
// CB_COOLDOWN_MS = 30000 (熔断冷卻期 30s)
|
|
126
|
+
// 内部状态:
|
|
127
|
+
// namespaces: Map<ns, { host, port, status: IDLE|PENDING|READY, handlers }>
|
|
128
|
+
// circuitBreakers: Map<ns, Map<peerKey, openedAt>>
|
|
129
|
+
|
|
130
|
+
constructor(props: ApplicationProps);
|
|
131
|
+
listen(port: number): Promise<() => Promise<void>>; // 自动连 Registry + 启心跳
|
|
132
|
+
|
|
133
|
+
get(namespace: string, exclude?: string[]): Promise<Client>;
|
|
134
|
+
// call() = get + request + response + correlationId + 熔断 + 重试 + 超时
|
|
135
|
+
call<T = any>(
|
|
136
|
+
namespace: string,
|
|
137
|
+
url: string,
|
|
138
|
+
data: any,
|
|
139
|
+
timeout?: number, // 单次超时, 默认 requestTimeoutMs
|
|
140
|
+
retries?: number, // 重试次数, 默认 1
|
|
141
|
+
): Promise<T>;
|
|
142
|
+
|
|
143
|
+
// 继承自 Server/MessageLoader:
|
|
144
|
+
// register<T, E>(url, handler): () => void;
|
|
145
|
+
// dispatch(url, data, extras): Promise<any>;
|
|
93
146
|
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
94
150
|
|
|
95
|
-
|
|
96
|
-
protected async connect(host: string, port: number, timeout?: number): Promise<Client>;
|
|
151
|
+
## 3. 功能详解(含状态机与逻辑流)
|
|
97
152
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
153
|
+
### 3.1 服务发现 `get(namespace, exclude?)`
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
get(ns, exclude?)
|
|
157
|
+
├─ namespaces 无此 ns → 创建 IDLE 条目
|
|
158
|
+
├─ status=READY + (client 已断连 or peer 被 exclude)
|
|
159
|
+
│ └─ 重置为 IDLE, 清空 host/port
|
|
160
|
+
├─ status=READY + client 有效 + 未被 exclude
|
|
161
|
+
│ └─ 直接返回缓存 Client (快路径)
|
|
162
|
+
└─ 否则:
|
|
163
|
+
└─ 新建 Promise, handler 入队
|
|
164
|
+
└─ status=IDLE → PENDING → findFromRegistry(ns, exclude)
|
|
165
|
+
├─ Registry 返回地址 → connect → status=READY
|
|
166
|
+
│ └─ 注册 disconnect → 清理 namespace 缓存
|
|
167
|
+
│ └─ resolve 所有 handler
|
|
168
|
+
└─ Registry 失败 or 无数据 →
|
|
169
|
+
└─ catch: 检查旧缓存 (cachedHost/cachedPort)
|
|
170
|
+
├─ 缓存有效 → 降级: restore status=READY, resolve
|
|
171
|
+
└─ 无缓存 → delete namespace, reject 所有 handler
|
|
172
|
+
└─ finally: handlers.clear()
|
|
104
173
|
```
|
|
105
174
|
|
|
106
|
-
|
|
175
|
+
**关键点:**
|
|
176
|
+
- `cachedHost` / `cachedPort` 在缓存失效前保存,用于降级路径
|
|
177
|
+
- 降级时恢复 `stack.host/port/status=READY`,使后续调用走快路径
|
|
178
|
+
- 多并发 `get()` 共享同一个 Promise,handler 入队后统一 resolve/reject
|
|
107
179
|
|
|
108
|
-
|
|
180
|
+
### 3.2 熔断器 (Circuit Breaker)
|
|
181
|
+
|
|
182
|
+
**数据结构:** `Map<namespace, Map<peerKey, openedAt>>`
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
call() 失败:
|
|
186
|
+
recordFailure(ns, host, port)
|
|
187
|
+
→ excludes.set("host:port", Date.now())
|
|
188
|
+
|
|
189
|
+
call() 成功:
|
|
190
|
+
recordSuccess(ns, host, port)
|
|
191
|
+
→ excludes.delete("host:port")
|
|
192
|
+
|
|
193
|
+
getActiveExcludes(ns):
|
|
194
|
+
→ 遍历 excludes, 移除 Date.now() - openedAt >= 30000 的过期条目
|
|
195
|
+
→ 返回活跃的 exclude key 数组
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**生命周期:**
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
peer 首次失败
|
|
202
|
+
→ circuitBreakers: { svc: { "127.0.0.1:8080": now } }
|
|
203
|
+
→ getActiveExcludes("svc") → ["127.0.0.1:8080"]
|
|
204
|
+
→ next call() 的 get() 带上 exclude, 排除该 peer
|
|
205
|
+
→ Registry 返回其他 peer (有则) 或 undefined (无则)
|
|
206
|
+
→ 如果所有 peer 都被排除, catch 块 delete circuitBreakers, 无 exclude 重试
|
|
207
|
+
=> "全排除 → 重置" 策略: 当 Registry 找不到未被排除的 peer, 熔断器清空, 从不安全全量重试
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**冷卻期:** `CB_COOLDOWN_MS = 30000` (30 秒)。到期后 `getActiveExcludes` 自动清除旧条目。
|
|
211
|
+
|
|
212
|
+
### 3.3 Correlation ID
|
|
213
|
+
|
|
214
|
+
`call()` 自动处理 `_correlationId`:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
// 非对象/假值/数组 → 包装
|
|
218
|
+
!data || typeof data !== 'object' || Array.isArray(data)
|
|
219
|
+
→ data = { _correlationId: randomUUID(), data }
|
|
220
|
+
|
|
221
|
+
// 对象无 _correlationId → 浅拷贝注入
|
|
222
|
+
else if (!data._correlationId)
|
|
223
|
+
→ data = { ...data, _correlationId: randomUUID() }
|
|
224
|
+
|
|
225
|
+
// 对象已有 _correlationId → 保留(透传)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- 使用 `import { randomUUID } from 'node:crypto'`(Node >= 14.17)
|
|
229
|
+
- 永远不修改原始 data 对象(浅拷贝 `{ ...data }`)
|
|
230
|
+
- retry 递归时 data 已包含 `_correlationId`,自动透传
|
|
231
|
+
|
|
232
|
+
### 3.4 请求超时
|
|
233
|
+
|
|
234
|
+
**配置链:**
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
ApplicationProps.requestTimeoutMs (default 30_000)
|
|
238
|
+
→ call(timeout?) // 可选 override
|
|
239
|
+
→ client.request(url, data, timeout ?? this._requestTimeoutMs)
|
|
240
|
+
→ MessageModem._send({ url, data }, timeout)
|
|
241
|
+
→ MessageModem._write(data, timeout, twoway=true)
|
|
242
|
+
→ setTimeout(reject, timeout)
|
|
243
|
+
→ 超时: 发送 ABORT 消息 → 对端取消执行
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- 超时默认 30 秒(MessageModem 默认值)
|
|
247
|
+
- 超时触发时向对端发送 **ABORT** 消息(不是仅仅本地 reject)
|
|
248
|
+
- 支持每个调用单独覆盖
|
|
249
|
+
|
|
250
|
+
### 3.5 手动取消(abort)
|
|
251
|
+
|
|
252
|
+
`client.request()` 返回的 `abort()` 函数可主动取消请求,**与超时共享同一套 ABORT 机制**:
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
const client = await app.get('svc');
|
|
256
|
+
const { response, abort } = client.request('/api', data);
|
|
257
|
+
|
|
258
|
+
// 主动取消
|
|
259
|
+
abort();
|
|
260
|
+
await response(); // → reject (AbortException)
|
|
261
|
+
|
|
262
|
+
// 典型场景:竞态淘汰
|
|
263
|
+
const req = client.request('/slow', data);
|
|
264
|
+
// 如果其他条件满足,提前取消
|
|
265
|
+
if (cached) abort();
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**实现原理:**
|
|
269
|
+
- `request()` 内部通过 `MessageModem._write()` 创建 `AbortController`
|
|
270
|
+
- `abort()` 调用 `controller.abort()` 触发 ABORT 消息发送
|
|
271
|
+
- 超时到期也调用同一个 `controller.abort()`,机制完全相同
|
|
272
|
+
- 区别仅在于触发源头:手动调用 vs 定时器到期
|
|
273
|
+
|
|
274
|
+
**适用场景:** 用户取消操作、页面/组件卸载、竞态条件(先发请求后发先至时取消旧请求)
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
call(ns, url, data, timeout, retries = 1):
|
|
278
|
+
get(ns, exclude) → client
|
|
279
|
+
try:
|
|
280
|
+
client.request(url, data, timeout) → response() → result
|
|
281
|
+
recordSuccess(ns, host, port)
|
|
282
|
+
return result
|
|
283
|
+
catch err:
|
|
284
|
+
recordFailure(ns, host, port)
|
|
285
|
+
if retries > 0:
|
|
286
|
+
return call(ns, url, data, timeout, retries - 1) // 递归
|
|
287
|
+
throw err
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**重试语义:**
|
|
291
|
+
- 首次失败 → peer 被排除
|
|
292
|
+
- 递归 `call(retries-1)` → `getActiveExcludes` 包含已失败的 peer
|
|
293
|
+
- `get()` 带上 exclude → Registry 返回其他 peer
|
|
294
|
+
- 所有 peer 都失败 → 熔断全排除 → catch 块重置 → 无 exclude 重试
|
|
295
|
+
- 每个 retry 共享同一个 timeout 值(不会延长总时间)
|
|
109
296
|
|
|
110
|
-
|
|
297
|
+
### 3.6 健康检查
|
|
111
298
|
|
|
112
|
-
|
|
299
|
+
在 `Application` 构造函数中注册:
|
|
113
300
|
|
|
114
301
|
```typescript
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
302
|
+
this.register('/-/health', async () => ({
|
|
303
|
+
status: 'ok' as const,
|
|
304
|
+
registry: !!this.registry, // 是否连上 Registry
|
|
305
|
+
uptime: process.uptime(), // 进程运行秒数
|
|
306
|
+
namespaces: [...this.namespaces.keys()], // 已缓存的 namespace
|
|
307
|
+
}));
|
|
118
308
|
```
|
|
119
309
|
|
|
120
|
-
|
|
310
|
+
只在 Application 上注册,Server 和 Registry 没有。
|
|
311
|
+
|
|
312
|
+
### 3.7 Registry 心跳
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
Application (10s 间隔):
|
|
316
|
+
startHeartbeat():
|
|
317
|
+
setInterval(10000):
|
|
318
|
+
registry.push('/-/heartbeat', {})
|
|
319
|
+
|
|
320
|
+
Registry (1s 间隔检查, 20s 超时):
|
|
321
|
+
构造函数:
|
|
322
|
+
events.on('connect', ...) → heartbeats.set(key, Date.now())
|
|
323
|
+
register('/-/heartbeat', ...) → heartbeats.set(key, Date.now())
|
|
324
|
+
listen():
|
|
325
|
+
setInterval(1000):
|
|
326
|
+
for each heartbeat entry:
|
|
327
|
+
if now - lastTime >= 20000:
|
|
328
|
+
clients.get(key).dispose() // 剔除死实例
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### 3.8 Registry 重连
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
listen():
|
|
335
|
+
reconnectToRegistry() → connect + events.on('disconnect')
|
|
336
|
+
|
|
337
|
+
disconnect 触发:
|
|
338
|
+
registry = undefined
|
|
339
|
+
reconnectToRegistry():
|
|
340
|
+
├─ 成功 → 恢复正常
|
|
341
|
+
└─ 失败 → scheduleRegistryRetry():
|
|
342
|
+
└─ setTimeout(3000) → reconnectToRegistry()
|
|
343
|
+
└─ 失败 → scheduleRegistryRetry() (循环)
|
|
344
|
+
|
|
345
|
+
listen() teardown 触发:
|
|
346
|
+
stopped = true → 停止所有重连尝试
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### 3.9 缓存降级
|
|
350
|
+
|
|
351
|
+
**触发条件:** `get()` 的 registry lookup 失败,但 `this.clients` 中仍然有之前缓存的 Client(WebSocket 未断开)。
|
|
352
|
+
|
|
353
|
+
**处理流程:**
|
|
354
|
+
1. `cachedHost` / `cachedPort` 在缓存失效前保存
|
|
355
|
+
2. Registry lookup 失败 → catch 块
|
|
356
|
+
3. `cachedHost && this.clients.has(cachedKey)` → 有效缓存
|
|
357
|
+
4. 恢复 `stack.host/port/status=READY`,resolve 所有 handler
|
|
358
|
+
5. 后续 `get()` 命中快路径,直接返回该 Client
|
|
359
|
+
|
|
360
|
+
**不处理的情况:** 全新 namespace(无缓存)、缓存 Client 已断连。
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## 4. 代码生成模板
|
|
365
|
+
|
|
366
|
+
### 4.1 三节点测试拓扑(所有 test 必须使用)
|
|
121
367
|
|
|
122
368
|
```typescript
|
|
123
|
-
const
|
|
124
|
-
|
|
369
|
+
const registryPort = await getAvailablePort();
|
|
370
|
+
const providerPort = await getAvailablePort();
|
|
371
|
+
const consumerPort = await getAvailablePort();
|
|
372
|
+
|
|
373
|
+
const registry = new Registry(testAdvertise);
|
|
374
|
+
const provider = new Application({
|
|
375
|
+
namespace: 'svc',
|
|
376
|
+
registry: { host: '127.0.0.1', port: registryPort },
|
|
377
|
+
...testAdvertise,
|
|
378
|
+
});
|
|
379
|
+
const consumer = new Application({
|
|
380
|
+
namespace: 'consumer',
|
|
125
381
|
registry: { host: '127.0.0.1', port: registryPort },
|
|
126
|
-
|
|
382
|
+
...testAdvertise,
|
|
127
383
|
});
|
|
128
|
-
const dispose = await app.listen(appPort);
|
|
129
384
|
|
|
130
|
-
|
|
131
|
-
|
|
385
|
+
const disposeRegistry = await registry.listen(registryPort);
|
|
386
|
+
const disposeProvider = await provider.listen(providerPort);
|
|
387
|
+
const disposeConsumer = await consumer.listen(consumerPort);
|
|
388
|
+
const unregister = provider.register('/echo', async ({ data }) => {
|
|
389
|
+
return { value: data.value };
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
// ... test logic ...
|
|
394
|
+
} finally {
|
|
395
|
+
unregister();
|
|
396
|
+
await disposeConsumer();
|
|
397
|
+
await disposeProvider();
|
|
398
|
+
await disposeRegistry();
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### 4.2 call() 的 timeout + retries 参数顺序
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
// signature: call(ns, url, data, timeout?, retries?)
|
|
406
|
+
await app.call('svc', '/api', data); // 默认超时 + 1 次重试
|
|
407
|
+
await app.call('svc', '/api', data, 5000); // 5s 超时 + 1 次重试
|
|
408
|
+
await app.call('svc', '/api', data, 5000, 0); // 5s 超时 + 不重试
|
|
409
|
+
await app.call('svc', '/api', data, undefined, 0); // 默认超时 + 不重试
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### 4.3 超时测试 Handler
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
// handler 延迟 500ms, call 超时 50ms → 应 reject
|
|
416
|
+
provider.register('/slow', async () => {
|
|
417
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
418
|
+
return { value: 'too-late' };
|
|
132
419
|
});
|
|
420
|
+
await expect(consumer.call('svc', '/slow', {}, 50, 0)).rejects.toThrow('Abort');
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### 4.4 熔断测试模板
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
// 两个 provider 同 namespace, 一个失败, 排除后应选另一个
|
|
427
|
+
// 见 circuit breaker test "excludes a failing peer and selects a different one"
|
|
133
428
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const result = await response();
|
|
429
|
+
// 单个 peer 全排除后应重置并重试该 peer
|
|
430
|
+
// 见 circuit breaker test "resets breaker when all peers are excluded"
|
|
137
431
|
```
|
|
138
432
|
|
|
139
|
-
### 4.
|
|
433
|
+
### 4.5 缓存降级测试模板
|
|
140
434
|
|
|
141
|
-
|
|
435
|
+
```typescript
|
|
436
|
+
// 1. 首次 call → 建立缓存
|
|
437
|
+
// 2. 切换为失败 handler → call 失败 → peer 被排除
|
|
438
|
+
// 3. 切回成功 handler → call 带 exclude → Registry find 返回 undefined
|
|
439
|
+
// 4. catch 块降级 → 返回缓存 Client → 请求成功
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## 5. 测试门禁(修改时必须遵守)
|
|
445
|
+
|
|
446
|
+
### 5.1 运行命令
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
pnpm --filter @hile/micro build # 必须通过
|
|
450
|
+
pnpm --filter @hile/micro test # 必须全部通过
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### 5.2 测试覆盖要求
|
|
454
|
+
|
|
455
|
+
修改行为时至少覆盖:
|
|
456
|
+
|
|
457
|
+
| 场景 | 测试位置 |
|
|
458
|
+
|------|----------|
|
|
459
|
+
| 服务发现端到端 | `application discovery > resolves a provider through the registry` |
|
|
460
|
+
| listen teardown 后可重新 listen | `application discovery > allows listen again after teardown` |
|
|
461
|
+
| Registry 不可达时 listen 回滚 | `application discovery > rolls back listen when registry is unreachable` |
|
|
462
|
+
| 心跳保活 | `heartbeat > keeps client alive when heartbeats arrive on time` |
|
|
463
|
+
| 心跳超时剔除 | `heartbeat > disconnects client that stops sending heartbeats` |
|
|
464
|
+
| call() 基本调用 | `circuit breaker > call() returns data on success` |
|
|
465
|
+
| 熔断排除 | `circuit breaker > excludes a failing peer and selects a different one` |
|
|
466
|
+
| 全排除重置 | `circuit breaker > resets breaker when all peers are excluded` |
|
|
467
|
+
| Correlation ID 注入 | `correlation ID > injects _correlationId into call() data` |
|
|
468
|
+
| Correlation ID 透传 | `correlation ID > preserves existing _correlationId` |
|
|
469
|
+
| 健康检查 | `health endpoint > /-/health returns status and registry state` |
|
|
470
|
+
| 超时 reject | `request timeout > rejects when request exceeds the timeout` |
|
|
471
|
+
| 超时充足则成功 | `request timeout > succeeds when timeout is long enough` |
|
|
472
|
+
| 缓存降级 | `cache degradation > uses cached client when registry lookup fails due to exclusion` |
|
|
473
|
+
|
|
474
|
+
### 5.3 测试规范(必须遵守)
|
|
475
|
+
|
|
476
|
+
- 端口必须使用 `getAvailablePort()` 获取
|
|
477
|
+
- 所有清理必须放在 `finally` 块中
|
|
478
|
+
- 清理顺序:`unregister()` → `disposeConsumer` → `disposeProvider` → `disposeRegistry`
|
|
479
|
+
- 禁止使用真实定时等待代替事件驱动(心跳测试是唯一例外,因其依赖实时时钟)
|
|
480
|
+
- 禁止 mock `Application`、`Registry`、`Server`、`Client` 的内部方法
|
|
481
|
+
- 禁止共享可变状态(每个测试独立端口)
|
|
482
|
+
|
|
483
|
+
---
|
|
142
484
|
|
|
143
|
-
|
|
485
|
+
## 6. 反模式(禁止)
|
|
144
486
|
|
|
145
|
-
|
|
487
|
+
1. **不要修改 WebSocket URL 三段式约定**不同时更新 `Server.onConnected` 和 `Server.connect` 的拼接格式
|
|
488
|
+
2. **不要在 Registry 中按 Set 迭代顺序固定返回第一个**(破坏负载分散)
|
|
489
|
+
3. **不要在 `Client.dispose()` 中删除 `socket.close()`**(会导致 WebSocketServer.close 长时间等待)
|
|
490
|
+
4. **不要假设 `host:port` 可无损表达 IPv6** — 使用 `[IPv6]:port` 格式,`parseAddressKey` 按最后一个 `:` 切分
|
|
491
|
+
5. **不要传错 Registry 端口** — 丢失 Registry 连接时依赖 `reconnectToRegistry`,不要在外部缓存 registry Client
|
|
492
|
+
6. **不要在 call() 中修改原始 data 对象** — 必须使用浅拷贝 `{ ...data, _correlationId }`
|
|
493
|
+
7. **不要在其他文件中重复 `selectRandomRegistryAddress` 或 `parseAddressKey`** — 这些是 Registry 的内部 helper
|
|
494
|
+
8. **不要给 call() 增加非可选参数** — `timeout` 和 `retries` 都在尾部且保持可选,不影响现有调用
|
|
146
495
|
|
|
147
496
|
---
|
|
148
497
|
|
|
149
|
-
##
|
|
498
|
+
## 7. 文件改动范围
|
|
150
499
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
500
|
+
| 文件 | 可修改 | 说明 |
|
|
501
|
+
|------|--------|------|
|
|
502
|
+
| `packages/micro/src/application.ts` | ✅ | 核心业务逻辑 |
|
|
503
|
+
| `packages/micro/src/index.test.ts` | ✅ | 测试 |
|
|
504
|
+
| `packages/micro/src/server.ts` | ❌ | 底层协议,不动 |
|
|
505
|
+
| `packages/micro/src/client.ts` | ❌ | 底层协议,不动 |
|
|
506
|
+
| `packages/micro/src/registry.ts` | ❌ | 注册中心,不动 |
|
|
507
|
+
| `packages/micro/src/utils.ts` | ❌ | 工具函数,不动 |
|
|
508
|
+
| `packages/micro/README.md` | ✅ | 用户文档 |
|
|
509
|
+
| `packages/micro/SKILL.md` | ✅ | AI 参考文档 |
|
|
156
510
|
|
|
157
511
|
---
|
|
158
512
|
|
|
159
|
-
##
|
|
513
|
+
## 8. 参考文件
|
|
160
514
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
515
|
+
| 文件 | 用途 |
|
|
516
|
+
|------|------|
|
|
517
|
+
| `packages/micro/src/application.ts` | 完整实现(共 ~310 行) |
|
|
518
|
+
| `packages/micro/src/index.test.ts` | 28 个测试用例 |
|
|
519
|
+
| `docs/superpowers/specs/2026-05-14-micro-improvements-design.md` | 四种改进的设计规约 |
|
|
520
|
+
| `docs/superpowers/plans/2026-05-14-micro-improvements.md` | 实施计划 |
|
package/dist/application.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type ApplicationProps = {
|
|
|
6
6
|
registry: RegistryAddress;
|
|
7
7
|
/** `/-/find` 等待响应的上限(毫秒),默认 `10000` */
|
|
8
8
|
registryLookupTimeoutMs?: number;
|
|
9
|
+
/** 单次 request() 等待响应的上限(毫秒),默认 `30000` */
|
|
10
|
+
requestTimeoutMs?: number;
|
|
9
11
|
} & MicroServerProps;
|
|
10
12
|
export declare class Application extends Server {
|
|
11
13
|
private registry?;
|
|
@@ -15,11 +17,22 @@ export declare class Application extends Server {
|
|
|
15
17
|
private stopped;
|
|
16
18
|
private readonly _registry_address;
|
|
17
19
|
private readonly _registryLookupTimeoutMs;
|
|
20
|
+
private readonly _requestTimeoutMs;
|
|
18
21
|
private readonly namespaces;
|
|
22
|
+
private static readonly HEARTBEAT_INTERVAL;
|
|
23
|
+
private heartbeatTimer?;
|
|
24
|
+
private static readonly CB_COOLDOWN_MS;
|
|
25
|
+
private readonly circuitBreakers;
|
|
19
26
|
constructor(props: ApplicationProps);
|
|
20
27
|
listen(port?: number): Promise<() => Promise<void>>;
|
|
21
28
|
private scheduleRegistryRetry;
|
|
22
29
|
private reconnectToRegistry;
|
|
30
|
+
private startHeartbeat;
|
|
31
|
+
private stopHeartbeat;
|
|
32
|
+
private recordSuccess;
|
|
33
|
+
private recordFailure;
|
|
34
|
+
private getActiveExcludes;
|
|
23
35
|
private findFromRegistry;
|
|
24
|
-
get(namespace: string): Promise<Client>;
|
|
36
|
+
get(namespace: string, exclude?: string[]): Promise<Client>;
|
|
37
|
+
call<T = any>(namespace: string, url: string, data: any, timeout?: number, retries?: number): Promise<T>;
|
|
25
38
|
}
|
package/dist/application.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { Server } from './server.js';
|
|
2
3
|
var RegistryLookupStatus;
|
|
3
4
|
(function (RegistryLookupStatus) {
|
|
@@ -43,19 +44,32 @@ export class Application extends Server {
|
|
|
43
44
|
stopped = false;
|
|
44
45
|
_registry_address;
|
|
45
46
|
_registryLookupTimeoutMs;
|
|
47
|
+
_requestTimeoutMs;
|
|
46
48
|
namespaces = new Map();
|
|
49
|
+
static HEARTBEAT_INTERVAL = 10000;
|
|
50
|
+
heartbeatTimer;
|
|
51
|
+
static CB_COOLDOWN_MS = 30_000;
|
|
52
|
+
circuitBreakers = new Map();
|
|
47
53
|
constructor(props) {
|
|
48
|
-
const { namespace, registry, registryLookupTimeoutMs = 10_000, ...microAndLoader } = props;
|
|
54
|
+
const { namespace, registry, registryLookupTimeoutMs = 10_000, requestTimeoutMs = 30_000, ...microAndLoader } = props;
|
|
49
55
|
super(namespace, microAndLoader);
|
|
50
56
|
assertValidRegistrySocket('registry address', registry.host, registry.port);
|
|
51
57
|
this._registry_address = registry;
|
|
52
58
|
this._registryLookupTimeoutMs = registryLookupTimeoutMs;
|
|
59
|
+
this._requestTimeoutMs = requestTimeoutMs;
|
|
60
|
+
this.register('/-/health', async () => ({
|
|
61
|
+
status: 'ok',
|
|
62
|
+
registry: !!this.registry,
|
|
63
|
+
uptime: process.uptime(),
|
|
64
|
+
namespaces: [...this.namespaces.keys()],
|
|
65
|
+
}));
|
|
53
66
|
}
|
|
54
67
|
async listen(port = 0) {
|
|
55
68
|
this.stopped = false;
|
|
56
69
|
const callback = await super.listen(port);
|
|
57
70
|
try {
|
|
58
71
|
await this.reconnectToRegistry();
|
|
72
|
+
this.startHeartbeat();
|
|
59
73
|
}
|
|
60
74
|
catch (err) {
|
|
61
75
|
try {
|
|
@@ -68,6 +82,7 @@ export class Application extends Server {
|
|
|
68
82
|
}
|
|
69
83
|
return async () => {
|
|
70
84
|
this.stopped = true;
|
|
85
|
+
this.stopHeartbeat();
|
|
71
86
|
if (this.reconnectTimeout) {
|
|
72
87
|
clearTimeout(this.reconnectTimeout);
|
|
73
88
|
this.reconnectTimeout = undefined;
|
|
@@ -119,14 +134,66 @@ export class Application extends Server {
|
|
|
119
134
|
});
|
|
120
135
|
return this.registryReconnectPromise;
|
|
121
136
|
}
|
|
122
|
-
|
|
137
|
+
startHeartbeat() {
|
|
138
|
+
this.stopHeartbeat();
|
|
139
|
+
this.heartbeatTimer = setInterval(() => {
|
|
140
|
+
if (!this.registry)
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
this.registry.push('/-/heartbeat', {});
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// registry connection may have dropped between null-check and push
|
|
147
|
+
}
|
|
148
|
+
}, Application.HEARTBEAT_INTERVAL);
|
|
149
|
+
}
|
|
150
|
+
stopHeartbeat() {
|
|
151
|
+
if (this.heartbeatTimer) {
|
|
152
|
+
clearInterval(this.heartbeatTimer);
|
|
153
|
+
this.heartbeatTimer = undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
recordSuccess(ns, host, port) {
|
|
157
|
+
const excludes = this.circuitBreakers.get(ns);
|
|
158
|
+
if (excludes) {
|
|
159
|
+
excludes.delete(`${host}:${port}`);
|
|
160
|
+
if (excludes.size === 0)
|
|
161
|
+
this.circuitBreakers.delete(ns);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
recordFailure(ns, host, port) {
|
|
165
|
+
let excludes = this.circuitBreakers.get(ns);
|
|
166
|
+
if (!excludes) {
|
|
167
|
+
excludes = new Map();
|
|
168
|
+
this.circuitBreakers.set(ns, excludes);
|
|
169
|
+
}
|
|
170
|
+
excludes.set(`${host}:${port}`, Date.now());
|
|
171
|
+
}
|
|
172
|
+
getActiveExcludes(ns) {
|
|
173
|
+
const excludes = this.circuitBreakers.get(ns);
|
|
174
|
+
if (!excludes)
|
|
175
|
+
return [];
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
const active = [];
|
|
178
|
+
for (const [key, openedAt] of excludes) {
|
|
179
|
+
if (now - openedAt >= Application.CB_COOLDOWN_MS) {
|
|
180
|
+
excludes.delete(key);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
active.push(key);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (excludes.size === 0)
|
|
187
|
+
this.circuitBreakers.delete(ns);
|
|
188
|
+
return active;
|
|
189
|
+
}
|
|
190
|
+
async findFromRegistry(namespace, exclude) {
|
|
123
191
|
if (!this.registry)
|
|
124
192
|
throw new Error('Registry not found');
|
|
125
|
-
const { response } = this.registry.request('/-/find', { namespace });
|
|
126
|
-
|
|
127
|
-
return await withTimeout(p, this._registryLookupTimeoutMs, 'Registry /-/find');
|
|
193
|
+
const { response } = this.registry.request('/-/find', { namespace, exclude });
|
|
194
|
+
return await withTimeout(response(), this._registryLookupTimeoutMs, 'Registry /-/find');
|
|
128
195
|
}
|
|
129
|
-
get(namespace) {
|
|
196
|
+
get(namespace, exclude) {
|
|
130
197
|
if (!this.namespaces.has(namespace)) {
|
|
131
198
|
this.namespaces.set(namespace, {
|
|
132
199
|
host: '',
|
|
@@ -136,8 +203,12 @@ export class Application extends Server {
|
|
|
136
203
|
});
|
|
137
204
|
}
|
|
138
205
|
const stack = this.namespaces.get(namespace);
|
|
206
|
+
// Save old cache info before potential invalidation (used for cache degradation)
|
|
207
|
+
const cachedHost = stack.host;
|
|
208
|
+
const cachedPort = stack.port;
|
|
139
209
|
if (stack.status === RegistryLookupStatus.READY &&
|
|
140
|
-
!this.clients.has(`${stack.host}:${stack.port}`)
|
|
210
|
+
(!this.clients.has(`${stack.host}:${stack.port}`) ||
|
|
211
|
+
(exclude?.length && exclude.includes(`${stack.host}:${stack.port}`)))) {
|
|
141
212
|
stack.status = RegistryLookupStatus.IDLE;
|
|
142
213
|
stack.host = '';
|
|
143
214
|
stack.port = 0;
|
|
@@ -150,7 +221,7 @@ export class Application extends Server {
|
|
|
150
221
|
stack.handlers.add([resolve, reject]);
|
|
151
222
|
if (stack.status === RegistryLookupStatus.IDLE) {
|
|
152
223
|
stack.status = RegistryLookupStatus.PENDING;
|
|
153
|
-
this.findFromRegistry(namespace).then(data => {
|
|
224
|
+
this.findFromRegistry(namespace, exclude).then(data => {
|
|
154
225
|
if (!data)
|
|
155
226
|
return Promise.reject(new Error('Namespace not found'));
|
|
156
227
|
assertValidRegistrySocket('peer address from registry', data.host, data.port);
|
|
@@ -170,6 +241,19 @@ export class Application extends Server {
|
|
|
170
241
|
}
|
|
171
242
|
});
|
|
172
243
|
}).catch(e => {
|
|
244
|
+
// Registry unavailable but previously cached client still valid -> degrade
|
|
245
|
+
const cachedKey = `${cachedHost}:${cachedPort}`;
|
|
246
|
+
if (cachedHost && this.clients.has(cachedKey)) {
|
|
247
|
+
const client = this.clients.get(cachedKey);
|
|
248
|
+
// Restore cache so subsequent calls hit the fast path
|
|
249
|
+
stack.host = cachedHost;
|
|
250
|
+
stack.port = cachedPort;
|
|
251
|
+
stack.status = RegistryLookupStatus.READY;
|
|
252
|
+
for (const [resolve] of stack.handlers.values()) {
|
|
253
|
+
resolve(client);
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
173
257
|
this.namespaces.delete(namespace);
|
|
174
258
|
for (const [_, reject] of stack.handlers.values()) {
|
|
175
259
|
reject(e);
|
|
@@ -178,4 +262,35 @@ export class Application extends Server {
|
|
|
178
262
|
}
|
|
179
263
|
});
|
|
180
264
|
}
|
|
265
|
+
async call(namespace, url, data, timeout, retries = 1) {
|
|
266
|
+
// Inject or preserve correlation ID (no mutation of original data)
|
|
267
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
268
|
+
data = { _correlationId: randomUUID(), data };
|
|
269
|
+
}
|
|
270
|
+
else if (!data._correlationId) {
|
|
271
|
+
data = { ...data, _correlationId: randomUUID() };
|
|
272
|
+
}
|
|
273
|
+
const exclude = this.getActiveExcludes(namespace);
|
|
274
|
+
let client;
|
|
275
|
+
try {
|
|
276
|
+
client = await this.get(namespace, exclude);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
this.circuitBreakers.delete(namespace);
|
|
280
|
+
client = await this.get(namespace);
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const { response } = client.request(url, data, timeout ?? this._requestTimeoutMs);
|
|
284
|
+
const result = await response();
|
|
285
|
+
this.recordSuccess(namespace, client.host, client.port);
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
this.recordFailure(namespace, client.host, client.port);
|
|
290
|
+
if (retries > 0) {
|
|
291
|
+
return this.call(namespace, url, data, timeout, retries - 1);
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
181
296
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Server, type MicroServerProps } from './server.js';
|
|
2
2
|
export interface RegistryFindData {
|
|
3
3
|
namespace: string;
|
|
4
|
+
exclude?: string[];
|
|
4
5
|
}
|
|
5
6
|
export interface RegistryAddress {
|
|
6
7
|
host: string;
|
|
@@ -12,7 +13,11 @@ export declare function selectRandomRegistryAddress(keys: Iterable<string>): Reg
|
|
|
12
13
|
export declare class Registry extends Server {
|
|
13
14
|
private readonly namespaces;
|
|
14
15
|
private unregisterFind?;
|
|
16
|
+
private static readonly HEARTBEAT_INTERVAL;
|
|
17
|
+
private static readonly HEARTBEAT_TIMEOUT;
|
|
18
|
+
private readonly heartbeats;
|
|
15
19
|
constructor(props?: MicroServerProps);
|
|
20
|
+
listen(port?: number): Promise<() => Promise<void>>;
|
|
16
21
|
/** 幂等:重复调用会先注销上一条 `/-/find` 再注册,避免叠多条路由 */
|
|
17
22
|
onFind(): void;
|
|
18
23
|
private mountFindHandler;
|
package/dist/registry.js
CHANGED
|
@@ -27,10 +27,14 @@ export function selectRandomRegistryAddress(keys) {
|
|
|
27
27
|
export class Registry extends Server {
|
|
28
28
|
namespaces = new Map();
|
|
29
29
|
unregisterFind;
|
|
30
|
+
static HEARTBEAT_INTERVAL = 1000;
|
|
31
|
+
static HEARTBEAT_TIMEOUT = 20000;
|
|
32
|
+
heartbeats = new Map();
|
|
30
33
|
constructor(props) {
|
|
31
34
|
super('registry', props ?? {});
|
|
32
35
|
this.events.on('connect', (client, extras) => {
|
|
33
36
|
const key = client.host + ':' + client.port;
|
|
37
|
+
this.heartbeats.set(key, Date.now());
|
|
34
38
|
const namespace = extras.join('/');
|
|
35
39
|
if (!this.namespaces.has(namespace)) {
|
|
36
40
|
this.namespaces.set(namespace, new Set());
|
|
@@ -39,6 +43,7 @@ export class Registry extends Server {
|
|
|
39
43
|
});
|
|
40
44
|
this.events.on('disconnect', (client, extras) => {
|
|
41
45
|
const key = client.host + ':' + client.port;
|
|
46
|
+
this.heartbeats.delete(key);
|
|
42
47
|
const namespace = extras.join('/');
|
|
43
48
|
if (this.namespaces.has(namespace)) {
|
|
44
49
|
const keys = this.namespaces.get(namespace);
|
|
@@ -51,6 +56,31 @@ export class Registry extends Server {
|
|
|
51
56
|
}
|
|
52
57
|
});
|
|
53
58
|
this.mountFindHandler();
|
|
59
|
+
this.register('/-/heartbeat', async ({ client }) => {
|
|
60
|
+
if (!client)
|
|
61
|
+
return;
|
|
62
|
+
const key = client.host + ':' + client.port;
|
|
63
|
+
this.heartbeats.set(key, Date.now());
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async listen(port = 0) {
|
|
67
|
+
const teardown = await super.listen(port);
|
|
68
|
+
const timer = setInterval(() => {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
for (const [key, lastTime] of this.heartbeats) {
|
|
71
|
+
if (now - lastTime >= Registry.HEARTBEAT_TIMEOUT) {
|
|
72
|
+
const client = this.clients.get(key);
|
|
73
|
+
if (client) {
|
|
74
|
+
this.heartbeats.delete(key);
|
|
75
|
+
client.dispose();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}, Registry.HEARTBEAT_INTERVAL);
|
|
80
|
+
return async () => {
|
|
81
|
+
clearInterval(timer);
|
|
82
|
+
await teardown();
|
|
83
|
+
};
|
|
54
84
|
}
|
|
55
85
|
/** 幂等:重复调用会先注销上一条 `/-/find` 再注册,避免叠多条路由 */
|
|
56
86
|
onFind() {
|
|
@@ -63,9 +93,16 @@ export class Registry extends Server {
|
|
|
63
93
|
}
|
|
64
94
|
this.unregisterFind = this.register('/-/find', async ({ data }) => {
|
|
65
95
|
const namespace = data.namespace;
|
|
66
|
-
|
|
96
|
+
let keys = this.namespaces.get(namespace);
|
|
67
97
|
if (!keys)
|
|
68
98
|
return;
|
|
99
|
+
if (data.exclude?.length) {
|
|
100
|
+
const excludeSet = new Set(data.exclude);
|
|
101
|
+
const filtered = [...keys].filter(k => !excludeSet.has(k));
|
|
102
|
+
if (filtered.length === 0)
|
|
103
|
+
return;
|
|
104
|
+
keys = new Set(filtered);
|
|
105
|
+
}
|
|
69
106
|
return selectRandomRegistryAddress(keys);
|
|
70
107
|
});
|
|
71
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hile/micro",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -28,5 +28,5 @@
|
|
|
28
28
|
"internal-ip": "^9.0.0",
|
|
29
29
|
"ws": "^8.19.0"
|
|
30
30
|
},
|
|
31
|
-
"gitHead": "
|
|
31
|
+
"gitHead": "ddc82850295d26358cbffbb695e642cdb748339d"
|
|
32
32
|
}
|