我的软件需要多少工程量?
过度工程化与工程化不足——防止两者的指南
引言
技术债很糟糕——大家都知道。
你往代码里添加的每一点“意大利面条”,都会让线结越拧越紧,代码的整体概览也越来越糟,直到添加一个简单的按钮都会导致软件的其他部分崩溃。客户的需求被遗忘,因为没有待办事项;日志不再工作,但你不会知道,直到下一个 bug 报告到来,因为没有实现任何测试。为什么要写文档?YAGNI!
另一方面,还有截止日期要赶。
你需要实现一个小工具,只用一次来解析一些测试数据到 csv 文件,但嘿,那里面的那个类可以用一个接口,所以我们先画个 UML 图。那个接口可以继承我们内部库的那个,我们就在里面再加个功能,这样它也能覆盖那个小解析器。然后我们在 wiki 上记录下来。哦,还有,这是一个尝试那个大家都谈论的新版本控制工具的绝佳用例……糟糕。错过了截止日期。
你——和我一样——有这两种极端的同事吗?你试过和他们就他们的观点争论吗?
我试过,但没有多大进展。那位“实用主义先生”告诉你 YAGNI,而那位“完美主义先生”告诉你 SOLID。前者用“对我来说好用”的代码弄乱了你的内部库,而后者则无法及时完成你急需的功能并发布那个漂亮的内部库的新版本。他们都试图说服你,只有他们的方式才是正确的。
我得出的结论是,正确的方式取决于具体情况。由于我没有找到一个关于“取决于什么”的指南,我决定自己写一份。
因此,这里有一份指南,教你如何同时避免过度工程化和工程化不足。
软件分类
首先,软件应该以某种方式进行分类,范围从“赶紧完成”的软件,到“几乎不可能过度工程化”的软件。
我在金属行业工作,建造我们自己的机器用于我们自己的生产,我体会到软件可以有很长的生命周期。有些机器仍然在运行,已经有七十年了(当然,那些仍然有机械控制器)。有些软件还在运行在发布时我几乎还不会走路的 Windows 版本上。当客户——我们公司的生产部门——需要改进他们的生产流程时,可能就需要你来修改那个老旧的 C++ 代码中的一个按钮,而这段代码从来没有人关心去写文档,甚至没有注释,只用了两个字母的变量名。
我的结论是,软件的生命周期越长,你的后继者就越痛恨你每一个 YAGNI 的决定。
当然,这并不是唯一需要考虑的度量。如果你有一个你过去二十年里每隔一天都用一次的单行代码,好吧,它仍然是一行代码。一行代码在代码中应该很容易阅读,并且你作为开发者应该能够不依赖大量解释就读懂它。
由于软件不应以代码行数来衡量,我认为可以通过复杂性来衡量(尽管界限可能模糊),因为复杂性不仅仅取决于代码行数,还取决于目标群体。如果你的用户是其他开发者(例如,你正在编写一个 API),那么需求将更加具有挑战性,结果也会比仅用于特定机器的某种机器软件更复杂。因此,复杂性还取决于人们会再次调用你的方法或函数的频率。
所以,这是我想到的:
1. 类别:赶紧完成
此类别中的“软件”包括你再也不会使用的工具。例如,你写两行代码从服务器下载一些示例数据,或者一个小的解析工具将 XML 数据转换为 CSV 格式,而且你只需要做一次。
通常,这些小工具只是帮助你完成你的实际软件开发。
因此,赶紧做!然后扔掉它。如果你认为“我会保留它,谁知道呢……”,那么你就属于错误的类别。
2. 类别:至少留下文档
也许你每天要用十次简单的工具。你如何找到上个月用来解析 XML 到 CSV 的那个?
好吧,如果你认为“我不会去找它,而是简单地重新做一个”,那么你就属于错误的类别。你不应该存储它。
但如果这个小工具实际上并不容易完成,你应该至少给它一个描述性的名称,并留下一些文档或 README 来说明你的小工具的作用。
如果你打算扩展你的小工具,它肯定会存活太久,不属于这个类别……
3. 类别:妥善存储并留下文档
一个可能每天使用,或者不只你一人使用,并且会存活很长时间的工具,当然应该被妥善地进行版本控制。你(或别人)很可能会有一天根据新情况对其进行调整,有一天你可能会庆幸拥有修改历史。
对于那些生命周期不长但仍可能在其(足够短的)生命周期内进行一些修改的小应用程序或演示工具,也应采用相同的处理方式。
我们仍然在谈论相当小而简单的软件,SOLID 原则应该很容易实现。如果你是那些喜欢测试驱动开发(TDD)的人,这是一个尝试使用它的好类别。
4. 类别:认真思考你在做什么
现在我们进入了一个更有趣的类别。要么是一个小而长寿的应用程序,要么是一个相当正常大小但生命周期不会超过一年的软件。
事实是:你的软件将收到更新,因此需要维护。会有支持问题。你需要仔细考虑在哪里以及如何存储你的代码,以免丢失。测试将是必要的,以确保你的更新不会破坏现有功能。你应该记录软件本身,以及谁做了更改以及为什么。
如果你热衷于 TDD,这个类别是一个很好的选择。
然而,这并不是复杂性阶梯的顶端。例如,抽象层应该只在软件内部真正需要时引入。如果不太可能出现其他具体实现,你就不会在每一步都使用接口。
5. 类别:按照教材教你的那样去做
回想一下你的学校/大学/课程/会议。在那里你做了所有那些有趣的“Hello World!
”示例,并创建了接口和抽象层。你制定了用户故事并跟踪它们。你把所有东西都放入 git 并写了很棒的文档。
好吧,在这个类别中,你需要所有这些东西。这里我们讨论的是相当复杂且长寿的软件。很可能在你离开公司或部门时,这个软件才最终消亡——所以给你后继者留个好地方,就像你希望找到的那样。
6. 类别:无法过度工程化(好吧,几乎是)
所以,如果你来到这里想着“为什么 API 是这里最复杂的东西?”,也许你应该先想想,人们为什么要把东西做成 API。最简单的事情不需要 API。你编写 API 是为了那些在其他软件中被最频繁使用的东西,因此需要最可靠和最充分的测试。或者你为最复杂的事情编写 API,这样其他人就不必再考虑它了,而这——再次——需要最可靠和最充分的测试。
需要对事物进行妥善记录,必须提供示例代码。经验告诉我,如果这些中的任何一个不可用,开发者就不会使用它,会重新发明轮子,错误地使用它——或者因为没完没了的问题而烦扰你。即使你自己也记不住实现的细节,所以照顾好你的工作也是为自己着想。
这样的 API 需要妥善存储并进行备份,需要跟踪以找到搞砸事情的人。
当然,API 也有截止日期。在我目前工作的地方,我们的内部库/API 是“在日常工作之余”完成的,缺乏测试,缺乏文档,缺乏通用最佳实践。所以我可以根据自己的经验告诉你,搞砸一个 API 会导致比你想要的更多(而且相当荒谬)的工作。如果你是一名 API 开发者,你最艰巨的任务是说服你的老板不仅要重视代码,还要重视围绕它的环境。
X - 此类别不存在
那么表格中的这些空白是什么意思?嗯,这类软件根本就不存在。一个只存在几天的 API?那你已经过度工程化了。
一旦 API 或相当复杂的软件发布,人们就会使用它。也许你不知道,但你曾经上传的 GitHub 代码可能已被复制到本地存储库,像我所在公司那样的人们将在其中使用它——可能长达三十年。如果你不为这些用户留下任何文档,难道不感到羞愧吗?或者更糟:未经测试,甚至恶意代码?
你的软件或 API 越复杂,你就越应该谨慎处理它,因为它将长久存在——可能比你愿意承认的还要长。
具体结论
基于这些类别,让我们更进一步,推导出在不同类别中真正需要多少工程量。
我需要多少文档
虽然描述性名称应该始终给定并且成为你的好习惯,但你不需要为“赶紧完成”的工具绘制详细的 UML 图。
代码注释总是值得讨论的——我个人使用 Visual Studio,即使是小型软件,我也保持“XML 文件”复选框勾选状态,以便获得关于未记录的公共功能的编译器警告。公共意味着“可以从外部使用”——任何使用它的人都不应该被迫打开你的代码来找出它的作用,特别是当名称不足以说明时。所以,如果你想“为什么我必须注释这个?”,不如想“为什么我把这个成员设为公共?”。顺便说一句——我不做内联注释,只做公共成员摘要。如果有人需要理解内部逻辑,他反正得阅读代码。
请注意,API 应该始终提供示例代码——对于复杂的软件也是如此,如果它包含复杂的抽象层。请务必记录如何维护和扩展它。你的后继者会因为这个而感激你。
Wiki——无论是为用户还是维护者——都能让你减少支持电话。即使错误消息告诉用户“无法保存。磁盘空间不足。”——经验告诉我们,他们还是会打电话来问该怎么办。为什么,增加磁盘空间或清理!——这正是 Wiki 应该告诉他们的。当然,对于只使用几个小时的工具,你不需要这样做。
尽量保持文档的更新——记录复杂软件应该是开发工作流程的一部分。
测试
你的软件生命周期越长,维护、更改或扩展的频率越高,拥有单元测试就越重要。
随着测试驱动开发(TDD)的流行(现在已经再次减弱),人们倾向于夸大单元测试的作用。没有人会需要对两行代码进行单元测试。单元测试的需求随着软件复杂性的增加而增加,以避免破坏现有功能。API 应该始终进行单元测试。
存储
为了存储软件,无论是大的还是小的,简单的还是复杂的,你应该始终使用一个存储库,以便能够跟踪和回滚更改。将存储库本身存储在备份区域也应该是理所当然的。
唯一不需要存储在存储库中的是现在——而且只现在——你需要的一个工具。
开发方法
一些原则——例如 SOLID——应该成为你的习惯,因此对你来说很容易。当然,对于两行代码,你不需要在不同的类中进行广泛的拆分——但任何更大的东西都应该结构良好。
对于真正复杂的软件甚至 API,你应该在开始编码之前规划一个基本的应用程序架构。接口和抽象层的用法应限制在真正被多次使用的简单软件功能中,而应成为复杂软件或 API 的默认选项。特别是在后者中,任何可能需要其他编码人员进行附加实现的内容,都应该通过接口来准备。
现在再次谈论 TDD……你可以在表格中看到我不是那些狂热追捧者。虽然 TDD 可以成为小型和中型软件的绝佳方法,但整体架构常常被狂热的开发者所忽视。特别是对于高度复杂的 API,应该是“架构优先”,而不是“测试优先”,也不是“代码优先”。当然,TDD 可以更容易地证明实施单元测试所需的工作量,因为它只是开发工作流程的一部分。但是,如果你需要 TDD 来说服你的老板,那么测试是一件重要的事情,你面临的问题比仅仅测试要大得多。
同样,再次强调,对于两行代码,永远不需要单元测试。
软件环境
最后一点是围绕你的软件的世界。
如今,API 应该始终通过 NuGet / npm 等方式交付,即使它们只是用于内部使用(然后使用私有管理系统)。使用自己的 API 对其他编码人员来说,不应该比使用世界其他地方的 API 更困难或不同。
ALM(应用程序生命周期管理)这个话题应该只让你关心那些真正需要回溯的软件。对于那些永远不会更新的工具,因为它们只使用一次,或者生命周期不长的小应用程序,不要增加这种开销。查看存储库提交注释应该足以在此处追溯更改。同样的原则也适用于更新策略。
需求/功能规范与 ALM 几乎相同——如今常常被后者取代。
如果代码发布给最终用户并且会收到更新,那么应该始终进行适当的软件版本控制,以便能够回溯用户在特定版本上遇到的问题。
关于此指南的思考
我仿佛已经听到了“完美主义先生”和“实用主义先生”再次就我的文章争论。
“看,”实用主义先生说,“文章说我们不需要实现单元测试。我只用一次那个工具。”完美主义先生回答:“但我们确实需要一个接口。因为,如果我们添加功能 X,也许有一天我们可以再次将该工具用于用例 Y,所以它是一个长寿的东西……”
当然,要正确应用此指南,需要健康的自我判断。我认为这是那些从未被提及但对项目成功和按时完成截止日期至关重要的软技能之一:判断如何对软件进行分类,从而判断应该投入多少工程量。
当然,我也会犯错,因为截止日期,我的很多 YAGNI 决定都显得有道理,而我一些有益且有文档记录的工具却逐渐被遗忘。我常常不坚持自己的规则,原因可能是我的老板从未听过“技术债”,却梦想着独角兽,或者可能是因为懒惰(或好玩)去做某些事情。
但是,写下这些想法应该能帮助我——也许也能帮助你——记住,过度工程化和工程化不足一样糟糕,而找到两者之间的平衡是我们最重要的日常任务之一。
个人说明
在回顾时,寻找软件类别比看起来要容易。我发现了很多其他的衡量标准,但最终都放弃了。例如,分发范围。每天被半个世界使用,例如,一个下载量达五十万的闹钟应用,难道不应该进行单元测试吗?嗯,如果它只有两行代码,确实,不——我认为。
我很想听听你对此的看法。你认为我应该考虑其他衡量标准吗?你觉得我的观点是否过于严格/过于开放?你是哪种开发者?实用主义者 vs. 完美主义者?试着评价一下自己,告诉我吧!
这是我发布的第一个帖子,所以请不要对我太苛刻。英语也不是我的母语。