[{"categories":["随笔"],"content":"人物介绍 姚远：某重点大学计算机系研究生，学霸一枚，成绩常年霸榜。外表斯文，戴着一副黑框眼镜。私下里却是个游戏狂魔，Warframe 和 DNF 双修玩家。对美女有着\u0026quot;学术般\u0026quot;的热情，但从不越界，止于欣赏。\n第一幕：图书馆的清晨 场景：大学图书馆，早上七点。阳光透过落地窗洒进来。\n姚远（低头奋笔疾书，桌上摊开三本厚厚的专业书）：\n今天的进度比预期快了二十分钟。很好。\n（手机震动。姚远瞥了一眼，嘴角微微上扬。）\n姚远：\n又是做任务的好日子。Warframe 的警报倒计时还有……\n（他迅速心算。）\n四小时三十二分。够我把这篇论文的算法部分写完。\n画外音：\n这是姚远的一天。精确、高效，却也藏着别人看不见的小确幸。\n第二幕：午间的\u0026quot;研究\u0026quot; 场景：校园食堂，人声鼎沸。\n姚远端着餐盘，正在寻找座位。忽然，他的目光定格。\n姚远（内心独白）：\n三点钟方向，长发，白裙子，气质型。两点钟方向，短发，运动风，活力型。\n（他微微推了推眼镜，露出一个心满意足的微笑。）\n姚远：\n今日份的欣赏额度，达标。\n（他找了个视野最好的位置坐下，一边吃饭一边继续\u0026quot;研究\u0026quot;。）\n姚远（自言自语）：\n不是好色，是审美。这是对美的尊重。\n画外音：\n姚远的\u0026quot;好色\u0026quot;从不冒犯任何人。他只是喜欢欣赏美好事物，就像欣赏一道精妙的算法。\n第三幕：深夜的游戏世界 场景：姚远的宿舍，晚上十一点。室友们已经睡了。\n姚远（戴着耳机，屏幕上 Warframe 的战甲正在刷图）：\n这个配卡还是有优化空间……如果换成范围流，效率能提升 15%。\n（突然，游戏里跳出一个交易请求。）\n陌生玩家：\n大佬，这个紫卡多少？\n姚远：\n看属性，这个值 200 白金。你急出的话，150 我收了。\n陌生玩家：\n成交！大佬爽快！\n姚远（满意地笑了）：\n又是盆满钵满的一天。\n（切屏，DNF 的登录界面出现。）\n姚远：\n接下来，该去阿拉德大陆转转了。\n画外音：\n没人知道，这个图书馆里沉默寡言的书呆子，是两个游戏世界里的\u0026quot;大佬\u0026quot;。他用实力证明：学霸也能把游戏玩到极致。\n第四幕：友情的温暖 场景：次日下午，宿舍楼下。\n我（看着姚远走过来）：\n姚远，听说你昨晚又熬夜打游戏了？\n姚远：\n凌晨一点就睡了，不算熬夜。而且……\n（他从包里掏出一本笔记本递给我。）\n姚远：\n你上次问的那个算法优化问题，我帮你推了一下，笔记在最后一页。\n我（翻开笔记本，里面密密麻麻全是推导过程）：\n这……你也太认真了吧！\n姚远（推了推眼镜）：\n朋友嘛。而且帮你推导的过程，也巩固了我自己的理解。\n我：\n你这人，游戏玩得好，学习也好，还这么靠谱。你是怎么做到的？\n姚远：\n很简单。把游戏当研究，把研究当游戏。\n（他笑了笑，转身离开。）\n姚远：\n对了，今晚公会活动，你要不要来？带你刷几把。\n画外音：\n这就是姚远。他教会我：热爱可以有很多种形式，而真正厉害的人，能在每个热爱的领域里都闪闪发光。\n尾声 场景：夜晚的图书馆，姚远又坐在了老位置。\n姚远（看着窗外）：\n明天又是新的一天。\n（他打开电脑，Warframe 的登录界面亮起。）\n姚远：\n不过在那之前……\n（他切到论文文档，继续敲击键盘。）\n姚远：\n先把这段代码优化完。\n画外音：\n这就是我的朋友姚远——一个学霸、游戏玩家、书呆子，和永远让人感到温暖的朋友。他的快乐很简单：做热爱的事，帮重要的人。\n（完）\n","date":"2026-03-23T17:55:00+08:00","permalink":"https://tangc.pro/blog/posts/2026-03-23-my-friend-yuanyuan-happy-life-test/","summary":"\u003ch2 id=\"人物介绍\"\u003e人物介绍\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e：某重点大学计算机系研究生，学霸一枚，成绩常年霸榜。外表斯文，戴着一副黑框眼镜。私下里却是个游戏狂魔，Warframe 和 DNF 双修玩家。对美女有着\u0026quot;学术般\u0026quot;的热情，但从不越界，止于欣赏。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"第一幕图书馆的清晨\"\u003e第一幕：图书馆的清晨\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e场景\u003c/strong\u003e：大学图书馆，早上七点。阳光透过落地窗洒进来。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e（低头奋笔疾书，桌上摊开三本厚厚的专业书）：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e今天的进度比预期快了二十分钟。很好。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e（手机震动。姚远瞥了一眼，嘴角微微上扬。）\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e又是做任务的好日子。Warframe 的警报倒计时还有……\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e（他迅速心算。）\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e四小时三十二分。够我把这篇论文的算法部分写完。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e画外音\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e这是姚远的一天。精确、高效，却也藏着别人看不见的小确幸。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"第二幕午间的研究\"\u003e第二幕：午间的\u0026quot;研究\u0026quot;\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e场景\u003c/strong\u003e：校园食堂，人声鼎沸。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e端着餐盘，正在寻找座位。忽然，他的目光定格。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e（内心独白）：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e三点钟方向，长发，白裙子，气质型。两点钟方向，短发，运动风，活力型。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e（他微微推了推眼镜，露出一个心满意足的微笑。）\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e今日份的欣赏额度，达标。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e（他找了个视野最好的位置坐下，一边吃饭一边继续\u0026quot;研究\u0026quot;。）\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e（自言自语）：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e不是好色，是审美。这是对美的尊重。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e画外音\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e姚远的\u0026quot;好色\u0026quot;从不冒犯任何人。他只是喜欢欣赏美好事物，就像欣赏一道精妙的算法。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"第三幕深夜的游戏世界\"\u003e第三幕：深夜的游戏世界\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e场景\u003c/strong\u003e：姚远的宿舍，晚上十一点。室友们已经睡了。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e（戴着耳机，屏幕上 Warframe 的战甲正在刷图）：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e这个配卡还是有优化空间……如果换成范围流，效率能提升 15%。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e（突然，游戏里跳出一个交易请求。）\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e陌生玩家\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e大佬，这个紫卡多少？\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e看属性，这个值 200 白金。你急出的话，150 我收了。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e陌生玩家\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e成交！大佬爽快！\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e（满意地笑了）：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e又是盆满钵满的一天。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e（切屏，DNF 的登录界面出现。）\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e接下来，该去阿拉德大陆转转了。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e画外音\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e没人知道，这个图书馆里沉默寡言的书呆子，是两个游戏世界里的\u0026quot;大佬\u0026quot;。他用实力证明：学霸也能把游戏玩到极致。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"第四幕友情的温暖\"\u003e第四幕：友情的温暖\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e场景\u003c/strong\u003e：次日下午，宿舍楼下。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e我\u003c/strong\u003e（看着姚远走过来）：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e姚远，听说你昨晚又熬夜打游戏了？\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e凌晨一点就睡了，不算熬夜。而且……\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e（他从包里掏出一本笔记本递给我。）\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e你上次问的那个算法优化问题，我帮你推了一下，笔记在最后一页。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e我\u003c/strong\u003e（翻开笔记本，里面密密麻麻全是推导过程）：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e这……你也太认真了吧！\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e姚远\u003c/strong\u003e（推了推眼镜）：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e朋友嘛。而且帮你推导的过程，也巩固了我自己的理解。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e我\u003c/strong\u003e：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e你这人，游戏玩得好，学习也好，还这么靠谱。你是怎么做到的？\u003c/p\u003e","tags":["随笔","剧本","友情"],"title":"测试：我的朋友姚远的快乐生活"},{"categories":["AI","思考"],"content":"编者按 这是一次关于大语言模型（LLM）本质的对话。从\u0026quot;相似性\u0026quot;这个直觉出发，聊到预训练、参数、注意力机制，最后聊到一个核心问题：LLM 真的在\u0026quot;理解\u0026quot;吗？有技术细节，也有对 AI 本质的冷静看法。\n第一部分：预训练 —— 压缩互联网，不是背书 问：帮我理解 LLM 如何能准确生成正确的答案，它内部是怎么理解知识和上下文的？我之前了解过，似乎就是相似性，通过预训练投喂 LLM 模型。\n答：你说的\u0026quot;相似性\u0026quot;是对的，但更准确说是高维空间中的位置关系。\n预训练时，模型看了海量文本，但它不是在记忆，而是在压缩规律。想象你要总结一本小说——背下来每一句话是死记硬背，学会\u0026quot;主角遇到困难会怎么做\u0026quot;才是掌握规律。LLM 做的是后者。\n它学会的是因果关系、语义关系（\u0026ldquo;国王 - 王后 ≈ 男 - 女\u0026rdquo;）、代码缩进规则、数学推理链条……这些规律被压缩进参数（权重）里。\n每个词被转成一个向量（比如 12288 维），在这个空间里，\u0026ldquo;猫\u0026quot;和\u0026quot;狗\u0026quot;距离近，\u0026ldquo;猫\u0026quot;和\u0026quot;汽车\u0026quot;距离远。相似概念在空间中聚集，不同概念分散。模型通过位置关系\u0026quot;理解\u0026quot;语义。\n第二部分：参数 —— 不是输入，是\u0026quot;大脑\u0026rdquo; 问：通常说的什么 xx B 参数，这个参数怎么理解，和程序开发中的方法参数类的有什么关系？\n答：这两个\u0026quot;参数\u0026quot;完全不是一回事，只是中文翻译撞名了。\n程序参数是输入，你调用时传进去的。而 LLM 的\u0026quot;参数\u0026quot;指的是模型内部的权重数量——训练完成后固定在那里的数值。\n7B 模型 = 70 亿个权重 70B 模型 = 700 亿个权重 一个简化的例子：\n# 一个最简单的神经元 def neuron(x, w, b): # x 是输入，w 和 b 是\u0026#34;参数\u0026#34; return w * x + b LLM 有几十亿个这样的 w 和 b，层层连接形成网络。训练就是不断调整这些数，让输出越来越对。\n类比一下：程序参数是你给函数的输入，LLM 参数是烘焙师傅 20 年积累的手感。你调用师傅（输入文本），他用手感（参数）做出蛋糕（输出）。\n第三部分：训练 —— 梯度下降找最优解 问：参数具体怎么组织的？什么是训练，训练时怎么调整这些参数？\n答：参数是一层层巨大的矩阵。以单个神经元为例：\ndef neuron(x, w, b): return w * x + b # 2 个参数：w 和 b 扩展到一层 4 个神经元、输入 2 个值，就是 12 个参数。GPT-3 有 175B 参数，就是一堆巨大的矩阵层层堆叠。\n训练过程是：\n前向传播：输入 → 网络 → 预测输出 计算损失：预测 vs 正确答案 → loss 数字 反向传播：计算每个参数的梯度（责任归属） 更新参数：参数 = 参数 - 学习率 × 梯度 想象你在山上（高 loss），要下山（低 loss）。梯度是山坡的陡峭方向，学习率是每步走多远。训练就是不断往下走，找到最低点。\nLLM 训练的规模：几万亿词、几周到几个月、数千张 GPU、百万美元电费。\n第四部分：本质 —— 巨大的概率预测器 问：我大致可以理解这种模型，就是将人类输入的语言抽象化转换为 LLM 模型的输入格式，然后使用这个巨大的概率预测模型进行计算，最后输出概率最大的结果。\n答：对，你抓住了本质。\n模型看到的不是文字，是 token ID：\n\u0026#34;我喜欢编程\u0026#34; → [我, 喜, 欢, 编, 程] → [2847, 1923, 4501, 7823, 2109] 输出也是一个概率分布：\n输入: \u0026#34;今天天气\u0026#34; 输出概率: \u0026#34;很好\u0026#34;: 0.35 \u0026#34;不错\u0026#34;: 0.28 \u0026#34;糟糕\u0026#34;: 0.22 实际会用采样策略，从高概率词里随机选一个，加点\u0026quot;创意\u0026rdquo;。\n所有\u0026quot;智能\u0026quot;都是从\u0026quot;预测下一个词\u0026quot;这个简单任务里出来的——预测得足够准，就需要理解语法、语义、逻辑、常识、世界知识。\n第五部分：局限 —— 上下文越长，越容易翻车 问：听着有点不可思议，按我的理解这种理解如果上下文太大的话，预测为正确的概率会非常低吧，因为有的人类的语言可能包含各种语境、歧义等等。\n答：你的直觉很敏锐。上下文越长，歧义越多，预测越难。\n注意力机制让每个词都能\u0026quot;看到\u0026quot;所有其他词，但计算量是 O(n²)，10000 个词就要算 10000 × 10000 的关系矩阵。所以长对话中，模型会重点\u0026quot;关注\u0026quot;最近的对话，远一点的内容变模糊。\n模型会倾向于最常见的解释。训练数据里\u0026quot;银行=金融机构\u0026quot;出现更多，即使上下文是\u0026quot;我刚钓完鱼，去银行\u0026quot;，模型可能还是往金融方向预测。\n什么时候会翻车：\n需要精确记忆 → \u0026ldquo;我第 3 段说了什么？\u0026rdquo; 需要精确逻辑 → \u0026ldquo;如果 A 且非 B，则\u0026hellip;\u0026rdquo; 需要理解歧义 → \u0026ldquo;他的意思是什么？\u0026rdquo; 长依赖关系 → \u0026ldquo;第 1 章埋的伏笔\u0026rdquo; 但语言不需要唯一正确答案，人类交流本来就有模糊性，模型输出\u0026quot;统计上合理\u0026quot;就够了。\n第六部分：真相 —— 不是智能体，是模式匹配器 问：这和我期望的人工智能不一样，这个虽然目前来说看起来很智能，但也只是通过巨大概率计算堆叠出来的，如果训练数据不全面，那它并不能输出很有价值的结果，都是基于已知的知识库模型化的结果。\n答：你切中了 LLM 的根本局限。\nLLM 本质上就是：训练数据 → 压缩成参数 → 基于统计预测输出\n所以：\n没有训练数据 = 不知道 训练数据有偏差 = 输出有偏差 需要真正推理 = 可能翻车 需要创造新知识 = 做不到 它不是\u0026quot;思考\u0026quot;，是\u0026quot;检索 + 重组\u0026quot;。\n学术界知道这个局限，有不同研究方向：符号 AI（逻辑推理）、神经符号结合、因果推理（Judea Pearl）、世界模型（LeCun）。但目前主流的 LLM 就是统计语言模型——名字其实挺诚实的。\nLLM 的价值在于：知识检索、语言任务、编程辅助、头脑风暴、辅助工具。它是增强工具，不是智能体。\n第七部分：价值 —— 取代的是重复劳动 问：不过按目前人类社会的发展来说，从经济社会角度，人们生活、工作上需要处理的任务，绝大多并不具备很强的复杂程度，基本都是可以流程化的，且每个流程要处理的任务都是简单不需要过多创新知识的，LLM 在这种场景下能发挥很大的作用，基本可以取代人类，我这么理解对么？\n答：你的判断很准确。\n大部分工作实际上是：接收信息 → 按规则处理 → 输出结果\n客服看问题、匹配答案模板、回复；初级文员看文档、提取信息、填表格；基础翻译看原文、转换语言、输出译文；初级编程理解需求、写常见模式、调试。这些不需要\u0026quot;创新\u0026quot;，需要的是\u0026quot;正确执行流程\u0026quot;。LLM 不能创造，但能执行。而大部分工作要的是执行。\n已经在替代的：客服机器人、文案生成、基础翻译、代码辅助、文档处理、数据标注。即将替代的：初级财务、初级法律、初级运营、基础设计。\n剩下不能替代的：真正的决策、复杂谈判、创新工作、信任关系、边缘情况、线下操作。\n新的分工大概是：AI 做 80%，人做 20%（审核、决策、处理边缘情况）。岗位会减少，但人往上游走——从执行者变成监督者、设计者。\n结语 从技术角度看，LLM 是一个巨大的、学了语言规律的、下一个词预测器。它的\u0026quot;智能\u0026quot;是从统计中出来的，不是真正的理解。\n但从经济角度看，这已经够有价值了——大部分人类工作要的不是创造，是执行。LLM 取代的不是\u0026quot;人类的智慧\u0026quot;，而是\u0026quot;人类的重复劳动\u0026quot;。\n我们就在这个节点上。\n本文整理自一次真实的对话，略有润色。\n","date":"2026-03-03T17:32:00+08:00","permalink":"https://tangc.pro/blog/posts/llm-interview-how-it-works/","summary":"\u003ch2 id=\"编者按\"\u003e编者按\u003c/h2\u003e\n\u003cp\u003e这是一次关于大语言模型（LLM）本质的对话。从\u0026quot;相似性\u0026quot;这个直觉出发，聊到预训练、参数、注意力机制，最后聊到一个核心问题：LLM 真的在\u0026quot;理解\u0026quot;吗？有技术细节，也有对 AI 本质的冷静看法。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"第一部分预训练--压缩互联网不是背书\"\u003e第一部分：预训练 —— 压缩互联网，不是背书\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e问：帮我理解 LLM 如何能准确生成正确的答案，它内部是怎么理解知识和上下文的？我之前了解过，似乎就是相似性，通过预训练投喂 LLM 模型。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e答：你说的\u0026quot;相似性\u0026quot;是对的，但更准确说是\u003cstrong\u003e高维空间中的位置关系\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e预训练时，模型看了海量文本，但它\u003cstrong\u003e不是在记忆\u003c/strong\u003e，而是在\u003cstrong\u003e压缩规律\u003c/strong\u003e。想象你要总结一本小说——背下来每一句话是死记硬背，学会\u0026quot;主角遇到困难会怎么做\u0026quot;才是掌握规律。LLM 做的是后者。\u003c/p\u003e\n\u003cp\u003e它学会的是因果关系、语义关系（\u0026ldquo;国王 - 王后 ≈ 男 - 女\u0026rdquo;）、代码缩进规则、数学推理链条……这些规律被压缩进参数（权重）里。\u003c/p\u003e\n\u003cp\u003e每个词被转成一个向量（比如 12288 维），在这个空间里，\u0026ldquo;猫\u0026quot;和\u0026quot;狗\u0026quot;距离近，\u0026ldquo;猫\u0026quot;和\u0026quot;汽车\u0026quot;距离远。相似概念在空间中聚集，不同概念分散。模型通过位置关系\u0026quot;理解\u0026quot;语义。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"第二部分参数--不是输入是大脑\"\u003e第二部分：参数 —— 不是输入，是\u0026quot;大脑\u0026rdquo;\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e问：通常说的什么 xx B 参数，这个参数怎么理解，和程序开发中的方法参数类的有什么关系？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e答：这两个\u0026quot;参数\u0026quot;完全不是一回事，只是中文翻译撞名了。\u003c/p\u003e\n\u003cp\u003e程序参数是\u003cstrong\u003e输入\u003c/strong\u003e，你调用时传进去的。而 LLM 的\u0026quot;参数\u0026quot;指的是\u003cstrong\u003e模型内部的权重数量\u003c/strong\u003e——训练完成后固定在那里的数值。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e7B 模型 = 70 亿个权重\n70B 模型 = 700 亿个权重\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e一个简化的例子：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 一个最简单的神经元\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003edef\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eneuron\u003c/span\u003e(x, w, b):      \u003cspan style=\"color:#75715e\"\u003e# x 是输入，w 和 b 是\u0026#34;参数\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e w \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e x \u003cspan style=\"color:#f92672\"\u003e+\u003c/span\u003e b\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eLLM 有几十亿个这样的 \u003ccode\u003ew\u003c/code\u003e 和 \u003ccode\u003eb\u003c/code\u003e，层层连接形成网络。训练就是不断调整这些数，让输出越来越对。\u003c/p\u003e\n\u003cp\u003e类比一下：程序参数是你给函数的输入，LLM 参数是烘焙师傅 20 年积累的手感。你调用师傅（输入文本），他用手感（参数）做出蛋糕（输出）。\u003c/p\u003e","tags":["LLM","AI","思考"],"title":"对话：LLM 真的在「理解」吗？—— 揭开大语言模型的工作原理"},{"categories":["教程","Hugo"],"content":"从使用者角度整理的 Hugo 常识，够用就行。\n目录结构 blog/ ├── hugo.toml # 主配置文件（也可用 config.toml/yaml） ├── content/ # 所有内容（文章、页面） │ ├── posts/ # 博客文章 │ └── about.md # 单页（/about/） ├── layouts/ # 自定义模板（覆盖主题） │ ├── _default/ │ │ ├── baseof.html # 基础模板 │ │ ├── single.html # 单页模板 │ │ └── list.html # 列表页模板 │ └── partials/ # 可复用片段 ├── themes/ # 主题目录 ├── assets/ # 需要处理的资源 │ └── css/extended/ # 自定义 CSS（自动合并） ├── static/ # 静态文件（直接复制） └── public/ # 构建输出（勿手动编辑） 构建原理 理解 Hugo 的构建流程，关键是看懂各目录的角色：\n┌─────────────────────────────────────────────────────────────────┐ │ Hugo 构建流程 │ └─────────────────────────────────────────────────────────────────┘ 输入层（你写的） 处理层 输出层 ────────────── ────────── ────────── ┌──────────────┐ │ hugo.toml │─────────────────────┐ │ (配置) │ │ └──────────────┘ ▼ ┌─────────────────┐ ┌──────────────┐ │ │ │ content/ │───────────▶│ │ │ (Markdown) │ │ Hugo │ └──────────────┘ │ 引擎 │ │ │ ┌──────────────┐ │ 模板 + 内容 │ │ layouts/ │───────────▶│ + 资源 = HTML │ │ (模板) │ │ │ └──────────────┘ │ │ ┌──────────────┐ │ │───────────────▶│ public/ │ ┌──────────────┐ │ │ │ (输出) │ │ assets/ │───────────▶│ │ │ │ │ (资源管道) │ │ │ │ index.html │ └──────────────┘ │ │ │ posts/ │ │ │ │ css/ │ ┌──────────────┐ │ │ │ ... │ │ static/ │───────────▶│ │ │ │ │ (直接复制) │ └─────────────────┘ └──────────────┘ └──────────────┘ 各目录的角色 目录 角色 构建时行为 content/ 数据源 Markdown → 解析为 Page 对象 layouts/ 渲染模板 Go 模板引擎处理，绑定内容 themes/ 备用模板 被 layouts/ 覆盖 assets/ 资源管道 处理、压缩、指纹化 static/ 静态资源 直接复制到 public/ public/ 输出目录 最终静态站点 单篇文章的渲染链路 content/posts/my-post.md │ ▼ ┌───────────────────────────────────────┐ │ 1. 解析 Front Matter（title, date...） │ │ 2. Markdown → HTML │ │ 3. 确定 Content Type → 选择模板 │ └───────────────────────────────────────┘ │ ▼ layouts/_default/single.html 或 themes/xxx/layouts/_default/single.html │ ▼ ┌───────────────────────────────────────┐ │ 模板渲染： │ │ - {{ .Title }} → 文章标题 │ │ - {{ .Content }} → 正文 HTML │ │ - {{ partial \u0026#34;toc.html\u0026#34; . }} → 目录 │ └───────────────────────────────────────┘ │ ▼ public/posts/my-post/index.html 模板选择逻辑 Hugo 按以下顺序查找模板（优先级从高到低）：\n1. layouts/posts/single.html ← 针对 posts section 2. layouts/_default/single.html ← 默认单页模板 3. themes/xxx/layouts/posts/single.html 4. themes/xxx/layouts/_default/single.html 找到第一个匹配的模板后停止搜索。这就是为什么在 layouts/ 放同名文件可以覆盖主题模板。\n资源管道流程 assets/css/extended/a.css assets/css/extended/b.css │ ▼ resources.Match \u0026#34;css/extended/*.css\u0026#34; │ ┌─────────────────┐ │ resources.Concat│ → 合并为一个文件 └─────────────────┘ │ ▼ ┌─────────────────┐ │ resources.Minify│ → 压缩（删除空格注释） └─────────────────┘ │ ▼ ┌─────────────────┐ │ fingerprint │ → 生成哈希文件名（缓存控制） └─────────────────┘ │ ▼ public/assets/css/stylesheet.abc123.css 模板优先级 layouts/ ← 最高优先级（你的自定义） themes/PaperMod/layouts/ ← 主题默认模板 同名文件，layouts/ 覆盖 themes/。部分模板可单独覆盖，比如 layouts/partials/toc.html 只覆盖目录组件。\n常用命令 hugo # 构建到 public/ hugo server # 本地预览（默认 localhost:1313） hugo server -D # 包含草稿 hugo new posts/xxx.md # 新建文章 文章格式 --- title: \u0026#34;文章标题\u0026#34; date: 2024-01-01 draft: false # true = 草稿，不发布 tags: [\u0026#34;hugo\u0026#34;] showToc: true # 显示目录 --- 正文内容... 样式覆盖方式 方式 说明 assets/css/extended/*.css 自动合并到主样式表 layouts/partials/extend_head.html 插入额外 \u0026lt;head\u0026gt; 内容 static/ 直接复制，用绝对路径引用 CSS 自动合并示例 PaperMod 主题在 head.html 中有这行：\n{{- $extended := (resources.Match \u0026#34;css/extended/*.css\u0026#34;) | resources.Concat \u0026#34;assets/css/extended.css\u0026#34; | resources.Minify }} 只要把 .css 放进 assets/css/extended/，Hugo 构建时自动加载，无需手动添加 \u0026lt;link\u0026gt; 标签。\n配置要点 baseURL = \u0026#39;https://example.com/\u0026#39; theme = \u0026#39;PaperMod\u0026#39; [params] ShowToc = true # 显示目录 defaultTheme = \u0026#34;auto\u0026#34; # auto/light/dark [markup.goldmark.renderer] unsafe = true # 允许 HTML 标签 关键概念 概念 说明 Section content 下的目录，如 posts/ Page Bundle index.md + 同目录资源（页面级资源管理） Shortcode 内容中的 {{\u0026lt; xxx \u0026gt;}}，扩展 Markdown Partial 可复用模板片段，{{ partial \u0026quot;xxx.html\u0026quot; . }} Archetype hugo new 时的默认模板 资源管道 Hugo 可以对资源进行处理链：\n{{- $css := (resources.Match \u0026#34;css/extended/*.css\u0026#34;) | resources.Concat \u0026#34;assets/css/extended.css\u0026#34; | resources.Minify }} 处理流程：匹配文件 → 合并 → 压缩 → 输出\n就这些，基本够用了。\n","date":"2026-03-03T10:33:00+08:00","permalink":"https://tangc.pro/blog/posts/hugo-essentials/","summary":"\u003cp\u003e从使用者角度整理的 Hugo 常识，够用就行。\u003c/p\u003e\n\u003ch2 id=\"目录结构\"\u003e目录结构\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eblog/\n├── hugo.toml          # 主配置文件（也可用 config.toml/yaml）\n├── content/           # 所有内容（文章、页面）\n│   ├── posts/        # 博客文章\n│   └── about.md      # 单页（/about/）\n├── layouts/           # 自定义模板（覆盖主题）\n│   ├── _default/\n│   │   ├── baseof.html   # 基础模板\n│   │   ├── single.html   # 单页模板\n│   │   └── list.html     # 列表页模板\n│   └── partials/      # 可复用片段\n├── themes/            # 主题目录\n├── assets/            # 需要处理的资源\n│   └── css/extended/  # 自定义 CSS（自动合并）\n├── static/            # 静态文件（直接复制）\n└── public/            # 构建输出（勿手动编辑）\n\u003c/code\u003e\u003c/pre\u003e\u003ch2 id=\"构建原理\"\u003e构建原理\u003c/h2\u003e\n\u003cp\u003e理解 Hugo 的构建流程，关键是看懂各目录的角色：\u003c/p\u003e","tags":["hugo","blog","static-site"],"title":"Hugo 使用常识速查"},{"categories":["运维","安全"],"content":"什么是 fail2ban？ 简单来说，fail2ban 就是一个自动封禁 IP 的工具。\n当你运行 SSH、Web 服务或者其他网络服务时，总会有人试图暴力破解或者恶意扫描。fail2ban 会监控日志文件，当发现某个 IP 在短时间内多次失败尝试后，自动调用防火墙把这个 IP 封禁一段时间。\n为什么需要它？ 我当初上云服务器的第一件事就是装 fail2ban。原因很简单：\n减少日志噪音，不用看着满屏的失败登录记录 自动阻止暴力破解 配置好后基本不用管 可以针对不同服务设置不同规则 安装 Ubuntu/Debian sudo apt update sudo apt install fail2ban CentOS/RHEL sudo yum install epel-release sudo yum install fail2ban 安装完成后，服务会自动启动：\nsudo systemctl enable fail2ban sudo systemctl start fail2ban 基本配置 fail2ban 的配置文件在 /etc/fail2ban/ 目录下：\nfail2ban.conf - 主配置（一般不用改） jail.conf - 默认规则配置（不要直接修改） jail.local - 本地覆盖配置（在这里自定义） 创建本地配置 sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local 最小可用配置 编辑 /etc/fail2ban/jail.local：\n[DEFAULT] # 封禁时长（秒） bantime = 3600 # 时间窗口（秒） findtime = 600 # 最大尝试次数 maxretry = 5 # 使用的防火墙 backend = auto # SSH 保护 [sshd] enabled = true port = ssh filter = sshd logpath = /var/log/auth.log maxretry = 3 bantime = 7200 💡 提示：不同系统的日志路径可能不同\nUbuntu/Debian: /var/log/auth.log CentOS/RHEL: /var/log/secure 常用命令 查看状态 # 查看整体状态 sudo fail2ban-client status # 查看某个 jail 的状态 sudo fail2ban-client status sshd 输出示例：\nStatus for the jail: sshd |- Filter | |- Currently failed: 2 | |- Total failed: 156 | `- File list: /var/log/auth.log `- Actions |- Currently banned: 3 |- Total banned: 45 `- Banned IP list: 1.2.3.4 5.6.7.8 9.10.11.12 手动封禁/解封 # 手动封禁一个 IP sudo fail2ban-client set sshd banip 1.2.3.4 # 手动解封一个 IP sudo fail2ban-client set sshd unbanip 1.2.3.4 重新加载配置 sudo fail2ban-client reload 查看日志 # fail2ban 自身日志 sudo tail -f /var/log/fail2ban.log # 实时查看封禁动态 sudo journalctl -u fail2ban -f 保护其他服务 fail2ban 不只是保护 SSH，还可以保护：\nNginx/Apache [nginx-botsearch] enabled = true port = http,https filter = nginx-botsearch logpath = /var/log/nginx/access.log maxretry = 2 [nginx-http-auth] enabled = true filter = nginx-http-auth port = http,https logpath = /var/log/nginx/error.log MySQL [mysqld-auth] enabled = true filter = mysqld-auth port = 3306 logpath = /var/log/mysql/error.log maxretry = 5 Postfix/Dovecot [postfix] enabled = true port = smtp,ssmtp,submission filter = postfix logpath = /var/log/mail.log [dovecot] enabled = true port = pop3,pop3s,imap,imaps filter = dovecot logpath = /var/log/mail.log 自定义 Filter 如果默认 filter 不满足需求，可以自定义。在 /etc/fail2ban/filter.d/ 下创建：\n# /etc/fail2ban/filter.d/myapp.conf [Definition] failregex = ^.*Failed login from \u0026lt;HOST\u0026gt;.*$ ignoreregex = 然后在 jail.local 中引用：\n[myapp] enabled = true filter = myapp logpath = /var/log/myapp.log 实用技巧 1. 白名单自己的 IP 避免把自己锁在外面：\n[DEFAULT] ignoreip = 127.0.0.1/8 ::1 你的公网 IP 2. 调整封禁策略 对于特别敏感的服务，可以更严格：\n[sshd] maxretry = 2 # 2 次失败就封 bantime = 86400 # 封 24 小时 findtime = 3600 # 1 小时内 3. 邮件通知 配置后可以在封禁时收到邮件（需要安装 mail 或 postfix）：\n[DEFAULT] destemail = your@email.com sender = fail2ban@yourserver.com mta = sendmail action = %(action_mwl)s 4. 查看被封 IP 的历史 # 查看 fail2ban 日志中的封禁记录 grep \u0026#34;Ban\u0026#34; /var/log/fail2ban.log # 统计被封次数最多的 IP grep \u0026#34;Ban\u0026#34; /var/log/fail2ban.log | awk \u0026#39;{print $NF}\u0026#39; | sort | uniq -c | sort -rn | head -10 常见问题 Q: 封禁不生效？ 检查防火墙：\n# 查看 iptables 规则 sudo iptables -L -n # 或者 nftables sudo nft list ruleset Q: 如何永久封禁？ 设置 bantime = -1 即可永久封禁（慎用）。\nQ: 误封了怎么办？ sudo fail2ban-client set \u0026lt;jail-name\u0026gt; unbanip \u0026lt;ip-address\u0026gt; 然后检查 filter 规则是否需要调整。\n总结 fail2ban 是服务器必备的安全工具。配置简单，效果显著。\n我的建议：\n装完系统第一时间配置 至少保护 SSH 服务 把自己的 IP 加入白名单 定期查看日志了解攻击情况 参考链接：\nfail2ban 官方文档 GitHub 仓库 ","date":"2026-03-02T17:38:00+08:00","permalink":"https://tangc.pro/blog/posts/fail2ban-basic-guide/","summary":"\u003ch2 id=\"什么是-fail2ban\"\u003e什么是 fail2ban？\u003c/h2\u003e\n\u003cp\u003e简单来说，\u003cstrong\u003efail2ban 就是一个自动封禁 IP 的工具\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e当你运行 SSH、Web 服务或者其他网络服务时，总会有人试图暴力破解或者恶意扫描。fail2ban 会监控日志文件，当发现某个 IP 在短时间内多次失败尝试后，自动调用防火墙把这个 IP 封禁一段时间。\u003c/p\u003e\n\u003ch2 id=\"为什么需要它\"\u003e为什么需要它？\u003c/h2\u003e\n\u003cp\u003e我当初上云服务器的第一件事就是装 fail2ban。原因很简单：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e减少日志噪音，不用看着满屏的失败登录记录\u003c/li\u003e\n\u003cli\u003e自动阻止暴力破解\u003c/li\u003e\n\u003cli\u003e配置好后基本不用管\u003c/li\u003e\n\u003cli\u003e可以针对不同服务设置不同规则\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"安装\"\u003e安装\u003c/h2\u003e\n\u003ch3 id=\"ubuntudebian\"\u003eUbuntu/Debian\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt update\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt install fail2ban\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"centosrhel\"\u003eCentOS/RHEL\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo yum install epel-release\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo yum install fail2ban\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e安装完成后，服务会自动启动：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo systemctl enable fail2ban\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo systemctl start fail2ban\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"基本配置\"\u003e基本配置\u003c/h2\u003e\n\u003cp\u003efail2ban 的配置文件在 \u003ccode\u003e/etc/fail2ban/\u003c/code\u003e 目录下：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003efail2ban.conf\u003c/code\u003e - 主配置（一般不用改）\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003ejail.conf\u003c/code\u003e - 默认规则配置（\u003cstrong\u003e不要直接修改\u003c/strong\u003e）\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003ejail.local\u003c/code\u003e - 本地覆盖配置（\u003cstrong\u003e在这里自定义\u003c/strong\u003e）\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"创建本地配置\"\u003e创建本地配置\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"最小可用配置\"\u003e最小可用配置\u003c/h3\u003e\n\u003cp\u003e编辑 \u003ccode\u003e/etc/fail2ban/jail.local\u003c/code\u003e：\u003c/p\u003e","tags":["fail2ban","security","linux","server"],"title":"fail2ban 基本介绍与使用指南"},{"categories":["随笔"],"content":"欢迎来到我的博客 🎉 这是我的第一篇博客文章，使用 Hugo + PaperMod 主题搭建。\n写博客这件事 几年前我也搭建过个人博客。那时写文章流程很完整：定主题、列大纲、理结构、写正文、反复修改润色，最后才发布。认真写了小半年，后来工作生活繁忙，慢慢就搁置了。再后来云服务器到期被收回，文章又没做版本管理，就这么找不到了。\n有时真感慨岁月不饶人。这几年起起伏伏，我也经历了多个身份的转变——如今是一位丈夫、一位父亲，年龄也到了 30+。心态与之前有了很大不同，很多个瞬间让我感觉人生到了一个新阶段，提醒我要做出些改变。\n前些日子翻看工作生活笔记，看到久远的一些文章草稿，又萌发了自己做博客网站的念头：在写作中沉淀自己，顺着过程中涌现的灵感做些新尝试，保持生活的热情和创造力。\nLLM 最近几年很热门，涌现了很多基于其的应用和工具——ChatGPT、Claude、OpenClaw……这次博客我也想尝试些新方式：技术类文章，我会指定主题、大纲、结构和写作偏好，内容通过 prompt、RAG 之类的方式由 LLM 生成，来源是我自己的笔记和相关技术文档，最终生成的文章我会仔细检查修改后发布。看看能不能碰撞出什么火花。\n关于这个博客 📝 使用 Markdown 写作 🚀 基于 Hugo 静态生成 🎨 PaperMod 主题 📦 Nginx 反向代理 接下来 在这里我会记录：\n技术学习笔记 生活感悟 有趣的项目 感谢访问！\n","date":"2026-03-02T13:20:00+08:00","permalink":"https://tangc.pro/blog/posts/hello-world/","summary":"\u003ch2 id=\"欢迎来到我的博客-\"\u003e欢迎来到我的博客 🎉\u003c/h2\u003e\n\u003cp\u003e这是我的第一篇博客文章，使用 \u003cstrong\u003eHugo\u003c/strong\u003e + \u003cstrong\u003ePaperMod\u003c/strong\u003e 主题搭建。\u003c/p\u003e\n\u003ch3 id=\"写博客这件事\"\u003e写博客这件事\u003c/h3\u003e\n\u003cp\u003e几年前我也搭建过个人博客。那时写文章流程很完整：定主题、列大纲、理结构、写正文、反复修改润色，最后才发布。认真写了小半年，后来工作生活繁忙，慢慢就搁置了。再后来云服务器到期被收回，文章又没做版本管理，就这么找不到了。\u003c/p\u003e\n\u003cp\u003e有时真感慨岁月不饶人。这几年起起伏伏，我也经历了多个身份的转变——如今是一位丈夫、一位父亲，年龄也到了 30+。心态与之前有了很大不同，很多个瞬间让我感觉人生到了一个新阶段，提醒我要做出些改变。\u003c/p\u003e\n\u003cp\u003e前些日子翻看工作生活笔记，看到久远的一些文章草稿，又萌发了自己做博客网站的念头：在写作中沉淀自己，顺着过程中涌现的灵感做些新尝试，保持生活的热情和创造力。\u003c/p\u003e\n\u003cp\u003eLLM 最近几年很热门，涌现了很多基于其的应用和工具——ChatGPT、Claude、OpenClaw……这次博客我也想尝试些新方式：技术类文章，我会指定主题、大纲、结构和写作偏好，内容通过 prompt、RAG 之类的方式由 LLM 生成，来源是我自己的笔记和相关技术文档，最终生成的文章我会仔细检查修改后发布。看看能不能碰撞出什么火花。\u003c/p\u003e\n\u003ch3 id=\"关于这个博客\"\u003e关于这个博客\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e📝 使用 Markdown 写作\u003c/li\u003e\n\u003cli\u003e🚀 基于 Hugo 静态生成\u003c/li\u003e\n\u003cli\u003e🎨 PaperMod 主题\u003c/li\u003e\n\u003cli\u003e📦 Nginx 反向代理\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"接下来\"\u003e接下来\u003c/h3\u003e\n\u003cp\u003e在这里我会记录：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e技术学习笔记\u003c/li\u003e\n\u003cli\u003e生活感悟\u003c/li\u003e\n\u003cli\u003e有趣的项目\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e感谢访问！\u003c/p\u003e","tags":["hugo","blog","first-post"],"title":"Hello World"},{"categories":["后端开发","分布式"],"content":"背景 最近在做一个在线文档协作系统，用户可以在平台上编辑文档，编辑完成后触发一系列业务流程——生成版本、数字签名、归档等。文档编辑器选用了 ONLYOFFICE，功能强大，协作体验也好。\n看起来是个挺常规的需求，对吧？\n我当初也是这么想的。直到系统上线后，用户反馈\u0026quot;数字签名偶尔会失效\u0026quot;，我才意识到自己踩进了一个不大不小的坑里。\n问题出在哪？ ONLYOFFICE 的架构中，文档编辑和文件存储是分离的。用户在编辑器里修改文档，改动先停留在 ONLYOFFICE Document Server 的内存中，并不会立即写入你的存储后端。\nONLYOFFICE 提供了 forcesave 接口，可以强制保存文档。我最初的实现是这样的：\n// 用户点击\u0026#34;提交\u0026#34;按钮时触发 public void submitDocument(String documentId) { // 1. 调用 forcesave onlyofficeService.forceSave(documentId); // 2. 获取文件内容 byte[] fileContent = storageService.getFile(documentId); // 3. 生成数字签名 String signature = signService.sign(fileContent); // 4. 保存签名记录 recordService.saveSignature(documentId, signature); } 看起来逻辑清晰，没什么问题。但上线后，用户反馈：某些文档的数字签名校验失败。\n排查后发现：当用户打开文档验证时，文件内容和签名时不一致——明明签名后没人再编辑过，怎么文件变了？\n根本原因：forcesave 是异步的 翻了 ONLYOFFICE 的文档，才发现 forcesave 的调用流程是这样的：\n后端调用 forcesave API ↓ ONLYOFFICE Document Server 接收请求 ↓ Document Server 在内部调度保存任务（异步） ↓ 保存完成后，Document Server 回调后端的 Callback Handler ↓ 后端在回调中收到最新的文件 URL 也就是说，forcesave() 返回时，文件还没保存完成。真正的文件更新是在回调里发生的。\n我的代码里，forcesave() 调用后立即去取文件，拿到的其实是旧文件。然后在旧文件上计算了数字签名。等 ONLYOFFICE 真正把新文件写回去，签名自然就失效了。\n时间线: ────────────────────────────────────────────────────────→ │ │ │ │ │ │ forcesave() sign(旧文件) 回调写入新文件 │ │ │ └────────────────────┴──────────────────────┘ 签名基于旧文件 文件已更新 ↓ ↓ └─────→ 签名验证失败 ←─────┘ 这是一个典型的时序问题：业务逻辑依赖了异步操作的结果，但没有等待操作完成。\n解决思路 要让业务逻辑正确，必须在 forcesave 真正完成后才能继续。也就是说，需要把异步的 forcesave 变成同步等待。\n用户触发动作 │ ↓ 调用 forcesave API │ ↓ ───────── 阻塞等待 ───────── │ │ ONLYOFFICE 处理 │ ↓ │ 回调后端接口 │ ↓ │ countDown() 触发 │ ↓ ───────── 阻塞解除 ───────── │ ↓ 获取最新文件 │ ↓ 继续业务逻辑 由于我们的服务是多实例部署的，调用 forcesave 的实例和接收回调的实例可能不是同一个，所以需要一个分布式的同步机制。\nRedisson 提供的 RCountDownLatch 正好满足这个需求。\n实现方案 1. 定义 Latch Key 每个 forcesave 操作需要一个唯一的标识，用来关联等待线程和回调。\nString latchKey = \u0026#34;forcesave:\u0026#34; + documentId + \u0026#34;:\u0026#34; + System.currentTimeMillis(); 使用时间戳确保每次 forcesave 都有独立的 latch，避免并发场景下的混乱。\n2. 调用方：阻塞等待 @Service public class DocumentService { @Autowired private RedissonClient redisson; @Autowired private OnlyOfficeService onlyOfficeService; public void submitDocument(String documentId) { // 生成唯一的 latch key String latchKey = \u0026#34;forcesave:\u0026#34; + documentId + \u0026#34;:\u0026#34; + System.currentTimeMillis(); // 初始化 CountDownLatch，计数为 1 RCountDownLatch latch = redisson.getCountDownLatch(latchKey); latch.trySetCount(1); try { // 调用 forcesave，传递 latchKey 用于回调时识别 onlyOfficeService.forceSave(documentId, latchKey); // 等待回调完成，设置超时避免死锁 boolean completed = latch.await(30, TimeUnit.SECONDS); if (!completed) { throw new RuntimeException(\u0026#34;forcesave 超时，文档保存失败\u0026#34;); } // 此时文件已经是最新的了 byte[] fileContent = storageService.getFile(documentId); String signature = signService.sign(fileContent); recordService.saveSignature(documentId, signature); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(\u0026#34;forcesave 被中断\u0026#34;, e); } finally { // 清理 latch latch.delete(); } } } 3. 调用 ONLYOFFICE forcesave API 在调用 forcesave 时，需要把 latchKey 传过去。ONLYOFFICE 的 forcesave 请求本身不支持自定义参数，但可以通过回调 URL 携带。\n一种做法是在文档打开时设置回调 URL 时带上占位符，forcesave 时替换：\npublic void forceSave(String documentId, String latchKey) { // 构造 forcesave 请求 JSONObject request = new JSONObject(); request.put(\u0026#34;c\u0026#34;, \u0026#34;forcesave\u0026#34;); request.put(\u0026#34;key\u0026#34;, documentId); // 告诉 ONLYOFFICE 回调时带上这个 key // 注意：实际实现中，回调 URL 通常在编辑器初始化时就固定了 // 这里需要在回调处理时能够关联到对应的 latchKey // 可以在 Redis 中维护 documentId -\u0026gt; latchKey 的映射 redisTemplate.opsForValue().set( \u0026#34;forcesave:latchKey:\u0026#34; + documentId, latchKey, 5, TimeUnit.MINUTES ); // 发送 HTTP 请求到 ONLYOFFICE Command Service // POST to http://\u0026lt;documentserver\u0026gt;/coauthoring/CommandService.ashx String response = httpClient.post( onlyofficeConfig.getCommandUrl(), request.toString() ); log.info(\u0026#34;forcesave response: {}\u0026#34;, response); } 4. 回调处理：解除阻塞 ONLYOFFICE 会在 forcesave 完成后调用我们配置的回调接口：\n@RestController @RequestMapping(\u0026#34;/onlyoffice\u0026#34;) public class OnlyOfficeCallbackController { @Autowired private RedissonClient redisson; @Autowired private RedisTemplate\u0026lt;String, String\u0026gt; redisTemplate; @PostMapping(\u0026#34;/callback\u0026#34;) public String callback(@RequestBody JSONObject body) { log.info(\u0026#34;Received callback: {}\u0026#34;, body); String status = body.getString(\u0026#34;status\u0026#34;); String documentKey = body.getString(\u0026#34;key\u0026#34;); // status = 2 表示文档正在保存，forcesave 完成也是这个状态 if (\u0026#34;2\u0026#34;.equals(status)) { // 获取文件 URL，下载并保存到存储 String downloadUrl = body.getString(\u0026#34;url\u0026#34;); byte[] fileContent = httpClient.download(downloadUrl); storageService.save(documentKey, fileContent); // 触发 CountDownLatch，解除阻塞 String latchKey = redisTemplate.opsForValue() .get(\u0026#34;forcesave:latchKey:\u0026#34; + documentKey); if (latchKey != null) { RCountDownLatch latch = redisson.getCountDownLatch(latchKey); latch.countDown(); // 清理映射 redisTemplate.delete(\u0026#34;forcesave:latchKey:\u0026#34; + documentKey); log.info(\u0026#34;forcesave completed, latch released: {}\u0026#34;, latchKey); } } // 返回 {\u0026#34;error\u0026#34;: 0} 表示成功 return \u0026#34;{\\\u0026#34;error\\\u0026#34;: 0}\u0026#34;; } } 5. 完整流程图 ┌─────────────────────────────────────────────────────────────────┐ │ 用户点击\u0026#34;提交\u0026#34; │ └───────────────────────────┬─────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 创建 Redisson CountDownLatch (key=forcesave:doc:timestamp) │ └───────────────────────────┬─────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 调用 ONLYOFFICE forcesave API │ │ 同时在 Redis 记录 docId -\u0026gt; latchKey 映射 │ └───────────────────────────┬─────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ latch.await(30, TimeUnit.SECONDS) ← 阻塞等待 │ └───────────────────────────┬─────────────────────────────────────┘ │ ┌───────────────────┴───────────────────┐ │ │ ▼ ▼ ┌───────────────────┐ ┌─────────────────────────────┐ │ ONLYOFFICE │ │ 回调接口收到通知 │ │ 处理保存（异步） │ ──────► │ 下载文件 → 写入存储 │ └───────────────────┘ │ latch.countDown() │ └─────────────────────────────┘ │ │ └───────────────────┬───────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ await() 返回 true，阻塞解除 │ └───────────────────────────┬─────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 获取最新文件 → 计算数字签名 → 保存记录 │ └─────────────────────────────────────────────────────────────────┘ 关键点总结 超时保护 必须设置合理的超时时间。ONLYOFFICE 可能在高负载时响应缓慢，或者回调因为网络问题失败。无限等待会造成线程堆积，最终拖垮服务。\nboolean completed = latch.await(30, TimeUnit.SECONDS); if (!completed) { // 超时处理：记录日志、告警、降级处理 } 幂等性 回调可能被重试。如果 ONLYOFFICE 没有收到 {\u0026quot;error\u0026quot;: 0} 的响应，可能会重新发送回调。要确保 countDown() 多次调用不会出问题——幸好 RCountDownLatch.countDown() 本身就是幂等的。\nLatch 生命周期管理 使用后要 delete() 清理，避免 Redis 中堆积无用的 key 设置合理的过期时间（Redisson 支持在 trySetCount 时配置） 超时或异常情况下，要清理对应的 Redis 映射 日志与监控 这个方案引入了分布式协调，出问题时排查会比同步调用复杂。建议：\n每次 forcesave 都记录 documentId、latchKey、开始时间 回调时记录收到的时间、处理耗时 监控 await 超时的频率，超时过多说明 ONLYOFFICE 压力大或网络不稳定 写在最后 这个问题的根源是对 ONLYOFFICE 机制理解不够深入。文档里其实写得很清楚：Command Service 是异步的，文件更新通过回调通知。但我一开始只看了接口怎么调，没理解完整的交互时序。\n\u0026ldquo;先让它跑起来\u0026quot;的心态，让我跳过了对底层机制的探究。结果就是，代码在开发环境一切正常——开发时只有我一个人在用，forcesave 几乎瞬间完成，异步问题根本暴露不出来。到了生产环境，并发一上来，问题才浮出水面。\n技术选型时，不仅要看\u0026quot;怎么用\u0026rdquo;，更要理解\u0026quot;它是怎么工作的\u0026quot;。异步变同步这种需求，看似简单，但在分布式环境下，需要考虑的点远比单机锁多得多。\n","date":"2025-11-15T09:00:00+08:00","permalink":"https://tangc.pro/blog/posts/onlyoffice-forcesave-sync/","summary":"\u003ch2 id=\"背景\"\u003e背景\u003c/h2\u003e\n\u003cp\u003e最近在做一个在线文档协作系统，用户可以在平台上编辑文档，编辑完成后触发一系列业务流程——生成版本、数字签名、归档等。文档编辑器选用了 ONLYOFFICE，功能强大，协作体验也好。\u003c/p\u003e\n\u003cp\u003e看起来是个挺常规的需求，对吧？\u003c/p\u003e\n\u003cp\u003e我当初也是这么想的。直到系统上线后，用户反馈\u0026quot;数字签名偶尔会失效\u0026quot;，我才意识到自己踩进了一个不大不小的坑里。\u003c/p\u003e\n\u003ch2 id=\"问题出在哪\"\u003e问题出在哪？\u003c/h2\u003e\n\u003cp\u003eONLYOFFICE 的架构中，文档编辑和文件存储是分离的。用户在编辑器里修改文档，改动先停留在 ONLYOFFICE Document Server 的内存中，并不会立即写入你的存储后端。\u003c/p\u003e\n\u003cp\u003eONLYOFFICE 提供了 \u003ccode\u003eforcesave\u003c/code\u003e 接口，可以强制保存文档。我最初的实现是这样的：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-java\" data-lang=\"java\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 用户点击\u0026#34;提交\u0026#34;按钮时触发\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003evoid\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003esubmitDocument\u003c/span\u003e(String documentId) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// 1. 调用 forcesave\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    onlyofficeService.\u003cspan style=\"color:#a6e22e\"\u003eforceSave\u003c/span\u003e(documentId);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// 2. 获取文件内容\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ebyte\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e[]\u003c/span\u003e fileContent \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e storageService.\u003cspan style=\"color:#a6e22e\"\u003egetFile\u003c/span\u003e(documentId);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// 3. 生成数字签名\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    String signature \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e signService.\u003cspan style=\"color:#a6e22e\"\u003esign\u003c/span\u003e(fileContent);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// 4. 保存签名记录\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    recordService.\u003cspan style=\"color:#a6e22e\"\u003esaveSignature\u003c/span\u003e(documentId, signature);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e看起来逻辑清晰，没什么问题。但上线后，用户反馈：\u003cstrong\u003e某些文档的数字签名校验失败\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e排查后发现：当用户打开文档验证时，文件内容和签名时不一致——明明签名后没人再编辑过，怎么文件变了？\u003c/p\u003e\n\u003ch2 id=\"根本原因forcesave-是异步的\"\u003e根本原因：forcesave 是异步的\u003c/h2\u003e\n\u003cp\u003e翻了 ONLYOFFICE 的文档，才发现 \u003ccode\u003eforcesave\u003c/code\u003e 的调用流程是这样的：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e后端调用 forcesave API\n        ↓\nONLYOFFICE Document Server 接收请求\n        ↓\nDocument Server 在内部调度保存任务（异步）\n        ↓\n保存完成后，Document Server 回调后端的 Callback Handler\n        ↓\n后端在回调中收到最新的文件 URL\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e也就是说，\u003ccode\u003eforcesave()\u003c/code\u003e 返回时，文件\u003cstrong\u003e还没保存完成\u003c/strong\u003e。真正的文件更新是在回调里发生的。\u003c/p\u003e","tags":["OnlyOffice","分布式","踩坑"],"title":"踩坑实录：ONLYOFFICE forcesave 的同步化处理"},{"categories":["后端开发","设计模式"],"content":"问题从哪里开始 项目里集成了 OnlyOffice 文档编辑器，功能挺好用，但回调处理这块慢慢变成了\u0026quot;噩梦\u0026quot;。\nOnlyOffice 会推送各种回调，比如用户保存文档、强制保存、文档关闭等等。每种回调有不同的 status 字段，对应不同的处理逻辑。一开始代码写得很\u0026quot;朴素\u0026quot;：\n// FileServiceImpl.java - 曾经的模样 public void onlyOfficeCallback(OnlyOfficeCallbackDTO callbackDTO, File file) { if (callbackDTO.getStatus() == 2) { if (callbackDTO.getActions() != null \u0026amp;\u0026amp; callbackDTO.getActions().stream().anyMatch(a -\u0026gt; a.getType() == 0)) { // 保存逻辑：下载文档、更新文件... // 几十行代码 } } else if (callbackDTO.getStatus() == 6) { // 强制保存逻辑 // 又是几十行代码 } else if (callbackDTO.getStatus() == 1) { // 正在编辑... } else if (...) { // 更多分支... } } 然后还有 Command 调用——主动向 OnlyOffice 发命令的逻辑，也堆在同一个 Service 里：\npublic void sendCommand(String command, Map\u0026lt;String, Object\u0026gt; params) { if (\u0026#34;forcesave\u0026#34;.equals(command)) { // 强制保存命令逻辑 } else if (\u0026#34;info\u0026#34;.equals(command)) { // 获取信息命令逻辑 } // 更多分支... } 问题来了：\n代码冗余：每种处理逻辑散落在 if-else 分支里，提取方法也救不了结构混乱 扩展性差：新增一种回调类型？再加一个 else if，然后祈祷别改坏其他分支 可读性低：几百行的方法，看完上面忘了下面，新人接手直接懵 测试麻烦：想测 status=6 的逻辑？得 mock 整个 Service，还要保证其他分支不会出问题 这就是典型的\u0026quot;策略模式适用的场景\u0026quot;——多种算法/行为需要在运行时选择。\n为什么是策略模式？ GoF 对策略模式的定义：\n定义一系列算法，把它们一个个封装起来，并且使它们可互相替换。\n这正好对应我们的问题：\n现状 策略模式的解法 每种回调一个 if 分支 每种回调一个策略类 分支判断逻辑硬编码 策略自带判断能力 新增回调改 Service 新增策略类 + 注册 测试要 mock 整体 单独测策略类 更妙的是，OnlyOffice 的回调报文有明确的类型标识（status 字段），天然适合做策略分发。\n重构实践 第一步：定义策略接口 核心洞察：每个策略需要两个能力——判断能不能处理和具体处理逻辑。\n// CallbackHandler.java public interface CallbackHandler { /** * 判断当前策略是否适合处理这个回调 */ boolean shouldHandle(OnlyOfficeCallbackDTO callbackDTO); /** * 执行处理逻辑 */ void handle(OnlyOfficeCallbackDTO callbackDTO, File.OnlyOfficeEditFile file); } 为什么要 shouldHandle() 而不是让工厂判断？因为策略自己最清楚自己能处理什么。把判断逻辑内聚到策略里，工厂就变得很纯粹——遍历、匹配、返回。\n// CommandHandler.java - 命令处理也如法炮制 public interface CommandHandler { boolean shouldHandle(Map\u0026lt;String, Object\u0026gt; params); void handle(Map\u0026lt;String, Object\u0026gt; params); } 第二步：实现具体策略 // SaveHandler.java - 处理 status=2（文档保存完成） public class SaveHandler implements CallbackHandler { @Override public boolean shouldHandle(OnlyOfficeCallbackDTO callbackDTO) { // status=2 且有退出编辑动作 return callbackDTO != null \u0026amp;\u0026amp; callbackDTO.getStatus() == 2 \u0026amp;\u0026amp; callbackDTO.getActions() != null \u0026amp;\u0026amp; callbackDTO.getActions().stream() .anyMatch(action -\u0026gt; action.getType() == 0); } @Override public void handle(OnlyOfficeCallbackDTO callbackDTO, File.OnlyOfficeEditFile file) { // 清爽的处理逻辑，不用管其他分支 // 下载文档、更新文件信息... } } // ForceSaveHandler.java - 处理 status=6（强制保存） public class ForceSaveHandler implements CallbackHandler { @Override public boolean shouldHandle(OnlyOfficeCallbackDTO callbackDTO) { return callbackDTO != null \u0026amp;\u0026amp; callbackDTO.getStatus() == 6; } @Override public void handle(OnlyOfficeCallbackDTO callbackDTO, File.OnlyOfficeEditFile file) { // 强制保存的处理逻辑 } } 每个策略类职责单一，代码独立，测试时只需测这个类。\n第三步：工厂模式管理策略 策略有了，谁来选？工厂出场：\npublic class CallbackHandlerFactory { private static final List\u0026lt;CallbackHandler\u0026gt; handlers = new ArrayList\u0026lt;\u0026gt;(); static { // 注册所有策略 handlers.add(new SaveHandler()); handlers.add(new ForceSaveHandler()); // 新增策略？加一行就行 } public static CallbackHandler getHandler(OnlyOfficeCallbackDTO callbackDTO) { for (CallbackHandler handler : handlers) { if (handler.shouldHandle(callbackDTO)) { return handler; } } return null; // 没有匹配的策略 } } 工厂的逻辑极其简单：遍历所有策略，找到第一个说\u0026quot;我来处理\u0026quot;的。\n第四步：客户端调用 Service 层终于解脱了：\n// FileServiceImpl.java - 重构后 @Override public void onlyOfficeCallback(OnlyOfficeCallbackDTO callbackDTO, File.OnlyOfficeEditFile file) { CallbackHandler handler = CallbackHandlerFactory.getHandler(callbackDTO); if (handler != null) { handler.handle(callbackDTO, file); } else { log.info(\u0026#34;未处理的回调类型: {}\u0026#34;, callbackDTO); } } 三行代码，干干净净。\n架构一览 ┌─────────────────────────────────────────────────────────────┐ │ FileServiceImpl │ │ (客户端调用者) │ └──────────────────────┬──────────────────────────────────────┘ │ getHandler(callbackDTO) ▼ ┌─────────────────────────────────────────────────────────────┐ │ CallbackHandlerFactory │ │ │ │ handlers: [SaveHandler, ForceSaveHandler, ...] │ │ │ │ getHandler() → 遍历 → shouldHandle()? → return │ └──────────────────────┬──────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ CallbackHandler (Strategy 接口) │ │ │ │ ┌──────────────────┐ ┌──────────────────────┐ │ │ │ SaveHandler │ │ ForceSaveHandler │ │ │ │ status=2 │ │ status=6 │ │ │ └──────────────────┘ └──────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ 收益 重构完成后：\n维度 重构前 重构后 新增回调类型 改 Service，加 if-else 新建策略类，工厂注册 单元测试 mock 整个 Service 独立测策略类 代码可读性 300 行大方法 每个策略 30-50 行 职责划分 Service 承载所有逻辑 策略各司其职 开闭原则在这里体现得淋漓尽致——新增功能不改动现有代码，只增加新类。\n进阶：更 Spring 的写法 上面的实现用静态注册，其实可以用 Spring 的自动注入更优雅：\n@Component public class CallbackHandlerFactory { private final List\u0026lt;CallbackHandler\u0026gt; handlers; // Spring 自动注入所有 CallbackHandler Bean public CallbackHandlerFactory(List\u0026lt;CallbackHandler\u0026gt; handlers) { this.handlers = handlers; } public CallbackHandler getHandler(OnlyOfficeCallbackDTO dto) { return handlers.stream() .filter(h -\u0026gt; h.shouldHandle(dto)) .findFirst() .orElse(null); } } // 每个策略加 @Component，Spring 自动扫描注册 @Component public class SaveHandler implements CallbackHandler { // ... } 好处：\n不用手动注册：新增策略只需加 @Component 依赖注入友好：策略类可以注入其他 Bean 配置更灵活：可以用 @Order 控制策略优先级 什么时候用策略模式？ 回顾这次重构，策略模式适合：\n多分支选择：代码里有一长串 if-else / switch 分支独立：各分支逻辑互不干扰 扩展频繁：未来可能新增分支类型 类型标识明确：有字段可以区分不同类型（如我们的 status） 像 OnlyOffice 回调这种报文驱动的场景，策略模式几乎是标配。类似的还有：\n消息队列消费者（不同消息类型） HTTP 请求路由（不同 API） 事件监听分发 支付渠道选择 写在最后 设计模式不是炫技，是解决实际问题的工具。当 if-else 开始\u0026quot;野蛮生长\u0026quot;，就该想想是不是策略模式入场的时候了。\n重构之后，每次新增回调类型，我只需要：\n新建一个 Handler 类 实现两个方法 加个 @Component 完事。不用碰 Service，不用改工厂，不用担心改坏其他逻辑。\n","date":"2025-11-13T15:30:00+08:00","permalink":"https://tangc.pro/blog/posts/strategy-pattern-refactoring/","summary":"\u003ch2 id=\"问题从哪里开始\"\u003e问题从哪里开始\u003c/h2\u003e\n\u003cp\u003e项目里集成了 OnlyOffice 文档编辑器，功能挺好用，但回调处理这块慢慢变成了\u0026quot;噩梦\u0026quot;。\u003c/p\u003e\n\u003cp\u003eOnlyOffice 会推送各种回调，比如用户保存文档、强制保存、文档关闭等等。每种回调有不同的 \u003ccode\u003estatus\u003c/code\u003e 字段，对应不同的处理逻辑。一开始代码写得很\u0026quot;朴素\u0026quot;：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-java\" data-lang=\"java\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// FileServiceImpl.java - 曾经的模样\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003evoid\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eonlyOfficeCallback\u003c/span\u003e(OnlyOfficeCallbackDTO callbackDTO, File file) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (callbackDTO.\u003cspan style=\"color:#a6e22e\"\u003egetStatus\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e 2) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (callbackDTO.\u003cspan style=\"color:#a6e22e\"\u003egetActions\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e!=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enull\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e callbackDTO.\u003cspan style=\"color:#a6e22e\"\u003egetActions\u003c/span\u003e().\u003cspan style=\"color:#a6e22e\"\u003estream\u003c/span\u003e().\u003cspan style=\"color:#a6e22e\"\u003eanyMatch\u003c/span\u003e(a \u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e a.\u003cspan style=\"color:#a6e22e\"\u003egetType\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e 0)) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#75715e\"\u003e// 保存逻辑：下载文档、更新文件...\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#75715e\"\u003e// 几十行代码\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    } \u003cspan style=\"color:#66d9ef\"\u003eelse\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (callbackDTO.\u003cspan style=\"color:#a6e22e\"\u003egetStatus\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e 6) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#75715e\"\u003e// 强制保存逻辑\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#75715e\"\u003e// 又是几十行代码\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    } \u003cspan style=\"color:#66d9ef\"\u003eelse\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (callbackDTO.\u003cspan style=\"color:#a6e22e\"\u003egetStatus\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e 1) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#75715e\"\u003e// 正在编辑...\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    } \u003cspan style=\"color:#66d9ef\"\u003eelse\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (...) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#75715e\"\u003e// 更多分支...\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e然后还有 Command 调用——主动向 OnlyOffice 发命令的逻辑，也堆在同一个 Service 里：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-java\" data-lang=\"java\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003evoid\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003esendCommand\u003c/span\u003e(String command, Map\u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003eString, Object\u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e params) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;forcesave\u0026#34;\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eequals\u003c/span\u003e(command)) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#75715e\"\u003e// 强制保存命令逻辑\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    } \u003cspan style=\"color:#66d9ef\"\u003eelse\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;info\u0026#34;\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eequals\u003c/span\u003e(command)) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#75715e\"\u003e// 获取信息命令逻辑\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// 更多分支...\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e问题来了：\u003c/strong\u003e\u003c/p\u003e","tags":["OnlyOffice","Java","设计模式","策略模式"],"title":"当 if-else 长成一棵树：用策略模式重构 OnlyOffice 回调处理"},{"categories":["开发技巧"],"content":"背景 远程开发模式下，代码跑在服务器上，但本地 IDE 更趁手。\n问题来了：怎么让本地编辑器直接访问服务器上的文件？\n方案不少：FTP 同步、网盘同步、rsync……但总觉得不够优雅。要么配置繁琐，要么实时性差，要么得装一堆东西。\n方案：Samba + SSH 隧道 核心思路：\n服务器上跑 Samba — 提供文件共享服务 SSH 隧道转发 — 把 Samba 端口安全地映射到本地 本地挂载 — 像操作本地文件夹一样操作远程文件 这样做的优点：\n✅ 无需额外客户端软件 ✅ 实时同步，所见即所得 ✅ SSH 加密，安全可靠 ✅ 本地 IDE 原生支持 服务端配置 Samba 安装 Samba sudo apt update sudo apt install samba 创建共享目录 # 创建共享目录（或使用现有项目目录） mkdir -p ~/projects # 备份原始配置 sudo cp /etc/samba/smb.conf /etc/samba/smb.conf.bak 编辑配置文件 编辑 /etc/samba/smb.conf，在末尾添加：\n[projects] path = /home/youruser/projects browseable = yes read only = no create mask = 0644 directory mask = 0755 valid users = youruser 记得把 youruser 和路径换成你自己的。\n设置 Samba 密码并启动服务 # 为当前用户设置 Samba 密码（可以和系统密码不同） sudo smbpasswd -a youruser # 启动服务 sudo systemctl start smbd sudo systemctl enable smbd SSH 隧道转发 Samba 默认监听 445 和 139 端口。为了让本地能安全访问，用 SSH 隧道转发：\n# 本地执行，建立隧道 ssh -L 4445:localhost:445 youruser@your-server-ip 参数说明：\n-L 4445:localhost:445：把服务器本地的 445 端口映射到本地 4445 保持这个终端窗口打开，隧道才生效 也可以配合 ~/.ssh/config 做持久化配置，或者用 autossh 保持连接稳定。\n本地挂载 macOS 打开 Finder → 前往 → 连接服务器（或按 Cmd + K），输入：\nsmb://localhost:4445 输入 Samba 用户名和密码后，就能看到共享目录了。\nWindows 打开「此电脑」 点击「映射网络驱动器」 文件夹填入：\\\\localhost:4445\\projects 勾选「使用其他凭据连接」，输入 Samba 账号密码 Linux # 安装 cifs-utils sudo apt install cifs-utils # 创建挂载点 mkdir -p ~/mnt/projects # 挂载 sudo mount -t cifs //localhost/projects ~/mnt/projects -o port=4445,user=youruser 使用体验 挂载完成后，在你的 IDE（VS Code、JetBrains 等）中打开这个目录，就像操作本地文件一样。\n保存即同步，无需手动上传下载。网络断开重连后，隧道恢复即可继续使用。\n小结 方案 优点 缺点 FTP/SFTP 同步 简单 需要手动同步，实时性差 网盘同步 自动 占用空间，冲突处理麻烦 Samba + SSH 隧道 实时、透明、安全 需要保持 SSH 连接 对于远程开发场景，Samba + SSH 隧道是一个轻量且优雅的解决方案。配置一次，长期受益。\n记录于 2025 年 1 月 9 日，远程开发实践中。\n","date":"2025-01-09T10:00:00+08:00","permalink":"https://tangc.pro/blog/posts/local-dev-server-files/","summary":"\u003ch2 id=\"背景\"\u003e背景\u003c/h2\u003e\n\u003cp\u003e远程开发模式下，代码跑在服务器上，但本地 IDE 更趁手。\u003c/p\u003e\n\u003cp\u003e问题来了：\u003cstrong\u003e怎么让本地编辑器直接访问服务器上的文件？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e方案不少：FTP 同步、网盘同步、rsync……但总觉得不够优雅。要么配置繁琐，要么实时性差，要么得装一堆东西。\u003c/p\u003e\n\u003ch2 id=\"方案samba--ssh-隧道\"\u003e方案：Samba + SSH 隧道\u003c/h2\u003e\n\u003cp\u003e核心思路：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e服务器上跑 Samba\u003c/strong\u003e — 提供文件共享服务\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSSH 隧道转发\u003c/strong\u003e — 把 Samba 端口安全地映射到本地\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e本地挂载\u003c/strong\u003e — 像操作本地文件夹一样操作远程文件\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e这样做的优点：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e✅ 无需额外客户端软件\u003c/li\u003e\n\u003cli\u003e✅ 实时同步，所见即所得\u003c/li\u003e\n\u003cli\u003e✅ SSH 加密，安全可靠\u003c/li\u003e\n\u003cli\u003e✅ 本地 IDE 原生支持\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"服务端配置-samba\"\u003e服务端配置 Samba\u003c/h2\u003e\n\u003ch3 id=\"安装-samba\"\u003e安装 Samba\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt update\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt install samba\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"创建共享目录\"\u003e创建共享目录\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 创建共享目录（或使用现有项目目录）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir -p ~/projects\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 备份原始配置\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo cp /etc/samba/smb.conf /etc/samba/smb.conf.bak\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"编辑配置文件\"\u003e编辑配置文件\u003c/h3\u003e\n\u003cp\u003e编辑 \u003ccode\u003e/etc/samba/smb.conf\u003c/code\u003e，在末尾添加：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-ini\" data-lang=\"ini\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e[projects]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   \u003cspan style=\"color:#a6e22e\"\u003epath\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e/home/youruser/projects\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e   browseable = yes\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e   read only = no\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e   create mask = 0644\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e   directory mask = 0755\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e   valid users = youruser\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cblockquote\u003e\n\u003cp\u003e记得把 \u003ccode\u003eyouruser\u003c/code\u003e 和路径换成你自己的。\u003c/p\u003e","tags":["远程开发","Samba","SSH"],"title":"Samba + SSH 隧道：本地 IDE 直接编辑服务器文件"},{"categories":["技术"],"content":"写在前面 在企业级微服务架构的落地过程中，部署往往是最让人头疼的环节之一。特别是当你的客户要求\u0026quot;断网部署\u0026quot;、\u0026ldquo;一键安装\u0026rdquo;、\u0026ldquo;版本可追溯\u0026quot;时，传统的手动部署或CI/CD流水线方案就显得力不从心了。\n这篇文章记录了我为某企业级微服务系统设计的一套离线自动化部署脚本——从背景痛点到架构设计，再到核心实现细节。希望能给同样面临类似场景的同行一些参考。\n背景：为什么需要离线自动化部署？ 痛点分析 我们团队负责的系统是基于一个成熟的企业级微服务框架二次开发的，整套系统包含：\n13个核心微服务（注册中心、网关、认证、权限等） 多种中间件（MySQL、MongoDB、Redis、MinIO） ONLYOFFICE文档编辑服务 每次给客户现场部署时，我们都要面对这些问题：\n耗时漫长：手动部署整套系统需要数小时，而且容易出错 网络限制：很多客户现场是内网环境，无法访问外网镜像仓库 版本混乱：手动记录版本信息容易遗漏，出问题难以追溯 环境差异：开发、测试、生产环境配置各不相同，切换困难 设计目标 基于这些问题，我确定了设计目标：\n离线部署 + 一键执行 + 版本可追溯 + 多环境支持 架构设计：模块化脚本职责划分 整体方案采用Shell脚本实现（约450行核心代码），核心思路是职责分离、配置集中。\n目录结构 deploy/ ├── deploy.sh # 主入口：环境准备 + 数据库初始化 ├── service.sh # K8s部署核心逻辑（install/uninstall） ├── initChart.sh # Helm Chart生成 + 版本信息SQL ├── services.sh # 服务列表定义（版本化管理） ├── vars.sh # 环境变量集中管理 ├── func.sh # 公共函数（字符串处理、分组解析） ├── k8s/ # K8s资源文件（PV/PVC/Deployment） ├── values/ # Helm Values配置（多环境支持） ├── mysql/ # 数据库初始化SQL ├── images/ # Docker镜像包（离线部署用） └── charts/ # Helm Chart包（离线部署用） 职责划分 脚本 职责 关键功能 deploy.sh 主编排 依赖检查、流程控制、数据库初始化 service.sh K8s操作 服务部署/卸载、镜像导入、Chart生成 initChart.sh Chart管理 模板渲染、版本SQL生成 services.sh 服务清单 版本化服务列表定义 vars.sh 配置中心 环境变量集中管理 func.sh 工具库 正则解析、日志输出等通用函数 这种设计让每个脚本职责单一，修改某个环节不会牵一发动全身。\n核心实现：关键技术与设计决策 1. 版本化服务管理 服务命名采用统一的版本格式：\n{服务名}-{日期}-{构建号}-{分支} 例如：app-gateway-2023.9.5-152505-master\n在 services.sh 中定义服务列表：\n# services.sh services[0]=\u0026#34;app-register-2023.5.29-095954-master\u0026#34; services[1]=\u0026#34;app-gateway-2023.9.5-152505-master\u0026#34; services[2]=\u0026#34;app-oauth-2023.8.15-141936-master\u0026#34; services[3]=\u0026#34;app-iam-2023.7.28-105949-master\u0026#34; # ... 共13个服务 通过正则解析服务名和版本：\nif [[ $chartName =~ ^(.*)-([0-9]{4}\\.[0-9]{1,2}.[0-9]{1,2}-[0-9]+-.*)$ ]]; then serviceName=${BASH_REMATCH[1]} # 提取服务名称 serviceImage=${BASH_REMATCH[2]} # 提取版本号 fi 这样做的好处是版本信息一目了然，新增服务只需添加一行配置。\n2. 多环境配置支持 环境配置采用\u0026quot;默认值 + 环境覆盖\u0026quot;的模式：\n# vars.sh（默认配置） declare -g VERSION=\u0026#34;1.0.0\u0026#34; declare -g HOST=\u0026#34;${SERVER_IP}\u0026#34; declare -g NAMESPACE=\u0026#34;app-prod\u0026#34; declare -g MYSQL_HOST=\u0026#34;${DB_IP}\u0026#34; declare -g MYSQL_USER=\u0026#34;app_user\u0026#34; # vars/vars_prod.sh（生产环境覆盖） # vars/vars_uat.sh（UAT环境覆盖） 切换环境只需传递不同的配置文件：\n./deploy.sh vars_prod.sh # 生产环境部署 ./deploy.sh vars_uat.sh # UAT环境部署 3. Helm Template + kubectl 方案 这是一个关键的架构决策。我没有使用标准的 helm install，而是采用了 helm template + kubectl apply 的方案：\n# 生成YAML文件 helm template \u0026#34;$serviceName\u0026#34; \u0026#34;$localChartPath\u0026#34; \\ --values \u0026#34;$customValuesFile\u0026#34; \\ --namespace \u0026#34;$NAMESPACE\u0026#34; \u0026gt; \u0026#34;$CHART_DIR/$serviceName.yaml\u0026#34; # 应用YAML kubectl apply -f \u0026#34;$CHART_DIR/$serviceName.yaml\u0026#34; -n $NAMESPACE 为什么这样做？\n对比项 helm install helm template + kubectl YAML可见性 隐藏 可检查、可版本管理 依赖Helm Release 是 否 离线环境友好度 一般 更好 故障排查 需要helm命令 直接看YAML 回滚机制 Helm内置 kubectl rollout 对于生产环境和离线部署场景，YAML可检查的优势太重要了——出问题时，你可以直接看文件内容定位问题。\n4. NFS共享存储配置 有状态服务需要持久化存储，我采用了NFS方案：\n# nfs-pv.yml apiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv spec: storageClassName: nfs accessModes: - ReadWriteMany capacity: storage: 50Gi nfs: path: /data/nfs server: ${NFS_SERVER_IP} 脚本中自动配置NFS服务：\nmkdir -p \u0026#34;$NFS_PATH\u0026#34; cp -f ./k8s/exports /etc/ exportfs -ra systemctl restart nfs-server 5. 数据库初始化自动化 数据库初始化分为两步：官方工具 + 自定义SQL：\n# 解压官方初始化工具 tar -xf \u0026#34;./mysql/init-tool.tgz\u0026#34; -C \u0026#34;./mysql\u0026#34; # 调用初始化脚本 sh -c \u0026#34;cd ./mysql/init-tool/ \u0026amp;\u0026amp; ./database-init.sh\u0026#34; # 执行自定义SQL mysql -u \u0026#34;$MYSQL_USER\u0026#34; app_db \u0026lt; ./mysql/init_app.sql 6. 版本信息自动生成 每次部署都会自动在数据库中记录版本信息：\n# initChart.sh sqlList=\u0026#34;update hpfm_release_history set enable = 0 where enable = 1;\\n\u0026#34; for service in services; do sqlList+=\u0026#34;replace into hpfm_release_history set release = \u0026#39;$VERSION\u0026#39;, svc_name = \u0026#39;$serviceName\u0026#39;, svc_version = \u0026#39;$serviceImage\u0026#39;, enable = 1;\\n\u0026#34; done echo -e $sqlList \u0026gt; \u0026#34;./mysql/version.sql\u0026#34; 这样运维团队可以随时查询部署历史，方便问题追溯。\n部署流程：完整链路 整个部署流程可以抽象为以下四个阶段：\n┌─────────────────────────────────────────────────────────────────┐ │ 1. 环境准备 │ │ - 检查依赖：MySQL/Redis/MongoDB/K8s/Docker │ │ - 用户确认 │ └───────────────────────────┬─────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 2. 数据库初始化 │ │ - 解压并调用官方初始化工具 │ │ - 执行自定义SQL │ │ - 用户确认完成 │ └───────────────────────────┬─────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 3. 配置注入 │ │ - 从 vars/*.sh 读取环境变量 │ │ - 替换 values/*.yaml 中的变量占位符 │ │ - 生成 Helm Chart 文件 │ └───────────────────────────┬─────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 4. K8s部署 │ │ - 创建命名空间 │ │ - 配置NFS存储 │ │ - 导入Docker镜像 │ │ - 遍历部署13个服务 │ │ - 生成版本SQL并执行 │ └─────────────────────────────────────────────────────────────────┘ 执行示例 # 1. 进入部署目录 cd /opt/deploy # 2. 执行部署脚本 ./deploy.sh vars_prod.sh # 脚本会依次： # ✓ 检查依赖服务 # ✓ 初始化数据库 # ✓ 导入Docker镜像 # ✓ 部署所有微服务 # ✓ 记录版本信息 成果与总结 部署效果 指标 优化前 优化后 部署时间 2-4小时 10-15分钟 人工干预 大量手动操作 仅需确认参数 成功率 约70%（人为错误多） 接近100% 版本追溯 依赖手工记录 数据库自动记录 技术亮点 模块化设计：6个脚本职责分离，维护成本低 版本化管理：服务版本自动解析、数据库记录，可追溯 多环境支持：一套脚本、多套配置，环境切换只需一个参数 离线友好：零外网依赖，纯本地资源部署 幂等性考虑：存在性检查、重复执行安全 错误处理：文件缺失检测、跳过处理，不会因为单个服务失败导致整体中断 扩展性 新增一个服务只需三步：\n在 services.sh 添加一行配置 准备对应的Docker镜像包 准备对应的Helm Chart包 注意事项 这套方案有一些前提条件：\n需预先安装：MySQL、MongoDB、K8s、Docker、JDK 需配置MySQL/MongoDB用户权限 离线镜像和Chart需提前准备 写在最后 从手动部署到自动化部署，提升的不仅是效率，更是可靠性和可维护性。Shell脚本虽然看起来\u0026quot;原始\u0026rdquo;，但在离线部署场景下，它的可控性和透明度反而是最大的优势。\n如果你的团队也在为K8s部署头疼，不妨试试这套思路——不一定完全照搬，但模块化、版本化、离线优先的设计原则应该是通用的。\n相关技术栈：Docker、Kubernetes、Helm、Shell、MySQL、MongoDB、Redis、MinIO、NFS\n","date":"2023-08-16T10:00:00+08:00","permalink":"https://tangc.pro/blog/posts/2023-08-16-offline-auto-deploy-k8s/","summary":"\u003ch2 id=\"写在前面\"\u003e写在前面\u003c/h2\u003e\n\u003cp\u003e在企业级微服务架构的落地过程中，部署往往是最让人头疼的环节之一。特别是当你的客户要求\u0026quot;断网部署\u0026quot;、\u0026ldquo;一键安装\u0026rdquo;、\u0026ldquo;版本可追溯\u0026quot;时，传统的手动部署或CI/CD流水线方案就显得力不从心了。\u003c/p\u003e\n\u003cp\u003e这篇文章记录了我为某企业级微服务系统设计的一套离线自动化部署脚本——从背景痛点到架构设计，再到核心实现细节。希望能给同样面临类似场景的同行一些参考。\u003c/p\u003e\n\u003ch2 id=\"背景为什么需要离线自动化部署\"\u003e背景：为什么需要离线自动化部署？\u003c/h2\u003e\n\u003ch3 id=\"痛点分析\"\u003e痛点分析\u003c/h3\u003e\n\u003cp\u003e我们团队负责的系统是基于一个成熟的企业级微服务框架二次开发的，整套系统包含：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e13个核心微服务\u003c/strong\u003e（注册中心、网关、认证、权限等）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e多种中间件\u003c/strong\u003e（MySQL、MongoDB、Redis、MinIO）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eONLYOFFICE文档编辑服务\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e每次给客户现场部署时，我们都要面对这些问题：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e耗时漫长\u003c/strong\u003e：手动部署整套系统需要数小时，而且容易出错\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e网络限制\u003c/strong\u003e：很多客户现场是内网环境，无法访问外网镜像仓库\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e版本混乱\u003c/strong\u003e：手动记录版本信息容易遗漏，出问题难以追溯\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e环境差异\u003c/strong\u003e：开发、测试、生产环境配置各不相同，切换困难\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"设计目标\"\u003e设计目标\u003c/h3\u003e\n\u003cp\u003e基于这些问题，我确定了设计目标：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e离线部署 + 一键执行 + 版本可追溯 + 多环境支持\n\u003c/code\u003e\u003c/pre\u003e\u003ch2 id=\"架构设计模块化脚本职责划分\"\u003e架构设计：模块化脚本职责划分\u003c/h2\u003e\n\u003cp\u003e整体方案采用Shell脚本实现（约450行核心代码），核心思路是\u003cstrong\u003e职责分离、配置集中\u003c/strong\u003e。\u003c/p\u003e\n\u003ch3 id=\"目录结构\"\u003e目录结构\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003edeploy/\n├── deploy.sh        # 主入口：环境准备 + 数据库初始化\n├── service.sh       # K8s部署核心逻辑（install/uninstall）\n├── initChart.sh     # Helm Chart生成 + 版本信息SQL\n├── services.sh      # 服务列表定义（版本化管理）\n├── vars.sh          # 环境变量集中管理\n├── func.sh          # 公共函数（字符串处理、分组解析）\n├── k8s/             # K8s资源文件（PV/PVC/Deployment）\n├── values/          # Helm Values配置（多环境支持）\n├── mysql/           # 数据库初始化SQL\n├── images/          # Docker镜像包（离线部署用）\n└── charts/          # Helm Chart包（离线部署用）\n\u003c/code\u003e\u003c/pre\u003e\u003ch3 id=\"职责划分\"\u003e职责划分\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e脚本\u003c/th\u003e\n          \u003cth\u003e职责\u003c/th\u003e\n          \u003cth\u003e关键功能\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003edeploy.sh\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e主编排\u003c/td\u003e\n          \u003ctd\u003e依赖检查、流程控制、数据库初始化\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003eservice.sh\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eK8s操作\u003c/td\u003e\n          \u003ctd\u003e服务部署/卸载、镜像导入、Chart生成\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003einitChart.sh\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eChart管理\u003c/td\u003e\n          \u003ctd\u003e模板渲染、版本SQL生成\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003eservices.sh\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e服务清单\u003c/td\u003e\n          \u003ctd\u003e版本化服务列表定义\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003evars.sh\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e配置中心\u003c/td\u003e\n          \u003ctd\u003e环境变量集中管理\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003efunc.sh\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e工具库\u003c/td\u003e\n          \u003ctd\u003e正则解析、日志输出等通用函数\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e这种设计让每个脚本职责单一，修改某个环节不会牵一发动全身。\u003c/p\u003e","tags":["运维","Kubernetes","自动化部署","微服务"],"title":"离线自动化部署脚本设计与实现——企业级微服务K8s部署实践"}]