Java微服务-领域驱动模型设计DDD学习总结-充血模型
Java微服务-领域驱动模型设计DDD学习总结-充血模型
What
面向对象编程(Object Oriented Programming - OOP):一种编程范式或编程风格,以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石。
贫血模型:数据和业务逻辑被分隔到不同的类中。数据与操作分离,破坏了面向对象的封装特性,是典型的面向过程的编程风格。
充血模型:数据和对应的业务逻辑被封装到同一个类(领域模型)中。满足面向对象的封装特性,是典型的面向对象编程风格。
领域驱动设计(Domain Driven Design - DDD):一种设计思想,主要是用来指导如何解耦业务系统,划分业务模块,定义业务领域模型及其交互。微服务就是一种典型的实践。
Why
基于充血模型的 DDD 开发模式相较于贫血模式的开发模式,优势在哪儿?
我们平时的开发,大部分都是 SQL 驱动(SQL-Driven)的开发模式。我们接到一个后端接口的开发需求的时候,就去看接口需要的数据对应到数据库中,需要哪张表或者哪几张表,然后思考如何编写 SQL 语句来获取数据。之后就是定义 Entity、BO、VO,然后模板式地往对应的 Repository、Service、Controller 类中添加代码。service中的业务逻辑或sql基本都是针对特定的嗯业务功能编写的,复用性差。对于简单业务系统来说,这种开发方式问题不大。但对于复杂业务系统的开发来说,这样的开发方式会让代码越来越混乱,最终导致无法维护。
基于充血模型的 DDD 开发模式用类来描述业务模型(数据和功能),把原来又重又凌乱的service层逻辑拆分并转移至各领域(Domain)类内,对不同业务功能的数据和方法进行封装,提升了代码内聚性和复用性,也提高了代码可读性。由于类等同于业务模型,在功能不断迭代后就不会像贫血模型的service一样变得杂乱、模糊、难以维护,整个系统的代码看起来层次清晰,阅读逻辑简单易懂,也方便新人快速上手了解系统逻辑。无论是开发新功能还是老功能的迭代,我们优先考虑的是领域类如何定义、修改,因此称之为领域驱动设计。
简而言之:提升了代码的复用性、扩展性、可维护性、可读性。这对于复杂系统十分重要。
为什么几乎所有Web项目都基于贫血模型开发?
原因1:大部分情况下,我们开发的系统业务可能都比较简单,简单到就是基于 SQL 的 CRUD 操作,所以,我们根本不需要动脑子精心设计充血模型,贫血模型就足以应付这种简单业务的开发工作。除此之外,因为业务比较简单,即便我们使用充血模型,那模型本身包含的业务逻辑也并不会很多,设计出来的领域模型也会比较单薄,跟贫血模型差不多,没有太大意义。
原因2:充血模型的设计要比贫血模型更加有难度。因为充血模型是一种面向对象的编程风格。我们从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑。而不是像贫血模型那样,我们只需要定义数据,之后有什么功能开发需求,我们就在 Service 层定义什么操作,不需要事先做太多设计。
原因3:思维已固化,转型有成本。基于贫血模型的传统开发模式经历了这么多年,已经深得人心、习以为常。你随便问一个旁边的大龄同事,基本上他过往参与的所有 Web 项目应该都是基于这个开发模式的,而且也没有出过啥大问题。如果转向用充血模型、领域驱动设计,那势必有一定的学习成本、转型成本。很多人在没有遇到开发痛点的情况下,是不愿意做这件事情的。
什么项目应该考虑使用基于充血模型的 DDD 开发模式?
复杂业务系统、非业务系统、框架、工具等。
越复杂的系统,对代码的复用性、易维护性要求就越高,我们就越应该花更多的时间和精力在前期设计上。而基于充血模型的 DDD 开发模式,正好需要我们前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发。
对于业务不复杂的系统开发来说,基于贫血模型的传统开发模式简单够用,基于充血模型的 DDD 开发模式有点大材小用,无法发挥作用。相反,对于业务复杂的系统开发来说,基于充血模型的 DDD 开发模式,因为前期需要在设计上投入更多时间和精力,来提高代码的复用性和可维护性,所以相比基于贫血模型的开发模式,更加有优势。
引用:
“工作中遇到非crud的需求我就会想尽一切办法让他通用,基本需求分析和需求设计的时间占用百分之五十,开发和重构到自认为最优占用百分之五十。。。(略)总之我认为如果有机会遇到非crud的需求,一定要好好珍惜,好好把握,把他打造成属于自己的产品,这样会让自己下意识的去想尽一切办法把他做到最优,亲儿子一样的待遇,再也不会无脑cv,连变量名可能都要认真的重构一两遍”
HOW DDD
思考模式:面向过程 → 面向对象
面向过程:一个功能,由上至下,先做什么 、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。
面向对象:一个功能,由下至上,先将任务拆解成一个个小模块(就是类,领域模型),设计类之间 的交互,最后按照流程将 类组装起来,完成整个任务。
需要涉及经验和技巧:如何封装合适的数据和方法到一个类中,如何涉及类之间的关系和交互等等。
面向对象分析(Object Oriented Analysis - OOA):需求分析
面向对象设计(Object Oriented Design - OOD):产出系统和类设计,包含属性、方法、类之间如何交互
- 划分职责进而识别出有哪些类;方式一:基于需求中出现的名词筛选方式二:基于需求中的功能点归类
- 定义类及其属性和方法;名词作属性,动词作方法
- 定义类与类之间的交互关系;继承实现组合依赖
- 将类组装起来并提供执行入口;
每个人的设计结果都可能不太一样,需要反复迭代、重构、打破重写,这个过程也是软件开发的本质。上面只是指导思想不用照搬,熟练了基本就凭感觉。
Service类的职责是什么?
- 与Repository 交流。使domain与数据层、开发框架(spring、mybatis)解耦,保证domain的复用性。
- 跨领域模型的业务聚合功能。保证domain之间的独立性,也是解耦。如果业务的聚合变得复杂,我们还可以考虑复杂的聚合功能是否可以独立成一个领域模型。
- 一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的 RPC 接口等,都可以放到 Service 类中。
Controller 层和 Repository 层是否有必要也进行充血领域建模?
没有必要。
Controller 层主要负责接口的暴露,Repository 层主要负责与数据库打交道,这两层包含的业务逻辑并不多,前面我们也提到了,如果业务逻辑比较简单,就没必要做充血建模,即便设计成充血模型,类也非常单薄,看起来也很奇怪。
就拿 Repository 的 Entity 来说,即便它被设计成贫血模型,违反面向对象编程的封装特性,有被任意代码修改数据的风险,但 Entity 的生命周期是有限的。一般来讲,我们把它传递到 Service 层之后,就会转化成 BO 或者 Domain 来继续后面的业务逻辑。Entity 的生命周期到此就结束了,所以也并不会被到处任意修改。
我们再来说说 Controller 层的 VO。实际上 VO 是一种 DTO(Data Transfer Object,数据传输对象)。它主要是作为接口的数据传输承载体,将数据发送给其他系统。从功能上来讲,它理应不包含业务逻辑、只包含数据。所以,我们将它设计成贫血模型也是比较合理的。
示例
示例1 - 虚拟钱包(业务)
/**
充血
**/
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.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
this.balance.add(amount);
}
}
/* 贫血*/
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);
}
}
/** Domain领域模型(充血模型) **/
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) {
//...跟基于贫血模型的传统开发模式的代码一样...
}
}
示例2 - 接口鉴权(非业务)
需求:“为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。我希望由你来负责这个任务的开发,争取尽快上线。”
定义类(领域模型)
需求分析
第一轮:appid+password,明文传输。问题:明文容易被截获,不安全。
第二轮:url+appid+password生成token(sign),传url+appid+token。问题:每个url的token固定,容易被截获后伪装请求,即重放攻击。
第三轮:url+appid+password+timestamp生成token(sign),传url+appid+token+timestamp,超过限定时间的请求被视为无效请求。问题:appid、用户的密码存哪儿?
第四轮:针对 AppID 和密码的存取,使用接口进行解耦,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。
确定需求:
- 调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。
- 微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。
- 微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
- 如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。
功能点列表:
- 把 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
- 把 URL、AppID、密码、时间戳拼接为一个字符串;
- 对字符串通过加密算法加密生成 token;
- 根据时间戳判断 token 是否过期失效;
- 验证两个 token 是否匹配。
Url
- 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;
- 解析 URL,得到 token、AppID、时间戳等信息。
CredentialStorage
- 从存储中取出 AppID 和对应的密码。
定义类与类之间的交互关系
MysqlCredentialStorage 实现 CredentialStorage接口
将类组装起来并提供执行入口
定义ApiAuthenticator 接口,提供给网关过滤器调用
/*
(鉴权 - 充血模型)ApiAuthenticator.java
**/
package com.heytea.manager.payment.utils;
import sun.plugin2.main.server.AppletID;
import java.util.Map;
public interface ApiAuthenticator {
void auth(String url);
void auth(ApiRequest apiRequest);
}
class AuthToken {
private static final long DEFAULT_EXPIRED_TIME_INTERVAL = 1 * 60 * 1000;
private String token;
private long createTime;
private long expriredTimeInterval = DEFAULT_EXPIRED_TIME_INTERVAL;
public AuthToken(String token, long createTime) {
this.token = token;
this.createTime = createTime;
}
public AuthToken(String token, long createTime, long expriredTimeInterval) {
this.token = token;
this.createTime = createTime;
this.expriredTimeInterval = expriredTimeInterval;
}
public static AuthToken generate(String baseUtl, long createTime, Map params) {
return null;
}
public String getToken() {
return null;
}
public boolean isExpired() {
return false;
}
public boolean match(AuthToken authToken) {
return false;
}
}
@Getter
class ApiRequest {
private String baseUrl;
private String token;
private String appId;
private long timestamp;
public ApiRequest(String baseUrl, String token, String appId, long timestamp) {
this.baseUrl = baseUrl;
this.token = token;
this.appId = appId;
this.timestamp = timestamp;
}
public static ApiRequest buildFromUrl(String url) {
return null;
}
}
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.getBaselUrl();
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.");
}
}
}