mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 14:43:42 +08:00
mod
This commit is contained in:
130
极客时间专栏/程序员进阶攻略/修炼:程序之术/05 | 架构与实现:它们的连接与分界?.md
Normal file
130
极客时间专栏/程序员进阶攻略/修炼:程序之术/05 | 架构与实现:它们的连接与分界?.md
Normal file
@@ -0,0 +1,130 @@
|
||||
<audio id="audio" title="05 | 架构与实现:它们的连接与分界?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/c7/65318d2b6dacce3a9b6f88425c01bfc7.mp3"></audio>
|
||||
|
||||
把一种想法、一个需求变成代码,这叫 “实现”,而在此之前,技术上有一个过程称为设计,设计中有个特别的阶段叫 “架构”。
|
||||
|
||||
程序员成长的很长一段路上,一直是在 “实现”,当有一天,需要承担起 “架构” 的责任时,可能会有一点搞不清两者的差异与界线。
|
||||
|
||||
## 是什么
|
||||
|
||||
架构是什么?众说纷纭。
|
||||
|
||||
架构(Architecture)一词最早源自建筑学术语,后来才被计算机科学领域借用。以下是其在维基百科(Wikipedia)中的定义:
|
||||
|
||||
>
|
||||
<p>架构是规划、设计和构建建筑及其物理结构的过程与产物。在计算机工程中,架构是描述功能、组织和计算机系统实现的一组规则与方法。 <br />
|
||||
Architecture is both the process and the product of planning, designing, and constructing bui<!-- [[[read_end]]] -->ldings and other physical structures. In computer engineering, “computer architecture” is a set of rules and methods that describe the functionality, organization, and implementation of computer systems.</p>
|
||||
|
||||
|
||||
在建筑学领域,有一组清晰的规则和方法来定义建筑架构。但可惜,到目前为止,在计算机软件工程领域并没有如此清晰的一组规则与方法来定义软件架构。
|
||||
|
||||
好在经过多年的实践,行业里逐渐形成了关于软件架构的共同认知:**软件系统的结构与行为设计**。而实现就是围绕这种已定义的宏观结构去开发程序的过程。
|
||||
|
||||
## 做什么
|
||||
|
||||
架构做什么?很多人会感觉糊里糊涂的。
|
||||
|
||||
我刚获得“架构师”称号时,也并不很明确架构到底在做什么,交付的是什么。后来不断在工作中去反思、实践和迭代,我才慢慢搞清楚架构工作和实现工作的差异与分界线。
|
||||
|
||||
从定义上,你已知道架构是一种结构设计,但它同时可能存在于不同的维度和层次上:
|
||||
|
||||
- 高维度:指系统、子系统或服务之间的切分与交互结构。
|
||||
- 中维度:指系统、服务内部模块的切分与交互结构。
|
||||
- 低维度:指模块组成的代码结构、数据结构、库表结构等。
|
||||
|
||||
在不同规模的团队中,存在不同维度的架构师,但不论工作在哪个维度的架构师,他们工作的共同点包括下面4个方面:
|
||||
|
||||
1. 确定边界:划定问题域、系统域的边界。
|
||||
1. 切分协作:切分系统和服务,目的是建立分工与协作,并行以获得效率。
|
||||
1. 连接交互:在切分的各部分之间建立连接交互的原则和机制。
|
||||
1. 组装整合:把切分的各部分按预期定义的规则和方法组装整合为一体,完成系统目标。
|
||||
|
||||
有时,你会认为架构师的职责是要交付 “一种架构”,而这“一种架构” 的载体通常又会以某种文档的形式体现。所以,很容易误解架构师的工作就是写文档。但实际上**架构师的交付成果是一整套决策流,文档仅仅是交付载体**,而且仅仅是过程交付产物,最终的技术决策流实际体现在线上系统的运行结构中。
|
||||
|
||||
而对于实现,你应该已经很清楚是在做什么了。但我在这里不妨更清晰地分解一下。实现的最终交付物是程序代码,但这个过程中会发生什么?一般会有下面6个方面的考虑:选型评估;程序设计;执行效率;稳定健壮;维护运维;集成部署。
|
||||
|
||||
下表为其对应的详细内容:<br />
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/5b/9cf03e6bdb195f8eca40386e297e0d5b.png" alt="" />
|
||||
|
||||
我以交付一个功能需求为例,讲述下这个过程。
|
||||
|
||||
实现一个功能,可能全部自己徒手做,也可能选择一些合适的库或框架,再从中找到需要的API。
|
||||
|
||||
确定了合适的选型后,需要从逻辑、控制与数据这三个方面进一步考虑程序设计:
|
||||
|
||||
- 逻辑,即功能的业务逻辑,反映了真实业务场景流程与分支,包含大量业务领域知识。
|
||||
- 控制,即考虑业务逻辑的执行策略,哪些可以并行执行,哪些可以异步执行,哪些地方又必须同步等待结果并串行执行?
|
||||
- 数据,包括数据结构、数据状态变化和存取方式。
|
||||
|
||||
开始编码实现时,你进一步要考虑代码的执行效率,需要运行多长时间?要求的最大等待响应时间能否满足?并发吞吐能力如何?运行的稳定性和各种边界条件、异常处理是否考虑到了?上线后,出现 Bug,相关的监控、日志能否帮助快速定位?是否有动态线上配置和变更能力,可以快速修复一些问题?新上线版本时,你的程序是否考虑了兼容老版本的问题等?
|
||||
|
||||
最后你开发的代码是以什么形态交付?如果是提供一个程序库,则需要考虑相关的依赖复杂度和使用便利性,以及未来的升级管理。如果是提供服务,就需要考虑服务调用的管理、服务使用的统计监控,以及相关的 SLA 服务保障承诺。
|
||||
|
||||
以上,就是我针对整个实现过程自己总结的一个思维框架。如果你每次写代码时,都能有一个完善的思维框架,应该就能写出更好的代码。这个思维框架是在过去多年的编程经验中逐步形成的,在过去每次写代码时如果漏掉了其中某个部分,后来都以某种线上 Bug 或问题的形式,让我付出了代价,做出了偿还。
|
||||
|
||||
“实现”作为一个过程,就是不断地在交付代码流。而完成的每一行代码,都包含了上面这些方面的考虑,而这些方面的所有判断也是一整套决策流,然后固化在了一块块的代码中。
|
||||
|
||||
因为实现是围绕架构来进行的,所以架构的决策流在先,一定程度上决定了实现决策流的方向与复杂度,而架构决策的失误,后续会成倍地放大实现的成本。
|
||||
|
||||
## 关注点
|
||||
|
||||
架构与实现过程中,有很多很多的点值得关注,若要选择一个核心点,会是什么?
|
||||
|
||||
架构的一个核心关注点,如果只能是一个点,我想有一个很适合的字可以表达: 熵。“熵”是一个物理学术语,在热力学中表达系统的混乱程度,最早是“信息论之父”克劳德·艾尔伍德·香农借用了这个词,并将其引入了信息科学领域,用以表达系统的混乱程度。
|
||||
|
||||
软件系统或架构,不像建筑物会因为时间的流逝而自然损耗腐坏,它只会因为变化而腐坏。一开始清晰整洁的架构与实现随着需求的变化而不断变得浑浊、混乱。这也就意味着系统的“熵”在不断增高。
|
||||
|
||||
这里我用一个图展示软件系统“熵”值的生命周期变化,如下:<br />
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/7a/ceb233b2c4c4088d2179cf8b0d7ad37a.png" alt="" />
|
||||
|
||||
系统只要是活跃的,“熵”值就会在生命周期中不断波动。需求的增加和改变,就是在不断增加“熵”值(系统的混乱程度)。但软件系统的“熵”有个临界值,当达到并超过临界值后,软件系统的生命也基本到头了。这时,你可能将迫不得已采取一种行动:重写或对系统做架构升级。
|
||||
|
||||
如果你不关注、也不管理系统的“熵”值,它最终的发展趋势就如图中的蓝线,一直升高,达到临界点,届时你就不得不付出巨大的代价来进行系统架构升级。
|
||||
|
||||
而实现中重构与优化的动作则是在不断进行减“熵”,作出平衡,让系统的“熵”值在安全的范围内波动。
|
||||
|
||||
那么,关于实现的核心关注点,也就呼之欲出了,我们也可以用一个字表达:简。
|
||||
|
||||
简,是简单、简洁、简明、简化,都是在做减法,但不是简陋。关于实现的全部智慧都浓缩在了这一个字里,它不仅减少代码量,也减少了开发时间,减少了测试时间,减少了潜在 Bug 的数量,甚至减少了未来的维护、理解与沟通成本。
|
||||
|
||||
架构关注复杂度的变化,自然就会带来简化,而实现则应当顺着把“简”做到极致。
|
||||
|
||||
## 断裂带
|
||||
|
||||
架构与实现之间,存在一条鸿沟,这是它们之间的断裂带。
|
||||
|
||||
断裂带出现在架构执行过程之中,落在文档上的架构决策实际上是静态的,但真正的架构执行过程却是动态的。架构师如何准确地传递架构决策?而开发实施的效果又如何能与架构决策保持一致?在这个过程中出现实施与决策的冲突,就又需要重新协调沟通讨论以取得新的一致。
|
||||
|
||||
当系统规模比较小时,有些架构师一个人就能把全部的设计决策在交付期限内开发完成,这就避免了很多沟通协调的问题。好些年前,我就曾这样做过一个小系统的架构升级改造,但后来的系统越来越大,慢慢就需要几十人的团队来分工协作。光是准确传递决策信息,并维持住大体的一致性,就是一件非常有挑战的工作了。
|
||||
|
||||
当系统规模足够大了,没有任何架构师能够把控住全部的细节。在实践中,我的做法是定期对系统的状态做快照,而非去把握每一次大大小小的变化,因为那样直接就会让我过载。在做快照的过程中我会发现很多的细节,也许和我当初想的完全不一样,会产生出一种“要是我来实现,绝对不会是这样”的感慨。
|
||||
|
||||
但在我发现和掌握的所有细节中,我需要做一个判断,哪些细节上的问题会是战略性的,而我有限的时间和注意力,必须放在这样的战略性细节上。而其他大量的实现细节也许和我想的不同,但只要没有越出顶层宏观结构定义的边界即可。系统是活的,控制演化的方向是可行的,而妄图掌控演化过程的每一步是不现实的。
|
||||
|
||||
关注与把控边界,这就比掌控整个领地的范围小了很多,再确认领地中的战略要地,那么掌控的能力也就有了支撑。架构与实现的鸿沟会始终存在,在这条鸿沟上选择合适的地方建设桥梁,建设桥梁的地方必是战略要地。
|
||||
|
||||
## 等效性
|
||||
|
||||
架构升级中,经常被问到一个问题:“这个架构能实现么?”
|
||||
|
||||
其实,这根本不是一个值得疑惑的问题。相对于建筑架构,软件架构过程其实更像是城市的规划与演变过程。有一定历史的城市,慢慢都会演变出所谓的旧城和新城。而新城相对于旧城,就是一次架构升级的过程。
|
||||
|
||||
城市规划师会对城市的分区、功能划分进行重新定位与规划。一个旧城所拥有的所有功能,如:社区、学校、医院、商业中心,难道新城会没有,或者说 “实现” 不了吗?
|
||||
|
||||
任何架构的可实现性,是完全等效的,但实现本身却不是等效的,对不同的人或不同的团队可实现性的可能、成本、效率是绝对不等效的。
|
||||
|
||||
近些年,微服务架构火了,很多人都在从曾经的单体应用架构升级到微服务架构。以前能实现的功能,换成微服务架构肯定也可以实现,只是编写代码的方式不同,信息交互的方式也不同。
|
||||
|
||||
架构升级,仅仅是一次系统的重新布局与规划,成本和效率的重新计算与设计,“熵”的重新分布与管理。
|
||||
|
||||
最后我归纳下:架构是关注系统结构与行为的决策流,而实现是围绕架构的程序开发过程;架构核心关注系统的“熵”,而实现则顺应“简”;架构注重把控系统的边界与 “要塞”,而实现则去建立 “领地”;所有架构的可实现性都是等效的,但实现的成本、效率绝不会相同。
|
||||
|
||||
文中提到,架构和实现之间有一条断裂带,而让架构与实现分道扬镳的原因有:
|
||||
|
||||
- 沟通问题:如信息传递障碍。
|
||||
- 水平问题:如技术能力不足。
|
||||
- 态度问题:如偷懒走捷径。
|
||||
- 现实问题:如无法变更的截止日期(Deadline)。
|
||||
|
||||
以上都是架构执行中需要面对的问题,你还能想到哪些?欢迎给我留言,和我一起探讨。
|
||||
|
||||
|
||||
91
极客时间专栏/程序员进阶攻略/修炼:程序之术/06 | 模式与框架:它们的关系与误区?.md
Normal file
91
极客时间专栏/程序员进阶攻略/修炼:程序之术/06 | 模式与框架:它们的关系与误区?.md
Normal file
@@ -0,0 +1,91 @@
|
||||
<audio id="audio" title="06 | 模式与框架:它们的关系与误区?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e2/fc/e21c19df4c954e0495b6dd811af250fc.mp3"></audio>
|
||||
|
||||
在学习程序设计的路上,你一定会碰到“设计模式”,它或者给你启发,或者让你疑惑,并且你还会发现在不同的阶段遇到它,感受是不同的。而“开发框架”呢?似乎已是现在写程序的必备品。那么框架和模式又有何不同?它们有什么关系?在程序设计中又各自扮演什么角色呢?
|
||||
|
||||
## 设计模式
|
||||
|
||||
设计模式,最早源自 GoF 那本已成经典的《设计模式:可复用面向对象软件的基础》一书。该书自诞生以来,在程序设计领域已被捧为“圣经”。
|
||||
|
||||
软件设计模式也是参考了建筑学领域的经验,早在建筑大师克里斯托弗·亚历山大(Christopher Alexander)的著作《建筑的永恒之道》中,已给出了关于“模式”的定义:
|
||||
|
||||
>
|
||||
每个模式都描述了一个在我们的环境中不断出现的问题,然后描述了该问题的解决方案的核心,通过这种方式,我们可以无数次地重用那些已有的成功的解决方案,无须再重复相同的工作。
|
||||
|
||||
|
||||
而《设计模式》一书借鉴了建筑领域的定义和形式,原书中是这么说的:
|
||||
|
||||
>
|
||||
本书中涉及的设计模式并不描述新的或未经证实的设计,我们只收录那些在不同系统中多次使用过的成功设计;尽管这些设计不包括新的思路,但我们用一种新的、便于理解的方式将其展现给读者。
|
||||
|
||||
|
||||
虽然该书采用了清晰且分门别类的方式讲述各种设计模式,但我相信很多新入门的程序员在看完该书后还是会像我当年一样有困扰,无法真正理解也不知道这东西到底有啥用。
|
||||
|
||||
早年我刚开始学习 Java 和面向对象编程,并编写 JSP 程序。当我把一个 JSP 文件写到一万行代码时,自己终于受不了了,然后上网大量搜索到底怎样写 JSP 才是对的。之后,我就碰到了《设计模式》一书,读完了,感觉若有所悟,但再去写程序时,反而更加困扰了。
|
||||
|
||||
因为学 “设计模式” 之前,写程序是无所顾忌,属于拿剑就刺,虽无章法却还算迅捷。但学了一大堆 “招式” 后反而变得有点瞻前顾后,每次出剑都在考虑招式用对没,挥剑反倒滞涩不少。有人说:“设计模式,对于初窥门径的程序员,带来的麻烦简直不逊于它所解决的问题。”回顾往昔,我表示深有同感。
|
||||
|
||||
后来回想,那个阶段我把《设计模式》用成了一本 “菜谱” 配方书。现实是,没做过什么菜只是看菜谱,也只能是照猫画虎,缺少好厨师的那种能力——火候。初窥门径的程序员其实缺乏的就是这样的“火候”能力,所以在看《设计模式》时必然遭遇困惑。而这种“火候”能力则源自大量的编程设计实践,在具体的实践中抽象出模式的思维。
|
||||
|
||||
“设计模式” 是在描述一些抽象的概念,甚至还给它们起了一些专有名字,这又增加了一道弯儿、一层抽象。初窥门径的程序员,具体的实践太少,面临抽象的模式描述时难免困惑。但实践中,经验积累到一定程度的程序员,哪怕之前就没看过《设计模式》,他们却可能已经基于经验直觉地用起了某种模式。
|
||||
|
||||
前面我说过我刚学习编程时看过一遍《设计模式》,看完后反而带来更多的干扰,不过后来倒也慢慢就忘了。好些年后,我又重读了一遍,竟然豁然开朗起来,因为其中一些模式我已经在过往的编程中使用过很多次,另一些模式虽未碰到,但理解起来已不见困惑。到了这个阶段,其实我已经熟练掌握了从具体到抽象之间切换的思维模式,设计模式的 “招数” 看来就亲切了很多。
|
||||
|
||||
在我看来,模式是前人解决某类问题方式的总结,是一种解决问题域的优化路径。但引入模式也是有代价的。设计模式描述了抽象的概念,也就在代码层面引入了抽象,它会导致代码量和复杂度的增加。而衡量应用设计模式付出的代价和带来的益处是否值得,这也是程序员 “火候” 能力另一层面的体现。
|
||||
|
||||
有人说,设计模式是招数;也有人说,设计模式是内功。我想用一种大家耳熟能详的武功来类比:降龙十八掌。以其中一掌“飞龙在天”为例,看其描述:
|
||||
|
||||
>
|
||||
气走督脉,行手阳明大肠经商阳…此式跃起凌空,居高下击,以一飞冲天之式上跃,双膝微曲,提气丹田,急发掌劲取敌首、肩、胸上三路。
|
||||
|
||||
|
||||
以上,前半句是关于内功的抽象描述,后半部分是具体招数的描述,而设计模式的描述表达就与此有异曲同工之妙。所以,设计模式是内功和招数并重、相辅相成的 “武功”。
|
||||
|
||||
当你解决了一个前人从没有解决的问题,并把解决套路抽象成模式,你就创造了一招新的 “武功”,后来的追随者也许会给它起个新名字叫:某某模式。
|
||||
|
||||
## 开发框架
|
||||
|
||||
不知从何时起,写程序就越来越离不开框架了。
|
||||
|
||||
记得我还在学校时,刚学习 Java 不久,那时 Java 的重点是 J2EE(现在叫 Java EE 了),而 J2EE 的核心是 EJB。当我终于用“JSP + EJB + WebLogic(EJB 容器)+ Oracle数据库”搭起一个 Web 系统时,感觉终于掌握了 Java 的核心。
|
||||
|
||||
后来不久,我去到一家公司实习,去了以后发现那里的前辈们都在谈论什么 DI(依赖注入)和 IoC(控制反转)等新概念。他们正在把老一套的 OA 系统从基于 EJB 的架构升级到一套全新的框架上,而那套框架包含了一堆我完全没听过的新名词。
|
||||
|
||||
然后有前辈给我推荐了一本书叫 **J2EE Development Without EJB**,看完后让我十分沮丧,因为我刚刚掌握的 Java 核心技术 EJB 还没机会出手就已过时了。
|
||||
|
||||
从那时起,我开始知道了框架(Framework)这个词,然后学习了一整套的基于开源框架的程序开发方式,知道了为什么 EJB 是重量级的,而框架是轻量级的。当时 EJB 已步入暮年,而框架的春天才刚开始来临,彼时最有名的框架正好也叫 Spring。如今框架已经枝繁叶茂,遍地开花。
|
||||
|
||||
现在的编程活动中,已是大量应用框架,而框架就像是给程序员定制的开发脚手架。一个框架是一个可复用的设计组件,它统一定义了高层设计和接口,使得从框架构建应用程序变得非常容易。因此,框架可以算是打开“快速开发”与“代码复用”这两扇门的钥匙。
|
||||
|
||||
在如今这个框架遍地开花的时代,正因为框架过于好用、易于复用,所以也可能被过度利用。
|
||||
|
||||
在 Java 中,框架很多时候就是由一个或一些 jar 包组成的。早在前几年(2012 年的样子)接触到一个 Web 应用系统,当时我尝试去拷贝一份工程目录时,意外发现居然有接近 500M 大小,再去看依赖的 jar 包多达 117 个,着实吓了一跳。在 500M 工程目录拷贝进度条缓慢移动中,我在想:“如今的程序开发是不是患上了框架过度依赖症?”
|
||||
|
||||
我想那时应该没有人能解释清楚为什么这个系统需要依赖 117 个 jar 包之多,也许只是为了完成一个功能,引入了一个开源框架,而这个框架又依赖了其他 20 个 jar 包。
|
||||
|
||||
有时候,框架确实帮我们解决了大部分的脏活累活,如果运气好,这些框架的质量很高或系统的调用量不大,那么它们可能也就从来没引发过什么问题,我们也就不需要了解它们是怎么去解决那些脏活、累活的。但若不巧,哪天某个框架在某些情况下出现了问题,在搞不懂框架原理的情况下,就总会有人惊慌失措。
|
||||
|
||||
如今,框架带来的束缚在于,同一个问题,会有很多不同框架可供选择。如何了解、评估、选择与取舍框架,成了新的束缚。
|
||||
|
||||
一些知名框架都是从解决一个特定领域问题的微小代码集合开始发展到提供解决方案、绑定概念、限定编程模式,并尝试不断通用化来扩大适用范围。
|
||||
|
||||
这样的框架自然不断变得庞大、复杂、高抽象度。
|
||||
|
||||
我一直不太喜欢通用型的框架,因为通用则意味着至少要适用于大于两种或以上的场景,场景越多我们的选择和取舍成本越高。另外,通用意味着抽象度更高,而现实是越高的抽象度,越不容易被理解。例如,人生活在三维世界,理解三维空间是直观的,完全没有抽象,理解四维空间稍微困难点,那五维或以上理解起来就很困难了。
|
||||
|
||||
框架,既是钥匙,也是枷锁,既解放了我们,也束缚着我们。
|
||||
|
||||
## 两者关系
|
||||
|
||||
分析了模式,解读了框架,那么框架和模式有什么关系呢?
|
||||
|
||||
框架和模式的共同点在于,它们都提供了一种问题的重用解决方案。其中,框架是代码复用,模式是设计复用。
|
||||
|
||||
软件开发是一种知识与智力的活动,知识的积累很关键。框架采用了一种结构化的方式来对特定的编程领域进行了规范化,在框架中直接就会包含很多模式的应用、模式的设计概念、领域的优化实践等,都被固化在了框架之中。框架是程序代码,而模式是关于这些程序代码的知识。
|
||||
|
||||
比如像 Spring 这样的综合性框架的使用与最佳实践,就隐含了大量设计模式的套路,即使是不懂设计模式的初学者,也可以按照这些固定的编程框架写出符合规范模式的程序。但写出代码完成功能是一回事,理解真正的程序设计又是另外一回事了。
|
||||
|
||||
小时候,看过一部漫画叫《圣斗士》。程序员就像是圣斗士,框架是“圣衣”,模式是“流星拳“,但最重要的还是自身的“小宇宙”啊。
|
||||
|
||||
我相信在编程学习与实践的路上,你对设计模式与开发框架也有过自己的思考。欢迎给我留言,说说你有过怎样的认识变化和体会,我们一起讨论。
|
||||
|
||||
|
||||
92
极客时间专栏/程序员进阶攻略/修炼:程序之术/07 | 多维与视图:系统设计的思考维度与展现视图.md
Normal file
92
极客时间专栏/程序员进阶攻略/修炼:程序之术/07 | 多维与视图:系统设计的思考维度与展现视图.md
Normal file
@@ -0,0 +1,92 @@
|
||||
<audio id="audio" title="07 | 多维与视图:系统设计的思考维度与展现视图" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8d/7d/8d1696933921b04510e2452330a9837d.mp3"></audio>
|
||||
|
||||
大学上机械设计课程时学习了 “三视图” 。三视图是观测者从三个不同位置观察同一个空间几何体所画出的图形,是正确反映物体长宽高尺寸正投影的工程图,在工程设计领域十分有用。三视图也是精确的,任何现实世界中的立体物都必然能被 “三视图” 投影到二维的平面,有了这张图就能准确制作出相应的机械零部件。
|
||||
|
||||
但在软件设计领域,则有较大的不同,软件系统是抽象的,而且维度更多。20世纪90年代,软件行业诞生了 UML(Unified Modeling Language): 统一建模语言,一种涵盖软件设计开发所有阶段的模型化与可视化支持的建模语言。
|
||||
|
||||
从 UML 的出现中就可以知道,软件先驱们一直在不懈地努力,使软件系统设计从不可直观感受触摸的抽象思维空间向现实空间进行投影。
|
||||
|
||||
UML 是一种类似于传统工程设计领域 “三视图” 的尝试,但却又远没有达到 “三视图” 的精准。虽然 UML 没能在工程实施领域内广泛流行起来,但其提供的建模思想给了我启发。让我一直在思考应该需要有哪些维度的视图,才能很好地表达一个软件系统的设计。
|
||||
|
||||
而在多年的工程实践中,我逐渐得到了一些维度的视图,下面就以我近些年一直在持续维护、设计、演进的系统(京东咚咚)为例来简单说明下。
|
||||
|
||||
## 一、组成视图
|
||||
|
||||
组成视图,表达了系统由哪些子系统、服务、组件部分构成。
|
||||
|
||||
2015 年,我写过一篇关于咚咚的文章:《京东咚咚架构演进》。当时我们团队对系统进行了一次微服务化的架构升级,而微服务的第一步就是拆分服务,并表达清楚拆分后整个系统到底由哪些服务构成,所以有了下面这张系统服务组成图。
|
||||
|
||||
如下图示例,它对服务进行大类划分,图中用了不同的颜色来表达这种分类:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a0/d9/a075c277981b3e56c347dc05591f18d9.png" alt="" />
|
||||
|
||||
每一类服务提供逻辑概念上比较相关的功能,而每一个微服务又按照如下两大原则进行了更细的划分:
|
||||
|
||||
- 单一化:每个服务提供单一内聚的功能集。
|
||||
- 正交化:任何一个功能仅由一个服务提供,无提供多个类似功能的服务。
|
||||
|
||||
如上,就是我们系统的服务组成视图,用于帮助团队理解整体系统的宏观组成,以及个人的具体工作内容在整个系统中的位置。
|
||||
|
||||
了解了服务的组成,进一步自然就需要了解服务之间的关系与交互。
|
||||
|
||||
## 二、交互视图
|
||||
|
||||
交互视图,表达了系统或服务与外部系统或服务的协作关系,也即:依赖与被依赖。
|
||||
|
||||
由于咚咚系统的业务场景繁多,拆分出来的服务种类也比较多,交互关系复杂。所以可以像地图一样通过不同倍率的缩放视角来表达和观察服务之间的交互关系。
|
||||
|
||||
如下图,是一张宏观大倍率的整体交互视图示例。它隐藏了内部众多服务的交互细节,强调了终端和服务端,以及服务端内部交互的主要过程。这里依然以地图作类比,它体现了整体系统主干道场景的运动过程。而每一个服务本身,在整体的交互图中,都会有其位置,有些在主干道上,而有些则在支线上。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/f4/4bf63fcd735af78c2258c1ddd8cde0f4.png" alt="" />
|
||||
|
||||
如果我们把目光聚焦在一个服务上,以其为中心的表达方式,就体现了该服务的依赖协作关系。所以,可以从不同服务为中心点出发,得到关注点和细节更明确的局部交互细节图,而这样的细节图一般掌握在每个服务开发者的脑中。当我们需要写关于某个服务的设计文档时,这样的局部细节交互图也应该是必不可少的。
|
||||
|
||||
在逻辑的层面了解了服务间的协作与交互后,则需要更进一步了解这些服务的部署环境与物理结构。
|
||||
|
||||
## 三、部署视图
|
||||
|
||||
部署视图,表达系统的部署结构与环境。
|
||||
|
||||
部署视图,从不同的人员角色出发,关注点其实不一样,不过从应用开发和架构的角度来看,会更关注应用服务实际部署的主机环境、网络结构和其他一些环境元素依赖。下面是一张强调服务部署的机房结构、网络和依赖元素的部署图示例。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/c7/90a43e28e56c0a21af03f741c358bac7.png" alt="" />
|
||||
|
||||
部署视图本身也可以从不同的视角来画,这取决于你想强调什么元素。上面这张示例图,强调的是应用部署的 IDC 及其之间的网络关系,和一些关键的网络通讯延时指标。因为这些内容可能影响系统的架构设计和开发实现方式。
|
||||
|
||||
至此,组成、交互和部署图更多是表达系统的宏观视图:关注系统组合、协作和依存的关系。但还缺乏关于系统设计或实现本身的表达,这就引出了流程和状态两类视图。
|
||||
|
||||
## 四、流程视图
|
||||
|
||||
流程视图,表达系统内部实现的功能和控制逻辑流程。
|
||||
|
||||
可能有人喜欢用常见的流程图来表达系统设计与实现的流程,但我更偏好使用 UML 的序列图,个人感觉更清晰些。
|
||||
|
||||
下图是咚咚消息投递的一个功能逻辑流程表达,看起来就像是 UML 的序列图,但并没有完全遵循 UML 的图例语法(主要是我习惯的画图工具不支持)。而且,我想更多人即使是程序员也并不一定会清楚地了解和记得住 UML 的各种图例语法,所以都用文字做了补充说明,也就没必要一定要遵循其语法了,重点还是在于要把逻辑表达清楚。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/2c/2b8ea4c772c314e3bb7b246501bea32c.png" alt="" />
|
||||
|
||||
逻辑流程一般分两种:业务与控制。有些系统业务逻辑很复杂,而有些系统业务逻辑不复杂但请求并发很高,导致对性能、安全与稳定的要求高,所以控制逻辑就复杂了。这两类复杂的逻辑处理流程都需要表达清楚,而上图就是对业务功能逻辑的表达示例。
|
||||
|
||||
除了逻辑流程的复杂性,系统维持的状态变迁很可能也是另一个复杂性之源。
|
||||
|
||||
## 五、状态视图
|
||||
|
||||
状态视图,表达系统内部管理了哪些状态以及状态的变迁转移路径。
|
||||
|
||||
像咚咚这样的 IM 消息系统,就自带一个复杂的状态管理场景:消息的已读/未读状态。它的复杂性体现在,它本身就处在一个不可控的分布式场景下,在用户的多个终端和服务端之间,需要保持尽可能的最终一致性。
|
||||
|
||||
为什么没法满足绝对严格的最终一致性?如下图所示,IM 的 “已读/未读” 状态需要在用户的多个终端和服务端之间进行分布式的同步。按照分布式 CAP 原理,IM 的业务场景限定了 AP 是必须满足的,所以 C 自然就是受限的了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/c3/6bbb1c9be59fcd472efd77d89cb057c3.png" alt="" />
|
||||
|
||||
所有的业务系统都一定会有状态,因为那就是业务的核心价值,并且这个系统只要有用户使用,用户就会产生行为,行为导致系统状态的变迁。比如,IM 中用户发出的消息,用户的上下线等等都是行为引发的状态变化。
|
||||
|
||||
但无状态服务相比有状态的服务和系统要简单很多,一个系统中不是所有的服务都有状态,只会有部分服务需要状态,我们的设计仅仅是围绕在,如何尽可能地把状态限制在系统的有限范围内,控制其复杂性的区域边界。
|
||||
|
||||
至此,关于软件系统设计,我感觉通用的维度与视图就这些,但每个具体的系统可能也还有其独特的维度,也会有自己独有的视图。
|
||||
|
||||
用更系统化的视图去观察和思考,想必也会让你得到更成体系化的系统设计。
|
||||
|
||||
以上就是我关于系统设计的一些通用维度与视图的思考,那么你平时都用怎样的方式来表达程序系统设计呢?
|
||||
|
||||
|
||||
81
极客时间专栏/程序员进阶攻略/修炼:程序之术/08 | 代码与分类:工业级编程的代码分类与特征.md
Normal file
81
极客时间专栏/程序员进阶攻略/修炼:程序之术/08 | 代码与分类:工业级编程的代码分类与特征.md
Normal file
@@ -0,0 +1,81 @@
|
||||
<audio id="audio" title="08 | 代码与分类:工业级编程的代码分类与特征" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/27/b394ba7208a807153a78fdf6bab51e27.mp3"></audio>
|
||||
|
||||
编程,就是写代码,那么在真实的行业项目中你编写的这些代码可以如何分类呢?回顾我曾经写过的各种系统代码,按代码的作用,大概都可以分为如下三类:
|
||||
|
||||
- 功能
|
||||
- 控制
|
||||
- 运维
|
||||
|
||||
如果你想提高编程水平,写出优雅的代码,那么就必须要清晰地认识清楚这三类代码。
|
||||
|
||||
## 一、功能
|
||||
|
||||
功能代码,是实现需求的业务逻辑代码,反映真实业务场景,包含大量领域知识。
|
||||
|
||||
一个程序软件系统,拥有完备的功能性代码仅是基本要求。因为业务逻辑的复杂度决定了功能性代码的复杂度,所以要把功能代码写好,最难的不是编码本身,而是搞清楚功能背后的需求并得到正确的理解。之后的编码活动,就仅是一个“翻译”工作了:把需求“翻译”为代码。
|
||||
|
||||
当然,“翻译” 也有自己独有的技术和积累,并不简单。而且 “翻译” 的第一步要求是 “忠于原文”,也即真正地理解并满足用户的原始需求。可这个第一步的要求实现起来就很困难。
|
||||
|
||||
为什么搞清楚用户需求很困难?因为从用户心里想要的,到他最后得到的之间有一条长长的链条,如下所示:
|
||||
|
||||
>
|
||||
用户心理诉求 -> 用户表达需求 -> 产品定义需求 -> 开发实现 -> 测试验证 -> 上线发布 -> 用户验收
|
||||
|
||||
|
||||
需求信息源自用户的内心,然后通过表达显性地在这个链条上传递,最终固化成了代码,以程序系统的形态反馈给了用户。
|
||||
|
||||
但信息在这个链条中的每个环节都可能会出现偏差与丢失,即使最终整个链条上的各个角色都貌似达成了一致,完成了系统开发、测试和发布,但最终也可能发现用户的心理诉求要么表达错了,要么被理解错了。
|
||||
|
||||
因为我近些年一直在做即时通讯产品(IM),所以在这儿我就以微信这样一个国民级的大家都熟悉的即时通讯产品为样本,举个例子。
|
||||
|
||||
微信里有个功能叫:消息删除。你该如何理解这个功能背后的用户心理诉求呢?用户进行删除操作的期待和反馈又是什么呢?从用户发消息的角度,我理解其删除消息可能的诉求有如下几种:
|
||||
|
||||
1. 消息发错了,不想对方收到。
|
||||
1. 消息发了后,不想留下发过的痕迹,但期望对方收到。
|
||||
1. 消息已发了,对于已经收到的用户就算了,未收到的最好就别收到了,控制其传播范围。
|
||||
|
||||
对于第一点,微信提供了两分钟内撤回的功能;而第二点,微信提供的删除功能正好满足;第三点,微信并没有满足。我觉着第三点其实是一个伪需求,它其实是第一点不能被满足情况下用户的一种妥协。
|
||||
|
||||
用户经常会把他们的需要,表达成对你的行为的要求,也就是说不真正告诉你要什么,而是告诉你要做什么。所以你才需要对被要求开发的功能进行更深入的思考。有时,即使是日常高频使用的产品背后的需求,你也未必能很好地理解清楚,而更多的业务系统其实离你的生活更远,努力去理解业务及其背后用户的真实需求,才是写好功能代码的基本能力。
|
||||
|
||||
程序存在的意义就在于实现功能,满足需求。而一直以来我们习惯于把完成客户需求作为程序开发的主要任务,当功能实现了便感觉已经完成了开发,但这仅仅是第一步。
|
||||
|
||||
## 二、控制
|
||||
|
||||
控制代码,是控制业务功能逻辑代码执行的代码,即业务逻辑的执行策略。
|
||||
|
||||
编程领域熟悉的各类设计模式,都是在讲关于控制代码的逻辑。而如今,很多这些常用的设计模式基本都被各类开源框架固化了进去。比如,在 Java 中,Spring 框架提供的控制反转(IoC)、依赖注入(DI)就固化了工厂模式。
|
||||
|
||||
通用控制型代码由各种开源框架来提供,程序员就被解放出来专注写好功能业务逻辑。而现今分布式领域流行的微服务架构,各种架构模式和最佳实践也开始出现在各类开源组件中。比如微服务架构模式下关注的控制领域,包括:通信、负载、限流、隔离、熔断、异步、并行、重试、降级。
|
||||
|
||||
以上每个领域都有相应的开源组件代码解决方案,而进一步将控制和功能分离的 “服务网格(Service Mesh)” 架构模式则做到了极致,控制和功能代码甚至运行在了不同的进程中。
|
||||
|
||||
控制代码,都是与业务功能逻辑不直接相关的,但它们和程序运行的性能、稳定性、可用性直接相关。提供一项服务,功能代码满足了服务的功能需求,而控制代码则保障了服务的稳定可靠。
|
||||
|
||||
有了控制和功能代码,程序系统终于能正常且稳定可靠地运行了,但难保不出现异常,这时最后一类 “运维” 型代码便要登场了。
|
||||
|
||||
## 三、运维
|
||||
|
||||
运维代码,就是方便程序检测、诊断和运行时处理的代码。它们的存在,才让系统具备了真正工业级的可运维性。
|
||||
|
||||
最常见的检测诊断性代码,应该就是日志了,打日志太过简单,因此我们通常也就疏于考虑。其实即使是打日志也需要有意识的设计,评估到底应该输出多少日志,在什么位置输出日志,以及输出什么级别的日志。
|
||||
|
||||
检测诊断代码有一个终极目标,就是让程序系统完成运行时的自检诊断。这是完美的理想状态,却很难在现实中完全做到。
|
||||
|
||||
因为它不仅仅受限于技术实现水平,也与实现的成本和效益比有关。所以,我们可以退而求其次,至少在系统异常时可以具备主动运行状态汇报能力,由开发和运维人员来完成诊断分析,这也是我们常见的各类系统或终端软件提供的机制。
|
||||
|
||||
在现实中,检测诊断类代码经常不是一开始就主动设计的。但生产环境上的程序系统可能会偶然出现异常或故障,而因为一开始缺乏检测诊断代码输出,所以很难找到真实的故障原因。现实就这样一步一步逼着你去找到真实原因,于是检测诊断代码就这么被一次又一次地追问为什么而逐渐完善起来了。
|
||||
|
||||
但如果一开始你就进行有意识地检测诊断设计,后面就会得到更优雅的实现。有一种编程模式:面向切面编程(AOP),通过早期的有意设计,可以把相当范围的检测诊断代码放入切面之中,和功能、控制代码分离,保持优雅的边界与距离。
|
||||
|
||||
而对于特定的编程语言平台,比如 Java 平台,有字节码增强相关的技术,可以完全干净地把这类检测诊断代码和功能、控制代码彻底分离。
|
||||
|
||||
运维类代码的另一种类,是方便在运行时,对系统行为进行改变的代码。通常这一类代码提供方便运维操作的 API 服务,甚至还会有专门针对运维提供的服务和应用,例如:备份与恢复数据、实时流量调度等。
|
||||
|
||||
功能、控制、运维,三类代码,在现实的开发场景中优先级这样依次排序。有时你可能仅仅完成了第一类功能代码就迫于各种压力上线发布了,但你要在内心谨记,少了后两类代码,将来都会是负债,甚至是灾难。而一个满足工业级强度的程序系统,这三类代码,一个也不能少。
|
||||
|
||||
而对三类代码的设计和实现,越是优雅的程序,这三类代码在程序实现中就越是能看出明显的边界。为什么需要边界?因为,“码以类聚,人以群分”。功能代码易变化,控制代码固复杂,运维代码偏繁琐,这三类不同的代码,不仅特征不同,而且编写它们的人(程序员)也可能分属不同群组,有足够的边界与距离才能避免耦合与混乱。
|
||||
|
||||
而在程序这个理性世界中,优雅有时就是边界与距离。
|
||||
|
||||
|
||||
88
极客时间专栏/程序员进阶攻略/修炼:程序之术/09 | 粗放与精益:编程的两种思路与方式.md
Normal file
88
极客时间专栏/程序员进阶攻略/修炼:程序之术/09 | 粗放与精益:编程的两种思路与方式.md
Normal file
@@ -0,0 +1,88 @@
|
||||
<audio id="audio" title="09 | 粗放与精益:编程的两种思路与方式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/c2/4505c1db4eff665c0d4e2e5303a673c2.mp3"></audio>
|
||||
|
||||
几年前,我给团队负责的整个系统写过一些公共库,有一次同事发现这个库里存在一个Bug,并告诉了我出错的现象。然后我便去修复这个Bug,最终只修改了一行代码,但发现一上午就这么过去了。
|
||||
|
||||
一上午只修复了一个Bug,而且只改了一行代码,到底发生了什么?时间都去哪里了?以前觉得自己写代码很快,怎么后来越来越慢了?我认真地思考了这个问题,开始认识到我的编程方式和习惯在那几年已经慢慢发生了变化,形成了明显的两个阶段的转变。这两个阶段是:
|
||||
|
||||
- 写得粗放,写得多
|
||||
- 写得精益,写得好
|
||||
|
||||
## 多与粗放
|
||||
|
||||
粗放,在软件开发这个年轻的行业里其实没有确切的定义,但在传统行业中确实存在相近的关于 “粗放经营” 的概念可类比。引用其百科词条定义如下:
|
||||
|
||||
>
|
||||
粗放经营(Extensive Management),泛指技术和管理水平不高,生产要素利用效率低,产品粗制滥造,物质和劳动消耗高的生产经营方式。
|
||||
|
||||
|
||||
若把上面这段话里面的 “经营” 二字改成 “编程”,就很明确地道出了我想表达的粗放式编程的含义。
|
||||
|
||||
一个典型的粗放式编程场景大概是这样的:需求到开发手上后,开始编码,编码完成,人肉测试,没问题后快速发布到线上,然后进入下一个迭代。
|
||||
|
||||
我早期参与的大量项目过程都与此类似,不停地重复接需求,快速开发,发布上线。在这个过程中,我只是在不停地堆砌功能代码,每天产出的代码量不算少,但感觉都很类似,也很粗糙。这样的过程持续了挺长一个阶段,一度让我怀疑:这样大量而粗放地写代码到底有什么作用和意义?
|
||||
|
||||
后来读到一个故事,我逐渐明白这个阶段是必要的,它因人、因环境而异,或长或短。而那个给我启发的故事,是这样的。
|
||||
|
||||
有一个陶艺老师在第一堂课上说,他会把班上学生分成两组,一组的成绩将会以最终完成的陶器作品数量来评定;而另一组,则会以最终完成的陶器品质来评定。
|
||||
|
||||
在交作业的时候,一个很有趣的现象出现了:“数量” 组如预期一般拿出了很多作品,但出乎意料的是质量最好的作品也全部是由 “数量” 组制作出来的。
|
||||
|
||||
按 “数量” 组的评定标准,他们似乎应该忙于粗制滥造大量的陶器呀。但实际情况是他们每做出一个垃圾作品,都会吸取上一次制作的错误教训,然后在做下一个作品时得到改进。
|
||||
|
||||
而 “品质” 组一开始就追求完美的作品,他们花费了大量的时间从理论上不断论证如何才能做出一个完美的作品,而到了最后拿出来的东西,似乎只是一堆建立在宏大理论上的陶土。
|
||||
|
||||
读完这个故事,我陷入了沉思,感觉故事里的制作陶器和编程提升之路是如此类似。很显然,“品质” 组的同学一开始就在追求理想上的 “好与精益” ,而 “数量” 组同学的完成方式则似我早期堆砌代码时的“多与粗放”,但他们正是通过做得多,不断尝试,快速迭代 ,最后取得到了更好的结果。
|
||||
|
||||
庆幸的是,我在初学编程时,就是在不断通过编程训练来解答一个又一个书本上得来的困惑;后来工作时,则是在不断写程序来解决一个又一个工作中遇到的问题。看到书上探讨各种优雅的代码之道、编程的艺术哲学,那时的我也完全不知道该如何通往这座编程的 “圣杯”,只能看着自己写出的蹩脚代码,然后继续不断重复去制作下一个丑陋的 “陶器”,不断尝试,不断精进和进阶。
|
||||
|
||||
《黑客与画家》书里说:“编程和画画近乎异曲同工。”所以,你看那些成名画家的作品,如果按时间顺序来排列展示,你会发现每幅画所用的技巧,都是建立在上一幅作品学到的东西之上;如果某幅作品特别出众,你往往也能在更早期的作品中找到类似的版本。而编程的精进过程也是类似的。
|
||||
|
||||
总之,这些故事和经历都印证了一个道理:**在通往 “更好” 的路上,总会经过 “更多” 这条路。**
|
||||
|
||||
## 好与精益
|
||||
|
||||
精益,也是借鉴自传统行业里的一个类比:精益生产。
|
||||
|
||||
>
|
||||
精益生产(Lean Production),简言之,就是一种以满足用户需求为目标、力求降低成本、提高产品的质量、不断创新的资源节约型生产方式。
|
||||
|
||||
|
||||
若将定义中的 “生产” 二字换成 “编程”,也就道出了精益编程的内涵。它有几个关键点:质量、成本与效率。但要注意:在编程路上,如果一开始就像 “品质” 组同学那样去追求完美,也许你就会被定义 “完美” 的品质所绊住,而忽视了制作的成本与效率。
|
||||
|
||||
因为编程的难点是,无论你在开始动手编程时看过多少有关编程理论、方法、哲学与艺术的书,一开始你还是无法领悟到什么是编程的正确方法,以及什么是“完美” 的程序。毕竟纸上得来终觉浅,绝知此事要躬行。
|
||||
|
||||
曾经,还在学校学习编程时,有一次老师布置了一个期中课程设计,我很快完成了这个课程设计中的编程作业。而另一位同学,刚刚看完了那本经典的《设计模式》书。
|
||||
|
||||
他尝试用书里学到的新概念来设计这个编程作业,并且又用 UML 画了一大堆交互和类图,去推导设计的完美与优雅。然后兴致勃勃向我(因为我刚好坐在他旁边)讲解他的完美设计,我若有所悟,觉得里面确实有值得我借鉴的地方,就准备吸收一些我能听明白的东西,重构一遍已经写好的作业程序。
|
||||
|
||||
后来,这位同学在动手实现他的完美设计时,发现程序越写越复杂,交作业的时间已经不够了,只好借用我的不完美的第一版代码改改凑合交了。而我在这第一版代码基础上,又按领悟到的正确思路重构了一次、改进了一番后交了作业。
|
||||
|
||||
所以,别被所谓 “完美“ 的程序所困扰,只管先去盯住你要用编程解决的问题,把问题解决,把任务完成。
|
||||
|
||||
**编程,其实一开始哪有什么完美,只有不断变得更好。**
|
||||
|
||||
工作后,我做了大量的项目,发现这些项目都有很多类似之处。每次,即使项目上线后,我也必然重构项目代码,提取其中可复用的代码,然后在下一个项目中使用。循环往复,一直干了七八年。每次提炼重构,都是一次从 “更多” 走向 “更好” 的过程。我想,很多程序员都有类似的经历吧?
|
||||
|
||||
回到开头修改Bug 的例子,我用半天的时间改一个Bug,感觉效率不算高,这符合精益编程的思路吗?先来回顾下这半天改这个Bug 的过程。
|
||||
|
||||
由于出问题的那个公共库是我接到Bug 时的半年前开发的,所以发现那个Bug 后,我花了一些时间来回忆整个公共库的代码结构设计。然后我研究了一下,发现其出现的场景比较罕见,要不不至于线上运行了很久也没人发现,属于重要但不紧急。
|
||||
|
||||
因此,我没有立刻着手去修改代码,而是先在公共库的单元测试集中新写了一组单元测试案例。单元测试构建了该Bug的重现场景,并顺利让单元测试运行失败了,之后我再开始去修改代码,并找到了出问题的那一行,修改后重新运行了单元测试集,并顺利看见了测试通过的绿色进度条。
|
||||
|
||||
而作为一个公共库,修改完成后我还要为本次修改更新发布版本,编写对应的文档,并上传到 Maven 仓库中,才算完成。回想这一系列的步骤,我发现时间主要花在了构建重现Bug 的测试案例场景中,有时为了构建一个测试场景编写代码的难度可能比开发功能本身更困难。
|
||||
|
||||
为修改一个Bug 付出的额外单元测试时间成本,算一种浪费吗?虽说这确实提高了代码的修复成本,但也带来了程序质量的提升。按前面精益的定义,这似乎是矛盾的,但其实更是一种权衡与取舍。
|
||||
|
||||
就是在这样的过程与反复中,我渐渐形成了属于自己的编程价值观:世上没有完美的解决方案,任何方案总是有这样或那样一些因子可以优化。一些方案可能面临的权衡取舍会少些,而另一些方案则会更纠结一些,但最终都要做取舍。
|
||||
|
||||
以上,也说明了一个道理:**好不是完美,好是一个过程,一个不断精益化的过程。**
|
||||
|
||||
编程,当写得足够多了,也足够好了,你才可能自如地在 “多” 与 “好” 之间做出平衡。
|
||||
|
||||
编程的背后是交付程序系统,交付关心的是三点:功能多少,质量好坏,效率快慢。真实的编程环境下, 你需要在三者间取得平衡,哪些部分可能是多而粗放的交付,哪些部分是好而精益的完成,同时还要考虑效率快慢(时间)的需求。
|
||||
|
||||
编程路上,“粗放的多” 是 “精益的好和快” 的前提,而好和快则是你的取舍:是追求好的极致,还是快的极致,或者二者的平衡?
|
||||
|
||||
在多而粗放和好而精益之间,现在你处在哪个阶段了?欢迎留言谈谈你的看法。
|
||||
|
||||
|
||||
63
极客时间专栏/程序员进阶攻略/修炼:程序之术/10 | 炫技与克制:代码的两种味道与态度.md
Normal file
63
极客时间专栏/程序员进阶攻略/修炼:程序之术/10 | 炫技与克制:代码的两种味道与态度.md
Normal file
@@ -0,0 +1,63 @@
|
||||
<audio id="audio" title="10 | 炫技与克制:代码的两种味道与态度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/83/05cac9fc28b03377f9ee01663316eb83.mp3"></audio>
|
||||
|
||||
虽然你代码可能已经写得不少了,但要真正提高代码水平,其实还需要多读代码。就像写作,写得再多,不多读书,思维和认知水平其实是很难提高的。
|
||||
|
||||
代码读得多了,慢慢就会感受到好代码中有一种味道和品质:克制。但也会发现另一种代码,它也会散发出一种味道:炫技。
|
||||
|
||||
## 炫技
|
||||
|
||||
什么是炫技的代码?
|
||||
|
||||
我先从一个读代码的故事说起。几年前我因为工作需要,去研究一个开源项目的源代码。这是一个国外知名互联网公司开源的工具项目,据说已在内部孵化了 6 年之久,这才开源出来。从其设计文档与代码结构来看,它高层设计的一致性还是比较好的,但到了源代码实现就显得凌乱了些,而且发现了一些炫技的痕迹。
|
||||
|
||||
代码中炫技的地方,具体来说就是关于状态机的使用。状态机程序本是不符合线性逻辑思维的,有点类似`goto`语句,程序执行会突然发生跳转,所以理解状态机程序的代码要比一般程序困难些。除此之外,它的状态机程序实现又是通过自定义的内存消息机制来驱动,这又额外添加了一层抽象复杂度。
|
||||
|
||||
而在我看来,状态机程序最适合的场景是一种真实领域状态变迁的映射。那什么叫真实领域状态呢?比如,红绿灯就表达了真实交通领域中的三种状态。而另一种场景,是网络编程领域,广泛应用在网络协议解析上,表达解析器当前的运行状态。
|
||||
|
||||
而但凡使用状态机来表达程序设计实现中引入的 “伪” 状态,往往都添加了不必要的复杂性,这就有点炫技的感觉了。但是我还是能常常在一些开源项目中看到一些过度设计和实现的复杂性,而这些项目往往还都是一些行业内头部大公司开源的。
|
||||
|
||||
在程序员的成长路径上,攀登公司的晋升阶梯时,通常会采用同行评审制度,而作为技术人就容易倾向性地关注项目或工程中的技术含量与难点。
|
||||
|
||||
这样的制度倾向性,有可能导致人为制造技术含量,也就是炫技了。就像体操运动中,你完成一个高难度动作,能加的分数有限,而一旦搞砸了,付出的代价则要惨重很多。所以,在比赛中高难度动作都是在关键的合适时刻才会选择。同样,项目中的炫技,未必能加分,还有可能导致减分,比如其维护与理解成本变高了。
|
||||
|
||||
而**除了增加不必要的复杂性外,炫技的代码,也可能更容易出 Bug**。
|
||||
|
||||
刚工作的头一年,我在广东省中国银行写过一个小程序,就是给所有广东省中国银行的信用卡客户发邮件账单。由于当时广东中行信用卡刚起步,第一个月只有不到 10 万客户,所以算是小程序。
|
||||
|
||||
这个小程序就是个单机程序,为了方便业务人员操作,我写了个 GUI 界面。这是我第一次用 Java Swing 库来写 GUI,为了展示发送进度,后台线程每发送成功一封邮件,就通知页面线程更新进度条。
|
||||
|
||||
为什么这么设计呢?因为那时我正在学习 Java 线程编程,感觉这个技术很高端,而当时的 Java JDK 都还没标配线程 concurrent 包。所以,我选择线程间通信的方案来让后台发送线程和前端界面刷新线程通信,这就有了一股浓浓的炫技味道。
|
||||
|
||||
之后,就出现了界面动不动就卡住等一系列问题,因为各种线程提前通知、遗漏通知等情况没考虑到,代码也越改越难懂。其实后来想想,用个共享状态,定时轮询即可满足需要,而且代码实现会简单很多(前面《架构与实现》一文中,关于实现的核心我总结了一个字:简。这都是血泪教训啊),出 Bug 的概率也小了很多。
|
||||
|
||||
回头想想,成长的路上不免见猎心喜,手上拿个锤子看到哪里都是钉子。
|
||||
|
||||
炫技是因为你想表达得不一样,就像平常说话,你要故意说得引经据典去彰显自己有文化,但其实效果不一定佳,因为我们更需要的是平实、易懂的表达。
|
||||
|
||||
## 克制
|
||||
|
||||
在说克制之前,先说说什么叫不克制,写代码的不克制。
|
||||
|
||||
刚工作的第二年,我接手了一个比较大的项目中的一个主要子系统。在熟悉了整个系统后,我开始往里面增加功能时,有点受不了原本系统设计分层中的 DAO(Data Access Object, 数据访问对象)层,那是基于原生的 JDBC 封装的。每次新增一个 DAO 对象都需要复制粘贴一串看起来很类似的代码,难免生出厌烦的感觉。
|
||||
|
||||
当时开源框架 Hibernate 刚兴起,我觉得它的设计理念优雅,代码写出来也简洁,所以就决定用 Hibernate 的方式来取代原本的实现。原来的旧系统里,说多不多,说少也不少,好几百个 DAO 类,而重新实现整个 DAO 层,让我连续加了一周的班。
|
||||
|
||||
这个替换过程,是个纯粹的搬砖体力活,弄完了还没松口气就又有了新问题:Hibernate 在某些场景下出现了性能问题。陆陆续续把这些新问题处理好,着实让我累了一阵子。后来反思这个决策感觉确实不太妥当,替换带来的好处仅仅是每次新增一个 DAO 类时少写几行代码,却带来很多当时未知的风险。
|
||||
|
||||
那时年轻,有激情啊,对新技术充满好奇与冲动。**其实对于新技术,即使从我知道、我了解到我熟悉、我深谙,这时也还需要克制,要等待合适的时机**。这让我想起了电影《勇敢的心》中的一个场景,是战场上华莱士看着对方冲过来,高喊:“Hold!Hold!”新技术的应用,也需要等待一个合适的出击时刻,也许是应用在新的服务上,也许是下一次架构升级。
|
||||
|
||||
不克制的一种形态是容易做出臆想的、通用化的假设,而且我们还会给这种假设安一个非常正当的理由:扩展性。不可否认,扩展性很重要,但扩展性也应当来自真实的需求,而非假设将来的某天可能需要扩展,因为扩展性的反面就是带来设计抽象的复杂性以及代码量的增加。
|
||||
|
||||
那么,如何才是克制的编程方式?我想可能有这样一些方面:
|
||||
|
||||
- 克制的编码,是每次写完代码,需要去反思和提炼它,代码应当是直观的,可读的,高效的。
|
||||
- 克制的代码,是即使站在远远的地方去看屏幕上的代码,甚至看不清代码的具体内容时,也能感受到它的结构是干净整齐的,而非 “意大利面条” 似的混乱无序。
|
||||
- 克制的重构,是每次看到 “坏” 代码不是立刻就动手去改,而是先标记圈定它,然后通读代码,掌握全局,重新设计,最后再等待一个合适的时机,来一气呵成地完成重构。
|
||||
|
||||
总之,克制是不要留下多余的想象,是不炫技、不追新,且恰到好处地满足需要,是一种平实、清晰、易懂的表达。
|
||||
|
||||
克制与炫技,匹配与适度,代码的技术深度未必体现在技巧上。有句话是这么说的:“看山是山,看水是水;看山不是山,看水不是水;看山还是山,看水还是水。”转了一圈回来,机锋尽敛,大巧若拙,深在深处,浅在浅处。
|
||||
|
||||
最后,亲爱的读者朋友,在你的编码成长过程中,有过想要炫技而不克制的时候吗?欢迎你留言。
|
||||
|
||||
|
||||
180
极客时间专栏/程序员进阶攻略/修炼:程序之术/11 | 三阶段进化:调试,编写与运行代码.md
Normal file
180
极客时间专栏/程序员进阶攻略/修炼:程序之术/11 | 三阶段进化:调试,编写与运行代码.md
Normal file
@@ -0,0 +1,180 @@
|
||||
<audio id="audio" title="11 | 三阶段进化:调试,编写与运行代码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bd/8f/bda17217e95ee0deeb8db9698a80d78f.mp3"></audio>
|
||||
|
||||
刚开始学编程写代码,总会碰到一些困惑。比如,曾经就有刚入行的同学问我:“写程序是想到哪写到哪,边写边改边验证好,还是先整体梳理出思路,有步骤、有计划地分析后,再写更好?”
|
||||
|
||||
老实说,我刚入行时走的是前一条路,因为没有什么人或方法论来指导我,都是自己瞎摸索。一路走来十多年后,再回溯编程之路的经历,总结编程的进化过程,大概会经历下面三个阶段。
|
||||
|
||||
## 阶段一:调试代码 Debugging
|
||||
|
||||
编程,是把用自然语言描述的现实问题,转变为用程序语言来描述并解决问题的过程;翻译,也是把一种语言的文字转变为另一种语言的文字,所以我想编程和翻译应该是有相通之处的。
|
||||
|
||||
好些年前,我曾偶然读到一篇关于性能的英文文章,读完不禁拍案叫绝,就忍不住想翻译过来。那是我第一次尝试翻译长篇英文,老实说翻得很痛苦,断断续续花了好几周的业余时间。那时的我,之于翻译,就是一个刚入门的初学者。
|
||||
|
||||
初次翻译,免不了遇到不少不熟悉的单词或词组,一路磕磕碰碰地查词典或 Google。一些似乎能理解含义的句子,却感觉无法很好地用中文来表达,如果直白地译出来感觉又不像正常的中文句子表达方式。
|
||||
|
||||
如是种种的磕碰之处,难道不像你刚学编程时候的情形吗?刚开始写代码,对语法掌握得不熟,对各种库和 API 不知道,不了解,也不熟悉。一路写代码,翻翻书,查查 Google,搜搜 API 文档,好不容易写完一段代码,却又不知道能否执行,执行能否正确等等。
|
||||
|
||||
小心翼翼地点击 Debug 按钮开始了单步调试之旅,一步步验证所有的变量或执行结果是否符合预期。如果出错了,是在哪一步开始或哪个变量出错的?一段不到一屏的代码,足足单步走了半小时,反复改了好几次,终于顺利执行完毕,按预期输出了执行结果。
|
||||
|
||||
如果不是自己写全新的代码,而是一来就接手了别人的代码,没有文档,前辈稍微给你介绍两句,你就很快又开始了 Debug 的单步调试之旅,一步步搞清代码运行的所有步骤和内部逻辑。根据你接手代码的规模,这个阶段可能持续数天到数周不等。
|
||||
|
||||
这就是我感觉可以划为编程第一阶段的 “调试代码 Debugging” 时期。这个时期或长或短,也许你曾经为各种编程工具或 IDE 提供的高级 Debug 功能激动不已,但如果你不逐渐降低使用Debug 功能的频率,那么你可能很难走入第二阶段。
|
||||
|
||||
## 阶段二:编写代码 Coding
|
||||
|
||||
翻译讲究 “信、达、雅”,编码亦如此。
|
||||
|
||||
那么何谓 “信、达、雅” ?它是由我国清末新兴启蒙思想家严复提出的,他在《天演论》中的 “译例言” 讲到:
|
||||
|
||||
>
|
||||
译事三难:信、达、雅。求其信已大难矣,顾信矣,不达,虽译犹不译也,则达尚焉。
|
||||
|
||||
|
||||
**信,指不违背原文,不偏离原文,不篡改,不增不减,要求准确可信地表达原文描述的事实**。
|
||||
|
||||
这条应用在编程上就是:程序员需要深刻地理解用户的原始需求。虽然需求很多时候来自于需求(产品)文档,但需求(产品)文档上写的并不一定真正体现了用户的原始需求。关于用户需求的“提炼”,早已有流传甚广的“福特之问”。
|
||||
|
||||
>
|
||||
<p>福特:您需要一个什么样的更好的交通工具? <br />
|
||||
用户:我要一匹更快的马。</p>
|
||||
|
||||
|
||||
用户说需要一匹更快的马,你就跑去 “养” 只更壮、更快的马;后来用户需求又变了,说要让马能在天上飞,你可能就傻眼了,只能拒绝用户说:“这需求不合理,技术上实现不了。”可见,用户所说的也不可 “信” 矣。只有真正挖掘并理解了用户的原始需求,最后通过编程实现的程序系统才是符合 “信” 的标准的。
|
||||
|
||||
但在这一条的修行上几乎没有止境,因为要做到 “信” 的标准,编写行业软件程序的程序员需要在一个行业长期沉淀,才能慢慢搞明白用户的真实需求。
|
||||
|
||||
**达,指不拘泥于原文的形式,表达通顺明白,让读者对所述内容明达**。
|
||||
|
||||
这条应用在编程上就是在说程序的可读性、可理解性和可维护性。
|
||||
|
||||
按严复的标准,只满足 “信” 一条的翻译,还不如不译,至少还需要满足 “达” 这条才算尚可。
|
||||
|
||||
同样,只满足 “信” 这一条的程序虽然能准确地满足用户的需要,但没有 “达” 则很难维护下去。因为程序固然是写给机器去执行的,但其实也是给人看的。
|
||||
|
||||
所有关于代码规范和风格的编程约束都是在约定 “达” 的标准。个人可以通过编程实践用时间来积累经验,逐渐达到 “达” 的标准。但一个团队中程序员们的代码风格差异如何解决?这就像如果一本书由一群人来翻译,你会发现每章的文字风格都有差异,所以我是不太喜欢读由一群人一起翻译的书。
|
||||
|
||||
一些流行建议的解决方案是:多沟通,深入理解别人的代码思路和风格,不要轻易盲目地修改。但这些年实践下来,这个方法在现实中走得并不顺畅。
|
||||
|
||||
随着微服务架构的流行,倒是提供了另一种解决方案:每个服务对应一个唯一的负责人(Owner)。长期由一个人来维护的代码,就不会那么容易腐烂,因为一个人不存在沟通问题。而一个人所能 “达” 到的层次,完全由个人的经验水平和追求来决定。
|
||||
|
||||
**雅,指选用的词语要得体,追求文章本身的古雅,简明优雅**。
|
||||
|
||||
雅的标准,应用在编程上已经从技艺上升到了艺术的追求,这当然是很高的要求与自我追求了,难以强求。而只有先满足于 “信” 和 “达” 的要求,你才有余力来追求 “雅” 。
|
||||
|
||||
举个例子来说明下从 “达” 到 “雅” 的追求与差异。
|
||||
|
||||
下面是一段程序片段,同一个方法,实现完全一样的功能,都符合 “信” 的要求;而方法很短小,命名也完全符合规范,可理解性和维护性都没问题,符合 “达” 的要求;差别就在对 “雅” 的追求上。
|
||||
|
||||
```
|
||||
private String generateKey(String service, String method) {
|
||||
String head = "DBO$";
|
||||
String key = "";
|
||||
|
||||
int len = head.length() + service.length() + method.length();
|
||||
if (len <= 50) {
|
||||
key = head + service + method;
|
||||
} else {
|
||||
service = service.substring(service.lastIndexOf(".") + 1);
|
||||
len = head.length() + service.length() + method.length();
|
||||
key = head + service + method;
|
||||
if (len > 50) {
|
||||
key = head + method;
|
||||
if (key.length() > 50) {
|
||||
key = key.substring(0, 48) + ".~";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
该方法的目标是生成一个字符串 key 值,传入两个参数:服务名和方法名,然后返回 key 值,key 的长度受外部条件约束不能超过 50 个字符。方法实现不复杂,很短,看起来也还不错,分析下其中的逻辑:
|
||||
|
||||
1. 先 key 由固定的头(head)+ service(全类名)+ method(方法)组成,若小于 50 字符,直接返回。
|
||||
1. 若超过 50 字符限制,则去掉包名,保留类名,再判断一次,若此时小于 50 字符则返回。
|
||||
1. 若还是超过 50 字符限制,则连类名一起去掉,保留头和方法再判断一次,若小于 50 字符则返回。
|
||||
1. 最后如果有个变态长的方法名(46+ 个字符),没办法,只好暴力截断到 50 字符返回。
|
||||
|
||||
这个实现最大限度地在生成的 key 中保留全部有用的信息,对超过限制的情况依次按信息重要程度的不同进行丢弃。这里只有一个问题,这个业务规则只有 4 个判断,实现进行了三次 if 语句嵌套,还好这个方法比较短,可读性还不成问题。
|
||||
|
||||
而现实中很多业务规则比这复杂得多,以前看过一些实现的 if 嵌套多达 10 层的,方法也长得要命。当然一开始没有嵌套那么多层,只是后来随着时间的演变,业务规则发生了变化,慢慢增加了。之后接手的程序员就按照这种方式继续嵌套下去,慢慢演变至此,到我看到的时候就有 10 层了。
|
||||
|
||||
程序员有一种编程的惯性,特别是进行维护性编程时。一开始接手一个别人做的系统,不可能一下能了解和掌控全局。当要增加新功能时,在原有代码上添加逻辑,很容易保持原来程序的写法惯性,因为这样写也更安全。
|
||||
|
||||
所以一个 10 层嵌套 if 的业务逻辑方法实现,第一个程序员也许只写了 3 次嵌套,感觉还不错,也不失简洁。后来写 4、5、6 层的程序员则是懒惰不愿再改,到了写第 8、9、10 层的程序员时,基本很可能就是不敢再乱动了。
|
||||
|
||||
那么如何让这个小程序在未来的生命周期内,更优雅地演变下去?下面是另一个版本的实现:
|
||||
|
||||
```
|
||||
private String generateKey(String service, String method) {
|
||||
String head = "DBO$";
|
||||
String key = head + service + method;
|
||||
|
||||
// head + service(with package) + method
|
||||
if (key.length() <= 50) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// head + service(without package) + method
|
||||
service = service.substring(service.lastIndexOf(".") + 1);
|
||||
key = head + service + method;
|
||||
if (key.length() <= 50) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// head + method
|
||||
key = head + method;
|
||||
if (key.length() <= 50) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// last, we cut the string to 50 characters limit.
|
||||
key = key.substring(0, 48) + ".~";
|
||||
return key;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从嵌套变成了顺序逻辑,这样可以为未来的程序员留下更优雅地编程惯性方向。
|
||||
|
||||
## 阶段三:运行代码 Running
|
||||
|
||||
编程相对翻译,其超越 “信、达、雅” 的部分在于:翻译出来的文字能让人读懂,读爽就够了;但代码写出来还需要运行,才能产生最终的价值。
|
||||
|
||||
写程序我们追求 “又快又好”,并且写出来的代码要符合 “信、达、雅” 的标准,但清晰定义 “多快多好” 则是指运行时的效率和效果。为准确评估代码的运行效率和效果,每个程序员可能都需要深刻记住并理解下面这张关于程序延迟数字的图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/52/00/5262e4b7f60a60273be6b79a2fd26f00.png" alt="" />
|
||||
|
||||
只有深刻记住并理解了程序运行各环节的效率数据,你才有可能接近准确地评估程序运行的最终效果。当然,上面这张图只是最基础的程序运行效率数据,实际的生产运行环节会需要更多的基准效率数据才可能做出更准确的预估。
|
||||
|
||||
说一个例子,曾经我所在团队的一个高级程序员和我讨论要在所有的微服务中引入一个限流开源工具。这对于他和我们团队都是一个新东西,如何进行引入后线上运行效果的评估呢?
|
||||
|
||||
第一步,他去阅读资料和代码搞懂该工具的实现原理与机制并能清晰地描述出来。第二步,去对该工具进行效果测试,又称功能可用性验证。第三步,进行基准性能测试,或者又叫基准效率测试(Benchmark),以确定符合预期的标准。
|
||||
|
||||
做完上述三步,他拿出一个该工具的原理性描述说明文档,一份样例使用代码和一份基准效率测试结果,如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/41/172a61261b64a6847a625afd17131c41.png" alt="" />
|
||||
|
||||
上图中有个红色字体部分,当阀值设置为 100 万而请求数超过 100 万时,发生了很大偏差。这是一个很奇怪的测试结果,但如果心里对各种基准效率数据有谱的话,会知道这实际绝不会影响线上服务的运行。
|
||||
|
||||
因为我们的服务主要由两部分组成:RPC 和业务逻辑。而 RPC 又由网络通信加上编解码序列化组成。服务都是 Java 实现的,而目前 Java 中最高效且吞吐最大的网络通信方式是基于 NIO 的方式,而我们服务使用的 RPC 框架正是基于 Netty(一个基于 Java NIO 的开源网络通信框架)的。
|
||||
|
||||
我曾经单独在一组 4 核的物理主机上测试过 Java 原生 NIO 与 Netty v3 和 v4 两个版本的基准性能对比,经过 Netty 封装后,大约有 10% 的性能损耗。在 1K 大小报文时,原生的 Java NIO 在当时的测试环境所能达到 TPS(每秒事务数) 的极限大约 5 万出头(极限,就是继续加压,但 TPS 不再上升,CPU 也消耗不上去,延时却在增加),而 Netty 在 4.5 万附近。增加了 RPC 的编解码后,TPS 极限下降至 1.3 万左右。
|
||||
|
||||
所以,实际一个服务在类似基准测试的环境下单实例所能承载的 TPS 极限不可能超过 RPC 的上限,因为 RPC 是没有包含业务逻辑的部分。加上不算简单的业务逻辑,我能预期的单实例真实 TPS 也许只有 1千 ~2 千。
|
||||
|
||||
因此,上面 100 万的阀值偏差是绝对影响不到单实例的服务的。当然最后我们也搞明白了,100 万的阀值偏差来自于时间精度的大小,那个限流工具采用了微秒作为最小时间精度,所以只能在百万级的范围内保证准确。
|
||||
|
||||
讲完上述例子,就是想说明一个程序员要想精确评估程序的运行效率和效果,就得自己动手做大量的基准测试。
|
||||
|
||||
基准测试和测试人员做的性能测试不同。测试人员做的性能测试都是针对真实业务综合场景的模拟,测试的是整体系统的运行;而基准测试是开发人员自己做来帮助准确理解程序运行效率和效果的方式,当测试人员在性能测试发现了系统的性能问题时,开发人员才可能一步步拆解根据基准测试的标尺效果找到真正的瓶颈点,否则大部分的性能优化都是在靠猜测。
|
||||
|
||||
到了这个阶段,一段代码写出来,基本就该在你头脑中跑过一遍了。等上线进入真实生产环境跑起来,你就可以拿真实的运行数据和头脑中的预期做出对比,如果差距较大,那可能就掩藏着问题,值得你去分析和思考。
|
||||
|
||||
最后,文章开头那个问题有答案了吗?在第一阶段,你是想到哪就写到哪;而到了第三阶段,写到哪,一段鲜活的代码就成为了你想的那样。
|
||||
|
||||
你现在处在编程的哪个阶段?有怎样的感悟?欢迎你留言分享。
|
||||
|
||||
|
||||
76
极客时间专栏/程序员进阶攻略/修炼:程序之术/12 | Bug的空间属性:环境依赖与过敏反应.md
Normal file
76
极客时间专栏/程序员进阶攻略/修炼:程序之术/12 | Bug的空间属性:环境依赖与过敏反应.md
Normal file
@@ -0,0 +1,76 @@
|
||||
<audio id="audio" title="12 | Bug的空间属性:环境依赖与过敏反应" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/53/72/53ba18e9c4c13e563f00f25af416b472.mp3"></audio>
|
||||
|
||||
从今天开始,咱们专栏进入 “程序之术” 中关于写代码的一个你可能非常熟悉,却也常苦恼的小主题:Bug。
|
||||
|
||||
写程序的路上,会有一个长期伴随你的 “同伴”:Bug,它就像程序里的寄生虫。不过,Bug 最早真的是一只虫子。
|
||||
|
||||
1947年,哈佛大学的计算机哈佛二代(Harvard Mark II)突然停止了运行,程序员在电路板编号为 70 的中继器触点旁发现了一只飞蛾。然后把飞蛾贴在了计算机维护日志上,并写下了首个发现 Bug 的实际案例。程序错误从此被称作 Bug。
|
||||
|
||||
这只飞蛾也就成了人类历史上的第一个程序 Bug。
|
||||
|
||||
回想下,在编程路上你遇到得最多的 Bug 是哪类?我的个人感受是,经常被测试或产品经理要求修改和返工的 Bug。这类 Bug 都来自于对需求理解的误差,其实属于沟通理解问题,我并不将其归类为真正的技术性 Bug。
|
||||
|
||||
技术性 Bug 可以从很多维度分类,而我则习惯于从 Bug 出现的 “时空” 特征角度来分类。可划为如下两类:
|
||||
|
||||
- 空间:环境过敏
|
||||
- 时间:周期规律
|
||||
|
||||
我们就先看看 Bug 的**空间维度**特征。
|
||||
|
||||
## 环境过敏
|
||||
|
||||
环境,即程序运行时的空间与依赖。
|
||||
|
||||
程序运行的依赖环境是很复杂的,而且一般没那么可靠,总是可能出现这样或那样的问题。曾经我经历过一次因为运行环境导致的故障案例:一开始系统异常表现出来的现象是,有个功能出现时不时的不可用;不久之后,系统开始报警,不停地接到系统的报警短信。
|
||||
|
||||
这是一个大规模部署的线上分布式系统,从一开始能感知到的个别系统功能异常到逐渐演变成大面积的报警和业务异常,这让我们陷入了一个困境:到底异常根源在哪里?为了迅速恢复系统功能的可用性,我们先把线上流量切到备用集群后,开始紧急地动员全体团队成员各自排查其负责的子系统和服务,终于找到了原因。
|
||||
|
||||
只是因为有个别服务器容器的磁盘故障,导致写日志阻塞,进程挂起,然后引发调用链路处理上的连锁雪崩效应,其影响效果就是整个链路上的系统都在报警。
|
||||
|
||||
互联网企业多采用普通的 PC Server 作为服务器,而这类服务器的可靠性大约在 99.9%,换言之就是出故障的概率是千分之一。而实际在服务器上,出问题概率最高的可能就是其机械硬盘。
|
||||
|
||||
Backblaze 2014 年发布的硬盘统计报告指出,根据对其数据中心 38000 块硬盘(共存储 100PB 数据)的统计,消费级硬盘头三年出故障的几率是 15%。而在一个足够大规模的分布式集群部署上,比如 Google 这种百万级服务器规模的部署级别上,几乎每时每刻都有硬盘故障发生。
|
||||
|
||||
我们的部署规模自是没有 Google 那么大,但也不算小了,运气不好,正好赶上我们的系统碰上磁盘故障,而程序的编写又并未考虑硬盘 I/O 阻塞导致的挂起异常问题,引发了连锁效应。
|
||||
|
||||
这就是当时程序编写缺乏对环境问题的考虑,引发了故障。人有时换了环境,会产生一些从生理到心理的过敏反应,程序亦然。运行环境发生变化,程序就出现异常的现象,我称其为 “程序过敏反应”。
|
||||
|
||||
以前看过一部美剧《豪斯医生》,有一集是这样的:一个手上出现红色疱疹的病人来到豪斯医生的医院,豪斯医生根据病症现象初步诊断为对某种肥皂产生了过敏,然后开了片抗过敏药,吃过后疱疹症状就减轻了。但一会儿后,病人开始出现呼吸困难兼并发哮喘,豪斯医生立刻给病人注射了 1cc 肾上腺素,之后病人呼吸开始变得平稳。但不久后病人又出现心动过速,而且很快心跳便停止了,经过一番抢救后,最终又回到原点,病人手上的红色疱疹开始在全身出现。
|
||||
|
||||
这个剧情中表现了在治疗病人时发生的身体过敏反应,然后引发了连锁效应的问题,这和我之前描述的例子有相通之处:都是局部的小问题,引发程序过敏反应,再到连锁效应。
|
||||
|
||||
过敏在医学上的解释是:“有机体将正常无害的物质误认为是有害的东西。”而我对 “程序过敏反应” 的定义是:“程序将存在问题的环境当作正常处理,从而产生的异常。”而潜在的环境问题通常就成了程序的 “过敏原”。
|
||||
|
||||
该如何应对这样的环境过敏引发的 Bug 呢?
|
||||
|
||||
## 应对之道
|
||||
|
||||
应对环境过敏,自然要先从**了解环境**开始。
|
||||
|
||||
不同的程序部署和运行的环境千差万别,有的受控,有的不受控。比如,服务端运行的环境,一般都在数据中心(IDC)机房内网中,相对受控;而客户端运行的环境是在用户的设备上,存在不同的品牌、不同的操作系统、不同的浏览器等等,多种多样,不可控。
|
||||
|
||||
环境那么复杂,你需要了解到何种程度呢?我觉得你至少必须关心与程序运行直接相关联的那一层环境。怎么理解呢?以后端 Java 程序的运行为例,Java 是运行在 JVM 中,那么 JVM 提供的运行时配置和特性就是你必须要关心的一层环境了。而 JVM 可能是运行在 Linux 操作系统或者是像 Docker 这样的虚拟化容器中,那么 Linux 或 Docker 这一层,理论上你的关心程度就没太多要求,当然,学有余力去了解到这一层次,自是更好的。
|
||||
|
||||
那么前文案例中的磁盘故障,已经到了硬件的层面,这个环境层次比操作系统还更低一层,这也属于我们该关心的?虽说故障的根源是磁盘故障,但直接连接程序运行的那一层,其实是日志库依赖的 I/O 特性,这才是我们团队应该关心、但实际却被忽略掉的部分。
|
||||
|
||||
同理,现今从互联网到移动互联网时代,几乎所有的程序系统都和网络有关,所以网络环境也必须是你关心的。但网络本身也有很多层次,而对于在网络上面开发应用程序的你我来说,可以把网络模糊抽象为一个层次,只用关心网络距离延时,以及应用程序依赖的具体平台相关网络库的 I/O 特性。
|
||||
|
||||
当然,如果能对网络的具体层次有更深刻的理解,自然也是更好的。事实上,如果你和一个对网络具体层次缺乏理解的人调试两端的网络程序,碰到问题时,经常会发现沟通不在一个层面上,产生理解困难。(这里推荐下隔壁的“趣谈网络协议”专栏)
|
||||
|
||||
了解了环境,也难免不出 Bug。因为我们对环境的理解是渐进式的,不可能一下子就完整掌握,全方位,无死角。当出现了因为环境产生的过敏反应时,收集足够多相关的信息才能帮助快速定位和解决问题,这就是前面《代码与分类》文章中 “运维” 类代码需要提供的服务。
|
||||
|
||||
**收集信息**,不仅仅局限于相关直接依赖环境的配置和参数,也包括用户输入的一些数据。真实场景确实大量存在这样一种情况:同样的环境只针对个别用户发生异常过敏反应。
|
||||
|
||||
有一种药叫抗过敏药,那么也可以有一种代码叫 “抗过敏代码”。在收集了足够的信息后,你才能编写这样的代码,因为现实中,程序最终会运行在一些一开始你可能没考虑到的环境中。收集到了这样的环境信息,你才能写出针对这种环境的 “抗过敏代码”。
|
||||
|
||||
这样的场景针对客户端编程特别常见,比如客户端针对运行环境进行的自检测和自适应代码。检测和适应范围包括:CPU、网络、存储、屏幕、操作系统、权限、安全等各方面,这些都属于环境抗过敏类代码。
|
||||
|
||||
而服务端相对环境一致性更好,可控,但面临的环境复杂性更多体现在 “三高” 要求,即:高可用、高性能、高扩展。针对 “三高” 的要求,服务端程序生产运行环境的可靠性并不如你想象的高,虽然平时的开发、调试中你可能很难遇到这些环境故障,但大规模的分布式程序系统,面向失败设计和编码(Design For Failure)则是服务端的 “抗过敏代码” 了。
|
||||
|
||||
整体简单总结一下就是:空间即环境,包括了程序的运行和依赖环境;环境是多维度、多层次的,你对环境的理解越全面、越深入,那么出现空间类 Bug 的几率也就越低;对环境的掌控有广度和深度两个方向,更有效的方法是先广度全面了解,再同步与程序直接相连的一层去深度理解,最后逐层深入,“各个击破”。
|
||||
|
||||
文章开头的第一只飞蛾 Bug,按我的分类就应该属于空间类 Bug 了,空间类 Bug 感觉麻烦,但若单独出现时,相对有形(异常现场容易捕捉);如果加上时间的属性,就变得微妙多了。
|
||||
|
||||
下一篇,我将继续为你分解 Bug 的时间维度特征。
|
||||
|
||||
|
||||
95
极客时间专栏/程序员进阶攻略/修炼:程序之术/13 | Bug的时间属性:周期特点与非规律性.md
Normal file
95
极客时间专栏/程序员进阶攻略/修炼:程序之术/13 | Bug的时间属性:周期特点与非规律性.md
Normal file
@@ -0,0 +1,95 @@
|
||||
<audio id="audio" title="13 | Bug的时间属性:周期特点与非规律性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/05/56c6cad43b9358f1796ecfbe7a220e05.mp3"></audio>
|
||||
|
||||
在上一篇文章中,我说明了“技术性 Bug 可以从很多维度分类,而我则习惯于从 Bug 出现的 ‘时空’ 特征角度来分类”。并且我也已讲解了Bug 的**空间维度**特征:程序对运行环境的依赖、反应及应对。
|
||||
|
||||
接下来我再继续分解 Bug 的**时间维度**特征。
|
||||
|
||||
Bug 有了时间属性,Bug 的出现就是一个概率性问题了,它体现出如下特征。
|
||||
|
||||
## 周期特点
|
||||
|
||||
周期特点,是一定频率出现的 Bug 的特征。
|
||||
|
||||
这类 Bug 因为会周期性地复现,相对还是容易捕捉和解决。比较典型的呈现此类特征的 Bug 一般是资源泄漏问题。比如,Java 程序员都不陌生的 `OutOfMemory` 错误,就属于内存泄漏问题,而且一定会周期性地出现。
|
||||
|
||||
好多年前,我才刚参加工作不久,就碰到这么一个周期性出现的 Bug。但它的特殊之处在于,出现 Bug 的程序已经稳定运行了十多年了,突然某天开始就崩溃(进程 Crash)了。而程序的原作者,早已不知去向,十多年下来想必也已换了好几代程序员来维护了。
|
||||
|
||||
一开始项目组内经验老到的高工认为也许这只是一个意外事件,毕竟这个程序已经稳定运行了十来年了,而且检查了一遍程序编译后的二进制文件,更新时间都还停留在那遥远的十多年前。所以,我们先把程序重启起来让业务恢复,重启后的程序又恢复了平稳运行,但只是安稳了这么一天,第二天上班没多久,进程又莫名地崩溃了,我们再次重启,但没多久后就又崩溃了。这下没人再怀疑这是意外了,肯定有 Bug。
|
||||
|
||||
当时想想能找出一个隐藏了这么多年的 Bug,还挺让人兴奋的,就好像发现了埋藏在地下久远的宝藏。
|
||||
|
||||
寻找这个 Bug 的过程有点像《盗墓笔记》中描述的盗墓过程:项目经理(三叔)带着两个高级工程师(小哥和胖子)连续奋战了好几天,而我则是个新手,主要负责 “看门”,在他们潜入跟踪分析探索的过程中,我就盯着那个随时有可能崩溃的进程,一崩掉就重启。他们“埋伏”在那里,系统崩溃后抓住现场,定位到对应的源代码处,最后终于找到了原因并顺利修复。
|
||||
|
||||
依稀记得,最后定位到的原因与网络连接数有关,也是属于资源泄漏的一种,只是因为过去十来年交易量一直不大且稳定,所以没有显现出来。但在我参加工作那年(2006年),中国股市悄然引来一场有史以来最大的牛市,这个处理银行和证券公司之间资金进出的程序的“工作量”突然出现了爆发性增长,从而引发了该 Bug。
|
||||
|
||||
我可以理解上世纪九十年代初那个编写该服务进程的程序员,他可能也难以预料到当初写的用者寥寥的程序,最终在十多年后的一天会服务于成百上千万的用户。
|
||||
|
||||
周期性的 Bug,虽然乍一看很难解决的样子,但它总会重复出现,就像可以重新倒带的 “案发现场”,找到真凶也就简单了。案例中这个 Bug 隐藏的时间很长,但它所暴露出的周期特点很明显,解决起来也就没那么困难。
|
||||
|
||||
其实主要麻烦的是那种这次出现了,但不知道下次会在什么时候出现的 Bug。
|
||||
|
||||
## 非规律性
|
||||
|
||||
没有规律性的 Bug,才是让人抓狂的。
|
||||
|
||||
曾经我接手过一个系统,是一个典型的生产者、消费者模型系统。系统接过来就发现一个比较明显的性能瓶颈问题,生产者的数据源来自数据库,生产者按规则提取数据,经过系统产生一系列的转换渲染后发送到多个外部系统。这里的瓶颈就在数据库上,生产能力不足,从而导致消费者饥饿。
|
||||
|
||||
问题比较明显,我们先优化 SQL,但效果不佳,遂改造设计实现,在数据库和系统之间增加一个内存缓冲区从而缓解了数据库的负载压力。缓冲区的效果,类似大河之上的堤坝,旱时积水,涝时泄洪。引入缓冲区后,生产者的生产能力得到了有效保障,生产能力高效且稳定。
|
||||
|
||||
本以为至此解决了该系统的瓶颈问题,但在生产环境运行了一段时间后,系统表现为速度时快时慢,这时真正的 Bug 才显形了。
|
||||
|
||||
这个系统有个特点,就是 I/O 密集型。消费者要与多达 30 个外部系统并发通信,所以猜测极有可能导致系统性能不稳定的 Bug 就在此,于是我把目光锁定在了消费者与外部系统的 I/O 通信上。既然锁定了怀疑区域,接下来就该用证据来证明,并给出合理的解释原因了。一开始假设在某些情况下触碰到了阈值极限,当达到临界点时程序性能则急剧下降,不过这还停留在怀疑假设阶段,接下来必须量化验证这个推测。
|
||||
|
||||
那时的生产环境不太方便直接验证测试,我便在测试环境模拟。用一台主机模拟外部系统,一台主机模拟消费者。模拟主机上的线程池配置等参数完全保持和生产环境一致,以模仿一致的并发数。通过不断改变通信数据包的大小,发现在数据包接近 100k 大小时,两台主机之间直连的千兆网络 I/O 达到满负载。
|
||||
|
||||
于是,再回头去观察生产环境的运行状况,当一出现性能突然急剧下降的情况时,立刻分析了生产者的数据来源。其中果然有不少大报文数据,有些甚至高达 200k,至此基本确定了与外部系统的 I/O 通信瓶颈。解决办法是增加了数据压缩功能,以牺牲 CPU 换取 I/O。
|
||||
|
||||
增加了压缩功能重新上线后,问题却依然存在,系统性能仍然时不时地急剧降低,而且这个时不时很没有时间规律,但关联上了一个 “嫌疑犯”:它的出现和大报文数据有关,这样复现起来就容易多了。I/O 瓶颈的怀疑被证伪后,只好对程序执行路径增加了大量跟踪调试诊断代码,包含了每个步骤的时间度量。
|
||||
|
||||
在完整的程序执行路径中,每个步骤的代码块的执行时间独立求和结果仅有几十毫秒,最高也就在一百毫秒左右,但多线程执行该路径的汇总平均时间达到了 4.5 秒,这比我预期值整整高了两个量级。通过这两个时间度量的巨大差异,我意识到线程执行该代码路径的时间其实并不长,但花在等待 CPU 调度的时间似乎很长。
|
||||
|
||||
那么是 CPU 达到了瓶颈么?通过观察服务器的 CPU 消耗,平均负载却不高。只好再次分析代码实现机制,终于在数据转换渲染子程序中找到了一段可疑的代码实现。为了验证疑点,再次做了一下实验测试:用 150k 的线上数据报文作为该程序输入,单线程运行了下,发现耗时居然接近 50 毫秒,我意识到这可能是整个代码路径中最耗时的一个代码片段。
|
||||
|
||||
由于这个子程序来自上上代程序员的遗留代码,包含一些稀奇古怪且复杂的渲染逻辑判断和业务规则,很久没人动过了。仔细分析了其中实现,基本就是大量的文本匹配和替换,还包含一些加密、Hash 操作,这明显是一个 CPU 密集型的函数啊。那么在多线程环境下,运行这个函数大概平均每个线程需要多少时间呢?
|
||||
|
||||
先从理论上来分析下,我们的服务器是 4 核,设置了 64 个线程,那么理想情况下同一时间可以运行 4 个线程,而每个线程执行该函数约为 50 毫秒。这里我们假设 CPU 50 毫秒才进行线程上下文切换,那么这个调度模型就被简化了。第一组 4 个线程会立刻执行,第二组 4 个线程会等待 50 毫秒,第三组会等待 100 毫秒,依此类推,第 16 组线程执行时会等待 750 毫秒。平均下来,每组线程执行前的平均等待时间应该是在 300 到 350 毫秒之间。这只是一个理论值,实际运行测试结果,平均每个线程花费了 2.6 秒左右。
|
||||
|
||||
实际值比理论值慢一个量级,这是为什么呢?因为上面理论的调度模型简化了 CPU 的调度机制,在线程执行过程的 50 毫秒中,CPU 将发生非常多次的线程上下文切换。50 毫秒对于 CPU 的时间分片来说,实在是太长了,因为线程上下文的多次切换和 CPU 争夺带来了额外的开销,导致在生产环境上,实际的监测值达到了 4.5 秒,因为整个代码路径中除了这个非常耗时的子程序函数,还有额外的线程同步、通知和 I/O 等操作。
|
||||
|
||||
分析清楚后,通过简单优化该子程序的渲染算法,从近 50 毫秒降低到 3、4 毫秒后,整个代码路径的线程平均执行时间下降到 100 毫秒左右。收益是明显的,该子程序函数性能得到了 10 倍的提高,而整体执行时间从 4.5 秒降低为 100 毫秒,性能提高了 45 倍。
|
||||
|
||||
至此,这个非规律性的 Bug 得到了解决。
|
||||
|
||||
虽然案例中最终解决了 Bug,但用的方法却非正道,更多依靠的是一些经验性的怀疑与猜测,再去反过来求证。这样的方法局限性非常明显,完全依赖程序员的经验,然后就是运气了。如今再来反思,一方面由于是刚接手的项目,所以我对整体代码库掌握还不够熟悉;另一方面也说明当时对程序性能的分析工具了解有限。
|
||||
|
||||
而更好的办法就应该是采用工具,直接引入代码 Profiler 等性能剖析工具,就可以准确地找到有性能问题的代码段,从而避免了看似有理却无效的猜测。
|
||||
|
||||
面对非规律性的 Bug,最困难的是不知道它的出现时机,但一旦找到它重现的条件,解决起来也没那么困难了。
|
||||
|
||||
## 神出鬼没
|
||||
|
||||
能称得上神出鬼没的 Bug 只有一种:**海森堡 Bug(Heisenbug)**。
|
||||
|
||||
这个 Bug 的名字来自量子物理学的 “海森堡不确定性原理”,其认为观测者观测粒子的行为会最终影响观测结果。所以,我们借用这个效应来指代那些无法进行观测的 Bug,也就是在生产环境下不经意出现,费尽心力却无法重现的 Bug。
|
||||
|
||||
海森堡 Bug 的出现场景通常都是和分布式的并发编程有关。我曾经在写一个网络服务端程序时就碰到过一次海森堡 Bug。这个程序在稳定性负载测试时,连续跑了十多个小时才出现了一次异常,然后在之后的数天内就再也不出现了。
|
||||
|
||||
第一次出现时捕捉到的现场信息太少,然后增加了更多诊断日志后,怎么测都不出现了。最后是怎么定位到的?还好那个程序的代码量不大,就天天反复盯着那些代码,好几天过去还真就灵光一现发现了一个逻辑漏洞,而且从逻辑推导,这个漏洞如果出现的话,其场景和当时测试发现的情况是吻合的。
|
||||
|
||||
究其根源,该 Bug 复现的场景与网络协议包的线程执行时序有关。所以,一方面比较难复现,另一方面通过常用的调试和诊断手段,诸如插入日志语句或是挂接调试器,往往会修改程序代码,或是更改变量的内存地址,或是改变其执行时序。这都影响了程序的行为,如果正好影响到了 Bug,就可能诞生了一个海森堡 Bug。
|
||||
|
||||
关于海森堡 Bug,一方面很少有机会碰到,另一方面随着你编程经验的增加,掌握了很多编码的优化实践方法,也会大大降低撞上海森堡 Bug 的几率。
|
||||
|
||||
综上所述,每一个 Bug 都是具体的,每一个具体的 Bug 都有具体的解法。但所有 Bug 的解决之道只有两类:事后和事前。
|
||||
|
||||
事后,就是指 Bug 出现后容易捕捉现场并定位解决的,比如第一类周期特点的 Bug。但对于没有明显重现规律,甚至神出鬼没的海森堡 Bug,靠抓现场重现的事后方法就比较困难了。针对这类 Bug,更通用和有效的方法就是在事前预防与埋伏。
|
||||
|
||||
之前在讲编程时说过一类代码:运维代码,它们提供的一种能力就像人体血液中的白细胞,可以帮助发现、诊断、甚至抵御 Bug 的 “入侵”。
|
||||
|
||||
而为了得到一个更健康、更健壮的程序,运维类代码需要写到何种程度,这又是编程的 “智慧” 领域了,充满了权衡选择。
|
||||
|
||||
程序员不断地和 Bug 对抗,正如医生不断和病菌对抗。不过Bug 的存在意味着这是一段活着的、有价值的代码,而死掉的代码也就无所谓 Bug 了。
|
||||
|
||||
在你的程序员职业生涯中,有碰到过哪些有意思的 Bug呢?欢迎你给我留言分享讨论。
|
||||
|
||||
|
||||
134
极客时间专栏/程序员进阶攻略/修炼:程序之术/14 | Bug的反复出现:重蹈覆辙与吸取教训.md
Normal file
134
极客时间专栏/程序员进阶攻略/修炼:程序之术/14 | Bug的反复出现:重蹈覆辙与吸取教训.md
Normal file
@@ -0,0 +1,134 @@
|
||||
<audio id="audio" title="14 | Bug的反复出现:重蹈覆辙与吸取教训" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d0/98/d0b58e1b9e1c158f3cb05f3a9504bf98.mp3"></audio>
|
||||
|
||||
Bug 除了时间和空间两种属性,还有一个特点是和程序员直接相关的。在编程的路上,想必你也曾犯过一些形态各异、但本质重复的错误,导致一些 Bug 总是以不同的形态反复出现。在你捶胸顿足懊恼之时,不妨试着反思一下:为什么你总会写出有 Bug 的程序,而且有些同类型的 Bug 还会反复出现?
|
||||
|
||||
## 1. 重蹈覆辙
|
||||
|
||||
重蹈覆辙的错误,老实说曾经我经历过不止一次。
|
||||
|
||||
也许每次具体的形态可能有些差异,但仔细究其本质却是类似的。想要写出没有 Bug 的程序是不可能的,因为所有的程序员都受到自身能力水平的局限。而我所经历的重蹈覆辙型错误,总结下来大概都可以归为以下三类原因。
|
||||
|
||||
### 1.1 粗心大意
|
||||
|
||||
人人都会犯粗心大意的错误,因为这就是 “人” 这个系统的普遍固有缺陷(Bug)之一。所以,作为人的程序员一定会犯一些非常低级的、因为粗心大意而导致的 Bug。
|
||||
|
||||
这就好比写文章、写书都会有错别字,即使经历过三审三校后正式出版的书籍,都无法完全避免错别字的存在。
|
||||
|
||||
而程序中也有这类 “错别字” 类型的低级错误,比如:条件`if` 后面没有大括号导致的语义变化,`==`、`=` 和 `===` 的数量差别,`++` 或`--` 的位置,甚至 `;`的有无在某些编程语言中带来的语义差别。即使通过反复检查也可能有遗漏,而自己检查自己的代码会更难发现这些缺陷,这和自己不容易发现自己的错别字是一个道理。
|
||||
|
||||
心理学家汤姆·斯塔福德(Tom Stafford)曾在英国谢菲尔德大学研究拼写错误,他说:“当你在书写的时候,你试图传达想法,这是非常高级的任务。而在做高级任务时,大脑将简单、零碎的部分(拼词和造句)概化,这样就可以更专注于更复杂的任务,比如将句子变成复杂的观点。”
|
||||
|
||||
而在阅读时,他解释说:“我们不会抓住每个细节,相反,我们吸收感官信息,将感觉和期望融合,并且从中提炼意思。”这样,如果我们读的是他人的作品,就能帮助我们用更少的脑力更快地理解含义。
|
||||
|
||||
但当我们验证自己的文章时,我们知道想表达的东西是什么。因为我们预期这些含义都存在,所以很容易忽略掉某些感官(视觉)表达上的缺失。我们眼睛看到的,在与我们脑子里的印象交战。这,便是我们对自己的错误视而不见的原因。
|
||||
|
||||
写程序时,我们是在进行一项高级的复杂任务:将复杂的需求或产品逻辑翻译为程序逻辑,并且还要补充上程序固有的非业务类控制逻辑。因而,一旦我们完成了程序,再来复审写好的代码,这时我们预期的逻辑含义都预先存在于脑中,同样也就容易忽略掉某些视觉感官表达上的问题。
|
||||
|
||||
从进化角度看,粗心写错别字,还看不出来,不是因为我们太笨,而恰恰还是进化上的权衡优化选择。
|
||||
|
||||
### 1.2 认知偏差
|
||||
|
||||
认知偏差,是重蹈覆辙类错误的最大来源。
|
||||
|
||||
曾经,我就对 Java 类库中的线程 API 产生过认知偏差,导致反复出现问题。Java 自带线程池有三个重要参数:核心线程数(core)、最大线程数(max)和队列长度(queues)。我曾想当然地以为当核心线程数(core)不够了,就会继续创建线程达到最大线程数(max),此时如果还有任务需要处理但已经没有线程了就会放进队列等待。
|
||||
|
||||
但实际却不是这样工作的,类库的实现是核心线程(core)满了就会进队列(queues)等待,直到队列也满了再创建新线程直至达到最大线程数(max)的限制。这类认知偏差曾带来线上系统的偶然性异常故障,然后还怎么都找不到原因。因为这进入了我的认知盲区,我以为的和真正的现象之间的差异一度让我困惑不解。
|
||||
|
||||
还有一个来自生活中的小例子,虽然不是关于程序的,但本质是一个性质。
|
||||
|
||||
有时互联网上,朋友圈中小道消息满天飞,与此类现象有关的一个成语叫 “空穴来风”,现在很多媒体文章有好多是像下面这样用这个成语的:
|
||||
|
||||
>
|
||||
<p>他俩要离婚了?看来空穴来风,事出有因啊!<br />
|
||||
物价上涨的传闻恐怕不是空穴来风。</p>
|
||||
|
||||
|
||||
第一句是用的成语原意:指有根据、有来由,“空”发三声读 kǒng,意同 “孔”。第二句是表达:没有根据和由来,“空”发一声读kōnɡ。第二种的新意很多名作者和普通大众沿用已久,约定俗成,所以又有辞书与时俱进增加了这个新的义项,允许这两种完全相反的解释并存,自然发展,这在语义学史上也不多见。
|
||||
|
||||
而关于程序上有些 API 的定义和实现也犯过 “空穴来风” 的问题,一个 API 可以表达两种完全相反的含义和行为。不过这样的 API 就很容易引发认知偏差导致的 Bug,所以在设计和实现 API 时我们就要避免这种情况的出现,而是要提供单一原子化的设计。
|
||||
|
||||
### 1.3 熵增问题
|
||||
|
||||
熵增,是借用了物理热力学的比喻,表达更复杂混乱的现象;程序规模变大,复杂度变高之后,再去修改程序或添加功能就更容易引发未知的 Bug。
|
||||
|
||||
腾讯曾经分享过 QQ 的架构演进变化,到了 3.5 版本 QQ 的用户在线规模进入亿时代,此时在原有架构下去新增一些功能,比如:
|
||||
|
||||
>
|
||||
“昵称” 长度增加一半,需要两个月;
|
||||
增加 “故乡” 字段,需要两个月;
|
||||
最大好友数从 500 变成 1000,需要三个月。
|
||||
|
||||
|
||||
后端系统的高度复杂性和耦合作用导致即使增加一些小功能特性,也可能带来巨大的牵连影响,所以一个小改动才需要数月时间。
|
||||
|
||||
我们不断进行架构升级的本质,就在于随着业务和场景功能的增加,去控制住程序系统整体 “熵” 的增加。而复杂且耦合度高(熵很高)的系统,正是容易滋生 Bug 的温床。
|
||||
|
||||
## 2. 吸取教训
|
||||
|
||||
为了避免重蹈覆辙,我们有什么办法来吸取曾经犯错的教训么?
|
||||
|
||||
### 2.1 优化方法
|
||||
|
||||
粗心大意,可以通过开发规范、代码风格、流程约束,代码评审和工具检查等工程手段来加以避免。甚至相对写错别字,代码更进一步,通过补充单元测试在运行时做一个正确性后验,反过来去发现这类我们视而不见的低级错误。
|
||||
|
||||
认知偏差,一般没什么太好的自我发现机制,但可以依赖团队和技术手段来纠偏。每次掉坑里爬出来后的经验教训总结和团队内部分享,另外就是像一些静态代码扫描工具也提供了内置的优化实践,通过它们的提示来发现与你的认知产生碰撞纠偏。
|
||||
|
||||
熵增问题,业界不断迭代更新流行的架构模式就是在解决这个问题。比如,微服务架构相对曾经的单体应用架构模式,就是通过增加开发协作,部署测试和运维上的复杂度来换取系统开发的敏捷性。在协作方式、部署运维等方面付出的代价都可以通过提升自动化水平来降低成本,但只有编程活动是没法自动化的,依赖程序员来完成,而每个程序员对复杂度的驾驭能力是有不同上限的。
|
||||
|
||||
所以,微服务本质上就是将一个大系统的熵增问题,局部化在一个又一个的小服务中。而每个微服务都有一个熵增的极限值,而这个极限值一般是要低于该服务负责人的驾驭能力上限的。对于一个熵增接近极限附近的微服务,服务负责人就需要及时重构优化,降低熵的水平。而高水平和低水平程序员负责的服务本质差别在于熵的大小。
|
||||
|
||||
而熵增问题若不及时重构优化,最后可能会付出巨大的代价。
|
||||
|
||||
丰田曾陷入的 “刹车门” 事件,就是因为其汽车动力控制系统软件存在缺陷。而为追查其原因,在十八个月中,有 12 位嵌入式系统专家受原告诉讼团所托,被关在马里兰州一间高度保安的房间内对丰田动力控制系统软件(主要是 2005 年的凯美瑞)源代码进行深度审查。最后得到的结论把丰田的软件缺陷分为三类:
|
||||
|
||||
- 非常业余的结构设计
|
||||
- 不符合软件开发规范
|
||||
- 对关键变量缺乏保护
|
||||
|
||||
第一类属于熵增问题,导致系统规模不断变大、变复杂,结果驾驭不了而失控;第二类属于开发过程的认知与管理问题;第三类才是程序员实现上的水平与粗心大意问题。
|
||||
|
||||
### 2.2 塑造环境
|
||||
|
||||
为了修正真正的错误,而不是头痛医头、脚痛医脚,我们需要更深刻地认识问题的本质,再来开出 “处方单”。
|
||||
|
||||
在亚马逊(Amazon),严重的故障需要写一个 COE(Correction of Errors)的文档,这是一种帮助去总结经验教训,加深印象避免再犯的形式。其目的也是为了帮助认识问题的本质,修正真正的错误。
|
||||
|
||||
但一旦这个东西和 KPI 之类的挂上钩,引起的负面作用是 COE 的数量会变少,但真正的问题并没有减少,只是被隐藏了。而其正面的效应像总结经验、吸取教训、找出真正问题等,就会被大大削弱。
|
||||
|
||||
关于如何构造一个鼓励修正错误的环境,我们可以看看来自《异类》一书讲述的大韩航空的例子,大韩航空曾一度困扰于它的飞机损失率:
|
||||
|
||||
>
|
||||
美国联合航空 1988 年到 1998 年的飞机损失率为百万分之 0.27,也就是说联合航空每飞行 400 万次,会在一次事故中损失一架飞机;而大韩航空同期的飞机损失率为百万分之 4.79,是前者的 17 倍之多。
|
||||
|
||||
|
||||
事实上大韩航空的飞机也是买自美国,和联合航空并无多大差别。它的飞行员们的飞行时长,经验和训练水平从统计数据看也差别不大,那为什么飞机损失率会如此地高于其他航空公司的平均水平呢?在《异类》这本书中,作者以此为案例做了详细分析,我这里直接引用结论。
|
||||
|
||||
>
|
||||
现代商业客机,就目前发展水平而言,跟家用烤面包机一样可靠。空难很多时候是一系列人为的小失误、机械的小故障累加的结果,一个典型空难通常包括 7 个人为的错误。
|
||||
|
||||
|
||||
一个飞机上有正副两个机长,副机长的作用是帮助发现、提醒和纠正机长在飞行过程中可能发生的一些人为小错误。大韩航空的问题正在于副机长是否敢于以及如何提醒纠正机长的错误。其背后的理论依据源自荷兰心理学家吉尔特·霍夫斯泰德(Geert Hofstede)对不同族裔之间文化差异的研究,就是今天被社会广泛接受的跨文化心理学经典理论框架:霍夫斯泰德文化纬度(Hofstede’s Dimensions)。
|
||||
|
||||
>
|
||||
在霍夫斯泰德的几个文化维度中,最引人注目的大概就是 “权力距离指数(Power Distance Index)”。权力距离是指人们对待比自己更高等级阶层的态度,特别是指对权威的重视和尊重程度。
|
||||
|
||||
|
||||
>
|
||||
而霍夫斯泰德的研究也提出了一个航空界专家从未想到过的问题:让副机长在机长面前维护自己的意见,必须帮助他们克服所处文化的权力距离。
|
||||
|
||||
|
||||
想想我们看过的韩国电影或电视剧中,职场上后辈对前辈、下级对上级的态度,就能感知到韩国文化相比美国所崇尚的自由精神所表现出来的权力距离是特别远的。因而造成了大韩航空未被纠正的人为小错误比例更高,最终的影响是空难率也更高,而空难就是航空界的终极系统故障,而且结果不可挽回。
|
||||
|
||||
吸取大韩航空的教训应用到软件系统开发和维护上,就是:需要**建立和维护有利于程序员及时暴露并修正错误,挑战权威和主动改善系统的低权力距离文化氛围,这其实就是推崇扁平化管理和 “工程师文化” 的关键所在**。
|
||||
|
||||
一旦系统出了故障非技术背景的管理者通常喜欢用流程、制度甚至价值观来应对问题,而技术背景的管理者则喜欢从技术本身的角度去解决当下的问题。我觉着两者需要结合,站在更高的维度去考虑问题:**规则、流程或评价体系的制定所造成的文化氛围,对于错误是否以及何时被暴露,如何被修正有着决定性的影响**。
|
||||
|
||||
我们常与错误相伴,查理·芒格说:
|
||||
|
||||
>
|
||||
世界上不存在不犯错误的学习或行事方式,只是我们可以通过学习,比其他人少犯一些错误,也能够在犯了错误之后,更快地纠正错误。但既要过上富足的生活又不犯很多错误是不可能的。实际上,生活之所以如此,是为了让你们能够处理错误。
|
||||
|
||||
|
||||
人固有缺陷,程序固有 Bug;吸取教训避免重蹈覆辙,除了不断提升方法,也要创造环境。你觉得呢?欢迎你留言和我分享。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user