写了25W行代码,3个操作系统:架构设计如何降低代码复杂度?好评如潮。JohnOusterhout总共编写了250,000行代码,是这三个操作系统的重要贡献者。这些原则也算是作者编程经验的总结。按照出版IT书籍的惯例,如果书名是“实践”,则书的内容侧重于某项技术的细节和技巧;如果标题是“艺术”,内容可以记录优秀作品的设计过程和心得;“哲学”这个名称是指一些普遍的原则和方法论。这些原则和方法可以串在一起形成一个系统。正如“知行合一”、“世界由原子构成”、“我思故我在”,这些耳熟能详的句子,在一定程度上可以代表其背后的人物和思想。一句话概括,软件设计的核心就是降低复杂度。本文围绕“降低复杂性”这一主题展开。许多重要的结论都来自JohnOusterhout。作者觉得很有共鸣,所以扩展了一些相关的话题,并增加了一些例子。虽然是“总则”,但不代表就是绝对真理。整理出来只是为了引起大家对软件设计的思考。2、如何定义复杂性关于复杂性,没有统一的定义,从不同的角度可以给出不同的答案。可以用数量来衡量,比如一个芯片集成的电子器件越多,它就越复杂(不一定正确);以层级[2]来衡量,复杂性在于层级的递归性和不可分解性。在信息论中,熵被用来衡量信息的不确定性。JohnOusterhout选择从认知负担和开发工作量的角度来定义软件的复杂度,给出了复杂度的衡量公式:子模块的复杂度Cp乘以模块对应的开发时间权重值tp,累加后得到系统的整体复杂度C。系统的整体复杂度并不是简单地等于所有子模块的复杂度之和,而是指开发和维护模块所花费的时间占整体时间的比例(对应权重值tp)。也就是说,即使某个模块非常复杂,如果很少使用或修改,也不会对系统的整体复杂度产生很大的影响。子模块的复杂度Cp是一个经验值,主要集中在几个现象上:修改扩散,修改时会发生连锁反应。认知负荷,开发人员理解功能模块需要多长时间。未知(UnknownUnknowns),开发者接到任务时不知??道从何下手。复杂的原因一般是代码依赖和晦涩难懂(Obscurity)。其中,依赖是指某部分代码不能独立修改和理解,必须涉及到其他代码。晦涩难懂的代码意味着很难从代码中找到重要的信息。3.解决复杂性的一般原则首先,互联网行业的软件系统很难从一开始就做出完美的设计,系统会通过功能模块的推导和迭代逐渐成型。对于现有的系统来说,也很难通过一次大动作来一劳永逸地解决所有问题。系统设计是一项需要持续投入的工作。通过细节的积累,最终得到一个完整的系统。因此,好的设计是每天努力的结果,在日常工作中要注意设计和细节的改进。其次,专业化和代码重用有助于提高软件生产力。例如,硬件工程师和软件工程师(底层、应用、不同编程语言)可以在不了解彼此技术背景的情况下进行合作开发;同一领域的服务可以支持不同的上层应用逻辑等,其背后的思想无非是通过将系统横向划分为若干层,明确各层的角色和分工,从而降低单一层次的复杂度.同时,每一层只要为相邻层提供一致的接口,就可以采用不同的方式实现,为软件复用提供了支持。分层是解决复杂性问题的重要原则。第三,类似于分层,子模块将系统垂直分解。子模块最常见的应用场景就是时下广泛流行的微服务。子模块降低了单个模块的复杂性,但也引入了新的复杂性,例如模块之间的交互,这将在后面的章节中讨论。在这里,我们将第三个原则确定为子模块。最后,代码可以描述程序的工作流程和结果,但很难描述开发者的思维,而注释和文档可以。另外,通过注释和文档,开发者无需阅读实现代码也能理解程序的功能,注释间接有助于代码抽象。好的注解可以帮助解决软件复杂性问题,尤其是认知负荷和不可知的事物(UnknownUnknowns)。4.解决当天的复杂性,做一个棋子4.1拒绝战术编程战术编程致力于完成任务。当增加新功能或修改bug时,解决问题就好了。这种工作方式会逐渐增加系统的复杂度。如果系统过于复杂难以维护,那么重构就会花费大量时间,很可能会影响新功能的迭代。战略规划是指注重设计并愿意投入时间。短期内可能会降低工作效率,但从长远来看,会增加系统的可维护性和迭代效率。在设计系统时,可能很难在一开始就把所有事情都做好。一个好的设计应该体现在每一个小模块上。修改bug时,也要抱着设计新系统的心态,让人感觉不到完成后“修”过的痕迹。经过积累,终于形成了一个完善的体系。从长远来看,对于中大型系统来说,每天花10%-15%的开发时间在设计上是值得的。有观点认为,初创公司需要追求业务迭代速度和成本节约,能够容忍糟糕的设计。这是追求正确目标的错误方法。降低开发成本最有效的方法是聘请优秀的工程师,而不是在设计上妥协。4.2两次设计为类、模块或系统的设计提供两种或多种解决方案有助于我们找到最佳设计。以我们日常的技术方案设计为例,技术方案本质上需要回答两个问题。首先,为什么方案可行?第二,在现有资源的约束下,为什么这个方案是最优的?为了回答第一个问题,我们需要在技术方案中补充架构图、界面设计、时间和人力预估。回答第二个问题,需要在关键点或争议点提供两三个解决方案,并给出建议的解决方案,这样才能有说服力。通常,我们会花很多时间准备第一个问题而忽略第二个问题。其实回答好第二个问题很重要。大型软件的设计非常复杂,没有人能一次性想到最好的解决方案。仅“可行”的解决方案可能会给系统增加额外的复杂性。聪明人更难接受这一点,因为他们习惯于“一次搞定”。但聪明人迟早会遇到自己的瓶颈,在低级问题上徘徊。不如花更多的时间思考和解决真正具有挑战性的问题。五、解决复杂性的层次结构5.1层次结构和抽象一个软件系统由不同的层次结构组成,它们通过接口进行交互。在严格分层的系统中,内层仅对相邻层可见,从而将复杂问题分解为一系列增量步骤。由于每一层最多影响两层,也给维护带来了极大的方便。分层系统最著名的例子是TCP/IP网络模型。在分层系统中,每一层都应该有不同的抽象。在TCP/IP模型中,应用层的抽象是用户界面和交互;传输层的抽象是端口和应用程序之间的数据传输;网络层的抽象是基于IP的寻址和数据传输;链路层的抽象是适配和虚拟化硬件设备。如果不同的层具有相同的抽象,则可能会出现层边界不清晰的问题。5.2复杂性下沉不应让用户面对系统的复杂性。即使有额外的工作量,开发者也应该尽量让用户更容易使用。如果你必须在某个级别处理复杂性,那么级别越低越好。比如调用Thrift接口时,需要引入数据传输失败的自动重试机制。将重试策略封装在Thrift内部显然更合适,开放给用户(下游开发者)会增加额外的使用负担。与之类似的还有系统中随处可见的配置参数(通常写在XML文件中)。这种情况在编程中应该尽量避免。用户(下游开发人员)通常很难决定哪个参数最好。开启参数配置,最好给个默认值。复杂度的下沉并不是说所有的功能都下移到一个层级,这样就太多了。如果复杂度与下层的功能有关,或者其他层或整体的复杂度下移后可以大大降低,那就下移。5.3异常处理异常和错误处理是软件复杂性的罪魁祸首之一。一些开发人员错误地认为,尽可能多地处理和报告错误会导致过度防御性编程。如果开发者捕获到一个异常,不知道如何处理,直接抛给上层,这就违背了封装的原则。降低复杂性的一个原则是尽量减少需要处理异常的可能性。最好的做法是保证错误终止,比如删除一个不存在的文件,与其报文件不存在的异常,还不如什么都不做。只需确保该文件不存在即可。不仅不会影响上层逻辑,而且还会因为不需要处理额外的异常而变得更简单。六、解决子模块的复杂性子模块是解决复杂性的重要方法。理想情况下,模块之间应该是相互隔离的,开发者在面对具体任务时只需要接触和理解整个系统的一小部分,而不需要了解或更改其他模块。6.1深层模块和浅层模块深层模块(DeepModule)是指功能强大、接口简单的模块。深度模块是抽象的最佳实践,通过排除模块内部不重要的信息,使用户更容易理解和使用。Unix操作系统文件I/O是一个典型的深度模块。以Open函数为例。该接口接受文件名作为参数并返回文件描述符。但是在这个接口的背后,有几百行的实现代码,用来处理文件存储、权限控制、并发控制、存储介质等,用户是看不到的。inopen(constchar*path,intflags,mode_tpermissions);与深层模块相对的是浅层模块(ShallowModule),功能简单,接口复杂。通常,浅模块对复杂性没有帮助。因为它们提供的好处(特性)被学习和使用成本抵消了。以JavaI/O为例,从I/O中读取对象时,需要同时创建FileInputStream、BufferedInputStream、ObjectInputStream三个对象,而前两个对象创建后不会直接使用,造成额外负担。默认情况下,开发人员不需要了解BufferedInputStream。缓冲有助于提高文件I/O性能,是一种可以合并到文件I/O对象中的有用功能。如果我们要放弃缓冲功能,也可以设计文件I/O,提供相应的定制选项。FileInputStreamfileStream=newFileInputStream(fileName);BufferedInputStreambufferedStream=newBufferedInputStream(fileStream);ObjectInputStreamobjectStream=newObjectInputStream(bufferedStream);关于浅模块存在一些争议,大部分是因为浅模块是一个既定的事实,不得不接受,而不一定是因为合理性。当然也有例外,比如领域驱动设计中的防腐层。当系统与外部系统对接时,会建立单独的服务或模块进行适配,以保证原有系统技术栈的统一性和稳定性。6.2通用和专用在设计一个新模块时,应该设计成通用模块还是专用模块?一种观点认为,通用模块满足多种场景,可以在以后遇到突发需求时节省时间。另一种观点认为,未来的需求很难预测,没有必要引入未使用的特性。专用模块可以快速满足当前需求,等有后续需求再重构为通用模块也不迟。以上两种想法是有道理的。在实际操作中,可以发挥两种方式各自的优势,即在功能实现上,能够满足当前的需求,便于快速实现;界面设计通用化,留有余地。例如。voidbackspace(Cursorcursor);voiddelete(Cursorcursor);voiddeleteSelection(Selectionselection);//以上三个函数可以组合成一个更通用的函数voiddelete(Positionstart,Positionend);设计通用接口需要权衡取舍,既要满足当前的需求,又不要在通用性方面过度工程化。一些可供参考的标准:满足当前需求的最简单的接口是什么?在不减少功能的前提下,减少方法的数量意味着提高了接口的通用性。接口使用了多少场景?如果一个接口只有一个特定的场景,那么可以将多个这样的接口组合成一个通用接口。在满足当前需求的同时,界面的易用性如何?如果接口很难用,说明我们可能设计过度了,需要拆分了。6.3信息隐藏信息隐藏是指程序的设计思想和内部逻辑应该包含在模块中,对其他模块不可见。如果一个模块隐藏了很多信息,说明这个模块在提供很多功能的同时简化了界面,符合前面说的深度模块的概念。软件设计领域有一个技巧,定义一个“大”类来帮助实现信息隐藏。这里的“大”类是指如果要实现某个功能,则将与该功能相关的所有信息都封装到一个类中。信息隐藏在降低复杂性方面主要有两个作用:一是简化模块接口,以更简单、更抽象的方式表达模块功能,减轻开发者的认知负担;二是减少模块之间的依赖,让系统迭代更轻。比如如何从B+树中获取信息是一些数据库索引的核心功能,但是数据库开发人员隐藏了这些信息,提供了一个简单的对外交互接口,即SQL脚本,这样产品和运营的同学也可以得到很快就开始了。并且,因为有足够的抽象,数据库可以在保持外部兼容性的同时将索引切换为散列或其他数据结构。与信息隐藏相对的是信息暴露,表现为:设计决策在多个模块中体现,导致不同模块之间产生依赖关系。例如,两个类可以处理相同类型的文件。在这种情况下,可以将这两个类合并,或者提取一个新的类(参考《重构》[3]一书)。工程师应该尽量减少外部模块所需的信息量。6.4拆分和合并两个函数,应该放在一起还是分开?“不管猫是黑猫还是白猫”,只要能降低复杂度就行。这里有一些设计思路可以参考:共享信息的模块要合并,比如两个模块都依赖某个配置项。可以在简化界面的时候合并,可以避免客户同时调用多个模块来完成某个功能。当您可以消除重复时合并,例如将重复代码提取到单个方法中。通用代码与专用代码是分开的。如果模块的某些功能可以通用,建议从专用代码中分离出来。例如,在实际的系统设计中,我们会将专用模块放在上层,将通用模块放在下层,以便复用。7.注释解决复杂性注释可以记录开发者的设计思路和程序功能,减轻开发者的认知负担,解决UnknownUnkowns问题,使代码更易于维护。通常,在程序的整个生命周期中,编码只占很小的一部分,大量的时间花在了后续的维护上。有经验的工程师理解这一点,并且通常会产生更高质量的评论和文档。注释也可以用作系统设计的工具。如果只需要简单的注释来描述一个模块的设计思路和功能,就说明这个模块设计的很好。另一方面,如果模块难以注释,则意味着该模块没有很好的抽象。7.1对注解的误解关于注解,很多开发者都有一些误解,这也是人们不愿意写注解的原因。比如“好代码就是自我注释”、“没时间”、“已有的注释没用,何必浪费时间”等等。这些观点是站不住脚的。“好代码是自注释”只在特定场景下才合理,比如为变量和方法选择合适的名字,不单独注释。但更多情况下,代码很难体现开发者的设计思想。还有,如果用户只能通过阅读代码来理解一个模块的使用,那么代码中就没有抽象。好的评论可以大大提高系统的可维护性,达到长期高效。没有“没有时间”这样的事情。注释也是一种可以学习的技能。一旦获得,就可以应用到后续工作中,解决了“标注无用”的问题。7.2使用注释来提高系统的可维护性注释应该提供代码之外的额外信息,重点是What和Why而不是代码是如何实现的(How)。最好不要简单地使用代码中已经出现过的词。根据抽象程度,注解可以分为低级注解和高级注解。低级注解用来增加准确性,补充程序的信息,比如变量的单位、控制条件的边界、值是否允许为空、是否需要释放资源等。等待。高级注释舍弃细节,只帮助读者从整体上理解代码的功能和结构。这种注解更容易维护。如果代码修改不影响整体功能,注解不需要更新。在实际工作中,既要考虑细节,又要考虑抽象。低级注解与相应的实现代码分开放在一起,高级注解一般用来描述接口。评论是第一位的。评论应该是设计过程的一部分。写注释的最佳时机是在开发之初。这不仅会产生更好的文档,而且有助于产生良好的设计,同时减少编写文档的痛苦。开发者推迟写注释的原因通常是:代码还在修改中,提前写好的注释又要改了。这产生了两个问题:首先,延迟注释通常意味着根本没有注释。一旦推迟决定,很容易引发连锁反应。代码稳定了,就不会有注释了。这时候如果要加评论,就得抽空,客观条件未必允许。第二,即使我们足够自律,抽出一些时间来写笔记,笔记的质量也不会很好。我们下意识的觉得代码已经写好了,迫不及待的想要开始下一个项目了。我们只是象征性地加了一些注释,无法准确复现当时的设计思路。避免重复评论。如果有重复的评论,开发者很难找到所有的评论进行更新。解决方法是找一个醒目的地方存放评论文档,然后在代码处标明对应文档的地址。如果该程序已经在外部文档中进行了注释,则不要在程序内部对其进行注释,只需添加对注释的引用即可。注释属于代码,不属于提交记录。一个不好的做法是将功能注释放在提交记录中,而不是相应的代码文件中。因为开发者通常不会去代码提交记录中查看程序的功能描述,非常不方便。7.3使用注解改进系统设计良好设计的基础是提供良好的抽象。在开始编码之前写注释可以帮助我们提炼出模块的核心元素:模块或对象最重要的功能和属性。这个过程鼓励我们思考,而不是简单地堆砌代码。另一方面,注解也可以帮助我们检查我们的模块设计是否合理。如上所述,深层模块提供简单的接口和强大的功能。如果接口注解又长又复杂,通常意味着接口也很复杂;注释很简单,这意味着界面也很简单。从长远来看,在设计早期注意并解决这些问题是值得的。8.结语有经验的工程师会对这些观点产生共鸣,一些作品如《代码大全》、《领域驱动设计》也会有类似的观点。因此,本文所提到的原则和方法具有一定的实用和指导价值。难以得出结论的问题,也可以在实践中摸索。对于原则和方法论,没有必要刻意吹捧,也没有必要嗤之以鼻。指导实践的不是更多的实践,而是实践后的总结和思考。应用原则和方法论的本质是从现有经验中学习,可以减少我们花在自我探索上的时间。探索新方法可以帮助我们适应新场景,但新方法本身需要经过时间的考验。九。参考文献[1]JohnOusterhout.一种软件设计哲学。Yaknyam出版社,2018年。[2]梅兰妮·米歇尔。复杂的。湖南科学技术出版社,2016.[3]MartinFowler.重构:改进现有代码的设计(第2版)。Addison-Wesley签名系列,2018。作者介绍了美团打车调度系统工程团队的工程师郑华、舜普、陶昕。
