如何写出一手好代码(上篇 - 理论储备)?

大家好,我是慕枫
前阿里巴巴高级工程师,InfoQ签约作者、阿里云专家博主,一直致力于用大白话讲解技术知识
在这里和大家分享一线互联网大厂面试经验、技术人成长路线以及Java技术、分布式、高并发、架构设计方面的经验总结
感恩遇见,希望我们都能成为更好的自己

无论是刚入行的新手还是已经工作多年的老司机,都希望自己可以写一手好代码,这样在代码 CR 的时候就可以悄悄惊艳所有人。特别是对于刚入职的新同学来说,代码写得好可以帮助自己在新环境快速建立技术影响力。因为对于从事 IT 互联网研发工作的同学来说,技术能力是研发同学的立身之本,而写代码的能力又是技术能力的重要体现。但可惜的是理想很丰满,现实很骨感。结合慕枫自己的经验来看,我们在工作中其实没那么容易可以看到写得很好的代码。造成这种情况的原因也许很多,但是无论什么原因都不应该妨碍我们对于写好代码的追求。今天慕枫就和大家探讨下到底怎样做才能写出一手大家都认为好的代码?

哪些因素制约好代码的产生?

我们首先来分析下到底哪些因素造成了现实工作中好代码难以产出。因为只有搞清楚了这个问题才能对症下药,这样在我们自己写代码的时候才能尽量避免这些问题影响我们写好代码。

假如让我们说出哪些是烂代码,我们也许会罗列出来代码不易理解、没有注释、方法或者类词不达意、分层不合理、不够抽象、单个方法过长、单个类过长、代码难以维护每次改动都牵一发动全身、重复代码过多等等,这些都是我们在实际项目开发过长中经常遇到的代码问题。那么到底是什么原因造成了现实项目中有这么多的代码问题呢?慕枫认为主要存在以下三方面的原因。

如何写出一手好代码(上篇 - 理论储备)?_第1张图片

1、项目倒排时间不够

项目需求倒排导致没有时间在写代码前好好进行设计,所以只能先快速满足需求等后面有时间再优化(大概率是没有时间的)。这就造成技术同学在写代码的时候怎么快怎么写,优先把功能实现了再说,很多该考虑的细节就不会考虑那么多,该处理的异常没有进行处理,所以可能写出来的代码可以说是一次性代码,只针对当前的业务场景,基本没什么扩展性可言。

2、团队技术氛围不足

团队内技术氛围不是很浓厚,本来你是想好好把代码写好的,但是发现大家都在短平快的写代码,而且没有太多人关心代码写的好不好,只关心需求有没有按时完成。在这样的团队氛围影响之下,自己写出来的代码也在慢慢地妥协。像在阿里这样的一线互联网公司,团队中的代码文化还是很强的,很多技术团队在需求上线前必须要进行代码 CR,CR 不过的代码不允许上线。因此好的团队技术氛围会促使你不得不把代码写好,否则在代码 CR 的时候就等着接受暴风雨般的吐槽吧。

3、自身技术水平有限

第三个原因就是自身的技术水平有限,设计模式不知道该在什么样的业务场景下使用,框架的高级用法没有掌握,经验不足导致异常情况经常考虑不到。自己本身没有把代码写好的追求,总想着能满足需求代码能跑就行。

以上大概是我们实际工作中导致我们不能产出好代码最主要的三大原因,第一个原因我们基本无法改变,因为在互联网行业竞争本身就非常激烈,谁能先推出新业务优化用户体验,谁就能占得市场先机。因此项目倒排必定是常有的事情,也是无法避免的事情。第二个原因,如果你自己是团队的 TL,那么尽量在团队中去营造代码 CR 的文化,提升团队中的技术氛围。因为代码是技术团队的根本,所有的业务效果落地都需要通过代码来实现,因此好的代码可以帮助团队减少 Bug 出现的概率、提升大家的代码效率从而达到降低人力物力成本的目的。如果你不是团队的 TL,同时团队中的技术氛围也没那么足,那么我们也不要放弃治疗,先把自己负责的模块的代码写好,一点点影响团队,逐渐唤起大家对于好代码的重视。

前两个因素都属于环境因素,也许我们不好改变,但是对于第三个因素,我觉得我们可以通过理论知识的学习,不断的代码实践以及思考总结是可以改变的,因此本文主要还是讨论如何通过改变自己来把代码写好。

到底什么是好代码?

要想写出好的代码,首先我们得知道什么样的代码才是好代码。但是好这个字本身就具有较强的主观性,正所谓一千个读者心中就有一千个哈姆雷特。因此我们需要先统一一下好代码的标准,有了标准之后我们再来探讨到底怎么做才能写出好代码。

我相信大家肯定听说过代码可读性、代码扩展性、可维护性等词汇来描述好代码的特点,实际上这些形容词都是从不同方面对代码进行了阐述。但是在慕枫看来,在实际的项目开发中,可维护性以及高鲁棒性是好代码的两个比较核心的衡量标准。因为无论是开发新需求还是修复 Bug,都是在原有的平台代码中进行修改,如果原来代码的扩展性比较强,那么我们编码的时候就就可以做到最小化修改,降低引入问题的风险。而鲁棒性高的代码在线上出现 Bug 的概率相对来说就第一点,对于维护线上服务的稳定性具有重要意义。

可维护性

我们都知道代码开发并不是一个人的工作,通常涉及到很多人团队合作。因此慕枫认为代码的可维护性是好代码的第一要义。而可维护性主要体现在代码可读容易理解以及修改方便容易扩展这两方面,下面分别进行阐述说明。

代码可读

我们写出来的代码不仅仅要自己能看得懂自己写的代码,别人也应该可以轻松看得懂你的代码。在一线的互联网大厂中工作内容发生变化是常有的事情,如果别人接手我们的代码或者我们接手别人的代码时,可读性强的代码无疑可以减少大家理解业务的时间成本。因为代码是最直接的业务表现,那些所谓的设计文档要么过时要么写的非常粗略,基本不太能指导我们熟悉业务。那么什么样的代码称得上可读性强呢?

命名准确

无论是包的命名、类的命名、方法的命名还是变量的命名都能很准确地表达业务含义,让人可以看其名知其义。命名应该和实际的代码逻辑相匹配,否则不合适的命名只会让人丈二和尚摸不着脑袋误导看代码的同学。以前看代码的时候我看过以 main 作为类中的方法名称,所以得看完这个方法的实现逻辑才能明白它到底干什么的,这对于后期维护的同学来说非常不友好。

代码注释

另外就是必要的注释,有些同学非常自信觉得自己写的代码很好懂,根本不需要写什么注释。结果自己过了一两个月再回头看自己的代码的时候,死活想不起来某段代码为什么要这么写。当然我们不必每一行代码都写注释,但是该注释的地方就要写注释,特别是一些逻辑比较复杂,业务性比较强的地方,既方便自己以后排查问题也方便后面维护的同学理解业务。因此不要对自己写的代码过于自信,间隔时间一长也许连你自己都未必记得代码为什么这么写。

结构清晰

无论是服务的包结构还是代码结构都体现了技术同学对于技术的理解,因此即便是不深入看代码逻辑,通过包结构的划分、模块的划分类结构的设计已经基本可以判断出来项目的代码质量了。我们在进行包结构设计的时候可以遵循依赖倒置的原则,让非核心层依赖核心层。

如何写出一手好代码(上篇 - 理论储备)?_第2张图片

可扩展性

随着业务需求的不断变化,技术同学免不了在原有的代码逻辑中进行修改。因此项目代码的可扩展性直接影响着后期维护的成本。如果改一个小需求就需要对原有的代码大动干戈,修改的地方越多引入 Bug 的风险就会越大。我们都知道线上的故障有七八成都是由于变更引起的,因此可扩展性强的代码可以有效控制变更的范围。

高鲁棒性

当我们说到代码鲁棒性高的时候,实际就是说代码比较健壮,能够应对各种输入,即便出现异常也会有对应的异常处理机制进行响应而不至于直接崩溃。而项目开发不是一个人的工作,通常都是团队合作,因此我们写的代码无时无刻不在和别人的代码进行交互,所以我们负责的代码模块总是在处理可能正常可能异常的输入。如果不能对可能出现的异常输入进行妥善的防御性处理,那么可能就会造成 Bug 的产生,严重情况下甚至会影响系统正常运行。因此好的代码除了方便扩展方便维护之外,它必定也是高鲁棒性的,否则如果每天 Bug 满天飞,哪有时间和精力去琢磨代码的可扩展性,大部分精力都用来修复 Bug,长此以往自己也会感觉身心俱疲,总是感觉自己没什么成长。

如何写出好代码?

强烈内在驱动

为什么我把强烈的内在驱动摆在首要位置,主要是因为我觉得程序员只有有了想把代码写好的愿望,才能真正驱动自己写出来好代码。否则即便掌握了各种设计原则以及优化技巧,但是自己没有写好代码的内在驱动,总是觉得程序又不是不能用,或者觉得代码和自己有一个能跑就行,亦或是抱着后面有时间再优化的态度(基本是没时间)是不可能写好代码的。因此首先我们得有写好代码的内在驱动和愿望,我们才能有把代码写好的可能。不过话又说回来,内在驱动是基础,全是感情没有技巧肯定也不行。

沉淀业务模型

谈完了内在驱动这个感情,我们就要来看看要掌握哪些技巧才能帮助我们写出来好代码,首当其冲的就是业务领域模型,因为它是领域业务在工程代码中的落地也是整个服务的核心,不过遗憾的是很多同学并没有意识到它的重要性,甚至经常会把数据模型和业务模型相混淆。而我自己在在团队中落地 DDD 领域驱动设计的时候,被技术同学问过比较多的问题就是数据库表对应的数据实体满足不了业务需要吗?为什么还需要业务领域模型?那么想要回答这些问题,我们得先搞清楚到底什么是领域模型,它到底能给技术团队带来什么。

从本质上来说领域模型就是我们对于本行业业务领域的认知,体现了你对行业认知的沉淀以及外化表现。那么怎么体现你对行业领域业务认知的深度呢?领域模型就是很好的验证手段,对行业认知越深刻的同学构建的领域模型越能够刻画现实中的业务场景,我们也可以认为领域模型是现实世界业务场景到代码世界的映射,同时它也是公司重要的业务资产。那么每个行业的业务认知又是从哪里来的呢?实际上就从实际的业务场景中抽象出来的。所以领域模型的建立通常都是伴随着业务需求的出现。因此领域模型是核心,包含了业务概念以及概念之间的关系,它可以帮助团队统一认识以及指导设计。

如何写出一手好代码(上篇 - 理论储备)?_第3张图片

但是领域建模具有一定的门槛,其中包含了很多难以理解的概念,这也造成了在很多技术团队中难以落地。但是在阿里等国内一线互联网公司却有着广泛的应用,因为 DDD 领域驱动设计可以指导我们应对复杂系统的设计开发,控制系统复杂度,帮助我们划分业务域,将业务模型域实现细节相分离。所以慕枫觉得让大家认识到 DDD 领域驱动设计以及领域模型的的重要性比如何玩转 DDD 本身更加重要。

如何写出一手好代码(上篇 - 理论储备)?_第4张图片

另外在这里不得不提一下数据模型和领域模型的区别,在实际的工作中我发现很多同学都容易将这两者混淆。领域模型关注的是业务场景下的领域知识,是业务需求中概念以及概念之间的关系,它的存在就是显示的精确的表达业务语义。而数据模型关注的是业务数据如何存储,如何扩展以及如何操作性能更高。因此他们关注的层面不同,领域模型关注业务,数据模型关心实现。

这里可以举个例子给大家说明一下,假设有这样的业务场景,告警规则中存在一个规则范围的概念,主要可以给出不同的告警取值判断的范围,比如某个接口调用次数失败的最大值,或者设备在线数量不能低于某个最小值等等,因此有了如下简化版本的领域模型。

如何写出一手好代码(上篇 - 理论储备)?_第5张图片

那么在实际实现落地的时候,就很自然想到将 AlarmRule 以及 RuleRange 分别用一个表进行进行存储。这其实就是把领域模型和数据模型混淆的典型例子,实际上我们没有必要搞两张表来存储,一张表其实就够了,主要有以下两个原因:

1、写代码的时候我们维护一张表肯定比维护两张表操作起来更加方便;

2、另外万一后面 ruleRange 有新的变化,增减了新的判断条件,我们还得要修改 rule_ranged 字段,不利于后期的扩展。

因此我们用一张表来就进行存储就好了,多一个 json 类型的字段,专门存储阈值判断范围。只不过在领域模型中我们需要把 c_rule_range 定义为一个对象,这样在代码层面操作起来比较方便。

如何写出一手好代码(上篇 - 理论储备)?_第6张图片

牢记设计原则

无论设计原则还是设计模式,都是先驱们在以往大量软件设计开发实践中总结出来的宝贵经验,因此我们在项目开发中完全可以站在巨人的肩膀上利用这些设计原则指导我们进行编码。当然如果我们想熟练使用这些设计原则,就必须先要理解他们,搞清楚这些设计原则到底是为了解决什么问题而产生的。

我们不妨仔细想一想,平日时间里技术同学的开发工作基本上都是在已有的服务中进行新需求开发或者在原有的逻辑中修修改改。因此如果因为一个需求需要修改原有代码逻辑,我们总是希望修改的地方越少越好,否则如果修改的地方多了,那么引入的 Bug 风险就会越大。即便是项目需要进行重构的情况,那我们也希望重构后的服务或者组件可以满足高内聚低耦合的大要求,这样在未来进行需求开发的时候可以更加方便的进行修改。这也是我们希望我们开发的代码高内聚低耦合的原因。可以看得出来,设计原则的核心思想就是帮助技术人员开发的软件平台能够更好地应对各种各样的需求变化,从而最终达到降低维护成本,提高工作效率的目的。

当我们说到设计原则的时候,通常都会想到 SOLID 五大原则,这里所说的设计原则主要包括 SOLID 原则、迪米特法则。

单一职责原则

对于一个方法、类或者模块来说,它的职责应该是单一的,方法、类或者模块应该只负责处理一个业务。这个原则应该很好理解,当我们在写代码的时候,无论是方法、类以及模块都应该从功能或者业务的角度考虑将无关的逻辑抽离出去。为什么这么做呢?主要还是为了能够实现代码业务功能的原子化操作,这样即便未来进行修改的时候影响的范围也会变得有限。如果我们不遵守单一职责原则,那么在修改代码逻辑的时候很可能影响了其他业务的逻辑,造成修改影响范围不可控的情况。

You want to isolate your modules from the complexities of the organization as a whole, and design your systems such that each module is responsible (responds to) the needs of just that one business function.

不过需要说明的是,这里的所说的单一职责是针对当前的业务场景来说的,也许随着业务的发展和场景的扩充,原来满足单一职责的方法、类或者模块可能现在就不满足了需要进一步的拆分细化。

开闭原则

慕枫认为开闭原则与其说它是一种设计原则,不如说它是一种软件设计指导思想。无论我们编写框架代码还是业务代码都可以在开闭原则这样的核心思想指导下进行设计。

Software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。

所谓开闭原则指的就是我们开发的框架、模块以及类等软件实体应该对扩展开放,对修改关闭。这个原则看上去很容易理解,但是在进行项目实际落地的时候却不是一件容易的事情。因为对于扩展以及修改并没有明确的定义,到底什么样的代码才是扩展,什么样的代码才是修改?这些问题不搞清楚的话,我们很难把开闭原则落地到实际的项目开发中。

结合自己的开发经验可以这么理解,假设我们在项目中开发一个功能的时候,如果能做到不修改已有代码逻辑,而是在原有代码结构中扩展新的模块、类或者方法的话,那么我们认为代码是䄦开闭原则的。当然这也不是绝对的,比如假设你修改一个原有逻辑中的判断条件的阈值,那只能在原有代码逻辑中进行修改。总不能因为要满足这个原则非要搞出来。所以我觉得我们不必要教条的去追求满足开闭原则,而是从大方向上以及整体上考虑满足开闭原则。

里氏替换原则

在面向对象思想构建的程序中,子类对象可以替换程序中任何地方出现的父类对象,同时还能保证程序的逻辑不变以及正确性不变,这就是里氏替换原则的字面理解。不知道大家有没有发现,这个里氏替换原则看上去和 Java 中的多态一样一样的。实际上他们还是有区别的,多态是面向对象编程的特性,是重要的代码实现思路。而里氏替换原则是一种设计原则,约定子类不能破坏父类定义好的逻辑以及异常处理。

比如在仓储业务域中,父类中有对拣货任务进行排序的 sortPickingTaskByTime()方法,它是按照任务创建的时间对到来的拣货任务进行排序,那么我们在子类实现的时候如果在 sortPickingTaskByTime()方法内部按照拣货任务涉及的商品品类进行排序,那么明显是不符合里氏替换原则的,但是从多态的角度来说或者从语法的角度来说却没有问题。

里氏替换原则的核心思想就是按照约定办事,父类约定好了的行为,子类实现需要严格遵守。那么里氏替换原则对于实际编码有什么指导意义呢?比如上文所说的 sortPickingTaskByTime()排序方法,如果父类中的算法实现效率不高,我们可以在子类中进行优化,有了里氏替换原则就可以通过子类改进当前已有的实现。另外父类中的方法定义就是契约,可以指导我们后面的编码。

接口隔离原则

所谓接口隔离说的是接口调用方不应该被迫依赖它不需要的接口。怎么理解这句话呢?按照慕枫自己的理解,接口调用方只关心和自己业务相关的接口,其他不相关的接口应该隔离到其他接口中。

Clients should not be forced to depend upon interfaces that they do not use。

从扩展能力层面来看,我们定义接口的时候按照原子能力进行定义,避免了定义一个大而全的接口,这样在进行扩展的时候就可以按照具体的原子能力来进行,这样无论是灵活性还是通用性上面都会更加满足需求。

从实现上来说,如果实现方仅仅需要实现它以来的接口功能就好,它不需要的接口功能就不需要实现,这样也会大大降低代码实现量。当我们扩展或者修改代码的时候能够做到最小化的修改。

依赖倒置原则                                                                                       依赖倒置原则不太容易理解,但是我们在实际的项目开发中却每一天都在使用,只是我们可能没太在意罢了。                  

High-level modules shouldn't depend on low-level modules. Both modules shoud depend on abstractions.In addition,abstractions shouldn't depend on details.Details depend on abstractions.

按照字面意思理解,高层级模块不应该依赖低层级模块,同时两者都应该依赖于抽象。另外抽象不应该依赖于细节,细节应该依赖于抽象。用大白话来说主要是两个核心点,一是面向接口编程,另一个是基础层依赖核心层。

面向接口编程这个应该很好理解,因为接口定义了清晰的协议规范,研发同学可以基于接口进行开发。

如何写出一手好代码(上篇 - 理论储备)?_第7张图片

                                                                     

迪米特法则                                                                                          

迪米特法则看名字是一点不知道它是干什么的,简单来说就是类和类之间能不要有关系就不要有关系,实在没办法必须要有关系的那也尽量只依赖必要的接口。这样说起来感觉还是比较抽象。看下面的图就明白了,左边的各个模块拆分比较独立,符合单一职责原则,同时模块间只依赖它所需要的模块,而下图右边的模块拆分不够独立,A 模块本来只需要依赖 F 模块,但是 FG 模块颗粒度较大,导致不得不依赖 G 模块的接口,显然这是不符合迪米特法则的。                                                                                            

如何写出一手好代码(上篇 - 理论储备)?_第8张图片

当我们有了写出来的代码能够实现高内聚低耦合、易扩展以及易维护愿景之后,那就要好好学习一些代码实现的设计原则,这些设计原则在战略层面可以指导我们扩展性强的代码应该往哪些方向进行设计考虑。而有了指导思想之后,结合不同场景下的设计模式就自然催生出来我们想要的结果。

如何写出一手好代码(上篇 - 理论储备)?_第9张图片

运用设计模式

设计模式是先驱们在实践的基础上总结出来可以落地的代码实现模板,针对一些业务场景提供代码级解决方案。我们根据各个设计模式的能力特点可以将 23 种设计模式分类为创建型模式、结构型模式以及行为型模式。这里不再对设计模式进行展开说明,后面有时间可以写系列文章专门进行介绍。不过我们需要清楚的是这 23 种设计模式就是程序员写代码打天下的招式,而提升代码扩展性才是最终目的。

如何写出一手好代码(上篇 - 理论储备)?_第10张图片

面向失败编码

代码中的异常处理往往最能体现技术同学的编码功力。完成一个需求并不难,但是能够考虑到各种异常情况,在异常发生的时候依然可以得到预想输出的代码,却不是每个程序员都能写出来的。  因此无论是写代码还是系统设计,都要有面向失败进行设计的意识,每一个业务流程都要考虑如果失败了应该怎么办,尽可能考虑周全可能会出现的意外情况,同时针对这些意外情况设计相应的兜底措施,以实现防御性编码。

这里假设有这样的业务场景,当我们的业务中有调用外部服务接口的逻辑,那么我们在编写这部分代码的时候就需要考虑面向失败进行编码。因为调用外部接口有可能成功,有可能失败。如果接口调用成功自然没什么好说的,继续执行后续的业务逻辑就好。但是如果调用失败了怎么办,是直接将调用异常返回还是进行重试,如果重试还是失败应该怎么办,需不需要设计下重试的策略,比如连续重试三次都失败的话,后续间隔固定时间再进行重试等等。当然我们并不需要在每个这样的业务流程中这么做,在一些比较核心的业务链路中不能出错的流程中要有兜底措施。

如何写出一手好代码(上篇 - 理论储备)?_第11张图片

总结

本文主要从理论层面为大家介绍写好代码的需要哪些知识储备,下一篇会从具体业务场景出发,具体实操怎么结合这些理论知识来把代码写好。不过我们必须认识到好代码是需要不断打磨的,并非一朝一夕就能练就,总是需要在不断的实践,不断的思考,不断的体会以及不断的沉淀中实现代码能力的提升。左手设计原则,右手设计模式,心中领域模型再加上强烈的内在驱动,我相信我们有信心一定可以写出一手好代码。

你可能感兴趣的:(Java代码精进之路,java,后端)