Djinn：一个受 Jinja2 启发的代码生成器和模板语言
代码生成器是非常有用的工具。我有时使用 jinja2 的命令行版本来生成高度冗余的配置文件和其他文本文件，但它在转换数据方面功能有限。显然，Jinja2 的作者有不同的想法，而我想要类似于 列表推导 list comprehensions 或 D 语言的 可组合范围 composable range 算法之类的东西。我决定制作一个类似于 Jinja2 的工具，但让我可以通过使用范围算法转换数据来生成复杂的文件。这个想法非常简单：一个直接用 D 语言代码重写的模板语言。因为它 就是 D 语言，它可以支持 D 语言所能做的一切。我想要一个独立的代码生成器，但是由于 D 语言的 mixin 特性，同样的模板语言可以作为嵌入式模板语言工作（例如，Web 应用程序中的 HTML）。有关该技巧的更多信息，请参阅 这篇 关于在编译时使用 mixins 将 Brainfuck 转换为 D 和机器代码的文章。像往常一样，源码在 GitLab 上。这篇文章中的例子也可以在这里找到。Hello world 示例这是一个演示这个想法的例子：Hello [= retro("dlrow") ]! [: enum one = 1; :] 1 + 1 = [= one + one ] [= some_expression ] 类似于 Jinja2 中的 {{ some_expression }}，它在输出中呈现一个值。[: some_statement; :] 类似于 {% some_statement %} ，用于执行完整的代码语句。我更改了语法，因为 D 也大量使用花括号，并且将两者混合使模板难以阅读（还有一些特殊的非 D 指令，比如 include，它们被包裹在 [< 和 >] 中）。如果你将上面的内容保存到一个名为 hello.txt.dj 的文件中并运行 djinn 命令行工具，你会得到一个名为 hello.txt 的文件，其中包含你可能猜到的内容：Hello world! 1 + 1 = 2 如果你使用过 Jinja2，你可能想知道第二行发生了什么。Djinn 有一个简化格式化和空格处理的特殊规则：如果源代码行包含 [: 语句或 [< 指令但不包含任何非空格输出，则整行都会被忽略输出。空行则仍会原样呈现。生成数据好的，现在来讲一些更实用的东西：生成 CSV 数据。x,f(x) [: import std.mathspecial; foreach (x; iota(-1.0, 1.0, 0.1)) :] [= "%0.1f,%g", x, normalDistribution(x) ] 一个 [= 和 ] 对可以包含多个用逗号分隔的表达式。如果第一个表达式是一个由双引号包裹的字符串，则会被解释为 格式化字符串。下面是输出结果：x,f(x) -1.0,0.158655 -0.9,0.18406 -0.8,0.211855 -0.7,0.241964 -0.6,0.274253 -0.5,0.308538 -0.4,0.344578 -0.3,0.382089 -0.2,0.42074 -0.1,0.460172 0.0,0.5 0.1,0.539828 0.2,0.57926 0.3,0.617911 0.4,0.655422 0.5,0.691462 0.6,0.725747 0.7,0.758036 0.8,0.788145 0.9,0.81594 制作图片这个例子展示了一个图片的生成过程。经典的 Netpbm 图像库定义了一堆图像格式，其中一些是基于文本的。例如，这是一个 3 x 3 向量的图像：P2 # PGM 格式标识 3 3 # 宽和高 7 # 代表纯白色的值（0 代表黑色） 7 0 7 0 0 0 7 0 7 你可以将上述文本保存到名为 cross.pgm 之类的文件中，很多图像工具都知道如何解析它。下面是一些 Djinn 代码，它以相同的格式生成 Mandelbrot 集 分形：[: import std.complex; enum W = 640; enum H = 480; enum kMaxIter = 20; ubyte mb(uint x, uint y) { const c = complex(3.0 * (x - W / 1.5) / W, 2.0 * (y - H / 2.0) / H); auto z = complex(0.0); ubyte ret = kMaxIter; while (abs(z) <= 2 && --ret) z = z * z + c; return ret; } :] P2 [= W ] [= H ] [= kMaxIter ] [: foreach (y; 0..H) :] [= "%(%s %)", iota(W).map!(x => mb(x, y)) ] 生成的文件大约为 800 kB，但它可以很好地被压缩为 PNG：$ # 使用 GraphicsMagick 进行转换 $ gm convert mandelbrot.pgm mandelbrot.png 结果如下：解决谜题这里有一个谜题：一个 5 行 5 列的网格需要用 1 到 5 的数字填充，每个数字在每一行中限使用一次，在每列中限使用一次（即，制作一个 5 行 5 列的 拉丁方格 Latin square ）。相邻单元格中的数字还必须满足所有 > 大于号表示的不等式。几个月前我使用了 线性规划 linear programming （LP）。线性规划问题是具有线性约束的连续变量系统。这次我将使用 混合整数线性规划 mixed integer linear programming （MILP），它通过允许整数约束变量来归纳 LP。事实证明，这足以成为 NP 完备的，而 MILP 恰好可以很好地模拟这个谜题。在上一篇文章中，我使用 Julia 库 JuMP 来帮助解决这个问题。这次我将使用 CPLEX：基于文本的格式，它受到多个 LP 和 MILP 求解器的支持（如果需要，可以通过现成的工具轻松转换为其他格式）。这是上一篇文章中 CPLEX 格式的 LP：Minimize obj: v Subject To ptotal: pr + pp + ps = 1 rock: 4 ps - 5 pp - v <= 0 paper: 5 pr - 8 ps - v <= 0 scissors: 8 pp - 4 pr - v <= 0 Bounds 0 <= pr <= 1 0 <= pp <= 1 0 <= ps <= 1 End CPLEX 格式易于阅读，但复杂度高的问题需要大量变量和约束来建模，这使得手工编码既痛苦又容易出错。有一些特定领域的语言，例如 ZIMPL，用于以高级方式描述 MILP 和 LP。对于许多问题来说，它们非常酷，但最终它们不如具有良好库（如 JuMP）支持的通用语言或使用 D 语言的代码生成器那样富有表现力。我将使用两组变量来模拟这个谜题：v_{r,c} 和 i_{r,c,v}。v_{r,c} 将保存 r 行 c 列单元格的值（从 1 到 5）。i_{r,c,v} 是一个二进制指示器，如果 r 行 c 列的单元格的值是 v，则该指示器值为 1，否则为 0。这两组变量是网格的冗余表示，但第一种表示更容易对不等式约束进行建模，而第二种表示更容易对唯一性约束进行建模。我只需要添加一些额外的约束来强制这两个表示是一致的。但首先，让我们从每个单元格必须只有一个值的基本约束开始。从数学上讲，这意味着给定行和列的所有指示器都必须为 0，但只有一个值为 1 的例外。这可以通过以下等式强制约束：[i_{r,c,1} + i_{r,c,2} + i_{r,c,3} + i_{r,c,4} + i_{r,c,5} = 1] 可以使用以下 Djinn 代码生成对所有行和列的 CPLEX 约束：\ 单元格只有一个值 [: foreach (r; iota(N)) foreach (c; iota(N)) :] [= "%-(%s + %)", vs.map!(v => ivar(r, c, v)) ] = 1 [::] ivar() 是一个辅助函数，它为我们提供变量名为 i 的字符串标识符，而 vs 存储从 1 到 5 的数字以方便使用。行和列内唯一性的约束完全相同，但在 i 的其他两个维度上迭代。为了使变量组 i 与变量组 v 保持一致，我们需要如下约束（请记住，变量组 i 中只有一个元素的值是非零的）：[i_{r,c,1} + 2i_{r,c,2} + 3i_{r,c,3} + 4i_{r,c,4} + 5i_{r,c,5} = v_{r,c}] CPLEX 要求所有变量都位于左侧，因此 Djinn 代码如下所示：\ 连接变量组 i 和变量组 v [: foreach (r; iota(N)) foreach (c; iota(N)) :] [= "%-(%s + %)", vs.map!(v => text(v, ' ', ivar(r, c, v))) ] - [= vvar(r,c) ] = 0 [::] 不等符号相邻的和左下角值为为 4 单元格的约束写起来都很简单。剩下的便是将指示器变量声明为二进制，并为变量组 v 设置边界。加上变量的边界，总共有 150 个变量和 111 个约束 你可以在仓库中看到完整的代码。GNU 线性规划工具集 有一个命令行工具可以解决这个 CPLEX MILP。不幸的是，它的输出是一个包含了所有内容的体积很大的转储，所以我使用 awk 命令来提取需要的内容：$ time glpsol --lp inequality.lp -o /dev/stdout | awk '/v[0-9][0-9]/ { print $2, $4 }' | sort v00 1 v01 3 v02 2 v03 5 v04 4 v10 2 v11 5 v12 4 v13 1 v14 3 v20 3 v21 1 v22 5 v23 4 v24 2 v30 5 v31 4 v32 3 v33 2 v34 1 v40 4 v41 2 v42 1 v43 3 v44 5 real 0m0.114s user 0m0.106s sys 0m0.005s 这是在原始网格中写出的解决方案：这些例子只是用来玩的，但我相信你已经明白了。顺便说一下，Djinn 代码仓库的 README.md 文件本身是使用 Djinn 模板生成的。正如我所说，Djinn 也可以用作嵌入在 D 语言代码中的编译期模板语言。我最初只是想要一个代码生成器，得益于 D 语言的元编程功能，这算是一个额外获得的功能。via: https://theartofmachinery.com/2021/01/01/djinn.html作者：Simon Arneaud 选题：lujun9972 译者：hanszhao80 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
将自己作为承包商而定价的基础原则
我职业生涯的大部分时间都是自由职业者。有时候，我也会和一些想要辞掉全职工作去做一些承包或服务业务的人聊天。到目前为止，我们新手最常犯的错误就是为自己定价。以 这篇有用的博客文章为例，它分析了美国雇员与自由职业者的收入间的对比。据估计，在美国，作为一名自由职业者，你需要获得 14 万美元的收入，才能获得相当于 10 万美元的雇员薪酬。我记得当我第一次创业时，我发现这样的计算非常有用。而，有些人看到结果会想：“哎呀，如果我是自由职业者，我得赚 1.4 倍的钱。我真的能做到吗？”不，不，不，这种想法是落后的。如何给自己定价让我们举个例子。假设你是一名全职软件工程师，年收入 10 万美元，你正考虑转成承保。当你是自由职业者时候，你必须像做企业一样思考，因为这就是你的谋生方式。所以，你必须把所有的成本加起来，并计算出如何收回这些成本。电子表格的口碑很差（有一些很好的理由），但它们实际上非常有用，尤其是对这些东西（以及作为企业主要进行的许多其他计算）。第一个要加入统计的成本是那 10 万美元。如果这听起来很奇怪，那就是所谓的“机会成本”。你本可以继续工作以赚到 10 万美元；而没有赚到这 10 万美元，实际上是在规划业务时的一种成本。把这一费用和其他你实际使用的任何其它就业福利一起记下来。如果你的雇主提供工作日午餐，那就加上一年中每个工作日午餐的费用。如果你的雇主为员工提供健身软件的折扣，但你却没有使用该软件，那么不要将该福利作为机会成本。其他费用取决于你在做什么和你住在哪里。员工医疗保险在澳大利亚不像在美国那么重要。另一方面，强制性养老金支付（类似于美国的 401(K) 计划）是一个大问题。我有自己的公司，我的主要非工资成本是保险、会计/档案、法律（合同审查等）、债务催收和各种在线服务成本。如果你在计算一些耐用的东西（比如一张桌子），把成本除以你预计使用该东西的年数。总之，到目前为止，这基本上就是 Caleb 的博客文章中的内容，所以为了简单起见，我将假设同样的 10 万美元的名义工资和 14 万美元的等效业务成本。（当然，一切都要根据自己的情况进行调整。）现在你需要想办法收回这笔成本。澳大利亚一年大约有 255 个工作日，所以如果你能把它们全部外包出去，你每天要收取 550 美元（外加销售税）。在现实中，你不可能为一整年的工作计费。我采取了一种风险更高的方法，在我目前从事自营职业的过去 6 年里，我的平均回报率约为 60%-70%。埃森哲的年度财务报告 说他们的承包商的“利用率”大约为 90%，我想这意味着他们付费了 90% 的总工作日的费用。让我们假设你的工作适度，75% 的工作日都有付账。这意味着你可以在 255 天的 75%（即 191 天） 里收回了 14 万美元的成本，每天开出 730 美元的账单（加上销售税）。误区刚接触合同的人通常会对这样的数字做出反应，并会想，“见鬼？！这么高啊！”。这只是一个计算示例，但服务价格通常相当于你天真猜测的全职雇员工资的两倍或两倍以上。然而，这日薪是通过一个简单的计算得出的，那就是你需要收取多少钱才能获得相当于 10 万美元的工资。这是一回事。不这样想才是关键的错误。新的承包商往往还不敢确定，他们要求那么多，听起来是不是很贪婪？如果你的客户有考虑采购你，他们也在做大致相同的计算。“我可以付给 Gentle Blog Reader 每天 730 美元，只要我愿意，我也可以花大约 14 万美元雇佣一个我甚至不是每天都真正需要的全职雇员。”从雇主的角度来看，10 万美元的薪水实际上也不是 10 万美元。把价格建立在名义基本工资的基础上是没有意义的。即使你是在销售 B2C 产品，你的潜在竞争对手也不会降价，至少不会持续降价。为什么这很重要这个具体的例子是针对承包的，但这是商业经济学的一条基本规则：除非你正在尝试一些风险极高的高收入行为（我们知道 Pets.com 的结果），否则你需要计算出你的成本，并设定足够高的价格来弥补这些成本。一些人仍然对他们需要设定的价格感到不安，他们认为降低价格是合理的。也许他们会这样想:“我是个很好的人，如果我每天只收 400 美元，我的客户会更高兴。”问题是你得不到同样的客户。那些愿意为雇员支付 10 万美元年薪的客户，不会为承包商支付 400 美元一天来做同样的工作。相反，在实践中，你可能会得到一些好客户，他们只是没有每天 730 美元的预算，但同时你也会得到一大堆非常糟糕的客户。想想看。如果一个陌生人以 50 美元的价格卖给你一枚看起来很精美的钻戒，你会付钱吗？还是宁愿以正常价格买另一枚戒指？我要强调的是，我只是从 Caleb 的博客文章中提取了数据，而且一切都是相对的。用你自己的数字代替吧。在世界上大多数地区，每天 400 美元可能是一个令人难以置信的价格。然而，如果你是硅谷的一名高级金融科技开发人员，每天收费 400 美元只会让你成为吸引糟糕客户的磁铁。大多数优秀的人都会知道有些事情不对劲，他们会被吓跑。我说的糟糕客户是什么意思？浏览一下 “来自地狱的客户”博客 吧。它包括很多基本的烦恼，比如客户永远不会得到满足，或者提出无理要求，或者浪费你的时间，一直到彻头彻尾的辱骂，或者让你按规格工作，然后辩称自己不应该付钱，因为“我不想要”。有些客户根本就不付钱。如果你不够重视你自己的产品，也不要因为你的客户不够重视你的产品而感到震惊。不过，情况会变得更糟。好客户倾向于与其他好客户合作。如果你总是按时做到，你会和那些浪费你时间的人一起工作吗？如果你尊重他人，你会和那些不讲道理、辱骂他人的人一起工作吗？一般来说，你的好客户会把你介绍给其他好客户。糟糕的客户则恰恰相反，如果他们甚至会感激地把你推荐给任何人的话。因此，如果你的价格合适，你的生意会随着你的声誉而增长。如果你收费过低，你会发现自己陷入了一个恶性循环，你不仅会赔钱，而且会发现越来越难获得适当的报酬。这些都只是平均水平，如果你幸运的话，低收费也能吸引到好客户，如果你不幸的话，价格合理也仍然会得到坏客户。然而，如果你的收入已经很低了，那么每一个坏客户都会对你造成伤害。希望超越平均水平不是一个好计划。“但没人付那么多钱！”假设你是一名经验丰富的全职工程师，你决定尝试独立工作。你可能会发现，你的计算比率似乎比你在自由职业网站上看到的要高。这是因为在自由职业网站上建立声誉很难。自由职业者网站对于那些主要想要低价的临时买家来说是最有用的。我想，很多聪明的工程师都认为，职业社交是很难的，而且需要非常外向的性格，所以他们不得不依靠自由职业网站来工作。坏消息是，你需要建立良好的声誉才能拿到高薪。好消息是，只要他们拥有所需的技能，大多数人都可以做到。社交并不是去参加所谓的“社交活动”（实际上，这些活动对社交来说都很糟糕）。建立关系网的技巧可以写一篇全新的博客文章，但关键是在他们的日常生活中找到好客户，并做一些让他们不断回头的事情，甚至可能让你找到其他好客户。在任何情况下，不要让自由职业网站或其他任何东西把你的价格定在你可以从全职工资中拿到的等价物以下。事实上，你甚至可能比你现在的工资还高，这就是为什么这是 “定价基础原则”。收费过低会扼杀你的自主创业生涯。via: https://theartofmachinery.com/2021/07/04/pricing_as_contractor_101.html作者：Simon Arneaud 选题：lujun9972 译者：CN-QUAN 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
我通常会抽象地总结我为他人所做的工作（出于显而易见的原因），但是我被允许公开谈论一个网站：Vocal 。我去年为它做了一些 SRE 工作。实际上早在 2 月份，我就在 GraphQL 悉尼会议上做过一次演讲，不过这篇博客推迟了一点才发表。Vocal 是一个基于 GraphQL 的网站，它获得了人们的关注，然后就遇到了可扩展性问题，而我是来解决这个问题的。这篇文章会讲述我的工作。显然，如果你正在扩展一个 GraphQL 站点，你会发现这篇文章很有用，但其中大部分内容讲的都是当一个站点获得了足够的流量而出现的必须解决的技术问题。如果你对站点可扩展性有兴趣，你可能想先阅读 最近我发表的一系列关于可扩展性的文章。VocalVocal 是一个博客平台，内容包括日记、电影评论、文章评论、食谱、专业或业余摄影、美容和生活小贴士以及诗歌，当然，还有可爱的猫猫和狗狗照片。Vocal 的不同之处在于，它允许人们制作观众感兴趣的作品而获得报酬。作者的页面每次被浏览都可以获得一小笔钱，还能获得其他用户的捐赠。有很多专业人士在这个平台上展示他们的工作，但对于大多数普通用户来说，他们只是把 Vocal 当作一个兴趣爱好，碰巧还能赚些零花钱作为奖励。Vocal 是新泽西初创公司 Jerrick Media 的产品，更新：Jerrick Media 已经更名为 Creatd，在纳斯达克上市。2015 年，他们与 Thinkmill 合作一起开发，Thinkmill 是一家悉尼中型软件开发咨询公司，擅长 JavaScript、React 和 GraphQL 开发。剧透不幸的是，有人告诉我，由于法律原因，我不能提供具体的流量数字，但公开的信息可以说明。Alexa 对所有网站按照流量进行排名。这是我演讲中展示的 Alexa 排名图，从 2019 年 11 月到今年 2 月，Vocal 流量增长到全球排名第 5567 位。曲线增长变慢是正常的，因为它需要越来越多的流量来赢得每个位置。Vocal 现在排名 4900 名左右，显然还有很长的路要走，但对于一家初创公司来说，这一点也不寒酸。大多数初创公司都很乐意与 Vocal 互换排名。在网站升级后不久，Creatd 开展了一项营销活动，使流量翻了一番。在技术方面，我们要做的就是观察仪表盘上的上升的数字。自发布以来的 9 个月里，只有两个平台问题需要员工干预：3 月份每五年一次的 AWS RDS 证书轮换，以及一款应用推出时遇到的 Terraform 错误。作为一名 SRE，我很高兴看到 Vocal 不需要太多的平台工作来保持运行。更新：该系统也抗过了 2020 年的美国大选，没有任何意外。以下是本文技术内容的概述：技术和历史背景从 MongoDB 迁移到 Postgres部署基础设施的改造使应用程序兼容扩展措施让 HTTP 缓存发挥作用其他一些性能调整一些背景信息Thinkmill 使用 Next.js（一个基于 React 的 Web 框架）构建了一个网站，和 Keystone 在 MongoDB 前面提供的 GraphQL API 进行交互。Keystone 是一个基于 GraphQL 的无头 CMS 库：在 JavaScripy 中定义一个模式，将它与一些数据存储挂钩，并获得一个自动生成的 GraphQL API 用于数据访问。这是一个自由开源软件项目，由 Thinkmill 提供商业支持。Vocal V2Vocal 的第一版就受到了关注，它找到了一个喜欢它的用户群，并不断壮大，最终 Creatd 请求 Thinkmill 帮助开发 V2，并于去年 9 月成功推出。Creatd 员工避免了 第二个系统效应，他们一般都是根据用户的反馈进行改变，所以他们 主要是 UI 和功能更改，我就不赘述了。相反，我将讨论下我的工作内容：使新站点更加健壮和可扩展。声明：我很感谢能与 Creatd 以及 Thinkmill 在 Vocal 上的合作，并且他们允许我发表这个故事，但 我仍然是一名独立顾问，我写这篇文章没有报酬，甚至没有被要求写它，这仍然是我自己的个人博客。迁移数据库Thinkmill 在使用 MongoDB 时遇到了几个可扩展性问题，因此决定升级到 Keystone 5 以利用其新的 Postgres 支持。如果你从事技术工作的时间足够长，那你可能还记得 00 年代末的 “NOSQL” 营销，这可能听起来很有趣。NoSQL 的一个重要特点是，像 Postgres 这样的关系数据库（SQL）不像 MongoDB 这样“网站级规模”的 NoSQL 数据库那样具有可扩展性。从技术上将，这种说法是正确的，但 NoSQL 数据库的可扩展性来自它可以有效处理各种查询的折衷。简单的非关系数据库（如文档数据库和键值数据库）有其一席之地，但当它们用作应用的通用后端时，应用程序通常会在超出关系数据库的理论扩展限制之前，就超出了数据库的查询限制。Vocal 的原本的大多数数据库查询在 MongoDB 上运行良好，但随着时间推移，越来越多的查询需要特殊技巧才能工作。在技术要求方面，Vocal 与维基百科非常相似。维基百科是世界上最大的网站之一，它运行在 MySQL（或者说它的分支 MariaDB）上。当然，这需要一些重要的工程来实现，但在可预见的未来，我认为关系数据库不会对 Vocal 的扩展构成严重威胁。我做过一个比较，托管的 AWS RDS Postgres 实例的成本不到旧 MongoDB 实例的五分之一，但 Postgres 实例的 CPU 使用率仍然低于 10%，尽管它提供的流量比旧站点多。这主要是因为一些重要的查询在文档数据库架构下一直效率很低。迁移的过程可以新写一篇博客文章来讲述，但基本上是 Thinkmill 开发人员构建了一个 ETL 管道，使用 MoSQL 来完成这项繁重的工作。由于 Keystone 对于 Postgres 支持仍然比较基础，但它是一个 FOSS 项目，所以我能够解决在 SQL 生成性能方面遇到的问题。对于这类事情，我总是推荐 Markys Winand 的 SQL 博文：使用 Luke 索引 和 现代 SQL。他的文章很友好，甚至对那些暂时不太关注 SQL 人来说也是容易理解的，但他拥有你大多数需要的理论知识。如果你仍然有问题，一本好的、专注于即可性能的书可以帮助你。平台架构V1 是几个 Node.js 应用，运行在 Cloudflare（作为 CDN）背后的单个虚拟专用服务器（VPS）上。我喜欢把避免过度工程化作为一个高度优先事项，所以我对这一架构竖起了大拇指。然而，在 V2 开始开发时，很明显，Vocal 已经超越了这个简单的架构。在处理巨大峰值流量时，它没有给 Thinkmill 开发人员提供很多选择，而且它很难在不停机情况下安全部署更新。这是 V2 的新架构：基本上就是两个 Node.js 应用程序复制放在负载均衡器后面，非常简单。有些人认为可扩展架构要比这复杂得多，但是我曾经在一些比 Vocal 规模大几个数量级的网站工作过，它们仍然只是在负载均衡器后面复制服务，带有 DB 后端。你仔细想想，如果平台架构需要随着站点的增长而变得越来越复杂，那么它就不是真正“可扩展的”。网站可扩展性主要是解决那些破坏可扩展的实现细节。如果流量增长得足够多，Vocal 的架构可能需要一些补充，但它变得更加复杂的主要原因是新功能。例如，如果（出于某种原因）Vocal 将来需要处理实时地理空间数据，那将是一个与博客文章截然不同的技术，所以我预期它会进行架构上的更改。大型网站架构的复杂性主要来自于复杂的功能。不知道未来的架构应该是什么样子很正常，所以我总是建议你尽可能从简单开始。修复一个简单架构要比复杂架构更容易，成本也更低。此外，不必要的复杂架构更有可能出现错误，而这些错误将更难调试。顺便说一下，Vocal 分成了两个应用程序，但这并不重要。一个常见的扩展错误是，以可扩展的名义过早地将应用分割成更小的服务，但将应用分割在错误的位置，从而导致更多的可扩展性问题。作为一个单体应用，Vocal 可以扩展的很好，但它的分割做的也很好。基础设施Thinkmill 有一些人有使用 AWS 经验，但它主要是一个开发车间，需要一些比之前的 Vocal 部署更容易上手的东西。我最终在 AWS Fargate 上部署了新的 Vocal，这是弹性容器服务（ECS）的一个相对较新的后端。在过去，许多人希望 ECS 作为一个“托管服务运行 Docker 容器”的简单产品，但人们仍然必须构建和管理自己的服务器集群，这让人感到失望。使用 ECS Fargate，AWS 就可以管理集群了。它支持运行带有复制、健康检查、滚动更新、自动缩放和简单警报等基本功能的 Docker 容器。一个好的替代方案是平台即服务（PaaS），比如 App Engine 或 Heroku。Thinkmill 已经在简单的项目中使用它们，但通常在其他项目中需要更大的灵活性。在 PaaS 上运行的网站规模要大得多，但 Vocal 的规模决定了使用自定义云部署是有经济意义的。另一个明显的替代方案是 Kubernetes。Kubernetes 比 ECS Fargate 拥有更多的功能，但它的成本要高得多 —— 无论是资源开销还是维护（例如定期节点升级）所需的人员。一般来说，我不向任何没有专门 DevOps 员工的地方推荐 Kubernetes。Fargate 具有 Vocal 需要的功能，使得 Thinkmill 和 Creatd 能专心于网站改进，而不是忙碌于搭建基础设施。另一种选择是“无服务器”功能产品，例如 AWS Lambda 或 Google 云。它们非常适合处理流量很低或很不规则的服务，但是 ECS Fargate 的自动伸缩功能足以满足 Vocal 的后端。这些产品的另一个优点是，它们允许开发人员在云环境中部署东西，但无需了解很多关于云环境的知识。权衡的结果是，无服务器产品与开发过程、测试以及调试过程紧密耦合。Thinkmill 内部已经有足够的 AWS 专业知识来管理 Fargate 的部署，任何知道如何开发 Node.js 简单的 Hello World 应用程序的开发人员都可以在 Vocal 上工作，而无需了解无服务器功能或 Fargate 的知识。ECS Fargate 的一个明显缺点是供应商锁定。但是，避免供应商锁定是一种权衡，就像避免停机一样。如果你担心迁移，那么在平台独立性花费比迁移上更多的钱是没有意义的。在 Vocal 中，依赖于 Fargate 的代码总量小于 500 行 Terraform。最重要的是 Vocal 应用程序代码本身与平台无关，它可以在普通开发人员的机器上运行，然后打包成一个 Docker 容器，几乎可以运行在任何可以运行 Docker 容器的地方，包括 ECS Fargate。Fargate 的另一个缺点是设置复杂。与 AWS 中的大多数东西一样，它处于一个 VPC、子网、IAM 策略世界中。幸运的是，这类东西是相对静态的（不像服务器集群一样需要维护）。制作一个可扩展的应用程序如果你想轻松地运行一个大规模的应用程序，需要做一大堆正确的事情。如果你遵循 应用程序设计的十二个守则 the Twelve-Factor App design ，事情会变得容易，所以我不会在这里赘述。如果员工无法规模化操作，那么构建一个“可扩展”系统就毫无意义 —— 就像在独轮车上安装喷气式发动机一样。使 Vocal 可扩展的一个重要部分是建立 CI/CD 和 基础设施即代码 之类的东西。同样，有些部署的思路也不值得考虑，因为它们使生产与开发环境相差太大（参阅 应用程序设计守则第十守则）。生产和开发之间的任何差异都会降低应用程序的开发速度，并且（在实践中）最终可能会导致错误。缓存缓存是一个很大的话题 —— 我曾经做过 一个关于 HTTP 缓存的演讲，相对比较基础。我将在这里谈论缓存对于 GraphQL 的重要性。首先，一个重要的警告：每当遇到性能问题时，你可能会想：“我可以将这个值放入缓存中吗，以便再次使用时速度更快？”微基准测试 总是 告诉你：是的。 然而，由于缓存一致性等问题，随处设置缓存往往会使整个系统 变慢。以下是我对于缓存的检查表：是否需要通过缓存解决性能问题再仔细想想（非缓存的性能往往更加健壮）是否可以通过改进现有的缓存来解决问题如果所有都失败了，那么可以考虑添加新的缓存在 Web 系统中，你经常使用的一个缓存是 HTTP 缓存系统，因此，在添加额外缓存之前，试着使用 HTTP 缓存是一个好主意。我将在这篇文章中重点讨论这一点。另一个常见的陷阱是使用哈希映射或应用程序内部其他东西进行缓存。它在本地开发中效果很好，但在扩展时表现糟糕。最好的办法是使用支持显式缓存库，支持 Redis 或 Memcached 这样的可插拔后端。基础知识HTTP 规范中有两种类型缓存：私有和公共。私有缓存不会和多个用户共享数据 —— 实际上就是用户的浏览器缓存。其余的就是公共缓存。它们包括受你控制的（例如 CDN、Varnish 或 Nginx 等服务器）和不受你控制的（代理）。代理缓存在当今的 HTTPS 世界中很少见，但一些公司网络会有。缓存查找键通常基于 URL，因此如果你遵循“相同的内容，相同的 URL；不同的内容，不同的 URL” 规则，即，给每个页面一个规范的 URL，避免从同一个 URL 返回不同的内容这样“聪明”的技巧，缓存就会强壮一点。显然，这对 GraphQL API 端点有影响（我将在后面讨论）。你的服务器可以采用自定义配置，但配置 HTTP 缓存的主要方法是在 Web 响应上设置 HTTP 头。最重要的标头是 cache-control。下面这一行说明所有缓存都可以缓存页面长达 3600 秒（一小时）：cache-control: max-age=3600, public 对于针对用户的页面（例如用户设置页面），重要的是使用 private 而不是 public 来告诉公共缓存不要存储响应，防止其提供给其他用户。另一个常见的标头是 vary，它告诉缓存，响应是基于 URL 之外的一些内容而变化。（实际上，它将 HTTP 头和 URL 一起添加到缓存键中。）这是一个非常生硬的工具，这就是为什么尽可能使用良好 URL 结构的原因，但一个重要的用例是告诉浏览器响应取决于登录的 cookie，以便在登录或注销时更新页面。vary: cookie 如果页面根据登录状态而变化，你需要 cache-control:private（和 vary:cookie），即使是在公开的、已登出的页面版本上，以确保响应不会混淆。其他有用的标头包括 etag 和 last-modified，但我不会在这里介绍它们。你可能仍然会看到一些诸如 expires 和 pragma:cache 这种老旧的 HTTP 标头。它们早在 1997 年就被 HTTP/1.1 淘汰了，所以我只在我想禁用缓存或者我感到偏执时才使用它们。客户端标头鲜为人知的是，HTTP 规范允许在客户端请求中使用 cache-control 标头以减少缓存时间并获得最新响应。不幸的是，似乎大多数浏览器都不支持大于 0 的 max-age ，但如果你有时在更新后需要一个最新响应，no-cache 会很有用。HTTP 缓存和 GraphQL如上，正常的缓存键是 URL。但是 GraphQL API 通常只使用一个端点（比如说 /api/）。如果你希望 GraphQL 查询可以缓存，你需要查询参数出现在 URL 路径中，例如 /api/?query={user{id}}&variables={"x":99}（忽略了 URL 转义）。诀窍是将 GraphQL 客户端配置为使用 HTTP GET 请求进行查询（例如，将 apollo-link-http 设置为 useGETForQueries ）。Mutation 不能缓存，所以它们仍然需要使用 HTTP POST 请求。对于 POST 请求，缓存只会看到 /api/ 作为 URL 路径，但缓存会直接拒绝缓存 POST 请求。请记住，GET 用于非 Mutation 查询（即幂等），POST 用于 Mutation（即非幂等）。在一种情况下，你可能希望避免使用 GET 查询：如果查询变量包含敏感信息。URL 经常出现在日志文件、浏览器历史记录和聊天中，因此 URL 中包含敏感信息通常是一个坏主意。无论如何，像身份验证这种事情应该作为不可缓存的 Mutation 来完成，这是一个特殊的情况，值得记住。不幸的是，有一个问题：GraphQL 查询往往比 REST API URL 大得多。如果你简单地切换到基于 GET 的查询，你会得到一些非常长的 URL，很容易超过 2000 字节的限制，目前一些流行的浏览器和服务器还不会接受它们。一种解决方案是发送某种查询 ID，而不是发送整个查询，即类似于 /api/?queryId=42&variables={"x":99}。Apollo GraphQL 服务器对此支持两种方式：一种方法是 从代码中提取所有 GraphQL 查询，并构建一个服务器端和客户端共享的查询表。缺点之一是这会使构建过程更加复杂，另一个缺点是它将客户端项目与服务器项目耦合，这与 GraphQL 的一个主要卖点背道而驰。还有一个缺点是 X 版本和 Y 版本的代码可能识别一组不同的查询，这会成为一个问题，因为 1：复制的应用程序将在更新推出或回滚期间提供多个版本，2：客户端可能会使用缓存的 JavaScript，即使你升级或降级服务器。另一种方式是 Apollo GraphQL 所宣称的 自动持久查询（APQ）。对于 APQ 而言，查询 ID 是查询的哈希值。客户端向服务器发出请求，通过哈希查询。如果服务器无法识别该查询，则客户端会在 POST 请求中发送完整的查询，服务器会保存此次查询的散列值，以便下次识别。HTTP 缓存和 Keystone 5如上所述，Vocal 使用 Keystone 5 生成 GraphQL API，而 Keystone 5 和 Apollo GraphQL 服务器配合一起工作。那么我们是如何设置缓存标头的呢？Apollo 支持 GraphQL 模式的 缓存提示 cache hint 。巧妙地是，Apollo 会收集查询涉及的所有内容的所有缓存提示，然后它会自动计算适当的缓存标头值。例如，以这个查询为例：query userAvatarUrl { authenticatedUser { name avatar_url } } 如果 name 的最长期限为 1 天，而 avatar_url 的最长期限为 1 小时，则整体缓存最长期限将是最小值，即 1 小时。authenticatedUser 取决于登录 cookie，因此它需要一个 private 提示，它会覆盖其他字段的 public，因此生成的 HTTP 头将是 cache-control:max-age=3600,private。我 对 Keystone 列表和字段添加了缓存提示支持。以下是一个简单例子，在文档的待办列表演示中给一个字段添加缓存提示：const keystone = new Keystone({ name: 'Keystone To-Do List', adapter: new MongooseAdapter(), }); keystone.createList('Todo', { schemaDoc: 'A list of things which need to be done', fields: { name: { type: Text, schemaDoc: 'This is the thing you need to do', isRequired: true, cacheHint: { scope: 'PUBLIC', maxAge: 3600, }, }, }, }); 另一个问题：CORS令人沮丧的是， 跨域资源共享 Cross-Origin Resource Sharing （CORS）规则会与基于 API 网站中的缓存产生冲突。在深入问题细节之前，让我们跳到最简单的解决方案：将主站点和 API 放在一个域名上。如果你的站点和 API 位于同一个域名上，就不必担心 CORS 规则（但你可能需要考虑 限制 cookie）。如果你的 API 专门用于网站，这是最简单的解决方案，你可以愉快地跳过这一节。在 Vocal V1 中，网站（Next.js）和平台（Keystone GraphQL）应用程序处于不同域（vocal.media 和 api.vocal.media）。为了保护用户免受恶意网站的侵害，现代浏览器不会随便让一个网站与另一个网站进行交互。因此，在允许 vocal.media 向 api.vocal.media 发出请求之前，浏览器会对 api.vocal.media 进行“预检”。这是一个使用 OPTIONS 方法的 HTTP 请求，主要是询问跨域资源共享是否允许。预检通过后，浏览器会发出最初的正常请求。令人沮丧的是，预检是针对每个 URL 的。浏览器为每个 URL 发出一个新的 OPTIONS 请求，服务器每次都会响应。服务器没法说 vocal.media 是所有 api.vocal.media 请求的可信来源 。当所有内容都是对一个 API 端点的 POST 请求时，这个问题并不严重，但是在为每个查询提供 GET 式 URL 后，每个查询都因预检而延迟。更令人沮丧的是，HTTP 规范说 OPTIONS 请求不能被缓存，所以你会发现你所有的 GraphQL 数据都被完美地缓存在用户身旁的 CDN 中，但浏览器仍然必须向源服务器发出所有的预检请求。如果你不能只使用一个共享的域，有几种解决方案。如果你的 API 足够简单，你或许可以利用 CORS 规则的例外。某些缓存服务器可以配置为忽略 HTTP 规范，任何情况都会缓存 OPTIONS 请求。例如，基于 Varnish 的缓存和 AWS CloudFrone。这不如完全避免预检那么有效，但比默认的要好。另一个很魔改的选项是 JSONP。当心：如果做错了，那么可能会创建安全漏洞。让 Vocal 更好地利用缓存让 HTTP 缓存在底层工作之后，我需要让应用程序更好地利用它。HTTP 缓存的一个限制是它在响应级别上要么是全有要么是全无的。大多数响应都可以缓存，但如果一个字节不能缓存，那整个页面就无法缓存。作为一个博客平台，大多数 Vocal 数据都是可缓存的，但在旧网站上，由于右上角的菜单栏，几乎没有页面可以缓存。对于匿名用户，菜单栏将显示邀请用户登录或创建账号的链接。对于已登录用户，它会变成用户头像和用户个人资料菜单，因为页面会根据用户登录状态而变化，所以无法在 CDN 中缓存任何页面。这些页面是由 React 组件的服务器端渲染（SSR）的。解决方法是将所有依赖于登录 cookie 的 React 组件，强制它们 只在客户端进行延迟呈现。现在，服务器会返回完全通用的页面，其中包含用于个性化组件（如登录菜单栏）的占位符。当页面在浏览器中加载时，这些占位符将通过调用 GraphQL API 在客户端填充。通用页面可以安全地缓存到 CDN 中。这一技巧不仅提高了缓存命中率，还帮助改善了人们感知的页面加载时间。空白屏幕和旋转动画让我们不耐烦，但一旦第一个内容出现，它会分散我们几百毫秒的注意力。如果人们在社交媒体上点击一个 Vocal 帖子的链接，主要内容就会立即从 CDN 中出现，很少有人会注意到，有些组件直到几百毫秒后才会完全出现。顺便说一下，另一个让用户更快地看到第一个内容的技巧是 流式渲染，而不是等待整个页面渲染完成后再发送。不幸的是，Node.js 还不支持这个功能。拆分响应来提高可缓存性也适用于 GraphQL。通过一个请求查询多个数据片段的能力通常是 GraphQL 的一个优势，但如果响应的不同部分具有差别很大的缓存，那么最好将它们分开。举个简单的例子，Vocal 的分页组件需要知道当前页面的页数和内容。最初，组件在一个查询中同时获取两个页面，但由于页面的总数是所有页面的一个常量，所有我将其设置为一个单独的查询，以便缓存它。缓存的好处缓存的明显好处是它减轻了 Vocal 后端服务器的负载。这很好。但是依赖缓存来获得容量是危险的，你仍然需要一个备份计划，以便当有一天你不可避免地放弃缓存。提高页面响应速度是使用缓存是一个更好的理由。其他一些好处可能不那么明显。峰值流量往往是高度本地化的。如果一个有很多社交媒体粉丝的人分享了一个页面的链接，那么 Vocal 的流量就会大幅上升，但主要是指向分享的那个页面及其元素。这就是为什么缓存擅长吸收最糟糕的流量峰值，它使后端流量模式相对更平滑，更容易被自动伸缩处理。另一个好处是 优雅的退化 graceful degradation 。即使后端由于某些原因出现了严重的问题，站点最受欢迎的部分仍然可以通过 CDN 缓存来提供服务。其他的性能调整正如我常说的，可扩展的秘诀不是让事情变得更复杂。它只是让事情变得不比需要的更复杂，然后彻底解决所有阻碍扩展的东西。扩展 Vocal 的规模涉及到许多小事，在这篇文章中无法一一说明。一个经验：对于分布式系统中难以调试的问题，最困难的部分通常是获取正确的数据，从而了解发生的原因。我能想到很多时候，我被困住了，只能靠猜测来“即兴发挥”，而不是想办法找到正确的数据。有时这行得通，但对复杂的问题却不行。一个相关技巧是，你可以通过获取系统中每个组件的实时数据（甚至只是 tail -F 的日志），在不同的窗口中显示，然后在另一个窗口中单击网站来了解很多信息。比如：“为什么切换这个复选框会在后台产生这么多的 DB 查询？”这里有个修复的例子。有些页面需要几秒钟以上的时间来呈现，但这个情况只会在部署环境中使用 SSR 时会出现。监控仪表盘没有显示任何 CPU 使用量峰值，应用程序也没有使用磁盘，所以这表明应用程序可能正在等待网络请求，可能是对后端的请求。在开发环境中，我可以使用 sysstat 工具来记录 CPU、RAM、磁盘使用情况，以及 Postgres 语句日志和正常的应用日志来观察应用程序是如何工作的。Node.js 支持探测跟踪 HTTP 请求，比如使用 bpftrace，但处于某些无聊的原因，它们不能在开发环境中工作，所以我在源代码中找到了探针，并创建了一个带有请求日志的 Node.js 版本。我使用 tcpdump 记录网络数据，这让我找到了问题所在：对于网站发出的每一个 API 请求，都要创建一个新的网络连接到 “Platform”。（如果这都不起作用，我想我会在应用程序中添加请求跟踪功能。）网络连接在本地机器上速度很快，但在现实网络上却不可忽略。建立加密连接（比在生产环境中）需要更长时间。如果你向一个服务器（比如一个 API）发出大量请求，保持连接打开并重用它是很重要的。浏览器会自动这么做，但 Node.js 默认不会，因为它不知道你是否发出了很多请求，所以这个问题只出现在 SSR 上。与许多漫长的调试过程一样，修复却非常简单：只需将 SSR 配置为 保持连接存活，这样会使页面的呈现时间大幅下降。如果你想了解更多这方面的知识，我强烈建议你阅读《高性能浏览器网络》这本书（可免费在线阅读），并跟进 Brendan Gregg 发表的指南。你的站点呢？实际上，我们还可以做很多事情来提升 Vocal 的速度，但我们没有全做。这是因为在初创公司和在大公司身为一个固定员工做 SRE 工作还是有很大区别的。我们的目标、预算和发布日期都很紧张，但最终我们的网站得到了很大改善，给了用户他们想要的东西。同样的，你的站点有它自己的目标，并且可能与 Vocal 有很大的不同。然而，我希望这篇文章和它的链接至少能给你一些有用的思路，为用户创造更好的东西。via: https://theartofmachinery.com/2020/06/29/scaling_a_graphql_site.html作者：Simon Arneaud 选题：lujun9972 译者：MjSeven 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
现实中的应用程序是如何丢失数据？
现代应用程序开发的一大优点是，像硬件故障或如何设置 RAID 这类问题是由云提供商操心的。优秀的云供应商不太可能丢失你的应用数据，所以有时我会被询问现在为什么还要备份？下面是一些现实世界的故事。故事之一第一个故事来自一个数据科学项目：它基本上是一个从正在进行的研究中来收集数据的庞大而复杂的管道，然后用各种不同的方式处理以满足一些尖端模型的需要。这个面向用户的应用程序还没有推出，但是一个由数据科学家和开发人员组成的团队已经为建立这个模型和它的数据集工作了好几个月。在项目中工作的人有他们自己的实验工作的开发环境。他们会在终端中做一些类似 export ENVIRONMENT=simonsdev 的事情，然后所有在终端上运行的软件都会在那个环境下运行，而不是在生产环境下。该团队迫切需要推出一个面向用户的应用程序，以便那些花钱的人能够从他们几个月的投资中真正看到一些回报。在一个星期六，一位工程师试图赶工一些工作。他在晚上很晚的时候做完了一个实验，决定收拾东西回家。他启动了一个清理脚本来删除他的开发环境中的所有内容，但奇怪的是，这比平时花费了更长的时间。这时他意识到，他已经忘记了哪个终端被配置为指向哪个环境。（LCTT 译注：意即删除了生产环境。）故事之二第二个故事来自于一个商业的网页和手机应用。后端有一个由一组工程师负责的微服务体系结构。这意味着部署需要协调，但是使用正式的发布过程和自动化简化了一些。新代码在准备好后会被审查并合并到主干中，并且高层开发人员通常会为每个微服务标记版本，然后自动部署到临时环境。临时环境中的版本会被定期收集到一个元版本中，在自动部署到生产环境之前，该版本会得到各个人的签署（这是一个合规环境）。有一天，一位开发人员正在开发一个复杂的功能，而其他开发该微服务的开发人员都同意将他们正在开发的代码提交到主干，也都知道它还不能被实际发布。长话短说，并不是团队中的每个人都收到了消息，而代码就进入了发布管道。更糟糕的是，那些实验性代码需要一种新的方式来表示用户配置文件数据，因此它有一个临时数据迁移，它在推出到生产环境时运行，损坏了所有的用户配置文件。故事之三第三个故事来自另一款网页应用。这个有一个更简单的架构：大部分代码在一个应用程序中，数据在数据库中。然而，这个应用程序也是在很大的截止日期压力下编写的。事实证明，在开发初期，当彻底更改的数据库架构很常见时，添加一项功能来检测此类更改并清理旧数据，这实际上对发布前的早期开发很有用，并且始终只是作为开发环境的临时功能。不幸的是，在匆忙构建应用的其余部分并推出时，我们忘记了这些代码。当然，直到有一天它在生产环境中被触发了。事后分析对于任何故障的事后分析，很容易忽视大局，最终将一切归咎于一些小细节。一个特例是发现某人犯了一些错误，然后责怪那个人。这些故事中的所有工程师实际上都是优秀的工程师（雇佣 SRE 顾问的公司不是那些在长期雇佣中偷工减料的公司），所以解雇他们，换掉他们并不能解决任何问题。即使你拥有 100 倍的开发人员，它仍然是有限的，所以在足够的复杂性和压力下，错误也会发生。最重要的解决方案是备份，无论你如何丢失数据（包括来自恶意软件，这是最近新闻中的一个热门话题），它都能帮助你。如果你无法容忍没有副本，就不要只有一个副本。故事之一的结局很糟糕：没有备份。该项目的六个月的数据收集白干了。顺便说一句，有些地方只保留一个每日快照作为备份，这个故事也是一个很好的例子，说明了这也会出错：如果数据丢失发生在星期六，并且你准备在星期一尝试恢复，那么一日备份就只能得到星期日的一个空数据备份。故事之二并不算好，但结果要好得多。备份是可用的，但数据迁移也是可逆的。不好的部分是发布是在推出前完成的，并且修复工作必须在生产站点关闭时进行编码。我讲这个故事的主要原因是为了提醒大家，备份并不仅仅是灾难性的数据丢失。部分数据损坏也会发生，而且可能会更加混乱。故事之三还好。尽管少量数据永久丢失，但大部分数据可以从备份中恢复。团队中的每个人都对没有标记极其明显的危险代码感到非常难过。我没有参与早期的开发，但我感觉很糟糕，因为恢复数据所需的时间比正常情况要长得多。如果有一个经过良好测试的恢复过程，我认为该站点应该在总共不到 15 分钟的时间内重新上线。但是第一次恢复没有成功，我不得不调试它为什么不能成功，然后重试。当一个生产站点宕机了，需要你重新启动它，每过 10 秒钟都感觉过了一个世纪。值得庆幸的是，老板们比某些人更能理解我们。他们实际上松了一口气，因为这一场可能使公司沉没的一次性灾难只导致了几分钟的数据丢失和不到一个小时的停机时间。在实践中，备份“成功”但恢复失败的情况极为普遍。很多时候，小型数据集上进行恢复测试是可以正常工作的，但在生产规模的大数据集上就会失败。当每个人都压力过大时，灾难最有可能发生，而生产站点的故障只会增加压力。在时间合适的时候测试和记录完整的恢复过程是一个非常好的主意。via: https://theartofmachinery.com/2021/06/06/how_apps_lose_data.html作者：Simon Arneaud 选题：lujun9972 译者：PearFL 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
这要从一次咨询的失误说起：政府组织 A 让政府组织 B 开发一个 Web 应用程序。政府机构 B 把部分工作外包给某个人。后来，项目的托管和维护被外包给一家私人公司 C。C 公司发现，之前外包的人（已经离开很久了）构建了一个自定义的 Docker 镜像，并将其成为系统构建的依赖项，但这个人没有提交原始的 Dockerfile。C 公司有合同义务管理这个 Docker 镜像，可是他们他们没有源代码。C 公司偶尔叫我进去做各种工作，所以处理一些关于这个神秘 Docker 镜像的事情就成了我的工作。幸运的是，Docker 镜像的格式比想象的透明多了。虽然还需要做一些侦查工作，但只要解剖一个镜像文件，就能发现很多东西。例如，这里有一个 Prettier 代码格式化 的镜像可供快速浏览。首先，让 Docker 守护进程 daemon 拉取镜像，然后将镜像提取到文件中：docker pull tmknom/prettier:2.0.5 docker save tmknom/prettier:2.0.5 > prettier.tar 是的，该文件只是一个典型 tarball 格式的归档文件：$ tar xvf prettier.tar 6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/ 6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/VERSION 6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/json 6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/layer.tar 88f38be28f05f38dba94ce0c1328ebe2b963b65848ab96594f8172a9c3b0f25b.json a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/ a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/VERSION a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/json a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/layer.tar d4f612de5397f1fc91272cfbad245b89eac8fa4ad9f0fc10a40ffbb54a356cb4/ d4f612de5397f1fc91272cfbad245b89eac8fa4ad9f0fc10a40ffbb54a356cb4/VERSION d4f612de5397f1fc91272cfbad245b89eac8fa4ad9f0fc10a40ffbb54a356cb4/json d4f612de5397f1fc91272cfbad245b89eac8fa4ad9f0fc10a40ffbb54a356cb4/layer.tar manifest.json repositories 如你所见，Docker 在命名时经常使用 哈希 hash 。我们看看 manifest.json。它是以难以阅读的压缩 JSON 写的，不过 JSON 瑞士军刀 jq 可以很好地打印 JSON：$ jq . manifest.json [ { "Config": "88f38be28f05f38dba94ce0c1328ebe2b963b65848ab96594f8172a9c3b0f25b.json", "RepoTags": [ "tmknom/prettier:2.0.5" ], "Layers": [ "a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/layer.tar", "d4f612de5397f1fc91272cfbad245b89eac8fa4ad9f0fc10a40ffbb54a356cb4/layer.tar", "6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/layer.tar" ] } ] 请注意，这三个 层 Layer 对应三个以哈希命名的目录。我们以后再看。现在，让我们看看 Config 键指向的 JSON 文件。它有点长，所以我只在这里转储第一部分：$ jq . 88f38be28f05f38dba94ce0c1328ebe2b963b65848ab96594f8172a9c3b0f25b.json | head -n 20 { "architecture": "amd64", "config": { "Hostname": "", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": false, "AttachStderr": false, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ], "Cmd": [ "--help" ], "ArgsEscaped": true, "Image": "sha256:93e72874b338c1e0734025e1d8ebe259d4f16265dc2840f88c4c754e1c01ba0a", 最重要的是 history 列表，它列出了镜像中的每一层。Docker 镜像由这些层堆叠而成。Dockerfile 中几乎每条命令都会变成一个层，描述该命令对镜像所做的更改。如果你执行 RUN script.sh 命令创建了 really_big_file，然后用 RUN rm really_big_file 命令删除文件，Docker 镜像实际生成两层：一个包含 really_big_file，一个包含 .wh.really_big_file 记录来删除它。整个镜像文件大小不变。这就是为什么你会经常看到像 RUN script.sh && rm really_big_file 这样的 Dockerfile 命令链接在一起——它保障所有更改都合并到一层中。以下是该 Docker 镜像中记录的所有层。注意，大多数层不改变文件系统镜像，并且 empty_layer 标记为 true。以下只有三个层是非空的，与我们之前描述的相符。$ jq .history 88f38be28f05f38dba94ce0c1328ebe2b963b65848ab96594f8172a9c3b0f25b.json [ { "created": "2020-04-24T01:05:03.608058404Z", "created_by": "/bin/sh -c #(nop) ADD file:b91adb67b670d3a6ff9463e48b7def903ed516be66fc4282d22c53e41512be49 in / " }, { "created": "2020-04-24T01:05:03.92860976Z", "created_by": "/bin/sh -c #(nop) CMD [\"/bin/sh\"]", "empty_layer": true }, { "created": "2020-04-29T06:34:06.617130538Z", "created_by": "/bin/sh -c #(nop) ARG BUILD_DATE", "empty_layer": true }, { "created": "2020-04-29T06:34:07.020521808Z", "created_by": "/bin/sh -c #(nop) ARG VCS_REF", "empty_layer": true }, { "created": "2020-04-29T06:34:07.36915054Z", "created_by": "/bin/sh -c #(nop) ARG VERSION", "empty_layer": true }, { "created": "2020-04-29T06:34:07.708820086Z", "created_by": "/bin/sh -c #(nop) ARG REPO_NAME", "empty_layer": true }, { "created": "2020-04-29T06:34:08.06429638Z", "created_by": "/bin/sh -c #(nop) LABEL org.label-schema.vendor=tmknom org.label-schema.name=tmknom/prettier org.label-schema.description=Prettier is an opinionated code formatter. org.label-schema.build-date=2020-04-29T06:34:01Z org .label-schema.version=2.0.5 org.label-schema.vcs-ref=35d2587 org.label-schema.vcs-url=https://github.com/tmknom/prettier org.label-schema.usage=https://github.com/tmknom/prettier/blob/master/README.md#usage org.label-schema.docker.cmd=do cker run --rm -v $PWD:/work tmknom/prettier --parser=markdown --write '**/*.md' org.label-schema.schema-version=1.0", "empty_layer": true }, { "created": "2020-04-29T06:34:08.511269907Z", "created_by": "/bin/sh -c #(nop) ARG NODEJS_VERSION=12.15.0-r1", "empty_layer": true }, { "created": "2020-04-29T06:34:08.775876657Z", "created_by": "/bin/sh -c #(nop) ARG PRETTIER_VERSION", "empty_layer": true }, { "created": "2020-04-29T06:34:26.399622951Z", "created_by": "|6 BUILD_DATE=2020-04-29T06:34:01Z NODEJS_VERSION=12.15.0-r1 PRETTIER_VERSION=2.0.5 REPO_NAME=tmknom/prettier VCS_REF=35d2587 VERSION=2.0.5 /bin/sh -c set -x && apk add --no-cache nodejs=${NODEJS_VERSION} nodejs-np m=${NODEJS_VERSION} && npm install -g prettier@${PRETTIER_VERSION} && npm cache clean --force && apk del nodejs-npm" }, { "created": "2020-04-29T06:34:26.764034848Z", "created_by": "/bin/sh -c #(nop) WORKDIR /work" }, { "created": "2020-04-29T06:34:27.092671047Z", "created_by": "/bin/sh -c #(nop) ENTRYPOINT [\"/usr/bin/prettier\"]", "empty_layer": true }, { "created": "2020-04-29T06:34:27.406606712Z", "created_by": "/bin/sh -c #(nop) CMD [\"--help\"]", "empty_layer": true } ] 太棒了！所有的命令都在 created_by 字段中，我们几乎可以用这些命令重建 Dockerfile。但不是完全可以。最上面的 ADD 命令实际上没有给我们需要添加的文件。COPY 命令也没有全部信息。我们还失去了 FROM 语句，因为它们扩展成了从基础 Docker 镜像继承的所有层。我们可以通过查看 时间戳 timestamp ，按 Dockerfile 对层进行分组。大多数层的时间戳相差不到一分钟，代表每一层构建所需的时间。但是前两层是 2020-04-24，其余的是 2020-04-29。这是因为前两层来自一个基础 Docker 镜像。理想情况下，我们可以找出一个 FROM 命令来获得这个镜像，这样我们就有了一个可维护的 Dockerfile。manifest.json 展示第一个非空层是 a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/layer.tar。让我们看看它：$ cd a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/ $ tar tf layer.tf | head bin/ bin/arch bin/ash bin/base64 bin/bbconfig bin/busybox bin/cat bin/chgrp bin/chmod bin/chown 看起来它可能是一个 操作系统 operating system 基础镜像，这也是你期望从典型 Dockerfile 中看到的。Tarball 中有 488 个条目，如果你浏览一下，就会发现一些有趣的条目：... dev/ etc/ etc/alpine-release etc/apk/ etc/apk/arch etc/apk/keys/ etc/apk/keys/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub etc/apk/keys/alpine-devel@lists.alpinelinux.org-5243ef4b.rsa.pub etc/apk/keys/alpine-devel@lists.alpinelinux.org-5261cecb.rsa.pub etc/apk/protected_paths.d/ etc/apk/repositories etc/apk/world etc/conf.d/ ... 果不其然，这是一个 Alpine 镜像，如果你注意到其他层使用 apk 命令安装软件包，你可能已经猜到了。让我们解压 tarball 看看：$ mkdir files $ cd files $ tar xf ../layer.tar $ ls bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var $ cat etc/alpine-release 3.11.6 如果你拉取、解压 alpine:3.11.6，你会发现里面有一个非空层，layer.tar 与 Prettier 镜像基础层中的 layer.tar 是一样的。出于兴趣，另外两个非空层是什么？第二层是包含 Prettier 安装包的主层。它有 528 个条目，包含 Prettier、一堆依赖项和证书更新：... usr/lib/libuv.so.1 usr/lib/libuv.so.1.0.0 usr/lib/node_modules/ usr/lib/node_modules/prettier/ usr/lib/node_modules/prettier/LICENSE usr/lib/node_modules/prettier/README.md usr/lib/node_modules/prettier/bin-prettier.js usr/lib/node_modules/prettier/doc.js usr/lib/node_modules/prettier/index.js usr/lib/node_modules/prettier/package.json usr/lib/node_modules/prettier/parser-angular.js usr/lib/node_modules/prettier/parser-babel.js usr/lib/node_modules/prettier/parser-flow.js usr/lib/node_modules/prettier/parser-glimmer.js usr/lib/node_modules/prettier/parser-graphql.js usr/lib/node_modules/prettier/parser-html.js usr/lib/node_modules/prettier/parser-markdown.js usr/lib/node_modules/prettier/parser-postcss.js usr/lib/node_modules/prettier/parser-typescript.js usr/lib/node_modules/prettier/parser-yaml.js usr/lib/node_modules/prettier/standalone.js usr/lib/node_modules/prettier/third-party.js usr/local/ usr/local/share/ usr/local/share/ca-certificates/ usr/sbin/ usr/sbin/update-ca-certificates usr/share/ usr/share/ca-certificates/ usr/share/ca-certificates/mozilla/ usr/share/ca-certificates/mozilla/ACCVRAIZ1.crt usr/share/ca-certificates/mozilla/AC_RAIZ_FNMT-RCM.crt usr/share/ca-certificates/mozilla/Actalis_Authentication_Root_CA.crt ... 第三层由 WORKDIR /work 命令创建，它只包含一个条目：$ tar tf 6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/layer.tar work/ 原始 Dockerfile 在 Prettier 的 git 仓库中。via: https://theartofmachinery.com/2021/03/18/reverse_engineering_a_docker_image.html作者：Simon Arneaud 选题：lujun9972 译者：DCOLIVERSUN 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
大约五年前的今天，我上交了 Google 员工证，然后走出了悉尼 Google 办公室，开启了一段自谋职业的崭新生活。我认为我应该详述一下这个故事，因为我通过阅读 Michael Lynch 的作品而收获颇丰。正如你所看到的，我仍然花费了几年时间才开始考虑写这篇文章，但是最终我告诉自己，倘若我不在五周年纪念日写它，我就永远也不会写了。这篇文章有点儿长，但是我希望它对那些对于在大型技术公司工作感兴趣的新开发人员或是想要离职的大型企业雇员能够有所帮助。我将谈谈我进入 Google，在 Google 工作和辞职的故事，以及之后我做了什么。如果你想了解更多的细节，可以随时询问，不过我已经有很多博文要写，所以不能保证有什么深入的内容。同样地，冒着显而易见的劳工风险：我已经有 5 年不在 Google 工作了，所以请不要以这个故事来作为当今 Google 或是 Google 雇员经历全貌的字面描述。但是，我认为其中的许多内容仍然与一般性的技术职业有关。通往 Google 的艰辛道路2005 年，我获得了第一份带薪的编程工作，是在当地的电力公司工作，把一些旧的 Pascal 代码用不同的编译器在不同的操作系统上运行。这基本上只是我为了挣外快而做的暑期工，同年我还刚刚开始攻读我数学和物理的学位。他们很高兴有一个本科生能够胜任这份工作。我被这些大人吓了一跳，因为他们不仅只是对我的编程爱好感兴趣，而且真的还会为此给我钱。直到 2007 年毕业以前，我一直在做类似的工作。我喜欢编程工作，而 Google 是一家从事着很酷的编程工作的很酷的公司，因此我申请了实习。 Google 的面试过程以困难而著称，所以我花了好几个星期时间练习了所有我在网上能够找到的 Google 面试题。我认为 13 年里面试流程并没有发生太大的变化 —— 我提交了简历，受邀参加了几轮电话面试，这些面试问的几乎都是算法问题（我记得有一个动态规划问题和一个分治几何问题）。我通过了最初的几轮面试，然后受邀前往悉尼接受了由 Google 的工程师们进行的为期一天的现场面试。我回到家里，等待 Google HR 的电话，这个过程漫长得像是有一辈子。我被拒绝了。对于我们收到的拒绝和失败感到难过很自然，因此我们并不会经常谈及它们。但是出于同样的原因，其他人也不会去谈论他们自己的失败，这只会使得情况变得更加糟糕。当我后来真的进入 Google 时，我觉得作为一个此前被拒绝过的人，我一定有哪里做得不对，但是有一天我和一群同事坐在一张桌子旁，开始交谈。那时候我才发现，实际上我身边的很多人都至少被拒绝过一次。我甚至都不是“最差的”。有个家伙开玩笑说，他肯定是因为 Google HR 厌倦了拒绝他才得以进来的。我说的也是一些相当厉害的工程师 —— 有些人负责着我一直在用的代码，而我打赌你也在用。进行面试的公司通常会为每个名额面试两名或更多的候选人。这意味着比起录用，会有更多的拒绝，所以一般面试参与者被拒绝的可能性要大于被录用。然而我们一直忘记了这一点。四个开发人员参加面试，一个被录用了，其他三个在社交媒体上抱怨这场面试是如何的漏洞百出，因为他们个人被拒绝了。当然，面试远非完美，但是我们需要停止如此个人化地谈论它。只要你能够找到问题所在并知道如何去改进自己，拒绝和失败就没有那么糟糕。Google 的面试主要针对算法，我在其中磕磕拌拌地摸索，但绝对没有能够脱颖而出。在被 Google 拒绝以后，我得到了两样东西，并进行了为期一年的休假。第一件东西是澳大利亚商务编号（ABN），我用它来提供数学与科学补习课程，以及技术工作合同。我获得的另一样东西是一张大学科技图书馆的借书证。我当时并不打算再次去参加 Google 的面试，但是那次的面试经历告诉我还有很多东西是我所不知道的。我就在图书馆开设课程给大家做辅导，并在期间阅读书籍。顺便说一句，有些人认为我为我的补习业务所做的所有这些财务工作和其他东西很奇怪，而大多数补习老师都只收现金。但是我学到了许多对我日后生活很有帮助的东西，所以我一点儿都不后悔。2009 年，我根据一个叫 Persi Diaconis 的魔术师转行为数学家的作品，进行了一个数学荣誉课程（也就是学士学位四年级）。计算机科学系让我选修他们的一个算法单元作为其中的一部分。就像我所说的那样，我本来并没有打算再去 Google 面试，但是让我快速地讲讲这是怎么发生的。我从高中就开始学习日语，因此在 2012 年，我决定尝试在东京生活。这基本上行得通，除了我犯了一个相当大的错误 —— 我没有任何日语方面的纸质资质证明，因此很难获得工作面试。最终，我的一个已经被 Google 录用的朋友建议我再试一次。与 Google 所有的办事处一样， Google 东京的官方商务语言是英语，因此他们不要求我具有日语资质证明。Google 面试，再一次我的朋友向 Google HR 推荐了我。这绝对有帮助，但是如果你自己得到了被推荐的机会，也不要太过于兴奋。它所能够确保的是你的简历会被注意到（不是小事）并且免去一次电话面试，但你仍然得通过剩下的电话面试和现场面试。这一次我用来自 Project Euler 和 Google CodeJam 的题进行练习。电话面试过程中，我不得不在 Google Doc 上进行一些在线编程，这有点儿尴尬，但是除此以外电话面试一切顺利。然后我受邀前往六本木的 Mori Tower 办公室进行了为期一天的现场面试。我的首个面试非常糟糕。我的脑子僵住了。我知道我能够解出那些题目，但是直到面试官走出房间我才想出答案。我立刻就感到很放松，并且意识到这是一个三元搜索问题。这是在是很令人沮丧，但是我觉得继续前进，看看剩下的面试进展如何。其中的两道面试题很糟糕。其中之一直至今日仍然是我遇到过的最糟糕的面试问题。面试官说：“你用同一输入运行一个程序两次，得到了两个不同的结果。告诉我这是为什么。”我回答道：“当这种情况在现代计算机上发生而且并不在我的预期之中时，通常是竞态条件。”他只说：“不，这不是竞态条件。”然后看着我等着我的下一个回答。如果他有兴趣讨论一下的话，这个问题本该是一个很棒的问题，但是很显然他实际上只想玩“猜猜神秘数”。对于我所说的几乎全部内容，他只是回答：“不。”显然，该程序完全是确定性的，不存储任何状态，并且不依赖于环境（例如磁盘或是实时时钟），但却在每次执行时都给出不同的结果。我怀疑我们对于“被存储的状态”或是“环境”的含义还是某些东西有着不同的理解，但是我无法区分。有一次（变得绝望了）我试着问电子元件的温度变化是否会有影响，而他说：“不，那会是一个竞态条件，我已经告诉过你这不是竞态条件了。”最终，面试结束了，而我仍然不知道那个秘密数字是什么。我讲这个故事的原因是，我听说过许多更为平淡的恐怖故事，用以证明面试官是憎恶面试者的坏人。然而，与流行的刻板印象所相反的是，当天的大多数面试基本上都还可以，面试官也很友好并且很尊重人。面试也着实很困难，因此最好减少面试官的工作量。希望那个“猜数字”面试官从 Google HR 那里得到的反馈是，他的问题对于作出聘用决定没什么帮助。这次，面试带来了一份要约，但是有一个小问题：这份工作在悉尼，担任站点可靠性工程师（SRE）。我以前从未听说过 SRE，但是我和一位悉尼的资深 SRE 通了电话，他解释说他注意到了我在天然气行业从事嵌入式工程的经历，并且认为 SRE 会和适合我，因为同样强调可靠性与拟合紧密约束。在东京花了大约一年时间来建立起自己的生活，我不想抛弃一切然后搬到悉尼，但是我绝不可能会拒绝一份来自 Google 的要约。与招聘人员交谈时，我确实犯了一个非常愚蠢的错误：我被问到当时能赚多少钱，然后我就脱口而出。别这么做。这意味着不管在面试中发生了什么事情，或是你上一份工作中被底薪了多少，或者其它什么。你可能会被拒绝，或者会在原来的薪水基础上得到一些象征性的提升，并且如果你试图进一步协商，会被认为疯狂而又不合理。就我而言，我的收入甚至远远低于 Google 的入门级职位。我无法肯定地说全是这样，但是在 2013 年我搬到了悉尼，在 Google Maps 成为了一名新毕业生级别的 SRE。悉尼的 Google Maps SRE像 Maps 这样的产品实际上是若干个软件项目，每个都有自己的开发人员团队。甚至诸如路线查找之类的功能实际上也是多个软件项目 —— 从交通时刻表数据收集，到线路计算，再到结果渲染，等等等等。 SRE 的工作包含两个方面：一方面是为各个项目提供待命，实时响应任何生产事故；另一方面（在无需救火时）则是将生产事故中所积攒的经验应用到其他项目中去，并且发现其中可能出错的方式，或是发现使其性能更好的机会。Google 的 SRE 还需要像开发人员的内部咨询小组一样，对部署实践、自动化、监控或是类似的问题提供咨询。这项工作相当紧张。作为一个团队，我们每周至少需要处理一次生产事故，否则就要为更多的服务提供支持。每个礼拜，悉尼的所有 SRE 都会聚在一起，交流发生过的故障事件或是有关如何使事情更好地运转的新技巧。学习曲线的感觉就像是再次成为了一名本科生。我有时会感到震惊，听说我选择离开 Google 的人会问：“但是你不会想念那些福利吗？！”物质上的福利（例如伙食等等）绝对很棒，但是它们是你可以买到的东西，因此，不，它们不是我所想念的东西。如果你问我所想念的是什么，我会说是在那里工作的人们。与你可能听说过的不同，傲慢的人不喜欢在 Google 之类的地方工作。有一个臭名昭著的故事，一个自恋的人在 Google 找了份工作，并假装自己是各方面的顶级专家，让自己尴尬不已。他待了不到半年就离开了。总的来说，与我工作过的其他地方相比，这里的文化在傲慢、指责以及政治方面很少。另一方面，Google 并没有垄断好同事。不过，有一种公司政治是个大问题。晋升需要“展示影响”，而众所周知的是，要做到这一点最简单的方法是发布一些新事物（不是惟一的方法，但是最简单）。结果是 Googler 们比起改进现有的解决方案，对于推广他们自己内测品质的原型方案更感兴趣。在 SRE 之间，我们经常开玩笑说， Google 内部有两种软件：一种是老的东西，工作得很好，但已经废弃了，甚至连考虑使用都是不够谷歌化的；另一种是热门的新东西，尽管它们还不能用，但却是今天 100% 可以使用的官方工具。作为 SRE，我们经常亲眼看到新的热点事物出了什么问题（有时甚至在没出 alpha 之前它就已经成了过时的旧东西）。（我此前已经对这类事物进行了更为深入的讨论。）这不是我们这些愤世疾俗的 SRE 所想象的东西；这在公司中被公认为是一个问题，而我记得有人向我保证，晋升委员会已经开始通过维护工作等方式寻找关于其影响的证据。晋升申请2015 年，在 Google 工作了两年之后，我的经理告诉我，现在是时候申请一个高于我新毕业生水准的晋升了。晋升过程是每年两次由晋升委员会进行集中管理的。你可以先提出申请，然后加上一份对你所从事过的项目的简短描述，再加上同事的推荐信。委员会将会进行审查，然后给你赞成或反对的意见。仅仅有你经理的推荐是不够的，因为你的经理有想让你获得晋升的动机。手下有高级别的员工有助于你自己的职业发展。长话短说，我提交了我的申请，而委员会说不。事实上，这是个相当糟糕的拒绝。我不记得详细的答复了，但感觉就像是委员会在我的申请中寻找可以不屑一顾的东西。例如，我从事过的一个项目是一个内部工具，它出现了功能需求的积压。我查看了这个项目，发现根本问题在于它已经超出了构建它的键值存储，需要一个合适的数据库。我主张切换到关系数据库，并实现了它：模式、数据迁移、查询、实时站点迁移等等。新查询的速度要快得多，而且（更重要的是）可以有效地支持新功能。在进行迁移之前，我必须要解决的一个问题是大部分代码没有被测试所覆盖，而这是由于大部分的代码都不可测试。我使用依赖注入以及我此前讨论过的其他技巧重构了代码，而这使我能够构建一组回归测试套件。我记得这个项目被驳回主要是被评价为测试单元的编写是“新毕业生水平的工作”。我的经理真的很支持我，并且写了上诉。他没有给我看，但是我认为这是可以被缩减成 “WTF” 的若干页（更雄辩而详尽地论述）。以下是一些我也认为这一回复有点 “WTF” 的原因：Google SRE 有一种“关键人物”的概念。一个项目的关键人物有两个角色：一个是比起其他 SRE 对于软件项目有着更为深入的了解，以便你能够回答他们可能会提出的问题；另一个角色是作为项目本身的开发人员的第一联络人，以便他们的所有 SRE 问题都能得到回答。 Google 的职业阶梯指南说，关键人物不应该处于“新毕业生水准”，而应该晋升。正如我在申请中所写的，我是三个项目的关键人物。我的关键人物经历使得想要找到同意支持我的晋升申请的资深开发人员很容易。当他们发现我是新毕业生级别时都十分震惊。他们都同意支持我的申请，认可我已经处在了一个更高的级别。在我的申请之中，我提到曾担任过一组新毕业实习生的导师。当我提出申请时，他们之中的许多人都已经被聘用为了正式雇员。我足够资深，可以去担任他们的导师，但是还绝不足以晋升到比他们更高的级别。给我经理上诉的回复与最初的审查截然不同。这次，我“大大超出了对于我‘新毕业生’级别工作的期望”，但是问题在于他们需要稍多一些时间来确保我能够晋升到新毕业生加一的级别。我被告知在接下来的 6 个月时间里，倘若我能够继续超出预期，直到下一个晋升周期，也许那时我就会得到晋升。上诉结束了；这就是最终结果。我写了一封电子邮件，表示我要采取另一种选择。就像许多科技公司一样， Google 也有员工持股计划。在开始工作时，你会得到一笔象征性的补助金，而在各个“投资”里程碑时刻，你会收到真正的股份。我的下一次股票授予是在几个月之后。从那以后，我将不再为 Google 工作。我离开的原因任何辞职的决定并不容易，而某天你或许会面临同样的抉择。以下是一些有助于我作出决定的因素。（我在以前的一篇贴子里对一些这类想法进行了更深入的解释。）如果你思考一下，考虑到我并不是字面意义上真正的应届毕业生， Google 的评价应该是这样的：“你正在做一些非常错误的事情。在 X、 Y 还有 Z 方面有所改进之前，你根本不会得到晋升。”被告知“你远远超出了预期，但是我们还需要 6 个月左右的时间”，这是毫无道理的。没有人关注我是否有能力做好我的工作。我得到了许多借口，但是没有能够帮助我提高的任何有用反馈。（注意：有时候你必须要明确地要求反馈。经理们可能会陷入捍卫自己所给出的绩效评级的陷阱，而不会去考虑报告是否需要反馈。）我也不确定晋升委员会会在 6 个月里看到什么他们在已经过去的 2 年时间里都没有看到的问题。他们难保不会再要求 6 个月时间？如果我需要花上多年时间来证明自己以获得新毕业生加一的级别晋升，那么我升到新毕业生加二的时候得有多老呢？刚加入 Google 时，我的工作级别无关紧要，因为我当时学到了那么多东西，并且能在我的简历里写入一家著名的公司。两年过后，等式变得不同了。 Google 所提供给我的未来所具有的价值正在下降，而 Google 之外机会的价值却正在上升。 Google 的职位实际上在 Google 之外几乎毫无意义。在过去的 5 年间，许多人都问过我在 Google 做过什么，但是没有一个人问我在 Google 是什么职位，也没人称我为“新毕业生”。尽管我在短期内受到了财务方面的打击，但实际上在我上交员工证的那天我就已经得到了晋升。值得称赞的是，Google 没有做过任何类似于以下的事情，但是在其他公司中却很常见：试图让员工对于要求加薪感到内疚。在几年前我工作过的地方，一些工程师在一次成功发布会后，在许多紧要关头要求加薪。管理层扮演起了受害者的角色，并且指责工程师们是在“强迫他们”。（大约 6 个月时间后，他们失去了自己大部分的工程团队。）如果你真的愿意就辞职时间进行配合（例如，在发布日期之后，而不是前一周），并且愿意记录下你的知识并做了整理等等，那么你仅仅是由于雇主支付给你的工资不足而“强迫他们”。名义上，我在 Google 留下了大量未授予的股票。但是知道你拥有股票时，股票才属于你。我只是得到了未来会有分红的承诺，而我可以将其除以所需的时间来将其转换为同等的工资率。为这项投资工作 2 个月是值得的，为了剩余的投资工作数年是不值得的。不要被授予股票的偏见所迷惑。什么时候不应该辞职呢？嗯，与在其他地方相比，你能得到的很多吗？公司的职业发展道路不是天上掉下来的，他们是一系列的业务报价，代表着你将为什么样的公司评估而工作。如果你认为自己能得到很多（考虑到所有的薪酬和像是工作环境之类的无形资产），很好！否则，是时候认真考虑一下下一步该做什么了。离开 Google 之后我应当警告你的是，我采取了高增长的战略，但是牺牲了短期稳定性。如果对你而言稳定性更为重要，你应该做出不一样的选择。我的 A 计划、 B 计划、 C 计划都失败了，我最终花费了几个月时间苦苦找寻出路。最后，我在一家小型网店得到了一份合同，为 Safety Town 工作，一家政府建立的面向孩子们的道路安全网站。那里的薪水较之于 Google 是一个巨大的缩减，尤其是考虑到这是我几个月以来的第一份工作。但是，你知道，我真的很享受这个项目。当然了，它不像 Google 那么“酷”，而且可能一些学校里的孩子也不觉得它酷。另一方面，在 Google，我只是机器中的一个螺丝钉。 Safety Town 有一个小团队，每个人都扮演着至关重要的角色。在 Safety Town 项目中，我是后端工程师， Safety Town 当时是我唯一需要费心的事情。而且可能一些孩子已经在这个网站上学到了一两件有关道路安全的事情。从那以后，我做了很多项目，大多数都更大，但是我仍然会向人们展示 Safety Town。我记得 Google 悉尼办事处的一张海报，上面写着：“飞向月球吧！即使你错过了，你也会降落在群星之中！”人们很容易忘记，即使你不是在为知名公司或初创公司做“登月计划”，你也可以拥有高质量的生活。这儿有一个帮助我获得合同的窍门。我会去参加悉尼的科技活动，站在能看到求职公告板的范围之中，等着看见有人在上面写东西。假设他们正在为一个保险公司项目写 CSS 开发方面的信息。即使我对 CSS 或保险不是特别感兴趣，我也会晃悠过去说：“嗨，这是个什么类型的保险项目？”这是最容易的开启谈话的方式，因为在他们努力往求职公告板上的狭小缝隙中写字的时候，满脑子都是这个项目。通常情况下，这样的谈话仍然不会为我带来一份工作，但是偶尔也会发现一些我能够帮上忙的东西。有些活动没有求职公告板，但是组织者们往往很乐意把麦克风递给别人几分钟。这为他们的活动增添了社区参与度。我在做了一个政府采购的网站后，我取得了重大的突破，因为我学会了不至于对政府采购一窍不通。很难确切说出这些知识的价值，但是不到一年过后，我就签署了一份政府合同，比我此前所期望的要多了 40%。（不过，我如今没有做那么多的政府和大型企业的工作了。）大约一年半过后，我有了自己的一人公司。随着我声誉的建立，我逐渐获得了更多类似于 SRE 的工作。基本上，从事开发工作是我的“工作”，然后几个月后就有一个需要 SRE/DevOps 帮助的人联系了我。我事实上既喜欢 SRE，也喜欢纯开发工作，但是供求关系意味着 SRE 工作是个好工作。我仍然可以在空余时间编程。说起这个，工作与生活的平衡是我在新生活中最喜欢的事情。没有人在两份合同之间给我酬劳，但是我可以通过在业余项目中学习新东西来充分利用这一间隙。在一个漫长而又紧张的合同之后，我休息了一下，进行了为期一个月的背包徒步旅行，探索了日本乡村。这是我期待了很长时间的一次旅行，但是在入职 Google 之前我需要更多的钱，而在 Google 供职期间我又需要更多的时间。自营职业远非没有压力，也不是适合每一个人的，但是有的压力会让你感到死气沉沉，有的压力则会让你越发充满活力。于我而言，自主营生是第二种，我想说，和在 Google 时相比，过去的 5 年间我的压力总体上有所减轻。对于我来说，至少我能够诚实地说我不后悔当初加入 Google，也不后悔当初离开 Google。via: https://theartofmachinery.com/2020/08/04/leaving_google.html作者：Simon Arneaud 选题：lujun9972 译者：JonnieWayy 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
基本上所有正而八经的算法教材都会解释像 快速排序 quicksort 和 堆排序 heapsort 这样的排序算法有多快，但并不需要复杂的数学就能证明你可以逐渐趋近的速度有多快。关于标记的一个严肃说明：大多数计算机专业的科学家使用大写字母 O 标记来指代“趋近，直到到达一个常数比例因子”，这与数学专业所指代的意义是有所区别的。这里我使用的大 O 标记的含义与计算机教材所指相同，但至少不会和其他数学符号混用。基于比较的排序先来看个特例，即每次比较两个值大小的算法（快速排序、堆排序，及其它通用排序算法）。这种思想后续可以扩展至所有排序算法。一个简单的最差情况下的计数角度假设有 4 个互不相等的数，且顺序随机，那么，可以通过只比较一对数字完成排序吗？显然不能，证明如下：根据定义，要对该数组排序，需要按照某种顺序重新排列数字。换句话说，你需要知道用哪种排列方式？有多少种可能的排列？第一个数字可以放在四个位置中的任意一个，第二个数字可以放在剩下三个位置中的任意一个，第三个数字可以放在剩下两个位置中的任意一个，最后一个数字只有剩下的一个位置可选。这样，共有 4×3×2×1=4!=24 种排列可供选择。通过一次比较大小，只能产生两种可能的结果。如果列出所有的排列，那么“从小到大”排序对应的可能是第 8 种排列，按“从大到小”排序对应的可能是第 24 种排列，但无法知道什么时候需要的是其它 22 种排列。通过 2 次比较，可以得到 2×2=4 种可能的结果，这仍然不够。只要比较的次数少于 5（对应 2 5 = 32 种输出），就无法完成 4 个随机次序的数字的排序。如果 W(N) 是最差情况下对 N 个不同元素进行排序所需要的比较次数，那么，两边取以 2 为底的对数，得：N! 的增长近似于 N N （参阅 Stirling 公式），那么，这就是最差情况下从输出计数的角度得出的 O(N log N) 上限。从信息论角度的平均状态的例子使用一些信息论知识，就可以从上面的讨论中得到一个更有力的结论。下面，使用排序算法作为信息传输的编码器：任取一个数，比如 15从 4 个数字的排列列表中查找第 15 种排列对这种排列运行排序算法，记录所有的“大”、“小”比较结果用二进制编码发送比较结果接收端重新逐步执行发送端的排序算法，需要的话可以引用发送端的比较结果现在接收端就可以知道发送端如何重新排列数字以按照需要排序，接收端可以对排列进行逆算，得到 4 个数字的初始顺序接收端在排列表中检索发送端的原始排列，指出发送端发送的是 15确实，这有点奇怪，但确实可以。这意味着排序算法遵循着与编码方案相同的定律，包括理论所证明的不存在通用的数据压缩算法。算法中每次比较发送 1 比特的比较结果编码数据，根据信息论，比较的次数至少是能表示所有数据的二进制位数。更技术语言点，平均所需的最小比较次数是输入数据的香农熵，以比特为单位。熵是衡量信息等不可预测量的数学度量。包含 N 个元素的数组，元素次序随机且无偏时的熵最大，其值为 log 2​ N! 个比特。这证明 O(N log N) 是一个基于比较的对任意输入排序的最优平均值。以上都是理论说法，那么实际的排序算法如何做比较的呢？下面是一个数组排序所需比较次数均值的图。我比较的是理论值与快速排序及 Ford-Johnson 合并插入排序 的表现。后者设计目的就是最小化比较次数（整体上没比快速排序快多少，因为生活中相对于最大限度减少比较次数，还有更重要的事情）。又因为 合并插入排序 merge-insertion sort 是在 1959 年提出的，它一直在调整，以减少了一些比较次数，但图示说明，它基本上达到了最优状态。一点点理论导出这么实用的结论，这感觉真棒！小结证明了：如果数组可以是任意顺序，在最坏情况下至少需要 O(N log N) 次比较。数组的平均比较次数最少是数组的熵，对随机输入而言，其值是 O(N log N) 。注意，第 2 个结论允许基于比较的算法优于 O(N log N)，前提是输入是低熵的（换言之，是部分可预测的）。如果输入包含很多有序的子序列，那么合并排序的性能接近 O(N)。如果在确定一个位之前，其输入是有序的，插入排序性能接近 O(N)。在最差情况下，以上算法的性能表现都不超出 O(N log N)。一般排序算法基于比较的排序在实践中是个有趣的特例，但从理论上讲，计算机的 CMP 指令与其它指令相比，并没有什么特别之处。在下面两条的基础上，前面两种情形都可以扩展至任意排序算法：大多数计算机指令有多于两个的输出，但输出的数量仍然是有限的。一条指令有限的输出意味着一条指令只能处理有限的熵。这给出了 O(N log N) 对应的指令下限。任何物理上可实现的计算机都只能在给定时间内执行有限数量的指令，所以算法的执行时间也有对应 O(N log N) 的下限。什么是更快的算法？一般意义上的 O(N log N) 下限，放在实践中来看，如果听人说到任何更快的算法，你要知道，它肯定以某种方式“作弊”了，其中肯定有圈套，即它不是一个可以处理任意大数组的通用排序算法。可能它是一个有用的算法，但最好看明白它字里行间隐含的东西。一个广为人知的例子是 基数排序 radix sort 算法，它经常被称为 O(N) 排序算法，但它只能处理所有数字都能放入 k 比特的情况，所以实际上它的性能是 O(kN)。什么意思呢？假如你用的 8 位计算机，那么 8 个二进制位可以表示 2 8=256 个不同的数字，如果数组有上千个数字，那么其中必有重复。对有些应用而言这是可以的，但对有些应用就必须用 16 个二进制位来表示，16 个二进制位可以表示 2 16=65,536 个不同的数字。32 个二进制位可以表示 2 32=4,294,967,296 不同的数字。随着数组长度的增长，所需要的二进制位数也在增长。要表示 N 个不同的数字，需要 k ≥ log 2​ N 个二进制位。所以，只有允许数组中存在重复的数字时， O(kN) 才优于 O(N log N)。一般意义上输入数据的 O(N log N) 的性能已经说明了全部问题。这个讨论不那么有趣因为很少需要在 32 位计算机上对几十亿整数进行排序，如果有谁的需求超出了 64 位计算机的极限，他一定没有告诉别人。via: https://theartofmachinery.com/2019/01/05/sorting_is_nlogn.html作者：Simon Arneaud 选题：lujun9972 译者：silentdawn-zz 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
我的大部分工作都涉及到部署软件系统，这意味着我需要花费很多时间来解决以下问题：这个软件可以在原开发者的机器上工作，但是为什么不能在我这里运行？这个软件昨天可以在我的机器上工作，但是为什么今天就不行？这是一种调试的类型，但是与一般的软件调试有所不同。一般的调试通常只关心代码的逻辑，但是在软件部署中的调试关注的是程序的代码和它所在的运行环境之间的相互影响。即便问题的根源是代码的逻辑错误，但软件显然可以在别的机器上运行的事实意味着这类问题与运行环境密切相关。所以，在软件部署过程中，我没有使用传统的调试工具（例如 gdb），而是选择了其它工具进行调试。我最喜欢的用来解决“为什么这个软件无法在这台机器上运行？”这类问题的工具就是 strace。什么是 strace？strace 是一个用来“追踪系统调用”的工具。它主要是一个 Linux 工具，但是你也可以在其它系统上使用类似的工具（例如 DTrace 和 ktrace）。它的基本用法非常简单。只需要在 strace 后面跟上你需要运行的命令，它就会显示出该命令触发的所有系统调用（你可能需要先安装好 strace）：$ strace echo Hello ...Snip lots of stuff... write(1, "Hello\n", 6) = 6 close(1) = 0 close(2) = 0 exit_group(0) = ? +++ exited with 0 +++这些系统调用都是什么？它们就像是操作系统内核提供的 API。很久以前，软件拥有直接访问硬件的权限。如果软件需要在屏幕上显示一些东西，它将会与视频硬件的端口和内存映射寄存器纠缠不清。当多任务操作系统变得流行以后，这就导致了混乱的局面，因为不同的应用程序将“争夺”硬件，并且一个应用程序的错误可能致使其它应用程序崩溃，甚至导致整个系统崩溃。所以 CPU 开始支持多种不同的特权模式（或者称为“保护环”）。它们让操作系统内核在具有完全硬件访问权限的最高特权模式下运行，于此同时，其它在低特权模式下运行的应用程序必须通过向内核发起系统调用才能够与硬件进行交互。在二进制级别上，发起系统调用相比简单的函数调用有一些区别，但是大部分程序都使用标准库提供的封装函数。例如，POSIX C 标准库包含一个 write() 函数，该函数包含用于进行 write 系统调用的所有与硬件体系结构相关的代码。简单来说，一个应用程序与其环境（计算机系统）的交互都是通过系统调用来完成的。所以当软件在一台机器上可以工作但是在另一台机器无法工作的时候，追踪系统调用是一个很好的查错方法。具体地说，你可以通过追踪系统调用分析以下典型操作：控制台输入与输出 (IO)网络 IO文件系统访问以及文件 IO进程/线程生命周期管理原始内存管理访问特定的设备驱动什么时候可以使用 strace？理论上，strace 适用于任何用户空间程序，因为所有的用户空间程序都需要进行系统调用。strace 对于已编译的低级程序最有效果，但如果你可以避免运行时环境和解释器带来的大量额外输出，则仍然可以与 Python 等高级语言程序一起使用。当软件在一台机器上正常工作，但在另一台机器上却不能正常工作，同时抛出了有关文件、权限或者不能运行某某命令等模糊的错误信息时，strace 往往能大显身手。不幸的是，它不能诊断高等级的问题，例如数字证书验证错误等。这些问题通常需要组合使用 strace（有时候是 ltrace）和其它高级工具（例如使用 openssl 命令行工具调试数字证书错误）。本文中的示例基于独立的服务器，但是对系统调用的追踪通常也可以在更复杂的部署平台上完成，仅需要找到合适的工具。一个简单的例子假设你正在尝试运行一个叫做 foo 的服务器应用程序，但是发生了以下情况：$ foo Error opening configuration file: No such file or directory显然，它没有找到你已经写好的配置文件。之所以会发生这种情况，是因为包管理工具有时候在编译应用程序时指定了自定义的路径，所以你应当遵循特定发行版提供的安装指南。如果错误信息告诉你正确的配置文件应该在什么地方，你就可以在几秒钟内解决这个问题，但如果没有告诉你呢？你该如何找到正确的路径？如果你有权访问源代码，则可以通过阅读源代码来解决问题。这是一个好的备用计划，但不是最快的解决方案。你还可以使用类似 gdb 的单步调试器来观察程序的行为，但使用专门用于展示程序与系统环境交互作用的工具 strace 更加有效。一开始， strace 产生的大量输出可能会让你不知所措，幸好你可以忽略其中大部分的无用信息。我经常使用 -o 参数把输出的追踪结果保存到单独的文件里：$ strace -o /tmp/trace foo Error opening configuration file: No such file or directory $ cat /tmp/trace execve("foo", ["foo"], 0x7ffce98dc010 /* 16 vars */) = 0 brk(NULL) = 0x56363b3fb000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=25186, ...}) = 0 mmap(NULL, 25186, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f2f12cf1000 close(3) = 0 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\260A\2\0\0\0\0\0"..., 832) = 832 fstat(3, {st_mode=S_IFREG|0755, st_size=1824496, ...}) = 0 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f2f12cef000 mmap(NULL, 1837056, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f2f12b2e000 mprotect(0x7f2f12b50000, 1658880, PROT_NONE) = 0 mmap(0x7f2f12b50000, 1343488, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x22000) = 0x7f2f12b50000 mmap(0x7f2f12c98000, 311296, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x16a000) = 0x7f2f12c98000 mmap(0x7f2f12ce5000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1b6000) = 0x7f2f12ce5000 mmap(0x7f2f12ceb000, 14336, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f2f12ceb000 close(3) = 0 arch_prctl(ARCH_SET_FS, 0x7f2f12cf0500) = 0 mprotect(0x7f2f12ce5000, 16384, PROT_READ) = 0 mprotect(0x56363b08b000, 4096, PROT_READ) = 0 mprotect(0x7f2f12d1f000, 4096, PROT_READ) = 0 munmap(0x7f2f12cf1000, 25186) = 0 openat(AT_FDCWD, "/etc/foo/config.json", O_RDONLY) = -1 ENOENT (No such file or directory) dup(2) = 3 fcntl(3, F_GETFL) = 0x2 (flags O_RDWR) brk(NULL) = 0x56363b3fb000 brk(0x56363b41c000) = 0x56363b41c000 fstat(3, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0x8), ...}) = 0 write(3, "Error opening configuration file"..., 60) = 60 close(3) = 0 exit_group(1) = ? +++ exited with 1 +++strace 输出的第一页通常是低级的进程启动过程。（你可以看到很多 mmap、mprotect、brk 调用，这是用来分配原始内存和映射动态链接库的。）实际上，在查找错误时，最好从下往上阅读 strace 的输出。你可以看到 write 调用在最后返回了错误信息。如果你向上找，你将会看到第一个失败的系统调用是 openat，它在尝试打开 /etc/foo/config.json 时抛出了 ENOENT (“No such file or directory”)的错误。现在我们已经知道了配置文件应该放在哪里。这是一个简单的例子，但我敢说在 90% 的情况下，使用 strace 进行调试不需要更多复杂的工作。以下是完整的调试步骤：从程序中获得含糊不清的错误信息使用 strace 运行程序在输出中找到错误信息往前追溯并找到第一个失败的系统调用第四步中的系统调用很可能向你显示出问题所在。小技巧在开始更加复杂的调试之前，这里有一些有用的调试技巧帮助你高效使用 strace：man 是你的朋友在很多 *nix 操作系统中，你可以通过 man syscalls 查看系统调用的列表。你将会看到类似于 brk(2) 之类的东西，这意味着你可以通过运行 man 2 brk 得到与此相关的更多信息。一个小问题：man 2 fork 会显示出在 GNU libc 里封装的 fork() 手册页，而 fork() 现在实际上是由 clone 系统调用实现的。fork 的语义与 clone 相同，但是如果我写了一个含有 fork() 的程序并使用 strace 去调试它，我将找不到任何关于 fork 调用的信息，只能看到 clone 调用。如果将源代码与 strace 的输出进行比较的时候，像这种问题会让人感到困惑。使用 -o 将输出保存到文件strace 可以生成很多输出，所以将输出保存到单独的文件是很有帮助的（就像上面的例子一样）。它还能够在控制台中避免程序自身的输出与 strace 的输出发生混淆。使用 -s 查看更多的参数你可能已经注意到，错误信息的第二部分没有出现在上面的例子中。这是因为 strace 默认仅显示字符串参数的前 32 个字节。如果你需要捕获更多参数，请向 strace 追加类似于 -s 128 之类的参数。-y 使得追踪文件或套接字更加容易“一切皆文件”意味着 *nix 系统通过文件描述符进行所有 IO 操作，不管是真实的文件还是通过网络或者进程间管道。这对于编程而言是很方便的，但是在追踪系统调用时，你将很难分辨出 read 和 write 的真实行为。-y 参数使 strace 在注释中注明每个文件描述符的具体指向。使用 -p 附加到正在运行的进程中正如我们将在后面的例子中看到的，有时候你想追踪一个正在运行的程序。如果你知道这个程序的进程号为 1337 （可以通过 ps 查询），则可以这样操作：$ strace -p 1337 ...system call trace output...你可能需要 root 权限才能运行。使用 -f 追踪子进程strace 默认只追踪一个进程。如果这个进程产生了一个子进程，你将会看到创建子进程的系统调用（一般是 clone），但是你看不到子进程内触发的任何调用。如果你认为在子进程中存在错误，则需要使用 -f 参数启用子进程追踪功能。这样做的缺点是输出的内容会让人更加困惑。当追踪一个进程时，strace 显示的是单个调用事件流。当追踪多个进程的时候，你将会看到以 <unfinished ...> 开始的初始调用，接着是一系列针对其它线程的调用，最后才出现以 <... foocall resumed> 结束的初始调用。此外，你可以使用 -ff 参数将所有的调用分离到不同的文件中（查看 strace 手册 获取更多信息）。使用 -e 进行过滤正如你所看到的，默认的追踪输出是所有的系统调用。你可以使用 -e 参数过滤你需要追踪的调用（查看 strace 手册）。这样做的好处是运行过滤后的 strace 比起使用 grep 进行二次过滤要更快。老实说，我大部分时间都不会被打扰。并非所有的错误都是不好的一个简单而常用的例子是一个程序在多个位置搜索文件，例如 shell 搜索哪个 bin/ 目录包含可执行文件：$ strace sh -c uname ... stat("/home/user/bin/uname", 0x7ffceb817820) = -1 ENOENT (No such file or directory) stat("/usr/local/bin/uname", 0x7ffceb817820) = -1 ENOENT (No such file or directory) stat("/usr/bin/uname", {st_mode=S_IFREG|0755, st_size=39584, ...}) = 0 ...“错误信息之前的最后一次失败调用”这种启发式方法非常适合于查找错误。无论如何，自下而上地查找是有道理的。C 编程指南非常有助于理解系统调用标准 C 库函数调用不属于系统调用，但它们仅是系统调用之上的唯一一个薄层。所以如果你了解（甚至只是略知一二）如何使用 C 语言，那么阅读系统调用追踪信息就非常容易。例如，如果你在调试网络系统调用，你可以尝试略读 Beej 经典的《网络编程指南》。一个更复杂的调试例子就像我说的那样，简单的调试例子表现了我在大部分情况下如何使用 strace。然而，有时候需要一些更加细致的工作，所以这里有一个稍微复杂（且真实）的例子。bcron 是一个任务调度器，它是经典 *nix cron 守护程序的另一种实现。它已经被安装到一台服务器上，但是当有人尝试编辑作业时间表时，发生了以下情况：# crontab -e -u logs bcrontab: Fatal: Could not create temporary file好的，现在 bcron 尝试写入一些文件，但是它失败了，也没有告诉我们原因。以下是 strace 的输出：# strace -o /tmp/trace crontab -e -u logs bcrontab: Fatal: Could not create temporary file # cat /tmp/trace ... openat(AT_FDCWD, "bcrontab.14779.1573691864.847933", O_RDONLY) = 3 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f82049b4000 read(3, "#Ansible: logsagg\n20 14 * * * lo"..., 8192) = 150 read(3, "", 8192) = 0 munmap(0x7f82049b4000, 8192) = 0 close(3) = 0 socket(AF_UNIX, SOCK_STREAM, 0) = 3 connect(3, {sa_family=AF_UNIX, sun_path="/var/run/bcron-spool"}, 110) = 0 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f82049b4000 write(3, "156:Slogs\0#Ansible: logsagg\n20 1"..., 161) = 161 read(3, "32:ZCould not create temporary f"..., 8192) = 36 munmap(0x7f82049b4000, 8192) = 0 close(3) = 0 write(2, "bcrontab: Fatal: Could not creat"..., 49) = 49 unlink("bcrontab.14779.1573691864.847933") = 0 exit_group(111) = ? +++ exited with 111 +++在程序结束之前有一个 write 的错误信息，但是这次有些不同。首先，在此之前没有任何相关的失败系统调用。其次，我们看到这个错误信息是由 read 从别的地方读取而来的。这看起来像是真正的错误发生在别的地方，而 bcrontab 只是在转播这些信息。如果你查阅了 man 2 read，你将会看到 read 的第一个参数 (3) 是一个文件描述符，这是 *nix 操作系统用于所有 IO 操作的句柄。你该如何知道文件描述符 3 代表什么？在这种情况下，你可以使用 -y 参数运行 strace（如上文所述），它将会在注释里告诉你文件描述符的具体指向，但是了解如何从上面这种输出中分析追踪结果是很有用的。一个文件描述符可以来自于许多系统调用之一（这取决于它是用于控制台、网络套接字还是真实文件等的描述符），但不论如何，我们都可以搜索返回值为 3 的系统调用（例如，在 strace 的输出中查找 =3）。在这次 strace 中可以看到有两个这样的调用：最上面的 openat 以及中间的 socket。openat 打开一个文件，但是紧接着的 close(3) 表明其已经被关闭。（注意：文件描述符可以在打开并关闭后重复使用。）所以 socket 调用才是与此相关的（它是在 read 之前的最后一个），这告诉我们 brcontab 正在与一个网络套接字通信。在下一行，connect 表明文件描述符 3 是一个连接到 /var/run/bcron-spool 的 Unix 域套接字。因此，我们需要弄清楚 Unix 套接字的另一侧是哪个进程在监听。有两个巧妙的技巧适用于在服务器部署中调试。一个是使用 netstat 或者较新的 ss。这两个命令都描述了当前系统中活跃的网络套接字，使用 -l 参数可以显示出处于监听状态的套接字，而使用 -p 参数可以得到正在使用该套接字的程序信息。（它们还有更多有用的选项，但是这两个已经足够完成工作了。）# ss -pl | grep /var/run/bcron-spool u_str LISTEN 0 128 /var/run/bcron-spool 1466637 * 0 users:(("unixserver",pid=20629,fd=3))这告诉我们 /var/run/bcron-spool 套接字的监听程序是 unixserver 这个命令，它的进程 ID 为 20629。（巧合的是，这个程序也使用文件描述符 3 去连接这个套接字。）第二个常用的工具就是使用 lsof 查找相同的信息。它可以列出当前系统中打开的所有文件（或文件描述符）。或者，我们可以得到一个具体文件的信息：# lsof /var/run/bcron-spool COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME unixserve 20629 cron 3u unix 0x000000005ac4bd83 0t0 1466637 /var/run/bcron-spool type=STREAM进程 20629 是一个常驻进程，所以我们可以使用 strace -o /tmp/trace -p 20629 去查看该进程的系统调用。如果我们在另一个终端尝试编辑 cron 的计划任务表，就可以在错误发生时捕获到以下信息：accept(3, NULL, NULL) = 4 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7faa47c44810) = 21181 close(4) = 0 accept(3, NULL, NULL) = ? ERESTARTSYS (To be restarted if SA_RESTART is set) --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=21181, si_uid=998, si_status=0, si_utime=0, si_stime=0} --- wait4(0, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WNOHANG|WSTOPPED, NULL) = 21181 wait4(0, 0x7ffe6bc36764, WNOHANG|WSTOPPED, NULL) = -1 ECHILD (No child processes) rt_sigaction(SIGCHLD, {sa_handler=0x55d244bdb690, sa_mask=[CHLD], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7faa47ab9840}, {sa_handler=0x55d244bdb690, sa_mask=[CHLD], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7faa47ab9840}, 8) = 0 rt_sigreturn({mask=[]}) = 43 accept(3, NULL, NULL) = 4 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7faa47c44810) = 21200 close(4) = 0 accept(3, NULL, NULL) = ? ERESTARTSYS (To be restarted if SA_RESTART is set) --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=21200, si_uid=998, si_status=111, si_utime=0, si_stime=0} --- wait4(0, [{WIFEXITED(s) && WEXITSTATUS(s) == 111}], WNOHANG|WSTOPPED, NULL) = 21200 wait4(0, 0x7ffe6bc36764, WNOHANG|WSTOPPED, NULL) = -1 ECHILD (No child processes) rt_sigaction(SIGCHLD, {sa_handler=0x55d244bdb690, sa_mask=[CHLD], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7faa47ab9840}, {sa_handler=0x55d244bdb690, sa_mask=[CHLD], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7faa47ab9840}, 8) = 0 rt_sigreturn({mask=[]}) = 43 accept(3, NULL, NULL（最后一个 accept 调用没有在追踪期间完成。）不幸的是，这次追踪没有包含我们想要的错误信息。我们没有观察到 bcrontan 往套接字发送或接受的任何信息。然而，我们看到了很多进程管理操作（clone、wait4、SIGCHLD，等等）。这个进程产生了子进程，我们猜测真实的工作是由子进程完成的。如果我们想捕获子进程的追踪信息，就必须往 strace 追加 -f 参数。以下是我们最终使用 strace -f -o /tmp/trace -p 20629 找到的错误信息：21470 openat(AT_FDCWD, "tmp/spool.21470.1573692319.854640", O_RDWR|O_CREAT|O_EXCL, 0600) = -1 EACCES (Permission denied) 21470 write(1, "32:ZCould not create temporary f"..., 36) = 36 21470 write(2, "bcron-spool[21470]: Fatal: logs:"..., 84) = 84 21470 unlink("tmp/spool.21470.1573692319.854640") = -1 ENOENT (No such file or directory) 21470 exit_group(111) = ? 21470 +++ exited with 111 +++现在我们知道了进程 ID 21470 在尝试创建文件 tmp/spool.21470.1573692319.854640 （相对于当前的工作目录）时得到了一个没有权限的错误。如果我们知道当前的工作目录，就可以得到完整路径并能指出为什么该进程无法在此处创建临时文件。不幸的是，这个进程已经退出了，所以我们不能使用 lsof -p 21470 去找出当前的工作目录，但是我们可以往前追溯，查找进程 ID 21470 使用哪个系统调用改变了它的工作目录。这个系统调用是 chdir（可以在搜索引擎很轻松地找到）。以下是一直往前追溯到服务器进程 ID 20629 的结果：20629 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7faa47c44810) = 21470 ... 21470 execve("/usr/sbin/bcron-spool", ["bcron-spool"], 0x55d2460807e0 /* 27 vars */) = 0 ... 21470 chdir("/var/spool/cron") = 0 ... 21470 openat(AT_FDCWD, "tmp/spool.21470.1573692319.854640", O_RDWR|O_CREAT|O_EXCL, 0600) = -1 EACCES (Permission denied) 21470 write(1, "32:ZCould not create temporary f"..., 36) = 36 21470 write(2, "bcron-spool[21470]: Fatal: logs:"..., 84) = 84 21470 unlink("tmp/spool.21470.1573692319.854640") = -1 ENOENT (No such file or directory) 21470 exit_group(111) = ? 21470 +++ exited with 111 +++（如果你在这里迷糊了，你可能需要阅读 我之前有关 *nix 进程管理和 shell 的文章）现在 PID 为 20629 的服务器进程没有权限在 /var/spool/cron/tmp/spool.21470.1573692319.854640 创建文件。最可能的原因就是典型的 *nix 文件系统权限设置。让我们检查一下：# ls -ld /var/spool/cron/tmp/ drwxr-xr-x 2 root root 4096 Nov 6 05:33 /var/spool/cron/tmp/ # ps u -p 20629 USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND cron 20629 0.0 0.0 2276 752 ? Ss Nov14 0:00 unixserver -U /var/run/bcron-spool -- bcron-spool这就是问题所在！这个服务进程以 cron 用户运行，但是只有 root 用户才有向 /var/spool/cron/tmp/ 目录写入的权限。一个简单 chown cron /var/spool/cron/tmp/ 命令就能让 bcron 正常工作。（如果不是这个问题，那么下一个最有可能的怀疑对象是诸如 SELinux 或者 AppArmor 之类的内核安全模块，因此我将会使用 dmesg 检查内核日志。）总结最初，系统调用追踪可能会让人不知所措，但是我希望我已经证明它们是调试一整套常见部署问题的快速方法。你可以设想一下尝试用单步调试器去调试多进程的 bcron 问题。通过一连串的系统调用解决问题是需要练习的，但正如我说的那样，在大多数情况下，我只需要使用 strace 从下往上追踪并查找错误。不管怎样，strace 节省了我很多的调试时间。我希望这也对你有所帮助。via: https://theartofmachinery.com/2019/11/14/deployment_debugging_strace.html作者：Simon Arneaud 选题：lujun9972 译者：hanwckf 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
早在 2015 年，Brian Will 撰写了一篇有挑衅性的博客：面向对象编程：一个灾难故事。他随后发布了一个名为面向对象编程很糟糕的视频，该视频更加详细。我建议你花些时间观看视频，下面是我的一段总结：OOP 的柏拉图式理想是一堆相互解耦的对象，它们彼此之间发送无状态消息。没有人真的像这样制作软件，Brian 指出这甚至没有意义：对象需要知道向哪个对象发送消息，这意味着它们需要相互引用。该视频大部分讲述的是这样一个痛点：人们试图将对象耦合以实现控制流，同时假装它们是通过设计解耦的。总的来说，他的想法与我自己的 OOP 经验产生了共鸣：对象没有问题，但是我一直不满意的是面向对象建模程序控制流，并且试图使代码“正确地”面向对象似乎总是在创建不必要的复杂性。有一件事我认为他无法完全解释。他直截了当地说“封装没有作用”，但在脚注后面加上“在细粒度的代码级别”，并继续承认对象有时可以奏效，并且在库和文件级别封装是可以的。但是他没有确切解释为什么有时会奏效，有时却没有奏效，以及如何和在何处划清界限。有人可能会说这使他的 “OOP 不好”的说法有缺陷，但是我认为他的观点是正确的，并且可以在根本状态和偶发状态之间划清界限。如果你以前从未听说过“ 根本 essential ”和“ 偶发 accidental ”这两个术语的使用，那么你应该阅读 Fred Brooks 的经典文章《没有银弹》。（顺便说一句，他写了许多很棒的有关构建软件系统的文章。）我以前曾写过关于根本和偶发的复杂性的文章，这里有一个简短的摘要：软件是复杂的。部分原因是因为我们希望软件能够解决混乱的现实世界问题，因此我们将其称为“根本复杂性”。“偶发复杂性”是所有其它的复杂性，因为我们正尝试使用硅和金属来解决与硅和金属无关的问题。例如，对于大多数程序而言，用于内存管理或在内存与磁盘之间传输数据或解析文本格式的代码都是“偶发的复杂性”。假设你正在构建一个支持多个频道的聊天应用。消息可以随时到达任何频道。有些频道特别有趣，当有新消息传入时，用户希望得到通知。而其他频道静音：消息被存储，但用户不会受到打扰。你需要跟踪每个频道的用户首选设置。一种实现方法是在频道和频道设置之间使用 映射 map （也称为哈希表、字典或关联数组）。注意，映射是 Brian Will 所说的可以用作对象的抽象数据类型（ADT）。如果我们有一个调试器并查看内存中的映射对象，我们将看到什么？我们当然会找到频道 ID 和频道设置数据（或至少指向它们的指针）。但是我们还会找到其它数据。如果该映射是使用红黑树实现的，我们将看到带有红/黑标签和指向其他节点的指针的树节点对象。与频道相关的数据是根本状态，而树节点是偶发状态。不过，请注意以下几点：该映射有效地封装了它的偶发状态 —— 你可以用 AVL 树实现的另一个映射替换该映射，并且你的聊天程序仍然可以使用。另一方面，映射没有封装根本状态（仅使用 get() 和 set() 方法访问数据并不是封装）。事实上，映射与根本状态是尽可能不可知的，你可以使用基本相同的映射数据结构来存储与频道或通知无关的其他映射。这就是映射 ADT 如此成功的原因：它封装了偶发状态，并与根本状态解耦。如果你思考一下，Brian 用封装描述的问题就是尝试封装根本状态。其他描述的好处是封装偶发状态的好处。要使整个软件系统都达到这一理想状况相当困难，但扩展开来，我认为它看起来像这样：没有全局的可变状态封装了偶发状态（在对象或模块或以其他任何形式）无状态偶发复杂性封装在单独函数中，与数据解耦使用诸如依赖注入之类的技巧使输入和输出变得明确组件可由易于识别的位置完全拥有和控制其中有些违反了我很久以来的直觉。例如，如果你有一个数据库查询函数，如果数据库连接处理隐藏在该函数内部，并且唯一的参数是查询参数，那么接口会看起来会更简单。但是，当你使用这样的函数构建软件系统时，协调数据库的使用实际上变得更加复杂。组件不仅以自己的方式做事，而且还试图将自己所做的事情隐藏为“实现细节”。数据库查询需要数据库连接这一事实从来都不是实现细节。如果无法隐藏某些内容，那么显露它是更合理的。我对将面向对象编程和函数式编程放在对立的两极非常警惕，但我认为从函数式编程进入面向对象编程的另一极端是很有趣的：OOP 试图封装事物，包括无法封装的根本复杂性，而纯函数式编程往往会使事情变得明确，包括一些偶发复杂性。在大多数时候，这没什么问题，但有时候（比如在纯函数式语言中构建自我指称的数据结构）设计更多的是为了函数编程，而不是为了简便（这就是为什么 Haskell 包含了一些“ 逃生出口 escape hatches ”）。我之前写过一篇所谓“ 弱纯性 weak purity ”的中间立场。Brian 发现封装对更大规模有效，原因有几个。一个是，由于大小的原因，较大的组件更可能包含偶发状态。另一个是“偶发”与你要解决的问题有关。从聊天程序用户的角度来看，“偶发的复杂性”是与消息、频道和用户等无关的任何事物。但是，当你将问题分解为子问题时，更多的事情就变得“根本”。例如，在解决“构建聊天应用”问题时，可以说频道名称和频道 ID 之间的映射是偶发的复杂性，而在解决“实现 getChannelIdByName() 函数”子问题时，这是根本复杂性。因此，封装对于子组件的作用比对父组件的作用要小。顺便说一句，在视频的结尾，Brian Will 想知道是否有任何语言支持无法访问它们所作用的范围的匿名函数。D 语言可以。 D 中的匿名 Lambda 通常是闭包，但是如果你想要的话，也可以声明匿名无状态函数：import std.stdio; void main() { int x = 41; // Value from immediately executed lambda auto v1 = () { return x + 1; }(); writeln(v1); // Same thing auto v2 = delegate() { return x + 1; }(); writeln(v2); // Plain functions aren't closures auto v3 = function() { // Can't access x // Can't access any mutable global state either if also marked pure return 42; }(); writeln(v3); }via: https://theartofmachinery.com/2019/10/13/oop_and_essential_state.html作者：Simon Arneaud 选题：lujun9972 译者：geekpi 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
在几个月前的一篇文章里，我曾说过“有个一个流行的传言，const 有助于编译器优化 C 和 C++ 代码”。我觉得我需要解释一下，尤其是曾经我自己也以为这是显然对的。我将会用一些理论并构造一些例子来论证，然后在一个真实的代码库 Sqlite 上做一些实验和基准测试。一个简单的测试让我们从一个最简单、最明显的例子开始，以前认为这是一个 const 让 C 代码跑得更快的例子。首先，假设我们有如下两个函数声明：void func(int *x); void constFunc(const int *x);然后假设我们如下两份代码：void byArg(int *x) { printf("%d\n", *x); func(x); printf("%d\n", *x); } void constByArg(const int *x) { printf("%d\n", *x); constFunc(x); printf("%d\n", *x); }调用 printf() 时，CPU 会通过指针从 RAM 中取得 *x 的值。很显然，constByArg() 会稍微快一点，因为编译器知道 *x 是常量，因此不需要在调用 constFunc() 之后再次获取它的值。它仅是打印相同的东西。没问题吧？让我们来看下 GCC 在如下编译选项下生成的汇编代码：$ gcc -S -Wall -O3 test.c $ view test.s以下是函数 byArg() 的完整汇编代码：byArg: .LFB23: .cfi_startproc pushq %rbx .cfi_def_cfa_offset 16 .cfi_offset 3, -16 movl (%rdi), %edx movq %rdi, %rbx leaq .LC0(%rip), %rsi movl $1, %edi xorl %eax, %eax call __printf_chk@PLT movq %rbx, %rdi call func@PLT # constFoo 中唯一不同的指令 movl (%rbx), %edx leaq .LC0(%rip), %rsi xorl %eax, %eax movl $1, %edi popq %rbx .cfi_def_cfa_offset 8 jmp __printf_chk@PLT .cfi_endproc函数 byArg() 和函数 constByArg() 生成的汇编代码中唯一的不同之处是 constByArg() 有一句汇编代码 call constFunc@PLT，这正是源代码中的调用。关键字 const 本身并没有造成任何字面上的不同。好了，这是 GCC 的结果。或许我们需要一个更聪明的编译器。Clang 会有更好的表现吗？$ clang -S -Wall -O3 -emit-llvm test.c $ view test.ll这是 IR 代码（LCTT 译注：LLVM 的中间语言）。它比汇编代码更加紧凑，所以我可以把两个函数都导出来，让你可以看清楚我所说的“除了调用外，没有任何字面上的不同”是什么意思：; Function Attrs: nounwind uwtable define dso_local void @byArg(i32*) local_unnamed_addr #0 { %2 = load i32, i32* %0, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2) tail call void @func(i32* %0) #4 %4 = load i32, i32* %0, align 4, !tbaa !2 %5 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) ret void } ; Function Attrs: nounwind uwtable define dso_local void @constByArg(i32*) local_unnamed_addr #0 { %2 = load i32, i32* %0, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2) tail call void @constFunc(i32* %0) #4 %4 = load i32, i32* %0, align 4, !tbaa !2 %5 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) ret void }某些有作用的东西接下来是一组 const 能够真正产生作用的代码：void localVar() { int x = 42; printf("%d\n", x); constFunc(&x); printf("%d\n", x); } void constLocalVar() { const int x = 42; // 对本地变量使用 const printf("%d\n", x); constFunc(&x); printf("%d\n", x); }下面是 localVar() 的汇编代码，其中有两条指令在 constLocalVar() 中会被优化掉：localVar: .LFB25: .cfi_startproc subq $24, %rsp .cfi_def_cfa_offset 32 movl $42, %edx movl $1, %edi movq %fs:40, %rax movq %rax, 8(%rsp) xorl %eax, %eax leaq .LC0(%rip), %rsi movl $42, 4(%rsp) call __printf_chk@PLT leaq 4(%rsp), %rdi call constFunc@PLT movl 4(%rsp), %edx # 在 constLocalVar() 中没有 xorl %eax, %eax movl $1, %edi leaq .LC0(%rip), %rsi # 在 constLocalVar() 中没有 call __printf_chk@PLT movq 8(%rsp), %rax xorq %fs:40, %rax jne .L9 addq $24, %rsp .cfi_remember_state .cfi_def_cfa_offset 8 ret .L9: .cfi_restore_state call __stack_chk_fail@PLT .cfi_endproc在 LLVM 生成的 IR 代码中更明显一点。在 constLocalVar() 中，第二次调用 printf() 之前的 load 会被优化掉：; Function Attrs: nounwind uwtable define dso_local void @localVar() local_unnamed_addr #0 { %1 = alloca i32, align 4 %2 = bitcast i32* %1 to i8* call void @llvm.lifetime.start.p0i8(i64 4, i8* nonnull %2) #4 store i32 42, i32* %1, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 42) call void @constFunc(i32* nonnull %1) #4 %4 = load i32, i32* %1, align 4, !tbaa !2 %5 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) call void @llvm.lifetime.end.p0i8(i64 4, i8* nonnull %2) #4 ret void }好吧，现在，constLocalVar() 成功的省略了对 *x 的重新读取，但是可能你已经注意到一些问题：localVar() 和 constLocalVar() 在函数体中做了同样的 constFunc() 调用。如果编译器能够推断出 constFunc() 没有修改 constLocalVar() 中的 *x，那为什么不能推断出完全一样的函数调用也没有修改 localVar() 中的 *x？这个解释更贴近于为什么 C 语言的 const 不能作为优化手段的核心原因。C 语言的 const 有两个有效的含义：它可以表示这个变量是某个可能是常数也可能不是常数的数据的一个只读别名，或者它可以表示该变量是真正的常量。如果你移除了一个指向常量的指针的 const 属性并写入数据，那结果将是一个未定义行为。另一方面，如果是一个指向非常量值的 const 指针，将就没问题。这份 constFunc() 的可能实现揭示了这意味着什么：// x 是一个指向某个可能是常数也可能不是常数的数据的只读指针 void constFunc(const int *x) { // local_var 是一个真正的常数 const int local_var = 42; // C 语言规定的未定义行为 doubleIt((int*)&local_var); // 谁知道这是不是一个未定义行为呢？ doubleIt((int*)x); } void doubleIt(int *x) { *x *= 2; }localVar() 传递给 constFunc() 一个指向非 const 变量的 const 指针。因为这个变量并非常量，constFunc() 可以撒个谎并强行修改它而不触发未定义行为。所以，编译器不能断定变量在调用 constFunc() 后仍是同样的值。在 constLocalVar() 中的变量是真正的常量，因此，编译器可以断定它不会改变 —— 因为在 constFunc() 去除变量的 const 属性并写入它将会是一个未定义行为。第一个例子中的函数 byArg() 和 constByArg() 是没有可能优化的，因为编译器没有任何方法能知道 *x 是否真的是 const 常量。补充（和题外话）：相当多的读者已经正确地指出，使用 const int *x，该指针本身不是限定的常量，只是该数据被加个了别名，而 const int * const extra_const 是一个“双向”限定为常量的指针。但是因为指针本身的常量与别名数据的常量无关，所以结果是相同的。仅在 extra_const 指向使用 const 定义的对象时，*(int*const)extra_const = 0 才是未定义行为。（实际上，*(int*)extra_const = 0 也不会更糟。）因为它们之间的区别可以一句话说明白，一个是完全的 const 指针，另外一个可能是也可能不是常量本身的指针，而是一个可能是也可能不是常量的对象的只读别名，我将继续不严谨地引用“常量指针”。（题外话结束）但是为什么不一致呢？如果编译器能够推断出 constLocalVar() 中调用的 constFunc() 不会修改它的参数，那么肯定也能继续在其他 constFunc() 的调用上实施相同的优化，是吗？并不。编译器不能假设 constLocalVar() 根本没有运行。如果不是这样（例如，它只是代码生成器或者宏的一些未使用的额外输出），constFunc() 就能偷偷地修改数据而不触发未定义行为。你可能需要重复阅读几次上述说明和示例，但不要担心，它听起来很荒谬，它确实是正确的。不幸的是，对 const 变量进行写入是最糟糕的未定义行为：大多数情况下，编译器无法知道它是否将会是未定义行为。所以，大多数情况下，编译器看见 const 时必须假设它未来可能会被移除掉，这意味着编译器不能使用它进行优化。这在实践中是正确的，因为真实的 C 代码会在“深思熟虑”后移除 const。简而言之，很多事情都可以阻止编译器使用 const 进行优化，包括使用指针从另一内存空间接受数据，或者在堆空间上分配数据。更糟糕的是，在大部分编译器能够使用 const 进行优化的情况，它都不是必须的。例如，任何像样的编译器都能推断出下面代码中的 x 是一个常量，甚至都不需要 const：int x = 42, y = 0; printf("%d %d\n", x, y); y += x; printf("%d %d\n", x, y);总结，const 对优化而言几乎无用，因为：除了特殊情况，编译器需要忽略它，因为其他代码可能合法地移除它在 #1 以外的大多数例外中，编译器无论如何都能推断出该变量是常量C++如果你在使用 C++ 那么有另外一个方法让 const 能够影响到代码的生成：函数重载。你可以用 const 和非 const 的参数重载同一个函数，而非 const 版本的代码可能可以被优化（由程序员优化而不是编译器），减少某些拷贝或者其他事情。void foo(int *p) { // 需要做更多的数据拷贝 } void foo(const int *p) { // 不需要保护性的拷贝副本 } int main() { const int x = 42; // const 影响被调用的是哪一个版本的重载函数 foo(&x); return 0; }一方面，我不认为这会在实际的 C++ 代码中大量使用。另一方面，为了导致差异，程序员需要假设编译器无法做出，因为它们不受语言保护。用 Sqlite3 进行实验有了足够的理论和例子。那么 const 在一个真正的代码库中有多大的影响呢？我将会在代码库 Sqlite（版本：3.30.0）上做一个测试，因为：它真正地使用了 const它不是一个简单的代码库（超过 20 万行代码）作为一个数据库，它包括了字符串处理、数学计算、日期处理等一系列内容它能够在绑定 CPU 的情况下进行负载测试此外，作者和贡献者们已经进行了多年的性能优化工作，因此我能确定他们没有错过任何有显著效果的优化。配置我做了两份源码拷贝，并且正常编译其中一份。而对于另一份拷贝，我插入了这个特殊的预处理代码段，将 const 变成一个空操作：#define const(GNU) sed 可以将一些东西添加到每个文件的顶端，比如 sed -i '1i#define const' *.c *.h。在编译期间使用脚本生成 Sqlite 代码稍微有点复杂。幸运的是当 const 代码和非 const 代码混合时，编译器会产生了大量的提醒，因此很容易发现它并调整脚本来包含我的反 const 代码段。直接比较编译结果毫无意义，因为任意微小的改变就会影响整个内存布局，这可能会改变整个代码中的指针和函数调用。因此，我用每个指令的二进制大小和汇编代码作为识别码（objdump -d libsqlite3.so.0.8.6）。举个例子，这个函数：000000000005d570 <sqlite3_blob_read>: 5d570: 4c 8d 05 59 a2 ff ff lea -0x5da7(%rip),%r8 # 577d0 <sqlite3BtreePayloadChecked> 5d577: e9 04 fe ff ff jmpq 5d380 <blobReadWrite> 5d57c: 0f 1f 40 00 nopl 0x0(%rax)将会变成这样：sqlite3_blob_read 7lea 5jmpq 4nopl在编译时，我保留了所有 Sqlite 的编译设置。分析编译结果const 版本的 libsqlite3.so 的大小是 4,740,704 字节，大约比 4,736,712 字节的非 const 版本大了 0.1% 。在全部 1374 个导出函数（不包括类似 PLT 里的底层辅助函数）中，一共有 13 个函数的识别码不一致。其中的一些改变是由于插入的预处理代码。举个例子，这里有一个发生了更改的函数（已经删去一些 Sqlite 特有的定义）：#define LARGEST_INT64 (0xffffffff|(((int64_t)0x7fffffff)<<32)) #define SMALLEST_INT64 (((int64_t)-1) - LARGEST_INT64) static int64_t doubleToInt64(double r){ /* ** Many compilers we encounter do not define constants for the ** minimum and maximum 64-bit integers, or they define them ** inconsistently. And many do not understand the "LL" notation. ** So we define our own static constants here using nothing ** larger than a 32-bit integer constant. */ static const int64_t maxInt = LARGEST_INT64; static const int64_t minInt = SMALLEST_INT64; if( r<=(double)minInt ){ return minInt; }else if( r>=(double)maxInt ){ return maxInt; }else{ return (int64_t)r; } }删去 const 使得这些常量变成了 static 变量。我不明白为什么会有不了解 const 的人让这些变量加上 static。同时删去 static 和 const 会让 GCC 再次认为它们是常量，而我们将得到同样的编译输出。由于类似这样的局部的 static const 变量，使得 13 个函数中有 3 个函数产生假的变化，但我一个都不打算修复它们。Sqlite 使用了很多全局变量，而这正是大多数真正的 const 优化产生的地方。通常情况下，它们类似于将一个变量比较代替成一个常量比较，或者一个循环在部分展开的一步。（Radare toolkit 可以很方便的找出这些优化措施。）一些变化则令人失望。sqlite3ParseUri() 有 487 个指令，但 const 产生的唯一区别是进行了这个比较：test %al, %al je <sqlite3ParseUri+0x717> cmp $0x23, %al je <sqlite3ParseUri+0x717>并交换了它们的顺序：cmp $0x23, %al je <sqlite3ParseUri+0x717> test %al, %al je <sqlite3ParseUri+0x717>基准测试Sqlite 自带了一个性能回归测试，因此我尝试每个版本的代码执行一百次，仍然使用默认的 Sqlite 编译设置。以秒为单位的测试结果如下： const非 const最小值10.658s10.803s中间值11.571s11.519s最大值11.832s11.658s平均值11.531s11.492s就我个人看来，我没有发现足够的证据来说明这个差异值得关注。我是说，我从整个程序中删去 const，所以如果它有明显的差别，那么我希望它是显而易见的。但也许你关心任何微小的差异，因为你正在做一些绝对性能非常重要的事。那让我们试一下统计分析。我喜欢使用类似 Mann-Whitney U 检验这样的东西。它类似于更著名的 T 检验，但对你在机器上计时时产生的复杂随机变量（由于不可预测的上下文切换、页错误等）更加健壮。以下是结果： const非 constN100100Mean rank121.3879.62 Mann-Whitney U2912Z-5.102-sided p value<10-6HL median difference-0.056s95% confidence interval-0.077s – -0.038sU 检验已经发现统计意义上具有显著的性能差异。但是，令人惊讶的是，实际上是非 const 版本更快——大约 60ms，0.5%。似乎 const 启用的少量“优化”不值得额外代码的开销。这不像是 const 启用了任何类似于自动矢量化的重要的优化。当然，你的结果可能因为编译器配置、编译器版本或者代码库等等而有所不同，但是我觉得这已经说明了 const 是否能够有效地提高 C 的性能，我们现在已经看到答案了。那么，const 有什么用呢？尽管存在缺陷，C/C++ 的 const 仍有助于类型安全。特别是，结合 C++ 的移动语义和 std::unique_pointer，const 可以使指针所有权显式化。在超过十万行代码的 C++ 旧代码库里，指针所有权模糊是一个大难题，我对此深有感触。但是，我以前常常使用 const 来实现有意义的类型安全。我曾听说过基于性能上的原因，最好是尽可能多地使用 const。我曾听说过当性能很重要时，重构代码并添加更多的 const 非常重要，即使以降低代码可读性的方式。当时觉得这没问题，但后来我才知道这并不对。via: https://theartofmachinery.com/2019/08/12/c_const_isnt_for_performance.html作者：Simon Arneaud 选题：lujun9972 译者：LazyWolfLin 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出