成为优秀程序员-代码篇

news2024/12/24 9:16:42

1. 序言

刚毕业参加工作时候,公司正在快速扩张,我入职的时候组内刚刚招了一大波人,当时leader提出集体cr代码来拉齐团队内的编码规范,每当有对于相对重要改动大的项目就会集体cr代码,老板想法初衷是好的,但由于没有一个编码的统一规范,或者参考标准,导致大家不知道项目的编码风格应该是什么样,只能模仿以前的代码风格,慢慢代码变了味道,只查找代码中的bug,防止有业务逻辑漏洞,不再统一编码规范。由于我工作经验比较少,经常会找团队技术负责人cr代码,大佬每次看到我的代码都会邹起眉头,很无奈的说“代码不能这样写”,给我一顿批,但很遗憾他只说代码不应该这样写,却没有告诉我应该怎样写,或者给出一些参考的好代码标准,不过依然很感激他能指出我的问题,知道自己写的代码有问题后,我开始找资料,一直思考代码应该怎么写。
入职蚂蚁,我经常会浏览ata和语雀看帖子,发现有很多人分享如何系统的学习写代码,看的资料越多越发现写好代码真是一件博大精深的事情,本文我会对最近看的资料系统的汇总,可以帮助后续有同样困扰的新同学提供一个学习路线,看完这些资料再写代码有了不一样的感受,我会记录一下自己的思考,总结适合自己的方法论,但是本人的工作经验和水平有限,有很多地方可能理解的不对,有问题希望大家指出,我及时修改。

2. 学习路线

代码学习是个循序渐进的事情,整体分为点->线->面->体这几个阶段,万变不离其中,ata和语雀上大部分优秀的文章都是按照这个递进过程介绍如何写代码,参考图中的书加上自己的理解总结出一套适合自己使用的套路。做很多事情都是有套路的,很多优秀额的人都不是人们口中的天才,只不过是他们善于总结,把做过事情中的一些共性抽象成一套模版,也就是方法论,做类似事情的时候按照模版套用对具体的事情稍作定制就可以快速得出解决方案。写代码也一样,很多大牛都已经对写好代码整理好了方法论,我们需要把大牛的方法论以自己的理解变成自己的方法论就可以写出不错的代码。
在这里插入图片描述

我们在看书学习时,尽量按照 点->线->面->体 的顺序循序渐进去学习,当然这些知识点不是完全独立彼此之间存在交集,而且这个顺序是在我个人理解上来看的,下面我将按照下面的顺序,对书中的内容加上自己的理解做一些介绍,当然大家可能对《阿里编码规范》都比较了解,所以这里面的知识就不过多赘述了。

3. 代码层面

3.1 好的命名

“计算机科学只存在两个难题:缓存失效和命名。” ——Phil KarIton

无论看哪篇代码改进文章都缺少不了对命名的强调,通常评价代码的好话必然少不了【易读性】,有人说好的代码不需要注释,完全通过 变量、函数和类的名字就可以清楚的理解代码表达的意思。从项目中包名可以看出领域划分,类名能看出领域模型结构,函数名能提现开发者抽象能力,毫不夸张的说,从命名可以快速看出开发者问题拆解过程、对业务的理解程度,代码的命名也非常考验作者的代抽象能力。首先看下Java中命名的基本规范,以下的例子大部分来自于优秀源码

3.1.1 符合语义

标准

无论是给变量、类、还是否方法起名字一定要符合语义,怎样的命名符合语义呢?如果脱离上下文你通过看名字,大致能知道代表的含义或者具有的能力,这样的命名是符合语义的,例如

  1. addBeanFactoryPostProcessor(),如果对Spring生命周期有一定了解,不看方法实现也大大概知道是给BeanFactory添加后置处理器
  2. initApplicationEventMulticaster(),通过命名大致能猜出来是初始化容器事件广播者。
不要担心名字长

如果由于自己的英文水平问题,无法给出简短的名字,那就用长名字,不要担心被埋怨名字长,总比表示不清楚语义要好,除了个别局部变量外,尽量不要使用简写来命名。例如:

  1. invokeBeanFactoryPostProcessors()名字虽然长,但是能表达语义就很好禁止使用 a,b,c,…i这种字符作为变量名。
避免人为制造误导
  1. 不要使用已经被暂用的名词命名,有些名字已经固化人心,如果拿作为变量表达自己的意思很容易被误解,例如你自己命名了一个支付工具给命名为Alipay,而实际上工具中内容和支付宝一点关系没有,很容易增加理解成本。
  2. 名字之间要有一定区分度,例如区分模块中某处的XYZControllerForEfficientHandingOfStrings和另一处的XYZControllerForEfficientStorageOfStrings,差别非常小容易区分出错,编译器通常会有自动提示的功能,如果名字之间非常相似,很容易在引用的时候选错,如果错误的引用与期望的功能差别很大还好,编译和测试的时候会发现,但是二者名字相似,功能也非常类似何有可能会被问题待上线导致故障。
  3. 避免使用同义词命名,例如class AccountUserInfoclass AccountUserData,基本无法凭借名字区分这两个类,每次要使用其中之一的时候都需要点进类中去寻找二者的区别,打断开发者节奏
  4. 限定词后置,例如Total,Avgrage,Mean,Sum,Min,Max...,在开发过程中会有很多类似的词汇,要注意积累,属性和变量命名时加上限定词后缀便于理解代码,例如priceMax,withrawAmountTotal,saveAmountTotal,delayTimeMax,类似变量一目了然。
  5. 在常量与变量的命名时,单位后置,以提升辨识度,例如:startTime / workQueue / nameList / TERMINATED_THREAD_COUNT,有单位的变量要以变量结尾,比如delayTimeMaxSecond = 10;可以清楚知道最大等待时间是10s

3.1.2 统一命名风格

很多单词都可以表达相似的含义,比如查询数据函数命名,可以是 getXXX, queryXXX, fetchXXX,这些词语含义相近,而且都可以表达获取数据的含义,开发者只需要选择一个自己喜欢的就可以,但是尽量要保证命名的统一,比如service层中约定使用queryXXX这种命名,那就在所有函数中都保持这种命名风格,忌讳一会使用getXXX, 一会使用queryXXX,所以维护一个属于团队的同一命名表,统一风格,提升可维护性、易读性。
通常命名词汇表都是以“对仗词”的方式进行维护,比如getXXX, setXXX,这是一个对仗词,如下是常见的对仗词汇表,可以根据自己需要补充,尽量挑出来一套命名规则自己使用。

动词

getset
add/saveremove
createdestroy
startstop
beginend
insertdelete
showhide
suspendresume
lockunlock
openclose
onoff
subscribeunsubscribe

名词

firstlast
incrementdecrement
minmax
nextprevious
sourcedestination
srcdest
souretarget

形容词或副词

oldnew
activeinactive
visibleinvisible
updown

个人使用以及推荐的service和dao命名规范:

操作servicedao
查询单个对象queryXXXqueryXXX
分页查询listXXXlistXXX
插入saveXXXinsertXXX
删除removeXXXdeleteXXX
更新updateXXXupdateXXX
统计值countXXXcountXXX
构建参数且返回buildXXX
函数目的是填充参数(不推荐这种用法)fillXXX
参数类型转换convertToXXX

3.1.3 选择对的词性

变量、函数、类、接口都有自己的一些列命名词语属性,按照规范的词性命名可以降低理解成本,提升代码易读性,减少歧义。

变量

词性:名词或名词短语,bool型:形容词
通过名字要大致知道这个变量的含义,不要担心名字太长,拒绝使用不规范缩写,例如:Custom cus = new Custom(),脱离上下文容易有歧义。

方法

词性:动词或者动词短语
方法的命名要注意两点:

  1. 函数名要具有可读性,脱离上线文,不看具体实现仅仅通过函数名就大致能够知道函数的功能。
  2. 接口对外暴露的方法要具有抽象性,通过函数名知道函数的作用和目的是什么,而不是表名函数怎么实现的,避免关注底层实现细节,例如想要定义一个接口中的查询方法,避免在方法中加入过多细节,例如,queryMysql()queryOB(),暴露出底层存储细节,这样不利于具体实现的扩展,丢失了方法的抽象能力。

在定义方法和接口的时候要重协议、轻实现,对外要暴露的方法需要反复打磨,如果在开发联调过程中感觉接口定义的不准确要及时修改,切忌因为怕麻烦或者业务方抱怨就将就运行不予修改,因为一旦系统上线,后续再觉得维护困难推业务改接口成本是要呈几何倍增长。

词性:名词
按照我的理解将常用类分成了几类

  1. 实体类:重要业务模型的映射,通常是充血模型,模型内不只是简单的get,set函数,在模型闭包内允许有一些行为操作的封装,有一定的业务状态流转,在领域驱动编程中和领域模型对应。例如:
class WithdrawOrder {
    private Long amount;

    public void setAmount(Long amount) {
        if (amount == null || amount <= 0) {
            throw new IllegalStateException();
        }
    }
}

实体类的命名与模型映射,通常是能表示业务类型的名词,比如 账户:Account,提现单:WithdrawOrder,支付单:PaymentOrder

  1. 服务类:服务类本身没有状态,实体类通常是业务实体模型的抽象,模型与模型之间无法直接操作,如果需要同时操作两个业务模型,可以通过服务类对实体类操作进行编排,例如,通常支付场景中会有两个实体类 【余额类】和【提现单类】,两个实体类之间无法直接操作,通常会抽象出一个Service作为上帝类,服务类会扣减用户余额,封装提现单。在领域驱动编程中对应的是领域服务类。通常服务类的命名 ServiceControllerManager
  2. 模型类:没有状态、行为很少的实体类
    1. 传输类:用户在分层架构之间传输的类,以下为参考阿里巴巴编程规范给出的意见,不一定要按照这种命名格式,只要团队内能够约定好统一的命名就可以,我其实很抵抗这种命名格式,因为会导致代码看起来很丑陋
      1. 数据对象:xxxDO,xxx即为数据表名
      2. 数据传输对象:xxxDTO,xxx为业务领域相关的名称。
      3. 展示对象:xxxVO,xxx一般是封装给前端使用参数的类。
    2. 值对象:将实体类中的某一些共性属性抽成一个模块,方便理解和复用,例如常见的例子
class Money {
    private Long amount;
    private String Currency;
}
  1. 枚举类:通常是用 类型、状态 + Enum的格式命名
  2. 辅助类:例如代码中的UtilHelper类,设计辅助类尽量要满足单一职责,避免某一个辅助类过大,当辅助类代码过多时尽量做拆分。
接口

词性:名词或形容词

  1. 如果想要表示归属性的接口可以使用名词或名词短语命名,例如:ApplicationEventPublisher,通过接口可以明确表示实现类是一个时间发布者。
  2. 如果想要表示某种能力,可以使用形容词命名,例如Closeable,表示实现类应该是可关闭的。

3.2 异常

3.2.1 异常介绍

Throwable有两个子类,ErrorExceptionExeption子类通常是由于代码逻辑问题导致的,在编码过程中需要处理和注意的异常,Error子类通常是代码维度无法处理的问题,例如虚拟机出错,内存溢出等严重问题,这类问题通常无法在编码的时候想清楚处理逻辑,只能上抛交由系统处理。

  1. 运行时异常:RuntimeException类及其子类异常,如NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)等,这些是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,运行时异常Java编译器不会检查它,即使没有用try-catch语句捕获它,也没有用throws子句声明抛出它,也会编译通过。
  2. **非运行时异常 (编译异常):**是RuntimeException以外的异常,类型上都属于Exception类及其子类。如果不处理,程序就不能编译通过。如IOExceptionSQLException等以及用户自定义的Exception异常,一般情况下不自定义检查异常。

3.2.2 异常使用

异常代替异常码

之前项目中有如下类似的Service层代码,校验函数中,如果checkAmount校验不通过则返回异常码,在withdraw方法中将返回的异常码封装到WithdrawResult中返回,如果外层有调用withdraw方法的地方还需要在外层对返回值做判断和封装,一层一层处理,这样会导致代码异常啰嗦,导致易读性降低。

public WithdrawResult withdraw(String walletId, Long amount) {
    ErrorCode errorCode = checkAmount(amount);
    if (errorCode != null) {
        return new WithdrawResult(errorCode);
    }
}
private ErrorCode checkAmount(Long amount) {
if (amount <= 0) {
    return ILLEGAL_WITHDARW_AMOUNT;
}
return null;
}

可以使用异常代替异常码的处理逻辑,如下所示,这样避免了checkAmount方法带有异常码返回值,也不需要在调用checkAmount的地方做各种异常码判断,只需要在最外层统一捕获做逻辑处理即可,提升了代码的连贯性。

public WithdrawResult withdraw(String walletId, Long amount) {
    checkAmount(amount);
}
private void checkAmount(Long amount) {
if (amount <= 0) {
    throw new WithdrawException(ILLEGAL_WITHDARW_AMOUNT);
}
return null;
}

有些人排斥这种做法,因为通过异常控制代码流程对性能是有损的,但大部分java用户都是应用开发者,相比于带来代码可读性收益,这点微乎其微性能损失无关痛痒。

3.2.3 异常处理

个人总结异常的处理总体上分为两类

  1. 调用者自己可以处理吞掉的异常
  2. 调用者无法处理的

使用try...catch...语句捕获异常并做并做逻辑处理是要根据业务语义确定的,没有一个通用的规则,但是有一些注意事项要注意

  1. 如果调用处知道该如何处理当前异常,则可以捕获处理做相应的逻辑处理,例如一些fail-safe场景,旁路链路,二方包弱依赖等,如果不能明确要如何处理,需要将异常上抛,由外层决策处理方案。
  2. 选择合适的口径,避免全都使用ExceptionThrowable这样粒度很粗的异常捕获。
  3. 在使用二方包、rpc调用、动态生成类的相关方法时,尽量要用Throwable粒度捕获异常,在防腐层做合适的异常转换。在非核心链路弱依赖的场景下,合理的做法是采用fail-safe逻辑捕获依赖,防止对核心主链路产生影响,开发者经常习惯性的使用Exception捕获异常,但是引入的依赖很有可能导致Error异常,且由于这个问题在阿里项目中引发过故障,感兴趣可以自行搜索一下。
  4. 做合适的异常转换,切忌 捕获一个铁块,抛出一块棉花。举例如下,捕获的是一个导致提现失败的异常,上抛一个FailSafeException,有可能在外层捕获认为对主流程没有影响继续推进,然而当前操作应该是失败的。
public WithdrawResult withdraw(String walletId, Long amount) {
    try {
        checkAmount(amount);
    } catch(Exception e) {
        throw new FailSafeException();
    }
    private void checkAmount(Long amount) {
    if (amount <= 0) {
        throw new WithdrawException(ILLEGAL_WITHDARW_AMOUNT);
    }
    return null;
}

3.2.4 错误码

主要分为两类:

  1. 对外使用的平台类错误码
  2. 内部微服务之间使用的错误码
平台类错误码:

对于平台、底层系统或软件产品,可以采用编号式的编码规范,好处是编码风格固定,给人一种正式感;缺点是必须要配合文档才能理解错误码代表的意思。
通常采用 【标识+编号】构成错误码,使用时注意点:

  1. 【标识】表示错误码大的划分规则,可以按自身需要对多无划分,不需要有强制要求,但是要保证MECE原则,标识之间正交并且可穷举。
  2. 编号采用代码段的方式,相同业务归属统一波段,尽量预留足够的波段长,避免随着业务发展编码不够用的问题。

举个例子可以采用【业务】【场景】【编号】格式
PAY01000-PAY01999:表示支付域提现相关错误。
PAY02000-PAY02999:表示支付域充值相关错误。

微服务之间错误码

微服务之间错误码通常是公司内部不同团队之间使用,应该尽可能让异常显性化,开发仅通过异常码不查资料、咨询下游团队同学也能大致知道异常产生的原因。
可以采用【业务+场景+错误】的情况来命名。
举例如下,可以表示【支付业务】【提现场景】【用户余额余额不足】
PAY_WITHDRAW_BALANCE_NOT_ENOUGTH(“140”, “提现余额不足”)
微服务之间错误码分为以下三类,参考《代码精进之路:从码农到工匠》里的建议,可以通过固定错误码前缀做区分:

错误类型错误码格式描述
参数错误P_XX_XX参数传递错误,外部调用接口传参不符合要求,通常要求业务使用方自己解决
业务错误B_XX_XX正常业务场景拦截,例如支付系统提现余额不足等,业务方能够根据错误码作相应逻辑处理
预期外异常S_XX_XX服务提供方系统层面异常一些预期之外的错误,细节原因应该封装到msg当中,业务使用方看到报错后立刻可以判断自己解决不了这个问题,需要咨询下游解决,下游开发者根据msg中的信息可以快速定位到报错,最快速度解决。
  1. 业务方自身使用不当P_XX_XX,例如参数错传、漏传、功能不支持,业务方在看到错误码后可以自己做出反应,不需要咨询服务提供者就能解决问题。
  2. B_XX_XX,正常业务场景拦截,例如支付系统提现余额不足等,业务方能够根据错误码作相应逻辑处理
  3. 服务提供方系统层面异常一些预期之外的错误,对外应该返回S_XX_XX 错误码,细节原因应该封装到msg当中,业务使用方看到报错后立刻可以判断自己解决不了这个问题,需要咨询下游解决,下游开发者根据msg中的信息可以快速定位到报错,最快速度解决。

微服务之间错误码都是作用于上下游系统之间,系统与系统之间对彼此的业务都有一定了解,因此定义错误码原则是:使用简短的信息让使用方可以快速自查问题出自哪个环节,可以在错误描述中暴露一些系统内部信息,但不要过度复杂容易误导使用者。

3.2.4 错误码和异常捕获的选择

在项目内部代码流转过程中,个人建议在编写业务代码过程中,根据上述定义错误码的规则直接抛出异常,在最外层捕获处理通过错误码+异常栈可以快速定位问题,但是要定义不同类型区分出是业务层面逻辑拦截还是系统层面报错,通常有三种形式异常对应不同的处理方式:

  1. 如果是参数异常,捕获异常后将错误码封装在返回值中,业务使用方可以根据错误码直接定位传参问题。
  2. 如果是业务异常,在抛出异常处打印触发异常的细节,给业务返回逻辑异常错误码,由开发同学配合处理。
  3. 如果是预期之外的系统异常,统一返回SYSTEM_ERROR在最外层捕获异常时日志打印出异常栈,入参、出参.

思考:
国无法,将国之不过,古代制定良好的法度,遇到一两个昏君国家也可以在法律的限制下正常运行,不至于亡国,同样团队也需要一个好的规范,定义好规范,一时之间人员流动也可以让项目很好的运行下去,错误码的格式就是团队规范的一种体现。

3.3 代码层次感

写代码要像写文章一样,按照金字塔思维落笔行文,写代码的思考方向:

  1. 从上之下,理清全文脉络,对代码整体结构有骨架把控。
  2. 从下而上,具体实现的过程中发现发现问题修改骨架结构。
  3. 直到项目测试之前,循环上述两个阶段
  4. 有很多问题由于时间限制,对业务理解不足,写代码的过程可能想不清楚,因此在后期维护过程中需要持续重构,对现有代码进行优化,降低其他人的接入和理解成本。
  5. 采用树结构关联模式,尽量减少图结构模式,树结构是指类与类之间尽量单方向依赖,图结构会出现很多的双向依赖,双向依赖通常会加重耦合程度,每次修改一个类时候很容易导致另一个类的变更,不符合开闭原则,会引入过多的修改。而且图结构的双向依赖会破坏代码的层次感,导致代码不易读

在这里插入图片描述

写代码要像写文章一样,要有层次,或者说任何一个问题都能拆解为一个问题数,文章首先有标题表明要做什么事,这就是问题树的跟节点,然后文章的摘要部分会介绍做这件事要分几步,每步是什么,这就是问题树的第一层节点;再往下会每个自然段讲每一步具体怎么做,如此层层拆解。写代码也一样也要层层拆解。
经过科学表明,人类由于受到精力和智力的限制,同时能够思考的事情有限,通常同时可以处理7(+/-)2个场景,智力稍差一点的可以思考5个场景,智力稍好一点的同时可以思考9个场景。开发过程中的要处理的业务场景可能远远不止这些,如果将所有逻辑平铺到同一个函数层面中,需要来回的切换思路,很容易有逻辑遗漏,产生bug。采用金字塔思维可以将复杂的问题拆分,对零散的业务场景归纳总结,最终形成和文章目录类似的代码,金字塔思维方式和画脑图的过程类似。

good case

以Spring这段核心代码 refresh()方法为例,这个方法负责大部分的spring容器的启动工作,可以说底层涉及的逻辑很复杂,但是开发通过合理的抽象,在refresh方法中使用模版方法约定了启动的整个执行过程,其中调用的每一个方法都赋予了相应的职责,排查问题的时候可以只看自己关注的部分即可,其他代码不会对逻辑产生影响。

public void refresh() throws BeansException, IllegalStateException {
    synchronized (this.startupShutdownMonitor) {
        // Prepare this context for refreshing.
        //初始化上下文
        prepareRefresh();

        // Tell the subclass to refresh the internal bean factory.
        // 从xml解析bean的定义为BeanDefinition存放在Factory中
        ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();

        // Prepare the bean factory for use in this context.
        // 准备Bean工厂,设置类加载器、初始化环境bean,BeanPostProcessor
        prepareBeanFactory(beanFactory);

        try {
            // Allows post-processing of the bean factory in context subclasses.
            // 在加载bean定义后,实例化bean之前,子类通过实现postProcessBeanFactory()方法,允许对beanFactory进行修改
            postProcessBeanFactory(beanFactory);

            // Invoke factory processors registered as beans in the context.
            // 获取所有BeanFactory的后置处理器对工厂进行处理
            invokeBeanFactoryPostProcessors(beanFactory);

            // Register bean processors that intercept bean creation.
            // 注册所有的BeanPostProcessor,对bean实例化拦截增强
            registerBeanPostProcessors(beanFactory);

            // Initialize message source for this context.
            initMessageSource();

            // Initialize event multicaster for this context.
            // 初始化容器的事件广播者
            initApplicationEventMulticaster();

            // Initialize other special beans in specific context subclasses.
            onRefresh();

            // Check for listener beans and register them.
            // 注册事件的监听者
            registerListeners();

            // Instantiate all remaining (non-lazy-init) singletons.
            //实例化所有非懒加载的bean
            finishBeanFactoryInitialization(beanFactory);

            // Last step: publish corresponding event.
            // 结束refresh方法,修改beanFactory生命周期,发出ContextRefreshedEvent事件广播
            finishRefresh();
        }

        catch (BeansException ex) {
            if (logger.isWarnEnabled()) {
                logger.warn("Exception encountered during context initialization - " +
                            "cancelling refresh attempt: " + ex);
            }

            // Destroy already created singletons to avoid dangling resources.
            destroyBeans();

            // Reset 'active' flag.
            cancelRefresh(ex);

            // Propagate exception to caller.
            throw ex;
        }

        finally {
            // Reset common introspection caches in Spring's core, since we
            // might not ever need metadata for singleton beans anymore...
            resetCommonCaches();
        }
    }
}

思考:
如果一个团队中同学编码都是层次分明,写代码就像写文章一样,模版代码如同文章目录标题,新人的理解成本会极大降低,模块之间相互隔离,写代码是出bug的可能性会降低,问题排查速度会很大提升。甚至不需要加入过多设计模式,仅仅通过这种由上至下,再自下而上编码方式就可以很好的维护一个项目。

3.4 代码坏味道

3.4.1. 重复代码

任何重复代码、结构都应该避免,常见的重复代码类型:

  1. 两处不同代码完全相同,这又分为三种情况
    1. 同一个类中重复:通过Extract Method,在同一个类中抽出一个方法复用
    2. 如果是兄弟类中出现同样的代码,Extract Method放入抽象类中
    3. 如果是跨包类中出现重复代码,通过Extract Class提取到一个公共类中
  2. 两处代码结构相同,局部细节地方不同,很多同学嫌麻烦直接将一段代码复制粘贴到另一个地方,后续维护的过程中需要修改逻辑,开发者需要将两段代码逐一对比寻找差异,极其低效。举例如下,下面三个方法,每个方法8行,只有一行不同的业务逻辑处理,其余代码均重复。
public void sendBook() {
    try {
        this.service.sendBook();
    } catch (Throwable t) {
        this.notification.send(new SendFailure(t)));
        throw t;
    }
}

@Task
public void sendChapter() {
    try {
        this.service.sendChapter();
    } catch (Throwable t) {
        this.notification.send(new SendFailure(t)));
        throw t;
    }
}

@Task
public void startTranslation() {
    try {
        this.service.startTranslation();
    } catch (Throwable t) {
        this.notification.send(new SendFailure(t)));
        throw t;
    }
}

这种代码通常按照框架思维,将变和不变的部分剥离开,如下所示。

private void executeTask(final Runnable runnable) {
    try {
        runnable.run();
    } catch (Throwable t) {
        this.notification.send(new SendFailure(t)));
        throw t;
    }
}

@Task
public void sendBook() {
    executeTask(this.service::sendBook);
}

@Task
public void sendChapter() {
    executeTask(this.service::sendChapter);
}

@Task
public void startTranslation() {
    executeTask(this.service::startTranslation);
}

. 过大类 Large Class

一个类代码量巨大,每次修改业务逻辑都晕头转向,类中方法数量太多检索起来都不方便,并且在一个大类中跳转过来跳转过去,很容易迷失,甚至忘记自己在干嘛。并且类过大通常意味着职责不单一。通常解决手段如下:

  1. 业务代码大类产生一般有两种原因,通常中会存在很多成员变量,通常将其按照功能归类(有很多种归类方式,按照MECE原则选择合适的粒度,,保证不同类别分组不交叉即可)并抽取类,举例如下。
    1. 业务本身很复杂,需要做大量逻辑处理,将处理逻辑分段,每段逻辑提取到单独类中。
    2. 在维护的过程中,开发者不做过多思考,为了方便顺手将用到的代码添加到当前类中,按照功能分类,相似功能分到一个新类中。
  2. 如果类中存在很多类似的方法,比如一个汽车类中,存在操作奔驰汽车,宝马汽车的方法,方法之间相似度很高,可以将相似代码抽取到父类中,提取接口配合多态功能,拆分子类。
public class User {

    private long userId;

    private String name;

    private String nickname;

    private String email;

    private String phoneNumber;

    ...

}

public class Author {

    private long userId;

    private AuthorType authorType;

    private ReviewStatus authorReviewStatus;

    ...

}

public class Editor {

    private long userId;

    private EditorType editorType;

    ...

}

3.4.3 长函数

有些方法代码像是写流水账一样,所有的逻辑平铺到一个方法中,其中夹杂着对象创建、日志、异常、核心逻辑,有时候20行代码,上面10行和下面9行都是创建对象,中间一行做核心业务逻辑处理,阅读代码的时候很容易将核心逻辑遗漏,需要反复阅读才能找到,一个方法中如果有多个类似的代码,那开发者一上午都不一定能读明白这个方法实现的功能。产生的原因通常有两种

  1. 平铺直叙写代码,宛如流水账

  2. 一点点维护增加逻辑,开发者以稳定不敢修改老逻辑为由堆代码。
    解决方式:

  3. 拆分出函数,将函数中代码按照职责分类Extract Method,赋予每一个新函数语义,后续有功能变更只需要修改指定语义的方法即可。

  4. 如果方法中有多处if else逻辑,可以将循环或者条件内代码Extract Method复用

3.4.4 长参数列表

方法参数量多很容易引发问题,我在前司时曾经就因为某个古老方法参数多,并排有两个相同类型的参数,调用传参时没有注意导致参数传错而引发故障,当时一直因为自己不细心而自责,其实不光是自己的责任,这种方法本身就不合理,所以才会导致问题发生。通常产生长参数列表原因:

  1. 开发者一开始就没有想清楚,赋予这个方法过多职责。

  2. 维护过程中,每次修改增加一个参数字段慢慢参数列表越来越长

  3. 缺乏抽象思维,例如接口参数传入 (String userId, String nickName, String phoneNumber)
    通常解决手段:

  4. 将方法参数抽出一个实体,如果抽出的实体参数量较多,可以将属性分类,提取出值对象,如下createBook方法参数较多,并且每个参数都不可或缺。

public void createBook(final String title,
                       final String introduction,
                       final URL coverUrl,
                       final BookType type,
                       final BookChannel channel,
                       final String protagonists,
                       final String tags,
                       final boolean completed) {
    ...
    Book book = Book.builder
    .title(title)
    .introduction(introduction)
    .coverUrl(coverUrl)
    .type(type)
    .channel(channel)
    .protagonists(protagonists)
    .tags(tags)
    .completed(completed)
    .build();
    this.repository.save(book);
}

如下抽出参数实体进行优化,有人会说NewBookParamters类同样有很多属性,使用该类的方法一样会面临着参数过长的问题,其实不然,如果此次请求必然要使用这么多次参数,那必然会在客户端调用的地方封装这些参数,通常会封装rpc方法,由业务方构造xxxRequest参数,我们能做到的只是服务端在接受到这种复杂结构体后不再使用类似长参数传参方式,内部接收到请求后将其转成NewBookParamters在系统内流转,这样在系统内部不会出现长参数列表函数的问题:

public class NewBookParamters {

    private String title;

    private String introduction;

    private URL coverUrl;

    private BookType type;

    private BookChannel channel;

    private String protagonists;

    private String tags;

    private boolean completed;

    public Book newBook() {

        return Book.builder
        .title(title)
        .introduction(introduction)
        .coverUrl(coverUrl)
        .type(type)
        .channel(channel)
        .protagonists(protagonists)
        .tags(tags)
        .completed(completed)
        .build();
    }
}

public void createBook(final NewBookParamters parameters) {

    ...

    Book book = parameters.newBook();
    this.repository.save(book);
}
  1. 上述做法只能避免在系统内部流转时长参数列表带来的影响,但是在客户端使用时构造这种长参数列表请求无法避免,由于参数较长大概率违反单一职责原则,合理的做法在无过高性能要求情况下,可以将一次复杂调用请求拆分成多个请求。

3.4.5 基本类型偏执

开发过程中,很多开发者在设计接口的时候,入参和出参喜欢使用基本类型,存在弊端:

  1. 没有oo的封装思想
  2. 入参:接口每次变更都需要新增或者删减字段,时间一长会导致接口参数列表过长,引发3.4.5的一些列问题
  3. 出参:返回字段如果使用基本类型会导致方法灵活性下降,无法添加新增的返回信息

因此建议在设计对外暴露的接口时,尽量封装对象作为出参入参,私有函数理论上不需要单独封装对象(参数过长情况除外)
根据最少知道原则,应该避免代码中出现链式传递,可以在直接依赖的对象中封装客户端需要的,如下例子为了获取作者信息通过链式调用获取作者名字,会存在接口语义变更、空指针异常等问题,可以在Book内部封装作者名字的方法暴露给外部,开发过程中要培养OO的思维,避免平铺直序,要善于封装属性、行为。

String name = book.getAuthor().getName();
class Book {
    Author author;
    public String getAuthorName() {
        if(author == null) {
            throw new  RuntimeException();
        }
        return this.author.getName();
    }
}

3.4.7 可变数据问题

  1. 滥用Getter和Setter:开发过程中,新建类时通常会通过ide自动添加Getter和Setter方法,这会导致类更像结构体,破话OO封装特性,消除 setter ,有一种专门的重构手法,叫做移除设值函数(Remove Setting Method)
    1. 只暴露外部会使用到的、稳定的接口
    2. 删除所有Setter方法,通过构造函数构造初始值
  2. 按照DDD思维,将对象分为实体对象和值对象,实体对象会暴露一些可控的修改接口,值对象全部设置成不可变对象,每次有变更时新建实体,解决线程安全问题
  3. 避免静态变量,静态变量一旦被修改也及其容易出现线程安全问题,可以对静态变量进行封装,通过接口控制变量的变化情况,虽然也有线程安全的问题,但通过接口暴露相比于静态变量,更加可控,可以将线程安全相关的逻辑全部封装在对外暴露的接口中

3.4.7 数据泥团

我们代码中经常会出现一个很大类似结构体的类,没有过多的作用,只是用于传递参数,在代码调用过程中如果想使用类中数据就要传递整个参数的引用。例如,有些开发者喜欢使用context,在请求入口处构造context把整个请求中所有要用的参数都放到里面,,然后整个请求调用过程中所有方法都传递context,这种做法好处

  • 实现简单,接口整洁

缺点也十分明显

  • 不符合最小惊奇原则,某个方法可能只需要使用几个简单的参数,却需要感知整个context中所有参数
  • 难维护,随着业务发展,这个类参数会越来越大可能会有上百个参数,全都糅杂在一起,属性含义会变得模糊,后来者根本不敢轻易变更
class WithdrawContext {
    private long withdrawNo;
    private long amount;
    private Strng accountId;
    private long bizId;
    private String userName;
    ....
}

解决方案其实很简单,只需要按照某种规则将参数分组并在注释中描述每个类的定位,防止后来开发者随意添加参数污染类语义,举个例子:

public class User {

    private String firstName;
    private String lastName;

    private String province;
    private String city;
    private String area;
    private String street;
}

public class User {

    private UserName username;
    private Adress adress;
}

class UserName{
    private String firstName;
    private String lastName;
}
class Address{
    private String province;
    private String city;
    private String area;
    private String street;
}

3.4.8 纯数据类

很多面向对象开发者其实还是使用面向过程思想开放,尤其是业务开发,很多人的开发流程是这样的:数据库设计->dao层代码->Service层->Controller,所有的逻辑都放在Service中,其中使用到的对象更像是结构体,所有属性声明为私有类型,但是自动生成所有get、set方法,没有任何业务逻辑,这种开发模式的好处是简单,开发者可以平铺直序的把所有逻辑放到Service中,对于快速迭代的初期业务很好用,但是随着业务发展,每个Service类开始变得臃肿,有经验的开发者会在Service之上抽出一层Manager分担Service的业务逻辑,用于编排Service,但这种方式治标不治本,业务更复杂,Manager也会变得臃肿,然后有些人会抽出一些静态工具类将非业务逻辑提取出来,减少Manager和Service压力,但是静态类太多很多开发者都不知道哪些功能工具类中已经支持,然后开始重复开发工具类。
贫血模型的优势和缺点都很明显,作为一个OO开发者,应该具有一定的面向对象开发思维,将对象属性相关联的行为封装在对象实例中,这样还可以更好的决定暴露哪些接口给外部使用

3.4.10 发散式变换

某个类因为不同原因需要修改,本应该归属B、C、D类的方法放到了A类中,那么A类就会因为四个原因发生变化,违反了单一职责。

解决办法:
移动函数,字段和函数可以搬移到一个现有的类也可以搬移到一个全新的类中,将不属于A的能力从类中转移B、C、D类中。

**3.5.10 **霰弹式修改

发散式变化产生原因是本应该归属B、C、D类的方法放到了A类中,霰弹式变化相反,本应该归属A类的方法放到了B、C、D类中,那么在对A类功能做修改时,B、C、D类均会受到影响。例如对AccountBalanceService类某个功能修改,充值、提现都需要修改,说明出现了霰弹式修改,这是由于低内聚、高耦合导致的。

解决办法:通过移动字段和移动函数,注意,字段和函数可以搬移到一个现有的类也可以搬移到一个全新的类中,如果调用B类的某个函数,只有A类用到,那么直接将函数移动到A类中,如果A类和B类都用到了这个方法,可以抽象出一个common类,让A、B依赖common

  • “移动函数”。如果一个类有太多行为,或如果一个类与另一个类有太多合作而形成高度耦合,就需要移动函数。通过这种手段,可以使系统中的类更简单。浏览类的所有函数,从中找出这样的函数:使用另一个对象的次数比使用自己所在对象的次数还多。一旦发现有可能的函数,就观察被调用方,如果被调用方中有相同功能代码,直接进行替换,否则在被调用方中定义这个方法,然后修改所有原函数调用点。
  • 移动字段:如果发现对于一个字段,在其所驻A类之外的另一个B类中,以参数传入的方式被B类数量跟多的函数使用,就考虑将这个字段移动至B类,如果在A类中该字段被多个A类中函数使用,使用“移动函数”将使用到A类的方法移动到另一个类中。

3.4.12 依恋情节

类的方法应该去该去的地方:即A类中某个函数为了计算某值,从另一个B类调用了大量的取值函数,或者需要大量访问另外Class中的成员变量来计算值,这时一个运用“移动函数”把它移到自己B类中。有时候函数中只有一部分受这种依恋之苦,这时候使用Extract Method 把这部分提炼到独立函数中,再使用“移动函数”将新函数移动到B类中。一个函数往往会用到几个类的功能,那么它究竟该被置于何处呢?根据专家原则是:判断哪个类拥有最多被此函数使用的数据,然后就把这个函数和那些数据摆在一起

public class User{
    private Phone phone;
    public User(Phone phone){
        this.phone = phone;
    }
    public void getFullPhoneNumber(Phone phone){
        System.out.println("areaCode:" + phone.getAreaCode());
        System.out.println("prefix:" + phone.getPrefix());
        System.out.println("number:" + phone.getNumber());
    }
}

在这种情况下,你可以考虑将这个方法移动到它使用的那个类中。例如,要将 getFullPhoneNumber()从 User 类移动到Phone类中,因为它调用了Phone类的很多方法.

3.4.13 父类中的累赘

子类仅仅使用父类中的部分方法和属性。其他来自父类的馈赠成为了累赘。

3.4.14 循环关联

两个类之间循环引用会导致代码的复杂度增加,除非开发者想的特别清楚,否则在维护过程中来回跳转很容易懵掉,如果度过相互依赖的代码就能发现这种逻辑很隐晦,较难读懂
在这里插入图片描述

建议尽量把有关联的方法或属性抽离出来,放到公共类,以减少关联:

在这里插入图片描述
两个对象之间ClassA和ClassB之间关联关系有两种表达方式:

  1. ClassA与ClassB之间相互依赖
  2. 在ClassA和ClassB之上抽象协同类ClassC,由ClassC组织ClassA和ClassB之间行为。
    举个例子,对大脑Head和手Hand建模:
  3. 大脑对手发号握拳指令,手指接收信号后握紧拳头
  4. 手收到刺激后对大脑发出疼痛信号,大脑处理信号。
    有两种实现方式
  5. 让Head和Hand间彼此相互持有,并互相调用方法
  6. 抽象出人的概念Person,持有Head和hand对象,Head和Hand之间解耦,由Person组织行为动作,但Person随着抽象概念的增多,可能承担的职责越来越重,后续很难维护。
    这两种方式更倾向于第二种,这也和DDD的设计理念相符合,Head和Hand均可视为实体,实体是独立的个体概念,不应该有直接交互,而应该让聚合根Person持有Head和Hand并编排行为。Head和Hand之间可以通过引用关系降低降低Person的职能压力,Head有个command(Hand h)方法,由Person编排传入Hand对象。

3.4.15 方法位置

写A类过程中感觉当前逻辑有点复杂,一个函数内代码量较高,通常会将部分相关逻辑抽出一个方法,很多时候我们也没有想好新方法的功能,只是大差不差生命成私有类型放在当前类中,然后在另一个B类开发过程中用到了类似的功能,就在B类中引入A类的bean,将私有方法改成共有方法,就这样运行着,后续在维护A类的过程中发现B类中有一个需要用到的方法,则在A类中引入了B类Bean,使用B类的方法。慢慢会导致A类和B类耦合越来越严重,类的定位也变得不清晰,有可能一个类中引入很多类似的Bean,每个Bean只用其中一两个方法,整个项目类之间耦合越来越严重,慢慢无法维护。
一旦代码中我们发现了类似的问题要及时处理,通常的做法是和3.4.14类似,将公共部分抽取出来,抽取方式通常有两种:

  1. 提取工具类。例如文件读取、集合操作这种通用功能,直接抽取工具类,工具类通常设计成单例模式,放在最底层的common包给项目中所有类使用
  2. 封装业务类。将A和B两个类都需要用到某些业务逻辑,而这些业务逻辑只是会在某些项目分层中被使用,且业务语义很强,通常单独将其拆除一个新基础类被其他业务类使用,注意依赖倒置原则对函数参数进行一定封装。例如如下代码,最开始只有一个提现服务类,需要使用用户信息,为了方便,直接将读取用户余额方法封装在WithdrawService中,后续增加了交易类TradeService也需要用到用户余额方法,就在TradeService中引入WithdrawService,而且需要封装WithdrawParam类,开发者觉得麻烦,就修改accountService方法,只传入需要用到的信息,有可能涉及到五六个参数,其他地方每次使用accountBalance方法的时候都要传入很多参数,很容易导致3.4.4的问题,因此解决方法是单独抽取出一个AccountService类,封装AccountParam作为参数。

在这里插入图片描述

3.5 编排和协调

提到编排和协调更多想到的是宏观系统之间组织方式,假设现在有订单、支付、库存三个核心业务服务

  • 编排:由协调服务接收请求并对执行流程进行编排,除了上诉三个服务外还存在一个调度服务,由调度服务对请求编排,如下图
    • 好处:由调度服务协调请求,三个核心服务之间彼此不感知只需要处理好自己的事情即可,降低耦合,流程新增服务只需要修改编排服务即可。
    • 坏处:引入了协调服务增加系统复杂度,随着请求量增加会引发单点问题,后期协调服务的运维是个极其麻烦的事情。

在这里插入图片描述

  • 协调:由系统与系统之间传播请求。
    • 好处:不会引入额外复杂度,系统运维压力比较平均
    • 坏处:彼此调用会导致系统格外复杂,每个系统都需要考虑请求异常情况。

519029634)

在应用项目中,通常会遵循分层思想将代码划分成Controller -> Service -> DAO 三层,上层可以调用下层且把业务逻辑处理部分放在Service层。很多复杂业务场景中,会涉及到不同的Service,例如下单请求会调用OrderService记录订单完成后会调用PayService进行支付,那OrderServicePayService之间调用应该怎么维护呢?其实也是使用编排和协调两种调用方式。

  • 协调:不同模块之间直接相互调用,注意“模块”不仅仅表示类,可以是类、代码块、服务等等,完全由模块自身驱动调用链路,比如OrderService->PayService->GatewayService ....,由Service类内部维护复杂的调用关系
    • 好处:
      • 写代码时方便开发者无需过多思考,就如同写流水账文章一样
      • 代码读起来通俗易懂连贯性比较强
    • 坏处:
      • Service之间调用混乱,代码调用不同类之间跳来跳去。
      • 耦合严重,开发爽了维护困难
      • 内聚差,由于一个Service可能散落在很多其他Service中,所以想要修改PayService,可能会影响很多其他Service
  • 编排:两个核心模块之间不再直接依赖,而是通过引入一个协同模块,协同模块只是编排,不要做业务逻辑处理,例如这里引入BuyComponent协同类,在协同类中编排不同核心类,。
    • 好处:
      • 提高内聚性、降低耦合,这样处理过后OrderService不再需要感知PayService的处理逻辑,PayService修改只需要改动本身和相关协同类,而协同类本身不包含业务逻辑,Service接口修改影响很低。
      • 提高代码可读性,例如上一小节提出的“代码层次感”就是在编排模式下带来的收益。
      • 提高可复用性,每个模块只关注自身核心逻辑,不与其他模块耦合会提升代码复用性。
    • 坏处:
      • 引入协同模块,增加代码复杂度
      • 协同模块滥用,以协同类为例,每两个核心Service类就需要有创建一个协同类吗?这会带来组合爆炸问题,没有什么收益反而极大增大代码复杂度。
      • 协同模块边界不容易区分,理论上不应该在协同模块中有业务逻辑处理,举个例子,很多项目代码分层采用了编排思想Controller->biz->service->dao,Service处理核心逻辑,是整个项目核心,biz层负责组合不同Service实现user case,理论应该重“Service”轻“biz”,有些人写代码为了图方便写代码不过多思考,Service层只是对dao做简单封装含参数转换等简单代码,本应该下沉到Service中的代码全部放到了biz层中,这样做不但没做到代码解耦提高复用性,反而因为引入一层无作用代码导致复杂度增加。

编排和协调两种代码组织方式各有利弊,作为一个优秀开发者应该做好权衡,不要过度使用某一种方式,大家较为推崇DDD领域驱动设计其实在编排和协调之间做了比较好的权衡,app层和domain层引入聚合根、领域服务概念,在聚合根内部以及对应领域服务内部使用协调代码组织方式,将复杂业务逻辑下沉到domain层,而在biz层根据user case使用编排代码组织方式对domain层不同领域服务做编排。
借鉴DDD领域驱动设计以及自己思考说一下我对应用服务代码中有关编排和协调使用的准则,DDD思想固然好但DDD概念有些复杂且对组内代码风格统一程度要求较高,实施起来难度较高,但其聚合根思想在Controller->biz->service->dao 代码分层中很值得借鉴:根据业务自身模型设计找到聚合根,那应该怎么找聚合根呢?通常是一个领域(子包)中只暴露一个聚合根,对领域拆分尺度不是绝对的,要根据业务复杂度而定,如下图,如果在支付业务中,需要和三方有复杂交互,可以再拆出paygateway子包构造聚合根和领域服务。

关于本节提出的DDD和项目代码分层问题后面会继续讨论。

3.6 总结

如何写好代码说了这么多用两个词来总结就是“高内聚”“低耦合”,用一个词来总结就是“分类”,有些人喜欢用“抽象”这个词,但是我觉得“分类”更易懂一些,这个词不止适用于代码层面,包层面、项目层面、组织层面都是围绕这个词展开的,“分类”可以分开看,先“分”再“类”,一个复杂的项目工程,作为一个普通人来说很难或者说根本不可能一次性想清楚全貌,通常我们在脑暴阶段会列举出所有我们能够想到的小事项,这是“分”的阶段,然后领域专家会将这些细节按照某种规则,将有共性相关的功能关联起来,这是“类”的阶段,经过这样反复几次,一个领域系统就被清晰的拆解、聚合成不同的子系统。然后再对每个子系统执行“分类”操作,根据问题空间的领域模型转译成解空间的实体模型,将整个项目分类成前后端,后端功能上分成业务功能、中间件、运行时环境依赖,每个子类都有专门的团队负责,在后端业务功能实现时,将代码按包分层,参考蚂蚁项目推荐结构,通常分成Controller、biz、service、dao、common、facade几层,其中Service用于存放领域服务和领域模型,在每一层下按照功能“分类”不同的包,在每个功能包中再按功能详细“分类”,再做合理抽象组织称Class文件,在每个Class文件中会按照功能再详细“分类”成函数,一个大项目由上至下一步步“分”成具体的每一行代码,再由下至上一块块“类”起来组成整个完整项目。
“高内聚"、“低耦合”或者说“分类”是软件工程项目开发过程中的关键,一个优秀的领域转件、架构师、工程师,可以按照自己评估事务的某种规范来做问题拆解,再按照某种方式组织起来,基本上每个人都是有意无意这样做的,对大问题拆解,逐一解决,最后完成整合。既然每个人都这么做,为什么有些人设计的系统稳定,有些人设计的系统运行一段时间后就要开始做各种重构呢?以后端开发为例,这个问题可能分以下几个层面:

  1. 技术掌握情况。后端开发上手很容易,但是想要精通较困难,因为涉及到很多技术栈,从编程语言、代码规范、项目设计、中间件、运维等等,这里涉及到很多的知识点,在项目设计开发阶段需要去调研、学习各种技术栈,在项目中使用,这过程中很可能因为学习的不全面导致技术栈选型错误,引入各种缺陷需要后人填补,这样导致系统极其不稳定。通常发生在刚接触后端开发或者由于业务不同使用的技术栈和之前工作完全不同开发者身上。
  2. 项目设计。大部分能独立设计项目的工程师都能基本掌握项目需要使用的各种技术栈主要功能和特性,但在项目设计阶段,可能由于自身缺乏问题拆解能力、项目设计经验不足、业务理解不到位,导致系统模块拆分很混乱,不同系统子域边界模糊,一个功能放到A系统、B系统都能说得通,这样在项目完结之后,虽然可以正常运行,但是随着项目发展,系统边界越来越不清晰,各个模块相互调用,整体乱成一团,每次需求评审要上所有团队的人一起讨论该由哪个团队负责,甚至一个小需求改动会设计多方系统配合。
  3. 业务理解。积累一定工作经验之后,基本可以掌握大部分常用技术栈,能够独立负责问题拆解,项目设计。这个时候更重要是对于业务的理解,在系统设计时,可以清晰拆解出子域,边界清晰,同时对业务有自己的独到见解,能够立足于现有业务,洞察出未来的发展方向,虽然现有系统不支持未来引入的功能,但是会预留出扩展点,当系统发展到那一步时,可以快速扩展,不扰乱现有系统。很多书在介绍领域模型时都会提到地心说和日心说这个例子,在当时的年代,地心说可以满足地球、太阳、各个行星的运行规律,不过随着发现的行星越多,想用现有系统解释各个星球的运行规律变得越来越复杂,理解成本越来越高,这可能不符合自然界的本质规律,于是有人在地心说基础之上,加入自己对业务的理解提出日心说,可以更简单解释星体的运行轨迹,并且很容易扩展,发现新行星时,也可以很好的运行在现有模型之上。好的架构师一定是对业务有深刻的理解,并且具有一定的前瞻性,能够遇见未来一定时间内行业的发展方向,能洞察的时间线越久,说明能力越强。

4. 代码设计

4.1 评价标准

常见的评判代码好坏的词汇:

灵活性(flexibility)、可扩展性(extensibility)、可维护性(maintainability)、可
读性(readability)、可理解性(understandability)、易修改性(changeability)、
可复用(reusability)、可测试性(testability)、模块化(modularity)、高内聚低耦
合(high cohesion loose coupling)、高效(high effciency)、高性能(high
performance)、安全性(security)、兼容性(compatibility)、易用性
(usability)、整洁(clean)、清晰(clarity)、简单(simple)、直接
(straightforward)、少即是多(less code is more)、文档详尽(well-
documented)、分层清晰(well-layered)、正确性(correctness、bug free)、健
壮性(robustness)、鲁棒性(robustness)、可用性(reliability)、可伸缩性
(scalability)、稳定性(stability)、优雅(elegant)、好(good)、坏(bad)

其最常用的几个评判标准:可维护性,可读性,可扩展性,灵活性,间接性,可复用性,可测试性。

可维护性

代码易维护:不破坏原有设计、不引入新bug的情况下,能够快速修改或者添加代码。
代码的可维护性是由多因素共同作用的结果:

  1. 代码可读性好、简洁、可扩展性好就会使得代码易维护
  2. 代码分层清晰、模块化好、高内聚、低耦合,遵循基于接口编程等原则
  3. 与项目代码量、复杂程度、文档等因素相关

可读性

是否符合编码规范,命名是否达意,注释是否详尽,函数长短是否合适,模块划分是否清晰,是否符合高内聚低耦合。
很直观的评判:如果一段代码很容易被读懂,说明可读性好,如果读完代码后有很多疑问,那就说明有问题。

可扩展性

代码可扩展性指的是:在不修改或者少量修改原有代码的情况下,通过扩展的方式添加新的功能代码。
多数的行为型设计模式都是用来解决这个问题的。

灵活性

灵活性的几个场景:

  1. 添加一个新的功能代码时,原有的代码已经预留好了扩展带你,不需要修改原有代码,只需要在扩展点上加代码,说明了灵活。
  2. 抽象出很多可复用的模块、类等代码,除了说可复用之外,代码很灵活
  3. 如果使用某组接口,接口可以应对很多种场景,满足不同的需求,说明接口设计或者代码写的灵活。

简洁性

写代码遵守KISS原则:“Keep It Simple Stupid”,尽量保持代码简单,逻辑清晰。
写应用代码和做设计方案时,如果为了解决某个需求时会导致引入其他问题,为了解决新引入的问题又需要做很多额外设计,这个时候应该停一下手上工作思考一下:

  1. 当前这个需求是否是必要的,能否在产品层面做些取舍,有时产品功能做一点影响不大的退让就会给方案设计带来极大简化。
  2. 当前方案是否合理,如果为解决一个不是很复杂的问题而使用了极其复杂解决方案,这通常不合理(但不是绝对),此时需要考虑有没有更简洁的解决方案。

可复用

减少重复代码的编写,复用现有的代码。例如,继承、多态的目的之一就是提高代码可复用性

4.2 设计原则

计算机大牛通过自身实践总结出了一系列编程设计原则,对后人写代码有指导性作用。遵循设计原则写出的代码会更加符合代码的评价标准。设计原则是对优秀代码准则上层抽象,是想写好代码的“道”,常用的26种设计模式是上层抽象设计原则的实现方式,是为写好代码的“术”,“术”是千变万化的,但是万变不离其“道’,因此理解好设计原则对设计模式的灵活使用有指导性作用。

4.2.1 耦合和内聚

上学时就经常听讲课老师说,通过高内聚、低耦合这两个基础指标可以评价代码的好坏,虽然“高内聚”,“低耦合”这两个词一直都不能理解,什么样代码是“高内聚低耦合”的,如何能提高代码的“内聚性”,降低“耦合性”这个问题困扰了我很久,当然现在也没有完全搞懂,但是隐隐约约有一点理解。
在这里插入图片描述

内聚

所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护
所谓高内聚是指一个软件模块是由相关性很强的代码组成,只负责一项任务,与单一职责有类似的含义,“只负责一项”这个定义其实很模糊的,同样的需求,不同的人在不同时刻有不同的代码组织。以账户操作为例,账户余额的操作其实只有两种,扣钱和加钱,在项目设计的初期可能只涉及简单的加钱减钱操作,为了满足内聚性,按我设计,维护了AccountIncreasementAccountDeduction将余额增加操作和扣减操作分开,每个类中代码量和复杂度都不高,当时看是满足了高内聚的准则,但是随着项目发展,账户加钱和减钱场景增加了很多,比如加钱:充值,赔付到账,转账,扣钱:提现,冻结,消费,这些场景都有着不同的业务属性,如果还是按照加钱减钱来区分会导致加钱类和加钱类很重甚至到后期无法维护,因此随着业务的发展单一职责的口径发生变更,需要重构拆分出职责更“单一”的类,优秀的开发者在业务增长的过程中能嗅到这样的问题并及时重构。
内聚分类:

  1. 偶然内聚:一个模块内的各处理元素之间没有任何联系,只是偶然地被凑到一起。这种模块也称为巧合内聚,内聚程度最低。
  2. 逻辑内聚:这种模块把几种相关的功能组合在一起, 每次被调用时,由传送给模块参数来确定该模块应完成哪一种功能 。
public int nextStepDoWhat(int type) {
if (type== 1) {
    goHome();
} else if (type == 2) {
    lol();
} else {
    doHomework();
}

}
  1. 时间内聚:把需要同时执行的动作组合在一起形成的模块称为时间内聚模块。
public void init() {
    this.account.init();
    this.configration.init();
}
  1. 过程内聚:构件或者操作的组合方式是,允许在调用前面的构件或操作之后,马上调用后面的构件或操作,即使两者之间没有数据进行传递。简单的说就是如果一个模块内的处理元素是相关的,而且必须以特定次序执行则称为过程内聚。
public void process() {
    preProcess();
    doProcess();
    postProcess();
}
  1. 通信内聚:指模块内所有处理元素都在同一个数据结构上操作或所有处理功能都通过公用数据而发生关联(有时称之为信息内聚)。即指模块内各个组成部分都使用相同的数据数据或产生相同的数据结构。
public interface DataCollector{
    void collect(Data data);
}
class DataCollectorAsChain implements DataCollector{
    private List<DataCollector> chain;
    @Override
    public void collect(Data data){
        chain.foreach(collector-> collector.collect(data));
    }
}
class DataCollectorFromServerA implements DataCollector{
    @Override
    public void collect(Data data){
        // 从数据库里查到一堆数据
        data.setDataA(xxx);
    }
}
// 此外还有类似的从ServerB/ServerC的接口获取数据的几个类;
// 这些类最终都会组合到DataCollectorAsChain的chain里面去。
  1. 顺序内聚:一个模块中各个处理元素和同一个功能密切相关,而且这些处理必须顺序执行,通常前一个处理元素的输出时后一个处理元素的输入。依然以提现场景为例。
public void withdraw(WithdrawRequest request) {
//1. 查用户信息
UserInfo userInfo = userService.query(request.getUid());
// 2. 获取余额
Account account = accountService.query(userInfo);
// 3. 构造参数
WithdrawParam param = buildWithdrawParam(userInfo, account, request);
// 4. 操作提现
withdrawService.doWithdraw(userInfo, account, param);
}
  1. 功能内聚:这是最强的内聚,指模块内的所有元素共同作用完成 一个功能 ,缺一不可。顺序内聚中的例子满足功能内聚。按我的理解,功能内聚是指完成某一功能的最小模块,满足功能内聚的设计都符合单一职责,在当前代码段内只处理一件事情,给代码段定义职能非常考验开发者对业务的理解和抽象能力,尽量抽象单一职责的原子代码段,然后组装业务逻辑。
耦合

所谓松耦合是说,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。
依赖注入、接口隔离、基于接口而非实现编程,迪米特法则等手段,都是为了实现代码的松耦合。

  1. 内容耦合:当一个模块直接使用另一个模块的内部数据,或通过非正常入口转入另一 个模块内部时,这种模块之间的耦合称为内容耦合。
class Account {
    private long amount;
}
class UserInfo {
    private Account account;
    public addAccount(long amount) {
        account.amount += amount;
    }
}
  1. 指通过一个公共数据环境相互作用的那些模块间的耦合。公共数据环境可以是全局数据结构、共享的通信区、内存的公共覆盖区等
  2. 外部耦合:模块间通过软件之外的环境联结( 如 I/O 将模块耦合到特定的设备、格式、 通信协议上 )时称为外部耦合
  3. 控制耦合:模块之间传递的不是数据信息,而是控制信息例如标志、开关量等,一个模块控制了另一个模块的功能。
  4. 标记耦合:指两个模块之间传递的是数据结构。相当于 传址过程
  5. 数据耦合:指两个模块之间有调用关系,传递的是简单的数据值,相当于高级语言中的值传递。
  6. 无直接耦合:指两个模块之间没有直接的关系,它们分别从属于不同模块的控制与调 用,它们之间不传递任何信息。因此,模块间耦合性最弱,模块独立性最高。

参考:图解耦合

4.2.2 单一职责

根本思想:一个类或者模块只负责完成一个职责(或者功能)
注意:这个原则描述的对象包含两个,一个是类(class),一个是模块(module)。
不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。
评价一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准,可以说,这是件非常主观、仁者见仁智者见智的事情。实际上,在真正的软件开发中,我们也没必要过于未雨绸缪,过度设计。所以,我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构
评价类是否是单一职责有几个可参考的指标:

  1. 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
  2. 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分
  3. 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
  4. 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
  5. 类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。

类的职责并不是越单一越好:单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。

4.2.3 开闭原则

添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。

  1. 如何理解”对扩展开发,对修改关闭“

添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。

  1. 如何做到“对扩展开放,对修改关闭”

我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
开闭原则的思考:

  1. 需要修改单测表名违反了开闭原则。修改单测说明方法接口的语义发生了变化,通常意味着侵入现有逻辑了
  2. 不能无脑遵循开闭原则,也要有所考量。好的扩展性意味着代码中存在很多设计,必然会导致代码的可读性变差。针对业务逻辑不复杂,扩展性不强的场景,应该将重心放在可读性上,如果可预见业务的复杂度,为了保证系统稳定性、扩展性,需要花一些心思在设计上。

4.2.4 依赖倒置

代码层面

高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象,其核心思想是:要面向接口编程,不要面向实现编程。

  1. 每个类尽量提供接口或抽象类,或者两者都具备。
  2. 变量的声明类型尽量是接口或者是抽象类。
  3. 任何类都不应该从具体类派生。
  4. 使用继承时尽量遵循里氏替换原则。
包层面

很久一段时间我对依赖倒置的理解只停留在类之间应该“依赖抽象,不应该依赖细节”这一层面,当我读完《架构整洁之道》和《领域驱动设计》这两本书时,我非常震惊,因为这两本书提倡的模型结构不谋而合。对比一下六边形架构和洋葱模型。这两种模型的依赖方向都是由外向内,在应用项目中 领域模型/领域层 位于整个架构的最核心位置,所有的其他组件都应该依赖领域模型。为什么这样呢?按我的理解,应用系统开发者应该着重关注业务逻辑,业务逻辑才是核心代码,除非业务变动,否则不应该轻易变更。大多数人开发web项目的流程是 需求评审-》数据实体设计-》需求评审-》开发-》测试,这套本质是数据模型驱动的,使用贫血模型,业务逻辑全部封装在Service类中,如果是简单项目完全没问题,开发起来非常高效,门槛低,但是随着业务逻辑变得复杂,service类会变得越来越复杂,如果设计不当很容易违反开闭原则,引发霰弹式修改和发散式变换问题。
如果不是业务逻辑自身变更,不应该随意修改核心代码,否则很容易引入意料之外的异常,传统的软件开发很容易违反这一约定。例如,如果我们依赖三方中间件,一旦中间件某个字段变更会直接对业务核心逻辑 产生影响,因此需要想办法让三方中间件依赖业务核心代码,而不是依赖外部代码,那应该怎么办呢?使用依赖倒置原则,引入防腐层,业务核心领域模型中定义好需要的模型和接口,在防腐层中实现模型和接口,这里其实有一个软件设计的关键思想,“没有什么事增加一个中间层解决不了的问题”,防腐层作为中间层,用于连接领域模型和外部依赖。
《架构整洁之道》一整本书围绕的核心就是 依赖倒置原则,可以想而知这个原则有多重要。《领域驱动设计》一书也花了大篇幅描述模型架构的依赖关系

4.2.5 里氏替换

里式替换原则是用来指导,继承关系中子类该如何设计的一个原则。理解里式替换原则,最
核心的就是理解“design by contract,按照协议来设计”这几个字。父类定义了函数
的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数原有
的“约定”。这里的约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至
包括注释中所罗列的任何特殊说明。
理解这个原则,我们还要弄明白里式替换原则跟多态的区别。虽然从定义描述和代码实现上
来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一
大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种
设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不
改变原有程序的逻辑及不破坏原有程序的正确性。

4.2.6 接口隔离

接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。
采用接口隔离原则对接口进行约束时,要注意以下几点:

  • 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
  • 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
  • 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
  • 定义好接口后,在后续维护阶段要适度扩展,避免破坏接口原有的语义。比如原来定义了一个提现接口,将名称定义成Withdraw()
    • 适度扩展接口语义:后续开发过程中需要一个余额扣减接口,有些同学就想直接复用提现接口,通过增加一个类型字段Type区分是提现动作还是单存的余额扣减。这样非常容易引起歧义,因为已经将接口定义成Withdraw,虽然通过类型字段可以区分,但是业务在使用的过程中通过接口名很难理解这一个过程,特别是缺少相关背景的同学,极容易犯错。
    • 避免缩小接口语义:比如提现接口开始的时候使用于所有用户,只要账户里有钱,绑定三方账户就可以提现,由于某些限制这个接口只能男性用户提现,而接口命名没有修改,这样缩小了接口语义,后续用户接入非常容易错误使用。
  • 定义接口协议时一定要慎重,尤其是设计对外系统暴露的接口。和外部系统合作开发时,为了提高效率多数情况是系分完成后代码开发前先定义好外部接口给合作方使用,在深入开发时,要回头思考接口定义合理性,一旦发现哪里可以优化,比如接口语义不合理、数据结构能简化、有更优字段命名,不要怕麻烦,立即和合作者沟通修改接口,因为一旦代码上线,在后续维护阶段发现接口不合理带来的维护成本是巨大的。

运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。

4.2.7 迪米特法则

不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。

4.2.8 KISS原则

KISS:尽量保持简单。有以下几种解释方式
Keep It Simple and Stupid
Keep It Short and Simple
Keep It Simple and Straightforward
KISS原则可以保持代码可读和可维护,因为代码编写足够简单,bug比较容易被发现,修复起来也简单。
如何写出满足KISS原则的代码

  1. 不要使用同事可能不懂得技术来实现代码,比如前面例子中的正则表达式,还有一些编程语言中的高级语法。
  2. 不要重复造轮子,要善于使用已经有的工具类库。
  3. 不要过度优化,不要过度使用一些奇技淫巧(比如,位运算代替算术运算,复杂的条件语句代替if-else,使用一些过于底层的函数等)优化代码,尤其是在写业务代码过程中,为了一点点无关痛痒的性能,而牺牲代码可读性,属于本末倒置。
  4. 不要过度设计,有些团队编码规范标明尽量少用设计模式。有些同学为了扩展性,经常会硬套设计模式导致代码复杂,不易理解,而在后续项目发展过程中又不会使用到,导致扩展点没有任何用处反而让代码变得复杂,还有些开发者为了炫技,引入各种设计模式。设计模式只是写好代码的“术”,理解各个设计原则才是“道”,根本没必要套用各种设计模式,只要让代码满足这些设计原则,代码层次分明,即使不用设计模式也是好代码。
  5. kiss原则也需要根据场景权衡,比如考量可读性与代码性能,例如字符串匹配算法KMP,为了提高字符串匹配的性能,使用复杂的实现逻辑导致算法逻辑复杂,可读性差,但由于字符串匹配优先需要考虑性能,所以可以接受复杂的KMP算法,没有违反KISS原则。
  6. 团队Code review的时候如果有多个同学提出疑问,通常表明这部分逻辑违反了KISS原则,一定不要过度设计,不要觉得简单的东西就没有技术含量。实际上,越是能用简单的方法解决复杂的问题,越能体现一个人的能力

4.2.9 YAGNI原则

YAGNI原则全称:You Ain’t Gonna Need It。他的意思是不要去设计当前用不到的功能,不要编写用不到的代码,核心思想是不要做过度设计。
比如,我们的系统暂时只会用到Redis配置信息,以后可能会用到zk,那在未用到zk之前,我们没必要编写这部分代码。当然这并不是说我们不需要考虑代码的扩展性,要预留好扩展点,等到需要的时候再实现zk配置信息的代码。

4.2.10 DRY原则(Don’t Repeat Yourself

DRY原则有两个层面

  1. 代码层面:如果代码没有业务逻辑,此时出现了重复代码,毫无疑问违反了DRY原则,比如字符串处理、集合处理等,如果相同的处理代码散落在多个地方,此时要抽象出公共模块。但是如果重复代码中存在有业务逻辑,如下代码,isValidUsernameisValidPassword,虽然代码相同,但二者有不同的业务语义分别用于校验登录名和密码,因此会有不同更改代码的原因,强行将两个逻辑合成一段代码,会增加引入bug的风险
public class UserAuthenticator {
    public void authenticate(String username, String password) {
        if (!isValidUsername(username)) {
            // ...throw InvalidUsernameException...
        }
        if (!isValidPassword(password)) {
            // ...throw InvalidPasswordException...
        }
        //...省略其他代码...
    }

    private boolean isValidUsername(String username) {
        // check not null, not empty
        if (StringUtils.isBlank(username)) {
            return false;
        }
        // check length: 4~64
        int length = username.length();
        if (length < 4 || length > 64) {
            return false;
        }
        // contains only lowcase characters
        if (!StringUtils.isAllLowerCase(username)) {
            return false;
        }
        // contains only a~z,0~9,dot
        for (int i = 0; i < length; ++i) {
            char c = username.charAt(i);
            if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
                return false;
            }
        }
        return true;
    }

    private boolean isValidPassword(String password) {
        // check not null, not empty
        if (StringUtils.isBlank(password)) {
            return false;
        }
        // check length: 4~64
        int length = password.length();
        if (length < 4 || length > 64) {
            return false;
        }
        // contains only lowcase characters
        if (!StringUtils.isAllLowerCase(password)) {
            return false;
        }
        // contains only a~z,0~9,dot
        for (int i = 0; i < length; ++i) {
            char c = password.charAt(i);
            if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
                return false;
            }
        }
        return true;
    }
}
  1. 语义层面:有时候虽然没有重复的代码,但实现的是相同逻辑,此时也要注意,比如如下,两段代码都是用于校验ip有效性的,实现的效果相同,但是有两段完全不同的代码实现,此代码也是违反了DRY原则。
public boolean isValidIp(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}

public boolean checkIfIpValid(String ipAddress) {
    if (StringUtils.isBlank(ipAddress)) return false;
    String[] ipUnits = StringUtils.split(ipAddress, '.');
    if (ipUnits.length != 4) {
        return false;
    }
    for (int i = 0; i < 4; ++i) {
        int ipUnitIntValue;
        try {
            ipUnitIntValue = Integer.parseInt(ipUnits[i]);
        } catch (NumberFormatException e) {
            return false;
        }
        if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
            return false;
        }
        if (i == 0 && ipUnitIntValue == 0) {
            return false;
        }
    }
    return true;
}

4.2.11 代码复用性

满足DRY原则,并不能说明代码就满足复用性,DRY是代码复用性的必要条件。曾有一段时间我天真的以为没有重复代码自然而然就满足复用性,这是一个误区,代码复用需要更强的抽象能力,业务理解能力,如果我们在编写代码的时候,已经有复用的需求场景,那根据复用的需求去开发可复用的代码,可能还不算难。但是,如果当下并没有复用的需求,我们只是希望现在编写的代码具有可复用的特点,能在未来某个同事开发某个新功能的时候复用得上。在这种没有具体复用需求的情况下,我们就需要去预测将来代码会如何复用,这就比较有挑战了,这种情况下很容易违反YAGNI原则,过度设计一些未来不会使用的功能。通常做法是先不去设计抽象,而是当情况再次发生的时候,我们选择重构这部分逻辑。
“Rule of Three”。这条原则可以用在很多行业和场景中,我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。在之后我们开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得更加可复用
那如何提高代码可复用性?当你满足高内聚、低耦合、SOLID原则、KISS、YAGNI、DRY等原则之后,一切可能不那么难,代码更加规范,再考虑代码的复用性问题会事半功倍。
具体提高可用性的手段:

  1. 减少代码耦合:对于高度耦合的代码,当我们希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。
  2. 满足单一职责原则:我们前面讲过,如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。
  3. 分治
    1. 模块化:这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。
    2. 业务与非业务逻辑分离:越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。
    3. 通用代码下沉:从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。
  4. 继承、多态、抽象、封装

在讲面向对象特性的时候,我们讲到,利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,我们可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。

  1. 应用模板等设计模式:一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2116699.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

esp32 gpio 初始化不同类型的管脚,产生脉冲,发生中断

硬件&#xff1a;D4与D18 连接&#xff0c;二极管接D15与3.3v脚 图片 二极管同期性点亮&#xff0c;间隔1秒 参考esp32官网程序&#xff0c;从此程序可以看出&#xff0c;中断程序没有处理任何数据&#xff0c;只是把中断发生的事件存入队列。而用另一新线程来处理中断事务。…

GNSS CTS GNSS Start and Location Flow of Android15

目录 1. 本文概述2.CTS 测试3.Gnss Flow3.1 Gnss Start Flow3.2 Gnss Location Output Flow 1. 本文概述 本来是为了做Android 14 Gnss CTS 的相关环境的搭建和测试&#xff0c;然后在测试中遇到了一些问题&#xff0c;去寻找CTS源码(/cts/tests/tests/location/src/android/l…

Vue3-05_组件高级

背景 对组件的进一步了解,如组件之间通信等知识点&#xff0c;根据教程实现购物车功能&#xff0c;并修复原本的bug. watch 侦听器 用途 watch 侦听器允许开发者监视数据的变化&#xff0c;从而针对数据的变化做特定的操作。例如&#xff0c;监视用户名的变化并发起请求&am…

大模型AI一体机对行业的帮助

大模型AI一体机&#xff0c;如AntSKPro AI离线知识库一体机&#xff0c;是专门为企业和机构设计的集成系统&#xff0c;旨在提供高效的人工智能服务。这类一体机通常包含预训练的大型机器学习模型&#xff0c;以及必要的硬件和软件资源&#xff0c;以支持复杂的数据处理和分析任…

maven 编译构建可以执行的jar包

&#x1f49d;&#x1f49d;&#x1f49d;欢迎莅临我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 推荐&#xff1a;「storm…

网易云音乐歌单下载器

最近要帮小朋友下载一些小学的诗词mp3&#xff0c;找了各种工具&#xff0c;还是这个好使 yun-playlist-downloader: 网易云音乐歌单下载器 特性 支持歌单 / 专辑 / 电台音质选择下载超时 / 重试再次下载默认跳过已下载部分, 使用 content-length 匹配自定义文件名下载进度显…

【代码随想录训练营第42期 Day53打卡 - 图论Part4 - 卡码网 110. 字符串接龙 105. 有向图的完全可达性

目录 一、个人感受 二、题目与题解 题目一&#xff1a;卡码网 110. 字符串接龙 题目链接 题解&#xff1a;BFS哈希 题目二&#xff1a;卡码网 105. 有向图的完全可达性 题目链接 题解&#xff1a;DFS 三、小结 一、个人感受 对于两大基本搜索&#xff1a; 深度优先搜…

JDBC:连接数据库

文章目录 报错 报错 Exception in thread “main” java.sql.SQLException: Can not issue SELECT via executeUpdate(). 最后这里输出的还是地址&#xff0c;就是要重写toString()方法&#xff0c;但是我现在还不知道怎么写 修改完的代码&#xff0c;但是数据库显示&#…

WebGL系列教程二(环境搭建及初始化Shader)

目录 1 前言2 新建html页面3 着色器介绍3.1 顶点着色器、片元着色器与光栅化的概念3.2 声明顶点着色器3.3 声明片元着色器 4 坐标系(右手系)介绍5 着色器初始化5.1 给一个画布canvas5.2 获取WebGL对象5.3 创建着色器对象5.4 获取着色器对象的源5.5 绑定着色器的源5.6 编译着色器…

MiniGPT-3D, 首个高效的3D点云大语言模型,仅需一张RTX3090显卡,训练一天时间,已开源

项目主页&#xff1a;https://tangyuan96.github.io/minigpt_3d_project_page/ 代码&#xff1a;https://github.com/TangYuan96/MiniGPT-3D 论文&#xff1a;https://arxiv.org/pdf/2405.01413 MiniGPT-3D在多个任务上取得了SoTA&#xff0c;被ACM MM2024接收&#xff0c;只拥…

佰朔资本:9月首选行业为汽车、电子、医药生物等

5—8月商场接连调整&#xff0c;9月开端进入成绩空窗期&#xff0c;流动性和政策改动从头成为商场中心驱动力&#xff0c;风格切换先行&#xff0c;对当时的商场能够豁达一些。价值和生长风格切换的拐点开始闪现&#xff0c;生长相对价值的成绩优势开端走扩&#xff0c;美联储降…

Axure中继器介绍

中继器我们一般在处理重复性比较高的任务时&#xff0c;能让我们达到事半功倍的效果&#xff0c;中继器在整个axure中属于复杂程度比较高的功能&#xff0c;我们今天大致讲一下常用的方法即可。 一、声明一个中继器 默认展示为三行。 点击样式&#xff0c;这里我们可以添加删…

【原创】java+springboot+mysql校园二手商品交易网设计与实现

个人主页&#xff1a;程序猿小小杨 个人简介&#xff1a;从事开发多年&#xff0c;Java、Php、Python、前端开发均有涉猎 博客内容&#xff1a;Java项目实战、项目演示、技术分享 文末有作者名片&#xff0c;希望和大家一起共同进步&#xff0c;你只管努力&#xff0c;剩下的交…

RestTemplateRibbonOpenFeign

网络模型 OSI七层模型 RestTemplate Ribbon 在微服务中的ribbon 实现负载均衡服务间调用的三种方式 ribbon其他负载均衡策略 OpenFeign 实战

【Shiro】Shiro 的学习教程(五)之 SpringBoot 集成 Shiro + JWT

与 Spring 集成&#xff1a;与 Spring 集成 与 SpringBoot 集成&#xff1a;与 SpringBoot 集成 1、SpringBoot Shiro Jwt ①&#xff1a;引入 pom.xml&#xff1a; <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-…

使用kubeadm手动安装K8s

本次教程安装主要基于Ubuntu 22.04&#xff0c; 使用AWS EC2服务器来部署。当然&#xff0c;AWS也有自己的AWS K8s服务&#xff0c;不过需要花费小钱钱。虽然也不是说不行&#xff0c;但手动安装下也能熟悉K8s。 1. 安装Docker 卸载旧版本&#xff1a; sudo apt-get re…

数据结构与算法 第12天(排序)

一、排序方法分类 按照数据存储介质&#xff1a; 内部排序&#xff1a;数据量不大、数据在内存&#xff0c;无需内外存交换数据 外部排序&#xff1a;数据量较大、数据在外存(文件排序) 将数据分批调入内存排序&#xff0c;结果放到外存 按照比较器个数&#xff1a; 串行…

微带结环行器仿真分析+HFSS工程文件

微带结环行器仿真分析HFSS工程文件 工程下载&#xff1a;微带结环行器仿真分析HFSS工程文件 我使用HFSS版本的是HFSS 2024 R2 参考书籍《微波铁氧体器件HFSS设计原理》和视频微带结环行器HFSS仿真 1、环形器简介 环行器是一个有单向传输特性的三端口器件&#xff0c;它表明…

大数据之Flink(六)

17、Flink CEP 17.1、概念 17.1.1、CEP CEP是“复杂事件处理&#xff08;Complex Event Processing&#xff09;”的缩写&#xff1b;而 Flink CEP&#xff0c;就是 Flink 实现的一个用于复杂事件处理的库&#xff08;library&#xff09;。 总结起来&#xff0c;复杂事件处…

IM项目运行说明

注册登录以及消息列表界面&#xff1a; 联系人界面&#xff1a;新的好友/群聊列表/好友列表 界面&#xff1a; 群聊界面&#xff1a;群聊不想支持发视频&#xff0c;因为非技术上的麻烦原因。。。 图片可以下载&#xff1a; 私聊可以发视频&#xff1a; 私聊支持服务器消…