[{"content":"TL;DR Agent 经常需要多路径探索、反复试错。数据分支就像给每次尝试准备一个独立的“实验场”，失败了可以直接丢弃，不影响其他分支。\n数据分支的关键是写时复制：创建时共享已有数据，发生修改时再分别保存变化，不必每次都复制整个数据库。\nXata 兼容性好，但分支能力较基础。Neon 在 Postgres 兼容和分支能力之间做了折中。\nDolt 和 MatrixOne 的分支操作更丰富。Dolt 在部分 SQL 执行性能上有所取舍；MatrixOne 则更偏 OLAP 场景。\n所以，没有哪种实现有绝对优势。怎么选，要看你更看重兼容性还是分支语义，更看重分支操作性能还是 SQL 执行性能。\n前言 在《AI Agent需要什么样的数据库？》和《对 Agent-First 数据库的畅想》中，我们讨论过在 Agent 应用下，数据库有哪些负载特征。那时我们认为，多路径探索、反复试错、频繁调试是 Agent 使用数据库主要特征之一，需要数据分支来替代传统的快照恢复机制。\n近一年随着 Claude Code 等 Agent 的火爆，这些特征也愈发明显。\n下面是一个典型的软件工程场景：\nAgent 正在开发一个忠诚度分级的新需求，根据客户情况把他们分成不同等级。开发过程涉及对数据库的表结构和数据做变更：\n  从 main 分支拉出特性分支 B1，先给数据库新增 tier 列，表示客户等级。\n  先从 B1 拉出 B2 分支，根据新开发的分级算法和支付历史计算客户等级，回填到 tier 列。随后进行测试回归，发现客户等级分布不符合预期，原因是分级算法有问题。\n  再从 B1 拉出 B3 分支，根据新的分级算法计算客户等级，回填到 tier 列。最后测试回归通过，需求回合到 main 分支。\n  这里的分支既包含了代码分支，也有数据库的数据分支。\n试想一下，如果没有数据分支，从第 2 步到第 3 步，还需要把已回填到 tier 列数据删除。效率低，也存在安全隐患。\n随着 Agent 走向成熟，Agent 应用场景也会从当前的 Vibe Coding 走向生产级的应用，数据分支的也将迎来更多的用武之地。比如：\n  故障复现：在生产环境复现导致故障的数据库事务。采用二分法创建分支 + 重放事务实现。\n  数据清洗：检测并修正数据库的数据错误，提升下游的任务质量。主 Agent 持续识别各数据表的错误，派生子 Agent 执行分支-\u0026gt;变更-\u0026gt;验证的清洗流程。\n  蒙特卡洛树搜索（MCTS）：常应用于金融、供应链管理、Text-to-SQL 等场景。每个搜索策略都可能涉及数据修改，都需要一个独立的数据分支。\n  仿真：与 MCTS 类似。\n  业界对数据分支的跟进热度，大都从 Databricks 以 10 亿美元收购 Neon 开始。之后，很多数据库都宣称支持了数据分支特性，它们各有各的实现方式。\n那么，哪种分支的实现才是更好的呢？\n本文将探讨数据分支不同的实现方式，对比它们的优缺点，尝试回答这一问题。\n数据分支实现技术分类 写时复制，Copy on Write（CoW），是实现数据分支绕不开的技术。根据 CoW 粒度的不同，我们可以把数据分支分成如下四类：\n  块设备 Block 级。代表产品 Xata，基于 ZFS 的 CoW 能力实现分支，数据库内核不感知，100% 兼容 Postgres。\n  数据库 Page 级。代表产品 Neon，基于 Page 快照 + 增量 WAL 实现分支，对 Postgres 做了存算分离改造，提供百毫秒级冷启动时延。\n  数据库 Chunk 级。代表产品 Dolt，基于 B 树变体的内容寻址树 Prolly Tree 实现分支，自研内核。\n  数据库 行 级。代表产品 MatrixOne，属于 HTAP 流派，更偏 OLAP。基于 LSM 引擎的 Append-only 特性实现 Tuple 级的 CoW。\n  文件系统 Block 级的生态兼容性最好，数据库 Tuple 级的分支语义最丰富。\nXata 的实现 Xata 基于 NVMe over Fabric（NVMe-oF） 协议构建存算分离架构。\n在 Xata 中，每个数据分支都有一个数据库实例，每个实例在存储层都独享一个块设备。块设备统一由 ZFS zpool 管理，每个块设备都会对应一个 zvol 卷。\n新创建的子分支的数据块 A, B, C 会引用到父分支，只有当子分支修改时，才会复制一份 A’, B’，从而实现了 Block 级的 CoW。\n分支的生命周期管理则由控制面完成，创建分支时，会创建子分支实例、创建新的 zvol 块设备，并让子分支实例通过 NVMe-oF 与块设备相连。\n从数据库实例的视角，它看到的仍是本地文件目录，也不感知分支的存在。\n所以，Xata 的策略是，数据库只管照常读写，把分支交给平台（ZFS 的存储块快照和克隆能力）。\n这样做最大的好处是数据库内核无感，理论上可以支持任何数据库，生态 100% 兼容。\n代价是，数据分支语义支持少，只能是最基础的 create、delete、checkout。\nNeon 的实现 Neon 是分支数据库领域的明星，它的存算分离、Log-as-Database 架构已经成为业界标杆。更多架构和原理细节，参考《AI Agent 需要什么样的数据库？》\nNeon 官方存储引擎说明\nNeon 使用 LSN 来做数据版本管理，每个数据变更都会有唯一的 LSN。所以，分支管理变得很容易，只要记住分支分叉的 LSN，就能维护整个分支血缘关系。\n基于 LSN 的分支管理也让 Neon 具备了 TimeTravel 能力，通过 Page 镜像 + WAL 增量回放，就能让分支回滚到任意的时间点。这是 Neon 比 Xata 多出的分支语义，rollback。\n父子分支共享分叉 LSN 之前的数据，分叉后会基于不同的 WAL 完成 Page 重建，所以，CoW 的粒度是 Page 级。\nNeon 改造了 Postgres 的存储引擎，把数据分支能力构筑在数据库内核，构建了基本完整的分支语义 create、delete、checkout、rollback。\nNeon 仍是经典的 B-Tree 引擎，仍适用于传统的数据库负载，而且计算层还是 Postgres 实例。\n所以，Neon 的策略是，够用的分支语义 + PostgresSQL 兼容。这种 Trade-Off 设计，也正是 Neon 广受欢迎的主要原因之一。\nDolt 的实现 Neon 让 Postgres 具备了数据分支能力，Dolt 则是面向数据分支重新设计了数据库。\nDolt 官方存储引擎说明\nDolt 走类似 Git 的分支管理路线，通过 DAG 来维护分支血缘，每次 commit 对应 DAG 中的一个节点。刚拉分支时，父子分支会指向同一 commit 点，随着数据写入会逐渐分化。\n这也意味着，Dolt 必须显式 commit 才能回滚。要做到 Neon 一样的 TimeTravel，就需要每次修改都 commit，但版本管理开销也会随之增大。\nDAG 只是管理，Dolt 实现数据分支语义的核心是 Prolly Tree，全称 Probabilistic B-Tree，也叫内容寻址树。\nProlly Tree 是 B-Tree 的变体，每个节点为一个 Chunk。Chunk 的大小通过内容定义分块算法动态划分，目标是 4K 大小。每个 Chunk 包含多行记录，且有一个内容哈希值。\n数据存储在叶子节点，非叶子节点存储的是叶子节点的内容哈希值。所以，只要有数据修改，必然会产生一颗新的 Tree。\n内容哈希值能够帮助 Dolt 快速定位到修改数据所在的 Chunk，因此非常适合用来做 diff 和 merge。\nDolt 已经不再是传统的 B-Tree 引擎，它专门为数据分支场景设计，具备了相当完善的分支语义，但代价是读写性能变差。\nDolt 官方博客数据，对比传统的 B-Tree，Prolly Tree 的读性能慢 20%，批量写性能慢 67%。特别是开启自动 commit 写时，性能慢 3 倍！\n所以，Dolt 的策略是牺牲一些 SQL 读写性能，换来更完善的分支语义支持。\nMatrixOne 的实现 前面提到的都是 OLTP 数据库，MatrixOne 则是基于 LSM-Tree 的 HTAP 数据库，更偏 OLAP。\nMatrixOne 官方原理说明 · Git4Data 论文\nLSM-Tree 的特点是 Append-only，任何一次数据新增、修改、删除都会被记录下来，而且能随机读，这就很适合做分支。\n WAL 也会记录任何一次数据变更，但只能靠顺序回放，无法高效随机读。\n MatrixOne 实现分支的核心机制就是 Append-only + MVCC。\nAppend-only 奠定了存储基础，父子分支共享不可变的数据对象，各自修改以增量记录的方式保存在新的数据对象中，实现了行级 CoW。\nMVCC 提供分支管理基础，能判断某个时间点哪些版本可见，结合分支元数据，实现父子分支各看各的数据。\n在此基础上，MatrixOne 很容易就实现了相对完善的分支语义。比如 diff、merge，可通过对父子分支的增量数据对象做一次按行聚合比较实现。\n所以，MatrixOne 很好地利用了 LSM-Tree 的 Append-only 特性，扩展了对数据分支的支持。\n评测对比 网上还没有对这 4 款数据库统一拉通测评，只能东拼西凑拿到一些信息。\n分支语义对比 在分支语义上，Dolt 和 MatrixOne 支持最丰富，Neon 够用，Xata 刚入门槛。\n分支创建性能对比 Xata 和 Neon 为每个分支都创建一个独立的计算实例，提供了最彻底的隔离。但因为涉及实例启动和建立连接，性能相对较差。Xata 官方数据为 1~2s，Neon 网上实测数据也在 1s 左右。\nDolt 和 MatrixOne 因为不涉及实例创建，分支创建时延更低，都达到了 10ms 左右。\nSQL 执行性能对比 Xata、Neon、MatrixOne 都没有对传统引擎内核做大改，只是扩展了对数据分支的支持，SQL 执行性能不会太差。\nDolt 把存储引擎换成了 Prolly Tree，是专门针对分支优化的设计。官方数据显示，Doltlite（把 Prolly Tree 替换到 SQLite 中）对比 SQLite，读性能慢 20%、批量写性能慢 67%、自动 commit 写性能慢 3 倍。\n最后 本文主要介绍了 4 类数据分支的实现方案。\nXata 生态兼容最好，但分支语义支持有限。\nNeon 是一个很好的折中方案，因此也最受欢迎。\nDolt 专门为数据分支设计，分支语义支持完善，分支创建性能高，但代价是 SQL 执行性能损耗大。\nMatrixOne 则是在 LSM-Tree 的基础上扩展了分支语义的支持，分支语义和性能都挺好，但生态较弱。\n所以，数据分支没有哪种实现有绝对的优势，它们都在各方面做了权衡。\n文章配图 配图由 ChatGPT + Figma 插件生成，可以在 用Keynote画出手绘风格的配图 中找到配图风格。\n 参考 [1] Inside Xatastor: ZFS + NVMe-oF for millions of Postgres databases, Xata\n[2] Deep dive into Neon storage engine, Neon\n[3] Storage Engine, DoltHub\n[4] How Fast Is DoltLite?, DoltHub\n[5] Git4Data: Database-Native Version Control for AI Agents, MatrixOrigin \u0026amp; Purdue University\n[6] BranchBench: Aligning Database Branching with Agentic Demands, Elaine Ang 等\n （完）\n","date":"2026-11-07T00:00:00Z","permalink":"https://yrunz.com/p/%E5%93%AA%E7%A7%8D-agent-%E6%95%B0%E6%8D%AE%E5%BA%93%E5%88%86%E6%94%AF%E5%AE%9E%E7%8E%B0%E6%9B%B4%E5%A5%BD/","title":"哪种 Agent 数据库分支实现更好？"},{"content":"前言 Claude Code 已经成为 Coding Agent 的事实标准，现今各类 Agent 都多多少少能看到它的影子。 Claude Code 的设计简洁、优雅：  Agent Loop 是大脑、核心，驱动着所有组件的运转。 Permission System 提供权限管理。 Tools \u0026amp; Execution Environment 是手脚，负责任务执行。 State \u0026amp; Persistence 提供会话和记忆的持久化，保证对话连贯。  那么，为什么 Claude Code 要这样设计呢？\n本文将探讨 Claude Code 的设计哲学，带大家理解主导这些组件设计的背后原因。\n为什么是 Agent Loop？ 在 Claude Code 横空出世之前，LangChain 是这领域的王者，它走的是 Workflow 的路线。\n用户定义好 Workflow，流程根据不同的状态在各个节点中流转，比如这样： 好处是，所有流程都在用户框定的范围内；\n坏处也是，所有流程都在用户框定的范围内；\n人都存在思维局限，而且现在大模型的水平已经远超普通人。给模型一个目标，放手让它自由发挥，反而能达到更好的效果。\n另外，定义 workflow 需要一定的技术门槛，无法实现 Vibe Coding。\n最后，Workflow 本质上是一个状态机，会导致系统变得复杂，不好维护。\n所以，Claude Code 选择了 Agent Loop。\nAgent Loop 遵循 ReAct 模式，本质上是一个 while 循环，反复循环”模型推理-\u0026gt;工具执行“流程，直到任务完成。\n简单、优雅、高效。\n为什么要有权限管理？ Agent Loop 突破了 Workflow 的限定，但高自由度会带来安全隐患，万一模型搞错了怎么办？\n为了避免该问题，Claude Code 将决策与执行分离。模型只做决策，决定调用哪些工具；工具执行则交给框架本身，并且在执行前引入 Permission System 做权限管理。\n这样设计的好处是，即使模型决策错误，也能在 Permission System 进行防护，判断到底要不要执行工具。\n但 Permission System 毕竟也是一段代码程序，无法在所有场景都判断正确，必要时还是需要人类介入。\n所以，Permission System 设计了 3 层权限，优先级由高到低分别是：Deny/Ask/Allow。\nDeny-Frist 的设计意图是安全第一的同时，避免频繁让用户介入决策；把握不住时，才 Ask 用户；允许用户设置 Allow 权限，全程交给 Agent。\n为什么要有沙箱？ 既然有了用户介入，为什么在工具调用时还需要沙箱这种隔离的执行环境？\n原因很简单，人也会犯错。\n另外，Anthropic 统计数据发现，当 Claude Code 询问用户是否执行某项工具调用时，93% 的回答都是 Allow！\n这意味着用户不会很仔细地阅读 Claude Code 的反馈，进一步扩大了犯错的概率。\n所以 Claude Code 需要沙箱、worktree 这类技术来保证，即使用户犯错了，也不会造成无法挽回的后果。\n会话和记忆为什么要明文存储？ 会话和记忆能够保证对话的连贯性和实现用户偏好。\nLangChain 的做法是在任务完成后提取相关记忆，并存储在向量数据库、图数据库等第三方组件中。且通常不会单独存储会话内容。\nClaude Code 则采用明文存储的方案：\n 把用户记忆以明文文本的方式存储到 CLAUDE.md 等本地文档； 把会话（用户问题、模型决策、工具调用结果）明文内容原封不动地追加到本地文档。  这个方案可以看做是对 Agent Loop 的设计延续，让任务执行流程公开，可追溯，且用户能够非常方便地审阅和修改。\nClaude Code的设计原则 最后，我们能总结出贯穿 Claude Code 架构的 3 个设计哲学：\n 对用户能力的放大。增加人类单位成本能完成的任务，不是仅仅加速现有工作流，而是充分发挥模型的主观能动性，采取原本想不到、不会尝试的方式。（Agent Loop） 用户享有最终决策权。确保用户能够实时看到 Agent 在做什么（Interface 实时反馈），参与关键决策（Permission System），并在事后能审计、回溯（会话/记忆明文存储） 安全第一。Deny-First，且用户犯错时也能保证系统免受伤害（Permission System、Execution Environment）   参考  Dive into Claude Code: The Design Space of Today’s and Future AI Agent Systems, Jiacheng Liu 等   （完）\n","date":"2026-05-05T00:00:00Z","permalink":"https://yrunz.com/p/%E4%B8%BA%E4%BB%80%E4%B9%88-claude-code-%E8%A6%81%E8%BF%99%E6%A0%B7%E8%AE%BE%E8%AE%A1/","title":"为什么 Claude Code 要这样设计？"},{"content":"前言 记忆系统（Memory System）是 Agent 保持多轮任务连贯性的核心组件，解决了模型上下文窗口有限的问题。它把 Agent 历史交互上下文保存起来，并在必要时召回，让 Agent 具备了人类的记忆能力。\n市面上的记忆系统很多，采用的技术也各不相同。\n它们总说自己是最好的，但真的这样吗？\n对好坏的评价，目前业界更多的把效果，即 Agent 任务成功率，作为唯一的标准，忽略了时延、成本等关键系统因素。\n如果你增加 100% 的成本才换来 5% 的成功率提升，这通常不会被看作是个好的设计。\n本文首先会介绍记忆的生命周期。然后从系统架构、记忆存储、记忆构建、记忆检索、记忆维护等六个维度对比当前主流记忆系统的技术实现。\n通过多维度的综合考虑，尝试探讨到底怎样才算是好的 Agent 记忆系统。\n记忆系统的实现方式 记忆的生命周期 记忆系统中，记忆的生命周期大致是这样的：\n  记忆摄入：Agent 把交互上下文输入给记忆系统。\n  记忆构建：将交互上下文转换记忆单元，可以是简单的分块，也可以基于 LLM 做摘要。\n  记忆存储：把记忆单元存储到存储系统中，并完成索引更新。\n  记忆检索：Agent 调用记忆系统接口，检索出相关记忆，追加到 Prompt 中发给 LLM 做生成。\n  记忆维护：更新记忆，完成不同记忆之间的流转、遗忘。 不同的记忆系统使用不同的技术实现这套流程。\n  系统架构的分类 朴素架构 最朴素的记忆系统实现是，记忆构建时把原始上下文原封不动保存下来，并在此之上建立索引。\nAgent 每次访问 LLM 前都根据用户问题在记忆系统中找到 top-k 个相似记忆，追加到 Prompt 中。\n朴素架构的主要问题是，原始上下文可能有大量的冗余和噪声，导致记忆效果并不好。\n可以引入 LLM 解决这个问题。\n基于 LLM 的架构 在该架构里，LLM 会被用作记忆抽取，比如对原始上下文做摘要、提取事件、提取实体+关系用于构建图索引等。\n另外，LLM 也可以被用作记忆调度，根据用户的原始问题，做意图分析，提炼成更精确的子查询，从而提升检索效果。\n目前为止，不管是朴素的架构还是基于 LLM 的架构，记忆更新和检索流程都是固定的。\n然而，并非所有的任务、问题都需要用到记忆。这种固定流程有时反而会成为一种负担。\n基于 Agent 的架构 基于 Agent 的架构会把记忆更新和记忆检索做成工具，由 Agent 自己判断是否需要调用。\n当然，Agent 判断是否需要工具调用也会有额外的成本。\n记忆存储的分类 平铺型记忆 平铺型记忆存储下，原始上下文会被分块或 LLM 提取成独立的记忆单元，随后建立向量、全文等索引，存储在文件系统和数据库中。\n平铺型记忆的问题是，记忆单元之间缺少关联关系，无法应对多跳推理等场景。\n关系型记忆 可以基于图或者树来构建关系型记忆，把割裂的记忆单元关联起来。这时，普通的分块已经无法满足，需要用到模型做实体、关系的抽取。\n混合型记忆 平铺型记忆具备更好的语义/关键字匹配，关系型记忆则能让孤立的记忆关联起来。所以，要想达到更好的效果，可以结合两者。\n记忆构建的分类 固定大小分块 最简单的方式是按照固定大小对原始上下文做切片，比如按照每切片大小固定为 n 个 token。\n这种方式的优点是快，记忆构建时延低。但这种一刀切的方式容易造成记忆单元语义不连贯，记忆检索效果一般。\n松散 LLM 抽取 一种解决方法是引入 LLM，利用 LLM 的语义理解能力，从原始上下文中抽取出摘要、事件、用户偏好等关键信息。\n松散的意思是，给 LLM 输入上下文，输出也是一段文本，无 JSON 这种强结构化的格式。\n松散的输出格式对模型能力要求更低，小模型即可完成。\n这种方法对应的是记忆存储中平铺型记忆，存在缺少关联关系的缺点。\n结构化 LLM 抽取 结构化 LLM 抽取会将原始上下文转成类似 JSON 这类结构化的输出，方便后续记忆系统处理。常见如基于图的记忆（实体-关系三元组）。\n结构化输出对模型能力要求更高，这也意味着成本和时延更高。\n记忆检索的分类 单阶段索引检索 最简单的检索是，根据具体的记忆存储方式，做一次索引检索。\n如果是全文索引，就做一次关键字匹配；如果是向量索引，就做一次语义匹配；如果是图索引，就做一次多跳推理。\n单阶段索引检索的优点是快、简单。\n但前面也有提过，不是所有的问题都需要检索记忆，过度检索记忆有时反而会降低效果。\nAgent 按需检索 可以把记忆检索做成 Agent 的一项工具，由 Agent 判断是否需要进行工具调用，按需做检索。\n不过，只做一次索引检索到的 top-k 记忆，受限于索引本身，可能还是不够精确。\n另外，用户问题本身可能也存在一定的模糊性，这样会导致检索到的记忆不准确。\n多阶段混合检索 更精准的记忆检索是：\n  检索前，利用 LLM 做意图分析，把原始问题扩展成更精确的子查询。\n  检索时，多路多模检索，充分利用全文索引的关键字匹配、向量索引的语义匹配、图索引的关系匹配。\n  检索后，使用 Rerank 模型对多路检索结果做一次重排序，找到全局最优的 top-k。\n  记忆维护的分类 Agent 在执行任务过程中会持续产生新的记忆，如果一直放任不管，记忆会膨胀，成为一团大泥球，检索精度也会随之下降。 所以，记忆也需要维护。\n基于时序的多版本管理 如果不想删除旧记忆，可以为每个记忆注入时间戳。当新记忆与旧记忆冲突时，通过时间戳判断新记忆有效。\n容量驱动记忆淘汰 基于时序的多版本管理简单粗暴，但只追加会导致记忆容量越来越大。可以按照一定的策略，比如 FIFO、LRU 等，对旧记忆做淘汰。\nLLM 驱动记忆维护 前面两种是偏传统的、基于规则的维护策略。另一个流派是让 LLM 主动理解记忆内容含，然后进行记忆合并、压缩、更新或删除。\n可以选择在闲时、记忆达到一定容量、超过 N 轮对话后，用 LLM 对记忆做离线整合，合并冗余记忆、解决记忆冲突。\n也可以每次更新记忆时，在线做一次整合。这样能够时刻保持记忆是最优的。代价是更高的成本。\n当然，还能把决策权交给 Agent，把记忆维护的操作作为 Agent 的工具。\n哪种实现方式最好？ 从效果来看 很多人会有这样的直觉：\n 记忆一定比把长上下文好 基于图的记忆组织一定比基于向量的好 用 LLM 做记忆摘要一定比保存原始上下文好 混合记忆一定比单一记忆好 Agent 工具按需调用一定比固定流程好  但真的是这样吗？\n下面是不同记忆系统在 LongMemEva、LoCoMo、 DB-Bench 三个 Benchmark 上的表现：\n可以看到，不同记忆方式在不同 Benchmark 上的表现可以差异很大。\n   LongMemEva 主要考量跨 Session 时的关键事实的召回、推导能力。\n  LoCoMo 主要考量语义连续的长对话的关键事实的召回能力。\n  DB-Bench 主要考量记忆系统是否能帮助 Agent 按步骤完成操作流程，而不仅是记住一条静态事实。\n   首先，记忆系统不一定比长上下文好。以 Mem0 为例，除了在 LongMemEva 中表现比 Long Context 稍微好点，LoCoMo 和 DB-Bench 表现要比 Long Context 更差。原因是 Mem0 会对记忆做 LLM 压缩，导致丢失细节，在关注细节的 LoCoMo 表现不佳。\n另外，关系型记忆在 LongMemEva 这类存在跨 Session 事实推导的场景变现更好，比如 Zep 拿到了最高的 48.0 分。\n而Letta 的分层记忆架构使记忆能在Recall Memory、Core Memory、Archival Memory之间自动下沉，而不是直接遗忘，更能保留关键状态，所以更适合 DB-Bench 这类多步骤执行的场景。\n最后，MemOS、MemoryOS 这类混合型记忆系统在所有任务中的表现都能接近最优，说明混合型记忆比单一型记忆效果更好。\n那么，混合型记忆系统就是最好的吗？\n不一定！\n加入成本和时延维度 记忆系统的实现会影响 LLM 生成的时延和成本。比如，对比 Mem0 这类用 LLM 摘要的记忆系统，长上下文的 LLM 生成阶段 Prefill 时延会更长，对应的成本也会更大。\n所以，对记忆系统成本和时延的考量，可以分成三个阶段：记忆构建（含记忆维护）、记忆检索、LLM 生成。\n现有的记忆系统更多关注记忆检索和LLM 生成阶段，所以，你会看到 MemOS 和 MemoryOS 这类混合型记忆系统会更有优势，它们记忆检索时延不高，LLM 生成效果又好。\n但是，它们都选择性忽略了关键的一步，记忆构建。\n论文《Agent Memory: Characterization and System Implications of Stateful Long-Horizon Workloads》数据显示，类似于 Letta 这类混合型记忆系统，记忆构建的成本和时延占比要远高于记忆检索 + LLM 生成的成本和时延！\n所以，对比不同记忆系统时延更适合用端到端时延/请求来算，即每个请求带来的记忆构建时延 + 记忆检索时延 + LLM 生成时延。\n综合端到端时延和效果来衡量记忆系统的好坏，会得到下面这个图：\n 目前论文《Are We Ready For An Agent-Native Memory System?》只考虑了记忆构建和记忆检索时延，如果把 LLM 生成时延也加进来，图中 Long Ctx 的位置应该要右移，Prefill 成本更高。\n 所以，虽然 MemoryOS 效果很好，但时延也很高，也就意味着成本更大。\n另外，Cognee 和 Zep 成本是最高的，它们都是基于图索引的记忆系统，涉及 LLM 抽取实体-关系，以及图索引的构建。所以，当你要引入基于图的记忆系统时，需要慎重考虑成本问题。\n在这个图中，Mem0 无疑是最尴尬的存在，时延/成本很高，但记忆效果很低！\n最后 回到我们最初的问题，怎样才算是好的 Agent 记忆系统？\n如果想要更好的记忆效果，那么，混合型、基于图记忆系统是更好的选择，比如 MemOS、MemoryOS、Zep。\n如果想要更低的成本，那么，基于向量索引的朴素记忆系统可能是更好的选择。\n如果你既要记忆效果好，又要低成本，对不起，做不到。\n所以，记忆系统的好坏，尽在在权衡！\n 参考   Are We Ready For An Agent-Native Memory System?, Wei Zhou 等\n  Agent Memory: Characterization and System Implications of Stateful Long-Horizon Workloads, Yasmine Omri 等\n   （完）\n","date":"2026-05-05T00:00:00Z","permalink":"https://yrunz.com/p/%E6%80%8E%E6%A0%B7%E6%89%8D%E7%AE%97%E6%98%AF%E5%A5%BD%E7%9A%84-agent-%E8%AE%B0%E5%BF%86%E7%B3%BB%E7%BB%9F/","title":"怎样才算是好的 Agent 记忆系统？"},{"content":" SIGMOD'26 论文《ByteHouse: ByteDance’s Cloud-Native Data Warehouse for Real-Time Multimodal Data Analytics》中，字节团队介绍了实时多模态分析引擎 ByteHouse，本文将讲解 ByteHouse 存储层的设计思路。\n 前言 在 AI 时代，多模态数据处理有如下 3 种负载：\n OLAP。传统分析型 SQL 查询，侧重范围扫描，以顺序读 IO 为主。 多模检索。传统 SQL 无法查询图片、视频等多模态数据，须将它们转成向量，通过向量检索进行语义相关性查询。侧重点查，以随机读 IO 为主。 实时 ETL。提供高时效性，逐条写入。  面对不同的负载，传统的解决方案是组合多种引擎。\nOLAP 采用 Spark、ClickHouse 等 OLAP 引擎；\n多模检索采用 Milvus、pgvector 等向量数据库；\n实时 ETL 采用 Kafka、Flink 等流式引擎；\n然而，不同引擎的存储格式通常是不同的，该方案会带来数据同步问题。\n另外，存算分离的云原生架构已经逐渐成为现代数据引擎的标配。相比于结构化数据，多模态数据的存储体积更大，基于低成本的对象存储构建存储层已经是主流。\n然而，对象存储在 IO 带宽和时延上都存在较大的劣势，容易成为整个数据处理管道的瓶颈。\n下面，我们看下实时多模态分析引擎 ByteHouse 的存储层是如何解决这两个问题 。\nParquet 的问题 在数据分析领域，Parquet 是最流行的开放数据格式。\n但 Parquet 是面向传统 OLAP 负载设计的，不擅长点查，无法高效支撑多模态检索。\n首先，点查路径长，需要 1 + N 次 IO：\n 通过 Foot Length 找到 FileMetaData 的 offset，1 次 IO。 加载 FileMetaData 到内存，过滤掉不相关的 RowGroup，找到对应列的 first page offset 。 遍历 Page，通过 Page Header 的 max 和 min 信息定位到数据所在的 Page，N 次 IO，N 为 RowGroup 中 Page 的数量。 拉取 Page 到内存，解压得到 M 条数据。 遍历数据，找到所需的那条数据。  针对这个问题，Parquet 2.x 做了些优化，将 Page Header 中 max, min 等统计信息，集中存储到 Column Index 和 Offset Index 中：\n这样只需 1 + 2 次 IO 就能完成点查。\n但只有 max、min 无法进行二分查找，遍历仍不可避免，点查路径还是很长。\n常见的解决方案是，在 Parquet 文件之外使用辅助索引。但因为是不同的文件，代价是增加额外的 IO 次数。\n向量不是 Parquet 的“一等公民”，采用嵌套数组的存储布局。同一列所有向量在物理上紧挨平铺，通过 offset 来索引。\n由于没有向量的元数据，比如向量范数，所以在检索时无法进行快速剪枝。对稀疏向量存储也没有优化。\n总结来说就是，Parquet 不适合向量的存储。\n另外，Parquet 没有版本管理，需要配合 Iceberg 等开放表格才能应对实时 ETL 的负载。\n针对这些问题，ByteHouse 提出了一个新的数据格式，Sniffer。\nSniffer 数据格式 论文把 Sniffer 称为自描述的文件格式。\n从布局上看，Sniffer 分成 3 部分：\n Data：存储实际数据，布局跟 Parquet 很像，DataBlock 是数据压缩的最小单位。     Sniffer Parquet     RecordGroup RowGroup   ColumnPartition Column   DataBlock Page     Descriptor：统一存储索引、Schema 等元数据。 Footer：只保留 File Version、Descriptor offset、CRC，长度固定。  通过 Descriptor， Sniffer 把 Sort-Key 与数据放在一个文件里面，不用引入额外的辅助索引就能进行二分搜索，点查路径更短了。\nDescriptor 常驻内存，并通过 LRU 进行缓存管理。这也意味着 Sniffer 在缓存命中的情况下，只需 1 次内存元数据获取 + 1 次 IO 即可实现点查，时延可达微秒级。\nByteHouse 包含一个 Encoding Decision Tree，能够根据不同的数据类型、数据采样特征，自动选择合适的编码，从而使得 Sniffer 存储和检索更高效。\nSniffer 把向量作为“一等公民”，抛弃了 Parquet 的 Repetition\u0026amp;Definition 编码，采用 Length-and-Presence (L\u0026amp;P) 编码，为每个向量附加了向量范式等单独的元数据。\n这样做的一个好处是，即使是同一列的不同向量，也能根据向量的特征选择不同 FOR 或 ALP 等不同的编码，更灵活了。代价是元数据存储体积更大了。\n另外，Sniffer 对稀疏向量也做了优化，对于值为 0 的部分不再需要 padding，节省了存储空间。\n总结来说，Sniffer 相比 Parquet 能够以更少的 IO 次数和更短的查询路径完成点查操作，并在向量存储和检索上做了优化。\n不过，读放大问题，Sniffer 并没有解决。读取某个数据单元，仍需把整个 DataBlock 拉取到内存中解压。\nLance 数据格式倒是通过 Full Zip 编码解决了该问题，它的压缩单位是单个数据单元。所以，它能直接索引到某列某行的数据。代价是更低的压缩率。\n表模型设计 ByteHouse 仍保留了表模型的设计，并针对多模态数据做了优化。\n我们很容易通过 Table 来表征传统结构化数据，每个数据存储为一个数据单元，检索时用该数据单元作匹配即可。\n但对多模态数据，我们常常需要进行向量语义检索，这时该模型就不灵了。\n以文档为例。虽然可以把文档全部内容都塞进一个数据单元，但对整个文档作向量 Embedding 后，会因为向量表征的文档太大，导致关键信息丢失。语义检索失效。\n常见的做法是对文档进行切片，以切片为 Embedding 粒度，保留更多的语义信息。\n视频、音频、图片也是类似的。\nByteHouse 就是采用 \u0026lt;document_id, chunk_id\u0026gt; 的表模型设计。\n另外，在物理存储时，每个 Table 都包含 stable segments 和 delta segments 两部分。\n stable segments 不可变，针对 OLAP 吞吐量作优化。 delta segments 应对数据的 insert、update，并根据自适应的 Compact 策略将变更数据合并到 stable segments 部分。  通过 MVCC 机制保证数据访问的一致性，从而能够支持实时 ETL 负载。\n为了避免频繁小 IO，ByteHouse 的数据存储管道分 2 步：\n Key-Value 缓存。将行级写入缓存到 ByteKV 中，基于 WAL 保证数据持久化。 刷入列式存储。当缓存累积到阈值后，刷入列式存储。  整体看下来，有点像 LSM-Tree 的设计。\nSSD 缓存 Sniffer 虽然降低了读数据的 IO 次数，但对象存储的带宽和时延硬伤，仍可能导致数据读写成为系统瓶颈。\nByteHouse 的生产数据表明，在处理小 IO、随机 IO 时，对象存储的读取时延可占端到端的 70%！\n为此，ByteHouse 在存储层引入了 SSD 缓存，Cluster-Scale Caching。\nCluster-Scale Caching 是一个分布式的缓存系统，包含 2 种角色：\n Cache Coordinators (CCs) ：管理全局 namespace 和 metadata。 Cache Nodes (CNs)：提供 SSD 数据缓存。逻辑上将文件按照固定的大小（默认 12MB）分块管理，物理上会进一步将数据块分成 更小的块，从而减缓读放大问题。  文件系统抽象 到这里，存储层已经准备好，还需要能被计算节点访问的 API。\nByteHouse 把它抽象成 NexusFS，提供文件访问接口，集成在计算节点，是连接存储层和计算层的桥梁。\nNexusFS 在计算侧也有本地 SSD 缓存，包含 3 个组件：\n Region Manager：将本地 SSD 划分成 1MB 大小的 region 进行管理，region 会被进一步划分成更小的 data segments，后者是 IO 调度和缓存的单位。 Buffer Manager：对内存数据进行管理，计算层算子能够零拷贝读取其中的数据。 Metadata Manager：管理 data segments 的元数据，比如该 data segment 所在的文件，该文件所在的 region 信息等。  NexusFS 采用全链路对齐 IO 的策略，确保数据在本地内存-\u0026gt;本地 SSD-\u0026gt;远端存储层都是 IO 对齐的，按照固定大小的 data segment 加载，消除碎片化 IO。\n总结 本文主要介绍了 ByteHouse 存储层的一些设计思路。\n在原有面向 OLAP 负载的设计上，增加了对多模检索和实时 ETL 能力的增强。\n核心是：\n 统一 3 种不同负载的数据格式，将索引和数据放在同一文件，降低点查 IO 次数。 在对象存储之上增加 SSD 缓存，降低数据加载时延、提升 IO 带宽。  不过，ByteHouse 在点查负载上，并没有 Lance 优化的彻底，是压缩率和点查性能的权衡。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] ByteHouse: ByteDance’s Cloud-Native Data Warehouse for Real-Time Multimodal Data Analytics, ByteDance\n[2] Parquet File Format, Apache Parquet\n （完）\n","date":"2026-04-12T00:00:00Z","permalink":"https://yrunz.com/p/%E5%AE%9E%E6%97%B6%E5%A4%9A%E6%A8%A1%E6%80%81%E6%95%B0%E6%8D%AE%E5%A4%84%E7%90%86%E7%9A%84%E5%AD%98%E5%82%A8%E5%B1%82%E8%A6%81%E6%80%8E%E4%B9%88%E8%AE%BE%E8%AE%A1-sigmod26/","title":"实时多模态数据处理的存储层要怎么设计？· SIGMOD'26"},{"content":" 拖了好久，还是得写完 2025 的年终总结，不然都没法开始 2026 年的写作！\n 2025 年最火的，应该是 AI 了。身边的人中，年轻的，年长的，都在用。\n我大姨就时不时来一句，“问下豆包！”\n多亏了豆包、千问们，今年在新西兰过春节期间，我和璐仅用 “This. This.”、 “OK, OK!” 就能应付大部分的英文交流。\n2025 年 AI 的发展速度是令人瞠目结舌的。\n年初大家还在为 DeepSeek 聊天助手而惊叹，到年底，有人已经能把龙虾🦞当作 7×24 小时在线的员工使唤了。\n受 AI 影响最大的群体可能是程序员，大家引以为傲的编码、Debug 能力，在 AI 面前不值一提。现在的 AI 能够更快、更好地完成任务，而且任劳任怨，成本更低。\n大厂的裁员新闻一个接着一个。\n每次工业革命都会伴随着一批职业的衰落，同时，也会带来一波新的机会。\n那么，AI 时代人们需要什么样的能力？\n最近 MIT 有篇论文《Some Simple Economics of AGI》，提出了关于 AGI 时代的经济规律，有些观点我觉得挺合理的。\n 随着 AI 的执行成本趋向于零，经济的稀缺资源不再是智力和执行力。 自动化的边界被重新定义为是否可衡量，任何可以被衡量、量化的任务都可以被 AI 自动化。 经济增长的瓶颈变成了人类的验证带宽。即人类是否有足够的时间、精力和专业知识，去审计 AI 的行为、验证结果的真实性，并为最终结果承担责任。 AI 自动化了初级执行工作，切断了人类积累初级知识的通道，导致未来缺少新生代的专家去验证 AI。  这意味着许多过去被认为需要高度创造力、分析能力和高学历的复杂工作，其收入将不可避免地向计算成本压缩。\n规划、设计、验证将替代复杂任务的执行，成为新的稀缺能力。\n阅读 本来，O\u0026rsquo;Reilly 主打的是新鲜，上面能看到最新发布、正在连载的技术书籍。\n但在 AI 时代，技术迭代更新速度又上了一个台阶。书籍已经很难跟上节奏，论文、博客或许是汲取新知识的更好的载体。\nalphaXiv.org 就很不错，上面有分类、热度排名，还有免费的 AI 问答。\n                     黄仁勋：英伟达之芯，★★★☆☆，虽说是黄仁勋的传记，但花在他身上的笔墨很少，没太能看到主角的成长轨迹。 巴菲特传，★★★★☆，巴菲特有着极强的专注力，这为他带来的非凡的成就，但也导致他对家庭的冷漠。成为伟人，是有代价的。 源代码，★★★★☆，比尔·盖茨是桥牌高手，他认为桥牌对智力能起很大的作用。我们从小就被教育打扑克牌、麻将是不务正业，现在看来，未必。 国家命运，★★★★★，回顾了我国两弹一星的那段历史，当时整个国家正处于内忧外患的阶段，科学家们在吃不饱饭的情况下，仍能把两弹一星研制出来，真是个奇迹。 置身事内：中国政府与经济发展，★★★★★，对了解我国政府运作和经济发展模式很有帮助，可读性非常好。 Designing Data-Intensive Applications, 2nd Edition，★★★★★，延续了第一版由浅入深、通俗易懂的写作风格，数据处理行业必读的神书。 AI Engineering，★★★☆☆，系统梳理了生成式 AI 的基本原理，简单易懂。 Learning Systems Thinking，★★☆☆☆，没有太多干货。 The Staff Engineer\u0026rsquo;s Path，★★★★☆，主要介绍走技术专家（Staff Engineer）路线的工程师需要具备哪些能力。技术能力只是基础，还要具备大局观、执行力和影响力。总结：走技术路线需要些什么？。  写作 在 AI 时代，写作还有必要吗？\n这是一个非常值得探讨的问题。我觉得是有必要的。\n就像在工业时代，“纯手工”制作反而变成了稀缺品。在 AI 时代，写作也可能成为一项稀缺技能。\n另外，写作能帮助我们更好地理清思路，避免因为过度使用 AI 而导致“大脑退化”。\n 过年到底过的是什么？，过完年突发奇想写的一篇文章，过年，长辈有长辈的过法，年轻人有年轻人的过法。 为什么CPU不能有更多的核？，探讨 CPU 的一些设计，总之，世上没有有一款完美的 CPU，系统设计，一切尽在权衡。 走技术路线需要些什么？，对书籍《 The Staff Engineer\u0026rsquo;s Path》 的总结 AI Agent需要什么样的数据库？，Databricks 收购 Neon 看中的就是它非常匹配 AI Agent 的业务特征。亚秒级冷启动、Scale to Zero、Branching 等特性，正成为 AI Agent 时代数据库所必备的能力之一。 分离还是统一，这是个问题，从业务、模型、平台、基础设施四个视角出发，探讨各类系统走向分离或者统一的原因，并尝试从中总结出趋势。 怎么用CXL加速数据库？· SIGMOD'25，介绍 SIGMOD'25 最佳工业论文《Unlocking the Potential of CXL for Disaggregated Memory in Cloud-Native Databases》，阿里云 PolarDB 在 CXL 上的实践。 OLTP上云，哪种架构最划算？·VLDB'25，介绍 VLDB'25 论文《OLTP in the Cloud: Architectures, Tradeoffs, and Cost》，分析了云上 OLTP 的工作负载，并确定每种负载下成本最优的架构及硬件配置。 对 Agent-First 数据库的畅想，UC Berkeley 的论文《Supporting Our AI Overlords: Redesigning Data Systems to be Agent-First》，探讨了 Agent 使用数据库的负载特征，提出了对 Agent-First 数据库的架构以及应具备的能力的畅想。 怎么让程序更高效地连起来？，从最简单的共享内存开始，讲述共享内存-\u0026gt;TCP-\u0026gt;DPDK-\u0026gt;RDMA-\u0026gt;CXL-\u0026gt;NVLink-\u0026gt;UB 等通信方式的基本原理，探讨如何让程序更高效地连起来。 20亿tpmC刷新记录，PolarDB做了哪些优化？· VLDB'25，介绍 VLDB'25 论文《From Scale-Up to Scale-Out: PolarDB’s Journey to Achieving 2 Billion tpmC》，阿里云 PolarDB 的一些优化手段。  生活 除夕当天，我们行程来到了新西兰的瓦纳卡。那里感受不到一点新年的气氛，直到我们走进一家中餐馆。\n餐馆不大，一张长长的吧台把餐馆分割成左右两边，各摆上三、四桌，坐满了人。\n顾客大部分都是国人，他们有说有笑，相互祝福着“新年快乐！”\n我们选了张室外的空桌坐下。外面下着小雨，好在风不大，还能感受到室内热烈氛围。\n“老板，点菜！”\n“一份水煮牛肉，一份麻婆豆腐，一份酸辣土豆丝，一份清水面条，再来两碗米饭。”\n就点菜这会，室外的空桌也陆续满座。老板简单应付了两句，匆忙收起菜单，走进室内，准备下一桌的招待。\n菜上得很慢，隔壁桌的大哥连着抱怨，“怎么菜还没上？！”\n“不好意思，今天人太多了。”，服务员看着像是老板的女儿。\n大哥正要再发作，他妻子忙着制止，“好啦，好啦，除夕呢！”\n跟国内比起来，这里的菜烧得很一般。牛肉表面很滑，但里面很柴；麻婆豆腐的芡勾得浓稠过头了；酸辣土豆丝也只有醋的味道。\n但是，能在异乡吃到家乡菜，而且是在除夕，已经很满足了。\n在假期的最后几天，我们并没有以往在国内旅游时的那种“假期倒计时”的伤感，甚至希望假期能早点结束。\n就像璐朋友圈里说的，“外面的世界很美，但我们想家了。”\n家就是这样的存在。你待久了会觉得无聊，但行走在外，偶尔就会在不经意间冒出一句，\n“还是家里的好！”\n（完，2026.04.05，清明）\n","date":"2026-04-05T00:00:00Z","permalink":"https://yrunz.com/p/2025%E5%B9%B4%E5%B0%8F%E7%BB%93/","title":"2025年小结"},{"content":"  本文要介绍的是 VLDB'25 论文《From Scale-Up to Scale-Out: PolarDB’s Journey to Achieving 2 Billion tpmC》。阿里云 PolarDB 今年登顶 TPC-C 基准测试，以 20.55 亿 tpmC 的成绩刷新世界记录，该论文主要介绍了 PolarDB 达成这一成绩所做的一些关键优化。\n  前言 在过去十年里，基于高扩展性和低成本的考虑，越来越多的应用选择迁移上云，而作为核心组件的数据库当然也必须跟上（OLTP上云，哪种架构最划算？·VLDB'25）。\n阿里云 PolarDB 就是一款云原生数据库，基于存算分离架构，以 PolarFS 分布式文件系统作为后端存储，计算节点无状态弹性扩缩容，提供高扩展性、低成本的能力。\n最开始，PolarDB 是单主架构，可以扩展多个只读从节点，但只能有一个读写主节点。这能够避免分布式事务，极大简化系统架构。\n然而，对于写密集型业务，主节点很容易成为性能瓶颈。\n我们很容易想到通过 Scale-up 的方式，把主节点部署在 CPU 核数更多的机器上，提升写性能。\n但受限于工艺、散热、成本等原因，CPU 核数无法一直扩展下去（为什么CPU不能有更多的核？）。\n而且，更多的核也并不意味着更高的性能，传统数据库的 B+ Tree、MVCC 等机制实现，并发冲突严重，限制了性能的线性增长。比如 MySQL 在扩展至 64 核后，性能不升反降。\n突破主节点瓶颈的另一种方法是，将数据库改造成多主架构，能够有多个读写节点。\n实现多主架构的主流方法有两种：1）共享内存，走 Shared-Everthing 路线；2）数据分片，走 Shared-Nothing 路线。\nPolarDB-MP 采用共享内存架构，在存算分离的基础上，把内存从计算节点中分离出来，各计算节点共享同一份内存。因此可以避免分布式事务带来的复杂性和网络通信损耗。\n但把本地内存访问变成跨节点的远端内存访问，性能损耗很大。本地访存时延大概在百纳秒级别，而传统 TCP/IP 网络通信，即使不考虑传输，时延也在百微秒级别，足足差了 3 个数量级（怎么让程序更高效地连起来？）。\n为此，PolarDB-MP 采用了性能更好的 RDMA 通信，更近一步的优化，就是不久前在 SIGMOD'25 发表的基于 CXL 实现的 PolarCXLMem 了（怎么用 CXL 加速数据库？· SIGMOD'25）。\n目前，受限于分离内存的硬件限制，PolarDB-MP 很难扩展至百节点以上。\n如果业务需要一个上百、甚至上千节点的超大规模数据库，目前最可行的仍是基于数据分片的分布式数据库架构。\n虽然可以优化数据分片方式，让业务尽可能地减少跨分片数据争用，但对分布式数据库来说，分布式事务是绕不开的。\n分布式事务的性能瓶颈，很大程度来源于两阶段提交（2PC）流程，传统基于 TCP/IP 通信的 2PC 协议，无疑雪上加霜。\n针对上述问题，PolarDB 通过 PolarIndex、PolarTrans、Polar2PC 几个关键优化，交出了 20.55 亿 tpmC 的成绩，刷新了 TPC-C 基准测试的世界记录。\n优化一：PolarIndex B+ Tree 被广泛应用于数据库索引，比如 MySQL，以及以 MySQL 为基础的 PolarDB。\nB+ Tree 每个节点的 Key 数量是固定的，当插入数据导致节点 Key 的数量超出上限时，就会引发节点分裂，即 SMO（Structural Modification Operation）。\n如果不加以保护，SMO 很容易引发并发问题，如下所示：\n线程1 要查询 15，线程2 要插入 9，它们都发生在 P1 页中。假设 线程2 先插入，并引发了 SMO，导致 15 被分裂到 P2 页中；线程 1 随后将会在 P1 中查找不到 15。\n防止 SMO 并发问题最简单的方式是，发生 SMO 时：\n 先对全局 B+ Tree index 加 X latch 写锁，确保没有其他线程读写。 从 root page 开始，沿途不加锁的方式遍历到 leaf page。 对 leaf page 及其左右兄弟节点加 X latch 写锁，对 leaf page 进行分裂。 对 parent page 加 X latch 写锁，新增指向新 page 的指针。 释放所有锁，结束 SMO。  这就是 MySQL 5.6 的做法。\n这个方案会使得 SMO 过程，无法对其他 page 进行读写，即使这个 page 不再 SMO 范围内。\n对此，MySQL 5.7 和 8.0 进行了优化，对 index 的加锁从 X latch 改成了 SX latch，SMO 过程允许读线程并发。\n因为读线程在自顶向下遍历时，会沿途加 S latch。而 MySQL 5.6 方案中 SMO 线程会自底向上加 X latch。容易形成死锁。\n所以，还需要让 SMO 线程的加锁顺序改成自顶向下，流程变成这样：\n虽然 MySQL 5.7/8.0 的方案允许了 SMO 线程与读线程并发，但 SMO 之间仍不支持并发，写密集场景锁冲突严重。\n观察上述流程可发现：\n 每次 SMO 只会涉及少量 page，原则上没有必要对整个 index 加锁。 另外，leaf page 分裂过程中，也没有必要对 parent page 加锁。加锁只是为了避免死锁。  那么，有没有更近一步的优化方案呢？\n针对上述两个问题，PolarDB 给出了他们的答案：PolarIndex。\n首先，PolarIndex 取消了 index latch。\n其次，PolarIndex 将一个 page 的 SMO 过程拆分成两个阶段，以 P1 分裂为例：\n阶段一只对叶子节点 P1 和 P3 加锁，分裂结束后释放锁；阶段二只对父节点 P0 加锁，新增指向 P3 的指针后释放锁。\n而在阶段一和阶段二之间的中间状态下，就可能出现并发读，从而导致前文所述数据不一致的问题。\n对此，PolarIndex 参考了 B-Link Tree 的方法，当读线程在 P1 中找不到数据时，说明该节点出现了分裂，只需继续往右再 P3 中查询即可。\nPolarIndex 通过设计更细粒度的锁，以及更短的锁持有时间，减缓了 PolarDB 的锁冲突问题，实现吞吐量的提升和时延的降低。\n优化二：PolarTrans MySQL 事务管理的核心是 trx_sys_t 数据结构，所有事务的开始、提交、回滚都需要经过它，它是全局唯一的单例。\ntrx_sys_t 的 3 个重要属性为：\n rw_trx_ids: 保存当前活跃的事务 ID，是一个数组。 next_trx_id_or_no: 用于申请事务 ID，是一个原子变量。 mutex: 互斥锁，在事务流程中多处被使用。  下图为一个简单的事务流程：\n事务启动时会从 next_trx_id_or_no 申请 trx_id，并追加到 rw_trx_ids。\n当需要一致性读时，会在 trx_sys_t-\u0026gt;mutex 的保护下创建一个 MVCC 读快照 read_view_t，关键属性如下：\n m_ids: 复制 rw_trx_ids 得到。 m_creator_trx_id: 当前事务的 trx_id。 m_up_limit_id: 当 trx_id 小于该值时，表示是当前事务开始之前创建的事务，而且已经提交，数据修改可见。 m_low_limit_id: 当trx_id 大于等于该值时，表示是当前事务开始之后创建的事务，数据修改不可见。  当 trx_id 落在 m_up_limit_id 和 m_low_limit_id 之间时，如果 trx_id 仍在 m_ids 里，表示事务活跃，数据修改不可见；否则数据修改可见。\n另外，事务提交结束后，需要在 trx_sys_t-\u0026gt;mutex 的保护下遍历 m_ids，删除当前事务。\n可以看到，trx_sys_t-\u0026gt;mutex 会被频繁调用到，容易成为性能瓶颈点。\n针对该瓶颈，PolarTrans 做了如下优化：\n 使用基于 RingBuffer 实现的 TIT（Transaction Information Table）替代数组实现的 rw_trx_ids 用作活跃事务的管理。TIT 的每个槽位 slot 保存指向事务对象的 trx pointer 和事务的提交时间戳 CTS（Commit Timestamp），而且可以做到无锁更新。 trx_id 只用作寻找 TIT 中的 slot，比如基于哈希取模的方式。 使用基于 Lamport timestamp 实现的 CTS 替代 trx_id 用作判断数据修改的可见性。 read view 不再需要拷贝 rw_trx_ids 数组，仅需拷贝 CTS_max_committed 变量即可。 使用后台清理线程释放事务资源，避免事务提交后仍需遍历 rw_trx_ids。  如下为 PolarTrans 一个简单的事务流程：\n事务开启时，申请 trx_id，找到对应的 slot，无锁更新，设置 CTS 为 INIT。\nCTS_max 的作用类似于 next_trx_id_or_no，用于分配 CTS，会一直递增；CTS_max_committed 表示最新提交事务的 CTS。\n事务结束后，从 CTS_max 全局变量中申请 CTS，同时更新 TIT 和 CTS_max_committed ，这一步需要加锁保证原子性。\n针对某条记录修改可见性的判断，会根据该记录最后修改事务的 trx_id1 在 TIT 中对应的 slot：\n 如果该 slot 已经被新的事务覆盖了，表示该事务已提交，可见。 如果事务人在 slot 中，且 CTS 为 INIT，表示事务仍处于活跃状态，不可见。 如果 CTS 小于等于读快照中的 CTS_max_committed，表示在本事务开始前已提交，可见；否则，不可见。  后台清理线程释放事务资源时，会检查当前所有活跃的 read view 中最小的 CTS，假设为 CTS_min，如果 TIT 中的事务 CTS 小于 CTS_min，表示该事务的修改对所有事物均可见，可以被释放了。\n然而，这可能会导致这样的情况，某个事物长时间未结束，导致 TIT slot 无法被回收，新事务被阻塞的问题，如下：\n针对该问题，PolarTrans 会将长事务的 slot 卸载到一个 Hash Table 中，避免阻塞 TIT 的回收。当然，此时可见性判断和后台清理线程都需要遍历 Hash Table，会带来一定的性能影响。\n不过，出现长事务的情况比较少，Hash Table 大部分时间都是空的，所以影响不大。\n通过减少事务锁竞争、简化可见性判断逻辑，PolarTrans 在不同的场景带来了 26.47%–50.0% 的吞吐量提升，21.3%–33.2% 的平均时延降低。\n优化三：Polar2PC PolarIndex 和 PolarTrans 都属于 Scale-up 的优化，下面即将介绍的 Polar2PC 属于 Scale-up 的优化。\n2PC（Two-Phase Commit，两阶段提交协议） 是一种在分布式事务中保证多个参与节点原子提交/回滚的协议。\n它将数据库节点分成了负责发起和协调整个事务提交过程的协调节点 CN，和多个实际执行本地事务的参与节点 PN。\n Prepare 阶段：CN 向所有 PN 发起 PREPARE 请求，PN 确认本地满足提交条件后，返回 YES，否则 NO。 Commit 阶段：如果 Prepare 阶段所有 PN 返回 YES，则发起 COMMIT 请求，各 PN 执行本地事务提交，并应答 ACK；如果在 Prepare 阶段有 PN 返回 NO，则发起 ROLLBACK 请求，各 PN执行本地事务回滚，并应答 ACK。  2PC 性能开销大，一方面是相比单机事务多了一个 Prepare 阶段；另一方面，多次的 TCP/IP 往返通信容易成为瓶颈。\n针对该问题，Polar2PC 基于 RDMA 通信协议重新设计了 2PC 流程。\nRDMA（Remote Direct Memory Access），远程直接内存访问，是一种能够绕过 CPU 和操作系统内核的通信技术。它支持单边内存读写操作，也就是在不需要远端节点 CPU 参与的情况下，完成对远端节点内存的读写。RDMA 可以达到微妙级的时延，对比 TCP/IP 的百微秒级时延性能提升巨大。\nPolar2PC 的流程如下：\n在执行 SQL 阶段，PN 节点会返回 \u0026lt;trx_id, LSN\u0026gt; 给 CN 节点，后者在本地维护一份全局的 \u0026lt;node_id, trx_id, LSN\u0026gt; 数据，在后面事务提交时会有用到。\nPolar2PC 也分成 Prepare 阶段和 Commit 阶段，但有所不同。\n① Prepare 阶段，Polar2PC 会默认 PN 已经具备提交事务的条件，所以只需等待 PN 完成 WAL 日志持久化后，即可进入 Commit 阶段。PN 本地内存中 LSN_flush 变量表示当前已持久化的最大 LSN 值，CN 通过单边 RDMA 读取 PN 的 LSN_flush，并与本地保存的 \u0026lt;node_id, trx_id, LSN\u0026gt; 中 LSN 对比。当 LSN_flush \u0026gt; LSN 时，表示 WAL 日志已完成持久化。\n接下来，进入 Commit 阶段。\n② 准备更新 TIT，因为 CN 无法保证在同一时刻更新所有 PN 的 TIT 的 CTS 值，为避免出现数据不一致的情况，CN 会先将 CTS_COMMITTING 写入 PN 的 TIT 表。如果 PN 发现 TIT 中某个事务的 CTS 值为 CTS_COMMITTING，会重复读取，直到 CTS 刷新。\n③ 申请 CTS，会有一个专门的 TSO（timestamp oracle）节点用于生成 CTS，CN 节点从 TSO 得到 CTS 值。\n④ CN 通过 RDMA 单边操作更新 PN 的 TIT 的 CTS 值。\n⑤ 到这里，可以认为事务已完成，CN 通知应用。\n⑥ 最后再通知 PN 节点事务已完成，让它们释放资源。\nPolar2PC 充分发挥了 RDMA 单边操作的优势，PN 节点在 Prepare 和 Commit 阶段大部分时间都不需要 CPU 介入，只在最后释放资源消耗了 CPU。\n可以看到，RDMA 不仅时延更低，而且降低了 PN 节点的 CPU 开销，从而带来了吞吐量的提升。\n最后 通过 Scale-up 和 Scale-out 的优化，PolarDB 扩展了 2340 个节点（48vCPU512GB），最终达成了 20.55 亿 tpmC 的 TPC-C 打榜第一的成绩，并且在性价比上也取得了 37% 以上的领先。\n不过，分布式 PolarDB 不见得在真实业务场景能达到如此高的性能。\n论文里提到，TPC-C 数据集已经过良好的数据分片，所以跨分片事务很少，这无疑放大了分布式架构的优势。\n另外，2340 节点这种超大规模的数据库集群，真实业务中，很少见。\n所以，在大部分场景下，内存分离共享的多主架构可能是更好的选择。\n这可能也是 PolarDB 在今年大力推广基于 CXL 实现的共享内存架构的原因？\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] From Scale-Up to Scale-Out: PolarDB’s Journey to Achieving 2 Billion tpmC, PolarDB\n[2] 路在脚下, 从BTree 到Polar Index, 陈宗志\n[3] InnoDB btree latch 优化历程, 陈宗志\n[4] PolarDB MySQL · PolarTrans事务系统介绍(一), 华洛\n[5] MySQL · 引擎特性 · InnoDB MVCC 相关实现, mianren\n （完）\n","date":"2025-12-10T00:00:00Z","permalink":"https://yrunz.com/p/20%E4%BA%BFtpmc%E5%88%B7%E6%96%B0%E8%AE%B0%E5%BD%95polardb%E5%81%9A%E4%BA%86%E5%93%AA%E4%BA%9B%E4%BC%98%E5%8C%96-vldb25/","title":"20亿tpmC刷新记录，PolarDB做了哪些优化？· VLDB'25"},{"content":"前言  本文将最简单的共享内存开始，讲述共享内存/TCP/DPDK/RDMA/CXL/NVLink/UB 等通信方式的基本原理，探讨如何让程序更高效地连起来。\n 上图为计算机的简易组成，核（Core）是 CPU 的大脑，执行程序的业务逻辑；\n内存 DRAM 通过 DDR 接口连接到 CPU 内的内存控制器（Memory Controller）中，存放程序运行所需的数据；\n而网卡 NIC、SSD 等高速外围设备则通过 PCIe 接口接入 CPU，由 PCIe Root Complex 进行协议解析、路由等。\n在《探索OS的内存管理原理》里我们提到，每个程序都有独立的内存地址空间，以防止其他程序的篡改。所以，程序 A 保存在内存里的业务数据，程序 B 是无法访问的。\n然而，复杂的业务往往需要多个程序协同合作，比如，数据需要先后经过程序 A 和程序 B 的处理。\n这时，要怎么把程序连起来，让程序 B 可以访问程序 A 的数据呢？\n共享内存 这是一个进程间通信的问题，回忆操作系统原理，我们能想到的最简单的方式就是共享内存。\nPOSIX 提供了 shm_open 和 mmap 系统调用，它们配合可实现进程间的通信，简易代码如下：\n// 程序A的代码 int procA(void) { // 1. 双方约定好的共享内存key  const char *key = \u0026#34;/proc_shm\u0026#34;; const size_t sz = sizeof(int); // 2. 创建并打开共享内存对象  int fd = shm_open(key, O_CREAT | O_RDWR, 0666); // 3. 设置大小  ftruncate(fd, sz); // 4. 映射  int *p = mmap(NULL, sz, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); // 5. 写入数据  *p = 12345; ... return 0; } // 程序B的代码 int procB(void) { // 1. 双方约定好的共享内存key  const char *key = \u0026#34;/proc_shm\u0026#34;; const size_t sz = sizeof(int); // 2. 创建并打开共享内存对象  int fd = shm_open(name, O_RDWR, 0666); // 3. 映射  int *p = mmap(NULL, sz, PROT_READ, MAP_SHARED, fd, 0); // 4. 读取数据，输出12345  printf(\u0026#34;%d\u0026#34;, *p); ... return 0; } 只要程序 A 和程序 B 约定好一个 key，就能使用 shm_open 等到一个 fd，再通过 mmap 将它映射到内存里，就能实现数据共享。\n共享内存的方式下，以 AMD Zen5 为例，数据访问的带宽理论可达 614GB/s，时延能够做到 \u0026lt;100ns，这对于常见的程序已经足够快。\n然而，使用共享内存的前提是程序们必须处在同一个物理内存下。如果程序 A 和程序 B 分布在不同的节点，那么共享内存就无法用了，需要网络通信。\nTCP 通信 最常见的网络通信协议是 TCP，它是可靠的传输协议，能够实现跨节点的程序通信。\nTCP 只是协议，要实现网络通信，还需要依赖网卡 NIC 硬件的能力。它的基本原理是，程序 A 将内存中的数据按照 TCP 协议格式打包好，通过 NIC 经过以太网传输到程序 B 所在的节点上。\nPOSIX 同样提供了 TCP 通信的系统调用，主要为 socket、recv、send 等，简易代码如下：\n// 程序B代码，数据接收方 int procB(void) { // 1. 创建套接字：IPv4、面向连接（SOCK_STREAM）、TCP  int listen_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 2. 绑定本地IP:端口  bind(listen_fd, \u0026amp;serv_addr, sizeof(serv_addr)); // 3. 开始监听  listen(listen_fd) // 4. 阻塞等待客户端发起连接  int conn_fd = accept(listen_fd, \u0026amp;cli_addr, \u0026amp;cli_len); // 5. 建立连接后，接受输入写入本地内存缓冲区buf  char buf[BUF_SIZE]; while ((n = recv(conn_fd, buf, BUF_SIZE, 0)) \u0026gt; 0) {...} ... } // 程序A代码，数据发送方 int procA(void) { // 1. 创建套接字：IPv4、面向连接（SOCK_STREAM）、TCP  int sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 2. 向服务端发起TCP连接  connect(sockfd, \u0026amp;serv_addr, sizeof(serv_addr)); // 3. 将内存缓冲区buf的数据发送到服务端  send(sockfd, buf, len, 0); ... } 上述代码实际运行时，会涉及到 CPU 与操作系统用户态内存、内核态内存、网卡缓冲区的协同配合，流程如下图所示：\n可以看到，在发送方，数据一共经过了 2 次拷贝和 1 次协议栈处理，而且 CPU 都有参与。这意味着，数据在经过 NIC 发出去之前，CPU 全程都处于忙碌状态。\n然而，让 CPU 来处理简单的数据传输任务，是一个十分低效的决定。\n为此，计算机引入了 DMA（Direct Memory Access）来分担 CPU 数据传输的任务。现代计算机体系结构中，DMA 控制器通常在网卡设备上。\n在 TCP 报文从内核态拷贝到 NIC 缓冲区阶段，CPU 只需将 TCP 报文的内存地址映射为 DMA 地址，这样 NIC 就能通过 DMA 将 TCP 报文拷贝到 NIC 缓冲区，拷贝过程无需 CPU 参与。\nNIC 发送数据后，再通过硬件中断唤醒 CPU 继续处理程序 A 接下来的逻辑。接收方也是类似的流程。\n然而，每次发送数据都要经历一次从用户态到内核态的数据拷贝，这其中涉及到了 1 次 CPU 上下文切换；而每次发送后产生中断，都需要 CPU 进行上下文切换，并处理中断程序。\n这些流程无疑都增加了 CPU 的负担，对于网络密集型程序，CPU 可能会一直忙于数据拷贝、上下文切换、中断处理！\nTCP 网络通信的带宽与网卡规格相关，比如常见的 100Gb 网卡，双边通信带宽 25GB/s。但在通信时延上，不算网络传播时延，数据从用户态到 NIC 发出的过程，就需要 \u0026gt;100µs。\n这百微妙级的时延，会使得网卡带宽的无法被充分利用起来，需要进一步的优化。\nDPDK 加速 TCP 通信 DPDK（Data Plane Development Kit）是由 Intel 推出的一个网络加速库，与传统 TCP 通信路径对比如下：\nDPDK 提供了 UIO 网卡驱动，应用程序通过 DPDK lib 可直接将数据从用户态传输到 NIC，完全绕过了内核，数据零拷贝；\n另外，它基于 PMD（Poll Mode Driver）的轮训模式，消除了数据收发路径上的中断。\n可以看到，DPDK 的路径更加短了，时延也从传统 TCP 通信方式的 100µs 降低至 10µs 左右。\n不过 DPDK 并不提供 TCP 协议栈处理能力，程序需要自己实现，或者引入第三方库。\nDPDK 虽然通过绕过内核的方式缩短了数据收发路径，降低了 TCP 通信时延，但它仍需要 CPU 对 TCP 协议栈进行处理。而且轮训的数据驱动方式也会一直占用 CPU。\n那么，有没有办法能够让整个数据收发链路都绕过 CPU，让 CPU 能够专注业务逻辑处理呢？\nRDMA 通信 RDMA（Remote Direct Memory Access），远程直接内存访问，是一种能够绕过 CPU 和操作系统内核的通信技术。\n不同于 DPDK 在 TCP 通信上做优化加速，RDMA 在协议栈、NIC 硬件上都做了优化升级。\n如上图所示，使用 RDMA 需要引入专门的网卡 RNIC，它支持在网卡内直接进行协议栈处理，并能够使用 DMA 将数据从内存传输到网卡上，从而绕过了 CPU。\nRDMA 有如下三种实现方式：\n    InfiniBand (IB) RoCE v2 iWARP     底层协议 专用 IB 物理/链路层 以太网+UDP/IP 以太网+TCP/IP   时延 0.5µs ~ 2µs 1µs ~ 3µs 10µs ~ 15µs   硬件依赖 专用 IB 交换机、光纤、HCAs、网卡 复用现有以太网基础设施、专用网卡 完全基于标准以太网+TCP、专用网卡    InfiniBand 虽然性能最好，但起建成本也最高。RoCE v2 性能较好且成本适中，约为 InfiniBand 的 30~50%，是目前应用最广泛的实现。\n因为 RDMA 时延更低，所以在规格相同的网卡下，比如 100Gb 的 NIC 和 RNIC，RDMA 比传统 TCP 有更高的带宽利用率。\n现在 RDMA 已经被广泛应用在 AI、HPC 等依赖高性能通信的场景。\n不过 RDMA 的数据收发链路上仍然存在 1 次数据拷贝和协议栈处理，在编程复杂度、带宽和时延上，对比共享内存的方式，仍有较大的差距。\n在前文分析中，我们注意到，NIC/RNIC 是通过 PCIe 接口接入 CPU，而 DMA 从内存中拷贝数据，也是通过 PCIe 传输。\n那么，能不能在节点间也通过 PCIe 协议通信，这样也许就能做到用 Load/Store 内存语义访问远端节点内存数据，免数据拷贝，也不用复杂的协议栈处理了？\nCXL 通信 CXL（Compute Express Link），是一种建立在 PCIe 之上的低时延、高带宽的互联协议，能够实现 CPU 与内存、IO设备、XPU 加速器的互联。\n从 2019 年发布至今，已经经历了 CXL 1.0/2.0/3.0 三个大版本。CXL 主要有 3 个子协议组成：\n CXL.io：与 PCIe 的事务层/链路层保持兼容，负责 设备发现、配置、DMA、中断等，为所有 CXL 设备提供统一的 I/O 通道。 CXL.cache：让 XPU 加速器和外设能够缓存并直接访问主机（CPU）内存，并保持缓存一致性。 CXL.mem：使主机 CPU 能够直接对 CXL 端点上挂载的内存设备进行 Load/Store 内存语义访问，等同于把外部内存映射到主机的内存地址空间。  如上图所示，程序 B 可直接远程访问程序 A 的内存数据，且无需经过节点 1 CPU 的处理，直接、高效。\n操作系统可以把外部的 CXL 内存设备看做是 DAX Device，这样程序 B 就能通过 mmap 把节点 1 的内存映射到自己的虚拟内存地址空间上，直接通过指针访问，省去了类似 TCP/DPDK/RDMA 繁琐的编程步骤。而且数据收发均在用户态完成。\n目前 CXL 2.0 已经陆续有落地应用，在《怎么用CXL加速数据库？· SIGMOD'25》里，我们介绍了阿里云 PolarDB 团队基于 CXL 2.0 实现 PolarCXLMem 全局内存池的案例。\n该案例实测数据表明，CXL 2.0 的直连访问时延可以达到 265ns，走 CXL Switch 则可以达到 549ns，是真正的纳秒级通信。CXL 3.0 标准更是将 80ns 点对点访问时延作为目标，这已经和当前的本地 DRAM 访问时延相当了。\nCXL 基于 PCIe 实现，这意味着 CXL 的性能受限与 PCIe 的性能，如下为各个版本 PCIe 协议的带宽规格：\nCXL 3.0 基于 PCIe 6 实现，可以做到 16GB/s/lane 的带宽，最高支持 x16 lane，所以理论带宽最大值为 256GB/s。这对大部分程序来说，已经够大了。\n然而，当前 CXL 并未被广泛应用，一个重要的原因是成本过高，它需要引入 CXL 控制器、CXL Switch 等新的硬件，且无法像 RDMA 一样复用现有以太网基础设施。\n对于大多数程序来说，RDMA 的性能已经足够了。而那些需要更高通信性能的应用，比如 LLM 训练和推理，CXL 的带宽又不够用。\n那怎么办呢？\n既然 PCIe 已经无法满足性能要求，那就不要 PCIe 了。\nNVLink 通信 AI 应用程序通常会跑在 GPU 上，GPU 通过 PCIe 接口接入 CPU。当程序需要跨 GPU 通信时，最开始的方案是基于 PCIe 的 DMA 完成数据传输。\n然而，随着模型变得越来越大，PCIe 的带宽已难以支撑高效的模型训练和推理，在 LLM 时代更是如此。\n既然 PCIe 性能达到了上限，那就重新为 GPU 设计一个更高带宽的通信方式，于是，NVLink 诞生了。\nNVLink 是英伟达在 2014 年推出的用于 GPU 高速互连的协议，它能够实现 GPU 之间的显存数据直接访问，完全绕过了 CPU。\nNVLink 依赖专门的硬件，NVLink 控制器、NVLink Switch 等，最新 NVLink 5.0 的点对点直连带宽可以达到 50GB/s/lane，每个 NVLink 端口 x2 lane，每个 GPU 最多可支持 18 个 NVLink 端口，总带宽可达 1.8TB/s，通信时延 \u0026lt;500ns。\n可以看到，NVLink 的带宽比 CXL 要大 7 倍多，但 CXL 的通信时延更低。这体现了两者设计的权衡，NVLink 是专门针对 GPU 强大并行计算能力的高带宽设计；而 CXL/PCIe 则针对 CPU 的低时延设计。\nNVLink 通信带宽很大，但仅限于 NVIDIA 生态，主要是面向 NVIDIA 的 GPU-GPU、GPU-CPU 互联；而 CXL 则更通用，CPU、DRAM、SSD、SmartNIC 等多种设备互联。\n那么，能不能设计一个带宽跟 NVLink 相当，而且通用性又能跟 CXL/PCIe 相当的协议或通信方式呢？\nUB 通信 华为给出了他们的答案，在今年 9 月份开放了灵衢 UB 2.0 协议。UB（Unified Bus）是一个面向超节点的互联协议：\n与 NVLink 的思路类似，UB 不再基于 PCIe 做优化，而是对通信协议进行了重新设计。\n与以 CPU 为中心的 CXL/PCIe 协议不同，UB 采用了对等架构。在 UB 的世界里，CPU/NPU/SSU/GPU/Memory/DPU 等设备都是平等的，任何设备都可以通过 Load/Store 内存语义直接访问其他设备的数据。\n这种对等架构的一大好处是，各种异构的计算资源、存储资源都可以池化，根据应用的需求动态组合，提高资源利用率，同时也能减少不必要的数据搬运。\nUB 的应用在今年初也已亮相，面向 LLM 推理场景的 CloudMatrix384 超节点，它基于 UB 协议完成 384 个 Ascend 910 NPU 和 192 个 Kunpeng CPU 的高速互联。\n据灵衢社区的宣传资料显示，UB 的带宽可达 TB/s 级，点对点时延可达 200ns。\n目前来看，PCIe 经过多年的发展，已具备非常好的生态。而 UB 想要替换 PCIe 成为新的总线，如何发展生态，可能是它所面临的最大难题。\n最后 本文从最简单的共享内存开始，回顾了程序通信的各种技术方案：\n整体来看，程序间的通信性能可以优化得很高，但与之伴随的代价是，需要引入新的硬件，需要更高的成本。\n其实，并不是所有的程序都需要很高的通信性能，比如，一个普通社交聊天软件并不需要 TB/s 级的通信带宽，传统的 TCP 通信已经够用了。\n所以，要选择最合适的，而不是最高性能的通信方式将程序连起来。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 优化篇 | 网络时延优化知多少？！, 一森咖记\n[2] 想要了解DPDK,这篇文章就够了, linux高性能网络\n[3] 5th Gen AMD EPYC Processor Architecture, AMD\n[4] The Performance of CXL Memory, 0x10.sh\n[5] Nvlink的国产替代：华为Unified Bus背后的思考, 半导体行业观察\n[6] Serving Large Language Models on Huawei CloudMatrix384, Huawei\n[7] 灵衢社区, Huawei\n （完）\n","date":"2025-10-19T00:00:00Z","permalink":"https://yrunz.com/p/%E6%80%8E%E4%B9%88%E8%AE%A9%E7%A8%8B%E5%BA%8F%E6%9B%B4%E9%AB%98%E6%95%88%E5%9C%B0%E8%BF%9E%E8%B5%B7%E6%9D%A5/","title":"怎么让程序更高效地连起来？"},{"content":" 本文要介绍的是 UC Berkeley 2025 年 8 月在 arXiv 上发表的论文《Supporting Our AI Overlords: Redesigning Data Systems to be Agent-First》。论文基于 Benchmark 对 Agent 使用数据库的负载特征进行了量化分析，最终结合这些负载特征，提出了对 Agent-First 数据库的架构以及应具备的能力的畅想。\n 前言 在《AI Agent 需要什么样的数据库？》一文中，我们总结了 AI Agent 使用数据库的负载特征，以及对应负载下，数据库所应具备的能力：\n   负载特征 能力要求     即时创建 极短的冷启动时长，提供极佳的用户体验。   大量小实例 / 活跃时长不稳定 自动弹性伸缩，降低平台成本。   反复调试 快速 PITR，提供任意时间点的快速数据恢复能力。    其中，即时创建、大量小实例、活跃时长不稳定更多的是数据库实例生命周期维度的负载特征。至于 Agent 使用 SQL 查询的特征是怎样的，文中没做太多分析。\n反复调试与 SQL 查询相关，但文中并没有做定量分析，仍无法回答以下问题：\n“Agent 需要反复调试多少次才能成功？”\n“Agent 查询数据库的 SQL 本身有哪些特征？”\n“有多少 SQL 是重复多次查询的？”\n“怎样才能帮助 Agent 减少调试的次数？”\n弄清楚这些 Agent 使用 SQL 查询的负载特征，有助于我们设计出一个 Agent-First 数据库。\nUC Berkeley 的论文《Supporting Our AI Overlords: Redesigning Data Systems to be Agent-First》针对这些问题给出了他们的答案，并提出了他们对 Agent-First 数据库的畅想。\nAgent SQL 查询的负载特征 为了弄清楚 Agent 使用 SQL 有哪些特征，论文基于 BIRD Text2SQL benchmark 进行分析，使用了 GPT-4o-mini 和 Qwen2.5-Coder-7B-Instruct 两个模型进行对比，后端数据库为 DuckDB。\nBIRD Text2SQL benchmark 包含了 12,751 个 Question-SQL 对，分布在 95 个库中，数据量 33.4GB。测试过程是将自然语言的 Question 发给 Agent，由 Agent 生成 SQL 并查询数据库，最终对比 Agent 生成的 SQL 和答案 SQL 的查询结果和时长。\n得到了如下分析结论。\n结论 1：单 Agent 多轮尝试或多 Agent 并发尝试有助于提升 SQL 查询正确率 如下所示，单 Agent 随着尝试轮次的增加，任务成功率呈增长趋势，5 轮后增长减弱，稳定在 55% 左右。\n采用多个 Agent 并发执行任务，也能提升任务成功率，如下图所示，当并发数增加到 50 时，成功率可达 70% 左右。\n不管是单 Agent 多轮尝试，还是多 Agent 并发尝试，都会导致请求吞吐量呈倍级、甚至数十倍级的提升，对数据库的性能提出了更高的要求。\n结论 2：Agent 有大量重复的 SQL 查询，占总查询数的 80%~90% 在多 Agent 并行尝试的方案下，取 K=50，观察在 BIRD dataset 下产生的所有执行计划，得到算子的分布如下：\n上图从 2 个维度展现了 SQL 的重复程度：\n(a) 不同的执行计划的节点数可能不同，在不同规模的执行计划下，unique 节点的占比只有 5%~22%（Prop. 曲线）。\n(b) 按照节点（算子）类型归类，无重复节点（算子类型相同，算子中表达式不同）的占比只有 5%~10% （Prop. 曲线）。\n大量的重复查询意味着，缓存或者记忆，将会是提升 Agent 访问数据库性能的关键。\n结论 3：Agent 的 SQL 查询类型是多样的 给 Agent 一个数据查询的任务，它并不会立刻生成结果查询 SQL，而是会先进行一些探索，比如找到有哪些表、表中有哪些列等。\n论文把 Agent 完成一个数据查询任务所执行的 SQL 划分成 4 个类型：\n 探索表（exploring tables）：查询数据库中都有哪些表。 探索列（exploring specifical columns）：查询指定表中有哪些列。 中间查询（attempting part of the query）：基于前 2 步的信息，构建中间查询，开始尝试查询相关数据。 完整查询（attempting entire query）：根据子查询生成完整的 SQL，并执行查询，得到最终结果。  可以看到，Agent 会先进行粗粒度的元数据查询，然后再是进行从部分到整体的数据查询，直到完成任务。\n当然，Agent 不一定会按严格的顺序执行这 4 个类型的 SQL。如上图所示，在任务执行的过程中，Agent 大部分时间里都处于试错阶段，反复探索列和执行中间查询。\n结论 4：适当的引导可以有效降低 Agent 完成一项任务的轮次 如果 Agent 在大部分时间里都在试错，就意味着数据库大部分时间里都是在做无用功，浪费资源。\n如果给 Agent 适当的引导，比如告诉 Agent完成这项任务所需的表和列有哪些，能否减少这些无用功呢？\n论文对此做了对比实验，结果数据如下：\n结果显示，**适当的引导，可以降低 18.1% 的总 SQL 查询次数！**其中，探索列和中间查询降幅最大，分别达到了 27.7% 和 36.6%。\n论文把满足上述特征的 Agent 负载，称为 Agentic Speculation。\n显然，为人类设计的传统数据库已无法高效支撑 Agentic Speculation，它需要从一个被动的查询执行者，转变为一个能够与 Agent 主动协作的伙伴，也即 Agent-First 数据库。\nAgent-First 数据库 论文提出了 Agent-First 数据库的架构畅想，如下图所示：\n可以看到，Agent-First 数据库已经不再是对传统数据库的小修小补，而是一种重构，实现了与 Agent 的融合。\n查询方式的重构 首先，Agent 与数据库交互的语言，不再是 SQL，而是一批 Probe。\nProbe 不仅包含了 SQL，还有附带一段由自然语言描述的背景信息，这主要起到引导作用，有助于减少反复试错。\n背景信息可以包含如下内容：\n 查询意图。阐述本次查询的最终目的，给数据库一个方向性的指导。 查询阶段。元数据探索阶段和数据查询阶段对数据的精度、调度优先级都不一样。 查询精度。比如可以指定查询结果不需要 100% 精确，80% 就好。 优先级。在本批 Probe 中，当前查询的优先级，方便数据库更好地调度。 终止条件。比如，在全表扫描请求中，指定查询终止条件为扫描 80% 数据即可，或查询时长不超过 1ms。 开放性目标。比如，Agent 想了解美国东西海岸的销售差异，可以下达一个开放性指令：请从东西海岸各选两个州，给我生成统计数据，具体选哪几个州，你看着办，选效率最高的就行。这是 SQL 无法实现的功能。  通过背景信息，Probe 极大扩展了 SQL 语义查询的功能。\n在数据库侧，库内 Agent 替代传统的 SQL 解析器。\nProbe Parser and Interpreter Agent 会解析 Probe，随后提交给后端执行查询；\n另外，数据库会按需唤醒一个或多个 Sleeper Agents，它们不负责回答主要问题，而是提供辅助信息和成本建议：\n  Sleeper Agents 有数据分布的全景图，可以帮助 MSP Agents 更好地理解数据本身。\n比如，可以推荐相关表：你查了 orders 表，可能你还会需要查 customers 表。\n比如，解释查询结果：你的查询结果为空，原因不是没有相关数据，而是列名 CA 错了，正确的列名是 California。\n  Sleeper Agents 还能给出查询效率和成本的建议，帮助 MSP Agents 更高效地进行查询。\n比如，本查询全美数据的请求成本太高，要不要先查加州试试？\n比如，我发现你在连续独立地查 50 个州的数据，把它们合并成一个批处理请求会快得多。\n  最终，数据库会将查询结果和这些建议打包返回给 MSP Agents，这就是从被动应答，到主动引导的转变。\n查询优化的重构 其次，查询优化器的目标不再是传统数据库那样的“最快地给单次查询一个精确的结果”，而是“用最小的总时间代价，完成本批次以及未来批次的 Probes 查询任务”。查询结果不一定是精确的，只要能够完成 MSP Agents 交代的任务即可。\n这意味着查询优化器不能局限于当前批次的优化，还要具备根据历史交互记录和最终目标，预测未来查询的能力。\n对于当前批次优化（Intra-Probe Optimization）：\n  近似替代精确。比如，优化器发现当前处于探索阶段，可以适当降低查询精度，减少资源消耗。\n  避免“因小便宜吃大亏”。比如，当前查询如果返回一个低精度的结果，很可能引发更多轮的查询，得不偿失。这就需要优化器综合考虑当前数据库自身的状态、系统资源，决定在本轮或下一轮执行精确查询。\n  动态调整计划。执行计划从静态转变为动态，比如，对于 A 列，最初优化的结果是设置查询精度为 80%，但随着查询的执行，发现系统资源不足，就主动将精度调整为 60%。\n  对于多轮批次优化（Inter-Probe Optimizations）：\n 基于增量信息做剪枝，避免做无用功。比如上一批 Probe P 查询了\u0026lt;用户姓名, 年龄\u0026gt;，本批次 Probe P‘ 又要查询 \u0026lt;用户姓名, 年龄, 电话\u0026gt;，优化器会分析新增的信息 电话 是否对最终目标有帮助，如果没有，则直接丢弃，避免重复查询。 提前准备好下一批查询的结果。比如，优化器学习到用户正反复对 users 和 orders 表进行 JOIN 查询，那么可以主动将两表 JOIN 的结果通过物化视图缓存下来，提升下次的查询效率。  数据索引的重构 对传统数据库来说，业务流程往往是固定的。\n比如，应用知道哪些列会被经常查询，就可以预先为这些列建立索引，提升随机查询性能。\n比如，应用知道自己是 OLAP 的业务，那么就采用列式存储，提升复杂分析查询性能。\n但 Agent 的业务流程是动态的，查询类型是多样化的。它可以是对元数据的探索性查询、也可以是精确数据查询；可以是 TP 业务的点查，也可以是 AP 业务的批量扫描；可以是全文检索，也可以是向量检索。\n另外，Agent 还会存在大量重复的查询。\n所以，光靠传统的索引机制，已经难以高效支撑 Agentic Speculation。\n还需要引入 Agent 记忆存储：\n 存储知识。存储元数据，比如该表有哪些列，每列的类型是什么，提升探索查询阶段的性能。 存储经验。存储过去的查询结果，使得下次查询能够直接复用，避免重复查询。  事务管理的重构 Agent 会存在大量的 “What-if” 式的探索流程。论文里提到，Neon 数据库统计数据显示，Agent 创建的数据分支数量是人类创建的 20 倍、Agent 执行数据回滚的次数是人类的 50 倍。\n这决定了 Agent 之间的数据操作需要良好的隔离性，避免相互影响。\n传统的 MVCC 事务隔离机制已无法应对这种场景。MVCC 的目的让多个并发事务感觉不到对方的存在，互不干扰。它处理的是短暂的、并行的“现在”。它的所有版本最终都会被清理，历史永远是一条直线，是小而短暂的。\n而 Agent 的 “What-if” 是要创建无数个平行的“未来”，它需要同时保留成百上千个数据快照，在每个快照上运行调试，比较结果，然后选择最优的那个“未来”。所以，它的历史是一个庞大的树状结构，是大而长寿的。\n类似于 Git 的数据分支机制，无疑更适合 Agent 大量的 “What-if” 事务管理。\n另外，鉴于 Agent 会频繁地调试数据，快速地创建和回滚分支是必备的能力。可以基于 Copy-on-Write、Redirect-on-Write 等技术实现该能力。\n在数据分支的能力上，Neon 数据库走在了业界前列，相关实现细节可参考《AI Agent 需要什么样的数据库？》。\n最后 总结 Agent SQL 查询的负载特征以及数据库所需的能力如下：\n   Agent SQL 查询负载特征 Agent-First 数据库能力     高吞吐量 批量 Probe 处理、跨批查询优化   高重复率 查询优化器主动增加结果缓存、Agent 记忆存储   多样性 Agent 记忆存储   可引导 Probe 替代 SQL、Sleeper Agents 提供辅助信息和成本建议   大量 “What-if” 探索 数据分支替代 MVCC 事务隔离    虽然论文给出了 Agent-First 数据库的架构，但这更多的是一种对未来的畅想，离真正落地，还有很多技术难题要解决。\n最突出的问题是，成本高昂。\nAgent-First 数据库在库内部署了多个 LLM Agent，每次数据查询都可能涉及多个 Agent 协作，查询成本和时延可能会非常高。\n另外，如何处理库内的 Agent 记忆更新、如何实现高效的查询优化策略等等，都是需要更深一步的研究。\n不过，随着今年依赖 AI Agent 的飞速发展，这个 Agent-First 数据库也许很快就会出现。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] Supporting Our AI Overlords: Redesigning Data Systems to be Agent-First, UC Berkeley\n[2] AI Agent 需要什么样的数据库？, 元闰子\n （完）\n","date":"2025-09-24T00:00:00Z","permalink":"https://yrunz.com/p/%E5%AF%B9-agent-first-%E6%95%B0%E6%8D%AE%E5%BA%93%E7%9A%84%E7%95%85%E6%83%B3/","title":"对 Agent-First 数据库的畅想"},{"content":" 本文介绍的是 VLDB'25 论文《OLTP in the Cloud: Architectures, Tradeoffs, and Cost》，论文系统分析了云上 OLTP 的工作负载，并确定每种负载下成本最优的架构及硬件配置。对于云上 OLTP 数据库选型以及了解数据库量化分析方法具有较好的参考意义。\n 前言 OLTP 数据库迁移上云已经成为一种趋势，公有云为数据库提供了更好的弹性伸缩、低成本等优势。\n目前，各类云厂商提供了丰富的云上数据库架构选型，比如 AWS Aurora/RDS、Microsoft Socrates/SQL Database、Google AlloyDB/Cloud SQL 等。而每种数据库下，又提供了多种存储选项和服务器实例配置。这形成了巨大的设计空间，使得系统设计者难以做出明确的决策。\n如果你正准备把业务迁移上云，一定会很头疼：\n要选择哪种数据库（架构）、哪种硬件配置，才能满足业务诉求，且成本最低？\n云上 OLTP 数据库架构 论文把现有的云上 OLTP 数据库总结归纳成如下 6 类架构：\n Classic HADR（High Availability Disaster Recovery） RBD（Remote Block Device） In-Memory Aurora-like Socrates-like  其中 Aurora-like 和 Socrates-like 属于云原生架构。\nClassic 架构 Classic 架构是一种单体架构，数据库的计算引擎和存储引擎都在一个进程、部署在一个服务器实例下。\n其中，存储引擎可以分为 2 部分，Log Service 复杂将 WAL 写入本地 SSD，保证数据可靠性；Page Service 负责 page 的管理，包括从 SSD 中读取 page 到内存中，以及将 dirty page 写回到 SSD 中。\n 鉴于 HDD 性能已无法满足现代 OLTP 性能要求，论文中的本地磁盘只考虑 SSD。\n Classic 架构最大的优点是，所有数据都来自于本地 SSD，避免了高昂的网络开销；而且本地 SSD 可以提供高达 3.3M IOPS@4K 随机读的性能（AWS EC2 实例）。\n然而，Classic 架构最大的缺点也是所有数据都存储在本地 SSD。在公有云上，我们通常认为弹性服务器实例的本地 SSD 是不可靠的，实例的下线或硬件故障，会导致本地 SSD 数据丢失。\n另外，Classic 架构无法进行水平扩展，随着业务负载的增大，容易成为单点瓶颈。\nHADR 架构 HADR 架构是一种主从架构，在 Classic 架构的基础上，引入了多个从节点。\n主节点可同时处理读和写请求，从节点只能处理读请求。主节点通过将 WAL 复制到从节点上，来进行数据同步。从节点通过将 WAL 回放成 page，对外提供数据查询服务。\nHADR 架构拥有多个数据副本，当主节点故障时，系统仍可从剩余的从节点上恢复，对比 Classic 架构，在可用性、可靠性上有了很大的提升。\nHADR 节点的数据流与 Classic 架构相同，新增了从主节点到从节点复制 WAL 的网络开销。\n虽然从节点支持水平扩展，但只有主节点能处理写请求，在写密集型负载下容易成为瓶颈。\n另外，全量数据仍在本地 SSD 上，而本地 SSD 容量有限（AWS EC2 最大可支持单盘 1.9TB，最多可配置 8 盘，共 15.2TB），这就导致当业务规模大到一定程度后，HADR 架构无法放下所有数据了。\nRBD 架构 RBD 架构将本地 SSD 替换成远端存储，比如 AWS Elastic Block Store（EBS）或者 Azure Managed Disks，提供了更高的可靠性、更低的成本。而且，这使得存储摆脱了单点瓶颈，容量可以无限扩展下去。\n然而，RBD 架构并未实现存算分离，主要原因是块存储不支持同步语义接口，导致 RBD 架构无法像 HADR 架构一样扩展多个只读节点。\n另外，存储拉远后，数据的读写增加了网络开销，对比本地 SSD，在读写带宽和时延上都更差。\nIn-Memory 架构 In-Memory 架构也属于单体架构，但与 Classic 架构不同，他把所有数据都加载到内存里，只会将 redo log 写入本地 SSD 保证事务持久性。\n因此，In-Memory 架构只在处理写请求时有少量的磁盘 IO，性能在所有架构里是最强的。\n但 In-Memory 架构的缺点也很明显，相比磁盘，内存的成本更高，而且容量更小（AWS EC2 实例最高只支持 4TB 内存容量）。所以，它只适合那些高读写负载、数据集很小的业务场景。\nAurora-like 架构 Aurora-like 架构是云原生架构，真正实现了存算分离。\n计算引擎部署在无状态的计算节点，能够根据业务负载来进行弹性扩缩容。\n存储节点将数据保存在本地 SSD。数据被切分成 10GB 大小的 chunk，每个 chunk 由至少 6 个存储节点组成的集群管理，通过一致性协议来保证数据可靠性。\nAurora-like 架构也是一写多读的架构。数据写入时，主节点会将 WAL 同时发到 6 个存储节点上，得到至少 4 个节点确认后，写入成功。\n存储节点实时回放 WAL 来构建 page，供计算节点查询。为降低存储成本，Aurora-like 架构只在其中 3 个节点上管理 page。\n因为 page 和 WAL 都存在多副本，所以 Aurora-like 架构具备较高的可靠性。但数据副本都存储在本地 SSD，成本很高，不适合拥有大量冷数据的场景。\n另外，每次读请求都会涉及存储节点和计算节点间的网络通信，带来了额外的开销。\nSocrates-like 架构 Socrates-like 架构在 Aurora-like 架构的基础上进一步将存储节点拆分成了 Log Server（XLog）和 Page Server，使得它们具备独立扩展的能力。\n数据写入时，主节点会同步将 WAL 写入远端快存储，保证可靠性。Log Server 负责将 WAL 整合和分发到 Page Server 和只读从节点上，它们会实时回放 WAL 来构建 page。\nSocrates-like 架构在计算节点、Log Server、Page Server 上都设了本地 SSD 缓存，它们共同构成了一个名为 Resilient Buffer Pool Extension（RBPEX）的统一缓存系统。\n数据查询时，计算节点只在 Cache Miss 时，才会发起到 Page Server 的读请求，对比 Aurora-like 架构有着更低的读时延。\n另外，Socrates-like 架构的可靠性主要依靠远端块存储来保障，无需在本地 SSD 保存全量数据，所以成本比 Aurora-like 架构更低。\n2 个 Page Server 的设计，更多的是基于可用性的考虑，当其中之一故障时，能够快速容灾到另一个。也可配置更多的 Page Server 来提供更高的可用性。\n量化评估方法 因为架构+硬件配置的组合太多，不可能通过实测的方法来确定（成本太高）。所以，论文提出了一个量化评估模型，能够根据给定的目标负载、数据库架构和硬件配置来快速找到最划算的组合。\n下面是量化模型的假设：\n 假设数据库都使用 B-Tree 索引，page 大小为 4K。 每个 CPU 核每秒处理的事务数固定，忽略锁开销。（不影响结果结果评估） 不考虑客户端与服务端之间的网络开销，只考虑数据库内部的网络流量。 只保留最近 1 小时的 WAL 文件。 服务器实例的 90% 内存可用作数据库缓存。 只读节点数量最多为 3 个。 硬件资源有限满足更新事务，有剩余的留给读事务。  模型的目标负载主要考虑如下几个维度：\n   目标负载 单位 取值范围     数据集大小 byte 10GB - 100TB   事务处理速率 tx/s 1k/s - 100M/s   读事务占比 % 0% - 100%   持久化 9\u0026rsquo;s/year 1x9 - 11x9   平均事务处理时延 us 1us - 1ms   可用性 容灾类型 Node, AZ, Region    硬件配置上，主要是服务器实例和存储，前者选择 AWS EC2 弹性实例，后者选择 AWS EBS 块存储服务，都有多种配置可选。\n要快速找到最划算的组合，关键是能够评估出指定架构在指定的硬件下能够达到的真实负载水平。如果可以满足目标负载，则该组合可行，记录对应的成本。这样就能对比所有组合的成本，找到最划算的组合。\n下面以 Classic 架构为例，介绍模型的评估算法。\n评估算法可表示为 $S = computeClassicProperties(W, N)$：\n 输入 $W$ 表示目标负载，包含数据本身大小 $W_{dbSize}$ (单位：byte)， WAL Record 大小 $W_{ariesRecordSize}$ (单位：byte)，WAL 是否采用 group commit $W_{groupCommit}$ （True/False），目标更新事务率 $W_{reqUpdate}$ (单位：tx/s)，目标读事务率 $W_{reqLookups}$ (单位：tx/s)。 输入 $N$ 表示硬件配置，包括存储容量大小 $N_{storageCapacity}$ (单位：byte)，单次 IO 数据大小 $N_{maxIOSize}$ (单位：byte)，最大随机读 IOPS $N_{readOps}$ (单位：ops/s)，最大随机写 IOPS $N_{writeOps}$ (单位：ops/s)，CPU 每秒的周期数 $N_{cpuCycles}$，内存容量大小 $N_{memoryCapacity}$ (单位：byte)。 输出 $S$ 表示真实负载水平，包含更新事务性能 $S_{update}$ (单位：tx/s)，读事务性能 $S_{lookups}$ (单位：tx/s)， 持久化能力 $S_{durability}$ (单位：9\u0026rsquo;s/year)和事务平均时延 $S_{opLatency}$ (单位：us)。  如果 $S \u0026lt; W$，说明此组合无法满足目标负载。\n以下为算法详细介绍：\n**1：**首选要判断硬件的存储容量是否够用。Classic 架构的存储包含了数据本身大小 $W_{dbSize}$ 和 最近 1 小时内 WAL 文件大小 $ariesLogStorage$。其中，$ariesLogStorage = W_{ariesRecordSize} * W_{reqUpdate} * 3600s$。\n**2：**Classic 架构在处理更新请求时，如果对应 page 不在内存中，还需先从磁盘读上来，涉及 1 次读操作。因此，每次更新事务所需的读操作数 $readsPerUpdate$ 等于缓存缺失率 $p(cacheMiss)$，可近似为 $N_{memoryCapacity}/W_{dbSize}$。\n3：$logWritesPerUpd$ 为每次更新写入 WAL 的 IO 次数。如果设置了 group commit，则 WAL Record 会累计到 $N_{maxIOSize}$ 大小后批量写入，否则，每次更新都会写 1 次。\n4：$writesPerUpdate$ 为每次更新的 IO 次数，等于 $p(dirtyPageEvict) + logWritesPerUpd$。其中，$p(dirtyPageEvict)$ 为 dirty page 回写的概率，近似为 $更新事务占比*p(cacheMiss)$，这是因为数据会优先写到内存中的 page，如果缓存缺失，则需要从磁盘读上来，并与内存中的 page 替换，若被替换的是 dirty page，则需要回写到磁盘。\n**5：**限制 $S_{update}$ 不能达到目标性能 $W_{reqUpdate}$ 的原因，可能是读 IO 瓶颈，此时它为 $N_{readOps}/readsPerUpdate$；也可能是写 IO 瓶颈，此时它为 $N_{writeOps}/writesPerUpdate$；也可能是 CPU 瓶颈，此时它为 $N_{cpuCycles}/cpuCyclesPerTx$，其中每次事务所需的 CPU 周期数 $cpuCyclesPerTx = 4000$ ，通过在 In-Memory 数据库 LeanStore 下实测结果，并考虑在不同服务器实例下的通用性，最后保守评估的值。包含了计算和访存。\n**6-11：**基于计算 $S_{update}$ 后剩余的资源，按照同样的方法，计算 $S_{lookups}$。\n**12：**Classic 架构的数据都存在本地 SSD，因此 $S_{durability}=1-AFR_{SSD}$，其中 $AFR_{SSD}$ 为本地 SSD 的年化故障率（Annualized Failure Rate）。\n**13：**其中，$memoryOpLatency$ 和 $nvmeReadLatency$ 都是经过实测评估出来，分别为 $2us$ 和 $132us$。\n其他架构的量化评估算法大同小异。\n比如，HADR 架构处理更新事务时，还需通过网络将 WAL 复制给所有从节点，因此在计算 $S_{update}$ 时还需考虑网络瓶颈，若为网络瓶颈，则 $S_{update}=N_{networkOutLimit}/networkOutPerUpdate$，其中 $networkOutPerUpdate = W_{logRecordSize} ∗ numSecondaries$，$N_{networkOutLimit}$ 为网络带宽。\n量化评估结果 事务处理性能 在不同的目标事务处理速率（tx/s）与数据集大小的配置下，最划算的架构和硬件配置，以及对应成本如下所示：\n 上图的右侧是不同架构对比在同等配置下最低成本架构的，成本比。1 表示本身为最低成本的架构，1.2 表示该架构的成本是成本最低架构的 1.2 倍。\n 结论 1：RBD 架构在高事务处理速率的场景，成本会变得非常昂贵。\n主要因为当事务处理速率增加时，数据库需要执行更多的读写操作，这意味着远程块设备必须配置更高的 IOPS。而云服务商（如AWS）对单个服务器实例所能支持的 EBS 设备数量、总带宽和 IOPS 都有严格的限制。这意味着，处理更高的 IOPS 通常需要更大、更昂贵的实例。\n结论 2：HADR 架构在扩展性上存在较大的局限性，仅在极少数场景具备成本效益。\n即使在胜出的 3 个场景中，HADR 成本对比其他架构也没有明显的优势。\n结论 3：Socrates-like 架构是在满足中等持久化要求（99.9%）下，在大多数场景都是成本效益最好的选择。\n结论 4：Aurora-like 架构因为 SSD 多副本的原因，在数据集较大时，成本效益较低。\n比如，在 100TB + 1K tx/s 的场景，Aurora-like 架构对比最好的 RBD 架构成本要贵 2.3 倍。\n结论 5：ARM 服务器实例对比 x86 服务器实例，成本更优。\n比如，论文提到，在 1TB + 100K tx/s 的场景，同等的事务处理性能下，ARM 实例对比 Intel 实例，成本要低 13%。\n可靠性（持久化）能力 在不同的目标事务处理速率（tx/s）、数据集大小的配置下，满足不同可靠性要求的，最划算的架构以及对应成本如下所示：\n结论 6：Aurora-like 架构适合那些需要满足 11x9\u0026rsquo;s 的高可靠性场景。\n主要得益于 Aurora-like 架构的多副本设计。\n结论 7：在不需要高可靠的场景，Classic 架构仍是最划算的一个。\n可用性能力 只有 RBD、HADR、Aurora-like、Socrates-like 架构存在多节点，涉及可用性评估。\n结论 8：跨 AZ 容灾时，Aurora-like 架构的网络将成为主要成本开销。\n主要因为 Aurora-like 架构内计算节点和存储节点间存在较多的网络通信。\n结论 9：Socrates-like 架构的 Page Server 主要用于增强可用性，而不是可靠性。\n因为有本地 SSD 缓存的原因，即使是跨 AZ 容灾，Socrates-like 架构的网络成本也不高。\n哪种事务成本更高？ 观察不同的读和更新事务占比下的成本，结果如下：\n结论 10：更新事务通常比读事务有更高的成本，特别是在高事务处理速率和低数据集的场景。\n事务时延与成本 下图为在保证不同事务时延（横坐标）的限制下，需要多大的成本曲线图：\n结论 11：云上 OLTP 数据库通过低成本 SSD 实现亚毫秒事务时延，而实现 100us 及更低的时延，需要更多的数据保存在内存里，带来了更高的成本。\n考虑数据倾斜 上述所有分析都认为对数据库中的数据是均匀访问的，而在大多的真实业务中，都会存在数据热点。\n因此，论文基于 Zipf 分布来建立数据访问模型，zipf factor 越大，表示数据热点越明显。\n结论 12：除了 In-Memory 架构外，得益于空间局部性，热数据能降低成本。\n最后 根据论文分析的结论，云原生架构（Aurora-like/Socrates-like）对比迁移上云的传统架构（Classic/RBD/In-Memory）更具备成本效益，它们能够处理更广泛的工作负载，同时提供高可用性和持久性。\n在存储选择上，虽然 AWS S3 对象存储在存储成本上更低，但 IOPS 成本高出数量级；具备更高持久化能力的 EBS io2 同样面临 IOPS 成本高的问题；而 DRAM 存储成本则是遥遥领先。\n所以，NVMe SSD 是更好的 OLTP 主存选择。\n另外，Aurora-like 和 Socrates-like 架构也还有改进空间。\nSocrates-like 架构虽然分离了 Page Server 和 Log Server，但依赖远程块存储进行 WAL 持久化，限制了其可靠性和性能；\nAurora-like 架构将 WAL 实时流式负载到多个存储节点，在可靠性和性能上会更好，但 Page Server 和 Log Server 未拆分，导致灵活性和扩展性稍弱了一点。\n可以把两者的优点结合在一起，Page Server 和 Log Server 分离。page 缓存在 Page Server 的本地 SSD 上；WAL 发送到多个 Log Server 上的本地 SSD 完成持久化；它们都用并用廉价的对象存储作为后端。\n有点像 Neon 的架构了。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] OLTP in the Cloud: Architectures, Tradeoffs, and Cost, Salesforce\n （完）\n","date":"2025-09-14T00:00:00Z","permalink":"https://yrunz.com/p/oltp%E4%B8%8A%E4%BA%91%E5%93%AA%E7%A7%8D%E6%9E%B6%E6%9E%84%E6%9C%80%E5%88%92%E7%AE%97vldb25/","title":"OLTP上云，哪种架构最划算？·VLDB'25"},{"content":" 本文要介绍的是 SIGMOD'25 最佳工业论文《Unlocking the Potential of CXL for Disaggregated Memory in Cloud-Native Databases》。阿里云 PolarDB 团队基于 CXL 实现了 PolarDB 共享内存池，对比原有的 RDMA 方案实现了倍级性能提升。\n 背景 传统数据库是存算一体架构，如果要扩展资源，CPU 核、内存、存储必须一起扩，这带来了资源利用率低、成本高的问题。\n比如，一台配置为 128 个 CPU 核 + 512 GB 内存 + 2 TB 存储的服务器上部署了一个数据库，业务高峰期只使用了 70% 的 CPU、60% 的内存，但存储已经接近 100%，必须再扩容一台服务器。这下，存储容量的问题解决了，但连带着增加了 CPU 和内存的成本，它们本无需扩容。\n业界解决该问题的主要手段为分离和池化，也即云原生架构。\n存储率先完成了分离。目前，存算分离已成为云原生数据库的主流架构，实现了低成本存储扩容。随着软硬件的发展，分离内存也逐渐流行了起来，旨在提升内存利用率。\n数据库中，Buffer Pool 是内存消耗的大头，它将热数据缓存在内存里，能够有效提升数据库的读写性能。但受限于单机内存容量，Buffer Pool 无法做到很大，而且多节点间无法共享，限制了性能的进一步提升。有了内存分离技术，我们就能构建一个更大的全局共享 Buffer Pool，实现多写架构，提供更高的读写吞吐量。\n分离就意味着远程通信，传统的 TCP/IP 通信方式存在用户态和内核态多次上下文切换和数据拷贝。虽然 DPDK 加速 TCP 能够实现内核旁路，但需要 CPU 在用户态轮训处理数据包、解析协议栈，效率仍是不高。\n所以，目前数据库实现内存分离的主流技术方案是基于 RDMA 通信，绕过 CPU 实现远程内存读写。但基于 RDMA 实现共享内存池的方案也有它的局限。\nRDMA 共享内存池的局限 （一）读写放大\n虽然 RDMA 性能比 TCP 更高，但比起本地 DRAM 内存访问，还是差了不少。所以，现有的基于 RDMA 内存分离数据库通常会设置本地 Buffer Pool，作为远端内存池的近端缓存。\n然而，RDMA 属于消息语义，数据库通过 RDMA 读取远端内存池时，会以 page 为粒度，从而带来读写放大的问题。\n当本地 Buffer Pool 缓存缺失时，读取 page 中的某一行，需要从远端加载对应的 page 到本地；更新 page 中的某一行，需要先从远端把对应的 page 加载到本地，本地更新后，再将 page 写回到远端。带来了很多非必要的 RDMA 带宽浪费。\n（二）故障恢复效率低\n当计算节点故障后，存储在远端内存池的 page 仍存在，可以避免从磁盘中恢复所带来的 I/O 瓶颈。但因为数据库对 page 的写发生在本地 Buffer Pool，而本地的这些脏页仍需要传统的恢复流程：从远端内存加载 page 确认 PageLSN（最后修改的 LSN），扫描 redo log 找到恢复 LSN 点，回放 redo log 恢复。\n另外，由于故障后 Buffer Pool 被清空，需要一定的预热时间才能恢复故障前的性能水平。\n（三）RDMA 网卡瓶颈\nRDMA 网卡（RNCI）在高并发场景下容易成为瓶颈，论文《Scaling Up Memory Disaggregated Applications with Smart》显示，当并发超过 32 核时，RDMA 的性能开始下降。主要原因是 RNIC 中 Doorbell 寄存器竞争过大成为瓶颈，RNIC Cache 中的元数据换出到主存等，导致时延进一步增大。\n（四）缺少缓存一致性\nRDMA 不提供缓存一致性。这意味着在多写架构下，数据库需要建立一套缓存一致性机制。比如，page1 同时存在于节点 A 和节点 B 的本地 Buffer Pool 中，当节点 A 修改了 page1，数据库需要保证节点 B 的 Buffer Pool 以及 CPU Cache 中不能有脏数据。这无疑增加了一定的性能损耗。\n针对 RDMA 共享内存池的这些局限，论文基于 CXL 重新实现了共享内存池，实现了倍级性能提升。\nCXL 对比 RDMA 的优势 CXL，Compute Express Link，是一种建立在 PCIe 之上的低时延、高带宽的互联协议，能够实现 CPU 与内存、IO设备、XPU 加速芯片的互联。\n目前，主流的 CPU 大部分都已经支持了 CXL 协议，比如 Intel 从第四代至强处理器开始、AMD 从 Zen 4 处理处理器开始。\nCXL 对比 RDMA 有以下的优势：\n 更低的时延。远端内存数据从 RDMA 到本地内存，需要从 InfiniBand 到 PCIe 的一层协议转换，而且依赖 DMA 完成读写。而 CXL 则支持 CPU 直接读写，从而时延更低。 支持 Load/Store 内存语义。CXL 能够让 CPU 像读写本地内存一样，读写远端内存，避免了像 RDMA 一样先把数据读到本地内存再进行处理的流程，性能更高。 更简单的编程。RDMA 依赖特有的驱动程序和接口，比如 RDMA core，用户需要进行额外的适配。而 CXL 远端内存可通过 mmap 映射到本地内存，实现程序透明读写。 支持缓存一致性。CXL 3.0 后支持多节点间的硬件缓存一致性，这意味着程序不再需要单独维护缓存一致性。  PolarCXLMem: 基于 CXL 的共享内存池 基于 CXL 的低时延、高带宽能力，论文提出来了一个新的 CXL 实现的数据库共享内存池方案，PolarCXLMem。并在此基础上构建了效率更高的故障恢复机制，PolarRecv。\n要想构建一个共享内存池，第一步就是将多个内存设备连在一起，PolarCXLMem 的硬件架构如下：\n共享内存池由多个 CXL memory box 组成，每个 CXL memory box 内有多个 DDR，每个 DDR 都连接到 CXL mem controller 上。\n不同节点的 CPU 通过 CXL switch 连接到内存池中，多个 CXL switch 间由 Controller 协同控制。\n数据流走的是 CXL 通道，而控制流走的是传统以太网通道。\n软件架构 虽然 CXL 的时延和带宽都比 RDMA 更好，但对比本地 DRAM，仍有倍级的差距。\n我们很容易想到通过像 RDMA 内存池方案一样，加一层本地 DRAM 缓存来弥补这性能差距。\n但这真的需要吗？\n论文实验表明，数据库场景，DRAM-Based Buffer Pool 和 CXL-Based Buffer Pool 的吞吐量差不多。\n所以，对数据库来说，CXL 内存池已经够了，不用本地 DRAM 缓存。这样既可以节省掉 30% 的 DRAM 成本，又能简化数据流。\n因为 CXL 内存池是全局的，会在不同实例、不同租户间共享，论文设计了一个 CXL memory manager 来统一管理它：\n 数据库节点启动时，CXL memory manager 会为其分配可用的 CXL 内存地址空间。 CXL 内存池划分成 block 来管理，每个 block 包含 page 数据和对应的元数据。 page 的元数据包括 page ID，page 所在的 CXL 地址（data addr），正在使用该 page 的节点 ID（active node），脏页标志位所在的 CXL 地址（invalid addr），缓存淘汰标志位所在的 CXL 地址（removal addr）、锁状态（lock state）等信息。 CXL memory manager 按照 LRU 淘汰策略管理 page，通过 in-use list 和 free list 实现。in-use list 为正在被使用的 block，free list 为空闲的 block，当 page 长时间未被使用时，对应的 block 会从 in-use list 释放回 free list。  为避免数据库节点频繁与 CXL memory manager 交互而形成瓶颈，节点本地 Buffer Pool 会存有一份它自己的元数据。\n所以，PolarCXLMem 的整体软件架构是这样的：\n读写数据时，数据库会先从本地 Buffer Pool 中的元数据找到对应 page 在 CXL 内存池中的地址，如果本地元数据缺失，会通过 RPC 请求从 CXL memory manager 中拉取，并更新到本地。\nCXL memory manager 收到节点的拉取请求后，首先从 in-use list 中寻找该 page，若存在，则将对应元数据返回给节点；若不存在，则从 free-list 中申请一个 block，更新元数据，然后返回给节点。\n节点拿到了 page 的元数据，就可直接通过 CXL 读写远端内存池中的 page 了。\n数据一致性机制 共享内存池难免会出现数据一致性的问题，论文的内存池基于 CXL 2.0 实现，硬件缓存一致性需要到 CXL 3.0 才提供，而且尚未成熟。因此，PolarCXLMem 必须实现一套数据一致性的机制。\nPolarCXLMem 的数据一致性主要通过元数据中的 lock state 和 invalid 两个标志位来实现。\n节点对 page 的读写都必须首先获得锁，避免写冲突或读到脏数据。\n当节点完成 page 更新后，会先执行 clflush 指令将 CPU Cache 中被修改的 cache line 刷回到 CXL 内存中，保证 CXL 内存数据是最新的。\n随后通知 CXL memory manager 更新所有其他持有该 page 的节点的 invalid 标志位，随后释放锁。\n这样，当其他节点读数据前，会先检查 invalid 标志位，若为 1，就会执行 clflush 指令将 CPU Cache 的缓存数据清空，从而保证了数据的一致性。\npage 淘汰机制 CXL memory manager 为每个数据库申请的 CXL 内存容量有限，因此必须要一套 page 淘汰机制。\n如前文所述，PolarCXLMem 基于 LRU 算法，通过 in-use list 和 free list 来实现。\nCXL memory manager 会有个后台线程定期检查 in-use list 中的 page 使用情况，将最近最少使用的 page 回收至 free-list，并更新所有其他持有该 page 的数据库节点的 removal 标志位。\n节点在读数据前，也会检查 removal 标志位，若为 1，就会重新向 CXL memory manager 拉取该 page 的最新元数据。\n另外，节点侧也会有一个后台进程定期清理 removal 标志位为 1 的元数据条目。\n故障恢复机制 基于 RDMA 的内存分离方案在故障恢复时效率不高的原因主要是，本地 Buffer Pool 数据会被清空，从而需要走传统的恢复流程。\n而在 PolarCXLMem 中，page 数据和元数据都存储在共享内存池中，有独立的供电系统，即使数据库节点故障也不会受影响，从而绕过了传统低效的故障恢复流程。\n论文在 PolarCXLMem 设计了一套新的故障恢复机制，PolarRecv。\n当数据库节点故障重启后，针对那些“干净”的 page，节点可以直接从 CXL 内存池中读取，无需回放 redo log 重建，也无需预热。\n对于那些“脏”的 page，需要进行单独的处理，分成以下几种情况：\n 写 page 过程中故障。检查是否处于写锁状态来判断，通过回放 redo log 来进行重建。 B-Tree SMO（页面合并或分裂）过程中故障。B-Tree SMO 由 mini-transaction 保证一致性，这过程中相关的 page 会处于锁状态。恢复过程同上。 “过新”的 page。这种情况出现在 redo log 未来得及从 redo log buffer 刷盘时发生故障，这样，CXL 内存中的 PageLSN 就会比 redo log 中的 LSN 更新。恢复时，需要舍去 CXL 内存中的 page，通过回放 redo log 来进行重建。 LRU 链表（in-use list 和 free list）不一致。LRU 链表本身也有一个锁，如果重启时发现 LRU 处于锁状态，则会重建整个 LRU 链表。  最后 本文主要介绍了 PolarCXLMem 的基本实现原理，它最亮点的创新之一，就是打破传统 CPU -\u0026gt; 本地内存 -\u0026gt; 远端内存 的数据流设计，让 CPU 能够直接读写远端内存，既能简化系统设计、又能降低本地内存消耗、还能实现性能不下降，一石三鸟。\n虽然 CXL 内存池实现比 RDMA 内存池性能更高，但也不是完美的。\n首先是成本问题。虽然论文提到 CXL 方案可以降低 30% 的 DRAM 成本，但 CXL 方案的硬件起建成本更高，包括 CXL 内存模组、CXL Switch 交换机等。这也是 CXL 这些年很难推广开的原因。而 RDMA 硬件在现有数据中心中已经很普遍，可以直接复用。\n另外，CXL 内存池虽然有独立的供电系统，但也有故障的可能，而且一旦故障，影响范围更大。\n总之，基于 CXL 内存分离的数据库离正式大规模商用还有很长的路要走。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] Unlocking the Potential of CXL for Disaggregated Memory in Cloud-Native Databases, 阿里云\n[2] Scaling Up Memory Disaggregated Applications with Smart, 清华大学\n （完）\n","date":"2025-09-07T00:00:00Z","permalink":"https://yrunz.com/p/%E6%80%8E%E4%B9%88%E7%94%A8-cxl-%E5%8A%A0%E9%80%9F%E6%95%B0%E6%8D%AE%E5%BA%93-sigmod25/","title":"怎么用 CXL 加速数据库？· SIGMOD'25"},{"content":" To be, or not to be, that is the question. —— 莎士比亚\n 前言 “高内聚，低耦合”是软件工程领域广为流传的一个原则，在此原则的驱使下，应用系统逐渐从单体架构演进成微服务架构，甚至进一步按函数粒度拆分成 FaaS（函数即服务），呈分离趋势。\n然而，我们又能看到很多软件系统呈统一的演进趋势，比如数据系统从批处理和流处理分离的 Lambda 架构[1]演进到流批一体架构、从数据仓库和数据湖分离演进到湖仓一体；比如大模型，从文本大模型演进到多模态大模型。\n再往下到硬件基础设施，随着 AI 时代的到来，CPU 已难以招架新的 AI 负载，各类加速芯片纷纷出现，如 GPU、TPU、DPU，整体呈从通用到专用的分离趋势；但包含 36 个 CPU 和 72 个 GPU 的 NVIDIA GB200 NVL72[2]、包含 192 个 CPU 和 384 个 NPU 的 HUAWEI CloudMatrix384[3]等超节点的出现，似乎又呈现统一趋势。\n那么，从顶层业务到底层硬件，整个系统到底是趋于分离，还是统一呢？\n这是个问题。\n本文将从业务、模型、平台、基础设施四个视角出发，探讨各类系统走向分离或者统一的原因，并尝试从中总结出趋势。\n业务视角 大多数业务始于单体架构，它架构和技术栈简单，非常利于初创团队进行原型验证。然而，随着业务的增长，代码量和用户量变得更多，单体架构逐渐显现出它的局限性：\n 可维护性差。某个模块新增或修改一个特性，就必须重新升级整个系统。 可用性差。某个模块出现一个 BUG 可能会导致整个系统宕机，对外无法提供服务。  这些局限性背后的原因是系统模块间耦合太深，导致牵一发而动全身。\n我们可以根据业务属性，将模块拆分出来成为独立的服务，于是就有了服务化架构。更近一步，再将数据解耦，让每个服务拥有独立的数据库实例，就有了微服务架构[4]。\n 更详细的从单体架构演进到微服务架构的内容，参考《软件架构，一切尽在权衡[4]》。\n 虽然微服务架构也有缺点，比如服务间通信导致的性能下降，但人们更倾向于在平台或基础设施层去解决它们，而不是走回单体架构的路。\nAI 时代的应用也是如此，虽然目前 Multi-Agent 系统的失败率普遍在 60% 以上 [5]，存在 Agent 间无效通信等诸多问题，但大多人都不会怀疑从 Single-Agent 到 Multi-Agent 的演进趋势。Multi-Agent 系统下，各个 Agent 各司其职，在复杂任务的执行上对比 Single-Agent 系统有着绝对的优势。\n到目前为止，从业务应用视角看，分离貌似是个必然的趋势。其背后的逻辑是，专业的人做专业的事，在各自领域做到最优，尽可能降低相互影响。\n然而，我们再往上来到用户交互层，又会发现明显的统一趋势。从黄页到搜索框，从复杂的图形交互界面 APP 到简单的自然语言交互 AI Agent，应用趋于将分散的交互入口汇聚成简单易用的统一入口。\n模型视角 从大模型的视角，我们可以看到很多统一的趋势。\n1、模型所支持的 token 上下文长度上限变得越来越大，过去需要分多次执行的推理请求，现在执行一次推理即可。2023 年发布的 GPT-3.5 模型最高支持 4 K 上下文长度（约 4 千个汉字），到今年发布的 Gemini 2.5 Pro 模型已经可以支持 1 M 上下文长度（约 2 百万个汉字）。现假设要为一本 1 百万字的中文书籍生成 1000 字的摘要，那么需要调用 GPT-3.5 约 161 次，生成效率低且不说，还可能会损失部分全局信息导致结果不准确；而 Gemini 2.5 Pro 只需调用 1 次即可，更高效、也更准确。\n2、多模态大模型已经成为主流，今年发布的豆包 1.6、Gemini 2.5、Grok 4 等都已原生支持多模态理解能力。我们正处于多模态数据融合的时代，一个典型的例子，如今很多文章往往会融入了大量的图表、插图和视频。为了更好地整合和理解文章内容，过去可能使用文本模型 + 图像模型 + 视频模型的组合方案，现在你只需一个多模态模型即可。\n3、模型趋于集成工具调用能力。我们在利用大模型执行任务时，很多情况下都需要额外的工具调用，比如通过联网搜索或向量数据库来增强输出质量、执行一段代码等。过去最常见的解决方案是借助 LangChain 这类第三方框架搭建一套工作流，模型会先根据用户输入确定要调用哪些工具，再由框架进行工具调用并将结果反馈给模型，最后模型生成答案。\n但这类方案需要人工进行工作流编排，对普通用户来说有一定的使用门槛；而且大模型与外部系统间必然存在中间转换的开销。因此，目前趋势更倾向于将工具调用融入到模型推理的过程中，比如 Claude 4、Kimi K2，毕竟只有模型自己才知道在推理的哪一步需要调用什么样的工具才能生成质量更高的答案。\n长文本、多模态、工具调用，这些能力都是用户可以感知到的，无一例外都趋于 All in one 原生集成到模型中，呈统一趋势。\n然而，当深入到用户感知不到的模型内部架构，我们又会发现一些分离的趋势，最典型的就是 FFN 层从稠密到稀疏的演进。\n在 Transformer 架构中，Self-Attention 层后面会跟着 Feed-Forward Network 层，为线性的注意力机制加上非线性的能力，使模型能够学习更复杂的模式。在稠密架构下，FFN 层计算时它的所有参数都是激活的，无论与任务是否相关。\n举个例子，假设这些参数中分成数字处理、因果推理、文学创作、实体识别这四大类，现在用户询问 \u0026ldquo;1+1=?\u0026quot;，即使这是一个数字处理相关的问题，但其他三类参数仍会参与计算，导致算力浪费。\n如果模型能够提前识别输入问题的类别，并只激活相关的参数参与计算，那么就能有效减少算力了。\nMoE（Mixture-of-Experts）架构就是这个思路，它将不同类别的参数拆分出来成为一个个 Expert 专家，由 Gating Network（路由） 将 token 路由到对应的 Top-K 个 Expert 上进行计算，未激活的 Expert 不参与计算。\n可见，模型也不是一昧的统一。\n平台视角 回到前文微服务的例子，服务拆分后，最简单的部署方案是把各服务部署在独立的节点上。然而，微服务的工作负载通常都是不稳定的，有波峰波谷，这种部署方案会带来资源利用率低的问题。比如在某个时间点，服务 A 正处于业务高峰期，节点资源已经用满，为保障 SLA 不得不采取限流机制；而服务 B 正处于业务空闲期，有大量的资源空闲。\n更好的方法（也是当前的主流方法）是将所有节点资源池化，通过全局的调度器（比如 K8S）来进行统一调度，这样就可以将原来服务 B 所在节点的资源给服务 A 使用了。\n除了微服务，企业通常还会存在大数据、AI 训练和推荐等各类业务，过去，为了避免不同业务之间相互影响，它们通常会部署在不同的集群。随着资源调度和隔离技术的成熟[12]，不同业务趋于混合部署、统一调度，以追求更高的资源利用率。\n再来看数据系统。\n假设现在要对微服务所产生的数据进行分析，以支持商业决策。最简单的方案是直接读取各微服务的数据库。\n但这个方案有着明显的缺点：数据过于分散。随着数据量的增大，这种跨库的数据查询性能将会变得很差。更好的方法是单独设立一个关系型数据仓库（Rational Data Warehouse, RDW），将各数据库的数据同步到 RDW，由 RDW 对外提供高性能的 SQL 查询服务。\n前面我们主要关注结构化的表格数据，但企业还有很多 PDF、图片、视频等非结构化数据，RDW 无法处理。为此，数据湖（Data Lake）被引入，提供非结构化数据的读写能力。\n到目前为止，结构化数据的写入和非结构化数据的写入是分开的，可以让它们都统一入湖 ，再从数据湖中同步数据到 RDW，这就是现代数据仓库架构（Modern Data Warehouse，MDW）。\nMDW 架构下结构化数据和非结构化数据的查询是割裂的，可以在此之上构建一个统一的数据治理层，提高数据的可访问性、可发现性、安全性和可用性，这就是 Data Fabric 架构。\n到现在，结构化数据还需从数据湖复制到 RDW，存在两份数据副本，可以在数据湖之上构建一个能够同时支持结构化和非结构化数据的读写格式，比如 Delta Lake、Iceberg，这就是 LakeHouse 湖仓一体架构。\n可见，从数据仓库到湖仓一体，数据系统着明显的统一趋势，旨在减少数据副本、为用户提供结构化和非结构化数据的统一查询服务。\n除了大数据系统，数据库系统也在走向统一的路线，特别是 AI 时代到来后，能够同时支持多种工作负载的一体化数据库[13]也成为了一种演进趋势。数据库虽然在功能上趋于统一，但在技术实现上却仍是分离的趋势，一体化数据库里针对 OLAP、OLTP、向量、全文检索等不同的负载，仍是采用不同类型的引擎，只是在查询层做了统一[14]。\n另外，数据库也从最开始的存算一体，到存算分离，再到今年阿里云 PloarDB 的计算、内存、存储三层解耦[15]，通过分离架构实现极致的资源利用率和可扩展性。\n基础设施视角 在最初的冯诺依曼体系架构中，包含控制器和运算器的中央处理器，即 CPU，被设计成能够处理所有业务负载的通用计算芯片。经过多年的优化，CPU 在分支预测、指令乱序执行等流程上都已经过了深度优化，非常善于执行复杂的计算任务。\n然而，并非所有的业务都需要复杂的计算，比如图形渲染和矩阵运算，它们都由大量的简单计算任务组成，所需的是强大的并行计算能力，这促使了专用加速芯片 GPU、NPU、TPU 的诞生。类似的，还有专门针对 IO 密集型负载的 DPU 加速芯片。\n这意味着，基础设施趋于用专门的硬件去应对不同的应用负载，与前面提到的数据引擎类似，走的是分离的路线。\n如《为什么CPU不能有更多的核？ 》里所提到的，基于功耗、散热等多方面的考虑，单个芯片的核数（算力）不能无限增加，这也同样适用于单服务器。当业务所需算力超过单机算力时，最常见的做法是通过 TCP/IP 网络将各个服务器连接起来，形成一个分布式的系统。\n但 TCP/IP 网络传输，就意味着数据需要经过多层协议栈解析，而且网络带宽通常都在 100 GB/s 以下。随着 AI 时代的到来，面对计算密集型的 AI 负载，通信往往会成为主要瓶颈。\n于是，类似 NVLink 这种支持 GPU 点对点通信的高速互联协议被推出，到 NVLink 5.0 代际，单 GPU 已可支持 18 个 NVLink 连接，总带宽可达 1.8 TB/s，极大改善了通信瓶颈。\n而 CXL 协议（Compute Express Link）则更近一步，它支持远端 Load/Store 内存语义通信，允许程序像访问本地内存一样访问远端节点内存，让真正意义上的超节点成为可能。\n所以，基础设施也趋于让各类异构算力互联更高效，形成一个超节点对外提供服务，是典型的统一路线。\n分离还是统一？ 如前文分析，从业务、模型、平台、基础设施几个视角上看，整个系统既有分离趋势，也有统一趋势。\n那么，这些趋势是否有规律可循呢？\n仔细观察可以发现，整体上计算机系统在物理上趋于分离，在逻辑上趋于统一。\n不管是微服务的拆分、MoE 模型 Expert 的分离，还是不同的数据引擎、异构算力，都在朝着“专业的人做专业的事”的方向发展。其背后的逻辑源于系统设计本身是一个权衡的过程，天下没有完美的解决方案。所以，在物理实现上，是分离的趋势。\n但要想做到整系统的运行效率最高，往往需要把各类资源整合在一起，进行统一的管理和调度，比如资源池化管理、异构算力混合调度等。其背后的逻辑源于必须有全局的视角才有可能做到全局最优。所以，在逻辑功能上，是统一的趋势。\n最后，回到最初的问题，计算机系统的发展到底是趋于分离，还是统一？\n这不是一个非黑即白的选项，整体上看，物理上分离、逻辑上统一的趋势对大部分系统都成立。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 深入理解 Lambda 架构, 元闰子\n[2] NVIDIA GB200 NVL72, NVIDIA\n[3] Serving Large Language Models on Huawei CloudMatrix384, HUAWEI\n[4] 软件架构，一切尽在权衡, 元闰子\n[5] Why Do Multi-Agent LLM Systems Fail?, UC Berkeley\n[6] 快速了解生成式AI, 元闰子\n[7] Introducing Claude 4, Anthropic\n[8] Kimi K2: Open Agentic Intelligence, Moonshot AI\n[9] A Comprehensive Review of Multimodal Large Language Models: Performance and Challenges Across Different Tasks, 中国西北工业大学等\n[10] Qwen 3 Model Family Explained: Dense vs MoE Architectures, Topmost Ads\n[11] Deciphering Data Architectures, James Serra\n[12] Volcano, CNCF\n[13] 一体化数据库，打造 GenAI 时代数据底, IDC, Oceanbase\n[14] 云原生多模数据库 Lindorm, 阿里云\n[15] 我，PolarDB云原生数据库，5年来实现这些重磅技术创新, 阿里云\n （完）\n","date":"2025-08-03T00:00:00Z","permalink":"https://yrunz.com/p/%E5%88%86%E7%A6%BB%E8%BF%98%E6%98%AF%E7%BB%9F%E4%B8%80%E8%BF%99%E6%98%AF%E4%B8%AA%E9%97%AE%E9%A2%98/","title":"分离还是统一，这是个问题"},{"content":"前言 Databricks 在 2025 年 5 月 14 日正式宣布以 10 亿美元收购开源数据库初创公司 Neon[1][2]，它的产品是一款兼容 PostgreSQL 的 Serverless 数据库。Neon 的 slogan 是 \u0026ldquo;Ship faster with Postgres\u0026quot;，其最初的目的是为应用开发者提供一个低成本、开发高效的数据库底座。\n回顾 Databricks 近两年的收购策略，Databricks 正围绕其 Data + AI 战略迅速补强 AI 短板，所收购的产品已几乎可组成一条完整的 AI 工作流水线。\n从 Databricks Data Intelligence Platform 架构图[4]来看，Databricks 几乎已具备 Data -\u0026gt; AI Model -\u0026gt; AI Application 的 AI 全栈能力，但 AI Application 所依赖的数据库仍然选择第三方的 Amazon RDS 等。\n所以，对 Databricks 来说，收购一款高度适配 AI 应用的数据库变得势在必行。\n在各类 AI 应用中，AI Agent 无疑是最活跃的发展主线。2025 年被誉为是 AI Agent 元年，相关的框架、协议、应用都得到了快速的发展。据 Gartner预测，到2028年，至少 15% 的日常工作决策将通过 AI Agent 自主做出，33% 的企业软件应用程序也将包含 AI Agent。\nDatabricks 最终选择 Neon，无疑是看中了 Neon 数据库的高度亲和 AI Agent 业务特征的关键能力。\n本文将从 AI Agent 业务特征讲起，探讨 AI Agent 时代需要什么样的数据库，以及 Databricks 选择 Neon 的原因。\nAI Agent 业务特征 AI 时代最受益的人群可能是是开发者，AI 辅助编程的出现，使得开发变得更加容易。当前主要有两类产品：\n Agentic IDE：更多的是作为开发工具，辅助以自然语言生成 App 的能力。典型的产品是 Cursor，以及字节的 Trae。 Text-to-Web App Generator：通常提供从 App 生成到上线的全流程托管能力。典型的产品是 Bolt 和 Replit。  a16z 数据显示[4]，2024 年以来，这两类产品的月活量急剧增加。Bolt 更是宣称在 2025 年前两个月已实现 2000 万美元年化收入，以及 200 万注册用户。\nAI 辅助编程背后的关键技术就是 AI Agent。以 Text-to-Web App Generator 为例，它的主要业务流程如下：\n用户只需通过自然语言命令 AI Agent 生成一款 App，后面的流程全程交给 AI Agent 规划和执行即可，用户只需不断点击确认或否定它的策略即可。\n在该业务流程中，数据库扮演者核心的角色。一方面，数据库几乎是 Web App 不可或缺的组件；另一方面 AI Agent 也需要它来存储记忆上下文，比如用户的历史对话、中间结果、计划等。\nAI Agent 对数据库的使用有着如下的几个特征：\n 即时创建。在收到用户的命令后，依据自身规划即时创建数据库、表结构。 大量小实例。从前文分析可知，AI 辅助编程的用户数量暴涨，带动了通过 AI Agent 创建数据库实例的数量上升，而且这类实例通常是小实例。Neon 数据[1]显示，2025 年以来，由 AI Agent 创建的实例数量已经是开发者创建的 4 倍之多。  活跃时长不稳定。一方面，AI 辅助编程的用户通常集中在白天使用；另一方面，开发调试阶段，数据库活跃时长通常很低，即使 App 发布后，新应用的流量也会是低且不稳定。 频繁调试。AI 的不确定性较大，很可能首次创建的 App 并不符合用户预期，需要频繁进行策略更改、调试。调试过程大概率会涉及数据回滚。  根据这些负载特征，我们很容易得出 AI Agent 对数据库的能力要求：\n   负载特征 能力要求     即时创建 极短的冷启动时长，提供极佳的用户体验。   大量小实例/活跃时长不稳定 自动弹性伸缩，降低平台成本。   频繁调试 PITR，提供任意时间点数据恢复能力。    看到这里，我们很容易联想到云原生 Serverless 数据库，快速冷启动、自动弹性伸缩、多租户隔离都是 Serverless 的基本能力。\nNeon 就是一款兼容 PostgreSQL 的 Serverless 数据库，相比其他同类产品， Neon 在某些方面做到了更加极致，这也是它能打动 Databricks 的根本原因。\n下面，我们将介绍 Neon 是如何匹配这些负载特征的，以及它与其他 Serverless 数据库相比的不同之处。\n存算分离架构 存算分离架构是云原生 Serverless 数据库能够实现快速冷启动、自动弹性伸缩的基础。\n在存算一体的架构下，传统数据库引擎的计算和存储都绑定在一个节点内，这会导致如下几个问题：\n 启动慢。节点是有状态的，启动时需要对数据进行初始化、加载等流程。 扩容慢。传统数据库通常通过主从或分布式架构应对高并发场景，扩容出新实例后，还需完成数据同步才能对外提供服务。 成本高。单节点磁盘容量有限，存储扩容意味着 CPU 也要跟着扩容，成本陡增。  把计算和存储分离，是解决上述几个问题的有效方法。\n存算分离后，存储层通常采用低成本的对象存储，无限扩容，不影响计算节点，且所有计算节点共享同一份数据，成本陡降；\n计算节点无状态，启动时省去各种数据初始化流程，只需与存储层建立连接，启动时长变短；且新扩容实例不再需要进行数据同步，扩容时长也变短了。\nNeon 采用了典型的存算分离架构，存储层除了远端对象存储外，还有负责 WAL 文件持久化的 Safekeeper，以及负责将 WAL 预写日志文件转化成 page 供计算层查询的 Pageserver。\n极速冷启动 存算分离架构让秒级的计算实例冷启动成为可能，而 Neon 不满足于此。\nNeon 计算实例的冷启动过程大致可分层实例创建、实例配置、网络配置：\n为了更优的冷启动速度，Neon 做了如下几个优化：\n（一）减少不必要的实例重配置\n一般来说，数据库配置很少变更。因此，处理首次冷启动，实例配置并非必选项。只需持续监听配置变化情况，在变化时再在冷启动时实施实例配置操作。\n（二）Compute Pool\n提前创建好一批“空”的计算实例，待用户请求到来时，优先从 Compute Pool 中拉取，若配置不符合规格，则新创建。\n（三）IP 缓存\n提前创建好一批随时可用的 IP 地址，避免地址创建和内部 DNS 路由消耗。\n（四）关键路径代码优化\n针对冷启动关键路径代码进行深度优化。\n通过这些优化，Neon 将冷启动时长从 3~6s 缩短至 500ms[8]！\n极致自动扩缩容 说起云原生，我们通常会想到 K8S + 容器 + 水平扩缩容，但 Neon 选择了 K8S + 虚机 + 垂直扩缩容的方案。\n原因主要有如下几个：\n（一）虚机更强大的隔离\n相比容器，虚机提供了更强大的隔离能力。\n虚机提供的是 OS 级的隔离，每个虚机都运行一个独立于宿主机的 GuestOS；\n而容器提供的是进程级的隔离，共享宿主机内核，通过 namespace 和 cgroup 实现。\n原生 K8S 并不支持对虚机的管理，所以 Neon 通过 NeonVM 来实现基于 QEUM + KVM 的虚拟化方案。\n（二）垂直扩容无需冷启动\n在K8S 中，水平扩容通过新创建 Pod 实现，这也意味着需要冷启动一次。\n而垂直扩容直接在原来的 Pod 上动态扩容 CPU 和内存资源，免去冷启动流程。\n在 Neon 中，扩缩容以 CU（Compute Unit，计算单元）为单位，每个 CU 固定为 1 vCPU 和 4 G 内存。\n当然，垂直扩容也有它的问题，当节点资源不足时，扩容就会失败，这时就需要将计算实例迁移到其他节点上。\n（三）虚机的快速热迁移\n原生 K8S + 容器 的迁移主要通过在新节点上重新创建 Pod 实现，涉及冷启动流程。\n而 Neon 基于虚机热迁移，能实现无缝实时迁移，业务中断时长可以控制在 100ms 以内。\n在自动扩缩容上，Neon 也做到了极致，支持 Scale to Zero。当计算实例在一段时间，比如 5 分钟后，实例将自动缩容至零。\n极速的冷启动使得 Neon 有底气这么做，从而为用户提供极致的低成本！\n极速 PITR PITR（Point-in-Time Recovery），时间点恢复指的是数据库可以恢复至任意特定点的历史版本数据，通常基于快照 + WAL 回放的方案实现。\n传统数据库，包括大部分云原生数据库，都会将备份恢复视为一项昂贵、偶尔使用的操作。它们通常会将备份数据与当前数据区别对待，分开存储。\n基于 ROW（Redirect-On-Write）技术可以做到秒级备份，但恢复时长则需要分钟级，甚至更久。\n以下为阿里云 PolarDB MySQL 版本的恢复流程，主要包含三个流程（以 2 核 8 GB 的计算资源为例）：\n 创建一个临时节点，并将快照数据恢复至该节点，耗时约 3~10 分钟。 回放 WAL 日志增量数据，恢复速度约 1.5 GB / 分钟。 将数据恢复至原集群，恢复速度约 2.16 GB / 分钟。  分钟级的恢复速度显然无法满足 AI Agent 频繁调试的业务特征。\n提起快速数据回滚，我们可能首先会想到 Git，它基于分支管理代码，支持恢复至任意分支的任意的 Commit 点，速度极快。\nNeon 正是以此为灵感，创新性地实现了 Branching 和 Time Travel 特性，像 Git 管理分支一样管理数据。\nNeon 采用 Non-overwriting Storage 架构，将历史数据库与当前数据存放在一起，一视同仁，这使得历史数据不再需要先恢复后访问，做到了实时历史数据访问。\n传统关系型数据库几乎都是基于 Overwriting Storage 架构，处理 Update 请求时，直接将源数据更新为新数据。采用 Non-overwriting Storage 架构大多是 Key-Value 数据库，比如 LevelDB、RocksDB 等，它们通常基于 LSM-Tree，不断追加新数据。\nNeon 采用了类似 LSM-Tree 的 KV 存储架构，数据按层组织：\n Image layer 包含某个 LSN 下，某个 Key Range 的所有 page 数据快照。它由 Neon 后台自动创建，用于加速历史数据访问。 Delta layer 包含某个 LSN Range 和 Key Range 下的 WAL Record，只存储带来数据变更的部分。   LSN（Log Sequence Number）是 WAL 的唯一标识，递增，可当时间戳来用。\n Neon 的 PITR 实现流程：找到离恢复点最近的 Image layer，回放此 Image layer 之上的 Delta layer 的 WAL Record。其中的关键，就是如何快速找到对应的 Image layer 和 Delta layer。\nNeon 查询数据的接口为 GetPage(Key, LSN)，把 LSN 作为纵坐标，Key 作为横坐标，在查询 (Key, LSN) 开始向下划条线，首先遇到的 Image layer 及其之上的 Delta layer 即为要找的 layer。\n这不是个简单的问题。最简单的方法是按照追加数组的形式组织 WAL，查询时间复杂度是 O(N)。但在大数据量场景下，查询性能将无法被接受。\nNeon 的解决方案是使用 RPDS (Rust Persistent Data Structures) 数据结构来构建 WAL 索引，简单来讲，就是把当前数据和历史数据统一按照树的形式组织，基于 Copy-on-Write 实现数据更新。如下为简单的原理示意图：\n对比传统按追加数组形式组织的 WAL Record，RPDS 实现了随机读时间复杂度从 O(N) 降至 O(logN)。\n当然，Non-overwriting Storage 架构的代价是消耗更多的存储空间，特别是在频繁更新的工作负载。\n所以，它必须要有 Compaction 和 Garbage Collecting 机制，避免数据无止境增长。另外，还能将冷数据存储到低成本的对象存储上，进一步提升了该方案可行性。\n基于这个方案，Neon 实现了秒级的 PITR，再加上高易用的类 git 命令，使得 Branching 和 Time Travel 成为它的 killer feature之一。\n最后 Neon 在 2021 年开源，最初主要面向的用户是开发者，面向的场景是大量小实例。它的很多特性都是以提供极致的开发体验为目的，比如 Scale to Zero 为开发者提供极低的成本、Branching 使得开发者能够基于生产数据创建副本调试而不影响生产环境。\n从某种程度上看，AI Agent 就是一个“智能开发者”，这使得 Neon 非常匹配 AI Agent 的业务特征，这也是他被 Databricks 高价收购的重要原因。\n类似地，阿里云 AnalyticDB PostgreSQL 版基于 Neon 架构推出了 Serverless 版本，也提供了 Scale to Zero、Branching 等特性。\n总结起来，亚秒级冷启动、Scale to Zero、Branching等特性，正成为 AI Agent 时代数据库所必备的能力之一。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] Databricks and Neon, Databricks\n[2] Neon and Databricks, Neon\n[3] Databricks architecture overview, Databricks\n[4] The Top 100 Gen AI Consumer Apps - 4th Edition, a16z\n[5] From Prompt to Product: The Rise of AI-Powered Web App Builders, a16z\n[6] Neon - Serverless PostgreSQL - ASDS Chapter 3, Jack Vanlightly\n[7] Architecture decisions in Neon, Neon\n[8] Cold starts just got hot, Neon\n[9] 1 Year of Autoscaling Postgres: How it’s going, and what’s next, Neon\n[10] Deep dive into Neon storage engine, Neon\n[11] A Deep Dive Into Neon’s Instant PITR, Neon\n[12] 云原生数据库 PolarDB MySQL 版库表恢复整体流程和预估时间, PolarDB\n[13] 4年10亿美金，Neon用Serverless PG证明：AI需要的不是“大”，而是“隐形”, 阿里云AnalyticDB\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2025-06-02T00:00:00Z","permalink":"https://yrunz.com/p/ai-agent%E9%9C%80%E8%A6%81%E4%BB%80%E4%B9%88%E6%A0%B7%E7%9A%84%E6%95%B0%E6%8D%AE%E5%BA%93/","title":"AI Agent需要什么样的数据库？"},{"content":"荐语 本文要介绍的是O\u0026rsquo;Reilly在2022年出版的书籍，《The Staff Engineer\u0026rsquo;s Path》。\n书中主要介绍走技术路线的工程师，或者说技术专家（Staff Engineer），需要具备哪些能力他们不仅仅要会写代码、做系统设计这么简单，技术能力只是基础，还要具备大局观、执行力和影响力。\n如果你想走技术路线，那么本书值得一读。\n前言 程序员的发展，总会遇到一个分叉路口，走技术路线，或者管理路线。\n程序员总有股技术情怀，相信很多人最初都有坚定走技术路线的想法，但走着走着，最后都走上管理路线。\n一方面，管理路线是清晰的，且有着大量成功的案例。对于沟通能力强、临危不乱、善于帮助周边同事成长的人来说，似乎成为管理者是一个常见，也是默认的职业发展规划。\n另一方面，走技术路线，在很多人的理解里，就是不断提升技术能力。但是，这样走了一段路后，很可能就会遇到了向上发展的瓶颈，而转向管理路线。于是就有了所谓“不能一直搞技术”的结论。\n然而，走技术路线遇到发展瓶颈的原因，并非是“不能一直搞技术”，而是“不能只搞技术！”\n要走好技术路线，成为一名技术专家，拥有扎实的技术功底只是基础。除此之外，还需要具备全面的大局观、强大的执行力、良好的影响力三大能力支柱。\n下面，我们将从技术专家的角色定位讲起，随后介绍这三大能力支柱，让你对“技术路线”有更深刻的理解。\n技术专家是个什么角色？ 提到技术专家的角色定位，我们可能首先会想到“关键方案设计和开发”、“疑难问题解决”、“技术决策”，但还不止这些。\n不是Manager，但也是个Leader 首先，技术专家也是一个Leader。\n在技术路线上，深入技术领域只能让你走到高级工程师这一步，要想成为技术专家，主导更大的项目，你需要与更多的人沟通协作、说服他们接受你的方案、成为他们的榜样。\n所以，技术专家不可避免地要成为一个Leader。\n与Manager不同，技术专家通常没有直接下属，不负责人员管理，但他们同样需要在技术决策、设计和代码review等方面表现出领导力。\n是个技术牛人 技术专家是一个高度专业化的角色。\n你提出的设计和代码review意见，要有指导意义，能够让产品的代码和架构变得更好；做技术决策时，要理解各类因素的权衡，必要时能够深入代码细节，提出正确的问题。\n所以，技术专家必须要有扎实的技术基础。\n但这并不意味着技术专家要写大量的代码。为了高效地解决问题，很多时候，他们负责方案设计、指导中高级工程师完成编码，可能更有意义，这也是给其他人更多的成长机会。\n牢记一点，技术专家最重要的是解决问题，而不是如何解决，代码、文档，都是一种方式。\n了解了技术专家的角色定位，接下来本文将介绍技术专家所需要的，除技术基础之外的，三大能力支柱：大局观、执行力、影响力。\n大局观 一个不断发展的组织，总会进行各种各样的决策：选择什么技术、打造什么产品、继续投资或者否决它。一个好的决策需要有详细、准确、全局的上下文知识来支撑。\n一名技术专家很可能只是负责组织下某个产品或特性的技术方案，因此，很容易陷入局部最优的困境。\n为保证能做出前瞻性的决策，技术专家需要成为一个纵观全局的人。而建立下面的这三张地图，也许能够帮你构筑大局观。\n定位图（Locator Map）：广阔的视角 要避免局部最大化，需要建立一张定位图（Locator Map）以获得更广阔的视角：\n 认清组织目标，找到真正重要的是什么。程序员很容易陷入唯技术论，技术专家要避免这一点。技术只是达成目标的手段，最重要的是搞清楚目标，是组织的目标，不是某个特性或小团队的目标。 理解客户的真正痛点。产品是为用户服务的，就算你的产品能够提升XX百倍性能，没有用户用，一样是没有价值。 了解领域行业动态。你正在解决的问题，很可能业界已经有人解决过。时刻了解你所在行业的动态，才能更高效地解决问题，更好地实现创新。  地形图（Topographical Map）：正确的导航 即使你有很强的技术能力，也可能会遇到好想法得不到重视、风险/困难无法提前识别、项目进展难以推动的情况。这时，你需要建立一张地形图（Topographical Map），帮助你实现技术落地、项目成功：\n 理清组织结构，建立良好关系。了解你所在组织的结构，理清每个子团队所负责的业务以及关键角色，并于他们建立良好的关系。没错，技术专家也需要懂得“搞政治”，这样才能更好地开展工作。 了解组织文化。你的组织是倡导口头汇报还是书面汇报？鼓励快速试错还是深思熟虑后的变革？了解并融入你所在组织的文化，金子才能更好地发光。 保持地形图更新。为了更好地支撑商业成功，组织结构和人员的变化很正常，时刻更新你的地形图。  藏宝图（Treasure Map）：长远的目标 最后，你需要一张藏宝图（Treasure Map），找到自己的长远目标（通常是组织目标或者能够支撑组织目标的达成），并为之奋斗。\n人们很容易过度关注短期目标，比如，当前版本发布的功能、某个用户对产品的差评等。寻求短期成功可能会限制你的发展。\n要从更宏观的角度思考：\n你想要达到什么目标？\n你为什么要做这些事情？\n这些事情能支撑你的目标达成吗？\n本周的工作是否偏离了你的目标？\n你是否还在不断接近你的目标？\n执行力 技术专家需要很强的自驱力，要学会自己给自己安排任务，主动、提前为组织出谋划策，而不是等待被安排。\n所以，技术专家通常会参与多个项目，处理很多事情。但人的精力总是有限的，学会时间和价值管理很重要。\n可以尝试按照以下几个维度，为你所参与的项目/事宜进行打分，在有限的时间内完成最有价值的事情：\n 能量（Energy）：这件事情将会耗费你多大的精力。 生活质量（Quantity of Life）：如果你的工作恰好是你的兴趣，那么你每天的生活质量将会非常的高。但这种机会是可遇不可求的，很多时候工作会降低你的幸福感。 信誉（Credibility）：信誉是领导力的一种体现，主导重要的项目、解决关键的难题，都能帮你提升在组织里的信誉。 社交资本（Social capital）：花时间与他人相处、与他们一起工作，建立良好的社交关系，互为战友。 技能（Skills）：技能是一种需要反复练习才能长久拥有的东西，你的工作内容一定程度上决定了你拥有哪些技能。  如何进行事情的优先级排序，一方面是要契合组织的目标，另一方面，也是你自己的职业规划。\n项目难免会遇到一些阻塞点，技术专家拥有强大的执行力的另一个重要体现是，能够及时推动项目关键问题的解决。\n首先要搞清楚阻塞点是什么（理解和解释），然后是做一些力所能及的事情（让事件变得更容易），再不行就上升求助（获得组织支持），行不通就换个方向（指定替代计划）。\n举个例子，假如项目正阻塞在另一个团队迟迟未能交付某个特性上，那么，你可以尝试按以下几个步骤去推动：\n 理解和解释。首先要沟通，弄清楚实际情况，他们是否不理解原始需求？或者有其他事情阻碍了他们？再次向他们解释项目的重要性，并澄清需求和交付时间。 让事情变得更容易。如果是他们忙不过来了，能否进行需求裁剪？或者考虑帮他们解决某些关键难题？ 获得组织支持。上升求助组织的领导，让具备更大全局观的领导完成优先级的排序。 制定替代计划。如果真的交不出来，能否有其他方法绕过这个特性？  总之，推动问题解决不仅是Manager的责任，技术专家也要为达成目标而“不择手段”。\n影响力 技术专家除了做好本职工作外，还要不断构筑自己的影响力，可以分成两个维度，自我提升和扩大影响范围。\n在自我提升方面，要做好以下几点：\n 保持学习。快速构建并持续丰富你所在领域的知识体系，然后通过项目实践，在解决问题过程中去学习和巩固。 认识自己。对自己的能力有一个深刻的认知，知道自己能做什么、能做到什么程度、需要多长时间、哪些是不擅长的。只有这样，才能不断发展自己的长处，修复短板。 严于律己。敢于承认自己的不足和错误，可以多发表观点，寻求建设性的批评建议。  在扩大影响范围上，要做好以下几点：\n 敢于担责。要敢于成为项目的第一负责人，解决项目的关键阻塞问题，成为一个可靠的人。 培养团队。你可能是解决问题最快的人，但不一定是最合适的人，多留些机会给中级/高级工程师，给他们足够的试错空间。 分享经验。通过写作、演讲等形式分享你的经验和知识，必要时提供一对一的指导帮助。  《On Being A Senior Engineer》这篇文章里有一句话，\n“作为一名工程师，其他人愿意与你合作的程度，直接反映了你的职业生涯的成功程度。”\n努力成为一名每个人都想与你共事的技术专家吧！\n最后 你可能已经发现，走技术路线也并不能一直安安静静地写代码、做系统设计。\n它需要你去了解整个组织结构，与周边团队建立良好关系，建立起大局观；\n它需要你具备强大的执行力，主动推动问题解决，为达成目标“不择手段”；\n它需要你持续提升自我，扩大影响力范围；\n读到这里，你还会走技术路线吗？\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] The Staff Engineer\u0026rsquo;s Path, Tanya Reilly\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2025-03-09T00:00:00Z","permalink":"https://yrunz.com/p/%E8%B5%B0%E6%8A%80%E6%9C%AF%E8%B7%AF%E7%BA%BF%E9%9C%80%E8%A6%81%E4%BA%9B%E4%BB%80%E4%B9%88/","title":"走技术路线需要些什么？"},{"content":"前言 与GPU动辄成千上万个核不同，CPU核数目前仍处于百核级别。AMD基于最新Zen 5架构发布的产品中，核数最多的型号AMD EPYC™ 9965也只有192个核。\n那么，为什么CPU不能有更多的核呢？\n这是一个系统工程的问题，涉及到多种因素的权衡。本文将结合AMD Zen 5 EPYC 9005系列（下文简称Zen 5）的架构，从CPU制造流程讲起，带读者理解现代CPU架构的同时，解释为什么CPU不能有更多的核。\n从CPU制造流程讲起 CPU的制造过程可以简单总结如下：\n晶圆是制作CPU的主要材料，它由圆柱形单晶硅锭（用直拉法从熔融后的高纯度多晶硅中拉出）切片而成，目前主流的晶圆尺寸为12英寸，约7万mm²。\n一片晶圆能够光刻若干个芯片，我们把这种未经封装的单个功能芯片称为Die（裸片）。我们很容易想到，如果把Die设计得足够大，一片晶圆只光刻一个Die，那么单Die的核数就能更多了。\n然而，受光刻技术限制，当前光刻掩膜版最大面积约858mm²，一旦超过这一阈值，需通过多次曝光拼接完成光刻，但会导致工艺复杂度增加和良率显著下降。\n另外，即使在最大光刻掩膜版面积内，Die的面积也不是越大越好。通常来说，晶圆都有一定的缺陷密度，考虑极限场景，晶圆面积=光刻掩膜版最大面积，如下图所示，Die的面积越小，良品率就越高。\n所以，出于成本的考虑，追求更高的良品率会限制芯片的面积，从而限制了芯片核的数量。\n既然单块芯片面积受限，那能不能把多块小芯片组合成一块大芯片呢？\n当然可以！\nChiplet架构：把小芯片组合起来 Chiplet是一种将传统单片式系统级芯片（SoC）拆分为多个功能模块化小芯片（Die），再通过先进封装技术（如2.5D/3D堆叠）互联集成的设计架构。其核心思想是通过标准化接口将不同工艺节点、不同功能的Die灵活组合，形成高性能异构系统芯片。\n所以，Chiplet既突破了传统单芯片的物理极限（858mm²），也兼顾架构设计的灵活性。\nChiplet架构下，Die通常会被分成CPU Die（CCD）和IO Die（IOD）两类。\n CPU Die负责高频计算，集成CPU Core（核）、高速缓存（L2/L3 Cache）等，采用先进的工艺，提供更高的计算密度； IO Die负责资源管控，集成内存控制器、PCIe总线、SATA接口等，采用成熟的工艺，更好保证信号的完整性。  AMD Zen 5采用Chiplet架构设计，一块完整的芯片包含多个CPU Die和1个IO Die。其中，CPU Die采用3nm/4nm工艺，IO Die采用6nm工艺。\n虽然Chiplet理论上支持，但通常不会为了得到更多的核而无限制地扩展CPU Die。比如，AMD Zen 5一个CPU Die上有8个核，当前最多支持集成16个CPU Die，共计128核。\n其中原因主要受以下几点限制：\n 功耗和散热问题。集成更多的CPU Die意味着更高的功耗，散热可能成为问题，会导致芯片温度过高，影响晶体管的寿命。 内存带宽受限。一块芯片上通常只有1个IO Die，其DDR内存通道数是固定的，比如，AMD Zen 5的内存通道数为12。更多的核意味着内存带宽竞争更大，平均单核内存带宽更低。 缓存一致性机制更复杂。虽然可以通过增加IO Die的数量来提升内存带宽，但这同样受功耗和散热问题限制，而且更多的CPU Die会导致缓存一致性机制过于复杂，得不偿失。  在拥有一定面积的芯片上，既然无法通过扩展更多的CPU Die来增加核数，那么能否提升单个CPU Die的核数呢？\n当然可以！通过把核做得更小，就能让一定面积下的CPU Die拥有更多的核。\n更小的核 以AMD Zen 5 EPYC 9005系列为例。\n除了前文提到的8核/Die的Zen 5 core，还有16核/Die的Zen 5C core。\n它们之间的一些参数如下：\n    Zen 5 core Zen 5C core     单Die核数 8核 16核   L2 Cache大小 1MB 1MB   共享L3 Cache大小 32MB 32MB   平均单核L3 Cache大小 4MB 2MB   基础频率 2.6-4.2GHZ 2.1-2.3GHZ   最高睿频频率 5GHZ 3.7GHZ    从上述对比可以看出，Zen 5C的核数更多，但单核性能更弱。这很好理解，每个核都由各种单元组成，如下展示了Zen 5的微架构图：\n为了把核做得更小，核内各单元的尺寸/电路必然会缩小，性能也就更低了。比如，TAGE分支预测器的BTB容量变小，会导致分支预测准确率下降，从而影响单核性能。\n所以，单CPU Die的核数也不能太多，否则单核性能太低，也没有意义。\n另外，GPU之所以能够做到成千上万个核，是因为GPU的设计理念上是并行性和吞吐量有限，要求同时处理数千个相似任务，这使得GPU的核极度简化，仅保留基础算术逻辑单元（ALU）和少量高速缓存。所以GPU的核能够做到足够小！\n而CPU的设计理念是追求通用性和灵活性，要求处理复杂的逻辑控制、分支预测、乱序执行等通用计算任务，所以核无法做得很小。\n既然单CPU Die核数不能太多，单CPU上的CPU Die也不能太多，那么能不能把多个CPU互联起来，形成一个拥有更多核的服务器呢？\n当然可以！\n多路CPU系统 如果一块主板上只有1个CPU，通常会把它称为单路（Single-Socket）CPU系统，有2个CPU则称为双路（Two-Socket）CPU系统，以此类推，还有四路CPU、八路CPU等。\n如今，单路和双路都是常见的配置，比如，AMD Zen 5提供了单路和双路两种配置。\n在多路CPU系统中，CPU之间通过IO Die上的高速互联接口完成互联。AMD的高速互联协议为Infinity Fabric，如下展示了AMD Zen 5在双路配置下的互联结构图：\n AMD Zen 5的IO Die中，Infinity Fabric高速互联接口与PCIe接口用的是同一个物理接口，所以这里存在计算和IO的权衡。多路CPU有着更多的计算算力，但更少的IO扩展接口。\n 类似地，多路CPU系统中的路数也不能无限扩展，整机功耗和散热问题、互联总线的带宽和时延瓶颈、更复杂的缓存一致性协议等都是阻碍因素。\n还要考虑业务负载 到目前为止，我们都在围绕成本、物理限制、技术复杂度等维度来讨论。\n假如我们克服了这些问题，并生产出一款超多核的CPU型号，那么它就一定能够很受欢迎吗？\n未必！我们忽略了最重要的因素，业务负载。\n比如，对Redis来说，它是一个单线程的业务负载，单实例下，给它再多的核也用不上，它需要的是更强的单核性能。\n比如，对IO密集的存储型业务来说，它不需要很强的CPU计算性能，需要的是更大的IO带宽和更低的IO时延。\n比如，对云基础设施提供商来说，一台整机服务器上通常运行着多个容器业务。多路CPU系统确实能够减少机柜的数量，从而降低成本。但考虑到系统的可靠性，他们可能更偏爱核数更低的单路CPU服务器，因为单路CPU下，单机故障的业务影响范围会更小。\n所以，“为什么CPU不能有更多的核？”这是一个系统工程的问题，要经过多方面因素的综合权衡。\n更好的方法是为用户提供多种选择，让用户能够根据自身的业务需求，选取合适的CPU型号。\n如下为AMD Zen 5 EPYC 9005系列的全部型号，多达27种：\n总结 本文主要针对“为什么CPU不能有更多的核？”进行了讨论，总结起来，主要有以下几个原因：\n 芯片本身的物理限制。更大面积的芯片往往会有着更低的良品率，成本会急剧上升；而且功耗会更大，散热很可能成为问题，从而影响晶体管的寿命。 带宽和时延限制。随着核数增多，内存带宽、高速缓存带宽、CPU的高速互联带宽都会成为瓶颈。 缓存一致性协议限制。更多的核数，特别是在多路CPU系统下，缓存一致性会变得非常复杂，为了追求更多的核数，而让系统复杂性急剧增大，可能会得不偿失。 业务负载不一定合适。技术服务于业务，很多业务并不需要很多的核，所以业务合适最重要。  总之，世上没有有一款完美的CPU，系统设计，一切尽在权衡！\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 5TH GEN AMD EPYC™ PROCESSOR ARCHITECTURE WHITE PAPER, AMD\n[2] AMD EPYC™ 9005 SERIES PROCESSORS DATASHEET, AMD\n[3] CPU 是如何制造出来的, 爱涂鸦啊\n[4] 现代分支预测：从学术界到工业界, Easton Man\n[5] 深入理解分支预测原理，揭开AMD Zen 5的高性能秘诀, 元闰子\n[6] DeepSeek\n[7] 秘塔AI搜索\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2025-03-02T00:00:00Z","permalink":"https://yrunz.com/p/%E4%B8%BA%E4%BB%80%E4%B9%88cpu%E4%B8%8D%E8%83%BD%E6%9C%89%E6%9B%B4%E5%A4%9A%E7%9A%84%E6%A0%B8/","title":"为什么CPU不能有更多的核？"},{"content":"每到初四、初五，长辈们常会感叹道，“又一年！”\n虽然一个月前就已经是2025年，但也只有过了农历新年，才感觉“年”过完了，才会从“明年”改口成“今年”。\n“有钱没钱，回家过年”，过年是中国人的头等大事，那么过“年”，到底过的是什么呢？\n在甲骨文里，“年”字上面是“禾”，意为庄稼；下面是弯腰垂臂的“人”，意为人把收获的庄稼背回家。在古代，中国人春种、夏耘、秋收、冬藏，一年四季辛勤劳作，终得收获，自然要好好地庆贺一番、过个节，这个节就叫“年”。\n如今，“年”的寓意早已超越了丰收，它是现代人的一种文化符号。\n过年，长辈们有长辈的过法。\n长辈们的年过得早，大扫除、拣年货。待到除夕，还须早早起床，乒铃乓啷地忙活起来。\n把阉鸡、五花腩、大干鱿或金丝鱼煠（粤语读saap6，用沸水煮）熟，煮上汤圆或大薯糖水，配上一些柿饼和糖果，作为拜神的供品。等时辰到了，用一个深红色的木盆把供品抬到祖先神位前，插上蜡烛，点着香，口中念着祷词，虔诚地跪拜，“保佑众家顺顺利利”。\n在屋门口架上木梯子，一人爬上去贴对联和门神公，一人在下面扶梯和纠正，\n“正嗲嘛？”\n“正嗲！”\n贴完对联，还要为地主、灶君、天神的神位换上新的利是，紧接着是准备团年饭、开年用的拜神供品······\n长辈们的过年，忙碌。\n过年，年轻人有年轻人的过法。\n吃过团年饭，冲完凉，换上新衣服，该去“行大运”了！\n在博贺，“行大运”胜过看春晚，出门上街走上一大圈，为来年积累好运气，是年轻人在除夕夜的首要大事。\n春节期间，难得亲朋好友聚齐，免不了做些平时少有的美食，焗窑、烧烤，都是年轻人的爱好。冬日里围在炭火边，聊聊天、打打牌，享受难得的休闲时光。\n吃完美食，就该娱乐，打麻将最受欢迎。不打复杂的清一色等特殊番牌，只能自摸。为减少流局，常打“白板变”，以“白板”作为赖子，听牌的口子更多了，胡牌也更能容易了。但也有手持三张“白板”，却没能胡牌的时候，该说是运气好，还是运气差呢？\n最理想的听牌是单吊“白板”！下一张，不管什么牌，都能自摸。但也有被截胡的可能。这时，当其他人摸牌后，须要伸出双手，做成剪刀摸样，念道，“剪住！”，把对方的运气剪断。\n常有听牌好几轮都未能自摸的时候，难免会有换牌的想法。但要坚持住，岳母常说“要死等农业社”，麻将也考验定力。\n“自摸！还好没换！”\n年轻人的过年，休闲。\n眨眼间，已是年初七，“又一年！”\n（完，2025.02.04，大年初七）\n","date":"2025-02-04T00:00:00Z","permalink":"https://yrunz.com/p/%E8%BF%87%E5%B9%B4%E5%88%B0%E5%BA%95%E8%BF%87%E7%9A%84%E6%98%AF%E4%BB%80%E4%B9%88/","title":"过年到底过的是什么？"},{"content":"不觉间，写文章已经5年多，现在已想不起当初的目的，只记得在2019年的一个空闲周末，开始了“元闰子的邀请”的写作。\n坚持做一件事情不容易。这些年里，很多事情都没能坚持下来。比如今年计划的早上跑步，也只坚持了一周。唯独写作，虽然频率不高，但好歹也没落下。\n为什么要坚持写作？\n首先，写作是有用的。《认知觉醒》里，作者把写作作为人生五件套（早冥读写跑）之一，\n“只读不写的学习很难把他人的知识转变成自己的知识，要学会使用自己的语言重新组织学到的知识。当你能够教授别人时，这份知识才真正成为你知识体系中的一部分。”\n其次，虽然视频和照片也能记录生活，但文字的质感是不一样的，它能带来更多的想象空间。\n为什么能坚持写作？\n我也不清楚。只是觉得，把一门技术通俗易懂地写出来，让大部分人都能看懂，会心生成就感；学习喜欢的作家的文笔，把生活记录下来，会心生欢喜。\n毛主席说，“坚持就是胜利”。\n复利效应是普遍存在的。很多时候，坚持做一件事情，可能短期看不到成果，但在将来，丰收时刻一定会出现。\n相信，“元闰子的邀请”会是人生中一笔宝贵的财富。\n阅读 2024年看传记看得多，深有体会，传记是一类非常好的读物。\n“很多名人传记，故事性很强，容易看进去。更重要的是，看看其他人的人生，你会明白遭遇苦难的并非你一个人，从中吸收信心，了解前人是怎么做的，吸取经验。”\n古人说，“以史为鉴”，史书过于宏大，传记则刚刚好。\n其实，我们每个人都是一本活生生的传记，多了解身边他人的经历，一定会收获良多。\n                             超标量处理器设计，★★★★★，一本现代处理器架构的绝佳入门读物，详细介绍了CPU流水线的各阶段，有深度，但很好读。总结：深入理解分支预测原理，揭开AMD Zen 5的高性能秘诀。 计算机体系结构基础（第3版），★★★☆☆，计算机体系结构的基础知识，强调体系结构、基础软件、电路和器件的融会贯通，但比较浅。 Software Architecture: The Hard Parts，★★★★★，教你如何在众多因素上进行权衡分析，以此来选择出一个符合实际业务场景的软件架构。总结：软件架构，一切尽在权衡。 Big Data: Principles and best practices of scalable realtime data systems，★★★☆☆，书中主要介绍一种常见的大数据架构，Lambda架构。虽然十年前的产物，但它的架构思想仍值得我们学习。总结：深入理解 Lambda 架构。 Unit Testing Principles, Practices, and Patterns，★★★★★，一本在豆瓣评分高达9.9的神书。读完会对单元测试有一个全新的认知，原来还能这么写！总结：深入理解单元测试。 Deciphering Data Architectures，★★★★☆，系统地介绍了数据系统架构的演进过程，帮助读者更好地理解现代数据架构的演变和选型。 The Creative Programmer，★★☆☆☆，书中探讨如何才能做一个有创造力的程序员，大部分内容都是点到为止，看完没有太多感觉。 Generative AI in Action，★★★☆☆，书中主要介绍了生成式AI的相关知识，虽然书中内容偏浅，但跟着作者的思路，能够快速理清整个领域的技术脉络，有助于后续展开更深入的了解。总结：快速了解生成式AI。 智人之上：从石器时代到AI时代的信息网络简史，★★★☆☆，《人类简史》作者的最新力作，探讨人类在信息网络发展过程中的演变，以及人工智能对人类未来的潜在影响，观点比较有趣。 人间草木，★★★★★，汪曾祺先生的一本散文集，整本书给我的最大的感受是，文字很朴实、很有趣，作者很温柔、对生活甚是热爱。总结：生活，是很好玩的。 史蒂夫·乔布斯传，★★★★★，作者很真实地把乔布斯呈现在读者眼前，他也是个凡人，也有各种各样的缺点。总结：乔布斯与钱学森。 走进钱学森，★★★★★，钱学森无疑是一位“三不朽”的大科学家。总结：乔布斯与钱学森。 埃隆·马斯克传，★★★★★，马斯克是一名狂人，读完本书，对他的五步工作法和第一性原理有了更深的体会。印象最深的是，他运用第一性原理创造性地将不锈钢作为火箭的建造材料。 超越好奇：周鸿祎创业实践，★★★☆☆，奇虎360创始人周鸿祎的自传，介绍他从农村走出，一路摸爬滚打，成为中国初代互联网成功创业者的故事。 世界因你不同：李开复自传，★★★★☆，才发现李开复是一名台湾人，而且有着很深的中国情结。 维纳传：信息时代的隐秘英雄，★★★☆☆，控制论的缔造者，是信息论和计算机背后的英雄。虽然他被誉为“神童”，但童年的经历却是他后来抑郁的源头。维纳很伟大，也很不易。 苏东坡传：在哪里跌倒就在哪里吃饱，★★★☆☆，书名“在哪里跌倒，就在哪里吃饱”很好地总结了苏东坡为人处世的哲学，不过书的前半部分更多的是讲宋朝的政治斗争史，对苏东坡的介绍反而少了一些。 我的几何人生：丘成桐自传，★★★★☆，第一位获得菲尔兹奖的华人数学家，童年的经历塑造了他坚韧不拔的性格。天才，从来都是努力出来的。  写作 以前写一篇技术文章，可能在周末花上的一天的时间就能完成。现在写一篇，从构思到终稿，往往需要花上2周，甚至更长的时间。\n专注力下降可能是一方面，但最主要的原因还是更深入了，参考的内容更多了。\n2024年也愈发明确自己的写作风格：用简洁、通俗易懂的文字，介绍一门复杂的技术，让大部分人都能看懂。\n  生活，是很好玩的，读完汪曾祺的《人间草木》的一篇总结。\n  下雨天，4月的一个下雨的周末，勾起小时候下雨天的各种回忆。\n  软件架构，一切尽在权衡，对《Software Architecture: The Hard Parts》一书的总结。\n  深入理解 Lambda 架构，对《Big Data: Principles and best practices of scalable realtime data systems》一书的总结。\n  实践GoF的23种设计模式：Go 实现，写了2篇，向这个系列完结的目标又前进了一小步（还差7篇）。有预感，2025年还是写不完。\n  深入理解单元测试，对《Unit Testing Principles, Practices, and Patterns》一书的总结。\n  乔布斯与钱学森，写这篇文章想法的源头是，10月份连着把《史蒂夫·乔布斯传》和《走进钱学森》两本传记读完，感受颇深。他们虽然民族各异、身处的领域也大不相同，但都有不少相似之处。\n  深入理解分支预测原理，揭开AMD Zen 5的高性能秘诀，从《超标量处理器设计》中的分支预测章节出发，扩展介绍了当前工业界的主流的TAGE分支预测算法，以及今年新发布的AMD Zen 5架构的2-ahead分支预测原理。\n  快速了解生成式AI，基于《Generative AI in Action》的基本脉络，对生成式AI各项技术做了更深入的介绍，也加入了该领域最新技术点的介绍，力求做到通俗易懂。\n  焗窑，焗窑是家乡春节的保留节目，今年的春节以一场美味的焗窑开始，真不错！\n  生活 “仿佛远方传来悠扬的恋曲，见证了此刻甜蜜。”\n不觉间，与璐的婚礼过去已将近一年。海边、椰林、草坪，婚礼的甜蜜记忆犹新，在《芬芳一生》的背景音乐下，我们完成了戒指交换。\n婚后的生活并没有太大的改变，我们依旧会一起买菜、吃宵夜、追剧，还买了两辆自行车，偶尔周末骑行，吹吹风，驱散一周的疲倦。\n朋友们都说我们有夫妻相，鼻子、笑容都像，越来越像。\n就连MBTI性格测试也一样。刚认识那会，我是INTJ，璐是ESFP，完全相反；现在，我是ISFJ，璐是ESFJ，几乎一样！\n我们都没有刻意改变自己，但在对方潜移默化的影响下，我们越来越像，越来越有默契。生活上偶有的柴米油盐，绊不住我们携手前行步伐。\n“雪地黄昏，缓缓两人，年轻的恋人已满是皱纹。”\n我们曾多次畅享未来退休的生活，在午后的阳台，我在看书喝茶，她在莳花弄草。\n（完，2025.01.28，除夕）\n","date":"2025-01-28T00:00:00Z","permalink":"https://yrunz.com/p/2024%E5%B9%B4%E5%B0%8F%E7%BB%93/","title":"2024年小结"},{"content":"春节假期，昨晚刚到家，今天便碰上姑姑家焗窑，好运至极。\n焗窑，博贺白话读“谷窑”，百度百科里叫“打土窑”，是一种用烧热后的泥块把食物焗熟的特色烹饪方式，趣味和美味并存。\n传统的焗窑，要用泥块搭成锥型窑，若嫌麻烦，也可找一个月饼盒，装满沙子，用柴火加热，也能达到类似的效果。\n焗窑要先挖泥。要拣灰白色沙子结成的泥块，干净、细腻，通常在田野间的草坡或大树底下能找到。挖泥要用一小木块，小心地划出圆饼形状，越大越好。小时候没有运载工具，常用衣服兜住，可防止泥块跌落碎裂。现在，泥块已能网购，方便多了，但也少了些趣味。\n把泥块运回家附近，就可开始搭窑。先用手抓一小撮泥沙，举约半米高，松手任泥沙自然落下，确认好风向。拣几块红砖，垒成大小合适的灶台，在上风向处留好灶口。把大泥块分成大小不一的小泥块，上下两边要磨平，才能稳。先在灶台上放一层稍大点的泥块垫底，一层层垒，直至搭成锥型窑，用小泥块封顶。\n有人搭窑，有人拣柴。要拣些干草、细柴用于生火；也要拣些大柴，更耐烧；带油漆的木柴会烧出蓝绿色的火焰，不能要。\n把火生起后，还须不断往灶口送柴，保持大火烧窑。火苗不断从泥块间窜出，把窑的四周烧得热烘烘的。等待烧窑的间隙，大家围在窑边取暖，聊天、打牌。或拣一两条干草，伸进泥块间的孔洞中点燃玩火，或拣一两块小泥块，往洞里扔，比谁更准。\n在大火的灼烧下，泥块表面先是变黑，随后变红，约两小时后，待泥土烧得通红，便可把未烧完的柴火从灶口中掏出。\n先用火钳把番薯从灶口中放入垫底，随后从窑顶部把泥块敲碎，让泥沙盖住番薯。此时，窑顶已开了个大洞，把鸡从洞中送入，叠在番薯上面，继续把泥块敲碎盖住。鸡蛋易熟，温度过高会炸开，要放在最顶层。\n把所有食材放入窑后，利用泥沙的余温把食材焗熟，半小时后，美食诞生。\n最爱吃焗番薯。番薯可用锡纸包裹，能更干净，但少些泥味。赤身的番薯经过半小时的干焗，靠近火炭的一边，表皮已经碳化。新鲜出炉的番薯烫手，无法拿住，须用两只手来回倒，随后用手掌夹住，来回搓，把表面泥沙搓掉。待表皮微凉，把从中间折开，熟透的番薯冒着热气，散发出窑焗特有的、带有泥味的香气。焗窑的番薯会分层，最外层是表皮，不能吃；往里一层是焗得微焦的表层肉，随表皮撕下，口感较韧，但焦香；最里层是玉青色的番薯肉，口感粉糯，细嚼后，香甜、可口。\n窑鸡用酱油、糖、葱、姜等简单的调料腌制即可，随后先包一层油纸，再过上一层锡纸。焗熟后，打开油纸的瞬间，房子都弥漫着香气。鸡的表皮已焗至焦黄，鸡汁不断往外流出，表皮下是极嫩的鸡肉，“用手撕才好吃！”\n鸡蛋须用湿水的纸巾厚厚地包裹住，防止炸开。出窑时，纸巾已干硬，剥开后是热腾腾的鸡蛋。这次的土鸡蛋偏小，而且焗的时间过久，蛋白已焗焦收缩，无法吃。蛋黄是完整的，长时间的焗烤，为蛋黄附上了焦香的风味，更美味了。\n今年的春节，以一场美味的焗窑开始，真不错！\n（完，2025.01.27）\n","date":"2025-01-28T00:00:00Z","permalink":"https://yrunz.com/p/%E7%84%97%E7%AA%91/","title":"焗窑"},{"content":"荐语 本文要介绍的是，O\u0026rsquo;Reilly在2024年出版的书籍，《Generative AI in Action》，作者是来自微软的Amit Bahree。\n书中主要介绍了生成式AI的相关知识，涉及LLM、提示工程、模型微调、RAG、向量数据库等内容，作者给出了大量的代码示例，通过实战的方式带读者入门生成式AI。\n生成式AI领域发展迅速，虽然本书是在2024年11月出版，但现在看来，书中介绍的技术已经算是“老旧”的了。不过，跟着作者的思路，读者能够快速理清整个生成式AI的技术脉络，有助于后续展开更深入的了解。\n生成式AI概述 随着大模型LLM的大热，生成式AI（Generative AI，GenAI）近年来获得了越来越多的关注和普及。如下图所示，生成式AI是AI的一个子领域，演变自深度学习。\n传统的机器学习基于统计学对数据进行预测；\n深度学习基于神经网络模拟人脑行为，去执行复杂的任务；\n而生成式AI则可以从数据中创作，就像我们人类一样。\n如下列出了生成式AI与传统AI的一些区别：\n    传统AI 生成式AI     用途 专注预测和分类任务 从原始数据中创造不存在的内容   硬件 所需算力较少，能够在各种硬件上运行 需要大量算力，通常运行在大规模的计算集群上。   训练数据 较小的标记数据集 大型的原始数据集   模型复杂度 百万级以下的参数数量 亿级以上的参数数量    很多人会错误地把ChatGPT与生成式AI划上等号，实际上，ChatGPT只是生成式AI最简单的应用。生成式AI能做的很多，个人层面，常见的如文字、图像、视频、代码、音乐的生成；企业层面，常见的如个性化营销、消费者服务、风险管控、合同管理等。\n总而言之，生成式AI能够弥补传统AI的局限性，帮助用户和企业构建更加智能的应用。\n在生成式AI领域中，大语言模型（LLM）作为大脑，能够生成出符合用户需求的内容，下文将简单介绍其原理。\n大语言模型 大语言模型（Large Language Models，LLM）是生成式AI的基础，它由基础模型（Foundational model，FM）发展而来，后者在2021年被首次提出。\n过去的AI模型通常只能应用于特定的任务，而FM则在此基础上做了泛化性优化，通过大数据量的训练得到了更强的适应性。\n早期最具代表性的FM为Google开发的BERT模型，它被广泛应用于自然语言理解（NLU）任务，比如文本分类、命名实体识别等。\nLLM是适用于自然语言处理（NLP）和生成（NLG）任务的基础模型，它的模型规模更大，包括参数数量、训练数据集大小、算力资源。\n通常，LLM都会在名称上带上参数规模，比如Meta的Llama 2-70B，其中7B表示参数规模为700亿。\n一般来说，模型参数越多，性能就越好，最近国产大模型DeepSeek V3甚至已经达到了671B规模。\n当然，模型规模越大，就要消耗越多的算力资源，也就意味着更高的经济成本。因此，在一些任务较为简单或明确的场景，用户可能会偏向使用成本效率更优的小语言模型（Small language model，SLM），比如微软最近推出的Phi-4模型，它的参数规模只有14B，但得益于多方面的技术进步，它在数学推理方面超越了同类和更大规模的模型。\nLLM的本质是利用当前上下文去猜测下文，但它并不像人类一样能够直接读懂自然语言。因此，必须把输入文本（Prompt）先分块（token），这样才能得到更好的泛化性，接着把它们转换成能理解的格式（Embedding），最后通过特定的算法（Transformer）去猜测出下文。\nToken序列 当前，几乎所有的LLM都是基于token的架构，它是LLM处理文本的基本单位。\n有多种将文本划分成token序列的方法，常见的如按字划分、按词划分、按句划分等。\n划分的粒度越细，模型的泛化性也会更好，但token数量会更多，所需计算资源更大；\n划分的粒度越粗，token保留的语义和上下文也就更多，数量也会更少，但遇到陌生词或多义词可能无法处理，依赖构建好的词典。\n因为token的数量与计算资源挂钩，所以模型对单次处理的最大token数量都会有限制，比如GPT-4o支持的最大token数为128K，而Llama 2则是4K。\nEmbedding向量 完成token划分后，还需要将它们转换成LLM能够识别和处理的向量形式，称为Embedding。\nEmbedding背后的思想是，具有相似含义的token应该有着相似的向量表示，它们的相似程度可通过向量的距离来衡量。\nEmbedding模型负责将token转换为Embedding，Embedding模型的构建也需要经过数据收集、预处理、特征提取、训练等一系列的流程。\n以“床前明月光，疑是地上霜。”为例子，我们按词进行token划分，并提前构建好一张词表[“床前”，“明月”，“光”，“，”，“疑是”，“地上”，“霜”，“。”]，那么“明月”可以简单表示为[0,1,0,0,0,0,0,0]，同理，“地上”可以表示为[0,0,0,0,0,1,0,0]。\n若把“床前明月”作为Transformer的输入，那么可能得到如下的输出Embedding，[0.2, 0.4, 0.9, 0.5, 0.1, 0, 0.3, 0.1]，其中“光”的概率最大，因此下一个token为“光”。\nTransformer架构 Transformer架构是LLM的基石，它使用一种被称为多头注意力（Multi-Head Attention，MHA）的机制来考虑整个Prompt（后文统称提示）上下文，能够有效捕捉token之间的依赖关系，从而具备了极佳的语言理解能力。\nTransformer架构的原理图如上，分为Encoder和Decoder两部分，原理图很复杂。为简单起见，我们先忽略Add\u0026amp;Norm、Feed Forward、Linear、Softmax，可以把它们都看成是标准化和维度对齐的模块。\nEncoder的输入是Prompt的Embeddings（用Ei表示），经过MHA机制找到E的隐藏状态，随后作为Decoder的输入预测的下个token的Embedding（用Eo1表示），再以Ei+Eo1去预测下下个token（Eo2），不断迭代。\n我们注意到，Embedding要先经过Positional Encoding才会输入到MHA上，那么Positional Encoding的作用是什么呢？\n一般来说，一个句子的语义，与词在句子中的顺序强相关，比如“你知道不”和“你不知道”的语义完全不同。因此，我们需要为Embeddings加入位置信息，这就是Positional Encoding的作用。\nMHA背后的核心思想是从多个角度（Multi-Head）来找到句子中各个词（token）之间的深层语义关系（Attention），并以此来预测下一个词。\n多角度通过向量的线性变换实现，这得益于一个向量在线性变换后挖掘到它的一些隐藏特征，但本质还是一样。\n比如，对于“床前明月光”和“疑是地上霜”，从语义的角度来看，“床前”与“地上”具备相似性；从韵律角度看，“光”和“霜”具备相似性。\n对于输入的向量E，下图展示如何通过注意力机制把深层的语义关系凸显出来了：\n上图对注意力计算做了简化，Q、K、V都是由E线性变换而来，忽略掉标准化和维度对齐后，可认为它们维度相同。\n首先是Q与K进行点积运算，对于两个向量的点积运算结果，相似度越高则值越大。因此，如果两个词语义相似度越高，它们Embedding的点积运算结果也就越大。最后将它（权重）与V相乘，就能把V中的深层语义关系凸显出来。\n一次注意力计算是一个头，进行多次注意力计算后把结果合并在一起，就是多头注意力机制。经过多次计算，也就相当于从多个角度挖掘语义关系，最终的结果也会更加的准确。\n根据Transformer的算法流程，每次计算需要对所有的token进行注意力计算。比如，输入为“床前”，对它进行注意力计算后得到“明月”，下一轮预测时，会把它们拼在一起，“床前明月”，随后进行计算，得到“光”。\n其实，“床前”在上一轮预测时已经计算过，完全可以把结果缓存下来，下次只需计算“明月”即可，这就是KV Cache的原理。\n至于为什么不用缓存Q，原理如下图所示：\nLLM的局限 LLM虽然很强大，但也有它的局限性：\n 缺失时效性。LLM强大的生成能力依赖于用于训练的数据，一旦训练结束，知识便不再更新，而且也无法进行外部搜索，缺失时效性。 幻觉问题。对上下文的理解不足或缺少专业领域的知识，可能导致LLM“自信”地生成不准确或不存的结果。  下面，我们举一个因缺少上下文导致的幻觉问题的例子。\n假设你是一名中学生，春节假期需要写一篇以春节为主题的文章，着重描写家乡的习俗，500字左右。\n现在你想让LLM帮你完成文章的写作，可以输入如下的提示：\n \u0026gt; “写一篇关于春节的文章”\n 以下为GPT-4o mini模型的输出：\n模型输出泛泛对春节进行了介绍，缺少细节描写，显然不满足你心里的要求。\n导致这种“幻觉”现象的重要原因是给模型输出的上下文不足，你只是提示它写一篇以春节为主题的文章，但并未提出更细节的要求（着重描写家乡的习俗等）。\n下面，我们丰富提示：\n \u0026gt;“现在你是一名语文成绩优异的中学生，现在要以类似汪曾祺的文笔写一篇以春节为主题的作文，字数在500字左右。你的家乡是广东，文章要着重描写广东的春节年夜饭的习俗，介绍常见的年夜饭菜式，然后着重描写白切鸡的做法、味道，能够突出食物的色香味，抒发对家乡的思念之情，作文不需要副标题。”\n 模型输出如下：\n显然，输出的质量大幅提高，这种通过优化提示来提升LLM生成内容质量的方法，就是提示工程（Prompt Engineering）。\n提示工程 提示（Prompt）是LLM的输入，用于引导LLM完成内容生成。而提示工程（Prompt Engineering）是指通过精心设计的提示来提升输出的质量。\n其核心思想是为提示提供更加丰富的上下文信息，旨在帮助模型更好地理解用户的意图。\n最简单的提示工程是人工设计好模板，在提问时基于模板编写提示。\n一个好的提示通常把包含如下几个要素：\n 指令（instruction）：模型执行的任务或问题，比如前文例子中的“写一篇以春节为主题的文章”。 主体内容（primary content）：模型执行的主要内容，是对指令更详细的补充，比如前文例子中的“文章要着重描写广东的春节年夜饭的习俗，介绍常见xxx” 指引（cue）：对指令和主体内容的补充，提供一些“限制”，有助于比如前文例子中的 “以类似汪曾祺的文笔”、“作文不需要副标题”等。 附加内容（supporting content）：可以在提示中注入一些专业领域或最新知识等的附加内容，以保证模型输出更准确。 样例（example）：模型生成响应的样例，让LLM有迹可循。  不同应用场景的提示模板各异，比如中学写作场景，要提示LLM“你是一名中学生”，在医疗场景，则要提示LLM“你是一名医生”。\n另外，提示模板的设计也是一个不断迭代的过程，这通常需要借助一些软件框架来实现自动化，比如Prompt Flow、LangChain等。\n提示工程是一种较为轻量级的优化方法，它无需修改模型的参数，进通过给定的提示引导模型输出。然而，提示工程并不总是有效。\nLLM生成内容的质量与训练数据强相关，提示工程的本质是引导，如果训练模型的数据缺乏专业的医疗知识，即使再好的提示，也难以引导模型输出高质量的医疗问答结果。\n 这里的“再好的提示”并不包含引入高质量的附加内容，有一种被称为检索增强生成（RAG）的技术可以实现，将在后文介绍。。\n 针对这种场景，我们可以用专业的医疗数据集对模型进行二次训练，让它的能力更强，这个过程称为模型微调。\n模型微调 LLM通过接受大量通用的数据训练（预训练模型），实现对自然语言的广泛理解，保证了系统的泛化性，但也因此可能在某些专业领域表现不佳。\n模型微调（Fine-tune）是指在已有的预训练模型（比如GPT-4o、Llama 3.3等）基础上，使用特定的数据集进行二次训练，以调整模型参数，使其能够在特定领域的任务上表现更好。\n模型微调大致可以分成两类：\n 全微调（Full Fine-tuning），对模型进行全面的二次训练，会更新所有参数。 低秩自适应（Low-rank adaptation，LoRA） ，允许在保持模型权重不变的情况下，利用较少的可训练参数实现有效的微调。  全微调和LoRA之间的选择是一种权衡，前者微调效果更好，但计算成本更大；后者通过牺牲了一定的准确性，但实现计算成本的大幅降低。\n如上所述，模型微调的流程也是一个不断迭代的过程，主要有如下几个关键步骤：\n 选择基础模型和微调方法。根据具体的应用场景，选择合适的基础模型和微调方法，通常需要考虑模型对任务的适应性、数据集大小、基础设施等因素。比如，如果中文问答应用，最好选择中文支持更好的Qwen，而不是Llama。 数据准备。主要对数据集的清晰、标准化、格式转换等预处理步骤。 微调。模型微调的实际过程，涉及对模型的二次训练。 评估。评估微调效果，如果效果不佳，则调整参数重新微调。评估的常见指标通常包含结果准确性、逻辑连贯性、语言流畅度等。 部署。评估通过后，模型即可部署并投入生产。  模型微调的成本是巨大的，下图展现了模型训练的几个阶段以及所需的计算和时间成本：\n对于一些时效性要求很高的应用，为保证模型能够时刻包含最新的知识，需要不断对模型进行微调。\n一方面，微调的是非常耗时（数天），时效性无法满足要求；另一方面，反复微调的巨大成本也是用户无法接受的。\n如果有一种方法，能够让LLM“自动”去检索最新知识，并以此作为输入生成结果，这样既能保证时效性，又能避免高昂的微调成本，就好了！\n检索增强生成（Retrieval-Augmented Generation，RAG）就是这样的技术。\n检索增强生成 检索增强生成（Retrieval-Augmented Generation，RAG）是一种利用信息检索来优化LLM输出的技术，通过在生成结果之前引用训练数据之外的知识库，丰富了上下文，从而增强生成内容的准确性。\n前文提到，提示有一项关键要素附加内容，能够为LLM提供训练数据之外的上下文。\nRAG的本质就是根据用户查询，从外部知识库中检索相关的知识，随后以附加内容的形式添加到提示中，增强LLM的生成内容。\nRAG基本工作流 前文提到，在LLM中，文本会被转换成向量来捕捉深层语义关系，RAG中也一样，文本和外部知识都要先被转换成向量来处理：\n如上图所示，RAG的工作流分成索引、检索、生成两个阶段。\n（一）索引阶段\n索引阶段就是将外部知识按照特定的数据结构组织起来，实现更高效的检索。主要有以下几个步骤：\n1、文本分块。原始文本往往都有冗长、信息量大等特点，比如一本书，这并不利于高效检索。一方面干扰信息太多，影响检索精度；另一方面检索过程计算开销很大。文本分块可以将长文本切分成多个粒度更细的文本块，能够更准确匹配用户查询意图的同时，计算和存储开销也更小。\n2、Embedding。通过Embedding模型将文本块转换成向量（Embedding）。\n3、索引。对向量按照特定的数据结构建立索引，比如基于图的HNSW、基于哈希桶的LSH、倒排索引IVF等。\n4、存储。将原始向量和索引数据存储起来，可以使用专用的向量数据库，比如Milvus等，也可以使用传统数据库+向量索引插件，比如PostgreSQL+pgvector。\n（二）检索阶段\n检索阶段就是利用查询去检索相关的文本块的过程，主要有以下2个步骤：\n1、Embedding。使用与索引阶段同样的Embedding模型，将原始查询转换成向量。\n2、检索。利用向量相似性计算，找到与原始查询最匹配的TopN个相关文本块。\n（三）生成阶段\n生成阶段首先将相关文本块与原始查询组成新的提示，引导LLM基于相关文本块完成内容的生成。\n基于上述流程的RAG，我们很容易回答这种问题：“广州的标志性建筑是什么？”\n但对于下面这类多跳推理或全局摘要问题，可能难以回答：“深圳所在的省份的省会的标志性建筑是什么？”，“广东各个省份的标志性建筑是什么？”。\n因为这需要结合多个文本块（可能向量并不相似）完成答案的生成。\n这体现了仅仅基于向量相似性检索的传统RAG的局限性：\n 忽视关系。文本块往往不是孤立的，而是相互关联的，仅仅基于向量检索无法难以捕捉具备高度结构化关系的知识。 缺乏全局信息。基于向量检索只能检索文本的子集，无法获取全局信息，难以完成诸如全局摘要等任务。  对于建立关系，我们很容易联想到知识图谱（Knowledge Graph），它将数据按照图的结构组织起来，实体就是图的顶点，关系就是图的边。\n为RAG引入知识图谱 RAG与知识图谱的结合归属到GraphRAG，它是RAG的一个重要分支，旨在按照图/知识图谱的形式组织文本，考虑了文本间的互联，从而能够更准确地捕捉文本间的深层联系；另外，还能通过识别子图或社区，获取全局的信息，从而能够应对全局摘要等场景。\nGraphRAG的实现方案有很多种，下面我们重点介绍微软开源的实现（https://github.com/microsoft/graphrag）。\n如上图所示，GraphRAG与传统RAG的区别主要体现在索引和检索阶段。\n（一）索引阶段\n1、利用LLM从文本块中完成知识图谱的提取，并以图的形式存储起来。\n 微软GraphRAG通过Parquet数据表的形式存储在磁盘中，并未用到图数据库。\n 2、利用Leiden算法识别多层级社区，并利用LLM为每个社区生成社区摘要，便于后续生成阶段的处理。\n3、将知识图谱的实体、关系、社区摘要、文本块等通过Embedding模型转换成向量，存储到向量数据库中。\n 微软GraphRAG目前实现可对接Azure AI Search、Azure Cosmosdb和LanceDB（只能单机部署）。\n （二）检索阶段\n按照查询的类别，多跳推理类查询使用本地查询（Local Search），全局摘要类查询使用全局查询（Global Search）：\n1、本地查询。根据查询从向量数据库中检索出起始实体，随后在知识图谱中它们作为起点，检索其他相关实体和关系，并以此作为提示的附加内容。\n2、全局查询。针对各个社区摘要，通过LLM进行并行初步汇总，生成中间结果，随后在生成阶段完成最终汇总。\n微软GraphRAG的缺点很明显，它在索引和查询阶段都频繁使用了LLM，导致高昂的计算成本。\n为此，微软在2024年底发布了优化版LazyGraphRAG，通过，1）在索引阶段使用轻量级的NLP算法完成知识图谱的抽取、2）在查询阶段对原始查询进行子查询拆解来提升查询效率等技术手段，保证查询准确度不下降的同时，大幅降低了成本。\n到目前为止，不管是传统RAG还是GraphRAG，每一次用户查询都只检索一次，在一些复杂的查询任务，结果可能依然不够准确。\n如果能够建立一种反馈机制，当评估本次检索不满足要求时，自动修正检索条件，并执行下次检索，经过多轮迭代，获得高质量检索结果的概率将会大幅提升。\n为RAG引入反馈机制 为RAG引入反馈机制，需要一个评估模块，用于评估检索结果的质量，并决定下一步的执行策略：\n我们很容易想到用AI Agent来进行评估和决策，这正式它擅长的。\nAI Agent是一种能够感知环境、进行决策和执行动作的智能实体。它能够分析环境信息和数据（检索结果），自动执行任务并优化行为（决定终止或继续迭代），已实现特定目标（返回高质量的检索结果）。\nRAG与AI Agent结合被称Agentic RAG，除了评估和决策，它还能进行查询拆分、查询路由、信息检索等。\nAI Agent的集成为RAG带来了极强的灵活性，可以根据用户需求，很方便地扩展新功能。\n当然，Agentic RAG也会带来系统复杂性和计算资源的增加，总之，没有十全十美的RAG方案，一切尽在权衡。\n回顾RAG的技术演进 回顾前文，从传统RAG，到GraphRAG，再到Agentic RAG，这一步步的优化对应着RAG的三种范式的演进。\n（一）Native RAG\nNative RAG是最早的范式，遵循Index（索引）-\u0026gt;Retrieval（检索）-\u0026gt;Generation（生成）的传统范式。如前文所述，它在一些复杂的场景（多跳推理、全局摘要等）依然存在检索准确度低的问题。\n（二）Advanced RAG\nAdvanced RAG在Native RAG的基础上增加了检索前处理（Pre-Retrieval）和检索后处理（Post-Retrieval）步骤，一定程度上克服了Native RAG的局限：\n 检索前处理主要优化索引结构（比如GraphRAG引入知识图谱）和原始查询优化（比如LazyGraphRAG的子查询拆分）。 检索后处理主要对检索结果进行重排序或者总结提炼，从而进一步提升上下文的准确性。  （三）Modular RAG\nModular RAG则在前两种范式的基础上，提供了更强的自适应能力，支持灵活地编排检索工作流，以应对不同的场景。\n到目前为止，我们发现，不管是哪种RAG范式，都离不开向量检索。因为向量是AI模型的语言，文本、图片、视频需要被转成向量才能供LLM使用。而提供向量检索能力的关键组件是向量数据库（vector database）。\n向量数据库 向量数据库（vector database）是一种用于存储、管理和查询高维向量的数据，能够高效地进行相似性检索。\n向量数据库可分成两种：\n 专门为向量检索设计的专用数据库，如Milvus、Weaviate、Pinecone等 支持向量检索功能的常规数据库，如PostgreSQL（通过pgvector插件）、Elasticsearch等。  随着生成式AI的火爆，向量数据库近两年也越来越受欢迎，越来越多的常规数据库开始支持向量检索功能；而随着应用场景的丰富，专用向量数据库也开始支持标量查询、全文检索等常规数据库的功能。\n综合型的向量数据库逐渐成为一种趋势。\n向量数据库主要利用了向量相似性的原理，前文已有相关介绍，这里不再展开。\n基于生成式AI的软件架构 过去，从单体时代、到服务化时代、再到云原生时代，企业级软件架构经历了几次重大变革，以满足对业务不断变化的需求。\n如今，生成式AI的兴起将促进新的一轮软件架构变革。\n书中作者将基于生成式AI的软件架构定义如下：\nAI基础设施层 AI基础设施层（AI infrastructure）是一切的动力来源，包含支持生成式AI应用程序开发和部署的硬件（XPU、内存、存储等）、软件（编译器、OS、容器等）和服务（可观测服务等）。\n它通常会针对生成式AI的工作负载进行优化，并且具备能够支持LLM训练和推理的高性能计算能力。\n基础模型层 基础模型层（Foundational models）包含了各类基础模型：\n可以是性能卓越的大模型LLM，比如DeepSeek V3 671B、Llama 3.1 405B等。\n也可以是面向边缘/端侧的小模型SLM，比如Phi-4 14B、Gemma-2 2B等。\n或者是面向专业领域微调的模型，比如面向医疗的MMedLM、Med-PaLM v2.0等。\n编排层 编排层（Orchestration）是整个架构的关键，负责任务分配、资源管理和工作流程优化等，包含Response filtering、Meta prompt、Grounding和Plugin execution四个组件。\n Response filtering。对基础模型的提示和响应进行分析、过滤和优化，确保返回给用户的响应是准确、安全的。 Meta Prompt。为基础模型提供额外的信息和约束，可以是用户设置或者系统自动生成，提示工程属于这一层。 Grounding。用于确保响应与用户指定的上下文相关，RAG属于这一层。 Plugin execution。为系统提供更多的功能，比如数据检索、格式化、校验等。RAG所依赖的向量数据库属于这一层。  当前，业界已经涌现了很多编排框架，比如LangChain、Llama-Index、Hugging Face等\nClopilot用户体验层 Clopilot，AI副驾驶，是生成式AI中的一个重要概念，旨在增强人类的能力和创造力。\nClopilot用户体验层（Clopilot UX）向用户提供强大、易用的AI工具链/界面，根据不同的应用场景，有着不同的运行模式，比如用于辅助编码的Github Copilot、用于辅助办公的Microsoft 365 Copilot等。\n最后 生成式AI仍在迅速发展。\n前不久发布的国产大模型DeepSeek V3火爆全网，通过FP8 混合精度训练、多令牌预测等技术手段，在保证高性能的同时大幅降低了成本。\nGoogle最新发布的Titans架构旨在解决现有Transformer架构在处理长序列数据时的记忆瓶颈问题，号称是后者的继任者。\n提示工程也在不断与Chain of Thought等技术相结合，应对更为复杂的场景。\n模型微调并未因为RAG的出现而消失，它们在不同的应用场景发挥着重大的作用。\n据中国互联网络信息中心（CNNIC）调查，截至2024年12月，我国生成式AI应用的用户规模达2.49亿人，占整体人口17.7%。\n生成式AI的时代已经来临。\n 参考 [1] Generative AI in Action, Amit Bahree\n[2] Attention Is All You Need, Ashish Vaswani, etc.\n[3] https://www.zhihu.com/question/596900067, 看图学\n[4] Retrieval-Augmented Generation for Large Language Models: A Survey, Yunfan Gao, etc.\n[5] Modular RAG: Transforming RAG Systems into LEGO-like Reconfigurable Frameworks, Yunfan Gao, etc.\n[6] Graph Retrieval-Augmented Generation: A Survey, Boci Peng Boci, etc.\n[7] CNNIC报告：生成式AI产品用户规模达2.49亿人, 南方都市报\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2025-01-19T00:00:00Z","permalink":"https://yrunz.com/p/%E5%BF%AB%E9%80%9F%E4%BA%86%E8%A7%A3%E7%94%9F%E6%88%90%E5%BC%8Fai/","title":"快速了解生成式AI"},{"content":"前言 今年，AMD Zen 5架构随着AMD旗下锐龙、霄龙等新一代处理器的发布而亮相。Zen 5对比上一代架构 Zen 4在性能上有了大幅提升，其中，IPC（每时钟周期执行的指令数量）更是提升了16%。\n在Zen 5 高性能的背后，分支预测器的优化功不可没，其中，对2-ahead分支预测的支持尤为重要。\n本文将从CPU分支预测原理讲起，试图带大家理解Zen 5在分支预测上的优化，揭开性能提升的秘密。\n 分支预测大体可以分成两部分，分支方向的预测和分支目的地址的预测，本文主要介绍前者。\n 从CPU流水线说起 在现代计算机体系结构下，程序都会被转换成一条条指令，在CPU中执行。\n我们能想到的最简单的指令执行流程是串行执行，比如现有指令A，B，C和D，那么，串行执行流程如下：\n然而，串行执行的效率无疑是低下的。为了充分榨干CPU的性能，指令流水线出现了，它把指令执行的流程划分成多个阶段，每个阶段可以独立执行。比如，下图中为2级流水线的示例图：\n在流水线下，虽然单个指令执行的耗时不变，但指令执行的吞吐量增大了。\n分支指令是众多指令中的一种，它伴随着程序中 if/for/while 等可能出现分支跳转的代码而出现。\nif (x == 0) { // doTingA } else { // doTingB } 上述的代码，可能会转换成如下的汇编代码：\nbranch_if_not_equal x, 0, else_label // doTingA goto end_label else_label: // doTingB end_label: // whatever happens later 分支指令的出现使得流水线执行变回了串行，因为在branch_if_not_equal执行完之前，CPU无法不知道下一步应该是doTingA，还是doTingB。\n我们可以加入分支预测的机制，提前“猜测”下一条指令，虽然猜错了会有惩罚代价，但只要我们猜得足够准确，CPU的性能就会得到大幅的提升。\n分支预测的发展 静态分支预测 静态分支预测机制最简单，可认为是工程师根据大多数程序的特征总结出来的规律，常见的有2种机制：\n Alway taken。每次都执行跳转操作，对循环逻辑效果很好。 BTFNT。如果目的地址为向前，则选择不跳转，向后则选择跳转。  静态分支预测机制实现简单、硬件开销低，但在复杂的程序上预测成功率低。因此，在现代处理器中占主导地位的为动态分支预测。\n动态分支预测 动态分支预测基本都基于分支执行历史进行，这里有个关键前提**，分支行为是重复的，这意味着分支行为是可以学习和预测的**！\n最简单的预测策略是基于最近一次分支执行的结果。在实现上通常使用1 bit来存储最近一次分支执行结果，1表示跳转（taken），0表示不跳转（no taken）。\n上图中，分支执行历史被存放到PHT（Pattern History Table）上，并通过分支指令的地址（PC）进行索引。\n该策略在分支方向变化不频繁的场景能够取得较高的预测成功率，比如下面的例子1：\n// 例子1 for(i = 0; i \u0026lt; m; i++) { // 循环体 } 该策略下，只会在首次进入循环，以及出循环时会预测失败，因此，预测准确率为(m-2)/m，当m足够大时，能够取得较好的准确率。\n现考虑下面的例子2，该场景下，每次分支方向都不一样：\n// 例子2 if (flag) { // doting } flag = !flag 在该预测策略下，分支预测准确率会是0！\n针对这种分支方向变化频繁的场景，现代处理器通常采用饱和计数器来进行优化。\n（1）基于两位饱和计数器的分支预测\n两位饱和计数器的原理是降低分支预测器对分支方向变化的敏感度，它通过2 bit来存储分支预测状态，一共有4种状态：\n当计数器处于饱和状态，单次预测失败并不会更改预测方向，只有连续两次预测失败时，才会。\n两位饱和计数器的分支预测算法，在预测正确率上要优于基于最近一次结果的预测算法，还是考虑前面2个例子：\n我们选取Strongly no taken作为初始状态，在例子1中第二次进入循环时，预测正确率提升到(m-1)/m；而在例子2中，预测正确率提升到50%。\n然而，基于两位饱和计数器的预测正确率有一个极限值，很难达到98%以上的正确率，因此现代处理器并不会直接使用它，而且与其他算法结合使用。\n（2）基于局部历史的分支预测\n在例子2中，两位饱和计数器虽然有所优化，但也只能达到50%的正确率，仍然无法接受。\n理论上，只要分支指令很有规律，应该都能被预测。\n为此，我们需要引入一个分支历史寄存器（Branch History Register，BHR）来存储分支的历史状态。一个n bit大小的BHR，可以记录一个分支过去n次的结果。每次执行该分支指令后，就会往BHR的右边插入一次结果，1表示taken，0表示no taken，然后左移一位，将最左边的值移除。\n这被称为基于局部历史的分支预测方法，它同时需要一个大小为2*2^n bit大小的PHT去存储每个历史对应的两位饱和计数器值。\n上述图中，我们使用2位的BHR，比如，当它的值为10时，表示过去2次分支结果为taken-\u0026gt;no taken。\n对于例子2的场景，可以推测出BHR的变化情况为01-\u0026gt;10-\u0026gt;01-\u0026gt;10-\u0026gt;...，因此，当BHR值为01时，下一次分支结果必定为0，即不跳转；BHR值为10时，下次必定为1，即跳转。\n经过充分预热后，预测正确率能达到100%！\n实际上，一个复杂的程序会有很多分支指令，我们不可能为每个分支指令都分配一个BHR和PHT。因此通常只会实现一定大小的BHT和PHT，其中BHT的每一项为BHR。\n分支预测时，先通过PC索引到BHR，再根据PC和BHR索引到PHT。在索引PHT表项时，通常会将PC和BHR值进行异或操作，以降低分支冲突的影响。\n 分支冲突会导致PHT重新训练，预测正确率降低。\n （3）基于全局历史的分支预测\n一个复杂的程序中，很多分支指令是相关的，比如下面的例子3：\n// 例子3 if(aa == 2 ) /b1/ aa = 0; if (bb == 2) /b2/ bb = 0; if(aa!= bb) /b3/ … 如果分支b1和b2都跳转了，则分支b3就不会跳转，这时，基于分支b3的局部历史永远无法学习到的规律。\n因此，我们需要把分支b1和b2的历史也考虑进来，这就是基于全局历史的分支预测。\n基于全局历史的分支预测需要一个存放过去所有分支执行历史的全局历史寄存器（Global History Register，GHR）。一个n bit大小的GHR能保存过去n次分支执行的历史，每当遇到一个新的分支指令执行后，就会往GHR右边插入执行结果，1表示taken，0表示no taken，然后左移一位。\n全局历史分支预测与局部历史分支预测实现类似，也需要一个大小为2^n*2 bit大小的PHT去存储对应历史的饱和计数器状态，并根据PC和GHR的值去索引PHT表项。不同点是，它只有一个GHR。\n基于全局历史的分支预测算法的一个关键问题是，分支历史记录应该设计成多长？\n分支本身的预测难度不尽相同，较长的历史记录可以准确预测一些较难预测的分支；\n但，对于一些预测难度低的分支，较长的历史记录一方面会浪费存储空间，另一方面也会使得PHT需要花更长的时间进行预热，从而降低预测正确性。\n如果有一种分支预测机制，能够同时兼顾不同预测难度的分支，就好了！\nTAGE就是这样的一种分支预测算法。\n现代工业界的王者：TAGE分支预测 TAGE可以说是现今最为成功的分支预测算法，它是现在高性能处理器的分支预测器的基础，包括今年最新发布的AMD Zen 5。\n那么，为什么TAGE如此受业界欢迎呢？下文我们试着解答这个问题。\nTAGE分支预测原理 TAGE的全称是TAgged GEometric history length，即基于带标签的几何级数递增历史长度的分支预测算法，它的两个核心部分为：\n 呈几何级数递增的分支历史长度匹配机制。 带标签的索引表项。  TAGE预测期的结构如下图所示：\nT0表使用分支地址直接索引，表项为两位饱和计数器的状态值，相当于前文介绍的基于两位饱和计数器的分支预测机制。\nTn表使用分支地址和对应历史长度进行哈希索引，表项有3部分组成：\n pred是一个3位饱和计数器的状态值，用于标识分支是否跳转； tag是分支地址与分支历史哈希得到的标签信息，用于区分不同的分支； u是一个2位的useful计数器，标识当前表项“有用”程度，0表示无用。  TAGE的预测思想是，每次在Tn表中，选取tag匹配的、u标识为有用的、分支历史长度最长的表项中pred的状态值进行预测；如果无法命中Tn表，则默认使用T0表进行预测。\n TAGE详细的预测机制细节本文不再介绍，感兴趣的读者可以参考原论文《A case for (partially)TAggedGEometric history length branch prediction》，或文章《分支预测算法（一）：TAGE》\n TAGE的变种 TAGE属于基于全局历史的分支预测，因此，一些适合适用局部历史预测的分支，TAGE并不能很好地兼顾。\n在分支预测的发展过程中，存在一种称为竞争的分支预测算法，它将全局历史预测器和局部历史预测器结合在一起，相互补充，以此达到一个更好的预测结果。\nTAGE也有类似的优化机制，常见的两类辅助器为Loop Predictor（L）和Statistical Corrector（SC），对应的TAGE变体为TAGE-L/TAGE-S/TAGE-SC-L。\nLoop Predictor主要用于补偿TAGE在具有固定次数的循环的预测短板；Statistical Corrector则用于补偿不具备相关性的分支预测短板。TAGE-SC-L的结构图如下：\n虽然TAGE-SC-L理论上比TAGE具有更高的预测准确率，在主流的商业处理器中却很少应用，因为新增的L和SC部件一方面新增芯片的面积、功耗、验证成本等；另一方面，它比TAGE的预测延迟要更大！\n为什么TAGE如此受业界欢迎？ 现在，我们可以回答这个问题了，要点如下：\n 通过tag来避免分支冲突。先前的大多数分支预测算法，只是简单地通过分支地址和历史来索引表项，而TAGE在此基础上，增加了一个tag校验机制，从而能够更好地避免分支冲突。 能够同时兼顾不同难度的分支预测。通过引入多分支历史长度匹配机制，兼顾不同难度的分支预测。 更高的芯片面积效率。通过前两点，TAGE能够达到使用较小的存储实现较高的分支预测准确率，因此有着更高的芯片面积效率。  业界对TAGE的优化从未停止，AMD Zen 5也是如此。\nAMD Zen5高性能的秘密：2-ahead分支预测 AMD Zen5新一代分支预测 AMD Zen 5针对分支预测做了不少优化，比如，通过更大的TAGE、BTB和RAS（后两者用于分支目标地址的预测）实现更高的预测准确度。\n但最重磅的更新是，支持了2-ahead分支预测器，这是Zen系列CPU架构向前发展的重大转变。\n目前，很多高性能处理器虽然每周期有2个分支预测窗口，却只能预测1个跳转分支。原因是，假设第一个分支跳转了，那么第二个分支的地址依赖第一个分支的目的地址计算得出，这个过程基本是串行的。而如果第一个分支不跳转，再去预测第二个分支，就很好实现了。\n所以，2个分支预测单元在同时预测两个分支，其中一个不跳转，另一个跳转的场景，会有更好的收益。\n依此类推，AMD Zen 5扩展到每周期3个分支预测窗口，必然需要每周期能够预测2个跳转分支，否则性能收益很小。\n那么，Zen 5是如何做到每周期预测2个跳转分支的呢？答案是，2-ahead分支预测。\n一个源于30年前的想法 现代编译器会将指令按照基本块（basic block）组织起来，一个基本块有唯一的入口，其他分支指令只能跳转至该入口；而基本块里的指令是连续执行的，分支指令只能出现在基本块最后一条指令。\n假设现在有4个基本块，A，B，C和D，并且顺序执行，即，A的最后一条为分支指令，跳转到B，同理，B跳转到C，C跳转到D。\n经典的分支预测算法是，用A去预测B，再用B预测C，最后用C去预测D，这样，每周期只能预测1个跳转分支。\n而在1996年的论文《Multiple-Block Ahead Branch Predictors》论文中，给出了一种通用的每周期预测2个跳转分支的方法，称为2-ahead分支预测器。\n它能够实现用A去预测C，用B去预测D，这样，每周期就能同时预测2个跳转分支！\n当然，这样的设计需要CPU能够支持每周期获取2个基本块（double I-fetch processor），且BTB等存储结构也需要支持双端口读取（dual-ported memory structures）。\n论文提到，2-ahead分支预测器能够适配其他任何“1-ahead”的分支预测算法，并且能够做到预测准确率相同！\n对2-ahead预测机制细节感兴趣的读者，可以直接阅读论文原文，本文不再详述。\n根据AMD Zen 5现有的资料可推测，它的分支预测器就是2-ahead与TAGE的结合：在TAGE预测算法的基础上，实现每周期预测2个跳转分支，因此性能得到更进一步的提升！\n为什么2-ahead至今才实现？ 既然2-ahead能带来性能收益，那为什么直到Zen 5才首次商业落地呢？原因个人猜测有2点：\n其一，2-ahead分支预测算法需要一定的硬件支持，以前的工艺无法满足。举个例子，论文里实验数据表明，当dispatch为8-width时，2-ahead能够带来最好的性能收益，而AMD Zen系列架构，在Zen 5才首次支持8-width dispatch。\n其二，虽然2-ahead在90s年代就被提出，但随后CPU往多核CPU方向发展，每个核的大小称为芯片设计的重要因素，因此大家主要投入在具有极高面积效率的TAGE算法上。而近年来，单个CPU已经能做到上百个核，单核性能重新被关注起来，像2-ahead这类能够提升单核性能的古老算法重新浮出水面。\n最后 本文从经典的分支预测算法讲起，先是引出了现代工业界的王者，TAGE分支预测器；然后介绍了AMD Zen 5在分支预测器上的重大优，2-ahead分支预测器。读完全文，希望读者能对分支预测的原理和发展有一个较为清晰的了解。\n分支预测是一个很大的课题，本文不可能做到面面俱到，如果读者感兴趣，可详细阅读参考列表中的学习资料。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 超标量处理器设计, 姚永斌\n[2] Multiple-Block Ahead Branch Predictors, Seznec等\n[3] Zen 5’s 2-Ahead Branch Predictor Unit: How a 30 Year Old Idea Allows for New Tricks, George Cozma/Camacho\n[4] 现代分支预测：从学术界到工业界, Easton Man\n[5] 分支预测算法（一）：TAGE, SunnyChen\n[6] Branch prediction, Patreon\n[7] 分支预测的 2-taken 和 2-ahead, 杰哥的{运维，编程，调板子}小笔记\n[8] Why TAGE is the best, rustam\n[9] Zen 5’s Leaked Slides, Chester Lam\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2024-11-10T00:00:00Z","permalink":"https://yrunz.com/p/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E5%88%86%E6%94%AF%E9%A2%84%E6%B5%8B%E5%8E%9F%E7%90%86%E6%8F%AD%E5%BC%80amd-zen5%E7%9A%84%E9%AB%98%E6%80%A7%E8%83%BD%E7%A7%98%E8%AF%80/","title":"深入理解分支预测原理，揭开AMD Zen5的高性能秘诀"},{"content":"乔布斯与钱学森，一位是美国的企业家，带领苹果公司发布一系列革命性电子产品；一位是中国的科学家，帮助国家完成“两弹一星”的伟业。乍看之下，你很难把他们关联起来。\n写这篇文章想法的源头是，最近连着把《史蒂夫·乔布斯传》和《走进钱学森》两本传记读完，感受颇深。他们虽然民族各异、身处的领域也大不相同，但都同样伟大。\n        伟人总有很多相似之处，这也是值得我们学习的地方。\n奋斗的天才 他们从小就是天才，却也一生都在奋斗。\n乔布斯从小就对电子器件展现出非凡的天赋，年仅 21 岁就与好友沃兹尼亚克创造了第一款苹果电脑 APPLE I，引领的个人电脑时代。论电子信息技术的天赋，乔布斯比不过沃兹尼亚克，他最为天才的地方是对产品的理解，以及强大的”现实扭曲力场“。\n乔布斯对电子产品的理解是超前的，从 APPLE I 到 iMAC、从 iPod 到 iPhone，他总能带领苹果发布革命性的产品。他不依赖市场调研，“因为他们并不知道自己想要什么。他们看到我们的产品，才会知道这就是自己想要的。”\n乔布斯的“现实扭曲力场”是他的领导力的体现。他能够把自信传递给别人，驱使他们完成自认为不可能完成的事情。这也是苹果公司能够不断创新的主要原因之一。\n乔布斯希望打造能够改变世界的产品，并为之奋斗了一生。最让人钦佩的两点是：\n 在正式二进宫苹果前的两年多时间里，他作为临时 CEO 只拿了 2.5 美元的工资，却要同时管理苹果和皮克斯两家公司，常年奔波于两个公司之间，为后面的癌症埋下了病根； 在患癌之后，尽管身体虚弱，仍领导公司发布了 iPad、iCloud 等革命性产品。   钱学森则更像“别人家的小孩”，从小成绩优异，一路名校，北师大附中-\u0026gt;上海交大-\u0026gt;麻省理工-\u0026gt;加州理工。从“两弹一星”到载人航天、从工程控制论到系统科学、从“山水城市”到沙漠改造，他在众多领域都有大建树；他被美国评价为“无论在哪里，他都值五个师。”；他是真正意义上的天才；他是“大科学家钱学森”。\n钱学森能有如此大的成就，除了天赋，也离不开他的努力和保持学习的习惯：\n 他曾说过，“我不是说大话，我在做空气动力学的时候，关于空气动力学方面的英文的、法文的、德文的、意大利文的文献我全都念过。为了要把它做好，我得这么念，而且还进行了分析。” 他终生保持了阅读和记录的习惯，“钱学森家中，藏有15000多册他阅读过的期刊。”，他习惯剪报来记录资料和信息，“保存的629袋剪报，总共24500多份，整整装满了5个大书柜。”  细节决定成败 他们处事严谨、追求细节。\n乔布斯是广为人知的细节狂魔，即使在别人看不见的细节上，也要做得工整漂亮。在做 Mac 电脑时，乔布斯会仔细查看内部的电路板，虽然用户永远不会看到它，但他仍会给出批评意见“这些存储芯片实在是太丑了，这些线靠得太近了。”，这是他的哲学理念，“希望设计能尽可能完美，即使是装在盒子里，没人看到。就好比好的木匠不会用烂木头做橱柜的背板。”\n乔布斯对细节的把控，除了体现在产品设计上以外，在发布会的准备上也体现得淋漓尽致。他不仅对演讲稿字斟句酌，认真审阅了邀请人员名单，甚至还检查了午餐菜单。\n 而钱学森作为一名科研人士，严谨是必备的品性。\n细节决定成败。作为导弹和火箭专家的他，尤其懂得每一根导线、每一个焊点、每一个元件都必须绝对可靠。他常跟学生说，“科学上不能有一点失误，小数点错一个，打出去的导弹就可能飞回来打到自己。”\n也正是对细节的关注，使得他能够在美国特务的重重监视之下，与国内取得联系，最终回归祖国。\n集聚众智 他们是伟大的帅才，他们的背后都有一个伟大的团队。\nMac、iPhone 等划时代的产品，并非乔布斯一人的创意，伟大产品的背后是一个伟大的团队。从刚开始的沃兹尼亚克，到后来的乔纳森·艾夫、蒂姆·库克，乔布斯非常善于识别人才，打造 A 级选手组成的团队，为自己出谋划策。\n 同样的，绝不是钱学森一人成就了中国“两弹一星”和载人航天的辉煌，他的背后还有任新民、孙家栋、王永志等一大批科技创新人才。正如钱学森所说，“科技帅才不仅个人要具备拔尖的学术水平，还要有本事团结一大批人，统领一大批专家攻克重大的科技难关。”\n在钱学森看来，科研成果大都是“集体工作的结晶”。\n多学科交叉 他们都是“杂”家。\n乔布斯作为苹果公司的创始人，我们很容易把他与“理工科”联系起来。但他不仅是一名只会电子信息的“理工男”，他对美术、音乐、禅修等广泛的领域都有涉猎。他自视为一名艺术家，总是有意识地把自己置于艺术和科技的交会点。在他的所有产品中，“科技总是与精妙的设计、优雅的外观、人文的关怀，甚至浪漫的元素相结合。”\n多学科交叉使得乔布斯具有独特的个人品味，当他把这种品味注入到产品时，总能引领时代的潮流。\n 钱学森晚年总结自己一生科学思想结晶为“集大成，得智慧”。在他看来，人的智慧是两大部分：量智和性智。缺一不成智慧！\n“科技为量智，文艺为性智。前者逻辑思维，后者形象思维。”\n“从思维科学角度看，科学工作总是从一个猜想开始的，然后才是科学论证；换言之，科学工作是源于形象思维，终于逻辑思维。”\n钱学森除了是“两弹一星”、载人航天等领域的巨人，在人体科学、地理科学、城市学等领域均有重要的著作，可见其“杂”。\n重视教育 他们都关注国家的教育事业。\n在中国教育界有一个著名的“钱学森之问”，也即，“为什么我们的学校总是培养不出杰出人才？”\n其实，这并非钱学森的原话，是后人根据他与国家领导人的谈话，“现在中国没有完全发展起来，一个重要原因是没有一所大学能够按照培养科学技术发明创造人才的模式去办学，没有自己独特的创新的东西，老是‘冒’不出杰出人才。这是很大的问题。”，总结得来。\n人才的教育和培养是钱学森一直关注的，他不仅为中国培养了一大批科学创新人才，在晚年也总结了现代科学技术体系，为国家的人才培养指出了道路。\n 其实，在国际教育技术领域，也有一个颇为著名的问题，叫“乔布斯之问”，也即“为什么计算机改变了几乎所有领域，却唯独对学校教育的影响小得令人吃惊？”\n乔布斯在与奥巴马会面时，当面抨击了美国教育体系，\n“美国教育极为过时，无可救药。”\n“美国的课堂模式仍然是老师站在黑板前照本宣科，真是荒谬。所有的书、学习材料和评估测试都应该是数字化、可交互的内容，为每个学生量身定做，而且能够提供实时反馈。”\n最后 最后谈一下读这两本书后最深的感受：\n乔布斯是一位复杂的艺术家，他感情丰富，经常在众人面前失声痛哭；他直言不讳，经常骂员工做的东西是“一堆垃圾”；他追求完美，对每一个细节都要反复斟酌；他是理想主义者，终其一生都在追求改变世界。\n钱学森是一位“三不朽”的大科学家，他有着极强的民族自尊心，尽管困难重重也要归国；他鞠躬尽瘁，为国家的“两弹一星”、载人航天等事业奉献大半生；他品行谦逊，言传身教，为国家培养了一大批科技人才。\n 参考 [1] 史蒂夫·乔布斯传, 沃尔特·艾萨克森\n[2] 走进钱学森, 叶永烈\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2024-10-13T00:00:00Z","permalink":"https://yrunz.com/p/%E4%B9%94%E5%B8%83%E6%96%AF%E4%B8%8E%E9%92%B1%E5%AD%A6%E6%A3%AE/","title":"乔布斯与钱学森"},{"content":"荐语 本文要介绍的是 2020 年 O\u0026rsquo;Reilly 出版的书籍 Unit Testing Principles, Practices, and Patterns，一本在豆瓣评分高达 9.9 的好书。\n作为一名软件开发工程师，你应该对单元测试（unit test）很熟悉，但单元测试的目的、Mock 的正确用法、单元测试和集成测试的区别等等，你真的懂吗？书中对这些内容都做了深入的介绍，并通过实际案例教你如何写出好的单元测试。读完这本书，你一定会有醍醐灌顶的感觉，原来单元测试要这么写！\n书中的另一个重要观点认为，单元测试和代码架构是息息相关的，写不出好的单元测试，极有可能是代码架构设计得不好。为了写出好的单元测试，需要不断对代码架构的优化，它们相辅相成，从而让软件能够持续演进。\n为什么要写单元测试？ 单元测试的目的 如今，在软件开发活动中，单元测试已经成为企业级软件不可或缺的一环，是代码门禁的必选项。\n如果突然有人问你，“为什么要写单元测试？”，你很容易会想到这样的答案，1）为了找到代码中已有的 Bug；2）为了防止引入新的 Bug；3）让代码架构变得更好；甚至是 4）满足代码门禁中覆盖率的要求。\n但这些都不是我们写单元测试的根本目的，它们只是“写单元测试”这一活动所带来的副产品。\n书中认为，单元测试的目的是实现软件项目的可持续增长。\n企业级软件应用通常都有着业务逻辑复杂度高和生命周期长的特点。随着产品的不断迭代，代码会变得越来越复杂乱，每次修改代码也会变得越来越难，这就是软件的熵增定律。而单元测试的目的就是降低熵增的加速度，使得重构代码或新增功能变得更加的容易。\n单元测试也分好坏 代码并非资产，而是一种负债，单元测试代码也是如此。\n然而，我们往往会将生产代码和测试代码区分对待，忽略了对测试代码的维护，久而久之，测试代码变得冗余、复杂，甚至出现误报的情况。这样的单元测试，只会让系统的演进更加难。\n如何评判单元测试的好坏？最常用的方法是代码覆盖率，也即测试用例执行了多少源代码。其中，又可分为行覆盖率和分支覆盖率。\n行覆盖率计算执行了多少代码行，比如下面的例子，return true 的代码行无法覆盖，因此代码行覆盖率为 4/5=80%：\n// C# public static bool IsStringLong(string input) { if (input.Length \u0026gt; 5) // 无法执行  return true; return false; } public void Test() { bool result = IsStringLong(\u0026#34;abc\u0026#34;); Assert.Equal(false, result); } 但是，某些时候行覆盖率并不能放映真实的覆盖情况，比如将上述的 IsStringLong 使用三目运算符进行重构，行覆盖率 3/3=100% 了，但实际情况并未改善。\n// C# public static bool IsStringLong(string input) { return input.Length \u0026gt; 5 ? true : false; } public void Test() { bool result = IsStringLong(\u0026#34;abc\u0026#34;); Assert.Equal(false, result); } 分支覆盖率情况会好一些，IsStringLong 重构前后的分支覆盖率都是 1/2=50%：\n但是，如果代码中涉及调用第三方库的代码，分支覆盖率也无法反映真实的情况：\npublic static int Parse(string input) { // int.Parse为第三方库中的方法  return int.Parse(input); } public void Test() { int result = Parse(\u0026#34;5\u0026#34;); Assert.Equal(5, result); } 上述例子中，看似分支覆盖率也 100% 了，但 int.Parse 方法的实现并未考虑到：\n另外，一些代码并未包含核心的业务逻辑，比如枚举类的声明，专门写单元测试来覆盖它们，除了得到更高的代码覆盖率之外，实际并不会带来价值。\n// C# // 稳定、不包含业务逻辑 public enum InputType { SOCKET, HTTP, HTTPS, WEBSOCKET } 所以，代码覆盖率低的单元测试，可以认为是不好的；但代码覆盖率高的单元测试，却不见得是好的。\n那么，怎样的单元测试才是好的呢？\n书中认为，好的单元测试应该包含如下几个特点：\n 被集成到开发周期中。单元测试应该被集成到 CICD 流程，每次代码的更改都能够自动执行。 仅针对代码中最重要的部分。单元测试代码也是一种负债，不能为了追求代码覆盖率而写出大量的冗余用例。 以最低的维护成本提供最大的价值。这是书中的重点，也是后文要详细介绍的内容。  什么是单元测试 测试金字塔 如果有人问你，“什么是单元测试？”，你可能会想到测试金字塔：\n测试金字塔从下往上分别是单元测试、集成测试和端到端测试，单元测试通常是面向类或方法级别的测试方法；集成测试则面向服务或模块级别；端到端测试是从最终用户的角度进行测试，测试对象为整个系统。\n在一个设计良好的系统中，这三类测试的占比就像它们在金字塔中的面积，越往下越高；测试执行速度越往下越快。\n测试金字塔所表达的意思是，单元测试的执行效率更高，因此我们应依靠大量的单元测试、辅助以少量的集成测试和端到端测试，来保证系统的功能正确。\n那么，怎样的测试才算是单元测试呢？\n单元测试中的依赖和测试替身 在给单元测试下定义之前，先介绍单元测试中的依赖，它是区分不同学派的单元测试，以及测试金字塔中不同测试类别的关键。\n书中将单元测试的依赖分成两类，共享依赖和私有依赖，其中私有依赖又可分为可变依赖和不可变依赖。\n 共享依赖指测试之间共享的依赖项，比如可变的静态全局变量（进程内）和数据库（进程外）。 私有依赖则是非共享的依赖项：  可变依赖，普通对象、无状态的静态方法都属于可变依赖，比如前面例子中 Parse 方法所依赖的 int.Parse 方法。 不可变依赖，又称值对象，比如前面例子的 InputType 枚举类。    写单元测试的过程中，总会存在某些依赖项无法在测试环境中集成，这时须要使用测试替身（test double）进行替换。\n测试替身是一个看起来跟实际依赖对象行为一致、实际是简化版实现的对象，有助于提升单元测试的可测试性。\n我们常用到的 Mock 属于测试替身的一种，能够用于校验被测对象和依赖项之间的交互行为。\n单元测试的定义 单元测试的定义很多，提取关键点可总结出这样的定义，单元测试是一种自动化测试方法，它具备如下特点：\n 验证一小段代码（也即，代码单元）。 执行速度很快。 并以相互隔离的方式来执行。  经典学派和伦敦学派之争 上述的定义延伸出两种学派的单元测试，经典学派（Classical School）和伦敦学派（London School）。\n它们分歧点主要在定义的第 1 点和第 3 点，以及选择使用测试替身的时机：\n    被测对象 隔离对象 使用测试替身的依赖     经典学派 一个或多个类 测试用例 共享依赖   伦敦学派 一个类 被测对象 除不可变依赖的其他依赖    支持伦敦学派的人通常持有如下观点：\n 观点一：伦敦学派拥有更细的测试粒度，一个用例只测试一个类，符合单一职责原则。 观点二：在依赖关系复杂的代码工程里，更易编写单元测试。 观点三：如果测试失败，很容易找到问题点。  而作者却更偏好经典学派，认为它能够写出更高质量的单元测试，因此也更能满足单元测试目标的要求。针对上述的几个观点，作者给出如下的反驳：\n  观点一：被测对象不应该是一个类，而应该是一个行为。也即单元测试应该站在用户的角度去验证系统的行为，至于该行为需要一个或是多个类来实现，这些都是实现细节，并不重要！\n单元测试应该是讲述一个你的代码如何解决业务问题的故事，书中举了一个很形象的例子，面向行为的单元测试就像下述故事一样，凝练、目标清晰：\n 当我呼唤我的狗时，它从右边向我走来。\n 而面向实现细节的单元测试则像这样，冗长、目标不明确：\n 当我呼唤我的狗时，它首先迈开它的左前腿，然后是有前腿，接着转过头来，并开始摇尾巴\u0026hellip;\n   观点二：代码的依赖关系复杂，通常都是代码架构设计的问题，使用测试替身只是隐藏了问题，并不能从根本上解决。\n  观点三：单元测试通常会被集成到 CICD 流程上，因此每次修改代码都会触发测试执行，因此，经典学派在问题定位上也不会很难。\n  更准确的定义 因此，书中认为，单元测试更准确的定义应该是，它是一种自动化测试方法，具备如下特点：\n 验证一个行为单元。 执行速度很快。 执行时与其他测试用例相隔离。  集成测试与端到端测试 书中对集成测试（integration test）的定义为不满足单元测试这三个特点的测试，比如如下的情形：\n 测试用例验证了多个行为（表现为用例中有多个 Assert 断言语句）。 测试用例中需要调用数据库、远程 API 服务等，考虑网络时延等，用例执行耗时可能来到秒级。 测试用例需要访问共享数据库，因此无法与其他用例隔离开。  端到端测试（end-to-end tests）属于集成测试的子集。它们的区别是，集成测试会使用测试替身来替代部分依赖，比如远程 API 服务；而端到端测试则不会，因此结果也更可靠。\n单元测试的结构 前面提到，好的单元测试应该要以最低的维护成本提供最大的价值。要降低维护成本，就要为它设计出好的代码结构。\n用例的命名 好的命名能够极大提升单元测试代码的可读性，当前，被提及最多的命名方式如下：\n [MethodUnderTest]_[Scenario]_[ExpectedResult]\n 其中：\n MethodUnderTest 表示被测试的方法。 Scenario 表示测试场景。 ExpectedResult 表示测试的预期结果。  // C# // [Fact]表示该方法为一个单元测试用例 [Fact] public void IsDeliveryValid_InvalidDate_ReturnsFalse() { DeliveryService sut = new DeliveryService(); DateTime pastDate = DateTime.Now.AddDays(-1); Delivery delivery = new Delivery { Date = pastDate }; bool isValid = sut.IsDeliveryValid(delivery); Assert.False(isValid); } 上述例子中，从用例名 IsDeliveryValid_InvalidDate_ReturnsFalse 可以看出，被测的方法为 IsDeliveryValid，场景是非法日期（Invalid Date），预期结果是方法返回 False。\n前文提到，我们应该**面向代码行为来编写单元测试，**而 IsDeliveryValid_InvalidDate_ReturnsFalse 这种命名方式却暴露了实现细节（被测方法）。\n更好的方法是，站在用户的角度命名测试用例：\npublic void Delivery_with_invalid_date_should_be_considered_invalid() 在该版本中，被测方法 IsDeliveryValid 从命名中被删除，命名已非常接近于人类语言，这样即使是非开发人员也能看懂。\n我们还能将进一步明确测试场景（delivery with past date）：\npublic void Delivery_with_past_date_should_be_considered_invalid() 到这里，命名已经很清晰，但还能更简洁，去掉没意义的 considered：\npublic void Delivery_with_past_date_should_be_invalid() 另外，我们应该在用例命名中使用肯定的语气，也即将 should be 替换为 is：\npublic void Delivery_with_past_date_is_invalid() 最后，我们没必要删减基本的英语语法，它能够让非开放人员更好地看懂用例，将 a 添加到命名中：\npublic void Delivery_with_a_past_date_is_invalid() 测试代码的组织 书中推荐按照 AAA 模式组织测试代码，AAA 模式将测试分成 3 部分：Arrange、Act 和 Assert。\n Arrange 部分负责初始化被测系统（System Under Test，SUT）和相关依赖的初始化。 Act 部分则调用 SUT 的被测方法，向其传递依赖，如果有返回值，则保存它。 Assert 部分校验 Act 部分的返回值，或系统的某个状态，或相关的交互动作等。   Given-When-Then 模式可能更常见，它跟 AAA 模式类似，Given 对应 Arrange，When 对应 Act，Then 对应 Assert。\n 前面的 Delivery_with_a_past_date_is_invalid 例子就是按照 AAA 的模式来组织：\n// C# [Fact] public void Delivery_with_a_past_date_is_invalid() { // Arrange  DeliveryService sut = new DeliveryService(); DateTime pastDate = DateTime.Now.AddDays(-1); Delivery delivery = new Delivery { Date = pastDate }; // Act  bool isValid = sut.IsDeliveryValid(delivery); // Assert  Assert.False(isValid); } 按 AAA 模式组织代码时，有以下几个注意点：\n  应始终将被测对象命名为 sut，这样可读性更高，比如上述例子中的 DeliveryService。\n  应避免在用例中包含 if 语句，如果必须，通常表明该用例测试的内容太多，需要拆分。\n  Act 部分应避免有多行，如果必须，通常表明代码设计不合理，缺少封装，需要重构。比如下述例子中，因缺少封装导致需要两次调用才能完成一次购买业务。更好的方法是将 store.RemoveInventory 封装到 customer.Purchase 里面，否则过度依赖调用方，容易产生数据不一致的问题。\n// C# // 缺少封装，导致Act需要2行 [Fact] public void Purchase_succeeds_when_enough_inventory() { // Arrange  var store = new Store(); store.AddInventory(Product.Shampoo, 10); var customer = new Customer(); // Act  bool success = customer.Purchase(store, Product.Shampoo, 5); store.RemoveInventory(success, Product.Shampoo, 5); // Assert  Assert.True(success); Assert.Equal(5, store.GetInventory(Product.Shampoo)); } // 合理的封装下，Act只需1行 [Fact] public void Purchase_succeeds_when_enough_inventory() { // Arrange  var store = new Store(); store.AddInventory(Product.Shampoo, 10); var customer = new Customer(); // Act  bool success = customer.Purchase(store, Product.Shampoo, 5); // Assert  Assert.True(success); Assert.Equal(5, store.GetInventory(Product.Shampoo)); }   测试代码的重用 测试用例之间往往会存在可重用的初始化代码，它们出现在 Arrange 部分。很多人的会选择通过将初始化代码抽取到构造函数中：\n// C# public class CustomerTests { // 将被测对象和依赖提取成为成员变量  private final Store _store; private final Customer _sut; // 在构造函数中进行初始化  public CustomerTests() { _store = new Store(); _store.AddInventory(Product.Shampoo, 10); _sut = new Customer(); } // 用例1  [Fact] public void Purchase_succeeds_when_enough_inventory() { bool success = _sut.Purchase(_store, Product.Shampoo, 5); Assert.True(success); Assert.Equal(5, _store.GetInventory(Product.Shampoo)); } // 用例2  [Fact] public void Purchase_fails_when_not_enough_inventory() { bool success = _sut.Purchase(_store, Product.Shampoo, 15); Assert.False(success); Assert.Equal(10, _store.GetInventory(Product.Shampoo)); } } 然而这种做法有两个明显的缺点：\n 导致测试用例之间耦合加深。上述例子中，两个用例都依赖了相同的 _sut 和 _store 实例。 降低了测试代码的可读性。上述例子中，阅读每个用例时，都必须跳转到构造函数查看 _sut 和 _store 的声明。  更好的方法是将初始化代码提取到私有工厂方法中，一方面消除了用例间的耦合，另一方面可读性也更好：\n// C# public class CustomerTests { // 用例1  [Fact] public void Purchase_succeeds_when_enough_inventory() { Store store = CreateStoreWithInventory(Product.Shampoo, 10); Customer sut = CreateCustomer(); bool success = sut.Purchase(store, Product.Shampoo, 5); Assert.True(success); Assert.Equal(5, store.GetInventory(Product.Shampoo)); } // 用例2  [Fact] public void Purchase_fails_when_not_enough_inventory() { Store store = CreateStoreWithInventory(Product.Shampoo, 10); Customer sut = CreateCustomer(); bool success = sut.Purchase(store, Product.Shampoo, 15); Assert.False(success); Assert.Equal(10, store.GetInventory(Product.Shampoo)); } // 初始化工厂方法1  private Store CreateStoreWithInventory( Product product, int quantity) { Store store = new Store(); store.AddInventory(product, quantity); return store; } // 初始化工厂方法2  private static Customer CreateCustomer() { return new Customer(); } } 评判单元测试的四个标准 只有好的结构，还不足以称为好的单元测试，理想的单元测试应该要做到四个标准：\n  避免漏报。\n  避免误报。\n  快速反馈。\n  可维护性高。\n   书中这四个标准的英文原文如下，本文按照个人理解进行了类似语义的翻译：\n Protection against regressions（避免漏报） Resistance to refactoring（避免误报） Fast feedback（快速反馈） Maintainability（可维护性）   标准一：避免漏报 好的单元测试应该尽量避免 Bug 漏报。\n代码是一项负债，代码越多，出错的概率也就越大。所以，单元测试覆盖的代码量越大，发现代码 Bug 的概率也就越大，漏报率越低。\n然而，高代码覆盖率只是好的单元测试的必要条件，我们不能一昧追求覆盖所有代码。有些代码的测试价值很低，比如枚举类、getter 和 setter 方法等，它们出错的概率很低，对它们做测试，收益并不大。\n因此，我们更应该关注如下两类代码：\n 复杂的代码。代码越复杂，出现 Bug 的概率越大。 关键业务代码。关键业务代码一旦出现 Bug，影响通常会很大，理应花更多的精力进行测试。  标准二：避免误报 好的单元测试应该尽量避免 Bug 误报。\n随着软件的不断演进，代码重构不可避免。重构就是在不改变可观测行为的基础上，对代码的实现进行修改，以降低它的复杂性。修改代码，就意味着可能引入 Bug，而单元测试作为防护网，能够有效阻止 Bug 的发生。\n重构，最怕遇到的就是单元测试的误报，也即测试报错但业务行为正常的情况。误报会加大重构的代价，久而久之，会影响开发人员重构的积极性。\n考虑如下代码，MessageRenderer 负责将消息 Message 渲染成 HTML 页面：\n// C# public class Message { public string Header { get; set; } public string Body { get; set; } public string Footer { get; set; } } // 渲染接口 public interface IRenderer { string Render(Message message); } public class MessageRenderer : IRenderer { public IReadOnlyList\u0026lt;IRenderer\u0026gt; SubRenderers { get; } public MessageRenderer() { SubRenderers = new List\u0026lt;IRenderer\u0026gt; { new HeaderRenderer(), new BodyRenderer() }; } // 将消息渲染成HTML页面，属于可观测的行为  public string Render(Message message) { return SubRenderers .Select(x =\u0026gt; x.Render(message)) .Aggregate(\u0026#34;\u0026#34;, (str1, str2) =\u0026gt; str1 + str2); } } 考虑有如下的单元测试，用于验证 MessageRenderer 具备正确的结构：\n// C# [Fact] public void MessageRenderer_uses_correct_sub_renderers() { var sut = new MessageRenderer(); IReadOnlyList\u0026lt;IRenderer\u0026gt; renderers = sut.SubRenderers; Assert.Equal(2, renderers.Count); Assert.IsAssignableFrom\u0026lt;HeaderRenderer\u0026gt;(renderers[0]); Assert.IsAssignableFrom\u0026lt;BodyRenderer\u0026gt;(renderers[1]); } 但该用例并未针对用户可观测的行为，也即 MessageRenderer.Render 方法，进行测试，而是陷入到具体的实现细节中。一旦我们对MessageRenderer 进行重构，可能就会出现误报。比如，从 BodyRenderer 拆分出 FooterRenderer：\n// C# public class MessageRenderer : IRenderer { public IReadOnlyList\u0026lt;IRenderer\u0026gt; SubRenderers { get; } public MessageRenderer() { SubRenderers = new List\u0026lt;IRenderer\u0026gt; { new HeaderRenderer(), new BodyRenderer(), // 重构点：拆分出FooterRenderer  new FooterRenderer() }; } // 将消息渲染成HTML页面，属于可观测的行为  public string Render(Message message) {...} } 那么，原有单元测试将会报错，但实际上，MessageRenderer.Render 的结果并未受影响。\n// C# [Fact] public void MessageRenderer_uses_correct_sub_renderers() { ... // 报错，重构后的renderers.Count为3  Assert.Equal(2, renderers.Count); ... } 更好的做法是只对可观测行为 MessageRenderer.Render 进行测试：\n[Fact] public void Rendering_a_message() { var sut = new MessageRenderer(); var message = new Message { Header = \u0026#34;h\u0026#34;, Body = \u0026#34;b\u0026#34;, Footer = \u0026#34;f\u0026#34; }; string html = sut.Render(message); Assert.Equal(\u0026#34;\u0026lt;h1\u0026gt;h\u0026lt;/h1\u0026gt;\u0026lt;b\u0026gt;b\u0026lt;/b\u0026gt;\u0026lt;i\u0026gt;f\u0026lt;/i\u0026gt;\u0026#34;, html); } 所以，防止误报的最有效做法是，针对可观测行为进行测试！\n漏报和误报的关系 把测试是否成功与功能是否正常放在一起，可得出如下表格：\n 真阴性。功能正常，且测试成功。正常情况下，单元测试都应该处于该情况。 真阳性。功能异常，且测试失败。这是单元测试存在的意义，当功能异常时，测试能够执行失败，以作提示。 假阴性。功能异常，但测试正常。测试漏报，表明\t代码中存在未被发现的 Bug。此情况影响最大，可能导致生产问题。可通过覆盖高价值代码降低该情况出现概率。 假阳性。功能正常，但测试失败。测试误报，可通过面向行为的测试降低该情况出现概率。  标准三：快速反馈 好的单元测试应该能够给开发人员快速反馈 Bug。\n快速执行是单元测试的基本属性，测试执行得越快，单位时间内执行的用例数量就越多，也就能缩短 Bug 反馈周期。一旦你引入 Bug，测试用例就能及时提示相关错误信息，避免出现更严重的影响。\n标准四：可维护性 好的单元测试应该有足够高的可维护性。\n可维护性由以下两方面组成：\n 理解测试用例的难度。测试代码越少，可读性越高，理解的难度越低。 运行测试用例的难度。测试依赖越少，运行难度越低。  理想的单元测试 能同时做到上述四点的即为理想的单元测试。然而，现实中却不存在这样的单元测试，除可维护性特征之外，其他三个特征会有所冲突，三者只能取其二，有点像 CAP 理论。\n  端到端测试能够同时做到避免误报和避免漏报，但无法做到快速反馈。\n  脆弱的测试，也即面向实现细节的测试，能够做到避免漏报和快速反馈，但很难避免误报。最极端的情况是针对源码本身做验证，一旦有修改代码的行为，立即报错，绝不会漏报，但极有可能是误报。比如：\n// C# [Fact] public void MessageRenderer_is_implemented_correctly() { string sourceCode = File.ReadAllText(@\u0026#34;[path]\\MessageRenderer.cs\u0026#34;); Assert.Equal(@\u0026#34; public class MessageRenderer : IRenderer { public IReadOnlyList\u0026lt;\u0026lt;IRenderer\u0026gt; SubRenderers { get; } public MessageRenderer() { SubRenderers = new List\u0026lt;\u0026lt;IRenderer\u0026gt; { new HeaderRenderer(), new BodyRenderer(), new FooterRenderer() }; } public string Render(Message message) { /* ... */ } }\u0026#34;, sourceCode); }   低价值的测试，比如针对 getter 和 setter 的测试，能够做到避免误报和快速反馈，但很难发现 Bug，导致漏报。\n  既然无法同时完美做到这四个标准，那么只能退而求其次，做权衡。\n通常来说，可维护性相对独立，通过组织良好的测试结构，能够达到很好的可维护性；而避免误报、消除测试的脆弱性，是构建测试系统/套件首要任务。因此，我们通常会在避免漏报和快速反馈之间做权衡：\n避免漏报和快速反馈之间的权衡，也可以对应到测试金字塔中各类测试的选择，端到端测试能避免漏报，单元测试能快速反馈，而集成测试则是折中选择：\nmock 的正确用法 测试替身的分类 前文提到，mock 是测试替身（test double）的一种，剩余的还有 spy 、stub、dummy 和 fake。实际上，它们又可以归成两类，Mock 和 Stub。其中 mock 和 spy 属于前者，dummy、stub 和 fake 属于后者。\n  Mock 是针对被测系统外发（outcoming）、有副作用的交互进行模拟和校验，如调用 SMPT 服务进行邮件发送。mock 和 spy 的主要区别在于，前者的替换对象是通过 mock 框架自动生成；而后者则是手动编写而成。\n// c# // mock和spy的区别 // 这是mock，使用了Mock框架的能力 var mock = new Mock\u0026lt;IEmailGateway\u0026gt;(); mock.Setup(x =\u0026gt; x.SendGreetingsEmail(“user@email.com”)) .Returns(true); public class EmailGatewayMock : IEmailGateway { ... public bool SendGreetingsEmail(string email) { if (email == “user@email.com”) { return ture; } ... } } // 这是spy，自定义一个测试替身类EmailGatewayMock var mock = new EmailGatewayMock() var sut = new Controller(mock); sut.GreetUser(\u0026#34;user@email.com\u0026#34;); // Mock需要校验交互行为，这里为校验SendGreetingsEmail被调用一次 mock.Verify(x =\u0026gt; x.SendGreetingsEmail(\u0026#34;user@email.com\u0026#34;), Times.Once);   Stub 是针对被测系统传入（incoming）、无副作用的交互进行模拟，但无发进行校验，如调用数据库进行数据查询。其中，dummy 主要是一些简单的硬编码常量，比如 NULL，一般作为占位符用于填充函数参数列表，并不会被使用；stub 则更复杂一些，需要根据不同的测试条件来预设不同的返回值；fake 则是实际依赖的裁剪版实现，它有实际的业务逻辑，但因经过简化而无法用到生产环境上，比如单元测试用的内存数据库。\n用的比较多的是 stub，我们可以通过 mock 框架来生成 stub：\n// c# [Fact] public void Creating_a_report() { // 这是Stub，只模拟传入行为  var stub = new Mock\u0026lt;IDatabase\u0026gt;(); stub.Setup(x =\u0026gt; x.GetNumberOfUsers()) .Returns(10); var sut = new Controller(stub.Object); Report report = sut.CreateReport(); // Stub不需要校验交互行为，校验读取结果即可  Assert.Equal(10, report.NumberOfUsers); }   可观测行为与实现细节 书中把系统的可观测行为定义为能够帮助客户端达成其目标的方法或状态，不满足这个定义的，都是实现细节。我们很容易将 public API 和可观测行为划上等号，但它们并不完全一样。\n在理想的情况下，public API 与可观测行为一致，实现细节都被包含在 private API 中。但大多数人在系统设计时，都会把部分实现细节泄露到 public API 里。\n比如，在下面的例子中，客户端主要使用 UserController.RenameUser 方法，用于给用户执行重命名操作：\n// C# public class User { public string Name { get; set; } public string NormalizeName(string name) { string result = (name ?? \u0026#34;\u0026#34;).Trim(); if (result.Length \u0026gt; 50) return result.Substring(0, 50); return result; } } public class UserController { public void RenameUser(int userId, string newName) { User user = GetUserFromDatabase(userId); string normalizedName = user.NormalizeName(newName); user.Name = normalizedName; SaveUserToDatabase(user); } } User 类提供了 2 个 public API，Name 属性和 NormalizeName 方法，但从“重命名”这个目标来看，NormalizeName 不应该是 UserController 可见的，它存在的意义是为了满足 User 的命名规则，因此，它属于 User 的实现细节。更好的设计是将它改为 private API：\n// C# public class User { private string _name; public string Name { get =\u0026gt; _name; set =\u0026gt; _name = NormalizeName(value); } private string NormalizeName(string name) { string result = (name ?? \u0026#34;\u0026#34;).Trim(); if (result.Length \u0026gt; 50) return result.Substring(0, 50); return result; } } public class UserController { public void RenameUser(int userId, string newName) { User user = GetUserFromDatabase(userId); user.Name = newName; SaveUserToDatabase(user); } } 理解可观测行为和实现细节的另一个途径是通过六边形架构（Hexagonal Architecture），它是领域驱动设计中推荐的架构，分成领域层和应用层，其中领域层实现业务逻辑，应用层负责与外部交互（比如消息总线、数据库等）。那么，在领域层内部的交互为实现细节，应用层与外部的交互为可观测行为。\nmock 可观测行为 从前文可知，针对实现细节进行测试往往会导致测试套变得脆弱，这对 mock 同样适用，mock 的对象应该只有可观测行为，而非实现细节。所以，不要尝试寻找对 private API 进行 mock 的方法！\n当我们回头再次审视单元测试的伦敦学派和经典学派之争时，经典学派应当是获胜的一方。主要的原因是，伦敦学派鼓励在不可变依赖之外的所有依赖项都使用 mock，而不区分可观察行为和实现细节，这会导致单元测试变得脆弱！\n单元测试的三种风格 现在，我们从另一个维度来审视单元测试的分类，基于输出的测试（output-based testing）、基于状态的测试（state-based testing）、基于通信的测试（communication-based testing）。\n  基于输出的测试，只校验 SUT 的输出结果，只适用于无副作用的方法，比如函数式编程。比如下面的例子：\n// C# public class PriceEngine { public decimal CalculateDiscount(params Product[] products) { decimal discount = products.Length * 0.01m; return Math.Min(discount, 0.2m); } } [Fact] public void Discount_of_two_products() { var product1 = new Product(\u0026#34;Hand wash\u0026#34;); var product2 = new Product(\u0026#34;Shampoo\u0026#34;); var sut = new PriceEngine(); decimal discount = sut.CalculateDiscount(product1, product2); Assert.Equal(0.02m, discount); }   基于状态的测试，需要校验系统状态，包含 SUT 本身及其依赖项的状态，比如下面的例子中：\n// C# public class Order { private readonly List\u0026lt;Product\u0026gt; _products = new List\u0026lt;Product\u0026gt;(); public IReadOnlyList\u0026lt;Product\u0026gt; Products =\u0026gt; _products.ToList(); public void AddProduct(Product product) { _products.Add(product); } } [Fact] public void Adding_a_product_to_an_order() { var product = new Product(\u0026#34;Hand wash\u0026#34;); var sut = new Order(); sut.AddProduct(product); Assert.Equal(1, sut.Products.Count); Assert.Equal(product, sut.Products[0]); }   基于交互的测试，则校验 SUT 与依赖对象的交互行为，其中依赖对象通常会被 mock 替代，比如下面的例子：\n[Fact] public void Sending_a_greetings_email() { var emailGatewayMock = new Mock\u0026lt;IEmailGateway\u0026gt;(); var sut = new Controller(emailGatewayMock.Object); sut.GreetUser(\u0026#34;user@email.com\u0026#34;); emailGatewayMock.Verify( x =\u0026gt; x.SendGreetingsEmail(\u0026#34;user@email.com\u0026#34;), Times.Once); }   下面，我们使用前文提到的四个标准来评判这三类风格的单元测试：\n 避免漏报。该标准与具体的测试风格相关性不大，主要与测试执行的代码量、代码的复杂性、代码的价值相关。 避免误报。基于输出的测试表现最好，因为测试用例只依赖 SUT 本身；基于状态的测试表现次之；基于交互的测试最易受到误报的影响，只针对可观测行为进行校验可降低误报影响。 快速反馈。只要没有进程外的依赖对象，三种风格的测试都能做到很快。 可维护性。基于输出的测试只依赖 SUT 本身，可维护性最好，其他两者相当。  所以，我们应当尽可能地使用基于输出的测试风格来编写单元测试，但它的适用场景决定了这很不容易，我们可以把系统设计成函数式架构（Functional Architecture）来达到这个目的。\n函数式架构与六边形架构类似，也分成两层，Functional Core 层和 Mutable Shell 层。Functional Core 层使用函数式编程风格来编写业务逻辑，核心是数据的不变性，根据系统输入，计算得出结果，无其他副作用；Mutable Shell 层一方面负责给 Functional Core 层输入，一方面负责将 Functional Core 层的输出转换成副作用，比如系统状态变更。\n函数式架构下，我们应该尽可能地增加 Functional Core 层的代码，而减少 Mutable Shell 层的代码。书中通过一个实际例子，详细介绍了如何将代码重构成函数式架构，本文不再展开。\n通过重构来更好地完成单元测试 代码的分类 我们可以从代码的复杂度（业务价值）和外部依赖的数量两个维度，将代码分成 4 类：\n 左上角通常为领域层代码，比如领域模型或核心算法，它们一般具备很高的业务价值和代码复杂度，但只有很少的外部依赖，通过单元测试覆盖。 右下角通常为应用层代码，比如控制器，它们一般没有业务逻辑，负责与外部依赖进行交互，通过集成测试覆盖。 左下角为低价值代码，比如常量定义，它们没有业务逻辑，也没有外部依赖，测试价值很低。 右上角为“大泥球”代码，既有复杂的业务逻辑，又有很多的外部依赖，我们很难对它进行单元测试。它是我们重点重构的对象，通常会将它拆分成领域层和应用层。  重构实战 考虑一个用户管理模块，当前只有一个业务，更改用户邮件，代码如下：\n// C# // 用户管理模块 public class User { public int UserId { get; private set; } public string Email { get; private set; } public UserType Type { get; private set; } // 当前只支持用户邮件更改业务  public void ChangeEmail(int userId, string newEmail) { // 从数据库中查询用户的邮件和用户类型  object[] data = Database.GetUserById(userId); UserId = userId; Email = (string)data[1]; Type = (UserType)data[2]; if (Email == newEmail) return; // 从数据库中查询公司邮箱域名和员工数量  object[] companyData = Database.GetCompany(); string companyDomainName = (string)companyData[0]; int numberOfEmployees = (int)companyData[1]; // 如果用户的新邮箱域名为公司域名，则该用户为员工，否则为客户  string emailDomain = newEmail.Split(\u0026#39;@\u0026#39;)[1]; bool isEmailCorporate = emailDomain == companyDomainName; UserType newType = isEmailCorporate ? UserType.Employee : UserType.Customer; // 若存在用户类型变更，则更新数据库中员工数量  if (Type != newType) { int delta = newType == UserType.Employee ? 1 : -1; int newNumber = numberOfEmployees + delta; Database.SaveCompany(newNumber); } Email = newEmail; Type = newType; // 保存当前用户信息，并向消息总线发送消息变更消息  Database.SaveUser(this); MessageBus.SendEmailChangedMessage(UserId, newEmail); } } 上述代码中，User.ChangeEmail 既包含了业务逻辑，又与包含了与 Database 和 MessageBus 外部依赖的交互，是个“大泥球”！\n接下来，我们对代码进行重构。\n  第一步：将外部依赖显式化。提高可测试性的常用手段是将隐式的依赖显式化，通常会通过依赖注入的方法实现。\n// C# // 用户管理模块 public class User { // 显式化外部依赖  private readonly Database _database = new Database(); private readonly MessageBus _messageBus = new MessageBus(); public int UserId { get; private set; } public string Email { get; private set; } public UserType Type { get; private set; } // 当前只支持用户邮件更改业务  public void ChangeEmail(int userId, string newEmail) { // 从数据库中查询用户的邮件和用户类型  object[] data = _database.GetUserById(userId); ... _messageBus.SendEmailChangedMessage(UserId, newEmail); } }   第二步：引入应用层。将与外部依赖交互的逻辑剥离到应用层中，通常会通过 Controller 实现：\n// C# // 用户管理应用层 public class UserController { private readonly Database _database = new Database(); private readonly MessageBus _messageBus = new MessageBus(); // 与外部依赖交互的逻辑，实际的业务执行调用领域层user.ChangeEmail方法  public void ChangeEmail(int userId, string newEmail) { object[] data = _database.GetUserById(userId); string email = (string)data[1]; UserType type = (UserType)data[2]; var user = new User(userId, email, type); object[] companyData = _database.GetCompany(); string companyDomainName = (string)companyData[0]; int numberOfEmployees = (int)companyData[1]; int newNumberOfEmployees = user.ChangeEmail( newEmail, companyDomainName, numberOfEmployees); _database.SaveCompany(newNumberOfEmployees); _database.SaveUser(user); _messageBus.SendEmailChangedMessage(userId, newEmail); } } // 用户管理领域层 public class User { public int UserId { get; private set; } public string Email { get; private set; } public UserType Type { get; private set; } // 函数式的方法，不涉及外部依赖  public int ChangeEmail(string newEmail, string companyDomainName, int numberOfEmployees) { if (Email == newEmail) return numberOfEmployees; string emailDomain = newEmail.Split(\u0026#39;@\u0026#39;)[1]; bool isEmailCorporate = emailDomain == companyDomainName; UserType newType = isEmailCorporate ? UserType.Employee : UserType.Customer; if (Type != newType) { int delta = newType == UserType.Employee ? 1 : -1; int newNumber = numberOfEmployees + delta; numberOfEmployees = newNumber; } Email = newEmail; Type = newType; return numberOfEmployees; } }   第三步：消除应用层的复杂性。应用层应该尽可能地简单：\n// C# public class UserController { private readonly Database _database = new Database(); private readonly MessageBus _messageBus = new MessageBus(); public void ChangeEmail(int userId, string newEmail) { object[] userData = _database.GetUserById(userId); User user = UserFactory.Create(userData); object[] companyData = _database.GetCompany(); Company company = CompanyFactory.Create(companyData); user.ChangeEmail(newEmail, company); _database.SaveCompany(company); _database.SaveUser(user); _messageBus.SendEmailChangedMessage(userId, newEmail); } } // UserFactory封装User实例化逻辑 public class UserFactory { public static User Create(object[] data) { Precondition.Requires(data.Length \u0026gt;= 3); int id = (int)data[0]; string email = (string)data[1]; UserType type = (UserType)data[2]; return new User(id, email, type); } } // CompanyFactory封装Company实例化逻辑 public class CompanyFactory {...} // Company封装公司领域逻辑 public class Company { public string DomainName { get; private set; } public int NumberOfEmployees { get; private set; } public void ChangeNumberOfEmployees(int delta) { Precondition.Requires(NumberOfEmployees + delta \u0026gt;= 0); NumberOfEmployees += delta; } public bool IsEmailCorporate(string email) { string emailDomain = email.Split(\u0026#39;@\u0026#39;)[1]; return emailDomain == DomainName; } } // User封装用户领域逻辑 public class User { public int UserId { get; private set; } public string Email { get; private set; } public UserType Type { get; private set; } public void ChangeEmail(string newEmail, Company company) { if (Email == newEmail) return; UserType newType = company.IsEmailCorporate(newEmail) ? UserType.Employee : UserType.Customer; if (Type != newType) { int delta = newType == UserType.Employee ? 1 : -1; company.ChangeNumberOfEmployees(delta); } Email = newEmail; Type = newType; } }   至此，我们成功将“大泥球” User 重构成领域层的 User、Company、UserFactory 和 CompanyFactory，应用层 UserController。\n这样，我们就能很方便地对 User 进行单元测试了：\n// C# [Fact] public void Changing_email_from_non_corporate_to_corporate() { var company = new Company(\u0026#34;mycorp.com\u0026#34;, 1); var sut = new User(1, \u0026#34;user@gmail.com\u0026#34;, UserType.Customer); sut.ChangeEmail(\u0026#34;new@mycorp.com\u0026#34;, company); Assert.Equal(2, company.NumberOfEmployees); Assert.Equal(\u0026#34;new@mycorp.com\u0026#34;, sut.Email); Assert.Equal(UserType.Employee, sut.Type); } 单元测试的反模式 对私有方法进行测试 对私有方法进行测试，是最常见的单元测试反模式。由前文可知，私有方法或实现细节往往是不稳定的，对它们进行测试会导致用例变得脆弱，更容易出现误报。\n如果测试用例在覆盖所有的可观测行为之后，仍然存在部分私有方法未覆盖，原因要么是这部分代码是冗余代码，要么是缺少合理的抽象。比如下面的例子中，Order.GetPrice 是一个具有独立且复杂逻辑的私有方法，理应有独立的单元测试对其进行覆盖：\n// C# public class Order { private Customer _customer; private List\u0026lt;Product\u0026gt; _products; public string GenerateDescription() { return $\u0026#34;Customer name: {_customer.Name}, \u0026#34; + $\u0026#34;total number of products: {_products.Count}, \u0026#34; + $\u0026#34;total price: {GetPrice()}\u0026#34;; 1 } private decimal GetPrice() 2 { decimal basePrice = /* Calculate based on _products */; decimal discounts = /* Calculate based on _customer */; decimal taxes = /* Calculate based on _products */; return basePrice - discounts + taxes; } } 更好的实现是将 Order.GetPrice 抽象成单独的类 PriceCalculator，提供公开接口 Calculate：\n// C# public class Order { private Customer _customer; private List\u0026lt;Product\u0026gt; _products; public string GenerateDescription() { var calc = new PriceCalculator(); return $\u0026#34;Customer name: {_customer.Name}, \u0026#34; + $\u0026#34;total number of products: {_products.Count}, \u0026#34; + $\u0026#34;total price: {calc.Calculate(_customer, _products)}\u0026#34;; } } public class PriceCalculator { public decimal Calculate(Customer customer, List\u0026lt;Product\u0026gt; products) { decimal basePrice = /* Calculate based on products */; decimal discounts = /* Calculate based on customer */; decimal taxes = /* Calculate based on products */; return basePrice - discounts + taxes; } } 为了可测试性而扩大 public API 范围 为了可测试性而扩大 public API 范围是我们常犯的另一个反模式，考虑下面的例子，Customer._status 是一个私有属性，其中 Promote 方法对其进行了更新：\n// C# public class Customer { private CustomerStatus _status = CustomerStatus.Regular; public void Promote() { _status = CustomerStatus.Preferred; } public decimal GetDiscount() { return _status == CustomerStatus.Preferred ? 0.05m : 0m; } } 为了测试 Promote 方法，很多人可能会将 _status 改成公有属性，便于在测试用例中校验正确性，这也是一种反模式！\n将领域逻辑泄漏到单元测试中 我们常常会无意中将领域逻辑泄漏到单元测试中，考虑下面的例子：\n// C# public static class Calculator { public static int Add(int value1, int value2) { return value1 + value2; } } public class CalculatorTests { [Fact] public void Adding_two_numbers() { int value1 = 1; int value2 = 3; // 领域逻辑的泄漏！  int expected = value1 + value2; 1 int actual = Calculator.Add(value1, value2); Assert.Equal(expected, actual); } } 上述的测试用例看起来没问题，但 int expected = value1 + value2; 相当于在测试用例中重新实现了一遍领域逻辑，若后续重构，很容易导致误报。更好的方法是，直接针对值进行断言：\npublic class CalculatorTests { [Fact] public void Adding_two_numbers() { int value1 = 1; int value2 = 3; int actual = Calculator.Add(value1, value2); // 直接针对值进行断言  Assert.Equal(4, actual); } } 代码污染 代码污染指的是在业务代码中增加仅用于单元测试的代码，这也是常见的反模式，比如下面的例子：\n// C# public class Logger { // 用于判断是否为测试环境  private readonly bool _isTestEnvironment; public Logger(bool isTestEnvironment) 1 { _isTestEnvironment = isTestEnvironment; } public void Log(string text) { if (_isTestEnvironment) 1 return; /* Log the text */ } } 对实现类进行 mock 我们一般只对接口进行 mock，mock 具体的实现类是一种反模式！\n与时间耦合 很多程序的实现逻辑需要访问当前时间，也即调用 DateTime.Now() 方法，因为方法与当前时间强耦合，不同的时间执行方法，结果可能会不一样。更好的方法是，将 DateTime 对象作为依赖注入到方法中。\n最后 本文花了很长的篇幅把 Unit Testing Principles, Practices, and Patterns 书中的主要内容列举了出来，书中有着更多的代码示例，能够让读者更深地理解作者的用意，建议大家直接读原书。\n总的来说，这不仅是一本介绍单元测试的书，更是一本介绍软件设计的书，非常值得一读！\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] Unit Testing Principles, Practices, and Patterns , Vladimir Khorikov\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2024-09-08T00:00:00Z","permalink":"https://yrunz.com/p/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95/","title":"深入理解单元测试"},{"content":"荐语 本文要介绍的是 2015 年 O\u0026rsquo;Reilly 出版的书籍 Big Data: Principles and best practices of scalable realtime data systems，书中主要介绍一种常见的大数据架构 —— Lambda 架构。Lambda 架构通过批处理和流处理相结合的方法，有效处理了大数据场景下的工作负载。相比以关系型数据库为核心的传统数据系统，它在可扩展性、容错性等方面有了更进一步的提升。\n虽然 Lambda 架构是十年前被提出的数据架构，但 Lambda 架构的以不可变数据作为核心、历史和实时数据分层处理的架构思想仍值得我们学习。\n传统数据系统的局限 考虑有这么一个系统，根据用户的浏览记录，统计用户访问网页的次数：\n在上述系统中，每次用户浏览一个 url，应用就会向数据库更新一行数据。随着用户量的不断上升，并发量急剧上涨，数据库最先达到瓶颈，频繁出现更新超时的现象。针对这种情况，我们可以引入消息队列，将单次写改成批量写，以此降低数据库的并发数。\n但这并不是长久之计，随着用户量的再次上涨，单实例的数据库还是会成为单点瓶颈。为此，可以对数据库进行水平扩展，分库分表，将原本集中在单个数据库实例上的请求，负载均衡到多个实例之上。\n分库分表虽能消除数据库单点瓶颈，但也会带来其他的一些问题：\n 应用逻辑更复杂。应用层需要根据数据的 key 找到其所在的数据库实例，以便进行数据更新。 维护成本更高。为了避免因数据库实例宕机导致的服务不可用，需要通过增加数据副本、缓冲队列等手段提升可用性。另外，随着数据量的增大，可能因数据库实例间负载不均衡，而产生对数据进行重分区的操作。这些都会导致维护成本的急剧增加。 容错性问题。一旦开发者在更新数据库时出现人为错误，比如在增加 pageviews 字段时多乘了 2，那么数据将难以恢复，因为很难溯源。  上述例子中，从简单的 CURD，到增加消息队列、分库分表、副本、重分区等，系统也越来越复杂。导致这一切的原因是，传统数据库系统并非为分布式而生，它无法很好地适用于大数据场景。\n大数据技术的局限 那么，大数据技术就能解决这些问题吗？答案是，否！\n像 Hadoop、MapReduce、HBase 等这类大数据技术，虽然能够天然就处理海量数据，但它们都有各种局限，比如，MapReduce 是批处理系统，计算时延高；HBase 键值查询快，但无法满足复杂的 SQL 查询场景。\n为了找到解决方法，我们还需从大数据系统的本质入手。\n大数据系统的本质 作者对数据系统进行了如下定义：\n A data system answers questions based on information that was acquired in the past up to the present.\n 也即，数据系统的本质是依据全量数据来回答用户提出的问题，从技术的角度讲，就是处理各类查询请求。因此，可以用一个公式来定义数据系统：\n query = function(all data)\n 对于大数据系统来说，在上述的定义之外，还应具备以下的属性：\n 健壮性和容错性。在大型分布式系统中，网络故障、服务器宕机等是必然发生的，大数据系统一方面应能应对这些异常，另一方面还应能为用户屏蔽其中的复杂性，避免高昂的维护成本。除此之外，大数据系统还须能容忍人为错误。 低时延。很多应用都要求低时延，通常在毫秒级别。 可扩展性。要能够支持通过扩展更多的机器来应对不断增加的数据量，保证查询性能不受影响。 可调试性。问题出现时，系统要提供必要的信息来辅助问题定位，关键是能够跟踪系统每一行数据的变更详情。 支持即席查询。用户能够灵活选择自定义的查询条件进行查询。  从前文可知，以传统关系型数据库为核心的数据系统并不完全具备这些属性，为此，Lambda 架构诞生了。\nLambda 架构的解决思路 我们可将数据分成两类，历史数据和实时数据（最近几个小时），用户对两类数据在查询时延、精度等方面的要求各不相同。所以，我们完全可以采取不同的技术方案对它们分别处理。这就是 Lambda 架构的核心思想之一，分层。\nLambda 架构分 3 个层次，每个层次职责分明、使用不同的技术方案，以此来满足大数据系统的各项诉求。\n 批处理层（Batch Layer）：负责离线处理历史数据，包含主数据集（Master Dataset）。所有的数据都会往主数据集写入。批处理层不会立即处理新数据，而是会在非业务繁忙时段，对全量数据进行批量处理，生成批视图（Batch View）。 服务层（Serving Layer）：负责存储批处理层生成的批视图，并对外提供批视图的查询接口。通常都会带有索引，以便用户能够快速查询数据。 速度层（Speed Layer）：负责实时处理新数据，生成实时视图（Realtime View），提供实时视图的查询接口，。  批处理层（Batch Layer） 不可变的数据 传统数据系统中，数据是可变的，比如前文所提到的网页访问例子中，pageviews 字段会随着网页访问行为而递增。当出现问题，特别是人为错误时，通常因难以溯源而无法纠错，导致容错性较低。\n为此，Lambda 架构中批处理层采取的策略是不可变的数据，通过存储原始的数据实现：\n上述例子中，采用可变数据的存储方式后，用户的每一次访问行为都对应一行数据，并通过时间戳来区分。pageviews 不再作为字段存储在数据库里，而是在数据处理时进行计算。\n这种不可变的数据存储能够带来以下一些好处：\n 更灵活的查询，可以对任何一个时间段的数据进行计算。 更高的容错性。即使在人为错误发生后，我们仍能够基于原始数据进行重新计算，得到正确的结果。 更简单的存储。存储只涉及追加新的数据，不涉及数据更改、索引更新等复杂操作。  不可变数据存储的缺点也很明显，相比可变存储，数据是持续追加的，因此需要占用更大的存储空间。当然，我们也可以制定一些数据过期删除策略，定时清理冗余、低价值的数据，降低存储成本。\n数据存储 主数据集包含了所有数据，存储它的数据库应具备以下的能力：\n 能够支持追加写数据。 能够保证数据是不可变的。 数据存储量要足够大。 能够支持大批量地处理历史数据。  由此，我们很容易想到用分布式文件系统（Distributed File Systems，DFS）来存储主数据集：\n DFS 实现追加写非常简单，只须将文件添加到主数据集所包含的文件夹中即可。 DFS 通过权限控制可以轻松确保数据不可变。 DFS 通常有很好的可扩展性，可以轻松增加机器来应对不断增长的数据量。 DFS 的优点之一是具备很高的读写吞吐量，比如 HDFS，结合 MapReduce 计算引擎，能够实现大批量的离线数据处理。  全量计算 VS 增量计算 批处理的数据计算方式可以分成全量计算（Recomputation algorithms）和增量计算（Incremental algorithms）。全量计算每次都会对全量数据进行重新计算，得到新的批视图；而增量计算只会对新的数据进行计算，并将计算出来的视图与已有视图进行合并，形成新的批视图。\n相比全量计算，增量计算每次计算量更小、有更好的性能，但其代价是更低的容错性，因此，更推荐使用全量计算。\n另外，当算法依赖历史数据时，增量计算显然难以得到正确答案。考虑一个生日推断算法的例子，系统根据不同时刻抓取的用户信息 [userid, age, timestamp] 来推断用户的生日，比如在 2023/2/21 抓取某用户的年龄为 28，在 2024/3/4 再次抓取他的年龄为 29，那么该用户的生日就在 2023/2/21 和 2024/3/4 之间。显然，每次用于计算的信息越多，生日推断的结果就越准。\n另外，我们也可以选择一个折中方案，部分重计算（Partial Recomputation algorithms），也即选取必要的历史数据进行重新计算，以生日推断算法为例，假设新数据中只包含用户 A 的数据，那么，我们可以从主数据集中选取用户 A 的历史数据与新数据合并，再进行计算，这样计算的数据量将会大大减少。\n服务层（Serving Layer） 服务层属于批处理层的延伸，负责存储批视图，并对外提供查询接口。通常，服务层所使用的数据库须要具备如下属性：\n 支持批量写。当新的批视图计算完成后，服务层需要支持批量写来完全替换掉旧视图。 可扩展性好。随着主数据集的增长，批视图的存储空间可能也会随之增大，因此也要具备良好的可扩展性。 支持随机读。服务层必须支持随机读，以及索引查询能力，这样才能做到快速的查询响应。 容错性好。服务层通常也是分布式数据库，因此容错性是基础能力。  在服务层中，我们通常采用以空间换时间的策略，适当地增加数据冗余，以此换取更优的查询性能。\n比如，假设用户的报表需要按月、按天、按小时、按分钟等维度的视图，最简单的方法是只保存一份分钟级视图，若用户需要查询小时级粒度的数据，临时汇聚。但因为这需要额外的聚合运算，无法满足低时延的诉求。更好的方法是，在服务层保存多种粒度的视图。\n速度层（Speed Layer） 批处理层可以很好地处理离线历史数据，但很多场景下，数据是实时产生的，比如 IoT。此时，单靠批处理层显然无法无法满足实时数据的低时延处理。因此，可以把速度层看成是对批处理层在实时性上的补充，它负责实时处理源源不断的新数据，并生成实时视图（Realtime View）供用户查询。\n与批处理层相比，速度层具备如下的特点：\n 能够处理实时数据。与批处理层处理全量数据不同，速度层只处理实时数据。在技术上选型上，通常使用流式计算引擎，能够不断处理数据，并实时更新实时视图。 采用增量计算。速度层的首要目的是为用户提供实时数据的低时延查询能力，作为代价，计算结果可能存在精度不足、甚至错误的问题，不过这些错误最终都会被批处理层纠正。  速度层除了要进行实时数据的增量处理外，还需保存实时视图，提供类似服务层的能力，也即对外提供实时视图的查询接口。\n更好的 Lambda 架构实现 书中所介绍的 Lambda 架构中，服务层只是作为批处理层的延伸，存储批视图。但在介绍速度层时我们提到，速度层也要对外提供实时视图的查询接口。因此，更好的实现方式是，把批视图和实时视图都存储在服务层，让服务层成为唯一对外提供接口的地方：\n最后 Big Data 书中除了介绍 Lambda 架构的原理，还对如何利用 Hadoop、Cassandra 等大数据技术构建 Lambda 架构进行了详细的说明，并且给出了关键源码，可以通过阅读原书了解更多，本文不再详细介绍。\nLambda 架构将数据处理拆分成批处理层和速度层，前者采用全量计算，保证计算结果的正确性，从而让系统具备了很好的容错性；后者采用增量计算，保证了系统具备了低时延的实时数据查询能力，而数据正确性问题又能在批处理层得到纠正。两者最终的计算结果通过服务层对外提供查询接口，三个层次之间相辅相成。\n如果用公式来定义 Lambda 架构，会是这样的：\n query = function(Batch View, Realtime View)\nBatch View = function(all data)\nRealtime View = function(Realtime View, new data)\n 在技术选型上，批处理层的存储侧通常是分布式文件系统，比如 HDFS 等，而计算侧通常是离线计算引擎，比如 MapReduce、Spark、Hive 等；速度层通常是流式计算引擎，比如 Flink、Spark Stream 等；服务层通常是 NoSQL 数据库，比如 HBase、Cassandra 等。\nLambda 架构从诞生至今，历经了十多年的发展，它的一些缺点也了暴露出来：\n 实时与批处理结果不一致，容易引起的数据口径问题。速度层和批处理层走了两套不同的计算逻辑，可能存在这样的情况，用户查询某个数据，昨天查询的结果（实时视图）与今天查询的结果（批视图）不一致，让人疑惑。 批处理在计算窗口内无法完成。批处理通常发生在晚上业务空闲时段，然而随着数据量的增大，必然会出现在原有的计算窗口内无法完成全量计算，从而影响用户第二天的数据报表生成。 开发和维护成本大。批处理层和速度层采用两套不同的技术方案，开发和维护成本都比较大。 存储成本大。Lambda 架构为实现不可变的数据需要存储原始数据，数据的不断追加会带来很大的的存储成本。  针对 Lambda 架构的这些局限，Kappa 架构等大数据架构也应运而生。但 Lambda 架构因其优秀的稳定性和容错性，仍被应用在众多场景中。正如 软件架构, 一切尽在权衡 里提到的，这世上没有十全十美的架构，最重要的是根据实际业务情况，选择合适的架构。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] Big Data: Principles and best practices of scalable realtime data systems, Nathan Marz\n[2] 一文读懂Kappa和Lambda架构, 王建峰\n[3] 深入理解大数据架构之——Lambda架构, 博客园\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2024-07-21T00:00:00Z","permalink":"https://yrunz.com/p/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3lambda%E6%9E%B6%E6%9E%84/","title":"深入理解Lambda架构"},{"content":"荐语 本文要介绍的是 2021 年 O\u0026rsquo;Reilly 出版的书籍 Software Architecture: The Hard Parts（后文简称 SAHP），某种程度上，它是 Fundamentals of Software Architecture（后文简称 FSA）的延续，两本书的主要作者相同。FSA 是基础篇，重点介绍了常见的 8 种架构模式；SAHP 是进阶篇，重点介绍了如何从单体架构演进到分布式架构。\n软件架构，特别是分布式架构，是很复杂的。这世上不存在所谓的通用架构模式，针对特定的应用场景，架构师需要在数据一致性、可扩展性、性能等多个因素上进行权衡，这也是本书标题强调的 The Hard Parts。\nFSA 里有一句名言，“Everything in software architecture is a trade-off.”。本书的重点就是要教会你，在单体架构演进到分布式架构的过程中，如何众多因素上进行权衡分析，以此来选择出一个符合实际业务场景的软件架构。\n为什么叫 “The Hard Part”？ 书的名字叫 Software Architecture: The Hard Parts，其中，Hard 有两层含义。\n第一，软件架构很难。设计软件架构需要考虑的因素很多，架构师也必然会遇到各式各样的难题，而且它们往往没有通用的解决方法。软件工程的问题，很多时候可以通过 Google 搜索来解决；而软件架构的问题，只能根据实际的业务场景，在众多的考虑因素上进行权衡。所以，在软件架构上，不要试图找到最好的设计，而是要努力实现最不糟糕的权衡组合。\n Don’t try to find the best design in software architecture; instead, strive for the least worst combination of trade-offs.\n 第二，软件架构是稳定的。Hard 取自于硬件的英文单词 Hardware，相比软件 Software，硬件是更稳定的一个，它是软件的基础。同理，软件架构是一款软件的架子、基础，好的架构通常都是稳定的，不轻易随软件的变更而变化。\nSAHP 从内容上分成 2 大部分，第一部分讲拆分，如何拆分服务、数据，服务拆分的粒度如何控制等；第二部分讲协作，功能如何复用、工作流如何编排、分布式事务如何实现等。\n软件架构的“UT”（Fitness Function） 我们通常会为程序编写单元测试 UT，防止因代码修改导致不在预期内的功能变化。UT 会集成到 CICD 流水线上，在构建阶段就能够拦截代码 Bug。\n软件架构是易腐化的，特别是多人协作开发时，稍不注意就会向“大泥球”方向演进。因此，软件架构也需要“UT”，书中把它称为 Fitness Function。Fitness Function 也会集成到 CICD 流水线上，因此架构师就能及时清除架构的腐化，并阻止它。\n这里是一个防止循环依赖的 Fitness Function 的例子：\npublic class CycleTest { private JDepend jdepend; @BeforeEach void init() { jdepend = new JDepend(); jdepend.addDirectory(\u0026#34;/path/to/project/persistence/classes\u0026#34;); jdepend.addDirectory(\u0026#34;/path/to/project/web/classes\u0026#34;); jdepend.addDirectory(\u0026#34;/path/to/project/thirdpartyjars\u0026#34;); } @Test void testAllPackages() { Collection packages = jdepend.analyze(); assertEquals(\u0026#34;Cycles exist\u0026#34;, false, jdepend.containsCycles()); } } 架构量子（Architecture Quantum） 作者提出了架构量子（Architecture Quantum）的概念。量子在物理学中指参与基本相互作用的任何物理实体的最小量；架构量子则是指在软件架构中能够独立部署的最小集合。书中定义如下：\n An architecture quantum is an independently deployable artifact with high functional cohesion, high static coupling, and synchronous dynamic coupling.\n 提起独立部署的最小集合，大家可能最先想到的是服务，但架构量子还需要把服务所依赖的数据库、UI 等考虑进来。\n最典型的，单体架构的架构量子数量就是 1，如下图所示：\n另一个典型，微服务架构的架构量子数量为 n，其中 n 为微服务数量，如下图所示：\n微服务架构是典型的分布式架构，但分布式架构的架构量子并不一定都为 n，比如服务化架构，虽然有多个服务，但它们依赖同一数据库。现假设服务 A 对数据表做了升级，因而重新部署；那么依赖同一张表的服务 B 也因适配新数据表而重新部署。所以，服务 A 和 服务 B 并不具备独立部署的性质，如下图所示：\n拆分 软件中的耦合 服务拆分一般可以依据以下 3 个步骤：\n 找到软件中哪些模块耦合在一起。 分析清楚它们是如何耦合在一起的。 评估改变它们的耦合关系对系统的影响，并对影响进行权衡分析，得出最终的拆分方式。  书中对耦合的定义如下：\n Two parts of a software system are coupled if a change in one might cause a change in the other.\n 也即如果 A 的变化可能会导致 B 也跟着变，那么 A 和 B 就是耦合的。作者认为，耦合有两种：\n 静态耦合（Static coupling）。常见于单体架构中，比如软件对操作系统、编程框架、静态库/动态库等的依赖。静态耦合通常发生在编译时，在架构量子的边界之内。 动态耦合（Dynamic coupling）。常见于分布式架构中，比如服务间通信、工作流、分布式事务等。  然而，耦合在软件系统中是必然存在的，它不应该是驱动拆分的主要因素。\n驱动服务拆分的因素 清晰的业务述求是拆分的唯一驱动力，好的软件，必然是敏捷的、能够快速响应市场需求、具备竞争力的优势，映射到软件架构语言上，主要是软件的可维护性（Maintainability）、可测试性（Testability）、可部署性（Deployability）、可扩展性（Scalability）和可用性（Availability）。\n可维护性 可维护性（Maintainability）指在软件中新增/改变/删除特性的容易程度，可维护性越高，就越能拥抱变化。相比于分布式架构，单体架构的可维护性更差，新需求会导致整个系统的变更，这显然无法满足敏捷的要求。\n可测试性 可测试性（Testability）指软件中完成自动化测试的容易程度。假设应用的测试集是固定的。如果采用单体架构，每次变更上线都必须执行一次全量的测试用例；如果是分布式架构，因为已经按业务领域进行了服务拆分，当某个领域服务变更上线时，只需执行该领域的测试用例即可，测试时长和范围大大减少。\n当然，如果变更内容涉及服务间通信，如 API 接口变更，那么相关的服务的用例都需要重新执行。\n可部署性 可部署性（Deployability）不仅是软件部署的容易程度，还包含部署的频率以及部署对系统的影响。跟可测试性类似，单体架构下，每次变更都会导致系统的重新部署；分布式架构下，只有实际变更的服务才需重新部署，因而新特性导致系统不可用的风险会更低。同理，如果服务拆分粒度太细，服务间的通信、分布式事务等动态耦合也会使得部署风险的提升。\n可扩展性 可扩展性（Scalability）指在用户负载逐步上升时，系统仍然能够快速响应的能力。另一个容易混淆的性质是可伸缩性（Elasticity），指系统在面临瞬间高峰负载时仍然能够快速响应的能力。\n可用性 可用性（Availability）指系统在部分异常时能够保持服务不中断的能力。单体架构可用性很低，多实例部署并不能应对程序 Bug 导致的可用性问题；分布式架构下，如果服务间动态耦合较深， 可用性一样会很低。\n服务拆分方法论 书中介绍了 2 种服务拆分方法，Component-Based Decomposition 和 Tactical Forking。前者基于组件进行拆分，适用于代码结构划分清晰的单体架构；后者则是做减法，不断删除不想要的代码，适用于代码结构模糊的单体架构。\n基于组件的拆分（Component-Based Decomposition） 组件（Component）被定义为应用中具备特定角色和职责的构建块（building block），通常在编程语言中以命名空间或者目录的形式组织。比如，在下图目录结构中，命名空间 penultimate.ss.ticket.assign 就表示 Ticket Assign 组件。\n值得注意的是，组件下只包含文件，上图中 assign 为组件，而 ticket 为 子领域（subdomain）。\n基于组件进行服务拆分可以分成 6 步走，书中把每一步总结成一个模式，整体的 roadmap 如下：\n明确组件及其大小模式（Identify and Size Components Pattern） 第一步，明确系统中的组件，及其大小。组件的定义如前文所述，那么组件的大小如何判定呢？ 组件内包含的文件数量？类的数量？代码行数？书中给的建议是语句（statement）的数量，在编程语言中，语句通常以特殊的字符结束，比如在 Java 或 C/C++ 中以分号 ; 结束。从语句数量的多少，大概就能够看出该组件的职责有多少，以及它的复杂程度。\n在进行这一步的分析时，建议按照如下的表格把当前的组件梳理清楚：\n   组件名 组件命名空间 语句数量占比 语句数量 文件数量     Login ss.login 2% 1865 3   Billing Payment ss.billing.payment 5% 4,312 23   Billing History ss.billing.history 4% 3,209 17   Reporting ss.reporting 33% 27,765 162   Ticket ss.ticket 8% 7,009 45   Ticket Assign ss.ticket.assign 9% 7,845 14   \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip;    这样，我们就能对当前系统组件的情况一目了然，比如上表中 Reporting 组件显然是一个大组件，需要重点审视是否需要拆分。\n聚合公共领域组件模式（Gather Common Domain Components Pattern） 第二步，识别属于公共领域的组件，把它们整合到一起，方便后面能够快速拆分出公共服务。\n比如下述的例子，Customer Notification、Ticket Notify 和 Survey Notify 三个组件都属于通知领域，那么我们就可以把它们整合到一起。\n   组件 命名空间 职责     Customer Notification ss.customer.notification 通用通知   Ticket Notify ss.ticket.notify 票务业务通知   Survey Notify ss.survey.notify 调查问卷业务通知    整合之后的组件如下，后续就能基于该组件拆分出一个通知服务了。\n   组件 命名空间 职责     Notification ss.notification 包含通用通知、票务通知、调查问卷通知等公共通知的能力    组件扁平化模式（Flatten Components Pattern） 第三步，避免组件与类文件在同一层级下，比如下图中，.survey 子领域下既有类文件，又有组件 .templates。\n针对这种方向，我们有 2 种扁平化解决方案。（1）将 .templates 组件合并到 .survery 组件下；（2）将 .survery 子领域下的类文件抽象成新的组件。\n明确组件依赖关系模式（Determine Component Dependencies Pattern） 第四步，明确组件间的依赖关系。当组件 A 中引用了组件 B 中的类/接口/方法/变量时，我们称组件 A 依赖组件 B。明确组件间依赖能够让我们清楚拆分的代价。\n比如，在下图 a 中的依赖关系下，拆分是低代价的，可能只须进行简单的代码重构，就能顺利地完成拆分；而在图 b 的依赖关系下，拆分的代价变得高昂，须要对代码进行大规模的重写，才可能完成拆分。\n聚合领域组件模式（Create Component Domains Pattern） 第五步，尽可能地将组件归属到同一领域之下，这样就能够拆分出粒度较大的服务。这样做的原因是，从单体架构演进到分布式架构时，演进的第一步建议是服务化架构（service-based architecture），然后再到其他架构，如微服务架构。\n在实践该模式前，先了解命名空间的组成，以 ss.customer.billing.payment.MonthlyBilling 为例，它的各层级含义如下：\n假设有如下组件：\n   组件 命名空间     Billing Payment ss.billing.payment   Billing History ss.billing.history   Customer Profile ss.customer.profile   Support Contract ss.supportcontract    因为上述组件从业务功能上看都属于 Customer 领域，那么，我们可以把这些组件都归到 customer 命名空间下：\n   组件 命名空间     Billing Payment ss.customer.billing.payment   Billing History ss.customer.billing.history   Customer Profile ss.customer.profile   Support Contract ss.customer.supportcontract    创建领域服务模式（Create Domain Services Pattern） 经过前 5 步之后，我们可以得到功能内聚的领域了，最后一步就是将这些领域拆分成独立的服务。\nTactical Forking Component-Based Decomposition 的拆分思路可以看成是抽取（Extraction），而 Tactical Forking 则是删除（Deletion），删除你不想要的代码，直至达成拆分目的。\n假设有一应用，包含 3 个模块，现需要对应用拆分成 2 个服务，分别由 2 个团队来负责维护。其中，团队 A 负责正方形和棱形；团队 B 负责三角形。\n那么，采用 Tactical Forking 进行服务拆分的步骤如下：\n 复制整个应用，这样每个团队都各自拥有一份应用代码。 各团队分别删除不需要的代码模块，这里，团队 A 逐渐删除三角形代码、团队 B 逐渐删除正方形和棱形代码。 反复执行删除操作，直至完成拆分。  Tactical Forking 的优势在于落地简单，无须过多的前置分析，且删除代码总比抽取模块要容易得多；缺点也很明显，一方面剩余的代码没经过优化依旧是“大泥球”，另一方面可能会导致两个团队间存在重复的代码。\n驱动数据拆分的因素 通过前文所述的服务拆分方法论，我们已经成功将单体架构应用拆分成服务化架构应用，但各服务之间仍然共享一个数据库，数据需要进行拆分吗？\n驱动数据拆分的因素主要有 6 个，分别是变更影响范围、数据库连接数、可扩展性、容错性、架构量子以及数据库类型的选择。\n变更影响范围 服务化架构下，可能会存在多个服务依赖同一数据表的情况，这时如果该数据表做了变更，比如列名修改，那么依赖此表的服务都要进行变更升级。\n一种更危险的情况是，数据库变更发生时，服务 E 却忘记同步变更了，那么服务 E 相关的业务将不可用。\n为了避免这种情况发生，我们可以根据系统的限界上下文进行数据拆分。限界上下文内的服务访问各自的数据库，变更的影响范围得到了控制；限界上下文间的数据访问通过服务接口进行，即使数据表结构变更了，只要服务接口不变，数据消费端也不会受此影响。\n数据库连接数 数据库连接很耗资源，因此我们常用数据库连接池来提升系统性能，一方面可以进行复用，另一方面也能够限制连接数。\n分布式架构下，服务通常会存在多个实例，当系统大到一定规模时，很可能就会出现数据库连接数达到上限的情况，这时，我们就要考虑分库了。\n可扩展性 服务化架构下，初始阶段我们可以通过增加服务实例来提升系统性能，但最后，数据库一定会成为性能瓶颈，从而影响了系统的可扩展性。这时，我们就要考虑分库了。\n容错性 服务化架构下，如果数据库出现故障，那么整个系统都会不可用。分库之后，数据库的故障只会影响相关的服务，容错性大大增强。\n架构量子 假设一个系统中，服务 A 和 B 更关注数据的强一致性，而服务 C、D 和 E 这只需最终一致性节课，但因共享数据库的存在，使得系统的架构量子始终为 1，它们无法根据自己的架构特征来进行对应的设计。分库之后，这个问题也解决了。服务 A 和 B 共享一个数据库，通过数据库的 ACID 保证数据的强一致性；服务 C、D、E 有独立的数据库，架构更解耦。\n数据库类型的选择 和架构量子类似，一个系统中，服务 A 和 B 提供 OLTP 服务，而服务 C、D、E 提供 OLAP 服务，那么它们的数据库选型可能就完全不一样，前者可能会选择关系型数据库，而后者这可能会选择数据仓库。\n数据库的选型通常会考虑如下几个因素，架构师需要根据实际的业务属性进行权衡：\n 学习曲线。 数据建模的难度。 可扩展性/吞吐量。 可用性/分区容错性。 数据一致性。 读写优先级。 编程语言支持、产品成熟度、SQL 支持、社区活跃度等其他因素。  不建议数据拆分的场景 然而，并不是所有场景都需要数据拆分：\n **数据模型中，表与表之间存在较多的依赖的场景，**如外键、触发器、视图、存储过程等。这种情况下，拆分的难度会很大，而且强行拆分后也会使得系统更加复杂。 业务流程依赖数据库事务来实现的场景。共享数据库使得事务具备了 ACID 的性质，分库之后，分布式事务将会使得系统变得复杂，而且也很难保证 ACID。  数据拆分的步骤 书中总结数据拆分的 5 个步骤。\n第一步：明确数据领域 分析数据模型，根据系统的限界上下文，梳理出系统的数据领域。\n第二步：数据表聚类 在数据库中针对每个数据领域建立一个 schema，将数据表们分别归属到各自的 schema 之下。\n在进行数据表归类时，我们常常会遇到这样的场景，服务 A 和 B 都依赖数据表 T，那么，数据表 T 应该归属到哪个服务上呢？\n书中给出了以下几种归类方法：\n1、数据表拆分。针对服务 A 和 B 分别依赖数据表 T 的不同列的场景，我们可以将表 T 一分为二，Ta 和 Tb。\n2、服务接口访问数据。根据业务优先级将表 T 归属给服务 A，服务 B 通过调用 A 的接口访问相关数据。\n3、合并服务。如果是在无法归属，说明服务 A 和 B 耦合较深，它们就不应该拆分成独立的服务，应该合并在一起。\n第三步：隔离数据库连接 对服务的数据库连接配置进行调整，使其连接到对应的 schema 之上。如果业务流程需要 2 个 schema 的数据，则通过服务接口调用来进行数据关联。\n第四步：备份 schema 将 schema 复制到独立的数据库之中，因为数据的同步需要时间，这一步中，业务流程仍然访问共享数据库。\n第五步：切换新的数据库 schema 同步完成后，下一步就是将业务流程切换到独立数据库中去。\n分布式数据的访问 数据拆分之后，我们将面临如何访问分布式数据的问题。\n书中给出了 3 种常用的方法，服务间接口调用、数据副本、分布式缓存。\n（1）服务间接口调用\n这是最常见、最简单的一种方法，优缺点总结如下：\n   优点 缺点     简单。 数据访问性能低下，网络时延、数据时延（比如上图例子中服务 B 查询 item_desc 数据的时延）、数据安全等因素都会影响系统性能。   数据只存一份，不会存在数据容量过大问题。 系统吞吐量可能存在问题。    系统可用性存在问题。直接的服务间接口调用意味着双方服务具有较强的耦合，一旦生产端服务异常宕机，消费端服务也服务对外提供业务。    需要通信双方遵循接口契约。    （2）数据副本\n可以针对需要跨服务传输的数据建立副本，比如，在前面的例子中，我们可以在表 Ta 上建立新增一列 item_desc，这样，服务 A 就无须调用服务 B 的接口获取 item_desc 了。\n数据副本的优缺点总结如下：\n   优点 缺点     数据访问性能高。 同一份数据存在于不同的数据库内，容易产生数据不一致问题。   不存在数据访问吞吐量问题。 存在数据归属问题，这种情况下服务 A 和 B 都能够对 item_desc 进行更新，数据归属边界模糊。   不存在可用性问题。 需要在不同数据库间进行数据同步。   无服务依赖。     （3）数据缓存\n缓存是最常见的提升数据访问性能的一种技术手段，也常用于分布式数据的访问和共享。普通的缓存通常是将数据库里的热点数据在本地内存中缓存一份，以提升数据访问性能，但此类型的缓存不具备分布式数据共享的能力。\n而用于分布式数据访问的缓存大体上可以分成两种类型，中心化缓存和去中心化缓存。\n中心化缓存下，系统有一个独立的缓存服务供所有业务服务共同使用，所有服务都可以对缓存数据进行读取和写入，常见的如 Redis、Memcached 等分布式缓存系统。中心化缓存方案简单，服务通过远程调用接口读写缓存数据，但缓存服务容易成为系统单点瓶颈。另外，远程调用存在网络时延，会有一定的性能影响；而且所有服务都具备数据的读写权限，数据归属模糊。\n去中心化缓存下，每个服务的本地内存空间里都存有一份全量的缓存数据，而且它们之间会自动进行数据同步，常见如 Hazelcast、Apache Ignit 等内存数据网格。去中心化缓存架构下，缓存数据都在本地内存空间，具备很好的访问性能；且不存在缓存单点瓶颈，容错性、可扩展型都不错。\n当然，去中心化缓存也有不少缺点：\n 服务存在启动依赖。比如，在服务 A 异常宕机后，服务 B 再次启动时需要依赖服务 A 恢复正常，以初始化缓存。 需要庞大的内存空间。每个服务的缓存都是全量的，如果数据量很大，所需的内存也会很大。 如果内存数据变更频率很快，可能存在同步不及时的情况。 内存数据的同步依赖 TCP 协议，如果集群庞大，节点间进行 TCP 建立连接也会很耗时。  拆分粒度的权衡 软件设计里有一项单一职责原则，这意味着拆分的服务粒度越细越好吗？其实不然，影响拆分粒度的因素很多，架构师需要做权衡。\n 很多人有这样的一个误区，根据代码行、类的多少来决定服务拆分的粒度。然而，每个开发者的编码风格、技术水平都有所差异，这就导致他们开发出来的代码在代码行、类数量上会存在差异。所以，这并不合适。\n 驱动细粒度服务的因素 服务职责 服务的职责是首要考虑因素，在 实践GoF的23种设计模式：SOLID原则 里我们提到，单一职责原则强调要从用户的视角来把握拆分的度，把面向不同用户的功能拆分开。太过聚合会导致牵一发动全身，拆分过细又会提升复杂性。\n举个例子。下图 a 中，通知服务包含了 SMS 模块、Email 模块和信件模块，从职责上看似可以拆分成 3 个服务，但从业务层面考虑，它们所服务的对象都是一样的，具有强相关性，所以就不必要拆分；下图 b 中，消费者服务包含了用户资料模块、偏好模块和意见模块，它们是弱相关性，用户资料模块服务于后台管理、偏好模块服务于商品推荐、意见模块服务于 XXX，所以拆分就合理了。\n代码改动频率 可以根据模块间代码改动的频率进行服务划分，将改动频率高的模块与改动频率低的模块分开，有利于提升系统的稳定性和可用性。同样考虑通知服务，假设 SMS 模块 和 Email 模块很少改动，而信件模块则改动频繁，那么可以拆分成 2 个服务。\n可扩展性和吞吐量 将具备不同吞吐量的模块拆分成服务，可以更精确地进行系统扩展，节省资源。\n容错性 如果某些模块经常异常，从而导致系统不可用，那么可以考虑将它们拆分开。跟前面介绍的代码改动频率相关，通常改动频率越高，模块越不稳定，也就越容易出异常。\n安全 一个系统中，每个功能模块对安全的要求不一样，因此在进行技术选型时也会有所差异，比如用户资料服务包含基本信息模块和支付信息模块，后者对安全的要求就远大于前者，可以考虑将支付信息模块单独拆分成钱包服务。\n可延展性 可延展性（Extensibility）指系统业务的增长不断扩充功能的能力，更强调功能，而可扩展性（Scalability）更强调性能。举个例子，当前支付服务包含了储蓄卡、信用卡两种支付方式，在可预见的未来，还会新增如微信、支付宝、华为钱包等支付方式。如果不拆分，每次新增功能就会导致支付服务的升级部署，而且新功能的异常也会导致旧功能的不可用，从而影响了系统的可用性。\n驱动粗粒度服务的因素 ACID 事务 分布式 ACID 事务是很复杂的，如果两个模块间的业务流需要 ACID 事务来保证，就要考虑将它们保留在一个服务内。\n数据依赖 如果模块间数据依赖比较多、复杂，强行拆分会到来高昂的服务间通信代价。\n工作流 工作流的影响与数据依赖类似，如果系统的工作流需要服务间紧密协作，那么服务间通信的代价会很大，最直接的影响就是性能下降。\n共享代码 如果模块间共享代码具备如下特征，不建议进行更细力度的拆分了：\n 共享的代码属于领域层，带有业务逻辑。这说明模块间具有较深的业务耦合关系。 共享的代码变更频繁。这种情况下，各个服务也需要频繁升级适配最新版本，维护成本变大。 共享的代码无法实现版本控制。版本控制是当多个服务依赖公共代码库/服务时，避免因功能升级导致相互影响的重要手段。如果不能实现版本控制，系统可维护性将会大幅下降。  协作 服务间的代码复用 在分布式系统中，服务间多多少少会存在着代码复用，下面我们将介绍几种常见的代码复用方法。\n代码复制 最简单的代码复用方法是将同样的代码复制到各个服务上。\n   优点 缺点     真正实现了“零共享架构”。 功能变更需要在每个服务进行代码修改，工作量大。    可能存在因同步不全而导致的代码不一致的问题。    适用场景：需要共享的代码是比较简单的静态代码，如属性、简答的工具类等。\n共享库 共享库是最常见的代码复用方法，它将需要复用的代码打包成共享库文件，比如 Java 中的 jar 文件、C++ 中的 so 文件，在编译时集成到服务中去。\n根据每个服务依赖的类文件的不同，我们可以将粗粒度的共享库拆分，避免不必要的耦合出现。\n另外，我们也可以通过版本管理来控制共享库的影响范围。比如，服务 C 需要类文件 C3 和 C4 升级功能，而服务 A 和 B 并不需要，我们可以给共享库进行版本升级，只需服务 C 更新依赖版本即可。\n   优点 缺点     可以实现版本管理。 依赖管理比较困难。   共享库在编译器集成，能够减少运行时的错误。 在存在多种编程语言的系统中，仍然会存在代码重复。   功能变更只需修改共享库即可，工作量小。 废弃老旧版本会很困难，某些原本无须升级的也会被迫切换到新版本。   编译时集成，不影响系统的性能、可扩展性、容错性 版本变更对某些用户必然存在着信息滞后。    适用场景：适用于同构的、编程语言相同的系统下的代码复用。\n共享服务 如果系统是异构的、有多种编程语言的，那么我们可以使用共享服务来实现代码复用。该方法将公共的代码抽离出一个服务，通过接口对外开放能力，因此，只要消费端服务遵循同样的接口协议，就能使用该功能，无论系统异构或者由不同编程语言开发而成。\n共享服务的一大好处是，消费端服务与它的依赖是运行时依赖，共享服务的改变不会导致消费端服务的重新部署；当然，如果共享服务出现异常宕机，所有服务都会受此影响。\n   优点 缺点     在共享代码变更频繁的场景，无须升级消费端服务。 版本管理比较困难，与共享库有着类似的缺点。   完全不存在代码重复。 功能的使用，从服务内接口调用变成服务间接口调用，网络时延影响系统性能。   无静态代码依赖。 共享服务的异常会影响整个系统的可用性    共享服务可能成为单点瓶颈，从而影响系统的可扩展性和吞吐量。    相比于编译时依赖，运行时依赖存在更多意料之外的异常。    适用场景：异构或多编程语言系统、公共功能变化频繁的场景。值得注意的是，如果系统对性能或时延要求很高，这不适合使用该方法。\n分布式事务 在单体架构或多个服务共享一个数据库的服务化架构之下，我们可以利用数据库的能力实现 ACID 事务。比如在下图中，在一个 ACID 事务内有三次 Insert，当最后一次失败时，数据库会将两次的 Insert 操作回滚。\n ACID 指的是数据库为保证事务的正确可靠所必须具备的 4 个特性。\n 原子性（Atomicity）：一个事务的所有操作要么全部 commit 成功，要么全部失败 rollback。 一致性（Consistency）：事务执行前后，数据库的完整性约束没有被破坏。比如，A 给 B 转账事务中，转账前后总金额应该保持一致。 隔离性（Isolation）：多事务并发访问时，事务之间是隔离的，不会看到中间状态的数据。 持久性（Durability）：事务完成后，所有对数据的更改都不会丢失。   然而，在微服务架构之下，服务间的数据库是隔离的，分布式事务并不会遵循 ACID 性质。\n1、每个服务的 Insert 操作都独自 commit，原子性只限于当前服务内，在业务流程上并不具备原子性；\n2、当前服务 C Insert 失败后，服务 A 和 B 并不会自动实现回滚，从而产生了数据不一致，因此不具备一致性。\n3、服务 A 插入数据成功后，其他服务就能通过 服务 A 访问到该数据，此时业务流程可能并未结束，因此不具备隔离性。\n4、持久性只局限于当前服务内，也即服务 A 插入成功，数据持久化了，但并不能保证服务 B 和 C 也如此。\n分布式事务通常会遵循 BASE 性质，也即\n 基本可用（Basically Available），出现故障时系统仍然可用，只会牺牲响应时间或功能降级； 软状态（Soft State），允许系统中的数据存在中间状态； 最终一致性（Eventually Consistent），系统不能一直处于软状态之下，经过一定时间后，系统数据要能够达到一个最终一致的状态。  基本可用通常基于服务间异步通信实现。如下图所示，在同步通信下，如果服务 C 异常宕机，客户端将得不到响应；在异步通信下，则不会受到影响。\n实现最终一致性的方法主要有以下几种：\n1、后台服务数据同步。该方法会专门独立出一个后台数据同步服务，通过离线同步达成最终一致性。这里的关键点是后台服务需要知道哪些数据需要同步，可以通过数据库触发器、事件流等技术手段实现。\n后台服务数据同步的优缺点如下：\n   优点 缺点     服务间解耦。 所有的数据都因为后台同步服务而产生了耦合。   良好的用户响应时间。 需要新增一个同步服务，实现起来比较复杂。    数据同步过程可能涉及业务逻辑，从而导致这部分代码重复于业务服务和同步服务上。    数据离线同步通常发生在深夜业务空闲间隙，从而导致实现最终一致性的时间很长    2、服务间同步通信。服务间采用同步通信方式，就能实现在业务流程结束后达成最终一致性。同步通信的最大缺点是，当服务 C 故障时，整个系统将处于不可用状态，表现为用户得不到正常响应。\n服务间同步通信的优缺点如下：\n   优点 缺点     服务间解耦。 糟糕的用户响应时间。   能够及时达成最终一致性。 异常处理复杂，因为没有统一的控制中心，需要各个服务自己实现错误回滚逻辑。   业务流程具备原子性。     3、事件通知。相当于基于消息队列的异步通信方式，综合了前面两种方式的优点，但错误处理仍然会很复杂。\n事件通知的优缺点如下：\n   优点 缺点     服务间解耦。 异常处理复杂。   能够及时达成最终一致性。    良好的用户响应时间。     工作流编排 工作流（Workflow）指让各个服务协作起来共同完成的业务流程。分布式架构之下，工作流的编排通常可以分成两大类，中心化的 Orchestration 和 去中心化的 Choreography。\nOrchestration 编排方式 Orchestration 编排的架构如下，Orchestrator 服务作为控制节点，也是业务流程的起始点，负责整个工作流的编排、任务下发，各个业务服务只须处理 Orchestrator 下发的任务即可。\n下面以购物订单流程举例，用户提交订单后，Orchestrator 完成工作流编排，总共需要执行 4 个业务流程，其中订单创建和订单支付为同步流程，执行结束后即可响应用户；商品配送和邮件通知为异步流程。\nOrchestration 编排架构方案简单，各业务服务只须处理任务即可，这在在错误处理和状态管理上具有明显的优势，下面是支付失败场景的举例：\nOrchestration 编排架构下，Orchestrator 服务容易成为单点瓶颈，因此容错性、可扩展性、性能是劣势。\nChoreography 编排方式 Choreography 编排的架构如下，是去中心化的编排方式，各个业务服务需要管理与自身业务相关的工作流，既负责任务执行，也要负责下一步流程的选择与触发。\n同样以购物订单流程举例，与Orchestration 编排架构相比，去除了Orchestrator 单点瓶颈，因此在容错性、可扩展性和性能得到了提升。值得注意的是，例子中我们使用了异步流程，这是为了更能发挥 Choreography 编排的性能优势：\nChoreography 编排架构下，错误处理的逻辑遍布在各个业务服务之上，随着业务的不断增长，错误处理将会越来越复杂。以下是支付失败的场景举例：\nChoreography 编排架构在状态管理上也存在劣势，没了统一编排服务，每个业务服务需要维护整个工作流的处理状态，状态同步上会存在不少挑战，稍有不慎可能就出现状态不一致的问题。\nSaga 分布式事务 Saga 事务是分布式事务中的一种，也是我们实现分布式事务最常用的模式之一。Saga 由一系列事务组成，事务间通过消息或事件来触发。 如果某个事务失败，则 Saga 将执行补偿事务，以抵消上一个事务的影响。\n书中根据通信、一致性、编排三个维度的不同对 Saga 事务模式进行了划分，共有八种类型，名称系作者根据类似的实际生活例子命名，可以不用深究：\n   Saga 模式名称 通信方式 一致性 工作流编排方式     Epic Saga 同步 强一致性 Orchestration 编排   Phone Tag Saga 同步 强一致性 Choreography 编排   Fairy Tale Saga 同步 最终一致性 Orchestration 编排   Time Travel Saga 同步 最终一致性 Choreography 编排   Fantasy Fiction Saga 异步 强一致性 Orchestration 编排   Horror Story Saga 异步 强一致性 Choreography 编排   Parallel Saga 异步 最终一致性 Orchestration 编排   Anthology Saga 异步 最终一致性 Choreography 编排    每种模式都是在性能、可扩展性、可用性、复杂性等方面的权衡，同步通信 VS 异步通信、强一致性 VS 最终一致性、Orchestration 编排 VS Choreography 编排，它们之间的优缺点在前文中已进行了介绍，我们需要根据实际的业务场景去做出选择。\n不过在这八种模式中，有一种连作者也不推荐使用，那就是 Horror Story Saga，它在通信方式、一致性、工作流编排方式上都选择了相对更复杂的一种，组合起来会使得整个架构变得极其复杂。而且选择异步通信和 Choreography 编排通常是为了提升系统的性能和可用性，但强一致性又限制了它们，所以，这是一个矛盾的模式！\n最后 SAHP 书中，作者构造了一个虚拟的设计案例，各个章节都基于该案例串联起来，通过阅读该案例，读者对各个知识点会有更深的理解。\n本文只是介绍了 SAHP 重要的知识点，书中还有关于服务通信协议（JSON、XML等）、如何利用数据分析技术来更好地做权衡等内容，感兴趣的可以直接阅读原书。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] Software Architecture: The Hard Parts, Neal Ford, Mark Richards\n[2] Saga 分布式事务模式, Microsoft Azure architecture reference\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2024-06-16T00:00:00Z","permalink":"https://yrunz.com/p/%E8%BD%AF%E4%BB%B6%E6%9E%B6%E6%9E%84%E4%B8%80%E5%88%87%E5%B0%BD%E5%9C%A8%E6%9D%83%E8%A1%A1/","title":"软件架构，一切尽在权衡"},{"content":"最近几天，雨下得大。\n周末早晨在家，刚打开电视看球赛，外面的雨也跟着哗啦啦地下了起来。起初是垂直落下，没一会，风也来了，东西南北刮个遍。雨串在空中相互交叉，成了白茫茫一片。\n昨天才跟璐说起，“喜欢周末在阳台上看下大雨”，但眼下的阳台根本没法站人，匆忙把阳台置物架上被雨水打湿的纸箱搬走，便躲进家中。站在客厅上看着被雨水浸湿的阳台，想起了家乡的一首童谣。\n“落雨大，水浸街，阿哥担柴上街卖，阿嫂出街着花鞋。”\n小孩时候，每当下起大雨，耳边总会响起这首童谣。到底是谁在唱，已记不太清了，是母亲吧？还是奶奶呢？又好像是邻居的叔婆。童谣的曲调给人以忧伤，但小时候的下雨天，可一点也不忧伤。\n下雨天最爱在家门口玩赛船。老家各户门前都会留一个坑渠，也即下水道入口，家乡话叫“坑窿”，用于排水。街道中间地势稍高，每次下大雨，雨水总会沿着街道流下，在门前形成一条“小溪”，往坑渠流去。“小溪”很浅，即使是纸折船也无法浮起，但小孩的世界里，一切皆是船。从用过的练习本上撕下瓜子般大小的两片纸，是为船，左右手各拿一艘，紧贴水面。随着心中倒计时结束，手同时放开，看两艘赛船你追我赶，往终点驶去。\n下雨天最爱在池塘边钓鱼。那时候，家附近很多房子还没起，大雨过后，洼地积水成了池塘，鱼也有了。晚饭过后，池塘边总会集结起一帮钓鱼的孩子们。捡一根竹子当鱼竿，抓一只蚱蜢或扣一点田螺肉当诱饵，在胶丝上绑个小泡沫当浮标，静候鱼儿上钩。如果池塘够大，偶尔还能看到几个撑船的孩子。船是用多块泡沫捆绑而成的泡沫船，能载两三人，用一根大竹子往后一撑，就能往前，如此这般在池塘两岸往来，好不快乐。船没有护栏，稍有不慎就会从船上跌落，一旦落水，必然少不了母亲的一顿骂，因此未敢尝试。\n下雨天最爱在雨声中入睡。炎热的夏天在大雨的冲刷下变得清凉，夜晚睡觉，开一点窗，让风能够进来，风扇都不用开了，很舒服。雨水击打树叶和地面的声音、偶尔出现的轻微的雷声、此起彼伏的牛蛙声、叽叽不停的虫叫，总能使人安宁。在电影《星际穿越》里，主角库珀有一个音乐播放器，里面播放的是地球下雨天的声音，雨声、雷鸣、虫鸟叫，即使是在寂静虚无的太空里，下雨天的声音仍能抚慰人心。\n雨后的阳台是混乱的，满地的水迹混杂着泥土和枯叶，而那几盆吸足了水分的绿植，貌似又长出了新芽。\n（完）\n","date":"2024-04-21T00:00:00Z","permalink":"https://yrunz.com/p/%E4%B8%8B%E9%9B%A8%E5%A4%A9/","title":"下雨天"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：解释器模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 GoF 对桥接模式（Bridge Pattern）的定义如下：\n Decouple an abstraction from its implementation so that the two can vary independently.\n 也即，将抽象部分和实现部分进行解耦，使得它们能够各自往独立的方向变化。\n桥接模式解决了在模块有多种变化方向的情况下，用继承所导致的类爆炸问题。\n举个例子，一个产品有形状和颜色两个特征（变化方向），其中形状分为方形和圆形，颜色分为红色和蓝色。如果采用继承的设计方案，那么就需要新增4个产品子类：方形红色、圆形红色、方形蓝色、圆形红色。如果形状总共有 m 种变化，颜色有 n 种变化，那么就需要新增 m * n 个产品子类！\n现在我们使用桥接模式进行优化，将形状和颜色分别设计为抽象接口独立出来，这样需要新增 2 个形状子类：方形和圆形，以及 2 个颜色子类：红色和蓝色。同样，如果形状总共有 m 种变化，颜色有 n 种变化，总共只需要新增 m + n 个子类！\n上述例子中，我们通过将形状和颜色抽象为一个接口，使产品不再依赖于具体的形状和颜色细节，从而达到了解耦的目的。桥接模式本质上就是面向接口编程，可以给系统带来很好的灵活性和可扩展性。如果一个对象存在多个变化的方向，而且每个变化方向都需要扩展，那么使用桥接模式进行设计那是再合适不过了。\n当然，Go 语言从语言特性本身就把继承剔除，但桥接模式中分离变化、面向接口编程的思想仍然值得学习。\nUML 结构 场景上下文 在 简单的分布式应用系统（示例代码工程）中，我们设计了一个 Monitor 监控系统模块，它可以看成是一个简单的 ETL 系统，负责对监控数据进行采集、处理、输出。监控数据来源于在线商场服务集群各个服务，当前通过消息队列模块 Mq 传递到监控系统，经处理后，存储到数据库模块 Db 上。\n我们假设未来要上线一个不支持对接消息队列的服务、结果数据也需要存储到 ClickHouse 以供后续分析，为了应对未来多变的需求，我们有必要将监控系统设计得足够的可扩展。\n于是，整个模块被设计为插件化风格的架构，Pipeline 是数据处理的流水线，其中包含了 Input、Filter 和 Output 三类插件，Input 负责从各类数据源中获取监控数据，Filter 负责数据处理，Output 负责将处理后的数据输出。\n上述设计中，我们抽象出 Input、Filter 和 Output 三类插件，它们各种往独立的方向变化，最后在 Pipeline 上进行灵活组合，这使用桥接模式正合适。\n代码实现 // 关键点1：明确产品的变化点，这里是input、filter和output三类插件，它们各自变化  // demo/monitor/input/input_plugin.go package input // 关键点2：将产品的变化点抽象成接口，这里是input.Plugin，filter.Plugin和output.Plugin // Plugin 输入插件 type Plugin interface { plugin.Plugin Input() (*plugin.Event, error) } // 关键点3：实现产品变化点的接口，这里是SocketInput, AddTimestampFilter和MemoryDbOutput // demo/monitor/input/socket_input.go type SocketInput struct { socket network.Socket endpoint network.Endpoint packets chan *network.Packet isUninstall uint32 } func (s *SocketInput) Input() (*plugin.Event, error) { packet, ok := \u0026lt;-s.packets if !ok { return nil, plugin.ErrPluginUninstalled } event := plugin.NewEvent(packet.Payload()) event.AddHeader(\u0026#34;peer\u0026#34;, packet.Src().String()) return event, nil } // demo/monitor/filter/filter_plugin.go package filter // Plugin 过滤插件 type Plugin interface { plugin.Plugin Filter(event *plugin.Event) *plugin.Event } // demo/monitor/filter/add_timestamp_filter.go // AddTimestampFilter 为MonitorRecord增加时间戳 type AddTimestampFilter struct { } func (a *AddTimestampFilter) Filter(event *plugin.Event) *plugin.Event { re, ok := event.Payload().(*model.MonitorRecord) if !ok { return event } re.Timestamp = time.Now().Unix() return plugin.NewEvent(re) } // demo/monitor/output/output_plugin.go // Plugin 输出插件 type Plugin interface { plugin.Plugin Output(event *plugin.Event) error } // demo/monitor/output/memory_db_output.go type MemoryDbOutput struct { db db.Db tableName string } func (m *MemoryDbOutput) Output(event *plugin.Event) error { r, ok := event.Payload().(*model.MonitorRecord) if !ok { return fmt.Errorf(\u0026#34;memory db output unknown event type %T\u0026#34;, event.Payload()) } return m.db.Insert(m.tableName, r.Id, r) } // 关键点4：定义产品的接口或者实现，通过组合的方式把变化点桥接起来。 // demo/monitor/pipeline/pipeline_plugin.go // Plugin pipeline由input、filter、output三种插件组成，定义了一个数据处理流程 // 数据流向为 input -\u0026gt; filter -\u0026gt; output // 如果是接口，可以通过定义Setter方法达到聚合的目的。 type Plugin interface { plugin.Plugin SetInput(input input.Plugin) SetFilter(filter filter.Plugin) SetOutput(output output.Plugin) } // 如果是结构体，直接把变化点作为成员变量来达到聚合的目的。 type pipelineTemplate struct { input input.Plugin filter filter.Plugin output output.Plugin isClose uint32 run func() } func (p *pipelineTemplate) SetInput(input input.Plugin) { p.input = input } func (p *pipelineTemplate) SetFilter(filter filter.Plugin) { p.filter = filter } func (p *pipelineTemplate) SetOutput(output output.Plugin) { p.output = output } // demo/monitor/pipeline/simple_pipeline.go // SimplePipeline 简单Pipeline实现，每次运行时新启一个goroutine type SimplePipeline struct { pipelineTemplate } 在本系统中，我们通过配置文件来灵活组合插件，利用反射来实现插件的实例化，实例化的实现使用了抽象工厂模式，详细的实现方法可参考【Go实现】实践GoF的23种设计模式：抽象工厂模式。\n总结实现桥接模式的几个关键点：\n 明确产品的变化点，这里是 input、filter 和 output 三类插件，它们各自变化。 将产品的变化点抽象成接口，这里是 input.Plugin，filter.Plugin 和 output.Plugin。 实现产品变化点的接口，这里是 SocketInput, AddTimestampFilter 和 MemoryDbOutput。 定义产品的接口或者实现，通过组合的方式把变化点桥接起来。这里是 pipeline.Plugin 通过 Setter 方法将input.Plugin，filter.Plugin 和 output.Plugin 三个抽象接口桥接了起来。后面即可实现各类 input、filter 和 output 的灵活组合了。  扩展 TiDB 中的桥接模式 TiDB 是一款出色的分布式关系型数据库，它对外提供了一套插件框架，方便用户进行功能扩展。TiDB 的插件框架的设计，也运用到了桥接模式的思想。\n如上图所示，每个 Plugin 都包含 Validate、OnInit、OnShutdown、OnFlush 四个待用户实现的接口，它们可以按照各自的方向去变化，然后灵活组合在 Plugin 中。\n// Plugin presents a TiDB plugin. type Plugin struct { *Manifest library *gplugin.Plugin Path string Disabled uint32 State State } // Manifest describes plugin info and how it can do by plugin itself. type Manifest struct { Name string Description string RequireVersion map[string]uint16 License string BuildTime string // Validate defines the validate logic for plugin.  // returns error will stop load plugin process and TiDB startup.  Validate func(ctx context.Context, manifest *Manifest) error // OnInit defines the plugin init logic.  // it will be called after domain init.  // return error will stop load plugin process and TiDB startup.  OnInit func(ctx context.Context, manifest *Manifest) error // OnShutDown defines the plugin cleanup logic.  // return error will write log and continue shutdown.  OnShutdown func(ctx context.Context, manifest *Manifest) error // OnFlush defines flush logic after executed `flush tidb plugins`.  // it will be called after OnInit.  // return error will write log and continue watch following flush.  OnFlush func(ctx context.Context, manifest *Manifest) error flushWatcher *flushWatcher Version uint16 Kind Kind } TiDB 在实现插件框架时，使用函数式编程的方式来定义 OnXXX 接口，更具有 Go 风格。\n典型应用场景  从多个维度上对系统/类/结构体进行扩展，如插件化架构。 在运行时切换不同的实现，如插件化架构。 用于构建与平台无关的程序适配层。  优缺点 优点  可实现抽象不分与实现解耦，变化实现时，客户端无须修改代码，符合开闭原则。 每个分离的变化点都可以专注于自身的演进，符合单一职责原则。  缺点  过度的抽象（过度设计）会使得接口膨胀，导致系统复杂性变大，难以维护。  与其他模式的关联 桥接模式通常与抽象工厂模式搭配使用，比如，在本文例子中，可以通过抽象工厂模式对各个 Plugin 完成实例化，详情见【Go实现】实践GoF的23种设计模式：抽象工厂模式。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] Design Patterns, Chapter 4. Structural Patterns, GoF\n[3] 【Go实现】实践GoF的23种设计模式：抽象工厂模式, 元闰子\n[4] 桥接模式, refactoringguru.cn\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2024-04-13T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E6%A1%A5%E6%8E%A5%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：桥接模式"},{"content":" 《人间草木》是汪曾祺先生的一本散文集，书分成了五个章节，分别是“一果一蔬”、“季节的供养”、“四方游记”、“联大师友”和“从容而安”，内容很杂，也很有趣。汪曾祺先生的文字很朴实、轻盈，能给人以温暖的感觉。\n 《人间草木》整本书给我的最大的感受是，文字很朴实、很有趣，作者很温柔、对生活甚是热爱。\n汪曾祺先生的文字极朴实，上一个给我这种感觉的作家是阿城。按照阿城的话，“汪曾祺的白话句子，成精了，随手便是。”\n同样是朴实，但两人的风格又很不一样。阿城的文字是朴实中带有力量感、给人以极强的画面感，比如《溜索》里面的这段：\n 那牛软下来，淌出两滴泪，大眼失了神，皮肉开始抖起来。汉子们缚了它的四蹄，挂在角框上，又将绳扣住框，发一声喊，猛力一推，牛嘴咧开，叫不出声，皮肉抖得模糊一层，屎尿尽数撒泻，飞起多高，又纷纷扬扬，星散坠下峡去。\n 汪曾祺先生的文字是朴实且轻盈、给人以温暖的感觉 ，比如《昆明的雨》里面的这段：\n 卖杨梅的都是苗族女孩子，戴一顶小花帽子，穿着扳尖的绣了满帮花的鞋，坐在人家阶石的一角，不时吆唤一声：“卖杨梅——”声音娇娇的。她们的声音使得昆明雨季的空气更加柔和了。\n 所以，《人间草木》读下来，很舒服。\n汪曾祺先生知识渊博，很善于把各地方言融入到他的文字里，读《人间草木》最大的惊喜是找到了不少老家方言。\n比如藠头（“藠”读 jiào），是薤的鳞茎，外观与未长熟的蒜类似，做法大多是入醋、糖腌制，酸甜、能开胃，现已不多见。一直以为藠头就是蒜头，因其辛辣而不爱吃。年前在陕西吃羊肉泡馍，岳母让店家上了糖蒜，说“泡馍要就糖蒜吃，才地道！”，还以为是一个东西。\n比如香蕈（“蕈”读 xùn），就是冬菇，香蕈是南方的叫法。小时候很是好奇，好端端的菇，为什么要叫“xùn”？八竿子打不着。而且这个“xùn”要怎么写呢？\n汪曾祺先生是很热爱生活的，从《人间草木》的引言就能看出来：\n 如果你来访我，我不在，请和我门外的花坐一会儿，它们很温暖，我注视它们很多很多日子了。\n 热爱生活本是不易，了解完他的生平，感觉更为难得。汪曾祺先生一生经历过许多坎坷，在反右斗争中被打成“右派”、在“文革”中也进过牛棚，即便如此，他依然保持乐观。\n他曾被下放到张家口沙岭子农业科学研究所劳动，摘去“右派”分子帽子后，被派去沽源画一套马铃薯图谱。在沽源，他“远离了家人和故友，独自生活在荒凉的绝塞”，但“日子过得非常悠闲”。每天都是重复的工作，画马铃薯的花、叶子、薯块，“画完一种薯块，我就把它放进牛粪火里烤烤，然后吃掉”，并且颇为得意，“全国像我一样吃过那么多种马铃薯的人，大概不多！”。\n汪曾祺先生对吃甚是讲究！他“劝大家口味不要太窄，什么都要尝尝，不管是古代的还是异地的食物”。所以，在《人间草木》里，我们可以找到许多闻所未闻的美食及其做法。他对美食的描写很白话，但总能勾起人的味蕾。\n比如豌豆黄：\n 北京以豌豆制成的食品，最有名的是“豌豆黄”。这东西其实制法很简单，豌豆熬烂，去皮，澄出细沙，加少量白糖，摊开压扁，切成5寸×3寸的长方块，再加刀割出四方小块，分而不离，以牙签扎取而食\n 比如豇豆：\n 嫩豇豆切寸段，入开水锅焯熟，以轻盐稍腌，滗去盐水，以好酱油、镇江醋、姜、蒜末同拌，滴香油数滴，可以“渗”酒。炒食亦佳。\n 比如中吃不中看的干巴菌：\n 颜色深褐带绿，有点像一堆半干的牛粪或一个被踩破了的马蜂窝。里头还有许多草茎、松毛，乱七八糟！可是下点功夫，把草茎松毛择净，撕成蟹腿肉粗细的丝，和青辣椒同炒，入口便会使你张目结舌：这东西这么好吃？\n 汪曾祺先生常被人称作是“生活家”，他对身边的人、事、物都感兴趣，都观察得细致入微。他鼓励大家多积累一点生活知识，在他看来，“草木虫鱼，多是与人的生活密切相关。对于草木虫鱼有兴趣，说明对人也有广泛的兴趣。”\n热爱生活，是我们现代人所欠缺的。我们总追求效率、以结果为导向，每天忙碌奔波于工作中。我们总说工作是为了更好的生活，但实际却是，工作让我们忘记生活本就很好。\n汪曾祺先生有一句名言，“生活，是很好玩的”，我觉得很对！\n（完）\n","date":"2024-03-24T00:00:00Z","permalink":"https://yrunz.com/p/%E7%94%9F%E6%B4%BB%E6%98%AF%E5%BE%88%E5%A5%BD%E7%8E%A9%E7%9A%84/","title":"生活，是很好玩的"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：适配器模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 解释器模式（Interpreter Pattern）应该是 GoF 的 23 种设计模式中使用频率最少的一种了，它的应用场景较为局限。\nGoF 对它的定义如下：\n Given a language, define a represention for its grammar along with an interpreter that uses the representation to interpret sentences in the language.\n 从定义可以看出，解释器模式主要运用于简单的语法解析场景，比如简单的领域特定语言（DSL）。举个例子，我们可以使用解析器模式来对“1+2+3-4+1”这样的文本表达式完成解析，并得到最终答案“3”。\n解释器模式的整体思想是分而治之，每一个语法规则都使用一个类或者结构体（我们称之为 Rule Struct）来定义，它们相互独立，比如前一个例子中，“+” 和 “-” 都各自定义为一个 Rule Struct。因此，解释器模式的可扩展性很好。\n通常，我们还能使用抽象语法树（Abstract Syntax Tree，AST）来直观地表示待解释的表达式，比如“1+2+3-4+1”可以表示成这样：\nUML 结构 解释器模式通常有 4 种角色：\n Context：解释上下文，包含了解释语法需要的所有信息，它是的生命周期贯穿整个解释过程，是一个全局对象。 AbstractExpression：声明了解释语法的方法，通常只有 Interpret(*Context) 一个方法。 TerminalExpression：实现了 AbstractExpression 接口，定义了终结表达式的解析逻辑。终结表达式在抽象语法树中作为叶子节点。 NonterminalExpression：实现了 AbstractExpression 接口，定义了非终结表达式的解析逻辑。在抽象语法树中，除了叶子节点，其他节点都是非终结表达式。NonterminalExpression 通常会比 TerminalExpression 更复杂一些。  场景上下文 在 简单的分布式应用系统（示例代码工程）中，db 模块用来存储服务注册信息和系统监控数据，它是一个 key-value 数据库。为了更高的易用性，它支持简单的 SQL 查询功能。用户在终端控制台上可以通过 SQL 语句来查询数据库中的数据：\n简单起见，我们实现的 SQL 固定为 select xxx,xxx,xxx from xxx where xxx=xxx; 的形式，为此，我们要实现 3 个 TerminalExpression，即 SelectExpression、FromExpression 和 WhereExpression，分别解释 select 语句、from 语句、where 语句；以及 1 个 NonterminalExpression，即 CompoundExpression，用来解释整个 SQL 语句。\n代码实现 // demo/db/sql.go package db // 关键点1：定义Context结构体/类，这里是SqlContext，里面存放解析过程所需的状态和数据，以及结果数据 // SqlContext SQL解析器上下文，保存各个表达式解析的中间结果 // 当前只支持基于主键的查询SQL语句 type SqlContext struct { tableName string fields []string primaryKey interface{} } ... // 关键点2：定义AbstractExpression接口，这里是SqlExpression，其中Interpret方法以Context作为入参 // SqlExpression Sql表达式抽象接口，每个词、符号和句子都属于表达式 type SqlExpression interface { Interpret(ctx *SqlContext) error } // 关键点3：定义TerminalExpression，实现AbstractExpression接口，这里是SelectExpression、FromExpression和WhereExpression // SelectExpression select语句解析逻辑，select关键字后面跟的为field，以,分割，比如select Id,name type SelectExpression struct { fields string } func (s *SelectExpression) Interpret(ctx *SqlContext) error { fields := strings.Split(s.fields, \u0026#34;,\u0026#34;) if len(fields) == 0 { return ErrSqlInvalidGrammar } // 关键点4：在解析过程中将状态或者结果数据存储到Context里面  ctx.SetFields(fields) return nil } // FromExpression from语句解析逻辑，from关键字后面跟的为表名，比如from regionTable1 type FromExpression struct { tableName string } func (f *FromExpression) Interpret(ctx *SqlContext) error { if f.tableName == \u0026#34;\u0026#34; { return ErrSqlInvalidGrammar } ctx.SetTableName(f.tableName) return nil } // WhereExpression where语句解析逻辑，where关键字后面跟的是主键过滤条件，比如where id=\u0026#39;1\u0026#39; type WhereExpression struct { condition string } func (w *WhereExpression) Interpret(ctx *SqlContext) error { vals := strings.Split(w.condition, \u0026#34;=\u0026#34;) if len(vals) != 2 { return ErrSqlInvalidGrammar } if strings.Contains(vals[1], \u0026#34;\u0026#39;\u0026#34;) { ctx.SetPrimaryKey(strings.Trim(vals[1], \u0026#34;\u0026#39;\u0026#34;)) return nil } if val, err := strconv.Atoi(vals[1]); err == nil { ctx.SetPrimaryKey(val) return nil } return ErrSqlInvalidGrammar } // 关键点5：实现NonterminalExpression，这里是CompoundExpression，它在解释过程中会引用到TerminalExpression，可以将TerminalExpression作为成员变量，也可以在Interpret方法中直接创建新对象。 // CompoundExpression SQL语句解释器，SQL固定为select xxx,xxx,xxx from xxx where xxx=xxx; 的固定格式 // 例子：select regionId from regionTable where regionId=1 type CompoundExpression struct { sql string } func (c *CompoundExpression) Interpret(ctx *SqlContext) error { childs := strings.Split(c.sql, \u0026#34; \u0026#34;) if len(childs) != 6 { return ErrSqlInvalidGrammar } // 关键点6：在NonterminalExpression的Interpret方法中，调用TerminalExpression的Interpret方法完成对语句的解释。  for i := 0; i \u0026lt; len(childs); i++ { switch strings.ToLower(childs[i]) { case \u0026#34;select\u0026#34;: i++ express := \u0026amp;SelectExpression{fields: childs[i]} if err := express.Interpret(ctx); err != nil { return err } case \u0026#34;from\u0026#34;: i++ express := \u0026amp;FromExpression{tableName: childs[i]} if err := express.Interpret(ctx); err != nil { return err } case \u0026#34;where\u0026#34;: i++ express := \u0026amp;WhereExpression{condition: childs[i]} if err := express.Interpret(ctx); err != nil { return err } default: return ErrSqlInvalidGrammar } } return nil } 客户端这么使用：\n// demo/db/memory_db.go package db // memoryDb 内存数据库 type memoryDb struct { tables sync.Map // key为tableName，value为table } ... func (m *memoryDb) ExecSql(sql string) (*SqlResult, error) { ctx := NewSqlContext() express := \u0026amp;CompoundExpression{sql: sql} if err := express.Interpret(ctx); err != nil { return nil, ErrSqlInvalidGrammar } // 关键点7：解释成功后，从Context中获取解释结果信息 \ttable, ok := m.tables.Load(ctx.TableName()) if !ok { return nil, ErrTableNotExist } record, ok := table.(*Table).records[ctx.PrimaryKey()] if !ok { return nil, ErrRecordNotFound } result := NewSqlResult() for _, f := range ctx.Fields() { field := strings.ToLower(f) if idx, ok := table.(*Table).metadata[field]; ok { result.Add(field, record.values[idx]) } } return result, nil } 总结实现解释器模式的几个关键点：\n 定义 Context 结构体/类，这里是 SqlContext，里面存放解释过程所需的状态和数据，也会存储解释结果。 定义 AbstractExpression 接口，这里是 SqlExpression，其中 Interpret 方法以 Context 作为入参。 定义 TerminalExpression 结构体，并实现 AbstractExpression 接口，这里是 SelectExpression、FromExpression 和 WhereExpression。 将 Interpret 方法解释过程中产生的过程状态、数据存储在 Context 上，使得其他 Expression 在解释过程中能够访问。 实现 NonterminalExpression，这里是 CompoundExpression，它在解释过程中会引用到 TerminalExpression，可以把 TerminalExpression 作为成员变量，也可以在 Interpret 方法中直接创建新对象。 在 NonterminalExpression 的 Interpret 方法中，调用 TerminalExpression 的 Interpret 方法完成对语句的解释。这里是 CompoundExpression.Interpret 调用 SelectExpression.Interpret、FromExpression.Interpret 和 WhereExpression.Interpret 完成对 SQL 的解释。 解释成功后，从 Context 中获取解释结果。  扩展 领域特定语言 DSL 在前文介绍解释器模式时有提到，它常用于对领域特定语言 DSL 的解释场景，那么什么是 DSL 呢？下面我们将简单介绍一下。\n维基百科对 DSL 的定义如下：\n A domain-specific language (DSL) is a computer language specialized to a particular application domain.\n 可见，DSL 是针对特定领域的一种计算机语言，与之相对的是 GPL，General Purpose Language，即通用编程语言。我们常用的 C/C++，Java，Go 等都属于 GPL 的范畴。\nDSL 又可细分成 2 类：\n  External DSL ：此类 DSL 拥有独立的语法以及解释器，比如 CSS 用于定义 Web 网页的样式和布局、SQL 用于数据查询、XML 和 YAML 用于配置管理，它们都是典型的 External DSL。\n#ExternalDSL举例，SQLselectid,namefromregionswhereid=‘1’;  Internal DSL：此类 DSL 构建与 GPL 之上，比如流式接口 fluent interface、单元测试中的 Mock 库，它们可以提升 GPL 的易用性和易理解性。\n  // Internal DSL，Java中的Mockito库 Mockito.when(mockDemo.isTrue()).thenReturn(1); Martin Fowler 大神专门写了一本书《领域特定语言》来介绍 DSL，更多详细、专业的知识请移步这里。\n典型应用场景  简单的语法解析。解释器模式的运用场景较为单一，主要运用于简单的语法解析场景，比如简单的领域特定语言（DSL）。  优缺点 优点  易于扩展。前文提到，使用解释器模式进行语法解释时，每种语法规则都会有对应的 Expression 结构体/类。因此，新增一种语法规则会非常的容易；类似地，改变一种已有的语法规则的解释方式也是很容易，单点改动即可。  缺点  不适用于复杂的语法解释。当语法过于复杂时，Expression 结构体/类的数量将会变得很多，从而难以维护。  与其他模式的关联 解释器模式通常与组合模式（Composite Pattern）结合在一起使用，UML 结构图中的 NonterminalExpression 和 AbstractExpression 的就是组合关系。\n另外，解释器模式这种分而治之的方法，与状态模式（State Pattern）中每种状态处理各种的逻辑很是类似。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] Design Patterns, Chapter 5. Behavioral Patterns, GoF\n[3] Domain-Specific Languages Guide, Martin Fowler\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2024-03-23T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E8%A7%A3%E9%87%8A%E5%99%A8%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：解释器模式"},{"content":"2023 年最重要的是完成了一项人生大事。\n生活 我和璐在年初相识、年末结婚，朋友们都说我们进度快。用一位同事的说法，我们在年度项目立项之后才认识，但项目还没结项就已经结婚了，比项目进度还快。其实，若非大家提起，我们俩倒没有这种感觉，反而觉得是相识已久的老朋友，从相识到结婚，每一步都走得很踏实。\n与璐在一起之后，生活丰富了许多，我们会一起骑车买菜、吃宵夜、打游戏、追剧、彻夜长谈。有时候，幸福不一定要一夜暴富，俩人一起打卡生活的这些小事情，也会幸福感爆棚。\n璐的身上一定有某种光环，跟她相处时会感到很舒适，她的朋友多，“小”朋友更多——家里的小孩、她朋友的小孩、我朋友的小孩，都爱跟她玩，特别是我那小侄女，每次回家和视频都会“璐璐阿姨”的叫个不停。我时常称她为“孩子王”，她总会回以一个得意的笑。\n璐是一个很热爱生活的人，家里很快就被生活填满了，冰箱里的水果、茶几上的零食、还有一只叫做“栗子”的小猫到处跑酷。一猫两人三餐四季，我们的故事刚开始不久，但还会持续很久很久。\n阅读 2023 年读的书少，表面上是工作更忙了，根本上还是变懒了，工作忙只是自我说服的一个说辞。由俭入奢易，由奢入俭难，现在再回头看 2019 年读过的书，真的佩服当初的自己。\n虽然依然记得《曾国藩家书》里面的“少睡多做，一人之生气”，但已经很难再做到“少睡”了。2023 年，在多睡的情况下，也多做了一点，读了 8 本书。\n                    Apache Spark In Action（2nd Edition），★★★☆☆，Spark 的入门读物，内容比较浅，读完可以对 Spark 有比较系统的理解。\n  High Performance Spark，★★★☆☆，Spark 的进阶读物，介绍 Spark 的调优方法，但主要集中在应用层调优。\n  计算机体系结构：量化研究方法（第 6 版），★★★★★，介绍了计算机体系结构相关知识，包括系统设计、指令集等与硬件相关的内容，只读了几章，对做计算机底层性能调优帮助比较大。\n  计算机组成与设计：硬件/软件接口，★★★★☆，也是介绍计算机体系结构的相关知识，相比《计算机体系结构》更底层一些，强调软硬协同设计及其对性能的影响。\n  芯片简史，★★★★☆，介绍芯片的发展史，与《芯片战争》类似。《芯片战争》更多从商业的角度来讲述，而《芯片简史》更多是从技术的角度。\n  把时间当朋友（修订版），★★★☆☆，李笑来的老书新编，与《财富自由之路》内容类似，介绍个人学习与成长的方法。\n  纳瓦尔宝典，★★★★☆，硅谷大佬纳瓦尔·拉维康特的一些智慧箴言，主题是关于如何积累财富和获得幸福人生，里面的很多观点都让人受益。\n  合作的进化，★★★★★，2023 年印象最深刻的书，《纳瓦尔宝典》的推荐书籍，作者以“重复囚徒困境”游戏作为切入点，讲述了在与他者的持续交往中，应该选择怎样的合作策略才能得到较好的收益。\n  写作 相比 2022 年的写作量，2023 年少得可怜。很多计划 2023 年写的文章，都没有完成，想得多、做得少。本来写作效率就不高，隔太久没写文章之后，再想“动笔”就更难了。\n 《纳瓦尔宝典》：如何积累财富？，《纳瓦尔宝典》的读书总结，纳瓦尔·拉维康特对财富的定义是，“财富就是在你睡觉时也可以帮你赚钱的资产”，而得到财富的关键点是，找到自己的专长，并持续扩大它的优势。 《纳瓦尔宝典》：如何获得幸福？，《纳瓦尔宝典》的读书总结，财富不是人生的终极目标，幸福才是。相比学习如何获得财富，其实，我们更应该学习如何获得幸福。 实践GoF的23种设计模式：Go 实现，写了 2 篇，向这个系列完结的目标又前进了一小步（还差 9 篇）。希望 2024 年能写完。 合作 or 背叛，你怎么选？，《合作的进化》的读书总结，人们总是习惯考虑零和博弈，这种情况下，一个人赢，另一个就输。但在现实生活中，大多数情况属于非零和博弈，可以双赢，也能双输。在人际交往中，我们应该尽可能地促成合作，不要嫉妒、不要首先背叛、对合作与背叛都要以回报、不要耍小聪明。  从 2019 年底开始写作，转眼间已经过去 4 年了，这些年也曾怀疑过写作的意义何在，它既不能增加收入，也不能让工作做得更好，而且还要花费大量的时间和精力。但每次看到有网友在评论里的好评、公众号新增的粉丝时，心里又是喜悦的，于是就有了继续写作的动力。\n这次春节回家，从老房子里把初中时期每天写的随笔翻出来看，很多早已忘记的人和事再次出现在脑海中，依旧能被很多生活的细节感动得热泪盈眶。\n                  我想，这就是写作的意义吧，把自己的所做所想记录下来，多年后再回头看，又会收获多一份感动。\n（完，2024.02.10，大年初一）\n","date":"2024-02-10T00:00:00Z","permalink":"https://yrunz.com/p/2023-%E5%B9%B4%E5%B0%8F%E7%BB%93/","title":"2023 年小结"},{"content":" 《合作的进化》是罗伯特·阿克塞尔罗德的一本博弈论经典之作，作者以“重复囚徒困境”游戏作为切入点，讲述了在与他者的持续交往中，应该选择怎样的合作策略才能得到较好的收益。本书的一个重要结论是，人们相互作用越频繁，合作的可能性就越大。\n本文是对《合作的进化》核心内容的总结。\n 前言 先从两个简单的问题开始：\n 在与他人的持续交往中，人什么时候应该合作，什么时候只需为自己着想？ 一个人会继续帮助他的一位从来不思回报的朋友吗？  合作，是我们常常要面对的事，小到个人，大到国家。合作虽然能带来好处，但并不总是能建立起来。\n两国之间的贸易壁垒就是一个很好的例子。显然，自由贸易对两国都能带来好处，但问题是，无论谁单方面取消壁垒，都会导致自己国家处于不利的贸易状态。所以，无论一个国家如何做，另一个国家保持它的贸易壁垒总是比较有利的。因此，每个国家都有利益动机保持贸易壁垒。\n同理，在人与人之间、公司之间，也会存在类似的场景，我们可以用一个简单的游戏来进行模拟，那就是著名的“囚徒困境”游戏。\n囚徒困境 在“囚徒困境”游戏中，有两个对策者，每人都有两个选择：合作或背叛，并且都需要在不知道对方选择的情况下做出自己的选择。每种选择对应的收益如下图所示：\n从图中可以看出，当双方都选择合作时，集体收益是最高的 6；当一人选择合作，另一人选择背叛时，背叛一方的个人收益是最高的 5。之所以称为“困境”，是因为不管对方怎么选择，从个人的角度，背叛都是更好的选择，而双方背叛却会导致最低的集体收益。\n从上述结论来看，背叛是必然的选择，那么为什么现实世界中还会存在这么多的合作呢？\n其实，上述结论的前提是，对策者双方只相遇一次，在只考虑当前回合的最大收益情况下，背叛是必然的。但如果双方会在将来相遇多次，那么就要考虑后续的收益，情况将变得复杂，我们将这称为“重复囚徒困境”。\n为了探讨“重复囚徒困境”下的最优策略，作者组织了重复囚徒困境游戏的计算机竞赛，并邀请众多博弈论专家参与。\n“一报还一报” 在计算机竞赛中，参加者提交一个能给出每一回合策略（合作或者背叛）的程序，该程序在作策略选择时可以利用对局的历史。竞赛是循环的，即每个程序都会与其他程序相遇，每轮比赛有 200 次对局，以此来模拟出“重复”。\n竞赛的结果有点出乎意料，胜者是“一报还一报”策略，它是所有提交程序中最简单的策略，结果却是最好的。\n“一报还一报”的策略是，最开始选择合作，随后按对方上一回合的选择去做，如果对方出现背叛，那么“一报还一报”也会在下一回合背叛来进行“报复”，但只会报复一个回合。下图举例了“一报还一报”与其他策略的 6 个回合的对局情况。\n从上述例子来看，“一报还一报”策略在每一个对局之中都不是得分领先的一方，甚至在遇到全背叛策略 A 时 6 个回合的得分仅仅 3 分。但在单回合不占优的情况下，“一报还一报”依然在竞赛中却能获得胜利。\n 值得注意的是，在所有竞赛参与者中，善良策略（不首先背叛）的平均得分要比非善良策略（首先背叛）的平均得分高 25%！前 8 名的策略都是善良的。而在善良策略中，得分最低的是最少宽容性的规则，一个采用永久报复的完全不宽容的规则，。\n “一报还一报”策略成功的原因是它综合了善良性、报复性、宽容性和清晰性。善良性防止它陷入不必要的麻烦；报复性使对方试着背叛一次后就不敢再背叛；宽容性有助于重新恢复合作；清晰性使它容易被对方理解，从而引出长期的合作。\n合作 or 背叛，你怎么选？ 那么，在人与人相处的时候，我们应该怎么做呢？作者给出了 4 点建议:\n 不要嫉妒 不要首先背叛 对合作与背叛都要以回报 不要耍小聪明  不要嫉妒 人们总是习惯考虑零和博弈，这种情况下，一个人赢，另一个就输，比如下棋。但在现实生活中，大多数情况属于非零和博弈，也即可以双赢，也能双输。这种情况下，合作是可能的，但并不总能实现。\n人们倾向于采用相对的标准，经常把对方的成功与自己的成功联系起来，从而导致了嫉妒。当嫉妒出现时，人会企图抵消对方已经得到的优势，通常通过背叛来实现。但背叛往往会导致更多的背叛，进而是双输的局面。因此嫉妒无疑是自我毁灭。\n 要求自己比对方做得好不是一个很好的标准，除非你的目的是消灭对方。\n 更好的做法是，把你所做的，与处在相同情况下的其他人所做的，进行比较。对于相同的策略，你是否已经做得最好了？其他人在这种情况下能做得更好吗？\n因此，只要你自己能做得更好，就让对方做得和你一样或略好些，就像“一报还一报”在每个对局中并不是优胜的一方，但并不妨碍它能赢得竞赛的胜利。\n我们没有理由去嫉妒他人的成功，因为在长时间的“重复囚徒困境”中，他人的成功是你自己成功的前提！\n不要首先背叛 竞赛和理论分析的结果都表明，在非零和博弈中，善良策略是表现更好的一方，善良性能够避免不必要的冲。非善良策略在开头还显得挺有希望，但是时间一长它就摧毁了它自己赖以成功的基础。\n对合作与背叛都要给予回报 竞赛中，“一报还一报”的成功给我们一个简单且有力的建议：要回报。\n对善良者报以合作，对非善良者报以背叛。\n值得注意的是，在报以背叛时，我们要保持惩罚与宽恕的平衡。“一报还一报”总是在对方每次背叛之后只背叛一次，而不是永久背叛，因此它成功了。\n但并不是说只背叛一次就是最优的，最优的宽恕水平与环境有关，如果周边绝大多数都是非善良的策略，那么，太多的宽恕反而会付出更多的代价。\n不要耍小聪明 在“囚徒困境”的情形中，人们容易耍小聪明，然而复杂的规则并不比简单的规则做得更好，赢得竞赛的反而是最简单、最易理解的“一报还一报”策略。\n原因主要在于，在零和博弈中（如下棋），对手任何的无效行为都会转换成你的收益，因此隐藏你的企图是很有用的，对手越是怀疑，策略越是没效；而在非零和博弈（如“重复囚徒困境”）中，你需要从对方的合作中得到好处，就必须鼓励合作，而对手越是清楚你愿意合作，合作就越容易促成。\n“一报还一报”在竞赛中得到巨大成功的原因之一，就是它具有很大的清晰性，非常容易被对方理解。\n最后 从前文可以得出这样的结论：在“重复囚徒困境”，也即非零和博弈中，合作是过更好的选择。\n 作者在书中举了一个现实的例子：在第一次世界大战堑壕战中，敌对的士兵经常表现出很大的克制，双方似乎都默契地执行着“自己活也让别人活”的策略。这种现象是堑壕战的特产。因为在堑壕战中，敌对双方都需要长时间对峙，符合“重复囚徒困境”的条件。而在其他类型战争，如突袭战、闪电战，则并不会出现类似的情况。\n 为了更好地促进合作，罗伯特·阿克塞尔罗德也提出了几个个建议：\n 增大未来的影响。如果未来相对于现在是足够重要的，或者双方有更高的频率在未来相遇的话，人们在做决策时就会更加考虑对未来的影响，从而更能促进合作。 改变收益值。比如增大对背叛的惩罚，当背叛的惩罚大到不管对方如何选择，从短期来看合作都是最好的策略时，就不会再有困境了。 教育人们相互关心和回报。促进合作的极好的方法依然是从教育入手，在孩童时代就教育人们要关心他人的利益、懂得回报，形成价值观。  读到这里，合作或者背叛应该怎么选，你清楚了吗？ 如果还没有，推荐大家玩一下《信任的进化》这款游戏，改编自《合作的进化》，通过图形化的表达让你更易理解书中的内容。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 合作的进化, 罗伯特·阿克塞尔罗德\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2023-12-28T00:00:00Z","permalink":"https://yrunz.com/p/%E5%90%88%E4%BD%9C-or-%E8%83%8C%E5%8F%9B%E4%BD%A0%E6%80%8E%E4%B9%88%E9%80%89/","title":"合作 or 背叛，你怎么选？"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：备忘录模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 适配器模式（Adapter）是最常用的结构型模式之一，在现实生活中，适配器模式也是处处可见，比如电源插头转换器，它可以让英式的插头工作在中式的插座上。\nGoF 对它的定义如下：\n Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces.\n 简单来说，就是适配器模式让原本因为接口不匹配而无法一起工作的两个类/结构体能够一起工作。\n适配器模式所做的就是将一个接口 Adaptee，通过适配器 Adapter 转换成 Client 所期望的另一个接口 Target 来使用，实现原理也很简单，就是 Adapter 通过实现 Target 接口，并在对应的方法中调用 Adaptee 的接口实现。\nUML 结构 场景上下文 在 简单的分布式应用系统（示例代码工程）中，db 模块用来存储服务注册信息和系统监控数据，它是一个 key-value 数据库。在 访问者模式 中，我们为它实现了 Table 的按列查询功能；同时，我们也为它实现了简单的 SQL 查询功能（将会在 解释器模式 中介绍），查询的结果是 SqlResult 结构体，它提供一个 toMap 方法将结果转换成 map 。\n为了方便用户使用，我们将实现在终端控制台上提供人机交互的能力，如下所示，用户输入 SQL 语句，后台返回查询结果：\n终端控制台的具体实现为 Console，为了提供可扩展的查询结果显示样式，我们设计了 ConsoleRender 接口，但因 SqlResult 并未实现该接口，所以 Console 无法直接渲染 SqlResult 的查询结果。\n为此，我们需要实现一个适配器，让 Console 能够通过适配器将 SqlResult 的查询结果渲染出来。示例中，我们设计了适配器 TableRender，它实现了 ConsoleRender 接口，并以表格的形式渲染出查询结果，如前文所示。\n代码实现 // demo/db/sql.go package db // Adaptee SQL语句执行返回的结果，并未实现Target接口 type SqlResult struct { fields []string vals []interface{} } func (s *SqlResult) Add(field string, record interface{}) { s.fields = append(s.fields, field) s.vals = append(s.vals, record) } func (s *SqlResult) ToMap() map[string]interface{} { results := make(map[string]interface{}) for i, f := range s.fields { results[f] = s.vals[i] } return results } // demo/db/console.go package db // Client 终端控制台 type Console struct { db Db } // Output 调用ConsoleRender完成对查询结果的渲染输出 func (c *Console) Output(render ConsoleRender) { fmt.Println(render.Render()) } // Target接口，控制台db查询结果渲染接口 type ConsoleRender interface { Render() string } // TableRender表格形式的查询结果渲染Adapter // 关键点1: 定义Adapter结构体/类 type TableRender struct { // 关键点2: 在Adapter中聚合Adaptee，这里是把SqlResult作为TableRender的成员变量  result *SqlResult } // 关键点3: 实现Target接口，这里是实现了ConsoleRender接口 func (t *TableRender) Render() string { // 关键点4: 在Target接口实现中，调用Adaptee的原有方法实现具体的业务逻辑  vals := t.result.ToMap() var header []string var data []string for key, val := range vals { header = append(header, key) data = append(data, fmt.Sprintf(\u0026#34;%v\u0026#34;, val)) } builder := \u0026amp;strings.Builder{} table := tablewriter.NewWriter(builder) table.SetHeader(header) table.Append(data) table.Render() return builder.String() } // 这里是另一个Adapter，实现了将error渲染的功能 type ErrorRender struct { err error } func (e *ErrorRender) Render() string { return e.err.Error() } 客户端这么使用：\nfunc (c *Console) Start() { fmt.Println(\u0026#34;welcome to Demo DB, enter exit to end!\u0026#34;) fmt.Println(\u0026#34;\u0026gt; please enter a sql expression:\u0026#34;) fmt.Print(\u0026#34;\u0026gt; \u0026#34;) scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { sql := scanner.Text() if sql == \u0026#34;exit\u0026#34; { break } result, err := c.db.ExecSql(sql) if err == nil { // 关键点5：在需要Target接口的地方，传入适配器Adapter实例，其中创建Adapter实例时需要传入Adaptee实例  c.Output(NewTableRender(result)) } else { c.Output(NewErrorRender(err)) } fmt.Println(\u0026#34;\u0026gt; please enter a sql expression:\u0026#34;) fmt.Print(\u0026#34;\u0026gt; \u0026#34;) } } 在已经有了 Target 接口（ConsoleRender）和 Adaptee（SqlResult）的前提下，总结实现适配器模式的几个关键点：\n 定义 Adapter 结构体/类，这里是 TableRender 结构体。 在 Adapter 中聚合 Adaptee，这里是把 SqlResult 作为 TableRender 的成员变量。 Adapter 实现 Target 接口，这里是 TableRender 实现了 ConsoleRender 接口。 在 Target 接口实现中，调用 Adaptee 的原有方法实现具体的业务逻辑，这里是在 TableRender.Render() 调用 SqlResult.ToMap() 方法，得到查询结果，然后再对结果进行渲染。 在 Client 需要 Target 接口的地方，传入适配器 Adapter 实例，其中创建 Adapter 实例时传入 Adaptee 实例。这里是在 NewTableRender() 创建 TableRender 实例时，传入 SqlResult 作为入参，随后将 TableRender 实例传入 Console.Output() 方法。  扩展 适配器模式在 Gin 中的运用 Gin 是一个高性能的 Web 框架，它的常见用法如下：\n// 用户自定义的请求处理函数，类型为gin.HandlerFunc func myGinHandler(c *gin.Context) { ... // 具体处理请求的逻辑 } func main() { // 创建默认的route引擎,类型为gin.Engine  r := gin.Default() // route定义  r.GET(\u0026#34;/my-route\u0026#34;, myGinHandler) // route引擎启动  r.Run() } 在实际运用场景中，可能存在这种情况。用户起初的 Web 框架使用了 Go 原生的 net/http，使用场景如下：\n// 用户自定义的请求处理函数，类型为http.Handler func myHttpHandler(w http.ResponseWriter, r *http.Request) { ... // 具体处理请求的逻辑 } func main() { // route定义  http.HandleFunc(\u0026#34;/my-route\u0026#34;, myHttpHandler) // route启动  http.ListenAndServe(\u0026#34;:8080\u0026#34;, nil) } 因性能问题，当前客户准备切换至 Gin 框架，显然，myHttpHandler 因接口不兼容，不能直接注册到 gin.Default() 上。为了方便用户，Gin 框架提供了一个适配器 gin.WrapH，可以将 http.Handler 类型转换成 gin.HandlerFunc 类型，它的定义如下：\n// WrapH is a helper function for wrapping http.Handler and returns a Gin middleware. func WrapH(h http.Handler) HandlerFunc { return func(c *Context) { h.ServeHTTP(c.Writer, c.Request) } } 使用方法如下：\n// 用户自定义的请求处理函数，类型为http.Handler func myHttpHandler(w http.ResponseWriter, r *http.Request) { ... // 具体处理请求的逻辑 } func main() { // 创建默认的route引擎  r := gin.Default() // route定义  r.GET(\u0026#34;/my-route\u0026#34;, gin.WrapH(myHttpHandler)) // route引擎启动  r.Run() } 在这个例子中，gin.Engine 就是 Client，gin.HandlerFunc 是 Target 接口，http.Handler 是 Adaptee，gin.WrapH 是 Adapter。这是一个 Go 风格的适配器模式实现，以更为简洁的 func 替代了 struct。\n典型应用场景  将一个接口 A 转换成用户希望的另外一个接口 B，这样就能使原来不兼容的接口 A 和接口 B 相互协作。 老系统的重构。在不改变原有接口的情况下，让老接口适配到新的接口。  优缺点 优点  能够使 Adaptee 和 Target 之间解耦。通过引入新的 Adapter 来适配 Target，Adaptee 无须修改，符合开闭原则。 灵活性好，能够很方便地通过不同的适配器来适配不同的接口。  缺点  增加代码复杂度。适配器模式需要新增适配器，如果滥用会导致系统的代码复杂度增大。  与其他模式的关联 适配器模式 和 装饰者模式、代理模式 在 UML 结构上具有一定的相似性。但适配器模式改变原有对象的接口，但不改变原有功能；而装饰者模式和代理模式则在不改变接口的情况下，增强原有对象的功能。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] Design Patterns, Chapter 4. Structural Patterns, GoF\n[3] 适配器模式, refactoringguru.cn\n[4] Gin Web Framework, Gin\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2023-12-10T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E9%80%82%E9%85%8D%E5%99%A8%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：适配器模式"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：命令模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 相对于代理模式、工厂模式等设计模式，备忘录模式（Memento）在我们日常开发中出镜率并不高，除了应用场景的限制之外，另一个原因，可能是备忘录模式 UML 结构的几个概念比较晦涩难懂，难以映射到代码实现中。比如 Originator（原发器）和 Caretaker（负责人），从字面上很难看出它们在模式中的职责。\n但从定义来看，备忘录模式又是简单易懂的，GoF 对备忘录模式的定义如下：\n Without violating encapsulation, capture and externalize an object’s internal state so that the object can be restored to this state later.\n 也即，在不破坏封装的前提下，捕获一个对象的内部状态，并在该对象之外进行保存，以便在未来将对象恢复到原先保存的状态。\n从定义上看，备忘录模式有几个关键点：封装、保存、恢复。\n对状态的封装，主要是为了未来状态修改或扩展时，不会引发霰弹式修改；保存和恢复则是备忘录模式的主要特点，能够对当前对象的状态进行保存，并能够在未来某一时刻恢复出来。\n现在，在回过头来看备忘录模式的 3 个角色就比较好理解了：\n Memento（备忘录）：是对状态的封装，可以是 struct ，也可以是 interface。 Originator（原发器）：备忘录的创建者，备忘录里存储的就是 Originator 的状态。 Caretaker（负责人）：负责对备忘录的保存和恢复，无须知道备忘录中的实现细节。  UML 结构 场景上下文 在前文 【Go实现】实践GoF的23种设计模式：命令模式 我们提到，在 简单的分布式应用系统（示例代码工程）中，db 模块用来存储服务注册信息和系统监控数据。其中，服务注册信息拆成了 profiles 和 regions 两个表，在服务发现的业务逻辑中，通常需要同时操作两个表，为了避免两个表数据不一致的问题，db 模块需要提供事务功能:\n事务的核心功能之一是，当其中某个语句执行失败时，之前已执行成功的语句能够回滚，前文我们已经介绍如何基于 命令模式 搭建事务框架，下面我们将重点介绍，如何基于备忘录模式实现失败回滚的功能。\n代码实现 // demo/db/transaction.go package db // Command 执行数据库操作的命令接口，同时也是备忘录接口 // 关键点1：定义Memento接口，其中Exec方法相当于UML图中的SetState方法，调用后会将状态保存至Db中 type Command interface { Exec() error // Exec 执行insert、update、delete命令  Undo() // Undo 回滚命令  setDb(db Db) // SetDb 设置关联的数据库 } // 关键点2：定义Originator，在本例子中，状态都是存储在Db对象中 type Db interface {...} // Transaction Db事务实现，事务接口的调用顺序为begin -\u0026gt; exec -\u0026gt; exec \u0026gt; ... -\u0026gt; commit // 关键点3：定义Caretaker，Transaction里实现了对语句的执行（Do）和回滚（Undo）操作 type Transaction struct { name string // 关键点4：在Caretaker（Transaction）中引用Originator（Db）对象，用于后续对其状态的保存和恢复  db Db // 注意，这里的cmds并非备忘录列表，真正的history在Commit方法中  cmds []Command } // Begin 开启一个事务 func (t *Transaction) Begin() { t.cmds = make([]Command, 0) } // Exec 在事务中执行命令，先缓存到cmds队列中，等commit时再执行 func (t *Transaction) Exec(cmd Command) error { if t.cmds == nil { return ErrTransactionNotBegin } cmd.setDb(t.db) t.cmds = append(t.cmds, cmd) return nil } // Commit 提交事务，执行队列中的命令，如果有命令失败，则回滚后返回错误 func (t *Transaction) Commit() error { // 关键点5：定义备忘录列表，用于保存某一时刻的系统状态  history := \u0026amp;cmdHistory{history: make([]Command, 0, len(t.cmds))} for _, cmd := range t.cmds { // 关键点6：执行Do方法  if err := cmd.Exec(); err != nil { // 关键点8：当Do方法执行失败时，则进行Undo操作，根据备忘录history中的状态进行回滚  history.rollback() return err } // 关键点7：如果Do方法执行成功，则将状态（cmd）保存在备忘录history中  history.add(cmd) } return nil } // cmdHistory 命令执行历史 type cmdHistory struct { history []Command } func (c *cmdHistory) add(cmd Command) { c.history = append(c.history, cmd) } func (c *cmdHistory) rollback() { for i := len(c.history) - 1; i \u0026gt;= 0; i-- { c.history[i].Undo() } } // InsertCmd 插入命令 // 关键点9: 定义具体的备忘录类，实现Memento接口 type InsertCmd struct { db Db tableName string primaryKey interface{} newRecord interface{} } func (i *InsertCmd) Exec() error { return i.db.Insert(i.tableName, i.primaryKey, i.newRecord) } func (i *InsertCmd) Undo() { i.db.Delete(i.tableName, i.primaryKey) } func (i *InsertCmd) setDb(db Db) { i.db = db } // UpdateCmd 更新命令 type UpdateCmd struct {...} // DeleteCmd 删除命令 type DeleteCmd struct {...} 客户端可以这么使用：\nfunc client() { transaction := db.CreateTransaction(\u0026#34;register\u0026#34; + profile.Id) transaction.Begin() rcmd := db.NewUpdateCmd(regionTable).WithPrimaryKey(profile.Region.Id).WithRecord(profile.Region) transaction.Exec(rcmd) pcmd := db.NewUpdateCmd(profileTable).WithPrimaryKey(profile.Id).WithRecord(profile.ToTableRecord()) transaction.Exec(pcmd) if err := transaction.Commit(); err != nil { return ... } return ... } 这里并没有完全按照标准的备忘录模式 UML 进行实现，但本质是一样的，总结起来有以下几个关键点：\n 定义抽象备忘录 Memento 接口，这里为 Command 接口。Command 的实现是具体的数据库执行操作，并且存有对应的回滚操作，比如 InsertCmd 为“插入”操作，其对应的回滚操作为“删除”，我们保存的状态就是“删除”这一回滚操作。 定义 Originator 结构体/接口，这里为 Db 接口。备忘录 Command 记录的就是它的状态。 定义 Caretaker 结构体/接口，这里为 Transaction 结构体。Transaction 采用了延迟执行的设计，当调用 Exec 方法时只会将命令缓存到 cmds 队列中，等到调用 Commit 方法时才会执行。 在 Caretaker 中引用 Originator 对象，用于后续对其状态的保存和恢复。这里为 Transaction 聚合了 Db。 在 Caretaker 中定义备忘录列表，用于保存某一时刻的系统状态。这里为在 Transaction.Commit 方法中定义了 cmdHistory 对象，保存一直执行成功的 Command。 执行 Caretaker 具体的业务逻辑，这里为在 Transaction.Commit 中调用 Command.Exec 方法，执行具体的数据库操作命令。 业务逻辑执行成功后，保存当前的状态。这里为调用 cmdHistory.add 方法将 Command 保存起来。 如果业务逻辑执行失败，则恢复到原来的状态。这里为调用cmdHistory.rollback 方法，反向执行已执行成功的 Command 的 Undo 方法进行状态恢复。 根据具体的业务需要，定义具体的备忘录，这里定义了InsertCmd 、UpdateCmd 和 DeleteCmd 。  扩展 MySQL 的 undo log 机制 MySQL 的 undo log（回滚日志）机制本质上用的就是备忘录模式的思想，前文中 Transaction 回滚机制实现的方法参考的就是 undo log 机制。\nundo log 原理是，在提交事务之前，会把该事务对应的回滚操作（状态）先保存到 undo log 中，然后再提交事务，当出错的时候 MySQL 就可以利用 undo log 来回滚事务，即恢复原先的记录值。\n比如，执行一条插入语句：\ninsertintoregion(id,name)values(1,\u0026#34;beijing\u0026#34;);那么，写入到 undo log 中对应的回滚语句为：\ndeletefromregionwhereid=1;当执行一条语句失败，需要回滚时，MySQL 就会从读取对应的回滚语句来执行，从而将数据恢复至事务提交之前的状态。undo log 是 MySQL 实现事务回滚和多版本控制（MVCC）的根基。\n典型应用场景  事务回滚。事务回滚的一种常见实现方法是 undo log，其本质上用的就是备忘录模式。 系统快照（Snapshot）。多版本控制的用法，保存某一时刻的系统状态快照，以便在将来能够恢复。 撤销功能。比如 Microsoft Offices 这类的文档编辑软件的撤销功能。  优缺点 优点  提供了一种状态恢复的机制，让系统能够方便地回到某个特定状态下。 实现了对状态的封装，能够在不破坏封装的前提下实现状态的保存和恢复。  缺点  资源消耗大。系统状态的保存意味着存储空间的消耗，本质上是空间换时间的策略。undo log 是一种折中方案，保存的状态并非某一时刻数据库的所有数据，而是一条反操作的 SQL 语句，存储空间大大减少。 并发安全。在多线程场景，实现备忘录模式时，要注意在保证状态的不变性，否则可能会有并发安全问题。  与其他模式的关联 在实现 Undo/Redo 操作时，你通常需要同时使用 备忘录模式 与 命令模式。\n另外，当你需要遍历备忘录对象中的成员时，通常会使用 迭代器模式，以防破坏对象的封装。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] 【Go实现】实践GoF的23种设计模式：命令模式, 元闰子\n[3] Design Patterns, Chapter 5. Behavioral Patterns, GoF\n[4] 备忘录模式, refactoringguru.cn\n[5] MySQL 8.0 Reference Manual :: 15.6.6 Undo Logs, MySQL\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2023-11-25T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E5%A4%87%E5%BF%98%E5%BD%95%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：备忘录模式"},{"content":" 上一篇：《纳瓦尔宝典》：如何积累财富？\n 你幸福吗？ “你幸福吗？”，看到这个问题，你的第一反应是什么？\n在孩童时候，我们常常会这样说，“我的梦想是\u0026hellip;，然后过上幸福的生活。”，但是，实现了梦想，就真的能获得幸福吗？有人的梦想是开公司做老板，但成为公司老板之后，可能会面临工作压力大、应酬多、陪伴家人时间少等问题，幸福感依然不强。\n所以，幸福到底是什么？我们要做些什么才能真正获得幸福？世俗意义上的成功与幸福是什么样的关系？\n对于这些问题，纳瓦尔有他的理解。\n什么是幸福？ 幸福很难有一个标准的定义，有人认为幸福是欲望得到满足、财富自由；也有人认为幸福是知足常乐、身体健康。\n在纳瓦尔看来，幸福是一种没有缺憾感、无欲无求、活在当下的状态。\n幸福并不是一种客观存在，而是一种主观感受。大自然并没有“幸福”和“不幸福”的概念，它只存在于我们大脑之中，因为我们有了欲望，才会给事物打上“好”和“不好”的标签。\n也因为有了欲望，人们才会深陷于过去的“错误”之中，总想着“要是\u0026hellip;，就好了”；才会对未来的“失败”深深恐惧，总想着“如果\u0026hellip;，那该怎么办”。这让我们长期处于焦虑的状态，从而无法真正感受当下，怎会幸福。\n而当你无欲无求时，自然也就不会悔不当初、不会谋求未来，此时此刻，你的内心会是一片宁静，是满足的，是快乐的，是幸福的。\n如何获得幸福 幸福，不是天生的，与基因无关，而是一种技能，能够通过后天习得的技能。\n就像可以通过锻炼来提高身体素质一样，我们也能通过刻意的练习来提高幸福感，可以尝试这么做：\n 降低自己的身份感。我们都只是浩瀚宇宙中的一粒尘埃，没那么重要，关注自己就好。 屏蔽脑海中的噪音和杂念、不在乎那些无关紧要的事。人的精力是有限的，想得越多，做得就越少，就越焦虑。 冥想。冥想是锻炼屏蔽杂念能力的一个好方法。 远离郁郁寡欢的人、和快乐知足的人相处。情绪是会传染的，想要获得幸福，就要经常跟幸福的人相处。 珍惜时间。  幸福，需要心境平和、活在当下。\n在任何时候，我们的大脑都只有很小一部分是关注当下的，大部分精力都在规划未来或悔恨过去。比如，在下班的路上，我们可能会想今天的工作怎么又没做好，也可能会想明天的汇报该怎么办，但很少会关注此时此刻走路时身体的状态，或感受周围的环境。\n这样的状态，我们很难获得绝妙的生活体验，无法欣赏身边的一切美好；如果每天都沉浸于过去或未来，那无疑是亲手扼杀自己的幸福。\n要想活在当下，必须要做到心境平和。但是，事情总是一件压着一件，永远有做不完的事，所以焦虑感才会普遍存在。那么，要怎样才能做到心境平和呢？\n纳瓦尔的方法是，不与焦虑对抗，接受这一现实，并不断问自己，“我是想一直执着于这些想法，还是想重获内心的平静？”，答案显而易见。于接受中寻找幸福，是我们必备的技能。\n幸福，源于好习惯。\n孩童时候的我们，幸福感都差不多。然而随着年龄的增长，有些人的幸福感越来越强，而有些人变得越来越不快乐，一个重要的原因就是，两类人群习惯不同。\n好的习惯可能会导致短暂的痛苦，但会带来长期的幸福。比如，少喝酒少吃糖、远离社交媒体，都能提高情绪稳定性；不好的习惯能带来短暂的快感，却会摧毁长期的幸福，比如沉迷电子游戏、抽烟酗酒。\n而幸福生活的本质，就是不断用精心培养的好习惯，去替换那些在不经意间养成的坏习惯。\n成功与幸福 很多人把成功和幸福划上等号，而纳瓦尔则认为两者存在一定的冲突。幸福就是满足现状；而成功源于对现状的不满，是对现在的改造。\n这里纳瓦尔所说的成功，指的是物质上的成功，对此，他是这样的解释的：再好的东西，很快就会习惯，它们无法持续带来刺激或愉快的感觉。人类的本性是贪婪的，普通人追求物质成功的脚步很难停下来，而追逐过程本就是对现状不满的过程。\n所以，不要过度执着于成功，欲望是主动选择的不开心，减少欲望，反而能使幸福感增加。\n最后 每个人对幸福的理解都各有不同；同一个人在不同年龄段，对幸福的理解也会有所差异。\n我们可以参考纳瓦尔的幸福观，并得到一些启发，但更重要的是，探索出此时此刻自己对幸福的定义。\n 参考 [1] 纳瓦尔宝典，埃里克·乔根森\n[2] 《纳瓦尔宝典》：如何积累财富？，元闰子\n[3] 《认知觉醒》的读后感，元闰子\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2023-11-19T00:00:00Z","permalink":"https://yrunz.com/p/%E7%BA%B3%E7%93%A6%E5%B0%94%E5%AE%9D%E5%85%B8%E5%A6%82%E4%BD%95%E8%8E%B7%E5%BE%97%E5%B9%B8%E7%A6%8F/","title":"《纳瓦尔宝典》：如何获得幸福？"},{"content":"关于《纳瓦尔宝典》 《纳瓦尔宝典》的主角，纳瓦尔·拉维康特，是一名成功的创业者和天使投资人，典型的硅谷大佬。本书收集整理了他的一些智慧箴言，它与记录查理·芒格智慧箴言的《穷查理宝典》有着相似的出发点，给大众传授大师的人生建议。\n从书的副标题“财富与幸福指南”能看出，它的主题是关于如何积累财富和获得幸福人生。《纳瓦尔宝典》并非教你如何投资，而更偏向个人成长。与它类似的，除了前面提到的《穷查理宝典》，还有《认知觉醒》、《财富自由之路》等，都是好书，都值得一读。\n本文主要总结《纳瓦尔宝典》中关于“积累财富”的一些人生建议。\n财富是什么？ 很多人会把“金钱”和“财富”划上等号，但这两者有着本质的区别。纳瓦尔对财富给出了通俗易懂的定义：\n 财富就是在你睡觉时也可以帮你赚钱的资产\n 比如可出租的房子、投入生产的工厂、持续工作的计算机程序等，都是常见的财富。\n而金钱只是转移财富的方式，是社会的信用符号，具有调用别人时间的能力。\n大多数人终其一生都在追求财富，我们努力工作、省吃俭用，却仍然没能做到财富自由。\n所以，获得财富跟努力程度没有必然的联系，与埋头苦干相比，更重要的是选择正确的方向。想要获得财富，你必须要知道做什么、怎么做、什么时候做。\n我要做什么？ 这世上，有很多获得财富的成功案例，但并非所有案例都适合你。盲目追求热点，并不会带来财富，关键是找到你的专长。\n 专长指的是无法通过培训获得的知识。如果社会可以培训你，那么社会也可以培训他人来取代你。\n 每个人的 DNA 里或多或少都会有一些比大多数人都擅长的东西，我们把它称为，专长和天赋。这是与生俱来的，其他人很难通过社会培训习得，或者需要付出巨大的代价。\n在“成为你自己”这件事上，没人能做得比你好，\n专长能带来差异化的竞争力，而你要做的，就是要持续地扩大你的优势，彻底一骑绝尘。\n识别自己的专长并不容易，这需要对自身有一个全面的了解，可以尝试这样：\n 从儿童时代开始回顾，找到那些别人要很努力，而你却毫无费劲就能完成的事。 从自己的兴趣中寻找，不是三分钟热度，而是你愿意为之付出一辈子的事。  我要怎么做？ 杠杆效应  给我一个支点，我可以翘起整个地球。 —— 阿基米德\n 当你找到自己的专长后，下一步就是，利用杠杆效应把专长规模化。一般来说，杠杆有 3 种：\n  劳动力杠杆，也即让别人给你打工。这是最古老的一种杠杆，但在现代社会中最低效。人员管理是一件极其复杂、极具挑战的工作，需要高超的领导技巧。\n  资本杠杆，也即利用今钱来扩大影响力。这是一种更现代的杠杆，虽然也需要一定的技巧，但管理资本要比管理人简单得多。但前提是，有钱。\n  复制边际成本为零的产品（下文统称新型杠杆），这是最新出现的一种杠杆，也是普通人最能触及的，并且借助互联网能够产生爆发式的增长，比如，书籍、媒体、代码。\n  使用劳动力杠杆需要有人追随你；使用资本杠杆需要资金；使用新型杠杆只需一台电脑或手机，而且无须经过他人的许可。\n其中，代码是最强大的一种杠杆，只需运行一台计算机就够了。并且，随着 SaaS 愈发成熟，普通人利用代码来规模化产品也变得愈发简单。\n复利效应  复利是世界第八大奇迹。知之者赚、不知之者被赚。—— 爱因斯坦\n 复利不仅仅适用于金融领域，在知识、经验、专长等方面，只要能积累的东西，都能产生复利效应。\n明确了杠杆后，下一步就是相信复利效应，坚持、坚持、再坚持。没有一夜暴富，成功需要时间，需要你对自己热爱的事物孜孜不倦，需要你的专长不断精进，需要你相信时间的力量。\n我要什么时候做？ 答案只有一个，现在！\n财富是人生的终极目标吗？ 财富不是人生的终极目标，幸福才是。财富只是通往幸福的一种工具，两者不能划等号，富豪的幸福感不见得一定比普通人的强。相比学习如何获得财富，其实，我们更应该学习如何获得幸福。\n下一篇，我们将总结纳瓦尔关于如何获得幸福的建议。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 纳瓦尔宝典，埃里克·乔根森\n[2] 如何超过大多数人, 酷壳 左耳朵耗子\n[3] 《认知觉醒》的读后感，元闰子\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2023-11-05T00:00:00Z","permalink":"https://yrunz.com/p/%E7%BA%B3%E7%93%A6%E5%B0%94%E5%AE%9D%E5%85%B8%E5%A6%82%E4%BD%95%E7%A7%AF%E7%B4%AF%E8%B4%A2%E5%AF%8C/","title":"《纳瓦尔宝典》：如何积累财富？"},{"content":"我想，2022 年的关键字，是“变”，工作、生活、心态都在变。人，总是趋于稳定；然而，变，才是生命中的主旋律。变化来临时，我们通常会经历阻力、挫折、迷茫，如何面对这些，将人分成了不同的样子。有人迷失自我、随波逐流；有人保持初心、逆风前行。\n薛定谔说，“人活着就是在对抗熵增定律，生命以负熵为生。”\n但是，在变化中保持不变的初心，很难。自问在过去的一年里·做到了吗？有点，但不多。2022 年也曾迷茫过，所幸每次都能调整回来，阅读、写作、生活，一直都在。\n阅读 2022 年读过的书不多，读完的，也才一半。\n最近一直在反思，人的精力是有限的，但书是无尽的；不同的书、同一本书的不同章节，质量也参差不齐。这种情况下，选择读什么、怎么读变得尤为重要。最直接地，读经典。但经典也很多、很厚，全部读完，也很难。\n所以，还是要回到根本问题，你为什么要读书？\n个人以为，如果想学习一门技术、弄清楚一个问题，那么可以选读相关章节；如果是想提升人文素养、获得人生感悟，那么还是通读为好。所以，我的阅读策略慢慢调整为，选读技术类书籍，通读人文类书籍。不管那种方式，读书过程中，思考是必不可少的。\n Computer Systems A Programmers Perspective (3rd edition)，中文名 深入理解计算机系统。CSAPP 不必多说，被誉为计算机必读经典，缺点是，难啃。今年因工作需要，专门把第二章精读了一遍，对数值类型有了更深刻的理解，还写了总结 深入理解计算机系统的数值类型。 Database System Concepts (7th edithion)，中文名 数据库系统概念。数据库领域的经典，值得通读，结合 CMU 的公开课更佳，目前只读到了第四章。 Clean Agile: Back to Basics，中文名 敏捷整洁之道。Martin 大叔继 代码整洁之道、架构整洁之道 之后的又一力作，介绍软件敏捷开发的流程。印象最深的一点是，项目管理是一个在 Good、Fast、Cheap、Done 之间 trade-off 的过程，四者只能满足其三，比如，想要在人力投入有限的情况下，快速完成需求交付，就必须牺牲软件的质量。 API Design Patterns，可以看成是 Google APIs 设计规范的详细版，对从事平台框架、微服务开发的同学很有帮助。 大数据处理框架 Apache Spark 设计与实现，对 Spark 的设计和实现原理都介绍得很清楚，做到了深入浅出，看完会对 Spark 有一个系统的认识。要是能够结合源码就更好了。 Spark SQL 内核剖析，读了前几章，算是 Spark 源码剖析类书籍里面讲的比较好的了，但对新手不友好，也可能是 Spark 太过博大精深了。 Trino: The Definitive Guide，可作为 Trino 的入门书籍，比较全，但很浅。Trino 是一个开源分布式 SQL 查询引擎，主打低时延和跨源查询，由 Facebook 开源的 Presto 演进而来。 Scala 编程（第三版），因工作需要在 Spark 上做开发，专门找来补 Scala 的知识，粗粗扫一遍。感受是，Scala 的语法糖真多。   棋王，阿城作品，包含了《棋王》、《树王》、《孩子王》三篇中篇小说，总结成一句话： 故事好、文字好。《棋王》最出名，关于“棋痴”王一生的故事，最后的九人车轮战，很有武侠风；《树王》最写实，两个“树王”，殊途同归；《孩子王》最童真，也是我最喜欢的一篇，探讨了教育问题，读完忍不住买了一本新华字典，想着每天学习几个字，最终没能坚持下来。 威尼斯日记、阿城文集之二，读完棋王，又专门找了两本阿城的作品来读。阿城的文字很有画面感，简洁、有力，而且读的过程中会不断惊叹于作者那渊博的知识。 芯片战争，因工作涉及，专门挑了一本关于芯片行业的通识书籍。读的时候会想起吴军的《浪潮之巅》，一本是互联网行业，一本是半导体行业，共同点是，可读性都很强。 极简欧洲史，把欧洲文明的基本要素总结为： 古希腊罗马文化、基督教教义以及日耳曼战士文化，以一个新颖的思路介绍了欧洲历史。印象最深的是，第一次发现，基督教里面的上帝和耶稣竟不是一个人。 人类群星闪耀时，描写了 14 个影响人类文明的历史瞬间，带有较多作者的主观感受。最喜欢的《决定世界的一分钟》，讲述拿破仑在滑铁卢战役上的惨败，记得还出现在初中课本上。 打开：周濂的 100 堂西方哲学课，介绍了从古希腊到近现代的西方哲学发展史，作为哲学入门读物很不错。今年看了不少哲学相关的知识，深感哲学对思维的重要性，它会让你养成思考的习惯。 刘擎西方现代思想讲义，2 年前买的书，没读完；阳的期间重新拿了起来，在发烧的深夜，读了 2 章，竟然神奇的退烧了。  写作 2022 年还算高产，写过的文章，比前两年加起来还多，类别也丰富起来了，技术类、思考类、记事类，都有。但也有很多计划写的，因为太懒、太拖，没完成。表达上，也有所转变，以前会想着，能不能再加点描述，再多点字；现在则是，能不能简洁点，再少点字。\n对我来说，写作是一件非常费时的事情，写一篇，往往需要一天时间，甚至更多。也一直在反思个中原因，结论是，不够专注。所以，想要高效地写好文章，还得费神，容不得半点分心。写作虽然费时、费神，但着实能提升表达和抽象能力。当你能够将一个复杂知识，用通俗易懂的方式表达出来时，才算是真正学会了。\n 实践GoF的23种设计模式：Go 实现，本来计划 2022 年就完成的系列写作，最终也只完成了一半（12 篇），希望能在 2023 年能完结。 假如让你来设计 SSL/TLS 协议，仿照《Operating Systems: Three Easy Pieces》的写作思路，从对称加密讲起，一步步推导出 SSL/TLS 的基本原理，个人认为是 2022 年最通俗易懂的一篇技术文章。 从分层架构到微服务架构（五）之服务化架构，时隔一年后，再次下笔写下了本系列的第 5 篇。从分层架构到微服务架构 系列是对《Fundamentals of Software Architecture》中提到的 8 中架构模式的总结，也希望能在 2023 年能完结。 用 Keynote 画出手绘风格的配图，一直很多小伙伴问我文章的配图是怎么画的，于是写了这篇。配图，是技术文章的重要组成部分，好的配图，能够极大提升文章的易读性，减轻读者负担。 深入理解 SQL 中的 Grouping Sets 语句，新项目组的第一个需求，跟 GROUPING SETS 语句相关。借此机会，以 Spark SQL 作为切入点，把其中技术原理弄清楚了。 深入理解计算机系统的数值类型，新项目组的第二个需求，与数值类型相关。为此，专门翻看了 CSAPP 和 IEEE 标准，把整型、浮点型的表示、转换规则都弄清楚了。 从哲学的角度看中医，年初时在喜马拉雅上听王德峰的 中西思想必修课，听到有关中医的哲学思辨时，深有感触，于是结合网上的一些资料和自身的经历，写了一篇胡说八道的文章。 懒惰，偶然看到一句话 “生活中往往没有标准答案，当你要求别人给你一个标准答案时，就是思想上的懒惰了”，于是开始反思自己在思想上的懒惰，具体表现就是，读的多，想的少。 让人生多些可能，2022 年做了一个重要的决定，换了部门。从应用服务开发，到大数据性能优化；从技术，到管理。新的技术栈、新的挑战，为的是，让人生多些可能。 属于篮球的快乐又回来了，换了部门，没那么忙了，打球的次数变多了。遇到了新的伙伴们，篮球的快乐好像又找回来了，有感而发，回顾了自己的篮球生涯。 送行，9 月份回老家参加朋友婚礼，此时正好在读阿城的作品，写作热情高昂，所以首次尝试用文字记录生活。生活中有很多感人的细节，希望以后能够好好观察生活，用文字记录感动。 二阶思维，偶然看到一篇文章《Second-Order Thinking: What Smart People Use to Outperform》，恰好自己也在有意练习思考，深感二阶思维带来的好处。  生活 2022 年，疫情改变了很多东西，还好，随着政策的放开，很多东西也慢慢回归正常。\n篮球 2022 年很幸运能遇到了一群一起打篮球的伙伴。\n读书时就爱打球，工作之后，太忙、也找不到伙伴，打得少。下半年换了部门，遇到了新伙伴，没那么忙了，每周能打几次；打得多，认识的伙伴也多起来；打完球、吃宵夜、聊会天，生活气息就有了。\n吃                                               家里就厨房装备最齐全，厨艺不行，热情来凑。周末闲在家中，尝试了不少菜和汤，甚至还烧起烤来。\n奇怪的是，很多小时候讨厌的佐料，如今变得喜爱。比如，沙姜，它有股很特别的甜香味，以前无法入口，现在倒是喜欢用来爆炒和作蘸料。生活中，事情很难一成不变，讨厌的会变成喜欢的、喜欢的也会变成讨厌的，唯有变化本身是不变的。\n吉他                         大一的时候，就开始学吉他，过去十年，弹得少且浅，复杂一点曲子都没毅力练下去。\n机缘巧合，9 月份买了新吉他，告别陪伴了十年的老吉他，弹琴的兴致又起来了。可能是工作多年带来的心态变化，对复杂的曲子不再感到恐惧，多练就行。\n除了指弹之外，最意想不到的是，还在视频号中录起了弹唱；对于只唱 K 过一次、在大学宿舍只弹琴不开口的自己，可以说是破天荒了。一切都在变化之中。\n最后，愿自己能在 2023 年的变化洪流中，依旧初心不变。\n","date":"2023-01-23T00:00:00Z","permalink":"https://yrunz.com/p/2022-%E5%B9%B4%E5%B0%8F%E7%BB%93/","title":"2022 年小结"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：代理模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 现在的软件系统往往是分层设计。在业务层执行一次请求时，我们很清楚请求的上下文，包括，请求是做什么的、参数有哪些、请求的接收者是谁、返回值是怎样的。相反，基础设施层并不需要完全清楚业务上下文，它只需知道请求的接收者是谁即可，否则就耦合过深了。\n因此，我们需要对请求进行抽象，将上下文信息封装到请求对象里，这其实就是命令模式，而该请求对象就是 Command。\nGoF 对命令模式（Command Pattern）的定义如下：\n Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.\n 也即，命令模式可将请求转换为一个包含与请求相关的所有信息的对象， 它能将请求参数化、延迟执行、实现 Undo / Redo 操作等。\n上述的请求是广义上的概念，可以是网络请求，也可以是函数调用，更通用地，指一个动作。\n命令模式主要包含 3 种角色：\n Command，命令，是对请求的抽象。具体的命令实现时，通常会引用 Receiver。 Invoker，请求的发起发起方，它并不清楚 Command 和 Receiver 的实现细节，只管调用命令的接口。 Receiver，请求的接收方。  命令模式，一方面，能够使得 Invoker 与 Receiver 消除彼此之间的耦合，让对象之间的调用关系更加灵活；另一方面，能够很方便地实现延迟执行、Undo、Redo 等操作，因此被广泛应用在软件设计中。\nUML 结构 场景上下文 在 简单的分布式应用系统（示例代码工程）中，db 模块用来存储服务注册信息和系统监控数据。其中，服务注册信息拆成了 profiles 和 regions 两个表，在服务发现的业务逻辑中，通常需要同时操作两个表，为了避免两个表数据不一致的问题，db 模块需要提供事务功能:\n事务的核心功能之一是，当其中某个语句执行失败时，之前已执行成功的语句能够回滚，而使用命令模式能够很方便地实现该功能。\n代码实现 // demo/db/transaction.go package db // Command 执行数据库操作的命令接口 // 关键点1: 定义命令抽象接口 type Command interface { // 关键点2: 命令抽象接口中声明执行命令的方法  Exec() error // Exec 执行insert、update、delete命令  // 关键点3: 如果有撤销功能，则需要定义Undo方法  Undo() // Undo 回滚命令  setDb(db Db) // SetDb 设置关联的数据库 } // Transaction Db事务实现，事务接口的调用顺序为begin -\u0026gt; exec -\u0026gt; exec \u0026gt; ... -\u0026gt; commit // 关键点4: 定义Invoker对象 type Transaction struct { name string db Db // 关键点5: Invoker对象持有Command的引用  cmds []Command } // Begin 开启一个事务 func (t *Transaction) Begin() { t.cmds = make([]Command, 0) } // Exec 在事务中执行命令，先缓存到cmds队列中，等commit时再执行 func (t *Transaction) Exec(cmd Command) error { if t.cmds == nil { return ErrTransactionNotBegin } cmd.setDb(t.db) t.cmds = append(t.cmds, cmd) return nil } // Commit 提交事务，执行队列中的命令，如果有命令失败，则回滚后返回错误 // 关键点6: 为Invoker对象定义Call方法，在方法内调用Command的执行方法Exec func (t *Transaction) Commit() error { history := \u0026amp;cmdHistory{history: make([]Command, 0, len(t.cmds))} for _, cmd := range t.cmds { if err := cmd.Exec(); err != nil { history.rollback() return err } history.add(cmd) } return nil } // cmdHistory 命令执行历史 type cmdHistory struct { history []Command } func (c *cmdHistory) add(cmd Command) { c.history = append(c.history, cmd) } // 关键点7: 在回滚方法中，调用已执行命令的Undo方法 func (c *cmdHistory) rollback() { for i := len(c.history) - 1; i \u0026gt;= 0; i-- { c.history[i].Undo() } } // InsertCmd 插入命令 // 关键点8: 定义具体的命令类，实现Command接口 type InsertCmd struct { // 关键点9: 命令通常持有接收者的引用，以便在执行方法中与接收者交互  db Db tableName string primaryKey interface{} newRecord interface{} } // 关键点10: 命令对象执行方法中，调用Receiver的Action方法，这里的Receiver为db对象，Action方法为Insert方法 func (i *InsertCmd) Exec() error { return i.db.Insert(i.tableName, i.primaryKey, i.newRecord) } func (i *InsertCmd) Undo() { i.db.Delete(i.tableName, i.primaryKey) } func (i *InsertCmd) setDb(db Db) { i.db = db } // UpdateCmd 更新命令 type UpdateCmd struct {...} // DeleteCmd 删除命令 type DeleteCmd struct {...} 客户端可以这么使用：\nfunc client() { transaction := db.CreateTransaction(\u0026#34;register\u0026#34; + profile.Id) transaction.Begin() rcmd := db.NewUpdateCmd(regionTable).WithPrimaryKey(profile.Region.Id).WithRecord(profile.Region) transaction.Exec(rcmd) pcmd := db.NewUpdateCmd(profileTable).WithPrimaryKey(profile.Id).WithRecord(profile.ToTableRecord()) transaction.Exec(pcmd) if err := transaction.Commit(); err != nil { return ... } return ... } 总结实现命令模式的几个关键点：\n 定义命令抽象接口，本例子中为 Command 接口。 在命令抽象接口中声明执行命令的方法，本例子中为 Exec 方法。 如果要实现撤销功能，还需要为命令对象定义 Undo 方法，在操作回滚时调用。 定义 Invoker 对象，本例子中为 Transaction 对象。 在 Invoker 对象持有 Command 的引用，本例子为 Command 的切片 cmds。 为 Invoker 对象定义 Call 方法，用于执行具体的命令，在方法内调用 Command 的执行方法 ，本例子中为 Transaction.Commit 方法。 如果要实现撤销功能，还要在回滚方法中，调用已执行命令的 Undo 方法，本例子中为 cmdHistory.rollback 方法。 定义具体的命令类，实现 Command 接口，本例子中为 InsertCmd、UpdateCmd、DeleteCmd。 命令通常持有接收者的引用，以便在执行方法中与接收者交互。本例子中，Receiver 为 Db 对象。 最后，在命令对象执行方法中，调用 Receiver 的 Action 方法，本例子中， Receiver 的 Action 方法为 db.Insert 方法。  值得注意的是，本例子中 Transaction 对象在 Transaction.Exec 方法中只是将 Command 保存在队列中，只有当调用 Transaction.Commit 方法时才延迟执行相应的命令。\n扩展 os/exec 中的命令模式 Go 标准库的 os/exec 包也用到了命令模式。\npackage main import ( \u0026#34;os/exec\u0026#34; ) // 对应命令模式中的Invoker func main() { cmd := exec.Command(\u0026#34;sleep\u0026#34;, \u0026#34;1\u0026#34;) err := cmd.Run() } 在上述例子中，我们通过 exec.Command 方法将一个 shell 命令转换成一个命令对象 exec.Cmd，其中的 Cmd.Run() 方法即是命令执行方法；而 main() 函数，对应到命令模式中的 Invoker；Receiver 则是操作系统执行 shell 命令的具体进程，从 exec.Cmd 的源码中可以看到：\n// src/os/exec/exec.go package exec // 对应命令模式中的Command type Cmd struct { ... // 对应命令模式中的Receiver  Process *os.Process ... } // 对应命令模式中Command的执行方法 func (c *Cmd) Run() error { if err := c.Start(); err != nil { return err } return c.Wait() } func (c *Cmd) Start() error { ... // Command与Receiver的交互  c.Process, err = os.StartProcess(c.Path, c.argv(), \u0026amp;os.ProcAttr{...}) ... } CQRS 架构 CQRS 架构，全称为 Command Query Responsibility Segregation，命令查询职责隔离架构。CQRS 架构是微服务架构模式中的一种，它利用事件（命令）来维护从多个服务复制数据的只读视图，通过读写分离思想，提升微服务架构下查询的性能。\nCQRS 架构可分为 命令端 和 查询端，其中命令端负责数据的更新；查询端负责数据的查询。命令端的写数据库在数据更新时，会向查询端的只读数据库发送一个同步数据的事件，保证数据的最终一致性。\n其中的命令端，就使用到了命令模式的思想，将数据更新请求封装成命令，异步更新到写数据库中。\n典型应用场景  事务模式。事务模式下往往需要 Undo 操作，使用命令模式实现起来很方便。 远程执行。Go 标准库下的 exec.Cmd、http.Client 都属于该类型，将请求封装成命令来执行。 CQRS 架构。微服务架构模式中的一种，通过命令模式来实现数据的异步更新。 延迟执行。当你希望一个操作能够延迟执行时，通常会将它封装成命令，然后放到一个队列中。  优缺点 优点  符合单一职责原则。在命令模式下，每个命令都是职责单一、松耦合的；当然也可以通过组合的方式，将多个简单的命令组合成一个负责的命令。 可以很方便地实现操作的延迟执行、回滚、重做等。 在分布式架构下，命令模式能够方便地实现异步的数据更新、方法调用等，提升性能。  缺点  命令模式下，调用往往是异步的，而异步会导致系统变得复杂，问题出现时不好定位解决。 随着业务越来越复杂，命令对象也会增多，代码会变得更难维护。  与其他模式的关联 在实现 Undo/Redo 操作时，你通常需要同时使用 命令模式 和 备忘录模式。\n另外，命令模式 也常常和 观察者模式 一起出现，比如在 CQRS 架构中，当命令端更新数据库后，写数据库就会通过事件将数据同步到读数据库上，这里就用到了 观察者模式。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] 【Go实现】实践GoF的23种设计模式：观察者模式, 元闰子\n[3] Design Patterns, Chapter 5. Behavioral Patterns, GoF\n[4] 命令模式, refactoringguru.cn\n[5] The command pattern in Go, rolandjitsu\n[6] CQRS 模式, microsoft azure\n[7] CQRS Design Pattern in Microservices Architectures, Mehmet Ozkaya\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-12-22T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E5%91%BD%E4%BB%A4%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：命令模式"},{"content":"事情往往不是你想象的那样，有时候，看似解决了问题，却在不经意间，引发了更严重的后果。帮助我们思考、决策、解决问题的最有效方法是，运用二阶思维。\n什么是二阶思维？  一阶思维是单纯而肤浅的，几乎人人都能做到；二阶思维则是深邃、复杂而迂回的，能做到的人少之又少。\n—— 霍华德·马克斯\n 我们的每一个行动都会导致一个后果，而每个后果，都会有进一步导致其他的后果。由行动直接导致的后果，我们称为一阶后果（First-Order Consequences）；由一阶后果导致的，二阶、三阶\u0026hellip; 后果，这里，我们统称为二阶后果（Second-Order Consequences）。\n好的一阶后果，不见得会有好的二阶后果，很多时候，它们是反的。\n二阶思维（Second-Order Thinking），简单来说，就是做事情不能只看一阶后果，还要考虑二阶后果。相对于一阶思维，它更强调对问题的深入思考，从逻辑、系统、因果、时间等多种维度来综合考虑。\n霍华德·马克斯在《投资最重要的事》中有举到一个股票投资的例子：\n 一阶思维的人，会这么想：“公司的前景是光明的，这表示股票会上涨”。\n二阶思维的人，则会考虑到：\n 未来可能出现的结果会在什么范围之内？ 我认为会出现什么样的结果？ 我正确的概率有多大？ 人们的共识是什么？ 我的预期与人们的共识有多大差异？ 资产的当前价格与大众所认为的未来价格以及我所认为的未来价格相符的程度如何？ 价格中所反映的共识心理是过于乐观，还是过于悲观？ 如果大众的看法是正确的，那么资产价格将会发生怎样的改变？如果我是正确的，那么资产价格又会怎样？   显然，二阶思维与一阶思维之间有着巨大的工作量差异，二阶思维对人的要求更高，实践起来也更复杂。\n二阶思维有什么用？  没有考虑二阶或者三阶后果，是造成众多痛苦而糟糕的决策的重要原因之一。\n—— 雷伊·达里奥\n 善用二阶思维，能帮助我们更好地决策、更好地找到问题根本从而解决问题。\n更好地决策 以软件开发中需求管理为例。我们总说以客户为中心，那么，一阶思维者的做法，很有可能是，将客户/产品经理所提的每个需求都纳入到版本中。这种不假思索的做法，看似满足了客户的所有诉求，实际危害更大。它忽略了最重要的一点，开发的人力是有限的，从而很容易导致版本无法按时交付。\n更好的方法是，运用二阶思维来进行需求的管理。\n我们可以从多个维度来决策一个需求是否应该被纳入版本，比如，该需求能给客户带来多大的收益？在哪些场景下才有收益？没有它系统能不能正常运行？需求的工作量有多大？当前开发人力能不能满足？\n这样，我们就能大致估算出每个需求的价值，然后对需求做价值的优先排序，最后根据当前的开发人力做需求裁剪。确保在交付时间点到时，我们能够为客户提供一个可用的、价值最大的软件系统。\n更好地找到问题根源 在决策中，我们用的是正向的二阶思维，也即，从眼前一步步往后推演出未来的各种可能性。\n而在找问题根源时，我们用的是逆向的二阶思维，也即，从眼前要解决的问题开始，分析产生这个问题的原因，然后不断扩展、推演，一直找到问题根源。\n比如，在《深入理解计算机系统的数值类型》中，有一个 double 转型为 float 的例子：\n// Java public static void main(String[] args) { double d1 = 3.267393471324506; System.out.print(\u0026#34;double d1: \u0026#34;); System.out.println(d1); System.out.print(\u0026#34;float d1: \u0026#34;); System.out.println((float) d1); } // 输出结果 double d1: 3.267393471324506 float d1: 3.2673936 从结果来看，转型的规则并不是简单的四舍五入。如果是一阶思维者，很容易会这样想，浮点数的转换应该存在精度丢失，然后就结束了。\n如果是二阶思维者，你一定会有这样的疑问，为什么会得到这样的转换结果？\n那么，接下来，你很可能就会这样干：\n 查阅 《Java 语言规范》，发现 double 到 float 的近似规则是 Round-to-even，但 3.267393471324506 到 3.2673936 也不符合这个规则。然后想，有没可能近似规则是用在二进制的表示上？ 继续查阅 《Java 语言规范》，发现计算机的浮点数实现都遵循着《IEEE Standard 754 Floating-Point Representation》 规范。 接着，从 《IEEE Standard 754 Floating-Point Representation》找到了 double 和 float 在二进制表示上差异，并发现 double 转型 float 时会出现截断。 对比 3.267393471324506 到 3.2673936 的二进制表示，发现截断后，再通过 Round-to-even 来近似，就能得出正确的结果。  看，经过这样的层层追溯，我们最终找到了问题的答案！\n怎样锻炼二阶思维？  经济领域中，最关键的是不管别人对你说什么，你总要问：“然后呢？”。 这个方法可以应用于几乎其他所有领域。所以，你必须经常问：“然后呢？”\n—— 沃伦.巴菲特\n 二阶思维并不是与生俱来的，它更像是一种习惯，需要我们不断地实践、总结、养成。\n总要问：然后呢？ 当你决定做一件事情前，总要问自己：“然后呢”？\n这时，可以拿出你的笔，在纸上列出一阶后果、二阶后果、三阶后果、\u0026hellip;，把所能想到的可能性都显现地列举出来，以帮助我们更好地决策。\n比如，作为架构师的你，想在业务服务和数据库之间加上一层缓存，来优化数据读性能。那么，在系统设计时，你不能仅仅看到这一点，而应该运用二阶思维，尽可能地，把增加缓存之后可能出现的现象/结果，都列出来，分析一遍：\n凡事从时间维度多加考虑 在做决策前，在时间维度上多加考虑，如果做了这件事，10 小时之后会怎样？10 天之后会怎样？10 周之后会怎样？10 个月之后会怎样？10 年之后会怎样？\n比如，在《一步步降低软件复杂性》提到的 战术编程 与 战略编程 的例子，我们总是偏向战术编程，因为它能够节省大量开发时间，更快地完成需求交付。但是，当你从时间的维度来考虑时，结果就会有所不同：\n另一个典型的例子是，背单词。我们总认为每天背 10 个单词好像没有多大用处，要是真能坚持下来，1 年就能认识 3650 个单词，2 年就是 7300 个单词，10 年后是 3 万多个。所以，不要低估时间的力量。\n多问几个：为什么？ 接触一个知识，碰到一个问题，多问几个：“为什么”？\n要养成问 “为什么” 的习惯，通过质疑，不断找到现象或问题的根本所在。\n对于一个知识，如果没有经过深度思考，只能算暂时记住，并不能纳入到你的知识体系中。\n比如，对于 SSL/TLS 协议建立连接的过程，如果只是流于表面地把它背下来，可能你会因此通过面试，却无法深入理解其背后所涉及的密码学、数字证书、网络通信等原理知识（详见《假如让你来设计SSL/TLS协议》）。\n对于一个问题，如果没有定位到根因，临时的规避做法，往往会导致更严重的后果。\n比如，在一个分布式系统中，当出现服务请求超时现象时，一阶思维者的做法，很有可能是，通过增加请求超时时长来规避问题。然而，出现请求超时的原因有很多，如果是下游服务处理不过来导致的，增加超时时长只会让问题愈发恶劣，更好的做法是增加流控机制。\n所以，多问几个“为什么”，找到根源，才能更好地解决问题。\n最后 相比一阶思维，二阶思维能够让我们更好地做出决策、找到问题的根源。但这需要更深入的思考，耗费的时间和精力也会更多。\n这与人类的天然惰性是相违背的，就像在《懒惰》里提到的，“读书很容易，但思考很难”。\n好消息是，二阶思维是一种习惯，能够通过不断地练习来养成。本文列出了 3 个比较容易实践的锻炼方法：\n 做一件事前，总是问：“然后呢？”； 凡事从时间维度多加考虑； 遇到知识/问题，多问几个：“为什么？”。  养成二阶思维的习惯是一个漫长、痛苦的过程，但坚持下来，总会收获很大。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] Second-Order Thinking: What Smart People Use to Outperform, Mental Models\n[2] 二阶思维Second-Order Thinking——让你脱颖而出的思维方式, 芒格学院\n[3] 投资最重要的事情, 霍华德·马克斯\n[4] 深入理解计算机系统的数值类型, 元闰子\n[5] 懒惰, 元闰子\n[6] 假如让你来设计SSL/TLS协议, 元闰子\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-11-20T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BA%8C%E9%98%B6%E6%80%9D%E7%BB%B4/","title":"二阶思维"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：访问者模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 GoF 对代理模式（Proxy Pattern）的定义如下：\n Provide a surrogate or placeholder for another object to control access to it.\n 也即，代理模式为一个对象提供一种代理以控制对该对象的访问。\n它是一个使用率非常高的设计模式，在现实生活中，也是很常见。比如，演唱会门票黄牛。假设你需要看一场演唱会，但官网上门票已经售罄，于是就当天到现场通过黄牛高价买了一张。在这个例子中，黄牛就相当于演唱会门票的代理，在正式渠道无法购买门票的情况下，你通过代理完成了该目标。\n从演唱会门票的例子我们也能看出，使用代理模式的关键在于，当 Client 不方便直接访问一个对象时，提供一个代理对象控制该对象的访问。Client 实际上访问的是代理对象，代理对象会将 Client 的请求转给本体对象去处理。\nUML 结构 场景上下文 在 简单的分布式应用系统（示例代码工程）中，db 模块用来存储服务注册和监控信息，它是一个 key-value 数据库。为了提升访问数据库的性能，我们决定为它新增一层缓存：\n另外，我们希望客户端在使用数据库时，并不感知缓存的存在，这些，代理模式可以做到。\n代码实现 // demo/db/cache.go package db // 关键点1: 定义代理对象，实现被代理对象的接口 type CacheProxy struct { // 关键点2: 组合被代理对象，这里应该是抽象接口，提升可扩展性  db Db cache sync.Map // key为tableName，value为sync.Map[key: primaryId, value: interface{}]  hit int miss int } // 关键点3: 在具体接口实现上，嵌入代理本身的逻辑 func (c *CacheProxy) Query(tableName string, primaryKey interface{}, result interface{}) error { cache, ok := c.cache.Load(tableName) if ok { if record, ok := cache.(*sync.Map).Load(primaryKey); ok { c.hit++ result = record return nil } } c.miss++ if err := c.db.Query(tableName, primaryKey, result); err != nil { return err } cache.(*sync.Map).Store(primaryKey, result) return nil } func (c *CacheProxy) Insert(tableName string, primaryKey interface{}, record interface{}) error { if err := c.db.Insert(tableName, primaryKey, record); err != nil { return err } cache, ok := c.cache.Load(tableName) if !ok { return nil } cache.(*sync.Map).Store(primaryKey, record) return nil } ... // 关键点4: 代理也可以有自己特有方法，提供一些辅助的功能 func (c *CacheProxy) Hit() int { return c.hit } func (c *CacheProxy) Miss() int { return c.miss } ... 客户端这样使用：\n// 客户端只看到抽象的Db接口 func client(db Db) { table := NewTable(\u0026#34;region\u0026#34;). WithType(reflect.TypeOf(new(testRegion))). WithTableIteratorFactory(NewRandomTableIteratorFactory()) db.CreateTable(table) table.Insert(1, \u0026amp;testRegion{Id: 1, Name: \u0026#34;region\u0026#34;}) result := new(testRegion) db.Query(\u0026#34;region\u0026#34;, 1, result) } func main() { // 关键点5: 在初始化阶段，完成缓存的实例化，并依赖注入到客户端  cache := NewCacheProxy(\u0026amp;memoryDb{tables: sync.Map{}}) client(cache) } 本例子中，Subject 是 Db 接口，Proxy 是 CacheProxy 对象，SubjectImpl 是 memoryDb 对象：\n总结实现代理模式的几个关键点：\n 定义代理对象，实现被代理对象的接口。本例子中，前者是 CacheProxy 对象，后者是 Db 接口。 代理对象组合被代理对象，这里组合的应该是抽象接口，让代理的可扩展性更高些。本例子中，CacheProxy 对象组合了 Db 接口。 代理对象在具体接口实现上，嵌入代理本身的逻辑。本例子中，CacheProxy 在 Query、Insert 等方法中，加入了缓存 sync.Map 的读写逻辑。 代理对象也可以有自己特有方法，提供一些辅助的功能。本例子中，CacheProxy 新增了Hit、Miss 等方法用于统计缓存的命中率。 最后，在初始化阶段，完成代理的实例化，并依赖注入到客户端。这要求，客户端依赖抽象接口，而不是具体实现，否则代理就不透明了。  扩展 Go 标准库中的反向代理 代理模式最典型的应用场景是远程代理，其中，反向代理又是最常用的一种。\n以 Web 应用为例，反向代理位于 Web 服务器前面，将客户端（例如 Web 浏览器）请求转发后端的 Web 服务器。反向代理通常用于帮助提高安全性、性能和可靠性，比如负载均衡、SSL 安全链接。\nGo 标准库的 net 包也提供了反向代理，ReverseProxy，位于 net/http/httputil/reverseproxy.go 下，实现 http.Handler 接口。http.Handler 提供了处理 Http 请求的能力，也即相当于 Http 服务器。那么，对应到 UML 结构图中，http.Handler 就是 Subject，ReverseProxy 就是 Proxy：\n下面列出 ReverseProxy 的一些核心代码：\n// net/http/httputil/reverseproxy.go package httputil type ReverseProxy struct { // 修改前端请求，然后通过Transport将修改后的请求转发给后端  Director func(*http.Request) // 可理解为Subject，通过Transport来调用被代理对象的ServeHTTP方法处理请求  Transport http.RoundTripper // 修改后端响应，并将修改后的响应返回给前端  ModifyResponse func(*http.Response) error // 错误处理  ErrorHandler func(http.ResponseWriter, *http.Request, error) ... } func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // 初始化transport  transport := p.Transport if transport == nil { transport = http.DefaultTransport } ... // 修改前端请求  p.Director(outreq) ... // 将请求转发给后端  res, err := transport.RoundTrip(outreq) ... // 修改后端响应  if !p.modifyResponse(rw, res, outreq) { return } ... // 给前端返回响应  err = p.copyResponse(rw, res.Body, p.flushInterval(res)) ... } ReverseProxy 就是典型的代理模式实现，其中，远程代理无法直接引用后端的对象引用，因此这里通过引入 Transport 来远程访问后端服务，可以将 Transport 理解为 Subject。\n可以这么使用 ReverseProxy：\nfunc proxy(c *gin.Context) { remote, err := url.Parse(\u0026#34;https://yrunz.com\u0026#34;) if err != nil { panic(err) } proxy := httputil.NewSingleHostReverseProxy(remote) proxy.Director = func(req *http.Request) { req.Header = c.Request.Header req.Host = remote.Host req.URL.Scheme = remote.Scheme req.URL.Host = remote.Host req.URL.Path = c.Param(\u0026#34;proxyPath\u0026#34;) } proxy.ServeHTTP(c.Writer, c.Request) } func main() { r := gin.Default() r.Any(\u0026#34;/*proxyPath\u0026#34;, proxy) r.Run(\u0026#34;:8080\u0026#34;) } 典型应用场景  远程代理（remote proxy），远程代理适用于提供服务的对象处在远程的机器上，通过普通的函数调用无法使用服务，需要经过远程代理来完成。因为并不能直接访问本体对象，所有远程代理对象通常不会直接持有本体对象的引用，而是持有远端机器的地址，通过网络协议去访问本体对象。 虚拟代理（virtual proxy），在程序设计中常常会有一些重量级的服务对象，如果一直持有该对象实例会非常消耗系统资源，这时可以通过虚拟代理来对该对象进行延迟初始化。 保护代理（protection proxy），保护代理用于控制对本体对象的访问，常用于需要给 Client 的访问加上权限验证的场景。 缓存代理（cache proxy），缓存代理主要在 Client 与本体对象之间加上一层缓存，用于加速本体对象的访问，常见于连接数据库的场景。 智能引用（smart reference），智能引用为本体对象的访问提供了额外的动作，常见的实现为 C++ 中的智能指针，为对象的访问提供了计数功能，当访问对象的计数为 0 时销毁该对象。  优缺点 优点  可以在客户端不感知的情况下，控制访问对象，比如远程访问、增加缓存、安全等。 符合 开闭原则，可以在不修改客户端和被代理对象的前提下，增加新的代理；也可以在不修改客户端和代理的前提下，更换被代理对象。  缺点  作为远程代理时，因为多了一次转发，会影响请求的时延。  与其他模式的关联 从结构上看，装饰模式 和 代理模式 具有很高的相似性，但是两种所强调的点不一样。前者强调的是为本体对象添加新的功能，后者强调的是对本体对象的访问控制。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] 【Go实现】实践GoF的23种设计模式：装饰模式, 元闰子\n[3] Design Patterns, Chapter 4. Structural Patterns, GoF\n[4] 代理模式, refactoringguru.cn\n[5] 什么是反向代理？, cloudflare\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-10-16T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E4%BB%A3%E7%90%86%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：代理模式"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：迭代器模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 GoF 对访问者模式（Visitor Pattern）的定义如下：\n Represent an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements on which it operates.\n 访问者模式的目的是，解耦数据结构和算法，使得系统能够在不改变现有代码结构的基础上，为对象新增一种新的操作。\n上一篇介绍的 迭代器模式 也做到了数据结构和算法的解耦，不过它专注于遍历算法。访问者模式，则在遍历的同时，将操作作用到数据结构上，一个常见的应用场景是语法树的解析。\nUML 结构 场景上下文 在 简单的分布式应用系统（示例代码工程）中，db 模块用来存储服务注册和监控信息，它是一个 key-value 数据库。另外，我们给 db 模块抽象出 Table 对象：\n// demo/db/table.go package db // Table 数据表定义 type Table struct { name string metadata map[string]int // key为属性名，value属性值的索引, 对应到record上存储  records map[interface{}]record iteratorFactory TableIteratorFactory // 默认使用随机迭代器 } 目的是提供类似于关系型数据库的按列查询能力，比如：\n上述的按列查询只是等值比较，未来还可能会实现正则表达式匹配等方式，因此我们需要设计出可供未来扩展的接口。这种场景，使用访问者模式正合适。\n代码实现 // demo/db/table_visitor.go package db // 关键点1: 定义表查询的访问者抽象接口，允许后续扩展查询方式 type TableVisitor interface { // 关键点2: Visit方法以Element作为入参，这里的Element为Table对象  Visit(table *Table) ([]interface{}, error) } // 关键点3: 定义Visitor抽象接口的实现对象，这里FieldEqVisitor实现按列等值查询逻辑 type FieldEqVisitor struct { field string value interface{} } // 关键点4: 为FieldEqVisitor定义Visit方法，实现具体的等值查询逻辑 func (f *FieldEqVisitor) Visit(table *Table) ([]interface{}, error) { result := make([]interface{}, 0) idx, ok := table.metadata[f.field] if !ok { return nil, ErrRecordNotFound } for _, r := range table.records { if reflect.DeepEqual(r.values[idx], f.value) { result = append(result, r) } } if len(result) == 0 { return nil, ErrRecordNotFound } return result, nil } func NewFieldEqVisitor(field string, value interface{}) *FieldEqVisitor { return \u0026amp;FieldEqVisitor{ field: field, value: value, } } // demo/db/table.go package db type Table struct {...} // 关键点5: 为Element定义Accept方法，入参为Visitor接口 func (t *Table) Accept(visitor TableVisitor) ([]interface{}, error) { return visitor.Visit(t) } 客户端可以这么使用：\nfunc client() { table := NewTable(\u0026#34;testRegion\u0026#34;).WithType(reflect.TypeOf(new(testRegion))) table.Insert(1, \u0026amp;testRegion{Id: 1, Name: \u0026#34;beijing\u0026#34;}) table.Insert(2, \u0026amp;testRegion{Id: 2, Name: \u0026#34;beijing\u0026#34;}) table.Insert(3, \u0026amp;testRegion{Id: 3, Name: \u0026#34;guangdong\u0026#34;}) visitor := NewFieldEqVisitor(\u0026#34;name\u0026#34;, \u0026#34;beijing\u0026#34;) result, err := table.Accept(visitor) if err != nil { t.Error(err) } if len(result) != 2 { t.Errorf(\u0026#34;visit failed, want 2, got %d\u0026#34;, len(result)) } } 总结实现访问者模式的几个关键点：\n 定义访问者抽象接口，上述例子为 TableVisitor， 目的是允许后续扩展表查询方式。 访问者抽象接口中，Visit 方法以 Element 作为入参，上述例子中， Element 为 Table 对象。 为 Visitor 抽象接口定义具体的实现对象，上述例子为 FieldEqVisitor。 在访问者的 Visit 方法中实现具体的业务逻辑，上述例子中 FieldEqVisitor.Visit(...) 实现了按列等值查询逻辑。 在被访问者 Element 中定义 Accept 方法，以访问者 Visitor 作为入参。上述例子中为 Table.Accept(...) 方法。  扩展 Go 风格实现 上述实现是典型的面向对象风格，下面以 Go 风格重新实现访问者模式：\n// demo/db/table_visitor_func.go package db // 关键点1: 定义一个访问者函数类型 type TableVisitorFunc func(table *Table) ([]interface{}, error) // 关键点2: 定义工厂方法，工厂方法返回的是一个访问者函数，实现了具体的访问逻辑 func NewFieldEqVisitorFunc(field string, value interface{}) TableVisitorFunc { return func(table *Table) ([]interface{}, error) { result := make([]interface{}, 0) idx, ok := table.metadata[field] if !ok { return nil, ErrRecordNotFound } for _, r := range table.records { if reflect.DeepEqual(r.values[idx], value) { result = append(result, r) } } if len(result) == 0 { return nil, ErrRecordNotFound } return result, nil } } // 关键点3: 为Element定义Accept方法，入参为Visitor函数类型 func (t *Table) AcceptFunc(visitorFunc TableVisitorFunc) ([]interface{}, error) { return visitorFunc(t) } 客户端可以这么使用：\nfunc client() { table := NewTable(\u0026#34;testRegion\u0026#34;).WithType(reflect.TypeOf(new(testRegion))) table.Insert(1, \u0026amp;testRegion{Id: 1, Name: \u0026#34;beijing\u0026#34;}) table.Insert(2, \u0026amp;testRegion{Id: 2, Name: \u0026#34;beijing\u0026#34;}) table.Insert(3, \u0026amp;testRegion{Id: 3, Name: \u0026#34;guangdong\u0026#34;}) result, err := table.AcceptFunc(NewFieldEqVisitorFunc(\u0026#34;name\u0026#34;, \u0026#34;beijing\u0026#34;)) if err != nil { t.Error(err) } if len(result) != 2 { t.Errorf(\u0026#34;visit failed, want 2, got %d\u0026#34;, len(result)) } } Go 风格的实现，利用了函数闭包的特点，更加简洁了。\n总结几个实现关键点：\n 定义一个访问者函数类型，函数签名以 Element 作为入参，上述例子为 TableVisitorFunc 类型。 定义一个工厂方法，工厂方法返回的是具体的访问访问者函数，上述例子为 NewFieldEqVisitorFunc 方法。这里利用了函数闭包的特性，在访问者函数中直接引用工厂方法的入参，与 FieldEqVisitor 中持有两个成员属性的效果一样。 为 Element 定义 Accept 方法，入参为 Visitor 函数类型 ，上述例子是 Table.AcceptFunc(...) 方法。  与迭代器模式结合 访问者模式经常与迭代器模式一起使用。比如上述例子中，如果你定义的 Visitor 实现不在 db 包内，那么就无法直接访问 Table 的数据，这时就需要通过 Table 提供的迭代器来实现。\n在 简单的分布式应用系统（示例代码工程）中，db 模块存储的服务注册信息如下：\n// demo/service/registry/model/service_profile.go package model // ServiceProfileRecord 存储在数据库里的类型 type ServiceProfileRecord struct { Id string // 服务ID  Type ServiceType // 服务类型  Status ServiceStatus // 服务状态  Ip string // 服务IP  Port int // 服务端口  RegionId string // 服务所属regionId  Priority int // 服务优先级，范围0～100，值越低，优先级越高  Load int // 服务负载，负载越高表示服务处理的业务压力越大 } 现在，我们要查询符合指定 ServiceId 和 ServiceType 的服务记录，可以这么实现一个 Visitor：\n// demo/service/registry/model/service_profile.go package model type ServiceProfileVisitor struct { svcId string svcType ServiceType } func (s *ServiceProfileVisitor) Visit(table *db.Table) ([]interface{}, error) { var result []interface{} // 通过迭代器来遍历Table的所有数据  iter := table.Iterator() for iter.HasNext() { profile := new(ServiceProfileRecord) if err := iter.Next(profile); err != nil { return nil, err } // 先匹配ServiceId，如果一致则无须匹配ServiceType  if profile.Id != \u0026#34;\u0026#34; \u0026amp;\u0026amp; profile.Id == s.svcId { result = append(result, profile) continue } // ServiceId匹配不上，再匹配ServiceType  if profile.Type != \u0026#34;\u0026#34; \u0026amp;\u0026amp; profile.Type == s.svcType { result = append(result, profile) } } return result, nil } 典型应用场景   k8s 中，kubectl 通过访问者模式来处理用户定义的各类资源。\n  编译器中，通常使用访问者模式来实现对语法树解析，比如 LLVM。\n  希望对一个复杂的数据结构执行某些操作，并支持后续扩展。\n  优缺点 优点  数据结构和操作算法解耦，符合 单一职责原则。 支持对数据结构扩展多种操作，具备较强的可扩展性，符合 开闭原则。  缺点  访问者模式某种程度上，要求数据结构必须对外暴露其内在实现，否则访问者就无法遍历其中数据（可以结合迭代器模式来解决该问题）。 如果被访问对象内的数据结构变更，可能要更新所有的访问者实现。  与其他模式的关联  访问者模式 经常和 迭代器模式 一起使用，使得被访问对象无须向外暴露内在数据结构。 也经常和 组合模式 一起使用，比如在语法树解析中，递归访问和解析树的每个节点（节点组合成树）。  文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] 【Go实现】实践GoF的23种设计模式：迭代器模式, 元闰子\n[3] Design Patterns, Chapter 5. Behavioral Patterns, GoF\n[4] GO 编程模式：K8S VISITOR 模式, 酷壳\n[5] 访问者模式, refactoringguru.cn\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-10-06T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E8%AE%BF%E9%97%AE%E8%80%85%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：访问者模式"},{"content":"前些天回老家参加朋友婚礼，明天还要上班，下午也该回去了。\n午饭时候，侄女在旁边手玩着手，低头嘟囔，\n“学叔，你得听日先回无啦？”，\n“好快就翻黎啦”。\n粗粗吃完，回房间眯了一会，把行李拎下楼，准备启程了。\n老妈看见，急忙放下手中忙活，开始乒乒乓乓地收拾，虾、墨鱼滑、蜂蜜、苹果、橘子、一捆生菜，一个都没落下；随后匆忙换好鞋子，提着几袋就出门，连着行李一起放上车。\n等东西都放好，坐上车，启动，打开地图导航，出发了。\n老爸在前头，边摆弄好街上散落的摩托车，给车子腾出位置；边双手举过头顶，不停向后招手，指挥着车子向前。老妈则是默默在车后头跟着。\n经过旧屋时，奶奶也出来了，等车窗放下，两只手搭到车门，把头微微伸入，\n“家阵上嗲哈？”\n“系啊。”\n“回去健健康康喔，一路顺风哈，回到记得打个电话俾婆婆哈。”\n“好啊。”\n挤挤开过了这条街，来到街口，老爸突然说，“你妈妈讲要送你出路口哇。”\n于是，把车门解锁，爸妈先后打开车门，坐了上来。\n去路口的路的两边边停满了车，双行道容不下两辆车并排开，地面还偶尔冒出些小石包，不敢开快。\n车上三人一句句闲聊，没多久，就到路口；停下来，等前后车都过了，回头问，“系度放你地落哈？”\n老妈急忙回了句，“呃，系度落。”\n老爸先下车，微微站出路口，背扣双手；刺眼的阳光下，紧皱着眉头，来回观察路况。\n老妈下车后，不断叮嘱把车窗摇上来，别让空调漏了出去。\n看老爸没有明示，我也轻踩油门，出路口左转离去了。\n等车身回直，瞄了眼后视镜，发现他们还站在路口，背扣双手，看着这边；\n过了一会，再看，还在，远远地看着；\n又过了一会，老妈先转身回去了；没一会，老爸也回头走进了路口。\n（完 \u0026ndash; 2022.09.04）\n","date":"2022-09-04T00:00:00Z","permalink":"https://yrunz.com/p/%E9%80%81%E8%A1%8C/","title":"送行"},{"content":"前言 在日常编程中，数值类型（numeric types）是我们打交道最多的类型，可能没有之一。除了最熟悉的 int，还有 long、float、double 等。正因太熟悉，我们往往不会深究它们的底层原理。因为平时的工作中，知道个大概，也够用了。\n但，在某些业务场景下，比如金融业务，数值运算不准确会带来灾难性的后果。这时，你就必须清楚数值类型的二进制表示、截断、转型等原理，否则很难保证运算结果的正确性。\n另外，数值类型也是一个容易被黑客攻击的点，考虑如下一段代码：\n// C++ /* Declaration of library function memcpy */ void *memcpy(void *dest, void *src, size_t n); /* Kernel memory region holding user-accessible data */ #define KSIZE 1024 char kbuf[KSIZE]; /* Copy at most maxlen bytes from kernel region to user buffer */ int copy_from_kernel(void *user_dest, int maxlen) { /* Byte count len is minimum of buffer size and maxlen */ int len = KSIZE \u0026lt; maxlen ? KSIZE : maxlen; memcpy(user_dest, kbuf, len); return len; } 如果你熟悉数值类型的原理，一定会敏锐察觉出第 10 行存在 int 到 size_t 的类型转换。在 64 位系统中，size_t 通常被定义为 unsigned long 类型，如果攻击者在调用 copy_from_kernel 时，特意传入一个负数的 maxlen，转型到 memcpy 中的 n 将会是一个很大的正数，从而导致了内存拷贝的越界！\n数值类型是计算机编程的基础，用的很多，也很重要，理解它的底层原理，有助于写出正确的代码，避免一些意料之外的错误。\n 每个计算机系统都有固定的 word size，也即常说的 xx 位，它也是指针的大小，跟 虚拟内存 相关，比如一个 w 位系统上的应用程序，最多能够访问 $2^{w}$ byte 大小的虚拟内存。\n最常用的是 32 位 和 64 位 系统，某些数值类型在它们之上会有些差异，比如 long 类型 在 32 位系统上是 32 bit 大小，在 64 位系统上是 64 bit 大小。考虑如今 64 位系统逐渐成为主流，本文会以它作为基础，进行数值类型的介绍。\n 整数 在计算机系统中，整数可以分成 无符号（unsigned）整数 和 有符号（signed）整数 两大类，这之下，按照类型表示的 bit 位大小，又可细分成 8 位的 char/byte/int8 、16 位的 short/innt16、32 位的 int/int32 和 64 位的 long/int64，它们的取值范围如下：\n   类型 最小值 最大值     [signed] char -128 127   unsigned char 0 255   short -32,768 32,767   unsigned short 0 65,535   int −2,147,483,648 2,147,483,647   unsigned int 0 4,294,967,295   long −9,223,372,036,854,775,808 −9,223,372,036,854,775,807   unsigned long 0 18,446,744,073,709,551,615    死记这个表不容易，下面我们将试图从二进制编码层面去理解它。\n二进制编码 整数在计算机系统上都是以二进制存储的，对于一个 w 位的整数 $x$，它的二进制表示写成这样： $$ [x_{w-1}, x_{w-2}, \u0026hellip;, x_0]_b $$ 其中，$x_i$ 取值 $0$ 或 $1$。\n无符号编码（Unsigned Encodings） 在二进制表示的基础上，无符号编码 是这样： $$ x = \\sum_{i=0}^{w-1}x_i \\cdot 2^i $$ 比如，w = 4 场景下的一些例子： $$ [0001]_b=0\\cdot2^3+0\\cdot2^2+0\\cdot2^1+1\\cdot2^0=0+0+0+1=1 $$ $$ [0101]_b=0\\cdot2^3+1\\cdot2^2+0\\cdot2^1+1\\cdot2^0=0+4+0+1=5 $$\n$$ [1011]_b=1\\cdot2^3+0\\cdot2^2+1\\cdot2^1+1\\cdot2^0=8+0+2+1=11 $$\n$$ [1111]_b=1\\cdot2^3+1\\cdot2^2+1\\cdot2^1+1\\cdot2^0=8+4+2+1=15 $$\n由上述可知，无符号编码无法表示负数，因此只能表示无符号整数。为了表示有符号整数，还要探寻另一种编码方式。\n原码编码（True Form Encodings） 为了区分正数和负数，很容易想到使用一个 bit 位作为符号位，$0$ 表示正数，$1$ 表示负数。在无符号编码的基础上，使用最高位作为符号位，其他位含义不变，得出 原码编码 形式： $$ x = (-1)^{x_{w-1}} \\cdot \\sum_{i=0}^{w-2}x_i2^i $$ 比如，w = 4 场景下的一些例子： $$ [0001]_b=(-1)^0\\cdot(0\\cdot2^2+0\\cdot2^1+1\\cdot2^0)=1\\cdot(0+0+1)=1 $$ $$ [0101]_b=(-1)^0\\cdot(1\\cdot2^2+0\\cdot2^1+1\\cdot2^0)=1\\cdot(4+0+1)=5 $$\n$$ [1011]_b=(-1)^1\\cdot(0\\cdot2^2+1\\cdot2^1+1\\cdot2^0)=-1\\cdot(0+2+1)=-3 $$\n$$ [1111]_b=(-1)^1\\cdot(1\\cdot2^2+1\\cdot2^1+1\\cdot2^0)=-1\\cdot(4+2+1)=-7 $$\n虽然原码编码方式简单直观，但它还存在两个问题：\n（1）$0$ 存在两种编码形式\n原码编码方式下，$0$ 存在两种编码形式，$[0,0,\u0026hellip;,0]_b$ 和 $[1,0,\u0026hellip;,0]_b$。同一个整数值，却有两种编码，这对计算机系统来说没什么意义，反而是一种浪费。\n（2）带负数的加法运算不正确\n原码编码方式下，两个正数的加法没问题，一旦带上负数，结果就出错了：\n所以，原码编码方式，注定不会被使用。\n补码编码（Two\u0026rsquo;s-complement Encodings） 于是，补码编码 被发明，它也是建立在无符号编码的基础上，仍然取最高位为符号位，编码方式是这样： $$ x = -x_{w-1} \\cdot 2^{w-1} + \\sum_{i=0}^{w-2}x_i2^i $$ **它与无符号编码的唯一区别是，最高位的取值从** $x_{w-1} \\cdot 2^{w-1}$ **变成了** $-x_{w-1} \\cdot 2^{w-1}$。\n比如，w = 4 场景下的一些例子： $$ [0001]_b=-0\\cdot2^3 +0\\cdot2^2+0\\cdot2^1+1\\cdot2^0)=0+0+0+1=1 $$ $$ [0101]_b=-0\\cdot2^3+1\\cdot2^2+0\\cdot2^1+1\\cdot2^0=0+4+0+1)=5 $$\n$$ [1011]_b=-1\\cdot2^3+0\\cdot2^2+1\\cdot2^1+1\\cdot2^0=-8+0+2+1=-5 $$\n$$ [1111]_b=-1\\cdot2^3+1\\cdot2^2+1\\cdot2^1+1\\cdot2^0=-8+4+2+1=-1 $$\n补码编码很巧妙地解决了原码编码的两个问题：\n首先，0 在补码编码下只有一种编码形式，$[0,0,\u0026hellip;,0]_b$ 。\n此外，带负数的加法运算，也正确了。\n因为补码编码的简单和正确性，目前，几乎所有的计算机系统，都采用补码编码来表示有符号整数。\n位运算 位运算主要包含 取反、与、或、异或、移位 等几种，我们在业务开发时用得比较少，但如果你有阅读开源代码的习惯，就会经常发现它们的踪迹。如果碰巧对位运算不熟悉，那么阅读这些代码，就同读天书一般。\n取反（~）、与（\u0026amp;）、或（|）、异或（^）的规则比较简单：\n移位运算，可以分成 左移 和 右移 两种，其中，右移又可分为 逻辑右移 和 算术右移。\n左移（\u0026laquo;）运算，是对二进制整数，向左移若干位，高位丢弃，低位补零。也即，对 $[x_{w-1}, x_{w-2}, \u0026hellip;, x_0]_b$ 左移 $k$ 位，得到 $[x_{w-k-1}, x_{w-k-2}, \u0026hellip;, x_0, 0, \u0026hellip;, 0]_b$。\n比如，对 int i = -1 左移 10 位，会得到 i = -1024 的结果：\n// Java语言 public static void main(String[] args) { int i = -1; System.out.println(\u0026#34;Before \u0026lt;\u0026lt; , i\u0026#39;s value is \u0026#34; + i); System.out.println(\u0026#34;i\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i)); i \u0026lt;\u0026lt;= 10; System.out.println(\u0026#34;After \u0026lt;\u0026lt; , i\u0026#39;s value is \u0026#34; + i); System.out.println(\u0026#34;i\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i)); } // 输出结果： Before \u0026lt;\u0026lt; , i\u0026#39;s value is -1 i\u0026#39;s binary string is 11111111111111111111111111111111 After \u0026lt;\u0026lt; , i\u0026#39;s value is -1024 i\u0026#39;s binary string is 11111111111111111111110000000000  在 C/C++ 中，两种右移操作符都是 \u0026raquo;，对无符号整数用的是逻辑右移，对有符号整数用的是算术右移；在 Java 中，逻辑右移的操作符是 \u0026raquo;\u0026gt;，算术右移的操作符是 \u0026raquo;。为了方便区分，下文统一用 Java 的表示方法。\n 逻辑右移（\u0026raquo;\u0026gt;）运算，是对二进制整数，向右移若干位，高位补零，低位丢弃。也即，对 $[x_{w-1}, x_{w-2}, \u0026hellip;, x_0]_b$ 逻辑左移 k 位，得到 $[0, \u0026hellip;, 0,x_{w-1}, x_{w-2}, \u0026hellip;, x_k]_b$。\n比如，对 int i = -1 逻辑右移 10 位，会得到 i = 4194303 的结果：\n// Java语言 public static void main(String[] args) { int i = -1; System.out.println(\u0026#34;Before \u0026gt;\u0026gt;\u0026gt; , i\u0026#39;s value is \u0026#34; + i); System.out.println(\u0026#34;i\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i)); i \u0026gt;\u0026gt;\u0026gt;= 10; System.out.println(\u0026#34;After \u0026gt;\u0026gt;\u0026gt; , i\u0026#39;s value is \u0026#34; + i); System.out.println(\u0026#34;i\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i)); } // 输出结果： Before \u0026gt;\u0026gt;\u0026gt; , i\u0026#39;s value is -1 i\u0026#39;s binary string is 11111111111111111111111111111111 After \u0026gt;\u0026gt;\u0026gt; , i\u0026#39;s value is 4194303 i\u0026#39;s binary string is 1111111111111111111111 算术右移（\u0026raquo;）运算，是对二进制整数，向右移若干位，高位补符号位，低位丢弃。也即，对 $[x_{w-1}, x_{w-2}, \u0026hellip;, x_0]_b$ 逻辑左移 k 位，得到 $[x_{w-1}, \u0026hellip;, x_{w-1},x_{w-1}, x_{w-2}, \u0026hellip;, x_k]_b$。\n比如，对 int i = -1 算术右移 10 位，仍会得到 i = -1 的结果：\n// Java语言 public static void main(String[] args) { int i = -1; System.out.println(\u0026#34;Before \u0026gt;\u0026gt; , i\u0026#39;s value is \u0026#34; + i); System.out.println(\u0026#34;i\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i)); i \u0026gt;\u0026gt;= 10; System.out.println(\u0026#34;After \u0026gt;\u0026gt; , i\u0026#39;s value is \u0026#34; + i); System.out.println(\u0026#34;i\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i)); } // 输出结果： Before \u0026gt;\u0026gt; , i\u0026#39;s value is -1 i\u0026#39;s binary string is 11111111111111111111111111111111 After \u0026gt;\u0026gt; , i\u0026#39;s value is -1 i\u0026#39;s binary string is 11111111111111111111111111111111 目前为止，介绍移位运算的原理时，我们都默认 k \u0026lt; w，如果 k \u0026gt;= w 会怎样？\n比如， $[x_{w-1}, x_{w-2}, \u0026hellip;, x_0]_b$ 左移 w 位，结果会是 $[0, 0, \u0026hellip;, 0]_b$ 吗：\n// Java语言 public static void main(String[] args) { int i1 = -1; System.out.println(\u0026#34;Before \u0026lt;\u0026lt; 31, i1\u0026#39;s value is \u0026#34; + i1); System.out.println(\u0026#34;i1\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i1)); i1 \u0026lt;\u0026lt;= 31; System.out.println(\u0026#34;After \u0026lt;\u0026lt; 31, i1\u0026#39;s value is \u0026#34; + i1); System.out.println(\u0026#34;i1\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i1)); int i2 = -1; System.out.println(\u0026#34;Before \u0026lt;\u0026lt; 32, i2\u0026#39;s value is \u0026#34; + i2); System.out.println(\u0026#34;i2\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i2)); i2 \u0026lt;\u0026lt;= 32; System.out.println(\u0026#34;After \u0026lt;\u0026lt; 32, i2\u0026#39;s value is \u0026#34; + i2); System.out.println(\u0026#34;i2\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i2)); } // 输出结果： Before \u0026lt;\u0026lt; 31, i1\u0026#39;s value is -1 i1\u0026#39;s binary string is 11111111111111111111111111111111 After \u0026lt;\u0026lt; 31, i1\u0026#39;s value is -2147483648 i1\u0026#39;s binary string is 10000000000000000000000000000000 Before \u0026lt;\u0026lt; 32, i2\u0026#39;s value is -1 i2\u0026#39;s binary string is 11111111111111111111111111111111 After \u0026lt;\u0026lt; 32, i2\u0026#39;s value is -1 i2\u0026#39;s binary string is 11111111111111111111111111111111 上述例子中， w = 32，我们发现 k = 31 时，结果还符合预期；当 k = 32 时，结果不是 0，而是 -1，也即相当于 k = 0 时的结果。\n原因是这样，对 w 位整数 x，当执行 x \u0026lt;\u0026lt; k 时，实际执行的是 x \u0026lt;\u0026lt; (k % w)。所以，当 i2 \u0026lt;\u0026lt; 32 时，实际是 i2 \u0026lt;\u0026lt; 32 % 32 = i2 \u0026lt;\u0026lt; 0。\n右移操作也遵循同样的规则，也即 x \u0026gt;\u0026gt; k = x \u0026gt;\u0026gt; (k % w)， x \u0026gt;\u0026gt;\u0026gt; k = x \u0026gt;\u0026gt;\u0026gt; (k % w)。\n算术运算 整数的算术运算也不总符合我们的常识，比如下面这个例子：\n// Java语言 public static void main(String[] args) { int i1 = 2147483647; int i2 = 1; System.out.printf(\u0026#34;2147483647+1=%d\\n\u0026#34;, i1+i2); } // 输出结果： 2147483647+1=-2147483648 两个正数 2147483647 和 1 相加，实际运行结果却是一个负数 -2147483648。原因是，运算结果超出了 int 类型的表示范围，我们把这种现象称作 溢出。\n接下来，我们将介绍整数类型常见的几种算术运算，包括当运算出现溢出时，计算机系统的处理方式。\n加法 （1）无符号整数加法运算\n对两个 w 位的无符号整数 $x, y \\in[0, 2^w-1]$，有 $x+y\\in[0, 2^{w+1}-2]$；如果仍用一个 w 位的整数表示相加结果，那么当结果在 $[2^w, 2^{w+1}-2]$ 范围时，也即结果需要 w + 1 位表示时，运算就产生溢出了。\n如果出现溢出，系统会对结果进行截断，只保留低 w 位。\n比如，w = 4 场景下的一些例子：\n因此，如果用 $+_w^{u}$ 表示 w 位无符号整数的加法运算，那么它有如下规则： $$ x+_w^{u}y= \\begin{cases} x+y, \u0026amp;x+y\u0026lt;2^w\\ \u0026amp;正常\\\nx+y-2^w, \u0026amp;2^w\\le x+y\u0026lt;2^{w+1}\\ \u0026amp;溢出 \\end{cases} $$ 其中，溢出场景因为对结果的进行截断，舍去了 $w+1$ 位，所以结果需要减去 $2^w$。\n（2）有符号整数加法运算\n对两个 w 位的有符号整数 $x, y \\in[-2^{w-1}, 2^{w-1}-1]$，有 $x+y \\in[-2^{w}, 2^{w}-2]$；如果仍用一个 w 位的整数表示相加结果，那么当结果在 $[-2^{w}, -2^{w-1}-1]$ 和 $[2^{w-1}, 2^{w}-2]$ 范围时，运算就产生溢出了。\n比如，w = 4 场景下的一些例子：\n由上述例子可知，两个 w 位的负数相加，即使结果需要 w + 1 位表示，也可能是正常场景。这取决于截断位 w 位后的最高位，为 1 则还属于正常场景，为 0 则属于溢出场景。\n因此，如果用 $+_w^{t}$ 表示 w 位有符号整数的加法运算，那么它有如下规则： $$ x+_w^{t}y= \\begin{cases} x+y-2^w, \u0026amp;2^{w-1}\\le x+y\\ \u0026amp;正溢出\\\nx+y, \u0026amp;-2^{w-1}\\le x+y \u0026lt;2^{w-1}\\ \u0026amp;正常\\\nx+y+2^w, \u0026amp;x+y\u0026lt;-2^{w-1}\\ \u0026amp;负溢出 \\end{cases} $$\n 正溢出场景下，原本 $x+y=2^{w-1}+\\sum_{i=0}^{w-2}x_i\\cdot2^i$，但按照补码编码方式，实际变成了 $x+_w^{t}y=-2^{w-1}+\\sum_{i=0}^{w-2}x_i\\cdot2^i$，所以就有 $x+_w^{t}y=x+y-2^w$。 负溢出场景下，w 位一定是 0，原本 $x+y=-2^{w}+\\sum_{i=0}^{w-2}x_i\\cdot2^i$，结果截断之后，实际变成了 $x+_w^{t}y=\\sum_{i=0}^{w-2}x_i\\cdot2^i$，所以就有 $x+_w^{t}y=x+y+2^w$。   为什么负溢出场景下，w 位一定是 0？\n负溢出场景出现的条件是 $x+y\u0026lt;-2^{w-1}$，也即 : $$ x+y=-2^w+\\sum_{i=0}^{w-1}x_i\\cdot 2^i \u0026lt; -2^{w-1}\\\n=\u0026gt;\\sum_{i=0}^{w-1}x_i\\cdot 2^i \u0026lt; 2^w-2^{w-1}=2^{w-1}\\\n=\u0026gt;x_{w-1}\\cdot 2^{w-1} + \\sum_{i=0}^{w-2}x_i\\cdot 2^i \u0026lt; 2^{w-1}\\\n$$ 如果要使上述不等式成立，$x_{w-1}$ 必然为 0.\n 前面很多公式，但记住这个就行，无符号整数和有符号整数的加法运算规则，本质都是一样的，可以拆解成 4 步：\n 先计算出真实加法运算的结果值。 对结果值用 w + 1 位二进制表示。 再将 w + 1 位的二进制结果截断，保留低 w 位。 将截断后的 w 位二进制值转换回十进制整数，得到最终结果。无符号整数用无符号编码，有符号整数用补码编码。  取反 取反，也即求相反数，给定一个整数 $x$，那么它的相反数 $y = -x$，也即满足 $x + y = 0$。\n（1）无符号整数的取反\n对一个 w 位的无符号整数 $x \\in [0, 2^w-1]$，对它取反，也即找到一个整数 $y \\in [0, 2^w-1]$，使得 $x + y = 0$：\n 当 $x = 0$，那么很容易得出 $y = 0$； 当 x \u0026gt; 0，只有在溢出场景才会存在 $x+y=0$ 的可能。由前文可知，无符号整数加法溢出场景下，$x+_w^uy=x+y-2^w=0$ 时，有，$y=2^w-x$。  因此，如果用 $-_w^{u}$ 表示 w 位无符号整数的取反运算，那么它有如下规则： $$ -_w^{u}x= \\begin{cases} x, \u0026amp;x=0\\\n2^w-x, \u0026amp;x\u0026gt;0 \\end{cases} $$ 比如，w = 8 场景下的例子：\n// C++ int main() { uint8_t i1 = 0; uint8_t i2 = -i1; printf(\u0026#34;i1=%u\\n\u0026#34;, i1); printf(\u0026#34;-i1=%u\\n\u0026#34;, i2); uint8_t i3 = 100; uint8_t i4 = -i3; printf(\u0026#34;i3=%u\\n\u0026#34;, i3); printf(\u0026#34;-i3=%u\\n\u0026#34;, i4); printf(\u0026#34;2^8-i3=256-%u=%u\\n\u0026#34;, i3, 256-i3); return 0; } // 输出结果 i1=0 -i1=0 i3=100 -i3=156 2^8-i3=256-100=156 （2）有符号整数的取反\n对一个 w 位的有符号整数 $x \\in [-2^{w-1}, 2^{w-1}-1]$，对它取反，也即找到一个整数 $y \\in [-2^{w-1}, 2^{w-1}-1]$，使得 $x + y = 0$：\n 当 $x \u0026gt; -2^{w-1}$，很容易得出 $y = -x$，因为此时 $-x\\in [-2^{w-1}+1, 2^{w-1}-1]$ ，仍是有效的范围。 当 $x = -2^{w-1}$，因为 $2^{w-1}$ 已经不再有效范围内，所以只能是溢出场景。而 $-2^{w-1}-2^{w-1}=-2^w$，截断为 w 之后，刚好为 0。所以，此时 $-x = -2^{w-1}$。  因此，如果用 $-_w^{t}$ 表示 w 位有符号整数的取反运算，那么它有如下规则： $$ -_w^{t}x= \\begin{cases} -2^{w-1}, \u0026amp;x=-2^{w-1}\\\n-x, \u0026amp;x\u0026gt;-2^{w-1} \\end{cases} $$ 比如，w = 8 场景下的例子：\n// C++ int main() { int8_t i1 = -128; int8_t i2 = -i1; printf(\u0026#34;i1=%d\\n\u0026#34;, i1); printf(\u0026#34;-i1=%d\\n\u0026#34;, i2); int8_t i3 = 100; int8_t i4 = -i3; printf(\u0026#34;i3=%d\\n\u0026#34;, i3); printf(\u0026#34;-i3=%d\\n\u0026#34;, i4); return 0; } // 输出结果 i1=-128 -i1=-128 i3=100 -i3=-100 乘法 前面介绍加法时说过，无符号整数和有符号整数的加法运算都可以拆成 4 步，这对乘法运算也适用。\n对于无符号整数 $x,y \\in [0, 2^w-1]$，那么 $x\\cdot y \\in [0, (2^w-1)^2]=[0,2^{2w}-2^w+1]$，即无符号整数的乘法运算结果最多需要 2w 位来表示。\n比如，w = 8 时，无符号整数的例子：\n// C++ int main() { uint8_t i1 = 100; uint8_t i2 = 2; uint8_t i3 = i1 * i2; printf(\u0026#34;normal: i1 * i2 = %d * %d = %d\\n\u0026#34;, i1, i2, i3); uint8_t i4 = 100; uint8_t i5 = 3; uint8_t i6 = i4 * i5; printf(\u0026#34;overflow: i4 * i5 = %d * %d = %d\\n\u0026#34;, i4, i5, i6); return 0; } // 输出结果 normal: i1 * i2 = 100 * 2 = 200 overflow: i4 * i5 = 100 * 3 = 44 对于有符号整数 $x,y \\in [-2^{w-1}, 2^{w-1}-1]$，那么 $x\\cdot y \\in [-2^{w-1}\\cdot(2^{w-1}-1), (-2^{w-1})^2]=[-2^{2w-2}+2^{w-1},2^{2w-2}]$，即有符号整数的乘法运算结果最多也需要 2w 位来表示。\n比如，w = 8 时，有符号整数的例子：\n// C++ int main() { int8_t i1 = -50; int8_t i2 = 2; int8_t i3 = i1 * i2; printf(\u0026#34;normal: i1 * i2 = %d * %d = %d\\n\u0026#34;, i1, i2, i3); int8_t i4 = -128; int8_t i5 = 127; int8_t i6 = i4 * i5; printf(\u0026#34;overflow: i4 * i5 = %d * %d = %d\\n\u0026#34;, i4, i5, i6); return 0; } // 输出结果 normal: i1 * i2 = -50 * 2 = -100 overflow: i4 * i5 = -128 * 127 = -128 大部分机器上，乘法运算消耗 3 ~ 10 个 CPU 时钟周期，而加法运算和位运算只消耗 1 个时钟周期，所以，追求极致性能的程序都会想办法通过加法运算和位运算来替代乘法运算。\n如果不考虑截断，对 $x=[x_{w-1}, x_{w-2}, \u0026hellip;, x_0]_b$ 左移 k 位，会得到: $$ [x_{w-1}, x_{w-2}, \u0026hellip;, x_0,0,\u0026hellip;,0]_b=\\sum_{i=0}^{w-1}x_i\\cdot 2^{i+k}=[\\sum_{i=0}^{w-1}x_i\\cdot 2^i]\\cdot 2^k=x\\cdot 2^k $$ **也即，对** $x$ **左移 k 位 相当于乘以** $2^k$ 。\n如果考虑截断，就会存在溢出场景，对于 w 位的整数，左移 k 位效果也等同于 $x*_w^u2^k$ 或 $x*_w^t2^k$。\n比如，w = 8 时，无符号整数的例子：\n// C++ int main() { uint8_t i1 = 100; uint8_t i2 = 4; uint8_t i3 = i1 * i2; printf(\u0026#34;i1 * i2 = %d * %d = %d\\n\u0026#34;, i1, i2, i3); uint8_t k = 2; uint8_t i4 = i1 \u0026lt;\u0026lt; k; printf(\u0026#34;i1 \u0026lt;\u0026lt; k = %d \u0026lt;\u0026lt; %d = %d\\n\u0026#34;, i1, k, i4); return 0; } // 输出结果 i1 * i2 = 100 * 4 = 144 i1 \u0026lt;\u0026lt; k = 100 \u0026lt;\u0026lt; 2 = 144 有符号整数的例子：\n// C++ int main() { int8_t i1 = -50; int8_t i2 = 4; int8_t i3 = i1 * i2; printf(\u0026#34;i1 * i2 = %d * %d = %d\\n\u0026#34;, i1, i2, i3); int8_t k = 2; int8_t i4 = i1 \u0026lt;\u0026lt; k; printf(\u0026#34;i1 \u0026lt;\u0026lt; k = %d \u0026lt;\u0026lt; %d = %d\\n\u0026#34;, i1, k, i4); return 0; } // 输出结果 i1 * i2 = -50 * 4 = 56 i1 \u0026lt;\u0026lt; k = -50 \u0026lt;\u0026lt; 2 = 56 那么，对于与任意常数 K 的相乘，有没可能转换为移位操作？\n任意常数 K，可以表示成 $[(0\u0026hellip;0)(1\u0026hellip;1)(0\u0026hellip;0)\\cdot\\cdot\\cdot(1\u0026hellip;1)]_b$ 的形式，也即，由一系列连续的 0 和 连续的 1 组成，比如 14 可以表示成 $[(0\u0026hellip;0)(111)(0)]_b$。\n假设只存在一个连续的 1 序列，从高到低， 位于 n 到 m 位，比如 14 中，n = 3，m = 1，那么： $$ x\\cdot K=(x\u0026laquo; n)+(x\u0026laquo; n-1)+\u0026hellip;+(x\u0026laquo; m) $$ $$ x\\cdot K=(x\u0026laquo; (n+1))-(x\u0026laquo; m) $$\n比如，$x\\cdot14$ 就可以表示成 $(x\u0026laquo; 3)+(x\u0026laquo; 2)+(x\u0026laquo; 1)$ 或者 $(x\u0026laquo; 4)-(x\u0026laquo; 1)$：\n// C++ int main() { int8_t x = 5; int8_t K = 14; printf(\u0026#34;x * 14 = %d\\n\u0026#34;, x*K); printf(\u0026#34;(x\u0026lt;\u0026lt;3) + (x\u0026lt;\u0026lt;2) + (x\u0026lt;\u0026lt;1) = %d\\n\u0026#34;, (x\u0026lt;\u0026lt;3)+(x\u0026lt;\u0026lt;2)+(x\u0026lt;\u0026lt;1)); printf(\u0026#34;(x\u0026lt;\u0026lt;4) - (x\u0026lt;\u0026lt;1) = %d\\n\u0026#34;, (x\u0026lt;\u0026lt;4)-(x\u0026lt;\u0026lt;1)); return 0; } // 输出结果 x * 14 = 70 (x\u0026lt;\u0026lt;3) + (x\u0026lt;\u0026lt;2) + (x\u0026lt;\u0026lt;1) = 70 (x\u0026lt;\u0026lt;4) - (x\u0026lt;\u0026lt;1) = 70 同理，当 K 的二进制表示，存在多个连续的 1 序列时，也成立。\n除法 除法运算比乘法运算更慢，通常需要 30 个 CPU 时钟以上。同理， $x/2^k$ 也可以转换成右移运算，注意结果的取整： $$ \\lfloor x/2^k\\rfloor = x \u0026raquo; k\\ or\\ x \u0026raquo;\u0026gt; k $$\n$$ \\lceil x/2^k \\rceil = (x+(1\u0026laquo; k)-1)\u0026raquo; k\\ or\\ (x+(1\u0026laquo; k)-1)\u0026raquo;\u0026gt; k $$\n// C++ int main() { int8_t x = -50; int8_t K = 4; printf(\u0026#34;x / 4 = %d\\n\u0026#34;, x/K); printf(\u0026#34;x \u0026gt;\u0026gt; 2 = %d\\n\u0026#34;, x\u0026gt;\u0026gt;2); printf(\u0026#34;(x+(1\u0026lt;\u0026lt;2))\u0026gt;\u0026gt;2 = %d\\n\u0026#34;, (x+(1\u0026lt;\u0026lt;2))\u0026gt;\u0026gt;2); uint8_t y = 50; uint8_t Z = 4; printf(\u0026#34;y / 4 = %d\\n\u0026#34;, y/Z); printf(\u0026#34;y \u0026gt;\u0026gt;\u0026gt; 2 = %d\\n\u0026#34;, y\u0026gt;\u0026gt;2); printf(\u0026#34;(y+(1\u0026lt;\u0026lt;2))\u0026gt;\u0026gt;\u0026gt;2 = %d\\n\u0026#34;, (y+(1\u0026lt;\u0026lt;2))\u0026gt;\u0026gt;2); return 0; } // 输出结果 x / 4 = -12 x \u0026gt;\u0026gt; 2 = -13 (x+(1\u0026lt;\u0026lt;2))\u0026gt;\u0026gt;2 = -12 y / 4 = 12 y \u0026gt;\u0026gt;\u0026gt; 2 = 12 (y+(1\u0026lt;\u0026lt;2))\u0026gt;\u0026gt;\u0026gt;2 = 13 浮点数 考虑如下一段代码：\n// Java public class Example { public static void main(String[] args) { int i = 12345; float f = 12345.0F; System.out.printf(\u0026#34;binary str of i: %s\\n\u0026#34;, int2BinaryStr(i)); System.out.printf(\u0026#34;binary str of f: %s\\n\u0026#34;, float2BinaryStr(f)); } // 将int转为32位的二进制表示  private static String int2BinaryStr(int i) { String str = Integer.toBinaryString(i); for (int len = str.length(); len \u0026lt; 32; len++) { str = \u0026#34;0\u0026#34; + str; } return str; } // 将float转为32位的二进制表示  private static String float2BinaryStr(float f) { return int2BinaryStr(Float.floatToRawIntBits(f)); } } 我们将整数 i = 12345 和 浮点数 f = 12345.0 转成二进制表示，结果如下：\nbinary str of i: 00000000000000000011000000111001 binary str of f: 01000110010000001110010000000000 虽然从十进制上看，值都是 12345，但是两种类型的二进制表示却差别很大，说明，计算机系统对整数和浮点数的处理是两套不同的机制。\n大部分编程语言支持的整数最大是 64 位，但很多场景下，我们需要更大的取值范围，或者是小数运算，这些都是 64 位整数无法满足的场景。为了解决这些，计算机系统引入了 浮点数（Floating Point）类型。\n浮点数类型可以分为 32 位单精度 float/float32 类型和 64 位双精度 double/float64 类型，取值范围如下：\n   类型 最小值 最大值     float -3.40282347E+38 3.40282347E+38   double -1.79769313486231570E+308 1.79769313486231570E+308    虽然浮点数的取值范围很广，但它只能精确表示其中一小部分，其他都是近似表示。\n下面，我们将深入介绍浮点数的二进制表示和近似规则。\n简单浮点数编码 对于十进制数 $d$，可以表示成 $[d_md_{m-1}\\cdot\\cdot\\cdot d_1d_0.d_{-1}d_{-2}\\cdot\\cdot\\cdot d_{-n+1}d_{-n}]_d$，其中，以小数点 $.$ 为界，左边为整数部分，右边为小数部分，那么： $$ d=\\sum_{i=-n}^m10^i\\cdot d_i $$ 比如，$123.45_d=10^2\\cdot1+10^1\\cdot2+10^0\\cdot3+10^{-1}\\cdot4+10^{-2}\\cdot5$ 。\n同理，对于二进制数 $b$，也可以表示成 $[b_mb_{m-1}\\cdot\\cdot\\cdot b_1b_0.b_{-1}b_{-2}\\cdot\\cdot\\cdot b_{-n+1}b_{-n}]_b$，同样以小数点 $.$ 分割整数和小数部分，那么： $$ b=\\sum_{i=-n}^m2^i\\cdot b_i $$ 比如，$101.11_b=2^2\\cdot1+2^1\\cdot0+2^0\\cdot1+2^{-1}\\cdot1+2^{-2}\\cdot1=5.75_d$。\n计算机系统的这种编码方式，注定只能表示 $M*2^E$ 形式的数，其他的，只能近似表示。\n比如，无法精确表示 0.2：\n   二进制表示 分数 十进制表示     0.0 0/2 0.0   0.01 1/4 0.25   0.010 2/8 0.25   0.0011 3/16 0.1875   0.00110 6/32 0.1875   0.001101 13/64 0.203125   0.0011010 26/128 0.203125   0.00110011 51/256 0.19921875    浮点数除了表示小数之外，还须表示大数，按照这种二进制编码思路，如果要表示 $5\\cdot2^{100}$ ，需要 102 位，受限于计算机系统的编码长度，这显然不能接受。\n其实，对 $M\\cdot2^E$ 形式的数，我们只须存储 M 和 E 即可，再来一个符号位 s，即可表示这种形式的数： $V=(-1)^s\\cdot M\\cdot2^E$。\n对 w 位浮点数，可以用 1 位表示 s，用 k 位表示 E，用 n 位表示 M，那么就有 w = 1 + k + n：\n（1）s 的编码\n用 0 表示正数，1 表示负数。\n（2）E 的编码\n二进制表示为 $[e_{k-1}e_{k-2}\u0026hellip;e_1e_0]$，因为 E 可以是正，也可以是负，因此，可以直接用 **补码** 进行编码。\n比如，k = 4 时，E 的取值是这样的：\n   e E     0000 0   0001 1   \u0026hellip;    0111 7   1000 -8   1001 -7   \u0026hellip;    1111 -1    但是，这样从 $[0000]_b$ 到 $[1111]_b$ 并不是递增的趋势。另一种方法是，对 e 用无符号编码，令 $E = e - Bias$，其中，$Bias=2^{k-1}-1$。比如，k = 4 时，$Bias=7$，那么 E 的取值是这样的：\n   e Bias E     0000 7 -7   0001 7 -6   \u0026hellip;     0111 7 0   1000 7 1   1001 7 2   \u0026hellip;     1111 7 8    （3）M 的编码\n二进制表示为 $[m_{n-1}m_{m-2}\u0026hellip;m_1m_0]$，M 不涉及负数，因此可以只用最高位表示整数，其他表示小数，即 $[m_{n-1}.m_{m-2}\u0026hellip;m_1m_0]$。比如，当 n = 3 时，M 的取值如下：\n   m M 十进制     000 0.00 0.00 (0/4)   001 0.01 0.25 (1/4)   010 0.10 0.5 (2/4)   011 0.11 0.75 (3/4)   100 1.00 1.00 (4/4)   101 1.01 1.25 (5/4)   110 1.10 1.5 (6/4)   111 1.11 1.75 (7/4)    但是，这种编码方式会导致 0 有很多种表示。比如 w = 8，k = 4，n = 3 时，因为 $V=(-1)^s\\cdot M\\cdot2^E$，所以 $[00000000]_b$、$[00001000]_b$、$[00010000]_b$ 等的浮点数值都是 0。\n可以这么改动，令 $m=[0.m_{n-1}m_{m-2}\u0026hellip;m_1m_0]$，当 $e = 0$ 时，$M = m$；当 $e\\neq0$ 时，$M=1+m$：\n   e m M 十进制     0000 000 0.000 0.000 (0/8)   0000 001 0.001 0.125 (1/8)   \u0026hellip;      0000 111 0.111 0.875 (7/8)   0001 000 1.000 1.000 (8/8)   0001  1.001 1.125 (9/8)   \u0026hellip;       这样，既解决了 0 的表示问题，又让 M 的精度更大了。\nIEEE 浮点数编码 上述这些编码方法，就是 IEEE 754 Floating-Point Representation 标准中定义的浮点数编码方式的基本思路，标准会在此基础上做了一些调整，比如新增了 Infinity 和 NaN 的表示。\nIEEE 标准将浮点数分成 单精度 和 双精度 两种，分别用 32 位和 64 位表示，它们的 k 值和 n 值都不同：\n在此基础上，根据 e 的取值不同，又分为 3 种场景，Denormalized Values、Normalized Values、Special Values：\n（1）Denormalized Values\n此场景下，e 取值为全 0，用来表示 0 值，以及接近 0 的小数。\n在 $V=(-1)^s\\cdot M\\cdot2^E$ 的表示下，$M = m$，$E = 1 - Bias = 1 - (2^{k-1}-1)$。\n 为什么 ，而不是 E = -Bias？\n根据前文分析，应该有 E = e - Bias，但 Denormalized Values 中，确是 E = 1 - Bias，而不是 E = 0 - Bias。\n这主要考虑到，从 Denormalized Values 到 Normalized Values 的平滑过度，让 Largest Denormalized Value 和 Smallest Normalized Value 更接近。后面的举例可以看到。\n 这里，0 分为了 -0.0 和 +0.0，在一些科学计算场景下，它们会表示不同的含义。比如 $1/(+0.0) =+\\infty$，$1/(-0.0) =-\\infty$。\n（2） Normalized Values\n此场景下，e 取值不是全 0，也不是全 1，是最常见的场景。\n在 $V=(-1)^s\\cdot M\\cdot2^E$ 的表示下，$M = 1 + m = 1.m_{n-1}m_{n-2}\u0026hellip;m_0$，$E = e - Bias = e - (2^{k-1}-1)$。\n（3）Special Values\n此场景下，e 取值为全 1，用来表示 Infinity 和 NaN。\n m 取值全 0，表示 Infinity：1）s = 0 时，为 $+\\infty$；2）s = 1 时，为 $-\\infty$。 m 取值不是全 0，表示 NaN（Not a Number），比如 $\\sqrt{-1}$ 或者 $\\infty-\\infty$。  比如，w = 8，k = 4（此时，$Bias=2^{k-1}-1=7$），n = 3 时，IEEE 标准浮点数的编码例子如下：\n   场景 二进制 e E $2^E$ m M $2^E\\cdot M$ 十进制     Denormalized 0 0000 000 0 -6 1/64 0/8 0/8 0/512 0.0    0 0000 001 0 -6 1/64 1/8 1/8 1/512 0.001953    \u0026hellip;           0 0000 111 0 -6 1/64 7/8 7/8 7/512 0.013672   Normalized 0 0001 000 1 -6 1/64 0/8 8/8 8/512 0.015625    0 0001 001 1 -6 1/64 1/8 9/8 9/512 0.017578    \u0026hellip;           0 1110 111 14 7 128 7/8 15/8 1920/8 240.0   Infinity 0 1111 000 - - - - - - $+\\infty$   NaN 0 1111 001 - - - - - - NaN    \u0026hellip;       NaN     从上表可以看出，因为 Denormalized 场景下令 E = 1 - Bias，从 Denormalized 到 Normalized 的过度，也即 7/512 到 8/512，更平滑了。如果令 E = - Bias，则是从 7/1024 到 8/512，跨度太大。\n 到这里，我们已经深入介绍了 IEEE 浮点数编码格式，回到本节最开始的例子，如何从 int i = 12345 的二进制表示，推断出 float f = 12345.0 的二进制表示？\n 首先，12345 整数的二进制表示为 $11000000111001_b$ ，也可以表示成 $1.1000000111001_b \\cdot 2^{13}$ ，对应到 $V=(-1)^s\\cdot M\\cdot2^E$ 的形式，可以确认 s = 0，E = 13，M = 1.1000000111001。 另外，12345 属于 Normalized 场景，而 float 类型中 k = 8，有，$E = e - (2^{8-1}-1) = 13$，得出 e = 140，按 k 位无符号编码表示为 $[10001100]_b$。 同理，由 $M = 1 + 0.m_{n-1}m_{n-2}\u0026hellip;m_0=1.1000000111001$，float 类型中，n = 23，所以，得出 m 的二进制表示为 $[10000001110010000000000]_b$，注意，**低位补零**。 最后将 s、e、m 按照 float 单精度的编码格式组合起来，就是 $[0\\ 10001100\\ 10000001110010000000000]_b$，也即 12345.0 的二进制编码。  近似规则（Rounding） 前面说过，浮点数只能表示 $M*2^E$ 形式的数，其他的，只能近似表示。\n近似规则，我们最熟悉的是 “四舍五入”，比如，要保留 2 位小数，那么 1.234、1.235、1.236 近似之后分别是 1.23、1.24、1.24。\nIEEE 浮点数标准中，并没采用 “四舍五入” 法，定义了如下 4 种近似规则：\n Round-to-even，往更近的方向靠，如果向上和向下的距离一样，则往偶数的方向靠。 Round-toward-zero，往 0 的方向靠。 Round-down，往更小的方向靠。 Round-up，往更大的方向靠。  比如，下面的例子，需要近似为整数：\n   规则 1.40 1.60 1.50 2.50 -1.50     Round-to-even 1 2 2 2 -2   Round-toward-zero 1 1 1 2 -1   Round-down 1  1  -2   Round-up 2 2  3 -1    这些规则对二进制也生效，比如，Round-to-even 规则，在二进制中，0 代表偶数，1 表示奇数。下面例子中，需要按照 Round-to-even 近似保留 2 位小数：\n   二进制（十进制） 向下 中点 向上 Round-to-even     10.00011 (67/32) 10.00 (64/32) 68/32 10.01 (72/32) 10.00 (64/32)   10.00110 (70/32) 10.00 (64/32) 68/32 10.01 (72/32) 10.01 (72/32)   10.11100 (23/8) 10.11 (22/8) 23/8 11.00 (24/8) 11.00 (24/8)   10.10100 (21/8) 10.10 (20/8) 21/8 10.11 (22/8) 10.10 (20/8)    上述例子中，前 2 个按照就近原则近似；后 2 个处于中点位置，往偶数方向靠。\n注意，IEEE 标准规定 Round-to-even 为默认的近似规则。\n浮点数运算 浮点数的运算规则比较简单，令 $\\oplus$ 指代后一类的运算符，x 和 y 为浮点数类型，那么 $x \\oplus y$ 的运算结果为 $Round(x \\oplus y)$，也即对真实计算结果进行近似。\n注意，算术运算的结合律、分配率在浮点数运算下是不生效的，比如：\n// Java public static void main(String[] args) { // 结合律不满足示例  float f1 = 3.14F; float f2 = 1e10F; float f3 = (f1 + f2) - f2; float f4 = f1 + (f2 - f2); System.out.printf(\u0026#34;(3.14 + 1e10) - 1e10 = %f\\n\u0026#34;, f3); System.out.printf(\u0026#34;3.14 + (1e10 - 1e10) = %f\\n\u0026#34;, f4); // 分配律不满足示例  float f5 = 1e20F; float f6 = f5 * (f5 - f5); float f7 = f5 * f5 - f5 * f5; System.out.printf(\u0026#34;1e20 * (1e20 - 1e20) = %f\\n\u0026#34;, f6); System.out.printf(\u0026#34;1e20 * 1e20 - 1e20 * 1e20 = %f\\n\u0026#34;, f7); } // 输出结果 (3.14 + 1e10) - 1e10 = 0.000000 3.14 + (1e10 - 1e10) = 3.140000 1e20 * (1e20 - 1e20) = 0.000000 1e20 * 1e20 - 1e20 * 1e20 = NaN 结合律例子中，(3.14+1e10)-1e10 结果是 0，因为 3.14 在近似时，精度丢失了，也即 3.14+1e10=1e10；类似，分配律例子中，1e20*1e20 的结果超出了 float 类型的表示范围，得到 Infinity，而 Infinity - Infinity 的结果是 NaN。\n注意，浮点数运算结果，如果超出了浮点数表示范围，会得到 Infinity/-Infinity。这与整数运算的溢出机制有所区别。\n数值类型转换 数值类型间的转换，可以分成 2 类：宽转换（Widening Conversion）和 窄转换（Narrowing Conversion）。\n宽转换指往表示范围更广的类型转换，比如从 int 到 long、从 long 到 float；窄转换则相反。\n整型间转换 （1）宽转换\n整型间的宽转换不会产生溢出，无符号整数场景，高位补零；有符号整数场景，高位补符号位。\n// C++ int main() { int8_t i1 = 100; cout \u0026lt;\u0026lt; \u0026#34;int8_t i1: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;8\u0026gt;(i1) \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; \u0026#34;int16_t i1: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;16\u0026gt;((int16_t) i1) \u0026lt;\u0026lt; endl; int8_t i2 = -100; cout \u0026lt;\u0026lt; \u0026#34;int8_t i2: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;8\u0026gt;(i2) \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; \u0026#34;int16_t i2: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;16\u0026gt;((int16_t) i2) \u0026lt;\u0026lt; endl; uint8_t i3 = 200; cout \u0026lt;\u0026lt; \u0026#34;uint8_t i3: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;8\u0026gt;(i3) \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; \u0026#34;uint16_t i3: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;16\u0026gt;((uint16_t) i3) \u0026lt;\u0026lt; endl; return 0; } // 输出结果 int8_t i1: 01100100 int16_t i1: 0000000001100100 int8_t i2: 10011100 int16_t i2: 1111111110011100 uint8_t i3: 11001000 uint16_t i3: 0000000011001000 （2）窄转换\n整型间的窄转换直接进行高位截断，只保留低 n 位。比如 16 位的 int16 转换为 8 位的 int8，直接保留 int16 类型值的低 8 位作为转换结果。\n// C++ int main() { int16_t i1 = 200; cout \u0026lt;\u0026lt; \u0026#34;int16_t i1: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;16\u0026gt;(i1) \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; \u0026#34;int8_t i1: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;8\u0026gt;((int8_t) i1) \u0026lt;\u0026lt; endl; int16_t i2 = -200; cout \u0026lt;\u0026lt; \u0026#34;int16_t i2: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;16\u0026gt;(i2) \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; \u0026#34;int8_t i2: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;8\u0026gt;((int8_t) i2) \u0026lt;\u0026lt; endl; uint16_t i3 = 300; cout \u0026lt;\u0026lt; \u0026#34;uint16_t i3: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;16\u0026gt;(i3) \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; \u0026#34;uint8_t i3: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;8\u0026gt;((uint8_t) i3) \u0026lt;\u0026lt; endl; return 0; } // 输出结果 int16_t i1: 0000000011001000 int8_t i1: 11001000 int16_t i2: 1111111100111000 int8_t i2: 00111000 uint16_t i3: 0000000100101100 uint8_t i3: 00101100 （3）无符号整数与有符号整数间的转换\n无符号整数与有符号整数间的转换规则是：\n 如果两者二进制位数一致，比如 int8 到 uint8 的转换，则二进制数值不变，只是改变编码方式； 如果位数不一致，比如 int16 到 uint8 的转换，则二进制数值，先按照宽转换或窄转换规则转换，再改变编码方式。  // C++ int main() { uint8_t i1 = 200; cout \u0026lt;\u0026lt; \u0026#34;uint8_t i1, decimal: \u0026#34; \u0026lt;\u0026lt; +i1 \u0026lt;\u0026lt; \u0026#34;, binary: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;8\u0026gt;(i1) \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; \u0026#34;int8_t i1, decimal: \u0026#34; \u0026lt;\u0026lt; +(int8_t) i1 \u0026lt;\u0026lt; \u0026#34;, binary: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;8\u0026gt;((int8_t) i1) \u0026lt;\u0026lt; endl; int16_t i2 = -300; cout \u0026lt;\u0026lt; \u0026#34;int16_t i2, decimal: \u0026#34; \u0026lt;\u0026lt; +i2 \u0026lt;\u0026lt; \u0026#34;, binary: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;16\u0026gt;(i2) \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; \u0026#34;uint8_t i2, decimal: \u0026#34; \u0026lt;\u0026lt; +(uint8_t) i2 \u0026lt;\u0026lt; \u0026#34;, binary: \u0026#34; \u0026lt;\u0026lt; bitset\u0026lt;8\u0026gt;((uint8_t) i2) \u0026lt;\u0026lt; endl; return 0; } // 输出结果 uint8_t i1, decimal: 200, binary: 11001000 int8_t i1, decimal: -56, binary: 11001000 int16_t i2, decimal: -300, binary: 1111111011010100 uint8_t i2, decimal: 212, binary: 11010100 整数与浮点数间转型 （1）宽转换\n整型到浮点数类型的转换这一方向，为宽转换：\n 如果浮点数的精度，能够表示整数，则正常转换。 如果浮点数精度，无法表示整数，则需要近似，会导致精度丢失。  // Java public static void main(String[] args) { int i1 = 1234567; System.out.printf(\u0026#34;int i1: %d, float i1: \u0026#34;, i1); System.out.println((float) i1); int i2 = 123456789; System.out.printf(\u0026#34;int i2: %d, float i2: \u0026#34;, i2); System.out.println((float) i2); } // 输出结果 int i1: 1234567, float i1: 1234567.0 int i2: 123456789, float i2: 1.23456792E8 上述例子中，i2=123456789 超过 float 类型能够表示的精度，所以为近似后的结果 1.23456792E8。\n那么，为什么 123456789 会近似为 1.23456792E8？\n要解释该问题，首先要把它们转换成二进制表示：\npublic static void main(String[] args) { ... System.out.println(\u0026#34;int i2: \u0026#34; + int2BinaryStr(i2)); System.out.println(\u0026#34;float i2: \u0026#34; + float2BinaryStr((float) i2)); } // 输出结果 int i2: 00000111010110111100110100010101 float i2: 01001100111010110111100110100011 接下来，我们根据 IEEE 浮点数的编码规则，尝试将 int i2 转换成 float i2：\n int i2 的二进制 $00000111010110111100110100010101_b$，可以写成 $1.11010110111100110100010101\\cdot2^{26}$，对应到 $V=(-1)^s\\cdot M\\cdot2^E$ 的形式，可以确认 s = 0，E = 26，M = 1.11010110111100110100010101。 float 类型中 k = 8，有，$E = e - (2^{8-1}-1) = 26$，得出 e = 153，按 k 位无符号编码表示为 $[10011001]_b$。 同理，由 $M = 1 + 0.m_{n-1}m_{n-2}\u0026hellip;m_0=1.11010110111100110100010101$，但由于 float 类型的 n = 23，而 m 一共有 26 位，因此需要按照 round-to-even 规则，对 0.11010110111100110100010101进行近似，保留 23 位小数，得到 0.11010110111100110100011，所以 m 为 $[11010110111100110100011]_b$ 最后，将 s、e、m 按照 float 单精度的编码格式组合起来，就是 $[0\\ 10011001\\ 11010110111100110100011]_b$，转换成十进制，就是 1.23456792E8。  （2）窄转换\n浮点数类型到整型的转换这一方向，为窄转换：\n 如果浮点数的整数部分，能够用整型表示，则直接舍去小数，保留整数部分。 如果超出了整型范围，则结果为该整型的最大/最小值。  // Java public static void main(String[] args) { float f1 = 12345.123F; System.out.print(\u0026#34;float f1: \u0026#34;); System.out.print(f1); System.out.printf(\u0026#34;, int f1: %d\\n\u0026#34;, (int) f1); float f2 = 1.2345E20F; System.out.print(\u0026#34;float f2: \u0026#34;); System.out.print(f2); System.out.printf(\u0026#34;, int f2: %d\\n\u0026#34;, (int) f2); float f3 = -1.2345E20F; System.out.print(\u0026#34;float f3: \u0026#34;); System.out.print(f3); System.out.printf(\u0026#34;, int f3: %d\\n\u0026#34;, (int) f3); } // 输出结果 float f1: 12345.123, int f1: 12345 float f2: 1.2345E20, int f2: 2147483647 float f3: -1.2345E20, int f3: -2147483648 浮点数间转型 （1）宽转换\n单精度 float 到 双精度 double 为宽转换，不会出现精度丢失的问题。\n对于 $V=(-1)^s\\cdot M\\cdot2^E$ ，规则如下：\n s 保持不变。 在 E 保持不变的前提下，因为 float 的 k = 8，而 double 的 k = 11，所以两者的 e 会有所不同。 在 M 保持不变的前提下，float 的 n = 23，而 double 的 n =52，所以 m 需要低位补 52 - 23 = 29 个 0。  // Java public static void main(String[] args) { float f1 = 1.2345E20F; System.out.print(\u0026#34;float f1: \u0026#34;); System.out.print(f1); System.out.print(\u0026#34;, double f1: \u0026#34;); System.out.println((double) f1); System.out.println(\u0026#34;float f1: \u0026#34; + float2BinaryStr(f1)); System.out.println(\u0026#34;double f1: \u0026#34; + double2BinaryStr((double) f1)); } // 输出结果 float f1: 1.2345E20, double f1: 1.2344999897320129E20 float f1: 01100000110101100010011011010000 double f1: 0100010000011010110001001101101000000000000000000000000000000000 （2）窄转换\ndouble 到 float 为窄转换，会存在精度丢失问题。\n如果 double 值超出了 float 的表示范围，则转换结果为 Infinity：\n// Java public static void main(String[] args) { double d1 = 1E200; System.out.print(\u0026#34;double d1: \u0026#34;); System.out.println(d1); System.out.print(\u0026#34;float d1: \u0026#34;); System.out.println((float) d1); double d2 = -1E200; System.out.print(\u0026#34;double d2: \u0026#34;); System.out.println(d2); System.out.print(\u0026#34;float d2: \u0026#34;); System.out.println((float) d2); } // 输出结果 double d1: 1.0E200 float d1: Infinity double d2: -1.0E200 float d2: -Infinity 如果 double 值还在 float 的表示范围内，则按照如下转换规则：\n s 保持不变。 在 E 保持不变的前提下，因为 float 的 k = 8，而 double 的 k = 11，所以两者的 e 会有所不同。 对于 M，因为 float 的 n = 23，而 double 的 n = 52，所以转换到 float 之后，需要进行截断，只保留高 23 位。  // Java public static void main(String[] args) { double d1 = 3.267393471324506; System.out.print(\u0026#34;double d1: \u0026#34;); System.out.println(d1); System.out.print(\u0026#34;float d1: \u0026#34;); System.out.println((float) d1); System.out.println(\u0026#34;double d1: \u0026#34; + double2BinaryStr(d1)); System.out.println(\u0026#34;float d1: \u0026#34; + float2BinaryStr((float) d1)); } // 输出结果 double d1: 3.267393471324506 float d1: 3.2673936 double d1: 0100000000001010001000111001111100110000001101000000010101110110 float d1: 01000000010100010001110011111010 最后 本文花了很长的篇幅，深入介绍了计算机系统对数值类型的编码、运算、转换的底层原理。\n数值类型间的转换是最容易出现隐藏 bug 的地方，特别是无符号整数与有符号整数之间的转换。所以，很多现代的编程语言，如 Java、Go 等都不再支持无符号整数，根除了该隐患。\n另外，浮点数的编码方式，注定它只能精确表示一小部分的数值范围，大部分都是近似，所以才有了不能用等号来比较两个浮点数的说法。\n数值类型虽然很基础，但使用时一定要多加小心。希望本文能够加深你对数值类型的理解，让你写出更健壮的程序。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] Computer Systems: A Programmer\u0026rsquo;s Perspective (3rd edition), Randal E. Bryant .etc\n[2] The Java® Language Specification (Java SE 18 Edition), James Gosling .etc\n[3] IEEE Standard 754 Floating-Point Representation, IEEE\n[4] 漫话：为什么计算机用补码存储数据？, 漫话编程\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-08-18T00:00:00Z","permalink":"https://yrunz.com/p/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F%E7%9A%84%E6%95%B0%E5%80%BC%E7%B1%BB%E5%9E%8B/","title":"深入理解计算机系统的数值类型"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：观察者模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 有时会遇到这样的需求，开发一个模块，用于保存对象；不能用简单的数组、列表，得是红黑树、跳表等较为复杂的数据结构；有时为了提升存储效率或持久化，还得将对象序列化；但必须给客户端提供一个易用的 API，允许方便地、多种方式地遍历对象，丝毫不察觉背后的数据结构有多复杂。\n对这样的 API，很适合使用 迭代器模式（Iterator Pattern）实现。\nGoF 对 迭代器模式 的定义如下：\n Provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation.\n 从描述可知，迭代器模式主要用在访问对象集合的场景，能够向客户端隐藏集合的实现细节。\nJava 的 Collection 家族、C++ 的 STL 标准库，都是使用迭代器模式的典范，它们为客户端提供了简单易用的 API，并且能够根据业务需要实现自己的迭代器，具备很好的可扩展性。\nUML 结构 场景上下文 在 简单的分布式应用系统（示例代码工程）中，db 模块用来存储服务注册和监控信息，它的主要接口如下：\n// demo/db/db.go package db // Db 数据库抽象接口 type Db interface { CreateTable(t *Table) error CreateTableIfNotExist(t *Table) error DeleteTable(tableName string) error Query(tableName string, primaryKey interface{}, result interface{}) error Insert(tableName string, primaryKey interface{}, record interface{}) error Update(tableName string, primaryKey interface{}, record interface{}) error Delete(tableName string, primaryKey interface{}) error ... } 从增删查改接口可以看出，它是一个 key-value 数据库，另外，为了提供类似关系型数据库的按列查询能力，我们又抽象出 Table 对象：\n// demo/db/table.go package db // Table 数据表定义 type Table struct { name string recordType reflect.Type records map[interface{}]record } 其中，Table 底层用 map 存储对象数据，但并没有存储对象本身，而是从对象转换而成的 record 。record 的实现原理是利用反射机制，将对象的属性名 field 和属性值 value 分开存储，以此支持按列查询能力（一类对象可以类比为一张表）：\n// demo/db/record.go package db type record struct { primaryKey interface{} fields map[string]int // key为属性名，value属性值的索引  values []interface{} // 存储属性值 } // 从对象转换成record func recordFrom(key interface{}, value interface{}) (r record, e error) { ... // 异常处理  vType := reflect.TypeOf(value) vVal := reflect.ValueOf(value) if vVal.Type().Kind() == reflect.Pointer { vType = vType.Elem() vVal = vVal.Elem() } record := record{ primaryKey: key, fields: make(map[string]int, vVal.NumField()), values: make([]interface{}, vVal.NumField()), } for i := 0; i \u0026lt; vVal.NumField(); i++ { fieldType := vType.Field(i) fieldVal := vVal.Field(i) name := strings.ToLower(fieldType.Name) record.fields[name] = i record.values[i] = fieldVal.Interface() } return record, nil } 当然，客户端并不会察觉 db 模块背后的复杂机制，它们直接使用的仍是对象：\ntype testRegion struct { Id int Name string } func client() { mdb := db.MemoryDbInstance() tableName := \u0026#34;testRegion\u0026#34; table := NewTable(tableName).WithType(reflect.TypeOf(new(testRegion))) mdb.CreateTable(table) mdb.Insert(tableName, \u0026#34;region1\u0026#34;, \u0026amp;testRegion{Id: 0, Name: \u0026#34;region-1\u0026#34;}) result := new(testRegion) mdb.Query(tableName, \u0026#34;region1\u0026#34;, result) } 另外，除了上述按 Key 查询接口，我们还想提供全表查询接口，有随机和有序 2 种表记录遍历方式，并且支持客户端自己扩展遍历方式。下面使用迭代器模式来实现该需求。\n代码实现 这里并没有按照标准的 UML 结构去实现，而是结合 工厂方法模式 来解决公共代码的复用问题：\n// demo/db/table_iterator.go package db // 关键点1: 定义迭代器抽象接口，允许后续客户端扩展遍历方式 // TableIterator 表迭代器接口 type TableIterator interface { HasNext() bool Next(next interface{}) error } // 关键点2: 定义迭代器接口的实现 // tableIteratorImpl 迭代器接口公共实现类 type tableIteratorImpl struct { // 关键点3: 定义一个集合存储待遍历的记录，这里的记录已经排序好或者随机打散  records []record // 关键点4: 定义一个cursor游标记录当前遍历的位置  cursor int } // 关键点5: 在HasNext函数中的判断是否已经遍历完所有记录 func (r *tableIteratorImpl) HasNext() bool { return r.cursor \u0026lt; len(r.records) } // 关键点6: 在Next函数中取出下一个记录，并转换成客户端期望的对象类型，记得增加cursor func (r *tableIteratorImpl) Next(next interface{}) error { record := r.records[r.cursor] r.cursor++ if err := record.convertByValue(next); err != nil { return err } return nil } // 关键点7: 通过工厂方法模式，完成不同类型的迭代器对象创建 // TableIteratorFactory 表迭代器工厂 type TableIteratorFactory interface { Create(table *Table) TableIterator } // 随机迭代器 type randomTableIteratorFactory struct{} func (r *randomTableIteratorFactory) Create(table *Table) TableIterator { var records []record for _, r := range table.records { records = append(records, r) } rand.Seed(time.Now().UnixNano()) rand.Shuffle(len(records), func(i, j int) { records[i], records[j] = records[j], records[i] }) return \u0026amp;tableIteratorImpl{ records: records, cursor: 0, } } // 有序迭代器 // Comparator 如果i\u0026lt;j返回true，否则返回false type Comparator func(i, j interface{}) bool // sortedTableIteratorFactory 根据主键进行排序，排序逻辑由Comparator定义 type sortedTableIteratorFactory struct { comparator Comparator } func (s *sortedTableIteratorFactory) Create(table *Table) TableIterator { var records []record for _, r := range table.records { records = append(records, r) } sort.Sort(newRecords(records, s.comparator)) return \u0026amp;tableIteratorImpl{ records: records, cursor: 0, } } 最后，为 Table 对象引入 TableIterator：\n// demo/db/table.go  // Table 数据表定义 type Table struct { name string recordType reflect.Type records map[interface{}]record // 关键点8: 持有迭代器工厂方法接口  iteratorFactory TableIteratorFactory // 默认使用随机迭代器 } // 关键点9: 定义Setter方法，提供迭代器工厂的依赖注入 func (t *Table) WithTableIteratorFactory(iteratorFactory TableIteratorFactory) *Table { t.iteratorFactory = iteratorFactory return t } // 关键点10: 定义创建迭代器的接口，其中调用迭代器工厂完成实例化 func (t *Table) Iterator() TableIterator { return t.iteratorFactory.Create(t) } 客户端这样使用：\nfunc client() { table := NewTable(\u0026#34;testRegion\u0026#34;).WithType(reflect.TypeOf(new(testRegion))). WithTableIteratorFactory(NewSortedTableIteratorFactory(regionIdComparator)) iter := table.Iterator() for iter.HashNext() { next := new(testRegion) err := iter.Next(next) ... } } 总结实现迭代器模式的几个关键点：\n 定义迭代器抽象接口，目的是提供客户端自扩展能力，通常包含 HashNext() 和 Next() 两个方法，上述例子为 TableIterator。 定义迭代器接口的实现类，上述例子为 tableIteratorImpl，这里主要起到了 Java/C++ 等带继承特性语言中，基类的作用，目的是复用代码。 在实现类中持有待遍历的记录集合，通常是已经排序好或随机打散后的，上述例子为 tableIteratorImpl.records。 在实现类中持有游标值，记录当前遍历的位置，上述例子为 tableIteratorImpl.cursor。 在 HashNext() 方法中判断是否已经遍历完所有记录。 在 Next() 方法中取出下一个记录，并转换成客户端期望的对象类型，取完后增加游标值。 通过工厂方法模式，完成不同类型的迭代器对象创建，上述例子为 TableIteratorFactory 接口，以及它的实现，randomTableIteratorFactory 和 sortedTableIteratorFactory。 在待遍历的对象中，持有迭代器工厂方法接口，上述例子为 Table.iteratorFactory。 为对象定义 Setter 方法，提供迭代器工厂的依赖注入，上述例子为 Table.WithTableIteratorFactory() 方法。 为对象定义创建迭代器的接口，上述例子为 Table.Iterator() 方法。  其中，7～9 步是结合 工厂方法模式 实现时的特有步骤，如果你的迭代器实现中没有用到工厂方法模式，可以省略这几步。\n扩展 Go 风格的实现 前面的实现，是典型的面向对象风格，下面以随机迭代器为例，给出一个 Go 风格的实现：\n// demo/db/table_iterator_closure.go package db // 关键点1: 定义HasNext和Next函数类型 type HasNext func() bool type Next func(interface{}) error // 关键点2: 定义创建迭代器的方法，返回HashNext和Next函数 func (t *Table) ClosureIterator() (HasNext, Next) { var records []record for _, r := range t.records { records = append(records, r) } rand.Seed(time.Now().UnixNano()) rand.Shuffle(len(records), func(i, j int) { records[i], records[j] = records[j], records[i] }) size := len(records) cursor := 0 // 关键点3: 在迭代器创建方法定义HasNext和Next的实现逻辑  hasNext := func() bool { return cursor \u0026lt; size } next := func(next interface{}) error { record := records[cursor] cursor++ if err := record.convertByValue(next); err != nil { return err } return nil } return hasNext, next } 客户端这样用：\nfunc client() { table := NewTable(\u0026#34;testRegion\u0026#34;).WithType(reflect.TypeOf(new(testRegion))). WithTableIteratorFactory(NewSortedTableIteratorFactory(regionIdComparator)) hasNext, next := table.ClosureIterator() for hasNext() { result := new(testRegion) err := next(result) ... } } Go 风格的实现，利用了函数闭包的特点，把原本在迭代器实现的逻辑，放到了迭代器创建方法上。相比面向对象风格，省掉了迭代器抽象接口和实现对象的定义，看起来更加的简洁。\n总结几个实现关键点：\n 声明 HashNext 和 Next 的函数类型，等同于迭代器抽象接口的作用。 定义迭代器创建方法，返回类型为 HashNext 和 Next，上述例子为 ClosureIterator() 方法。 在迭代器创建方法内，定义 HasNext 和 Next 的具体实现，利用函数闭包来传递状态（records 和 cursor）。  基于 channel 的实现 我们还能基于 Go 语言中的 channel 来实现迭代器模式，因为前文的 db 模块应用场景并不适用，所以另举一个简单的例子：\ntype Record int func (r *Record) doSomething() { // ... } type ComplexCollection struct { records []Record } // 关键点1: 定义迭代器创建方法，返回只能接收的channel类型 func (c *ComplexCollection) Iterator() \u0026lt;-chan Record { // 关键点2: 创建一个无缓冲的channel  ch := make(chan Record) // 关键点3: 另起一个goroutine往channel写入记录，如果接收端还没开始接收，会阻塞住  go func() { for _, record := range c.records { ch \u0026lt;- record } // 关键点4: 写完后，关闭channel  close(ch) }() return ch } 客户端这样使用：\nfunc client() { collection := NewComplexCollection() // 关键点5: 使用时，直接通过for-range来遍历channel读取记录  for record := range collection.Iterator() { record.doSomething() } } 总结实现基于 channel 的迭代器模式的几个关键点：\n 定义迭代器创建方法，返回一个只能接收的 channel。 在迭代器创建方法中，定义一个无缓冲的 channel。 另起一个 goroutine 往 channel 中写入记录。如果接收端没有接收，会阻塞住。 写完后，关闭 channel。 客户端使用时，直接通过 for-range 遍历 channel 读取记录即可。  带有 callback 函数的实现 还可以在创建迭代器时，传入一个 callback 函数，在迭代器返回记录前，先调用 callback 函数对记录进行一些操作。\n比如，在基于 channel 的实现例子中，可以增加一个 callback 函数，将每个记录打印出来：\n// 关键点1: 声明callback函数类型，以Record作为入参 type Callback func(record *Record) //关键点2: 定义具体的callback函数 func PrintRecord(record *Record) { fmt.Printf(\u0026#34;%+v\\n\u0026#34;, record) } // 关键点3: 定义以callback函数作为入参的迭代器创建方法 func (c *ComplexCollection) Iterator(callback Callback) \u0026lt;-chan Record { ch := make(chan Record) go func() { for _, record := range c.records { // 关键点4: 遍历记录时，调用callback函数作用在每条记录上  callback(\u0026amp;record) ch \u0026lt;- record } close(ch) }() return ch } func client() { collection := NewComplexCollection() // 关键点5: 创建迭代器时，传入具体的callback函数  for record := range collection.Iterator(PrintRecord) { record.doSomething() } } 总结实现带有 callback 的迭代器模式的几个关键点：\n 声明 callback 函数类型，以 Record 作为入参。 定义具体的 callback 函数，比如上述例子中打印记录的 PrintRecord 函数。 定义迭代器创建方法，以 callback 函数作为入参。 迭代器内，遍历记录时，调用 callback 函数作用在每条记录上。 客户端创建迭代器时，传入具体的 callback 函数。  典型应用场景   对象集合/存储类模块，并希望向客户端隐藏模块背后的复杂数据结构。\n  希望支持客户端自扩展多种遍历方式。\n  优缺点 优点   隐藏模块背后复杂的实现机制，为客户端提供一个简单易用的接口。\n  支持扩展多种遍历方式，具备较强的可扩展性，符合 开闭原则。\n  遍历算法和数据存储分离，符合 单一职责原则。\n  缺点  容易滥用，比如给简单的集合类型实现迭代器接口，反而使代码更复杂。 相比于直接遍历集合，迭代器效率要更低一些，因为涉及到更多对象的创建，以及可能的对象拷贝。 需要时刻注意在迭代器遍历过程中，由原始集合发生变更引发的并发问题。一种解决方法是，在创建迭代器时，拷贝一份原始数据（TableIterator 就这么实现），但存在效率低、内存占用大的问题。  与其他模式的关联 迭代器模式通常会与 工厂方法模式 一起使用，如前文实现。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] 【Go实现】实践GoF的23种设计模式：工厂方法模式, 元闰子\n[3] Design Patterns, Chapter 5. Behavioral Patterns, GoF\n[4] Iterators in Go, Ewen Cheslack-Postava\n[5] 迭代器模式, refactoringguru.cn\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-07-30T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E8%BF%AD%E4%BB%A3%E5%99%A8%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：迭代器模式"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：装饰者模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 现在有 2 个服务，Service A 和 Service B，通过 REST 接口通信；Service A 在某个业务场景下调用 Service B 的接口完成一个计算密集型任务，假设接口为 http://service_b/api/v1/domain；该任务运行时间很长，但 Service A 不想一直阻塞在接口调用上。为了满足 Service A 的要求，通常有 2 种方案：\n  Service A 隔一段时间调用一次 Service B 的接口，如果任务还没完成，就返回 HTTP Status 102 Processing；如果已完成，则返回 HTTP Status 200 Ok。\n  Service A 在请求 Service B 接口时带上 callback uri，比如 http://service_b/api/v1/domain?callbackuri=http://service_a/api/v1/domain，Service B 收到请求后立即返回 HTTP Status 200 Ok，等任务完成后再调用 Service A callback uri 进行通知。\n  方案 1 须要轮询接口，轮询太频繁会导致资源浪费，间隔太长又会导致任务完成后 Service A 无法及时感知。显然，方案 2 更加高效，因此也被广泛应用。\n方案 2 用到的思想就是本文要介绍的观察者模式（Observer Pattern），GoF 对它的定义如下：\n Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.\n 我们将观察者称为 Observer，被观察者（或主体）称为 Subject，那么 Subject 和 Observer 是一对多的关系，当 Subject 状态变更时，所有的 Observer 都会被通知到。\nUML 结构 场景上下文 在 简单的分布式应用系统（示例代码工程）中，应用之间通过 network 模块来通信，其中通信模型采用观察者模式：\n从上图可知，App 直接依赖 http 模块，而 http 模块底层则依赖 socket 模块：\n 在 App2 初始化时，先向 http 模块注册一个 request handler，处理 App1 发送的 http 请求。 http 模块会将 request handler 转换为 packet handler 注册到 socket 模块上。 App 1 发送 http 请求，http 模块将请求转换为 socket packet 发往 App 2 的 socket 模块。 App 2 的 socket 模块收到 packet 后，调用 packet handler 处理该报文；packet handler 又会调用 App 2 注册的 request handler 处理该请求。  在上述 socket - http - app 三层模型 中，对 socket 和 http，socket 是 Subject，http 是 Observer；对 http 和 app，http 是 Subject，app 是 Observer。\n代码实现 因为在观察者模式的实现上，socket 模块和 http 模块类似，所以，下面只给出 socket 模块的实现：\n// demo/network/socket.go package network // 关键点1: 定义Observer接口 // SocketListener Socket报文监听者 type SocketListener interface { // 关键2: 为Observer定义更新处理方法，入参为相关的上下文对象 \tHandle(packet *Packet) error } // Subject接口 // Socket 网络通信Socket接口 type Socket interface { // Listen 在endpoint指向地址上起监听 \tListen(endpoint Endpoint) error // Close 关闭监听 \tClose(endpoint Endpoint) // Send 发送网络报文 \tSend(packet *Packet) error // Receive 接收网络报文 \tReceive(packet *Packet) // AddListener 增加网络报文监听者 \tAddListener(listener SocketListener) } // 关键点3: 定义Subject对象 // socketImpl Socket的默认实现 type socketImpl struct { // 关键点4: 在Subject中持有Observer的集合 \tlisteners []SocketListener } // 关键点5: 为Subject定义注册Observer的方法 func (s *socketImpl) AddListener(listener SocketListener) { s.listeners = append(s.listeners, listener) } // 关键点6: 当Subject状态变更时，遍历Observers集合，调用它们的更新处理方法 func (s *socketImpl) Receive(packet *Packet) { for _, listener := range s.listeners { listener.Handle(packet) } } ... 总结实现观察者模式的几个关键点：\n 定义 Observer 接口，上述例子中为 SocketListener 接口。 为 Observer 接口定义状态更新的处理方法，其中方法入参为相关的上下文对象。上述例子为 Handle 方法，上下文对象为 Packet。 定义 Subject 对象，上述例子为 socketImpl 对象。当然，也可以先将 Subject 抽象为接口，比如上述例子中的 Socket 接口，但大多数情况下都不是必须的。 在 Subject 对象中，持有 Observer 接口的集合，上述例子为 listeners 属性。让 Subject 依赖 Observer 接口，能够使 Subject 与具体的 Observer 实现解耦，提升代码的可扩展性。 为 Subject 对象定义注册 Observer 的方法，上述例子为 AddListener 方法。 当 Subject 状态变更时，遍历 Observer 集合，并调用它们的状态更变处理方法，上述例子为 Receive 方法。  扩展 发布-订阅模式 与观察者模式相近的，是发布-订阅模式（Pub-Sub Pattern），很多人会把两者等同，但它们之间还是有些差异。\n从前文的观察者模式实现中，我们发现 Subject 持有 Observer 的引用，当状态变更时，Subject 直接调用 Observer 的更新处理方法完成通知。也就是，Subject 知道有哪些 Observer，也知道 Observer 的数量：\n在发布-订阅模式中，我们将发布方称为 Publisher，订阅方称为 Subscriber，不同于观察者模式，Publisher 并不直接持有 Subscriber 引用，它们之间通常通过 Broker 来完成解耦。也即，Publisher 不知道有哪些 Subscriber，也不知道 Subscriber 的数量：\n发布-订阅模式被广泛应用在消息中间件的实现上，比如 Apache Kafka 基于 Topic 实现了发布-订阅模式，发布方称为 Producer，订阅方称为 Consumer。\n下面，我们通过 简单的分布式应用系统（示例代码工程）中的 mq 模块，展示一个简单的发布-订阅模式实现，在该实现中，我们将 Publisher 的 produce 方法和 Subscriber 的 consume 方法都合并到 Broker 中：\n// demo/mq/memory_mq.go  // 关键点1: 定义通信双方交互的消息，携带topic信息 // Message 消息队列中消息定义 type Message struct { topic Topic payload string } // 关键点2: 定义Broker对象 // memoryMq 内存消息队列，通过channel实现 type memoryMq struct { // 关键点3: Broker中维持一个队列的map，其中key为topic，value为queue，go语言通常用chan实现。 \tqueues sync.Map // key为Topic，value为chan *Message，每个topic单独一个队列 } // 关键点4: 为Broker定义Produce方法，根据消息中的topic选择对应的queue发布消息 func (m *memoryMq) Produce(message *Message) error { record, ok := m.queues.Load(message.Topic()) if !ok { q := make(chan *Message, 10000) m.queues.Store(message.Topic(), q) record = q } queue, ok := record.(chan *Message) if !ok { return errors.New(\u0026#34;model\u0026#39;s type is not chan *Message\u0026#34;) } queue \u0026lt;- message return nil } // 关键点5: 为Broker定义Consume方法，根据topic选择对应的queue消费消息 func (m *memoryMq) Consume(topic Topic) (*Message, error) { record, ok := m.queues.Load(topic) if !ok { q := make(chan *Message, 10000) m.queues.Store(topic, q) record = q } queue, ok := record.(chan *Message) if !ok { return nil, errors.New(\u0026#34;model\u0026#39;s type is not chan *Message\u0026#34;) } return \u0026lt;-queue, nil } 客户端使用时，直接调用 memoryMq 的 Produce 方法和 Consume 方法完成消息的生产和消费：\n// 发布方 func publisher() { msg := NewMessage(\u0026#34;test\u0026#34;, \u0026#34;hello world\u0026#34;) err := MemoryMqInstance().Produce(msg) assert.Nil(t, err) } // 订阅方 func subscriber() { result, err := MemoryMqInstance().Consume(\u0026#34;test\u0026#34;) assert.Nil(err) assert.Equal(t, \u0026#34;hello world\u0026#34;, result.payload) } 总结实现发布-订阅模式的几个关键点：\n 定义通信双方交互的消息，携带 topic 信息，上述例子为 Message 对象。 定义 Broker 对象，Broker 是缓存消息的地方，上述例子为 memoryMq 对象。 在 Broker 中维持一个队列的 map，其中 key 为 topic，value 为 queue，go 语言通常用 chan 来实现 queue，上述例子为 queues 属性。 为 Broker 定义 produce 方法，根据消息中的 topic 选择对应的 queue 发布消息，上述例子为 Produce 方法。 为 Broker 定义 consume 方法，根据 topic 选择对应的 queue 消费消息，上述例子为 Consume 方法。  Push 模式 VS Pull 模式 实现观察者模式和发布-订阅模式时，都会涉及到 Push 模式或 Pull 模式的选取。所谓 Push 模式，指的是 Subject/Publisher 直接将消息推送给 Observer/Subscriber；所谓 Pull 模式，指的是 Observer/Subscriber 主动向 Subject/Publisher 拉取消息：\nPush 模式和 Pull 模式的选择，取决于通信双方处理消息的速率大小。\n如果 Subject/Publisher 方生产消息的速率要比 Observer/Subscriber 方处理消息的速率小，可以选择 Push 模式，以求得更高效、及时的消息传递；相反，如果 Subject/Publisher 方产生消息的速率要大，就要选择 Pull 模式，由 Observer/Subscriber 方决定消息的消费速率，否则可能导致 Observer/Subscriber 崩溃。\nPull 模式有个缺点，如果当前无消息可处理，将导致 Observer/Subscriber 空轮询，可以采用类似 Kafka 的解决方案：让 Observer/Subscriber 阻塞一定时长，让出 CPU，避免长期无效的 CPU 空转。\n典型应用场景  需要监听某个状态的变更，且在状态变更时，通知到监听者。 web 框架。很多 web 框架都用了观察者模式，用户注册请求 handler 到框架，框架收到相应请求后，调用 handler 完成处理逻辑。 消息中间件。如 Kafka、RocketMQ 等。  优缺点 优点   消息通信双方解耦。观察者模式通过依赖接口达到松耦合；发布-订阅模式则通过 Broker 达到解耦目的。\n  支持广播通信。\n  可基于 topic 来达到指定消费某一类型消息的目的。\n  缺点  通知 Observer/Subscriber 的顺序是不确定的，应用程序不应该依赖通知顺序来保证业务逻辑的正确性。 广播通信场景，需要 Observer/Subscriber 自己去判断是否需要处理该消息，否则容易导致 unexpected update。  与其他模式的关联 观察者模式和发布-订阅模式中的 Subject 和 Broker，通常都会使用 单例模式 来确保它们全局唯一。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] 【Go实现】实践GoF的23种设计模式：单例模式, 元闰子\n[3] Design Patterns, Chapter 5. Behavioral Patterns, GoF\n[4] 观察者模式, refactoringguru.cn\n[5] 观察者模式 vs 发布订阅模式, 柳树\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-07-23T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E8%A7%82%E5%AF%9F%E8%80%85%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：观察者模式"},{"content":"第一次接触篮球，大概是五年级的时候。那段时间跟大哥唯一的一次打架后，冷战了好久。不知老爸是不是察觉到什么，也许是临时兴起，给我们买了个篮球。球摸到了，兄弟也和好了。\n小学时候打篮球，热情是有的，常常早上 5 点多起床就去，但对篮球这项运动没什么概念。记得第一次比赛，替补上场，愣是在中场当了 2 个回合的立棍，不参与进攻，也不参与防守，就被教练急忙换下了。\n初中时候，身边都是些“要身体有身体，要技术有技术”的怪兽们，适合在场下静静地看，看他们飞天遁地的表演。偶尔还能偷学到一两招，转头就能在第二天用上。那时候的快乐很简单，跟同学们一起下课、打球，还常常叫嚣着要跟“南门头火箭队”决一胜负，只是一直没等来那场决战。\n高中时候，除了学习，还是学习，也就偶尔能在体育课上玩玩，次数也屈指可数。\n终于等到大学，逃不过男大学生的三点一线：上课、打球、游戏。一年级的学院新生杯比赛，我们一路打到决赛，面对的是不久前刚赢过我们的 S 班，但靠着 MVP 的稳定发挥，我们艰难拿下冠军。那是第一次冠军，也是最后一次。后来，我们的 MVP 专心搞学术了，而我们班，也再没达到原来的高度。\n后面来到北校区，遇到了一群很会打球的师兄们，第一次看他们用无球挡制造得分机会时，心里感叹，“篮球原来还能这么打！”。慢慢地，我们几个也有样学样，无球挡、跑位、内切、传球\u0026hellip;；你尽管跑，人到球到；接到传球，先大喊一声，“好球！”；投不进，懊恼的不是没得分，而是浪费了队友那一手好传球。\n偶尔也会遇到一些人，埋怨道，“怎么这么多挡，练过的吧”。\n窃喜，心想，“哪有，一起打久了，默契而已”。\n有些人以得分、打进高难度球为乐；我们，以分享球、打出流畅配合为乐。我想，这就是所谓的兄弟篮球吧。\n如果挑一个篮球生涯的巅峰期，得是研一时候。本科的好多伙伴都还在，兄弟篮球愈发成熟，正好遇上学校研究生篮球比赛。\n第一场，就遇上了实力很强的 J 学院，记得他们队中还有个校队大魔王。那场，我们打得很强硬，也很有韧性，就憋着一股气要赢；连讨厌抢篮板的我都去拼篮板了；赢球，也顺理成章了。\n过了第一场，后面几场轻松很多，下次再遇到强劲的对手，是半决赛的 C 学院。\n比赛打到最后几秒，落后 1 分，我方球权；\n底线发球，Z 爷接球，转身，摆脱，抛投；\n学生时代总有些遗憾，那一球，就是我们的遗憾之一。\n再后来，大家各自工作，也很难再遇上这样的一群家伙了。加上工作繁忙，几个月没打一次，偶尔几个聚起来玩玩，也没了以前那般体力。于是，打球的次数越来越少，想着再找不回以前打球的快乐了吧。\n前段时间工作换了新环境，没那么忙了，打球的次数变多了。遇到了新的伙伴们，无球挡、内切、传球竟也慢慢都有了，篮球的快乐好像又找回来了。\n大学时候，经常会在球场上遇到这样的几个人：他们满头白发；总是站在罚球线附近指挥；偶尔中投几个；更多的是各种使坏的小动作。\n以前很讨厌他们。\n现在想想，等老了，要是能成为他们这样，好像也挺不错的。\n（完）\n","date":"2022-07-08T00:00:00Z","permalink":"https://yrunz.com/p/%E5%B1%9E%E4%BA%8E%E7%AF%AE%E7%90%83%E7%9A%84%E5%BF%AB%E4%B9%90%E5%8F%88%E5%9B%9E%E6%9D%A5%E4%BA%86/","title":"属于篮球的快乐又回来了"},{"content":"前言 SQL 中 Group By 语句大家都很熟悉，根据指定的规则对数据进行分组，常常和聚合函数一起使用。\n比如，考虑有表 dealer，表中数据如下：\n   id (Int) city (String) car_model (String) quantity (Int)     100 Fremont Honda Civic 10   100 Fremont Honda Accord 15   100 Fremont Honda CRV 7   200 Dublin Honda Civic 20   200 Dublin Honda Accord 10   200 Dublin Honda CRV 3   300 San Jose Honda Civic 5   300 San Jose Honda Accord 8    如果执行 SQL 语句 SELECT id, sum(quantity) FROM dealer GROUP BY id ORDER BY id，会得到如下结果：\n+---+-------------+ |id|sum(quantity)|+---+-------------+ |100|32||200|33||300|13|+---+-------------+ 上述 SQL 语句的意思就是对数据按 id 列进行分组，然后在每个分组内对 quantity 列进行求和。\nGroup By 语句除了上面的简单用法之外，还有更高级的用法，常见的是 Grouping Sets、RollUp 和 Cube，它们在 OLAP 时比较常用。其中，RollUp 和 Cube 都是以 Grouping Sets 为基础实现的，因此，弄懂了 Grouping Sets，也就理解了 RollUp 和 Cube 。\n本文首先简单介绍 Grouping Sets 的用法，然后以 Spark SQL 作为切入点，深入解析 Grouping Sets 的实现机制。\n Spark SQL 是 Apache Spark 大数据处理框架的一个子模块，用来处理结构化信息。它可以将 SQL 语句翻译多个任务在 Spark 集群上执行，允许用户直接通过 SQL 来处理数据，大大提升了易用性。\n Grouping Sets 简介 Spark SQL 官方文档中 SQL Syntax 一节对 Grouping Sets 语句的描述如下：\n Groups the rows for each grouping set specified after GROUPING SETS. （\u0026hellip; 一些举例） This clause is a shorthand for a UNION ALL where each leg of the UNION ALL operator performs aggregation of each grouping set specified in the GROUPING SETS clause. （\u0026hellip; 一些举例）\n 也即，Grouping Sets 语句的作用是指定几个 grouping set 作为 Group By 的分组规则，然后再将结果联合在一起。它的效果和，先分别对这些 grouping set 进行 Group By  分组之后，再通过 Union All 将结果联合起来，是一样的。\n比如，对于 dealer 表，Group By Grouping Sets ((city, car_model), (city), (car_model), ()) 和 Union All((Group By city, car_model), (Group By city), (Group By car_model), 全局聚合) 的效果是相同的：\n先看 Grouping Sets 版的执行结果：\nspark-sql\u0026gt;SELECTcity,car_model,sum(quantity)ASsumFROMdealer\u0026gt;GROUPBYGROUPINGSETS((city,car_model),(city),(car_model),())\u0026gt;ORDERBYcity,car_model;+--------+------------+---+ |city|car_model|sum|+--------+------------+---+ |null|null|78||null|HondaAccord|33||null|HondaCRV|10||null|HondaCivic|35||Dublin|null|33||Dublin|HondaAccord|10||Dublin|HondaCRV|3||Dublin|HondaCivic|20||Fremont|null|32||Fremont|HondaAccord|15||Fremont|HondaCRV|7||Fremont|HondaCivic|10||SanJose|null|13||SanJose|HondaAccord|8||SanJose|HondaCivic|5|+--------+------------+---+ 再看 Union All 版的执行结果：\nspark-sql\u0026gt;(SELECTcity,car_model,sum(quantity)ASsumFROMdealerGROUPBYcity,car_model)UNIONALL\u0026gt;(SELECTcity,NULLascar_model,sum(quantity)ASsumFROMdealerGROUPBYcity)UNIONALL\u0026gt;(SELECTNULLascity,car_model,sum(quantity)ASsumFROMdealerGROUPBYcar_model)UNIONALL\u0026gt;(SELECTNULLascity,NULLascar_model,sum(quantity)ASsumFROMdealer)\u0026gt;ORDERBYcity,car_model;+--------+------------+---+ |city|car_model|sum|+--------+------------+---+ |null|null|78||null|HondaAccord|33||null|HondaCRV|10||null|HondaCivic|35||Dublin|null|33||Dublin|HondaAccord|10||Dublin|HondaCRV|3||Dublin|HondaCivic|20||Fremont|null|32||Fremont|HondaAccord|15||Fremont|HondaCRV|7||Fremont|HondaCivic|10||SanJose|null|13||SanJose|HondaAccord|8||SanJose|HondaCivic|5|+--------+------------+---+ 两版的查询结果完全一样。\nGrouping Sets 的执行计划 从执行结果上看，Grouping Sets 版本和 Union All 版本的 SQL 是等价的，但 Grouping Sets 版本更加简洁。\n那么，Grouping Sets 仅仅只是 Union All 的一个缩写，或者语法糖吗？\n为了进一步探究 Grouping Sets 的底层实现是否和 Union All 是一致的，我们可以来看下两者的执行计划。\n首先，我们通过 explain extended 来查看 Union All 版本的 Optimized Logical Plan:\nspark-sql\u0026gt; explain extended (SELECT city, car_model, sum(quantity) AS sum FROM dealer GROUP BY city, car_model) UNION ALL (SELECT city, NULL as car_model, sum(quantity) AS sum FROM dealer GROUP BY city) UNION ALL (SELECT NULL as city, car_model, sum(quantity) AS sum FROM dealer GROUP BY car_model) UNION ALL (SELECT NULL as city, NULL as car_model, sum(quantity) AS sum FROM dealer) ORDER BY city, car_model; == Parsed Logical Plan == ... == Analyzed Logical Plan == ... == Optimized Logical Plan == Sort [city#93 ASC NULLS FIRST, car_model#94 ASC NULLS FIRST], true +- Union false, false :- Aggregate [city#93, car_model#94], [city#93, car_model#94, sum(quantity#95) AS sum#79L] : +- Project [city#93, car_model#94, quantity#95] : +- HiveTableRelation [`default`.`dealer`, ..., Data Cols: [id#92, city#93, car_model#94, quantity#95], Partition Cols: []] :- Aggregate [city#97], [city#97, null AS car_model#112, sum(quantity#99) AS sum#81L] : +- Project [city#97, quantity#99] : +- HiveTableRelation [`default`.`dealer`, ..., Data Cols: [id#96, city#97, car_model#98, quantity#99], Partition Cols: []] :- Aggregate [car_model#102], [null AS city#113, car_model#102, sum(quantity#103) AS sum#83L] : +- Project [car_model#102, quantity#103] : +- HiveTableRelation [`default`.`dealer`, ..., Data Cols: [id#100, city#101, car_model#102, quantity#103], Partition Cols: []] +- Aggregate [null AS city#114, null AS car_model#115, sum(quantity#107) AS sum#86L] +- Project [quantity#107] +- HiveTableRelation [`default`.`dealer`, ..., Data Cols: [id#104, city#105, car_model#106, quantity#107], Partition Cols: []] == Physical Plan == ... 从上述的 Optimized Logical Plan 可以清晰地看出 Union All 版本的执行逻辑：\n 执行每个子查询语句，计算得出查询结果。其中，每个查询语句的逻辑是这样的：  在 HiveTableRelation 节点对 dealer 表进行全表扫描。 在 Project 节点选出与查询语句结果相关的列，比如对于子查询语句 SELECT NULL as city, NULL as car_model, sum(quantity) AS sum FROM dealer，只需保留 quantity 列即可。 在 Aggregate 节点完成 quantity 列对聚合运算。在上述的 Plan 中，Aggregate 后面紧跟的就是用来分组的列，比如 Aggregate [city#902] 就表示根据 city 列来进行分组。   在 Union 节点完成对每个子查询结果的联合。 最后，在 Sort 节点完成对数据的排序，上述 Plan 中 Sort [city#93 ASC NULLS FIRST, car_model#94 ASC NULLS FIRST] 就表示根据 city 和 car_model 列进行升序排序。  接下来，我们通过 explain extended 来查看 Grouping Sets 版本的 Optimized Logical Plan：\nspark-sql\u0026gt; explain extended SELECT city, car_model, sum(quantity) AS sum FROM dealer GROUP BY GROUPING SETS ((city, car_model), (city), (car_model), ()) ORDER BY city, car_model; == Parsed Logical Plan == ... == Analyzed Logical Plan == ... == Optimized Logical Plan == Sort [city#138 ASC NULLS FIRST, car_model#139 ASC NULLS FIRST], true +- Aggregate [city#138, car_model#139, spark_grouping_id#137L], [city#138, car_model#139, sum(quantity#133) AS sum#124L] +- Expand [[quantity#133, city#131, car_model#132, 0], [quantity#133, city#131, null, 1], [quantity#133, null, car_model#132, 2], [quantity#133, null, null, 3]], [quantity#133, city#138, car_model#139, spark_grouping_id#137L] +- Project [quantity#133, city#131, car_model#132] +- HiveTableRelation [`default`.`dealer`, org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe, Data Cols: [id#130, city#131, car_model#132, quantity#133], Partition Cols: []] == Physical Plan == ... 从 Optimized Logical Plan 来看，Grouping Sets 版本要简洁很多！具体的执行逻辑是这样的：\n 在 HiveTableRelation 节点对 dealer 表进行全表扫描。 在 Project 节点选出与查询语句结果相关的列。 接下来的 Expand 节点是关键，数据经过该节点后，多出了 spark_grouping_id 列。从 Plan 中可以看出来，Expand 节点包含了 Grouping Sets 里的各个 grouping set 信息，比如 [quantity#133, city#131, null, 1] 对应的就是 (city) 这一 grouping set。而且，每个 grouping set 对应的 spark_grouping_id 列的值都是固定的，比如 (city) 对应的 spark_grouping_id 为 1。 在 Aggregate 节点完成 quantity 列对聚合运算，其中分组的规则为 city, car_model, spark_grouping_id。注意，数据经过 Aggregate 节点后，spark_grouping_id 列被删除了！ 最后，在 Sort 节点完成对数据的排序。  从 Optimized Logical Plan 来看，虽然 Union All 版本和 Grouping Sets 版本的效果一致，但它们的底层实现有着巨大的差别。\n其中，Grouping Sets 版本的 Plan 中最关键的是 Expand 节点，目前，我们只知道数据经过它之后，多出了 spark_grouping_id 列。而且从最终结果来看，spark_grouping_id 只是 Spark SQL 的内部实现细节，对用户并不体现。\n spark_grouping_id 其实可通过 GROUPING_ID() 函数查询得到，但一般用户不必关注。\n 那么：\n Expand 的实现逻辑是怎样的，为什么能达到 Union All 的效果？ Expand 节点的输出数据是怎样的？ spark_grouping_id 列的作用是什么？  通过 Physical Plan，我们发现 Expand 节点对应的算子名称也是 Expand:\n== Physical Plan == AdaptiveSparkPlan isFinalPlan=false +- Sort [city#138 ASC NULLS FIRST, car_model#139 ASC NULLS FIRST], true, 0 +- Exchange rangepartitioning(city#138 ASC NULLS FIRST, car_model#139 ASC NULLS FIRST, 200), ENSURE_REQUIREMENTS, [plan_id=422] +- HashAggregate(keys=[city#138, car_model#139, spark_grouping_id#137L], functions=[sum(quantity#133)], output=[city#138, car_model#139, sum#124L]) +- Exchange hashpartitioning(city#138, car_model#139, spark_grouping_id#137L, 200), ENSURE_REQUIREMENTS, [plan_id=419] +- HashAggregate(keys=[city#138, car_model#139, spark_grouping_id#137L], functions=[partial_sum(quantity#133)], output=[city#138, car_model#139, spark_grouping_id#137L, sum#141L]) +- Expand [[quantity#133, city#131, car_model#132, 0], [quantity#133, city#131, null, 1], [quantity#133, null, car_model#132, 2], [quantity#133, null, null, 3]], [quantity#133, city#138, car_model#139, spark_grouping_id#137L] +- Scan hive default.dealer [quantity#133, city#131, car_model#132], HiveTableRelation [`default`.`dealer`, ..., Data Cols: [id#130, city#131, car_model#132, quantity#133], Partition Cols: []] 带着前面的几个问题，接下来我们深入 Spark SQL 的 Expand 算子源码寻找答案。\nExpand 算子的实现 Expand 算子在 Spark SQL 源码中的实现为 ExpandExec 类（Spark SQL 中的算子实现类的命名都是 XxxExec 的格式，其中 Xxx 为具体的算子名，比如 Project 算子的实现类为 ProjectExec），核心代码如下：\n/** * Apply all of the GroupExpressions to every input row, hence we will get * multiple output rows for an input row. * @param projections The group of expressions, all of the group expressions should * output the same schema specified bye the parameter `output` * @param output The output Schema * @param child Child operator */ case class ExpandExec( projections: Seq[Seq[Expression]], output: Seq[Attribute], child: SparkPlan) extends UnaryExecNode with CodegenSupport { ... // 关键点1，将child.output，也即上游算子输出数据的schema，  // 绑定到表达式数组exprs，以此来计算输出数据  private[this] val projection = (exprs: Seq[Expression]) =\u0026gt; UnsafeProjection.create(exprs, child.output) // doExecute()方法为Expand算子执行逻辑所在  protected override def doExecute(): RDD[InternalRow] = { val numOutputRows = longMetric(\u0026#34;numOutputRows\u0026#34;) // 处理上游算子的输出数据，Expand算子的输入数据就从iter迭代器获取  child.execute().mapPartitions { iter =\u0026gt; // 关键点2，projections对应了Grouping Sets里面每个grouping set的表达式，  // 表达式输出数据的schema为this.output, 比如 (quantity, city, car_model, spark_grouping_id)  // 这里的逻辑是为它们各自生成一个UnsafeProjection对象，通过该对象的apply方法就能得出Expand算子的输出数据  val groups = projections.map(projection).toArray new Iterator[InternalRow] { private[this] var result: InternalRow = _ private[this] var idx = -1 // -1 means the initial state  private[this] var input: InternalRow = _ override final def hasNext: Boolean = (-1 \u0026lt; idx \u0026amp;\u0026amp; idx \u0026lt; groups.length) || iter.hasNext override final def next(): InternalRow = { // 关键点3，对于输入数据的每一条记录，都重复使用N次，其中N的大小对应了projections数组的大小，  // 也即Grouping Sets里指定的grouping set的数量  if (idx \u0026lt;= 0) { // in the initial (-1) or beginning(0) of a new input row, fetch the next input tuple  input = iter.next() idx = 0 } // 关键点4，对输入数据的每一条记录，通过UnsafeProjection计算得出输出数据，  // 每个grouping set对应的UnsafeProjection都会对同一个input计算一遍  result = groups(idx)(input) idx += 1 if (idx == groups.length \u0026amp;\u0026amp; iter.hasNext) { idx = 0 } numOutputRows += 1 result } } } } ... } ExpandExec 的实现并不复杂，想要理解它的运作原理，关键是看懂上述源码中提到的 4 个关键点。\n关键点 1 和 关键点 2 是基础，关键点 2 中的 groups 是一个 UnsafeProjection[N] 数组类型，其中每个 UnsafeProjection 代表了 Grouping Sets 语句里指定的 grouping set，它的定义是这样的：\n// A projection that returns UnsafeRow. abstract class UnsafeProjection extends Projection { override def apply(row: InternalRow): UnsafeRow } // The factory object for `UnsafeProjection`. object UnsafeProjection extends CodeGeneratorWithInterpretedFallback[Seq[Expression], UnsafeProjection] { // Returns an UnsafeProjection for given sequence of Expressions, which will be bound to  // `inputSchema`.  def create(exprs: Seq[Expression], inputSchema: Seq[Attribute]): UnsafeProjection = { create(bindReferences(exprs, inputSchema)) } ... } UnsafeProjection 起来了类似列投影的作用，其中， apply 方法根据创建时的传参 exprs 和 inputSchema，对输入记录进行列投影，得出输出记录。\n比如，前面的 GROUPING SETS ((city, car_model), (city), (car_model), ()) 例子，它对应的 groups 是这样的：\n其中，AttributeReference 类型的表达式，在计算时，会直接引用输入数据对应列的值；Iteral 类型的表达式，在计算时，值是固定的。\n关键点 3 和 关键点 4 是 Expand 算子的精华所在，ExpandExec 通过这两段逻辑，将每一个输入记录，扩展（Expand）成 N 条输出记录。\n 关键点 4 中 groups(idx)(input) 等同于 groups(idx).apply(input) 。\n 还是以前面 GROUPING SETS ((city, car_model), (city), (car_model), ()) 为例子，效果是这样的：\n到这里，我们已经弄清楚 Expand 算子的工作原理，再回头看前面提到的 3 个问题，也不难回答了：\n  Expand 的实现逻辑是怎样的，为什么能达到 Union All 的效果？\n如果说 Union All 是先聚合再联合，那么 Expand 就是先联合再聚合。Expand 利用 groups 里的 N 个表达式对每条输入记录进行计算，扩展成 N 条输出记录。后面再聚合时，就能达到与 Union All 一样的效果了。\n  Expand 节点的输出数据是怎样的？\n在 schema 上，Expand 输出数据会比输入数据多出 spark_grouping_id 列；在记录数上，是输入数据记录数的 N 倍。\n  spark_grouping_id 列的作用是什么？\nspark_grouping_id 给每个 grouping set 进行编号，这样，即使在 Expand 阶段把数据先联合起来，在 Aggregate 阶段（把 spark_grouping_id 加入到分组规则）也能保证数据能够按照每个 grouping set 分别聚合，确保了结果的正确性。\n我们可以通过在 SELECT 语句后加上 GROUPING_ID() 函数来查询 spark_grouping_id：\nspark-sql\u0026gt; SELECT city, car_model, sum(quantity) AS sum, GROUPING_ID() as spark_grouping_id FROM dealer \u0026gt; GROUP BY GROUPING SETS ((city, car_model), (city), (car_model), ()) \u0026gt; ORDER BY city, car_model; +--------+------------+---+-----------------+ | city| car_model|sum|spark_grouping_id| +--------+------------+---+-----------------+ | null| null| 78| 3| | null|Honda Accord| 33| 2| | null| Honda CRV| 10| 2| | null| Honda Civic| 35| 2| | Dublin| null| 33| 1| | Dublin|Honda Accord| 10| 0| | Dublin| Honda CRV| 3| 0| | Dublin| Honda Civic| 20| 0| | Fremont| null| 32| 1| | Fremont|Honda Accord| 15| 0| | Fremont| Honda CRV| 7| 0| | Fremont| Honda Civic| 10| 0| |San Jose| null| 13| 1| |San Jose|Honda Accord| 8| 0| |San Jose| Honda Civic| 5| 0| +--------+------------+---+-----------------+ 这样，我们就能清晰看出每一条记录对应的 grouping set 是哪个了。\n  查询性能对比 从前文可知，Grouping Sets 和 Union All 两个版本的 SQL 语句有着一样的效果，但是它们的执行计划却有着巨大的差别。下面，我们将比对两个版本之间的执行性能差异。\nspark-sql 执行完 SQL 语句之后会打印耗时信息，我们对两个版本的 SQL 分别执行 10 次，得到如下信息：\n// Grouping Sets 版本执行10次的耗时信息 // SELECT city, car_model, sum(quantity) AS sum FROM dealer GROUP BY GROUPING SETS ((city, car_model), (city), (car_model), ()) ORDER BY city, car_model; Time taken: 0.289 seconds, Fetched 15 row(s) Time taken: 0.251 seconds, Fetched 15 row(s) Time taken: 0.259 seconds, Fetched 15 row(s) Time taken: 0.258 seconds, Fetched 15 row(s) Time taken: 0.296 seconds, Fetched 15 row(s) Time taken: 0.247 seconds, Fetched 15 row(s) Time taken: 0.298 seconds, Fetched 15 row(s) Time taken: 0.286 seconds, Fetched 15 row(s) Time taken: 0.292 seconds, Fetched 15 row(s) Time taken: 0.282 seconds, Fetched 15 row(s) // Union All 版本执行10次的耗时信息 // (SELECT city, car_model, sum(quantity) AS sum FROM dealer GROUP BY city, car_model) UNION ALL (SELECT city, NULL as car_model, sum(quantity) AS sum FROM dealer GROUP BY city) UNION ALL (SELECT NULL as city, car_model, sum(quantity) AS sum FROM dealer GROUP BY car_model) UNION ALL (SELECT NULL as city, NULL as car_model, sum(quantity) AS sum FROM dealer) ORDER BY city, car_model; Time taken: 0.628 seconds, Fetched 15 row(s) Time taken: 0.594 seconds, Fetched 15 row(s) Time taken: 0.591 seconds, Fetched 15 row(s) Time taken: 0.607 seconds, Fetched 15 row(s) Time taken: 0.616 seconds, Fetched 15 row(s) Time taken: 0.64 seconds, Fetched 15 row(s) Time taken: 0.623 seconds, Fetched 15 row(s) Time taken: 0.625 seconds, Fetched 15 row(s) Time taken: 0.62 seconds, Fetched 15 row(s) Time taken: 0.62 seconds, Fetched 15 row(s) 可以算出，Grouping Sets 版本的 SQL 平均耗时为 0.276s；Union All 版本的 SQL 平均耗时为 0.616s，是前者的 2.2 倍！\n所以，Grouping Sets 版本的 SQL 不仅在表达上更加简洁，在性能上也更加高效。\nRollUp 和 Cube Group By 的高级用法中，还有 RollUp 和 Cube 两个比较常用。\n首先，我们看下 RollUp 语句。\nSpark SQL 官方文档中 SQL Syntax 一节对 RollUp 语句的描述如下：\n Specifies multiple levels of aggregations in a single statement. This clause is used to compute aggregations based on multiple grouping sets. ROLLUP is a shorthand for GROUPING SETS. （\u0026hellip; 一些例子）\n 官方文档中，把 RollUp 描述为 Grouping Sets 的简写，等价规则为：RollUp(A, B, C) == Grouping Sets((A, B, C), (A, B), (A), ())。\n比如，Group By RollUp(city, car_model) 就等同于 Group By Grouping Sets((city, car_model), (city), ())。\n下面，我们通过 expand extended 看下 RollUp 版本 SQL 的 Optimized Logical Plan：\nspark-sql\u0026gt; explain extended SELECT city, car_model, sum(quantity) AS sum FROM dealer GROUP BY ROLLUP(city, car_model) ORDER BY city, car_model; == Parsed Logical Plan == ... == Analyzed Logical Plan == ... == Optimized Logical Plan == Sort [city#2164 ASC NULLS FIRST, car_model#2165 ASC NULLS FIRST], true +- Aggregate [city#2164, car_model#2165, spark_grouping_id#2163L], [city#2164, car_model#2165, sum(quantity#2159) AS sum#2150L] +- Expand [[quantity#2159, city#2157, car_model#2158, 0], [quantity#2159, city#2157, null, 1], [quantity#2159, null, null, 3]], [quantity#2159, city#2164, car_model#2165, spark_grouping_id#2163L] +- Project [quantity#2159, city#2157, car_model#2158] +- HiveTableRelation [`default`.`dealer`, ..., Data Cols: [id#2156, city#2157, car_model#2158, quantity#2159], Partition Cols: []] == Physical Plan == ... 从上述 Plan 可以看出，RollUp 底层实现用的也是 Expand 算子，说明 RollUp 确实是基于 Grouping Sets 实现的。 而且 Expand [[quantity#2159, city#2157, car_model#2158, 0], [quantity#2159, city#2157, null, 1], [quantity#2159, null, null, 3]] 也表明 RollUp 符合等价规则。\n下面，我们按照同样的思路，看下 Cube 语句。\nSpark SQL 官方文档中 SQL Syntax 一节对 Cube 语句的描述如下：\n CUBE clause is used to perform aggregations based on combination of grouping columns specified in the GROUP BYclause. CUBE is a shorthand for GROUPING SETS. (\u0026hellip; 一些例子)\n 同样，官方文档把 Cube 描述为 Grouping Sets 的简写，等价规则为：Cube(A, B, C) == Grouping Sets((A, B, C), (A, B), (A, C), (B, C), (A), (B), (C), ())。\n比如，Group By Cube(city, car_model) 就等同于 Group By Grouping Sets((city, car_model), (city), (car_model), ())。\n下面，我们通过 expand extended 看下 Cube 版本 SQL 的 Optimized Logical Plan：\nspark-sql\u0026gt; explain extended SELECT city, car_model, sum(quantity) AS sum FROM dealer GROUP BY CUBE(city, car_model) ORDER BY city, car_model; == Parsed Logical Plan == ... == Analyzed Logical Plan == ... == Optimized Logical Plan == Sort [city#2202 ASC NULLS FIRST, car_model#2203 ASC NULLS FIRST], true +- Aggregate [city#2202, car_model#2203, spark_grouping_id#2201L], [city#2202, car_model#2203, sum(quantity#2197) AS sum#2188L] +- Expand [[quantity#2197, city#2195, car_model#2196, 0], [quantity#2197, city#2195, null, 1], [quantity#2197, null, car_model#2196, 2], [quantity#2197, null, null, 3]], [quantity#2197, city#2202, car_model#2203, spark_grouping_id#2201L] +- Project [quantity#2197, city#2195, car_model#2196] +- HiveTableRelation [`default`.`dealer`, ..., Data Cols: [id#2194, city#2195, car_model#2196, quantity#2197], Partition Cols: []] == Physical Plan == ... 从上述 Plan 可以看出，Cube 底层用的也是 Expand 算子，说明 Cube 确实基于 Grouping Sets 实现，而且也符合等价规则。\n所以，RollUp 和 Cube 可以看成是 Grouping Sets 的语法糖，在底层实现和性能上是一样的。\n最后 本文重点讨论了 Group By 高级用法 Groupings Sets 语句的功能和底层实现。\n虽然 Groupings Sets 的功能，通过 Union All 也能实现，但前者并非后者的语法糖，它们的底层实现完全不一样。Grouping Sets 采用的是先联合再聚合的思路，通过 spark_grouping_id 列来保证数据的正确性；Union All 则采用先聚合再联合的思路。Grouping Sets 在 SQL 语句表达和性能上都有更大的优势。\nGroup By 的另外两个高级用法 RollUp 和 Cube 则可以看成是 Grouping Sets 的语法糖，它们的底层都是基于 Expand 算子实现，在性能上与直接使用 Grouping Sets 是一样的，但在 SQL 表达上更加简洁。\n文章配图 可以在 用Keynote画出手绘风格的配图 中找到文章的绘图方法。\n 参考 [1] Spark SQL Guide, Apache Spark\n[2] apache spark 3.3 版本源码, Apache Spark, GitHub\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-07-03T00:00:00Z","permalink":"https://yrunz.com/p/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3-sql-%E4%B8%AD%E7%9A%84-grouping-sets-%E8%AF%AD%E5%8F%A5/","title":"深入理解 SQL 中的 Grouping Sets 语句"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：原型模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 我们经常会遇到“给现有对象/模块新增功能”的场景，比如 http router 的开发场景下，除了最基础的路由功能之外，我们常常还会加上如日志、鉴权、流控等 middleware。如果你查看框架的源码，就会发现 middleware 功能的实现用的就是装饰者模式（Decorator Pattern）。\nGoF 给装饰者模式的定义如下：\n Decorators provide a flexible alternative to subclassing for extending functionality. Attach additional responsibilities to an object dynamically.\n 简单来说，装饰者模式通过组合的方式，提供了能够动态地给对象/模块扩展新功能的能力。理论上，只要没有限制，它可以一直把功能叠加下去，具有很高的灵活性。\n 如果写过 Java，那么一定对 I/O Stream 体系不陌生，它是装饰者模式的经典用法，客户端程序可以动态地为原始的输入输出流添加功能，比如按字符串输入输出，加入缓冲等，使得整个 I/O Stream 体系具有很高的可扩展性和灵活性。\n UML 结构 场景上下文 在简单的分布式应用系统（示例代码工程）中，我们设计了 Sidecar 边车模块，它的用处主要是为了 1）方便扩展 network.Socket 的功能，如增加日志、流控等非业务功能；2）让这些附加功能对业务程序隐藏起来，也即业务程序只须关心看到 network.Socket 接口即可。\n代码实现 Sidecar 的这个功能场景，很适合使用装饰者模式来实现，代码如下：\n// demo/network/socket.go package network // 关键点1: 定义被装饰的抽象接口 // Socket 网络通信Socket接口 type Socket interface { // Listen 在endpoint指向地址上起监听  Listen(endpoint Endpoint) error // Close 关闭监听  Close(endpoint Endpoint) // Send 发送网络报文  Send(packet *Packet) error // Receive 接收网络报文  Receive(packet *Packet) // AddListener 增加网络报文监听者  AddListener(listener SocketListener) } // 关键点2: 提供一个默认的基础实现 type socketImpl struct { listener SocketListener } func DefaultSocket() *socketImpl { return \u0026amp;socketImpl{} } func (s *socketImpl) Listen(endpoint Endpoint) error { return Instance().Listen(endpoint, s) } ... // socketImpl的其他Socket实现方法  // demo/sidecar/flowctrl_sidecar.go package sidecar // 关键点3: 定义装饰器，实现被装饰的接口 // FlowCtrlSidecar HTTP接收端流控功能装饰器，自动拦截Socket接收报文，实现流控功能 type FlowCtrlSidecar struct { // 关键点4: 装饰器持有被装饰的抽象接口作为成员属性  socket network.Socket ctx *flowctrl.Context } // 关键点5: 对于需要扩展功能的方法，新增扩展功能 func (f *FlowCtrlSidecar) Receive(packet *network.Packet) { httpReq, ok := packet.Payload().(*http.Request) // 如果不是HTTP请求，则不做流控处理  if !ok { f.socket.Receive(packet) return } // 流控后返回429 Too Many Request响应  if !f.ctx.TryAccept() { httpResp := http.ResponseOfId(httpReq.ReqId()). AddStatusCode(http.StatusTooManyRequest). AddProblemDetails(\u0026#34;enter flow ctrl state\u0026#34;) f.socket.Send(network.NewPacket(packet.Dest(), packet.Src(), httpResp)) return } f.socket.Receive(packet) } // 关键点6: 不需要扩展功能的方法，直接调用被装饰接口的原生方法即可 func (f *FlowCtrlSidecar) Close(endpoint network.Endpoint) { f.socket.Close(endpoint) } ... // FlowCtrlSidecar的其他方法  // 关键点7: 定义装饰器的工厂方法，入参为被装饰接口 func NewFlowCtrlSidecar(socket network.Socket) *FlowCtrlSidecar { return \u0026amp;FlowCtrlSidecar{ socket: socket, ctx: flowctrl.NewContext(), } } // demo/sidecar/all_in_one_sidecar_factory.go // 关键点8: 使用时，通过装饰器的工厂方法，把所有装饰器和被装饰者串联起来 func (a AllInOneFactory) Create() network.Socket { return NewAccessLogSidecar(NewFlowCtrlSidecar(network.DefaultSocket()), a.producer) } 总结实现装饰者模式的几个关键点：\n 定义需要被装饰的抽象接口，后续的装饰器都是基于该接口进行扩展。 为抽象接口提供一个基础实现。 定义装饰器，并实现被装饰的抽象接口。 装饰器持有被装饰的抽象接口作为成员属性。“装饰”的意思是在原有功能的基础上扩展新功能，因此必须持有原有功能的抽象接口。 在装饰器中，对于需要扩展功能的方法，新增扩展功能。 不需要扩展功能的方法，直接调用被装饰接口的原生方法即可。 为装饰器定义一个工厂方法，入参为被装饰接口。 使用时，通过装饰器的工厂方法，把所有装饰器和被装饰者串联起来。  扩展 Go 风格的实现 在 Sidecar 的场景上下文中，被装饰的 Socket 是一个相对复杂的接口，装饰器通过实现 Socket 接口来进行功能扩展，是典型的面向对象风格。\n如果被装饰者是一个简单的接口/方法/函数，我们可以用更具 Go 风格的实现方式，考虑前文提到的 http router 场景。如果你使用原生的 net/http 进行 http router 开发，通常会这么实现：\nfunc main() { // 注册/hello的router  http.HandleFunc(\u0026#34;/hello\u0026#34;, hello) // 启动http服务器  http.ListenAndServe(\u0026#34;localhost:8080\u0026#34;, nil) } // 具体的请求处理逻辑，类型是 http.HandlerFunc func hello(w http.ResponseWriter, r *http.Request) { w.Write([]byte(\u0026#34;hello, world\u0026#34;)) } 其中，我们通过 http.HandleFunc 来注册具体的 router， hello 是具体的请求处理方法。现在，我们想为该 http 服务器增加日志、鉴权等通用功能，那么可以把 func(w http.ResponseWriter, r *http.Request) 作为被装饰的抽象接口，通过新增日志、鉴权等装饰器完成功能扩展。\n// demo/network/http/http_handle_func_decorator.go  // 关键点1: 确定被装饰接口，这里为原生的http.HandlerFunc type HandlerFunc func(ResponseWriter, *Request) // 关键点2: 定义装饰器类型，是一个函数类型，入参和返回值都是 http.HandlerFunc 函数 type HttpHandlerFuncDecorator func(http.HandlerFunc) http.HandlerFunc // 关键点3: 定义装饰函数，入参为被装饰的接口和装饰器可变列表 func Decorate(h http.HandlerFunc, decorators ...HttpHandlerFuncDecorator) http.HandlerFunc { // 关键点4: 通过for循环遍历装饰器，完成对被装饰接口的装饰  for _, decorator := range decorators { h = decorator(h) } return h } // 关键点5: 实现具体的装饰器 func WithBasicAuth(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(\u0026#34;Auth\u0026#34;) if err != nil || cookie.Value != \u0026#34;Pass\u0026#34; { w.WriteHeader(http.StatusForbidden) return } // 关键点6: 完成功能扩展之后，调用被装饰的方法，才能将所有装饰器和被装饰者串起来  h(w, r) } } func WithLogger(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { log.Println(r.Form) log.Printf(\u0026#34;path %s\u0026#34;, r.URL.Path) h(w, r) } } func hello(w http.ResponseWriter, r *http.Request) { w.Write([]byte(\u0026#34;hello, world\u0026#34;)) } func main() { // 关键点7: 通过Decorate函数完成对hello的装饰  http.HandleFunc(\u0026#34;/hello\u0026#34;, Decorate(hello, WithLogger, WithBasicAuth)) // 启动http服务器  http.ListenAndServe(\u0026#34;localhost:8080\u0026#34;, nil) } 上述的装饰者模式的实现，用到了类似于 Functional Options 的技巧，也是巧妙利用了 Go 的函数式编程的特点，总结下来有如下几个关键点：\n 确定被装饰的接口，上述例子为 http.HandlerFunc。 定义装饰器类型，是一个函数类型，入参和返回值都是被装饰接口，上述例子为 func(http.HandlerFunc) http.HandlerFunc。 定义装饰函数，入参为被装饰的接口和装饰器可变列表，上述例子为 Decorate 方法。 在装饰方法中，通过for循环遍历装饰器，完成对被装饰接口的装饰。这里是用来类似 Functional Options 的技巧，一定要注意装饰器的顺序！ 实现具体的装饰器，上述例子为 WithBasicAuth 和 WithLogger 函数。 在装饰器中，完成功能扩展之后，记得调用被装饰者的接口，这样才能将所有装饰器和被装饰者串起来。 在使用时，通过装饰函数完成对被装饰者的装饰，上述例子为 Decorate(hello, WithLogger, WithBasicAuth)。  Go 标准库中的装饰者模式 在 Go 标准库中，也有一个运用了装饰者模式的模块，就是 context，其中关键的接口如下：\npackage context // 被装饰接口 type Context interface { Deadline() (deadline time.Time, ok bool) Done() \u0026lt;-chan struct{} Err() error Value(key any) any } // cancel装饰器 type cancelCtx struct { Context // 被装饰接口  mu sync.Mutex done atomic.Value children map[canceler]struct{}= err error } // cancel装饰器的工厂方法 func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { // ...  c := newCancelCtx(parent) propagateCancel(parent, \u0026amp;c) return \u0026amp;c, func() { c.cancel(true, Canceled) } } // timer装饰器 type timerCtx struct { cancelCtx // 被装饰接口  timer *time.Timer deadline time.Time } // timer装饰器的工厂方法 func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) { // ...  c := \u0026amp;timerCtx{ cancelCtx: newCancelCtx(parent), deadline: d, } // ...  return c, func() { c.cancel(true, Canceled) } } // timer装饰器的工厂方法 func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { return WithDeadline(parent, time.Now().Add(timeout)) } // value装饰器 type valueCtx struct { Context // 被装饰接口  key, val any } // value装饰器的工厂方法 func WithValue(parent Context, key, val any) Context { if parent == nil { panic(\u0026#34;cannot create context from nil parent\u0026#34;) } // ...  return \u0026amp;valueCtx{parent, key, val} } 使用时，可以这样：\n// 使用时，可以这样 func main() { ctx := context.Background() ctx = context.WithValue(ctx, \u0026#34;key1\u0026#34;, \u0026#34;value1\u0026#34;) ctx, _ = context.WithTimeout(ctx, time.Duration(1)) ctx = context.WithValue(ctx, \u0026#34;key2\u0026#34;, \u0026#34;value2\u0026#34;) } 不管是 UML 结构，还是使用方法，context 模块都与传统的装饰者模式有一定出入，但也不妨碍 context 是装饰者模式的典型运用。还是那句话，学习设计模式，不能只记住它的结构，而是学习其中的动机和原理。\n典型使用场景  I/O 流，比如为原始的 I/O 流增加缓冲、压缩等功能。 Http Router，比如为基础的 Http Router 能力增加日志、鉴权、Cookie等功能。 \u0026hellip;\u0026hellip;  优缺点 优点  遵循开闭原则，能够在不修改老代码的情况下扩展新功能。 可以用多个装饰器把多个功能组合起来，理论上可以无限组合。  缺点  一定要注意装饰器装饰的顺序，否则容易出现不在预期内的行为。 当装饰器越来越多之后，系统也会变得复杂。  与其他模式的关联 装饰者模式和代理模式具有很高的相似性，但是两种所强调的点不一样。前者强调的是为本体对象添加新的功能；后者强调的是对本体对象的访问控制。\n装饰者模式和适配器模式的区别是，前者只会扩展功能而不会修改接口；后者则会修改接口。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] 【Go实现】实践GoF的23种设计模式：建造者模式, 元闰子\n[3] Design Patterns, Chapter 4. Structural Patterns, GoF\n[4] 装饰模式, refactoringguru.cn\n[5] Golang Decorator Pattern, Henry Du\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-06-28T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E8%A3%85%E9%A5%B0%E8%80%85%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：装饰者模式"},{"content":" 一个月前作出换个领域的决定，今天清退完旧物，也算为过去 5 年的工作打了个结。新的征程即将开始。\n 想的太多了，做的也就少了 很喜欢黄华老师在《云端遐思》里的一句话：这个世界很大，但我们很容易把自己锁死，锁在一座大城里，锁在一块业务里。而诗和远方却永远停留在自己的梦里。\n我想这是当代人的一个困境吧。我们常说“为生活所迫”，本应美好的生活，变成了一块巨石重重地压在我们身上。而且，随着年龄增长，巨石变得越来越重，我们前进的步伐也逐渐缓慢，直到最后停留在原地，走不动了。\n导致这块巨石加重的因素有很多，可能是一套房子、一次投资、一份工作；但导致我们走不动的原因，不是巨石，是我们选择了背负这块巨石。\n求稳，是刻在我们中华民族的骨子里的观念。《菜根谭》修身篇的第一条就说，“思立掀天揭地的事功，须向薄冰上履过”，劝告我们要如履薄冰地做人、做事。\n所以，我们在面临选择时，往往会考虑很多，“会不会是一个深坑，还是保险起见”，“感觉跟现在也差不太多，还是算了”。想得越多，就越倾向于维持在稳定的现状，反正石头还背得动；越到后面，就越没有勇气做出改变。\n假如洞穴之外仍是洞穴 柏拉图的《理想国》里有个著名的洞穴比喻，大概的意思是这样的：有一群被困在洞穴里的囚徒，他们认为洞穴就是世界的本身；有一天，其中一人被解除桎梏，来到了洞穴之外的阳光之下；刚开始，他的眼睛会很难受，后面渐渐开始适应阳光，认识到洞外的世界有多真实和美好。那个看到了阳光的囚徒，就是看到了“善的理念”的哲人。\n那么，假如洞穴之外还是洞穴，走出洞穴与留在洞穴又有什么区别呢？换作是你，又会怎么选择？\n洞穴之外的洞穴是生活中的常态。我们常常付出很多努力，然后鼓起勇气做出一个选择，但生活并没有因此得到改善，甚至变得更糟糕了。一来二去，也就失去了穿越洞穴的勇气，即使下一条路通向的是阳光。\n就算你是一个旁观者，在看到他的“不幸”遭遇之后，相信你也会更倾向于留下，以求得更稳定的生活。\n从一个洞穴，到另一个洞穴，真的完全没有收获吗？我想未必。比起永远困在同一个洞穴，在各个洞穴之间来回穿越，体验不同洞穴的风土人情，不断开阔眼界，也不失为一种好的生活。而且，这种生活也更符合苏格拉底的那个著名观点：未经审视的人生是不值得过的。\n人生有无限的可能 人生有无限的可能，《瞬息全宇宙》，前段时间很火的电影，很生动地把这句话呈现了出来。生活中我们所做的每一个细微的选择，都会导致一个平行宇宙的出现，不同宇宙里的你可能过着完全不同的人生。\n当前宇宙的你，可能就同电影里的主角 Evelyn 一样，过着最不好的人生，每天都充斥着各种琐碎的、不好的事情；但只要你下定决心做出选择，就有可能体验到另类人生。\n当然，选择的代价是巨大的，就像电影里想要在不同的宇宙间跳转就必须做出违反常理的行为一样，而且选择之后的人生也是不可知的。\n尽管如此，它还是值得去体验，最坏的结果也不过是在体验完之后，仍然觉得当前的人生最合适，再回来就是了。相信经历过这些之后，你也会更加珍惜当下的生活。\n认识你自己 德尔菲神庙上最著名的一条箴言：认识你自己。\n每个人都有“非你不能做，非你做不好”的自然天赋，我们要做的就是不断调整方向，校准目标，去发现和实现它们。这过程必然会经历失败，但是没关系，失败本来就是人生的一部分，我们应该敢于试错。\n人生就是不断地认识自己、发现自己，唯有这样，才能成为更好的自己。\n最后送上狄金森的一首短诗：\n假如我没见过太阳， 或许我还可以忍受黑暗， 可如今，太阳把我的寂寞照耀得更加荒凉  说回这次换领域的选择，如果你在一年前告诉我有这么一个机会，是我非常喜欢、感兴趣的领域，我会选择它吗？我想未必。\n就像你从小告诉我很多大道理，但直到我经历了很多、对这些道理有切身体会的那一刻之前，我都不会理解它们。\n可能这就是人生吧。\n ","date":"2022-06-10T00:00:00Z","permalink":"https://yrunz.com/p/%E8%AE%A9%E4%BA%BA%E7%94%9F%E5%A4%9A%E4%BA%9B%E5%8F%AF%E8%83%BD/","title":"让人生多些可能"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：抽象工厂模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简介 原型模式（Prototype Pattern）主要解决对象复制的问题，它的核心就是 Clone() 方法，返回原型对象的复制品。\n最简单直接的对象复制方式是这样的：重新实例化一个该对象的实例，然后遍历原始对象的所有成员变量， 并将成员变量值复制到新实例中。但这种方式的缺点也很明显：\n 客户端程序必须清楚对象的实现细节。暴露细节往往不是件好事，它会导致代码耦合过深。 对象可能存在一些私有属性，客户端程序无法访问它们，也就无法复制。 很难保证所有的客户端程序都能完整不漏地把所有成员属性复制完。  更好的方法是使用原型模式，将复制逻辑委托给对象本身，这样，上述两个问题也都解决了。\nUML 结构 场景上下文 在简单的分布式应用系统（示例代码工程）中，我们设计了一个服务消息中介（Service Mediator）服务，可以把它看成是一个消息路由器，负责服务发现和消息转发：\n消息转发也就意味着它必须将上游服务的请求原封不动地转发给下游服务，这是一个典型的对象复制场景。不过，在我们的实现里，服务消息中介会先修改上行请求的 URI，之后再转发给下游服务。因为上行请求 URI 中携带了下游服务的类型信息，用来做服务发现，在转发给下游服务时必须剔除。\n比如，订单服务（order service）要发请求给库存服务（stock service），那么：\n 订单服务先往服务消息中介发出 HTTP 请求，其中 URI 为 /stock-service/api/v1/stock。 服务消息中介收到上行请求后，会从 URI 中提取出下游服务类型 stock-service ，通过服务注册中心发现库存服务的 Endpoint。 随后，服务消息中介将修改后的请求转发给库存服务，其中 URI 为 /api/v1/stock。  代码实现 如果按照简单直接的对象复制方式，实现是这样的：\n// 服务消息中介 type ServiceMediator struct { registryEndpoint network.Endpoint localIp string server *http.Server sidecarFactory sidecar.Factory } // Forward 转发请求，请求URL为 /{serviceType}+ServiceUri 的形式，如/serviceA/api/v1/task func (s *ServiceMediator) Forward(req *http.Request) *http.Response { // 提取上行请求URI中的服务类型  svcType := s.svcTypeOf(req.Uri()) // 剔除服务类型之后的请求URI  svcUri := s.svcUriOf(req.Uri()) // 根据服务类型做服务发现  dest, err := s.discovery(svcType) if err != nil { ... // 异常处理  } // 复制上行请求，将URI更改为剔除服务类型之后的URI  forwardReq := http.EmptyRequest(). AddUri(svcUri). AddMethod(req.Method()). AddHeaders(req.Headers()). AddQueryParams(req.QueryParams()). AddBody(req.Body()) // 转发请求给下游服务  client, err := http.NewClient(s.sidecarFactory.Create(), s.localIp) if err != nil { ... // 异常处理  } defer client.Close() resp, err := client.Send(dest, forwardReq) if err != nil { ... // 异常处理  } // 复制下行响应，将ReqId更改为上行请求的ReqId，其他保持不变  return http.NewResponse(req.ReqId()). AddHeaders(resp.Headers()). AddStatusCode(resp.StatusCode()). AddProblemDetails(resp.ProblemDetails()). AddBody(resp.Body()) } ... 上述实现中有 2 处进行了对象的复制：上行请求的复制和下行响应的复制。且不说直接进行对象复制具有前文提到的 3 种缺点，就代码可读性上来看也是稍显冗余。下面，我们使用原型模式进行优化。\n首先，为 http.Request 和 http.Response 定义 Clone 方法：\n// demo/network/http/http_request.go package http type Request struct { reqId ReqId method Method uri Uri queryParams map[string]string headers map[string]string body interface{} } // 关键点1: 定义原型复制方法Clone func (r *Request) Clone() *Request { // reqId重新生成，其他都拷贝原来的值  reqId := rand.Uint32() % 10000 return \u0026amp;Request{ reqId: ReqId(reqId), method: r.method, uri: r.uri, queryParams: r.queryParams, headers: r.headers, body: r.body, } } ... // demo/network/http/http_response.go  type Response struct { reqId ReqId statusCode StatusCode headers map[string]string body interface{} problemDetails string } func (r *Response) Clone() *Response { return \u0026amp;Response{ reqId: r.reqId, statusCode: r.statusCode, headers: r.headers, body: r.body, problemDetails: r.problemDetails, } } ... 最后，在客户端程序处通过 Clone 方法来完成对象的复制：\n// demo/service/mediator/service_mediator.go  type ServiceMediator struct {...} func (s *ServiceMediator) Forward(req *http.Request) *http.Response { ... dest, err := s.discovery(svcType) if err != nil { ... } // 关键点2: 通过Clone方法完成对象的复制，然后在此基础上进行进一步的修改  forwardReq := req.Clone().AddUri(svcUri) ... resp, err := client.Send(dest, forwardReq) if err != nil { ... } return resp.Clone().AddReqId(req.ReqId()) } 原型模式的实现相对简单，可总结为 2 个关键点：\n 为原型对象定义 Clone 方法，在此方法上完成成员属性的拷贝。 在客户端程序中通过 Clone 来完成对象的复制。  需要注意的是，我们不一定非得遵循标准的原型模式 UML 结构定义一个原型接口，然后让原型对象实现它，比如：\n// Cloneable 原型复制接口 type Cloneable interface { Clone() Cloneable } type Response struct {...} // 实现原型复制接口 func (r *Response) Clone() Cloneable { return \u0026amp;Response{ reqId: r.reqId, statusCode: r.statusCode, headers: r.headers, body: r.body, problemDetails: r.problemDetails, } } 在当前场景下，这样并不会给程序带来任何好处，反而新增一次类型强转，让程序变得更复杂了：\nfunc (s *ServiceMediator) Forward(req *http.Request) *http.Response { ... resp, err := client.Send(dest, forwardReq) if err != nil { ... } // 因为Clone方法返回的是Cloneable接口，因此需要转型为*http.Response  return resp.Clone().(*http.Response).AddReqId(req.ReqId()) } 所以，运用设计模式，最重要的是学得其中精髓，而不是仿照其形式，否则很容易适得其反。\n扩展 原型模式和与建造者模式的结合 原型模式和建造者模式相结合，也是常见的场景。还是以 http.Request 为例：\n首先，我们先为它新增一个 requestBuilder 对象来完成对象的构造：\n// demo/network/http/http_request_builder.go type requestBuilder struct { req *Request } // 普通Builder工厂方法，新创建一个Request对象 func NewRequestBuilder() *requestBuilder { return \u0026amp;requestBuilder{req: EmptyRequest()} } func (r *requestBuilder) AddMethod(method Method) *requestBuilder { r.req.method = method return r } func (r *requestBuilder) AddUri(uri Uri) *requestBuilder { r.req.uri = uri return r } ... // 一系列 Addxxx 方法  func (r *requestBuilder) Builder() *Request { return r.req } 下面，我们为 requestBuilder 新增一个 NewRequestBuilderCopyFrom 工厂方法来达到原型复制的效果：\n// demo/network/http/http_request_builder.go  // 实现原型模式的Builder工厂方法，复制已有的Request对象 func NewRequestBuilderCopyFrom(req *Request) *requestBuilder { reqId := rand.Uint32() % 10000 replica := \u0026amp;Request{ reqId: ReqId(reqId), method: req.method, uri: req.uri, queryParams: req.queryParams, headers: req.headers, body: req.body, } // 将复制后的对象赋值给requestBuilder  return \u0026amp;requestBuilder{req: replica} } 用法如下：\nfunc (s *ServiceMediator) Forward(req *http.Request) *http.Response { ... dest, err := s.discovery(svcType) if err != nil { ... } // 原型模式和建造者模式相结合的实现  forwardReq := http.NewRequestBuilderCopyFrom(req).Builder().AddUri(svcUri) ... resp, err := client.Send(dest, forwardReq) if err != nil { ... } // 普通原型模式的实现  return resp.Clone().AddReqId(req.ReqId()) } 浅拷贝和深拷贝 如果原型对象的成员属性包含了指针类型，那么就会存在浅拷贝和深拷贝两种复制方式，比如对于原型对象 ServiceProfile，其中的 Region 属性为指针类型：\n// demo/service/registry/model/service_profile.go package model // ServiceProfile 服务档案，其中服务ID唯一标识一个服务实例，一种服务类型可以有多个服务实例 type ServiceProfile struct { Id string // 服务ID  Type ServiceType // 服务类型  Status ServiceStatus // 服务状态  Endpoint network.Endpoint // 服务Endpoint  Region *Region // 服务所属region  Priority int // 服务优先级，范围0～100，值越低，优先级越高  Load int // 服务负载，负载越高表示服务处理的业务压力越大 } 浅拷贝的做法是直接复制指针：\n// 浅拷贝实现 func (s *ServiceProfile) Clone() Cloneable { return \u0026amp;ServiceProfile{ Id: s.Id, Type: s.Type, Status: s.Status, Endpoint: s.Endpoint, Region: s.Region, // 指针复制，浅拷贝  Priority: s.Priority, Load: s.Load, } } 深拷贝的做法则是创建新的 Region 对象：\n// 深拷贝实现 func (s *ServiceProfile) Clone() Cloneable { return \u0026amp;ServiceProfile{ Id: s.Id, Type: s.Type, Status: s.Status, Endpoint: s.Endpoint, Region: \u0026amp;Region{ // 新创建一个Region对象，深拷贝  Id: s.Region.Id, Name: s.Region.Name, Country: s.Region.Country, }, Priority: s.Priority, Load: s.Load, } } 具体使用哪种方式，因不同业务场景而异。浅拷贝直接复制指针，在性能上会好点；但某些场景下，引用同一个对象实例可能会导致业务异常，这时候就必须使用深拷贝了。\n典型使用场景  不管是复杂还是简单的对象，只要存在对象复制的场景，都适合使用原型模式。  优缺点 优点  对客户端隐藏实现细节，有利于避免代码耦合。 让客户端代码更简洁，有利于提升可读性。 可方便地复制复杂对象，有利于杜绝客户端复制对象时的低级错误，比如漏复制属性。  缺点  某些业务场景需要警惕浅拷贝问题。  与其他模式的关联 如前文提到的，原型模式和建造者模式相结合也是一种常见的应用场景。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] 【Go实现】实践GoF的23种设计模式：建造者模式, 元闰子\n[3] Design Patterns, Chapter 3. Creational Patterns, GoF\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-06-02T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E5%8E%9F%E5%9E%8B%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：原型模式"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：工厂方法模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简述 上一篇我们介绍了工厂方法模式，本文，我们继续介绍它的兄弟，抽象工厂模式（Abstract Factory Pattern）。\n在工厂方法模式中，我们通过一个工厂方法接口来创建产品，而创建哪类产品，由具体的工厂对象来决定。抽象工厂模式和工厂方法模式的功能很类似，只是把“产品”，变成了“产品族”。产品族就意味着这是一系列有关联的、一起使用的对象。我们当然也可以为产品族中的每个产品定义一个工厂方法接口，但这显得有些冗余，因为一起使用通常也意味着同时创建，所以把它们放到同一个抽象工厂来创建会更合适。\nUML 结构 场景上下文 在简单的分布式应用系统（示例代码工程）中，我们有一个 Monitor 监控系统模块，该模块可以看成是一个简单的 ETL 系统，负责对监控数据的采集、处理、输出。整个模块被设计为插件化风格的架构，Pipeline是数据处理的流水线，其中包含了 Input、Filter 和 Output 三类插件，Input 负责从各类数据源中获取监控数据，Filter 负责数据处理，Output 负责将处理后的数据输出。更详细的设计思想我们在桥接模式一篇再做介绍，本文主要聚焦如何使用抽象工厂模式来解决各类插件的配置加载问题。\n作为 ETL 系统，Monitor 模块应该具备灵活的扩展能力来应对不同的监控数据类型，因此，我们希望能够通过配置文件来定义 Pipeline 的行为。比如，下面就是一个 yaml 格式的配置内容：\nname:pipeline_0# pipeline名称type:simple# pipeline类型input:# input插件定义name:input_0# input插件名称type:memory_mq# input插件类型，这里使用的是MemoryMQ作为输入context:# input插件的配置上下文topic:access_log.topic# 这里配置的是订阅的MemoryMQ主题filters:# filter插件链定义，多个filter插件组成一个filters插件链- name:filter_0# filter插件名称type:extract_log# filter插件类型- name:filter_1type:add_timestampoutput:# output插件定义name:output_0# output插件名称type:memory_db# output插件类型，这里使用的是MemoryDB作为输出context:# output插件上下文tableName:monitor_record_0# 这里配置的是MemoryDB表名另外，我们也希望 Monitor 模块支持多种类型的配置文件格式，比如，json 配置内容应该也支持：\n{ \u0026#34;name\u0026#34;: \u0026#34;pipeline_0\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;simple\u0026#34;, \u0026#34;input\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;input_0\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;memory_mq\u0026#34;, \u0026#34;context\u0026#34;: { \u0026#34;topic\u0026#34;: \u0026#34;access_log.topic\u0026#34; } }, \u0026#34;filters\u0026#34;: [ { \u0026#34;name\u0026#34;: \u0026#34;filter_0\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;extract_log\u0026#34; }, { \u0026#34;name\u0026#34;: \u0026#34;filter_1\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;add_timestamp\u0026#34; } ], \u0026#34;output\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;output_0\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;memory_db\u0026#34;, \u0026#34;context\u0026#34;: { \u0026#34;tableName\u0026#34;: \u0026#34;monitor_record_0\u0026#34; } } } 所以，整体的效果是这样的：\n可以看出，配置管理子模块中对象之间的关系，很符合抽象工厂模式的 UML 的结构，其中产品族就是 4 个插件配置对象，conf.Input、conf.Filter、conf.Output、conf.Pipeline，因此，我们下面使用抽象工厂模式来实现该子模块。\n代码实现 首先，我们先把各个配置对象（产品）定义好：\n// demo/monitor/config/config.go package config // 配置基础结构 type item struct { Name string `json:\u0026#34;name\u0026#34; yaml:\u0026#34;name\u0026#34;` PluginType string `json:\u0026#34;type\u0026#34; yaml:\u0026#34;type\u0026#34;` Ctx plugin.Context `json:\u0026#34;context\u0026#34; yaml:\u0026#34;context\u0026#34;` loadConf func(conf string, item interface{}) error // 封装不同配置文件的加载逻辑，实现多态的关键 } // Input配置对象 type Input item func (i *Input) Load(conf string) error { return i.loadConf(conf, i) } // Filter配置对象 type Filter item func (f *Filter) Load(conf string) error { return f.loadConf(conf, f) } // Output配置对象 type Output item func (o *Output) Load(conf string) error { return o.loadConf(conf, o) } // Pipeline配置对象 type Pipeline struct { item `yaml:\u0026#34;,inline\u0026#34;` // yaml嵌套时需要加上,inline  Input Input `json:\u0026#34;input\u0026#34; yaml:\u0026#34;input\u0026#34;` Filters []Filter `json:\u0026#34;filters\u0026#34; yaml:\u0026#34;filters,flow\u0026#34;` Output Output `json:\u0026#34;output\u0026#34; yaml:\u0026#34;output\u0026#34;` } func (p *Pipeline) Load(conf string) error { return p.loadConf(conf, p) } 在 Java/C++ 等面向对象的编程语言中，我们定义一个产品的不同实现的时，通常采用继承的方式，也即先定义一个基类封装好公共逻辑，再定义不同的继承自该基类的不同子类来实现具体的逻辑。比如，对于 Input 配置对象，在 Java 中可能是这样定义的：\n// 基类 public abstract class InputConfig implements Config { protected String name; protected InputType type; protected Context ctx; // 子类实现具体加载逻辑  @Override public abstract void load(String conf); ... } // Json子类 public class JsonInputConfig extends InputConfig { @Override public void load(String conf) { ... // Json配置文件加载逻辑  } } // yaml子类 public class YamlInputConfig extends InputConfig { @Override public void load(String conf) { ... // Yaml配置文件加载逻辑  } } 但是在 Go 语言中并没有继承的概念，也无法定义抽象基类，因此，我们通过定义一个函数对象 loadConf 来实现多态，它的类型是 func(conf string, item interface{}) error，具体做的事情就是解析 conf 字符串（配置文件内容），然后完成 item 的赋值。\n Go 语言中通过函数对象来实现多态的技巧，我们在介绍模板方法模式时也会用到。\n 接下来，我们定义抽象工厂接口：\n// demo/monitor/config/config_factory.go  // 关键点1: 定义抽象工厂接口，里面定义了产品族中各个产品的工厂方法 type Factory interface { CreateInputConfig() Input CreateFilterConfig() Filter CreateOutputConfig() Output CreatePipelineConfig() Pipeline } 然后是不同的实现：\n// demo/monitor/config/json_config_factory.go  // loadJson 加载json配置 func loadJson(conf string, item interface{}) error { return json.Unmarshal([]byte(conf), item) } // 关键点2: 实现抽象工厂接口 type JsonFactory struct {} func NewJsonFactory() *JsonFactory { return \u0026amp;JsonFactory{} } // CreateInputConfig 例子 {\u0026#34;name\u0026#34;:\u0026#34;input1\u0026#34;, \u0026#34;type\u0026#34;:\u0026#34;memory_mq\u0026#34;, \u0026#34;context\u0026#34;:{\u0026#34;topic\u0026#34;:\u0026#34;monitor\u0026#34;,...}} func (j JsonFactory) CreateInputConfig() Input { return Input{loadConf: loadJson} } // CreateFilterConfig 例子 [{\u0026#34;name\u0026#34;:\u0026#34;filter1\u0026#34;, \u0026#34;type\u0026#34;:\u0026#34;to_json\u0026#34;},{\u0026#34;name\u0026#34;:\u0026#34;filter2\u0026#34;, \u0026#34;type\u0026#34;:\u0026#34;add_timestamp\u0026#34;},...] func (j JsonFactory) CreateFilterConfig() Filter { return Filter{loadConf: loadJson} } // CreateOutputConfig 例子 {\u0026#34;name\u0026#34;:\u0026#34;output1\u0026#34;, \u0026#34;type\u0026#34;:\u0026#34;memory_db\u0026#34;, \u0026#34;context\u0026#34;:{\u0026#34;tableName\u0026#34;:\u0026#34;test\u0026#34;,...}} func (j JsonFactory) CreateOutputConfig() Output { return Output{loadConf: loadJson} } // CreatePipelineConfig 例子 {\u0026#34;name\u0026#34;:\u0026#34;pipline1\u0026#34;, \u0026#34;type\u0026#34;:\u0026#34;simple\u0026#34;, \u0026#34;input\u0026#34;:{...}, \u0026#34;filter\u0026#34;:{...}, \u0026#34;output\u0026#34;:{...}} func (j JsonFactory) CreatePipelineConfig() Pipeline { pipeline := Pipeline{} pipeline.loadConf = loadJson return pipeline } // demo/monitor/config/yaml_config_factory.go // loadYaml 加载yaml配置 func loadYaml(conf string, item interface{}) error { return yaml.Unmarshal([]byte(conf), item) } // YamlFactory Yaml配置工厂 type YamlFactory struct { } func NewYamlFactory() *YamlFactory { return \u0026amp;YamlFactory{} } func (y YamlFactory) CreateInputConfig() Input { return Input{loadConf: loadYaml} } func (y YamlFactory) CreateFilterConfig() Filter { return Filter{loadConf: loadYaml} } func (y YamlFactory) CreateOutputConfig() Output { return Output{loadConf: loadYaml} } func (y YamlFactory) CreatePipelineConfig() Pipeline { pipeline := Pipeline{} pipeline.loadConf = loadYaml return pipeline } 使用方法如下；\n// demo/monitor/monitor_system.go type System struct { plugins map[string]plugin.Plugin // 关键点3: 在使用时依赖抽象工厂接口  configFactory config.Factory } func NewSystem(configFactory config.Factory) *System { return \u0026amp;System{ plugins: make(map[string]plugin.Plugin), configFactory: configFactory, } } func (s *System) LoadConf(conf string) error { pipelineConf := s.configFactory.CreatePipelineConfig() if err := pipelineConf.Load(conf); err != nil { return err } ... } // demo/example.go func main() { // 关键点4: 在初始化是依赖注入具体的工厂实现  monitorSys := monitor.NewSystem(config.NewYamlFactory()) conf, _ := ioutil.ReadFile(\u0026#34;monitor_pipeline.yaml\u0026#34;) monitorSys.LoadConf(string(conf)) ... } 总结实现抽象工厂模式的几个关键点：\n 定义抽象工厂接口，里面包含创建各个产品的工厂方法定义。 定义抽象工厂接口的实现类。 在客户端程序中依赖抽象工厂接口，通过接口来完成产品的创建。 在客户端程序初始化时，将抽象工厂接口的具体实现依赖注入进去。  典型应用场景  系统中有产品族，产品有不同的实现，且需要支持扩展。 希望产品的创建逻辑和业务逻辑分离。  优缺点 优点  产品创建逻辑和业务逻辑分离，符合单一职责原理。 具有较高的可扩展性，新增一种产品族实现，只需新增一个抽象工厂实现即可。  缺点  新增一些对象/接口的定义，滥用会导致代码更加复杂。  与其他模式的关联 很多同学容易将工厂方法模式和抽象工厂模式混淆，工厂方法模式主要应用在单个产品的实例化场景；抽象工厂模式则应用在“产品族”的实例化场景，可以看成是工厂方法模式的一种演进。\n另外，抽象工厂接口的实现类，有时也会通过单例模式来实现。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] 【Go实现】实践GoF的23种设计模式：工厂方法模式, 元闰子\n[3] Design Patterns, Chapter 3. Creational Patterns, GoF\n更多文章请关注微信公众号：元闰子的邀请\n  ","date":"2022-05-29T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E6%8A%BD%E8%B1%A1%E5%B7%A5%E5%8E%82%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：抽象工厂模式"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：建造者模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简述 工厂方法模式（Factory Method Pattern）跟上一篇讨论的建造者模式类似，都是将对象创建的逻辑封装起来，为使用者提供一个简单易用的对象创建接口。两者在应用场景上稍有区别，建造者模式常用于需要传递多个参数来进行实例化的场景；工厂方法模式常用于不指定对象具体类型的情况下创建对象的场景。\nUML 结构 代码实现 示例 在简单的分布式应用系统（示例代码工程）中，我们设计了 Sidecar 边车模块， Sidecar 的作用是为了给原生的 Socket 增加额外的功能，比如流控、日志等。\nSidecar 模块的设计运用了装饰者模式，修饰的是 Socket 。所以客户端其实是把 Sidecar 当成是 Socket 来使用了，比如：\n// demo/network/http/http_client.go package http // 创建一个新的HTTP客户端，以Socket接口作为入参 func NewClient(socket network.Socket, ip string) (*Client, error) { ... // 一些初始化逻辑 \treturn client, nil } // 使用NewClient时，我们可以传入Sidecar来给Http客户端附加额外的流控功能 client, err := http.NewClient(sidecar.NewFlowCtrlSidecar(network.DefaultSocket()), \u0026#34;192.168.0.1\u0026#34;) 在服务消息中介中，每次收到上游服务的 HTTP 请求，都会调用 http.NewClient 来创建一个 HTTP 客户端，并通过它将请求转发给下游服务：\ntype ServiceMediator struct { ... server *http.Server } // Forward 转发请求，请求URL为 /{serviceType}+ServiceUri 的形式，如/serviceA/api/v1/task func (s *ServiceMediator) Forward(req *http.Request) *http.Response { ... // 发现下游服务的目的IP地址  dest, err := s.discovery(svcType) // 创建HTTP客户端，硬编码sidecar.NewFlowCtrlSidecar(network.DefaultSocket())  client, err := http.NewClient(sidecar.NewFlowCtrlSidecar(network.DefaultSocket()), s.localIp) // 通过HTTP客户端转发请求  resp, err := client.Send(dest, forwardReq) ... } 在上述实现中，我们在调用 http.NewClient 时把 sidecar.NewFlowCtrlSidecar(network.DefaultSocket()) 硬编码进去了，那么如果以后要扩展 Sidecar ，就得修改这段代码逻辑，这违反了开闭原则 OCP。\n有经验的同学可能会想到，可以通过让 ServiceMediator 依赖 Socket 接口，在 Forward 方法调用 http.NewClient 时把 Socket 接口作为入参；然后在 ServiceMediator 初始化时，将具体类型的 Sidecar 注入到 ServiceMediator 中：\ntype ServiceMediator struct { ... server *http.Server // 依赖Socket抽象接口  socket network.Socket } // Forward 转发请求，请求URL为 /{serviceType}+ServiceUri 的形式，如/serviceA/api/v1/task func (s *ServiceMediator) Forward(req *http.Request) *http.Response { ... // 发现下游服务的目的IP地址  dest, err := s.discovery(svcType) // 创建HTTP客户端，将s.socket抽象接口作为入参  client, err := http.NewClient(s.socket, s.localIp) // 通过HTTP客户端转发请求  resp, err := client.Send(dest, forwardReq) ... } // 在ServiceMediator初始化时，将具体类型的Sidecar注入到ServiceMediator中 mediator := \u0026amp;ServiceMediator{ socket: sidecar.NewFlowCtrlSidecar(network.DefaultSocket()) } 上述的修改，从原来依赖具体，改成了依赖抽象，符合了开闭原则。\n但是， Forward 方法存在并发调用的场景，因此它希望每次被调用时都创建一个新的 Socket/Sidecar 来完成网络通信，否则就需要加锁来保证并发安全。而上述的修改会导致在 ServiceMediator 的生命周期内都使用同一个 Socket/Sidecar，显然不符合要求。\n因此，我们需要一个方法，既能够满足开闭原则，而且在每次调用Forward 方法时也能够创建新的 Socket/Sidecar 实例。工厂方法模式恰好就能满足这两点要求，下面我们通过它来完成代码的优化。\n实现 // demo/sidecar/sidecar_factory.go  // 关键点1: 定义一个Sidecar工厂抽象接口 type Factory interface { // 关键点2: 工厂方法返回Socket抽象接口 \tCreate() network.Socket } // 关键点3: 按照需要实现具体的工厂  // demo/sidecar/raw_socket_sidecar_factory.go // RawSocketFactory 只具备原生socket功能的sidecar，实现了Factory接口 type RawSocketFactory struct { } func (r RawSocketFactory) Create() network.Socket { return network.DefaultSocket() } // demo/sidecar/all_in_one_sidecar_factory.go // AllInOneFactory 具备所有功能的sidecar工厂，实现了Factory接口 type AllInOneFactory struct { producer mq.Producible } func (a AllInOneFactory) Create() network.Socket { return NewAccessLogSidecar(NewFlowCtrlSidecar(network.DefaultSocket()), a.producer) } 上述代码中，我们定义了一个工厂抽象接口 Factory ，并有了 2 个具体的实现 RawSocketFactory 和 AllInOneFactory。最后， ServiceMediator 依赖 Factory ，并在 Forward 方法中通过 Factory 来创建新的 Socket/Sidecar ：\n// demo/service/mediator/service_mediator.go  type ServiceMediator struct { ... server *http.Server // 关键点4: 客户端依赖Factory抽象接口  sidecarFactory sidecar.Factory } // Forward 转发请求，请求URL为 /{serviceType}+ServiceUri 的形式，如/serviceA/api/v1/task func (s *ServiceMediator) Forward(req *http.Request) *http.Response { ... // 发现下游服务的目的IP地址  dest, err := s.discovery(svcType) // 创建HTTP客户端，调用sidecarFactory.Create()生成Socket作为入参  client, err := http.NewClient(s.sidecarFactory.Create(), s.localIp) // 通过HTTP客户端转发请求  resp, err := client.Send(dest, forwardReq) ... } // 关键点5: 在ServiceMediator初始化时，将具体类型的sidecar.Factory注入到ServiceMediator中 mediator := \u0026amp;ServiceMediator{ sidecarFactory: \u0026amp;AllInOneFactory{} // sidecarFactory: \u0026amp;RawSocketFactory{} } 下面总结实现工厂方法模式的几个关键点：\n 定义一个工厂方法抽象接口，比如前文中的 sidecar.Factory。 工厂方法中，返回需要创建的对象/接口，比如 network.Socket。其中，工厂方法通常命名为 Create。 按照具体需要，定义工厂方法抽象接口的具体实现对象，比如 RawSocketFactory 和 AllInOneFactory。 客户端使用时，依赖工厂方法抽象接口。 在客户端初始化阶段，完成具体工厂对象的依赖注入。  扩展 Go 风格的实现 前文的工厂方法模式实现，是非常典型的面向对象风格，下面我们给出一个更具 Go 风格的实现。\n// demo/sidecar/sidecar_factory_func.go  // 关键点1: 定义Sidecar工厂方法类型 type FactoryFunc func() network.Socket // 关键点2: 按需定义具体的工厂方法实现，注意这里定义的是工厂方法的工厂方法，返回的是FactoryFunc工厂方法类型 func RawSocketFactoryFunc() FactoryFunc { return func() network.Socket { return network.DefaultSocket() } } func AllInOneFactoryFunc(producer mq.Producible) FactoryFunc { return func() network.Socket { return NewAccessLogSidecar(NewFlowCtrlSidecar(network.DefaultSocket()), producer) } } type ServiceMediator struct { ... server *http.Server // 关键点3: 客户端依赖FactoryFunc工厂方法类型  sidecarFactoryFunc FactoryFunc } func (s *ServiceMediator) Forward(req *http.Request) *http.Response { ... dest, err := s.discovery(svcType) // 关键点4: 创建HTTP客户端，调用sidecarFactoryFunc()生成Socket作为入参  client, err := http.NewClient(s.sidecarFactoryFunc(), s.localIp) resp, err := client.Send(dest, forwardReq) ... } // 关键点5: 在ServiceMediator初始化时，将具体类型的FactoryFunc注入到ServiceMediator中 mediator := \u0026amp;ServiceMediator{ sidecarFactoryFunc: RawSocketFactoryFunc() // sidecarFactory: AllInOneFactoryFunc(producer) } 上述的实现，利用了 Go 语言中函数作为一等公民的特点，少定义了几个 interface 和 struct，代码更加的简洁。\n几个实现的关键点与面向对象风格的实现类似。值得注意的是 关键点2 ，我们相当于定义了一个工厂方法的工厂方法，这么做是为了利用函数闭包的特点来传递参数。如果直接定义工厂方法，那么 AllInOneFactoryFunc 的实现是下面这样的，无法实现多态：\n// 并非FactoryFunc类型，无法实现多态 func AllInOneFactoryFunc(producer mq.Producible) network.Socket { return NewAccessLogSidecar(NewFlowCtrlSidecar(network.DefaultSocket()), producer) } 简单工厂 工厂方法模式的另一个变种是简单工厂，它并不通过多态，而是通过简单的 switch-case/if-else 条件判断来决定创建哪种产品：\n// demo/sidecar/sidecar_simple_factory.go  // 关键点1: 定义sidecar类型 type Type uint8 // 关键点2: 按照需要定义sidecar具体类型 const ( Raw Type = iota AllInOne ) // 关键点3: 定义简单工厂对象 type SimpleFactory struct { producer mq.Producible } // 关键点4: 定义工厂方法，入参为sidecar类型，根据switch-case或者if-else来创建产品 func (s SimpleFactory) Create(sidecarType Type) network.Socket { switch sidecarType { case Raw: return network.DefaultSocket() case AllInOne: return NewAccessLogSidecar(NewFlowCtrlSidecar(network.DefaultSocket()), s.producer) default: return nil } } // 关键点5: 创建产品时传入具体的sidecar类型，比如sidecar.AllInOne simpleFactory := \u0026amp;sidecar.SimpleFactory{producer: producer} sidecar := simpleFactory.Create(sidecar.AllInOne) 静态工厂方法 静态工厂方法是 Java/C++ 的说法，主要用于替代构造函数来完成对象的实例化，能够让代码的可读性更好，而且起到了与客户端解耦的作用。比如 Java 的静态工厂方法实现如下：\npublic class Packet { private final Endpoint src; private final Endpoint dest; private final Object payload; private Packet(Endpoint src, Endpoint dest, Object payload) { this.src = src; this.dest = dest; this.payload = payload; } // 静态工厂方法  public static Packet of(Endpoint src, Endpoint dest, Object payload) { return new Packet(src, dest, payload); } ... } // 用法 packet = Packet.of(src, dest, payload) Go 中并没有静态一说，直接通过普通函数来完成对象的构造即可，比如：\n// demo/network/packet.go type Packet struct { src Endpoint dest Endpoint payload interface{} } // 工厂方法 func NewPacket(src, dest Endpoint, payload interface{}) *Packet { return \u0026amp;Packet{ src: src, dest: dest, payload: payload, } } // 用法 packet := NewPacket(src, dest, payload) 典型应用场景  对象实例化逻辑较为复杂时，可选择使用工厂方法模式/简单工厂/静态工厂方法来进行封装，为客户端提供一个易用的接口。 如果实例化的对象/接口涉及多种实现，可以使用工厂方法模式实现多态。 普通对象的创建，推荐使用静态工厂方法，比直接的实例化（比如 \u0026amp;Packet{src: src, dest: dest, payload: payload}）具备更好的可读性和低耦合。  优缺点 优点  代码的可读性更好。 与客户端程序解耦，当实例化逻辑变更时，只需改动工厂方法即可，避免了霰弹式修改。  缺点  引入工厂方法模式会新增一些对象/接口的定义，滥用会导致代码更加复杂。  与其他模式的关联 很多同学容易将工厂方法模式和抽象工厂模式混淆，抽象工厂模式主要运用在实例化“产品族”的场景，可以看成是工厂方法模式的一种演进。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] Design Patterns, Chapter 3. Creational Patterns, GoF\n[3] Factory patterns in Go (Golang), Soham Kamani\n[4] 工厂方法, 维基百科\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-05-21T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E5%B7%A5%E5%8E%82%E6%96%B9%E6%B3%95%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：工厂方法模式"},{"content":"开始 前几天，看到这样的一句话：\n 生活中往往没有标准答案，当你要求别人给你一个标准答案时，就是思想上的懒惰了。\n 当我们说一个人“懒惰”时，通常都指他贪玩、不爱学习、好吃懒做。如果按照这个标准，自问我也不是一个懒惰的人。读书时就一直被当成是热爱学习的“好学生”；工作后也一直在践行曾国藩的“少睡多做，一人之生气”。\n但是，当我读到 思想上的懒惰 时，心里一惊，感觉自我设立的“勤奋”人设，在心中崩塌了。\n读书是很容易的事，可是思考却很难 富兰克林说，“读书是很容易的事，可是思考却很难”。\n在当代，读书更不是件难事。智能手机上的读书软件、听书 APP 层出不穷，只要利用好碎片时间，我们就能读书。工作之余，自己也读了不少书，特别是看完《穷查理宝典》后，开始践行查理芒格的“多学科交叉”理论，书籍涉猎也更广了。然后在年终时，把一年里读过的书列出来，给自己设立一个”勤奋“的人设，幻想着有一天能够融汇贯通，走上人生巅峰。\n人们总说读书是润物细无声，但几年过去了，读书好像并没有给自己的工作和生活带来多大的改变，难道努力和勤奋都配不上回报吗？\n最近算是想明白了，是自己缺少思考，陷入了思想上的懒惰。\n回顾过去的读书经历，也只是读而已。美其名曰追求知识广度，想着留个印象，以后总会有用处；实则在偷懒，逃避思考，毕竟吃透一个知识或问题，真的太烧脑、太耗时了。\n我想，很多人也会有类似的这些经历：复习时遇到一道题目，一时弄不太懂，“算了，时间来不及了，先记住，万一考试遇上了还能背出来”；工作时碰到一个程序 Bug，一时定位不出来，重启一下竟然好了，“算了，反正也不复现了，有问题也肯定是环境问题”。\n这些经历里，时间也许是个问题，但根本问题还是出在人类的懒惰天性。犹太人有句谚语，“人类一思考，上帝就发笑”，而我确实也做到了不给上帝发笑的机会。\n未经审视的人生是不值得过的 苏格拉底说，“未经审视的人生是不值得过的”。\n如果只在读书上缺乏思考，最多也就缺少点思想深度。但如果在人生规划上也缺乏思考，可能就会浑浑噩噩地过完一辈子。\n在当代的中国社会里，人们常常因为一套房子，就被锁在了一座城市里；因为一摞金钱，就忘掉了梦想；因为一份工作，就一直忍气吞声。在世俗意义上的成功的驱动之下，我们很容易疲于奔命，却很少停下来思考和规划自己的人生，问问自己到底真正想要的是什么。\n也许，只有等到年过花甲了，回过头来一看，才发现人生少了一点精彩。\n回顾过去二十几年的人生，自己也是一直在朝着世俗意义的成功稳步前进。努力读书，勤奋工作，工作之余还会读书写作，在很多家人朋友的眼里，也算是过得很好的了。但是，这段未经审视的人生，也许还可以更精彩一些。\n最后 这段时间也慢慢开始尝试着去思考了，在读书的时候，在独处的时候。也许跟最近接触了很多哲学方面的知识有关。以前觉得哲学家们都是些闲人，老是想一些不切实际的问题，最近才感受到，他们才是人类的明灯。平时多接触些西方哲学，学习他们刨根问底的特质，辩证思考问题的方法，慢慢的，自己也会去多问几个为什么了。\n至于如何才能在读书时培养深入思考的习惯，我想，做笔记会是一个很好的方法。做笔记能够让读书速度慢下来，有更多思考的间隙；而且把当时的想法记录下来，后面再回看，会有一种自豪感。\n至于如何才能过上精彩的人生，我想，需要勇气。\n","date":"2022-05-08T00:00:00Z","permalink":"https://yrunz.com/p/%E6%87%92%E6%83%B0/","title":"懒惰"},{"content":"开篇词 一直以来，时不时都会收到一些小伙伴的私信或留言，问“文章中的配图是用什么工具画的？”，比如：\n              我的回答也一直都很简练，“Keynote 画的”。细想一下，其实有种听君一席话，如听一席话的感觉。所以，今天专门花点时间总结一下自己文章配图的一些风格选择。\n用 MacOS 的小伙伴应该都对 Keynote 很熟悉，可以把它看成是 MacOS 上的 PowerPoint。\n想到用 Keynote 来画图的源头，是从看到《A successful Git branching model》这篇文章上很好看的手绘风格配图开始：\n        几轮 Google 之后才找到原来是通过 Keynote 画出来的，从此迷上了用 Keynote 来画这种手绘风格的配图。\n中途也有尝试过其他手绘风格的画图软件，比如 Excalidraw。Excalidraw 是一款开源免费的手绘风格画图软件，相比 Keynote，它显得更加的手绘风。不过对中文字体的支持太弱了，与英文字体放在一起显得极其不协调，最后也就放弃了。\n        几番对比之后，还是觉得 Keynote 在易用性、效率、配色、样式等方面更好用。下面，开始简单介绍下在使用 Keynote 画图时，主题、样式、形状、配色、字体等方面的一些选择和搭配。\n主题 Keynote 上有很多内置的主题，主题们都非常的好看（个人觉得 MacOS 在审美方面还是比 Windows 强不少）：\n主题的选取其实就是配图中背景的选取，既然给自己的配图定的是手绘风格，自然就要选一个跟手绘风搭配的主题。看来看去，还是觉得 草图 这个主题最顺眼。羊皮纸颜色的背景，跟手绘风很搭配。\n草图 主题中的空白背景和网格背景都很好看，根据个人喜好选择即可，个人比较喜欢空白背景的简约风。\n        样式 手绘风格的配图主要由 Keynote 上内置的 线条描边 和 图案边框 样式定格。在 Keynote 右侧的 样式 栏就能够选择，上面有好几种手绘风格的样式，个人比较喜欢下面的这种：\n        形状 Keynote 上内置的形状和图案已经够用了，再配合上内置的手绘风格线条/边框样式就很好看了：\n配色 关于图案配色，网上有很多颜色搭配网站，比如 Color Hunt， Nippon Colors， Color Drop，Coolors。可以按照自己的喜好来选择，实在有选择困难症的，选一个点赞数量较多的配色也基本不会错。\n              不过个人还是比较喜欢 草图 主题上的颜色搭配。一方面，它看起来很顺眼；另一方面，因为是主题内置，所以用起来很方便，不用重新取色搭配。个人用得最多的是最上层的浅色搭配，看起来明亮轻快些，不至于太压抑。\n另外，在线条和字体颜色的选择上，个人更喜欢使用内置的浅黑色，感觉它和手绘风格更加搭一些。\n字体 既然是手绘风格，就不能选择太过正式的字体。Keynote 内置的字体中，个人觉得跟手绘风格较搭的有 娃娃体、手札体、翩翩体、凌惠体 四种字体。最开始选择的是 娃娃体，后面觉得有点太 Q 了，就选择了各方面较为平衡的 手札体。\n最后 好的配图对文章是一个巨大的加分项，能够让人有看下去的欲望。而画好配图是一件很费时费力的事情，特别是工具使用不熟练的时候。\n画图软件有很多，比如 Keynote、PowerPoint、Visio、亿图、Excalidraw、Sketch、OmniGraffle、ProcessOn 等。有免费的，也有付费的。有功能齐全，但用法复杂的；也有基本够用，但用法简单的。找到一个合适自己的就行，关键是要提升画图效率，避免在配图上花费太多时间。\n最后，提升使用 Keynote 画图效率的一个好方法，就是将常用的图案预先画好，并保存到一个 Keynote 文稿上，等用到时再复制-粘贴使用即可。\n 相关链接 [1] Excalidraw: https://excalidraw.com\n[2] Color Hunt: https://www.colorhunt.co\n[3] Nippon Colors: https://nipponcolors.com\n[4] Color Drop: https://colordrop.io\n[5] Coolors: https://coolors.co\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-05-07T00:00:00Z","permalink":"https://yrunz.com/p/%E7%94%A8keynote%E7%94%BB%E5%87%BA%E6%89%8B%E7%BB%98%E9%A3%8E%E6%A0%BC%E7%9A%84%E9%85%8D%E5%9B%BE/","title":"用Keynote画出手绘风格的配图"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：单例模式\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简述 在程序设计中，我们会经常遇到一些复杂的对象，其中有很多成员属性，甚至嵌套着多个复杂的对象。这种情况下，创建这个复杂对象就会变得很繁琐。对于 C++/Java 而言，最常见的表现就是构造函数有着长长的参数列表：\nMyObject obj = new MyObject(param1, param2, param3, param4, param5, param6, ...) 对于 Go 语言来说，最常见的表现就是多层的嵌套实例化：\nobj := \u0026amp;MyObject{ Field1: \u0026amp;Field1 { Param1: \u0026amp;Param1 { Val: 0, }, Param2: \u0026amp;Param2 { Val: 1, }, ... }, Field2: \u0026amp;Field2 { Param3: \u0026amp;Param3 { Val: 2, }, ... }, ... } 上述的对象创建方法有两个明显的缺点：（1）对使用者不友好，使用者在创建对象时需要知道的细节太多；（2）代码可读性很差。\n针对这种对象成员较多，创建对象逻辑较为繁琐的场景，非常适合使用建造者模式来进行优化。\n建造者模式的作用有如下几个：\n1、封装复杂对象的创建过程，使对象使用者不感知复杂的创建逻辑。2、可以一步步按照顺序对成员进行赋值，或者创建嵌套对象，并最终完成目标对象的创建。3、对多个对象复用同样的对象创建逻辑。其中，第1和第2点比较常用，下面对建造者模式的实现也主要是针对这两点进行示例。\nUML 结构 代码实现 示例 在简单的分布式应用系统（示例代码工程）中，我们定义了服务注册中心，提供服务注册、去注册、更新、 发现等功能。要实现这些功能，服务注册中心就必须保存服务的信息，我们把这些信息放在了 ServiceProfile 这个数据结构上，定义如下：\n// demo/service/registry/model/service_profile.go // ServiceProfile 服务档案，其中服务ID唯一标识一个服务实例，一种服务类型可以有多个服务实例 type ServiceProfile struct { Id string // 服务ID  Type ServiceType // 服务类型  Status ServiceStatus // 服务状态  Endpoint network.Endpoint // 服务Endpoint  Region *Region // 服务所属region  Priority int // 服务优先级，范围0～100，值越低，优先级越高  Load int // 服务负载，负载越高表示服务处理的业务压力越大 } // demo/service/registry/model/region.go // Region 值对象，每个服务都唯一属于一个Region type Region struct { Id string Name string Country string } // demo/network/endpoint.go // Endpoint 值对象，其中ip和port属性为不可变，如果需要变更，需要整对象替换 type Endpoint struct { ip string port int } 实现 如果按照直接实例化方式应该是这样的：\n// 多层的嵌套实例化 profile := \u0026amp;ServiceProfile{ Id: \u0026#34;service1\u0026#34;, Type: \u0026#34;order\u0026#34;, Status: Normal, Endpoint: network.EndpointOf(\u0026#34;192.168.0.1\u0026#34;, 8080), Region: \u0026amp;Region{ // 需要知道对象的实现细节 \tId: \u0026#34;region1\u0026#34;, Name: \u0026#34;beijing\u0026#34;, Country: \u0026#34;China\u0026#34;, }, Priority: 1, Load: 100, } 虽然 ServiceProfile 结构体嵌套的层次不多，但是从上述直接实例化的代码来看，确实存在对使用者不友好和代码可读性较差的缺点。比如，使用者必须先对 Endpoint 和 Region 进行实例化，这实际上是将 ServiceProfile 的实现细节暴露给使用者了。下面我们引入建造者模式对代码进行优化重构：\n// demo/service/registry/model/service_profile.go // 关键点1: 为ServiceProfile定义一个Builder对象 type serviceProfileBuild struct { // 关键点2: 将ServiceProfile作为Builder的成员属性 \tprofile *ServiceProfile } // 关键点3: 定义构建ServiceProfile的方法 func (s *serviceProfileBuild) WithId(id string) *serviceProfileBuild { s.profile.Id = id // 关键点4: 返回Builder接收者指针，支持链式调用 \treturn s } func (s *serviceProfileBuild) WithType(serviceType ServiceType) *serviceProfileBuild { s.profile.Type = serviceType return s } func (s *serviceProfileBuild) WithStatus(status ServiceStatus) *serviceProfileBuild { s.profile.Status = status return s } func (s *serviceProfileBuild) WithEndpoint(ip string, port int) *serviceProfileBuild { s.profile.Endpoint = network.EndpointOf(ip, port) return s } func (s *serviceProfileBuild) WithRegion(regionId, regionName, regionCountry) *serviceProfileBuild { s.profile.Region = \u0026amp;Region{Id: regionId, Name: regionName, Country: regionCountry} return s } func (s *serviceProfileBuild) WithPriority(priority int) *serviceProfileBuild { s.profile.Priority = priority return s } func (s *serviceProfileBuild) WithLoad(load int) *serviceProfileBuild { s.profile.Load = load return s } // 关键点5: 定义Build方法，在链式调用的最后调用，返回构建好的ServiceProfile func (s *serviceProfileBuild) Build() *ServiceProfile { return s.profile } // 关键点6: 定义一个实例化Builder对象的工厂方法 func NewServiceProfileBuilder() *serviceProfileBuild { return \u0026amp;serviceProfileBuild{profile: \u0026amp;ServiceProfile{}} } 实现建造者模式有 6 个关键点：\n 为 ServiceProfile 定义一个 Builder 对象 serviceProfileBuild，通常我们将它设计为包内可见，来限制客户端的滥用。 把需要构建的 ServiceProfile 作为 Builder 对象 serviceProfileBuild 的成员属性，用来存储构建过程中的状态。 为 Builder 对象 serviceProfileBuild 定义用来构建 ServiceProfile 的一系列方法，上述代码中我们使用了 WithXXX 的风格。 在构建方法中返回 Builder 对象指针本身，也即接收者指针，用来支持链式调用，提升客户端代码的简洁性。 为 Builder 对象定义 Build() 方法，返回构建好的 ServiceProfile 实例，在链式调用的最后调用。 定义一个实例化 Builder 对象的工厂方法 NewServiceProfileBuilder()。  那么，使用建造者模式实例化逻辑是这样的：\n// 建造者模式的实例化方法 profile := NewServiceProfileBuilder(). WithId(\u0026#34;service1\u0026#34;). WithType(\u0026#34;order\u0026#34;). WithStatus(Normal). WithEndpoint(\u0026#34;192.168.0.1\u0026#34;, 8080). WithRegion(\u0026#34;region1\u0026#34;, \u0026#34;beijing\u0026#34;, \u0026#34;China\u0026#34;). WithPriority(1). WithLoad(100). Build() 当使用建造者模式来进行对象创建时，使用者不再需要知道对象具体的实现细节（这里体现为无须预先实例化 Endpoint 和 Region 对象），代码可读性、简洁性也更好了。\n扩展 Functional Options 模式 进一步思考，其实前文提到的建造者实现方式，还有 2 个待改进点：\n 我们额外新增了一个 Builder 对象，如果能够把 Builder 对象省略掉，同时又能避免长长的入参列表就更好了。 熟悉 Java 的同学应该能够感觉出来，这种实现具有很强的“Java 风格”。并非说这种风格不好，而是在 Go 中理应有更具“Go 风格”的建造者模式实现。  针对这两点，我们可以通过 Functional Options 模式 来优化。Functional Options 模式也是用来构建对象的，这里我们也把它看成是建造者模式的一种扩展。它利用了 Go 语言中函数作为一等公民的特点，结合函数的可变参数，达到了优化上述 2 个改进点的目的。使用 Functional Options 模式的实现是这样的：\n// demo/service/registry/model/service_profile_functional_options.go // 关键点1: 定义构建ServiceProfile的functional option，以*ServiceProfile作为入参的函数 type ServiceProfileOption func(profile *ServiceProfile) // 关键点2: 定义实例化ServiceProfile的工厂方法，使用ServiceProfileOption作为可变入参 func NewServiceProfile(svcId string, svcType ServiceType, options ...ServiceProfileOption) *ServiceProfile { // 关键点3: 可为特定的字段提供默认值 \tprofile := \u0026amp;ServiceProfile{ Id: svcId, Type: svcType, Status: Normal, Endpoint: network.EndpointOf(\u0026#34;192.168.0.1\u0026#34;, 80), Region: \u0026amp;Region{Id: \u0026#34;region1\u0026#34;, Name: \u0026#34;beijing\u0026#34;, Country: \u0026#34;China\u0026#34;}, Priority: 1, Load: 100, } // 关键点4: 通过ServiceProfileOption来修改字段 \tfor _, option := range options { option(profile) } return profile } // 关键点5: 定义一系列构建ServiceProfile的方法，在ServiceProfileOption实现构建逻辑，并返回ServiceProfileOption func Status(status ServiceStatus) ServiceProfileOption { return func(profile *ServiceProfile) { profile.Status = status } } func Endpoint(ip string, port int) ServiceProfileOption { return func(profile *ServiceProfile) { profile.Endpoint = network.EndpointOf(ip, port) } } func SvcRegion(svcId, svcName, svcCountry string) ServiceProfileOption { return func(profile *ServiceProfile) { profile.Region = \u0026amp;Region{ Id: svcId, Name: svcName, Country: svcCountry, } } } func Priority(priority int) ServiceProfileOption { return func(profile *ServiceProfile) { profile.Priority = priority } } func Load(load int) ServiceProfileOption { return func(profile *ServiceProfile) { profile.Load = load } } 实现 Functional Options 模式有 5 个关键点：\n 定义 Functional Option 类型 ServiceProfileOption，本质上是一个入参为构建对象 ServiceProfile 的指针类型。（注意必须是指针类型，值类型无法达到修改目的） 定义构建 ServiceProfile 的工厂方法，以 ServiceProfileOption 的可变参数作为入参。函数的可变参数就意味着可以不传参，因此一些必须赋值的属性建议还是定义对应的函数入参。 可为特定的属性提供默认值，这种做法在 为配置对象赋值的场景 比较常见。 在工厂方法中，通过 for 循环利用 ServiceProfileOption 完成构建对象的赋值。 定义一系列的构建方法，以需要构建的属性作为入参，返回 ServiceProfileOption 对象，并在ServiceProfileOption 中实现属性赋值。  Functional Options 模式 的实例化逻辑是这样的：\n// Functional Options 模式的实例化逻辑 profile := NewServiceProfile(\u0026#34;service1\u0026#34;, \u0026#34;order\u0026#34;, Status(Normal), Endpoint(\u0026#34;192.168.0.1\u0026#34;, 8080), SvcRegion(\u0026#34;region1\u0026#34;, \u0026#34;beijing\u0026#34;, \u0026#34;China\u0026#34;), Priority(1), Load(100)) 相比于传统的建造者模式，Functional Options 模式的使用方式明显更加的简洁，也更具“Go 风格”了。\nFluent API 模式 前文中，不管是传统的建造者模式，还是 Functional Options 模式，我们都没有限定属性的构建顺序，比如：\n// 传统建造者模式不限定属性的构建顺序 profile := NewServiceProfileBuilder(). WithPriority(1). // 先构建Priority也完全没问题  WithId(\u0026#34;service1\u0026#34;). ... // Functional Options 模式也不限定属性的构建顺序 profile := NewServiceProfile(\u0026#34;service1\u0026#34;, \u0026#34;order\u0026#34;, Priority(1), // 先构建Priority也完全没问题 \tStatus(Normal), ... 但是在一些特定的场景，对象的属性是要求有一定的构建顺序的，如果违反了顺序，可能会导致一些隐藏的错误。当然，我们可以与使用者的约定好属性构建的顺序，但这种约定是不可靠的，你很难保证使用者会一直遵守该约定。所以，更好的方法应该是通过接口的设计来解决问题， Fluent API 模式 诞生了。下面，我们使用 Fluent API 模式进行实现：\n// demo/service/registry/model/service_profile_fluent_api.go type ( // 关键点1: 为ServiceProfile定义一个Builder对象 \tfluentServiceProfileBuilder struct { // 关键点2: 将ServiceProfile作为Builder的成员属性 \tprofile *ServiceProfile } // 关键点3: 定义一系列构建属性的fluent接口，通过方法的返回值控制属性的构建顺序 \tidBuilder interface { WithId(id string) typeBuilder } typeBuilder interface { WithType(svcType ServiceType) statusBuilder } statusBuilder interface { WithStatus(status ServiceStatus) endpointBuilder } endpointBuilder interface { WithEndpoint(ip string, port int) regionBuilder } regionBuilder interface { WithRegion(regionId, regionName, regionCountry string) priorityBuilder } priorityBuilder interface { WithPriority(priority int) loadBuilder } loadBuilder interface { WithLoad(load int) endBuilder } // 关键点4: 定义一个fluent接口返回完成构建的ServiceProfile，在最后调用链的最后调用 \tendBuilder interface { Build() *ServiceProfile } ) // 关键点5: 为Builder定义一系列构建方法，也即实现关键点3中定义的Fluent接口 func (f *fluentServiceProfileBuilder) WithId(id string) typeBuilder { f.profile.Id = id return f } func (f *fluentServiceProfileBuilder) WithType(svcType ServiceType) statusBuilder { f.profile.Type = svcType return f } func (f *fluentServiceProfileBuilder) WithStatus(status ServiceStatus) endpointBuilder { f.profile.Status = status return f } func (f *fluentServiceProfileBuilder) WithEndpoint(ip string, port int) regionBuilder { f.profile.Endpoint = network.EndpointOf(ip, port) return f } func (f *fluentServiceProfileBuilder) WithRegion(regionId, regionName, regionCountry string) priorityBuilder { f.profile.Region = \u0026amp;Region{ Id: regionId, Name: regionName, Country: regionCountry, } return f } func (f *fluentServiceProfileBuilder) WithPriority(priority int) loadBuilder { f.profile.Priority = priority return f } func (f *fluentServiceProfileBuilder) WithLoad(load int) endBuilder { f.profile.Load = load return f } func (f *fluentServiceProfileBuilder) Build() *ServiceProfile { return f.profile } // 关键点6: 定义一个实例化Builder对象的工厂方法 func NewFluentServiceProfileBuilder() idBuilder { return \u0026amp;fluentServiceProfileBuilder{profile: \u0026amp;ServiceProfile{}} } 实现 Fluent API 模式有 6 个关键点，大部分与传统的建造者模式类似：\n 为 ServiceProfile 定义一个 Builder 对象 fluentServiceProfileBuilder。 把需要构建的 ServiceProfile 设计为 Builder 对象 fluentServiceProfileBuilder 的成员属性。 定义一系列构建属性的 Fluent 接口，通过方法的返回值控制属性的构建顺序，这是实现 Fluent API 的关键。比如 WithId 方法的返回值是 typeBuilder 类型，表示紧随其后的就是 WithType 方法。 定义一个 Fluent 接口（这里是 endBuilder）返回完成构建的 ServiceProfile，在最后调用链的最后调用。 为 Builder 定义一系列构建方法，也即实现关键点 3 中定义的 Fluent 接口，并在构建方法中返回 Builder 对象指针本身。 定义一个实例化 Builder 对象的工厂方法 NewFluentServiceProfileBuilder()，返回第一个 Fluent 接口，这里是 idBuilder，表示首先构建的是 Id 属性。  Fluent API 的使用与传统的建造者实现使用类似，但是它限定了方法调用的顺序。如果顺序不对，在编译期就报错了，这样就能提前把问题暴露在编译器，减少了不必要的错误使用。\n// Fluent API的使用方法 profile := NewFluentServiceProfileBuilder(). WithId(\u0026#34;service1\u0026#34;). WithType(\u0026#34;order\u0026#34;). WithStatus(Normal). WithEndpoint(\u0026#34;192.168.0.1\u0026#34;, 8080). WithRegion(\u0026#34;region1\u0026#34;, \u0026#34;beijing\u0026#34;, \u0026#34;China\u0026#34;). WithPriority(1). WithLoad(100). Build() // 如果方法调用不按照预定的顺序，编译器就会报错 profile := NewFluentServiceProfileBuilder(). WithType(\u0026#34;order\u0026#34;). WithId(\u0026#34;service1\u0026#34;). WithStatus(Normal). WithEndpoint(\u0026#34;192.168.0.1\u0026#34;, 8080). WithRegion(\u0026#34;region1\u0026#34;, \u0026#34;beijing\u0026#34;, \u0026#34;China\u0026#34;). WithPriority(1). WithLoad(100). Build() // 上述代码片段把WithType和WithId的调用顺序调换了，编译器会报如下错误 // NewFluentServiceProfileBuilder().WithType undefined (type idBuilder has no field or method WithType) 典型应用场景 建造者模式主要应用在实例化复杂对象的场景，常见的有：\n 配置对象。比如创建 HTTP Server 时需要多个配置项，这种场景通过 Functional Options 模式就能够很优雅地实现配置功能。 SQL 语句对象。一些 ORM 框架在构造 SQL 语句时也经常会用到 Builder 模式。比如 xorm 框架中构建一个 SQL 对象是这样的：builder.Insert().Into(\u0026quot;table1\u0026quot;).Select().From(\u0026quot;table2\u0026quot;).ToBoundSQL() 复杂的 DTO 对象。 \u0026hellip;\u0026hellip;  优缺点 优点 1、将复杂的构建逻辑从业务逻辑中分离出来，遵循了单一职责原则。2、可以将复杂对象的构建过程拆分成多个步骤，提升了代码的可读性，并且可以控制属性构建的顺序。3、对于有多种构建方式的场景，可以将 Builder 设计为一个接口来提升可扩展性。4、Go 语言中，利用 Functional Options 模式可以更为简洁优雅地完成复杂对象的构建。\n缺点 1、传统的建造者模式需要新增一个 Builder 对象来完成对象的构造，Fluent API 模式下甚至还要额外增加多个 Fluent 接口，一定程度上让代码更加复杂了。\n与其他模式的关联 抽象工厂模式和建造者模式类似，两者都是用来构建复杂的对象，但前者的侧重点是构建对象/产品族，后者的侧重点是对象的分步构建过程。\n 参考 [1] 【Go实现】实践GoF的23种设计模式：SOLID原则, 元闰子\n[2] Design Patterns, Chapter 3. Creational Patterns, GoF\n[3] GO 编程模式：FUNCTIONAL OPTIONS, 酷壳 CoolShell\n[4] Fluent API: Practice and Theory, Ori Roth\n[5] XORM BUILDER, xorm\n[6] 生成器模式, refactoringguru.cn\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-05-05T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E5%BB%BA%E9%80%A0%E8%80%85%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：建造者模式"},{"content":" 《从分层架构到微服务架构》是一系列介绍《Fundamentals of Software Architecture》中提到的8种架构模式的文章，这里不会事无巨细地介绍所有的细节，而是会挑选其中关键内容，更多详情请阅读原书。\n往期精彩：\n 从分层架构到微服务架构（一） 从分层架构到微服务架构（二）之分层架构 从分层架构到微服务架构（三）之管道架构 从分层架构到微服务架构（四）之微内核架构   前言 从本文开始，我们进入了《从分层架构到微服务架构》系列中分布式架构的介绍，本文要介绍的是服务化架构（Service-Based Architecture，SBA）。\nSBA 可以看成是单体架构和微服务架构之间的一个折中方案，它也是按照业务领域进行服务划分，但服务划分的粒度相比微服务要更粗。SBA 与微服务架构一大不同是，它允许各个服务间共享同一个数据库实例，这也使得 SBA 在架构上既有单体架构的特点，也有分布式架构的特点，显得更加的灵活。因此，从单体架构演进到 SBA，会比直接演进到微服务架构更加容易。\n架构视图 基础视图 SBA 的基础架构视图分成 3 部分：\n User Interface，作为系统的接入口，接收客户端的请求，并转发到业务服务。。 Domain Services，业务服务按照领域进行划分，分开部署、业务独立。 Database，服务间共享的数据库实例，因为数据库实例只有一个，所以可以支持 ACID 事务。  使用 SBA 的系统通常只会划分 4 ～ 12 个服务，避免产生过多的数据库连接。服务数量不多，也决定了 SBA 中的服务相比微服务架构中的服务有着更粗的粒度。User Interface 与服务间通过远程通信协议来完成业务往来，常见的通信方式有REST、RPC、消息队列等。需要注意的是，SBA 是不允许服务间通信的，这与微服务架构有着本质的区别。\n大多数情况下，SBA 中的服务只有一个或者少量实例，与微服务动辄成百上千个实例有着很大的区别。主要是因为 SBA 服务粒度更粗，无法做到像微服务那样精准的按需扩容，扩容太多反而会导致资源的浪费。\nSBA 的另一大特点是允许所有服务共享同一数据库实例，使得它能够直接将传统单体架构的那一套 SQL 查询逻辑、ACID 事务搬过来，让架构的演进更加的平滑。不过，共享数据也会带来一些问题，比如数据模型变更的影响范围更大，后面会在“**数据拆分”**一节详细讲述。\n拆分 User Interface 在大型系统中，单一的 User Interface 可能导致代码耦合、性能瓶颈等问题，这时候我们可以进一步对它进行拆分。拆分的方法可以是基于业务领域的拆分，业务相关的几个服务使用同一个 User Interface；或者基于服务的拆分，为每个服务都配备一个 User Interface。\n拆分 Database 类似地，我们也可以对数据库进行拆分，可以拆分成几个服务共享一个实例；也可以像微服务架构中那样，每个服务独享一个实例。数据库拆分的原则就是：确保数据是解耦的，不会被其他服务所依赖，避免出现跨库查询或服务间通信。\n增加 API 网关 我们也可以在 User Interface 和 Domain Services 之间增加一个 API 网关层，提供流控、鉴权、指标统计、服务发现等公共能力，进一步提升系统架构的安全性、可靠性、可维护性。\n业务服务的设计 SBA 中的服务具有较粗的粒度，因此在业务服务的架构设计上通常也会用到一些单体架构模式，常见的有分层架构和基于领域的组件化架构。\n不管是分层架构还是组件化架构，通常都需要增加一个 API 层，负责编排和转发来自 User Interface 的业务请求。下面以订单创建流程作为示例。\n假设现在有一个订单服务 OrderService，当它的 API 层接收到来自 User Interface 的订单创建请求时，API 层协调会各个组件依次完成如下的几个业务流程 ：\n 调用订单组件，完成订单ID、订单内容的生成。 调用支付组件，完成用户的扣款。 调用库存组件，更新商品的库存数量。  因为这些业务流程都是在同一个服务内完成，当其中的某个流程异常后，我们很容易通过数据库的 ACID 事务来完成回滚，从而能够确保数据的强一致性。\n相比在微服务架构之下，订单创建请求往往需要订单微服务、支付微服务、库存微服务之间协作来完成，这就涉及到分布式事务，也即 BASE（Basic Availability, Soft state, Eventual consistency） 事务。BASE 事务更加的复杂，而且无法保证数据的强一致性。当然，更粗的服务粒度也会带来服务可用性问题，比如在订单服务例子中，你会因为订单ID生成逻辑的变更而升级整个服务，也会因为库存组件中的一个BUG导致整个服务的故障。\n所以，服务粒度的粗与细，实际上也是数据一致性和服务可用性的一次 trade-off。\n数据拆分 服务间共享数据库使得系统具有更强的数据完整性和一致性，但简单的单库单表数据模型会带来耦合的问题。\n在单库单表的模型下，我们大概率会这么实现，将与数据库操作相关的实体对象、SQL 逻辑全部封装在一个共享的 shared lib 库上，供所有业务服务复用：\n这样的实现方式虽然简单，但是会带来“牵一发而动全身”的问题。假设某个服务所用到的某个字段类型需要变化，势必会修改表结构和 shared lib 库，而这两者是所有服务共用的，因此也就会导致所有服务都需要升级重新上线。这样的耦合会给 SRE 带来极大的困扰，一点也不敏捷。\n更好的方法是根据业务对数据进行拆分，将相对独立的数据拆分成多个表，每个表都有一个独立的 lib 库，对于公共表，则有一个 common lib 库，各服务按需依赖。对于 common lib 库的变更，我们还可以通过版本控制来尽量降低影响范围，但必须在 common lib 进行版本升级时保持向后兼容。\n架构评分 SBA 虽然是分布式架构，但是也保留了单体架构下的一些特点，在架构上具有较高的灵活性，也使得它在各方面的评分都比较高，没有明显的缺点。\nSBA 是一个 domain-partitioned 的架构，因此适合使用领域驱动设计来进行领域限界上下文的划分，进而规划出业务独立的服务。服务间业务独立，而且不会相互间通信，也就意味着具有更好的 Testability。\n前文有提到过，SBA 虽然支持服务实例扩容，但是更粗的服务粒度会导致扩容的性价比并不高，因此 Scalability 和 Elasticity 得分不高。\n Scalability 和 Elasticity的差异：\n Scalability 通常指软件系统在不中断业务的前提下，通过 scale-up 或 scale-out 等手段来应对更高业务负载，强调的是软件系统应对高负载的能力。 Elasticity 通常指硬件系统能够根据实际的业务负载情况，适时增加或减少硬件资源，强调的是硬件资源的高效利用。   总结 如果你打算从单体架构演进到分布式架构，SBA 会是一个不错的选择：\n 相比单体架构，SBA 按照业务进行服务拆分，在业务解耦、开发流程敏捷等方面有着明显的优势。 相比其他分布式架构，SBA 有着更粗的服务粒度，因此也得以减少了服务间的远程调用、网络带宽消耗，受网络故障的影响更小。 服务间共享数据库使得 SBA 支持 ACID 事务，在数据一致性方面具有良好的表现，但我们还是应该尽量按照业务进行分表，避免出现严重的数据耦合。 在架构评分上，SBA 各方面评分都不错，没有明显的缺点，是典型的“六边形战士”。   参考\n Fundamentals of Software Architecture (Chapter 13. Service-Based Architecture Style), Mark Richards, Neal Ford Service-Based Architecture as an Alternative to Microservice Architecture, Matt Fletcher What is the difference between scalability and elasticity?, stackoverflow   ","date":"2022-04-08T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BB%8E%E5%88%86%E5%B1%82%E6%9E%B6%E6%9E%84%E5%88%B0%E5%BE%AE%E6%9C%8D%E5%8A%A1%E6%9E%B6%E6%9E%84%E4%BA%94%E4%B9%8B%E6%9C%8D%E5%8A%A1%E5%8C%96%E6%9E%B6%E6%9E%84/","title":"从分层架构到微服务架构（五）之服务化架构"},{"content":" 上一篇：【Go实现】实践GoF的23种设计模式：SOLID原则\n简单的分布式应用系统（示例代码工程）：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 简述 GoF 对单例模式（Singleton）的定义如下：\n Ensure a class only has one instance, and provide a global point of access to it.\n 也即，保证一个类只有一个实例，并且为它提供一个全局访问点。\n在程序设计中，有些对象通常只需要一个共享的实例，比如线程池、全局缓存、对象池等。实现共享实例最简单直接的方式就是全局变量。但是，使用全局变量会带来一些问题，比如：\n 客户端程序可以创建同类实例，从而无法保证在整系统上只有一个共享实例。 难以控制对象的访问，比如想增加一个“访问次数统计”的功能就很难，可扩展性较低。 把实现细节暴露给客户端程序，加深了耦合，容易产生霰弹式修改。  对这种全局唯一的场景，更好的是使用单例模式去实现。单例模式能够限制客户端程序创建同类实例，并且可以在全局访问点上扩展或修改功能，而不影响客户端程序。\n但是，并非所有的全局唯一都适用单例模式。比如下面这种场景：\n 考虑需要统计一个API调用的情况，有两个指标，成功调用次数和失败调用次数。这两个指标都是全局唯一的，所以有人可能会将其建模成两个单例SuccessApiMetric和FailApiMetric。按照这个思路，随着指标数量的增多，你会发现代码里类的定义会越来越多，也越来越臃肿。这也是单例模式最常见的误用场景，更好的方法是将两个指标设计成一个对象ApiMetric下的两个实例ApiMetic success和ApiMetic fail。\n 那么，如何判断一个对象是否应该被建模成单例？通常，被建模成单例的对象都有“中心点”的含义，比如线程池就是管理所有线程的中心。所以，在判断一个对象是否适合单例模式时，先思考下，是一个中心点吗？\nUML结构 代码实现 根据单例模式的定义，实现的关键点有两个：\n 限制调用者直接实例化该对象； 为该对象的单例提供一个全局唯一的访问方法。  对于 C++ / Java 而言，只需把对象的构造函数设计成私有的，并提供一个 static 方法去访问该对象的唯一实例即可。但 Go 语言并没有构造函数的概念，也没有 static 方法，所以需要另寻出路。\n我们可以利用 Go 语言 package 的访问规则来实现，将单例对象设计成首字母小写，这样就能限定它的访问范围只在当前package下，模拟了 C++ / Java 的私有构造函数；然后，在当前 package 下实现一个首字母大写的访问函数，也就相当于 static 方法的作用了。\n示例 在简单的分布式应用系统（示例代码工程）中，我们定义了一个网络模块 network，模拟实现了网络报文转发功能。network 的设计也很简单，通过一个哈希表维持了 Endpoint 到 Socket 的映射，报文转发时，通过 Endpoint 寻址到 Socket，再调用 Socket 的 Receive 方法完成转发。\n因为整系统只需一个 network 对象，而且它在领域模型中具有中心点的语义，所以我们很自然地使用单例模式来实现它。单例模式大致可以分成两类，“饿汉模式”和“懒汉模式”。前者是在系统初始化期间就完成了单例对象的实例化；后者则是在调用时才进行延迟实例化，从而一定程度上节省了内存。\n“饿汉模式”实现 // demo/network/network.go package network // 1、设计为小写字母开头，表示只在network包内可见，限制客户端程序的实例化 type network struct { sockets sync.Mapvar instancevar instance } // 2、定义一个包内可见的实例对象，也即单例 var instance = \u0026amp;network{sockets: sync.Map{}} // 3、定义一个全局可见的唯一访问方法 func Instance() *network { return instance } func (n *network) Listen(endpoint Endpoint, socket Socket) error { if _, ok := n.sockets.Load(endpoint); ok { return ErrEndpointAlreadyListened } n.sockets.Store(endpoint, socket) return nil } func (n *network) Send(packet *Packet) error { record, rOk := n.sockets.Load(packet.Dest()) socket, sOk := record.(Socket) if !rOk || !sOk { return ErrConnectionRefuse } go socket.Receive(packet) return nil } 那么，客户端就可以通过 network.Instance() 引用该单例了：\n// demo/sidecar/flowctrl_sidecar.go package sidecar type FlowCtrlSidecar struct {...} // 通过 network.Instance() 直接引用单例 func (f *FlowCtrlSidecar) Listen(endpoint network.Endpoint) error { return network.Instance().Listen(endpoint, f) } ... “懒汉模式”实现 众所周知，“懒汉模式”会带来线程安全问题，可以通过普通加锁，或者更高效的双重检验加锁来优化。不管是哪种方法，都是为了保证单例只会被初始化一次。\ntype network struct {...} // 单例 var instance *network // 定义互斥锁 var mutex = sync.Mutex{} // 普通加锁，缺点是每次调用 Instance() 都需要加锁 func Instance() *network { mutex.Lock() if instance == nil { instance = \u0026amp;network{sockets: sync.Map{}} } mutex.Unlock() return instance } // 双重检验后加锁，实例化后无需加锁 func Instance() *network { if instance == nil { mutex.Lock() if instance == nil { instance = \u0026amp;network{sockets: sync.Map{}} } mutex.Unlock() } return instance } 对于“懒汉模式”，Go 语言还有一个更优雅的实现方式，那就是利用 sync.Once。它有一个 Do 方法，方法声明为 func (o *Once) Do(f func())，其中入参是 func() 的方法类型，Go 会保证该方法仅会被调用一次。利用这个特性，我们就能够实现单例只被初始化一次了。\ntype network struct {...} // 单例 var instance *network // 定义 once 对象 var once = sync.Once{} // 通过once对象确保instance只被初始化一次 func Instance() *network { once.Do(func() { // 只会被调用一次 \tinstance = \u0026amp;network{sockets: sync.Map{}} }) return instance } 扩展 提供多个实例 虽然单例模式从定义上表示每个对象只能有一个实例，但是我们不应该被该定义限制住，还得从模式本身的动机来去理解它。单例模式的一大动机是限制客户端程序对对象进行实例化，至于实例有多少个其实并不重要，根据具体场景来进行建模、设计即可。\n比如在前面的 network 模块中，现在新增一个这样的需求，将网络拆分为互联网和局域网。那么，我们可以这么设计：\ntype network struct {...} // 定义互联网单例 var inetInstance = \u0026amp;network{sockets: sync.Map{}} // 定义局域网单例 var lanInstance = \u0026amp;network{sockets: sync.Map{}} // 定义互联网全局可见的唯一访问方法 func Internet() *network { return inetInstance } // 定义局域网全局可见的唯一访问方法 func Lan() *network { return lanInstance } 虽然上述例子中，network 结构有两个实例，但是本质上还是单例模式，因为它做到了限制客户端实例化，以及为每个单例提供了全局唯一的访问方法。\n提供多种实现 单例模式也可以实现多态，如果你预测该单例未来可能会扩展，那么就可以将它设计成抽象的接口，让客户端依赖抽象，这样，未来扩展时就无需改动客户端程序了。\n比如，我们可以 network 设计为一个抽象接口：\n// network 抽象接口 type network interface { Listen(endpoint Endpoint, socket Socket) error Send(packet *Packet) error } // network 的实现1 type networkImpl1 struct { sockets sync.Map } func (n *networkImpl1) Listen(endpoint Endpoint, socket Socket) error {...} func (n *networkImpl1) Send(packet *Packet) error {...} // networkImpl1 实现的单例 var instance = \u0026amp;networkImpl1{sockets: sync.Map{}} // 定义全局可见的唯一访问方法，注意返回值时network抽象接口！ func Instance() network { return instance } // 客户端使用示例 func client() { packet := network.NewPacket(srcEndpoint, destEndpoint, payload) network.Instance().Send(packet) } 如果未来需要新增一种 networkImpl2 实现，那么我们只需修改 instance 的初始化逻辑即可，客户端程序无需改动：\n// 新增network 的实现2 type networkImpl2 struct {...} func (n *networkImpl2) Listen(endpoint Endpoint, socket Socket) error {...} func (n *networkImpl2) Send(packet *Packet) error {...} // 将单例 instance 修改为 networkImpl2 实现 var instance = \u0026amp;networkImpl2{...} // 单例全局访问方法无需改动 func Instance() network { return instance } // 客户端使用也无需改动 func client() { packet := network.NewPacket(srcEndpoint, destEndpoint, payload) network.Instance().Send(packet) } 有时候，我们还可能需要通过读取配置来决定使用哪种单例实现，那么，我们可以通过 map 来维护所有的实现，然后根据具体配置来选取对应的实现：\n// network 抽象接口 type network interface { Listen(endpoint Endpoint, socket Socket) error Send(packet *Packet) error } // network 具体实现 type networkImpl1 struct {...} type networkImpl2 struct {...} type networkImpl3 struct {...} type networkImpl4 struct {...} // 单例 map var instances = make(map[string]network) // 初始化所有的单例 func init() { instances[\u0026#34;impl1\u0026#34;] = \u0026amp;networkImpl1{...} instances[\u0026#34;impl2\u0026#34;] = \u0026amp;networkImpl2{...} instances[\u0026#34;impl3\u0026#34;] = \u0026amp;networkImpl3{...} instances[\u0026#34;impl4\u0026#34;] = \u0026amp;networkImpl4{...} } // 全局单例访问方法，通过读取配置决定使用哪种实现 func Instance() network { impl := readConf() instance, ok := instances[impl] if !ok { panic(\u0026#34;instance not found\u0026#34;) } return instance } 典型应用场景  日志。每个服务通常都会需要一个全局的日志对象来记录本服务产生的日志。 全局配置。对于一些全局的配置，可以通过定义一个单例来供客户端使用。 唯一序列号生成。唯一序列号生成必然要求整系统只能有一个生成实例，非常合适使用单例模式。 线程池、对象池、连接池等。xxx池的本质就是共享，也是单例模式的常见场景。 全局缓存 \u0026hellip;\u0026hellip;  优缺点 优点 在合适的场景，使用单例模式有如下的优点：\n 整系统只有一个或几个实例，有效节省了内存和对象创建的开销。 通过全局访问点，可以方便地扩展功能，比如新增加访问次数的统计。 对客户端隐藏实现细节，可避免霰弹式修改。  缺点 虽然单例模式相比全局变量有诸多的优点，但它本质上还是一个“全局变量”，还是避免不了全局变量的一些缺点：\n 函数调用的隐式耦合。通常我们都期望从函数的声明中就能知道该函数做了什么、依赖了什么、返回了什么。使用使用单例模式就意味着，无需通过函数传参，就能够在函数中使用该实例。也即将依赖/耦合隐式化了，不利于更好地理解代码。 对测试不友好。通常对一个方法/函数进行测试，我们并不需要知道它的具体实现。但如果方法/函数中有使用单例对象，我们就不得不考虑单例状态的变化了，也即需要考虑方法/函数的具体实现了。 并发问题。共享就意味着可能存在并发问题，我们不仅需要在初始化阶段考虑并发问题，在初始化后更是要时刻注意。因此，在高并发的场景，单例模式也可能存在锁冲突问题。  单例模式虽然简单易用，但也是最容易被滥用的设计模式。它并不是“银弹”，在实际使用时，还需根据具体的业务场景谨慎使用。\n与其他模式的关联 工厂方法模式、抽象工厂模式很多时候都会以单例模式来实现，因为工厂类通常是无状态的，而且全局只需一个实例即可，能够有效避免对象的频繁创建和销毁。\n","date":"2022-04-05T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E5%8D%95%E4%BE%8B%E6%A8%A1%E5%BC%8F/","title":"【Go实现】实践GoF的23种设计模式：单例模式"},{"content":" 最近在喜马拉雅上听王德峰的中西思想必修课，听到有关中医的哲学思辨，深有感触。本文将结合网上的一些资料和自身的经历，开启一本正经地胡说八道模式。\n 前言 毫无疑问，当前的医疗体系主要是以现代医学，也即常说的西医，作为根基。我们平时去医院看病、体检，基本都是在西医的范畴之内。相比之下，中国传统医学，也即常说的中医，更多的是作为一种养生、调理的辅助手段，比如工作累了就去中医馆做做针灸、推拿等。\n在中医发源地的中国，却形成西医为主、中医为辅的局面，这其中的源头可以追溯到五四运动期间，我们从西方请来了“德先生”和“赛先生”，从此就奠定了我们不断学习西方的文化和技术的基调。在这个基调之下，我们普遍认为西方的文化和技术更先进。因此，当我们采用现代的科学手段去研究中医，却发现中医中的很多概念无法解释时，我们不免会发出这样的疑问：“中医科学吗？”。\n在回答这个问题之前，我们首先需要理解“科学”是什么。\n科学的哲学基础 维基百科中，对现代科学，也即常说的科学，有如下的定义：\n Science is a systematic enterprise that builds and organizes knowledge in the form of testable explanations and predictions about the universe.\n 翻译过来就是，科学是一个系统性的知识体系，它构建并组织了关于解释和预测宇宙运作机制的知识，并强调了其中的可验证性。当然，科学也不等同于寻求绝对无误的真理，而是在现有基础上，摸索式地不断接近真理。\n哲学是科学之源，科学的前身就是古希腊的自然哲学，从前面对科学的解释中，我们也大概能看出柏拉图的理念论的影子。\n不管是古希腊的哲学，还是现代西方哲学，它们的内核都是一样的，那就是知识。西方哲学的主题是知识问题的解决，而如何解决则取决于对世间万物本质的理性的把握，以及概念的确认。所以，我们会看到西方的学者们对浩渺星空的好奇和惊异、宇宙本质问题的痴迷和热情、对思想穷根究底的辩驳和拷问、对逻辑与理性的推崇和赞赏。这些正是现代科学的精神内核。\n在西方的哲学看来，宇宙是由实体构成的，西方的学者会不断寻求该实体的基本单位，“生物-\u0026gt;细胞-\u0026gt;分子-\u0026gt;原子-\u0026gt;质子-\u0026gt;...”就是一个不断寻求的过程。所以西医认为疾病就是实体性的病变，比如流行性感冒就是因为人体感染了流感病毒，这些都是可以通过医学手段检验出来的，满足科学的可验证性。\n中医的哲学基础 中医的哲学基础自然是中国哲学，而中国哲学是与西方哲学截然不同的一套哲学体系。\n在中国哲学看来，宇宙的本质是气，气的变化就是宇宙的真相。相反，西方哲学是将宇宙看成由统一不变的实体所构成，这是中西方哲学的一大差异。气有“阴”和“阳”两个方面，有“金”、“木”、“水”、“火”、“土”五种类型，也即常说的阴阳五行。阴阳五行相生相克，它们之间的转换构成了整个宇宙，这就是中国哲学的宇宙观。\n中国哲学的内核是人生，主题是人生问题的解决，而人生问题的解决基本上取决于我们对天道的体认和觉解。所以，中国哲学的思辨是依靠“感性”，这与西方哲学的“理性”思辨的是两种不同的思辨路径。\n中国哲学强调“天人合一”，自然也会将阴阳五行这套宇宙观映射到我们人体身上，也就形成了中医的理论基础。中医将人的五脏六腑都看成是金木水火土之气，它们彼此之间的相互作用（相生相克）构成了人体。所以，中医认为疾病就是人体内阴阳五行之气的平衡被打破所导致的，治病就是重建人体之气的平衡，也即常说的调理。\n所以中医的精神是调理，西医的精神是解剖、分析和手术。\n中医科学吗？ 再回到“中医科学吗？”这个问题上，根据前文的内容，我们可以发现这个问题本身就是个伪问题的。因为这相当于在拿西方哲学的标准去衡量中国哲学，得到的答案自然是否定的，它们本身就是截然不同的哲学体系。\n下面可以列举一些中医不科学的例子：\n 中医的很多概念，比如气、经脉，是无法通过科学的手段进行检验的，也即在最基本的可验证性上就不成立了。 中医讲求“以形补形”，认为某种植物或动物的真正医药价值，在于它的形状、颜色、质地、名称等与人体器官或疾病是否具有对应性。如果具有对应性，则意味着这个植物或动物对那 个器官或疾病有疗效。这些在现代的生物医学看来同样也是不成立的。 中医古典中的一些偏方在现代科学看来完全就是无稽之谈，比如《本草纲目》有提到鱼骨鲠喉，把渔网煮成汁或烧成灰喝了，就能让鱼骨掉下。  既然中医不科学，那么中医有效吗？\n中医有效吗？ 从个体的角度看，中医是有效的。现在已经有足够多的例子说明在某些病例上，中医能够达到比西医更好的效果。国家在大力推进中医的现代化建设、世界卫生组织把中医药纳入国际医药体系，这些都侧面说明了中医的有效性。另外，一些中医药的成分也得到了科学的验证，最著名的就是屠呦呦发现青蒿素的例子。\n但从整体的角度看，特别是在西方统计学的视角下，中医的有效性是被质疑的。最有力的证据就是中国和欧洲平均寿命的对比：\n从表中可以看出，在现代医学传入中国之前中国人的平均寿命并不高于其他民族， 在古代和近代都只有三十岁左右，现代中国人平均寿命大幅度提高到七十多岁基本上是得益于现代医学。\n但是，就像前文所介绍，中医和科学起源于两套完全不同的哲学体系，用科学的手段衡量中医，注定得不到圆满。国医大师陆广莘也曾表示：\n “中医是个性化的，其治疗是因人而异的，总结和归纳是不符合中医特色的，医生面对的是动态的生命，而不是物，这与西方医学有着本质的区别。”\n 最后 我对中医感到震撼的一次经历是，高中时某次出现莫名其妙的头晕，然后去找一位老中医进行诊断，一坐下就开始把脉，几分钟过后就诊断出问题在头部。这是和西医完全不同的诊断方式，相反在西医的诊断手段下，我的身体并没有任何的问题。\n中医的哲学基础就注定它是一种“感性”的诊断和治疗方式，感性就意味着无法量化，而是依赖长时间的诊断经验来进行领悟。所以，中医的培养方式适合中国传统的师徒相授模式，而不是西方的高等教育批量培养模式。\n中国哲学和西方哲学都是看待宇宙的方式，并没有对错之分。只是目前来看，西方的科学宇宙观是更成功的一方，但也不能将它视为真理。我们还是要辩证地看待中医和中国哲学，而不是以现代科学的标准来评判它的对错。\n最后，我们应该做的是用科学的方法充实自己，但不应该用科学的标准来限制自己。\n 参考\n 王德峰的中西思想必修课，王德峰 科学的定义，维基百科 进一步深刻认识科学与哲学的关系，白春礼 论中医在现代医疗体系中的角色，Joshua Lotz, B.S. A critical examination of the main premises of Traditional Chinese Medicine，Michael Eigenschink 中医不属于物质科学的范畴，中国科学院 《本草纲目》中的偏方是怎么来的，方舟子   ","date":"2022-04-04T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BB%8E%E5%93%B2%E5%AD%A6%E7%9A%84%E8%A7%92%E5%BA%A6%E7%9C%8B%E4%B8%AD%E5%8C%BB/","title":"从哲学的角度看中医"},{"content":" 之前也有写过关于设计模式的文章《使用Go实现GoF的23种设计模式》，但是那个系列写了3篇文章就没再继续了，主要的原因是找不到合适的示例代码。考虑到，如果以类似于“鸭子是否会飞”、“烘焙的制作流程”等贴近生活的事情举例，很难在我们日常的开发中产生联系。（目前应该很少有这些逻辑的软件系统吧）\n《实践GoF的23种设计模式》可以看成是《使用Go实现GoF的23种设计模式》系列的重启，吸取了上次烂尾的教训，本次在写文章之前就已经完成了23种设计模式的示例代码实现。示例代码以我们日常开发中经常碰到的一些技术/问题/场景作为切入点，示范如何运用设计模式来完成相关的实现。\n 前言 从1995年GoF提出23种设计模式到现在，25年过去了，设计模式依旧是软件领域的热门话题。设计模式通常被定义为：\n 设计模式（Design Pattern）是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结，使用设计模式是为了可重用代码、让代码更容易被他人理解并且保证代码可靠性。\n 从定义上看，设计模式其实是一种经验的总结，是针对特定问题的简洁而优雅的解决方案。既然是经验总结，那么学习设计模式最直接的好处就在于可以站在巨人的肩膀上解决软件开发过程中的一些特定问题。\n学习设计模式的最高境界是吃透它们本质思想，可以做到即使已经忘掉某个设计模式的名称和结构，也能在解决特定问题时信手拈来。设计模式背后的本质思想，就是我们熟知的SOLID原则。如果把设计模式类比为武侠世界里的武功招式，那么SOLID原则就是内功内力。通常来说，先把内功练好，再来学习招式，会达到事半功倍的效果。因此，在介绍设计模式之前，很有必要先介绍一下SOLID原则。\n本文首先会介绍本系列文章中用到的示例代码demo的整体结构，然后开始逐一介绍SOLID原则，也即单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。\n一个简单的分布式应用系统  本系列示例代码demo获取地址：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Go-Implementation\n 示例代码demo工程实现了一个简单的分布式应用系统（单机版），该系统主要由以下几个模块组成：\n 网络 Network，网络功能模块，模拟实现了报文转发、socket通信、http通信等功能。 数据库 Db，数据库功能模块，模拟实现了表、事务、dsl等功能。 消息队列 Mq，消息队列模块，模拟实现了基于topic的生产者/消费者的消息队列。 监控系统 Monitor，监控系统模块，模拟实现了服务日志的收集、分析、存储等功能。 边车 Sidecar，边车模块，模拟对网络报文进行拦截，实现access log上报、消息流控等功能。 服务 Service，运行服务，当前模拟实现了服务注册中心、在线商城服务集群、服务消息中介等服务。  示例代码demo工程的主要目录结构如下：\n├── db # 数据库模块，定义Db、Table、TableVisitor等抽象接口和实现 ├── monitor # 监控系统模块，采用插件式的架构风格，当前实现access log日志etl功能 │ ├── config # 监控系统插件配置模块 │ ├── filter # 过滤插件的实现定义 │ ├── input # 输入插件的实现定义 │ ├── output # 输出插件的实现定义 │ ├── pipeline # Pipeline插件的实现定义，一个pipeline表示一个ETL处理流程 │ ├── plugin # 插件抽象接口的定义，比如Plugin、Config等 │ └── model # 监控系统模型对象定义 ├── mq # 消息队列模块 ├── network # 网络模块，模拟网络通信，定义了socket、packet等通用类型/接口  │ └── http # 模拟实现了http通信等服务端、客户端能力 ├── service # 服务模块，定义了服务的基本接口 │ ├── mediator # 服务消息中介，作为服务通信的中转方，实现了服务发现，消息转发的能力 │ ├── registry # 服务注册中心，提供服务注册、去注册、更新、 发现、订阅、去订阅、通知等功能 │ │ └── model # 服务注册/发现相关的模型定义 │ └── shopping # 模拟在线商城服务群的定义，包含订单服务、库存服务、支付服务、发货服务 └── sidecar # 边车模块，对socket进行拦截，提供http access log、流控功能 └── flowctrl # 流控模块，基于消息速率进行随机流控 SRP：单一职责原则 单一职责原则（The Single Responsibility Principle，SRP）应该是SOLID原则中，最容易被理解的一个，但同时也是最容易被误解的一个。很多人会把“将大函数重构成一个个职责单一的小函数”这一重构手法等价为SRP，这是不对的，小函数固然体现了职责单一，但这并不是SRP。\nSRP传播最广的定义应该是Uncle Bob给出的：\n A module should have one, and only one, reason to change.\n 也即，一个模块应该有且只有一个导致其变化的原因。\n这个解释里有2个需要理解的地方：\n（1）如何定义一个模块\n我们通常会把一个源文件定义为最小粒度的模块。\n（2）如何找到这个原因\n一个软件的变化往往是为了满足某个用户的需求，那么这个用户就是导致变化的原因。但是，一个模块的用户/客户端程序往往不只一个，比如Java中的ArrayList类，它可能会被成千上万的程序使用，但我们不能说ArrayList职责不单一。因此，我们应该把“一个用户”改为“一类角色”，比如ArrayList的客户端程序都可以归类为“需要链表/数组功能”的角色。\n于是，Uncle Bob给出了SRP的另一个解释：\n A module should be responsible to one, and only one, actor.\n 有了这个解释，我们就可以理解函数职责单一并不等同于SRP，比如在一个模块有A和B两个函数，它们都是职责单一的，但是函数A的使用者是A类用户，函数B的使用者是B类用户，而且A类用户和B类用户变化的原因都是不一样的，那么这个模块就不满足SRP了。\n下面，以我们的分布式应用系统demo为例进一步探讨。对于Registry类（服务注册中心）来说，它对外提供的基本能力有服务注册、更新、去注册和发现功能，那么，我们可以这么实现：\n// Registry 服务注册中心 type Registry struct { db db.Db server *http.Server localIp string } func (r *Registry) register(req *http.Request) *http.Response {...} func (r *Registry) deregister(req *http.Request) *http.Response {...} func (r *Registry) update(req *http.Request) *http.Response {...} func (r *Registry) discovery(req *http.Request) *http.Response {...} ... 上述实现中，Registry包含了register、update、deregister、discovery等4个主要方法，正好对应了Registry对外提供的能力，看起来已经是职责单一了。\n但是在仔细思考一下就会发现，服务注册、更新和去注册是给专门给服务提供者使用的功能，而服务发现则是专门给服务消费者使用的功能。服务提供者和服务消费者是两类不同的角色，它们产生变化的时间和方向都可能不同。比如：\n 当前服务发现功能是这么实现的：Registry从满足查询条件的所有ServiceProfile中挑选一个返回给服务消费者（也即Registry自己做了负载均衡）。\n假设现在服务消费者提出新的需求：Registry把所有满足查询条件的ServiceProfile都返回，由服务消费者自己来做负载均衡。\n为了实现这样的功能，我们就要修改Registry的代码。按理，服务注册、更新、去注册等功能并不应该受到影响，但因为它们和服务发现功能都在同一个模块（Registry）里，于是被迫也受到影响了，比如可能会代码冲突。\n 因此，更好的设计是将register、update、deregister内聚到一个服务管理模块SvcManagement，discovery则放到另一个服务发现模块SvcDiscovery，服务注册中心Registry再组合svcManagement和svcDiscovery。\n具体实现如下：\n// demo/service/registry/svc_management.go type svcManagement struct { db db.Db ... } func (s *svcManagement) register(req *http.Request) *http.Response {...} func (s *svcManagement) deregister(req *http.Request) *http.Response {...} func (s *svcManagement) update(req *http.Request) *http.Response {...} // demo/service/registry/svc_discovery.go type svcDiscovery struct { db db.Db } func (s *svcDiscovery) discovery(req *http.Request) *http.Response {...} // demo/service/registry/registry.go type Registry struct { db db.Db server *http.Server localIp string svcManagement *svcManagement // 组合svcManagement \tsvcDiscovery *svcDiscovery // 组合svcDiscovery } func (r *Registry) Run() error { ... // 调用svcManagement和svcDiscovery完成服务 \treturn r.server.Put(\u0026#34;/api/v1/service-profile\u0026#34;, r.svcManagement.register). Post(\u0026#34;/api/v1/service-profile\u0026#34;, r.svcManagement.update). Delete(\u0026#34;/api/v1/service-profile\u0026#34;, r.svcManagement.deregister). Get(\u0026#34;/api/v1/service-profile\u0026#34;, r.svcDiscovery.discovery). Put(\u0026#34;/api/v1/subscription\u0026#34;, r.svcManagement.subscribe). Delete(\u0026#34;/api/v1/subscription\u0026#34;, r.svcManagement.unsubscribe). Start() } 除了重复的代码编译，违反SRP还会带来以下2个常见的问题：\n1、代码冲突。程序员A修改了模块的A功能，而程序员B在不知情的情况下也在修改该模块的B功能（因为A功能和B功能面向不同的用户，完全可能由2位不同的程序员来维护），当他们同时提交修改时，代码冲突就会发生（修改了同一个源文件）。\n2、A功能的修改影响了B功能。如果A功能和B功能都使用了模块里的一个公共函数C，现在A功能有新的需求需要修改函数C，那么如果修改人没有考虑到B功能，那么B功能的原有逻辑就会受到影响。\n由此可见，违反SRP会导致软件的可维护性变得极差。但是，我们也不能盲目地进行模块拆分，这样会导致代码过于碎片化，同样也会提升软件的复杂性。比如，在前面的例子中，我们就没有必要再对服务管理模块进行拆分为服务注册模块、服务更新模块和服务去注册模块，一是因为它们面向的用户是一致的；二是在可预见的未来它们要么同时变化，要么都不变。\n因此，我们可以得出这样的结论：\n 如果一个模块面向的都是同一类用户（变化原因一致），那么就没必要进行拆分。 如果缺乏用户归类的判断，那么最好的拆分时机是变化发生时。  SRP是聚合和拆分的一个平衡，太过聚合会导致牵一发动全身，拆分过细又会提升复杂性。要从用户的视角来把握拆分的度，把面向不同用户的功能拆分开。如果实在无法判断/预测，那就等变化发生时再拆分，避免过度的设计。\nOCP：开闭原则 开闭原则（The Open-Close Principle，OCP）中，“开”指的是对扩展开放，“闭”指的是对修改封闭，它的完整解释为：\n A software artifact should be open for extension but closed for modification.\n 通俗地讲就是，一个软件系统应该具备良好的可扩展性，新增功能应当通过扩展的方式实现，而不是在已有的代码基础上修改。\n然而，从字面意思上看，OCP貌似又是自相矛盾的：想要给一个模块新增功能，但是又不能修改它。\n*如何才能打破这个困境呢？*关键是抽象！优秀的软件系统总是建立在良好的抽象的基础上，抽象化可以降低软件系统的复杂性。\n*那么什么是抽象呢？*抽象不仅存在于软件领域，在我们的生活中也随处可见。下面以《语言学的邀请》中的一个例子来解释抽象的含义：\n 假设某农庄有一头叫“阿花”的母牛，那么：\n1、当把它称为“阿花”时，我们看到的是它独一无二的一些特征：身上有很多斑点花纹、额头上还有一个闪电形状的伤疤。\n2、当把它称为母牛时，我们忽略了它的独有特征，看到的是它与母牛“阿黑”，母牛“阿黄”的共同点：是一头牛、雌性的。\n3、当把它称为家畜时，我们又忽略了它作为母牛的特征，而是看到了它和猪、鸡、羊一样的特点：是一个动物，在农庄里圈养。\n4、当把它称为农庄财产时，我们只关注了它和农庄上其他可售对象的共同点：可以卖钱、转让。\n从“阿花”，到母牛，到家畜，再到农庄财产，这就是一个不断抽象化的过程。\n 从上述例子中，我们可以得出这样的结论：\n 抽象就是不断忽略细节，找到事物间共同点的过程。 抽象是分层的，抽象层次越高，细节也就越少。  再回到软件领域，我们也可以把上述的例子类比到数据库上，数据库的抽象层次从低至高可以是这样的：MySQL 8.0版本 -\u0026gt; MySQL -\u0026gt; 关系型数据库 -\u0026gt; 数据库。现在假设有一个需求，需要业务模块将业务数据保存到数据库上，那么就有以下几种设计方案：\n 方案一：把业务模块设计为直接依赖MySQL 8.0版本。因为版本总是经常变化的，如果哪天MySQL升级了版本，那么我们就得修改业务模块进行适配，所以方案一违反了OCP。 方案二：把业务模块设计为依赖MySQL。相比于方案一，方案二消除了MySQL版本升级带来的影响。现在考虑另一种场景，如果因为某些原因公司禁止使用MySQL，必须切换到PostgreSQL，这时我们还是得修改业务模块进行数据库的切换适配。因此，在这种场景下，方案二也违反了OCP。 方案三：把业务模块设计为依赖关系型数据库。到了这个方案，我们基本消除了关系型数据库切换的影响，可以随时在MySQL、PostgreSQL、Oracle等关系型数据库上进行切换，而无须修改业务模块。但是，熟悉业务的你预测未来随着用户量的迅速上涨，关系型数据库很有可能无法满足高并发写的业务场景，于是就有了下面的最终方案。 方案四：把业务模块设计为依赖数据库。这样，不管以后使用MySQL还是PostgreSQL，关系型数据库还是非关系型数据库，业务模块都不需要再改动。到这里，我们基本可以认为业务模块是稳定的，不会受到底层数据库变化带来的影响，满足了OCP。  我们可以发现，上述方案的演进过程，就是我们不断对业务依赖的数据库模块进行抽象的过程，最终设计出稳定的、服务OCP的软件。\n那么，在编程语言中，我们用什么来表示“数据库”这一抽象呢？是接口！\n数据库最常见的几个操作就是CRUD，因此我们可以设计这么一个Db接口来表示“数据库”：\ntype Db interface { Query(tableName string, cond Condition) (*Record, error) Insert(tableName string, record *Record) error Update(tableName string, record *Record) error Delete(tableName string, record *Record) error } 这样，业务模块和数据库模块之间的依赖关系就变成如下图所示：\n满足OCP的另一个关键点就是分离变化，只有先把变化点识别分离出来，我们才能对它进行抽象化。下面以我们的分布式应用系统demo为例，解释如何实现变化点的分离和抽象。\n在demo中，监控系统主要负责对服务的access log进行ETL操作，也即涉及如下3个操作：1）从消息队列中获取日志数据；2）对数据进行加工；3）将加工后的数据存储在数据库上。\n我们把整一个日志数据的处理流程称为pipeline，那么我们可以这么实现：\ntype Pipeline struct { mq Mq db Db ... } func (p *Pipeline) Run() { for atomic.LoadUint32(\u0026amp;p.isClose) != 1 { // 1、从消息队列中获取数据  msg := p.mq.Consume(\u0026#34;monitor.topic\u0026#34;) log := msg.Payload() // 2、对数据进行字段提取操作  matches := p.pattern.FindStringSubmatch(log) if len(matches) != 3 { return event } record := model.NewMonitoryRecord() record.Endpoint = matches[1] record.Type = model.Type(matches[2]) // 3.存储到数据库上  p.db.Insert(\u0026#34;logs_table\u0026#34;, record.Id, record) ... } } 现在考虑新上线一个服务，但是这个服务不支持对接消息队列了，只支持socket传输数据，于是我们得在Pipeline上新增一个InputType来判断是否使用socket输入源：\nfunc (p *Pipeline) Run() { for atomic.LoadUint32(\u0026amp;p.isClose) != 1 { if inputType == input.MqType { // 从消息队列中获取数据  msg := p.mq.Consume(\u0026#34;monitor.topic\u0026#34;) log := msg.Payload() } else { // 使用socket为消息来源  packet := socket.Receice() log := packet.PayLoad().(string) } ... } } 过一段时间，有需求需要给access log打上一个时间戳，方便后续的日志分析，于是我们需要修改Pipeline的数据加工逻辑：\nfunc (p *Pipeline) Run() { for atomic.LoadUint32(\u0026amp;p.isClose) != 1 { ... // 对数据进行字段提取操作  matches := p.pattern.FindStringSubmatch(log) if len(matches) != 3 { return event } record := model.NewMonitoryRecord() record.Endpoint = matches[1] record.Type = model.Type(matches[2]) // 新增一个时间戳字段  record.Timestamp = time.Now().Unix() ... } } 很快，又有一个需求，需要将加工后的数据存储到ES上，方便后续的日志检索，于是我们再次修改了Pipeline的数据存储逻辑：\nfunc (p *Pipeline) Run() { for atomic.LoadUint32(\u0026amp;p.isClose) != 1 { ... if outputType == output.Db { // 存储到ES上  p.db.Insert(\u0026#34;logs_table\u0026#34;, record.Id, record) } else { // 存储到ES上  p.es.Store(record.Id, record) } ... } } 在上述的pipeline例子中，每次新增需求都需要修改Pipeline模块，明显违反了OCP。下面，我们来对它进行优化，使它满足OCP。\n第一步是分离变化点，根据pipeline的业务处理逻辑，我们可以发现3个独立的变化点，数据的获取、加工和存储。第二步，我们对这3个变化点进行抽象，设计出以下3个抽象接口：\n// demo/monitor/input/input_plugin.go // 数据获取抽象接口 type InputPlugin interface { plugin.Plugin Input() (*plugin.Event, error) } // demo/monitor/filter/filter_plugin.go // 数据加工抽象接口 type FilterPlugin interface { plugin.Plugin Filter(event *plugin.Event) *plugin.Event } // demo/monitor/output/output_plugin.go // 数据存储抽象接口 type OutputPlugin interface { plugin.Plugin Output(event *plugin.Event) error } 最后，Pipeline的实现如下，只依赖于InputPlugin、FilterPlugin和OutputPlugin三个抽象接口。后续再有需求变更，只需扩展对应的接口即可，Pipeline无须再变更：\n// demo/monitor/pipeline/pipeline_plugin.go // ETL流程定义 type pipelineTemplate struct { input input.Plugin filter filter.Plugin output output.Plugin ... } func (p *pipelineTemplate) doRun() { ... for atomic.LoadUint32(\u0026amp;p.isClose) != 1 { event, err := p.input.Input() event = p.filter.Filter(event) p.output.Output(event) } ... } OCP是软件设计的终极目标，我们都希望能设计出可以新增功能却不用动老代码的软件。但是100%的对修改封闭肯定是做不到的，另外，遵循OCP的代价也是巨大的。它需要软件设计人员能够根据具体的业务场景识别出那些最有可能变化的点，然后分离出去，抽象成稳定的接口。这要求设计人员必须具备丰富的实战经验，以及非常熟悉该领域的业务场景。否则，盲目地分离变化点、过度地抽象，都会导致软件系统变得更加复杂。\nLSP：里氏替换原则 上一节介绍中，OCP的一个关键点就是抽象，而如何判断一个抽象是否合理，这是里氏替换原则（The Liskov Substitution Principle，LSP）需要回答的问题。\nLSP的最初定义如下：\n If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.\n 简单地讲就是，子类型必须能够替换掉它们的基类型，也即基类中的所有性质，在子类中仍能成立。一个简单的例子：假设有一个函数f，它的入参类型是基类B。同时，基类B有一个派生类D，如果把D的实例传递给函数f，那么函数f的行为功能应该是不变的。\n由此可以看出，违反LSP的后果很严重，会导致程序出现不在预期之内的行为错误。最典型的就是正方形继承自长方形的例子。\n 长方形和正方形例子的详细介绍，请参考【Java实现】实践GoF的23种设计模式：SOLID原则 中的“LSP：里氏替换原则”一节\n 出现违反LSP的设计，主要原因还是我们孤立地进行模型设计，没有从客户端程序的角度来审视该设计是否正确。我们孤立地认为在数学上成立的关系（正方形 IS-A 矩形），在程序中也一定成立，而忽略了客户端程序的使用方法。\n该例子告诉我们：一个模型的正确性或有效性，只能通过客户端程序来体现。\n下面，我们总结一下在继承体系（IS-A）下，要想设计出符合LSP的模型所需要遵循的一些约束：\n 基类应该设计为一个抽象类（不能直接实例化，只能被继承）。 子类应该实现基类的抽象接口，而不是重写基类已经实现的具体方法。 子类可以新增功能，但不能改变基类的功能。 子类不能新增约束，包括抛出基类没有声明的异常。  前面的矩形和正方形的例子中，几乎把这些约束都打破了，从而导致了程序的异常行为：1）Square的基类Rectangle不是一个抽象类，打破约束1；2）Square重写了基类的setWidth和setLength方法，打破约束2；3）Square新增了Rectangle没有的约束，长宽相等，打破约束4。\n除了继承之外，另一个实现抽象的机制是接口。如果我们是面向接口的设计，那么上述的约束1～3其实已经满足了：1）接口本身不具备实例化能力，满足约束1；2）接口没有具体的实现方法（Java中接口的default方法比较例外，本文先不考虑），也就不会被重写，满足约束2；3）接口本身只定义了行为契约，并没有实际的功能，因此也不会被改变，满足约束3。\n因此，使用接口替代继承来实现多态和抽象，能够减少很多不经意的错误。但是面向接口设计仍然需要遵循约束4，下面我们以分布式应用系统demo为例，介绍一个比较隐晦地打破约束4，从而违反了LSP的实现。\n还是以监控系统为例，为例实现ETL流程的灵活配置，我们需要通过配置文件定义pipeline的流程功能（数据从哪获取、需要经过哪些加工、加工后存储到哪里）。当前需要支持json和yaml两种配置文件格式，以yaml配置为例，配置内容是这样的：\n# src/main/resources/pipelines/pipeline_0.yamlname:pipeline_0# pipeline名称type:single_thread# pipeline类型input:# input插件定义（数据从哪里来）name:input_0# input插件名称type:memory_mq# input插件类型context:# input插件的初始化上下文topic:access_log.topicfilter:# filter插件定义（需要经过哪些加工）- name:filter_0# 加工流程filter_0定义，类型为log_to_jsontype:log_to_json- name:filter_1# 加工流程filter_1定义，类型为add_timestamptype:add_timestamp- name:filter_2# 加工流程filter_2定义，类型为json_to_monitor_eventtype:json_to_monitor_eventoutput:# output插件定义（加工后存储到哪里）name:output_0# output插件名称type:memory_db# output插件类型context:# output插件的初始化上下文tableName:monitor_event_0首先我们定义一个Config接口来表示“配置”这一抽象:\n// demo/monitor/plugin/plugin.go packet plugin // Config 插件配置抽象接口 type Config interface { Load(conf string) error } 另外，上述配置中的input、filter、output子项，可以认为是input.Plugin、filter.Plugin、output.Plugin插件的配置项，由Pipeline插件的配置项组合在一起，因此我们定义了如下几个Config的实现类：\n// demo/monitor/config/config.go package config type item struct { Name string `json:\u0026#34;name\u0026#34; yaml:\u0026#34;name\u0026#34;` PluginType string `json:\u0026#34;type\u0026#34; yaml:\u0026#34;type\u0026#34;` Ctx plugin.Context `json:\u0026#34;context\u0026#34; yaml:\u0026#34;context\u0026#34;` loadConf func(conf string, item interface{}) error // 区分yaml和json的加载方式 } // 输入插件配置 type Input item func (i *Input) Load(conf string) error { return i.loadConf(conf, i) } // 过滤插件配置 type Filter item func (f *Filter) Load(conf string) error { return f.loadConf(conf, f) } // 输出插件配置 type Output item func (o *Output) Load(conf string) error { return o.loadConf(conf, o) } // Pipeline插件配置 type Pipeline struct { item `yaml:\u0026#34;,inline\u0026#34;` // yaml嵌套时需要加上,inline \tInput Input `json:\u0026#34;input\u0026#34; yaml:\u0026#34;input\u0026#34;` Filters []Filter `json:\u0026#34;filters\u0026#34; yaml:\u0026#34;filters,flow\u0026#34;` Output Output `json:\u0026#34;output\u0026#34; yaml:\u0026#34;output\u0026#34;` } func (p *Pipeline) Load(conf string) error { return p.loadConf(conf, p) } 因为涉及到从配置到对象的实例化过程，自然会想到使用工厂方法模式来创建对象。另外因为pipeline.Pipeline、input.Plugin、filter.Plugin和output.Plugin都实现了Plugin接口，我们也很容易想到定义一个PluginFactory接口来表示“插件工厂”这一抽象，具体的插件工厂再实现该接口：\n// 插件工厂接口，根据配置实例化插件 type PluginFactory interface { Create(config plugin.Config) plugin.Plugin } // input插件工厂 type InputPluginFactory struct {} func (i *InputPluginFactory) Create(config plugin.Config) plugin.Plugin { conf := config.(*config.Input) ... // input插件实例化过程 } // filter插件工厂 type FilterPluginFactory struct {} func (f *FilterPluginFactory) Create(config plugin.Config) plugin.Plugin { conf := config.(*config.Filter) ... // filter插件实例化过程 } // output插件工厂 type OutputPluginFactory struct {} func (o *OutputPluginFactory) Create(config plugin.Config) plugin.Plugin { conf := config.(*config.Output) ... // output插件实例化过程 } // pipeline插件工厂 type PipelinePluginFactory struct {} func (p *PipelinePluginFactory) Create(config plugin.Config) plugin.Plugin { conf := config.(*config.Pipeline) ... // pipeline插件实例化过程 } 最后，通过PipelineFactory来创建Pipline对象：\nconf := \u0026amp;config.Pipeline{...} pipeline := NewPipelinePluginFactory().Create(conf) ... 到目前为止，上述的设计看起来是合理的，运行也没有问题。\n但是，细心的读者可能会发现，每个插件工厂子类的Create方法的第一行代码都是一个转型语句，比如PipelineFactory的是conf := config.(*config.Pipeline)。所以，上一段代码能够正常运行的前提是：传入PipelineFactory.Create方法的入参必须是*config.Pipeline 。如果客户端程序传入*config.Input的实例，PipelineFactory.Create方法将会抛出转型失败的异常。\n上述这个例子就是一个违反LSP的典型场景，虽然在约定好的前提下，程序可以运行正确，但是如果有客户端不小心破坏了这个约定，就会带来程序行为异常（我们永远无法预知客户端的所有行为）。\n要纠正这个问题也很简单，就是去掉PluginFactory这一层抽象，让PipelineFactory.Create等工厂方法的入参声明为具体的配置类，比如PipelineFactory可以这么实现：\n// pipeline插件工厂 type PipelinePluginFactory struct {} func (p *PipelinePluginFactory) Create(config *config.Pipeline) plugin.Plugin { ... // pipeline插件实例化过程 } 从上述几个例子中，我们可以看出遵循LSP的重要性，而设计出符合LSP的软件的要点就是，根据该软件的使用者行为作出的合理假设，以此来审视它是否具备有效性和正确性。\nISP：接口隔离原则 接口隔离原则（The Interface Segregation Principle，ISP）是关于接口设计的一项原则，这里的“接口”并不单指Java或Go上使用interface声明的狭义接口，而是包含了狭义接口、抽象类、具象类等在内的广义接口。它的定义如下：\n Client should not be forced to depend on methods it does not use.\n 也即，一个模块不应该强迫客户程序依赖它们不想使用的接口，模块间的关系应该建立在最小的接口集上。\n下面，我们通过一个例子来详细介绍ISP。\n上图中，Client1、Client2、Client3都依赖了Class1，但实际上，Client1只需使用Class1.func1方法，Client2只需使用Class1.func2，Client3只需使用Class1.func3，那么这时候我们就可以说该设计违反了ISP。\n违反ISP主要会带来如下2个问题：\n 增加模块与客户端程序的依赖，比如在上述例子中，虽然Client2和Client3都没有调用func1，但是当Class1修改func1还是必须通知Client1～3，因为Class1并不知道它们是否使用了func1。 产生接口污染，假设开发Client1的程序员，在写代码时不小心把func1打成了func2，那么就会带来Client1的行为异常。也即Client1被func2给污染了。  为了解决上述2个问题，我们可以把func1、func2、func3通过接口隔离开：\n接口隔离之后，Client1只依赖了Interface1，而Interface1上只有func1一个方法，也即Client1不会受到func2和func3的污染；另外，当Class1修改func1之后，它只需通知依赖了Interface1的客户端即可，大大降低了模块间耦合。\n实现ISP的关键是将大接口拆分成小接口，而拆分的关键就是接口粒度的把握。想要拆分得好，就要求接口设计人员对业务场景非常熟悉，对接口使用的场景了如指掌。否则孤立地设计接口，很难满足ISP。\n下面，我们以分布式应用系统demo为例，来进一步介绍ISP的实现。\n一个消息队列模块通常包含生产（produce）和消费（consumer）两种行为，因此我们设计了Mq消息队列抽象接口，包含Produce和Consume两个方法：\n// Mq 消息队列接口 type Mq interface { Consume(topic Topic) (*Message, error) Produce(message *Message) error } // 当前提供MemoryMq内存消息队列的实现 type MemoryMq struct {...} func (m *memoryMq) Consume(topic Topic) (*Message, error) {...} func (m *memoryMq) Produce(message *Message) error {...} 当前demo中使用接口的模块有2个，分别是作为消费者的MemoryMqInput和作为生产者的AccessLogSidecar：\ntype MemoryMqInput struct { topic mq.Topic consumer mq.Mq // 同时依赖了Consume和Produce } type AccessLogSidecar struct { socket network.Socket producer mq.Mq // 同时依赖了Consume和Produce \ttopic mq.Topic } 从领域模型上看，Mq接口的设计确实没有问题，它就应该包含consume和produce两个方法。但是从客户端程序的角度上看，它却违反了ISP，对MemoryMqInput来说，它只需要consume方法；对AccessLogSidecar来说，它只需要produce方法。\n一种设计方案是把Mq接口拆分成2个子接口Consumable和Producible，让MemoryMq直接实现Consumable和Producible：\n// Consumable 消费接口，从消息队列中消费数据 type Consumable interface { Consume(topic Topic) (*Message, error) } // Producible 生产接口，向消息队列生产消费数据 type Producible interface { Produce(message *Message) error } 仔细思考一下，就会发现上面的设计不太符合消息队列的领域模型，因为Mq的这个抽象确实应该存在的。\n更好的设计应该是保留Mq抽象接口，让Mq继承自Consumable和Producible，这样的分层设计之后，既能满足ISP，又能让实现符合消息队列的领域模型：\n具体实现如下：\n// Mq 消息队列接口，继承了Consumable和Producible，同时又consume和produce两种行为 type Mq interface { Consumable Producible } type MemoryMqInput struct { topic mq.Topic consumer mq.Consumable // 只依赖Consumable } type AccessLogSidecar struct { socket network.Socket producer mq.Producible // 只依赖Producible \ttopic mq.Topic } 接口隔离可以减少模块间耦合，提升系统稳定性，但是过度地细化和拆分接口，也会导致系统的接口数量的上涨，从而产生更大的维护成本。接口的粒度需要根据具体的业务场景来定，可以参考单一职责原则，将那些为同一类客户端程序提供服务的接口合并在一起。\nDIP：依赖倒置原则 《Clean Architecture》中介绍OCP时有提过：如果要模块A免于模块B变化的影响，那么就要模块B依赖于模块A。这句话貌似是矛盾的，模块A需要使用模块B的功能，怎么会让模块B反过来依赖模块A呢？这就是依赖倒置原则（The Dependency Inversion Principle，DIP）所要解答的问题。\nDIP的定义如下：\n  High-level modules should not import anything from low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.   翻译过来，就是：\n 1、高层模块不应该依赖低层模块，两者都应该依赖抽象\n2、抽象不应该依赖细节，细节应该依赖抽象\n 在DIP的定义里，出现了高层模块、低层模块、抽象、细节等4个关键字，要弄清楚DIP的含义，理解者4个关键字至关重要。\n（1）高层模块和低层模块\n一般地，我们认为高层模块是包含了应用程序核心业务逻辑、策略的模块，是整个应用程序的灵魂所在；低层模块通常是一些基础设施，比如数据库、Web框架等，它们主要为了辅助高层模块完成业务而存在。\n（2）抽象和细节\n在前文“OCP：开闭原则”一节中，我们可以知道，抽象就是众多细节中的共同点，抽象就是不断忽略细节的出来的。\n现在再来看DIP的定义，对于第2点我们不难理解，从抽象的定义来看，抽象是不会依赖细节的，否则那就不是抽象了；而细节依赖抽象往往都是成立的。\n理解DIP的关键在于第1点，按照我们正向的思维，高层模块要借助低层模块来完成业务，这必然会导致高层模块依赖低层模块。但是在软件领域里，我们可以把这个依赖关系倒置过来，这其中的关键就是抽象。我们可以忽略掉低层模块的细节，抽象出一个稳定的接口，然后让高层模块依赖该接口，同时让低层模块实现该接口，从而实现了依赖关系的倒置：\n之所以要把高层模块和底层模块的依赖关系倒置过来，主要是因为作为核心的高层模块不应该受到低层模块变化的影响。高层模块的变化原因应当只能有一个，那就是来自软件用户的业务变更需求。\n下面，我们通过分布式应用系统demo来介绍DIP的实现。\n对于服务注册中心Registry来说，当有新的服务注册上来时，它需要把服务信息（如服务ID、服务类型等）保存下来，以便在后续的服务发现中能够返回给客户端。因此，Registry需要一个数据库来辅助它完成业务。刚好，我们的数据库模块实现了一个内存数据库MemoryDb，于是我们可以这么实现Registry：\ntype Registry struct { db db.MemoryDb // 直接依赖MemoryDb \tserver *http.Server localIp string svcManagement *svcManagement svcDiscovery *svcDiscovery } 按照上面的设计，模块间的依赖关系是Registry依赖于MemoryDb，也即高层模块依赖于低层模块。这种依赖关系是脆弱的，如果哪天需要把存储服务信息的数据库从MemoryDb改成DiskDb，那么我们也得改Registry的代码：\ntype Registry struct { db db.DiskDb // 改成依赖DiskDb \tserver *http.Server localIp string svcManagement *svcManagement svcDiscovery *svcDiscovery } 更好的设计应该是把Registry和MemoryDb的依赖关系倒置过来，首先我们需要从细节MemoryDb抽象出一个稳定的接口Db：\n更好的设计应该是把Registry和MemoryDb的依赖关系倒置过来，首先我们需要从细节MemoryDb抽象出一个稳定的接口Db：\n// Db 数据库抽象接口 type Db interface { Query(tableName string, primaryKey interface{}, result interface{}) error Insert(tableName string, primaryKey interface{}, record interface{}) error Update(tableName string, primaryKey interface{}, record interface{}) error Delete(tableName string, primaryKey interface{}) error ... } 接着，我们让Registry依赖Db接口，而MemoryDb实现Db接口，以此来完成依赖倒置：\ntype Registry struct { db db.Db // 依赖Db抽象接口 \tserver *http.Server localIp string svcManagement *svcManagement svcDiscovery *svcDiscovery } // MemoryDb 实现Db接口 type MemoryDb struct { tables sync.Map // key为tableName，value为table } func (m *memoryDb) Query(tableName string, primaryKey interface{}, result interface{}) error {...} func (m *memoryDb) Insert(tableName string, primaryKey interface{}, record interface{}) error {...} func (m *memoryDb) Update(tableName string, primaryKey interface{}, record interface{}) error {...} func (m *memoryDb) Delete(tableName string, primaryKey interface{}) error {...} 当高层模块依赖抽象接口时，总得在某个时候，某个地方把实现细节（低层模块）注入到高层模块上。在上述例子中，我们选择在main函数上，在创建Registry对象时，把MemoryDb注入进去。\n一般地，我们都会在main/启动函数上完成依赖注入，常见的注入的方式有以下几种：\n 构造函数注入（Registry所使用的方法） setter方法注入 提供依赖注入的接口，客户端直调用该接口即可 通过框架进行注入，比如Spring框架中的注解注入能力  另外，DIP不仅仅适用于模块/类/接口设计，在架构层面也同样适用，比如DDD的分层架构和Uncle Bob的整洁架构，都是运用了DIP：\n当然，DIP并不是说高层模块是只能依赖抽象接口，它的本意应该是依赖稳定的接口/抽象类/具象类。如果一个具象类是稳定的，比如Java中的String，那么高层模块依赖它也没有问题；相反，如果一个抽象接口是不稳定的，经常变化，那么高层模块依赖该接口也是违反DIP的，这时候应该思考下接口是否抽象合理。\n最后 本文花了很长的篇幅讨论了23种设计模式背后的核心思想 —— SOLID原则，它能指导我们设计出高内聚、低耦合的软件系统。但是它毕竟只是原则，如何落地到实际的工程项目上，还是需要参考成功的实践经验。而这些实践经验正是接下来我们要探讨的设计模式。\n学习设计模式最好的方法就是实践，在《实践GoF的23种设计模式》后续的文章，我们将以本文介绍的分布式应用系统demo作为实践示范，介绍23种设计模式的程序结构、适用场景、实现方法、优缺点等，让大家对设计模式有个更深入的理解，能够用对、不滥用设计模式。\n 参考\n Clean Architecture, Robert C. Martin (“Uncle Bob”) 敏捷软件开发：原则、模式与实践, Robert C. Martin (“Uncle Bob”) 使用Go实现GoF的23种设计模式, 元闰子 【Java实现】实践GoF的23种设计模式：SOLID原则 , 元闰子 SOLID原则精解之里氏替换原则LSP, 人民副首席码仔  更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-03-13T00:00:00Z","permalink":"https://yrunz.com/p/go%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8Fsolid%E5%8E%9F%E5%88%99/","title":"【Go实现】实践GoF的23种设计模式：SOLID原则"},{"content":"前言 说起网络通信协议，相信大家对 TCP 和 HTTP 都很熟悉，它们可以说是当今互联网通信的基石。但是，在网络安全方面，它们却是有着很大安全风险：\n 窃听风险。第三方攻击者可以随意窃听通信内容，比如获取支付账号密码。 冒充风险。第三方攻击者可以冒充他人身份与你通信，比如冒充银行网站以窃取银行账号密码。 篡改风险。第三方攻击者可以随意修改通信内容，比如在响应上加入钓鱼网址。  为此，SSL/TLS 协议应运而生。SSL/TLS 是建立在传输层之上、应用层之下的安全通信协议，它主要的设计意图就是消除上述几种安全风险，保证网络通信安全。我们熟知的 HTTPS 就是 HTTP + SSL/TLS 构建的，可以说 SSL/TLS 是当今互联网安全通信的基石。\n那么，现在假如让你来设计 SSL/TLS 协议，你会怎么设计呢？\n 本文将从设计者的视角介绍如何一步步设计出一个简易版的 SSL/TLS 的过程，在文章的最后，再简单介绍 TLS 1.2 版本的工作机制，以此帮助大家对 SSL/TLS 协议的基本原理有一个更深入的理解。\n 基于对称加密算法的数据加密 窃听风险主要是因为通信双方在网络上明文传输数据，导致攻击者可以通过简单网络抓包就能获取到通信的内容。\n要解决窃听风险，就最好的方法就是对数据进行加密。也即客户端在把数据发送出去之前，先对数据进行加密；服务端收到密文之后，再进行解密还原数据。这样就能避免在网络上传播明文，从而可以防止第三方攻击者的窃听。\n提到加密算法，很多人首先会想到对称加密算法，它以简单和高效著称。对称加密指的是加密和解密都使用同一份密钥，常见的算法有 DES、AES 等。\n现在，我们试着使用对称加密算法来实现安全通信：\n使用对称密钥加密的前提是，通信双方都必须用同一份密钥来对数据进行加密。主要有线下和线上密钥交换两种方案可以达到该目的：\n 线下密钥交换，也即通信双方线下约定好当面交换密钥（比如通过U盘作为媒介）。该方案可以保证密钥交换的安全性，但是很难推广使用。因为在绝大多数场景中，客户端和服务端都不可能碰面。 线上密钥交换，也即通过网络来传输密钥。但在网络明文传输密钥同样也会被攻击者拦截，这样的加密也没有意义了。  因此，单纯的对称加密并不能满足通信安全的要求，我们还要继续优化\u0026hellip;\u0026hellip;\n基于非对称加密算法的数据加密 非对称加密算法指的是加密和解密使用不同的密钥，这两个不同的密钥组成一个密钥对，也即公钥和私钥。公钥是公开的密钥，所有人都能获取到；私钥则是保密的。当我们使用公钥对数据进行加密后，只有对应的私钥才能完成解密。常见的非对称加密算法有 RSA、ECC 等。\n现在，我们试着使用非对称加密算法来实现安全通信：\n通过非对称加密算法，我们既能实现对数据的加密，又能解决密钥交换的问题，从而消除了窃听风险。但是，非对称加密算法最大的缺点，就是加解密速度很慢，相比于对称加密算法要慢1000多倍。因此，非对称加密算法通常只适用于对少量数据的加密。\n到目前为止，单纯地使用对称加密算法或非对称加密算法都无法满足要求，还需要继续优化\u0026hellip;\u0026hellip;\n基于对称加密+非对称加密算法的数据加密 既然对称加密算法加解密速度快，但存在密钥交换的问题；而非对称加密算法可以解决密钥交换问题，但加解密速度慢。那么我们可以把两种算法结合起来，也即通过对称加密算法进行数据加密，在交换对称密钥时，使用非对称加密算法来加密对称密钥，确保密钥在网络传输过程中不会被攻击者窃听。\n现在，我们试着使用对称加密+非对称加密算法来实现安全通信：\n使用对称加密+非对称加密算法的方案，我们消除了窃听风险，也不会存在加解密性能问题，但是还是无法消除冒充风险。\n考虑如下场景：\n 攻击者把服务端的公钥拦截，并保存下来。 攻击者伪造成服务端，把自己的公钥发送给客户端。 攻击者拦截使用非法公钥加密后的对称密钥，解密后得到对称密钥明文，并保存下来 攻击者使用服务端公钥重新加密对称密钥，伪造成客户端发送给服务端。  这番操作后，攻击者就能在客户端和服务端都不知情的情况下，得到了对称密钥。在这种场景下，攻击者从被动攻击的窃听，转为主动攻击的冒充，让客户端和服务端都误以为一直在跟对方通信。\n因此，我们需要找到一种方法，让客户端能够确保自己收到的公钥，一定是真实的服务端发送过来的，也即能够认证“服务端”的真实身份\u0026hellip;\u0026hellip;\n基于CA证书的身份认证 数字证书概述 引用百度百科的定义：\n 数字证书是指在互联网通讯中标志通讯各方身份信息的一个数字认证，人们可以在网上用它来识别对方的身份。\n 数字证书（Digital Certificate）就好比现实世界中的身份证，用于标识一个网络用户（人、公司、服务器等）的合法身份。就像身份证必须由公安局来颁发，可信的数字证书也必须由一个权威机构来颁发，该机构就是证书授权中心（Certificate Authority，CA），CA 颁发的数字证书我们通常称作 CA 证书。\n一个 CA 证书主要包含申请者的公钥、申请者的信息、签发机构 CA 的信息、有效时间、证书序列号等明文信息，同时也包含一个 CA 的数字签名，正是该签名的存在才证明了该证书的有效性。\n数字签名建立在非对称加密算法的基础上，CA 在颁发证书时，会先将证书的明文信息用指定的算法（比如 SHA256 算法）计算出一个数字摘要，再使用 CA 的私钥对摘要进行加密，形成签名。\n而证书的验证主要包含如下两部分：\n 检查证书的明文信息是否有效，比如证书是否过期、域名是否一致等。 用 CA 公布的公钥对证书签名进行解密，得到数字摘要1。再使用同样的算法对证书明文信息计算得出数字摘要2。对比数字摘要1和数字摘要2是否相等。   颁发证书的机构并非只有一个，比如机构 A 可以用 CA 颁发的根证书去给机构 B 颁发二级证书；机构 B 又可以用二级证书去给机构 C 颁发三级证书，以此类推，也即所谓的证书链。\n 使用CA证书认证通信双方的身份 现在，我们加入 CA 证书来认证通信双方的身份：\n引入 CA 证书之后，服务端的公钥就放在它提供的证书之中，当客户端验证服务端证书通过后，也就说明其中的公钥确实是来自服务端的合法公钥。这样，后续的通信流程就可以正常地进行了。\n然而，如果对称密钥一直不变的话，攻击者还是很有可能暴力破解出对称密钥。因此，我们还需要最好能够实现每次连接的对称密钥都是不同的\u0026hellip;\u0026hellip;\n使用随机数来生成对称密钥 为了使每次连接的对称密钥都不同，我们可以引入随机数来生成对称密钥，保证它的随机性。但是，考虑到当前计算机生成的随机数都是伪随机数，为了进一步提升随机性，我们可以通过生成多个随机数来达到此目的。\n我们可以这么设计：\n 客户端和服务端先各生成一个随机数（随机数1和随机数2），在 ClientHello 和 ServerHello 报文中完成交换。 客户端在验证完服务端的身份后，再生成随机数3，通过服务端的公钥加密发给服务端。（此时，通信双方都拥有了 3 个一样随机数） 客户端和服务端再根据这 3 个随机数，生成最终的对称密钥。  这样通过 3 个随机数来生成的密钥，就能较好地保证了密钥的随机性，降低被攻击者破解的可能。\n 虽然随机数1和随机数2是明文传输，但随机数3是密传输，也就能够保证攻击者很难破解到密钥。\n 到目前为止，我们已经通过多种手段成功阻止了攻击者窃听客户端和服务端之间的通信内容。但是，如果攻击者并不以窃听通信内容为目的，而是单纯地想搞破坏。比如，攻击者拦截了 ClientHello 报文，把其中的随机数1改成了随机数4，这样就会导致客户端和服务端生成的密钥不一致。在此场景下，虽然连接已经建立起来了，但是客户端和服务端还是无法正常地通信：\n为此，我们需要一种机制，校验连接建立阶段（握手阶段）所有消息的正确性，防止建立错误的连接\u0026hellip;\u0026hellip;\n校验握手消息的正确性 我们可以利用数字摘要来校验所有握手消息的正确性，也即，在握手阶段的最后，通信双方都通过 Hash 算法（比如 SHA256）对自己收到的和发送的所有消息计算出数字摘要，然后使用前面协商好的对称密钥对该数字摘要进行加密，发送给对方。\n当收到对方发过来的数字摘要密文后，先用对称密钥对其进行解密，如果解密成功，说明密钥生成没问题；接着对比双方的数字摘要是否一致，如果一致，说明握手阶段的消息没有被篡改过，也即可以建立起正确的连接了。\n到现在，我们基本上已经消除了窃听风险（通过数据加密）、冒充风险（通过证书认证）和篡改风险（通过数字摘要）。但是，为了建立起安全通信通道，我们需要经历多次消息交互、加解密、身份认证等步骤，对性能有一定的损耗。\n因为经历过一次握手之后，密钥已经协商好，并且双方都保存了下来。下次连接建立时，完全可以沿用上次握手协商好的密钥，从而避免了重新协商密钥，提升了性能。我们需要一种重用会话的机制来提升协议的性能\u0026hellip;\u0026hellip;\n重用会话来提升性能 为了达到沿用上次协商好的密钥的目的，我们为每次连接都分配一个会话 ID。\n 在初次创建连接时，由服务端生成，并通过 ServerHello 返回给客户端。 在下一次创建连接时，客户端通过 ClientHello 把该会话 ID 发给服务端，表示希望重用该会话。 服务端在收到该会话 ID 后，就可以发送 Finished 消息，表示同意了会话重用，也即可以沿用上次会话协商好的密钥进行安全通信了。  到这里，我们已经完成了一个简易版的 SSL/TLS 协议的设计，真实的 SSL/TLS 协议当然没这么简单，但是里面的核心思想和基本原理都是类似的。只是 SSL/TLS 为了更好的安全性、扩展性和易用性等增加了一些机制，比如支持多种加密算法、使用 MAC 替代普通的数字摘要完成完整性校验。\n下面，我们将简单介绍真实的 SSL/TLS 协议的工作机制。\nSSL/TLS协议机制概述 SSL 协议（Secure Sockets Layer）是 TLS（Transport Layer Security）协议的前身，它们的版本演进如下，当前最新的版本为 TLS 1.3 版本。本节，我们将以当前使用最广泛的 TLS 1.2 版本作为分析对象，介绍 SSL/TLS 的基本工作机制。\n SSL 1.0版本 -\u0026gt; SSL 2.0版本 -\u0026gt; SSL 3.0版本 -\u0026gt; TLS 1.0版本 -\u0026gt; TLS 1.1版本 -\u0026gt; TLS 1.2版本 -\u0026gt; TLS 1.3版本\n SSL/TLS协议总览 SSL/TLS 协议位于网络协议栈中传输层和应用层之间，它内部又可以分为 2 层，总共 5 种子协议：\nRecord协议 最底层的 Record 协议负责对上层子协议的封装，提供安全通信的能力：\n 私密连接。使用对称加密算法（比如 AES、RC4 等）来加密数据，而且在每次连接中，通信双方协商出来的加密密钥都是不同的，以此达到更好的安全性。另外，Record 协议也可以提供不加密的封装，比如在握手阶段的 Hello 报文。 可靠连接。使用 MAC（Message Authentication Code，消息验证码，TLS 目前使用的 HMAC 也属于 MAC 的一种）为数据提供完整性校验。同样，在握手阶段也可以不使用该功能。  Handshake 协议 上层的 Handshake 协议用在握手阶段，为通信双方提供身份认证、加密算法和密钥协商的能力：\n 身份认证。基于 CA 证书完成对端的身份认证，其中用到了非对称加密技术（比如 RSA、DES 等）。该功能是可选的，但通常的做法是至少进行单向认证。 安全参数的协商。完成用于安全参数的协商（比如加密算法、哈希算法、密钥等），并且能够保证在协商过程中，攻击者无法获取密钥。 可靠协商。确保在安全参数等协商过程中，攻击者无法对报文实施篡改。  Handshake 协议包含了如下几种报文类型：ClientHello、SeverHello、Certificate、ServerKeyExchange、CertificateRequest、ServerHelloDone、ClientKeyExchange、CertificateVerify、ChangeCipherSpec、Finished。\nChange Cipher Spec协议 Change Cipher Spec 协议也用在握手阶段，当通信的一方发出Change Cipher Spec 报文时，就表示密钥已经协商好，从下一条消息开始，使用该密钥来进行加密传输。\nAlert 协议 Alert 协议只有在连接异常时才会用上，当前协议定义的 Alert 消息类型如下：\nclose_notify: 表示发送方不会再发送任何消息，用于正常关闭连接，类似于TCP中的FIN报文 unexpected_message: 收到不在预期之内的消息 bad_record_mac: 收到的消息中MAC不正确，表示消息已经被篡改过 decryption_failed_RESERVED: 解密失败，用于TLS的早期版本 record_overflow: 消息长度溢出，密文长度不超过2^14+2048字节；压缩后的明文不超过2^14+1024字节 decompression_failure: 使用压缩功能时，解压失败 handshake_failure: 握手阶段无法协商出正确的安全参数 no_certificate_RESERVED: 为了兼容SSL 3.0版本，TLS不再使用 bad_certificate: 证书签名认证失败 unsupported_certificate: 收到不支持的证书类型 certificate_revoked: 收到被废弃的证书 certificate_expired: 收到过期的证书 certificate_unknown: 除上述4种情况外，其他证书异常场景 illegal_parameter: 握手阶段报文的参数非法，比如范围溢出等 unknown_ca: 不可信任的CA颁发的证书 access_denied: 证书校验通过，但发送方却拒绝继续握手 decode_error: 消息解码失败 decrypt_error: 握手阶段安全相关的步骤失败，比如签名校验失败、Finished消息校验失败等 export_restriction_RESERVED: 早期的TLS版本使用 protocol_version: 协议版本不支持 insufficient_security: 服务端要求的安全算法，客户端无法满足 internal_error: 协议内部错误 user_canceled: 用户非正常主动关闭连接 no_renegotiation: 拒绝重新握手 unsupported_extension: 不支持的扩展 Application Data 协议 Application Data 协议用在通信阶段，封装了应用层的数据，经由 Record 协议封装之后，通过 TCP 协议转发出去。\nSSL/TLS 协议的握手过程 第一次握手  客户端向服务端发送 ClientHello 报文发起连接建立，其中携带了如下内容：  Version: 客户端支持的TLS协议版本 Random: 客户端生成的随机数，随后用于生成 master secret SessionID: 会话 ID，如果不为空，表示客户端想重用该会话 CipherSuites: 客户端支持的加密套件列表，在 SessionID 为空时必须携带 CompressionMethods: 客户端支持的压缩算法列表 Extensions: 扩展内容    第二次握手   服务端向客户端发送 ServerHello 报文，其中携带了如下内容：\n Version: 选定的 TLS 版本，选择通信双方都支持的最高版本 Random: 服务端生成的随机数，随后用于生成 master secret SessionID: 会话 ID，如果为空，表示服务端开启新的会话，并且不希望被重用；如果与客户端带过来的 SessionID 一样，表示重用该会话；否则，开启一个新的会话，并且在未来可能会被重用 CipherSuite: 选定的加密套件 CompressionMethod: 选定的压缩算法 Extensions: 扩展内容    服务端向客户端发送 Certificate 报文，其中携带了服务端的证书，证书必须是 x.509 标准格式，包含服务端公钥、服务端域名、签发方信息、有效期等信息。\n 可选，客户端需要通过证书来认证服务端身份时发送\n   服务端向客户端发送 Server Key Exchange 报文，其中携带了客户端用于生成 premaster secret 的安全参数。\n 可选，当 Certificate 报文中携带的信息无法支撑客户端生成 premaster secret 时发送\n   服务端向客户端发送 CertificateRequest 报文，索求客户端证书，其中包含了期望的证书类型、签名算法和CA列表。\n 可选，开启双向认证时发送\n   服务端向客户端发送 ServerHelloDone 报文，表示当前服务端已经把所有与密钥交换相关的内容都发送完毕。\n  第三次握手   客户端向服务端发送 Certificate 报文，其中携带了客户端证书。\n 可选，收到服务端的 CertificateRequest 报文时发送\n   客户端向服务端发送 ClientKeyExchange 报文，其中包含了使用服务端公钥加密后的 premaster secret （随机数），随后用于生成master secret。\n  客户端向服务端发送 CertificateVerify 报文，其中包含了对通信双方到目前为止所有握手报文的数字签名，用于证明自己拥有的私钥与之前发送的证书中的公钥相对应。\n 可选，给服务端发送 Certificate 报文时发送\n   客户端向服务端发送 ChangeCipherSpec 报文，表示从下条消息开始进行加密传输。\n  客户端向服务端发送 Finished 报文，加密传输，其中包含了所有握手消息的数字摘要，用于防篡改。\n  第四次握手  服务端向客户端发送 ChangeCipherSpec 报文，表示从下条消息开始进行加密传输。 服务端向客户端发送 Finished 报文，加密传输，其中包含了所有握手消息的数字摘要，用于防篡改。  最后 SSL/TLS 协议也并非绝对安全，它也有许多漏洞被黑客们不断地挖掘出来，当然，SSL/TLS 协议也在不断地完善。2018 年发布的 TLS 1.3 版本就在 TLS 1.2 版本的基础上做了许多增强。比如，在性能上，握手阶段从 2-RTT 缩减为 1-RTT，并支持 0-RTT 模式；在安全上，ServerHello 报文之后就开始加密传输、一些不安全的加密套件也不再支持（比如静态 RSA、Diffie-Hellman 等）。\n虽然 TLS 1.3 版本机制上改变了很多，但是基本原理还是一样的。因此，把 SSL/TLS 协议原理理解透了，后续不管版本再怎么演进，我们都能快速完成协议机制的学习。\n 参考\nThe Transport Layer Security (TLS) Protocol Version 1.2，RFC 5246\nThe Transport Layer Security (TLS) Protocol Version 1.3，RFC 8446\nSSL/TLS 协议运行机制的概述，阮一峰\nOverview of SSL/TLS Encryption， MicroSoft Document\nThe First Few Milliseconds of an HTTPS Connection，Jeff Moser\n为什么 HTTPS 需要 7 次握手以及 9 倍时延，面向信仰编程\nHTTPS权威指南，杨洋、李振宇等\n数字证书, 百度百科\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-03-05T00:00:00Z","permalink":"https://yrunz.com/p/%E5%81%87%E5%A6%82%E8%AE%A9%E4%BD%A0%E6%9D%A5%E8%AE%BE%E8%AE%A1ssl/tls%E5%8D%8F%E8%AE%AE/","title":"假如让你来设计SSL/TLS协议"},{"content":" 之前也有写过关于设计模式的文章《使用Go实现GoF的23种设计模式》，但是那个系列写了3篇文章就没在继续了，主要的原因是找不到合适的示例代码。考虑到，如果以类似于“鸭子是否会飞”、“烘培的制作流程”等贴近生活的事情举例，很难在我们日常的开发中产生联系。（目前应该很少有这些逻辑的软件系统吧）\n《实践GoF的23种设计模式》可以看成是《使用Go实现GoF的23种设计模式》系列的重启，吸取了上次烂尾的教训，本次在写文章之前就已经完成了23种设计模式的示例代码实现。和上次不同，本次示例代码使用Java实现，以我们日常开发中经常碰到的一些技术/问题/场景作为切入点，示范如何运用设计模式来完成相关的实现。\n 前言 从1995年GoF提出23种设计模式到现在，25年过去了，设计模式依旧是软件领域的热门话题。设计模式通常被定义为：\n 设计模式（Design Pattern）是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结，使用设计模式是为了可重用代码、让代码更容易被他人理解并且保证代码可靠性。\n 从定义上看，设计模式其实是一种经验的总结，是针对特定问题的简洁而优雅的解决方案。既然是经验总结，那么学习设计模式最直接的好处就在于可以站在巨人的肩膀上解决软件开发过程中的一些特定问题。\n学习设计模式的最高境界是吃透它们本质思想，可以做到即使已经忘掉某个设计模式的名称和结构，也能在解决特定问题时信手拈来。设计模式背后的本质思想，就是我们熟知的SOLID原则。如果把设计模式类比为武侠世界里的武功招式，那么SOLID原则就是内功内力。通常来说，先把内功练好，再来学习招式，会达到事半功倍的效果。因此，在介绍设计模式之前，很有必要先介绍一下SOLID原则。\n本文首先会介绍本系列文章中用到的示例代码demo的整体结构，然后开始逐一介绍SOLID原则，也即单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。\n一个简单的分布式应用系统  本系列示例代码demo获取地址：https://github.com/ruanrunxue/Practice-Design-Pattern\u0026ndash;Java-Implementation\n 示例代码demo工程实现了一个简单的分布式应用系统（单机版），该系统主要由以下几个模块组成：\n 网络 Network，网络功能模块，模拟实现了报文转发、socket通信、http通信等功能。 数据库 Db，数据库功能模块，模拟实现了表、事务、dsl等功能。 消息队列 Mq，消息队列模块，模拟实现了基于topic的生产者/消费者的消息队列。 监控系统 Monitor，监控系统模块，模拟实现了服务日志的收集、分析、存储等功能。 边车 Sidecar，边车模块，模拟对网络报文进行拦截，实现access log上报、消息流控等功能。 服务 Service，运行服务，当前模拟实现了服务注册中心、在线商城服务集群、服务消息中介等服务。  示例代码demo工程的主要目录结构如下：\n├── db # 数据库模块，定义Db、Table、TableVisitor等抽象接口 【@单例模式】 │ ├── cache # 数据库缓存代理，为Db新增缓存功能 【@代理模式】 │ ├── console # 数据库控制台实现，支持dsl语句查询和结果显示 【@适配器模式】 │ ├── dsl # 实现数据库dsl语句查询能力，当前只支持select语句查询 【@解释器模式】 │ ├── exception # 数据库模块相关异常定义 │ ├── iterator # 遍历表迭代器，包含按序遍历和随机遍历 【@迭代器模式】 │ └── transaction # 实现数据库的事务功能，包括执行、提交、回滚等 【@命令模式】【@备忘录模式】 ├── monitor # 监控系统模块，采用插件式的架构风格，当前实现access log日志etl功能 │ ├── config # 监控系统插件配置模块 【@抽象工厂模式】【@组合模式】 │ │ ├── json # 实现基于json格式文件的配置加载功能 │ │ └── yaml # 实现基于yaml格式文件的配置加载功能 │ ├── entity # 监控系统实体对象定义 │ ├── exception # 监控系统相关异常 │ ├── filter # Filter插件的实现定义 【@责任链模式】 │ ├── input # Input插件的实现定义 【@策略模式】 │ ├── output # Output插件的实现定义 │ ├── pipeline # Pipeline插件的实现定义，一个pipeline表示一个ETL处理流程 【@桥接模式】 │ ├── plugin # 插件抽象接口定义 │ └── schema # 监控系统相关的数据表定义  ├── mq # 消息队列模块 ├── network # 网络模块，模拟网络通信，定义了socket、packet等通用类型/接口 【@观察者模式】 │ └── http # 模拟实现了http通信等服务端、客户端能力 ├── service # 服务模块，定义了服务的基本接口 │ ├── mediator # 服务消息中介，作为服务通信的中转方，实现了服务发现，消息转发的能力 【@中介者模式】 │ ├── registry # 服务注册中心，提供服务注册、去注册、更新、 发现、订阅、去订阅、通知等功能 │ │ ├── entity # 服务注册/发现相关的实体定义 【@原型模式】【@建造者模式】 │ │ └── schema # 服务注册中心相关的数据表定义 【@访问者模式】【@享元模式】 │ └── shopping # 模拟在线商城服务群的定义，包含订单服务、库存服务、支付服务、发货服务 【@外观模式】 └── sidecar # 边车模块，对socket进行拦截，提供http access log、流控功能 【@装饰者模式】【@工厂模式】 └── flowctrl # 流控模块，基于消息速率进行随机流控 【@模板方法模式】【@状态模式】 SRP：单一职责原则 单一职责原则（The Single Responsibility Principle，SRP）应该是SOLID原则中，最容易被理解的一个，但同时也是最容易被误解的一个。很多人会把“将大函数重构成一个个职责单一的小函数”这一重构手法等价为SRP，这是不对的，小函数固然体现了职责单一，但这并不是SRP。\nSRP传播最广的定义应该是Uncle Bob给出的：\n A module should have one, and only one, reason to change.\n 也即，一个模块应该有且只有一个导致其变化的原因。\n这个解释里有2个需要理解的地方：\n（1）如何定义一个模块\n我们通常会把一个源文件定义为最小粒度的模块。\n（2）如何找到这个原因\n一个软件的变化往往是为了满足某个用户的需求，那么这个用户就是导致变化的原因。但是，一个模块的用户/客户端程序往往不只一个，比如Java中的ArrayList类，它可能会被成千上万的程序使用，但我们不能说ArrayList职责不单一。因此，我们应该把“一个用户”改为“一类角色”，比如ArrayList的客户端程序都可以归类为“需要链表/数组功能”的角色。\n于是，Uncle Bob给出了SRP的另一个解释：\n A module should be responsible to one, and only one, actor.\n 有了这个解释，我们就可以理解函数职责单一并不等同于SRP，比如在一个模块有A和B两个函数，它们都是职责单一的，但是函数A的使用者是A类用户，函数B的使用者是B类用户，而且A类用户和B类用户变化的原因都是不一样的，那么这个模块就不满足SRP了。\n下面，以我们的分布式应用系统demo为例进一步探讨。对于Registry类（服务注册中心）来说，它对外提供的基本能力有服务注册、更新、去注册和发现功能，那么，我们可以这么实现：\n// demo/src/main/java/com/yrunz/designpattern/service/Registry.java public class Registry implements Service { private final HttpServer httpServer; private final Db db; ... @Override public void run() { httpServer.put(\u0026#34;/api/v1/service-profile\u0026#34;, this::register) .post(\u0026#34;/api/v1/service-profile\u0026#34;, this::update) .delete(\u0026#34;/api/v1/service-profile\u0026#34;, this::deregister) .get(\u0026#34;/api/v1/service-profile\u0026#34;, this::discovery) .start(); } // 服务注册  private HttpResp register(HttpReq req) { ... } // 服务更新  private HttpResp update(HttpReq req) { ... } // 服务去注册  private HttpResp deregister(HttpReq req) { ... } // 服务发现  private HttpResp discovery(HttpReq req) { ... } } 上述实现中，Registry包含了register、update、deregister、discovery等4个主要方法，正好对应了Registry对外提供的能力，看起来已经是职责单一了。\n但是在仔细思考一下就会发现，服务注册、更新和去注册是给专门给服务提供者使用的功能，而服务发现则是专门给服务消费者使用的功能。服务提供者和服务消费者是两类不同的角色，它们产生变化的时间和方向都可能不同。比如：\n 当前服务发现功能是这么实现的：Registry从满足查询条件的所有ServiceProfile中挑选一个返回给服务消费者（也即Registry自己做了负载均衡）。\n假设现在服务消费者提出新的需求：Registry把所有满足查询条件的ServiceProfile都返回，由服务消费者自己来做负载均衡。\n为了实现这样的功能，我们就要修改Registry的代码。按理，服务注册、更新、去注册等功能并不应该受到影响，但因为它们和服务发现功能都在同一个模块（Registry）里，于是被迫也受到影响了，比如可能会代码冲突。\n 因此，更好的设计是将register、update、deregister内聚到一个服务管理模块SvcManagement，discovery则放到另一个服务发现模块SvcDiscovery，服务注册中心Registry再组合SvcManagement和SvcDiscovery。\n具体实现如下：\n// demo/src/main/java/com/yrunz/designpattern/service/SvcManagement.java class SvcManagement { private final Db db; ... // 服务注册  HttpResp register(HttpReq req) { ... } // 服务更新  HttpResp update(HttpReq req) { ... } // 服务去注册  HttpResp deregister(HttpReq req) { ... } } // demo/src/main/java/com/yrunz/designpattern/service/SvcDiscovery.java class SvcDiscovery { private final Db db; ... // 服务发现  HttpResp discovery(HttpReq req) { ... } } // demo/src/main/java/com/yrunz/designpattern/service/Registry.java public class Registry implements Service { private final HttpServer httpServer; private final SvcManagement svcManagement; private final SvcDiscovery svcDiscovery; ... @Override public void run() { // 使用子模块的方法完成具体业务  httpServer.put(\u0026#34;/api/v1/service-profile\u0026#34;, svcManagement::register) .post(\u0026#34;/api/v1/service-profile\u0026#34;, svcManagement::update) .delete(\u0026#34;/api/v1/service-profile\u0026#34;, svcManagement::deregister) .get(\u0026#34;/api/v1/service-profile\u0026#34;, svcDiscovery::discovery) .start(); } } 除了重复的代码编译，违反SRP还会带来以下2个常见的问题：\n1、代码冲突。程序员A修改了模块的A功能，而程序员B在不知情的情况下也在修改该模块的B功能（因为A功能和B功能面向不同的用户，完全可能由2位不同的程序员来维护），当他们同时提交修改时，代码冲突就会发生（修改了同一个源文件）。\n2、A功能的修改影响了B功能。如果A功能和B功能都使用了模块里的一个公共函数C，现在A功能有新的需求需要修改函数C，那么如果修改人没有考虑到B功能，那么B功能的原有逻辑就会受到影响。\n由此可见，违反SRP会导致软件的可维护性变得极差。但是，我们也不能盲目地进行模块拆分，这样会导致代码过于碎片化，同样也会提升软件的复杂性。比如，在前面的例子中，我们就没有必要再对服务管理模块进行拆分为服务注册模块、服务更新模块和服务去注册模块，一是因为它们面向都用户是一致的；二是在可预见的未来它们要么同时变化，要么都不变。\n因此，我们可以得出这样的结论：\n 如果一个模块面向的都是同一类用户（变化原因一致），那么就没必要进行拆分。 如果缺乏用户归类的判断，那么最好的拆分时机是变化发生时。  SRP是聚合和拆分的一个平衡，太过聚合会导致牵一发动全身，拆分过细又会提升复杂性。要从用户的视角来把握拆分的度，把面向不同用户的功能拆分开。如果实在无法判断/预测，那就等变化发生时再拆分，避免过度的设计。\nOCP：开闭原则 开闭原则（The Open-Close Principle，OCP）中，“开”指的是对扩展开放，“闭”指的是对修改封闭，它的完整解释为：\n A software artifact should be open for extension but closed for modification.\n 通俗地讲就是，一个软件系统应该具备良好的可扩展性，新增功能应当通过扩展的方式实现，而不是在已有的代码基础上修改。\n然而，从字面意思上看，OCP貌似又是自相矛盾的：想要给一个模块新增功能，但是有不能修改它。\n*如何才能打破这个困境呢？*关键是抽象！优秀的软件系统总是建立在良好的抽象的基础上，抽象化可以降低软件系统的复杂性。\n*那么什么是抽象呢？*抽象不仅存在与软件领域，在我们的生活中也随处可见。下面以《语言学的邀请》中的一个例子来解释抽象的含义：\n 假设某农庄有一头叫“阿花”的母牛，那么：\n1、当把它称为“阿花”时，我们看到的是它独一无二的一些特征：身上有很多斑点花纹、额头上还有一个闪电形状的伤疤。\n2、当把它称为母牛时，我们忽略了它的独有特征，看到的是它与母牛“阿黑”，母牛“阿黄”的共同点：是一头牛、雌性的。\n3、当把它称为家畜时，我们又忽略了它作为母牛的特征，而是看到了它和猪、鸡、羊一样的特点：是一个动物，在农庄里圈养。\n4、当把它称为农庄财产时，我们只关注了它和农庄上其他可售对象的共同点：可以卖钱、转让。\n从“阿花”，到母牛，到家畜，再到农庄财产，这就是一个不断抽象化的过程。\n 从上述例子中，我们可以得出这样的结论：\n 抽象就是不断忽略细节，找到事物间共同点的过程。 抽象是分层的，抽象层次越高，细节也就越少。  在回到软件领域，我们也可以把上述的例子类比到数据库上，数据库的抽象层次从低至高可以是这样的：MySQL 8.0版本 -\u0026gt; MySQL -\u0026gt; 关系型数据库 -\u0026gt; 数据库。现在假设有一个需求，需要业务模块将业务数据保存到数据库上，那么就有以下几种设计方案：\n 方案一：把业务模块设计为直接依赖MySQL 8.0版本。因为版本总是经常变化的，如果哪天MySQL升级了版本，那么我们就得修改业务模块进行适配，所以方案一违反了OCP。 方案二：把业务模块设计为依赖MySQL。相比于方案一，方案二消除了MySQL版本升级带来的影响。现在考虑另一种场景，如果因为某些原因公司禁止使用MySQL，必须切换到PostgreSQL，这时我们还是得修改业务模块进行数据库的切换适配。因此，在这种场景下，方案二也违反了OCP。 方案三：把业务模块设计为依赖关系型数据库。到了这个方案，我们基本消除了关系型数据库切换的影响，可以随时在MySQL、PostgreSQL、Oracle等关系型数据库上进行切换，而无须修改业务模块。但是，熟悉业务的你预测未来随着用户量的迅速上涨，关系型数据库很有可能无法满足高并发写的业务场景，于是就有了下面的最终方案。 方案四：把业务模块设计为依赖数据库。这样，不管以后使用MySQL还是PostgreSQL，关系型数据库还是非关系型数据库，业务模块都不需要再改动。到这里，我们基本可以认为业务模块是稳定的，不会受到底层数据库变化带来的影响，满足了OCP。  我们可以发现，上述方案的演进过程，就是我们不断对业务依赖的数据库模块进行抽象的过程，最终设计出稳定的、服务OCP的软件。\n那么，在编程语言中，我们用什么来表示“数据库”这一抽象呢？是接口！\n数据库最常见的几个操作就是CRUD，因此我们可以设计这么一个Db接口来表示“数据库”：\npublic interface Db { Record query(String tableName, Condition cond); void insert(String tableName, Record record); void update(String tableName, Record record); void delete(String tableName, Record record); } 这样，业务模块和数据库模块之间的依赖关系就变成如下图所示：\n满足OCP的另一个关键点就是分离变化，只有先把变化点识别分离出来，我们才能对它进行抽象化。下面以我们的分布式应用系统demo为例，解释如何实现变化点的分离和抽象。\n在demo中，监控系统主要负责对服务的access log进行ETL操作，也即涉及如下3个操作：1）从消息队列中获取日志数据；2）对数据进行加工；3）将加工后的数据存储在数据库上。\n我们把整一个日志数据的处理流程称为pipeline，那么我们可以这么实现：\npublic class Pipeline implements Plugin { private Mq mq; private Db db; ... public void run() { while (!isClose.get()) { // 1、从消息队列中获取数据  Message msg = mq.consume(\u0026#34;monitor.topic\u0026#34;); String accessLog = msg.payload(); // 2、对数据进行清理操作，转换为json字符串对格式  ObjectNode logJson = new ObjectNode(JsonNodeFactory.instance); logJson.put(\u0026#34;content\u0026#34;, accessLog); String data = logJson.asText(); // 3、存储到数据库上  db.insert(\u0026#34;logs_table\u0026#34;, logId, data); } } ... } 现在考虑新上线一个服务，但是这个服务不支持对接消息队列了，只支持socket传输数据，于是我们得在Pipeline上新增一个InputType来判断是否适用socket输入源：\npublic class Pipeline implements Plugin { ... public void run() { while (!isClose.get()) { String accessLog; // 使用消息队列为消息来源  if (inputType == InputType.MQ) { Message msg = mq.consume(\u0026#34;monitor.topic\u0026#34;); accessLog = msg.payload(); } else { // 使用socket为消息来源  Packet packet = socket.receive(); accessLog = packet.payload().toString(); } ... } } } 过一段时间，有需求需要给access log打上一个时间戳，方便后续的日志分析，于是我们需要修改Pipeline的数据加工逻辑：\npublic class Pipeline implements Plugin { ... public void run() { while (!isClose.get()) { ... // 对数据进行清理操作，转换为json字符串对格式  ObjectNode logJson = new ObjectNode(JsonNodeFactory.instance); logJson.put(\u0026#34;content\u0026#34;, accessLog); // 新增一个时间戳字段  logJson.put(\u0026#34;timestamp\u0026#34;, Instant.now().getEpochSecond()); String data = logJson.asText(); ... } } } 很快，又有一个需求，需要将加工后的数据存储到ES上，方便后续的日志检索，于是我们再次修改了Pipeline的数据存储逻辑：\npublic class Pipeline implements Plugin { ... public void run() { while (!isClose.get()) { ... // 存储到ES上  if (outputType == OutputType.DB) { db.insert(\u0026#34;logs_table\u0026#34;, logId, data); } else { // 存储到ES上  es.store(logId, data) } } } } 在上述的pipeline例子中，每次新增需求都需要修改Pipeline模块，明显违反了OCP。下面，我们来对它进行优化，使它满足OCP。\n第一步是分离变化点，根据pipeline的业务处理逻辑，我们可以发现3个独立的变化点，数据的获取、加工和存储。第二步，我们对这3个变化点进行抽象，设计出以下3个抽象接口：\n// demo/src/main/java/com/yrunz/designpattern/monitor/input/InputPlugin.java // 数据获取抽象接口 public interface InputPlugin extends Plugin { Event input(); void setContext(Config.Context context); } // demo/src/main/java/com/yrunz/designpattern/monitor/filter/FilterPlugin.java // 数据加工抽象接口 public interface FilterPlugin extends Plugin { Event filter(Event event); } // demo/src/main/java/com/yrunz/designpattern/monitor/output/OutputPlugin.java // 数据存储抽象接口 public interface OutputPlugin extends Plugin { void output(Event event); void setContext(Config.Context context); } 最后，Pipeline的实现如下，只依赖于InputPlugin、FilterPlugin和OutputPlugin三个抽象接口。后续再有需求变更，只需扩展对应的接口即可，Pipeline无须再变更：\n// demo/src/main/java/com/yrunz/designpattern/monitor/pipeline/Pipeline.java // ETL流程定义 public class Pipeline implements Plugin { final InputPlugin input; final FilterPlugin filter; final OutputPlugin output; final AtomicBoolean isClose; public Pipeline(InputPlugin input, FilterPlugin filter, OutputPlugin output) { this.input = input; this.filter = filter; this.output = output; this.isClose = new AtomicBoolean(false); } // 运行pipeline  public void run() { while (!isClose.get()) { Event event = input.input(); event = filter.filter(event); output.output(event); } } ... } OCP是软件设计的终极目标，我们都希望能设计出可以新增功能却不用动老代码的软件。但是100%的对修改封闭肯定是做不到的，另外，遵循OCP的代价也是巨大的。它需要软件设计人员能够根据具体的业务场景识别出那些最有可能变化的点，然后分离出去，抽象成稳定的接口。这要求设计人员必须具备丰富的实战经验，以及非常熟悉该领域的业务场景。否则，盲目地分离变化点、过度地抽象，都会导致软件系统变得更加复杂。\nLSP：里氏替换原则 上一节介绍中，OCP的一个关键点就是抽象，而如何判断一个抽象是否合理，这是里氏替换原则（The Liskov Substitution Principle，LSP）需要回答的问题。\nLSP的最初定义如下：\n If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.\n 简单地讲就是，子类型必须能够替换掉它们的基类型，也即基类中的所有性质，在子类中仍能成立。一个简单的例子：假设有一个函数f，它的入参类型是基类B。同时，基类B有一个派生类D，如果把D的实例传递给函数f，那么函数f的行为功能应该是不变的。\n由此可以看出，违反LSP的后果很严重，会导致程序出现不在预期之内的行为错误。下面，我们看一个经典反面例子，矩形与正方形。\n假设现在有矩形Rectangle，可以通过setWidth方法设置宽度，setLength方法设置长度，area方法得到矩形面积：\n// 矩形定义 public class Rectangle { private int width; // 宽度  private int length; // 长度  // 设置宽度  public void setWidth(int width) { this.width = width; } // 设置长度  public void setLength(int length) { this.length = length; } // 返回矩形面积  public int area() { return width * length; } } 另外，有一个客户端程序Cient，它的方法f以Rectangle作为入参，逻辑为校验矩形的逻辑：\n// 客户端程序 public class Client { // 校验矩形面积为长*宽  public void f(Rectangle rectangle) { rectangle.setWidth(5); rectangle.setLength(4); if (rectangle.area() != 20) { throw new RuntimeException(\u0026#34;rectangle\u0026#39;s area is invalid\u0026#34;); } System.out.println(\u0026#34;rectangle\u0026#39;s area is valid\u0026#34;); } } // 运行程序 public static void main(String[] args) { Rectangle rectangle = new Rectangle(); Client client = new Client(); client.f(rectangle); } // 运行结果： // rectangle\u0026#39;s area is valid 现在，我们打算新增一种新的类型，正方形Square。因为从数学上看，正方形也是矩形的一种，因此我们让Square继承了Rectangle。另外，正方形要求长宽一致，因此Square重写了setWidth和setLength方法：\n// 正方形，长宽相等 public class Square extends Rectangle { // 设置宽度  public void setWidth(int width) { this.width = width; // 长宽相等，因此同时设置长度  this.length = width; } // 设置长度  public void setLength(int length) { this.length = length; // 长宽相等，因此同时设置长度  this.width = length; } } 下面，我们把Square实例化后作为入参传入Cient.f上：\npublic static void main(String[] args) { Square square = new Square(); Client client = new Client(); client.f(square); } // 运行结果: // Exception in thread \u0026#34;main\u0026#34; java.lang.RuntimeException: rectangle\u0026#39;s area is invalid // at com.yrunz.designpattern.service.mediator.Client.f(Client.java:8) // at com.yrunz.designpattern.service.mediator.Client.main(Client.java:16) 我们发现Cient.f的行为发生了变化，子类型Square并不能替代基类型Rectangle，违反了LSP。\n出现上面的这种违反LSP的设计，主要原因还是我们孤立地进行模型设计，没有从客户端程序的角度来审视该设计是否正确。我们孤立地认为在数学上成立的关系（正方形 IS-A 矩形），在程序中也一定成立，而忽略了客户端程序的使用方法（先设置宽度为5，长度为4，然后校验面积为20）。\n这个例子告诉我们：一个模型的正确性或有效性，只能通过客户端程序来体现。\n下面，我们总结一下在继承体系（IS-A）下，要想设计出符合LSP的模型所需要遵循的一些约束：\n 基类应该设计为一个抽象类（不能直接实例化，只能被继承）。 子类应该实现基类的抽象接口，而不是重写基类已经实现的具体方法。 子类可以新增功能，但不能改变基类的功能。 子类不能新增约束，包括抛出基类没有声明的异常。  前面的矩形和正方形的例子中，几乎把这些约束都打破了，从而导致了程序的异常行为：1）Square的基类Rectangle不是一个抽象类，打破约束1；2）Square重写了基类的setWidth和setLength方法，打破约束2；3）Square新增了Rectangle没有的约束，长宽相等，打破约束4。\n除了继承之外，另一个实现抽象的机制是接口。如果我们是面向接口的设计，那么上述的约束1～3其实已经满足了：1）接口本身不具备实例化能力，满足约束1；2）接口没有具体的实现方法（Java中接口的default方法比较例外，本文先不考虑），也就不会被重写，满足约束2；3）接口本身只定义了行为契约，并没有实际的功能，因此也不会被改变，满足约束3。\n因此，使用接口替代继承来实现多态和抽象，能够减少很多不经意的错误。但是面向接口设计仍然需要遵循约束4，下面我们以分布式应用系统demo为例，介绍一个比较隐晦地打破约束4，从而违反了LSP的实现。\n还是以监控系统为例，为例实现ETL流程的灵活配置，我们需要通过配置文件定义pipeline的流程功能（数据从哪获取、需要经过哪些加工、加工后存储到哪里）。当前需要支持json和yaml两种配置文件格式，以yaml配置为例，配置内容是这样的：\n# src/main/resources/pipelines/pipeline_0.yamlname:pipeline_0# pipeline名称type:single_thread# pipeline类型input:# input插件定义（数据从哪里来）name:input_0# input插件名称type:memory_mq# input插件类型context:# input插件的初始化上下文topic:access_log.topicfilter:# filter插件定义（需要经过哪些加工）- name:filter_0# 加工流程filter_0定义，类型为log_to_jsontype:log_to_json- name:filter_1# 加工流程filter_1定义，类型为add_timestamptype:add_timestamp- name:filter_2# 加工流程filter_2定义，类型为json_to_monitor_eventtype:json_to_monitor_eventoutput:# output插件定义（加工后存储到哪里）name:output_0# output插件名称type:memory_db# output插件类型context:# output插件的初始化上下文tableName:monitor_event_0首先我们定义一个Config接口来表示“配置”这一抽象:\n// demo/src/main/java/com/yrunz/designpattern/monitor/config/Config.java public interface Config { // 从json字符串中加载配置  void load(String conf); } 另外，上述配置中的input、filter、output子项，可以认为是InputPlugin、FilterPlugin、OutputPlugin插件的配置项，由Pipeline插件的配置项组合在一起，因此我们定义了如下几个Config的抽象类：\n// demo/src/main/java/com/yrunz/designpattern/monitor/config/InputConfig.java public abstract class InputConfig implements Config { protected String name; protected InputType type; protected Context ctx; // 子类实现具体加载逻辑，支持yaml和json的加载方式  @Override public abstract void load(String conf); ... } // demo/src/main/java/com/yrunz/designpattern/monitor/config/FilterConfig.java public abstract class FilterConfig implements Config { protected List\u0026lt;Item\u0026gt; items; // 子类实现具体加载逻辑，支持yaml和json的加载方式  @Override public abstract void load(String conf); ... } // demo/src/main/java/com/yrunz/designpattern/monitor/config/OutputConfig.java public abstract class OutputConfig implements Config { protected String name; protected OutputType type; protected Context ctx; // 子类实现具体加载逻辑，支持yaml和json的加载方式  @Override abstract public void load(String conf); ... } // demo/src/main/java/com/yrunz/designpattern/monitor/config/PipelineConfig.java public abstract class PipelineConfig implements Config { protected String name; protected PipelineType type; protected final InputConfig inputConfig; protected final FilterConfig filterConfig; protected final OutputConfig outputConfig; // 子类实现具体加载逻辑，支持yaml和json的加载方式  @Override public abstract void load(String conf); } 最后再实现具体的基于json和yaml的子类：\n// json方式加载Config子类目录：src/main/java/com/yrunz/designpattern/monitor/config/json public class JsonInputConfig extends InputConfig {...} public class JsonFilterConfig extends FilterConfig {...} public class JsonOutputConfig extends OutputConfig {...} public class JsonPipelineConfig extends PipelineConfig {...} // yaml方式加载Config子类目录：src/main/java/com/yrunz/designpattern/monitor/config/yaml public class YamlInputConfig extends InputConfig {...} public class YamlFilterConfig extends FilterConfig {...} public class YamlOutputConfig extends OutputConfig {...} public class YamlPipelineConfig extends PipelineConfig {...} 因为涉及到从配置到对象的实例化过程，自然会想到使用工厂模式来创建对象。另外因为Pipeline、InputPlugin、FilterPlugin和OutputPlugin都实现了Plugin接口，我们也很容易想到定义一个PluginFactory接口来表示“插件工厂”这一抽象，具体的插件工厂再实现该接口：\n// 插件工厂接口，根据配置实例化插件 public interface PluginFactory { Plugin create(Config config); } // input插件工厂 public class InputPluginFactory implements PluginFactory { ... @Override public InputPlugin create(Config config) { InputConfig conf = (InputConfig) config; try { Class\u0026lt;?\u0026gt; inputClass = Class.forName(conf.type().classPath()); InputPlugin input = (InputPlugin) inputClass.getConstructor().newInstance(); input.setContext(conf.context()); return input; } ... } } // filter插件工厂 public class FilterPluginFactory implements PluginFactory { ... @Override public FilterPlugin create(Config config) { FilterConfig conf = (FilterConfig) config; FilterChain filterChain = FilterChain.empty(); String name = \u0026#34;\u0026#34;; try { for (FilterConfig.Item item : conf.items()) { name = item.name(); Class\u0026lt;?\u0026gt; filterClass = Class.forName(item.type().classPath()); FilterPlugin filter = (FilterPlugin) filterClass.getConstructor().newInstance(); filterChain.add(filter); } } ... } } // output插件工厂 public class OutputPluginFactory implements PluginFactory { ... @Override public OutputPlugin create(Config config) { OutputConfig conf = (OutputConfig) config; try { Class\u0026lt;?\u0026gt; outputClass = Class.forName(conf.type().classPath()); OutputPlugin output = (OutputPlugin) outputClass.getConstructor().newInstance(); output.setContext(conf.context()); return output; } ... } } // pipeline插件工厂 public class PipelineFactory implements PluginFactory { ... @Override public Pipeline create(Config config) { PipelineConfig conf = (PipelineConfig) config; InputPlugin input = InputPluginFactory.newInstance().create(conf.input()); FilterPlugin filter = FilterPluginFactory.newInstance().create(conf.filter()); OutputPlugin output = OutputPluginFactory.newInstance().create(conf.output()); ... } } 最后，通过PipelineFactory来实创建Pipline对象：\nConfig config = YamlPipelineConfig.of(YamlInputConfig.empty(), YamlFilterConfig.empty(), YamlOutputConfig.empty()); config.load(Files.readAllBytes(\u0026#34;pipeline_0.yaml\u0026#34;)); Pipeline pipeline = PipelineFactory.newInstance().create(config); assertNotNull(pipeline); // 运行结果： Pass 到目前为止，上述的设计看起来是合理的，运行也没有问题。\n但是，细心的读者可能会发现，每个插件工厂子类的create方法的第一行代码都是一个转型语句，比如PipelineFactory的是PipelineConfig conf = (PipelineConfig) config;。所以，上一段代码能够正常运行的前提是：传入PipelineFactory.create方法的入参必须是PipelineConfig 。如果客户端程序传入InputConfig的实例，PipelineFactory.create方法将会抛出转型失败的异常。\n上述这个例子就是一个违反LSP的典型场景，虽然在约定好的前提下，程序可以运行正确，但是如果有客户端不小心破坏了这个约定，就会带来程序行为异常（我们永远无法预知客户端的所有行为）。\n要纠正这个问题也很简单，就是去掉PluginFactory这一层抽象，让PipelineFactory.create等工厂方法的入参声明为具体的配置类，比如PipelineFactory可以这么实现：\n// demo/src/main/java/com/yrunz/designpattern/monitor/pipeline/PipelineFactory.java // pipeline插件工厂，不在实现PluginFactory接口 public class PipelineFactory { ... // 工厂方法入参为PipelineConfig实现类，消除转型  public Pipeline create(PipelineConfig config) { InputPlugin input = InputPluginFactory.newInstance().create(config.input()); FilterPlugin filter = FilterPluginFactory.newInstance().create(config.filter()); OutputPlugin output = OutputPluginFactory.newInstance().create(config.output()); ... } } 从上述几个例子中，我们可以看出遵循LSP的重要性，而设计出符合LSP的软件的要点就是，根据该软件的使用者行为作出的合理假设，以此来审视它是否具备有效性和正确性。\nISP：接口隔离原则 接口隔离原则（The Interface Segregation Principle，ISP）是关于接口设计的一项原则，这里的“接口”并不单指Java或Go上使用interface声明的狭义接口，而是包含了狭义接口、抽象类、具象类等在内的广义接口。它的定义如下：\n Client should not be forced to depend on methods it does not use.\n 也即，一个模块不应该强迫客户程序依赖它们不想使用的接口，模块间的关系应该建立在最小的接口集上。\n下面，我们通过一个例子来详细介绍ISP。\n上图中，Client1、Client2、Client3都依赖了Class1，但实际上，Client1只需使用Class1.func1方法，Client2只需使用Class1.func2，Client3只需使用Class1.func3，那么这时候我们就可以说该设计违反了ISP。\n违反ISP主要会带来如下2个问题：\n 增加模块与客户端程序的依赖，比如在上述例子中，虽然Client2和Client3都没有调用func1，但是当Class1修改func1还是必须通知Client1～3，因为Class1并不知道它们是否使用了func1。 产生接口污染，假设开发Client1的程序员，在写代码时不小心把func1打成了func2，那么就会带来Client1的行为异常。也即Client1被func2给污染了。  为了解决上述2个问题，我们可以把func1、func2、func3通过接口隔离开：\n接口隔离之后，Client1只依赖了Interface1，而Interface1上只有func1一个方法，也即Client1不会受到func2和func3的污染；另外，当Class1修改func1之后，它只需通知依赖了Interface1的客户端即可，大大降低了模块间耦合。\n实现ISP的关键是将大接口拆分成小接口，而拆分的关键就是接口粒度的把握。想要拆分得好，就要求接口设计人员对业务场景非常熟悉，对接口使用的场景了如指掌。否则孤立地设计接口，很难满足ISP。\n下面，我们以分布式应用系统demo为例，来进一步介绍ISP的实现。\n一个消息队列模块通常包含生产（produce）和消费（consumer）两种行为，因此我们设计了Mq消息队列抽象接口，包含produce和consume两个方法：\n// 消息队列接口 public interface Mq { Message consume(String topic); void produce(Message message); } // demo/src/main/java/com/yrunz/designpattern/mq/MemoryMq.java // 当前提供MemoryMq内存消息队列的实现 public class MemoryMq implements Mq {...} 当前demo中使用接口的模块有2个，分别是作为消费者的MemoryMqInput和作为生产者的AccessLogSidecar：\npublic class MemoryMqInput implements InputPlugin { private String topic; private Mq mq; ... @Override public Event input() { Message message = mq.consume(topic); Map\u0026lt;String, String\u0026gt; header = new HashMap\u0026lt;\u0026gt;(); header.put(\u0026#34;topic\u0026#34;, topic); return Event.of(header, message.payload()); } ... } public class AccessLogSidecar implements Socket { private final Mq mq; private final String topic ... @Override public void send(Packet packet) { if ((packet.payload() instanceof HttpReq)) { String log = String.format(\u0026#34;[%s][SEND_REQ]send http request to %s\u0026#34;, packet.src(), packet.dest()); Message message = Message.of(topic, log); mq.produce(message); } ... } ... } 从领域模型上看，Mq接口的设计确实没有问题，它就应该包含consume和produce两个方法。但是从客户端程序的角度上看，它却违反了ISP，对MemoryMqInput来说，它只需要consume方法；对AccessLogSidecar来说，它只需要produce方法。\n一种设计方案是把Mq接口拆分成2个子接口Consumable和Producible，让MemoryMq直接实现Consumable和Producible：\n// demo/src/main/java/com/yrunz/designpattern/mq/Consumable.java // 消费者接口，从消息队列中消费数据 public interface Consumable { Message consume(String topic); } // demo/src/main/java/com/yrunz/designpattern/mq/Producible.java // 生产者接口，向消息队列生产消费数据 public interface Producible { void produce(Message message); } // 当前提供MemoryMq内存消息队列的实现 public class MemoryMq implements Consumable, Producible {...} 仔细思考一下，就会发现上面的设计不太符合消息队列的领域模型，因为Mq的这个抽象确实应该存在的。\n更好的设计应该是保留Mq抽象接口，让Mq继承自Consumable和Producible，这样的分层设计之后，既能满足ISP，又能让实现符合消息队列的领域模型：\n具体实现如下：\n// demo/src/main/java/com/yrunz/designpattern/mq/Mq.java // 消息队列接口，继承了Consumable和Producible，同时又consume和produce两种行为 public interface Mq extends Consumable, Producible {} // 当前提供MemoryMq内存消息队列的实现 public class MemoryMq implements Mq {...} // demo/src/main/java/com/yrunz/designpattern/monitor/input/MemoryMqInput.java public class MemoryMqInput implements InputPlugin { private String topic; // 消费者只依赖Consumable接口  private Consumable consumer; ... @Override public Event input() { Message message = consumer.consume(topic); Map\u0026lt;String, String\u0026gt; header = new HashMap\u0026lt;\u0026gt;(); header.put(\u0026#34;topic\u0026#34;, topic); return Event.of(header, message.payload()); } ... } // demo/src/main/java/com/yrunz/designpattern/sidecar/AccessLogSidecar.java public class AccessLogSidecar implements Socket { // 生产者只依赖Producible接口  private final Producible producer; private final String topic ... @Override public void send(Packet packet) { if ((packet.payload() instanceof HttpReq)) { String log = String.format(\u0026#34;[%s][SEND_REQ]send http request to %s\u0026#34;, packet.src(), packet.dest()); Message message = Message.of(topic, log); producer.produce(message); } ... } ... } 接口隔离可以减少模块间耦合，提升系统稳定性，但是过度地细化和拆分接口，也会导致系统的接口数量的上涨，从而产生更大的维护成本。接口的粒度需要根据具体的业务场景来定，可以参考单一职责原则，将那些为同一类客户端程序提供服务的接口合并在一起。\nDIP：依赖倒置原则 《Clean Architecture》中介绍OCP时有提过：如果要模块A免于模块B变化的影响，那么就要模块B依赖于模块A。这句话貌似是矛盾的，模块A需要使用模块B的功能，怎么会让模块B反过来依赖模块A呢？这就是依赖倒置原则（The Dependency Inversion Principle，DIP）所要解答的问题。\nDIP的定义如下：\n  High-level modules should not import anything from low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.   翻译过来，就是：\n 1、高层模块不应该依赖低层模块，两者都应该依赖抽象\n2、抽象不应该依赖细节，细节应该依赖抽象\n 在DIP的定义里，出现了高层模块、低层模块、抽象、细节等4个关键字，要弄清楚DIP的含义，理解者4个关键字至关重要。\n（1）高层模块和低层模块\n一般地，我们认为高层模块是包含了应用程序核心业务逻辑、策略的模块，是整个应用程序的灵魂所在；低层模块通常是一些基础设施，比如数据库、Web框架等，它们主要为了辅助高层模块完成业务而存在。\n（2）抽象和细节\n在前文“OCP：开闭原则”一节中，我们可以知道，抽象就是众多细节中的共同点，抽象就是不断忽略细节的出来的。\n现在再来看DIP的定义，对于第2点我们不难理解，从抽象的定义来看，抽象是不会依赖细节的，否则那就不是抽象了；而细节依赖抽象往往都是成立的。\n理解DIP的关键在于第1点，按照我们正向的思维，高层模块要借助低层模块来完成业务，这必然会导致高层模块依赖低层模块。但是在软件领域里，我们可以把这个依赖关系倒置过来，这其中的关键就是抽象。我们可以忽略掉低层模块的细节，抽象出一个稳定的接口，然后让高层模块依赖该接口，同时让低层模块实现该接口，从而实现了依赖关系的倒置：\n之所以要把高层模块和底层模块的依赖关系倒置过来，主要是因为作为核心的高层模块不应该受到低层模块变化的影响。高层模块的变化原因应当只能有一个，那就是来自软件用户的业务变更需求。\n下面，我们通过分布式应用系统demo来介绍DIP的实现。\n对于服务注册中心Registry来说，当有新的服务注册上来时，它需要把服务信息（如服务ID、服务类型等）保存下来，以便在后续的服务发现中能够返回给客户端。因此，Registry需要一个数据库来辅助它完成业务。刚好，我们的数据库模块实现了一个内存数据库MemoryDb，于是我们可以这么实现Registry：\n// 服务注册中心 public class Registry implements Service { ... // 直接依赖MemoryDb  private final MemoryDb db; private final SvcManagement svcManagement; private final SvcDiscovery svcDiscovery; private Registry(...) { ... // 初始化MemoryDb  this.db = MemoryDb.instance(); this.svcManagement = new SvcManagement(localIp, this.db, sidecarFactory); this.svcDiscovery = new SvcDiscovery(this.db); } ... } // 内存数据库 public class MemoryDb { private final Map\u0026lt;String, Table\u0026lt;?, ?\u0026gt;\u0026gt; tables; ... // 查询表记录  public \u0026lt;PrimaryKey, Record\u0026gt; Optional\u0026lt;Record\u0026gt; query(String tableName, PrimaryKey primaryKey) { Table\u0026lt;PrimaryKey, Record\u0026gt; table = (Table\u0026lt;PrimaryKey, Record\u0026gt;) tableOf(tableName); return table.query(primaryKey); } // 插入表记录  public \u0026lt;PrimaryKey, Record\u0026gt; void insert(String tableName, PrimaryKey primaryKey, Record record) { Table\u0026lt;PrimaryKey, Record\u0026gt; table = (Table\u0026lt;PrimaryKey, Record\u0026gt;) tableOf(tableName); table.insert(primaryKey, record); } // 更新表记录  public \u0026lt;PrimaryKey, Record\u0026gt; void update(String tableName, PrimaryKey primaryKey, Record record) { Table\u0026lt;PrimaryKey, Record\u0026gt; table = (Table\u0026lt;PrimaryKey, Record\u0026gt;) tableOf(tableName); table.update(primaryKey, record); } // 删除表记录  public \u0026lt;PrimaryKey\u0026gt; void delete(String tableName, PrimaryKey primaryKey) { Table\u0026lt;PrimaryKey, ?\u0026gt; table = (Table\u0026lt;PrimaryKey, ?\u0026gt;) tableOf(tableName); table.delete(primaryKey); } ... } 按照上面的设计，模块间的依赖关系是Registry依赖于MemoryDb，也即高层模块依赖于低层模块。这种依赖关系是脆弱的，如果哪天需要把存储服务信息的数据库从MemoryDb改成DiskDb，那么我们也得改Registry的代码：\n// 服务注册中心 public class Registry implements Service { ... // 改成依赖DiskDb  private final DiskDb db; ... private Registry(...) { ... // 初始化DiskDb  this.db = DiskDb.instance(); this.svcManagement = new SvcManagement(localIp, this.db, sidecarFactory); this.svcDiscovery = new SvcDiscovery(this.db); } ... } 更好的设计应该是把Registry和MemoryDb的依赖关系倒置过来，首先我们需要从细节MemoryDb抽象出一个稳定的接口Db：\n// demo/src/main/java/com/yrunz/designpattern/db/Db.java // DB抽象接口 public interface Db { \u0026lt;PrimaryKey, Record\u0026gt; Optional\u0026lt;Record\u0026gt; query(String tableName, PrimaryKey primaryKey); \u0026lt;PrimaryKey, Record\u0026gt; void insert(String tableName, PrimaryKey primaryKey, Record record); \u0026lt;PrimaryKey, Record\u0026gt; void update(String tableName, PrimaryKey primaryKey, Record record); \u0026lt;PrimaryKey\u0026gt; void delete(String tableName, PrimaryKey primaryKey); ... } 接着，我们让Registry依赖Db接口，而MemoryDb实现Db接口，以此来完成依赖倒置：\n// demo/src/main/java/com/yrunz/designpattern/service/registry/Registry.java // 服务注册中心 public class Registry implements Service { ... // 只依赖于Db抽象接口  private final Db db; private final SvcManagement svcManagement; private final SvcDiscovery svcDiscovery; private Registry(..., Db db) { ... // 依赖注入Db  this.db = db; this.svcManagement = new SvcManagement(localIp, this.db, sidecarFactory); this.svcDiscovery = new SvcDiscovery(this.db); } ... } // demo/src/main/java/com/yrunz/designpattern/db/MemoryDb.java // 内存数据库，实现Db抽象接口 public class MemoryDb implements Db { private final Map\u0026lt;String, Table\u0026lt;?, ?\u0026gt;\u0026gt; tables; ... // 查询表记录  @Override public \u0026lt;PrimaryKey, Record\u0026gt; Optional\u0026lt;Record\u0026gt; query(String tableName, PrimaryKey primaryKey) {...} // 插入表记录  @Override public \u0026lt;PrimaryKey, Record\u0026gt; void insert(String tableName, PrimaryKey primaryKey, Record record) {...} // 更新表记录  @Override public \u0026lt;PrimaryKey, Record\u0026gt; void update(String tableName, PrimaryKey primaryKey, Record record) {...} // 删除表记录  @Override public \u0026lt;PrimaryKey\u0026gt; void delete(String tableName, PrimaryKey primaryKey) {...} ... } // demo/src/main/java/com/yrunz/designpattern/Example.java public class Example { // 在main函数中完成依赖注入  public static void main(String[] args) { ... // 将MemoryDb.instance()注入到Registry上  Registry registry = Registry.of(..., MemoryDb.instance()); registry.run(); } } 当高层模块依赖抽象接口时，总得在某个时候，某个地方把实现细节（低层模块）注入到高层模块上。在上述例子中，我们选择在main函数上，在创建Registry对象时，把MemoryDb注入进去。\n一般地，我们都会在main/启动函数上完成依赖注入，常见的注入的方式有以下几种：\n 构造函数注入（Registry所使用的方法） setter方法注入 提供依赖注入的接口，客户端直调用该接口即可 通过框架进行注入，比如Spring框架中的注解注入能力  另外，DIP不仅仅适用于模块/类/接口设计，在架构层面也同样适用，比如DDD的分层架构和Uncle Bob的整洁架构，都是运用了DIP：\n当然，DIP并不是说高层模块是只能依赖抽象接口，它的本意应该是依赖稳定的接口/抽象类/具象类。如果一个具象类是稳定的，比如Java中的String，那么高层模块依赖它也没有问题；相反，如果一个抽象接口是不稳定的，经常变化，那么高层模块依赖该接口也是违反DIP的，这时候应该思考下接口是否抽象合理。\n最后 本文花了很长的篇幅讨论了23种设计模式背后的核心思想 —— SOLID原则，它能指导我们设计出高内聚、低耦合的软件系统。但是它毕竟只是原则，如何落地到实际的工程项目上，还是需要参考成功的实践经验。而这些实践经验正是接下来我们要探讨的设计模式。\n学习设计模式最好的方法就是实践，在《实践GoF的23种设计模式》后续的文章里，我们将以本文介绍的分布式应用系统demo作为实践示范，介绍23种设计模式的程序结构、适用场景、实现方法、优缺点等，让大家对设计模式有个更深入的理解，能够用对、不滥用设计模式。\n 参考\n Clean Architecture, Robert C. Martin (“Uncle Bob”) 敏捷软件开发：原则、模式与实践, Robert C. Martin (“Uncle Bob”) 使用Go实现GoF的23种设计模式, 元闰子 SOLID原则精解之里氏替换原则LSP, 人民副首席码仔  更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-02-26T00:00:00Z","permalink":"https://yrunz.com/p/java%E5%AE%9E%E7%8E%B0%E5%AE%9E%E8%B7%B5gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8Fsolid%E5%8E%9F%E5%88%99/","title":"【Java实现】实践GoF的23种设计模式：SOLID原则"},{"content":"新年将至，照例还是要对过去的2021年做个小结。在过去的一年里，读过的书不多，写过的文章也少，生活倒是有不少感悟。\n阅读                         2021年读过的书不多，从数量上看，比起往年还是少了些。一方面是懒惰的天性驱使；另一方面也是读得更慢了。以前读书很多时候是为了追求知识广度，走马观花浏览一遍，留个大致的印象便算完事。久而久之，虽然明面上读了不少书，但是知识倒是没涨多少。\n为此，自己也在不断思考如何更好地读书，于是开始尝试做笔记，在读书过程中把关键知识和自己的思考记录下来。然后发现，读书笔记不仅仅有助于加深对知识的理解，而且为文章的写作提供了很好的素材。于是就逐渐养成了读书过程中做笔记的习惯，读书的速度也就慢下来了。\n 《Fundamentals of Software Architecture》：被称为是架构师的入门指南，重点介绍了当今常见的8种架构模式。每种架构模式都有其优缺点以及适用场景，并不存在所谓的“银弹”。正如作者书中提到，Everything in software architecture is a trade-off。 《Microservices vs Service-Oriented Architecture》：可作为《Fundamentals of Software Architecture》的延伸阅读材料，重点对比了微服务架构和基于服务的架构之间的优缺点，有助于加深对两种架构的异同点的理解。 《Operating Systems: Three Easy Pieces》：读过的写得最好的操作系统书籍，作者把复杂的基本原理介绍得通俗易懂，又不乏深度。整本书条理清晰，非常值得一读，中文版被翻译成《操作系统导论》。 《软件架构设计：大型网站技术架构与业务架构融合之道》：同样是以架构设计为主题，相比于《Fundamentals of Software Architecture》，这本更“接地气”，不仅介绍了高并发、数据一致性等常用问题的解决方法，还介绍了诸如操作系统、数据库、网络等基础知识。 《凤凰架构》：周志明老师的又一力作，从架构的视角介绍了如何构建大型分布式系统，全书的话题涉猎很广，基本把分布式系统所能涉及的知识点都讲解了，比如远程服务调用、事务、安全、一致性等。书中罗列了很多参考阅读材料，可作为进阶架构师的一本导航书，如果把其中提到的知识点都研究透，那么可以就成为专家了。 《The Programmer\u0026rsquo;s Brain》：从认知科学的角度教你如何更好地阅读代码、理解代码、编写代码，对于程序员新手来说值得一读。书中提到的很多学习程序的方法，感觉就是将有经验的程序员的一些学习方法，结合认知科学的理论更好地呈现了出来。 《吴军阅读与写作讲义》：某天在一家书店上花了一下午的时间浏览完这本书的上半部分——“如何阅读和写作”，书中讨论的正是理工科学生容易忽视的阅读、写作、表达等软能力。 《邓小平时代》：读完本书，一方面被邓小平的坚韧、格局、领导力所屈服，另一方面也看到了我党一路走来也是在不断地犯错-反思-改正-进步。恰逢2021年是我党建党100周年，这一年里也看了很多关于党史的影视节目，对我党历史也有了更深刻的了解。从觉醒年代，到北伐战争，然后长征、抗日、大决战、抗美援朝、文革、改革开放，一路走来，我党在黑暗中不断摸索前行，磕磕碰碰，逐渐找到一条通往光明的道路。 《刘擎西方现代思想讲义》：通过介绍十多位西方思想家的思想结晶，将西方现代思想从启蒙逐渐走向成熟的过程呈现给读者。随着物质生活的满足，现代人也慢慢开始寻找人生的意义，“我是谁”，“我在做什么”，“我为什么这么做“，这些问题，那些伟大的思想家都有想过，读完这本书，也许会找到一些答案。   前些天有幸听了一场“哲学王子”王德峰的讲座，感触很大。中国的哲学（主要是孔子的儒家思想）和西方的哲学（主要是柏拉图的理念论）差别还是蛮大的，这也决定了西方和中国发展道路的不同。西方强调改变世界，中国则是强调天人合一。虽然目前结果来看，西方会更成功一些，但是天人合一的可持续发展也许更有潜力。\n  《人体简史》：印象最深的是书中最后关于衰老和死亡的描述，对于我们大多数人来说，死亡是能想象出的最可怕的事情，但是对于宇宙来说，人的死亡只不过又是物质的一次循环罢了。 “一具尸体仍然生机勃勃，只不过，它不再是你的生命。它属于你留下的细菌，以及其他蜂拥而至的细菌”。 《认知觉醒》：早冥读写跑，人生五件套；做每一件事情都要专注事情本身；坚持每日反思，每日计划。作者所提倡的这些虽然很难全部做到，但是至少在心里留下了个烙印，也在不断尝试在这个快节奏的生活里，学着慢下来专注和思考。 《巨人的陨落》、《世界的凛冬》、《永恒的边缘》：肯福莱特先生的三部史诗巨作，以小人物的视角讲述从一战到冷战的西方世界历史发展之路。在历史的滚滚车轮里，每一个迈向死亡的生命都在热烈地生长。这是对有声书的首次尝试，惠天和言亮老师（非星凡文化@喜马拉雅）的双人播讲体验极好，是通勤路上的必备小伴侣。除了小说剧情之外，两位老师以知识卡片的形式延伸了很多历史知识和人性感悟，非常值得一听。  写作 2021年只写了10篇文章，产出还是一如既往的少。而且一些打算写成系列的文章写着写着都烂尾断更了（2020年的《使用Go实现GoF的23种设计模式》系列，2021年的《从分层架构到微服务架构》系列），许多朋友催更也无果。倒不是因为偷懒，而是那份兴致很难再提起了，毕竟人总是喜新厌旧的😄。这也促使我后面宁愿写长文，也轻易不会再写系列短文。\n 《如何高效编写Go单元测试（一）》、《如何高效编写Go单元测试（二）》：少有写完的系列文章。虽然Go官方已经提供了单元测试能力（test标准库），但是对用惯了Java成熟的单元测试框架的我来说，原生的go test的功能还是很单薄。于是就查找了一些资料，把当前Go语言下比较流行的单元测试框架的用法总结成了这两篇文章。 《从分层架构到微服务架构（一）》、《从分层架构到微服务架构（二）之分层架构》、《从分层架构到微服务架构（三）之管道架构》、《从分层架构到微服务架构（四）之微内核架构》：第一烂尾系列，本来打算写九篇的，结果写到第四篇就停止了。这几篇文章都是对《Fundamentals of Software Architecture》这本书的总结笔记，主要介绍8种常见的软件架构模式，对比各种架构模式的优缺点，帮助大家更好地做架构选型。感兴趣的建议直接阅读原书，后面的更新也会尽量补上。 《教你写好技术文章》：从2019年底开始写技术文章，到如今已经两年多了，这期间深感技术写作给自己带来的好处。在五一假期心血来潮，就把这两年来一些技术写作的感悟、方法记录下来。写好文章的方法很多，但最简单，也是最有效的方法还是：不要停止写作。 《《认知觉醒》的读后感》：读完《认知觉醒》这本书后，感触颇大，于是就有了这篇读后感。《认知觉醒》是一本关于如何学习成长的书籍，书中介绍的很多高效成长方法都很实用，虽然很难坚持便是了。 《探索CPU的调度原理》：这篇是《Operating Systems: Three Easy Pieces》一书中关于CPU调度原理的关键知识点总结。个人感觉这是全书写得最好的一个篇章，读完之后有种豁然开朗的感觉。作者从最简单的FIFO调度说起，到最后的CFS完全公平调度，层层递进，阅读体验极好，知识密度也很大。 《实现DCI架构》：这篇文章来源于今年工作中的代码重构实践。在某些场景下，使用DDD中的战术建模设计出来的代码会存在“上帝类”、可维护性差等问题，主要原因还是DDD和传统的面向对象编程在对行为的建模上有所欠缺，这也导致了贫血模型和充血模型之争。于是，通过查阅资料找到了DCI建模方法，它可以看成是DDD战术建模的一种辅助。将DCI架构总结成一句话就是：领域对象（Object）在不同的场景（Context）中扮演（Cast）不同的角色（Role），角色之间通过交互（Interactive）来完成具体的业务逻辑。 《探索OS的内存管理原理》：同样也是《Operating Systems: Three Easy Pieces》的读书笔记，是关于内存管理篇章的总结提炼，介绍了独占式内存管理、段式内存管理、页式内存管理、TLB等基本原理，理解这些原理，对于写出高效程序有很大的好处。  生活 旅行 2021年只有国庆期间去了一趟云南旅行，玉龙雪山、涑河古镇、泸沽湖、丽江古城、虎跳峡、松赞林寺、普达措森林公园，6天的时间不长，但该去的景点基本也都去了，度过了开心的几天。2015年冬天也去过一次云南，也是6天时间，走的是大理-香格里拉路线。游玩的景点不多，因为当时真的是穷游：住在刚下过暴雪但没有热水的青旅、到了雪山下却没钱坐缆车上去、没钱打车只能步行6公里\u0026hellip;..对比今年的这次旅行，只能说预算充足真的好，2022年还要继续加油赚钱！\n徒步 2021年徒步了4次，都是在深圳，有艰难的东西冲穿越、登顶大雁岭，也有养生的爬南山、走梧桐绿道。最近几年基本每年都会去徒步几次，深感徒步是一件痛并快乐着的活动，特别是攀山越岭，攀登的过程是痛苦的，但是登顶的瞬间又会豁然开朗。除了看到了绝美的景色和呼吸到新鲜的空气外，更是因为成功的喜悦：在自己征服过的山的名单里，又多了一座山。\n电影 2021年完整看完的电影比较少，更多的是通过观看B站UP主@木鱼水心和@电影最TOP的影视解说栏目来速览剧情，在2位UP主的熏陶下，感觉自己的电影鉴赏水平也提升了不少😄。电影也是一门艺术，好的电影确实能够引人深思，但也需要一定的电影鉴赏能力和背景知识才能理解导演的用意，就像阅读理解能力之于阅读。\n生命 在过去的一年里，有亲人在医生断言机会渺茫的时候挺了过来，也有好友没能抵住病魔在花样的年纪离我们而去。人的生命是何等的脆弱，健康与疾病，生与死往往都只在一瞬之间。现在能做的，也就是好好珍惜当下，早点养生，健康生活。\n最后，如果新年有什么期望的话，那就是大家身体健康，凡事多加思考。\n","date":"2022-01-31T00:00:00Z","permalink":"https://yrunz.com/p/2021%E5%B9%B4%E5%B0%8F%E7%BB%93/","title":"2021年小结"},{"content":"前言 内存作为计算机系统的组成部分，跟开发人员的日常开发活动有着密切的联系，我们平时遇到的Segment Fault、OutOfMemory、Memory Leak、GC等都与它有关。本文所说的内存，指的是计算机系统中的主存（Main Memory），它位于存储金字塔中CPU缓存和磁盘之间，是程序运行不可或缺的一部分。\n在计算机系统中，主存通常都是由操作系统（OS）来管理，而内存管理的细则对开发者来说是无感的。对于一个普通的开发者，他只需懂得如何调用编程语言的接口来进行内存申请和释放，即可写出一个可用的应用程序。如果你使用的是带有垃圾回收机制的语言，如Java和Go，甚至都不用主动释放内存。但如果你想写出高效应用程序，熟悉OS的内存管理原理就变得很有必要了。\n下面，我们将从最简单的内存管理原理说起，带大家一起窥探OS的内存管理机制，由此熟悉底层的内存管理机制，写出高效的应用程序。\n独占式内存管理 早期的单任务系统中，同一时刻只能有一个应用程序独享所有的内存（除开OS占用的内存），因此，内存管理可以很简单，只需在内存上划分两个区域：\n在多任务系统中，计算机系统已经可以做到多个任务并发运行。如果还是按照独占式的管理方式，那么每次任务切换时，都会涉及多次内存和磁盘之间的数据拷贝，效率极其低下：\n最直观的解决方法就是让所有程序的数据都常驻在内存中（假设内存足够大），这样就能避免数据拷贝了：\n但这样又会带来另一个问题，程序之间的内存地址空间是没有隔离的，也就是程序A可以修改程序B的内存数据。这样的一个重大的安全问题是用户所无法接受的，要解决该问题，就要借助虚拟化的力量了。\n虚拟地址空间 为了实现程序间内存的隔离，OS对物理内存做了一层虚拟化。OS为每个程序都虚拟化出一段内存空间，这段虚拟内存空间会映射到一段物理内存上。但对程序自身而言，它只能看到自己的虚拟地址空间，也就有独占整个内存的错觉了。\n上图中，虚拟内存空间分成了三个区域，其中Code区域存储的是程序代码的机器指令；Heap区域存储程序运行过程中动态申请的内存；Stack区域则是存储函数入参、局部变量、返回值等。Heap和Stack会在程序运行过程中不断增长，分别放置在虚拟内存空间的上方和下方，并往相反方向增长。\n从虚拟地址空间到物理地址空间的映射，需要一个转换的过程，完成这个转换运算的部件就是MMU（memory management unit），也即内存管理单元，它位于CPU芯片之内。\n要完成从虚拟地址到物理地址到转换，MMU需要base和bound两个寄存器。其中base寄存器用来存储程序在物理内存上的基地址，比如在图5中，程序A的基地址就是192KB；bound寄存器（有时候也叫limit寄存器）则保存虚拟地址空间的Size，主要用来避免越界访问，比如图5中程序A的size值为64K。那么，基于这种方式的地址转换公式是这样的：\n 物理地址 = 虚拟地址 + 基地址\n 以图5中程序A的地址转换为例，当程序A尝试访问超过其bound范围的地址时，物理地址会转换失败：\n现在，我们再次仔细看下程序A的物理内存分布，如下图7所示，中间有很大的一段空闲内存是“已申请，未使用”的空闲状态。这也意味着即使这部分是空闲的，也无法再次分配给其他程序使用，这是一种巨大的空间浪费！为了解决这个内存利用率低下的问题，我们熟悉的段式内存管理出现了。\n段式内存管理 在上一节中，我们发现如果以程序为单位去做内存管理，会存在内存利用率不足的问题。为了解决该问题，段式内存管理被引入。段（Segment）是逻辑上的概念，本质上是一块连续的、有一定大小限制的内存区域，前文中，我们一共提到过3个段：Code、Heap和Stack。\n段式内存管理以段为单位进行管理，它允许OS将每个段灵活地放置在物理内存的空闲位置上，因此也避免了“已申请，未使用”的内存区域出现：\n地址转换 从上图8可知，段式内存管理中程序的物理内存空间可能不再连续了，因此为了实现从虚拟地址到物理地址到转换，MMU需要为每个段都提供一对base-bound寄存器，比如：\n给一个虚拟地址，MMU是如何知道该地址属于哪个段，从而根据对应的base-bound寄存器转换为对应的物理地址呢？\n假设虚拟地址有16位，因为只有3个段，因此，我们可以使用虚拟地址的高2位来作为段标识，比如00表示Code段，01表示Heap段，11表示Stack段。这样MMU就能根据虚拟地址来找到对应段的base-bound寄存器了：\n但这样还不是能够顺利的将虚拟地址转换为物理地址，我们忽略了重要的一点：Heap段和Stack段的增长方向是相反的，这也意味着两者的地址转换方式是不一样的。因此，我们还必须在虚拟地址中多使用一位来标识段的增长方向，比如0表示向上（低地址方向）增长，1表示向下（高地址方向）增长：\n下面，看一组段式内存管理地址转换的例子：\n那么，总结段式内存管理的地址转换算法如下：\n// 获取当前虚拟地址属于哪个段 Segment = (VirtualAddress \u0026amp; SEG_MASK) \u0026gt;\u0026gt; SEG_SHIFT // 得到段内偏移量 Offset = VirtualAddress \u0026amp; OFFSET_MASK // 获得内存增长的方向 GrowsDirection = VirtualAddress \u0026amp; GROWS_DIRECTION_MASK // 有效性校验 if (Offset \u0026gt;= Bounds[Segment]) RaiseException(PROTECTION_FAULT) else if (GrowsDirection == 0) { PhysAddr = Base[Segment] + Offset } else { PhysAddr = Base[Segment] - Offset } 内存共享和保护 段式内存管理还可以很方便地支持内存共享，从而达到节省内存的目的。比如，如果存在多个程序都是同一个可执行文件运行起来的，那么这些程序是可以共享Code段的。为了实现这个功能，我们可以在虚拟地址上设置保护位，当保护位为只读时，表示该段可以共享。另外，如果程序修改了只读的段，则转换地址失败，因此也可以达到内存保护的目的。\n内存碎片 段式内存管理的最明显的缺点就是容易产生内存碎片，这是因为在系统上运行的程序的各个段的大小往往都不是固定的，而且段的分布也不是连续的。当系统的内存碎片很多时，内存的利用率也会急剧下降，对外表现就是虽然系统看起来还有很多内存，却无法再运行起一个程序。\n解决内存碎片的方法之一是定时进行碎片整理：\n但是碎片整理的代价极大，一方面需要进行多次内存拷贝；另一方面，在拷贝过程中，正在运行的程序必须停止，这对于一些以人机交互任务为主的应用程序，将会极大影响用户体验。\n另一个解决方法就是接下来要介绍的页式内存管理。\n页式内存管理 页式内存管理的思路，是将虚拟内存和物理内存都划分为多个固定大小的区域，这些区域我们称之为页（Page）。页是内存的最小分配单位，一个应用程序的虚拟页可以存放在任意一个空闲的物理页中。\n 物理内存中的页，我们通常称之为页帧（Page Frame）\n 因为页的大小是固定的，而且作为最小的分配单位，这样就可以解决段式内存管理中内存碎片的问题了。\n 但页内仍然有可能存在内存碎片。\n 地址转换 页式内存管理使用页表（Page Table）来进行虚拟地址到物理地址到转换，地址转换的关键步骤如下：\n1）根据虚拟页找到对应的物理页帧\n每个虚拟页都有一个编号，叫做VPN（Virtual Page Number）；相应的，每个物理页帧也有一个编号，叫做PFN（Physical Frame Number）。页表存储的就是VPN到PFN的映射关系。\n2）找到地址在物理页帧内的偏移（Offset）\n地址在物理页帧内的偏移与在虚拟页内的偏移保持一致。\n我们可以将虚拟地址划分成两部分，分别存储VPN和Offset，这样就能通过VPN找到PFN，从而得到PFN+Offset的实际物理地址了。\n比如，假设虚拟内存空间大小为64Byte（6位地址），页的大小为16Byte，那么整个虚拟内存空间一共有4个页。因此我们可以使用高2位来存储VPN，低4位存储Offset：\n下面看一个转换例子，VPN（01）通过页表找到对应的PFN（111），虚拟地址和物理地址的页内偏移都是0100，那么虚拟地址010100对应的物理地址就是1110100了。\n页表和页表项 OS为每个程序都分配了一个页表，存储在内存当中，页表里由多个页表项（PTE，Page Table Entry）组成。我们可以把页表看成是一个数组，数组的元素为PTE：\n以x86系统下的PTE组成为例，PTE一共占32位，除了PFN之外，还有一些比较重要的信息，比如P（Present）标识当前页是否位于内存上（或是磁盘上）；R/W（Read/Write）标识当前页是否允许读写（或是只读）；U/S（User/Supervisor）标识当前页是否允许用户态访问；A（Access）标识当前页是否被访问过，在判断当前页是否为热点数据、页换出时特别有用；D（Dirty）标识当前页是否被修改过。\n页式内存管理的缺点 地址转换效率低 根据前文介绍，我们可以总结页式内存管理机制下地址转换的算法如下：\n// 从虚拟地址上得到VPN VPN = (VirtualAddress \u0026amp; VPN_MASK) \u0026gt;\u0026gt; SHIFT // 找到VPN对应的PTE的内存地址 PTEAddr = PTBR + (VPN * sizeof(PTE)) // 访问主存，获取PTE PTE = AccessMemory(PTEAddr) // 有效性校验 if (PTE.Valid == False) RaiseException(INVALID_ACCESS) else // 获取页内偏移量  offset = VirtualAddress \u0026amp; OFFSET_MASK // 计算得出物理地址  PhysAddr = (PTE.PFN \u0026lt;\u0026lt; PFN_SHIFT) | offset 我们发现，每次地址转换都会访问一次主存来获取页表，比段式内存管理（无主存访问）低效很多。\n占用空间大 假设地址空间为32-bit，页的大小固定为4KB，那么整个地址空间一共有$2^{32}/4KB=2^{20}$个页表，也即页表一共有$2^{20}$个PTE。现假设每个PTE大小为4-byte，那么每个页表占用4MB的内存。如果整个系统中有100个程序在运行，那么光是页表就占用了400MB的内存，这同样是用户无法接受的。\n接下来，我们将介绍如何去优化页式内存管理的这两个显著缺点。\n让页式管理的地址转换更快 TLB：Translation-Lookaside Buffer 根据前文所述，页式内存管理地址转换因为多了一次主存访问，导致效率很低。如果能够避免或者减少对主存的访问，那么就能让地址转换更快了。\n很多人应该都可以想到通过增加缓存的方式提升效率，比如为避免频繁查询磁盘，我们一般在内存中增加一层缓存来提升数据访问效率。那么为了提升访问主存中数据的效率，自然应该在离CPU更近的地方增加一层缓存。这个离CPU更近的地方，就是前文提到的位于CPU芯片之内的MMU。而这个高速缓存，就是TLB（Translation-Lookaside Buffer），它缓存了VPN到PFN到映射关系，类似于这样：\n增加TLB之后，地址转换的算法如下：\nVPN = (VirtualAddress \u0026amp; VPN_MASK) \u0026gt;\u0026gt; SHIFT (Success, TlbEntry) = TLB_Lookup(VPN) if (Success == True) // TLB Hit  if (CanAccess(TlbEntry.ProtectBits) == True) Offset = VirtualAddress \u0026amp; OFFSET_MASK PhysAddr = (TlbEntry.PFN \u0026lt;\u0026lt; SHIFT) | Offset else RaiseException(PROTECTION_FAULT) else // TLB Miss  PTEAddr = PTBR + (VPN * sizeof(PTE)) PTE = AccessMemory(PTEAddr) if (PTE.Valid == False) RaiseException(SEGMENTATION_FAULT) else if (CanAccess(PTE.ProtectBits) == False) RaiseException(PROTECTION_FAULT) else TLB_Insert(VPN, PTE.PFN, PTE.ProtectBits) RetryInstruction() 从上述算法可以发现，在TLB缓存命中（TLB Hit）时，能够避免直接访问主存，从而提升了地址转换的效率；但是在TLB缓存不命中（TLB Miss）时，仍然需要访问一次主存，而且还要往TLB中插入从主存中查询到的PFN，效率变得更低了。因此，我们必须尽量避免TLB Miss的出现。\n更好地利用TLB 下面，我们通过一个数组遍历的例子来介绍如何更好地利用TLB。\n假设我们要进行如下的一次数组遍历：\nint sum = 0; for (i = 0; i \u0026lt; 10; i++) { sum += a[i]; } 数组的内存的分布如下：\na[0]~a[2]位于Page 5上，a[3]~a[6]位于Page 6上，a[7]~a[8]位于Page 7上。当我们首先访问a[0]时，由于Page 5并未在TLB缓存里，所以会先出现一次TLB Miss，接下来的a[1]和a[2]都是TLB Hit；同理，访问a[3]和a[7]时都是TLB Miss，a[4]~a[6]和a[8]~a[9]都是TLB Hit。所以，整个数组遍历下来，TLB的缓存命中情况为：Miss，Hit，Hit，Miss，Hit，Hit，Hit，Miss，Hit，Hit，TLB缓存命中率为70%。我们发现，访问同一页上的数据TLB的缓存更易命中，这就是空间局部性的原理。\n接下来，我们再次重新遍历一次数组，由于经过上一次之后Page 5 ～ Page 7的转换信息已经在TLB缓存里里，所以第二次遍历的TLB命中情况为：Hit，Hit，Hit，Hit，Hit，Hit，Hit，Hit，Hit，Hit，TLB缓存命中率为100%！这就是时间局部性的原理，短时间内访问同一内存数据也能够提升TLB缓存命中率。\nTLB的上下文切换 因为TLB缓存的是当前正在运行程序的上下文信息，当出现程序切换时，TLB里面的上下文信息也必须更新，否则地址转换就会异常。解决方法主要有2种：\n 方法1：每次程序切换都清空TLB缓存（Flush TLB），让程序在运行过程中重新建立缓存。 方法2：允许TLB缓存多个程序的上下文信息，并通过ASID（address space identifier，地址空间标识符，可以理解为程序的PID）做区分。  方法1实现简单，但是每次程序切换都需要重新预热一遍缓存，效率较低，主流的做法是采用方法2。\n 需要注意的是TLB是嵌入到CPU芯片之内的，对于多核系统而言，如果程序在CPU之间来回切换，也是需要重新建立TLB缓存！因此，把一个程序绑定在一个固定的核上有助于提升性能。\n 让页表更小 大页内存 降低页表大小最简单的方法就是让页更大。前文的例子中，地址空间为32-bit，页的大小为4KB，PTE的大小为4-byte，那么每个页表需要4MB的内存空间。现在，我们把页的大小增加到16KB，其他保持不变，那么每个页表只需要$2^{32}/16KB=2^{18} $个PTE，也即1MB内存，内存占用降低了4倍。\n大页内存对TLB的使用也有优化效果，因TLB能够缓存的上下文数量是固定的，当页的数量更少时，上下文换出的频率会降低，TLB的缓存命中率也就增加了，从而让地址转换的效率更高。\n段页式内存管理 根据前文所述，程序的地址空间中，堆与栈之间的空间很多时候都是处于未使用状态。对应到页表里，就是有很大一部分的PTE是invalid状态。但因为页表要涵盖整个地址空间的范围，这部分invalid的PTE只能留在页表中，从而造成了很大的空间浪费。\n前文中，我们通过段式内存管理解决了堆与栈之间内存空间的浪费问题。对应到页表中，我们也可以为页式内存管理引入段式管理的方式，也即段页式内存管理，解决页表空间浪费的问题。\n具体的方法是，为程序的地址空间划分出多个段，比如Code、Heap、Stack等。然后，在每个段内单独进行页式管理，也即为每个段引入一个页表：\n从上图可知，将页表分段之后，页表不再需要记录那些处于空闲状态的页的PTE，从而节省了内存空间的消耗。\n多级页表 降低页表大小另一个常见的方法就是多级页表（Multi-level Page Table），多级页表的思路也是减少处于空闲状态的页的PTE数量，但方法与段页式内存管理不同。如果说段页式内存管理可以看成是将页表分段，那么多级页表则可以看成是将页表分页。\n具体的做法是将页表按照一定大小分成多个部分（页目录，Page Directory，PD），如果某个页目录下所有的页都是处于空闲状态，则无须为该页目录下的页申请PTE。\n以二级页表为例，下图对比了普通页表和多级页表的构成差异：\n下面，我们再对比一下普通页表和多级页表的空间消耗。还是假设地址空间为32-bit，页的大小为4KB，PTE的大小为4-byte，一共有$2^{20}$个页，那么普通页表需要4MB的内存空间。现在，我们将$2^{20}$个页切分为$2^{10}$份，也即有$2^{10}$个页目录，每个页目录下管理$2^{10}$个页，也即有$2^{10}$个PDE（Page Directory Entry）。假设PDE也占4-byte内存，且根据20/80定律假设有80%的页处于空闲状态，那么二级页表只需要0.804MB！(${2^{10}*4}$${+2^{20}4(1-80%)}$)\n由此可见，多级页表能够有效降低页表的内存消耗。多级页表在实际运用中还是较为常见的，比如Linux系统采用的就是4级页表的结构。\nSwap Sapce: 磁盘交换区 到目前为止，我们都是假设物理内存足够大，可以容纳所有程序的虚拟内存空间。然而，这往往是不切实际的，以32-bit地址空间为例，一个程序的虚拟内存为4G，假设有100个程序，那么一共需要400G的物理内存（忽略共享部分）！另外，程序运行过程中，并不是一直都需要所有的页，很多时候只需要其中的一小部分。\n因此，如果我们可以先把那些暂时用不到的页先存在磁盘上，等需要用到时再加载到内存上，那么就可以节省很多物理内存。磁盘中用来存放这些页的区域，被称作Swap Sapce，也即交换区。\n在这种机制之下，当程序访问某一个地址，而这个地址所在的页又不在内存上时，就会触发缺页（Page Fault）中断。就像TLB缓存不命中时会带来额外的开销一样，缺页也会导致内存的访问效率降低。因为在处理缺页中断时，OS必须从磁盘交换区上把数据加载到内存上；而且当空闲内存不足时，OS还必须将内存上的某些页换出到交换区中。这一进一出的磁盘IO访问也直接导致缺页发生时，内存访问的效率下降许多。\n因此，在空闲内存不足时，页的换出策略显得极为重要。如果把一个即将要被访问的页换出到交换区上，就会带来本可避免的无谓消耗。页的换出策略很多，常见的有FIFO（先进先出）、Random（随机）、LRU（最近最少使用）、LFU（最近最不经常使用）等。在常见的工作负载下，FIFO和Random算法的效果较差，实际用的不多；LRU和LFU算法都是建立在历史内存访问统计的基础上，因此表现较前两者好些，实际应用也多一些。目前很多主流的操作系统的页换出算法都是在LRU或LFU的基础上进行优化改进的结果。\n最后 本文主要介绍了OS内存管理的一些基本原理，从独占式内存管理，到页式内存管理，这过程中经历了许多次优化。这其中的每一种优化手段，都朝着如下3个目标前进：\n1、透明化（transparency）。内存管理的细节对程序不可见，换句话说，程序可以自认为独占整个内存空间。\n2、效率（efficiency ）。地址转换和内存访问的效率要高，不能让程序运行太慢；空间利用效率也要高，不能占用太多空闲内存。\n3、保护（protection）。保证OS自身不受应用程序的影响；应用程序之间也不能相互影响。\n当然，目前主流的操作系统（如Linux、MacOS等）的内存管理机制要比本文介绍的原理复杂许多，但本质原理依然离不开本文所描述的几种基础的内存管理原理。\n 参考\n1、Operating Systems: Three Easy Pieces, Remzi H Arpaci-Dusseau / Andrea C Arpaci-Dusseau\n2、为什么 HugePages 可以提升数据库性能 , 面向信仰编程\n3、探索CPU的调度原理, 元闰子的邀请\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2022-01-09T00:00:00Z","permalink":"https://yrunz.com/p/%E6%8E%A2%E7%B4%A2os%E7%9A%84%E5%86%85%E5%AD%98%E5%8E%9F%E7%90%86/","title":"探索OS的内存原理"},{"content":"前言 在面向对象编程的理念里，应用程序是对现实世界的抽象，我们经常会将现实中的事物建模为编程语言中的类/对象（“是什么”），而事物的行为则建模为方法（“做什么”）。面向对象编程有三大基本特性（封装、继承/组合、多态）和五大基本原则（单一职责原则、开放封闭原则、里氏替换原则、依赖倒置原则、接口分离原则），但知道这些还并不足以让我们设计出好的程序，于是很多方法论就涌现了出来。\n近来最火的当属领域驱动设计（DDD），其中战术建模提出的实体、值对象、聚合等建模方法，能够很好的指导我们设计出符合现实世界的领域模型。但DDD也不是万能的，在某些应用场景下，按照传统的战术建模/面向对象方法设计出来的程序，也会存在可维护性差、违反单一职责原则等问题。\n本文介绍的DCI建模方法可以看成是战术建模的一种辅助，在某些场景下，它可以很好的弥补DDD战术建模的一些缺点。接下来，我们将会通过一个案例来介绍DCI是如何解决DDD战术建模的这些缺点的。\n 本文涉及的代码归档在github项目：https://github.com/ruanrunxue/DCI-Architecture-Implementation\n 案例 考虑一个普通人的生活日常，他会在学校上课，也会趁着暑假去公司工作，在工作之余去公园游玩，也会像普通人一样在家吃喝玩乐。当然，一个人的生活还远不止这些，为了讲解方便，本文只针对这几个典型的场景进行建模示例。\n使用DDD建模 按照DDD战术建模的思路，首先，我们会列出该案例的通用语言：\n 人、身份证、银行卡、家、吃饭、睡觉、玩游戏、学校、学生卡、学习、考试、公司、工卡、上班、下班、公园、购票、游玩\n 接着，我们使用战术建模技术（值对象、实体、聚合、领域服务、资源库）对通用语言进行领域建模。\nDDD建模后的代码目录结构如下：\n- aggregate:聚合- company.go- home.go- park.go- school.go- entity:实体- people.go- vo:值对象- account.go- identity_card.go- student_card.go- work_card.go我们将身份证、学生卡、工卡、银行卡这几个概念，建模为值对象（Value Object）：\npackage vo // 身份证 type IdentityCard struct { Id uint32 Name string } // 学生卡 type StudentCard struct { Id uint32 Name string School string } // 工卡 type WorkCard struct { Id uint32 Name string Company string } // 银行卡 type Account struct { Id uint32 Balance int } ... 接着我们将人建模成实体（Entity），他包含了身份证、学生卡等值对象，也具备吃饭、睡觉等行为：\npackage entity // 人 type People struct { vo.IdentityCard vo.StudentCard vo.WorkCard vo.Account } // 学习 func (p *People) Study() { fmt.Printf(\u0026#34;Student %+v studying\\n\u0026#34;, p.StudentCard) } // 考试 func (p *People) Exam() { fmt.Printf(\u0026#34;Student %+v examing\\n\u0026#34;, p.StudentCard) } // 吃饭 func (p *People) Eat() { fmt.Printf(\u0026#34;%+v eating\\n\u0026#34;, p.IdentityCard) p.Account.Balance-- } // 睡觉 func (p *People) Sleep() { fmt.Printf(\u0026#34;%+v sleeping\\n\u0026#34;, p.IdentityCard) } // 玩游戏 func (p *People) PlayGame() { fmt.Printf(\u0026#34;%+v playing game\\n\u0026#34;, p.IdentityCard) } // 上班 func (p *People) Work() { fmt.Printf(\u0026#34;%+v working\\n\u0026#34;, p.WorkCard) p.Account.Balance++ } // 下班 func (p *People) OffWork() { fmt.Printf(\u0026#34;%+v getting off work\\n\u0026#34;, p.WorkCard) } // 购票 func (p *People) BuyTicket() { fmt.Printf(\u0026#34;%+v buying a ticket\\n\u0026#34;, p.IdentityCard) p.Account.Balance-- } // 游玩 func (p *People) Enjoy() { fmt.Printf(\u0026#34;%+v enjoying park scenery\\n\u0026#34;, p.IdentityCard) } 最后，我们将学校、公司、公园、家建模成聚合（Aggregate），聚合由一个或多个实体、值对象组合而成，组织它们完成具体的业务逻辑：\npackage aggregate // 家 type Home struct { me *entity.People } func (h *Home) ComeBack(p *entity.People) { fmt.Printf(\u0026#34;%+v come back home\\n\u0026#34;, p.IdentityCard) h.me = p } // 执行Home的业务逻辑 func (h *Home) Run() { h.me.Eat() h.me.PlayGame() h.me.Sleep() } // 学校 type School struct { Name string students []*entity.People } func (s *School) Receive(student *entity.People) { student.StudentCard = vo.StudentCard{ Id: rand.Uint32(), Name: student.IdentityCard.Name, School: s.Name, } s.students = append(s.students, student) fmt.Printf(\u0026#34;%s Receive stduent %+v\\n\u0026#34;, s.Name, student.StudentCard) } // 执行School的业务逻辑 func (s *School) Run() { fmt.Printf(\u0026#34;%s start class\\n\u0026#34;, s.Name) for _, student := range s.students { student.Study() } fmt.Println(\u0026#34;students start to eating\u0026#34;) for _, student := range s.students { student.Eat() } fmt.Println(\u0026#34;students start to exam\u0026#34;) for _, student := range s.students { student.Exam() } fmt.Printf(\u0026#34;%s finish class\\n\u0026#34;, s.Name) } // 公司 type Company struct { Name string workers []*entity.People } func (c *Company) Employ(worker *entity.People) { worker.WorkCard = vo.WorkCard{ Id: rand.Uint32(), Name: worker.IdentityCard.Name, Company: c.Name, } c.workers = append(c.workers, worker) fmt.Printf(\u0026#34;%s Employ worker %s\\n\u0026#34;, c.Name, worker.WorkCard.Name) } // 执行Company的业务逻辑 func (c *Company) Run() { fmt.Printf(\u0026#34;%s start work\\n\u0026#34;, c.Name) for _, worker := range c.workers { worker.Work() } fmt.Println(\u0026#34;worker start to eating\u0026#34;) for _, worker := range c.workers { worker.Eat() } fmt.Println(\u0026#34;worker get off work\u0026#34;) for _, worker := range c.workers { worker.OffWork() } fmt.Printf(\u0026#34;%s finish work\\n\u0026#34;, c.Name) } // 公园 type Park struct { Name string enjoyers []*entity.People } func (p *Park) Welcome(enjoyer *entity.People) { fmt.Printf(\u0026#34;%+v come to park %s\\n\u0026#34;, enjoyer.IdentityCard, p.Name) p.enjoyers = append(p.enjoyers, enjoyer) } // 执行Park的业务逻辑 func (p *Park) Run() { fmt.Printf(\u0026#34;%s start to sell tickets\\n\u0026#34;, p.Name) for _, enjoyer := range p.enjoyers { enjoyer.BuyTicket() } fmt.Printf(\u0026#34;%s start a show\\n\u0026#34;, p.Name) for _, enjoyer := range p.enjoyers { enjoyer.Enjoy() } fmt.Printf(\u0026#34;show finish\\n\u0026#34;) } 那么，根据上述方法建模出来的模型是这样的：\n模型的运行方法如下：\npaul := entity.NewPeople(\u0026#34;Paul\u0026#34;) mit := aggregate.NewSchool(\u0026#34;MIT\u0026#34;) google := aggregate.NewCompany(\u0026#34;Google\u0026#34;) home := aggregate.NewHome() summerPalace := aggregate.NewPark(\u0026#34;Summer Palace\u0026#34;) // 上学 mit.Receive(paul) mit.Run() // 回家 home.ComeBack(paul) home.Run() // 工作 google.Employ(paul) google.Run() // 公园游玩 summerPalace.Welcome(paul) summerPalace.Run() 贫血模型 VS 充血模型（工程派 VS 学院派） 上一节中，我们使用DDD的战术建模完成了该案例领域模型。模型的核心是People实体，它有IdentityCard、StudentCard等数据属性，也有Eat()、Study()、Work()等业务行为 ，非常符合现实世界中定义。这也是学院派所倡导的，同时拥有数据属性和业务行为的充血模型。\n然而，充血模型并非完美，它也有很多问题，比较典型的是这两个：\n问题一：上帝类\nPeople这个实体包含了太多的职责，导致它变成了一个名副其实的上帝类。试想，这里还是裁剪了很多“人”所包含的属性和行为，如果要建模一个完整的模型，其属性和方法之多，无法想象。上帝类违反了单一职责原则，会导致代码的可维护性变得极差。\n问题二：模块间耦合\nSchool与Company本应该是相互独立的，School不必关注上班与否，Company也不必关注考试与否。但是现在因为它们都依赖了People这个实体，School可以调用与Company相关的Work()和OffWork()方法，反之亦然。这导致模块间产生了不必要的耦合，违反了接口隔离原则。\n这些问题都是工程派不能接受的，从软件工程的角度，它们会使得代码难以维护。解决这类问题的方法，比较常见的是对实体进行拆分，比如将实体的行为建模成领域服务，像这样：\ntype People struct { vo.IdentityCard vo.StudentCard vo.WorkCard vo.Account } type StudentService struct{} func (s *StudentService) Study(p *entity.People) { fmt.Printf(\u0026#34;Student %+v studying\\n\u0026#34;, p.StudentCard) } func (s *StudentService) Exam(p *entity.People) { fmt.Printf(\u0026#34;Student %+v examing\\n\u0026#34;, p.StudentCard) } type WorkerService struct{} func (w *WorkerService) Work(p *entity.People) { fmt.Printf(\u0026#34;%+v working\\n\u0026#34;, p.WorkCard) p.Account.Balance++ } func (w *WorkerService) OffWOrk(p *entity.People) { fmt.Printf(\u0026#34;%+v getting off work\\n\u0026#34;, p.WorkCard) } // ... 这种建模方法，解决了上述两个问题，但也变成了所谓的贫血模型：People变成了一个纯粹的数据类，没有任何业务行为。在人的心理上，这样的模型并不能在建立起对现实世界的对应关系，不容易让人理解，因此被学院派所抵制。\n到目前为止，贫血模型和充血模型都有各有优缺点，工程派和学院派谁都无法说服对方。接下来，轮到本文的主角出场了。\nDCI架构 DCI（Data，Context，Interactive）架构是一种面向对象的软件架构模式，在《The DCI Architecture: A New Vision of Object-Oriented Programming》一文中被首次提出。与传统的面向对象相比，DCI能更好地对数据和行为之间的关系进行建模，从而更容易被人理解。\n Data，也即数据/领域对象，用来描述系统“是什么”，通常采用DDD中的战术建模来识别当前模型的领域对象，等同于DDD分层架构中的领域层。 Context，也即场景，可理解为是系统的Use Case，代表了系统的业务处理流程，等同于DDD分层架构中的应用层。 Interactive，也即交互，是DCI相对于传统面向对象的最大发展，它认为我们应该显式地对领域对象（Object）在每个业务场景（Context）中扮演（Cast）的角色（Role）进行建模。Role代表了领域对象在业务场景中的业务行为（“做什么”），Role之间通过交互完成完整的义务流程。   这种角色扮演的模型我们并不陌生，在现实的世界里也是随处可见，比如，一个演员可以在这部电影里扮演英雄的角色，也可以在另一部电影里扮演反派的角色。\n DCI认为，对Role的建模应该是面向Context的，因为特定的业务行为只有在特定的业务场景下才会有意义。通过对Role的建模，我们就能够将领域对象的方法拆分出去，从而避免了上帝类的出现。最后，领域对象通过组合或继承的方式将Role集成起来，从而具备了扮演角色的能力。\nDCI架构一方面通过角色扮演模型使得领域模型易于理解，另一方面通过“小类大对象”的手法避免了上帝类的问题，从而较好地解决了贫血模型和充血模型之争。另外，将领域对象的行为根据Role拆分之后，模块更加的高内聚、低耦合了。\n使用DCI建模 回到前面的案例，使用DCI的建模思路，我们可以将“人”的几种行为按照不同的角色进行划分。吃完、睡觉、玩游戏，是作为人类角色的行为；学习、考试，是作为学生角色的行为；上班、下班，是作为员工角色的行为；购票、游玩，则是作为游玩者角色的行为。“人”在家这个场景中，充当的是人类的角色；在学校这个场景中，充当的是学生的角色；在公司这个场景中，充当的是员工的角色；在公园这个场景中，充当的是游玩者的角色。\n 需要注意的是，学生、员工、游玩者，这些角色都应该具备人类角色的行为，比如在学校里，学生也需要吃饭。\n 最后，根据DCI建模出来的模型，应该是这样的：\n在DCI模型中，People不再是一个包含众多属性和方法的“上帝类”，这些属性和方法被拆分到多个Role中实现，而People由这些Role组合而成。\n另外，School与Company也不再耦合，School只引用了Student，不能调用与Company相关的Worker的Work()和OffWorker()方法。\n代码实现DCI模型 DCI建模后的代码目录结构如下；\n- context:场景- company.go- home.go- park.go- school.go- object:对象- people.go- data:数据- account.go- identity_card.go- student_card.go- work_card.go- role:角色- enjoyer.go- human.go- student.go- worker.go从代码目录结构上看，DDD和DCI架构相差并不大，aggregate目录演变成了context目录；vo目录演变成了data目录；entity目录则演变成了object和role目录。\n首先，我们实现基础角色Human，Student、Worker、Enjoyer都需要组合它：\npackage role // 人类角色 type Human struct { data.IdentityCard data.Account } func (h *Human) Eat() { fmt.Printf(\u0026#34;%+v eating\\n\u0026#34;, h.IdentityCard) h.Account.Balance-- } func (h *Human) Sleep() { fmt.Printf(\u0026#34;%+v sleeping\\n\u0026#34;, h.IdentityCard) } func (h *Human) PlayGame() { fmt.Printf(\u0026#34;%+v playing game\\n\u0026#34;, h.IdentityCard) } 接着，我们再实现其他角色，需要注意的是，Student、Worker、Enjoyer不能直接组合Human，否则People对象将会有4个Human子对象，与模型不符：\n// 错误的实现 type Worker struct { Human } func (w *Worker) Work() { fmt.Printf(\u0026#34;%+v working\\n\u0026#34;, w.WorkCard) w.Balance++ } ... type People struct { Human Student Worker Enjoyer } func main() { people := People{} fmt.Printf(\u0026#34;People: %+v\u0026#34;, people) } // 结果输出, People中有4个Human： // People: {Human:{} Student:{Human:{}} Worker:{Human:{}} Enjoyer:{Human:{}}} 为解决该问题，我们引入了xxxTrait接口：\n// 人类角色特征 type HumanTrait interface { CastHuman() *Human } // 学生角色特征 type StudentTrait interface { CastStudent() *Student } // 员工角色特征 type WorkerTrait interface { CastWorker() *Worker } // 游玩者角色特征 type EnjoyerTrait interface { CastEnjoyer() *Enjoyer } Student、Worker、Enjoyer组合HumanTrait，并通过Compose(HumanTrait)方法进行特征注入，只要在注入的时候保证Human是同一个，就可以解决该问题了。\n// 学生角色 type Student struct { // Student同时也是个普通人，因此组合了Human角色 \tHumanTrait data.StudentCard } // 注入人类角色特征 func (s *Student) Compose(trait HumanTrait) { s.HumanTrait = trait } func (s *Student) Study() { fmt.Printf(\u0026#34;Student %+v studying\\n\u0026#34;, s.StudentCard) } func (s *Student) Exam() { fmt.Printf(\u0026#34;Student %+v examing\\n\u0026#34;, s.StudentCard) } // 员工角色 type Worker struct { // Worker同时也是个普通人，因此组合了Human角色 \tHumanTrait data.WorkCard } // 注入人类角色特征 func (w *Worker) Compose(trait HumanTrait) { w.HumanTrait = trait } func (w *Worker) Work() { fmt.Printf(\u0026#34;%+v working\\n\u0026#34;, w.WorkCard) w.CastHuman().Balance++ } func (w *Worker) OffWork() { fmt.Printf(\u0026#34;%+v getting off work\\n\u0026#34;, w.WorkCard) } // 游玩者角色 type Enjoyer struct { // Enjoyer同时也是个普通人，因此组合了Human角色 \tHumanTrait } // 注入人类角色特征 func (e *Enjoyer) Compose(trait HumanTrait) { e.HumanTrait = trait } func (e *Enjoyer) BuyTicket() { fmt.Printf(\u0026#34;%+v buying a ticket\\n\u0026#34;, e.CastHuman().IdentityCard) e.CastHuman().Balance-- } func (e *Enjoyer) Enjoy() { fmt.Printf(\u0026#34;%+v enjoying scenery\\n\u0026#34;, e.CastHuman().IdentityCard) } 最后，实现People这一领域对象：\npackage object type People struct { // People对象扮演的角色 \trole.Human role.Student role.Worker role.Enjoyer } // People实现了HumanTrait、StudentTrait、WorkerTrait、EnjoyerTrait等特征接口 func (p *People) CastHuman() *role.Human { return \u0026amp;p.Human } func (p *People) CastStudent() *role.Student { return \u0026amp;p.Student } func (p *People) CastWorker() *role.Worker { return \u0026amp;p.Worker } func (p *People) CastEnjoyer() *role.Enjoyer { return \u0026amp;p.Enjoyer } // People在初始化时，完成对角色特征的注入 func NewPeople(name string) *People { // 一些初始化的逻辑... \tpeople.Student.Compose(people) people.Worker.Compose(people) people.Enjoyer.Compose(people) return people } 进行角色拆分之后，在实现Home、School、Company、Park等场景时，只需依赖相应的角色即可，不再需要依赖People这一领域对象：\n// 家 type Home struct { me *role.Human } func (h *Home) ComeBack(human *role.Human) { fmt.Printf(\u0026#34;%+v come back home\\n\u0026#34;, human.IdentityCard) h.me = human } // 执行Home的业务逻辑 func (h *Home) Run() { h.me.Eat() h.me.PlayGame() h.me.Sleep() } // 学校 type School struct { Name string students []*role.Student } func (s *School) Receive(student *role.Student) { // 初始化StduentCard逻辑 ... \ts.students = append(s.students, student) fmt.Printf(\u0026#34;%s Receive stduent %+v\\n\u0026#34;, s.Name, student.StudentCard) } // 执行School的业务逻辑 func (s *School) Run() { fmt.Printf(\u0026#34;%s start class\\n\u0026#34;, s.Name) for _, student := range s.students { student.Study() } fmt.Println(\u0026#34;students start to eating\u0026#34;) for _, student := range s.students { student.CastHuman().Eat() } fmt.Println(\u0026#34;students start to exam\u0026#34;) for _, student := range s.students { student.Exam() } fmt.Printf(\u0026#34;%s finish class\\n\u0026#34;, s.Name) } // 公司 type Company struct { Name string workers []*role.Worker } func (c *Company) Employ(worker *role.Worker) { // 初始化WorkCard逻辑 ...  c.workers = append(c.workers, worker) fmt.Printf(\u0026#34;%s Employ worker %s\\n\u0026#34;, c.Name, worker.WorkCard.Name) } // 执行Company的业务逻辑 func (c *Company) Run() { fmt.Printf(\u0026#34;%s start work\\n\u0026#34;, c.Name) for _, worker := range c.workers { worker.Work() } fmt.Println(\u0026#34;worker start to eating\u0026#34;) for _, worker := range c.workers { worker.CastHuman().Eat() } fmt.Println(\u0026#34;worker get off work\u0026#34;) for _, worker := range c.workers { worker.OffWork() } fmt.Printf(\u0026#34;%s finish work\\n\u0026#34;, c.Name) } // 公园 type Park struct { Name string enjoyers []*role.Enjoyer } func (p *Park) Welcome(enjoyer *role.Enjoyer) { fmt.Printf(\u0026#34;%+v come park %s\\n\u0026#34;, enjoyer.CastHuman().IdentityCard, p.Name) p.enjoyers = append(p.enjoyers, enjoyer) } // 执行Park的业务逻辑 func (p *Park) Run() { fmt.Printf(\u0026#34;%s start to sell tickets\\n\u0026#34;, p.Name) for _, enjoyer := range p.enjoyers { enjoyer.BuyTicket() } fmt.Printf(\u0026#34;%s start a show\\n\u0026#34;, p.Name) for _, enjoyer := range p.enjoyers { enjoyer.Enjoy() } fmt.Printf(\u0026#34;show finish\\n\u0026#34;) } 模型的运行方法如下：\npaul := object.NewPeople(\u0026#34;Paul\u0026#34;) mit := context.NewSchool(\u0026#34;MIT\u0026#34;) google := context.NewCompany(\u0026#34;Google\u0026#34;) home := context.NewHome() summerPalace := context.NewPark(\u0026#34;Summer Palace\u0026#34;) // 上学 mit.Receive(paul.CastStudent()) mit.Run() // 回家 home.ComeBack(paul.CastHuman()) home.Run() // 工作 google.Employ(paul.CastWorker()) google.Run() // 公园游玩 summerPalace.Welcome(paul.CastEnjoyer()) summerPalace.Run() 写在最后 从前文所描述的场景中，我们可以发现传统的DDD/面向对象设计方法在对行为进行建模方面存在着不足，进而导致了所谓的贫血模型和充血模型之争。\nDCI架构的出现很好的弥补了这一点，它通过引入角色扮演的思想，巧妙地解决了充血模型中上帝类和模块间耦合问题，而且不影响模型的正确性。当然，DCI架构也不是万能的，在行为较少的业务模型中，使用DCI来建模并不合适。\n最后，将DCI架构总结成一句话就是：领域对象（Object）在不同的场景（Context）中扮演（Cast）不同的角色（Role），角色之间通过交互（Interactive）来完成具体的业务逻辑。\n 参考\nThe DCI Architecture: A New Vision of Object-Oriented Programming, Trygve Reenskaug, James O. Coplien\n软件设计的演变过程, _张晓龙_\nImplement Domain Object in Golang, _张晓龙_\nDCI: 代码的可理解性, chelsea\nDCI in C++, MagicBowen\n更多文章请关注微信公众号：元闰子的邀请\n ","date":"2021-10-10T00:00:00Z","permalink":"https://yrunz.com/p/%E5%AE%9E%E7%8E%B0dci%E6%9E%B6%E6%9E%84/","title":"实现DCI架构"},{"content":"前言 软件工程师们总习惯把OS（Operating System，操作系统）当成是一个非常值得信赖的管家，我们只管把程序托管到OS上运行，却很少深入了解操作系统的运行原理。确实，OS作为一个通用的软件系统，在大多数的场景下都表现得足够的优秀。但仍会有一些特殊的场景，需要我们对OS进行各项调优，才能让业务系统更高效地完成任务。这就要求我们必须深入了解OS的原理，不仅仅只会使唤这个管家，还能懂得如何让管家做得更好。\nOS是一个非常庞大的软件系统，本文主要探索其中的冰山一角：CPU的调度原理。\n说起CPU的调度原理，很多人的第一反应是基于时间片的调度，也即每个进程都有占用CPU运行的时间片，时间片用完之后，就让出CPU给其他进程。至于OS是如何判断一个时间片是否用完的、如何切换到另一个进程等等更深层的原理，了解的人似乎并不多。\n其实，基于时间片的调度只是众多CPU的调度算法的一类，本文将会从最基础的调度算法说起，逐个分析各种主流调度算法的原理，带大家一起探索CPU调度的奥秘。\nCPU的上下文切换 在探索CPU调度原理之前，我们先了解一下CPU的上下文切换，它是CPU调度的基础。\n如今的OS几乎都支持\u0026quot;同时\u0026quot;运行远大于CPU数量的任务，OS会将CPU轮流分配给它们使用。这就要求OS必须知道从哪里加载任务，以及加载后从哪里开始运行，而这些信息都保存在CPU的寄存器中，其中即将执行的下一条指令的地址被保存在程序计数器（PC）这一特殊寄存器上。我们将寄存器的这些信息称为CPU的上下文，也叫硬件上下文。\nOS在切换运行任务时，将上一任务的上下文保存下来，并将即将运行的任务的上下文加载到CPU寄存器上的这一动作，被称为CPU上下文切换。\n CPU上下文属于进程上下文的一部分，我们常说的进程上下文由如下两部分组成：\n 用户级上下文：包含进程的运行时堆栈、数据块、代码块等信息。 系统级上下文：包含进程标识信息、进程现场信息（CPU上下文）、进程控制信息等信息。   这涉及到两个问题：（1）上一任务的CPU上下文如何保存下来？（2）什么时候执行上下文切换？\n问题1: 上一任务的CPU上下文如何保存下来？\nCPU上下文会被保存在进程的内核空间（kernel space）上。OS在给每个进程分配虚拟内存空间时，会分配一个内核空间，这部分内存只能由内核代码访问。OS在切换CPU上下文前，会先将当前CPU的通用寄存器、PC等进程现场信息保存在进程的内核空间上，待下次切换时，再取出重新装载到CPU上，以恢复任务的运行。\n问题2: 什么时候执行上下文切换？\nOS要想进行任务上下文切换，必须占用CPU来执行切换逻辑。然而，用户程序运行的过程中，CPU已经被用户程序所占用，也即OS在此刻并未处于运行状态，自然也无法执行上下文切换。针对该问题，有两种解决策略，协作式策略与抢占式策略。\n协作式策略依赖用户程序主动让出CPU，比如执行系统调用（System Call）或者出现除零等异常。但该策略并不靠谱，如果用户程序没有主动让出CPU，甚至是恶意死循环，那么该程序将会一直占用CPU，唯一的恢复手段就是重启系统了。\n抢占式策略则依赖硬件的定时中断机制（Timer Interrupt），OS会在初始化时向硬件注册中断处理回调（Interrupt Handler）。当硬件产生中断时，硬件会将CPU的处理权交给来OS，OS就可以在中断回调上实现CPU上下文的切换。\n调度的衡量指标 对于一种CPU调度算法的好坏，一般都通过如下两个指标来进行衡量：\n 周转时间（turnaround time），指从任务到达至任务完成之间的时间，即$T_{turnaround}=T_{completiong}-T_{arrival}$ 响应时间（response time），指从任务到达至任务首次被调度的时间，即$T_{response}=T_{firstrun}-T_{arrival}$  两个指标从某种程度上是对立的，要求高的平均周转时间，必然会降低平均响应时间。具体追求哪种指标与任务类型有关，比如程序编译类的任务，要求周转时间要小，尽可能快的完成编译；用户交互类的任务，则要求响应时间要小，避免影响用户体验。\n工作负载假设 OS上的工作负载（也即各类任务运行的状况）总是千变万化的，为了更好的理解各类CPU调度算法原理，我们先对工作负载进行来如下几种假设：\n 假设1：所有任务都运行时长都相同。 假设2：所有任务的开始时间都是相同的 假设3：一旦任务开始，就会一直运行，直至任务完成。 假设4：所有任务只使用CPU资源（比如不产生I/O操作）。 假设5：预先知道所有任务的运行时长。  准备工作已经做好，下面我们开始进入CPU调度算法的奇妙世界。\nFIFO：先进先出 FIFO（First In First Out，先进先出）调度算法以原理简单，容易实现著称，它先调度首先到达的任务直至结束，然后再调度下一个任务，以此类推。如果有多个任务同时到达，则随机选一个。\n在我们假设的工作负载状况下，FIFO效率良好。比如有A、B、C三个任务满足上述所有负载假设，每个任务运行时长为10s，在t=0时刻到达，那么任务调度情况是这样的：\n根据FIFO的调度原理，A、B、C分别在10、20、30时刻完成任务，平均周转时间为20s（ $\\frac {10+20+30}{3}$），效果很好。\n然而现实总是残酷的，如果假设1被打破，比如A的运行时间变成100s，B和C的还是10s，那么调度情况是这样的：\n根据FIFO的调度原理，由于A的运行时间过长，B和C长时间得不到调度，导致平均周转时间恶化为110（ $\\frac {100+110+120}{3}$）。\n因此，FIFO调度策略在任务运行时间差异较大的场景下，容易出现任务饿死的问题！\n针对这个问题，如果运行时间较短的B和C先被调度，问题就可以解决了，这正是SJF调度算法的思想。\nSJF：最短任务优先 SJF（Shortest Job First，最短任务优先）从相同到达时间的多个任务中选取运行时长最短的一个任务进行调度，接着再调度第二短的任务，以此类推。\n针对上一节的工作负载，使用SJF进行调度的情况如下，周转时间变成了50s（ $\\frac {10+20+120}{3}$），相比FIFO的110s，有了2倍多的提升。\n让我们继续打破假设2，A在t=0时刻，B和C则在t=10时刻到达，那么调度情况会变成这样：\n因为任务B和C比A后到，它们不得不一直等待A运行结束后才有机会调度，即使A需要长时间运行。周转时间恶化为103.33s（$\\frac {100+(110-10)+(120-10)}{3}$），再次出现任务饿死的问题！\nSTCF：最短时间完成优先 为了解决SJF的任务饿死问题，我们需要打破假设3，也即任务在运行过程中是允许被打断的。如果B和C在到达时就立即被调度，问题就解决了。这属于抢占式调度，原理就是CPU上下文切换一节提到的，在中断定时器到达之后，OS完成任务A和B的上下文切换。\n我们在协作式调度的SJF算法的基础上，加上抢占式调度算法，就演变成了STCF算法（Shortest Time-to-Completion First，最短时间完成优先），调度原理是当运行时长较短的任务到达时，中断当前的任务，优先调度运行时长较短的任务。\n使用STCF算法对该工作负载进行调度的情况如下，周转时间优化为50s（$\\frac {120+(20-10)+(30-10)}{3}$），再次解决了任务饿死问题！\n到目前为止，我们只关心了周转时间这一衡量指标，那么FIFO、SJF和STCF调度算法的响应时间又是多长呢？\n不妨假设A、B、C三个任务都在t=0时刻到达，运行时长都是5s，那么这三个算法的调度情况如下，平均响应时长为5s（$\\frac {0+(5-0)+(10-0)}{3}$）：\n更糟糕的是，随着任务运行时长的增长，平均响应时长也随之增长，这对于交互类任务来说将会是灾难性的，严重影响用户体验。该问题的根源在于，当任务都同时到达且运行时长相同时，最后一个任务必须等待其他任务全部完成之后才开始调度。\n为了优化响应时间，我们熟悉的基于时间片的调度出现了。\nRR：基于时间片的轮询调度 RR（Round Robin，轮训）算法给每个任务分配一个时间片，当任务的时间片用完之后，调度器会中断当前任务，切换到下一个任务，以此类推。\n 需要注意的是，时间片的长度设置必须是中断定时器的整数倍，比如中断定时器时长为2ms，那么任务的时间片可以设置为2ms、4ms、6ms \u0026hellip; 否则即使任务的时间片用完之后，定时中断没发生，OS也无法切换任务。\n 现在，使用RR进行调度，给A、B、C分配一个1s的时间片，那么调度情况如下，平均响应时长为1s（$\\frac {0+(1-0)+(2-0)}{3}$）：\n从RR的调度原理可以发现，把时间片设置得越小，平均响应时间也越小。但随着时间片的变小，任务切换的次数也随之上升，也就是上下文切换的消耗会变大。因此，时间片大小的设置是一个trade-off的过程，不能一味追求响应时间而忽略CPU上下文切换带来的消耗。\n CPU上下文切换的消耗，不只是保存和恢复寄存器所带来的消耗。程序在运行过程中，会逐渐在CPU各级缓存、TLB、分支预测器等硬件上建立属于自己的缓存数据。当任务被切换后，就意味着又得重来一遍缓存预热，这会带来巨大的消耗。\n 另外，RR调度算法的周转时间为14s（$\\frac {(13-0)+(14-0)+(15-0)}{3}$），相比于FIFO、SJF和STCF的10s（$\\frac {(5-0)+(10-0)+(15-0)}{3}$）差了不少。这也验证了之前所说的，周转时间和响应时间在某种程度上是对立的，如果想要优化周转时间，建议使用SJF和STCF；如果想要优化响应时间，则建议使用RR。\nI/O操作对调度的影响 到目前为止，我们并未考虑任何的I/O操作。我们知道，当触发I/O操作时，进程并不会占用CPU，而是阻塞等待I/O操作的完成。现在让我们打破假设4，考虑任务A和B都在t=0时刻到达，运行时长都是50ms，但A每隔10ms执行一次阻塞10ms的I/O操作，而B没有I/O。\n如果使用STCF进行调度，调度的情况是这样的：\n从上图看出，任务A和B的调度总时长达到了140ms，比实际A和B运行时长总和100ms要大。而且A阻塞在I/O操作期间，调度器并没有切换到B，导致了CPU的空转！\n要解决该问题，只需使用RR的调度算法，给任务A和B分配10ms的时间片，这样当A阻塞在I/O操作时，就可以调度B，而B用完时间片后，恰好A也从I/O阻塞中返回，以此类推，调度总时长优化至100ms。\n该调度方案是建立在假设5之上的，也即要求调度器预先知道A和B的运行时长、I/O操作时间长等信息，才能如此充分地利用CPU。然而，实际的情况远比这复杂，I/O阻塞时长不会每次都一样，调度器也无法准确知道A和B的运行信息。当假设5也被打破时，调度器又该如何实现才能最大程度保证CPU利用率，以及调度的合理性呢？\n接下来，我们将介绍一个能够在所有工作负载假设被打破的情况下依然表现良好，被许多现代操作系统采用的CPU调度算法，MLFQ。\nMLFQ：多级反馈队列 MLFQ（Multi-Level Feedback Queue，多级反馈队列）调度算法的目标如下：\n 优化周转时间。 降低交互类任务的响应时间，提升用户体验。  从前面分析我们知道，要优化周转时间，可以优先调度运行时长短的任务（像SJF和STCF的做法）；要优化响应时间，则采用类似RR的基于时间片的调度。然而，这两个目标看起来是矛盾的，要降低响应时间，必然会增加周转时间。\n那么对MLFQ来说，就需要解决如下两个问题：\n 在不预先清楚任务的运行信息（包括运行时长、I/O操作等）的前提下，如何权衡周转时间和响应时间？ 如何从历史调度中学习，以便未来做出更好的决策？  划分任务的优先级 MLFQ与前文介绍的几种调度算法最显著的特点就是新增了优先级队列存放不同优先级的任务，并定下了如下两个规则：\n 规则1：如果Priority(A) \u0026gt; Priority(B)，则调度A 规则2：如果Priority(A) = Priority(B)，则按照RR算法调度A和B  优先级的变化 MLFQ必须考虑改变任务的优先级，否则根据 规则1 和 规则2 ，对于上图中的任务C，在A和B运行结束之前，C都不会获得运行的机会，导致C的响应时间很长。因此，可以定下了如下几个优先级变化规则：\n 规则3：当一个新的任务到达时，将它放到最高优先级队列中 规则4a：如果任务A运行了一个时间片都没有主动让出CPU（比如I/O操作），则优先级降低一级 规则4b：如果任务A在时间片用完之前，有主动让出CPU，则优先级保持不变   规则3主要考虑到让新加入的任务都能得到调度机会，避免出现任务饿死的问题\n规则4a和4b主要考虑到，交互类任务大都是short-running的，并且会频繁让出CPU，因此为了保证响应时间，需要保持现有的优先级；而CPU密集型任务，往往不会太关注响应时间，因此可以降低优先级。\n 按照上述规则，当一个long-running任务A到达时，调度情况是这样的：\n如果在任务A运行到t=100时，short-time任务B到达，调度情况是这样的：\n从上述调度情况可以看出，MLFQ具备了STCF的优点，即可以优先完成short-running任务的调度，缩短了周转时间。\n如果任务A运行到t=100时，交互类任务C到达，那么调度情况是这样的：\nMLFQ会在任务处于阻塞时按照优先级选择其他任务运行，避免CPU空转。因此，在上图中，当任务C处于I/O阻塞状态时，任务A得到了运行时间片，当任务C从I/O阻塞上返回时，A再次挂起，以此类推。另外，因为任务C在时间片之内出现主动让出CPU的行为，C的优先级一直保持不变，这对于交互类任务而言，有效提升了用户体验。\nCPU密集型任务饿死问题 到目前为止，MLFQ似乎能够同时兼顾周转时间，以及交互类任务的响应时间，它真的完美了吗？\n考虑如下场景，任务A运行到t=100时，交互类任务C和D同时到达，那么调度情况会是这样的：\n由此可见，如果当前系统上存在很多交互类任务时，CPU密集型任务将会存在饿死的可能！\n为了解决该问题，可以设立了如下规则：\n 规则5：系统运行S时长之后，将所有任务放到最高优先级队列上（Priority Boost）  加上该规则之后，假设设置S为50ms，那么调度情况是这样的，饿死问题得到解决！\n恶意任务问题 考虑如下一个恶意任务E，为了长时间占用CPU，任务E在时间片还剩1%时故意执行I/O操作，并很快返回。根据规则4b，E将会维持在原来的最高优先级队列上，因此下次调度时仍然获得调度优先权：\n为了解决该问题，我们需要将规则4调整为如下规则：\n 规则4：给每个优先级分配一个时间片，当任务用完该优先级的时间片后，优先级降一级  应用新的规则4后，相同的工作负载，调度情况变成了如下所述，不再出现恶意任务E占用大量CPU的问题。\n到目前为止，MLFQ的基本原理已经介绍完，最后，我们总结下MLFQ最关键的5项规则：\n 规则1：如果Priority(A) \u0026gt; Priority(B)，则调度A 规则2：如果Priority(A) = Priority(B)，则按照RR算法调度A和B 规则3：当一个新的任务到达时，将它放到最高优先级队列中 规则4：给每个优先级分配一个时间片，当任务用完该优先级的时间片后，优先级降一级 规则5：系统运行S时长之后，将所有任务放到最高优先级队列上（Priority Boost）  现在，再回到本节开始时提出的两个问题：\n1、在不预先清楚任务的运行信息（包括运行时长、I/O操作等）的前提下，MLFQ如何权衡周转时间和响应时间？\n在预先不清楚任务到底是long-running或short-running的情况下，MLFQ会先假设任务属于shrot-running任务，如果假设正确，任务就会很快完成，周转时间和响应时间都得到优化；即使假设错误，任务的优先级也能逐渐降低，把更多的调度机会让给其他short-running任务。\n2、MLFQ如何从历史调度中学习，以便未来做出更好的决策？\nMLFQ主要根据任务是否有主动让出CPU的行为来判断其是否是交互类任务，如果是，则维持在当前的优先级，保证该任务的调度优先权，提升交互类任务的响应性。\n当然，MLFQ并非完美的调度算法，它也存在着各种问题，其中最让人困扰的就是MLFQ各项参数的设定，比如优先级队列的数量，时间片的长度、Priority Boost的间隔等。这些参数并没有完美的参考值，只能根据不同的工作负载来进行设置。\n 比如，我们可以将低优先级队列上任务的时间片设置长一些，因为低优先级的任务往往是CPU密集型任务，它们不太关心响应时间，较长的时间片长能够减少上下文切换带来的消耗。\n CFS：Linux的完全公平调度 本节我们将介绍一个平时打交道最多的调度算法，Linux系统下的CFS（Completely Fair Scheduler，完全公平调度）。与上一节介绍的MLFQ不同，CFS并非以优化周转时间和响应时间为目标，而是希望将CPU公平地均分给每个任务。\n 当然，CFS也提供了给进程设置优先级的功能，让用户/管理员决定哪些进程需要获得更多的调度时间。\n 基本原理 大部分调度算法都是基于固定时间片来进行调度，而CFS另辟蹊径，采用基于计数的调度方法，该技术被称为virtual runtime。\nCFS给每个任务都维护一个vruntime值，每当任务被调度之后，就累加它的vruntime。比如，当任务A运行了5ms的时间片之后，则更新为vruntime += 5ms。CFS在下次调度时，选择vruntime值最小的任务来调度，比如：\n那CFS应该什么时候进行任务切换呢？切换得频繁些，任务的调度会更加的公平，但是上下文切换带来的消耗也越大。因此，CFS给用户提供了个可配参数sched_latency，让用户来决定切换的时机。CFS将每个任务分到的时间片设置为 time_slice = sched_latency / n（n为当前的任务数） ，以确保在sched_latency周期内，各任务能够均分CPU，保证公平性。\n比如将sched_latency设置为48ms，当前有4个任务A、B、C和D，那么每个任务分到的时间片为12ms；后面C和D结束之后，A和B分到的时间片也更新为24ms：\n从上述原理上看，在sched_latency 不变的情况下，随着系统任务数的增加，每个任务分到的时间片也随之减少，任务切换所带来的消耗也会增大。为了避免过多的任务切换消耗，CFS提供了可配参数min_granularity来设置任务的最小时间片。比如sched_latency设置为48ms，min_granularity设置为 6ms，那么即使当前任务数有12，每个任务数分到的时间片也是6ms，而不是4ms。\n给任务分配权重 有时候，我们希望给系统中某个重要的业务进程多分配些时间片，而其他不重要的进程则少分配些时间片。但按照上一节介绍的基本原理，使用CFS调度时，每个任务都是均分CPU的，有没有办法可以做到这一点呢？\n可以给任务分配权重，让权重高的任务更多的CPU！\n加上权重机制后，任务时间片的计算方式变成了这样： $$ time_slice_{k}=\\frac{weight_k}{\\sum_{i=0}^{n-1}weight_{i}}*sched_latency $$ 比如，sched_latency还是设置为48ms，现有A和B两个任务，A的权重设置为1024，B的权重设置为3072，按照上述的公式，A的时间片是12ms，B的时间片是36ms。\n从上一节可知，CFS每次选取vruntime值最小的任务来调度，而每次调度完成后，vruntime的计算规则为vruntime += runtime，因此仅仅改变时间片的计算规则不会生效，还需将vruntime的计算规则调整为： $$ vruntime_{i}=vruntime_{i}+\\frac{weight_{0}}{weight_{i}}*runtime_{i} $$ 还是前面的例子，假设A和B都没有I/O操作，更新vruntime计算规则后，调度情况如下，任务B比任务A能够分得更多的CPU了。\n使用红黑树提升vruntime查找效率 CFS每次切换任务时，都会选取vruntime值最小的任务来调度，因此需要它有个数据结构来存储各个任务及其vruntime信息。\n最直观的当然就是选取一个有序列表来存储这些信息，列表按照vruntime排序。这样在切换任务时，CFS只需获取列表头的任务即可，时间复杂度为O(1)。比如当前有10个任务，vruntime保存为有序链表[1, 5, 9, 10, 14, 18, 17, 21, 22, 24]，但是每次插入或删除任务时，时间复杂度会是O(N)，而且耗时随着任务数的增多而线性增长！\n为了兼顾查询、插入、删除的效率，CFS使用红黑树来保存任务和vruntime信息，这样，查询、插入、删除操作的复杂度变成了log(N)，并不会随着任务数的增多而线性增长，极大提升了效率。\n另外，为了提升存储效率，CFS在红黑树中只保存了处于Running状态的任务的信息。\n应对I/O与休眠 每次都选取vruntime值最小的任务来调度这种策略，也会存在任务饿死的问题。考虑有A和B两个任务，时间片为1s，起初A和B均分CPU轮流运行，在某次调度后，B进入了休眠，假设休眠了10s。等B醒来后，$vruntime_{B}$就会比$vruntime_{A}$小10s，在接下来的10s中，B将会一直被调度，从而任务A出现了饿死现象。\n为了解决该问题，CFS规定当任务从休眠或I/O中返回时，该任务的vruntime会被设置为当前红黑树中的最小vruntime值。上述例子，B从休眠中醒来后，$vruntime_{B}$会被设置为11，因此也就不会饿死任务A了。\n 这种做法其实也存在瑕疵，如果任务的休眠时间很短，那么它醒来后依旧是优先调度，这对于其他任务来说是不公平的。\n 写在最后 本文花了很长的篇幅讲解了几种常见CPU调度算法的原理，每种算法都有各自的优缺点，并不存在一种完美的调度策略。在应用中，我们需要根据实际的工作负载，选取合适的调度算法，配置合理的调度参数，权衡周转时间和响应时间、任务公平和切换消耗。这些都应验了《Fundamentals of Software Architecture》中的那句名言：Everything in software architecture is a trade-off.\n本文中描述的调度算法都是基于单核处理器进行分析的，而多核处理器上的调度算法要比这复杂很多，比如需要考虑处理器之间共享数据同步、缓存亲和性等，但本质原理依然离不开本文所描述的几种基础调度算法。\n 参考\n Operating Systems: Three Easy Pieces, Remzi H Arpaci-Dusseau / Andrea C Arpaci-Dusseau 计算机系统基础(三)：异常、中断和输入/输出, 袁春风 南京大学  更多文章请关注微信公众号：元闰子的邀请\n ","date":"2021-07-25T00:00:00Z","permalink":"https://yrunz.com/p/%E6%8E%A2%E7%B4%A2cpu%E7%9A%84%E8%B0%83%E5%BA%A6%E5%8E%9F%E7%90%86/","title":"探索CPU的调度原理"},{"content":"《认知觉醒》是前些日子在微信读书上无意中点开，然后利用碎片时间读完的一本关于个人成长的书籍。作者周岭结合自己的成长经历，对现代年轻人普遍的成长困扰给出了自己的一些看法，并提出了能够帮助大家正确、高效地成长的一些实用方法。\n以前读过的关于个人成长的书籍还有李笑来的《财富自由之路》，《认知觉醒》的不同之处在于它更加的接地气。书中提到的很多高效成长的方法，在我看来，绝大部分人都可以做到。其中的一些方法，本人也一直在使用，效果确实很明显，这本书值得大家去翻一翻。\n关键是“专注” 在这个信息爆炸的年代，专注一件事情变得越来越困难。古人总说“一心不能二用”，但现在的人似乎总是在追求着“一心二用”。在跑步时听音乐、在吃饭时看视频、在走路时听书、在睡觉时思绪万千\u0026hellip; 我们追求时间利用率的最大化，却也导致了行为与感受的分离。\n”一心二用“这一习惯一旦养成，对个人成长必然会造成巨大的影响。个人的成长主要靠学习，科学的学习模式应该是极度的专注 + 适度的休息，如果我们没法专注，学习的效率急速下降，很多时候甚至是白忙活了。\n所以，要想更加正确、高效地成长，学会“专注”是关键。在跑步时，我们可以试着感受抬腿摆臂和迎面微风；在吃饭时，可以试着感受饭菜的酸甜苦辣；在睡觉时，可以试着感受身体的紧张与松弛\u0026hellip; 试着在生活的每一件小事情上养成“专注”的习惯。\n善于刻意练习与关联学习 人的天性就是避祸就福，我们总是习惯于待在舒适区，对舒适区之外的东西避而不谈。然而，成长就是不断扩大舒适区的过程。并不是说你在舒适区里待得足够够久了，它就会扩大；相反，你需要不断的走出舒适区。就好比你练习了1000遍的C和弦，却也照样学不会大横按。\n走出舒适区的一个关键点是刻意学习，找到我们的知识薄弱点，不断地进行补充，反复地练习。\n 比如，当你发现操作系统的基础知识不够牢固，那么就去找一本经典的操作系统书籍，仔细地阅读，认真地对待书上的每一道习题，以此达到查漏补缺的效果。\n 《认知觉醒》把成长区域分成了舒适区、拉伸区和困难区3部分，长期处于拉伸区才可以更快地进步。我们常犯的一个错误就是，从舒适区直接跳到了困难区，导致很容易受挫。\n 比如你发现操作系统的基础知识不够牢固之后，马上就去阅读起了Linux的内核源码，如此一来，估计要不了几天，就坚持不下去了。\n 除了刻意练习，学会关联学习也很重要，也就是我们常说的“举一反三”。关联学习除了可以加深对当前知识点的印象之外，更重要的是可以找到知识的本质原理。很多知识都是相通的，当你把一个知识的本质原理弄明白之后，就会达到所谓融会贯通的效果了。\n 比如你学习七层网络模型之后，记住了在这个模型中，网络分成了物理层、数据链路层、网络层、传输层、会话层、表示层、应用层等七层，但这也就仅仅记住了而已。\n如果你学会了关联学习，就会开始联想在软件计算机领域还有哪些地方用到了类似的模型。接着，你很快就发现了Web应用中经典的三层架构；领域驱动设计中的领域层、应用层、接口层、基础设施层；还有前台、中台和后台的划分等等，这些其实都运用了同一个架构模式，分层架构。在分层架构中，每一层都只专注于自己的职责，也就是软件设计领域的一个重要原则：单一职责原则。\n如此一来，以前学到的各种知识点，一下子就都融会贯通了起来。\n 学会反思 成长也是一个不断产生BUG，然后修复BUG的过程。现代的软件开发追求小步快跑、敏捷迭代，快速发现问题，然后解决问题。人的成长也一样需要敏捷迭代。我们每天都会犯一些错误（BUG），如果我们放任这些BUG不管，终有一天它们会酿成事故，在最关键的时刻，给你最致命的一击。\n所以，我们要学会每日反思，复盘产生BUG的原因，探讨解决BUG的措施。方法也很简单，找一个可以记录文字的本子或软件，比如OneNote，每天晚上睡前按照如下的模板进行反思：\n 1、描述经历 —— 以便日后回顾时能想起当时的场景。\n2、分析原因 —— 多问几个为什么，直到有深度的启发。\n3、改进措施 —— 尽可能提炼出一个认知点或行动点。\n 只有正视我们自身的问题，不害怕，不逃避，并纠正之，不重犯，我们才能更快速地成长。\n制定每日计划 制定每日计划很重要，它可以避免我们每天都像无头苍蝇一样迷失在茫茫的信息洪流之中。但计划有时候也会有副作用，当因为事情冲突而没完成当天计划时，可能会心生愧疚。久而久之，也就觉得没必要做计划了，因为做了也没法顺利完成。\n其实，我们完全可以把计划当成是一种提示工具，而不是必须完成的任务。计划的作用只是在完成一件事情之后，提示我们还有下一件事要做，而不至于无所事事。至于今天列出的计划，有多少完成了，有多少还未完成，这些都不是很重要。\n每日计划的模板不用很复杂，能够简单记录每天要做的事情即可，比如书中给出的模板就挺好。\n早冥读写跑，人生五件套 早起。早起可以让一天的时间更加的充实。如今，大部分的程序员晚上的时间基本都被工作填满，长期下去，个人成长速度也会放缓。而早起能够改变这一现状。曾国藩曾说过，“少睡多做，一人之生气”，每天早起2小时进行充电，一年下来就多了接近100个小时的时间，足以让一个人得到快速成长。\n冥想。冥想在最近几年变得流行起来，大家都在鼓吹冥想的好处，可以提升脑力，深度休息等等。在我看来，冥想最重要的是可以锻炼自己的专注力，磨练自己的耐心。当你能够沉住气在10分钟内什么也不想时，这份专注和耐心也足够强大了。\n读书。书是人类进步的阶梯，读书就是跟智者的一次面对面交流，它是获得知识的最廉价的方法。但前提是要学会辨别好书，豆瓣读书上的评分是一个很好的参考方式，挑那些经受过时间考验的经典书籍来读，克服英文阅读的障碍。\n写作。只读不写的学习很难把他人的知识转变成自己的知识，要学会使用自己的语言重新组织学到的知识。当你能够教授别人时，这份知识才真正成为你知识体系中的一部分。另外，不要把取悦读者作为写作的目的，要秉着自我提升的目的去写作，不断提升自己的知识总结能力，磨练自己的写作技巧。\n跑步。脑力锻炼的同时也不能忽视体力锻炼，适当的运动除了能够让身体更健康，还能让大脑得到放松，为接下来的工作学习做好准备。\n写在最后 《认知觉醒》这本书中有提到，评判一本书的好坏，关键点在于它有没有对你有所触动。对我来说，这本书做到了，其中的一些观点确实可以引发我的共鸣。书中提出了很多帮助个人成长的方法，但如果你仅仅是知道了这些知识点，而没有去行动落实，最终的结果终究还是“懂得很多大道理，却仍然过不好这一生”。所以，成长没有捷径，还得脚踏实地，一步一个脚印。\n","date":"2021-06-14T00:00:00Z","permalink":"https://yrunz.com/p/%E8%AE%A4%E7%9F%A5%E8%A7%89%E9%86%92%E7%9A%84%E8%AF%BB%E5%90%8E%E6%84%9F/","title":"《认知觉醒》的读后感"},{"content":"进行中 《打开：周濂的 100 堂西方哲学课》，70%，了解西方哲学史，学习大师的思考方式，从而认识你自己。\n《威尼斯日记》，30%，今年发现的，有“作家中的作家”之称的，阿城。文字简洁干练，非常值得学习。\n挂起中 《刘擎西方现代思想讲义》，50%，了解伟大的哲学家们的思想。\n已完成 《邓小平时代》，100%，读完本书，一方面被邓小平的坚韧、格局、领导力所屈服，另一方面也看到了我党一路走来也是在不断地犯错-反思-改正-进步。恰逢2021年是我党建党100周年，这一年里也看了很多关于党史的影视节目，对我党历史也有了更深刻的了解。从觉醒年代，到北伐战争，然后长征、抗日、大决战、抗美援朝、文革、改革开放，一路走来，我党在黑暗中不断摸索前行，磕磕碰碰，逐渐找到一条通往光明的道路。\n《吴军阅读与写作讲义》，100%，强调通识教育，特别是大语文的重要性​，教你如何更好地阅读和写作。但大多理论和方法都是点到为止，干货不多，但也有不少触动的地方。\n《认知觉醒》，100%，早冥读写跑，人生五件套；做每一件事情都要专注事情本身；坚持每日反思，每日计划。这些都是我对这本书最深刻的触动，正如作者所说，如果对一本书哪怕只有一点深刻的触动，那这本书就没白读了。\n《写给大家看的设计书》，100%，写给小白看的设计入门书籍，作者提出了设计的4大基本原则：亲密性、对齐、重复、对比，对于写PPT来说非常受用。\n《人生的智慧》，100%，只有健康的体魄，再加上平静的心境，才能让我们幸福的过完这一生。\n《艺术的故事》，100%，从史前时代开始讲起，介绍世界各地的各种艺术和艺术家们的故事。这本书一定要买实体书看，在微信读书上无法体会到艺术带给人的震撼。\n《非暴力沟通》，100%，记住书中介绍几种沟通技巧，观察、感受、需要、请求，并在与他人交流的时候提醒自己使用它们，有利于改善与他人的关系。\n《影响力》，100%，初看书名以为是管理学的书，读了才发现原来是一本社会心理学的书，通过很多实际的例子，讲述如何利用各种影响力来达成目标。\n《万历十五年》，100%，读完印象最深的是，万历皇帝一开始也是想励精图治，随之却被各种事情所打击，最后变成了无为而治。在怒其不争的同时，也深深体会到他身为人皇的不容易。\n《文心：不一样的国文课》，100%，跟着书中的王先生重新上了堂国文课，极大的提升了我对读书和写作的兴趣。\n《语言学的邀请》，100%，来自西方的语言学家的著作，作者知识面很广，在书中融入了大量其他领域的知识来介绍语言学，读完会发现，原来人类的语言竟是如此有趣。\n《朝花夕拾》，100%，读完《文心》，马上又找了鲁迅先生的这本散文集。鲁迅先生用平凡的语言描绘了他的少年往事，很温馨。读到有趣之处，不自觉也露出了笑容。\n《富兰克林自传》，100%，富兰克林是查理·芒格的偶像，从一个普通家庭的小孩，通过自学，逐渐成长为美国的开国元勋，而且还是一个科学家！读完这本书，像是经历了一遍他那传奇的一生。\n《大秦帝国：黑色裂变》，100%，描写了秦孝公和商鞅对秦国进行变法的那段历史，在作者的笔下，春秋战国变成了一个让人无比向往的时代。读完，让人有种想穿越回到那伟大的时代的冲动。\n《如果这是宋史》，100%，整个系列有5册，类似于《明朝那些事儿》的风格，但是文笔略逊一筹，可以当作了解宋朝历史的入门读物。\n《财富自由之路》，100%，刚开始以为是一本讲投资的书，读了之后发现原来是一本讲如何学习、如何提升自己的书。书中的一些观点和《穷查理宝典》中的很类似，获益良多。\n《原则》，对冲基金公司桥水创始人的人生经验之作，分成传记、生活原则、工作原则三部分。其中传记部分最为精彩，对于 “保持极度开放的头脑” 和 “保持极度求真” 这两个观点印象深刻。\n《曾国藩传》，100%，传奇的曾国藩的一生。\n《曾国藩家书》，100%，对我影响很大的一本书，对书中“少睡多做，一人之生气也”印象深刻。\n计划中 《Merriam-Webster\u0026rsquo;s Vocabulary Builder》\n《Word Power Made Easy : The Complete Handbook for Building a Superior Vocabulary》\n《老子道德经注》\n《周易大传今注》\n《心理学与生活》\n《与社会学同游》\n《乌合之众》\n《大问题：简明哲学导论》\n《乡土中国》\n《荷塘月色》\n《美的历程》\n《On Writing Well》\n《棋王》\n","date":"2021-06-05T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BA%BA%E6%96%87%E7%A4%BE%E7%A7%91%E7%B1%BB%E4%B9%A6%E7%B1%8D/","title":"人文社科类书籍"},{"content":"进行中 已完成 《人体简史：你的身体30亿岁了》，100%，了解人类身体的结构，读到最后一章介绍人体死亡后的变化，不免感慨万分。人类真的很渺小，最后终究会是宇宙的一粒尘埃。\n《基因之河》，100%，从基因的角度讲述生命的进化。读完之后，并没有很深刻的印象，还是觉得前些年读的《基因组：人种自传23章》更加好。\n《万物发明指南》，100%，以一种独特的穿越视角来介绍人类文明历史上各种重要的发明，非常的有趣易读。\n《病者生存：疾病如何延续人类寿命》，100%，颠覆常识，刷新认知的一本书，原来人类的很多疾病也帮助人类在特定的时期渡过了一个又一个的难关。\n《消失的微生物》，100%，原来微生物也可以像基因一样在世代中遗传，只是现代人所追求的“无菌”让人体内微生物多样性逐渐缺失，也间接导致了很多现代疾病的盛行。看完这本书最大的感受就是，以后要好好善待我们身体里的这群小朋友了。\n《上帝掷骰子吗？》，100%，读完此书，在了解到神奇的量子世界的同时，也如同亲身经历了那个星光璀璨的伟大时代。科学史上的乌云和暴雨、追逐流星的辉光、重重的迷雾和险滩，感同身受。\n《极简宇宙史》，100%，极大地扩展了我的宇宙观，读完会让你觉得人类太过渺小，这个世界太过美妙，世间万物有太多值得我们去探索、去求真的规律。如果早些年读到，说不定就选择读物理专业了。\n《物种起源》，100%，整本书更像是一篇长长的论文，作者对物种起源的论证实在是太过严谨了，以至于读起来略显枯燥。\n《走进奇妙的元素周期表》，100%，高中时死记硬背的元素周期表，其中的元素性质早已忘光了。但在作者的笔下，元素竟变得如此的有趣，而且充满了规律，再次体会到了这世界的奇妙。\n《随机漫步的傻瓜》，100%，这个世界充满着随机性，就像书中所说 “你的成功不见得是因为比其他人高明，而很可能是运气的结果。”。这本书可以让你认识到身边的事情多多少少都有些随机成分，并需要对“黑天鹅”事件时刻保持警惕。\n《妙趣横生博弈论》，100%，这本书通过现实事例来阐述博弈论，看完虽说对博弈论还是一知半解，但明白了这世上很多现象原来是博弈的结果。\n《系统之美：决策者的系统思考》，100%，这本书读起来很有亲切感，因为几乎整本书都是在讲述“反馈”。对于学控制专业的人而言，这是再熟悉不过的概念了。反馈，真的是无处不在。\n《失控：全人类的最终命运和结局》，《科技想要什么》，《新经济、新规则》，100%，凯文·凯利的三部曲，对未来的预言。\n计划中 《万物简史》\n《星际航行概论》\n《暗淡蓝点：探寻人类的太空家园》\n《改变世界的方程：牛顿、爱因斯坦和相对论》\n《意识探秘：意识的神经生物学研究》\n《演化：跨越40亿年的生命记录》\n《超越时空：通过平行宇宙、时间卷曲和第十维度的科学之旅》\n《天体物理概率论》\n","date":"2021-06-05T00:00:00Z","permalink":"https://yrunz.com/p/%E7%A7%91%E6%99%AE%E7%B1%BB%E4%B9%A6%E7%B1%8D/","title":"科普类书籍"},{"content":"进行中 《价值投资实战手册》，60%，唐朝对他的投资体系的回顾与总结。\n已完成 《穷查理宝典》，100%，查理·芒格的个人传记，被他那百科全书般的知识所折服，更深受其终身学习、时刻保持求知欲望的精神所影响。\n《世界上最简单的会计书》，100%，确实可以称得上世界上最简单的会计书，通过案例来解释会计学的种种概念，小白入门会计学的力荐之作。\n《富爸爸 穷爸爸》，100%，收获比想象中要多，有助于学会如何管理自己的财务。\n《手把手教你读财报》，100%，唐朝的力作，从财报中看一家公司的优秀与否。\n《聪明的投资者》，100%，学习投资理财的第一本书籍。\n《股票大作手回忆录》，100%，天才的回忆录。\n计划中 《彼得·林奇的成功投资》\n《巴芒演义》\n《投资最重要的事》\n","date":"2021-06-05T00:00:00Z","permalink":"https://yrunz.com/p/%E7%BB%8F%E6%B5%8E%E6%8A%95%E8%B5%84%E7%B1%BB%E4%B9%A6%E7%B1%8D/","title":"经济投资类书籍"},{"content":"进行中 《Database System Concepts(7th Edition)》，10%，数据库必读书籍，相比第 6 版增加了很多新内容，结合 CMU 的公开课，很棒。\n《Spark SQL 内核剖析》，5%，作为阅读 Spark SQL 源码的引导还是不错的。\n挂起中 《Fundamentals of Software Architecture》，80%，架构师的入门指南，书中介绍各种常用的架构模式，教你如何设计一个好的软件系统，而且也会教你如何成为一个优秀的Tech Leader。\n《Designing Data-Intensive Applications》，90%，值得时常温习的一本后端技术大全，每读一章都醍醐灌顶。\n《The Programmer’s Brain》：20%， 从认知科学的角度教你如何更好地阅读代码、理解代码、编写代码，对于程序员新手来说值得一读。书中提到的很多学习程序的方法，感觉就是将有经验的程序员的一些学习方法，结合认知科学的理论更好地呈现了出来。\n《Operating Systems: Three Easy Pieces》，90%，巩固一下操作系统基础，这本书将操作系统的知识点讲得非常清晰易懂。\n《Database Internals：A deep-dive into how distributed data systems work》，5%，复习一下数据库知识。\n已完成 《大数据处理框架 Apache Spark 设计与实现》，100%，将 Spark 的一些核心流程介绍得很易懂，而且有很多图片示范，但是还不够深入，没有结合源码来介绍。\n《Trino - The Definitive Guide》，100%，入门 Presto / Trino 的不错书籍，对架构、核心流程介绍的比官方文档要详细些。但是也不够深入，主要面向的是框架用户，而不是框架内核开发。\n《凤凰架构》，100%，周志明老师的又一力作，从架构的视角介绍了如何构建大型分布式系统，全书的话题涉猎很广，基本把分布式系统所能涉及的知识点都讲解了，比如远程服务调用、事务、安全、一致性等。书中罗列了很多参考阅读材料，可作为进阶架构师的一本导航书，如果把其中提到的知识点都研究透，那么可以就成为专家了。\n《软件架构设计：大型网站技术架构与业务架构融合之道》，100%，可以作为架构设计的入门书籍，知识面很广，但是不够深入。\n《Microservices vs. Service-Oriented Architecture》，100%，比较微服务架构和面向服务架构的异同点。\n《Go: Design Patterns for Real-World Projects》，100%，主要通过TDD的流程来讲解如何使用Go实现经典的23种设计模式，其中也介绍了Go的一些基础知识。书的整体水准一般，但也算是开卷有益。\n《Go语言程序设计语言》，100%，Go语言圣经，学习Go语言的必读之书，也是需要时常温习的一本经典书籍。\n《On Java 8》，100%，《Java编程思想》的Java 8版本，全面而详细地介绍了Java 8的各种基础知识。即使已经使用Java两年了，读这本书的过程中的还是获益匪浅，仔细阅读每一句话，会有意想不到的收获。\n《Java 8 实战》，100%，介绍了Java 8的各种新特性，新的日期API、Stream、Optional等都非常地好用，读完这本书，可以让你写出可读性更好的Java 8风格代码。\n《A Philosophy of Software Design》，100%，介绍降低软件复杂性的一些很实用的方法，比如写好代码注释，做好接口设计等。\n《微服务架构设计模式》，100%，深入浅出地把微服务的架构设计、开发、测试和发布运维都介绍了一遍，而且提供了很多例子，有助于加深理解微服务的各种概念。\n《实践领域驱动设计》，100%，对我影响很深的一本技术类书籍，对提升软件设计能力、写出优雅代码很有帮助。相比于Eric Evans的那本《领域驱动设计》，该书结合了大量的例子，让DDD中的各种概念更容易理解些。\n《领域驱动设计：软件核心复杂性应对之道》，100%，领域驱动设计的开山之作，内容比较偏理论。初读晦涩难懂，只依稀记得几个概念，再读时，对里面的一些概念也就理解清晰了许多。\n《架构整洁之道》，100%，《代码整洁之道》作者的又一力作，涵盖软件研发完整过程及所有核心架构模式。\n《Streaming Systems》，100%，目前流式计算框架五花八门，这本书以Apache Beam框架为例子，介绍了流式系统的一些通用概念。先了解Apache Beam，再来读这本书，里面的概念更容易理解些。\n《企业IT架构转型之道：阿里巴巴中台战略思想与架构实践》，100%，介绍阿里巴巴架构的演变过程，帮助了解时下热门的 “中台“ 概念的由来和含义。\n《聊聊“架构”》，100%，与“架构”相关的概念居多，没有太多的干货。\n《重构：改善既有代码的设计》，100%，深入浅出介绍了重构的各种常见方法，对写好代码很有帮助。\n《HBase不睡觉书》，100%，介绍了HBase的架构原理和用法，适合入门，相对于《HBase权威指南》，这本书确实不容易让人睡觉。\n《Apache Kafka源码剖析》，100%，介绍Kafka的设计思想和实现，对Kafka的使用者而言，很值得一读。\n《构建之法》，100%，介绍软件工程的一些概念，对技术管理很有帮助。\n《Redis设计与实现》，100%，介绍Redis的设计原理和数据结构。\n《MySQL技术内幕：InnoDB存储引擎》，100%，介绍InnoDB的技术原理和工作机制，对理解数据库技术很有帮助。\n《编写可读代码的艺术》，100%，提升代码可读性。\n《程序员的自我修养 : 链接、装载与库》，100%，主要讲C/C++运行库的工作原理。\n《STL源码剖析》，100%，侯捷老师是何等的功力深厚，才能讲STL剖析得如此清楚。\n《Effective C++：改善程序与设计的55个具体做法》，100%，可以将C++水平提升一个档次的书籍。\n《C++ Primer》，100%，值得反复翻看的C++经典书籍。\n计划中 《HBase原理与实践》\n《Apache Kafka实战》\n《Go语言笔记》\n《反应式设计模式》\n《Principles of Computer System Design》\n《敏捷整洁之道：回归本源》\n《Transaction Processing》\n《The Garbage Collection Handbook》\n《Category Theory for Programmers》\n《The Effective Engineer：How to Leverage Your Efforts in Software Engineering to Make a Disproportionate and Meaningful Impact》\n《TCP/IP Illustrated, Volume 1: The Protocols》\n《API Design Patterns》\n《Righting Software》\n《Software Architecture: The Hard Parts》\n","date":"2021-06-05T00:00:00Z","permalink":"https://yrunz.com/p/%E8%AE%A1%E7%AE%97%E6%9C%BA%E8%BD%AF%E4%BB%B6%E7%B1%BB%E4%B9%A6%E7%B1%8D/","title":"计算机软件类书籍"},{"content":"前言 对于软件工程师来说，编码能力的重要性自不必说，技术写作的能力也相当重要。一篇好的设计文档能够指导需求的开发测试，提升软件质量；一篇好的用户文档能够帮助用户迅速熟悉软件的使用方法；一篇好的技术博文可以让人耳目一新，受益匪浅；一篇好的经验总结可以让新手们少走弯路。\n技术写作的目的是让读者能够顺利地使用一个软件或理解一项技术或弄懂业务流程。它与创作型写作的最大区别在于，技术写作并非为了取悦读者，而是追求以简洁和精确的文字去阐明事实。\n 一篇好的技术文章应该能够让符合条件的读者，在良好的阅读体验下，理解甚至掌握文章所要传达的内容。\n 技术写作是一项学问，很多同学或多或少都有写些技术文章的想法，但因为缺乏一些基本的写作技巧，常常无从下手。本文将给出一些技术写作的建议，力求让软件工程师们能够写好技术文章，爱上技术写作。\n为什么要技术写作 前言有提到，一篇好的技术文章能够给读者带来众多益处，其实，技术写作也同样能让作者本身收益颇丰。\n提升表达能力 如何组织文章，如何将复杂的技术原理通熟易懂地表达出来，这都非常考验人的表达能力。\n加强架构和逻辑思维 文章如同软件，也有架构和逻辑。博士们的架构和逻辑思维之所以普遍强大，一个很重要原因是他们都经过了严格的论文写作磨练。当我们把文章写好时，架构和逻辑思维会得到增强，软件设计和开发能力也能随之提高。\n加深对技术/业务的理解 想要写好技术文章，必须要熟悉写作的内容，自然地要求作者在写作前做足功课，如此一来也就加深了对相关技术/业务的理解。\n展示自我 通过技术文章来分享知识是一个很好的展示自我的途径，让大家知道你当前熟悉的领域，逐渐扩大自己的影响力，对后续的职业发展可以起到很大的帮助。\n心灵愉悦 在公开平台上发表技术文章，收获很多浏览量，被读者评论，得到读者点赞和收藏，这些都能够让人得到心灵上的愉悦。\n如何开始技术写作 所谓万事开头难，对习惯于与代码打交道的软件工程师来说，要开始与文字打交道的技术写作，很难。相信很多同学都遇到过憋了几个小时都没写出几个字，或者一直在纠结写什么内容的窘境。其实，只要找到一些方法，着手技术写作，并没那么难。\n从记录学习/工作内容开始 可以先从记录日常的学习/工作内容开始，慢慢习惯与文字打交道，此过程重在建立起写作的自信。\n文章长短不重要 不要一开始就想着写出惊骇世俗的文章，成为最出色的技术博主。也并非只有长篇大论才算得上好的技术文章，一些问题解决记录、经验总结的短文也能给读者很大的帮助。\n通过学习找到写作灵感 如果你还在为要写什么内容而焦头烂额，那么就去学习一项新的技术或者去阅读一本书吧。最好的写作时机就是刚学到知识的时候，因为这时你很清楚从零到一的过程，这也是你要传递给读者的东西。\n学会做笔记 笔记是很好的写作素材，在日常的工作和学习中多做些笔记，把自己的灵感记录下来，后面写作起来也会轻松许多。\n写作的三部曲 第一步：立下写作目标 写作的第一步是立下目标，明确要写哪一类的文章，并朝着目标去写作。比如，立下了介绍Java中HashMap数据类型的目标，就不要在文章上描述JVM的垃圾回收原理，这是混淆了写作目标。\n第二步：确定受众读者 写作的第二步是确定受众读者，只写这类读者可以接受的知识。比如，要写一篇题目是《从零开始学习Java语言》的文章，这明显是一篇针对Java初学者的文章，那么就不要在文章里剖析JVM内存管理的实现源码，这是混淆了文章受众。\n第三步：组织文章 组织文章就是根据中心旨意，把要表达的知识串联成一篇条理清晰的文章。很多新手都会面临“心中想法万千，却无从下笔”的困境，这就是缺乏文章组织导致的。《文心》一书中有提到：\n 对于文章的组织，也不妨举出一个总方法来，那就是 ‘回问自己’ 四个大字。\n 我们可以通过“回问自己”的方法来组织文章，以《教你写好代码注释》一文为例：\n\u0026ldquo;是为了要说些什么才写这篇文章的？\u0026rdquo;\n—— 为了总结些写好代码注释的方法。这样文章的中心意旨就明确了。\n\u0026ldquo;中心意旨在我们意念中间是怎么来的？\u0026rdquo;\n—— 读到《A Philosophy of Software Design》一书中关于代码注释的章节深有感触，想分享给大家。这样文章依据的材料范围也就确认了。\n“这个材料可以增加中心意旨的力量吗？”\n—— 书中关于high-level注释和low-level注释的例子可以很好地体现“什么是好的代码注释”这个旨意。这样就可以不断筛选出好的素材，文章的主要内容也就确认了。\n“还有更简练通顺的表达吗？”\n—— 这样写好像更通顺一些。这样经过不断的修正，一篇文章也就出来了。\n一些技术写作建议 1 熟悉写作内容 作为文章的作者，你需要比读者更熟悉写作内容。可以不是相关领域的专家，但至少能够将知识清晰表达出来，并且能够回答大部分读者的问题。这就要求在写作之前，花时间去阅读相关文章、书籍，甚至是请教专家。\n2 写作前先列大纲 文章不仅仅是把内容罗列出来，要注重知识的表达，因此需要一个清晰的文章架构。在写作前先列出大纲，能够帮你理清写作思路，确认内容是否符合逻辑，构建清晰的文章架构。多想想，哪个内容需要先阐述？段落的顺序要怎么排？哪些知识需要更多的解释？哪些点到为止即可？\n3 精简文字 技术写作不是剧本小说，不需要反转曲折的剧情，更不需要含沙射影的表达，它追求的是直接、实用、清晰明了的表达风格。没必要使用过于复杂的文字去描述技术原理，这只会让它们更加难以理解。使用简洁的文字，多用短句，能够让文章可读性更好。\n4 多举例子 避免通篇介绍技术原理的写作，这会让文章过于枯燥，内容也晦涩难懂。要多举例子，它不仅能让技术原理更易懂，也能让主题更加深刻。比如在《教你写好代码注释》中，通过举例正反面的代码注释，更能让读者对好的代码注释产生共鸣。\n5 善用图表 有时候，我们会遇到很难用文字，或需要用很多文字才能描述清楚的东西，比如复杂的业务流程和模块依赖。这时可以使用图表来可视化表达，一张恰当的图表能够清晰地阐明技术原理，胜过千言万语。\n 一些画图软件推荐\n 适合新手：MicroSoft Office PowerPoint、MicroSoft Office Visio、Apple Keynote 在线画图：Draw.io、ProcessOn、Excalidraw 代码绘图：PlantUML 手绘风格：Apple Keynote、Excalidraw 高级玩家：Sketch、Lunacy   6 代码分段 在深度剖析一些技术原理或系统框架时，我们往往都会涉及源码分析，切忌直接在文章中贴上上百行的代码，然后在代码前/后用一段文字对其说明。这会导致读者为了将代码和说明对应上，而频繁上下翻页，严重影响阅读感受。\n 反面教材：\n... inline void* oopDesc::field_base(int offset) const { return (void*)\u0026amp;((char*)this)[offset]; } template \u0026lt;class T\u0026gt; inline T* oopDesc::obj_field_addr(int offset) const { return (T*)field_base(offset); } // 剩余上百行的代码 ... 由上述代码片段可知，每个field在oop中都有一个对应的偏移量（offset），xxx。\n 更好的方法是将代码分段讲解，并在在其中穿插关键注释。\n 正面例子：\n// 获取field前需要先判断是否采用了指针压缩技术，先根据offset调用obj_field_addr // 得到field的地址，然后调用load_decode_heap_oop得到实例 inline oop oopDesc::obj_field(int offset) const { return UseCompressedOops ? load_decode_heap_oop(obj_field_addr\u0026lt;narrowOop\u0026gt;(offset)) : load_decode_heap_oop(obj_field_addr\u0026lt;oop\u0026gt;(offset)); } oopDesc::obj_field方法主要用于xxx。\n// 基础类型特有的实现与obj_field_addr类似，只是转型成特有的基础类型指针 inline jbyte* oopDesc::byte_field_addr(int offset) const { return (jbyte*)field_base(offset); } oopDesc::byte_field_addr主要用于xxx，在xxx时会被调用。\n 7 反复修正 好的文章都是经过反复的修正才诞生，不仅仅局限于错别字和用词的修改，要以读者，甚至是审稿人的视角去审视整篇文章。怎么才能让这段内容表达更清晰？哪些知识需要延展？哪些片段可以裁剪掉？\n养成在发布前仔细阅读一遍文章的习惯，杜绝低级错误，这也是对读者最大的尊重。\n8 做好排版 如同代码需要分段讲解，文字也需要分段，一大段的文字容易让人觉得枯燥。对重点语句/词语需要加粗突出；善用标题、有序/无序列表来罗列知识点，让文章看起来更加清晰。\n推荐使用markdown进行技术写作，它排版简洁美观，语法简单，而且绝大多数的平台都支持markdown格式的文章发布。另外，还可以使用markdown-nice等排版工具自定义文章背景、色彩、字体等，提升阅读观感。\n9 读好的文章 养成读好文章的习惯，学习他们的写作方式，不断提升自己的写作技巧。\n总结 《文心》将文章写作归结为三个阶段：习作、应用和创作。\n 习作只是法则与手腕的练习，应用之作只是对付他人和事务的东西，创作才是发挥自己天分的真成绩。\n 技术写作也一样，第一阶段习作，对应读书笔记、经验分享等总结类文章；第二阶段应用，对应工作中的设计文档、用户文档等指南类文档；第三阶段创作，对应类似《对XXX领域未来发展的看法》的这类文章，这往往也要求作者具备深厚的知识储备。\n这三者之中，最基本、最重要的是习作。只有当习作到了相当的程度，才能有应用，也才能谈得上创作。\n上面介绍了很多写好技术文章的方法，但最简单，也是最有效的方法是：不要停止写作。\n 参考\n 文心, 夏丏尊/叶圣陶 技术文章如何写作才能有较好的阅读体验, 伍迷 Technical Writing: Definition and Observations, Richard Nordquist 15 Tips to Improve Your Technical Writing, TBS Staff Advice for Technical Writing, Chris Coyier Developers: The Why and How to Writing Technical Articles, Goodness Kayode   ","date":"2021-05-02T00:00:00Z","permalink":"https://yrunz.com/p/%E6%95%99%E4%BD%A0%E5%86%99%E5%A5%BD%E6%8A%80%E6%9C%AF%E6%96%87%E7%AB%A0/","title":"教你写好技术文章"},{"content":" 《从分层架构到微服务架构》是一系列介绍《Fundamentals of Software Architecture》中提到的8种架构模式的文章，这里不会事无巨细地介绍所有的细节，而是会挑选其中关键内容，更多详情请阅读原书。\n往期精彩：\n 从分层架构到微服务架构（一） 从分层架构到微服务架构（二）之分层架构 从分层架构到微服务架构（三）之管道架构   前言 微内核架构（Microkernel Architecture），也被称为插件式架构（plug-in architecture），作为一个在几十年前就被创建出来的架构模式，它如今仍然被广泛应用在各个领域中。比如在Web浏览器领域，谷歌的Chrome浏览器之所以被认为功能强大，一个很重要的原因是它有着丰富的插件类型；在开发工具领域，微软的VS Code初始安装后还只是个简单的文本编辑器，但用户可以安装各种插件，从而让它摇身一变成为功能强大的IDE。\nChrome和VS Code都是微内核架构的典型应用例子，它们提供一个具备最基础能力的核心系统，并定义好插件的开发接口。至于需要开发或安装哪种类型的插件，则完全由普通开发者和用户决定，这样的设计让系统具备了极强的可定制化和可扩展能力。\n架构视图 微内核架构由以下两部分组成：核心系统（core system）和插件（plug-in component），将应用系统的业务逻辑拆分成核心系统和插件，能够提供很好的可扩展性和灵活性，极大地方便了后续需求的新增和修改。\n核心系统 核心系统通常只需提供能够支撑整个系统正常运行的基本功能，比如前文所举的VS Code例子，用户初始安装的是VS Code的核心系统，它只是一个提供了打开文件、编辑文件内容和保存文件等基本功能的文本编辑器，其他的扩展功能（如语法检查）都是通过安装插件集成的。将复杂的业务逻辑从核心系统中剥离出来，并通过插件实现，能够提升系统的可扩展性和可维护性。同时，因为复杂的功能都成了互不干扰的插件，系统的可测性也得到了提高。\n考虑现在需要实现一个电子设备回收系统，在回收之前，每种型号的手机设备的回收流程都不一样，那么我们可以这样去实现：\npublic void assessDevice(String deviceID) { if (deviceID.equals(\u0026#34;iPhone6s\u0026#34;)) { assessiPhone6s(); } else if (deviceID.equals(\u0026#34;iPad1\u0026#34;)) assessiPad1(); } else if (deviceID.equals(\u0026#34;Galaxy5\u0026#34;)) assessGalaxy5(); } else ... ... } } 如果我们把assessDevice看成是核心系统，那么后面每次新增一个型号的手机，都需要新增一个if分支，也即对核心系统进行了改动。这样的设计会导致核心系统非常地脆弱，正所谓改的越多，出问题的概率也越大。\n比起这种将所有的可定制业务逻辑放在核心系统上的设计，更好的应该是将它们实现为插件的形式，这样不仅每个设备回收逻辑都解耦了，还提供了强大的可扩展性：添加一个新的回收设备类型，只需新增一种插件即可，核心系统无需变动。\npublic void assessDevice(String deviceID) { String plugin = pluginRegistry.get(deviceID); DevicePlugin devicePlugin = (DevicePlugin)constructor.newInstance(); DevicePlugin.assess(); } 微内核架构在实现时通常都结合了其他架构模式，这主要体现在核心系统的设计上，比如根据具体的业务特点，我们可以将核心系统设计成technically partitioned的分层架构，或者是domain partitioned的模块化架构。\n插件 插件就是一些包含了定制化业务逻辑、扩展功能、附加功能的独立组件，用于扩充核心系统的功能。插件之间是独立的，插件与核心系统之间则一般是“点对点”通信：核心系统通过调用插件提供的接口（比如插件类的方法）使用扩展功能。\n插件可以划分为编译时插件和运行时插件两种类型，前者每次变更都需要重新构建和部署整个系统，但实现较为简单；后者则可以在系统运行时进行插件的新增和删除操作，相对地，实现也较为复杂。\n编译时插件 在编译时插件中，插件通常以package或namespace实现，比如在package中可以以这样的命名规则来区分插件：app.plug-in.\u0026lt;domain\u0026gt;.\u0026lt;context\u0026gt;。\n运行时插件 运行时插件中插件的实现通常是动态库的形式，比如.jar 、.so、.dll文件。在上述的设备回收系统的例子中，每种型号的手机设备回收逻辑包含在一个独立的.jar文件中：\n远端插件 当然，插件和核心系统并非只能通过本地接口调用进行通信，还可以采用REST/消息队列/RPC等方式，这种场景下，插件就变成了一个独立部署的服务。远程插件具备运行时插件的特点，而且能够提供更好的scalability：插件和核心系统甚至都不必使用相同的技术栈实现，只需遵守既定的REST接口即可。\n为了提升系统处理请求的responsiveness，我们还可以将核心系统调用插件的过程实现为异步通信。以前文的电子设备回收系统为例，在异步通信的架构下，系统通过一个线程触发插件启动对某个设备的回收流程。之后，该线程无需一直等待回收结束，它可以去继续回收别的设备。当设备回收结束后，插件会通过异步队列告知核心系统。这样的异步设计可以减少无谓的等待流程，明显改善系统的responsiveness。\n如果涉及到读写数据库，为了能够维持插件的独立性，每个插件最好能够拥有独立的数据库。如果插件间有着无可避免的数据交互，则可以为核心系统配置一个中心数据库，并通过它来进行数据中转。\n插件中心 核心系统在加载插件前，必须得知道当前有哪些可用的插件，以及这些插件在哪里可以获取。这要求系统有一个地方去管理插件，这就是插件中心（plug-in registry）的功能。插件中心类似于服务化架构中服务注册中心的作用，它保存了所有插件的基本信息，包括名称、数据契约、通信协议、加载地址等。\n我们可以简单地将插件中心实现为一个本地的map表，其中key可以是插件名称，value为获取插件的地址：\nMap\u0026lt;String, String\u0026gt; registry = new HashMap\u0026lt;String, String\u0026gt;(); static { //point-to-point access example  registry.put(\u0026#34;iPhone6s\u0026#34;, \u0026#34;Iphone6sPlugin\u0026#34;); //messaging example  registry.put(\u0026#34;iPhone6s\u0026#34;, \u0026#34;iphone6s.queue\u0026#34;); //restful example  registry.put(\u0026#34;iPhone6s\u0026#34;, \u0026#34;https://atlas:443/assess/iphone6s\u0026#34;); } 为了实现一些较为复杂的功能，如插件上下线通知等，我们还可以借助Apache ZooKeeper、ETCD这类的分布式协同系统实现远程插件中心。\n通信契约 通信契约定义了插件与核心系统之间的通信方式、交互行为和数据格式。通信方式可以是本地接口调用、REST、RPC、消息队列等；交互行为则可以理解为插件对核心系统提供的接口，比如本地的函数/方法、REST的URI等；对本地插件而言，数据格式通常是一个类/结构体，对远程插件而言，常用的数据格式有JSON、XML、ProtoBuf等。\n考虑电子设备回收系统的例子，系统有着如下定义的通信契约：\npublic interface AssessmentPlugin { // 回收设备流程 \tpublic AssessmentOutput assess(); // 将该插件注册到插件中心 \tpublic String register(); // 从插件中心去注册 \tpublic String deregister(); } public class AssessmentOutput { // 回收报告，仅仅用于展示结构给用户看，核心系统无需了解该格式 \tpublic String assessmentReport; // 用于标识该设备是否可以在二手市场上重新售卖 \tpublic Boolean resell; // 表示该设备的价值 \tpublic Double value; // 表示推荐的售卖价格 \tpublic Double resellPrice; } 从该契约定义中可以看出，通信方式为本地接口调用（AssessmentPlugin接口）；它有着3个交互行为，assess()为回收设备流程、register()表示将该插件注册到插件中心、deregister表示去注册；数据格式则是AssessmentOutput类，它定义了回收流程的结果。\n架构评分 和之前介绍的分层架构、管道架构一样，微内核架构同样属于单体架构，因此Simplicity和Overall cost是该架构模式主要优势；而Elasticity、Fault tolerance和Scalability是主要劣势。\n另外，微内核架构的Testability、Deployability、Reliability、Modularity之所以能够取得3颗星，得益于不同的功能能够被拆分至独立的插件上，特别地，运行时插件的增删无需重新部署系统。这使得系统能够快速响应需求变更，具备很高的扩展性。比如对于前面的电子设备回收系统，如果需要新增一种新的电子设备回收流程，只需新增一个插件即可；如果某种设备不再需要回收，则去除对应插件即可。\n微内核架构比较特别的一点是，它既可以是technically partitioned，也可以是domain partitioned，这取决于核心系统的实现方式，前文也有介绍。\n总结 Robert C.Martin曾经说过，软件开发技术发展的历史就是一个如何想方设法方便地增加插件，从而构建一个可扩展、可维护的系统架构的故事。在敏捷开发的潮流之下，需求的变更如同家常便饭，系统不应该因为某一部分发生变更从而导致其他不相关的部分出现问题。将系统设计为微内核架构，就等于构建起了一面变更无法逾越的防火墙，插件发生的变更就不会影响系统的核心业务逻辑。\n微内核架构的设计思想，能够极大提升系统的可扩展性和健壮性，在其他的一些软件方法论里，我们也隐约能看到它的影子。比如在领域驱动设计中，领域层就相当于核心系统，它定义了系统的核心业务逻辑；基础设施层则相当于插件，切换不同的基础设施并不会影响系统的业务逻辑，这得益于基础设施层依赖倒置的设计原则。\n当然，作为微内核架构也有着一些缺点，它天然具备了单体架构的一些劣势，比如核心系统作为架构的中心节点并不具备Fault tolerance能力。因此，该架构模式往往被广泛应用于一些着重提供很强的用户定制化功能的小型产品，如VS Code等，它们对系统的Elasticity、Fault tolerance和Scalability并没有很高的要求。\n每种架构模式都有其合适的应用场景，只有熟悉常用的几种架构模式，才能设计出更好的软件系统。下一篇文章，我们将继续介绍面向服务的架构。\n","date":"2021-04-23T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BB%8E%E5%88%86%E5%B1%82%E6%9E%B6%E6%9E%84%E5%88%B0%E5%BE%AE%E6%9C%8D%E5%8A%A1%E6%9E%B6%E6%9E%84%E5%9B%9B%E4%B9%8B%E5%BE%AE%E5%86%85%E6%A0%B8%E6%9E%B6%E6%9E%84/","title":"从分层架构到微服务架构（四）之微内核架构"},{"content":" 《从分层架构到微服务架构》是一系列介绍《Fundamentals of Software Architecture》中提到的8种架构模式的文章，这里不会事无巨细地介绍所有的细节，而是会挑选其中关键内容，更多详情请阅读原书。\n往期精彩：\n 从分层架构到微服务架构（一） 从分层架构到微服务架构（二）之分层架构   前言 管道架构（Pipeline Architecture），通常也被称为管道-过滤器架构（Pipes and Filter Architecture），是最常用的架构模式之一。大部分软件工程师都是通过Unix终端初次接触到该架构模式，Unix终端的Shell语言，对管道-过滤器有着原生的支持。\n比如，现在需要实现这样的一个功能：读取一个文本文件的内容，找到使用频率最高的5个单词，并按照使用频率的大小顺序打印出单词及其使用频率。\n那么，使用Shell可以这样来实现：\ncat content.txt | # step1: 读取文件内容 tr -cs A-Za-z \u0026#39;\\n\u0026#39; | # step2: 将单词按行输出 tr A-Z a-z | # step3: 将所有单词转换为 sort | # step4: 对单词进行排序 uniq -c | # step5: 计算出单词的频率 sort -rn | # step6: 按照频率对单词进行排序 head -n 5 # step7: 获取排序前5的单词 # 输出结果示例： 4 to 4 and 3 the 3 networks 3 linux 这段Shell代码就是一个简单的管道架构实现，其中|表示管道pipe，每一个step就相当于一个过滤器filter。每个filter都将上一个filter的输出结果作为输入数据，对数据进行处理后再将结果输出到管道中。\n除了Shell语言之外，MapReduce也是基于管道架构搭建，其中的map和reduce可以看成是过滤器，只是它们通信的管道为HDFS。\nShell语言和MapReduce编程模型都可以看成是管道架构的low-level实现，当然，它也能应用于higher-level的系统应用上，下面我们来介绍管道架构模式的架构视图。\n架构视图 管道架构由管道pipe和过滤器filter组成：\npipe作为filter之间的数据传输通道，通常都是单向、点对点通信的，这样的设计不仅实现简单，在性能上也能取得较好的效果。另外，pipe上传输的数据并没有统一的格式，每个系统都可以根据自身的特点选择合适的数据结构。\nfilter作为数据处理的组件，通常是无状态的。每个filter都应当只完成一项工作，满足单一职责原则，复杂的工作流应该由多个filter组合而成。一般地，我们将filter分成以下几种类型：\n Producer: 有时候也称为Source，是整个pipeline的start point，负责从数据源中接收数据，并将数据输出到pipe中。 Transformer: 从pipe中接收输入数据，然后对部分或全部数据进按照一定的规则行转换，并将结果输出到pipe中。在函数式编程里，该步骤通常被称为map。 Tester: 从pipe中接收数据，然后对数据进行一些条件判断，并根据判断结果选择是否将数据传递到下游的pipe中。需要注意的是，tester并未对数据进行任何修改。 Consumer: 是整个pipeline的end point，通常将从pipe中读取到的数据持久化到数据库或呈现到用户界面上。  一个系统中可以有多个producer和consumer，比如我们可以同时通过Kafka和REST接口接收输入数据，经过系统的处理后，将结果数据存储到MySQL中，同时也传递一份到数据仓库上用作数据分析。总之，管道架构模式有着很大的灵活性。\n应用例子 管道架构模式被广泛应用在很多应用上，下面我们以一个ETL系统作为例子来理解该模式的运作方式。\n ETL（Extract, Transform, Load）是将业务系统的数据经过抽取、清洗转换之后加载到数据仓库的过程，目的是将企业中的分散、零乱、标准不统一的数据整合到一起，为企业的决策提供分析依据。\n 业务应用系统在运行过程中会产生各种各样的数据输出到kafka中，ETL系统会消费相关数据，并在经过处理后将结果存储到数据库上。在上图的ETL系统里，各个过滤器的作用如下所述：\n Service Info Capture: 订阅kafka的topic，从中消费业务系统产生的数据，然后通过pipe传送到下游filter。 Duration Filter: 判断数据是否与计算服务请求的处理时长（duration）指标相关，是则将数据传递给Duration Calculator，否则传递给Uptime Filter。 Duration Calculator: 计算服务请求的处理时长，并将计算结果传递给Database Output。 Uptime Filter: 判断数据是否与计算系统正常运行时长（uptime）指标相关，是则将数据传递给Uptime Calculator，否则认为数据并非本ETL系统所关系，结束数据流程。 Uptime Calculator: 计算系统正常运行时长，并将结果传递给Database Output。 Database Output: 将数据持久化到MongoDB中。  上述的ETL系统由1个producer filter，2个tester filter，2个transform filter和1个consumer filter组成，主要的数据处理逻辑是计算系统的遥测指标。系统在架构上具有很高的可扩展性，比如后续想要新增一个指标计算，我们可以在Uptime Filter之后加上新的tester和transform，系统原有的指标计算无需改动；又比如系统后续打算用HBase替换MongoDB，那么我们可以新开发一个HBase Output替换掉原有的Database Output，系统的其他流程同样无需改动。\n架构评分 管道架构模式通常被实现为单体架构，同分层架构模式一样，因为单体架构本身的劣势，其在Elasticity、Fault tolerance、Scalability方面都具有很低的评分。Simplicity是管道架构模式的主要优点之一，filter和pipe实现简单，可以快速构建起一个基于管道架构风格的系统，因此也具有很高的Overall cost评分。\n另外，相比于分层架构模式，管道架构模式在Modularity、Evolutionary和Testability上都有着较高的评分，这得益于filter之间的松耦合，我们可以很容易扩展系统的filter，以及对单个filter进行测试。\n总结 本文主要介绍了管道架构模式，它由管道pipe和过滤器filter组成。根据具体的数据处理逻辑，它将filter划分为producer、transformer、tester和consumer四种类型，是一种典型的technical partition软件架构风格。管道架构模式因为其可扩展性很高的特点而被广泛应用，其中不乏有Shell语言这种low-level的实现，也有ETL系统这种high-level的实现。\n虽说该模式通常被实现为单体架构，但也有像MapReduce这种基于分布式系统的编程模式实现，总之，如果你需要为一个数据处理型的系统选型，那么可以认真地考虑是否采用管道架构模式。\n每种架构模式都有其合适的应用场景，只有熟悉常用的几种架构模式，才能设计出更好的软件系统。下一篇文章，我们将继续介绍微内核架构。\n","date":"2021-04-05T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BB%8E%E5%88%86%E5%B1%82%E6%9E%B6%E6%9E%84%E5%88%B0%E5%BE%AE%E6%9C%8D%E5%8A%A1%E6%9E%B6%E6%9E%84%E4%B8%89%E4%B9%8B%E7%AE%A1%E9%81%93%E6%9E%B6%E6%9E%84/","title":"从分层架构到微服务架构（三）之管道架构"},{"content":" 《从分层架构到微服务架构》是一系列介绍《Fundamentals of Software Architecture》中提到的8种架构模式的文章，这里不会事无巨细地介绍所有的细节，而是会挑选其中关键内容，更多详情请阅读原书。\n往期精彩：\n 从分层架构到微服务架构（一）   前言 软件刚出现的时候，还是大型计算机的年代，一个软件系统一般都只会运行在一台机器上。随着软硬件技术的革新，计算机体积和成本逐渐变小，此时工程师们发现一个软件系统只运行在单台机器上会存在各种瓶颈。如果将系统按照功能划分成前端和后端，分别部署在两台服务器上，问题得到了缓解，于是便有了Client/Server架构的出现。\n随后，个人电脑的兴起带动了众多富桌面应用（rich desktop application）的出现，它们基于操作系统上的user interface开发，数据则是存储在单独部署的database server上，通过标准的网络协议进行数据通信。这种Desktop + Database Server的架构和C/S架构一样，同属两层架构（two-tier architecture）。\n随着90年代互联网的迅速崛起，Browser + Web Server + Database Server的组合也渐渐风靡。Browser为表现层，提供用户交互界面；Web Server为业务层，处理具体的业务逻辑；Database Server为数据层，存储系统数据。三个层次各司其职，这也是大家最熟悉的三层架构（three-tier architecture）。\n上述的几种架构模式都属于分层架构（layered architecture）的范畴，分层架构并没有限定一定得有多少个层次，层次的数量可以根据应用场景灵活控制，因此也被称为n-tier architecture。它结构简单，基于此架构进行系统开发成本也很低（很多公司在组织结构上划分为前端工程师、后端工程师、DBA，根据康威定律，这天然就具备了分层架构开发的良好条件），因此它在业界备受欢迎。如果你的团队还不确定选择什么样的架构，又或者为了践行敏捷宣言中的“just starts coding“，那么分层架构会是一个不错的选择。\n架构视图 在分层架构中，组件根据功能被划分在不同的层次上，虽然层次的数量和类型并没有被限制，但大多数的分层架构都由以下4层组成：表现层（presentation）、业务层（business）、持久层（persistence）和数据层（database），如下图所示。在一些简单的系统中，持久层的逻辑（如SQL）被嵌入到业务层中，形成了经典的三层架构；而在一些复杂的系统中，也会根据具体的业务划分为五层甚至更多的层次。\n前文所述的表现层等4个层次都是逻辑的划分方法，在实际部署时，一般会有下图所示的几种部署形态。形态1中，表现层、业务层和持久层为一个部署单元，而数据层则单独部署，具体表现为一个独立部署的数据库或文件系统；形态2中，表现层被分离出单独部署，业务层和持久层组成一个部署单元，数据层依旧是单独部署的数据库或文件系统；形态3中，包括数据层在内的4层全都在同一个部署单元内，常见于业务简单的系统，它们往往使用的是嵌入式数据库或内存数据库。\n分层架构中的每一层都扮演着各自的角色，比如表现层负责处理所有的用户请求和浏览器交互，而业务层则负责执行每次请求下的特定业务逻辑；表现层无需担心从哪里获取用户数据，它只需要将数据以特定的格式在浏览器上显示即可。同样地，业务层也无需关心用户数据从何而来以及如何呈现，它只需从持久层中取出数据，执行特定的业务逻辑（比如聚合数据），然后将结果返回给表现层。\n每一层都是特定行为的抽象，这样的职责划分，使得组织能够快速高效地创建出责任模型，围绕各层打造开发团队。\n层间隔离 分层架构中的每一层可以是封闭的或者开放的，封闭意味着当一个请求自顶向下在层间传递时，它不能跳过任意的一层。比如，当表现层接收到请求之后，它必须先后经过业务层和持久层才能到达数据层，如下图所示。\n对于简单的数据获取类请求，如果让表现层能够直接访问数据层获取数据，无疑是最简单高效的。也即是让业务层和持久层变成开放状态，允许请求在层间传递时跳过此层。那么，究竟是封闭好，还是开放好呢？要解答这个问题，就要回到层间隔离的出发点上。\n所谓的层间隔离，旨在降低一个层次上的变化对其他层次的组件的影响，简单来说，就是每个层次对其他层次的功能知道的越少越好。为了达到层间隔离的目的，就需要将每个层次置为封闭的状态。假设表现层能够直接访问持久层，那么持久层的变化将会直接影响到业务层和表现层，这加剧了层间的耦合，导致系统变化的代价高昂。\n层间隔离可以降低层次变化对系统的影响，凡事没有绝对，在某些的场景，将特定的层次置为开放的状态也不失为一件好事。考虑以下例子，业务层中存在着一些共享组件承载着业务层公共的功能（比如日志类、审计类、日期和字符串工具类等）。现在有一项架构决策要求表现层不能直接访问这些共享组件，但矛盾的是，原则上表现层是可以直接访问业务层的，这种需要违反原则的决策将会很难落地。\n一种解决方法是，新增一个服务层，该层包含了业务层的这些共享组件。因为业务层是关闭的状态，故表现层也就不能访问到这些共享组件了。然而，新增的服务层必须置为开放状态，否则业务层将无法直接访问持久层。新增一个服务层并置为开放状态，这样既落地了架构决策，也不会影响到原有的功能，一举两得。\n注意事项 在使用分层架构时，需要注意以下两点：\n1、做好模块的划分\n为分层架构做好模块划分主要是为后续的架构演进做好准备，比如在业务复杂到一定程度后演进为微服务架构时，各个模块可以很自然地演进为微服务。为此，应该避免出现类的继承层次过深的现象，这会导致代码严重的耦合，不利于后续的架构演进。\n2、避免掉进sinkhole反模式的陷阱\n所谓sinkhole反模式指的是请求只是简单地路过各个层次，并没有做一些业务处理。\n比如，表现层接收到一个获取基本用户数据（姓名、地址等）的请求后将它传递到业务层；然而，业务层并没有做任何的业务处理，直接将请求传递到持久层；持久层也仅仅是构造了一个简单的SQL语句，向数据层查询用户数据；最后，数据按照原路返回到表现层，中途没有经过任何的数据汇聚、转换等操作。\nsinkhole反模式会导致很多不必要的对象实例化开销，从而增大了系统的内存消耗，并且影响了性能。\n然而，一个系统多多少少都会存在一些sinkhole反模式场景，要判断一个系统是否已经彻底掉进sinkhole反模式的陷阱，主要还是看这类业务请求所占的百分比。根据20-80法则，当系统中有超过80%的业务请求是sinkhole类请求时，表示系统已经掉进sinkhole反模式的陷阱，这从侧面也说明该系统已经不再适合分层架构，是时候考虑架构演进了。\n架构评分 从综合得分上看，分层架构的Overall cost和Simplicity得分很高，这很大程度上得益于分层架构本身是单体架构，少了很多分布式系统才有的复杂性。但这样导致Deployability得分很低，因为3行代码的改动就足以造成整个系统的重新部署。Testability得分不高也是这个原因，整系统的重新上线通常都需要将测试用例全部执行一遍，多了不少额外的工作量。\nElasticity、Fault tolerance、Scalability这些都是单体架构天然的劣势，自然地，分层架构在这些方面得分都很低。另外，sinkhole反模式的存在也拉低了分层架构在Performance上的得分。\n总结 分层架构简单而高效，业界已经有很多成熟的应用，对那些项目刚刚起步，架构师们还没想好要采用哪种架构模式的系统而言，这是非常适合的。在实现分层架构时，我们需要合理地设置各个层次的封闭或开放状态，做好层间隔离，同时也要避免掉进sinkhole反模式陷阱。随着业务的不断扩张，分层架构在可维护性、可测试性、可扩展性等上的短板也会逐步被放大，此时就需要考虑往其他架构模式演进了。\n每种架构模式都有其合适的应用场景，只有熟悉常用的几种架构模式，才能设计出更好的软件系统。下一篇文章，我们将继续介绍管道架构。\n","date":"2021-01-30T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BB%8E%E5%88%86%E5%B1%82%E6%9E%B6%E6%9E%84%E5%88%B0%E5%BE%AE%E6%9C%8D%E5%8A%A1%E6%9E%B6%E6%9E%84%E4%BA%8C%E4%B9%8B%E5%88%86%E5%B1%82%E6%9E%B6%E6%9E%84/","title":"从分层架构到微服务架构（二）之分层架构"},{"content":" 《从分层架构到微服务架构》是一系列介绍《Fundamentals of Software Architecture》中提到的8种架构模式的文章，这里不会事无巨细地介绍所有的细节，而是会挑选其中关键内容，更多详情请阅读原书。\n 前言 谈到软件系统设计的方法论，在代码层面，有我们熟悉的23种设计模式（design pattern），对应到架构层面，则有所谓的架构模式（architecture pattern）。它们分别从微观和宏观的角度指导着我们设计出良好的软件系统，因此，作为一个软件工程师，我们不仅要熟悉设计模式，对常见的架构模式也要熟稔于心。正如看到一个设计模式的名字脑里就能浮现出大致的结构图，当我们看到一个架构模式的名字时，也要马上想到对应的架构图及其基本特点。比如，当谈到分层架构时，我们就应该想起它的架构图是怎样的、有哪些出色的架构特征（architecture characteristics）、系统是如何部署的、数据存储的策略是哪种、等等。\n一般地，架构模式大致可以分成两类，单体架构（monolithic architecture）和分布式架构（distributed architecture）。本系列文章将会介绍以下8种常用的架构模式：\n单体架构\n 分层架构（Layered architecture） 管道架构（Pipeline architecture） 微内核架构（Microkernel architecture）  分布式架构\n 基于服务的架构（Service-based architecture） 事件驱动架构（Event-driven architecture） 基于空间的架构（Space-based architecture） 面向服务的架构（Service-oriented architecture） 微服务架构（Microservices architecture）  软件设计中的谬误 在介绍架构模式前，我们先谈谈软件设计中的谬误（fallacy）。所谓谬误，就是在设计软件系统，特别是分布式系统时，我们先入为主地假设它们是正确，但实际上并非如此的一些观念。这些观念都是我们在设计软件时考虑不周的体现。\n谬误1：网络是可靠的 很多软件工程师常常假设网络是可靠的，但实际并非如此。相比20年前，现在的网络会可靠很多，但是仍然具有很大的不确定性。如上图所述，Serivce B可能完全是正常运行的，但是因为网络的问题，Service A发出的请求无法到达Service B。一种更糟糕的场景是，Service B可以收到Service A的请求，并处理了相关的数据，但是网络问题导致了Service A无法收到Service B的响应，从而造成了数据不一致。网络的不可靠也是为什么系统中常常出现服务通信超时、服务熔断等的原因。\n总而言之，如果假设网络是可靠的，那么我们设计出来的软件系统将会是不可靠的。\n谬误2：时延是0 如上图所示，服务内组件间的函数/方法级别的调用，耗时是微妙，甚至是纳秒级别；但是服务间的远程调用（比如REST、消息队列、RPC），耗时会是微秒级别，甚至在异常场景会达到了秒级！在设计系统，特别是分布式系统时，时延是一个无法被忽视的因素，我们必须清楚系统的平均时延，否则设计出来的方案可能根本不可行。比如，假设系统中服务间通信时延为100ms，如果一个请求的调用链涉及到10个服务，那么该请求的时延将会是1000ms！这么高的平均时延对于一般系统来说是完全无法接受的。\n进行系统设计时，考虑平均时延还不够，更重要的是95th和99th百分点。一个系统的平均时延可能仅仅只有数十毫秒，但是95th百分点的时延却达到了数百毫秒，很多时候，这也恰恰成为了拖垮整系统性能的那块“短板”。\n谬误3：带宽是无限的 在单体架构中，业务流程都在单服务内闭环，消耗的带宽很少甚至为0，因此带宽并不是主要关注点。一旦将系统拆分成分布式架构，一个业务流程可能涉及多个服务间的通信，带宽就成了必须考虑的因素。带宽的不足，会导致网络变慢，从而影响系统的时延（谬误2：时延是0）和可靠性（谬误1：网络是可靠的）。\n如上图所示，假设在一个Web系统中，Service A负责处理前端请求，Service B负责管理用户信息（包括姓名、性别、年龄等45个属性）。Service A每处理一个请求都需要向Service B查询用户姓名（200 bytes），而在一次请求中，Service B却返回了用户的所有信息（500 kb）。如果系统每秒处理2000次请求，每次请求消耗500 kb带宽，那么每秒消耗的总带宽会是1 Gb！如果Service B仅仅返回必须的姓名，那么同等条件下，每秒消耗的总带宽仅仅是400 kb。\n此类问题就是所谓的stamp coupling，解决方法也很多，比如在请求中添加属性选择，使用GraphQL替代REST。相比于这些技术手段，更重要的是确定服务间通信所需的最小数据集，并在进行系统设计时将其作为一个重点关注的因素。\n谬误4：网络是安全的 VPN、防火墙等的广泛使用，使得很多工程师在设计系统时忽略了“网络是不安全的”这一重要原则。特别是从单体架构演进到分布式架构以后，系统被攻击的概率将会大大增加。因此，在分布式系统中，每个服务都必须是安全的endpoint，这样才能确保任何未知或恶意的请求都被拦截掉。当然，安全是有代价的，这也是像微服务架构这类细服务粒度的系统，一次业务请求中调用链过长后性能极速下降的重要原因。\n谬误5：网络拓扑一成不变 这里的网络拓扑指的是系统运行时所涉及到的网络设备，包括所有的路由器、防火墙、集线器、交换机等。很多工程师会假设网络拓扑是固定的，然而并非如此。\n假设如下场景，为架构师的你在周一早上回到公司后，发现组内同事都在为系统中所有的服务间通信都在不断出现响应超时现象而抓狂，但奇怪的是周末并没有做服务变更。经过几个小时的攻关后，你发现周一凌晨2点时有过一次网络升级，而恰恰是这次“次要”的网络升级，推翻之前设计系统时的时延假设，从而触发了本次事故。\n因此，软件工程师也需要与网络管理员时常联系，确保在每次网络升级前都明确网络拓扑的变更点，从而做出相应的调整。\n谬误6：只有一个网络管理员 网络管理员往往不止有一个，特别是在“云”时代，数据中心分散在多个地域，理所当然也存在着多个局域网。运行在“云”上的系统很有可能跨越多个数据中心，因此工程师们应当感知各个数据中心的网络管理员对网络的相关操作，提前做出应对措施，避免出现因网络拓扑变更（谬误5：网络拓扑一成不变）而导致的服务通信超时，甚至触发服务熔断。\n谬误7：通信成本为0 这里的通信成本并非指网络时延，而是指每增加一次服务间调用所导致的钱的花销。很多工程师在设计系统时常常忽视掉通信成本，大家都在鼓吹分布式架构相对了单体架构的优越性，却忘记了它带来的服务器、防火墙、网关等硬件的数量增加，这些都是白花花的银子。\n因此，在进行系统设计时，我们也应该将硬件资源和网络拓扑纳入考虑因素。\n谬误8：网络是同质的 很多工程师都会假设网络是同质的，也就是所有的网络设备都来自同一硬件厂商，这当然也是一个谬误。实际上，一个大的通信网络中，硬件设备往往来自于不同的厂商，这得益于网络协议标准的统一。厂商间设备的协作测试毕竟不会太充分，在一些特殊场景下极有可能存在网络丢包，从而影响了网络的可靠性（谬误1：网络是可靠的）、时延（谬误2：时延是0）以及带宽（谬误3：带宽是无限的）。\n一切从“大泥球”开始 “大泥球”架构是著名的反模式架构，最初在1997年由Brian Foote 和 Joseph Yoder提出。在“大泥球”架构里，系统没有进行内部的模块划分，代码耦合严重，调用关系混乱，就像一个大的泥球。如上图所示，每一个点代表一个类，红线则表示类之间的耦合关系。这样的架构对需求变更极不友好，往往牵一发而动全身，而且在部署、可测试性、性能等方面也存在着很多问题。所有的架构师都在极力避免“大泥球”的出现，但很不幸的是，它仍然在实际项目中很常见，特别是项目伊始，代码质量和结构还没被严格管控起来前。\n有反模式的出现，必然就有解决它的方法，这便是架构模式，从下一篇文章开始，我们将逐个介绍常见的8种架构模式。\n总结 跟设计模式类似，架构模式是软件工程师们多年来在架构设计方面的经验总结。每种架构模式并没有绝对的优劣之分，我们不能说微服务架构就一定比单体分层架构优越，它们都有着各自的应用场景。分布式架构比单体架构有着更好的可扩展性、容错性，但也带来了更高的复杂性，比如分布式事务。因此，我们应该熟知各个架构模式的特点，这样才能在特定的业务场景使用合适的架构模式。\n","date":"2021-01-23T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BB%8E%E5%88%86%E5%B1%82%E6%9E%B6%E6%9E%84%E5%88%B0%E5%BE%AE%E6%9C%8D%E5%8A%A1%E6%9E%B6%E6%9E%84%E4%B8%80/","title":"从分层架构到微服务架构（一）"},{"content":"前言 上一篇文章《如何高效编写Go单元测试（一）》主要介绍了如何使用第三方断言库来使Go单元测试的代码更加简洁和具备可读性，本文我们来聊聊单元测试中的“打桩”。\n// 判断一个字符串s是否是回文字符串 func IsPalindrome(s string) bool { for i := range s { if s[i] != s[len(s)-1-i] { return false } } return true } go test框架足以应对像上述IsPalindrome这种简单的方法的单元测试，但是对于一些较为复杂的方法和模块，go test多多少少会显得力不从心。比如当你需要对依赖了很多第三方库或者跟平台、环境强相关的模块进行单元测试时，仅仅使用go test往往不能使测试用例正常的执行结束，更别说达到代码验证的目的了。\n// 判断/opt/container/config.properties文件中是否包含str字符串 func isConfigFileContain(str string) bool { file, err := os.Open(\u0026#34;/opt/container/config.properties\u0026#34;) if err != nil { panic(err) } content, err := ioutil.ReadAll(file) if err != nil { panic(err) } return strings.Contains(string(content), str) } 假设想要对上述isConfigFileContain函数进行单元测试，因为函数本身依赖了程序实际运行环境才有的/opt/container/config.properties文件，所以如果对其进行如下的单元测试，运行结果肯定出错。\nfunc TestIsConfigFileContain(t *testing.T) { ast := assert.New(t) ast.True(isConfigFileContain(\u0026#34;test\u0026#34;)) } 运行结果如下：\n=== RUN TestIsConfigFileContain --- FAIL: TestIsConfigFileContain (0.00s) panic: open /opt/container/config.properties: no such file or directory [recovered] panic: open /opt/container/config.properties: no such file or directory 果不其然，由于单元测试的环境并没有/opt/container/config.properties文件，因此用例执行失败。\n解决该问题的思路有两种，（1）在运行单元测试用例之前先把/opt/container/config.properties文件创建好；（2）使用一种技术手段让测试用例在调用os.Open(\u0026quot;/opt/container/config.properties\u0026quot;)时能够按照我们的预想返回相应的结果。\n这两种思路都属于广义上的“打桩”，那么我们常说的单元测试中的“打桩”究竟是什么？它又是用来解决什么问题的呢？\n什么是“打桩” 在单元测试中，通常可以将所涉及的对象分为两种，主要测试对象和次要测试对象。比如在上述例子中，我们测试的主要测试对象就是IsPalindrome函数，而次要测试对象则是os.Open和ioutil.ReadAll函数。一般地，在测试用例中我们只需关注主要测试对象的行为是否正确。对于次要测试对象，我们通常只会关注主要测试对象和次要测试对象之间的交互，比如是否被调用、调用参数、调用的次数、调用的结果等，至于次要测试对象是如何执行的，这些细节过程我们并不关注。\n因此，在进行单元测试中（特别是次要测试对象需要依赖特定的条件时，比如上述例子中依赖于/opt/container/config.properties文件的存在），我们常常选择使用一个模拟对象来替换次要测试对象，以此来模拟真实场景，对主要测试对象进行测试。而“使用一个模拟对象来替换次要测试对象”这个行为，我们通常称之为“打桩”。因此，“打桩”的作用就是在单元测试中让我们从次要测试对象的繁琐依赖中解脱出来，进而能够聚焦于对主要测试对象的测试上。\nstub与mock的区别 stub和mock是两种最常见的打桩手段，它们都能够用来替换次要测试对象，从而实现对一些复杂依赖的隔离，但是它们在实现和关注点上又有所区别。为了解它们之间的区别，考虑以下一段代码：\n// 邮箱消息 type Message struct {} // 邮箱服务 type MailService interface { Start() Send(msg Message) Stop() } // 订单 type Order struct { mailService MailService } // 创建新订单 func NewOrder(service MailService) *Order { return \u0026amp;Order{mailService:service} } // 进行一些业务操作 func (o *Order) DoSomething() { ... } // 关闭订单时，会发送邮件 func (o *Order) Close() { ... o.mailService.Send(Message{}) } 假设有一个订单业务Order ，它依赖了一个邮箱服务MailService。系统在创建订单之后，执行DoSometing方法进行一些业务操作，并在关闭订单时使用邮箱服务发送一个消息通知用户。现在我们要测试在订单关闭时，是否已经发送邮件。由于在单元测试中，一般不会使用真实的邮箱服务进行邮件发送，因此需要采用打桩技术来隔离邮箱服务的真实行为。下面，我们分别使用stub和mock来完成这一次的打桩。\n如果使用stub来对MailService进行打桩，那么就要进行如下的实现：\ntype StubMailService struct { messages []Message } func (s *StubMailService) Start() { // 空实现 } func (s *StubMailService) Send(msg Message) { s.messages = append(s.messages, msg) } func (s *StubMailService) Stop() { // 空实现 } func (s *StubMailService) SendNum() int { return len(s.messages) } func NewStubMailService() *StubMailService { return \u0026amp;StubMailService{messages:make([]Message, 0)} } 对应的测试用例如下：\nfunc TestOrder_Stub(t *testing.T) { stub := NewStubMailService() order := NewOrder(stub) order.DoSomething() order.Close() assert.Equal(t, 1, stub.SendNum()) } 如果使用mock来对MailService进行打桩（这里我们采用的是go mock框架），则对应的测试用例如下：\nfunc TestOrder_Mock(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mock := NewMockMailService(ctrl) mock.EXPECT().Send(Message{}).Times(1) order := NewOrder(mock) order.DoSomething() order.Close() } 从上述两个测试用例可以看出，在实现上，使用stub进行打桩时，我们需要自己实现MailService接口；而使用mock时，则直接利用框架的能力生成一个实现了MailService接口的mock对象。对比来看，mock更加简单一些，只需关注主要测试方法，也就是所谓的“just enough”；而stub则要实现具体的逻辑，即使是不需要关注的次要测试方法，也需要给出空实现。\nMartin Fowler在《Mocks Aren’t Stubs》一文中将单元测试中的验证（verification）分为state verification和behavior verification两种，stub属于前者，mock则属于后者。正如它们的名称所描述，state verification关注的是测试对象的状态；behavior verification关注的是测试对象的行为。如上述例子，同样是验证Order.DoSomething的逻辑，stub通过记录发送过的消息数量来实现，也即是状态；mock则通过mock.EXPECT().Send(Message{}).Times(1)来验证MailService.Send的调用次数来实现，也即是行为。\nGo单元测试中的“打桩” 为全局变量打桩 全局变量也经常在代码中用到，因为它具有全局性，如果在单元测试用例中对全局变量做了修改，则在用例结束时，需要将其恢复为原来的值，避免对其他用例造成影响，比如：\nfunc TestGlobalVal(t *testing.T) { val1 := global.Val1 // 步骤1：记住原来的值 \tglobal.Val1 = 5 // 步骤2：赋予新的值 \t... // 测试用例代码 \tglobal.Val1 = val1 // 步骤3：恢复原来的值 } 如果测试用例涉及到的全局变量很多，这样就需要对每个全局变量都执行上述3个步骤，显得代码很不简洁：\nfunc TestGlobalVal(t *testing.T) { val1 := global.Val1 val2 := global.Val2 val3 := global.Val3 global.Val1 = 5 global.Val2 = 7 global.Val3 = 6 ... // 测试用例代码 \tglobal.Val1 = val1 global.Val2 = val2 global.Val3 = val3 } 针对全局变量的打桩，gostub为我们提供了一个简洁的实现方式。\n gostub：https://github.com/prashantv/gostub\n 对上述例子，下面使用gostub对其进行优化：\nfunc TestGlobalVal(t *testing.T) { // 对全局变量进行打桩 \tstub := gostub.Stub(\u0026amp;global.Val1, 5). Stub(\u0026amp;global.Val2, 7). Stub(\u0026amp;global.Val3, 6) ... // 测试用例代码，这里使用这3个全局变量时的值分别为5，7，6 \t// 对全局变量进行复原 \tstub.Reset() } 从上述例子来看，使用gostub为全局变量打桩使得代码更加简洁，可读性更好了。\n除了为全局变量打桩，gostub也可以对函数/方法进行打桩，但是其对代码有一定的限制条件，因此易用性并不是十分好。下一节，我们将介绍一个更加好用的函数/方法打桩神器。\n为函数/方法打桩 Go语言中，如果在函数声明时给它加上一个接收者，即将该函数附加到一种类型上，就变成了方法。比如，在前文中func IsPalindrome(s string) bool就是一个典型的函数声明；而func (s *StubMailService) Send(msg Message) 则是一个典型的方法声明。\n在Go单元测试中，用来对函数和方法进行打桩的框架不少，但是就易用性而言，属monkey patch最好。\n monkey patch：https://github.com/bouk/monkey\n 如果直接对前言中的isConfigFileContain函数进行单元测试，因为没有/opt/container/config.properties文件，函数中的os.Open和ioutil.ReadAll都会执行失败。前文有提到，解决方法有两种，一种是创建/opt/container/config.properties文件；另一种，我们可以使用mokey patch实现。只要通过monkey patch对os.Open和ioutil.ReadAll进行打桩即可，测试代码如下：\nfunc TestIsConfigFileContain_MonkeyPatch(t *testing.T) { ast := assert.New(t) // 对os.Open进行打桩，固定返回(\u0026amp;os.File{}, nil) \tmonkey.Patch(os.Open, func(name string) (*os.File, error) { return \u0026amp;os.File{}, nil }) // 对ioutil.ReadAll进行打桩，固定返回([]byte(\u0026#34;test for monkey patch\u0026#34;), nil) \tmonkey.Path(ioutil.ReadAll, func(r io.Reader) ([]byte, error) { return []byte(\u0026#34;test for monkey patch\u0026#34;), nil }) // 测试用例结束时，解除打桩，避免影响其他用例 \tdefer monkey.UnpatchAll() ast.True(isConfigFileContain(\u0026#34;test\u0026#34;)) } monkey patch对函数进行打桩的API很简单，形式如下monkey.Patch(\u0026lt;target function\u0026gt;, \u0026lt;replacement function\u0026gt;)，需要注意的是，在用例结束之后，记得调用monkey.UnpatchAll来解除打桩，避免影响其他用例。\n使用monkey patch对方法的打桩方法与函数的打桩方法稍微有点差异，考虑如下代码：\n// 从远端key-value数据库中获取记录 func GetRecord(key string) (string, error) { cli := db.CreateClient() var val string if err := cli.Get(\u0026#34;key1\u0026#34;, \u0026amp;val); err != nil { return \u0026#34;\u0026#34;, err } return val, nil } 其中db.CreateClient创建一个一个连接远端数据库的客户端db.Client，其定义如下：\npackage db ... // 数据库服务端远程代理，实现db.KvDb接口 type Client struct { // RPC客户端 \tcli *rpc.Client } func (c *Client) Get(key string, reply *string) error { var ret string // 通过RPC调用服务端的接口 \terr := c.cli.Call(\u0026#34;Server.Get\u0026#34;, key, \u0026amp;ret) if err != nil { fmt.Printf(\u0026#34;Call db Server.Get rpc failed, error: %v\u0026#34;, err) *reply = \u0026#34;\u0026#34; return err } *reply = ret return nil } ... // 工厂方法，返回远程代理实例 func CreateClient() *Client { rpcCli, err := rpc.Dial(\u0026#34;tcp\u0026#34;, \u0026#34;192.168.21.34:8088\u0026#34;) if err != nil { fmt.Printf(\u0026#34;Create rpc client failed, error: %v.\u0026#34;, err) return nil } return \u0026amp;Client{cli: rpcCli} } 现在需要对GetRecord进行单元测试，因为在测试环境中并没有远端数据库实例，因此直接执行如下的测试用例，会运行失败：\nfunc TestGetRecord(t *testing.T) { val, err := GetRecord(\u0026#34;key1\u0026#34;) assert.Nil(t, err) assert.Equal(t, \u0026#34;value1\u0026#34;, val) } // 测试结果: === RUN TestGetRecord Create rpc client failed, error: dial tcp 192.168.21.34:8088: connect: connection refused. --- FAIL: TestGetRecord (0.00s) 现在我们使用monkey patch来解决该问题：\nfunc TestGetRecord_MonkeyPatch(t *testing.T) { var c *db.Client // 因为\u0026#34;Client.Get\u0026#34;方法的接收者是指针类型，所以这里必须声明为指针类型 \tmonkey.PatchInstanceMethod(reflect.TypeOf(c), \u0026#34;Get\u0026#34;, func(_ *db.Client, reply *string) error { *reply = \u0026#34;value1\u0026#34; return nil }) defer monkey.UnpatchAll() val, err := GetRecord(\u0026#34;key1\u0026#34;) assert.Nil(t, err) assert.Equal(t, \u0026#34;value1\u0026#34;, val) } // 测试结果: === RUN TestGetRecord --- PASS: TestGetRecord (0.00s) 如代码所述，使用monkey patch为方法进行打桩的用法为monkey.PatchInstanceMethod(\u0026lt;type\u0026gt;, \u0026lt;name\u0026gt;, \u0026lt;replacement\u0026gt;)，其中type通过reflect.TypeOf获得，值得注意的是，type必须跟方法定义的接收者类型一致，如上述代码中，Client.Get方法的接收者是指针类型，因此type必须声明为*Client类型。\n monkey patch并不支持对包私有（首字母小写）的函数/方法进行打桩。因为设计者认为包私有的函数/方法是不稳定的，因此对它们进行打桩可能会导致测试用例代码的频繁更改，得不偿失。\n 为接口打桩 在单元测试中，我们也经常需要对接口interface进行打桩，比如上文所述的订单业务例子就对接口MailService进行了打桩。在该例子中，我们介绍了两种对接口打桩的方式。一是自己创建一个实现了MailService接口的桩对象stub，但是该方法有一定限制，当接口比较复杂或嵌套很多层时，该方式将会变得异常麻烦，考虑如下ComplexItf接口定义：\ntype Itf1 interface { Func1() Func2() } type Itf2 interface { Func3(itf Itf1) Func4() } type Itf3 interface { Itf1 Func5() } type ComplexItf interface { Itf2 Itf3 Func6() Func7() Func8(itf2 Itf2) Func9() Itf1 ... } 对于这么复杂的ComplexItf接口，光是其自身的方法都已经很多，更别提嵌套的Itf2和Itf3了。更好的是采用第二种打桩方法，也就是mock方式，我们通常使用gomock框架来实现。\n gomock：https://github.com/golang/mock\n 使用gomock进行打桩时，我们完全将实现接口这一繁琐的工作交给了框架，比如使用gomock对MailService接口打桩时，在安装好gomock之后，只需执行如下命令，mock对象就会自动生成：\nmockgen -source=yrunz/mail_service.go -destination=yrunz/mock_mail_service.go -package=yrunz 其中-source表示MailService接口所在文件路径，-destination表示生成的mock对象所做的文件路径，-package表示mock对象的包名。生成的mock对象对应的代码如下：\n// Code generated by MockGen. DO NOT EDIT. // Source: yrunz/mail_service.go  // Package yrunz is a generated GoMock package. package yrunz import ( gomock \u0026#34;github.com/golang/mock/gomock\u0026#34; reflect \u0026#34;reflect\u0026#34; ) // MockMailService is a mock of MailService interface type MockMailService struct { ctrl *gomock.Controller recorder *MockMailServiceMockRecorder } // MockMailServiceMockRecorder is the mock recorder for MockMailService type MockMailServiceMockRecorder struct { mock *MockMailService } // NewMockMailService creates a new mock instance func NewMockMailService(ctrl *gomock.Controller) *MockMailService { mock := \u0026amp;MockMailService{ctrl: ctrl} mock.recorder = \u0026amp;MockMailServiceMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use func (m *MockMailService) EXPECT() *MockMailServiceMockRecorder { return m.recorder } // Start mocks base method func (m *MockMailService) Start() { m.ctrl.T.Helper() m.ctrl.Call(m, \u0026#34;Start\u0026#34;) } ... 生成之后就可以使用NewMockMailService工厂方法创建一个mock对象进行单元测试了：\nfunc TestOrder_Mock(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mock := NewMockMailService(ctrl) mock.EXPECT().Send(Message{}).Times(1) order := NewOrder(mock) order.DoSomething() order.Close() } 我们注意到，mockgen工具生成的mock对象都是Mock+接口名的命名形式，并提供了一个New+Mock+接口名命名方式的工厂方法用于创建mock对象，其中工厂方法的入参为*gomock.Controller类型。\n前文有提到，mock属于behavior verification，也就是行为验证方式。实际使用gomock框架时，我们通过调用mock对象的EXPECT设定预期返回值、预期动作以及调用次数验证等。比如上述例子中的mock.EXPECT().Send(Message{}).Times(1)表示mock对象的Send方法在测试用例中应该被调用一次，且入参为Message{}，否则用例验证失败。\ngomock的一些常用的行为验证方法如下：\n .Do()：声明在匹配时要运行的操作。\n.DoAndReturn()：声明在匹配调用时要运行的操作，并模拟返回该函数的返回值。\n.Return()：在匹配调用时模拟返回该函数的返回值。\n.MaxTimes()：设置最大的调用次数\n.MinTimes()：设置最小的调用次数\n.AnyTimes()：运行调用次数为0或更多次。\n.Times()：设置调用次数为n次。\n 如果调用方法的入参不固定，可以使用gomock.Any()进行匹配，比如前面的例子中，可以这样：\nmock.EXPECT().Send(gomock.Any()).Times(1) 常用的参数匹配如下：\n gomock.Any()：匹配任意值\ngomock.Eq()：通过反射匹配到指定的类型\ngomock.Nil()：匹配nil\n 总结 本文主要主要介绍了单元测试中的“打桩”，打桩主要作用是在单元测试中让我们从次要测试对象的繁琐依赖中解脱出来，进而能够聚焦于对主要测试对象的测试上。常见的打桩技术stub和mock也并非完全相同，前者属于state verification，后者属于behavior verification。另外本文还介绍了Go单元测试的几种打桩框架，为全局变量打桩的gostub框架、为函数/方法打桩的monkey patch框架、为接口打桩的go mock框架。熟练地使用这些框架，能够让我们更加高效、优雅地编写Go单元测试。\n","date":"2021-01-10T00:00:00Z","permalink":"https://yrunz.com/p/%E5%A6%82%E4%BD%95%E9%AB%98%E6%95%88%E7%BC%96%E5%86%99go%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95%E4%BA%8C/","title":"如何高效编写Go单元测试（二）"},{"content":"前言 2020年是艰难的一年，对于中国和世界而言，是新冠疫情的卷席全人类；对于个人而言，是工作上的无比繁忙。\n以前一直会开这么一个玩笑，“在我们公司工作，那是一年工作，三年经验”。在工作的前两年，说出这句话更多的是一种自嘲，但在今年，它却是一个真实的写照。回顾2020年自己所做的事情，初略一想，除了工作，貌似也只剩下工作了。年初时定下的一些计划，大部分都因为工作繁忙的缘故而被搁置。在阅读和写作上，梳理了自己今年读过的书，数量还不及去年的三分之一；而写过的文章，更是寥寥无几。特别是在作为主业的软件技术方面，更是没有实质的进步，感觉还是停留在2019年的水平。在这个技术日新月异的行业，不进则退，貌似，今年是失败的一年？\n一直以来都坚信《钢之炼金术师》中等价交换这一原则，既然花了这么多时间在工作上，那必定会有所收获。仔细一想，今年收获最大的，莫过于对意志的磨练。\n身为一名软件工程师，相信大家对技术都有着执着的追求。特别是初入职场，肯定有过把所有相关的技术框架学个遍的想法，唯恐跟不上时代的潮流。如果在工作的第一年问我，“对于一名软件工程师而言，什么是最重要的？”，我会回答，技术；工作的第二年，在学习了一些软件方法论，特别是领域驱动设计之后，结合工作上的项目经验，这才慢慢认识到，软件技术万变不离其宗，业务才是软件的核心。如果还问我同样的问题，我会回答，业务和技术；工作的第三年，在经历了如此艰难的2020年之后，如果再问题同样的问题，我会回答，意志、业务和技术。\n意志 一直以来大概是没有经历过很考验意志的时刻，高中按部就班地学习，然后考上大学；大学也是平平淡淡地度过六年，然后步入职场。连挑灯夜战都没几次，更别说引锥刺股了。工作的前两年虽说也有过曲折，但对比2020年，那都是不值得一提。因为新项目的巨大挑战，从3月初开始，下班的时间点逐渐延后，并在4月份达到了高峰，甚至在4月的最后一周里连睡觉都成了一件奢侈的事情。在整个4月里，为了解决新项目的难题，拼的已经不再是软件技术，更多的是意志力。\n记得有一天晚上为了解决一个问题，已经通宵工作到凌晨6点，却还是连问题原因都没找出来。心情的低落，身体的疲惫，那一刻已经临近崩溃。这时想起《指环王》里面Sam对Frodo说的一番话：\n Frodo: I can’t do this, Sam.\nSam: I know. It’s all wrong. By rights, we shouldn’t even be here. But we are. It’s like in the great stories, Mr. Frodo. The ones that really mattered.\nFull of darkness and danger they were. And sometimes you didn’t want to know the end. Because how could the end be happy? How could the world go back to the way it was when so much bad had happened?\nBut in the end, it’s only a passing thing, this shadow. Even darkness must pass. A new day will come. And when the sun shines, it will shine out the clearer. Those were the stories that stayed with you that meant something. Even if you were too small to understand why. But I think, Mr. Frodo, I do understand. I know now.\nFold in those stories had lots of chances of turning back, only they didn’t. They kept going because they were holding on to something.\nFrodo: What are we holding on to Sam?\nSam: There is some good in this world, Mr. Frodo. And it’s worth fighting for.\n 困难总会过去，只要心怀希望，没有迈不过去的坎。\n随着4月的结束，这一挑战也成功告一段落。当走在去庆祝的路上时，伴随着倾洒的阳光和迎面的微风，嘴角轻轻扬起，那一刻的舒畅，永远也不会忘掉。没有人愿意再重复一次这样的经历，但它却让我们的内心更加的强大了，而我也在这次的经历中收获了意志。\n读书 2020年读的书相比去年少了很多，而且大多都是在零散的时间里阅读的电子版书籍。软件技术类的书基本都是在O\u0026rsquo;Reilly平台上阅读，上面技术类的书籍很全还很新（通过ACM注册会员很划算，转送门：https://zhuanlan.zhihu.com/p/83928266）；其他人文、科普类的都是在微信读书上完成。不得不说，微信读书可以让人充分利用各种碎片时间，最重要的是还能在睡前起到催眠的作用，实在是失眠多梦必备APP。\n软件类              年初转Go开发之后看的第一本Go相关的技术书籍，江湖人称《Go语言圣经》，从基本语法讲到高级特性，细节很丰富，入门必备书籍。 主要通过TDD的流程来讲解如何使用Go实现经典的23种设计模式，其中也介绍了Go的一些基础知识。书的整体水准一般，但也算是开卷有益。       今年读过的最好的技术类书籍，从实现最简单的数据库讲起，深入浅出地介绍了当前大火的各种分布式系统的实现原理。读完之后有种醍醐灌顶的感觉。 可以说是架构师的入门指南，书中不仅介绍了如何设计一个好的软件系统，而且还教你如何成为一个优秀的Tech Leader，非常值得一读。    人文社科类              写给小白看的设计入门书籍，作者提出了设计的4大基本原则：亲密性、对齐、重复、对比，对于写PPT来说非常受用。 很早就听说这本书了，今年读完之后也深有感触，只有健康的体魄，再加上平静的心境，才能让我们幸福的过完这一生。       一直很好奇美学这一门学科讲的是什么，于是就挑了朱光潜大师的这一本《谈美》。看完之后，内容也没记住多少，倒是再看一棵树时，不再只会从科学和实用的角度去看待它，还会提醒自己尝试从美学的角度欣赏它。 从史前时代开始讲起，介绍世界各地的各种艺术和艺术家们的故事。这本书一定要买实体书看，在微信读书上无法体会到艺术带给人的震撼。       也是很出名的一本书了，记住书中介绍几种沟通技巧，观察、感受、需要、请求，并在与他人交流的时候提醒自己使用它们，有利于改善与他人的关系。 初看书名以为是管理学的书，读了才发现原来是一本社会心理学的书，通过很多实际的例子，讲述如何利用各种影响力来达成目标。       读完印象最深的是，万历皇帝一开始也是想励精图治，随之却被各种事情所打击，最后变成了无为而治。在怒其不争的同时，也深深体会到他身为人皇的不容易。     科普类              去年读的《原则》的作者力荐的书籍，从基因的角度讲述生命的进化。读完之后，并没有很深刻的印象，还是觉得前些年读的《基因组：人种自传23章》更加好。 以一种独特的穿越视角来介绍人类文明历史上各种重要的发明，非常的有趣易读。       颠覆常识，刷新认知的一本书，原来人类的很多疾病也帮助人类在特定的时期渡过了一个又一个的难关。 原来微生物也可以像基因一样在世代中遗传，只是现代人所追求的“无菌”让人体内微生物多样性逐渐缺失，也间接导致了很多现代疾病的盛行。看完这本书最大的感受就是，以后要好好善待我们身体里的这群小朋友了。    总结 2020年过得很艰难，但也遇到了很多美好的人和事；虽说工作繁忙，却也磨练了意志。希望2021年里能够克服惰性，保持阅读和写作，也希望生活能对自己更好些。\n","date":"2020-12-31T00:00:00Z","permalink":"https://yrunz.com/p/2020%E5%B9%B4%E7%9A%84%E6%88%90%E9%95%BF%E5%8D%B0%E8%AE%B0/","title":"2020年的成长印记"},{"content":"前言 单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。我们根据它来检验代码的行为是否和预期的一样，如果单元测试不通过，要么代码有bug，要么测试条件输入不正确，总之，需要修复使单元测试能够通过。单元测试一个最大的好处，就是确保一个程序模块的行为符合我们设计的预期，在将来对代码进行修改/重构时，还能最大限度地保证代码的行为仍然正确。\nGo对单元测试的支持已经相当友好了，原生的go test标准库就是专门用来进行单元测试的编写的。使用go test编写单元测试时需要遵循一些约定，比如所有测试代码都需要添加到_test.go结尾的测试文件中，这样在使用go build进行构建时，测试代码才会被排除在外。另外，每个测试函数都必须导入testing包，测试函数的名字必须以Test开头，且跟在Test后面的后缀名必须以大写开头，因此测试函数的声明应该是这样的：\nfunc TestSin(t *testing.T) { /* ... */ } func TestCos(t *testing.T) { /* ... */ } func TestLog(t *testing.T) { /* ... */ } _test.go测试文件通常和需要被测试的文件放在同一个包内，比如有如下的一段待测试的代码（在word包目录下的word.go文件）：\npackage word // 判断一个字符串s是否时回文字符串 func IsPalindrome(s string) bool { for i := range s { if s[i] != s[len(s)-1-i] { return false } } return true } 那么在编写测试时，我们同样在word包目录下，创建一个word_test.go文件，单元测试的代码如下：\npackage word import \u0026#34;testing\u0026#34; func TestPalindrome(t *testing.T) { // \u0026#34;detartrated\u0026#34;是一个回文字符串，因此IsPalindrome(\u0026#34;detartrated\u0026#34;)的返回值应该为true  // 如果返回false，则表示其实现有问题，需要使用t.Error进行错误报告 \tif !IsPalindrome(\u0026#34;detartrated\u0026#34;) { t.Error(`IsPalindrome(\u0026#34;detartrated\u0026#34;) = false`) } if !IsPalindrome(\u0026#34;kayak\u0026#34;) { t.Error(`IsPalindrome(\u0026#34;kayak\u0026#34;) = false`) } } func TestNonPalindrome(t *testing.T) { if IsPalindrome(\u0026#34;palindrome\u0026#34;) { t.Error(`IsPalindrome(\u0026#34;palindrome\u0026#34;) = true`) } } 测试用例成功则打印：\n=== RUN TestPalindrome --- PASS: TestPalindrome (0.00s) PASS 用例执行失败则是：\n=== RUN TestNonPalindrome --- FAIL: TestNonPalindrome (0.00s) nba_test.go:53: IsPalindrome(\u0026quot;kayak\u0026quot;) = true FAIL 没有断言的go test框架 从单元测试的代码中可以看出，在判断IsPalindrome方法是否运行正确时，我们并未使用断言，而是通过if语句进行判断：如果结果错误则通过t.Error方法报告错误。这是因为Go原生就不支持断言，官网也给出了他们这样设计的原因，简单来说就是不想让开发者在错误处理上进行偷懒。\n Go doesn\u0026rsquo;t provide assertions. They are undeniably convenient, but our experience has been that programmers use them as a crutch to avoid thinking about proper error handling and reporting\u0026hellip;(详见https://golang.org/doc/faq#assertions)\n 但对单元测试来说，没有断言真的是很不方便。特别地，对以前做Java开发时习惯了使用Junit进行单元测试的同学而言更是难受。另外，使用if语句来对方法的输出结果进行判断，并进行对应的错误处理，会导致单元测试代码充斥着大量的if分支，影响了代码的可读性。\n那么，有没有更好的解决方法呢？\n针对这个问题，我们可以引入第三方的断言库来解决。\n使用testify进行断言 在第三方断言库的选择上，就活跃度和易用性而言，testify都是最佳的选择。\n testify：https://github.com/stretchr/testify\n 现在，我们使用testify为上述IsPalindrome的单元测试用例进行重构：\npackage word import \u0026#34;testing\u0026#34; import \u0026#34;github.com/stretchr/testify/assert\u0026#34; func TestPalindrome(t *testing.T) { // 断言IsPalindrome方法的返回值为True \tassert.True(t, IsPalindrome(\u0026#34;detartrated\u0026#34;)) assert.True(t, IsPalindrome(\u0026#34;kayak\u0026#34;)) } func TestNonPalindrome(t *testing.T) { // 断言IsPalindrome方法的返回值为False \tassert.False(t, IsPalindrome(\u0026#34;palindrome\u0026#34;)) } 测试用例成功则打印：\n=== RUN TestPalindrome --- PASS: TestPalindrome (0.00s) PASS 用例执行失败则是：\n=== RUN TestNonPalindrome palindrome_test.go:23: Error Trace:\tpalindrome_test.go:23 Error: Should be false Test: TestNonPalindrome --- FAIL: TestNonPalindrome (0.00s) 由此可见，重构后的测试用例更加简洁，可读性也更加好了。而且，在断言出错的情况下，打印出来的错误信息也相对丰富。\ntestify底层实现也是基于go test框架，在上述用法中，每个assert方法都将testing.T作为第一个入参，以此来使用go test框架的错误报告的基础能力。另外，如果你在一个测试方法中需要断言很多次，每次都传参testing.T就显得比较繁琐，那么可以这样实现来简化：\nfunc TestSomething(t *testing.T) { // 创建一个assert实例，只需传参testing.T一次 \tassert := assert.New(t) // 断言两者相等 \tassert.Equal(123, 123) // 断言两者不相等 \tassert.NotEqual(123, 456) // 断言某个实例为nil \tassert.Nil(object) // 断言object实例不为nil \tif assert.NotNil(object) { // 当知道object不为nil之后，就可以安全地对其进行访问了 \t// 进一步断言object的Value属性的值为\u0026#34;Something\u0026#34; \tassert.Equal(\u0026#34;Something\u0026#34;, object.Value) } } testify有着极其丰富的断言方法，除了上述几个assert.True、assert.Nil、assert.Equal常用的断言方法以外，还有用来断言目录是否存在的assert.DirExists；断言某个方法是否抛出panic的assert.Panics；断言字符串是否符合指定正则表达式的assert.Regexp，等等。总之，testify绝对能够满足你在Go单元测试断言方面的所有需要。\n 更多用法请参考testify assert API文档：https://godoc.org/github.com/stretchr/testify/assert\n 总结 本文主要介绍了如何使用testify库来为Go单元测试引入断言的能力，以此来使得测试用例代码更加简洁。assert只是testify库提供能力之一，比如它还有mock包，提供了“打桩”的能力。说起“打桩”，写过Java单元测试的同学一定很熟悉，Java中的Mockito和PowerMock框架就提供了类似的功能。本文之所以没有介绍testify库的mock功能，是因为还有更加好用的mock库。下一篇文章，我们将介绍如何高效、优雅地在Go单元测试中进行打桩。\n","date":"2020-12-22T00:00:00Z","permalink":"https://yrunz.com/p/%E5%A6%82%E4%BD%95%E9%AB%98%E6%95%88%E7%BC%96%E5%86%99go%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95%E4%B8%80/","title":"如何高效编写Go单元测试（一）"},{"content":"家乡博贺是粤西的一个小渔港，三面环海的地理位置，天然的深水港口，让这里有着吃不完的海鲜。每个人的家乡都会或多或少有着独特的风俗习惯，博贺也不例外。小时候最喜欢的节日，除了春节以外，便是中秋节了。不是因为月饼，更不是因为“团圆”寓意，对于小孩子而言，真正能让我们期待的是自家的烧烤。\n小时候以为中秋节烧烤，那得是全国都有的习惯吧。等出来读大学之后才发现，这竟然是博贺独特的风俗。不同于现在满大街的烧烤店，二十年前，也只有春节的时候才能在街上看到大大小小的烧烤摊，更别说自家买食材开炉烧烤了。于是，中秋夜便成了家乡小孩子无比期待的一年一度的“烧烤庆典”。\n博贺的中秋夜有两个流程，先是赏月，然后才是烧烤。所谓赏月其实是对月亮的祭拜，在自家门前摆一个桌子，放上月饼、柚子、糖水等，倒几杯米酒，插上蜡烛和香，然后一家人坐在桌子前，一边赏着月亮，一边吃着月饼，一边扯着家常。对于小孩子而言，这当然是枯燥的，小时候对月饼无感，特别是叉烧伍仁馅，那简直就是黑暗料理之王。于是总追着妈妈问，“妈，几时赏完月啊？”，然而等到的回答永远都是，“等一阵，月公还未食完呢”。\n等赏完月，收拾好桌子，就可以准备开始烧烤了。整一条街，每家门口都摆上了烧烤炉，通街都是明亮的炉火，也别是一番风景。\n小时候的烧烤大多是鸡翅、鸡肾、鸡翅根、鲜牛肉等普通食材，但博贺烧烤的精华在于食材的腌制。食材通常在上午就已经准备好，加上自己调制的酱汁，要腌制个大半天。酱汁主料是生抽，还得配上新鲜的香葱、甜椒、大蒜和生姜，再加些许蚝油等调味，这些普通的调料混在一起，竟成了美味的自制烧烤汁。等到晚上把食材取出来烧烤时，酱汁的香味早已深深潜入到食材内部。\n火炭的高温能够把食材的风味发挥到极致，把腌制好的鸡翅放到炭炉上炙烤十几分钟，等鸡翅表面成了金黄色后，撒上白芝麻，再烤一会就能上口吃了。长时间的腌制使得鸡翅即使经过了高温的炙烤也依旧饱满，一口下去，焦脆的表皮下，是饱满多汁的鸡肉。外焦内嫩的口感，满口的香味，这样的鸡翅谁能不爱。这也使得烧鸡翅在我心中一直占据着烧烤之王的地位。\n长大些后，家庭条件好了些，海鲜也加入到烧烤食材的阵营里。不同于肉类的腌制，海鲜的烧烤则注重原汁原味。先把新鲜大虾的虾线挑出，用竹签串起，放到炭炉上烤至七成熟后，在表面上涂上一层生抽，然后烤至九成熟就可以起炉了。一口下去，鲜甜的虾肉再带上淡淡的酱香味，嗯，满分。\n一直觉得家乡的烧烤跟日料烧肉很像，腌制入味、酱汁饱满，以至于每次在外吃到干巴巴、满口孜然味的烧烤时，都会无比怀念家乡的自家烧烤。如今，小镇上早已开满了大大小小的烧烤店，吃烧烤也不再只属于节日的狂欢。但是家乡中秋夜烧烤的习俗还是很好的保留了下来，也许比起烧烤，大家更享受的是那份团圆的气氛吧。\n","date":"2020-10-02T00:00:00Z","permalink":"https://yrunz.com/p/%E5%AE%B6%E4%B9%A1%E7%9A%84%E4%B8%AD%E7%A7%8B%E7%83%A7%E7%83%A4%E5%A4%9C/","title":"家乡的中秋烧烤夜"},{"content":"前言 当你点开一个招聘APP，筛选条件选择互联网技术，在列出来的一大堆职位上，往往有那么几个带有“架构师”三个字眼的高薪职位。当你被它的高薪所吸引而点击查看职位详情时，又会被它的高要求所劝退。它们往往要求工作年限在5年以上，需要求职者有过3年以上的系统设计经验，精通各种架构模式和系统框架，反观自己却一个条件都不满足。\n软件架构师就是这么一个让人向往，但又让人望洋兴叹的一个职位。就像建筑设计师总有成为总设计师的梦想，航天工作者总有成为总工程师的壮志，相信每一个软件工程师都有过成为软件架构师的想法。引用维基百科里的定义，软件架构师的职责就是在软件系统研发中，负责依据需求来确定主要的技术选择、设计系统的主体框架结构，并负责搭建实施。然而，架构师所需的技能远远不止于技术选择和系统设计。本文主要介绍软件架构的定义，以及要成为一个软件架构师所需具备的一些技能，让你对软件架构师这一职位有一个更深的了解。\n 文中大部分的观点来自于《Fundamentals of Software Architecture》一书，想了解更多详情推荐阅读原书。\n 软件架构的定义 对于软件架构（Software Architecture），我们通常将它看成是软件系统的蓝图（blueprint），但是如果要给出一个精确的定义，往往很难。维基百科里对软件架构的定义为，有关软件整体结构与组件的抽象描述，用于指导大型软件系统各个方面的设计。但是，这种定义也是片面的，软件架构并不仅仅是系统的整体结构和组件，光有这些还不足以指导设计出好的软件系统。\nMark Richards和Neal Ford在书中，从四个维度上对软件架构进行了描述，分别是Structure、Architecture characteristics、Architecture decisions和Design principles。\nStructure Structure描述的是软件系统所使用的架构风格，比如最常见的分层架构（layered architecture）、事件驱动架构（event-driven architecture）、微核架构（microkernel architecture）、微服务架构（microservices architecture）等等。当你去问架构师一个软件系统使用什么架构时，如果他告诉你，“系统使用的是微服务架构”，那么也他仅仅阐明了系统的架构风格而已。若想了解整个系统的软件架构，对architecture characteristics、architecture decisions和design principles都要有深入的认识。\nArchitecture characteristics Architecture characteristics也就是我们常说的非功能需求，比如有可用性（Availability）、可扩展性（Scalability）、可靠性（Reliability）等。Architecture characteristics往往容易被软件新手所忽略，但是它对于软件系统而言却是非常的重要。如果说功能需求决定了一个软件系统的下限，那么非功能需求则决定了它的上限。\nArchitecture decisions Architecture decisions描述了开发软件系统时所必须遵循的规则，比如图中例子，对于一个分层架构风格的系统而言，开发工程师需要遵循以下规则：只有业务层才能直接访问服务层，表现层不能直接访问服务层。Architecture decisions更多的只是一种约束，违反了这种约束可能并不会对系统的功能造成影响，但是却是系统架构腐化的源头。\nDesign principles Design principles指的是系统设计的原则，用于引导开发团队选择更符合系统特点的技术方案。Design principles只会给出一个方向，而不是具体的实现方案。比如图中例子给出的系统设计原则就是：微服务之间应该尽可能通过异步通信来提升系统的性能。至于开发团队通过REST或者RPC的方式进行异步通信实现，设计原则并未进行限制。\n成为架构师所需的技能 就像软件架构不仅仅是系统的整体结构和组件一样，要成为一个软件架构师，只会技术选型是远远不够的。一个合格的软件架构师应该具备以下的几种技能：\n进行架构决策  An architect is expected to define the architecture decisions and design principles used to guide technology decisions within the team, the department, or across the enterprise.\n 这是一个架构师所需具备的最基本的技能，需要为开发团队给出系统设计的原则和系统开发的约束。架构师在这里的角色更多的是一个引导者，而不是具体技术方案的制定者。比如，开发团队要进行前端框架的选型，作为架构师应该给出的建议是选择Reactive风格的前端框架（引导团队在React.js、Angular、Vue.js或者其他Reactive风格的前端框架之间进行选择），而不是直接建议选择React.js框架。前者属于架构决策，而后者则是技术决策。\n持续对系统架构进行分析  An architect is expected to continually analyze the architecture and current technology environment and then recommend solutions for improvement.\n 就像一个软件系统的生命周期并不止于开发阶段的结束，软件架构也不是一锤子买卖。这就要求架构师能够持续对系统架构进行分析，并提出改进的建议，使得系统在面对业务和技术的双重变化时，仍然能够保持架构良好。\n保持对技术和业界的发展趋势的敏感  An architect is expected to keep current with the latest technology and industry trends\n 作为一个架构师必须时刻保持对技术和业界发展趋势的敏感。在敏捷开发的潮流下，软件的特性会频繁的发生变化，但是软件的基础架构往往是很少改变的。架构师如果不能把握当前技术和业界发展的趋势，从而导致设计出来的软件架构不能够应付未来几年的业务和技术变化，这对于一个软件系统而言将会是灾难性的。\n确保团队按照既定的规则进行开发  An architect is expected to ensure compliance with architecture decisions and design principles.\n 架构师不仅仅需要制定设计原则和开发约束，还需要确保团队能够一直按照这些规则进行软件开发。这就要求架构师对开发人员提交的核心代码进行Code Review，否则系统的架构很容易就腐化掉了。\n扩展知识的广度  An architect is expected to have exposure to multiple and diverse technologies, frameworks, platforms, and environments.\n 对于一个架构师而言，他并不需要精通每一种框架、平台和语言，但至少要尽可能多的了解它们，这样才能更好的支撑架构决策。这就要求架构师能够持续的学习新的知识，不断地跳出自己的舒适区。最好的情况就是精通2～3种语言和框架，并且熟悉业界各种常用的语言和框架，这样的知识深度和广度的结合才能设计出更好的软件架构。\n拥有一定的领域知识  An architect is expected to have a certain level of business domain expertise.\n 所有的技术都是服务于既有的业务，剥离了业务的软件技术毫无价值。架构师无需像领域专家一样精通系统的各种业务，但至少也要有一定的业务积累。软件是用来解决问题的，不懂业务的架构师来做软件架构设计，就相当于还没读懂题目就开始解题，结果往往适得其反。比如一个需要低时延的业务，交给一个不懂业务的架构师来进行系统设计，可能得出来的是一个高吞吐量的架构。\n人际交往的能力  An architect is expected to possess exceptional interpersonal skills, including teamwork, facilitation, and leadership.\n 对于大部分的开发工程师和架构师而言，这可能是最难的一条了要求了。他们很擅长，也很乐意去解决技术上的问题，但是对于与人相关的问题则相当的抵触。但这对于一个合格架构师来说所必须克服的一点，因为架构师不仅仅需要制定技术规则，更重要的是领导团队按照既定规则进行开发，这不可避免地就涉及到领导力和人际交往的能力。\n当一个开发工程师决定在一次需求开发中采用单例模式，可能团队里的其他人并不会有太多的关注。但是当一个架构师决定采用微服务架构来设计系统时，可能就会受到团队内各类人员的挑战，比如版本经理可能觉得微服务架构太复杂，会不会影响交付的节奏；开发人员可能觉得分层架构更好实现。这种情况下就要求架构师能够有很好的人际交往能力，说服各类人员，这样项目才能更好的进行下去。\n总结 软件架构是一个很抽象的东西，目前对它的定义大部分都是一些很宽泛的描述。《Fundamentals of Software Architecture》从四个维度上对软件架构进行了描述，让软件架构有了一个更加清晰的视图。在此基础上，书中也提出了一个合格的软件架构师所需要具备的几种技能。总的来说，就是想要设计出一个好的软件架构很难，要成为一个好的软件架构师更难。\n另外，书中还提出了软件架构的两个准则，很有道理，就是有点抽象。不过没关系，不要试图理解它，要去感受它。\n 1、Everything in software architecture is a trade-off.\n2、Why is more important than how.\n ","date":"2020-09-13T00:00:00Z","permalink":"https://yrunz.com/p/%E5%9C%A8%E6%88%90%E4%B8%BA%E6%9E%B6%E6%9E%84%E5%B8%88%E4%B9%8B%E5%89%8D%E6%89%80%E9%9C%80%E4%BA%86%E8%A7%A3%E7%9A%84%E4%B8%80%E4%BA%9B%E7%9F%A5%E8%AF%86/","title":"在成为架构师之前所需了解的一些知识"},{"content":"前言 上一篇文章《使用Go实现GoF的23种设计模式（二）》中，我们介绍了结构型模式（Structural Pattern）中的组合模式、适配器模式和桥接模式。本文将会介绍完剩下的几种结构型模式，代理模式、装饰模式、外观模式和享元模式。本文将会继续采用消息处理系统作为例子，如果对该例子不清楚，请移步《使用Go实现GoF的23种设计模式（一）》和《使用Go实现GoF的23种设计模式（二）》对其相关的设计和实现进行了解。\n代理模式（Proxy Pattern） 简介 代理模式为一个对象提供一种代理以控制对该对象的访问，它是一个使用率非常高的设计模式，即使在现实生活中，也是很常见，比如演唱会门票黄牛。假设你需要看一场演唱会，但是官网上门票已经售罄，于是就当天到现场通过黄牛高价买了一张。在这个例子中，黄牛就相当于演唱会门票的代理，在正式渠道无法购买门票的情况下，你通过代理完成了该目标。\n从演唱会门票的例子我们也可以看出，使用代理模式的关键在于当Client不方便直接访问一个对象时，提供一个代理对象控制该对象的访问。Client实际上访问的是代理对象，代理对象会将Client的请求转给本体对象去处理。\n在程序设计中，代理模式也分为好几种：\n1、远程代理（remote proxy），远程代理适用于提供服务的对象处在远程的机器上，通过普通的函数调用无法使用服务，需要经过远程代理来完成。因为并不能直接访问本体对象，所有远程代理对象通常不会直接持有本体对象的引用，而是持有远端机器的地址，通过网络协议去访问本体对象。\n2、虚拟代理（virtual proxy），在程序设计中常常会有一些重量级的服务对象，如果一直持有该对象实例会非常消耗系统资源，这时可以通过虚拟代理来对该对象进行延迟初始化。\n3、保护代理（protection proxy），保护代理用于控制对本体对象的访问，常用于需要给Client的访问加上权限验证的场景。\n4、缓存代理（cache proxy），缓存代理主要在Client与本体对象之间加上一层缓存，用于加速本体对象的访问，常见于连接数据库的场景。\n5、智能引用（smart reference），智能引用为本体对象的访问提供了额外的动作，常见的实现为C++中的智能指针，为对象的访问提供了计数功能，当访问对象的计数为0时销毁该对象。\n这几种代理都是一样的实现原理，下面我们将介绍远程代理的Go语言实现。\nGo实现 考虑要将消息处理系统输出到数据存储到一个数据库中，数据库的接口如下：\npackage db ... // Key-Value数据库接口 type KvDb interface { // 存储数据 \t// 其中reply为操作结果，存储成功为true，否则为false \t// 当连接数据库失败时返回error，成功则返回nil \tSave(record Record, reply *bool) error // 根据key获取value，其中value通过函数参数中指针类型返回 \t// 当连接数据库失败时返回error，成功则返回nil \tGet(key string, value *string) error } type Record struct { Key string Value string } 数据库是一个Key-Value数据库，使用map存储数据，下面为数据库的服务端实现，db.Server实现了db.KvDb接口：\npackage db ... // 数据库服务端实现 type Server struct { // 采用map存储key-value数据 \tdata map[string]string } func (s *Server) Save(record Record, reply *bool) error { if s.data == nil{ s.data = make(map[string]string) } s.data[record.Key] = record.Value *reply = true return nil } func (s *Server) Get(key string, reply *string) error { val, ok := s.data[key] if !ok { *reply = \u0026#34;\u0026#34; return errors.New(\u0026#34;Db has no key \u0026#34; + key) } *reply = val return nil } 消息处理系统和数据库并不在同一台机器上，因此消息处理系统不能直接调用db.Server的方法进行数据存储，像这种服务提供者和服务使用者不在同一机器上的场景，使用远程代理再适合不过了。\n远程代理中，最常见的一种实现是远程过程调用（Remote Procedure Call，简称 RPC），它允许客户端应用可以像调用本地对象一样直接调用另一台不同的机器上服务端应用的方法。在Go语言领域，除了大名鼎鼎的gRPC，Go标准库net/rpc包里也提供了RPC的实现。下面，我们通过net/rpc对外提供数据库服务端的能力：\npackage db ... // 启动数据库，对外提供RPC接口进行数据库的访问 func Start() { rpcServer := rpc.NewServer() server := \u0026amp;Server{data: make(map[string]string)} // 将数据库接口注册到RPC服务器上 \tif err := rpcServer.Register(server); err != nil { fmt.Printf(\u0026#34;Register Server to rpc failed, error: %v\u0026#34;, err) return } l, err := net.Listen(\u0026#34;tcp\u0026#34;, \u0026#34;127.0.0.1:1234\u0026#34;) if err != nil { fmt.Printf(\u0026#34;Listen tcp failed, error: %v\u0026#34;, err) return } go rpcServer.Accept(l) time.Sleep(1 * time.Second) fmt.Println(\u0026#34;Rpc server start success.\u0026#34;) } 到目前为止，我们已经为数据库提供了对外访问的方式。现在，我们需要一个远程代理来连接数据库服务端，并进行相关的数据库操作。对消息处理系统而言，它不需要，也不应该知道远程代理与数据库服务端交互的底层细节，这样可以减轻系统之间的耦合。因此，远程代理需要实现db.KvDb：\npackage db ... // 数据库服务端远程代理，实现db.KvDb接口 type Client struct { // RPC客户端 \tcli *rpc.Client } func (c *Client) Save(record Record, reply *bool) error { var ret bool // 通过RPC调用服务端的接口 \terr := c.cli.Call(\u0026#34;Server.Save\u0026#34;, record, \u0026amp;ret) if err != nil { fmt.Printf(\u0026#34;Call db Server.Save rpc failed, error: %v\u0026#34;, err) *reply = false return err } *reply = ret return nil } func (c *Client) Get(key string, reply *string) error { var ret string // 通过RPC调用服务端的接口 \terr := c.cli.Call(\u0026#34;Server.Get\u0026#34;, key, \u0026amp;ret) if err != nil { fmt.Printf(\u0026#34;Call db Server.Get rpc failed, error: %v\u0026#34;, err) *reply = \u0026#34;\u0026#34; return err } *reply = ret return nil } // 工厂方法，返回远程代理实例 func CreateClient() *Client { rpcCli, err := rpc.Dial(\u0026#34;tcp\u0026#34;, \u0026#34;127.0.0.1:1234\u0026#34;) if err != nil { fmt.Printf(\u0026#34;Create rpc client failed, error: %v.\u0026#34;, err) return nil } return \u0026amp;Client{cli: rpcCli} } 作为远程代理的db.Client并没有直接持有db.Server的引用，而是持有了它的ip:port，通过RPC客户端调用了它的方法。\n接下来，我们需要为消息处理系统实现一个新的Output插件DbOutput，调用db.Client远程代理，将消息存储到数据库上。\n 在《使用Go实现GoF的23种设计模式（二）》中我们为Plugin引入生命周期的三个方法Start、Stop、Status之后，每新增一个新的插件，都需要实现这三个方法。但是大多数插件的这三个方法的逻辑基本一致，因此导致了一定程度的代码冗余。对于重复代码问题，有什么好的解决方法呢？组合模式！\n下面，我们使用组合模式将这个方法提取成一个新的对象LifeCycle，这样新增一个插件时，只需将LifeCycle作为匿名成员（嵌入组合），就能解决冗余代码问题了。\npackage plugin ... type LifeCycle struct { name string status Status } func (l *LifeCycle) Start() { l.status = Started fmt.Printf(\u0026#34;%s plugin started.\\n\u0026#34;, l.name) } func (l *LifeCycle) Stop() { l.status = Stopped fmt.Printf(\u0026#34;%s plugin stopped.\\n\u0026#34;, l.name) } func (l *LifeCycle) Status() Status { return l.status }  DbOutput的实现如下，它持有一个远程代理，通过后者将消息存储到远端的数据库中。\npackage plugin ... type DbOutput struct { LifeCycle // 操作数据库的远程代理 \tproxy db.KvDb } func (d *DbOutput) Send(msg *msg.Message) { if d.status != Started { fmt.Printf(\u0026#34;%s is not running, output nothing.\\n\u0026#34;, d.name) return } record := db.Record{ Key: \u0026#34;db\u0026#34;, Value: msg.Body.Items[0], } reply := false err := d.proxy.Save(record, \u0026amp;reply) if err != nil || !reply { fmt.Println(\u0026#34;Save msg to db server failed.\u0026#34;) } } func (d *DbOutput) Init() { d.proxy = db.CreateClient() d.name = \u0026#34;db output\u0026#34; } 测试代码如下：\npackage test ... func TestDbOutput(t *testing.T) { db.Start() config := pipeline.Config{ Name: \u0026#34;pipeline3\u0026#34;, Input: plugin.Config{ PluginType: plugin.InputType, Name: \u0026#34;hello\u0026#34;, }, Filter: plugin.Config{ PluginType: plugin.FilterType, Name: \u0026#34;upper\u0026#34;, }, Output: plugin.Config{ PluginType: plugin.OutputType, Name: \u0026#34;db\u0026#34;, }, } p := pipeline.Of(config) p.Start() p.Exec() // 验证DbOutput存储的正确性 \tcli := db.CreateClient() var val string err := cli.Get(\u0026#34;db\u0026#34;, \u0026amp;val) if err != nil { t.Errorf(\u0026#34;Get db failed, error: %v\\n.\u0026#34;, err) } if val != \u0026#34;HELLO WORLD\u0026#34; { t.Errorf(\u0026#34;expect HELLO WORLD, but actual %s.\u0026#34;, val) } } // 运行结果 === RUN TestDbOutput Rpc server start success. db output plugin started. upper filter plugin started. hello input plugin started. Pipeline started. --- PASS: TestDbOutput (1.01s) PASS 装饰模式（Decorator Pattern） 简介 在程序设计中，我们常常需要为对象添加新的行为，很多同学的第一个想法就是扩展本体对象，通过继承的方式达到目的。但是使用继承不可避免地有如下两个弊端：（1）继承时静态的，在编译期间就已经确定，无法在运行时改变对象的行为。（2）子类只能有一个父类，当需要添加的新功能太多时，容易导致类的数量剧增。\n对于这种场景，我们通常会使用装饰模式（Decorator Pattern）来解决，它使用组合而非继承的方式，能够动态地为本体对象叠加新的行为。理论上，只要没有限制，它可以一直把功能叠加下去。装饰模式最经典的应用当属Java的I/O流体系，通过装饰模式，使用者可以动态地为原始的输入输出流添加功能，比如按照字符串输入输出，添加缓存等，使得整个I/O流体系具有很高的可扩展性和灵活性。\n从结构上看，装饰模式和代理模式具有很高的相似性，但是两种所强调的点不一样。前者强调的是为本体对象添加新的功能，后者强调的是对本体对象的访问控制。当然，代理模式中的智能引用在笔者看来就跟装饰模式完全一样了。\nGo实现 考虑为消息处理系统增加这样的一个功能，统计每个消息输入源分别产生了多少条消息，也就是分别统计每个Input产生Message的数量。最简单的方法是在每一个Input的Receive方法中进行打点统计，但是这样会导致统计代码与业务代码的耦合。如果统计逻辑发生了变化，就会产生霰弹式修改，随着Input类型的增多，相关代码也会变得越来越难维护。\n更好的方法是将统计逻辑放到一个地方，并在每次调用Input的Receive方法后进行打点统计。而这恰好适合采用装饰模式，为Input（本体对象）提供打点统计功能（新的行为）。我们可以设计一个InputMetricDecorator作为Input的装饰器，在装饰器中完成打点统计的逻辑。\n首先，我们需要设计一个用于统计每个Input产生Message数量的对象，该对象应该是一个全局唯一的，因此采用单例模式进行了实现：\npackage metric ... // 消息输入源统计，设计为单例 type input struct { // 存放统计结果，key为Input类型如hello、kafka \t// value为对应Input的消息统计 \tmetrics map[string]uint64 // 统计打点时加锁 \tmu *sync.Mutex } // 给名称为inputName的Input消息计数加1 func (i *input) Inc(inputName string) { i.mu.Lock() defer i.mu.Unlock() if _, ok := i.metrics[inputName]; !ok { i.metrics[inputName] = 0 } i.metrics[inputName] = i.metrics[inputName] + 1 } // 输出当前所有打点的情况 func (i *input) Show() { fmt.Printf(\u0026#34;Input metric: %v\\n\u0026#34;, i.metrics) } // 单例 var inputInstance = \u0026amp;input{ metrics: make(map[string]uint64), mu: \u0026amp;sync.Mutex{}, } func Input() *input { return inputInstance } 接下来我们开始实现InputMetricDecorator，它实现了Input接口，并持有一个本体对象Input。在InputMetricDecorator在Receive方法中调用本体Input的Receive方法，并完成统计动作。\npackage plugin ... type InputMetricDecorator struct { input Input } func (i *InputMetricDecorator) Receive() *msg.Message { // 调用本体对象的Receive方法 \trecord := i.input.Receive() // 完成统计逻辑 \tif inputName, ok := record.Header.Items[\u0026#34;input\u0026#34;]; ok { metric.Input().Inc(inputName) } return record } func (i *InputMetricDecorator) Start() { i.input.Start() } func (i *InputMetricDecorator) Stop() { i.input.Stop() } func (i *InputMetricDecorator) Status() Status { return i.input.Status() } func (i *InputMetricDecorator) Init() { i.input.Init() } // 工厂方法, 完成装饰器的创建 func CreateInputMetricDecorator(input Input) *InputMetricDecorator { return \u0026amp;InputMetricDecorator{input: input} } 最后，我们在Pipeline的工厂方法上，为本体Input加上InputMetricDecorator代理：\npackage pipeline ... // 根据配置创建一个Pipeline实例 func Of(conf Config) *Pipeline { p := \u0026amp;Pipeline{} p.input = factoryOf(plugin.InputType).Create(conf.Input).(plugin.Input) p.filter = factoryOf(plugin.FilterType).Create(conf.Filter).(plugin.Filter) p.output = factoryOf(plugin.OutputType).Create(conf.Output).(plugin.Output) // 为本体Input加上InputMetricDecorator装饰器 \tp.input = plugin.CreateInputMetricDecorator(p.input) return p } 测试代码如下：\npackage test ... func TestInputMetricDecorator(t *testing.T) { p1 := pipeline.Of(pipeline.HelloConfig()) p2 := pipeline.Of(pipeline.KafkaInputConfig()) p1.Start() p2.Start() p1.Exec() p2.Exec() p1.Exec() metric.Input().Show() } // 运行结果 === RUN TestInputMetricDecorator Console output plugin started. Upper filter plugin started. Hello input plugin started. Pipeline started. Console output plugin started. Upper filter plugin started. Kafka input plugin started. Pipeline started. Output: Header:map[content:text input:hello], Body:[HELLO WORLD] Output: Header:map[content:text input:kafka], Body:[I AM MOCK CONSUMER.] Output: Header:map[content:text input:hello], Body:[HELLO WORLD] Input metric: map[hello:2 kafka:1] --- PASS: TestInputMetricProxy (0.00s) PASS 外观模式（Facade Pattern） 简介 从结构上看，外观模式非常的简单，它主要是为子系统提供了一个更高层次的对外统一接口，使得Client能够更友好地使用子系统的功能。图中，Subsystem Class是子系统中对象的简称，它可能是一个对象，也可能是数十个对象的集合。外观模式降低了Client与Subsystem之间的耦合，只要Facade不变，不管Subsystem怎么变化，对于Client而言都是无感知的。\n外观模式在程序设计中用的非常多，比如我们在商城上点击购买的按钮，对于购买者而言，只看到了购买这一统一的接口，但是对于商城系统而言，其内部则进行了一系列的业务处理，比如库存检查、订单处理、支付、物流等等。外观模式极大地提升了用户体验，将用户从复杂的业务流程中解放了出来。\n外观模式经常运用于分层架构上，通常我们都会为分层架构中的每一个层级提供一个或多个统一对外的访问接口，这样就能让各个层级之间的耦合性更低，使得系统的架构更加合理。\nGo实现 外观模式实现起来也很简单，还是考虑前面的消息处理系统。在Pipeline中，每一条消息会依次经过Input-\u0026gt;Filter-\u0026gt;Output的处理，代码实现起来就是这样：\np := pipeline.Of(config) message := p.input.Receive() message = p.filter.Process(message) p.output.Send(message) 但是，对于Pipeline的使用者而言，他可能并不关心消息具体的处理流程，他只需知道消息已经经过Pipeline处理即可。因此，我们需要设计一个简单的对外接口：\npackage pipeline ... func (p *Pipeline) Exec() { msg := p.input.Receive() msg = p.filter.Process(msg) p.output.Send(msg) } 这样，使用者只需简单地调用Exec方法，就能完成一次消息的处理，测试代码如下：\npackage test ... func TestPipeline(t *testing.T) { p := pipeline.Of(pipeline.HelloConfig()) p.Start() // 调用Exec方法完成一次消息的处理 \tp.Exec() } // 运行结果 === RUN TestPipeline console output plugin started. upper filter plugin started. hello input plugin started. Pipeline started. Output: Header:map[content:text input:hello], Body:[HELLO WORLD] --- PASS: TestPipeline (0.00s) PASS 享元模式（Flyweight Pattern） 简介 在程序设计中，我们常常会碰到一些很重型的对象，它们通常拥有很多的成员属性，当系统中充斥着大量的这些对象时，系统的内存将会承受巨大的压力。此外，频繁的创建这些对象也极大地消耗了系统的CPU。很多时候，这些重型对象里，大部分的成员属性都是固定的，这种场景下， 可以使用享元模式进行优化，将其中固定不变的部分设计成共享对象（享元，flyweight），这样就能节省大量的系统内存和CPU。\n享元模式摒弃了在每个对象中保存所有数据的方式， 通过共享多个对象所共有的相同状态， 让你能在有限的内存容量中载入更多对象。\n当我们决定对一个重型对象采用享元模式进行优化时，首先需要将该重型对象的属性划分为两类，能够共享的和不能共享的。前者我们称为内部状态（intrinsic state），存储在享元中，不随享元所处上下文的变化而变化；后者称为外部状态（extrinsic state），它的值取决于享元所处的上下文，因此不能共享。比如，文章A和文章B都引用了图片A，由于文章A和文章B的文字内容是不一样的，因此文字就是外部状态，不能共享；但是它们所引用的图片A是一样的，属于内部状态，因此可以将图片A设计为一个享元\n工厂模式通常都会和享元模式结对出现，享元工厂提供了唯一获取享元对象的接口，这样Client就感知不到享元是如何共享的，降低了模块的耦合性。享元模式和单例模式有些类似的地方，都是在系统中共享对象，但是单例模式更关心的是对象在系统中仅仅创建一次，而享元模式更关心的是如何在多个对象中共享相同的状态。\nGo实现 假设现在需要设计一个系统，用于记录NBA中的球员信息、球队信息以及比赛结果。\n球队Team的数据结构定义如下：\npackage nba ... type TeamId uint8 const ( Warrior TeamId = iota Laker ) type Team struct { Id TeamId // 球队ID \tName string // 球队名称 \tPlayers []*Player // 球队中的球员 } 球员Player的数据结构定义如下：\npackage nba ... type Player struct { Name string // 球员名字 \tTeam TeamId // 球员所属球队ID } 比赛结果Match的数据结构定义如下：\npackage nba ... type Match struct { Date time.Time // 比赛时间 \tLocalTeam *Team // 主场球队 \tVisitorTeam *Team // 客场球队 \tLocalScore uint8 // 主场球队得分 \tVisitorScore uint8 // 客场球队得分 } func (m *Match) ShowResult() { fmt.Printf(\u0026#34;%s VS %s - %d:%d\\n\u0026#34;, m.LocalTeam.Name, m.VisitorTeam.Name, m.LocalScore, m.VisitorScore) } NBA中的一场比赛由两个球队，主场球队和客场球队，完成比赛，对应着代码就是，一个Match实例会持有2个Team实例。目前，NBA总共由30支球队，按照每个赛季每个球队打82场常规赛算，一个赛季总共会有2460场比赛，对应地，就会有4920个Team实例。但是，NBA的30支球队是固定的，实际上只需30个Team实例就能完整地记录一个赛季的所有比赛信息，剩下的4890个Team实例属于冗余的数据。\n这种场景下就适合采用享元模式来进行优化，我们把Team设计成多个Match实例之间的享元。享元的获取通过享元工厂来完成，享元工厂teamFactory的定义如下，Client统一使用teamFactory.TeamOf方法来获取球队Team实例。其中，每个球队Team实例只会创建一次，然后添加到球队池中，后续获取都是直接从池中获取，这样就达到了共享的目的。\npackage nba ... type teamFactory struct { // 球队池，缓存球队实例 \tteams map[TeamId]*Team } // 根据TeamId获取Team实例，从池中获取，如果池里没有，则创建 func (t *teamFactory) TeamOf(id TeamId) *Team { team, ok := t.teams[id] if !ok { team = createTeam(id) t.teams[id] = team } return team } // 享元工厂的单例 var factory = \u0026amp;teamFactory{ teams: make(map[TeamId]*Team), } func Factory() *teamFactory { return factory } // 根据TeamId创建Team实例，只在TeamOf方法中调用，外部不可见 func createTeam(id TeamId) *Team { switch id { case Warrior: w := \u0026amp;Team{ Id: Warrior, Name: \u0026#34;Golden State Warriors\u0026#34;, } curry := \u0026amp;Player{ Name: \u0026#34;Stephen Curry\u0026#34;, Team: Warrior, } thompson := \u0026amp;Player{ Name: \u0026#34;Klay Thompson\u0026#34;, Team: Warrior, } w.Players = append(w.Players, curry, thompson) return w case Laker: l := \u0026amp;Team{ Id: Laker, Name: \u0026#34;Los Angeles Lakers\u0026#34;, } james := \u0026amp;Player{ Name: \u0026#34;LeBron James\u0026#34;, Team: Laker, } davis := \u0026amp;Player{ Name: \u0026#34;Anthony Davis\u0026#34;, Team: Laker, } l.Players = append(l.Players, james, davis) return l default: fmt.Printf(\u0026#34;Get an invalid team id %v.\\n\u0026#34;, id) return nil } } 测试代码如下：\npackage test ... func TestFlyweight(t *testing.T) { game1 := \u0026amp;nba.Match{ Date: time.Date(2020, 1, 10, 9, 30, 0, 0, time.Local), LocalTeam: nba.Factory().TeamOf(nba.Warrior), VisitorTeam: nba.Factory().TeamOf(nba.Laker), LocalScore: 102, VisitorScore: 99, } game1.ShowResult() game2 := \u0026amp;nba.Match{ Date: time.Date(2020, 1, 12, 9, 30, 0, 0, time.Local), LocalTeam: nba.Factory().TeamOf(nba.Laker), VisitorTeam: nba.Factory().TeamOf(nba.Warrior), LocalScore: 110, VisitorScore: 118, } game2.ShowResult() // 两个Match的同一个球队应该是同一个实例的 \tif game1.LocalTeam != game2.VisitorTeam { t.Errorf(\u0026#34;Warrior team do not use flyweight pattern\u0026#34;) } } // 运行结果 === RUN TestFlyweight Golden State Warriors VS Los Angeles Lakers - 102:99 Los Angeles Lakers VS Golden State Warriors - 110:118 --- PASS: TestFlyweight (0.00s) 总结 本文我们主要介绍了结构型模式中的代理模式、装饰模式、外观模式和享元模式。代理模式为一个对象提供一种代理以控制对该对象的访问，强调的是对本体对象的访问控制；装饰模式能够动态地为本体对象叠加新的行为，强调的是为本体对象添加新的功能；外观模式为子系统提供了一个更高层次的对外统一接口，强调的是分层和解耦；享元模式通过共享对象来降低系统的资源消耗，强调的是如何在多个对象中共享相同的状态。\n到目前为止，7种结构型模式已经全部介绍完，下一篇文章，我们开始将介绍最后一类设计模式——行为型模式（Behavioral Pattern）。\n","date":"2020-09-06T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BD%BF%E7%94%A8go%E5%AE%9E%E7%8E%B0gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E4%B8%89/","title":"使用Go实现GoF的23种设计模式（三）"},{"content":"前言 上一篇文章《使用Go实现GoF的23种设计模式（一）》介绍了23种设计模式中的创建型模式（Creational Pattern），创建型模式是处理对象创建的一类设计模式，主要思想是向对象的使用者隐藏对象创建的具体细节，从而达到解耦的目的。本文主要聚焦在结构型模式（Structural Pattern）上，其主要思想是将多个对象组装成较大的结构，并同时保持结构的灵活和高效，从程序的结构上解决模块之间的耦合问题。\n组合模式（Composite Pattern） 简述 在面向对象编程中，有两个常见的对象设计方法，组合和继承，两者都可以解决代码复用的问题，但是使用后者时容易出现继承层次过深，对象关系过于复杂的副作用，从而导致代码的可维护性变差。因此，一个经典的面向对象设计原则是：组合优于继承。\n我们都知道，组合所表示的语义为“has-a”，也就是部分和整体的关系，最经典的组合模式描述如下：\n 将对象组合成树形结构以表示“部分-整体”的层次结构，使得用户对单个对象和组合对象的使用具有一致性。\n Go语言天然就支持了组合模式，而且从它不支持继承关系的特点来看，Go也奉行了组合优于继承的原则，鼓励大家在进行程序设计时多采用组合的方法。Go实现组合模式的方式有两种，分别是直接组合（Direct Composition）和嵌入组合（Embedding Composition），下面我们一起探讨这两种不同的实现方法。\nGo实现 直接组合（Direct Composition）的实现方式类似于Java/C++，就是将一个对象作为另一个对象的成员属性。\n一个典型的实现如《使用Go实现GoF的23种设计模式（一）》中所举的例子，一个Message结构体，由Header和Body所组成。那么Message就是一个整体，而Header和Body则为消息的组成部分。\ntype Message struct { Header *Header Body *Body } 现在，我们来看一个稍微复杂一点的例子，同样考虑上一篇文章中所描述的插件架构风格的消息处理系统。前面我们用抽象工厂模式解决了插件加载的问题，通常，每个插件都会有一个生命周期，常见的就是启动状态和停止状态，现在我们使用组合模式来解决插件的启动和停止问题。\n首先给Plugin接口添加几个生命周期相关的方法：\npackage plugin ... // 插件运行状态 type Status uint8 const ( Stopped Status = iota Started ) type Plugin interface { // 启动插件 \tStart() // 停止插件 \tStop() // 返回插件当前的运行状态 \tStatus() Status } // Input、Filter、Output三类插件接口的定义跟上一篇文章类似 // 这里使用Message结构体替代了原来的string，使得语义更清晰 type Input interface { Plugin Receive() *msg.Message } type Filter interface { Plugin Process(msg *msg.Message) *msg.Message } type Output interface { Plugin Send(msg *msg.Message) } 对于插件化的消息处理系统而言，一切皆是插件，因此我们将Pipeine也设计成一个插件，实现Plugin接口：\npackage pipeline ... // 一个Pipeline由input、filter、output三个Plugin组成 type Pipeline struct { status plugin.Status input plugin.Input filter plugin.Filter output plugin.Output } func (p *Pipeline) Exec() { msg := p.input.Receive() msg = p.filter.Process(msg) p.output.Send(msg) } // 启动的顺序 output -\u0026gt; filter -\u0026gt; input func (p *Pipeline) Start() { p.output.Start() p.filter.Start() p.input.Start() p.status = plugin.Started fmt.Println(\u0026#34;Hello input plugin started.\u0026#34;) } // 停止的顺序 input -\u0026gt; filter -\u0026gt; output func (p *Pipeline) Stop() { p.input.Stop() p.filter.Stop() p.output.Stop() p.status = plugin.Stopped fmt.Println(\u0026#34;Hello input plugin stopped.\u0026#34;) } func (p *Pipeline) Status() plugin.Status { return p.status } 一个Pipeline由Input、Filter、Output三类插件组成，形成了“部分-整体”的关系，而且它们都实现了Plugin接口，这就是一个典型的组合模式的实现。Client无需显式地启动和停止Input、Filter和Output插件，在调用Pipeline对象的Start和Stop方法时，Pipeline就已经帮你按顺序完成对应插件的启动和停止。\n相比于上一篇文章，在本文中实现Input、Filter、Output三类插件时，需要多实现3个生命周期的方法。还是以上一篇文章中的HelloInput、UpperFilter和ConsoleOutput作为例子，具体实现如下：\npackage plugin ... type HelloInput struct { status Status } func (h *HelloInput) Receive() *msg.Message { // 如果插件未启动，则返回nil \tif h.status != Started { fmt.Println(\u0026#34;Hello input plugin is not running, input nothing.\u0026#34;) return nil } return msg.Builder(). WithHeaderItem(\u0026#34;content\u0026#34;, \u0026#34;text\u0026#34;). WithBodyItem(\u0026#34;Hello World\u0026#34;). Build() } func (h *HelloInput) Start() { h.status = Started fmt.Println(\u0026#34;Hello input plugin started.\u0026#34;) } func (h *HelloInput) Stop() { h.status = Stopped fmt.Println(\u0026#34;Hello input plugin stopped.\u0026#34;) } func (h *HelloInput) Status() Status { return h.status } package plugin ... type UpperFilter struct { status Status } func (u *UpperFilter) Process(msg *msg.Message) *msg.Message { if u.status != Started { fmt.Println(\u0026#34;Upper filter plugin is not running, filter nothing.\u0026#34;) return msg } for i, val := range msg.Body.Items { msg.Body.Items[i] = strings.ToUpper(val) } return msg } func (u *UpperFilter) Start() { u.status = Started fmt.Println(\u0026#34;Upper filter plugin started.\u0026#34;) } func (u *UpperFilter) Stop() { u.status = Stopped fmt.Println(\u0026#34;Upper filter plugin stopped.\u0026#34;) } func (u *UpperFilter) Status() Status { return u.status } package plugin ... type ConsoleOutput struct { status Status } func (c *ConsoleOutput) Send(msg *msg.Message) { if c.status != Started { fmt.Println(\u0026#34;Console output is not running, output nothing.\u0026#34;) return } fmt.Printf(\u0026#34;Output:\\n\\tHeader:%+v, Body:%+v\\n\u0026#34;, msg.Header.Items, msg.Body.Items) } func (c *ConsoleOutput) Start() { c.status = Started fmt.Println(\u0026#34;Console output plugin started.\u0026#34;) } func (c *ConsoleOutput) Stop() { c.status = Stopped fmt.Println(\u0026#34;Console output plugin stopped.\u0026#34;) } func (c *ConsoleOutput) Status() Status { return c.status } 测试代码如下：\npackage test ... func TestPipeline(t *testing.T) { p := pipeline.Of(pipeline.DefaultConfig()) p.Start() p.Exec() p.Stop() } // 运行结果 === RUN TestPipeline Console output plugin started. Upper filter plugin started. Hello input plugin started. Pipeline started. Output: Header:map[content:text], Body:[HELLO WORLD] Hello input plugin stopped. Upper filter plugin stopped. Console output plugin stopped. Hello input plugin stopped. --- PASS: TestPipeline (0.00s) PASS 组合模式的另一种实现，嵌入组合（Embedding Composition），其实就是利用了Go语言的匿名成员特性，本质上跟直接组合是一致的。\n还是以Message结构体为例，如果采用嵌入组合，则看起来像是这样：\ntype Message struct { Header Body } // 使用时，Message可以引用Header和Body的成员属性，例如： msg := \u0026amp;Message{} msg.SrcAddr = \u0026#34;192.168.0.1\u0026#34; 适配器模式（Adapter Pattern） 简述 适配器模式是最常用的结构型模式之一，它让原本因为接口不匹配而无法一起工作的两个对象能够一起工作。在现实生活中，适配器模式也是处处可见，比如电源插头转换器，可以让英式的插头工作在中式的插座上。适配器模式所做的就是将一个接口Adaptee，通过适配器Adapter转换成Client所期望的另一个接口Target来使用，实现原理也很简单，就是Adapter通过实现Target接口，并在对应的方法中调用Adaptee的接口实现。\n一个典型的应用场景是，系统中一个老的接口已经过时即将废弃，但因为历史包袱没法立即将老接口全部替换为新接口，这时可以新增一个适配器，将老的接口适配成新的接口来使用。适配器模式很好的践行了面向对象设计原则里的开闭原则（open/closed principle），新增一个接口时也无需修改老接口，只需多加一个适配层即可。\nGo实现 继续考虑上一节的消息处理系统例子，目前为止，系统的输入都源自于HelloInput，现在假设需要给系统新增从Kafka消息队列中接收数据的功能，其中Kafka消费者的接口如下：\npackage kafka ... type Records struct { Items []string } type Consumer interface { Poll() Records } 由于当前Pipeline的设计是通过plugin.Input接口来进行数据接收，因此kafka.Consumer并不能直接集成到系统中。\n怎么办？使用适配器模式！\n为了能让Pipeline能够使用kafka.Consumer接口，我们需要定义一个适配器如下：\npackage plugin ... type KafkaInput struct { status Status consumer kafka.Consumer } func (k *KafkaInput) Receive() *msg.Message { records := k.consumer.Poll() if k.status != Started { fmt.Println(\u0026#34;Kafka input plugin is not running, input nothing.\u0026#34;) return nil } return msg.Builder(). WithHeaderItem(\u0026#34;content\u0026#34;, \u0026#34;text\u0026#34;). WithBodyItems(records.Items). Build() } // 在输入插件映射关系中加入kafka，用于通过反射创建input对象 func init() { inputNames[\u0026#34;hello\u0026#34;] = reflect.TypeOf(HelloInput{}) inputNames[\u0026#34;kafka\u0026#34;] = reflect.TypeOf(KafkaInput{}) } ... 因为Go语言并没有构造函数，如果按照上一篇文章中的抽象工厂模式来创建KafkaInput，那么得到的实例中的consumer成员因为没有被初始化而会是nil。因此，需要给Plugin接口新增一个Init方法，用于定义插件的一些初始化操作，并在工厂返回实例前调用。\npackage plugin ... type Plugin interface { Start() Stop() Status() Status // 新增初始化方法，在插件工厂返回实例前调用 \tInit() } // 修改后的插件工厂实现如下 func (i *InputFactory) Create(conf Config) Plugin { t, _ := inputNames[conf.Name] p := reflect.New(t).Interface().(Plugin) // 返回插件实例前调用Init函数，完成相关初始化方法 \tp.Init() return p } // KakkaInput的Init函数实现 func (k *KafkaInput) Init() { k.consumer = \u0026amp;kafka.MockConsumer{} } 上述代码中的kafka.MockConsumer为我们模式Kafka消费者的一个实现，代码如下：\npackage kafka ... type MockConsumer struct {} func (m *MockConsumer) Poll() *Records { records := \u0026amp;Records{} records.Items = append(records.Items, \u0026#34;i am mock consumer.\u0026#34;) return records } 测试代码如下：\npackage test ... func TestKafkaInputPipeline(t *testing.T) { config := pipeline.Config{ Name: \u0026#34;pipeline2\u0026#34;, Input: plugin.Config{ PluginType: plugin.InputType, Name: \u0026#34;kafka\u0026#34;, }, Filter: plugin.Config{ PluginType: plugin.FilterType, Name: \u0026#34;upper\u0026#34;, }, Output: plugin.Config{ PluginType: plugin.OutputType, Name: \u0026#34;console\u0026#34;, }, } p := pipeline.Of(config) p.Start() p.Exec() p.Stop() } // 运行结果 === RUN TestKafkaInputPipeline Console output plugin started. Upper filter plugin started. Kafka input plugin started. Pipeline started. Output: Header:map[content:kafka], Body:[I AM MOCK CONSUMER.] Kafka input plugin stopped. Upper filter plugin stopped. Console output plugin stopped. Pipeline stopped. --- PASS: TestKafkaInputPipeline (0.00s) PASS 桥接模式（Bridge Pattern） 简述 桥接模式主要用于将抽象部分和实现部分进行解耦，使得它们能够各自往独立的方向变化。它解决了在模块有多种变化方向的情况下，用继承所导致的类爆炸问题。举一个例子，一个产品有形状和颜色两个特征（变化方向），其中形状分为方形和圆形，颜色分为红色和蓝色。如果采用继承的设计方案，那么就需要新增4个产品子类：方形红色、圆形红色、方形蓝色、圆形红色。如果形状总共有m种变化，颜色有n种变化，那么就需要新增m*n个产品子类！现在我们使用桥接模式进行优化，将形状和颜色分别设计为一个抽象接口独立出来，这样需要新增2个形状子类：方形和圆形，以及2个颜色子类：红色和蓝色。同样，如果形状总共有m种变化，颜色有n种变化，总共只需要新增m+n个子类！\n上述例子中，我们通过将形状和颜色抽象为一个接口，使产品不再依赖于具体的形状和颜色细节，从而达到了解耦的目的。桥接模式本质上就是面向接口编程，可以给系统带来很好的灵活性和可扩展性。如果一个对象存在多个变化的方向，而且每个变化方向都需要扩展，那么使用桥接模式进行设计那是再合适不过了。\nGo实现 回到消息处理系统的例子，一个Pipeline对象主要由Input、Filter、Output三类插件组成（3个特征），因为是插件化的系统，不可避免的就要求支持多种Input、Filter、Output的实现，并能够灵活组合（有多个变化的方向）。显然，Pipeline就非常适合使用桥接模式进行设计，实际上我们也这么做了。我们将Input、Filter、Output分别设计成一个抽象的接口，它们按照各自的方向去扩展。Pipeline只依赖的这3个抽象接口，并不感知具体实现的细节。\npackage plugin ... type Input interface { Plugin Receive() *msg.Message } type Filter interface { Plugin Process(msg *msg.Message) *msg.Message } type Output interface { Plugin Send(msg *msg.Message) } package pipeline ... // 一个Pipeline由input、filter、output三个Plugin组成 type Pipeline struct { status plugin.Status input plugin.Input filter plugin.Filter output plugin.Output } // 通过抽象接口来使用，看不到底层的实现细节 func (p *Pipeline) Exec() { msg := p.input.Receive() msg = p.filter.Process(msg) p.output.Send(msg) } 测试代码如下：\npackage test ... func TestPipeline(t *testing.T) { p := pipeline.Of(pipeline.DefaultConfig()) p.Start() p.Exec() p.Stop() } // 运行结果 === RUN TestPipeline Console output plugin started. Upper filter plugin started. Hello input plugin started. Pipeline started. Output: Header:map[content:text], Body:[HELLO WORLD] Hello input plugin stopped. Upper filter plugin stopped. Console output plugin stopped. Pipeline stopped. --- PASS: TestPipeline (0.00s) PASS 总结 本文主要介绍了结构型模式中的组合模式、适配器模式和桥接模式。组合模式主要解决代码复用的问题，相比于继承关系，组合模式可以避免继承层次过深导致的代码复杂问题，因此面向对象设计领域流传着组合优于继承的原则，而Go语言的设计也很好实践了该原则；适配器模式可以看作是两个不兼容接口之间的桥梁，可以将一个接口转换成Client所希望的另外一个接口，解决了模块之间因为接口不兼容而无法一起工作的问题；桥接模式将模块的抽象部分和实现部分进行分离，让它们能够往各自的方向扩展，从而达到解耦的目的。\n下一篇文章，我们将继续聚焦于结构型模式，介绍完剩余的4种结模式：装饰模式、外观模式、享元模式和代理模式。\n","date":"2020-08-23T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BD%BF%E7%94%A8go%E5%AE%9E%E7%8E%B0gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E4%BA%8C/","title":"使用Go实现GoF的23种设计模式（二）"},{"content":"前言 从1995年GoF提出23种设计模式到现在，25年过去了，设计模式依旧是软件领域的热门话题。在当下，如果你不会一点设计模式，都不好意思说自己是一个合格的程序员。设计模式通常被定义为：\n 设计模式（Design Pattern）是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结，使用设计模式是为了可重用代码、让代码更容易被他人理解并且保证代码可靠性。\n 从定义上看，设计模式其实是一种经验的总结，是针对特定问题的简洁而优雅的解决方案。既然是经验总结，那么学习设计模式最直接的好处就在于可以站在巨人的肩膀上解决软件开发过程中的一些特定问题。然而，学习设计模式的最高境界是习得其中解决问题所用到的思想，当你把它们的本质思想吃透了，也就能做到即使已经忘掉某个设计模式的名称和结构，也能在解决特定问题时信手拈来。\n好的东西有人吹捧，当然也会招黑。设计模式被抨击主要因为以下两点：\n1、设计模式会增加代码量，把程序逻辑变得复杂。这一点是不可避免的，但是我们并不能仅仅只考虑开发阶段的成本。最简单的程序当然是一个函数从头写到尾，但是这样后期的维护成本会变得非常大；而设计模式虽然增加了一点开发成本，但是能让人们写出可复用、可维护性高的程序。引用《软件设计的哲学》里的概念，前者就是战术编程，后者就是战略编程，我们应该对战术编程Say No！（请移步《一步步降低软件复杂性》）\n2、滥用设计模式。这是初学者最容易犯的错误，当学到一个模式时，恨不得在所有的代码都用上，从而在不该使用模式的地方刻意地使用了模式，导致了程序变得异常复杂。其实每个设计模式都有几个关键要素：适用场景、解决方法、优缺点。模式并不是万能药，它只有在特定的问题上才能显现出效果。所以，在使用一个模式前，先问问自己，当前的这个场景适用这个模式吗？\n《设计模式》一书的副标题是“可复用面向对象软件的基础”，但并不意味着只有面向对象语言才能使用设计模式。模式只是一种解决特定问题的思想，跟语言无关。就像Go语言一样，它并非是像C++和Java一样的面向对象语言，但是设计模式同样适用。本系列文章将使用Go语言来实现GoF提出的23种设计模式，按照创建型模式（Creational Pattern）、结构型模式（Structural Pattern）和行为型模式（Behavioral Pattern）三种类别进行组织，文本主要介绍其中的创建型模式。\n单例模式（Singleton Pattern） 简述 单例模式算是23中设计模式里最简单的一个了，它主要用于保证一个类仅有一个实例，并提供一个访问它的全局访问点。\n在程序设计中，有一些对象通常我们只需要一个共享的实例，比如线程池、全局缓存、对象池等，这种场景下就适合使用单例模式。\n但是，并非所有全局唯一的场景都适合使用单例模式。比如，考虑需要统计一个API调用的情况，有两个指标，成功调用次数和失败调用次数。这两个指标都是全局唯一的，所以有人可能会将其建模成两个单例SuccessApiMetric和FailApiMetric。按照这个思路，随着指标数量的增多，你会发现代码里类的定义会越来越多，也越来越臃肿。这也是单例模式最常见的误用场景，更好的方法是将两个指标设计成一个对象ApiMetric下的两个实例ApiMetic success和ApiMetic fail。\n如何判断一个对象是否应该被建模成单例？\n通常，被建模成单例的对象都有“中心点”的含义，比如线程池就是管理所有线程的中心。所以，在判断一个对象是否适合单例模式时，先思考下，这个对象是一个中心点吗？\nGo实现 在对某个对象实现单例模式时，有两个点必须要注意：（1）限制调用者直接实例化该对象；（2）为该对象的单例提供一个全局唯一的访问方法。\n对于C++/Java而言，只需把类的构造函数设计成私有的，并提供一个static方法去访问该类点唯一实例即可。但对于Go语言来说，即没有构造函数的概念，也没有static方法，所以需要另寻出路。\n我们可以利用Go语言package的访问规则来实现，将单例结构体设计成首字母小写，就能限定其访问范围只在当前package下，模拟了C++/Java中的私有构造函数；再在当前package下实现一个首字母大写的访问函数，就相当于static方法的作用了。\n在实际开发中，我们经常会遇到需要频繁创建和销毁的对象。频繁的创建和销毁一则消耗CPU，二则内存的利用率也不高，通常我们都会使用对象池技术来进行优化。考虑我们需要实现一个消息对象池，因为是全局的中心点，管理所有的Message实例，所以将其实现成单例，实现代码如下：\npackage msgpool ... // 消息池 type messagePool struct { pool *sync.Pool } // 消息池单例 var msgPool = \u0026amp;messagePool{ // 如果消息池里没有消息，则新建一个Count值为0的Message实例 \tpool: \u0026amp;sync.Pool{New: func() interface{} { return \u0026amp;Message{Count: 0} }}, } // 访问消息池单例的唯一方法 func Instance() *messagePool { return msgPool } // 往消息池里添加消息 func (m *messagePool) AddMsg(msg *Message) { m.pool.Put(msg) } // 从消息池里获取消息 func (m *messagePool) GetMsg() *Message { return m.pool.Get().(*Message) } ... 测试代码如下：\npackage test ... func TestMessagePool(t *testing.T) { msg0 := msgpool.Instance().GetMsg() if msg0.Count != 0 { t.Errorf(\u0026#34;expect msg count %d, but actual %d.\u0026#34;, 0, msg0.Count) } msg0.Count = 1 msgpool.Instance().AddMsg(msg0) msg1 := msgpool.Instance().GetMsg() if msg1.Count != 1 { t.Errorf(\u0026#34;expect msg count %d, but actual %d.\u0026#34;, 1, msg1.Count) } } // 运行结果 === RUN TestMessagePool --- PASS: TestMessagePool (0.00s) PASS 以上的单例模式就是典型的“饿汉模式”，实例在系统加载的时候就已经完成了初始化。对应地，还有一种“懒汉模式”，只有等到对象被使用的时候，才会去初始化它，从而一定程度上节省了内存。众所周知，“懒汉模式”会带来线程安全问题，可以通过普通加锁，或者更高效的双重检验锁来优化。对于“懒汉模式”，Go语言有一个更优雅的实现方式，那就是利用sync.Once，它有一个Do方法，其入参是一个方法，Go语言会保证仅仅只调用一次该方法。\n// 单例模式的“懒汉模式”实现 package msgpool ... var once = \u0026amp;sync.Once{} // 消息池单例，在首次调用时初始化 var msgPool *messagePool // 全局唯一获取消息池pool到方法 func Instance() *messagePool { // 在匿名函数中实现初始化逻辑，Go语言保证只会调用一次 \tonce.Do(func() { msgPool = \u0026amp;messagePool{ // 如果消息池里没有消息，则新建一个Count值为0的Message实例 \tpool: \u0026amp;sync.Pool{New: func() interface{} { return \u0026amp;Message{Count: 0} }}, } }) return msgPool } ... 建造者模式（Builder Pattern） 简述 在程序设计中，我们会经常遇到一些复杂的对象，其中有很多成员属性，甚至嵌套着多个复杂的对象。这种情况下，创建这个复杂对象就会变得很繁琐。对于C++/Java而言，最常见的表现就是构造函数有着长长的参数列表：\nMyObject obj = new MyObject(param1, param2, param3, param4, param5, param6, ...) 而对于Go语言来说，最常见的表现就是多层的嵌套实例化：\nobj := \u0026amp;MyObject{ Field1: \u0026amp;Field1 { Param1: \u0026amp;Param1 { Val: 0, }, Param2: \u0026amp;Param2 { Val: 1, }, ... }, Field2: \u0026amp;Field2 { Param3: \u0026amp;Param3 { Val: 2, }, ... }, ... } 上述的对象创建方法有两个明显的缺点：（1）对对象使用者不友好，使用者在创建对象时需要知道的细节太多；（2）代码可读性很差。\n针对这种对象成员较多，创建对象逻辑较为繁琐的场景，就适合使用建造者模式来进行优化。\n建造者模式的作用有如下几个：\n1、封装复杂对象的创建过程，使对象使用者不感知复杂的创建逻辑。\n2、可以一步步按照顺序对成员进行赋值，或者创建嵌套对象，并最终完成目标对象的创建。\n3、对多个对象复用同样的对象创建逻辑。\n其中，第1和第2点比较常用，下面对建造者模式的实现也主要是针对这两点进行示例。\nGo实现 考虑如下的一个Message结构体，其主要有Header和Body组成：\npackage msg ... type Message struct { Header *Header Body *Body } type Header struct { SrcAddr string SrcPort uint64 DestAddr string DestPort uint64 Items map[string]string } type Body struct { Items []string } ... 如果按照直接的对象创建方式，创建逻辑应该是这样的：\n// 多层的嵌套实例化 message := msg.Message{ Header: \u0026amp;msg.Header{ SrcAddr: \u0026#34;192.168.0.1\u0026#34;, SrcPort: 1234, DestAddr: \u0026#34;192.168.0.2\u0026#34;, DestPort: 8080, Items: make(map[string]string), }, Body: \u0026amp;msg.Body{ Items: make([]string, 0), }, } // 需要知道对象的实现细节 message.Header.Items[\u0026#34;contents\u0026#34;] = \u0026#34;application/json\u0026#34; message.Body.Items = append(message.Body.Items, \u0026#34;record1\u0026#34;) message.Body.Items = append(message.Body.Items, \u0026#34;record2\u0026#34;) 虽然Message结构体嵌套的层次不多，但是从其创建的代码来看，确实存在对对象使用者不友好和代码可读性差的缺点。下面我们引入建造者模式对代码进行重构：\npackage msg ... // Message对象的Builder对象 type builder struct { once *sync.Once msg *Message } // 返回Builder对象 func Builder() *builder { return \u0026amp;builder{ once: \u0026amp;sync.Once{}, msg: \u0026amp;Message{Header: \u0026amp;Header{}, Body: \u0026amp;Body{}}, } } // 以下是对Message成员对构建方法 func (b *builder) WithSrcAddr(srcAddr string) *builder { b.msg.Header.SrcAddr = srcAddr return b } func (b *builder) WithSrcPort(srcPort uint64) *builder { b.msg.Header.SrcPort = srcPort return b } func (b *builder) WithDestAddr(destAddr string) *builder { b.msg.Header.DestAddr = destAddr return b } func (b *builder) WithDestPort(destPort uint64) *builder { b.msg.Header.DestPort = destPort return b } func (b *builder) WithHeaderItem(key, value string) *builder { // 保证map只初始化一次 \tb.once.Do(func() { b.msg.Header.Items = make(map[string]string) }) b.msg.Header.Items[key] = value return b } func (b *builder) WithBodyItem(record string) *builder { b.msg.Body.Items = append(b.msg.Body.Items, record) return b } // 创建Message对象，在最后一步调用 func (b *builder) Build() *Message { return b.msg } 测试代码如下：\npackage test ... func TestMessageBuilder(t *testing.T) { // 使用消息建造者进行对象创建 \tmessage := msg.Builder(). WithSrcAddr(\u0026#34;192.168.0.1\u0026#34;). WithSrcPort(1234). WithDestAddr(\u0026#34;192.168.0.2\u0026#34;). WithDestPort(8080). WithHeaderItem(\u0026#34;contents\u0026#34;, \u0026#34;application/json\u0026#34;). WithBodyItem(\u0026#34;record1\u0026#34;). WithBodyItem(\u0026#34;record2\u0026#34;). Build() if message.Header.SrcAddr != \u0026#34;192.168.0.1\u0026#34; { t.Errorf(\u0026#34;expect src address 192.168.0.1, but actual %s.\u0026#34;, message.Header.SrcAddr) } if message.Body.Items[0] != \u0026#34;record1\u0026#34; { t.Errorf(\u0026#34;expect body item0 record1, but actual %s.\u0026#34;, message.Body.Items[0]) } } // 运行结果 === RUN TestMessageBuilder --- PASS: TestMessageBuilder (0.00s) PASS 从测试代码可知，使用建造者模式来进行对象创建，使用者不再需要知道对象具体的实现细节，代码可读性也更好。\n工厂方法模式（Factory Method Pattern） 简述 工厂方法模式跟上一节讨论的建造者模式类似，都是将对象创建的逻辑封装起来，为使用者提供一个简单易用的对象创建接口。两者在应用场景上稍有区别，建造者模式更常用于需要传递多个参数来进行实例化的场景。\n使用工厂方法来创建对象主要有两个好处：\n1、代码可读性更好。相比于使用C++/Java中的构造函数，或者Go中的{}来创建对象，工厂方法因为可以通过函数名来表达代码含义，从而具备更好的可读性。比如，使用工厂方法productA := CreateProductA()创建一个ProductA对象，比直接使用productA := ProductA{}的可读性要好。\n2、与使用者代码解耦。很多情况下，对象的创建往往是一个容易变化的点，通过工厂方法来封装对象的创建过程，可以在创建逻辑变更时，避免霰弹式修改。\n工厂方法模式也有两种实现方式：（1）提供一个工厂对象，通过调用工厂对象的工厂方法来创建产品对象；（2）将工厂方法集成到产品对象中（C++/Java中对象的static方法，Go中同一package下的函数）\nGo实现 考虑有一个事件对象Event，分别有两种有效的时间类型Start和End：\npackage event ... type Type uint8 // 事件类型定义 const ( Start Type = iota End ) // 事件抽象接口 type Event interface { EventType() Type Content() string } // 开始事件，实现了Event接口 type StartEvent struct{ content string } ... // 结束事件，实现了Event接口 type EndEvent struct{ content string } ... 1、按照第一种实现方式，为Event提供一个工厂对象，具体代码如下：\npackage event ... // 事件工厂对象 type Factory struct{} // 更具事件类型创建具体事件 func (e *Factory) Create(etype Type) Event { switch etype { case Start: return \u0026amp;StartEvent{ content: \u0026#34;this is start event\u0026#34;, } case End: return \u0026amp;EndEvent{ content: \u0026#34;this is end event\u0026#34;, } default: return nil } } 测试代码如下：\npackage test ... func TestEventFactory(t *testing.T) { factory := event.Factory{} e := factory.Create(event.Start) if e.EventType() != event.Start { t.Errorf(\u0026#34;expect event.Start, but actual %v.\u0026#34;, e.EventType()) } e = factory.Create(event.End) if e.EventType() != event.End { t.Errorf(\u0026#34;expect event.End, but actual %v.\u0026#34;, e.EventType()) } } // 运行结果 === RUN TestEventFactory --- PASS: TestEventFactory (0.00s) PASS 2、按照第二种实现方式，分别给Start和End类型的Event单独提供一个工厂方法，代码如下：\npackage event ... // Start类型Event的工厂方法 func OfStart() Event { return \u0026amp;StartEvent{ content: \u0026#34;this is start event\u0026#34;, } } // End类型Event的工厂方法 func OfEnd() Event { return \u0026amp;EndEvent{ content: \u0026#34;this is end event\u0026#34;, } } 测试代码如下：\npackage event ... func TestEvent(t *testing.T) { e := event.OfStart() if e.EventType() != event.Start { t.Errorf(\u0026#34;expect event.Start, but actual %v.\u0026#34;, e.EventType()) } e = event.OfEnd() if e.EventType() != event.End { t.Errorf(\u0026#34;expect event.End, but actual %v.\u0026#34;, e.EventType()) } } // 运行结果 === RUN TestEvent --- PASS: TestEvent (0.00s) PASS 抽象工厂模式（Abstract Factory Pattern） 简述 在工厂方法模式中，我们通过一个工厂对象来创建一个产品族，具体创建哪个产品，则通过swtich-case的方式去判断。这也意味着该产品组上，每新增一类产品对象，都必须修改原来工厂对象的代码；而且随着产品的不断增多，工厂对象的职责也越来越重，违反了单一职责原则。\n抽象工厂模式通过给工厂类新增一个抽象层解决了该问题，如上图所示，FactoryA和FactoryB都实现·抽象工厂接口，分别用于创建ProductA和ProductB。如果后续新增了ProductC，只需新增一个FactoryC即可，无需修改原有的代码；因为每个工厂只负责创建一个产品，因此也遵循了单一职责原则。\nGo实现 考虑需要如下一个插件架构风格的消息处理系统，pipeline是消息处理的管道，其中包含了input、filter和output三个插件。我们需要实现根据配置来创建pipeline ，加载插件过程的实现非常适合使用工厂模式，其中input、filter和output三类插件的创建使用抽象工厂模式，而pipeline的创建则使用工厂方法模式。\n各类插件和pipeline的接口定义如下：\npackage plugin ... // 插件抽象接口定义 type Plugin interface {} // 输入插件，用于接收消息 type Input interface { Plugin Receive() string } // 过滤插件，用于处理消息 type Filter interface { Plugin Process(msg string) string } // 输出插件，用于发送消息 type Output interface { Plugin Send(msg string) } package pipeline ... // 消息管道的定义 type Pipeline struct { input plugin.Input filter plugin.Filter output plugin.Output } // 一个消息的处理流程为 input -\u0026gt; filter -\u0026gt; output func (p *Pipeline) Exec() { msg := p.input.Receive() msg = p.filter.Process(msg) p.output.Send(msg) } 接着，我们定义input、filter、output三类插件接口的具体实现：\npackage plugin ... // input插件名称与类型的映射关系，主要用于通过反射创建input对象 var inputNames = make(map[string]reflect.Type) // Hello input插件，接收“Hello World”消息 type HelloInput struct {} func (h *HelloInput) Receive() string { return \u0026#34;Hello World\u0026#34; } // 初始化input插件映射关系表 func init() { inputNames[\u0026#34;hello\u0026#34;] = reflect.TypeOf(HelloInput{}) } package plugin ... // filter插件名称与类型的映射关系，主要用于通过反射创建filter对象 var filterNames = make(map[string]reflect.Type) // Upper filter插件，将消息全部字母转成大写 type UpperFilter struct {} func (u *UpperFilter) Process(msg string) string { return strings.ToUpper(msg) } // 初始化filter插件映射关系表 func init() { filterNames[\u0026#34;upper\u0026#34;] = reflect.TypeOf(UpperFilter{}) } package plugin ... // output插件名称与类型的映射关系，主要用于通过反射创建output对象 var outputNames = make(map[string]reflect.Type) // Console output插件，将消息输出到控制台上 type ConsoleOutput struct {} func (c *ConsoleOutput) Send(msg string) { fmt.Println(msg) } // 初始化output插件映射关系表 func init() { outputNames[\u0026#34;console\u0026#34;] = reflect.TypeOf(ConsoleOutput{}) } 然后，我们定义插件抽象工厂接口，以及对应插件的工厂实现：\npackage plugin ... // 插件抽象工厂接口 type Factory interface { Create(conf Config) Plugin } // input插件工厂对象，实现Factory接口 type InputFactory struct{} // 读取配置，通过反射机制进行对象实例化 func (i *InputFactory) Create(conf Config) Plugin { t, _ := inputNames[conf.Name] return reflect.New(t).Interface().(Plugin) } // filter和output插件工厂实现类似 type FilterFactory struct{} func (f *FilterFactory) Create(conf Config) Plugin { t, _ := filterNames[conf.Name] return reflect.New(t).Interface().(Plugin) } type OutputFactory struct{} func (o *OutputFactory) Create(conf Config) Plugin { t, _ := outputNames[conf.Name] return reflect.New(t).Interface().(Plugin) } 最后定义pipeline的工厂方法，调用plugin.Factory抽象工厂完成pipelien对象的实例化：\npackage pipeline ... // 保存用于创建Plugin的工厂实例，其中map的key为插件类型，value为抽象工厂接口 var pluginFactories = make(map[plugin.Type]plugin.Factory) // 根据plugin.Type返回对应Plugin类型的工厂实例 func factoryOf(t plugin.Type) plugin.Factory { factory, _ := pluginFactories[t] return factory } // pipeline工厂方法，根据配置创建一个Pipeline实例 func Of(conf Config) *Pipeline { p := \u0026amp;Pipeline{} p.input = factoryOf(plugin.InputType).Create(conf.Input).(plugin.Input) p.filter = factoryOf(plugin.FilterType).Create(conf.Filter).(plugin.Filter) p.output = factoryOf(plugin.OutputType).Create(conf.Output).(plugin.Output) return p } // 初始化插件工厂对象 func init() { pluginFactories[plugin.InputType] = \u0026amp;plugin.InputFactory{} pluginFactories[plugin.FilterType] = \u0026amp;plugin.FilterFactory{} pluginFactories[plugin.OutputType] = \u0026amp;plugin.OutputFactory{} } 测试代码如下：\npackage test ... func TestPipeline(t *testing.T) { // 其中pipeline.DefaultConfig()的配置内容见【抽象工厂模式示例图】  // 消息处理流程为 HelloInput -\u0026gt; UpperFilter -\u0026gt; ConsoleOutput \tp := pipeline.Of(pipeline.DefaultConfig()) p.Exec() } // 运行结果 === RUN TestPipeline HELLO WORLD --- PASS: TestPipeline (0.00s) PASS 原型模式（Prototype Pattern） 简述 原型模式主要解决对象复制的问题，它的核心就是clone()方法，返回Prototype对象的复制品。在程序设计过程中，往往会遇到有一些场景需要大量相同的对象，如果不使用原型模式，那么我们可能会这样进行对象的创建：新创建一个相同对象的实例，然后遍历原始对象的所有成员变量， 并将成员变量值复制到新对象中。这种方法的缺点很明显，那就是使用者必须知道对象的实现细节，导致代码之间的耦合。另外，对象很有可能存在除了对象本身以外不可见的变量，这种情况下该方法就行不通了。\n对于这种情况，更好的方法就是使用原型模式，将复制逻辑委托给对象本身，这样，上述两个问题也都迎刃而解了。\nGo实现 还是以建造者模式一节中的Message作为例子，现在设计一个Prototype抽象接口：\npackage prototype ... // 原型复制抽象接口 type Prototype interface { clone() Prototype } type Message struct { Header *Header Body *Body } func (m *Message) clone() Prototype { msg := *m return \u0026amp;msg } 测试代码如下：\npackage test ... func TestPrototype(t *testing.T) { message := msg.Builder(). WithSrcAddr(\u0026#34;192.168.0.1\u0026#34;). WithSrcPort(1234). WithDestAddr(\u0026#34;192.168.0.2\u0026#34;). WithDestPort(8080). WithHeaderItem(\u0026#34;contents\u0026#34;, \u0026#34;application/json\u0026#34;). WithBodyItem(\u0026#34;record1\u0026#34;). WithBodyItem(\u0026#34;record2\u0026#34;). Build() // 复制一份消息 \tnewMessage := message.Clone().(*msg.Message) if newMessage.Header.SrcAddr != message.Header.SrcAddr { t.Errorf(\u0026#34;Clone Message failed.\u0026#34;) } if newMessage.Body.Items[0] != message.Body.Items[0] { t.Errorf(\u0026#34;Clone Message failed.\u0026#34;) } } // 运行结果 === RUN TestPrototype --- PASS: TestPrototype (0.00s) PASS 总结 本文主要介绍了GoF的23种设计模式中的5种创建型模式，创建型模式的目的都是提供一个简单的接口，让对象的创建过程与使用者解耦。其中，单例模式主要用于保证一个类仅有一个实例，并提供一个访问它的全局访问点；建造者模式主要解决需要创建对象时需要传入多个参数，或者对初始化顺序有要求的场景；工厂方法模式通过提供一个工厂对象或者工厂方法，为使用者隐藏了对象创建的细节；抽象工厂模式是对工厂方法模式的优化，通过为工厂对象新增一个抽象层，让工厂对象遵循单一职责原则，也避免了霰弹式修改；原型模式则让对象复制更加简单。\n下一篇文章，将介绍23种设计模式中的7种结构型模式（Structural Pattern），及其Go语言的实现。\n","date":"2020-08-10T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BD%BF%E7%94%A8go%E5%AE%9E%E7%8E%B0gof%E7%9A%8423%E7%A7%8D%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E4%B8%80/","title":"使用Go实现GoF的23种设计模式（一）"},{"content":"前言 在上一篇文章《从Hash索引到LSM树（一）》中，我们通过append-only log的数据结构，实现了一个具备高写入性能的key-value数据库。append-only log之所以有很高的写入性能，主要得益于磁盘的顺序写入。这可能违反了我们对磁盘的认知，因为在我们的印象中，写磁盘总是很慢。其实不然，准确地说应该是随机写磁盘很慢，因为在写之前可能会进行多次寻址。如果只是顺序写磁盘，性能是非常的高，如下的一个ACM报告中显示，顺序写磁盘甚至比随机写内存的性能还要高！\n 举个例子，Kafka是一个高性能的消息队列，它的厉害之处就在于极致地利用磁盘的顺序写入性能，如果生产者和消费者的速率相当，消息甚至可以在操作系统的Page Cache层面就完成了传递。所以，以后别再认为写磁盘很慢了！\n append-only log大幅提升了数据写入性能，但是随之而来的是，非常低的数据读取性能。针对这一点，我们采用Hash索引进行了优化，优化的效果也非常的显著。然而，Hash索引有两个明显的限制：（1）当key的数量很多时，维护Hash索引会给内存带来很大的压力；（2）区间查询很低效。如何对这两个限制进行优化呢？这就轮到本文介绍的主角，LSM树，出场了。\nLSM树（Log-Structured Merge Tree）并不是一种数据结构，准确来说是一种存储模型，由MemTable、Immutable MemTable、SSTable等部分组成。它也是利用了append-only log的优势，大幅提升了写入性能。同时，因为key的存储有序性，所以具备了不错的读取性能，也克服了上文所述Hash索引的两个限制。下面，本文将一步步分析LSM树是如何做到这一点的。\nSSTable 在最简单的数据库例子中，因为数据是无序存储的，所以在读取某个key的值时，就需要遍历整个数据文件，算法复杂度是O(n)。为了提升读性能，我们不得不在内存中维护所有key的Hash索引。\n假如存储数据时，对记录按照key进行排序的会怎样？\n对于key有序存储这种情况，即使不用Hash索引，也能得到很好的查询效率，因为我们可以使用二分查找法（Binary Search）来快速找到key所在的位置，算法复杂度是O(logn)。LSM树正是采用key有序这种方式来组织数据存储的，并称之为SSTable。\nSSTable（Sorted String Table）是LSM树最基础的一个存储结构，存储在磁盘中，并且数据按照key进行排序的。数据保持key有序的好处是可以在O(logn)的时间下，快速找到一个key值，相比于纯粹的append-only log有了很大的提升。但是，如果所有的数据都存储在一个SSTable上，数据量一大，查询效率也会下降。因此，LSM树通常会将数据分散存储在多个SSTable中，并且记录每个SSTable的最大key和最小key，这样就能快速定位到一个key存储在哪个SSTable上了。\n// SSTable，数据保存到SSTable后只读不写 public class SSTable { ... // 数据存储路径  private final LogFile logFile; // 该SStable中存储的最小Key  private String minKey; // 该SStable中存储的最大Key  private String maxKey; // 使用二分查找法获取key值  public String get(String key) { // step1：先判断是否在SSTable的范围内  if (key.compareTo(minKey) \u0026lt; 0 || key.compareTo(maxKey) \u0026gt; 0) { return \u0026#34;\u0026#34;; } // step2：二分查找  long start = 0; long end = logFile.size(); while (start \u0026lt; end) { long mid = start + (end - start) / 2; // 先找到一条record到起始offset  long startOffset = logFile.startOffsetOf(mid); String record = logFile.read(startOffset); String midKey = Util.keyOf(record); if (key.compareTo(midKey) == 0) { return Util.valueOf(record); } else if (key.compareTo(midKey) \u0026lt; 0) { end = mid; } else { // 找到一条record到起始offset时可能会有mid == start的情况  if (mid == start) { break; } start = mid; } } return \u0026#34;\u0026#34;; } ... }  这里只是介绍了一种比较简单的SSTable实现方式，实际上，各种LSM树存储引擎对SSTable的实现都有所差异，比如LevelDB就将SSTable划分成两大块，数据存储区存储key:value数据，数据管理区存储索引等数据。\n 那么怎样才能保证SSTable的有序性呢？\n类似的在磁盘中维护数据有序的存储结构最常见的当属B/B+树了，如果对SSTable也采用类似的存储结构，那么带来的第一个问题就是每次写入都会伴随着磁盘的随机写，从而影响了数据的写入性能，这明显就违反了LSM树的初衷。为了同时兼顾SSTable的有序性以及写入性能，LSM树采用了MemTable这一组件。\nMemTable 相比于磁盘上维护一个有序的数据结构，在内存上实现数据有序要简单得多，而且具备较高的读写性能，常见的有序数据结构有红黑树、AVL树、跳表等，不管你插入的顺序如何，读出来的数据总是有序的。MemTable正是LSM维护在内存中的一个有序的数据结构，接下来我们看下LSM是如何利用Memtable做到同时兼顾SSTable的有序行和写入性能的：\n1、当写入请求过来时，先将record写入到Memtable中，这样就能保证record在内存中有序了，而且具备较高的写入性能。\n2、当Memtable的大小达到一定的阈值后（通常是几个Mb的大小），将MemTable转成Immutable MemTable（一个只读不写的MemTable），并创建新的MemTable接收写请求。\n 和《从Hash索引到LSM树（一）》中的segment file机制类似，一个时刻只有current segment file接收写请求，其他的只读不写。\n 3、通过后台任务，定时将Immutable MemTable的数据刷到SSTable中，因为Immutable MemTable本身的有序性，所以就能保证SSTable中的数据是有序的，而且数据写入SSTable文件时完全是顺序写入，做到了有序性和写入性能的兼顾。\n4、当读请求过来时，查找的顺序是MemTable-\u0026gt;Immutable MemTable-\u0026gt;SSTable，找到则返回，否则一步步执行下去。\nMemtable底层通常采用跳表来实现（LevelDB、HBase都采用了这一实现方法），相比较AVL和红黑树，跳表在插入数据的时候可以避免频繁的树节点调整操作，所以写入效率很高，而且实现起来也更简单些。\n// LSM维护在内存中的有序数据结构，数据写入时先写MemTable public class MemTable { // 基于跳表实现的key-value结构  private final ConcurrentSkipListMap\u0026lt;String, String\u0026gt; cache; // 当前存储数据的大小  private AtomicInteger size; ... // 查找key  public String get(String key) { if (!cache.containsKey(key)) { return \u0026#34;\u0026#34;; } return cache.get(key); } // 添加key:value，并更新当前Memtable的大小  public void add(String key, String value) { cache.put(key, value); size.addAndGet(key.length() + value.length()); } // 返回当前Memtable的大小（字节为单位）  // 用于判断达到阈值之后，转成Immutable MemTable  public int size() { return this.size.get(); } // 达到阈值之后转储到SSTable  public void compact2Sst(SSTable sst) { cache.forEach(sst::add); } ... } LsmKvDb 使用LSM树作为存储引擎的数据库，通常对SSTable进行分层管理，方便查询以及后续的Compact操作。本文也将采用对SSTable进行分层的架构实现LsmKvDb。\n首先对Level进行抽象，每个Level都由多个SSTable组成：\n// 对LSM的层的抽象，由SSTable组成 public class Level { private final List\u0026lt;SSTable\u0026gt; ssts; ... public void add(SSTable sst) { ssts.add(sst); } // 在当前level中查找key对应的value, 从老到新遍历所有SSTable  public String find(String key) { for (int i = ssts.size() - 1; i \u0026gt;= 0; i--) { String value = ssts.get(i).get(key); if (!value.equals(\u0026#34;\u0026#34;)) { return value; } } return \u0026#34;\u0026#34;; } // sst与当前level进行compact操作  public void compactWith(SSTable sst) {...} // 对给定sst集合与当前level进行compact操作  public void compactWith(List\u0026lt;SSTable\u0026gt; ssts) {...} } LsmKvDb的实现代码如下，写数据时写入MemTable，当达到阈值后转Immutable MemTable。Immutable MemTable与MemTable具有相同的数据结构，唯一不同的是前者只读不写，后者既读也写。\n/** * 基于LSM树的key-value数据库, 采用分层架构 * MemTable -\u0026gt; Immutable MemTable -\u0026gt; Level0 -\u0026gt; Level1 -\u0026gt; Level2 */ public class LsmKvDb implements KvDb { ... // 存储SSTable的目录  private final String sstDir; // 当前写入的MemTable  private MemTable memTable; // MemTable到达阈值大小后转储到immutableMts  private final List\u0026lt;MemTable\u0026gt; immutableMts; // 后台定时将immutableMts中的MemTable刷到Level0，各SSTable之间可能由Key重叠  private final Level level0; // 后台定时将Level0中的SSTable与Level1中的进行合并  private final Level level1; // 当Level1中的SSTable的数量到达一定阈值后，选择最老的SSTable与Level2中的进行合并  private final Level level2; ... @Override public String get(String key) { // step1: 从MemTable中读取  String value = memTable.get(key); if (!value.equals(\u0026#34;\u0026#34;)) { return value; } // step2: 从Immutable MemTable中读取，从新到老  for (int i = immutableMts.size() - 1; i \u0026gt;= 0; i--) { value = immutableMts.get(i).get(key); if (!value.equals(\u0026#34;\u0026#34;)) { return value; } } // step3: 从Level0中读取  value = level0.find(key); if (!value.equals(\u0026#34;\u0026#34;)) { return value; } // step4: 从Level1中读取  ... // step5: 从Level2中读取  ... return \u0026#34;\u0026#34;; } @Override public void set(String key, String value) { memTable.add(key, value); // 当MemTable大小到达阈值后转成Immutable MemTable  if (memTable.size() \u0026gt; MEMTABLE_MAX_SIZE) { synchronized (this) { immutableMts.add(memTable); memTable = MemTable.create(); } } } ... } Compaction 上一篇文章《从Hash索引到LSM树（一）》已经对Compaction机制已经有了讲解，其目的是清除掉已经被覆写或删除了的记录，避免数据存储文件无休止的增长下去。对于LSM树而言，该机制同样适用，随着数据的不断添加、更新和删除，一些SSTable之间必然存在着重叠的key或被删除的key。通过Compaction，可以将多个SSTable合并成一个，从而节省了磁盘空间。\n 在上篇文章中，对segment file的compact操作主要依赖于Hash索引。因为是索引覆盖全部的key，所以可以很容易通过新的segment file的Hash索引来判断该key是否已经被处理过。但对于SSTable而言，并没有覆盖全部key的Hash索引，那么如何进行compact才高效呢？\n得益于SSTable的有序性，我们可以应用归并排序算法来进行compact操作！\n LSM树的Compaction通常有三种类型，分别是minor compact、major compact和full compact。\nMinor Compact minor compact指的是将Immutable MemTable中的数据直接转存到Level0中的SSTable。\n因为是直接将各个Immutable MemTable的数据转储成SSTable，并没有做合并的操作，因此在Level0中，各个SSTable之间的key可能存在重叠的现象。\nMajor Compact major compact指的是将Level n中的SSTable合并到Level n+1中。\nLevel0 -\u0026gt; Level1的合并步骤如下：\n1、选中Level0中的最老的SSTable sst0，然后在Level0中找到与sst0 的key存在重叠的所有SSTable sst0...n。\n2、在Level1中，选取所有与 sst0...n存在key重叠的SSTable sst'0...m。\n3、对sst0...n和sst'0...m采用多路归并排序算法进行合并，得到新的sst‘’0...k，并存储在Level1中。\n4、删除sst0...n和sst'0...m。\n不同于Level0，Level1和Level2中各个SSTable之间并不存在key重叠的现象，因此Level1 -\u0026gt; Level2的合并会稍微简单些。\nLevel1 -\u0026gt; Level2的合并步骤如下：\n1、选中Level1中的最老的SSTable sst0。\n2、在Level2中，选取所有与 sst0存在key重叠的SSTable sst'0...m。\n3、对sst0和sst'0...m采用多路归并排序算法进行合并，得到新的sst‘’0...k，并存储在Level2中。\n4、删除sst0和sst'0...m。\nFull Compact full compact指的是对Level0、Level1、Level2中所有的SSTable进行compact操作，同样也是采用多路归并排序算法。\n通常full compact耗时较多，所以一般都会选择在流量较少的时刻进行。\n优化LSM树 为SSTable引入block 到目前为止，对于在一个SSTable中查找一个key，我们首先会根据min key和max key判断该key是否属于该SSTable所属的范围，如果属于，则对SSTable采用二分查找法进行搜索。二分查找之所以在LsmKvDb中行得通，是因为这是一个简单的SSTable实现 —— 数据按string存储和\\n分隔。在实际的运用中，为了尽可能地利用磁盘空间，SSTable中数据通常都是以字节码的形式存储，也不会以\\n分隔每条record，这种情况下采用二分查找的实现就比较复杂了，而且效率也会太高。\n一个常见的优化方法是，在SSTable中对record按照一定的size组织成多个block，并以block为单位进行压缩。为了能够快速找到某个key所属的block，需要在内存中维护每个block的起始key对应在SSTable中的offset（一个稀疏的Hash索引）。\n在查找key的步骤如下：\n1、根据索引定位到key所属的block。\n2、将该block加载到内存中，并解压。\n3、对内存中的数据采用二分查找。\n 在设计block的大小时，应该利用磁盘的空间局部性原理，使得系统能够只花费一次磁盘I/O就能将整个block加载到内存中。\n 为SSTable引入Bloom Filter 其实当目标key属于某个SSTable的key范围时，该key也不一定会存在于该SSTable中。但是到目前为止，只要目标key在某个SSTable的范围内，LsmKvDb都会进行查找操作。随着系统中的SSTable数目的增多，查询效率必然会随之下降。\n一个常见的优化方法时，为SSTable引入布隆过滤器Bloom Filter。\nBloom Filter是保存在内存中的一种数据结构，可以用来告诉你 “某样东西一定不存在或者可能存在”。它由一个超长的二进制位数组和一系列的Hash函数组成。二进制位数组初始全部为0，当有元素加入集合时，这个元素会被一系列Hash函数计算映射出一系列的值，所有的值在位数组的偏移量处置为1。如果需要判断某个元素是否存在于集合当中，只需判断该元素被Hash后的值在数组中的值，如果存在为0的，则该元素一定不存在；如果全为1，则可能存在，这种情况可能有误判。\n通过Bloom Filter，我们可以很快就能判断目标key是否不存在于SSTable中，从而提升了读性能。\n Google的Guava库就提供了一个BloomFilter的实现，并且可以按需来设置误判率。\n 总结 本文承接上《从Hash索引到LSM树（一）》，主要介绍了LSM树的基本原理，并且在原来append-only log的基础上实现了一个简单的基于LSM树的key-value数据库LsmKvDb。LSM树主要由MemTable、Immutable MemTable、SSTable组成，其中MemTable和Immutable MemTable在内存中维护，而SSTable则存储在磁盘中。SSTable的有序性使得LSM树在无需Hash索引的情况下具有不错的读取性能，而且支持区间查询；而Memable则使得LSM树具备很高的写入性能。\n本系列文章，我们从一个最简单的key-value数据库起步，一步步通过Hash索引、LSM树、布隆过滤器等技术手段对其进行了优化，从中也深入分析了各个技术点的实现原理。但数据库的索引技术远不止这些，比如最常用到的B/B+树，也是非常值得深入学习的，以后有机会再对其进行深入分析.\n","date":"2020-07-28T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BB%8Ehash%E7%B4%A2%E5%BC%95%E5%88%B0lsm%E6%A0%91%E4%BA%8C/","title":"从Hash索引到LSM树（二）"},{"content":"前言 数据库算是软件应用系统中最常用的一类组件了，不管是一个庞大而复杂的电商系统，还是一个简单的个人博客，多多少少都会用到数据库，或是存储海量的数据，或是存储简单的状态信息。一般地，我们都喜欢将数据库划分为关系型数据库和非关系型数据库（又称NoSQL数据库），前者的典型代表是MySQL数据库，后者的典型代表是HBase数据库。不管是关系型，还是非关系型，数据库都离不开两个最基本的功能：（1）数据存储；（2）数据查询。简单来说就是，当你把数据丢给数据库时，它能够保持下来，并在稍后你想获取的时候，把数据返回给你。\n围绕着这两个基本功能，各类数据库都运用了很多技术手段对其进行了优化，其中最广为人知的当属数据库索引技术。索引是一种数据结构，它在牺牲少量数据存储（写）性能的情况下，可以大幅提升数据查询（读）性能。索引也有很多种类型，Hash索引算是最简单高效的一种了，但是由于它自身的限制，在数据库系统中并不被广泛使用。当今最常用的索引技术是B/B+树索引，被广泛地应用在关系型数据库中，主要应用于读多写少的场景。随着NoSQL数据库的兴起，LSM（Log-Structured Merged-Tree）树也逐渐流行，并被Google的BigTable论文所发扬光大。严格来说，LSM树并不算一种传统意义上的索引，它更像是一种设计思想，主要应用于写多读少的场景。\n本系列文章，将从实现最简单的Key-Value数据库讲起，然后针对实现过程中遇到的一些瓶颈，采用上述的索引技术，对数据库进行优化，以此达到对数据库的索引技术有一个较为深刻的理解。\n最简单的数据库 Martin Kleppmann在《Designing Data-Intensive Applications》一书中给出了一个最简单数据库的实现：\n#!/bin/bash db_set() { echo \u0026#34;$1,$2\u0026#34; \u0026gt;\u0026gt; database } db_get() { grep \u0026#34;^$1,\u0026#34; database | sed -e \u0026#34;s/^$1,//\u0026#34; | tail -n 1 } 这不到10行的shell代码实现了一个简单的Key-Value数据库。它一共有两个函数，db_set和db_get，前者对应数据存储功能，后者对应数据查询功能。该数据库采用简单的文本格式（database文件）进行数据存储，每条记录包含了一个键值对，key和value之间通过逗号（,）进行分隔。\n数据库的使用方法也很简单，通过调用db_set key value可以将key及其对应的value存储到数据库中；通过db_get key可以得到该key对应的value：\n$ db_set 123456 \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;London\u0026#34;,\u0026#34;attractions\u0026#34;:[\u0026#34;Big Ben\u0026#34;,\u0026#34;London Eye\u0026#34;]}\u0026#39; $ db_set 42 \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;San Francisco\u0026#34;,\u0026#34;attractions\u0026#34;:[\u0026#34;Golden Gate Bridge\u0026#34;]}\u0026#39; $ db_get 42 {\u0026#34;name\u0026#34;:\u0026#34;San Francisco\u0026#34;,\u0026#34;attractions\u0026#34;:[\u0026#34;Golden Gate Bridge\u0026#34;]} 透过db_set的实现我们发现，该数据库每次写都是直接往database文件中追加记录，鉴于文件系统顺序写的高效，因此该数据库的数据写入具有较高的性能。但是追加写也意味着，对同一个key进行更新时，其对应的旧的value并不会被覆盖，这也使得每次调用db_get获取某个key的value时，总是需要遍历所有记录，找到所有符合条件的value，并取其中最新的一个。因此，该数据库的读性能是非常低的。\n下面我们采用Java对这个最简单的数据库进行重写：\n/** * SimpleKvDb.java * 追加写 * 全文件扫描读 */ public class SimpleKvDb implements KvDb { ... @Override public String get(String key) { try (Stream\u0026lt;String\u0026gt; lines = logFile.lines()) { // step1: 筛选出该key对应的的所有value（对应grep \u0026#34;^$1,\u0026#34; database）  List\u0026lt;String\u0026gt; values = lines .filter(line -\u0026gt; Util.matchKey(key, line)) .collect(Collectors.toList()); // step2: 选取最新值（对应 sed -e \u0026#34;s/^$1,//\u0026#34; | tail -n 1）  String record = values.get(values.size() - 1); return Util.valueOf(record); } catch (IOException e) { e.printStackTrace(); return null; } } @Override public void set(String key, String value) { // step1: 追加写（对应 echo \u0026#34;$1,$2\u0026#34; \u0026gt;\u0026gt; database）  String record = Util.composeRecord(key, value); logFile.append(record); } ... } 使用JHM进行压测结果如下：\nBenchmark Mode Cnt Score Error Units SimpleKvDbReadBenchmark.simpleKvDb_get_10000_test avgt 8 0.631 ± 0.033 ms/op // 读耗时，1w条记录 SimpleKvDbReadBenchmark.simpleKvDb_get_100000_test avgt 8 7.020 ± 0.842 ms/op // 读耗时，10w条记录 SimpleKvDbReadBenchmark.simpleKvDb_get_1000000_test avgt 8 62.562 ± 5.466 ms/op // 读耗时，100w条记录 SimpleKvDbWriteBenchmark.simpleKvDb_set_test avgt 8 0.036 ± 0.005 ms/op // 写耗时 从结果可以看出，该数据库的实现具有较高的写性能，但是读性能很低，而且读耗时会随着数据量的增加而线性增长。\n那么如何优化SimpleKvDb的读性能？引入索引技术！\n索引（index）是从数据库中的数据衍生出来的一种数据结构，它并不会对数据造成影响，只会影响数据库的读写性能。对于读操作，它能快速定位到目的数据，从而极大地提升读性能；对于写操作，由于需要增加额外的更新索引操作，因此会略微降低写性能。\n 如前言所述，索引也有很多类型，每种索引的特点都各有差别。因此到底要不要采用索引，具体采用哪种索引，需要根据实际的应用场景来决定。\n 下面，我们将首先采用最简单高效的Hash索引对SimpleKvDb进行优化。\n给数据库加上Hash索引 考虑到Key-Value数据库本身就类似于Hash表的这个特点，我们很容易想到如下的索引策略：在内存中维护一个Hash表，记录每个key对应的记录在数据文件（如前文所说的database文件）中的字节位移（byte offset）。\n对于写操作，在往数据文件追加记录后，还需要更新Hash表；对于读操作，首先通过Hash表确定该key对应记录在数据文件中的位移，然后通过字节位移快速找到value在数据文件中的位置并读取，从而避免了全文遍历这样的低效行为。\n给SimpleKvDb加上Hash索引，对应的代码实现为：\n/** * HashIdxKvDb.java * 追加写 * 使用Hash索引提升读性能 */ public class HashIdxKvDb implements KvDb { // 数据存储文件  private final LogFile curLog; // 索引，value为该key对应的数据在文件中的offset  private final Map\u0026lt;String, Long\u0026gt; idx; ... @Override public String get(String key) { if (!idx.containsKey(key)) { return \u0026#34;\u0026#34;; } // step1: 读取索引  long offset = idx.get(key); // step2: 根据索引读取value  String record = curLog.read(offset); return Util.valueOf(record); } @Override public void set(String key, String value) { String record = Util.composeRecord(key, value); long curSize = curLog.size(); // step1: 追加写数据  if (curLog.append(record) != 0) { // step2: 更新索引  idx.put(key, curSize); } } ... } 在实现HashIdxKvDb之前，我们将数据存储文件抽象成了LogFile对象，它的两个基本方法是append（追加写记录）和read（根据offset读记录），其中，read函数通过RandomAccessFile的seek方法快速定位到记录所处的位置，具体实现如下：\n// 追加写的日志文件，存储数据库数据 class LogFile { // 文件所在路径  private Path path; ... // 向日志文件中写入一行记录，自动添加换行符  // 返回成功写入的字节大小  long append(String record) { try { record += System.lineSeparator(); Files.write(path, record.getBytes(), StandardOpenOption.APPEND); return record.getBytes().length; } catch (IOException e) { e.printStackTrace(); } return 0; } // 读取offset为起始位置的一行  String read(long offset) { try (RandomAccessFile file = new RandomAccessFile(path.toFile(), \u0026#34;r\u0026#34;)) { file.seek(offset); return file.readLine(); } catch (IOException e) { e.printStackTrace(); } return \u0026#34;\u0026#34;; } ... } 使用JHM进行压测结果如下：\nBenchmark Mode Cnt Score Error Units HashIdxKvDbReadBenchmark.hashIdxKvDb_get_10000_test avgt 8 0.021 ± 0.001 ms/op // 读耗时，1w条记录 HashIdxKvDbReadBenchmark.hashIdxKvDb_get_100000_test avgt 8 0.021 ± 0.001 ms/op // 读耗时，10w条记录 HashIdxKvDbReadBenchmark.hashIdxKvDb_get_1000000_test avgt 8 0.021 ± 0.001 ms/op // 读耗时，100w条记录 HashIdxKvDbWriteBenchmark.hashIdxKvDb_set_test avgt 8 0.038 ± 0.005 ms/op // 写耗时 从压测结果可以看出，相比SimpleKvDb，HashIdxKvDb的读性能有了大幅的提升，而且读耗时不再随着数据量的增加而成线性增长；而且写性能并没有明显的下降。\n虽然Hash索引实现很简单，却是极其的高效，它只需要1次磁盘寻址（seek操作），加上1次磁盘I/O（readLine操作），就能将数据加载出来。如果数据之前已经加载到文件系统缓存里，甚至都不用磁盘I/O。\n数据合并——compact 到目前为止，不管是SimpleKvDb，还是HashIdxKvDb，写操作都是不断在一个文件中追加数据，这种存储方式，通常我们称之为append-only log。\n那么，如何才能避免append-only log无休止的一直扩张下去，直到磁盘空间不足呢？\nappend-only log的一个显著特点是旧的记录不会被覆盖删除，但是这些数据往往是无用的，因为读取某个key的value时，数据库都是取其最新的值。因此，解决该问题的一个思路是把这些无用的记录清除掉：\n（1）当往append-only log追加数据到达一定大小后，另外创建一个新的append-only log进行追加。在这种机制下，数据分散到多个append-only log文件中存储，我们称这些log文件为segment file。\n（2）保证只有当前的current segment file是可读可写，old segment file只读不写。\n（3）对old segment file进行compact操作——只保留每个key对应的最新记录，把的老记录删除。\ncompact操作往往是在后台线程中执行，数据库会将合并的结果写到一个新的compacted segment file中，这样在执行compact操作时，就不会影响从old segment file中读数据的逻辑。等到compact操作完成之后，再把old segment file删除，后续的读操作迁移到compacted segment file上。\n现在，单个compacted segment file中的key都是唯一的，但是多个compacted segment file之间还是有可能存在重复的key，我们还能够更近一步，对多个compacted segment file再一次进行compact操作，这样数据量会再次减少。\n 类似的compact操作可以一层层执行下去，比如，可以对level2 compacted segment file进行compact，生成level3 compacted segment file。但是并不是compact的层次越多越好,具体的compact策略需要结合实际的应用场景进行设计。\n 给HashIdxKvDb加上compact机制 下面，我们试着给HashIdxKvDb加上compact机制。由于在compact机制下，数据会分散在多个segment file中存储，因此之前的Hash索引机制不再适用，我们需要给每个segment file都单独维护一份Hash索引。当然，这样也比较容易实现，只需要维护一个Hash表，key为segment file，value为该segment file对应的Hash索引。\n对应的代码实现如下：\n// 多文件哈希索引实现 class MultiHashIdx { ... private Map\u0026lt;LogFile, Map\u0026lt;String, Long\u0026gt;\u0026gt; idxs; // 获得指定LogFile中，Key的索引  long idxOf(LogFile file, String key) { if (!idxs.containsKey(file) || !idxs.get(file).containsKey(key)) { return -1; } return idxs.get(file).get(key); } // 添加指定LogFile中Key的索引  void addIdx(LogFile file, String key, long offset) { idxs.putIfAbsent(file, new ConcurrentHashMap\u0026lt;\u0026gt;()); idxs.get(file).put(key, offset); } ... } 另外，我们还需要在CompactionHashIdxKvDb里分别维护一份old segment file、level1 compacted segment file和level2 compacted segment file集合，并通过ScheduledExecutorService定时对这些集合进行compact操作。\n/** * 追加写，当当前segemnt file到达一定大小后，追加到新到segment file上。并定时对旧的segment file进行compact。 * 为每个segment file维持一个哈希索引，提升读性能 * 支持单线程写，多线程读 */ public class CompactionHashIdxKvDb implements KvDb { ... // 当前追加写的segment file路径  private LogFile curLog; // 写old segment file集合，会定时对这些文件进行level1 compact合并  private final Deque\u0026lt;LogFile\u0026gt; toCompact; // level1 compacted segment file集合，会定时对这些文件进行level2 compact合并  private final Deque\u0026lt;LogFile\u0026gt; compactedLevel1; // level2 compacted segment file集合  private final Deque\u0026lt;LogFile\u0026gt; compactedLevel2; // 多segment file哈希索引  private final MultiHashIdx idx; // 进行compact的定时调度  private final ScheduledExecutorService compactExecutor; ... } 相比于HashIdxKvDb， CompactionHashIdxKvDb 在写入新的数据之前，需要判断当前文件大小是否写满，如果写满了，则需要创建新的LogFile进行追加，并将写满后的LogFile归档到toCompact队列中。\n@Override public void set(String key, String value) { try { // 如果当前LogFile写满了，则放到toCompact队列中，并创建新的LogFile  if (curLog.size() \u0026gt;= MAX_LOG_SIZE_BYTE) { String curPath = curLog.path(); Map\u0026lt;String, Long\u0026gt; oldIdx = idx.allIdxOf(curLog); curLog.renameTo(curPath + \u0026#34;_\u0026#34; + toCompactNum.getAndIncrement()); toCompact.addLast(curLog); // 创建新的文件后，索引也要更新  idx.addAllIdx(curLog, oldIdx); curLog = LogFile.create(curPath); idx.cleanIdx(curLog); } String record = Util.composeRecord(key, value); long curSize = curLog.size(); // 写成功则更新索引  if (curLog.append(record) != 0) { idx.addIdx(curLog, key, curSize); } } catch (IOException e) { e.printStackTrace(); } } CompactionHashIdxKvDb 的读操作相对麻烦，因为数据被分散在多个segment file上，所以需要按照如下顺序完成数据查找，直到查询到为止：当前追加的LogFile-\u0026gt;toCompact队列-\u0026gt;compactedLevel1队列-\u0026gt;compactedLevel2队列。因此，CompactionHashIdxKvDb 的读操作在极端情况下（所查询的数据存储在comactedLevel2队列上时）也会较为低效。\n@Override public String get(String key) { // 第一步：从当前的LogFile查找  if (idx.idxOf(curLog, key) != -1) { long offset = idx.idxOf(curLog, key); String record = curLog.read(offset); return Util.valueOf(record); } // 第二步：从toCompact中查找  String record = find(key, toCompact); if (!record.isEmpty()) { return record; } // 第三步：从 compactedLevel1 中查找  record = find(key, compactedLevel1); if (!record.isEmpty()) { return record; } // 第四步：从 compactedLevel2 中查找  record = find(key, compactedLevel2); if (!record.isEmpty()) { return record; } return \u0026#34;\u0026#34;; } 对toCompact队列里的old segment file进行单文件level1 compact操作时，可以利用Hash索引。因为Hash索引上所对应的记录总是最新的，因此只需遍历Hash索引，将每个key对应的最新记录查询出来，写到新的level1 compacted segment file中即可。\n// 进行level1 compact，对单个old segment file合并 void compactLevel1() { while (!toCompact.isEmpty()) { // 创建新的level1 compacted segment file  LogFile newLogFile = LogFile.create(curLog.path() + \u0026#34;_level1_\u0026#34; + level1Num.getAndIncrement()); LogFile logFile = toCompact.getFirst(); // 只保留每个key对应的最新的value  idx.allIdxOf(logFile).forEach((key, offset) -\u0026gt; { String record = logFile.read(offset); long curSize = newLogFile.size(); if (newLogFile.append(record) != 0) { idx.addIdx(newLogFile, key, curSize); } }); // 写完后存储到compactedLevel1队列中，并删除toCompact中对应的文件  compactedLevel1.addLast(newLogFile); toCompact.pollFirst(); logFile.delete(); } } 对compactedLevel1队列进行多文件level2 compact的策略是：将当前队列里所有的level1 compacted segment file合并成一个level2 compacted segment file。具体步骤如下：\n1、生成一份compactedLevel1队列的snapshot。目的是为了避免在level2 compact过程中，有新的level1 compacted segment file加入到队列造成影响。\n2、对snapshot进行compact操作，按照从新到旧的顺序，将level1 compacted segment file中的记录写入新的level2 compacted segment file中，如果发现level2 compacted segment file已经存在该key，则跳过。\n3、等完成任务后，再从compactedLevel1队列里删除已经合并过的level1 compacted segment file。\n// 进行level2 compact，针对compactedLevel1队列中所有的文件进行合并 void compactLevel2() { ... // 生成一份快照  Deque\u0026lt;LogFile\u0026gt; snapshot = new LinkedList\u0026lt;\u0026gt;(compactedLevel1); if (snapshot.isEmpty()) { return; } int compactSize = snapshot.size(); // level2的文件命名规则为：filename_level2_num  LogFile newLogFile = LogFile.create(curLog.path() + \u0026#34;_level2_\u0026#34; + level2Num.getAndIncrement()); while (!snapshot.isEmpty()) { // 从最新的level1 compacted segment file开始处理  LogFile logFile = snapshot.pollLast(); logFile.lines().forEach(record -\u0026gt; { String key = Util.keyOf(record); // 只有当前level2 compacted segment file中不存在的key才写入  if (idx.idxOf(newLogFile, key) == -1) { // 写入成功后，更新索引  long offset = newLogFile.size(); if (newLogFile.append(record) != 0) { idx.addIdx(newLogFile, key, offset); } } }); } compactedLevel2.addLast(newLogFile); // 写入完成后，删除compactedLevel1队列中相应的文件  while (compactSize \u0026gt; 0) { LogFile logFile = compactedLevel1.pollFirst(); logFile.delete(); compactSize--; } ... } 总结 本文首先介绍了Martin Kleppmann给出的最简单的数据库，并使用Java语言对它进行了重新的实现，接着采用Hash索引和compact机制对其进行了一系列的优化。\nSimpleKvDb采用append-only log的方式存储数据，append-only log最主要的优点是具备很高的写入性能（文件系统的顺序写比随机写要快很多）。追加写也意味着同一个key对应的旧记录不会被覆盖删除，因此在查询数据时，需要遍历整个文件，找到该key的所有记录，并选取最新的一个。因为涉及到全文遍历，因此SimpleKvDb的读性能非常低。\n为了优化SimpleKvDb的读性能，我们实现了具有Hash索引的HashIdxKvDb。Hash索引是一个在内存中维护的Hash表，保存每个key对应的记录在文件中的offset。因此每次数据查询只需要1次磁盘寻址，加上1次磁盘I/O，非常高效。\n为了解决append-only log一直扩张导致磁盘空间不足的问题，我们给HashIdxKvDb引入了compact机制，实现了CompactionHashIdxKvDb。compact机制能够有效清理数据库中的无效的旧数据，从而减缓了磁盘空间的压力。\nHash索引虽然简单高效，但是有如下两个限制：\n1、必须在内存中维护Hash索引。如果选择在磁盘上实现Hash索引，那么将会带来大量的磁盘随机读写，导致性能的急剧下降。另外，随着compact机制的引入，数据被分散在多个segment file中存储，我们不得不为每个segment file维护一份Hash索引，这就导致Hash索引的内存占用量不断增加，给系统带来了很大的内存压力。\n2、区间查询非常低效。比如，当需要查询数据库中范围在[key0000, key9999]之间的所有key时，必须遍历索引中所有的元素，然后找到符合要求的key。\n针对Hash索引的这两个限制，要怎样进行优化呢？本系列的下一篇文章，我们将介绍另一种不存在这两个限制的索引——LSM树。\n","date":"2020-06-26T00:00:00Z","permalink":"https://yrunz.com/p/%E4%BB%8Ehash%E7%B4%A2%E5%BC%95%E5%88%B0lsm%E6%A0%91%E4%B8%80/","title":"从Hash索引到LSM树（一）"},{"content":"最近几年，领域驱动设计（Domain-Driven Design，DDD）这个术语越来越多地出现在软件工程师的视野里。对DDD不熟悉的人可能会觉得它是软件领域里的一个新的概念，但是实际上，Eric Evans在十几年前就已经提出了这个概念。这个“古老”的概念在之所以能够重焕新生，很大程度上是因为遇上了“微服务”这个浪潮。如果把DDD里面的理念拿去和微服务架构做对比，你会发现它们有着高度的相似性——DDD里的限界上下文不正是微服务架构中的微服务吗？于是，各大公司纷纷运用DDD的方法论来构建自己的产品架构。有些团队成功地将DDD结合到了产品架构中，产生了许多优秀的实践；也有些团队反映DDD太过复杂，很难落地。那么DDD究竟是个什么样的理念？为什么大家都争先恐后地使用它，彷佛不加点DDD都不好意思说自己是微服务架构？然而又为什么那么多团队说DDD难以落地？本文将会结合简单的代码实现来谈谈笔者对DDD的理解。\n什么是领域驱动设计？ 软件的核心是其为用户解决领域相关的问题的能力。\n软件就是为了解决某一领域相关问题而存在的，比如一个普通的计算器软件，就是为了满足我们进行简单的加减乘除运算而存在。对于计算器这种小而简单的软件，一个普通的软件工程师可能花上几天就能过设计开发出来，而且基本不会出现Bug。但是对于一些大型而且复杂的系统，一个团队都得花上很长的时间去设计整个架构，然后经过n轮迭代才能开发出可用的版本，而且后面还有各种Bug要去处理。比如证券交易系统，里面就包括了用户系统、账户系统、订单系统、撮合系统等一系列的子系统，而且其中的调用关系和业务都非常复杂。像这样一个庞大的系统，怎样才能把它设计好呢？这正是DDD要回答的问题。\n领域驱动设计（DDD）是一种软件开发的方法论，旨在帮助我们设计出高质量的软件模型。\n在软件领域，解决复杂问题的方法不外乎是“分治”和“抽象”，DDD也是基于这两个理念建立起一套方法论。其中将一个系统划分成多个限界上下文，限界上下文中划分出多个子域，这是分治；然后在分别对各个子域进行领域建模，这是抽象。当你在设计一个业务复杂的系统却无从下手时，尝试一下DDD，说不定困难就会迎刃而解了。DDD中最核心的理念就是领域建模，可以说它提供的各种方法都是为了帮助我们设计出更能准确传达业务规则的领域模型。一个好的领域模型可以让一个系统更加健壮，可以让一个框架易用性更加好，可以让一段代码更加好维护。那么，什么样的模型才是好的领域模型？下面，我们通过一个例子来简单说明下。\n 什么是领域模型？\n领域模型是关于某个特定业务领域的软件模型。通常，领域模型通过对象模型来实现，这些对象同时包含了数据和行为，并且表达了准确的业务含义。\n 日期和时间领域模型 如何设计一个日期和时间API？\n首先需要对日期和时间的概念进行建模，从直觉上，我们可以将日期和时间抽象成一个对象Date。另外，时间和日期经常都需要进行格式化输出，因此我们还需要一个用于表示时间格式的对象DateFormat。为了更好地表示年月周日等概念，再抽象出一个表示日历的Calendar对象，以及表示时区的TimeZone对象。\n相信到这里大家都已经知道，这正是JDK 1.1版本的日期时间API，下面我们先回顾一下它的用法：\npublic class TestOldDate { public static void main(String[] args) { // 获取表示当前时刻的Date对象  Date date1 = new Date(); // 通过Calendar等到指定日期时间的Date对象，采用当前的系统时区  Calendar calendar = Calendar.getInstance(TimeZone.getDefault()); calendar.set(2020, 2, 10, 0, 0, 0); Date date2 = calendar.getTime(); // 进行时间比较  System.out.println(\u0026#34;date1 is after date2: \u0026#34; + date1.after(date2)); // 进行时间的加减法，如获得昨天的这个时刻：  calendar.setTime(date1); calendar.set(Calendar.DAY_OF_MONTH, calendar.get(Calendar.DAY_OF_MONTH) - 1); Date date3 = calendar.getTime(); // 对日期格式化输出  DateFormat df = new SimpleDateFormat(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); System.out.println(\u0026#34;date3: \u0026#34; + df.format(date3)); } } /* Output: date1 is after date2: false date3: 2020-02-07 23:55:39 */ 如果你用习惯了JDK 1.1版本的日期时间API，可能会觉得上述例子中的用法也没有多大的问题。但是，仔细思考一下就会发现这其中的逻辑跟我们人类对时间的处理逻辑不太像，比如要对时间做加法，首先要将需要操作的Date对象设置到Calendar，然后对Calendar做加法，最后调用Calendar的接口得到结果。时间的加法难道不应该直接对Date对象加上一个时间段就行了吗？\n相信很多小伙伴都会遇到这种情况，自己写出来的代码可读性不够好。这其中原因可能是对领域的理解不够深，设计出来的领域模型没能准确的表达业务逻辑（如JDK 1.1的日期时间模型），或者开发前根本就没有进行领域建模。这样容易导致采用了一种“机器思维”去进行开发，而不是按照我们平常思考问题的思维去开发。\nJDK 1.8引入了全新的日期和时间API来解决老版本的API所存在的种种问题，下面，我们来看一下比之前更准确地表达日期和时间的领域模型：\nJDK 1.8的日期和时间领域模型中的领域知识明显比老版本的领域模型要丰富很多，而且模型更加符合人类思考日期和时间的思维。下面，我们看下新的日期和时间API的用法：\npublic class TestNewDate { public static void main(String[] args) { // 获取表示当前时刻的LocalDateTime对象  LocalDateTime date1 = LocalDateTime.now(ZoneId.systemDefault()); // 获取指定时间的LocalDateTime对象  LocalDateTime date2 = LocalDateTime.of( LocalDate.of(2020, 2, 10), LocalTime.of(0, 0, 0)); // 进行是时间比较  System.out.println(\u0026#34;date1 is after date2: \u0026#34; + date1.isAfter(date2)); // 进行时间的加减法，如获得昨天的这个时刻：  LocalDateTime date3 = date1.minus(Period.ofDays(1)); // 对时间进行格式化输出  DateTimeFormatter df = DateTimeFormatter.ofPattern(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); System.out.println(\u0026#34;date3: \u0026#34; + df.format(date3)); } } /* Output: date1 is after date2: false date3: 2020-02-07 23:56:51 */ 实现同样的功能，JDK 1.8版本的日期和时间API明显更加简洁，而且代码的逻辑更加符合人的思维，可读性更好（比如使用.now()函数创建当前时刻的LocalDateTime对象，代码阅读起来就跟人类的自然语言一样）。由此可见，设计出一个高质量的领域模型对于软件系统是多么的重要。\n在这个例子中，JDK并没有显式地使用DDD提供的战术建模手段对日期和时间API进行设计，但是从JDK 1.1到JDK 1.8版本中的变化，其中就蕴含着DDD最核心的内容：设计出更符合业务规则和人类思维的领域模型。从这个例子中我们也能看出，DDD并没有传说中的那么神秘，也未必一定要运用在复杂的系统，即使是一个简单的日期和时间API，其中也可以看到它的身影。\n如果让你对JDK 1.1的日期和时间API进行优化，相信很多人都设计不出像JDK 1.8版本的这样优秀的API，不管在经验，还是在方法上我们都欠点火候。简单的API如此，更别说设计复杂的大型系统了。这时，我们需要一些具体的建模方法来指导设计。\nDDD的建模方法 DDD主要提出了两种建模方法来帮助我们设计出高质量的领域模型：战略建模和战术建模。\n战略建模根据领域知识对系统进行限界上下文和子域的划分，战术建模具体为每个限界上下文设计出领域模型。而这两者中又内涵很多知识点，光看下面的这张DDD的概念图，你可能会觉得DDD太过复杂了。\n确实，DDD的学习曲线比较陡，特别是第一次看Eric Evans所著的《领域驱动设计——软件核心复杂性应对之道》时，会有种不知所云的感觉。再看Vaughn Vernon所著的《实现领域驱动设计》可能会好点了，但是里面提到的各种概念还是没能很清晰地理解。所谓“实践出真知”，只有通过不断地实践，才能学习到DDD的精髓，体会到它的魔力。下一篇文章，我们将开始通过实践一个简单的业务功能着手介绍DDD的各种理论知识。\n","date":"2020-03-08T00:00:00Z","permalink":"https://yrunz.com/p/%E5%AE%9E%E8%B7%B5%E9%A2%86%E5%9F%9F%E9%A9%B1%E5%8A%A8%E8%AE%BE%E8%AE%A1%E4%B8%80/","title":"实践领域驱动设计（一）"},{"content":"前言 在《Java对象表示——Oop-Klass模型（一）》一文的最后讲到，为了实现Java方法调用的动态绑定，HotSpot使用了与C++虚函数类似的机制，同时为了避免每个对象都维护一个虚函数表，于是就设计了Klass类。\n如下为HotSpot源码中对Klass的功能介绍：\n A Klass provides:\n​ 1: language level class object (method dictionary etc.)\n​ 2: provide vm dispatch behavior for the object\nBoth functions are combined into one C++ class.\n 可见，Klass主要提供了两个功能：\n（1）用于表示Java类。Klass中保存了一个Java对象的类型信息，包括类名、限定符、常量池、方法字典等。一个class文件被JVM加载之后，就会被解析成一个Klass对象存储在内存中。\n（2）实现对象的虚分派（virtual dispatch）。所谓的虚分派，是JVM用来实现多态的一种机制。\nclass A { void callMe() { System.out.println(\u0026#34;This is A.\u0026#34;); } } class B extends A { @Override public void callMe() { System.out.println(\u0026#34;This is B.\u0026#34;); } } class C extends A { @Override public void callMe() { System.out.println(\u0026#34;This is C.\u0026#34;); } } public class VmDispatch { public static void main(String[] args) { A b = new B(); A c = new C(); // b和c的静态类型为A，那么JVM是如何将它们动态绑定到正确的实现上的呢？  b.callMe(); c.callMe(); } } /* Output： This is B. This is C. */ 考虑上述例子，基类A有两个子类，分别为B和C。在main函数中，b和c的静态类型都是A，但是在调用callMe()方法时，JVM会将它们绑定到正确的实现上。这其中奥秘就是JVM的虚分派机制，而该机制的实现用到了Klass中的虚函数表。\nKlass的继承体系 跟Oop一样，Klass也有一个继承体系，如下图所示：\n// hotspot/src/share/vm/oops/oopsHierarchy.hpp ... class Klass; // Klass继承体系的最高父类 class InstanceKlass; // 表示一个Java普通类，包含了一个类运行时的所有信息 class InstanceMirrorKlass; // 表示java.lang.Class class InstanceClassLoaderKlass; // 主要用于遍历ClassLoader继承体系 class InstanceRefKlass; // 表示java.lang.ref.Reference及其子类 class ArrayKlass; // 表示一个Java数组类 class ObjArrayKlass; // 普通对象的数组类 class TypeArrayKlass; // 基础类型的数组类 ... 不同于Oop，Klass在InstanceKlass下又设计了3个子类，其中InstanceMirrorKlass用于表示java.lang.Class类型，该类型对应的oop特别之处在于其包含了static field，因此计算oop大小时需要把static field也考虑进来；InstanceClassLoaderKlass主要提供了遍历当前ClassLoader的继承体系；InstanceRefKlass用于表示java.lang.ref.Reference及其子类。\n对象的类型信息 Klass的主要用途之一就是保存一个Java对象的类型信息，如下选出其中一些比较重要的field：\n// hotspot/src/share/vm/oops/klass.hpp class Klass : public Metadata { ... // 类名，其中普通类名和数组类名略有不同  // 普通类名如：java/lang/String，数组类名如：[Ljava/lang/String;  Symbol* _name; // 最后一个secondary supertype  Klass* _secondary_super_cache; // 保存所有secondary supertypes  Array\u0026lt;Klass*\u0026gt;* _secondary_supers; // 保存所有primary supertypes的有序列表  Klass* _primary_supers[_primary_super_limit]; // 当前类所属的java/lang/Class对象对应的oop  oop _java_mirror; // 当前类的直接父类  Klass* _super; // 第一个子类 (NULL if none); _subklass-\u0026gt;next_sibling() 为下一个  Klass* _subklass; // 串联起当前类所有的子类  Klass* _next_sibling; // 串联起被同一个ClassLoader加载的所有类（包括当前类）  Klass* _next_link; // 对应用于加载当前类的java.lang.ClassLoader对象  ClassLoaderData* _class_loader_data; // 提供访问当前类的限定符途径, 主要用于Class.getModifiers()方法.  jint _modifier_flags; // 访问限定符  AccessFlags _access_flags; ... } 如上述代码片段所示，Klass继承了Metadata，后者为《深入解析Java的运行时数据区》一文中提到的“元空间”（Metaspace）的实现，这也意味着Java对象的类型信息存储在方法区，而不是在堆中。\nprimary supertype和secondary supertype主要用于快速类型检查（比如在调用instanceOf时能够快速得到结果），其中primary type和secondary type的定义出现在《Fast subtype checking in the HotSpot JVM》一文中：\n A klass T is a primary type iff T is a proper class, or an array of a primary type, or an array of primitive values. Interfaces and arrays of interfaces are excluded.\nA klass T is a secondary type iff T is a interface or an array of a secondary type. Every type is either a primary type or a secondary type but not both.\n 接着，我们继续看下表示普通对象类型的InstanceKlass所包含的信息，它继承自Klass，在父类的基础上增加了不少信息，如下列出较为重要的一些：\n// hotspot/src/share/vm/oops/instanceKlass.hpp class InstanceKlass: public Klass { ... // 当前类的状态  enum ClassState { allocated, // 已分配  loaded, // 已加载，并添加到类的继承体系中  linked, // 链接/验证完成  being_initialized, // 正在初始化  fully_initialized, // 初始化完成  initialization_error // 初始化失败  }; // 当前类的注解  Annotations* _annotations; // 当前类数组中持有的类型  Klass* _array_klasses; // 当前类的常量池  ConstantPool* _constants; // 当前类的内部类信息  Array\u0026lt;jushort\u0026gt;* _inner_classes; // 保存当前类的所有方法.  Array\u0026lt;Method*\u0026gt;* _methods; // 如果当前类实现了接口，则保存该接口的default方法  Array\u0026lt;Method*\u0026gt;* _default_methods; // 保存当前类所有方法的位置信息  Array\u0026lt;int\u0026gt;* _method_ordering; // 保存当前类所有default方法在虚函数表中的位置信息  Array\u0026lt;int\u0026gt;* _default_vtable_indices; // 保存当前类的field信息（包括static field），数组结构为：  // f1: [access, name index, sig index, initial value index, low_offset, high_offset]  // f2: [access, name index, sig index, initial value index, low_offset, high_offset]  // ...  // fn: [access, name index, sig index, initial value index, low_offset, high_offset]  // [generic signature index]  // [generic signature index]  // ...  Array\u0026lt;u2\u0026gt;* _fields; ... } 注意到，_fields中的每个元素都包含了当前field都偏移量信息，如前文《Java对象表示——Oop-Klass模型（一）》所提到，这些偏移量用于在oop中找到对应field的地址。\n虚函数表（vtable） 虚函数表（vtable）主要是为了实现Java中的虚分派功能而存在。HotSpot把Java中的方法都抽象成了Method对象，InstanceKlass中的成员属性_methods就保存了当前类所有方法对应的Method实例。HotSpot并没有显式地把虚函数表设计为Klass的field，而是提供了一个虚函数表视图，并在类初始化时创建出来。\n// hotspot/src/share/vm/oops/instanceKlass.hpp class InstanceKlass: public Klass { ... // 返回一个新的vtable，在类初始化时创建  klassVtable* vtable() const; inline Method* method_at_vtable(int index); .. } // 以下为方法对应实现 // hotspot/src/share/vm/oops/instanceKlass.cpp ... // vtable()的实现 klassVtable* InstanceKlass::vtable() const { return new klassVtable(this, start_of_vtable(), vtable_length() / vtableEntry::size()); } // method_at_vtable()的实现 inline Method* InstanceKlass::method_at_vtable(int index) { ... // 校验逻辑  vtableEntry* ve = (vtableEntry*)start_of_vtable(); return ve[index].method(); } 一个klassVtable可看成是由多个vtableEntry组成的数组，其中每个元素vtableEntry里面都包含了一个方法的地址。在进行虚分派时，JVM会根据方法在klassVtable中的索引，找到对应的vtableEntry，进而得到方法的实际地址，最后根据该地址找到方法的字节码并执行。\n总结 这两篇文章我们探讨了HotSpot的Oop-Klass对象模型，其中Oop表示对象的实例，存储在堆中；Klass表示对象的类型，存储在方法区中。但这也只是讲述了Oop-Klass对象模型中最基础的部分，该模型所包含的内容还远不止这些，如果想要更加全面而深入地了解Oop-Klass对象模型，最好的方法是阅读HotSpot的源码。\n最后，我们通过一张图来总结这两篇文章所讲述的内容：\n","date":"2020-02-02T00:00:00Z","permalink":"https://yrunz.com/p/java%E7%9A%84%E5%AF%B9%E8%B1%A1%E6%A8%A1%E5%9E%8Boop-klass%E6%A8%A1%E5%9E%8B%E4%BA%8C/","title":"Java的对象模型——Oop-Klass模型（二）"},{"content":"前言 谈起Java对象，笔者的第一反应是在：Java中的每一个对象（不包括基础类型）都继承于Object对象。相信这也是大多数程序员对Java对象的初次印象，Object可以表示所有的Java对象。但是，这种理解仅仅是停留在语言层面，至于更深的JVM层面，对象还是用Object来表示吗？显然不是。JVM通常使用非Java语言实现，是用来解析并运行Java程序的，它有自己的模型来表示Java语言的各种特性，包括Object。下面我们以HotSpot为例，一起来探讨Java对象在JVM层面的Java对象模型。\n HotSpot采用C++语言实现，下文中的JVM如无特殊说明，指的都是HotSpot。\n Java程序通过new操作符来创建一个对象，在深入探讨HotSpot的Java对象模型前，我们先看下new操作符的具体实现。\n// hotspot/src/share/vm/interpreter/interpreterRuntime.cpp ... // HotSpot中new操作符的实现函数 IRT_ENTRY(void, InterpreterRuntime::_new(JavaThread* thread, ConstantPool* pool, int index)) Klass* k_oop = pool-\u0026gt;klass_at(index, CHECK); instanceKlassHandle klass (THREAD, k_oop); // Make sure we are not instantiating an abstract klass  klass-\u0026gt;check_valid_for_instantiation(true, CHECK); // Make sure klass is initialized  klass-\u0026gt;initialize(CHECK); // At this point the class may not be fully initialized  // ...  oop obj = klass-\u0026gt;allocate_instance(CHECK); thread-\u0026gt;set_vm_result(obj); IRT_END ... 上述代码片段来自HotSpot源码中new操作符的实现函数，先不深入分析每一行的具体含义，这段代码给我们最直观的功能就是：先对klass对象进行初始化工作，然后再用它来创建出oop对象。到这里我们大致就能猜出，oop表示的就是一个Java对象。而这里的klass和Java中的Class之间似乎有着紧密的联系，一是两者的名字非常类似，另外也可通过第16行代码得到进一步的肯定。对Java的反射机制稍微有所了解的人，看着第16行代码一定很熟悉，因为它与使用Class.newInstance()方法来创建Object对象很类似。\n实际正如上述所猜测，HotSpot使用Oop-Klass模型来表示Java的对象。\nOop的继承体系 这里的Oop并非是Object-oriented programming，而是Ordinary object pointer（普通对象指针），是HotSpot用来表示Java对象的实例信息的一个体系。其中oop是Oop体系中的最高父类，整个继承体系如下所示：\n// hotspot/src/share/vm/oops/oopsHierarchy.hpp ... // Oop的继承体系 typedef class oopDesc* oop; typedef class instanceOopDesc* instanceOop; typedef class arrayOopDesc* arraysOop; typedef class objArrayOopDesc* objArrayOop; typedef class typeArrayOopDesc* typeArrayOop; ... 每个Java对象都有它独有的生命周期，我们使用new操作符将它创建出来，然后对它执行各式各样的操作（如获取成员属性、调用成员函数、加锁等），最后被GC回收掉。那么Java对象的这一系列经历，JVM又是怎么实现的呢？JVM使用Oop来表示一个Java对象，自然地，这些经历都会跟oop有关。\noop的子类有两个，分别是instanceOop和arrayOop。前者表示Java中普通的对象，后者则表示数组对象。arrayOop也有两个子类，objArrayOop表示普通对象类型的数组，而typeArrayOopDesc则表示基础类型的数组。如下图所示，oop的存储结构主要由对象头和对象体组成。\noop对象头 oop主要有两个成员属性：\n// hotspot/src/share/vm/oops/oop.hpp class oopDesc { ... private: // 用于存储对象的运行时记录信息，如哈希值、GC分代年龄、锁状态等  volatile markOop _mark; // Klass指针的联合体，指向当前对象所属的Klass对象  union _metadata { // 未采用指针压缩技术时使用  Klass* _klass; // 采用指针压缩技术时使用  narrowKlass _compressed_klass; } _metadata; ... } _mark和_metadata被称为对象头，其中前者存储对象的运行时记录信息；后者是一个指针，指向当前对象所属的Klass对象。\n 因为某些历史原因，HotSpot把markOop放到Oop体系里，但是它并继承oop，因此前文所描述的Oop体系并没有包含它。\n markOop的存储结构在32位和64位系统中有所差异，但是具体存储的信息是一样的，本节只介绍它在32位系统中的存储结构。在32位系统中，markOop一共占32位，存储结构如图下所示：\n从图中可知，诸如对象hash值、线程ID、分代年龄等信息都是存储在markOop中，而且在不同的状态下，其结构也是略有不同。无锁指一个对象没有被加锁时的状态；偏向锁，顾名思义会偏向于第一个访问锁的线程，当同步锁只有一个线程访问时，JVM会将其优化为偏向锁，此时就相当于没有同步语义；当发生多线程竞争时，偏向锁就会膨胀为轻量级锁，后者采用CAS（Compare And Swap）实现，避免了用户态和内核态之间的切换；如果某个线程获取轻量级锁失败，该锁就会继续膨胀为重量级锁，此时JVM会向操作系统申请互斥量，因此性能消耗也是最高的。\noop提供4个方法来判断当前对象处于何种状态下：\n// hotspot/src/share/vm/oops/oop.hpp class oopDesc { ... bool is_locked() const; bool is_unlocked() const; bool has_bias_pattern() const; ... bool is_gc_marked() const; } // hotspot/src/share/vm/oops/oop.inline.hpp ... inline bool oopDesc::is_gc_marked() const { return mark()-\u0026gt;is_marked(); } inline bool oopDesc::is_locked() const { return mark()-\u0026gt;is_locked(); } inline bool oopDesc::is_unlocked() const { return mark()-\u0026gt;is_unlocked(); } inline bool oopDesc::has_bias_pattern() const { return mark()-\u0026gt;has_bias_pattern(); } ... 从上述代码可知，oop调用markOop的方法来判断当前对象是否已经加锁、是否是偏向锁，markOop则通过判断其存储结构中的标志位来实现，如下列代码所示：\n// hotspot/src/share/vm/oops/markOop.hpp ... class markOopDesc: public oopDesc { ... // unlocked_value = 1  // lock_mask_in_place = right_n_bits(2)，is_locked()利用存储结构的最右边两位  // 来判断当前对象是否是加锁状态。值得注意的是，偏向锁并不属于加锁状态。  bool is_locked() const { return (mask_bits(value(), lock_mask_in_place) != unlocked_value); } // lock_mask_in_place = right_n_bits(3)，is_unlocked()并不是简单地对is_locked()  // 的结果取反，而是使用存储结构的最右边三位来判断。值得注意的是，偏向锁也并不属于无锁状态。  bool is_unlocked() const { return (mask_bits(value(), biased_lock_mask_in_place) == unlocked_value); } // marked_value = 3  // lock_mask_in_place = right_n_bits(2)，当锁标志位的值为3（二进制为11）时返回true。  bool is_marked() const { return (mask_bits(value(), lock_mask_in_place) == marked_value); } // biased_lock_pattern = 5  // biased_lock_mask_in_place = right_n_bits(3)，当存储结构的最后三位的值为5（二进制  // 为101）时返回true  bool has_bias_pattern() const { return (mask_bits(value(), biased_lock_mask_in_place) == biased_lock_pattern); } ... } oop对象体 JVM将Java对象的field存储在oop的对象体中，oop提供了一系列的方法来获取和设置field，并且针对每种基础类型都提供了特有的实现。\n// hotspot/src/share/vm/oops/oop.hpp class oopDesc { ... // 返回成员属性的地址  void* field_base(int offset) const; // 如果成员是基础类型，则用特有的方法  jbyte* byte_field_addr(int offset) const; jchar* char_field_addr(int offset) const; jboolean* bool_field_addr(int offset) const; jint* int_field_addr(int offset) const; jshort* short_field_addr(int offset) const; jlong* long_field_addr(int offset) const; jfloat* float_field_addr(int offset) const; jdouble* double_field_addr(int offset) const; Metadata** metadata_field_addr(int offset) const; // 同样是成员的地址获取方法，在GC时使用  template \u0026lt;class T\u0026gt; T* obj_field_addr(int offset) const; ... // instanceOop获取和设置其成员属性的方法  oop obj_field(int offset) const; volatile oop obj_field_volatile(int offset) const; void obj_field_put(int offset, oop value); void obj_field_put_raw(int offset, oop value); void obj_field_put_volatile(int offset, oop value); // 如果成员时基础类型，则使用其特有的方法，这里只列出针对byte类型的方法  jbyte byte_field(int offset) const; void byte_field_put(int offset, jbyte contents); ... } 具体实现如下列代码所示：\n// hotspot/src/share/vm/oops/oop.inline.hpp ... // 获取对象中field的地址 inline void* oopDesc::field_base(int offset) const { return (void*)\u0026amp;((char*)this)[offset]; } // 获取普通对象field的地址，对调用field_base的结果进行转型得到 template \u0026lt;class T\u0026gt; inline T* oopDesc::obj_field_addr(int offset) const { return (T*)field_base(offset); } // 基础类型特有的实现与obj_field_addr类似，只是转型成特有的基础类型指针 inline jbyte* oopDesc::byte_field_addr(int offset) const { return (jbyte*) field_base(offset); } ... // 获取field前需要先判断是否采用了指针压缩技术，先根据offset调用obj_field_addr // 得到field的地址，然后调用load_decode_heap_oop得到实例 inline oop oopDesc::obj_field(int offset) const { return UseCompressedOops ? load_decode_heap_oop(obj_field_addr\u0026lt;narrowOop\u0026gt;(offset)) : load_decode_heap_oop(obj_field_addr\u0026lt;oop\u0026gt;(offset)); } // 直接对指针解引用得到field inline oop oopDesc::load_decode_heap_oop(oop* p) { return *p; } ... // 设置field前需要先判断是否采用了指针压缩技术，同样也是先根据offset得到地址， // 然后在设置field的值 inline void oopDesc::obj_field_put(int offset, oop value) { UseCompressedOops ? oop_store(obj_field_addr\u0026lt;narrowOop\u0026gt;(offset), value) : oop_store(obj_field_addr\u0026lt;oop\u0026gt;(offset), value); } // 设置field前先更新barrier template \u0026lt;class T\u0026gt; inline void oop_store(T* p, oop v) { if (always_do_update_barrier) { oop_store((volatile T*)p, v); } else { update_barrier_set_pre(p, v); oopDesc::encode_store_heap_oop(p, v); update_barrier_set((void*)p, v); } } // 设置field时直接更新指针指向的值 inline void oopDesc::encode_store_heap_oop(oop* p, oop v) { *p = v; } 由上述代码片段可知，每个field在oop中都有一个对应的偏移量（offset），oop通过该偏移量得到该field的地址，再根据地址得到具体数据。因此，Java对象中的field存储的并不是对象本身，而是对象的地址。\n总结 HotSpot采用Oop-Klass模型来表示Java对象，其中Klass对应着Java对象的类型（Class），而Oop则对应着Java对象的实例（Instance）。Oop是一个继承体系，其中oop是体系中的最高父类，它的存储结构可以分成对象头和对象体。对象头存储的是对象的一些元数据，对象体存储的是具体的成员属性。值得注意的是，如果成员属性属于普通对象类型，则oop只存储它的地址。\n我们都知道Java中的普通方法（没有static和final修饰）是动态绑定的，在C++中，动态绑定通过虚函数来实现，代价是每个C++对象都必须维护一张虚函数表。Java的特点就是一切皆是对象，如果每个对象都维护一张虚函数表，内存开销将会非常大。JVM对此做了优化，虚函数表不再由每个对象维护，改成由Class类型维护，所有属于该类型的对象共用一张虚函数表。因此我们并没有在oop上找到方法调用的相关逻辑，这部分的代码被放在了klass里面。\nKlass相关的内容将会在下一篇文章《Java的对象模型——Oop-Klass（二）》中介绍。\n","date":"2020-01-31T00:00:00Z","permalink":"https://yrunz.com/p/java%E7%9A%84%E5%AF%B9%E8%B1%A1%E6%A8%A1%E5%9E%8Boop-klass%E6%A8%A1%E5%9E%8B%E4%B8%80/","title":"Java的对象模型——Oop-Klass模型（一）"},{"content":"前言 2019年最大的收获莫过于，静下心来，找到了读书和写作的乐趣。\n说起读书的习惯，那要追溯到研究生即将毕业的那一年，在一个盗版的kindle上读完了《曾国藩家书》。书中有一句话让我铭记至今，也让我震撼至今：“少睡多做，一人之生气”。于是便有了早起读书的习惯。研究生在读时，每天都有着充裕的时间，对早起的定义是8点之前，这倒也不算难事；工作之后，9点便要开始上班，对早起的定义也跟着提到7点以前。刚工作的几个月，早起着实是一件极其痛苦的事情，总有着延绵不断的困意，很多时候读着读着便又睡着了，有时甚至把困意带去了公司。多亏了那段时间的磨练，早上的困意逐渐消失，养成了如今早起的习惯。虽说是习惯，但更多的是逼着自己早起，也因此有几段时间以各种借口（最常用的便是工作太累）为由没能坚持这一习惯。很幸运的是，在2019年又重新拾得这一习惯。\n到目前为止，在读书习惯上的经历大致可以分成3个阶段。初读《曾国藩家书》时，便下决心要养成早起读书的习惯，这阶段读书的类别大多局限于史类、经济类和软件类；今年7月份读到《穷查理宝典》后，意识到多学科交叉的重要性，便开始刻意读一些各个领域的入门书籍。依旧记得读《穷查理宝典》时激动的心情，醍醐灌顶，读书的乐趣大概源至于此；当前所处的阶段，始于今年11月份读到《文心》。从前为了增加知识的广度（更多的是过于浮躁），喜欢快速把一本书读完，收得其中大概意思。读完《文心》，像是上了一堂不一样的国文课，“读书的目的，重在收得其内容意趣，否则只是文字的游戏而已。”，于是便学会了静下心来读完一本书。\n 今年有幸在公司听了侯捷老师（《STL源码剖析》的作者）的讲座，他对知识追根究底的精神，深深地影响了我。每次阅读技术书籍读到晦涩难懂之处，想要跳过之时，便会想起侯捷老师的做法，于是就乖乖地重新回去把知识彻底弄懂。\n 至于写作，对于我这么一个以写代码为业的工科生而言，那是很难想象的事情。从没想过写800字作文都得憋出内伤的我，会在2019年找到写作的乐趣。虽说工作以来，偶尔也会在公司平台写一些总结性的博客，但那都是记流水账似的，谈不上写作。搭建个人博客网站来写文章，那是很久之前的想法了，但一直都没有去落实。一是觉得搭建网站太浪费时间了；另外则认为写作更是浪费时间，还不如多读些书。\n今年双十一的时候，趁着优惠在腾讯云买了个云服务器，花上一天时间，搭建了个人博客网站。本着\u0026quot;一份时间出售多次\u0026ldquo;的原则，顺着把微信订阅号也注册了。尽管如此，对写作的热情还是没有明显地上升，依旧不情愿花超过一小时的时间去完成一篇文章。真正让我找到写作乐趣的，是《文心》。如今，写作不再是一件痛苦的事情，每个周末都舍得花上一天的时间去写完一篇文章。\n2019年读了不少书，有匆匆浏览的，也有仔细读完的。基本上做到了读完每本书都有所“触发”，不枉开卷。或是深刻地记住了其中的某句话，又或是学会了其中的一套方法。大体上，今年读过的书可以分成三类：软件类、人文社科类和科普类。\n软件类 道              今年对我影响最深的一本技术类书籍，对提升软件设计能力、写出优雅代码很有帮助。相比于Eric Evans的那本《领域驱动设计》，该书结合了大量的例子，让DDD中的各种概念更容易理解些。 领域驱动设计（DDD）的开山之作，内容比较偏理论。去年在学校图书馆偶然遇见，初读晦涩难懂，只依稀记得几个概念。今年读完《实现领域驱动设计》后，再回来重温这本书，里面的一些概念也就清晰多了。       主要介绍软件的复杂性以及降低复杂性的方法，书里用 “深” 和 “浅” 来表示一个模块的复杂性形容得非常形象。 《代码整洁之道》作者的又一力作，涵盖软件研发完整过程及所有核心架构模式。       目前流式计算框架五花八门，这本书以Apache Beam框架为例子，介绍了流式系统的一些通用概念。先了解Apache Beam，再来读这本书，里面的概念更容易理解些。 深入浅出地把微服务的架构设计、开发、测试和发布运维都介绍了一遍，而且提供了很多例子，有助于加深理解微服务的各种概念。       介绍阿里巴巴架构的演变过程，帮助了解时下热门的 “中台“ 概念的由来和含义。 与“架构”相关的概念居多，没有太多的干货，可以当成是睡前书来读。    术              《Java编程思想》的Java 8版本，全面而详细地介绍了Java 8的各种基础知识。即使已经使用Java两年了，读这本书的过程中的还是获益匪浅。 介绍了Java 8的各种新特性，新的日期API、Stream、Optional等都非常地好用，读完这本书，可以让你写出可读性更好的Java 8风格代码。       函数式编程范式逐渐成为各种语言的必备特性，这本书从理论出发，介绍了如何使用Java进行函数式编程。虽然Java不是函数式的语言，但是加点函数式的东西进去会很酷。 深入介绍了Docker的原理，去年初读时也是晦涩难懂。等到今年有了Docker实践之后，再回来读，对里面的概念和原理更有体会了。       今年公司提倡 重构 和 Clean Code，于是重新拿出来读了一遍。书中通过详细的例子介绍了一些常用的重构手法，真正做到了深入浅出。 介绍了HBase的架构原理和用法，适合入门，相对于《HBase权威指南》，这本书确实不容易让人睡觉。       《Effective C++》作者的又一力作，介绍改善C++ 11和C++ 14代码的一些方法和经验，对于写出现代C++风格代码很有帮助。 侯捷老师翻译的一本大作，详细介绍了C++标准库。虽然只是快速浏览了一遍，但是对里面STL的一些用法印象很是深刻。熟练使用STL可以帮助你写出更优雅的C++代码。       介绍使用C++进行API设计时的一些方法和技巧，对提升C++程序员的模块设计水平很有帮助。     人文社科类              今年读过的最好的人文社科类书籍，跟着书中的王先生重新上了堂国文课，极大的提升了我对读书和写作的兴趣。 来自西方的语言学家的著作，作者知识面很广，在书中融入了大量其他领域的知识来介绍语言学，读完会发现，原来人类的语言竟是如此有趣。       读完《文心》，马上又找了鲁迅先生的这本散文集。鲁迅先生用平凡的语言描绘了他的少年往事，很温馨。读到有趣之处，不自觉也露出了笑容。 同样也和语言有关，内容略显枯燥，读完最深刻的就是，对任何的言和事都要保持批判性的思考。       查理·芒格的个人传记，被他那百科全书般的知识所折服，更深受其终身学习、时刻保持求知欲望的精神所影响。 富兰克林是查理·芒格的偶像，从一个普通家庭的小孩，通过自学，逐渐成长为美国的开国元勋，而且还是一个科学家！读完这本书，像是经历了一遍他那传奇的一生。       描写了秦孝公和商鞅对秦国进行变法的那段历史，在作者的笔下，春秋战国变成了一个让人无比向往的时代。读完，让人有种想穿越回到那伟大的时代的冲动。 整个系列有5册，类似于《明朝那些事儿》的风格，但是文笔略逊一筹，可以当作了解宋朝历史的入门读物。       刚开始以为是一本讲投资的书，读了之后发现原来是一本讲如何学习、如何提升自己的书。书中的一些观点和《穷查理宝典》中的很类似，获益良多。 对冲基金公司桥水创始人的人生经验之作，分成传记、生活原则、工作原则三部分。其中传记部分最为精彩，对于 “保持极度开放的头脑” 和 “保持极度求真” 这两个观点印象深刻。       确实可以称得上世界上最简单的会计书，通过案例来解释会计学的种种概念，小白入门会计学的力荐之作。 很早以前就听过这本书了，直到今年才在微信读书上读完，收获比想象中要多，有助于学会如何管理自己的财务。    科普类              读完此书，在了解到神奇的量子世界的同时，也如同亲身经历了那个星光璀璨的伟大时代。科学史上的乌云和暴雨、追逐流星的辉光、重重的迷雾和险滩，感同身受。 这本书极大地扩展了我的宇宙观，读完会让你觉得人类太过渺小，这个世界太过美妙，世间万物有太多值得我们去探索、去求真的规律。如果早些年读到，说不定就选择读物理专业了。       整本书更像是一篇长长的论文，作者对物种起源的论证实在是太过严谨了，以至于读起来略显枯燥。 高中时死记硬背的元素周期表，其中的元素性质早已忘光了。但在作者的笔下，元素竟变得如此的有趣，而且充满了规律，再次体会到了这世界的奇妙。       这个世界充满着随机性，就像书中所说 “你的成功不见得是因为比其他人高明，而很可能是运气的结果。”。这本书可以让你认识到身边的事情多多少少都有些随机成分，并需要对“黑天鹅”事件时刻保持警惕。 这本书通过现实事例来阐述博弈论，看完虽说对博弈论还是一知半解，但明白了这世上很多现象原来是博弈的结果。       这本书读起来很有亲切感，因为几乎整本书都是在讲述“反馈”。对于学控制专业的人而言，这是再熟悉不过的概念了。反馈，真的是无处不在。     总结 王国维曾经说过，读书有三重境界。第一境界 “昨夜西风凋碧树，独上高楼，望尽天涯路“ 说的是博览群书，厚积薄发；第二境界 “衣带渐宽终不悔，为伊消得人憔悴” 说的是坚定不移，追根究底；第三境界 “众里寻她千百度，蓦然回首，那人却在，灯火阑珊处” 说的是融会贯通，知行合一。2019年末之际，有幸拾得读书乐趣，也算是踏进了读书的第一境界。\n","date":"2019-12-31T00:00:00Z","permalink":"https://yrunz.com/p/2019%E5%B9%B4%E7%9A%84%E8%AF%BB%E4%B9%A6%E5%8D%B0%E8%AE%B0/","title":"2019年的读书印记"},{"content":"前言 对于移位操作符，很多人既感到熟悉，又感到陌生。熟悉是因为移位操作符是最基本的操作符之一，几乎每种编程语言都包含这一操作符；陌生是因为除非是追求极致性能等罕见场景，否则也很难用得上它。打开JDK源码，你会发现移位操作符的身影极为常见，弄清楚它的用法，对阅读源码很有帮助。\n移位操作是把数据看作是二进制数，然后将其向左或向右移动若干位的运算。在Java编程语言中，移位操作符包含三种，分别是 \u0026lt;\u0026lt;（左移）、 \u0026gt;\u0026gt;（带符号右移）和 \u0026gt;\u0026gt;\u0026gt;（无符号右移），这三种操作符都只能作用于long、int、short、byte、char这四种基本的整型类型上。\n左移操作符 \u0026laquo; 左移操作符 \u0026lt;\u0026lt; 是将数据转换成二进制数后，向左移若干位，高位丢弃，低位补零。看如下例子：\npublic static void main(String[] args) { int i = -1; System.out.println(\u0026#34;Before \u0026lt;\u0026lt; , i\u0026#39;s value is \u0026#34; + i); System.out.println(\u0026#34;i\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i)); i \u0026lt;\u0026lt;= 10; System.out.println(\u0026#34;After \u0026lt;\u0026lt; , i\u0026#39;s value is \u0026#34; + i); System.out.println(\u0026#34;i\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i)); } Java的int占32位，因此对i = -1转换成二进制数，然后左移10位，其结果是左边高10位丢弃，右边低10位补0，再转换为十进制，得到i = -1024的结果。\n因此，上述例子的输出结果为：\nBefore \u0026lt;\u0026lt; , i's value is -1 i's binary string is 11111111111111111111111111111111 After \u0026lt;\u0026lt; , i's value is -1024 i's binary string is 11111111111111111111110000000000 带符号右移操作符 \u0026raquo; 众所周知，Java中整型表示负数时，最高位为符号位，正数为0，负数为1。\u0026gt;\u0026gt; 是带符号的右移操作符，将数据转换成二进制数后，向右移若干位，高位补符号位，低位丢弃。对于正数作右移操作时，具体体现为高位补0；负数则补1。看如下例子：\npublic static void main(String[] args) { // 对正数进行右移操作  int i1 = 4992; System.out.println(\u0026#34;Before \u0026gt;\u0026gt; , i1\u0026#39;s value is \u0026#34; + i1); System.out.println(\u0026#34;i1\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i1)); i1 \u0026gt;\u0026gt;= 10; System.out.println(\u0026#34;After \u0026gt;\u0026gt; , i1\u0026#39;s value is \u0026#34; + i1); System.out.println(\u0026#34;i1\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i1)); // 对负数进行右移操作  int i2 = -4992; System.out.println(\u0026#34;Before \u0026gt;\u0026gt; , i2\u0026#39;s value is \u0026#34; + i2); System.out.println(\u0026#34;i2\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i2)); i2 \u0026gt;\u0026gt;= 10; System.out.println(\u0026#34;After \u0026gt;\u0026gt; , i2\u0026#39;s value is \u0026#34; + i2); System.out.println(\u0026#34;i2\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i2)); } 例子中，i1 = 4992转换成二进制数，右移10位，其结果是左边高10位补0，右边低10位丢弃，再转换为十进制，得到i1 = 4的结果。同理，i2 = -4992，右移10位，左边高10位补1，右边低10位丢弃，得到i2 = -5的结果。\n因此，上述例子的输出结果为：\nBefore \u0026gt;\u0026gt; , i1's value is 4992 i1's binary string is 1001110000000 After \u0026gt;\u0026gt; , i1's value is 4 i1's binary string is 100 Before \u0026gt;\u0026gt; , i2's value is -4992 i2's binary string is 11111111111111111110110010000000 After \u0026gt;\u0026gt; , i2's value is -5 i2's binary string is 11111111111111111111111111111011 无符号右移操作符 \u0026raquo;\u0026gt; 无符号右移操作符 \u0026gt;\u0026gt;\u0026gt;与\u0026gt;\u0026gt;类似，都是将数据转换为二进制数后右移若干位，不同之处在于，不论负数与否，结果都是高位补零，低位丢弃。看如下例子：\npublic static void main(String[] args) { int i3 = -4992; System.out.println(\u0026#34;Before \u0026gt;\u0026gt;\u0026gt; , i3\u0026#39;s value is \u0026#34; + i3); System.out.println(\u0026#34;i3\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i3)); i3 \u0026gt;\u0026gt;\u0026gt;= 10; System.out.println(\u0026#34;After \u0026gt;\u0026gt;\u0026gt; , i3\u0026#39;s value is \u0026#34; + i3); System.out.println(\u0026#34;i3\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i3)); } 同样对i3 = -4992进行操作，转换成二进制数后，右移10位，其结果为左边高10位补0，右边低10位丢弃，再转换成十进制，得到i3 = 4194299的结果。\n因此，上述例子的输出结果为：\nBefore \u0026gt;\u0026gt;\u0026gt; , i3's value is -4992 i3's binary string is 11111111111111111110110010000000 After \u0026gt;\u0026gt;\u0026gt; , i3's value is 4194299 i3's binary string is 1111111111111111111011 真的懂了吗？ 对 short、byte、char 的移位操作 再看如下例子：\npublic static void main(String[] args) { byte b = -1; System.out.println(\u0026#34;Before \u0026gt;\u0026gt; , b\u0026#39;s value is \u0026#34; + b); System.out.println(\u0026#34;b\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(b)); b \u0026gt;\u0026gt;\u0026gt;= 6; System.out.println(\u0026#34;After \u0026gt;\u0026gt; , b\u0026#39;s value is \u0026#34; + b); System.out.println(\u0026#34;b\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(b)); } Java的byte占8位，按照前面讲述的原理，对b = -1转换为二进制数后，右移6位，左边高6位补0，右边低位丢弃，其结果应该是b = 3。\n真的这样吗？我们看一下例子运行的结果：\nBefore \u0026gt;\u0026gt; , b's value is -1 b's binary string is 11111111111111111111111111111111 After \u0026gt;\u0026gt; , b's value is -1 b's binary string is 11111111111111111111111111111111 运行结果与我们预期的结果不对！\n原来，Java在处理byte、short、char的移位操作前，会先将其转型成int类型，然后在进行操作！特别地，当对这三者使用\u0026lt;\u0026lt;=、\u0026gt;\u0026gt;=和\u0026gt;\u0026gt;\u0026gt;=时，其实是得到对移位后的int进行低位截断后的结果！对例子改动一下进行验证：\npublic static void main(String[] args) { byte b = -1; System.out.println(\u0026#34;Before \u0026gt;\u0026gt; , b\u0026#39;s value is \u0026#34; + b); System.out.println(\u0026#34;b\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(b)); System.out.println(\u0026#34;After \u0026gt;\u0026gt; , b\u0026#39;s value is \u0026#34; + (b \u0026gt;\u0026gt;\u0026gt; 6)); System.out.println(\u0026#34;b\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(b \u0026gt;\u0026gt;\u0026gt; 6)); } 在该例子中，没有使用 \u0026gt;\u0026gt;\u0026gt;= 对 b 进行再赋值，而是直接将 b \u0026gt;\u0026gt;\u0026gt; 6 进行输出（需要注意的是，b \u0026gt;\u0026gt;\u0026gt; 6 的结果为 int 类型），其输出如下：\nBefore \u0026gt;\u0026gt; , b's value is -1 b's binary string is 11111111111111111111111111111111 After \u0026gt;\u0026gt; , b's value is 67108863 b's binary string is 11111111111111111111111111 因此，第一个例子中实际的运算过程应该是这样：\n对于short和char的移位操作原理也一样，读者可以自行进行实验验证。\n如果移位的位数超过数值所占有的位数会怎样？ 到目前为止的所有的例子中，移位的位数都在数值所占有的位数之内，比如对int类型的移位都没有超过32。那么如果对int类型移位超过32位会怎样？且看如下例子：\npublic static void main(String[] args) { int i4 = -1; System.out.println(\u0026#34;Before \u0026gt;\u0026gt;\u0026gt; , i4\u0026#39;s value is \u0026#34; + i4); System.out.println(\u0026#34;i4\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i4)); System.out.println(\u0026#34;After \u0026gt;\u0026gt;\u0026gt; 31 , i4\u0026#39;s value is \u0026#34; + (i4 \u0026gt;\u0026gt;\u0026gt; 31)); System.out.println(\u0026#34;i4\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i4 \u0026gt;\u0026gt;\u0026gt; 31)); System.out.println(\u0026#34;After \u0026gt;\u0026gt;\u0026gt; 32 , i4\u0026#39;s value is \u0026#34; + (i4 \u0026gt;\u0026gt;\u0026gt; 32)); System.out.println(\u0026#34;i4\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i4 \u0026gt;\u0026gt;\u0026gt; 32)); System.out.println(\u0026#34;After \u0026gt;\u0026gt;\u0026gt; 33 , i4\u0026#39;s value is \u0026#34; + (i4 \u0026gt;\u0026gt;\u0026gt; 33)); System.out.println(\u0026#34;i4\u0026#39;s binary string is \u0026#34; + Integer.toBinaryString(i4 \u0026gt;\u0026gt;\u0026gt; 33)); } 根据前面讲述的原理，对于i4 \u0026gt;\u0026gt;\u0026gt; 31我们很容易得出结果为1。\n那么，i4 \u0026gt;\u0026gt;\u0026gt; 32的结果会是0吗？\nNO！Java对移位操作符的右操作数rhs有特别的处理，对于int类型，只取其低5位，也就是取rhs % 32的结果；对于long类型，只取其低6位，也即是取rhs % 64的结果。因此，对于i4 \u0026gt;\u0026gt;\u0026gt; 32，实际上是i4 \u0026gt;\u0026gt;\u0026gt; (32 % 32) ，也即i4 \u0026gt;\u0026gt;\u0026gt; 0 ，结果仍然是-1。\n同理，对于i4 \u0026gt;\u0026gt;\u0026gt; 33等同于i4 \u0026gt;\u0026gt;\u0026gt; 1，其结果为2147483647。\n因此，上述例子的输出结果如下：\nBefore \u0026gt;\u0026gt;\u0026gt; , i4's value is -1 i4's binary string is 11111111111111111111111111111111 After \u0026gt;\u0026gt;\u0026gt; 31 , i4's value is 1 i4's binary string is 1 After \u0026gt;\u0026gt;\u0026gt; 32 , i4's value is -1 i4's binary string is 11111111111111111111111111111111 After \u0026gt;\u0026gt;\u0026gt; 33 , i4's value is 2147483647 i4's binary string is 1111111111111111111111111111111 对于long类型也是同样的道理，读者可以自行进行实验验证。\n总结 移位操作符虽说是Java中最基本的操作符之一，但是若不彻底弄清楚其中细节，稍有不慎，便容易犯错。移位操作符实际上支持的类型只有int和long，编译器在对short、byte、char类型进行移位前，都会将其转换为int类型再操作。移位操作符在JDK源码中，最常见的用法便是将其当成是乘 * 或者除 / 操作符使用 ：对一个整型左移一位，相当于乘以2；右移一位，相当于除以2。其中原因就是，相比于使用 * 和 / ，在Java代码里使用 \u0026lt;\u0026lt; 和 \u0026gt;\u0026gt; 转换成的指令码运行起来会更高效些。\n","date":"2019-12-22T00:00:00Z","permalink":"https://yrunz.com/p/%E5%BD%BB%E5%BA%95%E5%BC%84%E6%87%82java%E7%9A%84%E7%A7%BB%E4%BD%8D%E6%93%8D%E4%BD%9C%E7%AC%A6/","title":"彻底弄懂Java的移位操作符"},{"content":"起 读完《文心》，就像是重新上了一堂国文课一样，跟之前不太一样的国文课。\n国文课是民国时期的叫法，现在都改称为语文课了。从小学、初中、至高中，语文作为一门基础课程 —— 从平时喊“语文、数学、英语”就可得知 —— 在我们学生心里，一直都是最重要的科目。对我而言，语文更是到了让人敬畏的程度。“敬”是语文作为高考必考的科目，不得不认真对待；“畏”是对语文所考察的知识点感到无力，完成一道题，你很难确认自己是否做对了。\n 不像数学有着固定的公式解法，语文的很多题目感觉就像是随机作答一般。就拿阅读理解中体会作者情感一题来说，就算这次猜对了，下一次给出另外一篇文章，难道就会解了吗？\n 以至于高考完以后，心里莫名的愉快，因为从此再无语文课。直到最近读完叶圣陶和夏丐尊两位大师所著的《文心》，对 国文 算是有了重新的认识。书中以故事的形式介绍了国文的种种，阅读的过程，就像是跟着书中王先生重新上了堂国文课。\n国文的种类很广，散文、叙事文、诗、词、小说、戏剧等等都属于这一范畴。而国文课所教授的，概括起来，莫过于是 读 的功夫与 写 的功夫。\n读 “作者把经验或想象所得的具体的事物翻译成白纸上的黑字，我们读者却要倒翻过去，把白纸上的黑字再依旧翻译为具体的事物。”\n从前读描写景物的文章最多只是脑海里浮现出文中情景，读讲述故事的文章最多也只是弄清楚文中情节，至于作者的感情，那是读再多遍也无法体会出来。所以，读大师的文章却丝毫鉴赏不出大师的风采，不看作者，还以为只不过是个普通的作家写出的文章罢了。\n这其中原因，除了不善于挖掘作者在文章中所表达的事物之外，另一个很重要的就是不了解文章的背景。每篇文章都有一个背景，作者便是在此背景下，把当时经验、感情编织到文章里面。\n 辛弃疾的那首起句“郁孤台下清江水”的《菩萨蛮》词，题目只作《题江西造口壁》，如果我们不知道宋室南渡的变乱及造口的位置，读去会有什么趣味呢？\n 在读一篇文章之前，尝试着先去把背景弄清楚，这样也许就可以感受到当时作者的情感，读起来也会更有趣味些。\n“读书的目的，重在收得其内容意趣，否则只是文字的游戏而已。”\n从前为了增加知识的广度（更多的是过于浮躁），喜欢快速把一本书读完，收得其中大概意思。至于书中的一些细节知识点却不会去记住，甚至很多时候根本都没有留意到，想着用到时再回去查阅。这样下来，虽说看起来懂得挺多知识，但其中大部分都是些皮毛，被深入地一问，便回答不上了。\n 最近公司需要进行软件认证，平时没少进行Java编码，之前《Java编程思想》也看过，而其中一些关于Java的基础知识很多却还是都不懂。直到最近静下心来细读《On Java 8》，才发现很多基础知识在书上都有介绍，只是以前没有关注罢了。\n 如果很难集中注意对一本书进行细读，可以通过写读书笔记或博客来驱动。读书笔记不是读到好的文段便摘录下来的几句，而是读到这些文段时的感想。“读书要精细，才能写出读书笔记，反过来，试写读书笔记，也就是使读书不苟且的一种方法。”。另外，写博客也是一种好的驱动方法，在阅读时不断去想着如何去给别人讲述这些知识点，这样对知识的理解也会更深刻些。\n“读书贵有新得，作文贵有新味，最重要的是触发的功夫。”\n所谓触发，就是由一件事感悟到其他的事。比如，读《文心》读到作文追求简炼，便想到也有代码简洁一说，这就是一种触发。我们常常听别人说要多学科交叉，触发就是一种可以把各科知识点关联起来的好方法。在阅读的过程中，多联想些之前学得的知识，便通过触发来逐步构建自己的知识体系。\n写 “文字是心的表现，也可有三种分别，就是知的文、情的文与意的文。”\n我们心的作用，普通心理学家分为知、情、意三种，文字是心的表现，因此也可分为这三类。知是知识，情是感情，意是意欲。知的文最典型的就是科普文章，作者通过文字给大众传授一些普遍的科学规律；情的文最典型的就是抒情散文，作者借景物或事物来寄托当时的情感；意的文最典型的就是广告，作者通过文字来煽动大家对商品的购买欲望。\n写作的第一步就是要认清目标，确定要写哪一类的文章。比如写一篇技术文章，却往文章里添加了些个人感情的文字，这是混淆了写作的目标；第二步就是要认清读者，只写这类读者可以接受的知识。比如要给Java新手写一篇入门的技术文章，而文章里写的却是JDK的源码剖析，这是混淆了文章受众；第三步就是要组织文章，根据中心旨意把要表达的知识组织成一篇条理清晰的文章。\n“对于文章的组织，也不妨举出一个总方法来，那就是 ‘回问自己’ 四个大字。”\n在写作的三个步骤里，文章的组织是最难的一步，这是关于怎么写好文章的学问。对于写作新手，难免会遇到“心中想法万千，却无从下笔”的窘境。这时候，可以尝试通过回问自己来着手一篇文章写作，以上一篇文章《一步步降低软件复杂性》为例：\n\u0026ldquo;是为了要说些什么才写这篇文章的?\u0026rdquo; —— 为了总结些降低软件复杂性的方法。这样文章的中心意旨就明确了。\n\u0026ldquo;中心意旨在我们意念中间是怎么来的？\u0026rdquo; —— 读完《A Philosophy of Software Design》一书深有感触，想分享给大家。这样文章依据的材料范围也就确认了。\n“这个材料可以增加中心意旨的力量吗？” —— 书中关于深浅模块例子可以很好地比喻软件的复杂性。这样就可以不断筛选出好的素材，文章的主要内容也就确认了。\n“还有更简练通顺的表达吗？” —— 这样写好像更通顺一些。这样经过不断的修正，一篇文章也就出来了。\n“习作只是法则与手腕的练习，应用之作只是对付他人和事务的东西，创作才是发挥自己天分的真成绩。”\n文章写作活动可以分为三类，习作、应用和创作。这三者之中，最基本最重要的是习作。只有当习作到了相当的程度，才能谈得到应用，才能谈得到创作。最基本的往往坚持下来最困难。想起初中时的语文老师蔡先生让我们每天都写一篇随笔，但总是不愿花超过半个小时的时间去完成。有时实在憋不出东西来，就把一段话拆成多行，撑满一页纸后便当作一首新体诗交差了上去。想起来也是可笑至极。如果当时能认真对待些，写作水平也不至于到现在这么差了。\n结 《文心》算是今年读过的社科类书中最好的一本了，读完不敢说文章鉴赏和写作能力有多大的提升，但至少兴趣是上来了，舍得花上一天的时间去完成一篇文章了。\n所有的习惯和知识都可以通过锻炼习得，文章的读和写也一样，而且它们是可以相互促进的。多读，学习优秀文章的写法，写作水平也就提升了；多写，知道写作在哪方面有缺陷，就会主动去阅读同类优秀文章来学习了。\n说到底，最重要的还是坚持，每天坚持让自己进步一点，一年下来，就会发现自己成长了许多。\n","date":"2019-12-15T00:00:00Z","permalink":"https://yrunz.com/p/%E4%B8%8D%E4%B8%80%E6%A0%B7%E7%9A%84%E5%9B%BD%E6%96%87%E8%AF%BE/","title":"不一样的国文课"},{"content":"前言 在进行软件开发时，我们常常会追求软件的高可维护性，高可维护性意味着当有新需求来时，系统易扩展；当出现bug时，开发人员易定位。而当我们说一个系统的可维护性太差时，往往指的是该系统太过复杂，导致给系统增加新功能时容易出现bug，而出现bug之后又难以定位。\n那么，软件的复杂性又是如何定义的呢？\nJohn Ousterhout给出的定义如下：\n Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system.\n 可见，软件的复杂性是一个很泛的概念，任何使软件难以理解和难以修改的东西，都属于软件的复杂性。为此，John Ousterhout提出了一个公式来度量一个系统的复杂性： $$ C = \\sum_{p}c_{p}t_{p} $$\n 式中，$p$表示系统中的模块，$c_{p}$表示该模块的认知负担（Cognitive Load，即一个模块难以理解的程度），$t_{p}$表示在日常开发中在该模块花费的开发时间。\n 从公式上看，一个软件的复杂性由它的各个模块的复杂性累加而成，而 模块复杂性 = 模块认知负担 * 模块开发时间，也就是模块的复杂性即和模块本身有关，也跟在该模块上花费的开发时间有关。需要注意的是，如果一个模块非常难以理解，但是后续开发过程中几乎没有涉及到它，那么它的复杂性也是很低的。\n导致软件复杂的原因 导致软件复杂的原因可以细分出很多种来，而概括起来莫过于两种：依赖（dependencies） 和 隐晦（obscurity）。前者会让修改起来很费劲而且容易出现bug，比如当修改模块1时，往往也涉及到模块2、模块3、... 的改动；后者会让软件难以理解，定位一个bug，甚至是仅仅读懂一段代码都需要花费大量的时间。\n软件的复杂性往往伴随着如下几种症状：\n霰弹式修改（Change amplification）。当只需要修改一个功能，但又不得不对许多模块作出改动时，我们称之为霰弹式修改。这通常是因为模块之间耦合过重，相互依赖太多导致的。 比如，有一组Web页面，每个页面都是一个HTML文件，每个HTML都有一个背景属性。由于各个HTML的背景属性都是分开定义的，因此如果需要把背景颜色从橙色修改为蓝色时，就需要改动所有的HTML文件。\n认知负担（Cognitive load）。当我们说一个模块隐晦、难以理解时，它就有过重的认知负担，这种情况下往往需要读者花费大量时间才能明白该模块的功能。比如，提供一个不带任何注释的calculate接口，它有2个int类型的入参和一个int类型的返回值。从该函数的签名上看，调用者根本无法得知函数的功能是什么，他只能通过花时间去阅读源码来确定函数功能后才敢去调用该函数。\nint calculate(int val1, int val2); 不确定性（Unknown unknowns）。相比于前两种症状，不确定性的破坏性更大，它通常指一些在开发需求时，你必须注意的，但是又无从得知的点。它常常是因为一些隐晦的依赖导致的，会让你在开发完一个需求之后感觉心里很没谱，隐约觉得自己的代码哪里有问题，但又不清楚问题在哪，只能祈祷在测试阶段能够暴露而不要漏洞商用阶段。\n如何降低软件的复杂性 对 “战术编程” Say No！ 很多程序员在进行特性开发或bug修复时，关注点往往是如何简单快速让程序跑起来，这就是典型的战术编程（Tactical programming）方法，它追求的是短期的效益——节省开发时间。战术编程最普遍的体现就是在编码之前没有进行模块设计，想到哪里就写到哪里。战术编程在系统前期可能会比较方便，一旦系统庞大起来、模块之间的耦合变重之后，添加或修改功能、修复bug都会变得寸步难行。随着系统变得越来越复杂，最后不得不对系统进行重构甚至重写。\n与战术编程相对的就是战略编程（Strategic programming），它追求的是长期的效益——增加系统可维护性。仅仅是让程序跑起来还不足以满足，还需要考虑程序的可维护性，让后续在添加或修改功能、修复bug时都能够快速响应。因为考虑的点比较多，也就注定战略编程需要花费一定的时间去进行模块设计，但相比于战术编程后期导致的问题，这一点时间也是完全值得的。\n让模块更“深”一点！ 一个模块由接口（interface）和实现（implementation）两部分组成，如果把一个模块比喻成一个矩形，那么接口就是矩形顶部的边，而实现就是矩形的面积（也可以把实现看成是模块提供的功能）。当一个模块提供的功能一定时，深模块（Deep module）的特点就是矩形顶部的边比较短，整体形状高瘦，也即接口比较简单；浅模块（Shallow module）的特点就是矩形顶部的边比较长，整体形状矮胖，也即接口比较复杂。\n模块的使用者往往只看到接口，模块越深，模块暴露给调用者的信息就越少，调用者与该模块的耦合性也就越低。因此，把模块设计得更“深”一点，有助于降低系统的复杂性。\n那么，怎样才能设计出一个深模块呢？\n  更简单的接口 简单的接口比简单的实现更重要，更简单的接口意味着模块的易用性更好，调用者使用起来更方便。而简单的实现 + 复杂的接口这种形式，一方面影响了接口的易用性，另一方面则加深了调用者与模块的耦合。因此，在进行模块设计时，最好遵守“把简单留给别人，把复杂留给自己”的原则。\n 异常也属于接口的一部分，在编码过程中，应该杜绝没经过处理，就随意将异常往上抛的现象，这样只会增加系统的复杂性。\n   更通用的接口 在设计接口时，你往往有两种选择：（1）设计成专用的接口；（2）设计成通用的接口。前者实现起来更方便，而且完全可以满足当前的需求，但可扩展性低，属于战术编程；后者则需要花时间对系统进行抽象，但可扩展性高，属于战略编程。通用的接口意味着该接口适用的场景不止一个，典型的就是“ 一个接口，多个实现 ”的形式。\n有些程序员可能会反驳，在无法预知未来变化的情况下，通用就意味着过度设计。过度通用确实属于过度设计，但对接口进行适度的抽象并不是，相反它可以使系统更有层次感，可维护性也更高。\n  隐藏细节 在进行模块设计时，还要学会区分对于调用者而言，哪些信息是重要的，哪些信息是不重要的。隐藏细节指的就是只给调用者暴露重要的信息，把不重要的细节隐藏起来。隐藏细节一则使模块接口更简单，二则使系统更易维护。\n 如何判断细节对于调用者是否重要？以下有几个例子：\n1、对于Java的Map接口，重要的细节：Map中每一个元素都是由\u0026lt;Key, Value\u0026gt;组成的；不重要的细节：Map底层是如何存储这些元素、如何实现线程安全等。\n2、对于文件系统中的read函数，重要的细节：每次读操作从哪个文件读、读多少字节；不重要的细节：如何切换到内核态、如何从硬盘里读数据等。\n3、对于多线程应用程序，重要的细节：如何创建一个线程；不重要的细节：多核CPU如何调度该线程。\n   进行分层设计！ 设计良好的软件架构都有一个特点，就是层次清晰，每一层都提供了不同的抽象，各个层次之间的依赖明确。不管是经典的Web三层架构、DDD所提倡的四层架构以及六边形架构，抑或是所谓的Clean Architecture，都有着鲜明的层次感。\n              在进行分层设计时，需要注意的是，每一层都应该提供不同的抽象，并要尽量避免在一个模块中出现大量的Pass-Through Mehod。比如在DDD的四层架构中，领域层提供了对领域业务逻辑的抽象，应用层提供了对系统用例的抽象，接口层提供了对系统访问接口的抽象，基础设施层则提供对如数据库访问这类的基础服务的抽象。\n所谓的Pass-Through Mehod是指那些“在函数体内直接调用其他函数，而本身只做了极少的事情”的函数，通常其函数签名与被其调用的函数签名很类似。Pass-Through Mehod所在的模块通常都是浅模块，让系统增加了无谓的层次和函数调用，会使系统更加复杂。\n学会写代码注释！ 注释是软件开发过程中的性价比极高的一种手法，它只需要花费20%的时间，即可获取80%的价值。它可以提高晦涩难懂的代码的可读性；可以起到隐藏代码复杂细节的作用，比如接口注释可以帮助开发者在没有阅读代码的情况下快速了解该接口的功能和用法；如果写的好，它还可以改善系统的设计。\n 具体如何写好代码注释，参考《教你写好代码注释》一文。\n 总结 软件的复杂性是我们程序员在日常开发中所必须面对的东西，学会如何 “弄清楚什么是软件复杂性，找到导致软件复杂的原因，并利用各种手法去战胜软件的复杂性” 是一门必备的能力。有句话说得很好，“代码质量决定生活质量”，当你把软件的复杂性降低了，bug减少了，系统可维护性更高了，自然也就带来了更好的生活质量。\n模块设计是降低软件复杂度最有效的手段，学会使用“战略编程”的方法，并坚持下去。我们常常提倡“一次把事情做对”，但这对于模块设计而言并不适用，几乎没有人可以第一次就把一个模块设计成完美的模样。二次设计是一个非常有效的手法，与其在系统腐化之后再花大量时间进行重构或重写，还不如在第一次完成模块设计后，再花点时间进行二次设计，多问问自己：是否有更简单的接口？是否有更通用的设计？是否有更简洁高效的实现？\n\u0026ldquo;罗马不是一天建成的\u0026rdquo;，降低软件的复杂性也一样，贵在坚持。\n","date":"2019-12-08T00:00:00Z","permalink":"https://yrunz.com/p/%E4%B8%80%E6%AD%A5%E6%AD%A5%E9%99%8D%E4%BD%8E%E8%BD%AF%E4%BB%B6%E5%A4%8D%E6%9D%82%E5%BA%A6/","title":"一步步降低软件复杂度"},{"content":"前言 相信大家都会遇到这种情况：一周前自己写的代码，现在再拿出来看，发现读不懂了，“ 这代码是我写的？？？”。这时候，代码注释就可以发挥它的作用了——提高晦涩难懂的代码的可读性；注释可以起到隐藏代码复杂细节的作用，比如接口注释可以帮助开发者在没有阅读代码的情况下快速了解该接口的功能和用法；如果写的好，注释还可以改善系统的设计。\n既然注释这么多好处，为什么我们程序员还是不愿意写注释？\n“代码都写不完了，哪有时间写注释，以后再补吧“\n时间不够论。这是最常见的原因，在交付速度飞快的今天，“代码写不完”是一个再常见不过的情况了，但写注释真的会导致需求延迟吗？绝不！相对于写一个接口的实现，写接口注释的时间可能只需要花费前者的5%。但不写注释，后面使用接口的人必须要多花费**50%**的时间去读懂代码！而且对于大部分程序员而言，“以后再补“大概要到2910年才能落实。\n“好的代码就是最好的注释，我的代码可读性很好，没必要写注释”\n好代码胜过注释论。不少程序员认为，好的代码就是最好的注释，只要代码可读性好，注释就可以省去。然而一个软件系统很多信息是无法通过代码呈现出来的，比如系统的设计思路、函数执行的预置条件等等。此外，代码可读性也不是绝对的，对于一个没有使用过Java 8 的 Lambda表达式的开发者而言，通篇的箭头“-\u0026gt;“简直就是一场噩梦。\n”过期的注释容易误导人“\n过期注释论。不可否认，过期的注释很容易误导读者，但是这并不能成为否认注释的借口。除非是重大的重构或重写，对注释进行大改动的情况很少出现。通常，在更改代码之后，只需花费极少的时间去更新注释，就可以避免过期注释这种情况了。\n注释的分类 注释大致可以分成四类：接口注释、数据成员注释、实现注释和模块依赖注释。\n接口注释 平时我们所说的接口，通常指的是一个类（包括interface、class、enum）和方法。对类而言，接口注释主要描述该类提供的功能；对方法而言，除了描述方法功能之外，方法的入参和返回值都要进行说明。当然，使用类/方法的一些预置条件和副作用等信息都需要在接口注释中提到。\n/** * Returns the length of this string. * The length is equal to the number of \u0026lt;a href=\u0026#34;Character.html#unicode\u0026#34;\u0026gt;Unicode * code units\u0026lt;/a\u0026gt; in the string. * * @return the length of the sequence of characters represented by this * object. */ public int length() { return value.length; }  典型的接口注释（选自JDK 1.8中的String类）\n 数据成员注释 数据成员注释和接口注释在大多数情况下都是必须的，这对于让读者快速读懂代码有很大的帮助。数据成员包括类的普通成员变量和静态成员变量，数据成员注释除了描述数据成员的本身用途之外，成员的默认值、副作用等信息都需要提及。\npublic final class String implements java.io.Serializable, Comparable\u0026lt;String\u0026gt;, CharSequence { /** The value is used for character storage. */ private final char value[]; /** Cache the hash code for the string */ private int hash; // Default to 0  /** use serialVersionUID from JDK 1.0.2 for interoperability */ private static final long serialVersionUID = -6849794470754667710L; /** * Class String is special cased within the Serialization Stream Protocol. * * A String instance is written into an ObjectOutputStream according to * \u0026lt;a href=\u0026#34;{@docRoot}/../platform/serialization/spec/output.html\u0026#34;\u0026gt; * Object Serialization Specification, Section 6.2, \u0026#34;Stream Elements\u0026#34;\u0026lt;/a\u0026gt; */ private static final ObjectStreamField[] serialPersistentFields = new ObjectStreamField[0]; .... }  典型的数据成员注释（选自JDK 1.8中的String类）\n 实现注释 对于一段代码，如果无法一眼就看出其含义而需要深入分析其实现才能读懂，就需要添加实现注释对这段代码的含义进行说明。代码的实现注释通常不是必须的，如果一段代码每一行都需要注释，那就要重新审视一下这段代码的设计是否有合理了。此外，实现注释描述还需要描述一些代码无法体现，但是对于读者了解这段代码很有帮助的信息，比如代码的设计思路等。\npublic final class String { ... public char[] toCharArray() { // Cannot use Arrays.copyOf because of class initialization order issues  char result[] = new char[value.length]; System.arraycopy(value, 0, result, 0, value.length); return result; } ... }  典型的实现注释（选自JDK 1.8中的String类）\n 模块依赖注释 模块依赖注释相对少见一些，主要描述两个相互依赖的模块的一些依赖信息，因为它并不属于单独某个模块，所以在哪里写这个注释需要认真衡量一下。实在找不到好的位置时，可以写一个类似README的文件，专门用于描述模块之间的依赖关系。\ntypedef enum Status { STATUS_OK = 0, STATUS_UNKNOWN_TABLET = 1, STATUS_WRONG_VERSION = 2, ... STATUS_INDEX_DOESNT_EXIST = 29, STATUS_INVALID_PARAMETER = 30, STATUS_MAX_VALUE = 30, // Note: if you add a new status value you must make the following  // additional updates:  // (1) Modify STATUS_MAX_VALUE to have a value equal to the  // largest defined status value, and make sure its definition  // is the last one in the list. STATUS_MAX_VALUE is used  // primarily for testing.  // (2) Add new entries in the tables \u0026#34;messages\u0026#34; and \u0026#34;symbols\u0026#34; in  // Status.cc.  // (3) Add a new exception class to ClientException.h  // (4) Add a new \u0026#34;case\u0026#34; to ClientException::throwException to map  // from the status value to a status-specific ClientException  // subclass.  // (5) In the Java bindings, add a static class for the exception  // to ClientException.java  // (6) Add a case for the status of the exception to throw the  // exception in ClientException.java  // (7) Add the exception to the Status enum in Status.java, making  // sure the status is in the correct position corresponding to  // its status code. }  典型的模块依赖注释（选自《A Philosophy of Software Design》中的例子）\n 如何写好代码注释 利用好注释模板 注释模板为注释写作提供了极大的便利，我们常用的开发工具如IDEA、VS Code都对注释模板有很好的支持。在配置好注释模板之后（一般情况下默认模板即可），只需简单的操作，就能生成模板，我们只需往模板里填上内容即可。对于方法的接口注释，注释模板尤为方便，方法的入参和返回值标识注释模板都已经提供好。\n/** * * @param user * @param password * @return */ private boolean login(String user, String password) { ... }  IntelliJ IDEA默认的注释模板\n 不要重复代码 注释与代码重复是开发者最容易犯的一个错误，这也是很多开发者认为注释是冗余的原因。确实，对于那些通过读代码可以很容易就推断出来的信息，注释就是多余的，更甚者，出现过期注释还容易误导读者。\n// Add a horizontal scroll bar hScrollBar = new JScrollBar(JScrollBar.HORIZONTAL); add(hScrollBar, BorderLayout.SOUTH); // Add a vertical scroll bar vScrollBar = new JScrollBar(JScrollBar.VERTICAL); add(vScrollBar, BorderLayout.EAST); // Initialize the caret-position related values caretX = 0; caretY = 0; caretMemX = null;  典型的重复代码的注释（选自《A Philosophy of Software Design》中的例子）\n 某些程序员喜欢在注释中使用代码中的变量名或其中的单词，这种情况也是注释重复代码的一种体现。像下面的这个例子，注释完全就是冗余的，可以猜测开发者纯粹是为了注释而注释。\n/** * 用户登录 * @param user User对象 * @param password Password对象 * @return */ private boolean login(User user, Password password) { ... }  使用代码变量名的注释\n 注释也要分层 在进行系统设计时，我们常常会采用分层架构，每一层负责不同功能。系统的顶层往往会更抽象一点，为功能调用者隐藏了很多细节（high-level）；底层往往会更细节一点，实现系统的具体功能（low-level）。在进行注释写作时，我们也要学会对注释进行分层。high-level的注释要提供比代码更抽象的信息，比如代码的设计思路；low-level的注释要提供比代码更细节的信息，比如表示一个范围的两个参数是左闭右开还是左闭右闭；我们需要避免写出与代码同一level的注释，因为这往往就是上一节所提到的注释重复代码。\nlow-level注释写作指南 对于需要通过阅读这一段代码才能获取的信息，就适合以low-level注释写在这段代码前面，一些常见的例子有：\n 变量的单位是什么？ 表示范围的一组参数是左闭右开还是左闭右闭？ 某个变量为null时代表什么含义？ 某个资源应该由谁来负责释放，接口调用者还是接口提供者？  low-level注释不能太过抽象，否则就失去了其应有的作用：\n// Current offset in resp Buffer uint32_t offset; // Contains all line-widths inside the document and // number of appearances. private TreeMap\u0026lt;Integer, Integer\u0026gt; lineWidths;  太过抽象的low-level注释（选自《A Philosophy of Software Design》中的例子）\n 好的low-level注释应当详细介绍代码的意图及其需要注意的点：\n// Position in this buffer of the first object that hasn\u0026#39;t // been returned to the client. uint32_t offset; // Holds statistics about line lengths of the form \u0026lt;length, count\u0026gt; // where length is the number of characters in a line (including // the newline), and count is the number of lines with // exactly that many characters. If there are no lines with // a particular length, then there is no entry for that length. private TreeMap\u0026lt;Integer, Integer\u0026gt; numLinesWithLength;  典型的low-level注释（选自《A Philosophy of Software Design》中的例子）\n high-level注释写作指南 high-level注释的作用是隐藏具体实现细节，快速让读者对整一段代码有一个全局的了解。high-level注释除了描述对代码进行抽象的概括（What）之外，还经常描述代码的设计思路（Why），这有助于帮助读者更容易、深入地了解整个系统。high-level注释不应该描述具体代码实现的信息，更不能犯注释重复代码的错误。在写high-level注释之前，先问一下自己：\n 这段代码要做什么事情？ 这段代码最核心的功能是什么？ 怎样才能以最简单的描述表达这段代码的核心功能？  太过细节的high-level注释其实就是在重复代码，另一方面该注释既不能解释此代码的总体目的，也不能解释其如何适合包含此代码的方法，导致其失去了其应有作用：\n// If there is a LOADING readRpc using the same session // as PKHash pointed to by assignPos, and the last PKHash // in that readRPC is smaller than current assigning // PKHash, then we put assigning PKHash into that readRPC. int readActiveRpcId = RPC_ID_NOT_ASSIGNED; for (int i = 0; i \u0026lt; NUM_READ_RPC; i++) { if (session == readRpc[i].session \u0026amp;\u0026amp; readRpc[i].status == LOADING \u0026amp;\u0026amp; readRpc[i].maxPos \u0026lt; assignPos \u0026amp;\u0026amp; readRpc[i].numHashes \u0026lt; MAX_PKHASHES_PERRPC) { readActiveRpcId = i; break; } }  太过细节的high-level注释（选自《A Philosophy of Software Design》中的例子）\n 更好的high-level注释应该在更高级别上描述代码的整体功能：\n// Try to append the current key hash onto an existing // RPC to the desired server that hasn\u0026#39;t been sent yet. int readActiveRpcId = RPC_ID_NOT_ASSIGNED; for (int i = 0; i \u0026lt; NUM_READ_RPC; i++) { if (session == readRpc[i].session \u0026amp;\u0026amp; readRpc[i].status == LOADING \u0026amp;\u0026amp; readRpc[i].maxPos \u0026lt; assignPos \u0026amp;\u0026amp; readRpc[i].numHashes \u0026lt; MAX_PKHASHES_PERRPC) { readActiveRpcId = i; break; } }  典型的high-level注释（选自《A Philosophy of Software Design》中的例子）\n 规范接口注释 接口注释是用的最多的一种注释，它可以让读者快速了解整个模块的功能并知道如何使用该接口，而不必花费大量时间去阅读源码。\n对于一个类的接口注释，其通常是high-level的注释，主要描述这个类提供的一些功能；\n对于一个方法的接口注释，则同时需要包括high-level和low-level的注释，常见的有以下几点：\n 整个方法提供的主要功能 方法的所有参数和返回值含义 调用该方法的副作用（比如改变了系统的某个状态） 该方法可能抛出的所有异常 调用该方法的预置条件（比如调用该方法前，系统必须处于某个状态）  实现注释描述what、why，而不是how 写实现注释时需要记住的最重要的一点就是，描述代码是做什么的（what）和为什么这么做（why），而不是描述怎么做（how）。因为实现代码本身就是how，如果还添加描述how的注释，那就犯了“注释重复代码”的错误了。\n前文中的这个注释就是典型的描述how的注释，该注释除了用文字重复一遍代码的逻辑，并没有其他更好的用处：\n// If there is a LOADING readRpc using the same session // as PKHash pointed to by assignPos, and the last PKHash // in that readRPC is smaller than current assigning // PKHash, then we put assigning PKHash into that readRPC.   典型的描写how的实现注释（选自《A Philosophy of Software Design》中的例子）\n 更好的方法是描述what的注释，通过文字表达该段代码的用途，置于该用途是如何实现的，那就交给代码去解释吧！\n// Try to append the current key hash onto an existing // RPC to the desired server that hasn\u0026#39;t been sent yet.  典型的描写what的实现注释（选自《A Philosophy of Software Design》中的例子）\n 需要注意的是，实现注释通常不是必须的，对于一些简短的、功能单一的函数，一个好的函数命名即可替代实现注释。\n总结 注释是软件开发过程中的性价比极高的一个工具，它只需要花费20%的时间，即可获取80%的价值。代码注释最重要的作用就是让读者可以在不读源码的情况下，快速了解一段代码的主要功能。注释的作用还远远不止这些，John Ousterhout在《A Philosophy of Software Design》一书中提到，写注释最好的时机是在写代码之前。这样，注释就相当于一种设计工具：在编码前通过注释描述这段代码的功能，然后在通过编码实现这个功能，如果这个过程有偏差，则再调整注释。写注释时要切记两点，不要重复代码！更新代码后也要更新注释！前者可以减少冗余的注释，后者则可以避免因为过期的注释而误导读者。\n当然，注释也不是越多越好，如果一个系统写的注释太多，很有可能就是该系统设计得过于复杂，只能通过注释来帮助读者理解整个系统。\n总而言之，学会怎么写好代码注释，是一个程序员的必备技能，这即是为了开发团队的其他成员，也是为了未来的自己。\n","date":"2019-12-04T00:00:00Z","permalink":"https://yrunz.com/p/%E6%95%99%E4%BD%A0%E5%86%99%E5%A5%BD%E4%BB%A3%E7%A0%81%E6%B3%A8%E9%87%8A/","title":"教你写好代码注释"}]