导读: 对于后端开发来说,大家或许过多崇尚于技术上的问题,或许是某个中间件的性能,某个微服务组件的强大?业务对于大部分人来说,只是CRUD,诚然,一切的需求落地就是对于各种数据库的增删改查。但是我们着手于现实社会,计算机的兴起就是为了帮助人们解决现实中存在的问题,Web技术的发展,就是为了解决一个个实际的需求与业务。再高层的技术落地也是为了业务所服务,这就引出了一个大家时常忽略的问题,如何去治理一个超大型业务项目,如何去规范约束代码。
在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。
前言
我更愿意称DDD是一种架构思想,而不是一种“框架”,框架给人一种死板的感觉。事实也是这样,自然世界是复杂多变的,业务随着需求的变化也在不断的改变,仅采用固定的一套框架,告诉你,我们用DDD框架,什么代码应该放在哪个包,这并不是DDD的目的。我们来谈谈目前大家应该都了解的MVC架构。MVC分层架构是非常优秀的一种架构体系 ,ORM框架的盛行更是助燃了MVC的发展。在Mybatis的帮助下,我们的代码结构不可避免会有如下几个包,controller,service,mapper,pojo。借用工具,我们的pojo层的实现变得死板简单,仅仅只是与数据库表对应,面向对象编程的思想逐渐转化为面向数据库表编程。在十年前,大部分的商业应用还处于单机时代,分布式微服务的概念还未盛行。大家的API调用大多都是基于REST接口的外部调用。
在之后,服务概念开始冒头,从一开始的SOA架构,到后面更加细致的微服务架构,甚至于如今盛行的所谓“云原生”,“serverless架构”。微服务崇尚的是业务的拆分,拆分为高内聚低耦合的服务,而DDD也是同样着重于业务的视角,两者在追求同样的目标达到了上下文统一。微服务场景下,服务调用方式不仅是REST,RPC以及消息队列的调用方式变得更加常见,服务之间的依赖关系变得错综复杂,如何去合理拆分服务也成为了一大挑战和难题。一个电商系统拆分,难道全部笼统的拆分于用户模块,商家模块,商品模块,店铺模块......DDD的思想是领域驱动设计,Domain Driven Design,这种思想让我们能真正冷静下来,去思考到底哪些东西可以被服务化拆分,哪些逻辑需要聚合,才能带来最小的维护成本,而不是简单的去追求开发效率。Gordon Moore提出软件设计好坏的标准是,高内聚低耦合,无论是单机时代,再到分布式时代,乃至今天的微服务云原生时代,这一言论依然可行,那么什么是高内聚与低耦合,DDD思想如何帮助我们设计出一个优秀的软件架构呢?
Domain Primitive
DP是DDD架构中的核心角色,也可以理解为最小单位,他明显区别于MVC架构中的model,最简单理解就是,充血模型与贫血模型。Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。下面我用一个订单的简单例子介绍一下什么是贫血模型,什么又是充血模型。
贫血模型
贫血模型是一种将业务逻辑与数据分离的设计方式。在这种模型中,领域对象通常只包含数据属性,而业务逻辑则分布在服务类(Service)中。领域对象在这种设计中更像是数据的载体,而实际的操作和逻辑处理则由独立的服务类负责。以下代码中,Order类仅包含订单的属性,这些属性一般和我们的数据库表对应,而所有的逻辑均放在我们的Service服务层进行处理。这种模型较为简单,并且比较清晰,但是他不符合高内聚的特点,是一种低内聚的设计,可惜的是,目前市面上绝大多数的教学,以及初学者都是应用这种模型进行编码。
public class Order {
private String orderId;
private List<OrderItem> items;
// getters and setters
}
public class OrderService {
public void addItem(Order order, OrderItem item) {
order.getItems().add(item);
}
public void removeItem(Order order, OrderItem item) {
order.getItems().remove(item);
}
public double calculateTotalPrice(Order order) {
return order.getItems().stream().mapToDouble(OrderItem::getPrice).sum();
}
}
充血模型
充血模型是一种将业务逻辑和数据紧密结合的设计方式。在这种模型中,领域对象不仅包含数据属性,还包含操作这些数据的业务逻辑方法。也就是说,领域对象不仅仅是数据的载体,还负责自身的行为和状态管理。充血模型提倡将业务逻辑尽量放在领域对象内部,这样可以提高代码的内聚性和可维护性。可以看到以下Order类不仅仅包含了必要的属性,还封装了部分增删改查函数,这边实例代码没有涉及到dao层,实际上充血模型是可以与持久层直接结合,对数据库进行操作的。领域驱动设计的最重要一环,就是如何应用充血模型。
public class Order {
private String orderId;
private List<OrderItem> items;
public Order(String orderId) {
this.orderId = orderId;
this.items = new ArrayList<>();
}
public void addItem(OrderItem item) {
items.add(item);
}
public void removeItem(OrderItem item) {
items.remove(item);
}
public double calculateTotalPrice() {
return items.stream().mapToDouble(OrderItem::getPrice).sum();
}
// getters and setters
}
样例
好了,了解了贫血模型和充血模型,我们用一个实际的业务例子来讲一下DP。这个业务逻辑是这样的
一个招商系统需要在全国范围内进行招商,商家需要注册进这个招商系统,根据商家经营的商品种类,对商家的type进行区分,并且根据商家负责人的手机号,划定这个商家的地域。
我们先不要管这个业务合理不合理,我们着手于实践,这个需求拿到我们手上,我们用MVC的方式去拆分那便是一个Merchant(商家类),包含了商家的基本属性,一个Respository用来于持久层交互,然后在服务层拼凑实现我们的业务。代码如下:
数据模型(pojo)
public class Merchant {
private Long id;
private String name;
private String phone;
private String productCategory;
private String type;
private String region;
// getters and setters
}
数据访问层(dao)
import java.util.List;
public interface MerchantRepository {
void save(Merchant merchant);
Merchant findById(Long id);
List<Merchant> findAll();
// 其他数据访问方法
}
服务层(service)
public class MerchantService {
private MerchantRepository merchantRepository;
public MerchantService(MerchantRepository merchantRepository) {
this.merchantRepository = merchantRepository;
}
public void registerMerchant(Merchant merchant) {
merchant.setType(determineType(merchant.getProductCategory()));
merchant.setRegion(determineRegion(merchant.getPhone()));
merchantRepository.save(merchant);
}
private String determineType(String productCategory) {
// 根据商品种类确定商家类型的逻辑
if ("电子产品".equals(productCategory)) {
return "电子类";
} else if ("食品".equals(productCategory)) {
return "食品类";
} else {
return "其他类";
}
}
private String determineRegion(String phone) {
// 根据手机号划定地域的逻辑,假设通过手机号前几位确定地域
if (phone.startsWith("13") || phone.startsWith("14")) {
return "华北";
} else if (phone.startsWith("15") || phone.startsWith("16")) {
return "华东";
} else {
return "其他";
}
}
}
我们发现了什么特点,那就是我们从Merchant类看过去,只能看到他的基础属性,而关于这个商家的业务属性,我们却一点都看不出来,然而大面积的代码沉积在service层中,一个service不仅仅是只操作一个pojo,然而关于这个pojo的所有操作竟然全部集成在service中,这是否违背了软件设计的高内聚的特性?接下来我们看看由领域驱动设计DP模型修改后的代码架构应该是怎样的。
public class Merchant {
private Long id;
private String name;
private String phone;
private String productCategory;
private String type;
private String region;
public Merchant(String name, String phone, String productCategory) {
this.name = name;
this.phone = phone;
this.productCategory = productCategory;
this.type = determineType();
this.region = determineRegion();
}
private String determineType() {
// 根据商品种类确定商家类型的逻辑
if ("电子产品".equals(productCategory)) {
return "电子类";
} else if ("食品".equals(productCategory)) {
return "食品类";
} else {
return "其他类";
}
}
private String determineRegion() {
// 根据手机号划定地域的逻辑,假设通过手机号前几位确定地域
if (phone.startsWith("13") || phone.startsWith("14")) {
return "华北";
} else if (phone.startsWith("15") || phone.startsWith("16")) {
return "华东";
} else {
return "其他";
}
}
// getters and setters
}
首先我们很明显的感觉出来,这个Merchant类比起前面传统模型代码量肿胀了很多,并且我们通过构造方法,可以直接构造出一个合适的Merchant对象出来,最直观的就是把service类的一些代码聚集到了我们的domain层中,这便是高内聚的体现。这些代码本身就是描述我们Merchant属性的特点的,难道不应该由这个domain自己来处理这些逻辑吗?
但是,以上给出的例子,还不是很“纯血”的DP。
跟着我的思路,我们现在来看这个注册商家的需求,假设需要以下一个接口
注册(商家名称,手机号),那么自然的我们会想到这样设计接口
public void register(String name,String phoneNumber);
在Java代码中,对于一个方法来说,所有的参数名在编译后消失,那么我们的代码在编译后其实是这样的
public void register(String,String)
那么假设我们在service层这样调用接口,是否会出错呢?
merchant.register("17720778576","AAA潮鞋直营店");
答案是不会的,虽然接口定义是name和phoneNumber,但是这两个参数都是String,我们传递手机号和商铺名字进去,自然是正确的,为什么会报错呢?或许现在还有些读者会看不明白,这样写有什么问题。问题就是我们定义的接口是先传入name再传入phoneNumber。而样例中我先传入了phoneNumber。
问题是这样在我们的代码编译过程中是不会出问题的,这个bug就会留到测试中显示出为业务问题,一段良好的代码应该具有更少的测试风险,测试诚然是必不可少的工作,但是倘若代码本身就具有自我检查机制,那么测试的工作量是否会减少,并且对于排查错误以及代码健壮性来说,都有极佳的意义。
那么我们要怎样改进这个方法。
首先就是把隐形的概念显性化,我们把phoneNumber和name中隐藏的业务逻辑显性化。创建PhoneNumber类和Name类。
public class Name {
private final String name;
public Name(String name) {
if (name == null || name.isEmpty()) {
throw new ValidationException("姓名不能为空");
}
this.name = name;
}
public String getName() {
return name;
}
}
import java.util.Arrays;
import java.util.regex.Pattern;
public class PhoneNumber {
private final String number;
public PhoneNumber(String number) {
if (number == null) {
throw new ValidationException("number不能为空");
} else if (!isValid(number)) {
throw new ValidationException("number格式错误");
}
this.number = number;
}
public String getNumber() {
return number;
}
public String getAreaCode() {
for (int i = 0; i < number.length(); i++) {
String prefix = number.substring(0, i + 1);
if (isAreaCode(prefix)) {
return prefix;
}
}
return null;
}
private static boolean isAreaCode(String prefix) {
String[] areas = new String[]{"0571", "021", "010"};
return Arrays.asList(areas).contains(prefix);
}
public static boolean isValid(String number) {
String pattern = "^0?[1-9]{2,3}-?\d{8}$";
return Pattern.matches(pattern, number);
}
}
领域对象改为
public class Merchant {
private Name name;
private PhoneNumber phoneNumber;
private String type;
private String region;
public Merchant(Name name, PhoneNumber phoneNumber) {
this.name = name;
this.phoneNumber = phoneNumber;
this.type = determineType();
this.region = determineRegion();
}
private String determineType() {
// 假设我们可以通过名字或其他方式来确定类型
if (name.getName().contains("潮鞋")) {
return "鞋类";
} else {
return "其他";
}
}
private String determineRegion() {
return phoneNumber.getAreaCode();
}
public Name getName() {
return name;
}
public PhoneNumber getPhoneNumber() {
return phoneNumber;
}
public String getType() {
return type;
}
public String getRegion() {
return region;
}
}
那么我们在真正调用这个register函数的时候,就需要改成如下调用。
register(new Name("AAA潮鞋专卖"),new PhoneNumber("17720778576"));
这样即便我们是跨层调用,业务属性也非常明显的显示出来,也再也不会出现,参数传递顺序错误的问题。假设出现
register(new PhoneNumber("17720778576"),new Name("AAA潮鞋专卖"));
那么编译器就会报错,无该函数的定义。这就把测试的工作提前到开发阶段完成,提高了代码的健壮性。
那么到这里,到底什么是DP,直白的说,就是这边我们创建的PhoneNumber和Name类,而这个Merchant便是由DP组成的domain service。也就是领域层模型
应用架构
何为架构,这边引用一位阿里技术大佬的描述
架构这个词源于英文里的“Architecture“,源头是土木工程里的“建筑”和“结构”,而架构里的”架“同时又包含了”架子“(scaffolding)的含义,意指能快速搭建起来的固定结构。而今天的应用架构,意指软件系统中固定不变的代码结构、设计模式、规范和组件间的通信方式。在应用开发中架构之所以是最重要的第一步,因为一个好的架构能让系统安全、稳定、快速迭代。在一个团队内通过规定一个固定的架构设计,可以让团队内能力参差不齐的同学们都能有一个统一的开发规范,降低沟通成本,提升效率和代码质量。
在做架构设计时,一个好的架构应该需要实现以下几个目标:
- 独立于框架: 架构不应该依赖某个外部的库或框架,不应该被框架的结构所束缚。
- 独立于UI: 前台展示的样式可能会随时发生变化(今天可能是网页、明天可能变成console、后天是独立app),但是底层架构不应该随之而变化。
- 独立于底层数据源: 无论今天你用MySQL、Oracle还是MongoDB、CouchDB,甚至使用文件系统,软件架构不应该因为不同的底层数据储存方式而产生巨大改变。
- 独立于外部依赖: 无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化。
- 可测试: 无论外部依赖了什么数据库、硬件、UI或者服务,业务的逻辑应该都能够快速被验证正确性。
这就好像是建筑中的楼宇,一个好的楼宇,无论内部承载了什么人、有什么样的活动、还是外部有什么风雨,一栋楼都应该屹立不倒,而且可以确保它不会倒。但是今天我们在做业务研发时,更多的会去关注一些宏观的架构,比如SOA架构、微服务架构,而忽略了应用内部的架构设计,很容易导致代码逻辑混乱,很难维护,容易产生bug而且很难发现。今天,我希望能够通过案例的分析和重构,来推演出一套高质量的DDD架构。
了解了DP和充血模型的概念,那么一个DDD的应用架构如何落地,又或者说。我们为什么需要DDD架构思想,一个MVC系统如何向DDD架构转变呢?
我们继续引出一个具体的业务例子,非常简单,那就是实现一个跨币种的转账功能。
这个转账功能涉及到的业务点就是实现转入转出业务,并且涉及到跨币种还需要实时的去查看汇率,并且给出相应计算。这么简单的业务经过初步的技术选型后,可能会拆解成以下需求步骤
跨币种转账样例
1、 从MySql数据库中找到转出和转入的账户,选择用 MyBatis 的 mapper 实现 DAO;
2、 从一个RPC服务提供的汇率服务获取转账的汇率信息,假设是google提供
3、 计算需要转出的金额,确保账户有足够余额。
4、 实现转入和转出操作,扣除手续费,保存数据库;
5、 发送 Kafka 审计消息,以便审计和对账用;
代码实现如下
@Service
public class AccountService {
private final AccountMapper accountMapper;
private final ExchangeRateService exchangeRateService;
private final AuditMessageProducer auditMessageProducer;
public AccountService(AccountMapper accountMapper, ExchangeRateService exchangeRateService, AuditMessageProducer auditMessageProducer) {
this.accountMapper = accountMapper;
this.exchangeRateService = exchangeRateService;
this.auditMessageProducer = auditMessageProducer;
}
@Transactional
public void transfer(Long fromAccountId, Long toAccountId, Double amount, String fromCurrency, String toCurrency) {
validateParameters(fromAccountId, toAccountId, amount, fromCurrency, toCurrency);
// 1. 从数据库中找到转出和转入的账户
Account fromAccount = accountMapper.findAccountById(fromAccountId);
Account toAccount = accountMapper.findAccountById(toAccountId);
// 2. 从RPC服务获取汇率信息
Double exchangeRate = googleExchangeRateService.getExchangeRate(fromCurrency, toCurrency);
// 3. 计算需要转出的金额,确保账户有足够余额
Double amountInFromCurrency = amount / exchangeRate;
if (fromAccount.getBalance() < amountInFromCurrency) {
throw new InsufficientBalanceException("账户余额不足");
}
// 4. 实现转入和转出操作,扣除手续费,保存数据库
Double fee = calculateFee(amountInFromCurrency);
fromAccount.setBalance(fromAccount.getBalance() - amountInFromCurrency - fee);
toAccount.setBalance(toAccount.getBalance() + amount);
accountMapper.updateAccount(fromAccount);
accountMapper.updateAccount(toAccount);
// 5. 发送Kafka审计消息,以便审计和对账用
auditMessageProducer.sendAuditMessage(createAuditMessage(fromAccount, toAccount, amount, fromCurrency, toCurrency, fee));
}
private void validateParameters(Long fromAccountId, Long toAccountId, Double amount, String fromCurrency, String toCurrency) {
if (fromAccountId == null || toAccountId == null) {
throw new IllegalArgumentException("账户ID不能为空");
}
if (amount == null || amount <= 0) {
throw new IllegalArgumentException("转账金额必须大于0");
}
if (!StringUtils.hasText(fromCurrency) || !StringUtils.hasText(toCurrency)) {
throw new IllegalArgumentException("货币类型不能为空");
}
}
private Double calculateFee(Double amount) {
// 假设手续费为转出金额的1%
return amount * 0.01;
}
private AuditMessage createAuditMessage(Account fromAccount, Account toAccount, Double amount, String fromCurrency, String toCurrency, Double fee) {
AuditMessage message = new AuditMessage();
message.setFromAccountId(fromAccount.getId());
message.setToAccountId(toAccount.getId());
message.setAmount(amount);
message.setFromCurrency(fromCurrency);
message.setToCurrency(toCurrency);
message.setFee(fee);
message.setTimestamp(System.currentTimeMillis());
return message;
}
}
以上这么一大段代码,全部聚合在service层,大家可以反思一下,在我们开发中是不是绝大多数的代码都是这样编写的。这就好似一道庞大的模拟题,我们只需要在service层描述出这个需求的过程。这种很常见的代码样式被叫做Transaction Script(事务脚本)。虽然这种类似于脚本的写法在功能上没有什么问题,但是长久来看,他有以下几个很大的问题:可维护性差、可扩展性差、可测试性差。那么我们接下来开始包丁解牛,这个service代码里面到底做了哪些事情?
MVC架构下的Servive职责
- 参数校验
private void validateParameters(Long fromAccountId, Long toAccountId, Double amount, String fromCurrency, String toCurrency) {
if (fromAccountId == null || toAccountId == null) {
throw new IllegalArgumentException("账户ID不能为空");
}
if (amount == null || amount <= 0) {
throw new IllegalArgumentException("转账金额必须大于0");
}
if (!StringUtils.hasText(fromCurrency) || !StringUtils.hasText(toCurrency)) {
throw new IllegalArgumentException("货币类型不能为空");
}
}
- 数据存储
Double fee = calculateFee(amountInFromCurrency);
fromAccount.setBalance(fromAccount.getBalance() - amountInFromCurrency - fee);
toAccount.setBalance(toAccount.getBalance() + amount);
accountMapper.updateAccount(fromAccount);
accountMapper.updateAccount(toAccount);
- 调用外部服务
Double exchangeRate = exchangeRateService.getExchangeRate(fromCurrency, toCurrency);
- 业务计算
private Double calculateFee(Double amount) {
// 假设手续费为转出金额的1%
return amount * 0.01;
}
- 发送消息
auditMessageProducer.sendAuditMessage(createAuditMessage(fromAccount, toAccount, amount, fromCurrency, toCurrency, fee));
遇到的问题
我们来列出这样的代码架构的问题
-
可维护性差
一个应用最大的成本一般都不是来自于开发阶段,而是应用整个生命周期的总维护成本,所以代码的可维护性代表了最终成本。
可维护性 = 当依赖变化时,有多少代码需要随之改变。
首先我们依赖于Account类,它是纯映射于数据库表的,数据库字段的变更是及其常见的,因此这个类要跟着数据库字段的改变而改变,他是及其不稳定的。并且我们显示的依赖了kafka,外部rpc服务,mybatis等。倘若有一天jar包依赖升级,或者我们不使用kafka改为rocketmq,又或者mybatis出现重大漏洞,需要迁移ORM层?那便是灾难性的代码重构体验,但是这些组件本是为业务服务的,而如今业务没有变化,只是因为组件本身的问题,我们便要对业务层代码做这么深的重构,这就涉及到一个问题。
代码的腐败,特别是一个大型项目中,对于外部组件高度耦合的代码变得难以维护,假设我们发现Mybatis出了一个新的版本,性能提升百分之五十,但是调用方式API有了很大的变化,采用这种架构的代码由于代码耦合,想要更换新版本需要耗费的精力和危险性大大增加,于是即使我们深刻的明白,更换新版本是可以直接提升系统性能,但是碍于种种,只能推迟这个计划。
-
可拓展性差
我们的业务逻辑这么长,但是却只能使用在register这个方法中,业务无法复用,且业务逻辑和数据库格式高度耦合,有新的需求进来,我们又只能新建一个方法,再次用脚本描述的方法来完成这新的一题“模拟题 ”,久而久之,代码变得不可维护,代码量和架构也变得混乱模糊不清。
-
可测试性差
参考以上的一段代码,这种代码有极低的可测试性:
- 设施搭建困难:当代码中强依赖了数据库、第三方服务、中间件等外部依赖之后,想要完整跑通一个测试用例需要确保所有依赖都能跑起来,这个在项目早期是及其困难的。在项目后期也会由于各种系统的不稳定性而导致测试无法通过。
- 运行耗时长:大多数的外部依赖调用都是I/O密集型,如跨网络调用、磁盘调用等,而这种I/O调用在测试时需要耗时很久。另一个经常依赖的是笨重的框架如Spring,启动Spring容器通常需要很久。当一个测试用例需要花超过10秒钟才能跑通时,绝大部分开发都不会很频繁的测试。
- 耦合度高:假如一段脚本中有A、B、C三个子步骤,而每个步骤有N个可能的状态,当多个子步骤耦合度高时,为了完整覆盖所有用例,最多需要有N * N * N个测试用例。当耦合的子步骤越多时,需要的测试用例呈指数级增长。
我们回到软件工程最基本的设计三大理念
- 单一性原则:单一性原则要求一个对象/类应该只有一个变更的原因。但是在以上的service代码中,无论是mybatis的升级,kafka组件的替换,外部服务的替换,业务的变更,都需要我们去修改这段代码。
- 依赖反转原则:依赖反转原则要求在代码中依赖抽象,而不是具体的实现。但是这段代码中,我们对于持久层,消息队列层,服务层都依赖于具体的实现
- 开闭原则:开放封闭原则指开放扩展,但是封闭修改。在这个案例中手续费的计算是一个固定的方法,当我们需要更改手续费的时候,就要直接修改这个方法,而不是做扩展。
可以看到,一个转账的简单例子,看似合理的设计,却违背了软件工程设计的三大原则。
如何重构
我们需要对代码重构才能解决这些问题。在这之前,我们先画出以上有问题的代码的依赖图
我们发现在业务层对下层基建层有很明显的强依赖,耦合度很高,所以需要对上图节点做抽象处理,来降低对外部依赖的耦合度。
抽象数据存储层
这一层的操作其实是比较简单的,首先我们来看到第一个问题,我们Service中操作的对象与数据库强关联,且这个对象本身只是对数据库的简单映射,自身不带有业务属性。那么要解决这个问题,很自然就联想到我们上文提到的DP和Domain Service的概念。首先我们应该保留和数据库直接映射的类,命名为AccoutDo,Do类与数据库直接映射,同时我们又需要有DomainService类,也就是我们在Service层中直接使用的Accout类,这个Accout和数据库有一定关系,但是他并不是直接依赖于数据库的,他不但有数据库字段的属性,他还能有自身的业务行为。简单代码示范如下。
@Data
public class Account {
//可以看到,这边的属性我们使用了DP做处理,这样也顺便解决了参数校验的问题
private AccountId id;
private AccountNumber accountNumber;
private UserId userId;
private Money available;
private Money dailyLimit;
//这里带有了这个domain自身的一些高内聚的方法函数
public void withdraw(Money money) {
// 转出
}
public void deposit(Money money) {
// 转入
}
}
@Data
public class AccountDo {
//直接与数据库一一映射
private Long id;
private String accountNumber;
private Long userId;
private Double available;
private Double dailyLimit;
}
在这边我们还需要一个DAO,这个DAO很好理解,就是对应数据库类型的一个真实操作,比如Mysql的insert,update,然后我们还需要一个Repository。
这时候这个Repository需要做两件事
- 提供数据库操作的通用抽象接口(例如save(我们无需去关注底层是什么数据库,是用insert还是update))
- 进行Account到AccountDo的映射
代码样例
public interface AccountRepository {
Account find(AccountId id);
Account find(AccountNumber accountNumber);
Account find(UserId userId);
Account save(Account account);
}
public class AccountRepositoryImpl implements AccountRepository {
@Autowired
private AccountMapper accountDAO;
@Autowired
private AccountBuilder accountBuilder;
@Override
public Account find(AccountId id) {
AccountDO accountDO = accountDAO.selectById(id.getValue());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account find(AccountNumber accountNumber) {
AccountDO accountDO = accountDAO.selectByAccountNumber(accountNumber.getValue());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account find(UserId userId) {
AccountDO accountDO = accountDAO.selectByUserId(userId.getId());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account save(Account account) {
AccountDO accountDO = accountBuilder.fromAccount(account);
if (accountDO.getId() == null) {
accountDAO.insert(accountDO);
} else {
accountDAO.update(accountDO);
}
return accountBuilder.toAccount(accountDO);
}
}
经过这样的架构转变,我们看到计算更新等业务操作也是直接依赖于Accout这个领域对象,然后领域对象依赖于Repository做映射,最下层才依赖具体的Mapper实现,就解决了业务层依赖于具体实现的问题。当我们需要更换数据库或者ORM的时候,Service层代码完全不需要改变,只需要去改变相应的Mapper层实现即可。这便是单一职责原则。
反腐层
在这里我想先引出一个概念。Anti-Corruption Layer(防腐层或ACL)。
很多时候我们的系统会去依赖其他的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被”腐蚀“。这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。
目的就是让我们的Service依赖于抽象而不是依赖于具体实现,真正的转换是通过反腐层去做的,而我们调用的永远是一个固定的抽象的业务接口,具体的适配器转换模式,下沉到防腐层中去实现。
借助反腐层抽象第三方服务
我们这里有一个第三方的服务, googleExchangeRateService,他是依赖于google的。我们假设google服务返回的是这个json
{
"source": "USD",
"rates": {
"EUR": 0.8534,
"JPY": 110.32,
"GBP": 0.7542,
"CNY": 6.4567
},
"timestamp": 1625123456
}
但是将来某一天google服务不可用,或者收费变贵了,我们想改用雅虎服务,返回的json格式是这样的
{
"base": "USD",
"date": "2024-07-01",
"rates": {
"EUR": 0.8456,
"JPY": 111.20,
"GBP": 0.7498,
"CNY": 6.5102
}
}
因为我们在Service层强依赖google服务,所以需要对业务代码再次进行修改。
引入ACL层后,我们可以抽象出一个统一的json格式
{
"source": "USD",
"date": "2024-07-01",
"rates": {
"EUR": {
"rate": 0.8534,
},
"JPY": {
"rate": 110.32,
},
"GBP": {
"rate": 0.7542,
},
"CNY": {
"rate": 6.4567,
}
}
}
做一个适配器模式的转换,无论我们从什么数据源获取到汇率数据,我们都使用这个ACL层的适配器转换为我们需要的数据格式,所以在我们的Service业务层就可以依赖于抽象,也就是ExchangeRateService,无论这个Service底层依赖于哪个第三方的服务,返回的数据格式经过防腐层都是永远固定不变的。
同样的对于MQ,我们也可以抽象中间件,无论是RocketMQ,kafka,我们都可以抽象为MessageQueue。并且使用ACL层做一次适配。适配多种MQ实现,这样我们的业务层只依赖于抽象的mq,而不是依赖于具体的mq实现。
封装业务逻辑
这一步就是把部分业务逻辑再内聚到我们的domain里面,比如汇率计算。
// 从RPC服务获取汇率信息
Double exchangeRate = googleExchangeRateService.getExchangeRate(fromCurrency, toCurrency);
// 计算需要转出的金额,确保账户有足够余额
Double amountInFromCurrency = amount / exchangeRate;
if (fromAccount.getBalance() < amountInFromCurrency) {
throw new InsufficientBalanceException("账户余额不足");
}
这些代码就可以内聚到domain之中,封装为getFromCurrency()方法
最后我们业务层代码就可以只写成
Double amountInFromCurrency = getFromCurrency(amount);
结果展示
经过以上种种重构,最后我们的业务代码会变得无比干净
public class TransferServiceImplNew implements TransferService {
private AccountRepository accountRepository;
private AuditMessageProducer auditMessageProducer;
private ExchangeRateService exchangeRateService;
private AccountTransferService accountTransferService;
@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 参数校验
Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));
// 读数据
Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
// 业务逻辑
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);
// 保存数据
accountRepository.save(sourceAccount);
accountRepository.save(targetAccount);
// 发送审计消息
AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
auditMessageProducer.send(message);
return Result.success(true);
}
}
对比重构前的代码,你是否能体会到DDD架构思想带来的改变?
可以看出来,经过重构后的代码有以下几个特征:
- 业务逻辑清晰,数据存储和业务逻辑完全分隔。
- Entity、Domain Primitive、Domain Service都是独立的对象,没有任何外部依赖,但是却包含了所有核心业务逻辑,可以单独完整测试。
- 原有的TransferService不再包括任何计算逻辑,仅仅作为组件编排,所有逻辑均delegate到其他组件。这种仅包含Orchestration(编排)的服务叫做Application Service(应用服务)。
项目分包样例
那么真正落地一个DDD项目,我们该如何分模块分包呢。由于业务的复杂性,以及每个公司落地DDD风格的不同,我不能给出一个绝对的答案,以下仅仅是抛砖引玉,给出一个比较基础简单的分包。
Types模块
类比于Integer,String,Double这些type,我们的DP也是一个个包含业务校验逻辑的Type,因此Types模块中我们存放DP
Domain模块
Domain 模块是核心业务逻辑的集中地,包含有状态的Entity、领域服务Domain Service、以及各种外部依赖的接口类(如Repository、ACL、中间件等。Domain模块仅依赖Types模块,也是纯 POJO 。
Application模块
这个模块主要依赖于Domain模块,他就是我们MVC结构中的service模块,用于组装拓展Domain中的业务逻辑,形成真正具体的业务需求函数
Infrastructure模块
基建模块,依赖于具体的基建实现,包括DB,MQ,ORM层的依赖。
Web模块
包含Controller等相关代码,视图层,把REST接口暴露
章节总结
DDD架构不是一个具体的特殊的架构模式,他的理念是所有传统代码经过合理的重构要达到的终点,在当下爆炸的微服务时代,业务呈井喷发展,我们推崇敏捷开发的同时,更要注重代码的健壮性,以及可维护性。DDD架构能够有效的解决传统架构中的问题。
- 高可维护性:当外部依赖变更时,内部代码只用变更跟外部对接的模块,其他业务逻辑不变。
- 高可扩展性:做新功能时,绝大部分的代码都能复用,仅需要增加核心业务逻辑即可。
- 高可测试性:每个拆分出来的模块都符合单一性原则,绝大部分不依赖框架,可以快速的单元测试,做到100%覆盖。
理论补充
领域(Domain) :
- 领域是指业务问题的特定部分或范围,通常涉及特定的业务概念和规则集合。一个领域可能包括多个相关的子领域。
子领域(Subdomain) :
- 子领域是领域中更小的部分,通常围绕一个特定的业务子集展开,有时可以作为独立的领域来管理。
限界上下文(Bounded Context) :
- 限界上下文是在DDD中用来定义领域模型边界的概念,它定义了领域模型在特定上下文中的含义和使用规则。一个很形象的隐喻:细胞质所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。
- 一个大型的领域可能会被分成多个限界上下文,每个上下文都有自己的领域模型和业务语言,它们通过显式的接口和映射来进行交互。
在一个电商系统中订单管理是一个领域,包括订单创建、支付、配送等业务。商品管理可以是一个子领域,涵盖商品发布、库存管理、价格策略等。在订单管理领域中,可能有一个用于订单创建和支付的限界上下文,另一个用于订单配送和物流管理的限界上下文。
实体
当一个对象由其标识(而不是属性)区分时,这种对象称为实体(Entity)。
例:最简单的,公安系统的身份信息录入,对于人的模拟,即认为是实体,因为每个人是独一无二的,且其具有唯一标识(如公安系统分发的身份证号码)。
值对象
值对象
当一个对象用于对事务进行描述而没有唯一标识时,它被称作值对象(Value Object)。
例:比如颜色信息,我们只需要知道{“name”:“黑色”,”css”:“#000000”}这样的值信息就能够满足要求了,这避免了我们对标识追踪带来的系统复杂性。它具有不变性、相等性和可替换性。
聚合
在领域驱动设计(DDD)中,聚合是一个核心概念,它帮助开发者管理复杂性,特别是在处理大量相关对象时。
聚合是由紧密关联的实体和值对象组成,是修改和保存数据的基本单位。每个聚合都有一个仓库,用于保存聚合的数据。
聚合有一个聚合根和上下文边界,边界根据业务需求和内聚原则,定义了聚合应该包含哪些实体和值对象,而聚合之间是松耦合的,这样设计的微服务,会很自然地实现高内聚、低耦合。
聚合在 DDD 分层架构中是领域层的一部分,领域层可以包含多个聚合,共同实现核心业务逻辑。实体在聚合内以充血模型实现业务能力,保证业务逻辑的高内聚。
跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现。
聚合根
Aggregate(聚合)是一组相关对象的集合,作为一个整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。
聚合是一个非常重要的概念,核心领域往往都需要用聚合来表达。其次,聚合在技术上有非常高的价值,可以指导详细设计。
聚合由根实体,值对象和实体组成。聚合根可以理解为是一系列聚合的管理者,他具有全局唯一id。聚合之间协作的时候,聚合根是对外的接口人,通过自己的ID关联其他聚合。外部对象不能直接访问聚合内实体。
领域服务
一些重要的领域行为或操作,可以归类为领域服务。它既不是实体,也不是值对象的范畴。可以理解为domain service中的一些内聚操作
如何设计聚合根?
1.选择合适的聚合根
聚合根应该是一个聚合中最重要的实体,其他实体和值对象通过聚合根关联。我们要考虑在这个业务中,哪个实体在业务中扮演核心角色。可以有效的管理封装聚合的行为。
2.聚合最小化原则
聚合应该尽可能的小,只包含必须由聚合根直接管理的实体和值对象。提高内聚性。
3.封装业务规则
聚合根应该封装聚合内部的业务规则,任何对聚合内部数据的修改,都应该通过聚合根的方法进行访问,这样聚合根才能保证聚合的状态的一致性。
4.一致性边界定义
聚合的状态应该是最小的一致性边界,保持自身聚合的一致性,事务应该在单个聚合范围内实现,不能跨聚合操作。
聚合根设计样例
我们以电商平台的订单系统为例,看看聚合和聚合根是如何设计和使用的。
在订单系统中,订单通常作为聚合根,因为订单是整个业务流程中最核心的业务对象。
订单聚合包括多个实体对象,如订单明细、支付信息、收货信息等。聚合中包含了实体也包含了值对象(DP)。
订单明细:聚合内的实体,每个订单明细代表订单中的一个购买项。它包括商品ID、购买数量、单价和小计。订单明细与订单紧密关联,其生命周期由订单聚合根管理。
支付信息:也是聚合内的实体,支付信息记录了支付方式(如信用卡、支付宝、微信支付)、支付状态、支付金额和支付时间等,这些信息对于完成交易和进行财务核算至关重要。
收货信息:通常是一个值对象,包含省、城市、街道和邮编等信息。因为它没有独立的标识,仅仅描述了一个地理位置。
通过设计正确的聚合,订单系统的业务操作会非常清晰,并能够集中管理。
下面列举一些常见的业务操作,介绍聚合是如何被使用的。
订单创建操作
当客户选完商品,并提交订单时,系统会触发订单创建的流程。
系统首先创建一个新的订单聚合实例,此实例以订单为聚合根。订单聚合根包含了必要的信息,如订单编号、订单初始状态等。
客户选定的每个商品都会作为订单明细,添加到订单中。每个订单明细实体包括商品ID、购买数量和单价等信息。这些订单明细在创建过程中由订单聚合根动态管理,确保数据的完整性。
客户提供的收货地址被创建为值对象,并与订单聚合关联。同时,初始化的支付信息也会被设置为一个实体,包括支付方式和支付状态等信息。
这边我想强调一下,值对象是不可变的。当订单创建完毕后,这个商品的详细信息或者价格发生了更改,不应该影响到这条订单里面的数据,也就是可以理解为,订单创建的瞬间,里面的值对象应该是快照。
支付处理
当客户进行支付时,支付信息实体会被更新,包括记录支付金额、支付方式、支付时间等。订单聚合根确保支付信息与订单的状态保持一致性。
支付成功后,订单聚合根会将订单状态更新为“已支付”,这是通过聚合内部的业务逻辑完成,确保所有数据一致。
发货处理
在订单准备发货时,订单聚合根会验证存储的收货地址信息的完整性和准确性。如果地址不完整,可能会要求客户提供更多信息,或进行二次确认。
一旦发货地址验证无误,且商品准备就绪,订单聚合根将订单状态更新为“已发货”。随后,实际物流操作开始进行,并在系统中记录和跟踪发货的过程信息。
代码演示
聚合根的定义
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class Order {
private String orderId;
private List<OrderItem> orderItems;
private PaymentInformation paymentInformation;
private ShippingAddress shippingAddress;
private OrderStatus status;
public Order(OrderRequest request) {
this.orderId = UUID.randomUUID().toString();
this.orderItems = request.getItems();
this.paymentInformation = request.getPaymentInformation();
this.shippingAddress = request.getShippingAddress();
this.status = OrderStatus.CREATED;
}
// 添加订单明细
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
}
// 获取订单总金额
public double getTotalAmount() {
double totalAmount = 0.0;
for (OrderItem item : orderItems) {
totalAmount += item.getTotalPrice();
}
return totalAmount;
}
// 支付订单
public void processPayment(String paymentMethod, double amount) {
if (!paymentInformation.isPaid()) {
paymentInformation = new PaymentInformation(paymentMethod, amount);
paymentInformation.markAsPaid(LocalDateTime.now());
status = OrderStatus.PAID; // 更新订单状态为已支付
}
}
// 设置发货地址
public void setShippingAddress(ShippingAddress shippingAddress) {
this.shippingAddress = shippingAddress;
}
// 获取订单状态
public OrderStatus getStatus() {
return status;
}
// 枚举类型定义订单状态
public enum OrderStatus {
CREATED,
PAID,
SHIPPED,
DELIVERED,
CANCELLED
}
}
实体以及值对象定义
public class OrderItem {
private String productId;
private int quantity;
private double unitPrice;
// Constructors, getters, setters
public OrderItem(String productId, int quantity, double unitPrice) {
this.productId = productId;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
// Getters
public String getProductId() {
return productId;
}
public int getQuantity() {
return quantity;
}
public double getUnitPrice() {
return unitPrice;
}
public double getTotalPrice() {
return quantity * unitPrice;
}
}
public class PaymentInformation {
private String paymentMethod;
private double amount;
private boolean paid;
private LocalDateTime paymentTime;
// Constructors, getters, setters
public PaymentInformation(String paymentMethod, double amount) {
this.paymentMethod = paymentMethod;
this.amount = amount;
this.paid = false; // 默认未支付
}
public void markAsPaid(LocalDateTime paymentTime) {
this.paid = true;
this.paymentTime = paymentTime;
}
// Getters
public String getPaymentMethod() {
return paymentMethod;
}
public double getAmount() {
return amount;
}
public boolean isPaid() {
return paid;
}
public LocalDateTime getPaymentTime() {
return paymentTime;
}
}
public class ShippingAddress {
private String province;
private String city;
private String street;
private String postalCode;
// Constructors, getters, setters
public ShippingAddress(String province, String city, String street, String postalCode) {
this.province = province;
this.city = city;
this.street = street;
this.postalCode = postalCode;
}
// Getters
public String getProvince() {
return province;
}
public String getCity() {
return city;
}
public String getStreet() {
return street;
}
public String getPostalCode() {
return postalCode;
}
}
订单请求对象Request
import java.util.List;
public class OrderRequest {
private List<OrderItem> items;
private PaymentInformation paymentInformation;
private ShippingAddress shippingAddress;
// Getters and setters
public List<OrderItem> getItems() {
return items;
}
public void setItems(List<OrderItem> items) {
this.items = items;
}
public PaymentInformation getPaymentInformation() {
return paymentInformation;
}
public void setPaymentInformation(PaymentInformation paymentInformation) {
this.paymentInformation = paymentInformation;
}
public ShippingAddress getShippingAddress() {
return shippingAddress;
}
public void setShippingAddress(ShippingAddress shippingAddress) {
this.shippingAddress = shippingAddress;
}
}
Controller层演示
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/orders")
public class OrderController {
@PostMapping("/create")
public Order createOrder(@RequestBody OrderRequest request) {
// 创建订单
Order order = new Order(request);
// 模拟支付过程
order.processPayment(request.getPaymentInformation().getPaymentMethod(), request.getPaymentInformation().getAmount());
// 返回创建的订单
return order;
}
@PostMapping("/{orderId}/pay")
public Order payOrder(@PathVariable String orderId, @RequestParam String paymentMethod, @RequestParam double amount) {
// 此处应该从数据库或缓存中获取订单信息,这里简化为示例
Order order = retrieveOrderFromDatabase(orderId);
// 处理支付
order.processPayment(paymentMethod, amount);
// 更新订单状态等操作
// 返回更新后的订单
return order;
}
@PutMapping("/{orderId}/shipping-address")
public Order updateShippingAddress(@PathVariable String orderId, @RequestBody ShippingAddress shippingAddress) {
// 此处应该从数据库或缓存中获取订单信息,这里简化为示例
Order order = retrieveOrderFromDatabase(orderId);
// 更新发货地址
order.setShippingAddress(shippingAddress);
// 返回更新后的订单
return order;
}
}
可以看到,我们的的service层变得很薄的。而且我们的整个订单聚合内聚性极强,支付信息,收货信息等聚合也全部都是通过聚合根Order来控制,整个订单聚合保持了一致性。
总结
领域驱动设计(DDD)是软件开发的一场革命,它将我们从技术细节的泥潭中解放出来,让我们重新聚焦于业务本身。DDD不是一套僵化的规则,而是一种思想,一种将业务领域知识置于软件开发核心的哲学。
通过DDD,我们构建的不再是简单的代码,而是一个个鲜活的业务模型。这些模型,如实体、值对象、聚合,它们携带着业务的DNA,它们的行为和属性定义了业务的逻辑和流程。这种以业务为中心的建模方式,让软件系统更加贴近真实世界,更加易于理解和维护。
DDD提倡的分层架构,将业务逻辑与技术实现解耦,使得系统更加灵活,更易于适应变化。它鼓励我们在不同的层次上使用最适合的技术,而不必为了技术而牺牲业务的清晰性和可维护性。
限界上下文的概念,更是DDD中的点睛之笔。它教会我们如何在庞大的业务领域中划定边界,定义清晰的业务规则和模型,同时通过上下文映射来实现不同业务领域间的协调和交互。
而充血模型的应用,则是对传统贫血模型的一种颠覆。它将业务逻辑重新赋予领域对象,让对象本身具备行为和决策能力,从而提升了代码的内聚性和可读性。
在微服务架构大行其道的今天,DDD的原则和模式更显价值。它指导我们在服务拆分时保持业务的完整性,避免业务逻辑的碎片化,构建出既灵活又健壮的微服务系统。
DDD是一种精神,一种追求业务与技术和谐统一的精神。它不是终点,而是起点,引导我们在软件开发的道路上不断探索和前行。拥抱DDD,就是拥抱一种更加人性化、更加富有创造力的软件开发方式。