不知不觉编程已经快一年了,总结整理一下这一年来对于编程的一些杂乱无章的一些思考,主要分为,1:应该用哪些原则指导自己写代码 2:设计模式真的是银弹吗?如何正确看待设计模式 3:如何高效且有质量的工作,工作的重心应该放在哪里,工作如何和自己对于代码的追求等等相平衡
主要参考:《Unix编程艺术》、《重构 改善既有代码的设计》、《设计模式之禅》、《微服务实战》、《深度工作》
如何正确地使用设计模式
代码
什么样的代码是好代码?
每个时期的评判标准可能不尽相同,在硬件条件匮乏的年代,可能代码更加注重运行的效率;但是在如今摩尔定律近乎失效,多核CPU大行其道的年代,性能已经不是程序要考虑的最核心的因素,最核心的因素个人觉得是《Unix编程艺术》中提到的代码的正交性、可读性、透明性。
所以我们应该用什么样的方法论指导自己进行编程呢?
编程方法论
讲一些自己的方法论前最重要的是提醒一点:编写代码前一定要先熟悉业务,脱离业务是很难写出清晰简单的代码的-代码未动,业务先行。
函数层面-多使用子方法
《重构》中有这样的一个观点,当你觉得你需要为这段代码增加一行注释的时候,就是你应该把这段代码抽成一个子方法的时候,用清晰良好的函数命名配合注释提高代码的可读性,并且使其他看到这块代码的人可以先把握整体逻辑而免于先陷入各种具体的细节之中,因此我们需要多使用子方法。
抽子方法有利于我们提高代码的可读性,不过我们应该按照什么样的原则指导我们编写函数呢?
正交性-一个方法做一件事(多划分方法模块-单一职责,高内聚)
何为代码的正交性,即代码应该拆多个清晰的模块,其实我们可以感受到设计优秀的开源框架往往具有非常清晰的模块性。
模块化-方法分割隔离变化
《Unix编程艺术》中提到接口同引擎分离原则,接口是指函数与函数调用方进行接触并对数据进行加工处理的部分,而引擎指真正的函数提供功能的逻辑,而该原则是建议我们将两个模块分开,这样如果之后我们需要对输入数据进行一些校验等,而无需改变具体功能实现时,我们只需要修改接口的实现即可,其实Controller(处理数据)-Service(业务逻辑)-Dao(数据库逻辑)中不同的模块之间通过层级隔离彼此的变化,不亦是这种思想的体现吗?
类似的,《重构》中提到将查询函数与修改函数分离,查询与修改是两件事,并且前者是无副作用的,因此应该抽成两个方法去做组合完成这件事;因为这样当你查询或者修改其中一个的逻辑发生改变时,你只需要专注于这一个方法即可,降低了程序的修改范围;而这也体现了模块化的思想以及带来的好处-易修改性,可复用性。
模块化-方法组合提供实现
《Unix编程艺术》中亦提到:我们的方法提供的是机制,而不是实现,我们应该通过一系列方法的按照不同的流程组装(机制)来实现功能,而不是一个方法直接全部包揽,即策略与机制分离,因为策略发生变化的次数要远多于机制发生变化的次数,其实本质上也是模块化思想的体现。
可读性-降低方法的复杂度
如果通过模块分隔后,某个模块依旧比较复杂,可读性较差,那么我们应该通过一些方法提高该模块的可读性。而可读性的提升主要在于避免多层if-else逻辑嵌套。
仅从代码层面
最简单的方法就是使用卫语句(提前return) 的方式避免过于深入的if/else嵌套,当然这个看个人的编程原则,《K&R》这本C语言的圣经也是使用if/else if/else的逻辑流控制的,但是我觉得else会使得人需要记忆else产生的条件,在多层的情况下可能看代码就需要借助于笔来理清逻辑;相反如果用卫语句的时候大脑只需要记住一层条件即可,相对简单明了。
选择合适的数据结构增强代码的表达能力
当然网上很多博客都在使用策略+工厂模式消除if/else,通过一个map存储策略对象,并通过工厂方法按照策略号生成对象,而其实本质上是通过将知识叠入数据以求逻辑质朴而健壮-表示原则,而此处的知识便是指这个存储着<策略号,策略对象>的数据结构map;无独有偶,《代码大全》中提到的表驱动法,本质上也是将复杂的逻辑转移到转换表中,通过使用恰当的数据结构来降低代码的复杂度,而这也是我们为什么至少要刷完《剑指Offer》的原因,因为其锻炼了我们将数据结构和代码配合使用的能力。
因为数据要比编程逻辑更好驾驭,因此在数据复杂性和代码逻辑复杂性中,我们更倾向于将代码逻辑的复杂度转移到数据中。
透明性-立即退出并提供有效的错误信息
即我们对于我们程序运行的状态是很清晰的,如果我们的程序发生崩溃,我们可以很清晰的了解到是哪里出现了问题。
接收不规范的输入-出现编码时可预见的错误
永远不要尝试相信:这个方法返回不可能为NULL,因此不需要做非空判断,使用通过方法获取的对象时一定要进行非空判断。
出现可捕获的异常
在函数层面往往是程序的输入与输出,对于产生可捕获的异常时的输出即为异常信息。因此在捕获异常时要先捕获代码中可能抛出的细致维度的Exception,然后再捕获宽泛范围的Exception。
模块层面
随着硬件的发展,函数调用代价的降低使得单体应用中更倾向于子方法的抽取,单体多进程的使用;而如今网络调用代价的降低使得由单体应用倾向于拆分为多个微服务,其实是和抽取子方法一致的思想-模块化,只是抽象的事物变得庞大了,本质上还是聚焦于模块的内聚性以隔离变化,并由模块间的组装实现业务功能。但是因为微服务之间的调用引入了网络这个相比于函数调用不太可靠的因素,因此我们需要在微服务中尽力容忍网络带来的不可靠性。
单体分割微服务
《微服务实战》中提到了三种拆分微服务的方法论:
- 按照业务功能划分,服务将对应于粒度相对粗一些但又紧密团结成一个整体的业务功能领域。
- 按照用例进行划分,这种服务应该是一个“动词”型,它反映了系统中将发生的动作。
- 按照易变性进行划分,服务会将那些未来可能发生变化的领域封装在内部。
我们没有必要孤立地应用这些方法。在许多微服务应用中,我们可以综合应用这些划分策略,以确保设计出来的服务能够适合不同的场景和需求。(这里不展开叙述,之后会单独结合
《微服务实战》、《微服务架构设计模式》、《SpringCloud微服务实战》这三本书写一篇总结性的博客)
使用各种面向对象的技术?
上文描述了指导我们进行代码编写的一些方法论原则,然后我们再回来看当我们在代码中使用面向对象的一些技术时是否有助于实现这些原则?
设计模式
没工作前我经常掉入设计模式的"陷阱"中,觉得一份好的代码应该用到诸如工厂、策略、模板、责任链、状态等等这些设计模式.....
但是如今虽然DDD已经提出了很多年,但是我觉得大部分公司还是以事务脚本模式开发为主,即将Model的状态交由Controller-Service-Dao这样的分层结构进行面向过程式的控制,而在这种模式下开发使用设计模式个人觉得往往会增加程序的复杂性,降低程序的可读性,因为这种模式本质上是披了面向对象的壳的面向过程。
因此在这种开发模式下,我们应该遵守的是Keep it Simple, Stupid原则,把写出简单、清晰、明了的代码作为自己的目标;
但是我们也不应该全盘否定,因为编程领域没有所谓的银弹,因此我们使用设计模式时,要先审视自己实现的功能是否适合使用某个设计模式,为什么要使用某个设计模式,使用设计模式需要权衡哪些?
面对这个问题,我们应该先要回答为什么要提出设计模式?
为什么提出设计模式?
其实GoF的《设计模式》一书,一共有三个层面的内容:
- 指出编程开发活动中存在模式,提出总结设计模式需要关注的四要素 "名称-问题-解决方案-效果",并给出描述一套模式的格式模板;
- 提出了面向对象开发中”针对接口编程优于针对实现编程”,”组合优于继承”的总体设计思路;
- 选取了现实开发中基于上述设计思路所形成的23种常见设计模式作为例子详细描述。
其实设计模式是对于经过现实考验被证明设计良好的面向对象工程中使用到的一些做法按照描述模式的四要素进行总结归纳从而总结出23种常见的设计模式,因此设计模式是对面向对象中一些良好设计方法进行命名。
是故设计模式并不止于这23种,我们亦可以通过模式的四要素命名新的模式。因此我们学习设计模式的时候,不应该宽泛的学习设计模式,而是要具体的学习某个具体的设计模式,关注于这个模式可以在什么场景下通过什么代码结构以达到解决某种问题的效果。
是否需要过度关心设计模式?应该在何时使用设计模式?
我觉得设计模式只是形,而对这23种设计模式进行总结归纳,其共有的特性才是代码设计的道。
因此我们可能在日常编程中,当我们按照某种原则(SOLID)进行编程时,其实在不知不觉中已经使用了设计模式,只是这种实现原则的方法被命名为比如说设配器模式等等;因此指导编程的原则往往更加重要。
我们不应该为了使用某种模式而使用某种模式,而应该在业务场景进行流程展开的过程中发现了类似于某个设计模式的结构时使用该设计模式提高代码的灵活性。
因此我觉得在日常编码中,更应该关注的是业务场景,这个场景下我们编写的代码面对变化时会产生什么问题?因为设计模式本质上是通过增加程序的复杂度来提高程序的灵活性和面对变化的能力,《编程的逻辑》中对于我们应该在何时使用设计模式有让我觉得比较认可的方法论,即当我们察觉到代码在哪里会产生变化时,就是我们使用设计模式进行隔离变化的地方。如果我们不能立即在程序中发现其变化的地方,我们应该避免过度设计或过早优化,而应该等到程序原有的逻辑发生变动时进行重构。正如《重构》中描述的那样,设计模式为重构提供了目标和方法。
设计模式的实现方式
有这样的一种观点,设计模式的出现是为了解决编程语言本身的短板和缺陷,比如在函数式编程语言中,函数作为一等公民可以直接传递,而这个时候很多行为型的设计模式便会失去作用,比如策略模式等;但是我持不太认可的态度,我觉得只是函数式编程语言在语言层面封装并实现了策略模式。
在Java8支持函数化特性后,我们亦可以通过传递@FunctionalInterface的接口实现对象来代替函数的传递;也因此原本Java中笨重的实现策略模式、模板模式的方法都得以轻便化,而这其实只是改变了实现这些模式的方式,其思想依旧贯穿其中。
设计模式的代价
设计模式是具有代价的,其显著增加了程序的复杂度、降低了可读性、提高了理解的成本。如果一个不知道适配器模式、Adapter命名的程序员,他自然很难知道这个类是用来做什么的,很容易就陷入复杂的代码细节中无法脱身。
工作
如何处理多件事情-规划自己的时间线
作为一个正常的程序员,你每天可能会遇到线上问题要处理,每天可能有业务迭代要开发,每天可能有产品研讨会、技术方案评审会、测试用例评审会这些会议,每天可能有代码Review,还可能有一些需要跟进的琐碎事情,还有一些日报、周报等等汇报工作性质的事情,因此如何规划自己的时间才能提高工作的效率,避免焦虑且焦躁的情绪产生,可能是我需要一直思考并改进的地方。
早晨简单按照四象限法进行规划
每天早上写一下自己今天要做的事情,并按照重要、紧急程度划分为四个象限,然后先集中精力做重要且紧急的事情,如果突然有线上问题(对用户有实际影响的问题)那么这个优先级是最高的。接着做简单但紧急的事情,比如说需要跟进的琐碎事情,可以先去钉一下对方,提醒一下对方我在跟进这件事,然后等他异步回调,如果说如果做简单且紧急的事情时虽然能做完,但是如果同时做完另一件事会更好,并且已经多次有这样的感觉了,而这件事便可以列为重要但不紧急的事情。
晚上进度确认
如果今天突然遇到了线上问题,然后紧急修复、测试、上线可能好几个小时没了,今天的进度十有八九是炸了,这个时候最好是比平时下班晚一些,最好是在今天完成一个可以将原本要实现的完整功能模块实现到最小可交付的程度,一些具体的细节可以等到明天,但是一定要是最小可交付的,这样就不会过于影响后续的开发节奏,即使到了测试阶段这块功能还没完善,也不会卡测试同学的进度。(如果天天出线上问题,我想是不是该好好问一下测试是怎么测的了)
出现问题时
首先解决问题
分析问题原因
不知道大家是否和我一样,一开始遇到线上问题的时候,大脑一片空白,手心冒汗。
但是一定要告诉自己,我需要先静下心来先分析并解决问题,而不是先考虑这个问题带来的后果。
首先考虑止损,是否需要先回滚。看报错日志提示的报错原因-(考验代码的透明性的时候到了),以及在监控平台上看报错在服务实例上的分布-确认是否是集群中某个容器本身的问题。如果是代码bug或者中间件等环境问题导致且对一定数目的用户产生可感知的影响需要立即回滚,如果是单台机器或者网络的原因紧急联系运维确认原因以及是否需要立即回滚。
确认问题原因后让流程先跑起来
确认问题的原因后,如果运维层面的问题,就交由运维同学处理,我们进行配合即可;如果是代码层面的bug,需要赶快联系测试同学,告知线上出现的问题并让他在测试环境复现,并让他将测试这个问题的优先级提高的同时,我们根据报错日志定位并修改问题并发布测试环境,测试的同时需要先告知Leader相应的状况和原因以及需要走一遍上线流程。这样整个流程是并行的,解决问题的效率便会得到提高。
解决问题后
自我情绪调节
典型的PUA方法就是Leader让你觉得这件事没有做好,大部分是因为你没有把事情做到位,这个时候自己要心里要清楚,一个版本的代码从产品提出需求、给出产品文档到开发实际开发再到测试同学测试,如果你的代码出现了问题,写代码的同学固然难辞其咎,但是这肯定不是一个人导致的问题;因为开发代码时还是会忽视一些边界情况,特别是项目的代码已经处于大厦将倾时,其耦合度已经无法忍受时,并且产品提出的需求对原有架构有较大冲击时,我觉得此时如果排期还紧张,那么别有bug暗处生也确实没办法。
自我反思
产生问题后,需要思考以后如何尽量避免, 一般线上代码bug产生的原因大抵是:
- 测试环境的代码和线上代码不匹配,即测试环境的代码是脏的或者Git分支漏合了;
- 测试环境没有高并发的请求,导致多线程安全、缓存不一致以及穿透等问题无法暴露;
- 测试用例不完善导致测漏某个场景。
三种原因我都遇到过,或是我或是我的同事大佬,如何解决呢?其实代码层面的问题是最好解决的,我们要始终记住问题发现的越早,解决问题的成本便越低,只要测试阶段每天从master上重新建立beta分支即可; 高并发导致的问题我觉得不是个人可以避免的,需要整个团队建立比较完善的压测机制-不仅仅是测试QPS,还包括线上实际请求的样本仓库的建立等等;如果是测试用例测漏了,那不是测试对于业务不熟悉便是代码模块化不清晰导致功能之间的耦合度过高。
业务与技术的关系
中国大部分互联网我觉得本质上是劳动密集型服务业。 因此我们要始终牢记,业务才是给公司创收的,老板不关心你使用了多么牛的技术-除非是关心公司本身的技术影响力的公司,我们始终要以业务为自己的重点,而技术是为业务服务的,当现在或者可预见的将来当前的技术设计方案不能够支持业务的发展时才最适合引进新的技术方案。而我们之所以要写出好的代码,其本质上也是为了方便日后的业务扩展性。
因此个人的发展我觉得也不仅仅是写好代码,写好代码反而可能是相对不那么重要的一环,重要的是自己要掌握快速熟悉业务的能力。
快速熟悉业务的一些个人总结
注重部分和整体的关系
随着互联网行业的业务越来越大,越来越复杂,而我们在公司中可能只是负责其中一个模块的一个项目,但是往往如果我们不先了解整体业务便去学习部分业务的时候便会忽视局部与整体的关系从而陷入管窥一豹的境地,我们不知道这个项目为何处于这个整体之中,出现的原因是什么?其在这个整体中的角色是怎样的。只有我们了解这个项目因何产生,我们才知道产品哪些需求是合理的,哪些需求会导致项目的臃肿,不是和产品battle,而是我们应该主动维护该项目功能的内聚性。
先成为该业务的用户-由宏观到具体
首先在测试环境走一遍从注册、使用、注销的流程,到处点点业务包含的功能,先形成一个宏观的概念,这个项目可以为用户提供哪些功能模块,因为往往功能模块会映射到代码模块,接着再深入看自己要开发的模块;当然如果设计不好的项目,其模块耦合度是比较高的,可能到处散布着为了兼容某些设计导致的副作用的代码,因此直接看代码会直接脑子炸锅。
总结
以上便是我工作一年对于如何将代码写简单的一些思考,做了一个系统的整理,可能有的地方并不是很对,留作一个阶段性总结的纪念,相信以后看到也会觉得会心一笑吧。
作为一个Coder,保持对代码的热情,对于新技术的好奇,对于业务的责任心,足矣。 ——2021/3/29 23:09