假设一个产品系列使用了多种处理器、不同容量的内存和几套外围硬件。它们共享大部分代码,但工具链、配置选项和最终制品并不完全相同。现在要修改其中一个公共模块,AI 很快就能理解接口、生成代码并补上测试,过去几天的工作可能缩短到几十分钟。

但对构建团队来说,工作才刚刚开始:这次修改影响哪些产品?旧型号还能不能编译?不同工具链产生的制品是否仍然满足相同的验收要求?新增的依赖能不能用于所有硬件平台?本地构建成功,是因为声明完整,还是因为开发机器上刚好安装了某个软件包?

AI 降低了得到一份实现的成本,却没有自动回答这些问题。实现越容易产生,工程体系越需要有能力判断:哪些变更可以进入系统,会引起哪些构建和重建,又该怎样验证。构建之外的功能、安全和运行影响,还需要其他工程环节共同判断。

实现可以重写,系统关系不能一键重来

AI 降低的不只是编写第一份代码的成本,也降低了修改、推翻和重新尝试的成本。让它换一种实现、去掉一个依赖或者保留旧接口,通常只需要补充几条要求。尚未进入系统的代码,越来越像一种用于探索问题的中间产物,而不再天然代表已经形成的工程承诺。

但是,这种低成本只存在于变更还没有深入系统的时候。

一项实现一旦进入系统,就会逐渐与其他组件、接口、数据、工具链、运行环境和交付要求建立关系。这些约束并不按行业整齐分开:通信设备既有 CPU 架构、内存规格、厂商 SDK 和产品型号这样的显性边界,也要持续演进 API、协议和运行环境;大型互联网系统虽然通常没有同样固定的硬件产品组合,却同样受基础镜像、语言运行时、数据库结构、公开 API 和消息协议等约束,其中不少一旦被大量服务依赖,也会成为不能随意突破的边界。

因此,AI 可以很快重写一个公共模块,也可以很快修改一个服务,但成本已经不再只来自实现本身。一个底层组件的变化可能牵动几十种硬件组合,也可能沿着公共库、接口和服务依赖传播到大量软件系统。行业不同,约束的形状不同,变化的节奏和验证方式也不同;真正共同的问题,是如何在这些既有关系中处置变更。代码可以很快重写,已经形成的系统关系却不能一键清除。数据库格式、公开接口、硬件协议、编译工具链、测试资产和产品维护承诺,都会让后续修改承担相应的兼容、重建和验证成本。

这里还有一个容易忽略的不对称:重新生成实现的成本下降了,理解实现的成本未必下降。AI 可以很快重写一个模块,但评审者仍然需要理解它改变了什么隐含行为,为什么引入新的抽象,是否增加了故障模式,以及其他产品配置会不会受到影响。

生成速度越快,理解、验证和集成在总成本中的占比反而可能越高。

为什么验证不会自然地以同样速度变便宜

既然 AI 可以生成代码,当然也可以用 AI 评审代码、编写测试和分析影响。那么,生成和验证是不是可以一起变便宜?

AI 确实能降低一部分分析和验证成本,但完成一项实现与正式接受一项变更并不是同一件事。对 AI 而言,完成实现通常意味着找到一条能够满足当前要求的路径;对工程体系而言,接受这项变更,还需要针对重要的失效方式取得足够证据。

例如,一个嵌入式驱动在默认开发板上编译通过,测试也没有报错,并不能说明它已经适用于整个产品系列。某些型号可能使用不同的编译器版本,某些型号的内存空间更紧张,还有一些配置可能启用了不同的中断、并发或者低功耗路径。这些差异平时不一定出现在 AI 获得的局部代码上下文中,却可能决定最终制品能不能使用。

这并不意味着所有产品组合都必须在每次修改后完整验证。工程仍然需要根据影响范围和失败后果选择验证强度。影响有限、容易发现并且可以恢复的变更,可以采用较轻的验证方式;涉及公共底层模块、硬件接口或者安全边界的变更,则需要更充分的证据。

AI 可以参与实现,也可以参与验证,但不能因为同一个 AI 智能体(Agent)同时生成了代码和测试,就认为这些测试已经提供了独立证据。它可能在两个阶段都沿用了同一个错误假设。

所以,AI 能够降低验证成本,却没有消除验证责任。实现的生成速度和工程体系接受变更的速度,仍然受不同条件约束。

真正的瓶颈是怎样处置变更

AI 不一定会让所有行业更快地发布软件。在车规、通信设备、工业控制和大型遗留系统中,产品准入和验证流程仍然可能保持原来的节奏。真正发生变化的,是更多实现和修改能够以更低成本到达工程体系的入口。

如果一个大规模软件项目同时使用多个编码 Agent,它们可以很快修改不同模块。单独看,每项修改可能都很合理;组合起来,却可能增加依赖数量、扩大工具链版本范围、引入重复抽象,或者让构建和测试矩阵不断膨胀。代码生成能力越强,验证队列反而越容易成为瓶颈。

因此,工程体系需要的不只是更快的流水线,还需要持续处置变更的能力。这里把它称为“变更处置能力”(Change Handling)。它不是更快地把更多修改处理完,而是理解变更影响、建立与风险相称的证据,并形成接受、拒绝、推迟、缩小范围或重新设计等明确的处置决定。

例如,AI 为一个公共模块引入了新的软件包,但这个软件包只支持部分产品使用的工具链。合理的处理方式未必是让构建团队想办法把所有平台都适配过去,也可能是要求重新设计,把新依赖限制在真正需要它的范围内。

能及时阻止一项不合适的变更,同样是工程能力。否则,所谓提高交付速度,只是把今天节省的时间变成未来需要偿还的复杂性。

“变更处置能力”也不应该成为新的产能指标。生成了多少代码、创建了多少合并请求、执行了多少次构建,都不能单独说明工程效率提高了。真正重要的是:工程体系能否在证据不被稀释、责任不被模糊、复杂性不持续失控的情况下,对这些变更形成明确处置。

工程的平衡从边界开始

要判断一项变更应该接受、调整还是拒绝,前提是知道哪些条件可以权衡,哪些边界不能突破。

技术选择很少在所有方向上都更好。提高性能可能增加复杂性,扩大兼容范围会提高验证成本,更严格的隔离也可能降低开发便利性。工程不是追求所有指标同时达到最好,而是在现实约束中寻找合适的平衡。

但平衡不等于什么都可以交换。不可突破的边界一旦明确,权衡才发生在剩余空间里。

在嵌入式和车载系统中,产品侧的资源预算、实时性和安全要求,不能因交付压力而突破;构建侧已经确定的执行隔离、工具链范围和依赖约束,也不能仅仅为了缩短构建时间而被关闭。纯软件系统同样存在不能随意交换的边界,例如已经对外承诺的接口兼容性、数据格式、运行平台要求和合规约束。边界的具体形态会随产品而不同,但一旦成为明确的产品或工程约束,就不能再简单拿它们与开发进度交换。

在这些边界以内,仍然存在大量需要平衡的选择。例如,是每次修改都验证所有硬件规格,还是先根据影响范围选择必要的构建和测试集合?是继续支持一个使用量很低的旧工具链,还是把它冻结到维护分支?是扩大缓存提高速度,还是减少缓存共享来增强隔离?

这才是“工程是一门妥协的艺术”的准确含义。妥协不是为降低标准寻找理由,而是在不可突破的条件下,明确选择保护什么、放弃什么,并承担相应的代价。

风险接受权本身也有边界。项目不能因为进度紧张,就通过一次内部批准把法律、监管、合同或者安全要求重新定义成普通的技术选项。有权决定项目计划,不代表有权豁免外部义务。

构建工程要控制什么能够影响制品

当实现供应越来越快,只依靠工程师记忆产品差异、手工检查环境和逐项维护流水线,很难持续处理不断增长的变更压力。已经明确的工程要求,需要尽可能变成可以重复执行的系统能力。

本文所说的构建工程,不只是运行一次编译命令,而是围绕制品生产形成的构建与验证体系。它包括依赖关系、工具链、执行环境、测试、打包、制品来源和准入过程,关心的是一次代码变更怎样成为可以交付的制品,以及这个过程能否被解释和验证。完整的业务决策和生产运行治理,则不属于本文讨论的构建工程范围。

做好这件事,至少需要同时解决两个问题。

第一个问题是说清楚哪些因素允许影响制品。源码、第三方依赖、编译器、系统软件、构建参数、硬件规格和产品配置,都可能改变最终结果。对于拥有多个产品系列的团队来说,“同一份源码”远远不足以确定一个制品,目标硬件、功能选项和工具链同样是输入。

第二个问题是确保在明确的构建信任边界内,只有这些被声明和授权的输入能够参与构建。构建边界之外仍不可避免地依赖的平台、硬件和服务,都应被明确列为信任假设。依赖声明写得再完整,只要构建脚本仍然可以随意读取开发机器上的文件、调用未声明的工具或者访问外部网络,实际结果就仍然可能被环境中的偶然因素改变。

一个常见场景是:某位工程师的机器上已经安装了厂商 SDK,构建脚本通过默认搜索路径找到了它,所以本地一直能够成功。换到另一台机器或者 CI 环境后,构建突然失败。更麻烦的情况是,两边都能构建成功,却因为找到的 SDK 版本不同而产生了不同的制品。

这里需要限定一下:构建系统擅长计算的是构建和重建影响。例如,一项输入变化后,哪些组件以及哪些产品配置对应的制品需要重新构建;根据预先建立的验证规则,哪些相关验证需要重新执行。至于功能行为、安全风险和运行影响,还不能只靠构建图判断。

这类问题不能只靠提醒开发者“记得声明依赖”解决。执行环境必须真正限制构建能够读取的文件、工具和网络资源。如果构建需要未声明的输入,它应该直接失败,让缺失的依赖暴露出来,而不是悄悄使用当前机器上碰巧存在的内容。

可以把这两个条件理解为“说清楚”和“管得住”:前者明确哪些输入可以影响制品,后者阻止未声明的输入影响构建结果。只有声明而没有执行限制,工程系统仍然依赖自觉;只有隔离而没有清楚的声明,系统又很难解释为什么能够构建。

后续工程首先需要一个确定的制品

过去一个常见做法是,开发团队构建一份软件用于自测,测试团队拿到相同代码后重新构建,上线前又在发布环境中构建一次。表面上三次使用的是同一份代码,实际却经过了三套可能存在差异的工具链、配置和系统环境。开发验证过的、测试通过的和最后上线的,未必是同一个软件制品。

即使团队统一了研发环境,也很难保证环境长期完全一致。一次工具升级、一项配置调整,或者机器上多安装了一个软件包,都可能改变构建结果。源码没有变化,并不代表制品没有变化。

因此,构建工程首先要解决的是确定性:相同的输入应当得到相同的输出。更进一步,面向交付的正式制品应当具有稳定、可验证的身份。通常可以让同一个制品依次进入测试、安全检查、发布和部署;如果在不同阶段重新构建,也必须能够证明它们使用了相同的输入并得到了相同的输出,而不能仅仅因为源码相同,就把它们视为同一个软件制品。

这一步是后续工程实践的基础。测试团队需要知道自己验证的就是最终交付的制品;安全和合规检查需要绑定到确定的制品;发布系统需要准确记录它的来源;运维系统才能可靠地部署、回滚和追踪它。连制品本身都不能确定,后面的证据和控制就失去了稳定的对象。

构建工程因此处在一个承上启下的位置:它承接研发产生的源码、依赖和配置,把它们变成确定的制品;另一边则连接质量验证、供应链安全、产品准入、发布部署和运维管理。它不是这些控制面的替代者,却为它们提供了共同工作的基础。

不是所有要求都能变成构建规则

确定的制品为后续验证提供了稳定对象,但它并不意味着所有工程属性都能由构建系统自动证明。

构建工程能够把很多要求转化成机器可以执行的控制,例如依赖版本、工具链范围、访问权限、制品大小、接口兼容检查和必须通过的测试。但并不是所有工程边界都能由构建系统独立证明。

例如,一个通信设备在特定负载下是否能够长期稳定运行,可能需要真实硬件和长时间测试;一个控制系统在异常条件下能否正确降级,可能需要台架或者系统级验证;某些安全要求还依赖部署方式和运行流程。

成熟的工程体系不应该假装只要策略语言足够强,所有要求最终都能写进流水线。构建工程真正需要做的是,把能够机器化的部分转化成不能轻易绕过的控制,同时明确暴露哪些条件仍然依赖人工评估、专门测试或者运行环境中的证据。

“无法自动验证”本身也应该是一项可见的工程事实,而不应该被隐藏在某位资深工程师的经验里。

Agent 越聪明,能力边界越重要

Agent 让执行边界变得更加重要,而不是不再重要。

假设 Agent 在构建过程中发现缺少一个头文件。为了尽快完成任务,它可能从网络下载一个软件包,也可能调整搜索路径,使用宿主机上已经安装的版本。从 Agent 的角度看,它成功解决了构建问题;从工程角度看,它可能绕过了依赖声明,引入了一个无法稳定复现的环境条件。

Agent 还会读取仓库里的说明文档、问题记录和测试日志。这些内容既可能包含有用信息,也可能包含错误甚至恶意指令。即使最终代码看起来合理,也不能据此相信整个执行过程只使用了允许的输入。

因此,Agent 应当像其他高能力执行主体一样受到最小权限、输入隔离和行为审计。它能够解释自己做了什么,不代表这些解释可以代替系统证据;它表现得越智能,越不能只依赖它自觉遵守边界。

AI 能力扩大了自动化可以完成的工作范围,也放大了错误操作可能造成的影响。能力越强,权限和执行边界越需要由外部系统控制。

构建系统提供证据,但不替组织作出决定

即使依赖声明完整、执行环境封闭、所有构建和测试都通过,也不能由此证明一项工程选择本身一定明智。构建系统负责执行已经确定的验证要求,并记录哪些已经完成、哪些仍然缺失。至于什么样的证据才算足够,仍然需要相关工程团队根据产品用途和风险作出判断。

例如,升级一套公共工具链以后,构建系统可以计算哪些产品需要重新生成制品并执行相应验证,同时记录新旧结果。它还可以发现某些旧硬件无法通过验证。但是否停止维护旧型号,是否扩大验证范围,或者是否接受某项剩余风险,并不是构建系统能够自行决定的事情。

“接受”不是流水线自动计算出的技术真理。相关专业人员需要先说明变更的技术后果。随后,再由具备相应授权的责任主体,结合用途、适用环境、关键假设、剩余风险和现有证据作出决定。同一份制品可以用于内部实验,却未必具备用于正式产品发布的证据。

反过来,构建系统也不应该只是帮助项目寻找通过方式的工具。当一项约束无法落实、验证成本远超预期,或者新的结果推翻了原有假设时,工程流程应当迫使原来的决策重新进入评估。

例如,一次看似普通的公共组件升级,经过影响分析后发现需要重新验证几十种硬件组合。正确的处理方式不一定是继续增加机器、缩短测试或者申请跳过检查,也可能是重新讨论升级范围,拆分变更,或者延后部分产品的迁移。

决策产生约束,工程系统落实并验证约束;执行产生的新证据,又可能迫使决策重新评估。这种反馈能力很重要。没有真实的背压和否决机制,流水线就只是自动化手续,而不是工程控制。

构建工程师在建设什么

成熟的构建工程早已不只是维护 Makefile、Shell 脚本和 CI 配置。依赖图、增量构建、缓存一致性、工具链管理、跨平台兼容、可复现性和软件供应链安全,本来就是构建团队长期处理的问题。

AI 不会突然把构建工程师从脚本编写者提升成约束管理者。更准确的变化是:AI 会减少其中大量局部、手工和经验驱动的操作,同时放大构建工程原本承担的系统性责任。

这里需要区分人和系统。构建工程师不是控制面,构建工程才是自动化软件生产体系中的关键技术控制面。它不是全部软件治理,而是通过依赖关系、执行隔离、权限规则、测试要求、制品准入和证据记录等机制,把架构、安全和质量等方面能够自动执行的要求落实到软件生产过程中。

构建工程师负责设计、维护和演进这套机制。他们把安全、架构、产品维护和组织风险要求中能够工程化的部分,转化成生产过程里不能轻易绕过的规则;对于无法自动落实或者无法自动验证的部分,则负责把缺口明确暴露出来。

因此,构建工程师的价值不在于亲手完成每一次构建,也不在于不断为每个新产品复制一套流水线,而在于建设一套能够持续处理产品差异、环境变化和大规模自动化变更的工程体系。

过去,一些依赖关系和产品差异还可以依靠团队经验维持。AI 让实现和修改更快地出现以后,这种依靠人脑和临场处理的方式会越来越难以跟上。构建工程的重要性不是因为脚本变得更难写,而是因为整个软件生产过程越来越需要明确、稳定且不能被轻易绕过的控制。

结语

AI 可以比过去更快地产生变更,却不能自动让这些变更变得可接受。代码容易生成,不代表影响容易理解;实现容易重写,不代表系统变更容易撤销;Agent 能够自我检查,也不代表它可以自我证明。

工程的目标不是阻止变化,也不是建立一个永远不会失败的系统,而是让已知风险受到明确控制,让未知失效更难扩散、更容易暴露和追溯,并让组织在事故发生以后仍然能够恢复、学习和继续演进。

对于构建团队来说,AI 时代真正需要建设的,不是更多由人手工维护的脚本,而是一套能够持续回答这些问题的工程体系:

哪些因素可以影响制品?一项修改会使哪些产品和硬件配置对应的制品需要重新构建?预先要求的验证是否已经完成?哪些条件仍然无法自动证明?当证据不支持原有决定时,系统能不能真正阻止相关变更继续向前?

实现越容易产生,接受实现的条件越应该明确;自动化能力越强,边界、证据和否决能力越不能依赖临场判断。

在现代软件工程里,研发与构建是构筑软件从无到有的两个核心环节。这个过程不仅仅是技术和工具的堆砌,更是对复杂性管理和创新思维的深刻体现。软件研发是将复杂问题逐步拆解为小问题并逐一实现的过程;软件构建则是将各个细节的实现逐步集成为完整系统的过程。

拆解不易,集成同样艰难。表面上看,集成似乎是拆解的逆过程,但两者解决的问题截然不同。拆解聚焦于设计领域,强调逻辑分解与结构优化,以降低系统复杂性;集成专注于工程领域,关注依赖组合与整体实现,确保组件兼容与系统稳定。虽然拆解和集成在表面上看似是逆过程,但它们解决的问题和使用的方法都截然不同。

软件研发的第一步是拆解,即将一个复杂的软件需求分解成一系列更小、更易于管理和实现的问题。这个过程要求开发者具备深厚的设计能力和对问题的深刻理解。在拆解的过程中,开发者需要识别出系统的关键组件和它们之间的关系。这不仅涉及到技术层面的分解,比如将一个大型的软件系统分解为多个模块和子系统,还涉及到业务逻辑的梳理,确保每个部分都能独立运作,同时又能够协同工作。

如果说拆解是将问题简化的过程,那么集成则是将这些简化后的部分重新组合起来,形成一个完整的系统。集成的挑战在于,它需要开发者在宏观层面上把握整个系统的运作。这不仅涉及到技术层面的集成,比如确保不同模块之间的接口能够正确对接,还包括业务流程的整合,确保整个系统的业务逻辑是连贯的。集成过程中,开发者需要解决各种可能出现的冲突和问题,比如版本兼容性、性能瓶颈、安全漏洞等,这些都是确保软件系统能够稳定运行的关键因素。

在实际的软件开发过程中,拆解和集成是相辅相成的。一个良好的拆解可以为集成打下坚实的基础,而一个成功的集成则能够验证拆解的有效性。两者共同作用,使得软件系统能够在保持高内聚低耦合的同时,实现功能的完整性和稳定性。

拆解与集成不仅仅是软件工程中的具体实践,更是一种思维方式的体现。它们代表了从分解复杂性到实现整体性的完整过程,体现了我们对问题的深刻理解和技术掌控力,也彰显了软件工程能力的艺术。

在当今数字化时代,软件已成为社会核心基础设施中不可或缺的一部分。无论是通信、金融、医疗、交通、航空航天,还是日常生活中的娱乐、购物和教育,软件无处不在。软件研发的质量和可靠性不仅影响用户体验和商业价值,还直接关系到公共安全。从社交媒体到智能家居和可穿戴设备,软件已经深入我们生活的每一个角落。对于全球商业和金融行业,高效运转的电子商务、支付平台以及金融证券交易依赖于高度可信的软件系统。而市政部门则依赖复杂的软件来支持电网运行、城市交通调度以及紧急医疗响应。这些广泛而深远的应用让软件成为推动经济发展和社会进步的关键力量,同时也加深了我们对软件的依赖。

然而,随着软件研发的规模和复杂性迅速增长,现代软件开发面临前所未有的挑战。技术栈日益多样化,从前端到后端、从云计算到边缘设备,生态系统愈加复杂;研发团队规模动辄达到数千甚至上万人,分布式协作和管理的难度显著增加;市场竞争加剧,产品需要在短周期内快速迭代以满足多样化需求。此外,全球软件供应链对开源社区和第三方组件的依赖也日益显著,但这也带来了潜在的安全隐患。一个简单的手机应用可能依赖数百个开源库,而大型互联网服务或智能硬件设备可能涉及多种编程语言混合开发的数千万乃至上亿行代码、上万个第三方组件、以及全球化协作以及部署运维的复杂管理。

这些深远的变化和迅猛的发展,虽然为社会和经济带来了巨大的机遇,但也不可避免地伴随着一系列复杂且严峻的问题与挑战:

  1. 安全性、隐私问题与用户信任危机
    • 软件漏洞与安全攻击:软件漏洞和网络攻击频发,对社会和企业造成巨大损失。
    • 数据泄露与隐私风险:敏感数据在存储和传输中面临泄露、盗用、滥用等风险,进一步削弱用户对软件系统的信任。
    • 第三方依赖的隐患:第三方组件和开源库带来的潜在安全隐患增加了系统风险。
    • 复杂系统的不透明加剧信任危机:用户无法理解系统的工作原理或开发过程,对其运行机制缺乏可见性,导致仅靠“安全”标签已不足以满足用户对质量和可靠性的基本期望。
    • 频发的安全事件:数据泄露与系统故障不断发生,进一步引发对软件质量和隐私保护的普遍质疑。
  2. 复杂的供应链风险
    • 第三方组件的隐性威胁:供应链中使用的第三方组件可能被恶意篡改或包含漏洞。有些后门并非直接存在于源码中,而是通过构建过程、更新机制等环节悄然注入,绕过传统的源码安全检查。
    • 开发环境的潜在风险:不仅仅是软件组件本身,开发工具、构建系统和流水线同样可能被攻击,攻击者可以渗透构建流程、自动化工具或专有源代码,获得从源头注入漏洞与后门的机会。
    • 风险响应的复杂性:软件产品研发常依赖于大量的第三方组件。当某个组件被发现存在风险时,快速识别其影响范围至关重要。需要明确受影响的产品和客户,同时在研发过程中防止引入已知风险的组件,但这在复杂的依赖关系下带来非常大的挑战。
    • 管理难度的升级:随着开发环境和供应链的复杂化,团队在依赖管理、工具链可见性以及统一的安全控制方面面临巨大的挑战,缺乏清晰的管理机制可能进一步放大风险。
  3. 市场竞争与快速迭代的压力
    • 快速迭代的发布周期:激烈的市场竞争要求企业不断缩短产品发布周期,快速迭代模式下,功能开发优先于质量保障,导致测试和优化环节被压缩。
    • 多规格产品的支持:为了满足不同市场需求,企业需要快速调整并支持多个产品型号和规格。不同产品的特性和需求差异增加了代码复用的难度和依赖管理的复杂性,进一步导致开发和维护成本上升。
    • 长期支持产品的复杂性:某些产品需要在长达5年甚至更久的时间内持续支持,包括兼容新技术、修复发现的缺陷以及提供必要的安全更新。这种长期支持要求开发团队具备复现多年前产品构建和测试环境的能力,带来了工程管理上的显著挑战。
  4. 研发过程透明性不足
    • 版本管理的复杂性:跨团队协作中,版本管理不仅容易混乱,还可能因组件源码的不同编译选项产生多个制品,导致版本组合复杂且缺乏清晰性。这种混乱使得依赖管理和问题定位变得更加困难。
    • 构建流水线的稳定性问题:复杂的构建流水线常因错误的缓存使用、并发构建冲突、基础设施耦合和非幂等的构建过程而不稳定。优化与维护这些流水线的成本高昂,却是确保高效交付的关键。
    • 环境一致性的挑战:开发、构建、测试与生产环境的不一致可能引发测试不稳定、部署失败和运维风险,同时,不同组件对基础设施的特殊需求进一步加剧了环境管理的复杂性。使用统一环境可能造成资源浪费,而为每个任务配置独立环境则增加了管理和配置的复杂性。
    • 架构实现偏离设计:为了快速交付,开发团队可能在实现过程中违反架构设计原则。这种偏离如果未能及时发现和修正,会随着时间推移积累技术债务,严重影响系统的长期稳定性和可维护性。
  5. 效率和成本问题
    • 自动化不足与测试低效:缺乏有效的自动化测试,测试用例的创建和维护复杂耗时,过度依赖人工测试,既增加了测试成本,又难以及时发现研发问题。流水线运行效率和稳定性不足,进一步限制了自动化测试的充分执行,导致问题被延迟到后期修复,显著抬高维护成本。
    • 构建效率瓶颈与流水线复杂性:随着产品研发规模的扩大,构建耗时长、资源占用高,成为制约快速迭代的瓶颈。同时,流水线复杂度迅速增加,可能涉及数十到上百个任务节点,不同版本需求还需独立流水线支持,优化、维护成本高昂。
    • 资源分配与管理的挑战:构建流水线需占用大量基础设施资源,不同任务可能对运行环境有特殊要求。统一环境会造成资源浪费,而独立环境则增加配置复杂性。如何平衡资源利用与效率成为关键难题。
  6. 合规与法规挑战
    • 法规合规的复杂性:不同领域的软件必须符合相关行业法规,如GDPR、医疗设备标准或汽车行业的软件安全法规。不达标可能带来严重的法律和财务后果。
    • 严格标准的研发压力:医疗设备、汽车软件等领域对产品安全性和可靠性的要求极为严格,研发过程需要经过大量验证和审查,增加了项目的复杂性和时间成本。
    • 客户透明性需求:重要客户可能要求审查源代码和研发流程,并提供透明性报告,以确保产品符合安全性和可靠性的高标准。这不仅提高了审核难度,还增加了项目管理的复杂性。
    • 高成本的全流程追踪与审计:满足法规和客户透明性要求需要端到端的研发流程追踪和审计,这需要投入大量人力和资源。为满足这些需求,研发过程可能被迫调整或妥协,进一步影响整体研发效率。

这些问题反映了现代软件研发中所面临的多层次复杂挑战。在全球数字化加速的背景下,构建和维护可信赖的软件系统已成为企业实现持续发展的关键基础。这不仅帮助企业提升市场竞争力,也为用户和社会提供了更高的安全感和价值。面对这些挑战,我们必须重新审视并优化整个研发生命周期中的方法与实践,以更高效地构建更加可靠、可信的软件系统。

在讨论软件工程能力时,许多人首先想到的是开发速度、代码质量或持续集成的效率。然而,这些能力背后隐藏着一个至关重要但常常被忽视的因素,那就是依赖关系管理。尽管依赖关系表面上看似简单,甚至显得微不足道,但精准且全面的依赖关系管理是构建高效、稳定的软件工程能力的基石。无论是在代码编写、构建过程,还是最终的产品交付中,依赖关系的管理贯穿于整个软件开发生命周期。然而,许多团队对这一关键点重视不足,导致工程能力削弱,项目质量不稳定。

依赖关系管理的关键要素

在软件构建过程中,依赖关系管理是不可忽视的核心因素,尽管团队往往不会在这个问题上投入足够的时间和精力。当提到依赖时,人们通常会想到软件类库和源码等。这些依赖的变化可能对最终的软件产物产生显著影响,不仅在功能和性能方面,还可能表现在诸如目录名称、时间戳等与功能无关的方面。如果依赖管理不当,可能导致软件制品无法正确构建或出现严重问题。

现代软件开发通常涉及多种编程语言,每种语言都有其特定的依赖关系管理工具和方法,这带来了跨语言依赖管理的挑战。例如,一个 Java 程序可能调用由 C++ 开发的动态库,而该动态库又依赖于 Rust 开发的类库。如何在这些不同语言和工具之间建立起全面且准确的依赖关系,是软件工程中必须解决的难题。

除了语言间的依赖关系,还有一些依赖关系是通过构建过程建立起来的,例如通过构建脚本。这些依赖关系通常不会由依赖关系声明文件来维护,而是隐藏在构建过程中的某些步骤中。此外,构建工具和环境等因素通常不被视为依赖,但实际上,它们的变化同样会影响软件制品的稳定性。如果工具缺失、配置错误或存在缺陷,软件制品可能无法正确构建,甚至质量会显著下降。因此,这些传统上不被视为依赖的开发工具也应纳入依赖管理的范畴。

从广义的依赖关系角度来看,依赖管理不仅包括软件库和源码,还涵盖编译器、构建工具、操作系统、系统类库、环境变量、构建脚本等所有可能影响构建结果的要素。基于这一视角,依赖锁文件(lock 文件)需要为每次构建生成,确保每一个构建产物都有其对应的依赖锁文件。这种全面记录所有影响因素的做法,类似于对软件制品进行全面的“白盒化”描述。如果依赖关系保持一致,构建结果也应保持一致;反之,若两个构建产物存在差异,它们的依赖声明必定有所不同。

依赖关系的准确性和全面性直接决定了构建工程的成败。错误的依赖关系可能导致构建失败,而未能全面管理的依赖关系则可能引发构建的不稳定性问题(如常见的“在我电脑上没问题”现象)。构建稳定的工程能力需要掌握准确而全面的依赖关系清单,以简化和优化复杂且成本高昂的工程实践,显著提升软件工程的整体质量和效率。

构建工程中的常见误区

依赖关系的最重要的一个应用场景就是构建。然而,许多团队往往忽视构建工程的重要性,轻视依赖关系管理,从而未能给予构建工程应有的关注。构建工程常被认为是简单且低技术含量的任务,因此通常交由新手程序员负责。然而,由于缺乏经验,这些新人难以为构建工程设计出合理的架构或实现高质量的解决方案。这种对依赖关系管理和构建工程的轻视,直接导致了构建脚本维护成本高、质量低下,流水线不稳定,构建过程频繁出错,甚至难以复现缺陷。即便是经验丰富的开发者,如果不重视构建脚本的质量,认为脚本的编写质量不如 Java、C++ 等代码重要,依然会导致上述问题的出现。工程能力不足的团队通常表现为构建时间过长、构建失败率高、难以定位和解决问题,且无法有效扩展和维护构建系统。这些问题的积累最终显著削弱了团队的整体工程能力。

此外,许多团队过度关注持续集成和流水线管理,甚至为适应流水线而调整构建工程。这种做法实际上是本末倒置。流水线的实现因持续集成环境和基础设施的不同而异,强行让构建去适应流水线只会增加对现有基础设施的耦合度,导致构建工程复杂化、维护成本上升,并使构建质量变得不稳定。相比之下,工程能力强的团队通常由经验丰富的开发者维护构建脚本。这些开发者深刻理解依赖关系管理的重要性,并具备全局视角,能够在设计和实现上做出合理的架构决策。这种做法不仅保障了构建工程的稳定性和高效性,还确保了依赖关系管理的精准性和全面性,从而提升了整体工程能力。此外,优秀的构建工程也具有很高的兼容性和可迁移性,能够轻松适应不同的持续集成流水线以及构建环境。

构建工程的核心角色

软件构建工程是整个软件工程能力的核心,决定了从源码到最终产品的全过程,包括代码检查、分析、编译、测试、打包和发布等任务。例如,在使用 Gradle 的情况下,构建任务涵盖了从代码检查 (lint)、编译 (compile, compileJava)、测试 (check, test) 到打包 (jar, war, package) 和发布 (publish, deploy) 的各个环节。这些任务之间存在紧密的依赖关系,而流水线的实现则是在调用这些构建任务,通过将任务分配给不同的流水线节点,实现高性能、高效率的流水线。

构建工程的质量直接影响着其他工程实践的有效性和质量。持续集成、流水线管理、构建集群、缺陷管理、增量构建、分布式构建、自动化测试和发布管理等工程实践都围绕着构建工程展开。如果构建中的测试极其不稳定或总是失败,即使流水线运行得再高效,其实际价值也会大幅降低。

依赖关系管理的实际应用

当通过精准且全面的依赖关系管理构建出稳定且可重复的构建时,可以在此基础上将其应用于更多场景。例如,可以根据不同的 CI 服务器自动生成构建流水线,实现高精度的增量构建;在代码提交时实时监测依赖变更,甚至在不实际构建的情况下精确对比不同构建产物之间的差异,从而快速识别依赖关系变化的来源。此外,这种依赖关系管理还可以用于漏洞追溯,精准识别漏洞的影响范围和链路,甚至能在秒级时间内分析出一个已知漏洞影响了哪些客户的哪些产品。这种管理方式同样适用于分析架构设计与实现的差异,进一步优化系统设计。

流水线节点的配置实际上反映了构建工程中的任务依赖关系,因此可以通过构建工程中的任务依赖关系推导出流水线结构,甚至自动优化流水线节点的配置。这不仅简化了流水线的设计过程,还确保了其与构建工程的紧密配合,从而最大限度地提高整体工程效率和稳定性。

结语

精准且全面的依赖关系管理不仅包括第三方库或源码的引用,还涵盖操作系统、编译器、构建环境等所有可能影响构建结果的因素。依赖关系是软件构建工程的核心,而软件构建工程又是整个软件工程能力的核心。只有通过正确理解和管理依赖关系,才能构建出高效、稳定的软件工程能力。从依赖关系的精准且全面的管理开始,确保一切有序进行,这正是高效、稳定的软件工程能力的基石。

写过很多 Bash 脚本的人都知道,Bash 的坑不是一般的多^。所以大家都认可应该用 Python 而不是 Bash 的一个理由就是 Bash 脚本不可靠,不仅运行不稳定,而且还很难维护。关于 Bash 脚本很难维护,在《关于 Bash 的 10 个常见误解》里面有提到,其实是很多人对于 Bash 的语法就根本不熟悉。连最常用的 if 表达式的语法都不甚了解,更何谈什么进程替代啊,或者来做Bash 脚本的单元测试呢?没有人会认为使用一个自己不熟悉的语言来做开发维护工作是一件容易的事情。

不过呢,就算是我们对 Bash 语法不熟悉,但我们只是来实现一个简单的功能。Bash 脚本也没写几行,甚至在命令行都一条一条的验证过,放到 Bash 脚本里面运行起来就是会莫名其妙的出错。有些在服务器上的出错根本没法儿在本地重现,本地测试的好好的,刚部署上去也好好的,一到晚上或者假期就是各种出错。所以,有人说这是因为 Bash 这个语言其实不严禁,导致其运行不可靠,不稳定,所以得用 Python。但同样的功能用 Python 实现起来的代码量比 Bash 多,所以只是为了个简单的功能,还是直接写到 Bash 脚本里面吧,在 Python 脚本里面用 os.command 把很多命令一个一个包装起来也是挺烦人的。

从一个简单的例子开始

下面我们就以一个简单的例子来看一下为什么 Bash 脚本会如此不可靠。用 Bash 脚本为 GNU/Linux 实现一个很常见的、也很简单的功能,重启 Tomcat 服务。

Tomcat 是 Java 开发里面很常见的一个服务器应用,安装和使用都很简单。只要系统上已经安装了对应的 JDK,配置好 JAVA_HOME 环境变量,把 Tomcat 下载回来后解压就可以使用了。在 Tomcat 的安装目录里面执行 ./bin/startup.sh 就可以启动 Tomcat 服务,运行 ./bin/shutdown.sh 就可以停止 Tomcat 服务。

如果你读到这里,请稍微暂停来思考一下:如果由你来实现「重启 Tomcat」这个脚本,需要考虑哪些情况呢?

重启 Tomcat 服务,无非就是先「停止」再「启动」就可以了。我们在命令行的 Tomcat 目录下执行了 ./bin/startup.sh 后,打开浏览器验证了 Tomcat 已经启动。然后再执行 ./bin/shutdown.sh,再次打开浏览器发现无法访问了。所以要重启 Tomcat 就是先后执行这两个命令就可以了。

但是有一个要考虑的,如果 Tomcat 已经停止了,再次执行命令来停止 Tomcat 会不会出错呢?我们在命令行下测试了反复执行 ./bin/shutdown.sh 之后,发现除了打印出一个无关紧要的可以被忽略的出错信息后,执行过程并不会出错。

看起来实现重启 Tomcat 这个功能的 Bash 脚本无非就是要做如下几步:1、切换到 Tomcat 的安装目录;2、停止 Tomcat;3、启动 Tomcat。

#!/bin/bash
export JAVA_HOME=/opt/jdk1.8.0_262
export PATH="$JAVA_HOME/bin:$PATH"
cd /home/chaifeng/apache-tomcat-8.5.57
./bin/shutdown.sh
./bin/startup.sh

为了防止 Bash 脚本没有获取到 JAVA_HOME 这个环境变量的设置,我们在脚本里面还额外设置了一下,这又能避免一些奇怪的场景下无法获得这个变量的问题。看起来这个脚本很完美了,在本地也执行了一下,确实如我们期望的 Tomcat 已经重启了。用 ps 命令也确认了重启前后的 java 进程 ID 也发生了变化。

但是当我们把这个脚本部署到服务器上以后,情况却不一样了,大部分时候这个脚本都无法重启 Tomcat。明明 Tomcat 已经运行,但是服务端口处于无法访问的状态,甚至还会出现好几个 Tomcat 进程。难道是 ./bin/shutdown.sh 不起作用吗?当我们登录到服务器上,用 kill -9 杀掉这些 java 进程后,手动执行命令启动 Tomcat,验证没有问题。然后再手动运行命令停止 Tomcat,发现也没问题。更诡异的是,不管我们在命令行反复测试多少次,这两个启动和停止命令都正常工作。

明明在命令行下测试过都正常工作的命令,只有放到 Bash 脚本里面执行才有问题。看来 Bash 脚本的执行确实有时候不知何故的会出问题。难道真的是 Bash 是个不严禁的语言,运行不稳定吗?

为什么连这个简单的 Bash 脚本都运行不稳定?

为什么在命令行下一条一条的执行 Bash 脚本中的命令都没有问题,但是放到一个脚本中执行却会出问题呢?这两种方式下的命令都一样,除了用 Bash 语言不严禁这个理由来解释外,好像也没什么其他原因了。

实际上,有一个很重要的因素很容易被我们所忽略,就是:两条命令执行之间的时间差

在命令行上手工执行 Tomcat 的停止命令 ./bin/shutdown.sh 后,到输入了启动命令 ./bin/startup.sh 准备按下回车键执行之间,已经过去了好几秒。即使我们是快速复用命令行的历史命令,也要耗费2、3秒。而放到一个脚本里面执行,前一个命令执行结束到下一个命令开始运行,中间大概只有几个毫秒的时间差。几秒与几毫秒的时间差的区别就是这个简单的重启 Tomcat 脚本运行不稳定的根本原因。

当执行了 ./bin/shutdown.sh 来停止 Tomcat,实际上只是给 Tomcat 服务器发送了一个「停止」的信号,然后命令就退出。但此时,Tomcat 收到了「停止」信号后开始运行停止前的清理任务,包括但不限于等待尚未结束运行的客户端连接、释放数据库资源、网络资源、清理临时文件等。所以 Tomcat 还在运行,并没有立刻停止。这个停止运行前的清理工作的耗费时间依据我们的项目不同可能会差别很大,从零点几秒到几十秒,甚至几分钟都有可能。所以,这里是第一个关键的地方,./bin/shutdown.sh 命令执行结束后,Tomcat 可能还在后台进行清理工作,而没有停止运行!

等我们输入了 ./bin/startup.sh 后,时间已经过去了几秒,这个时间差已经足够让 Tomcat 在大多数的情况下真正停止运行了。所以 Tomcat 就又正常启动了。

放在脚本中运行,由于两条命令之间的时间差可能只有几毫秒,这么短的时间不足以让 Tomcat 停止运行。当我们在本地测试运行这个 Bash 脚本时,停止命令和启动命令都已经先后结束运行后。此时在后台,前一个老 Tomcat 进程还尚未结束运行,一个新的 Tomcat 进程就已经启动。但这个新的 Tomcat 可能还需要几秒钟来初始化资源,才能够开始绑定服务端口,这又为老 Tomcat 进程的停止争取到宝贵的几秒时间。当这个新的 Tomcat 结束了资源初始化后开始绑定服务端口的时候,老 Tomcat 可能已经释放了这个服务端口并停止运行。所以在本地测试时,这个重启 Tomcat 的 Bash 脚本表现的非常完美。

当部署到服务器上以后,Tomcat 服务可能会因为资源使用很多而需要更久的时间来释放资源,仅仅几秒是不够的。这就使得当一个新的 Tomcat 结束了资源初始化后,由于老的 Tomcat 服务还没有结束资源释放且没有释放服务端口,导致新的 Tomcat 会因为服务端口被占用而启动失败。

这就是为什么这么简单的一个重启 Tomcat 的 Bash 脚本会出现运行不稳定的原因。而且也不会因为我们用 Python 重写这个 Bash 脚本就可以解决这个问题。

让这个简单的例子变的稳定

既然找到了原因,那就好说了,启动 Tomcat 之前先把现有的 Tomcat 进程杀掉。因为 Tomcat 本身是一个 Java 应用,所以也就是杀掉 java 进程。

现在的问题是「如何杀掉进程」。有人会说这很简单啊,用 kill -9 就可以杀掉进程。没错,这样的确可以。而且如果大家去搜索「Linux 杀掉进程」,很多的搜索结果都提到要用 kill -9 来杀掉进程。为什么要使用选项 -9 呢?有人会说,因为有时候直接用 kill 命令杀不掉进程,反复执行也杀不掉,用了选项 -9 之后就可以了。

事实上,kill -9是对系统危害最大的命令之一。也有人会说,我一直用 kill -9 来杀进程,也从来没破坏过系统啊。这是典型的幸存者偏差,就跟开车不系安全带、骑车不带头盔一样,确实有很多人不系安全带不带头盔,也没见过出事儿。但是只要发生一次,那就是严重伤害。

直接用 kill 命令去杀一个进程,默认是给进程发送了 SIGTERM 信号,当进程收到这个终止信号后,可能会开始终止前的清理工作。当清理工作结束后,该进程就会自行终止,如果不需要清理就直接结束了。如果清理时间稍微长一些,就会让我们产生 kill 命令不工作的错觉。但我们可能只要稍微多等待几秒,进程就会结束了。而使用 kill -9 则类似于一枪爆头,没有给进程在自杀前善后工作的机会。如果进程打开了很大的文件,或者正在写入数据库。暴力终止进程的执行是很有可能会导致文件或者数据库的损坏。

作为一名称职的司机,我们一定会系安全带的。作为一名称职的运维工程师,一定不会滥用 kill -9 这个命令的。现在抬头看看你的周围,有多少不称职的运维工程师呢?

既然在执行了停止 Tomcat 的命令后,不能立即强行终止 java 进程,那就需要等待。很显然,我们不能预计要等待多久。如果固定的等待一个比较长的时间,而 Tomcat 早已经停止,这就会让别人觉得这个 Bash 脚本的性能太差了。现在我们修改一下这个重启 Tomcat 的 Bash 脚本:

#!/bin/bash
export JAVA_HOME=/opt/jdk1.8.0_262
export PATH="$JAVA_HOME/bin:$PATH"
cd /home/chaifeng/apache-tomcat-8.5.57
./bin/shutdown.sh
for ((i=0;i<60;i++)); do
    pgrep java || break
    sleep 1
done
pgrep java | xargs --no-run-if-empty kill -9
./bin/startup.sh

新增了一个等待循环,每秒钟都检查一下 java 进程,如果没找到就终止循环,最多等待 60 秒。退出循环后,再次查找 java 进程,这里用了 GNU xargs 的选项 -—no-run-if-empty 可以避免 xargs 没有从管道接受了数据而去执行 kill -9 命令,导致无谓的出错。

到这里,这个简单的重启 Tomcat 的 Bash 脚本看上去没啥问题了,不管是本地还是服务器上都可以正常的稳定的工作了。而且我们应该也要注意到,讨论的这个问题不管我们用 Python 还是 Bash 还是其他什么语言来实现这个功能,都同样是需要考虑的。要承认,这个造成 Bash 脚本运行不稳定的原因与 Bash 一点儿关系都没有。

这个简单的例子还没有结束

看到这里,或许有人会疑惑还需要考虑什么呢?停止 Tomcat 后,如果在 60 秒内 Tomcat 还没有停止就会强制停止,然后再启动 Tomcat。完美,没什么要考虑的了。

如果在系统上除了 Tomcat 这个 java 进程外,还有其他的 Java 进程,我们怎样确保这个重启脚本不会杀掉其他无关的 Java 进程呢?

如果 Tomcat 是以某个特定用户来运行,如何阻止其他用户无意中执行这个脚本呢?

如果系统上运行了多个 Tomcat 实例,如果确保这个重启脚本只会重启特定的 Tomcat 而不是其他的 Tomcat 服务呢?

如何能够让不同的 Tomcat 复用同一个重启脚本,而不用是为每个 Tomcat 的安装实例复制多份脚本呢?

如何让这个脚本自动判断 Tomcat 版本并选择对应的 JDK?以避免使用错误的 JDK 来启动 Tomcat 呢?

如何让这个重启脚本自动判断当前目录是否位于一个 Tomcat 安装目录中,运行后重启的就是这个 Tomcat,这样就避免了每次使用可能需要传入 Tomcat 目录作为参数了。万一输入了错误的目录呢?

如何避免使用特权用户执行这个脚本来重启 Tomcat?否则不仅会引入额外的安全风险,也会让因一些文件所有者发生变化而导致非特权用户以后无法再管理 Tomcat。

如何让每次重新开机后可以自动执行这个脚本来重启 Tomcat?如果要做开机自动启动脚本,是不是还要考虑 System V 还是 Systemd ?

作为开机的自动启动脚本时,如何自动判断 Tomcat 的所属用户,并自动切换到该用户的权限下执行?这样可以避免无谓的启动配置。

如果 Tomcat 上部署的服务依赖其他的服务,比如 MySQL,如何让这个脚本自动判断依赖的服务已经运行?避免 Tomcat 启动后服务运行后失败

如何让重启脚本判断 Tomcat 重启后服务已经成功运行,否则将以某种方式发出通知?这样就避免了重启不成功,但是我们不知道。

如何避免这个脚本互斥运行?以防止这个脚本在不同的终端上分别同时运行而导致的运行冲突。

如何避免远程登录到一个服务器上运行了脚本,并确认 Tomcat 运行正常,结果退出登录后,Tomcat 服务也会终止运行的问题。

等等……等等……

为了写出一个稳定、可靠、易用的 Tomcat 重启脚本,我们有很多要考虑的事情。列出了这么多可能需要考虑的问题,有哪个问题是和 Bash 有关的?很显然没有。如果我们用 Python 去开发这样一个功能,是否也需要考虑这些问题呢?答案是肯定的。

结论,Bash 脚本不稳定的原因其实就是我们自己。用 Python 重写 Bash 脚本后,原先该有的问题还是存在,只是我们不能再怪罪是 Python 这个语言不严谨,Python 运行不稳定了。只能乖乖从我们自身去寻找问题,然后慢慢得以解决。

如果一开始我们就能够清楚的意识到我们对于一些基础技能的欠缺,就可以提前避免很多不该有的问题。比如对 Bash 的语法不熟悉,也存在很多的误解,那我们怎么能写好 Bash?缺乏对于操作系统的一些功能理解,比如滥用 SIGKILL 信号,不明白什么时候该忽略 SIGHUP 信号等等,那我们怎么能管理好系统?其次,我们缺乏一个系统的运维知识体系,认为运维工作就是去使用一个又一个的工具,不理解这些工具在运维知识体系中的位置。那我们如何能够有效的利用这些不同工具的组合来解决问题呢?再者,我们缺乏从全局系统角度去思考问题,解决了一个又冒出另一个。或者就没明白这些不同的问题之间可能存在一个共同的根因,总是就问题解决问题,那我们要浪费多少时间和人力才可以维护好一个系统?最后,做好运维工作,提升运维技能不只是运维工程师才需要掌握的。对于开发工程师也有提升自己工作效率,更有效的与运维团队协作和沟通,无论是对自己、对团队、对项目都有好处。

要想了解运维工作的知识体系以及技术脉络请看DevOps 技术栈。再看看你是否也有关于 Bash 的 10 个常见误解?要想写出可靠稳定的 Bash 脚本,那一定要写Bash 脚本的单元测试

为什么要为 Bash 脚本写单元测试?

因为 Bash 脚本通常都是在执行一些与操作系统有关的操作,可能会对运行环境造成一些不可逆的操作,比如修改或者删除文件、升级系统中的软件包等。所以为了确保 Bash 脚本的安全可靠,在生产环境中部署之前一定需要做好足够的测试以确保其行为符合我们的预期。

如何能够安全可靠的去测试 Bash 脚本呢?有人可能会说我们可以用 Docker 容器。是的,这样做即安全又方便。在容器隔离出来的环境中不用担心脚本会破坏我们的系统,而且也能非常简单的快速重建出一个可用的测试环境。

不过呢,请考虑以下的几个常见的场景:

场景一:在执行 Bash 脚本测试前,我们需要需要事先安装好所有在 Bash 脚本中会用到的第三方工具,否则这些测试将会因为命令找不到而执行失败。例如,我们在脚本中使用了 Bazel 这个构建工具。我们必须提前安装并配置好 Bazel,而且不要忘记为了能够正常使用 Bazel 还得需要一个支持使用 Bazel 构建的工程。

场景二:测试结果的稳定性可能取决于脚本中访问的第三方服务的稳定性。比如,我们在脚本中使用 curl 命令从一个网络服务中获取数据,但这个服务有时候可能会访问失败。有可能是因为网络不稳定导致的,也可能是因为这个服务本身不稳定。再或者如果我们需要第三方服务返回不同的数据以便测试脚本的不同分支逻辑,但我们可能很难去修改这个第三方服务的数据。

场景三:Bash 脚本的测试用例的执行时间取决于脚本中使用的命令的执行时间。例如,如果我们中脚本中使用了 Gradle 来构建一个工程,由于不同的工程大小 Gradle 的一个构建可能要执行3分钟或者3个小时。这还只是一个测试用例,如果我们还有20个或者100个测试用例呢?我们是否还能在几秒内获得测试报告呢?

即使使用了容器来执行 Bash 脚本测试,也一样无法避免上面的几个问题。环境的准备过程可能会随着测试用例的增多而变的繁琐,测试用例的稳定性和执行时长取决于第三方命令和服务的稳定性和执行时长,还可能很难做到使用不同数据来覆盖不同的测试场景。

对于测试 Bash 脚本来说,我们真正要验证的是 Bash 脚本的执行逻辑。比如在 Bash 脚本中可能会根据传入的参数来组合出内部所调用的命令的选项和参数,我们要验证的是这些选项和参数确实如我们预期的。至于调用的命令在接受了这些选项和参数后由于什么原因而失败,可能我们并不关心这所有的可能原因。因为这会有更多的外部影响因素,比如硬件和网络都是否工作正常、第三方服务是否正常运行、构建工程所需的编译器是否安装并配置妥当、授权和认证信息是否都有效、等等。但对于 Bash 脚本来说,这些外部原因导致的结果就是所调用的命令执行成功或者失败了。所以 Bash 脚本只要关注的是脚本中调用的命令是否能够成功执行,以及命令输出了哪些,并决定随后执行脚本中的哪些不同分支逻辑。

如果说我们就是想知道这个命令搭配上这些选项参数是否能按我们预期的那样工作呢?很简单,那就单独在命令行里面去执行一下。如果在命令行中也不能按预期的工作,放到 Bash 脚本里面也一样不会按预期的工作。这种错误和 Bash 脚本几乎没什么关系了。

所以,为了尽量去除影响 Bash 脚本验证的那些外部因素,我们应该考虑为 Bash 脚本编写单元测试,以关注在 Bash 脚本的执行逻辑上。

什么样的测试才是 Bash 脚本的单元测试?

首先,所有存在于 PATH 环境变量的路径中的命令都不应该在单元测试中被执行。对 Bash 脚本来说,被调用的这些命令可以正常运行,有返回值,有输出。但脚本中调用的这些命令都是被模拟出来的,用于模拟对应的真实命令的行为。这样,我们在 Bash 脚本的单元测试中就避免了很大一部分的外部依赖,而且测试的执行速度也不会受到真实命令的影响了。

其次,每个单元测试用例之间都应该是独立的。这意味着,这些测试用例可以独立执行或者被任意乱序执行,而不会影响验证结果。

最后,这些测试用例可以在不同的操作系统上执行,且都应该得到相同的验证结果。比如 Bash 脚本中使用了只有 GNU/Linux 上才有的命令,对应的单元测试也可以在 Windows 或者 macOS 上执行,且结果一致。

怎样为 Bash 脚本写单元测试?

与其他编程语言一样,Bash 也有多个测试框架,比如 BatsShunit2 等,但这些框架实际上并不能隔离所有 PATH 环境变量中的命令。有一个名为 Bach Testing Framework 的测试框架是目前唯一一个可以为 Bash 脚本编写真正的单元测试的框架。

Bach Testing Framework 的最独特的特性就是默认不会执行任何位于 PATH 环境变量中的命令,因此 Bach Testing Framework 非常适用于验证 Bash 脚本的执行逻辑。并且还带来了以下好处:

  • 简单
    什么也不用安装。我们就可以执行这些测试。比如可以在一个全新的环境中执行一个调用了大量第三方命令的 Bash 脚本。

  • 因为所有的命令都不会被真正执行,所以每一个测试用例的执行都非常快。
  • 安全
    因为不会执行任何外部的命令,所以即使因为 Bash 脚本中的某些错误导致执行了一个危险的命令,比如 rm -rf *。Bach 会保证这些危险命令不会被执行。
  • 与运行环境无关
    可以在 Windows 上去执行只能工作在 GNU/Linux 上的脚本的测试。

由于操作系统和 Bash 的一些限制,Bach Testing Framework 无法做到:

  • 拦截使用绝对路径调用的命令
    事实上我们应该避免在 Bash 脚本中使用绝对路径,如果不可避免的要使用,我们可以把这个绝对路径抽取为一个变量,或者放入到一个函数中,然后用 @mock API 去模拟这个函数。
  • 拦截诸如 >>><< 等等这样的 I/O 重定向
    是的,无法拦截 I/O 重定向。我们也同样可以把这些重定向操作隔离到一个函数中,然后再模拟这个函数。

Bach Testing Framework 的使用

Bach Testing Framework 需要 Bash v4.3 或更高版本。在 GNU/Linux 上还需要 Coreutils 和 Diffutils,在常用的发行版中都已经默认安装好了。Bach 在 Linux/macOS/Cygwin/Git Bash/FreeBSD 等操作系统或者运行环境中验证通过。
Bash v4.3+
Coreutils (GNU/Linux)
Diffutils (GNU/Linux)

安装 Bach Testing Framework

Bach Testing Framework 的安装很简单,只需要下载 https://github.com/bach-sh/bach/raw/master/bach.sh 到你的项目中,在测试脚本中用 source 命令导入 Bach Testing Framework 的 bach.sh 即可。

比如:

source path/to/bach.sh

一个简单的例子

与其它的测试框架不同,Bach Testing Framework 的每一个测试用例都是由两个 Bash 函数组成,一个是以 test- 开头的测试执行函数,另一个是同名的以 -assert 结尾的测试验证函数。

比如在下面的例子中,有两个测试用例,分别是
test-rm-rf
test-rm-your-dot-git

一个完整的测试用例:

#!/usr/bin/env bash
set -euo pipefail

source bach.sh # 导入 Bach Testing Framework

test-rm-rf() {
    # Bach 的标准测试用例是由两个方法组成
    #   - test-rm-rf
    #   - test-rm-rf-assert
    # 这个方法 `test-rm-rf` 是测试用例的执行

    project_log_path=/tmp/project/logs
    sudo rm -rf "$project_log_ptah/" # 注意,这里有个笔误!
}
test-rm-rf-assert() {
    # 这个方法 `test-rm-rf-assert` 是测试用例的验证
    sudo rm -rf /   # 这就是真实的将会执行的命令
                    # 不要慌!使用 Bach 测试框架不会让这个命令真的执行!
}

test-rm-your-dot-git() {
    # 模拟 `find` 命令来查找你的主目录下的所有 `.git` 目录,假设会找到两个目录

    @mock find ~ -type d -name .git === @stdout ~/src/your-awesome-project/.git \
                                                ~/src/code/.git

    # 开始执行!删除你的主目录下的所有 `.git` 目录!
    find ~ -type d -name .git | xargs -- rm -rf
}
test-rm-your-dot-git-assert() {
    # 验证在 `test-rm-your-dot-git` 这个测试执行方法中最终是否会执行以下这个命令。

    rm -rf ~/src/your-awesome-project/.git ~/src/code/.git
}

Bach 会分别运行每一个测试用例的两个方法,去验证两个方法中执行的命令及其参数是否是一致的。比如,第一个方法 test-rm-rf 是 Bach 的测试用例的执行,与之对应的测试验证方法就是 test-rm-rf-assert 这个方法

在第二个测试用例 test-rm-your-dot-git 中使用了 @mock API 来模拟了命令 find ~ type d -name .git 的行为,这个命令用来找出用户目录下的所有 .git 目录。模拟之后,这个命令并不会真的执行,而是利用了 @stdout API 在标准终端上输出了两个虚拟的目录名。

然后我们就可以执行真正的命令了,将 find 命令的输出结果传递给 xargs 命令,并组合到 rm -rf 命令之后。

在对应的测试验证函数 test-rm-your-dot-git-assert 里面就验证是 find ~ -type d -name .git | xargs -- rm -rf 的运行结果是否等同于命令 rm -rf ~/src/your-awesome-project/.git ~/src/code/.git

@mock 是 Bach Testing Framework 中很重要的一个 API,利用这个 API 我们就可以模拟 Bash 脚本中所使用的任意命令的行为或者输出。

比如

@mock curl --silent google.com === \
    @stdout "baidu.com"

模拟了命令 curl --silent google.com 的执行结果是输出 baidu.com。在真实的正常场景下,我们是无法做到访问 google.com 得到的是 baidu.com。这样模拟之后就可以用来验证 Bash 脚本中处理一个命令不同响应时的行为了。

@mock API 甚至还支持更复杂的行为模拟,我们可以自定义一个复杂的模拟逻辑,比如:


@mock ls <<\CMD
    if [[ "$var" -eq 1 ]]; then
        @stdout one
    else
        @stdout others
    fi
CMD

在这个模拟中,会根据变量 $var 的值来决定命令 ls 的输出 one 还是 others

@mock API 模拟的命令在任何时候执行的时候都是同样的行为。但如果要模拟同一个命令重复执行的时候要返回不同的值,Bach Testing Framework 还提供了一个 @@mock 这个 API,比如:

@@mock uuid === @stdout aaaa-1111-2222
@@mock uuid === @stdout bbbb-3333-4444
@@mock uuid === @stdout cccc-5555-6666

这三个模拟命令模拟了 uuid 在重复执行三次的时候都返回不同的结果,按照模拟的先后顺序分别输出对应的模拟输出。如果在执行完所有的模拟输出后,再重复执行将会始终输出最后一个模拟的输出。

更详细的 API 介绍请在 Bach Testing Framework 的官网 https://bach.sh 查看。

使用 Bach Testing Framework 还可以让我们更安全方便的练习 Bash 编程。

比如,我们希望实现一个函数 cleanup 用来删除参数上指定的文件。一个实现可能是:

function cleanup() {
    rm $1
}

这个函数的实现其实是有安全问题的,因为对于 Bash 来说,有没有把一个变量用双引号包含起来是非常重要的。在这个实现中,变量 $1 就没有用双引号,这会带来严重的后果。下面我们将使用 @touch API 来创建几个文件,其中将有一个文件名中含有特殊字符 的文件 bar。我们都知道,对于含有特殊字符的文件名是要放入到双引号中的。现在这个这个 cleanup 的实现里面没有使用双引号,但是传参的时候使用了双引号,那是否还会按照我们的预期来执行呢?


function cleanup() {
    rm -rf $1
}

test-learn-bash-no-double-quote-star() {
    # 创建了三个文件,其中有一个名为 "bar*" 的文件
    @touch bar1 bar2 bar3 "bar*"

    # 要删除这个错误的文件名 bar*,而不删除其他文件,使用了双引号来传参,这是正确的
    cleanup "bar*"
}

test-learn-bash-no-double-quote-star-assert() {
    rm -rf "bar*"
}

这个测试用例将会失败,从验证结果中我们可以看到,期望只删除文件 bar,但是在函数 cleanup 里面,因为遗漏了双引号,会导致变量被二次展开。实际执行的命令是 rm -rf "bar*" bar1 bar2 bar3

现在修复函数 cleanup,把变量 $1 放入双引号:

function cleanup() {
    rm -rf "$1"
}

再次执行测试,会发现确实执行的是命令 rm -rf "bar*"

Bach Testing Framework 目前已经在宝马集团和华为内部使用了。在宝马集团的一个有数千人规模的大型项目里,Bach Testing Framework 保证了数个非常重要的构建脚本的维护。这些脚本的可靠性和稳定性决定了数千人团队的工作效率,现在就可以在本地快速验证这些构建脚本的执行逻辑,也避免了在本地很难复现一些构建集群中的特殊场景的问题。

如果大家在使用上有啥问题和建议可以去Bach 测试框架的项目上留言。

即使为 Bash 脚本写了单元测试后,我们可能还会遇到为什么 Bash 脚本总是不稳定?难道真的是 Bash 不严禁不稳定吗?另外还有《关于 Bash 的 10 个常见误解》,看看你有多少误解。以及在学习和掌握运维技术栈时,除了 Bash 以外可能还要学有很多工具,比如 Python、Ansible、Kubernetes等等。这些工具在运维技术栈里处于什么位置,他们之间的关系又是如何呢?我们是应该学 Bash 还是 Python 还是 Ansible 呢?请看《DevOps 技术栈》这个我认为最重要的一篇文章以建立起我们的运维技术脉络。

09. August 2020 · 3 comments · Categories: 技术文章 · Tags: ,

Bash 大概是程序员们既是最熟悉的而又是最陌生的。说熟悉,Bash 几乎存在于程序员身边的绝大多数电脑上。说陌生,Bash 作为最常用的脚本语言,很少有程序员们了解 Bash 的特性和语法,而且因为不了解还产生了很多的误解。比如什么 Bash 作为一个脚本语言,其语法怪异且不严禁,仅适合编写简单的脚本,我们应该用 Python 代替 Bash。其实这完全是错误的理解。甚至很多程序员并不知道他们当前使用的 Bash 版本是多少,用的是 3.x?还是 4.x?还是 5.x?你知道你用的是哪个版本吗?

其实 Bash 的语法是图灵完备的,也就是说理论上我们可以用 Bash 实现任何其他语言可以实现的功能。话虽如此,但不同的语言有其业务场景的适用性,我们当然应该把 Bash 用在最适合的场景下,这才能发挥出 Bash 的强大威力。

完全没有道理用自己熟悉的编程语言去比较其他语言,如果不符合自己熟悉的习惯就评价为怪异。作为程序员一定要保持开放的心态去学习。

这篇文章介绍了大家对 Bash 的常见误解,看看你知道多少?

Bash 的 if 语句后面其实不是括号

这应该是被人误解最多的,吐槽最多的一点。比如 Bash 的语法非常怪异,if 后面的括号竟然是方括号,而且括号的前后都要有空格!

事实上,Bash 的 if 语法定义上其实并没有定义括号,而且命令序列。执行命令 help if 可以看到 if 的使用方法

if COMMANDS; then COMMANDS; 
[ elif COMMANDS; then COMMANDS; ]... 
[ else COMMANDS; ] 
fi

实际上所谓的方括号 [ 其实是一个命令,这个命令既是 Bash 的内建命令,也是一个外部命令,根据系统的不同可能位于 /bin 或者 /usr/bin 目录下。比如 macOS 上执行命令 ls -l “/bin/[” 就可以找到。

当我们知道了方括号 [ 是个命令后,这些怪异的语法就变得很容易理解了。因为 if 后面是命令,所以当然也可以用方括号 [ 命令了。既然这是一个命令,那命令的参数和命令本身自然也需要有空格来做分割了。事实上方括号 [ 命令等同于 test 命令,但是要求命令的最后一个参数必须是 ]。哈哈,如果一个括号只有开头而没有结尾,这才是最怪异的呢。

不过还有一种常见的是双方括号 [[,这个是 Bash 的内建关键字。虽然可以把关键字当作命令来看到,但是 Bash 会对关键字做特殊处理。比如在 [[ 与其结尾参数 ]] 之间是可以使用 &&、|| 这样的逻辑操作的,如果我们把 [[ 1 -eq 1 && 2 -eq 2 ]] 当作两条命令来看到,第二个命令是 2,这肯定会执行失败了。这就是 Bash 对关键字做的一个特殊处理。而 [ 是一个命令,不管是内建命令也好外部命令也好,[ 1 -ne 0 && 2 -ne 3 ] 一定会出错的,因为这将执行两条命令,第一条 [ 1 -ne 0 将会因为 [ 命令的最后一个参数不是 ] 而出错,第二条命令 2- ne 3 ] 可能将会因为在 PATH 环境变量中没有找到一个名为 2 的命令而出错。

说到这里,我们可以想想看。假如有一个 Java 程序员竟然对 Java 的 if 语句的语法不熟悉,这个程序员怎么可能写出好的 Java 程序?把 Java 换成 Bash 也是一样的。

真的无法阻止 Bash 中的函数修改全局的变量吗?

这应该是第二个被人误解最多的一个 Bash 特性,也常被用来证明 Bash 的语言多么不可靠。原因是如果在函数中没有使用 local 关键字来声明变量,那函数里的变量将会对全局产生影响。或许看起来有些绕口,请看如下的例子:

#!/usr/bin/env bash
x=42
bar=answer

function foo() {
  local x=999
  bar=world
  echo foo: x=$x bar=$bar
}

foo
echo x=$x bar=$bar

执行的结果是

foo: x=999 bar=world
x=42 bar=world

在函数 foo 中用 local 关键字声明了变量 x,这使得函数内变量 x 的作用域限于函数 foo 内,不同于外部的全局变量 x。而函数内使用变量 bar 时没有用 local 关键字声明,这使得函数内对变量 bar 的赋值实际上是修改了函数之外的变量 bar。这可能会造成我们在编写函数的时候,必须显式的使用 local 关键字来声明局部变量,否则会发生无意中使用了全局的同名变量,导致该全局变量被无意中修改的问题。

这个问题真的很严重,也的确是有些人吐槽 Bash 不严谨的地方之一。事实上,这也恰巧说明了我们可能并不了解 Bash 中定义函数的语法。

大家都知道,在 Bash 中定义函数时,把函数体放到花括号中。看上去这点和很多编程语言也非常类似,比如 C、Java 等。但却不会把变量的作用域限制在花括号的范围内。其实 Bash 中声明函数时,其语法要求函数体是一个组合命令即可。所以,花括号并不是必须的,我们还可以用圆括号。请看下面的例子中函数 foo 的函数体用了圆括号:

#!/usr/bin/env bash
x=42
bar=answer

function foo() ( # <--
  local x=999
  bar=world
  echo foo: x=$x bar=$bar
) # <--

foo
echo x=$x bar=$bar

执行的结果是:

foo: x=999 bar=world
x=42 bar=answer

在第二个例子中,我们把函数体放到了圆括号里面,这使得这个函数将会在子进程中执行。而子进程是无法修改父进程中的任何变量的。所以,即使在函数中没有用 local 关键字声明变量 bar,对这个变量的变更也不会影响到全局的同名变量。

因为在定义 Bash 的函数时需要的是组合命令,所以下面的函数定义也是合法的:

function foo()
for param; do
  echo "=> $param";
done

执行 foo a b c 会得到

=> a
=> b
=> c

是的,出乎大家的意料,定义 Bash 函数的时候连什么括号都可以不要。

所以,我想再吐槽一次。如果有人连 Bash 的语法都不了解不熟悉,有很多的误解。他们是如何得出 Bash 的语法不严禁,功能不如其他语言这些结论的?

Bash 也支持直接执行目录名来切换到该目录中

很多使用 Zsh 的用户在举例 Zsh 与 Bash 的区别时,这是一个常用来对比的特性。很可惜,这不是 Zsh 的独特特性,Bash 其实也支持。实际上 Zsh 下这个特性也是默认关闭的,只是很多人都会使用 Oh My Zsh 这个知名的配置工程,而 Oh My Zsh 默认开启了这个特性。在 Bash 下开启这个特性的命令是 shopt -s autocd,开启之后我们就可以直接执行目录名来切换到该目录了。

Bash 其实也支持 ** 通配符来匹配多级子目录

这个特性也是有些人会误认为 ** 通配符是 Zsh 比 Bash 好用的一个特性,但其实 Bash 也是支持的。

开启命令是 shopt -s globstar

然后我们就可以用 ls **/*.txt 来列出当前目录及其子目录下的所有 *.txt 文件了。

所以,以后也请不要那这个特性来对比 Zsh 和 Bash 了。

Bash 在命令行上其实也支持撤销、粘贴等操作

当然有很多人知道在命令行下如何快速跳转光标、删除单词或者删除到行尾等常用操作,但很少有人会知道的 Bash 在命令行也支持撤销、粘贴等操作,而且粘贴功能事实上比操作系统默认的更强大。

撤销的快捷键是 Ctrl-/,反复按下会继续撤销之前的操作。

粘贴的快捷键是 Ctrl-y。当在命令行使用了 Ctrl-k 删除到行尾、Ctrl-w 删除前一个单词、Alt-d 删除下一个单词、Ctrl-u 删除整行等操作后,Bash 会将删除掉的内容放到自己的剪贴板中。要注意的是这个 Bash 的剪贴板不同于操作系统的剪贴板。删除之后,可以按下 Ctrl-y 在当前光标处粘贴最后一次删除的内容,重复按下会重复粘贴。

如果删除过多次,按下 Ctrl-y 粘贴出最后一次删除的内容后,再紧接着按下 Alt-y 就会替换为前一次的内容,重复按下 Alt-y 会继续替换更早之前删除的历史内容。

Bash 中使用 I/O 重定向的时候,重定向可以不用放在命令的最后

我们经常使用 >、>>、<、<< 来把命令结果保存到文件、或者从文件中读取内容。通常我们都是把重定向放在命令的最后。比如 ls -ld * >/tmp/files.txt

实际上,重定向可以位于一条命令的任何位置,放在中间或者最前面都是可以的。比如以下命令都是一样的

>/tmp/files.txt ls -ld *

ls >/tmp/files.txt -ld *

有时候不把重定向放在命令的最后会给我们快速复用并修改之前的命令非常方便。比如我们只需要快速搜索到之前执行的命令 >/tmp/files.txt ls -ld *,就可以很容易的修改最后一个参数了。

顺便说以下,看上去 Bash 中的 I/O 重定向很简单、很易使用。但事实上Bash 的 I/O 重定向比绝大多数人认为的更强大且复杂。或许我会在另外的文章中来详细介绍一下。

Bash 支持直接访问网络

Bash 的网络功能是很多人不知道的一个特性,不需要什么 nc、socat 这些命令就可以访问网络资源。只需要使用 I/O 重定向直接访问设备文件 /dev/tcp/<host>/<port> 即可,把 <host> 和 <port> 换成你希望访问的网络地址和端口。比如 /dev/tcp/baidu.com/80 就是访问主机 baidu.com 的 80 端口。

请看下面的命令,用 Bash 的内建命令实现了一个简单的 HTTP 客户端功能,访问 baidu.com 并打印出响应。

#!/usr/bin/env bash
exec 6<>/dev/tcp/baidu.com/80;
printf "GET / HTTP/1.1\nHOST: baidu.com\nConnection: close\n\n" >&6;
while read -r -u 6 line; do
  echo "$line";
done;
exec 6>&-

执行结果

HTTP/1.1 200 OK
Date: Sat, 08 Aug 2020 23:31:36 GMT
Server: Apache
Last-Modified: Tue, 12 Jan 2010 13:48:00 GMT
ETag: "51-47cf7e6ee8400"
Accept-Ranges: bytes
Content-Length: 81
Cache-Control: max-age=86400
Expires: Sun, 09 Aug 2020 23:31:36 GMT
Connection: Close
Content-Type: text/html

<html>
<meta http-equiv="refresh" content="0;url=http://www.baidu.com/">
</html>

其他的 Shell,比如 Zsh、Sh 等可能不支持这种直接访问网络的方式,但是 Zsh 的网络支持其实更强大,只是方式不同。

Bash 的受限模式让自动化运维更安全

在启动 Bash 的时候,使用选项 -r 可以让 Bash 进入受限模式。在受限模式下,会有很多的限制,比如:

  • 不能用 cd 命令切换目录
  • 不能修改 PATH 环境变量
  • 不可以通过指定路径的方式来运行命令
  • 不能使用 I/O 重定向
  • ……

Bash 受限模式的一个常见的使用场景是自动化运维。想想看,如果系统会根据一些用户的请求来自动生成并执行一些命令,在这些命令中可能会使用一些用户的输入,这可能会产生类似 SQL 注入的漏洞攻击。再比如,在一些自动化运维的时候难免要临时切换到特权用户下执行命令。为了让系统更安全,在临时的特权用户环境里面只能执行一些特定的命令或者脚本,以尽量减少系统被攻击的可能性。使用 Bash 的受限模式就可以避免非期望的命令或者脚本被执行,让系统更加安全。

有些「Bash 命令」其实和 Bash 无关

或许有的人会把 ls、rm、mkdir、…… 等命令称为 Bash 命令,其实这些命令与 Bash 并没有关系。比如在 GNU/Linux 上,这些命令属于 GNU Coreutils 这个项目。而 Bash 命令应该是 Bash 的内建命令,比如 alias、exec、printf 等等。有些 Coreutils 中的命令因为使用的很多,为了避免无谓的进程创建开销,所以 Bash 也将其内建了,比如 echo、test 等。不过要注意的是,Bash 的内建命令的选项与 Coreutils 中的同名命令可能不同。

比如执行下列命令可能会得到不同的结果:

echo --help
/bin/echo --help

# macOS 上用 brew install coreutils 后的默认路径
/usr/local/opt/coreutils/libexec/gnubin/echo --help

我们也能够为 Bash 写单元测试

Bash 还有一个最易被人诟病的一点就是,很难去测试一个 Bash 脚本。所以这也是很多人说应该用 Python 代替 Bash 的一个理由,因为我们可以为 Python 写单元测试,而不能为 Bash 写单元测试。虽然已经有了一些 Bash 的测试框架,比如 Batsshunit2 等。但这些测试框架都是集成测试框架。这意味着,为了执行 Bash 脚本的测试用例,我们需要事先安装好被测脚本中依赖的各种工具,配置好所需的目录、配置等。比如在我们的脚本中使用了 Bazel 或者 CMake,如果没有安装 Bazel 或者 CMake,很显然这些测试都将失败。

如果可以为 Bash 脚本写单元测试,这就意味着这些测试可以在任意时候、任意的机器上乱序执行任意的测试都应该在秒级的时间内得到相同的执行结果。无论是在 Windows 上执行 GNU/Linux 的 Bash 脚本的单元测试,还是在一个全新的极简的环境中执行一个使用了很多第三方工具的复杂的 Bash 脚本的单元测试,都应该得到稳定的测试结果。

现在有一个名为 Bach 的测试框架(Bach Testing Framework),这是目前唯一的一个真正可以为 Bash 脚本编写单元测试的框架。Bach 测试框架的独特之处在于它不会执行任何在 PATH 环境变量中的命令,而且还提供了丰富的模拟命令用于模拟其它命令的行为。Bach 测试框架非常适合于测试 Bash 脚本的执行逻辑,而不用实现安装并配置好被测脚本中使用到的各个工具。

请看下面的例子:

#!/usr/bin/env bash
set -euo pipefail
source bach.sh

test-rm-rf() {
    # Write your test case

    project_log_path=/tmp/project/logs
    sudo rm -rf "$project_log_ptah/" # 这里写错了变量名,导致了一个严重的错误
}
test-rm-rf-assert() {
    # Verify your test case
    sudo rm -rf /   # 这就是将要执行的命令
                    # 别慌!Bach 不会让这个命令真的执行!
}

Bach 测试框架的每一个测试用例默认是由两个函数来组成,一个是用于编写测试用例,比如上例中的 test-rm-rf,与之对应的是用于验证测试结果的测试验证函数 test-rm-rf-assert。Bach 测试框架会分别运行这两个函数,并对比执行中调用的命令序列是否一致。

太长不想读

请直接看解决 UFW 和 Docker 的问题

问题

UFW 是 Ubuntu 上很流行的一个 iptables 前端,可以非常方便的管理防火墙的规则。但是当安装了 Docker,UFW 无法管理 Docker 发布出来的端口了。

具体现象是:

  1. 在一个对外提供服务的服务器上启用了 UFW,并且默认阻止所有未被允许的传入连接。
  2. 运行了一个 Docker 容器,并且使用 -p 选项来把该容器的某个端口发布到服务器的所有 IP 地址上。比如:docker run -d --name httpd -p 0.0.0.0:8080:80 httpd:alpine 将会运行一个 httpd 服务,并且将容器的 80 端口发布到服务器的 8080 端口上。
  3. UFW 将不会阻止所有对 8080 端口访问的请求,用命令 ufw deny 8080 也无法阻止外部访问这个端口。

这个问题其实挺严重的,这意味着本来只是为了在内部提供服务的一个端口被暴露在公共网络上。

在网络上搜索 “ufw docker” 可以发现很多的讨论:

基本上可以找到的解决办法就是首先禁用 docker 的 iptables 功能,但这也意味着放弃了 docker 的网络管理功能,很典型的现象就是容器将无法访问外部网络。在有的文章中也提到了可以在 UFW 的配置文件中手工添加一条规则,比如 -A POSTROUTING ! -o docker0 -s 172.17.0.0/16 -j MASQUERADE。但这也只是允许了 172.17.0.0/16 这个网络。如果有了新增的网络,我们也必须手工再为新增的网络添加这样类似的 iptables 规则。

期望的目标

目前网络上的解决方案都非常类似,而且也不优雅,我希望一个新的解决方案可以:

  1. 不要禁用 Docker 的 iptables,像往常一样由 Docker 来管理自己的网络。这样有任何新增的 Docker 网络时都无需手工维护 iptables 规则,也避免了在 Docker 中禁用 iptables 之后可能带来的副作用。
  2. 公共网络不可以访问 Docker 发布出来的端口,即使是使用类似 -p 0.0.0.0:8080:80 的选项把端口发布在所有的 IP 地址上。容器之间、内部网络之间都可以正常互相访问,只有公共网络不可以访问。 虽然可以让 Docker 把容器的某一个端口映射到服务器的私有 IP 地址上,这样公共网络上将不会访问到这个端口。但是这个服务器可能有多个私有 IP 地址,这些私有 IP 地址可能也会发生变化。
  3. 可以很方便的允许公共网络直接访问某个容器的端口,而无需额外的软件和配置。就像是用 ufw allow 8080 这样允许外部访问 8080 端口,然后用 ufw delete allow 8080 就不再允许外部访问。

如何做?

撤销原先的修改

如果已经按照目前网络上搜索到解决方案修改过了,请先修改回来,包括:

  1. 启用 Docker 的 iptables 功能,删除所有类似 --iptables=false 的修改,包括 /etc/docker/daemon.json 配置文件。
  2. UFW 的默认 FORWARD 规则改回默认的 DROP,而非 ACCEPT
  3. 删除 UFW 配置文件 /etc/ufw/after.rules 中与 Docker 网络相关的规则。
  4. 如果修改了 Docker 相关的配置文件,重启 Docker。稍后还要修改 UFW 的配置,可以一并重启。

解决 UFW 和 Docker 的问题

目前新的解决方案只需要修改一个 UFW 配置文件即可,Docker 的所有配置和选项都保持默认。

修改 UFW 的配置文件 /etc/ufw/after.rules,在最后添加上如下规则:

# BEGIN UFW AND DOCKER
*filter
:ufw-user-forward - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16

-A DOCKER-USER -j ufw-user-forward

-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 192.168.0.0/16
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 10.0.0.0/8
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12
-A DOCKER-USER -j DROP -p udp -m udp --dport 0:32767 -d 192.168.0.0/16
-A DOCKER-USER -j DROP -p udp -m udp --dport 0:32767 -d 10.0.0.0/8
-A DOCKER-USER -j DROP -p udp -m udp --dport 0:32767 -d 172.16.0.0/12

-A DOCKER-USER -j RETURN
COMMIT
# END UFW AND DOCKER

然后重启 UFW,sudo systemctl restart ufw。现在外部就已经无法访问 Docker 发布出来的任何端口了,但是容器内部以及私有网络地址上可以正常互相访问,而且容器也可以正常访问外部的网络。可能由于某些未知原因,重启 UFW 之后规则也无法生效,请重启服务器。

如果希望允许外部网络访问 Docker 容器提供的服务,比如有一个容器的服务端口是 80。那就可以用以下命令来允许外部网络访问这个服务:

ufw route allow proto tcp from any to any port 80

这个命令会允许外部网络访问所有用 Docker 发布出来的并且内部服务端口为 80 的所有服务。

请注意,这个端口 80 是容器的端口,而非使用 -p 0.0.0.0:8080:80 选项发布在服务器上的 8080 端口。

如果有多个容器的服务端口为 80,但只希望外部网络访问某个特定的容器。比如该容器的私有地址为 172.17.0.2,就用类似下面的命令:

ufw route allow proto tcp from any to 172.17.0.2 port 80

如果一个容器的服务是 UDP 协议,假如是 DNS 服务,可以用下面的命令来允许外部网络访问所有发布出来的 DNS 服务:

ufw route allow proto udp from any to any port 53

同样的,如果只针对一个特定的容器,比如 IP 地址为 172.17.0.2:

ufw route allow proto udp from any to 172.17.0.2 port 53

解释

在新增的这段规则中,下面这段规则是为了让私有网络地址可以互相访问。通常情况下,私有网络是比公共网络更信任的。

-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16

下面的规则是为了可以用 UFW 来管理外部网络是否允许访问 Docker 容器提供的服务,这样我们就可以在一个地方来管理防火墙的规则了。

-A DOCKER-USER -j ufw-user-forward

下面的规则阻止了所有外部网络发起的连接请求,但是允许内部网络访问外部网络。对于 TCP 协议,是阻止了从外部网络主动建立 TCP 连接。对于 UDP,是阻止了所有小余端口 32767 的访问。为什么是这个端口的?由于 UDP 协议是无状态的,无法像 TCP 那样阻止发起建立连接请求的握手信号。在 GNU/Linux 上查看文件 /proc/sys/net/ipv4/ip_local_port_range 可以看到发出 TCP/UDP 数据后,本地源端口的范围,默认为 32768 60999。当从一个运行的容器对外访问一个 UDP 协议的服务时,本地端口将会从这个端口范围里面随机选择一个,服务器将会把数据返回到这个随机端口上。所以,我们可以假定所有容器内部的 UDP 协议的监听端口都小余 32768,不允许外部网络主动连接小余 32768 的 UDP 端口。

-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 192.168.0.0/16
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 10.0.0.0/8
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12
-A DOCKER-USER -j DROP -p udp -m udp --dport 0:32767 -d 192.168.0.0/16
-A DOCKER-USER -j DROP -p udp -m udp --dport 0:32767 -d 10.0.0.0/8
-A DOCKER-USER -j DROP -p udp -m udp --dport 0:32767 -d 172.16.0.0/12

-A DOCKER-USER -j RETURN

一个典型的 Shell 脚本的详细讲解和重构

上周无意中看到了一个 Shell 脚本,是一个很典型的开发工程师思维的脚本。我将会先分步讲解一下这个看上去很简单的脚本,随后再看如何重构。

脚本的完整代码如下:

PACKAGE="\"$1\""

line=$(java -version 2>&1  | grep $PACKAGE | grep -iv openjdk | wc -l)
#echo $line

if [[ $line =~ 0 ]]; then
  echo '{ "found": false , "not_found": true  }'

else
  echo '{ "found": true  , "not_found": false }'

fi

TL;DR 警告:如果太长不想看,就翻到最后看重构结果。

脚本的详细讲解

这段脚本的需求就是判断是否已经安装了特定版本的 Oracle JDK。在调用的时候会传入 Java 的版本号码,比如:1.8.0_161,这就是 $1 的值。

首先定义了 PACKAGE 这个变量,为什么在把 $1 赋值给 PACKAGE 的时候要转义双引号呢?是因为 java -version 这个命令输出的时候,版本号码是有双引号的。所以定义这个 PACKAGE 的作用是要精准匹配 java 的版本。

# Oracle JDK
chaifeng@local ~ $ java -version
java version "1.8.0_161"
Java(TM) SE Runtime Environment (build 1.8.0_161-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.161-b12, mixed mode)

# OpenJDK
chaifeng@local ~ $ java -version
openjdk version "1.8.0_144"
OpenJDK Runtime Environment (Zulu 8.23.0.3-macosx) (build 1.8.0_144-b01)
OpenJDK 64-Bit Server VM (Zulu 8.23.0.3-macosx) (build 25.144-b01, mixed mode)

然后就开始执行判断,并把结果赋值给变量 line。这行脚本看上去做了很多的事情,我们来拆开看一下。

java -version 用来输出版本号,但是输出到了标准错误上。而默认情况下,标准错误是无法通过管道传递给下一个命令的。所以需要 2>&1 来把标准错误的内容重定向到标准输出,这样才可以把 java 的版本号码传递给 grep 命令,否则 grep 是无论如何都无法获取到 java -version 的输出的。

第一个 grep 命令用来过滤 java -version 输出中包含版本号的所有行,并且这个版本号是用双引号包含起来的。正常情况下将会得到 java -version 命令输出的第一行。

然后再把结果传递给 grep -iv openjdk 命令。grep-i 选项是匹配时忽略大小写。-v 选项是排除,也就是反向匹配,将会输出不匹配的所有行。如果系统中正在使用的是 OpenJDK,前一个 grep 命令只是判断是否是指定版本的 JDK,包括 Oracle JDK 和 OpenJDK。这个 grep 命令将排除 OpenJDK 的输出,所以如果当前系统中使用的是 OpenJDK,这个命令执行之后可能会得到空的结果。

最后再用 wc -l 命令来得到结果的行数。如果是特定版本的 Oracle JDK,那结果就是一行。如果不是特定版本的 Oracle JDK,或者是 OpenJDK,就会输出空的结果,所以行数将会是 0

line 这个变量就会保存最后结果的行数,如果是 0 行显然就是系统中没有使用特定版本的 Oracle JDK。所以这里用了 [[ $line =~ 0 ]] 来判断变量 line 是否为 0

为什么最后会根据 line 是否为 0 来输出一个 JSON 的字符串呢?是因为这个脚本将在 Ansible Playbook 中调用。相关的 Ansible 代码如下:

- name: copy scripts to server
  copy: src="../files/check-java-version.sh"  dest="{{ java_download_path }}/"  mode="a+x"

- name: check if specific version of Oracle JDK is installed?
  shell: LC_ALL="en_US.UTF-8"  {{ java_download_path }}/check-java-version.sh  "{{ jdk_version }}"
  register: jdk_info
  changed_when: false
  failed_when: jdk_info.rc > 0

#- debug: var=jdk_info

- include: install.yml
  when: (jdk_info.stdout|from_json).not_found

这里有3个 task。第一个 task 是把脚本复制到服务器上,并设置了执行权限。第二个 task 是执行这个脚本,并且传入了 java 的版本,最后把 task 的执行结果保存到 Ansible 变量 jdk_info 中。jdk_infostdout 就将是 Shell 脚本最后输出的那个 JSON 字符串,比如:{ "found": false , "not_found": true }。第三个 task 是把第二个 task 的执行结果转换为 JSON 对象,并判断这个对象中的 not_found 的值是否为 true 来决定是否加载 install.yml 这个文件。

开始重构

所有相关的代码都分析完了,我们来看看如何重构,以及里面有哪些需要注意的问题。

之前也给几位朋友看过这个脚本,大部分人觉得问题主要在最后的 if 那里,尤其是输出 JSON 字符串的时候有互为相反的 foundnot_found 两个值。

先说典型问题,首先就是在 Bash 脚本中,如果不是特别需要,所有的变量一定要加上双引号!有没有双引号在 Bash 中是不一样的,不仅仅是双引号里面变量会被替换,而单引号不替换这个区别。

其次 if 的条件判断用了 =~ 这个操作符,这是 Bash 中的正则匹配的判断。判断语句 [[ $line =~ 0 ]] 的含义是判断变量 line 这个字符串里面是否包含字符 0。所以当 line 的值为 10, 201 都可以成功匹配的,这里显然不是期望这么判断的。当然了,正常情况下,line 的值要么是 1 要么是 0。正确的做法是,这里应该用 -eq 操作符来判断 line 这个变量是否等于数值 0

[[ "$line" -eq 0 ]]

最后是这个 if 判断有没有必要呢?

通常我们在 Java、Python、C 等语言里面函数的执行结果就是其返回值。在 Shell 脚本中,每一个命令的执行结果就是其输出。所以,对于刚从开发转到运维的工程师来说,就会习惯的想到根据命令的输出结果来判断。但 Shell 中还有一个很常用的判断方法就是根据命令的退出状态。如果命令执行成功,那退出状态就是 0,否则就是非 0。根据退出状态的不同,我们还可以知道命令为什么会执行失败。在 Bash 中,内部变量 $? 保存的就是前一个命令的退出状态。其实 Shell 脚本里的退出状态也可以类比为其他编程语言中的抛出异常。

Shell 脚本的退出状态就是脚本里面执行的最后一个命令的退出状态。通常我们不在意命令的输出,而只要根据退出状态来判断执行结果就可以了。对于现在重构的这个检查 Java 版本的脚本也一样,如果退出状态是 0 说明系统中是特定版本的 Oracle JDK。如果退出状态为非 0 说明不是特定版本的 Oracle JDK 或者安装的是 OpenJDK。

如果用退出状态来判断 Java 版本,那就没有必要输出 JSON 格式的结果了。if 的那5行代码就可以换成一行:

[[ "$line" -eq 1 ]]

如果是特定版本的 Oracle JDK,那变量 line 的值就是 1,这个判断就会成功,Shell 的退出状态就将是 0,否则是 1

可不可以再简单一点儿呢?是的,返回结果的行数也是没必要的。在获取行数那行里面

line=$(java -version 2>&1  | grep $PACKAGE | grep -iv openjdk | wc -l)

其实 java -version 2>&1 | grep $PACKAGE | grep -iv openjdk 就已经完成了特定 Oracle JDK 的判断。只有当前就是特定版本的 Oracle JDK 的时候,这个管道命令的退出状态才是 0,否则就是 1。那这个脚本就可以改写为 1 行:

java -version 2>&1  | grep "\"$1\"" | grep -iv openjdk

既然我们改用退出状态来判断结果了,那 Ansible 的 3 个 task 也要修改一下。由于脚本已经被改写为 1 行,其实也没必要放到一个脚本中专门执行了,所以就可以把第一个 task 删除掉。

第 2 个 task,原先是:

- name: check if specific version of Oracle JDK is installed?
  shell: LC_ALL="en_US.UTF-8"  {{ java_download_path }}/check-java-version.sh  "{{ jdk_version }}"
  register: jdk_info
  changed_when: false
  failed_when: jdk_info.rc > 0

最后有一行 failed_when: jdk_info.rc > 0,这行的含义就是如果命令执行失败就标记这个 task 为失败。类似于下面的代码:

if(jdk_info.rc == false)
  return false;
else
  return true;

所以其实这行本身就是没有用处的。但是现在我们要在 Ansible 中根据这个新的命令的退出状态来判断是否需要安装 Oracle JDK,所以需要忽略这个 task 的失败。因为 Ansible 在遇到任何 task 执行失败后都会终止执行剩余的 task。

改写后的 Ansible 代码如下:

- name: check if specific version of Oracle JDK is installed?
  shell: LC_ALL="en_US.UTF-8" java -version 2>&1 | grep  '"{{ jdk_version }}"' | grep -iv openjdk
  register: check_jdk
  changed_when: false
  ignore_errors: true

- include: install.yml
  when: ansible_check_mode or check_jdk is failed

最后用到了 Ansible 的内部变量 ansible_check_mode,用来判断是否运行在检查模式下。因为 Ansible 的 shell 模块不支持检查模式,所以当 Ansible 运行在检查模式下的时候,这个 task 会被忽略。导致 check_jdk 这个变量没有被定义,下一个 task 就会执行失败。

重构结果

删掉了一个脚本和一个 Ansible 的 task。

所以,原先脚本中的 java -version 2>&1 | grep $PACKAGE | grep -iv openjdk 这行就足够了,其它代码都不需要。Ansible 的 3 个相关 task 也可以删掉 1 个。

代码越多,Bug 也越多。

在我们老家,一些私人的重要纪念日还是按照农历日期的,比如生日什么的。如果是一些公共的假期,比如中秋、端午还好说,有很多现成的公共农历日历。如果是自己关心的几个农历日期就要自己添加提醒了,尤其是要批量添加20、30年的农历日历提醒,而且每年好几个。

为了省事,有两个事情需要解决:

  • 获取特定农历日期对应的公历日期
  • 在命令行下添加一个提醒到 Google Calendar 或者 Apple Calendar

用 lunar 来获取农历和公历的对应日期

编译

在 Debian 的仓库里面有一个名为 lunar 的软件可以查询农历和公历的对应日期。这个软件非常的老了,最新的版本是2001年10月发布的 2.2 版本。能够查询的日期范围是1900年到2049年之间的。在 macOS 上需要自己下载编译,目前无法从 Debian 的 git 仓库中获取源码,还好有打包好的 lunar 源码可以下载。

下载并解压后,执行 make 命令即可。虽然可以看到很多的警告信息,但在源码目录下已经编译成功。


基本用法

使用上非常简单,直接执行会看到简单的帮助信息。

注意到有一个选项 -h 是输出中文信息的,但由于这个软件太过于『古老』,以至于没有支持 UTF-8。所以如果你的终端的字符集不是 GBK 或者 GB18030,在使用 -h 选项的时候需要用 iconv 命令来把输出转换为当前终端的字符集。

lunar -h 2019 10 13 | iconv -f gb18030

有一个比较有趣的选项是 -b 可以以『位图』的方式输出

查询公历对应的农历日期

比如:要查询2019年10月13日对应的农历日期

lunar 2019 10 13

可以看到那天是农历的九月十五日。

-i 选项来查询农历对应的公历日期

比如:要查询2019年农历九月十五日对应的公历日期

lunar -i 2019 9 15

使用 Fantastical2 来添加日历

我购买了 Fantastical 2 for Mac,这是一个非常易用的 macOS 桌面日历工具。有一些比较强大的功能,比如:

  • 支持自然语言来添加提醒
  • 跨日历合并相同的提醒
  • 支持 AppleScript
  • ……

给一个特定的日期添加一个提醒

只要在 Fantastical 的输入框里面输入一行文字,带上日期即可。要指定一个地点,就在用『at』加上地址。如果有多个不同的日历来源,希望添加到特定的日历中,那就在行首加上『/日历名称』。

只有日期没有具体的时间时,默认是全天的事项。由于 Fantastical2 对中文的自然语言其实是没有什么支持了,所以精准的时间还是别用自然语言了。

比如我的默认日历是『工作』,希望添加到『家庭』的日历,就在最前面添加『/家庭』。

把添加事项自动化!

在 Fantastical2 的帮助文档的与其他应用整合里面有提到多种自动化的添加方式,比如用 URL 的方式:

open "x-fantastical2://parse?s=/家庭 2018-6-7 6:45 在太原机场打印行程单"

这会直接打开 Fantastical 的快速添加窗口,按下回车键即可添加一个事项。

这需要我们手工确认一下!自动化一定是 100% 的,没有 99% 的自动化。还有有个 add 选项,用上选项 add=1 就可以直接添加事项而无需确认了。

open "x-fantastical2://parse?s=/家庭 2018-6-7 6:45 在太原机场打印行程单&add=1"

执行这个命令后就直接在『家庭』日历中添加了一个事项。看上去不错了,但有个问题是,每次执行了命令 Fantastical 的窗口都会打开。毕竟 open 命令就是打开应用程序的。

自动化的实现中还是要避免本没有必要显示的窗口。

用 AppleScript 来实现完全的自动化

在 Fantastical 的帮助文档中可以看到一个 AppleScript 的例子

tell application "Fantastical 2" 
    parse sentence "Wake up at 8am" 
end tell

要实现完全的自动化,就加上 with add immediately

tell application "Fantastical 2" 
    parse sentence "Wake up at 8am" with add immediately 
end tell

现在我们已经知道了所有需要的信息了,创建一个 AppleScript 脚本 fantastical.scpt,内容如下:

on run argv
  set query to (item 1 of argv)
  tell application "Fantastical" to parse sentence query with add immediately
  return "添加日历:" & query
end run

然后就可以用 osascript 来执行这个脚本,并加上我们要添加的事项。

osascript fantastical.scpt "/家庭 2018-11-4 9am 去买个蛋糕"

执行这个命令时,Fantastical 的窗口也没有打开,完美。

写个 Shell 通过 lunar、Fantastical 来按照农历日期批量添加事项提醒

虽然我们已经搞定了几乎所有的事情,也只需要用 osascript 这个命令一行就可以自动的添加提醒事项,但是我们需要总是记得这个 fantastical.scpt 脚本在哪里。我希望直接就执行一个命令,不要再带上路径什么的。虽然有 PATH 这个环境变量,但只是对命令生效。如何可以直接执行 fantastical.scpt 而无需带上路径呢?

Shebang!

我们可以在网络上搜索到很多的 AppleScript 的代码,发现里面的注释都是用类似 SQL 的方式,是两个短线 --。但其实 AppleScript 从 Mac OS X Leopard 发布的 2.0 版本开始就支持用 # 来做注释了,所以 Shebang (Unix)

在第一行添加上 #!/usr/bin/env osascript,并且给文件添加上可执行权限。

#!/usr/bin/env osascript

on run argv
  set query to (item 1 of argv)
  tell application "Fantastical" to parse sentence query with add immediately
  return "添加日历:" & query
end run

添加可执行权限

chmod +x fantastical.scpt

我习惯上把所有自己写的脚本都放到 $HOME/bin 这个目录中,并且这个目录也添加到了 PATH 环境变量里面。

把文件 fantastical.scpt 移动到 ~/bin 目录中,现在就可以直接执行啦!

mv fantastical.scpt ~/bin

fantastical.scpt "/家庭 2018-11-4 9:30 再去买一个蛋糕吧"

目前感觉已经不错了,可以无需确认的添加一个事项,而且 Fantastical 的窗口也不会打开,在执行命令的时候也不用指定路径了。

但还是有一点 小小的缺陷,就是 fantastical.scpt 其实只支持一个参数。就是说,在添加事项的时候,一定别忘记用引号,否则会出问题的。比如

fantastical.scpt 买个蛋糕 2018-11-4

其实真正传递给 Fantastical 的内容只有『买个蛋糕』,而没有后面的的日期。所以默认就是在当天添加了一个『买个蛋糕』的事项,这不是我们要的。

虽然记得加上引号不是什么问题,但我们还可以做的更好。

一个简单的解决办法就是用 AppleScript 把传递进来的 argv 数组用空格连接起来,而不是只是获取数组的第一个元素。但对于自动化的实现来说,还有一种常用的传递参数的方式,是通过管道来传递。这种方式用 AppleScript 麻烦了一些,用 Shell 就很容易了。

用 Shell 让自动化再完美一些

用 Shell 可以解决两个问题:

  • 把所有的参数用空格连接起来,作为一个参数传递给 fantastical.scpt
  • 可以把通过管道传递过来的数据,转换为调用 fantastical.scpt 的参数

把所有的参数打包作为一个参数

用 Shell 接受参数是很容易的,在脚本中使用 $1$2$3、…… 就可以获得第1、2、3、……个参数。说到这里,或许有人不知道如何获取第10个或者以上参数。难道不就是 $10 吗?还真的不是!

看下面的 Bash 脚本

#!/bin/bash

echo 第十个参数是 $10

就是直接输出第 10 个参数,如果执行命令

./test-parameter.sh 1 2 3 4 5 6 7 8 9 a b c d e f

是不是应该就是输出:第十个参数是 a?让我们执行一下看看效果。

结果竟然是『第十个参数是 10』!莫非是把 a 当做十六进制转换为十进制?其实在 Bash 中,第1-9个参数可以直接用 $ 加数字,但第10个及其以上,需要用花括号{}包含起来,所以正确的代码应该是:

#!/bin/bash

echo 第十个参数是 ${10}

再次执行

./test-parameter.sh 1 2 3 4 5 6 7 8 9 a b c d e f

就会看到正确的结果:第10个参数是 a

回到我们要解决的问题上,如何用 Bash 把接受到的所有参数用『空格』连接起来传递给 fantastical.scpt 呢?

很多人会知道在 Bash 下可以用 $@ 来代表所有的参数!

#!/usr/bin/env bash

fantastical.scpt "$@" # it's wrong

这样其实是不对的!有两个解决办法,一是先把$@赋值给一个变量,然后把变量传递给 fantastical.scpt

#!/usr/bin/env bash

QUERY="$@"
fantastical.scpt "$QUERY"

另一个办法是用 $*

#!/usr/bin/env bash

fantastical.scpt "$*"

以上两种方式可以任选一个。在 Bash 里面 $@$* 是不一样的,这里就不细谈了。

把通过管道接受的数据传递过去

通过管道传递的数据是无法用 $@ 或者 $* 来获取的,但是可以用 cat 来获取,代码如下:

#!/usr/bin/env bash
set -eu

QUERY="$(cat)"
"${BASH_SOURCE}.scpt" "$QUERY"

现在有了新问题,如何判断当前是要通过管道接受数据呢?通常情况下,通过管道接受数据的时候是没有参数的,所以一个办法就是通过内置变量 $# 来判断是否有参数。但这个判断并不准确,有时候我们需要在代码中判断如果没有任何选项参数的时候就显示一个简单的帮助信息。还有一个常见的判断方法就是利用 tty 这个命令。tty 会显示当前终端的名字,如果是通过管道接受数据,则会出错,显示 not a tty 的错误信息。

利用 tty 来区分是否从管道获取数据,代码如下

#!/usr/bin/env bash
set -eu

if tty; then
  QUERY="$@"
else
  QUERY="$(cat)"
fi

fantastical.scpt "$QUERY"

最后再改进一点儿,从管道获取数据的时候允许合并命令行上额外的参数,并且不显示 tty 的任何输出。

注意:IO 重定向 >/dev/null 2>&12>&1 >/dev/null 这两个是不同的。

#!/usr/bin/env bash
set -eu

QUERY="$@"

if ! tty >/dev/null 2>&1; then
  QUERY="$QUERY $(cat)"
fi

fantastical.scpt "$QUERY"

最后如果发现没有任何信息要添加,也就是变量 QUERY 为空值,那就显示帮助信息,搞定!

完整的脚本

这两个脚本都放置在 PATH 里面某一个目录中建议是 $HOME/bin,并且用 chmod +x fantastical* 设置可执行的权限。

fantastical

#!/usr/bin/env bash
set -eu

QUERY="$@"

if ! tty >/dev/null 2>&1; then
  QUERY="$QUERY $(cat)"
fi

if [[ -z "$QUERY" ]]; then
  echo "Usage: "
  echo "  $0 /家庭 2019-10-13 8am 记得去买个蛋糕"
  exit 1
else
  "${BASH_SOURCE}.scpt" "$QUERY"
fi

fantastical.scpt

#!/usr/bin/env osascript

on run argv
  set query to (item 1 of argv)
  tell application "Fantastical" to parse sentence query with add immediately
  return "添加日历:" & query
end run

开始执行

比如我要给每年的农历九月十五这一天加一个提醒

for YEAR in {2018..2049}; do
  fantastical "$(lunar -i $YEAR 9 15 | grep -m1 -Eo "[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}")" 这一天是农历的九月十五; 
done

就这么几行代码,搞定!写 Shell 脚本是不是很简单呢?

写完这两个脚本,并添加所有的农历日期提醒,也就半个来小时,写文章就多半天过去了。