面向对象
# 面向对象
来源:极客时间《设计模式之美》 (opens new window)专栏笔记
# 1. 概念
- 什么是面向对象编程? 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封 装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
- 什么是面向对象编程语言? 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
- 如何判定一个编程语言是否是面向对象编程语言? 如果按照严格的的定义,需要有现成的语法支持类、对象、四大特性才能叫作面向对象编程语言。如果放宽要求的话,只要某种编程语言支持类、对象语法机制,那基本上就可以说这种编程语言是面向对象编程语言了,不一定非得要求具有所有的四大特性。
- 面向对象编程和面向对象编程语言之间有何关系? 面向对象编程一般使用面向对象编程语言来进行,但是,不用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即便我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也有可能是面向过程编程风格的。
- 什么是面向对象分析和面向对象设计? 简单点讲,面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做。两个阶段最终的产出是类的设计,包括程序被拆解为哪些类,每个类有哪些属性方法、类与类之间如何交互等等。
# 2. 四大特性
理解面向对象编程及面向对象编程语言的关键就是理解其四大特性:封装、抽象、继承、多态。
# 2.1 封装(Encapsulation)
封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类 提供的方式来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持,例如 Java 中的 private、protected、public 关键字。封装特性存在的意义,一方面是保护数据 不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用 性。
# 2.2 抽象(Abstraction)
封装主要讲如何隐藏信息、保护数据,那抽象就是讲如何隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口类或者抽象类来实现,但也并不需要特殊的语法机制来支持。抽象存在的意义,一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息
# 2.3 继承(Inheritatance)
继承是用来表示类之间的 is-a 关系,分为两种模式:单继承和多继承。单继承表示一个子 类只继承一个父类,多继承表示一个子类可以继承多个父类。为了实现继承这个特性,编程 语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题。
# 2.4 多态(Polymorphism)
多态是指子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态这种特 性也需要编程语言提供特殊的语法机制来实现,比如继承、接口类、duck-typing。多态可 以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。
多态这种特性也需要编程语言提供特殊的语法机制来实现。如:
- 父类对象可以引用子类对象
- 支持继承
- 支持重写父类方法
# 3. 面向对象与面向过程
# 3.1 什么是面向对象和面向过程编程语言
实际上,面向过程编程和面向过程编程语言并没有严格的官方定义。理解这两个概念最好的方式是跟面向对象编程和面向对象编程语言进行对比。相较于面向对象编程以类为组织代码的基本单元,面向过程编程则是以过程(或方法)作为组织代码的基本单元。它最主要的特点就是数据和方法相分离。相较于面向对象编程语言,面向过程编程语言最大的特点就是不 支持丰富的面向对象编程特性,比如继承、多态、封装。
# 3.2 面向对象相比面向过程的优势
面向对象编程相比起面向过程编程的优势主要有三个:
- 对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对这种复杂类型的程序开发。
- 面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。
- 从编程语言跟机器打交道的方式的演进规律中,我们可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能。
# 4. 三种违反面向对象编程风格的典型代码设计
# 4.1 滥用 getter 和 setter 方法
在设计实现类的时候,除非真的需要,否则尽量不要给属性定义 setter 方法。除此之外, 尽管 getter 方法相对 setter 方法要安全些,但是如果返回的是集合容器,那也要防范集合 内部数据被修改的风险。
# 4.2 Constants 类、Utils 类的设计问题
对于这两种类的设计,我们尽量能做到职责单一,定义一些细化的小类,比如 RedisConstants、FileUtils,而不是定义一个大而全的 Constants 类、Utils 类。除此之 外,如果能将这些类中的属性和方法,划分归并到其他业务类中,那是最好不过的了,能极 大地提高类的内聚性和代码的可复用性。
# 4.3 基于贫血模式的开发
前后端分离后的 MVC 结构,分为 Controller 层、Service 层、 Repository 层。Controller 层负责暴露接口给前端调用,Service 层负责核心业务逻辑, Repository 层负责数据读写。而在每一层中,我们又会定义相应的 VO(View Object)、 BO(Business Object)、Entity。一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、 Repository 类中。这就是典型的面向过程的编程风格
# 5. 抽象类和接口
# 5.1 抽象类和接口的特性
抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。接口不能包含属性,只能声明方法,方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的所有方法。
# 5.2 抽象类和接口存在的意义
抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。接口仅 仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问 题,隔离接口和具体的实现,提高代码的扩展性。
# 5.3 抽象类和接口的应用场景
什么时候该用抽象类?什么时候该用接口?实际上,判断的标准很简单。如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,我们就用抽象类;如果要表示一种 has-a 关 系,并且是为了解决抽象而非代码复用问题,那我们就用接口。
# 6. 基于接口而非实现编程
实际上,“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编 程”。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一 就是需求的不断变化,这也是考验代码设计好坏的一个标准。越抽象、越顶层、越脱离具体 某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅 能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情 况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。
以图片存储为例:
public class AliyuImageStore {
// ...省略属性和构造函数
public void createBucketIfNotExist(String bucketName) {
// ... 创建 bucket 代码逻辑
}
public String genereateAccessToken() {
// 根据 accesskey 和 accessSecret 生成 accessToken
}
public String uploadImageToAliyu(Image image, String bucketName, String accessToken) {
// 上传图片到阿里云
// 返回阿里云上的存储地址
}
public Image dolownFromAliyun(String url, String accessToken) {
// 从阿里云下载图片
}
}
public class ImageProcessingJob {
public static final String BUCKET_NAME = "test";
public void process() {
Image image = ...;// 省略图片代码
AliyuImageStore imageStore = new AliyuImageStore(...); // 省略构造属性
imageStore.createBucketIfNotExist(NUCKET_NAME);
String accessToken = imageStore.generateAccessToken();
imageStore.uploadImageToAliyun(image, BUCKET_NAME, accessToken);
}
}
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
软件开发中唯一不变的就是变化。过了一段时间后,我们自建了私有云,不再将图片存储到阿里云了,而是将图片存储到自建私有云上。此时上面的代码就无法使用,需要重新写一个 PrivateImageStore 类。解决这个问题的根本方法就是,在编写代码的时候,要遵 从“基于接口而非实现编程”的原则:
- 函数的命名不能暴露任何实现细节。比如,前面提到的 uploadToAliyun() 就不符合要 求,应该改为去掉 aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()。
- 封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调 用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
- 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能 协议。使用者依赖接口,而不是具体的实现类来编程。
重构后的代码:
public interface ImageStore {
public String upload(Image image, String bucketName);
public Image download(String url);
}
public class AliyunImageStore implement ImageStore {
// ... 省略属性和构造函数
public String upload(Image image, String bucketName) {
createBucketIfNotExist(bucketName);
String accessToken = generateAccessToken();
// 上传图片到阿里云
// 返回图片地址
}
public Image download(String url) {
String accessToken = generateAccessToken();
// 从阿里云下载图片
}
private void createBucketIfNotExist(String bucketName) {
// 如果不存在则创建 bucket
}
private void generateAccessToken() {
// 根据 accesskey 和 accessSecret 生成 accessToken
}
}
public class PrivateImageStore implement ImageStore {
// ... 省略属性和构造函数
public String upload(Image image, String bucketName) {
createBucketIfNotExist(bucketName);
// 上传图片到私有服务器
// 返回图片地址
}
public Image download(String url) {
// 从私有服务器下载图片
}
private void createBucketIfNotExist(String bucketName) {
// 如果不存在则创建 bucket
}
}
public class ImageProcessingJob {
public static final String BUCKET_NAME = "test";
public void process() {
Image image = ...;// 省略图片代码
ImageStore imageStore = new PrivateImageStore(...); // 省略构造属性
imageStore.upload(image, BUCKET_NAME);
}
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
上面代码第 45 行会产生一个问题,那就是,如果我们要替换图片存储方式,还是需要修改很多类似第 8 行那样的代码。这样的设计还是不够完美,解决思路:配置文件 + 反射 + 工厂模式
# 7. 多用组合少用继承
# 7.1 为什么不推荐使用继承
继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通 过组合、接口、委托三个技术手段来达成。除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。
例如:设计一个关于鸟的类,使用继承如下:
如上图鸟类只有一种会飞的行为,继承关系有三层比较简单,算是可以接收的设计。但如果此时新增一个是否会叫的行为,两个行为搭配一起就有四种可能:会飞会叫、会飞不会叫、不会飞会叫、不会飞不会叫。那么就会设计为:
如果继续考虑“是否下蛋”的行为,类的继承层次会越来越深,越来越复杂。导致代码的可读性变差,需阅读父类们的方法才能搞清楚;另一方面,这也破坏了类的封装 特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦 父类代码修改,就会影响所有子类的逻辑。
# 7.2 组合相比继承有哪些优势
继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通 过组合、接口、委托三个技术手段来达成。除此之外,利用组合还能解决层次过深、过复杂 的继承关系影响代码可维护性的问题。
解决继承问题:
public interface Flyable {
void fly();
}
public class FlyAbility implement Flyable {
@Override
public void fly() {
// 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(); // 委托
}
}
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
# 7.3 如何判断该用组合还是继承
尽管鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。在实际的项目开发中,还是要根据具体的情况来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,就可以大胆地使用继承。反之就尽量使用组合来替代继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。
- 如 Url 工具类适合使用组合;
- 如重写 FeignClient 的 encode 方法则适合使用继承
- 如一些设计模式:装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系
# 8. 贫血模型VS充血模型
# 8.1 什么是贫血模型、充血模型
在日常业务中通常使用 MVC 模型进行开发,常常分为 Controller 层(包含 Controller 和 VO)用于暴露接口;Service 层(包含 Service 和 BO)用于处理业务逻辑;Repository 层(包含 Repository 和 Entity)用于数组访问。Service 层的业务逻辑和数据被分成两个类中(BO 和 Service),像 BO 这种只包含数据,不包含任何业务逻辑,就叫做贫血模型。
而充血模型中,业务逻辑和数据都被封装在了同一个类中。因此充血模式具有封装特性,更加符合面向对象编程风格。
基于充血模型的 DDD 开发模式实现的代码,也是按照 MVC 模型分为三层进行开发的。Controller 负责暴露接口;Repository 负责数据存取;Service 负责处理业务逻辑。两者的区别在 Service 层。充血模型将 Service 层分为 Service 和 Domain,Domain 中既包含数据也包含业务逻辑,而 Service 则比较简单。
换句话来说:贫血模型重 Service,充血模型重 Domain。
# 8.2 基于贫血模型的传统开发模式流行的原因
- 业务简单,都是基于 SQL 的 CRUD,使用充血模型有点大材小用
- 充血模型是典型的面向对象编程,需要熟悉业务,设计难度大
- 固定思维,未遇到痛点不愿改变
# 8.3 什么项目应该考虑使用充血模型
越复杂的系统,对代码的复用性、易维护性要求就越高,就越应该花更多 时间和精力在前期设计上。而基于充血模型的 DDD 开发模式,正好需要我们前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发。
# 9. 实战一:基于充血模型的 DDD 开发一个虚拟钱包系统
# 9.1 钱包业务分析
每个虚拟钱包账户都会对应用户的一个真实的支付账户,有可能是银行卡账户,也有可能是三方支付账户(比如支付宝、微信钱包)。钱包暂时只支持充值、提现、支付、查询余额、查询交易流水这五个核心的功能,其他比如冻结、透支、转赠等不常用的功能暂不考虑。
- 充值
- 从用户的银行卡账户转账到应用的公共银行卡账户
- 用户钱包余额加上充值金额
- 记录交易流水日志
- 支付
- 从用户的虚拟钱包账户划钱到商家的虚拟钱包账户上
- 记录交易流水日志
- 提现
- 扣减用户虚拟钱包中的余额
- 从应用的公共银行账户转钱到用户的银行账户
- 记录交易流水日志
- 查询余额
- 查询交易流水
# 9.2 钱包系统设计思路
为了解耦将整个钱包系统拆分为两个子系统:虚拟钱包系统(操作应用内的虚拟钱包账户)和三方支付系统(银行账号、支付宝等)。
充值、提现、查询余额三个功能,只涉及一个账户余额的加减操作,而支付功能涉及两个账户的余额加减操作:一个账户减余额,另一个账户加余额。
交易流水:流水ID、交易时间、交易金额、交易类型(充值、提取、支付)、入账钱包账号、出账钱包账号
# 9.3 基于贫血模型的传统开发模式
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的接口
}
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) < 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);
}
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# 9.4 基于充血模型的开发模式
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) < 0) {
throw new InsufficientBalanceException(...);
}
this.balance = this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 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) {
//...跟基于贫血模型的传统开发模式的代码一样...
}
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
此时领域模型 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) < 0) {
throw new InsufficientBalanceException(...);
}
this.balance = this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
this.balance = this.balance.add(amount);
}
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 9.5 问题讨论
问题一:基于充血模型的 DDD 开发模式中,业务逻辑在 Domain 中,那 Service 的职责是什么?
Service 类负责与 Repository 交互。调用 Respository 类的方法,获取数据库中的数据,转化成领域模型 VirtualWallet,然后由领域模型 VirtualWallet 来完成业务逻辑,最后调用 Repository 类的方法,将数据存回数据库。
之所以让 VirtualWalletService 类与 Repository 打交道,而不是让领域模型 VirtualWallet 与 Repository 打交道,那是因为想保持领域模型的独立性,不与任何其他层的代码(Repository 层的代码)或开发框架(比如 Spring、MyBatis)耦合在一起,将流程性的代码逻辑(比如从 DB 中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用。
Service 类负责跨领域模型的业务聚合功能。
Service 类负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的 RPC 接口等,都可以放到 Service 类中
问题二:充血模型的 DDD 开发模式中,Controller 层和 Repository层还是贫血模式,是否也改为充血模式
答案是没有必要。Controller 层主要负责接口的暴露,Repository 层主要负责与数据库打交道,这两层包含的业务逻辑并不多,业务逻辑比较简单,就没必要做充血建模,即便设计成充血模型,类也非常单薄,看起来也很奇怪
# 10. 实战二:对接口鉴权功能做面向对象分析
# 10.1 第一轮基础分析
对于如何做鉴权这样一个问题,最简单的解决方案就是,通过用户名加密码来做认证。我们 给每个允许访问我们服务的调用方,派发一个应用名(或者叫应用 ID、AppID)和一个对 应的密码(或者叫秘钥)。调用方每次进行接口请求的时候,都携带自己的 AppID 和密 码。微服务在接收到接口调用请求之后,会解析出 AppID 和密码,跟存储在微服务端的AppID 和密码进行比对。如果一致,说明认证成功,则允许接口调用请求;否则,就拒绝 接口调用请求。
# 10.2 第二轮分析优化
不过,这样的验证方式,每次都要明文传输密码。密码很容易被截获,是不安全的。那如果 我们借助加密算法(比如 SHA),对密码进行加密之后,再传递到微服务端验证,是不是 就可以了呢?实际上,这样也是不安全的,因为加密之后的密码及 AppID,照样可以被未 认证系统(或者说黑客)截获,未认证系统可以携带这个加密之后的密码以及对应的 AppID,伪装成已认证系统来访问我们的接口。这就是典型的“ 重放攻击”。
提出问题,然后再解决问题,是一个非常好的迭代优化方法。对于刚刚这个问题,我们可以 借助 OAuth 的验证思路来解决。调用方将请求接口的 URL 跟 AppID、密码拼接在一起, 然后进行加密,生成一个 token。调用方在进行接口请求的的时候,将这个 token 及 AppID,随 URL 一块传递给微服务端。微服务端接收到这些数据之后,根据 AppID 从数 据库中取出对应的密码,并通过同样的 token 生成算法,生成另外一个 token。用这个新 生成的 token 跟调用方传递过来的 token 对比。如果一致,则允许接口调用请求;否则, 就拒绝接口调用请求。
# 10.3 第三轮分析优化
不过,这样的设计仍然存在重放攻击的风险,还是不够安全。每个 URL 拼接上 AppID、密 码生成的 token 都是固定的。未认证系统截获 URL、token 和 AppID 之后,还是可以通 过重放攻击的方式,伪装成认证系统,调用这个 URL 对应的接口。
为了解决这个问题,我们可以进一步优化 token 生成算法,引入一个随机变量,让每次接 口请求生成的 token 都不一样。我们可以选择时间戳作为随机变量。原来的 token 是对 URL、AppID、密码三者进行加密生成的,现在我们将 URL、AppID、密码、时间戳四者 进行加密来生成 token。调用方在进行接口请求的时候,将 token、AppID、时间戳,随 URL 一并传递给微服务端。
微服务端在收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间 窗口内(比如一分钟)。如果超过一分钟,则判定 token 过期,拒绝接口请求。如果没有 超过一分钟,则说明 token 没有过期,就再通过同样的 token 生成算法,在服务端生成新的 token,与调用方传递过来的 token 比对,看是否一致。如果一致,则允许接口调用请 求;否则,就拒绝接口调用请求。
# 10.4 第四轮分析优化
这样还是不够安全啊,未认证系统还是可以在这一分钟的 token 失效窗口内,通过截获请求、重放请求,来调用接口。这个方案虽然还有漏洞,但是实现起来足够简单,而且不会过度影响接口本身的性能(比如响应时间)。所以,权衡安全性、开发成本、对系统性能的影响,这个方案算是比较折中、比较合理的了。
实际上,还有一个细节我们没有考虑到,那就是,如何在微服务端存储每个授权调用方的 AppID 和密码。最容易想到的方案就是存储到数据库里,比如 MySQL。不过,开发像鉴权这样的非业务功能,最好不要与具体的第三方系统有过度的耦合。
针对 AppID 和密码的存储,最好能灵活地支持各种不同的存储方式,比如 ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis 等。我们不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在切换存储方式的时候,尽可能地减少代码的改动。
# 10.5 最终方案
按照鉴权的流程,对需求再重新描述一下。 如果熟悉 UML,也可以用时序图、流程图来描述。用什么描述不是重点,描述清楚才是最重要的。考虑到在接下来的面向对象设计环节中,基于文字版本的需求描述, 来进行类、属性、方法、交互等的设计。
调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL中,一并发送到微服务端。
微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。
微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。
这就是需求分析的整个思考过程,从最粗糙、最模糊的需求开始,通过“提出问题 - 解决问题”的方式,循序渐进地进行优化,最后得到一个足够清晰、可落地的需求描述。
# 10.6 如何进行面向对象设计
面向对象分析的产出是详细的需求描述,那面向对象设计的产出就是类。把这一设计环节拆解细化一下,主要包含以下几个部分:
- 划分职责进而识别出有哪些类
- 定义类的属性和方法
- 定义类与类之间的关系
- 将类组装起来并提供执行入口
# 10.6.1 划分职责并识别有哪些类
方法:根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,可否应该归为同一个类。
根据最终方案的需求描述,逐句拆解可以得到下面的功能列表:
- 把 URL、AppID、密码、时间戳拼接成一个字符串
- 对字符串通过加密算法生成 token
- 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL
- 解析 URL,得到 token、AppID、时间戳等新信息
- 根据时间戳判断 token 是否过期
- 从存取中获取 AppID 和相应的密码
- 验证两个 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 这个操作。
如果我们面对的是更加大型的软件开发、更加复杂的需求开发,涉及的功能点可能会很多,对应的类也会比较多,像刚刚那样根据需求逐句罗列功能点的方法,最后会得到一个长长的列表,就会有点凌乱、没有规律。针对这种复杂的需求发,我们首先要做的是进行模块划分,将需求先简单划分成几个小的、独立的功能模块,然后再在模块内部,应用我们刚刚讲的方法,进行面向对象设计。而模块的划分和识别,跟类的划分和识别,是类似的套路。
# 10.6.2 定义类以及其属性和方法
对于方法的识别,很多面向对象相关的书籍,一般都是这么讲的,识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选。类比一下方法的识别,我们可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。
识别出来 AuthToken 类的属性和方法:
从上面的类图中,我们可以发现这样三个小细节。
- 第一个细节:并不是所有出现的名词都被定义为类的属性,比如 URL、AppID、密码、 时间戳这几个名词,我们把它作为了方法的参数。
- 第二个细节:我们还需要挖掘一些没有出现在功能点描述中属性,比如 createTime, expireTimeInterval,它们用在 isExpired() 函数中,用来判定 token 是否过期。
- 第三个细节:我们还给 AuthToken 类添加了一个功能点描述中没有提到的方法 getToken()。
第一个细节告诉我们,从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到 这个类里。比如 URL、AppID 这些信息,从业务模型上来说,不应该属于 AuthToken, 所以我们不应该放到这个类中。
第二、第三个细节告诉我们,在设计类具有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,理应具有哪些属性和方法。这样可以一方面保证类定义的完整性,另一方面不仅为当下的需求还为未来的需求做些准备。
URL 类的属性和方法:
虽然需求描述中,我们都是以 URL 来代指接口请求,但是,接口请求并不一定是以 URL 的形式来表达,还有可能是 dubbo RPC 等其他形式。为了让这个类更加通用,命名更加贴 切,我们接下来把它命名为 ApiRequest。下面是我根据功能点描述设计的 ApiRequest 类。
CredentialStorage 类的属性和方法:
# 10.6.3 定义类与类之间的交互
UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。从更加贴近编程的角度,对类与类之间的关系做了调整,只保留了四个关系:泛化、实现、组合、依赖,其中,泛化、实现、依赖的定义不变,组合关系替代 UML 中组合、聚合、关联三个概念。
目前只有三个核心的类,所以只用到了实现关系,也即 CredentialStorage 和 MysqlCredentialStorage 之间的关系。
# 10.6.4 将类组装起来并提供执行入口
这个入口可能是一个 main() 函数,也可能是一组给外部用的 API 接口。
接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计了一个最顶层的 ApiAuthencator 接口类,暴露一组给外部调用者 使用的 API 接口,作为触发执行鉴权逻辑的入口。具体的类的设计如下所示:
# 10.7 如何进行面向对象编程
面向对象编程的工作,就是将这些设计思 路翻译成代码实现。有了前面的类图,这部分工作相对来说就比较简单了。所以,这里我只给出比较复杂的 ApiAuthencator 的实现。对于 AuthToken、ApiRequest、CredentialStorage 这三个类,在这里我就不给出具体的 代码实现了。
public interface ApiAuthencator {
void auth(String url);
void auth(ApiRequest apiRequest);
}
public class ApiAuthencatorImpl implements ApiAuthencator {
private CredentialStorage credentialStorage;
public ApiAuthencator() {
this.credentialStorage = new CredentialStorage();
}
public ApiAuthencator(CredentialStorage credentialStorage) {
this.credentialStorage = credentialStorage;
}
public void auth(String url) {
ApiRequest apiRequest = ApiRequest.buildFromUrl(url);
auth(apiRequest);
}
public void auth(ApiRequest apiRequest) {
String token = apiRequest.getToken();
String appId = apiRequest.getAppId();
String originalUrl = apiRequest.getOriginalUrl();
long timestamp = apiRequest.getTimeStamp;
AuthToken clientToken = new AuthToken(token, timestamp);
if (clientToken.isExpired()) {
throw new RuntimeException("token is expired");
}
String password = credentialStorage.getPasswordByAppId(originalUrl);
AuthToken serverToken = AuthToken.generate(originalUrl, appId, password, timestamp);
if (!serverToken.match(clientToekn)) {
throw new RuntimeException("Token verfication failed");
}
}
}
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
33