feat: make gb32960 archive history query production ready
This commit is contained in:
62
modules/core/ingest-core/pom.xml
Normal file
62
modules/core/ingest-core/pom.xml
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<relativePath>../../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>ingest-core</artifactId>
|
||||
<name>ingest-core</name>
|
||||
<description>Dispatcher / Interceptor Chain / Disruptor Event Bus / 注解扫描与自动注册。</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-codec-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lmax</groupId>
|
||||
<artifactId>disruptor</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-ratelimiter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-circuitbreaker</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-all</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.lingniu.ingest.core.concurrency;
|
||||
|
||||
import com.lingniu.ingest.api.annotation.AsyncBatch;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerDefinition;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* {@link AsyncBatch} 注解的执行器:把 Handler 方法从"单条调用"变成"批量调用"。
|
||||
*
|
||||
* <p>每个 Handler 方法对应一个 {@link Batcher},背后是一个固定容量的 {@link BlockingQueue}
|
||||
* 和 {@link AsyncBatch#poolSize()} 条守护虚拟线程。批量触发条件:
|
||||
* <ul>
|
||||
* <li>累积 {@link AsyncBatch#size()} 条立即 flush
|
||||
* <li>从第一条入队起等待 {@link AsyncBatch#waitMs()} 毫秒后强制 flush
|
||||
* </ul>
|
||||
*
|
||||
* <p>目标 Handler 方法签名约定:
|
||||
* <pre>{@code
|
||||
* @MessageMapping(...)
|
||||
* @AsyncBatch(size = 4000, waitMs = 1000, poolSize = 2)
|
||||
* public List<VehicleEvent> onBatch(List<PayloadType> batch) { ... }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>flush 产出的事件由构造器注入的 {@code eventPublisher} 异步投递(通常是
|
||||
* {@link DisruptorEventBus#publish}),不阻塞 Netty EventLoop。
|
||||
*/
|
||||
public final class AsyncBatchExecutor implements AutoCloseable {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AsyncBatchExecutor.class);
|
||||
|
||||
private final Consumer<VehicleEvent> eventPublisher;
|
||||
private final ConcurrentMap<Method, Batcher> batchers = new ConcurrentHashMap<>();
|
||||
|
||||
public AsyncBatchExecutor(Consumer<VehicleEvent> eventPublisher) {
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
/** 供 Dispatcher 调用:把单条消息交给目标 Handler 的 batcher。 */
|
||||
public void submit(HandlerDefinition def, Object message) {
|
||||
Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher));
|
||||
b.offer(new BatchItem(message, UnaryOperator.identity(), false));
|
||||
}
|
||||
|
||||
public void submit(HandlerDefinition def, Object message, UnaryOperator<VehicleEvent> eventTransformer) {
|
||||
Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher));
|
||||
b.offer(new BatchItem(message, eventTransformer == null ? UnaryOperator.identity() : eventTransformer, true));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
batchers.values().forEach(Batcher::close);
|
||||
batchers.clear();
|
||||
log.info("AsyncBatchExecutor closed");
|
||||
}
|
||||
|
||||
// ===== internals =====
|
||||
|
||||
private static final class Batcher implements AutoCloseable {
|
||||
private final HandlerDefinition def;
|
||||
private final Consumer<VehicleEvent> publisher;
|
||||
private final int batchSize;
|
||||
private final long waitMs;
|
||||
private final BlockingQueue<BatchItem> queue;
|
||||
private final Thread[] workers;
|
||||
private volatile boolean running = true;
|
||||
|
||||
Batcher(HandlerDefinition def, Consumer<VehicleEvent> publisher) {
|
||||
AsyncBatch cfg = def.asyncBatch();
|
||||
this.def = def;
|
||||
this.publisher = publisher;
|
||||
this.batchSize = Math.max(1, cfg.size());
|
||||
this.waitMs = Math.max(1, cfg.waitMs());
|
||||
this.queue = new ArrayBlockingQueue<>(Math.max(batchSize * 4, 16));
|
||||
int pool = Math.max(1, cfg.poolSize());
|
||||
this.workers = new Thread[pool];
|
||||
for (int i = 0; i < pool; i++) {
|
||||
Thread t = Thread.ofVirtual()
|
||||
.name("batcher-" + def.method().getName() + "-" + i)
|
||||
.unstarted(this::loop);
|
||||
workers[i] = t;
|
||||
t.start();
|
||||
}
|
||||
log.info("batcher started method={} size={} waitMs={} pool={}",
|
||||
def.method().getName(), batchSize, waitMs, pool);
|
||||
}
|
||||
|
||||
void offer(BatchItem item) {
|
||||
try {
|
||||
if (!queue.offer(item, 100, TimeUnit.MILLISECONDS)) {
|
||||
log.warn("batcher queue full, dropping item for {}", def.method().getName());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private void loop() {
|
||||
while (running) {
|
||||
try {
|
||||
BatchItem first = queue.poll(200, TimeUnit.MILLISECONDS);
|
||||
if (first == null) continue;
|
||||
List<BatchItem> buf = new ArrayList<>(batchSize);
|
||||
buf.add(first);
|
||||
long deadlineNanos = System.nanoTime() + waitMs * 1_000_000L;
|
||||
while (buf.size() < batchSize) {
|
||||
long remain = deadlineNanos - System.nanoTime();
|
||||
if (remain <= 0) break;
|
||||
BatchItem next = queue.poll(remain, TimeUnit.NANOSECONDS);
|
||||
if (next == null) break;
|
||||
buf.add(next);
|
||||
}
|
||||
flush(buf);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.error("batcher loop error method={}", def.method().getName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void flush(List<BatchItem> buf) {
|
||||
if (requiresPerItemTransform(buf)) {
|
||||
for (BatchItem item : buf) {
|
||||
flushTransformedItem(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
publishEvents(invoke(buf.stream().map(BatchItem::message).toList()), UnaryOperator.identity());
|
||||
}
|
||||
|
||||
private boolean requiresPerItemTransform(List<BatchItem> buf) {
|
||||
for (BatchItem item : buf) {
|
||||
if (item.transformRequired()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void flushTransformedItem(BatchItem item) {
|
||||
publishEvents(invoke(List.of(item.message())), item.eventTransformer());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<VehicleEvent> invoke(List<Object> messages) {
|
||||
List<VehicleEvent> events;
|
||||
try {
|
||||
Object result = def.method().invoke(def.bean(), messages);
|
||||
events = switch (result) {
|
||||
case null -> List.of();
|
||||
case List<?> list -> (List<VehicleEvent>) list;
|
||||
case VehicleEvent e -> List.of(e);
|
||||
default -> throw new IllegalStateException(
|
||||
"@AsyncBatch method must return List<VehicleEvent>: " + def.method());
|
||||
};
|
||||
} catch (InvocationTargetException e) {
|
||||
log.error("batcher invoke failed method={}", def.method().getName(), e.getCause());
|
||||
return List.of();
|
||||
} catch (IllegalAccessException e) {
|
||||
log.error("batcher access denied method={}", def.method().getName(), e);
|
||||
return List.of();
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private void publishEvents(List<VehicleEvent> events, UnaryOperator<VehicleEvent> eventTransformer) {
|
||||
for (VehicleEvent e : events) {
|
||||
try {
|
||||
publisher.accept(eventTransformer.apply(e));
|
||||
} catch (Exception ex) {
|
||||
log.warn("batcher publish failed eventId={}", e.eventId(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
running = false;
|
||||
for (Thread t : workers) t.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private record BatchItem(Object message,
|
||||
UnaryOperator<VehicleEvent> eventTransformer,
|
||||
boolean transformRequired) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.lingniu.ingest.core.concurrency;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.sink.EventSink;
|
||||
import com.lmax.disruptor.BlockingWaitStrategy;
|
||||
import com.lmax.disruptor.BusySpinWaitStrategy;
|
||||
import com.lmax.disruptor.EventHandler;
|
||||
import com.lmax.disruptor.SleepingWaitStrategy;
|
||||
import com.lmax.disruptor.WaitStrategy;
|
||||
import com.lmax.disruptor.YieldingWaitStrategy;
|
||||
import com.lmax.disruptor.dsl.Disruptor;
|
||||
import com.lmax.disruptor.dsl.ProducerType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 基于 Disruptor 的事件总线:Dispatcher 投递事件 → RingBuffer → Sink 扇出。
|
||||
*
|
||||
* <p>使用虚拟线程作为 Sink 消费线程,避免阻塞 IO 占用平台线程。
|
||||
* 单 VIN 有序性由上游 Netty + 分区投递保证,RingBuffer 不做 per-vin 排序。
|
||||
*/
|
||||
public final class DisruptorEventBus implements AutoCloseable {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DisruptorEventBus.class);
|
||||
|
||||
private final Disruptor<VehicleEventSlot> disruptor;
|
||||
private final AtomicLong published = new AtomicLong();
|
||||
private final AtomicLong failed = new AtomicLong();
|
||||
|
||||
public DisruptorEventBus(int ringBufferSize, String waitStrategyName, List<EventSink> sinks) {
|
||||
ThreadFactory tf = Thread.ofVirtual().name("ingest-bus-", 0).factory();
|
||||
this.disruptor = new Disruptor<>(
|
||||
VehicleEventSlot::new,
|
||||
ringBufferSize,
|
||||
tf,
|
||||
ProducerType.MULTI,
|
||||
waitStrategy(waitStrategyName));
|
||||
|
||||
EventHandler<VehicleEventSlot>[] handlers = sinks.stream()
|
||||
.map(this::toHandler)
|
||||
.toArray(EventHandler[]::new);
|
||||
disruptor.handleEventsWith(handlers);
|
||||
disruptor.start();
|
||||
log.info("DisruptorEventBus started ringBuffer={} wait={} sinks={}",
|
||||
ringBufferSize, waitStrategyName, sinks.size());
|
||||
}
|
||||
|
||||
public void publish(VehicleEvent event) {
|
||||
disruptor.getRingBuffer().publishEvent((slot, seq, e) -> slot.event = e, event);
|
||||
published.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
disruptor.shutdown();
|
||||
log.info("DisruptorEventBus stopped published={} failed={}", published.get(), failed.get());
|
||||
}
|
||||
|
||||
private EventHandler<VehicleEventSlot> toHandler(EventSink sink) {
|
||||
return (slot, seq, endOfBatch) -> {
|
||||
VehicleEvent e = slot.event;
|
||||
if (e == null || !sink.accepts(e)) return;
|
||||
try {
|
||||
sink.publish(e).exceptionally(ex -> {
|
||||
failed.incrementAndGet();
|
||||
log.warn("sink {} publish failed", sink.name(), ex);
|
||||
return null;
|
||||
});
|
||||
} finally {
|
||||
if (endOfBatch) slot.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static WaitStrategy waitStrategy(String name) {
|
||||
return switch (name == null ? "yielding" : name.toLowerCase()) {
|
||||
case "blocking" -> new BlockingWaitStrategy();
|
||||
case "sleeping" -> new SleepingWaitStrategy();
|
||||
case "busy-spin" -> new BusySpinWaitStrategy();
|
||||
default -> new YieldingWaitStrategy();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lingniu.ingest.core.concurrency;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
/** Disruptor RingBuffer 槽位:持有可变引用,避免每次发布都分配新对象。 */
|
||||
public final class VehicleEventSlot {
|
||||
public VehicleEvent event;
|
||||
|
||||
public void clear() {
|
||||
this.event = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.lingniu.ingest.core.config;
|
||||
|
||||
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
|
||||
import com.lingniu.ingest.api.sink.EventSink;
|
||||
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
|
||||
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
|
||||
import com.lingniu.ingest.core.dispatcher.AnnotationHandlerBeanPostProcessor;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerInvoker;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerRegistry;
|
||||
import com.lingniu.ingest.core.pipeline.InterceptorChain;
|
||||
import com.lingniu.ingest.core.pipeline.builtin.DedupInterceptor;
|
||||
import com.lingniu.ingest.core.pipeline.builtin.RateLimitInterceptor;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* ingest-core 的自动装配入口。所有 Bean 都是 {@code @ConditionalOnMissingBean},便于下游替换。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(IngestCoreProperties.class)
|
||||
public class IngestCoreAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public HandlerRegistry handlerRegistry() {
|
||||
return new HandlerRegistry();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public HandlerInvoker handlerInvoker() {
|
||||
return new HandlerInvoker();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AnnotationHandlerBeanPostProcessor annotationHandlerBeanPostProcessor(HandlerRegistry registry) {
|
||||
return new AnnotationHandlerBeanPostProcessor(registry);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.pipeline.dedup", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public DedupInterceptor dedupInterceptor(IngestCoreProperties props) {
|
||||
return new DedupInterceptor(props.getDedup().getCacheSize(), props.getDedup().getTtlSeconds());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RateLimitInterceptor rateLimitInterceptor(IngestCoreProperties props) {
|
||||
return new RateLimitInterceptor(props.getRateLimit().getPerVinQps(), props.getRateLimit().getMaxVins());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public InterceptorChain interceptorChain(List<IngestInterceptor> interceptors) {
|
||||
return new InterceptorChain(interceptors);
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnMissingBean
|
||||
public DisruptorEventBus disruptorEventBus(IngestCoreProperties props, List<EventSink> sinks) {
|
||||
return new DisruptorEventBus(
|
||||
props.getDisruptor().getRingBufferSize(),
|
||||
props.getDisruptor().getWaitStrategy(),
|
||||
sinks);
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnMissingBean
|
||||
public AsyncBatchExecutor asyncBatchExecutor(DisruptorEventBus eventBus) {
|
||||
return new AsyncBatchExecutor(eventBus::publish);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Dispatcher dispatcher(HandlerRegistry registry,
|
||||
InterceptorChain chain,
|
||||
HandlerInvoker invoker,
|
||||
DisruptorEventBus eventBus,
|
||||
AsyncBatchExecutor batchExecutor) {
|
||||
return new Dispatcher(registry, chain, invoker, eventBus, batchExecutor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.lingniu.ingest.core.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.pipeline")
|
||||
public class IngestCoreProperties {
|
||||
|
||||
private Disruptor disruptor = new Disruptor();
|
||||
private Dedup dedup = new Dedup();
|
||||
private RateLimit rateLimit = new RateLimit();
|
||||
|
||||
public Disruptor getDisruptor() { return disruptor; }
|
||||
public void setDisruptor(Disruptor disruptor) { this.disruptor = disruptor; }
|
||||
|
||||
public Dedup getDedup() { return dedup; }
|
||||
public void setDedup(Dedup dedup) { this.dedup = dedup; }
|
||||
|
||||
public RateLimit getRateLimit() { return rateLimit; }
|
||||
public void setRateLimit(RateLimit rateLimit) { this.rateLimit = rateLimit; }
|
||||
|
||||
public static class Disruptor {
|
||||
private int ringBufferSize = 131072;
|
||||
private String waitStrategy = "yielding";
|
||||
private String producerType = "multi";
|
||||
|
||||
public int getRingBufferSize() { return ringBufferSize; }
|
||||
public void setRingBufferSize(int ringBufferSize) { this.ringBufferSize = ringBufferSize; }
|
||||
public String getWaitStrategy() { return waitStrategy; }
|
||||
public void setWaitStrategy(String waitStrategy) { this.waitStrategy = waitStrategy; }
|
||||
public String getProducerType() { return producerType; }
|
||||
public void setProducerType(String producerType) { this.producerType = producerType; }
|
||||
}
|
||||
|
||||
public static class Dedup {
|
||||
private boolean enabled = true;
|
||||
private int cacheSize = 200000;
|
||||
private long ttlSeconds = 600;
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public int getCacheSize() { return cacheSize; }
|
||||
public void setCacheSize(int cacheSize) { this.cacheSize = cacheSize; }
|
||||
public long getTtlSeconds() { return ttlSeconds; }
|
||||
public void setTtlSeconds(long ttlSeconds) { this.ttlSeconds = ttlSeconds; }
|
||||
}
|
||||
|
||||
public static class RateLimit {
|
||||
private int perVinQps = 50;
|
||||
private int maxVins = 100000;
|
||||
|
||||
public int getPerVinQps() { return perVinQps; }
|
||||
public void setPerVinQps(int perVinQps) { this.perVinQps = perVinQps; }
|
||||
public int getMaxVins() { return maxVins; }
|
||||
public void setMaxVins(int maxVins) { this.maxVins = maxVins; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.lingniu.ingest.core.dispatcher;
|
||||
|
||||
import com.lingniu.ingest.api.annotation.AsyncBatch;
|
||||
import com.lingniu.ingest.api.annotation.IdempotentKey;
|
||||
import com.lingniu.ingest.api.annotation.MessageMapping;
|
||||
import com.lingniu.ingest.api.annotation.ProtocolHandler;
|
||||
import com.lingniu.ingest.api.annotation.RateLimited;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.core.annotation.AnnotatedElementUtils;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Spring BeanPostProcessor:扫描带 {@link ProtocolHandler} 注解的 Bean,
|
||||
* 把每个带 {@link MessageMapping} 的方法注册到 {@link HandlerRegistry}。
|
||||
*
|
||||
* <p>替代旧代码里遍布的 {@code if (msgId == 0x0100) ... else if (msgId == 0x0102) ...} 风格。
|
||||
*/
|
||||
public class AnnotationHandlerBeanPostProcessor implements BeanPostProcessor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AnnotationHandlerBeanPostProcessor.class);
|
||||
|
||||
private final HandlerRegistry registry;
|
||||
|
||||
public AnnotationHandlerBeanPostProcessor(HandlerRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
|
||||
Class<?> type = bean.getClass();
|
||||
ProtocolHandler classAnno = AnnotatedElementUtils.findMergedAnnotation(type, ProtocolHandler.class);
|
||||
if (classAnno == null) return bean;
|
||||
|
||||
for (Method method : type.getMethods()) {
|
||||
MessageMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, MessageMapping.class);
|
||||
if (mapping == null) continue;
|
||||
|
||||
Class<?> paramType = method.getParameterCount() > 0 ? method.getParameterTypes()[0] : Object.class;
|
||||
int[] commands = mapping.command().length == 0 ? new int[]{0} : mapping.command();
|
||||
int[] infoTypes = mapping.infoType().length == 0 ? new int[]{0} : mapping.infoType();
|
||||
|
||||
RateLimited rl = AnnotatedElementUtils.findMergedAnnotation(method, RateLimited.class);
|
||||
IdempotentKey ik = AnnotatedElementUtils.findMergedAnnotation(method, IdempotentKey.class);
|
||||
AsyncBatch ab = AnnotatedElementUtils.findMergedAnnotation(method, AsyncBatch.class);
|
||||
|
||||
for (int cmd : commands) {
|
||||
for (int info : infoTypes) {
|
||||
HandlerDefinition def = new HandlerDefinition(
|
||||
classAnno.protocol(), cmd, info, mapping.desc(),
|
||||
bean, method, paramType, rl, ik, ab);
|
||||
registry.register(def);
|
||||
log.info("registered handler {} protocol={} command=0x{} info=0x{}",
|
||||
type.getSimpleName() + "#" + method.getName(),
|
||||
classAnno.protocol(), Integer.toHexString(cmd), Integer.toHexString(info));
|
||||
}
|
||||
}
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.lingniu.ingest.core.dispatcher;
|
||||
|
||||
import com.lingniu.ingest.api.event.RawArchiveKeys;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
|
||||
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
|
||||
import com.lingniu.ingest.core.pipeline.InterceptorChain;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 核心分发器:RawFrame → 拦截链 → Handler → 事件 → Disruptor EventBus。
|
||||
*
|
||||
* <p>所有 Inbound Adapter(Netty / MQTT / PushClient)都调用 {@link #dispatch(RawFrame)},
|
||||
* 协议差异在这里被彻底抹平。
|
||||
*
|
||||
* <p>对于带 {@code @AsyncBatch} 的 Handler,Dispatcher 不直接反射调用,而是把消息交给
|
||||
* {@link AsyncBatchExecutor} 进行聚合 → 批量调用 → 异步发布事件。
|
||||
*/
|
||||
public final class Dispatcher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Dispatcher.class);
|
||||
private static final AtomicLong RAW_ARCHIVE_SEQUENCE = new AtomicLong();
|
||||
|
||||
private final HandlerRegistry registry;
|
||||
private final InterceptorChain interceptors;
|
||||
private final HandlerInvoker invoker;
|
||||
private final DisruptorEventBus eventBus;
|
||||
private final AsyncBatchExecutor batchExecutor;
|
||||
|
||||
public Dispatcher(HandlerRegistry registry,
|
||||
InterceptorChain interceptors,
|
||||
HandlerInvoker invoker,
|
||||
DisruptorEventBus eventBus,
|
||||
AsyncBatchExecutor batchExecutor) {
|
||||
this.registry = registry;
|
||||
this.interceptors = interceptors;
|
||||
this.invoker = invoker;
|
||||
this.eventBus = eventBus;
|
||||
this.batchExecutor = batchExecutor;
|
||||
}
|
||||
|
||||
public void dispatch(RawFrame frame) {
|
||||
IngestContext ctx = new IngestContext(UUID.randomUUID().toString());
|
||||
try {
|
||||
// 在 interceptor 之前发 RawArchive,保证原始字节被无条件落盘(dedup/rate-limit
|
||||
// 不会过滤它),满足"原始可回放"目标。archive 的写盘靠下游 ArchiveEventSink 消费。
|
||||
RawArchiveLookup rawArchive = emitRawArchive(frame, ctx);
|
||||
|
||||
if (!interceptors.before(frame, ctx)) {
|
||||
log.debug("frame aborted: {}", ctx.abortReason());
|
||||
return;
|
||||
}
|
||||
|
||||
List<HandlerDefinition> handlers = registry.resolve(
|
||||
frame.protocolId(), frame.command(), frame.infoType());
|
||||
if (handlers.isEmpty()) {
|
||||
log.debug("no handler for {} cmd=0x{} info=0x{}",
|
||||
frame.protocolId(), Integer.toHexString(frame.command()),
|
||||
Integer.toHexString(frame.infoType()));
|
||||
return;
|
||||
}
|
||||
|
||||
for (HandlerDefinition def : handlers) {
|
||||
if (def.asyncBatch() != null) {
|
||||
batchExecutor.submit(def, frame.payload(), event -> enrichWithRawArchive(event, rawArchive));
|
||||
continue;
|
||||
}
|
||||
List<VehicleEvent> events = invoker.invoke(def, frame, ctx);
|
||||
for (VehicleEvent e : events) {
|
||||
e = enrichWithRawArchive(e, rawArchive);
|
||||
interceptors.after(e, ctx);
|
||||
eventBus.publish(e);
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
log.error("dispatch failure traceId={}", ctx.traceId(), t);
|
||||
interceptors.onError(t, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 {@link RawFrame} 构造一条 {@link VehicleEvent.RawArchive} 发到 EventBus。
|
||||
* 仅在 {@code rawBytes} 非空时发——有些入站适配器(未来)可能只传解析后的对象。
|
||||
*
|
||||
* <p>VIN 取自 sourceMeta 里的 {@code vin} key(由各入站适配器负责填充);缺失时
|
||||
* 留空字符串,archive sink 会用 "unknown-vin" 占位保证 key 可解析。
|
||||
*/
|
||||
private RawArchiveLookup emitRawArchive(RawFrame frame, IngestContext ctx) {
|
||||
byte[] bytes = frame.rawBytes();
|
||||
if (bytes == null || bytes.length == 0) return RawArchiveLookup.empty();
|
||||
|
||||
Map<String, String> meta = frame.sourceMeta() == null ? Map.of() : frame.sourceMeta();
|
||||
String vin = meta.getOrDefault("vin", "");
|
||||
Instant ingestTime = frame.receivedAt() != null ? frame.receivedAt() : Instant.now();
|
||||
String eventId = nextRawArchiveEventId(ingestTime);
|
||||
String key = RawArchiveKeys.key(ingestTime, frame.protocolId(), vin, eventId);
|
||||
Map<String, String> archiveMeta = addRawArchiveMetadata(meta, eventId, key);
|
||||
|
||||
VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive(
|
||||
eventId,
|
||||
vin,
|
||||
frame.protocolId(),
|
||||
ingestTime,
|
||||
ingestTime,
|
||||
ctx.traceId(),
|
||||
archiveMeta,
|
||||
frame.command(),
|
||||
frame.infoType(),
|
||||
bytes);
|
||||
eventBus.publish(raw);
|
||||
return new RawArchiveLookup(eventId, key, RawArchiveKeys.logicalUri(key));
|
||||
}
|
||||
|
||||
private static String nextRawArchiveEventId(Instant ingestTime) {
|
||||
long base = (ingestTime == null ? Instant.now() : ingestTime).toEpochMilli() * 1000L;
|
||||
long next = RAW_ARCHIVE_SEQUENCE.updateAndGet(previous -> Math.max(previous + 1, base));
|
||||
return Long.toString(next);
|
||||
}
|
||||
|
||||
private static Map<String, String> addRawArchiveMetadata(Map<String, String> metadata,
|
||||
String eventId,
|
||||
String key) {
|
||||
Map<String, String> out = new LinkedHashMap<>(metadata == null ? Map.of() : metadata);
|
||||
out.putIfAbsent(RawArchiveKeys.META_EVENT_ID, eventId);
|
||||
out.putIfAbsent(RawArchiveKeys.META_KEY, key);
|
||||
out.putIfAbsent(RawArchiveKeys.META_URI, RawArchiveKeys.logicalUri(key));
|
||||
return Map.copyOf(out);
|
||||
}
|
||||
|
||||
private static VehicleEvent enrichWithRawArchive(VehicleEvent event, RawArchiveLookup rawArchive) {
|
||||
if (event == null || rawArchive.isEmpty() || event instanceof VehicleEvent.RawArchive) {
|
||||
return event;
|
||||
}
|
||||
Map<String, String> metadata = addRawArchiveMetadata(event.metadata(), rawArchive.eventId(), rawArchive.key());
|
||||
return switch (event) {
|
||||
case VehicleEvent.Realtime e -> new VehicleEvent.Realtime(
|
||||
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
|
||||
e.traceId(), metadata, e.payload());
|
||||
case VehicleEvent.Location e -> new VehicleEvent.Location(
|
||||
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
|
||||
e.traceId(), metadata, e.payload());
|
||||
case VehicleEvent.Alarm e -> new VehicleEvent.Alarm(
|
||||
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
|
||||
e.traceId(), metadata, e.payload());
|
||||
case VehicleEvent.Login e -> new VehicleEvent.Login(
|
||||
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
|
||||
e.traceId(), metadata, e.iccid(), e.protocolVersion());
|
||||
case VehicleEvent.Logout e -> new VehicleEvent.Logout(
|
||||
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
|
||||
e.traceId(), metadata);
|
||||
case VehicleEvent.Heartbeat e -> new VehicleEvent.Heartbeat(
|
||||
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
|
||||
e.traceId(), metadata);
|
||||
case VehicleEvent.MediaMeta e -> new VehicleEvent.MediaMeta(
|
||||
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
|
||||
e.traceId(), metadata, e.mediaId(), e.mediaType(), e.sizeBytes(), e.archiveRef());
|
||||
case VehicleEvent.Passthrough e -> new VehicleEvent.Passthrough(
|
||||
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
|
||||
e.traceId(), metadata, e.passthroughType(), e.data());
|
||||
case VehicleEvent.RawArchive e -> e;
|
||||
};
|
||||
}
|
||||
|
||||
private record RawArchiveLookup(String eventId, String key, String uri) {
|
||||
private static RawArchiveLookup empty() {
|
||||
return new RawArchiveLookup("", "", "");
|
||||
}
|
||||
|
||||
private boolean isEmpty() {
|
||||
return key == null || key.isBlank();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.lingniu.ingest.core.dispatcher;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.annotation.AsyncBatch;
|
||||
import com.lingniu.ingest.api.annotation.IdempotentKey;
|
||||
import com.lingniu.ingest.api.annotation.RateLimited;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* 一个被注解扫描到的 Handler 方法的静态描述。不可变。
|
||||
*/
|
||||
public record HandlerDefinition(
|
||||
ProtocolId protocol,
|
||||
int command,
|
||||
int infoType,
|
||||
String desc,
|
||||
Object bean,
|
||||
Method method,
|
||||
Class<?> parameterType,
|
||||
RateLimited rateLimited,
|
||||
IdempotentKey idempotentKey,
|
||||
AsyncBatch asyncBatch
|
||||
) {
|
||||
public boolean matches(ProtocolId p, int cmd, int info) {
|
||||
if (p != protocol) return false;
|
||||
if (command != 0 && command != cmd) return false;
|
||||
if (infoType != 0 && infoType != info) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.lingniu.ingest.core.dispatcher;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 反射调用 Handler。单独抽出是为了后续可插拔:未来可替换为 MethodHandle 或字节码生成。
|
||||
*/
|
||||
public class HandlerInvoker {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<VehicleEvent> invoke(HandlerDefinition def, RawFrame frame, IngestContext ctx) {
|
||||
try {
|
||||
Object result = def.method().invoke(def.bean(), frame.payload());
|
||||
return switch (result) {
|
||||
case null -> Collections.emptyList();
|
||||
case VehicleEvent e -> List.of(e);
|
||||
case List<?> list -> (List<VehicleEvent>) list;
|
||||
default -> throw new IllegalStateException(
|
||||
"Handler return type must be VehicleEvent or List<VehicleEvent>: " + def.method());
|
||||
};
|
||||
} catch (InvocationTargetException e) {
|
||||
throw new RuntimeException("Handler threw exception: " + def.method(), e.getCause());
|
||||
} catch (IllegalAccessException e) {
|
||||
throw new RuntimeException("Handler not accessible: " + def.method(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.lingniu.ingest.core.dispatcher;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Handler 注册中心。按 {@code (ProtocolId, command)} 索引,O(1) 查找;同 key 可注册多个 Handler,由
|
||||
* {@link HandlerDefinition#matches} 进一步精确匹配 {@code infoType}。
|
||||
*/
|
||||
public final class HandlerRegistry {
|
||||
|
||||
private final Map<RoutingKey, List<HandlerDefinition>> byRoute = new ConcurrentHashMap<>();
|
||||
private final List<HandlerDefinition> wildcardHandlers = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
public void register(HandlerDefinition def) {
|
||||
if (def.command() == 0) {
|
||||
wildcardHandlers.add(def);
|
||||
return;
|
||||
}
|
||||
byRoute.computeIfAbsent(new RoutingKey(def.protocol(), def.command()),
|
||||
k -> Collections.synchronizedList(new ArrayList<>())).add(def);
|
||||
}
|
||||
|
||||
public List<HandlerDefinition> resolve(ProtocolId protocol, int command, int infoType) {
|
||||
List<HandlerDefinition> exact = byRoute.get(new RoutingKey(protocol, command));
|
||||
List<HandlerDefinition> result = new ArrayList<>();
|
||||
if (exact != null) {
|
||||
for (HandlerDefinition d : exact) {
|
||||
if (d.matches(protocol, command, infoType)) result.add(d);
|
||||
}
|
||||
}
|
||||
if (!result.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
for (HandlerDefinition d : wildcardHandlers) {
|
||||
if (d.matches(protocol, command, infoType)) result.add(d);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return byRoute.values().stream().mapToInt(List::size).sum() + wildcardHandlers.size();
|
||||
}
|
||||
|
||||
private record RoutingKey(ProtocolId protocol, int command) {}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.lingniu.ingest.core.pipeline;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 顺序执行的拦截器链,通过 Spring {@code @Order} 或 {@code Ordered} 排序。
|
||||
*/
|
||||
public final class InterceptorChain {
|
||||
|
||||
private final List<IngestInterceptor> interceptors;
|
||||
|
||||
public InterceptorChain(List<IngestInterceptor> interceptors) {
|
||||
List<IngestInterceptor> sorted = new ArrayList<>(interceptors);
|
||||
AnnotationAwareOrderComparator.sort(sorted);
|
||||
this.interceptors = List.copyOf(sorted);
|
||||
}
|
||||
|
||||
public boolean before(RawFrame frame, IngestContext ctx) {
|
||||
for (IngestInterceptor i : interceptors) {
|
||||
if (!i.before(frame, ctx) || ctx.aborted()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void after(VehicleEvent event, IngestContext ctx) {
|
||||
for (IngestInterceptor i : interceptors) {
|
||||
i.after(event, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
public void onError(Throwable error, IngestContext ctx) {
|
||||
for (IngestInterceptor i : interceptors) {
|
||||
i.onError(error, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.lingniu.ingest.core.pipeline.builtin;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 基于 Caffeine 的本地幂等去重。
|
||||
*
|
||||
* <p>Key 构造:{@code protocolId + command + sourceMeta.seq} —— 真实实现可以接入 Redis 做多节点一致性,
|
||||
* 本实现只覆盖单节点场景,满足第一阶段 PoC 需求。
|
||||
*/
|
||||
public class DedupInterceptor implements IngestInterceptor, Ordered {
|
||||
|
||||
private final Cache<String, Boolean> seen;
|
||||
|
||||
public DedupInterceptor(int maxSize, long ttlSeconds) {
|
||||
this.seen = Caffeine.newBuilder()
|
||||
.maximumSize(maxSize)
|
||||
.expireAfterWrite(Duration.ofSeconds(ttlSeconds))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean before(RawFrame frame, IngestContext ctx) {
|
||||
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
|
||||
String seq = frame.sourceMeta().get("seq");
|
||||
if (seq == null || seq.isBlank()) {
|
||||
seq = rawFingerprint(frame);
|
||||
}
|
||||
String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq;
|
||||
if (seen.asMap().putIfAbsent(key, Boolean.TRUE) != null) {
|
||||
ctx.abort("duplicate:" + key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return 100;
|
||||
}
|
||||
|
||||
private static String rawFingerprint(RawFrame frame) {
|
||||
byte[] rawBytes = frame.rawBytes();
|
||||
if (rawBytes != null && rawBytes.length > 0) {
|
||||
return "raw:" + Integer.toHexString(Arrays.hashCode(rawBytes));
|
||||
}
|
||||
return "receivedAt:" + frame.receivedAt();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.lingniu.ingest.core.pipeline.builtin;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 单 VIN 速率限制。每个 VIN 一个独立的 Resilience4j RateLimiter,由 Caffeine 按 LRU 管理。
|
||||
*/
|
||||
public class RateLimitInterceptor implements IngestInterceptor, Ordered {
|
||||
|
||||
private final Cache<String, RateLimiter> limiters;
|
||||
private final RateLimiterConfig defaultConfig;
|
||||
|
||||
public RateLimitInterceptor(int perVinQps, int maxVins) {
|
||||
this.defaultConfig = RateLimiterConfig.custom()
|
||||
.limitForPeriod(perVinQps)
|
||||
.limitRefreshPeriod(Duration.ofSeconds(1))
|
||||
.timeoutDuration(Duration.ZERO)
|
||||
.build();
|
||||
this.limiters = Caffeine.newBuilder()
|
||||
.maximumSize(maxVins)
|
||||
.expireAfterAccess(Duration.ofMinutes(10))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean before(RawFrame frame, IngestContext ctx) {
|
||||
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
|
||||
RateLimiter rl = limiters.get(vin, k -> RateLimiter.of("vin-" + k, defaultConfig));
|
||||
if (!rl.acquirePermission()) {
|
||||
ctx.abort("rate-limited:" + vin);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return 200;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.core.config.IngestCoreAutoConfiguration
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.lingniu.ingest.core.dispatcher;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.annotation.AsyncBatch;
|
||||
import com.lingniu.ingest.api.annotation.MessageMapping;
|
||||
import com.lingniu.ingest.api.annotation.ProtocolHandler;
|
||||
import com.lingniu.ingest.api.event.LocationPayload;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.api.sink.EventSink;
|
||||
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
|
||||
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
|
||||
import com.lingniu.ingest.core.pipeline.InterceptorChain;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DispatcherRawArchiveMetadataTest {
|
||||
|
||||
@Test
|
||||
void enrichesBusinessEventsWithRawArchiveLookupMetadata() throws Exception {
|
||||
CollectingSink sink = new CollectingSink();
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
try {
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registryWithLocationHandler(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
|
||||
dispatcher.dispatch(new RawFrame(
|
||||
ProtocolId.JT808,
|
||||
0x0200,
|
||||
0,
|
||||
new Object(),
|
||||
new byte[]{0x7e, 0x01, 0x7e},
|
||||
Map.of("vin", "VIN001", "peer", "127.0.0.1:10001"),
|
||||
Instant.parse("2026-06-22T08:00:00Z")));
|
||||
|
||||
VehicleEvent.RawArchive raw = awaitEvent(sink, VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.Location location = awaitEvent(sink, VehicleEvent.Location.class);
|
||||
|
||||
String rawArchiveKey = raw.metadata().get("rawArchiveKey");
|
||||
assertThat(rawArchiveKey).isNotBlank();
|
||||
assertThat(raw.eventId()).containsOnlyDigits();
|
||||
assertThat(rawArchiveKey).endsWith("/" + raw.eventId() + ".bin");
|
||||
assertThat(location.metadata())
|
||||
.containsEntry("rawArchiveEventId", raw.eventId())
|
||||
.containsEntry("rawArchiveKey", rawArchiveKey)
|
||||
.containsEntry("rawArchiveUri", "archive://" + rawArchiveKey);
|
||||
} finally {
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void enrichesAsyncBatchEventsWithTheirRawArchiveLookupMetadata() throws Exception {
|
||||
CollectingSink sink = new CollectingSink();
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
try {
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registryWithAsyncLocationHandler(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
|
||||
dispatcher.dispatch(new RawFrame(
|
||||
ProtocolId.JT808,
|
||||
0x0201,
|
||||
0,
|
||||
"payload-1",
|
||||
new byte[]{0x7e, 0x02, 0x7e},
|
||||
Map.of("vin", "VIN002"),
|
||||
Instant.parse("2026-06-22T08:01:00Z")));
|
||||
|
||||
VehicleEvent.RawArchive raw = awaitEvent(sink, VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.Location location = awaitLocationEvent(sink, "async-location-1");
|
||||
|
||||
String rawArchiveKey = raw.metadata().get("rawArchiveKey");
|
||||
assertThat(location.metadata())
|
||||
.containsEntry("rawArchiveEventId", raw.eventId())
|
||||
.containsEntry("rawArchiveKey", rawArchiveKey)
|
||||
.containsEntry("rawArchiveUri", "archive://" + rawArchiveKey);
|
||||
} finally {
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static HandlerRegistry registryWithLocationHandler() throws NoSuchMethodException {
|
||||
LocationHandler bean = new LocationHandler();
|
||||
Method method = LocationHandler.class.getDeclaredMethod("onLocation", Object.class);
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
registry.register(new HandlerDefinition(
|
||||
ProtocolId.JT808,
|
||||
0x0200,
|
||||
0,
|
||||
"test-location",
|
||||
bean,
|
||||
method,
|
||||
Object.class,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static HandlerRegistry registryWithAsyncLocationHandler() throws NoSuchMethodException {
|
||||
AsyncLocationHandler bean = new AsyncLocationHandler();
|
||||
Method method = AsyncLocationHandler.class.getDeclaredMethod("onLocationBatch", List.class);
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
registry.register(new HandlerDefinition(
|
||||
ProtocolId.JT808,
|
||||
0x0201,
|
||||
0,
|
||||
"test-async-location",
|
||||
bean,
|
||||
method,
|
||||
Object.class,
|
||||
null,
|
||||
null,
|
||||
method.getAnnotation(AsyncBatch.class)));
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static <T extends VehicleEvent> T awaitEvent(CollectingSink sink, Class<T> type) throws InterruptedException {
|
||||
long deadline = System.currentTimeMillis() + 3000;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
synchronized (sink.events) {
|
||||
for (VehicleEvent event : sink.events) {
|
||||
if (type.isInstance(event)) {
|
||||
return type.cast(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
Thread.sleep(25);
|
||||
}
|
||||
throw new AssertionError("event not published: " + type.getSimpleName() + ", actual=" + sink.events);
|
||||
}
|
||||
|
||||
private static VehicleEvent.Location awaitLocationEvent(CollectingSink sink, String eventId) throws InterruptedException {
|
||||
long deadline = System.currentTimeMillis() + 3000;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
synchronized (sink.events) {
|
||||
for (VehicleEvent event : sink.events) {
|
||||
if (event instanceof VehicleEvent.Location location && location.eventId().equals(eventId)) {
|
||||
return location;
|
||||
}
|
||||
}
|
||||
}
|
||||
Thread.sleep(25);
|
||||
}
|
||||
throw new AssertionError("location event not published: " + eventId + ", actual=" + sink.events);
|
||||
}
|
||||
|
||||
@ProtocolHandler(protocol = ProtocolId.JT808)
|
||||
static final class LocationHandler {
|
||||
@MessageMapping(command = 0x0200)
|
||||
VehicleEvent.Location onLocation(Object ignored) {
|
||||
return new VehicleEvent.Location(
|
||||
"location-1",
|
||||
"VIN001",
|
||||
ProtocolId.JT808,
|
||||
Instant.parse("2026-06-22T08:00:00Z"),
|
||||
Instant.parse("2026-06-22T08:00:01Z"),
|
||||
"trace-location",
|
||||
Map.of(),
|
||||
new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@ProtocolHandler(protocol = ProtocolId.JT808)
|
||||
public static final class AsyncLocationHandler {
|
||||
@MessageMapping(command = 0x0201)
|
||||
@AsyncBatch(size = 2, waitMs = 10, poolSize = 1)
|
||||
public List<VehicleEvent> onLocationBatch(List<Object> batch) {
|
||||
return List.of(new VehicleEvent.Location(
|
||||
"async-location-1",
|
||||
"VIN002",
|
||||
ProtocolId.JT808,
|
||||
Instant.parse("2026-06-22T08:01:00Z"),
|
||||
Instant.parse("2026-06-22T08:01:01Z"),
|
||||
"trace-async-location",
|
||||
Map.of(),
|
||||
new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CollectingSink implements EventSink {
|
||||
private final List<VehicleEvent> events = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "collecting";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> publish(VehicleEvent event) {
|
||||
synchronized (events) {
|
||||
events.add(event);
|
||||
}
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.lingniu.ingest.core.dispatcher;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HandlerRegistryTest {
|
||||
|
||||
@Test
|
||||
void exactCommandMatchTakesPrecedenceOverWildcardHandlers() throws Exception {
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
Object bean = new SampleHandlers();
|
||||
Method exact = SampleHandlers.class.getMethod("exact", Object.class);
|
||||
Method wildcard = SampleHandlers.class.getMethod("wildcard", Object.class);
|
||||
HandlerDefinition exactDef = definition(0x0200, "exact", bean, exact);
|
||||
HandlerDefinition wildcardDef = definition(0, "wildcard", bean, wildcard);
|
||||
|
||||
registry.register(exactDef);
|
||||
registry.register(wildcardDef);
|
||||
|
||||
assertThat(registry.resolve(ProtocolId.XINDA_PUSH, 0x0200, 0))
|
||||
.containsExactly(exactDef);
|
||||
assertThat(registry.resolve(ProtocolId.XINDA_PUSH, 0x0500, 0))
|
||||
.containsExactly(wildcardDef);
|
||||
}
|
||||
|
||||
private static HandlerDefinition definition(int command, String desc, Object bean, Method method) {
|
||||
return new HandlerDefinition(
|
||||
ProtocolId.XINDA_PUSH,
|
||||
command,
|
||||
0,
|
||||
desc,
|
||||
bean,
|
||||
method,
|
||||
Object.class,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
public static final class SampleHandlers {
|
||||
public void exact(Object ignored) {
|
||||
}
|
||||
|
||||
public void wildcard(Object ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.lingniu.ingest.core.pipeline.builtin;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DedupInterceptorTest {
|
||||
|
||||
@Test
|
||||
void usesRawFingerprintWhenSequenceIsMissing() {
|
||||
DedupInterceptor interceptor = new DedupInterceptor(100, 60);
|
||||
IngestContext ctx = new IngestContext("trace-1");
|
||||
|
||||
RawFrame first = frame(new byte[]{0x23, 0x23, 0x02, 0x01});
|
||||
RawFrame differentPayload = frame(new byte[]{0x23, 0x23, 0x02, 0x02});
|
||||
RawFrame duplicate = frame(new byte[]{0x23, 0x23, 0x02, 0x01});
|
||||
|
||||
assertThat(interceptor.before(first, ctx)).isTrue();
|
||||
assertThat(interceptor.before(differentPayload, ctx)).isTrue();
|
||||
assertThat(interceptor.before(duplicate, ctx)).isFalse();
|
||||
assertThat(ctx.aborted()).isTrue();
|
||||
}
|
||||
|
||||
private static RawFrame frame(byte[] rawBytes) {
|
||||
return new RawFrame(
|
||||
ProtocolId.GB32960,
|
||||
0x02,
|
||||
0,
|
||||
new Object(),
|
||||
rawBytes,
|
||||
Map.of("vin", "TESTSTATVIN000001"),
|
||||
Instant.parse("2026-06-22T13:00:00Z"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user