基于充血模型的DDD开发模型
1. 传统基于MVC的开发模式
MVC三层结构中的M表示Model,V表示View,C表示Controller。通过这三层将整个项目分为了三大部分,展示层,逻辑层,数据层
而贫血模型 - Anemic Domain Model,指的是我们将数据和操作分离,有专门的POJO类,即只包含数据的类,这实质上会破坏面向对象的封装特性,是一种面向过程的编程风格。
2. 基于充血模型的DDD开发模式
充血模型 - rich domain model,旨在将数据和对应的业务逻辑封装在同一个类当中。
3. 实战 DDD 开发虚拟钱包系统
3.1 钱包业务背景介绍
需要创建一个系统内的虚拟钱包账户,来支持用户的充值,提现,支付,冻结,透支,转赠,查询账户余额,查询交易流水等操作。
在这里,我们假定要去实现一个具备充值,提现, 支付,查询余额,还有查询交易流水五个功能的钱包。
其业务流程分别为:
- 充值
- 用户通过第三方支付渠道,将自己银行卡里面的钱充值到虚拟钱包账号当中
- 操作流程
- 从用户银行卡到应用的公共银行卡
- 用户虚拟钱包增加金额
- 记录刚刚这笔交易流水
- 支付
- 实际上是一个转账的过程,从用户的虚拟钱包账户划钱到商家的虚拟钱包账户当中
- 记录流水信息
- 提现
- 用户虚拟钱包 减去对应的钱数
- 应用的公共银行卡 打钱 到用户的银行卡
- 记录交易
- 查询余额
- 看虚拟钱包的余额数字
- 查询交易流水
- 查询充值,支付,提现三种操作
3.2 设计思路
首先我们需要对系统进行解耦,即用相似特征和特性的功能放到同一个子系统当中。根据特性,我们可以分为虚拟钱包系统和三方支付系统两个部分。
- 虚拟钱包
- 用户虚拟钱包
- 商家虚拟钱包
- 三方支付
- 用户银行卡
- 商家银行卡
- 应用公共银行卡
虚拟钱包需要支持的操作基本上就是对于余额的加减,充值,提现,查询三种操作都是只涉及到一个账户的余额的加减操作;而支付功能涉及到两个账户的余额的加减操作。
而对于交易记录,应当记录的信息有:
- 交易流水ID
- 交易时间
- 交易金额
- 交易类型
- 充值
- 提现
- 支付
- 入账钱包账号
- 出账钱包账号
这么设计是有点浪费存储空间的,因为对于充值提现这种交易类型来说,我们只要记录一个钱包账户信息就好了。
另外一种方式就是在交易类型处,设计成支付和被支付两种类型,这样在对待转账的情况的时候,数据库写两条数据,来记录整个transaction。能够省空间,但是会有一些问题:
最重要的难点还是在数据的一致性方面,当我们在做转账操作的时候,我们必须保证加减两个操作要么都成功,要么都失败。如果一个成功,一个失败,那会完蛋的。关于钱的事情,发生一点错误就会对公司造成非常大的影响。
对于转账及类似的操作,合理的做法是在操作两个钱包的账户余额之前,先记录交易流水,并且标记为待执行,当两个钱包的加减金额都完成了之后,我们再回头将交易记录的状态标记为失败。然后我们通过后台的补漏job,拉取状态为失败或者长时间处于待执行状态的交易记录,重新执行或者人工介入处理。
另外一个点在我们会构建一个钱包系统,然后分出两个子系统,虚拟钱包还有第三方交易平台,那么我们的商业逻辑都应该放到钱包系统这一个层级上,我们希望我们的虚拟钱包还有交易平台尽量和我们的商业逻辑脱钩,更多的是事务上方法上的更泛化的东西。这样做的好处是我们的商业逻辑会经常发生变化,但是我们希望虚拟钱包,还有第三方交易平台两个模块不需要经常性的变动。这也是去做两个子系统的初衷之一。
3.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) { ...} //转账
}
public class VirtualWalletBo {//省略getter/setter/constructor方法
private Long id;
private Long createTime;
private BigDecimal balance;
}
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);
}
public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
if (balance.compareTo(amount) < 0) {
throw new NoSufficientBalanceException(...);
}
walletRepo.updateBalance(walletId, balance.subtract(amount));
}
public void credit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
walletRepo.updateBalance(walletId, balance.add(amount));
}
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setFromWalletId(fromWalletId);
transactionEntity.setToWalletId(toWalletId);
transactionEntity.setStatus(Status.TO_BE_EXECUTED);
Long transactionId = transactionRepo.saveTransaction(transactionEntity);
try {
debit(fromWalletId, amount);
credit(toWalletId, amount);
} catch (InsufficientBalanceException e) {
transactionRepo.updateStatus(transactionId, Status.CLOSED);
...rethrow exception e...
} catch (Exception e) {
transactionRepo.updateStatus(transactionId, Status.FAILED);
...rethrow exception e...
}
transactionRepo.updateStatus(transactionId, Status.EXECUTED);
}
}
3.4 基于充血模式的DDD开发模式
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.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
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);
}
public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.debit(amount);
walletRepo.updateBalance(walletId, wallet.balance());
}
public void credit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.credit(amount);
walletRepo.updateBalance(walletId, wallet.balance());
}
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
//...跟基于贫血模型的传统开发模式的代码一样...
}
}
一些思考:
- 领域模型 希望其尽可能的独立,不包含任何其它层的代码,将流程性的代码逻辑和领域模型的业务逻辑解耦,让领域模型更加可以复用
- Service类负责一些非功能性的和与第三方交互的工作
- 信息传送
- metrics
- 日志
4. 实战 - 接口鉴权
目的是熟悉在拿到相对笼统的开发需求的时候,需要如何做需求分析,如何做职责划分,看需要定义哪些类,每个类应该具有哪些属性,方法;定义类和类的交互
4.1 需求
微服务系统,通过HTTP协议暴露接口给其他系统调用。需要实现一个接口鉴权系统,只有经过认证的系统才能调用我们的接口
- 需求分析
- 基础分析
- 通过用户名加密码来做认证
- 每个允许访问的调用发都有应用ID还有秘钥,在做接口请求的时候,需要传进来应用ID和秘钥,然后我们在自己的服务器来进行验证比对。如果一致,说明认证成功,允许接口调用了否则,就拒绝
- 二轮分析
- 这种方式,明文传输,容易被拦截,并不安全
- 借助加密算法,对密码进行加密再传递到微服务端验证,同样不安全。因为还是可以被拦截,被拦截以后黑客可以直接拿着这个加密的密码加ID来假装是调用者向服务端发出请求
- OAuth方式
- 调用方生成token (id + appId + pwd)
- 调用方生成新的URL (id + appId + token)
- Server解析出URL, appId, token
- Server从数据库根据appId拿出pwd
- Server利用Url,appId, pwd生成server端token
- 比较是否一致
- 三轮分析
- 上述方式还是可能存在重放攻击,被拦截,然后来伪装成认证系统,调用这个URL对应的接口。
- token生成过程加入时间戳,然后传递到微服务器端
- 微服务器收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内。超过时间窗口,也会决绝请求
- 四轮分析
- 基本就是到这个程度,因为我们还要考虑性能方面的东西。这种方式对于性能的影响比较小,也考量到了安全性。
- 如何在微服务端存储每个授权调用方的appId和密码
- 开发鉴权这种非业务功能,最好不要与具体的第三方系统有过度的耦合
- 最好能够支持多种不同的存储方式
- ZooKeeper
- 本地配置文件
- 自研配置中心
- MySQL
- Redis等
- 最终需求的确定
- 调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。
- 微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。
- 微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
- 如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。
- 基础分析
4.2 面向对象设计
- 进行职责划分,进而识别出都有哪些类
- 将需求描述中的名词罗列出来,作为可能的候选类,然后进行筛选
- 或者根据需求描述,将其中涉及的功能点,一个一个罗列出来,然后再看哪些功能点职责相近,操作同样的属性,能否归到同一个类当中
- 定义类,及其属性和方法
- 定义类和类之间的交互关系
- 将类组装起来并提供执行入口
- 功能点列表
- 把 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 和密码,负责从存储中读取 AppID 和密码。所以,我们可以粗略地得到三个核心的类:AuthToken、Url、CredentialStorage。AuthToken 负责实现 1、2、6、7 这四个操作;Url 负责 3、4 两个操作;CredentialStorage 负责 5 这个操作。
// 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 expredTImeInterval);
public static AuthToken create(String baseUrl, long createTime, Map<String, String> params);
public String getToken();
public boolean isExpired();
public boolean match(AuthToken authToken)
- Tips
- 并不是所有的需要的名词类的属性都会作为类的属性,有可能会作为方法的参数。选择的基准还是这个属性到底属不属于这个类,从这个角度来看的
- 我们有可能需要去挖掘一下在功能需求里面并没有体现的一些属性 还是需要从业务模型的角度上来看究竟需要怎么做才比较好
public interface ApiAuthenticator {
void auth(String url);
void auth(ApiRequest apiRequest);
}
public class DefaultApiAuthenticatorImpl implements ApiAuthenticator {
private CredentialStorage credentialStorage;
public DefaultApiAuthenticator() {
this.credentialStorage = new MysqlCredentialStorage();
}
public DefaultApiAuthenticator(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, timestamp);
if (!serverAuthToken.match(clientAuthToken)) {
throw new RuntimeException("Token verfication failed.");
}
}
}
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 stone2paul@gmail.com
文章标题:基于充血模型的DDD开发模型
文章字数:3.3k
本文作者:Leilei Chen
发布时间:2020-03-06, 08:49:16
最后更新:2020-03-09, 01:05:14
原始链接:https://www.llchen60.com/%E5%9F%BA%E4%BA%8E%E5%85%85%E8%A1%80%E6%A8%A1%E5%9E%8B%E7%9A%84DDD%E5%BC%80%E5%8F%91%E6%A8%A1%E5%9E%8B/版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。