This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
<audio id="audio" title="04 | 理论一:当谈论面向对象的时候,我们到底在谈论什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e6/64/e6ae5e1b96d371ba3caa6f84c6d22764.mp3"></audio>
考虑到各个水平层次的同学,并且保证专栏内容的系统性、全面性,我会循序渐进地讲解跟设计模式相关的所有内容。所以,专栏正文的第一个模块,我会讲一些设计原则、设计思想,比如,面向对象设计思想、经典设计原则以及重构相关的知识,为之后学习设计模式做铺垫。
在第一个模块中,我们又首先会讲到面向对象相关的理论知识。提到面向对象,我相信很多人都不陌生,随口都可以说出面向对象的四大特性:封装、抽象、继承、多态。实际上,面向对象这个概念包含的内容还不止这些。所以,今天我打算花一节课的时间,先大概跟你聊一下,当我们谈论面向对象的时候,经常会谈到的一些概念和知识点,为学习后面的几节更加细化的内容做一个铺垫。
特别说明一下,对于今天讲到的概念和知识点,大部分我都是点到为止,并没有展开详细讲解。如果你看了之后,对某个概念和知识点还不是很清楚,那也没有关系。在后面的几节课中,我会花更多的篇幅,对今天讲到的每个概念和知识点,结合具体的例子,一一做详细的讲解。
## 什么是面向对象编程和面向对象编程语言?
面向对象编程的英文缩写是OOP全称是Object Oriented Programming。对应地面向对象编程语言的英文缩写是OOPL全称是Object Oriented Programming Language。
面向对象编程中有两个非常重要、非常基础的概念那就是类class和对象object。这两个概念最早出现在1960年在Simula这种编程语言中第一次使用。而面向对象编程这个概念第一次被使用是在Smalltalk这种编程语言中。Smalltalk被认为是第一个真正意义上的面向对象编程语言。
1980年左右C++的出现带动了面向对象编程的流行也使得面向对象编程被越来越多的人认可。直到今天如果不按照严格的定义来说大部分编程语言都是面向对象编程语言比如Java、C++、Go、Python、C#、Ruby、JavaScript、Objective-C、Scala、PHP、Perl等等。除此之外大部分程序员在开发项目的时候都是基于面向对象编程语言进行的面向对象编程。
以上是面向对象编程的大概发展历史。在刚刚的描述中,我着重提到了两个概念,面向对象编程和面向对象编程语言。那究竟什么是面向对象编程?什么语言才算是面向对象编程语言呢?如果非得给出一个定义的话,我觉得可以用下面两句话来概括。
- 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
- 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
一般来讲, 面向对象编程都是通过使用面向对象编程语言来进行的但是不用面向对象编程语言我们照样可以进行面向对象编程。反过来讲即便我们使用面向对象编程语言写出来的代码也不一定是面向对象编程风格的也有可能是面向过程编程风格的。这里听起来是不是有点绕不过没关系我们在后面的第7节课中会详细讲解这个问题。
除此之外,从定义中,我们还可以发现,理解面向对象编程及面向对象编程语言两个概念,其中最关键的一点就是理解面向对象编程的四大特性。这四大特性分别是:封装、抽象、继承、多态。不过,关于面向对象编程的特性,也有另外一种说法,那就是只包含三大特性:封装、继承、多态,不包含抽象。为什么会有这种分歧呢?抽象为什么可以排除在面向对象编程特性之外呢?关于这个问题,在下一节课详细讲解这四大特性的时候,我还会再拿出来说一下。不过,话说回来,实际上,我们没必要纠结到底是四大特性还是三大特性,关键还是理解每种特性讲的是什么内容、存在的意义以及能解决什么问题。
而且在技术圈里封装、抽象、继承、多态也并不是固定地被叫作“四大特性”features也有人称它们为面向对象编程的四大概念concepts、四大基石cornerstones、四大基础fundamentals、四大支柱pillars等等。你也发现了吧叫法挺混乱的。不过叫什么并不重要。我们只需要知道这是前人进行面向对象编程过程中总结出来的、能让我们更容易地实现各种设计思路的几个编程套路这就够了。在之后的课程讲解中我统一把它们叫作“四大特性”。
## 如何判定某编程语言是否是面向对象编程语言?
如果你足够细心你可能已经留意到在我刚刚的讲解中我提到“如果不按照严格的定义来说大部分编程语言都是面向对象编程语言”。为什么要加上“如果不按照严格的定义”这个前提呢那是因为如果按照刚刚我们给出的严格的面向对象编程语言的定义前面提到的有些编程语言并不是严格意义上的面向对象编程语言比如JavaScript它不支持封装和继承特性按照严格的定义它不算是面向对象编程语言但在某种意义上它又可以算得上是一种面向对象编程语言。我为什么这么说呢到底该如何判断一个编程语言是否是面向对象编程语言呢
还记得我们前面给出的面向对象编程及面向对象编程语言的定义吗如果忘记了你可以先翻到上面回顾一下。不过我必须坦诚告诉你那个定义是我自己给出的。实际上对于什么是面向对象编程、什么是面向对象编程语言并没有一个官方的、统一的定义。而且从1960年也就是60年前面向对象编程诞生开始这两个概念就在不停地演化所以也无法给出一个明确的定义也没有必要给出一个明确定义。
实际上,面向对象编程从字面上,按照最简单、最原始的方式来理解,就是将对象或类作为代码组织的基本单元,来进行编程的一种编程范式或者编程风格,并不一定需要封装、抽象、继承、多态这四大特性的支持。但是,在进行面向对象编程的过程中,人们不停地总结发现,有了这四大特性,我们就能更容易地实现各种面向对象的代码设计思路。
比如我们在面向对象编程的过程中经常会遇到is-a这种类关系比如狗是一种动物而继承这个特性就能很好地支持这种is-a的代码设计思路并且解决代码复用的问题所以继承就成了面向对象编程的四大特性之一。但是随着编程语言的不断迭代、演化人们发现继承这种特性容易造成层次不清、代码混乱所以很多编程语言在设计的时候就开始摒弃继承特性比如Go语言。但是我们并不能因为它摒弃了继承特性就一刀切地认为它不是面向对象编程语言了。
实际上,我个人觉得,只要某种编程语言支持类或对象的语法概念,并且以此作为组织代码的基本单元,那就可以被粗略地认为它就是面向对象编程语言了。至于是否有现成的语法机制,完全地支持了面向对象编程的四大特性、是否对四大特性有所取舍和优化,可以不作为判定的标准。基于此,我们才有了前面的说法,**按照严格的定义,很多语言都不能算得上面向对象编程语言,但按照不严格的定义来讲,现在流行的大部分编程语言都是面向对象编程语言。**
所以,多说一句,关于这个问题,我们一定不要过于学院派,非要给面向对象编程、面向对象编程语言下个死定义,非得对某种语言是否是面向对象编程语言争个一清二白,这样做意义不大。
## 什么是面向对象分析和面向对象设计?
前面我们讲了面向对象编程OOP实际上跟面向对象编程经常放到一块儿来讲的还有另外两个概念那就是面向对象分析OOA和面向对象设计OOD。面向对象分析英文缩写是OOA全称是Object Oriented Analysis面向对象设计的英文缩写是OOD全称是Object Oriented Design。OOA、OOD、OOP三个连在一起就是面向对象分析、设计、编程实现正好是面向对象软件开发要经历的三个阶段。
关于什么是面向对象编程,我们前面已经讲过了。我们现在再来讲一下,什么是面向对象分析和设计。这两个概念相对来说要简单一些。面向对象分析与设计中的“分析”和“设计”这两个词,我们完全可以从字面上去理解,不需要过度解读,简单类比软件开发中的需求分析、系统设计即可。不过,你可能会说,那为啥前面还加了个修饰词“面向对象”呢?有什么特殊的意义吗?
之所以在前面加“面向对象”这几个字,是因为我们是围绕着对象或类来做需求分析和设计的。分析和设计两个阶段最终的产出是类的设计,包括程序被拆解为哪些类,每个类有哪些属性方法,类与类之间如何交互等等。它们比其他的分析和设计更加具体、更加落地、更加贴近编码,更能够顺利地过渡到面向对象编程环节。这也是面向对象分析和设计,与其他分析和设计最大的不同点。
看到这里,你可能会问,那面向对象分析、设计、编程到底都负责做哪些工作呢?简单点讲,面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做,面向对象编程就是将分析和设计的的结果翻译成代码的过程。今天,我们只是简单介绍一下概念,不展开详细讲解。在后面的面向对象实战环节中,我会用两节课的时间,通过一个实际例子,详细讲解如何进行面向对象分析、设计和编程。
## 什么是UML我们是否需要UML
讲到面向对象分析、设计、编程我们就不得不提到另外一个概念那就是UMLUnified Model Language统一建模语言。很多讲解面向对象或设计模式的书籍常用它来画图表达面向对象或设计模式的设计思路。
实际上UML是一种非常复杂的东西。它不仅仅包含我们常提到类图还有用例图、顺序图、活动图、状态图、组件图等。在我看来即便仅仅使用类图学习成本也是很高的。就单说类之间的关系UML就定义了很多种比如泛化、实现、关联、聚合、组合、依赖等。
要想完全掌握并且熟练运用这些类之间的关系来画UML类图肯定要花很多的学习精力。而且UML作为一种沟通工具即便你能完全按照UML规范来画类图可对于不熟悉的人来说看懂的成本也还是很高的。
所以从我的开发经验来说UML在互联网公司的项目开发中用处可能并不大。为了文档化软件设计或者方便讨论软件设计大部分情况下我们随手画个不那么规范的草图能够达意方便沟通就够了而完全按照UML规范来将草图标准化所付出的代价是不值得的。
所以我这里特别说明一下专栏中的很多类图我并没有完全遵守UML的规范标准。为了兼顾图的表达能力和你的学习成本我对UML类图规范做了简化并配上了详细的文字解释力图让你一眼就能看懂而非适得其反让图加重你的学习成本。毕竟我们的专栏并不是一个讲方法论的教程专栏中的所有类图本质是让你更清晰地理解设计。
## 重点回顾
今天的内容讲完了,我们来一起总结回顾一下,你需要重点掌握的几个概念和知识点。
**1.什么是面向对象编程?**
面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
**2.什么是面向对象编程语言?**
面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
**3.如何判定一个编程语言是否是面向对象编程语言?**
如果按照严格的的定义,需要有现成的语法支持类、对象、四大特性才能叫作面向对象编程语言。如果放宽要求的话,只要某种编程语言支持类、对象语法机制,那基本上就可以说这种编程语言是面向对象编程语言了,不一定非得要求具有所有的四大特性。
**4.面向对象编程和面向对象编程语言之间有何关系?**
面向对象编程一般使用面向对象编程语言来进行,但是,不用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即便我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也有可能是面向过程编程风格的。
**5.什么是面向对象分析和面向对象设计?**
简单点讲,面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做。两个阶段最终的产出是类的设计,包括程序被拆解为哪些类,每个类有哪些属性方法、类与类之间如何交互等等。
## 课堂讨论
今天我们要讨论的话题有两个:
1. 在文章中我讲到UML的学习成本很高沟通成本也不低不推荐在面向对象分析、设计的过程中使用对此你有何看法
1. 有关面向对象的概念和知识点,除了我们今天讲到的,你还能想到其他哪些吗?
欢迎在留言区发表你的观点,积极参与讨论。你也可以把这篇文章分享给你的朋友,邀请他一起学习。

View File

@@ -0,0 +1,329 @@
<audio id="audio" title="05 | 理论二:封装、抽象、继承、多态分别可以解决哪些编程问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ad/c8/ad62ace3ad990dcd5940518411edc0c8.mp3"></audio>
上一节课,我简单介绍了面向对象的一些基本概念和知识点,比如,什么是面向对象编程,什么是面向对象编程语言等等。其中,我们还提到,理解面向对象编程及面向对象编程语言的关键就是理解其四大特性:封装、抽象、继承、多态。不过,对于这四大特性,光知道它们的定义是不够的,我们还要知道每个特性存在的意义和目的,以及它们能解决哪些编程问题。所以,今天我就花一节课的时间,针对每种特性,结合实际的代码,带你将这些问题搞清楚。
这里我要强调一下,对于这四大特性,尽管大部分面向对象编程语言都提供了相应的语法机制来支持,但不同的编程语言实现这四大特性的语法机制可能会有所不同。所以,今天,我们在讲解四大特性的时候,并不与具体某种编程语言的特定语法相挂钩,同时,也希望你不要局限在你自己熟悉的编程语言的语法思维框架里。
## 封装Encapsulation
首先,我们来看封装特性。封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。这句话怎么理解呢?我们通过一个简单的例子来解释一下。
下面这段代码是金融系统中一个简化版的虚拟钱包的代码实现。在金融系统中我们会给每个用户创建一个虚拟钱包用来记录用户在我们的系统中的虚拟货币量。对于虚拟钱包的业务背景这里你只需要简单了解一下即可。在面向对象的实战篇中我们会有单独两节课利用OOP的设计思想来详细介绍虚拟钱包的设计实现。
```
public class Wallet {
private String id;
private long createTime;
private BigDecimal balance;
private long balanceLastModifiedTime;
// ...省略其他属性...
public Wallet() {
this.id = IdGenerator.getInstance().generate();
this.createTime = System.currentTimeMillis();
this.balance = BigDecimal.ZERO;
this.balanceLastModifiedTime = System.currentTimeMillis();
}
// 注意下面对get方法做了代码折叠是为了减少代码所占文章的篇幅
public String getId() { return this.id; }
public long getCreateTime() { return this.createTime; }
public BigDecimal getBalance() { return this.balance; }
public long getBalanceLastModifiedTime() { return this.balanceLastModifiedTime; }
public void increaseBalance(BigDecimal increasedAmount) {
if (increasedAmount.compareTo(BigDecimal.ZERO) &lt; 0) {
throw new InvalidAmountException(&quot;...&quot;);
}
this.balance.add(increasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
public void decreaseBalance(BigDecimal decreasedAmount) {
if (decreasedAmount.compareTo(BigDecimal.ZERO) &lt; 0) {
throw new InvalidAmountException(&quot;...&quot;);
}
if (decreasedAmount.compareTo(this.balance) &gt; 0) {
throw new InsufficientAmountException(&quot;...&quot;);
}
this.balance.subtract(decreasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
}
```
从代码中我们可以发现Wallet类主要有四个属性也可以叫作成员变量也就是我们前面定义中提到的信息或者数据。其中id表示钱包的唯一编号createTime表示钱包创建的时间balance表示钱包中的余额balanceLastModifiedTime表示上次钱包余额变更的时间。
我们参照封装特性,对钱包的这四个属性的访问方式进行了限制。调用者只允许通过下面这六个方法来访问或者修改钱包里的数据。
- String getId()
- long getCreateTime()
- BigDecimal getBalance()
- long getBalanceLastModifiedTime()
- void increaseBalance(BigDecimal increasedAmount)
- void decreaseBalance(BigDecimal decreasedAmount)
之所以这样设计是因为从业务的角度来说id、createTime在创建钱包的时候就确定好了之后不应该再被改动所以我们并没有在Wallet类中暴露id、createTime这两个属性的任何修改方法比如set方法。而且这两个属性的初始化设置对于Wallet类的调用者来说也应该是透明的所以我们在Wallet类的构造函数内部将其初始化设置好而不是通过构造函数的参数来外部赋值。
对于钱包余额balance这个属性从业务的角度来说只能增或者减不会被重新设置。所以我们在Wallet类中只暴露了increaseBalance()和decreaseBalance()方法并没有暴露set方法。对于balanceLastModifiedTime这个属性它完全是跟balance这个属性的修改操作绑定在一起的。只有在balance修改的时候这个属性才会被修改。所以我们把balanceLastModifiedTime这个属性的修改操作完全封装在了increaseBalance()和decreaseBalance()两个方法中不对外暴露任何修改这个属性的方法和业务细节。这样也可以保证balance和balanceLastModifiedTime两个数据的一致性。
对于封装这个特性,我们需要编程语言本身提供一定的语法机制来支持。这个语法机制就是**访问权限控制。**例子中的private、public等关键字就是Java语言中的访问权限控制语法。private关键字修饰的属性只能类本身访问可以保护其不被类之外的代码直接访问。如果Java语言没有提供访问权限控制语法所有的属性默认都是public的那任意外部代码都可以通过类似wallet.id=123;这样的方式直接访问、修改属性,也就没办法达到隐藏信息和保护数据的目的了,也就无法支持封装特性了。
**封装特性的定义讲完了,我们再来看一下,封装的意义是什么?它能解决什么编程问题?**
如果我们对类中属性的访问不做限制那任何代码都可以访问、修改类中的属性虽然这样看起来更加灵活但从另一方面来说过度灵活也意味着不可控属性可以随意被以各种奇葩的方式修改而且修改逻辑可能散落在代码中的各个角落势必影响代码的可读性、可维护性。比如某个同事在不了解业务逻辑的情况下在某段代码中“偷偷地”重设了wallet中的balanceLastModifiedTime属性这就会导致balance和balanceLastModifiedTime两个数据不一致。
除此之外,类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解。而这对于调用者来说也是一种负担。相反,如果我们将属性封装起来,暴露少许的几个必要的方法给调用者使用,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。这就好比,如果一个冰箱有很多按钮,你就要研究很长时间,还不一定能操作正确。相反,如果只有几个必要的按钮,比如开、停、调节温度,你一眼就能知道该如何来操作,而且操作出错的概率也会降低很多。
## 抽象Abstraction
讲完了封装特性,我们再来看抽象特性。 封装主要讲的是如何隐藏信息、保护数据,而抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。
在面向对象编程中我们常借助编程语言提供的接口类比如Java中的interface关键字语法或者抽象类比如Java中的abstract关键字语法这两种语法机制来实现抽象这一特性。
这里我稍微说明一下在专栏中我们把编程语言提供的接口语法叫作“接口类”而不是“接口”。之所以这么做是因为“接口”这个词太泛化可以指好多概念比如API接口等所以我们用“接口类”特指编程语言提供的接口语法。
对于抽象这个特性,我举一个例子来进一步解释一下。
```
public interface IPictureStorage {
void savePicture(Picture picture);
Image getPicture(String pictureId);
void deletePicture(String pictureId);
void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo);
}
public class PictureStorage implements IPictureStorage {
// ...省略其他属性...
@Override
public void savePicture(Picture picture) { ... }
@Override
public Image getPicture(String pictureId) { ... }
@Override
public void deletePicture(String pictureId) { ... }
@Override
public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) { ... }
}
```
在上面的这段代码中我们利用Java中的interface接口语法来实现抽象特性。调用者在使用图片存储功能的时候只需要了解IPictureStorage这个接口类暴露了哪些方法就可以了不需要去查看PictureStorage类里的具体实现逻辑。
实际上抽象这个特性是非常容易实现的并不需要非得依靠接口类或者抽象类这些特殊语法机制来支持。换句话说并不是说一定要为实现类PictureStorage抽象出接口类IPictureStorage才叫作抽象。即便不编写IPictureStorage接口类单纯的PictureStorage类本身就满足抽象特性。
之所以这么说那是因为类的方法是通过编程语言中的“函数”这一语法机制来实现的。通过函数包裹具体的实现逻辑这本身就是一种抽象。调用者在使用函数的时候并不需要去研究函数内部的实现逻辑只需要通过函数的命名、注释或者文档了解其提供了什么功能就可以直接使用了。比如我们在使用C语言的malloc()函数的时候,并不需要了解它的底层代码是怎么实现的。
除此之外,在上一节课中,我们还提到,抽象有时候会被排除在面向对象的四大特性之外,当时我卖了一个关子,现在我就来解释一下为什么。
抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中,也可以用来指导架构设计等。而且这个特性也并不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性、所以,它没有很强的“特异性”,有时候并不被看作面向对象编程的特性之一。
**抽象特性的定义讲完了,我们再来看一下,抽象的意义是什么?它能解决什么编程问题?**
实际上,如果上升一个思考层面的话,抽象及其前面讲到的封装都是人类处理复杂性的有效手段。在面对复杂系统的时候,人脑能承受的信息复杂程度是有限的,所以我们必须忽略掉一些非关键性的实现细节。而抽象作为一种只关注功能点不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。
除此之外,抽象作为一个非常宽泛的设计思想,在代码设计中,起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。我们在讲到后面的内容的时候,会具体来解释。
换一个角度来考虑我们在定义或者叫命名类的方法的时候也要有抽象思维不要在方法定义中暴露太多的实现细节以保证在某个时间点需要改变方法的实现逻辑的时候不用去修改其定义。举个简单例子比如getAliyunPictureUrl()就不是一个具有抽象思维的命名因为某一天如果我们不再把图片存储在阿里云上而是存储在私有云上那这个命名也要随之被修改。相反如果我们定义一个比较抽象的函数比如叫作getPictureUrl(),那即便内部存储方式修改了,我们也不需要修改命名。
## 继承Inheritance
学习完了封装和抽象两个特性我们再来看继承特性。如果你熟悉的是类似Java、C++这样的面向对象的编程语言那你对继承这一特性应该不陌生了。继承是用来表示类之间的is-a关系比如猫是一种哺乳动物。从继承关系上来讲继承可以分为两种模式单继承和多继承。单继承表示一个子类只继承一个父类多继承表示一个子类可以继承多个父类比如猫既是哺乳动物又是爬行动物。
为了实现继承这个特性编程语言需要提供特殊的语法机制来支持比如Java使用extends关键字来实现继承C++使用冒号class B : public APython使用parentheses ()Ruby使用&lt;。不过有些编程语言只支持单继承不支持多重继承比如Java、PHP、C#、Ruby等而有些编程语言既支持单重继承也支持多重继承比如C++、Python、Perl等。
为什么有些语言支持多重继承,有些语言不支持呢?这个问题留给你自己去研究,你可以针对你熟悉的编程语言,在留言区写一写具体的原因。
**继承特性的定义讲完了,我们再来看,继承存在的意义是什么?它能解决什么编程问题?**
继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。不过,这一点也并不是继承所独有的,我们也可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。
如果我们再上升一个思维层面去思考继承这一特性可以这么理解我们代码中有一个猫类有一个哺乳动物类。猫属于哺乳动物从人类认知的角度上来说是一种is-a关系。我们通过继承来关联两个类反应真实世界中的这种关系非常符合人类的认知而且从设计的角度来说也有一种结构美感。
继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码,还需要按照继承关系一层一层地往上查看“父类、父类的父类……”的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。
所以,继承这个特性也是一个非常有争议的特性。很多人觉得继承是一种反模式。我们应该尽量少用,甚至不用。关于这个问题,在后面讲到“多用组合少用继承”这种设计思想的时候,我会非常详细地再讲解,这里暂时就不展开讲解了。
## 多态Polymorphism
学习完了封装、抽象、继承之后,我们再来看面向对象编程的最后一个特性,多态。多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。对于多态这种特性,纯文字解释不好理解,我们还是看一个具体的例子。
```
public class DynamicArray {
private static final int DEFAULT_CAPACITY = 10;
protected int size = 0;
protected int capacity = DEFAULT_CAPACITY;
protected Integer[] elements = new Integer[DEFAULT_CAPACITY];
public int size() { return this.size; }
public Integer get(int index) { return elements[index];}
//...省略n多方法...
public void add(Integer e) {
ensureCapacity();
elements[size++] = e;
}
protected void ensureCapacity() {
//...如果数组满了就扩容...代码省略...
}
}
public class SortedDynamicArray extends DynamicArray {
@Override
public void add(Integer e) {
ensureCapacity();
int i;
for (i = size-1; i&gt;=0; --i) { //保证数组中的数据有序
if (elements[i] &gt; e) {
elements[i+1] = elements[i];
} else {
break;
}
}
elements[i+1] = e;
++size;
}
}
public class Example {
public static void test(DynamicArray dynamicArray) {
dynamicArray.add(5);
dynamicArray.add(1);
dynamicArray.add(3);
for (int i = 0; i &lt; dynamicArray.size(); ++i) {
System.out.println(dynamicArray.get(i));
}
}
public static void main(String args[]) {
DynamicArray dynamicArray = new SortedDynamicArray();
test(dynamicArray); // 打印结果1、3、5
}
}
```
多态这种特性也需要编程语言提供特殊的语法机制来实现。在上面的例子中,我们用到了三个语法机制来实现多态。
- 第一个语法机制是编程语言要支持父类对象可以引用子类对象也就是可以将SortedDynamicArray传递给DynamicArray。
- 第二个语法机制是编程语言要支持继承也就是SortedDynamicArray继承了DynamicArray才能将SortedDyamicArray传递给DynamicArray。
- 第三个语法机制是编程语言要支持子类可以重写override父类中的方法也就是SortedDyamicArray重写了DynamicArray中的add()方法。
通过这三种语法机制配合在一起我们就实现了在test()方法中子类SortedDyamicArray替换父类DynamicArray执行子类SortedDyamicArray的add()方法,也就是实现了多态特性。
对于多态特性的实现方式除了利用“继承加方法重写”这种实现方式之外我们还有其他两种比较常见的的实现方式一个是利用接口类语法另一个是利用duck-typing语法。不过并不是每种编程语言都支持接口类或者duck-typing这两种语法机制比如C++就不支持接口类语法而duck-typing只有一些动态语言才支持比如Python、JavaScript等。
**接下来,我们先来看如何利用接口类来实现多态特性。**我们还是先来看一段代码。
```
public interface Iterator {
boolean hasNext();
String next();
String remove();
}
public class Array implements Iterator {
private String[] data;
public boolean hasNext() { ... }
public String next() { ... }
public String remove() { ... }
//...省略其他方法...
}
public class LinkedList implements Iterator {
private LinkedListNode head;
public boolean hasNext() { ... }
public String next() { ... }
public String remove() { ... }
//...省略其他方法...
}
public class Demo {
private static void print(Iterator iterator) {
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
public static void main(String[] args) {
Iterator arrayIterator = new Array();
print(arrayIterator);
Iterator linkedListIterator = new LinkedList();
print(linkedListIterator);
}
}
```
在这段代码中Iterator是一个接口类定义了一个可以遍历集合数据的迭代器。Array和LinkedList都实现了接口类Iterator。我们通过传递不同类型的实现类Array、LinkedList到print(Iterator iterator)函数中支持动态的调用不同的next()、hasNext()实现。
具体点讲就是当我们往print(Iterator iterator)函数传递Array类型的对象的时候print(Iterator iterator)函数就会调用Array的next()、hasNext()的实现逻辑当我们往print(Iterator iterator)函数传递LinkedList类型的对象的时候print(Iterator iterator)函数就会调用LinkedList的next()、hasNext()的实现逻辑。
**刚刚讲的是用接口类来实现多态特性。现在我们再来看下如何用duck-typing来实现多态特性。**我们还是先来看一段代码。这是一段Python代码。
```
class Logger:
def record(self):
print(“I write a log into file.”)
class DB:
def record(self):
print(“I insert data into db. ”)
def test(recorder):
recorder.record()
def demo():
logger = Logger()
db = DB()
test(logger)
test(db)
```
从这段代码中我们发现duck-typing实现多态的方式非常灵活。Logger和DB两个类没有任何关系既不是继承关系也不是接口和实现的关系但是只要它们都有定义了record()方法就可以被传递到test()方法中在实际运行的时候执行对应的record()方法。
也就是说只要两个类具有相同的方法就可以实现多态并不要求两个类之间有任何关系这就是所谓的duck-typing是一些动态语言所特有的语法机制。而像Java这样的静态语言通过继承实现多态特性必须要求两个类之间有继承关系通过接口实现多态特性类必须实现对应的接口。
**多态特性讲完了,我们再来看,多态特性存在的意义是什么?它能解决什么编程问题?**
多态特性能提高代码的可扩展性和复用性。为什么这么说呢我们回过头去看讲解多态特性的时候举的第二个代码实例Iterator的例子
在那个例子中我们利用多态的特性仅用一个print()函数就可以实现遍历打印不同类型Array、LinkedList集合的数据。当再增加一种要遍历打印的类型的时候比如HashMap我们只需让HashMap实现Iterator接口重新实现自己的hasNext()、next()等方法就可以了完全不需要改动print()函数的代码。所以说,多态提高了代码的可扩展性。
如果我们不使用多态特性我们就无法将不同的集合类型Array、LinkedList传递给相同的函数print(Iterator iterator)函数。我们需要针对每种要遍历打印的集合分别实现不同的print()函数比如针对Array我们要实现print(Array array)函数针对LinkedList我们要实现print(LinkedList linkedList)函数。而利用多态特性我们只需要实现一个print()函数的打印逻辑,就能应对各种集合数据的打印操作,这显然提高了代码的复用性。
除此之外多态也是很多设计模式、设计原则、编程技巧的代码实现基础比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的if-else语句等等。关于这点在学习后面的章节中你慢慢会有更深的体会。
## 重点回顾
今天的内容就讲完了,我们来一起总结回顾一下,你需要重点掌握的几个知识点。
**1.关于封装特性**
封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口授权外部仅能通过类提供的方式来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持例如Java中的private、protected、public关键字。封装特性存在的意义一方面是保护数据不被随意修改提高代码的可维护性另一方面是仅暴露有限的必要接口提高类的易用性。
**2.关于抽象特性**
封装主要讲如何隐藏信息、保护数据,那抽象就是讲如何隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口类或者抽象类来实现,但也并不需要特殊的语法机制来支持。抽象存在的意义,一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。
**3.关于继承特性**
继承是用来表示类之间的is-a关系分为两种模式单继承和多继承。单继承表示一个子类只继承一个父类多继承表示一个子类可以继承多个父类。为了实现继承这个特性编程语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题。
**4.关于多态特性**
多态是指子类可以替换父类在实际的代码运行过程中调用子类的方法实现。多态这种特性也需要编程语言提供特殊的语法机制来实现比如继承、接口类、duck-typing。多态可以提高代码的扩展性和复用性是很多设计模式、设计原则、编程技巧的代码实现基础。
## 课堂讨论
今天我们要讨论的话题有如下两个。
1. 你熟悉的编程语言是否支持多重继承?如果不支持,请说一下为什么不支持。如果支持,请说一下它是如何避免多重继承的副作用的。
1. 你熟悉的编程语言对于四大特性是否都有现成的语法支持?对于支持的特性,是通过什么语法机制实现的?对于不支持的特性,又是基于什么原因做的取舍?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,203 @@
<audio id="audio" title="06 | 理论三:面向对象相比面向过程有哪些优势?面向过程真的过时了吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7a/a0/7a44a387e679f18b4c0b5857725883a0.mp3"></audio>
在上两节课中,我们讲了面向对象这种现在非常流行的编程范式,或者说编程风格。实际上,除了面向对象之外,被大家熟知的编程范式还有另外两种,面向过程编程和函数式编程。面向过程这种编程范式随着面向对象的出现,已经慢慢退出了舞台,而函数式编程目前还没有被广泛接受。
在专栏中,我不会对函数式编程做讲解,但我会花两节课的时间,讲一下面向过程这种编程范式。你可能会问,既然面向对象已经成为主流的编程范式,而面向过程已经不那么推荐使用,那为什么又要浪费时间讲它呢?
那是因为在过往的工作中,我发现很多人搞不清楚面向对象和面向过程的区别,总以为使用面向对象编程语言来做开发,就是在进行面向对象编程了。而实际上,他们只是在用面向对象编程语言,编写面向过程风格的代码而已,并没有发挥面向对象编程的优势。这就相当于手握一把屠龙刀,却只是把它当作一把普通的刀剑来用,相当可惜。
所以,我打算详细对比一下面向过程和面向对象这两种编程范式,带你一块搞清楚下面这几个问题(前三个问题我今天讲解,后三个问题我放到下一节课中讲解):
1. 什么是面向过程编程与面向过程编程语言?
1. 面向对象编程相比面向过程编程有哪些优势?
1. 为什么说面向对象编程语言比面向过程编程语言更高级?
1. 有哪些看似是面向对象实际是面向过程风格的代码?
1. 在面向对象编程中,为什么容易写出面向过程风格的代码?
1. 面向过程编程和面向过程编程语言就真的无用武之地了吗?
话不多说,带着这几个问题,我们就正式开始今天的学习吧!
## 什么是面向过程编程与面向过程编程语言?
如果你是一名比较资深的程序员最开始学习编程的时候接触的是Basic、Pascal、C等面向过程的编程语言那你对这两个概念肯定不陌生。但如果你是新生代的程序员一开始学编程的时候接触的就是面向对象编程语言那你对这两个概念可能会比较不熟悉。所以在对比面向对象与面向过程优劣之前我们先把面向过程编程和面向过程编程语言这两个概念搞清楚。
实际上,我们可以对比着面向对象编程和面向对象编程语言这两个概念,来理解面向过程编程和面向过程编程语言。还记得我们之前是如何定义面向对象编程和面向对象编程语言的吗?让我们一块再来回顾一下。
- 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
- 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
类比面向对象编程与面向对象编程语言的定义,对于面向过程编程和面向过程编程语言这两个概念,我给出下面这样的定义。
- 面向过程编程也是一种编程范式或编程风格。它以过程(可以理解为方法、函数、操作)作为组织代码的基本单元,以数据(可以理解为成员变量、属性)与方法相分离为最主要的特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能。
- 面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性(比如继承、多态、封装),仅支持面向过程编程。
不过,这里我必须声明一下,就像我们在之前讲到的,面向对象编程和面向对象编程语言并没有官方的定义一样,这里我给出的面向过程编程和面向过程编程语言的定义,也并不是严格的官方定义。之所以要给出这样的定义,只是为了跟面向对象编程及面向对象编程语言做个对比,以方便你理解它们的区别。
定义不是很严格也比较抽象所以我再用一个例子进一步解释一下。假设我们有一个记录了用户信息的文本文件users.txt每行文本的格式是name&amp;age&amp;gender比如小王&amp;28&amp;。我们希望写一个程序从users.txt文件中逐行读取用户信息然后格式化成name\tage\tgender其中\t是分隔符这种文本格式并且按照age从小到大排序之后重新写入到另一个文本文件formatted_users.txt中。针对这样一个小程序的开发我们一块来看看用面向过程和面向对象两种编程风格编写出来的代码有什么不同。
首先我们先来看用面向过程这种编程风格写出来的代码是什么样子的。注意下面的代码是用C语言这种面向过程的编程语言来编写的。
```
struct User {
char name[64];
int age;
char gender[16];
};
struct User parse_to_user(char* text) {
// 将text(“小王&amp;28&amp;男”)解析成结构体struct User
}
char* format_to_text(struct User user) {
// 将结构体struct User格式化成文本&quot;小王\t28\t男&quot;
}
void sort_users_by_age(struct User users[]) {
// 按照年龄从小到大排序users
}
void format_user_file(char* origin_file_path, char* new_file_path) {
// open files...
struct User users[1024]; // 假设最大1024个用户
int count = 0;
while(1) { // read until the file is empty
struct User user = parse_to_user(line);
users[count++] = user;
}
sort_users_by_age(users);
for (int i = 0; i &lt; count; ++i) {
char* formatted_user_text = format_to_text(users[i]);
// write to new file...
}
// close files...
}
int main(char** args, int argv) {
format_user_file(&quot;/home/zheng/user.txt&quot;, &quot;/home/zheng/formatted_users.txt&quot;);
}
```
然后我们再来看用面向对象这种编程风格写出来的代码是什么样子的。注意下面的代码是用Java这种面向对象的编程语言来编写的。
```
public class User {
private String name;
private int age;
private String gender;
public User(String name, int age, String gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
public static User praseFrom(String userInfoText) {
// 将text(“小王&amp;28&amp;男”)解析成类User
}
public String formatToText() {
// 将类User格式化成文本&quot;小王\t28\t男&quot;
}
}
public class UserFileFormatter {
public void format(String userFile, String formattedUserFile) {
// Open files...
List users = new ArrayList&lt;&gt;();
while (1) { // read until file is empty
// read from file into userText...
User user = User.parseFrom(userText);
users.add(user);
}
// sort users by age...
for (int i = 0; i &lt; users.size(); ++i) {
String formattedUserText = user.formatToText();
// write to new file...
}
// close files...
}
}
public class MainApplication {
public static void main(String[] args) {
UserFileFormatter userFileFormatter = new UserFileFormatter();
userFileFormatter.format(&quot;/home/zheng/users.txt&quot;, &quot;/home/zheng/formatted_users.txt&quot;);
}
}
```
从上面的代码中我们可以看出面向过程和面向对象最基本的区别就是代码的组织方式不同。面向过程风格的代码被组织成了一组方法集合及其数据结构struct User方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类方法和数据结构被绑定一起定义在类中。
看完这个例子之后,你可能会说,面向对象编程和面向过程编程,两种风格的区别就这么一点吗?当然不是,对于这两种编程风格的更多区别,我们继续往下看。
## 面向对象编程相比面向过程编程有哪些优势?
刚刚我们介绍了面向过程编程及面向过程编程语言的定义,并跟面向对象编程及面向对象编程语言做了一个简单对比。接下来,我们再来看一下,为什么面向对象编程晚于面向过程编程出现,却能取而代之,成为现在主流的编程范式?面向对象编程跟面向过程编程比起来,到底有哪些优势?
### 1.OOP更加能够应对大规模复杂程序的开发
看了刚刚举的那个格式化文本文件的例子,你可能会有这样的疑问,两种编程风格实现的代码貌似差不多啊,顶多就是代码的组织方式有点区别,没有感觉到面向对象编程有什么明显的优势呀!你的感觉没错。之所以有这种感觉,主要原因是这个例子程序比较简单、不够复杂。
对于简单程序的开发来说,不管是用面向过程编程风格,还是用面向对象编程风格,差别确实不会很大,甚至有的时候,面向过程的编程风格反倒更有优势。因为需求足够简单,整个程序的处理流程只有一条主线,很容易被划分成顺序执行的几个步骤,然后逐句翻译成代码,这就非常适合采用面向过程这种面条式的编程风格来实现。
但对于大规模复杂程序的开发来说,整个程序的处理流程错综复杂,并非只有一条主线。如果把整个程序的处理流程画出来的话,会是一个网状结构。如果我们再用面向过程编程这种流程化、线性的思维方式,去翻译这个网状结构,去思考如何把程序拆解为一组顺序执行的方法,就会比较吃力。这个时候,面向对象的编程风格的优势就比较明显了。
面向对象编程是以类为思考对象。在进行面向对象编程的时候,我们并不是一上来就去思考,如何将复杂的流程拆解为一个一个方法,而是采用曲线救国的策略,先去思考如何给业务建模,如何将需求翻译为类,如何给类之间建立交互关系,而完成这些工作完全不需要考虑错综复杂的处理流程。当我们有了类的设计之后,然后再像搭积木一样,按照处理流程,将类组装起来形成整个程序。这种开发模式、思考问题的方式,能让我们在应对复杂程序开发的时候,思路更加清晰。
除此之外,面向对象编程还提供了一种更加清晰的、更加模块化的代码组织方式。比如,我们开发一个电商交易系统,业务逻辑复杂,代码量很大,可能要定义数百个函数、数百个数据结构,那如何分门别类地组织这些函数和数据结构,才能不至于看起来比较凌乱呢?类就是一种非常好的组织这些函数和数据结构的方式,是一种将代码模块化的有效手段。
你可能会说像C语言这种面向过程的编程语言我们也可以按照功能的不同把函数和数据结构放到不同的文件里以达到给函数和数据结构分类的目的照样可以实现代码的模块化。你说得没错。只不过面向对象编程本身提供了类的概念强制你做这件事情而面向过程编程并不强求。这也算是面向对象编程相对于面向过程编程的一个微创新吧。
实际上,利用面向过程的编程语言照样可以写出面向对象风格的代码,只不过可能会比用面向对象编程语言来写面向对象风格的代码,付出的代价要高一些。而且,面向过程编程和面向对象编程并非完全对立的。很多软件开发中,尽管利用的是面向过程的编程语言,也都有借鉴面向对象编程的一些优点。
### 2.OOP风格的代码更易复用、易扩展、易维护
在刚刚的那个例子中,因为代码比较简单,所以只用到到了类、对象这两个最基本的面向对象概念,并没有用到更加高级的四大特性,封装、抽象、继承、多态。因此,面向对象编程的优势其实并没有发挥出来。
面向过程编程是一种非常简单的编程风格,并没有像面向对象编程那样提供丰富的特性。而面向对象编程提供的封装、抽象、继承、多态这些特性,能极大地满足复杂的编程需求,能方便我们写出更易复用、易扩展、易维护的代码。为什么这么说呢?还记得我们在上一节课中讲到的封装、抽象、继承、多态存在的意义吗?我们再来简单回顾一下。
首先,我们先来看下封装特性。封装特性是面向对象编程相比于面向过程编程的一个最基本的区别,因为它基于的是面向对象编程中最基本的类的概念。面向对象编程通过类这种组织代码的方式,将数据和方法绑定在一起,通过访问权限控制,只允许外部调用者通过类暴露的有限方法访问数据,而不会像面向过程编程那样,数据可以被任意方法随意修改。因此,面向对象编程提供的封装特性更有利于提高代码的易维护性。
其次,我们再来看下抽象特性。我们知道,函数本身就是一种抽象,它隐藏了具体的实现。我们在使用函数的时候,只需要了解函数具有什么功能,而不需要了解它是怎么实现的。从这一点上,不管面向过程编程还是是面向对象编程,都支持抽象特性。不过,面向对象编程还提供了其他抽象特性的实现方式。这些实现方式是面向过程编程所不具备的,比如基于接口实现的抽象。基于接口的抽象,可以让我们在不改变原有实现的情况下,轻松替换新的实现逻辑,提高了代码的可扩展性。
再次,我们来看下继承特性。继承特性是面向对象编程相比于面向过程编程所特有的两个特性之一(另一个是多态)。如果两个类有一些相同的属性和方法,我们就可以将这些相同的代码,抽取到父类中,让两个子类继承父类。这样两个子类也就可以重用父类中的代码,避免了代码重复写多遍,提高了代码的复用性。
最后,我们来看下多态特性。基于这个特性,我们在需要修改一个功能实现的时候,可以通过实现一个新的子类的方式,在子类中重写原来的功能逻辑,用子类替换父类。在实际的代码运行过程中,调用子类新的功能逻辑,而不是在原有代码上做修改。这就遵从了“对修改关闭、对扩展开放”的设计原则,提高代码的扩展性。除此之外,利用多态特性,不同的类对象可以传递给相同的方法,执行不同的代码逻辑,提高了代码的复用性。
所以说,基于这四大特性,利用面向对象编程,我们可以更轻松地写出易复用、易扩展、易维护的代码。当然,我们不能说,利用面向过程风格就不可以写出易复用、易扩展、易维护的代码,但没有四大特性的帮助,付出的代价可能就要高一些。
### 3.OOP语言更加人性化、更加高级、更加智能
人类最开始跟机器打交道是通过0、1这样的二进制指令然后是汇编语言再之后才出现了高级编程语言。在高级编程语言中面向过程编程语言又早于面向对象编程语言出现。之所以先出现面向过程编程语言那是因为跟机器交互的方式从二进制指令、汇编语言到面向过程编程语言是一个非常自然的过渡都是一种流程化的、面条式的编程风格用一组指令顺序操作数据来完成一项任务。
从指令到汇编再到面向过程编程语言,跟机器打交道的方式在不停地演进,从中我们很容易发现这样一条规律,那就是编程语言越来越人性化,让人跟机器打交道越来越容易。笼统点讲,就是编程语言越来越高级。实际上,在面向过程编程语言之后,面向对象编程语言的出现,也顺应了这样的发展规律,也就是说,面向对象编程语言比面向过程编程语言更加高级!
跟二进制指令、汇编语言、面向过程编程语言相比,面向对象编程语言的编程套路、思考问题的方式,是完全不一样的。前三者是一种计算机思维方式,而面向对象是一种人类的思维方式。我们在用前面三种语言编程的时候,我们是在思考,如何设计一组指令,告诉机器去执行这组指令,操作某些数据,帮我们完成某个任务。而在进行面向对象编程时候,我们是在思考,如何给业务建模,如何将真实的世界映射为类或者对象,这让我们更加能聚焦到业务本身,而不是思考如何跟机器打交道。可以这么说,越高级的编程语言离机器越“远”,离我们人类越“近”,越“智能”。
这里多聊几句,顺着刚刚这个编程语言的发展规律来想,如果一种新的突破性的编程语言出现,那它肯定是更加“智能”的。大胆想象一下,使用这种编程语言,我们可以无需对计算机知识有任何了解,无需像现在这样一行一行地敲很多代码,只需要把需求文档写清楚,就能自动生成我们想要的软件了。
## 重点回顾
今天的内容就讲完了,我们来一起总结回顾一下,你需要重点掌握的几个知识点。
**1.什么是面向过程编程?什么是面向过程编程语言?**
实际上,面向过程编程和面向过程编程语言并没有严格的官方定义。理解这两个概念最好的方式是跟面向对象编程和面向对象编程语言进行对比。相较于面向对象编程以类为组织代码的基本单元,面向过程编程则是以过程(或方法)作为组织代码的基本单元。它最主要的特点就是数据和方法相分离。相较于面向对象编程语言,面向过程编程语言最大的特点就是不支持丰富的面向对象编程特性,比如继承、多态、封装。
**2.面向对象编程相比面向过程编程有哪些优势?**
面向对象编程相比起面向过程编程的优势主要有三个。
- 对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对这种复杂类型的程序开发。
- 面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。
- 从编程语言跟机器打交道的方式的演进规律中,我们可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能。
## 课堂讨论
在文章中我讲到面向对象编程比面向过程编程更加容易应对大规模复杂程序的开发。但像Unix、Linux这些复杂的系统也都是基于C语言这种面向过程的编程语言开发的你怎么看待这个现象这跟我之前的讲解相矛盾吗
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,250 @@
<audio id="audio" title="07 | 理论四:哪些代码设计看似是面向对象,实际是面向过程的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/c2/fae6a9d658353028b04e2a91ab5037c2.mp3"></audio>
上一节课,我们提到,常见的编程范式或者说编程风格有三种,面向过程编程、面向对象编程、函数式编程,而面向对象编程又是这其中最主流的编程范式。现如今,大部分编程语言都是面向对象编程语言,大部分软件都是基于面向对象编程这种编程范式来开发的。
不过,在实际的开发工作中,很多同学对面向对象编程都有误解,总以为把所有代码都塞到类里,自然就是在进行面向对象编程了。实际上,这样的认识是不正确的。有时候,从表面上看似是面向对象编程风格的代码,从本质上看却是面向过程编程风格的。
所以,今天,我结合具体的代码实例来讲一讲,有哪些看似是面向对象,实际上是面向过程编程风格的代码,并且分析一下,为什么我们很容易写出这样的代码。最后,我们再一起辩证思考一下,面向过程编程是否就真的无用武之地了呢?是否有必要杜绝在面向对象编程中写面向过程风格的代码呢?
好了,现在,让我们正式开始今天的学习吧!
## 哪些代码设计看似是面向对象,实际是面向过程的?
在用面向对象编程语言进行软件开发的时候,我们有时候会写出面向过程风格的代码。有些是有意为之,并无不妥;而有些是无意为之,会影响到代码的质量。下面我就通过三个典型的代码案例,给你展示一下,什么样的代码看似是面向对象风格,实际上是面向过程风格的。我也希望你通过对这三个典型例子的学习,能够做到举一反三,在平时的开发中,多留心一下自己编写的代码是否满足面向对象风格。
### 1.滥用getter、setter方法
在之前参与的项目开发中我经常看到有同事定义完类的属性之后就顺手把这些属性的getter、setter方法都定义上。有些同事更加省事直接用IDE或者Lombok插件如果是Java项目的话自动生成所有属性的getter、setter方法。
当我问起为什么要给每个属性都定义getter、setter方法的时候他们的理由一般是为了以后可能会用到现在事先定义好类用起来就更加方便而且即便用不到这些getter、setter方法定义上它们也无伤大雅。
实际上,这样的做法我是非常不推荐的。它违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格。我通过下面这个例子来给你解释一下这句话。
```
public class ShoppingCart {
private int itemsCount;
private double totalPrice;
private List&lt;ShoppingCartItem&gt; items = new ArrayList&lt;&gt;();
public int getItemsCount() {
return this.itemsCount;
}
public void setItemsCount(int itemsCount) {
this.itemsCount = itemsCount;
}
public double getTotalPrice() {
return this.totalPrice;
}
public void setTotalPrice(double totalPrice) {
this.totalPrice = totalPrice;
}
public List&lt;ShoppingCartItem&gt; getItems() {
return this.items;
}
public void addItem(ShoppingCartItem item) {
items.add(item);
itemsCount++;
totalPrice += item.getPrice();
}
// ...省略其他方法...
}
```
在这段代码中ShoppingCart是一个简化后的购物车类有三个私有private属性itemsCount、totalPrice、items。对于itemsCount、totalPrice两个属性我们定义了它们的getter、setter方法。对于items属性我们定义了它的getter方法和addItem()方法。代码很简单,理解起来不难。那你有没有发现,这段代码有什么问题呢?
我们先来看前两个属性itemsCount和totalPrice。虽然我们将它们定义成private私有属性但是提供了public的getter、setter方法这就跟将这两个属性定义为public公有属性没有什么两样了。外部可以通过setter方法随意地修改这两个属性的值。除此之外任何代码都可以随意调用setter方法来重新设置itemsCount、totalPrice属性的值这也会导致其跟items属性的值不一致。
而面向对象封装的定义是通过访问权限控制隐藏内部数据外部仅能通过类提供的有限的接口访问、修改内部数据。所以暴露不应该暴露的setter方法明显违反了面向对象的封装特性。数据没有访问权限控制任何代码都可以随意修改它代码就退化成了面向过程编程风格的了。
看完了前两个属性我们再来看items这个属性。对于items这个属性我们定义了它的getter方法和addItem()方法并没有定义它的setter方法。这样的设计貌似看起来没有什么问题但实际上并不是。
对于itemsCount和totalPrice这两个属性来说定义一个public的getter方法确实无伤大雅毕竟getter方法不会修改数据。但是对于items属性就不一样了这是因为items属性的getter方法返回的是一个List<shoppingcartitem>集合容器。外部调用者在拿到这个容器之后是可以操作容器内部数据的也就是说外部代码还是能修改items中的数据。比如像下面这样</shoppingcartitem>
```
ShoppingCart cart = new ShoppCart();
...
cart.getItems().clear(); // 清空购物车
```
你可能会说清空购物车这样的功能需求看起来合情合理啊上面的代码没有什么不妥啊。你说得没错需求是合理的但是这样的代码写法会导致itemsCount、totalPrice、items三者数据不一致。我们不应该将清空购物车的业务逻辑暴露给上层代码。正确的做法应该是在ShoppingCart类中定义一个clear()方法将清空购物车的业务逻辑封装在里面透明地给调用者使用。ShoppingCart类的clear()方法的具体代码实现如下:
```
public class ShoppingCart {
// ...省略其他代码...
public void clear() {
items.clear();
itemsCount = 0;
totalPrice = 0.0;
}
}
```
你可能还会说我有一个需求需要查看购物车中都买了啥那这个时候ShoppingCart类不得不提供items属性的getter方法了那又该怎么办才好呢
如果你熟悉Java语言那解决这个问题的方法还是挺简单的。我们可以通过Java提供的Collections.unmodifiableList()方法让getter方法返回一个不可被修改的UnmodifiableList集合容器而这个容器类重写了List容器中跟修改数据相关的方法比如add()、clear()等方法。一旦我们调用这些修改数据的方法代码就会抛出UnsupportedOperationException异常这样就避免了容器中的数据被修改。具体的代码实现如下所示。
```
public class ShoppingCart {
// ...省略其他代码...
public List&lt;ShoppingCartItem&gt; getItems() {
return Collections.unmodifiableList(this.items);
}
}
public class UnmodifiableList&lt;E&gt; extends UnmodifiableCollection&lt;E&gt;
implements List&lt;E&gt; {
public boolean add(E e) {
throw new UnsupportedOperationException();
}
public void clear() {
throw new UnsupportedOperationException();
}
// ...省略其他代码...
}
ShoppingCart cart = new ShoppingCart();
List&lt;ShoppingCartItem&gt; items = cart.getItems();
items.clear();//抛出UnsupportedOperationException异常
```
不过这样的实现思路还是有点问题。因为当调用者通过ShoppingCart的getItems()获取到items之后虽然我们没法修改容器中的数据但我们仍然可以修改容器中每个对象ShoppingCartItem的数据。听起来有点绕看看下面这几行代码你就明白了。
```
ShoppingCart cart = new ShoppingCart();
cart.add(new ShoppingCartItem(...));
List&lt;ShoppingCartItem&gt; items = cart.getItems();
ShoppingCartItem item = items.get(0);
item.setPrice(19.0); // 这里修改了item的价格属性
```
这个问题该如何解决呢?我今天就不展开来讲了。在后面讲到设计模式的时候,我还会详细地讲到。当然,你也可以在留言区留言或者把问题分享给你的朋友,和他一起讨论解决方案。
getter、setter问题我们就讲完了我稍微总结一下在设计实现类的时候除非真的需要否则尽量不要给属性定义setter方法。除此之外尽管getter方法相对setter方法要安全些但是如果返回的是集合容器比如例子中的List容器也要防范集合内部数据被修改的危险。
### 2.滥用全局变量和全局方法
我们再来看,另外一个违反面向对象编程风格的例子,那就是滥用全局变量和全局方法。首先,我们先来看,什么是全局变量和全局方法?
如果你是用类似C语言这样的面向过程的编程语言来做开发那对全局变量、全局方法肯定不陌生甚至可以说在代码中到处可见。但如果你是用类似Java这样的面向对象的编程语言来做开发全局变量和全局方法就不是很多见了。
在面向对象编程中常见的全局变量有单例类对象、静态成员变量、常量等常见的全局方法有静态方法。单例类对象在全局代码中只有一份所以它相当于一个全局变量。静态成员变量归属于类上的数据被所有的实例化对象所共享也相当于一定程度上的全局变量。而常量是一种非常常见的全局变量比如一些代码中的配置参数一般都设置为常量放到一个Constants类中。静态方法一般用来操作静态变量或者外部数据。你可以联想一下我们常用的各种Utils类里面的方法一般都会定义成静态方法可以在不用创建对象的情况下直接拿来使用。静态方法将方法与数据分离破坏了封装特性是典型的面向过程风格。
在刚刚介绍的这些全局变量和全局方法中Constants类和Utils类最常用到。现在我们就结合这两个几乎在每个软件开发中都会用到的类来深入探讨一下全局变量和全局方法的利与弊。
**我们先来看一下在我过去参与的项目中一种常见的Constants类的定义方法**
```
public class Constants {
public static final String MYSQL_ADDR_KEY = &quot;mysql_addr&quot;;
public static final String MYSQL_DB_NAME_KEY = &quot;db_name&quot;;
public static final String MYSQL_USERNAME_KEY = &quot;mysql_username&quot;;
public static final String MYSQL_PASSWORD_KEY = &quot;mysql_password&quot;;
public static final String REDIS_DEFAULT_ADDR = &quot;192.168.7.2:7234&quot;;
public static final int REDIS_DEFAULT_MAX_TOTAL = 50;
public static final int REDIS_DEFAULT_MAX_IDLE = 50;
public static final int REDIS_DEFAULT_MIN_IDLE = 20;
public static final String REDIS_DEFAULT_KEY_PREFIX = &quot;rt:&quot;;
// ...省略更多的常量定义...
}
```
在这段代码中我们把程序中所有用到的常量都集中地放到这个Constants类中。不过定义一个如此大而全的Constants类并不是一种很好的设计思路。为什么这么说呢原因主要有以下几点。
首先,这样的设计会影响代码的可维护性。
如果参与开发同一个项目的工程师有很多,在开发过程中,可能都要涉及修改这个类,比如往这个类里添加常量,那这个类就会变得越来越大,成百上千行都有可能,查找修改某个常量也会变得比较费时,而且还会增加提交代码冲突的概率。
其次,这样的设计还会增加代码的编译时间。
当Constants类中包含很多常量定义的时候依赖这个类的代码就会很多。那每次修改Constants类都会导致依赖它的类文件重新编译因此会浪费很多不必要的编译时间。不要小看编译花费的时间对于一个非常大的工程项目来说编译一次项目花费的时间可能是几分钟甚至几十分钟。而我们在开发过程中每次运行单元测试都会触发一次编译的过程这个编译时间就有可能会影响到我们的开发效率。
最后,这样的设计还会影响代码的复用性。
如果我们要在另一个项目中复用本项目开发的某个类而这个类又依赖Constants类。即便这个类只依赖Constants类中的一小部分常量我们仍然需要把整个Constants类也一并引入也就引入了很多无关的常量到新的项目中。
那如何改进Constants类的设计呢我这里有两种思路可以借鉴。
第一种是将Constants类拆解为功能更加单一的多个类比如跟MySQL配置相关的常量我们放到MysqlConstants类中跟Redis配置相关的常量我们放到RedisConstants类中。当然还有一种我个人觉得更好的设计思路那就是并不单独地设计Constants常量类而是哪个类用到了某个常量我们就把这个常量定义到这个类中。比如RedisConfig类用到了Redis配置相关的常量那我们就直接将这些常量定义在RedisConfig中这样也提高了类设计的内聚性和代码的复用性。
**讲完了Constants类我们再来讨论一下Utils类。**首先我想问你这样一个问题我们为什么需要Utils类Utils类存在的意义是什么希望你先思考一下然后再来看我下面的讲解。
实际上Utils类的出现是基于这样一个问题背景如果我们有两个类A和B它们要用到一块相同的功能逻辑为了避免代码重复我们不应该在两个类中将这个相同的功能逻辑重复地实现两遍。这个时候我们该怎么办呢
我们在讲面向对象特性的时候讲过继承可以实现代码复用。利用继承特性我们把相同的属性和方法抽取出来定义到父类中。子类复用父类中的属性和方法达到代码复用的目的。但是有的时候从业务含义上A类和B类并不一定具有继承关系比如Crawler类和PageAnalyzer类它们都用到了URL拼接和分割的功能但并不具有继承关系既不是父子关系也不是兄弟关系。仅仅为了代码复用生硬地抽象出一个父类出来会影响到代码的可读性。如果不熟悉背后设计思路的同事发现Crawler类和PageAnalyzer类继承同一个父类而父类中定义的却是URL相关的操作会觉得这个代码写得莫名其妙理解不了。
既然继承不能解决这个问题我们可以定义一个新的类实现URL拼接和分割的方法。而拼接和分割两个方法不需要共享任何数据所以新的类不需要定义任何属性这个时候我们就可以把它定义为只包含静态方法的Utils类了。
实际上只包含静态方法不包含任何属性的Utils类是彻彻底底的面向过程的编程风格。但这并不是说我们就要杜绝使用Utils类了。实际上从刚刚讲的Utils类存在的目的来看它在软件开发中还是挺有用的能解决代码复用问题。所以这里并不是说完全不能用Utils类而是说要尽量避免滥用不要不加思考地随意去定义Utils类。
在定义Utils类之前你要问一下自己你真的需要单独定义这样一个Utils类吗是否可以把Utils类中的某些方法定义到其他类中呢如果在回答完这些问题之后你还是觉得确实有必要去定义这样一个Utils类那就大胆地去定义它吧。因为即便在面向对象编程中我们也并不是完全排斥面向过程风格的代码。只要它能为我们写出好的代码贡献力量我们就可以适度地去使用。
除此之外类比Constants类的设计我们设计Utils类的时候最好也能细化一下针对不同的功能设计不同的Utils类比如FileUtils、IOUtils、StringUtils、UrlUtils等不要设计一个过于大而全的Utils类。
### 3.定义数据和方法分离的类
我们再来看最后一种面向对象编程过程中常见的面向过程风格的代码。那就是数据定义在一个类中方法定义在另一个类中。你可能会觉得这么明显的面向过程风格的代码谁会这么写呢实际上如果你是基于MVC三层结构做Web方面的后端开发这样的代码你可能天天都在写。
传统的MVC结构分为Model层、Controller层、View层这三层。不过在做前后端分离之后三层结构在后端开发中会稍微有些调整被分为Controller层、Service层、Repository层。Controller层负责暴露接口给前端调用Service层负责核心业务逻辑Repository层负责数据读写。而在每一层中我们又会定义相应的VOView Object、BOBusiness Object、Entity。一般情况下VO、BO、Entity中只会定义数据不会定义方法所有操作这些数据的业务逻辑都定义在对应的Controller类、Service类、Repository类中。这就是典型的面向过程的编程风格。
实际上这种开发模式叫作基于贫血模型的开发模式也是我们现在非常常用的一种Web项目的开发模式。看到这里你内心里应该有很多疑惑吧既然这种开发模式明显违背面向对象的编程风格为什么大部分Web项目都是基于这种开发模式来开发呢
关于这个问题,我今天不打算展开讲解。因为它跟我们平时的项目开发结合得非常紧密,所以,更加细致、全面的讲解,我把它安排在面向对象实战环节里了,希望用两节课的时间,把这个问题给你讲透彻。
## 在面向对象编程中,为什么容易写出面向过程风格的代码?
我们在进行面向对象编程的时候,很容易不由自主地就写出面向过程风格的代码,或者说感觉面向过程风格的代码更容易写。这是为什么呢?
你可以联想一下,在生活中,你去完成一个任务,你一般都会思考,应该先做什么、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。面向过程编程风格恰恰符合人的这种流程化思维方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。我们在上一节课讲到了,这样的思考路径比较适合复杂程序的开发,但并不是特别符合人类的思考习惯。
除此之外,面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。
所以,基于这两点原因,很多工程师在开发的过程,更倾向于用不太需要动脑子的方式去实现需求,也就不由自主地就将代码写成面向过程风格的了。
## 面向过程编程及面向过程编程语言就真的无用武之地了吗?
前面我们讲了面向对象编程相比面向过程编程的各种优势,又讲了哪些代码看起来像面向对象风格,而实际上是面向过程编程风格的。那是不是面向过程编程风格就过时了被淘汰了呢?是不是在面向对象编程开发中,我们就要杜绝写面向过程风格的代码呢?
前面我们有讲到,如果我们开发的是微小程序,或者是一个数据处理相关的代码,以算法为主,数据为辅,那脚本式的面向过程的编程风格就更适合一些。当然,面向过程编程的用武之地还不止这些。实际上,面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为什么这么说?我们仔细想想,类中每个方法的实现逻辑,不就是面向过程风格的代码吗?
除此之外面向对象和面向过程两种编程风格也并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中面向过程风格的代码并不少见甚至在一些标准的开发库比如JDK、Apache Commons、Google Guava也有很多面向过程风格的代码。
不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。
## 重点回顾
今天的内容讲完了。让我们一块回顾一下,你应该掌握的重点内容。今天你要掌握的重点内容是三种违反面向对象编程风格的典型代码设计。
**1.滥用getter、setter方法**
在设计实现类的时候除非真的需要否则尽量不要给属性定义setter方法。除此之外尽管getter方法相对setter方法要安全些但是如果返回的是集合容器那也要防范集合内部数据被修改的风险。
**2.Constants类、Utils类的设计问题**
对于这两种类的设计我们尽量能做到职责单一定义一些细化的小类比如RedisConstants、FileUtils而不是定义一个大而全的Constants类、Utils类。除此之外如果能将这些类中的属性和方法划分归并到其他业务类中那是最好不过的了能极大地提高类的内聚性和代码的可复用性。
**3.基于贫血模型的开发模式**
关于这一部分我们只讲了为什么这种开发模式是彻彻底底的面向过程编程风格的。这是因为数据和操作是分开定义在VO/BO/Entity和Controler/Service/Repository中的。今天你只需要掌握这一点就可以了。为什么这种开发模式如此流行如何规避面向过程编程的弊端有没有更好的可替代的开发模式相关的更多问题我们在面向对象实战篇中会一一讲解。
## 课堂讨论
今天课堂讨论的话题有两个,你可以选择一个熟悉的来发表观点。
1.今天我们讲到用面向对象编程语言写出来的代码不一定是面向对象编程风格的有可能是面向过程编程风格的。相反用面向过程编程语言照样也可以写出面向对象编程风格的代码。尽管面向过程编程语言可能没有现成的语法来支持面向对象的四大特性但可以通过其他方式来模拟比如在C语言中我们可以利用函数指针来模拟多态。如果你熟悉一门面向过程的编程语言你能聊一聊如何用它来模拟面向对象的四大特性吗
2.看似是面向对象实际上是面向过程编程风格的代码有很多,除了今天我讲到的这三个,在你工作中,你还遇到过哪些其他情况吗?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,311 @@
<audio id="audio" title="08 | 理论五接口vs抽象类的区别如何用普通的类模拟抽象类和接口" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f9/a7/f9e4a9f08f84083c4b03f5420a7a9ba7.mp3"></audio>
在面向对象编程中,抽象类和接口是两个经常被用到的语法概念,是面向对象四大特性,以及很多设计模式、设计思想、设计原则编程实现的基础。比如,我们可以使用接口来实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则,使用抽象类来实现面向对象的继承特性和模板设计模式等等。
不过并不是所有的面向对象编程语言都支持这两个语法概念比如C++这种编程语言只支持抽象类不支持接口而像Python这样的动态编程语言既不支持抽象类也不支持接口。尽管有些编程语言没有提供现成的语法来支持接口和抽象类我们仍然可以通过一些手段来模拟实现这两个语法概念。
这两个语法概念不仅在工作中经常会被用到,在面试中也经常被提及。比如,“接口和抽象类的区别是什么?什么时候用接口?什么时候用抽象类?抽象类和接口存在的意义是什么?能解决哪些编程问题?”等等。
你可以先试着回答一下,刚刚我提出的几个问题。如果你对某些问题还有些模糊不清,那也没关系,今天,我会带你把这几个问题彻底搞清楚。下面我们就一起来看!
## 什么是抽象类和接口?区别在哪里?
不同的编程语言对接口和抽象类的定义方式可能有些差别但差别并不会很大。Java这种编程语言既支持抽象类也支持接口所以为了让你对这两个语法概念有比较直观的认识我们拿Java这种编程语言来举例讲解。
**首先我们来看一下在Java这种编程语言中我们是如何定义抽象类的。**
下面这段代码是一个比较典型的抽象类的使用场景模板设计模式。Logger是一个记录日志的抽象类FileLogger和MessageQueueLogger继承Logger分别实现两种不同的日志记录方式记录日志到文件中和记录日志到消息队列中。FileLogger和MessageQueueLogger两个子类复用了父类Logger中的name、enabled、minPermittedLevel属性和log()方法但因为这两个子类写日志的方式不同它们又各自重写了父类中的doLog()方法。
```
// 抽象类
public abstract class Logger {
private String name;
private boolean enabled;
private Level minPermittedLevel;
public Logger(String name, boolean enabled, Level minPermittedLevel) {
this.name = name;
this.enabled = enabled;
this.minPermittedLevel = minPermittedLevel;
}
public void log(Level level, String message) {
boolean loggable = enabled &amp;&amp; (minPermittedLevel.intValue() &lt;= level.intValue());
if (!loggable) return;
doLog(level, message);
}
protected abstract void doLog(Level level, String message);
}
// 抽象类的子类:输出日志到文件
public class FileLogger extends Logger {
private Writer fileWriter;
public FileLogger(String name, boolean enabled,
Level minPermittedLevel, String filepath) {
super(name, enabled, minPermittedLevel);
this.fileWriter = new FileWriter(filepath);
}
@Override
public void doLog(Level level, String mesage) {
// 格式化level和message,输出到日志文件
fileWriter.write(...);
}
}
// 抽象类的子类: 输出日志到消息中间件(比如kafka)
public class MessageQueueLogger extends Logger {
private MessageQueueClient msgQueueClient;
public MessageQueueLogger(String name, boolean enabled,
Level minPermittedLevel, MessageQueueClient msgQueueClient) {
super(name, enabled, minPermittedLevel);
this.msgQueueClient = msgQueueClient;
}
@Override
protected void doLog(Level level, String mesage) {
// 格式化level和message,输出到消息中间件
msgQueueClient.send(...);
}
}
```
通过上面的这个例子,我们来看一下,抽象类具有哪些特性。我总结了下面三点。
- 抽象类不允许被实例化只能被继承。也就是说你不能new一个抽象类的对象出来Logger logger = new Logger(...);会报编译错误)。
- 抽象类可以包含属性和方法。方法既可以包含代码实现比如Logger中的log()方法也可以不包含代码实现比如Logger中的doLog()方法)。不包含代码实现的方法叫作抽象方法。
- 子类继承抽象类必须实现抽象类中的所有抽象方法。对应到例子代码中就是所有继承Logger抽象类的子类都必须重写doLog()方法。
**刚刚我们讲了如何定义抽象类现在我们再来看一下在Java这种编程语言中我们如何定义接口。**
```
// 接口
public interface Filter {
void doFilter(RpcRequest req) throws RpcException;
}
// 接口实现类:鉴权过滤器
public class AuthencationFilter implements Filter {
@Override
public void doFilter(RpcRequest req) throws RpcException {
//...鉴权逻辑..
}
}
// 接口实现类:限流过滤器
public class RateLimitFilter implements Filter {
@Override
public void doFilter(RpcRequest req) throws RpcException {
//...限流逻辑...
}
}
// 过滤器使用Demo
public class Application {
// filters.add(new AuthencationFilter());
// filters.add(new RateLimitFilter());
private List&lt;Filter&gt; filters = new ArrayList&lt;&gt;();
public void handleRpcRequest(RpcRequest req) {
try {
for (Filter filter : filters) {
filter.doFilter(req);
}
} catch(RpcException e) {
// ...处理过滤结果...
}
// ...省略其他处理逻辑...
}
}
```
上面这段代码是一个比较典型的接口的使用场景。我们通过Java中的interface关键字定义了一个Filter接口。AuthencationFilter和RateLimitFilter是接口的两个实现类分别实现了对RPC请求鉴权和限流的过滤功能。
代码非常简洁。结合代码,我们再来看一下,接口都有哪些特性。我也总结了三点。
- 接口不能包含属性(也就是成员变量)。
- 接口只能声明方法,方法不能包含代码实现。
- 类实现接口的时候,必须实现接口中声明的所有方法。
前面我们讲了抽象类和接口的定义,以及各自的语法特性。从语法特性上对比,这两者有比较大的区别,比如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等。除了语法特性,从设计的角度,两者也有比较大的区别。
抽象类实际上就是类只不过是一种特殊的类这种类不能被实例化为对象只能被子类继承。我们知道继承关系是一种is-a的关系那抽象类既然属于类也表示一种is-a的关系。相对于抽象类的is-a关系来说接口表示一种has-a关系表示具有某些功能。对于接口有一个更加形象的叫法那就是协议contract
## 抽象类和接口能解决什么编程问题?
刚刚我们学习了抽象类和接口的定义和区别,现在我们再来学习一下,抽象类和接口存在的意义,让你知其然知其所以然。
**首先,我们来看一下,我们为什么需要抽象类?它能够解决什么编程问题?**
刚刚我们讲到,抽象类不能实例化,只能被继承。而前面的章节中,我们还讲到,继承能解决代码复用的问题。所以,抽象类也是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码。
不过,既然继承本身就能达到代码复用的目的,而继承也并不要求父类一定是抽象类,那我们不使用抽象类,照样也可以实现继承和复用。从这个角度上来讲,我们貌似并不需要抽象类这种语法呀。那抽象类除了解决代码复用的问题,还有什么其他存在的意义吗?
我们还是拿之前那个打印日志的例子来讲解。我们先对上面的代码做下改造。在改造之后的代码中Logger不再是抽象类只是一个普通的父类删除了Logger中log()、doLog()方法新增了isLoggable()方法。FileLogger和MessageQueueLogger还是继承Logger父类以达到代码复用的目的。具体的代码如下
```
// 父类:非抽象类,就是普通的类. 删除了log(),doLog()新增了isLoggable().
public class Logger {
private String name;
private boolean enabled;
private Level minPermittedLevel;
public Logger(String name, boolean enabled, Level minPermittedLevel) {
//...构造函数不变,代码省略...
}
protected boolean isLoggable() {
boolean loggable = enabled &amp;&amp; (minPermittedLevel.intValue() &lt;= level.intValue());
return loggable;
}
}
// 子类:输出日志到文件
public class FileLogger extends Logger {
private Writer fileWriter;
public FileLogger(String name, boolean enabled,
Level minPermittedLevel, String filepath) {
//...构造函数不变,代码省略...
}
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化level和message,输出到日志文件
fileWriter.write(...);
}
}
// 子类: 输出日志到消息中间件(比如kafka)
public class MessageQueueLogger extends Logger {
private MessageQueueClient msgQueueClient;
public MessageQueueLogger(String name, boolean enabled,
Level minPermittedLevel, MessageQueueClient msgQueueClient) {
//...构造函数不变,代码省略...
}
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化level和message,输出到消息中间件
msgQueueClient.send(...);
}
}
```
这个设计思路虽然达到了代码复用的目的但是无法使用多态特性了。像下面这样编写代码就会出现编译错误因为Logger中并没有定义log()方法。
```
Logger logger = new FileLogger(&quot;access-log&quot;, true, Level.WARN, &quot;/users/wangzheng/access.log&quot;);
logger.log(Level.ERROR, &quot;This is a test log message.&quot;);
```
你可能会说这个问题解决起来很简单啊。我们在Logger父类中定义一个空的log()方法让子类重写父类的log()方法,实现自己的记录日志的逻辑,不就可以了吗?
```
public class Logger {
// ...省略部分代码...
public void log(Level level, String mesage) { // do nothing... }
}
public class FileLogger extends Logger {
// ...省略部分代码...
@Override
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化level和message,输出到日志文件
fileWriter.write(...);
}
}
public class MessageQueueLogger extends Logger {
// ...省略部分代码...
@Override
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化level和message,输出到消息中间件
msgQueueClient.send(...);
}
}
```
这个设计思路能用,但是,它显然没有之前通过抽象类的实现思路优雅。我为什么这么说呢?主要有以下几点原因。
- 在Logger中定义一个空的方法会影响代码的可读性。如果我们不熟悉Logger背后的设计思想代码注释又不怎么给力我们在阅读Logger代码的时候就可能对为什么定义一个空的log()方法而感到疑惑需要查看Logger、FileLogger、MessageQueueLogger之间的继承关系才能弄明白其设计意图。
- 当创建一个新的子类继承Logger父类的时候我们有可能会忘记重新实现log()方法。之前基于抽象类的设计思路编译器会强制要求子类重写log()方法否则会报编译错误。你可能会说我既然要定义一个新的Logger子类怎么会忘记重新实现log()方法呢我们举的例子比较简单Logger中的方法不多代码行数也很少。但是如果Logger有几百行有n多方法除非你对Logger的设计非常熟悉否则忘记重新实现log()方法,也不是不可能的。
- Logger可以被实例化换句话说我们可以new一个Logger出来并且调用空的log()方法。这也增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,显然没有通过抽象类来的优雅。
**其次,我们再来看一下,我们为什么需要接口?它能够解决什么编程问题?**
抽象类更多的是为了代码复用而接口就更侧重于解耦。接口是对行为的一种抽象相当于一组协议或者契约你可以联想类比一下API接口。调用者只需要关注抽象的接口不需要了解具体的实现具体的实现代码对调用者透明。接口实现了约定和实现相分离可以降低代码间的耦合性提高代码的可扩展性。
实际上,接口是一个比抽象类应用更加广泛、更加重要的知识点。比如,我们经常提到的“基于接口而非实现编程”,就是一条几乎天天会用到,并且能极大地提高代码的灵活性、扩展性的设计思想。关于接口这个知识点,我会单独再用一节课的时间,更加详细全面的讲解,这里就不展开了。
## 如何模拟抽象类和接口两个语法概念?
在前面举的例子中我们使用Java的接口语法实现了一个Filter过滤器。不过如果你熟悉的是C++这种编程语言你可能会说C++只有抽象类并没有接口那从代码实现的角度上来说是不是就无法实现Filter的设计思路了呢
实际上,我们可以通过抽象类来模拟接口。怎么来模拟呢?这是一个不错的面试题,你可以先思考一下,然后再来看我的讲解。
我们先来回忆一下接口的定义接口中没有成员变量只有方法声明没有方法实现实现接口的类必须实现接口中的所有方法。只要满足这样几点从设计的角度上来说我们就可以把它叫作接口。实际上要满足接口的这些语法特性并不难。在下面这段C++代码中,我们就用抽象类模拟了一个接口(下面这段代码实际上是策略模式中的一段代码)。
```
class Strategy { // 用抽象类模拟接口
public:
~Strategy();
virtual void algorithm()=0;
protected:
Strategy();
};
```
抽象类Strategy没有定义任何属性并且所有的方法都声明为virtual类型等同于Java中的abstract关键字这样所有的方法都不能有代码实现并且所有继承这个抽象类的子类都要实现这些方法。从语法特性上来看这个抽象类就相当于一个接口。
不过如果你熟悉的既不是Java也不是C++而是现在比较流行的动态编程语言比如Python、Ruby等你可能还会有疑问在这些动态语言中不仅没有接口的概念也没有类似abstract、virtual这样的关键字来定义抽象类那该如何实现上面的讲到的Filter、Logger的设计思路呢实际上除了用抽象类来模拟接口之外我们还可以用普通类来模拟接口。具体的Java代码实现如下所示。
```
public class MockInteface {
protected MockInteface() {}
public void funcA() {
throw new MethodUnSupportedException();
}
}
```
我们知道类中的方法必须包含实现这个不符合接口的定义。但是我们可以让类中的方法抛出MethodUnSupportedException异常来模拟不包含实现的接口并且能强迫子类在继承这个父类的时候都去主动实现父类的方法否则就会在运行时抛出异常。我们将构造函数设置成protected属性的这样就能避免非同包下的类去实例化MockInterface。不过这样还是无法避免同包中的类去实例化MockInterface。为了解决这个问题我们可以学习Google Guava中@VisibleForTesting注解的做法,自定义一个注解,人为表明不可实例化。
刚刚我们讲了如何用抽象类来模拟接口,以及如何用普通类来模拟接口,那如何用普通类来模拟抽象类呢?这个问题留给你自己思考,你可以留言说说你的实现方法。
实际上对于动态编程语言来说还有一种对接口支持的策略那就是duck-typing。我们在上一节课中讲到多态的时候也有讲过你可以再回忆一下。
## 如何决定该用抽象类还是接口?
刚刚的讲解可能有些偏理论,现在,我们就从真实项目开发的角度来看一下,在代码设计、编程开发的时候,什么时候该用抽象类?什么时候该用接口?
实际上判断的标准很简单。如果我们要表示一种is-a的关系并且是为了解决代码复用的问题我们就用抽象类如果我们要表示一种has-a关系并且是为了解决抽象而非代码复用的问题那我们就可以使用接口。
从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。
## 重点回顾
好了,今天内容就讲完了,我们一块来总结回顾一下,你需要掌握的重点内容。
**1.抽象类和接口的语法特性**
抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。接口不能包含属性,只能声明方法,方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的所有方法。
**2.抽象类和接口存在的意义**
抽象类是对成员变量和方法的抽象是一种is-a关系是为了解决代码复用问题。接口仅仅是对方法的抽象是一种has-a关系表示具有某一组行为特性是为了解决解耦问题隔离接口和具体的实现提高代码的扩展性。
**3.抽象类和接口的应用场景区别**
什么时候该用抽象类什么时候该用接口实际上判断的标准很简单。如果要表示一种is-a的关系并且是为了解决代码复用问题我们就用抽象类如果要表示一种has-a关系并且是为了解决抽象而非代码复用问题那我们就用接口。
## 课堂讨论
1. 你熟悉的编程语言,是否有现成的语法支持接口和抽象类呢?具体是如何定义的呢?
1. 前面我们提到,接口和抽象类是两个经常在面试中被问到的概念。学习完今天的内容之后,你是否对抽象类和接口有一个新的认识呢?如果面试官再让你聊聊接口和抽象类,你会如何回答呢?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,192 @@
<audio id="audio" title="09 | 理论六:为什么基于接口而非实现编程?有必要为每个类都定义接口吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cf/00/cfb42ccc437904fabb1afd08944c5e00.mp3"></audio>
在上一节课中,我们讲了接口和抽象类,以及各种编程语言是如何支持、实现这两个语法概念的。今天,我们继续讲一个跟“接口”相关的知识点:基于接口而非实现编程。这个原则非常重要,是一种非常有效的提高代码质量的手段,在平时的开发中特别经常被用到。
为了让你理解透彻,并真正掌握这条原则如何应用,今天,我会结合一个有关图片存储的实战案例来讲解。除此之外,这条原则还很容易被过度应用,比如为每一个实现类都定义对应的接口。针对这类问题,在今天的讲解中,我也会告诉你如何来做权衡,怎样恰到好处地应用这条原则。
话不多说,让我们正式开始今天的学习吧!
## 如何解读原则中的“接口”二字?
“基于接口而非实现编程”这条原则的英文描述是“Program to an interface, not an implementation”。我们理解这条原则的时候千万不要一开始就与具体的编程语言挂钩局限在编程语言的“接口”语法中比如Java中的interface接口语法。这条原则最早出现于1994年GoF的《设计模式》这本书它先于很多编程语言而诞生比如Java语言是一条比较抽象、泛化的设计思想。
实际上,理解这条原则的关键,就是理解其中的“接口”两个字。还记得我们上一节课讲的“接口”的定义吗?从本质上来看,“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。“接口”在不同的应用场景下会有不同的解读,比如服务端与客户端之间的“接口”,类库提供的“接口”,甚至是一组通信的协议都可以叫作“接口”。刚刚对“接口”的理解,都比较偏上层、偏抽象,与实际的写代码离得有点远。如果落实到具体的编码,“基于接口而非实现编程”这条原则中的“接口”,可以理解为编程语言中的接口或者抽象类。
前面我们提到,这条原则能非常有效地提高代码质量,之所以这么说,那是因为,应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。
实际上,“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。**越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。**而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。
## 如何将这条原则应用到实战中?
对于这条原则,我们结合一个具体的实战案例来进一步讲解一下。
假设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。为了代码复用我们封装了图片存储相关的代码逻辑提供了一个统一的AliyunImageStore类供整个系统来使用。具体的代码实现如下所示
```
public class AliyunImageStore {
//...省略属性、构造函数等...
public void createBucketIfNotExisting(String bucketName) {
// ...创建bucket代码逻辑...
// ...失败会抛出异常..
}
public String generateAccessToken() {
// ...根据accesskey/secrectkey等生成access token
}
public String uploadToAliyun(Image image, String bucketName, String accessToken) {
//...上传图片到阿里云...
//...返回图片存储在阿里云上的地址(url...
}
public Image downloadFromAliyun(String url, String accessToken) {
//...从阿里云下载图片...
}
}
// AliyunImageStore类的使用举例
public class ImageProcessingJob {
private static final String BUCKET_NAME = &quot;ai_images_bucket&quot;;
//...省略其他无关代码...
public void process() {
Image image = ...; //处理图片并封装为Image对象
AliyunImageStore imageStore = new AliyunImageStore(/*省略参数*/);
imageStore.createBucketIfNotExisting(BUCKET_NAME);
String accessToken = imageStore.generateAccessToken();
imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken);
}
}
```
整个上传流程包含三个步骤创建bucket你可以简单理解为存储目录、生成access token访问凭证、携带access token上传图片到指定的bucket中。代码实现非常简单类中的几个方法定义得都很干净用起来也很清晰乍看起来没有太大问题完全能满足我们将图片存储在阿里云的业务需求。
不过,软件开发中唯一不变的就是变化。过了一段时间后,我们自建了私有云,不再将图片存储到阿里云了,而是将图片存储到自建私有云上。为了满足这样一个需求的变化,我们该如何修改代码呢?
我们需要重新设计实现一个存储图片到私有云的PrivateImageStore类并用它替换掉项目中所有的AliyunImageStore类对象。这样的修改听起来并不复杂只是简单替换而已对整个代码的改动并不大。不过我们经常说“细节是魔鬼”。这句话在软件开发中特别适用。实际上刚刚的设计实现方式就隐藏了很多容易出问题的“魔鬼细节”我们一块来看看都有哪些。
新的PrivateImageStore类需要设计实现哪些方法才能在尽量最小化代码修改的情况下替换掉AliyunImageStore类呢这就要求我们必须将AliyunImageStore类中所定义的所有public方法在PrivateImageStore类中都逐一定义并重新实现一遍。而这样做就会存在一些问题我总结了下面两点。
首先AliyunImageStore类中有些函数命名暴露了实现细节比如uploadToAliyun()和downloadFromAliyun()。如果开发这个功能的同事没有接口意识、抽象思维那这种暴露实现细节的命名方式就不足为奇了毕竟最初我们只考虑将图片存储在阿里云上。而我们把这种包含“aliyun”字眼的方法照抄到PrivateImageStore类中显然是不合适的。如果我们在新类中重新命名uploadToAliyun()、downloadFromAliyun()这些方法,那就意味着,我们要修改项目中所有使用到这两个方法的代码,代码修改量可能就会很大。
其次将图片存储到阿里云的流程跟存储到私有云的流程可能并不是完全一致的。比如阿里云的图片上传和下载的过程中需要生产access token而私有云不需要access token。一方面AliyunImageStore中定义的generateAccessToken()方法不能照抄到PrivateImageStore中另一方面我们在使用AliyunImageStore上传、下载图片的时候代码中用到了generateAccessToken()方法,如果要改为私有云的上传下载流程,这些代码都需要做调整。
那这两个问题该如何解决呢解决这个问题的根本方法就是在编写代码的时候要遵从“基于接口而非实现编程”的原则具体来讲我们需要做到下面这3点。
1. 函数的命名不能暴露任何实现细节。比如前面提到的uploadToAliyun()就不符合要求应该改为去掉aliyun这样的字眼改为更加抽象的命名方式比如upload()。
1. 封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
1. 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。
我们按照这个思路,把代码重构一下。重构后的代码如下所示:
```
public interface ImageStore {
String upload(Image image, String bucketName);
Image download(String url);
}
public class AliyunImageStore implements ImageStore {
//...省略属性、构造函数等...
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
String accessToken = generateAccessToken();
//...上传图片到阿里云...
//...返回图片在阿里云上的地址(url)...
}
public Image download(String url) {
String accessToken = generateAccessToken();
//...从阿里云下载图片...
}
private void createBucketIfNotExisting(String bucketName) {
// ...创建bucket...
// ...失败会抛出异常..
}
private String generateAccessToken() {
// ...根据accesskey/secrectkey等生成access token
}
}
// 上传下载流程改变私有云不需要支持access token
public class PrivateImageStore implements ImageStore {
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
//...上传图片到私有云...
//...返回图片的url...
}
public Image download(String url) {
//...从私有云下载图片...
}
private void createBucketIfNotExisting(String bucketName) {
// ...创建bucket...
// ...失败会抛出异常..
}
}
// ImageStore的使用举例
public class ImageProcessingJob {
private static final String BUCKET_NAME = &quot;ai_images_bucket&quot;;
//...省略其他无关代码...
public void process() {
Image image = ...;//处理图片并封装为Image对象
ImageStore imageStore = new PrivateImageStore(...);
imagestore.upload(image, BUCKET_NAME);
}
}
```
除此之外很多人在定义接口的时候希望通过实现类来反推接口的定义。先把实现类写好然后看实现类中有哪些方法照抄到接口定义中。如果按照这种思考方式就有可能导致接口定义不够抽象依赖具体的实现。这样的接口设计就没有意义了。不过如果你觉得这种思考方式更加顺畅那也没问题只是将实现类的方法搬移到接口定义中的时候要有选择性的搬移不要将跟具体实现相关的方法搬移到接口中比如AliyunImageStore中的generateAccessToken()方法。
总结一下,我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。在定义接口的时候,不要暴露任何实现细节。接口的定义只表明做什么,而不是怎么做。而且,在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。
## 是否需要为每个类定义接口?
看了刚刚的讲解,你可能会有这样的疑问:为了满足这条原则,我是不是需要给每个实现类都定义对应的接口呢?在开发的时候,是不是任何代码都要只依赖接口,完全不依赖实现编程呢?
做任何事情都要讲求一个“度”,过度使用这条原则,非得给每个类都定义接口,接口满天飞,也会导致不必要的开发负担。至于什么时候,该为某个类定义接口,实现基于接口的编程,什么时候不需要定义接口,直接使用实现类编程,我们做权衡的根本依据,还是要回归到设计原则诞生的初衷上来。只要搞清楚了这条原则是为了解决什么样的问题而产生的,你就会发现,很多之前模棱两可的问题,都会变得豁然开朗。
前面我们也提到,这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。
从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。
除此之外,越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性,投入不必要的开发时间。
## 重点回顾
今天的内容到此就讲完了。我们来一块总结回顾一下,你需要掌握的重点内容。
1.“基于接口而非实现编程”,这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性、扩展性、可维护性。
2.我们在定义接口的时候,一方面,命名要足够通用,不能包含跟具体实现相关的字眼;另一方面,与特定实现有关的方法不要定义在接口中。
3.“基于接口而非实现编程”这条原则,不仅仅可以指导非常细节的编程开发,还能指导更加上层的架构设计、系统设计等。比如,服务端与客户端之间的“接口”设计、类库的“接口”设计。
## 课堂讨论
在今天举的代码例子中尽管我们通过接口来隔离了两个具体的实现。但是在项目中很多地方我们都是通过下面第8行的方式来使用接口的。这就会产生一个问题那就是如果我们要替换图片存储方式还是需要修改很多类似第8行那样的代码。这样的设计还是不够完美对此你有更好的实现思路吗
```
// ImageStore的使用举例
public class ImageProcessingJob {
private static final String BUCKET_NAME = &quot;ai_images_bucket&quot;;
//...省略其他无关代码...
public void process() {
Image image = ...;//处理图片并封装为Image对象
ImageStore imageStore = new PrivateImageStore(/*省略构造函数*/);
imagestore.upload(image, BUCKET_NAME);
}
```
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,193 @@
<audio id="audio" title="10 | 理论七:为何说要多用组合少用继承?如何决定该用组合还是继承?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1b/21/1ba5e1f013dc89eec2f928757158b121.mp3"></audio>
在面向对象编程中,有一条非常经典的设计原则,那就是:组合优于继承,多用组合少用继承。为什么不推荐使用继承?组合相比继承有哪些优势?如何判断该用组合还是继承?今天,我们就围绕着这三个问题,来详细讲解一下这条设计原则。
话不多说,让我们正式开始今天的学习吧!
## 为什么不推荐使用继承?
继承是面向对象的四大特性之一用来表示类之间的is-a关系可以解决代码复用的问题。虽然继承有诸多作用但继承层次过深、过复杂也会影响到代码的可维护性。所以对于是否应该在项目中使用继承网上有很多争议。很多人觉得继承是一种反模式应该尽量少用甚至不用。为什么会有这样的争议我们通过一个例子来解释一下。
假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念定义为一个抽象类AbstractBird。所有更细分的鸟比如麻雀、鸽子、乌鸦等都继承这个抽象类。
我们知道大部分鸟都会飞那我们可不可以在AbstractBird抽象类中定义一个fly()方法呢答案是否定的。尽管大部分鸟都会飞但也有特例比如鸵鸟就不会飞。鸵鸟继承具有fly()方法的父类那鸵鸟就具有“飞”这样的行为这显然不符合我们对现实世界中事物的认识。当然你可能会说我在鸵鸟这个子类中重写overridefly()方法让它抛出UnSupportedMethodException异常不就可以了吗具体的代码实现如下所示
```
public class AbstractBird {
//...省略其他属性和方法...
public void fly() { //... }
}
public class Ostrich extends AbstractBird { //鸵鸟
//...省略其他属性和方法...
public void fly() {
throw new UnSupportedMethodException(&quot;I can't fly.'&quot;);
}
}
```
这种设计思路虽然可以解决问题但不够优美。因为除了鸵鸟之外不会飞的鸟还有很多比如企鹅。对于这些不会飞的鸟来说我们都需要重写fly()方法抛出异常。这样的设计一方面徒增了编码的工作量另一方面也违背了我们之后要讲的最小知识原则Least Knowledge Principle也叫最少知识原则或者迪米特法则暴露不该暴露的接口给外部增加了类使用过程中被误用的概率。
你可能又会说那我们再通过AbstractBird类派生出两个更加细分的抽象类会飞的鸟类AbstractFlyableBird和不会飞的鸟类AbstractUnFlyableBird让麻雀、乌鸦这些会飞的鸟都继承AbstractFlyableBird让鸵鸟、企鹅这些不会飞的鸟都继承AbstractUnFlyableBird类不就可以了吗具体的继承关系如下图所示
<img src="https://static001.geekbang.org/resource/image/1e/b7/1e27919f63ef615dba98bc00673914b7.jpg" alt="">
从图中我们可以看出,继承关系变成了三层。不过,整体上来讲,目前的继承关系还比较简单,层次比较浅,也算是一种可以接受的设计思路。我们再继续加点难度。在刚刚这个场景中,我们只关注“鸟会不会飞”,但如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢?
是否会飞是否会叫两个行为搭配起来会产生四种情况会飞会叫、不会飞会叫、会飞不会叫、不会飞不会叫。如果我们继续沿用刚才的设计思路那就需要再定义四个抽象类AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird
<img src="https://static001.geekbang.org/resource/image/3f/c6/3f99fa541e7ec7656a1dd35cc4f28bc6.jpg" alt="">
如果我们还需要考虑“是否会下蛋”这样一个行为,那估计就要组合爆炸了。类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。
总之,继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。这也是为什么我们不推荐使用继承。那刚刚例子中继承存在的问题,我们又该如何来解决呢?你可以先自己思考一下,再听我下面的讲解。
## 组合相比继承有哪些优势?
实际上我们可以利用组合composition、接口、委托delegation三个技术手段一块儿来解决刚刚继承存在的问题。
我们前面讲到接口的时候说过接口表示具有某种行为特性。针对“会飞”这样一个行为特性我们可以定义一个Flyable接口只让会飞的鸟去实现这个接口。对于会叫、会下蛋这些行为特性我们可以类似地定义Tweetable接口、EggLayable接口。我们将这个设计思路翻译成Java代码的话就是下面这个样子
```
public interface Flyable {
void fly();
}
public interface Tweetable {
void tweet();
}
public interface EggLayable {
void layEgg();
}
public class Ostrich implements Tweetable, EggLayable {//鸵鸟
//... 省略其他属性和方法...
@Override
public void tweet() { //... }
@Override
public void layEgg() { //... }
}
public class Sparrow impelents Flyable, Tweetable, EggLayable {//麻雀
//... 省略其他属性和方法...
@Override
public void fly() { //... }
@Override
public void tweet() { //... }
@Override
public void layEgg() { //... }
}
```
不过我们知道接口只声明方法不定义实现。也就是说每个会下蛋的鸟都要实现一遍layEgg()方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?
我们可以针对三个接口再定义三个实现类它们分别是实现了fly()方法的FlyAbility类、实现了tweet()方法的TweetAbility类、实现了layEgg()方法的EggLayAbility类。然后通过组合和委托技术来消除代码重复。具体的代码实现如下所示
```
public interface Flyable {
void fly()
}
public class FlyAbility implements Flyable {
@Override
public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility
public class Ostrich implements Tweetable, EggLayable {//鸵鸟
private TweetAbility tweetAbility = new TweetAbility(); //组合
private EggLayAbility eggLayAbility = new EggLayAbility(); //组合
//... 省略其他属性和方法...
@Override
public void tweet() {
tweetAbility.tweet(); // 委托
}
@Override
public void layEgg() {
eggLayAbility.layEgg(); // 委托
}
}
```
我们知道继承主要有三个作用表示is-a关系支持多态特性代码复用。而这三个作用都可以通过其他技术手段来达成。比如is-a关系我们可以通过组合和接口的has-a关系来替代多态特性我们可以利用接口来实现代码复用我们可以通过组合和委托来实现。所以从理论上讲通过组合、接口、委托三个技术手段我们完全可以替换掉继承在项目中不用或者少用继承关系特别是一些复杂的继承关系。
## 如何判断该用组合还是继承?
尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。
如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。
除此之外还有一些设计模式会固定使用继承或者组合。比如装饰者模式decorator pattern、策略模式strategy pattern、组合模式composite pattern等都使用了组合关系而模板模式template pattern使用了继承关系。
前面我们讲到继承可以实现代码复用。利用继承特性我们把相同的属性和方法抽取出来定义到父类中。子类复用父类中的属性和方法达到代码复用的目的。但是有的时候从业务含义上A类和B类并不一定具有继承关系。比如Crawler类和PageAnalyzer类它们都用到了URL拼接和分割的功能但并不具有继承关系既不是父子关系也不是兄弟关系。仅仅为了代码复用生硬地抽象出一个父类出来会影响到代码的可读性。如果不熟悉背后设计思路的同事发现Crawler类和PageAnalyzer类继承同一个父类而父类中定义的却只是URL相关的操作会觉得这个代码写得莫名其妙理解不了。这个时候使用组合就更加合理、更加灵活。具体的代码实现如下所示
```
public class Url {
//...省略属性和方法
}
public class Crawler {
private Url url; // 组合
public Crawler() {
this.url = new Url();
}
//...
}
public class PageAnalyzer {
private Url url; // 组合
public PageAnalyzer() {
this.url = new Url();
}
//..
}
```
还有一些特殊的场景要求我们必须使用继承。如果你不能改变一个函数的入参类型而入参又非接口为了支持多态只能采用继承来实现。比如下面这样一段代码其中FeignClient是一个外部类我们没有权限去修改这部分代码但是我们希望能重写这个类在运行时执行的encode()函数。这个时候,我们只能采用继承来实现了。
```
public class FeignClient { // Feign Client框架代码
//...省略其他代码...
public void encode(String url) { //... }
}
public void demofunction(FeignClient feignClient) {
//...
feignClient.encode(url);
//...
}
public class CustomizedFeignClient extends FeignClient {
@Override
public void encode(String url) { //...重写encode的实现...}
}
// 调用
FeignClient client = new CustomizedFeignClient();
demofunction(client);
```
尽管有些人说要杜绝继承100%用组合代替继承,但是我的观点没那么极端!之所以“多用组合少用继承”这个口号喊得这么响,只是因为,长期以来,我们过度使用继承。还是那句话,组合并不完美,继承也不是一无是处。只要我们控制好它们的副作用、发挥它们各自的优势,在不同的场合下,恰当地选择使用继承还是组合,这才是我们所追求的境界。
## 重点回顾
到此,今天的内容就讲完了。我们一块儿来回顾一下,你需要重点掌握的知识点。
**1.为什么不推荐使用继承?**
继承是面向对象的四大特性之一用来表示类之间的is-a关系可以解决代码复用的问题。虽然继承有诸多作用但继承层次过深、过复杂也会影响到代码的可维护性。在这种情况下我们应该尽量少用甚至不用继承。
**2.组合相比继承有哪些优势?**
继承主要有三个作用表示is-a关系支持多态特性代码复用。而这三个作用都可以通过组合、接口、委托三个技术手段来达成。除此之外利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。
**3.如何判断该用组合还是继承?**
尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。在实际的项目开发中,我们还是要根据具体的情况,来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,我们就可以大胆地使用继承。反之,我们就尽量使用组合来替代继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。
## 课堂讨论
我们在基于MVC架构开发Web应用的时候经常会在数据库层定义Entity在Service业务层定义BOBusiness Object在Controller接口层定义VOView Object。大部分情况下Entity、BO、VO三者之间的代码有很大重复但又不完全相同。我们该如何处理Entity、BO、VO代码重复的问题呢
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,151 @@
<audio id="audio" title="11 | 实战一业务开发常用的基于贫血模型的MVC架构违背OOP吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/28/949fe45c82caee3298905039cf341d28.mp3"></audio>
在前面几节课中,我们学习了面向对象的一些理论知识,比如,面向对象四大特性、接口和抽象类、面向对象和面向过程编程风格、基于接口而非实现编程和多用组合少用继承设计思想等等。接下来,我们再用四节课的时间,通过两个更加贴近实战的项目来进一步学习,如何将这些理论应用到实际的软件开发中。
据我了解大部分工程师都是做业务开发的所以今天我们讲的这个实战项目也是一个典型的业务系统开发案例。我们都知道很多业务系统都是基于MVC三层架构来开发的。实际上更确切点讲这是一种基于贫血模型的MVC三层架构开发模式。
虽然这种开发模式已经成为标准的Web项目的开发模式但它却违反了面向对象编程风格是一种彻彻底底的面向过程的编程风格因此而被有些人称为[反模式anti-pattern](https://zh.wikipedia.org/wiki/%E5%8F%8D%E9%9D%A2%E6%A8%A1%E5%BC%8F)。特别是**领域驱动设计**Domain Driven Design简称DDD盛行之后这种基于贫血模型的传统的开发模式就更加被人诟病。而基于充血模型的DDD开发模式越来越被人提倡。所以我打算用两节课的时间结合一个虚拟钱包系统的开发案例带你彻底弄清楚这两种开发模式。
考虑到你有可能不太了解我刚刚提到的这几个概念,所以,在正式进入实战项目的讲解之前,我先带你搞清楚下面几个问题:
- 什么是贫血模型?什么是充血模型?
- 为什么说基于贫血模型的传统开发模式违反OOP?
- 基于贫血模型的传统开发模式既然违反OOP那又为什么如此流行
- 什么情况下我们应该考虑使用基于充血模型的DDD开发模式
好了,让我们带着这些问题,正式开始今天的学习吧!
## 什么是基于贫血模型的传统开发模式?
我相信对于大部分的后端开发工程师来说MVC三层架构都不会陌生。不过为了统一我们之间对MVC的认识我还是带你一块来回顾一下什么是MVC三层架构。
MVC三层架构中的M表示ModelV表示ViewC表示Controller。它将整个项目分为三层展示层、逻辑层、数据层。MVC三层开发架构是一个比较笼统的分层方式落实到具体的开发层面很多项目也并不会100%遵从MVC固定的分层方式而是会根据具体的项目需求做适当的调整。
比如现在很多Web或者App项目都是前后端分离的后端负责暴露接口给前端调用。这种情况下我们一般就将后端项目分为Repository层、Service层、Controller层。其中Repository层负责数据访问Service层负责业务逻辑Controller层负责暴露接口。当然这只是其中一种分层和命名方式。不同的项目、不同的团队可能会对此有所调整。不过万变不离其宗只要是依赖数据库开发的Web项目基本的分层思路都大差不差。
刚刚我们回顾了MVC三层开发架构。现在我们再来看一下什么是贫血模型
实际上,你可能一直都在用贫血模型做开发,只是自己不知道而已。不夸张地讲,据我了解,目前几乎所有的业务后端系统,都是基于贫血模型的。我举一个简单的例子来给你解释一下。
```
////////// Controller+VO(View Object) //////////
public class UserController {
private UserService userService; //通过构造函数或者IOC框架注入
public UserVo getUserById(Long userId) {
UserBo userBo = userService.getUserById(userId);
UserVo userVo = [...convert userBo to userVo...];
return userVo;
}
}
public class UserVo {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}
////////// Service+BO(Business Object) //////////
public class UserService {
private UserRepository userRepository; //通过构造函数或者IOC框架注入
public UserBo getUserById(Long userId) {
UserEntity userEntity = userRepository.getUserById(userId);
UserBo userBo = [...convert userEntity to userBo...];
return userBo;
}
}
public class UserBo {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}
////////// Repository+Entity //////////
public class UserRepository {
public UserEntity getUserById(Long userId) { //... }
}
public class UserEntity {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}
```
我们平时开发Web后端项目的时候基本上都是这么组织代码的。其中UserEntity和UserRepository组成了数据访问层UserBo和UserService组成了业务逻辑层UserVo和UserController在这里属于接口层。
从代码中我们可以发现UserBo是一个纯粹的数据结构只包含数据不包含任何业务逻辑。业务逻辑集中在UserService中。我们通过UserService来操作UserBo。换句话说Service层的数据和业务逻辑被分割为BO和Service两个类中。像UserBo这样只包含数据不包含业务逻辑的类就叫作**贫血模型**Anemic Domain Model。同理UserEntity、UserVo都是基于贫血模型设计的。这种贫血模型将数据与操作分离破坏了面向对象的封装特性是一种典型的面向过程的编程风格。
## 什么是基于充血模型的DDD开发模式
刚刚我们讲了基于贫血模型的传统的开发模式。现在我们再讲一下另外一种最近更加被推崇的开发模式基于充血模型的DDD开发模式。
**首先,我们先来看一下,什么是充血模型?**
在贫血模型中,数据和业务逻辑被分割到不同的类中。**充血模型**Rich Domain Model正好相反数据和对应的业务逻辑被封装到同一个类中。因此这种充血模型满足面向对象的封装特性是典型的面向对象编程风格。
**接下来,我们再来看一下,什么是领域驱动设计?**
领域驱动设计即DDD主要是用来指导如何解耦业务系统划分业务模块定义业务领域模型及其交互。领域驱动设计这个概念并不新颖早在2004年就被提出了到现在已经有十几年的历史了。不过它被大众熟知还是基于另一个概念的兴起那就是微服务。
我们知道除了监控、调用链追踪、API网关等服务治理系统的开发之外微服务还有另外一个更加重要的工作那就是针对公司的业务合理地做微服务拆分。而领域驱动设计恰好就是用来指导划分服务的。所以微服务加速了领域驱动设计的盛行。
不过我个人觉得领域驱动设计有点儿类似敏捷开发、SOA、PAAS等概念听起来很高大上但实际上只值“五分钱”。即便你没有听说过领域驱动设计对这个概念一无所知只要你是在开发业务系统也或多或少都在使用它。做好领域驱动设计的关键是看你对自己所做业务的熟悉程度而并不是对领域驱动设计这个概念本身的掌握程度。即便你对领域驱动搞得再清楚但是对业务不熟悉也并不一定能做出合理的领域设计。所以不要把领域驱动设计当银弹不要花太多的时间去过度地研究它。
实际上基于充血模型的DDD开发模式实现的代码也是按照MVC三层架构分层的。Controller层还是负责暴露接口Repository层还是负责数据存取Service层负责核心业务逻辑。它跟基于贫血模型的传统开发模式的区别主要在Service层。
在基于贫血模型的传统开发模式中Service层包含Service类和BO类两部分BO是贫血模型只包含数据不包含具体的业务逻辑。业务逻辑集中在Service类中。在基于充血模型的DDD开发模式中Service层包含Service类和Domain类两部分。Domain就相当于贫血模型中的BO。不过Domain与BO的区别在于它是基于充血模型开发的既包含数据也包含业务逻辑。而Service类变得非常单薄。总结一下的话就是基于贫血模型的传统的开发模式重Service轻BO基于充血模型的DDD开发模式轻Service重Domain。
基于充血模型的DDD设计模式的概念今天我们只是简单地介绍了一下。在下一节课中我会结合具体的项目通过代码来给你展示如何基于这种开发模式来开发一个系统。
## 为什么基于贫血模型的传统开发模式如此受欢迎?
前面我们讲过基于贫血模型的传统开发模式将数据与业务逻辑分离违反了OOP的封装特性实际上是一种面向过程的编程风格。但是现在几乎所有的Web项目都是基于这种贫血模型的开发模式甚至连Java Spring框架的官方demo都是按照这种开发模式来编写的。
我们前面也讲过,面向过程编程风格有种种弊端,比如,数据和操作分离之后,数据本身的操作就不受限制了。任何代码都可以随意修改数据。既然基于贫血模型的这种传统开发模式是面向过程编程风格的,那它又为什么会被广大程序员所接受呢?关于这个问题,我总结了下面三点原因。
第一点原因是大部分情况下我们开发的系统业务可能都比较简单简单到就是基于SQL的CRUD操作所以我们根本不需要动脑子精心设计充血模型贫血模型就足以应付这种简单业务的开发工作。除此之外因为业务比较简单即便我们使用充血模型那模型本身包含的业务逻辑也并不会很多设计出来的领域模型也会比较单薄跟贫血模型差不多没有太大意义。
第二点原因是充血模型的设计要比贫血模型更加有难度。因为充血模型是一种面向对象的编程风格。我们从一开始就要设计好针对数据要暴露哪些操作定义哪些业务逻辑。而不是像贫血模型那样我们只需要定义数据之后有什么功能开发需求我们就在Service层定义什么操作不需要事先做太多设计。
第三点原因是思维已固化转型有成本。基于贫血模型的传统开发模式经历了这么多年已经深得人心、习以为常。你随便问一个旁边的大龄同事基本上他过往参与的所有Web项目应该都是基于这个开发模式的而且也没有出过啥大问题。如果转向用充血模型、领域驱动设计那势必有一定的学习成本、转型成本。很多人在没有遇到开发痛点的情况下是不愿意做这件事情的。
## 什么项目应该考虑使用基于充血模型的DDD开发模式
既然基于贫血模型的开发模式已经成为了一种约定俗成的开发习惯那什么样的项目应该考虑使用基于充血模型的DDD开发模式呢
刚刚我们讲到基于贫血模型的传统的开发模式比较适合业务比较简单的系统开发。相对应的基于充血模型的DDD开发模式更适合业务复杂的系统开发。比如包含各种利息计算模型、还款模型等复杂业务的金融系统。
你可能会有一些疑问这两种开发模式落实到代码层面区别不就是一个将业务逻辑放到Service类中一个将业务逻辑放到Domain领域模型中吗为什么基于贫血模型的传统开发模式就不能应对复杂业务系统的开发而基于充血模型的DDD开发模式就可以呢
实际上除了我们能看到的代码层面的区别之外一个业务逻辑放到Service层一个放到领域模型中还有一个非常重要的区别那就是两种不同的开发模式会导致不同的开发流程。基于充血模型的DDD开发模式的开发流程在应对复杂业务系统的开发的时候更加有优势。为什么这么说呢我们先来回忆一下我们平时基于贫血模型的传统的开发模式都是怎么实现一个功能需求的。
不夸张地讲我们平时的开发大部分都是SQL驱动SQL-Driven的开发模式。我们接到一个后端接口的开发需求的时候就去看接口需要的数据对应到数据库中需要哪张表或者哪几张表然后思考如何编写SQL语句来获取数据。之后就是定义Entity、BO、VO然后模板式地往对应的Repository、Service、Controller类中添加代码。
业务逻辑包裹在一个大的SQL语句中而Service层可以做的事情很少。SQL都是针对特定的业务功能编写的复用性差。当我要开发另一个业务功能的时候只能重新写个满足新需求的SQL语句这就可能导致各种长得差不多、区别很小的SQL语句满天飞。
所以在这个过程中很少有人会应用领域模型、OOP的概念也很少有代码复用意识。对于简单业务系统来说这种开发方式问题不大。但对于复杂业务系统的开发来说这样的开发方式会让代码越来越混乱最终导致无法维护。
如果我们在项目中应用基于充血模型的DDD的开发模式那对应的开发流程就完全不一样了。在这种开发模式下我们需要事先理清楚所有的业务定义领域模型所包含的属性和方法。领域模型相当于可复用的业务中间层。新功能需求的开发都基于之前定义好的这些领域模型来完成。
我们知道越复杂的系统对代码的复用性、易维护性要求就越高我们就越应该花更多的时间和精力在前期设计上。而基于充血模型的DDD开发模式正好需要我们前期做大量的业务调研、领域模型设计所以它更加适合这种复杂系统的开发。
## 重点回顾
今天的内容到此就讲完了,我们来一起回顾一下,你应该掌握的重点内容。
我们平时做Web项目的业务开发大部分都是基于贫血模型的MVC三层架构在专栏中我把它称为传统的开发模式。之所以称之为“传统”是相对于新兴的基于充血模型的DDD开发模式来说的。基于贫血模型的传统开发模式是典型的面向过程的编程风格。相反基于充血模型的DDD开发模式是典型的面向对象的编程风格。
不过DDD也并非银弹。对于业务不复杂的系统开发来说基于贫血模型的传统开发模式简单够用基于充血模型的DDD开发模式有点大材小用无法发挥作用。相反对于业务复杂的系统开发来说基于充血模型的DDD开发模式因为前期需要在设计上投入更多时间和精力来提高代码的复用性和可维护性所以相比基于贫血模型的开发模式更加有优势。
## 课堂讨论
今天课堂讨论的话题有两个。
1. 你做经历的项目中有哪些是基于贫血模型的传统的开发模式有哪些是基于充血模型的DDD开发模式呢请简单对比一下两者的优劣。
1. 对于我们举的例子中UserEntity、UserBo、UserVo包含的字段都差不多是否可以合并为一个类呢
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,342 @@
<audio id="audio" title="12 | 实战一如何利用基于充血模型的DDD开发一个虚拟钱包系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/b2/a131f7ae20c05eb288470246ce4d9ab2.mp3"></audio>
上一节课我们做了一些理论知识的铺垫性讲解讲到了两种开发模式基于贫血模型的传统开发模式以及基于充血模型的DDD开发模式。今天我们正式进入实战环节看如何分别用这两种开发模式设计实现一个钱包系统。
话不多说,让我们正式开始今天的学习吧!
## 钱包业务背景介绍
很多具有支付、购买功能的应用(比如淘宝、滴滴出行、极客时间等)都支持钱包的功能。应用为每个用户开设一个系统内的虚拟钱包账户,支持用户充值、提现、支付、冻结、透支、转赠、查询账户余额、查询交易流水等操作。下图是一张典型的钱包功能界面,你可以直观地感受一下。
<img src="https://static001.geekbang.org/resource/image/9e/4a/9e91377602ef154eaf866c7e9263a64a.jpg" alt="">
一般来讲,每个虚拟钱包账户都会对应用户的一个真实的支付账户,有可能是银行卡账户,也有可能是三方支付账户(比如支付宝、微信钱包)。为了方便后续的讲解,我们限定钱包暂时只支持充值、提现、支付、查询余额、查询交易流水这五个核心的功能,其他比如冻结、透支、转赠等不常用的功能,我们暂不考虑。为了让你理解这五个核心功能是如何工作的,接下来,我们来一块儿看下它们的业务实现流程。
### 1.充值
用户通过三方支付渠道,把自己银行卡账户内的钱,充值到虚拟钱包账号中。这整个过程,我们可以分解为三个主要的操作流程:第一个操作是从用户的银行卡账户转账到应用的公共银行卡账户;第二个操作是将用户的充值金额加到虚拟钱包余额上;第三个操作是记录刚刚这笔交易流水。
<img src="https://static001.geekbang.org/resource/image/39/14/3915a6544403854d35678c81fe65f014.jpg" alt="">
### 2.支付
用户用钱包内的余额,支付购买应用内的商品。实际上,支付的过程就是一个转账的过程,从用户的虚拟钱包账户划钱到商家的虚拟钱包账户上。除此之外,我们也需要记录这笔支付的交易流水信息。
<img src="https://static001.geekbang.org/resource/image/7e/5e/7eb44e2f8661d1c3debde85f79fb2c5e.jpg" alt="">
### 3.提现
除了充值、支付之外,用户还可以将虚拟钱包中的余额,提现到自己的银行卡中。这个过程实际上就是扣减用户虚拟钱包中的余额,并且触发真正的银行转账操作,从应用的公共银行账户转钱到用户的银行账户。同样,我们也需要记录这笔提现的交易流水信息。
<img src="https://static001.geekbang.org/resource/image/66/43/66ede1de93d29b86a9194ea0f80d1e43.jpg" alt="">
### 4.查询余额
查询余额功能比较简单,我们看一下虚拟钱包中的余额数字即可。
### 5.查询交易流水
查询交易流水也比较简单。我们只支持三种类型的交易流水:充值、支付、提现。在用户充值、支付、提现的时候,我们会记录相应的交易信息。在需要查询的时候,我们只需要将之前记录的交易流水,按照时间、类型等条件过滤之后,显示出来即可。
## 钱包系统的设计思路
根据刚刚讲的业务实现流程和数据流转图,我们可以把整个钱包系统的业务划分为两部分,其中一部分单纯跟应用内的虚拟钱包账户打交道,另一部分单纯跟银行账户打交道。我们基于这样一个业务划分,给系统解耦,将整个钱包系统拆分为两个子系统:虚拟钱包系统和三方支付系统。
<img src="https://static001.geekbang.org/resource/image/60/62/60d3cfec73986b52e3a6ef4fe147e562.jpg" alt="">
为了能在有限的篇幅内,将今天的内容讲透彻,我们接来下只聚焦于虚拟钱包系统的设计与实现。对于三方支付系统以及整个钱包系统的设计与实现,我们不做讲解。你可以自己思考下。
**现在我们来看下,如果要支持钱包的这五个核心功能,虚拟钱包系统需要对应实现哪些操作。**我画了一张图,列出了这五个功能都会对应虚拟钱包的哪些操作。注意,交易流水的记录和查询,我暂时在图中打了个问号,那是因为这块比较特殊,我们待会再讲。
<img src="https://static001.geekbang.org/resource/image/d1/30/d1a9aeb6642404f80a62293ab2e45630.jpg" alt="">
从图中我们可以看出,虚拟钱包系统要支持的操作非常简单,就是余额的加加减减。其中,充值、提现、查询余额三个功能,只涉及一个账户余额的加减操作,而支付功能涉及两个账户的余额加减操作:一个账户减余额,另一个账户加余额。
**现在,我们再来看一下图中问号的那部分,也就是交易流水该如何记录和查询?**我们先来看一下,交易流水都需要包含哪些信息。我觉得下面这几个信息是必须包含的。
<img src="https://static001.geekbang.org/resource/image/38/68/38b56bd1981d8b40ececa4d638e4a968.jpg" alt="">
从图中我们可以发现,交易流水的数据格式包含两个钱包账号,一个是入账钱包账号,一个是出账钱包账号。为什么要有两个账号信息呢?这主要是为了兼容支付这种涉及两个账户的交易类型。不过,对于充值、提现这两种交易类型来说,我们只需要记录一个钱包账户信息就够了。
整个虚拟钱包的设计思路到此讲完了。接下来我们来看一下如何分别用基于贫血模型的传统开发模式和基于充血模型的DDD开发模式来实现这样一个虚拟钱包系统
## 基于贫血模型的传统开发模式
实际上如果你有一定Web项目的开发经验并且听明白了我刚刚讲的设计思路那对你来说利用基于贫血模型的传统开发模式来实现这样一个系统应该是一件挺简单的事情。不过为了对比两种开发模式我还是带你一块儿来实现一遍。
这是一个典型的Web后端项目的三层结构。其中Controller和VO负责暴露接口具体的代码实现如下所示。注意Controller中接口实现比较简单主要就是调用Service的方法所以我省略了具体的代码实现。
```
public class VirtualWalletController {
// 通过构造函数或者IOC框架注入
private VirtualWalletService virtualWalletService;
public BigDecimal getBalance(Long walletId) { ... } //查询余额
public void debit(Long walletId, BigDecimal amount) { ... } //出账
public void credit(Long walletId, BigDecimal amount) { ... } //入账
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) { ...} //转账
//省略查询transaction的接口
}
```
Service和BO负责核心业务逻辑Repository和Entity负责数据存取。Repository这一层的代码实现比较简单不是我们讲解的重点所以我也省略掉了。Service层的代码如下所示。注意这里我省略了一些不重要的校验代码比如对amount是否小于0、钱包是否存在的校验等等。
```
public class VirtualWalletBo {//省略getter/setter/constructor方法
private Long id;
private Long createTime;
private BigDecimal balance;
}
public Enum TransactionType {
DEBIT,
CREDIT,
TRANSFER;
}
public class VirtualWalletService {
// 通过构造函数或者IOC框架注入
private VirtualWalletRepository walletRepo;
private VirtualWalletTransactionRepository transactionRepo;
public VirtualWalletBo getVirtualWallet(Long walletId) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWalletBo walletBo = convert(walletEntity);
return walletBo;
}
public BigDecimal getBalance(Long walletId) {
return walletRepo.getBalance(walletId);
}
@Transactional
public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
if (balance.compareTo(amount) &lt; 0) {
throw new NoSufficientBalanceException(...);
}
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setType(TransactionType.DEBIT);
transactionEntity.setFromWalletId(walletId);
transactionRepo.saveTransaction(transactionEntity);
walletRepo.updateBalance(walletId, balance.subtract(amount));
}
@Transactional
public void credit(Long walletId, BigDecimal amount) {
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setType(TransactionType.CREDIT);
transactionEntity.setFromWalletId(walletId);
transactionRepo.saveTransaction(transactionEntity);
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
walletRepo.updateBalance(walletId, balance.add(amount));
}
@Transactional
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setType(TransactionType.TRANSFER);
transactionEntity.setFromWalletId(fromWalletId);
transactionEntity.setToWalletId(toWalletId);
transactionRepo.saveTransaction(transactionEntity);
debit(fromWalletId, amount);
credit(toWalletId, amount);
}
}
```
## 基于充血模型的DDD开发模式
刚刚讲了如何利用基于贫血模型的传统开发模式来实现虚拟钱包系统现在我们再来看一下如何利用基于充血模型的DDD开发模式来实现这个系统
在上一节课中我们讲到基于充血模型的DDD开发模式跟基于贫血模型的传统开发模式的主要区别就在Service层Controller层和Repository层的代码基本上相同。所以我们重点看一下Service层按照基于充血模型的DDD开发模式该如何来实现。
在这种开发模式下我们把虚拟钱包VirtualWallet类设计成一个充血的Domain领域模型并且将原来在Service类中的部分业务逻辑移动到VirtualWallet类中让Service类的实现依赖VirtualWallet类。具体的代码实现如下所示
```
public class VirtualWallet { // Domain领域模型(充血模型)
private Long id;
private Long createTime = System.currentTimeMillis();;
private BigDecimal balance = BigDecimal.ZERO;
public VirtualWallet(Long preAllocatedId) {
this.id = preAllocatedId;
}
public BigDecimal balance() {
return this.balance;
}
public void debit(BigDecimal amount) {
if (this.balance.compareTo(amount) &lt; 0) {
throw new InsufficientBalanceException(...);
}
this.balance = this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) &lt; 0) {
throw new InvalidAmountException(...);
}
this.balance = this.balance.add(amount);
}
}
public class VirtualWalletService {
// 通过构造函数或者IOC框架注入
private VirtualWalletRepository walletRepo;
private VirtualWalletTransactionRepository transactionRepo;
public VirtualWallet getVirtualWallet(Long walletId) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
return wallet;
}
public BigDecimal getBalance(Long walletId) {
return walletRepo.getBalance(walletId);
}
@Transactional
public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.debit(amount);
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setType(TransactionType.DEBIT);
transactionEntity.setFromWalletId(walletId);
transactionRepo.saveTransaction(transactionEntity);
walletRepo.updateBalance(walletId, wallet.balance());
}
@Transactional
public void credit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.credit(amount);
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setType(TransactionType.CREDIT);
transactionEntity.setFromWalletId(walletId);
transactionRepo.saveTransaction(transactionEntity);
walletRepo.updateBalance(walletId, wallet.balance());
}
@Transactional
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
//...跟基于贫血模型的传统开发模式的代码一样...
}
}
```
看了上面的代码你可能会说领域模型VirtualWallet类很单薄包含的业务逻辑很简单。相对于原来的贫血模型的设计思路这种充血模型的设计思路貌似并没有太大优势。你说得没错这也是大部分业务系统都使用基于贫血模型开发的原因。不过如果虚拟钱包系统需要支持更复杂的业务逻辑那充血模型的优势就显现出来了。比如我们要支持透支一定额度和冻结部分余额的功能。这个时候我们重新来看一下VirtualWallet类的实现代码。
```
public class VirtualWallet {
private Long id;
private Long createTime = System.currentTimeMillis();;
private BigDecimal balance = BigDecimal.ZERO;
private boolean isAllowedOverdraft = true;
private BigDecimal overdraftAmount = BigDecimal.ZERO;
private BigDecimal frozenAmount = BigDecimal.ZERO;
public VirtualWallet(Long preAllocatedId) {
this.id = preAllocatedId;
}
public void freeze(BigDecimal amount) { ... }
public void unfreeze(BigDecimal amount) { ...}
public void increaseOverdraftAmount(BigDecimal amount) { ... }
public void decreaseOverdraftAmount(BigDecimal amount) { ... }
public void closeOverdraft() { ... }
public void openOverdraft() { ... }
public BigDecimal balance() {
return this.balance;
}
public BigDecimal getAvaliableBalance() {
BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount);
if (isAllowedOverdraft) {
totalAvaliableBalance += this.overdraftAmount;
}
return totalAvaliableBalance;
}
public void debit(BigDecimal amount) {
BigDecimal totalAvaliableBalance = getAvaliableBalance();
if (totoalAvaliableBalance.compareTo(amount) &lt; 0) {
throw new InsufficientBalanceException(...);
}
this.balance = this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) &lt; 0) {
throw new InvalidAmountException(...);
}
this.balance = this.balance.add(amount);
}
}
```
领域模型VirtualWallet类添加了简单的冻结和透支逻辑之后功能看起来就丰富了很多代码也没那么单薄了。如果功能继续演进我们可以增加更加细化的冻结策略、透支策略、支持钱包账号VirtualWallet id字段自动生成的逻辑不是通过构造函数经外部传入ID而是通过分布式ID生成算法来自动生成ID等等。VirtualWallet类的业务逻辑会变得越来越复杂也就很值得设计成充血模型了。
## 辩证思考与灵活应用
对于虚拟钱包系统的设计与两种开发模式的代码实现,我想你应该有个比较清晰的了解了。不过,我觉得还有两个问题值得讨论一下。
**第一个要讨论的问题是在基于充血模型的DDD开发模式中将业务逻辑移动到Domain中Service类变得很薄但在我们的代码设计与实现中并没有完全将Service类去掉这是为什么或者说Service类在这种情况下担当的职责是什么哪些功能逻辑会放到Service类中**
区别于Domain的职责Service类主要有下面这样几个职责。
1.Service类负责与Repository交流。在我的设计与代码实现中VirtualWalletService类负责与Repository层打交道调用Respository类的方法获取数据库中的数据转化成领域模型VirtualWallet然后由领域模型VirtualWallet来完成业务逻辑最后调用Repository类的方法将数据存回数据库。
这里我再稍微解释一下之所以让VirtualWalletService类与Repository打交道而不是让领域模型VirtualWallet与Repository打交道那是因为我们想保持领域模型的独立性不与任何其他层的代码Repository层的代码或开发框架比如Spring、MyBatis耦合在一起将流程性的代码逻辑比如从DB中取数据、映射数据与领域模型的业务逻辑解耦让领域模型更加可复用。
2.Service类负责跨领域模型的业务聚合功能。VirtualWalletService类中的transfer()转账函数会涉及两个钱包的操作因此这部分业务逻辑无法放到VirtualWallet类中所以我们暂且把转账业务放到VirtualWalletService类中了。当然虽然功能演进使得转账业务变得复杂起来之后我们也可以将转账业务抽取出来设计成一个独立的领域模型。
3.Service类负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的RPC接口等都可以放到Service类中。
**第二个要讨论问题是在基于充血模型的DDD开发模式中尽管Service层被改造成了充血模型但是Controller层和Repository层还是贫血模型是否有必要也进行充血领域建模呢**
答案是没有必要。Controller层主要负责接口的暴露Repository层主要负责与数据库打交道这两层包含的业务逻辑并不多前面我们也提到了如果业务逻辑比较简单就没必要做充血建模即便设计成充血模型类也非常单薄看起来也很奇怪。
尽管这样的设计是一种面向过程的编程风格,但我们只要控制好面向过程编程风格的副作用,照样可以开发出优秀的软件。那这里的副作用怎么控制呢?
就拿Repository的Entity来说即便它被设计成贫血模型违反面向对象编程的封装特性有被任意代码修改数据的风险但Entity的生命周期是有限的。一般来讲我们把它传递到Service层之后就会转化成BO或者Domain来继续后面的业务逻辑。Entity的生命周期到此就结束了所以也并不会被到处任意修改。
我们再来说说Controller层的VO。实际上VO是一种DTOData Transfer Object数据传输对象。它主要是作为接口的数据传输承载体将数据发送给其他系统。从功能上来讲它理应不包含业务逻辑、只包含数据。所以我们将它设计成贫血模型也是比较合理的。
## 重点回顾
今天的内容到此就讲完了。我们一块来总结回顾一下,你应该重点掌握的知识点。
基于充血模型的DDD开发模式跟基于贫血模型的传统开发模式相比主要区别在Service层。在基于充血模型的开发模式下我们将部分原来在Service类中的业务逻辑移动到了一个充血的Domain领域模型中让Service类的实现依赖这个Domain类。
在基于充血模型的DDD开发模式下Service类并不会完全移除而是负责一些不适合放在Domain类中的功能。比如负责与Repository层打交道、跨领域模型的业务聚合功能、幂等事务等非功能性的工作。
基于充血模型的DDD开发模式跟基于贫血模型的传统开发模式相比Controller层和Repository层的代码基本上相同。这是因为Repository层的Entity生命周期有限Controller层的VO只是单纯作为一种DTO。两部分的业务逻辑都不会太复杂。业务逻辑主要集中在Service层。所以Repository层和Controller层继续沿用贫血模型的设计思路是没有问题的。
## 课堂讨论
这两节课中对于DDD的讲解都是我的个人主观看法你可能会有不同看法。
欢迎在留言区说一说你对DDD的看法。如果觉得有帮助你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,100 @@
<audio id="audio" title="13 | 实战二(上):如何对接口鉴权这样一个功能开发做面向对象分析?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3a/a8/3a0bc4114ae8b2e19e3922fe744f21a8.mp3"></audio>
面向对象分析OOA、面向对象设计OOD、面向对象编程OOP是面向对象开发的三个主要环节。在前面的章节中我对三者的讲解比较偏理论、偏概括性目的是让你先有一个宏观的了解知道什么是OOA、OOD、OOP。不过光知道“是什么”是不够的我们更重要的还是要知道“如何做”也就是如何进行面向对象分析、设计与编程。
在过往的工作中我发现很多工程师特别是初级工程师本身没有太多的项目经验或者参与的项目都是基于开发框架填写CRUD模板似的代码导致分析、设计能力比较欠缺。当他们拿到一个比较笼统的开发需求的时候往往不知道从何入手。
对于“如何做需求分析,如何做职责划分?需要定义哪些类?每个类应该具有哪些属性、方法?类与类之间该如何交互?如何组装类成一个可执行的程序?”等等诸多问题,都没有清晰的思路,更别提利用成熟的设计原则、思想或者设计模式,开发出具有高内聚低耦合、易扩展、易读等优秀特性的代码了。
所以,我打算用两节课的时间,结合一个真实的开发案例,从基础的需求分析、职责划分、类的定义、交互、组装运行讲起,将最基础的面向对象分析、设计、编程的套路给你讲清楚,为后面学习设计原则、设计模式打好基础。
话不多说,让我们正式开始今天的学习吧!
## 案例介绍和难点剖析
假设你正在参与开发一个微服务。微服务通过HTTP协议暴露接口给其他系统调用说直白点就是其他系统通过URL来调用微服务的接口。有一天你的leader找到你说“为了保证接口调用的安全性我们希望设计实现一个接口调用鉴权功能只有经过认证之后的系统才能调用我们的接口没有认证过的系统调用我们的接口会被拒绝。我希望由你来负责这个任务的开发争取尽快上线。”
leader丢下这些话就走了。这个时候你该如何来做呢有没有脑子里一团浆糊一时间无从下手的感觉呢为什么会有这种感觉呢我个人觉得主要有下面两点原因。
### 1.需求不明确
leader给到的需求过于模糊、笼统不够具体、细化离落地到设计、编码还有一定的距离。而人的大脑不擅长思考这种过于抽象的问题。这也是真实的软件开发区别于应试教育的地方。应试教育中的考试题目一般都是一个非常具体的问题我们去解答就好了。而真实的软件开发中需求几乎都不是很明确。
我们前面讲过,面向对象分析主要的分析对象是“需求”,因此,面向对象分析可以粗略地看成“需求分析”。实际上,不管是需求分析还是面向对象分析,我们首先要做的都是将笼统的需求细化到足够清晰、可执行。我们需要通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些是不用考虑做的。
### 2.缺少锻炼
相比单纯的业务CRUD开发鉴权这个开发任务要更有难度。鉴权作为一个跟具体业务无关的功能我们完全可以把它开发成一个独立的框架集成到很多业务系统中。而作为被很多系统复用的通用框架比起普通的业务代码我们对框架的代码质量要求要更高。
开发这样通用的框架对工程师的需求分析能力、设计能力、编码能力甚至逻辑思维能力的要求都是比较高的。如果你平时做的都是简单的CRUD业务开发那这方面的锻炼肯定不会很多所以一旦遇到这种开发需求很容易因为缺少锻炼脑子放空不知道从何入手完全没有思路。
## 对案例进行需求分析
实际上,需求分析的工作很琐碎,也没有太多固定的章法可寻,所以,我不打算很牵强地罗列那些听着有用、实际没用的方法论,而是希望通过鉴权这个例子,来给你展示一下,面对需求分析的时候,我的完整的思考路径是什么样的。希望你能自己去体会,举一反三地类比应用到其他项目的需求分析中。
尽管针对框架、组件、类库等非业务系统的开发,我们一定要有组件化意识、框架意识、抽象意识,开发出来的东西要足够通用,不能局限于单一的某个业务需求,但这并不代表我们就可以脱离具体的应用场景,闷头拍脑袋做需求分析。多跟业务团队聊聊天,甚至自己去参与几个业务系统的开发,只有这样,我们才能真正知道业务系统的痛点,才能分析出最有价值的需求。不过,针对鉴权这一功能的开发,最大的需求方还是我们自己,所以,我们也可以先从满足我们自己系统的需求开始,然后再迭代优化。
现在,我们来看一下,针对鉴权这个功能的开发,我们该如何做需求分析?
实际上,这跟做算法题类似,先从最简单的方案想起,然后再优化。所以,我把整个的分析过程分为了循序渐进的四轮。每一轮都是对上一轮的迭代优化,最后形成一个可执行、可落地的需求列表。
### 1.第一轮基础分析
对于如何做鉴权这样一个问题最简单的解决方案就是通过用户名加密码来做认证。我们给每个允许访问我们服务的调用方派发一个应用名或者叫应用ID、AppID和一个对应的密码或者叫秘钥。调用方每次进行接口请求的时候都携带自己的AppID和密码。微服务在接收到接口调用请求之后会解析出AppID和密码跟存储在微服务端的AppID和密码进行比对。如果一致说明认证成功则允许接口调用请求否则就拒绝接口调用请求。
### 2.第二轮分析优化
不过这样的验证方式每次都要明文传输密码。密码很容易被截获是不安全的。那如果我们借助加密算法比如SHA对密码进行加密之后再传递到微服务端验证是不是就可以了呢实际上这样也是不安全的因为加密之后的密码及AppID照样可以被未认证系统或者说黑客截获未认证系统可以携带这个加密之后的密码以及对应的AppID伪装成已认证系统来访问我们的接口。这就是典型的“[重放攻击](https://zh.wikipedia.org/wiki/%E9%87%8D%E6%94%BE%E6%94%BB%E5%87%BB)”。
提出问题然后再解决问题是一个非常好的迭代优化方法。对于刚刚这个问题我们可以借助OAuth的验证思路来解决。调用方将请求接口的URL跟AppID、密码拼接在一起然后进行加密生成一个token。调用方在进行接口请求的的时候将这个token及AppID随URL一块传递给微服务端。微服务端接收到这些数据之后根据AppID从数据库中取出对应的密码并通过同样的token生成算法生成另外一个token。用这个新生成的token跟调用方传递过来的token对比。如果一致则允许接口调用请求否则就拒绝接口调用请求。
这个方案稍微有点复杂,我画了一张示例图,来帮你理解整个流程。
<img src="https://static001.geekbang.org/resource/image/07/d7/0704c4806f9d6c01bb20884d05ee54d7.jpg" alt="">
### 3.第三轮分析优化
不过这样的设计仍然存在重放攻击的风险还是不够安全。每个URL拼接上AppID、密码生成的token都是固定的。未认证系统截获URL、token和AppID之后还是可以通过重放攻击的方式伪装成认证系统调用这个URL对应的接口。
为了解决这个问题我们可以进一步优化token生成算法引入一个随机变量让每次接口请求生成的token都不一样。我们可以选择时间戳作为随机变量。原来的token是对URL、AppID、密码三者进行加密生成的现在我们将URL、AppID、密码、时间戳四者进行加密来生成token。调用方在进行接口请求的时候将token、AppID、时间戳随URL一并传递给微服务端。
微服务端在收到这些数据之后会验证当前时间戳跟传递过来的时间戳是否在一定的时间窗口内比如一分钟。如果超过一分钟则判定token过期拒绝接口请求。如果没有超过一分钟则说明token没有过期就再通过同样的token生成算法在服务端生成新的token与调用方传递过来的token比对看是否一致。如果一致则允许接口调用请求否则就拒绝接口调用请求。
优化之后的认证流程如下图所示。
<img src="https://static001.geekbang.org/resource/image/bd/60/bde932c73c6636ad85380e4801dbfb60.jpg" alt="">
### 4.第四轮分析优化
不过你可能会说这样还是不够安全啊。未认证系统还是可以在这一分钟的token失效窗口内通过截获请求、重放请求来调用我们的接口啊
你说得没错。不过,攻与防之间,本来就没有绝对的安全。我们能做的就是,尽量提高攻击的成本。这个方案虽然还有漏洞,但是实现起来足够简单,而且不会过度影响接口本身的性能(比如响应时间)。所以,权衡安全性、开发成本、对系统性能的影响,这个方案算是比较折中、比较合理的了。
实际上还有一个细节我们没有考虑到那就是如何在微服务端存储每个授权调用方的AppID和密码。当然这个问题并不难。最容易想到的方案就是存储到数据库里比如MySQL。不过开发像鉴权这样的非业务功能最好不要与具体的第三方系统有过度的耦合。
针对AppID和密码的存储我们最好能灵活地支持各种不同的存储方式比如ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis等。我们不一定针对每种存储方式都去做代码实现但起码要留有扩展点保证系统有足够的灵活性和扩展性能够在我们切换存储方式的时候尽可能地减少代码的改动。
### 5.最终确定需求
到此需求已经足够细化和具体了。现在我们按照鉴权的流程对需求再重新描述一下。如果你熟悉UML也可以用时序图、流程图来描述。不过用什么描述不是重点描述清楚才是最重要的。考虑到在接下来的面向对象设计环节中我会基于文字版本的需求描述来进行类、属性、方法、交互等的设计所以这里我给出的最终需求描述是文字版本的。
- 调用方进行接口请求的时候将URL、AppID、密码、时间戳拼接在一起通过加密算法生成token并且将token、AppID、时间戳拼接在URL中一并发送到微服务端。
- 微服务端在接收到调用方的接口请求之后从请求中拆解出token、AppID、时间戳。
- 微服务端首先检查传递过来的时间戳跟当前时间是否在token失效时间窗口内。如果已经超过失效时间那就算接口调用鉴权失败拒绝接口调用请求。
- 如果token验证没有过期失效微服务端再从自己的存储中取出AppID对应的密码通过同样的token生成算法生成另外一个token与调用方传递过来的token进行匹配如果一致则鉴权成功允许接口调用否则就拒绝接口调用。
这就是我们需求分析的整个思考过程,从最粗糙、最模糊的需求开始,通过“提出问题-解决问题”的方式,循序渐进地进行优化,最后得到一个足够清晰、可落地的需求描述。
## 重点回顾
今天的内容到此就讲完了。我们一块来总结回顾一下,你需要掌握的一些重点内容。
针对框架、类库、组件等非业务系统的开发,其中一个比较大的难点就是,需求一般都比较抽象、模糊,需要你自己去挖掘,做合理取舍、权衡、假设,把抽象的问题具象化,最终产生清晰的、可落地的需求定义。需求定义是否清晰、合理,直接影响了后续的设计、编码实现是否顺畅。所以,作为程序员,你一定不要只关心设计与实现,前期的需求分析同等重要。
需求分析的过程实际上是一个不断迭代优化的过程。我们不要试图一下就能给出一个完美的解决方案,而是先给出一个粗糙的、基础的方案,有一个迭代的基础,然后再慢慢优化,这样一个思考过程能让我们摆脱无从下手的窘境。
## 课堂讨论
除了工作中我们会遇到需求不明确的开发任务,实际上,在面试中,我们也经常遇到一些开放性的设计问题,对于这类问题,你是如何解答的?有哪些好的经验可以分享给大家呢?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,276 @@
<audio id="audio" title="14 | 实战二(下):如何利用面向对象设计和编程开发接口鉴权功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/e4/c86c8b5eafb09eaa4a1d7badb5ad03e4.mp3"></audio>
在上一节课中针对接口鉴权功能的开发我们讲了如何进行面向对象分析OOA也就是需求分析。实际上需求定义清楚之后这个问题就已经解决了一大半这也是为什么我花了那么多篇幅来讲解需求分析。今天我们再来看一下针对面向对象分析产出的需求如何来进行面向对象设计OOD和面向对象编程OOP
## 如何进行面向对象设计?
我们知道,面向对象分析的产出是详细的需求描述,那面向对象设计的产出就是类。在面向对象设计环节,我们将需求描述转化为具体的类的设计。我们把这一设计环节拆解细化一下,主要包含以下几个部分:
- 划分职责进而识别出有哪些类;
- 定义类及其属性和方法;
- 定义类与类之间的交互关系;
- 将类组装起来并提供执行入口。
实话讲,不管是面向对象分析还是面向对象设计,理论的东西都不多,所以我们还是结合鉴权这个例子,在实战中体会如何做面向对象设计。
### 1.划分职责进而识别出有哪些类
在面向对象有关书籍中经常讲到,类是现实世界中事物的一个建模。但是,并不是每个需求都能映射到现实世界,也并不是每个类都与现实世界中的事物一一对应。对于一些抽象的概念,我们是无法通过映射现实世界中的事物的方式来定义类的。
所以,大多数讲面向对象的书籍中,还会讲到另外一种识别类的方法,那就是把需求描述中的名词罗列出来,作为可能的候选类,然后再进行筛选。对于没有经验的初学者来说,这个方法比较简单、明确,可以直接照着做。
不过,我个人更喜欢另外一种方法,那就是根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,是否应该归为同一个类。我们来看一下,针对鉴权这个例子,具体该如何来做。
在上一节课中,我们已经给出了详细的需求描述,为了方便你查看,我把它重新贴在了下面。
- 调用方进行接口请求的时候将URL、AppID、密码、时间戳拼接在一起通过加密算法生成token并且将token、AppID、时间戳拼接在URL中一并发送到微服务端。
- 微服务端在接收到调用方的接口请求之后从请求中拆解出token、AppID、时间戳。
- 微服务端首先检查传递过来的时间戳跟当前时间是否在token失效时间窗口内。如果已经超过失效时间那就算接口调用鉴权失败拒绝接口调用请求。
- 如果token验证没有过期失效微服务端再从自己的存储中取出AppID对应的密码通过同样的token生成算法生成另外一个token与调用方传递过来的token进行匹配。如果一致则鉴权成功允许接口调用否则就拒绝接口调用。
首先,我们要做的是逐句阅读上面的需求描述,拆解成小的功能点,一条一条罗列下来。注意,拆解出来的每个功能点要尽可能的小。每个功能点只负责做一件很小的事情(专业叫法是“单一职责”,后面章节中我们会讲到)。下面是我逐句拆解上述需求描述之后,得到的功能点列表:
1. 把URL、AppID、密码、时间戳拼接为一个字符串
1. 对字符串通过加密算法加密生成token
1. 将token、AppID、时间戳拼接到URL中形成新的URL
1. 解析URL得到token、AppID、时间戳等信息
1. 从存储中取出AppID和对应的密码
1. 根据时间戳判断token是否过期失效
1. 验证两个token是否匹配
从上面的功能列表中我们发现1、2、6、7都是跟token有关负责token的生成、验证3、4都是在处理URL负责URL的拼接、解析5是操作AppID和密码负责从存储中读取AppID和密码。所以我们可以粗略地得到三个核心的类AuthToken、Url、CredentialStorage。AuthToken负责实现1、2、6、7这四个操作Url负责3、4两个操作CredentialStorage负责5这个操作。
当然,这是一个初步的类的划分,其他一些不重要的、边边角角的类,我们可能暂时没法一下子想全,但这也没关系,面向对象分析、设计、编程本来就是一个循环迭代、不断优化的过程。根据需求,我们先给出一个粗糙版本的设计方案,然后基于这样一个基础,再去迭代优化,会更加容易一些,思路也会更加清晰一些。
不过,我还要再强调一点,接口调用鉴权这个开发需求比较简单,所以,需求对应的面向对象设计并不复杂,识别出来的类也并不多。但如果我们面对的是更加大型的软件开发、更加复杂的需求开发,涉及的功能点可能会很多,对应的类也会比较多,像刚刚那样根据需求逐句罗列功能点的方法,最后会得到一个长长的列表,就会有点凌乱、没有规律。针对这种复杂的需求开发,我们首先要做的是进行模块划分,将需求先简单划分成几个小的、独立的功能模块,然后再在模块内部,应用我们刚刚讲的方法,进行面向对象设计。而模块的划分和识别,跟类的划分和识别,是类似的套路。
### 2.定义类及其属性和方法
刚刚我们通过分析需求描述识别出了三个核心的类它们分别是AuthToken、Url和CredentialStorage。现在我们来看下每个类都有哪些属性和方法。我们还是从功能点列表中挖掘。
**AuthToken类相关的功能点有四个**
- 把URL、AppID、密码、时间戳拼接为一个字符串
- 对字符串通过加密算法加密生成token
- 根据时间戳判断token是否过期失效
- 验证两个token是否匹配。
对于方法的识别,很多面向对象相关的书籍,一般都是这么讲的,识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选。类比一下方法的识别,我们可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。
我们可以借用这个思路根据功能点描述识别出来AuthToken类的属性和方法如下所示
<img src="https://static001.geekbang.org/resource/image/69/9b/69c8954e0db1a4db99a6094ee359fc9b.jpg" alt="">
从上面的类图中,我们可以发现这样三个小细节。
- 第一个细节并不是所有出现的名词都被定义为类的属性比如URL、AppID、密码、时间戳这几个名词我们把它作为了方法的参数。
- 第二个细节我们还需要挖掘一些没有出现在功能点描述中属性比如createTimeexpireTimeInterval它们用在isExpired()函数中用来判定token是否过期。
- 第三个细节我们还给AuthToken类添加了一个功能点描述中没有提到的方法getToken()。
第一个细节告诉我们从业务模型上来说不应该属于这个类的属性和方法不应该被放到这个类里。比如URL、AppID这些信息从业务模型上来说不应该属于AuthToken所以我们不应该放到这个类中。
第二、第三个细节告诉我们,在设计类具有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,理应具有哪些属性和方法。这样可以一方面保证类定义的完整性,另一方面不仅为当下的需求还为未来的需求做些准备。
**Url类相关的功能点有两个**
- 将token、AppID、时间戳拼接到URL中形成新的URL
- 解析URL得到token、AppID、时间戳等信息。
虽然需求描述中我们都是以URL来代指接口请求但是接口请求并不一定是以URL的形式来表达还有可能是Dubbo、RPC等其他形式。为了让这个类更加通用命名更加贴切我们接下来把它命名为ApiRequest。下面是我根据功能点描述设计的ApiRequest类。
<img src="https://static001.geekbang.org/resource/image/1c/d6/1cc9b95e511bd49fbc23c00ac5c0fed6.jpg" alt="">
**CredentialStorage类相关的功能点有一个**
- 从存储中取出AppID和对应的密码。
CredentialStorage类非常简单类图如下所示。为了做到抽象封装具体的存储方式我们将CredentialStorage设计成了接口基于接口而非具体的实现编程。
<img src="https://static001.geekbang.org/resource/image/3b/85/3b6d2c0cadafa723e26cc032c29c8785.jpg" alt="">
### 3.定义类与类之间的交互关系
类与类之间都有哪些交互关系呢UML统一建模语言中定义了六种类之间的关系。它们分别是泛化、实现、关联、聚合、组合、依赖。关系比较多而且有些还比较相近比如聚合和组合接下来我就逐一讲解一下。
**泛化**Generalization可以简单理解为继承关系。具体到Java代码就是下面这样
```
public class A { ... }
public class B extends A { ... }
```
**实现**Realization一般是指接口和实现类之间的关系。具体到Java代码就是下面这样
```
public interface A {...}
public class B implements A { ... }
```
**聚合**Aggregation是一种包含关系A类对象包含B类对象B类对象的生命周期可以不依赖A类对象的生命周期也就是说可以单独销毁A类对象而不影响B对象比如课程与学生之间的关系。具体到Java代码就是下面这样
```
public class A {
private B b;
public A(B b) {
this.b = b;
}
}
```
**组合**Composition也是一种包含关系。A类对象包含B类对象B类对象的生命周期依赖A类对象的生命周期B类对象不可单独存在比如鸟与翅膀之间的关系。具体到Java代码就是下面这样
```
public class A {
private B b;
public A() {
this.b = new B();
}
}
```
**关联**Association是一种非常弱的关系包含聚合、组合两种关系。具体到代码层面如果B类对象是A类的成员变量那B类和A类就是关联关系。具体到Java代码就是下面这样
```
public class A {
private B b;
public A(B b) {
this.b = b;
}
}
或者
public class A {
private B b;
public A() {
this.b = new B();
}
}
```
**依赖**Dependency是一种比关联关系更加弱的关系包含关联关系。不管是B类对象是A类对象的成员变量还是A类的方法使用B类对象作为参数或者返回值、局部变量只要B类对象和A类对象有任何使用关系我们都称它们有依赖关系。具体到Java代码就是下面这样
```
public class A {
private B b;
public A(B b) {
this.b = b;
}
}
或者
public class A {
private B b;
public A() {
this.b = new B();
}
}
或者
public class A {
public void func(B b) { ... }
}
```
看完了UML六种类关系的详细介绍不知道你有何感受我个人觉得这样拆分有点太细增加了学习成本对于指导编程开发没有太大意义。所以我从更加贴近编程的角度对类与类之间的关系做了调整只保留了四个关系泛化、实现、组合、依赖这样你掌握起来会更加容易。
其中泛化、实现、依赖的定义不变组合关系替代UML中组合、聚合、关联三个概念也就相当于重新命名关联关系为组合关系并且不再区分UML中的组合和聚合两个概念。之所以这样重新命名是为了跟我们前面讲的“多用组合少用继承”设计原则中的“组合”统一含义。只要B类对象是A类对象的成员变量那我们就称A类跟B类是组合关系。
理论的东西讲完了让我们来看一下刚刚我们定义的类之间都有哪些关系呢因为目前只有三个核心的类所以只用到了实现关系也即CredentialStorage和MysqlCredentialStorage之间的关系。接下来讲到组装类的时候我们还会用到依赖关系、组合关系但是泛化关系暂时没有用到。
### 4.将类组装起来并提供执行入口
类定义好了类之间必要的交互关系也设计好了接下来我们要将所有的类组装在一起提供一个执行入口。这个入口可能是一个main()函数也可能是一组给外部用的API接口。通过这个入口我们能触发整个代码跑起来。
接口鉴权并不是一个独立运行的系统而是一个集成在系统上运行的组件所以我们封装所有的实现细节设计了一个最顶层的ApiAuthenticator接口类暴露一组给外部调用者使用的API接口作为触发执行鉴权逻辑的入口。具体的类的设计如下所示
<img src="https://static001.geekbang.org/resource/image/f4/ca/f408ac59caffde117716d11148d010ca.jpg" alt="">
## 如何进行面向对象编程?
面向对象设计完成之后我们已经定义清晰了类、属性、方法、类之间的交互并且将所有的类组装起来提供了统一的执行入口。接下来面向对象编程的工作就是将这些设计思路翻译成代码实现。有了前面的类图这部分工作相对来说就比较简单了。所以这里我只给出比较复杂的ApiAuthenticator的实现。
对于AuthToken、ApiRequest、CredentialStorage这三个类在这里我就不给出具体的代码实现了。给你留一个课后作业你可以试着把整个鉴权框架自己去实现一遍。
```
public interface ApiAuthenticator {
void auth(String url);
void auth(ApiRequest apiRequest);
}
public class DefaultApiAuthenticatorImpl implements ApiAuthenticator {
private CredentialStorage credentialStorage;
public DefaultApiAuthenticatorImpl() {
this.credentialStorage = new MysqlCredentialStorage();
}
public DefaultApiAuthenticatorImpl(CredentialStorage credentialStorage) {
this.credentialStorage = credentialStorage;
}
@Override
public void auth(String url) {
ApiRequest apiRequest = ApiRequest.buildFromUrl(url);
auth(apiRequest);
}
@Override
public void auth(ApiRequest apiRequest) {
String appId = apiRequest.getAppId();
String token = apiRequest.getToken();
long timestamp = apiRequest.getTimestamp();
String originalUrl = apiRequest.getOriginalUrl();
AuthToken clientAuthToken = new AuthToken(token, timestamp);
if (clientAuthToken.isExpired()) {
throw new RuntimeException(&quot;Token is expired.&quot;);
}
String password = credentialStorage.getPasswordByAppId(appId);
AuthToken serverAuthToken = AuthToken.generate(originalUrl, appId, password, timestamp);
if (!serverAuthToken.match(clientAuthToken)) {
throw new RuntimeException(&quot;Token verfication failed.&quot;);
}
}
}
```
## 辩证思考与灵活应用
在之前的讲解中,面向对象分析、设计、实现,每个环节的界限划分都比较清楚。而且,设计和实现基本上是按照功能点的描述,逐句照着翻译过来的。这样做的好处是先做什么、后做什么,非常清晰、明确,有章可循,即便是没有太多设计经验的初级工程师,都可以按部就班地参照着这个流程来做分析、设计和实现。
不过在平时的工作中大部分程序员往往都是在脑子里或者草纸上完成面向对象分析和设计然后就开始写代码了边写边思考边重构并不会严格地按照刚刚的流程来执行。而且说实话即便我们在写代码之前花很多时间做分析和设计绘制出完美的类图、UML图也不可能把每个细节、交互都想得很清楚。在落实到代码的时候我们还是要反复迭代、重构、打破重写。
毕竟,整个软件开发本来就是一个迭代、修修补补、遇到问题解决问题的过程,是一个不断重构的过程。我们没法严格地按照顺序执行各个步骤。这就类似你去学驾照,驾校教的都是比较正规的流程,先做什么,后做什么,你只要照着做就能顺利倒车入库,但实际上,等你开熟练了,倒车入库很多时候靠的都是经验和感觉。
## 重点回顾
今天的内容到此就讲完了。我们来一块总结回顾一下,你需要掌握的重点内容。
面向对象分析的产出是详细的需求描述。面向对象设计的产出是类。在面向对象设计这一环节中,我们将需求描述转化为具体的类的设计。这个环节的工作可以拆分为下面四个部分。
**1.划分职责进而识别出有哪些类**
根据需求描述,我们把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,可否归为同一个类。
**2.定义类及其属性和方法**
我们识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选出真正的方法,把功能点中涉及的名词,作为候选属性,然后同样再进行过滤筛选。
**3.定义类与类之间的交互关系**
UML统一建模语言中定义了六种类之间的关系。它们分别是泛化、实现、关联、聚合、组合、依赖。我们从更加贴近编程的角度对类与类之间的关系做了调整保留四个关系泛化、实现、组合、依赖。
**4.将类组装起来并提供执行入口**
我们要将所有的类组装在一起提供一个执行入口。这个入口可能是一个main()函数也可能是一组给外部用的API接口。通过这个入口我们能触发整个代码跑起来。
## 课堂讨论
软件设计的自由度很大,这也是软件的复杂之处。不同的人对类的划分、定义、类之间交互的设计,可能都不大一样。那除了我今天给出的设计思路,你还有没有其他设计思路呢?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。