斯坦福教授、Tcl语言发明者John Ousterhout 的著做《A Philosophy of Software Design》[1],自出版以来,好评如潮。按照IT图书出版的惯例,若是冠名为“实践”,书中内容关注的是某项技术的细节和技巧;冠名为“艺术”,内容多是记录一件优秀做品的设计过程和经验;而冠名为“哲学",则是一些通用的原则和方法论,这些原则方法论串起来,可以造成一个体系。正如”知行合一”、“世界是由原子构成的”、“我思故我在”,这些耳熟能详的句子可以必定程度上表明背后的人物和思想。用一句话归纳《A Philosophy of Software Design》,软件设计的核心在于下降复杂性。算法
本篇文章是围绕着“下降复杂性”这个主题展开的,不少重要的结论来源于John Ousterhout,笔者以为颇有共鸣,就作了一些相关话题的延伸、补充了一些实例。虽然说是"通常原则“,也不意味着是绝对的真理,整理出来,只是为了引起你们对软件设计的思考。数据库
关于复杂性,尚无统一的定义,从不一样的角度能够给出不一样的答案。能够用数量来度量,好比芯片集成的电子器件越多越复杂(不必定对);按层次性[2]度量,复杂度在于层次的递归性和不可分解性。在信息论中,使用熵来度量信息的不肯定性。编程
John Ousterhout选择从认知的负担和开发工做量的角度来定义软件的复杂性,而且给出了一个复杂度量公式:网络
子模块的复杂度cp乘以该模块对应的开发时间权重值tp,累加后获得系统的总体复杂度C。系统总体的复杂度并不简单等于全部子模块复杂度的累加,还要考虑该模块的开发维护所花费的时间在总体中的占比(对应权重值tp)。也就是说,即便某个模块很是复杂,若是不多使用或修改,也不会对系统的总体复杂度形成大的影响。数据结构
子模块的复杂度cp是一个经验值,它关注几个现象:架构
形成复杂的缘由通常是代码依赖和晦涩(Obscurity)。其中,依赖是指某部分代码不能被独立地修改和理解,一定会牵涉到其余代码。代码晦涩,是指从代码中难以找到重要信息。并发
首先,互联网行业的软件系统,很难一开始就作出完美的设计,经过一个个功能模块衍生迭代,系统才会逐步成型;对于现存的系统,也很难经过一个大动做,一劳永逸地解决全部问题。系统设计是须要持续投入的工做,经过细节的积累,最终获得一个完善的系统。所以,好的设计是日拱一卒的结果,在平常工做中要重视设计和细节的改进。编程语言
其次,专业化分工和代码复用促成了软件生产率的提高。好比硬件工程师、软件工程师(底层、应用、不一样编程语言)能够在无需了解对方技术背景的状况下进行合做开发;同一领域服务能够支撑不一样的上层应用逻辑等等。其背后的思想,无非是经过将系统分红若干个水平层、明确每一层的角色和分工,来下降单个层次的复杂性。同时,每一个层次只要给相邻层提供一致的接口,能够用不一样的方法实现,这就为软件重用提供了支持。分层是解决复杂性问题的重要原则。函数
第三,与分层相似,分模块是从垂直方向来分解系统。分模块最多见的应用场景,是现在普遍流行的微服务。分模块下降了单模块的复杂性,可是也会引入新的复杂性,例如模块与模块的交互,后面的章节会讨论这个问题。这里,咱们将第三个原则肯定为分模块。微服务
最后,代码可以描述程序的工做流程和结果,却很难描述开发人员的思路,而注释和文档能够。此外,经过注释和文档,开发人员在不阅读实现代码的状况下,就能够理解程序的功能,注释间接促成了代码抽象。好的注释可以帮助解决软件复杂性问题,尤为是认知负担和不可知问题(Unknown Unknowns)。
战术编程致力于完成任务,新增长特性或者修改Bug时,能解决问题就好。这种工做方式,会逐渐增长系统的复杂性。若是系统复杂到难以维护时,再去重构会花费大量的时间,极可能会影响新功能的迭代。
战略编程,是指重视设计并愿意投入时间,短期内可能会下降工做效率,可是长期看,会增长系统的可维护性和迭代效率。
设计系统时,很难在开始阶段就面面俱到。好的设计应该体如今一个个小的模块上,修改bug时,也应该抱着设计新系统的心态,完工后让人感受不到“修补”的痕迹。通过累积,最终造成一个完善的系统。从长期看,对于中大型的系统,将平常开发时间的10-15%用于设计是值得的。有一种观点认为,创业公司须要追求业务迭代速度和节省成本,能够容忍糟糕的设计,这是用错误的方法去追求正确的目标。下降开发成本最有效的方式是雇佣优秀的工程师,而不是在设计上作妥协。
为一个类、模块或者系统的设计提供两套或更多方案,有利于咱们找到最佳设计。以咱们平常的技术方案设计为例,技术方案本质上须要回答两个问题,其一,为何该方案可行? 其二,在已有资源限制下,为何该方案是最优的?为了回答第一个问题,咱们须要在技术方案里补充架构图、接口设计和时间人力估算。而要回答第二个问题,须要咱们在关键点或争议处提供二到三种方案,并给出建议方案,这样才有说服力。一般状况下,咱们会花费不少的时间准备第一个问题,而忽略第二个问题。其实,回答好第二个问题很重要,大型软件的设计已经复杂到没人可以一次就想到最佳方案,一个仅仅“可行”的方案,可能会给系统增长额外的复杂性。对聪明人来讲,接受这点更困难,由于他们习惯于“一次搞定问题”。可是聪明人早晚也会碰到本身的瓶颈,在低水平问题上徘徊,不如花费更多时间思考,去解决真正有挑战性的问题。
软件系统由不一样的层次组成,层次之间经过接口来交互。在严格分层的系统里,内部的层只对相邻的层次可见,这样就能够将一个复杂问题分解成增量步骤序列。因为每一层最多影响两层,也给维护带来了很大的便利。分层系统最有名的实例是TCP/IP网络模型。
在分层系统里,每一层应该具备不一样的抽象。TCP/IP模型中,应用层的抽象是用户接口和交互;传输层的抽象是端口和应用之间的数据传输;网络层的抽象是基于IP的寻址和数据传输;链路层的抽象是适配和虚拟硬件设备。若是不一样的层具备相同的抽象,可能存在层次边界不清晰的问题。
不该该让用户直面系统的复杂性,即使有额外的工做量,开发人员也应当尽可能让用户使用更简单。若是必定要在某个层次处理复杂性,这个层次越低越好。举个例子,Thrift接口调用时,数据传输失败须要引入自动重试机制,重试的策略显然在Thrift内部封装更合适,开放给用户(下游开发人员)会增长额外的使用负担。与之相似的是系统里随处可见的配置参数(一般写在XML文件里),在编程中应当尽可能避免这种状况,用户(下游开发人员)通常很难决定哪一个参数是最优的,若是必定要开放参数配置,最好给定一个默认值。
复杂性下沉,并非说把全部功能下移到一个层次,过犹不及。若是复杂性跟下层的功能相关,或者下移后,能大大降低其余层次或总体的复杂性,则下移。
异常和错误处理是形成软件复杂的罪魁祸首之一。有些开发人员错误的认为处理和上报的错误越多越好,这会致使过分防护性的编程。若是开发人员捕获了异常并不知道如何处理,直接往上层扔,这就违背了封装原则。
下降复杂度的一个原则就是尽量减小须要处理异常的可能性。而最佳实践就是确保错误终结,例如删除一个并不存在的文件,与其上报文件不存在的异常,不如什么都不作。确保文件不存在就行了,上层逻辑不但不会被影响,还会由于不须要处理额外的异常而变得简单。
分模块是解决复杂性的重要方法。理想状况下,模块之间应该是相互隔离的,开发人员面对具体的任务,只须要接触和了解整个系统的一小部分,而无需了解或改动其余模块。
深模块(Deep Module)指的是拥有强大功能和简单接口的模块。深模块是抽象的最佳实践,经过排除模块内部不重要的信息,让用户更容易理解和使用。
Unix操做系统文件I/O是典型的深模块,以Open函数为例,接口接受文件名为参数,返回文件描述符。可是这个接口的背后,是几百行的实现代码,用来处理文件存储、权限控制、并发控制、存储介质等等,这些对用户是不可见的。
int open(const char* path, int flags, mode_t permissions);
与深模块相对的是浅模块(Shallow Module),功能简单,接口复杂。一般状况下,浅模块无助于解决复杂性。由于他们提供的收益(功能)被学习和使用成本抵消了。以Java I/O为例,从I/O中读取对象时,须要同时建立三个对象FileInputStream、BufferedInputStream、ObjectInputStream,其中前两个建立后不会被直接使用,这就给开发人员形成了额外的负担。默认状况下,开发人员无需感知到BufferedInputStream,缓冲功能有助于改善文件I/O性能,是个颇有用的特性,能够合并到文件I/O对象里。假如咱们想放弃缓冲功能,文件I/O也能够设计成提供对应的定制选项。
FileInputStream fileStream = new FileInputStream(fileName); BufferedInputStream bufferedStream = new BufferedInputStream(fileStream); ObjectInputStream objectStream = new ObjectInputStream(bufferedStream);
关于浅模块有一些争议,大多数状况是由于浅模块是不得不接受的既定事实,而不见得是由于合理性。固然也有例外,好比领域驱动设计里的防腐层,系统在与外部系统对接时,会单独创建一个服务或模块去适配,用来保证原有系统技术栈的统一和稳定性。
设计新模块时,应该设计成通用模块仍是专用模块?一种观点认为通用模块知足多种场景,在将来遇到预期外的需求时,能够节省时间。另一种观点则认为,将来的需求很难预测,不必引入用不到的特性,专用模块能够快速知足当前的需求,等有后续需求时再重构成通用的模块也不迟。
以上两种思路都有道理,实际操做的时候能够采用两种方式各自的优势,即在功能实现上知足当前的需求,便于快速实现;接口设计通用化,为将来留下余量。举个例子。
void backspace(Cursor cursor); void delete(Cursor cursor); void deleteSelection(Selection selection); //以上三个函数能够合并为一个更通用的函数 void delete(Position start, Position end);
设计通用性接口须要权衡,既要知足当前的需求,同时在通用性方面不要过分设计。一些可供参考的标准:
信息隐藏是指,程序的设计思路以及内部逻辑应当包含在模块内部,对其余模块不可见。若是一个模块隐藏了不少信息,说明这个模块在提供不少功能的同时又简化了接口,符合前面提到的深模块理念。软件设计领域有个技巧,定义一个"大"类有助于实现信息隐藏。这里的“大”类指的是,若是要实现某功能,将该功能相关的信息都封装进一个类里面。
信息隐藏在下降复杂性方面主要有两个做用:一是简化模块接口,将模块功能以更简单、更抽象的方式表现出来,下降开发人员的认知负担;二是减小模块间的依赖,使得系统迭代更轻量。举个例子,如何从B+树中存取信息是一些数据库索引的核心功能,可是数据库开发人员将这些信息隐藏了起来,同时提供简单的对外交互接口,也就是SQL脚本,使得产品和运营同窗也能很快地上手。而且,由于有足够的抽象,数据库能够在保持外部兼容的状况下,将索引切换到散列或其余数据结构。
与信息隐藏相对的是信息暴露,表现为:设计决策体如今多个模块,形成不一样模块间的依赖。举个例子,两个类能处理同类型的文件。这种状况下,能够合并这两个类,或者提炼出一个新类(参考《重构》[3]一书)。工程师应当尽可能减小外部模块须要的信息量。
两个功能,应该放在一块儿仍是分开?“无论黑猫白猫”,能下降复杂性就好。这里有一些能够借鉴的设计思路:
注释能够记录开发人员的设计思路和程序功能,下降开发人员的认知负担和解决不可知(Unkown Unkowns)问题,让代码更容易维护。一般状况下,在程序的整个生命周期里,编码只占了少部分,大量时间花在了后续的维护上。有经验的工程师懂得这个道理,一般也会产出更高质量的注释和文档。
注释也能够做为系统设计的工具,若是只须要简单的注释就能够描述模块的设计思路和功能,说明这个模块的设计是良好的。另外一方面,若是模块很难注释,说明模块没有好的抽象。
关于注释,不少开发者存在一些认识上的误区,也是形成你们不肯意写注释的缘由。好比“好代码是自注释的"、"没有时间“、“现有的注释都没有用,为何还要浪费时间”等等。这些观点是站不住脚的。“好代码是自注释的”只在某些场景下是合理的,好比为变量和方法选择合适的名称,能够不用单独注释。可是更多的状况,代码很难体现开发人员的设计思路。此外,若是用户只能经过读代码来理解模块的使用,说明代码里没有抽象。好的注释能够极大地提高系统的可维护性,获取长期的效率,不存在“没有时间”一说。注释也是一种能够习得的技能,一旦习得,就能够在后续的工做中应用,这就解决了“注释没有用”的问题。
注释应当能提供代码以外额外的信息,重视What和Why,而不是代码是如何实现的(How),最好不要简单地使用代码中出现过的单词。
根据抽象程度,注释能够分为低层注释和高层注释,低层次的注释用来增长精确度,补充完善程序的信息,好比变量的单位、控制条件的边界、值是否容许为空、是否须要释放资源等。高层次注释抛弃细节,只从总体上帮助读者理解代码的功能和结构。这种类型的注释更好维护,若是代码修改不影响总体的功能,注释就无需更新。在实际工做中,须要兼顾细节和抽象。低层注释拆散与对应的实现代码放在一块儿,高层注释通常用于描述接口。
注释先行,注释应该做为设计过程的一部分,写注释最好的时机是在开发的开始环节,这不只会产生更好的文档,也会帮助产生好的设计,同时减小写文档带来的痛苦。开发人员推迟写注释的理由一般是:代码还在修改中,提早写注释到时候还得再改一遍。这样的话就会衍生两个问题:
避免重复的注释。若是有重复注释,开发人员很难找到全部的注释去更新。解决方法是,能够找到醒目的地方存放注释文档,而后在代码处注明去查阅对应文档的地址。若是程序已经在外部文档中注释过了,不要在程序内部再注释了,添加注释的引用就能够了。
注释属于代码,而不是提交记录。一种错误的作法是将功能注释放在提交记录里,而不是放在对应代码文件里。由于开发人员一般不会去代码提交记录里去查看程序的功能描述,很不方便。
良好的设计基础是提供好的抽象,在开始编码前编写注释,能够帮助咱们提炼模块的核心要素:模块或对象中最重要的功能和属性。这个过程促进咱们去思考,而不是简单地堆砌代码。另外一方面,注释也可以帮助咱们检查本身的模块设计是否合理,正如前文中提到,深模块提供简单的接口和强大的功能,若是接口注释冗长复杂,一般意味着接口也很复杂;注释简单,意味着接口也很简单。在设计的早期注意和解决这些问题,会为咱们带来长期的收益。
John Ousterhout累计写过25万行代码,是3个操做系统的重要贡献者,这些原则能够视为做者编程经验的总结。有经验的工程师看到这些观点会有共鸣,一些著做如《代码大全》、《领域驱动设计》也会有相似的观点。本文中提到的原则和方法具备必定实操和指导价值,对于很难有定论的问题,也能够在实践中去探索。
关于原则和方法论,既没必要刻意拔高,也不要嗤之以鼻。指导实践的不是更多的实践,而是实践后的总结和思考。应用原则和方法论实质是借鉴已有的经验,能够减小咱们自行摸索的时间。探索新的方法能够帮助咱们适应新的场景,可是新方法自己须要通过时间检验。
政华,顺谱,陶鑫,美团打车调度系统工程团队工程师。
美团打车调度系统工程团队诚招高级工程师/技术专家,咱们的目标,是与算法、数据团队密切协做,建设高性能、高可用、可配置的打车调度引擎, 为用户提供更好的出行体验。欢迎有兴趣的同窗发送简历到tech@meituan.com(邮件标题注明:打车调度系统工程团队)。