本系列的上一篇文章重點介紹了Axon實現,本文將主要介紹Spring Cloud 提供的消息中間件的抽象Spring Cloud Stream的優化方法。
Spring Cloud Stream 優化
問題
Spring Cloud Stream
(以下簡稱 SCS )是 Spring Cloud 提供的消息中間件的抽象,但是目前也就支持 kafka 和 rabbitmq,這篇文章主要會討論一下如何讓 SCS 更好的服務我們之前搭建的 Event Sourcing、CQRS 模型。以下是我在使用 SCS 的過程中存在的一些問題:
StreamListener
用來做事件路由分發並不是很理想,SPEL 可能會寫的很長(我嘗試過用自定義註解代替原生的註解,從而達到簡化的目的,但是會出現一些莫名其妙的事件混亂)。- 如果配合之前的模型使用,我們需要保證消息的順序消費,每個方法都需要去 check 事件的當前 seq,很不方便。
- 在沒有 handler 處理某個 type 的事件時,框架會給出一個 warn,然而這個事件可能在 consumer 這裏根本不關心。
解決方案
爲了解決上面的問題,我們可以這麼處理,先統一一個入口將 SCS 的消息接收,然後我們自己構建一個路由系統,將請求分發到我們自己定義的註解方法上,並且在這個過程中將 seq 的檢查也給做了,大體的流程是這個樣子的:
這樣以上幾點問題都會得到解決,下面我們來看看具體如何實現:
- 首先定義一個註解用於接受自己分發的事件:
@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 的默認配置,存在一個致命的問題,那就是當消息處理失敗(重試三次)之後,消息直接沒了,這個相當於就是消息丟失了。那麼解決方案其實也是比較簡單的,一般有兩種解決方案:
- 拒絕這個消息,丟在 broker 原先的隊列裏。
- 將這個消息記錄到一個錯誤的 queue 中等待修復,後續可能將消息轉發回去,也可能直接就刪除了消息(比如重複的消息)。
方案 1 這麼做可能會出的問題就是,這個消息反覆消費,反覆失敗,引起循環問題從而導致服務出現問題,這個就需要在 broker 做一些策略配置了,爲了讓 broker 儘可能的簡單,我們這裏採用方案 2,要實現的流程是這樣的:
- 首先讓 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 就比較清晰了,只需要監聽相關的事件,然後更新視圖層即可。
- 添加時間的監聽
@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 當時的狀態做一個映射即可,畢竟比較簡單。
- 刪除原來的
ContractViewHandler
即可。完整的例子 - branch session6
作者介紹:
周國勇,目前就職於杭州匠人網絡創業,致力於樓宇資產管理的 SaaS 化,負責後端業務架構設計、項目管理,喜歡對業務模型的分析,熱衷新技術的探索和實踐,經常在踩坑的路上越走越遠。
相關文章:
《Event Sourcing 和 CQRS 落地(一):UID-Generator 實現》
《Event Sourcing 和 CQRS 落地(二):Event-Sourcing 實現》
《Event Sourcing 和 CQRS 落地(三):CQRS 實現》
《Event Sourcing 和 CQRS 落地(四):深入使用 -Axon》