高质量编写非功能性代码的一些实践

科技梦想在奔跑 2025-02-09 18:58:35

本文围绕软件开发中的非功能性质量交付展开讨论,强调了在编码实践中容易被忽视的非功能性需求的重要性。文章指出,非功能性质量(如可维护性、可靠性等)往往因缺乏明确的需求定义和约束机制而难以保证,且其交付水平受个体能力影响较大。为提升非功能性质量,作者以Java语言为例,详细分析了几对相关概念或实践,并提供了具体建议。

写在前面

软件的质量包含功能性、性能、可靠性、可维护性、可移植性等等。工程师产出的代码,首先必须满足功能性(即最基本的业务需求),此外还需要满足其他质量要求。在具体编码实践过程中,功能性的质量交付一般是完整且验证充分的,其他非功能质量的交付结果往往不可控。这是因为:

需求并未显式包含非功能质量要求,或因非功能性质量不会直接影响功能性能质量,在分析、设计、交付和验收过程中未考虑这部分质量交付的投入系统的架构原则在交付过程中的约束弱,无法充分识别或保证这类非功能质量需求的交付团队的研发规范和机制未强调或约束对非功能质量的关注和交付个体自发的非功能质量交付行为,最终质量受个体研发能力的影响很大功能性质量可以用有或无来直接界定,而非功能质量大部分情况下(除了性能等可量化的质量)只能用程度指标来度量,因此为交付非功能质量的而做的投入就可以被权衡非功能需求的交付,一般是借助研发平台的能力,遵循一些优秀实践来落实;优秀实践的约束一般比较弱,具体实践时受个体的认知差异影响比较大

本文,以Java语言为基础,整理几对和非功能性质量交付相关的概念或实践。这些概念或实践在实践过程中经常被混用或误用,从而影响了非功能质量的交付水平。通过解释并比较这些概念或实践,来理解这些概念或实践的本质,并给出一些实践建议,来帮助提升非功能性质量的交付水平。事实上,对于互联网平台这样的应用,非功能性质量大部分时候是为研发人员自己交付的。作为工程师应该充分理解这些相似概念,并在具体编码过程中良好的应用。

这些概念和实践包含:

注释 VS JavaDoc VS 代码自注释异常信息 VS 异常日志程序异常 VS 业务错误标准化 VS 特例化

注释 VS JavaDoc VS 代码自注释

在Java编程中,注释和Javadoc都是在代码之外,程序员可以添加的文字性描述,用来给代码的维护者或使用者提供代码之外的信息。由于代码本身也是文档,业界产生了一个叫做“代码自注释”的编程实践,通过赋予代码更多的信息来减少代码注释。这三个概念涉及了代码的可维护性。在实际编程过程中,这些代码之外的信息应该以什么方式产出以及应该包含哪些内容,并没有一个绝对的约束,从而导致所产出的注释或Javadoc并没有带来应有的作用。通过正确区分和使用注释、Javadoc和代码自注释,软件工程师可以显著提高代码的可读性和质量,减低代码的维护成本,提高团队的协作效率。

▐ 概念比较

注释:是程序员在代码中添加的文本,用于解释代码的逻辑或提供开发过程中的思考过程。注释的受众是代码的维护者。Javadoc:向其他开发者或使用者提供类、接口、方法和字段的详细说明,用来描述代码所包含的业务语义、提供的业务能力等。Javadoc的受众是代码的维护者和使用者。代码自注释(Self-Documenting Code):是一种编写代码的实践,旨在通过清晰、简洁的代码结构和命名来传达代码的意图,使代码本身就像文档一样易于理解。这种实践强调代码的可读性,减少对外部注释的依赖,受众是代码维护者。

概念

注释

Javadoc

代码自注释

用途

解释代码的实现逻辑,补充表达代码上看不到但需要读者了解的信息

介绍代码所包含的业务语义、业务能力、使用说明等

提升代码可读性,降低对外部注释的依赖,即通过代码表达所有信息

受众

代码的维护者

代码的使用者、维护者

代码维护者

编写视角

代码实现视角,强调how、why,是关于代码的实现细节

业务能力视角,强调What,是代码所提供的能力

代码实现视角

格式要求

单行注释以//开头;多行注释以/*开头,并以*/结束

应遵循Javadoc编写规范:《Javadoc规范》。基于JDK提供的Javadoc工具,可生成HTML格式的API文档

实践建议

1.保持简洁,避免过度注释

2.避免注释对代码的直译(听君一席话、如听一席话)

3.代码应该自解释,注释只在必要时添加

4.随着代码的变更,确保相关的注释也得到相应更新;过期甚至是错误的注释是有害的

1.对于所有公共类和公共方法,应该始终提供详细的Javadoc

2.Javadoc要避免暴露过多的细节,特别是一些敏感信息

3.随着代码的变更,确保相关的Javadoc也得到相应更新;过期甚至是错误的注释是有害的

4.借助各类IDE提自动生成Javadoc,以提高效率并确保一致性

5.无论代码是否自注释,都必须提供详细的Javadoc

1.严格遵循代码变量和方法的命名规范,名字需具备业务语义

2.用有语义的方法或变量替代复杂的逻辑,使代码更容易理解;这需要控制方法复杂度和内聚性,简化代码结构

3.如果业务发生变化,则先变更语义(如方法名)再变更实现(如方法实现逻辑)

4.尽可能通过代码自注释来减少注释,从而减低维护成本;但不应替代Javadoc

▐ 案例解读

/** * Gets a property from the configuration. * * @param key property to retrieve * @return value as object. Will return user value if exists, * if not then default value if exists, otherwise null */ public Object getProperty(String key) { // first, try to get from the 'user value' store Object obj = this.get(key); if (obj == null) { // if there isn't a value there, get it from the // defaults if we have them if (defaults != null) { obj = defaults.get(key); } } return obj; }// org.apache.commons.collections.ExtendedProperties

行1-行7:Javadoc描述了该方法的功能、入参和返回值。对于返回值,详细介绍了取值逻辑,便于使用者理解使用该方法的场景行9、行13、行14:方法内的注释解释了如何获取User Value,以及如何获取Default值行15:defaults实际上是一个ExtendedProperties,使用defaults而不是类似extendedProperties这样的变量名,是前者更直接表达了业务语义,这是一种典型的代码自注释风格

异常信息 VS 异常日志

在Java编码中,当我们需要处理异常时,一般会对异常信息进行组织,然后将异常信息通过异常继续向上抛出(如果当前无法处理异常),同时打印异常日志。异常信息和异常日志,以不同的方式对外反馈系统发生的异常情况,帮助理解并解决系统问题。它们从产出时机看是一致的,所包含的内容也是高度一致的。在实际编码中,我们会存在“处理了异常但不打印日志”、“异常信息和异常日志输出的比较随意”等情况,这一般是未充分考虑两者在实际的维护中起到的不同作用,从而增加了系统维护的难度和成本。

▐ 概念比较

异常信息:程序在运行时遇到异常时,抛出的异常所带的描述性信息;它通常包括异常的类型、描述性消息和堆栈跟踪。异常日志:程序在处理异常时,同步输出的日志信息;它通常包含异常发生的原因、异常发生时业务上下文以及其他一些关键信息。

概念

异常信息

日常日志

用途

异常信息用于在程序的不同部分之间传播错误,使得程序调用者知道发生了什么问题

用来支持系统为题的排查和诊断,并可用于监控系统状态,帮助进行系统分析和审计。

受众

代码的调用者

系统的维护者

数据特点

属于运行时信息,瞬时数据,容易丢失

属于维护时数据,持久数据,可长期保存

实践建议

1.在抛出异常时,确保异常信息中包含足够的业务上下文,以便于定位和修复问题

2.避免信息中包含敏感内容;如果调用方不可信,则提供最小必要信息

3.不应过度依赖异常信息来进行问题定位和修复,因为异常可能非必现,且重复执行异常流程需要付出额外成本

1.抛出异常时,必须同时打印日志,且日志级别至少为ERROR

2.日志信息应包含尽可能多的有助于问题定位和修复的信息,如业务上下文、异常堆栈(如果有);也需要对敏感内容进行不影响问题定位的脱敏处理

3.异常在哪儿发生,就在哪儿记录日志,这样可以通过日志可以找到异常的现场

▐ 案例解读

public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { RelaxedPropertyResolver resolver = new RelaxedPropertyResolver( event.getEnvironment(), "spring."); if (resolver.containsProperty("mandatoryFileEncoding")) { String encoding = System.getProperty("file.encoding"); String desired = resolver.getProperty("mandatoryFileEncoding"); if (encoding != null && !desired.equalsIgnoreCase(encoding)) { logger.error("System property 'file.encoding' is currently '" + encoding + "'. It should be '" + desired + "' (as defined in 'spring.mandatoryFileEncoding')."); logger.error("Environment variable LANG is '" + System.getenv("LANG") + "'. You could use a locale setting that matches encoding='" + desired + "'."); logger.error("Environment variable LC_ALL is '" + System.getenv("LC_ALL") + "'. You could use a locale setting that matches encoding='" + desired + "'."); throw new IllegalStateException( "The Java Virtual Machine has not been configured to use the " + "desired default character encoding (" + desired + ")."); } } }// org.springframework.boot.context.FileEncodingApplicationListener

行7:触发异常的条件行8-行16:输出异常日志。分3个部分输出,详细说明了异常原因,并给出了完整的修复这个问题的方法,极大方便了系统维护者行17-行20:抛出异常给调用方。异常信息只包含用户定义的编码类型,并没有包含系统的编码,这是考虑了系统编码不应随意暴露给调用者

程序异常 VS 业务错误

一次程序的执行,会得到三种可能得结果:正确、错误和异常。错误是指程序执行得到了用户初始预期不一致的结果,它和正确的结果都属于程序正常执行的范畴;异常是指程序运行时出现了预期外且当前程序无法自行处理的情况,即程序执行不正常。以用户登录场景为例,用户输入了用户名和密码,可能得到三种结果:

正确:用户名、密码匹配,用户登录成功错误:即业务错误,用户不存在、密码不正确等原因导致用户本次登录失败。业务错误的情况下,系统可通过提供一些信息指导使用者以正确的方式重试或者通过预先设计的容错机制(如兜底实现)来控制错误带来的业务影响异常:即程序异常,登录服务不可用。此时无法知道用户名以及密码的正确性,也无法指导用户下一步动作

注:本节不讨论是使用异常类型还是错误码来进程序错误的处理,而是为识别程序运行时遇到的一些例外情况是否属于异常提供一种判断方法,以便更好的处理这种例外情况,从而提升系统的可维护性和可用性。当需要处理业务错误或程序异常时,使用特定异常类型或编码,都是具体的手段,不改变这段程序的性质。

▐ 概念比较

程序异常:由于代码执行过程中的某种意外情况或错误操作导致的执行失败。业务错误:在业务逻辑或功能执行中违背了业务规则或约定,从而导致在表现上非正确的业务结果。再次强调下:这里的业务错误是属于正常的、预期内的业务表现。

概念

程序异常

业务错误

程序执行状态

未正常执行完成,产生中断

程序在特定分支下正常执行完成

诱因

编程错误、环境问题或不可预见的突发状况,如访问的文件不存在、网络异常等

不是程序本身的编码问题,而是业务逻辑设计或验证中的问题;如用户输入不正确

出现频次

低,一般情况下不会出现

高,属于业务常态情况

处理手段

异常保护机制,属于健壮性范畴

正常业务处理分支,属于业务满足度范畴

实践建议

1.出现此类问题,需实时处理(如实时告警),并进行问题定位和修复

2.代码实现上,可使用异常类或错误码来处理异常情况

3.原则上系统产生异常时需打印日志,级别为ERROR;除非方法上声明了调用方需处理这类异常

4.需提供系统级别的异常保护机制,避免将异常直接暴露给终端用户

1.出现此类问题,一般不需要实时处理,但需进行状态审计。如短时间内出现大量的业务错误,则需分析产生的原因

2.代码实现上,通过业务结果码来反馈业务结果,不建议使用异常

3.原则上业务错误发生时需打印日志,级别最高为WARN

4.通过设计一些良好的终端用户交互机制进行重试来纠错

▐ 案例解读

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; try { chain.doFilter(request, response); logger.debug("Chain processed normally"); } catch (IOException ex) { throw ex; } catch (Exception ex) { // Try to extract a SpringSecurityException from the stacktrace Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex); RuntimeException ase = (AuthenticationException) throwableAnalyzer .getFirstThrowableOfType(AuthenticationException.class, causeChain); if (ase == null) { ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType( AccessDeniedException.class, causeChain); } if (ase != null) { handleSpringSecurityException(request, response, chain, ase); } else { // Rethrow ServletExceptions and RuntimeExceptions as-is if (ex instanceof ServletException) { throw (ServletException) ex; } else if (ex instanceof RuntimeException) { throw (RuntimeException) ex; } // Wrap other Exceptions. This shouldn't actually happen // as we've already covered all the possibilities for doFilter throw new RuntimeException(ex); } } } private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException { if (exception instanceof AuthenticationException) { logger.debug( "Authentication exception occurred; redirecting to authentication entry point", exception); sendStartAuthentication(request, response, chain, (AuthenticationException) exception); } else if (exception instanceof AccessDeniedException) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) { logger.debug( "Access is denied (user is " + (authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point", exception); sendStartAuthentication( request, response, chain, new InsufficientAuthenticationException( "Full authentication is required to access this resource")); } else { logger.debug( "Access is denied (user is not anonymous); delegating to AccessDeniedHandler", exception); accessDeniedHandler.handle(request, response, (AccessDeniedException) exception); } } } protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException { // SEC-112: Clear the SecurityContextHolder's Authentication, as the // existing Authentication is no longer considered valid SecurityContextHolder.getContext().setAuthentication(null); requestCache.saveRequest(request, response); logger.debug("Calling Authentication entry point."); authenticationEntryPoint.commence(request, response, reason); }// org.springframework.security.web.access.ExceptionTranslationFilter

行11:出现IO异常,无法处理,继续抛出;方法显示声明了调用者需处理IO异常,因此此处没有同步打印日志行17-行27:如果异常链中存在AccessDeniedException或AccessDeniedException异常,则认为是预期内的业务错误,对这种异常进行单独的业务处理(这里实际上是通过异常类型来进行业务逻辑控制)行47-行53:如果异常为AuthenticationException,则进行重定向。此时需同步打印日志,日志级别都为DEBUG

需要再次强调的是,程序异常和程序是否出现Exception没有直接关系,判断程序异常的依据是程序执行过程中遇到的的意外情况是不在预期内且不可解决的。如果程序中设计了一些容错机制来提升健壮性,那么被容错的Exception以及容错的结果都是预期内的,这不属于程序异常范畴,甚至都不算是一种业务错误;但这种情况仍需通过一些审计机制来识别并优化。以推荐场景为例,用户请求推荐服务超时时,系统给出兜底的推荐结果,这个不影响用户基础体验(但影响业务效果);如果超时的情况是极少数的,那么可以认为这种情况属于正常现象;但如果超时的情况过多,产生较大的业务影响,那就需要进行系统优化。

标准化 VS 特例化

在软件架构和代码设计中,通过构建一个标准层来隔离上层应用与下层应用是实现模块化和低耦合的常见策略,如JDBC隔离了业务层和DB层,从而使得DB的更换对业务层无感。标准化策略有助于提高系统的可维护性、可移植性和灵活性。然而,在实际开发中,有时为了满足特定需求(如性能优化),上层应用可能需要利用下层实现的特性,从而导致对标准接口的使用的偏离。这种情况下,需要充分权衡标准化和特例化的利弊。

实际上,以上提到的标准接口仅仅只是标准化范式的一种,其它的还包含标准规范、优秀实践等。标准化很重要的一个收益就是消除依赖方变化带来的不稳定性;特例化实际上破坏了标准化,使得标准化的收益降低,甚至带来负面影响。

▐ 实践比较

标准化:所有场景均按照标准范式进行编程,如API使用、风格保持等特例化:根据不同场景进行针对性编程

实践

标准化

特例化

效用目标

全局最优

局部最优

可维护性

强:代码易理解

弱:代码不易理解

可移植性

强:上层组件对下层组件无依赖

弱:上层组件直接依赖了下层实现

业务确定性

强:按标准化方式实现,不会出现预期外情况

弱:一旦相关代码或组件无法兼容特例,则会产生预期外异常

效率

中:中间层增加了交互成本,同时无法充分利用下层组件特性

高:可充分使用下层组件特性

实践建议

1.一旦引入标准化实践,则在实际业务实现时,应充分遵循标准化要求,确保收益最大化

2.标准化应避免过度泛化,如在接口参数类型使用Object、Map等

3.应提供工具或机制保障标准化落地质量

1.如果业务对特定组件的依赖是确定的,则无需建立标准层

2.如果标准层和特例化的存在都是必要的,则应该将特例的处理隔离在特定范围内,避免扩散;同时应建立特例化实现管理机制,定期审计

▐ 案例解读

案例1:业务应用使用了JDBC来访问数据库,JDBC让应用可以以标准化的方式对接到不同的数据库。业务SQL非必要情况下不要使用和特定数据库绑定的语法(如Oracle数据库特定的SQL语法)。因为后续一旦更换了数据库,而新的数据库不支持此语法,就会产生迁移成本。

案例2:在很多Java编程规范中,针对大量字符串的拼接的场景,建议使用StringBuffer等组件来实现而不建议直接使用“+”号。但是因为现在绝大部分Java编译器会针对字符串“+”的写法进行优化,避免产生大量的中间变量,因此大部分时候这并不会带来实质影响。但是这意味着将字符串“+”带来的风险交由特定的编译器来规避,这显示的产生对特定编译器的依赖。如果代码在一个不支持此优化的编译器上编译并上线运行,那么风险就变成问题了。

团队介绍

我们是淘宝集团-供给技术业务架构团队。通过深刻理解业务和技术发展趋势,识别系统在支撑业务过程中的问题,定义面向业务中长期发展的架构命题,并持续推动架构治理和演进,实现架构的不腐化以及灵活高效的响应业务变化。

参考资料

《 Javadoc 规范》:https://docs.oracle.com/javase/8/docs/technotes/tools/windows/javadoc.html
0 阅读:12

科技梦想在奔跑

简介:感谢大家的关注