Event Sourcing 和 CQRS落地(六):Spring-Cloud-Stream 優化

本系列的上一篇文章重點介紹了Axon實現,本文將主要介紹Spring Cloud 提供的消息中間件的抽象Spring Cloud Stream的優化方法。

Spring Cloud Stream 優化

問題

Spring Cloud Stream(以下簡稱 SCS )是 Spring Cloud 提供的消息中間件的抽象,但是目前也就支持 kafka 和 rabbitmq,這篇文章主要會討論一下如何讓 SCS 更好的服務我們之前搭建的 Event Sourcing、CQRS 模型。以下是我在使用 SCS 的過程中存在的一些問題:

  1. StreamListener用來做事件路由分發並不是很理想,SPEL 可能會寫的很長(我嘗試過用自定義註解代替原生的註解,從而達到簡化的目的,但是會出現一些莫名其妙的事件混亂)。
  2. 如果配合之前的模型使用,我們需要保證消息的順序消費,每個方法都需要去 check 事件的當前 seq,很不方便。
  3. 在沒有 handler 處理某個 type 的事件時,框架會給出一個 warn,然而這個事件可能在 consumer 這裏根本不關心。

解決方案

爲了解決上面的問題,我們可以這麼處理,先統一一個入口將 SCS 的消息接收,然後我們自己構建一個路由系統,將請求分發到我們自己定義的註解方法上,並且在這個過程中將 seq 的檢查也給做了,大體的流程是這個樣子的:

image

這樣以上幾點問題都會得到解決,下面我們來看看具體如何實現:

  • 首先定義一個註解用於接受自己分發的事件:

@Target( {ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface StreamEventHandler {

    String[] payloadTypes() default {""};

    String[] types();
}

types 對應 Stream 本身 Inuput 的類型, payloadTypes 對應事件類型,比如 ContractCreated,我們要做的效果是這個 payloadTypes 可以不寫,直接從方法的第一個參數讀取 class 的 simapleName。

  • 定義用於記錄 aggregate sequenceNumber 的 entity 和 repository :
@Entity
@Table(indexes = @Index(columnList = "aggregateIdentifier,type", unique = true))
@Getter
@Setter
@NoArgsConstructor
public class DomainAggregateSequence {

    @Id
    @GeneratedValue
    private Long id;

    private Long sequenceNumber;

    private Long aggregateIdentifier;

    private String type;
}

@Repository
public interface DomainAggregateSequenceRepository extends JpaRepository<DomainAggregateSequence, Long> {

    /**
     * 根據 aggregate id 和 type 找到對應的記錄
     *
     * @param identifier
     * @param type
     *
     * @return
     */
    DomainAggregateSequence findByAggregateIdentifierAndType(Long identifier, String type);

}
  • 由於暫時沒有找到監聽所有已綁定 channel 的事件的方法,這裏實現一個類提供一個 dispatch 的方法用於分發:
@Slf4j
@Component
@AllArgsConstructor
public class StreamDomainEventDispatcher implements BeanPostProcessor {

    private final ObjectMapper mapper;

    private final DomainAggregateSequenceRepository domainAggregateSequenceRepository;

    private HashMap<Object, List<Method>> beanHandlerMap = new HashMap<>();

    @Autowired
    public StreamDomainEventDispatcher(ObjectMapper mapper, DomainAggregateSequenceRepository domainAggregateSequenceRepository) {
        this.mapper = mapper;
        this.domainAggregateSequenceRepository = domainAggregateSequenceRepository;
    }

    @Transactional
    public void dispatchEvent(DomainEvent event, String type) {

        log.info(MessageFormat.format("message [{0}] received", event.getEventIdentifier()));

        // 1. 檢查是否是亂序事件或者重複事件
        Long aggregateIdentifier = Long.parseLong(event.getAggregateIdentifier());
        String eventType = event.getType();
        Long eventSequence = event.getSequenceNumber();

        DomainAggregateSequence sequenceObject = domainAggregateSequenceRepository.findByAggregateIdentifierAndType(aggregateIdentifier, eventType);

        if (sequenceObject == null) {
            sequenceObject = new DomainAggregateSequence();
            sequenceObject.setSequenceNumber(eventSequence);
            sequenceObject.setAggregateIdentifier(aggregateIdentifier);
            sequenceObject.setType(eventType);
        } else if (sequenceObject.getSequenceNumber() + 1 != eventSequence) {
            // 重複事件,直接忽略
            if (sequenceObject.getSequenceNumber().equals(eventSequence)) {
                log.warn(MessageFormat.format("repeat event ignored, type[{0}] aggregate[{1}] seq[{2}] , ignored", event.getType(), event.getAggregateIdentifier(), event.getSequenceNumber()));
                return;
            }
            throw new StreamEventSequenceException(MessageFormat.format("sequence error, db [{0}], current [{1}]", sequenceObject.getSequenceNumber(), eventSequence));
        } else {
            sequenceObject.setSequenceNumber(eventSequence);
        }

        domainAggregateSequenceRepository.save(sequenceObject);

        // 2. 分發事件到各個 handler
        beanHandlerMap.forEach((key, value) -> {
            Optional<Method> matchedMethod = getMatchedMethods(value, type, event.getPayloadType());

            matchedMethod.ifPresent(method -> {
                try {
                    invoke(key, method, event);
                } catch (IllegalAccessException | InvocationTargetException e) {

                    throw new StreamHandlerException(MessageFormat.format("[{0}] invoke error", method.getName()), e);
                }
            });

            if (!matchedMethod.isPresent()) {
                log.info(MessageFormat.format("message [{0}] has no listener", event.getEventIdentifier()));
            }
        });

        log.info(MessageFormat.format("message [{0}] handled", event.getEventIdentifier()));
    }

    @Transactional
    public Optional<Method> getMatchedMethods(List<Method> methods, String type, String payloadType) {
        // 這裏應該只有一個方法,因爲將 stream 的單個事件分成多個之後,無法保證一致性
        List<Method> results = methods.stream().filter(m -> {
            StreamEventHandler handler = m.getAnnotation(StreamEventHandler.class);
            List<String> types = new ArrayList<>(Arrays.asList(handler.types()));
            List<String> payloadTypes = new ArrayList<>(Arrays.asList(handler.payloadTypes()));

            types.removeIf(StringUtils::isBlank);
            payloadTypes.removeIf(StringUtils::isBlank);

            if (CollectionUtils.isEmpty(payloadTypes) && m.getParameterTypes().length != 0) {
                payloadTypes = Collections.singletonList(m.getParameterTypes()[0].getSimpleName());
            }

            boolean isTypeMatch = types.contains(type);

            String checkedPayloadType = payloadType;
            if (StringUtils.contains(checkedPayloadType, ".")) {
                checkedPayloadType = StringUtils.substringAfterLast(checkedPayloadType, ".");
            }
            boolean isPayloadTypeMatch = payloadTypes.contains(checkedPayloadType);

            return isTypeMatch && isPayloadTypeMatch;
        }).collect(Collectors.toList());

        if (results.size() > 1) {
            throw new StreamHandlerException(MessageFormat.format("type[{0}] event[{1}] has more than one handler", type, payloadType));
        }

        return results.size() == 1 ? Optional.of(results.get(0)) : Optional.empty();
    }

    @Transactional
    public void invoke(Object bean, Method method, DomainEvent event) throws IllegalAccessException, InvocationTargetException {

        int count = method.getParameterCount();

        if (count == 0) {
            method.invoke(bean);
        } else if (count == 1) {
            Class<?> payloadType = method.getParameterTypes()[0];

            if (payloadType.equals(DomainEvent.class)) {
                method.invoke(bean, mapper.convertValue(event.getPayload(), DomainEvent.class));
            } else {
                method.invoke(bean, mapper.convertValue(event.getPayload(), payloadType));
            }

        } else if (count == 2) {
            Class<?> payloadType0 = method.getParameterTypes()[0];
            Class<?> payloadType1 = method.getParameterTypes()[1];

            Object firstParameterValue = mapper.convertValue(event.getPayload(), payloadType0);
            Object secondParameterValue = event.getMetaData();

            // 如果是 DomainEvent 類型則優先傳遞該類型,另外一個參數按照 payloadType > metaData 優先級傳入
            if (payloadType0.equals(DomainEvent.class)) {
                firstParameterValue = mapper.convertValue(event, payloadType0);
                secondParameterValue = mapper.convertValue(event.getPayload(), payloadType1);
            }
            if (payloadType1.equals(DomainEvent.class)) {
                secondParameterValue = mapper.convertValue(event, payloadType1);
            }
            method.invoke(bean, firstParameterValue, secondParameterValue);
        }
    }


    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        Class<?> targetClass = AopUtils.isAopProxy(bean) ? AopUtils.getTargetClass(bean) : bean.getClass();
        Method[] uniqueDeclaredMethods = ReflectionUtils.getUniqueDeclaredMethods(targetClass);

        List<Method> methods = new ArrayList<>();
        for (Method method : uniqueDeclaredMethods) {
            StreamEventHandler streamListener = AnnotatedElementUtils.findMergedAnnotation(method,
                StreamEventHandler.class);
            if (streamListener != null) {
                methods.add(method);
            }
        }
        if (!CollectionUtils.isEmpty(methods)) {
            beanHandlerMap.put(bean, methods);
        }
        return bean;
    }

}

這裏參照了 SCS 本身手機 handler的方式,會將有 @StreamEventHandler 註解的方法都找出來做一個記錄。在 dispatchEvent 的時候會更新事件的 seq 並且按照 type 去調用各個標有註解的方法。

  • 實現一個比較簡單的例子:
@Slf4j
@Component
@Transactional
@AllArgsConstructor
public class DomainEventDispatcher {

    private final StreamDomainEventDispatcher streamDomainEventDispatcher;

    @StreamListener(target = ChannelDefinition.CONTRACTS_INPUT, condition = "headers['messageType']=='eventSourcing'")
    public void handleBuilding(@Payload DomainEvent event) {
        streamDomainEventDispatcher.dispatchEvent(event, ChannelDefinition.CONTRACTS_INPUT);
    }
}

@Component
@AllArgsConstructor
@Transactional
public class ContractEventHandler {
    
    @StreamEventHandler(types = ChannelDefinition.CONTRACTS_INPUT)
    public void handle(ContractCreatedEvent event) {
        // 實現你的 view 層更新業務
    }
}

注意:

  • AbstractDomainEventDispatcher中監聽所有 bean 加載完成不能用 InitializingBean 接口,否則@Transactional會失效,這個有興趣的同學可以研究一下@Transactional的機制。

至此以上幾點就優化完了。

其他優化

錯誤處理

基於 SCS 的默認配置,存在一個致命的問題,那就是當消息處理失敗(重試三次)之後,消息直接沒了,這個相當於就是消息丟失了。那麼解決方案其實也是比較簡單的,一般有兩種解決方案:

  1. 拒絕這個消息,丟在 broker 原先的隊列裏。
  2. 將這個消息記錄到一個錯誤的 queue 中等待修復,後續可能將消息轉發回去,也可能直接就刪除了消息(比如重複的消息)。

方案 1 這麼做可能會出的問題就是,這個消息反覆消費,反覆失敗,引起循環問題從而導致服務出現問題,這個就需要在 broker 做一些策略配置了,爲了讓 broker 儘可能的簡單,我們這裏採用方案 2,要實現的流程是這樣的:

image

  • 首先讓 SCS 爲我們自動生成一個 DLQ
spring:
  application:
    name: event-sourcing-service
  datasource:
    url: jdbc:mysql://localhost:3306/event?useUnicode=true&autoReconnect=true&rewriteBatchedStatements=TRUE
    username: root
    password: root
  jpa:
    hibernate:
      ddl-auto: update
      use-new-id-generator-mappings: false
    show-sql: false
    properties:
      hibernate.dialect: org.hibernate.dialect.MySQL55Dialect
  rabbitmq:
    host: localhost
    port: 5672
    username: creams_user
    password: Souban701
  cloud:
    stream.bindings:
      contract-events: # 這個名字對應代碼中@input("value") 的 value
        destination: contract-events # 這個對應 rabbit 中的 channel
        contentType: application/json # 這個指定傳輸類型,其實可以默認指定,但是目前每個地方都寫了,所以統一下
      contract-events-input:
        destination: contract-events
        contentType: application/json
        group: event-sourcing-service
        durableSubscription: true
    stream.rabbit.bindings.contract-events-input.consumer:
      autoBindDlq: true
      republishToDlq: true
      deadLetterQueueName: contract-error.dlq
logging:
  level.org:
    springframework:
      web: INFO
      cloud.sleuth: INFO
    apache.ibatis: DEBUG
    java.sql: DEBUG
    hibernate:
      SQL: DEBUG
      type.descriptor.sql: TRACE

axon:
  serializer:
    general: jackson

加上這個配置之後,rabbit 會給這個隊列創建一個 .dlq 後綴的隊列,異常消息都會被塞到這個隊列裏面(消息中包含了異常信息以及來源),等待我們處理,deadLetterQueueName指定了 DLQ 的名稱,這樣所有的失敗消息都會存放到同一個 queue中。大部分的情況下,消息的異常都是由於 consumer 邏輯錯誤引起的,所以我們需要一個處理這些失敗的消息的地方,比如在啓動的時候自動拉取 DLQ 中的消息然後轉發到原來的 queue 中去遠程原有的業務邏輯,如果處理不了那麼還是會繼續進入到 DLQ 中。

  • 在啓動的時候拉取 DLQ 中的消息轉發到原來的 queue 中。
@Component
public class DLXHandler implements ApplicationListener<ContextRefreshedEvent>, ApplicationContextAware {

    private final RabbitTemplate rabbitTemplate;

    private ApplicationContext applicationContext;

    private static final String DLQ = "contract-error.dlq";

    @Autowired
    public DLXHandler(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        // SCS 會創建一個 child context ,這裏需要判斷下正確的 context 初始化完成
        if (event.getApplicationContext().equals(this.applicationContext)) {
            // 啓動後獲取 dlq 中所有的消息,進行消費
            Message message = rabbitTemplate.receive(DLQ);
            while (message != null) {
                rabbitTemplate.send(message.getMessageProperties().getReceivedRoutingKey(), message);
                message = rabbitTemplate.receive(DLQ);
            }
        }

    }
}

由於 SCS 沒有提供給我們類似的接口,這裏使用了 rabbitmq 的接口來獲取消息。

完善之前的 CQRS 例子

經常上述這些基礎操作之後,匯過來實現 CQRS 就比較清晰了,只需要監聽相關的事件,然後更新視圖層即可。

  1. 添加時間的監聽
    @StreamEventHandler(types = ChannelDefinition.CONTRACTS_INPUT)
    public void handle(ContractCreatedEvent event, DomainEvent<ContractCreatedEvent, HashMap> domainEvent) {
        QueryContractCommand command = new QueryContractCommand(event.getIdentifier(), domainEvent.getTimestamp());

        ContractAggregate aggregate = queryGateway.query(command, ContractAggregate.class).join();

        ContractView view = new ContractView();
        view.setIndustryName(aggregate.getIndustryName());
        view.setId(aggregate.getIdentifier());
        view.setPartyB(aggregate.getPartyB());
        view.setPartyA(aggregate.getPartyA());
        view.setName(aggregate.getName());
        view.setDeleted(aggregate.isDeleted());

        contractViewRepository.save(view);
    }

StreamDomainEventDispatcher 對傳參做了一些處理,當有兩個參數的時候會將 DomainEvent 傳遞,因爲有些時候可能會用到一些字段,比如時間、附加信息等等。這裏在消費事件的時候,可以根據時間去查詢 aggregate 的狀態,然後直接做一個映射,也可以根據事件直接對 view 層做 CUD ,個人覺得在性能和速度不存在大問題的時候直接去查詢一下 aggregate 當時的狀態做一個映射即可,畢竟比較簡單。

  1. 刪除原來的 ContractViewHandler 即可。完整的例子 - branch session6

作者介紹:

周國勇,目前就職於杭州匠人網絡創業,致力於樓宇資產管理的 SaaS 化,負責後端業務架構設計、項目管理,喜歡對業務模型的分析,熱衷新技術的探索和實踐,經常在踩坑的路上越走越遠。

相關文章:

《Event Sourcing 和 CQRS 落地(一):UID-Generator 實現》
《Event Sourcing 和 CQRS 落地(二):Event-Sourcing 實現》
《Event Sourcing 和 CQRS 落地(三):CQRS 實現》
《Event Sourcing 和 CQRS 落地(四):深入使用 -Axon》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章