第4章 编写可维护的代码
当暴露在“真实世界”中时,代码会做出奇怪的反应。用户行为不可预测,网络不可靠,事情总会出错。生产环境下的软件必须一直保持可用的状态。编写可维护的代码有助于你应对不可预见的情况,可维护的代码有内置的保护、诊断和控制。切记通过安全和有弹性的编码实践进行防御式编程来保护你的系统,安全的代码可以预防许多故障,而有弹性的代码可以在故障发生时进行恢复。你还需要能够看到正在发生的事情,这样你就可以诊断出故障,将日志、指标和跟踪的调用信息暴露出来可以方便诊断。最后,你需要在不修改代码的情况下控制系统。一个可维护的系统具有可配置参数和系统工具。
本章描述了一些最佳实践,它们将使你的代码更容易在生产环境中运行。本章要涵盖的内容很多,所以行文比较紧凑。到最后,你将熟悉那些可以使你的软件具有可操作性的关键概念和工具。此外,与可操作性相关的评审意见在代码评审环节中很常见,这些信息将帮助你给予和接受更好的反馈。
4.1 防御式编程
编写拥有良好防御性的代码是一种对那些运行你的代码的人(包括你自己!)富有同情心的表现。防御性的代码较少发生故障,就算它发生故障,也更有可能恢复。切记让你的代码安全而有弹性。安全的代码利用编译时的校验来避免运行时的故障,使用不可变的变量、限制范围的访问修饰符和静态类型检查工具来防止bug。在运行时,校验输入的值可以避免出现意外。有弹性的代码使用异常处理中的最佳实践来优雅地处理故障。
4.1.1 避免空值
在许多语言中,没有值的变量默认为null(或nil、None或其他一些变体)。空指针异常是一种常见的情况。跟踪堆栈信息总会使人们挠着头,并发出“这个变量怎么可能没有被赋值”这样的疑问。
通过检查变量是否为空,通过使用空对象模式(null objectpattern),或通过可选类型(option type)来避免空指针异常。
切记在方法的开头进行空值检查。在条件允许的情况下,可以使用NotNull注解和编程语言中类似的特性。在前面校验变量是否为空意味着后面的代码可以安全地假定它是在处理真实的值,这将使你的代码更干净、更易读。
空对象模式会使用一个对象来代替空值。这种模式的一个例子:对于某个搜索方法,当它没有找到任何结果时,会返回一个空列表而不是null。返回空列表可以允许调用者安全地遍历这个返回值,而不需要特别的代码来处理空结果集。
一些编程语言有内置的选项类型——Optional或Maybe,这迫使开发者考虑如何处理响应为空的状况。如果有这种可选类型的话,请利用它们的优势。
4.1.2 保持变量不可变
不可变的变量一旦被赋值就不能被改变。如果你的语言有办法明确地将变量声明为不可变的(Java中的final,Scala中的val而不是var,在Rust中使用let而不是let mut),那么应该尽可能地这样做。不可变的变量可以防止意外的修改。许多变量可以被设置成不可变,这比你一开始想象的要多。作为奖励,使用不可变的变量可以使并发编程变得更简单,而且当编译器或运行环境知道变量不会改变时就可以运转得更有效率。
4.1.3 使用类型提示和静态类型检查器
限制变量可以被赋的值。例如,只有几个可能的字符串的变量应该是一个Enum而不是一个String。限制变量将确保意外的值会立即失效(甚至可能无法编译),而不是任由其引发潜在的bug。在定义变量时,尽可能使用最具体的类型。
动态语言如Python(从Python 3.5开始)、通过Sorbet校验的Ruby (计划成为Ruby 3 的一部分)和JavaScript (通过TypeScript)现在都对类型提示和静态类型检查器有越来越强大的支持。类型提示会在动态定义的类型中让你明确指定一种变量的类型。例如,代码清单4-1所示的Python 3.5的方法就使用了类型提示来接收和返回一个字符串。
代码清单4-1
def say(something: str) -> str: return "You said: " + something
最重要的是,类型提示可以逐步地被添加到现有的代码库中。静态类型检查器在代码执行之前会使用类型提示来发现潜在bug,所以配合静态类型检查器一起使用,你就可以防止运行时出现故障。
4.1.4 验证输入
永远不要相信你的代码接收的输入,开发人员、有问题的硬件和人为的错误都会破坏输入的数据。通过校验输入的正确性去保护你的代码,可以使用先决条件、校验和(checksum)以及校验数据合法性,套用安全领域中的最佳实践以及使用工具等方法来发现常见的错误。尽可能地提早拒绝不良输入。
使用前置条件和后置条件的方式来校验方法中输入的变量。当你使用的数据类型不能完全地捕获有效的变量值时,可以使用校验前置条件的类库和框架。大多数语言都有类似的库,其中有像checkNotNull这样的方法或像 @Size(m i n=0,;m a x=100) 这样的注解。尽可能地限制可能的取值。校验输入的字符串是否符合预期的格式,并记得处理前面或后面的空格。校验所有的数字是否在适当的范围内:如果一个参数应该大于0,那就要确保它大于0;如果一个参数是IP地址,那就要检查它是否是一个有效的IP地址。
不接受Word文档
德米特里在大学时曾在一个比较基因组学实验室做兼职工作。他的团队构建了一个网络服务,供科学家上传DNA序列并在上面运行实验室的工具。他们遇到的最常见的错误原因之一是,生物学家会把DNA序列的文本(比如一长串的As、Cs、Ts和Gs)放到一个Word文档中而不是一个纯文本文件中。解析器当然会中断,还无法生成任何结果。用户被告知没有找到匹配的序列。这是一种常见的情况。人们甚至提交了错误报告,表明DNA搜索功能已经坏掉:它没有找到在数据库中绝对存在的序列。
这种情况持续了相当长的一段时间。该团队指责用户,因为说明书中明确指出了需要上传“纯文本文件”。最后,德米特里厌倦了回复那些电子邮件去指导用户保存纯文本文件,所以他更新了网站。难道他添加了一个Word解析器吗?天哪,当然没有。他有没有添加文件格式检查和适当的错误检查,来提醒用户该网站无法处理他们的提交请求?当然也没有。他只是添加了一个画着一条红线的微软Word的大图标和一个说明超链接。寻求支持的电子邮件数量急剧下降!搞定了!
旧的网站仍然在运行,尽管它已经升级了。“不接受Word文档!”的图标已经消失,只剩下一个警告。“只接受纯文本,不接受Word文档。”在离开那份工作15年后,德米特里试图上传一个记载有经过充分研究的基因序列的Word文档,但没有搜索出任何结果,也没有返回任何错误。那是十几年来的误导性结果,因为德米特里懒得校验输入的正确性。不要成为20岁的德米特里,他可能因此而破坏了治疗癌症的研究。
计算机硬件并不总值得信赖,网络和磁盘可能会损坏数据。如果你需要强大的耐久性保证,使用校验和的方式来检查数据没有意外的变化。
也不要忽视安全问题,外部输入是危险的。恶意用户可能试图在输入中注入代码或SQL,或撑爆缓冲区以获得对你的应用程序的控制权限。使用成熟的类库和框架来防止跨站脚本攻击,总是强制转义输入的字符来防止SQL注入攻击。在使用strcpy(特别是strncpy)等命令操作内存时,明确地设置缓冲区的大小,以防止缓冲区溢出。使用广泛采用的安全与密码类库或协议,而不是自己去编写这样的类库或协议。熟悉开放式Web应用程序安全项目(open Web applicationsecurity project,OWASP)的十大安全报告以快速建立你的安全知识体系。
4.1.5 善用异常
不要使用特殊的返回值来标识错误类型(如null、0、−1等)。所有的现代编程语言都支持异常或有标准的异常处理模式(例如Go的error类型),特殊值在方法签名中并不明显可见。开发者不会知道那些被返回的错误条件,也不知道这些错误需要被处理,同时也很难记住哪个返回值对应哪种故障状态。异常可以比null或−1携带更多的信息,它们可以被命名,并有堆栈跟踪、行号和错误消息。
例如,在Python中ZeroDivisionError返回的信息要比None返回的信息多得多,如代码清单4-2所示。
代码清单4-2
Traceback (most recent call last): File "< stdin>", line 1, in < module> Zero Division Error: integer division or modulo by zero
在许多语言中,需要检查的异常可以从方法签名中看到,如代码清单4-3所示。
代码清单4-3
// Go语言中Open方法会清晰地包含返回的错误类型 func Open(name string) (file File, err error)
// Java语言中Open方法会明确地抛出一个IOException异常public void open (File file) throws IOException
Go语言中的错误声明和Java语言中的异常声明都会清楚地标识出,开放方法可能会引发哪些需要被处理的错误。
4.1.6 异常要有精确含义
精确的异常使代码更容易使用。尽可能地使用内置的异常,避免创建通用的异常。使用异常处理来应对故障,而不是控制应用程序的运行逻辑。
大多数语言都有内置的异常类型(如FileNotFoundException、AssertionError、NullPointerException等)。如果一个内置的异常可以描述问题,就不要创建自定义的异常。开发人员有经验去处理现有的异常类型,他们会知道这些异常具体是什么意思。
当你创建自己的异常时,不要把它们弄得太通用。通用的异常很难处理,因为开发人员并不知道他们正面临什么样的具体问题。如果开发人员没有得到已发生错误的精确信息,他们就会迫使整个应用程序以失败结束,这将是一个重大的动作。对于你引发的异常类型的描述要尽可能具体,这样开发人员就能对程序失败做出适当的反应。
也不要在应用程序的运行逻辑中使用异常。你应该希望你的代码是不出人意料的,而不是聪明的。使用异常来跳出方法常常令人困惑,并且使代码难以调试。
代码清单4-4所示的是,该Python的样例使用FoundNode-Exception而不是直接返回找到的节点。
代码清单4-4
def find_node(start_node, search_name):
for node in start_node.neighbors:
if search_name in node.name:
raise Found Node Exception(node)
find_node(node, search_name)
不要这样做,直接返回节点即可。
4.1.7 早抛晚捕
遵循“早抛晚捕”的原则来处理异常。“早抛”意味着在尽可能接近错误的地方引发异常,这样开发人员就能迅速地定位相关的代码。等待抛出异常会使我们更难找到错误实际发生的位置。当一个错误发生之后,却在抛出异常之前执行了其他代码,你就有可能触发第二个错误。如果第二个错误抛出了异常,你就不知道第一个错误其实已经发生了。跟踪这类错误是令人“抓狂”的——你修复了一个bug,却发现真正的问题出在上游。
“晚捕”意味着在调用的堆栈上传播这个异常,直到你到达能够处理异常的程序的层级。假想一个应用程序试图向一个已满的磁盘写入数据,下一步操作有许多可能性:阻塞和重试,异步重试,写入另 一块不同的磁盘,提醒用户甚至程序崩溃。适当的反应取决于该应用程序的具体情况。一个数据库的预写日志必须被写入,而一个文字处理程序的后台可以延迟保存。能够在上述选择中做出决定的代码段很可能与遇到磁盘已满情况的底层类库中间相差了好几层,所有的中间层都需要将异常向上传播,而不是试图过早地进行补救处理。最糟糕的过早补救是“吞下”一个你无法处理的异常,这通常表现为catch代码块会自行忽略它,如代码清单4-5所示。
代码清单4-5
try {
// ...
} catch (Exception e) {
// 无视这个异常,因为我对它什么也做不了
}
代码清单4-5所示的异常不会被记录或者重新抛出,也不会触发任何其他的行动,它被完全忽略了。程序失败会被隐藏起来,它可能会造成灾难性的后果。当调用可能抛出异常的代码时,要么完全地处理它们,要么将它们在堆栈中进行传播。
4.1.8 智能重试
面对一个错误时,适当反应往往是简单地再试一次就好。在调用远程系统时,正常的计划就应该是偶尔要多尝试几次。重试一个操作听起来很简单:捕捉异常并重试该操作。但在实践中,决定何时重试以及重试的频率都需要一些技巧。
最单纯的重试方法是捕捉到一个异常马上就进行重试。但如果重试的操作再次失败了怎么办?如果一个磁盘空间耗尽,它很可能在10毫秒后仍然没有剩余可用的空间,再来10毫秒也是如此。一遍又一遍单纯地重试会使事情处理变慢,也会使系统更难以恢复。
谨慎的做法是使用一种叫作“退避”(backoff)的策略。退避会非线性地增加休眠时间(通常使用指数退避,如(retry number)^2)。如果你使用这种方法,请确保将退避时间限定在某个最大值内,这样它就不会变得太大。然而,如果一个网络服务器发生了一起突发事件,而且所有的客户端也都同时经历了这起突发事件,那么就用同样的方法进行退避。他们都会在同一时间重新发出请求,这被称为“惊群效应”。许多客户端同时发出重试请求,这会使正在恢复的服务器重新关停。为了处理这个问题,可以在退避策略中加入抖动。有了抖动,客户端就会给退避增加一个随机的、有限制的时间。引入随机性可以分散请求,降低发生“踩踏”的可能性。
不要盲目地重试所有失败的调用,尤其是那些写入数据或可能触发一些业务流程的调用。最好是让应用程序在遇到其在设计时没有预想到的错误时崩溃,这被称为“快速失败”。如果你引入了快速失败的机制,就不会造成进一步的损害,而且人们还可以找出正确的行动方案。确保不仅要快速地,而且还要显式地失败。相关的信息应该可见,这样调试起来就较容易。
4.1.9 构建幂等系统
在故障发生之后,系统处于什么状态并不总显而易见。如果在进行远程写入请求时网络发生故障,那么在故障发生前请求是否成功了?这就使你陷入了两难困境: 险,还是选择放弃并冒丢失数据的风险?在一个计费系统中,重试操作可能会向客户加倍收费,而不重试可能意味着根本不向他们收费。有时你可以通过读取远程系统来检查,但并不总是生效。本地状态的突变也会出现类似的问题。非事务性的内存数据结构的突变会使你的系统处于不一致的状态。
处理重试的最好方法是构建幂等系统。一个幂等的操作是可以被进行多次并且仍然产生相同结果的操作。将一个值添加到一个集合中就是一个幂等操作。无论该值被添加了多少次,只要它被添加过,它就在集合中存在。通过允许客户端单独为每个请求提供一个唯一ID的方式,远程API就可以变为幂等API。当客户端重试时,它提供的唯一ID与失败时的相同。如果该请求已经被处理过了,服务器可以移除重复的请求。让你的所有操作都成为幂等操作,这可大大简化系统的交互,同时也可消除一大类潜在的错误。
4.1.10 及时释放资源
当故障发生后,要确保清理所有的资源,释放你不再需要的内存、数据结构、网络套接字和文件句柄。操作系统对文件句柄和网络套接字有一段固定的预留空间,一旦超过了,所有新的句柄和套接字都无法打开。所谓网络套接字泄露,是指在使用后没有关闭它们。网络套接字泄露会使无用的连接一直存在,从而填满连接池。代码清单4-6所示的代码片段就很危险。
代码清单4-6
f = open('foo.txt', 'w') # ... f.close()
在f.close()之前发生的任何故障将阻止关闭文件指针。如果你的编程语言不支持自动关闭,请将你的代码包裹在一个try/ finally代码块中,这样即使发生了异常也能安全地关闭文件句柄。
许多现代语言都有自动关闭资源的特性。Rust会在对象离开范围 时调用一个析构方法来自动关闭资源;Python的with语句会在调用路径离开代码块时自动关闭句柄,如代码清单4-7所示。
代码清单4-7
with open('foo.txt') as f
4.2 关于日志的使用
当你在终端上第一次写出“Hello, world!”时,你就是在输出日志。输出日志信息对理解代码或调试一个小程序来说既简单又方便。对于复杂的应用程序,编程语言有精良的日志类库,让运维人员对要记录的内容和时间有更多的控制。运维人员可以通过修改日志级别来调节输出日志的总量,并控制日志格式。日志框架还可以注入上下文信息,诸如线程名、主机名、ID,你可以在调试的时候使用这些信息。日志框架与日志管理系统可以很好地配合,这种系统可以聚集日志信息,所以运维人员可以过滤并搜索它们。
使用一个日志框架可以让你的代码更容易操作和调试;设置日志级别可以使运维人员能够控制你的应用程序的日志量。保持日志的原子性、快速性和安全性。
4.2.1 给日志分级
日志框架设有日志级别,它可以让运维人员根据重要性过滤消息。当运维人员设置了某个日志级别后,所有处于该级别或高于该级别的日志都会被发出来,而低于该级别的日志则会被过滤掉。日志级别通常可以通过全局配置和对包或类级别的覆写来控制。日志级别可以让运维人员根据特定的情况来调整日志量,从极其详细的调试日志到正常操作的稳定的背景常规输出。
例如,代码清单4-8是一段Java的log4j.properties片段,它为来自com.foo.bar包空间的日志定义了一个ERROR级别的根日志配置和INFO。
代码清单4-8
将根日志配置为ERROR级别,并使用别名为fout的文件追加
方式进行输出 log4j.rootLogger $=$ ERROR,fout
你必须为每条日志消息提供一个适当的严重程度,这样日志级别才有用。虽然日志级别并没有完全统一的标准,但下面的分级很常见。
TRACE:这是一个极其精细的日志级别,只对特定的包或类开放,在开发阶段之外很少使用这个级别。如果你需要逐行的日志或数据结构临时信息,那么可以使用这个级别。如果你发现自己经常使用TRACE,那你应该考虑用一个调试器来代替它去检查代码。
DEBUG:这个日志级别多用于那些只在调查产品出故障时有用,但在正常操作中没有用的日志。如果输出了很多这个级别的日志,可以将这些日志调整到TRACE级别。
INFO:这个日志级别一般用于输出应用程序运转良好的日志,不应该用于输出任何问题的指示。像“服务开始”和“在端口5050上监听”这样的应用程序的状态信息可以应用这个日志级别。INFO是默认的日志级别。不要用INFO级别发出无意义的日志,“以防万一”类的日志应该放在TRACE或DEBUG中。INFO级别的日志应该在正常操作中告诉我们一些有用的信息。
WARN:这个日志级别一般用于提示那些潜在问题。一个资源已经接近其容量上限,就应该是一个WARN。每当你记录一个WARN时,应该对应一个你希望看到这个日志的人去采取的具体行动。如果这个WARN没有可操作性,就应该把它记录到INFO级别。
ERROR:这个日志级别表明正在发生需要注意的错误。一个无法写入的数据库通常需要一个ERROR日志。ERROR日志应该足够详细,以便诊断问题。记录明确的细节,包括相关的堆栈信息和软件正在执行的操作。
FATAL:这属于“最后一搏”类型的日志信息。如果程序遇到非常严重的情况,必须立即退出,就可以在FATAL级别上记录关于问题原因的信息。应包括该程序状态的上下文内容,恢复或诊断相关数据的位置也应该被记录下来。
代码清单4-9所示为一个用Rust语言发出的INFO级别日志。
代码清单4-9
info!("Failed request: {}, retrying", e);
这行日志包括导致请求失败的错误信息。使用INFO级别是因为应用程序会自动重试,并不需要运维人员去操作。
4.2.2 日志的原子性
如果某些信息只有在与其他数据配合时才有用,那么就应该把所有相关内容“原子化地”记录到一条消息中。所谓原子日志,就是指在一行消息中包含所有相关的信息。原子日志与日志聚合器搭配使用更方便。不要假设日志会按照特定的顺序被看到,许多操作工具会重新排序,甚至弃用一些消息。不要依赖系统的时间戳来排序,系统时钟可能被重置或来自不同的主机,从而造成日志信息难以理解。避免在日志信息中使用折行,许多日志聚合器会把每一个新行当作一串单独的消息。要特别确保堆栈跟踪被记录在一条消息中,因为它们在输出时经常包含折行。
代码清单4-10所示为一个非原子性日志消息的例子。
代码清单4-10
2022-03-19 12:18:32,320 – appLog – WARNING – 请求失败: 2022-03-19 12:18:32,348 – appLog – INFO – 用户登入 : 986 无法从管道中读取内容。 2022-03-19 12:18:32,485 – appLog – INFO – 用户登出 : 986
如果在WARNING日志的参数中出现了一个折行,就会使得这段日志很难阅读。WARNING日志的后续行将会没有时间戳,并且还会与来自另一个线程的其他INFO日志混在一起。WARNING日志应该被“原子化地”写成一行。
如果日志信息不能以原子化的方式输出,可以在消息中放置唯一的ID,这样日志信息就可以在后续的处理中被拼接起来。
4.2.3 关注日志性能
过度的日志记录会损害性能。日志必须被写入像磁盘、控制台或者某个远程系统这样的地方。在写入日志前,要记得处理好字符串的拼接和格式化。用参数化的日志输入及异步附加器来保持快速记录日志。
你会发现字符串的拼接效率非常低,在性能敏感的循环中甚至可能产生“毁灭性”的影响。当一个串联的字符串被传递到一个日志方法中时,无论其详细程度如何,拼接都会发生,因为参数在被传递到方法之前就已经被求值了。日志框架提供了延迟字符串拼接的机制,直到字符串需要被实际拼接在一起时才会真正执行。一些框架将日志信息强制转化为闭包,除非日志行被调用,否则不会被求值,而其他框架则为字符串提供参数化的支持。
例如,在Java中有3种在日志调用时拼接字符串的方法,其中两种在调用trace方法之前就会完成字符串参数的拼接,如代码清单4-11所示。
代码清单4-11
while(messages.size() > 0) {
Message m $=$ message.poll();
// 该字符串即便在 trace 方法未被激活时也会被拼接起来
log.trace("got message: " + m);
// 该字符串在 trace 方法未被激活时也会被拼接起来
log.trace("got message: {}".format(m));
// 该字符串只有在trace方法被激活的时候才会拼接在一起。
// 这样更快
log.trace("got message: {}", m);
}
最后的例子调用了一个参数化的字符串,只有当日志行被实际写入时才会被求值。
你也可以使用附加器来管理性能影响。附加器可以将日志发送到不同的位置:控制台、文件或远程日志聚合器。默认的日志附加器通常在调用者的线程中操作,与调用print的方式相同。异步附加器在写日志信息时不会阻塞执行线程,这提高了性能,因为应用程序代码不需要等待日志被写入之后再执行。分批写入式附加器在日志被写入磁 盘之前会在内存中缓冲日志信息,从而提高写入吞吐量。操作系统的分页缓存也可以通过充当缓冲器的方式来提高日志的吞吐量。虽然异步附加器和分批写入附加器提高了性能,但如果应用程序崩溃,它们也可能会丢失日志信息,因为并不是所有的日志都能保证被释放到磁盘上。
请注意,改变日志的冗余度和配置可以消除竞争条件和bug,因为它降低了应用程序的速度。如果你启用冗余的日志等级来调试一个问题,并发现一个bug消失了,日志等级的变化本身可能就是原因。
4.2.4 不要记录敏感数据
处理敏感数据时要万分小心。日志信息不应该包括任何私人数据,如密码、安全令牌、信用卡号码或电子邮件地址。这似乎是显而易见的,却很容易出问题,比如简单地记录一个URL或HTTP响应,就可能暴露出日志聚合器未设置为保护状态。大多数框架支持基于规则的字符串替换和编辑,要配置它们,但不要依赖它们作为你的唯一防护手段。要有较真的精神,因为记录敏感数据会产生安全风险并违反隐私法规。
4.3 系统监控
用各种系统指标来监控你的应用程序,看看它在做什么。系统指标相当于日志的数值,它们能反映出应用程序的行为。一个查询花了多长时间?一个队列里有多少个元素?有多少数据被写入磁盘?监控应用程序的行为有益于发现问题,对调试很有用。
有3种常见的系统指标类型:计数器、仪表盘和直方图。这些类型的名称在不同的监控系统中很相似,但并不完全一致。计数器测量的是某个事件发生的次数,通过使用计数器获得缓存命中数和请求总数,你就可以计算出缓存命中率。计数器只在进程重新启动时增加数值或被重置为0(它们是单向递增的)。仪表盘是一个基于时间点的测量值,它既可以上升又可以下降。想一想汽车上的速度表或油量表。仪表盘揭示了诸如队列大小、堆栈长短或map中键值对的总数等 统计数据。直方图根据事件的大小幅度分成不同的范围。每一个范围都会有一个计数器,每当某事件的值落入其范围时,计数器就会递增。直方图通常用来测量请求所需的时间或数据有效负载的长度。
系统性能通常以阈值百分比的形式来衡量,例如,从 $0%$ 到 $99%$ ,被称为P99。一个所谓P99耗时2毫秒级别的系统需要2毫秒或更少的时间来响应它所收到的99%的请求。百分数是由直方图得出的。为了减少需要跟踪的数据,一些系统会要求你去配置你真正关心的响应比例。如果一个系统默认跟踪P95,但你有一个P99的服务等级目标(service level objective,SLO),确保可以修改相应的系统设置。
应用程序的系统指标可以被汇总到一个集中式可视化系统中,如Datadog、LogicMonitor或Prometheus。可视化是控制论中的一个概念,即通过观察一个系统的输出结果来确定其状态的难易程度。可视化系统可以在聚合指标之上提供面板和监控工具,这样可以更容易确定一个正在运行的应用程序的状态。面板向运维人员展示了系统中正在发生的事情,而监控工具则可以根据指标值触发警告。
系统指标也被用来自动地进行系统扩容或缩容。系统资源的自动伸缩在提供动态资源分配的环境中很常见。例如,云主机可以通过监测负载指标来自动调整运行实例的数量。自动伸缩在需求增加时扩充 服务器容量,并在以后减少冗余资源以节省资金。
为了跟踪SLO,你可以使用可视化系统,同时也要利用自动伸缩的特性,所以你必须监控一切。使用标准的系统指标库来跟踪这些值,大多数应用程序框架都会提供这些系统指标。作为一名开发者,你的工作是确保重要的指标可以被可视化系统收集与呈现。
4.3.1 使用标准的监控组件
虽然计数器、仪表盘和直方图都很容易实现,但不要推出你自己的系统指标库。非标准库是“维护噩梦”,标准库可以与其他一切“开箱即用”的东西集成。你的公司可能有一个他们更中意的系统指标库,如果他们有的话,就使用现有的;如果他们没有,可以开始讨论选择采用某一个。
大多数可视化系统提供了一系列语言的系统指标的客户端。我们将在一个简单的Python网络应用程序中使用StatsD客户端来展示系统指标库的例子。系统指标的标准库看起来都很相似,所以我们的例子应该几乎可以逐字地翻译成你使用的任何库。
代码清单4-12中的Python网络应用程序有4个方法:set、get、unset和dump。set和get方法简单地设置和检索存储在服务中的map的值,unset方法从map中删除键值对,dump对map进行JSON编码并返回。
代码清单4-12 在Python Flask中使用StatsD客户端系统指标库的例子
import json from flask import Flask, jsonify from statsd import StatsClient
statsd $=$ StatsClient() map $\begin{array}{r l}{\mathbf{\Sigma}}&{}=\ \end{array}{\begin{array}{r l}\end{array}}$
@app.route('/set/< k>/< v>') def set(k, v):
""" 设置一个键的值,当该键有值时就覆盖原有的值。"""
map[k] = v
statsd.gauge('map_size', len(map))
@app.route('/get/< k>') def get(k):
""" 当某个键有值时,返回该键的值。否则,就返回 None 。 """
try:
v = map[k]
statsd.incr('key_hit')
return v
except KeyError as e:
statsd.incr('key_miss')
return None
@app.route('/unset/< k>') def unset(k):
""" 删掉某键的值。如果该键不存在,就什么都不做。"""
map.pop(k, None)
statsd.gauge('map_size', len(map))
@app.route('/dump')
def dump():
""" 将该map编码成JSON字符串,并返回该字符串。"""
with statsd.timer('map json encode time'):
return jsonify(map)
这个例子使用计数器key_hit和key_miss来跟踪statsd. incr内部匹配成功和失败的情况。计时器(代码清单4-12中的statsd.timer)记录将map编码成JSON所需的时间,这将被添加到时间直方图中。序列化是一个昂贵的、CPU密集型的操作,所以它花费的时间应该被记录下来。仪表盘(代码清单4-12中的statsd.gauge)测量map的当前大小。我们可以使用计数器上的递增和递减方法来跟踪map大小的变化,但只使用这个仪表盘不容易出错。
像Flask这样的Web应用框架通常会为你计算很多系统指标。他们大多数会计算Web服务中每个方法被调用时发生的所有HTTP状态码,并为所有的HTTP请求计时。使用框架自带的系统指标库是一个免费获得大量系统指标的好方法,因为你只需配置框架,然后把结果输出到你的可视化系统中。另外,你的代码也会更干净,因为所有指标的计算都发生在底层。
4.3.2 测量一切
监测的性能开销很低,你应该广泛地使用这些监测数据。监测以下所有的数据结构、操作和行为:
● 资源池; 缓存;● 数据结构;● CPU密集型操作;● I/O密集型操作;● 数据大小; 异常和错误; 远程请求和响应。
使用仪表盘来监测资源池的大小,要特别注意线程池和连接池。资源池使用过大表明系统此刻的响应很卡顿或无法跟上需求速度。
计算高速缓存的命中数和失误数,两者比率的变化会影响应用程序的性能。
用仪表盘监测关键数据结构的大小,数据结构大小的异样表明正在发生一些奇怪的事情。
为CPU密集型操作计时。要特别注意数据的序列化操作,它的性能开销高得令人吃惊。一个简单的数据结构的JSON-encode往往是代码中开销最高的操作。
磁盘和网络I/O操作是缓慢和不可预知的,使用计时器来监测它们所需的时间。监测你的代码所处理的数据的大小,跟踪远程过程调用(remote procedure call,RPC)有效载荷的大小变化。可以使用直方图(类似于计时器)去表现I/O产生的数据的大小,这样你就可以看到P99的性能指标了。大体量的数据对内存占用、I/O速度和磁盘使用都有影响。
计算异常、错误响应代码和不良输入的次数,监测错误的出现频 率可以在出错时很容易触发警报。
监测任何提交至你的应用程序的请求,高到不正常或低到不正常的请求数都是信号,表明有什么地方不对劲儿。用户希望你的系统能够快速响应,所以你需要监测系统延迟的程度。对所有的响应进行计时,以便你知道你的系统什么时候会变慢。
花点儿时间了解你的系统指标库是如何工作的。某个类库如何计算一个指标并不总是显而易见的,因为许多类库会对测量进行抽样。抽样可以保持快速的性能,减少磁盘和内存的使用,但它也会使测量的准确性降低。
4.4 跟踪器
开发人员都知道堆栈跟踪,但此外还有一种他们不太熟悉的类型:分布式调用跟踪。对上游API的一次调用可能会导致对下游的数百次不同服务的RPC调用。分布式调用跟踪将所有这些下游调用连接成一个图。分布式跟踪对于调试错误、监测性能、理解依赖关系和分析系统成本(哪些API的服务成本最高、哪些消费者线程成本最高等)都很有用。
RPC客户端会使用一个跟踪库,在他们的请求上附加一个调用跟踪ID。下游服务的后续RPC调用也会附加同样的调用跟踪ID。这些服 务随后报告他们收到的调用请求,以及调用跟踪ID和其他数据,诸如元数据标签和处理时间。会有一个专门的系统记录所有这些报告,并通过调用跟踪ID将这些调用跟踪拼接起来。有了这些信息,跟踪系统就可以呈现出完整的分布式调用图。
调用跟踪ID通常通过RPC客户端包装器和服务网格自动为你传播,用来验证你在调用其他服务时是否传播了任何需要的状态。
4.5 配置相关注意事项
应用程序和服务应该暴露出配置信息,并允许开发人员或网站稳定性工程师(site reliability engineers,SRE)配置运行时的行为。应用配置的最佳实践将使你的代码更容易运行。不要太有创意,要使用标准的配置格式,提供合理的默认值,校验配置的输入值,并尽可能地避免动态配置。
配置可以用许多方式来表达:
普通的、对人友好的格式的文件,如INI、JSON或YAML;
环境变量;
● 命令行参数;
定制的领域特定语言(DSL);
应用程序所使用的语言。
对人友好的配置文件、环境变量和命令行参数是最常见的方法。当有很多参数需要配置或者希望对配置进行版本控制时,就会使用配置文件这种方式;环境变量很容易在脚本中设置,而且检查和记录环境变量都很容易;命令行参数很容易设置,并且在ps等进程列表中可见。
当配置需要可编程的逻辑时,如for循环或if判断,DSL很有帮 助。基于DSL的配置通常针对的是用DSL(如Scala)编写的应用程序。使用DSL而不是完整的编程语言,作者就可以为复杂的操作提供便利,并将配置限制在安全的值和类型上,这对安全和启动性能是一个重要的考虑。但是,DSL很难使用标准工具进行解析,这使得它与其他工具很难互相操作。
用程序的语言来处理配置,这通常发生在程序是用Python这样的脚本语言来编写的时候。使用代码来生成配置的方式虽然很强大,但也很危险。可定制的逻辑会掩盖应用程序所见到的配置。
4.5.1 配置无须新花样
配置系统应该有固定的方式。某个在凌晨3点被叫起来的运维人员不应该需要记住Tcl语法来改变某个超时的值。
针对配置系统进行创新是很诱人的。配置对大家来说都很常见,简单的配置系统似乎缺少有用的特性,诸如变量替换、if语句等。许多有创意的好心人花了大量的时间来制作花哨的配置系统,可悲的是,配置方案越聪明,bug就越奇怪。不要在配置上搞新花样,而应该使用最简单、有效的方法。理想状态应该是单一标准格式的静态配置文件。
大多数应用程序是通过一个静态的配置文件来进行配置的,在应用程序运行时改变该文件不会影响到应用程序。要想让变更的配置生效,往往需要重新启动应用程序。当某个应用程序需要重新配置但不能重启时,就会用到动态配置系统。动态配置通常存储在一个专门的配置服务中,当某些值发生变化时,配置服务应该被轮询或主动推送。或者,动态配置是通过定期检查本地配置文件的更新来刷新自己 的。
通常动态配置带来的收益往往比不上它引入的复杂性,你需要仔细考虑运行过程中因为各种配置变化而产生的所有影响。它还会使你更难跟踪哪项配置被改变了,谁改变了它,以及它的值是什么,这些信息在调试运维问题时可能是至关重要的。它还会增加对其他分布式系统的外部依赖性。这听起来很简单,但重新启动一个进程来获取新的配置通常在操作上和架构上都更好一些。
不过,有一些常见的情况确实需要动态配置,如日志的分级经常是动态配置,当有奇怪的事情发生时,运维人员可以将日志级别改为更高的配置,如DEBUG。当某个进程出现奇怪的行为时,重新启动这个进程可能会改变你要观察的那个奇怪行为。改动某个正在运行的进程的日志级别,可以让你在不重新启动的情况下对它的行为一探究竟。
4.5.2 记录并校验所有的配置
在程序启动时立即记录所有(非秘密的)配置,以显示应用程序正在获取哪些值。开发人员和运维人员偶尔会误解一个配置文件应该放在哪里,或者多个配置文件是如何合并的。记录配置的值可以向用户显示应用程序是否获取了预期的配置。
始终在加载配置的值时对其进行校验。只做一次校验,并且尽可能早地进行(就在配置加载完之后)。确保配置的值都被设置成了适当的类型,例如端口应该为整数,并检查这些值是否有逻辑意义,比如检查边界、字符串长度、有效的枚举值等。−200是一个整数,但不是一个有效的端口。利用拥有强大数据类型的配置系统来表现可接受的配置值。
4.5.3 提供默认值
如果用户不得不配置大量的参数,你的系统将很难运行起来。提供良好的默认值,这样你的应用程序对大多数用户来说开箱即用。如果没有配置端口,应该默认大于1024的网络端口,因为更小的端口会受到限制。如果没有指定目录路径,那么就使用系统的临时目录或用户的主目录。
4.5.4 给配置分组
应用程序配置很容易变得难以管理,特别是不支持嵌套语法的键值格式。可以使用像YAML这样允许嵌套的标准格式。将相关属性分组,这样就更容易组织和维护配置信息。
将紧密耦合的参数(如超时时间和单位)组合在一个结构中,这样它们的关系就很清楚了,并迫使运维人员原子化地声明这些值。与其定义timeout_duration $=$ 10 time out units $=$ second,不如使用timeout $=$ 10s或timeout: { duration: 10, units $=$ second }。
4.5.5 将配置视为代码
配置即代码(configuration as code,CAC)的哲学认为,配置应该受到与代码同样严格的要求。配置错误可能是灾难性的,一个错误的整数或缺失的参数就可以毁掉一个应用程序。
为了保证配置变化的安全,配置应该被版本控制、评审、测试、构建和发布。将配置保存在像Git这样的VCS中,这样你就有了变更的历史。应该像评审代码一样评审配置的变化,验证配置的格式是否正确、是否符合预期的类型和配置的值是否在理论范围内,构建和发布配置包。我们将在第8章介绍更多关于配置交付的内容。
4.5.6 保持配置文件清爽
干净、清爽的配置对其他人来说更容易理解和改变。删除不使用的配置,使用标准的格式和间距,不要盲目地从其他文件中复制配置(一个被称为船货崇拜的例子:在没有真正理解它们的作用或原理的情况下就复制东西)。当你处于快速迭代的阶段,很难维护整洁的配置,但错误的配置会导致生产环境的被迫中断。
4.5.7 不要编辑已经部署的配置
避免在特定的某台计算机上手动编辑配置。配置的一次性修改会在随后的部署中被覆盖,不清楚是谁做的修改,而且配置相似的计算机最终会出现分歧。
就像保持配置文件的清爽一样,在生产环境中抵御手动编辑配置文件的诱惑非常困难,而且在某些情况下是不可避免的。如果你在生产事故中手动编辑配置,请确保所做的更改随后会被提交到真正的源(如VCS)。
4.6 工具集
可维护的系统通常会带有可以帮助运维人员去运行应用程序的工具。运维人员可能需要批量地加载数据、运行恢复、重置数据库状态、触发集群选举,或将分区分配从一台计算机转到另一台计算机。系统应该配备工具,帮助运维人员处理常见的操作。
编写工具是协作性的。在某些情况下,你将被期望编写和提供运维工具。拥有强大的SRE团队的组织也可能为你的系统编写工具。不管怎么样,与你的运维团队合作,了解他们需要什么。
SRE通常会喜欢基于命令行界面(command line interface,CLI) 的工具和自描述的API,因为它们很容易脚本化,脚本化的工具很容易实现自动化。如果你打算构建一个基于用户界面的工具,那就把逻辑抽象成一个共享库或服务,这样基于CLI的工具也可以使用。把你的系统工具当作代码一样对待:遵循干净整洁的编码规范,并进行严格的测试。
你的公司可能已经拥有了一个现成的工具集,例如,拥有一个标准的内部网络工具框架是很常见的。将你的工具集成到你可用的标准框架之上,寻找单一的“玻璃窗”(即统一的管理控制台)。拥有统一管理控制台的公司会期望所有的工具都能与之集成。如果你的公司已经拥有基于CLI的工具,问问将你的工具与之整合是否有价值。每个人都习惯于现有的工具界面,与它们集成将使你的工具更容易使用。
亚马逊让互联网崩溃
2017年2月28日,当克里斯发现视频会议软件Zoom停止工作时,他正在办公室的一间会议室里。没有多想,几分钟后他回到办公桌前。然后他注意到,几个主要的网站都表现得很奇怪。这时,他从运维团队那里听说,亚马逊网络服务(Amazon web service,AWS)的S3存储系统出现了问题。许多大型网站都依赖于亚马逊,而亚马逊在很大程度上依赖于S3。这影响了差不多整个互联网。Twitter上开始充斥着“我猜今天不宜出行”和“该回家了”这样的言论。
亚马逊最终发布了一份说明,描述当时发生的情况。一个运维团队正在调查一个计费子系统。一名工程师执行了一条命令,从S3计费池中删除少量计算机。该工程师设置节点数参数时“手滑了”(打错了字)。从节点池中删除的计算机要比预期的多得多,这引发了其他几个关键子系统的全面重启。最终,这导致了一个多小时的故障,影响了许多其他大型公司。
亚马逊的说明很简短却暴露了真相。“由于这起运维事故,我们正在进行一些改变。虽然在实操中删除容量是一个关键操作,但在这个例子中,我们使用的工具允许过多的容量被快速删除。我们已经修改了这个工具,这样可以更缓慢地删除容量,并增加了保障措施,以防止从低于容量最低限度的子系统中删除容量。这将防止一个不正确的输入在未来引发类似的事情。我们也正在评估我们的其他运维工具,以确保我们有类似的安全检查。”
4.7 行为准则
4.8 升级加油站
专门阐述可维护代码的书不多。相反,这些主题出现在许多软件工程类图书的章节中。史蒂夫·麦康奈尔的《代码大全:软件构造之实践指南》(Code Complete: A Practical Handbook of SoftwareConstruction,已由电子工业出版社于2006年引进出版)第8章涉及防御性编程。罗伯特·C.马丁的《代码整洁之道》(Clean Code: AHandbook of Agile Software Craftsmanship,已由人民邮电出版社于2020年引进出版)第7章和第8章涉及错误处理和边界。这些都是方便着手的好地方。
网络上也有很多关于防御性编程、异常、日志、配置和工具的文章。亚马逊构建者库是一个特别实用的资源(可以在亚马逊构建者的主页查看更多实用内容)。
谷歌SRE小组的《Google系统架构解密:构建安全可靠的系统》(Building Secure & Reliable Systems :Best Practices forDesigning, Implementing, and Maintaining Systems ,已由人民 邮电出版社于2021年引进出版)是一个合理建议的宝库,特别是从安全的角度来看。谷歌的《SRE:Google运维解密》(Site ReliabilityEngineering: How Google Runs Production Systems,已由电子工业出版社于2016年引进出版)是所有与网站可靠性有关的典范之作。虽然这本书不太专注于编写可维护的代码,但它仍然是一本必读书。它将让你看到运行生产级别软件的复杂世界。
第5章 依赖管理
2016年3月,当一个名为left-pad的软件包消失后,成千上万的JavaScript项目开始无法编译。left-pad是一个具有单一方法的类库,它只是简单地将一个字符串的左侧填充到某个特定的宽度。几个 基础的JavaScript底层库都依赖于left-pad。同时,许多项目又依赖于这些底层库。由于传递依赖管理具有病毒传播的性质,所以成千上万的开源和商业代码库也都依赖于这个相当微不足道的类库。当这个包被从NPM(JavaScript的node package manager,包管理器)中移除时,很多程序员体会到了“世道艰辛”。
在现有的代码上增加一个依赖似乎是一个简单的决定。“不要重复自己”(Don’t repeat yourself,DRY)是一个通常被教导的原则。为什么我们都要写自己的left-pad?数据库驱动程序、应用程序框架、机器学习包,有许多例子表明你不应该从头开始写某个类库。但依赖关系带来了风险:不兼容的变化、循环依赖、版本冲突和缺乏控制。你必须考虑这些风险以及如何规避它们。
在这一章中,我们将介绍依赖管理的基础知识,并谈论几乎每个工程师的“噩梦”:相依性“地狱”。
5.1 依赖管理基础知识
在我们谈论问题和最佳实践之前,我们必须向你介绍常见的相依性和版本控制概念。
相依性是指你的代码所依赖的代码。在编译、测试或运行期间,所有需要依赖关系的时间周期被称为依赖范围。
依赖关系是在软件包管理或构建文件中声明的:Java的Gradle或Maven 配置,Python 的setup.py 或requirements.txt ,以及JavaScript的NPM所使用的package.json。代码清单5-1所示的是一个Java项目的build.gradle文件的片段。
代码清单5-1
dependencies {
compile 'org.apache.httpcomponents:httpclient:4.3.6'
compile 'org.slf4j:slf4j-api:1.7.2'
}
该项目依赖于4.3.6版的HTTP客户端类库和1.7.2版的SLF4J应用程序接口(application program interface,API)库。每个依赖项都被 声明了一个范围,即compile,这意味着编译代码时需要这些依赖项。每个包都有一个定义的版本,httpclient为4.3.6,slf4j为1.7.2。版本包被用来控制依赖关系的变化,并在同一个包的不同版本出现时解决冲突(后面会详细介绍)。
一个好的版本管理方案,其版本都具有以下特点。
唯一性(unique):版本不应该被重复使用。构件会被分发、缓存,并被自动化工作流拉取。永远不要在现有版本下重新发布更改的代码。
可比性(comparable):版本应该帮助人们和工具对版本的优先顺序进行推断。当一个构建依赖于同一构件的多个版本时,可以使用优先顺序来解决冲突。
信息性(informative):版本信息区分了预先发布的代码和已发布的代码,将构建流水号与构件相关联,并设置了稳定性和兼容性的合理预期。
Git的哈希值或“营销相关”的版本,如Android操作系统的甜点系列(Android Cupcake 、Android Froyo) 或Ubuntu 的动物园(Trusty Tahr、Disco Dingo)都具有唯一性的特点,但它们没有可比性或信息性。类似地,一个递增的版本号(1、2、3)既是唯一的,也是可比较的,但其携带的信息量并不大。
5.1.1 语义化版本
前面例子中的软件包使用了一种叫作语义版本管理(semanticversioning,SemVer)的版本管理方案,这是版本管理中最常用的方案之一。官方的SemVer规范可在其网站中找到。该规范定义了3个数字:主版本号、次版本号和补丁版本号(有时也称作微版本号)。这3个数字被合并为“主版本号.次版本号.补丁版本号”的版本号格式。httpclient版本4.3.6意味着主版本号、次版本号和补丁版本号分别为4、3、6。
语义化版本同时具有唯一性、可比性、信息性。每个版本号只使用一次,可以通过从左到右进行比较(2.13.7在2.14.1之前)。它们提供不同版本之间的兼容性信息,并且可以选择对候选版本或构建流水号进行编码。
主版本号为0被认为是“预发布”,是为了快速迭代,不做任何兼容性保证。开发者可以用破坏旧代码的方式修改API,如新添加一个必要参数或删除一个公共方法。但是主版本号从1开始后,一个项目应该保证以下内容。
● 补丁版本号是递增的,用于修复bug,并且可以向下兼容。● 对于向下兼容的特性,次版本号是递增的。● 对于无法向下兼容的变化,主版本号会被递增。
SemVer还通过在补丁版本号后添加一个“-”来定义预发布版本。小数点分隔的字母和数字的序列被用作预发布版本的标识符(2.13.7-alpha.2)。预发布版本可以在不影响主版本的情况下进行突破性的修改。许多项目使用候选发布版(release candidate,RC)构建。早期采用者可以在正式版本发布之前发现RC中的错误。RC预发布版本有递增的标识符,如3.0.0-rc.1。然后,最终的RC被提升为正式发布版,重新发布的版本没有RC的后缀。所有的预发布版本都会被最终版本(在我们的例子中是3.0.0)所取代。关于发布管理机制的更多信息,请参见第8章。
构建流水号被附加在版本号和预发布元数据之后,如2.13.7-alpha $_{2+1942}$ 。包含构建流水号有助于开发者和工具找到任何版本被编译时的构建日志。
SemVer的方案还允许使用通配符来标记版本范围(2.13.*)。由于SemVer承诺跨小版本和补丁版本的兼容性,即使有更新版本被自动拉取,比如修复bug和新特性,此时构建工作也应该继续进行。
5.1.2 传递依赖
软件包管理或构建文件都揭示了项目的直接依赖关系,但直接依赖关系只是构建或打包系统实际使用的子集。依赖关系通常也依赖于其他类库,这就造成了依赖传递。依赖关系报告可以展示出完全解决的依赖关系树(或依赖关系图)。
大多数构建和打包系统都能生成依赖关系报告。继续前面的例子,如代码清单5-2所示,这是Gradle依赖关系的输出内容。
代码清单5-2
compile - Compile classpath for source set 'main'.
+--- org.apache.httpcomponents:httpclient:4.3.6
| +--- org.apache.httpcomponents:httpcore:4.3.3
| +--- commons-logging:commons-logging:1.1.3
| +--- commons-codec:commons-codec:1.6
| --- org.slf4j:slf4j-api:1.7.2
依赖关系树展示了构建系统在编译项目时实际使用的依赖关系。该报告会说明当前依赖的深度。3层依赖也会被记入报告,如此类推直到所有的依赖都被记入。httpclient库拉入了3个依赖传递:httpcore、commons-logging和commons-codec。该项目并不直 接依赖于这些类库,但通过httpclient,就完成了依赖传递。
了解依赖传递是依赖管理的一个关键部分。增加一个依赖关系看起来似乎是一个小变化,但如果相关类库依赖于其他100个类库,你的代码现在就依赖于101个类库。任何依赖关系的变化都会影响你的程序。确保你知道如何获得像我们例子中的依赖关系树那样的信息,以便你能解决依赖冲突。
5.2 相依性地狱
问问任何一名软件工程师关于相依性地狱的问题,你几乎都会得到一个“悲惨”的故事。同一个类库的冲突版本,或者不兼容的类库升级,都会破坏构建的正常进行并导致运行时失败。比较常见的相依性地狱的罪魁祸首是循环依赖、钻石依赖和版本冲突。
以前的依赖关系报告很简单。一个更现实的报告将展示出版本冲突,并让你可以一窥相依性地狱的情况,如代码清单5-3所示。
代码清单5-3
compile - Compile classpath for source set 'main'.
+--- com.google.code.findbugs:annotations:3.0.1
| +--- net.jcip:jcip-annotations:1.0
| --- com.google.code.findbugs:jsr305:3.0.1
+--- org.apache.zookeeper:zookeeper:3.4.10
| +--- org.slf4j:slf4j-api:1.6.1 -> 1.7.21
| +--- org.slf4j:slf4j-log4j12:1.6.1
| +--- log4j:log4j:1.2.16
| +--- jline:jline:0.9.94
| +--- io.netty:netty:3.10.5.Final
| --- com.mycompany.util:util:1.4.2
| --- org.slf4j:slf4j-api:1.7.21
代码清单5-3 所示的树结构展示了3 个直接的依赖关系:annotations、zookeeper和util。这些库都依赖于其他的类库,这些是它们的交叉依赖关系。报告中出现了两个版本的slf4j- api。util依赖于slf4j-api的1.7.21版本,但zookeeper依赖于slf4j-api的1.6.1版本。
这些依赖关系形成了钻石依赖,如图5-1所示。
图5-1 钻石依赖
一个项目不能同时使用同一个类库的两个不同的版本,所以构建系统必须从中选择其一。在Gradle依赖关系报告中,版本的选择是用注释来提示的,如代码清单5-4所示。
代码清单5-4
| +--- org.slf4j:slf4j-api:1.6.1 -> 1.7.21
1.6.1 -> 1.7.21意味着slf4j-api在整个项目中被升级到1.7.21以解决版本冲突。zookeeper在不同版本的slf4j-api下可能无法正常工作,尤其是因为相关的依赖slf4j-log4j12没有被升级。升级之后应该可行,因为zookeeper依赖关系的主版本号没有变化(SemVer保证在同一主版本号内向下兼容)。在现实中,兼容性是一个“美丽的愿望”。项目经常在没有检查兼容性的情况下就发放版本,即使是自动化也不能完全保证其兼容性。那些无法向下兼容的变化会被不经意地发版成次版本或补丁版本,给你的代码库带来巨大的破坏。
更糟糕的循环依赖(circular dependencies 或cyclic dependencies),即一个库间接性地依赖它自己(A依赖B,而B依赖C,C又依赖A,如图5-2所示)。
图5-2 循环依赖
循环依赖产生了一个“先有鸡还是先有蛋”的问题:升级一个库会破坏另一个库。工具类或辅助类项目通常出现在循环依赖中。例如,一个自然语言处理(natural language processing,NLP)库依赖于一个可以解析字符串的工具类,在不知情的情况下,另一个开发者将NLP库作为工具类的依赖加入一个词干提取的工具方法中。
谷歌集合类的奇怪案例
Java类库被打包成JAR文件。在运行时,Java会搜索其classpath上的所有JAR文件来定位所需要的类。这是一种很不错的机制,直到有一天你发现多个JAR文件中却包含同一个类的不同版本。
LinkedIn有一个叫作Azkaban的工具,这是一个工作流引擎,允许开发者上传代码包,并安排它们在Hadoop上运行。Azkaban是用Java编写的,并没有隔离它自己的classpath,这意味着所有上传的代码在运行时除了他们自己的依赖之外还会有Azkaban的依赖。有一天,运行的任务开始报错,提示出现NoSuchMethodErrors。令人困惑的是,克里斯的团队可以清楚地看到,在上传的软件包中竟然已经存在了那些缺失方法。这些错误有一个共同的模式:所有缺失的方法都来自流行的谷歌公司的Guava库。
Guava提供了许多有用的特性,包括让出了名笨重的Java集合类更容易使用。该团队怀疑Azkaban的类库和上传的包之间存在冲突。不过,事情并没有那么简单。Azkaban根本就没有使用Guava。他们最终意识到,Guava是从另一个库,即Azkaban正在使用的google-collections中演变而来的。Azkaban从google-collections中提取了两个类,ImmutableMap和ImmutableList。Java在Guava之前就发现了google-collections中的引用类,并试图调用在早期版本的类库中不存在的方法。
团队最终隔离了classpath,Azkaban停止向运行环境中添加其JAR文件。这基本上解决了这个问题,但有些运行的任务仍然失败了。然后他们发现那些仍然出问题的软件包同时包含了google-collections Guava。构建系统无法分辨出google-collections是 Guava的旧版本,所以它同时包含了这两个类库,造成了与Azkaban依赖性相同的问题。必须进行大量仔细的重构工作,这使许多工程师偏离了他们的正常工作。仅仅为了一些集合类的帮助方法,这一切值得吗?
5.3 避免相依性地狱
你有很大可能会陷入相依性地狱。因为你无法避免引用依赖,并且每一个新的依赖项都自有代价。问问你自己,添加一个依赖项的价值是否超过了它的成本?
你真的需要这些特性吗?
依赖关系的维护情况如何?
● 如果出了问题,你修复这个依赖有多容易?
● 依赖项的成熟度如何?
● 引用依赖后向下兼容的变化频率如何?
● 你自己、你的团队和你的组织对该依赖的理解程度如何?
● 自己写代码有多容易?
● 代码采用什么样的许可协议?
● 在依赖中,你使用的代码与你不使用的代码的比例是多少?
当你决定添加新的依赖时,请使用以下推荐做法。
5.3.1 隔离依赖项
你不必把依赖管理交给构建和打包系统。依赖相关的代码也可以被复制、被制作成供应商代码或被遮蔽。将代码复制到你的项目中,用依赖管理自动化换取更多的隔离(稳定性)。你将能够准确地挑选你使用的代码,但你必须管理代码的复制。
许多开发者都是在DRY的理念下成长的,它不鼓励代码的重复。要务实,不要害怕复制代码,如果它能帮助你避免一个庞大的或不稳定的依赖关系(并且软件许可协议容许这么做)。
直接复制代码在简短的、稳定的代码片段上效果最好。手动复制整个类库有许多缺点:可能会丢失版本历史,而且每次更新都必须重新复制代码。当供应商代码被整体嵌入时,会使用供应商工具来管理历史和更新,供应商文件夹包含完整的库副本。git-subtree和git-vendor等工具有助于管理你的代码库中的供应商文件夹。一些打包系统,如Go,甚至对供应商文件夹有内置支持。
遮蔽依赖也能达到隔离依赖项的目的。遮蔽依赖会自动将一个依赖关系重新定位到不同的命名空间,以避免冲突,比如将some.package.space变成shaded.some.package.space。这是一种可以防止库将其依赖关系强加给应用程序的友好方式。虽然遮蔽依赖来自Java生态系统,但这个概念的适用范围很广。其他语言如Rust也使用类似的技术。
遮蔽依赖是一种高级技术,应该少用。永远不要在公共API中暴露一个遮蔽依赖的对象,这样做意味着开发者将不得不在遮蔽的包空间(shaded.some.package.space.Class)中创建对象。遮蔽依赖是为了隐藏依赖关系的存在。对于类库的使用者来说,创建一个被遮蔽的对象非常棘手,有时甚至是不可能的。另外,请注意,由于包的名称与在构建构件时不同,遮蔽依赖会使开发人员感到困惑。我们建议只有在你创建一个被广泛使用的依赖项时,才需要对依赖关系进行遮蔽处理,因为这些依赖项可能会产生冲突。
5.3.2 按需添加依赖项
将你使用的所有类库显式声明为依赖项。不要使用来自横向依赖的方法和类,即使它看起来很有效。类库可以自由地改变它们的依赖关系,即使是在补丁级的版本升级中也应如此。在升级过程中,如果你所横向依赖的项目被废止,你的代码将停止工作。
一个只依赖于httpclient库的项目(来自前面的例子)不应该直接使用httpcore、commons-logging和commons- codec(这些都是httpclient的依赖项)中的类。如果要使用的话,就应该声明对这些库的直接依赖。
不要只靠IDE来进行依赖管理,在构建文件中明确声明你的依赖项。IDE通常会在自己的项目配置中存储依赖关系,而构建机制并不会去查看这些依赖项。IDE和构建文件之间的差异会使代码在IDE中正常工作,却无法实际地完成代码构建,反之亦然。
5.3.3 指定依赖项的版本
明确设定每个依赖项的版本号,这种做法称为版本指定(versionpinning)。未被指定的那些将由构建系统或软件包管理系统为你指定版本。把你的命运交给构建系统是个坏主意,当依赖版本在连续构建过程中发生变化时,你的代码就会不稳定。
代码清单5-5所示的代码片段声明了一个带有版本指定的Go库依赖列表。
代码清单5-5
require (
github.com/bgentry/speakeasy v0.1.0
github.com/cockroachdb/datadriven v0.0.0-2019080921
4429-80d97fb3cbaa
github.com/coreos/go-semver v0.2.0
github.com/coreos/go-systemd v0.0.0-20180511133405-
39ca1b05acc7
github.com/coreos/pkg v0.0.0-20160727233714-
3ac0863d7acf
...
)
作为对比,在Apache Airflow的这段依赖项声明中使用了3种不同的版本管理策略,如代码清单5-6所示。
代码清单5-6
flask_oauth $=$ [
'Flask-OAuthlib> $\scriptstyle\cdot=0$ .9.1',
'oauthlib $\rightharpoonup$ .0.3,! $\rightharpoonup$ .0.4,!=2.0.5,< 3.0.0,>=1.1.2',
'requests-oauthlib $==1$ .1.0'
]
requests-oauthlib库被明确地指定为1.1.0,Flask- OAuthlib的依赖项被设置为高于或等于0.9.1的任何版本。而oauthlib库是极其特殊的:1.1.2或更新的版本,但不能高于3.0.0,但也不能是2.0.3、2.0.4或2.0.5。由于已知的错误或不兼容,2.0.3到2.0.5版本被排除在外。
界定版本范围是在无特定范围和特殊指定某个版本之间的一个折中方案。依赖解析系统可以自由地解决冲突和更新依赖项,但面对重大的变动时能力有限。但是,任何未指定版本的依赖项不仅会拉取对bug的最新修复,还会拉取更多的东西,比如它们会拉取最新的bug、软件行为,甚至是不兼容的变化。
即使你明确指定了直接依赖项的版本,横向依赖仍然可能有通配符。可以通过生成一个包含所有已解决的依赖项及其版本的完整清单来明确指定横向依赖项的版本。依赖清单有很多名字:在Python中可以使用pip freeze $>$ requirements.txt命令生成依赖清单,在Ruby中可以生成Gemfile.lock,在Rust中可以创建Cargo.lock。构建系统需要使用依赖清单,这样能保证在每次执行时都会产生相同的结果。当开发者想要更改版本时,他们会明确地重新生成依赖清单。将依赖清单与其他代码一起提交,就可以明确地跟踪任何依赖项的变化,从而有机会防止潜在问题的发生。
为什么Airflow的flask_oauth如此混乱?
前面的代码块中精心设计的依赖项是为了修复Airflow从Flask-OAuthlib库中继承的一个依赖问题。Flask-OAuthlib对oauthlib和requests-oauthlib有自己的无界依赖,这就开始引发问题。Flask-OAuthlib的开发者为oauthlib和requests-oauthlib的依赖项引入了有界范围,以解决这个问题,但他们花了一段时间才发布这个修复。