Dunwu Blog

大道至简,知易行难

UML 行为建模图

行为图用来记录在一个模型内部,随时间的变化,模型执行的交互变化和瞬间的状态;并跟踪系统在真实环境下如何表现,以及观察系统对一个操作或事件的反应,以及它的结果。

关键词:活动图, 状态机图, 用例图, 通信图, 交互概述图, 时序图, 时间图

活动图

UML 中,活动图用来展示活动的顺序。显示了从起始点到终点的工作流,描述了活动图中存在于事件进程的判断路径。活动图可以用来详细阐述某些活动执行中发生并行处理的情况。活动图对业务建模也比较有用,用来详细描述发生在业务活动中的过程。
一个活动图的示例如下所示。

下面描述组成活动图的元素。

活动

活动是行为参数化顺序的规范。活动被表示为圆角矩形,内含全部的动作,工作流和其他组成活动的元素。

动作

一个动作代表活动中的一个步骤。动作用圆角矩形表示。

动作约束

动作可以附带约束,下图显示了一个带前置条件和后置条件的动作。

控制流

控制流显示一个动作到下一个动作的流。表示为带箭头实线

初始节点

一个开始或起始点用大黑圆点表示,如下图。

结束节点

结束节点有两种类型:活动结束节点和流结束节点。活动结束节点表示为中心带黑点的圆环。

流结束节点表示为内部为叉号的圆环。

这两种不同类型节点的区别为:流结束节点表明单独的控制流的终点。活动结束终点是活动图内所有控制流的结束。

对象和对象流

对象流是对象和数据转递的通道。对象显示为矩形。

对象流显示为带箭头的连接器,表明方向和通过的对象。

一个对象流在它的至少一个终端有一个对象。在上图中,可以采用带输入输出引脚的速记标柱表示。

数据存储显示为带 «datastore» 关键字的对象。

判断节点和合并节点

判断节点和合并节点是相同标注:菱形。它们可以被命名。从判断节点出来的控制流有监护条件,当监护条件满足时,可以对流控制。下图显示了判断节点和合并节点的使用。

分叉和结合节点

分叉和结合节点有同样的标柱:垂直或水平条(方向取决于工作流从左到右,还是从上到下)。它们说明了控制的并发线程的起始和终点,下图显示他们的使用示例。

结合节点与合并节点不同之处在于:结合节点同步两个输入量,产生一个单独的输出量。来自结合节点的输出量要接收到所有的输入量后才能执行。合并节点直接将控制流传递通过。如果两个或更多的输入量到达合并节点。则它的输出流指定的动作会被执行两次或更多次。

扩展域

扩展域是会执行多次的结构活动域。输入输出扩展节点表示为一组“3 厢” ,代表多个选择项。关键词 “iterative”, “parallel” 或 “stream”显示在区域的左上角

异常处理器

异常处理器在活动图中可以建模。

可中断活动区

可中断活动区环绕一组可以中断的动作。在下面非常简单的例子中: 当控制被传递到结束订单 “Close Order” 动作,定单处理”Process Order” 动作会执行直到完成,除非”Cancel Request”取消请求中断被接受,这会将控制传递给”Cancel Order”动作。

分割

一个活动分割显示为垂直或水平泳道。在下图中,分割被用来在活动图中分隔动作,有在 “accounting department”中执行的,有在 “customer”中执行的。

状态机图

状态机图(state-machine-diagram)对一个单独对象的行为建模,指明对象在它的整个生命周期里,响应不同事件时,执行相关事件的顺序。

如下示例, 下列的状态机图显示了门在它的整个生命周期里如何运作。

门可以处于以下的三种状态之一: “Opened”打开状态, “Closed”关闭状态,或者”Locked”锁定状态。 它分别响应事件:“Open”开门, “Close”关门, “Lock”锁门 和 “Unlock”解锁。 注意:不是所有的事件,在所有的状态下都是有效的。如:一个门打开的时候是不可能锁定的,除非你关上门。并且,状态转移可能有附加监护条件:假设门是开的,如果“doorWay->isEmpty”(门是空的)被满足,那么它只能响应关门事件。状态机图使用的语法和约定将在下面的部分进行讨论。

状态

状态被表示为圆角矩形,状态名写在里面。

起始和结束状态

初始状态表示为实心黑圆环,可以标注名称。结束状态表示为中心带黑点圆环,也可以被标注名称。

转移

一个状态到下一个状态的转移表示为带箭头实线。转移可以有一个“Trigger”触发器,一个“Guard”监护条件和一个“effect”效果。如下所示:

“Trigger”触发器是转移的起因,它可以是某个条件下的一个信号,一个事件,一个变化或一个时间通路。”Guard”监护是一个条件,而且必须为真,以便于让触发器引起转移。效果”Effect”是直接作用到对象上的一个动作,该对象具有做为转移结果的状态机。

状态活动

在上面的状态转移示例中,一个效果与该转移相关联。如果目标状态有多个转移到达,并且每一个转移都有相同的效果与它相关联,那最好将该效果与目标状态相关联,而不与转移相关联。你可以通过为这个状态定义初始动作来实现。下图显示了一个带入口动作和出口动作的状态。

可以定义发生在事件上的动作或一直发生的动作。每一种类型的动作是可以定义任意数量的。

自转移

一个状态可能有一个返回到自身的转移,如下图。效果与转移关联是十分有帮助。

复合状态

一个状态机图可以有子状态机图,如下图所示:

可选择不同方式显示相同信息,如下图所示:

上面版本的标注说明”Check PIN”的子状态机图显示在单独的图中。

入口点

有时,你不想在正常的初始状态进入子状态机。例如下面的子状态机,它通常从”初始化”状态开始,但是如果因为某些原因,它不必执行初始化,可能靠转移到指定的入口点来从 “Ready” 状态开始。

下图显示了状态机的上一层。

出口点

有与入口点相类似的方式,它可能也指定可选择的出口点。下图给出了主处理状态执行后,所执行状态的去向将取决于该状态转移时所使用的路径。

选择伪状态

选择伪状态显示为菱形,有一个转移输入,两个或多个输出。下图显示不管到达哪一个状态,经过选择伪状态后的去向,取决于在伪状态中执行时所选择的消息格式。

连接伪状态

连接伪状态用来将多个状态转移链接在一起。一个单独的连接伪状态可以有一个或多个输入和一个或多个输出,监护可能应用于每一个转移,连接是没有语义的。连接可以把一个输入转移分成多个输出转移来实现一个静态分支。与之对照的是选择伪状态实现一个动态条件分支。

终止伪状态

进入终止伪状态是指状态机生命线已经终止。终止伪状态表示为叉号。

历史状态

历史状态用来当状态机中断时,恢复状态机之前状态。下面例图说明了历史状态的使用。这个例子是关于洗衣机的状态机。

在这个状态机中,当洗衣机运行时,它会按照”Washing” 到 Rinsing”再到”Spinning”来进行。如果电源被切断 ,洗衣机会停止运行并进入”Power Off” 状态。当电源恢复,运行状态在”History State”符号处进入,表示它会从上次离开的地方恢复。

并发区

一个状态可以被分成几个不同的区,包含同时存在和执行的子状态。下面的例子显示状态 “Applying Brakes”, “front brake”和”rear brakes” 将同时独立运作。注意使用了分叉和结合伪状态而不是选择和合并伪状态。这些符号用来同步并发的线程。

用例图

用例图用来记录系统的需求,它提供系统与用户及其他参与者的一种通信手段。

执行者

用例图显示了系统和系统外实体之间的交互。这些实体被引用为执行者。执行者代表角色,可以包括:用户,外部硬件和其他系统。执行者往往被画成简笔画小人。也可以用带«actor»关键字的类矩形表示。

在下图中,执行者可以详细的泛化其他执行者:

用例

用例是有意义的单独工作单元。它向系统外部的人或事提供一个易于观察的高层次行为视图。 用例的标注符号是一个椭圆。

使用用例的符号是带可选择箭头的连接线,箭头显示控制的方向。下图说明执行者 “Customer”使用 “Withdraw”用例。

用途连接器(uses connector)可以有选择性的在每一个端点有多重性值,如下图,显示客户一次可能只执行一次取款交易。但是银行可以同时执行许多取款交易。

用例定义

一个典型的用例包括:

  • 名称和描述 - 用例通常用一个动词词组定义,而且有一个简短的文字说明。
  • 需求 - 需求定义了一个用例必须提供给终端用户的正式功能性需求。它们符合构造方法建立的功能性规范。一个需求是用例将执行一个动作或提供多个值给系统的约定或承诺。
  • 约束 - 一个约束是一个用例运行的条件或限制。它包括:前置条件,后置条件和不变化条件 。前置条件指明了用例在发生之前需要符合的条件。后置条件用来说明在用例执行之后一些条件必须为”真”。不变化条件说明用例整个执行过程中该条件始终为”真”。
  • 情形 - 情形是用例的实例在执行过程中,事件发生流程的形式描述。它定义了系统和外部执行者之间的事件指定顺序。通常用文本方式来表示,并对应时序图中的文字描述。
  • 情形图
  • 附加信息

包含用例

用例可能包含其他用例的功能来作为它正常处理的一部分。通常它假设,任何被包含的用例在基本程序运行时每一次都会被调用。下面例子:用例“卡的确认”<Card Identification> 在运行时,被用例“取钱”<Withdraw>当作一个子部分。

用例可以被一个或多个用例包含。通过提炼通用的行为,将它变成可以多次重复使用的用例。有助于降低功能重复级别。

扩展用例

一个用例可以被用来扩展另一个用例的行为,通常使用在特别情况下。例如:假设在修改一个特别类型的客户订单之前,用户必须得到某种更高级别的许可,然后“获得许可”<Get Approval>用例将有选择的扩展常规的“修改订单”<Modify Order>用例。

扩展点 - 扩展用例的加入点被定义为扩展点。

系统边界 - 它用来显示用例在系统内部,执行者在系统的外部。

通信图

通信图,以前称之为协作图,是一种交互图,所显示消息与时序图相似,但是它更侧重于对象间的联系

在通信图中,对象之间显示关联连接器。消息附加到这些关联上,显示短箭头指向消息流的方向。消息的顺序通过编号码显示。

下面的两个图用通信图和时序图分别显示相同的信息。尽管我们可能从通信图的编号码得到消息顺序,但它不是立即可见的。通信图十分清楚的显示了邻近对象间全部完整的消息传递。

交互概述图

一个交互概览图是活动图的一种形式,它的节点代表交互图。交互图包含时序图,通信图,交互概览图和时间图。 大多数交互概览图标注与活动图一样。例如:起始,结束,判断,合并,分叉和结合节点是完全相同。并且,交互概览图介绍了两种新的元素:交互发生和交互元素。

交互发生

交互发生引用现有的交互图。显示为一个引用框,左上角显示 “ref” 。被引用的图名显示在框的中央。

交互元素

交互元素与交互发生相似之处在于都是在一个矩形框中显示一个现有的交互图。不同之处在内部显示参考图的内容不同。

将它们放在一起

所有的活动图控件,都可以相同地被使用于交互概览图,如:分叉,结合,合并等等。它把控制逻辑放入较低一级的图中。下面的例子就说明了一个典型的销售过程。子过程是从交互发生抽象而来。

时序图

时序图是交互图的一种形式,它显示对象沿生命线发展,对象之间随时间的交互表示为从源生命线指向目标生命线的消息。时序图能很好地显示那些对象与其它那些对象通信,什么消息触发了这些通信,时序图不能很好显示复杂过程的逻辑。

生命线

一条生命线在时序图中代表一个独立的参与者。表示为包含对象名的矩形,如果它的名字是”self”,则说明该生命线代表控制带时序图的类元。

有时,时序图会包含一个顶端是执行者的生命线。这情况说明掌握这个时序图的是用例。健壮图中的边界,控制和实体元素也可以有生命线。

消息

消息显示为箭头。消息可以完成传输,也可能丢失和找回,它可以是同步的,也可以是异步的,即可以是调用,也可以是信号。在下图中,第一条消息是同步消息(标为实箭头)完成传输,并隐含一条返回消息。第二条消息是异步消息 (标为实线箭头),第三条是异步返回消息(标为虚线)。

执行发生

向下延伸的细条状矩形表示执行事件或控制焦点的激活。在上图中有三个执行事件。第一个是源对象发送两条消息和收到两条回复。第二个是目标对象收到一条同步消息并返回一条回复。第三个是目标对象收到一条异步消息并返回一条回复。

内部通信

内部消息表现为一个操作的递归调用,或一个方法调用属于同一个对象的其他方法。显示为生命线上执行事件的嵌套控制焦点。

迷路消息和拾取消息

迷路消息是那些发送了却没有到达指定接收者,或者到达的接收者不再当前图中。拾取消息是收到来自那些未知的发送者,或者来自没有显示在当前图的发送者的消息。它们都表明是去往或来自一个终点元素。

生命线开始与结束

生命线可以在时序图时间刻度范围内创建和销毁,在下面的例子中,生命线被停止符号(叉号)终止。在前面的例子中,生命线顶端的符号(Child)显示在比创建它的对象符号(parent)沿页面要低的位置上。下图显示创建和终止对象。

时间和期限约束

消息默认显示为水平线。因为生命线显示为沿屏幕向下的时间通道,所以当给实时系统建模,或是有时间约束的业务过程建模,考虑执行动作所需时间长度是很重要的。因此可以给消息设置一个期限约束,这样的消息显示为下斜线。

复合片段

如前面所说,时序图不适合表达复杂的过程逻辑。在一种情况下,有许多机制允许把一定程度的过程逻辑加入到图中,并把它们放到复合片段的标题下。复合片段是一个或多个处理顺序被包含在一个框架中,并在指定名称的环境下执行。片段可以是:

  • 选择性片段 (显示 “alt”) 为 if…then…else 结构建模。
  • 选项片段 (显示 “opt”) 为 “switch”(开关) 结构建模。
  • 中断片段对被处理事件的可选择顺序建模,而不是该图的其他部分。
  • 并行片段(显示 “par”) 为并发处理建模。
  • 弱顺序片段 (显示 “seq”) 包含了一组消息,这组消息必须在后继片段开始之前被处理。但不会把片段内消息的先后顺序强加到不共享同一条生命线的消息上。
  • 严格顺序片段 (显示 “strict”) 包含了一系列需要按照给定顺序处理的消息。
  • 非片段 (显示 “neg”) 包含了一系列不可用的消息。
  • 关键片段 具有关键部分。
  • 忽略片段 声明一个没有意义的消息,如果它出现在当前上下文中。
  • 考虑片段与忽略片段相反,不包含在考虑片段内的消息都应该被忽略。
  • 断言片段 (显示 “assert”)标明任何没有显示为声明操作数的顺序都是无效的。
  • 循环片段 包含一系列被重复的消息。

下图显示的是循环片段:

这也是一个类似于复合片段的交互发生。 交互发生被其他图参考,显示为左上角带”ref”,将被参考图名显示在方框的中间。

门是连接片段内消息和片段外消息的连接点。 在 EA 中,门显示为片段框架上的小正方形。作用为时序图与页面外的连接器。 用来表示进来的消息源,或者出去消息的终点。下面两个图显示它们在实践中的使用。注意:” top level diagram”中的门用消息箭头指向参考片段,在这里没有必要把它画成方块。

部分分解

一个对象可以引出多条生命线,使得对象内部和对象之间的消息显示在同一图上。

状态常量/延续

状态常量是生命线的约束,运行时始终为”真”。显示为两侧半圆的矩形,如下图:

延续虽与状态常量有同样的标注,但是被用于复合片段,并可以延伸跨越多条生命线。

时间图

UML 时间图被用来显示随时间变化,一个或多个元素的值或状态的更改。也显示时控事件之间的交互和管理它们的时间和期限约束。

状态生命线

状态生命线显示随时间变化,一个单项状态的改变。不论时间单位如何选择,X 轴显示经过的时间,Y 轴被标为给出状态的列表。状态生命线如下所示:

值生命线

值生命线显示随时间变化,一个单项的值的变化。X 轴显示经过的时间,时间单位为任意,和状态生命线一样。平行线之间显示值,每次值变化,平行线交叉。如下图所示。

将它们放在一起

状态和值的生命线能叠加组合。它们必须有相同的 X 轴。 消息可以从一个生命线传递到另一个。每一个状态和值的变换能有一个定义的事件,一个时间限制是指一个事件何时必须发生,和一个期限限制说明状态或值多长时间必须有效。一旦这些已经被应用,其时间图可能显示如下。

参考资料

第一次读《重构:改善既有代码的设计》时,我曾整理过一个简单的笔记。最近,因为参与一个重构项目,再一次温习了《重构:改善既有代码的设计》。过程中,萌发了认真总结、整理重构方法的冲动,于是有了这系列文字。

症与药

对代码的坏味道的思考

“有病要早治,不要放弃治疗”。多么朴素的道理 ,人人都懂。

病,就是不健康。

人有病,可以通过打针、吃药、做手术来进行治疗。

如果把代码的坏味道(代码质量问题)比作病症,那么重构就是治疗代码的坏味道的药。

个人认为,在重构这件事上,也可以应用治病的道理:

  • 防患于未然。
    —— 春秋战国时期的一代名医扁鹊,曾经有个很著名的医学主张:防患于未然。 我觉得这个道理应用于软件代码的重构亦然。编程前要有合理的设计、编程时要有良好的编程风格,尽量减少问题。从这个层面上说,了解代码的坏味道,不仅仅是为了发现问题、解决问题。更重要的作用是:指导我们在编程过程中有意识的去规避这些问题。

  • 小病不医,易得大病。
    —— 刘备说过:“勿以善小而不为,勿以恶小而为之”。发现问题就及时修改,代码质量自然容易进入良性循环;反之,亦然。要重视积累的力量,别总以为代码出现点小问题,那都不是事儿。

  • 对症下药。
    —— 程序出现了问题,要分析出问题的根本,有针对性的制定合理的重构方案。大家都知道吃错药的后果,同样的,瞎改还不如不改

  • 忌猛药
    —— 医病用猛药容易产生副作用。换一句俗语:步子大了容易扯着蛋。重构如果大刀阔斧的干,那你就要有随时可能扑街的心理准备。推倒重来不是重构,而是重写。重构应该是循序渐进,步步为营的过程。当你发现重写代码比重构代码更简单,往往说明你早就该重构了。

重构的原则

前面把代码质量问题比作病症,而把重构比作药。这里,我们再进一步讨论一下重构的原则。

何谓重构(What)

重构(Refactoring) 的常见定义是:不改变软件系统外部行为的前提下,改善它的内部结构。

个人觉得这个定义有点生涩。不妨理解为:重构是给代码治病的行为。而代码有病是指代码的质量(可靠性、安全性、可复用性、可维护性)和性能有问题。

重构的目的是为了提高代码的质量和性能

注:功能不全或者不正确,那是残疾代码。就像治病治不了残疾,重构也解决不了功能问题。

为何重构(Why)

翻翻书,上网搜一下,谈到重构的理由大体相同:

  • 重构改进软件设计
  • 重构使软件更容易理解
  • 重构帮助找到 bug
  • 重构提高编程速度

总之就是,重构可以提高代码质量

何时重构(When)

关于何时重构,我先引用一下 重构并非难在如何做,而是难在何时开始做 一文的观点。

对于一个高速发展的公司来说,停止业务开发,专门来做重构项目,从来就不是一个可接受的选项,“边开飞机边换引擎”才是这种公司想要的。

我们不妨来衡量一下重构的成本和收益。

  • 重构的成本

    重构是有成本的,费时费力(时间、人力)不说,还有可能会使本来正常运行的程序出错。所以,很多人都抱着“不求有功,但求无过”的心理得过且过。

    还有一种成本:重构使用较新且较为复杂的技术,学习曲线不平滑,团队成员技术切换困难,短期内开发效率可能不升反降。

    但是,如果一直放任代码腐朽下去,技术债务会越来越沉重。当代码最终快要跑不动时,架构师们往往还是不得不使用激进的手段来治疗代码的顽疾。但是,这个过程通常都是非常痛苦的,而且有着很高的失败风险。

  • 重构的收益

    重构的收益是提高代码的质量和性能,并提高未来的开发效率。但是,应当看到,重构往往并不能在短期内带来实际的效益,或者很难直观看出效益。而对于一个企业来说,没有什么比效益更重要。换句话说,没有实际效益的事,通常也没有价值。很多领导,尤其是非技术方向的领导,并不关心你应用了什么新技术,让代码变得多么优雅等等。

  • 重构的合适时机

    从以上来看,重构实在是个吃力不讨好的事情。

    于是,很多人屈服于万恶的 KPI 和要命的 deadline,一边吐槽着以前的代码是垃圾,一边自己也在造垃圾。

    但是,重构本应该是个渐进式的过程,不是只有伤筋动骨的改造才叫重构。如果非要等到代码已经烂到病入膏肓,再使用激进方式来重构,那必然是困难重重,风险极高。

    《重构》书中提到的重构时机应该在添加功能、修复功能、审查代码时,不建议专门抽出时间专门做重构项目。

    我认为,其思想就是指:重构应该是在开发过程中实时的、渐进的演化过程。

  • 重构的不恰当时机

    但是,这里我也要强调一下:不是所有软件开发过程都一定要重构。

    较能凸显重构价值的场景是:代码规模较大、生命周期还较长、承担了较多责任、有一个较大(且较不稳定,人员流动频繁)团队在其上工作的单一代码库。

    与之相反,有一些场景的重构价值就很小:

    • 代码库生命周期快要走到尾声,开发逐渐减少,以维护为主。
    • 代码库当前版本马上要发布了,这时重构无疑是给自己找麻烦。
    • 重构代价过于沉重:重构后功能的正确性、稳定性难以保障;技术过于超前,团队成员技术迁移难度太大。

如何重构(How)

重构行为在我看来,也是可以分层级的。由高到低,越高层级难度越大:

  • 系统架构、集群架构、框架、服务、数据库:这个层面的重构属于战略级重构。现代软件往往业务复杂、庞大。使用微服务、数据迁移来拆分业务,降低业务复杂度成为了主流。但是,这些技术的测试、部署复杂,技术难度很高。
  • 组件、模块、接口:这个层面的重构属于战术级重构。组件、模块、框架的重构,主要是针对代码的设计问题。解决的是代码的整体结构问题。需要对框架、设计模式、分布式、并发等等有足够的了解。
  • 类、接口、函数、字段等:这个层面的重构属于战法级重构。《重构》一书提到了 代码的坏味道 以及相关的重构方法。这些都是对类、接口、函数、字段级别代码的重构手段。由于这一级别的重构方法较为简单,所以可操作性较强。具体细节可以阅读《代码的坏味道》篇章。

前两种层级的重构已经涉及到架构层面,影响较大,难度较高,如果功力不够不要轻易变动。由于这两个层级涉及领域较广,这里不做论述。

此处为分割线。下面是代码的坏味道系列。。。

代码的坏味道

《重构:改善既有代码的设计》中介绍了 22 种代码的坏味道以及重构手法。这些坏味道可以进一步归类。我总觉得将事物分类有助于理解和记忆。所以本系列将坏味道按照特性分类,然后逐一讲解。

img

代码坏味道之代码臃肿

代码臃肿(Bloated)这组坏味道意味着:代码中的类、函数、字段没有经过合理的组织,只是简单的堆砌起来。这一类型的问题通常在代码的初期并不明显,但是随着代码规模的增长而逐渐积累(特别是当没有人努力去根除它们时)。

代码坏味道之滥用面向对象

滥用面向对象(Object-Orientation Abusers)这组坏味道意味着:代码部分或完全地违背了面向对象编程原则。

代码坏味道之变革的障碍

变革的障碍(Change Preventers)这组坏味道意味着:当你需要改变一处代码时,却发现不得不改变其他的地方。这使得程序开发变得复杂、代价高昂。

代码坏味道之非必要的

非必要的(Dispensables)这组坏味道意味着:这样的代码可有可无,它的存在反而影响整体代码的整洁和可读性。

代码坏味道之耦合

耦合(Couplers)这组坏味道意味着:不同类之间过度耦合。

扩展阅读

参考资料

翻译自:https://sourcemaking.com/refactoring/smells/oo-abusers

滥用面向对象(Object-Orientation Abusers)这组坏味道意味着:代码部分或完全地违背了面向对象编程原则。

Switch 声明

Switch 声明(Switch Statements)

你有一个复杂的 switch 语句或 if 序列语句。

img

问题原因

面向对象程序的一个最明显特征就是:少用 switchcase 语句。从本质上说,switch 语句的问题在于重复(if 序列也同样如此)。你常会发现 switch 语句散布于不同地点。如果要为它添加一个新的 case 子句,就必须找到所有 switch 语句并修改它们。面向对象中的多态概念可为此带来优雅的解决办法。

大多数时候,一看到 switch 语句,就应该考虑以多态来替换它。

解决方法

  • 问题是多态该出现在哪?switch 语句常常根据类型码进行选择,你要的是“与该类型码相关的函数或类”,所以应该运用 提炼函数(Extract Method)switch 语句提炼到一个独立函数中,再以 搬移函数(Move Method) 将它搬移到需要多态性的那个类里。
  • 如果你的 switch 是基于类型码来识别分支,这时可以运用 以子类取代类型码(Replace Type Code with Subclass)以状态/策略模式取代类型码(Replace Type Code with State/Strategy)
  • 一旦完成这样的继承结构后,就可以运用 以多态取代条件表达式(Replace Conditional with Polymorphism) 了。
  • 如果条件分支并不多并且它们使用不同参数调用相同的函数,多态就没必要了。在这种情况下,你可以运用 以明确函数取代参数(Replace Parameter with Explicit Methods)
  • 如果你的选择条件之一是 null,可以运用 引入 Null 对象(Introduce Null Object)

收益

  • 提升代码组织性。

img

何时忽略

  • 如果一个 switch 操作只是执行简单的行为,就没有重构的必要了。
  • switch 常被工厂设计模式族(工厂方法模式(Factory Method)抽象工厂模式(Abstract Factory))所使用,这种情况下也没必要重构。

重构方法说明

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

以子类取代类型码(Replace Type Code with Subclass)

问题

你有一个不可变的类型码,它会影响类的行为。

img

解决

以子类取代这个类型码。

img

以状态/策略模式取代类型码(Replace Type Code with State/Strategy)

问题

你有一个类型码,它会影响类的行为,但你无法通过继承消除它。

img

解决

以状态对象取代类型码。

img

以多态取代条件表达式(Replace Conditional with Polymorphism)

问题

你手上有个条件表达式,它根据对象类型的不同而选择不同的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Bird {
//...
double getSpeed() {
switch (type) {
case EUROPEAN:
return getBaseSpeed();
case AFRICAN:
return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
case NORWEGIAN_BLUE:
return (isNailed) ? 0 : getBaseSpeed(voltage);
}
throw new RuntimeException("Should be unreachable");
}
}

解决

将这个条件表达式的每个分支放进一个子类内的覆写函数中,然后将原始函数声明为抽象函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
abstract class Bird {
//...
abstract double getSpeed();
}

class European extends Bird {
double getSpeed() {
return getBaseSpeed();
}
}
class African extends Bird {
double getSpeed() {
return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
}
}
class NorwegianBlue extends Bird {
double getSpeed() {
return (isNailed) ? 0 : getBaseSpeed(voltage);
}
}

// Somewhere in client code
speed = bird.getSpeed();

以明确函数取代参数(Replace Parameter with Explicit Methods)

问题

你有一个函数,其中完全取决于参数值而采取不同的行为。

1
2
3
4
5
6
7
8
9
10
11
void setValue(String name, int value) {
if (name.equals("height")) {
height = value;
return;
}
if (name.equals("width")) {
width = value;
return;
}
Assert.shouldNeverReachHere();
}

解决

针对该参数的每一个可能值,建立一个独立函数。

1
2
3
4
5
6
void setHeight(int arg) {
height = arg;
}
void setWidth(int arg) {
width = arg;
}

引入 Null 对象(Introduce Null Object)

问题

你需要再三检查某对象是否为 null。

1
2
3
4
5
6
if (customer == null) {
plan = BillingPlan.basic();
}
else {
plan = customer.getPlan();
}

解决

将 null 值替换为 null 对象。

1
2
3
4
5
6
7
8
9
10
11
12
class NullCustomer extends Customer {
Plan getPlan() {
return new NullPlan();
}
// Some other NULL functionality.
}

// Replace null values with Null-object.
customer = (order.customer != null) ? order.customer : new NullCustomer();

// Use Null-object as if it's normal subclass.
plan = customer.getPlan();

临时字段

临时字段(Temporary Field)的值只在特定环境下有意义,离开这个环境,它们就什么也不是了。

img

问题原因

有时你会看到这样的对象:其内某个实例变量仅为某种特定情况而设。这样的代码让人不易理解,因为你通常认为对象在所有时候都需要它的所有变量。在变量未被使用的情况下猜测当初设置目的,会让你发疯。
通常,临时字段是在某一算法需要大量输入时而创建。因此,为了避免函数有过多参数,程序员决定在类中创建这些数据的临时字段。这些临时字段仅仅在算法中使用,其他时候却毫无用处。
这种代码不好理解。你期望查看对象字段的数据,但是出于某种原因,它们总是为空。

解决方法

  • 可以通过 提炼类(Extract Class) 将临时字段和操作它们的所有代码提炼到一个单独的类中。此外,你可以运用 以函数对象取代函数(Replace Method with Method Object) 来实现同样的目的。
  • 引入 Null 对象(Introduce Null Object) 在“变量不合法”的情况下创建一个 null 对象,从而避免写出条件表达式。

img

收益

  • 更好的代码清晰度和组织性。

img

重构方法说明

提炼类(Extract Class)

问题

某个类做了不止一件事。

img

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

img

以函数对象取代函数(Replace Method with Method Object)

问题

你有一个过长函数,它的局部变量交织在一起,以致于你无法应用提炼函数(Extract Method) 。

1
2
3
4
5
6
7
8
9
10
class Order {
//...
public double price() {
double primaryBasePrice;
double secondaryBasePrice;
double tertiaryBasePrice;
// long computation.
//...
}
}

解决

将函数移到一个独立的类中,使得局部变量成了这个类的字段。然后,你可以将函数分割成这个类中的多个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Order {
//...
public double price() {
return new PriceCalculator(this).compute();
}
}

class PriceCalculator {
private double primaryBasePrice;
private double secondaryBasePrice;
private double tertiaryBasePrice;

public PriceCalculator(Order order) {
// copy relevant information from order object.
//...
}

public double compute() {
// long computation.
//...
}
}

引入 Null 对象(Introduce Null Object)

问题

你需要再三检查某对象是否为 null。

1
2
3
4
5
6
if (customer == null) {
plan = BillingPlan.basic();
}
else {
plan = customer.getPlan();
}

解决

将 null 值替换为 null 对象。

1
2
3
4
5
6
7
8
9
10
11
12
class NullCustomer extends Customer {
Plan getPlan() {
return new NullPlan();
}
// Some other NULL functionality.
}

// Replace null values with Null-object.
customer = (order.customer != null) ? order.customer : new NullCustomer();

// Use Null-object as if it's normal subclass.
plan = customer.getPlan();

异曲同工的类

异曲同工的类(Alternative Classes with Different Interfaces)

两个类中有着不同的函数,却在做着同一件事。

img

问题原因

这种情况往往是因为:创建这个类的程序员并不知道已经有实现这个功能的类存在了。

解决方法

  • 如果两个函数做同一件事,却有着不同的签名,请运用 函数改名(Rename Method) 根据它们的用途重新命名。
  • 运用 搬移函数(Move Method)添加参数(Add Parameter)令函数携带参数(Parameterize Method) 来使得方法的名称和实现一致。
  • 如果两个类仅有部分功能是重复的,尝试运用 提炼超类(Extract Superclass) 。这种情况下,已存在的类就成了超类。
  • 当最终选择并运用某种方法来重构后,也许你就能删除其中一个类了。

收益

  • 消除了不必要的重复代码,为代码瘦身了。
  • 代码更易读(不再需要猜测为什么要有两个功能相同的类)。

img

何时忽略

  • 有时合并类是不可能的,或者是如此困难以至于没有意义。例如:两个功能相似的类存在于不同的 lib 库中。

重构方法说明

函数改名(Rename Method)

问题

函数的名称未能恰当的揭示函数的用途。

1
2
3
class Person {
public String getsnm();
}

解决

修改函数名。

1
2
3
class Person {
public String getSecondName();
}

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

添加参数(Add Parameter)

问题
某个函数需要从调用端得到更多信息。

1
2
3
class Customer {
public Contact getContact();
}

解决
为此函数添加一个对象函数,让改对象带进函数所需信息。

1
2
3
class Customer {
public Contact getContact(Date date);
}

令函数携带参数(Parameterize Method)

问题

若干函数做了类似的工作,但在函数本体中却包含了不同的值。

img
解决

建立单一函数,以参数表达哪些不同的值。

img

提炼超类(Extract Superclass)

问题

两个类有相似特性。

img

解决

为这两个类建立一个超类,将相同特性移至超类。

img

被拒绝的馈赠

被拒绝的馈赠(Refused Bequest)

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

img

问题原因

有些人仅仅是想重用超类中的部分代码而创建了子类。但实际上超类和子类完全不同。

解决方法

  • 如果继承没有意义并且子类和父类之间确实没有共同点,可以运用 以委托取代继承(Replace Inheritance with Delegation) 消除继承。
  • 如果继承是适当的,则去除子类中不需要的字段和方法。运用 提炼超类(Extract Superclass) 将所有超类中对于子类有用的字段和函数提取出来,置入一个新的超类中,然后让两个类都继承自它。

img

收益

  • 提高代码的清晰度和组织性。

img

重构方法说明

以委托取代继承(Replace Inheritance with Delegation)

问题

某个子类只使用超类接口中的一部分,或是根本不需要继承而来的数据。

img

解决

  1. 在子类中新建一个字段用以保存超类;
  2. 调整子类函数,令它改而委托超类;
  3. 然后去掉两者之间的继承关系。

img

提炼超类(Extract Superclass)

问题

两个类有相似特性。

img

解决

为这两个类建立一个超类,将相同特性移至超类。

img

扩展阅读

参考资料

翻译自:https://sourcemaking.com/refactoring/smells/bloaters

代码臃肿(Bloated)这组坏味道意味着:代码中的类、函数、字段没有经过合理的组织,只是简单的堆砌起来。这一类型的问题通常在代码的初期并不明显,但是随着代码规模的增长而逐渐积累(特别是当没有人努力去根除它们时)。

基本类型偏执

基本类型偏执(Primitive Obsession)

  • 使用基本类型而不是小对象来实现简单任务(例如货币、范围、电话号码字符串等)。
  • 使用常量编码信息(例如一个用于引用管理员权限的常量USER_ADMIN_ROLE = 1 )。
  • 使用字符串常量作为字段名在数组中使用。

img

问题原因

类似其他大部分坏味道,基本类型偏执诞生于类初建的时候。一开始,可能只是不多的字段,随着表示的特性越来越多,基本数据类型字段也越来越多。

基本类型常常被用于表示模型的类型。你有一组数字或字符串用来表示某个实体。

还有一个场景:在模拟场景,大量的字符串常量被用于数组的索引。

解决方法

img

大多数编程语言都支持基本数据类型和结构类型(类、结构体等)。结构类型允许程序员将基本数据类型组织起来,以代表某一事物的模型。

基本数据类型可以看成是机构类型的积木块。当基本数据类型数量成规模后,将它们有组织地结合起来,可以更方便的管理这些数据。

  • 如果你有大量的基本数据类型字段,就有可能将其中部分存在逻辑联系的字段组织起来,形成一个类。更进一步的是,将与这些数据有关联的方法也一并移入类中。为了实现这个目标,可以尝试 以类取代类型码(Replace Type Code with Class)
  • 如果基本数据类型字段的值是用于方法的参数,可以使用 引入参数对象(Introduce Parameter Object)保持对象完整(Preserve Whole Object)
  • 如果想要替换的数据值是类型码,而它并不影响行为,则可以运用 以类取代类型码(Replace Type Code with Class) 将它替换掉。如果你有与类型码相关的条件表达式,可运用 以子类取代类型码(Replace Type Code with Subclass)以状态/策略模式取代类型码(Replace Type Code with State/Strategy) 加以处理。
  • 如果你发现自己正从数组中挑选数据,可运用 以对象取代数组(Replace Array with Object)

收益

  • 多亏了使用对象替代基本数据类型,使得代码变得更加灵活。
  • 代码变得更加易读和更加有组织。特殊数据可以集中进行操作,而不像之前那样分散。不用再猜测这些陌生的常量的意义以及它们为什么在数组中。
  • 更容易发现重复代码。

img

重构方法说明

以类取代类型码(Replace Type Code with Class)

问题

类之中有一个数值类型码,但它并不影响类的行为。

img

解决

以一个新的类替换该数值类型码。

img

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

img

解决

以一个对象来取代这些参数。

img

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

1
2
3
int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);

解决

改为传递整个对象。

1
boolean withinPlan = plan.withinRange(daysTempRange);

以子类取代类型码(Replace Type Code with Subclass)

问题

你有一个不可变的类型码,它会影响类的行为。

img

解决

以子类取代这个类型码。

img

以状态/策略模式取代类型码(Replace Type Code with State/Strategy)

问题

你有一个类型码,它会影响类的行为,但你无法通过继承消除它。

img

解决

以状态对象取代类型码。

img

以对象取代数组(Replace Array with Object)

问题

你有一个数组,其中的元素各自代表不同的东西。

1
2
3
String[] row = new String[3];
row[0] = "Liverpool";
row[1] = "15";

解决

以对象替换数组。对于数组中的每个元素,以一个字段来表示。

1
2
3
Performance row = new Performance();
row.setName("Liverpool");
row.setWins("15");

数据泥团

数据泥团(Data Clumps)

有时,代码的不同部分包含相同的变量组(例如用于连接到数据库的参数)。这些绑在一起出现的数据应该拥有自己的对象。

img

问题原因

通常,数据泥团的出现时因为糟糕的编程结构或“复制-粘贴式编程”。

有一个判断是否是数据泥团的好办法:删掉众多数据中的一项。这么做,其他数据有没有因而失去意义?如果它们不再有意义,这就是个明确的信号:你应该为它们产生一个新的对象。

解决方法

  • 首先找出这些数据以字段形式出现的地方,运用 提炼类(Extract Class) 将它们提炼到一个独立对象中。
  • 如果数据泥团在函数的参数列中出现,运用 引入参数对象(Introduce Parameter Object) 将它们组织成一个类。
  • 如果数据泥团的部分数据出现在其他函数中,考虑运用 保持对象完整(Preserve Whole Object) 将整个数据对象传入到函数中。
  • 检视一下使用这些字段的代码,也许,将它们移入一个数据类是个不错的主意。

收益

  • 提高代码易读性和组织性。对于特殊数据的操作,可以集中进行处理,而不像以前那样分散。
  • 减少代码量。

img

何时忽略

  • 有时为了对象中的部分数据而将整个对象作为参数传递给函数,可能会产生让两个类之间不收欢迎的依赖关系,这中情况下可以不传递整个对象。

重构方法说明

提炼类(Extract Class)

问题

某个类做了不止一件事。

img

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

img

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

img

解决

以一个对象来取代这些参数。

img

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

1
2
3
int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);

解决

改为传递整个对象。

1
boolean withinPlan = plan.withinRange(daysTempRange);

过大的类

过大的类(Large Class)

一个类含有过多字段、函数、代码行。

img

问题原因

类通常一开始很小,但是随着程序的增长而逐渐膨胀。

类似于过长函数,程序员通常觉得在一个现存类中添加新特性比创建一个新的类要容易。

解决方法

设计模式中有一条重要原则:职责单一原则。一个类应该只赋予它一个职责。如果它所承担的职责太多,就该考虑为它减减负。

img

  • 如果过大类中的部分行为可以提炼到一个独立的组件中,可以使用 提炼类(Extract Class)
  • 如果过大类中的部分行为可以用不同方式实现或使用于特殊场景,可以使用 提炼子类(Extract Subclass)
  • 如果有必要为客户端提供一组操作和行为,可以使用 提炼接口(Extract Interface)
  • 如果你的过大类是个 GUI 类,可能需要把数据和行为移到一个独立的领域对象去。你可能需要两边各保留一些重复数据,并保持两边同步。 复制被监视数据(Duplicate Observed Data) 可以告诉你怎么做。

收益

  • 重构过大的类可以使程序员不必记住一个类中大量的属性。
  • 在大多数情况下,分割过大的类可以避免代码和功能的重复。

img

重构方法说明

提炼类(Extract Class)

问题

某个类做了不止一件事。

img

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

img

提炼子类(Extract Subclass)

问题

一个类中有些特性仅用于特定场景。

img

解决

创建一个子类,并将用于特殊场景的特性置入其中。

img

提炼接口(Extract Interface)

问题

多个客户端使用一个类部分相同的函数。另一个场景是两个类中的部分函数相同。

img

解决

移动相同的部分函数到接口中。

img

复制被监视数据(Duplicate Observed Data)

问题

如果存储在类中的数据是负责 GUI 的。

img

解决

一个比较好的方法是将负责 GUI 的数据放入一个独立的类,以确保 GUI 数据与域类之间的连接和同步。

img

过长函数

过长函数(Long Method)

一个函数含有太多行代码。一般来说,任何函数超过 10 行时,你就可以考虑是不是过长了。
函数中的代码行数原则上不要超过 100 行。

img

问题的原因

通常情况下,创建一个新函数的难度要大于添加功能到一个已存在的函数。大部分人都觉得:“我就添加这么两行代码,为此新建一个函数实在是小题大做了。”于是,张三加两行,李四加两行,王五加两行。。。函数日益庞大,最终烂的像一锅浆糊,再也没人能完全看懂了。于是大家就更不敢轻易动这个函数了,只能恶性循环的往其中添加代码。所以,如果你看到一个超过 200 行的函数,通常都是多个程序员东拼西凑出来的。

解决函数

一个很好的技巧是:寻找注释。添加注释,一般有这么几个原因:代码逻辑较为晦涩或复杂;这段代码功能相对独立;特殊处理。
如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名。如果函数有一个描述恰当的名字,就不需要去看内部代码究竟是如何实现的。就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函数中。

img

  • 为了给一个函数瘦身,可以使用 提炼函数(Extract Method)
  • 如果局部变量和参数干扰提炼函数,可以使用 以查询取代临时变量(Replace Temp with Query)引入参数对象(Introduce Parameter Object)保持对象完整(Preserve Whole Object)
  • 如果前面两条没有帮助,可以通过 以函数对象取代函数(Replace Method with Method Object) 尝试移动整个函数到一个独立的对象中。
  • 条件表达式和循环常常也是提炼的信号。对于条件表达式,可以使用 分解条件表达式(Decompose Conditional) 。至于循环,应该使用 提炼函数(Extract Method) 将循环和其内的代码提炼到独立函数中。

收益

  • 在所有类型的面向对象代码中,函数比较短小精悍的类往往生命周期较长。一个函数越长,就越不容易理解和维护。
  • 此外,过长函数中往往含有难以发现的重复代码。

img

性能

是否像许多人说的那样,增加函数的数量会影响性能?在几乎绝大多数情况下,这种影响是可以忽略不计,所以不用担心。
此外,现在有了清晰和易读的代码,在需要的时候,你将更容易找到真正有效的函数来重组代码和提高性能。

重构方法说明

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

以查询取代临时变量(Replace Temp with Query)

问题

将表达式的结果放在局部变量中,然后在代码中使用。

1
2
3
4
5
6
7
8
9
double calculateTotal() {
double basePrice = quantity * itemPrice;
if (basePrice > 1000) {
return basePrice * 0.95;
}
else {
return basePrice * 0.98;
}
}

解决

将整个表达式移动到一个独立的函数中并返回结果。使用查询函数来替代使用变量。如果需要,可以在其他函数中合并新函数。

1
2
3
4
5
6
7
8
9
10
11
double calculateTotal() {
if (basePrice() > 1000) {
return basePrice() * 0.95;
}
else {
return basePrice() * 0.98;
}
}
double basePrice() {
return quantity * itemPrice;
}

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

img

解决

以一个对象来取代这些参数。

img

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

1
2
3
int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);

解决

改为传递整个对象。

1
boolean withinPlan = plan.withinRange(daysTempRange);

以函数对象取代函数(Replace Method with Method Object)

问题

你有一个过长函数,它的局部变量交织在一起,以致于你无法应用提炼函数(Extract Method) 。

1
2
3
4
5
6
7
8
9
10
class Order {
//...
public double price() {
double primaryBasePrice;
double secondaryBasePrice;
double tertiaryBasePrice;
// long computation.
//...
}
}

解决

将函数移到一个独立的类中,使得局部变量成了这个类的字段。然后,你可以将函数分割成这个类中的多个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Order {
//...
public double price() {
return new PriceCalculator(this).compute();
}
}

class PriceCalculator {
private double primaryBasePrice;
private double secondaryBasePrice;
private double tertiaryBasePrice;

public PriceCalculator(Order order) {
// copy relevant information from order object.
//...
}

public double compute() {
// long computation.
//...
}
}

分解条件表达式(Decompose Conditional)

问题

你有复杂的条件表达式。

1
2
3
4
5
6
if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
charge = quantity * winterRate + winterServiceCharge;
}
else {
charge = quantity * summerRate;
}

解决

根据条件分支将整个条件表达式分解成几个函数。

1
2
3
4
5
6
if (notSummer(date)) {
charge = winterCharge(quantity);
}
else {
charge = summerCharge(quantity);
}

过长参数列

过长参数列(Long Parameter List)

一个函数有超过 3、4 个入参。

img

问题原因

过长参数列可能是将多个算法并到一个函数中时发生的。函数中的入参可以用来控制最终选用哪个算法去执行。

过长参数列也可能是解耦类之间依赖关系时的副产品。例如,用于创建函数中所需的特定对象的代码已从函数移动到调用函数的代码处,但创建的对象是作为参数传递到函数中。因此,原始类不再知道对象之间的关系,并且依赖性也已经减少。但是如果创建的这些对象,每一个都将需要它自己的参数,这意味着过长参数列。

太长的参数列难以理解,太多参数会造成前后不一致、不易使用,而且一旦需要更多数据,就不得不修改它。

解决方案

img

  • 如果向已有的对象发出一条请求就可以取代一个参数,那么你应该使用 以函数取代参数(Replace Parameter with Methods) 。在这里,,“已有的对象”可能是函数所属类里的一个字段,也可能是另一个参数。
  • 你还可以运用 保持对象完整(Preserve Whole Object) 将来自同一对象的一堆数据收集起来,并以该对象替换它们。
  • 如果某些数据缺乏合理的对象归属,可使用 引入参数对象(Introduce Parameter Object) 为它们制造出一个“参数对象”。

收益

  • 更易读,更简短的代码。
  • 重构可能会暴露出之前未注意到的重复代码。

何时忽略

  • 这里有一个重要的例外:有时候你明显不想造成”被调用对象”与”较大对象”间的某种依赖关系。这时候将数据从对象中拆解出来单独作为参数,也很合情理。但是请注意其所引发的代价。如果参数列太长或变化太频繁,就需要重新考虑自己的依赖结构了。

重构方法说明

以函数取代参数(Replace Parameter with Methods)

问题

对象调用某个函数,并将所得结果作为参数,传递给另一个函数。而接受该参数的函数本身也能够调用前一个函数。

1
2
3
4
int basePrice = quantity * itemPrice;
double seasonDiscount = this.getSeasonalDiscount();
double fees = this.getFees();
double finalPrice = discountedPrice(basePrice, seasonDiscount, fees);

解决

让参数接受者去除该项参数,并直接调用前一个函数。

1
2
int basePrice = quantity * itemPrice;
double finalPrice = discountedPrice(basePrice);

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

1
2
3
int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);

解决

改为传递整个对象。

1
boolean withinPlan = plan.withinRange(daysTempRange);

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

img

解决

以一个对象来取代这些参数。

img

扩展阅读

参考资料

翻译自:https://sourcemaking.com/refactoring/smells/change-preventers

变革的障碍(Change Preventers)这组坏味道意味着:当你需要改变一处代码时,却发现不得不改变其他的地方。这使得程序开发变得复杂、代价高昂。

发散式变化

发散式变化(Divergent Change) 类似于 霰弹式修改(Shotgun Surgery) ,但实际上完全不同。发散式变化(Divergent Change) 是指一个类受多种变化的影响。霰弹式修改(Shotgun Surgery) 是指多种变化引发多个类相应的修改。

特征

你发现你想要修改一个函数,却必须要同时修改许多不相关的函数。例如,当你想要添加一个新的产品类型时,你需要同步修改对产品进行查找、显示、排序的函数。

img

问题原因

通常,这种发散式修改是由于编程结构不合理或者“复制-粘贴式编程”。

解决办法

  • 运用 提炼类(Extract Class) 拆分类的行为。

收益

  • 提高代码组织结构
  • 减少重复代码

重构方法说明

提炼类(Extract Class)

问题

某个类做了不止一件事。

img

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

img

平行继承体系

平行继承体系(Parallel Inheritance Hierarchies) 其实是 霰弹式修改(Shotgun Surgery) 的特殊情况。

特征

每当你为某个类添加一个子类,必须同时为另一个类相应添加一个子类。这种情况的典型特征是:某个继承体系的类名前缀或类名后缀完全相同。

img

问题原因

起初的继承体系很小,随着不断添加新类,继承体系越来越大,也越来越难修改。

解决方法

  • 一般策略是:让一个继承体系的实例引用另一个继承体系的实例。如果再接再厉运用 搬移函数(Move Method)搬移字段(Move Field),就可以消除引用端的继承体系。

收益

  • 更好的代码组织
  • 减少重复代码

何时忽略

  • 有时具有并行类层次结构只是一种为了避免程序体系结构更混乱的方法。如果你发现尝试消除平行继承体系导致代码更加丑陋,那么你应该回滚你的修改。

重构方法说明

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

搬移字段(Move Field)

问题

在你的程序中,某个字段被其所驻类之外的另一个类更多地用到。

img

解决

在目标类新建一个字段,修改源字段的所有用户,令他们改用新字段。

img

霰弹式修改

霰弹式修改(Shotgun Surgery) 类似于 发散式变化(Divergent Change) ,但实际上完全不同。发散式变化(Divergent Change) 是指一个类受多种变化的影响。霰弹式修改(Shotgun Surgery) 是指多种变化引发多个类相应的修改。

特征

任何修改都需要在许多不同类上做小幅度修改。

img

问题原因

一个单一的职责被拆分成大量的类。

解决方法

  • 运用搬移函数(Move Method)搬移字段(Move Field) 来搬移不同类中相同的行为到一个独立类中。如果没有适合存放搬移函数或字段的类,就创建一个新类。
  • 通常,可以运用 将类内联化(Inline Class) 将一些列相关行为放进同一个类。

img

收益

  • 更好的代码组织
  • 减少重复代码
  • 更易维护

img

重构方法说明

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

搬移字段(Move Field)

问题

在你的程序中,某个字段被其所驻类之外的另一个类更多地用到。

img

解决

在目标类新建一个字段,修改源字段的所有用户,令他们改用新字段。

img

将类内联化(Inline Class)

问题

某个类没有做太多事情。

img

解决

将这个类的所有特性搬移到另一个类中,然后移除原类。

img

扩展阅读

参考资料

翻译自:https://sourcemaking.com/refactoring/smells/dispensables

非必要的(Dispensables)这组坏味道意味着:这样的代码可有可无,它的存在反而影响整体代码的整洁和可读性。

冗余类

冗余类(Lazy Class)

理解和维护总是费时费力的。如果一个类不值得你花费精力,它就应该被删除。

img

问题原因

也许一个类的初始设计是一个功能完全的类,然而随着代码的变迁,变得没什么用了。
又或者类起初的设计是为了支持未来的功能扩展,然而却一直未派上用场。

解决方法

  • 没什么用的类可以运用 将类内联化(Inline Class) 来干掉。

img

  • 如果子类用处不大,试试 折叠继承体系(Collapse Hierarchy)

收益

  • 减少代码量
  • 易于维护

何时忽略

  • 有时,创建冗余类是为了描述未来开发的意图。在这种情况下,尝试在代码中保持清晰和简单之间的平衡。

重构方法说明

将类内联化(Inline Class)

问题

某个类没有做太多事情。

img

解决

将这个类的所有特性搬移到另一个类中,然后移除原类。

img

折叠继承体系(Collapse Hierarchy)

问题

超类和子类之间无太大区别。

img

解决

将它们合为一体。

img

夸夸其谈未来性

夸夸其谈未来性(Speculative Generality)

存在未被使用的类、函数、字段或参数。

img

问题原因

有时,代码仅仅为了支持未来的特性而产生,然而却一直未实现。结果,代码变得难以理解和维护。

解决方法

  • 如果你的某个抽象类其实没有太大作用,请运用 折叠继承体系(Collapse Hierarch)

img

  • 不必要的委托可运用 将类内联化(Inline Class) 消除。
  • 无用的函数可运用 内联函数(Inline Method) 消除。
  • 函数中有无用的参数应该运用 移除参数(Remove Parameter) 消除。
  • 无用字段可以直接删除。

收益

  • 减少代码量。
  • 更易维护。

何时忽略

  • 如果你在一个框架上工作,创建框架本身没有使用的功能是非常合理的,只要框架的用户需要这个功能。
  • 删除元素之前,请确保它们不在单元测试中使用。如果测试需要从类中获取某些内部信息或执行特殊的测试相关操作,就会发生这种情况。

重构方法说明

折叠继承体系(Collapse Hierarchy)

问题

超类和子类之间无太大区别。

img

解决

将它们合为一体。

img

将类内联化(Inline Class)

问题

某个类没有做太多事情。

img

解决

将这个类的所有特性搬移到另一个类中,然后移除原类。

img

内联函数(Inline Method)

问题

一个函数的本体比函数名更清楚易懂。

1
2
3
4
5
6
7
8
9
class PizzaDelivery {
//...
int getRating() {
return moreThanFiveLateDeliveries() ? 2 : 1;
}
boolean moreThanFiveLateDeliveries() {
return numberOfLateDeliveries > 5;
}
}

解决

在函数调用点插入函数本体,然后移除该函数。

1
2
3
4
5
6
class PizzaDelivery {
//...
int getRating() {
return numberOfLateDeliveries > 5 ? 2 : 1;
}
}

移除参数(Remove Parameter)

问题

函数本体不再需要某个参数。

img

解决

将该参数去除。

img

纯稚的数据类

纯稚的数据类(Data Class) 指的是只包含字段和访问它们的 getter 和 setter 函数的类。这些仅仅是供其他类使用的数据容器。这些类不包含任何附加功能,并且不能对自己拥有的数据进行独立操作。

img

问题原因

当一个新创建的类只包含几个公共字段(甚至可能几个 getters / setters)是很正常的。但是对象的真正力量在于它们可以包含作用于数据的行为类型或操作。

解决方法

  • 如果一个类有公共字段,你应该运用 封装字段(Encapsulated Field) 来隐藏字段的直接访问方式。
  • 如果这些类含容器类的字段,你应该检查它们是不是得到了恰当的封装;如果没有,就运用 封装集合(Encapsulated Collection) 把它们封装起来。
  • 找出这些 getter/setter 函数被其他类运用的地点。尝试以 搬移函数(Move Method) 把那些调用行为搬移到 纯稚的数据类(Data Class) 来。如果无法搬移这个函数,就运用 提炼函数(Extract Method) 产生一个可搬移的函数。

img

  • 在类已经充满了深思熟虑的函数之后,你可能想要摆脱旧的数据访问方法,以提供适应面较广的类数据访问接口。为此,可以运用 移除设置函数(Remove Setting Method)隐藏函数(Hide Method)

收益

  • 提高代码的可读性和组织性。特定数据的操作现在被集中在一个地方,而不是在分散在代码各处。
  • 帮助你发现客户端代码的重复处。

重构方法说明

封装字段(Encapsulated Field)

问题

你的类中存在 public 字段。

1
2
3
class Person {
public String name;
}

解决

将它声明为 private,并提供相应的访问函数。

1
2
3
4
5
6
7
8
9
10
class Person {
private String name;

public String getName() {
return name;
}
public void setName(String arg) {
name = arg;
}
}

封装集合(Encapsulated Collection)

问题

有个函数返回一个集合。

img

解决

让该函数返回该集合的一个只读副本,并在这个类中提供添加、移除集合元素的函数。

img

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

移除设置函数(Remove Setting Method)

问题

类中的某个字段应该在对象创建时被设值,然后就不再改变。

img

解决

去掉该字段的所有设值函数。

img

隐藏函数(Hide Method)

问题

有一个函数,从来没有被其他任何类用到。

img

解决

将这个函数修改为 private。

img

过多的注释

过多的注释(Comments)

注释本身并不是坏事。但是常常有这样的情况:一段代码中出现长长的注释,而它之所以存在,是因为代码很糟糕。

img

问题原因

注释的作者意识到自己的代码不直观或不明显,所以想使用注释来说明自己的意图。这种情况下,注释就像是烂代码的除臭剂。

最好的注释是为函数或类起一个恰当的名字。

如果你觉得一个代码片段没有注释就无法理解,请先尝试重构,试着让所有注释都变得多余。

解决方法

  • 如果一个注释是为了解释一个复杂的表达式,可以运用 提炼变量(Extract Variable) 将表达式切分为易理解的子表达式。
  • 如果你需要通过注释来解释一段代码做了什么,请试试 提炼函数(Extract Method)
  • 如果函数已经被提炼,但仍需要注释函数做了什么,试试运用 函数改名(Rename Method) 来为函数起一个可以自解释的名字。
  • 如果需要对系统某状态进行断言,请运用 引入断言(Introduce Assertion)

收益

  • 代码变得更直观和明显。

何时忽略

注释有时候很有用:

  • 当解释为什么某事物要以特殊方式实现时。
  • 当解释某种复杂算法时。
  • 当你实在不知可以做些什么时。

重构方法说明

提炼变量(Extract Variable)

问题

你有个难以理解的表达式。

1
2
3
4
5
6
7
8
void renderBanner() {
if ((platform.toUpperCase().indexOf("MAC") > -1) &&
(browser.toUpperCase().indexOf("IE") > -1) &&
wasInitialized() && resize > 0 )
{
// do something
}
}

解决

将表达式的结果或它的子表达式的结果用不言自明的变量来替代。

1
2
3
4
5
6
7
8
9
void renderBanner() {
final boolean isMacOs = platform.toUpperCase().indexOf("MAC") > -1;
final boolean isIE = browser.toUpperCase().indexOf("IE") > -1;
final boolean wasResized = resize > 0;

if (isMacOs && isIE && wasInitialized() && wasResized) {
// do something
}
}

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

函数改名(Rename Method)

问题

函数的名称未能恰当的揭示函数的用途。

1
2
3
class Person {
public String getsnm();
}

解决

修改函数名。

1
2
3
class Person {
public String getSecondName();
}

引入断言(Introduce Assertion)

问题

某一段代码需要对程序状态做出某种假设。

1
2
3
4
5
6
double getExpenseLimit() {
// should have either expense limit or a primary project
return (expenseLimit != NULL_EXPENSE) ?
expenseLimit:
primaryProject.getMemberExpenseLimit();
}

解决

以断言明确表现这种假设。

1
2
3
4
5
6
7
double getExpenseLimit() {
Assert.isTrue(expenseLimit != NULL_EXPENSE || primaryProject != null);

return (expenseLimit != NULL_EXPENSE) ?
expenseLimit:
primaryProject.getMemberExpenseLimit();
}

注:请不要滥用断言。不要使用它来检查”应该为真“的条件,只能使用它来检查“一定必须为真”的条件。实际上,断言更多是用于自我检测代码的一种手段。在产品真正交付时,往往都会消除所有断言。

重复代码

重复代码(Duplicate Code)

重复代码堪称为代码坏味道之首。消除重复代码总是有利无害的。

img

问题原因

重复代码通常发生在多个程序员同时在同一程序的不同部分上工作时。由于他们正在处理不同的任务,他们可能不知道他们的同事已经写了类似的代码。

还有一种更隐晦的重复,特定部分的代码看上去不同但实际在做同一件事。这种重复代码往往难以找到和消除。

有时重复是有目的性的。当急于满足 deadline,并且现有代码对于要交付的任务是“几乎正确的”时,新手程序员可能无法抵抗复制和粘贴相关代码的诱惑。在某些情况下,程序员只是太懒惰。

解决方法

  • 同一个类的两个函数含有相同的表达式,这时可以采用 提炼函数(Extract Method) 提炼出重复的代码,然后让这两个地点都调用被提炼出来的那段代码。

img

  • 如果两个互为兄弟的子类含有重复代码:
    • 首先对两个类都运用 提炼函数(Extract Method) ,然后对被提炼出来的函数运用 函数上移(Pull Up Method) ,将它推入超类。
    • 如果重复代码在构造函数中,运用 构造函数本体上移(Pull Up Constructor Body)
    • 如果重复代码只是相似但不是完全相同,运用 塑造模板函数(Form Template Method) 获得一个 模板方法模式(Template Method)
    • 如果有些函数以不同的算法做相同的事,你可以选择其中较清晰地一个,并运用 替换算法(Substitute Algorithm) 将其他函数的算法替换掉。
  • 如果两个毫不相关的类中有重复代码:
    • 请尝试运用 提炼超类(Extract Superclass) ,以便为维护所有先前功能的这些类创建一个超类。
    • 如果创建超类十分困难,可以在一个类中运用 提炼类(Extract Class) ,并在另一个类中使用这个新的组件。
  • 如果存在大量的条件表达式,并且它们执行完全相同的代码(仅仅是它们的条件不同),可以运用 合并条件表达式(Consolidate Conditional Expression) 将这些操作合并为单个条件,并运用 提炼函数(Extract Method) 将该条件放入一个名字容易理解的独立函数中。
  • 如果条件表达式的所有分支都有部分相同的代码片段:可以运用 合并重复的条件片段(Consolidate Duplicate Conditional Fragments) 将它们都存在的代码片段置于条件表达式外部。

收益

  • 合并重复代码会简化代码的结构,并减少代码量。
  • 代码更简化、更易维护。

重构方法说明

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

函数上移(Pull Up Method)

问题

有些函数,在各个子类中产生完全相同的结果。

img

解决

将该函数移至超类。

img

构造函数本体上移(Pull Up Constructor Body)

问题

你在各个子类中拥有一些构造函数,它们的本体几乎完全一致。

1
2
3
4
5
6
7
8
class Manager extends Employee {
public Manager(String name, String id, int grade) {
this.name = name;
this.id = id;
this.grade = grade;
}
//...
}

解决

在超类中新建一个构造函数,并在子类构造函数中调用它。

1
2
3
4
5
6
7
class Manager extends Employee {
public Manager(String name, String id, int grade) {
super(name, id);
this.grade = grade;
}
//...
}

塑造模板函数(Form Template Method)

问题

你有一些子类,其中相应的某些函数以相同的顺序执行类似的操作,但各个操作的细节上有所不同。

img

解决

将这些操作分别放进独立函数中,并保持它们都有相同的签名,于是原函数也就变得相同了。然后将原函数上移至超类。

img

注:这里只提到具体做法,建议了解一下模板方法设计模式。

替换算法(Substitute Algorithm)

问题

你想要把某个算法替换为另一个更清晰的算法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
String foundPerson(String[] people){
for (int i = 0; i < people.length; i++) {
if (people[i].equals("Don")){
return "Don";
}
if (people[i].equals("John")){
return "John";
}
if (people[i].equals("Kent")){
return "Kent";
}
}
return "";
}

解决

将函数本体替换为另一个算法。

1
2
3
4
5
6
7
8
9
10
String foundPerson(String[] people){
List candidates =
Arrays.asList(new String[] {"Don", "John", "Kent"});
for (int i=0; i < people.length; i++) {
if (candidates.contains(people[i])) {
return people[i];
}
}
return "";
}

提炼超类(Extract Superclass)

问题

两个类有相似特性。

img

解决

为这两个类建立一个超类,将相同特性移至超类。

img

提炼类(Extract Class)

问题

某个类做了不止一件事。

img

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

img

合并条件表达式(Consolidate Conditional Expression)

问题

你有一系列条件分支,都得到相同结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
double disabilityAmount() {
if (seniority < 2) {
return 0;
}
if (monthsDisabled > 12) {
return 0;
}
if (isPartTime) {
return 0;
}
// compute the disability amount
//...
}

解决

将这些条件分支合并为一个条件,并将这个条件提炼为一个独立函数。

1
2
3
4
5
6
7
double disabilityAmount() {
if (isNotEligableForDisability()) {
return 0;
}
// compute the disability amount
//...
}

合并重复的条件片段(Consolidate Duplicate Conditional Fragments)

问题

在条件表达式的每个分支上有着相同的一段代码。

1
2
3
4
5
6
7
8
if (isSpecialDeal()) {
total = price * 0.95;
send();
}
else {
total = price * 0.98;
send();
}

解决

将这段重复代码搬移到条件表达式之外。

1
2
3
4
5
6
7
if (isSpecialDeal()) {
total = price * 0.95;
}
else {
total = price * 0.98;
}
send();

扩展阅读

参考资料

翻译自:https://sourcemaking.com/refactoring/smells/couplers

耦合(Couplers)这组坏味道意味着:不同类之间过度耦合。

不完美的库类

不完美的库类(Incomplete Library Class)

当一个类库已经不能满足实际需要时,你就不得不改变这个库(如果这个库是只读的,那就没辙了)。

问题原因

许多编程技术都建立在库类的基础上。库类的作者没用未卜先知的能力,不能因此责怪他们。麻烦的是库往往构造的不够好,而且往往不可能让我们修改其中的类以满足我们的需要。

解决方法

  • 如果你只想修改类库的一两个函数,可以运用 引入外加函数(Introduce Foreign Method)
  • 如果想要添加一大堆额外行为,就得运用 引入本地扩展(Introduce Local Extension)

收益

  • 减少代码重复(你不用一言不合就自己动手实现一个库的全部功能,代价太高)

何时忽略

  • 如果扩展库会带来额外的工作量。

重构方法说明

引入外加函数(Introduce Foreign Method)

问题

你需要为提供服务的类增加一个函数,但你无法修改这个类。

1
2
3
4
5
6
7
8
class Report {
//...
void sendReport() {
Date nextDay = new Date(previousEnd.getYear(),
previousEnd.getMonth(), previousEnd.getDate() + 1);
//...
}
}

解决

在客户类中建立一个函数,并一个第一个参数形式传入一个服务类实例。

1
2
3
4
5
6
7
8
9
10
class Report {
//...
void sendReport() {
Date newStart = nextDay(previousEnd);
//...
}
private static Date nextDay(Date arg) {
return new Date(arg.getYear(), arg.getMonth(), arg.getDate() + 1);
}
}

引入本地扩展(Introduce Local Extension)

问题

你需要为服务类提供一些额外函数,但你无法修改这个类。

img

解决

建立一个新类,使它包含这些额外函数,让这个扩展品成为源类的子类或包装类。

img

中间人

中间人(Middle Man)

如果一个类的作用仅仅是指向另一个类的委托,为什么要存在呢?

img

问题原因

对象的基本特征之一就是封装:对外部世界隐藏其内部细节。封装往往伴随委托。但是人们可能过度运用委托。比如,你也许会看到一个类的大部分有用工作都委托给了其他类,类本身成了一个空壳,除了委托之外不做任何事情。

解决方法

应该运用 移除中间人(Remove Middle Man),直接和真正负责的对象打交道。

收益

  • 减少笨重的代码。

img

何时忽略

如果是以下情况,不要删除已创建的中间人:

  • 添加中间人是为了避免类之间依赖关系。
  • 一些设计模式有目的地创建中间人(例如代理模式和装饰器模式)。

重构方法说明

移除中间人(Remove Middle Man)

问题

某个类做了过多的简单委托动作。

img

解决

让客户直接调用委托类。

img

依恋情结

依恋情结(Feature Envy)

一个函数访问其它对象的数据比访问自己的数据更多。

img

问题原因

这种气味可能发生在字段移动到数据类之后。如果是这种情况,你可能想将数据类的操作移动到这个类中。

解决方法

As a basic rule, if things change at the same time, you should keep them in the same place. Usually data and functions that use this data are changed together (although exceptions are possible).

有一个基本原则:同时会发生改变的事情应该被放在同一个地方。通常,数据和使用这些数据的函数是一起改变的。

img

  • 如果一个函数明显应该被移到另一个地方,可运用 搬移函数(Move Method)
  • 如果仅仅是函数的部分代码访问另一个对象的数据,运用 提炼函数(Extract Method) 将这部分代码移到独立的函数中。
  • 如果一个方法使用来自其他几个类的函数,首先确定哪个类包含大多数使用的数据。然后,将该方法与其他数据一起放在此类中。或者,使用 提炼函数(Extract Method) 将方法拆分为几个部分,可以放置在不同类中的不同位置。

收益

  • 减少重复代码(如果数据处理的代码放在中心位置)。
  • 更好的代码组织性(处理数据的函数靠近实际数据)。

img

何时忽略

  • 有时,行为被有意地与保存数据的类分开。这通常的优点是能够动态地改变行为(见策略设计模式,访问者设计模式和其他模式)。

重构方法说明

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

狎昵关系

狎昵关系(Inappropriate Intimacy)

一个类大量使用另一个类的内部字段和方法。

img

问题原因

类和类之间应该尽量少的感知彼此(减少耦合)。这样的类更容易维护和复用。

解决方法

  • 最简单的解决方法是运用 搬移函数(Move Method)搬移字段(Move Field) 来让类之间斩断羁绊。

img

  • 你也可以看看是否能运用 将双向关联改为单向关联(Change Bidirectional Association to Unidirectional) 让其中一个类对另一个说分手。

  • 如果这两个类实在是情比金坚,难分难舍,可以运用 提炼类(Extract Class) 把二者共同点提炼到一个新类中,让它们产生爱的结晶。或者,可以尝试运用 隐藏委托关系(Hide Delegate) 让另一个类来为它们牵线搭桥。

  • 继承往往造成类之间过分紧密,因为子类对超类的了解总是超过后者的主观愿望,如果你觉得该让这个子类自己闯荡,请运用 以委托取代继承(Replace Inheritance with Delegation) 来让超类和子类分家。

收益

  • 提高代码组织性。
  • 提高代码复用性。

img

重构方法说明

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

搬移字段(Move Field)

问题

在你的程序中,某个字段被其所驻类之外的另一个类更多地用到。

img

解决

在目标类新建一个字段,修改源字段的所有用户,令他们改用新字段。

img

将双向关联改为单向关联(Change Bidirectional Association to Unidirectional)

问题

两个类之间有双向关联,但其中一个类如今不再需要另一个类的特性。

img

解决

去除不必要的关联。

img

提炼类(Extract Class)

问题

某个类做了不止一件事。

img

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

img

隐藏委托关系(Hide Delegate)

问题

客户通过一个委托类来调用另一个对象。

img

解决

在服务类上建立客户所需的所有函数,用以隐藏委托关系。

img

以委托取代继承(Replace Inheritance with Delegation)

问题

某个子类只使用超类接口中的一部分,或是根本不需要继承而来的数据。

img

解决

在子类中新建一个字段用以保存超类;调整子类函数,令它改而委托超类;然后去掉两者之间的继承关系。

img

过度耦合的消息链

过度耦合的消息链(Message Chains)

消息链的形式类似于:obj.getA().getB().getC()

img

问题原因

如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象……这就是消息链。实际代码中你看到的可能是一长串 getThis()或一长串临时变量。采取这种方式,意味客户代码将与查找过程中的导航紧密耦合。一旦对象间关系发生任何变化,客户端就不得不做出相应的修改。

解决方法

  • 可以运用 隐藏委托关系(Hide Delegate) 删除一个消息链。

img

  • 有时更好的选择是:先观察消息链最终得到的对象是用来干什么的。看看能否以 提炼函数(Extract Method)把使用该对象的代码提炼到一个独立函数中,再运用 搬移函数(Move Method) 把这个函数推入消息链。

收益

  • 能减少链中类之间的依赖。
  • 能减少代码量。

img

何时忽略

  • 过于侵略性的委托可能会使程序员难以理解功能是如何触发的。

重构方法说明

隐藏委托关系(Hide Delegate)

问题

客户通过一个委托类来调用另一个对象。

img

解决

在服务类上建立客户所需的所有函数,用以隐藏委托关系。

img

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

扩展阅读

参考资料

Linux 典型运维应用

💡 如果没有特殊说明,本文的案例都是针对 Centos 发行版本。

网络操作

无法访问外网域名

(1)在 hosts 中添加本机实际 IP 和本机实际域名的映射

1
echo "192.168.0.1 hostname" >> /etc/hosts

如果不知道本机域名,使用 hostname 命令查一下;如果不知道本机实际 IP,使用 ifconfig 查一下。

(2)配置信赖的 DNS 服务器

执行 vi /etc/resolv.conf ,添加以下内容:

1
2
nameserver 114.114.114.114
nameserver 8.8.8.8

114.114.114.114 是国内老牌 DNS

8.8.8.8 是 Google DNS

:point_right: 参考:公共 DNS 哪家强

(3)测试一下能否 ping 通 www.baidu.com

配置网卡

使用 root 权限编辑 /etc/sysconfig/network-scripts/ifcfg-eno16777736X 文件

参考以下进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
TYPE=Ethernet                        # 网络类型:Ethernet以太网
BOOTPROTO=none # 引导协议:自动获取、static静态、none不指定
DEFROUTE=yes # 启动默认路由
IPV4_FAILURE_FATAL=no # 不启用IPV4错误检测功能
IPV6INIT=yes # 启用IPV6协议
IPV6_AUTOCONF=yes # 自动配置IPV6地址
IPV6_DEFROUTE=yes # 启用IPV6默认路由
IPV6_FAILURE_FATAL=no # 不启用IPV6错误检测功能
IPV6_PEERDNS=yes
IPV6_PEERROUTES=yes
IPV6_PRIVACY="no"

NAME=eno16777736 # 网卡设备的别名(需要和文件名同名)
UUID=90528772-9967-46da-b401-f82b64b4acbc # 网卡设备的UUID唯一标识号
DEVICE=eno16777736 # 网卡的设备名称
ONBOOT=yes # 开机自动激活网卡
IPADDR=192.168.1.199 # 网卡的固定IP地址
PREFIX=24 # 子网掩码
GATEWAY=192.168.1.1 # 默认网关IP地址
DNS1=8.8.8.8 # DNS域名解析服务器的IP地址

修改完后,执行 systemctl restart network.service 重启网卡服务。

自动化脚本

Linux 开机自启动脚本

(1)在 /etc/rc.local 文件中添加命令

如果不想将脚本粘来粘去,或创建链接,可以在 /etc/rc.local 文件中添加启动命令

  1. 先修改好脚本,使其所有模块都能在任意目录启动时正常执行;
  2. 再在 /etc/rc.local 的末尾添加一行以绝对路径启动脚本的行;

例:

执行 vim /etc/rc.local 命令,输入以下内容:

1
2
3
4
5
6
7
8
#!/bin/sh
#
# This script will be executed *after* all the other init scripts.
# You can put your own initialization stuff in here if you don't
# want to do the full Sys V style init stuff.

touch /var/lock/subsys/local
/opt/pjt_test/test.pl

(2)在 /etc/rc.d/init.d 目录下添加自启动脚本

Linux 在 /etc/rc.d/init.d 下有很多的文件,每个文件都是可以看到内容的,其实都是一些 shell 脚本或者可执行二进制文件。

Linux 开机的时候,会加载运行 /etc/rc.d/init.d 目录下的程序,因此我们可以把想要自动运行的脚本放到这个目录下即可。系统服务的启动就是通过这种方式实现的。

(3)运行级别设置

简单的说,运行级就是操作系统当前正在运行的功能级别。

1
2
3
4
5
6
7
8
不同的运行级定义如下:
# 0 - 停机(千万不能把initdefault 设置为0 )
# 1 - 单用户模式   进入方法#init s = init 1
# 2 - 多用户,没有 NFS
# 3 - 完全多用户模式(标准的运行级)
# 4 - 没有用到
# 5 - X11 多用户图形模式(xwindow)
# 6 - 重新启动 (千万不要把initdefault 设置为6 )

这些级别在 /etc/inittab 文件里指定,这个文件是 init 程序寻找的主要文件,最先运行的服务是放在/etc/rc.d 目录下的文件。

/etc 目录下面有这么几个目录值得注意:rcS.d rc0.d rc1.d … rc6.d (0,1… 6 代表启动级别 0 代表停止,1 代表单用户模式,2-5 代表多用户模式,6 代表重启) 它们的作用就相当于 redhat 下的 rc.d ,你可以把脚本放到 rcS.d,然后修改文件名,给它一个启动序号,如: S88mysql

不过,最好的办法是放到相应的启动级别下面。具体作法:

(1)先把脚本 mysql 放到 /etc/init.d 目录下

(2)查看当前系统的启动级别

1
2
$ runlevel
N 3

(3)设定启动级别

1
2
3
#  98 为启动序号
# 2 是系统的运行级别,可自己调整,注意不要忘了结尾的句点
$ update-rc.d mysql start 98 2 .

现在我们到 /etc/rc2.d 下,就多了一个 S98mysql 这样的符号链接。

(4)重启系统,验证设置是否有效。

(5)移除符号链接

当你需要移除这个符号连接时,方法有三种:

  1. 直接到 /etc/rc2.d 下删掉相应的链接,当然不是最好的方法;

  2. 推荐做法:update-rc.d -f s10 remove

  3. 如果 update-rc.d 命令你不熟悉,还可以试试看 rcconf 这个命令,也很方便。

:point_right: 参考:

定时执行脚本

配置

设置 Linux 启动模式

  1. 停机(记得不要把 initdefault 配置为 0,因为这样会使 Linux 不能启动)
  2. 单用户模式,就像 Win9X 下的安全模式
  3. 多用户,但是没有 NFS
  4. 完全多用户模式,准则的运行级
  5. 通常不用,在一些特殊情况下可以用它来做一些事情
  6. X11,即进到 X-Window 系统
  7. 重新启动 (记得不要把 initdefault 配置为 6,因为这样会使 Linux 不断地重新启动)

设置方法:

1
sed -i 's/id:5:initdefault:/id:3:initdefault:/' /etc/inittab

参考资料

Spring 面试

综合篇

不同版本的 Spring Framework 有哪些主要功能?

Version Feature
Spring 2.5 发布于 2007 年。这是第一个支持注解的版本。
Spring 3.0 发布于 2009 年。它完全利用了 Java5 中的改进,并为 JEE6 提供了支持。
Spring 4.0 发布于 2013 年。这是第一个完全支持 JAVA8 的版本。

什么是 Spring Framework?

  • Spring 是一个开源应用框架,旨在降低应用程序开发的复杂度。
  • 它是轻量级、松散耦合的。
  • 它具有分层体系结构,允许用户选择组件,同时还为 J2EE 应用程序开发提供了一个有凝聚力的框架。
  • 它可以集成其他框架,如 Structs、Hibernate、EJB 等,所以又称为框架的框架。

列举 Spring Framework 的优点。

  • 由于 Spring Frameworks 的分层架构,用户可以自由选择自己需要的组件。
  • Spring Framework 支持 POJO(Plain Old Java Object) 编程,从而具备持续集成和可测试性。
  • 由于依赖注入和控制反转,JDBC 得以简化。
  • 它是开源免费的。

Spring Framework 有哪些不同的功能?

  • 轻量级 - Spring 在代码量和透明度方面都很轻便。
  • IOC - 控制反转
  • AOP - 面向切面编程可以将应用业务逻辑和系统服务分离,以实现高内聚。
  • 容器 - Spring 负责创建和管理对象(Bean)的生命周期和配置。
  • MVC - 对 web 应用提供了高度可配置性,其他框架的集成也十分方便。
  • 事务管理 - 提供了用于事务管理的通用抽象层。Spring 的事务支持也可用于容器较少的环境。
  • JDBC 异常 - Spring 的 JDBC 抽象层提供了一个异常层次结构,简化了错误处理策略。

Spring Framework 中有多少个模块,它们分别是什么?

img

  • Spring 核心容器 – 该层基本上是 Spring Framework 的核心。它包含以下模块:
    • Spring Core
    • Spring Bean
    • SpEL (Spring Expression Language)
    • Spring Context
  • 数据访问/集成 – 该层提供与数据库交互的支持。它包含以下模块:
    • JDBC (Java DataBase Connectivity)
    • ORM (Object Relational Mapping)
    • OXM (Object XML Mappers)
    • JMS (Java Messaging Service)
    • Transaction
  • Web – 该层提供了创建 Web 应用程序的支持。它包含以下模块:
    • Web
    • Web – Servlet
    • Web – Socket
    • Web – Portlet
  • AOP – 该层支持面向切面编程
  • Instrumentation – 该层为类检测和类加载器实现提供支持。
  • Test – 该层为使用 JUnit 和 TestNG 进行测试提供支持。
  • 几个杂项模块:
    • Messaging – 该模块为 STOMP 提供支持。它还支持注解编程模型,该模型用于从 WebSocket 客户端路由和处理 STOMP 消息。
    • Aspects – 该模块为与 AspectJ 的集成提供支持。

什么是 Spring 配置文件?

Spring 配置文件是 XML 文件。该文件主要包含类信息。它描述了这些类是如何配置以及相互引入的。但是,XML 配置文件冗长且更加干净。如果没有正确规划和编写,那么在大项目中管理变得非常困难。

Spring 应用程序有哪些不同组件?

Spring 应用一般有以下组件:

  • 接口 - 定义功能。
  • Bean 类 - 它包含属性,setter 和 getter 方法,函数等。
  • Spring 面向切面编程(AOP) - 提供面向切面编程的功能。
  • Bean 配置文件 - 包含类的信息以及如何配置它们。
  • 用户程序 - 它使用接口。

使用 Spring 有哪些方式?

使用 Spring 有以下方式:

  • 作为一个成熟的 Spring Web 应用程序。
  • 作为第三方 Web 框架,使用 Spring Frameworks 中间层。
  • 用于远程使用。
  • 作为企业级 Java Bean,它可以包装现有的 POJO(Plain Old Java Objects)。

核心篇

IoC

什么是 IoC?什么是依赖注入?什么是 Spring IoC?

IoC控制反转(Inversion of Control,缩写为 IoC)。IoC 又称为依赖倒置原则(设计模式六大原则之一),它的要点在于:程序要依赖于抽象接口,不要依赖于具体实现。它的作用就是用于降低代码间的耦合度

IoC 的实现方式有两种:

  • 依赖注入(Dependency Injection,简称 DI):不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造器、函数参数等方式传递(或注入)给类使用。
  • 依赖查找(Dependency Lookup):容器中的受控对象通过容器的 API 来查找自己所依赖的资源和协作对象。

Spring IoC 是 IoC 的一种实现。DI 是 Spring IoC 的主要实现原则。

依赖注入有哪些实现方式?

依赖注入有如下方式:

依赖注入方式 配置元数据举例
Setter 方法注入 <proeprty name="user" ref="userBean"/>
构造器注入 <constructor-arg name="user" ref="userBean" />
字段注入 @Autowired User user;
方法注入 @Autowired public void user(User user) { ... }
接口回调注入 class MyBean implements BeanFactoryAware { ... }

构造器注入 VS. setter 注入

构造器注入 setter 注入
没有部分注入 有部分注入
不会覆盖 setter 属性 会覆盖 setter 属性
任意修改都会创建一个新实例 任意修改不会创建一个新实例
适用于设置很多属性 适用于设置少量属性

官方推荐使用构造器注入。

BeanFactory VS. ApplicationContext

在 Spring 中,有两种 IoC 容器:BeanFactoryApplicationContext

  • BeanFactory:**BeanFactory 是 Spring 基础 IoC 容器**。BeanFactory 提供了 Spring 容器的配置框架和基本功能。
  • ApplicationContext:**ApplicationContext 是具备应用特性的 BeanFactory 的子接口**。它还扩展了其他一些接口,以支持更丰富的功能,如:国际化、访问资源、事件机制、更方便的支持 AOP、在 web 应用中指定应用层上下文等。

实际开发中,更推荐使用 ApplicationContext 作为 IoC 容器,因为它的功能远多于 BeanFactory

BeanFactory VS. FactoryBean

BeanFactory 是 Spring 基础 IoC 容器

FactoryBean 是创建 Bean 的一种方式,帮助实现复杂的初始化逻辑。

Spring IoC 启动时做了哪些准备

IoC 配置元信息读取和解析

IoC 容器生命周期管理

Spring 事件发布

国际化

等等

Spring IoC 的实现机制是什么

Spring 中的 IoC 的实现原理就是工厂模式加反射机制。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
interface Fruit {
public abstract void eat();
}
class Apple implements Fruit {
public void eat(){
System.out.println("Apple");
}
}
class Orange implements Fruit {
public void eat(){
System.out.println("Orange");
}
}
class Factory {
public static Fruit getInstance(String ClassName) {
Fruit f=null;
try {
f=(Fruit)Class.forName(ClassName).newInstance();
} catch (Exception e) {
e.printStackTrace();
}
return f;
}
}
class Client {
public static void main(String[] a) {
Fruit f=Factory.getInstance("io.github.dunwu.spring.Apple");
if(f!=null){
f.eat();
}
}
}

Bean

什么是 Spring Bean

在 Spring 中,构成应用程序主体由 Spring IoC 容器管理的对象称为 Bean。Bean 是由 Spring IoC 容器实例化、装配和管理的对象。 Bean 以及它们之间的依赖关系反映在容器使用的配置元数据中。

Spring IoC 容器本身,并不能识别配置的元数据。为此,要将这些配置信息转为 Spring 能识别的格式——BeanDefinition 对象。

BeanDefinition 是 Spring 中定义 Bean 的配置元信息接口,它包含:

  • Bean 类名
  • Bean 行为配置元素,如:作用域、自动绑定的模式、生命周期回调等
  • 其他 Bean 引用,也可称为合作者(Collaborators)或依赖(Dependencies)
  • 配置设置,如 Bean 属性(Properties)

如何注册 Spring Bean

通过 BeanDefinition 和外部单例对象来注册。

spring 提供了哪些配置方式?

  • 基于 xml 配置

bean 所需的依赖项和服务在 XML 格式的配置文件中指定。这些配置文件通常包含许多 bean 定义和特定于应用程序的配置选项。它们通常以 bean 标签开头。例如:

1
2
3
<bean id="studentbean" class="org.edureka.firstSpring.StudentBean">
<property name="name" value="Edureka"></property>
</bean>
  • 基于注解配置

您可以通过在相关的类,方法或字段声明上使用注解,将 bean 配置为组件类本身,而不是使用 XML 来描述 bean 装配。默认情况下,Spring 容器中未打开注解装配。因此,您需要在使用它之前在 Spring 配置文件中启用它。例如:

1
2
3
4
<beans>
<context:annotation-config/>
<!-- bean definitions go here -->
</beans>
  • 基于 Java API 配置

Spring 的 Java 配置是通过使用 @Bean 和 @Configuration 来实现。

  1. @Bean 注解扮演与 <bean /> 元素相同的角色。
  2. @Configuration 类允许通过简单地调用同一个类中的其他 @Bean 方法来定义 bean 间依赖关系。

例如:

1
2
3
4
5
6
7
@Configuration
public class StudentConfig {
@Bean
public StudentBean myStudent() {
return new StudentBean();
}
}

spring 支持集中 bean scope?

Spring bean 支持 5 种 scope:

  • Singleton - 每个 Spring IoC 容器仅有一个单实例。
  • Prototype - 每次请求都会产生一个新的实例。
  • Request - 每一次 HTTP 请求都会产生一个新的实例,并且该 bean 仅在当前 HTTP 请求内有效。
  • Session - 每一次 HTTP 请求都会产生一个新的 bean,同时该 bean 仅在当前 HTTP session 内有效。
  • Global-session - 类似于标准的 HTTP Session 作用域,不过它仅仅在基于 portlet 的 web 应用中才有意义。Portlet 规范定义了全局 Session 的概念,它被所有构成某个 portlet web 应用的各种不同的 portlet 所共享。在 global session 作用域中定义的 bean 被限定于全局 portlet Session 的生命周期范围内。如果你在 web 中使用 global session 作用域来标识 bean,那么 web 会自动当成 session 类型来使用。

仅当用户使用支持 Web 的 ApplicationContext 时,最后三个才可用。

Spring Bean 的生命周期

spring bean 容器的生命周期如下:

  1. Spring 对 Bean 进行实例化(相当于 new XXX())

  2. Spring 将值和引用注入到 Bean 对应的属性中

  3. 如果 Bean 实现了 BeanNameAware 接口,Spring 将 Bean 的 ID 传递给 setBeanName 方法

    • 作用是通过 Bean 的引用来获得 Bean ID,一般业务中是很少有用到 Bean 的 ID 的
  4. 如果 Bean 实现了 BeanFactoryAware 接口,Spring 将调用 setBeanDactory 方法,并把 BeanFactory 容器实例作为参数传入。

    • 作用是获取 Spring 容器,如 Bean 通过 Spring 容器发布事件等
  5. 如果 Bean 实现了 ApplicationContextAware 接口,Spring 容器将调用 setApplicationContext 方法,把应用上下文作为参数传入

    • 作用与 BeanFactory 类似都是为了获取 Spring 容器,不同的是 Spring 容器在调用 setApplicationContext 方法时会把它自己作为 setApplicationContext 的参数传入,而 Spring 容器在调用 setBeanFactory 前需要使用者自己指定(注入)setBeanFactory 里的参数 BeanFactory
  6. 如果 Bean 实现了 BeanPostProcess 接口,Spring 将调用 postProcessBeforeInitialization 方法

    • 作用是在 Bean 实例创建成功后对其进行增强处理,如对 Bean 进行修改,增加某个功能
  7. 如果 Bean 实现了 InitializingBean 接口,Spring 将调用 afterPropertiesSet 方法,作用与在配置文件中对 Bean 使用 init-method 声明初始化的作用一样,都是在 Bean 的全部属性设置成功后执行的初始化方法。

  8. 如果 Bean 实现了 BeanPostProcess 接口,Spring 将调用 postProcessAfterInitialization 方法

    • postProcessBeforeInitialization 是在 Bean 初始化前执行的,而 postProcessAfterInitialization 是在 Bean 初始化后执行的
  9. 经过以上的工作后,Bean 将一直驻留在应用上下文中给应用使用,直到应用上下文被销毁

  10. 如果 Bean 实现了 DispostbleBean 接口,Spring 将调用它的 destory 方法,作用与在配置文件中对 Bean 使用 destory-method 属性的作用一样,都是在 Bean 实例销毁前执行的方法。

什么是 spring 的内部 bean?

只有将 bean 用作另一个 bean 的属性时,才能将 bean 声明为内部 bean。为了定义 bean,Spring 的基于 XML 的配置元数据在 <property><constructor-arg> 中提供了 <bean> 元素的使用。内部 bean 总是匿名的,它们总是作为原型。

例如,假设我们有一个 Student 类,其中引用了 Person 类。这里我们将只创建一个 Person 类实例并在 Student 中使用它。

Student.java

1
2
3
4
5
6
7
8
9
public class Student {
private Person person;
//Setters and Getters
}
public class Person {
private String name;
private String address;
//Setters and Getters
}

bean.xml

1
2
3
4
5
6
7
8
9
<bean id=“StudentBean" class="com.edureka.Student">
<property name="person">
<!--This is inner bean -->
<bean class="com.edureka.Person">
<property name="name" value=“Scott"></property>
<property name="address" value=“Bangalore"></property>
</bean>
</property>
</bean>

什么是 spring 装配

当 bean 在 Spring 容器中组合在一起时,它被称为装配或 bean 装配。 Spring 容器需要知道需要什么 bean 以及容器应该如何使用依赖注入来将 bean 绑定在一起,同时装配 bean。

自动装配有哪些方式?

Spring 容器能够自动装配 bean。也就是说,可以通过检查 BeanFactory 的内容让 Spring 自动解析 bean 的协作者。

自动装配的不同模式:

  • no - 这是默认设置,表示没有自动装配。应使用显式 bean 引用进行装配。
  • byName - 它根据 bean 的名称注入对象依赖项。它匹配并装配其属性与 XML 文件中由相同名称定义的 bean。
  • byType - 它根据类型注入对象依赖项。如果属性的类型与 XML 文件中的一个 bean 名称匹配,则匹配并装配属性。
  • 构造器 - 它通过调用类的构造器来注入依赖项。它有大量的参数。
  • autodetect - 首先容器尝试通过构造器使用 autowire 装配,如果不能,则尝试通过 byType 自动装配。

自动装配有什么局限?

  • 覆盖的可能性 - 您始终可以使用 <constructor-arg><property> 设置指定依赖项,这将覆盖自动装配。
  • 基本元数据类型 - 简单属性(如原数据类型,字符串和类)无法自动装配。
  • 令人困惑的性质 - 总是喜欢使用明确的装配,因为自动装配不太精确。

AOP

什么是 AOP?

AOP(Aspect-Oriented Programming), 即 面向切面编程, 它与 OOP( Object-Oriented Programming, 面向对象编程) 相辅相成, 提供了与 OOP 不同的抽象软件结构的视角.
在 OOP 中, 我们以类(class)作为我们的基本单元, 而 AOP 中的基本单元是 Aspect(切面)

AOP 中的 Aspect、Advice、Pointcut、JointPoint 和 Advice 参数分别是什么?

img

  • Aspect - Aspect 是一个实现交叉问题的类,例如事务管理。方面可以是配置的普通类,然后在 Spring Bean 配置文件中配置,或者我们可以使用 Spring AspectJ 支持使用 @Aspect 注解将类声明为 Aspect。
  • Advice - Advice 是针对特定 JoinPoint 采取的操作。在编程方面,它们是在应用程序中达到具有匹配切入点的特定 JoinPoint 时执行的方法。您可以将 Advice 视为 Spring 拦截器(Interceptor)或 Servlet 过滤器(filter)。
  • Advice Arguments - 我们可以在 advice 方法中传递参数。我们可以在切入点中使用 args() 表达式来应用于与参数模式匹配的任何方法。如果我们使用它,那么我们需要在确定参数类型的 advice 方法中使用相同的名称。
  • Pointcut - Pointcut 是与 JoinPoint 匹配的正则表达式,用于确定是否需要执行 Advice。 Pointcut 使用与 JoinPoint 匹配的不同类型的表达式。Spring 框架使用 AspectJ Pointcut 表达式语言来确定将应用通知方法的 JoinPoint。
  • JoinPoint - JoinPoint 是应用程序中的特定点,例如方法执行,异常处理,更改对象变量值等。在 Spring AOP 中,JoinPoint 始终是方法的执行器。

什么是通知(Advice)?

特定 JoinPoint 处的 Aspect 所采取的动作称为 Advice。Spring AOP 使用一个 Advice 作为拦截器,在 JoinPoint “周围”维护一系列的拦截器。

有哪些类型的通知(Advice)?

  • Before - 这些类型的 Advice 在 joinpoint 方法之前执行,并使用 @Before 注解标记进行配置。
  • After Returning - 这些类型的 Advice 在连接点方法正常执行后执行,并使用@AfterReturning 注解标记进行配置。
  • After Throwing - 这些类型的 Advice 仅在 joinpoint 方法通过抛出异常退出并使用 @AfterThrowing 注解标记配置时执行。
  • After (finally) - 这些类型的 Advice 在连接点方法之后执行,无论方法退出是正常还是异常返回,并使用 @After 注解标记进行配置。
  • Around - 这些类型的 Advice 在连接点之前和之后执行,并使用 @Around 注解标记进行配置。

指出在 spring aop 中 concern 和 cross-cutting concern 的不同之处。

concern 是我们想要在应用程序的特定模块中定义的行为。它可以定义为我们想要实现的功能。

cross-cutting concern 是一个适用于整个应用的行为,这会影响整个应用程序。例如,日志记录,安全性和数据传输是应用程序几乎每个模块都需要关注的问题,因此它们是跨领域的问题。

AOP 有哪些实现方式?

实现 AOP 的技术,主要分为两大类:

  • 静态代理 - 指使用 AOP 框架提供的命令进行编译,从而在编译阶段就可生成 AOP 代理类,因此也称为编译时增强;
    • 编译时编织(特殊编译器实现)
    • 类加载时编织(特殊的类加载器实现)。
  • 动态代理 - 在运行时在内存中“临时”生成 AOP 动态代理类,因此也被称为运行时增强。
    • JDK 动态代理
    • CGLIB

Spring AOP and AspectJ AOP 有什么区别?

Spring AOP 基于动态代理方式实现;AspectJ 基于静态代理方式实现。
Spring AOP 仅支持方法级别的 PointCut;提供了完全的 AOP 支持,它还支持属性级别的 PointCut。

如何理解 Spring 中的代理?

将 Advice 应用于目标对象后创建的对象称为代理。在客户端对象的情况下,目标对象和代理对象是相同的。

1
Advice + Target Object = Proxy

什么是编织(Weaving)?

为了创建一个 advice 对象而链接一个 aspect 和其它应用类型或对象,称为编织(Weaving)。在 Spring AOP 中,编织在运行时执行。请参考下图:

img

注解

你用过哪些重要的 Spring 注解?

  • @Controller - 用于 Spring MVC 项目中的控制器类。
  • @Service - 用于服务类。
  • @RequestMapping - 用于在控制器处理程序方法中配置 URI 映射。
  • @ResponseBody - 用于发送 Object 作为响应,通常用于发送 XML 或 JSON 数据作为响应。
  • @PathVariable - 用于将动态值从 URI 映射到处理程序方法参数。
  • @Autowired - 用于在 spring bean 中自动装配依赖项。
  • @Qualifier - 使用 @Autowired 注解,以避免在存在多个 bean 类型实例时出现混淆。
  • @Scope - 用于配置 spring bean 的范围。
  • @Configuration,**@ComponentScan** 和 @Bean - 用于基于 java 的配置。
  • @Aspect,**@Before@After@Around@Pointcut** - 用于切面编程(AOP)。

如何在 spring 中启动注解装配?

默认情况下,Spring 容器中未打开注解装配。因此,要使用基于注解装配,我们必须通过配置<context:annotation-config /> 元素在 Spring 配置文件中启用它。

@Component, @Controller, @Repository, @Service 有何区别?

  • @Component:这将 java 类标记为 bean。它是任何 Spring 管理组件的通用构造型。spring 的组件扫描机制现在可以将其拾取并将其拉入应用程序环境中。
  • @Controller:这将一个类标记为 Spring Web MVC 控制器。标有它的 Bean 会自动导入到 IoC 容器中。
  • @Service:此注解是组件注解的特化。它不会对 @Component 注解提供任何其他行为。您可以在服务层类中使用 @Service 而不是 @Component,因为它以更好的方式指定了意图。
  • @Repository:这个注解是具有类似用途和功能的 @Component 注解的特化。它为 DAO 提供了额外的好处。它将 DAO 导入 IoC 容器,并使未经检查的异常有资格转换为 Spring DataAccessException。

@Required 注解有什么用?

@Required 应用于 bean 属性 setter 方法。此注解仅指示必须在配置时使用 bean 定义中的显式属性值或使用自动装配填充受影响的 bean 属性。如果尚未填充受影响的 bean 属性,则容器将抛出 BeanInitializationException。

示例:

1
2
3
4
5
6
7
8
9
10
public class Employee {
private String name;
@Required
public void setName(String name){
this.name=name;
}
public string getName(){
return name;
}
}

@Autowired 注解有什么用?

@Autowired 可以更准确地控制应该在何处以及如何进行自动装配。此注解用于在 setter 方法,构造器,具有任意名称或多个参数的属性或方法上自动装配 bean。默认情况下,它是类型驱动的注入。

1
2
3
4
5
6
7
8
9
10
public class Employee {
private String name;
@Autowired
public void setName(String name) {
this.name=name;
}
public string getName(){
return name;
}
}

@Qualifier 注解有什么用?

当您创建多个相同类型的 bean 并希望仅使用属性装配其中一个 bean 时,您可以使用@Qualifier 注解和 @Autowired 通过指定应该装配哪个确切的 bean 来消除歧义。

例如,这里我们分别有两个类,Employee 和 EmpAccount。在 EmpAccount 中,使用@Qualifier 指定了必须装配 id 为 emp1 的 bean。

Employee.java

1
2
3
4
5
6
7
8
9
10
public class Employee {
private String name;
@Autowired
public void setName(String name) {
this.name=name;
}
public string getName() {
return name;
}
}

EmpAccount.java

1
2
3
4
5
6
7
8
9
public class EmpAccount {
private Employee emp;

@Autowired
@Qualifier(emp1)
public void showName() {
System.out.println(“Employee name : ”+emp.getName);
}
}

@RequestMapping 注解有什么用?

@RequestMapping 注解用于将特定 HTTP 请求方法映射到将处理相应请求的控制器中的特定类/方法。此注解可应用于两个级别:

  • 类级别:映射请求的 URL
  • 方法级别:映射 URL 以及 HTTP 请求方法

数据篇

spring DAO 有什么用?

Spring DAO 使得 JDBC,Hibernate 或 JDO 这样的数据访问技术更容易以一种统一的方式工作。这使得用户容易在持久性技术之间切换。它还允许您在编写代码时,无需考虑捕获每种技术不同的异常。

列举 Spring DAO 抛出的异常。

img

spring JDBC API 中存在哪些类?

  • JdbcTemplate
  • SimpleJdbcTemplate
  • NamedParameterJdbcTemplate
  • SimpleJdbcInsert
  • SimpleJdbcCall

使用 Spring 访问 Hibernate 的方法有哪些?

我们可以通过两种方式使用 Spring 访问 Hibernate:

  1. 使用 Hibernate 模板和回调进行控制反转
  2. 扩展 HibernateDAOSupport 并应用 AOP 拦截器节点

列举 spring 支持的事务管理类型

Spring 支持两种类型的事务管理:

  1. 程序化事务管理:在此过程中,在编程的帮助下管理事务。它为您提供极大的灵活性,但维护起来非常困难。
  2. 声明式事务管理:在此,事务管理与业务代码分离。仅使用注解或基于 XML 的配置来管理事务。

spring 支持哪些 ORM 框架

  • Hibernate
  • iBatis
  • JPA
  • JDO
  • OJB

MVC

Spring MVC 框架有什么用?

Spring Web MVC 框架提供 模型-视图-控制器 架构和随时可用的组件,用于开发灵活且松散耦合的 Web 应用程序。 MVC 模式有助于分离应用程序的不同方面,如输入逻辑,业务逻辑和 UI 逻辑,同时在所有这些元素之间提供松散耦合。

描述一下 DispatcherServlet 的工作流程

DispatcherServlet 的工作流程可以用一幅图来说明:

img

  1. 向服务器发送 HTTP 请求,请求被前端控制器 DispatcherServlet 捕获。
  2. DispatcherServlet 根据 <servlet-name>-servlet.xml 中的配置对请求的 URL 进行解析,得到请求资源标识符(URI)。然后根据该 URI,调用 HandlerMapping 获得该 Handler 配置的所有相关的对象(包括 Handler 对象以及 Handler 对象对应的拦截器),最后以HandlerExecutionChain 对象的形式返回。
  3. DispatcherServlet 根据获得的Handler,选择一个合适的 HandlerAdapter。(附注:如果成功获得HandlerAdapter后,此时将开始执行拦截器的 preHandler(…)方法)。
  4. 提取Request中的模型数据,填充Handler入参,开始执行HandlerController)。 在填充Handler的入参过程中,根据你的配置,Spring 将帮你做一些额外的工作:
    • HttpMessageConveter: 将请求消息(如 Json、xml 等数据)转换成一个对象,将对象转换为指定的响应信息。
    • 数据转换:对请求消息进行数据转换。如String转换成IntegerDouble等。
    • 数据根式化:对请求消息进行数据格式化。 如将字符串转换成格式化数字或格式化日期等。
    • 数据验证: 验证数据的有效性(长度、格式等),验证结果存储到BindingResultError中。
  5. Handler(Controller)执行完成后,向 DispatcherServlet 返回一个 ModelAndView 对象;
  6. 根据返回的ModelAndView,选择一个适合的 ViewResolver(必须是已经注册到 Spring 容器中的ViewResolver)返回给DispatcherServlet
  7. ViewResolver 结合ModelView,来渲染视图。
  8. 视图负责将渲染结果返回给客户端。

介绍一下 WebApplicationContext

WebApplicationContext 是 ApplicationContext 的扩展。它具有 Web 应用程序所需的一些额外功能。它与普通的 ApplicationContext 在解析主题和决定与哪个 servlet 关联的能力方面有所不同。

(完)


:point_right: 想学习更多 Spring 内容可以访问我的 Spring 教程:**spring-notes**

资料

大型系统核心技术

大型系统的设计目标就是为了快速、高效、稳定的处理海量的数据以及高并发的请求。

单机服务受限于硬件,客观存在着资源瓶颈,难以应对不断增长的数据量和请求量,为了打破瓶颈,大型系统基本上都被设计为分布式系统。

分布式系统由于其面临的共性问题,在很多场景下的解决方案往往也存在着共性。因此,我们会发现,很多优秀的大型系统在设计方案上存在着很多的共同点。

本文主要讨论应对分布式系统共性问题的解决方案,这既可以加深对分布式系统运作原理的理解,也可以作为设计大型分布式系统时的借鉴。

分布式事务

分布式锁

Java 原生 API 虽然有并发锁,但并没有提供分布式锁的能力,所以针对分布式场景中的锁需要解决的方案。

分布式锁的解决方案大致有以下几种:

  • 基于数据库实现
  • 基于缓存(redis,memcached 等)实现
  • 基于 Zookeeper 实现

基于数据库实现分布式锁

实现

创建表
1
2
3
4
5
6
7
8
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
获取锁

想要锁住某个方法时,执行以下 SQL:

1
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)

因为我们对 method_name 做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

成功插入则获取锁。

释放锁

当方法执行完毕之后,想要释放锁的话,需要执行以下 Sql:

1
delete from methodLock where method_name ='method_name'

问题

  1. 这把锁强依赖数据库的可用性。如果数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  2. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  3. 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  4. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

解决办法

  1. 单点问题可以用多数据库实例,同时塞 N 个表,N/2+1 个成功就任务锁定成功
  2. 写一个定时任务,隔一段时间清除一次过期的数据。
  3. 写一个 while 循环,不断的重试插入,直到成功。
  4. 在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

小结

  • 优点: 直接借助数据库,容易理解。
  • 缺点: 会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。操作数据库需要一定的开销,性能问题需要考虑。

基于 Redis 实现分布式锁

相比于用数据库来实现分布式锁,基于缓存实现的分布式锁的性能会更好一些。目前有很多成熟的分布式产品,包括 Redis、memcache、Tair 等。这里以 Redis 举例。

Redis 命令

  • setnx - setnx key val:当且仅当 key 不存在时,set 一个 key 为 val 的字符串,返回 1;若 key 存在,则什么都不做,返回 0。
  • expire - expire key timeout:为 key 设置一个超时时间,单位为 second,超过这个时间锁会自动释放,避免死锁。
  • delete - delete key:删除 key

实现

单点实现步骤:

  1. 获取锁的使用,使用 setnx 加锁,锁的 value 值为一个随机生成的 UUID,再使用 expire 设置一个过期值。
  2. 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
  3. 释放锁的时候,通过 UUID 判断是不是该锁,若是该锁,则执行 delete 进行锁释放。

问题

  • 单点问题。如果单机 redis 挂掉了,那么程序会跟着出错。
  • 如果转移使用 slave 节点,复制不是同步复制,会出现多个程序获取锁的情况

小结

可以考虑使用 redisson 的解决方案

基于 ZooKeeper 实现分布式锁

实现

这也是 ZooKeeper 客户端 curator 的分布式锁实现。

  1. 创建一个目录 mylock;
  2. 线程 A 想获取锁就在 mylock 目录下创建临时顺序节点;
  3. 获取 mylock 目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
  4. 线程 B 获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
  5. 线程 A 处理完,删除自己的节点,线程 B 监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

小结

ZooKeeper 版本的分布式锁问题相对比较来说少。

  • 锁的占用时间限制:redis 就有占用时间限制,而 ZooKeeper 则没有,最主要的原因是 redis 目前没有办法知道已经获取锁的客户端的状态,是已经挂了呢还是正在执行耗时较长的业务逻辑。而 ZooKeeper 通过临时节点就能清晰知道,如果临时节点存在说明还在执行业务逻辑,如果临时节点不存在说明已经执行完毕释放锁或者是挂了。由此看来 redis 如果能像 ZooKeeper 一样添加一些与客户端绑定的临时键,也是一大好事。
  • 是否单点故障:redis 本身有很多中玩法,如客户端一致性 hash,服务器端 sentinel 方案或者 cluster 方案,很难做到一种分布式锁方式能应对所有这些方案。而 ZooKeeper 只有一种玩法,多台机器的节点数据是一致的,没有 redis 的那么多的麻烦因素要考虑。

总体上来说 ZooKeeper 实现分布式锁更加的简单,可靠性更高。但 ZooKeeper 因为需要频繁的创建和删除节点,性能上不如 Redis 方式。

分布式 Session

在分布式场景下,一个用户的 Session 如果只存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器上,该服务器没有用户的 Session,就可能导致用户需要重新进行登录等操作。

分布式 Session 的几种实现策略:

  1. 粘性 session
  2. 应用服务器间的 session 复制共享
  3. 基于 cache DB 缓存的 session 共享

Sticky Sessions

需要配置负载均衡器,使得一个用户的所有请求都路由到一个服务器节点上,这样就可以把用户的 Session 存放在该服务器节点中。

缺点:当服务器节点宕机时,将丢失该服务器节点上的所有 Session。

Session Replication

在服务器节点之间进行 Session 同步操作,这样的话用户可以访问任何一个服务器节点。

缺点:占用过多内存;同步过程占用网络带宽以及服务器处理器时间。

Session Server

使用一个单独的服务器存储 Session 数据,可以存在 MySQL 数据库上,也可以存在 Redis 或者 Memcached 这种内存型数据库。

缺点:需要去实现存取 Session 的代码。

分布式存储

通常有两种解决方案:

  1. 数据分布:就是把数据分块存在不同的服务器上(分库分表)。
  2. 数据复制:让所有的服务器都有相同的数据,提供相当的服务。

分布式缓存

使用缓存的好处:

  • 提升数据读取速度
  • 提升系统扩展能力,通过扩展缓存,提升系统承载能力
  • 降低存储成本,Cache+DB 的方式可以承担原有需要多台 DB 才能承担的请求量,节省机器成本

根据业务场景,通常缓存有以下几种使用方式

  • 懒汉式(读时触发):写入 DB 后, 然后把相关的数据也写入 Cache
  • 饥饿式(写时触发):先查询 DB 里的数据, 然后把相关的数据写入 Cache
  • 定期刷新:适合周期性的跑数据的任务,或者列表型的数据,而且不要求绝对实时性

缓存分类:

  • 应用内缓存:如:EHCache
  • 分布式缓存:如:Memached、Redis

分布式计算

负载均衡

算法

轮询(Round Robin)

轮询算法把每个请求轮流发送到每个服务器上。下图中,一共有 6 个客户端产生了 6 个请求,这 6 个请求按 (1, 2, 3, 4, 5, 6) 的顺序发送。最后,(1, 3, 5) 的请求会被发送到服务器 1,(2, 4, 6) 的请求会被发送到服务器 2。

该算法比较适合每个服务器的性能差不多的场景,如果有性能存在差异的情况下,那么性能较差的服务器可能无法承担过大的负载(下图的 Server 2)。

加权轮询(Weighted Round Robbin)

加权轮询是在轮询的基础上,根据服务器的性能差异,为服务器赋予一定的权值。例如下图中,服务器 1 被赋予的权值为 5,服务器 2 被赋予的权值为 1,那么 (1, 2, 3, 4, 5) 请求会被发送到服务器 1,(6) 请求会被发送到服务器 2。

最少连接(least Connections)

由于每个请求的连接时间不一样,使用轮询或者加权轮询算法的话,可能会让一台服务器当前连接数过大,而另一台服务器的连接过小,造成负载不均衡。例如下图中,(1, 3, 5) 请求会被发送到服务器 1,但是 (1, 3) 很快就断开连接,此时只有 (5) 请求连接服务器 1;(2, 4, 6) 请求被发送到服务器 2,只有 (2) 的连接断开。该系统继续运行时,服务器 2 会承担过大的负载。

最少连接算法就是将请求发送给当前最少连接数的服务器上。例如下图中,服务器 1 当前连接数最小,那么新到来的请求 6 就会被发送到服务器 1 上。

加权最少连接(Weighted Least Connection)

在最少连接的基础上,根据服务器的性能为每台服务器分配权重,再根据权重计算出每台服务器能处理的连接数。

随机算法(Random)

把请求随机发送到服务器上。和轮询算法类似,该算法比较适合服务器性能差不多的场景。

源地址哈希法 (IP Hash)

源地址哈希通过对客户端 IP 哈希计算得到的一个数值,用该数值对服务器数量进行取模运算,取模结果便是目标服务器的序号。

  • 优点:保证同一 IP 的客户端都会被 hash 到同一台服务器上。
  • 缺点:不利于集群扩展,后台服务器数量变更都会影响 hash 结果。可以采用一致性 Hash 改进。

实现

HTTP 重定向

HTTP 重定向负载均衡服务器收到 HTTP 请求之后会返回服务器的地址,并将该地址写入 HTTP 重定向响应中返回给浏览器,浏览器收到后需要再次发送请求。

缺点:

  • 用户访问的延迟会增加;
  • 如果负载均衡器宕机,就无法访问该站点。

DNS 重定向

使用 DNS 作为负载均衡器,根据负载情况返回不同服务器的 IP 地址。大型网站基本使用了这种方式做为第一级负载均衡手段,然后在内部使用其它方式做第二级负载均衡。

缺点:

  • DNS 查找表可能会被客户端缓存起来,那么之后的所有请求都会被重定向到同一个服务器。

修改 MAC 地址

使用 LVS(Linux Virtual Server)这种链路层负载均衡器,根据负载情况修改请求的 MAC 地址。

修改 IP 地址

在网络层修改请求的目的 IP 地址。

代理自动配置

正向代理与反向代理的区别:

  • 正向代理:发生在客户端,是由用户主动发起的。比如翻墙,客户端通过主动访问代理服务器,让代理服务器获得需要的外网数据,然后转发回客户端。
  • 反向代理:发生在服务器端,用户不知道代理的存在。

PAC 服务器是用来判断一个请求是否要经过代理。

资料