--- url: /ai/index.md --- # AI相关 这里主要记录我在AI技术领域的实践与心得。AI发展日新月异,许多探索尚在进行中,内容难免存在认知局限或疏漏。 所有文章均为个人学习与思考的沉淀,旨在以技术视角记录成长过程。内容仅供参考交流,欢迎指正与讨论,请读者结合多方信息审慎判断。 --- --- url: /ai/claude-code/claude-config.md --- # Claude Code 配置文件 [原文链接在这里](https://platform.uno/blog/configuring-claude-code-for-real-net-projects/),翻译过程中我进行了适当调整,使文章能够适合不同语言的项目。AI 发展迅速,请注意本文的时效性。 ::: note 要点 Claude Code 开箱即用。但它对你的项目并不熟悉,有时候实现的功能完全不符合预期。**解决方法并非改进 Prompt,而是配置。** ::: Claude Code 的配置分布在几个文件中。一旦你理解了每个文件的作用,这个工具就能按照你预想的方式运行。需要配置的文件包含以下这些: | 级别 | 文件 | 目的 | |----|-----------------------|-----------------| | 用户 | `settings.json` | 基线权限,全局拒绝规则 | | 用户 | `CLAUDE.md` | 全局指令、堆栈标识、会话工作流 | | 项目 | `.mcp.json` | MCP 服务器注册表 | | 项目 | `CLAUDE.md` | 项目说明、架构、文档参考 | | 项目 | `settings.json` | 项目权限、钩子 | | 项目 | `settings.local.json` | 个人权限(不提交) | ## .claude/ 这指的不是某个配置文件,而是一个文件夹,其中包含`settings.json`和`settings.local.json`等文件。你可以类比`.vscode/`或者`.idea/`。它存在于两个层级:`~/.claude/`用户级和`your-repo/.claude/`项目级。 ## settings.json 规则引擎。它控制着 Claude Code 的权限:它可以读取或写入哪些文件,可以运行哪些 bash 命令,以及哪些操作会被禁止。 你还可以配置钩子:在特定操作后自动触发的命令(例如每次 Claude 写入文件时执行一个命令)。 它位于项目目录下的`.claude/settings.json`中,是一个项目级规则,而`~/.claude/settings.json`是全局默认值。提交项目目录下的那个文件,供团队成员共享。 ## settings.local.json 格式相同,功能相同,但不应被提交。Claude Code 将会自动 “gitignore” 它。这里存放的是特定于你本地的环境变量、API 密钥和实验性权限。这些东西你不想分享给团队其他成员。 在优先级链中,本地配置优先。如果共享配置禁止某项操作,而本地配置允许,则本地配置优先。 企业级策略仍然优先于所有其他策略。 ## CLAUDE.md `CLAUDE.md`是一个自由格式的 Markdown 文件,Claude Code 会在每次会话开始时读取它。没有模式,也没有强制机制。只有用浅显易懂的语言编写的指导,用来塑造智能体的思维方式。 但它必须保持简洁。Claude Code 会将你的`CLAUDE.md`封装在一个系统提醒中,告诉模型忽略与当前任务无关的指令。随着指令数量的增加,Claude 不仅会忽略新增的指令,还会统一忽略所有指令。普遍认为指令长度应少于 300 行。越短越好。 ::: warning 注意 **不要在这里编写代码风格规则**。那是`.editorconfig`的用途。Claude 是一个上下文学习器。如果你的代码库遵循某种模式,它会在读取你的文件时自动识别。你应该关注 Claude 无法推断的内容:你的技术栈标识、脚手架规则、框架层面的决策以及工作流程预期。 ::: 一些值得一提的模式: * **每次进行实质性更改后都提交更改**,并附上描述性消息。这样可以轻松回滚。 * **对于复杂的功能,请先编写一个 Markdown 格式的规范文档**。然后创建一个新的会话,根据规范文档进行实现。保持上下文清晰。 * **在会话结束时,让 Claude 总结会话内容**,并提出改进项目文档的建议。持续改进循环。 对于任何不具有普遍适用性的内容(例如数据库模式、组件模式、设计标记),不要直接内联,而是改用链接形式提供。例如,添加一个“开始之前阅读`docs/ARCHITECTURE.md`”的`Key References`章节,既能让 Claude 了解相关内容,又不会增加指令文件的体积。 ::: info 逐步披露 告诉 Claude 如何查找信息,而不是全部信息本身。 ::: 位置很重要。`~/.claude/CLAUDE.md`全局适用。脚手架规则放在这里,因为创建新项目时,项目目录中还没有配置文件。仓库根目录下的`CLAUDE.md`只适用于该特定项目。项目级规则与用户级规则冲突时,项目级规则优先。 ## .mcp.json 在这里,你可以通过模型上下文协议连接外部功能。 其中,**远程服务器**为 Claude Code 提供访问最新文档的权限:文档搜索、文档获取、代理规则和使用最佳实践。**本地应用 MCP** 连接到你正在运行的应用程序,并为代理提供运行时可见性:屏幕截图、可视化树快照、指针点击、键盘输入和元素数据上下文检查。 远程 MCP 帮助你编写符合规范的代码。应用 MCP 则验证代码在运行时是否真正有效。**设计时的知识 vs 运行时的真实情况**。 `.mcp.json`位于项目根目录,而不是`.claude/`目录下。它应当被提交到版本控制中。当团队成员克隆仓库并打开 Claude Code 时,系统会提示他们批准服务器。之后一切就能正常运行了。 ## 快速参考 ### 用户级别:~/.claude/ | 文件 | 路径 | 是否提交 | 目的 | |-----------------|---------------------------|------|-----------------| | `settings.json` | `~/.claude/settings.json` | 不提交 | 基线权限,全局拒绝规则 | | `CLAUDE.md` | `~/.claude/CLAUDE.md` | 不提交 | 全局指令、堆栈标识、会话工作流 | ### 项目级别:代码仓库根目录 | 文件 | 路径 | 是否提交 | 目的 | |-----------------------|--------------------------------------|------|--------------| | `.mcp.json` | `my-app/.mcp.json` | 提交 | MCP 服务器注册表 | | `CLAUDE.md` | `my-app/CLAUDE.md` | 提交 | 项目说明、架构、文档参考 | | `settings.json` | `my-app/.claude/settings.json` | 提交 | 项目权限、钩子 | | `settings.local.json` | `my-app/.claude/settings.local.json` | 不提交 | 个人权限 | ### 完整结构 ::: file-tree * \~ * .claude * settings.json * CLAUDE.md ::: ::: file-tree * my-app * .mcp.json * CLAUDE.md * .claude * settings.json * settings.local.json * docs * ARCHITECTURE.md * DESIGN-BRIEF.md * src/ * … ::: --- --- url: /ai/claude-code/claude-mem-install.md --- Claude Code 有个第三方插件叫 [claude-mem](https://claude-mem.ai/),可以让 Claude 记住之前的对话内容。 整个安装过程按理来说应该是全自动的,但可能会遇到一些坑,下面是我踩过的坑,仅供参考。 ## 安装插件 **首先,需要确保已经安装了 Node.js 18.0.0 以上版本。** 除此之外,以下两个工具如果没有,在安装插件时会自动安装,一般来说无需单独处理: * Bun(但我遇到了自动安装失败的情况) * SQLite 3(内嵌在插件里了) 安装插件的时候,按照官方文档,应该执行: ```claude /plugin marketplace add thedotmack/claude-mem /plugin install claude-mem ``` 在安装时,有三个选项:“为本用户安装”、“为本项目安装”、“Local”,一定要选择“**为本用户安装**”,否则后续有些服务可能启动失败。 ## 确定是否启动成功 安装成功后,一般来说会正常启动,我们可以检查一下: ```bash # claude-mem 默认是 37777 端口监听的 curl http://localhost:37777/health ``` 如果请求失败了,那就说明启动失败了,可以前往`~/.claude/logs`目录下查看日志,看看是什么原因导致启动失败了。 于是尝试到插件目录下面手动重启: ```bash cd ~/.claude/plugins/marketplaces/thedotmack npm run worker:restart ``` 结果重启失败了,查看了一下`worker:restart`对应的命令,直接执行: ```bash bun plugin/scripts/worker-service.cjs restart ``` 结果发现原来是`bun`没有安装成功,于是将其安装后再试,还是失败,尝试直接前台启动: ```bash bun plugin/scripts/worker-service.cjs ``` 发现前台能够启动成功。 那大概率是插件安装有问题,于是我卸载后重新安装就好了。至此,worker 总算启动成功了。 ## 到底有没有用? 在 Claude Code 中简单进行提问,然后在终端执行以下命令,可以查看到底有没有成功记录记忆: ```bash sqlite3 ~/.claude-mem/claude-mem.db "SELECT count(*) FROM observations;" ``` 结果输出`0`,说明没有成功记录。继续前往前往`~/.claude/logs`目录下查看日志,发现大部分都成功了,但有这样一个日志: ``` [INFO][SDK][session-20] ← Response received (33 chars) {promptNumber=2} Not logged in · Please run /login ``` 这就很奇怪了,明明 Claude Code 已经登录了,为什么这里会返回没登录。然后去 claude-mem 仓库下以及网上找了很多原因,都不对。最后终于找到了,在配置文件 `~/.claude-mem/settings.json` 中: ```json { "CLAUDE_MEM_MODEL": "sonnet" } ``` 安装的时候它把 `CLAUDE_MEM_MODEL` 默认填了一个错误的值,导致它一直无法正确连接到 Claude Code,修改为正确的值(例如`sonnet`)后就行了。 除此之外,你还可以修改 `CLAUDE_MEM_MODE` 为 `code--zh`,让它默认使用中文。 修改好之后重启: ```bash :no-line-numbers # 重启 cd ~/.claude/plugins/marketplaces/thedotmack npm run worker:restart # 看看是否有记录了 sqlite3 ~/.claude-mem/claude-mem.db "SELECT count(*) FROM observations;" ``` 发现有记录了,说明成功了。 --- --- url: /ai/tech/github-copilot-change.md --- # 聊聊 Github Copilot 调整的影响 2026年6月1号起,Github Copilot 将会调整定价模式,这个在 [Github 的官方 Blog](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/) 中也有提及。曾经,它是按照高级请求的次数来计费的,因此我们可以把多个问题放在同一个请求中进行,以此来节约成本。但现在,随着 Github Copilot 调整为基于使用量的计费模式后,这种做法就不再适用了。 关于这个事情,其实也在很多开发者的预期当中,毕竟曾经的那种计费模式在如今的环境中确实已经不太合适了。现在的 AI,例如 Claude Code、 Codex 等,功能十分强大,但收费也不菲。而在曾经的按次计费模式下,开发者们可以通过较低的花销,来让 Github Copilot 使用上述的这些模型,达到相同的效果,我猜这背后 Github Copilot 也做了不少赔钱的买卖。但这种模式显然是不可持续的,因此调整为基于使用量的计费模式也是情理之中的事情了。 ## 影响有多大? 这应该是大家非常关心的一个问题了,现在 Github 给了一个预览数据,可以看到4月的使用量改为按量计算后的结果。我简单分享一下: 最多的一天,我使用了27次高级请求,按照之前的按次计费,每次花费 $0.04 ,总共是 $1.08 。但按照新的按量计费模式,27次高级请求的总花费是 1008 个 AI Cridits ,按照官方给出的每个 AI Cridits 1美分的定价来计算,就是 $10 了,翻了大概10倍左右。大部分日期都是这样的情况,翻了 7 ~ 30 倍;极少数日期因为只聊了一两句话,用新的计费方式会有所减少。从当月总量来看,总计翻了18倍,是一个非常恐怖的增长了。 可以看出,这对于重度 AI 使用者来说,是一个重大的打击。对于在大厂工作的开发者们,一般情况下公司会负担这部分成本,让你们能够更好地走在时代的前沿,是一个不错的机会。但对于没有这样福利的小企业或者个人开发者,这就是一个不小的负担了。 ## 有没有什么替代方案? 还是得摆正一下心态,Github Copilot 这个操作,并不能算作“涨价”,应该说是把计费拉升到和同行相同的一个水平了。 这样说来,其实替代方案就很明确了,要么就接着使用 Github Copilot ,或者干脆使用 Claude Code、 Codex、 Cursor 等,要么就使用国内的类似工具,例如阿里的通义灵码、字节的豆包编程助手、或者DeepSeek‑Coder等。 ## 展望 我们就以上面那个一天 $10 的数据为例,或者更夸张一点,就算一天 $50 ,这也明显低于一线城市的程序员的收入水平。而且公司还不用为AI交社保。随着 AI 的不断发展,这个成本会继续逐渐降低,能力会越来越出色。这意味着什么,想必大家都明白。所以真的很有必要规划自己的职业方向,要能做一些AI做不到的事情,才能保证自己未来的竞争力。 --- --- url: /daily/index.md --- # 日常随记 --- --- url: /daily/hollowknight/index.md --- # 空洞骑士速通相关 《空洞骑士》是一款2D动作冒险游戏,开发者是来自澳大利亚的一个只有三个人的小团队Team Cherry,2017年2月25日发售在PC平台。你可以前往[百度百科](https://baike.baidu.com/item/空洞骑士/16422241)或[空洞骑士中文维基](https://hkss.huijiwiki.com/)查看更详细的介绍。 速通(Speedrun)是指玩家通过特定规则和技巧,以最短时间完成游戏全流程或达成特定目标的竞技活动,其核心在于挑战游戏机制极限和追求极致效率。这一概念既是游戏文化的重要组成部分,也是玩家群体展现操作能力和策略思维的平台。 speedrun.com 是一个专注于游戏速通的综合性在线平台,其中就含有[《空洞骑士》速通排行榜](https://www.speedrun.com/zh-CN/hollowknight)。 国内有很多《空洞骑士》速通玩家。在国内的一些视频网站中,例如哔哩哔哩、AcFun等,都可以找到大量《空洞骑士》速通的视频。比如国内知名的《空洞骑士》速通玩家——[翼侯大人](https://space.bilibili.com/2978046),包揽了《空洞骑士》速通排行榜相当一部分速通分类的世界第一名的成绩。 speedrun.com 上现在自带的中文支持已经比较完善了,国内玩家在上面投稿基本没有什么语言障碍。一般情况下,只要在哔哩哔哩等视频网站发布自己的速通视频,即可很容易的向 speedrun.com 提交自己的速通成绩。但是,关于速通规则方面的内容、speedrun.com要求使用的计时器软件LiveSplit,都是英文的,这就导致了很多玩家在使用上会遇到困难。为了帮助大家更好的使用LiveSplit和了解速通规则,我从2021年起在哔哩哔哩上发布了不少相关视频。我也将我发布的一些文字攻略整理到了这里。 除此之外,在2021年底,针对 speedrun.com 规定使用的计时器软件LiveSplit,我还开发了一个中文版的自动生成LiveSplit所需的\*.lss文件(俗称模板)的工具,方便大家使用。可以参考下方目录中的第一篇文章。 --- --- url: /daily/hollowknight/all-skills-old.md --- # 全技能速通路线(旧) 路线的.lss文件可以在[计时器生成器](hksplitmaker-faq.md#生成模板并导入)的模板中找到。 ::: warning 注意 请注意本文的发布时间,本文并不是[最新的全技能速通路线](all-skills.md)。由于[最新的全技能速通路线](all-skills.md)对新入门的玩家并不友好,而旧路线更适合新接触速通的玩家入门,因此我将这篇文章保留了下来。 ::: 首先贴出[speedrun.com](https://www.speedrun.com/zh-CN/hollowknight?h=All_Skills-1-4-3-2-nmg\&rules=category\&x=n2y577zk-38dopp1l.4lxogy4l)上面当前版本全技能速通(All Skills Current Patch NMG)的规则: > 收集所有的移动技能、骨钉技艺、法术后通关游戏 > > 游戏必须是在1.4.3.2或者之后的版本进行(主要区别是不能隔墙冲刺劈开关) > > 允许跳过白法术,直接获得黑法术 必须获得的收集品有:白冲黑冲、螳螂爪、二段跳、酸泳、超冲、三种骨钉技艺、白波黑波、白砸黑砸、白吼黑吼。其他规则就是正常的NMG规则,也就是不能使用任何重大bug。 *** 路线如下: 1. 在与假骑士战斗中劈墙离开,迅速去拿**复仇之魂(白波)**。 2. 选择不打反击蝇之王,而是打下面的两只苔藓骑士,并且拿到他们旁边的吉欧堆(比反击蝇之王的吉欧多)。不需要开启苍绿之径鹿角虫站。击败大黄蜂,拿到\*\*蛾翼披风(冲刺)\*\*后,迅速退出游戏重进,回到十字路的祖先山丘。 3. 从祖先山丘出来,一路去拿**螳螂爪**,退出游戏重进,回到祖先山丘。(如果能力允许,在路上可以进行下劈爆炸孢子skip) 4. 开启遗忘的十字路鹿角虫站(-50吉欧),击败格鲁兹之母(+50吉欧),购买并装备萨满之石(-220吉欧),救斯莱。 5. 劈魂上蓝湖,去泪水之城打华丽躯壳(+462吉欧)。(在泪水之城电梯间,路上的两排泪城贵族站位比较密集,可以顺路用白波秒了获得一些吉欧) 6. 下劈背景物品,来到守望者的尖塔,从守望者的尖塔中部的通道直达灵魂圣所,获得\*\*荒芜俯冲(白砸)\*\*后,再获得一个吉欧箱子(+385吉欧)。 7. 回到守望者的尖塔坐椅子(之后有很长一段路不坐椅子),之后开启国王驿站鹿角虫站(-300吉欧)。 8. 传送到遗忘的十字路,下砸进入水晶山峰,拿到店主的钥匙和**水晶之心(超冲)**。 9. 摸黑去拿**黑暗降临(黑砸)**。 10. 获得梦之钉。 11. 从安息之地鹿角虫站传送到德特茅斯,在斯莱的商店购买典雅的钥匙,然后去呼啸悬崖学习**旋风劈砍**,之后退出游戏重进,回到守望者的尖塔椅子。 12. 回到灵魂圣所,使用典雅的钥匙,获得**暗影之魂(黑波)**,之后退出游戏重进,回到守望者的尖塔椅子。 13. 击败守望者骑士,获得上面的吉欧箱子(+655吉欧),击败**守望者卢瑞恩**。 14. 从布满尖刺的管道进去,酸冲拿到**伊斯玛的眼泪(酸泪)**。(个人认为是全路线最难的部分,劈魂和摸黑跟这个比起来弱爆了) 15. 开启古老盆地的椅子(-150吉欧),击败残破容器,获得**帝王之翼(二段跳)**,退出游戏重进,回到古老盆地的椅子。 16. 开启隐藏的鹿角虫站(-300吉欧),传送到德特茅斯,击败乌姆,击败**教师莫诺蒙**。 17. 坐教师档案馆的椅子,然后如果钱不够可以顺路击败大型苔藓冲锋者(+300吉欧,钱够就不打,大约还剩910吉欧就够),之后去学**强力劈砍**,然后退出游戏重进,回到教师档案馆的椅子。 18. 酸泳进入王后花园,入口处向上拐,获得**嚎叫幽灵(白吼)**。 19. 从王后花园进入深邃巢穴左上部,摸黑来到野兽巢穴,不坐椅子,从天花板跳上去,路上击败一个拦路的潜行信徒(+43吉欧),击败**野兽赫拉**。 20. 开启遥远的村庄鹿角虫站(-250吉欧),传送到国王驿站,从右下角房间的甬道进入王国边境,学习**冲刺劈砍**(-800吉欧,路上有两排站位密集的跳虫,可以用黑波秒掉+96吉欧)。 21. 击败大黄蜂,获得王之印记。 22. 回到国王驿站,传送至宫殿广场,进入深渊,获得**暗影披风(黑冲)**和**深渊尖啸(黑吼)**。 23. 从深渊跳上来,回到宫殿广场,传送至德特茅斯,击败**空洞骑士**。 --- --- url: /daily/hollowknight/all-skills.md --- # 全技能速通路线 英文原文链接:,请注意发布时间。 路线的.lss文件可以在[计时器生成器](hksplitmaker-faq.md#生成模板并导入)的模板中找到。 ::: note 译者注 本文是最新的全技能速通路线,适合冲击极限的玩家,但对新接触速通的玩家并不友好。如果你是新接触速通的玩家,建议参考[全技能速通路线(旧)](all-skills-old.md)。 ::: 全技能1.4.3.2+版本先获得嚎叫幽灵(白吼)的路线,路线来自Yihou,文章最后作者BrotherWildness。 ### 复仇之魂(白波) * 不要在收集吉欧上浪费时间 * 跳过假骑士 * 通常这时应该有16~20吉欧 * 获得复仇之魂 ::: details 译者注:关于无能力巴德尔 因为速通规则中有明确规定:“允许跳过白法术,直接获得黑法术”,因此可以选择不获得复仇之魂(白波),这就是“无能力巴德尔”路线。 如果你想尝试这个路线,你应该做出如下调整: * 在国王山道获得亡者之怒护符 * 不去打假骑士,因此也不会触发阿司匹德猎手的遭遇战 * **直接击败苍绿之径巴德尔长者,进入苍绿之径(需要大量练习)** * 获得蛾翼披风后,退出游戏重进,会回到德特茅斯的椅子 * 前往获得螳螂爪的路上,才会触发阿司匹德猎手的遭遇战 * 通过阿司匹德猎手的遭遇战之后,进入该地图右上角的温泉,坐椅子 * 获得嚎叫幽灵(白吼)后,退出游戏重进,会回到温泉地图的椅子 * 之后的路线就完全相同了 * 因为前述部分会少收集一些吉欧,因此后续路线需要多收集一些吉欧进行补足 ::: ### 进入苍绿之径 * 祖先山丘巴德尔长者(+50吉欧) * 不拿灵魂捕手护符 * 苍绿之径巴德尔长者(+50吉欧) * 这时应该有115~120吉欧 * 进入苍绿之径 ### 蛾翼披风(白冲) * 正常路线通过苍绿之径 * 第一只苔藓骑士(+43吉欧) * 击败反击蝇之王(+60~65吉欧) * 苔藓骑士吉欧堆(练习无魂/少魂下批打法) * 苔藓骑士之后目标达到390吉欧,不过,只要超过350就行了 * 打开苍绿之径鹿角虫站(-140吉欧) * 击败大黄蜂,获得蛾翼披风 ### 螳螂爪 * 退出游戏重进,回到祖先山丘的椅子 * 前往下劈核弹的路上收集一些灵魂 * 用你喜欢的方式下劈核弹,或者从螳螂村绕路 * 打开开关之后下劈螳螂 * 获得螳螂爪 ### 嚎叫幽灵(白吼) * 获得螳螂爪之后,直接前往下劈核弹的房间,从另一条岔路前往王后驿站鹿角虫站的方向 * 攀爬雾之峡谷 * 穿过满是带电光蝇的房间 * 进入长满植物的山丘 * 通过遭遇战 * 获得嚎叫幽灵 ### 进入蓝湖 * 退出游戏重进,回到祖先山丘的椅子 * 在路上收集一些灵魂 * 在见到格鲁兹之母之前需要收集攻击6次的灵魂 * 击败格鲁兹之母和格鲁兹 * 坐椅子 * 购买萨满之石护符(-220吉欧) * 拯救斯莱的路上受到一些伤害 * 拯救斯莱 * 死亡(离萨鲁巴的商店近点) * **装备萨满之石!装备萨满之石!装备萨满之石!** * 劈魂上蓝湖 ### 华丽躯壳 * 不要上去拿梦之钉 * (可选)在路上顺路攻击一下有翼哨兵收集一点灵魂 * 坐电梯下到泪水之城 * 顺路收集一些灵魂,但不要浪费时间捡古董或击杀敌人获得吉欧 * 见到华丽躯壳之前收集满灵魂 * 进入华丽躯壳的房间 * 击败前面的小怪(+35左右吉欧) * 击败华丽躯壳(+400~440吉欧) * 击败华丽躯壳后要保持满灵魂 ### 进入守望者的尖塔 * 下劈背景进入守望者的尖塔 * 在地板下方上吼三次击败大型躯壳哨兵 * 从这个房间上去的路上顺手开门 * 向左前往灵魂圣所,顺路收集一些灵魂(最好攻击5~6下) * 击败灵魂扭曲者 * 进入灵魂圣所 ### 荒芜俯冲(白砸) * 打开前往灵魂战士的门 * 击败灵魂战士(+200吉欧) * 无需开启捷径 * 在地狱般的房间活下来 * 进入灵魂大师的战斗 * 击败灵魂大师 * 获得荒芜俯冲(在拾取荒芜俯冲之前有灵魂的话你可以回血,拾取后会自动回满灵魂但不会回血) ### 城市仓库鹿角虫站 * 打开灵魂大师吉欧箱(+380吉欧) * 这时应该有大约1200吉欧 * 从左侧出口离开灵魂圣所(路上收集灵魂,但不要浪费时间) * 用白波击破通往柯尼法旁边的椅子的墙,然后一直向左冲刺 * 爬上城市仓库,打开鹿角虫站(-200吉欧) ### 水晶之心(超冲) * 用白波摇铃(别太远了),出房间再进来 * 与鹿角虫对话,按一下↓,选择前往德特茅斯 * 开门,在德特茅斯坐椅子 * 进入深井,向右走,前往水晶山峰 * 从水晶山峰的下砸入口进入(路过米拉的那个入口) * (可选)下劈斧头 * 在水晶守卫门口拾取店主的钥匙 * 击败水晶守卫(+385吉欧) * 继续前往水晶之心 * 打开水晶之心前一个房间的门 * 采用不受伤的方案前往水晶之心,除非你很有自信或者很勇敢 * 获得水晶之心 ### 黑暗降临(黑砸) * **不要退出游戏重进!不要退出游戏重进!不要退出游戏重进!** * 超冲离开水晶之心房间 * 往下走,通过矿石粉碎机房间 * \~~记得救幼虫~~:rofl: * 摸黑(需要提前练习) * 超冲过山谷,前往结晶山丘 * (可选)如果你提前练习过并且你的血量足够,可以尝试勾引水晶猎手打断拾取黑暗降临的后摇 * 获得黑暗降临,离开结晶山丘 ### 梦之钉 * 从山谷跳下,前往安息之地 * 用超冲的方案通过梦境平台,获得梦之钉 ### 旋风劈砍 * 退出游戏重进,回到德特茅斯的椅子 * 从斯莱处购买典雅的钥匙(-800吉欧) * 向左穿过国王山道 * 向上到达呼啸悬崖 * 获得旋风劈砍 ### 暗影之魂(黑波) * 退出游戏重进,回到德特茅斯的椅子 * 与鹿角虫对话,按一下↑,选择前往城市仓库 * 从城市仓库下到最底下,然后向右超冲 * 使用典雅的钥匙开门 * 击败灵魂战士,获得吉欧 * 先开门,再获得暗影之魂 ### 守望者骑士 * 向右离开灵魂圣所,前往守望者的尖塔 * 向上前往守望者骑士 * 击落吊灯 * 击败守望者骑士 ### 王之印记 * 继续向上,击败守望者卢瑞恩 * 打开守望者骑士吉欧箱(+655吉欧) * 这时至少应该有1400吉欧 * 离开守望者的尖塔,向右前往国王驿站 * 前往王国边境 * 王国边境酸冲(参考全护符槽收集路线) * 下劈原始阿司匹德 * (可选)为防意外,在大黄蜂2的门口坐椅子 * 击败大黄蜂2 * 在与大黄蜂对话前先用梦钉攻击她 * 获得王之印记 ### 冲刺劈砍 * 向左然后向下,路过原始阿司匹德 * 下砸击碎可破坏的地面 * 获得吉欧堆(+约40吉欧) * 向下然后向右,路过大跳虫 * 获得冲刺劈砍(-800吉欧) ### 伊思玛的眼泪(酸泪) * 退出游戏重进,回到王之印记门口 * 从左边向上走(参考全护符槽收集路线) * 继续向左,前往国王驿站 * 继续向左,前往获取伊思玛的眼泪 * 下劈尖刺进入皇家水道 * 酸冲 * (可选)如果你练习过,可以尝试勾引呼噗打断拾取伊思玛的眼泪的后摇 * 原路返回,离开伊思玛的眼泪的房间 ### 帝王之翼(二段跳) * 继续往下走,前往古老盆地 * 激活古老盆地的椅子(-150吉欧) * 退出房间重进,跳过激活椅子的动画 * 坐椅子 * 穿过毛鲁克的房间,前往残破容器 * 击败残破容器 * 获得帝王之翼 ### 暗影披风(黑冲) * 退出游戏重进,回到古老盆地椅子 * 前往深渊 * 从偏右侧的位置掉落深渊 * 爬上灯塔,点亮灯塔 * 获得暗影披风,退出游戏重进跳过动画 ### 深渊尖啸(黑吼) * 选择存档后,按住↑和梦之钉键,可以跳过硬直 * 一直向左走 * 顺路收集3次攻击的灵魂 * 获得深渊尖啸 ### 强力劈砍 * 深渊攀爬 * 顺路收集6次攻击的灵魂 * 用黑波击碎隐藏的鹿角虫站的墙壁,用黑波摇铃(-300吉欧) * 与鹿角虫对话,按两下↑或↓,选择前往苍绿之径,坐椅子 * 向下前往获取强力劈砍 * 向左穿过很长的房间,获取强力劈砍 ### 教师莫诺蒙 * 退出游戏重进,回到苍绿之径椅子 * 与鹿角虫对话,按一下↑,选择前往德特茅斯 * 在深井使用旋风劈砍防止落地硬直 * 顺路收集一些灵魂,继续前往雾之峡谷酸冲入口,游过去 * 向下经过满是带电光蝇的房间 * 进入教师档案馆 * (可选)为了意外,坐椅子 * 顺路收集一些灵魂,并且在乌姆战斗开始时可以对乌姆使用一到两次梦之钉获得灵魂 * 一轮乌姆 * 击败教师莫诺蒙 ### 野兽赫拉 * 前往王后花园的酸冲入口,游过去 * 通过螳螂佩特拉遭遇战并开门 (尽可能保证有250吉欧,如果不够则需要补) * 向下然后向左,穿过满是螳螂佩特拉的房间 * 进入深邃巢穴 * 摸黑 * 进入野兽巢穴 * 二段跳跳过陷阱椅子 * 击败野兽赫拉 ### 前往黑卵 * 离开野兽巢穴 * 打开遥远的村庄鹿角虫站,用超冲摇铃(-250吉欧) * 与鹿角虫对话,按两下↓,选择前往德特茅斯 * 在深井使用旋风劈砍防止落地硬直 * 进入黑卵 * 无需坐椅子 * 劈断锁链 ### 空洞骑士 * 站在空洞骑士右侧开战,开局使用一个黑吼 * 一直黑吼,直到击败空洞骑士(面朝右黑吼不会触发格挡) --- --- url: /daily/hollowknight/any-ddark.md --- # 快速通关成就(黑砸线) ::: warning 注意 本路线不是“速通”路线,会比速通路线慢很多。 ::: 游戏中有一个“5小时之内通关”的成就,对于普通玩家来说,正常的速通路线在0钉白波情况下的守望者骑士、乌姆、空洞骑士战斗的难度太高了。如果有了黑暗降临(黑砸),对于已经通关了一周目的玩家而言,对付这些战斗就几乎没有难度了。 考虑到劈魂上蓝湖对于普通玩家也有点难度,本路线依据是否劈魂上蓝湖分为两个不同的分支,其中不劈魂上蓝湖的路线不需要主动死亡,因此也适用于钢魂模式。 点击下面文字可以下载对应路线的.lss文件: * ::file::劈魂上蓝湖路线 * ::file::不劈魂上蓝湖路线 这两个路线的极限速度分别比对应的 [Any%速通路线(超冲线)](any.md)和 钢魂Any%速通路线慢10分钟之内,普通玩家加以练习可以在50分钟内通关。有黑暗降临(黑砸)降低战斗难度,只有水晶山峰需要摸黑(非常容易,搜索相关视频2分钟就能学会),多余的时间可以额外收集一些能力以进一步减少战斗难度(例如强化一下骨钉、获取伊思玛的眼泪和帝王之翼、收集几个面具碎片、收集几个容易获得的护符),想要达成5小时内通关还是非常容易的。 \~~甚至,如果不想练习0钉白波路线的正常速通路线的话,你也可以用本路线来跑速通,在偏后的位置上榜。~~ ::: note 注意 在游戏中按下ESC暂停游戏后,游戏内置的计时器会暂停。既然不是参与速通,仅为了达成成就,你可以充分利用这一技巧。 路线中不会购买地图,如果你容易迷路,建议打开[中文wiki](https://hkss.huijiwiki.com/wiki/分类:区域_\(空洞骑士\)),利用暂停游戏的时间查看地图并规划路线。 ::: ## 白波冲刺螳螂爪 * 前往假骑士的战斗 * 在与假骑士战斗中劈墙离开 * 击败假骑士,获取城市纹章,打开战利品吉欧箱(+200吉欧) * 获得复仇之魂(白波) * 前往苍绿之径 * 击败反击蝇之王 * 一路前往大黄蜂战斗 * 击败大黄蜂,获得蛾翼披风(冲刺) * 退出游戏重进,回到祖先山丘的椅子 * 开启遗忘十字路鹿角虫站,击败格鲁兹之母,拯救斯莱,购买萨满之石护符,再次退出游戏重进,回到祖先山丘的椅子,装备萨满之石 * 前往获取螳螂爪 ## 荒芜俯冲(白砸) * 劈魂上蓝湖,获得白砸后,卖出古董 * 退出游戏重进,回到祖先山丘的椅子 * 开启遗忘十字路鹿角虫站,击败格鲁兹之母,购买萨满之石护符,拯救斯莱 * 装备萨满之石,劈魂上蓝湖 * 不要获取梦之钉,直接前往国王驿站 * 拾取国王驿站电梯夹层漫游者日记 * 前往灵魂圣所,击败灵魂大师,获得荒芜俯冲(白砸) * 开启战利品吉欧箱,拾取圣巢印章 * 找到里姆,卖出所有古董 * **坐守望者之塔的椅子!坐守望者之塔的椅子!坐守望者之塔的椅子!** * 开启国王驿站鹿角虫站 * 从正门进入泪水之城,获取白砸 * 从正门进入泪水之城 * 拾取平台上方的圣巢印章,找到里姆并卖出 * 前往灵魂圣所,击败灵魂大师,获得荒芜俯冲(白砸) * 开启战利品吉欧箱 * 开启城市仓库鹿角虫站 ## 水晶山峰 * 传送至遗忘十字路 * 下砸进入水晶山峰 * 获取水晶之心(超冲) * 摸黑前往结晶山丘,获取黑暗降临(黑砸) * 离开结晶山丘,获取梦之钉 ::: caution 恶性bug * 如果获取梦之钉后利用暂停后冲刺的技巧跳过先知对话,记住一定要用冲刺出来,不要使用超冲,否则击败卢瑞恩后会白屏,只能退出游戏重进(原理:利用暂停后超冲的技巧跳过先知对话后,如果不经历任何退出游戏重进,直接前往击败任一守梦者,就会导致游戏白屏) * 如果打算击败卢瑞恩后打开背包加速下落,在从先知冲刺出来后掉到下面,在落地之前需调整至面向左边。如果面向右落地,击败卢瑞恩后打开背包下落会导致游戏卡死 ::: ## 守望者骑士 * 前往守望者之塔 * 退出游戏重进,回到守望者之塔的椅子 * 下砸进入安息之地地窖,前往泪水之城 * 击败守望者骑士和守望者卢瑞恩,开启战利品吉欧箱 ## 乌姆 * 前往国王驿站,传送至德特茅斯 * 购买光蝇灯笼(-1800吉欧) * 酸冲进入雾之峡谷(全游戏最简单的酸冲,当场就能学会) * 前往教师档案馆,击败乌姆和教师莫诺蒙 ## 野兽赫拉 * 酸冲进入王后花园,前往遥远的村庄 * 王后花园酸冲很难,你也可以选择从深巢绕道,避开王后花园酸冲和螳螂佩特拉遭遇战 * 如果你从深巢绕道,顺路可以获取蘑菇孢子护符 * \~~如果有自信摸黑深巢,可以不买灯笼,立省1800吉欧,于是前面的收集古董以及守望者骑士的战利品吉欧箱都可以忽略~~ * 进入野兽巢穴,击败野兽赫拉 ## 空洞骑士 * 在野兽巢穴椅子休息,卸下萨满之石并装备蘑菇孢子 * 开启遥远的村庄鹿角虫站,传送至德特茅斯 * 前往黑卵,击败空洞骑士 --- --- url: /daily/hollowknight/any-isma.md --- # Any%速通路线(酸泪线) 英文原文链接: 路线的.lss文件可以在[计时器生成器](hksplitmaker-faq.md#生成模板并导入)的模板中找到。 ::: note 译者注 本路线需要获得帝王之翼(二段跳),并且在无超冲的情况下获得伊思玛的眼泪(酸泪),路线中需要多次劈波,操作难极高,失误重开风险极大,但比[Any%速通路线(超冲线)](any.md)要快一些,普通玩家请自行选择适合自己的路线。 因为本路线不会获得水晶之心(超冲),也不会击败灵魂大师,因此满足[“拯救米拉”](https://www.speedrun.com/zh-CN/hkmemes?h=Save_Myla-1.4.3.2_NMG\&x=824n6mgk-wlewrr4l.gq7pm4nq)的条件,用这个路线通关的选手同样也可以在[“拯救米拉”](https://www.speedrun.com/zh-CN/hkmemes?h=Save_Myla-1.4.3.2_NMG\&x=824n6mgk-wlewrr4l.gq7pm4nq)项目上榜。 原文未作排版处理,在翻译过程中对文本格式进行了必要优化。 ::: Any% 1.4.3.2+版本速通路线(酸泪线),原作者AlejsXY、DisneyReject。 ### 复仇之魂(白波) * 通过国王山道 * 阿司匹德猎手的遭遇战(8~15 吉欧) * 在与假骑士战斗中劈墙离开(大约8吉欧) * 获得复仇之魂(白波),击败祖先山丘巴德尔长者(+50吉欧) ### 苍绿之径/蛾翼披风(冲刺) * 击败苍绿之径巴德尔长者(+50吉欧),进入苍绿之径 * 苔藓骑士(40 吉欧) * 不打反击蝇之王 * 如果不够158吉欧,就路上打一个苔藓冲锋者 * 击败大黄蜂,获得蛾翼披风,退出游戏重进,回到祖先山丘的椅子 ### 螳螂爪 * 穿过真菌荒地 * 螳螂爪地图中,击杀看守开关的螳螂战士会掉落12吉欧 * 获得螳螂爪,前往冲刺大师护符 * 这里你需要170吉欧,如果不够就顺路击杀几只螳螂 * 拾取冲刺大师护符,退出游戏重进,回到祖先山丘的椅子 ### 格鲁兹之母/梦之钉 * 装备冲刺大师护符,前往格鲁兹之母(**不要**开启遗忘十字路鹿角虫站) * 击败格鲁兹之母(+50吉欧),购买萨满之石护符(-220吉欧) * 坐椅子装备萨满之石(护符过载),这样受伤三次就可以死亡(不要救斯莱) * 劈魂上蓝湖 * 获得梦之钉 ### 帝王之翼(二段跳) * 坐电梯前往泪水之城 * 不要浪费时间击杀任何敌人或收集古董,一路冲向守望者的尖塔的椅子,坐椅子(最好顺路收集一些灵魂) * 击败大型躯壳哨兵,离开守望者的尖塔,前往古老盆地 * 前往残破容器,途中需要下劈白波(顺路多收集一些灵魂,方便失误后的重试) * 击败残破容器(如果必要的话,战斗前可以适当回点血),获得帝王之翼 * 退出游戏重进,回到守望者的尖塔的椅子 ### 吊灯/守望者骑士 * 前往守望者骑士 * 顺路收集一些灵魂,并击落吊灯 * 击败守望者骑士和守望者卢瑞恩 * 下来的路上,打开守望者骑士的吉欧箱,此时你需要至少507吉欧,这意味着你不需要捡吉欧箱中的全部655吉欧 ### 伊思玛的眼泪(酸泪) * 从守望者的尖塔下来之后一路向右走,路上尽可能收集满灵魂,开启国王驿站鹿角虫站并坐椅子,用白波摇铃并离开房间 * 前往伊思玛的树林入口,劈刺进入伊思玛的树林 * \~~译者水平有限,不会描述~~(总之你需要一些高端操作,建议多参考速通视频学习) * 下劈白波,穿过酸液,获得伊思玛的眼泪 * 退出游戏重进,回到国王驿站鹿角虫站的椅子 ### 乌姆 * 传送到德特茅斯,一路向下前往教师档案馆 * 从酸液中游进雾之峡谷,进入教师档案馆 * 击败乌姆和教师莫诺蒙 ### 野兽赫拉 * 离开教师档案馆,从酸液中游入王后花园 * 通过螳螂佩特拉遭遇战并开门(当前版本不能通过撞荆棘快速离开) * 一直向下并向左,前往深邃巢穴 * 摸黑前往遥远的村庄 * 进入野兽巢穴,二段跳跳过陷阱椅子,击败野兽赫拉。此时你处于过载状态,和潜行信徒战斗时需要小心 ### 空洞骑士 * 开启遥远的村庄鹿角虫站,前往德特茅斯 * 进入黑卵,坐椅子并移除冲刺大师(不会浪费太多时间,没必要冒过载的风险) * 击败空洞骑士 --- --- url: /daily/hollowknight/any-shade-soul.md --- # Any%速通路线(黑波线) 路线的.lss文件可以在[计时器生成器](hksplitmaker-faq.md#生成模板并导入)的模板中找到。 ::: warning 注意 本路线并不算是真正意义上的“速通”路线。Any%正常的速通路线是需要在只拥有0钉白波的情况下进行守望者骑士、乌姆、空洞骑士的战斗,难度较大,新手玩家难以达成。但很多新手玩家想要直接参与Any%速通,因此就有了这个黑波线,在[Any%速通路线(超冲线)](any.md)的基础上变化不大,方便在熟练之后过渡到超冲线。 ::: 本路线是**0钉黑波路线**,全程无需摸黑,比0钉白波的[超冲线](any.md)慢7分钟之内。 本路线的思路就是在守望者骑士战斗之前就获得黑波,让战斗变得更加容易,但和0钉白波的战斗手法区别不大,方便新手玩家练习和过渡。 ## 白波冲刺螳螂爪 * 跳过假骑士,获取复仇之魂(白波) * 击败反击蝇之王,击败大黄蜂,获取蛾翼披风(冲刺) * 退出游戏重进,回到祖先山丘的椅子 * 获取螳螂爪 ## 收集古董 * 螳螂爪上方地图**漫游者日记** * 拜年菇下方地图再左边一张地图(也就是王后驿站右边地图)一堆爆炸孢子顶上**圣巢印章** * 拜年菇下方地图**漫游者日记** * 退出游戏重进,回到祖先山丘的椅子 * 开启遗忘十字路鹿角虫站,坐鹿角虫站椅子 * 传送至德特茅斯,跳入井中,井中有**圣巢印章** * 退出游戏重进,回到遗忘十字路鹿角虫站 ## 前往国王驿站 * 购买萨满之石护符(-220吉欧) * 拯救斯莱 * 装备萨满之石,劈魂上蓝湖 * 获取梦之钉 * 原路返回,前往国王驿站 ## 继续收集古董 * 国王驿站电梯夹层**漫游者日记** * 车站屋顶**圣巢印章** * 坐国王驿站椅子 * 击败华丽躯壳(+460吉欧) * 里姆所在地图的左边地图的平台上有一个**圣巢印章** * 找到里姆,出售所有古董,共有3个**漫游者日记**(价值200吉欧)和4个**圣巢印章**(价值450吉欧) * 退出游戏重进,回到国王驿站,传送至德特茅斯 ## 超冲和黑波 * 坐德特茅斯椅子 * 购买光蝇灯笼 * 传送至遗忘十字路 * 前往水晶山峰,获取店主的钥匙 * 获取水晶之心(超冲) * 退出游戏重进,回到德特茅斯 * 在斯莱的商店购买典雅的钥匙 * 传送至国王驿站 * 获取暗影之魂(黑波) ## 剩余内容 * 击败守望者骑士和守望者卢瑞恩 * 这里最多只需要166吉欧,后续野兽巢穴必经的两只潜行信徒各掉落43吉欧,共需250吉欧开启遥远的村庄鹿角虫站,你可以视情况选择是否要打开战利品吉欧箱 * 后续的王后花园/深巢跑图过程中也会获得少量吉欧,所以这里所需的吉欧数量其实会更少 * 酸冲进雾之峡谷,击败乌姆和教师莫诺蒙 * 酸冲进入王后花园,前往遥远的村庄 * 这一步你也可以选择从深巢绕道,避开王后花园酸冲和螳螂佩特拉遭遇战, 顺路可以获取蘑菇孢子护符 * 进入野兽巢穴,击败野兽赫拉 * 在野兽巢穴椅子休息,卸下萨满之石并装备蘑菇孢子 * 开启遥远的村庄鹿角虫站,传送至德特茅斯 * 前往黑卵,击败空洞骑士 ## 参考资料 --- --- url: /daily/hollowknight/any.md --- # Any%速通路线(超冲线) 英文原文链接: 路线的.lss文件可以在[计时器生成器](hksplitmaker-faq.md#生成模板并导入)的模板中找到。 ::: note 译者注 本路线是Any%速通玩家们最早使用的速通路线,直到现在仍然很流行,但并不是目前最快的路线。目前最快的路线是[Any%速通路线(酸泪线)](any-isma.md)。相比于酸泪线而言,本路线的难度会低很多。 Any%速通不可避免需要在只拥有0钉白波的情况下进行守望者骑士、乌姆、空洞骑士的战斗,难度较大。如果新手玩家难以达成,但又非常想参与Any%速通的话,可以考虑使用[Any%速通路线(黑波线)](any-shade-soul.md),在本路线的基础上变化不大,方便在熟练之后过渡到本路线。 ::: Any% 1.4.3.2+版本速通路线(超冲线),包含两个分支,仅在最后一部分路线上略有区别。我们先介绍较快的[王后花园酸冲线](#王后花园酸冲线),在文末会补充介绍[替代路线:深巢温泉/蘑菇孢子线](#替代路线-深巢温泉-蘑菇孢子线)。 ## 王后花园酸冲线 ### 复仇之魂(白波) * 进入遗忘十字路后向左走 * 通过阿司匹德猎手遭遇战(+10吉欧) * 跳过假骑士战斗(战场中的小怪+10吉欧) * 获得复仇之魂 ### 苍绿之径/蛾翼披风(冲刺) * 祖先山丘,击杀第一只巴德尔长者(+50吉欧) * 穿过格鲁兹房间(从右侧下落稍快,但还是依赖随机性) * 击杀第二只巴德尔长者(当前版本无法使用秒杀BUG) * 扭波/下劈蚊子跳过上层苍绿之径 * 击败苔藓骑士(+40吉欧) * 反击蝇之王(+65吉欧),此处至少需要220吉欧(顺路可收集一些吉欧) * 击败大黄蜂,获得蛾翼披风,退出游戏重进,回到祖先山丘的椅子 ### 螳螂爪 * 从遗忘十字路前往真菌荒地 * 在真菌荒地坠落处向右,会路过阿布和泪水之城入口 * 下劈核弹(或绕路) * 获得螳螂爪,退出游戏重进,回到祖先山丘的椅子 ### 格鲁兹之母/梦之钉 * 向下并向右穿过十字路 * 开启遗忘十字路鹿角站(-50吉欧),无需摇铃 * 击败格鲁兹之母(建议保留两白波的灵魂用于清场)(+50吉欧) * 在萨鲁巴的商店门口的长椅休息,购买萨满之石(-220吉欧) * 救出斯莱(途中受到两次伤害),再受到三次伤害死亡(确保死亡位置靠右,以防暗影生成的位置不正确) * 装备萨满之石护符,劈魂上蓝湖 * 穿过蓝湖,打开开关并上行至守梦者纪念碑 * 跳过梦境平台,不要跌落梦境,获得梦之钉 ### 吊灯/守望者骑士 * 跳过先知对话(即使失败也值得尝试) * 向左下方移动,搭乘电梯前往泪水之城 * 在国王驿站上方拾取漫游者日记 * 前往国王驿站,拾取圣巢印章(如有必要,可以坐一下椅子) * 向左击杀华丽躯壳及房间内的其它敌人(+460吉欧) * 向左进入喷泉房间,打开开关 * 下劈背景进入守望者尖塔 * 击杀大型躯壳哨兵(建议先在守望者的尖塔的椅子休息),之后向右上方移动(+50吉欧) * 进入守望者骑士战斗前确保有释放三次白波的灵魂 * 使用白波击碎天花板,击落吊灯 * 努力战胜守望者骑士 ### 光蝇灯笼 * 前往守望者卢瑞恩(路上先不打开守望者骑士吉欧箱) * 击败守望者卢瑞恩 * 返回下方,打开守望者骑士吉欧箱,向左下移动至前往左侧的桥梁 * 向下与喷泉旁的里姆对话并出售古董(+1100吉欧) * 返回国王驿站(此时需要约2320吉欧,可通过击杀守望者尖塔下方或喷泉房间的敌人补足) * 开启国王驿站(利用向下看/白波摇铃加速的技巧),传送至德特茅斯 * 开启德特茅斯鹿角站杠杆,坐椅子休息后前往斯莱处购买光蝇灯笼 ### 水晶之心(超冲) * 乘鹿角站传送至遗忘十字路 * 通过愤怒反击蝇房间进入暗室入口 * 打开收费的门(利用向下看加速的技巧),进入水晶山峰 * 直接上行穿过山峰(途中不要捡任何收集品,熟练的话可以使用下劈斧头技巧) * 向右移动,下落至大房间 * 获得水晶之心,退出游戏重进,回到德特茅斯 ### 乌姆 * 进入深井,向左下方走 * 酸冲进入雾之峡谷 * 继续跑图,进入教师档案馆(如有必要,可以坐椅子休息) * 击败乌姆(争取两轮,最多三轮) ### 王后花园/陷阱长椅 * 酸冲跳过酸液,击败教师莫诺蒙 * 离开教师档案馆,向左走,在下一张地图进入左侧从上数第二个入口 * 酸冲进入王后花园(需要大量练习) * 螳螂佩特拉遭遇战(佩特拉共45血,需2白波+1骨钉,若白波失误需调整策略,容易翻车) * 向左下进入深邃巢穴入口 * 下至深邃巢穴并左行至遥远村庄,顺路补满灵魂 * \~~坐椅子休息一下~~:rofl: ### 野兽赫拉 * 在整个房间,你需击杀两只潜行信徒(100血),最优策略为四白波加四次骨钉(需满灵魂) * 潜行信徒攻击前摇时可以安全输出两次,然后利用受伤无敌帧追加两次骨钉并释放最后一次白波(可减少一次受伤) * 后续需躲避沿途的深巢猎手(自行决定是否拯救幼虫) * 每只潜行信徒掉落43吉欧,需250吉欧开启遥远村庄鹿角站 * 建议多参考速通视频学习野兽巢穴的技巧 ### 前往黑卵 * 击败野兽赫拉后向左走(若吉欧不足250可从吉欧堆处获得) * 离开野兽巢穴,前往右上方的鹿角站 * 开启遥远的村庄鹿角站(利用向下看/白波摇铃加速的技巧),传送至德特茅斯 * 进入深井,向右超冲,前往黑卵 * 无需坐椅子,直接进入空洞骑士房间 * 劈断锁链,准备迎接空洞骑士战斗 ### 空洞骑士 * 建议多参考速通视频学习空洞骑士战斗技巧 * 建议每次攻击后仅使用骨钉攻击两次避免被格挡 * 空洞骑士自毁过程中受到的伤害会降为1点,这时候应当保留白波用于后续战斗 * 尝试在其战吼时受伤,可以取消硬直 * 击败空洞骑士,通关 ## 替代路线:深巢温泉/蘑菇孢子线 在1.4.3.2版本中,蘑菇孢子会对空洞骑士造成大量伤害,在1.5版本中修复了这个BUG。并且,由于王后花园酸冲和螳螂佩特拉遭遇战的难度都非常高,容易在此处损失时间,因此深巢温泉/蘑菇孢子线也是降低难度的一种可行路线。不考虑捡取蘑菇孢子的话这条路线会慢40~45秒,在1.4.3.2版本中捡取蘑菇孢子的话则只会慢20秒左右(以上的时间是估算的结果,假设王后花园酸冲线发挥得很好的情况下)。 直到击败乌姆和教师莫诺蒙之前的路线都和王后花园酸冲线相同,之后的路线进行调整,以绕过王后花园酸冲和螳螂佩特拉遭遇战。 ### 真菌荒地移动 * 离开教师档案馆后左行 * 向下走,右转通过王后驿站进入真菌荒地 * 从第一个岔路口下落,再下落经过一个过渡房间,左行至蘑菇孢子房间 * 拾取蘑菇孢子并跳起来撞荆棘 * 从左边出去,坠入深邃巢穴 ### 深邃巢穴移动 * 落地后(可以用冲刺取消坠落硬直)右行至底部 * 下穿加皮德房间,进入深巢温泉(如果需要,可以坐一下长椅) * 向左进入下一个房间,然后进入左上方的入口 * 一直向左上方走,穿过两个房间 * 最后一个房间和王后花园酸冲线相同,从左侧出去进入遥远的村庄 ### 前往黑卵 * 离开野兽巢穴前,坐椅子,卸下萨满之石换蘑菇孢子(不要过载,你的灵魂将用于治疗) ### 空洞骑士 * 拥有蘑菇孢子的情况下,战斗技巧将会有很大的区别 * 该护符产生孢子云时,它会进入一个冷却,但如果你受到伤害,冷却将会重置。所以最理想的情况是,你可以回血,受伤,然后再回血,以造成更多的伤害 * 另一个主要技巧是使用打开背包来取消硬直:在打开背包的情况下,如果你站在地面上受伤,你不会有受伤硬直。这意味着你可以在受伤后更快地开始回血 * 据说,蘑菇孢子可以无视空洞骑士在自毁过程中的1伤害限制,但自毁过程中仍然是回复灵魂的最佳时机之一 --- --- url: /daily/hollowknight/hksplitmaker-faq.md --- # 空洞骑士计时器生成器FAQ ::: info 为什么要使用LiveSplit? 在《空洞骑士》速通过程中,考虑到不同玩家的电脑配置不同,speedrun.com 上的空洞骑士排行榜规定:速通耗时的统计应当使用**排除了游戏加载时间的耗时(LRT,Load Removed Time)**,玩家应当将计时器全程放在视频中清晰可见的位置,否则会按照真实耗时进行统计。其中,**LiveSplit** 是 speedrun.com 《空洞骑士》排行榜中最推荐使用的计时器软件,其中的《空洞骑士》计时插件支持排除游戏加载时间的功能。 你可以前往[**LiveSplit**官网](https://livesplit.org/downloads/)下载最新版本的 **LiveSplit**。 ::: 考虑到**LiveSplit**是全英文的,很多国内玩家不方便使用,因此我开发了一个中文版的**计时器生成器**,可以用来快速生成你需要的模板(.lss)文件,把这个模板文件导入到**LiveSplit**就可以用了。 本文中,请注意区分以下两个概念: * **LiveSplit**:指的是 speedrun.com 《空洞骑士》排行榜推荐使用的计时器软件; * **计时器生成器**:指的是我开发的用来生成**LiveSplit**所需的模板(.lss)文件的小工具。 ## 下载方式 ::: important 声明 * 工具适用说明:**LiveSplit**是开源免费软件,**计时器生成器**是针对**LiveSplit**的一个小工具,不含任何破解、盗版或其它侵权内容; * 兼容性提示:**计时器生成器**使用Go语言开发,仅在 Windows 11 系统进行过基础测试,不保证其他环境下的稳定性; * 责任声明:使用者应自行测试并承担使用、修改或分发代码产生的全部风险; * 开源性质:本项目为个人开源工具,作者无义务提供更新或技术支持,改进建议可通过仓库 Issues 提交,但无法承诺响应时效; * 交流群组:如需讨论,可自愿加入QQ群:901564850,群内须遵守法律法规,违规内容将被清理。 ::: 你可以前往[**计时器生成器**的Gitee仓库](https://gitee.com/CuteReimu/hksplitmaker/releases)下载,你也可以下载仓库代码后自行编译。 ## 如何使用 ### 首次使用 ::: danger 注意 **LiveSplit**一定要把zip/rar压缩包给解压了再用!\ 千万不要把`LiveSplit.exe`单独从文件夹里拖出来!\ 实在想放在桌面上怎么办?右键 → 发送到 → 桌面快捷方式 ::: 第一次使用时,你需要**开启LiveSplit的《空洞骑士》计时插件**。具体方法是:打开**LiveSplit**后,右键 → Edit Splits,在第一行输入`Hollow Knight`,然后右边三个按钮,有一个`Activate`按钮。 * 如果没有`Activate`按钮,而是有一个`Deactivate`按钮,说明你已经开启了插件,无需再做额外操作。 * 如果`Activate`按钮可以点,点击它,变成`Deactivate`说明开启成功,然后按`OK`按钮。 * 如果点击`Activate`按钮弹出提示`The Auto Splitter could not be activated.`,采用和下一条一样的处理方式即可。 * 如果`Activate`按钮是灰色的不能点,首先关掉**LiveSplit**,打开**计时器生成器**,点击右上角的`更新LiveSplit`按钮,接下来自己操作,提示成功之后再重新打开**LiveSplit**,`Activate`按钮就可以点了。 ### 生成模板并导入 ![hksplitmaker.png](/hollow-knight/hksplitmaker.png) 1. 运行**计时器生成器** 2. 你可以选择一个已有的模板或者打开一个`*.lss`文件,也可以自己从头开始编辑 * 如果你是刚接触速通不久,强烈建议你直接使用已有的模板 * 如果你是速通老手,有自己对路线的理解,想要自己编辑模板,可以参考以下说明 1. 点击右侧的加号和删除可以增加或者删除一行 2. 第一列的文本框里填写自己想要显示的片段名称,第二列的下拉框用来选择在游戏中会自动分割的触发事件 3. 如果是一个全关速通或者万神殿某一门的速通,不需要勾选第一行的`自定义开始节点` 4. 如果最后一个节点不是游戏结束,则需要勾选最后一行的`不是游戏结束节点`,**否则不要勾选** 3. 全部设置好以后,点击下方的`另存为`按钮,即可保存成`*.lss`文件。 4. 打开**LiveSplit** → 右键 → Open Splits → From File... ,选择刚刚保存的文件即可。 ## 计时器不生效的排查方法 关于“开启一个新的游戏存档,但并没有自动开始计时”“完成了一个内容(例如获得白波),但并未自动跳转到下一行”“还没有完成相应内容,计时器就跳转到下一行或提前结束了”相关问题,建议从以下几种可能性进行排查: * 有可能你没有开启**LiveSplit**的《空洞骑士》计时插件([如何开启?](#首次使用)) * 请确保使用管理员模式打开**LiveSplit** * 有可能你的游戏安装了一些不兼容的Mod(可以先把所有Mod卸载了试试,而且 speedrun.com 除特定Mod以外都是禁止使用的) * 有可能你的模板文件(.lss文件)有问题,建议使用**计时器生成器**生成模板文件。如果能够自动开始计时但功能异常(例如不能自动跳转到下一行、提前跳转等),则很大概率是这个原因。 * 自查方法:在你确认了已经开启了**LiveSplit**的《空洞骑士》计时插件后,回到 Edit Splits 页面,右边有三个按钮,点击其中的`Settings`按钮,新弹出的设置界面中,在上方你会看到一个`Add`按钮。对于一个正确的模板文件,在`Add`按钮下方会有一排一排的节点。如果没有,说明你的模板文件有问题,建议使用**计时器生成器**重新生成一个模板文件。 * 如果是苦痛之路速通,还有一种可能,你的苦痛存档是用一些方式制造出来的存档,例如已经通关过苦痛的存档用 Debug Mod 把门打开。建议找他人要一个苦痛存档。或者你也可以前往 [speedrun.com 《空洞骑士》版块](https://www.speedrun.com/zh-CN/hollowknight/resources/gzf2t)获取一个苦痛之路速通可用的存档。 * 如果是愚人斗兽场速通,则可能是因为你没有使用愚人斗兽场的速通专用存档。打开**计时器生成器**,右上角模板里随便找一个愚人斗兽场模板,会有提示是否需要专用存档,放入游戏存档目录即可。或者你也可以前往 [speedrun.com 《空洞骑士》版块](https://www.speedrun.com/zh-CN/hollowknight/resources/y9ops)获取愚人斗兽场速通专用存档。 --- --- url: /daily/hollowknight/intro-cutscene-skip.md --- # 速通社区新允许的Mod 以下内容来自《空洞骑士》速通社区公告。 截至目前,《空洞骑士》速通社区正在允许使用下方的这个 Mod 进行《空洞骑士》速通。但需注意几点: 1. 如果发现使用此 Mod 会带来在原版《空洞骑士》游戏中不存在的意外优势,那么利用这些优势的速通将根据严重程度被重新计时或拒绝,且在修复之前,此 Mod 将被禁用。我们希望这种情况不会发生,但我们想明确一点,仅仅因为我们已对这些组件进行了初步测试,并不意味着完全没有问题,我们也意识到存在一些漏洞的可能性。 2. 此 Mod 允许用户设置配置变量。这些变量的允许设置如下: * **ScreenShakeModifier**(屏幕抖动) 可设置为`true`或`false`; * **FasterIntroSkip**(跳过报幕过场动画) 对于报幕过场动画不会计时的任何速通,可设置为`true`或`false`。对于报幕过场动画会计时的速通(例如全成就、Any%允许所有邪道),此项必须设置为`false`; * **MiniSaveStates** 应当设置为`false`,除非该速通类别允许使用 MiniSaveStates。 *** 以下是来自速通社区的 peekawoo! 大佬提供的`Assembly-CSharp.dll`。你需要在将其**放入游戏目录覆盖同名文件**后(覆盖前记得先备份),先运行一次游戏后关闭,此时会在**存档目录**下生成一个`assemblyPatchesConfiguration.json`配置文件,根据自己的需要修改此配置文件后重新启动游戏即可生效。 peekawoo! 大佬表示,此 Mod 只是做了简单的测试,希望大家在使用前多多测试。此 Mod 只支持在 Windows 系统使用。 ::: warning 注意 公告发布于2025年底,以下`Assembly-CSharp.dll`文件也取于当时。由于本文不一定会持续更新,不排除以下文件可能会过时。 ::: ::file:: 对应游戏1.4.3.2版本的`Assembly-CSharp.dll`\ ::file:: 对应游戏1.5.7.8版本的`Assembly-CSharp.dll` --- --- url: /daily/hollowknight/speedrun-categories.md --- # 空洞骑士速通类型科普 本篇主要是科普 [speedrun.com 上面《空洞骑士》游戏](https://www.speedrun.com/zh-CN/hollowknight)的速通分类。 ::: warning 注意 《空洞骑士》的速通榜一直在增加新的榜单,本文不保证永远保持最新。想要了解最新内容,可以前往 [speedrun.com 的《空洞骑士》板块](https://www.speedrun.com/zh-CN/hollowknight)。 ::: ::: tip 名词解释 * CP:Current Patch,当前版本,一般以1.4.3.2版本前和后来进行划分,例如在这之前有冲刺劈砍隔墙劈开关的操作,之后修复了。 * NMG:No Major Glitches,无主要邪道,所谓邪道主要包含这样一些种类的bug:无敌、出边界、角色不可见、漂浮、复制道具、超级滑行、背包存储、卡住boss的AI导致boss不动等。 * NMMS:No Main Menu Storage,无主菜单存储邪道,主要使用出界等bug,参与者基本都是用1.0.2.8以前的版本。 ::: ::: tip 如何降版本 Steam上下载的空洞骑士默认是最新版本,如果你想要降到1.4.3.2或1.2.2.1,直接对Steam中的空洞骑士右键 → 属性,选择测试版,再选择想要降到的版本即可。(注意:此方法会覆盖原来的空洞版本,如果想要多版本共存请参考下述降版本教程) 如果你想要降到任意版本,请参考:[::b:bilibili =20 /#FE7398:: Steam降版本教程](https://www.bilibili.com/video/BV1Sv411v7Ho?p=2) ::: 如果你是一个想要入门速通的玩家,没有任何速通经验,打算进行全流程速通,只建议从[Any%](#any)超冲线/黑波线和[全技能](#全技能)旧路线开始入手。当然,如果你喜欢[非流程通关类](#非流程通关类),例如苦痛之路、万神殿竞速等,也可以根据自己的喜好去尝试。 ## 不同结局通关 ### Any% 以任意完成度通关游戏,没有其他任何限制,因此大家肯定选择击败黑卵的空洞骑士。其下分为NMG、CP NMG、NMMS、允许任何邪道。 这是榜单的第一个项目,也是最火爆的一个速通项目,远远比其他项目火爆的多。 如果你喜欢火爆的项目,可以尝试一下。 其它参考资料: * [::b:bilibili =20 /#FE7398:: 当前版本Any%路线讲解视频](https://www.bilibili.com/video/BV18q4y137jd) 作者:奇葩の灵梦 * [ 零钉白波路线提要](https://docs.qq.com/doc/DS3piem1sY05wUGRE) 作者:liusyflat_ab * [::b:bilibili =20 /#FE7398:: 1.2.2.1版本路线讲解视频](https://www.bilibili.com/video/BV1AL411J72w) 作者:liusyflat_ab * [::b:bilibili =20 /#FE7398:: 1.2.2.1版本视频教程](https://www.bilibili.com/video/BV1Sv411v7Ho) 作者:fireb0rn,由岩窟翻译组翻译 ### True Ending 击败辐光通关。其下同样分为NMG、CP NMG、NMMS、允许任何邪道。 必须收集1800精华觉醒梦之钉,进入白色宫殿,获取国王之魂的右半部分,拼成国王之魂,并转化为虚空之心,才能触发黑卵战斗中途的大黄蜂加入的剧情,使用梦之钉攻击空洞骑士,进入辐光战斗。 如果你喜欢在黑卵打辐光,可以尝试这个项目。 其它参考资料: * [ 黑波路线提要(1.2.2.1版本适用)](https://docs.qq.com/doc/DS0xhUkxhU2JqVWhl) 作者:liusyflat_ab ### 神居结局 击败无上辐光通关。因为神居是只有CP才有的DLC,因此这个项目的NMG其实就是CP NMG,这个项目的NMMS也是没有意义的。 如果你擅长万神殿又想打全流程,可以尝试这个项目。 ## 全技能 收集所有的法术、移动技能、骨钉技艺后通关游戏,允许跳过1级法术直接拿2级法术(比如“无能力巴德尔”)。详细来说就是:三种黑法术、三种骨钉技艺、白冲黑冲、酸泳、二段跳、超冲、螳螂爪。其下同样分为NMG、CP NMG、NMMS、允许任何邪道。 这是跑图占比最高的项目,这个项目的旧路线也是最适合新手入门的项目。 如果你想入门速通,可以尝试一下这个项目的旧路线。 其它参考资料: * [ 1.2.2.1版本路线提要](https://docs.qq.com/doc/DS0laeFRzRG91dU9P) 作者:liusyflat_ab * [::b:bilibili =20 /#FE7398:: 当前版本全技能路线讲解视频](https://www.bilibili.com/video/BV1634y197YA) 作者:奇葩の灵梦 ## Low% 和 钢魂Any% 其中: * Low%是以11%完成度通关,包含白波1%、螳螂爪2%、梦钉1%、超冲2%、守望者骑士1%、乌姆1%、三个守梦者3%,这也是游戏完成需要的最低完成度。 * 钢魂Any%是在钢铁之魂模式下任意完成度通关。 两者的路线几乎一样,主要区别是Low%不会获得蛾翼披风(冲刺),因此路线安排上会有一些区别。值得一提的是,Low%想要获得螳螂爪需要无冲刺批核弹,这个在CP NMG中是非常困难的,需要大量练习。 Low%分为NMG、CP NMG、NMMS、允许任何邪道。由于路线基本一样,所以钢魂Any%的NMMS和允许任何邪道就没有什么意义了,只有NMG和CP NMG。 如果你喜欢摸黑深巢,可以选择这两项速通。 其它参考资料: * [ Low%完成度补充说明](https://docs.qq.com/doc/DS0ZIeGpLck9FQklC) 作者:liusyflat_ab * [ 1.2.2.1版本Low%路线提要](https://docs.qq.com/doc/DS2hlUGt5Wk5XRXJp) 作者:liusyflat_ab ## 全完成度类 由于每个DLC都包含了新的完成度,因此全完成度类的速通项目也分为不同版本。由于是固定了版本,因此除100% TE之外都只分为NMG和允许任何邪道。以下是目前的分类: * 112% APB:112%完成度,并且在万神殿中击败所有可能出现的boss(包括灰色王子左特),最终击败无上辐光通关。 * 107% AB:107%完成度,打败所有boss(除空洞骑士外),最终在黑卵击败辐光通关。禁止使用神居DLC相关的任何内容。 * 106% TE:使用1.2.1.0到1.2.2.1之间的版本(相比现版本,少了蜂巢骑士,地图进行了大幅度调整),106%完成度(需要击败梦魇之王格林),最终在黑卵击败辐光通关。 * 100% TE:使用1.1.1.8或更早的版本(相比1.2.2.1少了格林剧团DLC,其中包含4%护符完成度和两个格林剧团的Boss),100%完成度,最终在黑卵击败辐光通关。分为NMG、NMMS和允许任何邪道。 如果你喜欢耗时很久的速通项目,可以尝试这些。 其它参考资料: * [ 106% TE黑波路线提要](https://docs.qq.com/sheet/DS0pndWdiR0psUE5v) 作者:liusyflat_ab ::: note 补充 值得一提的是,虽然 112% APB 和 107% AB 都是基于现版本的速通,但由于规则的细小区别,完成内容上会有些许偏差。 * 112% APB 规则上只要求击败万神殿中所有可能出现的boss,(除梦境左特外)均不要求在现实世界中击败,因此在现实世界中击败梦境boss只为了凑齐2400梦点,从而达成先知升天的完成度。所有的梦境boss和战士之梦加起来共有2700梦点,因此可以放弃一个300梦点的梦境boss,通常选择放弃灵魂暴君。梦魇之王没有精华,因此也不打,选择放逐。 * 107% AB 规则上要求击败所有boss,包括梦魇之王格林、梦境左特、灵魂暴君在内的所有梦境boss。 * 两者之间还有一个小区别:112% APB 因为从boss身上只收集2400精华,而使用梦之门会消耗一些精华,因此通常需要从低语之根中额外收集一些精华补齐。而 107% AB 会击败所有boss,因此不会缺少精华。 ::: ## 全成就 完成全部63个成就,并且通关游戏。 \~~如果你真的很享受折磨,可以尝试这个项目。~~ ## 全boss 击败所有boss,并且通关游戏。类似上面的全完成度类,也按照版本进行划分。由于是固定了版本,因此只划分了NMG、NMMS、允许任何邪道。以下是目前的分类: * 生命血:也就是CP。 * 格林剧团:比CP少了蜂巢骑士。 * 隐藏的梦:比前者少了格林剧团DLC。 * 游戏本体:比前者少了白色防御者和灰色王子左特。 生命血全boss榜单开放时间不久,参与人数不算多,感兴趣的玩家可以冲一下榜。 ## 非流程通关类 以下是一些[非流程通关类的速通项目](https://www.speedrun.com/zh-CN/hollowknight/levels),主要是一些特殊的挑战: * 愚人斗兽场三个关卡各有一个榜单 * 白色宫殿(因为白色宫殿的地图在不同版本有所调整,因此分为不同的子榜单) * 苦痛之路 * 神居的五个门各有一个榜单,每个榜单下面分为任意锁、四锁、四锁CP(1.4.3.2版本修复了在锁骨钉的情况下剑技仍能造成原本伤害的bug,因此四锁的新旧版本拆分成了不同榜单) ## 额外榜单 除了上面提到的《空洞骑士》主榜单以外,在speedrun.com上还有一个[《空洞骑士》的额外榜单](https://www.speedrun.com/zh-CN/hkmemes),里面含有一些趣味玩法: * 从一个新的存档开始(大部分榜单分为NMG、CP NMG、NMMS、允许任何邪道,少部分榜单分类不同,可前往speedrun.com查看): * **4 Mask Shards**: 收集4个面具碎片 * **Myla&**: **击杀米拉**(击败灵魂大师并获得水晶之心后,米拉会完全感染,并且可被攻击) * **NKG%**: 击败梦魇之王格林(Nightmare King Grimm) * **0 Geo**: 不获得任何吉欧通关游戏 * **3 Vessel Fragments**: 收集3个容器碎片 * **A1uba%**: **击杀一只阿鲁巴** * **Al2ba%**: 击杀两只阿鲁巴 * **All Charm Notches**: 获得所有护符槽 * **All Stag Stations**: 解锁所有鹿角虫站 * **All Whispering Roots**: 收集所有低语之根的全部482精华 * **Cartographer**: 绘图师(收集所有地图后进入伊塞尔达的商店) * **Clawless Shade Cloak**: 在无螳螂爪的情况下获得黑冲 * **Elderbug%**: 给虫长老送花 * **Elegy%**: 获得蜕变挽歌护符 * **Ghostbusters**: 鬼魂破坏者(放逐神居之外的所有鬼魂) * **Great Hopper%**: 击杀一只大跳虫 * **GPZ%**: 击败灰色王子左特(Grey Prince Zote) * **Grubsong%**: 获得幼虫之歌护符 * **Happy Couple**: 达成幸福的夫妇成就 * **Hiveblood%**: 获得蜂巢之血护符 * **Marissa Audience**: 玛丽莎的观众(与米莉贝利、4只发光子宫护符的幼体、编织者之歌、格林之子一起听玛丽莎唱歌) * **Mr. Mushroom Ending**: 达成蘑菇先生结局(时代的终结成就) * **Pantheon 5 Boss Order**: 按照圣巢万神殿的顺序击败现实世界的所有boss * **PoP%**: 通关苦痛之路(Path of Pain) * **Save Myla**: 拯救米拉(在米拉完全没有感染,也就是不击败灵魂大师且不获得水晶之心的情况下通关游戏,换句话说就是Any%的酸泪线) * **Spelless**: 无法术通关 * **Who%**: 拯救王后花园最上方的幼虫 * **Zote%**: 在愚人斗兽场击败左特 * 非流程通关类 * 新存档速通国王山道(分为无超级滑行、CP、允许超级滑行、MMS) * 非新存档速通国王山道(分为无超级滑行、允许超级滑行) * 击杀维修虫(分为无超级滑行、允许超级滑行) * 深渊攀爬 这里非常值得一提的是**击杀一只阿鲁巴**和**击杀米拉**两个项目。如果你非常想尽早得到一些上榜的成就感,又不想落下对速通的练习,可以尝试这两个项目。 * **击杀一只阿鲁巴**:包含了遗忘十字路和苍绿之径的跑图,是大部分流程速通必备的内容。 * **击杀米拉**:包含了遗忘十字路、苍绿之径、真菌荒地跑图和劈魂上蓝湖,以及水晶山峰的跑图,是大部分流程速通必备的内容。 --- --- url: /daily/hollowknight/speedrun-submit.md --- # 空洞骑士速通投稿问题 本篇主要是科普 [speedrun.com 上面《空洞骑士》游戏](https://www.speedrun.com/zh-CN/hollowknight)的投稿问题。 ::: warning 注意 速通规则一直在持续变化和完善,本文不保证永远保持最新。想要了解最即时的规则,你需要前往 [speedrun.com 的《空洞骑士》板块](https://www.speedrun.com/zh-CN/hollowknight) 查看最新变化。 ::: ## 关于NMG ::: info 关于邪道 NMG是No Major Glitches的缩写,意为无主要邪道。游戏中的大部分bug和部分影响速通平衡的特性被定义为“邪道”,这是NMG规则中不被允许的。但是,到底哪些被算作是“邪道”,这很难有一个明确的定义。因此,《空洞骑士》速通社区给出了定义,并且对有争议的内容进行了投票,最终将规则列在了 speedrun.com 上。 我将其按照自己的理解进行了描述。请注意,翻译难免有误差,精确的规则请参考 [speedrun.com 的《空洞骑士》板块](https://www.speedrun.com/zh-CN/hollowknight)。 这里,也列出几个经速通社区投票允许的“邪道”供参考: * 自己发出的白波在特定的情况下可以卡在墙上,从而可以下劈白波(也可以下劈自己的其它法术)。这可能并不是游戏设计者的本意,但在经过社区投票后,被允许在NMG规则中使用。 * 受伤瞬间靠近巴德尔长者时,巴德尔长者不会立即进入蜷曲状态,因此可以对其进行攻击,从而在不获得白波的情况下进入苍绿之径。这可能并不是游戏设计者的本意,但在经过社区投票后,被允许在NMG规则中使用。 ::: * 意外发生的故障,如果对玩家没有明显好处,是允许的。 * 任何导致小骑士无敌、隐身、穿墙、出界的BUG,都是禁止的。 * 卡主菜单BUG是禁止的。(这涉及到了另一大邪道分类:NMMS,感兴趣可以自行查看相关视频) **暂停和返回主菜单** * 暂停后取消暂停以提前获取控制权是允许的 * 返回主菜单以跳过拾取动画是允许的。 * 返回主菜单以回到上一次坐的椅子是允许的。 **下落和硬直** * 空中开背包加速下落是允许的。在当前版本空中无法开背包,但在平台边缘开始掉落的瞬间开背包加速下落也是允许的。 * 掉落在平台边缘瞬间继续从平台跳下去,这样是没有前者的落地硬直的,这也是允许的。 **战斗相关** * 打开背包状态下受到伤害,如果目的是为了取消受伤硬直,是允许的。 * 在旧版本中,在屏蔽边缘用白波攻击巴德尔长者,会将其秒杀,是允许的。 * 通过故意受伤的方式取消战吼硬直,是允许的。 * 弄坏BOSS的AI,导致BOSS不会移动或者不会攻击,是禁止的。 * 但是,在112%APB速通时,如果在万神殿里卡了大黄蜂和灵魂战士的AI,会酌情允许,但是会分别增加5秒和15秒的时间惩罚。 * 开着背包死亡,以提前获得控制权,是禁止的。 **亡者之怒** * 触发亡者之怒后,捡起护符槽导致回复满血而亡者之怒状态仍然存在,是允许的。 * 触发亡者之怒后,使用梦之门离开梦境导致回复满血而亡者之怒状态仍然存在,是禁止的。 **小技巧** * 收集战士之梦的精华时,使用梦之钉提前获得控制权,是允许的。 * 开启付钱的门、鹿角虫站时,点头或者抬头,使机关更快地激活,是允许的。 **跑图相关** * (旧版本)超级滑行是禁止的 * (旧版本)爬墙滑行是禁止的。 * (旧版本)利用游戏本身的撞刺、撞荆棘后不正确的重生位置,快速到达想要去的地方,是允许的。 * (旧版本)隔墙攻击开关、幼虫,是允许的。 * (旧版本)用背包存储冲刺劈砍的状态是允许的。 * (旧版本)利用苦痛荆棘的BUG传送,是禁止的。 * (旧版本)电梯BUG是禁止的。 **卡状态** * 空中行走、卡背包、飞天,如果是故意的,或者对玩家产生了任何好处,都是禁止的。 * 卡小地图、卡聚集状态、卡超冲状态,都是禁止的。 * (旧版本)事实上过载了但是并没有过载惩罚的BUG,是禁止的。 * (旧版本)复制护符槽、复制物品、复制房间,都是禁止的。 * (旧版本)利用吸虫之巢击杀敌人,导致算作多次击杀,是禁止的。 * 关于WSC的说明非常复杂,一句话概括就是,在当前版本不要使用,实际上也很难触发。详细说明可以看下方。 ::: details 关于WSC WSC是Wall Climb Storage的缩写,意为爬墙状态存储。通过一些操作,可以让小骑士在即使离开了墙壁的情况下,仍然保持爬墙状态,从而可以在空中多次进行冲刺,并且冲刺间隔和距离都和地面正常的冲刺间隔和距离不同。 * 在旧版本中,是允许使用WSC的。但是,仅限在当前地图的当前状态下,当你不用WSC就能达到某一个地方,你才可以用WSC快速到达这个地方以节约时间。换句话说,就是不能用WSC跳过一些流程。举个例子: * 在你没有获得超冲和二段跳的情况下,在萨鲁巴的商店门口这张地图,你可以劈魂上蓝湖。但如果地图上没有魂,你就无法上蓝湖,因此就不允许使用WSC在没有魂的情况下直接飞上蓝湖。 * 在你已经有超冲的情况下,你本来就可以从格鲁兹之母所在位置的墙上直接超冲上蓝湖,因此你也可以使用WSC直接飞上蓝湖以节约时间。 * 但也有一些例外,例如在水晶之心所在的房间,你本来就可以从上面经过激光前往水晶之心,但这里也额外规定了不允许使用WSC直接从下方飞到水晶之心。 * 在旧版本中,想稳定手动卡出WSC是非常困难的,因此速通玩家也不会刻意使用WSC。但是TAS可以轻松卡出WSC,因此你会在一些旧版本的TAS中看到大量的WSC,这在旧版本的NMG规则中是允许的。 * 在当前版本中,这个bug被修复了大半,已经很难出现意外卡出WSC的情况了,因此不允许使用WSC了。 * 但是,如果在当前版本速通过程中真的意外卡出WSC,并且玩家因此没有获得明显的好处,是允许的。 ::: ## 速通投稿 由于 speedrun.com 的汉化已经比较完善了,速通玩家都可以简单地在B站上发布速通视频后,然后在 speedrun.com 上进行投稿,因此这里就不展开介绍了。 这里主要介绍一些投稿的注意事项: 1. 视频必须是一个整P,不能分P。 2. 视频在游戏开始前,必须能看到标题界面,用来看你的版本号、有没有装API,确认你游戏是一个新开始的档。 * 对于非全关竞速(例如苦痛、万神殿),可以在开始前或结束后,二选一的地方看到标题界面即可,一般选择在结束后,方便配合使用Minisavestates。 3. 对于例如112%、107%、low%等有完成度要求的速通类型,在游戏结束之后要可以看到完成度。 4. 视频的画质要好,不能频繁掉帧,关键战斗不能掉帧。 5. 游戏音效必须能听清,游戏背景音乐不做强制要求。如果开麦或者电脑放了其他音乐,也必须保证能足够听清游戏音效。同时也不应该有令人感到非常不适的噪音。 6. 左上角的灵魂槽、生命槽、吉欧、精华不能被挡住。如果游戏中获得了蓝血,蓝血也要保证全部都能看到。 7. 必须用LiveSplit的自动计时功能,而且计时器要能看得清。要用“无加载时间”,也就是Game Time。 * 现在也允许使用另外一个计时器工具——WASM,但还是建议优先使用LiveSplit。 8. 禁止安装大部分Mod,以下除外: * 1.2.2.1和1.4.3.2允许安装调整屏幕抖动参数的mod(1.5版本已经可以通过修改游戏配置文件直接调整) * NMG和CP NMG类型速通允许使用游戏加载速度归一化补丁 * NMMS和允许任何邪道类型速通允许使用游戏加载速度变缓补丁 * 在非全关竞速中,允许使用Minisavestates(使用快捷键保存存档点,后续可以用快捷键回到存档点) 上述允许使用的Mod可以前往[github.com/hk-speedrunning/HK-Rules](https://github.com/hk-speedrunning/HK-Rules/blob/main/game-rules.md#game-modifications)获取。 ### 关于中途暂停 在大部分情况下,是不允许手动暂停计时器的。一般情况下严禁在速通过程中退出游戏。只有当游戏卡死时,或者遇到骨钉大师隐身、BOSS隐身等严重不利BUG时,才允许退出游戏重新启动,而且在这期间也不允许手动暂停计时器。 最新的速通规则会更加人性化一些,考虑到在长流程速通中,玩家难免会遇到一些意外情况,例如手机来电、家里有事等,因此允许在速通过程中手动暂停计时器,但必须遵循以下规则: * 每次暂停不得超过10分钟,超出的时间会被计入你的速通总时间。 * 只能在选择存档界面暂停: * 当你点击了主菜单的开始游戏按钮(正在进入选择存档界面),立即手动暂停计时器; * 当你准备继续游戏时,先选择存档(正在进入游戏),然后立即手动恢复计时器。 * 你可以连续多次暂停,例如暂停20分钟会消耗2次暂停次数。 * 如果审核员认为难以判断你的暂停是否合规,可能会不通过你的速通视频。 * 在暂停期间,计时器和游戏应该始终保持在视频内,禁止切屏。 * 考虑到在正常速通过程中,玩家选择存档会有些许的时间浪费,因此对于暂停而言,也会增加0.1秒的惩罚时间。 * 错误的暂停并不会导致审核不通过,但会视情况增加0.3秒或更多的惩罚时间。 下文列举了所有允许暂停计时器的速通类型(文中所说的所有“时间”均指的是计时器上的“游戏时间”,排除了“加载时间”): **全成就** * 前7小时内,最多暂停4次; * 7小时后,允许任意次数暂停。 **112% APB、107% AB、106% TE、100% TE、全boss、神居结局、流程五门** * 前3小时内,最多暂停2次; * 3小时后,允许任意次数暂停; * 愚人斗兽场内、白色宫殿内禁止暂停,进入万神殿一门后禁止暂停。 --- --- url: /daily/hollowknight/user-data.md --- # 空洞骑士存档文件结构分析 空洞骑士和丝之歌的存档文件放在打开之后是一串乱码,显然是加密过的。本文简单讲一下它使用的加密算法。 存档文件的结构如图所示: ```mermaid packet-beta 0-21: "固定头 (22字节): \"\\0\\x01\\0\\0\\0\\xFF\\xFF\\xFF\\xFF\\x01\\0\\0\\0\\0\\0\\0\\0\\x06\\x01\\0\\0\\0\"" 22-26: "内容长度 (1-5字节)" 27-94: "内容 (加密后,不定长)" 95-95: "\\x0B" ``` *** 首先是22个字节的固定头:`"\0\x01\0\0\0\xFF\xFF\xFF\xFF\x01\0\0\0\0\0\0\0\x06\x01\0\0\0"`。 *** 接下来是内容长度,用以表示内容部分的长度。它类似于 Protobuf 的 Varint(变长整型)编码方式,我们来详细讲一下。首先它是小端 (little-endian) 存储的,换句话说就是低位在前高位在后。并且它是不定长的。我们知道一个字节有8位(bits),这里最高位预留出来,只用低7位来存储数字,这样就可以存储7 bits的数字。如果这个字节没存完,后续还有更多的字节,就把预留出来的最高位标记为1即可。 为了更通俗易懂一些,我们用`65543`举个例子,`65543`的二进制表示是`10000000000000111`: 1. 最低的7位是`0000111`,因为还没完,所以最高位标记为1,得到`10000111`,用十六进制表示就是`"\x87"` 2. 接下来的7位是`0000000`,也还没完,所以最高位标记为1,得到`10000000`,用十六进制表示就是`"\x80"` 3. 接下来是`100`,因为已经结束了,所以最高位保留0,仍然是`100`,用十六进制表示就是`"\x04"` 4. 由于是小端 (little-endian) 存储的,我们低位在前高位在后拼起来,得到`"\x87\x80\x04"` 我们用Python代码描述这个内容长度(变长整型)的序列方式如下: ```python :no-line-numbers :no-collapsed-lines def generate_length_prefixed_string(length): length = min(0x7FFFFFFF, length) # 最大支持一个32位有符号整型 b = bytearray() for i in range(4): # 4+1=5个字节可以代表5*7=35位,足够了 if length >> 7 != 0: b.append(length & 0x7F | 0x80) length >>= 7 else: b.append(length & 0x7F) length >>= 7 break if length != 0: b.append(length) return b ``` *** 再接下来是加密后的存档内容。 空洞骑士和丝之歌的存档内容本身是可读性非常好的json格式,将其使用ECB(Electronic Code Book)模式的AES加密。关于AES加密很复杂,这里就不详细讲解了,各大主流语言都有标准库或第三方库支持AES加密算法,直接调用即可。 密钥是固定的`UKu52ePUBwetZ9wNX88o54dnfKRu0T1l`,空洞骑士和丝之歌都是这个密钥。 AES加密算法是以每16个字节为一个块,如果总长度不是16的整数倍,就需要进行填充,使最终的总长度变为16的整数倍。这里采用的是pkcs7规则进行填充,这个规则很简单,就是比如还差3个字节才能凑齐16的倍数,就填上3个`"\x03"`,还差4个字节就填上4个`"\x04"`,还差12个字节就填上12个`"\x0C"`,以此类推。对于恰好是16的倍数的情况下,为了避免混淆,就规定这种情况也要进行填充,类似地也填充16个`"\x10"`。 我们把填充好的数据进行ECB模式的AES加密,加密后对其进行base64编码后,填入到固定头和内容长度之后。 关于加密算法,我们以Python为例,首先安装依赖: ```bash pip install pycryptodome ``` 接下来是加密的代码(其中`data`是需要加密的内容): ```python :no-line-numbers import base64 from Crypto.Cipher import AES from Crypto.Util.Padding import pad KEY = 'UKu52ePUBwetZ9wNX88o54dnfKRu0T1l' cipher = AES.new(KEY, AES.MODE_ECB) padded = pad(data, AES.block_size) encrypted = cipher.encrypt(padded) encoded = base64.b64encode(encrypted) ``` *** 最后是一个固定的`"\x0B"`字符结尾。 *** 以上是存档文件加密的算法。想要解密,我们只需要将加密算法反过来操作,去掉固定头、内容长度、固定字符结尾后,用相同的密钥进行解密,就可以得到可读性很好的json格式的存档内容。 我用Python将其进行了实现,放在了,欢迎自取。 --- --- url: /daily/hollowknight/video.md --- # 我的空洞骑士视频汇总 ::: note 说明 以下链接全是我在哔哩哔哩上发布的关于《空洞骑士》的视频。 有一些不建议观看的视频(比如一些太长的、纯用于speedrun.com速通投稿的视频,或者纯粹的水视频,观看价值不高),没有列举在下面,如果非常想看可以前往[我的哔哩哔哩主页](https://space.bilibili.com/1415334)中去找。我的速通技术很一般,如果对**纯速通视频**感兴趣,强烈建议搜索其他高手的视频。 ::: ### 速通科普 * [2021-09-29 当你用互动视频打空洞骑士速通](https://www.bilibili.com/video/BV1w64y187Bg) * [2021-10-10 【空洞骑士】普通人也能看懂的流程速通科普](https://www.bilibili.com/video/BV1WP4y1t7WJ) * [2021-11-08 空洞骑士速通课堂——Any%篇](https://www.bilibili.com/video/BV18q4y137jd) * [2021-11-27 空洞骑士速通课堂——全技能篇](https://www.bilibili.com/video/BV1634y197YA) ### 速通计时器相关 ::: warning 注意 这一些速通计时器相关的视频,因为当初技术有限,个人觉得做的不太好,其实直接看我的这篇文章[空洞骑士计时器生成器FAQ](hksplitmaker-faq.md)会更加清晰。 ::: * [2021-09-04 空洞骑士速通计时器LiveSplit使用教程](https://www.bilibili.com/video/BV1jq4y1S7YA) * [2021-09-25 \[空洞骑士\]中文版速通计时器生成器使用教程](https://www.bilibili.com/video/BV1fL4y187sN) * [2021-11-20 空洞骑士计时器生成器(中文版)](https://www.bilibili.com/video/BV1cq4y1g7Sy) * [2021-12-18 空洞骑士计时器LiveSplit汉化——万物皆可汉化](https://www.bilibili.com/video/BV1h34y197eW) ### 空洞骑士杂谈 * [2021-08-30 【空洞骑士】112%完成度包含哪些(近30万播放)](https://www.bilibili.com/video/BV1Cy4y1G7Yy) * [2021-09-15 纯粹萌新都能学会的空洞骑士流程BOSS攻略](https://www.bilibili.com/video/BV1oM4y1G7LW) * [2021-10-27 来看看空洞新版本的丝滑设置吧](https://www.bilibili.com/video/BV1JF411a7o2) --- --- url: /daily/hollowknight/wasm.md --- wasm版计时器支持Windows、Mac、Linux等多个平台运行,其开发者以及本文的原作者为[AlexKnauth](https://github.com/AlexKnauth),本文中的“我”均指代文章原作者。你可以[点击这里跳转到原文](https://github.com/AlexKnauth/hollowknight-autosplit-wasm/blob/master/README.md) 。 ::: warning 注意 如果在Windows电脑速通空洞骑士和丝之歌,还是强烈建议直接使用 LiveSplit,具体参考[空洞骑士计时器生成器FAQ](hksplitmaker-faq.md)、[丝之歌计时器生成器FAQ](../silksong/sssplitmaker-faq.md)。 ::: 你可以前往[空洞骑士wasm版计时器的Github下载页](https://github.com/AlexKnauth/hollowknight-autosplit-wasm/releases/latest)、[丝之歌wasm版计时器的Github下载页](https://github.com/AlexKnauth/silksong-autosplit-wasm/releases/latest)下载 `.wasm` 文件。保存好这个 `.wasm` 文件,在下文中会被使用。 ## LiveSplit Windows 版使用说明 原版 LiveSplit 仅支持 **Windows** 系统。若您使用 Mac 或 Linux,请参阅下文的其他选项。 LiveSplit 通过布局文件 (`.lsl`) 连接此自动切分器及其设置。请确保为此自动切分器使用的每一个不同的分段文件都配置一个独立的布局副本。 右键点击 → `Edit Layout...`,您将看到布局编辑器,其中包含如 `Title`、`Splits`、`Timer` 等组件。若其中没有名为 `Auto Splitting Runtime` 的组件,请通过 `+` 添加按钮 → `Control` → `Auto Splitting Runtime` 进行添加。添加后,点击 `Layout Settings` → `Auto Splitting Runtime`,在 `Script Path` 旁点击 `Browse...`,然后找到并选择前面下载的 `.wasm` 文件。随后点击 `Import Splits` 并选择您的分段文件。 点击 `Ok`,并通过 `Save Layout As...` 保存此布局,为其命名时应体现您正在运行的具体分段内容。 停用现有的 Hollow Knight 自动切分器:右键点击 → `Edit Splits...`,在 `Configurable Load Remover / Auto Splitter. (By DevilSquirrel)` 旁点击 `Deactivate`。 然后通过您刚才保存的布局文件添加此自动切分器。在同一个分段编辑器(通过右键点击 → `Edit Splits...` 打开)中,在原来显示 `Configurable Load Remover / Auto Splitter. (By DevilSquirrel)` 的位置下方,勾选 `Use Layout` 复选框,点击旁边的 `Browse`,找到并选择之前的布局文件。选择后点击 `Ok`。 最后,在使用此自动切分器运行时,请勿手动切分或跳过,除非该节点明确标记为 `ManualSplit` 或是最终节点。在任何其他情况下,请不要手动切分、跳过或撤销分段。自动切分器将无法感知您的这些操作,其状态会与 LiveSplit 的状态失去同步。 ## OBS LiveSplit One 使用说明 OBS LiveSplit One 在 **Windows** 或 **Linux** 系统上运行效果最佳。若您使用 Mac,我推荐使用下文所述的 [LiveSplit One Druid](#instructions-for-livesplit-one-druid)。 请确保使用 `obs-livesplit-one` 的 v0.3.5 或更高版本。 前往 [obs-livesplit-one 发布页面](https://github.com/LiveSplit/obs-livesplit-one/releases),在 `Assets` 部分下载与您系统架构和操作系统相匹配的文件。请按照 `obs-livesplit-one` 自述文件中[`How to install`](https://github.com/LiveSplit/obs-livesplit-one/blob/master/README.md#how-to-install)部分的说明进行操作。在 Windows 系统上,请将 `obs-livesplit-one.dll` 解压至 `C:\Program Files\obs-studio\obs-plugins\64bit` 或`C:\Program Files (x86)\obs-studio\obs-plugins\64bit` 目录。 在 OBS 中添加 Livesplit One 来源。 属性设置: * Splits: 选择您的分段文件 * Use local autosplitter: 勾选 * Local Auto Splitter file: 选择前面下载的 `.wasm` 文件 * Custom auto splitter settings: 选择 `Import Splits` * Select a file: 选择您的分段文件 运行时,OBS 需要具有读取其他进程内存的权限。 * 在 Mac 上,我尚未找到完善的实现方法。我*不推荐*使用 `sudo` 权限运行 OBS。 * 在 Linux 上,可通过以下方式之一授予权限: * 将 `/proc/sys/kernel/yama/ptrace_scope` 的值设置为 0,可通过命令 `echo "0"|sudo tee /proc/sys/kernel/yama/ptrace_scope` 实现 * 设置包含 `CAP_SYS_PTRACE` 的能力值,可通过命令 `sudo setcap CAP_SYS_PTRACE=+eip /usr/bin/obs` 或其变体实现 * 在 Windows 上,此功能应可直接使用。Windows 默认允许读取内存。 ## LiveSplit One Druid 使用说明 主仓库 `livesplit-one-druid` 的版本可能无法满足运行此自动切分器的新度要求。 * 若 包含 2023 年 12 月或之后的提交,则通常可满足要求。 * 但如果最新提交仍为 2023 年 4 月,则需要使用更新版本,例如我的复刻版本 。 安装复刻版本:请访问 [AlexKnauth LiveSplit One Druid 最新发布页](https://github.com/AlexKnauth/livesplit-one-druid/releases/latest),在 `Assets` 部分下载适用于您系统架构和操作系统的版本。 运行时,LiveSplit One Druid 需要具有读取其他进程内存的权限。 * 在 Mac 上,可能需要使用 `sudo` 权限运行。 * 在 Linux 上,可通过以下方式之一授予权限: * 将 `/proc/sys/kernel/yama/ptrace_scope` 的值设置为 0,可通过命令 `echo "0"|sudo tee /proc/sys/kernel/yama/ptrace_scope` 实现 * 设置包含 `CAP_SYS_PTRACE` 的能力值,可通过命令 `sudo setcap CAP_SYS_PTRACE=+eip LiveSplitOne` 或其变体实现 * 使用 `sudo` 权限运行 * 在 Windows 上可直接使用,系统默认允许读取内存。 通过右键点击或 Control 点击唤出上下文菜单: * Splits, Open...:选择您的 `.lss` 分段文件 * Layout, Open...:对于布局文件,建议为 LiveSplit One Druid 使用 `.ls1l` 格式文件而非 `.lsl` 文件。您可通过 LiveSplit One 网页版 创建 `.ls1l` 文件,或使用本资源库内置的 `layout-lso.ls1l` 文件作为起点 * Open Auto-splitter...:选择前面下载的 `.wasm` 文件 * Compare Against:选择 Game Time * Settings:配置所需的热键 最后请注意:在使用此自动切分器运行时,请勿手动切分或跳过,除非该节点明确标记为 `ManualSplit` 或是最终节点。在任何其他情况下,请不要手动切分、跳过或撤销分段。自动切分器将无法感知这些操作,其状态会与 LiveSplit One Druid 的状态失去同步。 ### 仅限 LiveSplit One Druid 的不稳定版本 如果您计划仅在 LiveSplit One Druid 0.4.1 或更高版本中使用此自动切分器,可以从[最新发布版](https://github.com/AlexKnauth/hollowknight-autosplit-wasm/releases/latest)下载 `hollowknight_autosplit_wasm_unstable.wasm` 文件来替代稳定版本。 此不稳定版本在 LiveSplit One Druid 中支持手动切分/跳过/撤销操作,但若尝试在其他计时器中使用将会崩溃。 ## livesplit-one-desktop 使用说明 请注意:主仓库 `livesplit-one-desktop` 的版本可能无法满足运行此自动切分器的新度要求。 * 若 包含 2023 年 12 月或之后的提交,则通常可满足要求。 * 但如果最新提交仍为 2023 年 7 月,则需要使用更新版本,例如我的复刻版本 。 您可以通过以下命令克隆我的复刻版本: ```sh git clone https://github.com/AlexKnauth/livesplit-one-desktop.git ``` 根据 [`rust_minifb` 构建说明](https://github.com/emoon/rust_minifb#build-instructions),在 Linux 系统上可能需要先安装这些依赖项: ```sh sudo apt install libxkbcommon-dev libwayland-cursor0 libwayland-dev ``` 在 `livesplit-one-desktop` 代码库中,修改 `config.yaml` 文件内容为: ```yaml :no-line-numbers # [!code word:<分段文件路径.lss>] # [!code word:<.wasm 文件路径>] general: splits: <分段文件路径.lss> timing-method: GameTime auto-splitter: <.wasm 文件路径> ``` 请将 `<分段文件路径.lss>` 替换为您的分段文件实际路径,并将 `<.wasm 文件路径>` 替换为前面下载的 `.wasm` 文件的实际路径。 如需配置布局文件,请将 `config.yaml` 文件修改为: ```yaml # [!code word:<布局文件路径.lsl>] general: layout: <布局文件路径.lsl> ``` 并将 `<布局文件路径.lsl>` 替换为您的布局文件路径。`livesplit-one-desktop` 支持使用 `.lsl` 或 `.ls1l` 格式的布局文件。 配置热键时,请将 `config.yaml` 文件修改为: ```yaml :no-line-numbers hotkeys: split: Numpad1 reset: Numpad3 undo: Numpad8 skip: Numpad2 pause: null undo_all_pauses: null previous_comparison: Numpad4 next_comparison: Numpad6 toggle_timing_method: null ``` 您可以根据 [`livesplit_hotkey::KeyCode`](https://docs.rs/livesplit-hotkey/latest/livesplit_hotkey/enum.KeyCode.html) 中的选项替换这些热键值。 运行 `livesplit-one-desktop` 时需要具有读取其他进程内存的权限。在 Mac 上可能需要使用 `sudo` 权限运行,例如在 `livesplit-one-desktop` 代码库中执行: ```sh cargo build --release sudo ./target/release/livesplit-one ``` 最后请注意:在使用此自动切分器运行时,请勿手动切分或跳过,除非该节点明确标记为 `ManualSplit` 或是最终节点。在任何其他情况下,请不要手动切分、跳过或撤销分段。自动切分器将无法感知这些操作,其状态会与 `livesplit-one-desktop` 的状态失去同步。 `livesplit-one-desktop` 的热键绑定基于 Qwerty 键盘布局设计,因此您可能需要按 Qwerty 布局中对应键位的位置进行操作。例如保存分段的快捷键在 Qwerty 布局中是 "Control S",但在 Dvorak 键盘上,Qwerty 的 "S" 键位对应 Dvorak 的 "O" 键,因此需要使用 Dvorak 布局的 "Control O" 进行保存。 在 Mac 上保持窗口在全屏游戏上方的方法:开始拖动 livesplit-one-desktop 窗口,在拖动过程中使用多指滑动手势切换到全屏游戏界面,然后停止拖动。只要之后不再进行任何屏幕切换操作,窗口将暂时保持在全屏游戏上方。 --- --- url: /daily/math/index.md --- # 数学随笔 --- --- url: /daily/math/bo3.md --- # 五局三胜问题 有这样一个问题: 甲乙两人进行五局三胜的比赛,假设两人实力相当,每一局获胜的概率相同。现在第一局已经结束,乙获得了胜利。请问,剩余4局结束后,甲最终获得五局三胜比赛胜利的概率是多少? 其实很简单,剩余4局一共有 $2^4 = 16$ 种可能,直接穷举就可以了。 下图用 ⭕ 表示甲胜,✖️ 表示乙胜: | 2 | 3 | 4 | 5 | 总 | |:--:|:--:|:--:|:--:|:--:| | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | | ⭕ | ⭕ | ⭕ | ✖️ | ⭕ | | ⭕ | ⭕ | ✖️ | ⭕ | ⭕ | | ⭕ | ⭕ | ✖️ | ✖️ | ✖️ | | ⭕ | ✖️ | ⭕ | ⭕ | ⭕ | | ⭕ | ✖️ | ⭕ | ✖️ | ✖️ | | ⭕ | ✖️ | ✖️ | ⭕ | ✖️ | | ⭕ | ✖️ | ✖️ | ✖️ | ✖️ | | ✖️ | ⭕ | ⭕ | ⭕ | ⭕ | | ✖️ | ⭕ | ⭕ | ✖️ | ✖️ | | ✖️ | ⭕ | ✖️ | ⭕ | ✖️ | | ✖️ | ⭕ | ✖️ | ✖️ | ✖️ | | ✖️ | ✖️ | ⭕ | ⭕ | ✖️ | | ✖️ | ✖️ | ⭕ | ✖️ | ✖️ | | ✖️ | ✖️ | ✖️ | ⭕ | ✖️ | | ✖️ | ✖️ | ✖️ | ✖️ | ✖️ | 统计一下:共计16种情况,甲最终获胜的情况有5种,所以甲获胜的概率是 $\dfrac{5}{16}$。 *** 接下来,我们再进一步考虑一下:如果甲乙两人实力不相当,例如每一局比赛中,甲获胜的概率是 $\dfrac{2}{3}$,乙获胜的概率是 $\dfrac{1}{3}$。现在第一局已经结束,乙获得了胜利。那么剩余4局结束后,甲最终获得五局三胜比赛胜利的概率是多少呢? 其实不难,我们依旧观察上面的表格可以得出,这5种甲获胜的情况分别是:甲连胜4局(共一种情况),甲胜3局乙胜1局(共四种情况)。而它们的概率分别是: * 甲连胜4局:$\left(\dfrac{2}{3}\right)^4 = \dfrac{16}{81}$ * 甲胜3局乙胜1局:$\left(\dfrac{2}{3}\right)^3 \times \left(\dfrac{1}{3}\right)^1 = \dfrac{8}{81}$ 因此最终甲获胜的概率是:\ $$ \dfrac{16}{81} + 4 \times \dfrac{8}{81} = \dfrac{48}{81} = \dfrac{16}{27} $$ *** 那我们再换一个题目:如果甲乙两人实力不相当,例如每一局比赛中,甲获胜的概率是 $P$,乙获胜的概率是 $1-P$。现在第一局已经结束,乙获得了胜利。如果剩余4局结束后,甲乙最终获得五局三胜比赛胜利的概率相同,那么 $P$ 的值是多少呢? 同样很简单,我们按照上面的思路来,可以得到这样一个方程:\ $$ P^4 + 4P^3(1-P) = \dfrac{1}{2} $$ 这个方程解起来较为复杂,直接使用牛顿迭代法逐步逼近求解。方程在 $(0, 1)$ 区间的唯一解是 $P \approx 0.614$,也就是当甲每局获胜的概率约为61.4%时,剩余四局结束后,甲乙最终赢得五局三胜比赛的概率相同。 --- --- url: /daily/math/duck.md --- # 池塘问题 有这样一个问题: 4条小鱼在一个大的圆形水池中,分别随机的出现在圆圈中的任意一点。4条小鱼出现在同一个半圆的概率是多少? ![duck.svg =x300](/math/duck.svg) ::: warning 注意 以下内容含有大量剧透。 如果对题目感兴趣的话,可以先思考一下再往下看。 ::: 作为一个程序员,我在毫无思路的情况下首先想到的方法就是用代码模拟: ```java ::collapsed-lines=7 public class Test { private static Random random = new Random(); public static void main(String[] args) { int count = 0; for (int i = 0; i < 100000000; i++) if (check(random(), random(), random(), random())) count++; System.out.println(count); } private static boolean check(Point a, Point b, Point c, Point d) { if (a.x == 0 && a.y == 0 || b.x == 0 && b.y == 0 || c.x == 0 && c.y == 0 || d.x == 0 && d.y == 0) return true; // 有一个点在圆心,概率极低,忽略 // 如果存在一个点,过圆心和这个点做连线,另外三个点在连线的同一边,说明在同一个半圆 return check2(a,b,c,d) || check2(b,a,c,d) || check2(c,a,b,d) || check2(d,a,b,c); } private static boolean check2(Point a, Point b, Point c, Point d) { // 向量叉乘,右手定则,正或负代表顺时针或逆时针 double[] result = {b.x * a.y - b.y * b.x, c.x * a.y - c.y * a.x, d.x * a.y - d.y * a.x}; return result[0] > 0 && result[1] > 0 && result[2] > 0 || result[0] < 0 && result[1] < 0 && result[2] < 0; } private static Point random() { while (true) { double x = 1 - random.nextDouble() * 2; double y = 1 - random.nextDouble() * 2; if (x * x + y * y <= 1) return new Point(x, y); } } } ``` 总共模拟了1亿次,最终共半圆的次数基本在!!4999万到5001万之间,说明概率应该是1/2!!。 ## 数学原理 那么到底是为什么呢?于是我深入思考了一下: 首先,小鱼随机分布在池塘的一个点,这个问题太复杂了。由于池塘是个圆形,要求小鱼在同一个半圆,其实与小鱼距离圆心的距离是没有关系的,只与小鱼在哪条半径上(换句话说就是所在位置的角度)有关。因此,这个问题我们可以等价地转化为**小鱼分布在池塘的周长上**。 经过这样一番简化以后,上述的问题就简单多了,甚至可以推广到N条小鱼。 接下来,我们随机放入第一条小鱼,它的位置是随机的。在第一条小鱼的位置已经确定了的情况下,后面每一条小鱼的位置,要么在第一条小鱼的顺时针方向0-180°之间,要么在第一条小鱼的逆时针方向0-180°之间。那么剩下(N-1)条小鱼全部在顺时针方向0-180°之间的概率就是$\dfrac{1}{2^{N-1}}$。 由于我们是随机取的第一条小鱼,那么每一条小鱼都有可能是第一条小鱼,所以我们需要乘以N。最后的结果就是:$\dfrac{N}{2^{N-1}}$。 我们再来理一下思路:每一条小鱼都可能是这个半圆中**最逆时针**方向的那条小鱼,剩下(N-1)条小鱼都是在它的**顺时针0-180°之间**,所以上面这个推断是没有问题的。 将$N=4$代入上面的公式,得到的结果就是:$\dfrac{N}{2^{N-1}}=\dfrac{4}{2^{3}}=\dfrac{1}{2}$。 这与上面用代码随机模拟的结果是一致的。 为了验证这个公式的正确性,我用代码测试了一下3条小鱼和5条小鱼的情况,结果都和公式一致: 当$N=3$时,$\dfrac{N}{2^{N-1}}=\dfrac{3}{2^{2}}=\dfrac{3}{4}$ 当$N=5$时,$\dfrac{N}{2^{N-1}}=\dfrac{5}{2^{4}}=\dfrac{5}{16}$ 这里就不再展示验证代码了。 --- --- url: /daily/math/function-chess.md --- # 函数棋 函数棋:。规则很简单,在直角坐标系上,系统给你一些绿格和红格,用给定的符号(四则运算、乘方、绝对值、$\sin$、$\cos$、$\tan$、$\sqrt x$、$\ln$等),构造一个函数,使其穿过所有的绿格,不穿过任何红格。 这个小游戏主要还是锻炼大家的思维能力,所以一般而言,还是应该通过观察、目测、联想的方法去推导出答案。但是,其中不乏有一些离谱的题目,例如绿格被红格团团围住,堪称“无解”。因此,我们可以考虑利用一些“不讲武德”的手段来解决这些问题。 下面,就来推导一下这个“不讲武德”的手段。 *** 我们知道,对于它给出的任何一道题目,无论绿格、红格如何分布,理论上我们都能用分段函数强行表示。就以第一题为例,它是一个左下角在原点的边长为1的正方形绿格,正常来讲,出题者希望我们用 $y=x$ 或者 $y=0.5$ 来表示。但我们也不是不可以强行用分段函数来表示: $$ y = \begin{cases} 0, & x < 0.1 \\ 0.5, & 0.1 \le x \le 0.9 \\ 0, & x > 0.9 \end{cases} $$ 特别地,当同一纵列有多个绿块时(也就是同一个x值有多个y值对应的绿块时),我们也可以更加细分多拆几次,例如拆成 $0.1 \lt x \lt 0.5$ 和 $0.5 < x \lt 0.9$ 两个区间。 而任何有限分段函数(即定义域被分成有限个区间,每个区间上由初等函数定义)都可以用绝对值、四则运算以及初等函数表示成一个不含显式分段的单一表达式。核心在于用绝对值构造 **最大值** 和 **最小值** 函数: * $\max(a, b) = \dfrac{a + b + |a - b|}{2}$ * $\min(a, b) = \dfrac{a + b - |a - b|}{2}$ 通过嵌套 $\max$ 和 $\min$,可以实现任意有限个条件分支。例如,一个三段函数: $$ y = \begin{cases} f_1(x), & x \le a \\ f_2(x), & a < x \le b \\ f_3(x), & x > b \end{cases} $$ 可以写成: $$ f(x) = f_1(x) \cdot I\_{x\le a} + f_2(x) \cdot I\_{a\b} $$ 指示函数 $I\_{x\le a} = \dfrac{1}{2}\left(1 - \operatorname{sgn}(x-a)\right)$,其中 $\operatorname{sgn}(x) = \dfrac{x}{|x|}$。 不过需要注意的是,这样得到的表达式在分段点处可能无定义(分母为零)。在函数棋这类游戏中,由于格子是开区间或闭区间,这些奇点往往可以被忽略(因为函数不需要在精确的边界点取值)。 综上,既然任何一道题目可以用分段函数表示,而只要允许使用绝对值,就能构造出所有有限分段函数,因此理论上我们就可以用“暴力破解”的方法解决所有函数棋的问题。 那么不能使用绝对值怎么办?实际上,我们可以用各种各样的方法构造分段函数: $$ \operatorname{sgn}(x)=\dfrac{x}{|x|}=\dfrac{x}{\sqrt{x^2}}=x \cdot \left(x^2\right)^{i \cdot i \cdot 2^{i \cdot i}}=x|x|^{i \cdot i} $$ 不过,尽管我们可以强行用这种方法进行“暴力破解”,但设计者的初衷还是希望大家找到简洁美观的“特解”,从而感受函数表达式的魅力。 --- --- url: /daily/math/gandi.md --- # 名侦探甘迪问题 一切的一切,要源于《冒险岛Online》在2018年推出的一个小游戏——名侦探甘迪: > 游戏开始时,一群玩家会被传送进一个房间。此时,系统会在1-9九个数字中随机选择三个数字按某个顺序排列,作为答案。 > > 每一轮,玩家需要按顺序猜测3个数字,系统会给出反馈,告知玩家有几个数字猜对了并且位置正确(用○表示),有几个数字猜对了但位置不正确(用△表示)。 > > 举个例子:玩家猜测1-2-3三个数字,系统给出反馈1○1△,表示有一个数字猜对了并且位置正确,有一个数字猜对了但位置不正确。可能是1猜对了且位置正确,2猜对了但位置不正确,答案中没有3,当然也有别的可能。 > > 玩家可以根据反馈来继续推测答案。所有玩家独立完成游戏,不能看到其它玩家的选择及反馈。玩家之间互为竞争对手,目标就是用最少的次数猜出答案。 问题就来了,如何设计策略,让我们在尽可能少的次数内猜出答案呢? 这是一个十分经典的策略问题。 ## 信息熵 信息熵是信息论中的一个核心概念,由克劳德·香农(Claude Shannon)于1948年提出,用于**量化信息的不确定性**或**随机性**。它衡量的是一个系统或随机事件中“不可预测性”的程度。 我们用一个简单的例子做比方:假设我们投掷一枚硬币,有50%的几率正面朝上,50%的几率反面朝上。我们事先不知道结果是什么,这就是一个不确定的事件。当我们投掷硬币之后,得到的结果就是一个确定的事件,此时只有正面或反面两种可能性,我们用0和1表示。0和1就是二进制中的1位,用信息学术语就是1比特(bit)。 那么我们换成一个八面骰子,我们投掷它,得到的结果是1-8中的一个数字,用二进制表示就是3位(000~111),也就是3比特(bit),用数学式子表示就是 $log_2 8 = 3 \text{ bits}$。 以上是我们在所有情况发生的概率相同时的结果。接下来考虑概率不同的情况: 如果我们投掷一枚硬币,现在来猜硬币到底是立起来还是倒下去的,假设立起来的概率是0.1%,倒下去的概率是99.9%。如果我投掷了一次硬币,发现硬币倒下去了,我并不会感到意外,因为我没有得到什么信息。如果我投掷了一次硬币,发现硬币立起来了,我会非常惊讶,因为我得到了大量信息。怎么用数学来量化这种信息呢? 既然立起来的概率是0.1%,换句话说,我们投掷1000次硬币,才有可能得到一次立起来的结果。我这一次投掷,硬币立起来了,让我需要进行1000次尝试的结果在这一次实现了,我获得了 $log_2 1000 \approx 9.97 \text{ bits}$ 的信息,其实就是概率的倒数的对数。我们用 $P(x_i)$ 表示得到 $x_i$ 结果的概率,那么信息量就是 $\log_2 \dfrac{1}{P(x_i)} = -\log_2{P(x_i)}$。 用同样的方式,我们可以得出,如果硬币倒下了,我们获得的信息量就是:$-\log_2 0.999 \approx 0.0014 \text{ bits}$,确实获得的信息量很少。 考虑到这两者发生的概率是不同的,我们在投掷硬币之前,可以计算一个数学期望,也就是它们的**加权平均值**:$9.97 \times 0.1% + 0.0014 \times 99.9% \approx 0.0114 \text{ bits}$,这就是我们这次投掷硬币所获得的信息量的数学期望。 我们管一个概率事件获得信息量的数学期望就叫做**信息熵**,**信息熵**越大则结果越不确定。通过上面的例子我们发现,想要求**信息熵**,直接求 $-\log_2{P(x_i)}$ 的加权平均值即可,因此可以得到**信息熵**的公式为: $$ H(X) = -\sum\_{i=1}^{n} P(x_i) \log_2 P(x_i) $$ **符号解释**: * $X$:一个随机变量(例如天气、抛硬币的结果)。 * $P(x_i)$:事件$x_i$发生的概率(例如“晴天”的概率是0.3)。 * $\log_2$:以2为底的对数,单位是**比特(bit)**(也可用自然对数,单位是纳特)。 我们来代入之前的硬币正反面的例子:$H(X) = -\left(0.5 \log_2 0.5 + 0.5 \log_2 0.5\right) = 1 \text{ bit}$,和我们之前的定义吻合,这个定义是没有问题的。 **信息熵**有这样一些性质: * 当所有可能事件的概率相等时,熵最大。*(例如:猜硬币正反面,信息熵大)* * 当某个事件概率趋近于1时,熵趋近于0。*(例如:猜硬币是立着还是倒着,几乎不可能是立着,信息熵趋近于0)* ## 最大化信息熵策略 让我们回到名侦探甘迪问题。在这个游戏中,我们想要用尽可能少的次数猜出答案,就要让每次猜测都能获得尽可能多的信息,换句话说就是每次猜测选择信息熵最大的策略,即**最大化信息熵**策略。 举个例子,首轮我们猜测1-2-3,得到1○1△的反馈。我们可以排除大部分可能性,还剩下36种可能(1-3-4、1-3-5、1-3-6、……、9-2-1)。接下来,我们依次计算每种猜测的信息熵。举两个例子: * 猜测1-4-5,可能的反馈有: * 0○0△:有8种可能,概率为8/36 * 0○1△:有10种可能,概率为10/36 * 0○2△:有4种可能,概率为4/36 * 1○0△:有10种可能,概率为10/36 * 1○1△:有2种可能,概率为2/36 * 2○0△:有2种可能,概率为2/36 因此信息熵为: $$ H = -\left(\dfrac{8}{36} \log_2 \dfrac{8}{36} + \dfrac{10}{36} \log_2 \dfrac{10}{36} + \dfrac{4}{36} \log_2 \dfrac{4}{36} + \dfrac{10}{36} \log_2 \dfrac{10}{36} + \dfrac{2}{36} \log_2 \dfrac{2}{36} + \dfrac{2}{36} \log_2 \dfrac{2}{36}\right) \approx 2.32 \text{ bits} $$ * 猜测4-5-6,可能的反馈有: * 0○0△:有18种可能,概率为18/36 * 0○1△:有12种可能,概率为12/36 * 1○0△:有6种可能,概率为6/36 因此信息熵为: $$ H = -\left(\dfrac{18}{36} \log_2 \dfrac{18}{36} + \dfrac{12}{36} \log_2 \dfrac{12}{36} + \dfrac{6}{36} \log_2 \dfrac{6}{36}\right) \approx 1.46 \text{ bits} $$ 前者比后者的信息熵更大,因此我们选择前者进行猜测更优。 以上只列举了两种猜测的例子,我们只需要遍历所有的猜测(一共$A_9^3=504$种),计算每种猜测的信息熵,选择信息熵最大的进行猜测即可。 ### 代码实现 ::: code-tabs @tab TypeScript ```ts :no-collapsed-lines {43} // compare 函数用于比较两个结果的差异,返回○和△值 const compare = (result1: number[], result2: number[]): [number, number] => { let count1 = 0; let count2 = 0; for (let i = 0; i < result1.length; i++) { for (let j = 0; j < result2.length; j++) { if (result1[i] === result2[j]) { if (i === j) count1++; else count2++; } } } return [count1, count2]; }; // 首轮猜测1-2-3,反馈1○1△ const maybeResult: [number, number, number][] = []; for (let a = 1; a <= 9; a++) { for (let b = 1; b <= 9; b++) { for (let c = 1; c <= 9; c++) { if (a === b || a === c || b === c) continue; const [count1, count2] = compare([1, 2, 3], [a, b, c]); if (count1 === 1 && count2 === 1) maybeResult.push([a, b, c]); } } } // 这时 maybeResult 应该只有36种可能 let maxH = 0.0; // 最大信息熵 let maxHResults: [number, number, number][] = []; // 最大信息熵对应的选项 // 计算每种猜测的信息熵 for (let a = 1; a <= 9; a++) { for (let b = 1; b <= 9; b++) { for (let c = 1; c <= 9; c++) { if (a === b || a === c || b === c) continue; const counts = {}; // 统计每种反馈的出现次数 for (const i in maybeResult) { const [count1, count2] = compare([a, b, c], maybeResult[i]); counts[`${count1},${count2}`] = (counts[`${count1},${count2}`] || 0) + 1; } let H = 0; // 信息熵 for (const i in counts) { const p = counts[i] / maybeResult.length; H += p * Math.log2(i.startsWith("3") ? 1/p + 10 : 1/p); } if (Math.abs(H - maxH) < 0.0001) { // 浮点数精度 maxHResults.push([a, b, c]); } else if (H > maxH) { maxH = H; maxHResults = [[a, b, c]]; } } } } console.log(maxH); console.log(maxHResults); ``` @tab Go ```go :no-collapsed-lines {52-56} // compare 函数用于比较两个结果的差异,返回○和△值 func compare(result1, result2 [3]int) (int, int) { var count1, count2 int for i := range 3 { for j := range 3 { if result1[i] == result2[j] { if i == j { count1++ } else { count2++ } } } } return count1, count2 } func main() { // 首轮猜测1-2-3,反馈1○1△ var maybeResult [][3]int for a := 1; a <= 9; a++ { for b := 1; b <= 9; b++ { for c := 1; c <= 9; c++ { if a == b || a == c || b == c { continue } count1, count2 := compare([3]int{1, 2, 3}, [3]int{a, b, c}) if count1 == 1 && count2 == 1 { maybeResult = append(maybeResult, [3]int{a, b, c}) } } } } // 这时 maybeResult 应该只有36种可能 var maxH float64 // 最大信息熵 var maxHResults [][3]int // 最大信息熵对应的选项 // 计算每种猜测的信息熵 for a := 1; a <= 9; a++ { for b := 1; b <= 9; b++ { for c := 1; c <= 9; c++ { if a == b || a == c || b == c { continue } counts := make(map[[2]int]int) // 统计每种反馈的出现次数 for i := range maybeResult { count1, count2 := compare([3]int{a, b, c}, maybeResult[i]) counts[[2]int{count1, count2}]++ } var H float64 // 信息熵 for i := range counts { var p = float64(counts[i]) / float64(len(maybeResult)) if i[0] == 3 { H += p * math.Log2(1/p + 10) } else { H += p * math.Log2(1/p) } } if math.Abs(H-maxH) < 0.0001 { // 浮点数精度 maxHResults = append(maxHResults, [3]int{a, b, c}) } else if H > maxH { maxH = H maxHResults = [][3]int{{a, b, c}} } } } } fmt.Println(maxH) fmt.Println(maxHResults) } ``` ::: 注意我们代码中的高亮行,如果某个猜测的反馈结果中有3个○,也就是完全正确,我会将它的信息量增加一个固定的值。为什么要这样做呢?考虑这样一种情况,假设我们的某个猜测不是正确答案,但是在这个猜测之后已经确定了正确答案,接下来我们仍然要浪费1次次数去猜测正确答案,所以理应给直接猜中正确答案增加一些信息量加以奖励。增加的数字`10`是测试之后得到的一个经验值,最终测试次数的数学期望最小。你可以根据实际情况调整这个经验值。 上面的代码可以得到这样的结果:在第一轮得到1○1△的反馈后,只需要固定一个数字作为1○,并从4~9中选择另外两个数字进行猜测,就可以获得最大信息熵,约为2.32。上文列举的1-4-5即为其中之一猜测方案。 接下来,我们每一轮只需要根据反馈缩小可能的范围,再遍历一次找到最大信息熵,这就是**最大化信息熵**的策略。 这种策略的预期猜测次数如下图所示: ::: chartjs ```json { "type": "bar", "data": { "labels": ["1", "2", "3", "4", "5", "6"], "datasets": [ { "label": "概率(%)", "data": [0.20, 1.19, 6.35, 23.81, 59.13, 9.33] } ] }, "options": { "scales": { "x": { "title": { "display": true, "text": "猜测次数" }, "grid": { "display": false } }, "y": { "title": { "display": true, "text": "概率(%)" }, "beginAtZero": true, "border": { "display": false } } }, "plugins": { "legend": { "display": false } } } } ``` ::: 可以确保在6次之内猜中,大概率在5次之内,数学期望约为4.7次。 完整的代码实现可以参考这里: ## 拓展问题 我们来看一个类似的问题:给你9颗小石头和一架没有砝码的天平,其中有8颗石头重量一样,另外一个比这8颗略重。请问在最坏情况下,最少要称重几次,才能把这颗较重的石头找出来? 回顾一下**信息熵**的性质:当所有可能事件的概率相等时,熵最大。 !!我们只需要将9颗石头分成3组,每组3颗,选其中的两组称重一次,有三种情况,左边重,右边重,或者两边一样重,概率都是1/3,**信息熵**最大,这样一次就可以求出较重的石头在哪三颗中。接下来我们如法炮制,取这三颗石头中的任意两颗称重一次,即可得到答案。!! --- --- url: /daily/math/hok.md --- # 如何反推胜率 假设某个游戏,已知各个角色的总胜率为 $r\_\text{总}$,出场率为 $p\_\text{总}$。在高分段的对局中,总胜率和出场率分别为 $r\_\text{高}$ 和 $p\_\text{高}$。如果我们想要反向推导出所有角色在低分段的胜率和出场率 $r\_\text{低}$ 和 $p\_\text{低}$,可以这样推导: 首先,我们可以假设高分段的场次占总场次的占比为 $f$(例如20%),则低分段的场次占比为 $(1 - f)$(例如80%)。 由于$\text{场次}*\text{总}\times\text{出场率}*\text{总}=\text{场次}*\text{高}\times\text{出场率}*\text{高}+\text{场次}*\text{低}\times\text{出场率}*\text{低}$,我们可以得到以下公式: $$ p\_\text{总} = f \cdot p\_\text{高} + (1 - f) \cdot p\_\text{低} $$ 而我们知道,$\text{胜场}=\text{场次}\times\text{出场率}\times\text{胜率}$,而$\text{胜场}*\text{总}=\text{胜场}*\text{高}+\text{胜场}\_\text{低}$,因此我们有: $$ \text{场次} \times p\_\text{总} \times r\_\text{总} = f \cdot \text{场次} \times p\_\text{高} \times r\_\text{高} + (1 - f) \cdot \text{场次} \times p\_\text{低} \times r\_\text{低} $$ 两边约掉场次,就有: $$ p\_\text{总} r\_\text{总} = f p\_\text{高} r\_\text{高} + (1 - f) p\_\text{低} r\_\text{低} $$ 其中,$r\_\text{总}$、$p\_\text{总}$、$r\_\text{高}$、$p\_\text{高}$ 都是已知的,$f$ 是假设的高分段占比,都可以视为已知量。我们可以将这两个公式变形为: $$ \begin{cases} p\_\text{低} = \dfrac{p\_\text{总} - f p\_\text{高}}{1 - f} \\ r\_\text{低} = \dfrac{p\_\text{总} r\_\text{总} - f p\_\text{高} r\_\text{高}}{(1 - f) p\_\text{低} } \end{cases} $$ 调整合适的 $f$ 值,就可以得到低分段的胜率和出场率。 | | 胜率~总~ | 出场率~总~ | 胜率~高~ | 出场率~高~ | 假设f~高~ | 胜率~低~ | 出场率~低~ | |-----|-------|--------|-------|--------|--------|-------|--------| | 角色A | 54.5 | 2.0 | 53.3 | 4.8 | 20 | 55.6 | 1.3 | | 角色B | 53.3 | 2.0 | 51.8 | 4.7 | 20 | 54.6 | 1.3 | | 角色C | 52.3 | 6.3 | 48.5 | 8.3 | 20 | 53.7 | 5.8 | | 角色D | 52.4 | 2.9 | 49.4 | 3.3 | 20 | 53.3 | 2.8 | | 角色E | 52.4 | 16.2 | 49.5 | 17.3 | 20 | 53.2 | 15.9 | | 角色F | 52.5 | 4.2 | 49.9 | 4.5 | 20 | 53.2 | 4.1 | | 角色G | 52.5 | 17.0 | 49.4 | 11.4 | 20 | 53.0 | 18.4 | | 角色H | 52.5 | 12.6 | 51.3 | 11.5 | 20 | 52.8 | 12.9 | | 角色I | 52.4 | 5.6 | 52.2 | 5.9 | 20 | 52.5 | 5.5 | | 角色J | 51.4 | 5.8 | 52.7 | 17.4 | 20 | 49.5 | 2.9 | --- --- url: /daily/math/rect.md --- ## 判断点是否在凸多边形内 已知凸多边形的所有顶点的坐标,如何判断一个点是否在这个多边形内? 我们把每条边看做一个向量,$\overrightarrow{AB}, \overrightarrow{BC}, \overrightarrow{CD}, \dots$。我们知道,平面向量叉乘结果的数值正负,取决于两个向量位置关系。 那么我们换个思路来思考,点在凸多边形内,其实就等价于点在每条边决定的向量的同一侧。 这样一来,问题就简单了,我们假设点为 $P$,那么我们只需要判断 $\overrightarrow{AB} \times \overrightarrow{AP}, \overrightarrow{BC} \times \overrightarrow{BP}, \overrightarrow{CD} \times \overrightarrow{CP}, \dots$ 的结果的正负号是否相同即可。当且仅当所有结果的正负号相同,说明点 $P$ 在多边形内;如果有正有负,说明点 $P$ 在多边形外。 ::: code-tabs#1 @tab Python ```python :no-collapsed-lines # 已知凸多边形的N个顶点坐标points = [(x1, y1), (x2, y2), (x3, y3), ...] # 已知点P的坐标p = (x, y) def check(points, p): old_result = 0 for i in range(1, len(points)): dx1 = points[i].x - points[i-1].x dy1 = points[i].y - points[i-1].y dx2 = p.x - points[i-1].x dy2 = p.y - points[i-1].y result = dx1 * dy2 - dy1 * dx2 if result == 0: continue # 允许在边上就continue,不允许就直接return False if old_result == 0: old_result = result elif (result > 0) != (old_result > 0): return False # 正负号不同,说明在多边形外 return True ``` @tab Go ```go :no-collapsed-lines // 已知凸多边形的N个顶点坐标points = [(x1, y1), (x2, y2), (x3, y3), ...] // 已知点P的坐标p = (x, y) func check(points []Point, p Point) bool { var oldResult int for i := 1; i < len(points); i++ { dx1, dy1 := points[i].X-points[i-1].X, points[i].Y-points[i-1].Y dx2, dy2 := p.X-points[i-1].X, p.Y-points[i-1].Y result := dx1*dy2 - dy1*dx2 if result == 0 { continue // 允许在边上就continue,不允许就直接return false } if oldResult == 0 { oldResult = result } else if (result > 0) != (oldResult > 0) { return false } } return true } ``` ::: ::: tip 那如果是凹多边形怎么办? 把凹多边形切分成几个凸多边形即可。 ::: ### 特殊情况 对于矩形等旋转之后很容易判断的情况,可以把整张图旋转一下再进行判断,可能会更快。 例如:一个矩形的四个顶点为 $(0, 0), (1, 1), (0, 2), (-1, 1)$,我们知道绕原点顺时针旋转45°之后,就变成了一个在第一象限的边长为$\sqrt{2}$的正方形,因此我们将点 $P(x, y)$ 也绕原点顺时针旋转45°变成 $P'(x', y')$,那么我们只需要判断 $0 < x' < \sqrt{2}$ 且 $0 < y' < \sqrt{2}$ 即可。 将点 $(x, y)$ 绕点 $(x_0, y_0)$ **逆时针**旋转 $\theta$ 角度得到点 $(x', y')$ 的公式为: $$ \begin{aligned} x' - x_0 & = (x-x_0) \cdot \cos{\theta} - (y-y_0) \cdot \sin{\theta}\\ y' - y_0 & = (x-x_0) \cdot \sin{\theta} + (y-y_0) \cdot \cos{\theta} \end{aligned} $$ 当 $(x_0, y_0)$ 是原点时,公式简化为: $$ \begin{aligned} x' & = x \cdot \cos{\theta} - y \cdot \sin{\theta}\\ y' & = x \cdot \sin{\theta} + y \cdot \cos{\theta} \end{aligned} $$ ## 判断凸边形外的点到凸多边形的距离 已知凸多边形的所有顶点的坐标,如何判断凸边形外的一个点到这个多边形的距离? 点到凸多边形的距离,其实就是点到凸边形周长上所有点的最短距离。再进一步转化就是,点到凸多边形的每一条边的线段的距离的最小值。 那么如何求点到线段的距离呢? * 如果点在直线的投影在线段上,那么点到线段的距离就是点到投影的距离,即垂直距离。 * 如果点在直线的投影在线段外,那么点到线段的距离就是点到线段两端点的距离的最小值。 用公式描述就是: * 如果 $\overrightarrow{AB} \cdot \overrightarrow{AP} < 0$ ,说明点 $P$ 在点 $A$ 的投影在线段外靠近点 $A$ 这一侧,距离就是点 $P$ 到点 $A$ 的距离。 * 如果 $\overrightarrow{AB} \cdot \overrightarrow{AP}$ > $||\overrightarrow{AB}||^2$,说明点 $P$ 在点 $B$ 的投影在线段外靠近点 $B$ 这一侧,距离就是点 $P$ 到点 $B$ 的距离。 * 否则,说明点 $P$ 在点 $A$ 和点 $B$ 之间,距离就是点 $P$ 到线段的垂直距离:$\dfrac{||\overrightarrow{AP} \times \overrightarrow{AB}||}{||\overrightarrow{AB}||}$。 上述三者取最小值,即为点到线段的距离。 那么问题就简单了,遍历多边形的每一条边,计算点到线段的距离,然后取最小值即可。 ::: code-tabs#1 @tab Python ```python :no-collapsed-lines # 计算点到凸多边形的距离 def distance(p, rect): minDist = float('inf') for i in range(len(rect)): a = rect[i] b = rect[(i + 1) % len(rect)] dist = distanceToSegment(p, a, b) if dist < minDist: minDist = dist return minDist # 计算点P到线段AB的最短距离 def distanceToSegment(p, a, ab): dx = ab.x - a.x dy = ab.y - a.y if dx == 0 and dy == 0: return pointDistance(p, a) ap_x = p.x - a.x ap_y = p.y - a.y len_squared = dx * dx + dy * dy t = ap_x * dx + ap_y * dy if t < 0: return math.sqrt(ap_x*ap_x + ap_y*ap_y) elif t > len_squared: return math.Sqrt((p.X-ab.X)*(p.X-ab.X) + (p.Y-ab.Y)*(p.Y-ab.Y)) else: # 计算垂直距离 numerator = abs(dy * (p.x - a.x) - dx * (p.y - a.y)) return abs(numerator) / math.sqrt(len_squared) ``` @tab Go ```go :no-collapsed-lines // 计算点到凸多边形的距离 func distance(p Point, rect []Point) float64 { minDist := math.MaxFloat64 for i := range rect { a, b := rect[i], rect[(i + 1) % len(rect)] dist := distanceToSegment(p, a, b) if dist < minDist { minDist = dist } } return minDist } // 计算点P到线段AB的最短距离 func distanceToSegment(p, a, b Point) float64 { dx, dy := b.X - a.X, b.Y - a.Y if dx == 0 && dy == 0 { return pointDistance(p, a) } apX, apY := p.X - a.X, p.Y - a.Y lenSquared := dx*dx + dy*dy t := apX*dx + apY*dy if t < 0 { return math.Sqrt(apX*apX + apY*apY) } else if t > lenSquared { return math.Sqrt((p.X-b.X)*(p.X-b.X) + (p.Y-b.Y)*(p.Y-b.Y)) } else { // 计算垂直距离 numerator := math.Abs(dy*(p.X-a.X) - dx*(p.Y-a.Y)) return math.Abs(numerator) / math.Sqrt(lenSquared) } } ``` ::: --- --- url: /daily/math/rune.md --- # 游戏中的洗符石问题 ## 问题描述 《幻唐志》中的宠物符石系统是一个数学逻辑很复杂的概率系统。 宠物符石共三组,每组两个,共六个符石。每次洗练可以重置其中一组,并从本次洗出的候选符石中**自选 2 个**替换原来的两个符石。 符石的属性由“颜色”和“字”组成: * 颜色:五种五行,共 5 种; * 字:八卦字 8 种,外加 3 种不同的“无字”,共 11 种。 因此候选石的总组合数为: $$ 5\times 11=55 $$ 本文要求最终三组符石都成为“同色同字”,且**不允许无字**。也就是说,有效目标组合只有: $$ 5\times 8=40 $$ 种。 每次洗练时,出现的候选石数量的原始概率为:40%几率出现2个候选石,30%几率出现3个候选石,20%几率出现4个候选石,10%几率出现5个候选石。 但存在保底机制: * 若连续 9 次没有出至少 4 个候选石,则第 10 次必出至少 4 个; * 若连续 19 次没有出 5 个候选石,则第 20 次必出 5 个。 我们的目标是计算完成三组同色同字所需洗练次数的数学期望。 ## 出石数量 $N$ 的稳态分布 ### 状态定义 定义状态: $$ (a,b) $$ 其中: * $a$:连续未出至少 4 个候选石的次数; * $b$:连续未出 5 个候选石的次数。 显然: $$ 0\le a\le 9,\qquad 0\le b\le 19 $$ 状态总数为: $$ 10\times 20=200 $$ 每洗一次,状态发生转移。转移规则为: 若本次洗出 $n$ 个候选石: * 若 $n\ge 4$,则 $a$ 重置为 0;否则 $a$ 加 1; * 若 $n=5$,则 $b$ 重置为 0;否则 $b$ 加 1。 同时,当 $a=9$ 且 $b<19$ 时,本次出石数量强制为: $$ P(N=4)=\frac23,\qquad P(N=5)=\frac13 $$ 当 $b=19$ 时,本次强制为: $$ P(N=5)=1 $$ 否则按原始概率分布。 ### 平稳分布求解 由于状态空间有限且不可约,该马尔可夫链存在唯一平稳分布 $\pi(a,b)$,满足: $$ \pi = \pi \mathbf P $$ 其中 $\mathbf P$ 是 $200\times 200$ 的状态转移矩阵。 该方程可以通过数值方法精确求解。解出平稳分布后,可得到出石数量 $N$ 的边际分布: $$ q_n=P(N=n)=\sum\_{\substack{a,b\ \text{状态}(a,b)\text{下本次出}n}}\pi(a,b) $$ 数值结果如下: | $n$ | $q_n$ | |---:|-------:| | 2 | 0.3902 | | 3 | 0.2925 | | 4 | 0.2020 | | 5 | 0.1153 | 可见,保底机制使 5 石概率从原始 10% 提升到约 11.5%,2 石概率从 40% 下降到约 39.0%。 ::: note 注意 值得一提的是,由于三组石头是共用保底的,因此这种把保底机制近似成一个独立的数学期望的方式并不完全准确,但用于估算是足够的。 ::: ## 单次洗练成功概率 ### 固定目标组合的成功概率 当第一组已经锁定某个目标组合 $c$ 后,后续两组都必须洗出至少两个 $c$。 单个候选石恰好是 $c$ 的概率为: $$ \frac{1}{55} $$ 若某次洗出 $n$ 个候选石,令 $X$ 表示其中组合 $c$ 的个数,则: $$ X\sim \mathrm{Binomial}\left(n,\frac{1}{55}\right) $$ 成功条件为: $$ X\ge 2 $$ 因此: $$ P_2^{(n)}=P(X\ge 2) $$ $$ P_2^{(n)} ========= ## 1-\left(\frac{54}{55}\right)^n n\cdot\frac{1}{55}\left(\frac{54}{55}\right)^{n-1} $$ 利用出石数量的稳态分布 $q_n$,得到锁定目标后单次洗练成功概率: $$ p_2=\sum\_{n=2}^{5}q_n P_2^{(n)} $$ 代入数值: $$ p_2\approx 1.13\times 10^{-3} $$ 即约 **0.113%**。 ### 第一组任意有效组合的成功概率 第一组尚未锁定具体组合,只要本次洗出的候选石中存在某一种**非无字**组合出现至少两次,即可成功锁定目标。 有效组合共有 40 种,无字组合有 15 种。 设某次洗出 $n$ 个候选石,令 $Y$ 表示其中属于有效组合的个数。 由于每个候选石属于有效组合的概率为: $$ \frac{40}{55}=\frac{8}{11} $$ 所以: $$ Y\sim \mathrm{Binomial}\left(n,\frac{8}{11}\right) $$ 给定 $Y=k$ 时,这 $k$ 个有效组合均匀分布在 40 种具体组合上。 若这 $k$ 个有效组合全部互不相同,则没有任意一个有效组合出现至少两次,洗练失败。 因此,给定 $k\ge 2$ 时,成功的条件概率为: $$ P(\text{成功}\mid n,k) ==================== 1-\frac{(40)\_k}{40^k} $$ 其中: $$ (40)\_k=40\cdot 39\cdot 38\cdots(40-k+1) $$ 是降阶乘。 对于 $k=0$ 或 $k=1$,显然成功概率为 0。 所以: $$ P_1^{(n)} ========= \sum\_{k=2}^{n} \binom{n}{k} \left(\frac89\right)^k \left(\frac19\right)^{n-k} \left\[ 1-\frac{(40)\_k}{40^k} \right] $$ 则第一组单次洗练成功概率为: $$ p_1=\sum\_{n=2}^{5}q_n P_1^{(n)} $$ 代入数值: $$ p_1\approx 5.57\times 10^{-2} $$ 即约 **5.57%**。 ## 总期望次数 完成三组需要: 1. 第一组洗出任意同色同字非无字组合,锁定目标; 2. 第二组洗出与第一组相同的组合; 3. 第三组洗出与第一组相同的组合。 每次洗练相互独立,且每次只影响当前组。 因此第一组所需次数服从几何分布,期望为: $$ \frac{1}{p_1} $$ 锁定目标后,第二组和第三组每次成功概率均为 $p_2$,期望分别为: $$ \frac{1}{p_2} $$ 所以总期望次数为: $$ \mathbb E\[T] ============= \frac{1}{p_1} \+ 2\cdot\frac{1}{p_2} $$ 代入数值: $$ p_1\approx 5.57\times 10^{-2} $$ $$ p_2\approx 1.13\times 10^{-3} $$ 得到: $$ \mathbb E\[T] \approx \frac{1}{0.0557} \+ 2\cdot\frac{1}{0.00113} $$ $$ \mathbb E\[T] \approx 18 \+ 1770 \approx 1788 $$ 实际游戏中考虑到波动,体感通常会在 **1700~1800 次** 之间。 ## 更优方案 实际上还有一个更优方案,就是第一组洗出任意同色同字组合后,第二组同色同字并不要求和第一组相同,如果不同,可以做为一个备选方案。第三组同色同字先匹配上哪个就用哪个。 有了这个更优方案后,所需次数大约在 **1300~1400 次** 左右。计算方式在这里就不举例出来了,留给大家思考。 ## 代码检验 我们可以用代码进行模拟并统计: ```typescript let lessThen4 = 0, lessThen5 = 0, result = 0; const results: number[] = []; const getColor = (v: number) => v % 5; const getCharacter = (v: number) => v % 8; // 随机符石的个数 const randLine = (): number[] => { result++; let count: number; if (lessThen5 >= 19) { count = 5; } else if (lessThen4 >= 9) { count = 4; } else { const n = Math.random(); if (n < 0.4) count = 2; else if (n < 0.7) count = 3; else if (n < 0.9) count = 4; else count = 5; } switch (count) { case 5: lessThen5 = 0; lessThen4 = 0; break; case 4: lessThen5++; lessThen4 = 0; break; default: lessThen5++; lessThen4++; } return Array.from({length: count}, () => Math.floor(Math.random() * 55)); }; interface TestConfig { name: string; find: (result: number[], cur: [number, number][]) => [number, number] | boolean | undefined; after?: (cur: [number, number][]) => void; } const newTest = (d: TestConfig): void => { console.log(d.name); lessThen4 = 0; lessThen5 = 0; results.length = 0; for (let i = 0; i < 10000; i++) { result = 0; const cur: [number, number][] = []; for (let j = 0; j < 3; j++) { while (true) { const v = d.find(randLine(), cur); if (!v) continue; if (typeof v === "boolean") cur.push([0, 0]); else cur.push(v); break; } } d.after?.(cur); results.push(result); } results.sort((a, b) => a - b); console.log("平均值:", results.reduce((a, b) => a + b, 0) / 10000); console.log("95%置信区间:", results.at(250), "~", results.at(-250)); }; const filterSame = (arr: number[]): number[] => { return arr.filter((v, i, arr) => { if (v >= 15) { for (let j = i + 1; j < arr.length; j++) { if (v === arr[j]) return true; } } return false; }); }; newTest({ name: "===========六个完全相同的符石===========", find: (result, cur) => { const same = filterSame(result); if (same.length === 0) return undefined; const v = same.find(v => cur.some(arr => arr[0] === v)); if (v) return [v, v]; return cur.length < 2 ? [same[0], same[0]] : undefined; }, after: (cur) => { if (cur[0][0] !== cur[1][0]) { while (true) { const same = filterSame(randLine()); if (same.some(v => v === cur[2][0])) break; } } } }); newTest({ name: "===========六金===========", find: result => result.filter(v => v >= 15 && getColor(v) === 0).length >= 2 }); newTest({ name: "===========六乾===========", find: result => result.filter(v => v >= 15 && getCharacter(v) === 0).length >= 2 }); newTest({ name: "===========六金乾===========", find: result => result.filter(v => v === 15).length >= 2 }); newTest({ name: "===========六金同字===========", find: (result, cur) => { const same = filterSame(result).filter(v => getColor(v) === 0); if (same.length === 0) return undefined; const v = same.find(v => cur.some(arr => arr[0] === v)); if (v) return [v, v]; return cur.length < 2 ? [same[0], same[0]] : undefined; }, after: (cur) => { if (cur[0][0] !== cur[1][0]) { while (true) { const same = filterSame(randLine()); if (same.some(v => v === cur[2][0])) break; } } } }); newTest({ name: "===========六中庸===========", find: result => result.filter(v => v < 15).length >= 2 }); ``` 运行结果如下: ```text :no-line-numbers ===========六个完全相同的符石=========== 平均值: 1304.2537 95%置信区间: 174 ~ 3713 ===========六金=========== 平均值: 46.4213 95%置信区间: 11 ~ 110 ===========六乾=========== 平均值: 111.865 95%置信区间: 24 ~ 270 ===========六金乾=========== 平均值: 2559.5173 95%置信区间: 523 ~ 6151 ===========六金同字=========== 平均值: 1443.2518 95%置信区间: 262 ~ 3903 ===========六中庸=========== 平均值: 15.8468 95%置信区间: 5 ~ 36 ``` --- --- url: /daily/music/abc-notation.md --- # ABC记谱法 本篇主要介绍如何在网页上显示五线谱。这就要用到上个世纪一直延续至今的一种记谱法:**ABC记谱法**。首先我们来看一个例子: ```abc :no-line-numbers :no-collapsed-lines Q:1/4=105 M:4/4 L:1/4 K:D %%score { V:1 V:2 } V:1 !mp! .d .a e3/2 e//f// | (g/f/e/d/ A>)F | .G .f c>d | !p!.[FA] .[DF] .[EG] .[FA] | .B .a (c3/2 e///d///c///B///) | (A e !<(!^d3/2) e//f//!<)! | !mf! .Lg3/2 (g//f//) .=d .e | .d z z2 | V:2 !p! A .D [Ac] .D | [DGA] .A, [DF] .A, [K:bass] | E, .[B,E] A, ([EA]/.A,/) | .D, .D, .E, .F, | .G, .[DG] G, ([EA]/.G,/) | F, ([CE]/.F,/) ([B,A]/^D/ .F) | .L[F,G,B,=D] z .[A,CE] .[A,EG] | .[D,F,A,D] .A,, .D,, z | ``` 对应以下这个曲谱: ```component AbcNonation notation: | Q:1/4=105 M:4/4 L:1/4 K:D %%score { V:1 V:2 } V:1 !mp! .d .a e3/2 e//f// | (g/f/e/d/ A>)F | .G .f c>d | !p!.[FA] .[DF] .[EG] .[FA] | .B .a (c3/2 e///d///c///B///) | (A e !<(!^d3/2) e//f//!<)! | !mf! .Lg3/2 (g//f//) .=d .e | .d z z2 | V:2 !p! A .D [Ac] .D | [DGA] .A, [DF] .A, [K:bass] | E, .[B,E] A, ([EA]/.A,/) | .D, .D, .E, .F, | .G, .[DG] G, ([EA]/.G,/) | F, ([CE]/.F,/) ([B,A]/^D/ .F) | .L[F,G,B,=D] z .[A,CE] .[A,EG] | .[D,F,A,D] .A,, .D,, z | ``` 我们后续会花一些篇幅来详细介绍**ABC记谱法**。 --- --- url: /daily/music/cogwork-dancers-bgm.md --- # 丝之歌机枢舞者BGM ```component AbcNonation notation: | X:1 T:COGWORK DANCERS C:Christopher Larkin Q:"Mysterious" 1/4=120 M:4/4 L:1/4 K:Eb %%score { V:1 V:2 } V:2 bass octave=-1 [V:1] (g c d e | f2) (f g/a/ | g c d e | f2) (f g/a/ | [V:2] !p!C [Gce] [Gce] [Gce] | C [A=Bd] [ABd] [ABd] | C [Gce] [Gce] [Gce] | =B, [FAd] [FAd] [FAd] | [V:1] g B e g | b2) (b a/g/ | a g f e | d > e !<(! d =B) !<)! | [V:2] _B, [GBe] [GBe] [GBe] | B, [FB_d] [FBd] [FBd] | A, [FAc] [FAc] [FAc] | G, [F=B] G [FB] | [V:1] (g c d e | f2) (f g/a/ | g c d e | f2) (f g/a/ | [V:2] C [Gce] [Gce] [Gce] | C [A=Bd] [ABd] [ABd] | C [Gce] [Gce] [Gce] | =B, [FAd] [FAd] [FAd] | [V:1] g B e g | b2) (b a/g/ | a c f a | [c']2 c' b/=a/) | [V:2] _B, [GBe] [GBe] [GBe] | B, [FB_d] [FBd] [FBd] | A, [FAc] [FAc] [FAc] | G, [EGc] [EGc] [EGc] | [V:1] (b d g =b | [egd'] [egc']/=b/ [egc'] a/f/) | [=Bdfg] z [DFB] z || [Q:1/4=150] !f! [Gg] [Cc] [Dd] [Ee] | [V:2] G, [EGB] F, [DG] | E D (C/B,/) A, | G, z G, z || !mf!C, [EG] [EG] [EG] | [V:1] [Ff]2 [Ff] [Gg]/[Aa]/ | [Gg] [Cc] [Dd] [Ee] | [Ff]2 [Ff] [Gg]/[Aa]/ | [Gg] [B,B] [Ee] [Gg] | [V:2] =B, [DF] [DF] [DF] | C [EG] [EG] [EG] | =B, [DF] [DF] [DF] | B, [EG] [EG] [EG] | [V:1] [Bb]2 [Bb] [_da]/g/ | [ca] [Acf] [cfa] [=Bdg]/f/ | [ceg] [Gce] [^c=eg] [=Ac^f]/=e/ | [=Ac^f] [^Fd] [Acf] c/f/ | [V:2] B, [FB] [FB] [FB] | A, [CF] F, [DF] | E, [EG] G, [=E=A] | D, [D^F] D, [DF] | [V:1] [Gg] B d e | ^f2 f c/f/ | g B d e | f2 [df] [_ce]/d/ | [V:2] E, [B,G]/[B,G]/ E E | D, [=A,^F]/[A,F]/ D D | E, [B,G]/[B,G]/ E E | =B,, [DF]/[DF]/ =B, B, | [V:1] [Gce] [CEG] [DG=B] [EGc] | [FAd] [=B,DF] [DFA] [FA=B] | [EGc] [G,CE] [A,CF] [CEG] | [=B,DA] G/F/ [B,DG] B, || [V:2] [C,C] E/F/ G/=B/c/z/ | [C,C] D/E/ F/G/A/z/ | [C,C] C/D/ E/F/G/z/ | [G,,G,] z [G,,G,] z || Q:1/4=240 [V:1] [Gceg] c/G/ d/G/e/G/ | f/=B/e/B/ d/B/f/B/ | e/G/d/G/ c/G/e/G/ | d/F/c/F/ =B/F/G/F/ | [V:2] !f!C, [G,C] C, [G,C] | D, [=B,D] D, [B,D] | E, [CE] E, [CE] | F, [CF] F, [CF] | [V:1] c/C/E/C/ F/C/G/C/ | A/C/G/C/ F/C/A/C/ | G,/[CG]/G,/[CG]/ C/[Gc]/C/[Gc]/ | =B,/[F=B]/B,/[F=B]/ D/[Ad]/D/[Ad]/ | [V:2] G, [CE] G, [CE] | A, [CF] A, [CF] | B, [CE] B, [CEG] | =B, [DF] =B, [DF=B] | [V:1] [Gceg] [Cc] [DAd] [Ee] | [F=Bdf] [Ee] [DGd] [Ff] | [EGce] [Dd] [CGc] [Ee] | [DF=Bd] [Cc] [=B,DB] [G,G] | [V:2] [C,C] z [C,C] z | [D,D] z [D,D] z | [E,E] z [E,E] z | [F,F] z [F,F] z | [V:1] [CGc] [G,CE] [CEF] [CEG] | [CFA] [CFG] [CF] [CFA] | z [^F=Ad^f] z [Adf=a] | [Gceg] [Ee] !<(! (3C/D/E/ (3=E/F/^F/ !<)! | [V:2] [G,G] z [G,G] z | [A,A] z [A,A] z | [A,,A,] z [D,,D,] z | [G,,G,] z !<(! (3E/F/G/ A/ z/ !<)! | [V:1] !f! [CG] [Gcg] [EGe] [CGc] | [Acfa] [Ee] !<(! (3D/E/=E/ (3F/^F/G/ !<)! | !f! A [Acfa] [Fcf] [CAc] | [Gceg] [Ee] !<(! (3C/D/E/ (3=E/F/^F/ !<)! | [V:2] !mf![G,G] [E,E] [F,F] [G,G] | [A,A] z !<(! (3F/G/A/ B/z/ !<)! | !mf![A,A] [F,F] [G,G,] [A,A] | [=B,=B] z !<(! (3B/c/^c/ d/ z/ !<)! | [V:1] !f! =B [FAB] z [DABd] | z [EGc] z [EGce] | z [D^F=Ad] z [Fd^f] | z [F=Bdf] z [Adfa] | [V:2] !mf!=B, [DFA] A, [DF] | G, [CEG] E, [CE] | ^F, [D^F] D, [=A,D] | F, [DF] D, [=B,D] | [V:1] z [Gceg] z [cegc'] | z [=Bdf=b] z [dgbd'] | z [cfac'] z [ac'a'] | [g=bd'g'] z [gg'] [ff'] | [^f=ad'^f] z [g=bd'g'] z | [V:2] E, [CE] C, [G,C] | D, [=B,D] =B,, [G,B,] | C, [A,C] A, [CA] | [G,DG] z z2 | [D^F=A] [G,DG] [DG=B] [G,DG] | [V:1] [cegc']2 [E,G,C]2 | [D,G,=B,D]2 [E,G,CE]2 | [A,=B,DF]4- | [A,=B,DF]2 [G,=B,DG] [A,A] | [V:2] C,/C/C,/C/ C,/C/C,/C/ | C,/C/C,/C/ C,/C/G,,/G,/ | A,,/A,/A,,/A,/ A,,/A,/A,,/A,/ | D,,/D,/D,,/D,/ D,,/D,/D,,/D,/ | [V:1] [G,CEG]2 [CEGc]2 | [CE^Fc]2 [EF=Ad]2 | [CDGc]4 | [B,DGB]4 | [V:2] E,,/E,/E,,/E,/ E,,/E,/E,,/E,/ | ^F,,/^F,/F,,/F,/ F,,/F,/F,,/F,/ | G,,/G,/G,,/G,/ G,,/G,/G,,/G,/ | G,,/G,/G,,/G,/ G,,/G,/=A,,/=A,/ | [V:1] [FB_df]2 [B,_DFB]2 | [CFBc]2 [_DFB_d]2 | [=EGc=e]4- | [=EGc=e]2 [CGc] [=Ec=e] || [V:2] B,,/B,/B,,/B,/ B,,/B,/B,,/B,/ | B,,/B,/B,,/B,/ B,,/B,/B,,/B,/ | C,/C/C,/C/ C,/C/C,/C/ | B,,/B,/B,,/B,/ =A,,/=A,/A,,/A,/ || K:Db [V:1] [FAcf]2 [Acfa]2 | [A=B=da]2 [_Bdfb]2 | [ABea]4 | [GBeg]4 || [V:2] A,,/A,/A,,/A,/ A,,/A,/A,,/A,/ | =D,,/=D,/D,,/D,/ D,,/D,/D,,/D,/ | E,,/E,/E,,/E,/ E,,/E,/E,,/E,/ | E,,/E,/E,,/E,/ E,,/E,/F,,/F,/ || K:A [V:1] !mf!!<(![CFA]2 [F,A,C]2 | [A,CF]2 [CFA]2 | [=C=FA]2- [CFB]2- | [CFB]2 [^E^B] [DGd] || [V:2] F,,/F,/F,,/F,/ F,,/F,/G,,/G,/ | A,,/A,/G,,/G,/ A,,/A,/B,,/B,/ | =C,/=C/C,/C/ C,/C/C,/C/ | G,,/G,/G,,/G,/ G,,/G,/G,,/G,/ || K:C [V:1] [CEGc]2 [E,A,CE]2 | [A,CEA]2 [CEAc]2 | [C_E_Ac]2 [EAcd]2- | [EAcd]2 [_egb_e'] [gf'] || [V:2] A,,/A,/A,,/A,/ A,,/A,/B,,/B,/ | C,/C/C,/C/ C,/C/D,/D/ | _E,/_E/E,/E/ E,/E/E,/E/ | B,,/B,/B,,/B,/ B,,/B,/B,,/z/ || K:Eb [V:1] [egc'e']2 [Gceg]2 | [cegc']2 [egc'e']2 | [eg=be']2 [G=Beg]2 | [=Beg=b]2 [egbe'] [gg']!<)! | !ff! [a=bd'a']2 z2 | z4 | z4 || [V:2] [C,,C,]3 [D,,D,] | [E,,E,]3 [F,,F,] | [^F,,^F,]3 [G,,G,] | [A,,A,]3 [B,,B,] | !ff! [=B,,=B,]z z2 | z4 | z4 || [V:1 treble+8] [Q:1/4=90] !mp!G C D E | F z F G/A/ | G C D E | F z [DF] G/A/ | [V:2 bass+8] [Q:1/4=90] !p! E/G/ z z2 | D/F/ =B z2 | E/G/ z z2 | D/F/A/=B/ z2 | [V:1] G B, E G | B z B A/G/ | A G F E | !>(! D z/E/ D [G,=B,] !>)! | !pp!C4 |] [V:2] B,/E/ G z2 | B,/_D/F/B/ z2 | A,/C/E/G/ z2 | !>(! =A,/C/E/G/ _A,/=B,/ z !>)! | !pp!z4 |] ``` --- --- url: /daily/music/hollow-knight-theme-bgm.md --- # 空洞骑士主题曲 ```component AbcNonation notation: | X:1 T:Hollow Knight Main Theme C:Christopher Larkin Q:1/4=55 M:3/4 L:1/8 K:Eb %%score { V:1 V:2 } V:1 (c2 c2 de | d6) | (cG A2 GF | G6) | (c2 c2 de | f6) | (ge dc B2 | c6) | z8 | ([Cc]2 [Cc]2 [Dd][Ee] | [Dd]6) | ([Cc][G,G] [A,A]2 [G,G][F,F] | [G,G]6) | ([Cc]2 [Cc]2 [Dd][Ee] | [Ff]4) z ([Gg]/2[Aa]/2 | [Gg]2 [Ee]3 [B,B] | [Cc]2 [B,B]3 F | G2) C4 | [g']6- | [g']2 G4- | G4 z (=B | =B6 | c4 (3zde | f6) | g4 Hb2 | [Hc']6 |] V:2 CG A4 | CF G4 | CE F4 | CD ED [B,]2 | CG A4 | B,G F4 | A,E F4 | F,B, CF CB, | [!arpeggio!F,=A,CF]4 \ K:bass E,2 | C,G, A,4 | C,F, G,4 | C,E, F,2 z2 | B,,E, D,2 B,,2 | A,,E, F,4 | F,,C, F,G, A,C | E,,C, E,G, C2 | A,,E, B,,F, D,2 | C,G, A,G, A,G, | C,F, G,F, G,F, | C,E, F,E, F,E, | [!arpeggio!F,,B,,D,]2 F,,2 [B,,D,]2 | C,G, A,G, A,G, | C,F, G,F, G,F, | C,E, F,E, F,E, | B,,D, F,B, HD2 | HC6 |] ``` --- --- url: /daily/music/maplestory-mushroom-castle.md --- # 冒险岛-蘑菇城堡 ```component AbcNonation notation: | Q:1/4=210 M:C L:1/4 K:Eb %%score{ V:1 V:2 } [V:1] !mp! [Bb] .[Bb]/.[Aa]/ .[Gg] .[Bb] | [ee']3 .[ee'] | [dd'] {e'd'}c' .[dd'] .[ee'] | [ee']3 .[ee'] | [V:2] !pp! .[G,B,E] .[G,B,E] .[G,B,E] .[G,B,E] & [E,]4 | .[CEA] .[CEA] .[CEA] .[CEA] & [A,]4 | .[DF] .[DF] .[DF] .[DF] & [B,]4 | .[EG] .[EG] .[EG] .[EG] & C4 | [V:1] [Bb] .[bb'] .[aa'] .[gg'] | [ff'] .[ee'] .[dd'] .[cc'] | [dd'] {e'd'}c' .[dd'] .[ee'] | [ee']4 | [V:2] .[B,EG] .[B,EG] .[B,EG] .[B,EG] & [G,]4 | .[CEA] .[CEA] .[CEA] .[CEA] & [A,]4 | .[DF] .[DF] .[DF] .[DF] & [B,]4 | .[G,B,E] .[G,B,E] .[G,B,E] .[G,B,E] & [E,]4 | [V:1] [Bb] .[Bb]/.[Aa]/ .[Gg] .[Bb] | [e']3 .e' & e .e/.f/ .g .e | [dd'] {e'd'}c' .[dd'] .[ee'] | [e']3 .e' & e .e/.f/ .g .e | [V:2] .[G,B,E] .[G,B,E] .[G,B,E] .[G,B,E] & [E,]4 |. [CEA] .[CEA] .[CEA] .[CEA] & [A,]4 | .[DF] .[DF] .[DF] .[DF] & [B,]4 | .[EG] .[EG] .[EG] .[EG] & C4 | [V:1] [Bb] .[bb'] .[aa'] .[ff']/.[gg']/ | [ff'] .[ee'] .[dd']/.f/ .[cc'] | [dd'] {e'd'}c' .[dd'] .[ee'] | [ae'] .g .f/.b/ z || [V:2] .[B,EG] .[B,EG] .[B,EG] .[B,EG] & [G,]4 | .[CEA] .[CEA] .[CEA] .[CEA] & [A,]4 | .[DF] .[DF] .[DF] .[DF] & [B,]4 | .[G,B,E] .[G,B,E] .[G,B,E] B & [E,]4 || [V:1] !f! ([e']2 f' g' | [f']2 e' d' | e' d' c' d' | b3) (g | [V:2] !pp! z [GBe] [GBe] [GBe] & E4 | B, [FBd] [FBd] [FBd] | A, [EAc] [EAc] [EAc] | G, [EGB] [EGB] [EGB] | [V:1] a2 b a | g b e' {d'e'}d' | c' d' e' g' | [f']3) (b | [V:2] F, [CA] [CA] [CA] | G, [EB] [EB] [EB] | A, [EAc] [EAc] [EAc] | B, [FBd] [FBd] [FBd] | [V:1] [ege']2 f' g' | [fbd'f']2 [e']/[f']/[e']/[d']/ | [ae'] d' c' [dd'] | [Bgb]3) (g | [V:2] !pp! z [GBe] [GBe] [GBe] & E4 | B, [FBd] [FBd] [FBd] | A, [EAc] [EAc] [EAc] | !pp! z [B,EG] [B,EG] [B,EG] & [G,]4 | [V:1] f g a c' | [e']2 [d']/[e']//[d']//[c']/[d']/ | [e']) !pp![be'g'] [be'g'] [be'g'] | [be'g'] [be'g'] [be'g'] !mp!z//b//[c']//[d']// | [V:2] F, [A,CA] [A,CA] [A,CA] | B, [FBd] [FBd] !mp! C/D/ | E E/F/ G F | E B, G, !pp![B,B] | [V:1] [ee']2 [fd'f'] [gc'g'] | [fbd'f']2 [eae'] [dgd'] | [eae'] [dgd'] [eac'] [dbd'] | [Bgb]2 G/B/g/E/ | [V:2] [E,E] [EBe]/[EBe]/ [EBe] [EBe] | [B,,B,] [FBd]/[FBd]/ [FBd] [FBd] | [A,,A,] [EAc]/[EAc]/ [EAc] [EAc] | [G,,G,] [B,E]/[B,E]/ [B,E] z | [V:1] [Aa] B/c/ b [da] | [eg] [fb] [ee'] [d']//[e']//d'/ | !arpeggio![cc'] !arpeggio![dd'] !arpeggio![ee'] !arpeggio![gg'] | [ff']/[Bb]/[cc']/[dd']/ [ee']/[ff']/[gg']/[aa']/ | [V:2] [F,,F,] [A,CF]/[A,CF]/ [A,CF] [A,CF] | [G,,G,] [B,EG]/[B,EG]/ [B,EG] [B,EG] | [A,,A,] [A,CF]/[A,CF]/ [A,CF] [A,CF] | [B,,B,] [F,B,D]/[F,B,D]/ [F,B,D] [F,B,D] | [V:1] [bb']2 [fd'f'] [gc'g'] | [fbd'f']2 [ee']/[ff']/[ee']/[dd']/ | [eae'] [dgd'] [eac'] [dbd'] | [Bgb] G/B/ [Ee]/[Gg]/[Ee]/[Cc]/ | [V:2] [E,,E,] [GBe]/[GBe]/ [GBe] [GBe] | [B,,B,] [FBd]/[FBd]/ [FBd] [FBd] | [A,,A,] [EAc]/[EAc]/ [EAc] [EAc] | [G,,G,] [B,E]/[B,E]/ [B,E] z | [V:1] [Ff] [Gg] [Aa] [cc'] | [ee']2 [d']/[e']//[d']//[c']/[d']/ | [ee'] f/g/a/b/[c']/[d']/ | e' [Gg] [Aa] [Bb] | [V:2] [F,,F,] [A,CF]/[A,CF]/ [A,CF] [A,CF] | [B,,B,] [F,B,D]/[F,B,D]/ [F,B,D] [F,B,D] | [E,,E,] [B,EG]/[B,EG]/ [B,EG] [B,EG] | [B,EG] !mp!z//[E,,]//[B,,]//[E,]// [G,]//[A,]//[B,]//C//D//E//F//G// | [V:1] [cc'] [gg']2 [ff'] | [=B=b] [gg']2 [ff'] | [Bb] [ff']2 [ee'] | [Bb]3 [Gg] | [V:2] !pp! [A,EA] e g z | [A,A] e/f/ g z | [G,EG] e/f/ g z | [^F,E^F] e/f/ ^f z | [V:1] [Aa] [ee']2 [dd'] | [cc'] [ee'] [dd'] [ff'] | [Bb]4- | [Bb] [Gg] [Aa] [Bb] | [V:2] [F,CF]4 | [B,FB]4 | [E,,B,,E,] [G,B,E] [G,B,E] [G,B,E]/G/ | z [G,B,_D] [G,B,_D] [G,B,_D] & E3 E | [V:1] [cc'] [gg']2 [ff'] | [=B=b] [gg']2 [ff'] | [bb'] [gg']2 [ff'] | [ee']3 [ee'] | [V:2] [A,EA] e g z | [A,A] e/f/ g z | [G,EG] e/f/ g z | [^F,E^F] e/f/ ^f z | [V:1] [bb']2 [aa']2- | [aa'] [gg'] [aa'] [ff']- | [ff']4 | B e/b/ z//c//d//e//f//g//a//b// | [V:2] [A,CA] e z e | z [Ace] [Ace] [Ace] & F4 | D B f F | B, F G/B/ z | [V:1] [cc'] [gg']2 [ff'] | [=B=b] [gg']2 [ff'] | [Bb] [ff']2 [ee'] | [Bb] b' a' [Gg] | [V:2] [A,EA] e g z | [A,A] e/f/ g z | [G,EG] e/f/ g z | [^F,E^F] e/f/ ^f z | [V:1] [Aa] [ee'] a [dd'] | [cc'] [ee'] [dd'] [ff'] | [Bb]4- | [Bb] [eg] [fa] [gb] | [V:2] [F,CF]4 | [B,FB]4 | [E,B,E]2 [A,A] F | [B,B] [B,_DEG] [B,DEG] [B,DEG] | [V:1] [cc'] [gg']2 [ff'] | [=B=b] [gg']2 [ff'] | [bb'] [gg']2 [ff'] | [ee']3 [ee'] | [V:2] [A,EA] e g z | [A,A] e/f/ g z | [G,EG] e/f/ g z | [^F,E^F] e/f/ ^f z | [V:1] [bb']2 [aa']2- | [aa'] [gg'] [aa'] [bb']- | [bb']4 | B2 .E/.F/.G/.A/ || [V:2] [A,CA] e z e | z [Ace] [Ace] [Ace] & F4 | D B f F | B, F .G/.A/ z || [V:1] !mp! [Bb] .[Bb]/.[Aa]/ .[Gg] .[Bb] | [ee']3 .[ee'] | [dd'] {e'd'}c' .[dd'] .[ee'] | [ee']3 .[ee'] | [V:2] !pp! .[G,B,E] .[G,B,E]/.[G,B,E]/ .[G,B,E] .[G,B,E] & [E,]4 | .[CEA] .[CEA]/.[CEA]/ .[CEA] .[CEA] & [A,]4 | .[DF] .[DF]/.[DF]/ .[DF] .[DF] & [B,]4 | .[EG] .[EG]/.[EG]/ .[EG] .[EG] & C4 | [V:1] [Bb] .[bb'] .[aa'] .[gg'] | [ff'] .[ee'] .[dd'] .[cc'] | [dd'] {e'd'}c' .[dd'] .[ee'] | [ee']4 | [V:2] .[B,EG] .[B,EG]/.[B,EG]/ .[B,EG] .[B,EG] & [G,]4 | .[CEA] .[CEA]/.[CEA]/ .[CEA] .[CEA] & [A,]4 | .[DF] .[DF]/.[DF]/ .[DF] .[DF] & [B,]4 | .[G,B,E] .[G,B,E]/.[G,B,E]/ .[G,B,E] .[G,B,E] & [E,]4 | [V:1] [Bb] .[Bb]/.[Aa]/ .[Gg] .[Bb] | [ee'] .e/.f/.g .[ee'] | [dd'] {e'd'}c' .[dd'] .[ee'] | [ee'] c/d/e/f/g/a/ | [V:2] .[G,B,E] .[G,B,E]/.[G,B,E]/ .[G,B,E] .[G,B,E] & [E,]4 | .[CEA] .[CEA]/.[CEA]/ .[CEA] .[CEA] & [A,]4 | .[DF] .[DF]/.[DF]/ .[DF] .[DF] & [B,]4 | .[EG] .[EG]/.[EG]/ .[EG] .[EG] & C4 | [V:1] [Bb] .[bb'] .[aa'] .[ff']/.[gg']/ | [ff'] .[ee'] .[dd']/.f/ .[cc'] | [dd'] {e'd'}c' .[dd'] .[ee'] | [e']4 & b !pp!g f/g/ b |] [V:2] .[B,EG] .[B,EG]/.[B,EG]/ .[B,EG] .[B,EG] & [G,]4 | .[CEA] .[CEA]/.[CEA]/ .[CEA] .[CEA] & [A,]4 | .[DF] .[DF]/.[DF]/ .[DF] .[DF] & [B,]4 | .[G,B,E] .[G,B,E] .[G,B,E]/.[G,B,E]/ .[G,B,E] & [E,]4 |] ``` --- --- url: /daily/physics/index.md --- # 物理随笔 --- --- url: /daily/physics/wandering-earth.md --- # 《流浪地球》观后感 ## 引力弹弓效应 看完《流浪地球1》后,有一个问题一直在困扰着我,为什么地球要靠近木星,以至于差点被吸进去,面临电影中那么危险的境遇呢?为什么不选择在木星不在旁边的时机冲出去呢? 这就涉及到了一个天体物理学的知识点:**引力弹弓**。 我们知道,飞行器想要飞出太阳系,需要加速到**第三宇宙速度**。但其实更准确来讲,我们需要**相对于太阳的速度**达到某个速度,就能摆脱太阳引力了。但如果直接加速到这个速度,需要消耗大量的燃料,按照现在地球上的技术是不可能做到的。那么有没有节省燃料的方法呢? 根据**开普勒第一定律**,行星绕太阳的轨道是椭圆形的,太阳位于椭圆的一个焦点上。入射的行星在接近太阳时会加速,离开太阳时会减速。 整张图是延虚线对称的,当行星位于椭圆的对称位置时,例如图中上半椭圆的点和下半椭圆标记的对应点位时,很显然纵向速度相同,横向速度相反。当然了,这样说有点复杂,我们简单用一句话概括就是:**入射速度和出射速度大小相同,且方向近似相反**。 考虑到木星的质量是地球的数百倍,和木星相比,地球就像是一个飞行器一样。如果地球绕着木星飞,也应该满足**开普勒第一定律**。木星本身是绕着太阳公转的,《流浪地球1》中,人类让地球围绕木星转,那么根据上述结论,**地球绕木星**的**入射速度和出射速度大小相同,且方向近似相反**。如果让**地球绕木星**的入射方向和**木星绕太阳**公转方向近似相反,则出射方向就会与**木星绕太阳**公转方向近似相同。接下来,就可以这样推理了: 我们令**木星绕太阳**公转的速率为$|\vec{v_1}|$,**地球绕木星**的速率为$|\vec{v_2}|$。由于速度是一个向量,需要考虑方向的,同向的速度相加会变大,即$|\vec{v_1}|+|\vec{v_2}|$,而反向的速度相加会变成其差值,即$|\vec{v_1}|-|\vec{v_2}|$。只要我们让入射的时候是反向的,出射的时候变成同向,这样一来,就可以获得不超过$2|\vec{v_2}|$的额外速度。这就叫做**引力弹弓**效应。《流浪地球1》这部电影主要描述的就是利用**引力弹弓**效应,借助木星给地球加速的这个过程。 利用**引力弹弓**效应可以大大节约太空旅行的燃料消耗,这也是为什么《流浪地球》中要冒着那么大的危险靠近木星的原因。 实际上,**引力弹弓**效应在现实中已经被广泛应用于太空探测器的飞行路径设计中。大家平时听说的“旅行者一号”“旅行者二号”飞出太阳系的过程,其实也是利用了**引力弹弓**效应。它们在飞出太阳系之前,先后绕过了木星、土星、天王星、海王星,借助这些行星给自己加速。 说句题外话,“旅行者一号”和“旅行者二号”出发前,科学家们精确计算了它们的飞行轨迹,确保它们在飞出太阳系时恰好能够经过木星、土星、天王星、海王星,这和发射时地球、木星、土星、天王星、海王星的相对位置是有关系的。实际上,想要恰好有这么合适的一个相对位置,虽不说是千载难逢,但也不是经常能遇到的。 ## 洛希极限 影片里面还有一个**洛希极限**的概念。我们用一个不怎么恰当的比方来解释一下: 地球本身是有大小的,并不是一个质点。根据牛顿的万有引力定律,相距越近,引力越大,因此地球靠近木星的一面受到木星的引力会大于远离木星一面受到的引力,而这个差值会随着靠近木星而变得越来越大。如果太靠近木星,这个引力差会太大,大到足以把地球撕碎,这个会把地球撕碎的临界距离就叫**洛希极限**。 当然了,上述并不是严格的推导过程,这里也不详细展开介绍。 影片中,最后紧张激烈的时刻就是要赶在地球和木星距离达到**洛希极限**之前成功点火,利用木星上的氢气和地球上的氧气充分混合后点燃发生的爆炸,把地球弹开。但是无奈的是,达到**洛希极限**之前,点火距离都不够引燃木星上的氢气,地球面临被撕碎的危险。最后刘培强只能选择用空间站冲向木星,引燃木星上的氢气。 值得一提的是,洛希极限分为刚体洛希极限和流体洛希极限,如果环绕着运动的卫星是流体,由于流体会被潮汐力拉长,导致卫星并不会呈一个完整的球形,而是一个椭球形,那么洛希极限就会变得更大。这里引用一下维基百科中的公式: > 设洛希极限为$d$。 > > 对于一个完全刚体、圆球形的卫星,假设其物质都是因为重力才合在一起的,且所环绕的行星亦是圆球形,并忽略其他因素如潮汐变形及自转。 > > $$ > d = R\left(2 \times \dfrac{\rho_M}{\rho_m}\right)^{1/3} \approx 1.260R\left(\dfrac{\rho_M}{\rho_m}\right)^{1/3} > $$ > > 其中$R$是卫星所环绕的星体的半径,$\rho_M$是该星体的密度,$\rho_m$是卫星的密度。 > > 对于是流体的卫星,潮汐力会拉长它,令它变得更易碎裂。 > > $$ > d \approx 2.423R\left(\dfrac{\rho_M}{\rho_m}\right)^{1/3} > $$ 我们可以查出木星和地球的平均密度分别为$\rho\_\text{木}=1330\text{ kg/m}^3$,$\rho\_\text{地}=5515\text{ kg/m}^3$。带入公式计算出: $$ \begin{aligned} d\_\text{刚} & \approx 1.260R\_\text{木}\left(\dfrac{\rho\_\text{木}}{\rho\_\text{地}}\right)^{1/3} \approx 1.260 \times R\_\text{木} \times \left(\dfrac{1330}{5515}\right)^{1/3} \approx 0.784R\_\text{木}\\ d\_\text{流} & \approx 2.423R\_\text{木}\left(\dfrac{\rho\_\text{木}}{\rho\_\text{地}}\right)^{1/3} \approx 2.423 \times R\_\text{木} \times \left(\dfrac{1330}{5515}\right)^{1/3} \approx 1.508R\_\text{木} \end{aligned} $$ 当进入流体洛希极限时,地球上的气体会开始被抽离,木星上的氢气和地球上的氧气开始混合。进入刚体洛希极限时,地壳才开始会被撕裂。而上面的计算结果表明: $$ d\_\text{流} > R\_\text{木} > d\_\text{刚} $$ 实际上,刚体洛希极限比木星的半径要小。换句话说,还未进入刚体洛希极限,地球就已经进入木星内部了。 我们引用一下维基百科中的结论: > 如果一个刚体卫星的密度是所环绕的星体的密度两倍以上(例如一个巨大的气体行星跟刚体卫星),$d\ d\_\text{刚} > R\_\text{木}$,这样才会有地球在撞上木星之前就被木星撕裂的危险。这和我们计算出来的结果不同。考虑到《流浪地球1》是科幻电影,我们也不必过多在意。 --- --- url: /daily/silksong/index.md --- # 丝之歌速通相关 --- --- url: /daily/silksong/5hour.md --- # 丝之歌5小时成就路线 ::: warning 注意 个人获得5小时成就的路线,仅供参考。此路线极限是在2小时之内,有很多容错。 ::: 最终配置: * 生存:碎面甲、磁石骰、7血 * 输出:齿轮蜂(对空)、2钉 * 纹章:猎手足够,也可以选择漫游者纹章(比较顺路) * 移动:飞针冲刺、二段跳 * 另外一个蓝槽位可选注丝套针、记忆晶石、爪镜 * 高庭连战无吸魂怪 * (可选)荒沼翼主加蒙德助战、高庭连战加蒙德助战 难点应该仅有二见蕾丝。如果一周目打过结局二,应该对苍白之母的技能非常熟悉了。 **沿途捷径顺手开,不差这点时间,毕竟谁也不能保证不会翻车。** ## 前期参考 Any% 速通路线 前期缺念珠,只能坐免费的椅子。 * 苔藓之母 * 丝之矛 * (可选)髓骨洞窟有一个skip,可跳过遭遇战直接到达制图师处 * 在丝之矛所在的地图,顺路左转打破隐藏墙拿古董 * 钟道兽 * 前往深坞 * (可选)顺路拿髓骨洞窟的制造金属 * 小游戏房间上方隐藏念珠堆 * (可选)顺路拿直针并装备(小游戏房间) * 获取冲刺 * 蕾丝 * 斗篷 * 收集斗篷任务时,顺路捡一个念珠串(在接任务的屋子出来地图右下角岩浆处) * 拿完斗篷顺路拿面具碎片 * 第四圣咏团不会skip就打 * 二选一: * 开灰沼钟道 * 拿灵丝风暴。但请注意,虽然灵丝风暴在后续的小怪战斗中很强,但是这个遭遇战太容易翻车了 * 如果拿了灵丝风暴,则可以在这里敲灰沼的钟,后续就不需要回来了,也不需要开灰沼钟道了 * 荒沼翼主 * (可选)触发加蒙德助战条件:和加蒙德对话 → 荒沼翼主战败一次 → 加蒙德对话 → 坐椅子 → 荒沼翼主顶上的那张图必经之路上和加蒙德对话 * 获取爬墙 * 中途开启甲木林的钟道 * 中途挑衅勾引飞怪进行skip * 黑寡妇 * (可选)开启甲木林中间的椅子通往黑寡妇的捷径,以防翻车。顺路可以捡一个念珠串。 * 卖古董、升钉、开钟心镇的钟道 * 敲灰沼和甲木林的钟 * 进入第二幕之前最后的收集 * 3个面具碎片(甲木林、沙噬虫道、出生点织巢) * (可选)漫游者纹章 * 顺路捡一些古董(甲木林贴着左边掉入骸底镇、出生点织巢),去钟心镇卖掉 ## 冲刺和二段跳 考虑到后续非常容易翻车,**建议沿途多开椅子**,因此可以准备400-500念珠再前往蚀阶,不够就去中途酒馆右边刷几分钟。 以下默认沿途开椅子。 * 进入蚀阶,开蚀阶钟道 * 顺路捡忆境纪念盒 * 末代裁决者 * 开圣堡钟道和电梯 * 在圣歌盟地捡白愈钥匙,通过电梯传回圣堡钟道 * **全程不要敲圣歌盟地的钟**(敲钟会使圣咏殿的怪变多) * 进入白愈厅,前往工厂右侧,获取飞针冲刺 * (可选)顺路捡注丝套针 * 通过电梯传回圣堡钟道,经过罪石牢狱前往费耶山,获取二段跳 * (可选)顺路捡记忆晶石 * 最后的收集 * 面具碎片(费耶山、蚀阶) * 磁石骰(蚀阶钟道旁) * \~~髓骨洞窟忆境纪念盒(漫游者纹章只有两个蓝槽,因此两个就够了,这个可以不拿)~~ * 髓骨洞窟小游戏房间坐椅子 * 面具碎片(深坞) * 小游戏房间上方进入猎者小径 * 兽蝇门口的忆境纪念盒(如果用猎手纹章,则只需要一个纪念盒,这个可以不拿) * 商人尸体处捡碎面甲 至此,应该是离7血差一个面具碎片。 ## 最后部分 * 机枢舞者 * 一路跑向低语书库 * 获取苍白油 * 低语书库面具碎片 * 前往特罗比奥,开捷径,坐圣堡钟道的椅子,升钉,SL回来 * 特罗比奧 * (可选)打完捡爪镜 * 管理员的旋律 * 机枢核心上层,建筑师的旋律 * 前往高庭,获取齿轮蜂,关掉吸魂怪的召唤 * 高庭连战,指挥官的旋律 * (可选)加蒙德助战条件是,以下的1、2、3中的任意一处遇到加蒙德后,去4处就可以遇到加蒙德,将其击败后即可在高庭获得其助战 1. 上文提到,前期在灰沼可以遇到加蒙德 2. 在前往高庭的必经之路路上**可能**会遇到加蒙德 3. 从白愈厅的白愈钥匙开锁处一直向左走的长廊里**可能**会遇到加蒙德 4. 开启书库右侧隐藏,向上前往圣歌盟地,然后坐椅子(在大部分跑图已经进行完后,你可以放心地在圣歌盟地敲钟了),再回来就能碰到加蒙德 * 二见蕾丝 * 苍白之母 附:机枢核心上层谜题 ```text :no-line-numbers 1 3 3 3 2 0 0 3 0 1 1 1 1 1 0 2 ``` --- --- url: /daily/silksong/act-3.md --- # 灵丝与灵魂前置 | 必要祈愿 | 祈愿前置 | |--------|-------------------------------| | 柔韧棘芯 | | | 骸底修缮 | 打败钟道兽 | | 救命桥 | 骸底修缮 + 去过灰沼 + 打败黑寡妇 | | 清剿腐囊虫 | | | 重振钟心镇 | 卖过古董 + 升级过针 | | 钟心荣光 | 重振钟心镇 + 击败机枢舞者/获得飞针/到达圣歌盟地三选一 | | 建设圣歌盟地 | | | 强化圣歌盟地 | 建设圣歌盟地 + 拿到三个旋律 |\ | 愈伤良方 | | | 终迹 | | 还需要: * 获得二段跳 * 让跳蚤搬到蚤托邦 * 在摇篮圣所击败蕾丝 * 获得钟居钥匙 * 获得17个祈愿分数点 ::: tabs @tab 真结局 真结局速通中,想要快速获得17个祈愿分数点,建议完成以下16个打勾的祈愿加上2个最简单的送货任务。 **1分祈愿** @flexs gap-x-15 flex-wrap @flex **骸底镇** * \[x] 朝圣者的衣装 * \[x] 爆裂燧甲虫 * \[x] 暴君讨伐 * \[x] 希望丰碑 @flex **钟心镇** * \[x] 银光铃铛 * \[x] 走丢的送货员 * \[x] 针匠秘油 * \[x] 寻虫启示 * \[ ] 暴怒兽蝇 @flex **圣歌盟地** * \[x] 精致刺针 * \[x] 商贾无踪 * \[x] 圣咏外衣 * \[ ] 最终觐见 * \[x] 再寻商贾 * \[ ] 哀嚎母体 @flex **其它** * \[x] 苔莓收集(苔穴) * \[x] 猎人任务(灰沼) * \[x] 花芯仪式(甲木林) * \[ ] 炼金助手(沙噬虫道) * \[x] 蟑螂内脏(罪途) * \[ ] 纺络珍馐(圣咏殿) * \[ ] 育巢盛宴(腐殖渠) @end **0.5分祈愿** * 送货虫处接到的任意送货任务(每个首次完成计入0.5分) @tab 100% 100%速通中,想要快速获得17个祈愿分数点,建议完成以下17个打勾的祈愿,不做送货任务。 **1分祈愿** @flexs gap-x-15 flex-wrap @flex **骸底镇** * \[x] 朝圣者的衣装 * \[ ] 爆裂燧甲虫 * \[x] 暴君讨伐 * \[ ] 希望丰碑 @flex **钟心镇** * \[ ] 银光铃铛 * \[x] 走丢的送货员 * \[x] 针匠秘油 * \[x] 寻虫启示 * \[x] 暴怒兽蝇 @flex **圣歌盟地** * \[x] 精致刺针 * \[x] 商贾无踪 * \[x] 圣咏外衣 * \[x] 最终觐见 * \[x] 再寻商贾 * \[ ] 哀嚎母体 @flex **其它** * \[x] 苔莓收集(苔穴) * \[x] 猎人任务(灰沼) * \[x] 花芯仪式(甲木林) * \[x] 炼金助手(沙噬虫道) * \[ ] 蟑螂内脏(罪途) * \[x] 纺络珍馐(圣咏殿) * \[x] 育巢盛宴(腐殖渠) @end **0.5分祈愿** * 送货虫处接到的任意送货任务(每个首次完成计入0.5分) --- --- url: /daily/silksong/any.md --- # 丝之歌Any%速通路线 ## Any% 路线 ::: note 注意 * 如果只是为了刷5小时通关成就,建议参考[这篇文章](5hour.md)。 * 在最新版本下,tc修复了各种skip,因此主流路线一定是斗篷线。获取斗篷之后,根据进入圣咏殿的方式不同,分为敲钟线和迷雾线。**在最新版本下,敲钟线最快,下图中已将此路线标粗**。 * 在最早的版本,还有两个可行的路线:跳蚤线和钥匙线。**在回退版本的情况下,钥匙线最快,下图中已将此路线标粗**。 * Low%则不拿丝之矛、钟道兽的丝之心、冲刺,少了3点完成度。前期类似Any%钥匙线,先经由沙噬虫道进入甲木林,拿到爬墙之后,无冲刺也可以利用skip见到蕾丝,拿到斗篷后,剩下的部分与Any%迷雾线类似。 ::: ```mermaid flowchart TD A1[苔藓之母] ==>|Any%| A[丝之矛
钟道兽
冲刺] ==>|钥匙线| I[刷钱买钥匙
经沙噬虫道
进入甲木林] ==> G[爬墙
黑寡妇] ==> J1[迷雾
幽影
进入第二幕] ==> O1[机枢舞者
特罗比奥] -.->|需回退版本| P[飞针冲刺
管理员的旋律
建筑师的旋律
高庭竞技场
指挥家的旋律
蕾丝2
苍白之母] A -->|跳蚤线| B2[蕾丝
5只跳蚤] --> W[跳蚤旅团
进入灰沼] --> G A ==>|斗篷线| B[蕾丝
斗篷] ==>|敲钟线| L[灰沼敲钟
荒沼翼主] ==> M[爬墙
黑寡妇
甲木林敲钟] B -->|迷雾线| F[荒沼翼主] --> H[爬墙
黑寡妇] --> J[迷雾
幽影
进入第二幕] A1 --->|Low%| B1[刷钱买钥匙
经沙噬虫道
进入甲木林
爬墙] --> C1[蕾丝
斗篷] --> D1[黑寡妇] --> J M ==> K[末代裁决者
进入第二幕] J --> O[机枢舞者
特罗比奥] ==> P K ==> O ``` ## 机枢核心上层谜题 ```text :no-line-numbers 1 3 3 3 2 0 0 3 0 1 1 1 1 1 0 2 ``` --- --- url: /daily/silksong/completion.md --- # 丝之歌完成度 建议使用[丝之歌存档分析器](https://gitee.com/CuteReimu/ss-completion/releases) **纹章(6%)**:收割者、漫游者、野兽、巫妪、建筑师、萨满 **法术(6%)**:丝之矛、灵丝风暴、十字绣、丝刃镖、符文之怒、苍白之爪 **能力(8%)**:疾风步、蛛攀术、织忆弦针、飞针冲刺、灵丝升腾、蓄力斩、风灵谣、永绽花 **强化(29%)**:丝之心(3%)、织针升级(4%)、工具袋容量(4%)、工具伤害(4%)、生命值(5%)、灵丝轴(9%) **工具(51%,不含陷阱设置器)** @flexs gap-x-12 flex-wrap @flex * 直针 * 三重镖 * 蛰刺碎片 @flex * 钉刺 * 长针 * 弧爪/曲镰 @flex * 投掷环 * 爆燃囊 * 螺切刃 @flex * 丝弹(三选一) * 掘洞钻 * 机轮刃 @flex * 齿轮蜂 * 念珠炮 * 电枢球 @flex * 燧石板 * 跳蚤秘酿 * 生质液瓶 @end *** @flexs gap-x-5 flex-wrap @flex * 德鲁伊之眼/双瞳 * 熔岩钟 * 护佑钟 @flex * 花芯囊 * 碎面甲 * 多重缚丝器 @flex * 织光仪 * 锯齿环 * 注丝套针 @flex * 储丝延展器 * 储备缚丝 * (双生)爪镜 @flex * 记忆晶石 * 撬脏钩 * 伏特丝 @flex * 速射索 * 净界花环 * 长爪 @flex * 灵火提灯 * 蚤母卵 * 针徽 @end *** @flexs gap-x-30 flex-wrap @flex * 罗盘 * 碎壳坠 * 磁石胸针 @flex * 负重环带 * 棘刺手环 * 亡虫囊/壳囊 @flex * 磁石骰 * 迅捷脊锁 * 登极握爪 @flex * 蛛丝弦 * 丝速脚环 * 窃者印记 @end 共计100% --- --- url: /daily/silksong/delivery-mod.md --- # 速通社区关于快递Mod的公告 以下内容来自《空洞骑士》速通社区公告。 28891 之前版本的快递任务中,可能会刷新已经送完的快递的 RNG,这将导致在真结局速通中可能由于糟糕的 RNG 导致前一个小时的努力全部浪费,28891 之后的版本修复了这个问题。 社区投票决定同意在 28891 之前的版本使用 Mod 来修复这个 RNG 问题,但需注意:这个 Mod 是 beta 版本,因此如果后续发现有问题可能会导致记录不过审。 该 Mod 不可与 BepInEx 共存,所以如果需要同时使用 Debug Mod 进行练习,建议自行安装两个不同版本的丝之歌,一个用于安装 Debug Mod 进行练习,另一个用于安装此 Mod 进行速通。 具体步骤: 1. 点击这里下载::file-zipper::快递补丁beta.zip,然后将其解压 2. 在 Steam 中右键丝之歌 → 管理 → 浏览本地文件,打开游戏目录。将上述解压后的文件拖进游戏根目录,然后启动游戏,之后你会看到左上角显示 speedpatchV0.10 3. 回到游戏的文件夹找到`hksr_patches.json`然后把`CourierRNGFix`改成`true` --- --- url: /daily/silksong/down-patch.md --- # 丝之歌如何回退版本 ## 降版本方法 1. 按 `Win + R` ,在弹出的窗口中输入 `steam://open/console` 并确定 2. 进入 steam 控制台,在下方输入 ```bash download_depot 1030300 版本补丁号 ``` 比如要下载1.0.28324版本,那就输入 ```bash download_depot 1030300 1030301 3229726349000518284 ``` ::: note 注意 在`download_depot`后一共有3个数字,`1030300`表示丝之歌,`1030301`表示 Windows,最后面一长串数字是版本对应的序号。这3个数字都不要漏了。 ::: 3. 回车,等待下载 4. 下载完成后,在 steam 控制台上找到输出的文件路径,在电脑中打开这个路径 5. 在 `depot_1030301` 文件夹中创建一个空白文本文件 `steam_appid.txt` ,并写入: ```text 1030300 ``` ::: note 注意 编辑完了别忘了保存 ::: 6. 在 steam 中找到 `添加非steam游戏` ,在弹出的窗口中点击 `浏览` 按钮,选中 `depot_1030301` 文件夹中带有丝之歌图标的 `Hollow Knight Silksong.exe` 以上步骤结束后就可以在 steam 里使用旧版本了 ## 目前已发布版本 ::: warning 注意 本文目前更新到 1.0.30000 版本,更新不一定及时,请注意核对。 ::: 以下内容来自 [::b:github =20 /black:: https://github.com/hk-speedrunning/Silksong-Resources](https://github.com/hk-speedrunning/Silksong-Resources/blob/main/manifests.md) ### 1.0.28324 最初的发布版本。 * Windows: `1030301 3229726349000518284` * MacOS: `1030302 1365730835793684614` * Linux: `1030303 8384590172287463475` ### 1.0.28497 * Windows: `1030301 539129767115354441` * MacOS: `1030302 8670159430480702509` * Linux: `1030303 6701825740120558137` ### 1.0.28561 * Windows: `1030301 8642535143474926050` * MacOS: `1030302 9022715293716759452` * Linux: `1030303 6373658714389144408` ### 1.0.28650 * Windows: `1030301 3900764848237536293` * MacOS: `1030302 7832939953657548180` * Linux: `1030303 7495630131038458486` ### 1.0.28714 * Windows: `1030301 5977483240701257214` * MacOS: `1030302 7917356342743942630` * Linux: `1030303 1617544312110692774` 此补丁有2个版本,上面的那个使用了新的Unity版本,这将会使一些邪道更难使用。下面的这个是旧的Unity版本: * Windows: `1030301 6773297648406051922` * MacOS: `1030302 7562703448257682540` * Linux: 同上 ### 1.0.28891 * Windows: `1030301 3690203822520536668` * MacOS: `1030302 2374057204384257562` * Linux: `1030303 5954103139200615141` ### 1.0.29242 * Windows: `1030301 426651197780377263` * MacOS: `1030302 2058007571598677908` * Linux: `1030303 8078874762924599313` ### 1.0.29315 * Windows: `1030301 3545882420322545098` * MacOS: `1030302 7345001466169537628` * Linux: `1030303 4349246050376532986` ### 1.0.29909 * Windows: `1030301 3853982342899707391` * MacOS: `1030302 8626896570586771768` * Linux: `1030303 2218100490558811317` ### 1.0.29926 * Windows: `1030301 3703686001292550981` * MacOS: `1030302 4837712425280970616` * Linux: `1030303 6891565886978421564` ### 1.0.29980 * Windows: `1030301 468692862190470536` * MacOS: `1030302 4899622998101152532` * Linux: `1030303 7780372375671997910` ### 1.0.30000 steam 上最新的发布版本。 * Windows: `1030301 4421626056705534276` * MacOS: `1030302 4136280015582261500` * Linux: `1030303 7921642076658611197` --- --- url: /daily/silksong/livesplit.md --- # LiveSplit 常见问题 本文主要收录了一些LiveSplit在使用过程中一些常见的进阶问题。有关于计时器怎么用、怎么自动计时等基础问题,请参考[空洞骑士计时器生成器FAQ](../hollowknight/hksplitmaker-faq.md)和[丝之歌计时器生成器FAQ](sssplitmaker-faq.md)。 ## 大分段下面套小分段怎么弄 打开**LiveSplit** → 右键 → Edit Layout,你会看到有很多个组件。找到其中的 Splits ,将其删掉,然后点击左边的加号,新增一个 SubSplits 组件,放在相同位置。这样设置后,你的**LiveSplit**就支持折叠分段了。 经过上述设置后,编辑你的模板(\*.lss)文件,在你想要折叠进去的分段名前面加上`-`,这个分段就变成了小分段,小分段会折叠入其下方的一个大分段。 其实计时器生成器的很多长流程模板中都已经帮你加好了`-`。 :::: details 举个例子 假设分段名如下: | 分段名 | |-----| | -A | | -B | | C | | -D | | E | 那么显示的折叠会是这样: ::: file-tree * C * A * B * C * E * D * E ::: 从小分段上看,依旧是A、B、C、D、E五个分段。由于C的分段名前面没有加`-`,因此会将A、B、C折叠为一个大分段,大分段名默认是C。同理,D、E折叠为一个大分段,大分段名是E。 换句话说,**大分段名默认使用最后这个不加`-`的小分段名**。 那么如何只修改大分段名,保持小分段名不变呢?只需把`C`改为`{XX}C`,这样小分段名还是C,而折叠外面的大分段名就变成`XX`了。 :::: ## 如何设置对比目标 打开**LiveSplit** → 右键 → Compare Against,下面有几个选项: * 黑屏加载期间是否计时: * Game Time:黑屏加载期间不计时 * Real Time:全程计时 * 对比目标: * Personal Best:和个人PB对比 * Best Segments:和各分段最佳对比(注:各分段最佳加起来就是SOB:sum of best) * Average Segments:和各分段平均值对比 ## 如何在LiveSplit上显示丝之歌完成度和受击次数 这是**LiveSplit**的《丝之歌》计时插件自带的功能,你需要确保已经[激活插件](sssplitmaker-faq.md#首次使用)。接下来按照以下步骤执行: 1. 打开**LiveSplit** → 右键 → Edit Layout 2. 点击加号 → Information → Text ,这将会增加一个 Text 组件 3. 根据你的喜好调整组件的顺序 4. 双击 Text ,打开它的设置界面,勾选 Custom Variable ,在 Custom Variable Name 中填上 `percent` 就可以显示完成度,填上 `hits` 就可以显示受击次数。如果你想同时显示完成度和受击次数,可以增加两个 Text 组件 5. 一切设置完成后,按`OK`按钮关闭界面,右键**LiveSplit** → Save Layout 保存你的设置 值得一提的是,完成度并不是绝对地实时更新,只会在特定的时候(例如SL、游戏中触发某个事件)才会更新。 计时插件的完成度统计是默认开启的,而**受击次数计数并没有默认开启**,你需要手动在插件设置中开启它,方法如下: 1. 首先在**LiveSplit**中加载好你的计时板子,然后右键**LiveSplit** → Edit Splits 2. 在弹出的窗口里点击 Settings 按钮 3. 在弹出的窗口中,确保已经勾选上了 Hit Counter(受击次数计数器) ## 如何让LiveSplit窗口变得半透明 首先要明确,你是想自己看起来半透明,还是直播/录的视频看起来窗口半透明。 * 如果想让自己看起来窗口半透明,打开LiveSplit → 右键 → Edit Layout → 点击下方的Layout按钮 → 调节`Opacity`。 * 如果想让直播/录的视频中看起来窗口半透明,应当去录屏软件(例如OBS)里面找对应的设置进行调节。 ## LiveSplit每次开启都很慢 在防火墙中设置禁止 `LiveSplit.exe` 联网即可。此方法不一定 100% 有效,但值得一试。 原理是,**LiveSplit**每次启动时都会检测更新(包括**LiveSplit**本身和各个游戏计时插件的版本更新),更新成功或失败后才会打开**LiveSplit**界面。更新地址本身访问较慢,因此会卡很久,禁止联网可以让它立即就失败,界面就打开了。当然,你也可以尝试使用其它方法达到类似的效果(例如修改`/etc/hosts`让访问更新地址立即失败)。 ::: details Windows如何禁止某个应用程序联网? 1. 在控制面板中找到 Windows Defender 防火墙的设置页面,点击`高级设置`。\ ![](/hollow-knight/windows-defender-1.png) 2. 在`出站规则`中,点击`新建规则`。\ ![](/hollow-knight/windows-defender-2.png) 3. 勾选`程序` → 下一步 → 选择程序的路径 → 下一步 → 勾选`阻止连接` → 后面一直点下一步即可。 **注意:所选程序的路径如果包含特殊字符,可能会设置失败。** ::: ## 窗口遮挡 在LiveSplit设置中设置了“总在最前”,但游戏全屏了仍然会挡住LiveSplit窗口怎么办? 1. 将游戏切换成窗口模式(按`Alt + Enter`),窗口大小设置为与你的屏幕分辨率相同,就解决了游戏窗口挡住LiveSplit的问题。但窗口会有标题栏和边框,并不美观。 2. 在 Steam 中找到《空洞骑士》游戏,右键 → 属性 → 常规,在启动选项中添加`-popupwindow`。重新启动游戏,会发现窗口的标题栏和边框都消失了。 《丝之歌》自带**无边框窗口模式**,可以在游戏设置中开启,省去了上述第2步操作。 --- --- url: /daily/silksong/mist.md --- # 丝之歌迷瘴地图机制 **迷瘴 (The Mist)** 是《丝之歌》中的一个地点,作为连接区域,连接着**罪途**与**腐汁泽**的子区域**管风琴**。这使得玩家无需完成第一幕的敲钟任务即可进入圣咏殿(开启第二幕)。 该区域是一个迷宫区域,进入后,玩家会被传送到 8 个房间之一,每个房间有多个出口。每个出口会随机通向剩下的 7 个可能房间中的一个,其中一个出口是正确出口。如果进入了错误出口,就会回到迷宫的起点。 为了识别正确出口,玩家必须对着成群的**光蛾**弹琴,它们会指引玩家找到正确出口。**由于此机制,弹琴是推进迷宫进程的硬性要求**。从第二个房间之后,会出现幽灵怪攻击玩家,阻碍玩家的前进。每个房间有各种陷阱,并且大多数房间的下方都有虚空,坠落虚空或其他原因死亡也会重置迷宫。 ::: caution 注意 在2025年11月初的更新补丁中,基本所有迷雾相关的机制全部已经被移除,因此新版本想要通过迷雾只能在每张地图分别弹琴确认正确出口,无法使用下述的迷瘴机制来加速通过迷瘴。 ::: ## 迷瘴机制 :::: details 旧版本的迷瘴机制 [::b:bilibili =20 /#FE7398:: 点击跳转到B站视频讲解](https://www.bilibili.com/video/BV1H5pUzmEiD) ```mermaid flowchart LR A[图1] --> B[图2] --> C[图3] --> D[一字路口] --> E[图4] --> F[图5] --> G[最终房间] F -.->|可能| H[图6] -.-> G ``` 1. 图与图从物理上是连通的,从前一张图的左侧出口出去,就会从后一张图的右侧进来,这叫做**物理连通性**。 2. 在任意一张图(例如图2),我们管进来的地方叫做入口,如果直接从入口出去,不算作走错,虽然你回到了前面的一张图,但是此时还算作图2,但是出口会被重新roll。这个机制我们叫做回溯。如果后面一张图太大或者前面一张图好跑,回溯可以降低跑图难度。 * 上面这个技巧在图4不能使用,因为使用后会回到一字路口,你必须再跑到一字路口对面,才算作图4,很浪费时间。 3. 一字路口肯定是横着的,要么从左进从右出,要么从右进从左出。考虑到**物理连通性**,图3有这样一个机制:所有左/右出口都是正确出口,不需要弹琴确认。(当然了,你不能从入口直接出去) 4. 最终房间类似也是横着的,一定从左进从右出。考虑到**物理连通性**,图5的正确出口一定是右侧。 * 如果图5有两个右侧出口,你需要弹琴确认; * *8张图只有两张大图有两个右侧口* * 如果图5只有一个右侧出口,就是正确答案; * 如果图5没有右侧出口,那么就会多出来一个图6,你可以回溯来避免多出来一个图6。 * *8张图只有一张图没有右侧口* * 入口一定不是出口,例如图5有两个右侧口,但是其中一个是入口,那另一个肯定是出口。例如图5只有一个右侧口,但它是入口,那等于是没有右侧出口。 ::: tip 方案参考 目前相对比较效率且记忆成本低的方案是这样的(仅供参考): 1. 弹琴过图1 2. 回溯(回到上一张图)再弹琴过图2 * 考虑到记忆成本,我们刚才上一张图刚刚跑过,相对较熟,不需要记 3. 在图3直接从左或者右侧最方便的出口出去(如果图1方便,也可以回溯一下) 4. 穿过一字路口 5. 弹琴过图4 6. 图5直接前往右出口,但是需要考虑以下情况: * 无右出口的图只有一张,需要记一下,遇到这张图则回溯 * 有两个右侧口的图只有两张,仅在这两个右侧口都不是入口的情况下,则需要弹琴(上述条件全都满足,属于RNG很糟糕的情况),根据图4的方便程度考虑要不要回溯,如果不回溯,虽然需要弹琴确认,但既然是速通,最后这50%几率猜一个 * 如果图4可以很方便到达唯一的右出口,则可以在图5回溯 记忆成本是只需要记那张无右侧口的图(观察三个口的样子,方便一进图就能判断),其它适当记忆即可(跑的过程中能认出来)。 ::: :::: 新版本迷雾机制调整后,已经不能**回溯**了,并且图3的出口改为了左或者右侧**随机一个**(以前是左或者右侧任意一个出口都正确),仅剩下**物理连通性**对速通有一定的意义。例如,图3的出口一定是左或者右侧**随机一个**,图5的出口一定是右侧随机一个,而入口一定不是出口,如果运气好能够排除到只剩下一种可能,也可以对速通有帮助。 ## 光蝇 \~~(待确认)有一种说法是,即使使用了上述回溯机制回到了上一个房间,光蝇位置也不会重新刷,此结论有待确认。如果这一点是真的,当你发现某张地图的光蝇位置非常好到达,进入下一张图之后你可以回溯到上一个房间,并继续弹琴,以加快速通的速度。这个策略可以用在图1进入图2。~~ 新版本没了迷瘴机制后,回溯已经没有意义了,因此光蝇位置的讨论也没有意义了。 ## 幽灵 迷瘴中的主要危险是幽灵敌人。一般而言,每个房间幽灵最多可能出现的数量满足以下条件: * 首次进入图1,不会刷新幽灵。进入错误出口再次回到图1,最多刷新2个幽灵。 * 从图2开始,图N最多会刷新N个幽灵,上限为5。 * 较小的房间最多只会刷新3个幽灵,即使它作为图4及以后的地图。 --- --- url: /daily/silksong/speedrun-api.md --- # 通过 Speedrun API 获取排行榜数据 speedrun.com 官方提供了 [Speedrun API](https://github.com/speedruncomorg/api),可以供我们通过 API 接口拉取 JSON 格式的数据。 它提供的功能非常强大,这里我们以《空洞骑士:丝之歌》为例进行一些简单介绍。 值得一提的是,对同一个IP,它有每分钟 100 次的访问限制。 直接访问 ,会返回一段 JSON 数据,它代表了丝之歌游戏的基本信息。最下面有这样一段内容: ```json :no-line-numbers :collapsed-lines=21 {15-18} { "links": [ { "rel": "self", "uri": "https://www.speedrun.com/api/v1/games/y65r7g81" }, { "rel": "runs", "uri": "https://www.speedrun.com/api/v1/runs?game=y65r7g81" }, { "rel": "levels", "uri": "https://www.speedrun.com/api/v1/games/y65r7g81/levels" }, { "rel": "categories", "uri": "https://www.speedrun.com/api/v1/games/y65r7g81/categories" }, { "rel": "variables", "uri": "https://www.speedrun.com/api/v1/games/y65r7g81/variables" }, { "rel": "records", "uri": "https://www.speedrun.com/api/v1/games/y65r7g81/records" }, { "rel": "series", "uri": "https://www.speedrun.com/api/v1/series/wnp1vlzn" }, { "rel": "derived-games", "uri": "https://www.speedrun.com/api/v1/games/y65r7g81/derived-games" }, { "rel": "romhacks", "uri": "https://www.speedrun.com/api/v1/games/y65r7g81/derived-games" }, { "rel": "leaderboard", "uri": "https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/zd39j4nd" } ] } ``` 我们主要看这个`categories`,访问 ,会得到这样一段 JSON 数据: ```jsonc :no-line-numbers { "data": [ { "id": "zd39j4nd", "name": "Any%", "links": [ { "rel": "variables", "uri": "https://www.speedrun.com/api/v1/categories/zd39j4nd/variables" }, { "rel": "leaderboard", "uri": "https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/zd39j4nd" } ] }, // { // ... // } ] } ``` `data`下面是一个数组,每个元素是一个类别,就以这个`Any%`为例,`leaderboard`这个链接就是所有`Any%`类别的排行榜。但是打开之后发现有很多内容,这是因为没有筛选,所以无论是 All Glitches 还是 NMG 都会显示在里面。那么如何筛选呢?就需要用到这个 `variables` 了,打开它后面的这个链接 ,会得到这样一段 JSON 数据: ```json :no-line-numbers :no-collapsed-lines { "data": [ { "id": "ylq4yvzn", "name": "Any% Subcategory", "values": { "_note": "`choices` is deprecated, please use `values` instead", "choices": { "qzne828q": "No Major Glitches", "lr34vkml": "All Glitches" }, "values": { "qzne828q": { "label": "No Major Glitches", "rules": "# No Major Glitches rules\n\nIf a glitch is not listed here, assume it is banned until it is listed. You can check with a mod in the Discord or create a forum post.\n\nAny accidentally performed major glitch that does not save time may be allowed by verifier's discretion.\n\n## Allowed Glitches\n\n- [Pogo Endlag Cancels](https://github.com/hk-speedrunning/Silksong-Rules/blob/main/glossary.md#pogo-endlag-cancel)\n- [Beast Boosts](https://github.com/hk-speedrunning/Silksong-Rules/blob/main/glossary.md#beast-boost)\n- [Bind Dash Refresh](https://github.com/hk-speedrunning/Silksong-Rules/blob/main/glossary.md#bind-dash-refresh)\n- [Tool Pogos (Drill/Snare w/ Voltvessels & others)](https://github.com/hk-speedrunning/Silksong-Rules/blob/main/glossary.md#voltvessel-drill-pogos)\n - Including Pin Pogos (_excluding_ pogoing infinitely spinning pins)\n- Lever Skips (including hitting chests through walls)\n- Queued Walljump Interrupts, including when Queued Walljump Interrupt Storage is incidentally gained.\n\n## Banned Glitches\n\n- Queued Walljump Interrupt Storage, when either:\n - The storage is prolonged through other movement, such as sprint or clawline.\n - The stored interrupt activates on NonSlider geometry.\n- Deep Docks Bridge Hazard Respawn\n- [Voltvessel One-way Wallbreak @ Bilewater-\u003EDucts shortcut](https://github.com/hk-speedrunning/Silksong-Rules/blob/main/glossary.md#voltvessel-wall-breaks)\n- [Fourth Chorus Skip Skip](https://github.com/hk-speedrunning/Silksong-Rules/blob/main/glossary.md#fourth-chorus-skip-skip)\n- [Trobbio Skips](https://github.com/hk-speedrunning/Silksong-Rules/blob/main/glossary.md#trobbio-skip---rune-rage)\n- [Triple Jump](https://github.com/hk-speedrunning/Silksong-Rules/blob/main/glossary.md#triple-jump)\n - If accidental, a 2s penalty may be applied instead of rejection.\n- Scuttlebrace Jump Reset\n- [Float Sprint](https://github.com/hk-speedrunning/Silksong-Rules/blob/main/glossary.md#float-sprint)\n- [Silkspear Storage](https://github.com/hk-speedrunning/Silksong-Rules/blob/main/glossary.md#silkspear-storage-trobbio-skip)\n- Any glitch that results in going Out of Bounds.\n- Main Menu Storage\n- Room Dupes\n- Any glitch that results in invincibility\n- Any glitch that results in enemy AI becoming unresponsive\n- Any glitch that results in invisibility where the player would otherwise be visible.\n - Any glitch that gives the player control while invisible while they would not otherwise have control.", "flags": { "miscellaneous": false } }, "lr34vkml": { "label": "All Glitches", "rules": "", "flags": { "miscellaneous": false } } }, "default": "qzne828q" } }, { "id": "rn1kmmvl", "name": "Any% Drifters Cloak?", "values": { "_note": "`choices` is deprecated, please use `values` instead", "choices": { "10vzvmol": "Cloakless", "qj70747q": "Cloak" }, "values": { "10vzvmol": { "label": "Cloakless", "rules": "", "flags": { "miscellaneous": false } }, "qj70747q": { "label": "Cloak", "rules": "", "flags": { "miscellaneous": false } } }, "default": "10vzvmol" } } ] } ``` 没用的部分我们省略掉了,可以看到这里有两段:`Any% Subcategory`和`Any% Drifters Cloak?`,它们分别代表了两个变量,前者是 All Glitches 和 NMG 的区别,后者是是否有披风的路线的区别。我们在访问排行榜的时候就可以通过这两个变量来筛选出我们想要的排行榜了。 例如我们打算筛选出 NMG + Cloakless 的排行榜,也就是说,变量`ylq4yvzn`的值为`qzne828q`,变量`rn1kmmvl`的值为`10vzvmol`,我们就可以在地址后面加上参数`?var-ylq4yvzn=qzne828q&var-rn1kmmvl=10vzvmol`。 结果我们发现,排行榜中只有每个玩家的ID,那么还需要调用一下用户信息的查询接口才能获取玩家的名字,这很麻烦。好在 API 还给我们提供了一个参数,在地址最后面加上`&embed=players`,那么在返回的 JSON 数据中就会多出来一个字段,顺带帮你查找了这些榜上用户的用户信息。 再者,我们可能只想显示前五名,那就继续加上 `&top=5`。 完整的地址如下:\ 得到的数据自行解析即可,这里就不花篇幅讲解了。下面给一个代码示例,仅供参考。(示例中的有些分类后续可能被速通社区修改,因此这些地址不一定永远正确,使用时请注意) ```python import requests import sys from datetime import datetime, date URL = {"anylp":"https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/zd39j4nd?var-ylq4yvzn=qzne828q&var-rn1kmmvl=qj70747q", "anyrp": "https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/zd39j4nd?var-ylq4yvzn=qzne828q&var-rn1kmmvl=10vzvmol", "te":"https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/n2y0m18d?var-dloed1dn=qyzod221", "100noab":"https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/rkl6zprk?var-rn1k7xol=lx5o7641&var-38dg4448=1w4p4dmq", "100ab":"https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/rkl6zprk?var-rn1k7xol=lx5o7641&var-38dg4448=qoxpx35q", "judgement":"https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/wk6544o2?var-jlz631q8=1w4ozxvq", "low":"https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/wkp4r60k?var-9l7geqpl=1397dnx1", "ab":"https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/w206ox52?var-kn0eyxz8=10vzo8wl", "twisted":"https://www.speedrun.com/api/v1/leaderboards/y65r7g81/category/9kvvl0ok?var-yn26pzel=le2z97kl", "苔穴":"https://www.speedrun.com/api/v1/leaderboards/yd4r2x51/level/9m58yezd/xd1ypjwd?var-r8r69958=qvvpvrrq"} CATEGORY_NAMES = { "anylp": "Any% 斗篷", "anyrp": "Any% 无斗篷", "te": "True Ending", "100noab": "100% No AB", "100ab": "100% All Bosses", "judgement": "Judgement", "low": "Low%", "ab": "All Bosses", "twisted": "Twisted%", "苔穴": "苔穴", } def get_player_name(player, players): for p in players: if p["id"] == player["id"]: return p["names"]["international"] return player.get("name", "Unknown") def format_time(t): m = int(t // 60) s = t - m * 60 h = int(m // 60) if h > 0: m = m - h * 60 return f"{h:d}:{m:02d}:{int(s):02d}" if m < 10: return f"{m:d}:{s:06.3f}" return f"{m:02d}:{int(s):02d}" def format_relative_date(date_str): """ 将日期字符串转换为相对时间描述 参数: date_str: 日期字符串,格式为 'YYYY-MM-DD' 返回: str: 相对时间描述,如"今天"、"昨天"、"前天"、"X天前"、"X个月前" """ try: input_date = datetime.strptime(date_str, '%Y-%m-%d').date() today = date.today() delta = today - input_date days_diff = delta.days if days_diff == 0: return "今天" elif days_diff == 1: return "昨天" elif days_diff == 2: return "前天" elif 3 <= days_diff < 30: return f"{days_diff}天前" elif 30 <= days_diff < 60: return "上个月" elif days_diff >= 60: months = days_diff // 30 return f"{months}个月前" else: # 如果是未来日期,返回原日期 return date_str except ValueError: # 如果日期格式错误,返回原字符串 return date_str def main(user_input): resp = requests.get(URL[user_input] + "&embed=players&top=5", timeout=60) resp.raise_for_status() data = resp.json()["data"] runs = data["runs"] if len(runs) > 5: runs = runs[:5] players = data.get("players", {}).get("data", []) print(f"=== 丝之歌 — {CATEGORY_NAMES[user_input]} — NMG ===") for entry in runs: place = entry["place"] run = entry["run"] time_sec = run["times"]["primary_t"] player = get_player_name(run["players"][0], players) time_str = format_time(time_sec) relative_date = " — " + format_relative_date(run["date"]) if "date" in run else "" print(f"{place}. {player} — {time_str}{relative_date}") if __name__ == "__main__": if len(sys.argv) > 1: arg = sys.argv[1] else: arg = input("输入您想查询的榜单(any,te,100,judgement,low,ab,twisted,苔穴): ") arg = arg.replace("%", "").lower() if arg == "any": main("anyrp") main("anylp") elif arg in ("all bosses", "all boss", "allbosses", "allboss"): main("ab") elif arg == "100": main("100noab") main("100ab") else: main(arg) ``` --- --- url: /daily/silksong/speedrun-submit.md --- # 丝之歌速通投稿问题 本篇主要介绍 [speedrun.com 上面《丝之歌》游戏](https://www.speedrun.com/zh-CN/silksong)的投稿问题和NMG规则。 丝之歌排行榜的投稿于2025年10月1日正式开放。 ::: warning 注意 规则的英文原文见: 本文旨在帮助国内速通玩家了解速通投稿规则,以意译为主,附带本人的解释,方便国内速通玩家理解,难免有翻译错误,**最终请以英文原文为准**。下面的每一段翻译都附带了英文原文,方便比对。 规则随时可能变化,但**本文档更新可能滞后,最终请以上方链接的规则原文为准**。 ::: ## 新规则:允许跳过开场动画 *来自速通社区公告* :::: tabs @tab 个人翻译 现在,允许使用丝之歌自带的回档功能跳过开场动画。 操作方法是:在存档选择界面,在任意一个现有存档下方点击回档按钮,下滑到最下方,选择`第一幕开启`。 同时你需要对计时器做出以下改动: ::: info 译者注 以下步骤已经集成入[计时器生成器](sssplitmaker-faq.md)(v1.17.0版本开始)中,你可以使用[计时器生成器](sssplitmaker-faq.md)打开你原来的板子(.lss文件),勾选`跳过开局动画`后,另存为,重新用 **LiveSplit** 打开即可。 ::: * 打开 **LiveSplit** → 右键 → Edit Splits → 右边的 Settings 按钮 → 将开始计时的触发条件从 `Start New Game (Start)` 改为 `Act 1 Start (Start)` 。 * 打开 **LiveSplit** → 右键 → Edit Splits → 将 `Start Timer at:` 从 `0.000` 改为 `21.760` ,如下图所示。\ ![skip-cutscene.png](/hollow-knight/skip-cutscene.png) ::: note 说明 * 为什么是 `21.760` 这个时间?因为这个就是按照TAS计算可以开始移动的时间(如果你开局向左走)。如果你开局向右走的话,TAS还会在这个基础上快 0.6 秒(但按照目前规则,即使你开局向右走你也必须填 `21.760` 这个时间)。 * 这个规则可能会变动,因为将来可能会禁止使用回档功能,改为用另外的方案代替。不过,在规则修改之前提交的记录都会保留在排行榜上。 ::: @tab 英文原文 **Starting runs from autosaves** Starting runs from the new-game wakeup autosave is now legal, provided you are using the autosplitter. If you decide to start runs from the autosave, you MUST set your LiveSplit "Start Timer at" option to 21.76. **Setup guide** * Change your starting split from "Start New Game (Start)" to "Act 1 Start (Start)" in your layout's Auto Splitting Runtime settings (or on HK Split Maker). * Change "Start Timer at:" from 0.000 to 21.760 in your splits settings * You can also make splits starting from [this template on HK Split Maker](https://hksplitmaker.com/?game=silksong\&builtin=blank-act1start) **Other notes** * This time was chosen to exactly match TAS (i.e. a perfect bind input) when going left. It loses 0.6 to TAS when going right. * We have some concerns about this decision and aren't confident it will be the permanent solution. If autosave starts are disallowed in the future we will propose another method to skip the cutscenes, and runs using autosave starts prior to that change will be allowed to remain on the leaderboards. :::: ## 速通投稿 速通投稿相关目前参照[空洞骑士速通投稿](../hollowknight/speedrun-submit.md#速通投稿)即可,这里就不再进行翻译了。 帧率方面倒是有个值得注意的要求: ::: tabs @tab 个人翻译 * 如果你在速通期间调整了垂直同步或者帧率限制的设置,你必须将FPS清晰地显示在你的录像中。 @tab 英文原文 * If you change either the VSync or Frame Rate Cap setting during the run, you must have a clearly visible FPS display in your recording. ::: 还有一些值得一提的内容,我列在这里: * 允许降级至任何官方发布版本,但基于`public-beta`的版本仍被禁止 * 不能安装任何Mod * **不能使用屏幕分辨率超过 2:1 的超宽屏,并且你的整个游戏窗口都应当在你的录像中,不能进行裁剪** * 一定要用新版的计时器,旧的wasm过图时会在屏幕还未完全变黑的情况下就暂停计时了 * Judgement(末日裁决者)速通类别**不是以打完末日裁决者作为结束**,而是应当在进入第二幕的过场动画后,弹出“任务目标变更”时结束 ## 中途暂停 ::: tabs @tab 个人翻译 * 首次连续游戏30分钟后,可休息10分钟 * 之后每满1小时速通时长,可额外休息10分钟 * 休息时间可叠加使用(例如连续游戏1小时后可休息20分钟) * 游戏时长超过3小时后,仍可享有合理范围内的无限次休息权 * 所有休息时段**必须**在速通投稿的简介中标注时间节点 必须遵循以下规则: * 只能在存档选择菜单进行暂停: * 当进入存档选择菜单时暂停,选择一个存档开始游戏时取消暂停。 * 如果不遵守暂停规则,可能导致审核不通过。 * 暂停期间屏幕全程必须保持在存档选择菜单,切屏会导致审核不通过。 @tab 英文原文 * After your first 30 minutes of gameplay, you may take a 10 minute break. * Additionally, for every hour of run length, you may take a 10 minute break. * Breaks may be combined; for example, taking a 20 minute break after playing for 1 hour. * Unlimited (within reason) breaks after 3 hours. * Breaks **must** be timestamped in the description of your run. Breaks must be taken as follows: * Breaks must be taken on the File Select screen on the menu: * The timer should pause upon opening the file select menu, and continue after loading into your selected your file. * Verifiers may reject your run if they determine it to be abusing the break system. * The recording must be left on the file select screen - switching to a different scene will result in a rejection. ::: ## 关于NMG ::: info 关于邪道 NMG是No Major Glitches的缩写,意为无主要邪道。游戏中的大部分bug和部分影响速通平衡的特性被定义为“邪道”,这是NMG规则中不被允许的。但是,到底哪些被算作是“邪道”,这很难有一个明确的定义。因此,《丝之歌》速通社区给出了定义,并且对有争议的内容进行了投票。 ::: ::: tabs @tab 个人翻译 若某项邪道未列入此列表,则默认禁止使用,直至其被正式收录。您可在Discord频道咨询管理员,或于论坛发帖确认。 任何意外触发且没有受益的主要邪道,可根据审核人员的判断酌情予以豁免。 @tab 英文原文 If a glitch is not listed here, assume it is banned until it is listed. You can check with a mod in the Discord or create a forum post. Any accidentally performed major glitch that does not save time may be allowed by verifier's discretion. ::: ### 允许的邪道 ::: tabs @tab 个人翻译 * 下劈后摇取消 * 跳跃 → 下劈 → 松开方向键(不输入任何方向),在落地前完成。此技巧能消除猎手纹章下劈攻击的落地后摇,从而让你能更早开始移动。 * 野兽蓄力斩 * 使用野兽纹章时,在上升阶段释放蓄力斩。这能让你在向前突进的同时,获得显著的高度提升。 * 如果你在下降阶段释放蓄力斩,则会导致蓄力斩的高度显著降低。*并且你会保持这个糟糕的蓄力斩,直到使用一次地面蓄力斩或者SL之后*。 * 缚丝冲刺刷新 * 缚丝 → 缓冲输入冲刺。通过缓冲输入冲刺指令,即使你在空中已经用过了冲刺,也还能进行空中冲刺。 * 注意:此技巧的实用性尚待进一步研究,我们将在后续对其重新评估。 * 工具下劈(掘洞钻/陷阱 + 电枢球,以及其它工具) * Including Pin Pogos (*excluding* pogoing infinitely spinning pins)(不懂) * 投掷电枢球 → 在电枢球上方使用掘洞钻 * 掘洞钻允许你在自己的电枢球上进行下劈跳跃。你可以在电枢球爆炸前使用上劈攻击电枢球,使其移动到更好的位置 * 隔墙打开开关(包括隔墙打开宝箱) * Queued Walljump Interrupts, including when Queued Walljump Interrupt Storage is incidentally gained.(不懂) @tab 英文原文 * Pogo Endlag Cancels * Jump -> Attack -> Neutral (no directional input) before hitting the ground. Removes end lag from the Hunter's Crest pogo, allowing moving early. * Beast Boosts * Using Beast Crest, Release Needlestrike while ascending. You gain a significant amount of height as you lunge forwards. * If you release while descending, you will get a version of the attack that gives you less height. *You will keep getting this worse version until you do a grounded needlestrike or save\&quit*. * Bind Dash Refresh * Bind -> Buffered Dash. Buffering a dash input allows you to dash even if you have exhausted your midair dash. * NB: this will revisited on at a later time, once we understand the extent of its utility. * Tool Pogos (Drill/Snare w/ Voltvessels & others) * Including Pin Pogos (*excluding* pogoing infinitely spinning pins) * Throw Voltvessels > Use Delver's Drill above them * Delver's Drill allows you to pogo off your own Voltvessels. You can upslash the Voltvessel before it detonates to reposition it favourably. * Lever Skips (including hitting chests through walls) * Queued Walljump Interrupts, including when Queued Walljump Interrupt Storage is incidentally gained. ::: ### 禁止的邪道 ::: tabs @tab 个人翻译 * Queued Walljump Interrupt Storage(不懂) * Deep Docks Bridge Hazard Respawn(不懂) * 电枢球开启单向墙———从腐汁泽到管风琴的捷径 * 电枢球的伤害可以击中单向墙的背面 * 通过以下方式跳过第四咏唱团 * 完成披风任务 → 立即按ESC退出重进 * 完成披风任务本应该进行一次自动存档,在自动存档前退出游戏重进,就回到了上一个椅子(如果你没在披风任务处坐椅子)。 * 特罗比奥skip,这里列举了两种不同的方式 * 利用丝之矛存储进行特罗比奥skip(*下文有详细解释*) * 符文之怒skip * *文档中没有提到护佑钟skip,但已确定在最新的NMG规则禁止使用* * 三段跳 * 在下劈后的一个特定时间(取决于不同纹章)使用二段跳,会刷新两次二段跳,下劈本应该只刷新一次二段跳。 * 如果无意中触发,不会被直接拒绝,而是给一个2秒的时间惩罚。 * Scuttlebrace Jump Reset(不懂) * 浮空冲刺 * 浮空后立即取消浮空,然后冲刺。你就可以在没有获得冲刺的情况下使用冲刺。 * 丝之矛存储(用于特罗比奥skip) * 在施展"丝之矛"技能的特定帧数,故意让一个"高伤害"陷阱击中自己。然后行走(待确认:是否必须行走?)至障碍物旁,大致站在 x=46.00 的位置,再次施展技能。此时,障碍物会受到错误方向的攻击判定。 * 任何导致出界的邪道 * 主菜单存储 * 房间复制 * 任何导致隐身的邪道 * 任何导致敌人AI变得无响应的邪道 @tab 英文原文 * Queued Walljump Interrupt Storage, when either: * The storage is prolonged through other movement, such as sprint or clawline. * The stored interrupt activates on NonSlider geometry. * Deep Docks Bridge Hazard Respawn * Voltvessel One-way Wallbreak @ Bilewater->Ducts shortcut * The damage from Voltvessels can hit the backside of one-way breakable walls. * Fourth Chorus Skip Skip * Hand in Flexile Spines quest -> Press pause immediately after finishing the dialogue * Avoids the hardsave when obtaining Drifter's Cloak, allowing you to save\&quit to a bench before Fourth Chorus while keeping Drifter's Cloak. * Trobbio Skips * Trobbio Skip - Silkspear Storage * Trobbio Skip - Rune Rage * Triple Jump * Inputting wings at a specific time after pogoing, dependent on crest, gives 2 wings refreshes instead of one. * If accidental, a 2s penalty may be applied instead of rejection. * Scuttlebrace Jump Reset * Float Sprint * Float -> Cancel float & immediately dash. Allows you to airdash without sprint. * Silkspear Storage * Get hit by a 'hard' hazard on a specific frame of the Silkspear animation. Walk (todo: required?) to the block & stand around x=46.00, then cast again. Block gets hit the wrong way. * Any glitch that results in going Out of Bounds. * Main Menu Storage * Room Dupes * Any glitch that results in invincibility * Any glitch that results in enemy AI becoming unresponsive ::: --- --- url: /daily/silksong/ss-tools.md --- # 能力(丝之歌) 为速通整理的各重要能力的效果和位置。 ::: warning 注意 本文是在[《丝之歌》中文Wiki](https://hkss.huijiwiki.com/wiki/%E5%B7%A5%E5%85%B7)发布前整理的内容,还算较为详细。为感谢各位作者做出的贡献,本页将会保留下来。你可以前往[《丝之歌》中文Wiki](https://hkss.huijiwiki.com/wiki/%E5%B7%A5%E5%85%B7)查看更详细的工具介绍。 ::: ## 纹章 | 名称 | 描述 | 蓄力斩 | 冲刺斩 | 缚丝 | 位置 | |:-:|---|---|---|---|---| | 猎手 | 可以携带1个灵丝技能,2个工具攻击长度、速度中等下劈为45度角斜下可在阿特拉织巢进化两次,进化后连击会积攒能提升伤害的专注条 | 向前进行两次大范围的斩击,可滞空 | 向前突刺,命中敌人向上翻滚 | 回复3点生命 | 初始纹章 | | 收割者 | 可以携带1个灵丝技能,2个工具攻击为弧形,长度长,攻速较慢下劈为大范围下半圆弧,有滞空 | 上半圆形状的大范围斩击,可滞空 | 向上跃起并攻击 | 回复3点生命并在一段时间内从敌人身上收割额外灵丝 | 灰沼左侧的收割者教堂内 | | 漫游者 | 可以携带1个灵丝技能,1个工具攻击有概率暴击,长度短、速度快下劈为小范围正下劈 | 向前进行连续戳刺,有小幅度滞空 | 进行不会打断疾跑的普通攻击 | 回复3点生命 | 骸底镇左侧大门后的漫游者教堂内(需要爬墙或简单钥匙) | | 野兽 | 可以携带1个灵丝技能,2个工具攻击长度、速度中等下劈为向下的圆弧轨迹 | 向前跃起并进行大范围挥击,有滞空 | 向前跃起并进行较大范围挥击 | 进入狂暴状态,提升伤害并在攻击命中敌人时窃取1点生命(在时间结束、窃取数量达到上限或受到2伤重击时结束) | 猎者小径右下角的野兽教堂内 | | 巫妪 | 可以携带1个灵丝技能,2个工具攻击长度长、速度中等下劈为角度较小的斜下劈(有两段判定),有滞空 | 挥出两段旋风斩,有小幅度滞空 | 进行轻微向前移动的两次斩击 | 造成范围伤害,每次命中回复1点生命(默认上限3) | 从书库隐藏进入腐汁泽拿取畸芽甲木林npc处感染畸咒灰沼神医治愈获得 | | 建筑师 | 可以携带3个工具,无法携带灵丝技能攻击为钻头形状,会轻微向前位移,有多段伤害攻击长度较长、速度较慢下劈为45度角斜下,有多段伤害,有较长滞空 | 略微后退后,向前突进形成范围很大的钻头状斩击有多段伤害,有较长滞空 | 向前突进距离更长的普通攻击 | 回复3点生命按住↑+缚丝键可消耗灵丝补充道具(消耗碎片且强制额外消耗5碎片) | 第12代建筑师处购买钥匙,进入上方建筑师教堂获取 | | 萨满 | 增强灵丝技能伤害,可以携带3个灵丝技能,无法携带工具攻击为剑气攻击,长度长、速度较慢下劈为45度角斜下剑气,有轻微的反方向位移 | 向前斩出一道巨大剑气,有滞空和轻微后退 | 后跳并向前扔出一个飞盘 | 回复3点生命值,无法滞空回复 | 进入第三幕,获得灵丝升腾后,在苔穴破败教堂右侧获取 | ## 战斗技能 这些并非工具,而是类似于前作中骑士所获得的战斗技能。 | 名称 | 描述 | 位置 | |---|---|---| | 丝之矛 | 向前发动的攻击。(横向范围远,纵向范围小) | 前期主线苔栖乡 | | 灵丝风暴 | 周身范围攻击,造成较高伤害。 | 灰沼最右边顶上 | | 十字缝 | 招架敌人并立即以十字斩反击,造成大量伤害。 | 废鸣管风琴 | | 丝刃镖 | 冲刺攻击,范围良好且带有无敌帧。 | 沙噬虫道最左边(弹琴+简单钥匙) | | 苍白之爪 | 释放三个苍白之爪,追踪并攻击敌人。 | 第三幕摇篮圣所 | | 符文之怒 | 引导并在附近敌人周围释放9个符文圈,短暂延迟后爆炸。 | 监狱原罪者 | ## 红色工具 有限使用次数的消耗性工具,需在长椅处消耗壳片补充。 | 工具 | 描述 | 位置 | |:-:|----------------------------------------------------------------------------------------------------------------------------|--------------------------------------------| | 直针 | 沿直线投掷一枚针。 | 骸骨洞穴飞镖小游戏房间 | | 三重镖 | 散射投出三枚发射物。 | 灰沼鸟巢右边 | | 蛰刺碎片 | 投出悬停于空中的发射物,触发后会刺伤接触的敌人。 | 熔炉之女 | | 钉刺 | 投出四枚落地的地刺,对踩上的敌人造成伤害,可多次生效。 | 罪途蟑螂内脏支线任务奖励 | | 长针 | 投掷一枚可以穿透护甲的针。 | 甲木林钟心镇入口处 | | 弧爪曲镰 | 投出回旋镖。 | 弧爪:猎人小径商人购买(第二幕改为遭遇战奖励)曲镰:远野打靶小游戏 | | 投掷环 | 投出会反弹的圆环。 | 腐汁泽右上角(制图师支线最终奖励) | | 爆燃囊 | 投出易爆的丝囊,命中敌人会引爆。 | 神医上方消耗金属制作 | | 螺切刃 | 斜向投掷会多次反弹的投射物。 | 沙川boss战奖励 | | 丝弹(三选一) | 深坞升级:单发;建筑师升级:三连发;雪山升级(第三幕):激光。 | 腐汁泽右边蜘蛛巢 | | 掘洞钻 | 如钻头般向下冲击,对路径上的敌人造成伤害。 | 工厂左下侧房间 | | 机轮刃 | 投掷会沿着地面前进的机轮,对路径上的敌人造成伤害。 | 建筑师购买 | | 齿轮蜂 | 召唤齿轮蜂,最多同时存在4只,每只可攻击6次后损坏。 | 高庭中部房间消耗金属制作 | | 念珠炮 | 使用念珠填充的火炮。 | 高庭钥匙门隐藏 | | 电枢球 | 可以作为电球投出,也可以用来生成电柱。 | 忆廊 | | 燧石板 | 在一定时间内点燃织针,提升织针攻击的伤害。 | 深坞钥匙门 | | 跳蚤秘酿 | 在一定时间内提升移动与攻击速度。配合花芯囊使用会在周身生成毒雾。 | 救5只跳蚤后领取 | | 生质液瓶生质液腺 | 增加一点生命血(临时血量),可叠加,在长椅休息会失去所有生命血。生命血过多时会进入蓝血状态,每隔6秒自动回复1点血,持续生效。配合花芯囊使用则无法保持生命血,爆炸并让周围敌人中毒。第三章升级后不再需要去商人处补充。 | 沙噬虫道炼金任务 | | 陷阱设置器 | 用来设置陷阱。同时也是一个重要的任务道具。 | 苔穴蜘蛛巢中部隐藏 | ## 蓝色工具 | 工具 | 描述 | 位置 | |:-:|---|--------------------------| | 德鲁伊之眼德鲁伊双瞳 | 受伤时回复灵丝。 | 苔栖乡德鲁伊 | | 熔岩钟 | 减少受到的火焰与熔岩伤害。(有很短的冷却时间) | 熔炉之女 | | 护佑钟 | 疗伤期间为持有者提供护盾。(受到攻击仍然会被打断疗伤) | 远野左下角深坞最右边 | | 花芯囊 | 为装备工具淬毒。 | 甲木林灰蕨支线 | | 碎面甲 | 可抵挡一次致命伤害。在长椅休息时恢复次数。 | 猎人小径商人购买(第二幕改为直接获取) | | 多重缚丝器 | 延长缚丝时间并提高治疗效果。 | 完成送货员任务后钟心镇商人购买 | | 织光仪 | 灵丝较少时自动回复的灵丝多一格,并提高自动回复的速度。 | 苔穴蜘蛛巢左侧隐藏 | | 锯齿环 | 二段跳和滑翔时会对敌人造成伤害。 | 建筑师 | | 注丝套针 | 加快缚丝速度。 | 白愈厅 | | 储丝延展器 | 灵丝上限增加3格。 | 圣歌盟地商人 | | 储备缚丝 | 在灵丝不够时也能使用一次缚丝。在长椅休息时恢复次数。 | 次席支线boss奖励 | | 爪镜双生爪镜 | 缚丝时对周围敌人造成伤害。 | 击败特罗比奥 | | 记忆晶石 | 受伤时会留下晶石,晶石会对触碰的敌人造成伤害。 | 雪山收费椅子隐藏 | | 撬赃钩 | 飞针冲刺会偷取敌人的念珠和甲壳碎片,增加飞针冲刺的伤害。 | 蚀阶小偷 | | 伏特丝 | 增加法术的伤害。 | 沙川伏特维姆奖励 | | 速射索 | 使用投掷工具时每次投出两个。(也会消耗两个) | 腐汁泽中部隐藏 | | 净界花环 | 免疫蛆水。 | 腐殖渠跳跳乐 | | 长爪 | 增加织针攻击范围。 | 腐殖渠女猎手任务奖励 | | 灵火提灯 | 自动消耗灵丝生成灼烧敌人的灵火。 | 火灵树丛boss奖励 | | 蚤母卵 | 生命值全满时降低灵丝技能消耗。 | 跳蚤全收集奖励 | | 针徽 | 缩短蓄力斩的蓄力时间。 | 第三幕针锋对决boss奖励 | ## 黄色工具 | 工具 | 描述与用途 | 位置 | |:-:|---|--------------------------------------------| | 罗盘 | 装备后可在地图显示持有者位置。 | 制图师 | | 碎壳坠 | 击败敌人时可获取更多甲壳碎片。 | 髓骨洞窟获取 | | 磁石胸针 | 使散落念珠向佩戴者聚集。 | 骸底镇商人 | | 负重环带 | 减少受针击及受伤时的击退效果。 | 远野商人出售 | | 棘刺手环 | 承受伤害变为2倍,织针伤害变为2倍。 | 罪途中部隐藏 | | 亡虫囊壳囊 | 亡虫囊:使持有者战败时保留一半念珠。钢魂模式替换为壳囊,增加红工具携带数量。 | 沙道获取 | | 磁石骰 | 一定几率免疫受到的伤害。 | 蚀阶左侧幸运儿获取(第二幕改为直接获取)(第三幕改为小偷处购买) | | 迅捷脊锁 | 增强持有者灵活性,实现快速撤离。 | 建筑师 | | 登极握爪 | 允许在墙面上固定位置。 | 圣歌盟地商人 | | 蛛丝弦 | 加强织忆弦针的效果。 | 圣歌盟地商人购买 | | 丝速脚环 | 提升奔跑速度,但会缓慢消耗灵丝。 | 原野蜘蛛巢疾跑获得 | | 窃者印记 | 击败敌人时获得额外念珠,但受伤会损失部分。 | 蚀阶小偷 | --- --- url: /daily/silksong/sssplitmaker-faq.md --- # 丝之歌计时器生成器FAQ ::: info 为什么要使用LiveSplit? 在《丝之歌》速通过程中,考虑到不同玩家的电脑配置不同,speedrun.com 上的丝之歌排行榜规定:速通耗时的统计应当使用**排除了游戏加载时间的耗时(LRT,Load Removed Time)**,玩家应当将计时器全程放在视频中清晰可见的位置,否则会按照真实耗时进行统计。其中,**LiveSplit** 是 speedrun.com 《丝之歌》排行榜中最推荐使用的计时器软件,其中的《丝之歌》计时插件支持排除游戏加载时间的功能。 你可以前往[**LiveSplit**官网](https://livesplit.org/downloads/)下载最新版本的 **LiveSplit**。 ::: 考虑到**LiveSplit**是全英文的,很多国内玩家不方便使用,因此我开发了一个中文版的**计时器生成器**,可以用来快速生成你需要的模板(.lss)文件,把这个模板文件导入到**LiveSplit**就可以用了。 ::: warning 注意 你至少需要**LiveSplit 1.8.34**或更高的版本,否则可能无法正常使用《丝之歌》计时插件。 ::: 本文中,请注意区分以下两个概念: * **LiveSplit**:指的是 speedrun.com 《丝之歌》排行榜推荐使用的计时器软件; * **计时器生成器**:指的是我开发的用来快速编辑**LiveSplit**自动分割插件的配置的小工具。 [::b:bilibili =20 /#FE7398:: 点击跳转到B站视频讲解](https://www.bilibili.com/video/BV1PJ4VzwE3n) ## 下载方式 ::: important 声明 * 工具适用说明:**LiveSplit**是开源免费软件,**计时器生成器**是针对**LiveSplit**的一个小工具,不含任何破解、盗版或其它侵权内容; * 兼容性提示:**计时器生成器**使用Go语言开发,仅在 Windows 11 系统进行过基础测试,不保证其他环境下的稳定性;对于其它操作系统,可以使用下文提到的网页版,操作界面基本相同。 * 责任声明:使用者应自行测试并承担使用、修改或分发代码产生的全部风险; * 开源性质:本项目为个人开源工具,作者无义务提供更新或技术支持,改进建议可通过仓库 Issues 提交,但无法承诺响应时效; * 交流群组:如需讨论,可自愿加入QQ群:901564850,群内须遵守法律法规,违规内容将被清理。 ::: 你可以前往[**计时器生成器**的Gitee仓库](https://gitee.com/CuteReimu/sssplitmaker/releases)下载,你也可以下载仓库代码后自行编译。 ## 如何使用 ### 首次使用 ::: danger 注意 **LiveSplit**一定要把zip/rar压缩包给解压了再用!\ 千万不要把`LiveSplit.exe`单独从文件夹里拖出来!\ 实在想放在桌面上怎么办?右键 → 发送到 → 桌面快捷方式 ::: 第一次使用时,你需要**开启LiveSplit的《丝之歌》计时插件**。具体方法是:打开**LiveSplit**后,右键 → Edit Splits,在第一行输入`Hollow Knight: Silksong`,然后右边三个按钮,有一个`Activate`按钮。 * 如果没有`Activate`按钮,而是有一个`Deactivate`按钮,说明你已经开启了插件,无需再做额外操作。 * 如果`Activate`按钮可以点,点击它,变成`Deactivate`说明开启成功,然后按`OK`按钮。 * 如果点击`Activate`按钮弹出提示`The Auto Splitter could not be activated.`,采用和下一条一样的处理方式即可。 * 如果`Activate`按钮是灰色的不能点,首先关掉**LiveSplit**,打开**计时器生成器**,点击右上角的`更新LiveSplit`按钮,找到`LiveSplit.exe`,提示成功之后再重新打开**LiveSplit**,`Activate`按钮就可以点了。 ### 生成模板并导入 ![sssplitmaker.png](/hollow-knight/sssplitmaker.png) 1. 运行**计时器生成器** 2. 你可以选择一个已有的模板或者打开一个`*.lss`文件,也可以自己从头开始编辑 * 如果你是刚接触速通不久,强烈建议你直接使用已有的模板 * 如果你是速通老手,有自己对路线的理解,想要自己编辑模板,可以参考以下说明 1. 点击右侧的加号和删除可以增加或者删除一行 2. 第一列的文本框里填写自己想要显示的片段名称,第二列的下拉框用来选择在游戏中会自动分割的触发事件。 3. (v1.17.0版本开始支持)由于[最新速通规则允许跳过开场动画](speedrun-submit.md#新规则-允许跳过开场动画),你可以勾选`跳过开局动画`,这会自动将开始计时的触发条件设置为`第一幕开始(开始)`,并将计时器的初始值设为`21.760`秒。 3. 全部设置好以后,滚动到最下方,点击`另存为`按钮,即可保存成`*.lss`文件。 4. 打开**LiveSplit** → 右键 → Open Splits → From File... ,选择刚刚保存的文件即可。 ## 计时器不生效的排查方法 关于“开启一个新的游戏存档,但并没有自动开始计时”“完成了一个内容(例如击败苔藓之母),但并未自动跳转到下一行”“还没有完成相应内容,计时器就跳转到下一行或提前结束了”相关问题,建议从以下几种可能性逐一进行排查: * 有可能你的**LiveSplit**不是 1.8.34 或更高的版本 * 有可能你没有开启**LiveSplit**的《丝之歌》计时插件([如何开启?](#首次使用)) * 请确保使用管理员模式打开**LiveSplit** * 有可能你的游戏安装了一些不兼容的Mod(可以先把所有Mod卸载了试试,况且 speedrun.com 投稿要求不能安装任何Mod) * 有可能你的模板文件(.lss文件)有问题,建议使用**计时器生成器**生成模板文件。 * 自查方法:在你确认了已经开启了**LiveSplit**的《丝之歌》计时插件后,回到 Edit Splits 页面,右边有三个按钮,点击其中的`Settings`按钮,新弹出的设置界面中,你会看到很多节点。如果没有,说明你的模板文件有问题,建议使用**计时器生成器**重新生成一个模板文件。 * 在激活《丝之歌》计时插件时,有可能由于操作顺序问题,导致模板文件损坏(正确顺序是先激活插件,再导入模板文件)。所以建议根据上述方法自查一下,如果确实模板文件出现问题,重新生成一个模板文件重新导入即可。 --- --- url: /intro.md --- # 关于我 ## 技术背景 * **超过10年工作经验的资深游戏服务端工程师,在高并发编程方面有着深厚功底** * **擅长多种服务器开发语言** * **有不错的算法能力,LeetCode周赛历史最佳成绩26名** * **略懂一些Web开发语言** ## 社区贡献 * **弹幕射击游戏社群——“东方Project沙包聚集地”的创始人(2011-2023期间担任群主)** * **进行了《空洞骑士》和《空洞骑士:丝之歌》速通规则的汉化和速通玩法的推广,并为速通计时器制作了方便国内玩家使用的开源编辑工具** * **提出游卡《风声》桌游规则优化方案,整理了民间版的规则文档** --- --- url: /programming/index.md --- # 编程经验 --- --- url: /programming/algorithm/dijkstra.md --- 有这样一个图: ![dijkstra.png](/programming/dijkstra.png) 上图中,连线上的数字表示点与点之间的距离,那么从点A到点F的最短距离是多少? ::: important 重要 Dijkstra算法仅适用于所有点与点之间的距离都是非负的图。 ::: 为了解决这个问题,我们从出发点(点A)开始考虑,很显然,从A到A的最短距离是0。 接下来,从A到C直接走的距离是3,从A到B直接走的距离是6,而从A到C到B绕弯的距离是5,也就是说A到B点的最短距离我们没法直接判断,我们要试各种路径,找到最小值,才能知道它的最短距离。 也就是说,当A的最短距离为0刚刚确定下来的时候,与它相邻的点B和C的距离处于“暂未确定”状态,但是我们起码知道,如果采用直接走的方法他们的距离分别是B-6、C-3。 我们可以把所有的点分为三类: * “已经确定”的点,我们把所有这类点的集合叫做“**闭集**” * “暂未确定”的点,我们把所有这类点的集合叫做“**开集**” * “还没开始计算”的点 此时,有一个很显而易见的道理,如果所有边的值都是非负的话,那么**开集**中最小距离那个点肯定已经确定了,例如上面那种情况,C的距离为3已经确定了。为什么呢?C的距离为3是最小的,其他的**开集**的点的值都比它大,因为所有边的值都是非负的,不管怎么绕路,距离都不会减少(一个较大的数+一个非负数,怎么都会越加越大)。 好了,**开集**中,一个点已经确定了,把他放入**闭集**中,与它相邻的“还没开始计算”的点可以算出直接走的距离,然后放入**开集**中。 接着,继续从**开集**中取距离最小的点,直到距离最小的点是目标点,或者**开集**为空为止。如果**开集**为空,说明没有路径可以到达目标点。 注意到,上面用下划线标注的那句话中,算与它相邻的点的情况可能有三种: * **闭集**的点,不用再回头算了; * **开集**的点,也许这样算出来的距离比之前算出来的距离短,那么说明这条更短,用它来替代暂时求出来的最短距离即可; * “还没开始计算”的点。 这样一直重复算下去,当从**开集**中取出的点为F时,答案就出来了;或者当**开集**为空时,说明没有路径可以到达目标点。 ```cpp :no-collapsed-lines #include #include #include #include #include using namespace std; const int INF = INT_MAX; // 返回从start到end的最短距离,不可达返回-1 // 参数edges是一个集合,每个元素都是三元元组:(点1的序号, 点2的序号, 点1到点2的距离) int dijkstra(int start, int end, const vector>& edges, int nodeCount) { vector dist(nodeCount, INF); // 使用最小堆优先队列,存储格式:(从起点到当前节点的距离, 当前节点的序号) priority_queue, vector>, greater<>> pq; // 初始化起点 dist[start] = 0; pq.emplace(0, start); while (!pq.empty()) { pair top = pq.top(); int current_dist = top.first; int u = top.second; pq.pop(); if (u == end) return current_dist; // 找到目标点 if (current_dist > dist[u]) continue; for (const auto& edge : edges) { int from = edge.first; int to = edge.second; int weight = edge.third; if (from != u) continue; if (dist[to] > dist[u] + weight) { dist[to] = dist[u] + weight; pq.emplace(dist[to], to); } } } return (dist[end] == INF) ? -1 : dist[end]; } ``` --- --- url: /programming/algorithm/level_up_cost.md --- 某游戏中有一个升级系统,每次尝试升级时,会有一定的概率成功或失败,成功后则等级+1,失败则等级不变。升级时会消耗一定的资源,升级成功的概率和资源的消耗量都仅与当前等级有关。具体规则如下: | 当前等级 | 成功率 | 升级消耗 | |--------|-----|------| | 0 ~ 2 | 35% | 10 | | 3 ~ 6 | 20% | 20 | | 7 | 15% | 30 | | 8 | 10% | 30 | | ≥ 9 | 5% | 50 | 一开始等级为0,玩家有且仅有20次升级尝试的机会,无论成功或失败都算作一次尝试。 问题:计算玩家用完20次升级尝试后,总消耗的资源的概率分布。 *** 为了计算总消耗的分布,我们采用动态规划方法。 我们首先可以用数学方法可以得到以下两个简单的推论: * 因为9级之后的消耗是固定的50,题目只是求解消耗,因此我们可以干脆认为9级必定失败,就不需要后续等级的状态了 * 20次最多消耗720(对应全部成功的情况),大于720的状态是不会达到的,并且消耗一定是10的倍数 定义状态 $dp\[n]\[\ell]\[s]$ 表示经过 $n$ 次升级后,等级为 $\ell$,总消耗为 $s$ 的概率。其中: * $n = 0,1,\dots,20$(升级次数) * $\ell = 0,1,\dots,9$(等级) * $s$ 为总消耗,是 10 的倍数,范围从 0 到 720 初始状态:$dp\[0]\[0]\[0] = 1$。 状态转移:对于每个状态 $dp\[n]\[\ell]\[s]$,根据当前等级 $\ell$ 查表得到消耗 $c(\ell)$ 和升级成功概率 $p(\ell)$。 * 如果 $\ell < 9$ * 以概率 $p(\ell)$ 升级成功,转移到 $dp\[n+1]\[\ell+1]\[s + c(\ell)]$。 * 以概率 $1 - p(\ell)$ 升级失败,转移到 $dp\[n+1]\[\ell]\[s + c(\ell)]$。 * 当 $\ell >= 9$ 时,消耗固定为 $c(9) = 50$。 最终,总消耗为 $s$ 的概率为 $\displaystyle\sum\_{\ell=0}^{9} dp\[20]\[\ell]\[s]$。 代码实现如下: ```python :no-collapsed-lines # 概率和消耗表 prob_main = [0.35]*3 + [0.2]*4 + [0.15, 0.1, 0.0] cost = [10]*3 + [20]*4 + [30, 30, 50] # 动态规划 dp = [[[0]*73 for _ in range(10)] for _ in range(21)] # s/10 作为索引,最大72 dp[0][0][0] = 1.0 for n in range(20): for l in range(10): p = prob_main[l] c = cost[l] // 10 # 除以10作为索引步长 for s_idx in range(73): prob = dp[n][l][s_idx] if prob == 0: continue # 升级失败 dp[n+1][l][s_idx+c] += prob * (1-p) # 升级成功 if l < 10: dp[n+1][l+1][s_idx+c] += prob * p # 收集结果 dist = {} for s_idx in range(73): total_prob = sum(dp[20][l][s_idx] for l in range(10)) if total_prob > 0: dist[s_idx*10] = total_prob # 按消耗排序输出 for s in sorted(dist.keys()): print(f"{s}: {dist[s]:.6f}") ``` 最终可以得到总消耗的概率分布,如下图所示: ::: chartjs ```json { "type": "bar", "data": { "labels": [ "200", "210", "220", "230", "240", "250", "260", "270", "280", "290", "300", "310", "320", "330", "340", "350", "360", "370", "380", "390", "400", "410", "420", "430", "440", "450", "460", "470", "480", "490", "500", "510", "520", "530", "540", "550", "560", "570", "580", "590", "600", "610", "620", "630", "640", "650", "660", "670", "680", "690", "700", "720" ], "datasets": [ { "label": "概率(%)", "data": [ 1.6956391220756921, 0.6660537382759878, 0.9108427190098979, 1.2364380801039339, 1.6644358770629877, 2.21569703954625, 2.9103655094504806, 3.759992227167592, 4.764964459642544, 5.901369140749157, 7.115607445804771, 8.307155201817364, 9.325696884500045, 9.960940369954145, 9.96504253338648, 9.089871686124754, 7.204739772407863, 4.495895843642834, 1.864791308484083, 1.5808563475642683, 1.3059798908200848, 1.0471562019582266, 0.8167739719690246, 0.6141936378966437, 0.44899318770983965, 0.3157940124093763, 0.21715044112879997, 0.14509255968496923, 0.0996046580297862, 0.06865420181086501, 0.05270175098427022, 0.04205585856842835, 0.03579310915215238, 0.02991793603907299, 0.02573898697758886, 0.020632425398103747, 0.0173077285801103, 0.014055584026154996, 0.011273171654907418, 0.008674465369499996, 0.007284230448173434, 0.005120573597999999, 0.004109387227968749, 0.0030604518, 0.002176419249375, 0.0013986167999999993, 0.0012856711874999994, 0.0005762399999999996, 0.0004370677499999997, 0.00032927999999999983, 0.00018007499999999992, 0.00010289999999999994 ], "borderWidth": 1, "categoryPercentage": 1.0, "barPercentage": 1.0 } ] }, "options": { "scales": { "x": { "title": { "display": true, "text": "总消耗" }, "ticks": { "maxRotation": 0 }, "grid": { "display": false } }, "y": { "title": { "display": true, "text": "概率(%)" }, "beginAtZero": true, "border": { "display": false } } }, "plugins": { "legend": { "display": false } } } } ``` --- --- url: /programming/algorithm/slice_game.md --- # 用AStar算法解决数字华容道 有这样一个问题: 给定一个3×3的网格,每个格子上有1-8共8个数字,最后一个格子是空的。每次只能将紧挨着空格子的一个格子移到空格子上,将其随机打乱,例如得到这样一个状态: | 3 | | 4 | |:-:|:-:|:-:| | 5 | 2 | 8 | | 1 | 6 | 7 | 现在请问,如何用尽可能少的步数将其恢复到初始状态? 首先我们要解决的问题是,需要判断它有没有解。这是一个纯数学问题,有固定的方法:我们将3×3网格按从左到右、从上到下展开成一维序列,忽略空格,然后计算其中**逆序对**的数量,**逆序对**为偶数就有解,奇数就无解。 举两个例子: * 假设展开后得到的一维序列是12365478,其中6和5、6和4、5和4的顺序反了,也就是有3个**逆序对**,为奇数,因此这个状态是没有解的。 * 文章开头的那个例子展开是34528167,一共有10个**逆序对**,为偶数,因此这个状态是有解的。 对于一个有解的数字华容道,我们就可以用**广度优先遍历**的方法来解决这个问题了。我们从初始状态开始,只有可能空格相邻的格子移动到空格上,于是我们可以用一个队列来存储这些移动方法。接下来,我们从队列中取出一个移动方法,从这个新的状态开始继续考虑,同样只有可能空格相邻的格子移动到空格上,我们继续将这些可能的操作加入到队列中去,重复这个过程,直到我们找到一个状态是初始状态1-8排好为止。当然了,在这之中,我们需要记下已经遍历过的状态,以免重复遍历。前面所说的队列就是广度优先遍历的**开集**,后面记录已经遍历过的状态就是**闭集**。如果对这个算法还不熟悉,可以参考[《Dijkstra寻路算法》](dijkstra.md)这篇文章。 既然我们可以用**广度优先遍历**,那自然就可以用**AStar**算法来解决问题。按照AStar算法的思路我们只需要把队列按照“评估函数”来排个序。那么“评估函数”是什么呢? $$ f(n) = g(n) + h(n) $$ 其中,$g(n)$表示从最开始的状态到现在已走过的步数,$h(n)$表示从当前状态到最终目标状态的预估步数。前者很简单,那么预估步数怎么得到呢?我们可以用**曼哈顿距离**来计算。 在一个网格中,两个点之间的**曼哈顿距离**等于它们在横向和纵向上的距离之和。我们就以这个状态举例: | 3 | | 4 | |:-:|:-:|:-:| | 5 | 2 | 8 | | 1 | 6 | 7 | 现在的数字5所在的位置其实应该是数字4的目标位置,那么数字4从现在的位置到目标位置的曼哈顿距离就是$2+1=3$。我们把每个数字的当前位置和目标位置的曼哈顿距离加起来,就可以作为预估步数$h(n)$的值了。 这样一来,我们就可以用AStar算法来解决这个问题了。下面是一个简单的实现: ::: code-tabs @tab Go ```go package main import ( "cmp" "fmt" "gitee.com/CuteReimu/goutil" "slices" ) func main() { problem := []int{ 3, 0, 4, 5, 2, 8, 1, 6, 7, } openSet := goutil.NewPriorityQueue(nil, func(o1, o2 *Problem) int { return cmp.Compare(o1.dist, o2.dist) }) openSet.Add(&Problem{ hash: hash(problem), dist: dist(problem), problem: problem, }) closeSet := make(map[int]bool) results := make(map[int]*Result) results[hash(problem)] = &Result{} for openSet.Len() > 0 { p := openSet.Poll() r0 := results[p.hash] if p.hash == 123456780 { displayResult(p.hash, results) break } index0 := slices.Index(p.problem, 0) for _, idx := range directions[index0] { newProblem := slices.Clone(p.problem) newProblem[index0], newProblem[idx] = newProblem[idx], newProblem[index0] p2 := &Problem{ hash: hash(newProblem), dist: dist(newProblem) + r0.dist + 1, problem: newProblem, } if !closeSet[p2.hash] { openSet.Add(p2) } if r, ok := results[p2.hash]; !(ok && r0.dist+1 >= r.dist) { results[p2.hash] = &Result{ lastHash: p.hash, dist: r0.dist + 1, who: p.problem[idx], dir: formatDir[index0-idx], } } } closeSet[p.hash] = true } } func displayResult(h int, results map[int]*Result) { if r := results[h]; r.dist != 0 { displayResult(r.lastHash, results) fmt.Printf("%d move %s\n", r.who, r.dir) } } type Result struct { lastHash int dist int who int dir string } type Problem struct { hash, dist int problem []int } var ( directions = map[int][]int{ 0: {1, 3}, 1: {0, 2, 4}, 2: {1, 5}, 3: {0, 4, 6}, 4: {1, 3, 5, 7}, 5: {2, 4, 8}, 6: {3, 7}, 7: {4, 6, 8}, 8: {5, 7}, } formatDir = map[int]string{ 1: "right", -1: "left", 3: "down", -3: "up", } ) func dist(a []int) (d int) { for i, v := range a { if v != 0 { v-- d += max(i%3-v%3, v%3-i%3) d += max(i/3-v/3, v/3-i/3) } } return } func hash(a []int) (h int) { for _, v := range a { h = h*10 + v } return } ``` @tab Erlang ```erlang -module('AStar'). %% API -export([answer/0]). -record(open_data, {predict_distance, hash, problem}). -record(result, {last_hash = 0, distance = 0, display = ""}). answer() -> Problem = { 3, 0, 4, 5, 2, 8, 1, 6, 7 }, Hash = erlang:phash2(Problem), a_star([#open_data{predict_distance = cal_dist(Problem), hash = Hash, problem = Problem}], sets:new(), #{Hash => #result{}}). % A*算法 a_star([#open_data{hash = Hash, problem = {1, 2, 3, 4, 5, 6, 7, 8, 0}} | _], _, Result) -> display_result(Hash, Result); a_star([#open_data{hash = Hash, problem = Problem} | OpenList], CloseSet, Result) -> #{Hash := #result{distance = Dist}} = Result, IndexOf0 = index_of_0(Problem), Directions = get_4_directions(IndexOf0), {NewOpenList, NewResult} = lists:foldl( fun(Index1, {AccOpenList, AccResult}) -> NewProblem = swap(Problem, IndexOf0, Index1), NewHash = erlang:phash2(NewProblem), NewAccOpenList = case sets:is_element(NewHash, CloseSet) of true -> AccOpenList; false -> ordsets:add_element(#open_data{predict_distance = Dist + 1 + cal_dist(NewProblem), hash = NewHash, problem = NewProblem}, AccOpenList) end, NewAccResult = case Result of #{NewHash := #result{distance = OldDist}} when Dist + 1 >= OldDist -> AccResult; _ -> AccResult#{NewHash => #result{last_hash = Hash, distance = Dist + 1, display = display(Problem, IndexOf0, Index1)}} end, {NewAccOpenList, NewAccResult} end, {OpenList, Result}, Directions), a_star(NewOpenList, sets:add_element(Hash, CloseSet), NewResult). % 计算曼哈顿距离 cal_dist(Problem) -> lists:foldl( fun(Index, AccIn) -> case element(Index, Problem) of 0 -> AccIn; H -> AccIn + abs((H - 1) div 3 - (Index - 1) div 3) + abs((H - 1) rem 3 - (Index - 1) rem 3) end end, 0, lists:seq(1, 9)). % 找到0的坐标 index_of_0(Problem) -> index_of_0(Problem, 1). index_of_0(Problem, Index) -> case element(Index, Problem) of 0 -> Index; _ -> index_of_0(Problem, Index + 1) end. % 交换Problem的两个位置的值,返回新的Problem swap(Problem, Index1, Index1) -> Problem; swap(Problem, Index1, Index2) -> setelement(Index2, setelement(Index1, Problem, element(Index2, Problem)), element(Index1, Problem)). % 获取这个坐标的上下左右的坐标(仅界内,不含界外) get_4_directions(1) -> [2, 4]; get_4_directions(2) -> [1, 3, 5]; get_4_directions(3) -> [2, 6]; get_4_directions(4) -> [1, 5, 7]; get_4_directions(5) -> [2, 4, 6, 8]; get_4_directions(6) -> [3, 5, 9]; get_4_directions(7) -> [4, 8]; get_4_directions(8) -> [5, 7, 9]; get_4_directions(9) -> [6, 8]. % 用文字展示把Index1位置的滑块移到IndexOf0位置的空位 display(Problem, IndexOf0, Index1) when IndexOf0 - Index1 == 1 -> integer_to_list(element(Index1, Problem)) ++ " move right"; display(Problem, IndexOf0, Index1) when IndexOf0 - Index1 == -1 -> integer_to_list(element(Index1, Problem)) ++ " move left"; display(Problem, IndexOf0, Index1) when IndexOf0 - Index1 == 3 -> integer_to_list(element(Index1, Problem)) ++ " move down"; display(Problem, IndexOf0, Index1) when IndexOf0 - Index1 == -3 -> integer_to_list(element(Index1, Problem)) ++ " move up". % 输出结果 display_result(Hash, Result) -> case Result of #{Hash := #result{distance = 0}} -> ok; #{Hash := #result{last_hash = LastHash, display = Display}} -> display_result(LastHash, Result), io:format("~s~n", [Display]) end. ``` ::: 算法不难,这里只列举了Go和Erlang的实现,其他语言的实现也大同小异,就不一一展示了。执行一下可以看到输出: ```text :collapsed-lines=2 :no-line-numbers 2 move up 6 move up 7 move left 8 move down 4 move down 2 move right 6 move up 5 move right 1 move up 7 move left 5 move down 4 move left 2 move down 6 move right 3 move right 1 move up 4 move left 2 move left 6 move down 3 move right 2 move up 5 move up 8 move left ``` ::: note 注意 在AStar算法中,采用曼哈顿距离估算预计距离。这就带来了一个问题:实际剩余距离更小的状态,预估剩余距离不一定更小,因此整个算法得到的最终路径不一定是最短的路径。 但确实在向尽可能短的方向找,比普通的**广度优先遍历**要快得多。 ::: 上述是以3×3的网格为例,实际上我们可以将其推广到更大的网格中,例如4×4、5×5的网格。只要我们在计算曼哈顿距离的时候,注意到每个数字的目标位置就可以了。 --- --- url: /programming/db/redis-blpop.md --- # Redis阻塞队列操作:BLPOP 我们在使用 Redis 时,对 List 数据结构的操作经常使用的是 `LPUSH`、`RPUSH`、`LPOP`、`RPOP` 等命令,这些命令都是非阻塞的。 Redis 还提供了对 List 数据结构的阻塞操作 `BLPOP` 和 `BRPOP`。 命令格式是: ```redis BLPOP LIST1 LIST2 .. LISTN TIMEOUT ``` 其中,`LIST1 LIST2 .. LISTN`是列表的key,支持传入多个列表。`TIMEOUT`是阻塞的超时时间,单位是秒,0表示永久阻塞。这个命令会一直阻塞,直到该key存在且对应的列表不为空,或者达到最大阻塞时间。例如: ```redis BLPOP list1 100 ``` 该命令会从列表的头部弹出元素并返回。返回值是一个数组,包含两个元素,这两个元素都是字符串,依次是列表的key和被弹出的元素。如果直到超时该key仍然不存在或者列表为空,则返回`(nil)`。 当传入多个列表的key时,会依次检查每个列表,直到找到第一个非空的列表并弹出元素。如果所有列表都为空,才会进入阻塞。 上面只以`BLPOP`为例,`BRPOP`的用法类似,唯一的区别是它从列表的尾部弹出元素。 --- --- url: /programming/db/redis-large-data.md --- 我们知道,在`redis-cli`中使用如`SET`、`GET`等命令可以读写数据。但如果过大(例如一个几百K的字符串),这种方法就有些不方便了。 #### 读取数据 ```bash :no-line-numbers # 直接在 redis-cli 后面填写 redis 命令,就可以输出执行结果 # 然后将执行结果重定向到文件中去即可 redis-cli -h xxx -p 6379 -a xxx get key > 1.txt ``` #### 写入数据 这就要用到`redis-cli`的`-x`选项了,它的作用是**从标准输入(stdin)读取数据,用作命令的最后一个参数**。 假设我们已经将数据写入了文件`1.txt`中,现在将其写入数据库: ```bash cat 1.txt | redis-cli -h xxx -p 6379 -a xxx -x set mykey ``` --- --- url: /programming/db/redis-zset.md --- # Redis有序集合(Sorted Set) 和 Redis 的集合(Set)类似,有序集合(Sorted Set)也是一组不重复的字符串元素,但每个元素都会关联一个`double`类型的分数(score),Redis 会根据分数对元素进行排序。 有序集合的成员是唯一的,但分数(score)可以重复。 有序集合(Sorted Set)可以很容易地用来实现排行榜、优先队列等功能。 常用的命令有: ```redis :no-line-numbers :no-collapsed-lines -- 插入 member1,分数为 100,如果存在则更新分数 ZADD key 100 member1 -- 将member1 的分数增加 100,如果不存在则插入 ZINCRBY key 100 member1 -- 删除 member1 ZREM key member1 -- 计算在有序集合中指定区间分数的成员数 ZCOUNT key 100 200 -- 返回有序集合的成员数量 ZCARD key -- 获取成员的索引 ZRANK key member1 -- 获取成员的从大到小排名 ZREVRANK key member1 -- 获取成员的分数 ZSCORE key member1 -- 通过索引范围获取成员,其中 WITHSCORES 选项表示会返回成员的分数 ZRANGE key 0 -1 WITHSCORES -- 通过分数从高到低排序,返回指定排名范围成员,其中 WITHSCORES 选项表示会返回成员的分数 ZREVRANGE key 0 10 WITHSCORES -- 通过分数从高到底排序,返回指定分数区间的成员,其中 WITHSCORES 选项表示会返回成员的分数 ZREVRANGEBYSCORE key 200 100 WITHSCORES ``` 还有一些命令,例如`ZINTERSTORE`、`ZLEXCOUNT`、`ZRANGEBYLEX`、`ZRANGEBYSCORE`、`ZREMRANGEBYLEX`、`ZREMRANGEBYRANK`、`ZREMRANGEBYSCORE`、`ZUNIONSTORE`、`ZSCAN`等不常用的操作,这里就不一一列举了。 --- --- url: /programming/frontend/frontend-languages.md --- # 前端语言选型 2026年了,到底应该学什么前端语言? 如果你想要学习编程,或者想要转语言,面对现在市面上那么多的语言,你肯定有所纠结到底应该选择什么语言。本篇文章将会分别列举一些前端的常用语言,给出一些推荐。 本文基于个人开发经验给出前端语言选型建议,仅供参考。 *** 说到前端,大家听到最多的莫过于**JavaScript**了,它的学习成本低、适用面广,所有主流浏览器都支持**JavaScript**。而且,它的生态非常丰富,在网上你可以找到非常多非常完备的第三方库。它有**npm**、**pnpm**等包管理工具,可以方便地引入这些第三方库,并将你的项目和他人分享。顺带提一句,有了**Node.js**,你甚至可以用**JavaScript**写后端代码,学一种语言就可以做到全栈,何乐而不为呢? 用**JavaScript**做前端,最著名的框架就是**Vue**和**React**了,由于篇幅原因,本文暂不对这两个框架展开详细讲解。 但是,**JavaScript**有一个重大的缺陷,它是弱类型的语言,这就导致了在大型项目中,有可能由于一些不严谨,导致你写的代码执行起来完全不是你想要的结果。而这些问题在编写代码期间是不会有任何异常的,只有在运行时你才会发现不对劲,而想要修复这些bug需要大量时间去查看代码。举个例子,你知道`30+"1"`和`31-"1"`分别会得到什么吗?前者会得到`"301"`,而后者会得到`30`。鉴于以上痛点,因此就出现了一些新的语言,对其进行了改善,其中最著名的就是**TypeScript**。**TypeScript**本质上是**JavaScript**的超集,它完全兼容原来的**JavaScript**语法,在此基础上又增加了类型的声明,这就解决了**JavaScript**弱类型的痛点,在编写代码期间你就可以通过IDE或者编译报错,提前发现问题。 基于**JavaScript**/**TypeScript**技术栈,有不少成熟的跨平台开发工具(如**Tauri**、**Electron**等),可用于构建跨平台应用。使用这些框架,你可以仅使用同样的技术栈来做跨平台应用。但这些框架也有着一些痛点,例如**Tauri**需要安装**Rust**以及**C++**桌面开发环境,动辄就是几个G,安装环境成本较高。而**Electron**的本质是给你写的代码套了个浏览器的壳,在这个浏览器的壳里面运行**JavaScript**代码,并没有完好适配各平台的原生环境,这就会导致发布的包很大,并且操作习惯上的适配也并不那么好。**JavaScript**相关的做跨平台应用的框架都或多或少有着类似的问题。 那么有没有别的语言适合做跨平台应用呢?那就是**Flutter** —— 准确说它基于**Dart**语言,但**Dart**语言并不火,**Dart**的普及主要依托**Flutter**框架。**Flutter**框架非常适合做跨平台应用,可通过一套代码构建适配 Windows、macOS、Android、iOS 多平台的应用。而且,**Dart**语言的语法和主流语言非常相似,学习起来也很容易上手。 除此之外,**Kotlin**也有官方推出的跨平台开发框架 —— **Kotlin Compose Multiplatform**(简称**KMP**)。说到**Kotlin**大家应该并不陌生,它基于 JVM 平台,语法非常像**Java**,但又比**Java**简洁,还增加了很多语法糖,并增加了很多适合函数式编程的特性。它新增的这些语法糖非常符合人的阅读习惯,这就导致了你一旦用上了**Kotlin**之后再也不想回到**Java**了。而且**Kotlin**是安卓开发的官方指定语言,绝大部分安卓APP都是使用**Kotlin**进行开发的。我们回到正题,该框架是**Kotlin**官方提供的一个用于开发跨平台应用的框架。鉴于**Kotlin**有这么多优点,我也将它作为一个推荐。但**KMP**在跨平台开发中,也存在与**JavaScript**生态工具类似的问题,安装环境需要的时间还可以接受,但打出来的包依旧有点大。 此外,从第三方库的生态方面看,**Flutter**和**KMP**在开发前端界面时肯定远远不如**JavaScript**的生态,但也有不少好用的框架,不需要完全自己造轮子。这一点也是各位在学习新语言之前需要考虑的问题。不过,**Flutter**和**KMP**不像**JavaScript**那样的套 WebView 壳的方案,二者基于自研的渲染引擎实现跨平台适配,而非依赖 WebView 封装,因此能实现全平台样式高度统一。 --- --- url: /programming/frontend/headless-browser.md --- 假如我们有一个Vue项目,希望将整个Vue项目的不同页面(通过路由访问的各个页面)导出,然后合并成一个PDF文件。为了解决这个问题,最容易的方法就是使用浏览器自带的保存页面功能。 如果我们要用程序来实现,实际上可以不需要真的有这么一个浏览器,确切说是不需要浏览器真的显示出来页面的内容,只需要它将页面加载好,供我们保存即可。于是就有了这样一个概念——**无头浏览器**。 所谓**无头浏览器**,就是这种不需要真的把内容显示出来的浏览器,它们大多数是对一些市面上常用浏览器的阉割,把界面部分去掉。相比普通浏览器,无头浏览器对资源的占用会更少,很适合我们这个需求。 现在不同语言都有很多无头浏览器的库供我们使用,例如 Javascript 中就有 Puppeteer、Playwright 等。 下文我们将以 Puppeteer 为例,展开讲述具体的实现方案: ## 核心思路 ```mermaid flowchart LR subgraph vue[你的Vue项目] direction TB A[启动Vue项目] --> B[等待请求] end subgraph auto[自动化脚本] direction TB C[启动无头浏览器] --> D[访问] --> E[生成PDF] end auto -.->|访问并保存页面| vue ``` ## NodeJs 实现 首先,新建一个node项目,并安装依赖: ```bash npm install puppeteer pdf-merger-js ``` 在项目根目录创建自动化脚本 `generate-pdf.mjs`: ```javascript {15-20} :no-collapsed-lines title="generate-pdf.mjs" import puppeteer from 'puppeteer'; import PDFMerger from 'pdf-merger-js'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; // 兼容 __dirname const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // 本地服务配置 const PORT = 8080; // 确保与本地服务端口一致 const BASE_URL = `http://localhost:${PORT}`; // 需要导出的路由列表(根据你的路由配置填写) const ROUTES = [ '/', '/about', '/contact' ]; (async () => { // 启动浏览器 const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); const merger = new PDFMerger(); // 遍历所有路由 for (const route of ROUTES) { const url = `${BASE_URL}${route}`; await page.goto(url, { waitUntil: 'networkidle0' }); // 处理页面异步内容(例如数据请求) // 可选:添加自定义等待逻辑 await page.waitForTimeout(1000); // 等待1秒确保内容渲染 // [!code error] // 生成PDF文件名 const pdfPath = path.join(__dirname, 'temp', `${route.replace(/\//g, '_')}.pdf`); // 生成PDF(A4纵向布局) await page.pdf({ path: pdfPath, format: 'A4', printBackground: true, margin: { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' } }); // 合并PDF await merger.add(pdfPath); } // 保存合并后的PDF await merger.save('vue-project.pdf'); await browser.close(); // 清理临时文件 fs.rmSync(path.join(__dirname, 'temp'), { recursive: true, force: true }); console.log('PDF生成完成!'); })(); ``` ::: important 非常重要 1. 注意上文中的高亮行(第15-20行),你需要将所有页面的URL列在这里。如果页面较多,你也可以自行写逻辑获取所有页面的URL。 2. 注意上文中的报错行(第35行),在最新版的 Puppeteer 中,这个函数已经没有了,你需要增加一个等待时间,确保页面已经加载完毕(尤其是一些懒加载的内容)。你也可以采用事件监听的方式判断页面已经加载完毕,这可能会更靠谱一些。 ::: 运行你的Vue项目: ```bash cd your/vue/project/path npm run serve ``` 确保Vue项目运行成功后,就可以执行脚本了: ```bash :no-line-numbers # 创建临时文件夹 mkdir temp # 运行脚本 node generate-pdf.mjs ``` ::: note 注意 因为 `pdf-merger-js` 从 v4.0.0 开始只支持 ESM 模块,你应当检查 `package.json`,确保支持 ESM。 ```json title="package.json" { "type": "module" } ``` ::: ## Go 实现 这里也贴一个Go语言的实现,使用了`github.com/go-rod/rod`这个第三方库: ```go :no-collapsed-lines {32} title="main.go" package main import ( "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/utils" "image" "image/png" "time" ) func main() { // 创建浏览器 browser := rod.New().Sleeper(func() utils.Sleeper { return utils.EachSleepers(rod.DefaultSleeper(), utils.CountSleeper(30)) }).ControlURL(launcher.New(). Headless(true). // 强制无头模式 NoSandbox(true). // 禁用沙箱 Set("disable-gpu", ""). // 禁用 GPU 加速 MustLaunch()). MustConnect() // 打开页面 page := browser.MustPage("https://xxx.com/xxx.html") if page == nil { slog.Error("获取页面失败") return } defer page.MustClose() // 等待页面加载 time.Sleep(time.Second) // 截图 page.MustScreenshotFullPage("screenshot.png") } ``` 这里只简单展示了对一个页面进行截图并保存,可以稍加修改,依次截图每个页面并合并为一个PDF文件。同样地,代码中的高亮行也应当替换为一个自定义等待逻辑,确保页面已经完全加载完毕。 使用方法同上,先运行你的Vue项目,再运行此Go代码即可。 --- --- url: /programming/frontend/nginx.md --- Nginx的配置一般放在其安装目录下的`conf/nginx.conf`中,其大致结构如下: ```nginx :no-collapsed-lines :no-line-numbers {16-23,26-42} title="nginx.conf" worker_processes 1; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; # gzip 相关配置 gzip on; gzip_min_length 256; gzip_buffers 4 16k; gzip_http_version 1.1; gzip_comp_level 2; gzip_types text/xml application/xml application/atom+xml application/rss+xml application/xhtml+xml image/svg+xml text/javascript application/javascript application/x-javascript text/x-json application/json application/x-web-app-manifest+json text/css text/plain text/x-component font/opentype application/x-font-ttf applicationn/vnd.ms-fontobject image/x-icon; gzip_disable "MSIE [1-6]\."; gzip_vary on; # server 相关配置 server { listen 80; server_name localhost; location / { root html; index index.html index.htm; access_log /var/log/nginx/access.log; } error_page 404 /404.html; error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } } } ``` 接下来,我们将两段高亮的内容详细介绍一下。 ## gzip 配置 在浏览器中按 F12 打开开发者工具,在“网络”页签,随便选择一条请求,我们可以找到请求标头: ```http :no-line-numbers {3} GET / HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Accept-Language: zh-CN,zh;q=0.9 ...... ``` 上述内容只是举个例子,具体内容因浏览器而异。我们主要关注`Accept-Encoding`这一行,这表示浏览器告诉服务器:“我支持以下这些压缩方式,你可以使用任意一种方式将消息压缩后发给我,我自行解压即可。” 可以看到,其中就有一种压缩方式叫做`gzip`。Nginx的`gzip`配置就是用来开启和配置这种压缩方式的: ```nginx :no-line-numbers {2-10} http { gzip on; # 开启gzip压缩 gzip_min_length 256; # 最小压缩内容大小,小于这个大小的源内容就不进行压缩 gzip_buffers 4 16k; # 以16k为单位,按照原始数据的大小以4倍的方式申请内存空间 gzip_http_version 1.1; # 压缩版本 gzip_comp_level 2; # 压缩等级,越低则效率越高,但对于CPU的性能负载也会越高 # gzip_types 用于配置需要压缩的类型 gzip_types text/xml application/xml application/atom+xml application/rss+xml application/xhtml+xml image/svg+xml text/javascript application/javascript application/x-javascript text/x-json application/json application/x-web-app-manifest+json text/css text/plain text/x-component font/opentype application/x-font-ttf applicationn/vnd.ms-fontobject image/x-icon; gzip_disable "MSIE [1-6]\."; # 禁止微软的IE6浏览器进行压缩(因为IE6有bug) gzip_vary on; } ``` 注释非常清晰,就不赘述了。 这样一来,浏览器访问Nginx服务器时,就会自动使用gzip进行压缩和解压,从而大大减小传输内容的体积。 ## server 配置 在`http`下方可以配置多个`server`,`server`下面支持非常多的配置,解释起来也很复杂,因此下文将以不同的常用场景为例分别进行说明。 ::: tip 小知识 在浏览器中输入`http://ip或域名:端口号/`时,会使用 http 协议访问该ip或域名的指定端口号。 如果省略了`:端口号`,则会使用默认端口号: * http 的默认端口号是 80 * https 的默认端口号是 443 ::: ### 用作普通的 http 服务器 最基础的配置如下: ```nginx :no-line-numbers :no-collapsed-lines http { server { listen 80; # 监听的端口号 server_name localhost; # 域名 charset utf-8; # 编码 location / { root /usr/share/nginx/html; index index.html index.htm; access_log /var/log/nginx/access.log; } error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } } } ``` `listen`后配的是监听的端口号,`server_name`后配的是域名。这里就涉及到域名解析的问题了。 当我们购买了一个域名后,在域名管理后台可以配置域名解析,将其解析到某个ip,这个域名解析的映射会同步到专门的DNS服务器。当用户访问这个域名时,会去DNS服务器查询它对应的ip,并最终访问这个ip。很显然,这个映射关系是 域名 → ip 的映射,也就是说,完全可以将多个域名映射到同一个ip。我们正常访问网站时,一般都是不填写端口号的,所以会通过默认端口80访问。这里在Nginx中就可以配置多个`server`,其监听同一个端口,但是`server_name`配置不同的域名。当且仅当使用配置的这个域名来访问时,才会进入这个`server`的配置。 那么就有个新的问题,如果都不匹配怎么办?如果都不匹配,就会使用这个端口的第一个`server`的配置。因此,如果我们想禁止直接通过IP访问,必须通过域名访问,就可以在最前面增加一个默认的`server`,负责进行拦截,而后续的`server`指定域名,进行正确的访问逻辑。 ```nginx :no-line-numbers :no-collapsed-lines http { server { listen 80 default_server; # 针对80端口的访问 listen [::]:80 default_server; # 针对ipv6访问的80端口 return 404; # 直接返回404错误 } # 后续内容 } ``` 接下来我们看这一段: ```nginx :no-line-numbers :no-collapsed-lines {5-9} http { server { listen 80; # 监听的端口号 server_name localhost; # 域名 location / { root /usr/share/nginx/html; index index.html index.htm; access_log /var/log/nginx/access.log; } } } ``` 这一段代码中的高亮部分表示将本地目录 `/usr/share/nginx/html` 下的文件映射到 `http://localhost/` 链接进行访问,如果想要使用子链接就将其改为`location /example/`。`index`用来配置默认主页,当用户直接访问 `http://localhost/` ,其后不带具体的文件名时,就会尝试访问`index`后配置的默认文件。`access_log`用来配置访问日志的存储路径。 如果你只是简单地支持访问某个文件夹下的所有内容(例如Node项目打包好的dist目录),按照上述配置稍加修改即可。但如果想要做复杂的配置,则需要详细了解[`location`配置规范](#location-配置),由于篇幅较长,我将其放在文末。 ### https 服务器(SSL认证) 如果想要搭建一个https服务器,首先你需要证书和密钥。你完全可以自行搜索如何生成一个自建证书和密钥,但用户在访问时,浏览器会提示该网站不安全。为了解决这个问题,最好的办法是去申请一个证书,例如前往阿里云或腾讯云申请一个免费证书。如何申请这里就不细讲了。 申请好之后,你会得到一个`.crt`文件和`.key`文件,将其放在你的机器上,并且修改Nginx的配置: ```nginx :no-line-numbers :no-collapsed-lines http { server { listen 443 ssl; # https 要监听443端口 server_name www.example.com; # 替换为你的域名 ssl_certificate /etc/nginx/ssl/example.crt; # 替换为你的 SSL 证书路径 ssl_certificate_key /etc/nginx/ssl/example.key; # 替换为你的 SSL 私钥路径 ssl_session_timeout 5m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; location / { root /usr/share/nginx/html; index index.html index.htm; } } } ``` ::: tip 举个例子 我们以腾讯云为例,申请好证书后,下载 Nginx 版证书,会得到一个`xxxxxx.com_nginx.zip`压缩包,然后执行以下步骤: ```bash :no-line-numbers # 放入上述 Nginx 配置中指定的路径 mv xxxxxx.com_nginx.zip /etc/nginx/ssl/ cd /etc/nginx/ssl/ # 解压缩,参数 -j 表示所有文件都解压到当前目录下,不保留原有的目录结构,参数 -o 表示强制覆盖同名文件 unzip -jo xxxxxx.com_nginx.zip # 确认无误后删除压缩包 rm -f xxxxxx.com_nginx.zip # 将解压得到的文件的权限设置为600 chmod 600 * # 重新加载 Nginx 配置 nginx -s reload ``` ::: 如果需要将 HTTP 自动跳转到 HTTPS,可以加一个 80 端口的重定向: ```nginx :no-line-numbers http { server { listen 80; server_name www.example.com; return 301 https://$host$request_uri; } } ``` ::: tip 小知识 301返回码的意思是“重定向”,一般这个返回码会附带返回另一个网址。当浏览器访问收到301返回码时,会自动跳转到返回的网址。 ::: ### 用作文件服务器 如果你想将Nginx用作文件服务器,让某个目录下的所有文件都能被直接访问,在`location`下面增加如下三行即可: ```nginx :no-line-numbers {8-10} http { server { listen 80; server_name fileserver; location /files/ { root files; autoindex on; # 是否开启目录浏览 [!code ++] autoindex_exact_size off; # 是否显示文件距离大小 [!code ++] autoindex_localtime on; # 是否显示文件时间 [!code ++] } } } ``` ### 服务转发 有的时候,你的http服务的真实端口并不想暴露,打算用Nginx做转发,就可以这样配置: ```nginx :no-line-numbers {3-7} http { server { proxy_pass http://localhost:11111; # 将请求转发到你的服务进程 # 以下三行是为了转发请求头信息 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } ``` ### 综合例子 我们把上文的内容融合在一起,展示一个完整的配置示例。想象这样一个场景:我打算做一个网站 `http://www.example.com/` ,使用Node工程完成了网站的编写,并打包放在了`dist/`目录下。网站服务不直接监听80端口,而是监听一个别的端口(该端口用防火墙限制只能内部访问),然后使用 Nginx 监听80和443端口进行转发,支持 http 和 https,并禁止使用 ip 直接访问。 ```nginx :no-line-numbers title="nginx.conf" worker_processes 1; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; # gzip 相关配置 gzip on; gzip_min_length 256; gzip_buffers 4 16k; gzip_http_version 1.1; gzip_comp_level 2; gzip_types text/xml application/xml application/atom+xml application/rss+xml application/xhtml+xml image/svg+xml text/javascript application/javascript application/x-javascript text/x-json application/json application/x-web-app-manifest+json text/css text/plain text/x-component font/opentype application/x-font-ttf applicationn/vnd.ms-fontobject image/x-icon; gzip_disable "MSIE [1-6]\."; gzip_vary on; # 禁止ip直接访问 server { listen 80 default_server; listen [::]:80 default_server; listen 443 ssl default_server; listen [::]:443 ssl default_server; ssl_certificate /usr/local/nginx/ssl/dummy.crt; ssl_certificate_key /usr/local/nginx/ssl/dummy.key; return 404; # 直接返回404 } # 配置80端口转发11111端口 server { listen 80; server_name example.com www.example.com; # 替换为你的域名 server_tokens off; keepalive_timeout 5; # 防盗链,禁止其它网站直接引用本网站的静态资源 location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|mp4|mp3|woff2?)$ { valid_referers none blocked server_names; if ($invalid_referer) { return 403; } proxy_pass http://localhost:11111; # 将请求转发到你的服务进程 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { access_log /var/log/nginx/access-all.log; proxy_pass http://localhost:11111; # 将请求转发到你的服务进程 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } # 配置443端口转发11111端口 server { listen 443 ssl; server_name example.com www.example.com; # 替换为你的域名 server_tokens off; keepalive_timeout 5; ssl_certificate /usr/local/nginx/ssl/example.com_bundle.crt; # 替换为你的 SSL 证书路径 ssl_certificate_key /usr/local/nginx/ssl/example.com.key; # 替换为你的 SSL 私钥路径 ssl_session_timeout 5m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; # 防盗链,禁止其它网站直接引用本网站的静态资源 location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|mp4|mp3|woff2?)$ { valid_referers none blocked server_names; if ($invalid_referer) { return 403; } proxy_pass http://localhost:11111; # 将请求转发到你的服务进程 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { access_log /var/log/nginx/access-all.log; proxy_pass http://localhost:11111; # 将请求转发到你的服务进程 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } # 监听11111端口启动服务 server { listen 11111; # 监听11111端口,此端口由防火墙限制只能内部访问 server_name localhost; location / { root dist; index index.html index.htm; access_log /var/log/nginx/access.log; } error_page 404 /404.html; error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } } } ``` ## location 配置 接下来,我们要详细讲解一下 `location` 配置。这是 Nginx 配置中非常核心且容易混淆的概念。 Nginx 的 `location` 匹配遵循一个清晰的优先级层次结构。**记住一个核心原则:先匹配前缀,再匹配正则,但正则的优先级(一旦匹配)高于普通前缀(除非使用了 `^~` 修饰符)**。 具体优先级从高到低如下: 1. **`=` 精确匹配** * 最高优先级。如果请求的 URI 与 `=` 后的模式完全一致,则立即使用此 location,并停止搜索其他 location。 * 示例:`location = /login { ... }` 只匹配 `/login` 这个精确的请求。 2. **`^~` 前缀匹配(停止正则检查)** * 如果请求的 URI 与 `^~` 后的模式**开头部分匹配**,Nginx 会立即选择此 location,并且**不再检查后续的任何正则 location**。 * 它优先于任何正则表达式匹配 (`~` 和 `~*`)。 * 示例:`location ^~ /static/ { ... }` 匹配任何以 `/static/` 开头的请求(如 `/static/css/style.css`)。 3. **`~` 和 `~*` 正则表达式匹配(按配置文件中的顺序)** * `~` 表示**区分大小写**的正则匹配。 * `~*` 表示**不区分大小写**的正则匹配。 * **关键点**:如果有多个正则 location 匹配,Nginx 会**按照它们在配置文件中出现的第一个匹配的正则规则**来选择,并立即使用它。 * 它们的优先级低于 `=` 和 `^~`,但高于普通前缀匹配。 4. **普通前缀匹配(无修饰符)** * 这是最常见的匹配方式,如 `location /images/ { ... }`。 * 如果有多个普通前缀匹配,Nginx 会选择**最长匹配**的前缀。 * 但是,**所有普通前缀匹配的优先级都低于任何类型的正则匹配**。这意味着即使一个普通前缀有更长的匹配,只要存在一个能匹配的正则 location,Nginx 就会选择那个正则 location。 ### 匹配流程示意图 Nginx 处理一个请求时,选择 location 的逻辑流程可以简化为以下步骤: ```mermaid flowchart TD A[收到请求URI] --> B{检查所有
前缀location} B -- 找到精确匹配「=」 --> C[立即使用该location
处理请求] B -- 找到最长普通前缀匹配
并记录 --> D{是否存在「^~」匹配?} D -- 是 --> E[使用该「^~」location
(停止正则检查)] D -- 否 --> F[按顺序检查所有
正则location「\~」「~*」] F -- 找到第一个匹配的正则 --> G[使用该正则location] F -- 所有正则都不匹配 --> H[使用之前记录的
最长普通前缀location] ``` ### 示例详解 假设我们有如下配置文件片段: ```nginx :no-line-numbers :no-collapsed-lines server { listen 80; server_name example.com; # Location 1: 精确匹配 location = /logo.png { return 200 "You hit the exact match for logo.png\n"; } # Location 2: 优先前缀匹配(阻止正则检查) location ^~ /static/ { return 200 "You hit the static prefix match\n"; } # Location 3: 区分大小写的正则匹配 location ~ \.php$ { return 200 "You hit the PHP regex (case-sensitive)\n"; } # Location 4: 不区分大小写的正则匹配 location ~* \.html$ { return 200 "You hit the HTML regex (case-insensitive)\n"; } # Location 5: 普通前缀匹配(最长匹配规则) location /images/ { return 200 "You hit the general images prefix match\n"; } # Location 6: 通用捕获(最低优先级) location / { return 200 "You hit the generic catch-all location\n"; } } ``` 现在,我们来看各种请求会匹配到哪个 location: | 请求的 URI | 匹配的 Location | 原因分析 | |---------------------------------------|----------------|------------------------------------------------------------------------------------------------------------| | `http://example.com/logo.png` | **Location 1** | 最高优先级的**精确匹配**。 | | `http://example.com/static/style.css` | **Location 2** | 匹配 `^~ /static/`。Nginx 选择它后,**不再检查**后面的正则 Location 3 和 4。 | | `http://example.com/test.php` | **Location 3** | 匹配正则 `\.php$`。虽然也匹配 Location 6 (`/`),但**正则的优先级高于普通前缀**。 | | `http://example.com/INDEX.HTML` | **Location 4** | 匹配不区分大小写的正则 `\.html$`。 | | `http://example.com/images/cat.jpg` | **Location 5** | **普通前缀匹配**。虽然也匹配 Location 6 (`/`),但 `/images/` 比 `/` **更长**。 | | `http://example.com/some/path/` | **Location 6** | 只匹配最通用的 `/`。 | | `http://example.com/static/app.php` | **Location 2** | **关键例子**:首先它匹配 `^~ /static/`,因此 Nginx **立即选择 Location 2 并停止正则搜索**,即使后面有一个能匹配 `.php` 的正则 Location 3 也不会被检查。 | ### 最佳实践与建议 1. **精确匹配 (`=`)**:用于非常具体、高频的端点(如 `/`, `/favicon.ico`),可以快速响应并减少匹配开销。 2. **优先前缀 (`^~`)**:用于服务静态资源(如 `/static/`, `/assets/`)。因为它能避免不必要的正则表达式检查,提升性能,同时优先级又足够高。 3. **正则表达式 (`~`, `~*`)**:用于基于文件扩展名(如 `\.php$`)或其他复杂模式的动态请求处理。注意它们的顺序很重要。 4. **通用匹配 (`/`)**:总是作为兜底方案放在最后。 5. **调试技巧**:如果不确定请求匹配了哪个 location,可以在 location 块中使用 `add_header X-Matched-Location "location-name";` 来帮助调试。 --- --- url: /programming/frontend/vue-self-learning.md --- # 自学Vue踩坑笔记 本文是一些自学Vue时的踩坑记录。 ## 自闭合标签和`v-else`的坑 ```vue {2,3} ``` 这里的`v-if`和`v-else`看起来是对的,但是实际上会解析报错。像这种**自闭合标签**``的写法,Vue在解析`v-else`时,就会找不到相邻的上一个`v-if`,就会解析报错。正确的写法是不要用自闭合标签: ```vue {2,3} ``` ## `v-if`和`v-for`同时使用 ```vue
{{ item.content }}
``` 这里`v-if`和`v-for`同时使用,Vue会视为`v-if`在外层,`v-for`在内层,如下: ```js if (item.visible) { for (const item of items) { // ... } } ``` 因此这个`item.visible`显然会报错。那如果换个顺序,`v-for`写在前面,`v-if`写在后面呢?实际上,Vue同样会视为`v-if`在外层,`v-for`在内层。 那么想要`v-for`在外层,正确的写法应该是: ```vue ``` --- --- url: /programming/git/git-log.md --- `git log` 命令是查看 Git 提交历史的强大工具,最常见的是**根据提交信息(commit message)的关键词**来查找提交,这主要通过 `--grep` 选项实现。 除了 `--grep`,`git log` 还提供了其他几种实用的筛选方式,可以灵活组合,精准定位历史记录。 ### 按提交信息筛选 (`--grep`) 这是最直接对应“commit 内容”的方式,可以在提交信息中搜索特定模式。 **基本用法**:使用 `--grep` 选项搜索包含特定关键词的提交。 ```bash # 搜索提交信息中包含 "fix bug" 的提交 git log --grep="fix bug" ``` `--grep` 参数非常灵活,支持更多高级用法: * **不区分大小写**:添加 `-i` 参数,让搜索忽略大小写。 ```bash git log --grep="fix bug" -i ``` * **组合多个关键词**:可以多次使用 `--grep` 来查找提交信息中包含任意一个关键词的提交。 ```bash # 搜索提交信息中包含 "feature" 或 "enhancement" 的提交 git log --grep="feature" --grep="enhancement" ``` * **要求同时匹配多个关键词**:如果想查找提交信息中包含**所有**指定关键词的提交,可以添加 `--all-match` 选项。 ```bash # 搜索提交信息中同时包含 "auth" 和 "security" 的提交 git log --grep="auth" --grep="security" --all-match ``` * **排除特定关键词**:使用 `--invert-grep` 参数,可以反转匹配,筛选出提交信息中**不包含**指定关键词的提交。 ```bash # 搜索提交信息中不包含 "WIP" 的提交 git log --grep="WIP" --invert-grep ``` * **使用正则表达式**:`--grep` 也支持强大的正则表达式,实现更复杂的模式匹配。 ```bash # 搜索提交信息中引用 "ISSUE-" 后跟数字的提交 git log --grep='ISSUE-[0-9]+' ``` ::: note 注意 `git log --grep` 默认使用的正则引擎是**POSIX 基本正则表达式(BRE)**,它的一些规则和我们常用的正则表达式有些出入。不过,Git 也提供了强大的方法来支持更现代的正则语法,我们可以这样操作来启用不同的正则表达式引擎: * **POSIX BRE**:`git log --grep` 的默认引擎。它有一些特殊的规则,比如要表示“或”不能直接用 `|`,需要转义成 `\|`;要表示量词比如 `{n,m}` 也需要写成 `\{n,m\}`。 * **POSIX ERE**:通过 `-E` 或 `--extended-regexp` 启用,语法更符合我们的直觉。 * **PCRE**:通过 `-P` 或 `--perl-regexp` 启用,支持正则表达式中更高级的功能(如 `\d`、`\s` 和 `?=` 等正向先行断言)。 * **固定字符串**:通过 `-F` 或 `--fixed-strings` 启用,将模式视为普通字符串进行搜索。 另外,也可以通过 `git config --global grep.patternType` 永久修改默认的引擎类型。 ::: ### 按其他信息筛选 `git log`也可以按照其它信息筛选,比如作者、时间或具体的代码变更等。 * **按作者/提交者筛选 (`--author`, `--committer`)**:按作者(撰写人)或提交者(将变更提交到仓库的人)过滤。`--author` 非常灵活,支持部分匹配、完整姓名或邮箱地址。 ```bash # 按作者名筛选 git log --author="John Doe" # 按邮箱筛选 git log --author="john.doe@example.com" ``` * **按提交范围/哈希值筛选**:可以指定一个提交范围,或者直接用哈希值定位到某个具体的提交。 ```bash # 查看最近3次提交 git log -n 3 # 查看特定哈希值的提交(可以只使用前几位) git log 1a2b3c4 ``` * **按时间筛选 (`--since`, `--until`)**:筛选特定时间范围内的提交。Git 支持多种日期格式,非常方便。 ```bash # 查看最近两周的提交 git log --since="2 weeks ago" # 查看2023年全年的提交 git log --since="2023-01-01" --until="2023-12-31" ``` * **按分支筛选**:如果只关心特定分支的提交,可以直接将分支名作为参数。 ```bash # 查看 dev 分支的提交历史 git log dev ``` * **按文件/目录路径筛选**:查看影响了特定文件或目录的提交。注意要在路径前使用 `--` ,避免路径名与分支名混淆。 ```bash # 查看 README.md 文件的修改历史 git log -- README.md # 查看 src 目录,但是排除其中的 .pb.go 文件 git log -- src ':!src/*.pb.go' ``` * **按代码变更内容筛选 (`-S`, `-G`)**:这是更深层次的筛选,用于查找增加或删除了特定字符串(如函数名、特定代码)的提交。 ```bash # 查找增加或删除了 "some_function" 这个字符串的提交 git log -S"some_function" ``` ### 简洁地显示提交历史 如果想将每个提交压缩成一行,简洁地显示提交历史,只需加上 `--oneline` 选项即可。 如果只想看最新的几个提交,可以加上 `-n` 参数,例如 `git log -n 5` 或者 `git log -1`。 ### 组合筛选 以上选项都可以灵活组合,形成更精准的查询,让你能高效地定位到目标提交。 ```bash # 示例:查看 John 在 2023 年提交的、包含 "bugfix" 关键词的、对 src/ 目录下文件的修改,并以单行格式显示 git log --oneline --author="John" --since="2023-01-01" --until="2023-12-31" --grep="bugfix" -- src/ ``` 总的来说,`git log` 的筛选功能非常强大。理解并熟练运用 `--grep`、`--author`、`-S` 等选项,就能高效地定位到任何你想要的提交记录。 --- --- url: /programming/git/git-patch.md --- # git 如何生成和应用补丁 Git 可以一键生成补丁文件,补丁文件记录了代码的修改内容,可以方便地在不同的代码库之间传递和应用这些修改。 将多个提交打包成一个补丁文件,以及如何应用它,是Git中非常实用的技能,尤其在需要离线分享代码或进行代码审查时。 ## 方法一:使用 `git format-patch` + `git am` git 官方推荐,能完整保留每个提交的作者、日期和提交信息。 `git format-patch` 会为每个提交生成一个独立的 `.patch` 文件。但我们可以通过 `--stdout` 选项将它们合并到一个文件中。 基本命令格式如下: ```bash git format-patch --stdout > all-changes.patch ``` 关键是 `` 参数,它用来指定你想要的提交范围。常见的几种用法有: * **最近N次提交**:例如,最近3次提交: ```bash git format-patch -3 --stdout > last-3-commits.patch ``` * **两个提交之间的所有提交**:包含 commit-B,但不包含 commit-A: ```bash git format-patch .. --stdout > changes.patch ``` * **某次提交之后的所有新提交**:例如,从 `abc123` 之后到当前HEAD的所有提交: ```bash git format-patch abc123 --stdout > all-after-abc.patch ``` ::: note 补充说明 如果你不指定 `--stdout`,`git format-patch` 会为范围内的每个提交生成一个单独的 `.patch` 文件。这在应用时,可以使用 `git am *.patch` 来一次性全部应用。 ::: 接下来,我们只需要使用 `git am`(apply mailbox)命令就可以应用补丁文件: ```bash git am -3 all-changes.patch ``` 这个命令会读取补丁文件,并依次为每个提交创建新的提交,完整保留作者、日期和提交信息。注意这个`-3`参数,它的意思是让 Git 启用“三方合并”能力,方便使用可视化工具来解决冲突。 ::: note 注意 应用的时候还是有可能有错误,你可以使用以下命令来确认到底是什么差异导致的失败: ```bash git apply --check -v all-changes.patch ``` 如果错误里出现 `trailing whitespace`、`space before tab` 之类,就能判断是空白问题。 你可以在导出时忽略行尾空白差异: ```bash git format-patch -1 --stdout --ignore-space-at-eol > all-changes.patch ``` 在应用时也忽略空白差异: ```bash git am --ignore-space-change --ignore-whitespace all-changes.patch ``` 这样就能解决了 ::: ## 方法二:使用 `git diff` (不保留提交历史) `git diff` 生成的补丁只包含文件的变动,不包含提交元数据。 ```bash git diff > changes.patch ``` * **指定两次提交**:`git diff abc123 def456 > diff.patch` * **指定两个分支**:`git diff branch1..branch2 > diff.patch` 这里我们要使用 `git apply` 命令来应用补丁文件: ```bash git apply changes.patch ``` `git apply` 只会修改工作区的文件,**不会自动创建提交**。应用后,你需要手动 `git add` 和 `git commit`。 ::: tip 建议 应用前先用 `git apply --check` 检查一下是否能干净地应用,避免冲突。 ::: --- --- url: /programming/git/git-revert.md --- # git revert -m 撤销合并提交 在 Git 中,`git revert -m` 命令中的 `-m` 选项(全称 `--mainline`)用于指定撤销合并提交时要保留的主线分支。这个选项**仅在撤销合并提交(merge commit)时**需要用到。 ::: tip 为什么需要 `-m` 选项? 合并提交(merge commit)有**两个父提交**: 1. **父提交1(parent 1)**:接收合并的分支(通常是主干分支,如 `main/master`) 2. **父提交2(parent 2)**:被合并的分支(如 `feature-branch`) 当撤销合并提交时,Git 需要知道:应该保留哪个父分支的更改,丢弃哪个分支的更改? ::: 对于这样一条revert命令: ```bash git revert -m ``` `-m`参数的含义: * `-m 1`:保留**第一个父分支**(接收合并的分支)的更改,丢弃被合并分支的更改\ (常用场景:撤销误合并到主干的特性分支) * `-m 2`:保留**第二个父分支**(被合并的分支)的更改,丢弃主干分支的更改\ (较少使用,通常用于撤销反向合并) 举个例子,假设你的提交历史如下: ```git-graph commit id:"A" commit id:"B" commit id:"C" branch feature commit id:"D" commit id:"E" checkout main merge feature id:"M" ``` 图中,`M` 是合并提交,有两个父提交: * 父提交1:`C`(main 分支的提交) * 父提交2:`E`(feature 分支的提交) 要撤销这次合并(即丢弃 feature 分支的更改): ```bash git revert -m 1 M ``` 这里 `-m 1` 明确告诉 Git:保留父提交1(main 分支)的代码状态,丢弃父提交2(feature 分支)引入的所有更改。 完成撤销后,Git会自动生成一个新的提交,该提交抵消了合并引入的更改。再使用`git push`将更改推送到远程仓库即可。 ::: note 注意 * 普通提交(只有一个父节点)不需要 `-m` 选项 * 撤销合并提交时如果不加 `-m`,Git 会报错:`error: Commit M is a merge but no -m option was given.` ::: *** 在团队协作中,撤销已推送到远程的**合并**提交时,**始终使用**`git revert -m 1 `,这是最安全且可追溯的方式。 --- --- url: /programming/git/git-skip-worktree.md --- # git如何屏蔽一个文件的修改 有的时候,我们想要本地修改一个文件(例如配置文件),但不想提交到 Git 版本控制,每次提交的时候都要手动勾选掉这个文件,容易忘记。有什么好办法解决呢? Git 的 `.gitignore` 无法作用于已跟踪的文件。针对这个场景,最优雅的解决方案是使用 Git 的 `skip-worktree` 标记,让 Git 在本地“假装”这个文件没有被修改过。 `skip-worktree` 是专门为“文件已提交到仓库,但需要保留本地修改且不提交”这一场景设计的。 **操作步骤:** 1. **在 JetBrains IDE 的终端中执行命令**(或使用系统终端): ```bash git update-index --skip-worktree <你的配置文件路径> ``` 例如:`git update-index --skip-worktree config/local.properties` 2. **验证状态**: 执行 `git ls-files -v | grep '^S'`,被标记的文件会显示为 `S` 开头。 3. **恢复跟踪(当需要提交时)**: ```bash git update-index --no-skip-worktree <你的配置文件路径> ``` ::: warning 在 JetBrains IDE 中的注意事项 JetBrains 的 Git 集成界面(如 Commit 工具窗口)**不显式支持** `skip-worktree` 标记。设置后,该文件的修改将不再出现在你的提交列表中,完美解决你的担忧。但需要注意:**切勿在 IDE 中手动对文件执行 `git add` 操作**,这可能会意外取消 `skip-worktree` 状态。 ::: `skip-worktree` 标记是**本地专属**的,不会影响仓库中的其他协作者。 --- --- url: /programming/git/git-sparse-checkout.md --- 使用git时,我们有时候并不需要整个代码库的所有内容,只需要其中的一部分内容。尤其当目标仓库很大时,这个功能就更为常用了。这时,我们可以使用git的**稀疏检出**功能(sparse checkout)来实现。 稀疏检出有两种不同行为模式: \[\[toc]] 它们在模式匹配方式、性能和易用性上有显著区别。接下来我们会分别介绍。 我们将以这个仓库为例进行介绍,其目录结构如下: ::: file-tree * .github/ * docs * .vuepress/ * document/ * README.md * .gitignore * LICENSE * README.md * … ::: ### 锥形模式(Cone Mode) 先介绍如何只检出其中的`docs/document`目录: ::: code-tabs#1 @tab git 2.25+ ```bash :no-collapsed-lines :no-line-numbers # 克隆时只下载最近一次的提交历史和树结构,不下载文件内容 git clone --filter=blob:none --sparse --depth=1 https://github.com/CuteReimu/fengsheng-doc.git cd fengsheng-doc # 设置要检出的目录 git sparse-checkout set "docs/document" # 如果需要添加更多目录 git sparse-checkout add "docs/README.md" ``` @tab 传统方式 ```bash :no-collapsed-lines :no-line-numbers # 1. 创建并进入新目录 mkdir fengsheng-doc && cd fengsheng-doc # 2. 初始化 Git 仓库 git init # 3. 添加远程仓库 git remote add origin https://github.com/CuteReimu/fengsheng-doc.git # 4. 启用稀疏检出 git config core.sparseCheckout true # 5. 指定要检出的路径 echo "docs/document" > .git/info/sparse-checkout echo "docs/README.md" >> .git/info/sparse-checkout # 6. 拉取数据(浅克隆 + 稀疏检出) git pull --depth=1 origin main ``` ::: ::: warning 注意 值得一提的是`sparse-checkout`命令是git从2.25版本开始支持的一个新特性,使用前你需要用`git version`命令检查自己的git版本号。如果`git`版本较低,你可以使用传统方式。 ::: 锥形模式的性能较高、占用内存较小,但缺点只能指定哪些内容需要检出,对于一些复杂的需求就无法实现了。 想要取消稀疏检出,转而检出所有文件,可以使用如下命令: ::: code-tabs#1 @tab git 2.25+ ```bash git sparse-checkout disable ``` @tab 传统方式 ```bash :no-line-numbers # 取消 config 设置 git config --unset core.sparseCheckout # 删除配置文件 rm -f .git/info/sparse-checkout # 重置工作目录 git read-tree -mu HEAD ``` ::: ### 非锥形模式(Non-Cone Mode) 接下来我们介绍一下如何检出某个文件夹的同时,排除其某个子目录。 ```bash :no-collapsed-lines :no-line-numbers git clone --filter=blob:none --sparse --depth=1 https://github.com/CuteReimu/fengsheng-doc.git cd fengsheng-doc # 设置稀疏检出(非锥形模式) git sparse-checkout init --no-cone echo "docs" >> .git/info/sparse-checkout echo "!docs/.vuepress" # 排除路径 # 应用更改 git sparse-checkout reapply git pull --depth=1 origin main ``` 我们可以打开`.git/info/sparse-checkout`文件看一看,它是这样的: ```console $ cat .git/info/sparse-checkout docs !docs/.vuepress ``` 非锥形模式支持使用`!`来指定排除某个路径,其灵活性更高,可以实现更复杂的需求,但性能相对较低,占用内存也较多。 --- --- url: /programming/git/git.md --- 有些时候不小心上传了一些敏感文件,或者有一些二进制文件(例如图片)不再需要,想要从仓库中删除。但别人clone你的代码仓库时,这些内容仍然存在于git历史记录中。 因此,我们有时候需要删除文件的所有历史记录。 执行以下命令即可: ```bash # [!code word:path-to-file] git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch path-to-file' --prune-empty --tag-name-filter cat -- --all ``` 将其中的`path-to-file`替换为你要删除的文件路径。如果想要删除目录,在`rm`后面加上`-r`参数即可。 检查无误之后,强制推送到远程仓库: ```bash git push origin --force --all git push origin --force --tags ``` (可选)然后你可以考虑执行`git gc`命令来清理本地的git缓存。 --- --- url: /programming/git/github-graphql.md --- 今天我们聊一聊如何调用 Github 的 GraphQL API 接口。 我们就以 这个仓库为例,它用于获取自己的 Github 信息,并生成漂亮的统计图表,展示在自己的 README 中。例如: ![top-langs.svg](/top-langs.svg) 我们先举一个简单的 GraphQL 例子: ```graphql :no-line-numbers query userInfo($login: String!) { user(login: $login) { name bio repositories(first: 5) { totalCount nodes { name } } } } ``` 其中`query userInfo`表示调用这个接口,圆括号里面的内容是传入的参数,后面大括号里的内容是希望服务器返回的JSON结构。 我们可以通过 Github 提供的命令行工具 `gh` 来简单地测试一下。首先可以在 [Github 文档](https://docs.github.com/zh/github-cli)中找到 `gh` 的安装方法,安装并登录后,即可执行: ```bash :no-line-numbers gh api graphql -f query='query userInfo($login: String!) { user(login: $login) { name bio repositories(first: 5) { totalCount nodes { name } } } }' -f login='CuteReimu' ``` 就可以得到如下返回: ```json :no-line-numbers :no-collapsed-lines { "data": { "user": { "name": "奇葩の灵梦", "bio": "Go / Java / Kotlin / Erlang / C++ / Python / TypeScript / Vue 苦手", "repositories": { "totalCount": 85, "nodes": [ { "name": "Touhou-Freshman-Camp-Robot" }, { "name": "HollowKnightComSoB" }, { "name": "hksplitmaker" }, { "name": "gobang" }, { "name": "CuteReimu" } ] } } } } ``` ::: note 补充 `gh` 命令行除了用于 GraphQL API 请求之外,还有很多功能,例如: ```bash # 查询某个仓库的语言信息 gh repo view CuteReimu/cutereimu.github.io --json languages ``` 这些用法都可以在 [Github 文档](https://docs.github.com/zh/github-cli)中找到。这些内容与本文无关,就不过多赘述了。 ::: 获取 Top Langs 的 GraphQL 查询语句会更复杂一些: ```graphql :no-line-numbers query userInfo($login: String!) { user(login: $login) { # fetch only owner repos & public & not forks repositories(ownerAffiliations: OWNER, privacy: PUBLIC, isFork: false, first: 100) { nodes { name languages(first: 5, orderBy: {field: SIZE, direction: DESC}) { edges { size node { color name } } } } } } } ``` 这样一来,就可以得到每个仓库排名前5的语言,以及每种语言的代码量大小。然后我们在本地利用代码解析这个JSON返回即可得到每种语言的总代码量,最后就可以生成漂亮的统计图表了。 关于 Github 的 GraphQL API 支持的各种请求和参数,可以前往 [Github 文档](https://docs.github.com/zh/graphql)进行了解。 --- --- url: /programming/git/github.md --- ## 为提交增加共同作者 在提交消息的下一行,加入 `Co-authored-by: name `。 如果要添加多个共同作者,则为每个共同作者增加一行 `Co-authored-by:`,之间不加空行。 ```git-commit :no-line-numbers :whitespace=all # 提交信息下面一定要空一行再写共同作者,共同作者之间不空行 这一行是提交信息 Co-authored-by: name1 Co-authored-by: name2 ``` ## 获取提交的补丁文件 一般情况下,在Github上查看一个提交,url应该是:`https://github.com/:name/:repo/commit/:sha`,我们可以在后面加上`.patch`,就可以得到这个提交的补丁文件了。 ## 提交自定义信息 ```bash git commit --author="example " --date="Mon, 02 Jan 2006 15:04:05 +0800" ``` 这个`--author`参数用来指定提交的**作者(author)**,并不会影响提交的**提交者(committer)**。如果你想要指定**提交者**,可以使用`GIT_COMMITTER_NAME`和`GIT_COMMITTER_EMAIL`环境变量来设置**提交者**的信息。例如: ```bash GIT_COMMITTER_NAME="提交者姓名" GIT_COMMITTER_EMAIL="提交者邮箱" git commit -m "提交信息" ``` 同理`--date`参数用来指定**编写时间**,想要指定**提交时间**则可以使用`GIT_COMMITTER_DATE`环境变量。 ## 比较提交 链接是:`https://github.com/:name/:repo/compare/:aaa...:bbb`,表示比较`:aaa`到`:bbb`的差异。 中间可以使用两个点,也可以使用三个点,区别是: * 两个点是比较所有的差异。 * 三个点主要用于将`:bbb`合并到`:aaa`。存在于`:bbb`但不存在于`:aaa`的提交会显示出来,而反过来的差异不显示。 其中,`:aaa`和`:bbb`支持的内容如下: * 分支(branch)名 * 标记(tag)名 * 提交(commit)的SHA代码 特殊用法: * 跨用户比较时,在前面加上`用户名:`,例如`my_name:master` * 如果跨用户且仓库名不同,在前面加上`用户名:仓库名:`,例如`my_name:my_repo:master` * 如果想要取前一个提交,则用`^`,例如`aaaaaaa^`表示取`aaaaaaa`的前一个提交,支持连续多个`^` * 如果想要取前`n`个提交,则用`~n`,例如`aaaaaaa~5`表示取`aaaaaaa`的前5个提交。 --- --- url: /programming/git/linguist.md --- [Linguist](https://github.com/github/linguist) 是 GitHub 官方用于检测和高亮代码仓库中各种编程语言的工具,主要用于**代码语言识别**、**语法高亮**、**代码统计与标签**、**自动归类与过滤**等。 可以通过在仓库下新增`.gitattributes`文件对 Linguist 的行为进行定制,比如强制指定文件类型、忽略某些文件等。 举个例子: ```.gitattributes :no-line-numbers # 将所有的 .rb 文件标记为 Java 语言 *.rb linguist-language=Java # 用-替代语言名中的空格 *.glyphs linguist-language=OpenStep-Property-List # 语言名大小写不敏感,并且可以使用别名,以下三行是等价的 *.es linguist-language=js *.es linguist-language=JS *.es linguist-language=JAVASCRIPT ``` 以下是支持的属性: | 属性 | 备注 | |:-----------------------------------------------|:-----------------------------------| | `linguist-detectable` | 即使语言类型为 `data` 或 `prose`,也计入统计 | | `linguist-documentation` | 不计入统计 | | `linguist-generated` | 不计入统计,在 diff 中隐藏 | | `linguist-language`=name | 以 name 高亮和归类 | | `linguist-vendored` | 不计入统计 | ### Detectable 默认情况下,只有类型为 `programming` 或 `markup` 的语言才会计入语言统计。其它类型的语言默认不会被统计为 detectable,因此不会计入语言统计,但可以如下面方式使其计入语言统计: ```.gitattributes :no-line-numbers # 计入统计 *.kicad_pcb linguist-detectable *.sch linguist-detectable # 取消计入统计 tools/export_bom.py -linguist-detectable ``` ### Documentation 默认情况下 Linguist 会将文档文件从语言统计中排除。你可以使用 `linguist-documentation` 属性标记或取消标记路径为文档: ```.gitattributes :no-line-numbers # 标记为文档 project-docs/* linguist-documentation ano-dir/** linguist-documentation # 取消标记为文档 docs/formatter.rb -linguist-documentation ``` ### Generated 有些文件其实是生成文件,应该将其排除在仓库语言统计之外。此外,这些文件在 diff 中也会被自动隐藏。你可以使用 `linguist-generated` 属性标记或取消标记路径为生成文件: ```.gitattributes :no-line-numbers Api.elm linguist-generated ``` ### Vendored 有些第三方依赖会虚高项目的语言统计,甚至可能导致项目被误判为另一种语言。你可以使用 `linguist-vendored` 属性标记或取消标记路径为第三方依赖: ```.gitattributes :no-line-numbers # 标记为第三方依赖 special-vendored-path/* linguist-vendored ano-dir/** linguist-vendored # 取消标记为第三方依赖 jquery.js -linguist-vendored ``` ### 参考资料 * * --- --- url: /programming/go/go-build.md --- # go build 命令的使用技巧 ```bat set GOOS=windows set GOARCH=amd64 go build -ldflags "-s -w -H=windowsgui" -o test.exe ``` **参数解释** * `GOOS` 和 `GOARCH` 是 Go 的环境变量,分别表示操作系统和 CPU 架构。 * `-ldflags` 是链接器的参数,可以用来设置一些链接器的选项。 * `-s` 表示去掉符号表和调试信息。 * `-w` 表示去掉 DWARF 调试信息。 * `-H=windowsgui` 表示将程序编译为 Windows GUI 应用程序,而不是控制台应用程序。换句话说就是:如果不加这个参数,编译出来的程序会有一个控制台窗口,这个参数可以去掉这个控制台窗口。 * `-o` 用来指定输出的文件名。 `-s`和`-w`这两个参数常用于发布版本,可以显著减小二进制文件大小,但会导致无法用调试器调试。 ```bat go build -ldflags "-s -w" ``` --- --- url: /programming/go/go-chan.md --- # go 通道的使用技巧 ## 一些通道的基础问题 ### `select` 语句 ```go select { case <-c: case c <- struct{}{}: default: fmt.Println("Go here.") } ``` 对于一条`select`语句,在有多个`case`分支的情况下,优先级如下: 1. 优先选择不阻塞的`case`分支执行,如果有多个`case`分支不阻塞,则**随机选择一个执行**,并非从上到下执行。 2. 如果全部`case`分支都阻塞,则执行`default`分支。 3. 如果全部`case`分支都阻塞,且没有`default`分支,则会阻塞在`select`语句上,直到有一个`case`分支可以执行。 ### 尝试接收和尝试发送 如下代码,只有一个`case`和一个`default`分支的`select`语句,叫做尝试发送/尝试接收语句,编译器对其进行了优化。相较于普通的`select`语句,尝试发送/尝试接收语句的开销非常小。 ```go select { // 尝试发送 case ch <- data: doSth() default: } select { // 尝试接收 case data2 := <- ch: doSth2(data2) default: } ``` ## 将通道用做计数信号量(counting semaphore) 计数信号量经常被使用于限制最大并发数。 ::: tip 提示 使用通道作为信号量是一个非常简洁的实现方式,但只支持最基本的功能。如果想要使用更复杂的功能,例如**加权控制**、**超时/取消**或**非阻塞尝试**等,可以使用`golang.org/x/sync/semaphore`包。 ::: 缓冲通道可以被用做计数信号量。例如下面这个例子: ```go :no-collapsed-lines func main() { seats := make(chan int, 10) for i := range cap(seats) { seats <- i // 初始化10个信号量 } for c := range 10000 { go func(c int) { i := <- seats // 获取一个信号量,如果没有可用的信号量,则会阻塞 log.Print("++ 消费者#", c, "获取到了信号量#", i) time.Sleep(time.Second * time.Duration(2+rand.IntN(6))) log.Print("-- 消费者#", c, "即将释放信号量#", i) seats <- i // 释放信号量 }(c) } select {} } ``` 在上述示例中,只有获得了信号量的协程才能继续执行,其他协程会被阻塞,直到有信号量可用。这样就实现了对并发数的限制。 尽管协程的开销远比系统线程小得多,但在上述示例中,我们创建了大量的协程,积少成多也是一种资源浪费。我们可以对代码进行如下优化: ```go :no-collapsed-lines {7,8,10,16,20-22} func main() { seats := make(chan int, 10) for i := 0; i < cap(seats); i++ { seats <- i } consumers := make(chan int) for i := 0; i < cap(seats); i++ { go func() { for c := range consumers { i := <- seats log.Print("++ 消费者#", c, "获取到了信号量#", i) time.Sleep(time.Second * time.Duration(2 + rand.IntN(6))) log.Print("-- 消费者#", c, "即将释放信号量#", i) seats <- i // 释放信号量 } }() } for c := range 10000 { consumers <- c } select {} } ``` 上述代码将会更高效一些,在程序的生命期内最多只会有10个消费者协程被创建出来。 ## 如何优雅地关闭通道 ::: important 通道关闭原则 一个常用的使用Go通道的原则是**不要在数据接收方或者在有多个发送者的情况下关闭通道**。换句话说,我们只应该让一个通道唯一的发送者关闭此通道。 ::: 我们分为以下情形进行讨论: * M个接收者和一个发送者。发送者通过关闭用来传输数据的通道来传递发送结束信号 * 一个接收者和N个发送者,此唯一接收者通过关闭一个额外的信号通道来通知发送者不要再发送数据了 * M个接收者和N个发送者。它们中的任何协程都可以让一个中间调解协程帮忙发出停止数据传送的信号 ### M个接收者和一个发送者 第一个情形是最简单的,只需要发送者协程在发送完毕后关闭通道即可,这里不再罗列代码。 值得注意的是,用来传输数据的通道的关闭请求也可以由第三方发出。 ### 一个接收者和N个发送者 第二个情形中,很显然不能由接收者直接关闭通道,否则发送者会向已关闭的通道发送数据,导致 panic。我们可以使用一个额外的信号通道来通知发送者停止发送数据。 ```go :no-collapsed-lines func main() { const Max = 100000 const NumSenders = 1000 var wgReceivers sync.WaitGroup wgReceivers.Add(1) dataCh := make(chan int) stopCh := make(chan struct{}) for i := 0; i < NumSenders; i++ { // 多个发送者 go func() { for { select { // 尝试接收,让发送者在接收到停止信号后提前退出 case <- stopCh: return default: } select { case <- stopCh: return case dataCh <- rand.IntN(Max): } } }() } go func() { // 一个接收者 defer wgReceivers.Done() for value := range dataCh { if value == Max-1 { close(stopCh) return } log.Println(value) } }() wgReceivers.Wait() } ``` 可以看到,上述代码没有关闭`dataCh`通道,它会在不再被任何协程使用后自动被垃圾回收,我们也就避免了发送者向已关闭的通道发送数据而导致的 panic。 这个情形是非常常见的,例如在处理 TCP 连接时,由多个协程向同一个连接发送数据,一般就需要先写入一个带缓存的通道,然后由一个专门的协程从通道中读取数据并写入 TCP 连接中去。 ::: caution 易错点 在 TCP 连接中使用上述代码时,一般会这样写: ```go :collapsed-lines=24 func (conn *Conn) sendData(data []byte) { for { select { case <- conn.stopChan: return default: } select { case <- conn.stopChan: return case conn.dataChan <- data:// [!code warning] } } } func (conn *Conn) writeLoop() { for { data := <- conn.dataChan// [!code warning] if _, err := conn.Write(data); err != nil { // 写入数据失败 conn.Close() return } } } func (conn *Conn) Close() { conn.closeOnce.Do(func() { conn.netConn.Close() // 关闭网络 close(conn.stopChan) // 关闭 stopChan }) } func NewConn(netConn net.Conn) *Conn { return &Conn{ netConn: netConn, dataChan: make(chan []byte, 1024), // 带缓存的通道 stopChan: make(chan struct{}), } } ``` 上述代码中,`writeLoop`是一个专门的协程,用以从`dataChan`通道中读取数据并写入 TCP 连接中去。观察这两行高亮行会发现,前者是向`dataChan`通道中发送数据,后者是从`dataChan`通道中读取数据。 这里就引发了一个问题:在`writeLoop`中,`conn.Write`方法是向网络层写入数据,在实际网络传输中完全有可能阻塞住。一旦这里阻塞了,就不再会持续从`dataChan`通道中读取数据。而业务逻辑代码持续不断调用`sendData`方法向`dataChan`通道中发送数据,这就会导致`dataChan`通道被填满,直到阻塞在`dataCh <- data`这一行,最终业务逻辑代码持续阻塞在这里,导致卡死。 正确做法是增加一个`default`分支来处理通道写入满了的情况,一旦写入满了就说明这个连接出现了问题,可以根据业务需要考虑直接关掉或丢弃本条消息。下文展示了一个直接关掉的例子: ```go :no-collapsed-lines select { case <- stopChan: return case dataCh <- data: default:// [!code ++] log.Println("缓冲区已写满,连接可能已断开,关闭连接")// [!code ++] conn.Close()// [!code ++] return// [!code ++] } ``` ::: ### M个接收者和N个发送者 第三种情形是最复杂的一种情形。我们不能让接收者和发送者中的任何一个关闭用来传输数据的通道,我们也不能让多个接收者之一关闭一个额外的信号通道。 这两种做法都违反了通道关闭原则。 然而,我们可以引入一个中间调解者角色并让其关闭额外的信号通道来通知所有的接收者和发送者结束工作。 具体实现如下: ```go :no-collapsed-lines func main() { const Max = 100000 const NumReceivers = 10 const NumSenders = 1000 var wgReceivers sync.WaitGroup wgReceivers.Add(NumReceivers) dataCh := make(chan int) stopCh := make(chan struct{}) // 关闭用的通道 toStop := make(chan string, 1) // 额外通道通知第三方协程去关闭stopCh,此通道必须有缓冲区 var stoppedBy string go func() { stoppedBy = <-toStop // 这个toStop必须有缓冲区,以防发送者和接收者阻塞在 toStop <- close(stopCh) }() for i := 0; i < NumSenders; i++ { // 发送者 go func(id string) { for { value := rand.IntN(Max) if value == 0 { select { case toStop <- "发送者#" + id: default: } return } select { case <- stopCh: return default: } select { case <- stopCh: return case dataCh <- value: } } }(strconv.Itoa(i)) } // 接收者 for i := 0; i < NumReceivers; i++ { go func(id string) { defer wgReceivers.Done() for { select { case <- stopCh: return default: } select { case <- stopCh: return case value := <-dataCh: if value == Max-1 { select { case toStop <- "接收者#" + id: default: } return } log.Println(value) } } }(strconv.Itoa(i)) } wgReceivers.Wait() log.Println("被" + stoppedBy + "终止了") } ``` #### 特殊情况:有时候由于某些原因必须关掉`dataCh` 这种情况,我们只需要加入一个“中间发送者”,就可以转化为M个接收者和一个发送者的最简单的情形了。 ```mermaid graph LR A[发送者1] --> D[中间发送者]:::green B[发送者2] --> D C[发送者3] --> D D --> E((通道)) E --> F[接收者1] E --> G[接收者2] E --> H[接收者3] classDef green fill:#66CC66; ``` ```go :collapsed-lines=40 {11,12,17-40,48,64} func main() { const Max = 1000000 const NumReceivers = 10 const NumSenders = 1000 const NumThirdParties = 15 var wgReceivers sync.WaitGroup wgReceivers.Add(NumReceivers) dataCh := make(chan int) // 将被关闭 middleCh := make(chan int) // 不会被关闭 closing := make(chan string) closed := make(chan struct{}) var stoppedBy string go func() { // 中间层 exit := func(v int, needSend bool) { close(closed) if needSend { dataCh <- v } close(dataCh) } for { select { case stoppedBy = <-closing: exit(0, false) return case v := <- middleCh: select { case stoppedBy = <-closing: exit(v, true) return case dataCh <- v: } } } }() for i := 0; i < NumSenders; i++ { // 发送者 go func(id string) { for { value := rand.IntN(Max) if value == 0 { select { case closing <- "sender#" + id: <-closed case <-closed: } return } select { case <- closed: return default: } select { case <- closed: return case middleCh <- value: } } }(strconv.Itoa(i)) } for range NumReceivers { // 接收者 go func() { defer wgReceivers.Done() for value := range dataCh { log.Println(value) } }() } wgReceivers.Wait() log.Println("stopped by", stoppedBy) } ``` ## 参考资料 强烈建议阅读[Go101](https://gfw.go101.org/article/101.html)中的文章: * [通道](https://gfw.go101.org/article/channel.html) * [通道用例大全](https://gfw.go101.org/article/channel-use-cases.html) * [如何优雅地关闭通道](https://gfw.go101.org/article/channel-closing.html) --- --- url: /programming/go/go-context.md --- # context 包的使用 我们在使用Go标准库以及一些第三方库时,经常看到需要传入一个`context.Context`类型的参数。Context 一词翻译过来叫做“上下文”,但在Go语言中,它到底有什么用,应该怎样用呢? ## 如何停止线程/协程? 我们就以之前聊到的 [五子棋AI](../gobang/gobang4.md#缓存的用途) 为例: > 假设我们计算6步棋花了数秒钟,但是计算8步棋花了1分钟还没算出来,就可以提前返回计算6步的策略了。 这段话看似简单,实则涉及到并发编程的一个问题:提前返回后,8步棋的计算仍在进行,这样会持续浪费资源,如何将其停掉呢? 首先我们想到的方案是,把这个8步棋的计算线程/协程停掉不就行了。但是,真的可以直接停掉吗?事实上,所有主流语言中,都**不能在某个线程/协程中主动直接停掉另一个线程/协程**。 以Java为例,我们在[官方文档](https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/doc-files/threadPrimitiveDeprecation.html)中可以找到这样一个问题: ::: info 为什么 `Thread.stop` 被弃用,不能用于停止线程? 因为它在本质上是不安全的。停止一个线程会导致该线程解锁它已锁定的所有监视器。(当 `ThreadDeath` 异常在调用堆栈中向上传播时,监视器会被解锁。)如果之前受这些监视器保护的任何对象处于不一致状态,其他线程就可能观察到这些处于不一致状态的对象。此类对象被称为受损(damaged)对象。当线程操作受损对象时,可能导致任意行为。这种行为可能是微妙且难以检测的,也可能是非常显著的。与其他未检查异常不同,`ThreadDeath` 会静默地杀掉线程;因此,用户无法收到程序可能已损坏的警告。这种损坏在实际损害发生之后的任何时间都可能显现出来,甚至可能在数小时或数天之后。 ::: 举个很简单的例子,如果我们在线程A使用了`Lock`加锁,正常来讲应当在合适的时机使用`Unlock`来解锁。而如果我们在线程B调用`stop`方法强制停止线程A,是无法确认线程A到底有没有执行`Unlock`方法的,一旦没有,这个锁将永远得不到释放。为了防止这样的错误,设计者从语言层面上已经不支持强制停止线程的操作了。 那么,对于这样的需求,我们应当怎么实现呢?Java也提供了一个合适的方案: ```java {4,6-10,17} title="Main.java" public class Main { public static void main(String[] args) { Thread thread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // 。。一些业务逻辑。。 try { Thread.sleep(1000); // 休眠1秒 } catch (InterruptedException e) { break; } // 。。一些业务逻辑。。 } }); thread.start(); // 启动线程 // 在需要的时候可以调用 interrupt 方法来中断线程 thread.interrupt(); } } ``` 我们观察代码中的高亮行,第17行调用`interrupt`方法,会给该线程设置一个中断标志,第4行的`isInterrupted`方法会判断这个中断标志,当中断标志被设置了,则会跳出`while`循环。 除此之外,第7行`sleep`阻塞1秒的过程中,如果调用了`interrupt`方法,`sleep`阻塞会立即结束并抛出`InterruptedException`异常,被下一行的`catch`语句捕获住,从而`break`跳出循环。 而Go语言中是没有`throw`和`catch`的,语言设计层面也不建议使用`panic`和`recover`来处理正常的业务逻辑。于是,Go语言就设计了`context`包来处理这种情况。 ## Go语言的`context`包 对于上文中的这个例子,我们使用Go语言来实现就是这样的: ```go {9-13,15-19,25} title="main.go" package main import "context" func main() { ctx, cancelFunc := context.WithCancel(context.Background()) go func() { for { select { case <-ctx.Done(): // 检查上下文是否已经结束 return default: } // 。。一些业务逻辑。。 select { case <-time.After(time.Second): // 阻塞一秒,如果一秒时间到了则逻辑继续 case <-ctx.Done(): // 如果一秒时间还未到,上下文就结束了,则跳出循环 return } // 。。一些业务逻辑。。 } }() // 在需要的时候可以调用 cancelFunc 函数来结束上下文 cancelFunc() } ``` 代码中的高亮行和上文的Java代码中的高亮行一一对应,比较容易理解,这里就不详细讲解了。 ::: note 题外话 1. 顺带一提的是,我们在Go例子中使用的是`return`而不是`break`,这是因为外层用了`select`语句,如果使用`break`只会跳出`select`语句而不是跳出更外层的`for`循环。 2. 第16行`time.After`启动的定时器如果还未触发,这个协程就`return`了,这个定时器将不再被引用,理应被垃圾回收。在 Go 1.23 之前,它并不会被垃圾回收,仍然占用资源,直到计时器触发后才能被回收。从 Go 1.23 之后,对于这种情况,Go语言对其进行了优化,我们不再需要手动停止这个计时器,即使它不触发,也会被垃圾回收,因此**我们只需要简单地使用`time.After`即可**。 ::: `context`包中最常用使用的函数有: * `context.Background()`和`context.TODO()`:用来生成一个空的上下文,它永远不会被停止。 * `context.WithDeadline`、`context.WithCancel`、`context.WithTimeout`等:用来创建一个在某些特定的情况下会结束的上下文。 * `context.WithValue`:用来创建一个附带一些信息的上下文。 * 在较新版本Go中,还新增了一些其它方便的函数或方法,例如`context.AfterFunc`,方便我们使用。 我们在使用一些库提供的函数时,经常需要传入`context.Context`类型的参数,例如`http.NewRequestWithContext`,这个参数就是用来自定义上下文的结束条件,方便提前结束请求,直接返回`error`。 ::: tip `context.Background()`和`context.TODO()`的区别 `context.Background()`用于创建一个确定是空的上下文,它永远不会被停止。 而`context.TODO()`顾名思义,就是一个简单的`TODO`。当我们需要传入一个上下文,但是暂时没有确定要传入什么时,一般会临时写一个空的上下文作为占位符,但直接使用`context.Background()`不太合适。此时就应当使用`context.TODO()`,将来在我们确定要传入的上下文后,再将其替换掉。当我们最终完成整个程序的编写后,可以使用一些静态分析工具来检查是否有`context.TODO()`未被替换的情况。 ::: --- --- url: /programming/go/go-fatal-error.md --- # Go无法捕获的异常 在Go语言中,尽管许多`panic`可以通过`recover()`函数来捕获并处理,但依然存在一些特定情况下引发的异常是无法被捕获的。本文将探讨几种常见的无法通过常规手段恢复的错误情形及其原因。 ## 并发相关问题 ### 并发读写`map` 当尝试同时从多个协程读取和修改同一个`map`时,如果没有适当的同步机制(如使用互斥锁),则会触发运行时错误。 ```go m := make(map[int]int) go func() { for { m[1] = 1 } }() go func() { for { _ = m[1] } }() ``` **错误信息**:`fatal error: concurrent map read and map write`或者`fatal error: concurrent map writes` ::: note 注意 开启了`-race`参数后,更多潜在的竞争条件会被发现并导致程序崩溃。 ::: *** ### 使用`nil`函数启动协程 如果试图使用一个值为`nil`的函数启动新的协程,则会导致致命错误。 ```go var f func() go f() ``` **错误信息**:`fatal error: go of nil func value` *** ### 对未加锁的`Mutex`调用`Unlock` 当尝试解锁一个尚未锁定或已经解锁的互斥锁时,同样会产生致命错误。 ```go var mu sync.Mutex mu.Unlock() ``` **错误信息**:`fatal error: sync: unlock of unlocked mutex` ## 内存管理问题 以下是一些与内存相关的常见致命错误: * 栈溢出(Stack overflow) * 内存溢出(OOM) * 单个协程超出最大堆栈限制 例如,递归调用自身直到耗尽所有可用栈空间会导致栈溢出 ```go :no-line-numbers func f() { f() } func main() { f() } ``` **错误信息**:`fatal error: stack overflow` 下文中的[`runtime.SetFinalizer`](#不当设置runtime-setfinalizer)中也列举了一个内存泄漏的例子。 ## 底层库的错误使用 ### 错误地使用`unsafe`包 不恰当地操作底层数据结构可能会引起分段错误或其他严重问题,特别是当涉及到静态存储区域时。 示例代码如下: ```go package main import ( "fmt" "strings" "unsafe" ) var _ = strings.Join type SliceHeader struct { Data unsafe.Pointer Len int Cap int } type StringHeader struct { Data unsafe.Pointer Len int } func String2ByteSlice(str string) (bs []byte) { strHdr := (*StringHeader)(unsafe.Pointer(&str)) sliceHdr := (*SliceHeader)(unsafe.Pointer(&bs)) sliceHdr.Data = strHdr.Data sliceHdr.Len = strHdr.Len sliceHdr.Cap = strHdr.Len return } //var Golang = strings.Join([]string{"Go", "lang"}, "") // 这样就不会导致崩溃,因为不在静态存储区 var Golang = "Golang" // 在静态存储区,会导致崩溃 func main() { var goland = String2ByteSlice(Golang) goland[5] = 'd' // fatal error: unexpected fault address // [!code error] fmt.Printf("%s\n", goland) } ``` ### 不当设置`runtime.SetFinalizer` 虽然可以通过`SetFinalizer`为对象指定清理逻辑,但这并不等同于析构函数。如果依赖此功能来确保资源正确释放,则可能遇到问题。 例如:创建包含循环引用的对象组可能导致垃圾回收器无法正常工作,从而造成内存泄露。 ```go :no-collapsed-lines func memoryLeaking() { type T struct { v [1<<20]int t *T } var finalizer = func(t *T) { fmt.Println("finalizer called") } var x, y T // 此SetFinalizer函数调用将使x逃逸到堆上。 runtime.SetFinalizer(&x, finalizer) // [!code warning] // 下面这行将形成一个包含x和y的循环引用值组。 // 这有可能造成x和y不可回收。 x.t, y.t = &y, &x // y也逃逸到了堆上。 // [!code warning] } ``` ## 缺乏活动的协程 最后一种情况是所有活跃的协程都处于阻塞状态或者已经结束执行,此时整个程序将会崩溃。 这种情况通常发生在所有协程因为等待通道消息、执行`select`语句而阻塞,或是被显式调用了`runtime.Goexit()`方法而结束时。 --- --- url: /programming/go/go-fix.md --- # go fix 命令的使用技巧 ```bash go fix ./... ``` 这是 Go1.26 引入的一个新命令,用于进行一系列代码优化。它包含以下的一些功能: ```console :no-line-numbers $ go tool fix help ... Registered analyzers: any replace interface{} with any buildtag check //go:build and // +build directives fmtappendf replace []byte(fmt.Sprintf) with fmt.Appendf forvar remove redundant re-declaration of loop variables hostport check format of addresses passed to net.Dial inline apply fixes based on 'go:fix inline' comment directives mapsloop replace explicit loops over maps with calls to maps package minmax replace if/else statements with calls to min or max newexpr simplify code by using go1.26's new(expr) omitzero suggest replacing omitempty with omitzero for struct fields plusbuild remove obsolete //+build comments rangeint replace 3-clause for loops with for-range over integers reflecttypefor replace reflect.TypeOf(x) with TypeFor[T]() slicescontains replace loops with slices.Contains or slices.ContainsFunc slicessort replace sort.Slice with slices.Sort for basic types stditerators use iterators instead of Len/At-style APIs stringsbuilder replace += with strings.Builder stringscut replace strings.Index etc. with strings.Cut stringscutprefix replace HasPrefix/TrimPrefix with CutPrefix stringsseq replace ranging over Split/Fields with SplitSeq/FieldsSeq testingcontext replace context.WithCancel with t.Context in tests waitgroup replace wg.Add(1)/go/wg.Done() with wg.Go ``` 对于其中的`inline`修复器,可以在代码中使用`//go:fix inline`指令来指定需要内联的函数。举两个例子: ::: note 示例1 ```go //go:fix inline func show(prefix, item string) { fmt.Println(prefix, item) } ``` 执行`go fix ./...`后,所有调用`show`函数的地方都会被替换为内联的代码: ```go :no-collapsed-lines // 旧代码 show("", "hello") // 会被替换为以下代码 fmt.Println("", item) ``` ::: ::: note 示例2 ```go //go:fix inline func printPair(before, x, y, after string) { fmt.Println(before, x, after) fmt.Println(before, y, after) } ``` 执行`go fix ./...`后,所有调用`printPair`函数的地方都会被替换为内联的代码: ```go :no-collapsed-lines // 旧代码 printPair("[", "x", "y", "]") // 会被替换为以下代码,这里会自动考虑变量复用 var before, after = "[", "]" fmt.Println(before, "one", after) fmt.Println(before, "two", after) ``` ::: --- --- url: /programming/go/go-json-int64.md --- # Go对JSON中的int类型的处理 Go 语言对 JSON 中`int`类型的处理非常方便,基本涵盖了大多数特殊情况。 ## 本地是 int,但 JSON 中是 string 有的时候,我们本地结构是一个较大的`int`类型,如果按照正常的方式序列化,会得到`{"bar": 9223372036854775807}`。然而,JavaScript 中采用 IEEE 754 双精度浮点数(即`Number`类型)来存储所有数字,其能安全表示的最大整数是 2^53^ - 1。因此如果使用 JavaScript 做前端,处理这个 JSON 时就会丢失精度。这种情况下,我们可能会约定在 JSON 中将这个`int`类型加上引号,作为字符串来传输。对于这种情况,我们可以这样定义结构体: ```go type foo struct { Bar int `json:"bar,string"` } ``` 这样在序列化时,`int`类型的`Bar`字段会被转换成字符串形式的数字(带引号)。在反序列化时,也会正确地将其解析成一个`int`类型的值。值得一提的是,在反序列化时,如果 JSON 中不带引号,但这里加了`string`选项,就会解析失败。 当然,这种方式也适用于其他基本类型,比如`float64`、`bool`等,都只要在 JSON 标签中加上`string`选项即可。 ## 不确定是 int 还是 string 有的时候,由于供应方的 API 不规范,对于某个字段,它是一个数字,但它在 JSON 中有可能直接作为数字(不带引号),也有可能作为一个字符串(带引号)。这种情况我们就可以这样定义结构体: ```go type foo struct { Bar json.Number `json:"bar"` } ``` 这样不管传过来的是`{"bar": 123}`还是`{"bar": "123"}`,都可以正确解析。 我们查看`encoding/json`包的源码,找到其中`json.Number`的定义: ```go // A Number represents a JSON number literal. type Number string // String returns the literal text of the number. func (n Number) String() string { return string(n) } // Float64 returns the number as a float64. func (n Number) Float64() (float64, error) { return strconv.ParseFloat(string(n), 64) } // Int64 returns the number as an int64. func (n Number) Int64() (int64, error) { return strconv.ParseInt(string(n), 10, 64) } ``` 可以看到,`json.Number`的底层是`string`。在调用`json.Unmarshal`时,如果字段类型是`json.Number`,无论它是否带引号,以一个字符串的形式存储(只会做一个轻量级的格式校验,判断是否能解析成数字)。当我们调用它的`Int64()`、`Float64()`等方法时,才会真正去解析这个字符串并返回对应的数值。这个方式叫做**延迟解析**。 值得一提的是,在序列化的时候,如果字段类型是`json.Number`,会得到一个数字类型(不带引号)。如果我们想要在序列化时保持字符串的形式(带引号),可以在 JSON 标签中加上`string`选项: ```go type foo struct { Bar json.Number `json:"bar,string"` } ``` ## 不想延迟解析怎么办? `json.Number`采用了**延迟解析**的方式,但我们有可能遇到这样一种情况,在代码中我们对这个字段有大量调用,我们不希望每次调用`Int64()`、`Float64()`等方法时都要判断返回的`error`,这也有办法解决。 `encoding/json`包中有一个`Unmarshaler`接口: ```go type Unmarshaler interface { UnmarshalJSON([]byte) error } ``` 只要我们实现这个接口,就可以自定义 JSON 反序列化的行为了。我们可以定义一个新的类型`Int64`,它的底层类型是`int64`,并实现`UnmarshalJSON`方法来处理 JSON 中既可能是数字又可能是字符串的情况: ```go type Int64 int64 func (i *Int64) UnmarshalJSON(data []byte) error { if len(data) >= 2 && data[0] == '"' && data[len(data)-1] == '"' { data = data[1 : len(data)-1] } v, err := strconv.ParseInt(string(data), 10, 64) *i = Int64(v) return err } // 序列化时默认不带引号,如果想要带引号,就可以再实现一下 MarshalJSON 方法 func (i Int64) MarshalJSON() ([]byte, error) { str := strconv.FormatInt(int64(i), 10) return strconv.AppendQuote(nil, str), nil } ``` --- --- url: /programming/go/go-memory-model.md --- # Go语言内存模型 原文链接:[The Go Memory Model (Version of June 6, 2022)](https://go.dev/ref/mem) ## 引言 Go内存模型规定了在哪些条件下,可以保证一个goroutine对某个变量的读操作能够观察到另一个goroutine对该变量写入的值。 ### 建议 多个goroutine同时访问并修改数据时,必须将这些访问操作序列化。为了将访问操作序列化,应使用通道操作或其他同步原语(例如 [`sync`](https://go.dev/pkg/sync/) 和 [`sync/atomic`](https://go.dev/pkg/sync/atomic/) 包中提供的工具)来保护数据。 如果你的程序行为必须通过阅读本文档的其余部分才能理解,那么你是在过度设计。不要自作聪明。 ### 非正式概述 Go语言处理其内存模型的方式与其整体语言设计思路一致,旨在保持语义简单、易懂且实用。本节提供了方法的总览,对于大多数程序员来说已经足够。下一节将对内存模型进行更正式的规范说明。 **数据竞争**的定义是:对某个内存位置的写入操作,与对该相同位置进行的另一个读取或写入操作同时发生(除非所有涉及的访问都是通过`sync/atomic`包提供的原子数据访问)。如前所述,强烈建议程序员使用适当的同步机制来避免数据竞争。在没有数据竞争的情况下,Go程序的行为就像是所有的goroutine都被复用到单个处理器上执行一样。这个特性有时被称为DRF-SC。 ::: note 关于 DRF-SC Data-race-free programs execute in a sequentially consistent manner. 大致意思是,无数据竞争的程序以顺序一致的方式执行。简写为DRF-SC。 ::: 尽管程序员应该编写没有数据竞争的Go程序,但Go实现在应对数据竞争方面存在一些限制。实现可以选择在检测到数据竞争时始终报告该竞争并终止程序。除此之外,对单字长或子字长内存位置的每次读取,都必须观察到实际写入该位置的值(可能来自并发执行的goroutine),且该值尚未被覆盖。这些实现上的约束使得Go更类似于Java或JavaScript,即大多数数据竞争只有有限的结果可能性;而不像C和C++,任何存在数据竞争的程序其意义完全未定义,编译器可能执行任意操作。Go的方法旨在使存在错误的程序更加可靠、易于调试,同时仍然坚持认为数据竞争是错误,并且工具能够诊断和报告它们。 ## 内存模型 Go内存模型的以下正式定义,紧密遵循Hans-J. Boehm和Sarita V. Adve在PLDI 2008上发表的论文《[C++并发内存模型的基础](https://dl.acm.org/doi/10.1145/1375581.1375591)》中提出的方法。无数据竞争程序的定义以及对无竞争程序顺序一致性的保证,均与该工作中的定义等效。 内存模型描述了程序执行(由goroutine执行组成,而goroutine执行又由内存操作组成)所需满足的要求。 **内存操作**通过四个细节来建模: * 其类型,表明它是普通数据读取、普通数据写入,还是**同步操作**(例如原子数据访问、互斥锁操作或通道操作), * 其在程序中的位置, * 所访问的内存位置或变量, * 以及该操作读取或写入的数值。 某些内存操作是**类读取操作**,包括读取、原子读取、互斥锁加锁和通道接收。其他内存操作是**类写入操作**,包括写入、原子写入、互斥锁解锁、通道发送和通道关闭。还有一些操作,例如原子比较并交换,同时属于类读取和类写入操作。 一个**goroutine执行**被建模为由单个goroutine执行的一组内存操作。 **要求1**:对于每个goroutine,考虑到从内存读取和向内存写入的数值,其内存操作必须对应于该goroutine的正确顺序执行。该执行必须符合**先后顺序**关系,该关系由[Go语言规范](https://go.dev/ref/spec)为Go的控制流结构以及[表达式求值顺序](https://go.dev/ref/spec#Order_of_evaluation)所定义的偏序要求决定。 一个Go**程序执行**被建模为一组goroutine执行,连同映射W,该映射指明了每个类读取操作所读取的类写入操作。(同一程序的不同执行可能有不同的程序执行过程。) **要求2**:对于给定的程序执行,当映射 $W$ 仅限于同步操作时,必须能够通过某种与执行顺序及这些操作读写数值相一致的同步操作隐式全序来解释。 “同步于”关系是同步内存操作上的一个偏序,由 $W$ 推导得出。如果一个同步的类读取内存操作 $r$ 观察到一个同步的类写入内存操作 $w$(即 $W(r) = w$),那么 $w$ 同步于 $r$。非正式地说,“同步于”关系是前一段提到的隐含全序的一个子集,仅限于 $W$ 直接观察到的信息。 “发生于”关系定义为“先后顺序”关系和“同步于”关系之并集的传递闭包。 **要求3**:对于内存位置 $x$ 上的一个普通(非同步)数据读取操作 $r$,$W(r)$ 必须是一个对 $r$ **可见**的写入操作 $w$,这里“可见”意味着以下两者同时成立: 1. $w$ 发生于 $r$。 2. 不存在其他对 $x$ 的写入操作 $w'$ 使得 $w'$ 发生于 $r$ 并且 $w$ 发生于 $w'$。 内存位置 $x$ 上的一个**读写数据竞争**由一个对 $x$ 的类读取内存操作 $r$ 和一个对 $x$ 的类写入内存操作 $w$ 构成,其中至少有一个是非同步操作,并且它们未被“发生于”关系排序(即 $r$ 不发生于 $w$ 且 $w$ 不发生于 $r$)。 内存位置 $x$ 上的一个**写写数据竞争**由两个对 $x$ 的类写入内存操作 $w$ 和 $w'$ 构成,其中至少有一个是非同步操作,并且它们未被“发生于”关系排序。 注意,如果内存位置 $x$ 上没有读写或写写数据竞争,那么任何对 $x$ 的读取操作 $r$ 都只有一个可能的 $W(r)$:在“发生于”顺序中紧邻其前的那个单一的写入操作 $w$。 更一般地,可以证明,任何数据竞争无关(即其程序执行中没有读写或写写数据竞争)的Go程序,其所有可能的结果都只能由goroutine执行的某种顺序一致的交错执行来解释。(证明过程与上文引用的Boehm和Adve论文中的第7节相同。)此特性被称为DRF-SC。 此正式定义的目的是与包括C、C++、Java、JavaScript、Rust和Swift在内的其他语言为无竞争程序提供的DRF-SC保证相匹配。 某些Go语言操作,例如goroutine创建和内存分配,充当同步操作。这些操作对“同步于”偏序关系的影响记录在下文的"同步"章节中。各个包应为其自身的操作提供类似的文档说明。 ## 对包含数据竞争的程序的实现限制 上一节给出了无数据竞争程序执行的正式定义。本节非正式地描述了实现**必须**为确实包含竞争的程序提供的语义。 任何实现在检测到数据竞争时,都可以报告该竞争并停止程序执行。使用 `ThreadSanitizer`(通过 `go build -race` 启用)的实现正是这样做的。 对数组、结构体或复数的读取,**可以**以任意顺序实现为对其每个独立子值(数组元素、结构体字段或实部/虚部)的读取。类似地,对数组、结构体或复数的写入,**可以**以任意顺序实现为对其每个独立子值的写入。 对于保存的值不大于机器字长的内存位置 $x$ 的读取操作 $r$,**必须**观察到某个写入操作 $w$,使得 $r$ 不发生在 $w$ 之前,并且不存在写入操作 $w'$ 使得 $w$ 发生在 $w'$ 之前且 $w'$ 发生在 $r$ 之前。也就是说,每次读取都必须观察到由某个先前或并发的写入操作所写入的值。 此外,**不允许**观察到违反因果关系的写入和“凭空出现”的写入。 对于大小超过单个机器字长的内存位置的读取,**鼓励但不强制要求**满足与字长内存位置相同的语义,即观察到单个允许的写入 $w$。出于性能原因,实现**可以**将更大的操作视为一组以未指定顺序进行的独立机器字长操作。这意味着,多字数据结构上的竞争可能导致出现与单个写入操作不对应的不一致值。当这些值依赖于内部(指针,长度)或(指针,类型)配对的一致性时(在大多数 Go 实现中,接口值、映射、切片和字符串可能属于这种情况),此类竞争进而可能导致任意的内存损坏。 文末的章节中给出了一些不正确的示例: * [不正确的同步](#不正确的同步) * [不正确的编译](#不正确的编译) ## 同步 ### 初始化 程序初始化在单个goroutine中运行,但该goroutine可能会创建其他并发运行的goroutine。 **如果一个包 `p` 导入了包 `q`,则 `q` 的 `init` 函数完成发生于 `p` 的任何 `init` 函数开始之前。** **所有 `init` 函数的完成后,才会开始执行 `main.main` 函数。** ### Goroutine创建 **启动新goroutine的 `go` 语句执行后,新的goroutine才会开始执行。** 例如,在此程序中: ```go :no-line-numbers var a string func f() { print(a) } func hello() { a = "hello, world" go f() } ``` 调用 `hello` 将在未来的某个时刻(可能在 `hello` 函数返回后)打印出 `hello, world`。 ### Goroutine销毁 goroutine的退出不能保证同步于程序中的任何事件之前。例如,在以下程序中: ```go :no-line-numbers var a string func hello() { go func() { a = "hello" }() print(a) } ``` 对变量 `a` 的赋值操作之后没有任何同步事件,因此不能保证被其他任何goroutine观察到。实际上,激进的编译器可能会删除整个 `go` 语句。 如果一个goroutine产生的效果必须被另一个goroutine观察到,应使用锁或通道通信等同步机制来建立相对的先后顺序。 ### 通道通信 通道通信是goroutine之间同步的主要方法。在特定通道上的每次发送,都会与从该通道的一次对应接收相匹配,通常发生在不同的goroutine中。 **通道上的发送操作同步于从该通道对应接收操作的完成之前。** 以下程序: ```go :no-line-numbers var c = make(chan int, 10) var a string func f() { a = "hello, world" c <- 0 } func main() { go f() <-c print(a) } ``` 可以确保会打印出 `hello, world`。对 `a` 的写入先于通道 `c` 上的发送操作,而该发送操作在通道 `c` 对应的接收操作完成之前,该接收操作又先于 `print` 语句。 **通道的关闭操作在因通道关闭而返回零值的接收操作之前。** 在上例中,将 `c <- 0` 替换为 `close(c)`,得到的程序具有相同的保证行为。 **从无缓冲通道的接收操作在该通道上对应发送操作完成之前。** 以下程序(与上例类似,但交换了发送和接收语句,并使用无缓冲通道): ```go :no-line-numbers var c = make(chan int) var a string func f() { a = "hello, world" <-c } func main() { go f() c <- 0 print(a) } ``` 同样保证会打印出 `hello, world`。对 `a` 的写入先于从通道 `c` 的接收操作,而该接收操作在通道 `c` 上对应发送操作的完成之前,该发送操作又先于 `print` 语句。 如果通道是缓冲通道(例如 `c = make(chan int, 1)`),则该程序不能保证打印出 `hello, world`。(它可能打印空字符串、崩溃或执行其他操作。) **从容量为 $C$ 的通道进行的第 $k$ 次接收操作在该通道上第 $k+C$ 次发送操作的完成之前。** 此规则将前一条规则推广至缓冲通道。它允许用缓冲通道来建模计数信号量:通道中的元素数量对应于活动使用的数量,通道的容量对应于同时使用的最大数量,发送一个元素表示获取信号量,接收一个元素表示释放信号量。这是限制并发性的常用模式。 以下程序为工作列表中的每个条目启动一个goroutine,但goroutine使用 `limit` 通道进行协调,确保最多同时有三个goroutine在执行工作函数。 ```go :no-line-numbers var limit = make(chan int, 3) func main() { for _, w := range work { go func(w func()) { limit <- 1 w() <-limit }(w) } select{} } ``` ### 锁 `sync` 包实现了两种锁数据类型:`sync.Mutex` 和 `sync.RWMutex`。 **对于任意 `sync.Mutex` 或 `sync.RWMutex` 类型的变量 `l`,且满足 $n < m$ 时,对 `l.Unlock()` 的第 $n$ 次调用发生在对 `l.Lock()` 的第 $m$ 次调用返回之前。** 以下程序: ```go :no-line-numbers var l sync.Mutex var a string func f() { a = "hello, world" l.Unlock() } func main() { l.Lock() go f() l.Lock() print(a) } ``` 确保会打印出 `hello, world`。第一次调用 `l.Unlock()`(在函数 `f` 中)发生在第二次调用 `l.Lock()`(在 `main` 函数中)返回之前,而第二次加锁操作发生在打印语句之前。 **对于在 `sync.RWMutex` 类型变量 `l` 上的任意 `l.RLock` 调用,存在一个 `n`,使得对 `l.Unlock` 的第 `n` 次调用发生在该次 `l.RLock` 调用返回之前,并且与之匹配的 `l.RUnlock` 调用发生在对 `l.Lock` 的第 `n+1` 次调用返回之前。** **一次成功的 `l.TryLock`(或 `l.TryRLock`)调用等价于一次 `l.Lock`(或 `l.RLock`)调用。一次不成功的调用则完全不产生同步效果。就内存模型而言,可以认为即使在互斥锁 `l` 处于未锁定状态时,`l.TryLock`(或 `l.TryRLock`)也可能返回 `false`。** ### Once `sync` 包通过 `Once` 类型,为多goroutine环境下的初始化提供了安全的机制。多个线程可以对特定的 `f` 执行 `once.Do(f)`,但其中只有一个会运行 `f()`,其他调用将被阻塞,直到 `f()` 返回。 来自 `once.Do(f)` 对 `f()` 的单次调用完成发生在任意 `once.Do(f)` 调用返回之前。 在此程序中: ```go :no-line-numbers :no-callapsed-lines var a string var once sync.Once func setup() { a = "hello, world" } func doprint() { once.Do(setup) print(a) } func twoprint() { go doprint() go doprint() } ``` 调用 `twoprint` 将恰好调用 `setup` 一次。`setup` 函数将在任意一次 `print` 调用之前完成。结果将是 `hello, world` 被打印两次。 ### 原子值 [`sync/atomic`](https://go.dev/pkg/sync/atomic/) 包中的 API 统称为“原子操作”,可用于同步不同 goroutine 的执行。如果原子操作 $A$ 的效果被原子操作 $B$ 观察到,那么 $A$ 发生在 $B$ 之前。程序中执行的所有原子操作,其行为表现得像是在某种顺序一致的顺序中执行的一样。 上述定义与 C++ 的顺序一致原子操作以及 Java 的 `volatile` 变量具有相同的语义。 ### 终结器 [`runtime`](https://go.dev/pkg/runtime/) 包提供了 `SetFinalizer` 函数,用于添加一个终结器,当特定对象不再被程序可达时,该终结器将被调用。对 `SetFinalizer(x, f)` 的调用发生在终结器调用 `f(x)` 之前。 ### 其他同步机制 `sync` 包提供了其他的同步抽象机制,包括条件变量、无锁映射、分配池和等待组。这些机制中,每一项的文档都明确了其在同步方面提供的保证。 其他提供同步抽象机制的包也应对它们所提供的保证进行相应的文档说明。 ## 不正确的同步 存在数据竞争的程序是不正确的,并且可能表现出非顺序一致的执行行为。特别需要注意的是,一个读取操作 $r$ 可能会观察到任何与 $r$ 并发执行的写入操作 $w$ 所写入的值。即使这种情况发生,也并不意味着在 $r$ 之后发生的读取操作会观察到在 $w$ 之前发生的写入操作。 在此程序中: ```go :no-line-numbers :no-collapsed-lines var a, b int func f() { a = 1 b = 2 } func g() { print(b) print(a) } func main() { go f() g() } ``` 可能会出现 `g` 打印出 `2` 然后打印出 `0` 的情况。 这一事实使得一些常见的惯用法不再有效。 双重检查锁定是一种试图避免同步开销的尝试。例如,`twoprint` 程序可能会被错误地写成: ```go :no-line-numbers :no-collapsed-lines var a string var done bool func setup() { a = "hello, world" done = true } func doprint() { if !done { once.Do(setup) } print(a) } func twoprint() { go doprint() go doprint() } ``` 但是不能保证,在 `doprint` 中,观察到对 `done` 的写入就意味着观察到了对 `a` 的写入。这个版本可能会(错误地)打印空字符串而不是 `hello, world`。 另一个错误的惯用法是忙等待某个值,例如: ```go :no-line-numbers var a string var done bool func setup() { a = "hello, world" done = true } func main() { go setup() for !done { } print(a) } ``` 和之前一样,不能保证在 `main` 中观察到对 `done` 的写入就意味着观察到了对 `a` 的写入,所以这个程序也可能打印空字符串。更糟糕的是,由于两个线程之间没有同步事件,不能保证 `main` 会观察到对 `done` 的写入。`main` 中的循环不保证会结束。 这个主题还有一些更微妙的变体,例如这个程序: ```go :no-line-numbers :no-collapsed-lines type T struct { msg string } var g *T func setup() { t := new(T) t.msg = "hello, world" g = t } func main() { go setup() for g == nil { } print(g.msg) } ``` 即使 `main` 观察到 `g != nil` 并退出循环,也不能保证它会观察到 `g.msg` 的初始化值。 在所有这些例子中,解决方案是相同的:使用显式同步。 ## 不正确的编译 Go内存模型对编译器优化的限制与其对Go程序的限制同样严格。某些在单线程程序中有效的编译器优化,并非在所有Go程序中均有效。具体而言,编译器不得引入原始程序中不存在的写入操作,不得允许单次读取观察到多个值,也不得允许单次写入写入多个值。 以下所有示例均假定 `*p` 和 `*q` 指向多个goroutine可访问的内存位置。 不向无数据竞争的程序引入数据竞争,意味着不得将写入操作移出它们所在的条件语句之外。例如,编译器不得反转以下程序中的条件判断: ```go :no-line-numbers *p = 1 if cond { *p = 2 } ``` 也就是说,编译器不得将程序重写为: ```go :no-line-numbers *p = 2 if !cond { *p = 1 } ``` 如果 `cond` 为 `false` 且另一个goroutine正在读取 `*p`,那么在原始程序中,该goroutine只能观察到 `*p` 的任何先前值和 `1`。而在重写后的程序中,该goroutine可以观察到 `2`,这在之前是不可能的。 不引入数据竞争也意味着不得假设循环会终止。例如,编译器通常不得将以下程序中对 `*p` 或 `*q` 的访问移到循环之前: ```go :no-line-numbers n := 0 for e := list; e != nil; e = e.next { n++ } i := *p *q = 1 ``` 如果 `list` 指向一个循环链表,那么原始程序永远不会访问 `*p` 或 `*q`,但重写后的程序则会访问。(如果编译器能证明 `*p` 不会引发恐慌,将 `*p` 前移是安全的;将 `*q` 前移还需要编译器证明没有其他goroutine可以访问 `*q`。) 不引入数据竞争还意味着不得假设被调用的函数总是返回或不包含同步操作。例如,编译器不得将以下程序中对 `*p` 或 `*q` 的访问移到函数调用之前(至少在无法直接获知 `f` 的确切行为时如此): ```go f() i := *p *q = 1 ``` 如果该调用永不返回,那么原始程序同样永远不会访问 `*p` 或 `*q`,但重写后的程序则会访问。此外,如果该调用包含同步操作,那么原始程序可能在访问 `*p` 和 `*q` 之前建立了"发生于"边界,但重写后的程序则不会。 不允许单次读取观察到多个值,意味着不得从共享内存中重新加载局部变量。例如,编译器不得在以下程序中丢弃 `i` 并从 `*p` 重新加载它: ```go :no-line-numbers i := *p if i < 0 || i >= len(funcs) { panic("invalid function index") } // ... 复杂代码 ... // 编译器此处不得重新加载 i = *p funcs[i]() ``` 如果复杂代码需要许多寄存器,针对单线程程序的编译器可能会在未保存副本的情况下丢弃 `i`,然后在 `funcs[i]()` 之前重新加载 `i = *p`。但Go编译器不得这样做,因为 `*p` 的值可能已经改变。(相反,编译器可以将 `i` 溢出到堆栈。) 不允许单次写入写入多个值也意味着不得将局部变量将要写入的内存区域在写入前用作临时存储。例如,编译器不得在以下程序中使用 `*p` 作为临时存储: ```go *p = i + *p/2 ``` 也就是说,它不得将程序重写为: ```go *p /= 2 *p += i ``` 如果 `i` 和 `*p` 初始值都等于2,原始代码执行 `*p = 3`,因此一个存在竞争的线程从 `*p` 只能读取到 `2` 或 `3`。重写后的代码先执行 `*p = 1`,然后执行 `*p = 3`,这使得存在竞争的线程还可能读取到 `1`。 请注意,所有这些优化在C/C++编译器中是允许的:与C/C++编译器共享后端的Go编译器必须注意禁用那些对Go无效的优化。 还需注意,如果编译器能够证明数据竞争不影响在目标平台上的正确执行,则禁止引入数据竞争的规则不适用。例如,在几乎所有CPU上,将 ```go :no-line-numbers n := 0 for i := 0; i < m; i++ { n += *shared } ``` 重写为: ```go :no-line-numbers n := 0 local := *shared for i := 0; i < m; i++ { n += local } ``` 是有效的,前提是可以证明 `*shared` 在访问时不会出错,因为潜在增加的读取操作不会影响任何现有的并发读取或写入。然而,这种重写在源码到源码的转换器中将是无效的。 ## 结语 编写无数据竞争程序的Go程序员可以依赖这些程序顺序一致的执行,正如几乎所有其他现代编程语言一样。 当涉及存在数据竞争的程序时,程序员和编译器都应牢记这个建议:不要自作聪明。 --- --- url: /programming/go/go-mod.md --- # go.mod 文件介绍 从 Go 1.11 开始,Go 引入了模块化管理,并于 Go 1.16 版本开始正式成为默认构建模式。现在,Go 模块已经成为 Go 语言通用的包管理方式。 在项目根目录下使用`go mod init example/mymodule`就可以初始化一个 Go 模块,并生成一个名为 go.mod 的文件,其中第一行即为`module example.com/mymodule`。 go.mod 文件用于描述模块的依赖关系和版本信息。一个可能的 go.mod 文件可以包含以下内容: ```go.mod title="go.mod" :no-collapsed-lines module example.com/mymodule go 1.23.1 toolchain go1.23.3 godebug asynctimerchan=0 tool example.com/mymodule/cmd/mytool require ( example.com/othermodule v1.2.3 example.com/thismodule v1.2.3 example.com/thatmodule v1.2.3 ) replace example.com/thatmodule => ../thatmodule ignore ./node_modules exclude example.com/thismodule v1.3.0 retract v1.1.0 ``` 下面对其各个部分进行解释。 ## module `module`用于声明模块的模块路径,它是模块的唯一标识符。该模块路径将成为模块包含的所有包的导入前缀。 在上面的例子中,是这样定义的:`module example.com/mymodule`。假设其下有一个目录`foo`: ::: file-tree * example.go * go.mod * go.sum * foo * bar.go ::: 我们想要导入该包,应该使用这样的路径: ```go title="example.go" import "example.com/mymodule/foo" ``` 如果我们的代码托管在 Gitee 上,例如[gitee.com/CuteReimu/neuquant](https://gitee.com/CuteReimu/neuquant/blob/master/go.mod),我们也应该把模块路径定义为: ```go.mod title="go.mod" module gitee.com/CuteReimu/neuquant ``` 以避免导入路径和模块路径不一致,其它人导入时可能会出错。 如果我们打算发布`v0`/`v1`版本的模块,按照上述方法定义模块路径即可。如果我们打算发布`v2`或更高版本的模块,则需要在模块路径中后增加版本号,例如: ```go.mod title="go.mod" module gitee.com/CuteReimu/neuquant/v2 ``` 这样别人导入时就可以使用: ```go.mod title="go.mod" require gitee.com/CuteReimu/neuquant/v2 v2.1.1 ``` ```go title="example.go" import "gitee.com/CuteReimu/neuquant/v2/foo" ``` ## go 和 toolchain `go 1.23.1`指定了该模块使用的最低 Go 版本。同时,这一行也会影响到编译器的行为。 `toolchain go1.23.3`指定了建议使用的工具链版本。 一般情况下,如果只是自己做一个项目,`go`和`toolchain`设置成当前使用的 Go 版本即可。 但如果你打算发布一个库供别人使用,你需要考虑使用你的库的用户可能并不一定更新到了你的 Go 版本,尤其是当你在更新 Go 版本的时候,需要多加考虑。 ## godebug `godebug`用于设置 Go 调试器的行为。在我们的日常编程中一般不需要设置。详细说明可以参考[官方文档](https://go.dev/doc/godebug)。 ## require `require`和`replace`是最常用的两个指令。 `require example.com/othermodule v1.2.3`声明了该模块依赖于`example.com/othermodule`模块的版本`v1.2.3`。 ::: tip 简写 特别要说明的是,如果遇到连续多行,例如: ```go.mod require example.com/othermodule v1.2.3 require example.com/thismodule v1.2.3 require example.com/thatmodule v1.2.3 ``` 可以将其简写为: ```go.mod :no-line-numbers require ( example.com/othermodule v1.2.3 example.com/thismodule v1.2.3 example.com/thatmodule v1.2.3 ) ``` 除了`require`外,其他部分也都可以使用类似的方式进行简写。 ::: 当你使用`go mod tidy`命令后,Go 会自动分析你的代码,删除不再需要的依赖,并更新`go.mod`文件。较新版本的 Go 会把`require`分成两部分:第一部分是直接依赖的模块,第二部分是间接依赖的模块,间接依赖的模块后会添加`// indirect`注释。 ## replace 如果你引用的模块并不是在互联网上,而是本地的另一个项目,你可以用`replace`来指定本地路径。例如: ```go.mod require example.com/othermodule v1.2.5 replace example.com/othermodule v1.2.5 => ../othermodule ``` 这样,引用的`example.com/othermodule v1.2.5`模块将被替换为本地的`../othermodule`路径下的项目。 ## tool `tool`是 Go 1.24 引入的一个新特性,用于管理工具。详细用法可以参考[官方文档](https://go.dev/doc/modules/managing-dependencies#tools)。 ## ignore `ignore`是 Go 1.25 引入的一个新特性,可以让所有 Go 命令都忽略指定的目录,这在大型多语言仓库中非常有用。 例如你有一个 NodeJS 和 Go 混合的大型项目,`node_modules`目录下有上千个js的第三方库,Go工具每次都会去遍历,大大拖慢了效率。这时,你就可以使用`ignore`来忽略Go工具对特定目录的遍历。 下面给出一个示例: ```go.mod :no-line-numbers ignore ./node_modules ignore ( static content/html ./third_party/javascript ) ``` 所有指定的目录及其子目录都会被忽略。如果目录是以`./`开头的,则该目录将会相对于项目根目录进行解析。否则,项目中任意位置的该目录及其子目录都会被忽略。 ## exclude 如果你发现,引用的模块的最新版本有问题,或者并不想使用最新版本,但是在`go get -u`时却会自动更新到该版本,你可以使用`exclude`来排除该版本。例如: ```go.mod exclude example.com/thismodule v1.3.5 ``` 这样,`example.com/thismodule v1.3.5`版本将不会被使用。如果有新的版本,会更新到新的版本。否则,退而求其次,将会使用`v1.3.4`或更低版本。 ## retract 从 Go 1.16 开始,Go 模块支持了`retract`指令,用于撤回某个版本。 例如,你发布了一个模块的`v1.1.0`版本,但后来发现这个版本有问题,你可以紧急发布一个`v1.1.1`版本,在其中添加: ```go.mod // 撤回的原因 retract v1.1.0 ``` 之后,所有引用了该库的工程应用,执行`go list`就可以看到如下提醒: ```console $ go list -m -u all github.com/example/mymodule v1.1.0 (retracted) [v1.1.1] ``` 在手动执行`go get`时,也会提示: ```console $ go get github.com/example/mymodule@v1.1.0 go: github.com/example/mymodule@v1.1.0: retracted by author: 撤回的原因 go: to update to a non-retracted version, use "go get github.com/example/mymodule@v1.1.1" ``` 当然了,你也可以在`v1.1.1`版本中把自己版本也撤回: ```go.mod :no-line-numbers retract ( v1.1.0 v1.1.1 ) ``` 这样,用户在引用时,由于`v1.1.0`和`v1.1.1`版本都不可用,在没有新的版本的情况下,就会自动降级到更低版本。 `retract`指令不光可以撤回单个版本,也可以撤回一系列版本。例如: ```go.mod retract [v1.1.0, v1.1.9] ``` 上述指令将会撤回`v1.1.0`到`v1.1.9`之间(闭区间)的所有版本。 --- --- url: /programming/go/go-race.md --- # Go检测代码中的数据竞争风险 在`go run`、`go test`、`go build`和`go install`命令中都可以使用`-race`参数来检测代码中的数据竞争风险。 ```bash :no-line-numbers go test -race test.go go run -race main.go go build -race -o main go install -race mypkg ``` * `-race`会显著增加程序的运行时间和内存使用,因为它需要跟踪所有的内存访问。 * 如果检测到数据竞争,运行时会输出详细的竞争信息,包括代码位置和调用栈。 通过这种方式,你可以有效检测`map`或其他共享资源的并发访问问题。例如: ```go title="main.go" package main func main() { m := make(map[int]int) for range 100 { go func() { for i := 0; i < 1000; i++ { m[i] = i } }() } select {} } ``` 上述代码中,多个 goroutine 同时访问和修改同一个`map`,会导致数据竞争,触发`fatal error: concurrent map writes`错误,进程直接崩溃,无法被`recover`。但也有可能不触发竞争,就不会崩溃,留下隐患。 使用`-race`参数运行时,就会输出类似以下的错误信息: ```console :collapsed-lines=12 :no-line-numbers ================== WARNING: DATA RACE Write at 0x00c000090000 by goroutine 8: runtime.mapassign_fast32() /usr/local/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.4.darwin-arm64/src/runtime/map_fast32.go:113 +0x34c main.main.func1() /root/MyTest/main.go:16 +0x44 Previous write at 0x00c000090000 by goroutine 5: runtime.mapassign_fast32() /usr/local/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.4.darwin-arm64/src/runtime/map_fast32.go:113 +0x34c main.main.func1() /root/MyTest/main.go:16 +0x44 Goroutine 8 (running) created at: main.main() /root/MyTest/main.go:14 +0x34 Goroutine 5 (running) created at: main.main() /root/MyTest/main.go:14 +0x34 ================== fatal error: concurrent map writes ``` 可以通过修改`GORACE`环境变量来控制数据竞争检测的行为,例如: ```bash GORACE="log_path=race_log history_size=5" go build -race ``` GORACE 支持的常用配置项如下: | 选项 | 说明 | 默认值 | |---------------------------|------------------------------------------|-------| | `halt_on_error=1` | 检测到竞态时是否立即停止程序(1 表示停止,0 表示继续运行) | 1 | | `exitcode=N` | 检测到竞态时退出的返回码 | 66 | | `strip_path_prefix=STR` | 输出日志时去掉文件名前缀 STR | "" | | `log_path=PATH` | 将 race detector 的日志输出到指定文件路径(默认为 stderr) | "" | | `history_size=N` | 指定 race detector 保存的历史操作数量(影响内存消耗) | 2^20^ | | `suppress_equal_stacks=0` | 是否抑制报告相同调用栈上的竞态(1 表示抑制,0 表示不抑制) | 0 | | `atexit_sleep_ms=N` | 检测到竞态后进程退出前等待 N 毫秒(调试用) | 0 | **设置方式示例:** ```bash GORACE="halt_on_error=0 log_path=./race.log" go run -race main.go ``` **参考文档:** * [Go 官方文档 - Race Detector](https://golang.org/doc/articles/race_detector.html) * [Go 源码 runtime/race/race.go](https://github.com/golang/go/blob/master/src/runtime/race/race.go) --- --- url: /programming/go/go-repo.md --- # 我用过的 Go 开源库汇总 整理了一下我用过的 Go 开源第三方库,供大家参考。使用时请注意遵守各个库的开源协议。 以下外链会跳转到对应项目的官网(如果有)或 GitHub 仓库。 **代码质量工具** * [**golangci-lint**](https://golangci-lint.run/)是著名的 Go 代码静态检查工具集,集成了多个流行的 linters,可以帮助我们快速发现代码中的潜在问题。在 Jetbrains Goland 2025版本开始,已经内置了对 golangci-lint 的支持,可以直接在 IDE 中使用,这更加说明了大家对 golangci-lint 的认可。使用方法可以参考[我的另一篇文章](golangci-lint.md)。 * [**testify**](https://github.com/stretchr/testify)是一个流行的 Go 测试框架,提供了丰富的断言和模拟功能,可以帮助我们编写更易读、更易维护的测试代码。它的知名度也是有目共睹。在 golangci-lint 中,也包含了对 testify 代码规范检查的 linter。 **Web框架** * [**gin**](https://gin-gonic.com/)是一个高性能的 Go Web 框架,它的性能和易用性都非常出色。我们都知道,对于一个 http 请求,按照标准的处理流程,需要考虑对请求的解析、路由、处理、响应等多个环节,其中有很多对请求头和请求体的解析等处理,如果纯用Go自带的`http`库进行操作,会非常麻烦。这些内容 gin 都为我们封装好了,我们只需要关注业务逻辑即可。 * [**resty**](https://github.com/go-resty/resty)对应于上面的 Go Web 服务端框架gin,resty是一个功能强大的 Go HTTP 客户端库,提供了丰富的功能来处理 HTTP 请求和响应。它支持链式调用、请求重试、请求拦截器等功能,非常适合用于构建复杂的 HTTP 客户端应用。resty 的 API 设计非常友好,易于使用。 * [**gorilla/websocket**](https://github.com/gorilla/websocket)是一个流行的 Go WebSocket 库,提供了简单易用的 API 来处理 WebSocket 连接,支持 Websocket 的服务端和客户端。这也是在 Go 中处理 WebSocket 的非常通用的一个库。 **其它服务端框架** * [**davyxu/cellnet**](https://github.com/davyxu/cellnet)相比较而言,这并不是一个比较知名的库,可靠性和稳定性可能有缺陷,并且年久失修。但其作者是国内的,并且项目的文档主要是中文文档,非常简单易用。如果对项目的稳定性要求并不是特别高,仅仅是想快速搭建一个 TCP/UDP/WebSocket/HTTP 服务器的话,可以考虑使用这个库。我针对这个库还开发了一些[扩展](https://gitee.com/CuteReimu/cellnet-plus),使其支持KCP,以及不同格式的TCP、UDP包。 * [**protoactor**](https://proto.actor/)是一个基于 Actor 模型的 Go 服务端框架,提供了高性能的并发处理能力。它的设计灵感来自 Erlang 的 Actor 模型,适合用于构建分布式系统和微服务架构。对于我这种有 Erlang 基础的开发者来说,Actor 模型的编程模型非常熟悉,使用起来也非常顺手。protoactor 是跨语言的,支持 Go 和 C#,并且支持多节点。尽管目前只是预发布版本,不排除接口会有变动,但稳定性和可靠性还是不错的。但它在用作 User Actor 时,涉及到 Actor 过期和再次创建的时序问题时有一些问题,可以参考我的[另一篇文章](protoactor.md)。 **数据库** * [**badger**](https://docs.hypermode.com/badger)是一个高性能的嵌入式键值对数据库,适用于需要快速读写操作的场景。它可以嵌入到自己的 Go 代码中。badger 基于 Go 的`mmap`库(内存映射),性能非常出色,可靠性也非常高,甚至不用太担心突然宕机等的极端问题。badger 也拥有自己的命令行工具,可以轻松访问数据库内容。在不打算使用 mysql、redis 等大型数据库的情况下,badger 是我非常喜欢的使用的一个内嵌型数据库。 **图形界面** * [**ebiten**](https://ebitengine.org/)是一个2D的游戏引擎,使用 Go 语言编写,提供了简单易用的 API 来创建2D游戏和图形应用。ebiten 的性能非常出色,并且支持多平台,包括 Windows、macOS、Linux、Android 和 iOS。我自己做的[五子棋](https://gitee.com/CuteReimu/gobang)的Go语言版本就是用的这个库。 * [**walk**](https://github.com/lxn/walk)是一个用于创建 Windows GUI 应用程序的 Go 库,提供了丰富的控件和布局管理功能,适合用于开发 Windows 桌面应用程序。虽然这个库已经很久没有更新了,但如果你需要使用 Go 开发 Windows 桌面应用程序,它仍然是一个不错的选择。 * [**gogleman/gg**](https://github.com/fogleman/gg)是一个2D的图形库,可以用它来快速画图。 * [**go-charts**](https://github.com/vicanso/go-charts)是一个快速生成图表的 Go 库,支持多种图表类型,包括折线图、柱状图、饼图等。它可以生成 SVG 或 PNG 格式的图表,非常适合用于数据可视化。不过,这个库目前已经归档,不再维护了。 **文本处理** * [**viper**](https://github.com/spf13/viper)是一个用于处理配置文件的 Go 库,支持多种格式,包括 JSON、YAML、TOML 等。它提供了简单易用的 API 来读取和写入配置文件,并且支持热加载配置文件。viper 是 Go 生态中非常流行的配置库,很多项目都在使用它。 * [**gjson**](https://github.com/tidwall/gjson)是一个用于快速解析 JSON 的 Go 库,提供了简单易用的 API 来读取和查询 JSON 数据。它的性能非常出色,并且支持多种查询方式,包括点语法、数组索引等。gjson 是处理 JSON 数据的利器,比起 Go 自带的`json`库,在用于处理大规模的 JSON 数据时表现更为突出。 * [**regexp2**](https://github.com/dlclark/regexp2)是一个 Go 的正则表达式库。由于 Go 自带的`regexp`库不支持一些高级的正则表达式特性,这个库提供了更强大的正则表达式功能。它的 API 与 Go 自带的`regexp`库类似,可以很容易地替换掉原有的正则表达式代码。 --- --- url: /programming/go/go-version.md --- # Go语言版本新特性总结 ## Go 1.27 新特性 参考 主要内容有: * 支持了**泛型方法** * `json/v2`包转正 * 新增了`uuid`包 * 优化了内存分配,特别是针对小对象(小于80字节)的分配开销最高可降低30% * 增加了对 goroutine 泄漏分析的支持 ## Go 1.26 新特性 参考 ### 语言的变化 ::: details `new`函数增强 `new`函数现在可以接受一个表达式来指定初始值。这在序列化包(如`encoding/json`和`protobuf`)中处理指针字段时尤为实用。 ```go :no-line-numbers type Person struct { Name string `json:"name"` Age *int `json:"age"` } p := Person{ Name: "Alice", Age: new(30), // 以前需要先声明一个变量再取地址 } ``` ::: ::: details 自引用泛型类型 泛型类型现在可以在其类型参数列表中引用自身: ```go :no-line-numbers type Adder[A Adder[A]] interface { Add(A) A } func algo[A Adder[A]](x, y A) A { return x.Add(y) } ``` ::: ### 工具链与开发体验 * [`go fix` 工具,用以优化代码](go-fix.md)。 * `go mod init` 默认使用更低的 Go 版本:版本`1.N.X`会创建`go 1.(N-1).0`的`go.mod`。 * `pprof` 的 Web UI(`-http`模式)默认展示火焰图视图。 ### 运行时与 GC * `greenteagc` 垃圾回收器现在默认开启(Go 1.25 中还是实验性特性),在实际程序中可减少 10%~40% 的 GC 开销,在较新的 amd64 CPU 上额外改进约 10%。可通过`GOEXPERIMENT=nogreenteagc`关闭。 * cgo 调用的基线运行时开销降低约 30%。 * 64 位平台启动时随机化堆基地址,增强安全性。可通过`GOEXPERIMENT=norandomizedheapbase64`关闭。 * 实验性特性:`goroutineleak` profile,可检测泄漏的 goroutine,通过`GOEXPERIMENT=goroutineleakprofile`启用。 ### 编译器和链接器 * 编译器现在可以将更多的切片后备存储分配到栈上,提升性能。 * 64 位 ARM Windows 上的链接器支持 cgo 程序的内部链接模式。 * 移除了 32 位`windows/arm`端口;`linux/riscv64`现在支持竞态检测器。 ### 标准库 ::: details 新增`crypto/hpke`包 实现了混合公钥加密(RFC 9180),支持后量子混合密钥封装。 ::: ::: details 实验性特性:`simd/archsimd`包 通过`GOEXPERIMENT=simd`启用,提供架构特定的 SIMD 操作,目前支持 amd64 平台上的 128 位、256 位、512 位向量运算。 ::: ::: details 其它库的变化 * `errors`包新增泛型函数`AsType()` * `io.ReadAll()`性能约提升 2 倍,内存占用减少一半 * `bytes.Buffer`新增`Peek()`方法 * `reflect`包新增迭代器方法:`Fields()`、`Methods()`、`Ins()`、`Outs()` * `log/slog`新增`NewMultiHandler()` * `net`包的`Dialer`新增`DialIP`、`DialTCP`、`DialUDP`、`DialUnix`方法 * `testing`新增`ArtifactDir()`方法和`-artifacts`命令行参数 * TLS 默认启用后量子混合密钥交换(`SecP256r1MLKEM768`、`SecP384r1MLKEM1024`) * 加密模块中的随机参数现在始终使用安全源,忽略自定义随机源 * 还有一些影响不大的变化,就不一一列举了 ::: ## Go 1.25 新特性 参考 ### 语言的变化 * 泛型的优化:移除了“核心类型”概念,回归类型集本质。 ```go :no-line-numbers // 从 Go 1.25 开始,以下语法可以编译通过 type Constraint interface { ~[]byte | ~string } func Slice[T Constraint](s T) T { return s[1:3] // 合法:切片操作对 []byte 和 string 均有效 } ``` ### 工具链与开发体验 * `go build -asan` 默认开启 CGo 内存泄漏检测,提高安全性。 * `go.mod` 新增 `ignore` 指令:可忽略部分目录,方便大型多语言仓库管理。 * `go doc -http` 可直接启动本地文档服务器并自动打开浏览器。 * `go version -m -json`:以 JSON 格式输出构建信息,更适合自动化分析。 * 仓库子目录可作为模块根路径,多模块仓库更灵活。 * `go vet` 新增 `hostport`、`waitgroup` 分析器,提高静态检查能力。 ### 标准库 * 新增 `testing/synctest` 并发测试包,支持虚拟时钟模拟、协程编排。 * 实验性特性:`encoding/json/v2` ,性能更优,通过环境变量显式启用。 * 加密、文件系统、网络接口等多项优化。 * `sync.WaitGroup.Run` 方法,代替原先的 `Add(1)` + `defer Done()` 的写法,简化并发任务管理。 ### 运行时与 GC * `GOMAXPROCS` 将会自动适配容器 CPU 限制,对于容器环境将会更友好,支持动态调整。 * 实验性特性:`greenteagc` 垃圾回收器,**针对小对象**,大幅度降低 GC 开销。 * `panic`优化:输出更整洁。 ### 编译器和链接器 * DWARF 5 调试信息默认开启,内联和逃逸分析进一步优化,编译更快、体积更小,大型项目尤为显著。 * 放弃 macOS 的 10.15/11 支持,Windows arm32 将在下个版本终止支持。 ## Go 1.24 新特性 参考 Go 1.24 没有太多的新特性,因此这里简单提及一些比较值得关注的变化。 ### 类型别名支持泛型 举个例子,我们要根据`map`定义一个`set`,在 Go 1.24 中可以这样定义: ```go type set[P comparable] = map[P]bool ``` ### 标准库 * `bytes`包新增了迭代器相关函数 * 还有一些影响不大的变化,就不一一列举了 ## Go 1.23 新特性 参考 ### 语言的变化 Go 1.23 版本中,将 Go 1.22 中的关于“函数迭代器”的实验性功能正式发布。 ```go :no-line-numbers :collapsed-lines=2 func Range0To10(func(int) bool) { for i := 0; i < 10; i++ { if !cb(i) { return } } } func main() { for i := range Range0To10 { fmt.Println(i) } } ``` 上面展示的是单参数的函数迭代器的用法,Go 1.23 版本中正式支持了零参数、单参数和双参数的函数迭代器。 ```go func(func() bool) func(func(K) bool) func(func(K, V) bool) ``` ### 标准库 ::: details `time`包的调整 首先,`time.Timer`和`time.Ticker`即使没有调用`Stop`方法,只要不再被引用,都会被自动回收。 其次,`time.Timer`和`time.Ticker`关联的通道现在改为了无缓冲区的通道,在早期的Go版本中,这个通道有一个元素的缓冲区。 ::: ::: details 迭代器相关包 新增了`iter`包,提供了迭代器对应的定义。 `slices`包和`maps`包也进行了相应的调整,提供了迭代器的功能。 ::: ::: details 其它库的调整 * 补上了`math/rand/v2`包中缺失的`Uint`函数和方法 * `slices`包新增了`Repeat`函数,用以创建元素重复的切片 * `sync.Map`新增了`Clear`方法,用以清除其中的所有键值对 * `sync/atomic`包中新增了`And`和`Or`方法 * 还有一些影响不大的变化,就不一一列举了 ::: *** ## Go 1.22 新特性 参考 ### 语言的变化 Go 1.22 对`for`循环进行了两项更改,和一项实验性功能。 ::: details 循环变量的调整 以前,`for`循环声明的变量只创建一次,并在每次迭代时更新。在 Go 1.22 中,循环的每次迭代都会创建新变量,以避免意外共享错误。举个例子: ```go ss := []string{"123", "234", "345"} for _, s := range ss { // 从 Go 1.22 开始,这里不需要再加一行 s := s 了 go func() { fmt.Println(s) }() } ``` 对于以前的版本,上述代码大概率会输出三个"345",因为在每一遍`for`循环时,`s`是同一个变量,只不过每次循环把它更新成了新值。而使用`go`语句启动协程一般是需要一些时间的,协程真正开始执行会比`for`循环要慢一些的,此时的`s`已经变成了最后一个值"345",所以三个协程都会输出"345"。 而在 Go 1.22 中,上述代码会将三个字符串各输出一遍,三个字符串的输出顺序会因为协程执行的顺序而不同。 ::: ::: details 整数范围循环 现在,range后面允许是一个整数了,例如: ```go for i := range 10 { // 等价于 for i := 0; i < 10; i++ { fmt.Println(i) } ``` ::: ::: details (实验性功能)函数迭代器 想要使用这个功能,需要在编译时启用环境变量`GOEXPERIMENT=rangefunc`。 ```go ss := []string{"123", "234", "345"} f := func(cb func(int, string) bool) { for i, s := range ss { if !cb(i, s) { return } } } for i, s := range f { fmt.Println(i, s) } ``` 从上述代码中可以看到,`f`是一个函数,它的格式为`func(cb func(xxx) bool)`,其中xxx可以为零到两个参数,两个参数支持任意类型,我们将其称为“函数迭代器”。当我们`for i, s := range f`时,会将for下面的大括号体中的内容作为`cb`函数传入`f`函数中去,然后前面的`i, s := `两个参数对应`cb`的两个形参,并且变量类型和`cb`的形参声明的类型也一致。 通过这种语法,我们可以方便的生成一些迭代器,例如切片的逆序迭代器: ```go :no-collapsed-lines func Backward[E any](s []E) func(func(int, E) bool) { return func(yield func(int, E) bool) { for i := len(s)-1; i >= 0; i-- { if !yield(i, s[i]) { return } } } } func main() { s := []string{"hello", "world"} for i, x := range Backward(s) { fmt.Println(i, x) } } ``` ::: ### `go vet` 工具 ::: details `go vet` 工具的变化 **对循环变量的引用** 由于上文的**函数迭代器**的变化,导致上述代码不会再有类似的风险,所以`vet`工具不再会报告这些错误。 **`append`的新警告** 现在,像`s = append(s)`这样不产生任何效果的`append`代码,会被`vet`工具报告错误。 **在`defer`中错误使用`time`的警告** 参考这样一个例子: ```go t := time.Now() defer log.Println(time.Since(t)) // 事实上,time.Since并不会在defer的时候才调用。我们实际defer的是log.Println tmp := time.Since(t); defer log.Println(tmp) // 同上 defer func() { log.Println(time.Since(t)) // 正确的time.Since写法 }() ``` 现在,`vet`工具会报告出上述错误的写法。 **对于`log/slog`的警告** `slog`库的正确用法是`slog.Info(message, key1, v1, key2, v2)`,如果在key的位置填写的既不是一个`string`,又不是一个`slog.Attr`,现在`vet`工具会报告这个错误。 ::: ### 核心库 * 新增了`math/rand/v2`包 * 新增了`go/version`包,用以比较 Go 版本号,例如:`version.Compare("go1.21rc1", "go1.21.0")` * `net/http.ServeMux`现在有了更多的支持,已经支持了传入请求方法和通配符 * 一些库的小变化 * `archive/tar`包新增了`Writer.AddFS`方法 * `archive/zip`包新增了`Writer.AddFS`方法 * `cmp`包新增了`Or`函数,用以返回一系列变量中的第一个非零值变量 * `log/slog`包新增了`SetLogLoggerLevel`函数 * `net/http`包新增了`ServeFileFS`, `FileServerFS`, `NewFileTransportFS`函数 * `reflect`包新增了`Value.IsZero`方法 * `slices`包新增了`Concat`函数,用以合并多个切片。`Delete`, `Compact`, `Replace`等函数现在会把切片末尾空出来的位置置为零值。 * 还有一些影响不大的变化,就不一一列举了 ## Go 1.21 新特性 参考 ### Go版本号的变化 以前,每个版本的第一个发行版的版本编号为"1.20"。从这个版本开始,第一个发行版的版本编号为"1.21.0"。 ### 语言的变化 Go 1.21 新增了三个内置函数`min`、`max`、`clear`。其中`clear`用于删除掉一个`map`的所有键值对或者将一个`slice`的所有元素置为零值。 调整了包初始化顺序的算法。 进行了多项改进,提高了类型推断的能力和精度。 ### 核心库 * 新增了`log/slog`包 * 新增了`testing/slogtest`包 * 新增了`slices`包 * 新增了`maps`包 * 新增了`cmp`包 * 一些库的小变化,就不一一列举了 ## Go 1.20 新特性 参考 ::: details 切片转数组 现在支持将切片转为数组了,例如`*(*[4]byte)(x)`现在可以简写为`[4]byte(x)`。 ::: :::: details `unsafe`包新增函数 `unsafe`包提供了三个新函数`SliceData`、`String`、`StringData`,这些函数现在提供了构造和解构切片和字符串值的完整功能。例如: ```go s := "abc" buf := unsafe.Slice(unsafe.StringData(s), len(s)) ``` 就可以得到字符串`s`的底层数组。 ::: note 注意 但在一般情况下,你可以放心的使用`[]byte(s)`。如果编译器检测到后续不会再用到`s`,也会直接把它的底层数组返回出来,而不是复制一份。 ::: :::: ::: details 关于`comparable`约束 ```go a := map[string]any{"a": 1, "b": 2.3, "c": "c"} b := map[string]any{"a": 1, "b": 2.3, "c": "c"} fmt.Println(maps.Equal(a, b)) // 输出 true ``` 我们来看一下`maps.Equal`函数的定义: ```go func Equal[M1, M2 ~map[K]V, K, V comparable](m1 M1, m2 M2) bool ``` 在之前的版本,由于`maps.Equal`函数接收的两个`map`要求键与值都是`comparable`,但实际上它是`map[string]any`,`any`并不一定满足`comparable`,所以编译会报错。\ 在Go1.20之后,不会再因此而编译报错。如果出现不能比较的元素,则会在运行时报错:`panic: runtime error: comparing uncomparable type []int` ::: --- --- url: /programming/go/golangci-lint.md --- golangci-lint是一个Go语言的代码静态检查工具集,官网是,它集成了多个流行的linters,可以帮助我们快速发现代码中的潜在问题。 ::: warning 注意 以下内容截止golangci-lint的`2.13.1`版本。 ::: 使用以下命令即可安装`golangci-lint`: ```bash :no-line-numbers # binary will be $(go env GOPATH)/bin/golangci-lint curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.13.1 golangci-lint --version ``` 当然你也不是不可以用`go install`命令安装(不推荐): ```bash go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 ``` 运行: ```bash golangci-lint run ./... ``` 支持的参数说明: * `--fix`表示自动修复代码中的问题(如果对应的linter支持自动修复的话) * `--new`表示只检查变更的内容(git 中 unstage 或 untracked 的内容),可以简写为`-n` * `--new-from-rev=HEAD~`一般用于CI,表示只展示最近的一个提交中包含的问题 * `--new-from-merge-base=master`只检查从当前分支和目标分支的共同祖先版本开始出现的问题 你也可以在项目中创建一个`.golangci.yml`文件来配置,运行`golangci-lint run`时,会自动扫描当前目录以及父目录中的`.golangci.yml`文件,使用其中的配置。配置支持`yml`、`yaml`、`json`、`toml`等格式。 最重要的配置是`linters`,可以在其中指定要启用或禁用的linters。以下是一个示例配置: ```yml :no-line-numbers title=".golangci.yml" version: "2" linters: default: none enable: - gosec settings: gosec: excludes: - G115 - G204 ``` 更详细的示例可以参考 ## linters 列表 ### arangolint 专门针对的 arangodb 客户端的代码检测工具。 ### asasalint 检查将`[]any`当作`any`传入变长参数函数`func(...any)`中。 ### asciicheck 检查代码标识符的名字中是否包含非 ASCII 符号。 ### bidichk 检查危险的 Unicode 字符序列。 ### bodyclose 检查 HTTP 响应体是否都成功关闭了。 ### canonicalheader 检查 `net/http.Header` 是否使用了规范的 Header。 ### containedctx 检查包含了 `context.Context` 字段的 `struct`。 ### contextcheck 检查函数是否使用了非继承的 context。 ### copyloopvar 用于检测是否拷贝了循环变量。(从Go1.22开始,将循环变量传入别的协程,不再需要拷贝循环变量,因为每次循环都会生成新的变量,不再复用旧的变量) ### cyclop 检查函数和包的循环复杂度。 ### decorder 检查类型、常量、变量、函数的声明顺序和数量。 ### depguard 用于检测所有 import 的包是否都在给定的黑名单/白名单中。 ### dogsled 用于检测是否分配了太多的空标识符(例如`x, _, _, _ := f()`)。 ### dupl 检测重复的代码段。 ### dupword 检测源代码中的重复词。 ### durationcheck 检测两个`time.Duration`的相乘运算。 ### embeddedstructfieldcheck 内嵌类型应当在`struct`的字段列表的开头,并且内嵌字段应当和普通字段之间空一行。 ### err113 检测错误处理语句 ### errcheck 检测代码中是否有未处理的`error`,未处理的`error`在某些情况下可能会引发严重的bug。 ### errchkjson 检测传入json序列化函数的参数中是否有不支持的类型,以及是否存在返回的`error`可以忽略的调用。 ### errname 检测`error`变量的命名是否以`Err`作为前缀,`error`类型的命名是否以`Error`作为后缀。 ### errorlint 检测是否有`error`的`Wrap`相关的错误(该特性是从Go1.13中引入的)。 ### exhaustive 检测`switch`语句是否穷举了所有的可能性。 ### exhaustruct 检测结构体的所有字段是否都已经初始化。 ### exptostd 检测`golang.org/x/exp/`包中的函数是否可以用标准库中的函数代替。 ### fatcontext 检测循环和函数中是否有嵌套的上下文。 ### forbidigo 指定禁止使用某些标识符。 ### forcetypeassert 检查强制类型断言。 ### funcorder 检测函数、方法、构造器的顺序。 ### funlen 检测函数是否太长。 ### ginkgolinter 执行 ginkgo 和 gomega 的使用标准。 ### gocheckcompilerdirectives 检测go编译器指令注释(`//go:`)是否合法。 ### gochecknoglobals 禁止使用全局变量。 ### gochecknoinits 禁止使用`init`函数。 ### gochecksumtype 对Go “sum types” 运行穷举性检查。 ### gocognit 计算和检查函数的认知复杂度。 ### goconst 检测重复的字符串,可以用一个常量来代替。 ### gocritic 提供检查错误、性能和样式问题的诊断。无需通过动态规则重新编译即可扩展。动态规则是用AST模式、过滤器、报告消息和可选建议以声明方式编写的。 ### gocyclo 计算和检测函数的循环复杂度。 ### godoclint 检查Go文档是否规范。 ### godot 检查是否所有的注释都以句号结尾。 ### godox 检查注释中是否有`FIXME`、`TODO`以及其它的关键词。 ### goheader 检测Go文件的头部注释是否满足要求。 ### gomoddirectives 管理`go.mod`中的`replace`、`retract`和`excludes`指令。 ### gomodguard 用于指定允许和禁止的直接Go模块依赖。这与[depguard](#depguard)不同,[depguard](#depguard)有不同的块类型,例如版本约束和模块建议。 ### goprintffuncname 检查`printf`类的函数是否以`f`结尾命名。 ### gosec 检查源代码是否存在安全问题。 ### gosmopolitan 检查代码中的多语言是否违反了 i18n/l10n。 ### govet Vet检查Go源代码并报告可疑构造。它与`go vet`大致相同。 ### grouper 分析表达式组。 ### importas 强制使用一致的 import 别名。 ### inamedparam 检查`interface`的所有方法的参数是否都命名了。 ### ineffassign 检查是否对已存在但还未使用过的变量进行赋值。 ### interfacebloat 用于检测`interface`的方法数量。 ### intrange 用于检测`for`循环是否可以用`for range 一个int值`的语法代替。(此语法是从Go1.22开始引入的) ### iotamixing 检测常量组声明中,同一个组中是否既包含了`iota`声明,又包含了非`iota`声明。 ### ireturn 函数应当接收`interface`,并返回具体的类型。 ### lll 检查太长的代码行。 ### loggercheck 检查常用日志库(kitlog、klog、logr、slog、zap)中的键值对。 ### maintidx 用于衡量函数的可维护性指标。 ### makezero 检查是否有长度非0的切片初始化。 ### mirror 检查是否存在`bytes`/`strings`使用的错误镜像模式。 ### misspell 检查常见的英文单词拼写错误。 ### mnd 检测魔法数字。 ### modernize 使用现代语言和库功能对Go代码提出简化的建议。 ### musttag `Marshal`和`Unmarshal`的结构体字段必须包含标签。 ### nakedret 检查是否存在超过指定(可以为0)行数的含有裸返回的函数。 ### nestif 检查嵌套过深的`if`语句。 ### nilerr 检查是否存在即使检查了`error`不是`nil`但是却返回了`nil`的代码。 ### nilnesserr 检查是否存在检查了`err != nil`但返回了一个不同的`nil`值`error`。 ### nilnil 检查是否存在同时返回了`nil`值的`error`和一个非法的值。 ### nlreturn 检查`return`语句和分支语句前是否有一行空行,以增加代码的清晰度。 ### noctx 检查是否使用了不含`context.Context`的函数和方法。 ### noinlineerr 禁止使用内联错误处理(`if err := ...; err != nil {`)。 ### nolintlint 检查格式不正确或不完整的`nolint`指令。 ### nonamedreturns 禁止使用命名的返回。 ### nosprintfhostport 检查在URL中错误地使用`Sprintf`进行地址和端口的拼接。 ### paralleltest 检查你的 Go test 中是否未使用 `t.Parallel()`。 ### perfsprint 检查`fmt.Sprintf`是否可以用更高效的函数代替。 ### prealloc 检查切片声明是否可以预分配内存。 ### predeclared 检查覆盖了Go预定义标识符的代码。 ### promlinter 使用 promlinter 检查 Prometheus 指标命名。 ### protogetter 检查是否存在本该使用getter方法但却直接读了proto的字段。 ### reassign 检查包变量是否被重复分配。 ### recvcheck 检查接收器类型是否一致。 ### revive 快速的、可配置的、可扩展的、灵活的、漂亮的代码检测工具。golint的替代品。 ### rowserrcheck 检查`Rows.Err`是否成功地判断了。 ### sloglint 检查使用`log/slog`的代码风格是否一致。 ### spancheck 检查 OpenTelemetry/Census spans 的错误。 ### sqlclosecheck 检查`sql.Row`、`sql.Stmt`、`sqlx.NamedStmt`、`pgx.Query`是否关闭了。 ### staticcheck 一系列staticcheck的规则。 ```yml :no-line-numbers linters: settings: staticcheck: # https://staticcheck.dev/docs/configuration/options/#dot_import_whitelist # Default: ["github.com/mmcloughlin/avo/build", "github.com/mmcloughlin/avo/operand", "github.com/mmcloughlin/avo/reg"] dot-import-whitelist: - fmt # https://staticcheck.dev/docs/configuration/options/#initialisms # Default: ["ACL", "API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP", "HTTPS", "ID", "IP", "JSON", "QPS", "RAM", "RPC", "SLA", "SMTP", "SQL", "SSH", "TCP", "TLS", "TTL", "UDP", "UI", "GID", "UID", "UUID", "URI", "URL", "UTF8", "VM", "XML", "XMPP", "XSRF", "XSS", "SIP", "RTP", "AMQP", "DB", "TS"] initialisms: ["ACL", "API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP", "HTTPS", "ID", "IP", "JSON", "QPS", "RAM", "RPC", "SLA", "SMTP", "SQL", "SSH", "TCP", "TLS", "TTL", "UDP", "UI", "GID", "UID", "UUID", "URI", "URL", "UTF8", "VM", "XML", "XMPP", "XSRF", "XSS", "SIP", "RTP", "AMQP", "DB", "TS"] # https://staticcheck.dev/docs/configuration/options/#http_status_code_whitelist # Default: ["200", "400", "404", "500"] http-status-code-whitelist: ["200", "400", "404", "500"] # SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks # Example (to disable some checks): [ "all", "-SA1000", "-SA1001"] # Run `GL_DEBUG=staticcheck golangci-lint run --enable=staticcheck` to see all available checks and enabled by config checks. # Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"] checks: # 非法的正则表达式。 # https://staticcheck.dev/docs/checks/#SA1000 - SA1000 # 非法的 template。 # https://staticcheck.dev/docs/checks/#SA1001 - SA1001 # 'time.Parse' 中非法的格式。 # https://staticcheck.dev/docs/checks/#SA1002 - SA1002 # 向 'encoding/binary' 中的函数传入不支持的参数. # https://staticcheck.dev/docs/checks/#SA1003 - SA1003 # Suspiciously small untyped constant in 'time.Sleep'. # https://staticcheck.dev/docs/checks/#SA1004 - SA1004 # Invalid first argument to 'exec.Command'. # https://staticcheck.dev/docs/checks/#SA1005 - SA1005 # 'Printf' with dynamic first argument and no further arguments. # https://staticcheck.dev/docs/checks/#SA1006 - SA1006 # Invalid URL in 'net/url.Parse'. # https://staticcheck.dev/docs/checks/#SA1007 - SA1007 # Non-canonical key in 'http.Header' map. # https://staticcheck.dev/docs/checks/#SA1008 - SA1008 # '(*regexp.Regexp).FindAll' called with 'n == 0', which will always return zero results. # https://staticcheck.dev/docs/checks/#SA1010 - SA1010 # Various methods in the "strings" package expect valid UTF-8, but invalid input is provided. # https://staticcheck.dev/docs/checks/#SA1011 - SA1011 # A nil 'context.Context' is being passed to a function, consider using 'context.TODO' instead. # https://staticcheck.dev/docs/checks/#SA1012 - SA1012 # 'io.Seeker.Seek' is being called with the whence constant as the first argument, but it should be the second. # https://staticcheck.dev/docs/checks/#SA1013 - SA1013 # Non-pointer value passed to 'Unmarshal' or 'Decode'. # https://staticcheck.dev/docs/checks/#SA1014 - SA1014 # Using 'time.Tick' in a way that will leak. Consider using 'time.NewTicker', and only use 'time.Tick' in tests, commands and endless functions. # https://staticcheck.dev/docs/checks/#SA1015 - SA1015 # Trapping a signal that cannot be trapped. # https://staticcheck.dev/docs/checks/#SA1016 - SA1016 # Channels used with 'os/signal.Notify' should be buffered. # https://staticcheck.dev/docs/checks/#SA1017 - SA1017 # 'strings.Replace' called with 'n == 0', which does nothing. # https://staticcheck.dev/docs/checks/#SA1018 - SA1018 # Using a deprecated function, variable, constant or field. # https://staticcheck.dev/docs/checks/#SA1019 - SA1019 # Using an invalid host:port pair with a 'net.Listen'-related function. # https://staticcheck.dev/docs/checks/#SA1020 - SA1020 # Using 'bytes.Equal' to compare two 'net.IP'. # https://staticcheck.dev/docs/checks/#SA1021 - SA1021 # Modifying the buffer in an 'io.Writer' implementation. # https://staticcheck.dev/docs/checks/#SA1023 - SA1023 # A string cutset contains duplicate characters. # https://staticcheck.dev/docs/checks/#SA1024 - SA1024 # It is not possible to use '(*time.Timer).Reset''s return value correctly. # https://staticcheck.dev/docs/checks/#SA1025 - SA1025 # Cannot marshal channels or functions. # https://staticcheck.dev/docs/checks/#SA1026 - SA1026 # Atomic access to 64-bit variable must be 64-bit aligned. # https://staticcheck.dev/docs/checks/#SA1027 - SA1027 # 'sort.Slice' can only be used on slices. # https://staticcheck.dev/docs/checks/#SA1028 - SA1028 # Inappropriate key in call to 'context.WithValue'. # https://staticcheck.dev/docs/checks/#SA1029 - SA1029 # Invalid argument in call to a 'strconv' function. # https://staticcheck.dev/docs/checks/#SA1030 - SA1030 # Overlapping byte slices passed to an encoder. # https://staticcheck.dev/docs/checks/#SA1031 - SA1031 # Wrong order of arguments to 'errors.Is'. # https://staticcheck.dev/docs/checks/#SA1032 - SA1032 # 'sync.WaitGroup.Add' called inside the goroutine, leading to a race condition. # https://staticcheck.dev/docs/checks/#SA2000 - SA2000 # Empty critical section, did you mean to defer the unlock?. # https://staticcheck.dev/docs/checks/#SA2001 - SA2001 # Called 'testing.T.FailNow' or 'SkipNow' in a goroutine, which isn't allowed. # https://staticcheck.dev/docs/checks/#SA2002 - SA2002 # Deferred 'Lock' right after locking, likely meant to defer 'Unlock' instead. # https://staticcheck.dev/docs/checks/#SA2003 - SA2003 # 'TestMain' doesn't call 'os.Exit', hiding test failures. # https://staticcheck.dev/docs/checks/#SA3000 - SA3000 # Assigning to 'b.N' in benchmarks distorts the results. # https://staticcheck.dev/docs/checks/#SA3001 - SA3001 # Binary operator has identical expressions on both sides. # https://staticcheck.dev/docs/checks/#SA4000 - SA4000 # '&*x' gets simplified to 'x', it does not copy 'x'. # https://staticcheck.dev/docs/checks/#SA4001 - SA4001 # Comparing unsigned values against negative values is pointless. # https://staticcheck.dev/docs/checks/#SA4003 - SA4003 # The loop exits unconditionally after one iteration. # https://staticcheck.dev/docs/checks/#SA4004 - SA4004 # Field assignment that will never be observed. Did you mean to use a pointer receiver?. # https://staticcheck.dev/docs/checks/#SA4005 - SA4005 # A value assigned to a variable is never read before being overwritten. Forgotten error check or dead code?. # https://staticcheck.dev/docs/checks/#SA4006 - SA4006 # The variable in the loop condition never changes, are you incrementing the wrong variable?. # https://staticcheck.dev/docs/checks/#SA4008 - SA4008 # A function argument is overwritten before its first use. # https://staticcheck.dev/docs/checks/#SA4009 - SA4009 # The result of 'append' will never be observed anywhere. # https://staticcheck.dev/docs/checks/#SA4010 - SA4010 # Break statement with no effect. Did you mean to break out of an outer loop?. # https://staticcheck.dev/docs/checks/#SA4011 - SA4011 # Comparing a value against NaN even though no value is equal to NaN. # https://staticcheck.dev/docs/checks/#SA4012 - SA4012 # Negating a boolean twice ('!!b') is the same as writing 'b'. This is either redundant, or a typo. # https://staticcheck.dev/docs/checks/#SA4013 - SA4013 # An if/else if chain has repeated conditions and no side-effects; if the condition didn't match the first time, it won't match the second time, either. # https://staticcheck.dev/docs/checks/#SA4014 - SA4014 # Calling functions like 'math.Ceil' on floats converted from integers doesn't do anything useful. # https://staticcheck.dev/docs/checks/#SA4015 - SA4015 # Certain bitwise operations, such as 'x ^ 0', do not do anything useful. # https://staticcheck.dev/docs/checks/#SA4016 - SA4016 # Discarding the return values of a function without side effects, making the call pointless. # https://staticcheck.dev/docs/checks/#SA4017 - SA4017 # Self-assignment of variables. # https://staticcheck.dev/docs/checks/#SA4018 - SA4018 # Multiple, identical build constraints in the same file. # https://staticcheck.dev/docs/checks/#SA4019 - SA4019 # Unreachable case clause in a type switch. # https://staticcheck.dev/docs/checks/#SA4020 - SA4020 # "x = append(y)" is equivalent to "x = y". # https://staticcheck.dev/docs/checks/#SA4021 - SA4021 # Comparing the address of a variable against nil. # https://staticcheck.dev/docs/checks/#SA4022 - SA4022 # Impossible comparison of interface value with untyped nil. # https://staticcheck.dev/docs/checks/#SA4023 - SA4023 # Checking for impossible return value from a builtin function. # https://staticcheck.dev/docs/checks/#SA4024 - SA4024 # Integer division of literals that results in zero. # https://staticcheck.dev/docs/checks/#SA4025 - SA4025 # Go constants cannot express negative zero. # https://staticcheck.dev/docs/checks/#SA4026 - SA4026 # '(*net/url.URL).Query' returns a copy, modifying it doesn't change the URL. # https://staticcheck.dev/docs/checks/#SA4027 - SA4027 # 'x % 1' is always zero. # https://staticcheck.dev/docs/checks/#SA4028 - SA4028 # Ineffective attempt at sorting slice. # https://staticcheck.dev/docs/checks/#SA4029 - SA4029 # Ineffective attempt at generating random number. # https://staticcheck.dev/docs/checks/#SA4030 - SA4030 # Checking never-nil value against nil. # https://staticcheck.dev/docs/checks/#SA4031 - SA4031 # Comparing 'runtime.GOOS' or 'runtime.GOARCH' against impossible value. # https://staticcheck.dev/docs/checks/#SA4032 - SA4032 # Assignment to nil map. # https://staticcheck.dev/docs/checks/#SA5000 - SA5000 # Deferring 'Close' before checking for a possible error. # https://staticcheck.dev/docs/checks/#SA5001 - SA5001 # The empty for loop ("for {}") spins and can block the scheduler. # https://staticcheck.dev/docs/checks/#SA5002 - SA5002 # Defers in infinite loops will never execute. # https://staticcheck.dev/docs/checks/#SA5003 - SA5003 # "for { select { ..." with an empty default branch spins. # https://staticcheck.dev/docs/checks/#SA5004 - SA5004 # The finalizer references the finalized object, preventing garbage collection. # https://staticcheck.dev/docs/checks/#SA5005 - SA5005 # Infinite recursive call. # https://staticcheck.dev/docs/checks/#SA5007 - SA5007 # Invalid struct tag. # https://staticcheck.dev/docs/checks/#SA5008 - SA5008 # Invalid Printf call. # https://staticcheck.dev/docs/checks/#SA5009 - SA5009 # Impossible type assertion. # https://staticcheck.dev/docs/checks/#SA5010 - SA5010 # Possible nil pointer dereference. # https://staticcheck.dev/docs/checks/#SA5011 - SA5011 # Passing odd-sized slice to function expecting even size. # https://staticcheck.dev/docs/checks/#SA5012 - SA5012 # Using 'regexp.Match' or related in a loop, should use 'regexp.Compile'. # https://staticcheck.dev/docs/checks/#SA6000 - SA6000 # Missing an optimization opportunity when indexing maps by byte slices. # https://staticcheck.dev/docs/checks/#SA6001 - SA6001 # Storing non-pointer values in 'sync.Pool' allocates memory. # https://staticcheck.dev/docs/checks/#SA6002 - SA6002 # Converting a string to a slice of runes before ranging over it. # https://staticcheck.dev/docs/checks/#SA6003 - SA6003 # Inefficient string comparison with 'strings.ToLower' or 'strings.ToUpper'. # https://staticcheck.dev/docs/checks/#SA6005 - SA6005 # Using io.WriteString to write '[]byte'. # https://staticcheck.dev/docs/checks/#SA6006 - SA6006 # Defers in range loops may not run when you expect them to. # https://staticcheck.dev/docs/checks/#SA9001 - SA9001 # Using a non-octal 'os.FileMode' that looks like it was meant to be in octal. # https://staticcheck.dev/docs/checks/#SA9002 - SA9002 # Empty body in an if or else branch. # https://staticcheck.dev/docs/checks/#SA9003 - SA9003 # Only the first constant has an explicit type. # https://staticcheck.dev/docs/checks/#SA9004 - SA9004 # Trying to marshal a struct with no public fields nor custom marshaling. # https://staticcheck.dev/docs/checks/#SA9005 - SA9005 # Dubious bit shifting of a fixed size integer value. # https://staticcheck.dev/docs/checks/#SA9006 - SA9006 # Deleting a directory that shouldn't be deleted. # https://staticcheck.dev/docs/checks/#SA9007 - SA9007 # 'else' branch of a type assertion is probably not reading the right value. # https://staticcheck.dev/docs/checks/#SA9008 - SA9008 # Ineffectual Go compiler directive. # https://staticcheck.dev/docs/checks/#SA9009 - SA9009 # Incorrect or missing package comment. # https://staticcheck.dev/docs/checks/#ST1000 - ST1000 # Dot imports are discouraged. # https://staticcheck.dev/docs/checks/#ST1001 - ST1001 # Poorly chosen identifier. # https://staticcheck.dev/docs/checks/#ST1003 - ST1003 # Incorrectly formatted error string. # https://staticcheck.dev/docs/checks/#ST1005 - ST1005 # Poorly chosen receiver name. # https://staticcheck.dev/docs/checks/#ST1006 - ST1006 # A function's error value should be its last return value. # https://staticcheck.dev/docs/checks/#ST1008 - ST1008 # Poorly chosen name for variable of type 'time.Duration'. # https://staticcheck.dev/docs/checks/#ST1011 - ST1011 # Poorly chosen name for error variable. # https://staticcheck.dev/docs/checks/#ST1012 - ST1012 # Should use constants for HTTP error codes, not magic numbers. # https://staticcheck.dev/docs/checks/#ST1013 - ST1013 # A switch's default case should be the first or last case. # https://staticcheck.dev/docs/checks/#ST1015 - ST1015 # Use consistent method receiver names. # https://staticcheck.dev/docs/checks/#ST1016 - ST1016 # Don't use Yoda conditions. # https://staticcheck.dev/docs/checks/#ST1017 - ST1017 # Avoid zero-width and control characters in string literals. # https://staticcheck.dev/docs/checks/#ST1018 - ST1018 # Importing the same package multiple times. # https://staticcheck.dev/docs/checks/#ST1019 - ST1019 # The documentation of an exported function should start with the function's name. # https://staticcheck.dev/docs/checks/#ST1020 - ST1020 # The documentation of an exported type should start with type's name. # https://staticcheck.dev/docs/checks/#ST1021 - ST1021 # The documentation of an exported variable or constant should start with variable's name. # https://staticcheck.dev/docs/checks/#ST1022 - ST1022 # Redundant type in variable declaration. # https://staticcheck.dev/docs/checks/#ST1023 - ST1023 # Use plain channel send or receive instead of single-case select. # https://staticcheck.dev/docs/checks/#S1000 - S1000 # Replace for loop with call to copy. # https://staticcheck.dev/docs/checks/#S1001 - S1001 # Omit comparison with boolean constant. # https://staticcheck.dev/docs/checks/#S1002 - S1002 # Replace call to 'strings.Index' with 'strings.Contains'. # https://staticcheck.dev/docs/checks/#S1003 - S1003 # Replace call to 'bytes.Compare' with 'bytes.Equal'. # https://staticcheck.dev/docs/checks/#S1004 - S1004 # Drop unnecessary use of the blank identifier. # https://staticcheck.dev/docs/checks/#S1005 - S1005 # Use "for { ... }" for infinite loops. # https://staticcheck.dev/docs/checks/#S1006 - S1006 # Simplify regular expression by using raw string literal. # https://staticcheck.dev/docs/checks/#S1007 - S1007 # Simplify returning boolean expression. # https://staticcheck.dev/docs/checks/#S1008 - S1008 # Omit redundant nil check on slices, maps, and channels. # https://staticcheck.dev/docs/checks/#S1009 - S1009 # Omit default slice index. # https://staticcheck.dev/docs/checks/#S1010 - S1010 # Use a single 'append' to concatenate two slices. # https://staticcheck.dev/docs/checks/#S1011 - S1011 # Replace 'time.Now().Sub(x)' with 'time.Since(x)'. # https://staticcheck.dev/docs/checks/#S1012 - S1012 # Use a type conversion instead of manually copying struct fields. # https://staticcheck.dev/docs/checks/#S1016 - S1016 # Replace manual trimming with 'strings.TrimPrefix'. # https://staticcheck.dev/docs/checks/#S1017 - S1017 # Use "copy" for sliding elements. # https://staticcheck.dev/docs/checks/#S1018 - S1018 # Simplify "make" call by omitting redundant arguments. # https://staticcheck.dev/docs/checks/#S1019 - S1019 # Omit redundant nil check in type assertion. # https://staticcheck.dev/docs/checks/#S1020 - S1020 # Merge variable declaration and assignment. # https://staticcheck.dev/docs/checks/#S1021 - S1021 # Omit redundant control flow. # https://staticcheck.dev/docs/checks/#S1023 - S1023 # Replace 'x.Sub(time.Now())' with 'time.Until(x)'. # https://staticcheck.dev/docs/checks/#S1024 - S1024 # Don't use 'fmt.Sprintf("%s", x)' unnecessarily. # https://staticcheck.dev/docs/checks/#S1025 - S1025 # Simplify error construction with 'fmt.Errorf'. # https://staticcheck.dev/docs/checks/#S1028 - S1028 # Range over the string directly. # https://staticcheck.dev/docs/checks/#S1029 - S1029 # Use 'bytes.Buffer.String' or 'bytes.Buffer.Bytes'. # https://staticcheck.dev/docs/checks/#S1030 - S1030 # Omit redundant nil check around loop. # https://staticcheck.dev/docs/checks/#S1031 - S1031 # Use 'sort.Ints(x)', 'sort.Float64s(x)', and 'sort.Strings(x)'. # https://staticcheck.dev/docs/checks/#S1032 - S1032 # Unnecessary guard around call to "delete". # https://staticcheck.dev/docs/checks/#S1033 - S1033 # Use result of type assertion to simplify cases. # https://staticcheck.dev/docs/checks/#S1034 - S1034 # Redundant call to 'net/http.CanonicalHeaderKey' in method call on 'net/http.Header'. # https://staticcheck.dev/docs/checks/#S1035 - S1035 # Unnecessary guard around map access. # https://staticcheck.dev/docs/checks/#S1036 - S1036 # Elaborate way of sleeping. # https://staticcheck.dev/docs/checks/#S1037 - S1037 # Unnecessarily complex way of printing formatted string. # https://staticcheck.dev/docs/checks/#S1038 - S1038 # Unnecessary use of 'fmt.Sprint'. # https://staticcheck.dev/docs/checks/#S1039 - S1039 # Type assertion to current type. # https://staticcheck.dev/docs/checks/#S1040 - S1040 # Apply De Morgan's law. # https://staticcheck.dev/docs/checks/#QF1001 - QF1001 # Convert untagged switch to tagged switch. # https://staticcheck.dev/docs/checks/#QF1002 - QF1002 # Convert if/else-if chain to tagged switch. # https://staticcheck.dev/docs/checks/#QF1003 - QF1003 # Use 'strings.ReplaceAll' instead of 'strings.Replace' with 'n == -1'. # https://staticcheck.dev/docs/checks/#QF1004 - QF1004 # Expand call to 'math.Pow'. # https://staticcheck.dev/docs/checks/#QF1005 - QF1005 # Lift 'if'+'break' into loop condition. # https://staticcheck.dev/docs/checks/#QF1006 - QF1006 # Merge conditional assignment into variable declaration. # https://staticcheck.dev/docs/checks/#QF1007 - QF1007 # Omit embedded fields from selector expression. # https://staticcheck.dev/docs/checks/#QF1008 - QF1008 # Use 'time.Time.Equal' instead of '==' operator. # https://staticcheck.dev/docs/checks/#QF1009 - QF1009 # Convert slice of bytes to string when printing it. # https://staticcheck.dev/docs/checks/#QF1010 - QF1010 # Omit redundant type from variable declaration. # https://staticcheck.dev/docs/checks/#QF1011 - QF1011 # Use 'fmt.Fprintf(x, ...)' instead of 'x.Write(fmt.Sprintf(...))'. # https://staticcheck.dev/docs/checks/#QF1012 - QF1012 ``` ### tagalign 检查结构体的标签是否对齐了。 ### tagliatelle 检查结构体的标签。 ### testableexamples 检查例子是否可以测试(有预期的输出)。 ### testifylint 检查`github.com/stretchr/testify`的用法。 ### testpackage 应当使用一个单独的`_test`包。 ### thelper 检查 tests helpers 是否没有以`t.Helper()`方法开头。 ### tparallel 检查你的Go测试代码中`t.Parallel()`方法的错误用法。 ### unconvert 移除不需要的类型转换。 ### unparam 检查未使用的函数参数。 ### unqueryvet 检查 SQL 语句或者 SQL builders 中的 `SELECT *`,避免一些性能问题,鼓励明确的列选择。 ### unused 检测Go代码中未使用的常量、变量、函数和类型。 ### usestdlibvars 检测是否有可以替换为标准库中的变量/常量的代码。 ### usetesting 检测使用的函数是否可以被`testing`包中的函数代替。 ### varnamelen 检查变量名称的长度是否与其作用域匹配。 ### wastedassign 检测没用的赋值语句。 ### whitespace 检查函数、`if`语句、`for`语句开头或结尾无意义的空行。 ### wrapcheck 检查来自外部包的`error`是否被封装。 ### wsl 已废弃,请使用[wsl_v5](#wsl_v5)。 ### wsl_v5 增加或删除空行。 ### zerologlint 检查`zerolog`的错误用法,例如用户忘记使用`Send`或`Msg`分配。 --- --- url: /programming/go/pprof.md --- # pprof工具的使用笔记 pprof是Go语言的性能分析工具,可以帮助我们分析程序的性能瓶颈。pprof可以生成CPU、内存、阻塞等多种性能分析报告。 通常,我们直接加入如下代码即可开启pprof功能: ```go :no-collapsed-lines {4,11-16} title="main.go" package main import ( _ "runtime/pprof" "log/slog" ) func main() { // ... 你的代码 ... go func() { err := http.ListenAndServe(":6060", nil) // 假设监听端口是6060 if err != nil { slog.Error("启动pprof失败", "error", err) } } // ... 你的代码 ... } ``` 然后我们可以通过访问`http://localhost:6060/debug/pprof`来查看pprof的相关信息。 在页面中我们可以看到pprof支持的功能,例如`allocs`表示对过去的内存分配的采样,对应的地址就是`http://localhost:6060/debug/pprof/allocs`。 需要注意的是,`profile`和`trace`功能需要在地址之后加上`?seconds=30`参数,指定采样的秒数。 *** 那么如何将这些数据可视化呢?我们可以使用`go tool pprof`命令来实现。格式如下: ```bash go tool pprof -http=0.0.0.0:8848 http://localhost:6060/debug/pprof/profile?seconds=30 ``` 这个就代表获取CPU的性能30秒分析数据,并在8848端口上进行可视化。然后访问`http://localhost:8848`即可查看可视化的结果。`go tool pprof`命令后面的地址可以换成上述任意你想要的功能对应的地址。 如果不加`-http=0.0.0.0:8848`参数,就会在终端中进入pprof的交互模式,你可以在其中输入命令来查看性能数据。 ::: caution 易错点 有一个可能踩坑的地方,执行上述命令时有可能会报错:`zsh: no matches found`。如果你是在zsh终端中执行这个命令,因为命令中url后面恰好含有`?`符号,zsh会将其当作通配符处理,导致出错。将最后的url加个引号就可以解决。 ::: ::: note 注意 可视化需要一些图形库,例如`graphviz`,如果没有安装,访问`http://localhost:8848`时会报错: ```console Failed to execute dot. Is Graphviz installed? exec: "dot": executable file not found in $PATH ``` 直接使用例如`yum`、`apt`等工具安装即可。 如果只是查看火焰图,则不需要安装`graphviz`,忽略上面的报错,直接访问`http://localhost:8848/ui/flamegraph`即可。 ::: 在执行上述命令后,会有提示: ```console // [!code word:path/to/file] Fetching profile over HTTP from http://localhost:6060/debug/pprof/profile?seconds=30 Saved profile in path/to/file ``` 注意,这个`path/to/file`是保存的文件路径。下次你可以直接使用这个文件,查看当时的性能数据,不再需要重新拉取了: ```bash go tool pprof -http=0.0.0.0:8848 path/to/file ``` --- --- url: /programming/go/protoactor.md --- # protoactor-go库的已知问题 [proto.actor](https://proto.actor/)是一个跨语言的分布式Actor框架,支持多种编程语言,包括Go,也就是[protoactor-go](https://github.com/asynkron/protoactor-go)。虽然它提供了强大的功能,但在使用过程中也存在一些已知问题,这里列出来供参考。 具体用法请参考官方文档,这里只简要介绍一下和本文有关的内容。 我们知道,Actor模型的通用做法就是每个Actor都有一个消息队列,发送消息时会将消息放入队列中,Actor会轮询从队列中取出消息进行处理。protoactor-go的实现是有两个队列,一个是`UserMessage`队列,一个是`SystemMessage`队列。每次轮询的时候总会先检查`SystemMessage`队列,如果为空再去检查`UserMessage`队列。换句话说就是,在这个框架下,如果新来了`SystemMessage`是可以“插队”的。 值得一提的是,**轮询`UserMessage`队列的这个方法叫做`InvokeUserMessage`,它接收一个参数,就是待处理的消息,在`run`方法轮询得到了队首的消息后,传入`InvokeUserMessage`方法即可**。这个很重要,下面会提到。我简要摘取一些源码说明这个问题: ```go {23} func (ctx *actorContext) InvokeUserMessage(md interface{}) { if atomic.LoadInt32(&ctx.state) == stateStopped { // already stopped return } // ... 一些其它逻辑 ... ctx.processMessage(md) } func (ctx *actorContext) processMessage(m interface{}) { // ... 一些其它逻辑 ... ctx.defaultReceive() } func (ctx *actorContext) defaultReceive() { switch msg := ctx.Message().(type) { case *PoisonPill: ctx.Stop(ctx.self) case AutoRespond: // ... 一些其它逻辑 ... default: // ... 一些其它逻辑 ... ctx.actor.Receive(ctx) // 这个就是用户自己实现的 Actor 的 Receive 方法 } } ``` 源码中可以看出,`InvokeUserMessage`方法会检查一下状态是否为`stateStopped`,然后通过`switch`语句判断一下类型,两个内置类型`*PoisonPill`和`AutoRespond`会有特殊处理,其他的消息类型就会调用用户自己实现的 `Receive` 方法。 当我们停止Actor的时候会发生什么呢? 我们比较常用的停止Actor的方法就是`Poison`,也就是通知Actor停止。这个`Poison`其实就是往队列里发一个消息,它是这个框架预定义的一个消息类型,叫做`PoisonPill`,属于`UserMessage`。当处理到`PoisonPill`消息时,会调用`Stop`方法,这个方法会发一条`Stop`消息,这个`Stop`消息属于`SystemMessage`,所以它会被“插队”到前面去处理。 然后接下来就有个问题,按照正常的逻辑,`Stop`执行的时候应该就已经停止了,不能再继续处理`UserMessage`队列剩余的消息了。protoactor-go的实现是,**不走`run`轮询了,直接调用上面所说的`InvokeUserMessage`,传入一个`*Stopping`消息供用户处理**。我们来看一下源码: ```go {20-21} // 这个 InvokeSystemMessage 方式用来处理 SystemMessage 队列里的消息 // 同样接收一个参数,就是 run 方法轮询到的队首消息 func (ctx *actorContext) InvokeSystemMessage(message interface{}) { switch msg := message.(type) { // ... 一些其它逻辑 ... case *Stop: ctx.handleStop() // ... 一些其它逻辑 ... } } func (ctx *actorContext) handleStop() { if atomic.LoadInt32(&ctx.state) >= stateStopping { // already stopping or stopped return } atomic.StoreInt32(&ctx.state, stateStopping) // 这里直接调用 InvokeUserMessage,交给用户实现的 Receive 方法去处理 Stopping 消息 ctx.InvokeUserMessage(stoppingMessage) ctx.stopAllChildren() ctx.tryRestartOrTerminate() } ``` 然后我们还要看一下这个`tryRestartOrTerminate`方法: ```go {10-12,18-19} func (ctx *actorContext) tryRestartOrTerminate() { if ctx.extras != nil && !ctx.extras.children.Empty() { return } switch atomic.LoadInt32(&ctx.state) { case stateRestarting: ctx.CancelReceiveTimeout() ctx.restart() case stateStopping: // 上面 handleStop 的方法里把状态改成了 stateStopping,所以肯定会走这个分支 ctx.CancelReceiveTimeout() ctx.finalizeStop() } } func (ctx *actorContext) finalizeStop() { ctx.actorSystem.ProcessRegistry.Remove(ctx.self) // 这里直接调用 InvokeUserMessage,交给用户实现的 Receive 方法去处理 Stopped 消息 ctx.InvokeUserMessage(stoppedMessage) // ... 一些其它逻辑 ... atomic.StoreInt32(&ctx.state, stateStopped) } ``` 可以看到,它再次**不走`run`轮询,直接调用`InvokeUserMessage`,传入一个`*Stopped`消息供用户处理**,之后又将状态改成`stateStopped`。我们再回顾一下上面的第一个代码段开头,`InvokeUserMessage`方法里有个检查状态的逻辑,如果状态是`stateStopped`,就直接返回了,不会继续处理了。 总结一下就是: 1. 当我们`Poison`了这个Actor时,会往`UserMessage`队列里发一个`PoisonPill`消息。此时Actor还未真正停止,还在持续接收新的消息。 2. 轮询处理到`PoisonPill`消息的时候,会修改Actor状态,并依次把`*Stopping`和`*Stopped`消息直接调用用户实现的 `Receive` 方法去处理。 3. 因为处理完上一步后,Actor状态已经标记为`stateStopped`,因此接下来不会再继续处理`UserMessage`队列里剩余的消息了。 听起来似乎很正确,但这会引发另一个问题。对于同步请求,我们会调用`RequestFuture`方法,它依赖于我们在`Receive`方法里调用`Respond`方法来回复消息,如下: ```go :line-numbers {5} func (myActor *MyActor) Receive(ctx actor.Context) { switch m := ctx.Message().(type) { case *SomeRequstMessage: result := doSth(m) // 处理请求得到结果 ctx.Respond(result) // 这里调用 Respond 方法回复消息 } } ``` 如果没有调用`Respond`方法,那么这个请求就会一直等待下去,直到超时了才会返回错误。根据我们上述分析,如果中途`Poison`了这个Actor,比`PoisonPill`消息晚进入队列的消息,此时还未标记为`stateStopped`,所以判断不是`deadletter`。但根据上面的分析,这些消息实际上不会被处理,也就是永远不会调用`Respond`方法,所以这些消息就会一直等待下去,直到超时了才会返回错误。 *** 那么这个问题有办法解决吗?有一个方案,就是使用`Send`方法发送异步消息,利用Go语言的`chan`来自己实现等待操作,不用`RequestFuture`方法了,例如: ```go {6,8,12,20,23} func RequestSync(myActor *actor.PID, msg any) (any, error) { m := &SomeRequstMessage{ Msg: msg, Done: make(chan any, 1), } actorSystem.Root.Send(myActor.pid, m) // 使用 Send 方法发送异步消息 select { case ret := <-m.Done: // 对应23行 m.Done <- result return ret, nil case <-time.After(5 * time.Second): return nil, actor.ErrTimeout case <-myActor.stopping: // 对应20行 close(myActor.stopping) return nil, actor.ErrDeadLetter } } func (myActor *MyActor) Receive(ctx actor.Context) { switch m := ctx.Message().(type) { case *actor.Stopping: close(myActor.stopping) case *SomeRequstMessage: result := doSth(m) m.Done <- result } } ``` 我们把`m.Done`一并传进去,在`Receive`处理完后,把结果从`m.Done`这个`chan`里发回调用方即可。对于`Poison`了这个Actor的情况,直接在`Receive`处理`*Stopping`消息时,直接`close(myActor.stopping)`关掉`chan`,这样调用方就能知道这个Actor已经停止了,不会再继续等待了。这就完美利用了`select`语句的特性。此外,由于往`m.Done`中写内容和`close(myActor.stopping)`都是在`Receive`方法中,根据我们上文的分析可知不会并行,因此这个`select`语句触发哪个分支也是可以预期的,不会有时序性问题。 --- --- url: /programming/gobang/gobang1.md --- # 五子棋AI算法(1) ::: warning 注意 此AI并非“人工智能”,而是我们早期意义上的传统AI,并无学习能力,纯粹用算法实现五子棋每一步棋的应对策略。 ::: 之前说想写一些比较大型的算法,想了半天,还是觉得写五子棋的AI比较合适。一则因为自己研究过这个,有一些基础,二则尽管现在网上有很多五子棋AI算法的资料,但是确实都有些晦涩难懂。就想着借这个机会,凭自己的理解,尽量的讲通俗一些。 然而,这个算法确实有些复杂,想要通俗一些需要较大的篇幅,一篇文章难以讲完,这里就分很多个章节,一点一点的深入探讨。为了让文章更加通俗一些,我会略去一部分很简单但是占用篇幅很长的代码,改为用几行注释说明。 ::: note 注意 本文是以Java语言进行讲解。最近抽空整理了一下代码,已经分享在: ,需要的可以自取。 ::: ## 框架的搭建 首先,我们计划是做一个五子棋AI,也就是说让玩家和这个AI对下。整个游戏的框架是这样的: ```mermaid classDiagram direction LR class 棋盘 { byte[][] board 玩家 玩家1 玩家 玩家2 void play() bool checkForWin() } 棋盘 *-- 玩家 class 玩家 { <> Point play() void display(Point p) void notifyWinner(int color) } 玩家 <|-- HumanPlayer 玩家 <|-- RobotPlayer ``` 其中,棋盘是一个`Object`,存放当前的棋局情况,通知每个`Player`“轮到你下棋了”、“对方下了什么棋”、“游戏结束,XXX获胜”等消息,并且从每个`Player`那里获取他下了什么棋。两个`Player`分别是人类玩家和AI。`Player`的基类应该是一个`interface`,里面只有三个方法。人类玩家和AI是它的子类,分别实现这三个方法。 ```java title="Player.java" :line-numbers public interface Player { Point play(); void display(Point p); void notifyWinner(int color); } ``` ```java title="Point.java" public final class Point { public final int x; public final int y; public Point(int x, int y) { this.x = x; this.y = y; } @Override public String toString() {return "(" + x + "," + y + ")";} } ``` 解释一下: * `play`方法,告知“轮到你下棋了”,并且返回一个`Point`,也就是下一步下的棋。对于人类玩家,则就是阻塞,等待玩家在界面上选取一个点,并且将这个点的坐标返回。对于AI,则是直接开始用我们的AI算法进行计算,并返回计算结果。 * `void display(Point p)`方法,告知“对方下了什么棋”。对于人类玩家,则就是将对方下了的棋在界面上显示出来。对于AI,则是将对方下了的棋记在AI的缓存中,以便后续的计算。 * `void notifyWinner(int color)`方法,告知“游戏结束,XXX玩家赢了”。对于人类玩家,则就是在界面上展示谁赢了的文字及特效,并且从此之后再点击棋盘就不再有反应了。对于AI,则是通知AI不要再计算了。 当然了,如果打算连续下多盘棋,可能还需要一个`reset`方法,通知人类玩家和AI清空当前棋盘。当然了,这个和我们的算法关系不大就不列出来了。 然后,我们的`interface Player`需要两个实现类,分别叫做`HumanPlayer`和`RobotPlayer`,这个`RobotPlayer`的`play`方法将是五子棋AI算法的核心内容,后面会花费大量篇幅进行讲解。 接下来就是我们的棋盘: ```java title="Constant.java" :line-numbers public abstract class Constant { public static int MAX_LEN = 15; } ``` ```java title="ChessBoard.java" public class ChessBoard { private byte[][] board = new byte[Constant.MAX_LEN][Constant.MAX_LEN]; private Player[] players = new Player[2]; private int whoseTurn = 0; private int count = 0; private boolean isEnd = false; private boolean checkForWin(Point p) { /* 因为篇幅问题,此处省略十几行代码 */ /* 这个函数就是在下完每一步棋时调用,只需要判断以这步棋若形成五连珠即可判定获胜 */ return false; } public void play() { if (isEnd) return; Point p = players[whoseTurn].play(); //调用Player的play方法,获取下一步下的棋 if (board[p.y][p.x] != 0) //严谨,以防万一 throw new IllegalArgumentException(p.toString() + board[p.y][p.x]); board[p.y][p.x] = (byte) (whoseTurn + 1); System.out.println((whoseTurn == 0 ? "黑" : "白") + p.toString()); //打印日志 if (++count == Constant.MAX_LEN * Constant.MAX_LEN) //严谨,如果棋盘下满了游戏结束 isEnd = true; if (checkForWin(p)) //如果下了这步棋后赢了,游戏结束 isEnd = true; whoseTurn = 1 - whoseTurn; //切换当前下棋的人 players[whoseTurn].display(p); //调用Player的display方法,告知他对方下了哪步棋 if (isEnd) { //如果下完这一步棋后有一方赢了,则调用Player的notifyWinner方法通知 players[0].notifyWinner(2 - whoseTurn); players[1].notifyWinner(2 - whoseTurn); } } public static void main(String[] args) { ChessBoard b = new ChessBoard(); b.players[0] = new HumanPlayer(1); //这里我从构造函数中传入了颜色,例如1表示执黑,2表示执白 b.players[1] = new RobotPlayer(2); while (b.getWinner() == null) { b.play(); } } } ``` 棋盘的代码确实很简单易懂,也做了很多注释,就不多介绍了。 接下来,就只剩下`HumanPlayer`和`RobotPlayer`的实现了。 ## 人类玩家 人类玩家无非就是实现三个方法: * `play`方法,阻塞等待玩家点击棋盘上的一个点并返回这个点 * `display`方法,将AI下的棋展示在界面上 * `notifyWinner`方法,显示一行字“你赢(输)了” 这段代码与本文无关,就不贴出来了,我把我做的这个丑陋的界面贴出来展示一下,哈哈。 ![丑陋的界面](/programming/gobang.png) (不要吐槽我的界面,这不是重点。) ## RobotPlayer AI才是重点内容,涉及了大量的算法和数学知识,博弈树、评估函数、极大极小值搜索、启发式搜索、α-β剪枝等等,将会占用大量的篇幅。从下一章开始,将对此逐一展开。 --- --- url: /programming/gobang/gobang2.md --- # 五子棋AI算法(2) ::: warning 注意 此AI并非“人工智能”,而是我们早期意义上的传统AI,并无学习能力,纯粹用算法实现五子棋每一步棋的应对策略。 ::: [上一章](gobang1.md)进行了一个五子棋游戏框架的搭建。应该来说,除开AI以外,其他的部分全部写完了。从这章开始,就详细介绍一下五子棋的AI算法。这里说一件非常令人振奋的消息:这章看完之后,你的五子棋AI已经可以下棋了,唯一的缺点就是奇慢无比,但是只要你愿意让他思考足够长的时间,他的棋力绝对是非常棒的了。优化算法将在后续的章节慢慢讲解。 回顾一下[上一章](gobang1.md)的内容,我们需要一个`RobotPlayer`类,实现`Player`接口的三个方法: ```java title="RobotPlayer.java" public class RobotPlayer implements Player { private final int color; //1-执黑 2-执白 public RobotPlayer(int color) { this.color = color; } private byte[][] board = new byte[Constant.MAX_LEN][Constant.MAX_LEN]; //对应格子0-空,1-黑,2-白 // 浅显易懂的set和get方法 private void set(Point p, int val) { board[p.x][p.y] = (byte) val; } private byte get(Point p) { return p == null ? -1 : board[p.x][p.y]; } @Override public void display(Point p) { // 接口的第一个方法:display,对方下了哪步棋 if (get(p) != 0) throw new IllegalArgumentException(p.toString() + get(p)); // 严谨,做个保护 set(p, 3 - color); // 通知过来的是对方下了哪里,自己如果执黑,则应该标记那个点为白色 } @Override public void notifyWinner(int color) { // 接口的第二个方法:notifyWinner,告知游戏结束了,谁赢了。 // 这里我们什么都不用做 } @Override public Point play() { // 接口的第三个方法:play,本文的重头戏,由AI计算出下一步应该下在哪里,并返回。下文开始重点讲解 } } ``` 在讲这个`play`方法之前,我们首先需要掌握一些数学知识。 ## 完全信息动态零和博弈 五子棋按照分类属于完全信息动态零和博弈。这里就涉及了很多看上去貌似很复杂的词汇。 首先,**博弈**就是下棋,或者说就是game,这个很好理解。前面三个词语就需要一一解释了: * **完全信息**:游戏进行的任何时候,双方参与者对棋盘上的全部信息都完全了解。这一点就不像军旗、斗地主、麻将,我并不知道对方的一些信息。 * **动态**:双方是轮流下子,每一步棋做决策之前,需要上一步棋的局面状态做出相应的选择。而非石头剪刀布那样,双方同时做决策。 * **零和(zero sum)**:或者说是“非合作性”,五子棋不存在任何“双方共同目标”。换句话说,某步棋对这个玩家产生了正价值,则一定对他的对手产生了等量的负价值。关于什么是“非零和博弈”,可以自行搜索一下“囚徒困境”。 对于这类“完全信息动态零和博弈”问题,我们有一种通用的解法。因为是“完全信息”的,我们不需要考虑复杂的概率问题,只需要找到那个最好的决策即可。因为是“零和”的,我们只需要一个评估函数就可以对场上的局面进行评价(我的评分和对方的评分一定互为相反数)。因为是“动态”的,所以要考虑的问题很多,就需要用到后文的“博弈树”进行解决了。 (在这里我要提一句,下文暂时先不考虑国际通用的“索索夫8规则”,包含三手交换、四手交换、五手多打等复杂的变种规则。我们只考虑最普通最原始的规则,简单说就是:谁先连成五个子谁就赢了) ## 评估函数 在讲博弈树之前,我们首先说一下这个评估函数。分析一下五子棋的规则,如果连成了五个就赢了。如果连成了四个子,并且两边都没有被对手堵住,也赢了。如果连成了四个子,并且只有其中一边被对手堵上,则对手下一步必须堵另一边。如果连成了三个子……(此处省略几百个字)同样,如果对手连成了五个就输了,对手连成了四个或者三个……(此处省略几百个字)。想必大家玩过五子棋的人肯定根据经验都知道这些事情,就不浪费篇幅多说了。 根据上述的这些规则和经验,我们可以做一个评估函数的设计,大概是这样的: ```java title="RobotPlayer.java" public class RobotPlayer implements Player { private final int color; //1-执黑 2-执白 /* 此处省略了上文写了的方法 */ private int evaluateBoard() { return evaluateBoard(color) - evaluateBoard(3 - color); } // 这个函数是评价当前棋盘上仅考虑某一种颜色的得分 // 想要得到考虑双方棋子的得分,就是自己得分减去对方得分即可evaluateBoard(1) - evaluateBoard(2) private int evaluateBoard(int color) { int values = 0; for (int i = 0; i < Constant.MAX_LEN; i++) { for (int j = 0; j < Constant.MAX_LEN; j++) { Point p = new Point(i, j); // 如果同一个方向有连续5个子,则 values += 1000000; // 如果同一个方向有连续4个子并且两边都没有堵住,则 values += 300000; // 如果同一个方向有连续4个子并且仅有一边被堵住,则 values += 2500; // 如果是***0*的情况,则values += 3000; // 如果是**0**的情况,则values += 2600; // 如果是“活三”的情况,则values += 3000 // 如果是“活三”被堵住了一边,则values += 500; // 此处省略约一百行没有难度仅仅是下棋经验的代码 /* 这些分值其实自己都可以进行调整,注意控制一下分数 例如如果场上有两个“活三”,应该和场上有一个“活四”差不多得分,因为都是已经赢了。 需要注意的是,比如一连串有三个子,其实只需要算一遍价值就够了,不要对于这三个子都算一遍。 如果有两个子连成一条线,建议也象征性的加少量分数 */ } } return values; } } ``` 为什么没有把完整代码贴出来,一是因为篇幅问题,二是因为确实这些代码虽然复杂繁琐,但都只是一些毫无算法难度的游戏规则和经验,与本文的算法关系不大。只要稍微花费一些时间,大家都是可以自行写出来的。 得到了这个评估函数之后,我们就可以回到刚才的数学讨论了,如何决策下一步棋该如何走。 ## 博弈树与极大极小值搜索 循序渐进,我们先考虑一步棋,如下图所示: ```mermaid graph TD 当前盘面 --> 10 当前盘面 --> 15 当前盘面 --> 7 当前盘面 --> 9 ``` 假设在当前盘面下,我们有四种走法,对于每种走法我们调用上文的评估函数`evaluateBoard`,得到四个得分,显然,我们更倾向于选择最高分15对应的那个走法。换句话说,我们可以认为以当前局面发展,可以到达15分的局面。 现在我们开始考虑两步棋,如下图所示: ```mermaid flowchart TD subgraph MAX 1 10 15 7 9 end subgraph MIN 2 6 5 2 8 0 4 3 1 end 当前盘面 --> 10 10 --> 6 10 --> 5 当前盘面 --> 15 15 --> 2 15 --> 8 当前盘面 --> 7 7 --> 0 7 --> 4 当前盘面 --> 9 9 --> 3 9 --> 1 ``` 假设对于我的这四种走法,对方分别有两种走法进行应对。现在情况开始变得复杂了。我们重新强调一下,这是一个“零和博弈”,也就是说,我的正分一定等于对方的负分。如果我选择了15分这种走法,对方肯定不傻,一定会选择2分这种走法,想让我的分更低。如果我选择了10分这种走法,对方一定会选择5分这种走法。想要将局面变成6分或者8分的结果,是不可能的(除非对面犯傻)。那么对于图上的那种情况,我们分析一下:如果我选第一种走法,则会得到5分;如果我选第二种走法,则会得到2分;如果我选第三种走法,则会得到0分;如果我选第四种走法,则会得到1分。那我到底应该选择哪种走法呢?显然,我更希望两步棋后,局面是5分,我选择了第一种走法。 重新审视一下这个问题,我们不难发现,如果我考虑两步棋,那么第一步棋的得分是没有用的。我的实际求解过程是:先通过每种第一步棋,求得对应的第二步棋的最小得分,再从这些最小得分中,找到那个最大得分。 好了,为了游戏更加精确,我们继续尝试考虑4步棋: ```mermaid flowchart TD subgraph MAX 1 b0 b1 end subgraph MIN 2 c0 c1 c2 c3 end subgraph MAX 3 d0 d1 d2 d3 d4 d5 d6 d7 d8 end subgraph MIN 4 e0 e1 e2 e3 e4 e5 e6 e7 e8 e9 e10 e11 e12 e13 e14 end 当前盘面 --> b0 当前盘面 --> b1 b0 --> c0 b0 --> c1 b1 --> c2 b1 --> c3 c0 --> d0 c0 --> d1 c0 --> d2 c1 --> d3 c1 --> d4 c2 --> d5 c2 --> d6 c3 --> d7 c3 --> d8 d0 --> e0 d1 --> e1 d1 --> e2 d1 --> e3 d2 --> e4 d3 --> e5 d3 --> e6 d4 --> e7 d4 --> e8 d5 --> e9 d5 --> e10 d6 --> e11 d7 --> e12 d8 --> e13 d8 --> e14 ``` 同样,按照上面的思路,我们需要反着考虑。首先考虑第四步棋,这是对方选择的一步棋。对于每一种第三步的局面,对方肯定选择分数最低的一步棋,我们把同一个第三步下的所有第四步的最小值求出来,作为第三步的分数即可。然后对于每个第二步的局面,我肯定选择分数最高的那个第三步,因此只需要求出同一个第二步下的所有第三步的最大值求出来,即可作为第二步的分数。同理,我们继续找第二步的最小值当做第一步的分数。最后再找到第一步的最大值,作为我决策的下一步棋。 以上,就是我们所说的“极小极大值搜索”算法。 值得一提的是,如果我优先下出了五连珠,游戏会立即结束,如果下一步棋对方也下出了五连珠,则我的五连珠调用`evaluateBoard(1)`减去对方的五连珠调用`evaluateBoard(2)`等于0,这个情况我们要排除掉,因为我已经先下出五连珠了,游戏已经结束了,对方再下出来的棋是无效的。 推广到连续考虑N步棋,我们可以得到这样的代码: ```java title="PointAndValue.java" :line-numbers // 因为下面的算法需要返回这一步棋下在哪里,以及对应这个下法的分数,我就封装了这样一个类 public class PointAndValue { public final Point point; public final int value; public PointAndValue(Point point, int value) { this.point = point; this.value = value; } } ``` ```java title="RobotPlayer.java" public class RobotPlayer implements Player { private final int color; //1-执黑 2-执白 /* 此处省略了上文写了的方法 */ private int evaluateBoard() { /* 这是上文的评估函数 */ } // 参数leftstep是还需要计算几步,color是当前这一步论到谁下 // 例如打算计算8步棋,则直接这样进行调用即可getMaxEvaluate(8, this.color) private PointAndValue getMaxEvaluate(int leftStep, int color) { Point maxPoint = null; int maxValue = 0; for (int i = 0; i < Constant.MAX_LEN; i++) { for (int j = 0; j < Constant.MAX_LEN; j++) { Point p = new Point(i, j); if (get(p) != 0) continue; // 如果这个点已经有子了,则跳过 // 进一步考虑,如果这个点离棋盘上存在的其他子距离太远了,也可以跳过以减少计算量,代码就不展示了 set(p, color); // 回溯法,先set,在递归之后再set回来 int val = evaluateBoard(); // 因为上文给了五连珠1000000分,那么我这里就把大于800000分当做已经出现了五连珠 // 出现了五连珠,游戏立即结束,不继续递归了 if (val > 800000) return new PointAndValue(p, val); // 如果不是最后一步,则这个分数是没有用的,需要以下一步的分数的最大(小)值作为这一步的分数,递归 if (leftStep > 1) { PointAndValue nextStep = getMaxEvaluate(leftStep - 1, 3 - color); // 这里要注意,递归调用时是从对方的角度算分,所以对方的负分才是我的正分 val = -nextStep.value; } if (maxPoint == null || val > maxValue) { // 因为这个函数是从当前这一步的那个人的角度算分,所以只需要求最大值即可。想要求最小值,递归之后再取负即可 maxPoint = p; maxValue = val; } set(p, 0); // 回溯法,递归调用之后再set回来 } } // 如果棋盘下满了,上面的循环会全部进入if (get(p) != 0) continue;因此最后maxPoint依旧为空 // 这里应该对maxPoint == null的情况增加一些容错,代码就不展示出来了 return new PointAndValue(maxPoint, maxValue); } } ``` 到此为止,我们就可以用`getMaxEvaluate`方法求出一个“最好的策略”了。把这个方法再进一步组合,我们可以得到`play`方法: ```java title="RobotPlayer.java" import java.util.*; public class RobotPlayer implements Player { private final int color; //1-执黑 2-执白 /* 此处省略了上文写了的方法 */ private PointAndValue getMaxEvaluate(int leftStep, int color) { /* 这是上文的用以计算最好策略的方法 */ } private int count = 0; // 记录一下是目前是第几步棋 @Override public Point play() { Point p; if (count == 0) { // 如果是第一步棋,则直接下在天元位置 p = new Point(Constant.MAX_LEN / 2, Constant.MAX_LEN / 2); } else if (count == 1) { // 如果是第二步棋,天元位置是空的就下在天元,否则就下在天元旁边(斜向或直向)。当然也可以选择别的策略 if (board[Constant.MAX_LEN / 2][Constant.MAX_LEN / 2] == 0) p = new Point(Constant.MAX_LEN / 2, Constant.MAX_LEN / 2); else if (new Random().nextBoolean()) p = new Point(Constant.MAX_LEN / 2 - 1, Constant.MAX_LEN / 2); else p = new Point(Constant.MAX_LEN / 2 - 1, Constant.MAX_LEN / 2 - 1); } else { // 第三步棋之后开始调用getMaxEvaluate方法,这里我们以搜索深度是8为例 PointAndValue result = getMaxEvaluate(8, this.color); p = result.point; } set(p, color); count++; return p; } } ``` 好了,至此为止,恭喜你,我们已经可以和AI进行五子棋对弈了。 写完了算法,我们大致看看这个算法的计算量。尽管我们可以排除距离局面太远的点,但是下到中局的时候,我们每一步棋起码也要考虑三四十个点。假设要考虑八步棋,就是 $30^8=6561\text{亿}$ 个分支,简直庞大的计算量。目前这个算法在搜索深度是4的情况下,可以保证在几秒钟最多一分钟之内计算出结果,如果想要考虑八步甚至十步棋,显然远远是不够的。下一章会介绍α-β剪枝、启发式搜索等一些算法,对目前的算法进行优化。 --- --- url: /programming/gobang/gobang3.md --- # 五子棋AI算法(3) ::: warning 注意 此AI并非“人工智能”,而是我们早期意义上的传统AI,并无学习能力,纯粹用算法实现五子棋每一步棋的应对策略。 ::: [上一章](gobang2.md)成功的实现了一个五子棋AI,尽管它的性能非常不佳。接下来,我们打算在此基础上进行一系列优化,大幅度提高运算性能。 ## α-β剪枝 回顾一下我们[上一章](gobang2.md)说的“极大值极小值搜索”: ```mermaid flowchart TD subgraph MAX 1 10 15 7 9 end subgraph MIN 2 6 5 2 8 0 4 3 1 end 当前盘面 --> 10 10 --> 6 10 --> 5 当前盘面 --> 15 15 --> 2 15 --> 8 当前盘面 --> 7 7 --> 0 7 --> 4 当前盘面 --> 9 9 --> 3 9 --> 1 ``` 按照[上一章](gobang2.md)的方法,首先我们从第一个分支6和5中找到最小值5。接下来,我们找第二个分支,结果我们一上来就看到了2。这个时候我们思考一下,如果后面的数比2大,那么2是最小值;如果后面的数比2小,那么说明最小值比2小。这样一来,我在第二个分支中得到的最小值肯定不会超过2。在第一个分支我们已经得到最小值5了,由于MAX层要找最大值,第二个分支是不会超过2的,所以整个第二个分支下面的所有分支我们都可以放弃了。这就是所谓的“剪枝”。同理,第三个分支找到了0,第四个分支找到了3,都比5小,这两个分支也都不用看了。 这样一来,如果在“运气好”的情况下,每一排只需要看第一个分支即可,后面的分支都可以忽略。这个忽略后面的分支的方法就叫做“剪枝”。 那么代码是怎么写的呢?首先我们拿到[上一章](gobang2.md)的代码过来看一下: ```java title="RobotPlayer.java" // 这里只展示了getMaxEvaluate函数的代码,其他代码在前面的章节中可以找到 private PointAndValue getMaxEvaluate(int leftStep, int color) { Point maxPoint = null; int maxValue = 0; for (int i = 0; i < Constant.MAX_LEN; i++) { for (int j = 0; j < Constant.MAX_LEN; j++) { Point p = new Point(i, j); if (get(p) != 0) continue; // 如果这个点已经有子了,则跳过 // 进一步考虑,如果这个点离棋盘上存在的其他子距离太远了,也可以跳过以减少计算量,代码就不展示了 set(p, color); int val = evaluateBoard(); if (val > 800000) { set(p, 0); return new PointAndValue(p, val); } if (leftStep > 1) { PointAndValue nextStep = getMaxEvaluate(leftStep - 1, 3 - color); val = -nextStep.value; } if (maxPoint == null || val > maxValue) { maxPoint = p; maxValue = val; } set(p, 0); } } // 如果棋盘下满了,上面的循环会全部进入if (get(p) != 0) continue;因此最后maxPoint依旧为空 // 这里应该对maxPoint == null的情况增加一些容错,代码就不展示出来了 return new PointAndValue(maxPoint, maxValue); } ``` 观察一下,`for`循环就相当于博弈树的一个节点下的不同分支,而中间的递归调用相当于进入下一个子节点。我们需要修改的是,在递归调用的时候,将已经遍历过的子节点中得到过的极大值(或极小值)传给未遍历过的节点,若未遍历过的节点中已经发现满足剪枝的条件时进行剪枝即可。 我们把上面的代码稍加修改 ```java title="RobotPlayer.java" private PointAndValue getMaxEvaluate(int leftStep, int color, int passValue) {// 这个passValue就是其他节点传过来的值 Point maxPoint = null; int maxValue = -Integer.MAX_VALUE; for (int i = 0; i < Constant.MAX_LEN; i++) { for (int j = 0; j < Constant.MAX_LEN; j++) { Point p = new Point(i, j); if (get(p) != 0) continue; // 如果这个点已经有子了,则跳过 // 进一步考虑,如果这个点离棋盘上存在的其他子距离太远了,也可以跳过以减少计算量,代码就不展示了 set(p, color); int val = evaluateBoard(); if (val > 800000) { set(p, 0); return new PointAndValue(p, val); } if (leftStep > 1) { PointAndValue nextStep = getMaxEvaluate(leftStep - 1, 3 - color, -maxValue); if (nextStep.value >= passValue) { // α-β剪枝 set(p, 0); return new PointAndValue(p, nextStep.value); } val = -nextStep.value; } if (maxPoint == null || val > maxValue) { maxPoint = p; maxValue = val; } set(p, 0); } } // 如果棋盘下满了,上面的循环会全部进入if (get(p) != 0) continue;因此最后maxPoint依旧为空 // 这里应该对maxPoint == null的情况增加一些容错,代码就不展示出来了 return new PointAndValue(maxPoint, maxValue); } ``` 好了,经历了α-β剪枝之后,在“运气好”的情况下,可以省去很多的进一步遍历。 ## 启发式搜索 上面多次着重强调了“运气好”这三个字,那么也就是说,我们的运气并不是一直都这么好。如果算法仅仅适用于运气好,那么就不通用了。 首先,我们想一下。什么样的点,容易出现上述的“极大值”(或者“极小值”)?显然,那些对胜负至关重要的点,相比那些不重要的点,更容易出现极值。那么,我们在遍历下一层之前,先交换一下遍历的顺序,让重要的点先进行遍历,就更加容易出现之前所说的“运气好”的情况——更容易很快出现极值。那么我们首先需要一个函数,来给每个点一个评估,它到底对胜负是否非常重要,这个函数就被称为“启发式搜索”函数。(这段代码看似很长,其实并无含金量,原本打算不贴出来的,但想了想还是补上了,方便读者节约时间,不在这个问题上浪费太多时间) ```java title="RobotPlayer.java" public int evaluatePoint(Point p, int color) { return evaluatePoint(p, color, 1) + evaluatePoint(p, color, 2); } private int evaluatePoint(Point p, int me, int plyer) { int value = 0; int numoftwo = 0; for (Direction dir : Direction.values()) { // 8个方向 // 活四 01111* *代表当前空位置 0代表其他空位置 下同 if (getLine(p, dir, -1) == plyer && getLine(p, dir, -2) == plyer && getLine(p, dir, -3) == plyer && getLine(p, dir, -4) == plyer && getLine(p, dir, -5) == 0) { value += 300000; if (me != plyer) { value -= 500; } continue; } // 死四A 21111* if (getLine(p, dir, -1) == plyer && getLine(p, dir, -2) == plyer && getLine(p, dir, -3) == plyer && getLine(p, dir, -4) == plyer && (getLine(p, dir, -5) == 3 - plyer || getLine(p, dir, -5) == -1)) { value += 250000; if (me != plyer) { value -= 500; } continue; } // 死四B 111*1 if (getLine(p, dir, -1) == plyer && getLine(p, dir, -2) == plyer && getLine(p, dir, -3) == plyer && getLine(p, dir, 1) == plyer) { value += 240000; if (me != plyer) { value -= 500; } continue; } // 死四C 11*11 if (getLine(p, dir, -1) == plyer && getLine(p, dir, -2) == plyer && getLine(p, dir, 1) == plyer && getLine(p, dir, 2) == plyer) { value += 230000; if (me != plyer) { value -= 500; } continue; } // 活三 近3位置 111*0 if (getLine(p, dir, -1) == plyer && getLine(p, dir, -2) == plyer && getLine(p, dir, -3) == plyer) { if (getLine(p, dir, 1) == 0) { value += 1450; if (getLine(p, dir, -4) == 0) { value += 6000; if (me != plyer) { value -= 300; } } } if ((getLine(p, dir, 1) == 3 - plyer || getLine(p, dir, 1) == -1) && getLine(p, dir, -4) == 0) { value += 500; } if ((getLine(p, dir, -4) == 3 - plyer || getLine(p, dir, -4) == -1) && getLine(p, dir, 1) == 0) { value += 500; } continue; } // 活三 远3位置 1110* if (getLine(p, dir, -1) == 0 && getLine(p, dir, -2) == plyer && getLine(p, dir, -3) == plyer && getLine(p, dir, -4) == plyer) { value += 350; continue; } // 死三 11*1 if (getLine(p, dir, -1) == plyer && getLine(p, dir, -2) == plyer && getLine(p, dir, 1) == plyer) { value += 700; if (getLine(p, dir, -3) == 0 && getLine(p, dir, 2) == 0) { value += 6700; continue; } if ((getLine(p, dir, -3) == 3 - plyer || getLine(p, dir, -3) == -1) && (getLine(p, dir, 2) == 3 - plyer || getLine(p, dir, 2) == -1)) { value -= 700; continue; } else { value += 800; continue; } } // 活二的个数(因为会算2次,就2倍) if (getLine(p, dir, -1) == plyer && getLine(p, dir, -2) == plyer && getLine(p, dir, -3) == 0 && getLine(p, dir, 1) == 0) { if (getLine(p, dir, 2) == 0 || getLine(p, dir, -4) == 0) numoftwo += 2; else value += 250; } if (getLine(p, dir, -1) == plyer && getLine(p, dir, -2) == 0 && getLine(p, dir, 2) == plyer && getLine(p, dir, 1) == 0 && getLine(p, dir, 3) == 0) { numoftwo += 2; } if (getLine(p, dir, -1) == 0 && getLine(p, dir, 4) == 0 && getLine(p, dir, 3) == plyer && (getLine(p, dir, 2) == plyer && getLine(p, dir, 1) == 0 || getLine(p, dir, 1) == plyer && getLine(p, dir, 2) == 0)) { numoftwo += 2; } if (getLine(p, dir, -1) == plyer && getLine(p, dir, 1) == plyer && getLine(p, dir, -2) == 0 && getLine(p, dir, 2) == 0) { if (getLine(p, dir, 3) == 0 || getLine(p, dir, -3) == 0) numoftwo++; else value += 125; } // 其余散棋 int numOfplyer = 0; for (int k = -4; k <= 0; k++) { // ++++* +++*+ ++*++ +*+++ *++++ int temp = 0; for (int l = 0; l <= 4; l++) { if (getLine(p, dir, k + l) == plyer) { temp += 5 - Math.abs(k + l); } else if (getLine(p, dir, k + l) == 3 - plyer || getLine(p, dir, k + l) == -1) { temp = 0; break; } } numOfplyer += temp; } value += numOfplyer * 5; } numoftwo /= 2; if (numoftwo >= 2) { value += 3000; if (me != plyer) { value -= 100; } } else if (numoftwo == 1) { value += 2725; if (me != plyer) { value -= 10; } } return value; } ``` 参数`p`表示接下来要计算的点,`me`表示调用这个函数的那一方自己的颜色,`plyer`代表接下来要计算的人的编号。一个点对两方的重要性之和,就是我们需要的结果。这样我们就可以大致得到了一个对单个点的价值评估函数,也就是“启发式搜索”函数。 值得一提的是,这里的这些数字,其实都是凭个人感觉写的(例如四肯定比三重要,例如活四就直接赢了那我肯定优先走活四),并且多次测试后慢慢调整的,所以不精确。将来如果有机会,我打算用其他的方法来给这些数字一些更加科学的值。 然后我们在遍历之前,排个序即可。这里偷点懒,直接用了优先队列`PriorityQueue`。 ```java title="RobotPlayer.java" private PointAndValue max(int leftStep, int color, int passValue) { PriorityQueue queue = new PriorityQueue<>((obj1, obj2)->Integer.compare(obj2.value, obj1.value)); for (int i = 0; i < Constant.MAX_LEN; i++) { for (int j = 0; j < Constant.MAX_LEN; j++) { Point p = new Point(j, i); if (board.get(p) == 0 && board.isNeighbor(p)) { int evathis = evaluatePoint(p, colorNext); queue.add(new PointAndValue(p, evathis)); } } } //下面的代码循环就从优先队列的头开始循环,而不再需要从左上角到右下角一一遍历了 //循环代码和上文基本一样,就不再赘述了 } ``` 从减少遍历次数的方法对博弈树的极大极小值搜索进行优化,就基本到这里为止了。后续还会介绍一些其他的方法进行优化的技巧。 --- --- url: /programming/gobang/gobang4.md --- # 五子棋AI算法(4) ::: warning 注意 此AI并非“人工智能”,而是我们早期意义上的传统AI,并无学习能力,纯粹用算法实现五子棋每一步棋的应对策略。 ::: [上一章](gobang3.md)我们用α-β剪枝和启发式搜索对博弈树搜索算法进行优化,大大提高了算法性能。这一章我们继续用更多的方法进行优化。 ## 算杀 回顾一下我们[上一章](gobang3.md)讲的剪枝,既然有剪枝,那么也有“增枝”。我们对少量特殊情况进行更深的处理,就可以提高算法的精确度。 我们知道,在下五子棋的过程中,我们经常连续走出“活三”“冲四”的**先手**棋,让对手必须响应。在连续下出多步这样的棋后,最后形成“双三”“双四”“四三”等对方无法顾及的局面,就可以获胜了。 那么我们只需要对“活三”“冲四”的**先手**棋进行更深入的处理就可以了。用伪代码可以表示为: ```java :collapsed-lines=12 for ( int i = 0; i < Constant.MAX_LEN; i++ ) { for ( int j = 0; j < Constant.MAX_LEN; j++ ) { // if ( 下完这步棋之后,我有“四”,且对方无“四” ) { // 递归,对方也要遍历,找到一个点,使我没有“四” // 对方找到这样一个点之后,再递归,我再找到一个点,使我有“四” // 如果对方找不到一个点使我无“四”,则我这种走法是杀招 // 如果我找不到一个点使我有“四”,递归结束,加深算法结束,回到之前的博弈树搜索 // } } } for ( int i = 0; i < Constant.MAX_LEN; i++ ) { for ( int j = 0; j < Constant.MAX_LEN; j++ ) { // if ( 下完这步棋之后,我有“三”,且对方无“四”和“三” ) { // 递归,对方也要遍历,找到一个点,使我没有“三” // 对方找到这样一个点之后,再递归,我再找到一个点,使我有“三” // 如果对方找不到一个点使我无“三”,则我这种走法是杀招 // 如果我找不到一个点使我有“三”,递归结束,加深算法结束,回到之前的博弈树搜索 // } } } ``` 既然有“算杀”,自然也有“防杀”: ```java :line-numbers for ( int i = 0; i < Constant.MAX_LEN; i++ ) { for ( int j = 0; j < Constant.MAX_LEN; j++ ) { // if ( 下完这步棋之后,对方算杀,结果找到了一种杀招 ) { // continue; 我不能选择这种走法 // } } } // 如果找到一步棋,对方算杀不成功,那么就防杀成功了 // 如果找不到这样的棋,则说明我已经输了 ``` 算杀和防杀也应该有一个步数上限,但这个步数应该长于博弈树搜索的步数,这就是所谓的“增枝”。 但是,还有一个小问题我们没有处理:在“算杀”的过程中,很有可能本来就有一个杀招,但由于我们遍历的顺序随机,可能会先去别的地方走出一系列“或三”“冲四”,最后才会回来走这个杀招,显得很“傻”。 解决方法也很简单,我们可以在“算杀”的过程中,慢慢增加步数,例如先计算4步之内的杀招、再计算6步之内、再计算8步之内,这样慢慢增加步数限制,就会优先找到步数最少的“杀招”了。 算杀和防杀的代码较为简单,这里就不详细列举了,感兴趣可以查看[完整代码](#完整代码)。 ## 缓存 我们接下来考虑这样一些问题: * 之前算过的棋,如果我们存起来,下次就不用再重新算了。毕竟算一次也是需要挺久的时间的。 * 根据之前的博弈树搜索算法可以看出,我们下一步棋到底应该下在哪里,只与当前盘面有关,与我之前是如何走出这个盘面的无关。换句话说,我按照1→2→3→4的顺序走出来,还是1→4→3→2的顺序走出来,只要当前盘面一样,不会影响我的下一步棋。 那么我们只需要一个`Map`存储,它的key是当前棋局,value是下一步我要下在哪里,就能节约大量计算时间。 众所周知,`Map`一般有两种,`HashMap`和`TreeMap`。我们应该用哪种呢? `TreeMap`的底层是二叉搜索树,需要一个比较函数`int compare ( 棋盘状态1, 棋盘状态2 ) `返回大于、等于、小于,显然每次都比较的话,尤其数据量大了之后,是不划算的。 而`HashMap`,底层是Hash表,需要一个`int hash()`函数,这个函数应该怎么写?一定要全遍历一遍这个棋盘,根据每个位置的状态(黑、白、空),才能写出来吗?这样的话,又要全遍历,就没什么意义了。 这样,我们就引出了 **Zobrist Hashing** 算法。 ### Zobrist Hashing **Zobrist Hashing** 算法常用于棋类游戏的盘面Hash值计算中,尤其是五子棋、国际象棋等。它的基本思想是将棋盘上的每个位置和每种棋子(黑、白)都映射到一个随机数上,然后通过异或运算来计算当前棋盘的Hash值。 首先我们需要将每个格子赋值一个随机数,黑子和白子都需要分别赋值一个随机数: ```java :line-numbers int hash_value = random.nextInt(); int[][] white_hash = new int[Constant.MAX_LEN][Constant.MAX_LEN]; int[][] black_hash = new int[Constant.MAX_LEN][Constant.MAX_LEN]; for (int i = 0; i < Constant.MAX_LEN; i++) { for (int i = 0; i < Constant.MAX_LEN; i++) { white_hash[i][j] = random.nextInt(); black_hash[i][j] = random.nextInt(); } } ``` 每下一步棋,我执行一次下面的操作: ```java :line-numbers if (whose_turn == Constant.BLACK) hash_value ^= black_hash[x][y]; else hash_value ^= white_hash[x][y]; ``` 这里的`^`是“按位异或”运算符。 我们来思考一下,为什么可以用异或? * 假设原值是1,我们执行两次异或操作:1 ^ 1 = 0,0 ^ 1 = 1。我们发现,异或两次之后,又变为原先的值。 * 假设原值是0,我们执行两次异或操作:0 ^ 1 = 1,1 ^ 1 = 0。我们发现,异或两次之后,也会变为原先的值。 换句话说,我把一个白棋放上去,异或一次之后,如果把它拿下来,再异或一次,盘面不变,Hash值也不变。黑棋同理。 `hash()`函数最基本的要求是,当盘面相同时,`hash()`函数的返回值必须相等,这一点我们已经实现。 这样一来,随着每下一步棋,Hash值跟着变动一次,Hash值的生成不需要遍历棋盘了,`hash()`的效率已经满足了。 *** 第二个问题来了,Hash冲突怎么解决? 可能有人会说,棋盘有 $15 \times 15 = 225$ 个格子,黑白棋两种颜色,加上这个格子没有下棋,共有3种情况,那么总共有 $3^{225}$ 种棋盘状态。而一个`long`只能存$2^{64}$种可能的数字,Hash冲突的概率应该很大吧? 其实并不能这样计算。首先,五子棋不像围棋一样存在提子,五子棋一定是轮流下棋,因此不可能出现全盘都是黑棋或者全盘都是白棋的情况。并且,很多已经成五的棋已经结束了,不会再继续下了,这样的盘面也不成立。再者,我们也不可能乱下,例如一直在版边或者角落下棋,即使我们这样下,AI算法也不会这样下的,因此这样的盘面也不会出现。 那么我们换一个角度思考,一局棋就算100步结束,下10000盘棋,不算重复情况都只有100万种局面,一个`long`能存$2^{64} \approx 1.8 \times 10^{19}$种情况,这样一看,触发Hash冲突的概率就极低了。实际运用中一般是忽略Hash冲突的。 既然忽略了Hash冲突,我们就可以直接默认如果Hash值相同则棋局相同。 ### 缓存的用途 有了这个缓存,我们就可以干很多事情了,例如: * 缓存,已经算过的不用再重新算了。 * 提前存储开局库。 * 多线程运算,可以将计算结果存入到缓存中,方便线程之间的共享。 * 玩家在下棋的过程中,算法也可以在后台进行计算,提前算出下一步棋应该下在哪里。 * 还可以增加一些时间策略,例如计算6步花了10秒,而计算8步花了1分钟还没算出来,就可以提前返回计算6步的策略了。 ## 后记 以上,就是我们五子棋AI算法的全部内容了。我们从最开始的简单的博弈树搜索算法,到后来的α-β剪枝、启发式搜索、算杀、缓存等一系列优化,最后实现了一个性能还不错的五子棋AI。 当然了,现代的机器学习技术已经可以实现更高效的五子棋AI了。我们在这里实现的五子棋AI算法只是一个简单的实现,更多的是为了让大家了解五子棋AI算法的基本原理和实现方法。 说到这里,我刚好也想到,我们可以把这个五子棋AI算法和机器学习结合起来。可以考虑的一种思路是:我们在前面的[评估函数](gobang2.md#评估函数)的设计时,用了很多个人经验来“云”出来的每一步多少分,这些分值肯定是有误差的,我们只需要通过机器学习的思路,持续训练这些数值,就可以让AI更加聪明。由于我对机器学习了解尚浅,这里我就不“班门弄斧”了。 到这里,本篇就基本完结了。可以看到,这个系列的文章跨度时间很久,也是我鸽了又鸽,最后终于补齐了。 ## 完整代码 整个五子棋的代码(Go版和Java版)已经分享在: ,需要的可以自取。 --- --- url: /programming/kotlin/kotlin_intro.md --- # 快速上手Kotlin Kotlin是基于Java虚拟机的一种静态类型编程语言,如果你有Java编程经验,那么学习Kotlin将会非常容易。 本文主要面向有Java编程经验的开发者,将会介绍Kotlin的基本语法和特性。如果你没有Java编程经验,想要直接上手Kotlin,建议直接在网上搜索Kotlin入门的相关文章。 我们用一个小代码片段,对比一下Java和Kotlin: ::: code-tabs @tab Java ```java public class HelloWorld { // 这是一个生成斐波那契数列的方法 public int[] fib(int n) { int[] result = new int[n]; if (n <= 2) { Arrays.fill(result, 1); return result; } for (int i = 0; i < n; i++) { result[i] = i < 2 ? 1 : result[i - 1] + result[i - 2]; } return result; } } ``` @tab Kotlin ```kotlin public class HelloWorld { // 这是一个生成斐波那契数列的方法 public fun fib(n: Int): IntArray { if (n <= 2) { return IntArray(n) { 1 } } val result = IntArray(n) for (i in 0 until n) { result[i] = if (i < 2) 1 else result[i - 1] + result[i - 2] } return result } } ``` ::: 我们可以看到,Kotlin和Java的语法非常相似,但是Kotlin更加简洁,省略了Java中的一些冗余代码。 ## 函数式编程 大多数开发者对面向对象编程十分了解,例如我们熟知的C++、Java、Python等编程语言都是面向对象编程语言。而Kotlin不仅支持面向对象编程,还支持函数式编程。那么什么是函数式编程呢? 函数式编程(FP)相比面向对象编程(OOP)主要有两个不同点: * 变量是不可变的,即一旦赋值就不能再改变。想要改变变量的值,只能重新赋值。这样的好处就是在并发的情况下完全不需要加锁,不需要担心多线程同时修改变量的问题。 * 任何一个操作都可以视为函数,函数一定有返回值。 举个例子,`if`语句就可以视为一个简短的函数: ```kotlin val result = if (n <= 2) { n++ -1 } else if (n >= 10) { 1 } else { 0 } ``` 在上文中,`if`、`else if`和`else`分支的最后一行都是`Int`类型的值,所以可以视为一个简单的函数,当n值不同时返回不同的值。最后将这个返回值赋值给`result`变量。 Kotlin既可以当作普通的面向对象编程语言使用,也可以当作函数式编程语言使用。还是拿`if`语句举例,我们可以用面向对象编程的方式来使用`if`: ```kotlin val a = 1 if (a < 2) { println("a < 2") } ``` 其它语言中的`switch`语句在Kotlin中被称为`when`语句: ```kotlin :no-collapsed-lines // 函数式编程,且when后面带参数 val a = when (b) { 0, 1 -> "b is 0 or 1" 2 -> "b is 2" else -> { // 函数式编程必须有else分支,保证所有情况都有返回值 "b is other" // 如果想要写多行,需要用大括号括起来 } } // 面向对象编程,且when后面不带参数 var c = "" when { b < 1 -> c = "b < 1" b == 2 -> c = "b is 2" else -> c = "b is other" // 面向对象编程可以没有else分支 } ``` ## 变量、函数、方法 ```kotlin val a: Int = 3 // 声明一个不可变变量 var b: Int = 2 // 声明一个可变变量 b++ // 可以修改b的值 // 如果类型可推断,可以省略类型声明 val c = 1 var d = 2 // 函数声明,参数可以有默认值,返回值类型写在最后面 fun add(a: Int, b: Int = 1): Int { return a + b } // 可以简写 fun add2(a: Int, b: Int = 1) = a + b class HelloWorld { // 方法声明 fun add3(a: Int, b: Int): Int { return a + b } } // 在类外面也可以声明类的方法 fun HellowWorld.add4(a: Int, b: Int) = a + b // 继承 class HelloWorld2: HelloWorld { } // 同时声明类的构造函数和成员变量 class HelloWorld3(val a: Int, val b: Int) { } // 调用函数 add(1, 2) // 直接调用 add(b = 2, a = 1) // 可以指定参数名来调用 // 函数可以作为函数的参数 fun doSth(a: Int, cb: (Int) -> Int): Int { return cb(a) } doSth(1, ::add) // 传入一个合适的函数 // 因为add的第二个参数有默认值,所以可以当作只有一个参数的函数使用 doSth(1, { c -> c + 1 }) // 传入一个lambda表达式(lambda表达式本质就是一个函数,最后一行是返回值) doSth(1, { it + 1 }) // 可以不声明参数名,直接用it来表示参数 doSth(1) { it + 1 } // 如果函数的最后一个参数是函数,可以放在括号外面 doSth(1) { if (it < 10) return@doSth it // lambda表达式可以提前return,但是要加@doSth指定到底返回的是哪个函数,否则会被误认为return外面的大函数。 it + 1 } ``` ## 对集合的优化 相比于Java,Kotlin对集合的操作更加方便。在Kotlin中,`List`就是不可变的列表,`MutableList`、`ArrayList`等就是可变的列表。 Kotlin还有很多方便的集合操作,例如: ```kotlin :no-collapsed-lines val list1 = listOf(2, 4, 6, 8, 10) // 快速声明一个不可变列表 val list2 = List(5) { (it + 1) * 2 } // 和上一行等价 // 一些函数式编程语言中常见的操作 val list3 = list1.map { it * 2 } // 返回一个新列表,其中每个元素都是list1中的每个元素乘以2 val list4 = list1.filter { it > 5 } // 过滤出list1中大于5的元素 val list5 = list1.sum() // 求list1中所有元素的和 val b1 = list1.all { it % 2 == 0 } // 判断list1中所有元素是否都是偶数 val b2 = list1.any { it % 2 == 0 } // 判断list1中是否有偶数 val list6 = list1.sorted() // 对list1进行排序 val list7 = list1 + list2 // 将list1和list2合并,返回一个新的不可变列表 list7.foreach { println(it) } // 也可以用for循环来遍历list for (i in list1) { println(i) } ``` Kotlin也支持Java中的集合操作,例如: ```kotlin val list = mutableListOf(1, 2, 3, 4, 5) // 快速声明一个可变列表 list.add(6) // 向list中添加元素 list += 8 // 向list中添加元素 list[0] = 0 // 修改list中的元素 println(list[0]) // 输出list中的第一个元素 ``` `Map`也是类似的,这里不再赘述。 除此之外,范围也是一种可以当作集合来操作的数据结构: ```kotlin val range = 1..10 // 1到10(闭区间) val range2 = 1 until 10 // 1到9(左闭右开区间) val range3 = 10 downTo 1 // 10到1(闭区间,递减) // 可以把range视为一个列表来操作,用法同上文中的不可变列表 val list4 = range.map { it * 2 } val list5 = range2 + list4 range3.forEach { println(it) } for (i in range) { println(i) } ``` 由于Kotlin支持函数式编程,我们尽量使用不可变变量,因此尽可能不要使用`Array`,而是用`List`和`MutableList`代替。 ## 特殊作用域函数 Kotlin中有一些特殊的作用域函数,可以简化代码的书写: ```kotlin :no-collapsed-lines val a = 3 val b = a.let { // 在这个作用域内,a可以用it来表示 it + 1 } // 最后一行的值作为函数的返回值,因此b的值为4 val c = a.also { // 在这个作用域内,a可以用it来表示 it + 1 } // a本身作为函数的返回值,因此c的值为3 val d = a.run { // 在这个作用域内,a可以用this来表示 this + 1 } // 最后一行的值作为函数的返回值,因此d的值为4 val e = a.apply { // 在这个作用域内,a可以用this来表示 this + 1 } // a本身作为函数的返回值,因此e的值为3 val f = with (a) { // 在这个作用域内,a可以用this来表示 this + 1 } // 最后一行的值作为函数的返回值,因此f的值为4 ``` 例如,以下代码可以这样进行简化: ::: code-tabs @tab 简化前 ```kotlin // val p = 当前回合玩家 // val allPlayers = 所有玩家 val players = allPlayers.filter { it.alive }. // [!code warning] filter { p.isEnemy(it) }.ifEmpty { allPlayers.filter { it.alive } // 和上面第4行重复了 // [!code warning] } ``` @tab 简化后 ```kotlin // val p = 当前回合玩家 // val allPlayers = 所有玩家 // [!code word:run] // [!code word:this] val players = allPlayers.filter { it.alive }.run { // 过滤出所有存活的玩家后,在run作用域内,所有存活的玩家就可以用this来表示 this.filter { p.isEnemy(it) }.ifEmpty { this } // 第一个this.可以省略 } ``` @tab 拆开写 ```kotlin // val p = 当前回合玩家 // val allPlayers = 所有玩家 // 也可以直接多声明一个中间变量 // [!code word:players1] val players1 = allPlayers.filter { it.alive } val players = players1.filter { p.isEnemy(it) }.ifEmpty { players1 } ``` ::: --- --- url: /programming/linux/awk.md --- `awk`是Linux中处理文本文件的语言,功能非常强大。 ## 基本语法 ```bash awk options 'pattern { action }' file ``` 其中,`options`是一系列可选参数,我们最常用的是`-F`参数,用于指定分隔符,例如`-F ','`表示以`,`为分隔符,如果不指定则默认是空格。`'pattern { action }'`是脚本的内容,其中`pattern`是个布尔表达式,满足这个表达式的行才会被输出,默认是遍历所有行。`file`是要处理的文件。 也有一些这样的用法: ```bash :no-line-numbers # 脚本太长了,可以将脚本写在一个文件中,然后使用`-f`选项来指定脚本文件 awk -f script.awk file # 可以用通道的形式使用 cat file | awk options 'pattern { action }' ``` ### awk脚本的基本格式 ```awk :no-line-numbers :no-collapsed-lines #!/bin/awk -f #运行前 BEGIN { math = 0 english = 0 computer = 0 printf "NAME NO. MATH ENGLISH COMPUTER TOTAL\n" printf "---------------------------------------------\n" } #每一行都执行 { math+=$3 english+=$4 computer+=$5 printf "%-6s %-6s %4d %8d %8d %8d\n", $1, $2, $3,$4,$5, $3+$4+$5 } #偶数行才执行 NR%2==0 { printf "第%d行处理完毕\n", NR } #运行后 END { printf "---------------------------------------------\n" printf " TOTAL:%10d %8d %8d \n", math, english, computer printf "AVERAGE:%10.2f %8.2f %8.2f\n", math/NR, english/NR, computer/NR } ``` 其包含四段大括号的内容,大括号前如果没有条件,则会在每行被处理时执行一次。如果有条件,则会在满足条件时才会执行,其中`BEGIN`和`END`分别表示只会在处理开始和结束时执行一次。 ### 常用内建变量 | 变量名 | 说明 | |-----------------|-----------------------| | `$0` | 整行文本 | | `$1`, `$2`, ... | 分隔后的各个字段(列) | | `NR` | 当前行号(从1开始) | | `FNR` | 当前文件的行号(从1开始) | | `NF` | 当前行的字段数 | | `FS` | 字段分隔符(默认是空格) | | `OFS` | 输出字段分隔符,默认值与输入字段分隔符一致 | | `RS` | 行分隔符(默认是换行符) | | `ORS` | 输出行分隔符,默认是换行符 | | `FILENAME` | 当前处理的文件名 | | `OFMT` | 数字的输出格式(默认值是`%.6g`) | ## 一些例子 ### 过滤逻辑 ```bash :no-line-numbers :no-collapsed-lines # 过滤第一列等于2的行 awk '$1==2 {print $1,$2,$3}' log.txt # 输出包含 "re" 的行 awk '/re/ ' log.txt # 输出第二列包含"th"的行,~表示正则匹配,!~表示正则不匹配 awk '$2 ~ /th/' log.txt # 忽略大小写匹配 awk 'BEGIN{IGNORECASE=1} /this/' log.txt # 从文件中找出长度大于80的行 awk 'length>80' log.txt ``` ### 字段处理 ```bash :no-line-numbers :no-collapsed-lines # 将只有一列的文本转化为一行 awk 'BEGIN{ORS=" "} 1' file.txt # 将只有一列的两个文本文件合成一个文件,新文件有两列 awk '{getline b < "file2.txt"; print $0, b}' file1.txt # 对第二列求和 awk '{sum+=$2} END {print sum}' file.txt # 求第二列的最大值 awk 'NR==1 || $2>max {max=$2} END {print max}' file.txt # 求和所有文件大小 ls -l *.txt | awk '{sum+=$5} END {print sum}' ``` ::: caution 注意 在不同版本的`awk`中,进行`<`和`>`比较时,可能会有不同的行为。在不确定版本的情况下,强烈建议坚持这个原则:**字符串只和字符串比较(按字母表顺序),数字只和数字比较。不要进行混合比较**。 如果担心出问题,可以进行强制类型转换: * 使用`str + 0`将字符串转换为数字 * 使用`num ""`或`"" num`将数字转换为字符串 ::: ### 打印乘法表 ```bash seq 9 | sed 'H;g' | awk -v RS='' '{for(i=1;i<=NF;i++)printf("%dx%d=%d%s", i, NR, i*NR, i==NR?"\n":"\t")}' ``` 输出: ```text :no-line-numbers 1x1=1 1x2=2 2x2=4 1x3=3 2x3=6 3x3=9 1x4=4 2x4=8 3x4=12 4x4=16 1x5=5 2x5=10 3x5=15 4x5=20 5x5=25 1x6=6 2x6=12 3x6=18 4x6=24 5x6=30 6x6=36 1x7=7 2x7=14 3x7=21 4x7=28 5x7=35 6x7=42 7x7=49 1x8=8 2x8=16 3x8=24 4x8=32 5x8=40 6x8=48 7x8=56 8x8=64 1x9=9 2x9=18 3x9=27 4x9=36 5x9=45 6x9=54 7x9=63 8x9=72 9x9=81 ``` ### 一个复杂点的例子 以下awk脚本可以将文本文件的每两行合并成一行(中间用逗号隔开): ```awk :no-line-numbers #!/bin/awk -f NR%2 { printf "%s, ", $0 next } 1 ``` 解释: * `NR%2`表示计算当前行号对2求余,如果余数不为0,即奇数行,则会执行大括号后面的内容。 * `next`表示跳过对当前行文本的处理,直接开始处理下一行文本。 * 如果是偶数行,则不会进入`NR%2`后的大括号,直接执行`1`。`1`即为真,永远会执行。但`1`后面没有大括号,表示执行默认动作,即打印当前行文本。 在命令行中使用时,将以上内容写在同一行,可以写成: ```bash # [!code word:NR%2] # [!code word:, ] awk 'NR%2 {printf "%s, ",$0; next} 1' file.txt ``` 每三行合并成一行的话,将`NR%2`改为`NR%3`即可。 分隔符如果想用其他符号的话,将`%s`后的逗号空格改为其它符号即可。 --- --- url: /programming/linux/docker.md --- ```bash :no-line-numbers :no-collapsed-lines # 彻底清理系统 docker system prune -a -f # 运行容器 docker run -d --name <容器名> <镜像名> # 列出所有容器 docker ps # 列出所有镜像 docker images # 强制删除容器 docker rm -f <容器名> # 删除镜像 docker rmi <镜像名> # 拉取镜像(指定系统) docker pull --platform linux/amd64 <镜像名> # 保存镜像到tar文件(指定系统) docker save --platform linux/amd64 -o <文件名>.tar <镜像名> # 从tar文件加载镜像 docker load -i <文件名>.tar ``` --- --- url: /programming/linux/journalctl.md --- 查询 `systemctl` 相关的日志,主要使用 `journalctl` 命令,因为 `systemd` 的日志是由 `journald` 来管理的。 ## 最基本的命令:查看所有日志 ```bash sudo journalctl ``` 这会输出系统从开机到现在所有的 `journald` 收集的日志,内容非常多,通常需要配合过滤条件使用。 ## 查看指定服务的日志(最常用) 我们知道,服务是通过 systemd 管理的,而 systemd 的配置文件放置于 `/usr/lib/systemd/system/` 或 `/etc/systemd/system/` 目录内。配置好服务后,只需要执行 `systemctl start <服务名>` 等命令,就可以对服务进行一系列操作(例如启动、停止等)。 如果我们要查看某一个服务的日志,可以使用 `journalctl` 的 `-u` 参数来指定服务名。 ```bash :no-line-numbers # 基本语法 sudo journalctl -u <服务名> # 示例:查看 nginx 服务的日志 sudo journalctl -u nginx # 示例:查看 docker 服务的日志 sudo journalctl -u docker ``` ## 控制日志输出方式 ### 实时滚动查看最新日志(类似 `tail -f`) 在排查问题时,实时查看日志非常有用。 ```bash sudo journalctl -u nginx -f ``` ### 查看从本次启动以来的日志 如果怀疑是本次重启后出现的问题,可以只看本次启动的日志。 ```bash :no-line-numbers # 查看指定服务本次启动的日志 sudo journalctl -u nginx -b # 查看系统本次启动的所有日志 sudo journalctl -b ``` ### 输出成文本 默认情况下,使用 `journalctl` 命令会进入 less 分页器查看日志,但有的时候我们想要直接按照文本的方式输出,只要加上 `--no-pager` 参数即可。 ## 按时间筛选日志 ### 查看最近一段时间的日志 ```bash :no-line-numbers # 查看最近一小时的日志 sudo journalctl --since="1 hour ago" # 查看最近一天的日志 sudo journalctl --since="1 day ago" # 查看指定服务最近30分钟的日志 sudo journalctl -u nginx --since="30 min ago" ``` ### 查看指定时间段的日志 ```bash :no-line-numbers # 查看从某个时间点开始的日志 sudo journalctl -u nginx --since="2024-01-01 00:00:00" # 查看一个时间区间的日志 sudo journalctl -u nginx --since="2024-01-01 00:00:00" --until="2024-01-01 12:00:00" # 查看昨天的日志 sudo journalctl --since="yesterday" --until="today" ``` ### 按日志级别(优先级)筛选 只显示错误、警告等特定级别的信息,可以帮助你快速定位问题。 ```bash :no-line-numbers # 显示所有错误级别的日志 sudo journalctl -p err # 显示指定服务的警告及以上级别的日志 sudo journalctl -u nginx -p warning ``` 常见的级别有(严重程度从高到低): * `emerg` (0) * `alert` (1) * `crit` (2) * `err` (3) * `warning` (4) * `notice` (5) * `info` (6) * `debug` (7) ## 组合使用 假设你的 `myapp.service` 在昨天下午3点后启动失败,你可以这样查: ```bash :no-line-numbers # 查看该服务昨天下午3点至今的所有错误和警告信息,不要使用分页器查看 sudo journalctl -u myapp \ --since="yesterday 15:00:00" \ --until="today" \ -p warning \ --no-pager ``` ## 配合 `systemctl status` 使用 通常,我们先使用 `systemctl status` 快速查看服务状态和最近的几条日志。 ```bash systemctl status nginx ``` 这个命令的输出末尾通常会包含该服务最新的几条日志信息。如果这些信息不够,我们再使用 `journalctl -u nginx` 进行更详细的查看。 ## 日志持久化 默认情况下,`journald` 将日志存储在 `/run/log/journal/` 中,这意味着**重启后日志会丢失**。 要启用持久化存储,执行: ```bash sudo mkdir -p /var/log/journal sudo systemctl restart systemd-journald ``` 之后,日志会永久保存在 `/var/log/journal/` 目录下。 --- --- url: /programming/linux/nohup.md --- ## 输出重定向 在Linux中,**输出重定向**是将命令的输出重定向到文件或其他设备的过程,用`>`符号来实现。 其中: * `0`表示标准输入(stdin) * `1`表示标准输出(stdout) * `2`表示标准错误输出(stderr) * 直接输入文件名表示输出到文件,想要重定向到前面的`0`、`1`、`2`,需要加上`&`符号。 * `/dev/null`是一个特殊的设备文件,表示空设备,任何写入到它的数据都会被丢弃。 * `/dev/zero`是一个特殊的设备文件,表示零设备,读取它会无限返回空(`0x00`)。(一般用于输入重定向) 那么我们就有: ```bash :no-line-numbers # 将ll命令的标准输出和标准错误输出分别重定向到文件 ll 1>info.txt 2>error.txt # 其中的1可以省略,就是我们常见的形式 ll >info.txt # >表示覆盖文件,>>表示追加到文件后面 ll >>info.txt # 将ll命令的标准错误输出重定向到标准输出 ll 2>&1 ``` ## nohup后台运行 `nohup`命令用于在Linux中后台运行一个命令,并且使其在用户退出后继续运行,它的英文全称是no hang up(不挂起)。格式如下: ```bash :no-line-numbers nohup go run main.go & ``` 其中的`&`表示将命令放到后台运行。 `nohup`命令会将命令的标准输出和标准错误输出重定向到`nohup.out`文件中。如果`nohup.out`文件不可写,输出重定向到`$HOME/nohup.out`文件中。 如果想要将输出重定向到其他文件,可以使用上文的`>`符号: ```bash :no-line-numbers nohup ./main >output.log 2>&1 & ``` 想要停止运行,首先要使用`ps aux | grep`命令查找进程的PID,然后使用`kill`命令停止进程。 --- --- url: /programming/linux/rsync.md --- # rsync命令的使用 `rsync`是Linux系统下的数据镜像备份工具,采用快速增量备份机制,支持本地复制,也可以通过SSH、rsync协议与其他主机进行远程同步。 它的用法如下: ```bash rsync -rlgoDzvci --delete dist/ user1@192.168.1.2:/var/local/nginx/www/ ``` 这些参数的含义如下: | 参数 | 含义 | |----------------------------|-----------------------------------------------------------------------------| | `-r` / `--recursive` | 递归复制目录 | | `-l` / `--links` | 保留符号链接,不跟随链接复制目标内容 | | `-g` / `--group` | 保留文件所属组 | | `-o` / `--owner` | 保留文件所有者,通常需要 root 权限 | | `-D` | 等价于 `--devices --specials`,保留设备文件和特殊文件,如 FIFO、Unix socket | | `-z` / `--compress` | 传输时压缩,主要对网络传输有意义 | | `-v` / `--verbose` | 输出详细过程 | | `-c` / `--checksum` | 用校验和判断文件是否变化,而不是默认的“大小 + 修改时间”;更准但更慢 | | `-i` / `--itemize-changes` | 逐项输出变更摘要,比如 `>f+++++++++`、`.d..t......` | | `--delete` | 删除目标端多余的文件/目录,使目标端和源端一致 | 所以合起来等价于: ```bash :no-line-numbers rsync \ --recursive \ --links \ --group \ --owner \ --devices \ --specials \ --compress \ --verbose \ --checksum \ --itemize-changes \ --delete \ dist/ user1@192.168.1.3:/var/local/nginx/www/ ``` 源和目标都可以是本地地址或者远程地址。 在正式开始执行前可以加上 `-n` 或者 `--dry-run` 参数,表示只输出变更摘要,不实际执行。 值得注意的是,**路径末尾是否有斜杠的含义需要区分** * `rsync -r /src/ /dst/`:复制 `/src/` 里面的内容到 `/dst/` * `rsync -r /src /dst/`:把 `/src` 这个目录本身复制到 `/dst/src` --- --- url: /programming/note/concurrency.md --- # 并发编程漫谈 近几年,越来越多的人都说,OOP不再像以前那么火了,事实的确是这样的。作为一个程序员,我们应该有寻根究底的习惯,而不是一味的随大流。到底是什么样的一种背景下,导致OOP的热度慢慢减退呢?面对这样一种趋势,我们应该做什么?本文的前半部分主要是对OOP和FP的简要介绍,后半部分用代码的形式举一些简单的例子来说明一下我的思考。这里要特别说明的是,本文主要还是以“科普”为主,后续的文章中才会涉及一些高端的算法。 ## 前言 像C++、C#、Java这样,把事物抽象成类(class)和对象(object),通过建立这种模型来进行编程的方法就叫做面向对象的编程(OOP,Object Oriented Programming)。从很早以前,可能追溯到上个世纪90年代,一直到前几年,OOP都是非常流行的一种编程模式,把事物抽象成类和对象,确实和平时正常的思维模式很相像。对象的成员变量(或称之为域),对应着事物的一系列属性或特征;而对象的方法(或函数),正好对应了事物的行为和动作。 ## 并发编程的重要性 这里要引用经典的摩尔定律(摘自百度百科): > 摩尔定律是由英特尔(Intel)创始人之一戈登·摩尔(Gordon Moore)提出来的。其内容为:当价格不变时,集成电路上可容纳的元器件的数目,约每隔18-24个月便会增加一倍,性能也将提升一倍。换言之,每一美元所能买到的电脑性能,将每隔18-24个月翻一倍以上。这一定律揭示了信息技术进步的速度。 > > 尽管这种趋势已经持续了超过半个世纪,摩尔定律仍应该被认为是观测或推测,而不是一个物理或自然法。预计定律将持续到至少2015年或2020年。然而,2010年国际半导体技术发展路线图的更新增长已经在2013年年底放缓,之后的时间里晶体管数量密度预计只会每三年翻一番。 按照目前的趋势,很难再像以前那样继续以这样的增速再提高CPU的单核性能。现在的CPU不得不更趋向于横向发展,也就是越来越多的核数。我记得我上初中那会儿,听说双核的CPU都有一种很了不起的感觉,十年过去了,到如今,我自己用的个人PC机,都是六核十二线程的。现在比较先进的i9-9980XE甚至都已经达到了16核32线程。单线程编程最多充分调用起一个核的性能,其他的核完全浪费了,在这样一种趋势下,多线程编程的重要性就越发的重要了。 随着并发编程的引入,在考虑到多个线程对同一内存进行读或写的操作时,必然会引发竞争问题,因此这些OOP语言,都有着“锁”的存在,以解决并发读写的竞争问题。在高并发的情况下,“锁”会有一个严重的诟病:不管什么类型的锁,在同一时间都只有一个线程(也可能是少量的几个线程)能够正常执行这段代码,其他线程都将阻塞住,直到持有锁的线程处理完毕,将锁释放掉。然而,因为阻塞的存在,假设逻辑处理器的个数翻了10倍,代码运行的效率并不能接近翻10倍。这个可能说的有些抽象,在文章后半段将会有例子说明。 ## 一种缓解阻塞影响的方法——函数式编程 函数式编程(FP,Functionnal Programming)的重点在于函数,将一些变量(或者说就是对象)传入函数中,结果返回出来一个新的对象。换句话说,经过这个函数,原始对象不会发生任何形式的更改。展开一点说,如果想要返回一个改变了的对象,那就只能重新新建一个对象,这个对象的其他属性和原对象一模一样,部分属性按照需求是不一样的,将这个新的对象返回出来;如果这个对象经过了这个函数完全没变化,那么(作为编译器的优化)可以考虑把这个原对象的指针返回出来,因为不管任何函数都不会将对象进行任何形式的改变,所以你也不需要担心原对象变了。因为对象不会发生改变,所以也就不存在“线程”的说法了(在OOP中,线程和进程的区别在于,同一进程的不同线程之间是可以互相访问内存地址的)。 进程之间,完全通过互相发消息进行通信,这样只需要一个消息队列就可以解决这个问题。你可以简单的用一个BlockingQueue来实现消息队列,而且它的队列头只有自己进程会进行读访问,队列尾可能会有多个进程进行写访问,在这样的条件下我们还可以对这个消息队列进行一些可以想象的优化,这将会比OOP的锁的效率要高得多。 现在比较常用的函数式编程语言主要有Haskell、Clean、Erlang和Miranda等。其实,相比各个知名的OOP语言,他们都不是怎么出名。 相比于OOP,函数式编程有明显的优点和缺点:缺点很明显,每次对象发生变化都需要创建一个新的对象,这个操作相比OOP的直接修改变量的值而言会大大降低运行效率。但是他也有他独特的优点: * 在并发编程时,不需要像OOP那样频繁的使用锁,可以有效的保证并发性能,同时避免死锁问题。这一点在多人合作的项目中的重要性尤为突出 * 进程间的通信方式比较单一,因此对进程的管理相比OOP要容易一些 * 进程间的通信方式比较单一,因此从多进程编程扩展到分布式部署也颇为容易 * 统一使用消息队列进行进程间通信,因此可以对消息队列进行有针对性的优化 * 因为不需要面向对象的思维方式,因此初学编程的人上手较快。这一点在公司人力成本上非常具有优势 我们有一句古话叫做“取长补短”,从这些函数式编程的优点中,我们能不能得到一些启发,来对我们的OOP代码进行一些优化,以方便将来对代码进行并发扩展呢?下面就来举一些简单易懂的例子,来说明一些初步的并发优化思路。 ## 我们开始上代码了 前几年一直排名第一的语言就是Java了,尽管近年来被号称“无所不能”的Python反超,但是Java仍然能够稳稳把握住第二的位置。本文就以Java为例,来简要介绍一些优化思路,对于其他语言,同理类推即可。 ### 第一个例子:减少使用锁 据说这是某游戏公司的数值策划面试题: > 某游戏要举办一次抽卡活动,有10种不同的卡,每次抽卡可以抽到这10种卡的一张(已抽到的卡可能重复抽到),每种卡被抽到的概率都是十分之一。当玩家集齐十种卡各一张时,便可以兑换大奖(玩家是不能把卡片给别人的,并且每个玩家最多只能兑换一次大奖,也就是说抽到重复的卡只能浪费了)。请问,玩家想要抽齐一套,所需抽卡次数的数学期望。 这道题对于一个数值策划而言,是一个复杂的概率论+无穷级数求和问题,这里就不详细列出解题过程了。但是对于程序员而言,这种概率问题我们完全可以采用“暴力破解”的方法——用随机数去模拟大量玩家抽卡,把平均抽取的次数统计出来就行了。于是可以得到这样的代码: ```java import java.util.*; public class Test { private static Random rand = new Random(); private static int count = 0; public static void main(String[] args) { long time = System.currentTimeMillis(); cal(); System.out.println("平均次数:" + count / 10000000.0); System.out.println("耗时:" + (System.currentTimeMillis() - time) / 1000.0); } private static void cal() { boolean[] buf = new boolean[10]; for (int i = 0; i < 10000000; i++) {//用一千万个玩家进行模拟抽卡 Arrays.fill(buf, false); for (int c = 0; c < 10;) {//十张卡抽齐则跳出循环,下一个人 int index = rand.nextInt(10); count++;//每random一次,相当于抽了一张卡 if (!buf[index]) { c++;//如果抽到不重复的卡,则加一 buf[index] = true; } } } } } ``` 以上代码简单易懂,这里也不花费篇幅解释了。可以看到,一共模拟了1000万次。大概输出结果如下: ``` 平均次数:29.2929306 耗时:3.301 ``` 打开任务管理器,我们可以看到CPU根本没有跑满。很显然,因为我们的CPU是多核的,单线程的情况下,最多也只能跑满一个核。 那么,怎么把这个算法变成多线程的呢?一个笨办法就是,把`count`的数据类型从`int`换成`AtomicInteger`,把`cal`方法用多个线程启动,比如100个线程,每个线程只需要模拟10万次,等所有线程都跑完再打印`count`的值。代码就变成这样: ```java import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; public class Test { private static Random rand = new Random(); private static AtomicInteger count = new AtomicInteger(0); private static CountDownLatch countDownLatch = new CountDownLatch(100); public static void main(String[] args) throws Exception { long time = System.currentTimeMillis(); for (int i = 0; i < 100; i++) new Thread(Test::cal).start(); countDownLatch.await(); System.out.println("平均次数:" + count / 10000000.0); System.out.println("耗时:" + (System.currentTimeMillis() - time) / 1000.0); } private static void cal() { boolean[] buf = new boolean[10]; for (int i = 0; i < 100000; i++) {//用十万个玩家进行模拟抽卡 Arrays.fill(buf, false); for (int c = 0; c < 10;) {//十张卡抽齐则跳出循环,下一个人 int index = rand.nextInt(10); count.getAndIncrement();//每random一次,相当于抽了一张卡 if (!buf[index]) { c++;//如果抽到不重复的卡,则加一 buf[index] = true; } } } countDownLatch.countDown(); } } ``` 这个`CountDownLatch`是Java5就有的一个跨线程的计数器,一开始设置了100,每个子线程跑完会调用`countDown`使计数器减一,主线程跑到`await`的时候会阻塞,直到计数器减到0才会继续往下跑,打印最终的结果。代码看起来不错,而且CPU跑到了100%,看起来很完美。然而输出结果是这样的: ``` 平均次数:29.2966694 耗时:36.256 ``` 居然耗时达到了单线程的10倍多!显然这是有问题的,那么问题出在哪了呢? 我们观察一下不难发现,整个代码中一共使用了三处锁,`AtomicInteger`,`Random`,`CountDownLatch`。其中`CountDownLatch`在整个过程中每个线程各触发了一次,使用频率很低,不需要进一步优化了。我们看看另外两个 * `AtomicInteger`是一个自旋锁,感兴趣的话可以去了解一下什么是CAS,这里我们只需要知道这里使用了锁。自旋锁在超高并发的情况下性能非常糟糕,这里100个线程同时访问,并且总计调用了约2.9亿次。 * `Random`的底层实现是这样的:首先有一个初始的随机数种子,然后每次会根据当前的种子值生成一个随机数,生成之后这个种子会变,要把变了的种子存回去。取当前的种子值、计算随机数值、把新种子存回去的过程中,其他进程可能访问和修改了这个种子,为了线程安全,这里就需要加锁了。JDK中采用了自旋锁解决这个线程安全问题,很像上面的`AtomicInteger`。同样,在本例中,100个线程同时访问,并且总计调用了约2.9亿次。 那么我们就可以根据这两个锁的问题,对代码进行优化了。 * 针对`AtomicInteger`,我们可以在每个线程自己用自己的变量来统计次数,统计结束之后,再统一加到`AtomicInteger`中。这样,100个线程最终只调用了100次`AtomicInteger`的加法运算。 * 针对`Random`,Java7提供了一个`ThreadLocalRandom`类,可以生成一个只属于本线程的`Random`对象,每个线程各自调用自己的`Random`对象,就不需要对种子加锁了。而且,用`ThreadLocalRandom`类生成的`Random`对象,把自旋锁去掉了,因为在正常使用的情况下是不会出现线程安全问题的。这样,本来需要并发访问2.9亿次的`Random`对象,我们不再需要并发访问了。 * 我的代码启动了100个线程,而我的电脑显然没有100个逻辑处理器,于是就会出现频繁的线程切换,线程切换是需要额外消耗资源的。我们完全可以只启动等于电脑逻辑处理器个数的线程,每个线程做完一个任务再做下一个任务。我们常说的“线程池”就是用来解决这个问题的。 优化后的代码如下: ```java import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; public class Test { private static AtomicInteger count = new AtomicInteger(0); private static CountDownLatch countDownLatch = new CountDownLatch(100); public static void main(String[] args) throws Exception { ExecutorService es = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); long time = System.currentTimeMillis(); for (int i = 0; i < 100; i++) es.submit(Test::cal); countDownLatch.await(); es.shutdown(); System.out.println("平均次数:" + count.get() / 10000000.0); System.out.println("耗时:" + (System.currentTimeMillis() - time) / 1000.0); } private static void cal() { int count = 0; Random rand = ThreadLocalRandom.current(); boolean[] buf = new boolean[10]; for (int i = 0; i < 100000; i++) {//用十万个玩家进行模拟抽卡 Arrays.fill(buf, false); for (int c = 0; c < 10;) {//十张卡抽齐则跳出循环,下一个人 int index = rand.nextInt(10); count++;//每random一次,相当于抽了一张卡 if (!buf[index]) { c++;//如果抽到不重复的卡,则加一 buf[index] = true; } } } Test.count.getAndAdd(count); countDownLatch.countDown(); } } ``` 简单解释一下,`Executors.newFixedThreadPool`可以获得一个线程数固定的线程池,后面的`availableProcessors`方法可以得到电脑的逻辑处理器的个数。举个例子,我的CPU的型号是i7-8700,6核12线程的,所以这里就创建了一个12线程的线程池。 在我的电脑上,最后的运行结果是: ``` 平均次数:29.2871288 耗时:0.307 ``` 计算速度大约是单线程代码的11倍,还是很令人满意的。 上面这个例子,总结一下:尽量**减少使用锁**。有的时候,稍微多花一点内存空间,就可以减少很多锁的使用。 ### 第二个例子:不妨结合一下“分治法” 所谓分治,就是分而治之的意思。一提到这个词,我们首先想到的就是几种经典的排序算法,例如快速排序、堆排序等。我后来想了一下,感觉排序算法放在这里举例子不太合适,因为排序算法在最坏情况和最好情况下的耗时相差很大,不太好说明问题。因此我这里就换了这样一个例子: ```java public class Test { public static void main(String[] args) throws Exception { long time = System.currentTimeMillis(); int result = fib(45); System.out.println("fib(45) = " + result); System.out.println("耗时:" + (System.currentTimeMillis() - time) / 1000.0); } private static int fib(int i) { if (i <= 1) return 1; return fib(i - 1) + fib(i - 2); } } ``` 这是个很常见的斐波那契数列。斐波那契数列的正常解法是动态规划,只需要把数列的最后两个值记下来,每次累加就行。我们这里为了研究多线程性能,就直接用强行递归。结果如下: ``` fib(45)= 1836311903 耗时:3.828 ``` 那如何用多线程来解决这个问题呢?有这样一个思路:每次递归的时候,启动一个新的线程,用新的线程计算递归的值。顺带把上面例子中的线程池用上,可以得到这样的代码: ```java :no-collipsed-lines import java.util.concurrent.*; public class Test { private static ExecutorService es; public static void main(String[] args) throws Exception { es = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); long time = System.currentTimeMillis(); int result = fib(45); es.shutdown(); System.out.println("fib(45) = " + result); System.out.println("耗时:" + (System.currentTimeMillis() - time) / 1000.0); } private static int fib(int i) throws Exception { if (i <= 1) return 1; Future task1 = es.submit(() -> fib(i - 1)); Future task2 = es.submit(() -> fib(i - 2)); return task1.get() + task2.get(); } } ``` 看上去似乎没什么问题,然而运行起来之后,发现迟迟不出结果。是运算太慢了吗?我们把任务管理器打开一看,发现CPU使用率根本没有增加,这显然是程序卡在了奇怪的地方。 回忆一下之前的说的线程池,固定启动少量的线程,执行任务,其中一个任务跑完了之后,再从任务队列里取下一个任务来执行。观察我们的代码,这里我们把前一个线程阻塞住,等两个子线程返回了之后,利用返回值继续进行计算。随着我们启动的线程越来越多,而线程池的大小是一个固定值(比如我的电脑就是12),排满之后,12个线程池全部阻塞住了,并没有空余的线程再去跑新的任务了,整个程序就卡住了。 我们现在想要的是,在线程阻塞的时候,当前任务放在一边,先去执行别的任务。好在,Java给我们提供了`ForkJoinPool`(我们需要的特殊的线程池),以及`RecursiveAction`(对应`Runnable`)和`RecursiveTask`(对应`Callable`)。 ```java import java.util.concurrent.*; public class Test extends RecursiveTask { public static void main(String[] args) { long time = System.currentTimeMillis(); int result = fib(45); System.out.println("fib(45) = " + result); System.out.println("耗时:" + (System.currentTimeMillis() - time) / 1000.0); } private static int fib(int i) { if (i <= 1) return 1; RecursiveTask task1 = new Test(i - 1); RecursiveTask task2 = new Test(i - 2); task1.fork(); task2.fork(); return task1.join() + task2.join(); } private Test(int i) { this.i = i; } private int i; @Override protected Integer compute() { return fib(i); } } ``` Java 8 这里做了代码上的简化,我们不再需要显式创建一个`ForkJoinPool`。它会自动创建,并且我们调用`RecursiveAction`和`RecursiveTask`的`fork`方法时,它会自动的检测当前线程所在的线程池,结果发现我们并没有使用一个符合要求的线程池,就会把新建的线程放进这个自动创建的`ForkJoinPool`去;调用`join`方法时,如果阻塞了不能立即返回值,则会把当前进程放回线程池的队列中去,换一个新的任务执行。 执行了一下试试,发现: ``` fib(45) = 1836311903 耗时:34.461 ``` 在多线程的情况下,竟然还是变慢了!这个结果令人感到很不愉快。这里就要提到两个技巧了: * 重新看一下`fib`函数,不难发现,我们新启动了两个线程之后,主线程直接阻塞住了,等待子线程返回。仔细想一想,主线程似乎完全没有必要阻塞,我们可以把其中一个子线程的任务直接在主线程跑就好了,这样可以节约差不多一半的线程调度的开销。 * Task的创建和插入线程池队列都有不小的开销。我们可以发现,对于线程的拆分明显太过于频繁,没必要每次调用`fib`方法都新建一个新的任务。我们可以适当的进行优化,比如当`i < 20`时,我们就不要再拆分线程了,直接进行递归计算。 优化之后的代码如下: ```java import java.util.concurrent.*; public class Test extends RecursiveTask { public static void main(String[] args) { long time = System.currentTimeMillis(); int result = fib(45); System.out.println("fib(45) = " + result); System.out.println("耗时:" + (System.currentTimeMillis() - time) / 1000.0); } private static int fib(int i) { if (i <= 1) return 1; if (i <= 20) return fib(i - 1) + fib(i - 2); RecursiveTask task2 = new Test(i - 2); task2.fork(); return fib(i - 1) + task2.join(); } private Test(int i) { this.i = i; } private int i; @Override protected Integer compute() { return fib(i); } } ``` 运行看一下结果: ``` fib(45) = 1836311903 耗时:0.662 ``` 相比单线程代码,快了5~6倍。 总结一下,我们得到这样一个经验:在使用分治法的时候,**减少不必要的任务创建,合理调整任务划分的粒度**。 ## 后记 展望一下未来,随着CPU核数的增多,多线程编程就显得越来越重要了,这是显而易见的。另外,有了多线程编程的基础,我们很容易能把它扩展到分布式计算,无非就是多线程内存直接访问推广到不同机器间消息互通。随着5G的推广,通信延迟将大大降低,云计算一定比现在会有更广泛的用途。打个比方,我们将北京的一个机房和深圳的一个机房的计算机群连接在一起,在目前的云计算环境下,还是有一定的网络延迟的;而将来在5G的环境下,相比现在,网络延迟将会大大降低。这进一步展现了并发编程在未来的重要作用。 --- --- url: /programming/note/fork_join_pool.md --- # 回溯问题的多线程解法 [上一篇文章](concurrency.md)主要讲了一些并发编程的简单例子。本文进一步扩展,就以常见的N皇后问题,来展示一下如何对回溯问题进行多线程求解。 题目摘自LeetCode第51题: > n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。 > > 给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。 > > 下图为8皇后问题的一种解法: > > ![ 8 皇后问题的一种解法](/programming/8-queens.png) 首先我们从穷举考虑,每个格子只有放和不放两种情况,那么一共有2^n\*n^种情况,对于每种情况进行判断,是否满足条件,满足则输出出来。看似可行,但是想一想,如果是个8皇后问题,就要遍历2^64^次,这个计算量确实有点大。 简单观察一下,可以发现,显然每一排只能放一个皇后,那么如果第一排第一个格子放了皇后之后,第一排的其他格子就不用尝试了,这样下来,遍历次数就减少为n^n^了,对于8皇后问题,减少到8^8^也就是2^24^了,确实减少了很多计算量。 再进一步考虑,如果第一排的第一个位置放了皇后,那么后面每排的第一个位置都不用考虑了。接着第二排如果放了第三个位置,那么后面每排的第三个位置都不用考虑了。这样遍历次数就变成了n!次,又大大减少了计算量。 还可以进一步考虑,如果第一排的第一个位置放了皇后,那么一条斜线都不用考虑了。不光往右斜线不用考虑了,往左斜线也不用考虑了。这样又进一步减少了计算量。 好了,上面分析了这么多,很好理解,但是用代码确实不容易写出来。怎么写呢。一行只能放一个,一列只能放一个,这个用代码很容易实现。关键问题在于斜着方向,我们继续观察一下,不难发现,同一斜线上的两个格子,行号加列号的和相等(或行号减列号的差相等)。通过这个就很好写代码了,例如,如果行号加列号等于3的格子有棋子了,那么其他行号加列号等于3的格子就不能放棋子了。这样,我们不难得到如下代码: ```java import java.util.*; public class Solution { public static void main(String[] args) { long time = System.currentTimeMillis(); System.out.println(new Solution().solveNQueens(14).size()); System.out.println("耗时:" + (System.currentTimeMillis() - time)); } private boolean[] sum; private boolean[] diff; private boolean[] line; public List> solveNQueens(int n) { sum = new boolean[n * 2 - 1];//用来存放已经放置棋子的格子的行号加列号 diff = new boolean[n * 2 - 1];//用来存放已经放置棋子的格子的行号减列号 line = new boolean[n];//用来存放已经放置了棋子的列 List> result = new ArrayList<>(); solveNQueens(result, n, new ArrayList<>()); return result; } private void solveNQueens(List> result, int n, List list) { //list作为回溯用的缓存,存储了每一行的第几列放置了皇后 int j = list.size(); if (j == n) { List strings = new ArrayList<>(); for (int i : list) { char[] ch = new char[n]; for (int x = 0; x < n; x++) ch[x] = x == i ? 'Q' : '.'; strings.add(new String(ch)); } result.add(strings); return; } list.add(0); for (int i = 0; i < n; i++) { if (line[i]) continue; if (sum[i + j]) continue; int diffVal = i - j + n - 1;//减了可能是负值,加一个n-1使其变为非负 if (diff[diffVal]) continue; //上面几行的意思很明确,如果同一列有皇后了则跳过,如果同一斜线有皇后了则跳过 line[i] = true; sum[i + j] = true; diff[diffVal] = true; //上面三行用以标记这一列和这一斜线上有皇后了 list.set(j, i);//第j行的第i列放置了皇后 solveNQueens(result, n, list);//将list缓存传下去递归 line[i] = false; sum[i + j] = false; diff[diffVal] = false; //把标记清除掉 } list.remove(j); } } ``` 因为打印所有答案太过冗长,我们退而求其次打印了答案的数量。用14皇后进行测试,可以得到如下的结果: ``` 365596 耗时:4414 ``` 上面就是所谓的回溯法:在递归之前,先将现在的状态标记下来,然后递归,递归后再还原回原来的状态。代码很容易看懂,不细讲。 接下来我们将它优化成多线程代码,利用前一篇文章的`ForkJoinPool`。先贴代码,对着代码说。 ```java public class Solution extends RecursiveTask>> { public static void main(String[] args) { long time = System.currentTimeMillis(); System.out.println(new Solution().solveNQueens(14).size()); System.out.println("耗时:" + (System.currentTimeMillis() - time)); } private boolean[] sum; private boolean[] diff; private boolean[] line; private int n; private List list; private List> result; public List> solveNQueens(int n) { sum = new boolean[n * 2 - 1];//用来存放已经放置棋子的格子的行号加列号 diff = new boolean[n * 2 - 1];//用来存放已经放置棋子的格子的行号减列号 line = new boolean[n];//用来存放已经放置了棋子的列 list = new ArrayList<>();//list作为回溯用的缓存,存储了每一行的第几列放置了皇后 result = new ArrayList<>();//用以存储最后的返回值 this.n = n; return compute(); } public Solution() { } //拷贝构造函数 public Solution(Solution s) { n = s.n; list = new ArrayList<>(s.list); sum = Arrays.copyOf(s.sum, s.sum.length); diff = Arrays.copyOf(s.diff, s.diff.length); line = Arrays.copyOf(s.line, s.line.length); result = new ArrayList<>(); } protected List> compute() { int j = list.size(); if (j == n) { List strings = new ArrayList<>(); for (int i : list) { char[] ch = new char[n]; for (int x = 0; x < n; x++) ch[x] = x == i ? 'Q' : '.'; strings.add(new String(ch)); } result.add(strings); return null; } List>>> tasks = new ArrayList<>(); list.add(0); for (int i = 0; i < n; i++) { if (line[i]) continue; if (sum[i + j]) continue; int diffVal = i - j + n - 1;//减了可能是负值,加一个n-1使其变为非负 if (diff[diffVal]) continue; //上面几行的意思很明确,如果同一列有皇后了则跳过,如果同一斜线有皇后了则跳过 line[i] = true; sum[i + j] = true; diff[diffVal] = true; //上面三行用以标记这一列和这一斜线上有皇后了 list.set(j, i);//第j行的第i列放置了皇后 if (j <= 2) tasks.add(new Solution(this).fork());//将list缓存传下去递归 else compute();//减少JoinForkPool的拆分,直接用本线程算 line[i] = false; sum[i + j] = false; diff[diffVal] = false; } list.remove(j); //以下是合并解集 for (ForkJoinTask>> task : tasks) { List> val = task.join(); if (val != null) result.addAll(val); } return result; } } ``` 可以看到,优化后的代码和之前的代码很相似,区别在于,首先我们把`n`、`list`、`result`都提出来作为成员变量了,为了方便封装,这个很好理解。`compute`方法将原先的递归改为了用`JoinForkPool`的`fork`函数,新启动一个线程运算。在启动线程之前,就要将目前的缓存数据拷贝到新的对象中去,会调用上面的“拷贝构造函数”。为了不进行过多的拆分,上面的代码只在棋盘的前三行进行了拆分(`j <= 2`),这样对于14皇后问题将会拆分出14^3^个线程。(尽量减少拆分是`JoinForkPool`的一个原则,这在[上一篇文章](concurrency.md)中有讲)。 这样一来,我们就把回溯法的问题优化成了多线程解法。 测试结果如下: ``` 365596 耗时:1380 ``` 耗时大约是之前的三分之一,还是非常令人满意的。 回溯法对于多线程优化而言很具有代表性,所以写了这篇文章来简要讲解一下。不过回溯法相对来说是一个过于简单的算法了,因此后续打算写一些大型的算法,将其进行多线程优化。一来练练手,二来和大家分享一下。不过,大型算法需要耗费非常多的篇幅来讲解,可能需要分多篇文章进行解释,还是期待我能够坚持下去了。 --- --- url: /programming/note/license.md --- # 开源协议简介 **开源协议**通常指的是经过**开放源代码倡议**组织批准,符合其**开源定义**的许可证。 ## 一、宽松型许可证 这类许可证对使用者限制最少,基本上只要署名,你就可以自由使用、修改、分发,甚至作为闭源的商业软件发布。 1. **MIT License** * 非常简短、宽松,是目前最流行的许可证之一,基本上只需要在副本中包含原始版权声明和许可文本。常见的jQuery, .NET Core, Rails等都在使用这个开源许可证。 2. **Apache License 2.0** * 比MIT更详细,提供了明确的专利授权和保护,并且要求对修改过的文件进行说明,要求包含署名、修改声明、专利授权。常见的Android, Apache HTTP Server, Kubernetes等都在使用这个开源许可证。 3. **BSD Licenses** * **BSD 2-Clause “Simplified” License**: 和MIT非常相似,非常宽松。 * **BSD 3-Clause “New” or “Revised” License**: 在2条款基础上,增加了一条“不得使用著作权人的名字为衍生品背书”的条款。 ## 二、GPL类许可证 这类许可证要求任何包含本项目代码的衍生作品,在分发时都必须以相同的许可证完全开源。不同的版本限制略微有所区别。 1. **GNU General Public License (GPL)** * 分为v2和v3两个版本,非常著名和严格。要求任何分发衍生作品的行为,都必须公开整个作品的完整源代码。常见的Linux内核, GCC, Git等都在使用这个开源许可证。 2. **GNU Lesser General Public License (LGPL)** * 主要用于软件库。在GPL的基础上,如果只是作为库链接使用,则不一定需要开源。但如果你修改了LGPL库本身,则必须将修改部分开源。常见的Glibc, GTK等都在使用这个开源许可证。 3. **GNU Affero General Public License (AGPL)** * 它弥补了GPL的一个“漏洞”:如果一个基于GPL的软件被部署在网络上提供服务(SaaS),而没有“分发”二进制文件,那么GPL不要求其开源。但AGPL要求这种情况也必须开源。常见的MongoDB (早期版本), Nextcloud等都在使用这个开源许可证。 ## 四、公共领域与极简许可证 这类许可证旨在提供比MIT等更极致的自由。 1. **Unlicense** * 这更像是一种“放弃版权,投入公共领域”的声明,而不是一个典型的许可证。它旨在最大程度地摆脱版权限制。但请注意,在某些法域,完全放弃版权可能不被法律认可。 2. **WTFPL – Do What The F\*\*\* You Want To Public License** * 极其简短和直白,本质上就是“爱干嘛干嘛”。但它可能在某些企业环境中因为其名称和缺乏明确的法律条文而不被接受。 3. **CC0 – Creative Commons Zero** * 这不是一个软件许可证,而是一种放弃版权及相关权利,将作品置于“公共领域”的法律工具。在软件领域,它常被用于数据、代码片段等。 ## 如何选择 * **主流项目**:优先选择 **MIT、Apache 2.0、GPL(v2/v3)**,因为它们认知度高,法律条款清晰。 * **库/框架**:如果想被广泛使用,选 **MIT** 或 **Apache 2.0**;如果想保护库本身的改进,选 **LGPL**。 * **想最大程度“传染”开源**:选 **GPL**。 * **针对SaaS/网络服务**:考虑 **AGPL**。 * **个人小工具/脚本**:如果你完全不在意,可以用 **Unlicense** 或 **WTFPL**。 你也可以前往[Open source initiative](https://opensource.org/)和[Choose a license](https://choosealicense.com/)获取详细的内容。 --- --- url: /programming/note/neuquant.md --- # 一种图像量化算法的分享 ## 什么叫图像量化? 假设我们有一张彩色图像,例如RGB图像,我想要存成gif格式,以压缩文件大小。gif图片首先有一个调色板,这个调色板有256种颜色,然后每个像素只需要一个字节(0-255)来表示选用调色板上的哪个颜色。 那么现在问题就来了,一个RGB图像有256^3^种颜色,而gif图像只有256种颜色,如何将256^3^种颜色映射到256种颜色上呢?这就是**图像量化**的问题。 **图像量化**肯定是**有损**的,但我们希望尽可能地让图像不过于失真。那么思路就是:变化比较多的颜色,我们在调色板上多分配一点,而变化比较少的颜色,我们在调色板上少分配一点。这样一来,调色板上的颜色就会比较均匀地分布在整个RGB空间中。 Go、Java等语言在标准库中实现了一些图像量化算法,大家可以直接使用。除此之外,还有一些其它的图像量化算法。 ## Neuquant Anthony H. Dekker 在 1994-08-01 发表的论文 《Kohonen neural networks for optimal colour quantization》 中提到了一种基于神经网络的方法。具体的思路大家感兴趣可以查看论文。 我使用Go语言对这个算法进行了实现: 简单做一个测试,看看失真程度如何: ```go :no-collapsed-lines package main import ( "gitee.com/CuteReimu/neuquant" "image/gif" "image/jpeg" "os" ) func main() { f, _ := os.Open("1.jpg") defer f.Close() img, _ := jpeg.Decode(f) f2, _ := os.Create("1.gif") defer f2.Close() _ = gif.Encode(f2, img, neuquant.Opt()) } ``` ![neuquant.jpg =x300](/programming/neuquant.jpg)![neuquant.gif =x300](/programming/neuquant.gif) 可以看出,有一定的失真,但是整体效果还是可以的。 在这个基础上,我们应该就很容易做出gif动画,比如说: ```go package main import ( "gitee.com/CuteReimu/colortools" "gitee.com/CuteReimu/neuquant" "image" "image/color" "image/gif" "image/jpeg" "os" ) func main() { f, _ := os.Open("1.jpg") defer func() { _ = f.Close() }() img, _ := jpeg.Decode(f) result := &gif.GIF{} for j := 0; j < 360; j += 30 { c := make([]color.Color, 361) p := make([]float64, 361) for i := 0; i <= 360; i++ { c[i] = &colortools.HSV{H: float64(i + j), S: 1.0, V: 0.5} p[i] = float64(i) / 360.0 } img1 := colortools.NewLineGradChgColorImage(img.Bounds(), c, p, img.Bounds()) img2 := colortools.Screen(img1, img) img3 := neuquant.Paletted(img2) result.Image = append(result.Image, img3) result.Delay = append(result.Delay, 10) } f2, _ := os.Create("1.gif") defer func() { _ = f2.Close() }() _ = gif.EncodeAll(f2, result) } ``` ![neuquant_animate.gif =x300](/programming/neuquant_animate.gif) --- --- url: /programming/note/nndl.md --- # 初识神经网络 ::: important 重要 这篇文章是我看完《Neural Networks and Deep Learning》一书后的个人读书笔记。本文的风格更加偏向于对有一定数学基础的人的科普,并非专业文章。如果你是相关专业的学生或者专业人士,在学习和研究过程中应该考虑研读权威书籍,请勿以本文作为参考。如有理解错误,欢迎指正。 ::: 本文主要围绕着如何识别手写数字展开。具体来讲就是给定一个28x28的纯灰度图的手写数字图像,如何判断它是0-9中的哪个数字。我们将使用一个简单的神经网络来实现这个功能。 ## 感知器 ```mermaid graph LR D(( )):::bigCircle A((x₁)):::noShape --> |w₁| D B((x₂)):::noShape --> |w₂| D C((x₃)):::noShape --> |w₃| D D --> E((output)):::noShape classDef bigCircle stroke:#333,stroke-width:2,r:50; classDef noShape fill:transparent,stroke:transparent; ``` $$ \text{output} = \begin{cases} 0 & \text{if } \sum_jw_jx_j \le \text{threshold} \\ 1 & \text{if } \sum_jw_jx_j > \text{threshold} \end{cases} $$ 如图,我们有三个输入$x_1$、$x_2$、$x_3$(0或1),分别对应权重$w_1$、$w_2$、$w_3$。它们的线性组合经过一个激活函数后输出一个结果output(0或1)。 我们令 $z = w_1x_1 + w_2x_2 + w_3x_3$,如果 $z \le \text{threshold}$,则 $\text{output} = 0$;如果 $z > \text{threshold}$,则 $\text{output} = 1$。这里threshold是我们给定的一个阈值。 这就是一个感知器。 举个例子,比如今天我想去某个地方玩,我正在决定是否要去。我到底去不去,取决于以下三种因素: * 今天天气如何,1代表晴,0代表下雨 * 目的地离地铁近不近,1代表近,0代表远 * 有没有人一起去,1代表有,0代表无 然后我给这三种因素分别赋予一个权重: * 假设我并不是很在意天气,令$w_1 = 2$ * 假设我也并不介意离地铁站距离,令$w_2 = 2$ * 假设我很关心有没有人一起去,令$w_3 = 6$ 那么,根据不同的阈值,我们可以得到不同的结果: * 如果阈值为5,说明不管天气如何、不管距离地铁站远近,我去不去只取决于有没有人一起去。 * 如果阈值为3,说明如果天气好,我就去;如果天气不好,但是离地铁站近并且有人一起去,我也去。 接下来,我们用多个感知器组合起来,就可以形成一个感知器网络: ![nndl2](/programming/nndl2.svg) 对于上面这个式子 $$ \text{output} = \begin{cases} 0 & \text{if } \sum_jw_jx_j \le \text{threshold} \\ 1 & \text{if } \sum_jw_jx_j > \text{threshold} \end{cases} $$ 我们做两个简写: * 我们把$(w_1,w_2,\cdots,w_j)$看做一个向量$\vec{w}$,$(x_1,x_2,\cdots,x_j)$看做一个向量$\vec{x}$。它们一一对应相乘后求和,可以用向量的点乘表示$\vec{w}\cdot\vec{x}$ * 我们把threshold移项到左边来,用$b=-\text{threshold}$代替它 这样,我们可以写成下面这种漂亮的形式 $$ \text{output} = \begin{cases} 0 & \text{if } \vec{w}\cdot\vec{x} + b \le 0 \\ 1 & \text{if } \vec{w}\cdot\vec{x} + b > 0 \end{cases} $$ ### 与非门 我们来讨论一种特殊的感知器: ```mermaid graph LR D((3)):::bigCircle A((x₁)):::noShape --> |-2| D B((x₂)):::noShape --> |-2| D D --> E(( )):::noShape classDef bigCircle stroke:#333,stroke-width:2,r:50; classDef noShape fill:transparent,stroke:transparent; ``` * 输入$x_1=1$、$x_2=1$,则$\vec{w}\cdot\vec{x} + b=-2-2+3<0$,输出$0$ * 输入$x_1=1$、$x_2=0$,则$\vec{w}\cdot\vec{x} + b=-2+3>0$,输出$1$ * 输入$x_1=0$、$x_2=1$,则$\vec{w}\cdot\vec{x} + b=-2+3>0$,输出$1$ * 输入$x_1=0$、$x_2=0$,则$\vec{w}\cdot\vec{x} + b=3>0$,输出$1$ 当且仅当$x_1$和$x_2$都为$1$时,最终输出才为$0$,这就是经典的**与非门**。既然有了**与非门**,那么我们很容易就能得到**加法器**,这里就不展开介绍了。 我们有这样一个好消息:因为计算机就是由一个一个与非门搭建起来的,所以只要我们搭建足够大的感知器网络,就能使它和其他计算设备一样强大。\ 但坏消息是:它看上去只不过是一种新的与非门。\ 不过还有一个好消息是:我们可以设计一个算法,来自动调整权重$\vec{w}$和偏置$\vec{b}$。这就是所谓的学习算法。 ## 神经网络 如果$\vec{w}$或者$\vec{b}$产生微小的改变,会引起output的微小改变,我们就可以通过微调,使输出变得越来越好,这个过程就是学习的过程。 但是前面的感知器并不可以,因为当结果跨过阈值的时候,输出会发生突变。我们要想个办法平滑这种突变。 我们进行一个微小的改动,我们允许0到1之间的任何值作为输入,并且令: $$ \text{output} = \sigma(z) \equiv \frac{1}{1+e^{-z}}\\ $$ 其中$z = \vec{w}\cdot\vec{x} + b$。这个就叫做**sigmoid function**,函数图像如下: ![nndl3](/programming/nndl3.png) 这样一来,我们用光滑的曲线代替了阈值threshold的突变,同时也将输出output限制在了0和1之间。 接下来,我们要重新定义一下神经网络: ![nndl4](/programming/nndl4.png) 其中,最左边一列叫做**输入层**,最右边一列叫做**输出层**,中间的叫做**隐藏层**。每一层的每一个节点都和下一层的每一个节点相连,这个就叫做**全连接**。 ### 使用神经网络识别手写数字 ![nndl5](/programming/nndl5.png) 我们的每个数字都是大小为$28\times 28=784$个像素的灰度图像,我们将它展平为一个784维的向量$\vec{x}$。我们将每个像素点的灰度值(0-255)除以255,变成0-1之间的数值,其中0.0代表白色,1.0代表黑色。 一共有0-9十个输出,每个输出的值可以视为“是这个数字的可能性”,我们简单地把输出值最大(“可能性最大”)的那个输出作为对应判断的数字。 ::: note 注意 这里说的“可能性”并不是数学意义上的“概率”,只是我为了方便理解而做的一个比喻。 ::: MNIST 数据分为两个部分。第一部分包含 60000 幅用于训练数据的图像。这些图像扫描自 250 人的手写样本。这些图像是 $28\times 28$ 大小的灰度图像。第二部分是 10000 幅用于测试数据的图像,同样是 $28\times 28$ 的灰度图像。我们将用这些测试数据来评估我们的神经网络学会识别数字有多好。为了让其有好的测试表现,测试数据取自和原始训练数据不同的另外一组 250 人。这有助于确保我们的系统能识别那些没有看到训练数据的人写的数字。 我们首先给$\vec{w}$和$\vec{b}$的每个分量填上均值为0、方差为1的随机数。 对于训练集的数据,输入和输出都是已知的,例如这个数字是 6 ,对应的 0-9 的输出应该是 $(0, 0, 0, 0, 0, 0, 1, 0, 0, 0)$ 。根据输入的数据,我们带入计算,也可以得到一个输出。这两个输出之间会存在一个误差。 我们用这样一个函数来描述误差的大小: $$ C(w, b) \equiv \frac{1}{2n}\sum_x||y(x) - a||^2 $$ 这个就叫做**代价函数**。**代价函数**是误差的体现。 我们的目的是调整$\vec{w}$和$\vec{b}$,使代价函数最小。 ![nndl6](/programming/nndl6.png) 我们知道,只要沿着梯度的方向移动,就可以使C减小。 对于二维的$\vec{v}=(v_1, v_2)$,我们有: $$ \begin{aligned} \Delta C & = \frac{\partial C}{\partial v_1} \Delta v_1 + \frac{\partial C}{\partial v_2} \Delta v_2\\ \nabla C & = \left(\frac{\partial C}{\partial v_1}, \frac{\partial C}{\partial v_2}\right)^T\\ \Delta C & \approx \nabla C \cdot \Delta \vec{v}\\ \Delta \vec{v} & = -\eta \nabla C\\ \vec{v} \to \vec{v}' & = \vec{v} - \eta \nabla C \end{aligned} $$ 对于多维的$\vec{v}=(v_1, v_2, \cdots, v_n)$,上述公式同样有效。 有了这个公式,我们就可以很容易得到每一层的每一个$w$和$b$的值了: $$ \begin{aligned} \vec{v} \to \vec{v}' & = \vec{v} - \eta \nabla C\\ w_k \to w_k' & = w_k - \eta \frac{\partial C}{\partial w_k}\\ b_k \to b_k' & = b_k - \eta \frac{\partial C}{\partial b_k} \end{aligned} $$ 这样一来,我们的目标就变成了,求出代价函数$C$对$w_k$和$b_l$的偏导数。 先做一个简单的定义: ![nndl7](/programming/nndl7.png) ![nndl8](/programming/nndl8.png) $b^l_j$表示第$l$层第$j$个神经元的偏置,$a^l\_{j}$表示第$l$层第$j$个神经元的激活值(经过**sigmoid function**之前的值)。 ## 反向传播算法 推到过程较为复杂,这里省略,直接给出结论。 1. 首先正向推导,得出输出层误差$\delta$ 2. 下一层的误差推倒当前层的误差:$\delta^l = ((w^{l+1})^T\delta^{l+1})\odot\delta'(z^l)$ 3. 求出代价函数的梯度:$\frac{\partial C}{\partial w^l\_{jk}} = a^{l-1}\_k \delta^l_j$ 和 $\frac{\partial C}{\partial b^l_j} = \delta^l_j$ 4. 沿梯度方向按照某一个步长更新权重和偏置 为此,我用 Java 实现了这样一个识别手写数字的逻辑: ::: warning 注意 这个实现是纯手写的,只为检验我对算法的理解。如果真的想要学习神经网络,建议使用 TensorFlow、PyTorch 等深度学习框架。 ::: --- --- url: /programming/note/paradigm.md --- # 聊聊编程范式 当你学会了越来越多的编程语言后,你就会发现,不同的语言的设计思路、用法习惯天差地别。为什么会有这么大的差别?这是一件很有趣的事情,今天我们就来聊聊。 值得一提的是,本文将会涉及很多种不同的语言,读者在看到自己熟练的语言时,会觉得非常简单,但看到自己不熟悉的语言时,看了这篇文章后,你又会觉得非常神奇。 *** ::: important 重要 在阅读本文之前,首先要了解堆和栈的概念,本文就不花篇幅讲解了。如果对此不了解,建议先去查阅相关资料,弄明白这两个重要概念。这里用简短的话进行总结: * **栈**是函数的临时工作区,函数里定义的局部变量都放在这里。函数执行完毕,栈上的变量**自动销毁**。 * **堆**是内存中的一片自由区域,用于存放需要长期存在、跨多个函数使用的变量。堆上的内存不会自动消失,你必须**手动释放**。 栈的**分配和释放速度**比堆快得多(只需移动指针),因此临时变量优先使用栈可以提升性能。但访问速度上两者差异不大。 ::: 我们先以 C++ 为例,这是一个 C++ 的函数: ```cpp int *test() { int a = 5; return &a; } ``` 它返回一个指向`int`的指针。这段代码有问题吗?编译的时候,是能够编译通过的。但是运行起来就有可能有问题,有可能没问题。 我们在声明`int a = 5;`时,`a`这个变量会被分配到栈上,在函数退出时,它就被释放掉了,但是我们这里却把它的地址返回了出来。其它代码拿到它的地址时,如果这个地址暂未被其它变量占用,那目前存放的还是`5`,那就没有问题;如果这个地址已经被别的变量占用了,那拿到的就是别的变量存放的值。 那如何避免这个问题呢?就要显式将其分配在堆上: ```cpp int *test() { int *a = new int(5); return a; } ``` 使用`new`关键字,就会将变量强制分配在堆上,离开了函数它仍然存在。但这会引发一个新的问题,你在用完了这个变量后,要手动调用`delete a;`将其释放,否则它的内存就一直被占用,这样程序运行久了内存占用会越来越大,这就是“内存泄漏”。 内存管理问题在 C++ 中是一个很麻烦的事情,这也让很多程序员对 C++ 感到非常头痛。于是,就有了 Java 语言: ```java :no-line-numbers title="Test.java" public class Test { public int test1() { int a = 5; return a; } public Person test2() { Person p = new Person(); return p; } } ``` 我们看到这里有两个函数:`test1`和`test2`。由于 Java 中没有指针的概念,因此我们在例子中只能将`a`和`p`返回。那这两个函数有什么不同呢?在`test1`中,由于`a`是一个基础类型`int`,它会以值的方式返回出来,就如同 C++ 中的按值返回。在`test2`中,由于`p`是`new`出来的一个对象,并不是基础类型,我们得到的是它的引用,因此返回的也是该对象的引用。 那我们需不需要手动释放`p`呢?在 Java 中是不需要的,因为 Java 有\*\*垃圾回收(Garbage Collection,简称GC)\*\*机制。它通过某种机制判断,如果`p`不再会被用到了,就会自动将其释放。这就解决了 C++ 程序员诟病的内存管理问题。 那么其它语言呢?其实大部分**应用层开发语言**(如 Java、Python、Go、JavaScript)都有 GC 机制,你不需要手动管理内存。除非你对性能有严格的要求,否则在大部分业务需求中,都没必要使用 C++ 等系统编程语言进行开发,用其它一门有 GC 机制的语言将会大大提高你的开发效率。 我们继续来看一段 Java 代码: ```java :no-line-numbers title="Test.java" public class Test { public void test() { for (int i = 0; i < 10; i++) { new Thread(new Runnable() { public void run() { System.out.println(i);// [!code error] } }).start(); } } } ``` 这段代码的用途就是启动 10 个线程,然后分别打印 0 ~ 9 到控制台。我们先不说它有并发问题,首先它直接在红色的这一行编译报错。报错的原因很简单,`i`是基本类型的变量,它在另外的线程使用了,运行到`System.out.println(i);`这一行的时候,有可能外面的函数已经返回了,`i`可能都已经释放了,这时候你就不该拿到`i`。甚至,只要你进入了一个匿名类,即使你不是启动了一个新的线程,Java 为了严谨,你也不能用外面的对象。有没有方法能用呢?有,就是加个`final`: ```java :no-line-numbers title="Test.java" public class Test { public void test() { for (int i = 0; i < 10; i++) { final int ii = i; new Thread(new Runnable() { public void run() { System.out.println(ii); } }).start(); } } } ``` 为什么这里不报错了呢?其实这里是一个语法糖,它实际上是偷偷藏了一个参数,把`ii`作为一个参数传进去,就可以被里面引用了。只不过它没有明确的写出来这个参数,它实际的效果就等于通过参数的方式传入进去,这就叫做**语法糖**。 那为什么不加`final`不行呢?因为如果这个变量可能会变的话,你就无法保证里面读到的值是变之前还是变之后的,你就不知道作为一个参数传进的去语法糖应该取变之前的还是变之后的,这就会乱套。Java 为了避免产生更多的问题,直接不允许你这样写。 或者我们有一种更加通用的写法: ```java :no-line-numbers title="Test.java" public class Test { public void test() { for (int i = 0; i < 10; i++) { int[] ii = new int[]{i}; new Thread(new Runnable() { public void run() { System.out.println(ii[0]); } }).start(); } } } ``` 为什么这样写可以呢?看看这个`new`关键字,很显然数组会被分配到堆上,进行引用传递,因此就可以传出去使用了。 通过上面的例子,我们可以得到这样一个结论:想要把函数内部的局部变量的引用(或指针)传递到函数外面去,只要**把它分配到堆上**即可。 接下来我们用 Go 实现相同的代码: ```go func test() { for i := 0; i < 10; i++ { ii := i go func() { fmt.Println(ii); }() } } ``` 这里的`ii`可以变,但是在 Go 中这样传进去却不会报错。这是为什么呢?因为 Go 的 GC 机制和 Java 有些不太一样。Go 有一个**逃逸分析**的机制,它无论是什么类型,它都会去分析。如果它只在本函数中使用,函数结束了就不会再用了,那分配在栈上就足够了;如果离开了函数还可能被使用,那就只能分配在堆上。上面这个例子中,`ii`肯定就只能分配在堆上了。 既然有了 GC 和逃逸分析,Go 就可以放心地写出我们最上面 C++ 那样写会有问题的代码: ```go func test() *int { a := 5 return &a } ``` 逃逸分析会发现`a`会在函数外面使用,它就会被分配在堆上。而由于 GC 机制,它也会正确地自动回收。 Java 也有逃逸分析,为什么不能这样做呢?有以下几个原因: * 一方面,Java 没有指针,什么类型是值传递,什么类型是引用传递,在 Java 中是定死的。 * 另一方面,Java 的逃逸分析和 Go 不一样,它只是会分析那些本该分配在堆上的`new`出来的对象,如果该对象只在函数内部使用,那分配在堆上就有些浪费了,JVM 会做优化,将其打散分配在栈上,离开函数时自动就释放了。这是 JVM 的**优化手段**,不是语言规范保证的特性。 * 最重要的一方面是,一个语言的规范从一开始就定死了,很多 JVM 底层能够运行都建立在这个规范的基础上。语言的设计者如果修改以前的规范,可能会导致底层出现大问题。 *** 接下来我们聊一聊**类(Class)**和**对象(Object)**,这是\*\*面向对象编程(OOP)\*\*中很重要的概念。当然了,下面的内容和上文的关系非常紧密,一定要先将上文理解通透。 在 Java 中,我们可以这样声明类,以及类里面的**域(Field)**和**方法(Method)**: ```java :no-line-numbers title="Person.java" public class Person { private int age = 0; public void grow() { age++; // 长大了一岁 } public int getAge() { return age; } } ``` 这段代码很简单,就不详细讲解了。 JavaScript 语言我们肯定不陌生,现代浏览器都支持 JavaScript 语言。换句话说,浏览器就是 JavaScript 语言的一种运行环境。 早期的 JavaScript 语言没有类的概念。为了写类似上面的那一段简单的 Java 代码,程序员需要构造非常复杂的**原型(prototype)链**,这是一个令人诟病的地方。于是,聪明的程序员们想到了另外一个写法: ```javascript :no-line-numbers function createPerson() { let age = 0; const grow = function() { age++; }; const getAge = function() { return age; }; return { grow, getAge }; } ``` 如果你没系统性地学过 JavaScript,对上述代码肯定很陌生。但实际上,它和上面的 Java 代码的`Person`类实现了完全一样的功能。如果你理解了上文中所说的逃逸分析,你就会发现`age`在这里由于会被传入其它函数中,因此在这个例子上它会“逃逸”,只能分配在堆中。这样,`createPerson`函数就会返回一个对象,对象有两个方法`grow`和`getAge`,这就和上面的 Java 代码想要表示的内容一模一样了。 那么 Go 能不能这样写呢?当然可以: ```go :no-line-numbers func createPerson() (func(), func()) { age := 0 grow := func() { age++ } getAge := func() int { return age } return grow, getAge } ``` 根据我们上文的知识,Go 这样写当然没有问题。但为什么 Go 很少有人这么写呢?因为 Go 的语言设计还是更建议使用`struct`并声明方法的方式,也就是在一定程度上类似 Java 中的类。 归根结底还是因为早期的 JavaScript 没有类的概念。现代的 JavaScript 有了类的概念,官方也建议大家优先使用类,但由于常年累月的积累,非常多的大型框架仍然使用了上面的那种写法。 回到文章的开头,为什么不同语言的范式相差那么多,想必大家应该也有一个感性的认识了。这就是编程神奇的地方。 --- --- url: /programming/note/proto.md --- 在编写网络应用时,服务端与客户端的协议设计是一个非常重要的环节。本文将介绍业界常见的通信协议设计思路。 ## 序列化与反序列化 我们知道,对于面向对象编程(OOP),在程序的内存中,数据一般是`struct`、`object`等封装好的类型。而在网络上进行交互,却只能使用二进制流。因此就存在这样两个问题: * 如何将内存数据转化为二进制流?我们称之为**序列化** * 如何将二进制流转化为内存数据?我们称之为**反序列化** 关于序列化与反序列化,我个人习惯将其分类为两种,一种是**可视化**的,内存对象序列化之后,仍然是易读的,方便调试代码时进行查看。一种是**纯字节**的,内存对象的每个基础字段都被转化为字节流(例如`int32`占4个字节),此方式效率更高,得到的序列化后的字节流长度更短,在性能方面更有优势。 ### 可视化 最常用的可视化序列化方式就是JSON,它支持字符串、数字、布尔值、`null`等基本类型,也支持数组、对象等组合类型: * 数字、布尔值、`null`等类型,直接写即可,例如`10`、`true`、`null`等。 * 字符串使用双引号括起来,例如`"hello world"`。特殊字符需要进行转义,转义方式同大部分编程语言,例如`"\""`。 * 数组使用方括号括起来,数组的每个元素可以是任何类型,例如`[1, 2, 3]`。 * 对象(键值对)使用花括号括起来,键必须是字符串,值可以是任何类型。键和值之间用冒号分隔,键值对之间用逗号分隔,例如`{"name": "Alice", "age": 30}`。 上述类型之间都可以互相嵌套使用,没有什么限制。任何一对标记之间都可以插入空格、换行等空白符,方便阅读。举个例子: ```json :no-line-numbers [ {"id": 1, "name": "Alice", "age": 30}, {"id": 2, "name": "Bob", "age": 25}, {"id": 3, "name": "Charlie", "age": 35} ] ``` 可以看出,非常可读且易懂。JSON基本可以涵盖我们常用的各种数据结构,并且主流语言的标准库中基本都提供了JSON的序列化和反序列化函数,方便我们直接使用。 值得一提的是,任何一个JSON数据结构放在Javascript代码中都可以直接使用: ```js var a = [ {"id": 1, "name": "Alice", "age": 30}, {"id": 2, "name": "Bob", "age": 25}, {"id": 3, "name": "Charlie", "age": 35} ] console.log(a[1].name) ``` 这也是为什么在网页编程(浏览器端编程)时,大家普遍喜欢使用JSON作为序列化和反序列化的格式。 除了JSON外,XML、YAML、TOML等都可以用来序列化和反序列化,除了编码格式不一样以外,其它方面基本上都是大同小异了。这里拿XML举个例子: ```xml :no-line-numbers ``` 当然,你也可以针对自己的业务逻辑自创一个序列化方式,例如金融行业非常喜欢使用的类FIX报文格式: ```text :no-line-numbers |id|name|age| |1|Alice|30| |2|Bob|25| |3|Charlie|35| ``` 尽管这个设计对不同数据结构的支持明显不如上述JSON、XML等,但金融行业的数据有它独到的特点:数据量非常大(行数非常多)、字段值固定(列数固定)、无需复杂的嵌套数据结构。使用JSON、XML等会带来每一行都需要加上字段名的冗余,反而使用这样的自定义结构会更加有性能优势。 ### 纯字节 大多数语言都提供了将内存数据结构转化为纯字节流的方式,例如Java中的`Serializable`和`ObjectOutputStream`,Go中的`encoding/gob`包,在使用单个语言的项目中非常好用。但是,一旦发生跨语言(例如服务端使用Java或Go,客户端使用C#),这种方法就行不通了。 那么,有没有一个支持各种语言的序列化和反序列化方式呢? 有的,有这样一个非常常用的方式:protobuf,使用文档在:。它的使用方式也很简单: 首先前往[下载页](https://github.com/protocolbuffers/protobuf/releases),根据你的操作系统下载最新版本并解压,解压后的`bin`目录下就会有一个名为`protoc`的可执行文件。为方便使用,建议将`bin`目录加入系统环境变量的`PATH`中。 接下来,创建一个`.proto`文件,用来声明你的结构体,例如`example.proto`: ```protobuf title="example.proto" message Person { string name = 1; int32 id = 2; string email = 3; } ``` 然后我们就可以使用`protoc`根据声明文件来生成代码了: ```bash # [!code word:--java_out=.] protoc --java_out=. example.proto ``` 注意这里的`--java_out`参数用来指定生成Java代码的路径,你也可以用`--go_out`等来指定生成对应语言代码的路径。这样就会生成一个同名的代码文件,在上述例子中会生成一个`Person.java`文件,其中包含`Person`类的定义以及序列化和反序列化代码。 ::: note 注意 protobuf官方已经内置支持了C++、C#、Java、Kotlin、Objective-C、PHP、Python、Ruby等语言,对于[Dart](https://github.com/google/protobuf.dart)、[Go](https://github.com/protocolbuffers/protobuf-go)语言,也提供了官方实现,但需要额外安装相应的插件。对于其它语言,你也可以在Github上的其它仓库找到一些非官方实现。 ::: 生成好后,我们就可以使用生成的代码进行序列化了,我们以Java为例: ```java Person john = Person.newBuilder() .setId(1234) .setName("John Doe") .setEmail("jdoe@example.com") .build(); OutputStream output = new FileOutputStream(args[0]); john.writeTo(output); ``` 将序列化后的二进制流发给对方,对方接收后,就可以用生成的代码(可能是另一种语言)来进行反序列化,我们以C++为例: ```cpp Person john; fstream input(argv[1], ios::in | ios::binary); john.ParseFromIstream(&input); int id = john.id(); std::string name = john.name(); std::string email = john.email(); ``` 上述听起来复杂,总结一下,其实很简单: 1. 安装`protoc` 2. 编写`.proto`文件定义数据结构 3. 使用`protoc`生成服务端和客户端代码 4. 使用生成的代码进行序列化和反序列化 *** 到这里,我们已经解决了序列化和反序列化的问题,但还有一个问题没有解决:在通信过程中,有多种不同类型的消息(登录、心跳、各种类型请求),如何进行区分呢? ## 包的组装 服务端和客户端的通信一般分为两种,短连接(例如HTTP)和长连接(例如纯手写TCP、Websocket)。对于HTTP,就很简单了,我们只需要使用不同的url来区分是什么请求,接下来就可以对不同的请求解析不同的消息参数了。这里我们主要讨论长连接通信。 ### Websocket Websocket的每一个包已经从协议层拆解好了,所以应用层拿到包的时候都是一段段独立的字符串/字节数组。我们需要在这个字符串/字节数组中,既能够区分是什么请求,又包含请求参数。 如果采用JSON、XML等进行序列化和反序列化,最常用设计思路就是在外面再套一层: ```json :no-line-numbers { "type": 10, "data": [ {"id": 1, "name": "Alice", "age": 30}, {"id": 2, "name": "Bob", "age": 25}, {"id": 3, "name": "Charlie", "age": 35} ] } ``` 其中,`type`字段用来区分这条消息是什么请求,一般使用数字或者字符串,`data`字段是请求参数。 如果采用protobuf来进行序列化和反序列化,也可以考虑采用这个方案,外面套一层: ```protobuf :no-line-numbers import "google/protobuf/any.proto"; message Message { int32 type = 1; // 消息类型 google.protobuf.Any data = 2; // 消息内容 } ``` 设计思路和上述JSON的例子类似。进一步地,对于强类型语言,其实`google.protobuf.Any`结构体中已经包含了消息类型,外面的`type`字段完全可以省略,之后在编程语言中使用类型断言等方式判断`data`的类型,从而区分是哪种消息。 但这种方案也有明显的缺点,实际上在`google.protobuf.Any`结构体中存储的消息类型是一个完全展开的可视化字符串,这样一来就完全失去了纯二进制序列化的优势(字节流长度短)。因此我们就有了另外一种方案,自行拼接: ```mermaid packet-beta 0-1: "Type" 2-31: "Data (不定长)" ``` 如上图所示,我们只需要提前约定,前2个字节(0-65535)用一个`short`类型的数字来代表上述所说的`type`,后续剩余的字节就是请求对象序列化后的二进制流即可。反序列化时,我们先截取前2个字节,转化为一个`short`类型的数字,以此来找到对应是哪种消息,然后再将后续的字节流反序列化为对应的对象即可。 ### 纯手写TCP 对于纯手写TCP,并不会像Websocket那样自动拆包,我们需要支持反序列化时能够将字节流正确地拆分成一个一个包。最常用的方式就是在包的开头添加包的长度,例如: ```mermaid packet-beta 0-1: "Len" 2-3: "Type" 4-31: "Data (不定长)" ``` 如上图所示,我们同样可以约定,前两个字节(0-65535)用一个`short`类型的数字来代表包的长度(如果包超过64K长度不够可以换成4个字节的`int`),后续内容和上文的Websocket中使用protobuf的方案一致即可:2个字节的`type`字段,剩余部分是序列化后的字节流。 这里使用Go语言给个简单的例子来展示一下发送端和接收端的代码: ```go {23} :no-collapsed-lines // 发送端 func (conn *Conn) write(messageType uint16, data proto.Message) { dataBuf := proto.Marshal(data) // 序列化 buf := make([]byte, len(dataBuf)+4) binary.LittleEndian.PutUint16(uint16(len(dataBuf)+2)) // 前2个字节写入包长度 binary.LittleEndian.PutUint16(buf[2:], uint16(传入messageType)) // 后续2个字节写入type copy(buf[4:], dataBuf) // 剩余部分即是序列化后的二进制流 conn.Write(buf) conn.Flush() } // 接收端 func (conn *Conn) read() proto.Message { // 前2个字节是包长度 lenbuf := make([]byte, 2) _, _ = io.ReadFull(conn, lenbuf) reqlen := binary.LittleEndian.Uint16(lenbuf) // 后续2个字节是type typeBuf := make([]byte, 2) _, _ = io.ReadFull(conn, typeBuf) reqType := binary.LittleEndian.Uint16(typeBuf) // 剩余部分二进制流需要反序列化 m := genMessageByType(reqType) // 根据type值,由预先加载好的消息类型映射生成对应的消息对象 buf := make([]byte, reqlen-2) _, _ = io.ReadFull(conn, buf) proto.Unmarshal(buf, m) return m } ``` 注意上述代码中的高亮行,需要在程序加载时就指定好哪个消息类型对应哪个消息对象,这个函数就需要自行实现(或写脚本生成)了。 ## 应用层方案设计 到这里,我们已经可以完整地进行服务器和客户端之间的通信了,也基本可以实现我们的功能需求了。但是,在大型项目中,我们需要考虑一些特殊情况,例如网络环境不好等问题。因此,在绝大部分项目中,都把协议分为三类: * 由客户端发送给服务端的**请求协议** * 对应上述请求协议的**返回协议** * 服务端主动发送给客户端的**通知协议** 从客户端角度看,为了及时确认是否断网,一般要求**请求协议**和**返回协议**一一对应,但凡某个请求没有收到返回,即可判定出现了异常,就可以采取断线重连等措施。 那么问题就来了,如何让请求协议和返回协议一一对应呢?其中一个方案就是提前约定好,例如采用“奇偶法”,请求协议的`type`用`2N`,对应的返回协议的`type`就使用`2N+1`。或者进一步地,由于**请求协议**一定是客户端→服务器,而**返回协议**一定是服务器→客户端,它们之间不会发生冲突,完全可以将一一对应的协议使用相同的`type`值。这是目前非常常用的一种解决方案。 我们继续考虑另外一个设计问题:如果服务端和客户端约定,当客户端进行一个非法请求后(例如玩家钱不够仍然要买东西),服务端返回一个**通用的错误协议**,这样客户端可以非常容易地根据**通用的错误协议**弹窗提示用户。这是一个十分合理的设计,可以降低客户端的设计复杂度,但会带来一个新的问题:如果客户端连续发多条请求,服务端返回一个**通用的错误协议**,客户端怎么知道它对应哪一个请求呢? 为了解决这个问题,也有一个非常通用的方案,客户端在**请求协议**中加一个编号,我们以JSON为例: ```json :no-line-numbers { "type": 10, "seq": 1, // [!code ++] "data": [ {"id": 1, "name": "Alice", "age": 30}, {"id": 2, "name": "Bob", "age": 25}, {"id": 3, "name": "Charlie", "age": 35} ] } ``` 服务端收到请求后,发送返回协议时,将编号原封不动返回回去,客户端就可以精准对应了。 对于protobuf,我们也可以在前面合适的位置添加2个字节,作为`seq`,例如: ```mermaid packet-beta 0-1: "Len" 2-3: "Type" 4-5: "Seq" 6-31: "Data (不定长)" ``` 到这里,对于现在非常常用的服务端-客户端协议设计方案,我们已经基本介绍完了。接下来给出几个例子,供大家参考: * 一种基于JSON和Websocket的设计,如上文所说,请求协议和返回协议一一对应:[协议定义](https://github.com/CuteReimu/th-bingo/tree/master/doc)、[发送和接收逻辑](https://github.com/CuteReimu/th-bingo/blob/master/src/main/kotlin/Dispatcher.kt)。 * [OneBot](https://11.onebot.dev/):一个广泛且通用的聊天机器人应用接口标准,如上文所说,请求协议和返回协议一一对应。 * 一种客户端和服务端完全异步的设计,不要求请求协议和返回协议一一对应:[协议定义](https://github.com/CuteReimu/TheMessage/tree/kotlin/src/main/proto)、[发送逻辑](https://github.com/CuteReimu/TheMessage/blob/da532b5d6ee281548e1e82aab5be5cb8adc69a52/src/main/kotlin/HumanPlayer.kt#L71-L96)、[接收逻辑](https://github.com/CuteReimu/TheMessage/blob/da532b5d6ee281548e1e82aab5be5cb8adc69a52/src/main/kotlin/network/ProtoServerChannelHandler.kt#L75-L106)。 --- --- url: /programming/note/search.md --- # 全文搜索算法杂谈 **全文搜索**指的是像搜索引擎一样,在大量文档中,找出和查询语句相关联的文档。 我们先讨论搜索一个字符串的情况,为什么顺序扫描检索的速度非常慢? 对于一个无固定格式的数据(例如一个文件),我们所知道的是文件里包含了哪些字符串,换句话说就是**文件→字符串**的映射。而我们搜索的时候,是已知字符串,欲求哪些文件包含了这个字符串,换句话说这是**字符串→文件**的映射。可以看出,这两者是恰恰相反的。 也就是说,如果我们能提前建立**字符串→文件**的映射,就可以大大加快检索速度。 ## 创建索引 创建索引的方法很简单,遍历所有文件,按照每个词进行划分,建立一个反向映射表即可。下面用简单的代码来说明一下: ```java // 假设我们已读取了多个文件,并将文件名和文件内容组成了一个映射: // Map files; var wordsToFiles = new HashMap>(); for (var entry : files.entrySet()) { String fileName = entry.getKey(); String content = entry.getValue(); for (String word : content.split("[ .,!]")) { // 按照空格和标点符号分割,如果是中文,可能需要一些特殊的分词算法 if (!wordsToFiles.containsKey(word)) { wordsToFiles.put(word, new LinkedList<>()); } wordsToFiles.get[word].add(fileName); } } ``` 其中词汇是key,包含指定词汇的文件名组成一个链表,作为这个词汇对应的value。 除此之外,我们还需要对文件进行进一步的一些操作。例如,对于英语,我们应该把单词统一变成小写,把复数形式、过去式等统一变成原词的词根形式。例如在中文中,我们也需要去掉一些例如“了”“呢”等无意义的词汇。 ### 分词 英文是以单词为单位的,单词与单词之间以空格或者逗号句号隔开,所以将一篇文档分成一个一个单词是非常容易的。而中文的语义比较特殊,很难像英文那样,一个汉字一个汉字来划分。 好在现在有非常多很成熟的开源中文分词算法库,我们可以直接使用。 ## 搜索和排序 经过以上的这些步骤,我们便可以建立起一个key是单词、value是文件名链表的映射关系,也就是我们说的**倒排索引**。这个索引我们在后续查找的过程中就可以重复利用了。 到了这一步,似乎我们已经可以根据单词找到我们想要的文档了。然而事情并没有结束,“找到文档”仅仅是全文检索的一个方面。如果仅仅只有一个或十个文档包含我们查询的字符串,我们的确找到了。但如果结果有成千上万个文档,哪个才是我们最希望找到的呢? 例如,我们打开一个搜索引擎,想要查找某个内容,结果会发现总共查找到几百万甚至几千万个结果。在如此多的结果中,如何将最关键的放在最前面呢? 这就引入了“打分”的机制。我们知道,一个文档由很多词(Term)组成,但是,不同的词的重要性不同。就比方说,例如两篇文档都包含`search`、`fulltext`,说明这两篇文档的相关性高。而如果两篇文档都包含`this`、`is`、`a`,这些词并不能影响两篇文档的相关性。 换句话说,不同的词应该有不同的权重(Weight)。那么我们应该怎么计算不同的词的权重呢?下面就来介绍具体方法: 影响一个词(Term)在一篇文档中的重要性主要有两个因素: * **词频率**(Term Frequency,简称tf):即此Term在此文档中出现了多少次,越大说明越重要。 * **文档频率**(Document Frequency,简称df):即有多少文档包含此Term,越大说明越不重要。 那么公式就是:$W = tf \times log(n/df)$ 当然了,这仅仅是一种最简单最经典的公式,实现全文检索系统的算法一般都会有自己的实现。 通过上述方法,我们就能知道一个词对于一个文档的重要性。接下来,我们把查询语句看作一片短小的文档,我们的问题就转化为如何比较查询语句和需要搜索的文档之间的相关性了,即**两篇文档的相关性**。 首先,一个文档中有多个单词,我们把每个单词的权重排列在一起,可以得到一个向量。 $$ \vec{V_1} = (W\_{11}, W\_{12}, W\_{13}, \cdots, W\_{1n}) $$ 然后,我们把查询语句也看作一个向量,就可以类似地得到: $$ \vec{V_2} = (W\_{21}, W\_{22}, W\_{23}, \cdots, W\_{2n}) $$ 注意,这里一定要每个单词的位置要一致。也就是说,$W\_{11}$和$W\_{21}$是同一个单词的权重,$W\_{12}$和$W\_{22}$是同一个单词的权重。如果缺少这个单词,就把位置空出来,这个位置填上0。这样,这两个向量就会有相同的维度。 那么如何判断两个向量之间的相似度呢?我们可以用他们的夹角$\theta$来表示。夹角越小,说明两个向量越相似。 我们又知道,夹角$\theta$越小,余弦值$\cos{\theta}$越大。而根据向量的点积公式,我们可以得到: $$ \begin{aligned} \cos{\theta} & = \frac{\vec{V_1} \cdot \vec{V_2}}{|\vec{V_1}| \cdot |\vec{V_2}|}\\ & = \frac{W\_{11}W\_{21} + W\_{12}W\_{22} + \cdots + W\_{1n}W\_{2n}}{\sqrt{W\_{11}^2+W\_{12}^2+\cdots+W\_{1n}^2} \times \sqrt{W\_{21}^2+W\_{22}^2+\cdots+W\_{2n}^2}} \end{aligned} $$ 这样一来,这个$\cos{\theta}$的值越大,就说明夹角$\theta$越小,两个向量越相似,即查询语句和文档的相关性越大。 --- --- url: /programming/note/vcard.md --- .vcf 文件,也称为 VCard 文件,是一种用于存储联系人信息的标准文件格式。它通常用于电子邮件客户端、手机通讯录和其他应用程序中,以便于导入和导出联系人数据。大部分手机和邮箱的通讯录都支持导入和导出 .vcf 文件。 一个简单的 .vcf 文件大致是这样的: ```vcard :no-line-numbers :no-collapsed-lines BEGIN:VCARD VERSION:2.1 N:;example;;; FN:example TEL;CELL:123456789 EMAIL;WORK:example@example.com END:VCARD BEGIN:VCARD VERSION:2.1 N:;test;;; FN:test TEL;CELL:123456789 EMAIL;WORK:test@test.com END:VCARD ``` 可以看出,.vcf 文件是由多个`BEGIN:VCARD`及`END:VCARD`块组成的,每个块表示一个联系人。中间的每一行都是`key:value`的形式。我们对几个特殊的`key`简单介绍一下: * `VERSION`:表示 VCard 的版本号。 * `N`:表示联系人的名字,是一个包含5个字符串的数组(中间用分号分隔),依次是 Last Name(姓)、First Name(名)、Additional Name(附加名)、Prefix(前缀)、Suffix(后缀)。 * `FN`:表示联系人的全名(Full Name)。 * `TEL`:表示联系人的电话号码,可以有多个,后面可以跟上`;CELL`(手机)、`;WORK`(工作电话)、`;HOME`(家庭电话)等。 * `EMAIL`:表示联系人的电子邮件地址,和`TEL`类似,可以有多个。 很多编程语言都有支持读写 .vcf 文件的库,比如 Python 的`vobject`库。下面是一个简单的 Python 代码示例,展示如何使用`vobject`库来读取和写入 .vcf 文件。 首先安装依赖: ```bash pip install vobject ``` 以下是一段生成 .vcf 文件的示例代码: ```python :no-collapsed-lines import vobject from types import SimpleNamespace all_vcards = [] vcard = vobject.vCard() n = vcard.add('N') n.value = SimpleNamespace(family='', given='example', additional='', prefix='', suffix='') fn = vcard.add('FN') fn.value = 'example' tel = vcard.add('TEL') tel.value = '123456789' tel.type_param = 'CELL' email = vcard.add('EMAIL') email.value = 'example@example.com' email.type_param = 'WORK' all_vcards.append(vcard.serialize()) vcard = vobject.vCard() n = vcard.add('N') n.value = SimpleNamespace(family='', given='test', additional='', prefix='', suffix='') fn = vcard.add('FN') fn.value = 'test' tel = vcard.add('TEL') tel.value = '123456789' tel.type_param = 'CELL' email = vcard.add('EMAIL') email.value = 'test@test.com' email.type_param = 'WORK' all_vcards.append(vcard.serialize()) # 将所有vCard数据写入一个单一的.vcf文件 with open('contract.vcf', 'w', encoding='utf-8') as f: f.writelines(all_vcards) ``` 运行这段 Python 代码后,会生成一个名为`contract.vcf`的文件,内容与本文开头的示例相同。 之后,就可以在手机或邮箱的通讯录中按照说明进行操作,将生成的 .vcf 文件导入。