当前位置: 首页 > 科技观察

如何编写健壮的代码?

时间:2023-03-14 23:14:39 科技观察

关于代码的健壮性,其重要性不言而喻。那么我们怎样才能写出健壮的代码呢?阿里娱乐技术专家张彤将从防御性编程、如何正确使用异常和DRY原则三个方面,结合代码实例,分享自己的观点和经验,希望对同学们有所启发。您无法编写完美的软件。因为它没有,也不会。每个车手都认为自己是最好的车手。在我们鄙视那些闯红灯、乱停车、乱变道、不守规矩的司机的同时,我们在开车的时候更应该注意防范,小心那些突然冲出来的车辆,遇到麻烦就避开他。这与编程非常相似。在编程世界中,我们必须不断地与其他人的代码(那些不符合您的高标准的代码)进行交互,并处理可能有效或无效的输入。无效输入就像一辆横冲直撞的大卡车。这种世界防御编程也是很有必要的,但是如果我们想开几千年的船,我们可能连自己都不相信,因为你不知道冲出来的是不是你自己的车。我们将在防御性编程中讨论这个问题。没有人可以否认Java中异常处理的重要性,但如果使用不当,弊大于利。我在正确使用异常中讨论了这个问题。干,不要重复自己。不要重复自己。我们都知道重复的危险,但重复经常出现在我们的工作、代码和文档中。有时重复感觉就像不得不,有时你没有意识到你在重复它,有时是因为你很懒惰。再借也不难。这句俗语在编程界也适用。只要我们在编程,我们就要管理资源:内存、事物、线程、文件、定时器,凡是数量有限的事物都称为资源。资源使用通常遵循一种模式:分配、使用和回收。防御性编程防御性编程是对软件质量技术的有用辅助。防御性编程的主要思想是:程序/方法不应该因为传入错误的数据而被破坏,即使是自写的方法和程序产生的其他错误数据。这个想法是把可能的错误影响控制在一个有限的范围内。一个好的程序,在非法输入的情况下,要么不输出任何东西,要么输出错误信息。我们倾向于检查每个外部输入(所有外部数据输入,包括但不限于数据库和配置中心),我们经常检查每个方法的输入参数。一旦发现非法输入,按照防御性编程的思想,一开始就不会引入错误。我们通常使用if...else来使用guard语句来检查非法输入,但往往在判断的过程中,由于参数对象的层次结构,判断会逐层展开。publicvoiddoSomething(DomainAa){if(a!=null){assignAction;if(a.getB()!=null){otherAction;if(a.getB().getC()instanceofDomainC){doSomethingB();doSomethingA();doSomthingC();}}}}相信很多人都看过或写过上面的嵌套判断代码。这种方法虽然实现了基本的防御性编程,但也引入了丑陋之处。《Java 开发手册》建议我们使用保护句来应对这种情况。什么是保护刑?我们举个例子来说明什么是守卫句。publicvoiddoSomething(DomainAa){if(a==null){return;//logsomeerrorA}if(a.getB()==null){return;//logsomeerrorB}if(!(a.getB().getCinstanceofDomainC)){return;//logsomeerrorC}assignAction;otherAction;doSomethingA();doSomethingB();doSomethingC();}方法中的条件逻辑让人很难看到正常的分支执行路径。嵌套表达式被拆分为多个表达式,我们使用guard语句来表示所有特殊情况。使用验证器(validator)验证器是我开发中的一个实践,将合法性检查与OOP结合起来是一次美妙的体验。publicListdemo(DemoParamdParam){Assert.isTrue(dParam.validate(),()->newSysException("参数验证失败-"+DemoParam.class.getSimpleName()+"验证失败:"+dParam));DemoResultdemoResult=doBiz();doSomething();returndemoResult;}在这个例子中,方法的第一句是调用验证器来获取当前参数是否合法。在参数对象中实现校验接口,为字段配置校验注解,需要组合校验时重写validate0方法。这样就把合法性验证逻辑封装到了对象中。uplatClassDemopAramextendSbasedoImplementsValidatesUbject{@validatestring(strmaxlength=128)privatestestringastring;@valiatebobject;@valiatebobject(requient=requip=true)privateListblibistdo>blist;@validateSt;@validateSt;@validaTESTrequient=remign=true,strmaxlenge=trueprivementspriveraint=128))throwsValidateException{if(validateSubjectinstanceofDemoParam){DemoParamparam=(DemoParam)validateSubject;returnStringUtils.isNotBlank(param.getAString())&&SubjectDO.allValidate(param.getBList());}返回假;关于上面的问题,相信很多小伙伴的心里一定都闪过这样的念头。“这不科学……不可能发生……”,“计数器怎么可能是负数”,“这个对象不能为null”,但是它发生了,它就在那里。我们不要这样自欺欺人,尤其是在编码方面。如果它不会发生,请使用断言来确保它不会发生。使用断言的重要原则是断言不能有副作用,绝对不能把必须执行的代码放到断言中。断言不能有副作用,如果我每年都加错误检查代码,出新的bug,那就尴尬了。举个反面例子:while(iter.next()!=null){assert(iter.next()!=null);Objectnext=iter.next();//...}必须执行的代码不能放置Enter断言,因为生产环境很可能关闭了Java断言。因此,我更喜欢使用Spring提供的Assert工具。此工具提供的断言只会返回IllegalStateException。如果这个异常不能满足我们的业务需求,我们可以重新创建一个Assert类,继承org.springframework.util.Assert,在新类中添加断言方法,支持自定义异常的输入。publicclassAssertextendsorg.springframework.util.Assert{publicstaticvoidisTrue(booleanexpression,SuppliertSupplier){if(!expression){if(tSupplier!=null){throwtSupplier.get();}thrownewIllegalArgumentException();}}}Assert.isTrue(crParam.validate(),()->newSysException("参数验证失败-"+Calculate.class.getSimpleName()+"验证失败:"+crParam));有些人认为断言只是一种调试工具,一旦代码发布就应该关闭断言,因为断言会增加一些开销(微小的CPU时间)。所以在很多工程实践中,断言确实是关闭的,很多大V都提出过这样的建议。DndrewHunt和DavidThomas反对这种说法。他们书中有一个比喻我觉得很形象。当您将程序投入使用时关闭断言就像在没有安全网的情况下走钢丝一样,因为您已经成功了。——《The pragmatic Programmer》处理错误时的关键选择防御性编程以错误处理为前提。发生错误后的后续过程通常有两种选择,终止程序和继续运行。终止程序。如果发生严重错误,最好终止程序或要求用户重新启动程序。比如银行的ATM机出现错误,就应该关掉设备,防止出现取100元吐出10000元的悲剧。要继续运行,通常有两种选择,本地处理和抛出错误。本地处理通常使用默认值处理,错误会以异常或错误码的形式返回。在处理错误的时候,我们还面临另一个选择,正确性和健壮性的选择。正确,选择正确就意味着结果总是正确的。如果出现问题,与其给出不准确的值,不如不给出结果。比如用户资产的业务。健壮性,健壮性是指通过一些措施保证软件能够正常运行,即使有时会出现一些不准确的值。例如,产品介绍是否超出页面展示范围,是否使用守卫语言、断言或默认错误处理,都是怀着对程序世界敬畏的态度小心驾驶,时刻提防着别人甚至是你自己。北京市三区交通委提醒您,道路千万条,安全第一。正确使用异常是一种很好的做法,可以检查每个可能的错误,尤其是那些意料之外的错误。Java为我们提供异常真是太好了。如果充分发挥异常的优势,可以提高程序的可读性、可靠性和可维护性,但是如果使用不当,异常的负面影响也是非常值得我们注意和避免的。只有在特殊情况下才使用例外作者在《The pragmatic Programmer》和《Effective Java》中都有这一点。我认为这有两层意思。一个意思是如何识别和处理异常情况,另一个意思是只有在异常情况下才使用异常处理。那么什么是异常情况,如何处理呢?这个问题在代码模式上无法给出标准答案。这完全取决于实际情况。你必须意识到每一个错误,检查每一个可能的错误并区分错误。和异常。即使是同样的打开文件,读取“/etc/passwd”和读取用户上传的文件的操作,也是FileNotFoundException,如何处理完全看实际情况,Surprise!前者直接读取文件直接抛出异常让程序尽早Crash,而后者要先判断文件是否存在,如果存在则抛出FileNotFoundException。publicstaticvoidopenPasswd()throwsFileNotFoundException{FileInputStreamfs=newFileInputStream("/etc/passwd");}无法读取“/etc/passwd”,惊喜!publicstaticbooleanopenUserFile(Stringpath)throwsFileNotFoundException{Filef=newFile(path);if(!f.exists()){returnfalse;}FileInputStreamfs=newFileInputStream(path);returntrue;}当文件存在时读取文件失败,惊喜!还是那句话,异常与否的关键在于它是否给了我们一个Surprise!,这就是本节开头的意思,检查每一个错误都是很好的做法。使用异常控制正常流程的反面例子,我偷懒借用《Effective Java Second Edition》的例子来说明。Integer[]range={1,2,3};//Horribleabuseofexceptions.Don'teverdothis!try{inti=0;println(range[i++].intValue());}catch(ArrayIndexOutOfBoundsExceptione){}这个例子看起来我不知道我在做什么。这段代码其实就是利用数组越界异常来控制数组的遍历。这个脑洞很笨拙。如何正确遍历一个数组我觉得没必要举个例子,那是对读者的亵渎。那么,为什么有些人会这么苦思冥想呢?因为这种做法试图利用Java的错误判断机制来提高性能,因为JVM会在每次访问数组时检查越界情况,所以他们认为检测到的错误应该是循环终止的条件。但是for-each循环会忽略检测到的错误并隐藏它们,因此用户应避免使用for-each。对于这个脑洞的原因,JoshuaBloch给出了三个反驳:因为异常机制最初是为异常情况设计的,所以很少有JVM实现尝试对其进行优化,使其与显示测试一样快。将代码放在try-catch块中会阻止现代JVM实现可能执行的某些优化。遍历数组的标准模式不会导致冗余检查。一些现代JVM实现将它们优化掉了。还有一个例子,我曾经遇到过,但是由于年代久远,已经找不到项目地址了。我的一个朋友曾经给我看github上的一个MVC框架项目。虽然已经很多年了,但让我印象深刻的是,这个项目使用自定义异常和异常代码来控制Dispatcher,并且把异常作为一种方便的传递结果的方式来作为goto来使用,太可怕了。不过try-catch方法在字节码表达式上确实是一个goto表达式。我们最好不要这样想。这两个例子主要是为了说明异常应该只在特殊情况下使用;无论您的理由看起来多么聪明,都不应在正常流程中使用它们。这样做往往弄巧成拙,使代码的可读性大打折扣。Checkedexceptions和uncheckedexceptions我不止一次看到有人提倡将系统中的checkedexceptions打包成uncheckedexceptions。我不认真对待这个建议。因为Java的设计者其实是希望通过区分异常的类型来指导我们的编程。Java一共提供了三种类型的可抛结构(throwable)、已检查异常、未检查异常(runtimeexceptions)和错误(error)。我常常傻傻分不清它们的界限,但还是有迹可循。Checkedexception:如果希望调用者能够正常恢复,比如RMI每次调用都要处理的异常,设计者希望调用者重试或者尝试用其他方式恢复;比如上面提到的FileInputStream的构造方法,会抛出一个FileNotFoundException,设计者可能希望调用者尝试从另一个目录读取文件,这样程序才能继续执行。uncheckedexceptionsanderrors:表示编程错误,往往是不可恢复的情况,不应该提前捕获,应该迅速抛给顶层处理器,比如在服务接口的基类方法中统一处理uncheckedexceptions.检查异常。这种未经检查的异常通常也表明在编程中违反了某些约定。比如数组越界异常,说明访问数组不能越界的前提被违反了。总之,对于可恢复的情况,使用检查异常;程序错误的未经检查的异常。因此,自己程序内部定义的异常应该是非检查异常;在面向接口或2方/3方库的方法中尽可能多地使用已检查的异常。说到面向接口或者2/3方库,你可能遇到的就是跑路的车。找出你调用的接口或库中的异常,也是我们能够编写健壮代码的有力保证。不要忽略异常的建议很明显,但经常被违反。当API的设计者声明一个方法将抛出异常时,通常是说发生了什么事。忽略异常就是我们平时说的eatexceptions,try-catchbutnothing。吃异常食物就像拉响警报,当灾难降临时,没人知道发生了什么。为每个catch块打印至少一个日志,解释异常或解释不处理的原因。这个明显的建议适用于已检查和未检查的异常。DRY(Don'tRepeatYourself)DRY原则最早是在《The pragmatic Programmer》中提出的,现在已经被业界广泛认可。我相信每个软件工程师都知道。我想有很多人对它的理解是模糊的,只是没有重复代码;有人不屑一顾这个原则,抽象就是浪费时间,快点上线才是正理;重复。今天我们就来聊一聊这个既熟悉又陌生的话题。什么是干?DRY的原则是“系统的每一部分都必须有一个单一的、明确的、权威的代表”,指的是由代码和测试(由人类编写而不是机器生成)组成的系统,必须能够表达它所表达的内容应该表达,但不能包含任何重复代码。当成功应用DRY原则时,系统中任何单个元素的修改都不需要更改与其逻辑无关的其他元素。此外,与它逻辑相关的其他元素的更改是可预测的、统一的,因此是同步的。这个定义来自中文维基百科,但是这个定义好像和AndrewHunt和DavidThomas给出的定义不一样。作者将这一原则定义如下:每条知识都必须在一个系统中有一个单一的、明确的、权威的表示。系统中的每一条知识都必须有一个单一的、明确的、权威的表示。作者提倡的是知识的重复而不是代码的重复。那么什么是知识呢?我敢于给出自己的理解。知识是系统中逻辑的解释/定义,系统中的逻辑必须被外界输出或感知。逻辑的定义/解释包括代码和写在代码和宏实现上的文档。我们要避免的是,在改变一个逻辑的时候,我们需要修改十个地方。漏掉任何一个都会导致bug甚至上线失败。变化是软件开发的常态,尤其是在互联网行业,在一个充满重复的系统中维护变化是非常困难的。没有文档总比错误的文档好。大部分程序员在写代码的时候同时写文档是一个好习惯,但是相当一部分程序开发者没有这样的习惯,这使得代码比较干(dry)——有点滑稽。因为底层知识应该放在代码中,所以底层代码应该是职责单一、逻辑简单的代码。给底层代码加注释是在做重复的事情,有可能因为知识讲解过时,看注释多过代码。更容易、更可怕的事情往往就是这样发生的;将注释放在更高级别的复杂逻辑中。满是注释不是好代码,也不是好习惯。好的代码不需要注释。CP大法,禁止!每个项目都有时间压力,这往往是诱惑我们使用CP大法的最重要原因。但是“欲速则不达”,你现在可能节省了十分钟,但以后你就需要花好几个小时来处理各种线上问题。因为变化是常态,我们留下的坑可能会帮你挖的更深更大,然后掉进自己挖的坑里,我们就会怨猪队友,谁是猪队友。这实际上是我领导的一个团队中实际发生的事情。把知识的解释/定义放在一处!PS:感受下程序员的冷幽默。违反DRY原则的代码,程序员称之为WET,可以理解为WriteEverythingTwice(任何东西都写两次),WeEnjoyingTyping(我们喜欢在键盘上打字)或者WasteEveryone'sTime(浪费大家的时间)。关于DRY原则的争论自从DRY原则提出以来,无论是粉丝还是黑人,都存在一些争议和讨论。如果有百分比的话,我会选择95%服从这个原则。《The pragmatic Programmer》告诉我们只有一次。《Extreme Programing》也告诉我们Youaren'tgonnaneedit(YAGNI),指的是你认为有用,但实际上并没有使用的功能。这里好像有个问题,DRY和YAGNI不完全兼容。DRY需要精力去抽象,追求通用性,而YAGNI需要速度和经济性,你花精力做的抽象很可能用不上。这个时候我们的第三个选择是什么?《Refactoring》提出的三法则似乎是一个很好的妥协。它的意思是当你第一次使用某个功能时,你写一个具体的解决方案;第二次使用时,复制上次的代码;当它第三次出现时,你就开始“抽象”,写一个通用的解决方案。这样做有几个原因:省事如果一个函数只用在一两个地方,没必要花时间在“抽象”上。容易找到模式“抽象”需要找到问题的模式。出现问题的场合越多,就越容易看出规律,从而更准确地“抽象”出来。例如,对于一个序列,两个元素不足以确定模式:1,2,_,_,_,_第三个元素出现后,模式变得更加清晰:1,2,4,_,_,_Prevent过度冗余如果一个功能同时有多个实现,管理起来很麻烦,修改的时候需要多处修改。在实际工作中,重复执行最多只能容忍一次,不能再多了。我认为以上三个原则都不能算是灵丹妙药,还是要根据实际情况做出正确的选择。DRY原则在理论上没有问题,但在实际应用中,要避免墨守成规。只能起到指导作用,没有量化标准。否则,理论上,程序中的每一行代码只能出现一次,这是非常荒谬的。三法则不是重复代码必须出现三次才能被抽象出来。我认为三倍不应该是一个衡量标准。未来预测和项目趋势等因素也应该在抽象中考虑。PS:王音曾经写过一篇文章《DRY 原则的危害》,有兴趣的朋友可以看看:如何评价王音的最新文章,《DRY 原则的危害》?(https://www.zhihu.com/question/31278077)后记原理不是银弹原理是沙漠中的绿洲还是沙漠中的海市蜃楼中的绿洲。面对所谓的原则,需要我们每个人都有辨别能力,不是盲从圣贤,而是要有独立思考的能力。拥有识别和首先思考的能力需要足够的输入和足够的练习。参考文献[1]《The pragmatic Programmer:From Journeyman to Master》作者:AndrewHunt,DavidThomas[2]《Effective Java Second Edition》作者:JoshuaBloch[3]《Java 开发手册》[4]中文维基百科[5]抽象代码三原则-阮一峰http://www.ruanyifeng.com/blog/2013/01/abstraction_principles.html