21.Axon框架-聚合

183 阅读13分钟

聚合

1.介绍

1767930297621.png

2.基本组成

1767930386811.png

3.类型

总览

1767930458604.png

事件溯源

事件溯源聚合(Event Sourced Aggregate)的存储方式是通过回放构成聚合变更的事件来重建状态

对于用户来说在Axon中主要通过@EventSourcingHandler来支持

使用方式和上面的基本组成一样

状态存储

介绍

聚合也可以直接按当前状态存储。这种情况下,用于保存和加载聚合的仓库(Repository)是GenericJpaRepository。状态存储聚合的结构与事件溯源聚合略有不同

示例
import org.axonframework.commandhandling.CommandHandler;
import org.axonframework.eventhandling.EventHandler;
import org.axonframework.modelling.command.AggregateIdentifier;
import org.axonframework.modelling.command.AggregateMember;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;

// 1.标注为 JPA 实体,因聚合通过 JPA 存储
@Entity
public class GiftCard {

    // 2.@Id(JPA 要求)与 @AggregateIdentifier(Axon 要求)共用,标记聚合唯一标识
    @Id
    @AggregateIdentifier
    private String id;

    // 3.子实体列表:JPA 映射(@OneToMany)+ Axon 子实体声明(@AggregateMember)
    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "giftCardId") // 关联子实体的外键字段
    @AggregateMember
    private List<GiftCardTransaction> transactions = new ArrayList<>();

    // 聚合状态:礼品卡剩余金额
    private int remainingValue;

    // 4. 命令处理构造函数:处理“发行礼品卡”命令(IssueCardCommand)
    @CommandHandler
    public GiftCard(IssueCardCommand cmd) {
        // 6. 业务规则校验:金额不能小于等于 0
        if (cmd.getAmount() <= 0) {
            throw new IllegalArgumentException("amount <= 0");
        }
        // 7. 直接修改聚合状态:初始化聚合标识和剩余金额
        id = cmd.getCardId();
        remainingValue = cmd.getAmount();

        // 5. 发布“礼品卡已发行”事件(CardIssuedEvent)
        apply(new CardIssuedEvent(cmd.getCardId(), cmd.getAmount()));
    }

    // 命令处理器:处理“消费礼品卡”命令(RedeemCardCommand)
    @CommandHandler
    public void handle(RedeemCardCommand cmd) {
        // 6. 业务规则校验:金额合法性、剩余金额充足性、交易ID唯一性
        if (cmd.getAmount() <= 0) {
            throw new IllegalArgumentException("amount <= 0");
        }
        if (cmd.getAmount() > remainingValue) {
            throw new IllegalStateException("amount > remaining value");
        }
        if (transactions.stream()
                        .map(GiftCardTransaction::getTransactionId)
                        .anyMatch(cmd.getTransactionId()::equals)) {
            throw new IllegalStateException("TransactionId must be unique");
        }

        // 7. 直接修改聚合状态:扣减剩余金额、添加消费交易记录
        remainingValue -= cmd.getAmount();
        transactions.add(new GiftCardTransaction(id, cmd.getTransactionId(), cmd.getAmount()));

        // 5. 发布“礼品卡已消费”事件(CardRedeemedEvent)
        apply(new CardRedeemedEvent(id, cmd.getTransactionId(), cmd.getAmount()));
    }

    // 8. 事件处理器:监听“交易已退款”事件(CardReimbursedEvent),更新聚合状态
    @EventHandler
    protected void on(CardReimbursedEvent event) {
        this.remainingValue += event.getAmount(); // 退款:恢复剩余金额
    }

    // 9. 无参构造函数:JPA 强制要求,用于加载聚合时实例化
    protected GiftCard() { }
}
注意
  1. 直接修改状态:与事件溯源聚合不同,状态存储聚合的状态修改直接在CommandHandler中完成(无需通过 @EventSourcingHandler回放事件)。校验通过后,可直接更新聚合字段或子实体
  2. 无参构造函数:JPA强制要求实体类提供无参构造函数,Axon加载聚合时会通过该构造函数创建空实例,再从数据库加载状态填充。若未提供,加载聚合时会抛出异常,通常将其设为protected以避免外部误调用
  3. 领域模型被技术细节污染:领域模型理论上来说应该和技术分离的,这么搞导致领域模型和JPA耦合了,会导致一些问题,比如被数据库细节绑架等问题

4.生命周期函数

1767930744358.png

5.多实体聚合

介绍

1767931540883.png

组成

import org.axonframework.modelling.command.AggregateIdentifier;
import org.axonframework.modelling.command.AggregateMember;
import org.axonframework.modelling.command.EntityId;

public class GiftCard {

    @AggregateIdentifier
    private String id; // 聚合根标识符

    // 标注子实体列表,告知 Axon 需扫描该字段下的消息处理器
    @AggregateMember
    private List<GiftCardTransaction> transactions = new ArrayList<>();

    private int remainingValue; // 礼品卡剩余金额(聚合根状态)

    // 省略构造函数、命令处理器(command handlers)和事件溯源处理器(event sourcing handlers)
}
public class GiftCardTransaction {

    @EntityId
    private String transactionId; // 子实体标识符

    private int transactionValue; // 交易金额(子实体状态)
    private boolean reimbursed = false; // 是否已退款(子实体状态)

    // 子实体构造函数(非命令处理构造函数)
    public GiftCardTransaction(String transactionId, int transactionValue) {
        this.transactionId = transactionId;
        this.transactionValue = transactionValue;
    }

    // 向聚合内其他实体暴露状态(仅内部访问,不对外)
    public String getTransactionId() {
        return transactionId;
    }
    
    // 子实体中的命令处理器:处理“退款命令”
    @CommandHandler
    public void handle(ReimburseCardCommand cmd) {
        // 业务规则校验:已退款的交易不允许再次退款
        if (reimbursed) {
            throw new IllegalStateException("Transaction already reimbursed");
        }
        // 发布“交易已退款”事件(事件会关联聚合根标识符)
        apply(new CardReimbursedEvent(cmd.getCardId(), transactionId, transactionValue));
    }
    // 省略命令处理器、事件溯源处理器和 equals/hashCode 方法
}

消息在多实体聚合中的传播机制

介绍

1767932007963.png

命令路由
  1. 空值与不存在的子实体:Axon仅扫描@AggregateMember标注字段的声明类型以查找@CommandHandler。若命令到达时该字段值为null,会抛出异常;若子实体是Collection/Map的一部分,且无匹配路由键的子实体,Axon会抛出IllegalStateException(表示当前聚合无法处理该命令)
  2. @CommandHandler唯一性:一个聚合中,同一类型的命令必须仅有一个处理器,不能在多个实体(无论是否为聚合根)上为同一命令类型标注@CommandHandler。若需根据条件路由命令到不同子实体,应由父实体(如聚合根)处理命令,再根据条件转发到目标子实体
  3. 运行时类型不影响扫描:字段的运行时类型无需与声明类型完全一致,但Axon仅扫描@AggregateMember标注字段的声明类型以查找@CommandHandler方法
传播己知
介绍

当使用事件溯源存储聚合时,不仅聚合根需要通过事件触发状态变更,聚合内的所有子实体也需如此。Axon开箱即支持为这种复杂聚合结构提供事件溯源能力

流程

当某个实体(包括聚合根)发布事件时,事件会按以下顺序传播并被处理:

  1. 首先由聚合根处理该事件(调用聚合根的@EventSourcingHandler)
  2. 然后事件向下冒泡,通过所有@AggregateMember标注的字段,传播到聚合内的所有子实体(调用子实体的@EventSourcingHandler)
示例
import org.axonframework.commandhandling.CommandHandler;
import org.axonframework.modelling.command.AggregateIdentifier;
import org.axonframework.modelling.command.AggregateMember;
import org.axonframework.modelling.command.EntityId;

import static org.axonframework.modelling.command.AggregateLifecycle.apply;

public class GiftCard {

    @AggregateIdentifier
    private String id;
    @AggregateMember
    private List<GiftCardTransaction> transactions = new ArrayList<>();

    // 聚合根的命令处理器:处理“礼品卡消费命令”
    @CommandHandler
    public void handle(RedeemCardCommand cmd) {
        // 业务决策逻辑(如校验剩余金额是否充足)
        apply(new CardRedeemedEvent(id, cmd.getTransactionId(), cmd.getAmount()));
    }

    // 聚合根的事件溯源处理器:处理“礼品卡已消费”事件
    @EventSourcingHandler
    public void on(CardRedeemedEvent evt) {
        // 1. 在聚合根中创建子实体(子实体的创建必须通过父实体的事件溯源处理器)
        transactions.add(new GiftCardTransaction(evt.getTransactionId(), evt.getAmount()));
    }

    // 省略构造函数、其他命令和事件溯源处理器
}

public class GiftCardTransaction {

    @EntityId
    private String transactionId;
    private int transactionValue;
    private boolean reimbursed = false;

    public GiftCardTransaction(String transactionId, int transactionValue) {
        this.transactionId = transactionId;
        this.transactionValue = transactionValue;
    }

    // 子实体的命令处理器:处理“退款命令”(前文已展示)
    @CommandHandler
    public void handle(ReimburseCardCommand cmd) { /* 省略实现 */ }

    // 子实体的事件溯源处理器:处理“交易已退款”事件
    @EventSourcingHandler
    public void on(CardReimbursedEvent event) {
        // 2. 校验事件是否属于当前子实体(避免其他子实体处理该事件)
        if (transactionId.equals(event.getTransactionId())) {
            reimbursed = true; // 更新子实体状态:标记为已退款
        }
    }

    // 省略 getter 和 equals/hashCode 方法
}
注意
  1. 子实体的创建方式:子实体的创建必须在其父实体(如聚合根)的@EventSourcingHandler中完成,子实体类不能像聚合根那样拥有命令处理构造函数(因为子实体的创建依赖父实体的状态变更,需通过事件触发)
  2. 子实体的事件归属校验:由于一个实体发布的事件会传播到所有同类型的子实体,因此子实体的@EventSourcingHandler必须先校验事件是否属于自己(如通过transactionId匹配)
转发模式

Axon提供三种事件转发模式:

  1. ForwardAll(默认):将所有事件转发给所有子实体;
  2. ForwardMatchingInstances:仅当事件包含与子实体@EntityId字段同名的属性时,才转发给匹配的子实体(可通过 @EntityId(routingKey) 自定义路由键,与命令路由逻辑一致)
  3. ForwardNone:不将任何事件转发给子实体

可通过@AggregateMember注解的eventForwardingMode属性自定义,示例如下:

import org.axonframework.modelling.command.AggregateIdentifier;
import org.axonframework.modelling.command.AggregateMember;
import org.axonframework.modelling.command.ForwardMatchingInstances;

public class GiftCard {

    @AggregateIdentifier
    private String id;
    // 配置事件仅转发给“匹配路由键”的子实体
    @AggregateMember(eventForwardingMode = ForwardMatchingInstances.class)
    private List<GiftCardTransaction> transactions = new ArrayList<>();

    // 省略构造函数、命令和事件溯源处理器
}

7.多态

介绍

在某些场景下,为聚合结构设计成多态会带来便利。多态聚合层级中的子类型会继承父聚合的@CommandHandler、@EventSourcingHandler和@CommandHandlerInterceptor。Axon会基于@AggregateIdentifier加载正确的聚合类型,并在该类型上执行命令

1767933849781.png

案例

以下是一个示例:

// 抽象父聚合
public abstract class Card {}

// 具体聚合类型:继承自 Card
public class GiftCard extends Card {}

// 子聚合类型:继承自 GiftCard
public class ClosedLoopGiftCard extends GiftCard {}
public class OpenLoopGiftCard extends GiftCard {}
public class RechargeableGiftCard extends ClosedLoopGiftCard {}

我们可以将这种结构定义为以GiftCard为父类型的多态聚合,其子类型包括ClosedLoopGiftCard、OpenLoopGiftCard和RechargeableGiftCard。如果Card类中定义了处理器,所有子聚合都会继承这些处理器

约束

1767933932587.png

注册

原始方式

多态聚合层级可通过AggregateConfigurer注册,调用AggregateConfigurer#withSubtypes(Set<Class<? extends A>>) 方法即可

需要注意的是:未显式注册的父聚合直接子类会被框架自动注册为子类型,但间接子类不会。例如,若ClosedLoopGiftCard是GiftCard的直接子类,即使不显式注册,框架也会自动将其视为GiftCard的子类型;而如果存在LimitedRechargeableGiftCard extends RechargeableGiftCard(RechargeableGiftCard是GiftCard的间接子类),框架不会自动注册LimitedRechargeableGiftCard,需显式声明

public class AxonConfig {
    // 省略其他配置方法...
    
    // 配置 GiftCard 多态聚合及其子类型
    public AggregateConfigurer<GiftCard> giftCardConfigurer() {
        // 定义需要显式注册的子类型集合
        Set<Class<? extends GiftCard>> subtypes = new HashSet<>();
        subtypes.add(OpenLoopGiftCard.class);       // 直接子类,显式注册
        subtypes.add(RechargeableGiftCard.class);   // 间接子类(继承自 ClosedLoopGiftCard),需显式注册
        
        // 注册父聚合类型为 GiftCard,并关联子类型
        return AggregateConfigurer.defaultConfiguration(GiftCard.class)
                                  .withSubtypes(subtypes);
    }

    // ...
}

// 父聚合
class GiftCard {
    // 为简洁起见,省略实现
}

// 直接子聚合(GiftCard 的直接子类)
class OpenLoopGiftCard extends GiftCard {
    // 为简洁起见,省略实现
}

// 间接子聚合(继承自 ClosedLoopGiftCard,而 ClosedLoopGiftCard 继承自 GiftCard)
class RechargeableGiftCard extends GiftCard {
    // 为简洁起见,省略实现
}
SpringBoot中

若使用Spring框架,Axon会基于@Aggregate注解和类层级自动检测多态聚合

注意:@Aggregate注解需标注在共享父类(包含聚合标识符的类)上,同时也需标注在每个可能作为该父类实例类型的子类上。这样Spring才能正确识别整个多态聚合层级

8.从一个聚合创建另一个聚合

介绍

通常情况下,实例化新聚合的方式是:发送一个创建命令,由标注了@CommandHandler的聚合构造函数处理该命令。这类命令可能由简单的REST接口发布,或由事件处理组件作为对特定事件的响应发布。但在某些领域场景中,业务规则明确要求从一个实体创建另一个实体,此时,从父聚合中实例化子聚合,更能贴合领域模型的设计意图

1767934033976.png

适用场景

从父聚合创建子聚合的最适合场景是:创建子聚合的决策逻辑属于父聚合的上下文范围。例如,父聚合中包含驱动子聚合创建所需的状态,只有基于父聚合的当前状态(如剩余配额、权限校验结果),才能判断是否允许创建子聚合。这种场景下,将子聚合的创建逻辑放在父聚合中,能更好地封装领域规则,确保决策的一致性

代码

假设我们有一个ParentAggregate,在处理某个命令时,会触发创建ChildAggregate的逻辑。ParentAggregate的实现示例如下:

import org.axonframework.commandhandling.CommandHandler;
import org.axonframework.modelling.command.AggregateIdentifier;

import static org.axonframework.modelling.command.AggregateLifecycle.createNew;

public class ParentAggregate {

    @AggregateIdentifier
    private String parentId; // 父聚合标识符

    // 父聚合的命令处理器:处理 SomeParentCommand,触发子聚合创建
    @CommandHandler
    public void handle(SomeParentCommand command) {
        // 1. (可选)基于父聚合状态做创建决策(如校验是否有创建子聚合的权限)
        validateChildCreationPermission();

        // 2. 调用 AggregateLifecycle.createNew() 创建子聚合
        createNew(
            ChildAggregate.class, // 第一个参数:待创建子聚合的类类型
            // 第二个参数:工厂方法(Callable),用于实例化子聚合(需传入构造函数所需参数)
            () -> new ChildAggregate(command.getChildAggregateId()) 
        );
    }

    // 省略无参构造函数、事件溯源处理器(Event Sourcing Handler)和其他命令处理器
    protected ParentAggregate() { }

    // 示例:创建子聚合前的权限校验(父聚合状态驱动的决策逻辑)
    private void validateChildCreationPermission() {
        // 假设父聚合有“剩余子聚合配额”状态,需校验配额是否充足
        // if (this.remainingChildQuota <= 0) {
        //     throw new IllegalStateException("No remaining quota to create child aggregate");
        // }
    }
}
import org.axonframework.modelling.command.AggregateIdentifier;

import static org.axonframework.modelling.command.AggregateLifecycle.apply;

public class ChildAggregate {

    @AggregateIdentifier
    private String childId; // 子聚合标识符

    // 子聚合的构造函数(非命令处理构造函数,由父聚合的工厂方法调用)
    public ChildAggregate(String aggregateId) {
        // 发布“子聚合已创建”事件(ChildAggregateCreatedEvent)
        apply(new ChildAggregateCreatedEvent(aggregateId));
    }

    // 3. 事件溯源处理器:通过创建事件初始化子聚合标识符
    @EventSourcingHandler
    public void on(ChildAggregateCreatedEvent event) {
        this.childId = event.getChildAggregateId();
    }

    // 省略无参构造函数、其他命令处理器和事件溯源处理器
    protected ChildAggregate() { }
}

注意

  1. 必须发布创建事件:子聚合的构造函数中需显式调用apply()发布子聚合已创建事件(如ChildAggregateCreatedEvent)。若不发布该事件,子聚合的创建逻辑仅封装在父聚合的命令处理器中,外部组件(如投影器、其他领域服务)无法感知子聚合的创建,导致状态不一致
  2. 子聚合的构造函数:子聚合的构造函数无需标注@CommandHandler(因为它由父聚合的createNew()调用,而非直接处理外部命令),但需确保构造函数能接收工厂方法传入的参数(如子聚合标识符)

禁止行为

1767934173446.png

9.变更冲突

介绍

更精确地检测冲突性变更。这类冲突性变更通常发生在两个用户(几乎)同时操作相同数据的场景中

1767934328926.png

案例理解

想象一下:两个用户都查看了数据的特定版本,并且都决定对该数据进行修改。他们都会发送类似在该聚合的版本X上执行某操作的命令(其中X是聚合的预期版本)。最终,只有一个用户的变更会实际应用到预期版本上,另一个用户的变更则不会生效

我们不必在聚合被其他进程修改时简单地拒绝所有传入命令,而是可以检查用户的操作意图是否与任何未察觉的变更存在冲突

使用

要检测冲突,可在聚合的@CommandHandler方法中传入ConflictResolver类型的参数。该接口提供了detectConflicts方法,允许你定义在执行特定类型命令时,哪些事件类型会被视为冲突

只有当聚合是基于预期版本加载时,ConflictResolver才会包含潜在的冲突事件。可在命令的某个字段上使用@TargetAggregateVersion注解,以指定聚合的预期版本

如果发现了与谓词匹配的事件,会抛出异常(detectConflicts的可选第二个参数允许你自定义要抛出的异常)。如果未发现匹配事件,则处理流程正常继续

如果未调用detectConflicts方法,但存在潜在的冲突事件,@CommandHandler会处理失败。这种情况可能发生在:命令提供了预期版本,但@CommandHandler方法的参数中没有ConflictResolver时