-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathatom.xml
More file actions
455 lines (241 loc) · 380 KB
/
atom.xml
File metadata and controls
455 lines (241 loc) · 380 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>钟意博客</title>
<subtitle>感谢你的阅读 | ThatCoder's Blog 钟意博客 那个码农</subtitle>
<link href="https://log.cns.red/atom.xml" rel="self"/>
<link href="https://log.cns.red/"/>
<updated>2026-02-13T10:53:03.122Z</updated>
<id>https://log.cns.red/</id>
<author>
<name>钟意</name>
</author>
<generator uri="https://hexo.io/">Hexo</generator>
<entry>
<title>大语言模型的不确定性</title>
<link href="https://log.cns.red/ai/llm-Indeterminacy/"/>
<id>https://log.cns.red/ai/llm-Indeterminacy/</id>
<published>2026-02-07T02:00:00.000Z</published>
<updated>2026-02-13T10:53:03.122Z</updated>
<content type="html"><![CDATA[<blockquote><p>temperature=0、seed=0 也不等于完全确定, 工程实践的过程中总会有取舍, 请允许理想与现实的偏差.</p></blockquote><span id="more"></span><p><img src="https://oss.sov.red/note/img/039dda2b08243fb862ab3e04daae2d9e.svg" alt="大佬的镇楼图"></p><h2 id="导读"><a href="#导读" class="headerlink" title="导读"></a>导读</h2><blockquote><p> <strong>温度=0 + 种子固定</strong>,最多是“更接近 deterministic”,而不是“数学意义的 deterministic”。介不就是光速与绝对零度嘛。</p></blockquote><p>在真实工程环境下,即使做到:</p><ul><li>temperature = 0</li><li>随机种子(seed)固定为 0</li><li>模型权重不变</li><li>输入 Prompt 字节级一致</li></ul><p>LLM 输出依然无法保证严格的确定性。 原因来自多个层级:采样配置陷阱、浮点数值误差、Batch 动态调度、MoE 路由竞争以及底层算子差异。<br>工程实践中,更现实的目标不是追求数学意义上的“位级完全一致(Bit-wise Deterministic)”,而是通过参数控制、架构设计与缓存机制,把模型行为收敛在业务可接受的稳定性范围内。</p><p>理想化推理链路:<br>固定权重 + 固定输入 + Greedy 解码(每步选 argmax) + 完全确定的数值计算 ⇒ 输出必然完全一致。</p><ul><li><strong>temperature (温度):</strong><ul><li>越高 → 分布越“平”,强随机采样;</li><li>越低 → 分布越“尖”,趋向于 Greedy;</li><li>理论上 temperature=0 对应“总是选最大值(Argmax)”,但工程上常被框架处理为“极小噪声采样”或依赖特定的实现逻辑。</li></ul></li><li><strong>seed (随机种子):</strong><ul><li>仅控制“伪随机数生成器(RNG)”的初始化序列;</li><li>只对依赖随机采样的步骤生效;</li><li>对并行计算中的归约顺序(浮点误差)、Batch 调度策略等物理层面的随机性完全无效。</li></ul></li></ul><p>下文将按照 “排查优先级” 对这些干扰因素进行分层解析,并给出相应的规避策略。</p><p><img src="https://oss.sov.red/note/img/fb7baf5531b52128e2f937daffc5e13d.webp" alt="推理过程与参数参与情况"></p><h2 id="工程中概率排行"><a href="#工程中概率排行" class="headerlink" title="工程中概率排行"></a>工程中概率排行</h2><h3 id="请求配置层"><a href="#请求配置层" class="headerlink" title="请求配置层"></a>请求配置层</h3><p>我不是像 AI 一样让你检查 <code>temperature=0</code> , 是要理解配置层不只是请求参数, 这是高频误区:</p><ul><li>仍然在用采样:<code>top_p < 1</code>、<code>top_k > 1</code>,只是温度降到 0;</li><li>请求 <code>n > 1</code>,服务端对多条采样结果再做内部选择;</li><li>模型供应商 对 <code>temperature=0</code> 做了特殊处理或者忽略0,比如强制改成一个很小但非 0 的温度;</li><li>部分供应商接口文档应该写的是 “mostly deterministic” 或 “best effort reproducibility”,并未承诺严格一致。</li></ul><p>这些都意味着你以为关掉了随机性,实际上还在采样,而且 seed 只保证“这条采样序列可复现”,并不保证不采样。工程上遇到的多数“T=0 还在变”的案例,根因都在这一层。</p><figure class="highlight json"><figcaption><span>参数建议</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">{</span><br> <span class="hljs-attr">"model"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"xxx"</span><span class="hljs-punctuation">,</span><br> <span class="hljs-attr">"temperature"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">0.0</span><span class="hljs-punctuation">,</span> <span class="hljs-comment">// 或一个非常小的值,例如 0.1</span><br> <span class="hljs-attr">"top_p"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">1.0</span><span class="hljs-punctuation">,</span> <span class="hljs-comment">// 不再做 nucleus 截断</span><br> <span class="hljs-attr">"n"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">1</span><span class="hljs-punctuation">,</span> <span class="hljs-comment">// 不要生成多候选再让服务端挑</span><br> <span class="hljs-attr">"seed"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">42</span><span class="hljs-punctuation">,</span> <span class="hljs-comment">// 固定为某个整数,在同一测试中保持不变</span><br> <span class="hljs-attr">"presence_penalty"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">0.0</span><span class="hljs-punctuation">,</span> <span class="hljs-comment">// 禁用额外的随机去重行为</span><br> <span class="hljs-attr">"frequency_penalty"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">0.0</span><span class="hljs-punctuation">,</span><br> <span class="hljs-attr">"max_tokens"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">256</span><span class="hljs-punctuation">,</span> <span class="hljs-comment">// 评估/回归测试时也建议固定</span><br> <span class="hljs-attr">"stop"</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">null</span></span> <span class="hljs-comment">// 如无特殊需要,不要动态变化</span><br><span class="hljs-punctuation">}</span><br></code></pre></td></tr></table></figure><h3 id="请求调度层"><a href="#请求调度层" class="headerlink" title="请求调度层"></a>请求调度层</h3><blockquote><p>Batch 批次请求打包产生差异</p></blockquote><p>在云端 API 上,请求通常如下情况:</p><ul><li>被和其他用户的请求一起打包进不同大小的 batch;</li><li>由底层推理引擎根据 batch 维度选择不同的并行 kernel 或归约策略。</li></ul><p>这会导致:</p><ul><li>并行计算的归约顺序不确定导致浮点加法结果差异(不满足结合律)=> <code>(a+b)+c!=a+(b+c)</code>;</li><li>attention / matmul / RMSNorm 的归约路径不同;</li><li>logits 在 1e-6 量级上产生差异;</li><li>若两个 token 概率本来就非常接近,argmax 可能翻转,后续生成路径完全分叉。</li></ul><p><a href="https://arxiv.org/abs/2408.04667">《Non-Determinism of “Deterministic” LLM Settings》</a>在理论“应当确定”的配置下反复测试,发现输出字符串的一致率明显小于100%,下游任务准确率在不同 run 间可以差十几个百分点。</p><blockquote><p>对云 API 来说,这是<strong>最常见且几乎不可控</strong>的非确定性来源。用户也不可能决定好每个批次元素的归约顺序.</p></blockquote><h3 id="模型算子层"><a href="#模型算子层" class="headerlink" title="模型算子层"></a>模型算子层</h3><blockquote><p>浮点并行本身就是非确定性的</p></blockquote><p>即便你在本地单机推理,只要使用 GPU / 并行 kernel,也会碰到:</p><ul><li>并行归约(<code>atomicAdd</code> 等)导致累加顺序未定义;</li><li>cudnn/cublas/自定义 kernel 采用了不同实现路径;</li><li>多线程抢占导致不同 run 间执行顺序略有差异。</li></ul><p>数值差异微小,但 softmax + argmax 会放大这些差异;自回归生成会进一步放大第一步 argmax 的差异。</p><p>PyTorch 论坛中多次讨论:即使 <code>model.eval()</code>,也需要额外开启 deterministic 模式,<br>否则多次 inference 仍然无法 bit-wise 一致。</p><p>批次与浮点计算可以合在一起看,下图是一个简单的归因链路示意:</p><p><img src="https://oss.sov.red/note/img/4d3e1a521e61238e1ccc1a51b359b52b.webp" alt="浮点非结合性效应"></p><h3 id="模型解码层"><a href="#模型解码层" class="headerlink" title="模型解码层"></a>模型解码层</h3><p>不同框架/服务对于 “temperature=0” 的实现并不统一:</p><ul><li>有的分支直接走 greedy,不采样;</li><li>有的把 0 改成一个很小的正数,仍然进行采样;</li><li>有的在低温下仍然允许 nucleus / top-k 筛选后采样。</li></ul><p>再叠加 tie-breaking 策略:</p><ul><li>概率相等或近似相等时,是按 token id 排序选第一个</li><li>还是仍然用 RNG 做一次随机决策</li></ul><p>这些实现级细节,很容易在“理论上相同配置”的两次调用之间,积累成肉眼可见的输出差异。</p><blockquote><p>实际代码里,几个主流高 star 项目对 temperature=0 的处理就已经完全不一样:Transformers 直接视为非法值, vLLM 把它解释成“强制 greedy 并重写 top_p/top_k”, llama.cpp 则在不同版本中先后把非正温度当作 greedy 的捷径、后来又要求配合 top‑k 才能得到真正的 greedy 行为。<br>这本身就说明:“temperature=0”的语义强依赖具体框架实现,目前并没有统一标准。</p></blockquote><h3 id="模型架构层"><a href="#模型架构层" class="headerlink" title="模型架构层"></a>模型架构层</h3><p>对采用 Mixture-of-Experts(MoE)的模型:</p><ul><li>每个 token 会先经过 gating 网络决定路由到哪些专家子网络;</li><li>为了负载均衡,路由逻辑中可能包含截断、近似甚至随机裁剪;</li><li>当不同 batch 下竞争同一专家时,调度顺序变化会改变路由结果。</li></ul><h3 id="链路复现性"><a href="#链路复现性" class="headerlink" title="链路复现性"></a>链路复现性</h3><blockquote><p>模型/系统版本漂移, 部署的集群非一致, 请求的链路不一定落到系统、驱动、算子全一致的软硬件上。</p></blockquote><p>在云端服务里,以下情况都很常见:</p><ul><li>模型权重热更新、系统 prompt 调整;</li><li>不同 region / 集群挂载了略有差异的模型快照;</li><li>路由策略在多个版本间做灰度。</li></ul><p>同一个 model name 在不同时刻/不同 region 调用,底层实际上可能已经不是同一个模型实例。<br>这更常见于“隔一段时间”再次调用发现结果不同,而非“连续两次立刻不同”。</p><h3 id="输入一致性"><a href="#输入一致性" class="headerlink" title="输入一致性"></a>输入一致性</h3><p>常见人祸因素:</p><ul><li>prompt 拼装引入 time、userid、skill 等隐变量;</li><li>system prompt / few-shot demo 在不同调用间细微变化;</li><li>不可见字符(BOM、零宽空格)或换行差异。</li></ul><p>日志里看上去完全一样,但序列化出来并不一样。这一类问题本身不深,只说明排查非确定性前先校验输入字节级是否一致。</p><p><img src="https://oss.sov.red/note/img/a25c65d51fc8af8f2265fd51c32bcfa5.webp" alt="llm-Indeterminacy-why"></p><h2 id="如何尽量确定性"><a href="#如何尽量确定性" class="headerlink" title="如何尽量确定性"></a>如何尽量确定性</h2><p>下面区分两种场景:云 API 调用 / 自建推理。</p><h3 id="防御策略"><a href="#防御策略" class="headerlink" title="防御策略"></a>防御策略</h3><blockquote><p>黑盒对抗赛</p></blockquote><ol><li>配置层:参数去随机化<ul><li>配置:temperature 非 0 但极低(如 0.01–0.1),避免 0 被特殊处理;</li><li>采样:锁定 top_p = 1、不使用 top_k、n = 1;</li><li>文档:查阅各家关于 deterministic 字段的说明,按官方建议传参。</li></ul></li><li>输入层:严格归一化 (针对“输入一致性”)<ul><li>确保在计算签名或调用前,对 Prompt 进行 字节级清洗对比, 严查时间戳等变量注入;</li><li>剔除零宽空格、统一换行符 (\n vs \r\n)、并在 JSON 序列化时保证 key 顺序固定。</li></ul></li><li>业务层:利用缓存构造“伪确定性” (针对“链路复现性”)<ul><li>定义一个请求签名: (model_version, temperature, top_p, system prompt, user prompt, seed);</li><li>命中即返回:确保“相同参数 + 输入 ⇒ 永远返回同一条缓存结果”,在业务侧屏蔽底层的微小波动。</li></ul></li><li>兜底策略:把 LLM 视为噪声组件<ul><li>结构化输出:LLM 生成候选 → Schema/Parser 严格校验 → 失败则重试;</li><li>多数票机制:对关键分类任务发起 3 次调用取共识(Majority Vote);</li><li>预期管理:在业务逻辑里显式兼容小范围的不一致。</li></ul></li></ol><h3 id="控制策略"><a href="#控制策略" class="headerlink" title="控制策略"></a>控制策略</h3><blockquote><p>白盒自控主场赛</p></blockquote><ol><li>解码层:强制 Greedy<ul><li>在 vLLM / Transformers 中显式设置 do_sample=False;</li><li>彻底禁用 top_p / top_k,防止框架内部的“低温采样”行为;</li><li>若用 Beam Search,禁用所有涉及随机扰动的参数。</li></ul></li><li>配置层:全链路种子固定<ul><li>代码级固定:torch.manual_seed、torch.cuda.manual_seed_all、numpy.random.seed;</li><li>这只能解决“采样层”的随机性,无法解决“算子层”的并发噪声。</li></ul></li><li>算子层:开启确定性模式 (牺牲性能)<ul><li>开启 PyTorch 确定性算法:torch.use_deterministic_algorithms(True);</li><li>禁用优选 Benchmark:torch.backends.cudnn.benchmark = False;</li><li>配置环境:设置 CUBLAS_WORKSPACE_CONFIG 使用确定性算法实现。</li><li>代价:吞吐量显著下降,且部分高性能算子(FlashAttention 等)可能无法在确定性模式下运行。</li></ul></li><li>调度层:牺牲 Batching (针对“批次差异”)<ul><li>独占推理:不跨请求拼 Batch,每个请求单独执行;</li><li>Batch-invariant 库:仅使用明确承诺了“批量无关性”的推理库版本。</li><li>代价:GPU 利用率暴跌,成本激增。通常仅适用于离线审计或基准评测。</li></ul></li></ol><p><img src="https://oss.sov.red/note/img/5fee48fd83cca8e81d1da71029a1d4bd.png" alt="自建确定性策略带来的延迟倍率对比"></p><p><img src="https://oss.sov.red/note/img/e5bd79cf45bbb9f3aea187cf11e6b17b.png" alt="数值精度与管线设计在重现性、性能与显存代价上的折中示意图"></p><p><img src="https://oss.sov.red/note/img/54ac7129e9c5de9df79e92986d9c6259.webp" alt="llm-Indeterminacy-do"></p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>从研究与工程实践看:</p><ul><li>即使在 配置层 (temperature=0) 和 配置层 (seed) 拉满的情况下,算子层 和 调度层 的噪声依然存在;</li><li>在云端 API 上追求 Bit-wise Deterministic 是不切实际的;</li><li>在自建推理中,获得严格确定性的代价是大幅牺牲 吞吐与算力成本。</li></ul><p>更合理的心智模型是:</p><blockquote><p><strong>LLM = 强大但带噪声的推理器</strong><br>——在设计系统时,默认它“不完全可复现”,通过参数、缓存和上层逻辑来吸收这种噪声。</p></blockquote><p>适合追求强一致性的环节(计费、风控、合规决策),应优先考虑确定性模型或规则系统;<br>LLM 更适合作为“辅助决策 + 文本代理”,而不是唯一的“权威判官”。</p><p>这样设计出来的系统,更符合当下大模型技术的真实边界。</p><hr><blockquote><p>文章主要借助 <code>Perplexity</code> + <code>NotebookLM</code> 检索文献和生成配图,对其中一篇核心参考强烈推荐原文阅读 <a href="https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/">Defeating Nondeterminism in LLM Inference</a></p></blockquote>]]></content>
<summary type="html">即使 temperature=0、seed=0 也无法保证相同输出</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="AI" scheme="https://log.cns.red/tags/AI/"/>
<category term="LLM" scheme="https://log.cns.red/tags/LLM/"/>
</entry>
<entry>
<title>科艺知识库 ARM64</title>
<link href="https://log.cns.red/kykms-arm64/"/>
<id>https://log.cns.red/kykms-arm64/</id>
<published>2025-12-02T14:00:00.000Z</published>
<updated>2026-02-13T10:53:03.131Z</updated>
<content type="html"><![CDATA[<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>因某些需求需要一款文档管理与索引工具, 遂相中了科艺知识库, 并且构建了一份科艺知识库 arm64 架构版的, 记录构建过程.</p><h2 id="部署"><a href="#部署" class="headerlink" title="部署"></a>部署</h2><blockquote><p>部署先放前面, 防止未来有急切的看客.</p></blockquote><ol><li><p><strong>克隆源码</strong>:</p><ul><li><a href="https://github.com/tovarsh/kykms">Github</a></li><li><a href="https://git.thatcdn.cn/open/kykms">国内分支</a></li></ul></li><li><p><strong>Docker 编排</strong>:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 克隆代码</span><br>git <span class="hljs-built_in">clone</span> https://git.thatcdn.cn/open/kykms.git<br><br><span class="hljs-comment"># 工作目录</span><br><span class="hljs-built_in">cd</span> ./kykms/deploy<br><br><span class="hljs-comment"># 启动编排: 镜像已经适配 amd64/arm64 双版本 根据平台自动拉取</span><br>docker compose up -d<br></code></pre></td></tr></table></figure></li></ol><h2 id="构建"><a href="#构建" class="headerlink" title="构建"></a>构建</h2><blockquote><p>若看客系统架构非 amd64/arm64, 可参考构建过程.</p></blockquote><h3 id="elasticsearch"><a href="#elasticsearch" class="headerlink" title="elasticsearch"></a>elasticsearch</h3><ol><li>在 kykms/deploy/ES 目录下 Dockerfile 文件可知, arm64 架构没有 elasticsearch:7.6.1 版本, 遂换成 7.8.1</li><li>相应的 ES 目录下 elasticsearch.tar 打包的 analysis-ik 这个插件也需要换成对应的版本, 这个网址有存档 analysis-ik 全版本 => <a href="https://release.infinilabs.com/analysis-ik/stable/">analysis-ik-all</a></li><li>下载对应的 analysis-ik.zip 解压再压缩为 analysis-ik.tar 替换原来的, 注意压缩包展开就是全部文件, 不要封一层目录.</li><li>至此修改 Dockerfile 并且构建得到 kykms-es<figure class="highlight dockerfile"><figcaption><span>es-build</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs Dockerfile"><span class="hljs-comment"># 更新对应镜像</span><br><span class="hljs-keyword">FROM</span> elasticsearch:<span class="hljs-number">7.8</span>.<span class="hljs-number">1</span><br><span class="hljs-comment"># 利用 ADD 自动解压到目标目录</span><br><span class="hljs-keyword">ADD</span><span class="language-bash"> elasticsearch.tar /usr/share/elasticsearch/plugins/analysis-ik</span><br><span class="hljs-comment"># 默认工作目录,以免影响 ES 启动</span><br><span class="hljs-keyword">WORKDIR</span><span class="language-bash"> /usr/share/elasticsearch</span><br></code></pre></td></tr></table></figure></li></ol><h3 id="mysql"><a href="#mysql" class="headerlink" title="mysql"></a>mysql</h3><p>在 kykms/deploy/DB 目录下 Dockerfile, 检查 mysql:5.7 是否有对应架构版本. 直接构建得到 kykms-mysql</p><h3 id="redis"><a href="#redis" class="headerlink" title="redis"></a>redis</h3><p>随便用一个版本为6就行, 构建得到 kykms-redis</p><h3 id="web"><a href="#web" class="headerlink" title="web"></a>web</h3><ol><li>在 kykms/ant-design-vue-jeecg 目录下用node构建前端项目得到 dist</li><li>构建镜像得到 kykms-web</li></ol><h3 id="api"><a href="#api" class="headerlink" title="api"></a>api</h3><ol><li>在 kykms/jeecg-boot 目录下用maven/java构建后端项目得到 jar, 用 java8 的基础镜像安装libreoffice库 (WORD转PDF的). 我的建议是随便一个amd设备运行 registry.cn-guangzhou.aliyuncs.com/kyxxjs/kykms:comm 取得对应的 jar 和 字体, 省去构建过程.</li><li>构建镜像得到 kykms-api<figure class="highlight dockerfile"><figcaption><span>api-build</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><code class="hljs Dockerfile"><span class="hljs-keyword">FROM</span> docker.io/eclipse-temurin:<span class="hljs-number">8</span>u472-b08-jdk<br><span class="hljs-keyword">LABEL</span><span class="language-bash"> maintainer=<span class="hljs-string">"Joney K."</span></span><br><span class="hljs-comment"># 因为是 ARM64 架构,apt 源必须指向 ports.ubuntu.com</span><br><span class="hljs-comment"># 显式替换为对应架构加速源</span><br><span class="hljs-keyword">RUN</span><span class="language-bash"> sed -i <span class="hljs-string">'s/archive.ubuntu.com/ports.ubuntu.com/g'</span> /etc/apt/sources.list && \</span><br><span class="language-bash"> sed -i <span class="hljs-string">'s/security.ubuntu.com/ports.ubuntu.com/g'</span> /etc/apt/sources.list && \</span><br><span class="language-bash"> apt-get update && \</span><br><span class="language-bash"> apt-get install -y --no-install-recommends libreoffice fontconfig && \</span><br><span class="language-bash"> apt-get clean && \</span><br><span class="language-bash"> <span class="hljs-built_in">rm</span> -rf /var/lib/apt/lists/*</span><br><span class="hljs-keyword">COPY</span><span class="language-bash"> ./simsun.ttc /usr/share/fonts/simsun.ttc</span><br><span class="hljs-keyword">RUN</span><span class="language-bash"> fc-cache -fv</span><br><span class="hljs-keyword">WORKDIR</span><span class="language-bash"> /kykms</span><br><span class="hljs-keyword">COPY</span><span class="language-bash"> ./jeecg-boot-module-system-2.4.5.jar ./app.jar</span><br><span class="hljs-keyword">EXPOSE</span> <span class="hljs-number">8080</span><br><span class="hljs-keyword">CMD</span><span class="language-bash"> [<span class="hljs-string">"java"</span>, <span class="hljs-string">"-Dfile.encoding=utf-8"</span>, <span class="hljs-string">"-jar"</span>, <span class="hljs-string">"app.jar"</span>]</span><br></code></pre></td></tr></table></figure></li></ol><h3 id="编排"><a href="#编排" class="headerlink" title="编排"></a>编排</h3><p>替换 kykms/deploy/docker-compose.yml 里面的 image, 都换成新构建的即可</p>]]></content>
<summary type="html">一款知识库系统,用来将文档整理归类,并在团队内受控共享以充分利用这些文档,或者将文档分享到外部。</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="部署" scheme="https://log.cns.red/tags/%E9%83%A8%E7%BD%B2/"/>
</entry>
<entry>
<title>胶东半岛观察</title>
<link href="https://log.cns.red/daily/Jiaodong-Peninsula/"/>
<id>https://log.cns.red/daily/Jiaodong-Peninsula/</id>
<published>2025-10-08T13:00:00.000Z</published>
<updated>2026-02-13T10:53:03.125Z</updated>
<content type="html"><![CDATA[<p>一面法定潮汐表,丈量着胶东半岛的野性与文明。</p><span id="more"></span><h2 id="威海"><a href="#威海" class="headerlink" title="威海"></a>威海</h2><p>一座偎在胶东半岛角落的精致小城。裙边是绿白清秀的不成连绵的矮山,前摆是弧度不那么规整的有小屿突触的海岸线。这种不加雕琢的美,像某个记忆里的僻如坪山碧岭的海滨小镇,安静而耐人寻味。</p><p>未经雕琢的安静被网红流量打破。无人机如新的候鸟群盘旋,三脚架在旧的观景点扎根。火炬八街的午后,人潮比海浪更懂得重复的艺术。而角落熙攘的垃圾桶,只是无声地装着鲜花、雨衣、折断的高跟鞋——显然它们没有海鸥挑食。随着潮汐退去又涨来,可知潮汐才是这片海岸真正的主人。</p><p>而我,也是这潮汐里的一滴水,可惜遵循的是法定潮汐表,而非岸边张贴的。</p><h2 id="烟台"><a href="#烟台" class="headerlink" title="烟台"></a>烟台</h2><p>养马岛的海,是一块被时光遗忘的果冻。它不求深邃,却以清澈自持,让人相信海也可以是天然的泳池。这种坦诚,与威海的含蓄保持着默契的距离,正如牟平与威海的距离。它们像半岛伸出的两只手:一只捧着未经雕琢的安静,另一只,则盛着这汪见底的透明。</p><p>至于长岛的潮汐,我尚未赴约。或许下次,等我能分清,哪一滴水声来自太平洋,哪一声又只是我手机里定时响起的行程提示音。</p><h2 id="青岛"><a href="#青岛" class="headerlink" title="青岛"></a>青岛</h2><p>青岛字如其名长青之岛。CBD的夜色,是资本与潮汐共同浇筑的结晶。五十层高空俯视,霓虹如血管般在楼宇间奔流,勾勒出金融与欲望的拓扑图。远处吊塔的红色信号灯明灭,像为这片填海而生的新城打着节拍——一种被精密计算的、永不疲倦的潮汐。</p><p>而浮山森林公园则扮演着它的反面。那里的山海如未被驯服的旧梦,松涛与海浪合谋,试图淹没来自CBD的电子脉冲。山路蜿蜒如静默的抵抗,提醒着人们:在成为国际湾区之前,青岛首先是一座岛。</p><p>真正的“人间”则散落在海边任何一寸可供落座的土地。无需帐篷,不论晨昏,支一把露营椅,男女老少便能面朝大海,为自己辟出一席之地。这并非精致的野趣,而是一种更朴素的与故里海浪的交流。</p><p>真正的“雕琢”藏在街角。道路护栏外侧悄然延展出休憩的桌椅,桌面之下,无线充电线圈正发出温柔的磁力。这已超越了便民服务,更像一场城市与过客达成的微妙契约:你为我停留,我予你能量。正如栈桥旁的太阳能座椅,白昼吸吮阳光,夜晚则为听海的过客释放些许光明——赛博朋克的光合作用。</p><p>在这座城,自然与科技并非对峙,而是达成了某种共谋。山海是底色,代码是笔触,金融是驱力,共同书写着一份既野性又文明的“法定潮汐表”。而每一位过客、每一个集装箱,既是观潮者,也是被计量的水滴。</p><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>没参考攻略,一次极其简单的胶东半岛旅行观察。归程时蓝调被朝阳划破,麦田被高铁划破,两股潮汐被工作划破。明明远离了海边,那若隐若现的熙熙攘攘的潮汐却跟了一路,回到天津,更甚。原来都是回到自己的岛屿,在各自的法定表格上,一次次签到与签退,迎接岛屿的下一次潮汐。</p><h2 id="附录-旅拍"><a href="#附录-旅拍" class="headerlink" title="附录: 旅拍"></a>附录: 旅拍</h2><div class="tag-plugin link dis-select"><a class="link-card plain" title="详细相册地址" href="https://www.picgo.net/album/ksZEr" target="_blank" rel="external nofollow noopener noreferrer" cardlink autofill="icon,desc"><div class="left"><span class="title">详细相册地址</span><span class="cap link footnote">https://www.picgo.net/album/ksZEr</span></div><div class="right"><div class="lazy img" data-bg="https://gcore.jsdelivr.net/gh/cdn-x/placeholder@1.0.12/link/8f277b4ee0ecd.svg"></div></div></a></div><p><a href="https://www.picgo.net/image/IMG-20251004-174840.NCWG0j"><img src="https://origin.picgo.net/2025/10/08/IMG_20251004_174840a36966580cd5ad0d.jpg" alt="威海日落湾没有日落" border="0"></a><br><a href="https://www.picgo.net/image/IMG-20251005-165433.NCWr7f"><img src="https://origin.picgo.net/2025/10/08/IMG_20251005_16543376f4df14a574e173.jpg" alt="眺望威海国际浴场" border="0"></a><br><a href="https://www.picgo.net/image/IMG-20251006-102502.NCW5a2"><img src="https://origin.picgo.net/2025/10/08/IMG_20251006_102502c19cb8aba6d1c951.jpg" alt="街拍第二国际浴场" border="0"></a><br><a href="https://www.picgo.net/image/IMG-20251005-122207.NCWRdL"><img src="https://origin.picgo.net/2025/10/08/IMG_20251005_1222079e3eb6857a2bb91f.jpg" alt="烟台养马岛一角海景" border="0"></a><br><a href="https://www.picgo.net/image/IMG-20251006-211433.NCW2h7"><img src="https://origin.picgo.net/2025/10/08/IMG_20251006_2114339b35a72ed13f9f5d.jpg" alt="青岛黄岛区俯拍夜景" border="0"></a><br><a href="https://www.picgo.net/image/IMG-20251007-103211.NCWFTy"><img src="https://origin.picgo.net/2025/10/08/IMG_20251007_103211aa440c6e1f11bb40.jpg" alt="唐鸟湾赶海敲敲敲" border="0"></a><br><a href="https://www.picgo.net/image/IMG-20251007-160644.NCWQiN"><img src="https://origin.picgo.net/2025/10/08/IMG_20251007_160644af18f60570742756.jpg" alt="浮山森林公园远眺" border="0"></a><br><a href="https://www.picgo.net/image/IMG-20251008-064203.NCWwSi"><img src="https://origin.picgo.net/2025/10/08/IMG_20251008_064203ede391fd269780ad.jpg" alt="早六的归程" border="0"></a></p>]]></content>
<summary type="html">一面法定潮汐表,丈量着胶东半岛的野性与文明。</summary>
<category term="生活" scheme="https://log.cns.red/categories/%E7%94%9F%E6%B4%BB/"/>
<category term="随笔" scheme="https://log.cns.red/tags/%E9%9A%8F%E7%AC%94/"/>
</entry>
<entry>
<title>海光 K100 DCU VLLM 推理环境构建</title>
<link href="https://log.cns.red/ai/hygon-vllm/"/>
<id>https://log.cns.red/ai/hygon-vllm/</id>
<published>2025-08-30T02:00:00.000Z</published>
<updated>2026-02-13T10:53:03.122Z</updated>
<content type="html"><![CDATA[<h2 id="系统环境"><a href="#系统环境" class="headerlink" title="系统环境"></a>系统环境</h2><ul><li><strong>系统</strong>: Kylin OS</li><li><strong>芯片</strong>: 128H, Hygon C86 7390 2S * 64</li><li><strong>显存</strong>: 128G, Hygon K100 DCU 64G * 2</li><li><strong>内存</strong>: 500G</li></ul><h2 id="基础驱动"><a href="#基础驱动" class="headerlink" title="基础驱动"></a>基础驱动</h2><blockquote><p>PS: 详情参考 <a href="https://developer.sourcefind.cn/gitbook//dcu_developer/OperationManual/2_InstallROCm/InstallROCm.html">DTK环境安装与部署</a></p></blockquote><ul><li><strong>DTK</strong>: <a href="https://download.sourcefind.cn:65024/1/main/latest">最新DTK列表</a><ol><li>解压: <code>tar -xzvf DTK*.tar.gz</code></li><li>载入环境: <code>cd DTK* && source env.sh</code></li><li>测试: <code>hipcc --version</code></li></ol></li><li><strong>驱动</strong>: <a href="https://download.sourcefind.cn:65024/6/main/latest%E9%A9%B1%E5%8A%A8/dtk-25.04">最新驱动列表</a><ul><li>执行: <code>./rock*</code></li><li>测试: <code>hy-smi</code></li></ul></li></ul><h2 id="部署资源"><a href="#部署资源" class="headerlink" title="部署资源"></a>部署资源</h2><h3 id="模型文件"><a href="#模型文件" class="headerlink" title="模型文件"></a>模型文件</h3><p>可以从任意平台下载 vllm 所需要的离线模型文件,以下举例 HF、魔搭下载。</p><p><strong>强烈建议像示例一样小模型测试,启动快看报错也快。</strong></p><ul><li><strong>HuggingFace</strong></li></ul><blockquote><p>PS: 也可以选择Git命令下载: <code>git clone https://hf-mirror.com/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B</code></p></blockquote><figure class="highlight shell"><figcaption><span>HF下载模型</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs shell"><span class="hljs-meta prompt_"># </span><span class="language-bash">基于PY环境安装HF脚手架</span><br>pip install huggingface-hub<br><span class="hljs-meta prompt_"></span><br><span class="hljs-meta prompt_"># </span><span class="language-bash">镜像加速</span><br><span class="hljs-meta prompt_">$</span><span class="language-bash">HF_ENDPOINT = <span class="hljs-string">"https://hf-mirror.com"</span></span><br><span class="hljs-meta prompt_"></span><br><span class="hljs-meta prompt_"># </span><span class="language-bash">下载完整模型</span><br>huggingface-cli download --repo-type model \<br> deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B \<br> --local-dir ./DeepSeek-R1-Distill-Qwen-1.5B \<br> --local-dir-use-symlinks False<br></code></pre></td></tr></table></figure><ul><li><strong>ModelScope</strong>(国内)</li></ul><blockquote><p>PS: 也可以选择Git命令下载: <code>git clone https://www.modelscope.cn/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B.git</code></p></blockquote><figure class="highlight shell"><figcaption><span>魔搭下载模型</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs shell">pip install modelscope<br>modelscope download --model deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B --local_dir ./DeepSeek-R1-Distill-Qwen-1.5B<br></code></pre></td></tr></table></figure><h3 id="环境镜像"><a href="#环境镜像" class="headerlink" title="环境镜像"></a>环境镜像</h3><p>与其它卡有所不同,因缺少 CDNA/GCN 架构的优化内核、未针对 Hygon 芯片做算子优化等原因,国产加速卡需要使用定制的镜像。</p><p>镜像按照自己的DCU驱动版本选择: <a href="https://www.sourcefind.cn/#/image/dcu/pytorch?activeName=overview">光源定制镜像</a> 。</p><p>离线内网环境请先准备好镜像包导入。</p><h2 id="部署服务"><a href="#部署服务" class="headerlink" title="部署服务"></a>部署服务</h2><p><strong>参数详情</strong>:</p><ul><li><code>HIP_VISIBLE_DEVICES</code>: 使用的显卡槽</li><li><code>HSA_OVERRIDE_GFX_VERSION</code>: 匹配K100架构</li><li><code>--tensor-parallel-size</code>: 使用显卡数量</li><li><code>--gpu-memory-utilization</code>: 显卡使用率</li></ul><p><strong>编排文件</strong>: 请自行修改以下内容</p><ul><li>environment里显卡数、显卡槽</li><li>command里model路径</li><li>volumes里映射的模型路径实际路径</li></ul><figure class="highlight yaml"><figcaption><span>docker-compose.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">"3.9"</span><br><br><span class="hljs-attr">services:</span><br> <span class="hljs-attr">vllm:</span><br> <span class="hljs-attr">image:</span> <span class="hljs-string">image.sourcefind.cn:5000/dcu/admin/base/pytorch:2.4.1-ubuntu22.04-dtk25.04.1-py3.10</span><br> <span class="hljs-attr">container_name:</span> <span class="hljs-string">vllm-test</span><br> <span class="hljs-attr">environment:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">HIP_VISIBLE_DEVICES=0,1</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">HSA_OVERRIDE_GFX_VERSION=10.3.0</span><br> <span class="hljs-attr">command:</span> <span class="hljs-string">></span><br><span class="hljs-string"> python3 -m vllm.entrypoints.openai.api_server</span><br><span class="hljs-string"> --model /workspace/models/DeepSeek-R1-Distill-Qwen-1.5B</span><br><span class="hljs-string"> --tensor-parallel-size 2</span><br><span class="hljs-string"> --gpu-memory-utilization 0.9</span><br><span class="hljs-string"> --served-model-name ds-r1-1.5b</span><br><span class="hljs-string"> --dtype float16</span><br><span class="hljs-string"> --trust-remote-code</span><br><span class="hljs-string"> --enforce-eager</span><br><span class="hljs-string"> --host 0.0.0.0</span><br><span class="hljs-string"> --port 8000</span><br><span class="hljs-string"></span> <span class="hljs-attr">network_mode:</span> <span class="hljs-string">host</span><br> <span class="hljs-attr">ipc:</span> <span class="hljs-string">host</span><br> <span class="hljs-attr">devices:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">"/dev/kfd:/dev/kfd"</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">"/dev/dri:/dev/dri"</span><br> <span class="hljs-attr">volumes:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">/opt/hyhal:/opt/hyhal:ro</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">/workspace/service/vllm/models:/workspace/models:ro</span><br> <span class="hljs-attr">cap_add:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">SYS_PTRACE</span><br> <span class="hljs-attr">security_opt:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">seccomp=unconfined</span><br> <span class="hljs-attr">group_add:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">video</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">render</span><br> <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</span><br></code></pre></td></tr></table></figure><h2 id="测试命令"><a href="#测试命令" class="headerlink" title="测试命令"></a>测试命令</h2><blockquote><p>一些打印测试的命令,成功部署可以忽略。</p></blockquote><ul><li><p>测试服务是否正常运行</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs shell">curl -X POST "http://127.0.0.1:8000/v1/completions" \<br> -H "Content-Type: application/json" \<br> -d '{<br> "model": "ds-r1-1.5b",<br> "prompt": "Compute the Fourier transform of the constant function f(t) = 1. What should the correct answer be?",<br> "max_tokens": 50,<br> "temperature": 0.7<br> }'<br><span class="hljs-meta prompt_"></span><br><span class="hljs-meta prompt_"># </span><span class="language-bash">====理论输出====</span><br><span class="hljs-meta prompt_"># </span><span class="language-bash">answer is 2πδ(ω)</span><br></code></pre></td></tr></table></figure></li><li><p>查看容器对显卡的识别</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><code class="hljs shell">python3 - <<'PY'<br>import torch<br>print("PyTorch 版本:", torch.__version__)<br>print("是否可见 HIP/GPU:", torch.cuda.is_available())<br>print("GPU 数量:", torch.cuda.device_count())<br>for i in range(torch.cuda.device_count()):<br> print(f"设备 {i} 名称:", torch.cuda.get_device_name(i))<br>PY<br><span class="hljs-meta prompt_"></span><br><span class="hljs-meta prompt_"># </span><span class="language-bash">====理论输出====</span><br><span class="hljs-meta prompt_"># </span><span class="language-bash">PyTorch 版本: 2.5.1</span><br><span class="hljs-meta prompt_"># </span><span class="language-bash">是否可见 HIP/GPU: True</span><br><span class="hljs-meta prompt_"># </span><span class="language-bash">GPU 数量: 2</span><br><span class="hljs-meta prompt_"># </span><span class="language-bash">设备 0 名称: K100</span><br><span class="hljs-meta prompt_"># </span><span class="language-bash">设备 1 名称: K100</span><br></code></pre></td></tr></table></figure></li></ul>]]></content>
<summary type="html">基于国产海光DCU加速卡的硬件基础,通过Docker容器化技术,获取并部署为深度优化定制的软件镜像,以实现高效能计算任务的快速部署与性能释放。</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="部署" scheme="https://log.cns.red/tags/%E9%83%A8%E7%BD%B2/"/>
<category term="AI" scheme="https://log.cns.red/tags/AI/"/>
</entry>
<entry>
<title>博客多平台负载均衡方案</title>
<link href="https://log.cns.red/Blog-Load-Balance/"/>
<id>https://log.cns.red/Blog-Load-Balance/</id>
<published>2025-06-21T02:00:00.000Z</published>
<updated>2026-02-15T08:17:49.581Z</updated>
<content type="html"><![CDATA[<blockquote><p>折中式 博客多平台负载均衡方案</p></blockquote><h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>此前博客在 Vercel 托管,域名是腾讯云 CNAME 到 Vercel 的加速源,但是最近发现这个方案有如下问题:</p><ol><li>神秘力量:有时网络只位于墙内或墙外,单向访问</li><li>额度耗尽:Vercel 的免费额度是 100G/月,这个月博客居然把额度耗尽</li><li>容灾能力:受单平台限制没有灾害转移能力,包括不限于对网络问题、额度问题的处理</li></ol><p>基于以上问题,前天计划周末进行多平台负载均衡,增强博客的稳定性与白嫖性。虽然不能完美解决,但已经得到很大改善。</p><p>而且该方案可复用于无状态、一致性的项目,如:博客、文档、SPA、只读类接口、静态加速等等。毕竟额度不够,平台账号数量来凑。</p><h3 id="问题"><a href="#问题" class="headerlink" title="问题"></a>问题</h3><blockquote><p>考虑到该方案的可能缺陷,如统计数据、SEO易混乱、部分平台行为不一致、首次 TLS 握手稍慢等等,未来将在此处记录出现的问题与解决方案。</p></blockquote><div class="tag-plugin timeline"><div class="timenode" index="0"><div class="header"><span>2025-06-21</span></div><div class="body fs14"><p>使用多平台负载均衡方案</p></div></div></div><h2 id="方案"><a href="#方案" class="headerlink" title="方案"></a>方案</h2><h3 id="准备过程"><a href="#准备过程" class="headerlink" title="准备过程"></a>准备过程</h3><ul><li>服务器:可以是微型服务器,只用来运行负载均衡程序。最好是香港服务器防止国内网络问题。</li><li>域名:可以是任何域名,但需要解析到服务器的 IP 地址。</li><li>负载均衡程序:可以是任何具备负载均衡程序,如 Nginx、OpenResty 等。</li><li>多平台账号:Vercel、Netlify、Cloudflare 等。</li></ul><h3 id="设计思路"><a href="#设计思路" class="headerlink" title="设计思路"></a>设计思路</h3><ol><li>部署博客在Vercel等多平台,不需要配置DOMAIN,用默认的域名即可。<ul><li>Hexo等框架通常生成一个静态的 public 文件夹,上传到 Git 仓库中在 Vercel、GitHub Pages、Cloudflare Pages、Netlify 等引用仓库部署。(后续推送会自动触发更新)</li><li>需要的话服务器本身也可以托管一份</li></ul></li><li>域名解析到服务器的IP地址</li><li>服务器安装负载均衡程序,自行搜索《Linux安装Nginx》之类的教程。</li><li>签发证书,新建站点,并配置规则。(配置完无需维护,除非部署平台有增减)</li></ol><h3 id="详细教程"><a href="#详细教程" class="headerlink" title="详细教程"></a>详细教程</h3><div class="tag-plugin link dis-select"><a class="link-card plain" title="" href="https://log.cns.red/wiki/LoadBalance/deploy/config.html" target="_blank" rel="external nofollow noopener noreferrer" cardlink autofill="title,icon,desc"><div class="left"><span class="title">https://log.cns.red/wiki/LoadBalance/deploy/config.html</span><span class="cap link footnote">https://log.cns.red/wiki/LoadBalance/deploy/config.html</span></div><div class="right"><div class="lazy img" data-bg="https://gcore.jsdelivr.net/gh/cdn-x/placeholder@1.0.12/link/8f277b4ee0ecd.svg"></div></div></a></div><h3 id="视频教程"><a href="#视频教程" class="headerlink" title="视频教程"></a>视频教程</h3><blockquote><p>因为对于服务器新手配置可能过于宽泛与繁琐,录制了视频作为参考。</p></blockquote><iframe src="//player.bilibili.com/player.html?bvid=BV1FdN2zFEoP" allowfullscreen="allowfullscreen" width="100%" height="500" scrolling="no" frameborder="0" sandbox="allow-top-navigation allow-same-origin allow-forms allow-scripts"></iframe><h2 id="效果"><a href="#效果" class="headerlink" title="效果"></a>效果</h2><h3 id="网站测速"><a href="#网站测速" class="headerlink" title="网站测速"></a>网站测速</h3><div class="tag-plugin tabs"id="tab_1"><div class="nav-tabs"><div class="tab active"><a href="#tab_1-1">国内</a></div><div class="tab"><a href="#tab_1-2">海外</a></div></div><div class="tab-content"><div class="tab-pane active" id="tab_1-1"><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1024/1110;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/BLOG-CDN-CN.png" data-src="https://upyun.thatcdn.cn/myself/typora/BLOG-CDN-CN.png" alt="博客国内访问效果" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">博客国内访问效果</span></div></div></div><div class="tab-pane" id="tab_1-2"><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1022/893;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/BLOG-CDN-FEIGN.png" data-src="https://upyun.thatcdn.cn/myself/typora/BLOG-CDN-FEIGN.png" alt="博客海外访问效果" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">博客海外访问效果</span></div></div></div></div></div><h3 id="必应索引"><a href="#必应索引" class="headerlink" title="必应索引"></a>必应索引</h3><p>测试了必应站点地图索引与网站扫描与往常一致。</p><h2 id="局限性与解决思路"><a href="#局限性与解决思路" class="headerlink" title="局限性与解决思路"></a>局限性与解决思路</h2><h3 id="需要自建服务器"><a href="#需要自建服务器" class="headerlink" title="需要自建服务器"></a>需要自建服务器</h3><p>维护成本高于单纯用平台白嫖部署,但可以选择轻量 VPS(1H/1G 足矣)。</p><h3 id="不具备主动健康检查"><a href="#不具备主动健康检查" class="headerlink" title="不具备主动健康检查"></a>不具备主动健康检查</h3><p>负载均衡程序本身不具备主动健康检查功能,无法动态剔除故障节点,只能故障转移。可通过 max_fails + 定时 reload 替代;未来可引入 Lua 脚本来实现。</p><h3 id="SEO问题"><a href="#SEO问题" class="headerlink" title="SEO问题"></a>SEO问题</h3><p>多副本可能被搜索引擎识别为重复,可通过添加 <a href="https://developers.google.cn/search/docs/crawling-indexing/consolidate-duplicate-urls?hl=zh-cn">canonical标签</a>、站点地图等指向源站。(Stellar已实现)</p><h3 id="部分平台行为不一致"><a href="#部分平台行为不一致" class="headerlink" title="部分平台行为不一致"></a>部分平台行为不一致</h3><p>如平台对404错误处理不一致,可添加自定义 404 页面。(Stellar已实现)</p>]]></content>
<summary type="html">多平台负载均衡,增强博客的可用性与可持续白嫖性。</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="部署" scheme="https://log.cns.red/tags/%E9%83%A8%E7%BD%B2/"/>
</entry>
<entry>
<title>浅谈RAG</title>
<link href="https://log.cns.red/ai/RAG/"/>
<id>https://log.cns.red/ai/RAG/</id>
<published>2025-06-15T02:00:00.000Z</published>
<updated>2026-02-13T10:53:03.121Z</updated>
<content type="html"><![CDATA[<blockquote><p>RAG是权衡LLM的发散性与其准确性而诞生的产物</p></blockquote><span id="more"></span><h2 id="为何存在"><a href="#为何存在" class="headerlink" title="为何存在"></a>为何存在</h2><blockquote><p>RAG(Retrieval-Augmented Generation,检索增强生成)</p></blockquote><p><strong>诞生</strong>: 解决基础LLM的三个核心短板:</p><ol><li><strong>知识固化</strong>: 训练数据固定,无法动态更新知识,导致的知识时效性问题。</li><li><strong>知识不足</strong>: 对冷门、专业领域、机密等特性的知识掌握有限。</li><li><strong>事实幻觉</strong>: 生成看似合理但完全虚构的内容,比之更头疼的是混杂性幻觉。</li></ol><p><strong>价值</strong>: 低成本控制基础LLM:</p><ol><li><strong>数据可控</strong>: 将私有数据纳入检索库,避免敏感数据泄露给第三方基础模型。</li><li><strong>引用追溯</strong>: 生成的答案附带检索到的参考文档,方便验证可信度与追溯来源。</li><li><strong>成本效益</strong>: 相比微调大模型,RAG成本降低80%。</li><li><strong>秒级更新</strong>: 允许秒级更新知识(股票、价格),而LLM微调需小时级耗时。</li></ol><h2 id="设计思想"><a href="#设计思想" class="headerlink" title="设计思想"></a>设计思想</h2><blockquote><p>RAG架构更像一位“学者”,在模仿人类认知双系统(快思考/慢思考)。先查阅文献,再写论文,而非仅凭记忆吃老本。</p></blockquote><p>RAG的本质是将信息检索与文本生成结合,通过动态注入外部知识来增强LLM的能力。其核心逻辑是:</p><ul><li><strong>检索阶段</strong>:从海量私有数据中精准筛选与问题相关的片段。</li><li><strong>生成阶段</strong>:LLM基于检索结果生成答案。</li></ul><h2 id="技术实现"><a href="#技术实现" class="headerlink" title="技术实现"></a>技术实现</h2><details class="tag-plugin colorful folding" ><summary><p>RAG技术架构图</p></summary><div class="body"><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1093/2486;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/20250615024811.png" data-src="https://upyun.thatcdn.cn/myself/typora/20250615024811.png" alt="RAG技术架构图" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">RAG技术架构图</span></div></div></div></details><ol><li><strong>准备阶段</strong><ol><li>数据准备: 将私有准备的各类型数据利用分块技术进行切分。</li><li>数据向量化: 用嵌入模型将分块向量化。</li><li>数据落盘: 向量存入向量数据库,建立高效检索索引。</li></ol></li><li><strong>检索阶段</strong><ol><li>用户输入问题 → 转换为Embedding → 在向量库中搜索Top-K相似片段。</li><li>结合多模块检索、多跳检索、重排序、BM25等技术,提升召回的准确率。</li></ol></li><li><strong>生成阶段</strong><ol><li>将检索到的文档片段作为上下文,与用户问题一起输入LLM。</li><li>调优Prompt限制LLM发散性提高准确性。</li></ol></li></ol><h2 id="企业落地"><a href="#企业落地" class="headerlink" title="企业落地"></a>企业落地</h2><ul><li>三甲医院:智能医患问答系统<ul><li><strong>实施方</strong>: 深圳市第三人民医院</li><li><strong>方案</strong>: DeepSeek+RAG动态增强系统,知识来源300万条临床数据与52万字院内指南</li><li><strong>效果</strong>: 患者满意度97%,节省医生73小时/月(截至2025年2月)</li><li><strong>来源</strong>: <a href="https://mp.weixin.qq.com/s?__biz=MjM5MDUxNTA5MQ==&mid=2247513651&idx=3&sn=913b40e6ee6daf62c4c0b747cf1d71bd">深圳三院AI健康管家搭载DeepSeek,秒级响应守护您的健康</a></li></ul></li><li>测绘院:实景三维知识库<ul><li><strong>实施方</strong>: 湖南省自然资源厅直属单位</li><li><strong>方案</strong>: 基于DeepSeek大模型+RAG构建专业地理信息知识库</li><li><strong>效果</strong>: 提升测绘数据语义化检索效率,支持智能解析</li><li><strong>来源</strong>: <a href="https://zrzyt.hunan.gov.cn/zrzyt/xxgk/gzdt/gzxx_1/202503/t20250311_33609284.html">省第一测绘院开展实景三维知识库建设与应用培训 - 湖南省自然资源厅</a></li></ul></li><li>华夏银行:数字金融风控系统<ul><li><strong>实施方</strong>:全国性股份制商业银行</li><li><strong>方案</strong>:RAG+Agent技术实现企业注销风险预测模型</li><li><strong>效果</strong>:自动化构建判别式AI模型,提升风控效率</li><li><strong>来源</strong>:<a href="https://mp.weixin.qq.com/s?__biz=MzA3MDQ5NDYzMA==&mid=2650272694&idx=1&sn=10ce5e5c88065a7a273314f7ef36127c">华夏银行吴永飞等:大语言模型打开银行数字金融发展新思路</a></li></ul></li></ul><h2 id="未来思考"><a href="#未来思考" class="headerlink" title="未来思考"></a>未来思考</h2><p>既然开头说了是权衡的产物,那么发散性与准确性的平衡被打破时,RAG必将面临一个退位局面。</p><p>当LLM或者说另一种新的M突破知识固化与幻觉瓶颈时,RAG的“检索增强”功能可能逐渐隐入幕后,很多维护的LLM增强型RAG可能失去其存在的意义。</p><p>当然我没看空RAG,秒级更新与数据可控是无法替代。</p><p>在我看来未来王者退位,但荣光依旧。RAG不再以“独立技术”存在,但其设计思想会融入LLM架构,形成更智能的自我检索机制,成为LLM的“标准”之一。RAG不是过渡技术,而是人机协作的范式,RAG永远是LLM的移动硬盘。</p>]]></content>
<summary type="html">权衡LLM的发散性与其准确性之后的产物</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="AI" scheme="https://log.cns.red/tags/AI/"/>
<category term="RAG" scheme="https://log.cns.red/tags/RAG/"/>
</entry>
<entry>
<title>Web技术构建桌面应用方案</title>
<link href="https://log.cns.red/Web-Package/"/>
<id>https://log.cns.red/Web-Package/</id>
<published>2025-06-08T02:00:00.000Z</published>
<updated>2026-02-13T10:53:03.121Z</updated>
<content type="html"><![CDATA[<h2 id="桌面应用方案"><a href="#桌面应用方案" class="headerlink" title="桌面应用方案"></a>桌面应用方案</h2><blockquote><p>从Electron、Tauri、Flutter、pkg四个方案比较,打包复杂度中小web项目为例(vue构建结果10MB左右)</p></blockquote><p><strong>下表列出 Electron、Tauri、Flutter、pkg 四种方案在关键变量下的特性对比:</strong></p><table><thead><tr><th>抉择变量</th><th>Electron</th><th>Tauri</th><th>Flutter</th><th>pkg</th></tr></thead><tbody><tr><td><strong>支持平台</strong></td><td>Windows/macOS/Linux (跨平台)</td><td>Windows/macOS/Linux (跨平台)</td><td>Windows/macOS/Linux(跨平台)</td><td>Windows/macOS/Linux(依赖Node)</td></tr><tr><td><strong>启动速度</strong></td><td>较慢(典型示例约4秒)</td><td>较快(示例约2秒)</td><td>一般(取决于硬件,Dart AOT编译)</td><td>较快(纯Node环境,无浏览器启动开销)</td></tr><tr><td><strong>内存占用</strong></td><td>较高(空闲时约100MB+)</td><td>较低(空闲时约80MB)</td><td>低(简单应用约25MB)</td><td>较低(无UI时几十MB,不含浏览器进程)</td></tr><tr><td><strong>CPU负载</strong></td><td>较高(多进程架构、Chromium开销)</td><td>较低(Rust后端+系统WebView)</td><td>低(编译为原生码、使用GPU加速)</td><td>低(单进程Node,轻量运行)</td></tr><tr><td><strong>打包体积</strong></td><td>较大(包含Chromium+Node,例如示例约244MB)</td><td>很小(示例约8.6MB)</td><td>中等(包含Flutter引擎,通常几十MB)</td><td>中等(包含Node运行时,几十MB)</td></tr><tr><td><strong>内置运行时</strong></td><td>内置 Chromium 和 Node.js</td><td>不内置Node.js,使用系统 WebView 引擎</td><td>内置 Dart VM(编译为本地二进制)</td><td>内置 Node.js 运行时</td></tr><tr><td><strong>运行环境依赖</strong></td><td>无需额外环境(Chromium已打包)</td><td>需要目标系统提供对应 WebView(Win: WebView2;Linux/Mac: WebKit)</td><td>需要目标系统对应的图形库和编译环境</td><td>无需预装Node.js(运行时已包含在可执行文件中)</td></tr><tr><td><strong>构建资源需求</strong></td><td>中等(需要安装Node依赖,下载Electron二进制)</td><td>较高(需要安装Rust工具链,首次编译耗时较长)</td><td>较高(需安装Flutter SDK及桌面支持工具)</td><td>较低(仅需Node环境和pkg工具)</td></tr><tr><td><strong>前端兼容性</strong></td><td>完全支持任意Web前端(Vue、React等)</td><td>完全支持任意Web前端(Vue、React等)</td><td>不使用HTML/JS,仅支持Flutter/Dart组件</td><td>无原生前端,仅打包Node后台逻辑,不自带GUI</td></tr><tr><td><strong>原生功能集成</strong></td><td>丰富的Electron API(窗口、托盘、通知等)+Node插件支持</td><td>提供Rust后端API和插件(窗口、文件系统、托盘等,需显式暴露)</td><td>通过插件或平台通道访问原生(文件系统、窗口管理、托盘可用第三方库)</td><td>受限于Node能力,可调用系统命令或Node模块,通常用于CLI或后台逻辑</td></tr><tr><td><strong>安全性</strong></td><td>中等(默认开启Node集成会增大风险;需严格启用Context Isolation等安全策略)</td><td>高(默认安全模型严格,需要显式暴露API;Rust内存安全)</td><td>良好(编译为原生,可执行文件难以反编译,但需自行管理应用权限)</td><td>中等(打包后源代码不可见,有一定保护;无内置更新机制)</td></tr><tr><td><strong>适用场景</strong></td><td>适合快速开发的跨平台富GUI应用,如桌面客户端工具、大型桌面应用</td><td>适合对包体积和性能敏感的桌面应用,如小型工具、系统实用程序、高安全性需求的应用</td><td>适合需要高性能UI和动画交互的应用,如游戏、多媒体应用或跨移动+桌面项目</td><td>适合命令行工具或后台常驻程序,如自动化脚本、本地服务器等(不依赖图形界面)</td></tr></tbody></table><p><strong>注:</strong><br>上表中的性能数据和包体积等来自 <a href="https://www.gethopp.app/blog/tauri-vs-electron">公开基准测试</a></p><h2 id="简要分析"><a href="#简要分析" class="headerlink" title="简要分析"></a>简要分析</h2><h3 id="Electron"><a href="#Electron" class="headerlink" title="Electron"></a>Electron</h3><p>Electron 基于 Chromium 及 Node.js 运行时,支持 Windows、macOS、Linux 三大平台。它将 Web 应用封装为桌面应用,对于习惯 Web 开发的程序员来说,上手简单,功能强大。<br>性能方面:启动时间通常在几秒左右,内存与 CPU 消耗较高——Windows 下测试显示空闲状态约消耗 120 MB 内存。<br>包体积方面:整合 Chromium 和 Node,使得最终体积通常为几百 MB。<br>依赖方式:打包时已将所有运行时一并内置,终端用户无需额外配置。<br>前端兼容性:可任意使用 Vue、React 等现代框架。<br>原生接口:提供如窗口控制、系统托盘、文件访问、通知等丰富 API,并可直接使用 Node 模块。<br>安全性:默认允许主进程完全访问 Node,会带来潜在风险——推荐启用 contextIsolation、预加载脚本等安全策略。<br>更新机制:常见方案为 electron-updater 与 GitHub Releases 的结合,实现自动更新。</p><p>总结:生态成熟、开发快速,适合需要大量 Web 交互、复杂界面的大型应用,但在包体体积和运行效率上存在较大代价。</p><h3 id="Tauri"><a href="#Tauri" class="headerlink" title="Tauri"></a>Tauri</h3><p>Tauri 后端采用 Rust,界面部分使用操作系统自带的 WebView(例如 Win 的 WebView2,Linux/macOS 的 WebKit),实现了小巧和高效的目标。<br>性能方面:启动速度快,实测约 2 秒;Windows 空闲内存约 80 MB,多窗口下整体占用约 170 MB。<br>包体积方面:经测试仅约 8.6 MiB。<br>依赖方式:最终需要系统中预装相应 WebView 运行时。<br>开发成本:需要安装 Rust 工具链,首次编译时间较长,但后续增量编译迅速。<br>兼容性:支持 Vue、React 等任意 Web 技术。<br>原生接口:提供可控的 Rust 插件体系,默认不开放危险 API,提高安全性。<br>安全性:默认启用 CSP 和权限许可机制,攻击面极小。<br>更新机制:可内置轻量自更新模块,结合 JSON、HTTP 等方案完成。</p><p>总结:安全、高效、体积极小,适合轻量型或系统级工具,但对 Rust 生态掌握有所要求,社区相对年轻。</p><h3 id="Flutter"><a href="#Flutter" class="headerlink" title="Flutter"></a>Flutter</h3><p>Flutter 使用 Dart 编写,通过自带的 Skia 渲染引擎生成原生界面。桌面支持 Windows、macOS、Linux。<br>性能方面:编译为本地执行码,UI 流畅,GPU 加速友好。Linux 测试显示,轻量应用占用约 25 MB 内存、50 MB 磁盘,比 Electron 更轻量。<br>包体积方面:需打包 Flutter 引擎,“Hello World” 即超过 50 MB。<br>依赖方式:需要目标平台对应的 Flutter 运行库及工具链支持。<br>开发成本:需安装 Flutter SDK 和桌面构建插件,环境搭建稍重。<br>兼容性:不得使用 JS 框架,必须采用 Dart + Flutter。<br>原生接口:通过 plugin 或 platform channels,可调系统功能。<br>安全性:源码被编译,逆向相对困难;但需自行实现更新机制。</p><p>总结:适合 UI 复杂、动画丰富、还需移动+iOS支持的项目,不建议用于简单工具或仅 Web 前端项目。</p><h3 id="pkg"><a href="#pkg" class="headerlink" title="pkg"></a>pkg</h3><p>pkg 将 Node.js 应用打包为可执行文件的工具,支持 Windows/macOS/Linux。<br>性能方面:性能接近原生 Node.js,启动迅速。<br>包体积方面:包含 Node 运行时,体积几十至一百多 MB,介于脚本与 Electron 之间。<br>依赖方式:无需用户预装 Node 环境。<br>开发成本:仅需 Node/npm 环境,配置简单。<br>兼容性:不支持 GUI,适用于 CLI 或后台业务。<br>功能支持:可调用任意 Node 模块与系统命令,适合自动化、脚本工具。<br>安全性:源码被打包,具基本保护,但无自动更新机制。</p><p>总结:最适合命令行工具、本地后台服务等无需前端的项目,不支持桌面 GUI。</p><h2 id="基于项目类型推荐"><a href="#基于项目类型推荐" class="headerlink" title="基于项目类型推荐"></a>基于项目类型推荐</h2><ul><li><strong>Electron</strong>:适合需要快速开发、依赖丰富Web生态的大型跨平台桌面应用(如聊天客户端、IDE、管理工具等)。对于开发者熟悉Web栈的项目,Electron可实现复杂功能,但会带来较大包体和运行时开销。</li><li><strong>Tauri</strong>:适合对应用体积、安全和性能敏感的场景,如系统实用工具、轻量级编辑器或企业级安全应用。Tauri 能制作极小的可执行文件,运行内存低,并内建安全策略。</li><li><strong>Flutter</strong>:适合对UI/动画要求高的应用,如图形化界面、游戏、多媒体工具或需要同时覆盖移动和桌面的项目。Flutter 的原生性能强劲,但包体较大,且开发需使用 Dart 生态。</li><li><strong>pkg</strong>:适合纯后端或命令行型工具(无需GUI),例如自动化脚本、CLI工具和后台服务。它可以打包Node应用为独立可执行文件,方便分发和部署。</li></ul><blockquote><p>除非真的只打包基于node的工具类,不轻易推荐 pkg. 非特定Dart编写, 即正常web项目推荐rust驱动的tauri打包构建. 亲身经验在占用和丝滑度来说尚佳!</p></blockquote><h2 id="构建参考"><a href="#构建参考" class="headerlink" title="构建参考"></a>构建参考</h2><blockquote><p>贴几个参考,然后再补构建参考教程嘞,又是拖更的一天</p></blockquote><div class="tag-plugin link dis-select"><a class="link-card plain" title="Electron" href="https://www.mryunwei.com/379116.html" target="_blank" rel="external nofollow noopener noreferrer" cardlink autofill="icon,desc"><div class="left"><span class="title">Electron</span><span class="cap link footnote">https://www.mryunwei.com/379116.html</span></div><div class="right"><div class="lazy img" data-bg="https://gcore.jsdelivr.net/gh/cdn-x/placeholder@1.0.12/link/8f277b4ee0ecd.svg"></div></div></a></div><div class="tag-plugin link dis-select"><a class="link-card plain" title="Tauri" href="https://segmentfault.com/a/1190000046020931" target="_blank" rel="external nofollow noopener noreferrer" cardlink autofill="icon,desc"><div class="left"><span class="title">Tauri</span><span class="cap link footnote">https://segmentfault.com/a/1190000046020931</span></div><div class="right"><div class="lazy img" data-bg="https://gcore.jsdelivr.net/gh/cdn-x/placeholder@1.0.12/link/8f277b4ee0ecd.svg"></div></div></a></div><div class="tag-plugin link dis-select"><a class="link-card plain" title="Flutter" href="https://docs.flutter.cn/reference/learning-resources" target="_blank" rel="external nofollow noopener noreferrer" cardlink autofill="icon,desc"><div class="left"><span class="title">Flutter</span><span class="cap link footnote">https://docs.flutter.cn/reference/learning-resources</span></div><div class="right"><div class="lazy img" data-bg="https://gcore.jsdelivr.net/gh/cdn-x/placeholder@1.0.12/link/8f277b4ee0ecd.svg"></div></div></a></div>]]></content>
<summary type="html">Electron、Tauri、Flutter、pkg...抉择方案</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="web" scheme="https://log.cns.red/tags/web/"/>
<category term="构建" scheme="https://log.cns.red/tags/%E6%9E%84%E5%BB%BA/"/>
<category term="桌面应用" scheme="https://log.cns.red/tags/%E6%A1%8C%E9%9D%A2%E5%BA%94%E7%94%A8/"/>
</entry>
<entry>
<title>Spring WebSocket 错误</title>
<link href="https://log.cns.red/bug/spring-websocket-bug/"/>
<id>https://log.cns.red/bug/spring-websocket-bug/</id>
<published>2025-06-07T08:10:00.000Z</published>
<updated>2026-02-13T10:53:03.123Z</updated>
<content type="html"><![CDATA[<h2 id="1-JSR-356-容器握手失败"><a href="#1-JSR-356-容器握手失败" class="headerlink" title="1. JSR-356 容器握手失败"></a>1. JSR-356 容器握手失败</h2><p><strong>现象</strong></p><ul><li>控制台没有任何 <code>afterConnectionEstablished</code> 日志</li><li>客户端卡在握手阶段或报错超时</li></ul><p><strong>原因</strong></p><ul><li>Spring MVC 默认用 <code>StandardWebSocketClient()</code>,但未指定底层 <code>WebSocketContainer</code></li><li>嵌入式 Tomcat(JSR-356 实现)与默认容器不匹配,导致握手不发起</li></ul><p><strong>排查 & 解决</strong></p><ol><li><p>显式注入 JSR-356 容器:</p> <figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-meta">@Bean</span><br><span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">webSocketClient</span><span class="hljs-params">()</span></span>: WebSocketClient {<br> <span class="hljs-keyword">val</span> container = ContainerProvider.getWebSocketContainer()<br> <span class="hljs-keyword">return</span> StandardWebSocketClient(container)<br>}<br></code></pre></td></tr></table></figure></li><li><p>或干脆切换到 Reactor Netty 客户端:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-meta">@Bean</span><br><span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">webSocketClient</span><span class="hljs-params">()</span></span>: WebSocketClient {<br> <span class="hljs-keyword">return</span> ReactorNettyWebSocketClient()<br>}<br></code></pre></td></tr></table></figure></li><li><p>启动时应看到类似日志:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">Downstream connection established for session: <id><br></code></pre></td></tr></table></figure></li><li><p>实在不行web等依赖都排除tomcat,自行在web依赖加上exclusions:</p> <figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><code class="hljs xml"><span class="hljs-comment"><!-- 1. Spring Boot Web,但排除默认 Tomcat --></span><br><span class="hljs-tag"><<span class="hljs-name">dependency</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">groupId</span>></span>org.springframework.boot<span class="hljs-tag"></<span class="hljs-name">groupId</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">artifactId</span>></span>spring-boot-starter-web<span class="hljs-tag"></<span class="hljs-name">artifactId</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">exclusions</span>></span><br> <span class="hljs-comment"><!-- 排除 Tomcat 嵌入式容器 --></span><br> <span class="hljs-tag"><<span class="hljs-name">exclusion</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">groupId</span>></span>org.springframework.boot<span class="hljs-tag"></<span class="hljs-name">groupId</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">artifactId</span>></span>spring-boot-starter-tomcat<span class="hljs-tag"></<span class="hljs-name">artifactId</span>></span><br> <span class="hljs-tag"></<span class="hljs-name">exclusion</span>></span><br> <span class="hljs-tag"></<span class="hljs-name">exclusions</span>></span><br><span class="hljs-tag"></<span class="hljs-name">dependency</span>></span><br><br><span class="hljs-comment"><!-- 2. 专门引入 Undertow 及其 JSR-356 支持 --></span><br><span class="hljs-tag"><<span class="hljs-name">dependency</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">groupId</span>></span>org.springframework.boot<span class="hljs-tag"></<span class="hljs-name">groupId</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">artifactId</span>></span>spring-boot-starter-undertow<span class="hljs-tag"></<span class="hljs-name">artifactId</span>></span><br><span class="hljs-tag"></<span class="hljs-name">dependency</span>></span><br><span class="hljs-comment"><!-- Undertow 对 JSR-356 WebSocket 的实现 --></span><br><span class="hljs-tag"><<span class="hljs-name">dependency</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">groupId</span>></span>io.undertow<span class="hljs-tag"></<span class="hljs-name">groupId</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">artifactId</span>></span>undertow-websockets-jsr<span class="hljs-tag"></<span class="hljs-name">artifactId</span>></span><br> <span class="hljs-comment"><!-- 可根据 Spring Boot 版本选择合适版本,通常与 Spring Boot 兼容即可 --></span><br><span class="hljs-tag"></<span class="hljs-name">dependency</span>></span><br><br><span class="hljs-comment"><!-- 3. Spring WebSocket 模块,用于控制握手与消息处理 --></span><br><span class="hljs-tag"><<span class="hljs-name">dependency</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">groupId</span>></span>org.springframework.boot<span class="hljs-tag"></<span class="hljs-name">groupId</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">artifactId</span>></span>spring-boot-starter-websocket<span class="hljs-tag"></<span class="hljs-name">artifactId</span>></span><br> <span class="hljs-comment"><!-- 注意:Spring Boot Starter WebSocket 本身会带 Tomcat 的 WebSocket 支持,</span><br><span class="hljs-comment"> 但由于我们已经排除了 Tomcat Starter,这里只会引入 spring-websocket 相关依赖,不会再带 tomcat-embed-websocket --></span><br><span class="hljs-tag"></<span class="hljs-name">dependency</span>></span><br><br></code></pre></td></tr></table></figure></li></ol><h2 id="2-路由没生效"><a href="#2-路由没生效" class="headerlink" title="2. 路由没生效"></a>2. 路由没生效</h2><p><strong>现象</strong></p><ul><li>配置了 <code>WebSocketConfigurer</code>,但 <code>afterConnectionEstablished</code> 并未触发</li><li>日志中不见任何 <code>/your/path</code> 映射信息</li></ul><p><strong>原因</strong></p><ul><li>构造函数注入 <code>List<ISocketHandler></code> 导致循环依赖</li><li>Spring Boot 2.6+ 默认禁用循环引用,Bean 未注册</li><li>或者忘记 <code>@EnableWebSocket</code></li></ul><p><strong>排查 & 解决</strong></p><ol><li><p>确保配置类加上:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-meta">@Configuration</span><br><span class="hljs-meta">@EnableWebSocket</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">WebSocketConfig</span>(<br> <span class="hljs-meta">@Lazy</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> handlers: List<ISocketHandler><br>) : WebSocketConfigurer { … }<br></code></pre></td></tr></table></figure></li><li><p>日志应包含:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">Mapping “[/{registerPath}]” to WebSocketHandler<br></code></pre></td></tr></table></figure></li></ol><h2 id="3-API-弃用与签名变化"><a href="#3-API-弃用与签名变化" class="headerlink" title="3. API 弃用与签名变化"></a>3. API 弃用与签名变化</h2><h3 id="3-1-Unresolved-reference-handshake-execute"><a href="#3-1-Unresolved-reference-handshake-execute" class="headerlink" title="3.1 Unresolved reference: handshake / execute"></a>3.1 Unresolved reference: <code>handshake</code> / <code>execute</code></h3><p><strong>提示</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">Unresolved reference 'handshake'<br>Unresolved reference 'execute'<br></code></pre></td></tr></table></figure><p><strong>解读</strong></p><ul><li><code>StandardWebSocketClient</code> 使用 <code>doHandshake(...)</code> 而非 <code>execute</code></li><li><code>execute()</code> 属于 WebFlux Reactor 客户端,与 MVC 客户端不通</li></ul><p><strong>修正</strong></p><ul><li><p>MVC 客户端:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs kotlin">client.doHandshake(handler, uri)<br> .addCallback({ sess -> … }, { ex -> … })<br></code></pre></td></tr></table></figure></li><li><p>Reactor 客户端:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs kotlin">client.execute(URI.create(uri)) { wsSession -><br> …<br>}.subscribe()<br></code></pre></td></tr></table></figure></li></ul><h3 id="3-2-doHandshake-自-6-0-起弃用"><a href="#3-2-doHandshake-自-6-0-起弃用" class="headerlink" title="3.2 doHandshake(...) 自 6.0 起弃用"></a>3.2 <code>doHandshake(...)</code> 自 6.0 起弃用</h3><p><strong>提示</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">'doHandshake(WebSocketHandler, String, Object...)' 自版本 6.0 起已弃用并标记为移除<br></code></pre></td></tr></table></figure><p><strong>说明</strong></p><ul><li>Spring Framework 6 推荐注入 <code>WebSocketContainer</code> 或切换到 Reactor Netty</li><li>暂可忽略警告,或升级为新版推荐 API</li></ul><h2 id="4-依赖冲突-类加载"><a href="#4-依赖冲突-类加载" class="headerlink" title="4. 依赖冲突 & 类加载"></a>4. 依赖冲突 & 类加载</h2><p><strong>典型报错</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">ClassNotFoundException: javax.websocket.ContainerProvider<br>NoSuchMethodError: jakarta.websocket.ContainerProvider.getWebSocketContainer()<br></code></pre></td></tr></table></figure><p><strong>原因</strong></p><ul><li>Spring Boot 3.x 使用 <code>jakarta.websocket</code>,2.x 使用 <code>javax.websocket</code></li><li>嵌入式 Tomcat/WebSocket API 版本与项目引入冲突</li></ul><p><strong>解决</strong></p><ul><li><p>统一依赖至一个版本,且与 Spring Boot 主版本匹配</p></li><li><p>必要时在依赖中排除冲突项:</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs xml"><span class="hljs-tag"><<span class="hljs-name">dependency</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">groupId</span>></span>org.apache.tomcat.embed<span class="hljs-tag"></<span class="hljs-name">groupId</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">artifactId</span>></span>tomcat-embed-websocket<span class="hljs-tag"></<span class="hljs-name">artifactId</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">exclusions</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">exclusion</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">groupId</span>></span>jakarta.websocket<span class="hljs-tag"></<span class="hljs-name">groupId</span>></span><br> <span class="hljs-tag"><<span class="hljs-name">artifactId</span>></span>jakarta.websocket-api<span class="hljs-tag"></<span class="hljs-name">artifactId</span>></span><br> <span class="hljs-tag"></<span class="hljs-name">exclusion</span>></span><br> <span class="hljs-tag"></<span class="hljs-name">exclusions</span>></span><br><span class="hljs-tag"></<span class="hljs-name">dependency</span>></span><br></code></pre></td></tr></table></figure></li></ul><h2 id="5-其他常见错误"><a href="#5-其他常见错误" class="headerlink" title="5. 其他常见错误"></a>5. 其他常见错误</h2><table><thead><tr><th>错误类型</th><th>典型现象 / 异常</th><th>排查要点</th></tr></thead><tbody><tr><td>循环依赖</td><td><code>BeanCreationException: circular reference</code></td><td>使用 <code>@Lazy</code> 或拆分配置</td></tr><tr><td>无效 JSON</td><td><code>JsonParseException: Unexpected character (‘“’)</code></td><td>前端必须用英文双引号;捕获异常并 friendly 返回</td></tr><tr><td>ConcurrentModificationException</td><td><code>java.util.ConcurrentModificationException</code></td><td>迭代时修改集合;先转成列表再遍历</td></tr><tr><td>NullPointerException</td><td><code>Cannot invoke "JsonNode.asText()" ... get(...)</code></td><td>判空或使用 <code>?.asText(default)</code></td></tr></tbody></table>]]></content>
<summary type="html">多数是由于依赖引发的</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="BUG" scheme="https://log.cns.red/tags/BUG/"/>
</entry>
<entry>
<title>SpringBoot WebSocket 代理模式、客户端模式</title>
<link href="https://log.cns.red/Spring-WebSocket/"/>
<id>https://log.cns.red/Spring-WebSocket/</id>
<published>2025-06-07T08:00:00.000Z</published>
<updated>2026-02-13T10:53:03.115Z</updated>
<content type="html"><![CDATA[<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><ol><li>本文实现 上下游ws的代理功能、客户端发布功能</li><li>开发语言:Spring Boot + Kotlin</li><li>实现方式很多种,这里给出接口代码是思路,可以改 <code>@ServerEndpoint</code> 托管实现</li></ol><h2 id="代理模式"><a href="#代理模式" class="headerlink" title="代理模式"></a>代理模式</h2><blockquote><p>用户连接为上游,被代理地址为下游。</p></blockquote><ol><li>劫持控制修改上下游消息内容</li><li>对上游进行鉴权</li></ol><h3 id="时序设计"><a href="#时序设计" class="headerlink" title="时序设计"></a>时序设计</h3><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:4094/2968;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/IWebSocketProxier.png" data-src="https://upyun.thatcdn.cn/myself/typora/IWebSocketProxier.png" alt="IWebSocketProxier时序图" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">IWebSocketProxier时序图</span></div></div><figure class="highlight text"><figcaption><span>时序代码</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br></pre></td><td class="code"><pre><code class="hljs text">sequenceDiagram<br> participant UpClient as 上游客户端<br> participant Proxier as IWebSocketProxier<br> participant DownClient as 下游服务<br> participant Scheduler2 as 清理线程<br><br> Note over UpClient,Proxier: 1. 上游连接建立<br> UpClient->>Proxier: WebSocket 握手<br> activate Proxier<br> Proxier-->>Proxier: afterConnectionEstablished(session)<br> Proxier-->>Proxier: sessions[session.id] = WebSocketProxySession(...)<br> Proxier-->>Proxier: onUpstreamOpen(proxy)<br> deactivate Proxier<br><br> Note over UpClient,Proxier: 2. 上游首条消息(授权)<br> UpClient->>Proxier: TextMessage(首次消息)<br> activate Proxier<br> Proxier-->>Proxier: handleMessage(session, message)<br> Proxier-->>Proxier: onUpstreamFirstMessage(proxy, message)<br> alt 授权失败<br> Proxier-->>UpClient: sendMessage(授权失败通知)<br> Proxier-->>Proxier: closeSession(session.id)<br> else 授权成功<br> Proxier-->>Proxier: proxy.authorized = true<br> Proxier-->>Proxier: onAuthSuccess(proxy)<br> Proxier-->>Proxier: connectDownstream(session.id)<br> Proxier-->>Proxier: downstreamContexts[session.id].pending.offer(clone(message))<br> end<br> deactivate Proxier<br><br> Note over UpClient,Proxier: 3. 上游后续消息<br> UpClient->>Proxier: TextMessage(后续消息)<br> activate Proxier<br> Proxier-->>Proxier: handleMessage<br> alt !downConnected<br> Proxier-->>Proxier: pending.offer(clone(message))<br> else 已连接下游<br> Proxier-->>DownClient: sendToDownstream(transformUpstream(message))<br> end<br> deactivate Proxier<br><br> Note over DownClient,Proxier: 4. 下游连接建立完成<br> DownClient->>Proxier: 握手完成<br> activate Proxier<br> Proxier-->>Proxier: ctx.downConnected = true<br> Proxier-->>Proxier: flush pending → sendToDownstream(...)<br> deactivate Proxier<br><br> Note over DownClient,Proxier: 5. 下游消息回传<br> DownClient->>Proxier: TextMessage(下游响应)<br> activate Proxier<br> Proxier-->>UpClient: proxy.session.sendMessage(transformDownstream(msg))<br> deactivate Proxier<br><br> Note over Scheduler2,Proxier: 6. 会话超时自动清理<br> Scheduler2->>Proxier: cleanupExpired()<br> activate Proxier<br> Proxier-->>Proxier: closeSession(超时 session.id)<br> deactivate Proxier<br><br> Note over UpClient,Proxier: 7. 上游主动关闭<br> UpClient->>Proxier: closeConnection<br> activate Proxier<br> Proxier-->>Proxier: afterConnectionClosed(session, status)<br> Proxier-->>Proxier: closeSession(session.id)<br> deactivate Proxier<br></code></pre></td></tr></table></figure><h3 id="接口代码"><a href="#接口代码" class="headerlink" title="接口代码"></a>接口代码</h3><figure class="highlight kotlin"><figcaption><span>IWebSocketProxier</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br><span class="line">142</span><br><span class="line">143</span><br><span class="line">144</span><br><span class="line">145</span><br><span class="line">146</span><br><span class="line">147</span><br><span class="line">148</span><br><span class="line">149</span><br><span class="line">150</span><br><span class="line">151</span><br><span class="line">152</span><br><span class="line">153</span><br><span class="line">154</span><br><span class="line">155</span><br><span class="line">156</span><br><span class="line">157</span><br><span class="line">158</span><br><span class="line">159</span><br><span class="line">160</span><br><span class="line">161</span><br><span class="line">162</span><br><span class="line">163</span><br><span class="line">164</span><br><span class="line">165</span><br><span class="line">166</span><br><span class="line">167</span><br><span class="line">168</span><br><span class="line">169</span><br><span class="line">170</span><br><span class="line">171</span><br><span class="line">172</span><br><span class="line">173</span><br><span class="line">174</span><br><span class="line">175</span><br><span class="line">176</span><br><span class="line">177</span><br><span class="line">178</span><br><span class="line">179</span><br><span class="line">180</span><br><span class="line">181</span><br><span class="line">182</span><br><span class="line">183</span><br><span class="line">184</span><br><span class="line">185</span><br><span class="line">186</span><br><span class="line">187</span><br><span class="line">188</span><br><span class="line">189</span><br><span class="line">190</span><br><span class="line">191</span><br><span class="line">192</span><br><span class="line">193</span><br><span class="line">194</span><br><span class="line">195</span><br><span class="line">196</span><br><span class="line">197</span><br><span class="line">198</span><br><span class="line">199</span><br><span class="line">200</span><br><span class="line">201</span><br><span class="line">202</span><br><span class="line">203</span><br><span class="line">204</span><br><span class="line">205</span><br><span class="line">206</span><br><span class="line">207</span><br><span class="line">208</span><br><span class="line">209</span><br><span class="line">210</span><br><span class="line">211</span><br><span class="line">212</span><br><span class="line">213</span><br><span class="line">214</span><br><span class="line">215</span><br><span class="line">216</span><br><span class="line">217</span><br><span class="line">218</span><br><span class="line">219</span><br><span class="line">220</span><br><span class="line">221</span><br><span class="line">222</span><br><span class="line">223</span><br><span class="line">224</span><br><span class="line">225</span><br><span class="line">226</span><br><span class="line">227</span><br><span class="line">228</span><br><span class="line">229</span><br><span class="line">230</span><br><span class="line">231</span><br><span class="line">232</span><br><span class="line">233</span><br><span class="line">234</span><br><span class="line">235</span><br><span class="line">236</span><br><span class="line">237</span><br><span class="line">238</span><br><span class="line">239</span><br><span class="line">240</span><br><span class="line">241</span><br><span class="line">242</span><br><span class="line">243</span><br><span class="line">244</span><br><span class="line">245</span><br><span class="line">246</span><br><span class="line">247</span><br><span class="line">248</span><br><span class="line">249</span><br><span class="line">250</span><br><span class="line">251</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-keyword">import</span> com.fasterxml.jackson.databind.ObjectMapper<br><span class="hljs-keyword">import</span> org.slf4j.LoggerFactory<br><span class="hljs-keyword">import</span> org.springframework.web.socket.*<br><span class="hljs-keyword">import</span> org.springframework.web.socket.client.WebSocketClient<br><span class="hljs-keyword">import</span> org.springframework.web.socket.handler.AbstractWebSocketHandler<br><span class="hljs-keyword">import</span> java.util.concurrent.*<br><span class="hljs-keyword">import</span> java.util.concurrent.atomic.AtomicBoolean<br><span class="hljs-keyword">import</span> java.util.concurrent.atomic.AtomicInteger<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 包装上游会话及其状态,用于管理授权和心跳</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> session WebSocket 上游会话</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> authorized 是否已通过授权验证</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> downConnected 下游连接是否已建立</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> lastHeartbeat 最近心跳时间戳(毫秒)</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">data</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">WebSocketProxySession</span>(<br> <span class="hljs-keyword">val</span> session: WebSocketSession,<br> <span class="hljs-keyword">var</span> authorized: <span class="hljs-built_in">Boolean</span> = <span class="hljs-literal">false</span>,<br> <span class="hljs-keyword">var</span> downConnected: <span class="hljs-built_in">Boolean</span> = <span class="hljs-literal">false</span>,<br> <span class="hljs-keyword">var</span> lastHeartbeat: <span class="hljs-built_in">Long</span> = System.currentTimeMillis()<br>)<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 通用 WebSocket 代理抽象类</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * 负责管理上游和下游的连接生命周期、消息转发以及超时清理</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * 使用方式:</span><br><span class="hljs-comment"> * 1. 实现核心抽象方法:</span><br><span class="hljs-comment"> * - registerPath: 定义代理路由</span><br><span class="hljs-comment"> * - onUpstreamFirstMessage: 处理上游首条消息并进行授权</span><br><span class="hljs-comment"> * - downstreamUri: 获取下游 URI</span><br><span class="hljs-comment"> * - transformUpstream: 上游→下游 转换逻辑</span><br><span class="hljs-comment"> * - transformDownstream: 下游→上游 转换逻辑</span><br><span class="hljs-comment"> * 2. 可选覆盖钩子:</span><br><span class="hljs-comment"> * - onUpstreamOpen: 上游连接初始化</span><br><span class="hljs-comment"> * - onAuthSuccess: 授权成功回调</span><br><span class="hljs-comment"> * - onUpstreamFirstMessageIsNull: 授权失败处理</span><br><span class="hljs-comment"> * - onSessionClosed: 会话关闭后处理</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> objectMapper 用于 JSON 序列化/反序列化</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> client WebSocket 客户端,用于建立下游连接</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@author</span> ThatCoder</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">abstract</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">IWebSocketProxier</span>(<br> <span class="hljs-keyword">val</span> objectMapper: ObjectMapper,<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> client: WebSocketClient<br>) : AbstractWebSocketHandler() {<br> <span class="hljs-comment">/** 代理接入路径 */</span><br> <span class="hljs-keyword">abstract</span> <span class="hljs-keyword">val</span> registerPath: String<br><br> <span class="hljs-comment">/** 会话超时时间,默认 10 分钟 */</span><br> <span class="hljs-keyword">open</span> <span class="hljs-keyword">val</span> sessionTimeoutMillis: <span class="hljs-built_in">Long</span> = <span class="hljs-number">10</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span><br><br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> logger = LoggerFactory.getLogger(<span class="hljs-keyword">this</span>::<span class="hljs-keyword">class</span>.java)<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> sessions = ConcurrentHashMap<String, WebSocketProxySession>()<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> downstreamContexts = ConcurrentHashMap<String, DownstreamContext>()<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> scheduler = Executors.newSingleThreadScheduledExecutor(<br> NamedThreadFactory(<span class="hljs-string">"proxy-session-timeout-"</span>)<br> )<br><br> <span class="hljs-keyword">init</span> {<br> <span class="hljs-comment">// 定期清理超时会话</span><br> scheduler.scheduleAtFixedRate(<br> { cleanupExpired() },<br> sessionTimeoutMillis,<br> sessionTimeoutMillis,<br> TimeUnit.MILLISECONDS<br> )<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">afterConnectionEstablished</span><span class="hljs-params">(session: <span class="hljs-type">WebSocketSession</span>)</span></span> {<br> logger.info(<span class="hljs-string">"Upstream connected: <span class="hljs-subst">${session.id}</span>"</span>)<br> sessions[session.id] = WebSocketProxySession(session)<br> onUpstreamOpen(sessions[session.id]!!)<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">handleMessage</span><span class="hljs-params">(session: <span class="hljs-type">WebSocketSession</span>, message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> {<br> <span class="hljs-keyword">val</span> proxy = sessions[session.id] ?: <span class="hljs-keyword">return</span><br> <span class="hljs-keyword">if</span> (!proxy.authorized) {<br> <span class="hljs-keyword">val</span> ok = onUpstreamFirstMessage(proxy, message)<br> <span class="hljs-keyword">if</span> (!ok) {<br> onUpstreamFirstMessageIsNull(proxy)<br> closeSession(session.id)<br> <span class="hljs-keyword">return</span><br> }<br> proxy.authorized = <span class="hljs-literal">true</span><br> onAuthSuccess(proxy)<br> connectDownstream(session.id)<br> downstreamContexts[session.id]?.pending?.offer(clone(message))<br> <span class="hljs-keyword">return</span><br> }<br> <span class="hljs-keyword">val</span> ctx = downstreamContexts[session.id] ?: <span class="hljs-keyword">return</span><br> <span class="hljs-keyword">if</span> (!ctx.downConnected.<span class="hljs-keyword">get</span>()) {<br> ctx.pending.offer(clone(message))<br> } <span class="hljs-keyword">else</span> {<br> ctx.sendToDownstream(transformUpstream(message))<br> }<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">afterConnectionClosed</span><span class="hljs-params">(session: <span class="hljs-type">WebSocketSession</span>, status: <span class="hljs-type">CloseStatus</span>)</span></span> {<br> logger.info(<span class="hljs-string">"Upstream closed: <span class="hljs-subst">${session.id}</span>"</span>)<br> closeSession(session.id)<br> }<br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 向所有上游会话发送心跳,维持长连接</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">sendHeartbeat</span><span class="hljs-params">()</span></span> {<br> <span class="hljs-keyword">val</span> ping = PingMessage()<br> sessions.values.forEach {<br> <span class="hljs-keyword">try</span> {<br> it.session.sendMessage(ping)<br> } <span class="hljs-keyword">catch</span> (_: Exception) {<br> <span class="hljs-comment">// 忽略发送失败</span><br> }<br> }<br> }<br><br> <span class="hljs-comment">// ---------- 可覆盖钩子 ----------</span><br><br> <span class="hljs-comment">/** 上游连接建立后回调 */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">open</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamOpen</span><span class="hljs-params">(proxy: <span class="hljs-type">WebSocketProxySession</span>)</span></span> = <span class="hljs-built_in">Unit</span><br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 上游首条消息处理并授权</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@return</span> true 表示通过,false 则触发授权失败</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">abstract</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamFirstMessage</span><span class="hljs-params">(</span></span><br><span class="hljs-params"><span class="hljs-function"> proxy: <span class="hljs-type">WebSocketProxySession</span>,</span></span><br><span class="hljs-params"><span class="hljs-function"> message: <span class="hljs-type">WebSocketMessage</span><*></span></span><br><span class="hljs-params"><span class="hljs-function"> )</span></span>: <span class="hljs-built_in">Boolean</span><br><br> <span class="hljs-comment">/** 授权失败发送给上游的消息 */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">open</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamFirstMessageIsNull</span><span class="hljs-params">(proxy: <span class="hljs-type">WebSocketProxySession</span>)</span></span> {<br> <span class="hljs-keyword">val</span> err = mapOf(<span class="hljs-string">"finish"</span> to <span class="hljs-literal">true</span>, <span class="hljs-string">"error"</span> to <span class="hljs-string">"身份认证失败"</span>)<br> proxy.session.sendMessage(TextMessage(objectMapper.writeValueAsString(err)))<br> }<br><br> <span class="hljs-comment">/** 授权成功后回调 */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">open</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onAuthSuccess</span><span class="hljs-params">(proxy: <span class="hljs-type">WebSocketProxySession</span>)</span></span> = <span class="hljs-built_in">Unit</span><br><br> <span class="hljs-comment">/** 根据上游会话获取下游 URI */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">abstract</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">downstreamUri</span><span class="hljs-params">(proxy: <span class="hljs-type">WebSocketProxySession</span>)</span></span>: String<br><br> <span class="hljs-comment">/** 上游→下游 消息转换 */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">abstract</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">transformUpstream</span><span class="hljs-params">(message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span>: WebSocketMessage<*><br><br> <span class="hljs-comment">/** 下游→上游 消息转换 */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">abstract</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">transformDownstream</span><span class="hljs-params">(message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span>: WebSocketMessage<*><br><br> <span class="hljs-comment">/** 会话关闭后回调 */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">open</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onSessionClosed</span><span class="hljs-params">(proxy: <span class="hljs-type">WebSocketProxySession</span>)</span></span> = <span class="hljs-built_in">Unit</span><br><br> <span class="hljs-comment">// ---------- 内部逻辑 ----------</span><br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 建立下游连接,并将后续消息路由到 DownstreamContext</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">connectDownstream</span><span class="hljs-params">(sessionId: <span class="hljs-type">String</span>)</span></span> {<br> <span class="hljs-keyword">val</span> proxy = sessions[sessionId]!!<br> <span class="hljs-keyword">val</span> ctx = DownstreamContext(proxy)<br> downstreamContexts[sessionId] = ctx<br> client.execute(<span class="hljs-keyword">object</span> : AbstractWebSocketHandler() {<br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">afterConnectionEstablished</span><span class="hljs-params">(down: <span class="hljs-type">WebSocketSession</span>)</span></span> {<br> logger.info(<span class="hljs-string">"Downstream connected for: <span class="hljs-variable">$sessionId</span>"</span>)<br> ctx.downConnected.<span class="hljs-keyword">set</span>(<span class="hljs-literal">true</span>)<br> ctx.downstream = down<br> <span class="hljs-keyword">while</span> (<span class="hljs-literal">true</span>) {<br> <span class="hljs-keyword">val</span> msg = ctx.pending.poll() ?: <span class="hljs-keyword">break</span><br> ctx.sendToDownstream(transformUpstream(msg))<br> }<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">handleMessage</span><span class="hljs-params">(down: <span class="hljs-type">WebSocketSession</span>, msg: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> {<br> proxy.session.sendMessage(transformDownstream(msg))<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">afterConnectionClosed</span><span class="hljs-params">(down: <span class="hljs-type">WebSocketSession</span>, status: <span class="hljs-type">CloseStatus</span>)</span></span> {<br> logger.warn(<span class="hljs-string">"Downstream closed early: <span class="hljs-subst">${status.code}</span>"</span>)<br> closeSession(sessionId)<br> }<br> }, downstreamUri(proxy))<br> }<br><br> <span class="hljs-comment">/** 关闭并清理指定会话 */</span><br> <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">closeSession</span><span class="hljs-params">(sessionId: <span class="hljs-type">String</span>)</span></span> {<br> sessions.remove(sessionId)?.also { onSessionClosed(it) }<br> downstreamContexts.remove(sessionId)?.closeAll()<br> }<br><br> <span class="hljs-comment">/** 清理超时会话 */</span><br> <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">cleanupExpired</span><span class="hljs-params">()</span></span> {<br> <span class="hljs-keyword">val</span> now = System.currentTimeMillis()<br> sessions.entries<br> .filter { now - it.value.lastHeartbeat > sessionTimeoutMillis }<br> .forEach { closeSession(it.key) }<br> }<br><br> <span class="hljs-comment">/** 克隆消息以避免并发问题 */</span><br> <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">clone</span><span class="hljs-params">(msg: <span class="hljs-type">WebSocketMessage</span><*>)</span></span>: WebSocketMessage<*> = <span class="hljs-keyword">when</span> (msg) {<br> <span class="hljs-keyword">is</span> TextMessage -> TextMessage(msg.payload)<br> <span class="hljs-keyword">is</span> BinaryMessage -> BinaryMessage(msg.payload.asReadOnlyBuffer())<br> <span class="hljs-keyword">else</span> -> msg<br> }<br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 管理下游消息发送及队列</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">DownstreamContext</span>(proxy: WebSocketProxySession) {<br> <span class="hljs-meta">@Volatile</span> <span class="hljs-keyword">var</span> downstream: WebSocketSession? = <span class="hljs-literal">null</span><br> <span class="hljs-keyword">val</span> downConnected = AtomicBoolean(<span class="hljs-literal">false</span>)<br> <span class="hljs-keyword">val</span> pending = ConcurrentLinkedQueue<WebSocketMessage<*>>()<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> executor: ExecutorService = ThreadPoolExecutor(<br> <span class="hljs-number">4</span>, <span class="hljs-number">16</span>, <span class="hljs-number">60</span>, TimeUnit.SECONDS,<br> LinkedBlockingQueue(<span class="hljs-number">1000</span>),<br> NamedThreadFactory(<span class="hljs-string">"proxy-send-<span class="hljs-subst">${proxy.session.id}</span>"</span>)<br> )<br><br> <span class="hljs-comment">/** 将消息异步发送到下游 */</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">sendToDownstream</span><span class="hljs-params">(msg: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> {<br> executor.execute {<br> <span class="hljs-keyword">try</span> {<br> downstream?.sendMessage(msg)<br> } <span class="hljs-keyword">catch</span> (e: Exception) {<br> LoggerFactory.getLogger(<span class="hljs-string">"DownstreamLogger"</span>).error(<span class="hljs-string">"Send downstream failed"</span>, e)<br> }<br> }<br> }<br><br> <span class="hljs-comment">/** 关闭下游并清理资源 */</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">closeAll</span><span class="hljs-params">()</span></span> {<br> <span class="hljs-keyword">try</span> {<br> downstream?.close()<br> } <span class="hljs-keyword">catch</span> (_: Exception) {<br> }<br> executor.shutdownNow()<br> pending.clear()<br> }<br> }<br><br> <span class="hljs-comment">/** 为线程池生成可读性线程名 */</span><br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">NamedThreadFactory</span>(prefix: String) : ThreadFactory {<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> cnt = AtomicInteger(<span class="hljs-number">1</span>)<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> name = <span class="hljs-string">"<span class="hljs-subst">${prefix}</span>-<span class="hljs-subst">${cnt.getAndIncrement()}</span>"</span><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">newThread</span><span class="hljs-params">(r: <span class="hljs-type">Runnable</span>)</span></span> = Thread(r, name)<br> }<br>}<br><br></code></pre></td></tr></table></figure><h3 id="实现示例"><a href="#实现示例" class="headerlink" title="实现示例"></a>实现示例</h3><blockquote><p>以代理 FunAsr 为例,统一上下游的消息类型,对上游进行身份权限认证</p></blockquote><figure class="highlight kotlin"><figcaption><span>AsrProxier</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-keyword">import</span> com.bidr.waterx.transpond.config.extension.fieldJust<br><span class="hljs-keyword">import</span> com.bidr.waterx.transpond.config.extension.fieldRemove<br><span class="hljs-keyword">import</span> com.bidr.waterx.transpond.config.extension.fieldRename<br><span class="hljs-keyword">import</span> com.bidr.waterx.transpond.config.extension.putMap<br><span class="hljs-keyword">import</span> com.bidr.waterx.transpond.config.extension.toObjectNode<br><span class="hljs-keyword">import</span> com.bidr.waterx.transpond.config.extension.toTextMessage<br><span class="hljs-keyword">import</span> com.fasterxml.jackson.databind.ObjectMapper<br><span class="hljs-keyword">import</span> com.fasterxml.jackson.databind.node.ArrayNode<br><span class="hljs-keyword">import</span> org.slf4j.LoggerFactory<br><span class="hljs-keyword">import</span> org.springframework.stereotype.Component<br><span class="hljs-keyword">import</span> org.springframework.web.socket.CloseStatus<br><span class="hljs-keyword">import</span> org.springframework.web.socket.TextMessage<br><span class="hljs-keyword">import</span> org.springframework.web.socket.WebSocketMessage<br><span class="hljs-keyword">import</span> org.springframework.web.socket.client.WebSocketClient<br><br><span class="hljs-comment">/** ASR 代理实现 */</span><br><span class="hljs-meta">@Component</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">AsrProxier</span>(<br> objectMapper: ObjectMapper,<br> webSocketClient: WebSocketClient,<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> akService: IAKService<br>) : IWebSocketProxier(objectMapper, webSocketClient) {<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> paramProxy = mapOf(<br> <span class="hljs-string">"id"</span> to <span class="hljs-string">"wav_name"</span>,<br> <span class="hljs-string">"finish"</span> to <span class="hljs-string">"is_speaking"</span>,<br> <span class="hljs-string">"answer"</span> to <span class="hljs-string">"text"</span><br> )<br><br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> logger = LoggerFactory.getLogger(<span class="hljs-keyword">this</span>::<span class="hljs-keyword">class</span>.java)<br><br> <span class="hljs-keyword">override</span> <span class="hljs-keyword">val</span> registerPath = <span class="hljs-string">"/ws/asr"</span><br><br> <span class="hljs-comment">// 鉴权服务</span><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamFirstMessage</span><span class="hljs-params">(proxy: <span class="hljs-type">WebSocketProxySession</span>, message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span>: <span class="hljs-built_in">Boolean</span> {<br> <span class="hljs-keyword">val</span> node = message.toObjectNode(objectMapper) ?: <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span><br> <span class="hljs-keyword">val</span> ak = node.<span class="hljs-keyword">get</span>(<span class="hljs-string">"ak"</span>)?.asText() ?: <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span><br> <span class="hljs-keyword">return</span> akService.check(ak)<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">downstreamUri</span><span class="hljs-params">(proxy: <span class="hljs-type">WebSocketProxySession</span>)</span></span> = <span class="hljs-string">"ws://localhost:10095"</span><br><br> <span class="hljs-comment">// 处理上游消息适配成FUNASR接收类型</span><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">transformUpstream</span><span class="hljs-params">(message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> = <span class="hljs-keyword">when</span> (message) {<br> <span class="hljs-keyword">is</span> TextMessage -> runCatching {<br> <span class="hljs-keyword">val</span> forward = message.toObjectNode(objectMapper)?.fieldRename(paramProxy) ?: <span class="hljs-keyword">return</span> message<br> forward.<span class="hljs-keyword">get</span>(<span class="hljs-string">"is_speaking"</span>)?.let {<br> <span class="hljs-keyword">val</span> finished = it.asBoolean(<span class="hljs-literal">false</span>)<br> <span class="hljs-keyword">if</span> (!finished) forward.putMap(mapOf(<br> <span class="hljs-string">"language"</span> to <span class="hljs-string">"zn"</span>,<br> <span class="hljs-string">"itn"</span> to <span class="hljs-literal">false</span>,<br> <span class="hljs-string">"hotwords"</span> to <span class="hljs-string">"{\"阿里巴巴\":20,\"hello world\":40}"</span><br> ))<br> forward.put(<span class="hljs-string">"is_speaking"</span>, !finished)<br> }<br> forward.<span class="hljs-keyword">get</span>(<span class="hljs-string">"mode"</span>)?.let {<br> <span class="hljs-keyword">if</span> (listOf(<span class="hljs-string">"mixed"</span>,<span class="hljs-string">"online"</span>).contains(it.asText())) {<br> <span class="hljs-keyword">val</span> arr = objectMapper.createArrayNode().add(<span class="hljs-number">5</span>).add(<span class="hljs-number">10</span>).add(<span class="hljs-number">5</span>)<br> forward.<span class="hljs-keyword">set</span><ArrayNode>(<span class="hljs-string">"chunk_size"</span>, arr)<br> forward.put(<span class="hljs-string">"chunk_interval"</span>, <span class="hljs-number">10</span>)<br> <span class="hljs-keyword">if</span> (it.asText() == <span class="hljs-string">"mixed"</span>) forward.put(<span class="hljs-string">"mode"</span>, <span class="hljs-string">"2pass"</span>)<br> }<br> }<br> forward.fieldRemove(listOf(<span class="hljs-string">"ak"</span>))<br> logger.info(<span class="hljs-string">"transformUpstream: <span class="hljs-variable">$forward</span>"</span>)<br> forward.toTextMessage(objectMapper)<br> }.getOrDefault(message)<br> <span class="hljs-keyword">else</span> -> message<br> }<br><br> <span class="hljs-comment">// 处理下游消息适配成客户接收类型</span><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">transformDownstream</span><span class="hljs-params">(message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> = <span class="hljs-keyword">when</span> (message) {<br> <span class="hljs-keyword">is</span> TextMessage -> {<br> <span class="hljs-keyword">val</span> forward = message.toObjectNode(objectMapper)<br> ?.fieldRename(paramProxy.toMutableMap().plus(<span class="hljs-string">"finish"</span> to <span class="hljs-string">"is_final"</span>), <span class="hljs-literal">true</span>)<br> ?: <span class="hljs-keyword">return</span> message<br> forward.putMap(mapOf(<br> <span class="hljs-string">"mode"</span> to <span class="hljs-keyword">when</span> (forward.<span class="hljs-keyword">get</span>(<span class="hljs-string">"mode"</span>)?.asText() ?: <span class="hljs-string">"2pass-offline"</span>) {<br> <span class="hljs-string">"2pass-online"</span> -> <span class="hljs-string">"online"</span><br> <span class="hljs-string">"2pass-offline"</span> -> <span class="hljs-string">"offline"</span><br> <span class="hljs-keyword">else</span> -> forward.<span class="hljs-keyword">get</span>(<span class="hljs-string">"mode"</span>).asText()<br> },<br> <span class="hljs-string">"timestamp"</span> to System.currentTimeMillis()<br> ))<br> forward.fieldJust(paramProxy.keys.plus(<span class="hljs-string">"mode"</span>).toList())<br> logger.info(<span class="hljs-string">"transformDownstream: <span class="hljs-variable">$forward</span>"</span>)<br> forward.toTextMessage(objectMapper)<br> }<br> <span class="hljs-keyword">else</span> -> message<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamFirstMessageIsNull</span><span class="hljs-params">(proxy: <span class="hljs-type">WebSocketProxySession</span>)</span></span> {<br> <span class="hljs-keyword">super</span>.onUpstreamFirstMessageIsNull(proxy)<br> proxy.session.close(CloseStatus.POLICY_VIOLATION)<br> }<br>}<br></code></pre></td></tr></table></figure><h2 id="客户端模式"><a href="#客户端模式" class="headerlink" title="客户端模式"></a>客户端模式</h2><blockquote><p>客户端模式是自己为发布器,用户为上游,自己作为下游。</p></blockquote><ol><li>用户认证</li><li>会话对象维护</li><li>心跳维护</li><li>消息广播</li><li>消息过滤广播</li><li>单例模式</li></ol><h3 id="时序设计-1"><a href="#时序设计-1" class="headerlink" title="时序设计"></a>时序设计</h3><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:4094/2968;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/IWebSocketPublisher.png" data-src="https://upyun.thatcdn.cn/myself/typora/IWebSocketPublisher.png" alt="IWebSocketPublisher时序图" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">IWebSocketPublisher时序图</span></div></div><figure class="highlight text"><figcaption><span>时序代码</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br></pre></td><td class="code"><pre><code class="hljs text">sequenceDiagram<br> participant Client as 客户端<br> participant Publisher as IWebSocketPublisher<br> participant Scheduler as 定时清理线程<br><br> Note over Client,Publisher: 1. 连接建立与初始化<br> Client->>Publisher: WebSocket 握手并建立连接<br> activate Publisher<br> Publisher-->>Publisher: afterConnectionEstablished(session)<br> Publisher-->>Publisher: onUpstreamOpen(session)<br> deactivate Publisher<br><br> Note over Client,Publisher: 2. 首次消息(身份验证)<br> Client->>Publisher: TextMessage(首次消息)<br> activate Publisher<br> Publisher-->>Publisher: handleMessage(session, message)<br> Publisher-->>Publisher: onUpstreamFirstMessage(session, message)<br> alt 验证失败<br> Publisher-->>Client: onUpstreamFirstMessageIsNull → 发送错误提示<br> Publisher-->>Client: session.close(POLICY_VIOLATION)<br> else 验证成功<br> Publisher-->>Publisher: sessions[session.id] = WebSocketSenderSession(...)<br> Publisher-->>Publisher: onAuthSuccess(...)<br> end<br> deactivate Publisher<br><br> Note over Client,Publisher: 3. 后续业务消息处理<br> Client->>Publisher: TextMessage(业务消息) 或 PingMessage(心跳)<br> activate Publisher<br> Publisher-->>Publisher: handleMessage<br> alt 心跳<br> Publisher-->>Publisher: 更新 lastHeartbeat<br> else 业务消息<br> Publisher-->>Publisher: onUpstreamMessage(...)<br> end<br> deactivate Publisher<br><br> Note over Scheduler,Publisher: 4. 会话超时清理<br> Scheduler->>Publisher: cleanupExpired()<br> activate Publisher<br> Publisher-->>Publisher: 关闭过期 session → onSessionClosed<br> deactivate Publisher<br><br> Note over Publisher,Client: 5. 发布/广播/心跳<br> Publisher->>Client: publishAll/publishByFilter/publishSender<br> Publisher-->>Publisher: transformPublish(...)<br> Publisher-->>Client: sendMessage(转换后消息)<br> Publisher->>Client: sendHeartbeat() → PingMessage()<br><br></code></pre></td></tr></table></figure><h3 id="接口代码-1"><a href="#接口代码-1" class="headerlink" title="接口代码"></a>接口代码</h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br><span class="line">142</span><br><span class="line">143</span><br><span class="line">144</span><br><span class="line">145</span><br><span class="line">146</span><br><span class="line">147</span><br><span class="line">148</span><br><span class="line">149</span><br><span class="line">150</span><br><span class="line">151</span><br><span class="line">152</span><br><span class="line">153</span><br><span class="line">154</span><br><span class="line">155</span><br><span class="line">156</span><br><span class="line">157</span><br><span class="line">158</span><br><span class="line">159</span><br><span class="line">160</span><br><span class="line">161</span><br><span class="line">162</span><br><span class="line">163</span><br><span class="line">164</span><br><span class="line">165</span><br><span class="line">166</span><br><span class="line">167</span><br><span class="line">168</span><br><span class="line">169</span><br><span class="line">170</span><br><span class="line">171</span><br><span class="line">172</span><br><span class="line">173</span><br><span class="line">174</span><br><span class="line">175</span><br><span class="line">176</span><br><span class="line">177</span><br><span class="line">178</span><br><span class="line">179</span><br><span class="line">180</span><br><span class="line">181</span><br><span class="line">182</span><br><span class="line">183</span><br><span class="line">184</span><br><span class="line">185</span><br><span class="line">186</span><br><span class="line">187</span><br><span class="line">188</span><br><span class="line">189</span><br><span class="line">190</span><br><span class="line">191</span><br><span class="line">192</span><br><span class="line">193</span><br><span class="line">194</span><br><span class="line">195</span><br><span class="line">196</span><br><span class="line">197</span><br><span class="line">198</span><br><span class="line">199</span><br><span class="line">200</span><br><span class="line">201</span><br><span class="line">202</span><br><span class="line">203</span><br><span class="line">204</span><br><span class="line">205</span><br><span class="line">206</span><br><span class="line">207</span><br><span class="line">208</span><br><span class="line">209</span><br><span class="line">210</span><br><span class="line">211</span><br><span class="line">212</span><br><span class="line">213</span><br><span class="line">214</span><br><span class="line">215</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-keyword">import</span> com.fasterxml.jackson.databind.ObjectMapper<br><span class="hljs-keyword">import</span> org.slf4j.LoggerFactory<br><span class="hljs-keyword">import</span> org.springframework.web.socket.*<br><span class="hljs-keyword">import</span> org.springframework.web.socket.handler.AbstractWebSocketHandler<br><span class="hljs-keyword">import</span> java.util.concurrent.*<br><span class="hljs-keyword">import</span> java.util.concurrent.atomic.AtomicInteger<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 会话、用户信息的包装类</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">data</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">WebSocketSenderSession</span><<span class="hljs-type">T</span>>(<br> <span class="hljs-keyword">val</span> session: WebSocketSession,<br> <span class="hljs-keyword">val</span> user: T,<br> <span class="hljs-comment">/** 心跳超时标志 */</span><br> <span class="hljs-keyword">val</span> lastHeartbeat: <span class="hljs-built_in">Long</span> = System.currentTimeMillis()<br>)<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * WebSocket 发布者抽象接口,用于构建支持用户认证、心跳维护、消息广播的通用 WebSocket 服务。</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * ### 使用方式</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * #### 必须实现</span><br><span class="hljs-comment"> * > 继承本类并实现以下核心抽象方法</span><br><span class="hljs-comment"> * - [registerPath]:注册的路径,WebSocket 接入入口</span><br><span class="hljs-comment"> * - [onUpstreamFirstMessage]:处理上游客户端首次连接时的消息,一般用于身份验证,返回的用户信息将用于标识会话;若返回 null,连接将被关闭</span><br><span class="hljs-comment"> * - [onUpstreamMessage]:处理客户端后续发送的消息</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * #### 可选重写</span><br><span class="hljs-comment"> * - [onUpstreamOpen]:连接建立但未发送任何消息时的初始化回调</span><br><span class="hljs-comment"> * - [onSessionClosed]:连接关闭后的回调处理</span><br><span class="hljs-comment"> * - [onUpstreamFirstMessageIsNull]:首次消息认证失败时的回调,默认发送错误信息</span><br><span class="hljs-comment"> * - [onAuthSuccess]:首次消息认证通过后的回调</span><br><span class="hljs-comment"> * - [transformPublish]:发送消息前进行的消息变换</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * ### 会话管理</span><br><span class="hljs-comment"> * - 会话信息以 [WebSocketSenderSession] 包装,包含 `session`、用户信息及心跳时间</span><br><span class="hljs-comment"> * - 默认 10 分钟未活跃会话将被关闭,可通过 [sessionTimeoutMillis] 调整</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * ### 发布功能</span><br><span class="hljs-comment"> * - [publishAll]:向所有连接发布消息</span><br><span class="hljs-comment"> * - [publishByFilter]:根据过滤条件发布消息</span><br><span class="hljs-comment"> * - [publishSender]:向单个连接发送消息</span><br><span class="hljs-comment"> * - [sendHeartbeat]:向所有连接发送 Ping 消息,维持长连接</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> objectMapper Jackson 用于 JSON 序列化/反序列化</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> T 用户类型,需由 [onUpstreamFirstMessage] 提供</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@author</span> ThatCoder</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">abstract</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">IWebSocketPublisher</span><<span class="hljs-type">T</span>>(<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> objectMapper: ObjectMapper<br>) : AbstractWebSocketHandler() {<br><br> <span class="hljs-keyword">abstract</span> <span class="hljs-keyword">val</span> registerPath: String<br><br> <span class="hljs-comment">/** 会话超时毫秒数 默认十分钟 */</span><br> <span class="hljs-keyword">val</span> sessionTimeoutMillis: <span class="hljs-built_in">Long</span> = <span class="hljs-number">10</span>*<span class="hljs-number">60</span>*<span class="hljs-number">1000</span><br><br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> logger = LoggerFactory.getLogger(<span class="hljs-keyword">this</span>::<span class="hljs-keyword">class</span>.java)<br><br> <span class="hljs-comment">/** 所有会话管理器 */</span><br> <span class="hljs-keyword">val</span> sessions = ConcurrentHashMap<String, WebSocketSenderSession<T>>()<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> scheduler = Executors.newSingleThreadScheduledExecutor(NamedThreadFactory(<span class="hljs-string">"session-timeout-"</span>))<br><br> <span class="hljs-keyword">init</span> {<br> <span class="hljs-comment">// 定期清理超时会话</span><br> scheduler.scheduleAtFixedRate({ cleanupExpired() }, sessionTimeoutMillis, sessionTimeoutMillis, TimeUnit.MILLISECONDS)<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">afterConnectionEstablished</span><span class="hljs-params">(session: <span class="hljs-type">WebSocketSession</span>)</span></span> {<br> logger.info(<span class="hljs-string">"Client connected: <span class="hljs-subst">${session.id}</span>"</span>)<br> onUpstreamOpen(session)<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">handleMessage</span><span class="hljs-params">(session: <span class="hljs-type">WebSocketSession</span>, message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> {<br> <span class="hljs-comment">// 首次消息处理授权与注册</span><br> <span class="hljs-keyword">if</span> (!sessions.containsKey(session.id)) {<br> <span class="hljs-keyword">val</span> user = onUpstreamFirstMessage(session, message)<br> <span class="hljs-keyword">if</span> (user == <span class="hljs-literal">null</span>) {<br> onUpstreamFirstMessageIsNull(session)<br> session.close(CloseStatus.POLICY_VIOLATION)<br> <span class="hljs-keyword">return</span><br> }<br> sessions[session.id] = WebSocketSenderSession(session, user)<br> onAuthSuccess(sessions[session.id]!!)<br> logger.info(<span class="hljs-string">"Session registered: <span class="hljs-subst">${session.id}</span> -> <span class="hljs-variable">$user</span>"</span>)<br> <span class="hljs-keyword">return</span><br> }<br> <span class="hljs-comment">// 心跳更新或具体消息处理</span><br> onUpstreamMessage(sessions[session.id]!!, message)<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">afterConnectionClosed</span><span class="hljs-params">(session: <span class="hljs-type">WebSocketSession</span>, status: <span class="hljs-type">CloseStatus</span>)</span></span> {<br> logger.info(<span class="hljs-string">"Client closed: <span class="hljs-subst">${session.id}</span> (<span class="hljs-subst">${status.reason}</span>)"</span>)<br> sessions.remove(session.id)?.let { onSessionClosed(it) }<br> }<br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 全局发布消息</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> message 消息</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">publishAll</span><span class="hljs-params">(message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> {<br> sessions.values.forEach { sender -> send(sender, message) }<br> }<br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 按过滤器发布</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> filter 过滤器</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> message 消息</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">publishByFilter</span><span class="hljs-params">(filter: (<span class="hljs-type">WebSocketSenderSession</span><<span class="hljs-type">T</span>>) -> <span class="hljs-type">Boolean</span>, message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> {<br> sessions.values.filter(filter).forEach { send(it, message) }<br> }<br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 发送消息给指定会话</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> sender 会话</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> message 消息</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">publishSender</span><span class="hljs-params">(sender: <span class="hljs-type">WebSocketSenderSession</span><<span class="hljs-type">T</span>>, message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> {<br> send(sender, message)<br> }<br><br> <span class="hljs-comment">/** 发送心跳 */</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">sendHeartbeat</span><span class="hljs-params">()</span></span> {<br> <span class="hljs-keyword">val</span> ping = PingMessage()<br> sessions.values.forEach {<br> <span class="hljs-keyword">try</span> { it.session.sendMessage(ping) } <span class="hljs-keyword">catch</span> (_: Exception) {}<br> }<br> }<br><br> <span class="hljs-comment">// ========== 子类扩展点 ===========</span><br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 会话首次创建时调用</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> session 会话</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">open</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamOpen</span><span class="hljs-params">(session: <span class="hljs-type">WebSocketSession</span>)</span></span> = <span class="hljs-built_in">Unit</span><br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 会话消息</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> sender 会话对象</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> message 消息</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">abstract</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamMessage</span><span class="hljs-params">(sender: <span class="hljs-type">WebSocketSenderSession</span><<span class="hljs-type">T</span>>, message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span><br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 会话关闭时调用</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> sender 会话对象</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">open</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onSessionClosed</span><span class="hljs-params">(sender: <span class="hljs-type">WebSocketSenderSession</span><<span class="hljs-type">T</span>>)</span></span> = <span class="hljs-built_in">Unit</span><br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 会话首条消息</span><br><span class="hljs-comment"> *</span><br><span class="hljs-comment"> * 通常在验证用户权限时调用</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> session 会话</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> message 消息</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@return</span> 用户信息 如果返回null则触发 onUpstreamFirstMessageIsNull</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@see</span> onUpstreamFirstMessageIsNull</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">abstract</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamFirstMessage</span><span class="hljs-params">(</span></span><br><span class="hljs-params"><span class="hljs-function"> session: <span class="hljs-type">WebSocketSession</span>,</span></span><br><span class="hljs-params"><span class="hljs-function"> message: <span class="hljs-type">WebSocketMessage</span><*></span></span><br><span class="hljs-params"><span class="hljs-function"> )</span></span>: T?<br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 会话首条消息处理为空时调用</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> session 会话</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">open</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamFirstMessageIsNull</span><span class="hljs-params">(session: <span class="hljs-type">WebSocketSession</span>)</span></span> {<br> <span class="hljs-keyword">val</span> err = mapOf(<span class="hljs-string">"error"</span> to <span class="hljs-string">"身份认证失败"</span>)<br> session.sendMessage(TextMessage(objectMapper.writeValueAsString(err)))<br> }<br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 认证成功后执行</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> sender 会话</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">open</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onAuthSuccess</span><span class="hljs-params">(sender: <span class="hljs-type">WebSocketSenderSession</span><<span class="hljs-type">T</span>>)</span></span> = <span class="hljs-built_in">Unit</span><br><br> <span class="hljs-comment">/** 清理超时会话 */</span><br> <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">cleanupExpired</span><span class="hljs-params">()</span></span> {<br> <span class="hljs-keyword">val</span> now = System.currentTimeMillis()<br> sessions.values.filter { now - it.lastHeartbeat > sessionTimeoutMillis }<br> .forEach {<br> <span class="hljs-keyword">try</span> { it.session.close(CloseStatus.SESSION_NOT_RELIABLE) } <span class="hljs-keyword">catch</span> (_: Exception) {}<br> sessions.remove(it.session.id)<br> logger.info(<span class="hljs-string">"Session timeout removed: <span class="hljs-subst">${it.session.id}</span>"</span>)<br> }<br> }<br><br> <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">send</span><span class="hljs-params">(sender: <span class="hljs-type">WebSocketSenderSession</span><<span class="hljs-type">T</span>>, message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> {<br> <span class="hljs-keyword">if</span> (!sender.session.isOpen) <span class="hljs-keyword">return</span><br> <span class="hljs-keyword">try</span> {<br> sender.session.sendMessage(transformPublish(message, sender))<br> } <span class="hljs-keyword">catch</span> (e: Exception) {<br> logger.error(<span class="hljs-string">"Publish to <span class="hljs-subst">${sender.session.id}</span> failed"</span>, e)<br> }<br> }<br><br> <span class="hljs-keyword">protected</span> <span class="hljs-keyword">open</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">transformPublish</span><span class="hljs-params">(</span></span><br><span class="hljs-params"><span class="hljs-function"> message: <span class="hljs-type">WebSocketMessage</span><*>,</span></span><br><span class="hljs-params"><span class="hljs-function"> sender: <span class="hljs-type">WebSocketSenderSession</span><<span class="hljs-type">T</span>></span></span><br><span class="hljs-params"><span class="hljs-function"> )</span></span>: WebSocketMessage<*> = message<br><br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">NamedThreadFactory</span>(prefix: String) : ThreadFactory {<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> cnt = AtomicInteger(<span class="hljs-number">1</span>)<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> name = prefix + cnt.getAndIncrement()<br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">newThread</span><span class="hljs-params">(r: <span class="hljs-type">Runnable</span>)</span></span>: Thread {<br> <span class="hljs-keyword">return</span> Thread(r, name)<br> }<br> }<br>}<br><br></code></pre></td></tr></table></figure><h3 id="实现示例-1"><a href="#实现示例-1" class="headerlink" title="实现示例"></a>实现示例</h3><blockquote><p>以实现聊天室为例,这个例子有对单对群发送演示</p></blockquote><blockquote><p>兼容单例模式,只使用 publishSender 方法即可, 相当于一对一服务</p></blockquote><ul><li>实现后可以多开几个网页测试 <a href="http://www.websocket-test.com/">websocket测试网页</a></li><li>链接本地 <code>ws://localhost:8080/ws/chat</code>后可以发送一个body鉴权进群 <code>{"ak": "123456", "message": "我是卢本伟", "name": "卢本伟"}</code></li><li>进群后续可以不发送 ak,已经有了sessionId对应的用户, 后面发送 <code>{"message": "欢迎来到卢本伟广场"}</code> 即可</li></ul><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-keyword">import</span> com.bidr.waterx.transpond.config.extension.toObjectNode<br><span class="hljs-keyword">import</span> com.bidr.waterx.transpond.config.extension.toTextMessage<br><span class="hljs-keyword">import</span> com.fasterxml.jackson.databind.ObjectMapper<br><span class="hljs-keyword">import</span> org.springframework.stereotype.Component<br><span class="hljs-keyword">import</span> org.springframework.web.socket.TextMessage<br><span class="hljs-keyword">import</span> org.springframework.web.socket.WebSocketMessage<br><span class="hljs-keyword">import</span> org.springframework.web.socket.WebSocketSession<br><br><span class="hljs-keyword">data</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ChatUser</span>(<span class="hljs-keyword">val</span> userid: String, <span class="hljs-keyword">val</span> name: String)<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 聊天室发布者</span><br><span class="hljs-comment"> */</span><br><span class="hljs-meta">@Component</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">ChatPublisher</span>(<span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> objectMapper: ObjectMapper) : IWebSocketPublisher<ChatUser>(objectMapper) {<br><br> <span class="hljs-keyword">override</span> <span class="hljs-keyword">val</span> registerPath = <span class="hljs-string">"/ws/chat"</span><br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamFirstMessage</span><span class="hljs-params">(session: <span class="hljs-type">WebSocketSession</span>, message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span>: ChatUser? {<br> <span class="hljs-keyword">val</span> message = message.toObjectNode() ?: <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span><br> <span class="hljs-keyword">val</span> ak = message.<span class="hljs-keyword">get</span>(<span class="hljs-string">"ak"</span>)?.asText() ?: <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span><br> <span class="hljs-keyword">val</span> name = message.<span class="hljs-keyword">get</span>(<span class="hljs-string">"name"</span>)?.asText() ?: <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span><br> <span class="hljs-keyword">if</span> (ak != <span class="hljs-string">"123456"</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span><br> <span class="hljs-comment">// 创建用户</span><br> <span class="hljs-keyword">val</span> user = ChatUser( session.id, name)<br> <span class="hljs-comment">// 给该用户发送欢迎信息</span><br> session.sendMessage(TextMessage(<span class="hljs-string">"Hi, <span class="hljs-variable">$name</span>. Please chat friendly!"</span>))<br> <span class="hljs-comment">// 群发用户入群提示</span><br> publishAll(TextMessage(<span class="hljs-string">"<span class="hljs-variable">$name</span>'ve joined the chat room."</span>))<br> <span class="hljs-keyword">return</span> user<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onSessionClosed</span><span class="hljs-params">(sender: <span class="hljs-type">WebSocketSenderSession</span><<span class="hljs-type">ChatUser</span>>)</span></span> {<br> <span class="hljs-comment">// 群发用户离开提示</span><br> publishAll(TextMessage(<span class="hljs-string">"<span class="hljs-subst">${sender.user.name}</span> has left the chat room."</span>))<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onUpstreamMessage</span><span class="hljs-params">(sender: <span class="hljs-type">WebSocketSenderSession</span><<span class="hljs-type">ChatUser</span>>, message: <span class="hljs-type">WebSocketMessage</span><*>)</span></span> {<br> <span class="hljs-comment">// 转发用户消息至群聊</span><br> publishAll(objectMapper.createObjectNode().apply {<br> put(<span class="hljs-string">"type"</span>, <span class="hljs-string">"chat"</span>)<br> putPOJO(<span class="hljs-string">"user"</span>, sender.user)<br> putPOJO(<span class="hljs-string">"message"</span>, message.toObjectNode())<br> }.toTextMessage())<br> }<br>}<br><br></code></pre></td></tr></table></figure><h2 id="路由注册"><a href="#路由注册" class="headerlink" title="路由注册"></a>路由注册</h2><blockquote><p>两个接口都有 <code>registerPath</code> 所以我们可以让 Spring 收集 IWebSocketPublisher、IWebSocketProxier 的实现类,自动注册里面的路由实现</p></blockquote><figure class="highlight kotlin"><figcaption><span>WebSocketConfig</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-keyword">package</span> cn.uwant.auto.config<br><br><span class="hljs-keyword">import</span> IWebSocketProxier<br><span class="hljs-keyword">import</span> IWebSocketPublisher<br><span class="hljs-keyword">import</span> jakarta.websocket.ContainerProvider<br><span class="hljs-keyword">import</span> org.springframework.context.<span class="hljs-keyword">annotation</span>.Bean<br><span class="hljs-keyword">import</span> org.springframework.context.<span class="hljs-keyword">annotation</span>.Configuration<br><span class="hljs-keyword">import</span> org.springframework.web.socket.client.WebSocketClient<br><span class="hljs-keyword">import</span> org.springframework.web.socket.client.standard.StandardWebSocketClient<br><span class="hljs-keyword">import</span> org.springframework.web.socket.config.<span class="hljs-keyword">annotation</span>.EnableWebSocket<br><span class="hljs-keyword">import</span> org.springframework.web.socket.config.<span class="hljs-keyword">annotation</span>.WebSocketConfigurer<br><span class="hljs-keyword">import</span> org.springframework.web.socket.config.<span class="hljs-keyword">annotation</span>.WebSocketHandlerRegistry<br><span class="hljs-keyword">import</span> org.springframework.context.<span class="hljs-keyword">annotation</span>.Lazy<br><span class="hljs-keyword">import</span> kotlin.collections.map<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * WebSocket配置</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@author</span> ThatCoder</span><br><span class="hljs-comment"> */</span><br><span class="hljs-meta">@Configuration</span><br><span class="hljs-meta">@EnableWebSocket</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">WebSocketConfig</span>(<br> <span class="hljs-meta">@Lazy</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> proxies: List<IWebSocketProxier>,<br> <span class="hljs-meta">@Lazy</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> publishers: List<IWebSocketPublisher<*>><br>) : WebSocketConfigurer {<br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">registerWebSocketHandlers</span><span class="hljs-params">(registry: <span class="hljs-type">WebSocketHandlerRegistry</span>)</span></span> {<br> proxies.map {<br> registry.addHandler(it, it.registerPath).setAllowedOrigins(<span class="hljs-string">"*"</span>)<br> }<br> publishers.map {<br> registry.addHandler(it, it.registerPath).setAllowedOrigins(<span class="hljs-string">"*"</span>)<br> }<br> }<br> <span class="hljs-meta">@Bean</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">webSocketClient</span><span class="hljs-params">()</span></span>: WebSocketClient {<br> <span class="hljs-keyword">val</span> container = ContainerProvider.getWebSocketContainer()<br> <span class="hljs-keyword">return</span> StandardWebSocketClient(container)<br> }<br>}<br></code></pre></td></tr></table></figure><h2 id="相关错误"><a href="#相关错误" class="headerlink" title="相关错误"></a>相关错误</h2><blockquote><p>见 BUG 专栏</p></blockquote><div class="tag-plugin link dis-select"><a class="link-card plain" title="Spring-WebSocket-Bug" href="/bug/spring-websocket-bug/" cardlink autofill="icon,desc"><div class="left"><span class="title">Spring-WebSocket-Bug</span><span class="cap link footnote">https://log.cns.red/bug/spring-websocket-bug/</span></div><div class="right"><div class="lazy img" data-bg="https://gcore.jsdelivr.net/gh/cdn-x/placeholder@1.0.12/link/8f277b4ee0ecd.svg"></div></div></a></div>]]></content>
<summary type="html">Spring+WebSocket的相关实践</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="业务" scheme="https://log.cns.red/tags/%E4%B8%9A%E5%8A%A1/"/>
</entry>
<entry>
<title>Spring AOP 调用自身失效</title>
<link href="https://log.cns.red/bug/spring-aop-failure-dynamic/"/>
<id>https://log.cns.red/bug/spring-aop-failure-dynamic/</id>
<published>2025-03-23T12:00:00.000Z</published>
<updated>2026-02-13T10:53:03.123Z</updated>
<content type="html"><![CDATA[<h2 id="错误情景"><a href="#错误情景" class="headerlink" title="错误情景"></a>错误情景</h2><ul><li>环境:<ul><li>Spring: 2.7.18</li></ul></li><li>操作:<ol><li>顶层方法分析所选数据源</li><li>切换数据源</li><li>调用对应查询</li><li>AOP代理失效导致多数据源切换失败</li></ol></li></ul><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 数据库元数据服务实现</span><br><span class="hljs-comment"> */</span><br><span class="hljs-meta">@Service</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">DataBaseMetaService</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">IDataBaseMetaService</span> {<br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 获取输入库表在数据库里的字段注释</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-meta">@Override</span><br> <span class="hljs-keyword">public</span> List<DataBaseMateField> <span class="hljs-title function_">selectTableFieldsByScope</span><span class="hljs-params">(String dbName, String tableName)</span> {<br> List<DataBaseMateField> dataBaseMetaFields = ListUtil.empty();<br> <span class="hljs-keyword">switch</span> (dbName) {<br> <span class="hljs-keyword">case</span> <span class="hljs-string">"xxx"</span>:<br> dataBaseMetaFields = byMaster(tableName);<br> <span class="hljs-keyword">break</span>;<br> <span class="hljs-keyword">case</span> <span class="hljs-string">"xxxxxx"</span>:<br> dataBaseMetaFields = byMonitorHm(tableName);<br> <span class="hljs-keyword">if</span> (CollectionUtils.isEmpty(dataBaseMetaFields))<br> dataBaseMetaFields = byMonitorWce(tableName);<br> <span class="hljs-keyword">break</span>;<br> <span class="hljs-keyword">default</span>:<br> <span class="hljs-keyword">break</span>;<br> }<br> <span class="hljs-keyword">return</span> dataBaseMetaFields.stream().peek(field -><br> field.setFieldName(StringUtil.toCamelCase(field.getFieldName()))<br> ).collect(Collectors.toList());<br> }<br><br> <span class="hljs-meta">@DS("a")</span><br> <span class="hljs-keyword">public</span> List<DataBaseMateField> <span class="hljs-title function_">byMaster</span><span class="hljs-params">(String tableName)</span> {}<br><br> <span class="hljs-meta">@DS("b-a")</span><br> <span class="hljs-keyword">public</span> List<DataBaseMateField> <span class="hljs-title function_">byMonitorHm</span><span class="hljs-params">(String tableName)</span> {}<br><br> <span class="hljs-meta">@DS("b-b")</span><br> <span class="hljs-keyword">public</span> List<DataBaseMateField> <span class="hljs-title function_">byMonitorWce</span><span class="hljs-params">(String tableName)</span> {}<br>}<br></code></pre></td></tr></table></figure><h2 id="错误诱因"><a href="#错误诱因" class="headerlink" title="错误诱因"></a>错误诱因</h2><ol><li><p>Spring AOP 原理:@DS 依赖 AOP 代理,而 this.xxx() 直接调用自身方法,不会经过代理对象。</p></li><li><p>JDK 代理与 CGLIB 代理的区别:默认情况下,@Transactional 和 @DS 这种 AOP 机制都是基于代理的,需要从代理对象调用方法才能生效。</p></li></ol><h2 id="解决方案"><a href="#解决方案" class="headerlink" title="解决方案"></a>解决方案</h2><ol><li><p>方式 1:使用 @Lazy 注解注入自身(推荐)</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-meta">@Lazy</span><br><span class="hljs-meta">@Autowired</span><br><span class="hljs-keyword">private</span> DataBaseMetaService self;<br></code></pre></td></tr></table></figure><p>这样 self.byXXX() 实际是从代理对象调用,从而触发 AOP,确保 @DS 切换数据源生效。</p></li><li><p>方式 2:通过 AopContext 获取代理对象</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-type">DataBaseMetaService</span> <span class="hljs-variable">proxy</span> <span class="hljs-operator">=</span> (DataBaseMetaService) AopContext.currentProxy();<br>proxy.byMaster(tableName);<br></code></pre></td></tr></table></figure><p>需要开启 exposeProxy = true,在 application.yml 配置:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">spring:</span><br> <span class="hljs-attr">aop:</span><br> <span class="hljs-attr">proxy-target-class:</span> <span class="hljs-literal">true</span><br> <span class="hljs-attr">expose-proxy:</span> <span class="hljs-literal">true</span><br></code></pre></td></tr></table></figure><p>但这种方式代码侵入性较强,不如方式 1 优雅。</p></li><li><p>方式 3:将 byXXX() 方法抽取到另一个 @Service。这样 @DS 标注的方法始终在被代理对象上执行,避免 this 调用导致 AOP 失效。</p></li></ol><h2 id="结尾"><a href="#结尾" class="headerlink" title="结尾"></a>结尾</h2><p>我这里选择第一种解决方案,注入自身。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 数据库元数据服务实现</span><br><span class="hljs-comment"> */</span><br><span class="hljs-meta">@Service</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">DataBaseMetaService</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">IDataBaseMetaService</span> {<br> <span class="hljs-comment">// 延迟注入自身,确保 @DS 生效</span><br> <span class="hljs-meta">@Lazy</span><br> <span class="hljs-meta">@Autowired</span><br> <span class="hljs-keyword">private</span> DataBaseMetaService self;<br><br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> DataBaseMetaMapper dataBaseMetaMapper;<br><br> <span class="hljs-keyword">public</span> <span class="hljs-title function_">DataBaseMetaService</span><span class="hljs-params">(DataBaseMetaMapper dataBaseMetaMapper)</span> {<br> <span class="hljs-built_in">this</span>.dataBaseMetaMapper = dataBaseMetaMapper;<br> }<br><br> <span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 获取输入库表在数据库里的字段注释</span><br><span class="hljs-comment"> */</span><br> <span class="hljs-meta">@Override</span><br> <span class="hljs-keyword">public</span> List<DataBaseMateField> <span class="hljs-title function_">selectTableFieldsByScope</span><span class="hljs-params">(String dbName, String tableName)</span> {<br> List<DataBaseMateField> dataBaseMetaFields = ListUtil.empty();<br> <span class="hljs-keyword">switch</span> (dbName) {<br> <span class="hljs-keyword">case</span> <span class="hljs-string">"xxx"</span>:<br> dataBaseMetaFields = self.byMaster(tableName);<br> <span class="hljs-keyword">break</span>;<br> <span class="hljs-keyword">case</span> <span class="hljs-string">"xxxxxx"</span>:<br> dataBaseMetaFields = self.byMonitorHm(tableName);<br> <span class="hljs-keyword">if</span> (CollectionUtils.isEmpty(dataBaseMetaFields))<br> dataBaseMetaFields = self.byMonitorWce(tableName);<br> <span class="hljs-keyword">break</span>;<br> <span class="hljs-keyword">default</span>:<br> <span class="hljs-keyword">break</span>;<br> }<br> <span class="hljs-keyword">return</span> dataBaseMetaFields.stream().peek(field -><br> field.setFieldName(StringUtil.toCamelCase(field.getFieldName()))<br> ).collect(Collectors.toList());<br> }<br><br> <span class="hljs-meta">@DS("a")</span><br> <span class="hljs-keyword">public</span> List<DataBaseMateField> <span class="hljs-title function_">byMaster</span><span class="hljs-params">(String tableName)</span> {}<br><br> <span class="hljs-meta">@DS("b-a")</span><br> <span class="hljs-keyword">public</span> List<DataBaseMateField> <span class="hljs-title function_">byMonitorHm</span><span class="hljs-params">(String tableName)</span> {}<br><br> <span class="hljs-meta">@DS("b-b")</span><br> <span class="hljs-keyword">public</span> List<DataBaseMateField> <span class="hljs-title function_">byMonitorWce</span><span class="hljs-params">(String tableName)</span> {}<br>}<br></code></pre></td></tr></table></figure>]]></content>
<summary type="html">AOP代理失效导致多数据源切换失败</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="BUG" scheme="https://log.cns.red/tags/BUG/"/>
</entry>
<entry>
<title>window 端口占用但是查不到</title>
<link href="https://log.cns.red/bug/port-usage/"/>
<id>https://log.cns.red/bug/port-usage/</id>
<published>2025-03-07T14:00:00.000Z</published>
<updated>2026-02-13T10:53:03.122Z</updated>
<content type="html"><![CDATA[<h2 id="错误情景"><a href="#错误情景" class="headerlink" title="错误情景"></a>错误情景</h2><ul><li>系统:<ul><li>window11</li><li>docker run in wsl</li></ul></li><li>操作:<ul><li>idea run nacos</li></ul></li></ul><h2 id="错误诱因"><a href="#错误诱因" class="headerlink" title="错误诱因"></a>错误诱因</h2><p>Window 默认预留的 TCP 动态端口范围与需要启动的服务端口冲突导致。</p><p>所以查不来的原因是端口确实未使用,但是保留。</p><h2 id="解决方案"><a href="#解决方案" class="headerlink" title="解决方案"></a>解决方案</h2><ol><li><p>查看windows保留端口序列是否在冲突范围,默认应该是1024开始,步长为13977。所以我nacos的8848、9848都在里面</p><figure class="highlight shell"><figcaption><span>查看保留端口序列</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs shell">netsh int ipv4 show dynamicport tcp<br></code></pre></td></tr></table></figure></li><li><p>修改端口默认起始与步长,设置为自己不常用的区间。</p></li></ol><ul><li>start: 起始值</li><li>num: 长度<figure class="highlight shell"><figcaption><span>修改保留端口序列</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs shell">netsh int ipv4 set dynamicport tcp start=30000 num=13977<br></code></pre></td></tr></table></figure></li></ul><h2 id="结尾"><a href="#结尾" class="headerlink" title="结尾"></a>结尾</h2><p>分享一个如果端口存在就kill端口的命令 e.g. <code>killIf 8848</code></p><figure class="highlight shell"><figcaption><span>killIf</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br></pre></td><td class="code"><pre><code class="hljs shell">function killIf {<br> param (<br> [Parameter(Mandatory = $true)]<br> [int]$Port,<br><br> [switch]$l<br> )<br><br> try {<br> # 获取 netstat 输出并过滤与端口相关的信息<br> $processInfo = netstat -ano | Select-String -Pattern ":\b${Port}\b"<br><br> # 判空处理<br> if ($null -eq $processInfo -or $processInfo.Length -eq 0) {<br> Write-Host "端口 $Port 未被占用。" -ForegroundColor Yellow<br> Write-Host "提示:无法使用该端口,请检查是否有其他服务在使用,或尝试重启电脑。" -ForegroundColor Green<br> return<br> }<br><br> # 提取唯一的进程ID<br> $processIds = $processInfo | ForEach-Object { ($_ -split '\s+')[-1] } | Select-Object -Unique<br><br> foreach ($ProcId in $processIds) {<br> try {<br> # 确认 PID 是否为有效的数字<br> if ($ProcId -match '^\d+$') {<br> if ($l) {<br> Write-Host "端口 $Port 存在进程号: ${ProcId}" -ForegroundColor Yellow<br> }<br> # 尝试终止进程<br> taskkill /PID $ProcId /F<br> Write-Host "已终止进程 ${ProcId}, 释放端口 $Port 完毕。" -ForegroundColor Green<br> } else {<br> Write-Host "不正确的进程号: ${ProcId}" -ForegroundColor Red<br> }<br> }<br> catch {<br> Write-Host "终止进程 ${ProcId} 失败: $($_.Exception.Message)" -ForegroundColor Red<br> }<br> }<br> }<br> catch {<br> Write-Host "发生错误: $($_.Exception.Message)" -ForegroundColor Red<br> }<br>}<br></code></pre></td></tr></table></figure><p>它是一个PowerShell 脚本(扩展名为 .ps1),放到 <code>$PROFILE</code> 这个变量下面就行,直接在命令行输入 <code>$PROFILE</code> 有地址。</p>]]></content>
<summary type="html">Address already in use: bind,但是你始终查不出来,脏东西真气人!</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="BUG" scheme="https://log.cns.red/tags/BUG/"/>
</entry>
<entry>
<title>Coze同插件不同工具之间代码复用</title>
<link href="https://log.cns.red/dev/coze-import/"/>
<id>https://log.cns.red/dev/coze-import/</id>
<published>2024-12-20T16:00:00.000Z</published>
<updated>2026-02-13T10:53:03.128Z</updated>
<content type="html"><![CDATA[<h2 id="问题描述"><a href="#问题描述" class="headerlink" title="问题描述"></a>问题描述</h2><p>用官方在线IDE的Node环境开发Coze插件的工具时,如果import复用其它模块定义好的函数、类、类型等,会出现类似如下报错:</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs shell">Error: Cannot find module 'xxx'<br><br>ESLint couldn't find an eslint.config.(js|mjs|cjs) file.<br></code></pre></td></tr></table></figure><p>问题已解决,急的话直接点击跳转到 <a href="#%E6%9C%80%E7%BB%88%E6%96%B9%E6%A1%88">最终方案</a> 部分。</p><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1474/511;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/20241222001921.png" data-src="https://upyun.thatcdn.cn/myself/typora/20241222001921.png" alt="最终效果" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">最终效果</span></div></div><h2 id="分析原因"><a href="#分析原因" class="headerlink" title="分析原因"></a>分析原因</h2><p>毫无疑问,我们编写的IDE文件是一个ts文件,而Coze插件运行时是Node环境,Node环境运行时模块加载机制不能直接加载ts文件,因此需要先编译成js文件才能运行。</p><p>而编译过程中,如果遇到import语句,就会去查找对应的模块文件,但由于Node环境无法直接运行ts文件,因此会报错。</p><h2 id="尝试解决"><a href="#尝试解决" class="headerlink" title="尝试解决"></a>尝试解决</h2><p>我大致思考尝试了如下方案</p><ul><li>方案一:修改配置,但是我们无法修改IDE的配置。</li><li>方案二:用额外的包去支持ts文件,比如<code>ts-node</code>、<code>ts-node-dev</code>等。但是我们不能控制命令行。</li><li>方案三:用 <code>const {xxx} = require('../common/common')</code> 这种方式导入模块。但是这样会导致IDE没有注释提示且无法提示具体属性(导入的类型是一个any),无法自动补全。</li></ul><p>经过一番挣扎,方案三是最佳可行方案,起码能解决基本的模块导入问题。最后我们要解决的是IDE的注释提示和自动补全问题,也就是编译时类型推断问题。</p><h2 id="最终方案"><a href="#最终方案" class="headerlink" title="最终方案"></a>最终方案</h2><blockquote><p>虽然丑陋,但是好用。</p></blockquote><ul><li>举例定义一个通用请求工具</li></ul><figure class="highlight typescript"><figcaption><span>通用工具</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-keyword">import</span> { <span class="hljs-title class_">Args</span>, <span class="hljs-title class_">Logger</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/runtime'</span>;<br><span class="hljs-keyword">import</span> axios, { <span class="hljs-title class_">AxiosInstance</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'axios'</span>;<br><br><span class="hljs-comment">// 省略handle函数</span><br><br><span class="hljs-comment">// 基础响应类型</span><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> <span class="hljs-title class_">HttpRes</span><T> = {<br> <span class="hljs-attr">code</span>: <span class="hljs-built_in">number</span><br> <span class="hljs-attr">message</span>: <span class="hljs-built_in">string</span><br> <span class="hljs-attr">result</span>: <span class="hljs-built_in">boolean</span><br> <span class="hljs-attr">data</span>: T[]<br>}<br><br><span class="hljs-comment">// 定义通用请求工具</span><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">abstract</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">BaseApi</span><T> {<br> <span class="hljs-keyword">private</span> <span class="hljs-attr">logger</span>: <span class="hljs-title class_">Logger</span>;<br> <span class="hljs-title function_">info</span>(<span class="hljs-params">...args: <span class="hljs-built_in">any</span>[]</span>) {<br> <span class="hljs-variable language_">this</span>.<span class="hljs-property">logger</span>.<span class="hljs-title function_">info</span>(...args);<br> }<br> <span class="hljs-attr">host</span>: <span class="hljs-built_in">string</span> = <span class="hljs-string">'https://example.com'</span>;<br> <span class="hljs-title function_">constructor</span>(<span class="hljs-params">baseUrl: <span class="hljs-built_in">string</span>, logger: Logger</span>){<br> <span class="hljs-variable language_">this</span>.<span class="hljs-property">api</span> = axios.<span class="hljs-title function_">create</span>({<br> <span class="hljs-attr">baseURL</span>: <span class="hljs-variable language_">this</span>.<span class="hljs-property">host</span> + baseUrl,<br> <span class="hljs-attr">headers</span>: {<br> <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span><br> }<br> });<br> <span class="hljs-variable language_">this</span>.<span class="hljs-property">logger</span> = logger;<br> }<br> <span class="hljs-keyword">async</span> <span class="hljs-title function_">get</span>(<span class="hljs-attr">url</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">params</span>: <span class="hljs-title class_">Object</span>): <span class="hljs-title class_">Promise</span><<span class="hljs-title class_">HttpRes</span><T> | <span class="hljs-literal">null</span>> {<br> <span class="hljs-keyword">const</span> res = (<span class="hljs-keyword">await</span> <span class="hljs-variable language_">this</span>.<span class="hljs-property">api</span>.<span class="hljs-title function_">get</span>(url, {params}))?.<span class="hljs-property">data</span><br> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">info</span>(url, res, params, {<span class="hljs-attr">count</span>: res?.<span class="hljs-property">data</span>?.<span class="hljs-property">length</span> || <span class="hljs-literal">null</span>})<br> <span class="hljs-keyword">return</span> res<br> }<br> <span class="hljs-comment">// async post()</span><br> <span class="hljs-comment">// ...</span><br>}<br><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> <span class="hljs-title class_">TOrder</span> = {}<br><span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">OrderApi</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_ inherited__">BaseApi</span><<span class="hljs-title class_">TOrder</span>> {<br> <span class="hljs-title function_">constructor</span>(<span class="hljs-params">logger: Logger</span>) {<br> <span class="hljs-variable language_">super</span>(<span class="hljs-string">"/order"</span>, logger);<br> }<br> <span class="hljs-keyword">async</span> <span class="hljs-title function_">getOrders</span>(<span class="hljs-attr">userId</span>: <span class="hljs-built_in">string</span>): <span class="hljs-title class_">Promise</span><<span class="hljs-title class_">HttpRes</span><<span class="hljs-title class_">TOrder</span>> | <span class="hljs-literal">null</span>> {<br> <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">'/list'</span>, {userId})<br> }<br>}<br><span class="hljs-comment">// ... 省略其它API</span><br></code></pre></td></tr></table></figure><ul><li>在其它工具代码中导入,并使用 typeof import() 去获取类型信息,这样IDE能自动补全提示。</li></ul><figure class="highlight typescript"><figcaption><span>需求工具导入</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-keyword">import</span> { <span class="hljs-title class_">Args</span>, <span class="hljs-title class_">Logger</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/runtime'</span>;<br><span class="hljs-keyword">const</span> { <span class="hljs-title class_">OrderApi</span> }: { <span class="hljs-title class_">OrderApi</span>: <span class="hljs-keyword">typeof</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">"../common/common"</span>).<span class="hljs-property">OrderApi</span> } = <span class="hljs-variable language_">module</span>.<span class="hljs-built_in">require</span>(<span class="hljs-string">"../common/common"</span>);<br><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">handler</span>(<span class="hljs-params">{ input, logger }: Args<Input></span>): <span class="hljs-title class_">Promise</span><<span class="hljs-title class_">Output</span>> {<br> <span class="hljs-keyword">const</span> userId = input.<span class="hljs-property">userId</span><br> <span class="hljs-keyword">const</span> api = <span class="hljs-keyword">new</span> <span class="hljs-title class_">OrderApi</span>(logger)<br> <span class="hljs-keyword">const</span> orders = <span class="hljs-keyword">await</span> api.<span class="hljs-title function_">get</span>(<span class="hljs-string">'/order'</span>, {userId})<br> <span class="hljs-keyword">return</span> {<br> <span class="hljs-attr">data</span>: orders<br> };<br>};<br></code></pre></td></tr></table></figure><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>typeof import 是 TypeScript 提供的静态类型推断工具,它在 <strong>编译阶段</strong> 就能捕捉模块的导出结构,而无需等到运行时去加载实际模块。<br>这一特性让我们能够应付 Coze 插件运行时环境中无法使用 import 的限制,在编译时获取类型信息,而不必依赖模块是否能被实际解析。</p><p>至于为何 require() 能支持动态导入,是因为做了一些拦截并转译工作,使得 require() 运行能支持动态导入。</p><p>总之,在Coze的IDE的Node环境中,使用运行时依赖得靠 require(),而在编译时得到依赖类型得靠 typeof import() 去做静态类型检查。</p><h2 id="闲聊"><a href="#闲聊" class="headerlink" title="闲聊"></a>闲聊</h2><p>好久没更新博客,都忘了怎么发布文章,有闲暇时候还是多写写保持思考与输出。</p>]]></content>
<summary type="html">解决Coze插件运行时不能 import 其它工具的模块问题</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="Coze" scheme="https://log.cns.red/tags/Coze/"/>
</entry>
<entry>
<title>Pachelbel's Greatest Hit: The Ultimate Canon - 纪念帕海贝尔:终极卡农</title>
<link href="https://log.cns.red/hi-res/Canon/"/>
<id>https://log.cns.red/hi-res/Canon/</id>
<published>2024-08-13T02:00:00.000Z</published>
<updated>2026-02-13T10:53:03.130Z</updated>
<content type="html"><![CDATA[<h2 id="资源介绍"><a href="#资源介绍" class="headerlink" title="资源介绍"></a>资源介绍</h2><p>为了庆祝帕海贝尔350周年诞辰(1653/9/1),BMG唱片公司特别搜罗分佈在全球旗下的各领域知名乐手、乐团,以15首不同编曲、配器与演奏风格的卡农演出版本,来纪念这位音乐家。</p><p>专辑囊括了许多顶尖音乐家和极具时代意义的指标性演出版本,诸如在20世纪(1940年)第一个将“卡农”这首巴罗克杰作以大眾流行手法演出、带动古典音乐普及化功不可没的费德勒与他的小交响乐团;知名的法国百雅室内乐团的演出则是早期身历声录音时代(1970)广播电臺最热门的播放版本;长笛名家詹姆斯.高威自编自演的招牌长笛版,是一份保留了巴罗克原味的优雅版本;无与伦比的双钢琴演出,则是市调票选的人气首选版本;日本作曲家兼电子音乐大师富田勋的改编版,是最教人惊豔的现代版电子合成卡农;加拿大铜管乐团自编自演的管乐演出,展示出金属色泽的堂皇卡农;葛莱美奖女歌手克丽欧·莲恩的填词演唱版“何如、何处、何时”,成为风景独特的福音版;白金美声无伴奏重唱乐团展现了纯净圣洁的巴罗克正统;独一无二的“尺八与箏乐五重奏”版本巧妙地以东方古乐器呈现西方古乐;此外,被乐坛寄予厚望的英国古典吉他新秀克裏夫·卡洛首次面世的古典超技吉他独奏版;西班牙的古典吉他世家罗梅洛的吉他与音色合成器配合的协奏版,赋予出人意表的现代风貌;由伦敦市政厅弦乐团演出的终结乐章则忠实地以正统的巴罗克编制演出,让听眾亲炙原汁原味的正宗卡农。全专辑计有十轨全球首度重新编录版本,绝对能够满足无数卡农迷的重度搜藏欲。</p><h2 id="曲目风格"><a href="#曲目风格" class="headerlink" title="曲目风格"></a>曲目风格</h2><p><strong>管弦版永远的神,长笛的也很绝</strong></p><ol><li>Canon in D 室内管弦版卡农 </li><li>Canon in D 长笛协奏版卡农 </li><li>Canon (Over a Basso Ostinato) 钢琴二重奏版卡农 </li><li>Canon of the Three Stars 现代版电子合成卡农 </li><li>How, Where, When? (Canon in D)</li><li>Earth Angel - Williams 尘世天使 </li><li>Canon 铜管版卡农 </li><li>Canon in F F大调古乐器版卡农 </li><li>Variations on Pachelbel’s Canon in D 弦乐四重奏变奏版卡农 </li><li>Canon 超技吉他独奏版卡农 </li><li>Canon 美声无伴奏版卡农 </li><li>Sweet Home - Sakakibara, Dai </li><li>Canon 吉他&音色合成器版卡农 </li><li>Canon in D 跨界乐团版卡农 </li><li>Canon & Gigue in D 卡农与吉格舞曲</li></ol><h2 id="资源分享"><a href="#资源分享" class="headerlink" title="资源分享"></a>资源分享</h2><h3 id="试听地址"><a href="#试听地址" class="headerlink" title="试听地址"></a>试听地址</h3><p><a href="https://music.163.com/#/album?id=508055">网易云音乐</a></p><h3 id="下载地址"><a href="#下载地址" class="headerlink" title="下载地址"></a>下载地址</h3><div class="tag-plugin link dis-select"><a class="link-card plain" title="终极卡农(DSD)" href="https://alist.thatcdn.cn/Tianyi/Hi-Res/%E7%BA%AF%E9%9F%B3/%E5%8D%A1%E5%86%9C" target="_blank" rel="external nofollow noopener noreferrer" cardlink autofill="icon,desc"><div class="left"><span class="title">终极卡农(DSD)</span><span class="cap link footnote">https://alist.thatcdn.cn/Tianyi/Hi-Res/%E7%BA%AF%E9%9F%B3/%E5%8D%A1%E5%86%9C</span></div><div class="right"><div class="lazy img" data-bg="https://gcore.jsdelivr.net/gh/cdn-x/placeholder@1.0.12/link/8f277b4ee0ecd.svg"></div></div></a></div><h2 id="TIPS"><a href="#TIPS" class="headerlink" title="TIPS"></a>TIPS</h2><p>DSD类资源一般都比较大,对播放与监听设备可能要求较高。</p>]]></content>
<summary type="html">庆祝帕海贝尔350周年诞辰,15首不同演出版本,来纪念这位音乐家。DSD(DFF)| 2.8MHz/1bit</summary>
<category term="分享" scheme="https://log.cns.red/categories/%E5%88%86%E4%BA%AB/"/>
<category term="音乐" scheme="https://log.cns.red/tags/%E9%9F%B3%E4%B9%90/"/>
</entry>
<entry>
<title>圣诞快乐,劳伦斯先生 Merry Christmas Mr. Lawrence</title>
<link href="https://log.cns.red/hi-res/Sakamoto-Ry%C5%ABichi/"/>
<id>https://log.cns.red/hi-res/Sakamoto-Ry%C5%ABichi/</id>
<published>2024-08-12T02:00:00.000Z</published>
<updated>2026-02-13T10:53:03.130Z</updated>
<content type="html"><![CDATA[<h2 id="资源介绍"><a href="#资源介绍" class="headerlink" title="资源介绍"></a>资源介绍</h2><p><a href="https://movie.douban.com/subject/1303535/">《战场上的快乐圣诞 Merry Christmas Mr. Lawrence》</a></p><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:872/413;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/1cb925fd-c967-4923-af99-266e05be10a7.jpeg" data-src="https://upyun.thatcdn.cn/myself/typora/1cb925fd-c967-4923-af99-266e05be10a7.jpeg" alt="《战场上的快乐圣诞 Merry Christmas Mr. Lawrence》" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">《战场上的快乐圣诞 Merry Christmas Mr. Lawrence》</span></div></div><p>电影就不多评价,总之主演里面有北野武和坂本龙一,两位都是很出色有趣。虽然坂本龙一是搞音乐,北野武后来成为大导演了。</p><p>想起朋友推荐过一本书<a href="https://weread.qq.com/web/reader/b6b32bc0717f290cb6b9cf2">《北野武的小酒馆》</a>,里面也提过坂本龙一的合作。朋友说我语言和性格像北野武。</p><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1087/586;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/20240812124401.png" data-src="https://upyun.thatcdn.cn/myself/typora/20240812124401.png" alt="《北野武的小酒馆》" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">《北野武的小酒馆》</span></div></div><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1125/2865;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/3e15e0281a61b6ca652abcb6a31b0c6.jpg" data-src="https://upyun.thatcdn.cn/myself/typora/3e15e0281a61b6ca652abcb6a31b0c6.jpg" alt="书中桥段" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">书中桥段</span></div></div><p>配乐的原声经典曲《Merry Christmas Mr. Lawrence》与后来坂本龙一演奏的有些不一样,后流行演奏版下面也会单独给出,出自<a href="https://music.sonyselect.net/page/album.html?id=6452">《夜(nacht)》</a>的收录曲。</p><h2 id="资源分享"><a href="#资源分享" class="headerlink" title="资源分享"></a>资源分享</h2><h3 id="电影原声专辑"><a href="#电影原声专辑" class="headerlink" title="电影原声专辑"></a>电影原声专辑</h3><h4 id="试听地址"><a href="#试听地址" class="headerlink" title="试听地址"></a>试听地址</h4><p><a href="https://music.163.com/#/album?id=47812">网易云音乐</a></p><h4 id="下载地址"><a href="#下载地址" class="headerlink" title="下载地址"></a>下载地址</h4><div class="tag-plugin link dis-select"><a class="link-card plain" title="圣诞快乐,劳伦斯先生 OST原声(DSD)" href="https://alist.thatcdn.cn/Tianyi/Hi-Res/%E7%BA%AF%E9%9F%B3/%E5%9D%82%E6%9C%AC%E9%BE%99%E4%B8%80/Merry%20Christmas%20Mr.%20Lawrence" target="_blank" rel="external nofollow noopener noreferrer" cardlink autofill="icon,desc"><div class="left"><span class="title">圣诞快乐,劳伦斯先生 OST原声(DSD)</span><span class="cap link footnote">https://alist.thatcdn.cn/Tianyi/Hi-Res/%E7%BA%AF%E9%9F%B3/%E5%9D%82%E6%9C%AC%E9%BE%99%E4%B8%80/Merry%20Christmas%20Mr.%20Lawrence</span></div><div class="right"><div class="lazy img" data-bg="https://gcore.jsdelivr.net/gh/cdn-x/placeholder@1.0.12/link/8f277b4ee0ecd.svg"></div></div></a></div><h3 id="流行单曲"><a href="#流行单曲" class="headerlink" title="流行单曲"></a>流行单曲</h3><h4 id="试听地址-1"><a href="#试听地址-1" class="headerlink" title="试听地址"></a>试听地址</h4><p><a href="https://music.163.com/#/song?id=4899152">网易云音乐</a></p><h4 id="下载地址-1"><a href="#下载地址-1" class="headerlink" title="下载地址"></a>下载地址</h4><div class="tag-plugin link dis-select"><a class="link-card plain" title="圣诞快乐,劳伦斯先生《夜》(DSD)" href="https://alist.thatcdn.cn/Tianyi/Hi-Res/%E7%BA%AF%E9%9F%B3/%E5%9D%82%E6%9C%AC%E9%BE%99%E4%B8%80/%E5%A4%9C%20(nacht)/%E5%9C%A3%E8%AF%9E%E5%BF%AB%E4%B9%90,%20%E5%8A%B3%E4%BC%A6%E6%96%AF%E5%85%88%E7%94%9F%20(Merry%20Christmas%20Mr.Lawrence).dsf" target="_blank" rel="external nofollow noopener noreferrer" cardlink autofill="icon,desc"><div class="left"><span class="title">圣诞快乐,劳伦斯先生《夜》(DSD)</span><span class="cap link footnote">https://alist.thatcdn.cn/Tianyi/Hi-Res/%E7%BA%AF%E9%9F%B3/%E5%9D%82%E6%9C%AC%E9%BE%99%E4%B8%80/%E5%A4%9C%20(nacht)/%E5%9C%A3%E8%AF%9E%E5%BF%AB%E4%B9%90,%20%E5%8A%B3%E4%BC%A6%E6%96%AF%E5%85%88%E7%94%9F%20(Merry%20Christmas%20Mr.Lawrence).dsf</span></div><div class="right"><div class="lazy img" data-bg="https://gcore.jsdelivr.net/gh/cdn-x/placeholder@1.0.12/link/8f277b4ee0ecd.svg"></div></div></a></div><h2 id="TIPS"><a href="#TIPS" class="headerlink" title="TIPS"></a>TIPS</h2><p>DSD类资源一般都比较大,对播放与监听设备可能要求较高。</p>]]></content>
<summary type="html">坂本龙一电影配乐《战场上的快乐圣诞》OST原声,DSD(DSF)|5.6MHz/1bit</summary>
<category term="分享" scheme="https://log.cns.red/categories/%E5%88%86%E4%BA%AB/"/>
<category term="音乐" scheme="https://log.cns.red/tags/%E9%9F%B3%E4%B9%90/"/>
</entry>
<entry>
<title>设计模式系列——观察者模式</title>
<link href="https://log.cns.red/design/Design-Observer/"/>
<id>https://log.cns.red/design/Design-Observer/</id>
<published>2024-06-10T02:01:00.000Z</published>
<updated>2026-02-13T10:53:03.126Z</updated>
<content type="html"><![CDATA[<h2 id="模式"><a href="#模式" class="headerlink" title="模式"></a>模式</h2><blockquote><p>一种订阅机制, 在可观察对象事件发生时通知多个 “观察” 该对象的其他对象。中文以订阅者(观察者)和订阅对象(可观察对象)更容易理解,而发布者理解为统一的通知部门。</p><p>啊〰老师老师,有人就要问了,为什么不用Kafka?Redis?RabbitMQ?<br>没有为什么,Kafka、Redis、RabbitMQ都是消息队列,但观察者模式是一种更加通用的模式,可以用于非使命必达的场景。</p></blockquote><ol><li><strong>发布者</strong> (Publisher):<ul><li>定义:当可观察对象发生变更,筛选对应的订阅者并发布他们关注的内容</li></ul></li><li><strong>订阅者</strong> (Subscriber):<ul><li>定义:除了有<code>update</code>方法,订阅者还需要实现逻辑来处理发布者的通知参数</li></ul></li></ol><h2 id="场景"><a href="#场景" class="headerlink" title="场景"></a>场景</h2><blockquote><p>这个模式的生活场景巨多,就比如 <a href="https://easyf12.top/">一蓑烟雨</a> 的博客就有<a href="https://mailchi.mp/32f0882c1d8e/easyf12">文章订阅</a> 哈哈哈</p></blockquote><ul><li>邮箱订阅:给感兴趣的人推送更新,当然现在不感兴趣也会被迫收到。</li><li>期刊订阅:小学订阅的小学生之友,还有英语老师让大家(可自愿)订阅的英语报。</li><li>菜市场:和老板娘说有漂亮的五花肉记得打电话给我。就是她有时候会忘记。</li><li>群聊通知:排除掉开启了免打扰的成员,剩下的都是订阅者。</li></ul><h2 id="案例"><a href="#案例" class="headerlink" title="案例"></a>案例</h2><h3 id="简单点"><a href="#简单点" class="headerlink" title="简单点"></a>简单点</h3><blockquote><p>一个商品降价订阅通知,商品为小米SU7,为了能在线分享用 TypeScript 写案例分享。</p></blockquote><p>以下代码点击 <a href="https://codesandbox.io/p/devbox/thatcoder-design-qdcpy4?embed=1&file=/src/observer/run.ts">codesandbox</a> 按钮即可运行。<br><a href="https://codesandbox.io/p/devbox/thatcoder-design-qdcpy4?embed=1&file=/src/observer/run.ts"><img src="https://codesandbox.io/static/img/play-codesandbox.svg" alt="Edit ThatCoder-Design"></a></p><h4 id="观察者接口"><a href="#观察者接口" class="headerlink" title="观察者接口"></a>观察者接口</h4><blockquote><p>定义了基本的观察者接口,有观察者的信息和可观察对象的变更回调方法<code>update()</code></p></blockquote><figure class="highlight ts"><figcaption><span>观察者接口</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><code class="hljs ts"><span class="hljs-comment">// Observer.ts 观察者接口</span><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">Observer</span> {<br> <span class="hljs-comment">// 可观察对象变更回调</span><br> <span class="hljs-title function_">update</span>(<span class="hljs-attr">product</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">price</span>: <span class="hljs-built_in">number</span>): <span class="hljs-built_in">void</span>;<br> <span class="hljs-attr">userUUID</span>: <span class="hljs-built_in">string</span>;<br> <span class="hljs-attr">email</span>: <span class="hljs-built_in">string</span>;<br> <span class="hljs-attr">subscriptionType</span>: <span class="hljs-title class_">SubscriptionType</span>;<br> discountThreshold?: <span class="hljs-built_in">number</span>; <span class="hljs-comment">// 仅对 DISCOUNT_TO 类型有效</span><br>}<br><br><span class="hljs-comment">// 订阅类型枚举</span><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">SubscriptionType</span> {<br> <span class="hljs-keyword">private</span> <span class="hljs-title function_">constructor</span>(<span class="hljs-params"><span class="hljs-keyword">public</span> <span class="hljs-keyword">readonly</span> model: <span class="hljs-built_in">string</span></span>) {}<br><br> <span class="hljs-keyword">static</span> <span class="hljs-keyword">readonly</span> <span class="hljs-variable constant_">IN_STOCK</span> = <span class="hljs-keyword">new</span> <span class="hljs-title class_">SubscriptionType</span>(<span class="hljs-string">"IN_STOCK"</span>);<br> <span class="hljs-keyword">static</span> <span class="hljs-keyword">readonly</span> <span class="hljs-variable constant_">DISCOUNT</span> = <span class="hljs-keyword">new</span> <span class="hljs-title class_">SubscriptionType</span>(<span class="hljs-string">"DISCOUNT"</span>);<br> <span class="hljs-keyword">static</span> <span class="hljs-keyword">readonly</span> <span class="hljs-variable constant_">DISCOUNT_TO</span> = <span class="hljs-keyword">new</span> <span class="hljs-title class_">SubscriptionType</span>(<span class="hljs-string">"DISCOUNT_TO"</span>);<br><br> <span class="hljs-title function_">getDescription</span>(): <span class="hljs-built_in">string</span> {<br> <span class="hljs-keyword">switch</span> (<span class="hljs-variable language_">this</span>.<span class="hljs-property">model</span>) {<br> <span class="hljs-keyword">case</span> <span class="hljs-string">"IN_STOCK"</span>:<br> <span class="hljs-keyword">return</span> <span class="hljs-string">"来货通知"</span>;<br> <span class="hljs-keyword">case</span> <span class="hljs-string">"DISCOUNT"</span>:<br> <span class="hljs-keyword">return</span> <span class="hljs-string">"降价通知"</span>;<br> <span class="hljs-keyword">case</span> <span class="hljs-string">"DISCOUNT_TO"</span>:<br> <span class="hljs-keyword">return</span> <span class="hljs-string">"降价到预期通知"</span>;<br> <span class="hljs-attr">default</span>:<br> <span class="hljs-keyword">return</span> <span class="hljs-string">"未知订阅"</span>;<br> }<br> }<br>}<br></code></pre></td></tr></table></figure><h4 id="观察者实现"><a href="#观察者实现" class="headerlink" title="观察者实现"></a>观察者实现</h4><blockquote><p>实现了观察者,增加了发送邮箱这个实际的通知方法,在<code>update()</code>实现通知调用</p></blockquote><figure class="highlight ts"><figcaption><span>观察者接口</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><code class="hljs ts"><span class="hljs-comment">// UserObserver.ts 实现具体的观察者,处理不同类型的通知</span><br><span class="hljs-keyword">import</span> {logger} <span class="hljs-keyword">from</span> <span class="hljs-string">"../util/Logger"</span><br><span class="hljs-keyword">import</span> { <span class="hljs-title class_">Observer</span>, <span class="hljs-title class_">SubscriptionType</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">"./Observer"</span>;<br><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">UserObserver</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">Observer</span> {<br> <span class="hljs-title function_">constructor</span>(<span class="hljs-params"></span><br><span class="hljs-params"> <span class="hljs-keyword">public</span> userUUID: <span class="hljs-built_in">string</span>,</span><br><span class="hljs-params"> <span class="hljs-keyword">public</span> email: <span class="hljs-built_in">string</span>,</span><br><span class="hljs-params"> <span class="hljs-keyword">public</span> subscriptionType: SubscriptionType,</span><br><span class="hljs-params"> <span class="hljs-keyword">public</span> discountThreshold?: <span class="hljs-built_in">number</span> <span class="hljs-comment">// 仅对 DISCOUNT_TO 类型有效</span></span><br><span class="hljs-params"> </span>) {}<br><br> <span class="hljs-title function_">update</span>(<span class="hljs-attr">product</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">price</span>: <span class="hljs-built_in">number</span>): <span class="hljs-built_in">void</span> {<br> <span class="hljs-keyword">switch</span> (<span class="hljs-variable language_">this</span>.<span class="hljs-property">subscriptionType</span>) {<br> <span class="hljs-keyword">case</span> <span class="hljs-title class_">SubscriptionType</span>.<span class="hljs-property">IN_STOCK</span>:<br> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">sendEmailNotification</span>(<span class="hljs-string">`<span class="hljs-subst">${product}</span> 来货了!`</span>);<br> <span class="hljs-keyword">break</span>;<br> <span class="hljs-keyword">case</span> <span class="hljs-title class_">SubscriptionType</span>.<span class="hljs-property">DISCOUNT</span>:<br> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">sendEmailNotification</span>(<span class="hljs-string">`<span class="hljs-subst">${product}</span> 现在已经降价至 $<span class="hljs-subst">${price}</span>!`</span>);<br> <span class="hljs-keyword">break</span>;<br> <span class="hljs-keyword">case</span> <span class="hljs-title class_">SubscriptionType</span>.<span class="hljs-property">DISCOUNT_TO</span>:<br> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">sendEmailNotification</span>(<br> <span class="hljs-string">`<span class="hljs-subst">${product}</span> 现在已经降价至 $<span class="hljs-subst">${price}</span>, 满足您期待的降价 $<span class="hljs-subst">${</span></span><br><span class="hljs-subst"><span class="hljs-string"> <span class="hljs-variable language_">this</span>.discountThreshold ?? <span class="hljs-number">0</span></span></span><br><span class="hljs-subst"><span class="hljs-string"> }</span>% !`</span><br> );<br> <span class="hljs-keyword">break</span>;<br> }<br> }<br><br> <span class="hljs-keyword">private</span> <span class="hljs-title function_">sendEmailNotification</span>(<span class="hljs-attr">message</span>: <span class="hljs-built_in">string</span>): <span class="hljs-built_in">void</span> {<br> logger.<span class="hljs-title function_">info</span>(<span class="hljs-string">`发送邮件 <span class="hljs-subst">${<span class="hljs-variable language_">this</span>.email}</span>: <span class="hljs-subst">${message}</span>`</span>);<br> }<br>}<br></code></pre></td></tr></table></figure><h4 id="可观察者接口"><a href="#可观察者接口" class="headerlink" title="可观察者接口"></a>可观察者接口</h4><blockquote><p>定义了基本的可观察者接口,主要有订阅、取消订阅、通知三要素。</p></blockquote><figure class="highlight ts"><figcaption><span>可观察者接口</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><code class="hljs ts"><span class="hljs-comment">// Observable.ts 定义一个可观察对象接口,包括订阅、取消订阅和通知方法</span><br><span class="hljs-keyword">import</span> { <span class="hljs-title class_">Observer</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">"../Observer"</span>;<br><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">Observable</span> {<br> <span class="hljs-comment">// 订阅</span><br> <span class="hljs-title function_">subscribe</span>(<span class="hljs-attr">observer</span>: <span class="hljs-title class_">Observer</span>): <span class="hljs-built_in">void</span>;<br><br> <span class="hljs-comment">// 取消订阅</span><br> <span class="hljs-title function_">unsubscribe</span>(<span class="hljs-attr">observer</span>: <span class="hljs-title class_">Observer</span>): <span class="hljs-built_in">void</span>;<br><br> <span class="hljs-comment">// 通知</span><br> <span class="hljs-title function_">notifyObservers</span>(): <span class="hljs-built_in">void</span>;<br>}<br></code></pre></td></tr></table></figure><h4 id="可观察者实现"><a href="#可观察者实现" class="headerlink" title="可观察者实现"></a>可观察者实现</h4><blockquote><p>实现了一个商品观察对象</p></blockquote><figure class="highlight ts"><figcaption><span>可观察者实现</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br></pre></td><td class="code"><pre><code class="hljs ts"><span class="hljs-comment">// ProductObservable.ts 实现具体的可观察对象(商品通知器)</span><br><span class="hljs-keyword">import</span> { <span class="hljs-title class_">Observable</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">"./Observable"</span>;<br><span class="hljs-keyword">import</span> { <span class="hljs-title class_">Observer</span>, <span class="hljs-title class_">SubscriptionType</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">"../Observer"</span>;<br><span class="hljs-keyword">import</span> { logger } <span class="hljs-keyword">from</span> <span class="hljs-string">"../../util/Logger"</span>;<br><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ProductObservable</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">Observable</span> {<br> <span class="hljs-keyword">private</span> <span class="hljs-attr">publishers</span>: <span class="hljs-title class_">Observer</span>[] = [];<br> <span class="hljs-keyword">private</span> <span class="hljs-attr">currentPrice</span>: <span class="hljs-built_in">number</span> = <span class="hljs-number">0.0</span>;<br> <span class="hljs-keyword">private</span> <span class="hljs-attr">originalPrice</span>: <span class="hljs-built_in">number</span> = <span class="hljs-number">100.0</span>; <span class="hljs-comment">// 原始价格,用于比较</span><br><br> <span class="hljs-title function_">constructor</span>(<span class="hljs-params"><span class="hljs-keyword">private</span> product: <span class="hljs-built_in">string</span></span>) {<br> logger.<span class="hljs-title function_">info</span>(<br> <span class="hljs-string">`创建可观察对象(商品:<span class="hljs-subst">${product}</span>),价格 $<span class="hljs-subst">${<span class="hljs-variable language_">this</span>.originalPrice}</span>`</span><br> );<br> }<br><br> <span class="hljs-title function_">subscribe</span>(<span class="hljs-attr">publisher</span>: <span class="hljs-title class_">Observer</span>): <span class="hljs-built_in">void</span> {<br> <span class="hljs-variable language_">this</span>.<span class="hljs-property">publishers</span>.<span class="hljs-title function_">push</span>(publisher);<br> logger.<span class="hljs-title function_">info</span>(<br> <span class="hljs-string">`用户UUID: <span class="hljs-subst">${publisher.userUUID}</span> ,成功订阅商品 <span class="hljs-subst">${</span></span><br><span class="hljs-subst"><span class="hljs-string"> <span class="hljs-variable language_">this</span>.product</span></span><br><span class="hljs-subst"><span class="hljs-string"> }</span> ,订阅类型 <span class="hljs-subst">${publisher.subscriptionType.getDescription()}</span>.`</span><br> );<br> }<br><br> <span class="hljs-title function_">unsubscribe</span>(<span class="hljs-attr">publisher</span>: <span class="hljs-title class_">Observer</span>): <span class="hljs-built_in">void</span> {<br> <span class="hljs-variable language_">this</span>.<span class="hljs-property">publishers</span> = <span class="hljs-variable language_">this</span>.<span class="hljs-property">publishers</span>.<span class="hljs-title function_">filter</span>(<br> <span class="hljs-function">(<span class="hljs-params">obs</span>) =></span> obs.<span class="hljs-property">userUUID</span> !== publisher.<span class="hljs-property">userUUID</span><br> );<br> logger.<span class="hljs-title function_">info</span>(<br> <span class="hljs-string">`用户UUID: <span class="hljs-subst">${publisher.userUUID}</span> ,取消订阅商品 <span class="hljs-subst">${<span class="hljs-variable language_">this</span>.product}</span> `</span><br> );<br> }<br><br> <span class="hljs-title function_">notifyObservers</span>(): <span class="hljs-built_in">void</span> {<br> <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> publisher <span class="hljs-keyword">of</span> <span class="hljs-variable language_">this</span>.<span class="hljs-property">publishers</span>) {<br> <span class="hljs-keyword">switch</span> (publisher.<span class="hljs-property">subscriptionType</span>) {<br> <span class="hljs-keyword">case</span> <span class="hljs-title class_">SubscriptionType</span>.<span class="hljs-property">IN_STOCK</span>:<br> publisher.<span class="hljs-title function_">update</span>(<span class="hljs-variable language_">this</span>.<span class="hljs-property">product</span>, <span class="hljs-variable language_">this</span>.<span class="hljs-property">currentPrice</span>);<br> <span class="hljs-keyword">break</span>;<br> <span class="hljs-keyword">case</span> <span class="hljs-title class_">SubscriptionType</span>.<span class="hljs-property">DISCOUNT</span>:<br> <span class="hljs-keyword">if</span> (<span class="hljs-variable language_">this</span>.<span class="hljs-property">currentPrice</span> < <span class="hljs-variable language_">this</span>.<span class="hljs-property">originalPrice</span>) {<br> publisher.<span class="hljs-title function_">update</span>(<span class="hljs-variable language_">this</span>.<span class="hljs-property">product</span>, <span class="hljs-variable language_">this</span>.<span class="hljs-property">currentPrice</span>);<br> }<br> <span class="hljs-keyword">break</span>;<br> <span class="hljs-keyword">case</span> <span class="hljs-title class_">SubscriptionType</span>.<span class="hljs-property">DISCOUNT_TO</span>:<br> <span class="hljs-keyword">if</span> (<span class="hljs-variable language_">this</span>.<span class="hljs-property">currentPrice</span> <= (publisher.<span class="hljs-property">discountThreshold</span> ?? <span class="hljs-number">0</span>)) {<br> publisher.<span class="hljs-title function_">update</span>(<span class="hljs-variable language_">this</span>.<span class="hljs-property">product</span>, <span class="hljs-variable language_">this</span>.<span class="hljs-property">currentPrice</span>);<br> }<br> <span class="hljs-keyword">break</span>;<br> }<br> }<br> }<br> <br> <span class="hljs-title function_">productRestocked</span>(): <span class="hljs-built_in">void</span> {<br> logger.<span class="hljs-title function_">info</span>(<span class="hljs-string">`商品 <span class="hljs-subst">${<span class="hljs-variable language_">this</span>.product}</span> 采购成功`</span>);<br> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">notifyObservers</span>();<br> }<br><br> <span class="hljs-title function_">productDiscounted</span>(<span class="hljs-attr">newPrice</span>: <span class="hljs-built_in">number</span>): <span class="hljs-built_in">void</span> {<br> <span class="hljs-variable language_">this</span>.<span class="hljs-property">currentPrice</span> = newPrice;<br> <span class="hljs-keyword">if</span> (newPrice === <span class="hljs-variable language_">this</span>.<span class="hljs-property">originalPrice</span>) {<br> logger.<span class="hljs-title function_">info</span>(<span class="hljs-string">`商品 <span class="hljs-subst">${<span class="hljs-variable language_">this</span>.product}</span> 恢复原价`</span>);<br> } <span class="hljs-keyword">else</span> {<br> logger.<span class="hljs-title function_">info</span>(<span class="hljs-string">`商品 <span class="hljs-subst">${<span class="hljs-variable language_">this</span>.product}</span> 降价至: $<span class="hljs-subst">${<span class="hljs-variable language_">this</span>.currentPrice}</span>`</span>);<br> }<br> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">notifyObservers</span>();<br> }<br>}<br></code></pre></td></tr></table></figure><h4 id="测试效果"><a href="#测试效果" class="headerlink" title="测试效果"></a>测试效果</h4><blockquote><p>创建 小米SU7 这个可观察对象<br>三个用户关注了 小米SU7,关注类型不一样<br>在 小米SU7 库存和价格变动时候可以观测到对应的通知变化</p></blockquote><figure class="highlight ts"><figcaption><span>测试</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br></pre></td><td class="code"><pre><code class="hljs ts"><span class="hljs-comment">// main.ts</span><br><span class="hljs-keyword">import</span> { <span class="hljs-title class_">ProductObservable</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">"./observable/ProductObservable"</span>;<br><span class="hljs-keyword">import</span> { <span class="hljs-title class_">UserObserver</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">"./UserObserver"</span>;<br><span class="hljs-keyword">import</span> { <span class="hljs-title class_">SubscriptionType</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">"./Observer"</span>;<br><span class="hljs-keyword">import</span> { logger } <span class="hljs-keyword">from</span> <span class="hljs-string">"../util/Logger"</span>;<br><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title function_">TestObserver</span> = (<span class="hljs-params"></span>) => {<br> <span class="hljs-comment">// 创建可观察对象(商品通知器)</span><br> <span class="hljs-keyword">const</span> su7Notifier = <span class="hljs-keyword">new</span> <span class="hljs-title class_">ProductObservable</span>(<span class="hljs-string">"小米SU7"</span>);<br><br> <span class="hljs-comment">// 创建观察者(用户)</span><br> <span class="hljs-keyword">const</span> user1 = <span class="hljs-keyword">new</span> <span class="hljs-title class_">UserObserver</span>(<br> <span class="hljs-string">"UUID-1111"</span>,<br> <span class="hljs-string">"user1@thatcoder.cn"</span>,<br> <span class="hljs-title class_">SubscriptionType</span>.<span class="hljs-property">IN_STOCK</span><br> );<br> <span class="hljs-keyword">const</span> user2 = <span class="hljs-keyword">new</span> <span class="hljs-title class_">UserObserver</span>(<br> <span class="hljs-string">"UUID-2222"</span>,<br> <span class="hljs-string">"user2@thatcoder.cn"</span>,<br> <span class="hljs-title class_">SubscriptionType</span>.<span class="hljs-property">DISCOUNT</span><br> );<br> <span class="hljs-keyword">const</span> user3 = <span class="hljs-keyword">new</span> <span class="hljs-title class_">UserObserver</span>(<br> <span class="hljs-string">"UUID-3333"</span>,<br> <span class="hljs-string">"user3@thatcoder.cn"</span>,<br> <span class="hljs-title class_">SubscriptionType</span>.<span class="hljs-property">DISCOUNT_TO</span>,<br> <span class="hljs-number">50</span><br> );<br><br> <span class="hljs-comment">// 用户1订阅iPhone 15有货通知</span><br> su7Notifier.<span class="hljs-title function_">subscribe</span>(user1);<br> <span class="hljs-comment">// 用户2订阅iPhone 15降价通知</span><br> su7Notifier.<span class="hljs-title function_">subscribe</span>(user2);<br> <span class="hljs-comment">// 用户3订阅iPhone 15降价到50%通知</span><br> su7Notifier.<span class="hljs-title function_">subscribe</span>(user3);<br><br> <span class="hljs-comment">// 商品到货,通知相关用户</span><br> su7Notifier.<span class="hljs-title function_">productRestocked</span>();<br><br> <span class="hljs-comment">// 商品降价,通知相关用户</span><br> su7Notifier.<span class="hljs-title function_">productDiscounted</span>(<span class="hljs-number">60.0</span>);<br><br> <span class="hljs-comment">// 商品恢复原价</span><br> su7Notifier.<span class="hljs-title function_">productDiscounted</span>(<span class="hljs-number">100.0</span>);<br><br> <span class="hljs-comment">// 商品降价到50%,通知相关用户</span><br> su7Notifier.<span class="hljs-title function_">productDiscounted</span>(<span class="hljs-number">45.0</span>);<br><br> <span class="hljs-comment">// 用户1取消iPhone 15的订阅</span><br> su7Notifier.<span class="hljs-title function_">unsubscribe</span>(user1);<br><br> <span class="hljs-comment">// 商品到货,通知剩余的用户</span><br> su7Notifier.<span class="hljs-title function_">productRestocked</span>();<br>};<br></code></pre></td></tr></table></figure><h4 id="测试结果"><a href="#测试结果" class="headerlink" title="测试结果"></a>测试结果</h4><p>和预想一致,可观察对象只需要关注自己的变动就可以了,用户考虑的就多了(还要点击订阅)。<br>降价到60,所以用户3不被通知<br>用户1取消订阅,所以来货了也不被通知<br>当然这是最简单的示例</p><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1463/577;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/20240811190729.png" data-src="https://upyun.thatcdn.cn/myself/typora/20240811190729.png" alt="运行结果" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">运行结果</span></div></div><h3 id="Spring监听机制"><a href="#Spring监听机制" class="headerlink" title="Spring监听机制"></a>Spring监听机制</h3><p>Spring有<code>EventListener</code>类似去定义一个事件的处理逻辑,相当于在里面写了订阅者的通知方法。<code>ApplicationEventPublisher</code>会去发布定义的事件,相当于可观察者的对象发生了变动。不同的是我们只关心发布和处理逻辑即可,中间的调用交给了<code>Listener</code>。</p><h4 id="生命周期事件"><a href="#生命周期事件" class="headerlink" title="生命周期事件"></a>生命周期事件</h4><p>在包 <a href="https://github.com/spring-projects/spring-framework/tree/bf5e218b3580df20e6df129eadead4721e1f4f03/spring-context/src/main/java/org/springframework/context/event">org.springframework.context.event</a> 下面有很多与 <code>ApplicationContext</code> 生命周期相关的事件,这些事件都继承自 <code>ApplicationContextEvent</code>,包括 <code>ContextRefreshedEvent</code>, <code>ContextStartedEvent</code>, <code>ContextStoppedEvent</code>, <code>ContextClosedEvent</code>。<br>到了对应的生命周期会调用订阅。</p><figure class="highlight kotlin"><figcaption><span>启动和刷新</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-keyword">import</span> org.springframework.context.ApplicationListener<br><span class="hljs-keyword">import</span> org.springframework.context.event.ContextRefreshedEvent <br><span class="hljs-keyword">import</span> org.springframework.stereotype.Component <br> <br><span class="hljs-meta">@Component</span> <br><span class="hljs-keyword">class</span> <span class="hljs-title class_">StartupListener</span> : <span class="hljs-type">ApplicationListener</span><<span class="hljs-type">ContextRefreshedEvent</span>> { <br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onApplicationEvent</span><span class="hljs-params">(event: <span class="hljs-type">ContextRefreshedEvent</span>)</span></span> { <br> println(<span class="hljs-string">"应用刷新成功!"</span>) <br> } <br>}<br></code></pre></td></tr></table></figure><h4 id="事务监听"><a href="#事务监听" class="headerlink" title="事务监听"></a>事务监听</h4><blockquote><p>@TransactionalEventListener<br>举例一个下单成功后的发布事务</p></blockquote><figure class="highlight kotlin"><figcaption><span>事件定义</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-keyword">data</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">OrderPlacedEvent</span>(<span class="hljs-keyword">val</span> orderId: String, <span class="hljs-keyword">val</span> userEmail: String)<br></code></pre></td></tr></table></figure><figure class="highlight kotlin"><figcaption><span>事件处理</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-keyword">import</span> org.springframework.context.event.TransactionalEventListener<br><span class="hljs-keyword">import</span> org.springframework.stereotype.Component<br><br><span class="hljs-meta">@Component</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">OrderPlacedEventListener</span> {<br><br> <span class="hljs-meta">@TransactionalEventListener</span><br> <span class="hljs-meta">@Async</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">handleOrderPlacedEvent</span><span class="hljs-params">(event: <span class="hljs-type">OrderPlacedEvent</span>)</span></span> {<br> <span class="hljs-comment">// 发送订单确认邮件</span><br> <span class="hljs-keyword">val</span> orderId = event.orderId<br> <span class="hljs-keyword">val</span> userEmail = event.userEmail<br> println(<span class="hljs-string">"发送 <span class="hljs-variable">$orderId</span> 信息到用户邮箱 <span class="hljs-variable">$userEmail</span>"</span>)<br> <span class="hljs-comment">// 实际发送邮件的逻辑...</span><br> }<br>}<br></code></pre></td></tr></table></figure><figure class="highlight kotlin"><figcaption><span>事件触发</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-keyword">import</span> org.springframework.context.ApplicationEventPublisher<br><span class="hljs-keyword">import</span> org.springframework.stereotype.Service<br><span class="hljs-keyword">import</span> org.springframework.transaction.<span class="hljs-keyword">annotation</span>.Transactional<br><br><span class="hljs-meta">@Service</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">OrderService</span>(<span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> eventPublisher: ApplicationEventPublisher) {<br><br> <span class="hljs-meta">@Transactional</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">save</span><span class="hljs-params">(order: <span class="hljs-type">Order</span>)</span></span> {<br> <span class="hljs-comment">// 处理下单逻辑...</span><br> <span class="hljs-comment">// 发布事件</span><br> eventPublisher.publishEvent(OrderPlacedEvent(orderId, userEmail))<br> }<br>}<br></code></pre></td></tr></table></figure><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><h3 id="优点"><a href="#优点" class="headerlink" title="优点"></a>优点</h3><ul><li><strong>代码解耦</strong>:观察者和订阅者的逻辑分开,订阅者只引用了抽象的发布者接口,每个可观察者只需要关注自己的实现。</li><li><strong>抽象耦合</strong>:如上代码解耦后逻辑上依然保持着抽象的耦合,订阅者只需要注册订阅即可</li></ul><h3 id="缺点"><a href="#缺点" class="headerlink" title="缺点"></a>缺点</h3><ul><li><strong>隐式依赖</strong>:抽象耦合就代表着事件通知机制是隐式的,系统的行为可能变得难以预测和理解。及时补充文档,不然就慢慢DEBUG。</li><li><strong>瞬时峰值</strong>:某个可观察对象有大量订阅时,触发<code>update</code>带来的巨额性能开销可能会导致性能瓶颈,甚至系统阻塞。注意异步和削峰。</li><li><strong>并发问题</strong>:多线程中,事件的发布和订阅者的变动可能带来并发问题。需要复杂的同步机制来确保线程安全,比如<code>ConcurrentModificationException</code>。除了线程安全的集合可能还需要考虑显式锁、读写锁或原子操作。</li></ul><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:670/334;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/77412f61-b3f4-4a0a-8a2d-acf82821291c.jpeg" data-src="https://upyun.thatcdn.cn/myself/typora/77412f61-b3f4-4a0a-8a2d-acf82821291c.jpeg" alt="IDEA的监听耳机" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">IDEA的监听耳机</span></div></div>]]></content>
<summary type="html">观察者模式定义对象间一对多依赖关系,使得一对象状态变化时通知并更新其依赖对象进行处理。</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="设计模式" scheme="https://log.cns.red/tags/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F/"/>
<category term="编程艺术" scheme="https://log.cns.red/tags/%E7%BC%96%E7%A8%8B%E8%89%BA%E6%9C%AF/"/>
</entry>
<entry>
<title>设计模式系列——责任链模式</title>
<link href="https://log.cns.red/design/Design-Chain-of-Responsibility/"/>
<id>https://log.cns.red/design/Design-Chain-of-Responsibility/</id>
<published>2024-06-10T02:00:00.000Z</published>
<updated>2026-02-13T10:53:03.126Z</updated>
<content type="html"><![CDATA[<p>开个坑补齐设计模式系列笔记, 顺带回顾Spring源码中的设计模式运用…</p><h2 id="模式"><a href="#模式" class="headerlink" title="模式"></a>模式</h2><blockquote><p>责任链模式(Chain of Responsibility Pattern)是一种行为设计模式,它允许多个对象有机会处理同一个请求,从而避免请求的发送者与多个接收者之间的耦合, 通常用于请求有多个阶段。该模式通过将这些对象连成一条链,并沿着链传递请求,直到有一个对象处理请求为止。如果链的末端没有对象处理请求,整个请求将被丢弃或默认处理。</p></blockquote><p><strong>未加粗的是责任链复杂程度上去之后可选的角色</strong></p><ol><li><strong>抽象处理者(Handler)</strong>:<ul><li><strong>定义</strong>:一个接口或抽象类,通常包含一个方法来处理请求以及一个方法来设置下一个处理者。它规定了所有具体处理者都必须实现的基本操作,如处理请求或将请求传递给链中的下一个处理者。</li><li><strong>角色</strong>:<strong>接口或抽象类</strong> 作为处理链的核心定义,保证每个处理者都具有处理请求的能力。</li></ul></li></ol><ul><li>基础处理者 (Base Handler): 可选<ul><li><strong>定义</strong>:一个接口或部分实现类,它实现了抽象处理者的部分功能或是抽象处理者的拓展。</li><li><strong>角色</strong>:<strong>复用和扩展</strong> 提供公共的逻辑实现,减少子类的重复代码,增强代码的可复用性。</li></ul></li></ul><ol start="2"><li><strong>具体处理者(ConcreteHandlers)</strong>:<ul><li><strong>定义</strong>:每个实现类,实现了处理某种请求的逻辑。当它无法处理请求时,会将请求传递给链中的下一个处理者。</li><li><strong>角色</strong>:<strong>链的节点实现</strong> 实现自己的处理逻辑来参与责任链的请求处理。</li></ul></li></ol><ul><li>链的管理者(Chain Manager):可选<ol><li>定义:通常是一个Chain类,实现创建和管理处理者的顺序、组装责任链,并维护链的整体状态。</li><li>角色:<strong>构建和维护责任链</strong> 确保处理者按照正确的顺序处理请求,同时可以动态地添加、移除或调整链中的处理者。</li></ol></li><li>链的构造器(Chain Creator):可选<ol><li>定义:通常是一个工厂类或构造器,负责初始化责任链的结构并返回链的起始处理者。它可以根据具体的业务需求,创建不同类型的责任链。</li><li>角色:<strong>生成责任链实例</strong> 确保处理者按照正确的顺序处理请求,同时可以动态地添加、移除或调整链中的处理者。</li></ol></li></ul><ol start="3"><li><strong>客户类(Client)</strong>:<ul><li><strong>定义</strong>:客户类是发起请求的对象,它通常会自行或调用链的构造器创建并设置责任链,然后向责任链的第一个处理者提交请求。</li><li><strong>角色</strong>:<strong>责任链的创建者</strong> <strong>请求的发起者</strong></li></ul></li></ol><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1095/541;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/547ba1ae581c65781d4575b7b5792b0c.png" data-src="https://upyun.thatcdn.cn/myself/typora/547ba1ae581c65781d4575b7b5792b0c.png" alt="责任链模式结构图" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">责任链模式结构图</span></div></div><h2 id="场景"><a href="#场景" class="headerlink" title="场景"></a>场景</h2><p>公司报销流程就像一条责任链,每个环节都根据条件决定是否继续处理:</p><ol><li><strong>填写申请</strong>:你提交的报销单是链上的第一个环节,检查所有必要的字段和金额,确保格式正确。如果不符合条件,它会被退回。</li><li><strong>审批环节</strong>:提交后的申请进入审批环节(链路)。每一级上司根据公司政策比如费用上限或是否有票据判断是否继续处理。如果符合条件或该审批者无权审批,申请会被传递到下一个环节。</li><li><strong>财务审核</strong>:通过所有审批后,财务部门再进行一次核查,确保链路都是<code>true</code>且符合财务规定。如果审核通过,申请继续流向资金发放环节 (实际的处理方法)。</li><li><strong>资金发放</strong>:经过所有环节的条件检查和批准,资金会发放到你的账户中。</li></ol><p>每个环节都有自己的条件判断,每个节点只处理自己能处理的部分,不符合条件的节点将被跳过,确保整个流程高效顺畅。</p><h2 id="案例"><a href="#案例" class="headerlink" title="案例"></a>案例</h2><h3 id="简单点"><a href="#简单点" class="headerlink" title="简单点"></a>简单点</h3><blockquote><p>先来个简单点的,虚拟一个简单的过滤器链实现和实际的使用,用来处理HTTP请求。</p></blockquote><h4 id="抽象处理者"><a href="#抽象处理者" class="headerlink" title="抽象处理者"></a>抽象处理者</h4><figure class="highlight kotlin"><figcaption><span>接口定义</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-keyword">interface</span> <span class="hljs-title class_">Handler</span> : <span class="hljs-type">Comparable</span><<span class="hljs-type">Handler</span>> {<br> <span class="hljs-keyword">var</span> index: <span class="hljs-built_in">Int</span> <span class="hljs-comment">// 责任链顺序</span><br><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">setNext</span><span class="hljs-params">(handler: <span class="hljs-type">Handler</span>)</span></span>: Handler <span class="hljs-comment">// 下一个</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">handleRequest</span><span class="hljs-params">(request: <span class="hljs-type">Request</span>)</span></span> <span class="hljs-comment">// 处理方法,处理同一种入参</span><br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">compareTo</span><span class="hljs-params">(other: <span class="hljs-type">Handler</span>)</span></span>: <span class="hljs-built_in">Int</span> = <span class="hljs-keyword">this</span>.index - other.index<br>}<br></code></pre></td></tr></table></figure><h4 id="基础处理者"><a href="#基础处理者" class="headerlink" title="基础处理者"></a>基础处理者</h4><figure class="highlight kotlin"><figcaption><span>基础实现</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-comment">// 基础处理者,提供处理链传递逻辑,并实现index</span><br><span class="hljs-keyword">abstract</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">BaseHandler</span>(<span class="hljs-keyword">override</span> <span class="hljs-keyword">var</span> index: <span class="hljs-built_in">Int</span>) : Handler {<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> nextHandler: Handler? = <span class="hljs-literal">null</span><br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">setNext</span><span class="hljs-params">(handler: <span class="hljs-type">Handler</span>)</span></span>: Handler {<br> nextHandler = handler<br> <span class="hljs-keyword">return</span> handler<br> }<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">handleRequest</span><span class="hljs-params">(request: <span class="hljs-type">Request</span>)</span></span> {<br> nextHandler?.handleRequest(request)<br> }<br>}<br></code></pre></td></tr></table></figure><h4 id="具体处理者"><a href="#具体处理者" class="headerlink" title="具体处理者"></a>具体处理者</h4><figure class="highlight kotlin"><figcaption><span>具体实现</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-comment">// 具体处理者1:处理认证逻辑</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">AuthenticationHandler</span>(index: <span class="hljs-built_in">Int</span>) : BaseHandler(index) {<br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">handleRequest</span><span class="hljs-params">(request: <span class="hljs-type">Request</span>)</span></span> {<br> <span class="hljs-keyword">if</span> (<span class="hljs-string">"ROLE_ADMIN"</span> == request?.role) {<br> <span class="hljs-keyword">super</span>.handleRequest(request)<br> } <span class="hljs-keyword">else</span> {<br> response.WriteError(<span class="hljs-string">"认证失败"</span>)<br> }<br> }<br>}<br><br><span class="hljs-comment">// 具体处理者2:处理日志记录逻辑</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">LoggingHandler</span>(index: <span class="hljs-built_in">Int</span>) : BaseHandler(index) {<br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">handleRequest</span><span class="hljs-params">(request: <span class="hljs-type">Request</span>)</span></span> {<br> Logger.info(<span class="hljs-string">"登录:<span class="hljs-subst">${request.username}</span>"</span>)<br> <span class="hljs-keyword">super</span>.handleRequest(request)<br> }<br>}<br><br></code></pre></td></tr></table></figure><h4 id="链管理者"><a href="#链管理者" class="headerlink" title="链管理者"></a>链管理者</h4><figure class="highlight kotlin"><figcaption><span>链管理者</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-comment">// 链的管理者:负责组装和管理责任链</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">ChainManager</span> {<br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> handlers: MutableList<Handler> = mutableListOf()<br> <span class="hljs-comment">// 添加处理器到链中,指定顺序</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">addHandler</span><span class="hljs-params">(handler: <span class="hljs-type">Handler</span>)</span></span>: ChainManager {<br> handlers.add(handler)<br> <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span><br> }<br> <span class="hljs-comment">// 从链中移除处理器</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">removeHandler</span><span class="hljs-params">(handler: <span class="hljs-type">Handler</span>)</span></span>: ChainManager {<br> handlers.remove(handler)<br> <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span><br> }<br> <span class="hljs-comment">// 构建责任链</span><br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">buildChain</span><span class="hljs-params">()</span></span>: Handler? {<br> <span class="hljs-keyword">if</span> (handlers.isEmpty()) {<br> <span class="hljs-keyword">throw</span> IllegalArgumentException(<span class="hljs-string">"空链"</span>)<br> }<br> <span class="hljs-comment">// 根据index字段排序</span><br> handlers.sort()<br> <span class="hljs-comment">// 链接处理器</span><br> <span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span> until handlers.size - <span class="hljs-number">1</span>) {<br> handlers[i].setNext(handlers[i + <span class="hljs-number">1</span>])<br> }<br> <span class="hljs-keyword">return</span> handlers.firstOrNull()<br> }<br>}<br></code></pre></td></tr></table></figure><h4 id="链构建者"><a href="#链构建者" class="headerlink" title="链构建者"></a>链构建者</h4><figure class="highlight kotlin"><figcaption><span>链构建者</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-comment">// 链的创建者:工厂类,用于创建责任链</span><br><span class="hljs-keyword">object</span> ChainCreator {<br> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">createDefaultChain</span><span class="hljs-params">()</span></span>: Handler? {<br> <span class="hljs-keyword">val</span> manager = ChainManager()<br> manager.addHandler(LoggingHandler(<span class="hljs-number">2</span>)) <span class="hljs-comment">// 指定顺序</span><br> .addHandler(AuthenticationHandler(<span class="hljs-number">1</span>))<br> <span class="hljs-keyword">return</span> manager.buildChain()<br> }<br>}<br></code></pre></td></tr></table></figure><h4 id="使用"><a href="#使用" class="headerlink" title="使用"></a>使用</h4><blockquote><p>这里假设将我们虚拟的责任链加入到请求拦截器,真实这里应该是基于<code>HandlerInterceptor</code>实现的处理者。</p></blockquote><figure class="highlight kotlin"><figcaption><span>run</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs kotlin"><span class="hljs-meta">@Configuration</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">WebConfig</span> : <span class="hljs-type">WebMvcConfigurer</span> {<br><br> <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">addInterceptors</span><span class="hljs-params">(registry: <span class="hljs-type">InterceptorRegistry</span>)</span></span> {<br> <span class="hljs-comment">// 创建责任链</span><br> <span class="hljs-keyword">val</span> chain = ChainCreator.createDefaultChain()<br> <br> <span class="hljs-comment">// 注册责任链的第一个处理者</span><br> registry.addInterceptor(chain)<br> }<br>}<br></code></pre></td></tr></table></figure><h3 id="Spring-Web"><a href="#Spring-Web" class="headerlink" title="Spring Web"></a>Spring Web</h3><blockquote><p>在 Spring Web 框架中,责任链模式被广泛应用于请求处理的各个阶段,尤其是在处理请求拦截和映射时。</p></blockquote><p>看 Spring Web 案例代码之前,现梳理一遍请求到链路末端处理到返回的逻辑,比如启动并访问 <code>OrderController </code>的 <code>@GetMapping("/order/list") </code>的 <code>public ResponseEntity<ResponseRow> list() </code>方法。</p><ol><li>Spring Boot 启动时,<code>SpringApplication.run()</code> 方法会被调用。这一方法负责引导和启动 Spring 应用程序上下文,加载所有的配置类和 Bean。</li><li>理所当然 <a href="https://github.com/spring-projects/spring-framework/blob/bf5e218b3580df20e6df129eadead4721e1f4f03/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java">DispatcherServlet.java</a> 作为核心组件会被通过自动配置机制自动注册并初始化,当作 Spring MVC 的前端控制器,用于接收和处理所有的 HTTP 请求。</li><li><code>DispatcherServlet</code> 初始化时会调用 <a href="https://github.com/spring-projects/spring-framework/blob/bf5e218b3580df20e6df129eadead4721e1f4f03/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java#L600-L621">initHandlerMappings()</a> 方法来加载所有的<code>HandlerMapping</code> 实现类储存到私有属性 <code>private List<HandlerMapping> handlerMappings</code>,并且等待处理即将到来的请求。</li><li><a href="https://github.com/spring-projects/spring-framework/blob/bf5e218b3580df20e6df129eadead4721e1f4f03/spring-webmvc/src/main/java/org/springframework/web/servlet/HandlerMapping.java">HandlerMapping</a> 类负责将请求映射到适当的处理器(例如 <code>@Controller</code> 中的方法)。</li><li>用户发出 <code>/order/list</code> 请求时,<code>DispatcherServlet</code> 作为前端控制器接收 HTTP 请求,在 <a href="https://github.com/spring-projects/spring-framework/blob/bf5e218b3580df20e6df129eadead4721e1f4f03/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java#L1048-L1129">doDispatch()</a> 方法中遍历 <code>handlerMappings</code> 列表并调用每个 <code>HandlerMapping</code> 实现类的 <code>getHandler()</code> 方法,尝试找到合适的 <code>HandlerExecutionChain</code> 来处理这个请求。</li><li>我们举例是 <code>@GetMapping("/order/list") </code>所以会用<a href="https://github.com/spring-projects/spring-framework/blob/bf5e218b3580df20e6df129eadead4721e1f4f03/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java">RequestMappingHandlerMapping</a>识别出 <code>OrderController</code> 中相应的处理方法,并返回一个 <code>HandlerExecutionChain</code>。这个 <code>HandlerExecutionChain</code> 包含了处理该请求的控制器方法(如 <code>OrderController.list()</code>)以及相关的拦截器链。</li><li>当然拦截器(多个 <code>HandlerInterceptor</code> 以及可能是异步的 <code>AsyncHandlerInterceptor</code> 和 <code>WebRequestHandlerInterceptorAdapter</code> )可能会执行一些通用逻辑,如权限验证、日志记录等,都是按既定顺序执行。如果所有的拦截器都允许请求通过,将调用实际的处理器方法(如 <code>OrderController.list()</code>)。</li><li>调用实际的处理器方法后还会继续调用拦截器的 <code>postHandle()</code> 方法,在生成响应之前对结果进一步处理。最后,无论请求是否成功,<code>afterCompletion()</code> 方法都会被调用,此为清理操作。</li></ol><h4 id="关系图"><a href="#关系图" class="headerlink" title="关系图"></a>关系图</h4><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1309/952;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/20240810215423.png" data-src="https://upyun.thatcdn.cn/myself/typora/20240810215423.png" alt="DispatcherServlet.java" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">DispatcherServlet.java</span></div></div> <div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1351/850;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/20240810215605.png" data-src="https://upyun.thatcdn.cn/myself/typora/20240810215605.png" alt="RequestMappingHandlerMapping.java" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">RequestMappingHandlerMapping.java</span></div></div> <div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:738/559;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/20240810214830.png" data-src="https://upyun.thatcdn.cn/myself/typora/20240810214830.png" alt="抽象处理者、基础处理者、具体处理者" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">抽象处理者、基础处理者、具体处理者</span></div></div><h4 id="HandlerInterceptor"><a href="#HandlerInterceptor" class="headerlink" title="HandlerInterceptor"></a>HandlerInterceptor</h4><blockquote><p><strong>抽象处理者</strong> 网络请求处理的不同阶段拦截器</p></blockquote><pre><code class="java">public interface HandlerInterceptor { default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { return true; }//调用"Controller"方法之前 default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception { }//调用"Controller"方法之后渲染"ModelAndView"之前 default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception { }//渲染"ModelAndView"之后 } </code></pre><h4 id="AsyncHandlerInterceptor"><a href="#AsyncHandlerInterceptor" class="headerlink" title="AsyncHandlerInterceptor"></a>AsyncHandlerInterceptor</h4><blockquote><p><strong>基础处理者</strong> 继承了 <code>HandlerInterceptor</code> 接口,并添加了处理异步请求的方法。</p></blockquote><pre><code class="java">public interface AsyncHandlerInterceptor extends HandlerInterceptor { default void afterConcurrentHandlingStarted(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { } } </code></pre><h4 id="WebRequestHandlerInterceptorAdapter"><a href="#WebRequestHandlerInterceptorAdapter" class="headerlink" title="WebRequestHandlerInterceptorAdapter"></a>WebRequestHandlerInterceptorAdapter</h4><blockquote><p><strong>具体处理者</strong> 实现了 <code>AsyncHandlerInterceptor</code> 接口。并将 <code>WebRequestInterceptor</code> 转换为 <code>HandlerInterceptor</code>。它将 <code>WebRequestInterceptor</code> 的处理逻辑适配为 HTTP 请求处理逻辑,并处理异步请求的逻辑。</p></blockquote><pre><code class="java">public class WebRequestHandlerInterceptorAdapter implements AsyncHandlerInterceptor { private final WebRequestInterceptor requestInterceptor; public WebRequestHandlerInterceptorAdapter(WebRequestInterceptor requestInterceptor) { Assert.notNull(requestInterceptor, "WebRequestInterceptor must not be null"); this.requestInterceptor = requestInterceptor; } public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { this.requestInterceptor.preHandle(new DispatcherServletWebRequest(request, response)); return true; } public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception { this.requestInterceptor.postHandle(new DispatcherServletWebRequest(request, response), modelAndView != null && !modelAndView.wasCleared() ? modelAndView.getModelMap() : null); } public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception { this.requestInterceptor.afterCompletion(new DispatcherServletWebRequest(request, response), ex); } public void afterConcurrentHandlingStarted(HttpServletRequest request, HttpServletResponse response, Object handler) { WebRequestInterceptor var5 = this.requestInterceptor; if (var5 instanceof AsyncWebRequestInterceptor asyncInterceptor) { DispatcherServletWebRequest webRequest = new DispatcherServletWebRequest(request, response); asyncInterceptor.afterConcurrentHandlingStarted(webRequest); } } } </code></pre><h4 id="DispatcherServlet"><a href="#DispatcherServlet" class="headerlink" title="DispatcherServlet"></a>DispatcherServlet</h4><blockquote><p><strong>客户端</strong> 该调度程序负责配置和执行拦截器链。Spring MVC 的前端控制器,用于接收和处理所有的 HTTP 请求。</p><p>这条链路客户端到处理器中间其实还有两个部分,稍后就有介绍<br><strong>HandlerMapping</strong>:用于查找与请求匹配的处理器(<code>Controller</code>),并返回<code>HandlerExecutionChain</code>。<br><strong>HandlerExecutionChain</strong>:包含处理器和一系列拦截器(处理器)(<code>HandlerInterceptor</code>)。它负责在请求处理过程中依次调用链中的拦截器。</p></blockquote><pre><code class="java">public class DispatcherServlet extends FrameworkServlet { ... protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception { HandlerExecutionChain mappedHandler = null; ... // 获取处理链 mappedHandler = this.getHandler(processedRequest); ... } ... @Nullable protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception { if (this.handlerMappings != null) { Iterator var2 = this.handlerMappings.iterator(); while(var2.hasNext()) { HandlerMapping mapping = (HandlerMapping)var2.next(); HandlerExecutionChain handler = mapping.getHandler(request); if (handler != null) { return handler; } } } return null; } ...} </code></pre><h4 id="HandlerExecutionChain"><a href="#HandlerExecutionChain" class="headerlink" title="HandlerExecutionChain"></a>HandlerExecutionChain</h4><blockquote><p><strong>处理链管理者</strong> Spring Web中需要一个角色,将处理器和拦截器链组织在一起,按照顺序处理请求。</p><p>它是一个管理处理器和拦截器链的组件。在处理请求时,它负责管理和调用所有的拦截器方法,并最终调用实际的处理器(例如 Controller)。</p></blockquote><h4 id="HandlerMapping"><a href="#HandlerMapping" class="headerlink" title="HandlerMapping"></a>HandlerMapping</h4><blockquote><p><strong>链的建立者</strong> Spring Web中需要一个角色,负责从请求中确定合适的处理器,并将处理器与拦截器链组合起来,形成一个完整的处理链(也就是<code>HandlerExecutionChain</code>)。</p><p>它可以被视为该条责任链的启动点或“链的建立者”。它确定了请求将由哪个处理器处理,并且可能为处理器配置了一些拦截器。</p></blockquote><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><h3 id="优点"><a href="#优点" class="headerlink" title="优点"></a>优点</h3><ul><li><strong>业务解耦</strong>:每个节点独立可以随时新增或删除,不仅不会影响事务请求的业务代码,还不会影响其它责任节点。</li><li><strong>责任单一</strong>:责任链每个节点处理的对象是同一种,但是每个节点处理的事务不一样。</li><li><strong>动态组合</strong>:节点之间有强秩序,但是可以根据不同业务动态重新组合成一个新链路。</li></ul><h3 id="缺点"><a href="#缺点" class="headerlink" title="缺点"></a>缺点</h3><ul><li><strong>性能缺陷</strong>:节点之间有强秩序,大概率会走完全部链路,必影响耗时。</li><li><strong>死循环</strong>:链路过长开发人员不熟悉整个流程一样难以新增节点,容易出现链路闭环。即新增的F节点可能下一步需要B, 但是要在C之前,便出现 B->D->C->F->B。当然这个问题在开发阶段能处理。</li></ul>]]></content>
<summary type="html">责任链模式通过链条传递请求,简化责任分配,增强系统灵活性。</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="设计模式" scheme="https://log.cns.red/tags/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F/"/>
<category term="编程艺术" scheme="https://log.cns.red/tags/%E7%BC%96%E7%A8%8B%E8%89%BA%E6%9C%AF/"/>
</entry>
<entry>
<title>Tabby的Web前端与Gateway网关部署</title>
<link href="https://log.cns.red/tabby-web-gateway/"/>
<id>https://log.cns.red/tabby-web-gateway/</id>
<published>2024-05-13T02:00:00.000Z</published>
<updated>2026-02-13T10:53:03.131Z</updated>
<content type="html"><![CDATA[<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>经常换设备用终端时候总是要下载 <code>tabby</code> 和添加原来的连接配置,还不同步。一直想搭建官方提供的<code>tabby-web</code>,现在终于有空搞。<br>搭建完发现不只是可以同步,还可以在网页连接配置里的终端,但是要搭建网关,顺便把网关也搭建了。web是http协议,网关是ws协议。<br>但是搭建过程和官方REDEME相比差别甚大 <span class="tag-plugin emoji"><img no-lazy="" class="inline" src="https://upyun.thatcdn.cn/public/web/emojis/bilibili/bb_tiaokan.png"/></span> 遂本文记载 <code>tabby-web</code> 与 <code>tabby-gateway</code> 的搭建配置与联动。</p><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:2701/1705;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/e97c6c33f5dc5124da4eff1a818b454c.png" data-src="https://upyun.thatcdn.cn/myself/typora/e97c6c33f5dc5124da4eff1a818b454c.png" alt="部署结果" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">部署结果</span></div></div><h2 id="准备"><a href="#准备" class="headerlink" title="准备"></a>准备</h2><ul><li>docker!docker!docker!</li><li>两份域名证书(前端和网关,前端的自己反向代理使用,网关的需要挂载到容器)</li></ul><h2 id="部署"><a href="#部署" class="headerlink" title="部署"></a>部署</h2><blockquote><p>先部署吧,碰到的小毛病都是部署后面的(只部署 tabby-web 的话自己删一下网关那段)<br>一起整合到了编排文件。</p></blockquote><p>需要修改的地方如下,分是否开启SSL两种:</p><ul><li>开启SSL<ol><li>填写 网关证书目录地址:签名和私钥命名为gateway.pem和gateway.key</li><li>填写 前端容器挂载目录:后面要用到。</li><li>填写 两个映射的端口</li><li>填写 前端域名</li><li>填写 网关密钥(相当于自定义的密码)</li><li>填写 SOCIAL_AUTH_GITHUB_KEY 与 SOCIAL_AUTH_GITHUB_SECRET (用于用户Github登录,<a href="https://github.com/settings/developers">点进来进来创建一个</a>,回调地址填前端域名)</li></ol></li><li>取消SSL<ol><li>修改 tabby-gateway.command: –token-auth –host 0.0.0.0</li><li>删除 tabby-gateway.volumes</li><li>修改 “网关端口:443” -> “网关端口:9000”</li><li>填写 网关密钥</li><li>填写 前端域名</li><li>填写 SOCIAL_AUTH_GITHUB_KEY 与 SOCIAL_AUTH_GITHUB_SECRET</li></ol></li></ul><figure class="highlight yaml"><figcaption><span>docker-compose.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">"3.0"</span><br><br><span class="hljs-attr">services:</span><br> <span class="hljs-attr">tabby-gateway:</span><br> <span class="hljs-attr">image:</span> <span class="hljs-string">ghcr.io/eugeny/tabby-connection-gateway:master</span><br> <span class="hljs-attr">container_name:</span> <span class="hljs-string">"tabby-gateway"</span><br> <span class="hljs-attr">command:</span> <span class="hljs-string">--token-auth</span> <span class="hljs-string">--host</span> <span class="hljs-number">0.0</span><span class="hljs-number">.0</span><span class="hljs-number">.0</span> <span class="hljs-string">--port</span> <span class="hljs-number">443</span> <span class="hljs-string">--certificate</span> <span class="hljs-string">/custom/ssl/gateway.pem</span> <span class="hljs-string">--private-key</span> <span class="hljs-string">/custom/ssl/gateway.key</span><br> <span class="hljs-attr">environment:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">TABBY_AUTH_TOKEN=网关密钥</span><br> <span class="hljs-attr">ports:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">"网关端口:443"</span><br> <span class="hljs-attr">volumes:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">/你的网关证书地址:/custom/ssl</span><br> <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</span><br><br> <span class="hljs-attr">tabby-web:</span><br> <span class="hljs-attr">image:</span> <span class="hljs-string">ghcr.io/eugeny/tabby-web:latest</span><br> <span class="hljs-attr">container_name:</span> <span class="hljs-string">tabby-web</span><br> <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</span><br> <span class="hljs-attr">volumes:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">/你的前端容器挂载目录:/data</span><br> <span class="hljs-attr">environment:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">DATABASE_URL=sqlite:////data/db.sqlite3</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">DEBUG=False</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">PORT=8080</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">APP_DIST_STORAGE=file:///data</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">SOCIAL_AUTH_GITHUB_KEY=记得填</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">SOCIAL_AUTH_GITHUB_SECRET=记得填</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">com.centurylinklabs.watchtower.enable=true</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">traefik.enable=true</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">traefik.http.routers.app-tabby-web.tls=true</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">traefik.http.routers.app-tabby-web.tls.certresolver=cloudflare</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">traefik.http.routers.app-tabby-web.entrypoints=websecure</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">traefik.http.routers.app-tabby-web.rule=Host(`前端域名`)</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">traefik.http.routers.app-tabby-web.service=app-tabby-web</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">traefik.http.services.app-tabby-web.loadbalancer.server.port=9090</span><br> <span class="hljs-attr">logging:</span><br> <span class="hljs-attr">driver:</span> <span class="hljs-string">json-file</span><br> <span class="hljs-attr">options:</span><br> <span class="hljs-attr">max-size:</span> <span class="hljs-string">5m</span><br> <span class="hljs-attr">max-file:</span> <span class="hljs-string">"5"</span><br> <span class="hljs-attr">ports:</span><br> <span class="hljs-bullet">-</span> <span class="hljs-string">"前端端口:8080"</span><br></code></pre></td></tr></table></figure><blockquote><p>编排就交给你了,部署完接下来分两步,先跑通web后讲gateway。</p></blockquote><h2 id="前端"><a href="#前端" class="headerlink" title="前端"></a>前端</h2><h3 id="安装依赖"><a href="#安装依赖" class="headerlink" title="安装依赖"></a>安装依赖</h3><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:751/67;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/20240513122813.png" data-src="https://upyun.thatcdn.cn/myself/typora/20240513122813.png" alt="前端警告⚠️" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">前端警告⚠️</span></div></div><p>访问前端后会肯定有一个提醒,我们来到tabby-web容器的命令行执行如下</p><figure class="highlight bash"><figcaption><span>web容器</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">/manage.sh add_version 1.0.187-nightly.1<br></code></pre></td></tr></table></figure><p><a href="#%E8%B0%83%E6%95%B4%E4%BE%9D%E8%B5%96">下面是毛病吐槽,点击跳过碎碎念。</a></p><p>最新版其实是 <code>1.0.197-nightly.1</code> ,但是容易暴毙。<br>安装完之后重启容器发现仍然是黑屏! <span class="tag-plugin emoji"><img no-lazy="" class="inline" src="https://upyun.thatcdn.cn/public/web/emojis/bilibili/bb_think.png"/></span></p><p>打开F12网络检查会发现找不到 <code>/app-dist/1.0.187-nightly.1/</code> 下面的文件。<br>来到服务器前端容器挂载目录会发现有<code>1.0.187-nightly.1</code>目录,但是它的子目录是<code>/tmpxxxx</code>,而<code>/tmpxxxx</code>目录下面就是前端网络找不到的文件,是不是很抽象。<span class="tag-plugin emoji"><img no-lazy="" class="inline" src="https://upyun.thatcdn.cn/public/web/emojis/bilibili/bb_annoyed.png"/></span><span class="tag-plugin emoji"><img no-lazy="" class="inline" src="https://upyun.thatcdn.cn/public/web/emojis/bilibili/bb_annoyed.png"/></span></p><p>当你把文件<code>/tmpxxxx</code>目录下面文件移动到<code>1.0.187-nightly.1</code>下面,重启容器,你会发现前端不再黑屏,但是一直在加载,打开F12故技重施发现又要<code>/tmpxxxx</code>下面的文件,而且要的还是<code>1.0.187-nightly.1</code>依赖未解压的版本,实在是抽象。<span class="tag-plugin emoji"><img no-lazy="" class="inline" src="https://upyun.thatcdn.cn/public/web/emojis/bilibili/bb_vomit.png"/></span><span class="tag-plugin emoji"><img no-lazy="" class="inline" src="https://upyun.thatcdn.cn/public/web/emojis/bilibili/bb_vomit.png"/></span><span class="tag-plugin emoji"><img no-lazy="" class="inline" src="https://upyun.thatcdn.cn/public/web/emojis/bilibili/bb_vomit.png"/></span></p><p>好了我吐槽完了,下面厘清操作。</p><h3 id="调整依赖"><a href="#调整依赖" class="headerlink" title="调整依赖"></a>调整依赖</h3><blockquote><p>在前端容器挂载目录完成。</p></blockquote><ol><li>将 <code>/1.0.187-nightly.1/tmpxxxx</code> 下面的文件移动到 <code>/1.0.187-nightly.1</code></li></ol><figure class="highlight bash"><figcaption><span>移动文件</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 去目标目录</span><br><span class="hljs-built_in">cd</span> /前端容器挂载目录/1.0.187-nightly.1<br><span class="hljs-comment"># 查询tmpxxx名字</span><br><span class="hljs-built_in">ls</span><br><span class="hljs-built_in">mv</span> ./tmpxxxx/* ./<br></code></pre></td></tr></table></figure><ol start="2"><li>下载源码,解压到 <code>./tmpxxxx</code></li></ol><figure class="highlight bash"><figcaption><span>下载解压</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 去目标目录</span><br><span class="hljs-built_in">cd</span> ./tmpxxx<br><span class="hljs-comment"># 下载解压</span><br>wget https://registry.npmjs.org/tabby-web-demo/-/tabby-web-demo-1.0.187-nightly.1.tgz tar -xvf tabby-web-demo-1.0.187-nightly.1.tgz<br></code></pre></td></tr></table></figure><h3 id="同步配置"><a href="#同步配置" class="headerlink" title="同步配置"></a>同步配置</h3><p>自此重启容器,前端能加载完毕。</p><p>访问:<code>你的域名/login</code>。可以使用Github登录。<br>登陆之后点击设置有token,然后在客户端配置即可,参考下面图片,前者前端,后者客户端。</p><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1014/1615;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/cae19f41855f761dd0042734dd91634f.png" data-src="https://upyun.thatcdn.cn/myself/typora/cae19f41855f761dd0042734dd91634f.png" alt="前端设置" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">前端设置</span></div></div><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1631/1097;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/20240513115712.png" data-src="https://upyun.thatcdn.cn/myself/typora/20240513115712.png" alt="客户端配置" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">客户端配置</span></div></div><h2 id="网关"><a href="#网关" class="headerlink" title="网关"></a>网关</h2><blockquote><p>编排能通过网关就没问题,网关一直重启就是证书目录有问题。</p></blockquote><p>注意:网关是WS协议,不要习惯去http反向代理,然后配置了证书的话网关地址应该是 <code>wss://网关域名:网关端口</code></p><p>填写是在前端填写,这样就能在网页上用同步的配置连接终端,如图。</p><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:2159/1261;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/94c4cf524760ab9dc43cee6c4365dcbe.png" data-src="https://upyun.thatcdn.cn/myself/typora/94c4cf524760ab9dc43cee6c4365dcbe.png" alt="网页使用配置" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">网页使用配置</span></div></div><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:1365/1447;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/eddf71237d15b46455b8ba416e9ba197.png" data-src="https://upyun.thatcdn.cn/myself/typora/eddf71237d15b46455b8ba416e9ba197.png" alt="网页通过网关连接终端" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div><div class="image-meta"><span class="image-caption center">网页通过网关连接终端</span></div></div><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>遇事不决,欢迎提问。 <span class="tag-plugin emoji"><img no-lazy="" class="inline" src="https://upyun.thatcdn.cn/public/web/emojis/bilibili/bb_bye.png"/></span></p>]]></content>
<summary type="html">docker-compose的tabby-web与tabby-connection-gateway的部署配置</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="部署" scheme="https://log.cns.red/tags/%E9%83%A8%E7%BD%B2/"/>
<category term="捣腾" scheme="https://log.cns.red/tags/%E6%8D%A3%E8%85%BE/"/>
</entry>
<entry>
<title>vue运行时反向代理无限刷新</title>
<link href="https://log.cns.red/bug/vue-reverse-proxy-unlimited-refresh/"/>
<id>https://log.cns.red/bug/vue-reverse-proxy-unlimited-refresh/</id>
<published>2024-05-12T14:00:00.000Z</published>
<updated>2026-02-13T10:53:03.123Z</updated>
<content type="html"><![CDATA[<h2 id="吐槽"><a href="#吐槽" class="headerlink" title="吐槽"></a>吐槽</h2><p>本以为是一次习以为常的部署业务。结果要是再刷新下去,CPU都要爆了!!!</p><h2 id="错误情景"><a href="#错误情景" class="headerlink" title="错误情景"></a>错误情景</h2><ul><li><strong>开发版本</strong>:<ul><li>vue:2</li></ul></li><li><strong>开发环境</strong>:正常本地window开发。<ul><li><strong>bug</strong>:无。</li></ul></li><li><strong>生成环境</strong>:项目是运行时,运行在52002端口,nginx反向代理127.0.0.1:52002,挂载到域名开启ssl。<ul><li><strong>bug</strong>:访问域名会无限刷新网页。刷新时候可以看到网页有内容,抓住机会点击跳转也是正常路由跳转,跳转后依然开始无限刷新。</li></ul></li></ul><p>下面是部署项目部分配置代码</p><figure class="highlight js"><figcaption><span>vue.config.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">const</span> webpack = <span class="hljs-built_in">require</span>(<span class="hljs-string">'webpack'</span>)<br><span class="hljs-variable language_">module</span>.<span class="hljs-property">exports</span> = {<br> <span class="hljs-attr">publicPath</span>: <span class="hljs-string">'/'</span>,<br> <span class="hljs-attr">assetsDir</span>: <span class="hljs-string">'static'</span>,<br> <span class="hljs-attr">productionSourceMap</span>: <span class="hljs-literal">true</span>,<br> <span class="hljs-attr">devServer</span>: {<br> <span class="hljs-attr">host</span>: <span class="hljs-string">'0.0.0.0'</span>,<br> <span class="hljs-attr">port</span>: <span class="hljs-number">52002</span>,<br> <span class="hljs-attr">proxy</span>: {<br> <span class="hljs-string">'/proxy/api'</span>:{<br> <span class="hljs-attr">target</span>:<span class="hljs-string">'https://xxx.thatcoder.cn'</span>,<br> <span class="hljs-attr">changeOrigin</span>:<span class="hljs-literal">true</span>,<br> <span class="hljs-attr">pathRewrite</span>:{<br> <span class="hljs-string">'/proxy/api'</span>:<span class="hljs-string">'https://xxx.thatcoder.cn'</span><br> }<br> }<br> },<br> <span class="hljs-attr">allowedHosts</span>: [<span class="hljs-string">'localhost'</span>, <span class="hljs-string">'127.0.0.1'</span>, <span class="hljs-string">'0.0.0.0'</span>, <span class="hljs-string">'xxx.thatcoder.cn'</span>],<br> }<br>};<br></code></pre></td></tr></table></figure><h2 id="解决方案"><a href="#解决方案" class="headerlink" title="解决方案"></a>解决方案</h2><blockquote><p>严谨的说,仅提供博主排错误时看到的类似错误与解决方案收录。因为即使错误表现一致,错误原因可能不一致。</p></blockquote><h3 id="破局一"><a href="#破局一" class="headerlink" title="破局一"></a>破局一</h3><blockquote><p>此方法解决博主问题。</p></blockquote><p>执行打包命令,将反向代理的站点设置为打包的目录(默认是dist)。或者将打包文件复制到站点的目录下。(博主选择后者)</p><h3 id="破局二"><a href="#破局二" class="headerlink" title="破局二"></a>破局二</h3><p>部分人是碰到运行时的热部署经过反向代理后无法通讯导致,热部署是ws协议,与项目共用端口。部分人通过配置关闭热部署能解决。</p><ul><li>vue2和vue3关闭热部署</li></ul><figure class="highlight js"><figcaption><span>vue.config.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-variable language_">module</span>.<span class="hljs-property">exports</span> = {<br> <span class="hljs-attr">devServer</span>: {<br> <span class="hljs-attr">hot</span>: <span class="hljs-literal">false</span><br> }<br>}<br></code></pre></td></tr></table></figure><ul><li>vite关闭热部署</li></ul><figure class="highlight js"><figcaption><span>vite.config.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> {<br> <span class="hljs-attr">server</span>: {<br> <span class="hljs-attr">hmr</span>: <span class="hljs-literal">false</span><br> }<br>}<br></code></pre></td></tr></table></figure><h3 id="破局三"><a href="#破局三" class="headerlink" title="破局三"></a>破局三</h3><p>部分人是碰到运行时的热部署ws协议端口占用,与项目共用端口。部分人通过修改端口能解决。</p><ul><li>修改热部署ws协议端口</li></ul><figure class="highlight js"><figcaption><span>vue.config.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-variable language_">module</span>.<span class="hljs-property">exports</span> = {<br> <span class="hljs-attr">devServer</span>: {<br> <span class="hljs-attr">client</span>: { <br> <span class="hljs-attr">webSocketURL</span>: <span class="hljs-string">'ws://0.0.0.0:52003/ws'</span> <br>},<br> }<br>}<br></code></pre></td></tr></table></figure>]]></content>
<summary type="html">服务器vue部署反向代理热重载导致无限刷新。</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="BUG" scheme="https://log.cns.red/tags/BUG/"/>
</entry>
<entry>
<title>OnlyOffice集成到Vue与Spring项目</title>
<link href="https://log.cns.red/OnlyOffice-Vue-Spring/"/>
<id>https://log.cns.red/OnlyOffice-Vue-Spring/</id>
<published>2024-05-11T22:00:00.000Z</published>
<updated>2026-02-15T08:17:49.649Z</updated>
<content type="html"><![CDATA[<h2 id="基础环境"><a href="#基础环境" class="headerlink" title="基础环境"></a>基础环境</h2><ul><li>需安装依赖:<code>@onlyoffice/document-editor-vue</code></li><li>需自行搭建OnlyOffice服务:<code>https://office.thatcoder.cn/</code> (仅供测试使用,请勿用于生产环境,随时可能关闭)</li><li>补充:搭建OnlyOffice要开启ssl按官方的比较麻烦,可以移步底下目录有<a href="#%E7%A4%BE%E5%8C%BA%E8%A7%A3%E9%94%81%E7%89%88">解锁版</a>,调教了配置。</li></ul><h2 id="简单使用"><a href="#简单使用" class="headerlink" title="简单使用"></a>简单使用</h2><blockquote><p>一口吃不成胖子,先从最简单的DEMO,来测试所用服务的可靠性。</p></blockquote><p>测试的DEMO,仅需要替换以下变量即可跑通测试。</p><ul><li><code>documentSite</code>:自行搭建的OnlyOffice服务地址。可用 <code>https://office.thatcoder.cn/</code> 进行测试。</li><li><code>documentUrl</code>: DOCX文档地址。</li></ul><div class="tag-plugin image"><div class="image-bg" style="aspect-ratio:2698/1624;"><img class="lazy" src="https://upyun.thatcdn.cn/myself/typora/4e6bc0de79be547da8ad0d62bd6023b0.png" data-src="https://upyun.thatcdn.cn/myself/typora/4e6bc0de79be547da8ad0d62bd6023b0.png" data-fancybox="true"onerror="this.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2rem' height='2rem' viewBox='0 0 24 24'%3E%3C!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ --%3E%3Cpath fill='%23F44336' d='M22 12.698c-.002 1.47-.013 2.718-.096 3.743c-.097 1.19-.296 2.184-.74 3.009a4.2 4.2 0 0 1-.73.983c-.833.833-1.893 1.21-3.237 1.39C15.884 22 14.2 22 12.053 22h-.106c-2.148 0-3.83 0-5.144-.177c-1.343-.18-2.404-.557-3.236-1.39c-.738-.738-1.12-1.656-1.322-2.795c-.2-1.12-.236-2.512-.243-4.241Q1.999 12.737 2 12v-.054c0-2.148 0-3.83.177-5.144c.18-1.343.557-2.404 1.39-3.236s1.893-1.21 3.236-1.39c1.168-.157 2.67-.175 4.499-.177a.697.697 0 1 1 0 1.396c-1.855.002-3.234.018-4.313.163c-1.189.16-1.906.464-2.436.994S3.72 5.8 3.56 6.99C3.397 8.2 3.395 9.788 3.395 12v.784l.932-.814a2.14 2.14 0 0 1 2.922.097l3.99 3.99a1.86 1.86 0 0 0 2.385.207l.278-.195a2.79 2.79 0 0 1 3.471.209l2.633 2.37c.265-.557.423-1.288.507-2.32c.079-.972.09-2.152.091-3.63a.698.698 0 0 1 1.396 0' opacity='.5'/%3E%3Cpath fill='%23F44336' fill-rule='evenodd' d='M17.5 11c-2.121 0-3.182 0-3.841-.659S13 8.621 13 6.5s0-3.182.659-3.841S15.379 2 17.5 2s3.182 0 3.841.659S22 4.379 22 6.5s0 3.182-.659 3.841S19.621 11 17.5 11m-1.47-7.03a.75.75 0 1 0-1.06 1.06l1.47 1.47l-1.47 1.47a.75.75 0 0 0 1.06 1.06l1.47-1.47l1.47 1.47a.75.75 0 1 0 1.06-1.06L18.56 6.5l1.47-1.47a.75.75 0 0 0-1.06-1.06L17.5 5.44z' clip-rule='evenodd'/%3E%3C/svg%3E""/><div class="lazy-icon" style="background-image:url(https://api.iconify.design/eos-icons:three-dots-loading.svg?color=%231cd0fd);"></div></div></div><figure class="highlight plaintext"><figcaption><span>office-demo.vue</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br></pre></td><td class="code"><pre><code class="hljs vue"><template><br><DocumentEditor<br> id="docEditor"<br> :documentServerUrl="documentSite"<br> :config="editorConfig"<br> :events_onDocumentReady="onDocumentReady"<br>/><br></template><br><br><script setup> <br>import {ref} from "vue"; <br>import { DocumentEditor } from '@onlyoffice/document-editor-vue'; <br>import axios from "axios"; <br><br>// DOCX文档地址<br>const documentUrl = ref('https://resumes.thatcdn.cn/public/template/resumes_001.docx'); <br>const documentTitle = ref('论Vue如何使用OnlyOffice');<br>const documentType = ref('docx'); <br>// 文档标识符<br>const documentKey = ref('your-document-key');<br>// 私有化的OnlyOffice地址<br>const documentSite = 'https://office.thatcoder.cn/'<br>// Vue处理OnlyOffice事务的回调地址<br>const documentCallSite = 'https://www.baidu.com/'<br><br>// 文档加载完毕的回调<br>const onDocumentReady = () => { <br> console.log('Document is ready'); <br>}; <br>// 文档变更的回调<br>const onDocumentStateChange = (event) => { <br> if (event.data === 'onDocumentStateChange') { <br> saveDocument(); <br> } <br>}; <br><br>// 处理文档另存为的回调事件<br>const onRequestSaveAs = (event) => { <br> console.log('Document save as requested'); <br> // 执行文档另存为的逻辑,例如弹出保存对话框等 <br>}; <br><br>// 插入图片的回调事件<br>const onRequestInsertImage = (event) => { <br> console.log('Insert image requested'); <br> // 执行插入图片的逻辑,例如打开图片选择器等 <br>}; <br><br>// 文档保存回调方法<br>const saveDocument = () => { <br> axios.post('https://www.baidu.com/save-document', { <br> documentUrl: documentUrl.value, <br> documentType: documentType.value, <br> documentKey: documentKey.value, <br> }) <br> .then(response => { <br> console.log('Document saved successfully'); <br> }) <br> .catch(error => { <br> console.error('Document save error:', error); <br> }); <br>};<br><br>// 初始化OnlyOffice<br>const editorConfig = ref({ <br> document: { <br> title: documentTitle.value, <br> url: documentUrl.value, <br> fileType: documentType.value, <br> key: documentKey.value, <br> }, <br> editorConfig: { <br> mode: 'edit', <br> lang: 'zh', <br> callbackUrl: documentCallSite, <br> }, <br> events: { <br> onReady: onDocumentReady, <br> onDocumentStateChange: onDocumentStateChange, <br> onRequestSaveAs: onRequestSaveAs, <br> onRequestInsertImage: onRequestInsertImage, <br> // 添加其他需要处理的回调事件 <br> }, <br>}); <br></script><br></code></pre></td></tr></table></figure><h2 id="配置参数"><a href="#配置参数" class="headerlink" title="配置参数"></a>配置参数</h2><blockquote><p>Config提供了大量配置,不过社区版支持的参数不是特别多。</p></blockquote><ul><li><code>editorConfig</code>:参数配置</li><li><code>document</code>:当前文档参数</li><li><code>events</code>:回调事件绑定<br>更多参考官方文档<a href="https://api.onlyoffice.com/zh/editors/config/">ONLYOFFICE API 文档 - Config</a></li></ul><figure class="highlight js"><figcaption><span>office配置</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">const</span> editorConfig = <span class="hljs-title function_">ref</span>({ <br> <span class="hljs-attr">editorConfig</span>: { <br> <span class="hljs-attr">mode</span>: <span class="hljs-string">'edit'</span>, <br> <span class="hljs-attr">lang</span>: <span class="hljs-string">'zh'</span>, <br> <span class="hljs-attr">customization</span>: { <br> <span class="hljs-attr">anonymous</span>: { <br> <span class="hljs-attr">request</span>: <span class="hljs-literal">true</span>, <br> <span class="hljs-attr">label</span>: <span class="hljs-string">"Guest"</span> <br> }, <br> <span class="hljs-attr">autosave</span>: <span class="hljs-literal">false</span>, <br> <span class="hljs-attr">comments</span>: <span class="hljs-literal">true</span>, <br> <span class="hljs-attr">compactHeader</span>: <span class="hljs-literal">true</span>, <br> <span class="hljs-attr">compactToolbar</span>: <span class="hljs-literal">false</span>, <br> <span class="hljs-attr">compatibleFeatures</span>: <span class="hljs-literal">true</span>, <br> <span class="hljs-attr">customer</span>: { <br> <span class="hljs-attr">address</span>: <span class="hljs-string">"A small corner of China"</span>, <br> <span class="hljs-attr">info</span>: <span class="hljs-string">"A member of the open source spirit of the Internet"</span>, <br> <span class="hljs-attr">logo</span>: <span class="hljs-string">"https://resume.app.thatcoder.cn/favicon.ico"</span>, <br> <span class="hljs-attr">logoDark</span>: <span class="hljs-string">"https://resume.app.thatcoder.cn/favicon.ico"</span>, <br> <span class="hljs-attr">mail</span>: <span class="hljs-string">"thatcoder@163.com"</span>, <br> <span class="hljs-attr">name</span>: <span class="hljs-string">"钟意"</span>, <br> <span class="hljs-attr">phone</span>: <span class="hljs-number">13305374721</span>, <br> <span class="hljs-attr">www</span>: <span class="hljs-string">"https://log.cns.red"</span> <br> }, <br> <span class="hljs-attr">forcesave</span>: <span class="hljs-literal">false</span>, <br> <span class="hljs-attr">goback</span>: { <br> <span class="hljs-attr">blank</span>: <span class="hljs-literal">true</span>, <br> <span class="hljs-attr">requestClose</span>: <span class="hljs-literal">false</span>, <br> <span class="hljs-attr">text</span>: <span class="hljs-string">"回到简历列表"</span>, <br> <span class="hljs-attr">url</span>: <span class="hljs-string">"https://resume.app.thatcoder.cn/mine/folder"</span> <br> }, <br> <span class="hljs-attr">help</span>: <span class="hljs-literal">false</span>, <br> <span class="hljs-attr">hideRightMenu</span>: <span class="hljs-literal">false</span>, <br> <span class="hljs-attr">hideRulers</span>: <span class="hljs-literal">true</span>, <br> <span class="hljs-attr">integrationMode</span>: <span class="hljs-string">"embed"</span>, <br> <span class="hljs-attr">logo</span>: { <br> <span class="hljs-attr">image</span>: <span class="hljs-string">"https://resume.app.thatcoder.cn/favicon.ico"</span>, <br> <span class="hljs-attr">imageDark</span>: <span class="hljs-string">"https://resume.app.thatcoder.cn/favicon.ico"</span>, <br> <span class="hljs-attr">imageEmbedded</span>: <span class="hljs-string">"https://resume.app.thatcoder.cn/favicon.ico"</span>, <br> <span class="hljs-attr">url</span>: <span class="hljs-string">"https://resume.app.thatcoder.cn"</span> <br> }, <br> <span class="hljs-attr">macros</span>: <span class="hljs-literal">true</span>, <br> <span class="hljs-attr">macrosMode</span>: <span class="hljs-string">"禁用"</span>, <br> <span class="hljs-attr">mentionShare</span>: <span class="hljs-literal">false</span>, <br> <span class="hljs-attr">mobileForceView</span>: <span class="hljs-literal">true</span>, <br> <span class="hljs-attr">plugins</span>: <span class="hljs-literal">false</span>, <br> <span class="hljs-attr">toolbarHideFileName</span>: <span class="hljs-literal">true</span>, <br> <span class="hljs-attr">toolbarNoTabs</span>: <span class="hljs-literal">true</span>, <br> <span class="hljs-comment">// uiTheme: "theme-dark", </span><br> <span class="hljs-attr">unit</span>: <span class="hljs-string">"厘米"</span>, <br> <span class="hljs-attr">zoom</span>: <span class="hljs-number">100</span> <br> }, <br> <span class="hljs-comment">// callbackUrl: 'https://resume.app.thatcoder.cn/onlyoffice-callback', </span><br> <span class="hljs-attr">callbackUrl</span>: <span class="hljs-string">'https://resume.thatapi.cn/office/callback'</span>, <br> }, <br> <span class="hljs-attr">document</span>: { <br> <span class="hljs-attr">title</span>: documentName, <br> <span class="hljs-attr">url</span>: documentUrl, <br> <span class="hljs-attr">fileType</span>: documentType, <br> <span class="hljs-attr">key</span>: documentKey, <br> <span class="hljs-attr">info</span>: { <br> <span class="hljs-attr">favorite</span>: <span class="hljs-literal">false</span>, <br> <span class="hljs-attr">folder</span>: <span class="hljs-string">""</span> + thatLocal.<span class="hljs-title function_">getItem</span>(<span class="hljs-string">'userId'</span>), <br> <span class="hljs-attr">owner</span>: <span class="hljs-string">""</span> + thatLocal.<span class="hljs-title function_">getItem</span>(<span class="hljs-string">'userName'</span>), <br> <span class="hljs-attr">sharingSettings</span>: [ <br> { <br> <span class="hljs-attr">permissions</span>: <span class="hljs-string">"Full Access"</span>, <br> <span class="hljs-attr">user</span>: <span class="hljs-string">""</span> + thatLocal.<span class="hljs-title function_">getItem</span>(<span class="hljs-string">'userName'</span>) <br> } <br> ], <br> <span class="hljs-attr">uploaded</span>: <span class="hljs-string">"2010-07-07 3:46 PM"</span> <br> } <br> }, <br> <span class="hljs-attr">events</span>: { <br> <span class="hljs-attr">onReady</span>: onDocumentReady, <br> <span class="hljs-comment">// onDocumentStateChange: onDocumentStateChange, </span><br> <span class="hljs-attr">onRequestSaveAs</span>: onRequestSaveAs, <br> <span class="hljs-comment">// onRequestInsertImage: onRequestInsertImage, </span><br> <span class="hljs-comment">// 添加其他需要处理的回调事件 </span><br> }, <br> <span class="hljs-comment">// documentServerUrl: 'https://office.thatcoder.cn', // 指定 ONLYOFFICE 服务器的网址 </span><br>});<br></code></pre></td></tr></table></figure><h2 id="回调服务"><a href="#回调服务" class="headerlink" title="回调服务"></a>回调服务</h2><blockquote><p>回调服务是指OnlyOffice与你的服务端的通讯地址,主要用于处理文档的保存、另存为等事件。<br>这里实例用spring演示,仅供参考,逻辑部分请自行编写。<br>建议边参考官方文档看代码。<a href="https://api.onlyoffice.com/zh/editors/callback">ONLYOFFICE API 文档 - 回调处理程序</a></p></blockquote><figure class="highlight java"><figcaption><span>OfficeCallbackDemoController.java</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">package</span> cn.onestack.project.office.controller;<br><br><span class="hljs-keyword">import</span> cn.hutool.json.JSONObject;<br><span class="hljs-keyword">import</span> org.springframework.web.bind.annotation.RequestMapping;<br><span class="hljs-keyword">import</span> org.springframework.web.bind.annotation.RestController;<br><br><span class="hljs-keyword">import</span> javax.servlet.ServletException;<br><span class="hljs-keyword">import</span> javax.servlet.http.HttpServletRequest;<br><span class="hljs-keyword">import</span> javax.servlet.http.HttpServletResponse;<br><span class="hljs-keyword">import</span> java.io.IOException;<br><span class="hljs-keyword">import</span> java.io.PrintWriter;<br><span class="hljs-keyword">import</span> java.util.Scanner;<br><br><span class="hljs-meta">@RestController</span><br><span class="hljs-meta">@RequestMapping("/office")</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">OfficeCallbackDemoController</span> {<br><br> <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">SAVE_DIRECTORY</span> <span class="hljs-operator">=</span> <span class="hljs-string">"/path/to/save/directory"</span>; <span class="hljs-comment">// 替换为你的保存路径</span><br><br> <span class="hljs-meta">@RequestMapping("/callback")</span><br> <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">callback</span><span class="hljs-params">(HttpServletRequest request, HttpServletResponse response)</span> <span class="hljs-keyword">throws</span> ServletException, IOException {<br> <span class="hljs-type">PrintWriter</span> <span class="hljs-variable">writer</span> <span class="hljs-operator">=</span> response.getWriter();<br><br> <span class="hljs-type">Scanner</span> <span class="hljs-variable">scanner</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Scanner</span>(request.getInputStream()).useDelimiter(<span class="hljs-string">"\\A"</span>);<br> <span class="hljs-type">String</span> <span class="hljs-variable">body</span> <span class="hljs-operator">=</span> scanner.hasNext() ? scanner.next() : <span class="hljs-string">""</span>;<br><br> <span class="hljs-type">JSONObject</span> <span class="hljs-variable">jsonObj</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">JSONObject</span>(body);<br> System.out.println(jsonObj);<br><br> <span class="hljs-keyword">if</span>(jsonObj.getInt(<span class="hljs-string">"status"</span>) == <span class="hljs-number">2</span>)<br> {<br> System.out.println(<span class="hljs-string">"Office文档可保存"</span>);<br> }<br> <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span>(jsonObj.getInt(<span class="hljs-string">"status"</span>) == <span class="hljs-number">1</span>)<br> {<br> System.out.println(<span class="hljs-string">"Office文档已连接"</span>);<br> }<br> <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span>(jsonObj.getInt(<span class="hljs-string">"status"</span>) == <span class="hljs-number">4</span>){<br> System.out.println(<span class="hljs-string">"Office文档已断开"</span>);<br> }<br> System.out.println(jsonObj);<br> writer.write(<span class="hljs-string">"{\"error\":0}"</span>);<br> }<br>}<br></code></pre></td></tr></table></figure><h2 id="社区解锁版"><a href="#社区解锁版" class="headerlink" title="社区解锁版"></a>社区解锁版</h2><h3 id="镜像地址"><a href="#镜像地址" class="headerlink" title="镜像地址"></a>镜像地址</h3><p>出自oo中文交流群,企鹅群号 186184848。</p><ul><li>国内地址1:<code>docker pull registry.cn-hangzhou.aliyuncs.com/186184848/documentserver</code></li><li>国内地址2:<code>docker pull registry.cn-hangzhou.aliyuncs.com/miiror/only-office:v8.0.1</code></li><li>DockerHub:<code>docker pull 186184848/documentserver</code></li></ul><h3 id="修改说明"><a href="#修改说明" class="headerlink" title="修改说明"></a>修改说明</h3><ul><li>基于docker镜像制作,最新版本号: 8.0.1</li><li>关闭地址过滤,导入镜像后可以完美通过IP局域网运行(在7.4以上版本默认无法通过IP访问)</li><li>去除最大在线编辑人数限制</li><li>完美解锁手机端浏览和编辑(无需设置手机UA为电脑模式)</li><li>添加中文字体,加入了常用的十几种字体</li><li>支持http/https快速部署(默认开启ssl非常繁琐)</li><li>优化文档打开速度(进一步优化,打开速度提升20%)</li><li>最大支持300M文档在线编辑(7.5以上新增)(默认30M)。</li><li>关闭SSL证书校验,自签证书也可以直接使用了。(7.6新增)</li></ul><h3 id="使用说明"><a href="#使用说明" class="headerlink" title="使用说明"></a>使用说明</h3><p>镜像包含amd64,arm64架构<br>运行镜像遇到启动不起来问题报端口错误需要加上命令:–privileged=true<br>相关教程文档请参考:<a href="https://www.yuque.com/xiaohuochai-t5dj3/wc519f/fhw8kgrwf3l8ryy2?singleDoc#">《从零使用onlyoffice及各类网盘》</a></p><h2 id="插件扩展"><a href="#插件扩展" class="headerlink" title="插件扩展"></a>插件扩展</h2><p>未来写吧,有需要的可以参考官方文档。</p>]]></content>
<summary type="html">OnlyOffice的使用记录,在Vue项目中编写文档。</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="业务" scheme="https://log.cns.red/tags/%E4%B8%9A%E5%8A%A1/"/>
</entry>
<entry>
<title>PlanetScale的免费Hobby计划即将结束</title>
<link href="https://log.cns.red/PlanetScale%20free%20Hobby%20plan%20is%20being%20retired/"/>
<id>https://log.cns.red/PlanetScale%20free%20Hobby%20plan%20is%20being%20retired/</id>
<published>2024-03-21T19:00:00.000Z</published>
<updated>2026-02-13T10:53:03.114Z</updated>
<content type="html"><![CDATA[<h2 id="如题"><a href="#如题" class="headerlink" title="如题"></a>如题</h2><p>三封邮件,十分钟一封,赶紧停下代码迁移数据库。</p><p>之前临时测试 waline 评论的数据,就部署在 PlanetScale,没想到一用就是一年。</p><p>白嫖这么久怪不好意思的。 <span class="tag-plugin emoji"><img no-lazy="" class="inline" src="https://upyun.thatcdn.cn/public/web/emojis/bilibili/bb_doge.png"/></span></p><p>不会有博友也是PlanetScale吧。</p><p><img src="https://upyun.thatcdn.cn/myself/typora/202403220321769.png" alt="PlanetScale的免费Hobby计划即将结通知"></p>]]></content>
<summary type="html">白嫖党们,PlanetScale的免费Hobby计划即将结业,届时发现,白嫖之路已经走到了尽头。</summary>
<category term="堆栈" scheme="https://log.cns.red/categories/%E5%A0%86%E6%A0%88/"/>
<category term="随笔" scheme="https://log.cns.red/tags/%E9%9A%8F%E7%AC%94/"/>
</entry>
</feed>