本文是彭博社的一位开发者所写的文章, 介绍了从一位资深工程师同事的身上学到的一些开发经验.
过去一年中, 我坐在一位资深的软件工程师旁边, 可以仔细地观察他是怎么工作的. 我们两人经常共同编程, 使得这项观察更为容易. 此外, 在团队文化中, 从背后窥探写代码的人并不令人反感. 以下是我所学到的:
一, 编写代码
1. 如何命名
我首先着手的是 React UI. 我们有一个主要组件来放置其他所有的组件. 我喜欢在代码里加点幽默感, 因此我想要将它命名为 GodComponent. 当进入代码审查环境的时候, 我才明白为什么命名这么难.
在计算机科学里有两个难题:内存不足、命名、以及差一 (off-by-one) 错误。
——Leon Bambrick
|
我每个命名的代码段都有隐藏含义在里面. GodComponent 是所有我不必费心去寻找合适位置来存放那些垃圾的地方, 它可以容纳所有东西. 如果我早早把它命名为 LayoutComponent, 之后的我就会发现它所做的就是分配 layout, 没有状态.
我发现命名好的另一个好处是: 如果它看起来太长了, 就像 LayoutComponent 包含了很多业务逻辑层, 我就知道是时候要重构了, 因为业务逻辑层并不属于这里. 如果是以 GodComponent 命名, 这里的业务逻辑层也不会和其他有所区别.
命名你的集群? 以在服务器上运行的服务名称来命名更好, 直到用它们来运行其他服务为止. 我们最终以团队的名字来命名服务器.
在函数上也是同样的道理. doEverything() 是一个糟糕的名字, 会有很多难以预料的后果. 如果这个函数能够做所有事情, 那么在测试函数某个特定部分时将变得非常困难. 因为不管这个函数有多大, 你都不会觉得奇怪, 毕竟这个函数应该做所有的事情. 这时候就需要改名, 重构了.
有意义的命名也有不太好的一面. 如果名字的表意太强, 结果掩盖了一些功能上的细微差别怎么办? 例如: 当你在 SQLAlchemy 中调用 session.close() 时, 这只会关闭会话但不会关闭底层数据库的连接.
在这种情况下, 可以以 x,y,z 来命名而不是 count(),close(),insertIntoDB(), 这样可防止为其赋予隐性含义并强制开发人员仔细检查它所执行的操作.
2. 历史代码和下一名开发者
你曾否看过一些代码, 觉得它们很奇怪? 这些代码为什么这么做呢? 它们的实现一点都不合理.
我曾负责过遗留代码库. 代码中有诸如「当 Mohammad 发现情况时取消注释代码」这类的注释. 这是在做什么? 谁是 Mohammad?
在这里可以做下角色转换 -- 想象下一个人来看我的代码, 他们是否会觉得奇怪?
同行审查可以某种程度上解决代码注释这个问题. 这让我想到了上下文的概念: 注意我团队正处的上下文位置.
如果我忘记了这部分代码, 之后又回到了代码工作上, 没有注释的话我不能重新创建上下文, 我可能只会想:「为什么他们要这么写? 这没有任何意义...... 哦, 等等, 是我写的.」
这里就是开发文档和注释该出现的地方.
3. 文档和注释
文档和注释有助于维护上下文和分享知识.
正如李在《如何构建好软件》中所说,「软件的主要价值不是编写它的代码, 而是编写它的人所积累的知识.」
比如说, 我们有个似乎没有人用过的, 面向随机客户端的 API 终端. 因为这些原因, 我就应该把它删除吗? 毕竟这是一个技术累赘.
如果说, 在某个特定国家, 有 10 名记者会一年一次将他们的报道发送到这个终端, 怎么办? 你如何测试它? 如果没有开发文档 (那时就没有) 就不能测试. 所以我们没有测试. 我们删除了那个终端. 过了几个月后, 到了一年中发送的时间, 因为这个终端已经不存在了, 10 名记者也就无法发送这 10 份重要报告.
虽然熟悉产品的人已经离开了团队, 但是现在代码中有注释解释终端的作用.
据我所知, 文档是每个团队都在努力的东西. 不仅仅是代码的文档, 还有关于代码的流程.
4. 自信地删掉垃圾代码
我过去很不喜欢删除垃圾代码或过时的代码. 我认为过去写的代码都是神圣的. 我的想法是:「他们写这些代码的时候肯定有一些想法.」这是传统和文化与第一性原则之间的碰撞, 与删除一年一次的终端发生的事相同. 我在那里学到了详细的一课.
我尝试基于已有代码进行工作, 但是资深工程师会尝试解决掉它 -- 全部删除. 一个永远无法到达的 if 声明? 一个不应该调用的函数? 是的, 都消失了.
至于我呢? 我只会把我的函数写在最上面. 我没有减少这些技术累赘, 反而增加了代码的复杂程度, 以及误导别人的可能. 下一个人将事情拼凑起来会更困难.
现在我受到的启发是: 有一些代码你可能不理解, 也有一些代码你知道永远不会用. 删除那些你永远都不会用的代码, 小心那些你不理解的代码.
5. 代码审查
代码审查对学习来说非常有用. 这是你写代码和其他人写代码时进行的外部反馈循环.
两种实现有什么区别呢? 一种方法比另一种好吗? 每次代码审查时我都问自己:「他们为什么这样做?「. 每当我找不到合适的答案时, 我就会去和他们谈谈.
在第一个月后, 我开始在同事的代码中找到错误(就像他们对我代码做的一样). 同行审查对我来说变得更有趣了 -- 这是我期待的游戏 -- 一个提高我代码意识的游戏.
我的启发是: 在理解代码如何实现前不要批准它.
二, 测试
我非常喜欢测试, 以至于如果没有测试就将代码写入代码库我会感到非常不舒服.
如果整个应用程序只做一件事(就像我所有的学校项目), 那么手动测试是可以的. 但是如果该应用程序可完成 100 种不同的功能, 那该怎么办呢? 我不想花半个小时来测试所有的功能, 何况有时候还会忘记一些需要测试的地方.
所以就出现了自动化测试.
我认为测试是一种文档, 是对代码假设的文档. 测试会告诉我 (或我之前的人) 他们预想代码是如何工作的, 以及他们预期哪里会出错.
所以, 当写测试时, 我会记住:
记录如何使用测试时用到的类 / 函数 / 系统.
记录我所想到的会出错的地方.
在大多数情况下, 以上的结论是在我在测试而不是实现的过程中想到的.
以下是我在 Google 卫生间小休时学到的例子:
我在 #2 中遗漏了一些东西, 那里是 bug 出现的地方;
所以每当发现 bug 时, 确保修复 bug 的代码也有相应的测试(称为回归测试), 用于记录信息: 这里可能出现另一种错误.
仅仅编写这些测试并不能提高我代码的质量, 而编写代码却可以. 但是我从阅读测试代码中获得了写更好代码的直觉.
但是, 并不只有这一种测试, 这就是为什么有部署环境测试的原因.
你可以有完美的测试单元, 但是如果没有系统测试, 就会出现以下的情况:
这同样适用于已经测试好的代码: 如果你机器上没有你需要的库, 你会崩溃.
为了测试你需要:
有一台你用于开发的机器;
有一台你用于测试的机器;
最后, 有一台你部署的机器(请不要用与开发程序使用同一台).
如果测试和部署机器之间的环境不匹配, 你就遇到麻烦了. 所以这里就出现了部署环境.
我们先有本地开发环境, 在我的机器上是 docker;
然后有服务器上的开发环境, 机器上安装了一系列的库(和开发工具), 我们在安装了代码的机器上进行开发. 其他相关依赖的测试都可以在这里进行;
接下来是 beta/stage 环境, 它与生产环境完全一样;
最后是生产环境, 它是代码运行和服务于实际客户的机器上的环境.
这里的想法是尝试捕获单元和系统测试无法捕获的错误. 例如, 请求系统和响应系统之间的 API 不匹配. 个人项目与小公司的情况大不一样. 不是每个人都有资源来搭建自己的设备. 然而, 这个想法仍适用于像 AWS 和 AZURE 这样的云供应商.
你可以为开发和生产设置分开的集群. AWS ECS 使用 docker 镜像来部署, 所以即使跨环境事情也会相对平稳. 棘手的一点是其他 AWS 服务之间的集成. 你是否可以在正确的环境中调用正确的终端呢?
你甚至可以更进一步: 下载其他 AWS 服务的备用容器镜像并使用 docker-compose 来配置本地完整的环境. 它会加速反馈循环.
三, 设计
为什么我要将设计放到写代码和测试的后面呢? 设计本应该在第一位, 但是如果我没有在环境中写代码和测试, 我可能会不擅长设计一个遵循环境特性的系统.
在设计系统时, 有很多事情需要考虑:
使用编号是多少?
有多少用户? 预期增长是多少?(即需要使用多少数据行)
未来可能出现的问题是什么?
我需要把它转成一个名为「需求收集」的合理清单. 这个过程有点与灵活性的原则相悖 -- 在开始系统开发之前, 你可以设计多少部分呢? 但是这是一种平衡 -- 你需要选择什么时候做什么. 当然仅仅收集需求并不是所有需要考虑的事情. 我认为, 在设计中包含了开发的过程也是值得去做的. 例如:
本地开发如何运作?
怎么打包和部署?
如何进行端对端的测试?
怎么对新的服务进行压力测试?
怎么管理机密信息?
CI/CD 集成?
我们最近为 BNEF 开发了一个新的搜索系统. 做这件事真的很棒. 我开始设计本地开发, 学习 DPKG(打包和部署)和试图解决部署机密信息的问题.
谁会想到对产品中的机密信息进行部署会变得如此棘手呢?
你不能将这些信息存到代码中, 因为这样任何人都能看得到.
把它们作为环境变量? 这是一个好主意. 但你怎么把它们放在那里?(每次机器启动时访问 PROD 机器来填充环境变量是一件痛苦的事情)
部署为机密文件? 文件从哪里来呢? 怎么进行填充呢?
而且我们不想进行手动操作.
最后我们使用了一个有角色访问控制的数据库(只有我们的机器可以与数据库对话). 我们的代码在启动时从这个数据库中获取秘密数据. 这个能在开发, 测试和产品之间很好地复制 -- 在各自的数据库中都有机密.
同样的, 对于像 AWS 这样的云供应商, 这可能非常不同. 你不必考虑太多机密. 获取你角色账户, 在用户界面中输入机密数据, 在需要的时候你的代码会找到它们. 它简化了很多时间, 这非常酷, 而我很高兴有经验领会这种简易性.
设计时考虑维护需求:
设计系统是件令人兴奋的事. 维护系统呢? 就没那么有趣了.
我在维护过程中遇到了这个问题: 系统为什么会降级, 以及如何降级?
有两个原因可以解答为什么系统也会有降级的时候:
首先, 系统不应当舍弃旧的东西, 而是在已有的基础上增加更多功能. 系统更新倾向于增加而不是删除.
其次, 带着最终目标来设计. 一个进化到做不该做的事情的系统和一个从零来设计做同样事情的系统一样, 没有用. 这是一种系统的倒退. 因此需要对系统进行降级.
现在我知道至少三种降低降级机率的方法:
将业务逻辑和基础设施分开: 通常是对基础设施降级 -- 当使用量增加, 框架过时, 出现零日漏洞等情况下;
围绕系统维护建立流程. 对旧的和新的组件都使用相同的更新. 这可以防止组件之间出现差异, 保持整个代码「现代化」;
确保一直修剪你不想要的 / 旧的东西.
四, 部署
将功能进行捆绑部署还是逐个部署呢? 如果答案是将功能捆绑在一起, 则会出现问题.
接下来要问的问题是: 为什么想要把功能进行捆绑呢?
部署是否花费过多时间?
代码审查是否容易进行?
不管是什么原因, 这是需要修复的流程瓶颈.
捆绑功能部署至少有两个问题:
如果一个功能中有 bug, 将妨碍另一个功能执行;
增加整体出错的风险.
然后, 无论你选择什么部署过程, 你总是希望你的机器像一头牛而不是像宠物一样. 它们并不珍贵. 你知道每台机器上运行的是什么, 以及如何在死机的情况下重新创建它们. 当一台机器死机时, 你不会心烦意乱, 你只需要启动一台新机器. 你像牛一样放养它们, 而不是像宠物一样养着他们.
五, 程序出错的时候
当事情出错时, 而且一定会有出问题的时候, 黄金法则是将对客户的影响最小化.
当事情出了差错, 我自然倾向于赶快解决 bug. 事实证明, 这并不是最理想的解决方案. 与其修复哪里错了, 即使只是「修改一行」, 所做的第一件事应该是回滚版本. 回到之前的工作状态, 这是让客户恢复工作最快的方法.
过了这个时候, 才应该看看哪里出了问题并修复那些 bug.
在你的集群中出现一台「垮掉」的机器也应当是同样的做法 -- 在试图找出机器出了什么问题之前, 先把它停了, 并标记它不可用.
首先找 bug 这种本能会引导我走上解决 bug 的漫长旅途, 反而偏离了让客户先恢复工作这一理想的目标状态. 有时候, 我觉得它没有工作的原因是因为写的代码有问题, 而仔细阅读每一行代码后会陷入混乱, 像是一种深度优先搜索.
之后, 我的启发是, 首先开始广度优先搜索, 然后再深度优先搜索, 去除最顶端的节点. 能否用已有的资源确认:
机器启动了吗?
是否安装了正确的代码?
配置是否正确?
<代码特定配置>, 像代码中的路由是否正确?
模式版本是否正确?
然后进入代码.
在某次出错的问题上, 我们以为机器上没有正确安装 nginx, 但结果是配置被设置为了 false.
当然, 我不需要总是这样做. 有时候错误信息已经足以减少需要搜索代码的区域. 而且当我无法解决这个问题时, 我尝试并持续修改代码以将问题降到最低. 修改的次数越少, 我就能越快地处理实际问题.
但是我现在还是会记录花了 1 个多小时来解决的 bug: 遗漏了什么? 这通常是一些我忘记检查的愚蠢错误, 比如像设置路由, 确保模式版本和服务版本匹配等. 这是熟悉使用的技术堆栈的另一步, 而且只有经验会告诉我为什么系统无法运行.
六, 监控
这是我以前从未想过去做的事. 说句公道话, 在全职编码之前, 我从没维护过系统. 我只是搭建它们, 使用 1 个星期后然后进行下一项工作.
有两个系统, 一个有良好的监控, 另一个并不那么好. 我逐渐非常喜欢监控. 如果我不知道 bug 在哪我就不能修改错误. 其中一种最糟糕的感觉是从客户那里知道有 bug.
「我做了什么?! 我甚至不知道我的系统出了什么问题?」
我认为监控由 3 个部分组成 -- 日志, 衡量标准和警报.
1. 日志
以代码中进行日记记录就像人写日志一样, 是一个进化的过程.
你要找到你可能需要监控的东西, 日志记录下来, 运行系统. 一段时间后, 你会发现你没有足够信息来解决的 bug. 这是增强日志记录的好时机 -- 你的代码少了些什么?
我想你会凭直觉地知道什么东西很重要需要记录, 但是在我们的服务器中我和资深软件工程师所记录的东西有很多不同. 我认为只要请求 - 相应日志就足够了, 但是他会有更多的记录内容, 比如查询执行时间, 代码进行的一些特定的内部调用, 以及何时转储日志. 一切都已经解决了.
几乎不可能在没有日志的情况下进行调试 -- 如果你不知道系统的状态, 你怎么重新创建它呢?
2. 衡量标准和惊爆
衡量标准可以源于日志, 也可以独立于日志(例如向 AWS CloudWatch 和 Grafana 发送时间). 你可以决定你的衡量指标并在代码运行时发送数字.
警报是把所有东西整合到一个的强大监控系统的粘合剂. 如果一个衡量标准是当前产品中运行的机器数量, 当这个数字降到 50% 时, 这是一个很好的警报 -- 你知道有什么出错了.
失败计数高于某个阈值时? 是的, 又一个警报.
这里暗示了另一个需要养成的习惯. 当你修复 bug 时, 你不仅仅关注如何修复 bug, 而是你为什么不早点发现它呢? 是否有布置警报? 如何能够更好地监控来避免类似的问题?
我还不知道如何监控 UI. 即使吧组件测试到位, 也还不足以了解出错的情况. 这些错误通常是由客户来告诉我们的 -- 这看起来不太对劲.
七, 总结
在过去的一年里, 我学到了很多东西. 当我对这篇文章进行回顾时, 我能够更好地体会到我的成长. 希望你也可以从这里得到一些东西!
来源: http://zhuanlan.51cto.com/art/201909/602336.htm