0%

什么是DDD:

领域驱动设计(Domain-Driven Design,简称DDD)是一种软件设计方法,旨在帮助开发者有效地构建复杂的软件系统,特别是那些与业务领域紧密相关的应用程序。DDD 强调通过深入理解业务领域的本质和规则来构建软件系统,以便软件能够更好地反映和支持业务需求。是以业务视角来规划系统架构,使系统架构更好的服务于业务的一套指导方法论。

DDD定义的相关概念:

边界上下文:

一个由显示边界限定的特定职责。领域模型便存在于这个边界之内。在边界内,每一个模型概念,包括它的属性和操作,都具有特殊的含义。 边界上下文是一组基于业务角度定义的一些通用相关语言,包含了一个完整的业务流程里。是为了使业务和技术具有统一沟通术语和限定业务边界而出现。也就是说确定了边界上下文也就是确定了业务边界。
img.png

限界上下文之间的映射关系

  • 合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。
  • 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。
  • 客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。
  • 遵奉者(Conformist):下游上下文只能盲目依赖上游上下文。
  • 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。
  • 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。
  • 发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。
  • 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。
  • 另谋他路(SeparateWay):两个完全没有任何联系的上下文。
    img_2.png
    img_1.png

    领域:

    领域就是业务知识,它包括业务规则、实体、值对象、聚合、库存、服务等,以及与这些概念相关的关系和交互。是具体指向一个特定的业务领域。
    img_3.png

    领域服务:

    领域服务是一个动作,是一个业务具体的实施过程。对领域对象进行转换或者以多个对象进行计算返回一个值对象。总的来说领域服务是处理业务逻辑。
    领域需要协调多个领域对象共同完成这个操作或动作。如果强行将这些操作职责分配给任何一个对象,则被分配的对象就是承担一些不该承担的职责,从而会导致对象的职责不明确很混乱。但是基于类的面向对象语言规定任何属性或行为都必须放在对象里面。所以我们需要寻找一种新的模式来表示这种跨多个对象的操作,DDD认为服务是一个很自然的范式用来对应这种跨多个对象的操作,所以就有了领域服务这个模式。和领域对象不同,领域服务是以动词开头来命名的,比如资金转帐服务可以命名为MoneyTransferService。当然,你也可以把服务理解为一个对象,但这和一般意义上的对象有些区别。因为一般的领域对象都是有状态和行为的,而领域服务没有状态只有行为。需要强调的是领域服务是无状态的,它存在的意义就是协调领域对象共完成某个操作,所有的状态还是都保存在相应的领域对象中.
    最直观的现象是当两个对象有牵连或者依赖的操作时候,且感觉别扭的时候,最好把这个操作放到领域层。

    实体(entity):

    实体是具有唯一标识的对象,其状态和行为与业务领域相关。实体通常具有生命周期,并且可以经历不同的状态变化。entity是基于领域逻辑的实体类,它的字段和数据库储存不需要有必然的联系。Entity包含数据,同时也应该包含行为,是充血模型。

    值对象:

    值没有唯一标识且不可变的对象,它具有不变性、相等性和可替换性。
    如地址相对于用户来说,单独就地址具有唯一性,但是放在人的从属性上是一旦确定是不会再有地址属性的变化的。在实践中,需要保证值对象创建后就不能被修改,即不允许外部再修改其属性。在不同上下文集成时,会出现模型概念的公用,如商品模型会存在于电商的各个上下文中。在订单上下文中如果你只关注下单时商品信息快照,那么将商品对象视为值对象是很好的选择。

    聚合根:

    聚合是一组相关对象的集合,其中一个对象被指定为聚合根,用于管理整个聚合。这有助于维护领域对象之间的一致性.
    比如相对于订单来说,有订单数据,订单关联的子单,订单关联的商品。 比如商品表和商品sku。
    聚合由根实体,值对象和实体组成。

    如何创建好的聚合?

  • 边界内的内容具有一致性:在一个事务中只修改一个聚合实例。如果你发现边界内很难接受强一致,不管是出于性能或产品需求的考虑,应该考虑剥离出独立的聚合,采用最终一致的方式。
  • 设计小聚合:大部分的聚合都可以只包含根实体,而无需包含其他实体。即使一定要包含,可以考虑将其创建为值对象。
  • 通过唯一标识来引用其他聚合或实体:当存在对象之间的关联时,建议引用其唯一标识而非引用其整体对象。如果是外部上下文中的实体,引用其唯一标识或将需要的属性构造值对象。 如果聚合创建复杂,推荐使用工厂方法来屏蔽内部复杂的创建逻辑。

聚合内部多个组成对象的关系可以用来指导数据库创建,但不可避免存在一定的抗阻。如聚合中存在List<值对象>,那么在数据库中建立1:N的关联需要将值对象单独建表,此时是有id的,建议不要将该id暴露到资源库外部,对外隐蔽。

领域事件:

领域事件由实战领域驱动设计一书提出,领域事件用于在领域内部和不同限界上下文之间传递信息。它们用于通知系统中发生的重要事件。
领域事件有助于降低系统内部各个领域对象之间的耦合度,因为它们提供了一种松散耦合的通信机制。这使得系统更容易维护、扩展和修改,因为领域对象之间的依赖性降低了。
领域事件还可用于构建系统的历史记录和审计功能。通过捕捉所有重要的领域变化事件,可以轻松地跟踪系统状态的演变,并在需要时进行审计。
领域事件常常与事件驱动架构(Event-Driven Architecture)结合使用。在这种架构中,系统中的各个组件通过发布和订阅事件的方式进行通信。当某个领域对象的状态发生变化时,它会发布一个相关的领域事件,而其他订阅了这个事件的组件会被通知并采取相应的行动。

阅读全文 »

应用架构
架构这个词源于英文里的“Architecture“,源头是土木工程里的“建筑”和“结构”,而架构里的”架“同时又包含了”架子“(scaffolding)的含义,意指能快速搭建起来的固定结构。而今天的应用架构,意指软件系统中固定不变的代码结构、设计模式、规范和组件间的通信方式。在应用开发中架构之所以是最重要的第一步,因为一个好的架构能让系统安全、稳定、快速迭代。在一个团队内通过规定一个固定的架构设计,可以让团队内能力参差不齐的同学们都能有一个统一的开发规范,降低沟通成本,提升效率和代码质量。

在做架构设计时,一个好的架构应该需要实现以下几个目标:

独立于框架:架构不应该依赖某个外部的库或框架,不应该被框架的结构所束缚。
独立于UI:前台展示的样式可能会随时发生变化(今天可能是网页、明天可能变成console、后天是独立app),但是底层架构不应该随之而变化。
独立于底层数据源:无论今天你用MySQL、Oracle还是MongoDB、CouchDB,甚至使用文件系统,软件架构不应该因为不同的底层数据储存方式而产生巨大改变。
独立于外部依赖:无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化。
可测试:无论外部依赖了什么数据库、硬件、UI或者服务,业务的逻辑应该都能够快速被验证正确性。
这就好像是建筑中的楼宇:一个好的楼宇,无论内部承载了什么人、有什么样的活动、还是外部有什么风雨,一栋楼都应该屹立不倒,而且可以确保它不会倒。但是今天我们在做业务研发时,更多的会去关注一些宏观的架构,比如SOA架构、微服务架构,而忽略了应用内部的架构设计,很容易导致代码逻辑混乱,很难维护,容易产生bug而且很难发现。今天,我希望能够通过案例的分析和重构,来推演出一套高质量的DDD架构。

1. 案例分析

我们先看一个简单的案例需求如下:

    用户可以通过银行网页转账给另一个账号,支持跨币种转账。
同时因为监管和对账需求,需要记录本次转账活动。

拿到这个需求之后,一个开发可能会经历一些技术选型,最终可能拆解需求如下:

    从MySql数据库中找到转出和转入的账户,选择用MyBatis的mapper实现DAO
从Yahoo(或其他渠道)提供的汇率服务获取转账的汇率信息(底层是http开放接口)
计算需要转出的金额,确保账户有足够余额,并且没超出每日转账上限
实现转入和转出操作,扣除手续费,保存数据库
发送Kafka审计消息,以便审计和对账用
而一个简单的代码实现如下:

1
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
public class TransferController {

private TransferService transferService;

public Result<Boolean> transfer(String targetAccountNumber, BigDecimal amount, HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
return transferService.transfer(userId, targetAccountNumber, amount, "CNY");
}
}

public class TransferServiceImpl implements TransferService {

private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
private AccountMapper accountDAO;
private KafkaTemplate<String, String> kafkaTemplate;
private YahooForexService yahooForex;

@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 1. 从数据库读取数据,忽略所有校验逻辑如账号是否存在等
AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);

// 2. 业务参数校验
if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
throw new InvalidCurrencyException();
}

// 3. 获取外部数据,并且包含一定的业务逻辑
// exchange rate = 1 source currency = X target currency
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);

// 4. 业务参数校验
if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
throw new InsufficientFundsException();
}

if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
throw new DailyLimitExceededException();
}

// 5. 计算新值,并且更新字段
BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
sourceAccountDO.setAvailable(newSource);
targetAccountDO.setAvailable(newTarget);

// 6. 更新到数据库
accountDAO.update(sourceAccountDO);
accountDAO.update(targetAccountDO);

// 7. 发送审计消息
String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
kafkaTemplate.send(TOPIC_AUDIT_LOG, message);

return Result.success(true);
}

}

我们可以看到,一段业务代码里经常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。在这个案例里虽然是写在了同一个方法里,在真实代码中经常会被拆分成多个子方法,但实际效果是一样的,而在我们日常的工作中,绝大部分代码都或多或少的接近于此类结构。在Martin Fowler的 P of EAA书中,这种很常见的代码样式被叫做Transaction Script(事务脚本)。虽然这种类似于脚本的写法在功能上没有什么问题,但是长久来看,他有以下几个很大的问题:可维护性差、可扩展性差、可测试性差。

阅读全文 »

sync.mutex 作用:
sync.Mutex是Go标准库中常用的一个排外锁。当一个 goroutine 获得了这个锁的拥有权后, 其它请求锁的 goroutine 就会阻塞在
Lock 方法的调用上,直到锁被释放。

初代mutext

1
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
type Mutex struct {
key int32;
sema int32;
}

func xadd(val *int32, delta int32) (new int32) {
for {
v := *val;
if cas(val, v, v+delta) {
return v+delta;
}
}
panic("unreached")
}

func (m *Mutex) Lock() {
if xadd(&m.key, 1) == 1 {
// changed from 0 to 1; we hold lock
return;
}
sys.semacquire(&m.sema);
}

func (m *Mutex) Unlock() {
if xadd(&m.key, -1) == 0 {
// changed from 1 to 0; no contention
return;
}
sys.semrelease(&m.sema);
}

通过cas对 key 进行加一, 如果key的值是从0加到1, 则直接获得了锁。否则通过semacquire进行sleep, 被唤醒的时候就获得了锁。
2016年 Go 1.9中增加了饥饿模式,让锁变得更公平,不公平的等待时间限制在1毫秒,并且修复了一个大bug,唤醒的goroutine总是放在等待队列的尾部会导致更加不公平的等待时间。
现有的锁实现是很复杂的。粗略的瞄一眼,很难理解其中的逻辑, 尤其实现中字段的共用,标识的位操作,sync函数的调用、正常模式和饥饿模式的改变等

sync.mutex

在分析源代码之前, 我们要从多线程(goroutine)的并发场景去理解为什么实现中有很多的分支。

  1. 当一个goroutine获取这个锁的时候, 有可能这个锁根本没有竞争者, 那么这个goroutine轻轻松松获取了这个锁。
  2. 而如果这个锁已经被别的goroutine拥有, 就需要考虑怎么处理当前的期望获取锁的goroutine。
  3. 同时, 当并发goroutine很多的时候,有可能会有多个竞争者, 而且还会有通过信号量唤醒的等待者。
    mutex的结构:
    1
    2
    3
    4
    type Mutex struct {
    state int32
    sema uint32
    }
    state 是一个共用的字段,第 0 个bit 表示是被 goroutine 所拥有加锁。第一个 bit 表示mux 是否被唤醒,
    也就是有某个唤醒的goroutine要尝试获取锁。第二个 bit 标记这个mutex状态, 值为1表明此锁已处于饥饿状态。

同时 goroutine也有自身的状态:有可能它是新来的goroutine,也有可能是被唤醒的goroutine,
可能是处于正常状态的goroutine, 也有可能是处于饥饿状态的goroutine。
也就是说在变更mutex状态的时候,也和当前goroutine的状态有关。

mutex 的状态

mutext 饥饿状态主要是为了保持锁的公平性,防止早运行的goroutine一直获取不到 锁,从而得不到执行。
互斥锁有两种状态:正常状态和饥饿状态:

阅读全文 »

什么是 context:
context 是golang用来设计的在多个Goroutine中传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。
context.Context 类型的值可以协调多个 groutine 中的代码执行“取消”操作,并且可以存储键值对。最重要的是它是并发安全的。
随着 context 包的引入,标准库中很多接口因此加上了 context 参数,例如 database/sql 包。context 几乎成为了并发控制和超时控制的标准做法。

context 在设计上是一个多叉树,且不同的子context,在不同的groutine中是不会有影响的。而且context 实现中有的是基于channel,有的加有 lock所以是线程安全的。
一个context 的典型图

1
2
3
4
5
6
7
8
9
10
11
//如:net/http 中就是在 Listener后,产生一个context.Background,
// 随后 会有一个循环,一直接受 新的请求,并且启动一个goruntime去处理,在处理函数里会生成一个Cancel的context,
// 用来在处理完毕请求后,通知这个请求下所有 goruntime取消。
for {
.....
r := l.Accept()
go server(ctx) {
ctx,cancel := context.WithCancel(ctx)
}

}

在使用context的中,如果想监听 取消,或者超时的通知,需要在接受的goruntime里 监听 Context.Done()的方法,来订阅到超时或者取消的通知,或者是没什么用的

1
2
3
4
5
6
7
go fun(ctx) {
select {
case <-ctx.Done():
fmt.Print("context is cancel")
}

}

context的使用场景

  1. 想在多个goruntime 间传递数据,可以使用 ValueCtx,如 trace id
  2. 想在多个goruntime 控制时长,可以使用 timerCtx,需要在 多个 goruntime监听context.Done()方法的通知
    例如在业务的高峰期,某个下游服务的响应变慢,而当前系统的请求又没有超时控制,或者超时时间设置地过大,那么等待下游服务返回数据的协程就会越来越多。而我们知道,协程是要消耗系统资源的,后果就是协程数激增,内存占用飙涨,甚至导致服务不可用。更严重的会导致雪崩效应,整个服务对外表现为不可用.
  3. 想在多个goruntime 控制取消操作,可以使用 cancelCtx,需要在 多个 goruntime监听context.Done()方法的通知.
    当请求被取消,这有可能是使用者关闭了浏览器,请求方直接放弃了这次请求结果。这时,所有正在为这个请求工作的 goroutine 需要快速退出,因为它们的“工作成果”不再被需要了。在相关联的 goroutine 都退出后,系统就可以回收相关的资源.

context的结构

golang 官方默认提供了

阅读全文 »