其他
对抗软件复杂度的战争
The following article is from 阿里技术 Author 晓斌
服务一个人的系统,和服务一亿人的系统,复杂度有着天壤之别。本文从工程师文化、组织战略、公司内部协作等角度来分析软件复杂度形成的原因,并提出了一些切实可落地的解法。
02 本质复杂度和偶然复杂度
此外,我们可以从所谓问题空间(Problem Space)和方案空间(Solution Space)来理解这两个复杂度,问题空间就是现实的初始状态和期望状态,以及一系列约束规则(我们常常称之为业务),方案空间就是工程师设计实现的,一系列从初始状态达到期望状态的步骤。缺乏经验的工程师往往在还没理解清楚问题的情况下就急于写代码,这便是缺乏对于问题空间和方案空间的理解,而近年来领域驱动设计为那么多工程师所推崇,其核心原因就是它指导了大家去重视问题空间,去直面本质复杂度。Eric Evans 在 2003 年的著作《Domain Driven Design》,其副标题是 “Tackling Complexity in the Heart of Software”,我想这也不是偶然。
本质复杂度是一个方面,毕竟更多用户意味着更多的功能特性,但我们无法忽略这里的偶然复杂度,其中最典型的就是分布式系统引入的偶然复杂度。为了能够支撑如此大规模的用户量,系统需要能够管理数万机器(调度系统),需要能否管理好用户的流量(负载均衡系统),需要能够管理好不同计算单元之间的通讯(服务发现,RPC,消息系统),需要能够保持服务的稳定性(高可用体系)。这里的每一个主题都能延展开用几本书来描述,而开发者只有在初步掌握了这些知识后,才能够设计实现足够 Scalable 的系统,服务好大规模的用户。
例如,团队成员因为个人喜好,在一个全部是 Java 体系的系统中加入了 NodeJS 的组件,当该成员离开团队后,这个组件对于其他不熟悉 NodeJS 的成员来说,就是纯粹多出来的偶然复杂度;
例如,团队新人不熟悉系统,为了急于上线一个特性,又不想影响到系统的其他部分,就会很自然地在某个地方加一个 flag,然后在所有需要改动的地方加 if 判断,而不是去调整系统设计以适应新的问题空间;
例如,同一个领域概念,不同的人在系统不同的模块中使用了不同的名字,核心内涵完全一致,但又加入了差异的属性,平添了大量理解成本。
类似的复杂度都不是软件的本质复杂度,但它们会随着时间的流逝而积累,给开发者带来巨大的认知负担。如果软件存在的时间很长,那除了当前开发团队的规模之外,还得一并考虑历史上给这个软件贡献过代码的所有人,也难怪当程序员看到“祖传代码,勿动!”之类调侃的时候,会会心一笑。
原本方案只需要直接改动系统 A,但由于负责系统 A 的团队并没有解决该问题的动力,其他人不得不绕道去修改系统 B,C,D 来解决该问题。 原本方案只需要直接改动系统 A,但迫于系统 B 负责人或者上司的压力,方案不得不演进成同时改 A,B,甚至引入 C。
但在现实情况下,决策者往往忽略了当前的问题是否是“更先进”的技术可以解决的问题。如果现有的系统服务的用户在迅速增长,Scalablity 面临了严重的困境,那么这个答案是肯定的;如果现有的系统的稳定性堪忧,经常不可用且严重影响了用户体验,那么这个答案是肯定的。但是,如果现有的软件系统面临着研发效率下降问题,那么“更先进”的技术不仅帮不了什么忙,还会因为新老技术的切换给系统增加偶然复杂度。
Wardley Map 是一个帮助分析技术战略的工具,它以地图的方式展现,地图中的每个组件可以被理解成一个软件模块,纵坐标是价值方向,越往上越靠近用户价值,横坐标是进化方向,越往右越靠近成熟商业产品。
从 Wardley Map 的方式去分析,我们就会发现,几乎所有的业务,其左上角(贴近直接用户价值,不成熟)都必须是要自己研发和承担复杂度的,而只要做好正确的软件架构,那么就能把右下角的部分(远离直接用户价值,有现成商业产品)提取出来,直接购买。所以在今天,一个合格的架构师,除非自己是云厂商,否则绝对不应该自己去投入研发数据库、调度系统、消息队列、分布式缓存等软件。通过购买的方式,研发团队完全不用承担这些复杂度,也能轻松地支撑好用户规模的增长。
var emails = [];
for(var i = 0; i < customers.length; i++) {
var customer = customers[i];
var email = emailForCustomer(customer, goods, bests);
emails.push(email);
}
}
function biggestPurchasePerCustomer(customers) {
var purchases = [];
for(var i = 0; i < customers.length; i++) {
var customer = customers[i];
var purchase = biggestPurchase(customer);
purchases.push(purchase);
}
}
return map(customers, function(customer) {
return emailForCustomer(customer, goods, bests);
});
}
function biggestPurchasePerCustomer(customers) {
return map(customers, function(customer) {
return biggestPurchase(customer);
});
}
It works It is easy to understand It is safe to change
低质量的单元测试:包括不写 assert,到处是 print 语句,要人去验证。 不稳定的单元测试:代码是好的,测试是失败的,测试集无法被信任。 耗时非常长的单元测试:运行一下要几十分钟或者几小时。 用代码生成单元测试:对不起,我认为这个东西除了提升覆盖率虚荣指标外,毫无意义。
07 软件道德观
开发者都是在生产代码、文档、API 服务等软件中间产物,这些中间产物被逐渐组装起来成为产品,产生商业价值。软件中间产物的质量对于研发组织的整体效率是至关重要的,而复杂度得到很好控制的代码和系统,就是高质量的软件中间产物;良好的软件研发道德,或者有时候也会认为这是良好的工程师文化,就是大家形成一种以交付高质量软件中间产物为荣,以交付低质量软件中间产物为耻的共识文化。
软件研发的核心职责之一是关注软件复杂度,通过开放代码、文档,Code Review 等方式让软件复杂度的信息透明,并且让所有在增加/降低复杂度的行为透明,并且持续激励那些消除复杂度的行为。唯有如此,在微观层面的控制复杂度的方法才能得到落实。
我近期对一个遗留系统做了一个依赖链路的梳理分析,这个系统是负责生产环境中各类资源的管理的,包括资源的规格,版本,依赖关系等等,梳理完成后,整体的结构吓了我一跳,这个图大致是这样的:
不同子系统对于同一个概念有不同的名称,交互的时候会涉及各种翻译。 不同子系统承担了同一个实体的部分概念,导致修改的时候需要大范围一起修改,且容易出错。 更高的运维成本。
当一个问题域没有系统架构,或者其系统架构是错误的时候,你就会发现不同的人在发明不同的语言,这就好比相隔几十公里的两个村子,常常对同一个概念有不同的用词或者发音。日常生活中语言的不精确不是问题,因为日常的沟通是充满上下文的(表情,气氛,环境等),但在计算机的世界,语言的不精确就意味着需要写代码翻译,一旦翻译错误软件就会执行出错。这也就是为什么领域驱动设计那么强调统一语言,强调限定上下文。但领域驱动设计是方法论,而知道方法并不能取代系统架构角色的缺位。
这个复杂系统是康威定律的绝佳例证,康威定律说:“任何系统设计的系统,其系统结构会复制组织的沟通结构。”这句话其实还是有些抽象的,更具体的一些阐述是:
讨论管理工作似乎已经超出了这篇论述软件复杂度的文章的范畴,但很多工程师或者隐隐感觉,或者思来想去最终领悟,这是我们的软件系统或优雅健壮或千疮百孔的根本因素。
Why choose Domain-Driven Design? 这篇文章清晰地解释了本质复杂度和领域驱动设计的关系。 《人月神话》-「没有银弹」一篇阐述了本质复杂度和偶然复杂度的概念。 《The Lean Product Playbook》- 本书的第2章清晰地解释了 Problem Space 和 Solution Space。 Wardley Map - 分析技术战略的绝佳工具,合理地选取商业产品可以帮助降低系统复杂度。 Grokking Simplicity - 在微观层面,使用函数式的思维降低软件复杂度。 Design Docs at Google Conway’s Law 警惕复杂度困局:关于软件复杂度的思考