记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步
文章目录
实战案例:虚拟钱包
1. 业务背景
很多有支付、购买功能的应用都支持钱包的功能,应用为每个用户开设一个系统内的虚拟钱包账户,支持用户充值、提现、支付、冻结、透支、转赠、查询账户余额、查询交易流水等操作。
一般,每个虚拟钱包账户都会对应用户的一个真实的支付账户,可能是银行卡账户,或者支付宝、微信钱包等三方支付账户。暂时限定只支持充值、提现、支付、查询账户余额、查询交易流水五个核心功能。
具体业务流程
1. 充值
用户通过三方支付渠道,把自己银行卡账户的钱,充值到虚拟钱包账号中。可分解为三个主要的操作流程:
- 从用户的银行卡账户转账到应用的公共银行卡账户
- 将用户的充值金额加到虚拟钱包余额中
- 记录刚刚的这笔交易流水
2. 支付
用户用钱包内的余额,支付购买应用内的商品。实际上,就是个转账的过程,从用户的虚拟钱包账户划钱到商家的虚拟钱包账户,然后出发真正的银行账户转账操作,从应用的公共银行账户转钱到商家的银行账户。此外,也要记录这笔支付的交易流水信息。
3. 提现
除了充值、支付外,用户可将虚拟钱包的余额,提现到自己的银行卡。实际就是扣减用户虚拟钱包中的余额,并触发真正的银行转账操作,从应用的公共银行账户转账到用户的银行账户。同样也要记录这笔提现的交易流水信息。
4. 查询余额
查询余额比较简单,看下虚拟钱包中的余额数字即可
5. 查询交易流水
只支持三种类型的交易流水:充值、支付、提现。会记录相应的交易信息。在需要查询时,将之前记录的交易流水,按照时间、类型等条件过滤后,展示即可。
2. 钱包系统的设计思路
根据上述业务流程和数据流转图,将业务分为两部分,一部分单纯跟应用的虚拟钱包账户打交道,一部分跟银行账户打交道。将钱包系统拆分为两个子系统:虚拟钱包系统和三方支付系统。
接下来只关注虚拟钱包系统的设计和实现
要支持钱包的五个核心功能,虚拟钱包系统需要对应实现哪些操作。交易流水的记录和查询较特殊,之后再看。
操作很简单,就是余额的加加减减。充值、提现、查询余额功能,只涉及一个账户余额的加减,而支付涉及两个账户的余额加减:一个账户减余额,一个账户加余额。
交易流水该如何记录和查询?
交易流水需要包含的信息:
- 交易流水ID
- 交易时间
- 交易金额
- 交易类型:充值、提现、支付
- 入账钱包账号
- 出账钱包账号
为什么两个账号?为了兼容支付场景。为了保证数据一致性。对于业务来说,只需要保证最终一致性即可。
对于支付这种类似转账的操作,操作两个钱包的账户余额之前,先记录交易流水,并标记为“待执行”,当两个钱包的加减金额都完成后,再将交易流水标记为“成功”。否则,只要任意一个失败,都将状态标记为“失败”。通过后台task,拉取状态为“失败”或长时间处于“待执行”的交易记录,重新执行或人工处理。
是否应该在虚拟钱包系统的交易流水中记录充值、提现、支付这三种类型
虚拟钱包只需支持余额的加加减减,不涉及复杂业务概念,职责单一、功能通用。如果耦合太多业务概念,影响通用性,导致系统越做越复杂。
那用户查流水时,如何展示每条交易流水的交易类型
系统设计角度,不应该在虚拟钱包系统的交易流水中记录交易类型;产品需求角度,必须记录交易类型,如何解决该矛盾?
通过记录两条交易流水信息的方式解决。整个钱包系统分为两个子系统,对于上层钱包系统,可感知充值、支付、提现等业务概念,所以在钱包系统上层额外记录一条包含交易类型的交易流水信息。底层虚拟钱包系统不记录交易类型。
如上图,通过查询上层钱包系统的交易流水信息,来满足用户查询交易流水的功能需求,而虚拟钱包中的交易流水只用来解决数据一致性问题。
3. 贫血模型MVC实现
controller层
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){...}//转账
}
service层代码如下,省略了一些不重要的校验代码,如对amount是否小于0、钱包是否存在的校验等。
public class VirtualWalletBo{
// 省略getter、setter和constructor
private Long id;
private Long createTime;
private BigDecimal balance;
}
public class VirtualWalletService{
//通过构造函数或IOC框架注入
private VirtualWalletDao walletDao;
private VirtualWalletTransactionDao transactionDao;
public VirtualWalletBo getVirtualWallet(Long walletId){
VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
VirtualWalletBo walletBo = convert(walletEntity);
return walletBo;
}
public BigDecimal getBalance(Long walletId){
return virtualWalletDao.getBalance(walletId);
}
public void debit(Long walletId,BigDecimal amount){
VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
if(balance.compareTo(amount)<0){
throw new NoSufficientBalanceException(...);
}
walletDao.updateBalance(walletId,balance.substract(amount));
}
public void credit(Long walletId,BigDecimal amount){
VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
walletDao.updateBalance(walletId,balance.add(amount));
}
public void transfer(Long fromWalletId,Long toWalletId,BigDecimal amount){
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMills);
transactionEntity.setFromWalletId(fromWalletId);
transactionEntity.setToWalletId(toWalletId);
transactionEntity.setStatus(Status.TO_BE_EXECUTED);
Long transactionId = transactionDao.saveTransaction(transactionEntity);
try{
debit(fromWalletId,amount);
credit(toWalletId,amount);
}catch(InsufficientBalanceException e){
transactionDao.updateStatus(transactionId,Status.CLOSED);
...throw exception e...
}catch(Exception e){
transactionDao.updateStatus(transactionId,Status.FAILED);
...throw exception e...
}
transactionDao.updateStatus(transactionId,Status.EXECUTED);
}
}
4. 充血模型的DDD开发模式实现
只有service层不同,把虚拟钱包VirtualWallet类设计成一个充血的Domain领域模型,并将原来在service类的部分业务逻辑移到VirtualWallet类中,让service类的实现依赖VirtualWallet类。
public class VirtualWallet{
//Domain 领域模型
private Long id;
private Long createTime = System.currentTimeMills;
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 NoSufficientBalanceException(...);
}
this.balance.substract(amount);
}
public void credit(BigDecimal amount){
if(amount.compareTo(BigDecimal.ZERO)<0){
throw new InvalidAmountException(...);
}
this.balance.add(amount);
}
}
public class VirtualWalletService{
//通过构造函数或IOC框架注入
private VirtualWalletDao walletDao;
private VirtualWalletTransactionDao transactionDao;
public VirtualWalletBo getVirtualWallet(Long walletId){
VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
return wallet;
}
public BigDecimal getBalance(Long walletId){
return virtualWalletDao.getBalance(walletId);
}
public void debit(Long walletId,BigDecimal amount){
VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.debit(amount);
walletDao.updateBalance(walletId,wallet.balance());
}
public void credit(Long walletId,BigDecimal amount){
VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.credit(amount);
walletDao.updateBalance(walletId,wallet.balance());
}
public void transfer(Long fromWalletId,Long toWalletId,BigDecimal amount){
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMills);
transactionEntity.setFromWalletId(fromWalletId);
transactionEntity.setToWalletId(toWalletId);
transactionEntity.setStatus(Status.TO_BE_EXECUTED);
Long transactionId = transactionDao.saveTransaction(transactionEntity);
try{
debit(fromWalletId,amount);
credit(toWalletId,amount);
}catch(InsufficientBalanceException e){
transactionDao.updateStatus(transactionId,Status.CLOSED);
...throw exception e...
}catch(Exception e){
transactionDao.updateStatus(transactionId,Status.FAILED);
...throw exception e...
}
transactionDao.updateStatus(transactionId,Status.EXECUTED);
}
}
如果业务逻辑更加复杂,充血模型的优势就显示出来了。如要支持透支一定额度和冻结部分余额的功能。
public class VirtualWallet{
//Domain 领域模型
private Long id;
private Long createTime = System.currentTimeMills;
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.substract(this.frozenAmount);
if(isAllowedOverdraft){
totalAvaliableBalance += this.overdraftAmount;
}
return totalAvaliableBalance;
}
public void debit(BigDecimal amount){
BigDecimal totalAvaliableBalance = this.balance.substract(this.frozenAmount);
if(totalAvaliableBalance.compareTo(amount)<0){
throw new NoSufficientBalanceException(...);
}
this.balance.substract(amount);
}
public void credit(BigDecimal amount){
if(amount.compareTo(BigDecimal.ZERO)<0){
throw new InvalidAmountException(...);
}
this.balance.add(amount);
}
}
当然,随着功能的演进,可增加更加细化的冻结策略、透支策略、支持钱包账号(VirtualWalletId字段)自动生成的逻辑(不是通过构造函数经外部传入id,而是分布式id生成算法自动生成id)等。业务越复杂,就很值得设计为充血模型。
5. 两个问题
问题1
基于充血模型的DDD开发模式,将业务逻辑移到domain,service类变得很薄,能否直接将service类去掉?service类的职责是什么?哪些逻辑放到service中的?
service类主要有以下几个职责:
- service类与dao层交互。我们需要保持领域模型domain的独立性,不和其他层的代码耦合,让domain更加可复用。
- service类负责跨domain的业务逻辑聚合功能。如VirtualWalletService类的transfer()转账行数会涉及两个钱包的操作,无法放到VirtualWallet类中。将转账业务放到VIrtualWalletService类中,随着功能的演进,转账业务更复杂后,可以将转账业务抽取出来,设计为一个独立的domain。
- service类负责一些非功能性和与三方系统交互的工作。如幂等、事务、发邮件、发消息、记录日志、调用其他系统的RPC接口等,都可以放到service类中。
问题2
基于充血模型的DDD开发模式,service层被改造为充血模型,但controller和dao层都没改,是否有必要改造?
没必要,这两层包含的业务逻辑并不多。
controller的vo,实际上是一种DTO(Data Transfer Object,数据传输对象)。主要作为接口的数据传输承载体,将数据发送给其他系统,理应不包含业务逻辑,只包含数据。
实战2:如何对接口鉴权这样一个功能开发做面向对象分析
1. 案例介绍和难点分析
假设正在参与开发一个微服务,通过http协议暴露接口给其他系统调用,也就是其他系统通过URL调用微服务的接口。一天,leader给你说,“为保证接口调用的安全性,希望设计实现一个接口调用鉴权功能,只有经过认证后的系统才能调我们的接口,没有认证过的调用会被拒绝,希望你负责这个任务的开发,争取尽快上线”。
leader很忙,说完就走了,你该如何做?是否感觉无从下手?
原因:
- 需求不明确 需求过于笼统,不够具体,离落地编码还有一定距离。当然,真实的开发中,需求几乎都是不很明确。需求分析,首先做的就是将笼统的需求细化到可执行。通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些不用考虑的。
- 缺少锻炼 相比单纯的业务CRUD,鉴权更有难度。作为和具体业务无关的功能,完全可以把它开发为一个独立的框架,集成到多个业务系统,对代码质量要求更高。开发通用的框架,对需求分析能力、设计能力、编码能力额逻辑思维能力的要求,都较高,平时简单的业务开发,这种锻炼机会不多,因此无从下手,没有思路。
2. 对案例需求分析
先要想到个MVP,最简单的可实现的demo
1. 第一轮基础分析
最简单的就是通过用户名、密码认证。给每个调用方,派发一个应用名appId,和一个密码(或者叫密钥)。调用方每次对接口请求时,都携带自己的appId和密钥。微服务接收到请求后,解析appId和密码,跟存储在服务端的appId和密码对比,如果一致,说明认证成功,允许调用,否则拒绝请求。
2. 第二轮优化
这种每次都是明文传输密码,不安全。借助加密算法如SHA,加密后传到服务端验证,也不安全,如重放攻击。
如何解决?借助OAuth的验证思路。调用方将请求接口的URL跟appId和密码拼接到一起,再加密,生成token。调用方请求时,将token和appId跟随url一起传到服务端,服务端解析后,根据appId从数据库中获取密码,通过同样的token生成算法,生成新token,跟调用方传来的token对比。一致,允许请求,否则拒绝。
3. 第三轮优化
这种仍不太安全,url拼接appId、密码生成的token都是固定的,未认证系统截获url、token和appId后,还能重放攻击,调用这个url对应的接口。
我们需要引入一个随机变量,如时间戳,让每次生成的token都不一样。微服务端收到数据后,验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(如一分钟),如果超时,判定token过期,拒绝请求;没有超时,用同样的token生成算法,在服务端生成新token,对比是否一致。
4. 第四轮优化
攻防之间,本就没有绝对的安全,只是提高攻击的成本。该方案足够简单,也不过度影响接口本身的性能如响应时间。权衡安全性、开发成本、对系统性能的影响,该方案较合理。
其实还有个细节,就是如何在服务端存储每个授权调用方的appId和密码。鉴权这种非业务功能,最好不要和具体的第三方系统有过度的耦合,最好能灵活的支持不同存储方式,如zookeeper、本地配置文件、自研配置中心、mysql、redis等。最好留下扩展点,保证系统有足够的灵活性和扩展性。
5. 最终确定需求
跟leader描述清楚细化的需求。
调用方在进行接口请求的时候,将url、appId、密码、时间戳拼接在一起,通过加密算法生成token,并将token、appId、时间戳拼接到url中,一起发送到服务端。
服务端接收到调用方请求后,解析出token、appId和时间戳。首先检查时间戳跟当前时间,是否在token失效时间窗口内,如果超过失效时间,调用鉴权失败,拒绝调用请求。
如果没有过期,从自己的存储中取出appId对应的密码,用同样的token生成算法,生成另一个token,与调用方传的token匹配,一致允许调用,否则拒绝调用
ps:可以和10X程序员一起服用,效果更好
3. 如何进行面向对象设计
面向对象分析产出的是详细的需求描述,面向对象设计的产出就是类。把设计环节细化后:
- 划分职责进而识别有哪些类
- 定义类及其属性和方法
- 定义类与类之间的交互关系
- 将类组装并提供执行入口
1. 划分职责进而识别有哪些类
识别类的方法,把需求描述中的名词罗列出来,作为可能的候选项,再筛选。
另一种,根据需求描述,把其中涉及到的功能点,一个个罗列出来,再看哪些功能点职责相近,操作同样的属性,可否归为同一个类。
逐句拆解鉴权的需求描述后得到的功能点:
- 把url、appId、密码、时间戳拼接为一个字符串
- 对字符串通过加密算法加密生成token
- 将token、appId、时间戳拼接到url,形成新的url
- 解析url得到token、appId和时间戳
- 从存储中取出appId和对应密码
- 根据时间戳判断token是否过期
- 验证两个token是否匹配
其中,1、2、6、7都跟token有关,负责token的生成、验证;3、4都在处理url,负责url的拼接和解析;5操作appId和密码。粗略得到三个核心类:AuthToken、Url、CredentialStorage。
这是初步的划分,编程本身就是不断迭代优化的过程。先有个基础的设计方案。
真正的大型软件开发、复杂的需求开发,涉及到的功能点较多,对应类也较多,需要先进行模块划分,将需求简单划分为几个小的功能模块,再在模块内部,应用刚才的方法,进行面向对象设计。套路类似。
2. 定义类及其属性和方法
AuthToken类的功能点有4个:
- 把url、appId、密码、时间戳拼接为一个字符串
- 对字符串通过加密算法加密生成token
- 根据时间戳判断token是否过期
- 验证两个token是否匹配
对于方法的识别,一般都是识别出需求描述的动词,作为候选的方法,再筛选。把功能点涉及到的名词,作为候选属性,同样筛选。
AuthToken类
属性:
private static final long DEFAULT_EXPIRED_TIME_INTERVAL = 1*60*1000;
private String token;
private long createTime;
private long expiredTimeInterval = DEFAULT_EXPIRED_TIME_INTERVAL;
构造函数:
public AuthToken(String token,long createTime);
public AuthToken(String token,long createTime,long expiredTimeInterval);
方法:
public static AuthToken create(String baseUrl,long createTime,Map<String,String> params);
public String getToken();
public boolean isExpired();
public boolean match(AuthToken authToken);
有三个细节:
- 并不是所有出现的名词都被定义为类的属性,如url、appId、密码、时间戳,我们把它作为方法的参数
- 还需要挖掘一些没有出现在描述中的属性,如createTime,expireTimeInterval,用在isExpired()方法中,判定token是否过期
- 还给AuthToken类添加了一个功能点描述中没提到的方法getToken()
第一个细节说明,从业务模型上说,不应该属于这个类的属性和方法,不要放到这个类里,如url、appId信息。
第二三个细节说明,不能简单的依赖当下的需求,还要从整个业务模型触发,看应该具备哪些属性和方法。一方面保证类定义的完整性,另一方面也为未来的需求做准备。
url类的功能点有两个:
- 将token、appId、时间戳拼接到url,形成新的url
- 解析url得到token、appId和时间戳
虽然需求描述中是url来指代接口请求,但接口请求不一定是url的形式,也可能是dubbo rpc等其他形式。为了让该类更加通用,命名更贴切,起名为ApiRequest。
ApiRequest类
属性:
private String baseUrl;
private String token;
private String appId;
private long timestamp;
构造函数:
public ApiRequest(String baseUrl,String token,String appId,long timestamp);
方法:
public static ApiRequest createFromFullUrl(String url);
public String getBaseUrl();
public String getToken();
public String getAppId();
public long getTimestamp();
而CredentialStorage类的相关的功能点有一个:
从存储中取出appId和对应密码
该类很简单,为了做到抽象封装具体的存储方式,将其设计为接口,基于接口而非具体的实现编程
CredentialStorage接口
接口方法:
String getPasswordByAppId(String appId);
3. 定义类与类之间的交互关系
UML统一建模语言定义了六种类之间的关系:泛化、实现、关联、聚合、组合、依赖。
泛化generalization可以简单理解为java的继承关系。
实现realization一般指接口和实现类之间的关系。
聚合aggregation是一种包含关系,A类对象包含B类对象,B类对象的生命周期可以不依赖A类对象的生命周期,如课程与学生之间的关系。
组合composition也是一种包含关系,只是B类对象的生命周期依赖A类对象的生命周期,B不能单独存在,如鸟跟翅膀之间的关系。
关联association是一种弱关系,只要B类对象是A类的成员变量,B类和A类就是关联关系。
依赖dependency是一种比关联关系更弱的关系,包含关联关系。只要B类对象和A类对象有任何使用关系,都称为依赖关系。
从更贴近编程的角度,对类与类之间的关系做调整,只保留4个关系:泛化、实现、组合、依赖。组合关系替代了UML的组合、聚合、关联三个概念,只要B类对象是A类对象的成员变量,就称A类跟B类是组合关系。
那刚定义的三个类之间有哪些关系?只用到了实现关系,也就是CredentialStorage和MysqlCredentialStorage之间的关系。
4. 将类组装起来并提供执行入口
这个入口可能是main函数,也可能是一组给外部调用的Api接口,通过该入口,触发代码跑起来。
接口鉴权不是一个独立运行的系统,而是一个集成在系统上运行的组件,封装所有实现细节,设计一个顶层的ApiAuthencator接口类,暴露给外部调用者使用的API接口,作为触发执行鉴权逻辑的入口。
ApiAuthencator接口
接口方法:
void auth(String url);
void auth(ApiRequest apiRequest);
DefaultApiAuthencatorImpl实现类
属性:
private CredentialStorage credentailStorage;
构造方法:
public ApiAuthencator();
public ApiAuthencator(CredentialStorage credentialStorage);
方法:
void auth(String url);
void auth(ApiRequest apiRequest);
4. 如何面向对象编程
只需要将类图翻译为代码,只给出较为复杂的ApiAuthencator的实现
public interface ApiAuthencator{
void auth(String url);
void auth(ApiRequest apiRequest);
}
public class DefaultApiAuthencatorImpl implements ApiAuthencator{
private CredentialStorage credentialStorage;
public ApiAuthencator(){
this.credentialStorage = new MysqlCredentialStorage();
}
public ApiAuthencator(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("token is expired");
}
String password = credentialStorage.getPasswordByAppId(appId);
AuthToken serverAuthToken = AuthToken.generate(originalUrl,appId,password);
if(!serverAuthToken.match(clientAuthToken)){
throw new RuntimeException("token verfication failed");
}
}
}
5. 辩证思考和灵活运用
其实代码一般都是边写边重构、迭代,就像学驾照,驾校的流程很正规,按照流程顺利倒库,实际开车熟练后,都是根据经验和感觉。
遵循这套SOP,方法单个不超过五十行,我们平时都是将各种东西都塞到方法里,导致面向过程编程,非常臃肿。