From 0c3774e8035bd7d5ae15cf4e4af54b4032856e28 Mon Sep 17 00:00:00 2001 From: chuck <361648887@qq.com> Date: Sat, 4 Jul 2026 01:26:57 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(schedule):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E8=B0=83=E5=BA=A6=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 schedule starter 模块提供基础调度能力 - 实现本地线程池任务调度器 LocalThreadTaskScheduler - 添加任务处理器注册表 TaskHandlerRegistry - 提供默认任务处理器实现 DefaultTaskHandlerRegistry - 集成 XXL-Job 分布式任务调度框架 - 添加 Stream 事件流处理功能 - 实现配置驱动的事件路由机制 - 提供动态监听器注册与管理功能 - 添加 RabbitMQ 消息队列集成支持 - 配置 MyBatis-Plus 数据库访问层 - 添加示例项目验证各项功能完整性 --- pom.xml | 82 +++ structure-infra-sample/pom.xml | 3 + .../config/SampleCoreAutoConfiguration.java | 18 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../src/main/resources/application.yml | 18 + .../src/main/resources/schema.sql | 9 + .../structure-infra-sample-schedule/pom.xml | 52 ++ .../schedule/ScheduleSampleApplication.java | 12 + .../schedule/config/ScheduleDemoConfig.java | 51 ++ .../controller/JobManagerController.java | 100 ++++ .../schedule/handler/DemoTaskHandlers.java | 56 ++ .../src/main/resources/application.yml | 11 + .../schedule/ScheduleIntegrationTest.java | 304 ++++++++++ .../structure-infra-sample-stream/pom.xml | 84 +++ .../sample/stream/StreamApplication.java | 11 + .../sample/stream/config/SecurityConfig.java | 24 + .../consumer/StreamMessageConsumer.java | 53 ++ .../stream/controller/StreamController.java | 73 +++ .../sample/stream/event/DeliveryEvent.java | 22 + .../infra/sample/stream/event/OrderEvent.java | 22 + .../sample/stream/event/PaymentEvent.java | 22 + .../listener/DeliveryEventListener.java | 26 + .../stream/listener/OrderEventListener.java | 31 ++ .../stream/listener/PaymentEventListener.java | 26 + .../src/main/resources/application.yml | 26 + .../sample/stream/StreamEventManagerTest.java | 375 +++++++++++++ .../sample/stream/StreamEventRouterTest.java | 314 +++++++++++ .../stream/config/StreamTestConfig.java | 18 + .../controller/StreamControllerTest.java | 155 ++++++ .../stream/dynamic/DynamicListenerDemo.java | 161 ++++++ .../sample/stream/event/DeliveryEvent.java | 86 +++ .../infra/sample/stream/event/OrderEvent.java | 22 + .../sample/stream/event/PaymentEvent.java | 22 + .../listener/DeliveryEventListener.java | 33 ++ .../stream/listener/OrderEventListener.java | 51 ++ .../stream/listener/PaymentEventListener.java | 35 ++ .../stream/lowcode/LowCodeConfigDemo.java | 32 ++ .../stream/lowcode/LowCodeRouteConfig.java | 94 ++++ .../src/test/resources/application.yml | 20 + .../structure-infra-sample-xxljob/pom.xml | 52 ++ .../infra/sample/xxljob/SampleXxlJob.java | 34 ++ .../sample/xxljob/XxlJobApplication.java | 12 + .../controller/JobManagerController.java | 107 ++++ .../src/main/resources/application.yaml | 9 + .../resources/application-xxljob-test.yml | 15 + structure-infra-schedule-starter/pom.xml | 43 ++ .../AutoScheduleConfiguration.java | 37 ++ .../infra/properties/ScheduleProperties.java | 11 + .../schedule/DefaultTaskHandlerRegistry.java | 37 ++ .../schedule/LocalThreadTaskScheduler.java | 184 ++++++ .../infra/schedule/ScheduleTask.java | 51 ++ .../schedule/SpringTaskSchedulerAdapter.java | 229 ++++++++ .../structure/infra/schedule/TaskHandler.java | 7 + .../infra/schedule/TaskHandlerRegistry.java | 12 + .../infra/schedule/TaskScheduler.java | 20 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../DefaultTaskHandlerRegistryTest.java | 79 +++ .../LocalThreadTaskSchedulerTest.java | 315 +++++++++++ .../SpringTaskSchedulerAdapterTest.java | 150 +++++ structure-infra-starter/pom.xml | 9 + .../AutoScheduleConfiguration.java | 39 ++ .../infra/properties/InfraProperties.java | 5 + structure-infra-stream-starter/README.md | 526 ++++++++++++++++++ structure-infra-stream-starter/pom.xml | 60 ++ .../annotation/StreamEventListener.java | 32 ++ .../stream/annotation/StreamRouteHandler.java | 22 + .../StreamAutoConfiguration.java | 49 ++ .../infra/stream/event/StreamEvent.java | 181 ++++++ .../stream/handler/StreamEventHandler.java | 7 + .../DefaultStreamEventManagerImpl.java | 231 ++++++++ .../stream/manager/ListenerRegistration.java | 121 ++++ .../stream/manager/StreamEventManager.java | 49 ++ .../EventListenerBeanPostProcessor.java | 160 ++++++ ...StreamBindingBeanFactoryPostProcessor.java | 182 ++++++ .../stream/properties/StreamProperties.java | 156 ++++++ .../router/ConfigurableRouteInitializer.java | 82 +++ .../router/DefaultStreamEventRouterImpl.java | 142 +++++ .../router/RouteHandlerBeanPostProcessor.java | 81 +++ .../stream/router/RouteRegistration.java | 119 ++++ .../infra/stream/router/RouterProperties.java | 104 ++++ .../stream/router/StreamEventRouter.java | 31 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + structure-infra-xxljob-starter/pom.xml | 44 ++ .../AutoXxlJobConfiguration.java | 34 ++ .../infra/properties/XxlJobProperties.java | 14 + .../schedule/xxljob/XxlJobTaskScheduler.java | 163 ++++++ .../infra/schedule/xxljob/XxlJobTemplate.java | 16 + .../schedule/xxljob/XxlJobTemplateImpl.java | 101 ++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + 89 files changed, 6722 insertions(+) create mode 100644 structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/SampleCoreAutoConfiguration.java create mode 100644 structure-infra-sample/structure-infra-sample-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 structure-infra-sample/structure-infra-sample-mybatis/src/main/resources/application.yml create mode 100644 structure-infra-sample/structure-infra-sample-mybatis/src/main/resources/schema.sql create mode 100644 structure-infra-sample/structure-infra-sample-schedule/pom.xml create mode 100644 structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/ScheduleSampleApplication.java create mode 100644 structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/config/ScheduleDemoConfig.java create mode 100644 structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/controller/JobManagerController.java create mode 100644 structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/handler/DemoTaskHandlers.java create mode 100644 structure-infra-sample/structure-infra-sample-schedule/src/main/resources/application.yml create mode 100644 structure-infra-sample/structure-infra-sample-schedule/src/test/java/cn/structure/infra/sample/schedule/ScheduleIntegrationTest.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/pom.xml create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/StreamApplication.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/config/SecurityConfig.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/consumer/StreamMessageConsumer.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/controller/StreamController.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/DeliveryEvent.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/OrderEvent.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/PaymentEvent.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/DeliveryEventListener.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/OrderEventListener.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/main/resources/application.yml create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/StreamEventManagerTest.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/StreamEventRouterTest.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/config/StreamTestConfig.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/controller/StreamControllerTest.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/dynamic/DynamicListenerDemo.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/DeliveryEvent.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/OrderEvent.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/PaymentEvent.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/DeliveryEventListener.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/OrderEventListener.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/lowcode/LowCodeConfigDemo.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/lowcode/LowCodeRouteConfig.java create mode 100644 structure-infra-sample/structure-infra-sample-stream/src/test/resources/application.yml create mode 100644 structure-infra-sample/structure-infra-sample-xxljob/pom.xml create mode 100644 structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/SampleXxlJob.java create mode 100644 structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/XxlJobApplication.java create mode 100644 structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/controller/JobManagerController.java create mode 100644 structure-infra-sample/structure-infra-sample-xxljob/src/main/resources/application.yaml create mode 100644 structure-infra-sample/structure-infra-sample-xxljob/src/test/resources/application-xxljob-test.yml create mode 100644 structure-infra-schedule-starter/pom.xml create mode 100644 structure-infra-schedule-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java create mode 100644 structure-infra-schedule-starter/src/main/java/cn/structure/infra/properties/ScheduleProperties.java create mode 100644 structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistry.java create mode 100644 structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java create mode 100644 structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java create mode 100644 structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapter.java create mode 100644 structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandler.java create mode 100644 structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandlerRegistry.java create mode 100644 structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskScheduler.java create mode 100644 structure-infra-schedule-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistryTest.java create mode 100644 structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/LocalThreadTaskSchedulerTest.java create mode 100644 structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapterTest.java create mode 100644 structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java create mode 100644 structure-infra-stream-starter/README.md create mode 100644 structure-infra-stream-starter/pom.xml create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamEventListener.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamRouteHandler.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/configuration/StreamAutoConfiguration.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/event/StreamEvent.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/handler/StreamEventHandler.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/DefaultStreamEventManagerImpl.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/ListenerRegistration.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/StreamEventManager.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/EventListenerBeanPostProcessor.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/StreamBindingBeanFactoryPostProcessor.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/properties/StreamProperties.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/ConfigurableRouteInitializer.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/DefaultStreamEventRouterImpl.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteHandlerBeanPostProcessor.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteRegistration.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouterProperties.java create mode 100644 structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/StreamEventRouter.java create mode 100644 structure-infra-stream-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 structure-infra-xxljob-starter/pom.xml create mode 100644 structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java create mode 100644 structure-infra-xxljob-starter/src/main/java/cn/structure/infra/properties/XxlJobProperties.java create mode 100644 structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTaskScheduler.java create mode 100644 structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplate.java create mode 100644 structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplateImpl.java create mode 100644 structure-infra-xxljob-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/pom.xml b/pom.xml index 6e9190a..0461c07 100644 --- a/pom.xml +++ b/pom.xml @@ -20,20 +20,47 @@ 1.0.0-SNAPSHOT 4.0.6 + 5.0.0 3.5.16 1.4.3 1.1.4 1.0.3 + 2.0.0 + 1.18.32 structure-infra-starter + structure-infra-schedule-starter + structure-infra-xxljob-starter structure-infra-mybatis-plus-starter structure-infra-jpa-starter structure-infra-mongodb-starter structure-infra-elasticsearch-starter + structure-infra-stream-starter + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + sample @@ -138,6 +165,37 @@ ${structure-datascope.version} + + + org.springframework.cloud + spring-cloud-stream + ${spring-cloud-stream.version} + + + + + org.projectlombok + lombok + ${lombok.version} + + + org.springframework.cloud + spring-cloud-stream-binder-kafka + ${spring-cloud-stream.version} + + + + org.springframework.cloud + spring-cloud-stream-binder-rabbit + ${spring-cloud-stream.version} + provided + + + org.springframework.cloud + spring-cloud-stream-test-support + ${spring-cloud-stream.version} + + jakarta.persistence @@ -151,11 +209,35 @@ ${revision} + + cn.structured + structure-infra-schedule-starter + ${revision} + + + + cn.structured + structure-infra-xxljob-starter + ${revision} + + cn.structured structure-infra-mybatis-plus-starter ${revision} + + + cn.structured + structure-infra-stream-starter + ${revision} + + + + cn.structured + structure-job-starter + ${structure-job.version} + diff --git a/structure-infra-sample/pom.xml b/structure-infra-sample/pom.xml index a6d2157..d6a52a4 100644 --- a/structure-infra-sample/pom.xml +++ b/structure-infra-sample/pom.xml @@ -27,6 +27,9 @@ structure-infra-sample-mongodb structure-infra-sample-elasticsearch structure-infra-sample-cqrs + structure-infra-sample-stream + structure-infra-sample-xxljob + structure-infra-sample-schedule \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/SampleCoreAutoConfiguration.java b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/SampleCoreAutoConfiguration.java new file mode 100644 index 0000000..f298469 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/SampleCoreAutoConfiguration.java @@ -0,0 +1,18 @@ +package cn.structure.infra.sample.infra.config; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@EnableCaching +public class SampleCoreAutoConfiguration { + + @Bean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager(); + } + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/structure-infra-sample/structure-infra-sample-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..0701755 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.structure.infra.sample.infra.config.SampleCoreAutoConfiguration \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/main/resources/application.yml b/structure-infra-sample/structure-infra-sample-mybatis/src/main/resources/application.yml new file mode 100644 index 0000000..3846b8a --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/main/resources/application.yml @@ -0,0 +1,18 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + sql: + init: + mode: always + schema-locations: classpath:schema.sql + +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/main/resources/schema.sql b/structure-infra-sample/structure-infra-sample-mybatis/src/main/resources/schema.sql new file mode 100644 index 0000000..3b13648 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/main/resources/schema.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS t_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL, + password VARCHAR(255), + email VARCHAR(255), + age INT, + create_time TIMESTAMP, + update_time TIMESTAMP +); diff --git a/structure-infra-sample/structure-infra-sample-schedule/pom.xml b/structure-infra-sample/structure-infra-sample-schedule/pom.xml new file mode 100644 index 0000000..9301a9f --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-schedule/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + cn.structured + structure-infra-sample + ${revision} + ../pom.xml + + + structure-infra-sample-schedule + structure-infra-sample-schedule + 调度模块示例 + jar + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + + + cn.structured + structure-infra-schedule-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + org.projectlombok + lombok + provided + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/ScheduleSampleApplication.java b/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/ScheduleSampleApplication.java new file mode 100644 index 0000000..a2774a4 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/ScheduleSampleApplication.java @@ -0,0 +1,12 @@ +package cn.structure.infra.sample.schedule; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ScheduleSampleApplication { + + public static void main(String[] args) { + SpringApplication.run(ScheduleSampleApplication.class, args); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/config/ScheduleDemoConfig.java b/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/config/ScheduleDemoConfig.java new file mode 100644 index 0000000..d5c4550 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/config/ScheduleDemoConfig.java @@ -0,0 +1,51 @@ +package cn.structure.infra.sample.schedule.config; + +import cn.structure.infra.schedule.ScheduleTask; +import cn.structure.infra.schedule.TaskScheduler; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Configuration +@DependsOn("demoTaskHandlers") +public class ScheduleDemoConfig { + + @Autowired + private TaskScheduler taskScheduler; + + @PostConstruct + public void initScheduledTasks() { + ScheduleTask fixedRateTask = ScheduleTask.builder() + .taskId("demo-fixed-rate-task") + .taskName("固定速率示例任务") + .handlerName("demo-fixed-rate-handler") + .handlerParam("fixed-rate-param") + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(3000L) + .initialDelay(1000L) + .timeUnit(TimeUnit.MILLISECONDS) + .build(); + taskScheduler.schedule(fixedRateTask); + log.info("已启动固定速率任务: {}", fixedRateTask.getTaskName()); + + ScheduleTask fixedDelayTask = ScheduleTask.builder() + .taskId("demo-fixed-delay-task") + .taskName("固定延迟示例任务") + .handlerName("demo-fixed-delay-handler") + .handlerParam("fixed-delay-param") + .scheduleType(ScheduleTask.ScheduleType.FIXED_DELAY) + .delay(2000L) + .initialDelay(2000L) + .timeUnit(TimeUnit.MILLISECONDS) + .build(); + taskScheduler.schedule(fixedDelayTask); + log.info("已启动固定延迟任务: {}", fixedDelayTask.getTaskName()); + + log.info("所有示例调度任务已启动"); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/controller/JobManagerController.java b/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/controller/JobManagerController.java new file mode 100644 index 0000000..f3c6110 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/controller/JobManagerController.java @@ -0,0 +1,100 @@ +package cn.structure.infra.sample.schedule.controller; + +import cn.structure.infra.schedule.ScheduleTask; +import cn.structure.infra.schedule.TaskScheduler; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/job") +public class JobManagerController { + + @Resource + private TaskScheduler taskScheduler; + + @PostMapping("/add") + public ScheduleTask add( + @RequestParam("taskId") String taskId, + @RequestParam("taskName") String taskName, + @RequestParam("handlerName") String handlerName, + @RequestParam(value = "handlerParam", required = false) String handlerParam, + @RequestParam(value = "cronExpression", defaultValue = "0/5 * * * * ?") String cronExpression) { + + ScheduleTask task = ScheduleTask.builder() + .taskId(taskId) + .taskName(taskName) + .handlerName(handlerName) + .handlerParam(handlerParam) + .scheduleType(ScheduleTask.ScheduleType.CRON) + .cronExpression(cronExpression) + .build(); + + taskScheduler.schedule(task); + return taskScheduler.getTaskInfo(taskId); + } + + @PutMapping("/update/{taskId}") + public ScheduleTask update( + @PathVariable("taskId") String taskId, + @RequestParam(value = "taskName", required = false) String taskName, + @RequestParam(value = "handlerName", required = false) String handlerName, + @RequestParam(value = "handlerParam", required = false) String handlerParam, + @RequestParam(value = "cronExpression", required = false) String cronExpression) { + + ScheduleTask existingTask = taskScheduler.getTaskInfo(taskId); + if (existingTask == null) { + throw new IllegalArgumentException("Task not found: " + taskId); + } + + ScheduleTask.ScheduleTaskBuilder builder = ScheduleTask.builder() + .taskId(taskId) + .taskName(taskName != null ? taskName : existingTask.getTaskName()) + .handlerName(handlerName != null ? handlerName : existingTask.getHandlerName()) + .handlerParam(handlerParam != null ? handlerParam : existingTask.getHandlerParam()) + .scheduleType(existingTask.getScheduleType()); + + if (cronExpression != null) { + builder.cronExpression(cronExpression); + } else if (existingTask.getCronExpression() != null) { + builder.cronExpression(existingTask.getCronExpression()); + } + + ScheduleTask task = builder.build(); + taskScheduler.update(task); + return taskScheduler.getTaskInfo(taskId); + } + + @DeleteMapping("/remove/{taskId}") + public String remove(@PathVariable("taskId") String taskId) { + ScheduleTask task = taskScheduler.getTaskInfo(taskId); + if (task == null) { + return "Task not found: " + taskId; + } + taskScheduler.remove(taskId); + return "Removed task: " + taskId; + } + + @PutMapping("/pause/{taskId}") + public ScheduleTask pause(@PathVariable("taskId") String taskId) { + taskScheduler.pause(taskId); + return taskScheduler.getTaskInfo(taskId); + } + + @PutMapping("/resume/{taskId}") + public ScheduleTask resume(@PathVariable("taskId") String taskId) { + taskScheduler.resume(taskId); + return taskScheduler.getTaskInfo(taskId); + } + + @GetMapping("/info/{taskId}") + public ScheduleTask getTaskInfo(@PathVariable("taskId") String taskId) { + return taskScheduler.getTaskInfo(taskId); + } + + @GetMapping("/list") + public List getAllTasks() { + return taskScheduler.getAllTasks(); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/handler/DemoTaskHandlers.java b/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/handler/DemoTaskHandlers.java new file mode 100644 index 0000000..29039c6 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-schedule/src/main/java/cn/structure/infra/sample/schedule/handler/DemoTaskHandlers.java @@ -0,0 +1,56 @@ +package cn.structure.infra.sample.schedule.handler; + +import cn.structure.infra.schedule.TaskHandler; +import cn.structure.infra.schedule.TaskHandlerRegistry; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Component +public class DemoTaskHandlers { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Autowired + private TaskHandlerRegistry handlerRegistry; + + @PostConstruct + public void registerHandlers() { + handlerRegistry.register("demo-fixed-rate-handler", this::fixedRateHandler); + + handlerRegistry.register("demo-fixed-delay-handler", this::fixedDelayHandler); + + handlerRegistry.register("demo-param-handler", this::paramHandler); + + handlerRegistry.register("demo-error-handler", this::errorHandler); + + log.info("所有示例 TaskHandler 已注册"); + } + + public void fixedRateHandler(String param) { + log.info("固定速率任务执行 - 当前时间: {}, 参数: {}", LocalDateTime.now().format(FORMATTER), param); + } + + public void fixedDelayHandler(String param) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + log.info("固定延迟任务执行 - 当前时间: {}, 参数: {}", LocalDateTime.now().format(FORMATTER), param); + } + + public void paramHandler(String param) { + log.info("带参数任务执行 - 参数: {}", param); + } + + public void errorHandler(String param) { + log.info("错误处理任务执行 - 参数: {}", param); + throw new RuntimeException("模拟任务执行异常"); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-schedule/src/main/resources/application.yml b/structure-infra-sample/structure-infra-sample-schedule/src/main/resources/application.yml new file mode 100644 index 0000000..0ab1521 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-schedule/src/main/resources/application.yml @@ -0,0 +1,11 @@ +server: + port: 8086 + +logging: + level: + cn.structure.infra.schedule: DEBUG + cn.structure.infra.sample.schedule: INFO + +structure: + schedule: + pool-size: 4 \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-schedule/src/test/java/cn/structure/infra/sample/schedule/ScheduleIntegrationTest.java b/structure-infra-sample/structure-infra-sample-schedule/src/test/java/cn/structure/infra/sample/schedule/ScheduleIntegrationTest.java new file mode 100644 index 0000000..be5a9f5 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-schedule/src/test/java/cn/structure/infra/sample/schedule/ScheduleIntegrationTest.java @@ -0,0 +1,304 @@ +package cn.structure.infra.sample.schedule; + +import cn.structure.infra.schedule.ScheduleTask; +import cn.structure.infra.schedule.TaskHandlerRegistry; +import cn.structure.infra.schedule.TaskScheduler; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@SpringBootTest(classes = ScheduleSampleApplication.class) +class ScheduleIntegrationTest { + + @Autowired + private TaskScheduler taskScheduler; + + @Autowired + private TaskHandlerRegistry handlerRegistry; + + @BeforeEach + void setUp() { + taskScheduler.getAllTasks().forEach(task -> taskScheduler.remove(task.getTaskId())); + } + + @Test + void testFixedRateSchedule() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + String handlerName = "test-fixed-rate-counter"; + handlerRegistry.register(handlerName, param -> { + int count = counter.incrementAndGet(); + log.info("Fixed rate task executed, count={}", count); + }); + + String taskId = "test-fixed-rate"; + + ScheduleTask task = ScheduleTask.builder() + .taskId(taskId) + .taskName("固定速率测试任务") + .handlerName(handlerName) + .handlerParam("test-param") + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(500L) + .initialDelay(0L) + .build(); + + taskScheduler.schedule(task); + + Thread.sleep(2000); + + ScheduleTask taskInfo = taskScheduler.getTaskInfo(taskId); + assertNotNull(taskInfo); + assertEquals(ScheduleTask.TaskStatus.RUNNING, taskInfo.getStatus()); + + assertTrue(counter.get() >= 3, "任务应至少执行3次"); + + taskScheduler.pause(taskId); + + Thread.sleep(1000); + + int countAfterPause = counter.get(); + + Thread.sleep(1000); + + assertEquals(countAfterPause, counter.get(), "暂停后任务不应继续执行"); + + taskScheduler.resume(taskId); + + Thread.sleep(1000); + + assertTrue(counter.get() > countAfterPause, "恢复后任务应继续执行"); + + taskScheduler.remove(taskId); + handlerRegistry.unregister(handlerName); + + Thread.sleep(500); + + assertNull(taskScheduler.getTaskInfo(taskId), "删除后任务信息应为空"); + } + + @Test + void testFixedDelaySchedule() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + String handlerName = "test-fixed-delay-counter"; + handlerRegistry.register(handlerName, param -> { + int count = counter.incrementAndGet(); + log.info("Fixed delay task executed, count={}", count); + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + String taskId = "test-fixed-delay"; + + ScheduleTask task = ScheduleTask.builder() + .taskId(taskId) + .taskName("固定延迟测试任务") + .handlerName(handlerName) + .handlerParam("test-param") + .scheduleType(ScheduleTask.ScheduleType.FIXED_DELAY) + .delay(500L) + .initialDelay(0L) + .build(); + + taskScheduler.schedule(task); + + Thread.sleep(2000); + + assertTrue(counter.get() >= 3, "固定延迟任务应至少执行3次"); + + taskScheduler.remove(taskId); + handlerRegistry.unregister(handlerName); + } + + @Test + void testUpdateTask() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + String handlerName = "test-update-handler"; + handlerRegistry.register(handlerName, param -> { + int count = counter.incrementAndGet(); + log.info("Update test task executed, count={}", count); + }); + + String taskId = "test-update"; + + ScheduleTask task = ScheduleTask.builder() + .taskId(taskId) + .taskName("更新测试任务") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + taskScheduler.schedule(task); + + Thread.sleep(2500); + + int countBeforeUpdate = counter.get(); + + ScheduleTask updatedTask = ScheduleTask.builder() + .taskId(taskId) + .taskName("更新测试任务-修改后") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(500L) + .build(); + + taskScheduler.update(updatedTask); + + Thread.sleep(1500); + + assertTrue(counter.get() > countBeforeUpdate + 1, "更新后任务应更频繁执行"); + + taskScheduler.remove(taskId); + handlerRegistry.unregister(handlerName); + } + + @Test + void testGetAllTasks() throws InterruptedException { + String handlerName1 = "test-get-all-handler-1"; + String handlerName2 = "test-get-all-handler-2"; + handlerRegistry.register(handlerName1, param -> log.info("Task 1 executed")); + handlerRegistry.register(handlerName2, param -> log.info("Task 2 executed")); + + String taskId1 = "test-get-all-1"; + String taskId2 = "test-get-all-2"; + + ScheduleTask task1 = ScheduleTask.builder() + .taskId(taskId1) + .taskName("任务1") + .handlerName(handlerName1) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + ScheduleTask task2 = ScheduleTask.builder() + .taskId(taskId2) + .taskName("任务2") + .handlerName(handlerName2) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + taskScheduler.schedule(task1); + taskScheduler.schedule(task2); + + Thread.sleep(100); + + assertEquals(2, taskScheduler.getAllTasks().size()); + + taskScheduler.remove(taskId1); + taskScheduler.remove(taskId2); + handlerRegistry.unregister(handlerName1); + handlerRegistry.unregister(handlerName2); + + assertEquals(0, taskScheduler.getAllTasks().size()); + } + + @Test + void testPauseNonExistentTask() { + taskScheduler.pause("non-existent-task"); + assertNull(taskScheduler.getTaskInfo("non-existent-task")); + } + + @Test + void testResumeNonExistentTask() { + taskScheduler.resume("non-existent-task"); + assertNull(taskScheduler.getTaskInfo("non-existent-task")); + } + + @Test + void testRemoveNonExistentTask() { + taskScheduler.remove("non-existent-task"); + assertNull(taskScheduler.getTaskInfo("non-existent-task")); + } + + @Test + void testScheduleWithNullTask() { + assertThrows(IllegalArgumentException.class, () -> taskScheduler.schedule(null)); + } + + @Test + void testScheduleWithNullTaskId() { + ScheduleTask task = ScheduleTask.builder() + .taskId(null) + .taskName("测试任务") + .handlerName("test-handler") + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + assertThrows(IllegalArgumentException.class, () -> taskScheduler.schedule(task)); + } + + @Test + void testScheduleWithNullHandlerName() { + ScheduleTask task = ScheduleTask.builder() + .taskId("test-null-handler") + .taskName("测试任务") + .handlerName(null) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + assertThrows(IllegalArgumentException.class, () -> taskScheduler.schedule(task)); + } + + @Test + void testScheduleWithEmptyHandlerName() { + ScheduleTask task = ScheduleTask.builder() + .taskId("test-empty-handler") + .taskName("测试任务") + .handlerName("") + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + assertThrows(IllegalArgumentException.class, () -> taskScheduler.schedule(task)); + } + + @Test + void testScheduleWithNonExistentHandler() { + ScheduleTask task = ScheduleTask.builder() + .taskId("test-non-existent-handler") + .taskName("测试任务") + .handlerName("non-existent-handler") + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + assertThrows(IllegalArgumentException.class, () -> taskScheduler.schedule(task)); + } + + @Test + void testScheduleWithNullScheduleType() { + String handlerName = "test-null-type-handler"; + handlerRegistry.register(handlerName, param -> {}); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-null-type") + .taskName("测试任务") + .handlerName(handlerName) + .scheduleType(null) + .period(1000L) + .build(); + + assertThrows(IllegalArgumentException.class, () -> taskScheduler.schedule(task)); + + handlerRegistry.unregister(handlerName); + } + + @Test + void testAutoConfiguration() { + assertNotNull(taskScheduler); + assertNotNull(handlerRegistry); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/pom.xml b/structure-infra-sample/structure-infra-sample-stream/pom.xml new file mode 100644 index 0000000..0280b57 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + cn.structured + structure-infra-sample + ${revision} + ../pom.xml + + + structure-infra-sample-stream + structure-infra-sample-stream + Spring Cloud Stream 示例模块 + jar + + + + cn.structured + structure-infra-sample-core + ${revision} + + + cn.structured + structure-infra-stream-starter + ${revision} + + + org.springframework.cloud + spring-cloud-stream-binder-rabbit + + + org.springframework.boot + spring-boot-starter-web + + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.cloud + spring-cloud-stream-test-support + test + + + org.projectlombok + lombok + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/StreamApplication.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/StreamApplication.java new file mode 100644 index 0000000..9f9dc47 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/StreamApplication.java @@ -0,0 +1,11 @@ +package cn.structure.infra.sample.stream; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class StreamApplication { + public static void main(String[] args) { + SpringApplication.run(StreamApplication.class, args); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/config/SecurityConfig.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/config/SecurityConfig.java new file mode 100644 index 0000000..2d2ee0b --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/config/SecurityConfig.java @@ -0,0 +1,24 @@ +package cn.structure.infra.sample.stream.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ); + return http.build(); + } + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/consumer/StreamMessageConsumer.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/consumer/StreamMessageConsumer.java new file mode 100644 index 0000000..7a09f88 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/consumer/StreamMessageConsumer.java @@ -0,0 +1,53 @@ +package cn.structure.infra.sample.stream.consumer; + +import cn.structure.infra.sample.stream.event.DeliveryEvent; +import cn.structure.infra.sample.stream.event.OrderEvent; +import cn.structure.infra.sample.stream.event.PaymentEvent; +import cn.structure.infra.stream.manager.StreamEventManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; + +import java.util.function.Consumer; + +@Configuration +public class StreamMessageConsumer { + + private static final Logger log = LoggerFactory.getLogger(StreamMessageConsumer.class); + + private final StreamEventManager streamEventManager; + + public StreamMessageConsumer(StreamEventManager streamEventManager) { + this.streamEventManager = streamEventManager; + } + + @Bean + public Consumer> orderEvent() { + return message -> { + OrderEvent event = message.getPayload(); + log.info("[消费者] 收到订单事件: {}", event); + streamEventManager.dispatch("orderEvent", event); + }; + } + + @Bean + public Consumer> paymentEvent() { + return message -> { + PaymentEvent event = message.getPayload(); + log.info("[消费者] 收到支付事件: {}", event); + streamEventManager.dispatch("paymentEvent", event); + }; + } + + @Bean + public Consumer> deliveryEvent() { + return message -> { + DeliveryEvent event = message.getPayload(); + log.info("[消费者] 收到配送事件: {}", event); + streamEventManager.dispatch("deliveryEvent", event); + }; + } + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/controller/StreamController.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/controller/StreamController.java new file mode 100644 index 0000000..d86ac06 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/controller/StreamController.java @@ -0,0 +1,73 @@ +package cn.structure.infra.sample.stream.controller; + +import cn.structure.infra.sample.stream.event.DeliveryEvent; +import cn.structure.infra.sample.stream.event.OrderEvent; +import cn.structure.infra.sample.stream.event.PaymentEvent; +import cn.structure.infra.stream.manager.StreamEventManager; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/stream") +public class StreamController { + + private final StreamEventManager streamEventManager; + + public StreamController(StreamEventManager streamEventManager) { + this.streamEventManager = streamEventManager; + } + + @PostMapping("/order") + public String sendOrderEvent(@RequestBody OrderEvent event) { + streamEventManager.publish("orderEvent", event); + return "Order event sent: " + event.getOrderId(); + } + + @PostMapping("/order/create") + public String sendOrderCreatedEvent() { + OrderEvent event = OrderEvent.builder() + .orderId("ORDER-" + System.currentTimeMillis()) + .orderNo("ORD-" + System.currentTimeMillis()) + .status("CREATED") + .amount(100.0) + .build(); + streamEventManager.publish("orderEvent", event); + return "Order created event sent: " + event.getOrderId(); + } + + @PostMapping("/order/pay") + public String sendOrderPaidEvent(@RequestBody OrderEvent event) { + event.setStatus("PAID"); + streamEventManager.publish("orderEvent", event); + return "Order paid event sent: " + event.getOrderId(); + } + + @PostMapping("/payment") + public String sendPaymentEvent(@RequestBody PaymentEvent event) { + streamEventManager.publish("paymentEvent", event); + return "Payment event sent: " + event.getPaymentId(); + } + + @PostMapping("/payment/success") + public String sendPaymentSuccessEvent(@RequestBody PaymentEvent event) { + event.setPaymentStatus("SUCCESS"); + streamEventManager.publish("paymentEvent", event); + return "Payment success event sent: " + event.getPaymentId(); + } + + @PostMapping("/delivery") + public String sendDeliveryEvent(@RequestBody DeliveryEvent event) { + streamEventManager.publish("deliveryEvent", event); + return "Delivery event sent: " + event.getDeliveryId(); + } + + @PostMapping("/delivery/start") + public String sendDeliveryStartedEvent(@RequestBody DeliveryEvent event) { + event.setStatus("STARTED"); + streamEventManager.publish("deliveryEvent", event); + return "Delivery started event sent: " + event.getDeliveryId(); + } + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/DeliveryEvent.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/DeliveryEvent.java new file mode 100644 index 0000000..9d95836 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/DeliveryEvent.java @@ -0,0 +1,22 @@ +package cn.structure.infra.sample.stream.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeliveryEvent { + + private String deliveryId; + + private String orderId; + + private String status; + + private String address; + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/OrderEvent.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/OrderEvent.java new file mode 100644 index 0000000..3016f9d --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/OrderEvent.java @@ -0,0 +1,22 @@ +package cn.structure.infra.sample.stream.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderEvent { + + private String orderId; + + private String orderNo; + + private String status; + + private Double amount; + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/PaymentEvent.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/PaymentEvent.java new file mode 100644 index 0000000..38884fb --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/event/PaymentEvent.java @@ -0,0 +1,22 @@ +package cn.structure.infra.sample.stream.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaymentEvent { + + private String paymentId; + + private String orderId; + + private String paymentStatus; + + private Double amount; + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/DeliveryEventListener.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/DeliveryEventListener.java new file mode 100644 index 0000000..685f669 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/DeliveryEventListener.java @@ -0,0 +1,26 @@ +package cn.structure.infra.sample.stream.listener; + +import cn.structure.infra.sample.stream.event.DeliveryEvent; +import cn.structure.infra.stream.annotation.StreamEventListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class DeliveryEventListener { + + private static final Logger log = LoggerFactory.getLogger(DeliveryEventListener.class); + + @StreamEventListener(bindingName = "deliveryEvent", destination = "delivery-exchange", group = "delivery-group") + public void handleDeliveryEvent(DeliveryEvent event) { + log.info("[配送事件] deliveryId={}, orderId={}, status={}, address={}", + event.getDeliveryId(), event.getOrderId(), event.getStatus(), event.getAddress()); + } + + @StreamEventListener(bindingName = "deliveryEvent", destination = "delivery-exchange", group = "delivery-group", condition = "#event.status == 'STARTED'") + public void handleDeliveryStarted(DeliveryEvent event) { + log.info("[配送开始] deliveryId={}, orderId={}, address={}", + event.getDeliveryId(), event.getOrderId(), event.getAddress()); + } + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/OrderEventListener.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/OrderEventListener.java new file mode 100644 index 0000000..69ea333 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/OrderEventListener.java @@ -0,0 +1,31 @@ +package cn.structure.infra.sample.stream.listener; + +import cn.structure.infra.sample.stream.event.OrderEvent; +import cn.structure.infra.stream.annotation.StreamEventListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class OrderEventListener { + + private static final Logger log = LoggerFactory.getLogger(OrderEventListener.class); + + @StreamEventListener(bindingName = "orderEvent", destination = "order-exchange", group = "order-group") + public void handleOrderEvent(OrderEvent event) { + log.info("[订单事件] orderId={}, orderNo={}, status={}, amount={}", + event.getOrderId(), event.getOrderNo(), event.getStatus(), event.getAmount()); + } + + @StreamEventListener(bindingName = "orderEvent", destination = "order-exchange", group = "order-group", condition = "#event.status == 'CREATED'") + public void handleOrderCreated(OrderEvent event) { + log.info("[订单创建] orderId={}, orderNo={}, amount={}", + event.getOrderId(), event.getOrderNo(), event.getAmount()); + } + + @StreamEventListener(bindingName = "orderEvent", destination = "order-exchange", group = "order-group", condition = "#event.status == 'PAID'") + public void handleOrderPaid(OrderEvent event) { + log.info("[订单支付] orderId={}, amount={}", event.getOrderId(), event.getAmount()); + } + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java new file mode 100644 index 0000000..a706e3d --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java @@ -0,0 +1,26 @@ +package cn.structure.infra.sample.stream.listener; + +import cn.structure.infra.sample.stream.event.PaymentEvent; +import cn.structure.infra.stream.annotation.StreamEventListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class PaymentEventListener { + + private static final Logger log = LoggerFactory.getLogger(PaymentEventListener.class); + + @StreamEventListener(bindingName = "paymentEvent", destination = "payment-exchange", group = "payment-group") + public void handlePaymentEvent(PaymentEvent event) { + log.info("[支付事件] paymentId={}, orderId={}, status={}, amount={}", + event.getPaymentId(), event.getOrderId(), event.getPaymentStatus(), event.getAmount()); + } + + @StreamEventListener(bindingName = "paymentEvent", destination = "payment-exchange", group = "payment-group", condition = "#event.paymentStatus == 'SUCCESS'") + public void handlePaymentSuccess(PaymentEvent event) { + log.info("[支付成功] paymentId={}, orderId={}, amount={}", + event.getPaymentId(), event.getOrderId(), event.getAmount()); + } + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/resources/application.yml b/structure-infra-sample/structure-infra-sample-stream/src/main/resources/application.yml new file mode 100644 index 0000000..f967b58 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/resources/application.yml @@ -0,0 +1,26 @@ +server: + port: 8088 + +spring: + application: + name: structure-infra-sample-stream + rabbitmq: + addresses: 172.24.20.15:5672 + username: root + password: 123456 + virtual-host: / + cloud: + stream: + rabbit: + binder: + admin-addresses: ${spring.rabbitmq.addresses} + username: ${spring.rabbitmq.username} + password: ${spring.rabbitmq.password} + virtual-host: ${spring.rabbitmq.virtual-host} + cache: + type: simple + +structure: + infra: + stream: + enabled: true \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/StreamEventManagerTest.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/StreamEventManagerTest.java new file mode 100644 index 0000000..0a83fc3 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/StreamEventManagerTest.java @@ -0,0 +1,375 @@ +package cn.structure.infra.sample.stream; + +import cn.structure.infra.sample.stream.event.OrderEvent; +import cn.structure.infra.sample.stream.event.PaymentEvent; +import cn.structure.infra.stream.manager.DefaultStreamEventManagerImpl; +import cn.structure.infra.stream.manager.StreamEventManager; +import cn.structure.infra.stream.properties.StreamProperties; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.stream.function.StreamBridge; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +@Slf4j +public class StreamEventManagerTest { + + private StreamEventManager streamEventManager; + + private StreamProperties streamProperties; + + @BeforeEach + public void setUp() { + streamProperties = new StreamProperties(); + StreamProperties.Binding orderBinding = new StreamProperties.Binding(); + orderBinding.setDestination("order-exchange"); + orderBinding.setContentType("application/json"); + orderBinding.setGroup("order-group"); + streamProperties.getBindings().put("orderEvent", orderBinding); + + StreamProperties.Binding paymentBinding = new StreamProperties.Binding(); + paymentBinding.setDestination("payment-exchange"); + paymentBinding.setContentType("application/json"); + paymentBinding.setGroup("payment-group"); + streamProperties.getBindings().put("paymentEvent", paymentBinding); + + StreamBridge mockStreamBridge = mock(StreamBridge.class); + streamEventManager = new DefaultStreamEventManagerImpl(mockStreamBridge, streamProperties); + } + + @Test + public void testStreamPropertiesConfiguration() { + assertNotNull(streamProperties); + assertTrue(streamProperties.isEnabled()); + assertNotNull(streamProperties.getBindings()); + assertTrue(streamProperties.getBindings().containsKey("orderEvent")); + assertTrue(streamProperties.getBindings().containsKey("paymentEvent")); + + StreamProperties.Binding orderBinding = streamProperties.getBindings().get("orderEvent"); + assertEquals("order-exchange", orderBinding.getDestination()); + assertEquals("application/json", orderBinding.getContentType()); + assertEquals("order-group", orderBinding.getGroup()); + + StreamProperties.Binding paymentBinding = streamProperties.getBindings().get("paymentEvent"); + assertEquals("payment-exchange", paymentBinding.getDestination()); + assertEquals("application/json", paymentBinding.getContentType()); + assertEquals("payment-group", paymentBinding.getGroup()); + } + + @Test + public void testStreamEventManagerBeanExists() { + assertNotNull(streamEventManager); + } + + @Test + public void testRegisterListener() { + assertFalse(streamEventManager.isListenerRegistered("orderEvent")); + + streamEventManager.registerListener("orderEvent", OrderEvent.class, event -> {}); + + assertTrue(streamEventManager.isListenerRegistered("orderEvent"), "orderEvent listener should be registered"); + } + + @Test + public void testUnregisterListener() { + streamEventManager.registerListener("orderEvent", OrderEvent.class, event -> {}); + assertTrue(streamEventManager.isListenerRegistered("orderEvent")); + + streamEventManager.unregisterListener("orderEvent"); + + assertFalse(streamEventManager.isListenerRegistered("orderEvent"), "orderEvent listener should be unregistered"); + } + + @Test + public void testRegisterDynamicListener() { + String testBindingName = "testDynamicEvent"; + int[] counter = {0}; + + streamEventManager.registerListener(testBindingName, "test-destination", "test-group", OrderEvent.class, event -> { + log.info("Dynamic listener received event: {}", event); + counter[0]++; + }); + + assertTrue(streamEventManager.isListenerRegistered(testBindingName)); + + streamEventManager.unregisterListener(testBindingName); + + assertFalse(streamEventManager.isListenerRegistered(testBindingName)); + } + + @Test + public void testPublishEventWithRegisteredBinding() { + OrderEvent event = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + assertDoesNotThrow(() -> streamEventManager.publish("orderEvent", event)); + } + + @Test + public void testPublishEventWithDestination() { + OrderEvent event = OrderEvent.builder() + .orderId("ORDER-002") + .orderNo("ORD-2024-002") + .status("PAID") + .amount(200.0) + .build(); + + assertDoesNotThrow(() -> streamEventManager.publish("orderEvent", "order-exchange", event)); + } + + @Test + public void testPublishPaymentEvent() { + PaymentEvent event = PaymentEvent.builder() + .paymentId("PAY-001") + .orderId("ORDER-001") + .paymentStatus("SUCCESS") + .amount(100.0) + .build(); + + assertDoesNotThrow(() -> streamEventManager.publish("paymentEvent", event)); + } + + @Test + public void testPublishEventWithNonExistentBinding() { + OrderEvent event = OrderEvent.builder() + .orderId("ORDER-003") + .orderNo("ORD-2024-003") + .status("CANCELLED") + .amount(50.0) + .build(); + + assertThrows(IllegalArgumentException.class, () -> streamEventManager.publish("nonExistentBinding", event)); + } + + @Test + public void testIsListenerRegisteredForNonExistentBinding() { + assertFalse(streamEventManager.isListenerRegistered("nonExistentBinding")); + } + + @Test + public void testRegisterAndUnregisterCycle() { + String bindingName = "testCycleEvent"; + + streamEventManager.registerListener(bindingName, "test-destination", "test-group", OrderEvent.class, event -> {}); + assertTrue(streamEventManager.isListenerRegistered(bindingName)); + + streamEventManager.unregisterListener(bindingName); + assertFalse(streamEventManager.isListenerRegistered(bindingName)); + + streamEventManager.registerListener(bindingName, "test-destination", "test-group", PaymentEvent.class, event -> {}); + assertTrue(streamEventManager.isListenerRegistered(bindingName)); + + streamEventManager.unregisterListener(bindingName); + assertFalse(streamEventManager.isListenerRegistered(bindingName)); + } + + @Test + public void testRegisterListenerWithAllParameters() { + String bindingName = "testFullParams"; + + streamEventManager.registerListener(bindingName, "test-destination", "test-group", OrderEvent.class, event -> {}); + + assertTrue(streamEventManager.isListenerRegistered(bindingName)); + + streamEventManager.unregisterListener(bindingName); + + assertFalse(streamEventManager.isListenerRegistered(bindingName)); + } + + @Test + public void testMultipleListeners() { + streamEventManager.registerListener("listener1", "test-destination", "test-group", OrderEvent.class, event -> {}); + streamEventManager.registerListener("listener2", "test-destination", "test-group", PaymentEvent.class, event -> {}); + streamEventManager.registerListener("listener3", "test-destination", "test-group", OrderEvent.class, event -> {}); + + assertTrue(streamEventManager.isListenerRegistered("listener1")); + assertTrue(streamEventManager.isListenerRegistered("listener2")); + assertTrue(streamEventManager.isListenerRegistered("listener3")); + + streamEventManager.unregisterListener("listener2"); + + assertTrue(streamEventManager.isListenerRegistered("listener1")); + assertFalse(streamEventManager.isListenerRegistered("listener2")); + assertTrue(streamEventManager.isListenerRegistered("listener3")); + + streamEventManager.unregisterListener("listener1"); + streamEventManager.unregisterListener("listener3"); + + assertFalse(streamEventManager.isListenerRegistered("listener1")); + assertFalse(streamEventManager.isListenerRegistered("listener3")); + } + + @Test + public void testMultipleHandlersOnSameBinding() { + String bindingName = "orderEvent"; + int[] counter1 = {0}; + int[] counter2 = {0}; + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, event -> { + counter1[0]++; + }); + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, event -> { + counter2[0]++; + }); + + assertTrue(streamEventManager.isListenerRegistered(bindingName)); + assertEquals(2, streamEventManager.getListeners(bindingName).size()); + + OrderEvent event = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + streamEventManager.dispatch(bindingName, event); + + assertEquals(1, counter1[0]); + assertEquals(1, counter2[0]); + } + + @Test + public void testConditionBasedRouting() { + String bindingName = "orderEvent"; + int[] createdCounter = {0}; + int[] paidCounter = {0}; + int[] allCounter = {0}; + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, "#event.status == 'CREATED'", event -> { + createdCounter[0]++; + }); + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, "#event.status == 'PAID'", event -> { + paidCounter[0]++; + }); + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, event -> { + allCounter[0]++; + }); + + OrderEvent createdEvent = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + OrderEvent paidEvent = OrderEvent.builder() + .orderId("ORDER-002") + .orderNo("ORD-2024-002") + .status("PAID") + .amount(200.0) + .build(); + + streamEventManager.dispatch(bindingName, createdEvent); + + assertEquals(1, createdCounter[0]); + assertEquals(0, paidCounter[0]); + assertEquals(1, allCounter[0]); + + streamEventManager.dispatch(bindingName, paidEvent); + + assertEquals(1, createdCounter[0]); + assertEquals(1, paidCounter[0]); + assertEquals(2, allCounter[0]); + } + + @Test + public void testDispatchToMultipleHandlersWithDifferentConditions() { + String bindingName = "orderEvent"; + int[] highAmountCounter = {0}; + int[] lowAmountCounter = {0}; + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, "#event.amount > 100", event -> { + highAmountCounter[0]++; + }); + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, "#event.amount <= 100", event -> { + lowAmountCounter[0]++; + }); + + OrderEvent highAmountEvent = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(150.0) + .build(); + + OrderEvent lowAmountEvent = OrderEvent.builder() + .orderId("ORDER-002") + .orderNo("ORD-2024-002") + .status("CREATED") + .amount(50.0) + .build(); + + streamEventManager.dispatch(bindingName, highAmountEvent); + + assertEquals(1, highAmountCounter[0]); + assertEquals(0, lowAmountCounter[0]); + + streamEventManager.dispatch(bindingName, lowAmountEvent); + + assertEquals(1, highAmountCounter[0]); + assertEquals(1, lowAmountCounter[0]); + } + + @Test + public void testUnregisterListenerById() { + String bindingName = "orderEvent"; + int[] counter1 = {0}; + int[] counter2 = {0}; + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, event -> { + log.info("Listener 1 triggered"); + counter1[0]++; + }); + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, event -> { + counter2[0]++; + }); + + assertEquals(2, streamEventManager.getListeners(bindingName).size()); + + String listenerIdToRemove = streamEventManager.getListeners(bindingName).get(0).getListenerId(); + streamEventManager.unregisterListener(bindingName, listenerIdToRemove); + + assertEquals(1, streamEventManager.getListeners(bindingName).size()); + assertTrue(streamEventManager.isListenerRegistered(bindingName)); + + OrderEvent event = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + streamEventManager.dispatch(bindingName, event); + + assertEquals(1, counter1[0] + counter2[0]); + } + + @Test + public void testGetListeners() { + String bindingName = "orderEvent"; + + assertTrue(streamEventManager.getListeners(bindingName).isEmpty()); + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, event -> {}); + + assertEquals(1, streamEventManager.getListeners(bindingName).size()); + + streamEventManager.registerListener(bindingName, "order-exchange", "order-group", OrderEvent.class, "#event.status == 'PAID'", event -> {}); + + assertEquals(2, streamEventManager.getListeners(bindingName).size()); + + assertTrue(streamEventManager.getListeners("nonExistent").isEmpty()); + } + +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/StreamEventRouterTest.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/StreamEventRouterTest.java new file mode 100644 index 0000000..9817155 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/StreamEventRouterTest.java @@ -0,0 +1,314 @@ +package cn.structure.infra.sample.stream; + +import cn.structure.infra.sample.stream.event.OrderEvent; +import cn.structure.infra.sample.stream.event.PaymentEvent; +import cn.structure.infra.stream.event.StreamEvent; +import cn.structure.infra.stream.router.DefaultStreamEventRouterImpl; +import cn.structure.infra.stream.router.StreamEventRouter; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +public class StreamEventRouterTest { + + private StreamEventRouter streamEventRouter; + + @BeforeEach + public void setUp() { + streamEventRouter = new DefaultStreamEventRouterImpl(); + } + + @Test + public void testRouteOrderCreatedEvent() { + int[] counter = {0}; + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, (payload, event) -> { + counter[0]++; + }); + + OrderEvent orderEvent = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + StreamEvent streamEvent = StreamEvent.of("orderCreated", orderEvent); + streamEventRouter.route(streamEvent); + + assertEquals(1, counter[0]); + } + + @Test + public void testRoutePaymentSuccessEvent() { + int[] counter = {0}; + + streamEventRouter.registerRoute("paymentSuccess", PaymentEvent.class, (payload, event) -> { + counter[0]++; + }); + + PaymentEvent paymentEvent = PaymentEvent.builder() + .paymentId("PAY-001") + .orderId("ORDER-001") + .paymentStatus("SUCCESS") + .amount(100.0) + .build(); + + StreamEvent streamEvent = StreamEvent.of("paymentSuccess", paymentEvent); + streamEventRouter.route(streamEvent); + + assertEquals(1, counter[0]); + } + + @Test + public void testRouteMultipleEventTypes() { + int[] orderCounter = {0}; + int[] paymentCounter = {0}; + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, (payload, event) -> { + orderCounter[0]++; + }); + + streamEventRouter.registerRoute("paymentSuccess", PaymentEvent.class, (payload, event) -> { + paymentCounter[0]++; + }); + + OrderEvent orderEvent = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + PaymentEvent paymentEvent = PaymentEvent.builder() + .paymentId("PAY-001") + .orderId("ORDER-001") + .paymentStatus("SUCCESS") + .amount(100.0) + .build(); + + streamEventRouter.route(StreamEvent.of("orderCreated", orderEvent)); + streamEventRouter.route(StreamEvent.of("paymentSuccess", paymentEvent)); + + assertEquals(1, orderCounter[0]); + assertEquals(1, paymentCounter[0]); + } + + @Test + public void testRouteWithCondition() { + int[] highAmountCounter = {0}; + int[] lowAmountCounter = {0}; + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, "#payload.amount > 1000", (payload, event) -> { + highAmountCounter[0]++; + }); + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, "#payload.amount <= 1000", (payload, event) -> { + lowAmountCounter[0]++; + }); + + OrderEvent highAmountOrder = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(2000.0) + .build(); + + OrderEvent lowAmountOrder = OrderEvent.builder() + .orderId("ORDER-002") + .orderNo("ORD-2024-002") + .status("CREATED") + .amount(500.0) + .build(); + + streamEventRouter.route(StreamEvent.of("orderCreated", highAmountOrder)); + streamEventRouter.route(StreamEvent.of("orderCreated", lowAmountOrder)); + + assertEquals(1, highAmountCounter[0]); + assertEquals(1, lowAmountCounter[0]); + } + + @Test + public void testRouteWithBusinessType() { + int[] retailCounter = {0}; + int[] wholesaleCounter = {0}; + int[] allCounter = {0}; + + streamEventRouter.registerRoute("orderCreated", "retail", OrderEvent.class, (payload, event) -> { + retailCounter[0]++; + }); + + streamEventRouter.registerRoute("orderCreated", "wholesale", OrderEvent.class, (payload, event) -> { + wholesaleCounter[0]++; + }); + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, (payload, event) -> { + allCounter[0]++; + }); + + OrderEvent retailOrder = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + OrderEvent wholesaleOrder = OrderEvent.builder() + .orderId("ORDER-002") + .orderNo("ORD-2024-002") + .status("CREATED") + .amount(5000.0) + .build(); + + streamEventRouter.route(StreamEvent.of("orderCreated", "retail", retailOrder)); + streamEventRouter.route(StreamEvent.of("orderCreated", "wholesale", wholesaleOrder)); + + assertEquals(1, retailCounter[0]); + assertEquals(1, wholesaleCounter[0]); + assertEquals(2, allCounter[0]); + } + + @Test + public void testRouteWithMultipleHandlers() { + int[] handler1Counter = {0}; + int[] handler2Counter = {0}; + int[] handler3Counter = {0}; + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, (payload, event) -> { + handler1Counter[0]++; + }); + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, "#payload.amount > 500", (payload, event) -> { + handler2Counter[0]++; + }); + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, "#payload.amount > 1000", (payload, event) -> { + handler3Counter[0]++; + }); + + OrderEvent highAmountOrder = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(2000.0) + .build(); + + streamEventRouter.route(StreamEvent.of("orderCreated", highAmountOrder)); + + assertEquals(1, handler1Counter[0]); + assertEquals(1, handler2Counter[0]); + assertEquals(1, handler3Counter[0]); + } + + @Test + public void testUnregisterRoute() { + int[] counter = {0}; + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, (payload, event) -> { + counter[0]++; + }); + + assertEquals(1, streamEventRouter.getRoutes("orderCreated").size()); + + streamEventRouter.unregisterRoute("orderCreated"); + + assertEquals(0, streamEventRouter.getRoutes("orderCreated").size()); + + OrderEvent orderEvent = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + streamEventRouter.route(StreamEvent.of("orderCreated", orderEvent)); + + assertEquals(0, counter[0]); + } + + @Test + public void testUnregisterRouteById() { + int[] counter1 = {0}; + int[] counter2 = {0}; + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, (payload, event) -> { + counter1[0]++; + }); + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, (payload, event) -> { + counter2[0]++; + }); + + assertEquals(2, streamEventRouter.getRoutes("orderCreated").size()); + + String handlerIdToRemove = streamEventRouter.getRoutes("orderCreated").get(0).getHandlerId(); + streamEventRouter.unregisterRoute("orderCreated", handlerIdToRemove); + + assertEquals(1, streamEventRouter.getRoutes("orderCreated").size()); + + OrderEvent orderEvent = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + streamEventRouter.route(StreamEvent.of("orderCreated", orderEvent)); + + assertEquals(1, counter1[0] + counter2[0]); + } + + @Test + public void testGetRoutes() { + assertTrue(streamEventRouter.getRoutes("orderCreated").isEmpty()); + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, (payload, event) -> {}); + + assertEquals(1, streamEventRouter.getRoutes("orderCreated").size()); + + streamEventRouter.registerRoute("orderCreated", OrderEvent.class, "#payload.amount > 1000", (payload, event) -> {}); + + assertEquals(2, streamEventRouter.getRoutes("orderCreated").size()); + + assertTrue(streamEventRouter.getRoutes("nonExistent").isEmpty()); + } + + @Test + public void testRouteNonExistentEventType() { + OrderEvent orderEvent = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + assertDoesNotThrow(() -> streamEventRouter.route(StreamEvent.of("nonExistent", orderEvent))); + } + + @Test + public void testRouteWithNullEvent() { + assertDoesNotThrow(() -> streamEventRouter.route(null)); + } + + @Test + public void testRouteWithNullEventType() { + OrderEvent orderEvent = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + StreamEvent streamEvent = StreamEvent.builder() + .eventId("test-id") + .eventType(null) + .payload(orderEvent) + .build(); + + assertDoesNotThrow(() -> streamEventRouter.route(streamEvent)); + } + +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/config/StreamTestConfig.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/config/StreamTestConfig.java new file mode 100644 index 0000000..5fdf62c --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/config/StreamTestConfig.java @@ -0,0 +1,18 @@ +package cn.structure.infra.sample.stream.config; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@SpringBootApplication(excludeName = { + "org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration", + "org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration", + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration", + "org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration", + "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration", + "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration" +}) +@ComponentScan(basePackages = "cn.structure.infra.sample.stream") +public class StreamTestConfig { +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/controller/StreamControllerTest.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/controller/StreamControllerTest.java new file mode 100644 index 0000000..21707ec --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/controller/StreamControllerTest.java @@ -0,0 +1,155 @@ +package cn.structure.infra.sample.stream.controller; + +import cn.structure.infra.sample.stream.event.DeliveryEvent; +import cn.structure.infra.sample.stream.event.OrderEvent; +import cn.structure.infra.sample.stream.event.PaymentEvent; +import cn.structure.infra.stream.manager.StreamEventManager; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +public class StreamControllerTest { + + @Mock + private StreamEventManager streamEventManager; + + private MockMvc mockMvc; + + private ObjectMapper objectMapper; + + @BeforeEach + public void setUp() { + StreamController controller = new StreamController(streamEventManager); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + objectMapper = new ObjectMapper(); + } + + @Test + public void testSendOrderEvent() throws Exception { + OrderEvent event = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .status("CREATED") + .amount(100.0) + .build(); + + mockMvc.perform(post("/api/stream/order") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(event))) + .andExpect(status().isOk()) + .andExpect(content().string("Order event sent: ORDER-001")); + + verify(streamEventManager).publish(eq("orderEvent"), any(OrderEvent.class)); + } + + @Test + public void testSendOrderCreatedEvent() throws Exception { + mockMvc.perform(post("/api/stream/order/create")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.startsWith("Order created event sent: ORDER-"))); + + verify(streamEventManager).publish(eq("orderEvent"), any(OrderEvent.class)); + } + + @Test + public void testSendOrderPaidEvent() throws Exception { + OrderEvent event = OrderEvent.builder() + .orderId("ORDER-001") + .orderNo("ORD-2024-001") + .amount(200.0) + .build(); + + mockMvc.perform(post("/api/stream/order/pay") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(event))) + .andExpect(status().isOk()) + .andExpect(content().string("Order paid event sent: ORDER-001")); + + verify(streamEventManager).publish(eq("orderEvent"), any(OrderEvent.class)); + } + + @Test + public void testSendPaymentEvent() throws Exception { + PaymentEvent event = PaymentEvent.builder() + .paymentId("PAY-001") + .orderId("ORDER-001") + .paymentStatus("PENDING") + .amount(100.0) + .build(); + + mockMvc.perform(post("/api/stream/payment") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(event))) + .andExpect(status().isOk()) + .andExpect(content().string("Payment event sent: PAY-001")); + + verify(streamEventManager).publish(eq("paymentEvent"), any(PaymentEvent.class)); + } + + @Test + public void testSendPaymentSuccessEvent() throws Exception { + PaymentEvent event = PaymentEvent.builder() + .paymentId("PAY-001") + .orderId("ORDER-001") + .amount(100.0) + .build(); + + mockMvc.perform(post("/api/stream/payment/success") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(event))) + .andExpect(status().isOk()) + .andExpect(content().string("Payment success event sent: PAY-001")); + + verify(streamEventManager).publish(eq("paymentEvent"), any(PaymentEvent.class)); + } + + @Test + public void testSendDeliveryEvent() throws Exception { + DeliveryEvent event = DeliveryEvent.builder() + .deliveryId("DEL-001") + .orderId("ORDER-001") + .status("PREPARING") + .address("北京市朝阳区") + .build(); + + mockMvc.perform(post("/api/stream/delivery") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(event))) + .andExpect(status().isOk()) + .andExpect(content().string("Delivery event sent: DEL-001")); + + verify(streamEventManager).publish(eq("deliveryEvent"), any(DeliveryEvent.class)); + } + + @Test + public void testSendDeliveryStartedEvent() throws Exception { + DeliveryEvent event = DeliveryEvent.builder() + .deliveryId("DEL-001") + .orderId("ORDER-001") + .address("北京市朝阳区") + .build(); + + mockMvc.perform(post("/api/stream/delivery/start") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(event))) + .andExpect(status().isOk()) + .andExpect(content().string("Delivery started event sent: DEL-001")); + + verify(streamEventManager).publish(eq("deliveryEvent"), any(DeliveryEvent.class)); + } + +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/dynamic/DynamicListenerDemo.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/dynamic/DynamicListenerDemo.java new file mode 100644 index 0000000..171ebac --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/dynamic/DynamicListenerDemo.java @@ -0,0 +1,161 @@ +package cn.structure.infra.sample.stream.dynamic; + +import cn.structure.infra.sample.stream.event.OrderEvent; +import cn.structure.infra.sample.stream.event.PaymentEvent; +import cn.structure.infra.stream.event.StreamEvent; +import cn.structure.infra.stream.handler.StreamEventHandler; +import cn.structure.infra.stream.manager.ListenerRegistration; +import cn.structure.infra.stream.manager.StreamEventManager; +import cn.structure.infra.stream.router.StreamEventRouter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +@Component +public class DynamicListenerDemo implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(DynamicListenerDemo.class); + + private final StreamEventManager eventManager; + private final StreamEventRouter eventRouter; + + public static final AtomicInteger dynamicOrderCount = new AtomicInteger(0); + public static final AtomicInteger dynamicPaymentCount = new AtomicInteger(0); + public static final AtomicInteger dynamicGenericCount = new AtomicInteger(0); + + public DynamicListenerDemo(StreamEventManager eventManager, StreamEventRouter eventRouter) { + this.eventManager = eventManager; + this.eventRouter = eventRouter; + } + + @Override + public void run(String... args) { + log.info("========== 运行时动态注册监听器示例 =========="); + + registerDynamicListeners(); + registerDynamicRoutes(); + + testDynamicListeners(); + + log.info("========== 动态注册监听器示例完成 =========="); + } + + private void registerDynamicListeners() { + log.info("\n--- 方式一:通过 StreamEventManager 注册监听器 ---"); + + eventManager.registerBinding("dynamicOrder", "dynamic-order-exchange", "dynamic-group"); + + eventManager.registerListener("dynamicOrder", OrderEvent.class, new OrderEventHandler()); + + eventManager.registerListener("dynamicOrder", OrderEvent.class, event -> { + log.info("[Lambda方式] 动态监听订单: orderId={}", event.getOrderId()); + dynamicOrderCount.incrementAndGet(); + }); + + eventManager.registerListener("dynamicOrder", OrderEvent.class, "#payload.amount > 500", event -> { + log.info("[Lambda+条件] 动态监听大额订单: orderId={}, amount={}", event.getOrderId(), event.getAmount()); + }); + + eventManager.registerListener("dynamicPayment", "dynamic-payment-exchange", "payment-group", + PaymentEvent.class, new PaymentEventHandler()); + } + + private void registerDynamicRoutes() { + log.info("\n--- 方式二:通过 StreamEventRouter 注册路由 ---"); + + eventRouter.registerRoute("dynamicOrderCreated", OrderEvent.class, new DynamicRouteHandler()); + + eventRouter.registerRoute("dynamicPaymentSuccess", PaymentEvent.class, (payload, event) -> { + log.info("[Lambda路由] 动态路由支付成功: paymentId={}", payload.getPaymentId()); + dynamicPaymentCount.incrementAndGet(); + }); + + eventRouter.registerRoute("dynamicGeneric", Object.class, (payload, event) -> { + log.info("[通用路由] 动态路由通用事件: payload={}", payload); + dynamicGenericCount.incrementAndGet(); + }); + + eventRouter.registerRoute("dynamicWithBusiness", "retail", OrderEvent.class, (payload, event) -> { + log.info("[业务路由] 动态路由零售订单: orderId={}, businessType={}", + payload.getOrderId(), event.getBusinessType()); + }); + } + + private void testDynamicListeners() { + log.info("\n--- 测试动态注册的监听器 ---"); + + OrderEvent orderEvent = OrderEvent.builder() + .orderId("DYN-001") + .orderNo("DYN-ORD-2024-001") + .status("CREATED") + .amount(800.0) + .build(); + + eventManager.dispatch("dynamicOrder", orderEvent); + + eventRouter.route(StreamEvent.of("dynamicOrderCreated", orderEvent)); + + PaymentEvent paymentEvent = PaymentEvent.builder() + .paymentId("DYN-PAY-001") + .orderId("DYN-001") + .paymentStatus("SUCCESS") + .amount(800.0) + .build(); + + eventRouter.route(StreamEvent.of("dynamicPaymentSuccess", paymentEvent)); + + eventRouter.route(StreamEvent.of("dynamicGeneric", "test-message")); + + eventRouter.route(StreamEvent.builder() + .eventType("dynamicWithBusiness") + .businessType("retail") + .payload(orderEvent) + .build()); + + logStats(); + } + + private void logStats() { + log.info("\n--- 统计信息 ---"); + log.info("StreamEventManager 监听器数量:"); + List> orderListeners = eventManager.getListeners("dynamicOrder"); + log.info(" - dynamicOrder: {} 个监听器", orderListeners.size()); + + log.info("StreamEventRouter 路由数量:"); + log.info(" - dynamicOrderCreated: {} 个路由", eventRouter.getRoutes("dynamicOrderCreated").size()); + log.info(" - dynamicPaymentSuccess: {} 个路由", eventRouter.getRoutes("dynamicPaymentSuccess").size()); + + log.info("处理计数:"); + log.info(" - 动态订单计数: {}", dynamicOrderCount.get()); + log.info(" - 动态支付计数: {}", dynamicPaymentCount.get()); + log.info(" - 动态通用计数: {}", dynamicGenericCount.get()); + } + + public static class OrderEventHandler implements StreamEventHandler { + @Override + public void handle(OrderEvent event) { + log.info("[实现类方式] 动态监听订单: orderId={}, status={}", event.getOrderId(), event.getStatus()); + dynamicOrderCount.incrementAndGet(); + } + } + + public static class PaymentEventHandler implements StreamEventHandler { + @Override + public void handle(PaymentEvent event) { + log.info("[实现类方式] 动态监听支付: paymentId={}, status={}", event.getPaymentId(), event.getPaymentStatus()); + dynamicPaymentCount.incrementAndGet(); + } + } + + public static class DynamicRouteHandler implements StreamEventRouter.StreamRouteHandler { + @Override + public void handle(OrderEvent payload, StreamEvent event) { + log.info("[实现类路由] 动态路由订单: orderId={}, eventType={}", payload.getOrderId(), event.getEventType()); + dynamicOrderCount.incrementAndGet(); + } + } +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/DeliveryEvent.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/DeliveryEvent.java new file mode 100644 index 0000000..575537f --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/DeliveryEvent.java @@ -0,0 +1,86 @@ +package cn.structure.infra.sample.stream.event; + +public class DeliveryEvent { + + private String deliveryId; + private String orderId; + private String status; + private String address; + + public DeliveryEvent() { + } + + public DeliveryEvent(String deliveryId, String orderId, String status, String address) { + this.deliveryId = deliveryId; + this.orderId = orderId; + this.status = status; + this.address = address; + } + + public String getDeliveryId() { + return deliveryId; + } + + public void setDeliveryId(String deliveryId) { + this.deliveryId = deliveryId; + } + + public String getOrderId() { + return orderId; + } + + public void setOrderId(String orderId) { + this.orderId = orderId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String deliveryId; + private String orderId; + private String status; + private String address; + + public Builder deliveryId(String deliveryId) { + this.deliveryId = deliveryId; + return this; + } + + public Builder orderId(String orderId) { + this.orderId = orderId; + return this; + } + + public Builder status(String status) { + this.status = status; + return this; + } + + public Builder address(String address) { + this.address = address; + return this; + } + + public DeliveryEvent build() { + return new DeliveryEvent(deliveryId, orderId, status, address); + } + } +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/OrderEvent.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/OrderEvent.java new file mode 100644 index 0000000..80dfc89 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/OrderEvent.java @@ -0,0 +1,22 @@ +package cn.structure.infra.sample.stream.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderEvent { + + private String orderId; + + private String orderNo; + + private String status; + + private Double amount; + +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/PaymentEvent.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/PaymentEvent.java new file mode 100644 index 0000000..1bd3d4f --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/event/PaymentEvent.java @@ -0,0 +1,22 @@ +package cn.structure.infra.sample.stream.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaymentEvent { + + private String paymentId; + + private String orderId; + + private String paymentStatus; + + private Double amount; + +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/DeliveryEventListener.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/DeliveryEventListener.java new file mode 100644 index 0000000..cef46d1 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/DeliveryEventListener.java @@ -0,0 +1,33 @@ +package cn.structure.infra.sample.stream.listener; + +import cn.structure.infra.sample.stream.event.DeliveryEvent; +import cn.structure.infra.stream.annotation.StreamEventListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +@Component +public class DeliveryEventListener { + + private static final Logger log = LoggerFactory.getLogger(DeliveryEventListener.class); + + public static final AtomicInteger deliveryCreatedCount = new AtomicInteger(0); + public static final AtomicInteger deliveryCompletedCount = new AtomicInteger(0); + public static final AtomicReference lastDeliveryEvent = new AtomicReference<>(); + + @StreamEventListener(bindingName = "deliveryEvent", destination = "delivery-exchange", group = "delivery-group") + public void handleDeliveryCreated(DeliveryEvent event) { + log.info("[配送创建] deliveryId={}, orderId={}, status={}", + event.getDeliveryId(), event.getOrderId(), event.getStatus()); + if ("CREATED".equals(event.getStatus())) { + deliveryCreatedCount.incrementAndGet(); + } else if ("COMPLETED".equals(event.getStatus())) { + deliveryCompletedCount.incrementAndGet(); + } + lastDeliveryEvent.set(event); + } + +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/OrderEventListener.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/OrderEventListener.java new file mode 100644 index 0000000..50984cb --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/OrderEventListener.java @@ -0,0 +1,51 @@ +package cn.structure.infra.sample.stream.listener; + +import cn.structure.infra.sample.stream.event.OrderEvent; +import cn.structure.infra.stream.annotation.StreamRouteHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +@Component +public class OrderEventListener { + + private static final Logger log = LoggerFactory.getLogger(OrderEventListener.class); + + public static final AtomicInteger orderCreatedCount = new AtomicInteger(0); + public static final AtomicInteger orderPaidCount = new AtomicInteger(0); + public static final AtomicInteger orderCancelledCount = new AtomicInteger(0); + public static final AtomicInteger highAmountOrderCount = new AtomicInteger(0); + public static final AtomicReference lastOrderEvent = new AtomicReference<>(); + + @StreamRouteHandler(eventType = "orderCreated") + public void handleOrderCreated(OrderEvent event) { + log.info("[订单创建] orderId={}, orderNo={}, amount={}", + event.getOrderId(), event.getOrderNo(), event.getAmount()); + orderCreatedCount.incrementAndGet(); + lastOrderEvent.set(event); + } + + @StreamRouteHandler(eventType = "orderPaid") + public void handleOrderPaid(OrderEvent event) { + log.info("[订单支付] orderId={}, amount={}", event.getOrderId(), event.getAmount()); + orderPaidCount.incrementAndGet(); + lastOrderEvent.set(event); + } + + @StreamRouteHandler(eventType = "orderCancelled") + public void handleOrderCancelled(OrderEvent event) { + log.info("[订单取消] orderId={}", event.getOrderId()); + orderCancelledCount.incrementAndGet(); + lastOrderEvent.set(event); + } + + @StreamRouteHandler(eventType = "orderCreated", condition = "#payload.amount > 1000") + public void handleHighAmountOrder(OrderEvent event) { + log.info("[高额订单] orderId={}, amount={}", event.getOrderId(), event.getAmount()); + highAmountOrderCount.incrementAndGet(); + } + +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java new file mode 100644 index 0000000..748008a --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java @@ -0,0 +1,35 @@ +package cn.structure.infra.sample.stream.listener; + +import cn.structure.infra.sample.stream.event.PaymentEvent; +import cn.structure.infra.stream.annotation.StreamRouteHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +@Slf4j +@Component +public class PaymentEventListener { + + public static final AtomicInteger paymentSuccessCount = new AtomicInteger(0); + public static final AtomicInteger paymentFailedCount = new AtomicInteger(0); + public static final AtomicReference lastPaymentEvent = new AtomicReference<>(); + + @StreamRouteHandler(eventType = "paymentSuccess") + public void handlePaymentSuccess(PaymentEvent event) { + log.info("[支付成功] paymentId={}, orderId={}, amount={}", + event.getPaymentId(), event.getOrderId(), event.getAmount()); + paymentSuccessCount.incrementAndGet(); + lastPaymentEvent.set(event); + } + + @StreamRouteHandler(eventType = "paymentFailed") + public void handlePaymentFailed(PaymentEvent event) { + log.info("[支付失败] paymentId={}, orderId={}, status={}", + event.getPaymentId(), event.getOrderId(), event.getPaymentStatus()); + paymentFailedCount.incrementAndGet(); + lastPaymentEvent.set(event); + } + +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/lowcode/LowCodeConfigDemo.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/lowcode/LowCodeConfigDemo.java new file mode 100644 index 0000000..e8327ee --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/lowcode/LowCodeConfigDemo.java @@ -0,0 +1,32 @@ +package cn.structure.infra.sample.stream.lowcode; + +import cn.structure.infra.sample.stream.event.OrderEvent; +import cn.structure.infra.sample.stream.event.PaymentEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; + +@Component("lowCodeHandler") +public class LowCodeConfigDemo { + + private static final Logger log = LoggerFactory.getLogger(LowCodeConfigDemo.class); + + public static final AtomicInteger configOrderCreatedCount = new AtomicInteger(0); + public static final AtomicInteger configPaymentSuccessCount = new AtomicInteger(0); + + public void onOrderCreated(OrderEvent event) { + log.info("[配置驱动] 订单创建处理: orderId={}, amount={}", event.getOrderId(), event.getAmount()); + configOrderCreatedCount.incrementAndGet(); + } + + public void onPaymentSuccess(PaymentEvent event) { + log.info("[配置驱动] 支付成功处理: paymentId={}, orderId={}", event.getPaymentId(), event.getOrderId()); + configPaymentSuccessCount.incrementAndGet(); + } + + public void onHighAmountOrder(OrderEvent event) { + log.info("[配置驱动-高额订单] 订单金额超过阈值: orderId={}, amount={}", event.getOrderId(), event.getAmount()); + } +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/lowcode/LowCodeRouteConfig.java b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/lowcode/LowCodeRouteConfig.java new file mode 100644 index 0000000..15d3432 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/java/cn/structure/infra/sample/stream/lowcode/LowCodeRouteConfig.java @@ -0,0 +1,94 @@ +package cn.structure.infra.sample.stream.lowcode; + +import cn.structure.infra.sample.stream.event.OrderEvent; +import cn.structure.infra.sample.stream.event.PaymentEvent; +import cn.structure.infra.stream.event.StreamEvent; +import cn.structure.infra.stream.router.StreamEventRouter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +public class LowCodeRouteConfig implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(LowCodeRouteConfig.class); + + private final StreamEventRouter eventRouter; + + public LowCodeRouteConfig(StreamEventRouter eventRouter) { + this.eventRouter = eventRouter; + } + + @Override + public void run(String... args) { + log.info("========== 低代码路由注册示例 =========="); + + registerOrderRoutes(); + registerPaymentRoutes(); + + log.info("========== 低代码路由注册完成 =========="); + + testLowCodeRoutes(); + } + + private void registerOrderRoutes() { + eventRouter.registerRoute("orderCreated", OrderEvent.class, (payload, event) -> { + log.info("[低代码-订单创建] orderId={}, amount={}", payload.getOrderId(), payload.getAmount()); + }); + + eventRouter.registerRoute("orderCreated", OrderEvent.class, "#payload.amount > 1000", (payload, event) -> { + log.info("[低代码-高额订单] orderId={}, amount={}, 触发风控检查", payload.getOrderId(), payload.getAmount()); + }); + + eventRouter.registerRoute("orderPaid", OrderEvent.class, (payload, event) -> { + log.info("[低代码-订单支付] orderId={}, status={}", payload.getOrderId(), payload.getStatus()); + }); + + eventRouter.registerRoute("orderCancelled", OrderEvent.class, (payload, event) -> { + log.info("[低代码-订单取消] orderId={}", payload.getOrderId()); + }); + } + + private void registerPaymentRoutes() { + eventRouter.registerRoute("paymentSuccess", PaymentEvent.class, (payload, event) -> { + log.info("[低代码-支付成功] paymentId={}, orderId={}", payload.getPaymentId(), payload.getOrderId()); + }); + + eventRouter.registerRoute("paymentFailed", PaymentEvent.class, (payload, event) -> { + log.info("[低代码-支付失败] paymentId={}, status={}", payload.getPaymentId(), payload.getPaymentStatus()); + }); + } + + private void testLowCodeRoutes() { + log.info("========== 低代码路由测试 =========="); + + OrderEvent normalOrder = OrderEvent.builder() + .orderId("LC-001") + .orderNo("LC-ORD-2024-001") + .status("CREATED") + .amount(500.0) + .build(); + + OrderEvent highAmountOrder = OrderEvent.builder() + .orderId("LC-002") + .orderNo("LC-ORD-2024-002") + .status("CREATED") + .amount(2000.0) + .build(); + + eventRouter.route(StreamEvent.of("orderCreated", normalOrder)); + eventRouter.route(StreamEvent.of("orderCreated", highAmountOrder)); + + PaymentEvent paymentEvent = PaymentEvent.builder() + .paymentId("LC-PAY-001") + .orderId("LC-001") + .paymentStatus("SUCCESS") + .amount(500.0) + .build(); + + eventRouter.route(StreamEvent.of("paymentSuccess", paymentEvent)); + + log.info("========== 低代码路由测试完成 =========="); + } +} diff --git a/structure-infra-sample/structure-infra-sample-stream/src/test/resources/application.yml b/structure-infra-sample/structure-infra-sample-stream/src/test/resources/application.yml new file mode 100644 index 0000000..ea514fe --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-stream/src/test/resources/application.yml @@ -0,0 +1,20 @@ +spring: + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + - org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration + - org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration + - org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration + - org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration + - org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration + cloud: + stream: + default-binder: test + +structure: + infra: + stream: + enabled: true + auto-binding: true + default-group: test-group + default-concurrency: 1 diff --git a/structure-infra-sample/structure-infra-sample-xxljob/pom.xml b/structure-infra-sample/structure-infra-sample-xxljob/pom.xml new file mode 100644 index 0000000..764ebf2 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-xxljob/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + cn.structured + structure-infra-sample + ${revision} + ../pom.xml + + + structure-infra-sample-xxljob + structure-infra-sample-xxljob + XXL-Job 示例模块 + jar + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + + + cn.structured + structure-infra-xxljob-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + org.projectlombok + lombok + provided + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/SampleXxlJob.java b/structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/SampleXxlJob.java new file mode 100644 index 0000000..6b84f3d --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/SampleXxlJob.java @@ -0,0 +1,34 @@ +package cn.structure.infra.sample.xxljob; + +import com.xxl.job.core.handler.annotation.XxlJob; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@Component +public class SampleXxlJob { + + private final AtomicInteger counter = new AtomicInteger(0); + + @XxlJob("sampleJobHandler") + public void sampleJobHandler() { + int count = counter.incrementAndGet(); + + log.info("SampleXxlJob executed, count={}", count); + } + + @XxlJob("simpleJobHandler") + public void simpleJobHandler() { + log.info("SimpleXxlJob executed"); + } + + public int getCounter() { + return counter.get(); + } + + public void resetCounter() { + counter.set(0); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/XxlJobApplication.java b/structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/XxlJobApplication.java new file mode 100644 index 0000000..9eef0c0 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/XxlJobApplication.java @@ -0,0 +1,12 @@ +package cn.structure.infra.sample.xxljob; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class XxlJobApplication { + + public static void main(String[] args) { + SpringApplication.run(XxlJobApplication.class, args); + } +} diff --git a/structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/controller/JobManagerController.java b/structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/controller/JobManagerController.java new file mode 100644 index 0000000..6afc01f --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-xxljob/src/main/java/cn/structure/infra/sample/xxljob/controller/JobManagerController.java @@ -0,0 +1,107 @@ +package cn.structure.infra.sample.xxljob.controller; + +import cn.structure.infra.schedule.ScheduleTask; +import cn.structure.infra.schedule.TaskScheduler; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/job") +public class JobManagerController { + + @Resource + private TaskScheduler taskScheduler; + + @PostMapping("/add") + public ScheduleTask add( + @RequestParam("taskId") String taskId, + @RequestParam("taskName") String taskName, + @RequestParam("handlerName") String handlerName, + @RequestParam(value = "handlerParam", required = false) String handlerParam, + @RequestParam(value = "cronExpression", defaultValue = "0/5 * * * * ?") String cronExpression) { + + ScheduleTask task = ScheduleTask.builder() + .taskId(taskId) + .taskName(taskName) + .handlerName(handlerName) + .handlerParam(handlerParam) + .scheduleType(ScheduleTask.ScheduleType.CRON) + .cronExpression(cronExpression) + .build(); + + taskScheduler.schedule(task); + return taskScheduler.getTaskInfo(taskId); + } + + @PutMapping("/update/{taskId}") + public ScheduleTask update( + @PathVariable("taskId") String taskId, + @RequestParam(value = "taskName", required = false) String taskName, + @RequestParam(value = "handlerName", required = false) String handlerName, + @RequestParam(value = "handlerParam", required = false) String handlerParam, + @RequestParam(value = "cronExpression", required = false) String cronExpression) { + + ScheduleTask existingTask = taskScheduler.getTaskInfo(taskId); + if (existingTask == null) { + throw new IllegalArgumentException("Task not found: " + taskId); + } + + ScheduleTask.ScheduleTaskBuilder builder = ScheduleTask.builder() + .taskId(taskId) + .taskName(taskName != null ? taskName : existingTask.getTaskName()) + .handlerName(handlerName != null ? handlerName : existingTask.getHandlerName()) + .handlerParam(handlerParam != null ? handlerParam : existingTask.getHandlerParam()) + .scheduleType(existingTask.getScheduleType()); + + if (cronExpression != null) { + builder.cronExpression(cronExpression); + } else if (existingTask.getCronExpression() != null) { + builder.cronExpression(existingTask.getCronExpression()); + } + + ScheduleTask task = builder.build(); + taskScheduler.update(task); + return taskScheduler.getTaskInfo(taskId); + } + + @DeleteMapping("/remove/{taskId}") + public String remove(@PathVariable("taskId") String taskId) { + ScheduleTask task = taskScheduler.getTaskInfo(taskId); + if (task == null) { + return "Task not found: " + taskId; + } + taskScheduler.remove(taskId); + return "Removed task: " + taskId; + } + + @PutMapping("/pause/{taskId}") + public ScheduleTask pause(@PathVariable("taskId") String taskId) { + taskScheduler.pause(taskId); + return taskScheduler.getTaskInfo(taskId); + } + + @PutMapping("/resume/{taskId}") + public ScheduleTask resume(@PathVariable("taskId") String taskId) { + taskScheduler.resume(taskId); + return taskScheduler.getTaskInfo(taskId); + } + + @GetMapping("/info/{taskId}") + public ScheduleTask getTaskInfo(@PathVariable("taskId") String taskId) { + return taskScheduler.getTaskInfo(taskId); + } + + @GetMapping("/list") + public List getAllTasks() { + return taskScheduler.getAllTasks(); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-xxljob/src/main/resources/application.yaml b/structure-infra-sample/structure-infra-sample-xxljob/src/main/resources/application.yaml new file mode 100644 index 0000000..49ba714 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-xxljob/src/main/resources/application.yaml @@ -0,0 +1,9 @@ +server: + port: 12001 +structure: + job: + enable: true + admin-address: http://localhost:8080 + executor: + appname: xxl-job-executor-sample + access-token: xxl-job-admin-token \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-xxljob/src/test/resources/application-xxljob-test.yml b/structure-infra-sample/structure-infra-sample-xxljob/src/test/resources/application-xxljob-test.yml new file mode 100644 index 0000000..baae450 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-xxljob/src/test/resources/application-xxljob-test.yml @@ -0,0 +1,15 @@ +structure: + schedule: + xxl-job: + enabled: true + admin-address: http://localhost:8080/xxl-job-admin + access-token: default_token + job-group: 1 + executor-app-name: structure-infra-sample-xxljob + executor-port: 9999 + executor-log-path: /data/applogs/xxl-job/jobhandler + executor-log-retention-days: 30 + +spring: + application: + name: structure-infra-sample-xxljob \ No newline at end of file diff --git a/structure-infra-schedule-starter/pom.xml b/structure-infra-schedule-starter/pom.xml new file mode 100644 index 0000000..5833d29 --- /dev/null +++ b/structure-infra-schedule-starter/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + cn.structured + structure-pro-infra + ${revision} + ../pom.xml + + + structure-pro-schedule-starter + structure-infra-schedule-starter + structure-pro-schedule-starter + jar + + + + cn.structured + structure-common + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-starter-test + test + + + + \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java new file mode 100644 index 0000000..819e81f --- /dev/null +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java @@ -0,0 +1,37 @@ +package cn.structure.infra.configuration; + +import cn.structure.infra.properties.ScheduleProperties; +import cn.structure.infra.schedule.DefaultTaskHandlerRegistry; +import cn.structure.infra.schedule.LocalThreadTaskScheduler; +import cn.structure.infra.schedule.SpringTaskSchedulerAdapter; +import cn.structure.infra.schedule.TaskHandlerRegistry; +import cn.structure.infra.schedule.TaskScheduler; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(ScheduleProperties.class) +public class AutoScheduleConfiguration { + + @Bean + @ConditionalOnMissingBean(TaskHandlerRegistry.class) + public TaskHandlerRegistry taskHandlerRegistry() { + return new DefaultTaskHandlerRegistry(); + } + + @Bean + @ConditionalOnMissingBean(TaskScheduler.class) + public TaskScheduler taskScheduler(ScheduleProperties scheduleProperties, TaskHandlerRegistry handlerRegistry) { + Integer poolSize = scheduleProperties.getPoolSize(); + return new LocalThreadTaskScheduler(poolSize != null ? poolSize : Runtime.getRuntime().availableProcessors(), handlerRegistry); + } + + @Bean + @ConditionalOnBean(LocalThreadTaskScheduler.class) + public org.springframework.scheduling.TaskScheduler springTaskScheduler(LocalThreadTaskScheduler taskScheduler, TaskHandlerRegistry handlerRegistry) { + return new SpringTaskSchedulerAdapter(taskScheduler, handlerRegistry); + } +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/properties/ScheduleProperties.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/properties/ScheduleProperties.java new file mode 100644 index 0000000..ffec34d --- /dev/null +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/properties/ScheduleProperties.java @@ -0,0 +1,11 @@ +package cn.structure.infra.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "structure.schedule") +public class ScheduleProperties { + + private Integer poolSize = Runtime.getRuntime().availableProcessors(); +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistry.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistry.java new file mode 100644 index 0000000..da47820 --- /dev/null +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistry.java @@ -0,0 +1,37 @@ +package cn.structure.infra.schedule; + +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +public class DefaultTaskHandlerRegistry implements TaskHandlerRegistry { + + private final Map handlerMap = new ConcurrentHashMap<>(); + + @Override + public void register(String handlerName, TaskHandler handler) { + if (handlerName == null || handler == null) { + throw new IllegalArgumentException("Handler name and handler cannot be null"); + } + handlerMap.put(handlerName, handler); + log.info("Registered task handler: {}", handlerName); + } + + @Override + public TaskHandler get(String handlerName) { + return handlerMap.get(handlerName); + } + + @Override + public void unregister(String handlerName) { + handlerMap.remove(handlerName); + log.info("Unregistered task handler: {}", handlerName); + } + + @Override + public boolean contains(String handlerName) { + return handlerMap.containsKey(handlerName); + } +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java new file mode 100644 index 0000000..bc0842e --- /dev/null +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java @@ -0,0 +1,184 @@ +package cn.structure.infra.schedule; + +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class LocalThreadTaskScheduler implements TaskScheduler { + + private final ScheduledExecutorService executorService; + + private final Map> futureMap = new ConcurrentHashMap<>(); + + private final Map taskMap = new ConcurrentHashMap<>(); + + private final TaskHandlerRegistry handlerRegistry; + + public LocalThreadTaskScheduler(TaskHandlerRegistry handlerRegistry) { + this(Runtime.getRuntime().availableProcessors(), handlerRegistry); + } + + public LocalThreadTaskScheduler(int poolSize, TaskHandlerRegistry handlerRegistry) { + this.executorService = Executors.newScheduledThreadPool(poolSize, r -> { + Thread thread = new Thread(r); + thread.setName("structure-schedule-" + thread.getId()); + thread.setDaemon(true); + return thread; + }); + this.handlerRegistry = handlerRegistry; + log.info("LocalThreadTaskScheduler initialized with pool size: {}", poolSize); + } + + @Override + public void schedule(ScheduleTask task) { + validateTask(task); + + remove(task.getTaskId()); + + ScheduledFuture future; + Runnable wrappedRunnable = wrapRunnable(task); + + switch (task.getScheduleType()) { + case FIXED_DELAY: + long delay = task.getDelay() != null ? task.getDelay() : 1000L; + long initialDelay = task.getInitialDelay() != null ? task.getInitialDelay() : 0L; + TimeUnit timeUnit = task.getTimeUnit() != null ? task.getTimeUnit() : TimeUnit.MILLISECONDS; + future = executorService.scheduleWithFixedDelay(wrappedRunnable, initialDelay, delay, timeUnit); + break; + + case FIXED_RATE: + long period = task.getPeriod() != null ? task.getPeriod() : 1000L; + initialDelay = task.getInitialDelay() != null ? task.getInitialDelay() : 0L; + timeUnit = task.getTimeUnit() != null ? task.getTimeUnit() : TimeUnit.MILLISECONDS; + future = executorService.scheduleAtFixedRate(wrappedRunnable, initialDelay, period, timeUnit); + break; + + case CRON: + if (task.getCronExpression() == null || task.getCronExpression().isEmpty()) { + throw new IllegalArgumentException("Cron expression cannot be null for CRON schedule type"); + } + future = scheduleCronTask(task, wrappedRunnable); + break; + + default: + throw new IllegalArgumentException("Unsupported schedule type: " + task.getScheduleType()); + } + + futureMap.put(task.getTaskId(), future); + task.setStatus(ScheduleTask.TaskStatus.RUNNING); + taskMap.put(task.getTaskId(), task); + + log.info("Scheduled task: id={}, name={}, type={}, handler={}", task.getTaskId(), task.getTaskName(), task.getScheduleType(), task.getHandlerName()); + } + + @Override + public void update(ScheduleTask task) { + validateTask(task); + + ScheduleTask existingTask = taskMap.get(task.getTaskId()); + if (existingTask == null) { + log.warn("Task not found for update: {}", task.getTaskId()); + return; + } + + schedule(task); + log.info("Updated task: id={}", task.getTaskId()); + } + + private void validateTask(ScheduleTask task) { + if (task == null || task.getTaskId() == null) { + throw new IllegalArgumentException("Task and taskId cannot be null"); + } + + if (task.getHandlerName() == null || task.getHandlerName().isEmpty()) { + throw new IllegalArgumentException("Handler name cannot be null or empty"); + } + + if (!handlerRegistry.contains(task.getHandlerName())) { + throw new IllegalArgumentException("Handler not found: " + task.getHandlerName()); + } + + if (task.getScheduleType() == null) { + throw new IllegalArgumentException("ScheduleType cannot be null"); + } + } + + private ScheduledFuture scheduleCronTask(ScheduleTask task, Runnable wrappedRunnable) { + return executorService.scheduleAtFixedRate(() -> { + try { + wrappedRunnable.run(); + } catch (Exception e) { + log.error("Cron task execution failed: id={}, error={}", task.getTaskId(), e.getMessage(), e); + } + }, 0, 1000, TimeUnit.MILLISECONDS); + } + + private Runnable wrapRunnable(ScheduleTask task) { + return () -> { + try { + TaskHandler handler = handlerRegistry.get(task.getHandlerName()); + if (handler != null) { + handler.execute(task.getHandlerParam()); + } else { + log.error("Handler not found during execution: {}", task.getHandlerName()); + } + } catch (Exception e) { + log.error("Task execution failed: id={}, name={}, handler={}, error={}", task.getTaskId(), task.getTaskName(), task.getHandlerName(), e.getMessage(), e); + } + }; + } + + @Override + public void remove(String taskId) { + ScheduledFuture future = futureMap.remove(taskId); + if (future != null) { + future.cancel(false); + } + + ScheduleTask task = taskMap.remove(taskId); + if (task != null) { + task.setStatus(ScheduleTask.TaskStatus.STOPPED); + } + + log.info("Removed task: id={}", taskId); + } + + @Override + public void pause(String taskId) { + ScheduledFuture future = futureMap.get(taskId); + if (future != null) { + future.cancel(false); + ScheduleTask task = taskMap.get(taskId); + if (task != null) { + task.setStatus(ScheduleTask.TaskStatus.PAUSED); + } + log.info("Paused task: id={}", taskId); + } + } + + @Override + public void resume(String taskId) { + ScheduleTask task = taskMap.get(taskId); + if (task != null && task.getStatus() == ScheduleTask.TaskStatus.PAUSED) { + schedule(task); + log.info("Resumed task: id={}", taskId); + } + } + + @Override + public ScheduleTask getTaskInfo(String taskId) { + return taskMap.get(taskId); + } + + @Override + public List getAllTasks() { + return List.copyOf(taskMap.values()); + } +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java new file mode 100644 index 0000000..fbc3787 --- /dev/null +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java @@ -0,0 +1,51 @@ +package cn.structure.infra.schedule; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.concurrent.TimeUnit; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleTask { + + private String taskId; + + private String taskName; + + private String handlerName; + + private String handlerParam; + + private ScheduleType scheduleType; + + private String cronExpression; + + private Long initialDelay; + + private Long delay; + + private Long period; + + private TimeUnit timeUnit; + + @Builder.Default + private TaskStatus status = TaskStatus.PENDING; + + public enum ScheduleType { + CRON, + FIXED_DELAY, + FIXED_RATE + } + + public enum TaskStatus { + PENDING, + RUNNING, + PAUSED, + STOPPED + } +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapter.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapter.java new file mode 100644 index 0000000..0a25a03 --- /dev/null +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapter.java @@ -0,0 +1,229 @@ +package cn.structure.infra.schedule; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.Trigger; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class SpringTaskSchedulerAdapter implements TaskScheduler { + + private final LocalThreadTaskScheduler localThreadTaskScheduler; + + private final TaskHandlerRegistry handlerRegistry; + + public SpringTaskSchedulerAdapter(LocalThreadTaskScheduler localThreadTaskScheduler, + TaskHandlerRegistry handlerRegistry) { + this.localThreadTaskScheduler = localThreadTaskScheduler; + this.handlerRegistry = handlerRegistry; + } + + @Override + public ScheduledFuture schedule(Runnable task, Trigger trigger) { + String handlerName = "spring-trigger-task-" + System.currentTimeMillis(); + handlerRegistry.register(handlerName, param -> task.run()); + + ScheduleTask scheduleTask = ScheduleTask.builder() + .taskId(handlerName) + .taskName("Spring Trigger Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + localThreadTaskScheduler.schedule(scheduleTask); + + return new ScheduledFuture() { + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + localThreadTaskScheduler.remove(handlerName); + handlerRegistry.unregister(handlerName); + return true; + } + + @Override + public boolean isCancelled() { + return localThreadTaskScheduler.getTaskInfo(handlerName) == null; + } + + @Override + public boolean isDone() { + return isCancelled(); + } + + @Override + public Void get() { + return null; + } + + @Override + public Void get(long timeout, TimeUnit unit) { + return null; + } + + @Override + public long getDelay(TimeUnit unit) { + return 0; + } + + @Override + public int compareTo(java.util.concurrent.Delayed other) { + return 0; + } + }; + } + + @Override + public ScheduledFuture schedule(Runnable task, Instant startTime) { + String handlerName = "spring-delay-task-" + System.currentTimeMillis(); + handlerRegistry.register(handlerName, param -> task.run()); + + long initialDelay = Duration.between(Instant.now(), startTime).toMillis(); + if (initialDelay < 0) { + initialDelay = 0; + } + + ScheduleTask scheduleTask = ScheduleTask.builder() + .taskId(handlerName) + .taskName("Spring Delay Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_DELAY) + .delay(Long.MAX_VALUE) + .initialDelay(initialDelay) + .build(); + + localThreadTaskScheduler.schedule(scheduleTask); + + return createScheduledFuture(handlerName); + } + + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable task, Instant startTime, Duration period) { + String handlerName = "spring-fixed-rate-task-" + System.currentTimeMillis(); + handlerRegistry.register(handlerName, param -> task.run()); + + long initialDelay = Duration.between(Instant.now(), startTime).toMillis(); + if (initialDelay < 0) { + initialDelay = 0; + } + + ScheduleTask scheduleTask = ScheduleTask.builder() + .taskId(handlerName) + .taskName("Spring Fixed Rate Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(period.toMillis()) + .initialDelay(initialDelay) + .build(); + + localThreadTaskScheduler.schedule(scheduleTask); + + return createScheduledFuture(handlerName); + } + + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable task, Duration period) { + String handlerName = "spring-fixed-rate-task-" + System.currentTimeMillis(); + handlerRegistry.register(handlerName, param -> task.run()); + + ScheduleTask scheduleTask = ScheduleTask.builder() + .taskId(handlerName) + .taskName("Spring Fixed Rate Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(period.toMillis()) + .build(); + + localThreadTaskScheduler.schedule(scheduleTask); + + return createScheduledFuture(handlerName); + } + + @Override + public ScheduledFuture scheduleWithFixedDelay(Runnable task, Instant startTime, Duration delay) { + String handlerName = "spring-fixed-delay-task-" + System.currentTimeMillis(); + handlerRegistry.register(handlerName, param -> task.run()); + + long initialDelay = Duration.between(Instant.now(), startTime).toMillis(); + if (initialDelay < 0) { + initialDelay = 0; + } + + ScheduleTask scheduleTask = ScheduleTask.builder() + .taskId(handlerName) + .taskName("Spring Fixed Delay Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_DELAY) + .delay(delay.toMillis()) + .initialDelay(initialDelay) + .build(); + + localThreadTaskScheduler.schedule(scheduleTask); + + return createScheduledFuture(handlerName); + } + + @Override + public ScheduledFuture scheduleWithFixedDelay(Runnable task, Duration delay) { + String handlerName = "spring-fixed-delay-task-" + System.currentTimeMillis(); + handlerRegistry.register(handlerName, param -> task.run()); + + ScheduleTask scheduleTask = ScheduleTask.builder() + .taskId(handlerName) + .taskName("Spring Fixed Delay Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_DELAY) + .delay(delay.toMillis()) + .build(); + + localThreadTaskScheduler.schedule(scheduleTask); + + return createScheduledFuture(handlerName); + } + + private ScheduledFuture createScheduledFuture(String handlerName) { + return new ScheduledFuture() { + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + localThreadTaskScheduler.remove(handlerName); + handlerRegistry.unregister(handlerName); + return true; + } + + @Override + public boolean isCancelled() { + return localThreadTaskScheduler.getTaskInfo(handlerName) == null; + } + + @Override + public boolean isDone() { + return isCancelled(); + } + + @Override + public Void get() { + return null; + } + + @Override + public Void get(long timeout, TimeUnit unit) { + return null; + } + + @Override + public long getDelay(TimeUnit unit) { + return 0; + } + + @Override + public int compareTo(java.util.concurrent.Delayed other) { + return 0; + } + }; + } +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandler.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandler.java new file mode 100644 index 0000000..89cfe81 --- /dev/null +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandler.java @@ -0,0 +1,7 @@ +package cn.structure.infra.schedule; + +@FunctionalInterface +public interface TaskHandler { + + void execute(String param); +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandlerRegistry.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandlerRegistry.java new file mode 100644 index 0000000..0012ebe --- /dev/null +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandlerRegistry.java @@ -0,0 +1,12 @@ +package cn.structure.infra.schedule; + +public interface TaskHandlerRegistry { + + void register(String handlerName, TaskHandler handler); + + TaskHandler get(String handlerName); + + void unregister(String handlerName); + + boolean contains(String handlerName); +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskScheduler.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskScheduler.java new file mode 100644 index 0000000..41f520c --- /dev/null +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskScheduler.java @@ -0,0 +1,20 @@ +package cn.structure.infra.schedule; + +import java.util.List; + +public interface TaskScheduler { + + void schedule(ScheduleTask task); + + void update(ScheduleTask task); + + void remove(String taskId); + + void pause(String taskId); + + void resume(String taskId); + + ScheduleTask getTaskInfo(String taskId); + + List getAllTasks(); +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/structure-infra-schedule-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..134f052 --- /dev/null +++ b/structure-infra-schedule-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.structure.infra.configuration.AutoScheduleConfiguration \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistryTest.java b/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistryTest.java new file mode 100644 index 0000000..d41d1c4 --- /dev/null +++ b/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistryTest.java @@ -0,0 +1,79 @@ +package cn.structure.infra.schedule; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class DefaultTaskHandlerRegistryTest { + + private DefaultTaskHandlerRegistry registry; + + @BeforeEach + void setUp() { + registry = new DefaultTaskHandlerRegistry(); + } + + @Test + void testRegisterAndGet() { + String handlerName = "test-handler"; + TaskHandler handler = param -> {}; + + registry.register(handlerName, handler); + + assertTrue(registry.contains(handlerName)); + assertNotNull(registry.get(handlerName)); + assertSame(handler, registry.get(handlerName)); + } + + @Test + void testRegisterWithNullHandlerName() { + assertThrows(IllegalArgumentException.class, () -> registry.register(null, param -> {})); + } + + @Test + void testRegisterWithNullHandler() { + assertThrows(IllegalArgumentException.class, () -> registry.register("test-handler", null)); + } + + @Test + void testUnregister() { + String handlerName = "test-handler"; + TaskHandler handler = param -> {}; + + registry.register(handlerName, handler); + assertTrue(registry.contains(handlerName)); + + registry.unregister(handlerName); + assertFalse(registry.contains(handlerName)); + assertNull(registry.get(handlerName)); + } + + @Test + void testUnregisterNonExistent() { + assertDoesNotThrow(() -> registry.unregister("non-existent")); + } + + @Test + void testGetNonExistent() { + assertNull(registry.get("non-existent")); + } + + @Test + void testContainsNonExistent() { + assertFalse(registry.contains("non-existent")); + } + + @Test + void testRegisterOverwrite() { + String handlerName = "test-handler"; + TaskHandler handler1 = param -> {}; + TaskHandler handler2 = param -> {}; + + registry.register(handlerName, handler1); + assertSame(handler1, registry.get(handlerName)); + + registry.register(handlerName, handler2); + assertSame(handler2, registry.get(handlerName)); + } +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/LocalThreadTaskSchedulerTest.java b/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/LocalThreadTaskSchedulerTest.java new file mode 100644 index 0000000..4775888 --- /dev/null +++ b/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/LocalThreadTaskSchedulerTest.java @@ -0,0 +1,315 @@ +package cn.structure.infra.schedule; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class LocalThreadTaskSchedulerTest { + + private LocalThreadTaskScheduler scheduler; + private DefaultTaskHandlerRegistry registry; + + @BeforeEach + void setUp() { + registry = new DefaultTaskHandlerRegistry(); + scheduler = new LocalThreadTaskScheduler(2, registry); + } + + @AfterEach + void tearDown() { + scheduler.getAllTasks().forEach(task -> scheduler.remove(task.getTaskId())); + } + + @Test + void testScheduleFixedRateTask() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + String handlerName = "fixed-rate-handler"; + registry.register(handlerName, param -> counter.incrementAndGet()); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-fixed-rate") + .taskName("Fixed Rate Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(100L) + .initialDelay(0L) + .build(); + + scheduler.schedule(task); + + Thread.sleep(500); + + assertTrue(counter.get() >= 4, "Fixed rate task should execute at least 4 times"); + assertEquals(ScheduleTask.TaskStatus.RUNNING, scheduler.getTaskInfo("test-fixed-rate").getStatus()); + + scheduler.remove("test-fixed-rate"); + registry.unregister(handlerName); + } + + @Test + void testScheduleFixedDelayTask() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + String handlerName = "fixed-delay-handler"; + registry.register(handlerName, param -> counter.incrementAndGet()); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-fixed-delay") + .taskName("Fixed Delay Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_DELAY) + .delay(100L) + .initialDelay(0L) + .build(); + + scheduler.schedule(task); + + Thread.sleep(500); + + assertTrue(counter.get() >= 4, "Fixed delay task should execute at least 4 times"); + + scheduler.remove("test-fixed-delay"); + registry.unregister(handlerName); + } + + @Test + void testPauseAndResumeTask() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + String handlerName = "pause-resume-handler"; + registry.register(handlerName, param -> counter.incrementAndGet()); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-pause-resume") + .taskName("Pause Resume Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(100L) + .build(); + + scheduler.schedule(task); + + Thread.sleep(300); + int countBeforePause = counter.get(); + + scheduler.pause("test-pause-resume"); + assertEquals(ScheduleTask.TaskStatus.PAUSED, scheduler.getTaskInfo("test-pause-resume").getStatus()); + + Thread.sleep(300); + int countAfterPause = counter.get(); + assertEquals(countBeforePause, countAfterPause, "Task should not execute while paused"); + + scheduler.resume("test-pause-resume"); + assertEquals(ScheduleTask.TaskStatus.RUNNING, scheduler.getTaskInfo("test-pause-resume").getStatus()); + + Thread.sleep(300); + assertTrue(counter.get() > countAfterPause, "Task should resume executing"); + + scheduler.remove("test-pause-resume"); + registry.unregister(handlerName); + } + + @Test + void testRemoveTask() throws InterruptedException { + String handlerName = "remove-handler"; + registry.register(handlerName, param -> {}); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-remove") + .taskName("Remove Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(100L) + .build(); + + scheduler.schedule(task); + assertNotNull(scheduler.getTaskInfo("test-remove")); + + scheduler.remove("test-remove"); + + assertNull(scheduler.getTaskInfo("test-remove")); + registry.unregister(handlerName); + } + + @Test + void testGetAllTasks() throws InterruptedException { + String handlerName1 = "handler-1"; + String handlerName2 = "handler-2"; + registry.register(handlerName1, param -> {}); + registry.register(handlerName2, param -> {}); + + ScheduleTask task1 = ScheduleTask.builder() + .taskId("task-1") + .taskName("Task 1") + .handlerName(handlerName1) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + ScheduleTask task2 = ScheduleTask.builder() + .taskId("task-2") + .taskName("Task 2") + .handlerName(handlerName2) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + scheduler.schedule(task1); + scheduler.schedule(task2); + + Thread.sleep(50); + + assertEquals(2, scheduler.getAllTasks().size()); + + scheduler.remove("task-1"); + scheduler.remove("task-2"); + registry.unregister(handlerName1); + registry.unregister(handlerName2); + + assertEquals(0, scheduler.getAllTasks().size()); + } + + @Test + void testUpdateTask() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + String handlerName = "update-handler"; + registry.register(handlerName, param -> counter.incrementAndGet()); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-update") + .taskName("Update Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(500L) + .build(); + + scheduler.schedule(task); + + Thread.sleep(1200); + int countBeforeUpdate = counter.get(); + + ScheduleTask updatedTask = ScheduleTask.builder() + .taskId("test-update") + .taskName("Updated Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(100L) + .build(); + + scheduler.update(updatedTask); + + Thread.sleep(500); + + assertTrue(counter.get() > countBeforeUpdate + 2, "Updated task should execute more frequently"); + + scheduler.remove("test-update"); + registry.unregister(handlerName); + } + + @Test + void testScheduleWithNullTask() { + assertThrows(IllegalArgumentException.class, () -> scheduler.schedule(null)); + } + + @Test + void testScheduleWithNullTaskId() { + String handlerName = "test-handler"; + registry.register(handlerName, param -> {}); + + ScheduleTask task = ScheduleTask.builder() + .taskId(null) + .taskName("Test Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + assertThrows(IllegalArgumentException.class, () -> scheduler.schedule(task)); + registry.unregister(handlerName); + } + + @Test + void testScheduleWithNullHandlerName() { + ScheduleTask task = ScheduleTask.builder() + .taskId("test-null-handler") + .taskName("Test Task") + .handlerName(null) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + assertThrows(IllegalArgumentException.class, () -> scheduler.schedule(task)); + } + + @Test + void testScheduleWithNonExistentHandler() { + ScheduleTask task = ScheduleTask.builder() + .taskId("test-non-existent") + .taskName("Test Task") + .handlerName("non-existent-handler") + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + assertThrows(IllegalArgumentException.class, () -> scheduler.schedule(task)); + } + + @Test + void testScheduleWithNullScheduleType() { + String handlerName = "test-type-handler"; + registry.register(handlerName, param -> {}); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-null-type") + .taskName("Test Task") + .handlerName(handlerName) + .scheduleType(null) + .period(1000L) + .build(); + + assertThrows(IllegalArgumentException.class, () -> scheduler.schedule(task)); + registry.unregister(handlerName); + } + + @Test + void testPauseNonExistentTask() { + assertDoesNotThrow(() -> scheduler.pause("non-existent")); + } + + @Test + void testResumeNonExistentTask() { + assertDoesNotThrow(() -> scheduler.resume("non-existent")); + } + + @Test + void testRemoveNonExistentTask() { + assertDoesNotThrow(() -> scheduler.remove("non-existent")); + } + + @Test + void testResumeNotPausedTask() throws InterruptedException { + String handlerName = "resume-handler"; + registry.register(handlerName, param -> {}); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-resume-running") + .taskName("Resume Running Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(1000L) + .build(); + + scheduler.schedule(task); + + Thread.sleep(50); + + scheduler.resume("test-resume-running"); + + assertNotNull(scheduler.getTaskInfo("test-resume-running")); + + scheduler.remove("test-resume-running"); + registry.unregister(handlerName); + } +} \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapterTest.java b/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapterTest.java new file mode 100644 index 0000000..6d6b952 --- /dev/null +++ b/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapterTest.java @@ -0,0 +1,150 @@ +package cn.structure.infra.schedule; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class SpringTaskSchedulerAdapterTest { + + private SpringTaskSchedulerAdapter adapter; + private LocalThreadTaskScheduler localScheduler; + private DefaultTaskHandlerRegistry registry; + + @BeforeEach + void setUp() { + registry = new DefaultTaskHandlerRegistry(); + localScheduler = new LocalThreadTaskScheduler(2, registry); + adapter = new SpringTaskSchedulerAdapter(localScheduler, registry); + } + + @AfterEach + void tearDown() { + localScheduler.getAllTasks().forEach(task -> localScheduler.remove(task.getTaskId())); + } + + @Test + void testScheduleAtFixedRateWithDuration() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + + var future = adapter.scheduleAtFixedRate(() -> counter.incrementAndGet(), Duration.ofMillis(100)); + + Thread.sleep(500); + + assertTrue(counter.get() >= 4, "Task should execute at least 4 times"); + + assertTrue(future.cancel(false)); + assertTrue(future.isDone()); + } + + @Test + void testScheduleAtFixedRateWithStartTime() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + + Instant startTime = Instant.now().plusMillis(100); + var future = adapter.scheduleAtFixedRate(() -> counter.incrementAndGet(), startTime, Duration.ofMillis(100)); + + Thread.sleep(600); + + assertTrue(counter.get() >= 4, "Task should execute at least 4 times"); + + assertTrue(future.cancel(false)); + } + + @Test + void testScheduleWithFixedDelay() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + + var future = adapter.scheduleWithFixedDelay(() -> counter.incrementAndGet(), Duration.ofMillis(100)); + + Thread.sleep(500); + + assertTrue(counter.get() >= 4, "Task should execute at least 4 times"); + + assertTrue(future.cancel(false)); + } + + @Test + void testScheduleWithFixedDelayAndStartTime() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + + Instant startTime = Instant.now().plusMillis(100); + var future = adapter.scheduleWithFixedDelay(() -> counter.incrementAndGet(), startTime, Duration.ofMillis(100)); + + Thread.sleep(600); + + assertTrue(counter.get() >= 4, "Task should execute at least 4 times"); + + assertTrue(future.cancel(false)); + } + + @Test + void testScheduleWithInstant() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + + Instant startTime = Instant.now().plusMillis(50); + var future = adapter.schedule(() -> counter.incrementAndGet(), startTime); + + Thread.sleep(200); + + assertEquals(1, counter.get(), "Task should execute once after delay"); + + assertTrue(future.cancel(false)); + } + + @Test + void testScheduleWithTrigger() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + + var future = adapter.schedule(() -> counter.incrementAndGet(), triggerContext -> Instant.now().plusMillis(100)); + + Thread.sleep(3500); + + assertTrue(counter.get() >= 3, "Task should execute at least 3 times"); + + assertTrue(future.cancel(false)); + } + + @Test + void testCancelTask() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + + var future = adapter.scheduleAtFixedRate(() -> counter.incrementAndGet(), Duration.ofMillis(50)); + + Thread.sleep(200); + int countBeforeCancel = counter.get(); + + assertTrue(future.cancel(false)); + + Thread.sleep(200); + + assertEquals(countBeforeCancel, counter.get(), "Task should stop executing after cancel"); + } + + @Test + void testIsCancelled() throws InterruptedException { + var future = adapter.scheduleAtFixedRate(() -> {}, Duration.ofMillis(100)); + + assertFalse(future.isCancelled()); + + future.cancel(false); + + assertTrue(future.isCancelled()); + } + + @Test + void testIsDone() throws InterruptedException { + var future = adapter.scheduleAtFixedRate(() -> {}, Duration.ofMillis(100)); + + assertFalse(future.isDone()); + + future.cancel(false); + + assertTrue(future.isDone()); + } +} \ No newline at end of file diff --git a/structure-infra-starter/pom.xml b/structure-infra-starter/pom.xml index bf8264e..5a0e82c 100644 --- a/structure-infra-starter/pom.xml +++ b/structure-infra-starter/pom.xml @@ -28,10 +28,19 @@ cn.structured structure-datascope-message + + org.springframework.boot + spring-boot-data-commons + cn.structured structure-datascope-cache + + cn.structured + structure-infra-schedule-starter + ${revision} + \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java b/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java new file mode 100644 index 0000000..9f334b1 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java @@ -0,0 +1,39 @@ +package cn.structure.infra.configuration; + +import cn.structure.infra.properties.InfraProperties; +import cn.structure.infra.schedule.DefaultTaskHandlerRegistry; +import cn.structure.infra.schedule.LocalThreadTaskScheduler; +import cn.structure.infra.schedule.SpringTaskSchedulerAdapter; +import cn.structure.infra.schedule.TaskHandlerRegistry; +import cn.structure.infra.schedule.TaskScheduler; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(InfraProperties.class) +public class AutoScheduleConfiguration { + + @Bean + @ConditionalOnMissingBean(TaskHandlerRegistry.class) + public TaskHandlerRegistry taskHandlerRegistry() { + return new DefaultTaskHandlerRegistry(); + } + + @Bean + @ConditionalOnMissingBean(TaskScheduler.class) + public TaskScheduler taskScheduler(InfraProperties infraProperties, TaskHandlerRegistry handlerRegistry) { + Integer poolSize = infraProperties.getSchedulePoolSize(); + return new LocalThreadTaskScheduler(poolSize != null ? poolSize : Runtime.getRuntime().availableProcessors(), handlerRegistry); + } + + @Bean + @ConditionalOnMissingBean(name = "springTaskScheduler") + public org.springframework.scheduling.TaskScheduler springTaskScheduler(TaskScheduler taskScheduler, TaskHandlerRegistry handlerRegistry) { + if (taskScheduler instanceof LocalThreadTaskScheduler) { + return new SpringTaskSchedulerAdapter((LocalThreadTaskScheduler) taskScheduler, handlerRegistry); + } + return null; + } +} \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java b/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java index 53be23e..7dca4f7 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java @@ -35,4 +35,9 @@ public class InfraProperties { * @return */ private TimeUnit cacheTimeUnit = TimeUnit.SECONDS; + + /** + * 调度线程池大小,默认 CPU 核心数 + */ + private Integer schedulePoolSize = Runtime.getRuntime().availableProcessors(); } diff --git a/structure-infra-stream-starter/README.md b/structure-infra-stream-starter/README.md new file mode 100644 index 0000000..4815b47 --- /dev/null +++ b/structure-infra-stream-starter/README.md @@ -0,0 +1,526 @@ +# Structure Infra Stream Starter + +基于 Spring Cloud Stream 的事件监听管理器,提供灵活的事件监听配置和统一路由能力。 + +## 功能特性 + +- **动态配置**:支持动态配置 Stream Binding,替代静态配置 +- **自动绑定**:根据注解自动生成 binding 配置,无需手动配置 +- **统一路由**:基于 eventType/businessType/condition 的统一事件路由 +- **声明式注册**:通过注解自动注册事件处理器 +- **运行时动态注册**:支持运行时动态注册监听器和绑定 +- **低代码支持**:支持配置文件驱动、代码动态注册等多种使用方式 +- **SpEL 条件路由**:支持 SpEL 表达式进行条件过滤 +- **业务解耦**:事件生产者和消费者完全解耦 + +## 快速开始 + +### 添加依赖 + +```xml + + cn.structured + structure-infra-stream-starter + 1.0.0-SNAPSHOT + +``` + +### 基本配置 + +```yaml +structure: + infra: + stream: + enabled: true + auto-binding: true # 自动生成 binding 配置(默认开启) + default-group: my-service # 所有自动绑定的默认消费组 + default-concurrency: 1 # 默认并发数 +``` + +> **重要**:启用 `auto-binding` 后,无需手动配置 `spring.cloud.stream.bindings`,系统会根据注解自动生成! + +## 核心概念 + +### StreamEvent 统一事件信封 + +所有事件通过 `StreamEvent` 封装,包含路由元数据: + +```java +public class StreamEvent { + private String eventId; // 事件唯一标识 + private String eventType; // 事件类型(用于路由) + private String businessType; // 业务类型(可选) + private LocalDateTime timestamp; // 时间戳 + private T payload; // 业务数据 + private Map headers; // 扩展头信息 + private String traceId; // 链路追踪ID +} +``` + +## 使用方式 + +### 方式一:注解声明式(推荐) + +通过 `@StreamRouteHandler` 注解声明路由,自动注册: + +```java +@Component +public class OrderEventListener { + + @StreamRouteHandler(eventType = "orderCreated") + public void handleOrderCreated(OrderEvent event) { + log.info("[订单创建] orderId={}, amount={}", event.getOrderId(), event.getAmount()); + } + + @StreamRouteHandler(eventType = "orderCreated", condition = "#payload.amount > 1000") + public void handleHighAmountOrder(OrderEvent event) { + log.info("[高额订单] 触发风控检查: orderId={}, amount={}", event.getOrderId(), event.getAmount()); + } + + @StreamRouteHandler(eventType = "orderPaid") + public void handleOrderPaid(OrderEvent event) { + log.info("[订单支付] orderId={}", event.getOrderId()); + } + + @StreamRouteHandler(eventType = "orderCancelled", businessType = "retail") + public void handleRetailOrderCancelled(OrderEvent event) { + log.info("[零售订单取消] orderId={}", event.getOrderId()); + } +} +``` + +#### @StreamRouteHandler 注解参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| value / eventType | String | 是 | 事件类型,用于路由匹配 | +| businessType | String | 否 | 业务类型,支持 `*` 通配符 | +| condition | String | 否 | SpEL 表达式条件过滤 | + +### 方式二:代码动态注册 + +通过 `StreamEventRouter` API 动态注册路由: + +```java +@Component +public class RouteConfig implements CommandLineRunner { + + private final StreamEventRouter eventRouter; + + @Override + public void run(String... args) { + // 注册基础路由 + eventRouter.registerRoute("orderCreated", OrderEvent.class, (payload, event) -> { + log.info("[订单创建] orderId={}", payload.getOrderId()); + }); + + // 注册带条件的路由 + eventRouter.registerRoute("orderCreated", OrderEvent.class, + "#payload.amount > 1000", (payload, event) -> { + log.info("[高额订单] 触发风控检查"); + }); + + // 注册带业务类型的路由 + eventRouter.registerRoute("orderCreated", "wholesale", OrderEvent.class, (payload, event) -> { + log.info("[批发订单] orderId={}", payload.getOrderId()); + }); + } +} +``` + +### 方式三:配置文件驱动 + +通过 YAML 配置文件声明路由,真正的低代码: + +```yaml +structure: + infra: + stream: + router: + enabled: true + routes: + - id: route-order-created + event-type: orderCreated + payload-type: cn.structure.infra.sample.stream.event.OrderEvent + handler-bean: orderHandler + handler-method: onOrderCreated + description: 处理订单创建事件 + + - id: route-payment-success + event-type: paymentSuccess + payload-type: cn.structure.infra.sample.stream.event.PaymentEvent + handler-bean: paymentHandler + handler-method: onPaymentSuccess + description: 处理支付成功事件 + + - id: route-high-amount-order + event-type: orderCreated + payload-type: cn.structure.infra.sample.stream.event.OrderEvent + condition: "#payload.amount > 1000" + handler-bean: orderHandler + handler-method: onHighAmountOrder + description: 处理高额订单(金额>1000) +``` + +业务处理器(只需写方法,无需注解): + +```java +@Component("orderHandler") +public class OrderHandler { + + public void onOrderCreated(OrderEvent event) { + log.info("[配置驱动] 订单创建处理: orderId={}", event.getOrderId()); + } + + public void onHighAmountOrder(OrderEvent event) { + log.info("[配置驱动] 高额订单处理: orderId={}, amount={}", event.getOrderId(), event.getAmount()); + } +} +``` + +### 方式四:传统消息监听 + +通过 `@StreamEventListener` 绑定到特定消息队列: + +```java +@Component +public class DeliveryEventListener { + + @StreamEventListener(bindingName = "deliveryEvent", + destination = "delivery-exchange", + group = "delivery-group") + public void handleDelivery(DeliveryEvent event) { + log.info("[配送] deliveryId={}, orderId={}", event.getDeliveryId(), event.getOrderId()); + } +} +``` + +#### @StreamEventListener 注解参数 + +### 方式五:运行时动态注册(推荐) + +支持在应用运行时动态注册监听器和绑定,无需重启应用。 + +#### 通过 StreamEventManager 动态注册 + +```java +@Component +public class DynamicListenerDemo implements CommandLineRunner { + + private final StreamEventManager eventManager; + + @Override + public void run(String... args) { + // 1. 动态注册 binding + eventManager.registerBinding("dynamicOrder", "dynamic-order-exchange", "dynamic-group"); + + // 2. 通过实现类注册监听器 + eventManager.registerListener("dynamicOrder", OrderEvent.class, new OrderEventHandler()); + + // 3. 通过 Lambda 注册监听器 + eventManager.registerListener("dynamicOrder", OrderEvent.class, event -> { + log.info("[Lambda] 动态监听订单: orderId={}", event.getOrderId()); + }); + + // 4. 通过 Lambda + SpEL 条件注册监听器 + eventManager.registerListener("dynamicOrder", OrderEvent.class, + "#payload.amount > 500", event -> { + log.info("[条件] 动态监听大额订单: orderId={}", event.getOrderId()); + }); + + // 5. 动态发布事件(自动创建 binding) + eventManager.publish("dynamicPayment", "dynamic-payment-exchange", paymentEvent); + } + + public static class OrderEventHandler implements StreamEventHandler { + @Override + public void handle(OrderEvent event) { + log.info("[实现类] 动态监听订单: orderId={}", event.getOrderId()); + } + } +} +``` + +#### 通过 StreamEventRouter 动态注册路由 + +```java +@Component +public class DynamicRouteDemo implements CommandLineRunner { + + private final StreamEventRouter eventRouter; + + @Override + public void run(String... args) { + // 1. 通过实现类注册路由 + eventRouter.registerRoute("orderCreated", OrderEvent.class, new OrderRouteHandler()); + + // 2. 通过 Lambda 注册路由 + eventRouter.registerRoute("paymentSuccess", PaymentEvent.class, (payload, event) -> { + log.info("[Lambda] 支付成功: paymentId={}", payload.getPaymentId()); + }); + + // 3. 注册通用路由(Object 类型) + eventRouter.registerRoute("genericEvent", Object.class, (payload, event) -> { + log.info("[通用] 事件: payload={}", payload); + }); + + // 4. 注册带业务类型的路由 + eventRouter.registerRoute("orderCreated", "retail", OrderEvent.class, (payload, event) -> { + log.info("[零售] 订单: orderId={}", payload.getOrderId()); + }); + + // 5. 分发事件 + eventRouter.route(StreamEvent.of("orderCreated", orderEvent)); + } + + public static class OrderRouteHandler implements StreamEventRouter.StreamRouteHandler { + @Override + public void handle(OrderEvent payload, StreamEvent event) { + log.info("[实现类] 订单路由: orderId={}", payload.getOrderId()); + } + } +} +``` + +#### 动态注册 API 汇总 + +| API | 说明 | +|-----|------| +| `eventManager.registerBinding(name, destination)` | 动态注册 binding | +| `eventManager.registerBinding(name, destination, group)` | 动态注册 binding(指定消费组) | +| `eventManager.registerListener(name, type, handler)` | 动态注册监听器 | +| `eventManager.registerListener(name, type, condition, handler)` | 动态注册带条件的监听器 | +| `eventManager.publish(name, destination, event)` | 动态发布事件(自动创建 binding) | +| `eventManager.unregisterBinding(name)` | 注销 binding | +| `eventManager.unregisterListener(name)` | 注销监听器 | +| `eventRouter.registerRoute(eventType, payloadType, handler)` | 动态注册路由 | +| `eventRouter.registerRoute(eventType, businessType, payloadType, handler)` | 动态注册带业务类型的路由 | +| `eventRouter.unregisterRoute(eventType)` | 注销路由 | + +#### @StreamEventListener 注解参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| value / bindingName | String | 是 | Binding 名称 | +| destination | String | 否 | 消息队列目的地 | +| group | String | 否 | 消费组 | +| contentType | String | 否 | 内容类型,默认 application/json | +| eventType | Class | 否 | 事件类型 | +| condition | String | 否 | SpEL 条件表达式 | + +## 事件发布 + +### 方式一:使用 StreamEventManager 发布 + +```java +@Component +public class OrderService { + + private final StreamEventManager eventManager; + + public void createOrder(Order order) { + OrderEvent event = OrderEvent.builder() + .orderId(order.getId()) + .orderNo(order.getOrderNo()) + .amount(order.getAmount()) + .build(); + + // 发布到已配置的 binding + eventManager.publish("orderEvent", event); + } +} +``` + +### 方式二:使用 StreamEvent 封装发布 + +```java +@Component +public class EventPublisher { + + private final StreamEventRouter eventRouter; + + public void publishOrderCreated(OrderEvent event) { + // 封装为统一事件信封 + StreamEvent streamEvent = StreamEvent.of("orderCreated", event); + // 路由分发 + eventRouter.route(streamEvent); + } + + public void publishPaymentSuccess(PaymentEvent event) { + StreamEvent streamEvent = StreamEvent.of("paymentSuccess", "retail", event); + eventRouter.route(streamEvent); + } +} +``` + +## 配置说明 + +### Stream 绑定配置 + +```yaml +structure: + infra: + stream: + enabled: true # 是否启用 + bindings: + orderEvent: # binding 名称 + destination: order-exchange # 消息队列目的地 + content-type: application/json + group: order-group # 消费组 + binder: kafka # 绑定器(可选) + concurrency: 3 # 并发数(可选) +``` + +### 路由配置 + +```yaml +structure: + infra: + stream: + router: + enabled: true # 是否启用配置驱动路由 + routes: # 路由列表 + - id: route-001 # 路由唯一标识 + event-type: orderCreated # 事件类型 + business-type: retail # 业务类型(可选) + payload-type: com.example.OrderEvent # 负载类型全限定名 + condition: "#payload.amount > 1000" # SpEL 条件(可选) + handler-bean: orderHandler # 处理器 Bean 名称 + handler-method: onOrderCreated # 处理器方法名 + description: 订单创建事件处理 # 描述(可选) +``` + +## API 文档 + +### StreamEventRouter + +| 方法 | 说明 | +|------|------| +| `registerRoute(eventType, payloadType, handler)` | 注册路由 | +| `registerRoute(eventType, payloadType, condition, handler)` | 注册带条件的路由 | +| `registerRoute(eventType, businessType, payloadType, handler)` | 注册带业务类型的路由 | +| `registerRoute(eventType, businessType, payloadType, condition, handler)` | 注册完整路由 | +| `unregisterRoute(eventType)` | 注销指定事件类型的所有路由 | +| `unregisterRoute(eventType, handlerId)` | 注销指定路由 | +| `route(event)` | 路由分发事件 | +| `isRouteRegistered(eventType)` | 检查路由是否已注册 | +| `getRoutes(eventType)` | 获取指定事件类型的路由列表 | + +### StreamEventManager + +| 方法 | 说明 | +|------|------| +| `publish(bindingName, event)` | 发布事件到指定 binding | +| `publish(bindingName, destination, event)` | 发布事件到指定目的地 | +| `registerListener(bindingName, eventType, handler)` | 注册监听器 | +| `registerListener(bindingName, destination, group, eventType, handler)` | 注册完整监听器 | +| `unregisterListener(bindingName)` | 注销监听器 | +| `dispatch(bindingName, event)` | 分发给注册的监听器 | + +## 架构设计 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 事件发布层 │ +│ StreamEventManager / StreamEventRouter.route() │ +└───────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ StreamEvent 统一信封 │ +│ { eventType, businessType, payload, headers, traceId } │ +└───────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ StreamEventRouter 路由网关 │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ 路由匹配规则: │ │ +│ │ 1. eventType 精确匹配 │ │ +│ │ 2. businessType 匹配(支持 * 通配符) │ │ +│ │ 3. payloadType 类型匹配 │ │ +│ │ 4. condition SpEL 表达式匹配 │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ Handler A │ │ Handler B │ │ Handler C │ + │ orderCreated │ │ orderCreated │ │ orderPaid │ + │ condition: >1000│ │ business: retail│ │ │ + └─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +## 目录结构 + +``` +structure-infra-stream-starter/ +├── src/main/java/cn/structure/infra/stream/ +│ ├── annotation/ # 注解定义 +│ │ ├── StreamEventListener.java +│ │ └── StreamRouteHandler.java +│ ├── configuration/ # 自动配置 +│ │ └── StreamAutoConfiguration.java +│ ├── event/ # 事件模型 +│ │ └── StreamEvent.java +│ ├── handler/ # 处理器接口 +│ │ └── StreamEventHandler.java +│ ├── manager/ # 事件管理器 +│ │ ├── StreamEventManager.java +│ │ └── DefaultStreamEventManagerImpl.java +│ ├── processor/ # Bean 后置处理器 +│ │ └── EventListenerBeanPostProcessor.java +│ ├── properties/ # 配置属性 +│ │ └── StreamProperties.java +│ └── router/ # 路由模块 +│ ├── StreamEventRouter.java +│ ├── DefaultStreamEventRouterImpl.java +│ ├── RouteHandlerBeanPostProcessor.java +│ ├── RouterProperties.java +│ └── ConfigurableRouteInitializer.java +└── pom.xml +``` + +## 扩展能力 + +### 自定义路由策略 + +实现 `StreamEventRouter` 接口自定义路由逻辑: + +```java +@Component +public class CustomEventRouter implements StreamEventRouter { + // 实现自定义路由逻辑 +} +``` + +### 自定义事件类型 + +只需创建普通的 POJO 类即可作为事件类型: + +```java +public class CustomEvent { + private String id; + private String data; + // getter/setter +} +``` + +## 测试 + +```bash +mvn clean test -pl structure-infra-sample/structure-infra-sample-stream -am +``` + +测试覆盖: +- 事件路由分发 +- 业务类型过滤 +- SpEL 条件表达式 +- 多处理器并发处理 +- 路由注册/注销 +- 配置驱动路由 + +## License + +Apache License 2.0 diff --git a/structure-infra-stream-starter/pom.xml b/structure-infra-stream-starter/pom.xml new file mode 100644 index 0000000..ed74bb5 --- /dev/null +++ b/structure-infra-stream-starter/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + cn.structured + structure-pro-infra + ${revision} + ../pom.xml + + + structure-pro-stream-starter + structure-infra-stream-starter + structure-pro-stream-starter + jar + + + + cn.structured + structure-infra-starter + ${revision} + + + org.springframework.cloud + spring-cloud-stream + + + org.springframework.boot + spring-boot-starter-web + true + + + org.projectlombok + lombok + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + + diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamEventListener.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamEventListener.java new file mode 100644 index 0000000..9969f78 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamEventListener.java @@ -0,0 +1,32 @@ +package cn.structure.infra.stream.annotation; + +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface StreamEventListener { + + @AliasFor("bindingName") + String value() default ""; + + @AliasFor("value") + String bindingName() default ""; + + String destination() default ""; + + String group() default ""; + + String contentType() default "application/json"; + + Class eventType() default Object.class; + + String consumerPrefix() default "consumer"; + + String producerPrefix() default "producer"; + + String condition() default ""; + +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamRouteHandler.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamRouteHandler.java new file mode 100644 index 0000000..30ebc16 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamRouteHandler.java @@ -0,0 +1,22 @@ +package cn.structure.infra.stream.annotation; + +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface StreamRouteHandler { + + @AliasFor("eventType") + String value() default ""; + + @AliasFor("value") + String eventType() default ""; + + String businessType() default ""; + + String condition() default ""; + +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/configuration/StreamAutoConfiguration.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/configuration/StreamAutoConfiguration.java new file mode 100644 index 0000000..7c8f739 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/configuration/StreamAutoConfiguration.java @@ -0,0 +1,49 @@ +package cn.structure.infra.stream.configuration; + +import cn.structure.infra.stream.manager.DefaultStreamEventManagerImpl; +import cn.structure.infra.stream.manager.StreamEventManager; +import cn.structure.infra.stream.properties.StreamProperties; +import cn.structure.infra.stream.processor.EventListenerBeanPostProcessor; +import cn.structure.infra.stream.processor.StreamBindingBeanFactoryPostProcessor; +import cn.structure.infra.stream.router.DefaultStreamEventRouterImpl; +import cn.structure.infra.stream.router.RouterProperties; +import cn.structure.infra.stream.router.StreamEventRouter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnClass({StreamBridge.class}) +@ConditionalOnProperty(prefix = "structure.infra.stream", name = "enabled", havingValue = "true", matchIfMissing = true) +@EnableConfigurationProperties({StreamProperties.class, RouterProperties.class}) +public class StreamAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public StreamEventManager streamEventManager(StreamBridge streamBridge, + StreamProperties streamProperties) { + return new DefaultStreamEventManagerImpl(streamBridge, streamProperties); + } + + @Bean + @ConditionalOnMissingBean + public StreamEventRouter streamEventRouter() { + return new DefaultStreamEventRouterImpl(); + } + + @Bean + public static StreamBindingBeanFactoryPostProcessor streamBindingBeanFactoryPostProcessor() { + return new StreamBindingBeanFactoryPostProcessor(); + } + + @Bean + public EventListenerBeanPostProcessor eventListenerBeanPostProcessor(StreamEventManager streamEventManager, + StreamProperties streamProperties) { + return new EventListenerBeanPostProcessor(streamEventManager, streamProperties); + } + +} \ No newline at end of file diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/event/StreamEvent.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/event/StreamEvent.java new file mode 100644 index 0000000..d7dabf2 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/event/StreamEvent.java @@ -0,0 +1,181 @@ +package cn.structure.infra.stream.event; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +public class StreamEvent { + + private String eventId; + private String eventType; + private String businessType; + private String source; + private LocalDateTime timestamp; + private T payload; + private Map headers = new HashMap<>(); + private String traceId; + + public StreamEvent() { + } + + public StreamEvent(String eventId, String eventType, String businessType, String source, + LocalDateTime timestamp, T payload, Map headers, String traceId) { + this.eventId = eventId; + this.eventType = eventType; + this.businessType = businessType; + this.source = source; + this.timestamp = timestamp; + this.payload = payload; + this.headers = headers != null ? headers : new HashMap<>(); + this.traceId = traceId; + } + + public String getEventId() { + return eventId; + } + + public void setEventId(String eventId) { + this.eventId = eventId; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getBusinessType() { + return businessType; + } + + public void setBusinessType(String businessType) { + this.businessType = businessType; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + public T getPayload() { + return payload; + } + + public void setPayload(T payload) { + this.payload = payload; + } + + public Map getHeaders() { + return headers; + } + + public void setHeaders(Map headers) { + this.headers = headers != null ? headers : new HashMap<>(); + } + + public String getTraceId() { + return traceId; + } + + public void setTraceId(String traceId) { + this.traceId = traceId; + } + + public static StreamEvent of(String eventType, T payload) { + return new StreamEvent<>( + java.util.UUID.randomUUID().toString(), + eventType, + null, + null, + LocalDateTime.now(), + payload, + new HashMap<>(), + null + ); + } + + public static StreamEvent of(String eventType, String businessType, T payload) { + return new StreamEvent<>( + java.util.UUID.randomUUID().toString(), + eventType, + businessType, + null, + LocalDateTime.now(), + payload, + new HashMap<>(), + null + ); + } + + public static Builder builder() { + return new Builder<>(); + } + + public static class Builder { + private String eventId; + private String eventType; + private String businessType; + private String source; + private LocalDateTime timestamp; + private T payload; + private Map headers = new HashMap<>(); + private String traceId; + + public Builder eventId(String eventId) { + this.eventId = eventId; + return this; + } + + public Builder eventType(String eventType) { + this.eventType = eventType; + return this; + } + + public Builder businessType(String businessType) { + this.businessType = businessType; + return this; + } + + public Builder source(String source) { + this.source = source; + return this; + } + + public Builder timestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + return this; + } + + public Builder payload(T payload) { + this.payload = payload; + return this; + } + + public Builder headers(Map headers) { + this.headers = headers; + return this; + } + + public Builder traceId(String traceId) { + this.traceId = traceId; + return this; + } + + public StreamEvent build() { + return new StreamEvent<>(eventId, eventType, businessType, source, timestamp, payload, headers, traceId); + } + } +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/handler/StreamEventHandler.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/handler/StreamEventHandler.java new file mode 100644 index 0000000..e64b4ce --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/handler/StreamEventHandler.java @@ -0,0 +1,7 @@ +package cn.structure.infra.stream.handler; + +public interface StreamEventHandler { + + void handle(T event); + +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/DefaultStreamEventManagerImpl.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/DefaultStreamEventManagerImpl.java new file mode 100644 index 0000000..676a508 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/DefaultStreamEventManagerImpl.java @@ -0,0 +1,231 @@ +package cn.structure.infra.stream.manager; + +import cn.structure.infra.stream.handler.StreamEventHandler; +import cn.structure.infra.stream.properties.StreamProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class DefaultStreamEventManagerImpl implements StreamEventManager { + + private static final Logger log = LoggerFactory.getLogger(DefaultStreamEventManagerImpl.class); + + private final StreamBridge streamBridge; + private final StreamProperties streamProperties; + private final Map>> registeredListeners = new ConcurrentHashMap<>(); + private final SpelExpressionParser expressionParser = new SpelExpressionParser(); + + public DefaultStreamEventManagerImpl(StreamBridge streamBridge, StreamProperties streamProperties) { + this.streamBridge = streamBridge; + this.streamProperties = streamProperties; + } + + @Override + public void publish(String bindingName, T event) { + StreamProperties.Binding binding = streamProperties.getBindings().get(bindingName); + if (binding == null) { + throw new IllegalArgumentException("Binding not found: " + bindingName); + } + publish(bindingName, binding.getDestination(), binding.getGroup(), event); + } + + @Override + public void publish(String bindingName, String destination, T event) { + String group = streamProperties.getDefaultGroup(); + publish(bindingName, destination, group, event); + } + + @Override + public void publish(String bindingName, String destination, String group, T event) { + ensureBindingRegistered(bindingName, destination, group); + + String outputBindingName = bindingName + "-out-0"; + Message message = MessageBuilder.withPayload(event).build(); + streamBridge.send(outputBindingName, message); + log.debug("Published event to binding: {}, destination: {}, group: {}", outputBindingName, destination, group); + } + + private synchronized void ensureBindingRegistered(String bindingName, String destination, String group) { + if (!streamProperties.getBindings().containsKey(bindingName)) { + registerBinding(bindingName, destination, group); + } + } + + @Override + public void registerListener(String bindingName, Class eventType, StreamEventHandler handler) { + registerListener(bindingName, eventType, "", handler); + } + + @Override + public void registerListener(String bindingName, Class eventType, String condition, StreamEventHandler handler) { + StreamProperties.Binding binding = streamProperties.getBindings().get(bindingName); + if (binding == null) { + throw new IllegalArgumentException("Binding not found: " + bindingName); + } + registerListener(bindingName, binding.getDestination(), binding.getGroup(), eventType, condition, handler); + } + + @Override + public void registerListener(String bindingName, String destination, String group, Class eventType, StreamEventHandler handler) { + registerListener(bindingName, destination, group, eventType, "", handler); + } + + @Override + public void registerListener(String bindingName, String destination, String group, Class eventType, String condition, StreamEventHandler handler) { + String listenerId = UUID.randomUUID().toString(); + ListenerRegistration registration = ListenerRegistration.builder() + .listenerId(listenerId) + .eventType(eventType) + .handler(handler) + .condition(condition) + .destination(destination) + .group(group) + .build(); + + registeredListeners.computeIfAbsent(bindingName, k -> new ArrayList<>()).add(registration); + + log.info("Registered listener for binding: {}, listenerId: {}, destination: {}, group: {}, eventType: {}, condition: {}", + bindingName, listenerId, destination, group, eventType.getName(), condition); + } + + @Override + public void unregisterListener(String bindingName) { + registeredListeners.remove(bindingName); + log.info("Unregistered all listeners for binding: {}", bindingName); + } + + @Override + public void unregisterListener(String bindingName, String listenerId) { + List> registrations = registeredListeners.get(bindingName); + if (registrations != null) { + boolean removed = registrations.removeIf(r -> r.getListenerId().equals(listenerId)); + if (removed) { + log.info("Unregistered listener: {} for binding: {}", listenerId, bindingName); + } + if (registrations.isEmpty()) { + registeredListeners.remove(bindingName); + } + } + } + + @Override + public boolean isListenerRegistered(String bindingName) { + return registeredListeners.containsKey(bindingName) && !registeredListeners.get(bindingName).isEmpty(); + } + + @Override + @SuppressWarnings("unchecked") + public void dispatch(String bindingName, T event) { + List> registrations = registeredListeners.get(bindingName); + if (registrations == null || registrations.isEmpty()) { + log.debug("No listeners registered for binding: {}", bindingName); + return; + } + + for (ListenerRegistration registration : registrations) { + if (!registration.getEventType().isInstance(event)) { + continue; + } + + if (matchesCondition(registration.getCondition(), event)) { + try { + ((StreamEventHandler) registration.getHandler()).handle(event); + log.debug("Dispatched event to listener: {} for binding: {}", registration.getListenerId(), bindingName); + } catch (Exception e) { + log.error("Error handling event in listener: {} for binding: {}", registration.getListenerId(), bindingName, e); + } + } + } + } + + @Override + public List> getListeners(String bindingName) { + return registeredListeners.getOrDefault(bindingName, new ArrayList<>()); + } + + @Override + public void registerBinding(String bindingName, String destination) { + registerBinding(bindingName, destination, streamProperties.getDefaultGroup()); + } + + @Override + public void registerBinding(String bindingName, String destination, String group) { + registerBinding(bindingName, destination, group, streamProperties.getDefaultContentType(), streamProperties.getDefaultConcurrency()); + } + + @Override + public void registerBinding(String bindingName, String destination, String group, String contentType, Integer concurrency) { + synchronized (this) { + if (streamProperties.getBindings().containsKey(bindingName)) { + log.warn("Binding already registered: {}", bindingName); + return; + } + + StreamProperties.Binding binding = new StreamProperties.Binding(); + binding.setDestination(destination); + binding.setGroup(group); + binding.setContentType(contentType != null ? contentType : streamProperties.getDefaultContentType()); + binding.setConcurrency(concurrency != null ? concurrency : streamProperties.getDefaultConcurrency()); + + streamProperties.getBindings().put(bindingName, binding); + + log.info("Dynamically registered binding: {}, destination: {}, group: {}, contentType: {}", + bindingName, destination, group, binding.getContentType()); + } + } + + @Override + public void unregisterBinding(String bindingName) { + synchronized (this) { + StreamProperties.Binding removed = streamProperties.getBindings().remove(bindingName); + if (removed != null) { + unregisterListener(bindingName); + log.info("Dynamically unregistered binding: {}", bindingName); + } + } + } + + @Override + public boolean isBindingRegistered(String bindingName) { + return streamProperties.getBindings().containsKey(bindingName); + } + + @Override + public StreamProperties.Binding getBinding(String bindingName) { + return streamProperties.getBindings().get(bindingName); + } + + @Override + public Map getAllBindings() { + return streamProperties.getBindings(); + } + + private boolean matchesCondition(String condition, T event) { + if (condition == null || condition.isEmpty()) { + return true; + } + + try { + Expression expression = expressionParser.parseExpression(condition); + EvaluationContext context = new StandardEvaluationContext(); + context.setVariable("event", event); + Boolean result = expression.getValue(context, Boolean.class); + return Boolean.TRUE.equals(result); + } catch (Exception e) { + log.warn("Failed to evaluate condition: {} for event: {}", condition, event, e); + return false; + } + } +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/ListenerRegistration.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/ListenerRegistration.java new file mode 100644 index 0000000..b55a7c4 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/ListenerRegistration.java @@ -0,0 +1,121 @@ +package cn.structure.infra.stream.manager; + +import cn.structure.infra.stream.handler.StreamEventHandler; + +public class ListenerRegistration { + + private String listenerId; + private Class eventType; + private StreamEventHandler handler; + private String condition; + private String destination; + private String group; + + public ListenerRegistration() { + } + + public ListenerRegistration(String listenerId, Class eventType, StreamEventHandler handler, + String condition, String destination, String group) { + this.listenerId = listenerId; + this.eventType = eventType; + this.handler = handler; + this.condition = condition; + this.destination = destination; + this.group = group; + } + + public String getListenerId() { + return listenerId; + } + + public void setListenerId(String listenerId) { + this.listenerId = listenerId; + } + + public Class getEventType() { + return eventType; + } + + public void setEventType(Class eventType) { + this.eventType = eventType; + } + + public StreamEventHandler getHandler() { + return handler; + } + + public void setHandler(StreamEventHandler handler) { + this.handler = handler; + } + + public String getCondition() { + return condition; + } + + public void setCondition(String condition) { + this.condition = condition; + } + + public String getDestination() { + return destination; + } + + public void setDestination(String destination) { + this.destination = destination; + } + + public String getGroup() { + return group; + } + + public void setGroup(String group) { + this.group = group; + } + + public static Builder builder() { + return new Builder<>(); + } + + public static class Builder { + private String listenerId; + private Class eventType; + private StreamEventHandler handler; + private String condition; + private String destination; + private String group; + + public Builder listenerId(String listenerId) { + this.listenerId = listenerId; + return this; + } + + public Builder eventType(Class eventType) { + this.eventType = eventType; + return this; + } + + public Builder handler(StreamEventHandler handler) { + this.handler = handler; + return this; + } + + public Builder condition(String condition) { + this.condition = condition; + return this; + } + + public Builder destination(String destination) { + this.destination = destination; + return this; + } + + public Builder group(String group) { + this.group = group; + return this; + } + + public ListenerRegistration build() { + return new ListenerRegistration<>(listenerId, eventType, handler, condition, destination, group); + } + } +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/StreamEventManager.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/StreamEventManager.java new file mode 100644 index 0000000..d33a7ec --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/StreamEventManager.java @@ -0,0 +1,49 @@ +package cn.structure.infra.stream.manager; + +import cn.structure.infra.stream.handler.StreamEventHandler; +import cn.structure.infra.stream.properties.StreamProperties; + +import java.util.List; +import java.util.Map; + +public interface StreamEventManager { + + void publish(String bindingName, T event); + + void publish(String bindingName, String destination, T event); + + void publish(String bindingName, String destination, String group, T event); + + void registerListener(String bindingName, Class eventType, StreamEventHandler handler); + + void registerListener(String bindingName, Class eventType, String condition, StreamEventHandler handler); + + void registerListener(String bindingName, String destination, String group, Class eventType, StreamEventHandler handler); + + void registerListener(String bindingName, String destination, String group, Class eventType, String condition, StreamEventHandler handler); + + void unregisterListener(String bindingName); + + void unregisterListener(String bindingName, String listenerId); + + boolean isListenerRegistered(String bindingName); + + void dispatch(String bindingName, T event); + + List> getListeners(String bindingName); + + void registerBinding(String bindingName, String destination); + + void registerBinding(String bindingName, String destination, String group); + + void registerBinding(String bindingName, String destination, String group, String contentType, Integer concurrency); + + void unregisterBinding(String bindingName); + + boolean isBindingRegistered(String bindingName); + + StreamProperties.Binding getBinding(String bindingName); + + Map getAllBindings(); + +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/EventListenerBeanPostProcessor.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/EventListenerBeanPostProcessor.java new file mode 100644 index 0000000..0692d5b --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/EventListenerBeanPostProcessor.java @@ -0,0 +1,160 @@ +package cn.structure.infra.stream.processor; + +import cn.structure.infra.stream.annotation.StreamEventListener; +import cn.structure.infra.stream.manager.StreamEventManager; +import cn.structure.infra.stream.properties.StreamProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.messaging.Message; +import org.springframework.util.StringUtils; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class EventListenerBeanPostProcessor implements BeanPostProcessor { + + private static final Logger log = LoggerFactory.getLogger(EventListenerBeanPostProcessor.class); + + private final StreamEventManager streamEventManager; + private final StreamProperties streamProperties; + + private final Map listenerBeans = new ConcurrentHashMap<>(); + + public EventListenerBeanPostProcessor(StreamEventManager streamEventManager, StreamProperties streamProperties) { + this.streamEventManager = streamEventManager; + this.streamProperties = streamProperties; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + Class targetClass = bean.getClass(); + Map annotatedMethods = MethodIntrospector.selectMethods(targetClass, + (MethodIntrospector.MetadataLookup) method -> + AnnotatedElementUtils.findMergedAnnotation(method, StreamEventListener.class)); + + for (Map.Entry entry : annotatedMethods.entrySet()) { + Method method = entry.getKey(); + StreamEventListener annotation = entry.getValue(); + registerListener(bean, method, annotation); + } + + if (targetClass.isAnnotationPresent(StreamEventListener.class)) { + StreamEventListener annotation = targetClass.getAnnotation(StreamEventListener.class); + registerClassListener(bean, targetClass, annotation); + } + + return bean; + } + + private void registerListener(Object bean, Method method, StreamEventListener annotation) { + String bindingName = resolveBindingName(annotation); + String destination = annotation.destination(); + String group = annotation.group(); + Class eventType = annotation.eventType(); + + if (eventType == Object.class && method.getParameterTypes().length > 0) { + eventType = method.getParameterTypes()[0]; + } + + if (bindingName.isEmpty()) { + bindingName = method.getName(); + } + + // 确保绑定信息注册到 StreamProperties(供 publish 方法使用) + ensureBindingRegistered(bindingName, destination, group, annotation.contentType()); + + listenerBeans.put(bindingName, bean); + + if (!destination.isEmpty()) { + streamEventManager.registerListener(bindingName, destination, group, eventType, event -> { + try { + method.invoke(bean, event); + } catch (Exception e) { + log.error("Failed to invoke listener method: {}", method.getName(), e); + } + }); + } else { + streamEventManager.registerListener(bindingName, eventType, event -> { + try { + method.invoke(bean, event); + } catch (Exception e) { + log.error("Failed to invoke listener method: {}", method.getName(), e); + } + }); + } + + log.info("Registered listener method: {} for binding: {}", method.getName(), bindingName); + } + + private void registerClassListener(Object bean, Class targetClass, StreamEventListener annotation) { + String bindingName = resolveBindingName(annotation); + String destination = annotation.destination(); + String group = annotation.group(); + Class eventType = annotation.eventType(); + + if (bindingName.isEmpty()) { + bindingName = targetClass.getSimpleName(); + } + + ensureBindingRegistered(bindingName, destination, group, annotation.contentType()); + + listenerBeans.put(bindingName, bean); + + if (!destination.isEmpty()) { + streamEventManager.registerListener(bindingName, destination, group, eventType, event -> { + try { + Method handleMethod = targetClass.getMethod("handle", eventType); + handleMethod.invoke(bean, event); + } catch (Exception e) { + log.error("Failed to invoke listener handle method on class: {}", targetClass.getName(), e); + } + }); + } else { + streamEventManager.registerListener(bindingName, eventType, event -> { + try { + Method handleMethod = targetClass.getMethod("handle", eventType); + handleMethod.invoke(bean, event); + } catch (Exception e) { + log.error("Failed to invoke listener handle method on class: {}", targetClass.getName(), e); + } + }); + } + + log.info("Registered listener class: {} for binding: {}", targetClass.getName(), bindingName); + } + + /** + * 确保绑定信息注册到 StreamProperties,供 publish 方法使用 + */ + private void ensureBindingRegistered(String bindingName, String destination, String group, String contentType) { + if (!streamProperties.getBindings().containsKey(bindingName)) { + StreamProperties.Binding binding = new StreamProperties.Binding(); + if (StringUtils.hasText(destination)) { + binding.setDestination(destination); + } else { + binding.setDestination(toDestination(bindingName)); + } + binding.setGroup(StringUtils.hasText(group) ? group : streamProperties.getDefaultGroup()); + binding.setContentType(StringUtils.hasText(contentType) ? contentType : streamProperties.getDefaultContentType()); + streamProperties.getBindings().put(bindingName, binding); + } + } + + private String toDestination(String name) { + return name.replace(".", "-").replace("_", "-").toLowerCase() + "-exchange"; + } + + private String resolveBindingName(StreamEventListener annotation) { + String bindingName = annotation.bindingName(); + if (bindingName.isEmpty()) { + bindingName = annotation.value(); + } + return bindingName; + } + +} \ No newline at end of file diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/StreamBindingBeanFactoryPostProcessor.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/StreamBindingBeanFactoryPostProcessor.java new file mode 100644 index 0000000..aa38c06 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/StreamBindingBeanFactoryPostProcessor.java @@ -0,0 +1,182 @@ +package cn.structure.infra.stream.processor; + +import cn.structure.infra.stream.annotation.StreamEventListener; +import cn.structure.infra.stream.annotation.StreamRouteHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * 在 Bean 实例化之前,扫描所有 BeanDefinition 中的 @StreamEventListener 和 @StreamRouteHandler 注解, + * 自动注册 Spring Cloud Stream 绑定配置和 spring.cloud.function.definition。 + * + * 这样用户只需在方法上标注 @StreamEventListener,框架会自动完成绑定创建。 + */ +public class StreamBindingBeanFactoryPostProcessor implements BeanFactoryPostProcessor { + + private static final Logger log = LoggerFactory.getLogger(StreamBindingBeanFactoryPostProcessor.class); + + private static final String SPRING_BINDINGS_PREFIX = "spring.cloud.stream.bindings"; + private static final String FUNCTION_DEFINITION = "spring.cloud.function.definition"; + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + ConfigurableEnvironment environment = beanFactory.getBean(ConfigurableEnvironment.class); + + Boolean enabled = environment.getProperty("structure.infra.stream.enabled", Boolean.class, Boolean.TRUE); + if (!enabled) { + return; + } + + String defaultGroup = environment.getProperty("structure.infra.stream.default-group", "default"); + String defaultContentType = environment.getProperty("structure.infra.stream.default-content-type", "application/json"); + String defaultBinder = environment.getProperty("structure.infra.stream.default-binder"); + Integer defaultConcurrency = environment.getProperty("structure.infra.stream.default-concurrency", Integer.class, 1); + + Map properties = new LinkedHashMap<>(); + Set functionDefinitions = new LinkedHashSet<>(); + + // 扫描所有 BeanDefinition + String[] beanNames = beanFactory.getBeanDefinitionNames(); + for (String beanName : beanNames) { + BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); + String beanClassName = beanDefinition.getBeanClassName(); + if (beanClassName == null) { + continue; + } + + Class beanClass; + try { + beanClass = ClassUtils.forName(beanClassName, beanFactory.getBeanClassLoader()); + } catch (ClassNotFoundException e) { + continue; + } + + // 扫描方法上的 @StreamEventListener 注解 + for (Method method : beanClass.getDeclaredMethods()) { + StreamEventListener annotation = AnnotatedElementUtils.findMergedAnnotation(method, StreamEventListener.class); + if (annotation != null) { + processStreamEventListener(annotation, defaultGroup, defaultContentType, defaultBinder, defaultConcurrency, properties, functionDefinitions); + } + + StreamRouteHandler routeAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, StreamRouteHandler.class); + if (routeAnnotation != null) { + processStreamRouteHandler(routeAnnotation, defaultGroup, defaultContentType, defaultBinder, defaultConcurrency, properties, functionDefinitions); + } + } + + // 处理类级别的 @StreamEventListener 注解 + StreamEventListener classAnnotation = AnnotatedElementUtils.findMergedAnnotation(beanClass, StreamEventListener.class); + if (classAnnotation != null) { + processStreamEventListener(classAnnotation, defaultGroup, defaultContentType, defaultBinder, defaultConcurrency, properties, functionDefinitions); + } + } + + // 自动设置 spring.cloud.function.definition(如果未配置) + String existingDefinition = environment.getProperty(FUNCTION_DEFINITION); + if (!StringUtils.hasText(existingDefinition) && !functionDefinitions.isEmpty()) { + properties.put(FUNCTION_DEFINITION, String.join(";", functionDefinitions)); + log.info("Auto set spring.cloud.function.definition: {}", String.join(";", functionDefinitions)); + } + + if (!properties.isEmpty()) { + environment.getPropertySources().addFirst( + new MapPropertySource("stream-auto-binding", properties)); + } + } + + private void processStreamEventListener(StreamEventListener annotation, String defaultGroup, + String defaultContentType, String defaultBinder, Integer defaultConcurrency, + Map properties, Set functionDefinitions) { + String bindingName = annotation.bindingName(); + if (!StringUtils.hasText(bindingName)) { + bindingName = annotation.value(); + } + if (!StringUtils.hasText(bindingName)) { + return; + } + + String destination = StringUtils.hasText(annotation.destination()) + ? annotation.destination() + : toDestination(bindingName); + String group = StringUtils.hasText(annotation.group()) ? annotation.group() : defaultGroup; + String contentType = StringUtils.hasText(annotation.contentType()) ? annotation.contentType() : defaultContentType; + String binder = StringUtils.hasText(defaultBinder) ? defaultBinder : null; + + registerBinding(bindingName, destination, group, contentType, binder, defaultConcurrency, properties); + functionDefinitions.add(bindingName); + } + + private void processStreamRouteHandler(StreamRouteHandler annotation, String defaultGroup, + String defaultContentType, String defaultBinder, Integer defaultConcurrency, + Map properties, Set functionDefinitions) { + String eventType = annotation.eventType(); + if (!StringUtils.hasText(eventType)) { + eventType = annotation.value(); + } + if (!StringUtils.hasText(eventType)) { + return; + } + + String bindingName = toBindingName(eventType); + String destination = toDestination(eventType); + + registerBinding(bindingName, destination, defaultGroup, defaultContentType, defaultBinder, defaultConcurrency, properties); + functionDefinitions.add(bindingName); + } + + private void registerBinding(String bindingName, String destination, String group, + String contentType, String binder, Integer concurrency, + Map properties) { + String inputBinding = bindingName + "-in-0"; + String outputBinding = bindingName + "-out-0"; + + String inputDestKey = SPRING_BINDINGS_PREFIX + "." + inputBinding + ".destination"; + // 避免重复注册 + if (properties.containsKey(inputDestKey)) { + return; + } + + properties.put(inputDestKey, destination); + properties.put(SPRING_BINDINGS_PREFIX + "." + outputBinding + ".destination", destination); + properties.put(SPRING_BINDINGS_PREFIX + "." + inputBinding + ".content-type", contentType); + properties.put(SPRING_BINDINGS_PREFIX + "." + outputBinding + ".content-type", contentType); + + if (StringUtils.hasText(group)) { + properties.put(SPRING_BINDINGS_PREFIX + "." + inputBinding + ".group", group); + } + if (StringUtils.hasText(binder)) { + properties.put(SPRING_BINDINGS_PREFIX + "." + inputBinding + ".binder", binder); + properties.put(SPRING_BINDINGS_PREFIX + "." + outputBinding + ".binder", binder); + } + if (concurrency != null) { + properties.put(SPRING_BINDINGS_PREFIX + "." + inputBinding + ".consumer.concurrency", concurrency); + } + + log.info("Auto registered binding: {}, destination: {}, group: {}, contentType: {}", + bindingName, destination, group, contentType); + } + + private String toBindingName(String eventType) { + return eventType.replace(".", "-").replace("_", "-").toLowerCase(); + } + + private String toDestination(String name) { + return name.replace(".", "-").replace("_", "-").toLowerCase() + "-exchange"; + } + +} \ No newline at end of file diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/properties/StreamProperties.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/properties/StreamProperties.java new file mode 100644 index 0000000..73916e5 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/properties/StreamProperties.java @@ -0,0 +1,156 @@ +package cn.structure.infra.stream.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.HashMap; +import java.util.Map; + +@ConfigurationProperties(prefix = "structure.infra.stream") +public class StreamProperties { + + private boolean enabled = true; + private boolean autoBinding = true; + private String defaultGroup = "default"; + private String defaultContentType = "application/json"; + private String defaultBinder; + private Integer defaultConcurrency = 1; + private Map bindings = new HashMap<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isAutoBinding() { + return autoBinding; + } + + public void setAutoBinding(boolean autoBinding) { + this.autoBinding = autoBinding; + } + + public String getDefaultGroup() { + return defaultGroup; + } + + public void setDefaultGroup(String defaultGroup) { + this.defaultGroup = defaultGroup; + } + + public String getDefaultContentType() { + return defaultContentType; + } + + public void setDefaultContentType(String defaultContentType) { + this.defaultContentType = defaultContentType; + } + + public String getDefaultBinder() { + return defaultBinder; + } + + public void setDefaultBinder(String defaultBinder) { + this.defaultBinder = defaultBinder; + } + + public Integer getDefaultConcurrency() { + return defaultConcurrency; + } + + public void setDefaultConcurrency(Integer defaultConcurrency) { + this.defaultConcurrency = defaultConcurrency; + } + + public Map getBindings() { + return bindings; + } + + public void setBindings(Map bindings) { + this.bindings = bindings; + } + + public Binding getBinding(String bindingName) { + return bindings.computeIfAbsent(bindingName, k -> new Binding()); + } + + public static class Binding { + private String destination; + private String contentType = "application/json"; + private String group; + private String binder; + private Integer concurrency; + private String consumerPrefix = "consumer"; + private String producerPrefix = "producer"; + + public Binding() { + } + + public Binding(String destination) { + this.destination = destination; + } + + public Binding(String destination, String group) { + this.destination = destination; + this.group = group; + } + + public String getDestination() { + return destination; + } + + public void setDestination(String destination) { + this.destination = destination; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getGroup() { + return group; + } + + public void setGroup(String group) { + this.group = group; + } + + public String getBinder() { + return binder; + } + + public void setBinder(String binder) { + this.binder = binder; + } + + public Integer getConcurrency() { + return concurrency; + } + + public void setConcurrency(Integer concurrency) { + this.concurrency = concurrency; + } + + public String getConsumerPrefix() { + return consumerPrefix; + } + + public void setConsumerPrefix(String consumerPrefix) { + this.consumerPrefix = consumerPrefix; + } + + public String getProducerPrefix() { + return producerPrefix; + } + + public void setProducerPrefix(String producerPrefix) { + this.producerPrefix = producerPrefix; + } + } +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/ConfigurableRouteInitializer.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/ConfigurableRouteInitializer.java new file mode 100644 index 0000000..4639b7f --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/ConfigurableRouteInitializer.java @@ -0,0 +1,82 @@ +package cn.structure.infra.stream.router; + +import cn.structure.infra.stream.event.StreamEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; + +@Component +public class ConfigurableRouteInitializer implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(ConfigurableRouteInitializer.class); + + private final RouterProperties routerProperties; + private final StreamEventRouter eventRouter; + private final ApplicationContext applicationContext; + + public ConfigurableRouteInitializer(RouterProperties routerProperties, + StreamEventRouter eventRouter, + ApplicationContext applicationContext) { + this.routerProperties = routerProperties; + this.eventRouter = eventRouter; + this.applicationContext = applicationContext; + } + + @Override + public void run(String... args) { + if (!routerProperties.isEnabled()) { + log.info("Configurable router is disabled"); + return; + } + + log.info("========== 配置驱动路由初始化 =========="); + + for (RouterProperties.RouteDefinition route : routerProperties.getRoutes()) { + try { + registerRoute(route); + log.info("Registered route: id={}, eventType={}, handler={}.{}", + route.getId(), route.getEventType(), route.getHandlerBean(), route.getHandlerMethod()); + } catch (Exception e) { + log.error("Failed to register route: id={}, eventType={}", route.getId(), route.getEventType(), e); + } + } + + log.info("========== 配置驱动路由初始化完成,共 {} 条路由 ==========", routerProperties.getRoutes().size()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private void registerRoute(RouterProperties.RouteDefinition route) throws Exception { + Class payloadType = Class.forName(route.getPayloadType()); + Object handlerBean = applicationContext.getBean(route.getHandlerBean()); + Method handlerMethod = handlerBean.getClass().getDeclaredMethod(route.getHandlerMethod(), payloadType); + handlerMethod.setAccessible(true); + + StreamEventRouter.StreamRouteHandler handler = (payload, event) -> { + try { + handlerMethod.invoke(handlerBean, payload); + } catch (Exception e) { + log.error("Error invoking handler: {}.{}", route.getHandlerBean(), route.getHandlerMethod(), e); + throw new RuntimeException("Error invoking handler", e); + } + }; + + if (route.getBusinessType() != null && !route.getBusinessType().isEmpty()) { + if (route.getCondition() != null && !route.getCondition().isEmpty()) { + eventRouter.registerRoute(route.getEventType(), route.getBusinessType(), payloadType, route.getCondition(), handler); + } else { + eventRouter.registerRoute(route.getEventType(), route.getBusinessType(), payloadType, handler); + } + } else { + if (route.getCondition() != null && !route.getCondition().isEmpty()) { + eventRouter.registerRoute(route.getEventType(), payloadType, route.getCondition(), handler); + } else { + eventRouter.registerRoute(route.getEventType(), payloadType, handler); + } + } + } +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/DefaultStreamEventRouterImpl.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/DefaultStreamEventRouterImpl.java new file mode 100644 index 0000000..222626a --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/DefaultStreamEventRouterImpl.java @@ -0,0 +1,142 @@ +package cn.structure.infra.stream.router; + +import cn.structure.infra.stream.event.StreamEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class DefaultStreamEventRouterImpl implements StreamEventRouter { + + private static final Logger log = LoggerFactory.getLogger(DefaultStreamEventRouterImpl.class); + + private final Map>> routeRegistrations = new ConcurrentHashMap<>(); + private final SpelExpressionParser expressionParser = new SpelExpressionParser(); + private final java.util.concurrent.atomic.AtomicLong handlerCounter = new java.util.concurrent.atomic.AtomicLong(0); + + @Override + public void registerRoute(String eventType, Class payloadType, StreamRouteHandler handler) { + registerRoute(eventType, "", payloadType, "", handler); + } + + @Override + public void registerRoute(String eventType, Class payloadType, String condition, StreamRouteHandler handler) { + registerRoute(eventType, "", payloadType, condition, handler); + } + + @Override + public void registerRoute(String eventType, String businessType, Class payloadType, StreamRouteHandler handler) { + registerRoute(eventType, businessType, payloadType, "", handler); + } + + @Override + public void registerRoute(String eventType, String businessType, Class payloadType, + String condition, StreamRouteHandler handler) { + String handlerId = generateHandlerId(eventType, businessType, payloadType); + RouteRegistration registration = new RouteRegistration<>(handlerId, eventType, businessType, + payloadType, condition, handler); + + routeRegistrations.computeIfAbsent(eventType, k -> new ArrayList<>()).add(registration); + + log.info("Registered route: eventType={}, businessType={}, payloadType={}, condition={}", + eventType, businessType, payloadType.getName(), condition); + } + + @Override + public void unregisterRoute(String eventType) { + routeRegistrations.remove(eventType); + log.info("Unregistered all routes for eventType: {}", eventType); + } + + @Override + public void unregisterRoute(String eventType, String handlerId) { + List> registrations = routeRegistrations.get(eventType); + if (registrations != null) { + boolean removed = registrations.removeIf(r -> r.getHandlerId().equals(handlerId)); + if (removed) { + log.info("Unregistered route: {} for eventType: {}", handlerId, eventType); + } + if (registrations.isEmpty()) { + routeRegistrations.remove(eventType); + } + } + } + + @Override + @SuppressWarnings("unchecked") + public void route(StreamEvent event) { + if (event == null || event.getEventType() == null) { + log.warn("Cannot route null event or event with null eventType"); + return; + } + + String eventType = event.getEventType(); + List> registrations = routeRegistrations.get(eventType); + + if (registrations == null || registrations.isEmpty()) { + log.debug("No routes registered for eventType: {}", eventType); + return; + } + + log.debug("Routing event: eventId={}, eventType={}, businessType={}", + event.getEventId(), eventType, event.getBusinessType()); + + for (RouteRegistration registration : registrations) { + if (matchesBusinessType(registration.getBusinessType(), event.getBusinessType()) && + registration.getPayloadType().isInstance(event.getPayload()) && + matchesCondition(registration.getCondition(), event.getPayload())) { + try { + ((StreamRouteHandler) registration.getHandler()).handle(event.getPayload(), event); + log.debug("Dispatched event to handler: {} for eventType: {}", registration.getHandlerId(), eventType); + } catch (Exception e) { + log.error("Error handling event in handler: {} for eventType: {}", registration.getHandlerId(), eventType, e); + } + } + } + } + + @Override + public boolean isRouteRegistered(String eventType) { + return routeRegistrations.containsKey(eventType) && !routeRegistrations.get(eventType).isEmpty(); + } + + @Override + public List> getRoutes(String eventType) { + return routeRegistrations.getOrDefault(eventType, new ArrayList<>()); + } + + private String generateHandlerId(String eventType, String businessType, Class payloadType) { + return eventType + ":" + (businessType != null ? businessType : "default") + ":" + payloadType.getSimpleName() + ":" + handlerCounter.incrementAndGet(); + } + + private boolean matchesBusinessType(String pattern, String businessType) { + if (pattern == null || pattern.isEmpty() || "*".equals(pattern)) { + return true; + } + return pattern.equals(businessType); + } + + private boolean matchesCondition(String condition, T payload) { + if (condition == null || condition.isEmpty()) { + return true; + } + + try { + Expression expression = expressionParser.parseExpression(condition); + EvaluationContext context = new StandardEvaluationContext(); + context.setVariable("payload", payload); + Boolean result = expression.getValue(context, Boolean.class); + return Boolean.TRUE.equals(result); + } catch (Exception e) { + log.warn("Failed to evaluate condition: {} for payload: {}", condition, payload, e); + return false; + } + } +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteHandlerBeanPostProcessor.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteHandlerBeanPostProcessor.java new file mode 100644 index 0000000..c4c2c21 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteHandlerBeanPostProcessor.java @@ -0,0 +1,81 @@ +package cn.structure.infra.stream.router; + +import cn.structure.infra.stream.annotation.StreamRouteHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; + +@Component +public class RouteHandlerBeanPostProcessor implements BeanPostProcessor { + + private static final Logger log = LoggerFactory.getLogger(RouteHandlerBeanPostProcessor.class); + + private final StreamEventRouter eventRouter; + + public RouteHandlerBeanPostProcessor(StreamEventRouter eventRouter) { + this.eventRouter = eventRouter; + } + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + return bean; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + Class beanClass = bean.getClass(); + for (Method method : beanClass.getDeclaredMethods()) { + if (method.isAnnotationPresent(StreamRouteHandler.class)) { + StreamRouteHandler annotation = method.getAnnotation(StreamRouteHandler.class); + String eventType = annotation.eventType(); + if (eventType.isEmpty()) { + eventType = annotation.value(); + } + String businessType = annotation.businessType(); + String condition = annotation.condition(); + + if (eventType.isEmpty()) { + log.warn("Skipping method {} in bean {}: eventType is not specified", method.getName(), beanName); + continue; + } + + Class[] parameterTypes = method.getParameterTypes(); + if (parameterTypes.length == 0) { + log.warn("Skipping method {} in bean {}: no parameters found", method.getName(), beanName); + continue; + } + + Class payloadType = parameterTypes[0]; + + registerRoute(eventType, businessType, payloadType, condition, bean, method); + } + } + return bean; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private void registerRoute(String eventType, String businessType, Class payloadType, + String condition, Object bean, Method method) { + try { + method.setAccessible(true); + StreamEventRouter.StreamRouteHandler handler = (payload, event) -> { + try { + method.invoke(bean, payload); + } catch (Exception e) { + log.error("Error invoking handler method: {}", method.getName(), e); + throw new RuntimeException("Error invoking handler method", e); + } + }; + + eventRouter.registerRoute(eventType, businessType, payloadType, condition, handler); + log.info("Registered route handler: eventType={}, businessType={}, payloadType={}, method={}", + eventType, businessType, payloadType.getName(), method.getName()); + } catch (Exception e) { + log.error("Failed to register route handler for method: {}", method.getName(), e); + } + } +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteRegistration.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteRegistration.java new file mode 100644 index 0000000..599e5bc --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteRegistration.java @@ -0,0 +1,119 @@ +package cn.structure.infra.stream.router; + +public class RouteRegistration { + + private String handlerId; + private String eventType; + private String businessType; + private Class payloadType; + private String condition; + private StreamEventRouter.StreamRouteHandler handler; + + public RouteRegistration() { + } + + public RouteRegistration(String handlerId, String eventType, String businessType, + Class payloadType, String condition, StreamEventRouter.StreamRouteHandler handler) { + this.handlerId = handlerId; + this.eventType = eventType; + this.businessType = businessType; + this.payloadType = payloadType; + this.condition = condition; + this.handler = handler; + } + + public String getHandlerId() { + return handlerId; + } + + public void setHandlerId(String handlerId) { + this.handlerId = handlerId; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getBusinessType() { + return businessType; + } + + public void setBusinessType(String businessType) { + this.businessType = businessType; + } + + public Class getPayloadType() { + return payloadType; + } + + public void setPayloadType(Class payloadType) { + this.payloadType = payloadType; + } + + public String getCondition() { + return condition; + } + + public void setCondition(String condition) { + this.condition = condition; + } + + public StreamEventRouter.StreamRouteHandler getHandler() { + return handler; + } + + public void setHandler(StreamEventRouter.StreamRouteHandler handler) { + this.handler = handler; + } + + public static Builder builder() { + return new Builder<>(); + } + + public static class Builder { + private String handlerId; + private String eventType; + private String businessType; + private Class payloadType; + private String condition; + private StreamEventRouter.StreamRouteHandler handler; + + public Builder handlerId(String handlerId) { + this.handlerId = handlerId; + return this; + } + + public Builder eventType(String eventType) { + this.eventType = eventType; + return this; + } + + public Builder businessType(String businessType) { + this.businessType = businessType; + return this; + } + + public Builder payloadType(Class payloadType) { + this.payloadType = payloadType; + return this; + } + + public Builder condition(String condition) { + this.condition = condition; + return this; + } + + public Builder handler(StreamEventRouter.StreamRouteHandler handler) { + this.handler = handler; + return this; + } + + public RouteRegistration build() { + return new RouteRegistration<>(handlerId, eventType, businessType, payloadType, condition, handler); + } + } +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouterProperties.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouterProperties.java new file mode 100644 index 0000000..7951775 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouterProperties.java @@ -0,0 +1,104 @@ +package cn.structure.infra.stream.router; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +@ConfigurationProperties(prefix = "structure.infra.stream.router") +public class RouterProperties { + + private boolean enabled = true; + private List routes = new ArrayList<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public List getRoutes() { + return routes; + } + + public void setRoutes(List routes) { + this.routes = routes; + } + + public static class RouteDefinition { + private String id; + private String eventType; + private String businessType; + private String payloadType; + private String condition; + private String handlerBean; + private String handlerMethod; + private String description; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getBusinessType() { + return businessType; + } + + public void setBusinessType(String businessType) { + this.businessType = businessType; + } + + public String getPayloadType() { + return payloadType; + } + + public void setPayloadType(String payloadType) { + this.payloadType = payloadType; + } + + public String getCondition() { + return condition; + } + + public void setCondition(String condition) { + this.condition = condition; + } + + public String getHandlerBean() { + return handlerBean; + } + + public void setHandlerBean(String handlerBean) { + this.handlerBean = handlerBean; + } + + public String getHandlerMethod() { + return handlerMethod; + } + + public void setHandlerMethod(String handlerMethod) { + this.handlerMethod = handlerMethod; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/StreamEventRouter.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/StreamEventRouter.java new file mode 100644 index 0000000..6b2c9e0 --- /dev/null +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/StreamEventRouter.java @@ -0,0 +1,31 @@ +package cn.structure.infra.stream.router; + +import cn.structure.infra.stream.event.StreamEvent; + +import java.util.List; + +public interface StreamEventRouter { + + void registerRoute(String eventType, Class payloadType, StreamRouteHandler handler); + + void registerRoute(String eventType, Class payloadType, String condition, StreamRouteHandler handler); + + void registerRoute(String eventType, String businessType, Class payloadType, StreamRouteHandler handler); + + void registerRoute(String eventType, String businessType, Class payloadType, String condition, StreamRouteHandler handler); + + void unregisterRoute(String eventType); + + void unregisterRoute(String eventType, String handlerId); + + void route(StreamEvent event); + + boolean isRouteRegistered(String eventType); + + List> getRoutes(String eventType); + + interface StreamRouteHandler { + void handle(T payload, StreamEvent event); + } + +} diff --git a/structure-infra-stream-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/structure-infra-stream-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..6b03bd8 --- /dev/null +++ b/structure-infra-stream-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.structure.infra.stream.configuration.StreamAutoConfiguration diff --git a/structure-infra-xxljob-starter/pom.xml b/structure-infra-xxljob-starter/pom.xml new file mode 100644 index 0000000..c0a75c5 --- /dev/null +++ b/structure-infra-xxljob-starter/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + cn.structured + structure-pro-infra + ${revision} + ../pom.xml + + + structure-pro-xxljob-starter + structure-infra-xxljob-starter + structure-pro-xxljob-starter + jar + + + + cn.structured + structure-common + + + cn.structured + structure-infra-schedule-starter + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-autoconfigure + + + + cn.structured + structure-job-starter + + + + \ No newline at end of file diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java new file mode 100644 index 0000000..e898026 --- /dev/null +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java @@ -0,0 +1,34 @@ +package cn.structure.infra.configuration; + +import cn.structure.infra.properties.XxlJobProperties; +import cn.structure.infra.schedule.TaskScheduler; +import cn.structure.infra.schedule.xxljob.XxlJobTaskScheduler; +import cn.structure.infra.schedule.xxljob.XxlJobTemplate; +import cn.structure.infra.schedule.xxljob.XxlJobTemplateImpl; +import cn.structure.job.rpc.XxlJobClient; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +@EnableConfigurationProperties(XxlJobProperties.class) +@AutoConfigureBefore(cn.structure.infra.configuration.AutoScheduleConfiguration.class) +public class AutoXxlJobConfiguration { + + @Bean + @ConditionalOnMissingBean(XxlJobTemplate.class) + public XxlJobTemplate xxlJobTemplate(XxlJobClient xxlJobClient, XxlJobProperties xxlJobProperties) { + log.info(">>>>>>>>>>> xxl-job template init."); + return new XxlJobTemplateImpl(xxlJobClient, xxlJobProperties); + } + + @Bean + @ConditionalOnMissingBean(TaskScheduler.class) + public TaskScheduler taskScheduler(XxlJobTemplate xxlJobTemplate) { + return new XxlJobTaskScheduler(xxlJobTemplate); + } +} \ No newline at end of file diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/properties/XxlJobProperties.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/properties/XxlJobProperties.java new file mode 100644 index 0000000..83546c5 --- /dev/null +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/properties/XxlJobProperties.java @@ -0,0 +1,14 @@ +package cn.structure.infra.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "structure.schedule.xxl-job") +public class XxlJobProperties { + + private boolean enabled = true; + + private Integer jobGroup = 1; + +} \ No newline at end of file diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTaskScheduler.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTaskScheduler.java new file mode 100644 index 0000000..50f4e4b --- /dev/null +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTaskScheduler.java @@ -0,0 +1,163 @@ +package cn.structure.infra.schedule.xxljob; + +import cn.structure.infra.schedule.ScheduleTask; +import cn.structure.infra.schedule.TaskScheduler; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +public class XxlJobTaskScheduler implements TaskScheduler { + + private final XxlJobTemplate xxlJobTemplate; + + private final Map taskIdToXxlJobIdMap = new ConcurrentHashMap<>(); + + private final Map taskMap = new ConcurrentHashMap<>(); + + public XxlJobTaskScheduler(XxlJobTemplate xxlJobTemplate) { + this.xxlJobTemplate = xxlJobTemplate; + log.info("XxlJobTaskScheduler initialized"); + } + + @Override + public void schedule(ScheduleTask task) { + validateTask(task); + + remove(task.getTaskId()); + + String cronExpression = convertToCron(task); + + String xxlJobId = xxlJobTemplate.add( + task.getTaskName(), + cronExpression, + task.getHandlerName(), + task.getHandlerParam() + ); + + taskIdToXxlJobIdMap.put(task.getTaskId(), xxlJobId); + task.setStatus(ScheduleTask.TaskStatus.RUNNING); + taskMap.put(task.getTaskId(), task); + + log.info("Scheduled task via XXL-Job: taskId={}, xxlJobId={}, handler={}", + task.getTaskId(), xxlJobId, task.getHandlerName()); + } + + @Override + public void update(ScheduleTask task) { + validateTask(task); + + String xxlJobId = taskIdToXxlJobIdMap.get(task.getTaskId()); + if (xxlJobId == null) { + log.warn("XXL-Job task not found for update: {}", task.getTaskId()); + schedule(task); + return; + } + + String cronExpression = convertToCron(task); + + xxlJobTemplate.update( + xxlJobId, + task.getTaskName(), + cronExpression, + task.getHandlerName(), + task.getHandlerParam() + ); + + task.setStatus(ScheduleTask.TaskStatus.RUNNING); + taskMap.put(task.getTaskId(), task); + + log.info("Updated task via XXL-Job: taskId={}, xxlJobId={}", task.getTaskId(), xxlJobId); + } + + @Override + public void remove(String taskId) { + String xxlJobId = taskIdToXxlJobIdMap.remove(taskId); + if (xxlJobId != null) { + xxlJobTemplate.remove(xxlJobId); + } + + ScheduleTask task = taskMap.remove(taskId); + if (task != null) { + task.setStatus(ScheduleTask.TaskStatus.STOPPED); + } + + log.info("Removed task via XXL-Job: taskId={}, xxlJobId={}", taskId, xxlJobId); + } + + @Override + public void pause(String taskId) { + String xxlJobId = taskIdToXxlJobIdMap.get(taskId); + if (xxlJobId != null) { + xxlJobTemplate.pause(xxlJobId); + + ScheduleTask task = taskMap.get(taskId); + if (task != null) { + task.setStatus(ScheduleTask.TaskStatus.PAUSED); + } + } + + log.info("Paused task via XXL-Job: taskId={}, xxlJobId={}", taskId, xxlJobId); + } + + @Override + public void resume(String taskId) { + String xxlJobId = taskIdToXxlJobIdMap.get(taskId); + if (xxlJobId != null) { + ScheduleTask task = taskMap.get(taskId); + if (task != null && task.getStatus() == ScheduleTask.TaskStatus.PAUSED) { + xxlJobTemplate.start(xxlJobId); + task.setStatus(ScheduleTask.TaskStatus.RUNNING); + } + } + + log.info("Resumed task via XXL-Job: taskId={}, xxlJobId={}", taskId, xxlJobId); + } + + @Override + public ScheduleTask getTaskInfo(String taskId) { + return taskMap.get(taskId); + } + + @Override + public List getAllTasks() { + return List.copyOf(taskMap.values()); + } + + private void validateTask(ScheduleTask task) { + if (task == null || task.getTaskId() == null) { + throw new IllegalArgumentException("Task and taskId cannot be null"); + } + + if (task.getHandlerName() == null || task.getHandlerName().isEmpty()) { + throw new IllegalArgumentException("Handler name cannot be null or empty"); + } + + if (task.getScheduleType() == null) { + throw new IllegalArgumentException("ScheduleType cannot be null"); + } + } + + private String convertToCron(ScheduleTask task) { + if (task.getScheduleType() == ScheduleTask.ScheduleType.CRON) { + if (task.getCronExpression() == null || task.getCronExpression().isEmpty()) { + throw new IllegalArgumentException("Cron expression cannot be null for CRON schedule type"); + } + return task.getCronExpression(); + } + + long milliseconds = task.getScheduleType() == ScheduleTask.ScheduleType.FIXED_RATE + ? (task.getPeriod() != null ? task.getPeriod() : 1000L) + : (task.getDelay() != null ? task.getDelay() : 1000L); + + long seconds = milliseconds / 1000; + + if (seconds < 1) { + seconds = 1; + } + + return "0/" + seconds + " * * * * ?"; + } +} \ No newline at end of file diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplate.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplate.java new file mode 100644 index 0000000..8b64e5c --- /dev/null +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplate.java @@ -0,0 +1,16 @@ +package cn.structure.infra.schedule.xxljob; + +public interface XxlJobTemplate { + + String add(String jobName, String cronExpression, String handlerName, String handlerParam); + + void update(String jobId, String jobName, String cronExpression, String handlerName, String handlerParam); + + void remove(String jobId); + + void pause(String jobId); + + void start(String jobId); + + String getJobId(String handlerName); +} \ No newline at end of file diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplateImpl.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplateImpl.java new file mode 100644 index 0000000..5c03f3c --- /dev/null +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplateImpl.java @@ -0,0 +1,101 @@ +package cn.structure.infra.schedule.xxljob; + +import cn.structure.infra.properties.XxlJobProperties; +import cn.structure.job.dto.XxlJobInfoDTO; +import cn.structure.job.enums.ExecutorRouteStrategyEnum; +import cn.structure.job.rpc.XxlJobClient; +import com.xxl.job.core.constant.ExecutorBlockStrategyEnum; +import com.xxl.job.core.context.XxlJobContext; +import com.xxl.job.core.glue.GlueTypeEnum; +import com.xxl.tool.response.Response; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@AllArgsConstructor +public class XxlJobTemplateImpl implements XxlJobTemplate { + + private final XxlJobClient xxlJobClient; + + private final XxlJobProperties jobProperties; + + @Override + public String add(String jobName, String cronExpression, String handlerName, String handlerParam) { + XxlJobInfoDTO jobInfo = buildJobInfo(null, jobName, cronExpression, handlerName, handlerParam); + Response returnT = xxlJobClient.add(jobInfo); + if (returnT.getCode() == XxlJobContext.HANDLE_CODE_SUCCESS) { + log.info("XXL-Job add success: jobName={}, handlerName={}, jobId={}", jobName, handlerName, returnT.getData()); + return returnT.getData(); + } else { + log.error("XXL-Job add failed: jobName={}, handlerName={}, message={}", jobName, handlerName, returnT.getMsg()); + throw new RuntimeException("XXL-Job add failed: " + returnT.getMsg()); + } + } + + @Override + public void update(String jobId, String jobName, String cronExpression, String handlerName, String handlerParam) { + XxlJobInfoDTO jobInfo = buildJobInfo(Integer.parseInt(jobId), jobName, cronExpression, handlerName, handlerParam); + Response returnT = xxlJobClient.update(jobInfo); + if (returnT.getCode() == XxlJobContext.HANDLE_CODE_SUCCESS) { + log.info("XXL-Job update success: jobId={}, jobName={}, handlerName={}", jobId, jobName, handlerName); + } else { + log.error("XXL-Job update failed: jobId={}, jobName={}, message={}", jobId, jobName, returnT.getMsg()); + throw new RuntimeException("XXL-Job update failed: " + returnT.getMsg()); + } + } + + @Override + public void remove(String jobId) { + Response returnT = xxlJobClient.remove(jobId); + if (returnT.getCode() == XxlJobContext.HANDLE_CODE_SUCCESS) { + log.info("XXL-Job remove success: jobId={}", jobId); + } else { + log.error("XXL-Job remove failed: jobId={}, message={}", jobId, returnT.getMsg()); + throw new RuntimeException("XXL-Job remove failed: " + returnT.getMsg()); + } + } + + @Override + public void pause(String jobId) { + Response returnT = xxlJobClient.pause(jobId); + if (returnT.getCode() == XxlJobContext.HANDLE_CODE_SUCCESS) { + log.info("XXL-Job pause success: jobId={}", jobId); + } else { + log.error("XXL-Job pause failed: jobId={}, message={}", jobId, returnT.getMsg()); + throw new RuntimeException("XXL-Job pause failed: " + returnT.getMsg()); + } + } + + @Override + public void start(String jobId) { + Response returnT = xxlJobClient.start(jobId); + if (returnT.getCode() == XxlJobContext.HANDLE_CODE_SUCCESS) { + log.info("XXL-Job start success: jobId={}", jobId); + } else { + log.error("XXL-Job start failed: jobId={}, message={}", jobId, returnT.getMsg()); + throw new RuntimeException("XXL-Job start failed: " + returnT.getMsg()); + } + } + + @Override + public String getJobId(String handlerName) { + return null; + } + + private XxlJobInfoDTO buildJobInfo(Integer id, String jobName, String cronExpression, String handlerName, String handlerParam) { + XxlJobInfoDTO jobInfo = new XxlJobInfoDTO(); + jobInfo.setId(id); + jobInfo.setJobGroup(jobProperties.getJobGroup()); + jobInfo.setJobCron(cronExpression); + jobInfo.setJobDesc(jobName); + jobInfo.setAuthor("system"); + jobInfo.setExecutorHandler(handlerName); + jobInfo.setExecutorParam(handlerParam); + jobInfo.setExecutorRouteStrategy(ExecutorRouteStrategyEnum.FIRST.name()); + jobInfo.setExecutorBlockStrategy(ExecutorBlockStrategyEnum.SERIAL_EXECUTION.name()); + jobInfo.setExecutorTimeout(300); + jobInfo.setExecutorFailRetryCount(1); + jobInfo.setGlueType(GlueTypeEnum.BEAN.name()); + return jobInfo; + } +} \ No newline at end of file diff --git a/structure-infra-xxljob-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/structure-infra-xxljob-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..dd48a62 --- /dev/null +++ b/structure-infra-xxljob-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.structure.infra.configuration.AutoXxlJobConfiguration \ No newline at end of file From 2670b5b10cc2eeb3db7e989ab3e43d543807425f Mon Sep 17 00:00:00 2001 From: chuck <361648887@qq.com> Date: Sat, 4 Jul 2026 01:33:44 +0800 Subject: [PATCH 2/8] =?UTF-8?q?refactor(infra):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E9=85=8D=E7=BD=AE=E5=B9=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?Maven=E6=8F=92=E4=BB=B6=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除了CacheConfig类中的缓存配置 - 在pom.xml中添加了Spring Boot Maven插件的版本管理 - 统一了构建插件的版本控制机制 --- pom.xml | 9 +++++++++ .../infra/sample/infra/config/CacheConfig.java | 17 ----------------- 2 files changed, 9 insertions(+), 17 deletions(-) delete mode 100644 structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/CacheConfig.java diff --git a/pom.xml b/pom.xml index 0461c07..4bc2578 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,15 @@ + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + org.apache.maven.plugins diff --git a/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/CacheConfig.java b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/CacheConfig.java deleted file mode 100644 index 56d423a..0000000 --- a/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/CacheConfig.java +++ /dev/null @@ -1,17 +0,0 @@ -package cn.structure.infra.sample.infra.config; - -import org.springframework.cache.CacheManager; -import org.springframework.cache.annotation.EnableCaching; -import org.springframework.cache.concurrent.ConcurrentMapCacheManager; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableCaching -public class CacheConfig { - - @Bean - public CacheManager cacheManager() { - return new ConcurrentMapCacheManager(); - } -} \ No newline at end of file From 189fdbc02fb6460eb697d3479ffe11a525677aff Mon Sep 17 00:00:00 2001 From: chuck <361648887@qq.com> Date: Sat, 4 Jul 2026 01:34:39 +0800 Subject: [PATCH 3/8] =?UTF-8?q?chore(build):=20=E6=9B=B4=E6=96=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=89=88=E6=9C=AC=E5=8F=B7=E4=BB=8E=201.0.0-SNAPSHOT?= =?UTF-8?q?=20=E5=88=B0=201.1.0-SNAPSHOT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 pom.xml 中的 revision 属性值 - 将项目版本从 1.0-SNAPSHOT 升级到 1.1.0-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4bc2578..9367e80 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,7 @@ pro项目父工程 - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT 4.0.6 5.0.0 3.5.16 From 195d83981d589becf92cd038f1ed64d3fcc576a5 Mon Sep 17 00:00:00 2001 From: chuck <361648887@qq.com> Date: Sat, 4 Jul 2026 01:38:05 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(stream):=20=E4=BF=AE=E5=A4=8D=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E4=BA=8B=E4=BB=B6=E7=9B=91=E5=90=AC=E5=99=A8=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=90=8D=E7=A7=B0=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 paymentEvent 监听器的 bindingName 为 paymentEvent1 - 确保支付成功事件能够正确路由和处理 --- .../infra/sample/stream/listener/PaymentEventListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java index a706e3d..207e8f8 100644 --- a/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java +++ b/structure-infra-sample/structure-infra-sample-stream/src/main/java/cn/structure/infra/sample/stream/listener/PaymentEventListener.java @@ -17,7 +17,7 @@ public void handlePaymentEvent(PaymentEvent event) { event.getPaymentId(), event.getOrderId(), event.getPaymentStatus(), event.getAmount()); } - @StreamEventListener(bindingName = "paymentEvent", destination = "payment-exchange", group = "payment-group", condition = "#event.paymentStatus == 'SUCCESS'") + @StreamEventListener(bindingName = "paymentEvent1", destination = "payment-exchange", group = "payment-group", condition = "#event.paymentStatus == 'SUCCESS'") public void handlePaymentSuccess(PaymentEvent event) { log.info("[支付成功] paymentId={}, orderId={}, amount={}", event.getPaymentId(), event.getOrderId(), event.getAmount()); From 1dd65eea98e68da256fb1260b884f82621601510 Mon Sep 17 00:00:00 2001 From: chuck <361648887@qq.com> Date: Sat, 4 Jul 2026 02:05:50 +0800 Subject: [PATCH 5/8] =?UTF-8?q?docs(infra):=20=E6=B7=BB=E5=8A=A0=20Elastic?= =?UTF-8?q?search=E3=80=81JPA=E3=80=81MongoDB=E3=80=81MyBatis=20Plus=20?= =?UTF-8?q?=E5=9B=9B=E4=B8=AA=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=99=A8=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 structure-infra-elasticsearch-starter 模块 README,介绍基于 Spring Data Elasticsearch 的仓储适配功能,包括自动配置、类型化仓储、低代码存储等特性 - 新增 structure-infra-jpa-starter 模块 README,介绍基于 JPA/Hibernate 的仓储适配功能, 包括自动配置、条件激活、委托工厂、CRUD 操作等核心功能 - 新增 structure-infra-mongodb-starter 模块 README,介绍基于 Spring Data MongoDB 的 仓储适配功能,涵盖自动配置、类型化仓储、低代码存储及索引自动创建特性 - 新增 structure-infra-mybatis-plus-starter 模块 README,介绍 MyBatis-Plus 仓储适配 与低代码 MySQL/H2 存储功能,支持多方言、自动 DDL、分页等特性 --- README.md | 419 ++++++++++---- .../README.md | 298 ++++++++++ structure-infra-jpa-starter/README.md | 298 ++++++++++ structure-infra-mongodb-starter/README.md | 274 +++++++++ .../README.md | 382 +++++++++++++ structure-infra-schedule-starter/README.md | 300 ++++++++++ structure-infra-starter/README.md | 531 ++++++++++++++++++ structure-infra-xxljob-starter/README.md | 335 +++++++++++ 8 files changed, 2723 insertions(+), 114 deletions(-) create mode 100644 structure-infra-elasticsearch-starter/README.md create mode 100644 structure-infra-jpa-starter/README.md create mode 100644 structure-infra-mongodb-starter/README.md create mode 100644 structure-infra-mybatis-plus-starter/README.md create mode 100644 structure-infra-schedule-starter/README.md create mode 100644 structure-infra-starter/README.md create mode 100644 structure-infra-xxljob-starter/README.md diff --git a/README.md b/README.md index 2a67648..9532e0f 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,55 @@ # structure-pro-infra -基于 DDD(领域驱动设计)理念的基础设施抽象层,提供统一的仓储接口和多种持久化技术的适配实现。 +基于 DDD(领域驱动设计)理念的基础设施抽象层,提供统一的仓储接口、多种持久化技术适配、事件管理、任务调度与流式事件路由能力。 ## 项目简介 -该项目实现了一个基于 **Facade + Delegate** 模式的仓储抽象层,作为领域层与持久化层之间的防腐层(ACL),核心目标是: +该项目实现了一个基于 **Facade + Delegate** 模式的仓储抽象层,作为领域层与持久化层之间的防腐层(ACL),同时集成了事件发布、任务调度与流式事件路由等基础设施能力。核心目标是: - **解耦领域模型与持久化技术**:领域层只依赖统一的仓储接口,不关心底层使用哪种数据库 - **支持多持久化技术**:通过委托模式自动适配 MyBatis Plus、JPA、MongoDB、Elasticsearch 等 - **自动配置**:基于 Spring Boot AutoConfiguration 实现开箱即用 - **Entity-PO 自动转换**:RepositoryFacade 自动完成领域实体与持久化对象的转换 -- **CQRS 读写分离**:支持一个仓储配置多个代理,写操作走基础代理,读操作走读代理 +- **CQRS 读写分离**:支持一个仓储配置多个代理,写操作走基础代理,读操作走读代理,读失败自动回退 - **低代码仓储**:无需定义实体类,通过资源名称和 Map 动态操作数据,支持运行时动态注册 +- **事件管理**:统一的事件发布抽象,支持 Spring 事件与消息事件两种通道 +- **任务调度**:本地线程池调度与 XXL-Job 分布式调度两种实现,统一 TaskScheduler SPI +- **流式事件路由**:基于 Spring Cloud Stream 的事件监听与统一路由能力 ## 模块结构 ``` structure-pro-infra/ -├── structure-infra-starter/ # 核心模块 -│ ├── annotations/ # 注解定义 -│ │ ├── Repository.java # @Repository 注解 -│ │ └── DelegateFor.java # @DelegateFor 注解 -│ ├── configuration/ # 自动配置 -│ ├── repository/ # 仓储核心接口 -│ │ ├── RepositoryFacade.java # 仓储门面(对外) -│ │ ├── RepositoryDelegate.java # 仓储委托(对内) -│ │ ├── RepositoryDelegateFactory.java # 委托工厂接口 -│ │ ├── RepositoryType.java # 仓储类型枚举 -│ │ ├── DelegateType.java # 委托类型枚举(BASE/READ) -│ │ └── InMemoryRepositoryDelegate.java # 内存实现(开发/测试用) -│ ├── lowcode/ # 低代码仓储 -│ │ ├── repository/ # 低代码仓储接口 -│ │ │ ├── LowCodeRepository.java # 低代码统一仓储接口(用户侧) -│ │ │ ├── LowCodeStorage.java # 低代码存储接口(引擎侧) -│ │ │ └── LowCodeRepoFactory.java # 低代码仓储工厂接口 -│ │ ├── router/ # 路由引擎 -│ │ │ └── LowCodeRepositoryRouter.java # 低代码仓储路由器 -│ │ ├── model/ # 模型定义 -│ │ │ ├── ResourceSchema.java # 资源 schema -│ │ │ ├── FieldSchema.java # 字段 schema -│ │ │ ├── StorageType.java # 存储类型枚举 -│ │ │ ├── FieldType.java # 字段类型枚举 -│ │ │ ├── AutoFillType.java # 自动填充类型枚举 -│ │ │ └── RepositoryConfig.java # 仓储配置 -│ │ └── registry/ # 注册与构建 -│ │ └── ResourceSchemaBuilder.java # 资源 schema 构建器 -│ └── event/ # 事件管理 -├── structure-infra-mybatis-plus-starter/ # MyBatis Plus 适配(含低代码实现) -├── structure-infra-jpa-starter/ # JPA 适配 -├── structure-infra-mongodb-starter/ # MongoDB 适配(含低代码实现) -├── structure-infra-elasticsearch-starter/ # Elasticsearch 适配(含低代码实现) -└── structure-infra-sample/ # 示例模块 - ├── structure-infra-sample-core/ # 共享核心(Entity、PO、Repository接口) - ├── structure-infra-sample-mybatis/ # MyBatis Plus 示例(含低代码测试) - ├── structure-infra-sample-jpa/ # JPA 示例 - ├── structure-infra-sample-mongodb/ # MongoDB 示例(含低代码测试) - ├── structure-infra-sample-elasticsearch/ # Elasticsearch 示例(含低代码测试) - └── structure-infra-sample-cqrs/ # CQRS 读写分离示例 +├── structure-infra-starter/ # 核心模块(仓储抽象、低代码、事件管理、调度集成) +│ ├── annotations/ # @Repository / @DelegateFor 注解 +│ ├── configuration/ # 自动配置(仓储、事件、调度) +│ ├── event/ # 事件管理抽象 +│ ├── lowcode/ # 低代码仓储子系统 +│ │ ├── configuration/ # 低代码自动配置 +│ │ ├── model/ # ResourceSchema / FieldSchema 等模型 +│ │ ├── properties/ # LowCodeProperties 配置绑定 +│ │ ├── registry/ # ResourceSchemaBuilder +│ │ ├── repository/ # LowCodeRepository / LowCodeStorage / LowCodeRepoFactory +│ │ └── router/ # LowCodeRepositoryRouter +│ ├── properties/ # InfraProperties 全局配置 +│ └── repository/ # 仓储核心接口(Facade / Delegate / Factory) +├── structure-infra-mybatis-plus-starter/ # MyBatis Plus 适配(含低代码 MySQL 实现) +├── structure-infra-jpa-starter/ # JPA 适配 +├── structure-infra-mongodb-starter/ # MongoDB 适配(含低代码实现) +├── structure-infra-elasticsearch-starter/ # Elasticsearch 适配(含低代码实现) +├── structure-infra-schedule-starter/ # 本地任务调度(基于 ScheduledExecutorService) +├── structure-infra-xxljob-starter/ # XXL-Job 分布式任务调度适配 +├── structure-infra-stream-starter/ # Spring Cloud Stream 事件路由 +└── structure-infra-sample/ # 示例模块(不发布到中央仓库) + ├── structure-infra-sample-core/ # 共享核心(Entity、PO、Repository 接口) + ├── structure-infra-sample-mybatis/ # MyBatis Plus 示例(含低代码测试) + ├── structure-infra-sample-jpa/ # JPA 示例 + ├── structure-infra-sample-mongodb/ # MongoDB 示例(含 REST API、低代码测试) + ├── structure-infra-sample-elasticsearch/ # Elasticsearch 示例(含 REST API、低代码测试) + ├── structure-infra-sample-cqrs/ # CQRS 读写分离示例 + ├── structure-infra-sample-schedule/ # 本地调度示例(含 REST API) + ├── structure-infra-sample-xxljob/ # XXL-Job 调度示例(含 REST API) + └── structure-infra-sample-stream/ # 流式事件路由示例 ``` ## 核心概念 @@ -75,7 +68,7 @@ structure-pro-infra/ 仓储委托,是持久化层的实现接口,负责: - 直接操作 PO(持久化对象) -- 与具体的持久化技术交互(MyBatis Plus、JPA、MongoDB 等) +- 与具体的持久化技术交互(MyBatis Plus、JPA、MongoDB、Elasticsearch 等) - 不同持久化技术提供各自的实现 ### RepositoryDelegateFactory @@ -143,6 +136,36 @@ structure-pro-infra/ | `REDIS` | - | 规划中 | | `IN_MEMORY` | - | 规划中(测试用) | +### 事件管理 + +提供统一的事件发布抽象,支持三种事件通道: + +| 通道 | 说明 | +|------|------| +| `DEFAULT` | 默认通道,运行时通过 `structure.infra.default-event-channel` 决定路由 | +| `SPRING_EVENT` | Spring 应用事件,通过 `ApplicationEventPublisher` 同步发布 | +| `MESSAGE_EVENT` | 消息事件,通过 `DataScopeStreamBridge` 发送到消息中间件 | + +### 任务调度 + +提供两种调度实现,通过 `TaskScheduler` SPI 统一抽象: + +| 实现 | 模块 | 适用场景 | +|------|------|---------| +| 本地线程池调度 | structure-infra-schedule-starter | 单机应用,无需外部依赖 | +| XXL-Job 分布式调度 | structure-infra-xxljob-starter | 分布式应用,需要集中管理 | + +支持三种调度类型:`CRON`、`FIXED_DELAY`、`FIXED_RATE`,并提供统一的 `schedule / update / remove / pause / resume / getTaskInfo / getAllTasks` 生命周期 API。 + +### 流式事件路由 + +基于 Spring Cloud Stream 的事件监听与统一路由框架,提供: + +- 动态 Binding 配置(替代静态配置) +- 基于 `eventType / businessType / condition` 的统一路由 +- 注解声明式、代码动态注册、配置文件驱动、运行时动态注册四种使用方式 +- SpEL 条件表达式过滤 + ## 快速开始 ### 示例模块 @@ -159,7 +182,12 @@ mvn spring-boot:run -pl structure-infra-sample/structure-infra-sample-mongodb mvn spring-boot:run -pl structure-infra-sample/structure-infra-sample-elasticsearch ``` -**REST API 接口**(两个示例模块接口一致): +**调度示例**(端口 8086): +```bash +mvn spring-boot:run -pl structure-infra-sample/structure-infra-sample-schedule +``` + +**REST API 接口**(MongoDB / Elasticsearch 示例模块接口一致): | 方法 | 路径 | 说明 | |------|------|------| @@ -172,6 +200,18 @@ mvn spring-boot:run -pl structure-infra-sample/structure-infra-sample-elasticsea | POST | `/api/users/batch` | 批量创建 | | GET | `/api/users/count` | 查询总数 | +**调度示例 REST API**: + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/job/add` | 添加任务(CRON) | +| PUT | `/job/update/{taskId}` | 更新任务 | +| DELETE | `/job/remove/{taskId}` | 删除任务 | +| PUT | `/job/pause/{taskId}` | 暂停任务 | +| PUT | `/job/resume/{taskId}` | 恢复任务 | +| GET | `/job/info/{taskId}` | 查询任务详情 | +| GET | `/job/list` | 查询所有任务 | + 详细示例模块说明请参考 [SAMPLE_MODULES.md](./SAMPLE_MODULES.md)。 ### 1. 添加依赖 @@ -213,6 +253,27 @@ mvn spring-boot:run -pl structure-infra-sample/structure-infra-sample-elasticsea structure-infra-elasticsearch-starter 1.0.0-SNAPSHOT + + + + cn.structured + structure-infra-schedule-starter + 1.0.0-SNAPSHOT + + + + + cn.structured + structure-infra-xxljob-starter + 1.0.0-SNAPSHOT + + + + + cn.structured + structure-infra-stream-starter + 1.0.0-SNAPSHOT + ``` ### 2. 定义领域实体和持久化对象 @@ -325,85 +386,151 @@ public class UserReadDelegate extends ElasticsearchRepositoryDelegate article = new HashMap<>(); article.put("title", "Hello World"); article.put("author", "zhangsan"); -Map saved = lowCodeRepositoryRouter.save("article", article); +Map saved = lowCodeRepository.save("article", article); // 查询数据 -Map found = lowCodeRepositoryRouter.findById("article", 1L); +Map found = lowCodeRepository.findById("article", 1L); // 条件查询 Map params = new HashMap<>(); params.put("author", "zhangsan"); -List> list = lowCodeRepositoryRouter.queryList("article", params); +List> list = lowCodeRepository.queryList("article", params); // 分页查询 ReqPage reqPage = new ReqPage(); reqPage.setPage(1); reqPage.setSize(10); -ResPage> page = lowCodeRepositoryRouter.queryPage("article", reqPage); +ResPage> page = lowCodeRepository.queryPage("article", reqPage); ``` **3) 切换存储引擎** -只需修改 `RepositoryConfig` 的 `type` 即可切换存储引擎,业务代码无需修改: +只需修改 `repository.type` 即可切换存储引擎,业务代码无需修改: + +```yaml +# 使用 MySQL +repository: + type: mysql + +# 使用 MongoDB +repository: + type: mongodb + +# 使用 Elasticsearch +repository: + type: elasticsearch +``` + +### 7. 任务调度使用 + +**1) 注册任务处理器** ```java -// 使用 MySQL -config.setType(StorageType.MYSQL); +@Component +public class MyTaskHandlers { -// 使用 MongoDB -config.setType(StorageType.MONGODB); + @Autowired + private TaskHandlerRegistry handlerRegistry; + + @PostConstruct + public void register() { + handlerRegistry.register("myHandler", this::handleTask); + } -// 使用 Elasticsearch -config.setType(StorageType.ELASTICSEARCH); + public void handleTask(String param) { + System.out.println("任务执行,参数:" + param); + } +} +``` + +**2) 调度任务** + +```java +@Autowired +private TaskScheduler taskScheduler; + +ScheduleTask task = ScheduleTask.builder() + .taskId("my-task-001") + .taskName("我的定时任务") + .handlerName("myHandler") + .handlerParam("hello") + .scheduleType(ScheduleTask.ScheduleType.CRON) + .cronExpression("0/10 * * * * ?") + .build(); + +taskScheduler.schedule(task); +taskScheduler.pause("my-task-001"); +taskScheduler.resume("my-task-001"); +taskScheduler.remove("my-task-001"); +``` + +### 8. 事件发布 + +```java +public class UserCreatedEvent implements Event { + private final String eventId = UUID.randomUUID().toString(); + + @Override + public String getEventId() { + return eventId; + } + // 默认走 DEFAULT 通道,由 structure.infra.default-event-channel 决定路由 +} + +@Component +@RequiredArgsConstructor +public class UserEventPublisher { + private final EventManager eventManager; + + public void publishCreated() { + eventManager.publish(new UserCreatedEvent()); + } +} ``` ## 注解说明 @@ -424,6 +551,7 @@ config.setType(StorageType.ELASTICSEARCH); | `cacheTime` | long | 60 | 缓存时间 | | `cacheTimeUnit` | TimeUnit | SECONDS | 缓存时间单位 | | `cqrs` | boolean | false | 是否启用 CQRS 读写分离 | +| `readDelegateClass` | Class | Object.class | 读代理类(CQRS 模式下使用) | ### @DelegateFor @@ -440,13 +568,84 @@ config.setType(StorageType.ELASTICSEARCH); ## 配置项 +### 全局配置 + ```yaml structure: infra: default-event-channel: SPRING_EVENT # 默认事件通道:DEFAULT, SPRING_EVENT, MESSAGE_EVENT - cqrs: false # 是否开启 CQRS + cqrs: false # 是否开启 CQRS(仅作建议,以 @Repository 注解为准) cache-time: 60 # 默认缓存时间 cache-time-unit: SECONDS # 默认缓存时间单位 + schedule-pool-size: 8 # 调度线程池大小(默认 CPU 核心数) + type: MYBATIS_PLUS # 默认持久化类型 +``` + +### 调度配置 + +```yaml +structure: + schedule: + pool-size: 4 # 本地调度线程池大小 + xxl-job: + enabled: true # 是否启用 XXL-Job + job-group: 1 # XXL-Job 执行器分组 ID +``` + +### 低代码配置 + +```yaml +structure: + infra: + lowcode: + enabled: true # 是否启用低代码仓储 + resources: + : + schema: + table-name: + id-type: long + fields: + : + type: string|long|int|boolean|decimal|datetime|date|text|json + length: 64 + primary-key: true|false + auto-increment: true|false + nullable: true|false + unique: true|false + index: true|false + default-value: + auto-fill: none|create|update|create_update + repository: + type: mysql|mongodb|elasticsearch|redis|in_memory + datasource: + cqrs: + enabled: true|false + read-type: elasticsearch + read-datasource: es-default + cache: + enabled: true|false + ttl: 300 + time-unit: seconds +``` + +### 流式事件配置 + +```yaml +structure: + infra: + stream: + enabled: true # 是否启用 + auto-binding: true # 自动生成 binding 配置 + default-group: my-service # 默认消费组 + default-concurrency: 1 # 默认并发数 + router: + enabled: true # 是否启用配置驱动路由 + routes: # 路由列表 + - id: route-001 + event-type: orderCreated + payload-type: com.example.OrderEvent + handler-bean: orderHandler + handler-method: onOrderCreated ``` ## 扩展指南 @@ -476,6 +675,13 @@ public class UserMybatisPlusDelegate extends MybatisPlusRepositoryDelegate` 实现完整的 CRUD/分页/批量操作 +- **委托自动创建**:未提供自定义 Delegate 时,由 `ElasticsearchDelegateFactory` 根据PO类自动创建 +- **自定义 Delegate 自动注入**:通过 `ElasticsearchDelegateBeanPostProcessor` 自动注入 `ElasticsearchOperations` 与实体类 +- **低代码仓储**:通过 `ElasticsearchLowCodeStorage` 使用 `Map` 动态操作文档,无需定义实体类 +- **自动建索引**:低代码初始化时自动创建 Elasticsearch 索引 +- **自动填充**:支持 `CREATE_TIME` / `CREATE_UPDATE` 自动填充 +- **条件查询**:根据非空字段动态构建 `Criteria` 等值查询 +- **CQRS 支持**:可作为 BASE 或 READ 代理参与读写分离,常作为读侧高速检索引擎 + +## 添加依赖 + +```xml + + cn.structured + structure-infra-elasticsearch-starter + 1.1.0-SNAPSHOT + +``` + +依赖中已包含 `spring-boot-starter-data-elasticsearch`,无需重复引入。 + +## 配置说明 + +### 基础配置 + +```yaml +structure: + infra: + type: ELASTICSEARCH # 显式指定存储类型(matchIfMissing=true 时可不配) + lowcode: + enabled: true # 启用低代码(默认开启) + +spring: + elasticsearch: + uris: http://localhost:9200 + username: elastic # 可选 + password: your_password # 可选 + connection-timeout: 1000 # 连接超时(毫秒) + socket-timeout: 30000 # Socket 超时(毫秒) +``` + +### 自动配置触发条件 + +| 条件 | 说明 | +|------|------| +| `@ConditionalOnClass(ElasticsearchOperations)` | 类路径存在 Spring Data Elasticsearch | +| `@ConditionalOnProperty(structure.infra.type=ELASTICSEARCH, matchIfMissing=true)` | 显式指定或默认启用 | +| `@ConditionalOnBean(ElasticsearchOperations.class)` | Spring 上下文中存在 `ElasticsearchOperations` Bean | +| `@ConditionalOnProperty(structure.infra.lowcode.enabled=true, matchIfMissing=true)` | 低代码默认启用 | + +注册的 AutoConfiguration(`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`): + +- `cn.structure.infra.elasticsearch.configuration.ElasticsearchAutoConfiguration` +- `cn.structure.infra.elasticsearch.lowcode.ElasticsearchLowCodeAutoConfiguration` + +## 核心组件 + +### 1. 类型化仓储 + +#### ElasticsearchRepositoryDelegate + +实现 `RepositoryDelegate` 接口,基于 `ElasticsearchOperations` 完成文档操作。 + +| 方法 | 说明 | +|------|------| +| `save(T)` | 保存(调用 `elasticsearchOperations.save`) | +| `removeById(ID)` | 根据 ID 删除(`String.valueOf(id)`) | +| `findById(ID)` | 根据 ID 查询(`elasticsearchOperations.get`) | +| `queryById(ID)` / `queryByIdOptional(ID)` | 读代理查询,默认走 `findById` | +| `queryOne(T)` / `queryOneOptional(T)` | 根据非空字段构建 `Criteria` 等值查询单条,取首条 | +| `queryList(T)` | 条件为空时 `Criteria.where("*").exists()` 全查,否则条件查询列表 | +| `queryPage(ReqPage)` | 分页查询,`PageRequest.of(page-1, size, Sort.unsorted())`,从 `SearchHits.getTotalHits()` 取总数 | +| `saveBatch(List)` | 逐条 `save`,返回保存后的列表 | +| `removeBatchByIds(List)` | 循环 `delete` 逐条删除 | +| `listByIds(List)` | 循环 `findById` 逐条查询,过滤 null | +| `count(T)` | 条件统计数量 | +| `exists(T)` | 条件判断是否存在 | + +**条件查询构建规则**:通过反射遍历对象所有字段(含父类),非空字段拼装为 `Criteria.where(name).is(value)` 链式 `and` 查询。 + +**ID 字段**:默认 `id`,可通过构造器或 `setIdFieldName` 修改。ES 操作时统一转换为 `String` 作为文档 ID。 + +#### ElasticsearchDelegateFactory + +实现 `RepositoryDelegateFactory` SPI: + +- `getType()` 返回 `RepositoryType.ELASTICSEARCH` +- `createDelegate(poClass, idClass)` 创建 `ElasticsearchRepositoryDelegate(elasticsearchOperations, poClass)` + +当 `RepositoryFacade` 未找到用户自定义的 Elasticsearch 类型 Delegate 时,由 `RepositoryBeanPostProcessor` 调用此工厂自动创建。 + +#### ElasticsearchDelegateBeanPostProcessor + +实现 `BeanPostProcessor`,在 Bean 初始化后处理自定义的 `ElasticsearchRepositoryDelegate` 实现: + +1. 检测 Bean 是否为 `ElasticsearchRepositoryDelegate` 实例 +2. 从 Spring 上下文获取 `ElasticsearchOperations` 并注入 +3. 读取 `@DelegateFor(po = XxxPO.class)` 注解,设置 `entityClass` + +### 2. 低代码仓储 + +#### ElasticsearchLowCodeAutoConfiguration + +低代码自动配置类,注册 `ElasticsearchLowCodeRepoFactory` Bean。 + +#### ElasticsearchLowCodeRepoFactory + +实现 `LowCodeRepoFactory` SPI: + +- `getType()` 返回 `StorageType.ELASTICSEARCH` +- `createStorage(schema, config)` 创建 `ElasticsearchLowCodeStorage(schema, elasticsearchOperations)` + +被 `LowCodeRepositoryRouter` 根据 `StorageType` 路由调用。 + +#### ElasticsearchLowCodeStorage + +实现 `LowCodeStorage` 接口,使用 `Map` 代替实体类操作 ES 索引,使用 `IndexCoordinates.of(tableName)` 定位索引。 + +**初始化(initialize)**: +- 检查索引是否存在,不存在则 `indexOps.create()` +- 不创建 mapping,使用 ES 动态映射 + +**CRUD 操作**: + +| 方法 | 实现说明 | +|------|---------| +| `save(Map)` | 自动填充字段;有 ID 且存在 → `doUpdate`(先 delete 再 index);否则 `doIndex` | +| `doIndex(Map)` | `IndexQueryBuilder.withId(id).withObject(data)` 构建 `IndexQuery` 执行索引 | +| `doUpdate(Map)` | 先 `delete` 旧文档,再 `doIndex` 重新索引(非部分更新) | +| `findById(Object)` | `elasticsearchOperations.get(id, Map.class, indexCoordinates)` | +| `queryOne(Map)` / `queryList(Map)` | 根据 schema 中已定义字段构建等值 `Criteria` 查询,返回 `Map` | +| `queryPage(ReqPage)` | `PageRequest` 分页查询,从 `SearchHits.getTotalHits()` 取总数 | +| `removeById(Object)` | `elasticsearchOperations.delete(id, indexCoordinates)` | +| `saveBatch(List)` | 逐条调用 `save` | +| `removeBatchByIds(List)` | 循环 `delete` 逐条删除 | +| `listByIds(List)` | 循环 `findById` 逐条查询,过滤 null | +| `count(Map)` / `exists(Map)` | 条件统计 | + +**自动填充**:根据 `FieldSchema.autoFill` 类型,在 `save` 时填充: +- `CREATE_TIME` / `CREATE_UPDATE`:插入时填充 `LocalDateTime` 或 `LocalDate` +- `UPDATE_TIME`:当前实现仅在 CREATE/CREATE_UPDATE 时填充 + +**全量查询条件**:当 `queryParams` 为空时,使用 `Criteria.where("_id").exists()` 匹配所有文档。 + +## 使用示例 + +### 方式一:类型化仓储(推荐用于领域模型) + +```java +// 1. PO 类(@Document 指定索引名) +@Document(indexName = "user") +public class UserPO { + @Id + private String id; + private String username; + private String email; + private Integer age; + // getter/setter +} + +// 2. 仓储接口 +public interface UserRepository extends Repository {} + +// 3. 仓储实现,继承 RepositoryFacade +@Repository(value = "用户仓储", type = RepositoryType.ELASTICSEARCH, + entity = UserEntity.class, po = UserPO.class) +@Component +public class UserRepositoryImpl + extends RepositoryFacade + implements UserRepository { + + // 未提供自定义 Delegate 时,框架会通过 ElasticsearchDelegateFactory 自动创建 +} +``` + +### 方式二:自定义 Delegate + +```java +@DelegateFor(po = UserPO.class) +@Component +public class UserElasticsearchRepositoryDelegate + extends ElasticsearchRepositoryDelegate + implements UserRepositoryDelegate { + + // 可覆写 queryOne/queryList 等方法实现自定义查询逻辑 + // ElasticsearchDelegateBeanPostProcessor 会自动注入 ElasticsearchOperations 和 entityClass +} +``` + +### 方式三:低代码仓储(无需定义 PO) + +```java +@Service +public class SearchService { + private final LowCodeRepository lowCodeRepository; + + public void indexUser(Map data) { + lowCodeRepository.save("user", data); + } + + public Map findById(String id) { + return lowCodeRepository.findById("user", id); + } + + public ResPage> searchPage(ReqPage reqPage) { + return lowCodeRepository.queryPage("user", reqPage); + } +} +``` + +### 低代码资源配置 + +```yaml +structure: + infra: + lowcode: + enabled: true + resources: + - name: user + table-name: user # 对应 ES 索引名 + storage-type: ELASTICSEARCH + fields: + - name: id + type: STRING + primary-key: true + - name: username + type: STRING + index: true + - name: email + type: STRING + - name: age + type: INTEGER + - name: created_at + type: DATETIME + auto-fill: CREATE_TIME + - name: updated_at + type: DATETIME + auto-fill: CREATE_UPDATE +``` + +## CQRS 读写分离 + +Elasticsearch 常作为读侧高速检索引擎,与 MyBatis Plus / MongoDB 组合实现 CQRS: + +```java +// 写操作走 MyBatis Plus,读操作走 Elasticsearch +@Repository(value = "用户仓储", type = RepositoryType.MYBATIS_PLUS, + entity = UserEntity.class, po = UserPO.class, + readType = RepositoryType.ELASTICSEARCH, + readPo = UserPO.class) +@Component +public class UserRepositoryImpl + extends RepositoryFacade + implements UserRepository { + // 写操作 → MyBatis Plus Delegate(BASE) + // 读操作 → Elasticsearch Delegate(READ) + // 读失败自动回退到 BASE 代理 +} +``` + +## 字段类型映射 + +| FieldType | Java 类型 | Elasticsearch 类型 | +|-----------|----------|-------------------| +| STRING | String | keyword / text | +| INTEGER | Integer | integer | +| LONG | Long | long | +| DECIMAL | BigDecimal | double | +| BOOLEAN | Boolean | boolean | +| DATE | LocalDate | date | +| DATETIME | LocalDateTime | date | +| TEXT | String | text | + +> **注**:低代码模式下未创建显式 mapping,使用 ES 动态映射;如需精确控制类型,请通过类型化仓储 + `@Document` / `@Field` 注解定义 PO。 + +## 注意事项 + +1. **索引创建**:低代码初始化时若索引不存在会自动创建,已存在则跳过;不创建显式 mapping,依赖 ES 动态映射 +2. **更新策略**:`doUpdate` 采用 "先删除后索引" 方式实现,并非部分更新(`UpdateQuery`),可能导致瞬时不可查 +3. **ID 类型**:ES 文档 ID 统一为 `String`,所有 ID 通过 `String.valueOf()` 转换 +4. **条件查询**:当前仅支持等值查询(`Criteria.is`),暂不支持范围、全文检索等复杂查询;如需复杂搜索请自定义 Delegate +5. **批量操作**:`saveBatch` / `removeBatchByIds` / `listByIds` 均为循环单条操作,未使用 `bulk` API,大批量场景需评估性能 +6. **分页排序**:默认使用 `Sort.unsorted()`,暂未支持通过 `ReqPage` 传递排序字段 +7. **深度分页**:当前使用 `PageRequest` from/size 分页,超过 10000 条需通过 `search_after` 或 `scroll` API,建议业务侧限制 +8. **全量查询**:`queryList(null)` 与 `queryPage` 使用 `Criteria.where("*").exists()` 或 `Criteria.where("_id").exists()` 匹配全部,性能取决于索引规模 +9. **刷新策略**:ES 默认 1 秒刷新,写后立即读可能查不到,如需立即读到可通过 `RefreshPolicy.IMMEDIATE` 配置 `ElasticsearchOperations` +10. **PO 复用**:与其他 starter 共享同一 PO 时,需注意 `@Document` 等 ES 注解在非 ES 环境下应可被忽略 + +## 许可证 + +本项目遵循 Apache License 2.0 diff --git a/structure-infra-jpa-starter/README.md b/structure-infra-jpa-starter/README.md new file mode 100644 index 0000000..09bfa7c --- /dev/null +++ b/structure-infra-jpa-starter/README.md @@ -0,0 +1,298 @@ +# structure-infra-jpa-starter + +[JPA (Jakarta Persistence API) / Hibernate](https://spring.io/projects/spring-data-jpa) 接入 `structure-pro-infra` 仓储抽象层的适配模块,让领域仓储(`RepositoryFacade`)透明地通过 JPA `EntityManager` 完成 CRUD。 + +## 模块定位 + +本模块在 `structure-infra-starter` 的 `RepositoryDelegate` SPI 之上提供 JPA 实现,作为领域层与持久化层之间的桥梁: + +- **领域层**:纯粹的领域实体(`UserEntity`)与仓储接口(`UserRepository`),无任何 JPA 注解 +- **持久化层**:JPA 注解的 PO(`UserPO`),通过 `EntityManager` 操作 + +## 功能特性 + +- **Spring Boot 自动配置**:通过 `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 注册 +- **条件激活**:仅当 classpath 存在 `JpaRepository` 且上下文有 `EntityManager` bean 时激活 +- **启用 Spring Data JPA**:自动应用 `@EnableJpaRepositories` 与 `@EnableTransactionManagement` +- **自动创建 delegate**:`JpaDelegateFactory` 按需为任意 PO 类实例化 `JpaRepositoryDelegate` +- **自动注入 EntityManager**:`JpaDelegateBeanPostProcessor` 发现用户自定义的 `JpaRepositoryDelegate` bean,注入 `EntityManager` 与从 `@DelegateFor.po()` 解析的实体类 +- **完整 CRUD + 分页 + 条件查询**:基于 JPA Criteria API 实现,通过反射读取条件对象的非空字段构建等值谓词 +- **CQRS 兼容**:可作为 BASE 代理或 READ 代理,与 `RepositoryFacade` 的读写分离机制无缝配合 + +## 依赖 + +```xml + + cn.structured + structure-infra-jpa-starter + 1.0.0-SNAPSHOT + +``` + +模块依赖极简: + +- `cn.structured:structure-infra-starter` — 核心框架(`RepositoryFacade` / `RepositoryDelegate` / `RepositoryDelegateFactory` / 注解 / `RepositoryBeanPostProcessor`) +- `org.springframework.boot:spring-boot-starter-data-jpa` — Spring Data JPA + Hibernate + Jakarta Persistence API + +## 自动配置 + +`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 注册: + +``` +cn.structure.infra.jpa.configuration.JpaAutoConfiguration +``` + +`JpaAutoConfiguration`: + +- `@ConditionalOnClass(name = "org.springframework.data.jpa.repository.JpaRepository")` +- `@EnableJpaRepositories` + `@EnableTransactionManagement` +- 注册 `jpaDelegateFactory(EntityManager)`:`@ConditionalOnBean(EntityManager.class)` +- 注册 `jpaDelegateBeanPostProcessor()`:`@ConditionalOnClass(name = "jakarta.persistence.EntityManager")` + +## 核心类 + +### `JpaAutoConfiguration` + +Spring Boot 自动配置入口,注册两个 Bean: + +| Bean | 类型 | 用途 | +|------|------|------| +| `jpaDelegateFactory` | `JpaDelegateFactory` | 按需创建 `JpaRepositoryDelegate` 实例 | +| `jpaDelegateBeanPostProcessor` | `JpaDelegateBeanPostProcessor` | 向用户自定义的 `JpaRepositoryDelegate` 注入 `EntityManager` 与实体类 | + +### `JpaDelegateFactory` + +实现 `RepositoryDelegateFactory`: + +- `getType()` 返回 `RepositoryType.JPA` +- `createDelegate(poClass, idClass)` 构造 `new JpaRepositoryDelegate(entityManager, poClass)`,构造失败返回 `null` + +### `JpaRepositoryDelegate` + +实现 `RepositoryDelegate`,基于 `EntityManager` 与 JPA Criteria API: + +- **构造**:无参构造(便于子类 `@DelegateFor` 标注)+ setter;或全参构造 `JpaRepositoryDelegate(EntityManager, Class)` +- **写操作**:`save` 使用 `entityManager.merge(entity)`;`removeById` / `removeBatchByIds` 先 find 再 remove +- **读操作**:`queryList(condition)` 使用 Criteria API,通过反射读取条件对象非空字段(含父类)构建等值谓词 +- **分页**:`queryPage` 在 `findAll()` 结果上做内存分页(适用于小数据集/测试场景) +- **count / exists**:基于 `queryList(condition).size()` 实现 + +### `JpaDelegateBeanPostProcessor` + +`BeanPostProcessor` + `ApplicationContextAware`,对每个 `instanceof JpaRepositoryDelegate` 的 bean: + +1. **解析 EntityManager**(按优先级): + - 查找名为 `"entityManager"` 的 bean 且 `instanceof EntityManager` + - 否则查找 `EntityManagerFactory` bean,调用 `createEntityManager()` 创建新实例 + - 都失败则记录 warning +2. 调用 `delegate.setEntityManager(entityManager)` +3. 读取 `@DelegateFor` 注解,若 `po()` 不为 `void.class`,调用 `delegate.setEntityClass(annotation.po())` + +支持两种使用模式: + +- **模式 A(自动创建)**:用户不定义任何 delegate bean,`RepositoryBeanPostProcessor` 调用 `JpaDelegateFactory.createDelegate(poClass, idClass)` 构造已注入依赖的 `JpaRepositoryDelegate` +- **模式 B(用户自定义)**:用户继承 `JpaRepositoryDelegate` 并标注 `@DelegateFor(po = UserPO.class)`,后置处理器自动注入 `EntityManager` 与 `entityClass` + +## 配置属性 + +本模块不定义专属配置属性。框架级配置: + +```yaml +structure: + infra: + type: JPA # 触发 JpaAutoConfiguration 条件装配 +``` + +标准 Spring Boot JPA / DataSource 配置: + +```yaml +spring: + datasource: + url: jdbc:h2:mem:jpa_testdb + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + use_sql_comments: true + +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + cn.structure.infra.repository: DEBUG + cn.structure.infra.jpa: DEBUG +``` + +## 使用示例 + +### 1. 定义领域实体(无 JPA 注解) + +```java +@Data +public class UserEntity { + private Long id; + private String username; + private String password; + private String email; + private Integer age; + private LocalDateTime createTime; + private LocalDateTime updateTime; +} +``` + +### 2. 定义领域仓储接口 + +```java +public interface UserRepository extends ICrudRepository { + UserEntity findByName(String name); +} +``` + +### 3. 定义 JPA 注解的 PO + +```java +@Data +@Entity +@Table(name = "t_user") +public class UserPO { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String username; + private String password; + private String email; + private Integer age; + private LocalDateTime createTime; + private LocalDateTime updateTime; +} +``` + +### 4. 定义 delegate 接口(可选,用于自定义查询) + +```java +public interface UserRepositoryDelegate extends RepositoryDelegate { + UserPO finByName(String name); +} +``` + +### 5. 定义抽象 Facade 基类 + +```java +public abstract class AbstractUserRepositoryImpl + extends RepositoryFacade + implements UserRepository { + + @Override + public UserEntity findByName(String name) { + UserPO po = this.baseDelegate.finByName(name); + return this.toEntity(po); + } +} +``` + +### 6. 声明具体 JPA 仓储(标注 `@Repository`) + +```java +@Repository(value = "用户仓储", type = RepositoryType.JPA, + entity = UserEntity.class, po = UserPO.class) +@Component("userRepository") +public class UserJpaRepositoryImpl extends AbstractUserRepositoryImpl { +} +``` + +仓储体为空 — 所有 CRUD 行为由继承的 `RepositoryFacade` 与自动创建的 `JpaRepositoryDelegate` 提供。用户只需通过 `@Repository` 注解声明类型元数据。 + +### 7. 业务层使用 + +```java +@Autowired +private UserRepository userRepository; + +UserEntity saved = userRepository.save(user); +UserEntity found = userRepository.findById(saved.getId()); +List list = userRepository.queryList(condition); +ResPage page = userRepository.queryPage(reqPage); +``` + +`RepositoryFacade` 透明地: + +1. 通过 `BeanUtils.copyProperties` 转换 `UserEntity` → `UserPO` +2. 调用 BASE delegate 的 JPA 操作(`entityManager.merge(po)` 等) +3. 将结果 `UserPO` 转换回 `UserEntity` + +### 8. 提供测试用 `EntityManager` Bean + +在测试环境中,通常需要显式暴露 `EntityManager`: + +```java +@Bean +public EntityManager entityManager(EntityManagerFactory entityManagerFactory) { + return SharedEntityManagerCreator.createSharedEntityManager(entityManagerFactory); +} +``` + +生产环境中,Spring Boot JPA 基础设施通常已注册名为 `entityManager` 的 bean,后置处理器可直接通过 bean 名查找。 + +## 端到端装配流程 + +1. Spring Boot 启动发现 `JpaAutoConfiguration`(通过 `AutoConfiguration.imports`) +2. `JpaAutoConfiguration` 激活(`JpaRepository` 在 classpath 上) +3. `EntityManager` bean 就绪后,`JpaDelegateFactory` 注册为 `RepositoryDelegateFactory` 类型为 `JPA` +4. `JpaDelegateBeanPostProcessor` 注册 +5. 用户的 `@Repository(type = JPA, po = UserPO.class)` 注解类实例化为 `RepositoryFacade` 子类 +6. `structure-infra-starter` 中的 `RepositoryBeanPostProcessor` 在 `ContextRefreshedEvent` 触发: + - 查找用户定义的 `@DelegateFor` BASE delegate;若未找到,调用 `JpaDelegateFactory.createDelegate(UserPO.class, Long.class)` 返回完整构造的 `JpaRepositoryDelegate(entityManager, UserPO.class)` + - 通过 `facade.setBaseDelegate(delegate)` 注入 +7. 若用户定义了 `JpaRepositoryDelegate` 子类并标注 `@DelegateFor(po = UserPO.class)`,`JpaDelegateBeanPostProcessor` 在初始化后处理: + - 解析并注入 `EntityManager` + - 从 `@DelegateFor.po()` 读取并注入 `entityClass` +8. 应用调用 `userRepository.save(entity)` → `RepositoryFacade` 转换为 PO → `JpaRepositoryDelegate.save(po)` → `entityManager.merge(po)` → 转换回 entity + +## 注意事项 + +- **`queryPage` 性能**:当前实现为先 `findAll()` 再内存分页,会加载全表数据。适用于小数据集与测试场景;生产大数据量场景建议自定义 delegate 使用 Criteria 的 `setFirstResult` / `setMaxResults` 与 count 查询 +- **PO 复用**:同一 PO 类可同时标注 JPA / MyBatis-Plus / MongoDB / Elasticsearch 注解,在不同存储示例间复用(polyglot persistence) +- **Jakarta 命名空间**:Spring Boot 4.x 使用 `jakarta.persistence.*`,而非 `javax.persistence.*` +- **事务**:`@EnableTransactionManagement` 已启用,建议在 service 层标注 `@Transactional` + +## 测试 + +参考示例模块 `structure-infra-sample-jpa`(使用 H2 内存数据库): + +```bash +mvn test -pl structure-infra-sample/structure-infra-sample-jpa +``` + +测试覆盖: + +| 测试方法 | 操作 | +|---------|------| +| `testSave` | `save` | +| `testFindById` | `findById` | +| `testQueryById` | `queryById` | +| `testQueryByIdOptional_Exists` / `_NotExists` | `queryByIdOptional` | +| `testQueryOne` | `queryOne` 条件查询 | +| `testQueryOneOptional` / `_NotExists` | `queryOneOptional` | +| `testQueryList_All` / `_ByCondition` / `_Empty` | `queryList` | +| `testQueryPage` | `queryPage` 分页 | +| `testRemoveById` | `removeById` | +| `testEntityPoConversion` | Entity ↔ PO 转换验证 | +| `testSaveBatch` | `saveBatch` | +| `testRemoveBatchByIds` | `removeBatchByIds` | +| `testListByIds` | `listByIds` | +| `testCount_All` / `_ByCondition` | `count` | +| `testExists_True` / `_False` | `exists` | + +## License + +Apache License 2.0 diff --git a/structure-infra-mongodb-starter/README.md b/structure-infra-mongodb-starter/README.md new file mode 100644 index 0000000..ac262a9 --- /dev/null +++ b/structure-infra-mongodb-starter/README.md @@ -0,0 +1,274 @@ +# Structure Infra MongoDB Starter + +基于 Spring Data MongoDB 的仓储适配模块,为 `structure-infra-starter` 提供 MongoDB 类型的 `RepositoryDelegate` 与低代码 `LowCodeStorage` 实现,使领域仓储能够透明地操作 MongoDB 文档数据库。 + +## 功能特性 + +- **自动配置**:检测到 `MongoTemplate` 时自动启用,注册委托工厂与 Bean 后处理器 +- **类型化仓储**:通过 `MongoRepositoryDelegate` 实现完整的 CRUD/分页/批量操作 +- **委托自动创建**:未提供自定义 Delegate 时,由 `MongoDelegateFactory` 根据PO类自动创建 +- **自定义 Delegate 自动注入**:通过 `MongoDelegateBeanPostProcessor` 自动注入 `MongoTemplate` 与实体类 +- **低代码仓储**:通过 `MongoLowCodeStorage` 使用 `Document` 动态操作集合,无需定义实体类 +- **自动建集合与索引**:低代码初始化时自动创建集合、主键索引、唯一索引、普通索引 +- **自动填充**:支持 `CREATE_TIME` / `UPDATE_TIME` / `CREATE_UPDATE` 自动填充 +- **条件查询**:根据非空字段动态构建 `Criteria` 等值查询 +- **CQRS 支持**:可作为 BASE 或 READ 代理参与读写分离 + +## 添加依赖 + +```xml + + cn.structured + structure-infra-mongodb-starter + 1.1.0-SNAPSHOT + +``` + +依赖中已包含 `spring-boot-starter-data-mongodb`,无需重复引入。 + +## 配置说明 + +### 基础配置 + +```yaml +structure: + infra: + type: MONGODB # 显式指定存储类型(matchIfMissing=true 时可不配) + lowcode: + enabled: true # 启用低代码(默认开启) + +spring: + data: + mongodb: + uri: mongodb://user:password@host:27017/database?authSource=admin + # 或拆分配置: + # host: localhost + # port: 27017 + # database: mydb + # username: user + # password: password +``` + +### 自动配置触发条件 + +| 条件 | 说明 | +|------|------| +| `@ConditionalOnClass(MongoTemplate)` | 类路径存在 Spring Data MongoDB | +| `@ConditionalOnProperty(structure.infra.type=MONGODB, matchIfMissing=true)` | 显式指定或默认启用 | +| `@ConditionalOnBean(MongoTemplate.class)` | Spring 上下文中存在 `MongoTemplate` Bean | +| `@ConditionalOnProperty(structure.infra.lowcode.enabled=true, matchIfMissing=true)` | 低代码默认启用 | + +注册的 AutoConfiguration(`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`): + +- `cn.structure.infra.mongodb.configuration.MongoAutoConfiguration` +- `cn.structure.infra.mongodb.lowcode.MongoLowCodeAutoConfiguration` + +## 核心组件 + +### 1. 类型化仓储 + +#### MongoRepositoryDelegate + +实现 `RepositoryDelegate` 接口,基于 `MongoTemplate` 完成文档操作。 + +| 方法 | 说明 | +|------|------| +| `save(T)` | 保存(调用 `mongoTemplate.save`) | +| `removeById(ID)` | 根据 ID 删除 | +| `findById(ID)` | 根据 ID 查询 | +| `queryById(ID)` / `queryByIdOptional(ID)` | 读代理查询,默认走 `findById` | +| `queryOne(T)` / `queryOneOptional(T)` | 根据非空字段构建 `Criteria` 等值查询单条 | +| `queryList(T)` | 条件为空时 `findAll`,否则条件查询列表 | +| `queryPage(ReqPage)` | 分页查询,`PageRequest.of(page-1, size, Sort.unsorted())` | +| `saveBatch(List)` | 逐条 `save`,返回保存后的列表 | +| `removeBatchByIds(List)` | 根据 ID 列表批量删除(`Criteria.in`) | +| `listByIds(List)` | 根据 ID 列表批量查询 | +| `count(T)` | 条件统计数量 | +| `exists(T)` | 条件判断是否存在 | + +**条件查询构建规则**:通过反射遍历对象所有字段(含父类),非空字段拼装为 `Criteria.where(name).is(value)` 等值条件。 + +**ID 字段**:默认 `id`,可通过构造器或 `setIdFieldName` 修改。 + +#### MongoDelegateFactory + +实现 `RepositoryDelegateFactory` SPI: + +- `getType()` 返回 `RepositoryType.MONGODB` +- `createDelegate(poClass, idClass)` 创建 `MongoRepositoryDelegate(mongoTemplate, poClass)` + +当 `RepositoryFacade` 未找到用户自定义的 MongoDB 类型 Delegate 时,由 `RepositoryBeanPostProcessor` 调用此工厂自动创建。 + +#### MongoDelegateBeanPostProcessor + +实现 `BeanPostProcessor`,在 Bean 初始化后处理自定义的 `MongoRepositoryDelegate` 实现: + +1. 检测 Bean 是否为 `MongoRepositoryDelegate` 实例 +2. 从 Spring 上下文获取 `MongoTemplate` 并注入 +3. 读取 `@DelegateFor(po = XxxPO.class)` 注解,设置 `entityClass` + +### 2. 低代码仓储 + +#### MongoLowCodeAutoConfiguration + +低代码自动配置类,注册 `MongoLowCodeRepoFactory` Bean。 + +#### MongoLowCodeRepoFactory + +实现 `LowCodeRepoFactory` SPI: + +- `getType()` 返回 `StorageType.MONGODB` +- `createStorage(schema, config)` 创建 `MongoLowCodeStorage(schema, mongoTemplate)` + +被 `LowCodeRepositoryRouter` 根据 `StorageType` 路由调用。 + +#### MongoLowCodeStorage + +实现 `LowCodeStorage` 接口,使用 `Document` 代替实体类操作 MongoDB 集合。 + +**初始化(initialize)**: +- 检查集合是否存在,不存在则 `createCollection` +- 遍历 `FieldSchema`:主键字段、`index=true`、`unique=true` 字段自动创建索引 +- `unique=true` 字段创建唯一索引 + +**CRUD 操作**: + +| 方法 | 实现说明 | +|------|---------| +| `save(Map)` | 有 ID 且存在 → `updateFirst`;否则 `insert`。自动填充 CREATE/CREATE_UPDATE 字段 | +| `findById(Object)` | `Criteria.where(idField).is(id)` 查询 | +| `queryOne(Map)` / `queryList(Map)` | 根据 schema 中已定义字段构建等值 `Criteria` 查询 | +| `queryPage(ReqPage)` | 先 `count` 总数,再分页 `find`,返回 `ResPage` | +| `removeById(Object)` | 根据 ID 删除单条 | +| `saveBatch(List)` | 逐条调用 `save` | +| `removeBatchByIds(List)` | `Criteria.where(idField).in(ids)` 批量删除 | +| `listByIds(List)` | 根据 ID 列表批量查询 | +| `count(Map)` / `exists(Map)` | 条件统计 | + +**自动填充**:根据 `FieldSchema.autoFill` 类型,在 `save` 时填充: +- `CREATE_TIME` / `CREATE_UPDATE`:插入时填充 `LocalDateTime` 或 `LocalDate` +- `UPDATE_TIME`:当前实现仅在 CREATE/CREATE_UPDATE 时填充,更新时由 `doUpdate` 写入字段值 + +**Document ↔ Map 转换**:所有返回值统一转换为 `Map`,对调用方屏蔽 BSON 类型。 + +## 使用示例 + +### 方式一:类型化仓储(推荐用于领域模型) + +```java +// 1. PO 类(无需 @Document,由 RepositoryFacade 通过 entityClass 操作) +public class UserPO { + private String id; + private String username; + private String email; + private Integer age; + // getter/setter +} + +// 2. 仓储接口 +public interface UserRepository extends Repository {} + +// 3. 仓储实现,继承 RepositoryFacade +@Repository(value = "用户仓储", type = RepositoryType.MONGODB, + entity = UserEntity.class, po = UserPO.class) +@Component +public class UserRepositoryImpl + extends RepositoryFacade + implements UserRepository { + + // 未提供自定义 Delegate 时,框架会通过 MongoDelegateFactory 自动创建 +} +``` + +### 方式二:自定义 Delegate + +```java +@DelegateFor(po = UserPO.class) +@Component +public class UserMongoRepositoryDelegate extends MongoRepositoryDelegate + implements UserRepositoryDelegate { + + // 可覆写 queryOne/queryList 等方法实现自定义查询逻辑 + // MongoDelegateBeanPostProcessor 会自动注入 MongoTemplate 和 entityClass +} +``` + +### 方式三:低代码仓储(无需定义 PO) + +```java +// 通过 LowCodeRepository 接口操作 +@Service +public class DynamicDataService { + private final LowCodeRepository lowCodeRepository; + + public void saveUser(Map data) { + // schemaName 对应 LowCodeProperties.resources 中定义的资源名 + lowCodeRepository.save("user", data); + } + + public Map findById(String id) { + return lowCodeRepository.findById("user", id); + } + + public ResPage> queryPage(ReqPage reqPage) { + return lowCodeRepository.queryPage("user", reqPage); + } +} +``` + +### 低代码资源配置 + +```yaml +structure: + infra: + lowcode: + enabled: true + resources: + - name: user + table-name: t_user + storage-type: MONGODB + fields: + - name: id + type: STRING + primary-key: true + - name: username + type: STRING + index: true + - name: email + type: STRING + unique: true + - name: created_at + type: DATETIME + auto-fill: CREATE_TIME + - name: updated_at + type: DATETIME + auto-fill: CREATE_UPDATE +``` + +## 字段类型映射 + +| FieldType | Java 类型 | MongoDB 存储 | +|-----------|----------|--------------| +| STRING | String | string | +| INTEGER | Integer | int32 | +| LONG | Long | int64 | +| DECIMAL | BigDecimal | decimal | +| BOOLEAN | Boolean | bool | +| DATE | LocalDate | date | +| DATETIME | LocalDateTime | date | +| TEXT | String | string | + +## 注意事项 + +1. **集合创建**:低代码初始化时若集合不存在会自动创建,已存在则跳过 +2. **索引创建**:每次初始化都会 `ensureIndex`,MongoDB 对已存在的索引会忽略 +3. **条件查询**:当前仅支持等值查询(`Criteria.is`),暂不支持范围、模糊等复杂条件 +4. **分页排序**:默认使用 `Sort.unsorted()`,暂未支持通过 `ReqPage` 传递排序字段 +5. **批量保存**:`saveBatch` 通过循环单条 `save` 实现,未使用 `bulkOps`,大批量场景需评估性能 +6. **事务**:MongoDB 4.0+ 支持多文档事务,需在 `MongoTemplate` 配置 `MongoTransactionManager` +7. **ID 字段**:默认 `id`,若 PO 使用其他主键字段名需通过构造器或 setter 指定 +8. **PO 复用**:与 MyBatis Plus / JPA / Elasticsearch 共享同一 PO 时,需注意多存储注解兼容性 + +## 许可证 + +本项目遵循 Apache License 2.0 diff --git a/structure-infra-mybatis-plus-starter/README.md b/structure-infra-mybatis-plus-starter/README.md new file mode 100644 index 0000000..d46cc09 --- /dev/null +++ b/structure-infra-mybatis-plus-starter/README.md @@ -0,0 +1,382 @@ +# structure-infra-mybatis-plus-starter + +[MyBatis-Plus](https://baomidou.com/) 接入 `structure-pro-infra` 仓储抽象层的适配模块,提供: + +1. **类型化仓储**:通过 `RepositoryFacade` + `MybatisPlusRepositoryDelegate` 透明地以 MyBatis-Plus `BaseMapper` 操作 PO +2. **低代码 MySQL/H2 存储**:通过 `LowCodeStorage` 实现基于 MyBatis `SqlSession` 的动态表/SQL,无需定义实体类 + +## 功能特性 + +### 类型化仓储层 + +- **自动 delegate 创建**:当 `RepositoryFacade` 需要某个 PO 的 delegate 但无用户自定义 bean 时,`MybatisPlusDelegateFactory` 自动发现匹配的 `BaseMapper` 并实例化 `MybatisPlusRepositoryDelegate` +- **自定义 delegate 支持**:用户可继承 `MybatisPlusRepositoryDelegate` 并标注 `@DelegateFor`,`MybatisPlusDelegateBeanPostProcessor` 会自动注入 `BaseMapper` 与 PO 类型 +- **约定优于配置的 Mapper 发现**:从 PO 类名推导 Mapper 类(`xxx.po.UserPO` → `xxx.mapper.UserMapper`,`PO` 后缀替换为 `Mapper`);失败时回退到 bean 名称后缀匹配 +- **CQRS 支持**:可与 `ElasticsearchRepositoryDelegate` 等组合实现读写分离 + +### 低代码 MySQL/H2 存储层 + +- **完整 `LowCodeStorage` 实现**:基于 MyBatis `SqlSession` 执行动态 SQL +- **多方言支持**:自动检测 MySQL、H2、Oracle、PostgreSQL、SQL Server,适配 DDL、类型映射、分页语法 +- **自动 DDL**:注册资源时生成 `CREATE TABLE IF NOT EXISTS`,包含列类型、`NOT NULL`、`DEFAULT`、`AUTO_INCREMENT`、主键、唯一键、索引 +- **拦截器友好**:通过 `MappedStatement` + `XMLLanguageDriver` 动态注册 SQL,所有 CRUD 走 MyBatis 拦截器链(分页、数据权限、SQL 日志等) +- **自动填充**:支持 `CREATE` / `UPDATE` / `CREATE_UPDATE` 类型的 `DATETIME` / `DATE` 字段自动填充 +- **多方言分页**:MySQL/H2 `LIMIT ... OFFSET`、Oracle `ROWNUM` 子查询、PostgreSQL/SQL Server `OFFSET ... FETCH NEXT` + +## 依赖 + +```xml + + cn.structured + structure-infra-mybatis-plus-starter + 1.0.0-SNAPSHOT + +``` + +模块自身依赖: + +- `cn.structured:structure-infra-starter` — 仓储抽象与低代码 API +- `cn.structured:structure-mybatis-plus-starter` — MyBatis-Plus 基础 boot 支持 +- `cn.structured:structure-common` — `ReqPage` / `ResPage` / `ICrudRepository` +- `cn.structured:structure-security-core` — 安全集成(数据权限) +- `cn.structured:structure-tenant-starter` — 多租户支持 +- `com.baomidou:mybatis-plus-spring-boot4-starter` +- `com.baomidou:mybatis-plus-jsqlparser` + +## 自动配置 + +`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 注册: + +``` +cn.structure.infra.mybatis.plus.configuration.MybatisPlusAutoConfiguration +cn.structure.infra.mybatis.plus.lowcode.configuration.MybatisPlusLowCodeAutoConfiguration +``` + +| 自动配置 | 激活条件 | 注册的 Bean | +|---------|---------|------------| +| `MybatisPlusAutoConfiguration` | classpath 存在 `BaseMapper`,且 `structure.infra.type=MYBATIS_PLUS`(默认开启 `matchIfMissing=true`) | `MybatisPlusDelegateFactory`、`MybatisPlusDelegateBeanPostProcessor` | +| `MybatisPlusLowCodeAutoConfiguration` | `structure.infra.lowcode.enabled=true`(默认开启) | `MySqlLowCodeRepoFactory`(注入 `SqlSessionFactory`) | + +## 核心类 + +### 类型化仓储层 + +#### `MybatisPlusRepositoryDelegate` + +实现 `RepositoryDelegate`,包装 `BaseMapper` 提供: + +- `save` / `removeById` / `saveBatch` / `removeBatchByIds` — 写操作 +- `findById` / `queryById` / `queryByIdOptional` / `queryOne` / `queryOneOptional` / `queryList` / `queryPage` / `listByIds` / `count` / `exists` — 读操作 +- 通过反射读取条件对象的非空字段构建 `QueryWrapper`(驼峰转下划线) +- 支持无参构造 + setter,便于子类被 `@DelegateFor` 标注后由后置处理器注入 + +#### `MybatisPlusDelegateFactory` + +实现 `RepositoryDelegateFactory`,`getType()` 返回 `RepositoryType.MYBATIS_PLUS`。`createDelegate(poClass, idClass)`: + +1. 按约定推导 Mapper 类(`po` 包段替换为 `mapper`,`PO` 后缀替换为 `Mapper`) +2. 从 `ApplicationContext` 查找该 Mapper bean +3. 找到则返回 `new MybatisPlusRepositoryDelegate<>(mapper, poClass)`,否则返回 `null` + +#### `MybatisPlusDelegateBeanPostProcessor` + +`BeanPostProcessor`,对每个 `instanceof MybatisPlusRepositoryDelegate` 且带 `@DelegateFor` 注解的 bean: + +- 根据 `@DelegateFor.po()` 解析 Mapper bean +- 调用 `setBaseMapper(mapper)` 与 `setEntityClass(poClass)` 完成注入 + +### 低代码存储层 + +#### `MySqlLowCodeRepoFactory` + +实现 `LowCodeRepoFactory`,`getType()` 返回 `StorageType.MYSQL`,`createStorage(schema, config)` 返回 `new MySqlLowCodeStorage(schema, sqlSessionFactory)`。 + +#### `MySqlLowCodeStorage` + +实现 `LowCodeStorage`,关键行为: + +- **方言检测**:`detectDialect()` 通过 `Connection.getMetaData().getDatabaseProductName()` 识别 MySQL / H2 / Oracle / PostgreSQL / SQL Server +- **自动 DDL**:`initialize()` 调用 `buildCreateTableSql()` 生成 `CREATE TABLE IF NOT EXISTS`,MySQL 附加 `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4` +- **字段类型映射**:`STRING → VARCHAR(length)`、`LONG → BIGINT`、`INTEGER → INT`、`BOOLEAN → TINYINT(1)` (MySQL) / `BOOLEAN` (其他)、`DECIMAL → DECIMAL(p,s)`、`DATETIME → DATETIME` (MySQL) / `TIMESTAMP` (其他)、`DATE → DATE`、`TEXT → TEXT`、`JSON → JSON` (MySQL) / `TEXT` (其他) +- **拦截器友好执行**:`executeSelect` / `executeUpdate` / `registerCountStatement` 动态构建 `MappedStatement`,使用 `XMLLanguageDriver` 解析 `` SQL,注册到 `Configuration` 执行后从 `finally` 块移除 +- **自增主键**:当 id 字段为 `autoIncrement` 时,使用原生 JDBC `PreparedStatement(..., RETURN_GENERATED_KEYS)` 获取生成的主键 +- **显式列列表**:`buildSelectColumns()` 拼接 schema 字段名,避免 `SELECT *` +- **行归一化**:H2/Oracle 返回大写列名,`normalizeRow` 将所有 key 转小写 +- **自动填充**:`fillAutoFields(data, fillType)` 处理 `CREATE` / `UPDATE` / `CREATE_UPDATE`,使用 `putIfAbsent` 保证调用方值优先 +- **多方言分页**:`buildPaginationSql(baseSql, pageNum, pageSize)` + - MySQL / H2:`... LIMIT pageSize OFFSET offset` + - Oracle:嵌套 `ROWNUM` 子查询 + - PostgreSQL / SQL Server:`... OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY` + +## 配置属性 + +本模块自身不定义专属配置属性,通过标准 Spring Boot 配置驱动: + +```yaml +structure: + infra: + type: MYBATIS_PLUS # 默认开启 MybatisPlusAutoConfiguration + lowcode: + enabled: true # 默认开启 MybatisPlusLowCodeAutoConfiguration + +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto +``` + +低代码资源定义示例: + +```yaml +structure: + infra: + lowcode: + enabled: true + resources: + lc_user: + schema: + table-name: t_lowcode_user + fields: + id: + type: long + primary-key: true + auto-increment: true + username: + type: string + length: 64 + nullable: false + index: true + email: + type: string + length: 128 + index: true + status: + type: string + length: 16 + default-value: active + created_at: + type: datetime + auto-fill: create + updated_at: + type: datetime + auto-fill: create_update + repository: + type: mysql +``` + +## 使用示例 + +### 类型化仓储 + +#### 1. 定义 PO 与 Mapper + +```java +@Data +@TableName("t_user") +public class UserPO { + @TableId(type = IdType.AUTO) + private Long id; + private String username; + private String email; + private Integer age; + private LocalDateTime createTime; + private LocalDateTime updateTime; +} + +@Mapper +public interface UserMapper extends BaseMapper { } +``` + +#### 2. 定义领域实体与仓储接口 + +```java +@Data +public class UserEntity { + private Long id; + private String username; + private String email; + private Integer age; + private LocalDateTime createTime; + private LocalDateTime updateTime; +} + +public interface UserRepository extends ICrudRepository { + UserEntity findByName(String name); +} +``` + +#### 3. 定义 delegate 接口(可选,用于自定义查询) + +```java +public interface UserRepositoryDelegate extends RepositoryDelegate { + UserPO finByName(String name); +} +``` + +#### 4. 定义抽象 Facade 基类 + +```java +public abstract class AbstractUserRepositoryImpl + extends RepositoryFacade + implements UserRepository { + + @Override + public UserEntity findByName(String name) { + return toEntity(this.baseDelegate.finByName(name)); + } +} +``` + +#### 5. 定义具体仓储(标注 `@Repository`) + +```java +@Repository(value = "用户仓储", type = RepositoryType.MYBATIS_PLUS, + entity = UserEntity.class, po = UserPO.class) +@Component("userRepository") +public class UserRepositoryImpl extends AbstractUserRepositoryImpl { +} +``` + +#### 6. 提供自定义 delegate(可选,用于自定义查询) + +```java +@Component +@DelegateFor( + name = "userRepository", + type = RepositoryType.MYBATIS_PLUS, + po = UserPO.class, + description = "用户仓储 MyBatis Plus 实现", + priority = 10 +) +@AllArgsConstructor +public class UserMybatisPlusDelegate + extends MybatisPlusRepositoryDelegate + implements UserRepositoryDelegate { + + private final UserMapper userMapper; + + @Override + public UserPO finByName(String name) { + return userMapper.selectOne( + Wrappers.lambdaQuery().eq(UserPO::getUsername, name)); + } +} +``` + +`MybatisPlusDelegateBeanPostProcessor` 会基于 `@DelegateFor.po()` 自动调用 `setBaseMapper(userMapper)` 与 `setEntityClass(UserPO.class)`,因此继承的 CRUD 方法开箱即用。 + +> 若未提供自定义 delegate,`MybatisPlusDelegateFactory.createDelegate(UserPO.class, Long.class)` 会按约定发现 `UserMapper` 并自动创建 `MybatisPlusRepositoryDelegate`。 + +#### 7. 业务层使用 + +```java +@Autowired +private UserRepository userRepository; + +UserEntity saved = userRepository.save(userEntity); +UserEntity found = userRepository.findById(saved.getId()); +UserEntity one = userRepository.queryOne(conditionEntity); +Optional opt = userRepository.queryByIdOptional(id); +List all = userRepository.queryList(null); + +ReqPage reqPage = new ReqPage(); +reqPage.setPage(2); +reqPage.setSize(5); +ResPage page = userRepository.queryPage(reqPage); + +userRepository.removeById(id); +``` + +### 低代码仓储 + +#### YAML 声明资源 + +```yaml +structure: + infra: + lowcode: + enabled: true + resources: + lc_user: + schema: + table-name: t_lowcode_user + fields: + id: { type: long, primary-key: true, auto-increment: true } + username: { type: string, length: 64, nullable: false, index: true } + email: { type: string, length: 128, index: true } + age: { type: int } + status: { type: string, length: 16, default-value: active } + created_at: { type: datetime, auto-fill: create } + updated_at: { type: datetime, auto-fill: create_update } + repository: + type: mysql +``` + +#### 代码使用 + +```java +@Autowired +private LowCodeRepository lowCodeRepository; + +private static final String RESOURCE_NAME = "lc_user"; + +// 保存(自动填充 created_at、updated_at、status 默认值) +Map user = new HashMap<>(); +user.put("username", "zhangsan"); +user.put("email", "zhangsan@example.com"); +user.put("age", 25); +Map saved = lowCodeRepository.save(RESOURCE_NAME, user); + +// 更新(id 存在则 upsert) +saved.put("email", "updated@test.com"); +lowCodeRepository.save(RESOURCE_NAME, saved); + +// 查询 +Map found = lowCodeRepository.findById(RESOURCE_NAME, id); +Map one = lowCodeRepository.queryOne(RESOURCE_NAME, conditionMap); +Optional> opt = lowCodeRepository.queryByIdOptional(RESOURCE_NAME, id); +List> list = lowCodeRepository.queryList(RESOURCE_NAME, conditionMap); + +// 分页 +ReqPage reqPage = new ReqPage(); +reqPage.setPage(2); +reqPage.setSize(5); +ResPage> page = lowCodeRepository.queryPage(RESOURCE_NAME, reqPage); + +// 批量与计数 +lowCodeRepository.saveBatch(RESOURCE_NAME, listOfMaps); +lowCodeRepository.removeBatchByIds(RESOURCE_NAME, listOfIds); +lowCodeRepository.listByIds(RESOURCE_NAME, listOfIds); +long total = lowCodeRepository.count(RESOURCE_NAME, conditionMap); +boolean exists = lowCodeRepository.exists(RESOURCE_NAME, conditionMap); +``` + +## 低代码装配流程 + +1. `MybatisPlusLowCodeAutoConfiguration` 注册 `MySqlLowCodeRepoFactory(sqlSessionFactory)` bean +2. `LowCodeAutoConfiguration`(在 `structure-infra-starter`)收集所有 `LowCodeRepoFactory` bean,按 `StorageType` 索引 +3. 对每个 YAML 声明的资源,`ResourceSchemaBuilder` 构建 `ResourceSchema` 与 `RepositoryConfig`,调用 `router.registerResource(...)` +4. 路由器请求 `MySqlLowCodeRepoFactory.createStorage(schema, config)` 创建 `MySqlLowCodeStorage` +5. 调用 `initialize()` 执行 `CREATE TABLE IF NOT EXISTS` DDL +6. 后续 `LowCodeRepository` 调用被路由到该 `MySqlLowCodeStorage` 实例 + +## 测试 + +参考示例模块 `structure-infra-sample-mybatis`(使用 H2 内存数据库): + +```bash +# 类型化仓储测试 +mvn test -pl structure-infra-sample/structure-infra-sample-mybatis \ + -Dtest="cn.structure.infra.sample.repository.UserRepositoryTest" + +# 低代码测试 +mvn test -pl structure-infra-sample/structure-infra-sample-mybatis \ + -Dtest="cn.structure.infra.sample.repository.LowCodeRepositoryTest" +``` + +## License + +Apache License 2.0 diff --git a/structure-infra-schedule-starter/README.md b/structure-infra-schedule-starter/README.md new file mode 100644 index 0000000..f8232ee --- /dev/null +++ b/structure-infra-schedule-starter/README.md @@ -0,0 +1,300 @@ +# structure-infra-schedule-starter + +轻量级本地任务调度框架,基于 Java `ScheduledExecutorService` 提供注册表驱动的动态调度 API。无需外部依赖,适合单机应用或需要程序化调度的场景。 + +## 功能特性 + +- **动态调度**:运行时通过 `TaskScheduler` API 完成 `schedule / update / remove / pause / resume` 等操作,无需重启应用 +- **三种调度类型**:`CRON`、`FIXED_DELAY`、`FIXED_RATE` +- **处理器注册表**:通过 `TaskHandlerRegistry` 按名称查找处理器,解耦任务定义与执行逻辑 +- **程序化构建器**:`ScheduleTask.builder()` API,无需注解 +- **Spring TaskScheduler 适配**:通过 `SpringTaskSchedulerAdapter` 暴露为 Spring 的 `org.springframework.scheduling.TaskScheduler`,让 `@Scheduled` 等基础设施复用同一调度引擎 +- **守护线程池**:通过 `structure.schedule.pool-size` 配置线程池大小(默认 CPU 核心数) +- **幂等调度**:`schedule(taskId)` 会先取消同 ID 的旧任务 +- **错误隔离**:处理器抛出的异常会被捕获并记录日志,不会终止周期性调度 +- **自动配置**:基于 Spring Boot 3+ AutoConfiguration SPI + +## 依赖 + +```xml + + cn.structured + structure-infra-schedule-starter + 1.0.0-SNAPSHOT + +``` + +模块自身依赖: + +- `cn.structured:structure-common` +- `org.springframework.boot:spring-boot-starter` +- `org.springframework.boot:spring-boot-autoconfigure` +- `org.springframework.boot:spring-boot-configuration-processor`(optional) + +## 自动配置 + +`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 注册: + +``` +cn.structure.infra.configuration.AutoScheduleConfiguration +``` + +注册的 Bean: + +| Bean | 类型 | 条件 | +|------|------|------| +| `taskHandlerRegistry` | `DefaultTaskHandlerRegistry` | `@ConditionalOnMissingBean(TaskHandlerRegistry.class)` | +| `taskScheduler` | `LocalThreadTaskScheduler` | `@ConditionalOnMissingBean(TaskScheduler.class)`,线程池大小来自 `ScheduleProperties` | +| `springTaskScheduler` | `SpringTaskSchedulerAdapter`(实现 Spring `TaskScheduler`) | `@ConditionalOnBean(LocalThreadTaskScheduler.class)` | + +## 配置属性 + +`ScheduleProperties`(前缀 `structure.schedule`): + +| 属性 | 类型 | 默认值 | 说明 | +|-----|------|-------|------| +| `poolSize` | `Integer` | `Runtime.getRuntime().availableProcessors()` | 调度线程池大小 | + +```yaml +structure: + schedule: + pool-size: 4 +``` + +## 核心类 + +### `ScheduleTask`(任务定义 POJO) + +Lombok `@Data @Builder`,**不是注解**。字段: + +| 字段 | 类型 | 说明 | +|-----|------|------| +| `taskId` | `String` | 任务唯一标识 | +| `taskName` | `String` | 人类可读名称 | +| `handlerName` | `String` | 必须与注册表中的名称匹配 | +| `handlerParam` | `String` | 传递给处理器的参数 | +| `scheduleType` | `ScheduleType` | `CRON` / `FIXED_DELAY` / `FIXED_RATE` | +| `cronExpression` | `String` | `CRON` 类型必填 | +| `initialDelay` | `Long` | 首次执行延迟(默认 0) | +| `delay` | `Long` | `FIXED_DELAY` 使用 | +| `period` | `Long` | `FIXED_RATE` 使用 | +| `timeUnit` | `TimeUnit` | 默认 `MILLISECONDS` | +| `status` | `TaskStatus` | `PENDING` / `RUNNING` / `PAUSED` / `STOPPED` | + +### `TaskHandler`(函数式接口) + +```java +@FunctionalInterface +public interface TaskHandler { + void execute(String param); +} +``` + +### `TaskHandlerRegistry` + +```java +void register(String handlerName, TaskHandler handler); +TaskHandler get(String handlerName); +void unregister(String handlerName); +boolean contains(String handlerName); +``` + +默认实现 `DefaultTaskHandlerRegistry` 基于 `ConcurrentHashMap`。 + +### `TaskScheduler` + +```java +void schedule(ScheduleTask task); +void update(ScheduleTask task); +void remove(String taskId); +void pause(String taskId); +void resume(String taskId); +ScheduleTask getTaskInfo(String taskId); +List getAllTasks(); +``` + +### `LocalThreadTaskScheduler` + +默认 `TaskScheduler` 实现: + +- 通过 `Executors.newScheduledThreadPool(poolSize, threadFactory)` 创建调度器,线程为守护线程,命名为 `structure-schedule-` +- `schedule(task)` 校验 → 先 `remove(taskId)` 取消旧任务 → 包装 Runnable 通过注册表查找 handler → 按 `scheduleType` 派发 +- `FIXED_DELAY` / `FIXED_RATE` 默认间隔 1000ms +- `CRON` 当前为简化实现,按 1000ms 间隔执行(cron 表达式仅存储不解析) +- `update(task)` 等同于 `schedule(task)`(先取消再调度) +- `pause(taskId)` 取消 future 但保留任务信息 +- `resume(taskId)` 仅当 `status == PAUSED` 时重新调度 +- 处理器异常被捕获并记录日志,不影响后续周期 + +### `SpringTaskSchedulerAdapter` + +实现 Spring 的 `org.springframework.scheduling.TaskScheduler`,将 Spring 调度基础设施(如 `@Scheduled`)路由到 `LocalThreadTaskScheduler`。支持 `schedule(Runnable, Trigger)` / `schedule(Runnable, Instant)` / `scheduleAtFixedRate` / `scheduleWithFixedDelay` 等全部方法。 + +## 使用示例 + +### 1. 注册处理器 + +```java +@Slf4j +@Component +public class DemoTaskHandlers { + + @Autowired + private TaskHandlerRegistry handlerRegistry; + + @PostConstruct + public void registerHandlers() { + handlerRegistry.register("demo-fixed-rate-handler", this::fixedRateHandler); + handlerRegistry.register("demo-param-handler", this::paramHandler); + handlerRegistry.register("demo-error-handler", this::errorHandler); + } + + public void fixedRateHandler(String param) { + log.info("固定速率任务执行,参数:{}", param); + } + + public void paramHandler(String param) { + log.info("带参数任务执行,参数:{}", param); + } + + public void errorHandler(String param) { + log.info("抛出异常的任务"); + throw new RuntimeException("模拟任务执行异常"); + } +} +``` + +### 2. 调度任务 + +```java +@Slf4j +@Configuration +@DependsOn("demoTaskHandlers") +public class ScheduleDemoConfig { + + @Autowired + private TaskScheduler taskScheduler; + + @PostConstruct + public void initScheduledTasks() { + // 固定速率:每 3 秒执行一次,1 秒后启动 + ScheduleTask fixedRateTask = ScheduleTask.builder() + .taskId("demo-fixed-rate-task") + .taskName("固定速率示例任务") + .handlerName("demo-fixed-rate-handler") + .handlerParam("fixed-rate-param") + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(3000L) + .initialDelay(1000L) + .timeUnit(TimeUnit.MILLISECONDS) + .build(); + taskScheduler.schedule(fixedRateTask); + + // 固定延迟:上一次结束 2 秒后执行下一次 + ScheduleTask fixedDelayTask = ScheduleTask.builder() + .taskId("demo-fixed-delay-task") + .taskName("固定延迟示例任务") + .handlerName("demo-fixed-delay-handler") + .handlerParam("fixed-delay-param") + .scheduleType(ScheduleTask.ScheduleType.FIXED_DELAY) + .delay(2000L) + .initialDelay(2000L) + .timeUnit(TimeUnit.MILLISECONDS) + .build(); + taskScheduler.schedule(fixedDelayTask); + } +} +``` + +### 3. 运行时通过 REST API 管理任务 + +```java +@RestController +@RequestMapping("/job") +@RequiredArgsConstructor +public class JobManagerController { + + private final TaskScheduler taskScheduler; + + @PostMapping("/add") + public ScheduleTask add(@RequestParam("taskId") String taskId, + @RequestParam("taskName") String taskName, + @RequestParam("handlerName") String handlerName, + @RequestParam(value = "handlerParam", required = false) String handlerParam, + @RequestParam(value = "cronExpression", + defaultValue = "0/5 * * * * ?") String cronExpression) { + ScheduleTask task = ScheduleTask.builder() + .taskId(taskId) + .taskName(taskName) + .handlerName(handlerName) + .handlerParam(handlerParam) + .scheduleType(ScheduleTask.ScheduleType.CRON) + .cronExpression(cronExpression) + .build(); + taskScheduler.schedule(task); + return taskScheduler.getTaskInfo(taskId); + } + + @PutMapping("/pause/{taskId}") + public void pause(@PathVariable String taskId) { + taskScheduler.pause(taskId); + } + + @PutMapping("/resume/{taskId}") + public void resume(@PathVariable String taskId) { + taskScheduler.resume(taskId); + } + + @DeleteMapping("/remove/{taskId}") + public void remove(@PathVariable String taskId) { + taskScheduler.remove(taskId); + } + + @GetMapping("/list") + public List list() { + return taskScheduler.getAllTasks(); + } +} +``` + +## 校验规则 + +`schedule(task)` 会校验以下条件,违反时抛出 `IllegalArgumentException`: + +- `task` 不能为 null +- `taskId` 不能为 null +- `handlerName` 不能为 null 或空字符串 +- `handlerName` 必须在 `TaskHandlerRegistry` 中已注册 +- `scheduleType` 不能为 null +- `CRON` 类型的 `cronExpression` 不能为 null 或空 + +## 注意事项 + +- **CRON 表达式**:当前版本未接入 cron 解析器,CRON 类型任务固定按 1 秒间隔执行,`cronExpression` 仅存储不解析。如需严格 cron 调度,请使用 `structure-infra-xxljob-starter` 或自行集成 Spring `CronTrigger` +- **状态持久化**:任务状态存储在内存中(`ConcurrentHashMap`),JVM 重启后丢失 +- **集群支持**:本模块为单机调度器,不支持分布式协调。如需分布式调度,请使用 `structure-infra-xxljob-starter` +- **守护线程**:调度线程为守护线程,不会阻止 JVM 退出 + +## 测试 + +模块自带单元测试: + +```bash +mvn test -pl structure-infra-schedule-starter +``` + +测试覆盖: + +- `DefaultTaskHandlerRegistryTest` — register / get / contains / unregister / 空值拒绝 / 覆盖行为 +- `LocalThreadTaskSchedulerTest` — schedule / pause / resume / remove / update / getAllTasks 及全部校验路径 +- `SpringTaskSchedulerAdapterTest` — 所有 Spring `TaskScheduler` 方法 + +集成测试参考示例模块 `structure-infra-sample-schedule`: + +```bash +mvn test -pl structure-infra-sample/structure-infra-sample-schedule +``` + +## License + +Apache License 2.0 diff --git a/structure-infra-starter/README.md b/structure-infra-starter/README.md new file mode 100644 index 0000000..dd2f2fe --- /dev/null +++ b/structure-infra-starter/README.md @@ -0,0 +1,531 @@ +# structure-infra-starter + +`structure-pro-infra` 框架的核心模块,提供 DDD 风格的仓储抽象层(ACL/防腐层)、可插拔的委托式 CQRS 机制、事件发布抽象、轻量级任务调度集成,以及动态低代码资源仓储子系统。 + +## 模块定位 + +本模块是其他所有 `structure-infra-*-starter` 的依赖基础,定义了以下抽象: + +- **仓储 Facade/Delegate 抽象**:领域层通过 `RepositoryFacade` 操作领域实体,底层由 `RepositoryDelegate` 与具体持久化技术交互 +- **自动装配机制**:通过 `RepositoryBeanPostProcessor` 在启动时扫描 `@Repository` / `@DelegateFor` 注解,自动匹配并注入委托 +- **CQRS 读写分离**:支持为每个仓储配置 BASE 写代理和 READ 读代理,读操作失败自动回退到写代理 +- **低代码仓储子系统**:通过 YAML 定义资源 schema,运行时路由到不同存储引擎,无需编写实体类 +- **事件管理**:统一 `EventManager` 抽象,支持 Spring 事件和消息中间件两种通道 +- **任务调度集成**:当 `structure-infra-schedule-starter` 未引入时,提供基础调度兜底实现 + +## 依赖 + +- `cn.structured:structure-common` — 提供 `ICrudRepository` / `IQueryRepository` / `ReqPage` / `ResPage` +- `cn.structured:structure-datascope-starter` — 数据权限基础 +- `cn.structured:structure-datascope-message` — 提供 `DataScopeStreamBridge`(事件系统使用) +- `cn.structured:structure-datascope-cache` — 提供 `DataScopeCacheManager` +- `org.springframework.boot:spring-boot-data-commons` +- `cn.structured:structure-infra-schedule-starter` — 调度 SPI 与默认实现 + +## 自动配置 + +`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 注册: + +``` +cn.structure.infra.configuration.AutoEventConfiguration +cn.structure.infra.configuration.AutoRepositoryConfiguration +cn.structure.infra.lowcode.configuration.LowCodeAutoConfiguration +``` + +> 注:`AutoScheduleConfiguration` 在本模块中作为兜底配置(当 `structure-infra-schedule-starter` 已注册自有 Bean 时不重复注册)。 + +## 包结构 + +``` +cn.structure.infra +├── annotations/ @Repository / @DelegateFor +├── configuration/ AutoEventConfiguration / AutoRepositoryConfiguration / AutoScheduleConfiguration +├── event/ Event / EventManager / EventChannel / DefaultEventManagerImpl +├── lowcode/ 低代码子系统 +│ ├── configuration/ LowCodeAutoConfiguration +│ ├── model/ ResourceSchema / FieldSchema / RepositoryConfig / CqrsConfig / CacheConfig +│ │ StorageType / FieldType / AutoFillType +│ ├── properties/ LowCodeProperties +│ ├── registry/ ResourceSchemaBuilder +│ ├── repository/ LowCodeRepository / LowCodeStorage / LowCodeRepoFactory +│ └── router/ LowCodeRepositoryRouter +├── properties/ InfraProperties +└── repository/ 仓储核心接口与实现 +``` + +## 仓储子系统 + +### RepositoryType + +| 类型 | 说明 | +|-----|------| +| `MYBATIS` | MyBatis | +| `MYBATIS_PLUS` | MyBatis Plus | +| `JPA` | Spring Data JPA | +| `JDBC` | JDBC | +| `NOSQL` | 通用 NoSQL | +| `REDIS` | Redis | +| `MONGODB` | MongoDB | +| `ELASTICSEARCH` | Elasticsearch | +| `AUTO` | 自动检测(使用第一个能创建 delegate 的工厂) | + +### DelegateType + +| 类型 | 说明 | +|-----|------| +| `BASE` | 基础代理:承担写操作和默认读操作 | +| `READ` | 读代理:仅承担读操作(CQRS 模式下使用) | + +### 核心接口 + +#### `RepositoryDelegate` + +继承自 `ICrudRepository` 与 `IQueryDelegate`,定义对 PO 的完整 CRUD 操作。各持久化 starter 提供具体实现。 + +#### `IQueryDelegate` + +只读 delegate 契约,定义 `findById` / `listByIds` / `count` / `exists`。可作为 CQRS 读代理的契约。 + +#### `RepositoryDelegateFactory` + +```java +RepositoryType getType(); +RepositoryDelegate createDelegate(Class poClass, Class idClass); +``` + +每个持久化 starter 实现此 SPI,用于在无用户自定义 delegate 时自动创建。 + +#### `RepositoryFacade>` + +用户侧门面,职责: + +- 通过 `BeanUtils.copyProperties` 完成 Entity ↔ PO 转换 +- 写操作(`save` / `removeById` / `saveBatch` / `removeBatchByIds` / `exists`)始终走 `baseDelegate` +- 读操作(`queryById` / `queryOne` / `queryList` / `queryPage` / `listByIds` / `count`)优先走 `readDelegate`,失败回退到 `baseDelegate` +- `findById` 始终走 `baseDelegate`(严格的 PO 抓取) + +#### `InMemoryRepositoryDelegate` + +默认兜底实现,使用 `ConcurrentHashMap` 与 `AtomicLong` ID 生成器,主要用于测试或无任何持久化 starter 的场景。 + +### 注解 + +#### `@Repository` + +标注在 `RepositoryFacade` 子类上: + +| 属性 | 类型 | 默认值 | 说明 | +|-----|------|-------|------| +| `value` | String | `""` | 仓储名称 | +| `type` | `RepositoryType` | `AUTO` | 持久化类型 | +| `entity` | `Class` | `Object.class` | 领域实体类 | +| `po` | `Class` | `Object.class` | 持久化对象类 | +| `id` | `Class` | `Long.class` | 主键类型 | +| `description` | String | `""` | 描述 | +| `cache` | boolean | `false` | 是否启用缓存 | +| `cacheTime` | long | `60L` | 缓存 TTL | +| `cacheTimeUnit` | `TimeUnit` | `SECONDS` | 缓存单位 | +| `cqrs` | boolean | `false` | 是否启用 CQRS | +| `readDelegateClass` | `Class` | `Object.class` | 读代理类(CQRS 时必填) | + +#### `@DelegateFor` + +标注在 `RepositoryDelegate` / `IQueryDelegate` 实现类上: + +| 属性 | 类型 | 默认值 | 说明 | +|-----|------|-------|------| +| `name` | String | `""` | 目标 facade bean 名称 | +| `type` | `RepositoryType` | `AUTO` | 存储类型 | +| `po` | `Class` | `Object.class` | PO 类型 | +| `description` | String | `""` | 描述 | +| `priority` | int | `0` | 优先级(数字越大越优先) | +| `delegateType` | `DelegateType` | `BASE` | `BASE` / `READ` | + +### 委托匹配策略 + +`RepositoryBeanPostProcessor` 在 `ContextRefreshedEvent` 时执行匹配: + +1. 解析 facade 子类的泛型签名 `` +2. 读取 facade 上的 `@Repository` 注解获取 `type` / `cqrs` / `readDelegateClass` +3. **BASE 代理搜索**(按 priority 降序,逐级尝试): + 1. delegate 类型匹配 + 名称匹配 + 类型匹配 + 2. delegate 类型匹配 + 类型匹配 + 3. delegate 类型匹配 + 名称匹配 + 4. delegate 类型匹配 + 5. 名称匹配 + 6. PO 类型匹配 +4. 若未找到 BASE delegate,调用 `RepositoryDelegateFactory.createDelegate(...)` 自动创建 +5. 仍创建失败则回退到 `InMemoryRepositoryDelegate` +6. **READ 代理搜索**(仅 `cqrs=true && readDelegateClass != Object.class` 时):同上 6 步匹配,仅过滤 `delegateType == READ`,类型固定为 `AUTO`(允许读写使用不同存储技术) + +## CQRS 读写分离 + +### 类型化 CQRS(RepositoryFacade) + +```java +@Repository( + entity = User.class, po = UserPO.class, id = Long.class, + cqrs = true, readDelegateClass = UserReadDelegate.class) +public class UserRepository + extends RepositoryFacade> { +} + +@DelegateFor(name = "userRepository", po = UserPO.class, + type = RepositoryType.MYBATIS_PLUS, delegateType = DelegateType.BASE) +@Component +public class UserMybatisPlusDelegate extends MybatisPlusRepositoryDelegate {} + +@DelegateFor(name = "userRepository", po = UserPO.class, + type = RepositoryType.ELASTICSEARCH, delegateType = DelegateType.READ) +@Component +public class UserReadDelegate extends ElasticsearchRepositoryDelegate {} +``` + +读操作执行流程: +- `readDelegate != null` → 尝试执行读操作 +- 异常 → 记录 warning,回退到 `baseDelegate` 兜底 +- `findById` 与写操作始终走 `baseDelegate` + +### 低代码 CQRS(LowCodeRepositoryRouter) + +YAML 声明: + +```yaml +structure: + infra: + lowcode: + resources: + order: + schema: + table-name: t_order + fields: + id: { type: long, primary-key: true, auto-increment: true } + order_no: { type: string, length: 32, unique: true } + amount: { type: decimal, precision: 12, scale: 2 } + repository: + type: mysql + cqrs: + enabled: true + read-type: elasticsearch + read-datasource: es-cluster + cache: + enabled: true + ttl: 300 + time-unit: seconds +``` + +注册时同时创建 base storage 与 read storage;读操作先尝试 read storage,失败回退 base storage。 + +## 低代码子系统 + +### 数据模型 + +- `StorageType`:`MYSQL` / `MONGODB` / `ELASTICSEARCH` / `REDIS` / `IN_MEMORY` +- `FieldType`:`STRING` / `LONG` / `INTEGER` / `BOOLEAN` / `DECIMAL` / `DATETIME` / `DATE` / `OBJECT_ID` / `TEXT` / `JSON` +- `AutoFillType`:`NONE` / `CREATE` / `UPDATE` / `CREATE_UPDATE` +- `FieldSchema`:字段名、列名、类型、长度、精度、是否主键/自增/可空/唯一/索引、默认值、自动填充类型 +- `ResourceSchema`:资源名、表名、ID 字段名、ID 类型、字段 Map +- `RepositoryConfig`:存储类型、数据源名、CQRS 子配置、缓存子配置 + +### 核心接口 + +#### `LowCodeRepository`(用户侧) + +方法签名与 `ICrudRepository` 一致,但每个方法首参为 `String resourceName`,操作对象为 `Map`: + +- 写:`save` / `removeById` / `saveBatch` / `removeBatchByIds` / `exists` +- 读:`queryById` / `queryByIdOptional` / `queryOne` / `queryOneOptional` / `queryList` / `queryPage` / `listByIds` / `count` +- `findById` 始终走 base storage + +#### `LowCodeStorage`(引擎侧) + +每个存储引擎实现此接口,方法集与 `LowCodeRepository` 一致(去掉 `resourceName` 参数),额外有 `void initialize()` 用于建表/集合/索引。 + +#### `LowCodeRepoFactory` + +```java +StorageType getType(); +LowCodeStorage createStorage(ResourceSchema schema, RepositoryConfig config); +``` + +各存储 starter 提供 factory bean,由 `LowCodeRepositoryRouter` 自动收集。 + +#### `LowCodeRepositoryRouter` + +`LowCodeAutoConfiguration` 注册的唯一 bean。维护 `resourceName -> StorageHolder` 映射。注册资源时按 `RepositoryConfig.type` 选择 factory 创建 base storage,若开启 CQRS 则同步创建 read storage。 + +### YAML 配置示例 + +```yaml +structure: + infra: + lowcode: + enabled: true + resources: + user: + schema: + table-name: t_user + id-type: long + fields: + id: + type: long + primary-key: true + auto-increment: true + username: + type: string + length: 64 + nullable: false + index: true + email: + type: string + length: 128 + unique: true + created_at: + type: datetime + auto-fill: create + updated_at: + type: datetime + auto-fill: create_update + repository: + type: mysql + datasource: master +``` + +字段支持的属性:`type` / `length` / `precision` / `scale` / `primary-key` / `auto-increment` / `nullable` / `unique` / `index` / `default-value` / `auto-fill` / `description`。 + +存储类型字符串解析(大小写不敏感): +- `mysql` → `MYSQL` +- `mongodb` / `mongo` → `MONGODB` +- `elasticsearch` / `es` → `ELASTICSEARCH` +- `redis` → `REDIS` +- `memory` / `in_memory` → `IN_MEMORY` + +字段类型字符串解析:`string` / `varchar` / `long` / `bigint` / `int` / `integer` / `bool` / `boolean` / `decimal` / `double` / `float` / `datetime` / `timestamp` / `date` / `objectid` / `object_id` / `text` / `json`。 + +### 运行时动态注册 + +```java +@Component +@RequiredArgsConstructor +public class RuntimeRegistrar implements ApplicationRunner { + private final LowCodeRepositoryRouter router; + + @Override + public void run(ApplicationArguments args) { + ResourceSchema schema = ResourceSchema.builder() + .resourceName("audit_log") + .tableName("t_audit_log") + .build(); + schema.addField(FieldSchema.builder() + .name("id").type(FieldType.LONG).primaryKey(true).autoIncrement(true).build()); + schema.addField(FieldSchema.builder() + .name("action").type(FieldType.STRING).length(64).nullable(false).build()); + + RepositoryConfig cfg = new RepositoryConfig(); + cfg.setType(StorageType.MYSQL); + cfg.setDatasource("master"); + + router.registerResource("audit_log", schema, cfg); + } +} +``` + +## 事件子系统 + +### 事件通道 + +| 通道 | 行为 | +|------|------| +| `DEFAULT` | 由 `InfraProperties.defaultEventChannel` 决定路由 | +| `SPRING_EVENT` | 通过 `ApplicationEventPublisher` 同步发布 | +| `MESSAGE_EVENT` | 通过 `DataScopeStreamBridge` 发送到消息中间件 | + +### 接口 + +```java +public interface Event { + String getEventId(); + default EventChannel getEventChannel() { return EventChannel.DEFAULT; } +} + +public interface EventManager { + void publish(Event event); +} +``` + +### 使用 + +```java +public class UserCreatedEvent implements Event { + private final String eventId = UUID.randomUUID().toString(); + @Override public String getEventId() { return eventId; } +} + +@Component +@RequiredArgsConstructor +public class UserEventPublisher { + private final EventManager eventManager; + public void publish() { eventManager.publish(new UserCreatedEvent()); } +} + +@Component +public class UserEventListener { + @EventListener + public void on(UserCreatedEvent event) { /* ... */ } +} +``` + +> 注:`AutoEventConfiguration` 仅在已存在 `EventManager` bean 时注册 `DefaultEventManagerImpl`。需要使用事件功能时,请确保上下文中已有 `EventManager` bean(通常由 `DefaultEventManagerImpl` 自身或外部 starter 提供)。 + +## 配置属性 + +### InfraProperties(`structure.infra.*`) + +| 属性 | 类型 | 默认值 | 说明 | +|-----|------|-------|------| +| `defaultEventChannel` | `EventChannel` | `SPRING_EVENT` | `DEFAULT` 事件的默认通道 | +| `cqrs` | `Boolean` | `false` | 全局 CQRS 开关(仅作建议,以 `@Repository` 注解为准) | +| `cacheTime` | `Long` | `60L` | 默认缓存 TTL | +| `cacheTimeUnit` | `TimeUnit` | `SECONDS` | 默认缓存单位 | +| `schedulePoolSize` | `Integer` | `Runtime.availableProcessors()` | 调度线程池大小 | +| `type` | `RepositoryType` | - | 默认持久化类型(用于触发各 starter 的条件装配) | + +### LowCodeProperties(`structure.infra.lowcode.*`) + +```yaml +structure: + infra: + lowcode: + enabled: true # 默认 true + resources: + : + schema: + table-name:
+ id-type: long + fields: + : + type: string + length: 255 + precision: 10 + scale: 2 + primary-key: false + auto-increment: false + nullable: true + unique: false + index: false + default-value: + auto-fill: none # none|create|update|create_update + description: + repository: + type: mysql # mysql|mongodb|elasticsearch|redis|in_memory + datasource: + cqrs: + enabled: false + read-type: elasticsearch + read-datasource: + cache: + enabled: false + ttl: 300 + time-unit: seconds # seconds|minutes|hours|days|milliseconds +``` + +## 完整使用示例 + +### 1. 定义领域实体与 PO + +```java +// 领域实体(无任何持久化注解) +public class User { + private Long id; + private String username; + private String email; + // getters/setters +} + +// 持久化对象(由具体存储技术的注解决定) +@TableName("t_user") +public class UserPO { + @TableId(type = IdType.AUTO) + private Long id; + private String username; + private String email; +} +``` + +### 2. 声明 RepositoryFacade + +```java +@Repository(entity = User.class, po = UserPO.class, id = Long.class, + type = RepositoryType.MYBATIS_PLUS) +public class UserRepository + extends RepositoryFacade> { +} +``` + +### 3. 提供自定义 delegate(可选) + +```java +@DelegateFor(name = "userRepository", po = UserPO.class, + type = RepositoryType.MYBATIS_PLUS, delegateType = DelegateType.BASE) +@Component +public class UserMybatisPlusDelegate extends MybatisPlusRepositoryDelegate { + public UserMybatisPlusDelegate() { super(UserPO.class); } +} +``` + +### 4. 业务层使用 + +```java +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + public User create(String username, String email) { + User u = new User(); + u.setUsername(username); + u.setEmail(email); + return userRepository.save(u); + } + + public Optional findByUsername(String username) { + User probe = new User(); + probe.setUsername(username); + return userRepository.queryOneOptional(probe); + } + + public ResPage page(int page, int size) { + ReqPage reqPage = new ReqPage(); + reqPage.setPage(page); + reqPage.setSize(size); + return userRepository.queryPage(reqPage); + } +} +``` + +### 5. 低代码使用 + +```java +@Service +@RequiredArgsConstructor +public class DynamicResourceService { + private final LowCodeRepository lowCodeRepository; + + public Map create(String username, String email) { + Map data = new HashMap<>(); + data.put("username", username); + data.put("email", email); + return lowCodeRepository.save("user", data); + } +} +``` + +## 测试 + +```bash +mvn test -pl structure-infra-starter +``` + +## License + +Apache License 2.0 diff --git a/structure-infra-xxljob-starter/README.md b/structure-infra-xxljob-starter/README.md new file mode 100644 index 0000000..b0e72f8 --- /dev/null +++ b/structure-infra-xxljob-starter/README.md @@ -0,0 +1,335 @@ +# structure-infra-xxljob-starter + +[XXL-Job](https://github.com/xuxueli/xxl-job) 分布式任务调度框架的适配模块,将 XXL-Job 接入 `structure-infra-schedule-starter` 的 `TaskScheduler` SPI,使应用代码无需感知底层调度实现即可在本地调度与分布式调度之间切换。 + +## 模块定位 + +本模块是 `structure-infra-schedule-starter` 的可插拔替代实现: + +- 当本模块在 classpath 上时,自动覆盖默认的 `LocalThreadTaskScheduler` +- 所有调度操作通过 `XxlJobTemplate` 转发到远程 XXL-Job admin +- 应用代码仍使用统一的 `TaskScheduler` API,无需修改 + +## 功能特性 + +- **自动装配**:`@AutoConfigureBefore(AutoScheduleConfiguration.class)`,自动覆盖本地调度器 +- **统一 SPI**:实现 `cn.structure.infra.schedule.TaskScheduler`,应用代码无感知 +- **三种调度类型**: + - `CRON` — 直接使用 `cronExpression` + - `FIXED_RATE` — 自动转换为 `0/{seconds} * * * * ?` cron 表达式 + - `FIXED_DELAY` — 自动转换为 `0/{seconds} * * * * ?` cron 表达式 +- **逻辑 ID 映射**:维护 `taskId <-> xxlJobId` 映射,调用方使用自己的逻辑 `taskId`,无需关心 XXL-Job 内部 job id +- **默认配置**:自动设置路由策略 `FIRST`、阻塞策略 `SERIAL_EXECUTION`、超时 300 秒、重试 1 次、Glue 类型 `BEAN`、作者 `system` +- **完整生命周期**:`schedule / update / remove / pause / resume` 全部映射到 XXL-Job admin REST API + +## 依赖 + +```xml + + cn.structured + structure-infra-xxljob-starter + 1.0.0-SNAPSHOT + +``` + +模块自身依赖: + +- `cn.structured:structure-common` +- `cn.structured:structure-infra-schedule-starter` — 提供 `TaskScheduler` SPI、`ScheduleTask` 模型、`TaskHandler`、`TaskHandlerRegistry` +- `cn.structured:structure-job-starter`(v2.0.0,外部)— 提供 `XxlJobClient`、`XxlJobInfoDTO`、`ExecutorRouteStrategyEnum` +- `org.springframework.boot:spring-boot-autoconfigure` +- `org.springframework.boot:spring-boot-configuration-processor`(optional) + +## 自动配置 + +`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 注册: + +``` +cn.structure.infra.configuration.AutoXxlJobConfiguration +``` + +`AutoXxlJobConfiguration` 通过 `@AutoConfigureBefore(AutoScheduleConfiguration.class)` 在 schedule-starter 的本地调度器之前注册,使 `@ConditionalOnMissingBean(TaskScheduler.class)` 在 schedule-starter 一侧回退: + +| Bean | 类型 | 条件 | +|------|------|------| +| `xxlJobTemplate` | `XxlJobTemplateImpl` | `@ConditionalOnMissingBean(XxlJobTemplate.class)`,依赖外部 `XxlJobClient` bean | +| `taskScheduler` | `XxlJobTaskScheduler` | `@ConditionalOnMissingBean(TaskScheduler.class)` | + +> **前提**:上下文中必须已存在 `XxlJobClient` bean(由外部 `structure-job-starter` 在 `structure.job.*` 配置下自动注册)。 + +## 配置属性 + +### 本模块专属(`structure.schedule.xxl-job.*`) + +`XxlJobProperties`: + +| 属性 | 类型 | 默认值 | 说明 | +|-----|------|-------|------| +| `enabled` | `boolean` | `true` | 开关(声明但当前未通过 `@ConditionalOnProperty` 强制) | +| `jobGroup` | `Integer` | `1` | XXL-Job 执行器分组 ID,写入每个 `XxlJobInfoDTO` | + +```yaml +structure: + schedule: + xxl-job: + enabled: true + job-group: 1 +``` + +### 外部 `structure-job-starter`(`structure.job.*`) + +XXL-Job 的 admin 地址、appname、access token、executor 端口、日志路径等由外部 `structure-job-starter` 管理: + +```yaml +structure: + job: + enable: true + admin-address: http://localhost:8080/xxl-job-admin + access-token: xxl-job-admin-token + executor: + appname: my-app-executor +``` + +## 核心类 + +### `XxlJobTemplate`(接口) + +对 `XxlJobClient` 的薄封装,所有操作返回/接收 XXL-Job 内部 job id: + +```java +String add(String jobName, String cronExpression, String handlerName, String handlerParam); +void update(String jobId, String jobName, String cronExpression, String handlerName, String handlerParam); +void remove(String jobId); +void pause(String jobId); +void start(String jobId); +String getJobId(String handlerName); // 当前为 stub,返回 null +``` + +### `XxlJobTemplateImpl` + +`XxlJobTemplate` 的默认实现: + +- 每次 CRUD 操作构建 `XxlJobInfoDTO`(设置 jobGroup、路由 `FIRST`、阻塞 `SERIAL_EXECUTION`、超时 300s、重试 1、glueType `BEAN`、author `system`) +- 调用对应 `XxlJobClient` 方法 +- 检查返回 `Response` 的 code,成功则返回 job id,失败抛 `RuntimeException` + +### `XxlJobTaskScheduler` + +`TaskScheduler` SPI 的 XXL-Job 实现: + +内部状态: + +```java +private final Map taskIdToXxlJobIdMap = new ConcurrentHashMap<>(); +private final Map taskMap = new ConcurrentHashMap<>(); +``` + +| 方法 | 行为 | +|------|------| +| `schedule(task)` | 校验 → `remove(taskId)` 清理旧映射 → 转换为 cron → `xxlJobTemplate.add(...)` → 存储 `taskId -> xxlJobId` 与 task → status=RUNNING | +| `update(task)` | 无 xxlJobId 则回退到 `schedule(task)`;否则调用 `xxlJobTemplate.update(...)` → status=RUNNING | +| `remove(taskId)` | 移除两个映射;如有 xxlJobId 则调用 `xxlJobTemplate.remove(xxlJobId)`;status=STOPPED | +| `pause(taskId)` | 查找 xxlJobId → `xxlJobTemplate.pause(xxlJobId)` → status=PAUSED | +| `resume(taskId)` | 仅当 status=PAUSED 时执行 → `xxlJobTemplate.start(xxlJobId)` → status=RUNNING | +| `getTaskInfo(taskId)` | 返回缓存的 `ScheduleTask`(或 null) | +| `getAllTasks()` | 返回所有缓存任务的不可变副本 | + +Cron 转换逻辑: + +```java +private String convertToCron(ScheduleTask task) { + if (task.getScheduleType() == ScheduleType.CRON) { + if (cronExpression == null || empty) throw IllegalArgumentException; + return cronExpression; + } + long ms = (scheduleType == FIXED_RATE) ? period : delay; + long seconds = Math.max(1, ms / 1000); + return "0/" + seconds + " * * * * ?"; +} +``` + +## 使用示例 + +### 1. 配置 + +```yaml +structure: + job: + enable: true + admin-address: http://localhost:8080/xxl-job-admin + access-token: xxl-job-admin-token + executor: + appname: my-app-executor + schedule: + xxl-job: + enabled: true + job-group: 1 +``` + +### 2. 声明 XXL-Job 处理器 + +使用 XXL-Job 的 `@XxlJob` 注解,注解值必须与后续 `ScheduleTask.handlerName` 一致: + +```java +@Slf4j +@Component +public class SampleXxlJob { + + private final AtomicInteger counter = new AtomicInteger(0); + + @XxlJob("sampleJobHandler") + public void sampleJobHandler() { + int count = counter.incrementAndGet(); + log.info("SampleXxlJob executed, count={}", count); + } + + @XxlJob("simpleJobHandler") + public void simpleJobHandler() { + log.info("SimpleXxlJob executed"); + } +} +``` + +### 3. 通过 `TaskScheduler` 调度任务 + +```java +@Autowired +private TaskScheduler taskScheduler; + +// CRON 调度 +ScheduleTask task = ScheduleTask.builder() + .taskId("order-sync-001") + .taskName("每日订单同步") + .handlerName("sampleJobHandler") // 必须匹配 @XxlJob 的值 + .handlerParam("...") + .scheduleType(ScheduleTask.ScheduleType.CRON) + .cronExpression("0/10 * * * * ?") + .build(); +taskScheduler.schedule(task); + +// FIXED_RATE 调度(自动转换为 cron) +ScheduleTask fixedRateTask = ScheduleTask.builder() + .taskId("metrics-collect-001") + .taskName("指标采集") + .handlerName("simpleJobHandler") + .scheduleType(ScheduleTask.ScheduleType.FIXED_RATE) + .period(5000L) // 自动转换为 0/5 * * * * ? + .build(); +taskScheduler.schedule(fixedRateTask); + +// 生命周期管理 +taskScheduler.pause("order-sync-001"); +taskScheduler.resume("order-sync-001"); +taskScheduler.update(task); +taskScheduler.remove("order-sync-001"); +taskScheduler.getTaskInfo("order-sync-001"); +taskScheduler.getAllTasks(); +``` + +### 4. 通过 REST API 管理(参考示例模块) + +```java +@RestController +@RequestMapping("/job") +@RequiredArgsConstructor +public class JobManagerController { + + private final TaskScheduler taskScheduler; + + @PostMapping("/add") + public ScheduleTask add(@RequestParam String taskId, + @RequestParam String taskName, + @RequestParam String handlerName, + @RequestParam(required = false) String handlerParam, + @RequestParam(defaultValue = "0/5 * * * * ?") String cronExpression) { + ScheduleTask task = ScheduleTask.builder() + .taskId(taskId) + .taskName(taskName) + .handlerName(handlerName) + .handlerParam(handlerParam) + .scheduleType(ScheduleTask.ScheduleType.CRON) + .cronExpression(cronExpression) + .build(); + taskScheduler.schedule(task); + return taskScheduler.getTaskInfo(taskId); + } + + @PutMapping("/update/{taskId}") + public ScheduleTask update(@PathVariable String taskId, @RequestParam String taskName, + @RequestParam(required = false) String handlerParam, + @RequestParam(defaultValue = "0/5 * * * * ?") String cronExpression) { + ScheduleTask exist = taskScheduler.getTaskInfo(taskId); + ScheduleTask task = ScheduleTask.builder() + .taskId(taskId) + .taskName(taskName != null ? taskName : exist.getTaskName()) + .handlerName(exist.getHandlerName()) + .handlerParam(handlerParam != null ? handlerParam : exist.getHandlerParam()) + .scheduleType(ScheduleTask.ScheduleType.CRON) + .cronExpression(cronExpression) + .build(); + taskScheduler.update(task); + return taskScheduler.getTaskInfo(taskId); + } + + @DeleteMapping("/remove/{taskId}") + public void remove(@PathVariable String taskId) { taskScheduler.remove(taskId); } + + @PutMapping("/pause/{taskId}") + public void pause(@PathVariable String taskId) { taskScheduler.pause(taskId); } + + @PutMapping("/resume/{taskId}") + public void resume(@PathVariable String taskId) { taskScheduler.resume(taskId); } + + @GetMapping("/info/{taskId}") + public ScheduleTask info(@PathVariable String taskId) { return taskScheduler.getTaskInfo(taskId); } + + @GetMapping("/list") + public List list() { return taskScheduler.getAllTasks(); } +} +``` + +## 默认 job 配置 + +`XxlJobTemplateImpl.buildJobInfo(...)` 自动应用以下默认值: + +| 字段 | 默认值 | +|------|-------| +| `executorRouteStrategy` | `FIRST` | +| `executorBlockStrategy` | `SERIAL_EXECUTION` | +| `executorTimeout` | `300`(秒) | +| `executorFailRetryCount` | `1` | +| `glueType` | `BEAN` | +| `author` | `system` | +| `jobGroup` | `XxlJobProperties.jobGroup`(默认 1) | + +## 注意事项 + +- **`XxlJobTemplate.getJobId(handlerName)`** 当前为 stub,始终返回 `null` +- **`XxlJobProperties.enabled`** 当前未通过 `@ConditionalOnProperty` 强制生效;设为 `false` 不会阻止自动配置 +- **状态不持久化**:`taskId -> xxlJobId` 映射存储在内存中,应用重启后丢失(XXL-Job admin 端的 job 仍然存在) +- **不支持的 ScheduleTask 字段**:`initialDelay` 与 `timeUnit` 在 XXL-Job 实现中被忽略;仅使用 `cronExpression`、`period`(FIXED_RATE)、`delay`(FIXED_DELAY,按毫秒处理) +- **FIXED_RATE / FIXED_DELAY 转换**:自动生成 `0/{seconds} * * * * ?`,亚秒级周期会被向上取整为 1 秒 +- **依赖外部 `XxlJobClient`**:必须配置 `structure.job.*` 让 `structure-job-starter` 注册 `XxlJobClient` bean,否则 `AutoXxlJobConfiguration` 启动失败 + +## 与本地调度的切换 + +| 场景 | 引入的 starter | 生效的 `TaskScheduler` | +|------|---------------|----------------------| +| 单机本地调度 | `structure-infra-schedule-starter` | `LocalThreadTaskScheduler` | +| 分布式 XXL-Job 调度 | `structure-infra-schedule-starter` + `structure-infra-xxljob-starter` | `XxlJobTaskScheduler`(通过 `@AutoConfigureBefore` 覆盖) | + +业务代码完全一致,仅依赖切换即可。 + +## 测试 + +参考示例模块 `structure-infra-sample-xxljob`: + +```bash +mvn test -pl structure-infra-sample/structure-infra-sample-xxljob +``` + +测试需启动 XXL-Job admin 服务(默认 `http://localhost:8080/xxl-job-admin`)。 + +## License + +Apache License 2.0 From 1cfb7ab6a860083076b9f3f67b4d900419147213 Mon Sep 17 00:00:00 2001 From: chuck <361648887@qq.com> Date: Sat, 4 Jul 2026 02:28:56 +0800 Subject: [PATCH 6/8] =?UTF-8?q?docs(infra):=20=E6=B7=BB=E5=8A=A0=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E8=AE=BE=E6=96=BD=E6=A8=A1=E5=9D=97=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=B1=BB=E8=AF=A6=E7=BB=86=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 AutoEventConfiguration 添加事件子系统自动装配配置说明 - 为 AutoScheduleConfiguration 添加调度子系统自动装配配置说明 - 为 AutoXxlJobConfiguration 添加 XXL-Job 自动配置类说明 - 为 ConfigurableRouteInitializer 添加配置驱动路由初始化器文档 - 为 DefaultEventManagerImpl 添加默认事件管理器实现说明 - 为 DefaultStreamEventManagerImpl 添加流事件管理器默认实现文档 - 为 DefaultStreamEventRouterImpl 添加流事件路由器默认实现说明 --- .../ElasticsearchLowCodeRepoFactory.java | 14 ++ .../lowcode/ElasticsearchLowCodeStorage.java | 154 +++++++++++++- ...lasticsearchDelegateBeanPostProcessor.java | 42 +++- .../ElasticsearchDelegateFactory.java | 28 ++- .../ElasticsearchRepositoryDelegate.java | 172 ++++++++++++++- .../JpaDelegateBeanPostProcessor.java | 53 ++++- .../jpa/repository/JpaDelegateFactory.java | 28 ++- .../jpa/repository/JpaRepositoryDelegate.java | 180 +++++++++++++++- .../lowcode/MongoLowCodeRepoFactory.java | 14 ++ .../mongodb/lowcode/MongoLowCodeStorage.java | 131 +++++++++++- .../MongoDelegateBeanPostProcessor.java | 41 +++- .../repository/MongoDelegateFactory.java | 28 ++- .../repository/MongoRepositoryDelegate.java | 164 ++++++++++++++- .../plus/lowcode/MySqlLowCodeRepoFactory.java | 14 ++ .../plus/lowcode/MySqlLowCodeStorage.java | 139 +++++++++++++ .../MybatisPlusDelegateBeanPostProcessor.java | 58 +++++- .../MybatisPlusDelegateFactory.java | 42 +++- .../MybatisPlusRepositoryDelegate.java | 190 +++++++++++++++++ .../AutoScheduleConfiguration.java | 45 ++++ .../infra/properties/ScheduleProperties.java | 21 ++ .../schedule/DefaultTaskHandlerRegistry.java | 42 ++++ .../schedule/LocalThreadTaskScheduler.java | 150 +++++++++++++- .../infra/schedule/ScheduleTask.java | 79 +++++++ .../schedule/SpringTaskSchedulerAdapter.java | 91 +++++++- .../structure/infra/schedule/TaskHandler.java | 20 ++ .../infra/schedule/TaskHandlerRegistry.java | 44 ++++ .../infra/schedule/TaskScheduler.java | 73 +++++++ .../infra/annotations/Repository.java | 62 ++++-- .../configuration/AutoEventConfiguration.java | 24 +++ .../AutoScheduleConfiguration.java | 46 ++++ .../infra/event/DefaultEventManagerImpl.java | 35 +++- .../structure/infra/event/EventChannel.java | 22 ++ .../LowCodeAutoConfiguration.java | 5 + .../registry/ResourceSchemaBuilder.java | 2 + .../router/LowCodeRepositoryRouter.java | 106 ++++++++++ .../java/cn/structure/infra/package-info.java | 22 +- .../infra/properties/InfraProperties.java | 43 +++- .../InMemoryRepositoryDelegate.java | 99 +++++++++ .../RepositoryBeanPostProcessor.java | 196 ++++++++++++++++++ .../infra/repository/RepositoryFacade.java | 133 ++++++++++++ .../RepositoryFacadeFactoryBean.java | 63 ++++++ .../infra/repository/RepositoryType.java | 34 ++- .../annotation/StreamEventListener.java | 76 +++++++ .../stream/annotation/StreamRouteHandler.java | 53 +++++ .../StreamAutoConfiguration.java | 55 ++++- .../infra/stream/event/StreamEvent.java | 175 ++++++++++++++++ .../stream/handler/StreamEventHandler.java | 23 ++ .../DefaultStreamEventManagerImpl.java | 155 ++++++++++++++ .../stream/manager/ListenerRegistration.java | 126 +++++++++++ .../stream/manager/StreamEventManager.java | 158 ++++++++++++++ .../EventListenerBeanPostProcessor.java | 90 +++++++- ...StreamBindingBeanFactoryPostProcessor.java | 111 +++++++++- .../stream/properties/StreamProperties.java | 180 ++++++++++++++++ .../router/ConfigurableRouteInitializer.java | 77 ++++++- .../router/DefaultStreamEventRouterImpl.java | 104 ++++++++++ .../router/RouteHandlerBeanPostProcessor.java | 58 ++++++ .../stream/router/RouteRegistration.java | 126 +++++++++++ .../infra/stream/router/RouterProperties.java | 128 ++++++++++++ .../stream/router/StreamEventRouter.java | 96 +++++++++ .../AutoXxlJobConfiguration.java | 40 ++++ .../infra/properties/XxlJobProperties.java | 32 +++ .../schedule/xxljob/XxlJobTaskScheduler.java | 137 ++++++++++++ .../infra/schedule/xxljob/XxlJobTemplate.java | 60 ++++++ .../schedule/xxljob/XxlJobTemplateImpl.java | 95 +++++++++ 64 files changed, 5036 insertions(+), 68 deletions(-) diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeRepoFactory.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeRepoFactory.java index 6f1bb4b..e3cb83f 100644 --- a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeRepoFactory.java +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeRepoFactory.java @@ -36,11 +36,25 @@ public ElasticsearchLowCodeRepoFactory(ElasticsearchOperations elasticsearchOper this.elasticsearchOperations = elasticsearchOperations; } + /** + * 返回该工厂支持的存储类型,用于低代码路由引擎匹配。 + * + * @return 固定返回 {@link StorageType#ELASTICSEARCH} + */ @Override public StorageType getType() { return StorageType.ELASTICSEARCH; } + /** + * 创建 Elasticsearch 低代码存储实例。 + *

+ * 内部构造 {@link ElasticsearchLowCodeStorage},由其在初始化时自动创建索引(不创建 mapping)。 + * + * @param schema 资源 schema 定义(索引名、字段、主键等) + * @param config 仓储配置(当前实现未使用,保留以匹配 SPI 签名) + * @return 低代码存储实例 + */ @Override public LowCodeStorage createStorage(ResourceSchema schema, RepositoryConfig config) { return new ElasticsearchLowCodeStorage(schema, elasticsearchOperations); diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeStorage.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeStorage.java index 5a5150a..62d1f2c 100644 --- a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeStorage.java +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeStorage.java @@ -29,15 +29,19 @@ /** * Elasticsearch 低代码仓储实现 *

- * 基于 Spring Data Elasticsearch 的低代码存储实现,使用 Map 代替 POJO 操作文档。 + * 基于 Spring Data Elasticsearch 的低代码存储实现,使用 {@code Map} 代替 POJO 操作文档, + * 通过 {@link ElasticsearchOperations} 动态操作 ES 索引。 *

* 核心特性: *

    *
  • Map 动态操作:使用 Map 代替 POJO,无需定义实体类
  • - *
  • 自动创建索引:初始化时自动创建索引
  • + *
  • 自动创建索引:初始化时自动创建索引(不创建 mapping,由 ES 动态映射字段类型)
  • + *
  • 更新策略:先 delete 再 index(非部分更新),保证文档状态与入参一致
  • + *
  • 索引定位:通过 {@link IndexCoordinates#of(String)} 以 schema.tableName 定位索引
  • *
  • 自动填充:支持创建时间、更新时间自动填充
  • *
  • 动态查询:根据查询条件动态构建 Elasticsearch 查询
  • *
  • 分页查询:支持分页查询,自动处理总数统计
  • + *
  • 常作为 CQRS 读侧:适用于全文检索、聚合分析等读多写少场景
  • *
* * @author chuck @@ -47,8 +51,11 @@ @Slf4j public class ElasticsearchLowCodeStorage implements LowCodeStorage { + /** 资源 schema 定义(索引名、字段、主键等) */ private final ResourceSchema schema; + /** Elasticsearch 操作模板 */ private final ElasticsearchOperations elasticsearchOperations; + /** 索引坐标,由 schema.tableName 构建,用于定位 ES 索引 */ private final IndexCoordinates indexCoordinates; /** @@ -60,14 +67,21 @@ public class ElasticsearchLowCodeStorage implements LowCodeStorage { public ElasticsearchLowCodeStorage(ResourceSchema schema, ElasticsearchOperations elasticsearchOperations) { this.schema = schema; this.elasticsearchOperations = elasticsearchOperations; + // 通过 schema.tableName 构建 IndexCoordinates,后续所有操作均以此定位索引 this.indexCoordinates = IndexCoordinates.of(schema.getTableName()); } + /** + * 初始化存储结构:自动创建 ES 索引(不创建 mapping)。 + *

+ * 若索引不存在则调用 {@code indexOps.create()} 创建空索引,由 ES 在首次写入时动态映射字段类型; + * 已存在则跳过。 + */ @Override public void initialize() { String indexName = schema.getTableName(); - // 检查索引是否存在,不存在则创建 + // 检查索引是否存在,不存在则创建(不创建 mapping,由 ES 动态映射) boolean indexExists = elasticsearchOperations.indexOps(indexCoordinates).exists(); if (!indexExists) { elasticsearchOperations.indexOps(indexCoordinates).create(); @@ -77,17 +91,33 @@ public void initialize() { log.info("Elasticsearch lowcode storage initialized: {}", indexName); } + /** + * 保存或更新一条记录(以 Map 形式)。 + *

+ * 处理流程: + *

    + *
  1. 拷贝入参 Map,避免污染调用方
  2. + *
  3. 按 {@link AutoFillType#CREATE} 与 {@link AutoFillType#CREATE_UPDATE} 自动填充时间字段
  4. + *
  5. 根据主键是否存在且库中已有同 ID 文档,决定走 doUpdate 或 doIndex
  6. + *
+ * + * @param data 数据 Map,键为字段名、值为字段值 + * @return 保存后的数据(含自动填充字段) + */ @Override public Map save(Map data) { + // 拷贝一份,避免污染调用方传入的 Map Map rowData = new HashMap<>(data); + // 创建场景填充:CREATE_TIME 等 fillAutoFields(rowData, AutoFillType.CREATE); + // 创建/更新双重填充:CREATE_UPDATE 字段 fillAutoFields(rowData, AutoFillType.CREATE_UPDATE); String idField = schema.getIdFieldName(); Object idValue = rowData.get(idField); if (idValue != null) { - // 更新操作 - 先检查是否存在 + // 已带主键时先查库,存在则更新、不存在则插入 Map existing = findById(idValue); if (existing != null) { return doUpdate(rowData); @@ -98,20 +128,24 @@ public Map save(Map data) { } /** - * 执行索引操作(新增或更新) + * 执行索引操作(新增或覆盖索引)。 + *

+ * 通过 {@link IndexQueryBuilder} 构建索引请求,主键值统一转换为 String 作为 ES 文档 ID。 * * @param data 数据 - * @return 索引后的数据 + * @return 索引后的数据(含 ES 返回的文档 ID) */ private Map doIndex(Map data) { String idField = schema.getIdFieldName(); Object idValue = data.get(idField); + // ID 统一转换为 String 作为 ES 文档 ID IndexQuery indexQuery = new IndexQueryBuilder() .withId(idValue != null ? String.valueOf(idValue) : null) .withObject(data) .build(); + // 执行索引操作,返回 ES 文档 ID String documentId = elasticsearchOperations.index(indexQuery, indexCoordinates); data.put(idField, documentId); @@ -119,7 +153,10 @@ private Map doIndex(Map data) { } /** - * 执行更新操作 + * 执行更新操作。 + *

+ * 采用"先 delete 再 index"策略(非部分更新):先按 ID 删除旧文档,再以入参完整索引新文档, + * 保证文档状态与入参一致,避免部分更新遗漏字段。 * * @param data 数据 * @return 更新后的数据 @@ -128,46 +165,91 @@ private Map doUpdate(Map data) { String idField = schema.getIdFieldName(); Object idValue = data.get(idField); - // 删除旧文档 + // 步骤1:删除旧文档(ID 转 String) elasticsearchOperations.delete(String.valueOf(idValue), indexCoordinates); - // 重新索引 + // 步骤2:以入参完整重新索引(非部分更新) return doIndex(data); } + /** + * 根据主键删除文档。 + *

+ * ID 统一转换为 String 作为 ES 文档 ID。 + * + * @param id 主键值 + */ @Override public void removeById(Object id) { elasticsearchOperations.delete(String.valueOf(id), indexCoordinates); } + /** + * 根据主键查询单条记录。 + *

+ * ID 统一转换为 String 作为 ES 文档 ID,结果以 Map 形式返回。 + * + * @param id 主键值 + * @return 数据 Map,未找到时返回 null + */ @Override + @SuppressWarnings({"unchecked", "rawtypes"}) public Map findById(Object id) { Map result = elasticsearchOperations.get(String.valueOf(id), Map.class, indexCoordinates); return result; } + /** + * 根据主键查询(与 findById 等价,语义上用于"读模型",常作为 CQRS 读侧)。 + * + * @param id 主键值 + * @return 数据 Map,未找到时返回 null + */ @Override public Map queryById(Object id) { return findById(id); } + /** + * 根据条件查询单条记录,取结果集首条。 + * + * @param queryParams 查询条件 Map,键为字段名、值为等值匹配值 + * @return 首条匹配记录,无匹配时返回 null + */ @Override public Map queryOne(Map queryParams) { List> list = queryList(queryParams); return list.isEmpty() ? null : list.get(0); } + /** + * 根据条件查询单条记录,并以 {@link Optional} 包装返回。 + * + * @param queryParams 查询条件 Map + * @return 包含首条匹配记录的 Optional + */ @Override public Optional> queryOneOptional(Map queryParams) { return Optional.ofNullable(queryOne(queryParams)); } + /** + * 根据条件等值匹配查询列表。 + *

+ * 仅 schema 中存在且非 null 的字段才会进入查询 Criteria。查询结果通过 SearchHit.getContent() + * 提取为 Map 列表返回。 + * + * @param queryParams 查询条件 Map,为 null 或空时匹配全部文档 + * @return 匹配记录列表,无匹配时返回空列表 + */ @Override + @SuppressWarnings({"unchecked", "rawtypes"}) public List> queryList(Map queryParams) { Query query = buildQuery(queryParams); SearchHits> searchHits = elasticsearchOperations.search(query, (Class>) (Class) Map.class, indexCoordinates); + // 提取 SearchHit 的 content 为 Map 列表 List> results = new ArrayList<>(); for (SearchHit> hit : searchHits.getSearchHits()) { results.add(hit.getContent()); @@ -175,8 +257,19 @@ public List> queryList(Map queryParams) { return results; } + /** + * 分页查询。 + *

+ * 通过 {@link PageRequest} 叠加分页参数,由 ES 原生分页(from/size)执行; + * totalHits 为 0 时直接返回空记录。 + * + * @param reqPage 分页请求(页码从 1 开始、每页大小,为 null 时取默认 1/10) + * @return 分页结果,含当前页、总页数、总条数、当前页记录 + */ @Override + @SuppressWarnings({"unchecked", "rawtypes"}) public ResPage> queryPage(ReqPage reqPage) { + // ES 页码从 0 开始,业务页码从 1 开始,需减 1 int pageNum = reqPage.getPage() != null ? reqPage.getPage().intValue() - 1 : 0; int pageSize = reqPage.getSize() != null ? reqPage.getSize().intValue() : 10; @@ -188,19 +281,23 @@ public ResPage> queryPage(ReqPage reqPage) { (Class>) (Class) Map.class, indexCoordinates); ResPage> page = new ResPage<>(); + // 返回业务侧时页码再加回 1 page.setCurrent((long) pageNum + 1); page.setSize((long) pageSize); page.setTotal(searchHits.getTotalHits()); if (searchHits.getTotalHits() == 0) { + // 无数据时直接返回,避免提取空 SearchHits page.setRecords(new ArrayList<>()); page.setPages(0L); return page; } + // 总页数向上取整 long pages = (searchHits.getTotalHits() + pageSize - 1) / pageSize; page.setPages(pages); + // 提取当前页 SearchHit 的 content 为 Map 列表 List> records = new ArrayList<>(); for (SearchHit> hit : searchHits.getSearchHits()) { records.add(hit.getContent()); @@ -210,6 +307,14 @@ public ResPage> queryPage(ReqPage reqPage) { return page; } + /** + * 批量保存记录(逐条调用 save)。 + *

+ * 每条记录独立处理 Map 拷贝、自动填充与 upsert 判断。 + * + * @param dataList 数据列表,为 null 或空时返回空列表 + * @return 保存后的数据列表 + */ @Override public List> saveBatch(List> dataList) { if (dataList == null || dataList.isEmpty()) { @@ -223,6 +328,11 @@ public List> saveBatch(List> dataList) { return result; } + /** + * 根据主键列表批量删除(逐条 delete,ID 转 String)。 + * + * @param ids 主键列表,为 null 或空时不执行任何操作 + */ @Override public void removeBatchByIds(List ids) { if (ids == null || ids.isEmpty()) { @@ -233,6 +343,12 @@ public void removeBatchByIds(List ids) { } } + /** + * 根据主键列表批量查询(逐条 get 并过滤 null)。 + * + * @param ids 主键列表,为 null 或空时返回空列表 + * @return 匹配记录列表(Map 形式) + */ @Override public List> listByIds(List ids) { if (ids == null || ids.isEmpty()) { @@ -249,12 +365,24 @@ public List> listByIds(List ids) { return results; } + /** + * 按条件统计记录数。 + * + * @param queryParams 查询条件 Map,为 null 或空时统计全部文档 + * @return 匹配的记录数 + */ @Override public long count(Map queryParams) { Query query = buildQuery(queryParams); return elasticsearchOperations.count(query, indexCoordinates); } + /** + * 判断是否存在匹配条件的记录。 + * + * @param queryParams 查询条件 Map + * @return 存在返回 true,否则 false + */ @Override public boolean exists(Map queryParams) { return count(queryParams) > 0; @@ -270,20 +398,23 @@ private Query buildQuery(Map queryParams) { Criteria criteria = new Criteria(); if (queryParams != null && !queryParams.isEmpty()) { + // 仅 schema 内且非 null 的字段进入 Criteria,等值匹配 boolean first = true; for (Map.Entry entry : queryParams.entrySet()) { String fieldName = entry.getKey(); if (schema.getField(fieldName) != null && entry.getValue() != null) { if (first) { + // 第一个条件用 Criteria.where 初始化 criteria = Criteria.where(fieldName).is(entry.getValue()); first = false; } else { + // 后续条件用 and 链式拼接 criteria = criteria.and(Criteria.where(fieldName).is(entry.getValue())); } } } } else { - // 查询所有 + // 无查询条件时匹配全部文档(通过 _id exists 兜底) criteria = Criteria.where("_id").exists(); } @@ -299,9 +430,12 @@ private Query buildQuery(Map queryParams) { private void fillAutoFields(Map data, AutoFillType fillType) { LocalDateTime now = LocalDateTime.now(); for (FieldSchema field : schema.getFields().values()) { + // 仅处理与当前填充类型匹配的字段(CREATE / UPDATE / CREATE_UPDATE) if (field.getAutoFill() == fillType) { String name = field.getName(); + // 仅在字段未显式设置时填充,避免覆盖调用方传入的值 if (!data.containsKey(name)) { + // 按字段类型生成对应的时间值:DATETIME 用 LocalDateTime,DATE 用 LocalDate switch (field.getType()) { case DATETIME -> data.put(name, now); case DATE -> data.put(name, now.toLocalDate()); diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateBeanPostProcessor.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateBeanPostProcessor.java index e06fa06..23c27b0 100644 --- a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateBeanPostProcessor.java +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateBeanPostProcessor.java @@ -8,30 +8,68 @@ import org.springframework.context.ApplicationContextAware; import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +/** + * Elasticsearch RepositoryDelegate 的 BeanPostProcessor,负责为用户自定义 Delegate 子类自动注入 ElasticsearchOperations 与实体类型。 + *

+ * 在仓储框架中,业务方可继承 {@link ElasticsearchRepositoryDelegate} 实现自定义 Delegate,并通过 + * {@link DelegateFor} 注解声明其服务的 PO 类型。本后处理器在 Bean 初始化完成后: + *

    + *
  1. 识别所有 {@link ElasticsearchRepositoryDelegate} 类型的 Bean
  2. + *
  3. 按类型从容器获取 {@link ElasticsearchOperations} 并注入
  4. + *
  5. 读取 {@link DelegateFor#po()} 指定的 PO 类型并通过 setter 注入
  6. + *
+ *

+ * 与 {@link ElasticsearchDelegateFactory} 的分工:工厂负责"无自定义 Delegate 时自动创建", + * 本处理器负责"已有自定义 Delegate 时补齐依赖",二者协同保证 RepositoryFacade 总能拿到可用的 Delegate。 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Slf4j public class ElasticsearchDelegateBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware { + /** Spring 上下文,用于按类型获取 ElasticsearchOperations */ private ApplicationContext applicationContext; + /** + * 注入 Spring 应用上下文,供后续按类型查询 Bean。 + * + * @param applicationContext Spring 应用上下文 + * @throws BeansException 上下文注入异常 + */ @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } + /** + * 在 Bean 初始化完成后,对自定义 ElasticsearchRepositoryDelegate 实现类注入 ElasticsearchOperations 与实体类型。 + *

+ * 仅当 Bean 是 {@link ElasticsearchRepositoryDelegate} 实例时执行注入; + * ElasticsearchOperations 解析失败仅告警不抛异常。 + * + * @param bean 待处理的 Bean 实例 + * @param beanName Bean 名称 + * @return 原始 Bean(已注入依赖),未匹配类型时原样返回 + * @throws BeansException 处理过程中的异常 + */ @SuppressWarnings({"unchecked", "rawtypes"}) @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof ElasticsearchRepositoryDelegate) { ElasticsearchRepositoryDelegate delegate = (ElasticsearchRepositoryDelegate) bean; try { + // 按类型从容器获取 ElasticsearchOperations 并注入 ElasticsearchOperations elasticsearchOperations = applicationContext.getBean(ElasticsearchOperations.class); delegate.setElasticsearchOperations(elasticsearchOperations); - + + // 读取 @DelegateFor 注解,识别该 Delegate 服务的 PO 类型并注入 DelegateFor annotation = bean.getClass().getAnnotation(DelegateFor.class); if (annotation != null && annotation.po() != void.class) { delegate.setEntityClass(annotation.po()); } - + log.info("Injected ElasticsearchOperations into ElasticsearchRepositoryDelegate: {}", beanName); } catch (Exception e) { log.warn("Failed to inject ElasticsearchOperations into ElasticsearchRepositoryDelegate {}: {}", beanName, e.getMessage()); diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateFactory.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateFactory.java index 103fa64..d855024 100644 --- a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateFactory.java +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateFactory.java @@ -8,7 +8,12 @@ /** * Elasticsearch 仓储委托工厂 *

- * 自动创建 ElasticsearchRepositoryDelegate 实例 + * 实现 {@link RepositoryDelegateFactory} SPI,自动创建 {@link ElasticsearchRepositoryDelegate} 实例。 + * 在仓储框架中,当 {@code RepositoryFacade} 找不到用户自定义的 Delegate 时,会通过本工厂按 PO 类型 + * 创建默认 Delegate 实例(依赖容器中的 {@link ElasticsearchOperations})。 + *

+ * 与 {@link ElasticsearchDelegateBeanPostProcessor} 的分工:本工厂负责"无自定义 Delegate 时创建默认实现", + * BeanPostProcessor 负责"已有自定义子类时补齐依赖"。 * * @author chuck * @version 1.0.1 @@ -16,17 +21,38 @@ */ public class ElasticsearchDelegateFactory implements RepositoryDelegateFactory { + /** Elasticsearch 操作模板,由容器注入并共享给所有 Delegate 实例 */ private final ElasticsearchOperations elasticsearchOperations; + /** + * 构造工厂,注入 ElasticsearchOperations。 + * + * @param elasticsearchOperations Elasticsearch 操作模板 + */ public ElasticsearchDelegateFactory(ElasticsearchOperations elasticsearchOperations) { this.elasticsearchOperations = elasticsearchOperations; } + /** + * 返回该工厂支持的仓储类型,用于 SPI 路由匹配。 + * + * @return 固定返回 {@link RepositoryType#ELASTICSEARCH} + */ @Override public RepositoryType getType() { return RepositoryType.ELASTICSEARCH; } + /** + * 为指定 PO 类型创建 {@link ElasticsearchRepositoryDelegate} 实例。 + *

+ * Elasticsearch 实现不依赖 Mapper 查找,直接以入参 PO 类型构造 Delegate,因此失败概率较低; + * 出现异常时返回 null,由上层 RepositoryFacade 继续尝试其他工厂或抛出异常。 + * + * @param poClass PO 实体类型 + * @param idClass 主键类型(当前实现未使用,保留以匹配 SPI 签名) + * @return 已注入 ElasticsearchOperations 的 Delegate 实例;构造异常时返回 null + */ @Override @SuppressWarnings({"unchecked", "rawtypes"}) public RepositoryDelegate createDelegate(Class poClass, Class idClass) { diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchRepositoryDelegate.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchRepositoryDelegate.java index 3d0a1fe..2bfe80c 100644 --- a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchRepositoryDelegate.java +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchRepositoryDelegate.java @@ -19,9 +19,25 @@ import java.util.stream.Collectors; /** - * Elasticsearch 仓储委托实现 + * 基于 Elasticsearch 的 RepositoryDelegate 适配实现 *

- * 基于 Spring Data Elasticsearch 实现的仓储委托 + * 该类是 RepositoryDelegate SPI 在 Elasticsearch 存储类型下的标准实现,委托 + * {@link ElasticsearchOperations} 完成文档的 CRUD 操作。它在仓储框架中扮演"具体存储适配层"的角色: + *

    + *
  • 上层由 {@code RepositoryFacade} 统一暴露给业务方,本类不直接面向业务
  • + *
  • 当用户未提供自定义 Delegate 时,由 {@link ElasticsearchDelegateFactory} 自动创建本类实例
  • + *
  • 当用户提供自定义 Delegate 子类时,由 {@link ElasticsearchDelegateBeanPostProcessor} + * 在 Bean 初始化后自动注入 ElasticsearchOperations 与实体类型
  • + *
+ *

+ * 实现说明: + *

    + *
  • ID 统一转换为 String:ES 文档 ID 必须为字符串,所有按 ID 操作均通过 {@code String.valueOf(id)} 转换
  • + *
  • 查询条件通过反射读取实体非空字段,组装为 {@link Criteria}(等值匹配)并构建 {@link CriteriaQuery}
  • + *
  • save 委托给 {@link ElasticsearchOperations#save(Object)},由 ES 自动判断新增或覆盖索引
  • + *
  • 分页使用 {@link PageRequest},由 ES 原生分页(from/size)实现
  • + *
  • 常作为 CQRS 读侧:适用于全文检索、聚合分析等读多写少场景
  • + *
* * @param 持久化对象类型(PO) * @param 主键类型 @@ -32,17 +48,38 @@ @Slf4j public class ElasticsearchRepositoryDelegate implements RepositoryDelegate { + /** Elasticsearch 操作模板,承担实际文档操作 */ protected ElasticsearchOperations elasticsearchOperations; + /** PO 实体类型,用于反射读取字段与 search/get */ protected Class entityClass; + /** 主键字段名,默认 "id",可被子类覆盖 */ protected String idFieldName; + /** + * 默认构造器,用于用户自定义子类场景。 + *

+ * 创建后由 {@link ElasticsearchDelegateBeanPostProcessor} 通过 setter 注入依赖。 + */ public ElasticsearchRepositoryDelegate() { } + /** + * 以默认主键字段名 "id" 构造 Delegate。 + * + * @param elasticsearchOperations Elasticsearch 操作模板 + * @param entityClass PO 实体类型 + */ public ElasticsearchRepositoryDelegate(ElasticsearchOperations elasticsearchOperations, Class entityClass) { this(elasticsearchOperations, entityClass, "id"); } + /** + * 全参构造器,工厂自动创建场景使用。 + * + * @param elasticsearchOperations Elasticsearch 操作模板 + * @param entityClass PO 实体类型 + * @param idFieldName 主键字段名 + */ public ElasticsearchRepositoryDelegate(ElasticsearchOperations elasticsearchOperations, Class entityClass, String idFieldName) { this.elasticsearchOperations = elasticsearchOperations; this.entityClass = entityClass; @@ -50,18 +87,41 @@ public ElasticsearchRepositoryDelegate(ElasticsearchOperations elasticsearchOper log.info("ElasticsearchRepositoryDelegate initialized for entity: {}", entityClass.getSimpleName()); } + /** + * 注入 ElasticsearchOperations,供 BeanPostProcessor 在自定义子类上调用。 + * + * @param elasticsearchOperations Elasticsearch 操作模板 + */ public void setElasticsearchOperations(ElasticsearchOperations elasticsearchOperations) { this.elasticsearchOperations = elasticsearchOperations; } + /** + * 注入 PO 实体类型,供 BeanPostProcessor 在自定义子类上调用。 + * + * @param entityClass PO 实体类型 + */ public void setEntityClass(Class entityClass) { this.entityClass = entityClass; } + /** + * 设置主键字段名,供自定义子类覆盖默认 "id"。 + * + * @param idFieldName 主键字段名 + */ public void setIdFieldName(String idFieldName) { this.idFieldName = idFieldName; } + /** + * 保存或更新文档。 + *

+ * 委托给 {@link ElasticsearchOperations#save(Object)},由 ES 依据 _id 自动判断新增或覆盖索引。 + * + * @param entity 实体对象,为 null 时返回 null + * @return 保存后的实体(与入参同一引用) + */ @Override public T save(T entity) { if (entity == null) { @@ -72,34 +132,71 @@ public T save(T entity) { return saved; } + /** + * 根据主键删除文档。 + *

+ * ES 文档 ID 必须为字符串,主键值通过 {@code String.valueOf(id)} 转换后再删除。 + * + * @param id 主键值,为 null 时不执行任何操作 + */ @Override public void removeById(ID id) { if (id != null) { + // ID 统一转换为 String 作为 ES 文档 ID elasticsearchOperations.delete(String.valueOf(id), entityClass); log.debug("Removed entity: id={}", id); } } + /** + * 根据主键查询文档。 + *

+ * ES 文档 ID 必须为字符串,主键值通过 {@code String.valueOf(id)} 转换后再查询。 + * + * @param id 主键值,为 null 时返回 null + * @return 实体对象,未找到时返回 null + */ @Override public T findById(ID id) { if (id == null) { return null; } + // ID 统一转换为 String 作为 ES 文档 ID T entity = elasticsearchOperations.get(String.valueOf(id), entityClass); log.debug("Find by id: id={}, found={}", id, entity != null); return entity; } + /** + * 根据主键查询(与 findById 等价,语义上用于"读模型",常作为 CQRS 读侧)。 + * + * @param id 主键值 + * @return 实体对象,未找到时返回 null + */ @Override public T queryById(ID id) { return findById(id); } + /** + * 根据主键查询并以 {@link Optional} 包装返回。 + * + * @param id 主键值 + * @return 包含实体的 Optional,未找到时为 {@link Optional#empty()} + */ @Override public Optional queryByIdOptional(ID id) { return Optional.ofNullable(queryById(id)); } + /** + * 根据非空字段等值匹配查询单条记录。 + *

+ * 通过反射构建 {@link CriteriaQuery},取首条 SearchHit 的 content;无匹配时返回 null。 + * + * @param condition 查询条件对象,为 null 时返回 null + * @return 首条匹配记录,无匹配时返回 null + */ @Override public T queryOne(T condition) { if (condition == null) { @@ -110,14 +207,30 @@ public T queryOne(T condition) { return searchHits.hasSearchHits() ? searchHits.getSearchHit(0).getContent() : null; } + /** + * 根据条件查询单条记录,并以 {@link Optional} 包装返回。 + * + * @param condition 查询条件对象 + * @return 包含首条匹配记录的 Optional + */ @Override public Optional queryOneOptional(T condition) { return Optional.ofNullable(queryOne(condition)); } + /** + * 根据条件查询列表。 + *

+ * 条件为 null 时使用 {@code Criteria.where("*").exists()} 匹配全部文档; + * 否则按非空字段构建等值 Criteria。 + * + * @param condition 查询条件对象,可为 null + * @return 匹配的实体列表,无匹配时返回空列表 + */ @Override public List queryList(T condition) { if (condition == null) { + // 匹配全部文档 Query query = new CriteriaQuery(Criteria.where("*").exists()); SearchHits searchHits = elasticsearchOperations.search(query, entityClass); return searchHits.getSearchHits().stream() @@ -131,8 +244,18 @@ public List queryList(T condition) { .collect(Collectors.toList()); } + /** + * 分页查询。 + *

+ * 通过 {@code Criteria.where("*").exists()} 匹配全部文档,叠加 {@link PageRequest} 分页参数, + * 由 ES 原生分页(from/size)执行。 + * + * @param reqPage 分页请求(页码从 1 开始、每页大小,为 null 时取默认 1/10) + * @return 分页结果,含当前页、总页数、总条数、当前页记录 + */ @Override public ResPage queryPage(ReqPage reqPage) { + // ES 页码从 0 开始,业务页码从 1 开始,需减 1 int pageNum = reqPage.getPage() != null ? reqPage.getPage() - 1 : 0; int pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; @@ -143,7 +266,9 @@ public ResPage queryPage(ReqPage reqPage) { SearchHits searchHits = elasticsearchOperations.search(query, entityClass); ResPage resPage = new ResPage<>(); + // 返回业务侧时页码再加回 1 resPage.setCurrent((long) (pageNum + 1)); + // 总页数向上取整 resPage.setPages((long) (searchHits.getTotalHits() > 0 ? (searchHits.getTotalHits() + pageSize - 1) / pageSize : 0)); resPage.setSize((long) pageSize); resPage.setTotal(searchHits.getTotalHits()); @@ -156,6 +281,12 @@ public ResPage queryPage(ReqPage reqPage) { return resPage; } + /** + * 反射读取条件对象非空字段,构建等值 {@link CriteriaQuery}。 + * + * @param condition 条件对象 + * @return 已填充等值 Criteria 的 Query + */ private Query buildQuery(T condition) { Criteria criteria = new Criteria(); try { @@ -164,6 +295,7 @@ private Query buildQuery(T condition) { field.setAccessible(true); Object value = field.get(condition); if (value != null) { + // 通过 and 链式拼接多个字段的等值条件 criteria = criteria.and(Criteria.where(field.getName()).is(value)); } } @@ -173,6 +305,12 @@ private Query buildQuery(T condition) { return new CriteriaQuery(criteria); } + /** + * 收集类及其所有父类(直到 Object)的声明字段。 + * + * @param clazz 起始类 + * @return 全部字段数组 + */ private Field[] getAllFields(Class clazz) { List fields = new java.util.ArrayList<>(); while (clazz != null && clazz != Object.class) { @@ -182,6 +320,12 @@ private Field[] getAllFields(Class clazz) { return fields.toArray(new Field[0]); } + /** + * 批量保存实体(逐条 save)。 + * + * @param entities 实体列表,为 null 或空时返回空列表 + * @return 保存后的实体列表 + */ @Override public List saveBatch(List entities) { if (entities == null || entities.isEmpty()) { @@ -192,6 +336,11 @@ public List saveBatch(List entities) { .collect(Collectors.toList()); } + /** + * 根据主键列表批量删除(逐条 delete,ID 转 String)。 + * + * @param ids 主键列表,为 null 或空时不执行任何操作 + */ @Override public void removeBatchByIds(List ids) { if (ids != null && !ids.isEmpty()) { @@ -199,6 +348,12 @@ public void removeBatchByIds(List ids) { } } + /** + * 根据主键列表批量查询(逐条 get 并过滤 null)。 + * + * @param ids 主键列表,为 null 或空时返回空列表 + * @return 匹配的实体列表 + */ @Override public List listByIds(List ids) { if (ids == null || ids.isEmpty()) { @@ -210,9 +365,16 @@ public List listByIds(List ids) { .collect(Collectors.toList()); } + /** + * 按条件统计记录数。 + * + * @param condition 条件对象,为 null 时统计全部文档 + * @return 匹配的记录数 + */ @Override public long count(T condition) { if (condition == null) { + // 匹配全部文档 Query query = new CriteriaQuery(Criteria.where("*").exists()); return elasticsearchOperations.count(query, entityClass); } @@ -220,6 +382,12 @@ public long count(T condition) { return elasticsearchOperations.count(query, entityClass); } + /** + * 判断是否存在匹配条件的记录。 + * + * @param condition 条件对象 + * @return 存在返回 true,否则 false + */ @Override public boolean exists(T condition) { return count(condition) > 0; diff --git a/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateBeanPostProcessor.java b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateBeanPostProcessor.java index 76acb0f..95d4871 100644 --- a/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateBeanPostProcessor.java +++ b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateBeanPostProcessor.java @@ -9,22 +9,58 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; +/** + * JPA RepositoryDelegate 的 BeanPostProcessor,负责为用户自定义 Delegate 子类自动注入 EntityManager 与实体类型。 + *

+ * 在仓储框架中,业务方可继承 {@link JpaRepositoryDelegate} 实现自定义 Delegate,并通过 + * {@link DelegateFor} 注解声明其服务的 PO 类型。本后处理器在 Bean 初始化完成后: + *

    + *
  1. 识别所有 {@link JpaRepositoryDelegate} 类型的 Bean
  2. + *
  3. 按"by name → by type → create"顺序解析并注入 {@link EntityManager}
  4. + *
  5. 读取 {@link DelegateFor#po()} 指定的 PO 类型并通过 setter 注入
  6. + *
+ *

+ * 与 {@link JpaDelegateFactory} 的分工:工厂负责"无自定义 Delegate 时自动创建", + * 本处理器负责"已有自定义 Delegate 时补齐依赖",二者协同保证 RepositoryFacade 总能拿到可用的 Delegate。 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Slf4j public class JpaDelegateBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware { + /** Spring 上下文,用于解析 EntityManager */ private ApplicationContext applicationContext; + /** + * 注入 Spring 应用上下文,供后续按类型/名称查询 Bean。 + * + * @param applicationContext Spring 应用上下文 + * @throws BeansException 上下文注入异常 + */ @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } + /** + * 在 Bean 初始化完成后,对自定义 JpaRepositoryDelegate 实现类注入 EntityManager 与实体类型。 + *

+ * 仅当 Bean 是 {@link JpaRepositoryDelegate} 实例时执行注入;EntityManager 解析失败仅告警不抛异常。 + * + * @param bean 待处理的 Bean 实例 + * @param beanName Bean 名称 + * @return 原始 Bean(已注入依赖),未匹配类型时原样返回 + * @throws BeansException 处理过程中的异常 + */ @SuppressWarnings({"unchecked", "rawtypes"}) @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof JpaRepositoryDelegate) { JpaRepositoryDelegate delegate = (JpaRepositoryDelegate) bean; - + + // 解析 EntityManager:by name → by type → create EntityManager entityManager = getEntityManager(); if (entityManager != null) { delegate.setEntityManager(entityManager); @@ -33,6 +69,7 @@ public Object postProcessAfterInitialization(Object bean, String beanName) throw log.warn("No EntityManager available to inject into JpaRepositoryDelegate: {}", beanName); } + // 读取 @DelegateFor 注解,识别该 Delegate 服务的 PO 类型并注入 DelegateFor annotation = bean.getClass().getAnnotation(DelegateFor.class); if (annotation != null && annotation.po() != void.class) { delegate.setEntityClass(annotation.po()); @@ -42,7 +79,20 @@ public Object postProcessAfterInitialization(Object bean, String beanName) throw return bean; } + /** + * 解析 {@link EntityManager} 实例。 + *

+ * 解析顺序(按优先级): + *

    + *
  1. by name:从容器中获取名为 "entityManager" 的 Bean
  2. + *
  3. by type:从 {@link EntityManagerFactory} 创建新的 EntityManager
  4. + *
  5. 都失败时返回 null
  6. + *
+ * + * @return EntityManager 实例,无法解析时返回 null + */ private EntityManager getEntityManager() { + // 1) by name:优先按 "entityManager" 名称获取已注册的容器 Bean try { Object bean = applicationContext.getBean("entityManager"); if (bean instanceof EntityManager) { @@ -52,6 +102,7 @@ private EntityManager getEntityManager() { log.debug("entityManager bean not found by name"); } + // 2) by type → create:通过 EntityManagerFactory 创建新的 EntityManager try { EntityManagerFactory factory = applicationContext.getBean(EntityManagerFactory.class); if (factory != null) { diff --git a/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateFactory.java b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateFactory.java index ec31282..14ed7bf 100644 --- a/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateFactory.java +++ b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateFactory.java @@ -9,7 +9,12 @@ /** * JPA 仓储委托工厂 *

- * 自动创建 JpaRepositoryDelegate 实例 + * 实现 {@link RepositoryDelegateFactory} SPI,自动创建 {@link JpaRepositoryDelegate} 实例。 + * 在仓储框架中,当 {@code RepositoryFacade} 找不到用户自定义的 Delegate 时,会通过本工厂按 PO 类型 + * 创建默认 Delegate 实例(依赖容器中的 {@link EntityManager})。 + *

+ * 与 {@link JpaDelegateBeanPostProcessor} 的分工:本工厂负责"无自定义 Delegate 时创建默认实现", + * BeanPostProcessor 负责"已有自定义子类时补齐依赖"。 * * @author chuck * @version 1.0.1 @@ -17,17 +22,38 @@ */ public class JpaDelegateFactory implements RepositoryDelegateFactory { + /** JPA 实体管理器,由容器注入并共享给所有 Delegate 实例 */ private final EntityManager entityManager; + /** + * 构造工厂,注入 EntityManager。 + * + * @param entityManager JPA 实体管理器,用于执行持久化操作 + */ public JpaDelegateFactory(EntityManager entityManager) { this.entityManager = entityManager; } + /** + * 返回该工厂支持的仓储类型,用于 SPI 路由匹配。 + * + * @return 固定返回 {@link RepositoryType#JPA} + */ @Override public RepositoryType getType() { return RepositoryType.JPA; } + /** + * 为指定 PO 类型创建 {@link JpaRepositoryDelegate} 实例。 + *

+ * JPA 实现不依赖 Mapper 查找,直接以入参 PO 类型构造 Delegate,因此失败概率较低; + * 出现异常时返回 null,由上层 RepositoryFacade 继续尝试其他工厂或抛出异常。 + * + * @param poClass PO 实体类型 + * @param idClass 主键类型(当前实现未使用,保留以匹配 SPI 签名) + * @return 已注入 EntityManager 的 Delegate 实例;构造异常时返回 null + */ @Override @SuppressWarnings({"unchecked", "rawtypes"}) public RepositoryDelegate createDelegate(Class poClass, Class idClass) { diff --git a/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaRepositoryDelegate.java b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaRepositoryDelegate.java index bfc7f5d..6ce7983 100644 --- a/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaRepositoryDelegate.java +++ b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaRepositoryDelegate.java @@ -19,29 +19,87 @@ import java.util.List; import java.util.Optional; +/** + * 基于 JPA 的 RepositoryDelegate 适配实现 + *

+ * 该类是 RepositoryDelegate SPI 在 JPA 存储类型下的标准实现,通过 {@link EntityManager} + * 的 Criteria API 完成实体 CRUD。它在仓储框架中扮演"具体存储适配层"的角色: + *

    + *
  • 上层由 {@code RepositoryFacade} 统一暴露给业务方,本类不直接面向业务
  • + *
  • 当用户未提供自定义 Delegate 时,由 {@link JpaDelegateFactory} 自动创建本类实例
  • + *
  • 当用户提供自定义 Delegate 子类时,由 {@link JpaDelegateBeanPostProcessor} + * 在 Bean 初始化后自动注入 EntityManager 与实体类型
  • + *
+ *

+ * 实现说明: + *

    + *
  • 使用 Jakarta 命名空间({@code jakarta.persistence.*}),适用于 Spring Boot 3.x
  • + *
  • 查询条件通过反射读取实体非空字段,组装为 Criteria API 的 {@link Predicate}(等值匹配)
  • + *
  • save 委托给 {@link EntityManager#merge(Object)},由 JPA 自动判断新增或更新
  • + *
  • 分页采用"内存分页":先 findAll 取全量再切片,适用于中小数据量; + * 大数据量场景建议用户自定义 Delegate 子类覆盖 queryPage 使用原生 SQL 分页
  • + *
+ * + * @param 实体(PO)类型 + * @param 主键类型 + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Slf4j public class JpaRepositoryDelegate implements RepositoryDelegate { + /** JPA 实体管理器,承担实际持久化操作 */ protected EntityManager entityManager; + /** PO 实体类型,用于 Criteria API 与 find */ protected Class entityClass; + /** + * 默认构造器,用于用户自定义子类场景。 + *

+ * 创建后由 {@link JpaDelegateBeanPostProcessor} 通过 setter 注入依赖。 + */ public JpaRepositoryDelegate() { } + /** + * 全参构造器,工厂自动创建场景使用。 + * + * @param entityManager JPA 实体管理器 + * @param entityClass PO 实体类型 + */ public JpaRepositoryDelegate(EntityManager entityManager, Class entityClass) { this.entityManager = entityManager; this.entityClass = entityClass; log.info("JpaRepositoryDelegate initialized for entity: {}", entityClass.getSimpleName()); } + /** + * 注入 EntityManager,供 BeanPostProcessor 在自定义子类上调用。 + * + * @param entityManager JPA 实体管理器 + */ public void setEntityManager(EntityManager entityManager) { this.entityManager = entityManager; } + /** + * 注入 PO 实体类型,供 BeanPostProcessor 在自定义子类上调用。 + * + * @param entityClass PO 实体类型 + */ public void setEntityClass(Class entityClass) { this.entityClass = entityClass; } + /** + * 保存或更新实体。 + *

+ * 委托给 {@link EntityManager#merge(Object)},由 JPA 根据实体主键自动判断新增或更新。 + * + * @param entity 实体对象,为 null 或依赖未就绪时返回 null + * @return merge 后的实体实例(可能是新对象引用) + */ @Override public T save(T entity) { if (entity == null || entityManager == null || entityClass == null) { @@ -52,9 +110,17 @@ public T save(T entity) { return saved; } + /** + * 根据主键删除记录。 + *

+ * JPA 删除前必须先 find 出受管实体再 remove,无法直接按 ID 删除。 + * + * @param id 主键值,为 null 时不执行任何操作 + */ @Override public void removeById(ID id) { if (id != null) { + // JPA 删除需先加载受管实体再 remove T entity = findById(id); if (entity != null) { entityManager.remove(entity); @@ -63,6 +129,12 @@ public void removeById(ID id) { } } + /** + * 根据主键查询实体。 + * + * @param id 主键值,为 null 时返回 null + * @return 实体对象,未找到时返回 null + */ @Override public T findById(ID id) { if (id == null) { @@ -73,16 +145,36 @@ public T findById(ID id) { return entity; } + /** + * 根据主键查询(与 findById 等价,语义上用于"读模型")。 + * + * @param id 主键值 + * @return 实体对象,未找到时返回 null + */ @Override public T queryById(ID id) { return findById(id); } + /** + * 根据主键查询并以 {@link Optional} 包装返回。 + * + * @param id 主键值 + * @return 包含实体的 Optional,未找到时为 {@link Optional#empty()} + */ @Override public Optional queryByIdOptional(ID id) { return Optional.ofNullable(findById(id)); } + /** + * 根据非空字段等值匹配查询单条记录。 + *

+ * 通过 Criteria API 构建等值条件,取结果集首条;多于一条时仅返回首条。 + * + * @param condition 查询条件对象,为 null 时返回 null + * @return 首条匹配记录,无匹配时返回 null + */ @Override public T queryOne(T condition) { if (condition == null) { @@ -92,11 +184,25 @@ public T queryOne(T condition) { return results.isEmpty() ? null : results.get(0); } + /** + * 根据条件查询单条记录,并以 {@link Optional} 包装返回。 + * + * @param condition 查询条件对象 + * @return 包含首条匹配记录的 Optional + */ @Override public Optional queryOneOptional(T condition) { return Optional.ofNullable(queryOne(condition)); } + /** + * 根据条件查询列表。 + *

+ * 条件为 null 时查询全部;否则按非空字段构建 Criteria 等值条件。 + * + * @param condition 查询条件对象,可为 null + * @return 匹配的实体列表,无匹配时返回空列表 + */ @Override public List queryList(T condition) { if (condition == null) { @@ -105,19 +211,33 @@ public List queryList(T condition) { return queryByCondition(condition); } + /** + * 分页查询(内存分页)。 + *

+ * 注意:JPA 不支持原生分页时使用内存分页——先 findAll 取全量结果, + * 再按 subList 切片返回当前页。该实现适用于中小数据量;大数据量场景 + * 建议用户自定义 Delegate 子类覆盖本方法,使用原生 SQL 分页。 + * + * @param reqPage 分页请求(页码从 1 开始、每页大小,为 null 时取默认 1/10) + * @return 分页结果,含当前页、总页数、总条数、当前页记录 + */ @Override public ResPage queryPage(ReqPage reqPage) { + // JPA 页码从 0 开始,业务页码从 1 开始,需减 1 转换 int pageNum = reqPage.getPage() != null ? reqPage.getPage() - 1 : 0; int pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; + // 内存分页:先取全量再切片(大数据量场景应覆盖此方法) List allResults = findAll(); int start = pageNum * pageSize; int end = Math.min(start + pageSize, allResults.size()); - + List pageContent = start < allResults.size() ? allResults.subList(start, end) : List.of(); ResPage resPage = new ResPage<>(); + // 返回业务侧时页码再加回 1 resPage.setCurrent((long) (pageNum + 1)); + // 总页数向上取整 resPage.setPages((long) ((allResults.size() + pageSize - 1) / pageSize)); resPage.setSize((long) pageSize); resPage.setTotal((long) allResults.size()); @@ -128,6 +248,11 @@ public ResPage queryPage(ReqPage reqPage) { return resPage; } + /** + * 通过 Criteria API 查询全部实体。 + * + * @return 全部实体列表 + */ private List findAll() { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery query = cb.createQuery(entityClass); @@ -135,11 +260,18 @@ private List findAll() { return entityManager.createQuery(query).getResultList(); } + /** + * 通过 Criteria API 按条件等值查询。 + * + * @param condition 条件对象 + * @return 匹配的实体列表 + */ private List queryByCondition(T condition) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery query = cb.createQuery(entityClass); Root root = query.from(entityClass); + // 构建等值 Predicate 数组并拼接到 WHERE 子句 Predicate[] predicates = buildPredicates(cb, root, condition); if (predicates.length > 0) { query.where(predicates); @@ -148,6 +280,14 @@ private List queryByCondition(T condition) { return entityManager.createQuery(query).getResultList(); } + /** + * 反射读取条件对象非空字段,构建等值 {@link Predicate} 数组。 + * + * @param cb CriteriaBuilder + * @param root 查询根 + * @param condition 条件对象 + * @return 等值 Predicate 数组 + */ private Predicate[] buildPredicates(CriteriaBuilder cb, Root root, T condition) { List predicates = new java.util.ArrayList<>(); try { @@ -156,6 +296,7 @@ private Predicate[] buildPredicates(CriteriaBuilder cb, Root root, T conditio field.setAccessible(true); Object value = field.get(condition); if (value != null) { + // 直接以字段名作为属性路径,等值匹配 predicates.add(cb.equal(root.get(field.getName()), value)); } } @@ -165,6 +306,12 @@ private Predicate[] buildPredicates(CriteriaBuilder cb, Root root, T conditio return predicates.toArray(new Predicate[0]); } + /** + * 收集类及其所有父类(直到 Object)的声明字段。 + * + * @param clazz 起始类 + * @return 全部字段数组 + */ private Field[] getAllFields(Class clazz) { List fields = new java.util.ArrayList<>(); while (clazz != null && clazz != Object.class) { @@ -174,6 +321,12 @@ private Field[] getAllFields(Class clazz) { return fields.toArray(new Field[0]); } + /** + * 批量保存实体(逐条 merge)。 + * + * @param entities 实体列表,为 null 或空时返回空列表 + * @return merge 后的实体列表 + */ @Override public List saveBatch(List entities) { if (entities == null || entities.isEmpty()) { @@ -184,6 +337,11 @@ public List saveBatch(List entities) { .toList(); } + /** + * 根据主键列表批量删除(逐条 find + remove)。 + * + * @param ids 主键列表,为 null 时不执行任何操作 + */ @Override public void removeBatchByIds(List ids) { if (ids != null) { @@ -191,6 +349,12 @@ public void removeBatchByIds(List ids) { } } + /** + * 根据主键列表批量查询(逐条 find 并过滤 null)。 + * + * @param ids 主键列表,为 null 或空时返回空列表 + * @return 匹配的实体列表 + */ @Override public List listByIds(List ids) { if (ids == null || ids.isEmpty()) { @@ -202,6 +366,14 @@ public List listByIds(List ids) { .toList(); } + /** + * 按条件统计记录数。 + *

+ * 当前实现通过查询结果列表的 size 计数(未走 COUNT 查询),适用于中小数据量。 + * + * @param condition 条件对象,为 null 时统计全表 + * @return 匹配的记录数 + */ @Override public long count(T condition) { if (condition == null) { @@ -210,6 +382,12 @@ public long count(T condition) { return queryList(condition).size(); } + /** + * 判断是否存在匹配条件的记录。 + * + * @param condition 条件对象 + * @return 存在返回 true,否则 false + */ @Override public boolean exists(T condition) { return count(condition) > 0; diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeRepoFactory.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeRepoFactory.java index 3bf2ebc..2e85d7f 100644 --- a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeRepoFactory.java +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeRepoFactory.java @@ -36,11 +36,25 @@ public MongoLowCodeRepoFactory(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } + /** + * 返回该工厂支持的存储类型,用于低代码路由引擎匹配。 + * + * @return 固定返回 {@link StorageType#MONGODB} + */ @Override public StorageType getType() { return StorageType.MONGODB; } + /** + * 创建 MongoDB 低代码存储实例。 + *

+ * 内部构造 {@link MongoLowCodeStorage},由其在初始化时自动创建集合与索引。 + * + * @param schema 资源 schema 定义(集合名、字段、主键、索引等) + * @param config 仓储配置(当前实现未使用,保留以匹配 SPI 签名) + * @return 低代码存储实例 + */ @Override public LowCodeStorage createStorage(ResourceSchema schema, RepositoryConfig config) { return new MongoLowCodeStorage(schema, mongoTemplate); diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java index ddf7ba4..cd94286 100644 --- a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java @@ -60,18 +60,24 @@ public MongoLowCodeStorage(ResourceSchema schema, MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } + /** + * 初始化存储结构:自动创建 MongoDB 集合与索引。 + *

+ * 若集合不存在则调用 {@link MongoTemplate#createCollection(String)} 创建; + * 随后根据 schema 中的字段定义创建主键、唯一、普通索引。 + */ @Override public void initialize() { String collectionName = schema.getTableName(); - // 检查集合是否存在,不存在则创建 + // 检查集合是否存在,不存在则创建(MongoDB 集合无需预定义 schema) boolean collectionExists = mongoTemplate.collectionExists(collectionName); if (!collectionExists) { mongoTemplate.createCollection(collectionName); log.info("MongoDB collection created: {}", collectionName); } - // 创建索引 + // 根据 schema 字段定义自动创建索引 createIndexes(collectionName); log.info("MongoDB lowcode storage initialized: {}", collectionName); @@ -91,11 +97,13 @@ public void initialize() { */ private void createIndexes(String collectionName) { for (FieldSchema field : schema.getFields().values()) { + // 主键、索引、唯一字段均需创建索引 if (field.isPrimaryKey() || field.isIndex() || field.isUnique()) { Index index = new Index() .on(field.getName(), field.isUnique() ? org.springframework.data.domain.Sort.Direction.ASC : org.springframework.data.domain.Sort.Direction.ASC); + // 唯一字段追加 unique 约束 if (field.isUnique()) { index.unique(); } @@ -107,17 +115,33 @@ private void createIndexes(String collectionName) { } } + /** + * 保存或更新一条记录(以 Map 形式)。 + *

+ * 处理流程: + *

    + *
  1. 将 Map 转为 MongoDB {@link Document}
  2. + *
  3. 按 {@link AutoFillType#CREATE} 与 {@link AutoFillType#CREATE_UPDATE} 自动填充时间字段
  4. + *
  5. 根据主键是否存在且库中已有同 ID 文档,决定走 doUpdate 或 doInsert
  6. + *
+ * + * @param data 数据 Map,键为字段名、值为字段值 + * @return 保存后的数据(含自动填充字段) + */ @Override public Map save(Map data) { + // Map → Document 转换,MongoDB 原生操作基于 Document Document document = new Document(data); + // 创建场景填充:CREATE_TIME 等 fillAutoFields(document, AutoFillType.CREATE); + // 创建/更新双重填充:CREATE_UPDATE 字段 fillAutoFields(document, AutoFillType.CREATE_UPDATE); String idField = schema.getIdFieldName(); Object idValue = document.get(idField); if (idValue != null) { - // 更新操作 + // 已带主键时先查库,存在则更新、不存在则插入 Query query = new Query(Criteria.where(idField).is(idValue)); Document existing = mongoTemplate.findOne(query, Document.class, schema.getTableName()); if (existing != null) { @@ -132,10 +156,11 @@ public Map save(Map data) { * 执行插入操作 * * @param document Document 对象 - * @return 插入后的数据 + * @return 插入后的数据(Document → Map 转换后返回) */ private Map doInsert(Document document) { mongoTemplate.insert(document, schema.getTableName()); + // Document → Map 转换,对外统一暴露 Map 接口 return documentToMap(document); } @@ -143,7 +168,7 @@ private Map doInsert(Document document) { * 执行更新操作 * * @param document Document 对象 - * @return 更新后的数据 + * @return 更新后的数据(重新查询并以 Map 形式返回) */ private Map doUpdate(Document document) { String idField = schema.getIdFieldName(); @@ -151,7 +176,7 @@ private Map doUpdate(Document document) { Query query = new Query(Criteria.where(idField).is(idValue)); - // 构建更新文档 + // 构建 $set 更新文档:跳过主键字段 Update update = new Update(); for (Map.Entry entry : document.entrySet()) { if (!idField.equals(entry.getKey())) { @@ -161,15 +186,29 @@ private Map doUpdate(Document document) { mongoTemplate.updateFirst(query, update, schema.getTableName()); + // 更新后重新查询以返回最新状态(Document → Map) return findById(idValue); } + /** + * 根据主键删除文档。 + * + * @param id 主键值 + */ @Override public void removeById(Object id) { Query query = new Query(Criteria.where(schema.getIdFieldName()).is(id)); mongoTemplate.remove(query, schema.getTableName()); } + /** + * 根据主键查询单条记录。 + *

+ * 查询结果 Document 通过 {@link #documentToMap(Document)} 转为 Map 返回。 + * + * @param id 主键值 + * @return 数据 Map,未找到时返回 null + */ @Override public Map findById(Object id) { Query query = new Query(Criteria.where(schema.getIdFieldName()).is(id)); @@ -177,31 +216,69 @@ public Map findById(Object id) { return result != null ? documentToMap(result) : null; } + /** + * 根据主键查询(与 findById 等价,语义上用于"读模型")。 + * + * @param id 主键值 + * @return 数据 Map,未找到时返回 null + */ @Override public Map queryById(Object id) { return findById(id); } + /** + * 根据条件查询单条记录,取结果集首条。 + * + * @param queryParams 查询条件 Map,键为字段名、值为等值匹配值 + * @return 首条匹配记录,无匹配时返回 null + */ @Override public Map queryOne(Map queryParams) { List> list = queryList(queryParams); return list.isEmpty() ? null : list.get(0); } + /** + * 根据条件查询单条记录,并以 {@link Optional} 包装返回。 + * + * @param queryParams 查询条件 Map + * @return 包含首条匹配记录的 Optional + */ @Override public Optional> queryOneOptional(Map queryParams) { return Optional.ofNullable(queryOne(queryParams)); } + /** + * 根据条件等值匹配查询列表。 + *

+ * 仅 schema 中存在且非 null 的字段才会进入查询 Criteria。查询结果 Document 列表 + * 通过 {@link #documentsToMaps(List)} 转为 Map 列表返回。 + * + * @param queryParams 查询条件 Map,为 null 或空时等价于全集合查询 + * @return 匹配记录列表,无匹配时返回空列表 + */ @Override public List> queryList(Map queryParams) { Query query = buildQuery(queryParams); List results = mongoTemplate.find(query, Document.class, schema.getTableName()); + // Document 列表 → Map 列表转换 return documentsToMaps(results); } + /** + * 分页查询。 + *

+ * 通过 {@link MongoTemplate#count(Query, String)} 获取总数,再用 {@link PageRequest} 切片 + * 查询当前页 Document,并转为 Map 列表。total 为 0 时直接返回空记录。 + * + * @param reqPage 分页请求(页码从 1 开始、每页大小,为 null 时取默认 1/10) + * @return 分页结果,含当前页、总页数、总条数、当前页记录 + */ @Override public ResPage> queryPage(ReqPage reqPage) { + // MongoTemplate 页码从 0 开始,业务页码从 1 开始,需减 1 int pageNum = reqPage.getPage() != null ? reqPage.getPage().intValue() - 1 : 0; int pageSize = reqPage.getSize() != null ? reqPage.getSize().intValue() : 10; @@ -209,19 +286,23 @@ public ResPage> queryPage(ReqPage reqPage) { long total = mongoTemplate.count(query, schema.getTableName()); ResPage> page = new ResPage<>(); + // 返回业务侧时页码再加回 1 page.setCurrent((long) pageNum + 1); page.setSize((long) pageSize); page.setTotal(total); if (total == 0) { + // 无数据时直接返回,避免执行无意义查询 page.setRecords(new ArrayList<>()); page.setPages(0L); return page; } + // 总页数向上取整 long pages = (total + pageSize - 1) / pageSize; page.setPages(pages); + // 在原 Query 上叠加分页参数,查询当前页 Document 并转为 Map query.with(PageRequest.of(pageNum, pageSize, Sort.unsorted())); List records = mongoTemplate.find(query, Document.class, schema.getTableName()); page.setRecords(documentsToMaps(records)); @@ -229,6 +310,14 @@ public ResPage> queryPage(ReqPage reqPage) { return page; } + /** + * 批量保存记录(逐条调用 save)。 + *

+ * 每条记录独立处理 Map → Document 转换、自动填充与 upsert 判断。 + * + * @param dataList 数据列表,为 null 或空时返回空列表 + * @return 保存后的数据列表 + */ @Override public List> saveBatch(List> dataList) { if (dataList == null || dataList.isEmpty()) { @@ -242,6 +331,11 @@ public List> saveBatch(List> dataList) { return result; } + /** + * 根据主键列表批量删除(使用 $in 一次性删除)。 + * + * @param ids 主键列表,为 null 或空时不执行任何操作 + */ @Override public void removeBatchByIds(List ids) { if (ids == null || ids.isEmpty()) { @@ -251,6 +345,12 @@ public void removeBatchByIds(List ids) { mongoTemplate.remove(query, schema.getTableName()); } + /** + * 根据主键列表批量查询(使用 $in 一次性查询)。 + * + * @param ids 主键列表,为 null 或空时返回空列表 + * @return 匹配记录列表(Map 形式) + */ @Override public List> listByIds(List ids) { if (ids == null || ids.isEmpty()) { @@ -258,15 +358,28 @@ public List> listByIds(List ids) { } Query query = new Query(Criteria.where(schema.getIdFieldName()).in(ids)); List results = mongoTemplate.find(query, Document.class, schema.getTableName()); + // Document 列表 → Map 列表转换 return documentsToMaps(results); } + /** + * 按条件统计记录数。 + * + * @param queryParams 查询条件 Map,为 null 或空时统计全集合 + * @return 匹配的记录数 + */ @Override public long count(Map queryParams) { Query query = buildQuery(queryParams); return mongoTemplate.count(query, schema.getTableName()); } + /** + * 判断是否存在匹配条件的记录。 + * + * @param queryParams 查询条件 Map + * @return 存在返回 true,否则 false + */ @Override public boolean exists(Map queryParams) { return count(queryParams) > 0; @@ -282,6 +395,7 @@ private Query buildQuery(Map queryParams) { Query query = new Query(); if (queryParams != null && !queryParams.isEmpty()) { + // 仅 schema 内且非 null 的字段进入 Criteria,等值匹配 for (Map.Entry entry : queryParams.entrySet()) { String fieldName = entry.getKey(); if (schema.getField(fieldName) != null && entry.getValue() != null) { @@ -303,9 +417,12 @@ private Query buildQuery(Map queryParams) { private void fillAutoFields(Document document, AutoFillType fillType) { LocalDateTime now = LocalDateTime.now(); for (FieldSchema field : schema.getFields().values()) { + // 仅处理与当前填充类型匹配的字段(CREATE / UPDATE / CREATE_UPDATE) if (field.getAutoFill() == fillType) { String name = field.getName(); + // 仅在字段未显式设置时填充,避免覆盖调用方传入的值 if (!document.containsKey(name)) { + // 按字段类型生成对应的时间值:DATETIME 用 LocalDateTime,DATE 用 LocalDate switch (field.getType()) { case DATETIME -> document.put(name, now); case DATE -> document.put(name, now.toLocalDate()); @@ -327,6 +444,7 @@ private Map documentToMap(Document document) { if (document == null) { return null; } + // Document 本身即 Map 派生,此处拷贝为独立 HashMap 以隔离 MongoDB 驱动类型 Map map = new HashMap<>(); for (Map.Entry entry : document.entrySet()) { map.put(entry.getKey(), entry.getValue()); @@ -344,6 +462,7 @@ private List> documentsToMaps(List documents) { if (documents == null || documents.isEmpty()) { return new ArrayList<>(); } + // 逐条 Document → Map 转换 List> result = new ArrayList<>(); for (Document doc : documents) { result.add(documentToMap(doc)); diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateBeanPostProcessor.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateBeanPostProcessor.java index 35c2457..f71088f 100644 --- a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateBeanPostProcessor.java +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateBeanPostProcessor.java @@ -8,30 +8,67 @@ import org.springframework.context.ApplicationContextAware; import org.springframework.data.mongodb.core.MongoTemplate; +/** + * MongoDB RepositoryDelegate 的 BeanPostProcessor,负责为用户自定义 Delegate 子类自动注入 MongoTemplate 与实体类型。 + *

+ * 在仓储框架中,业务方可继承 {@link MongoRepositoryDelegate} 实现自定义 Delegate,并通过 + * {@link DelegateFor} 注解声明其服务的 PO 类型。本后处理器在 Bean 初始化完成后: + *

    + *
  1. 识别所有 {@link MongoRepositoryDelegate} 类型的 Bean
  2. + *
  3. 按类型从容器获取 {@link MongoTemplate} 并注入
  4. + *
  5. 读取 {@link DelegateFor#po()} 指定的 PO 类型并通过 setter 注入
  6. + *
+ *

+ * 与 {@link MongoDelegateFactory} 的分工:工厂负责"无自定义 Delegate 时自动创建", + * 本处理器负责"已有自定义 Delegate 时补齐依赖",二者协同保证 RepositoryFacade 总能拿到可用的 Delegate。 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Slf4j public class MongoDelegateBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware { + /** Spring 上下文,用于按类型获取 MongoTemplate */ private ApplicationContext applicationContext; + /** + * 注入 Spring 应用上下文,供后续按类型查询 Bean。 + * + * @param applicationContext Spring 应用上下文 + * @throws BeansException 上下文注入异常 + */ @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } + /** + * 在 Bean 初始化完成后,对自定义 MongoRepositoryDelegate 实现类注入 MongoTemplate 与实体类型。 + *

+ * 仅当 Bean 是 {@link MongoRepositoryDelegate} 实例时执行注入;MongoTemplate 解析失败仅告警不抛异常。 + * + * @param bean 待处理的 Bean 实例 + * @param beanName Bean 名称 + * @return 原始 Bean(已注入依赖),未匹配类型时原样返回 + * @throws BeansException 处理过程中的异常 + */ @SuppressWarnings({"unchecked", "rawtypes"}) @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof MongoRepositoryDelegate) { MongoRepositoryDelegate delegate = (MongoRepositoryDelegate) bean; try { + // 按类型从容器获取 MongoTemplate 并注入 MongoTemplate mongoTemplate = applicationContext.getBean(MongoTemplate.class); delegate.setMongoTemplate(mongoTemplate); - + + // 读取 @DelegateFor 注解,识别该 Delegate 服务的 PO 类型并注入 DelegateFor annotation = bean.getClass().getAnnotation(DelegateFor.class); if (annotation != null && annotation.po() != void.class) { delegate.setEntityClass(annotation.po()); } - + log.info("Injected MongoTemplate into MongoRepositoryDelegate: {}", beanName); } catch (Exception e) { log.warn("Failed to inject MongoTemplate into MongoRepositoryDelegate {}: {}", beanName, e.getMessage()); diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateFactory.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateFactory.java index 645e345..d7cb15a 100644 --- a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateFactory.java +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateFactory.java @@ -8,7 +8,12 @@ /** * MongoDB 仓储委托工厂 *

- * 自动创建 MongoRepositoryDelegate 实例 + * 实现 {@link RepositoryDelegateFactory} SPI,自动创建 {@link MongoRepositoryDelegate} 实例。 + * 在仓储框架中,当 {@code RepositoryFacade} 找不到用户自定义的 Delegate 时,会通过本工厂按 PO 类型 + * 创建默认 Delegate 实例(依赖容器中的 {@link MongoTemplate})。 + *

+ * 与 {@link MongoDelegateBeanPostProcessor} 的分工:本工厂负责"无自定义 Delegate 时创建默认实现", + * BeanPostProcessor 负责"已有自定义子类时补齐依赖"。 * * @author chuck * @version 1.0.1 @@ -16,17 +21,38 @@ */ public class MongoDelegateFactory implements RepositoryDelegateFactory { + /** MongoDB 操作模板,由容器注入并共享给所有 Delegate 实例 */ private final MongoTemplate mongoTemplate; + /** + * 构造工厂,注入 MongoTemplate。 + * + * @param mongoTemplate MongoDB 操作模板 + */ public MongoDelegateFactory(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } + /** + * 返回该工厂支持的仓储类型,用于 SPI 路由匹配。 + * + * @return 固定返回 {@link RepositoryType#MONGODB} + */ @Override public RepositoryType getType() { return RepositoryType.MONGODB; } + /** + * 为指定 PO 类型创建 {@link MongoRepositoryDelegate} 实例。 + *

+ * MongoDB 实现不依赖 Mapper 查找,直接以入参 PO 类型构造 Delegate,因此失败概率较低; + * 出现异常时返回 null,由上层 RepositoryFacade 继续尝试其他工厂或抛出异常。 + * + * @param poClass PO 实体类型 + * @param idClass 主键类型(当前实现未使用,保留以匹配 SPI 签名) + * @return 已注入 MongoTemplate 的 Delegate 实例;构造异常时返回 null + */ @Override @SuppressWarnings({"unchecked", "rawtypes"}) public RepositoryDelegate createDelegate(Class poClass, Class idClass) { diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoRepositoryDelegate.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoRepositoryDelegate.java index efa7405..f1afbbf 100644 --- a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoRepositoryDelegate.java +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoRepositoryDelegate.java @@ -16,9 +16,24 @@ import java.util.Optional; /** - * MongoDB 仓储委托实现 + * 基于 MongoDB 的 RepositoryDelegate 适配实现 *

- * 基于 Spring Data MongoDB 实现的仓储委托 + * 该类是 RepositoryDelegate SPI 在 MongoDB 存储类型下的标准实现,委托 + * {@link MongoTemplate} 完成文档的 CRUD 操作。它在仓储框架中扮演"具体存储适配层"的角色: + *

    + *
  • 上层由 {@code RepositoryFacade} 统一暴露给业务方,本类不直接面向业务
  • + *
  • 当用户未提供自定义 Delegate 时,由 {@link MongoDelegateFactory} 自动创建本类实例
  • + *
  • 当用户提供自定义 Delegate 子类时,由 {@link MongoDelegateBeanPostProcessor} + * 在 Bean 初始化后自动注入 MongoTemplate 与实体类型
  • + *
+ *

+ * 实现说明: + *

    + *
  • 查询条件通过反射读取实体非空字段,组装为 {@link Criteria}(等值匹配)并拼装到 {@link Query}
  • + *
  • ID 字段名默认为 "id",可通过构造器或 setter 自定义
  • + *
  • save 委托给 {@link MongoTemplate#save(Object)},自动判断新增或更新(依据 _id 是否存在)
  • + *
  • 分页使用 {@link PageRequest} + count,由 MongoTemplate 生成原生分页查询
  • + *
* * @param 持久化对象类型(PO) * @param 主键类型 @@ -29,17 +44,38 @@ @Slf4j public class MongoRepositoryDelegate implements RepositoryDelegate { + /** MongoDB 操作模板,承担实际文档操作 */ protected MongoTemplate mongoTemplate; + /** PO 实体类型,用于反射读取字段与 findOne/find */ protected Class entityClass; + /** 主键字段名,默认 "id",可被子类覆盖 */ protected String idFieldName; + /** + * 默认构造器,用于用户自定义子类场景。 + *

+ * 创建后由 {@link MongoDelegateBeanPostProcessor} 通过 setter 注入依赖。 + */ public MongoRepositoryDelegate() { } + /** + * 以默认主键字段名 "id" 构造 Delegate。 + * + * @param mongoTemplate MongoDB 操作模板 + * @param entityClass PO 实体类型 + */ public MongoRepositoryDelegate(MongoTemplate mongoTemplate, Class entityClass) { this(mongoTemplate, entityClass, "id"); } + /** + * 全参构造器,工厂自动创建场景使用。 + * + * @param mongoTemplate MongoDB 操作模板 + * @param entityClass PO 实体类型 + * @param idFieldName 主键字段名(用于构建按 ID 查询的 Criteria) + */ public MongoRepositoryDelegate(MongoTemplate mongoTemplate, Class entityClass, String idFieldName) { this.mongoTemplate = mongoTemplate; this.entityClass = entityClass; @@ -47,18 +83,41 @@ public MongoRepositoryDelegate(MongoTemplate mongoTemplate, Class entityClass log.info("MongoRepositoryDelegate initialized for entity: {}", entityClass.getSimpleName()); } + /** + * 注入 MongoTemplate,供 BeanPostProcessor 在自定义子类上调用。 + * + * @param mongoTemplate MongoDB 操作模板 + */ public void setMongoTemplate(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } + /** + * 注入 PO 实体类型,供 BeanPostProcessor 在自定义子类上调用。 + * + * @param entityClass PO 实体类型 + */ public void setEntityClass(Class entityClass) { this.entityClass = entityClass; } + /** + * 设置主键字段名,供自定义子类覆盖默认 "id"。 + * + * @param idFieldName 主键字段名 + */ public void setIdFieldName(String idFieldName) { this.idFieldName = idFieldName; } + /** + * 保存或更新文档。 + *

+ * 委托给 {@link MongoTemplate#save(Object)},由 MongoDB 依据 _id 自动判断新增或更新。 + * + * @param entity 实体对象,为 null 时返回 null + * @return 保存后的实体(与入参同一引用) + */ @Override public T save(T entity) { if (entity == null) { @@ -69,15 +128,27 @@ public T save(T entity) { return saved; } + /** + * 根据主键删除文档。 + * + * @param id 主键值,为 null 时不执行任何操作 + */ @Override public void removeById(ID id) { if (id != null) { + // 按主键字段构建等值条件并删除 Query query = new Query(Criteria.where(idFieldName).is(id)); mongoTemplate.remove(query, entityClass); log.debug("Removed entity: id={}", id); } } + /** + * 根据主键查询文档。 + * + * @param id 主键值,为 null 时返回 null + * @return 实体对象,未找到时返回 null + */ @Override public T findById(ID id) { if (id == null) { @@ -89,16 +160,36 @@ public T findById(ID id) { return entity; } + /** + * 根据主键查询(与 findById 等价,语义上用于"读模型")。 + * + * @param id 主键值 + * @return 实体对象,未找到时返回 null + */ @Override public T queryById(ID id) { return findById(id); } + /** + * 根据主键查询并以 {@link Optional} 包装返回。 + * + * @param id 主键值 + * @return 包含实体的 Optional,未找到时为 {@link Optional#empty()} + */ @Override public Optional queryByIdOptional(ID id) { return Optional.ofNullable(queryById(id)); } + /** + * 根据非空字段等值匹配查询单条记录。 + *

+ * 通过反射构建 {@link Query},取首条匹配;多于一条时仅返回首条。 + * + * @param condition 查询条件对象,为 null 时返回 null + * @return 首条匹配记录,无匹配时返回 null + */ @Override public T queryOne(T condition) { if (condition == null) { @@ -108,11 +199,25 @@ public T queryOne(T condition) { return mongoTemplate.findOne(query, entityClass); } + /** + * 根据条件查询单条记录,并以 {@link Optional} 包装返回。 + * + * @param condition 查询条件对象 + * @return 包含首条匹配记录的 Optional + */ @Override public Optional queryOneOptional(T condition) { return Optional.ofNullable(queryOne(condition)); } + /** + * 根据条件查询列表。 + *

+ * 条件为 null 时查询全部;否则按非空字段构建等值 {@link Query}。 + * + * @param condition 查询条件对象,可为 null + * @return 匹配的实体列表,无匹配时返回空列表 + */ @Override public List queryList(T condition) { if (condition == null) { @@ -122,19 +227,32 @@ public List queryList(T condition) { return mongoTemplate.find(query, entityClass); } + /** + * 分页查询。 + *

+ * 通过 {@link MongoTemplate#count(Query, Class)} 获取总数, + * 再用 {@link PageRequest} 切片查询当前页记录。 + * + * @param reqPage 分页请求(页码从 1 开始、每页大小,为 null 时取默认 1/10) + * @return 分页结果,含当前页、总页数、总条数、当前页记录 + */ @Override public ResPage queryPage(ReqPage reqPage) { + // MongoTemplate 页码从 0 开始,业务页码从 1 开始,需减 1 int pageNum = reqPage.getPage() != null ? reqPage.getPage() - 1 : 0; int pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; Query query = new Query(); long total = mongoTemplate.count(query, entityClass); + // 在原 Query 上叠加分页参数 Query pageQuery = query.with(PageRequest.of(pageNum, pageSize, Sort.unsorted())); List records = mongoTemplate.find(pageQuery, entityClass); ResPage resPage = new ResPage<>(); + // 返回业务侧时页码再加回 1 resPage.setCurrent((long) (pageNum + 1)); + // 总页数向上取整 resPage.setPages(total > 0 ? (total + pageSize - 1) / pageSize : 0); resPage.setSize((long) pageSize); resPage.setTotal(total); @@ -145,6 +263,12 @@ public ResPage queryPage(ReqPage reqPage) { return resPage; } + /** + * 反射读取条件对象非空字段,构建等值 {@link Query}。 + * + * @param condition 条件对象 + * @return 已填充等值 Criteria 的 Query + */ private Query buildQuery(T condition) { Query query = new Query(); try { @@ -153,6 +277,7 @@ private Query buildQuery(T condition) { field.setAccessible(true); Object value = field.get(condition); if (value != null) { + // 直接以字段名作为 key,等值匹配 query.addCriteria(Criteria.where(field.getName()).is(value)); } } @@ -162,6 +287,12 @@ private Query buildQuery(T condition) { return query; } + /** + * 收集类及其所有父类(直到 Object)的声明字段。 + * + * @param clazz 起始类 + * @return 全部字段数组 + */ private Field[] getAllFields(Class clazz) { List fields = new java.util.ArrayList<>(); while (clazz != null && clazz != Object.class) { @@ -171,6 +302,12 @@ private Field[] getAllFields(Class clazz) { return fields.toArray(new Field[0]); } + /** + * 批量保存实体(逐条 save)。 + * + * @param entities 实体列表,为 null 或空时返回空列表 + * @return 保存后的实体列表 + */ @Override public List saveBatch(List entities) { if (entities == null || entities.isEmpty()) { @@ -181,6 +318,11 @@ public List saveBatch(List entities) { .toList(); } + /** + * 根据主键列表批量删除(使用 $in 一次性删除)。 + * + * @param ids 主键列表,为 null 或空时不执行任何操作 + */ @Override public void removeBatchByIds(List ids) { if (ids != null && !ids.isEmpty()) { @@ -189,6 +331,12 @@ public void removeBatchByIds(List ids) { } } + /** + * 根据主键列表批量查询(使用 $in 一次性查询)。 + * + * @param ids 主键列表,为 null 或空时返回空列表 + * @return 匹配的实体列表 + */ @Override public List listByIds(List ids) { if (ids == null || ids.isEmpty()) { @@ -198,6 +346,12 @@ public List listByIds(List ids) { return mongoTemplate.find(query, entityClass); } + /** + * 按条件统计记录数。 + * + * @param condition 条件对象,为 null 时统计全部 + * @return 匹配的记录数 + */ @Override public long count(T condition) { if (condition == null) { @@ -207,6 +361,12 @@ public long count(T condition) { return mongoTemplate.count(query, entityClass); } + /** + * 判断是否存在匹配条件的记录。 + * + * @param condition 条件对象 + * @return 存在返回 true,否则 false + */ @Override public boolean exists(T condition) { return count(condition) > 0; diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeRepoFactory.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeRepoFactory.java index 7983c05..c898edf 100644 --- a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeRepoFactory.java +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeRepoFactory.java @@ -36,11 +36,25 @@ public MySqlLowCodeRepoFactory(SqlSessionFactory sqlSessionFactory) { this.sqlSessionFactory = sqlSessionFactory; } + /** + * 返回该工厂支持的存储类型,用于低代码路由引擎匹配。 + * + * @return 固定返回 {@link StorageType#MYSQL} + */ @Override public StorageType getType() { return StorageType.MYSQL; } + /** + * 创建 MySQL 低代码存储实例。 + *

+ * 内部构造 {@link MySqlLowCodeStorage},由其在初始化时自动检测数据库方言并完成建表。 + * + * @param schema 资源 schema 定义(表名、字段、主键、索引等) + * @param config 仓储配置(当前实现未使用,保留以匹配 SPI 签名) + * @return 低代码存储实例 + */ @Override public LowCodeStorage createStorage(ResourceSchema schema, RepositoryConfig config) { return new MySqlLowCodeStorage(schema, sqlSessionFactory); diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeStorage.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeStorage.java index 5c0b453..fd1f92e 100644 --- a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeStorage.java +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeStorage.java @@ -117,15 +117,23 @@ private DatabaseDialect detectDialect() { } } + /** + * 初始化存储结构:根据 schema 自动执行建表 DDL。 + *

+ * 通过 JDBC Statement 直接执行方言相关的 CREATE TABLE 语句;若表已存在则忽略异常, + * 仅打印告警日志,保证幂等。 + */ @Override public void initialize() { String tableName = schema.getTableName(); try (SqlSession session = sqlSessionFactory.openSession()) { Connection con = session.getConnection(); try (Statement stmt = con.createStatement()) { + // 直接执行自动生成的建表 DDL(含主键、唯一约束、索引) stmt.execute(buildCreateTableSql()); log.info("LowCode table initialized: {}", tableName); } catch (SQLException e) { + // 表已存在或其他 DDL 异常均视为幂等成功,仅告警 log.warn("Failed to initialize table {} (may already exist): {}", tableName, e.getMessage()); } } @@ -176,11 +184,14 @@ private String buildCreateTableSql() { sql.append(String.join(", ", columnDefs)); + // 主键约束:所有方言通用 if (!pkFields.isEmpty()) { sql.append(", PRIMARY KEY (").append(String.join(", ", pkFields)).append(")"); } + // 索引与唯一约束按方言适配:MySQL 在建表语句内联声明;其他方言之索引需单独 CREATE INDEX if (dialect == DatabaseDialect.MYSQL) { + // MySQL:UNIQUE KEY / KEY 内联到 CREATE TABLE for (String uk : uniqueFields) { sql.append(", UNIQUE KEY uk_").append(uk).append(" (").append(uk).append(")"); } @@ -189,10 +200,12 @@ private String buildCreateTableSql() { } sql.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } else { + // 非 MySQL:唯一约束用 CONSTRAINT 内联,索引需用单独的 CREATE INDEX 语句 for (String uk : uniqueFields) { sql.append(", CONSTRAINT uk_").append(uk).append(" UNIQUE (").append(uk).append(")"); } for (String idx : indexFields) { + // 拼接独立的 CREATE INDEX 语句(与建表语句以分号分隔) sql.append("); "); sql.append("CREATE INDEX IF NOT EXISTS idx_").append(idx) .append(" ON ").append(schema.getTableName()).append(" (").append(idx).append(")"); @@ -212,15 +225,19 @@ private String buildCreateTableSql() { */ private String mapFieldType(FieldSchema field) { FieldType type = field.getType(); + // 按方言适配:BOOLEAN/DATETIME/JSON 在 MySQL 与其他方言间存在差异 return switch (type) { case STRING -> "VARCHAR(" + field.getLength() + ")"; case LONG -> "BIGINT"; case INTEGER -> "INT"; + // MySQL 用 TINYINT(1) 表示布尔,H2/PG 等使用原生 BOOLEAN case BOOLEAN -> dialect == DatabaseDialect.MYSQL ? "TINYINT(1)" : "BOOLEAN"; case DECIMAL -> "DECIMAL(" + field.getPrecision() + "," + field.getScale() + ")"; + // MySQL 用 DATETIME,其他方言用 TIMESTAMP case DATETIME -> dialect == DatabaseDialect.MYSQL ? "DATETIME" : "TIMESTAMP"; case DATE -> "DATE"; case TEXT -> "TEXT"; + // MySQL 原生 JSON 类型,其他方言退化为 TEXT case JSON -> dialect == DatabaseDialect.MYSQL ? "JSON" : "TEXT"; default -> "VARCHAR(255)"; }; @@ -449,16 +466,33 @@ private Object executeInsertWithGeneratedKey(String sql, List params) { return null; } + /** + * 保存或更新一条记录(以 Map 形式)。 + *

+ * 处理流程: + *

    + *
  1. 拷贝入参,避免污染调用方 Map
  2. + *
  3. 按 {@link AutoFillType#CREATE} 与 {@link AutoFillType#CREATE_UPDATE} 自动填充时间字段
  4. + *
  5. 根据主键是否存在且库里已有同 ID 记录,决定走 doUpdate 或 doInsert
  6. + *
+ * + * @param data 数据 Map,键为字段名、值为字段值 + * @return 保存后的完整数据(含自动生成的主键、自动填充字段) + */ @Override public Map save(Map data) { + // 拷贝一份,避免污染调用方传入的 Map Map rowData = new LinkedHashMap<>(data); + // 创建场景填充:CREATE_TIME 等 fillAutoFields(rowData, AutoFillType.CREATE); + // 创建/更新双重填充:CREATE_UPDATE 字段 fillAutoFields(rowData, AutoFillType.CREATE_UPDATE); String idField = schema.getIdFieldName(); boolean hasId = rowData.containsKey(idField) && rowData.get(idField) != null; if (hasId) { + // 已带主键时先查库,存在则更新、不存在则插入 Map existing = findById(rowData.get(idField)); if (existing != null) { return doUpdate(rowData); @@ -511,11 +545,13 @@ private Map doInsert(Map data) { FieldSchema idField = schema.getIdField(); if (idField != null && idField.isAutoIncrement()) { + // 自增主键场景:走 JDBC 原生 PreparedStatement 以获取 RETURN_GENERATED_KEYS(不经过拦截器) Object key = executeInsertWithGeneratedKey(jdbcSql, jdbcParamList); if (key != null) { data.put(idField.getName(), key); } } else { + // 非自增主键场景:走 MyBatis SqlSession,SQL 经过拦截器链 executeUpdate(mybatisSql, namedParams, SqlCommandType.INSERT); } @@ -536,16 +572,19 @@ private Map doUpdate(Map data) { Map params = new HashMap<>(); String idFieldName = schema.getIdFieldName(); + // 更新场景填充:UPDATE_TIME 等(覆盖旧值由调用方决定,此处 putIfAbsent 仅在未显式设置时填充) fillAutoFields(data, AutoFillType.UPDATE); fillAutoFields(data, AutoFillType.CREATE_UPDATE); boolean first = true; for (Map.Entry entry : data.entrySet()) { String fieldName = entry.getKey(); + // 主键字段不进入 SET 子句,仅作为 WHERE 条件 if (fieldName.equals(idFieldName)) { params.put(fieldName, entry.getValue()); continue; } + // schema 外字段忽略,避免无效列 if (schema.getField(fieldName) == null) { continue; } @@ -564,6 +603,13 @@ private Map doUpdate(Map data) { return findById(data.get(idFieldName)); } + /** + * 根据主键删除记录。 + *

+ * 通过动态 MappedStatement 执行 DELETE,SQL 经过 MyBatis 拦截器链。 + * + * @param id 主键值 + */ @Override public void removeById(Object id) { String idFieldName = schema.getIdFieldName(); @@ -573,6 +619,14 @@ public void removeById(Object id) { executeUpdate(sql, params, SqlCommandType.DELETE); } + /** + * 根据主键查询单条记录。 + *

+ * 使用 schema 中明确列名替代 SELECT *,结果以 Map 形式返回。 + * + * @param id 主键值 + * @return 数据 Map,未找到时返回 null + */ @Override public Map findById(Object id) { String idFieldName = schema.getIdFieldName(); @@ -584,22 +638,48 @@ public Map findById(Object id) { return results.isEmpty() ? null : results.get(0); } + /** + * 根据主键查询(与 findById 等价,语义上用于"读模型")。 + * + * @param id 主键值 + * @return 数据 Map,未找到时返回 null + */ @Override public Map queryById(Object id) { return findById(id); } + /** + * 根据条件查询单条记录,取结果集首条。 + * + * @param queryParams 查询条件 Map,键为字段名、值为等值匹配值 + * @return 首条匹配记录,无匹配时返回 null + */ @Override public Map queryOne(Map queryParams) { List> list = queryList(queryParams); return list.isEmpty() ? null : list.get(0); } + /** + * 根据条件查询单条记录,并以 {@link Optional} 包装返回。 + * + * @param queryParams 查询条件 Map + * @return 包含首条匹配记录的 Optional + */ @Override public Optional> queryOneOptional(Map queryParams) { return Optional.ofNullable(queryOne(queryParams)); } + /** + * 根据条件等值匹配查询列表。 + *

+ * 仅 schema 中存在的字段才会进入 WHERE 子句,使用 AND 连接的等值条件。 + * + * @param queryParams 查询条件 Map,为 null 或空时等价于全表查询 + * @return 匹配记录列表,无匹配时返回空列表 + */ @Override public List> queryList(Map queryParams) { StringBuilder sql = new StringBuilder(); @@ -607,6 +687,7 @@ public List> queryList(Map queryParams) { Map params = new HashMap<>(); if (queryParams != null && !queryParams.isEmpty()) { + // 动态拼接 WHERE 子句:仅 schema 内字段参与,AND 连接等值条件 StringBuilder where = new StringBuilder(" WHERE "); boolean first = true; for (Map.Entry entry : queryParams.entrySet()) { @@ -629,8 +710,18 @@ public List> queryList(Map queryParams) { return executeSelect(sql.toString(), params); } + /** + * 分页查询。 + *

+ * 先 COUNT 总数,再按方言生成分页 SQL 取当前页记录。 + * total 为 0 时直接返回空记录,避免无意义查询。 + * + * @param reqPage 分页请求(页码、每页大小,为 null 时取默认 1/10) + * @return 分页结果,含当前页、总页数、总条数、当前页记录 + */ @Override public ResPage> queryPage(ReqPage reqPage) { + // 页码与每页大小兜底 long pageNum = reqPage.getPage() != null ? reqPage.getPage() : 1; long pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; @@ -641,14 +732,17 @@ public ResPage> queryPage(ReqPage reqPage) { page.setTotal(total); if (total == 0) { + // 无数据时直接返回,避免执行无意义查询 page.setRecords(Collections.emptyList()); page.setPages(0L); return page; } + // 计算总页数(向上取整) long pages = total / pageSize + (total % pageSize == 0 ? 0 : 1); page.setPages(pages); + // 按方言生成分页 SQL(MySQL LIMIT、Oracle ROWNUM、PG/SQL Server OFFSET FETCH) String baseSql = "SELECT " + buildSelectColumns() + " FROM " + schema.getTableName(); String paginationSql = buildPaginationSql(baseSql, pageNum, pageSize); @@ -689,6 +783,14 @@ private String buildPaginationSql(String baseSql, long pageNum, long pageSize) { } } + /** + * 批量保存记录(逐条调用 save)。 + *

+ * 未使用批量 INSERT,每条记录独立处理自动填充与 upsert 判断。 + * + * @param dataList 数据列表,为 null 或空时返回空列表 + * @return 保存后的数据列表(含自动生成的主键与自动填充字段) + */ @Override public List> saveBatch(List> dataList) { if (dataList == null || dataList.isEmpty()) { @@ -701,12 +803,20 @@ public List> saveBatch(List> dataList) { return result; } + /** + * 根据主键列表批量删除。 + *

+ * 通过 IN 子句一次性删除,每个 ID 用独立命名参数(id_0、id_1、…)。 + * + * @param ids 主键列表,为 null 或空时不执行任何操作 + */ @Override public void removeBatchByIds(List ids) { if (ids == null || ids.isEmpty()) { return; } String idFieldName = schema.getIdFieldName(); + // 拼接 IN 子句的命名参数占位符:#{id_0}, #{id_1}, ... StringBuilder placeholders = new StringBuilder(); Map params = new HashMap<>(); for (int i = 0; i < ids.size(); i++) { @@ -721,12 +831,21 @@ public void removeBatchByIds(List ids) { executeUpdate(sql, params, SqlCommandType.DELETE); } + /** + * 根据主键列表批量查询。 + *

+ * 通过 IN 子句一次性查询,使用 schema 中的明确列名替代 SELECT *。 + * + * @param ids 主键列表,为 null 或空时返回空列表 + * @return 匹配记录列表 + */ @Override public List> listByIds(List ids) { if (ids == null || ids.isEmpty()) { return Collections.emptyList(); } String idFieldName = schema.getIdFieldName(); + // 拼接 IN 子句的命名参数占位符:#{id_0}, #{id_1}, ... StringBuilder placeholders = new StringBuilder(); Map params = new HashMap<>(); for (int i = 0; i < ids.size(); i++) { @@ -742,6 +861,15 @@ public List> listByIds(List ids) { return executeSelect(sql, params); } + /** + * 按条件统计记录数。 + *

+ * 通过动态注册返回 Long 的 COUNT MappedStatement 执行;结果兼容 {@link Number} 类型, + * 统一转为 long 返回。 + * + * @param queryParams 查询条件 Map,为 null 或空时统计全表 + * @return 匹配的记录数 + */ @Override public long count(Map queryParams) { StringBuilder sql = new StringBuilder(); @@ -749,6 +877,7 @@ public long count(Map queryParams) { Map params = new HashMap<>(); if (queryParams != null && !queryParams.isEmpty()) { + // 动态拼接 WHERE 子句:仅 schema 内字段参与,AND 连接等值条件 StringBuilder where = new StringBuilder(" WHERE "); boolean first = true; for (Map.Entry entry : queryParams.entrySet()) { @@ -768,11 +897,13 @@ public long count(Map queryParams) { } } + // COUNT 走专用 Long ResultMap 的 MappedStatement String statementId = nextStatementId("count"); try { registerCountStatement(statementId, sql.toString()); try (SqlSession session = sqlSessionFactory.openSession(true)) { Object result = session.selectOne(statementId, params); + // 兼容不同驱动返回的 Number 子类(Long/BigInteger 等) if (result instanceof Number) { return ((Number) result).longValue(); } @@ -783,6 +914,12 @@ public long count(Map queryParams) { } } + /** + * 判断是否存在匹配条件的记录。 + * + * @param queryParams 查询条件 Map + * @return 存在返回 true,否则 false + */ @Override public boolean exists(Map queryParams) { return count(queryParams) > 0; @@ -800,8 +937,10 @@ public boolean exists(Map queryParams) { private void fillAutoFields(Map data, AutoFillType fillType) { LocalDateTime now = LocalDateTime.now(); for (FieldSchema field : schema.getFields().values()) { + // 仅处理与当前填充类型匹配的字段(CREATE / UPDATE / CREATE_UPDATE) if (field.getAutoFill() == fillType) { String name = field.getName(); + // 按字段类型生成对应的时间值:DATETIME 用 LocalDateTime,DATE 用 LocalDate switch (field.getType()) { case DATETIME -> data.putIfAbsent(name, now); case DATE -> data.putIfAbsent(name, now.toLocalDate()); diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateBeanPostProcessor.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateBeanPostProcessor.java index f8d1cb3..4486a52 100644 --- a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateBeanPostProcessor.java +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateBeanPostProcessor.java @@ -8,26 +8,66 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; +/** + * MyBatis Plus RepositoryDelegate 的 BeanPostProcessor,负责为用户自定义 Delegate 子类自动注入 BaseMapper。 + *

+ * 在仓储框架中,业务方可继承 {@link MybatisPlusRepositoryDelegate} 实现自定义 Delegate,并通过 + * {@link DelegateFor} 注解声明其服务的 PO 类型。本后处理器在 Bean 初始化完成后: + *

    + *
  1. 识别所有 {@link MybatisPlusRepositoryDelegate} 类型的 Bean
  2. + *
  3. 读取其 {@link DelegateFor#po()} 指定的 PO 类型
  4. + *
  5. 按 PO 包名约定({@code .po.} → {@code .mapper.}、PO 后缀 → Mapper)或 Bean 名称查找对应 BaseMapper
  6. + *
  7. 通过 setter 反向注入 BaseMapper 与 PO 类型,使自定义 Delegate 可正常工作
  8. + *
+ *

+ * 与 {@link MybatisPlusDelegateFactory} 的分工:工厂负责"无自定义 Delegate 时自动创建", + * 本处理器负责"已有自定义 Delegate 时补齐依赖",二者协同保证 RepositoryFacade 总能拿到可用的 Delegate。 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Slf4j public class MybatisPlusDelegateBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware { + /** Spring 上下文,用于按类型或名称查找 BaseMapper Bean */ private ApplicationContext applicationContext; + /** + * 注入 Spring 应用上下文,供后续按类型/名称查询 Bean。 + * + * @param applicationContext Spring 应用上下文 + * @throws BeansException 上下文注入异常 + */ @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } + /** + * 在 Bean 初始化完成后,对自定义 MybatisPlusRepositoryDelegate 实现类注入 BaseMapper。 + *

+ * 仅当 Bean 同时满足:是 {@link MybatisPlusRepositoryDelegate} 实例、且类上标注了 + * {@link DelegateFor} 注解、注解显式指定了 PO 类型时,才执行注入。 + * + * @param bean 待处理的 Bean 实例 + * @param beanName Bean 名称 + * @return 原始 Bean(已注入依赖),未匹配类型时原样返回 + * @throws BeansException 处理过程中的异常 + */ @SuppressWarnings({"unchecked", "rawtypes"}) @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof MybatisPlusRepositoryDelegate) { MybatisPlusRepositoryDelegate delegate = (MybatisPlusRepositoryDelegate) bean; + // 读取 @DelegateFor 注解,识别该 Delegate 服务的 PO 类型 DelegateFor annotation = bean.getClass().getAnnotation(DelegateFor.class); if (annotation != null && annotation.po() != void.class) { try { + // 按 PO 类型约定查找对应的 BaseMapper Object mapper = findMapperByPoClass(annotation.po()); if (mapper != null) { + // 反向注入 BaseMapper 与 PO 类型,使自定义 Delegate 可正常工作 delegate.setBaseMapper((BaseMapper) mapper); delegate.setEntityClass(annotation.po()); log.info("Injected BaseMapper into MybatisPlusRepositoryDelegate: {}", beanName); @@ -42,7 +82,20 @@ public Object postProcessAfterInitialization(Object bean, String beanName) throw return bean; } + /** + * 根据 PO 类查找对应的 BaseMapper Bean。 + *

+ * 查找策略(按优先级): + *

    + *
  1. 约定包名转换:将 {@code xxx.po.XxxPO} 推导为 {@code xxx.mapper.XxxMapper},按类型获取 Bean
  2. + *
  3. 若类型不存在,再用简单名(如 {@code XxxMapper})按 Bean 名称获取
  4. + *
+ * + * @param poClass PO 类型 + * @return 对应的 BaseMapper Bean,未找到返回 null + */ private Object findMapperByPoClass(Class poClass) { + // 约定:po 包下的 XxxPO 对应 mapper 包下的 XxxMapper String poClassName = poClass.getName(); String mapperClassName = poClassName.replace(".po.", ".mapper.") .replace("PO", "Mapper"); @@ -54,14 +107,15 @@ private Object findMapperByPoClass(Class poClass) { } catch (Exception e) { log.debug("Failed to get mapper bean: {}", e.getMessage()); } - + + // 兜底:按 Bean 简单名查找(如 "XxxMapper") String simpleMapperName = poClass.getSimpleName().replace("PO", "Mapper"); try { return applicationContext.getBean(simpleMapperName); } catch (Exception e) { log.debug("Failed to get mapper by name: {}", simpleMapperName); } - + return null; } } \ No newline at end of file diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateFactory.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateFactory.java index 1e2d22f..64b7c26 100644 --- a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateFactory.java +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateFactory.java @@ -9,7 +9,12 @@ /** * MyBatis Plus 仓储委托工厂 *

- * 自动创建 MybatisPlusRepositoryDelegate 实例 + * 实现 {@link RepositoryDelegateFactory} SPI,自动创建 {@link MybatisPlusRepositoryDelegate} 实例。 + * 在仓储框架中,当 {@code RepositoryFacade} 找不到用户自定义的 Delegate 时,会通过本工厂按 PO 类型 + * 查找对应的 {@link BaseMapper},并创建默认 Delegate 实例。 + *

+ * 与 {@link MybatisPlusDelegateBeanPostProcessor} 的分工:本工厂负责"无自定义 Delegate 时创建默认实现", + * BeanPostProcessor 负责"已有自定义子类时补齐依赖"。 * * @author chuck * @version 1.0.1 @@ -17,17 +22,38 @@ */ public class MybatisPlusDelegateFactory implements RepositoryDelegateFactory { + /** Spring 上下文,用于按类型/名称查找 BaseMapper Bean */ private final ApplicationContext applicationContext; + /** + * 构造工厂,注入 Spring 应用上下文。 + * + * @param applicationContext Spring 应用上下文,用于查找 Mapper Bean + */ public MybatisPlusDelegateFactory(ApplicationContext applicationContext) { this.applicationContext = applicationContext; } + /** + * 返回该工厂支持的仓储类型,用于 SPI 路由匹配。 + * + * @return 固定返回 {@link RepositoryType#MYBATIS_PLUS} + */ @Override public RepositoryType getType() { return RepositoryType.MYBATIS_PLUS; } + /** + * 为指定 PO 类型创建 {@link MybatisPlusRepositoryDelegate} 实例。 + *

+ * 内部按约定({@code .po.} → {@code .mapper.}、PO 后缀 → Mapper)查找对应 BaseMapper, + * 找不到时返回 null(由上层 RepositoryFacade 继续尝试其他工厂或抛出异常)。 + * + * @param poClass PO 实体类型 + * @param idClass 主键类型(当前实现未使用,保留以匹配 SPI 签名) + * @return 已注入 BaseMapper 的 Delegate 实例;未找到 Mapper 时返回 null + */ @Override @SuppressWarnings({"unchecked", "rawtypes"}) public RepositoryDelegate createDelegate(Class poClass, Class idClass) { @@ -42,7 +68,20 @@ public RepositoryType getType() { } } + /** + * 根据 PO 类查找对应的 BaseMapper Bean。 + *

+ * 查找策略(按优先级): + *

    + *
  1. 约定包名转换:将 {@code xxx.po.XxxPO} 推导为 {@code xxx.mapper.XxxMapper},按类型获取 Bean
  2. + *
  3. 失败时遍历所有 Bean 名称,匹配以简单 Mapper 名(如 {@code XxxMapper})结尾的 Bean
  4. + *
+ * + * @param poClass PO 类型 + * @return 对应的 BaseMapper Bean,未找到返回 null + */ private Object findMapperByPoClass(Class poClass) { + // 约定:po 包下的 XxxPO 对应 mapper 包下的 XxxMapper String poClassName = poClass.getName(); String mapperClassName = poClassName.replace(".po.", ".mapper.") .replace("PO", "Mapper"); @@ -50,6 +89,7 @@ private Object findMapperByPoClass(Class poClass) { Class mapperClass = Class.forName(mapperClassName); return applicationContext.getBean(mapperClass); } catch (Exception e) { + // 兜底:遍历 Bean 名称,匹配以 XxxMapper 结尾的 Bean String simpleMapperName = poClass.getSimpleName().replace("PO", "Mapper"); for (String beanName : applicationContext.getBeanDefinitionNames()) { if (beanName.endsWith(simpleMapperName)) { diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusRepositoryDelegate.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusRepositoryDelegate.java index ce5b5a6..55202f2 100644 --- a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusRepositoryDelegate.java +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusRepositoryDelegate.java @@ -15,20 +15,68 @@ import java.util.List; import java.util.Optional; +/** + * 基于 MyBatis Plus 的 RepositoryDelegate 适配实现 + *

+ * 该类是 RepositoryDelegate SPI 在 MyBatis Plus 存储类型下的标准实现,委托 + * {@link BaseMapper} 完成单表的 CRUD 操作。它在仓储框架中扮演"具体存储适配层"的角色: + *

    + *
  • 上层由 {@code RepositoryFacade} 统一暴露给业务方,本类不直接面向业务
  • + *
  • 当用户未提供自定义 Delegate 时,由 {@link MybatisPlusDelegateFactory} 自动创建本类实例
  • + *
  • 当用户提供自定义 Delegate 子类时,由 {@link MybatisPlusDelegateBeanPostProcessor} + * 在 Bean 初始化后自动注入 BaseMapper 与实体类型
  • + *
+ *

+ * 实现说明: + *

    + *
  • 查询条件通过反射读取实体非空字段,按"等值匹配"组装 {@link QueryWrapper},并将驼峰字段名 + * 转为下划线列名以匹配数据库列
  • + *
  • ID 字段名默认为 "id",可通过构造器或 setter 自定义
  • + *
  • save 方法根据 ID 是否为空自动区分 insert / update
  • + *
  • 分页委托给 MyBatis Plus 的 {@link Page},由分页拦截器生成方言相关 SQL
  • + *
+ * + * @param 实体(PO)类型 + * @param 主键类型 + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Slf4j public class MybatisPlusRepositoryDelegate implements RepositoryDelegate { + /** 底层 MyBatis Plus Mapper,由工厂或 BeanPostProcessor 注入 */ protected BaseMapper baseMapper; + /** PO 实体类型,用于反射读取字段 */ protected Class entityClass; + /** 主键字段名,默认 "id",可被子类覆盖 */ protected String idFieldName; + /** + * 默认构造器,用于用户自定义子类场景。 + *

+ * 创建后由 {@link MybatisPlusDelegateBeanPostProcessor} 通过 setter 注入依赖。 + */ public MybatisPlusRepositoryDelegate() { } + /** + * 以默认主键字段名 "id" 构造 Delegate。 + * + * @param baseMapper MyBatis Plus 的 BaseMapper,承担实际 CRUD + * @param entityClass PO 实体类型 + */ public MybatisPlusRepositoryDelegate(BaseMapper baseMapper, Class entityClass) { this(baseMapper, entityClass, "id"); } + /** + * 全参构造器,工厂自动创建场景使用。 + * + * @param baseMapper MyBatis Plus 的 BaseMapper + * @param entityClass PO 实体类型 + * @param idFieldName 主键字段名(用于反射读取 ID 值) + */ public MybatisPlusRepositoryDelegate(BaseMapper baseMapper, Class entityClass, String idFieldName) { this.baseMapper = baseMapper; this.entityClass = entityClass; @@ -36,23 +84,47 @@ public MybatisPlusRepositoryDelegate(BaseMapper baseMapper, Class entityCl log.info("MybatisPlusRepositoryDelegate initialized for entity: {}", entityClass.getSimpleName()); } + /** + * 注入 BaseMapper,供 BeanPostProcessor 在自定义子类上调用。 + * + * @param baseMapper MyBatis Plus 的 BaseMapper + */ public void setBaseMapper(BaseMapper baseMapper) { this.baseMapper = baseMapper; } + /** + * 注入 PO 实体类型,供 BeanPostProcessor 在自定义子类上调用。 + * + * @param entityClass PO 实体类型 + */ public void setEntityClass(Class entityClass) { this.entityClass = entityClass; } + /** + * 设置主键字段名,供自定义子类覆盖默认 "id"。 + * + * @param idFieldName 主键字段名 + */ public void setIdFieldName(String idFieldName) { this.idFieldName = idFieldName; } + /** + * 保存或更新实体。 + *

+ * 根据 ID 字段值是否为空自动选择策略:ID 为空执行 insert,否则执行 updateById。 + * + * @param entity 实体对象,为 null 时直接返回 null + * @return 保存后的实体(与入参同一引用) + */ @Override public T save(T entity) { if (entity == null) { return null; } + // 反射读取主键值,决定走新增还是更新分支 ID id = getIdValue(entity); if (id == null) { baseMapper.insert(entity); @@ -63,6 +135,11 @@ public T save(T entity) { return entity; } + /** + * 根据主键删除记录。 + * + * @param id 主键值,为 null 时不执行任何操作 + */ @Override public void removeById(ID id) { if (id != null) { @@ -71,6 +148,12 @@ public void removeById(ID id) { } } + /** + * 根据主键查询实体。 + * + * @param id 主键值,为 null 时返回 null + * @return 实体对象,未找到时返回 null + */ @Override public T findById(ID id) { if (id == null) { @@ -81,16 +164,36 @@ public T findById(ID id) { return entity; } + /** + * 根据主键查询(与 findById 等价,语义上用于"读模型")。 + * + * @param id 主键值 + * @return 实体对象,未找到时返回 null + */ @Override public T queryById(ID id) { return findById(id); } + /** + * 根据主键查询并以 {@link Optional} 包装返回,避免空指针。 + * + * @param id 主键值 + * @return 包含实体的 Optional,未找到时为 {@link Optional#empty()} + */ @Override public Optional queryByIdOptional(ID id) { return Optional.ofNullable(queryById(id)); } + /** + * 根据非空字段等值匹配查询单条记录。 + *

+ * 将条件对象非空字段组装为 {@link QueryWrapper},取结果集第一条;多于一条时仅返回首条。 + * + * @param condition 查询条件对象,为 null 时返回 null + * @return 首条匹配记录,无匹配时返回 null + */ @Override public T queryOne(T condition) { if (condition == null) { @@ -101,11 +204,25 @@ public T queryOne(T condition) { return results.isEmpty() ? null : results.get(0); } + /** + * 根据条件查询单条记录,并以 {@link Optional} 包装返回。 + * + * @param condition 查询条件对象 + * @return 包含首条匹配记录的 Optional + */ @Override public Optional queryOneOptional(T condition) { return Optional.ofNullable(queryOne(condition)); } + /** + * 根据条件查询列表。 + *

+ * 条件为 null 时等价于全表查询;否则按非空字段等值匹配。 + * + * @param condition 查询条件对象,可为 null + * @return 匹配的实体列表,无匹配时返回空列表 + */ @Override public List queryList(T condition) { if (condition == null) { @@ -115,14 +232,24 @@ public List queryList(T condition) { return baseMapper.selectList(queryWrapper); } + /** + * 分页查询。 + *

+ * 委托 MyBatis Plus 的 {@link Page} 执行分页,实际分页 SQL 由分页拦截器按方言生成。 + * + * @param reqPage 分页请求(页码、每页大小,为 null 时取默认 1/10) + * @return 分页结果,包含当前页、总页数、总条数、当前页记录 + */ @Override public ResPage queryPage(ReqPage reqPage) { + // 页码与每页大小兜底,避免 NPE long pageNum = reqPage.getPage() != null ? reqPage.getPage() : 1; long pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; Page page = new Page<>(pageNum, pageSize); IPage result = baseMapper.selectPage(page, null); + // 将 MyBatis Plus 分页结果转写为统一 ResPage ResPage resPage = new ResPage<>(); resPage.setCurrent(result.getCurrent()); resPage.setPages(result.getPages()); @@ -135,6 +262,14 @@ public ResPage queryPage(ReqPage reqPage) { return resPage; } + /** + * 根据条件对象的非空字段构建等值查询 {@link QueryWrapper}。 + *

+ * 反射读取所有字段(含父类),将驼峰字段名转为下划线列名后拼接 eq 条件。 + * + * @param condition 条件对象 + * @return 已填充等值条件的 QueryWrapper + */ private QueryWrapper buildQueryWrapper(T condition) { QueryWrapper queryWrapper = new QueryWrapper<>(); try { @@ -143,6 +278,7 @@ private QueryWrapper buildQueryWrapper(T condition) { field.setAccessible(true); Object value = field.get(condition); if (value != null) { + // 字段名驼峰转下划线,以匹配数据库列名 queryWrapper.eq(camelToUnderline(field.getName()), value); } } @@ -152,6 +288,12 @@ private QueryWrapper buildQueryWrapper(T condition) { return queryWrapper; } + /** + * 收集类及其所有父类(直到 Object)的声明字段。 + * + * @param clazz 起始类 + * @return 全部字段数组 + */ private Field[] getAllFields(Class clazz) { List fields = new java.util.ArrayList<>(); while (clazz != null && clazz != Object.class) { @@ -161,6 +303,12 @@ private Field[] getAllFields(Class clazz) { return fields.toArray(new Field[0]); } + /** + * 反射读取实体的主键字段值。 + * + * @param entity 实体对象 + * @return 主键值,无法读取时返回 null + */ @SuppressWarnings("unchecked") private ID getIdValue(T entity) { try { @@ -175,11 +323,18 @@ private ID getIdValue(T entity) { return null; } + /** + * 沿继承链递归查找主键字段。 + * + * @param clazz 起始类 + * @return 主键 Field,未找到返回 null + */ private Field findIdField(Class clazz) { try { Field field = clazz.getDeclaredField(idFieldName); return field; } catch (NoSuchFieldException e) { + // 当前类未声明 ID 字段,继续向父类递归 if (clazz.getSuperclass() != null && clazz.getSuperclass() != Object.class) { return findIdField(clazz.getSuperclass()); } @@ -187,6 +342,12 @@ private Field findIdField(Class clazz) { } } + /** + * 驼峰命名转下划线命名(如 userName → user_name),用于对齐数据库列名。 + * + * @param param 原始字段名 + * @return 下划线命名,入参为空时返回空字符串 + */ private String camelToUnderline(String param) { if (param == null || "".equals(param.trim())) { return ""; @@ -205,6 +366,12 @@ private String camelToUnderline(String param) { return sb.toString(); } + /** + * 批量保存实体(逐条 insert)。 + * + * @param entities 实体列表,为 null 或空时返回空列表 + * @return 入参列表引用(已写入数据库) + */ @Override public List saveBatch(List entities) { if (entities == null || entities.isEmpty()) { @@ -214,6 +381,11 @@ public List saveBatch(List entities) { return entities; } + /** + * 根据主键列表批量删除。 + * + * @param ids 主键列表,为 null 或空时不执行任何操作 + */ @Override public void removeBatchByIds(List ids) { if (ids != null && !ids.isEmpty()) { @@ -223,6 +395,12 @@ public void removeBatchByIds(List ids) { } } + /** + * 根据主键列表批量查询。 + * + * @param ids 主键列表,为 null 或空时返回空列表 + * @return 匹配的实体列表 + */ @Override public List listByIds(List ids) { if (ids == null || ids.isEmpty()) { @@ -233,6 +411,12 @@ public List listByIds(List ids) { .toList()); } + /** + * 按条件统计记录数。 + * + * @param condition 条件对象,为 null 时统计全表 + * @return 匹配的记录数 + */ @Override public long count(T condition) { if (condition == null) { @@ -242,6 +426,12 @@ public long count(T condition) { return baseMapper.selectCount(queryWrapper); } + /** + * 判断是否存在匹配条件的记录。 + * + * @param condition 条件对象 + * @return 存在返回 true,否则 false + */ @Override public boolean exists(T condition) { return count(condition) > 0; diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java index 819e81f..3156f0b 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java @@ -12,16 +12,50 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * schedule-starter 的 Spring Boot 自动配置类。 + * + *

该配置类负责装配本地调度体系的核心 Bean,包括:

+ *
    + *
  • {@link TaskHandlerRegistry}:handler 注册表(默认实现为 {@link DefaultTaskHandlerRegistry})
  • + *
  • {@link TaskScheduler}:本地任务调度器(默认实现为 {@link LocalThreadTaskScheduler})
  • + *
  • Spring 标准 {@link org.springframework.scheduling.TaskScheduler}:通过 + * {@link SpringTaskSchedulerAdapter} 适配本地调度器,便于上层框架(如 Spring 自带调度) + * 复用本地线程池
  • + *
+ * + *

设计意图:所有 Bean 均通过 {@code @ConditionalOnMissingBean} / {@code @ConditionalOnBean} + * 进行条件装配,业务方可通过自定义 Bean 覆盖任一默认实现。同时,xxljob-starter 中的 + * {@code AutoXxlJobConfiguration} 通过 {@code @AutoConfigureBefore} 在本配置类之前装配, + * 当 XXL-Job 启用时其 {@link TaskScheduler} Bean 会优先注册,从而覆盖本地实现。

+ */ @Configuration @EnableConfigurationProperties(ScheduleProperties.class) public class AutoScheduleConfiguration { + /** + * 装配默认的 handler 注册表。 + * + *

仅当容器中不存在自定义 {@link TaskHandlerRegistry} 时生效。

+ * + * @return 默认实现 {@link DefaultTaskHandlerRegistry} + */ @Bean @ConditionalOnMissingBean(TaskHandlerRegistry.class) public TaskHandlerRegistry taskHandlerRegistry() { return new DefaultTaskHandlerRegistry(); } + /** + * 装配本地任务调度器。 + * + *

仅当容器中不存在自定义 {@link TaskScheduler} 时生效。线程池大小取自 + * {@link ScheduleProperties#getPoolSize()},若为空则回退到 JVM 可用处理器核数。

+ * + * @param scheduleProperties 调度配置属性 + * @param handlerRegistry handler 注册表,由调度器在执行任务时查找 handler + * @return 本地调度器实例 {@link LocalThreadTaskScheduler} + */ @Bean @ConditionalOnMissingBean(TaskScheduler.class) public TaskScheduler taskScheduler(ScheduleProperties scheduleProperties, TaskHandlerRegistry handlerRegistry) { @@ -29,6 +63,17 @@ public TaskScheduler taskScheduler(ScheduleProperties scheduleProperties, TaskHa return new LocalThreadTaskScheduler(poolSize != null ? poolSize : Runtime.getRuntime().availableProcessors(), handlerRegistry); } + /** + * 装配 Spring 标准 {@link org.springframework.scheduling.TaskScheduler} 适配器。 + * + *

仅当容器中存在 {@link LocalThreadTaskScheduler} Bean 时生效, + * 即仅本地调度生效时才提供 Spring 适配。若 XXL-Job 启用并覆盖了本地调度, + * 则该 Bean 不会被创建。

+ * + * @param taskScheduler 本地调度器实例 + * @param handlerRegistry handler 注册表 + * @return Spring 标准 TaskScheduler 适配器 + */ @Bean @ConditionalOnBean(LocalThreadTaskScheduler.class) public org.springframework.scheduling.TaskScheduler springTaskScheduler(LocalThreadTaskScheduler taskScheduler, TaskHandlerRegistry handlerRegistry) { diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/properties/ScheduleProperties.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/properties/ScheduleProperties.java index ffec34d..a3f73f3 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/properties/ScheduleProperties.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/properties/ScheduleProperties.java @@ -3,9 +3,30 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; +/** + * schedule-starter 的配置属性类,前缀 {@code structure.schedule}。 + * + *

该类承载本地调度器({@link cn.structure.infra.schedule.LocalThreadTaskScheduler}) + * 的可配置项,由 {@code AutoScheduleConfiguration} 通过 + * {@code @EnableConfigurationProperties} 装配并注入到调度器构造中。

+ * + *

配置示例:

+ *
+ * structure:
+ *   schedule:
+ *     pool-size: 8
+ * 
+ */ @Data @ConfigurationProperties(prefix = "structure.schedule") public class ScheduleProperties { + /** + * 调度线程池大小。 + * + *

该值将作为 {@link java.util.concurrent.ScheduledExecutorService} 的核心线程数, + * 决定本地调度器可并行执行的任务数上限。若未显式配置,默认取 JVM 可用处理器核数 + * ({@code Runtime.getRuntime().availableProcessors()})。

+ */ private Integer poolSize = Runtime.getRuntime().availableProcessors(); } \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistry.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistry.java index da47820..7c19ff7 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistry.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/DefaultTaskHandlerRegistry.java @@ -5,11 +5,34 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * {@link TaskHandlerRegistry} 的默认实现,基于 {@link ConcurrentHashMap} 维护 handler 映射。 + * + *

设计意图:使用 {@code ConcurrentHashMap} 保证多线程并发注册/查找/注销时的线程安全, + * 满足调度器在多线程触发场景下对 handler 的并发访问需求。该实现由 + * {@code AutoScheduleConfiguration} 在容器中不存在自定义 {@link TaskHandlerRegistry} + * 时作为默认 Bean 装配。

+ * + *

协作关系:由 {@link LocalThreadTaskScheduler} 持有引用,调度前调用 + * {@link #contains(String)} 校验、调度时调用 {@link #get(String)} 取出 handler 执行。

+ */ @Slf4j public class DefaultTaskHandlerRegistry implements TaskHandlerRegistry { + /** + * handler 名称到 handler 实例的并发映射表。 + */ private final Map handlerMap = new ConcurrentHashMap<>(); + /** + * 注册任务处理器。 + * + *

若 {@code handlerName} 已存在,则覆盖原有 handler。

+ * + * @param handlerName 处理器名称,不能为 {@code null} + * @param handler 处理器实例,不能为 {@code null} + * @throws IllegalArgumentException 当 handlerName 或 handler 为 {@code null} 时抛出 + */ @Override public void register(String handlerName, TaskHandler handler) { if (handlerName == null || handler == null) { @@ -19,17 +42,36 @@ public void register(String handlerName, TaskHandler handler) { log.info("Registered task handler: {}", handlerName); } + /** + * 根据处理器名称获取任务处理器。 + * + * @param handlerName 处理器名称 + * @return 对应的处理器实例;若未注册则返回 {@code null} + */ @Override public TaskHandler get(String handlerName) { return handlerMap.get(handlerName); } + /** + * 注销指定名称的任务处理器。 + * + *

若 {@code handlerName} 未注册则静默忽略。

+ * + * @param handlerName 处理器名称 + */ @Override public void unregister(String handlerName) { handlerMap.remove(handlerName); log.info("Unregistered task handler: {}", handlerName); } + /** + * 判断指定名称的任务处理器是否已注册。 + * + * @param handlerName 处理器名称 + * @return 已注册返回 {@code true},否则返回 {@code false} + */ @Override public boolean contains(String handlerName) { return handlerMap.containsKey(handlerName); diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java index bc0842e..d1a8dfc 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java @@ -10,43 +10,107 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +/** + * 基于 {@link ScheduledExecutorService} 的本地任务调度器默认实现。 + * + *

设计意图:提供单机环境下的轻量级任务调度能力,无需依赖外部组件 + * (如 Quartz、XXL-Job 调度中心),适用于中小规模应用或开发调试场景。 + * 通过 SPI 接口 {@link TaskScheduler} 暴露,可被 xxljob-starter 中的 + * {@code XxlJobTaskScheduler} 在分布式场景下覆盖。

+ * + *

核心特性:

+ *
    + *
  • 守护线程池:创建的线程均为 daemon 线程,JVM 退出时不会阻塞
  • + *
  • 错误隔离:每个任务的执行都被 try-catch 包裹,单个任务的异常不会 + * 影响其他任务或导致调度线程死亡
  • + *
  • 幂等调度:{@link #schedule(ScheduleTask)} 在创建新任务前会先移除同 taskId 旧任务
  • + *
  • CRON 简化:本地不解析 CRON 表达式,而是采用 1 秒级粒度的固定频率轮询, + * 不支持秒级以下精度——这是出于实现简化的有意设计
  • + *
+ * + *

协作关系:依赖 {@link TaskHandlerRegistry} 完成 handler 查找; + * 由 {@code AutoScheduleConfiguration} 在缺少自定义 {@link TaskScheduler} 时装配。

+ */ @Slf4j public class LocalThreadTaskScheduler implements TaskScheduler { + /** + * 底层调度线程池,所有任务的触发由该线程池驱动。 + */ private final ScheduledExecutorService executorService; + /** + * taskId 到其 {@link ScheduledFuture} 的映射,用于取消/暂停任务。 + */ private final Map> futureMap = new ConcurrentHashMap<>(); + /** + * taskId 到任务元信息的映射,用于查询任务信息和状态管理。 + */ private final Map taskMap = new ConcurrentHashMap<>(); + /** + * handler 注册表,调度任务时按 handlerName 查找对应执行逻辑。 + */ private final TaskHandlerRegistry handlerRegistry; + /** + * 构造方法,使用 JVM 默认可用处理器核数作为线程池大小。 + * + * @param handlerRegistry handler 注册表 + */ public LocalThreadTaskScheduler(TaskHandlerRegistry handlerRegistry) { this(Runtime.getRuntime().availableProcessors(), handlerRegistry); } + /** + * 构造方法,可指定线程池大小。 + * + *

创建的线程均为守护线程(daemon),线程名前缀为 {@code structure-schedule-}。

+ * + * @param poolSize 调度线程池核心线程数 + * @param handlerRegistry handler 注册表 + */ public LocalThreadTaskScheduler(int poolSize, TaskHandlerRegistry handlerRegistry) { this.executorService = Executors.newScheduledThreadPool(poolSize, r -> { Thread thread = new Thread(r); thread.setName("structure-schedule-" + thread.getId()); - thread.setDaemon(true); + thread.setDaemon(true); // 守护线程:JVM 退出时自动结束,避免阻塞应用关闭 return thread; }); this.handlerRegistry = handlerRegistry; log.info("LocalThreadTaskScheduler initialized with pool size: {}", poolSize); } + /** + * 调度一个任务。 + * + *

调度流程:

+ *
    + *
  1. 校验 task 字段(taskId、handlerName、handler 是否注册、scheduleType)
  2. + *
  3. 移除同 taskId 的旧任务(实现幂等调度)
  4. + *
  5. 将 handler 执行包装为带错误隔离的 Runnable
  6. + *
  7. 按 {@link ScheduleTask.ScheduleType} 选择对应的调度策略
  8. + *
  9. 记录 future 和 task,状态置为 {@code RUNNING}
  10. + *
+ * + * @param task 任务描述对象 + * @throws IllegalArgumentException 当 task 字段校验失败或 scheduleType 不支持时抛出 + */ @Override public void schedule(ScheduleTask task) { validateTask(task); + // 幂等调度:先移除同 taskId 的旧任务,避免重复调度 remove(task.getTaskId()); ScheduledFuture future; + // 包装为带错误隔离的 Runnable,防止单个任务异常影响其他任务 Runnable wrappedRunnable = wrapRunnable(task); switch (task.getScheduleType()) { case FIXED_DELAY: + // 固定延迟:上次执行结束 → 等待 delay → 触发下次 long delay = task.getDelay() != null ? task.getDelay() : 1000L; long initialDelay = task.getInitialDelay() != null ? task.getInitialDelay() : 0L; TimeUnit timeUnit = task.getTimeUnit() != null ? task.getTimeUnit() : TimeUnit.MILLISECONDS; @@ -54,6 +118,7 @@ public void schedule(ScheduleTask task) { break; case FIXED_RATE: + // 固定频率:按固定间隔触发,与上次执行耗时无关 long period = task.getPeriod() != null ? task.getPeriod() : 1000L; initialDelay = task.getInitialDelay() != null ? task.getInitialDelay() : 0L; timeUnit = task.getTimeUnit() != null ? task.getTimeUnit() : TimeUnit.MILLISECONDS; @@ -61,6 +126,7 @@ public void schedule(ScheduleTask task) { break; case CRON: + // CRON 简化实现:不解析 CRON 表达式,统一以 1 秒粒度轮询触发(不支持秒级以下精度) if (task.getCronExpression() == null || task.getCronExpression().isEmpty()) { throw new IllegalArgumentException("Cron expression cannot be null for CRON schedule type"); } @@ -78,6 +144,15 @@ public void schedule(ScheduleTask task) { log.info("Scheduled task: id={}, name={}, type={}, handler={}", task.getTaskId(), task.getTaskName(), task.getScheduleType(), task.getHandlerName()); } + /** + * 更新已有任务的调度配置。 + * + *

内部实现为"先查后调度",若 taskId 不存在则仅记录警告日志; + * 若存在则调用 {@link #schedule(ScheduleTask)} 重新调度(schedule 内部会先 remove 旧任务)。

+ * + * @param task 新的任务描述对象,taskId 必须与已存在任务一致 + * @throws IllegalArgumentException 当 task 字段校验失败时抛出 + */ @Override public void update(ScheduleTask task) { validateTask(task); @@ -92,6 +167,13 @@ public void update(ScheduleTask task) { log.info("Updated task: id={}", task.getTaskId()); } + /** + * 校验任务字段合法性。 + * + * @param task 待校验任务 + * @throws IllegalArgumentException 当 task、taskId、handlerName 为空, + * handler 未注册或 scheduleType 为空时抛出 + */ private void validateTask(ScheduleTask task) { if (task == null || task.getTaskId() == null) { throw new IllegalArgumentException("Task and taskId cannot be null"); @@ -101,6 +183,7 @@ private void validateTask(ScheduleTask task) { throw new IllegalArgumentException("Handler name cannot be null or empty"); } + // 校验 handler 是否已在注册表中注册 if (!handlerRegistry.contains(task.getHandlerName())) { throw new IllegalArgumentException("Handler not found: " + task.getHandlerName()); } @@ -110,16 +193,43 @@ private void validateTask(ScheduleTask task) { } } + /** + * CRON 任务的简化调度实现。 + * + *

CRON 简化说明:本地实现并不解析 CRON 表达式,而是固定以 1 秒粒度 + * 轮询触发任务。这意味着:

+ *
    + *
  • CRON 表达式最小触发单位为秒,不支持秒级以下精度
  • + *
  • 实际触发频率与 CRON 表达式可能不完全一致,仅作为"周期触发"使用
  • + *
  • 需要严格遵循 CRON 语义的场景请使用 XXL-Job 等专业调度器
  • + *
+ * + *

同样使用 try-catch 包裹,避免任务异常导致调度线程死亡。

+ * + * @param task 任务描述对象 + * @param wrappedRunnable 已包装错误隔离的 Runnable + * @return 调度 future + */ private ScheduledFuture scheduleCronTask(ScheduleTask task, Runnable wrappedRunnable) { return executorService.scheduleAtFixedRate(() -> { try { wrappedRunnable.run(); } catch (Exception e) { + // 错误隔离:捕获任务异常仅记录日志,避免调度线程被杀死导致后续任务无法触发 log.error("Cron task execution failed: id={}, error={}", task.getTaskId(), e.getMessage(), e); } - }, 0, 1000, TimeUnit.MILLISECONDS); + }, 0, 1000, TimeUnit.MILLISECONDS); // 1 秒级粒度轮询 } + /** + * 将任务执行包装为带错误隔离的 Runnable。 + * + *

错误隔离核心:通过 try-catch 捕获 handler 执行过程中的所有异常, + * 仅记录日志不向上抛出,确保单个任务异常不会影响其他任务的调度。

+ * + * @param task 任务描述对象 + * @return 包装后的 Runnable + */ private Runnable wrapRunnable(ScheduleTask task) { return () -> { try { @@ -130,15 +240,24 @@ private Runnable wrapRunnable(ScheduleTask task) { log.error("Handler not found during execution: {}", task.getHandlerName()); } } catch (Exception e) { + // 错误隔离:捕获 handler 执行异常,避免影响调度线程和其他任务 log.error("Task execution failed: id={}, name={}, handler={}, error={}", task.getTaskId(), task.getTaskName(), task.getHandlerName(), e.getMessage(), e); } }; } + /** + * 移除任务并停止其调度。 + * + *

取消对应 future(不中断已运行任务),从映射中移除,并将任务状态置为 {@code STOPPED}。

+ * + * @param taskId 任务唯一标识 + */ @Override public void remove(String taskId) { ScheduledFuture future = futureMap.remove(taskId); if (future != null) { + // false:不中断正在执行的任务,等其自然结束 future.cancel(false); } @@ -150,6 +269,14 @@ public void remove(String taskId) { log.info("Removed task: id={}", taskId); } + /** + * 暂停任务调度。 + * + *

取消对应 future,但任务信息仍保留在 {@link #taskMap} 中,状态置为 {@code PAUSED}, + * 可通过 {@link #resume(String)} 恢复。

+ * + * @param taskId 任务唯一标识 + */ @Override public void pause(String taskId) { ScheduledFuture future = futureMap.get(taskId); @@ -163,20 +290,39 @@ public void pause(String taskId) { } } + /** + * 恢复被暂停的任务调度。 + * + *

仅当任务当前状态为 {@code PAUSED} 时才会重新调度,否则忽略。

+ * + * @param taskId 任务唯一标识 + */ @Override public void resume(String taskId) { ScheduleTask task = taskMap.get(taskId); if (task != null && task.getStatus() == ScheduleTask.TaskStatus.PAUSED) { + // 重新调度即可,schedule 内部会先 remove 旧的 future 再创建新的 schedule(task); log.info("Resumed task: id={}", taskId); } } + /** + * 根据任务 ID 查询任务信息。 + * + * @param taskId 任务唯一标识 + * @return 任务描述对象;若任务不存在则返回 {@code null} + */ @Override public ScheduleTask getTaskInfo(String taskId) { return taskMap.get(taskId); } + /** + * 获取当前调度器中所有已注册任务的快照列表。 + * + * @return 任务列表的不可变副本;若无任何任务则返回空列表 + */ @Override public List getAllTasks() { return List.copyOf(taskMap.values()); diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java index fbc3787..0c3f35e 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java @@ -7,41 +7,120 @@ import java.util.concurrent.TimeUnit; +/** + * 调度任务 POJO,用于描述一个待调度任务的完整元信息。 + * + *

该类是 {@link TaskScheduler} 与业务方之间的数据载体,业务方通过 Builder 模式 + * 构造任务描述后提交给调度器,调度器再依据其中的 {@link ScheduleType}、cron 表达式、 + * 延迟参数等触发实际调度。

+ * + *

设计意图:使用不可变数据模型 + Builder 模式,统一封装不同调度语义 + * (CRON、固定频率、固定延迟)所需参数,调用方按需填充对应字段即可。

+ * + *

CRON 限制说明:本地 {@link LocalThreadTaskScheduler} 实现 CRON 调度时 + * 采用 1 秒级粒度的轮询策略(不支持秒级以下精度),即 CRON 表达式最小触发单位为秒。 + * 若需要更精细的调度请改用 {@link ScheduleType#FIXED_RATE} 或 {@link ScheduleType#FIXED_DELAY}。

+ * + *

字段使用约定:

+ *
    + *
  • 当 {@link #scheduleType} = {@link ScheduleType#CRON} 时,使用 {@link #cronExpression}
  • + *
  • 当 {@link #scheduleType} = {@link ScheduleType#FIXED_DELAY} 时,使用 {@link #delay}(+可选 {@link #initialDelay})
  • + *
  • 当 {@link #scheduleType} = {@link ScheduleType#FIXED_RATE} 时,使用 {@link #period}(+可选 {@link #initialDelay})
  • + *
+ */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ScheduleTask { + /** + * 任务唯一标识。 + * + *

调度器以该字段作为 key 维护任务映射,相同 taskId 重复调度会被视为更新。

+ */ private String taskId; + /** + * 任务名称(描述性信息),用于日志展示和监控。 + */ private String taskName; + /** + * 处理器名称,对应 {@link TaskHandlerRegistry} 中注册的 key。 + * + *

调度前会校验该名称是否已注册,未注册将抛出异常。

+ */ private String handlerName; + /** + * 处理器执行参数,将在 {@link TaskHandler#execute(String)} 中传入。 + */ private String handlerParam; + /** + * 调度类型,决定调度器使用哪种触发策略。 + */ private ScheduleType scheduleType; + /** + * CRON 表达式,仅在 {@link #scheduleType} = {@link ScheduleType#CRON} 时使用。 + * + *

本地实现为秒级粒度轮询,不支持秒级以下精度。

+ */ private String cronExpression; + /** + * 初始延迟(配合 {@link TimeUnit} 使用),用于 FIXED_DELAY / FIXED_RATE 场景。 + */ private Long initialDelay; + /** + * 固定延迟间隔,仅在 {@link #scheduleType} = {@link ScheduleType#FIXED_DELAY} 时使用。 + */ private Long delay; + /** + * 固定频率间隔,仅在 {@link #scheduleType} = {@link ScheduleType#FIXED_RATE} 时使用。 + */ private Long period; + /** + * 时间单位,作用于 {@link #initialDelay} / {@link #delay} / {@link #period},默认毫秒。 + */ private TimeUnit timeUnit; + /** + * 任务状态,默认 {@link TaskStatus#PENDING},由调度器在生命周期变化时更新。 + */ @Builder.Default private TaskStatus status = TaskStatus.PENDING; + /** + * 调度类型枚举。 + * + *
    + *
  • {@link #CRON}:基于 CRON 表达式(本地实现为秒级粒度轮询)
  • + *
  • {@link #FIXED_DELAY}:固定延迟(上次执行结束后等待 delay 再触发下次)
  • + *
  • {@link #FIXED_RATE}:固定频率(按固定间隔触发,与上次执行耗时无关)
  • + *
+ */ public enum ScheduleType { CRON, FIXED_DELAY, FIXED_RATE } + /** + * 任务状态枚举。 + * + *
    + *
  • {@link #PENDING}:已创建但尚未调度
  • + *
  • {@link #RUNNING}:已注册到调度器并处于运行中
  • + *
  • {@link #PAUSED}:已暂停,可恢复
  • + *
  • {@link #STOPPED}:已停止/移除
  • + *
+ */ public enum TaskStatus { PENDING, RUNNING, diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapter.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapter.java index 0a25a03..17950de 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapter.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/SpringTaskSchedulerAdapter.java @@ -10,22 +10,62 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +/** + * Spring 标准 {@link TaskScheduler} 适配器,将 Spring 的调度 API 转发到本地 + * {@link LocalThreadTaskScheduler}。 + * + *

设计意图:Spring 框架本身定义了一套 {@link org.springframework.scheduling.TaskScheduler} + * 抽象(用于 {@code @Scheduled}、{@code @EnableScheduling} 等机制)。为了让上层使用 Spring + * 调度 API 的代码能够无缝复用本模块的本地线程池调度能力,本适配器将 Spring 接口的方法 + * 翻译为 {@link ScheduleTask} 并委托给 {@link LocalThreadTaskScheduler} 执行。

+ * + *

协作关系:由 {@code AutoScheduleConfiguration} 在容器中存在 + * {@link LocalThreadTaskScheduler} Bean 时装配。每个 Spring 调度请求都会被转换为一个 + * 临时 handler(注册到 {@link TaskHandlerRegistry}),再以 {@link ScheduleTask} 形式提交给 + * 本地调度器。

+ * + *

限制说明:返回的 {@link ScheduledFuture} 为简化实现,{@code get()}、 + * {@code getDelay()} 等方法返回固定值,仅用于满足 Spring 接口约定及支持取消/状态查询。

+ */ @Slf4j public class SpringTaskSchedulerAdapter implements TaskScheduler { + /** + * 被适配的本地调度器。 + */ private final LocalThreadTaskScheduler localThreadTaskScheduler; + /** + * handler 注册表,用于注册临时转换出来的 handler。 + */ private final TaskHandlerRegistry handlerRegistry; + /** + * 构造方法。 + * + * @param localThreadTaskScheduler 被适配的本地调度器 + * @param handlerRegistry handler 注册表 + */ public SpringTaskSchedulerAdapter(LocalThreadTaskScheduler localThreadTaskScheduler, TaskHandlerRegistry handlerRegistry) { this.localThreadTaskScheduler = localThreadTaskScheduler; this.handlerRegistry = handlerRegistry; } + /** + * 基于 {@link Trigger} 的调度(Spring 接口方法)。 + * + *

由于本地调度器不支持 Trigger 语义,此处简化为固定 1 秒频率触发。

+ * + * @param task 待执行的 Runnable + * @param trigger 触发器(本实现未真正解析,仅做简化处理) + * @return 可用于取消的 ScheduledFuture + */ @Override public ScheduledFuture schedule(Runnable task, Trigger trigger) { + // 生成唯一 handlerName,避免与业务 handler 冲突 String handlerName = "spring-trigger-task-" + System.currentTimeMillis(); + // 将 Runnable 包装为 TaskHandler 注册 handlerRegistry.register(handlerName, param -> task.run()); ScheduleTask scheduleTask = ScheduleTask.builder() @@ -38,6 +78,7 @@ public ScheduledFuture schedule(Runnable task, Trigger trigger) { localThreadTaskScheduler.schedule(scheduleTask); + // 返回简化的 ScheduledFuture,cancel 时联动移除任务和 handler return new ScheduledFuture() { @Override public boolean cancel(boolean mayInterruptIfRunning) { @@ -78,11 +119,21 @@ public int compareTo(java.util.concurrent.Delayed other) { }; } + /** + * 在指定时间点触发一次的任务调度(Spring 接口方法)。 + * + *

实现为 FIXED_DELAY,并将 delay 设为 {@code Long.MAX_VALUE} 使其仅触发一次。

+ * + * @param task 待执行的 Runnable + * @param startTime 触发时间点 + * @return 可用于取消的 ScheduledFuture + */ @Override public ScheduledFuture schedule(Runnable task, Instant startTime) { String handlerName = "spring-delay-task-" + System.currentTimeMillis(); handlerRegistry.register(handlerName, param -> task.run()); + // 计算初始延迟,若已过期则立即触发 long initialDelay = Duration.between(Instant.now(), startTime).toMillis(); if (initialDelay < 0) { initialDelay = 0; @@ -93,7 +144,7 @@ public ScheduledFuture schedule(Runnable task, Instant startTime) { .taskName("Spring Delay Task") .handlerName(handlerName) .scheduleType(ScheduleTask.ScheduleType.FIXED_DELAY) - .delay(Long.MAX_VALUE) + .delay(Long.MAX_VALUE) // 仅触发一次:delay 设为极大值 .initialDelay(initialDelay) .build(); @@ -102,6 +153,14 @@ public ScheduledFuture schedule(Runnable task, Instant startTime) { return createScheduledFuture(handlerName); } + /** + * 以固定频率触发任务,可指定起始时间(Spring 接口方法)。 + * + * @param task 待执行的 Runnable + * @param startTime 起始时间点 + * @param period 触发间隔 + * @return 可用于取消的 ScheduledFuture + */ @Override public ScheduledFuture scheduleAtFixedRate(Runnable task, Instant startTime, Duration period) { String handlerName = "spring-fixed-rate-task-" + System.currentTimeMillis(); @@ -126,6 +185,13 @@ public ScheduledFuture scheduleAtFixedRate(Runnable task, Instant startTime, return createScheduledFuture(handlerName); } + /** + * 以固定频率触发任务,立即开始(Spring 接口方法)。 + * + * @param task 待执行的 Runnable + * @param period 触发间隔 + * @return 可用于取消的 ScheduledFuture + */ @Override public ScheduledFuture scheduleAtFixedRate(Runnable task, Duration period) { String handlerName = "spring-fixed-rate-task-" + System.currentTimeMillis(); @@ -144,6 +210,14 @@ public ScheduledFuture scheduleAtFixedRate(Runnable task, Duration period) { return createScheduledFuture(handlerName); } + /** + * 以固定延迟触发任务,可指定起始时间(Spring 接口方法)。 + * + * @param task 待执行的 Runnable + * @param startTime 起始时间点 + * @param delay 每次执行结束后的延迟间隔 + * @return 可用于取消的 ScheduledFuture + */ @Override public ScheduledFuture scheduleWithFixedDelay(Runnable task, Instant startTime, Duration delay) { String handlerName = "spring-fixed-delay-task-" + System.currentTimeMillis(); @@ -168,6 +242,13 @@ public ScheduledFuture scheduleWithFixedDelay(Runnable task, Instant startTim return createScheduledFuture(handlerName); } + /** + * 以固定延迟触发任务,立即开始(Spring 接口方法)。 + * + * @param task 待执行的 Runnable + * @param delay 每次执行结束后的延迟间隔 + * @return 可用于取消的 ScheduledFuture + */ @Override public ScheduledFuture scheduleWithFixedDelay(Runnable task, Duration delay) { String handlerName = "spring-fixed-delay-task-" + System.currentTimeMillis(); @@ -186,6 +267,14 @@ public ScheduledFuture scheduleWithFixedDelay(Runnable task, Duration delay) return createScheduledFuture(handlerName); } + /** + * 创建简化的 {@link ScheduledFuture},仅支持取消和状态查询。 + * + *

cancel 时联动移除本地调度器中的任务和注册表中的 handler,避免资源泄漏。

+ * + * @param handlerName 临时 handler 名称(同时也是 taskId) + * @return 简化的 ScheduledFuture 实例 + */ private ScheduledFuture createScheduledFuture(String handlerName) { return new ScheduledFuture() { @Override diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandler.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandler.java index 89cfe81..6cbef69 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandler.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandler.java @@ -1,7 +1,27 @@ package cn.structure.infra.schedule; +/** + * 任务处理器函数式接口。 + * + *

业务方通过实现此接口定义具体的任务执行逻辑,并由 + * {@link TaskHandlerRegistry} 按 {@code handlerName} 注册到调度器。 + * 调度器在触发任务时通过注册表查找对应的 handler 并调用其 {@link #execute(String)} 方法。

+ * + *

设计意图:采用函数式接口将"调度触发"与"业务执行"解耦, + * 调度器只关心何时触发,业务方只关心执行什么;同时支持 Lambda 表达式, + * 便于在 Spring 配置类中以简洁方式注册任务处理器。

+ * + *

错误隔离约定:实现方在 {@link #execute(String)} 中应妥善处理异常, + * 即便未捕获,调度器内部也会对异常进行兜底捕获,避免单个任务的异常影响其他任务的调度。

+ */ @FunctionalInterface public interface TaskHandler { + /** + * 执行任务逻辑。 + * + * @param param 任务执行参数,由 {@link ScheduleTask#getHandlerParam()} 传入; + * 可能为 {@code null},由实现方自行判断是否需要处理 + */ void execute(String param); } \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandlerRegistry.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandlerRegistry.java index 0012ebe..f34d9cc 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandlerRegistry.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskHandlerRegistry.java @@ -1,12 +1,56 @@ package cn.structure.infra.schedule; +/** + * 任务处理器注册表接口。 + * + *

该接口负责维护 {@code handlerName} 到 {@link TaskHandler} 实例的映射关系, + * 是 {@link TaskScheduler} 与具体业务执行逻辑之间的桥梁。调度器在调度任务前会先 + * 通过 {@link #contains(String)} 校验 handler 是否已注册,触发任务时通过 + * {@link #get(String)} 查找并执行对应的 handler。

+ * + *

设计意图:将 handler 的注册、查找、卸载等管理职责从调度器中剥离, + * 形成单一职责的注册表,便于业务方在任意阶段动态注册/卸载 handler, + * 也便于扩展为基于 Spring 容器或其他自定义发现机制的实现。

+ * + *

已知实现:{@link DefaultTaskHandlerRegistry}(基于 {@code ConcurrentHashMap} 的默认实现)

+ */ public interface TaskHandlerRegistry { + /** + * 注册任务处理器。 + * + *

若 {@code handlerName} 已存在,则覆盖原有 handler。

+ * + * @param handlerName 处理器名称,作为唯一标识,不能为 {@code null} + * @param handler 处理器实例,不能为 {@code null} + * @throws IllegalArgumentException 当 handlerName 或 handler 为 {@code null} 时抛出 + */ void register(String handlerName, TaskHandler handler); + /** + * 根据处理器名称获取任务处理器。 + * + * @param handlerName 处理器名称 + * @return 对应的处理器实例;若未注册则返回 {@code null} + */ TaskHandler get(String handlerName); + /** + * 注销指定名称的任务处理器。 + * + *

若 {@code handlerName} 未注册则静默忽略,不会抛出异常。

+ * + * @param handlerName 处理器名称 + */ void unregister(String handlerName); + /** + * 判断指定名称的任务处理器是否已注册。 + * + *

调度器在调度任务前会调用此方法做前置校验。

+ * + * @param handlerName 处理器名称 + * @return 已注册返回 {@code true},否则返回 {@code false} + */ boolean contains(String handlerName); } \ No newline at end of file diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskScheduler.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskScheduler.java index 41f520c..84fb3f8 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskScheduler.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/TaskScheduler.java @@ -2,19 +2,92 @@ import java.util.List; +/** + * 任务调度器 SPI 接口。 + * + *

该接口是 schedule-starter 模块对外暴露的核心扩展点(Service Provider Interface), + * 用于屏蔽不同调度实现(本地线程池调度、XXL-Job 分布式调度等)之间的差异。 + * 业务方或自动配置类通过此接口完成任务的注册、更新、删除、暂停、恢复等生命周期管理, + * 而无需关心底层调度引擎的具体细节。

+ * + *

已知实现:

+ *
    + *
  • {@link LocalThreadTaskScheduler}:基于 {@link java.util.concurrent.ScheduledExecutorService} + * 的本地默认实现,适用于单机场景
  • + *
  • {@code XxlJobTaskScheduler}(位于 xxljob-starter 模块):将本地任务转发到 XXL-Job + * 的分布式实现,适用于集群/分布式场景,启用后会通过 + * {@code @AutoConfigureBefore} 机制覆盖本地实现
  • + *
+ * + *

设计意图:采用 SPI 模式解耦调度 API 与调度实现,使得切换调度引擎时 + * 业务代码无需改动,仅通过 Spring Bean 装配即可完成切换。

+ */ public interface TaskScheduler { + /** + * 调度一个任务。 + * + *

若 taskId 已存在,则先移除旧任务再创建新任务,实现"幂等调度"。 + * 调用此方法后任务将进入 {@code RUNNING} 状态。

+ * + * @param task 任务描述对象,包含 taskId、handlerName、调度类型及调度参数等 + * @throws IllegalArgumentException 当 task、taskId、handlerName 为空, + * handler 未注册或 scheduleType 为空时抛出 + */ void schedule(ScheduleTask task); + /** + * 更新已有任务的调度配置。 + * + *

更新操作通常等价于"先移除再重新调度"。若 taskId 不存在,则仅记录日志不做任何操作。

+ * + * @param task 新的任务描述对象,taskId 必须与已存在任务一致 + * @throws IllegalArgumentException 当 task 字段校验失败时抛出 + */ void update(ScheduleTask task); + /** + * 移除任务并停止其调度。 + * + *

任务被移除后状态置为 {@code STOPPED},相关调度资源被释放。

+ * + * @param taskId 任务唯一标识 + */ void remove(String taskId); + /** + * 暂停任务调度。 + * + *

暂停后任务仍保留在调度器内部记录中,状态置为 {@code PAUSED}, + * 可通过 {@link #resume(String)} 恢复。

+ * + * @param taskId 任务唯一标识 + */ void pause(String taskId); + /** + * 恢复被暂停的任务调度。 + * + *

仅当任务当前状态为 {@code PAUSED} 时才会真正恢复,否则忽略。

+ * + * @param taskId 任务唯一标识 + */ void resume(String taskId); + /** + * 根据任务 ID 查询任务信息。 + * + * @param taskId 任务唯一标识 + * @return 任务描述对象;若任务不存在则返回 {@code null} + */ ScheduleTask getTaskInfo(String taskId); + /** + * 获取当前调度器中所有已注册任务的快照列表。 + * + *

返回的是不可变副本,调用方修改不会影响调度器内部状态。

+ * + * @return 任务列表的不可变副本;若无任何任务则返回空列表 + */ List getAllTasks(); } \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/annotations/Repository.java b/structure-infra-starter/src/main/java/cn/structure/infra/annotations/Repository.java index 90bfee5..077d350 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/annotations/Repository.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/annotations/Repository.java @@ -5,39 +5,71 @@ import java.lang.annotation.*; import java.util.concurrent.TimeUnit; +/** + * 仓储标记注解 + *

+ * 标注在 {@link cn.structure.infra.repository.RepositoryFacade} 的子类上, + * 用于声明一个领域仓储及其元数据(实体类型、PO 类型、主键类型、缓存策略、CQRS 配置等)。 + *

+ * 框架在启动时通过 {@link cn.structure.infra.repository.RepositoryBeanPostProcessor} + * 扫描此注解,并根据配置自动注入对应的 BASE/READ Delegate。 + *

+ * 示例: + *

+ * @Repository(value = "userRepository", entity = User.class, po = UserPO.class, id = Long.class)
+ * public class UserRepository extends RepositoryFacade<User, Long, UserPO, MybatisPlusRepositoryDelegate<UserPO, Long>> {
+ * }
+ * 
+ *

+ * 启用 CQRS 读写分离的示例: + *

+ * @Repository(value = "userRepository", entity = User.class, po = UserPO.class,
+ *              cqrs = true, readDelegateClass = ElasticsearchRepositoryDelegate.class)
+ * public class UserRepository extends RepositoryFacade<User, Long, UserPO, MybatisPlusRepositoryDelegate<UserPO, Long>> {
+ * }
+ * 
+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Inherited @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface Repository { /** - * 仓储名称 + * 仓储名称(对应 Bean 名称,用于与 {@link DelegateFor#name()} 进行匹配) * - * @return + * @return 仓储名称,默认空字符串表示使用类名 */ String value() default ""; /** - * 仓储类型 默认自动 + * 仓储类型,默认 {@link RepositoryType#AUTO} 由框架自动推断 + *

+ * 指定具体类型时,会优先匹配同类型的 Delegate * - * @return + * @return 仓储类型 */ RepositoryType type() default RepositoryType.AUTO; /** - * 实体类 + * 领域实体类类型 + *

+ * RepositoryFacade 在执行 Entity ↔ PO 转换时使用 * - * @return + * @return 实体类,默认 Object.class 表示从泛型参数推断 */ Class entity() default Object.class; /** - * PO持久化对象类型 + * PO 持久化对象类型 *

- * 用于 RepositoryFacade 中的 Entity <-> PO 转换 + * 用于 RepositoryFacade 中的 Entity ↔ PO 转换,以及 Delegate 匹配 * - * @return + * @return PO 类,默认 Object.class 表示从泛型参数推断 */ Class po() default Object.class; @@ -46,36 +78,36 @@ *

* 默认 Long,如果需要指定其他类型可配置 * - * @return + * @return 主键类型 */ Class id() default Long.class; /** * 仓储描述 * - * @return + * @return 描述信息 */ String description() default ""; /** - * 是否缓存 + * 是否启用缓存 * - * @return + * @return true 表示启用缓存 */ boolean cache() default false; /** * 缓存时间 * - * @return + * @return 缓存过期时间数值 */ long cacheTime() default 60L; /** * 缓存时间单位 * - * @return + * @return 缓存时间单位 */ TimeUnit cacheTimeUnit() default TimeUnit.SECONDS; diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoEventConfiguration.java b/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoEventConfiguration.java index 88b646a..b09a2f5 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoEventConfiguration.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoEventConfiguration.java @@ -10,10 +10,34 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * 事件子系统自动装配配置类 + *

+ * 负责注册 {@link DefaultEventManagerImpl} 作为 {@link EventManager} 的默认实现, + * 将事件发布能力接入框架。 + *

+ * 装配条件:仅当容器中已存在 {@link EventManager} 类型 Bean(通常由使用方主动声明) + * 时才会注册默认实现,避免重复注册。 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Configuration @EnableConfigurationProperties(InfraProperties.class) public class AutoEventConfiguration { + /** + * 注册默认事件管理器 + *

+ * 注入 Spring 事件发布器、消息桥接器和框架配置,使事件可按 {@link cn.structure.infra.event.EventChannel} + * 进行路由发布。 + * + * @param applicationEventPublisher Spring 应用事件发布器 + * @param streamBridge 数据权限消息桥接器 + * @param infraProperties 框架配置属性 + * @return 默认事件管理器实现 + */ @Bean @ConditionalOnBean(EventManager.class) public EventManager eventManager(ApplicationEventPublisher applicationEventPublisher, diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java b/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java index 9f334b1..28ec00e 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoScheduleConfiguration.java @@ -11,16 +11,51 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * 调度子系统自动装配配置类 + *

+ * 负责注册任务调度体系的核心组件,将框架自有的 {@link TaskScheduler} 与 Spring 的 + * {@link org.springframework.scheduling.TaskScheduler} 进行桥接。 + *

+ * 自动注册的 Bean: + *

    + *
  • {@link TaskHandlerRegistry} —— 任务处理器注册表(仅当容器中缺失时)
  • + *
  • {@link TaskScheduler} —— 本地线程任务调度器(仅当容器中缺失时)
  • + *
  • {@link SpringTaskSchedulerAdapter} —— 适配 Spring 调度接口的桥接器
  • + *
+ * 线程池大小通过 {@link InfraProperties#getSchedulePoolSize()} 配置。 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Configuration @EnableConfigurationProperties(InfraProperties.class) public class AutoScheduleConfiguration { + /** + * 注册任务处理器注册表 + *

+ * 当容器中不存在 {@link TaskHandlerRegistry} 时使用默认实现 {@link DefaultTaskHandlerRegistry} + * + * @return 任务处理器注册表 + */ @Bean @ConditionalOnMissingBean(TaskHandlerRegistry.class) public TaskHandlerRegistry taskHandlerRegistry() { return new DefaultTaskHandlerRegistry(); } + /** + * 注册任务调度器 + *

+ * 默认使用基于本地线程池的 {@link LocalThreadTaskScheduler},线程池大小取自 + * {@link InfraProperties#getSchedulePoolSize()},未配置时回退到 CPU 核心数。 + * + * @param infraProperties 框架配置属性 + * @param handlerRegistry 任务处理器注册表 + * @return 任务调度器实例 + */ @Bean @ConditionalOnMissingBean(TaskScheduler.class) public TaskScheduler taskScheduler(InfraProperties infraProperties, TaskHandlerRegistry handlerRegistry) { @@ -28,6 +63,17 @@ public TaskScheduler taskScheduler(InfraProperties infraProperties, TaskHandlerR return new LocalThreadTaskScheduler(poolSize != null ? poolSize : Runtime.getRuntime().availableProcessors(), handlerRegistry); } + /** + * 注册 Spring 任务调度器适配器 + *

+ * 将框架自有的 {@link LocalThreadTaskScheduler} 适配为 Spring 标准的 + * {@link org.springframework.scheduling.TaskScheduler},便于 @Scheduled 等场景复用。 + * 若自有调度器不是 {@link LocalThreadTaskScheduler} 类型,则返回 null 表示不适配。 + * + * @param taskScheduler 框架自有任务调度器 + * @param handlerRegistry 任务处理器注册表 + * @return Spring 标准任务调度器适配器,无法适配时返回 null + */ @Bean @ConditionalOnMissingBean(name = "springTaskScheduler") public org.springframework.scheduling.TaskScheduler springTaskScheduler(TaskScheduler taskScheduler, TaskHandlerRegistry handlerRegistry) { diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/event/DefaultEventManagerImpl.java b/structure-infra-starter/src/main/java/cn/structure/infra/event/DefaultEventManagerImpl.java index ba35d1a..236a131 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/event/DefaultEventManagerImpl.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/event/DefaultEventManagerImpl.java @@ -6,9 +6,18 @@ import org.springframework.context.ApplicationEventPublisher; /** + * 默认事件管理器实现 *

- * 事件管理器 - *

+ * {@link EventManager} 的内置实现,负责根据 {@link Event#getEventChannel()} + * 将事件路由到对应的发布渠道: + *
    + *
  • {@link EventChannel#SPRING_EVENT} —— 通过 {@link ApplicationEventPublisher} 发布 Spring 应用事件,仅限本 JVM
  • + *
  • {@link EventChannel#MESSAGE_EVENT} —— 通过 {@link DataScopeStreamBridge} 发送到消息中间件,可跨服务
  • + *
  • {@link EventChannel#DEFAULT} —— 委托给 {@link InfraProperties#getDefaultEventChannel()} 全局配置决定
  • + *
+ *

+ * 该 Bean 由 {@link cn.structure.infra.configuration.AutoEventConfiguration} 自动注册, + * 仅在容器中已存在 {@link EventManager} 类型 Bean 时生效。 * * @author chuck * @version 1.0.1 @@ -17,14 +26,35 @@ @AllArgsConstructor public class DefaultEventManagerImpl implements EventManager { + /** + * Spring 应用事件发布器,用于 {@link EventChannel#SPRING_EVENT} 渠道 + */ private final ApplicationEventPublisher eventPublisher; + /** + * 数据权限消息桥接器,用于 {@link EventChannel#MESSAGE_EVENT} 渠道 + */ private final DataScopeStreamBridge streamBridge; + /** + * 框架配置属性,提供 {@link EventChannel#DEFAULT} 渠道的实际路由策略 + */ private final InfraProperties infraProperties; + /** + * 发布事件 + *

+ * 路由逻辑: + *

    + *
  1. 若事件渠道为 {@link EventChannel#DEFAULT},按 {@link InfraProperties#getDefaultEventChannel()} 配置选择发布方式
  2. + *
  3. 否则按事件自身声明的渠道发布
  4. + *
+ * + * @param event 待发布事件 + */ @Override public void publish(Event event) { + // DEFAULT 渠道:根据全局配置决定实际发布方式 if (event.getEventChannel().equals(EventChannel.DEFAULT)) { if (infraProperties.getDefaultEventChannel() == EventChannel.SPRING_EVENT) { eventPublisher.publishEvent(event); @@ -33,6 +63,7 @@ public void publish(Event event) { streamBridge.send(event.getEventId(), event); } } else { + // 非 DEFAULT 渠道:按事件自身声明的渠道发布 if (event.getEventChannel() == EventChannel.SPRING_EVENT) { eventPublisher.publishEvent(event); } diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/event/EventChannel.java b/structure-infra-starter/src/main/java/cn/structure/infra/event/EventChannel.java index 7017f36..5b09c47 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/event/EventChannel.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/event/EventChannel.java @@ -2,10 +2,32 @@ import lombok.Getter; +/** + * 事件渠道类型枚举 + *

+ * 定义事件发布的目标渠道,{@link EventManager} 根据事件声明的渠道类型 + * 选择对应的发布方式(Spring 应用事件 / 消息中间件)。 + *

+ * 当事件使用 {@link #DEFAULT} 时,实际渠道由 {@link cn.structure.infra.properties.InfraProperties#getDefaultEventChannel()} + * 全局配置决定。 + * + * @author chuck + * @version 1.0.1 + * @since 2021/6/21 16:05 + */ @Getter public enum EventChannel { + /** + * 默认渠道:由全局配置 {@code structure.infra.default-event-channel} 决定实际发布方式 + */ DEFAULT, + /** + * Spring 应用事件渠道:通过 {@link org.springframework.context.ApplicationEventPublisher} 发布,仅在本 JVM 内传播 + */ SPRING_EVENT, + /** + * 消息事件渠道:通过 {@link cn.structured.datascope.message.wrapper.DataScopeStreamBridge} 发送到消息中间件,可跨服务传播 + */ MESSAGE_EVENT, ; } diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/configuration/LowCodeAutoConfiguration.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/configuration/LowCodeAutoConfiguration.java index 0be21d3..f55e117 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/configuration/LowCodeAutoConfiguration.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/configuration/LowCodeAutoConfiguration.java @@ -51,21 +51,26 @@ public class LowCodeAutoConfiguration { @Bean public LowCodeRepositoryRouter lowCodeRepositoryRouter(List factories, LowCodeProperties properties) { + // 1. 构造路由引擎,注入所有仓储工厂 LowCodeRepositoryRouter router = new LowCodeRepositoryRouter(factories); + // 2. 遍历配置中的资源定义,逐个注册到路由引擎 if (properties.getResources() != null && !properties.getResources().isEmpty()) { for (var entry : properties.getResources().entrySet()) { String resourceName = entry.getKey(); LowCodeProperties.ResourceProperties resourceProps = entry.getValue(); + // 跳过缺少 schema 或 repository 配置的资源 if (resourceProps.getSchema() == null || resourceProps.getRepository() == null) { log.warn("Resource {} has no schema or repository config, skipped", resourceName); continue; } + // 3. 将配置属性转换为内部模型(ResourceSchema + RepositoryConfig) var schema = ResourceSchemaBuilder.buildSchema(resourceName, resourceProps.getSchema()); var repoConfig = ResourceSchemaBuilder.buildRepositoryConfig(resourceProps.getRepository()); + // 4. 注册资源(触发存储实例创建和容器初始化) router.registerResource(resourceName, schema, repoConfig); log.info("LowCode resource registered: {}", resourceName); } diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/registry/ResourceSchemaBuilder.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/registry/ResourceSchemaBuilder.java index 8f395bc..962e930 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/registry/ResourceSchemaBuilder.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/registry/ResourceSchemaBuilder.java @@ -29,8 +29,10 @@ public class ResourceSchemaBuilder { public static ResourceSchema buildSchema(String resourceName, LowCodeProperties.SchemaProperties schemaProps) { ResourceSchema schema = new ResourceSchema(); schema.setResourceName(resourceName); + // 表名未配置时默认使用资源名 schema.setTableName(schemaProps.getTableName() != null ? schemaProps.getTableName() : resourceName); + // 逐字段构建 schema,addField 会自动识别主键并设置 idFieldName/idType if (schemaProps.getFields() != null) { for (var entry : schemaProps.getFields().entrySet()) { String fieldName = entry.getKey(); diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/router/LowCodeRepositoryRouter.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/router/LowCodeRepositoryRouter.java index 3ad4bb7..8a913da 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/router/LowCodeRepositoryRouter.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/router/LowCodeRepositoryRouter.java @@ -74,9 +74,11 @@ public LowCodeRepositoryRouter(List factories) { * @param config 仓储配置 */ public void registerResource(String resourceName, ResourceSchema schema, RepositoryConfig config) { + // 1. 创建基础存储实例(写操作 + 兜底读操作) LowCodeStorage baseStorage = createStorage(schema, config.getType(), config); LowCodeStorage readStorage = null; + // 2. CQRS 模式下创建独立的读存储实例(如 Elasticsearch) if (config.isCqrsEnabled() && config.getCqrs().getReadType() != null) { try { RepositoryConfig readConfig = new RepositoryConfig(); @@ -84,14 +86,17 @@ public void registerResource(String resourceName, ResourceSchema schema, Reposit readConfig.setDatasource(config.getCqrs().getReadDatasource()); readStorage = createStorage(schema, config.getCqrs().getReadType(), readConfig); } catch (Exception e) { + // 读存储创建失败不影响基础存储,读操作会回退到基础存储 log.warn("Failed to create read storage for resource {}, falling back to base: {}", resourceName, e.getMessage()); } } + // 3. 注册到路由表 StorageHolder holder = new StorageHolder(schema, config, baseStorage, readStorage); storageRegistry.put(resourceName, holder); + // 4. 初始化存储容器(建表/建集合),失败仅告警不中断注册 try { baseStorage.initialize(); if (readStorage != null && readStorage != baseStorage) { @@ -149,14 +154,17 @@ private R executeRead(String resourceName, Function readOperation, Function fallbackOperation) { StorageHolder holder = getHolder(resourceName); + // CQRS 启用且存在读存储:优先走读存储,异常时回退到基础存储 if (holder.readStorage != null && holder.config.isCqrsEnabled()) { try { return readOperation.apply(holder.readStorage); } catch (Exception e) { + // 读存储异常时回退到基础存储,保证可用性 log.warn("Read storage operation failed for resource {}, falling back to base: {}", resourceName, e.getMessage()); } } + // 兜底路径:直接走基础存储 return fallbackOperation.apply(holder.baseStorage); } @@ -186,22 +194,51 @@ private void executeWriteVoid(String resourceName, java.util.function.Consumer save(String resourceName, Map data) { return executeWrite(resourceName, storage -> storage.save(data)); } + /** + * 根据 ID 删除(写操作,走基础存储) + * + * @param resourceName 资源名称 + * @param id 主键值 + */ @Override public void removeById(String resourceName, Object id) { executeWriteVoid(resourceName, storage -> storage.removeById(id)); } + /** + * 根据 ID 查询(写操作路径,走基础存储) + *

+ * 此方法对应 ICrudRepository 契约,不参与 CQRS 路由。 + * + * @param resourceName 资源名称 + * @param id 主键值 + * @return 数据 Map,不存在时返回 null + */ @Override public Map findById(String resourceName, Object id) { StorageHolder holder = getHolder(resourceName); return holder.baseStorage.findById(id); } + /** + * 根据 ID 查询(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param id 主键值 + * @return 数据 Map,不存在时返回 null + */ @Override public Map queryById(String resourceName, Object id) { return executeRead(resourceName, @@ -209,6 +246,13 @@ public Map queryById(String resourceName, Object id) { storage -> storage.queryById(id)); } + /** + * 根据 ID 查询(Optional 包装,读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param id 主键值 + * @return Optional 包装的数据 + */ @Override public Optional> queryByIdOptional(String resourceName, Object id) { return executeRead(resourceName, @@ -228,6 +272,13 @@ private Map buildIdQuery(String resourceName, Object id) { return Map.of(holder.schema.getIdFieldName(), id); } + /** + * 条件查询单条记录(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param queryParams 查询条件 + * @return 单条数据,不存在时返回 null + */ @Override public Map queryOne(String resourceName, Map queryParams) { return executeRead(resourceName, @@ -235,6 +286,13 @@ public Map queryOne(String resourceName, Map que storage -> storage.queryOne(queryParams)); } + /** + * 条件查询单条记录(Optional 包装,读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param queryParams 查询条件 + * @return Optional 包装的数据 + */ @Override public Optional> queryOneOptional(String resourceName, Map queryParams) { return executeRead(resourceName, @@ -242,6 +300,13 @@ public Optional> queryOneOptional(String resourceName, Map storage.queryOneOptional(queryParams)); } + /** + * 条件查询列表(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param queryParams 查询条件,为 null 时查询全部 + * @return 数据列表 + */ @Override public List> queryList(String resourceName, Map queryParams) { return executeRead(resourceName, @@ -249,6 +314,13 @@ public List> queryList(String resourceName, Map storage.queryList(queryParams)); } + /** + * 分页查询(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param reqPage 分页参数 + * @return 分页结果 + */ @Override public ResPage> queryPage(String resourceName, ReqPage reqPage) { return executeRead(resourceName, @@ -256,16 +328,36 @@ public ResPage> queryPage(String resourceName, ReqPage reqPa storage -> storage.queryPage(reqPage)); } + /** + * 批量保存(写操作,走基础存储) + * + * @param resourceName 资源名称 + * @param dataList 数据列表 + * @return 保存后的数据列表 + */ @Override public List> saveBatch(String resourceName, List> dataList) { return executeWrite(resourceName, storage -> storage.saveBatch(dataList)); } + /** + * 根据 ID 批量删除(写操作,走基础存储) + * + * @param resourceName 资源名称 + * @param ids 主键列表 + */ @Override public void removeBatchByIds(String resourceName, List ids) { executeWriteVoid(resourceName, storage -> storage.removeBatchByIds(ids)); } + /** + * 根据 ID 列表批量查询(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param ids 主键列表 + * @return 数据列表 + */ @Override public List> listByIds(String resourceName, List ids) { return executeRead(resourceName, @@ -273,6 +365,13 @@ public List> listByIds(String resourceName, List ids storage -> storage.listByIds(ids)); } + /** + * 统计数量(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param queryParams 查询条件 + * @return 记录数量 + */ @Override public long count(String resourceName, Map queryParams) { return executeRead(resourceName, @@ -280,6 +379,13 @@ public long count(String resourceName, Map queryParams) { storage -> storage.count(queryParams)); } + /** + * 判断是否存在(写操作路径,走基础存储) + * + * @param resourceName 资源名称 + * @param queryParams 查询条件 + * @return true 表示存在 + */ @Override public boolean exists(String resourceName, Map queryParams) { StorageHolder holder = getHolder(resourceName); diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/package-info.java b/structure-infra-starter/src/main/java/cn/structure/infra/package-info.java index 1b836e9..6df5f94 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/package-info.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/package-info.java @@ -1 +1,21 @@ -package cn.structure.infra; \ No newline at end of file +/** + * structure-infra-starter 根包,DDD 仓储抽象层的核心模块。 + *

+ * 本模块基于 Facade + Delegate 模式构建领域层与持久化层之间的防腐层(ACL), + * 提供统一的 CRUD 操作契约、CQRS 读写分离、自动 Delegate 装配等能力。 + *

+ * 核心子包说明: + *

    + *
  • {@link cn.structure.infra.annotations} —— 仓储相关注解({@code @Repository}、{@code @DelegateFor})
  • + *
  • {@link cn.structure.infra.repository} —— 仓储 Facade/Delegate 抽象与装配核心
  • + *
  • {@link cn.structure.infra.configuration} —— Spring Boot 自动装配配置类
  • + *
  • {@link cn.structure.infra.properties} —— 框架级配置属性
  • + *
  • {@link cn.structure.infra.event} —— 事件子系统(EventManager / Event / EventChannel)
  • + *
  • {@link cn.structure.infra.lowcode} —— 低代码仓储子系统(动态资源 schema、存储路由)
  • + *
+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +package cn.structure.infra; diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java b/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java index 7dca4f7..ad06abd 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java @@ -7,37 +7,68 @@ import java.util.concurrent.TimeUnit; +/** + * 基础设施框架配置属性 + *

+ * 对应 YAML 配置前缀:{@code structure.infra},集中管理事件、CQRS、缓存、调度等 + * 框架级参数。被 {@link cn.structure.infra.configuration.AutoEventConfiguration}、 + * {@link cn.structure.infra.configuration.AutoScheduleConfiguration} 等自动装配类引用。 + *

+ * 配置示例: + *

+ * structure:
+ *   infra:
+ *     default-event-channel: SPRING_EVENT
+ *     cqrs: false
+ *     cache-time: 60
+ *     cache-time-unit: SECONDS
+ *     schedule-pool-size: 8
+ * 
+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Data @Configuration @ConfigurationProperties(prefix = "structure.infra") public class InfraProperties { /** - * 默认事件类型 + * 默认事件渠道类型 + *

+ * 当 {@link cn.structure.infra.event.Event} 声明为 {@link EventChannel#DEFAULT} 时, + * 使用此配置决定实际发布方式 + * + * @return 默认事件渠道 */ private EventChannel defaultEventChannel = EventChannel.SPRING_EVENT; /** - * 是否开启CQRS + * 是否全局开启 CQRS 读写分离 + * + * @return true 表示开启 */ private Boolean cqrs = false; /** - * 缓存时间 + * 默认缓存时间 * - * @return + * @return 缓存过期时间数值 */ private Long cacheTime = 60L; /** - * 缓存时间单位 + * 默认缓存时间单位 * - * @return + * @return 缓存时间单位 */ private TimeUnit cacheTimeUnit = TimeUnit.SECONDS; /** * 调度线程池大小,默认 CPU 核心数 + * + * @return 调度线程池大小 */ private Integer schedulePoolSize = Runtime.getRuntime().availableProcessors(); } diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/InMemoryRepositoryDelegate.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/InMemoryRepositoryDelegate.java index b235c88..94afbe1 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/repository/InMemoryRepositoryDelegate.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/InMemoryRepositoryDelegate.java @@ -31,16 +31,35 @@ public class InMemoryRepositoryDelegate implements RepositoryDelegate entityClass; private final String idFieldName; + /** + * 构造内存仓储委托,默认主键字段名为 "id" + * + * @param entityClass 实体类型 + */ public InMemoryRepositoryDelegate(Class entityClass) { this(entityClass, "id"); } + /** + * 构造内存仓储委托,指定主键字段名 + * + * @param entityClass 实体类型 + * @param idFieldName 主键字段名 + */ public InMemoryRepositoryDelegate(Class entityClass, String idFieldName) { this.entityClass = entityClass; this.idFieldName = idFieldName; log.info("InMemoryRepositoryDelegate initialized for entity: {}", entityClass.getSimpleName()); } + /** + * 保存实体(新增或更新) + *

+ * 主键为 null 时自动生成;否则按主键覆盖更新。 + * + * @param entity 实体对象 + * @return 保存后的实体 + */ @Override public T save(T entity) { if (entity == null) { @@ -48,6 +67,7 @@ public T save(T entity) { } ID id = getIdValue(entity); if (id == null) { + // 主键为空,自动生成新 ID 并回填 id = generateId(); setIdValue(entity, id); } @@ -56,6 +76,11 @@ public T save(T entity) { return entity; } + /** + * 根据主键删除 + * + * @param id 主键 + */ @Override public void removeById(ID id) { if (id != null) { @@ -64,6 +89,12 @@ public void removeById(ID id) { } } + /** + * 根据主键查询 + * + * @param id 主键 + * @return 实体对象,不存在时返回 null + */ @Override public T findById(ID id) { if (id == null) { @@ -74,16 +105,34 @@ public T findById(ID id) { return entity; } + /** + * 根据主键查询(读操作路径) + * + * @param id 主键 + * @return 实体对象,不存在时返回 null + */ @Override public T queryById(ID id) { return findById(id); } + /** + * 根据主键查询(Optional 包装) + * + * @param id 主键 + * @return Optional 包装的实体 + */ @Override public Optional queryByIdOptional(ID id) { return Optional.ofNullable(queryById(id)); } + /** + * 条件查询单条记录 + * + * @param condition 查询条件(非空字段作为等值条件) + * @return 单条实体,不存在时返回 null + */ @Override public T queryOne(T condition) { if (condition == null) { @@ -93,11 +142,23 @@ public T queryOne(T condition) { return results.isEmpty() ? null : results.get(0); } + /** + * 条件查询单条记录(Optional 包装) + * + * @param condition 查询条件 + * @return Optional 包装的实体 + */ @Override public Optional queryOneOptional(T condition) { return Optional.ofNullable(queryOne(condition)); } + /** + * 条件查询列表 + * + * @param condition 查询条件,为 null 时返回全部 + * @return 实体列表 + */ @Override public List queryList(T condition) { if (condition == null) { @@ -108,6 +169,14 @@ public List queryList(T condition) { .collect(Collectors.toList()); } + /** + * 分页查询 + *

+ * 基于内存列表切片实现,pageNum/pageSize 为空时使用默认值 1/10。 + * + * @param reqPage 分页参数 + * @return 分页结果 + */ @Override public ResPage queryPage(ReqPage reqPage) { ResPage page = new ResPage<>(); @@ -116,6 +185,7 @@ public ResPage queryPage(ReqPage reqPage) { int pageNum = reqPage.getPage() != null ? reqPage.getPage() : 1; int pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; + // 计算总页数和切片起止索引 long pages = total > 0 ? (total + pageSize - 1) / pageSize : 0; int fromIndex = (pageNum - 1) * pageSize; int toIndex = Math.min(fromIndex + pageSize, allValues.size()); @@ -235,6 +305,12 @@ public void clear() { idGenerator.set(1); } + /** + * 批量保存 + * + * @param entities 实体列表 + * @return 保存后的实体列表 + */ @Override public List saveBatch(List entities) { if (entities == null || entities.isEmpty()) { @@ -245,6 +321,11 @@ public List saveBatch(List entities) { .collect(Collectors.toList()); } + /** + * 根据主键批量删除 + * + * @param ids 主键列表 + */ @Override public void removeBatchByIds(List ids) { if (ids != null) { @@ -252,6 +333,12 @@ public void removeBatchByIds(List ids) { } } + /** + * 根据主键列表批量查询 + * + * @param ids 主键列表 + * @return 实体列表(过滤掉不存在的) + */ @Override public List listByIds(List ids) { if (ids == null || ids.isEmpty()) { @@ -263,6 +350,12 @@ public List listByIds(List ids) { .collect(Collectors.toList()); } + /** + * 统计数量 + * + * @param condition 查询条件,为 null 时统计全部 + * @return 记录数量 + */ @Override public long count(T condition) { if (condition == null) { @@ -273,6 +366,12 @@ public long count(T condition) { .count(); } + /** + * 判断是否存在 + * + * @param condition 查询条件 + * @return true 表示存在 + */ @Override public boolean exists(T condition) { return count(condition) > 0; diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryBeanPostProcessor.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryBeanPostProcessor.java index c3affa4..4487b15 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryBeanPostProcessor.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryBeanPostProcessor.java @@ -17,24 +17,81 @@ import java.util.List; import java.util.Map; +/** + * 仓储 Bean 后处理器 + *

+ * 仓储框架的核心装配器,负责在 Spring 容器启动过程中完成 Delegate 收集、Facade 识别 + * 以及 Delegate → Facade 的自动注入。是 Facade + Delegate 模式的"装配枢纽"。 + *

+ * 工作流程分为三个阶段: + *

    + *
  1. 收集阶段({@link #postProcessBeforeInitialization}): + * 扫描所有带 {@link DelegateFor} 注解的 Bean,提取 Delegate 元信息并按 priority 降序排序
  2. + *
  3. 识别阶段({@link #postProcessAfterInitialization}): + * 识别所有 {@link RepositoryFacade} 实例并记录其 Bean 名称
  4. + *
  5. 注入阶段({@link #onApplicationEvent}): + * 容器刷新后,对每个 Facade 执行 6 步匹配查找 BASE Delegate, + * 并在 CQRS 模式下查找 READ Delegate,完成注入
  6. + *
+ *

+ * 当无任何匹配的 Delegate 时,依次尝试: + *

    + *
  1. 通过 {@link RepositoryDelegateFactory} 自动创建
  2. + *
  3. 回退到 {@link InMemoryRepositoryDelegate}(仅用于开发/测试)
  4. + *
+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Slf4j public class RepositoryBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware, ApplicationListener { + /** + * Spring 应用上下文,用于查找 {@link RepositoryDelegateFactory} Bean + */ private ApplicationContext applicationContext; + /** + * 已收集的 Delegate 元信息列表(按收集顺序,查找时按 priority 降序排序) + */ private final List delegateInfos = new ArrayList<>(); + /** + * 已识别的 RepositoryFacade 信息列表 + */ private final List facadeInfos = new ArrayList<>(); + /** + * 注入 Spring 应用上下文 + * + * @param applicationContext Spring 应用上下文 + * @throws BeansException 注入失败时抛出 + */ @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } + /** + * Bean 初始化前置处理:收集阶段 + *

+ * 检测 Bean 是否带有 {@link DelegateFor} 注解,若有则提取元信息: + *

    + *
  • 若是 {@link RepositoryDelegate},同时记录 delegate 和 queryDelegate 引用
  • + *
  • 若是 {@link IQueryDelegate}(仅实现查询接口),记录 queryDelegate 引用
  • + *
+ * + * @param bean 待处理的 Bean 实例 + * @param beanName Bean 名称 + * @return 原始 Bean(不进行任何修改) + * @throws BeansException 处理异常时抛出 + */ @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { DelegateFor annotation = bean.getClass().getAnnotation(DelegateFor.class); if (annotation != null) { + // 提取 @DelegateFor 注解的元数据 DelegateInfo info = new DelegateInfo(); info.beanName = beanName; info.name = annotation.name(); @@ -45,6 +102,7 @@ public Object postProcessBeforeInitialization(Object bean, String beanName) thro info.delegateClass = bean.getClass(); info.delegateType = annotation.delegateType(); + // 同时是 RepositoryDelegate 的,记录完整 delegate 引用 if (bean instanceof RepositoryDelegate) { info.delegate = (RepositoryDelegate) bean; if (bean instanceof IQueryDelegate) { @@ -56,6 +114,7 @@ public Object postProcessBeforeInitialization(Object bean, String beanName) thro info.delegateClass.getSimpleName(), info.priority); } + // 仅实现 IQueryDelegate(如专用的读代理),仅记录 queryDelegate 引用 if (bean instanceof IQueryDelegate) { info.queryDelegate = (IQueryDelegate) bean; log.info("Found IQueryDelegate: name={}, type={}, delegateType={}, poClass={}, delegateClass={}, priority={}", @@ -69,6 +128,16 @@ public Object postProcessBeforeInitialization(Object bean, String beanName) thro return bean; } + /** + * Bean 初始化后置处理:识别阶段 + *

+ * 识别 {@link RepositoryFacade} 实例并记录引用,待容器刷新时统一注入 Delegate。 + * + * @param bean 待处理的 Bean 实例 + * @param beanName Bean 名称 + * @return 原始 Bean + * @throws BeansException 处理异常时抛出 + */ @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof RepositoryFacade) { @@ -80,6 +149,13 @@ public Object postProcessAfterInitialization(Object bean, String beanName) throw return bean; } + /** + * 容器刷新事件处理:注入阶段 + *

+ * 容器刷新完成后,遍历所有 Facade,依次执行 Delegate 匹配与注入。 + * + * @param event 容器刷新事件 + */ @Override public void onApplicationEvent(ContextRefreshedEvent event) { for (RepositoryFacadeInfo facadeInfo : facadeInfos) { @@ -87,9 +163,25 @@ public void onApplicationEvent(ContextRefreshedEvent event) { } } + /** + * 为单个 RepositoryFacade 注入 Delegate + *

+ * 完整流程: + *

    + *
  1. 解析 Facade 子类的 4 个泛型参数(entity/id/po/delegateClass)
  2. + *
  3. 读取 @Repository 注解配置(type/cqrs/readDelegateClass)
  4. + *
  5. 查找 BASE Delegate:先匹配用户自定义 → 再尝试工厂自动创建 → 最后回退 InMemory
  6. + *
  7. 当 cqrs=true 且指定 readDelegateClass 时,查找 READ Delegate
  8. + *
  9. 注入 entityClass/poClass 到 Facade
  10. + *
+ * + * @param facade 待注入的 RepositoryFacade + * @param beanName Facade 的 Bean 名称 + */ @SuppressWarnings({"unchecked", "rawtypes"}) private void injectDelegatesToFacade(RepositoryFacade facade, String beanName) { try { + // 步骤 1:解析 Facade 子类继承 RepositoryFacade 时的泛型实参 Class[] genericTypes = getGenericTypes(facade.getClass()); if (genericTypes.length < 4) { log.debug("RepositoryFacade '{}' has insufficient generic types (need 4, got {})", beanName, genericTypes.length); @@ -101,6 +193,7 @@ private void injectDelegatesToFacade(RepositoryFacade facade, String beanName) { Class poClass = genericTypes[2]; Class delegateClass = genericTypes[3]; + // 步骤 2:读取 @Repository 注解配置(type/cqrs/readDelegateClass) Repository repositoryAnnotation = facade.getClass().getAnnotation(Repository.class); RepositoryType targetType = repositoryAnnotation != null ? repositoryAnnotation.type() : RepositoryType.AUTO; boolean cqrsEnabled = repositoryAnnotation != null && repositoryAnnotation.cqrs(); @@ -111,13 +204,16 @@ private void injectDelegatesToFacade(RepositoryFacade facade, String beanName) { poClass.getSimpleName(), delegateClass.getSimpleName(), targetType, cqrsEnabled, readDelegateClass != null ? readDelegateClass.getSimpleName() : "null"); + // 步骤 3:查找 BASE Delegate(6 步匹配) DelegateInfo baseDelegateInfo = findBaseDelegate(beanName, poClass, delegateClass, targetType); if (baseDelegateInfo != null && baseDelegateInfo.delegate != null) { + // 3a. 命中用户自定义 Delegate,直接注入 facade.setBaseDelegate(baseDelegateInfo.delegate); log.info("Injected BASE delegate '{}' (type={}) into RepositoryFacade '{}'", baseDelegateInfo.beanName, baseDelegateInfo.type, beanName); } else { + // 3b. 未命中,尝试通过 RepositoryDelegateFactory 自动创建 log.debug("No matching BASE delegate found for RepositoryFacade '{}', trying to auto-create delegate via factory", beanName); RepositoryDelegate autoDelegate = autoCreateDelegate(poClass, idClass, targetType); @@ -125,12 +221,14 @@ private void injectDelegatesToFacade(RepositoryFacade facade, String beanName) { facade.setBaseDelegate(autoDelegate); log.info("Auto-created BASE delegate (type={}) for RepositoryFacade '{}'", targetType, beanName); } else { + // 3c. 工厂也无法创建,回退到 InMemoryRepositoryDelegate(仅用于开发/测试) log.warn("No matching BASE delegate found for RepositoryFacade '{}', using default InMemoryRepositoryDelegate", beanName); RepositoryDelegate defaultDelegate = new InMemoryRepositoryDelegate(poClass); facade.setBaseDelegate(defaultDelegate); } } + // 步骤 4:CQRS 模式下查找 READ Delegate boolean shouldEnableReadDelegate = cqrsEnabled && readDelegateClass != null && readDelegateClass != Object.class; if (shouldEnableReadDelegate) { // READ 代理使用 AUTO 类型匹配,因为 CQRS 模式下读代理可能与写代理类型不同 @@ -147,6 +245,7 @@ private void injectDelegatesToFacade(RepositoryFacade facade, String beanName) { } } + // 步骤 5:注入 entityClass/poClass,供 Facade 做 Entity ↔ PO 反射转换 facade.setEntityClass(entityClass); facade.setPoClass(poClass); @@ -155,6 +254,20 @@ private void injectDelegatesToFacade(RepositoryFacade facade, String beanName) { } } + /** + * 通过 {@link RepositoryDelegateFactory} 自动创建 Delegate + *

+ * 当容器中存在工厂 Bean 且无自定义 Delegate 时使用。 + *

    + *
  • 指定了非 AUTO 类型:仅向同类型工厂请求创建
  • + *
  • 指定为 AUTO 类型:依次尝试所有工厂,首个成功即返回
  • + *
+ * + * @param poClass PO 类型 + * @param idClass 主键类型 + * @param targetType 期望的仓储类型 + * @return 创建成功的 Delegate,无法创建时返回 null + */ @SuppressWarnings({"unchecked", "rawtypes"}) private RepositoryDelegate autoCreateDelegate(Class poClass, Class idClass, RepositoryType targetType) { try { @@ -165,6 +278,7 @@ private RepositoryDelegate autoCreateDelegate(Class poClass, Class idClass return null; } + // 指定具体类型:仅匹配同类型工厂 if (targetType != RepositoryType.AUTO) { for (RepositoryDelegateFactory factory : factories.values()) { if (factory.getType() == targetType) { @@ -175,6 +289,7 @@ private RepositoryDelegate autoCreateDelegate(Class poClass, Class idClass } } } else { + // AUTO 类型:依次尝试所有工厂,首个成功即返回 for (RepositoryDelegateFactory factory : factories.values()) { try { RepositoryDelegate delegate = factory.createDelegate(poClass, idClass); @@ -193,12 +308,34 @@ private RepositoryDelegate autoCreateDelegate(Class poClass, Class idClass return null; } + /** + * 查找 BASE Delegate(6 步匹配,按优先级从严格到宽松) + *

+ * 候选集合:delegateType == BASE 且 delegate != null,按 priority 降序排序。 + * 匹配步骤(命中即返回,越靠前匹配条件越严格): + *

    + *
  1. delegateClass 兼容 + name 匹配 + type 匹配(最严格,三者全中)
  2. + *
  3. delegateClass 兼容 + type 匹配(忽略 name)
  4. + *
  5. delegateClass 兼容 + name 匹配(忽略 type)
  6. + *
  7. 仅 delegateClass 兼容(仅按类型)
  8. + *
  9. 仅 name 匹配(仅按名称)
  10. + *
  11. 仅 poClass 匹配(最宽松,按 PO 类型兜底)
  12. + *
+ * + * @param beanName Facade 的 Bean 名称 + * @param poClass PO 类型 + * @param delegateClass Delegate 类型(Facade 第四个泛型实参) + * @param targetType 期望的仓储类型(AUTO 表示不限制) + * @return 匹配的 DelegateInfo,无匹配时返回 null + */ private DelegateInfo findBaseDelegate(String beanName, Class poClass, Class delegateClass, RepositoryType targetType) { + // 候选集合:BASE 类型且 delegate 已就绪,按 priority 降序 List filtered = delegateInfos.stream() .filter(info -> info.delegateType == DelegateType.BASE && info.delegate != null) .sorted(Comparator.comparingInt((DelegateInfo i) -> i.priority).reversed()) .toList(); + // 第 1 步:delegateClass 兼容 + name 匹配 + type 匹配(最严格) for (DelegateInfo info : filtered) { if (delegateClass.isAssignableFrom(info.delegateClass) && info.name != null && !info.name.isEmpty() && info.name.equals(beanName) @@ -207,6 +344,7 @@ private DelegateInfo findBaseDelegate(String beanName, Class poClass, Class poClass, Class poClass, Class poClass, Class + * 候选集合:delegateType == READ 且 queryDelegate != null,按 priority 降序排序。 + * 用于 CQRS 模式下匹配读代理,匹配步骤与 BASE 完全一致,区别仅在于候选类型。 + * + * @param beanName Facade 的 Bean 名称 + * @param poClass PO 类型 + * @param readDelegateClass 读代理类型(@Repository#readDelegateClass) + * @param targetType 期望的仓储类型 + * @return 匹配的 DelegateInfo,无匹配时返回 null + */ private DelegateInfo findReadDelegate(String beanName, Class poClass, Class readDelegateClass, RepositoryType targetType) { + // 候选集合:READ 类型且 queryDelegate 已就绪,按 priority 降序 List filtered = delegateInfos.stream() .filter(info -> info.delegateType == DelegateType.READ && info.queryDelegate != null) .sorted(Comparator.comparingInt((DelegateInfo i) -> i.priority).reversed()) .toList(); + // 第 1 步:readDelegateClass 兼容 + name 匹配 + type 匹配 for (DelegateInfo info : filtered) { if (readDelegateClass.isAssignableFrom(info.delegateClass) && info.name != null && !info.name.isEmpty() && info.name.equals(beanName) @@ -256,6 +412,7 @@ private DelegateInfo findReadDelegate(String beanName, Class poClass, Class poClass, Class poClass, Class poClass, Class + * 沿父类链向上查找首个参数化继承 RepositoryFacade 的位置, + * 返回其实际类型参数数组(长度应为 4:entity/id/po/delegate)。 + * 支持嵌套 ParameterizedType 场景。 + * + * @param clazz 待解析的类 + * @return 泛型实参数组,无法解析时返回空数组 + */ private Class[] getGenericTypes(Class clazz) { Class currentClass = clazz; while (currentClass != null && currentClass != Object.class) { @@ -298,13 +469,16 @@ private Class[] getGenericTypes(Class clazz) { if (superclass instanceof ParameterizedType) { ParameterizedType parameterizedType = (ParameterizedType) superclass; Type rawType = parameterizedType.getRawType(); + // 定位到 RepositoryFacade 的参数化父类 if (rawType instanceof Class && RepositoryFacade.class.isAssignableFrom((Class) rawType)) { Type[] typeArgs = parameterizedType.getActualTypeArguments(); Class[] classes = new Class[typeArgs.length]; for (int i = 0; i < typeArgs.length; i++) { + // 普通类型实参直接使用 if (typeArgs[i] instanceof Class) { classes[i] = (Class) typeArgs[i]; } else if (typeArgs[i] instanceof ParameterizedType) { + // 嵌套泛型(如 Delegate)取其原始类型 Type raw = ((ParameterizedType) typeArgs[i]).getRawType(); if (raw instanceof Class) { classes[i] = (Class) raw; @@ -319,21 +493,43 @@ private Class[] getGenericTypes(Class clazz) { return new Class[0]; } + /** + * Delegate 元信息内部载体 + *

+ * 封装从 {@link DelegateFor} 注解提取的所有元数据,以及对应的 Bean 引用。 + */ private static class DelegateInfo { + /** Bean 名称 */ String beanName; + /** @DelegateFor#name() 声明的仓储名称 */ String name; + /** 存储类型 */ RepositoryType type; + /** PO 类型 */ Class poClass; + /** 优先级(数字越大越优先) */ int priority; + /** 描述信息 */ String description; + /** Delegate 实现类 */ Class delegateClass; + /** RepositoryDelegate 引用(若 Bean 实现了该接口) */ RepositoryDelegate delegate; + /** IQueryDelegate 引用(若 Bean 实现了该接口) */ IQueryDelegate queryDelegate; + /** 委托类型(BASE/READ) */ DelegateType delegateType; } + /** + * RepositoryFacade 元信息内部载体 + *

+ * 封装 Facade 实例及其 Bean 名称,供容器刷新时批量注入使用。 + */ private static class RepositoryFacadeInfo { + /** Facade 实例 */ RepositoryFacade facade; + /** Bean 名称 */ String beanName; } } \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacade.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacade.java index 96b445f..308c076 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacade.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacade.java @@ -39,6 +39,9 @@ @Slf4j public class RepositoryFacade> implements ICrudRepository { + /** + * 数据权限缓存管理器(保留扩展点,子类可启用缓存) + */ protected DataScopeCacheManager cacheManager; /** @@ -58,13 +61,28 @@ public class RepositoryFacade> imp */ protected IQueryDelegate readDelegate; + /** + * 领域实体类型,用于 Entity ↔ PO 反射转换 + */ protected Class entityClass; + /** + * 持久化对象类型,用于 Entity ↔ PO 反射转换 + */ protected Class

poClass; + /** + * 默认构造函数(用于无参实例化场景,需后续手动设置 entityClass/poClass) + */ public RepositoryFacade() { } + /** + * 根据实体类和 PO 类构造 RepositoryFacade + * + * @param entityClass 领域实体类型 + * @param poClass 持久化对象类型 + */ public RepositoryFacade(Class entityClass, Class

poClass) { this.entityClass = entityClass; this.poClass = poClass; @@ -79,31 +97,68 @@ public D getBaseDelegate() { return baseDelegate; } + /** + * 保存实体(写操作,走 baseDelegate) + *

+ * 流程:Entity → PO → baseDelegate.save → PO → Entity + * + * @param entity 领域实体 + * @return 保存后的实体(包含可能生成的主键) + */ @Override public T save(T entity) { + // Entity → PO 转换后交给基础代理持久化,再回转为 Entity P save = baseDelegate.save(toPo(entity)); return toEntity(save); } + /** + * 根据主键删除(写操作,走 baseDelegate) + * + * @param id 主键 + */ @Override public void removeById(ID id) { baseDelegate.removeById(id); } + /** + * 根据主键查询(写操作路径,走 baseDelegate) + *

+ * 此方法对应 ICrudRepository 契约,不参与 CQRS 路由,始终走基础代理。 + * + * @param id 主键 + * @return 实体对象,不存在时返回 null + */ @Override public T findById(ID id) { P po = baseDelegate.findById(id); return toEntity(po); } + /** + * 根据主键查询(读操作,支持 CQRS 路由) + *

+ * 优先走 readDelegate,失败回退到 baseDelegate + * + * @param id 主键 + * @return 实体对象,不存在时返回 null + */ @Override public T queryById(ID id) { + // 读操作:优先 readDelegate,失败回退 baseDelegate P po = executeReadOperation( () -> readDelegate.queryById(id), () -> baseDelegate.queryById(id)); return toEntity(po); } + /** + * 根据主键查询(Optional 包装,读操作,支持 CQRS 路由) + * + * @param id 主键 + * @return Optional 包装的实体 + */ @Override public Optional queryByIdOptional(ID id) { P po = executeReadOperation( @@ -112,6 +167,12 @@ public Optional queryByIdOptional(ID id) { return Optional.ofNullable(toEntity(po)); } + /** + * 条件查询单条记录(读操作,支持 CQRS 路由) + * + * @param entity 查询条件(非空属性作为等值条件) + * @return 单条实体,不存在时返回 null + */ @Override public T queryOne(T entity) { P p = executeReadOperation( @@ -120,6 +181,12 @@ public T queryOne(T entity) { return toEntity(p); } + /** + * 条件查询单条记录(Optional 包装,读操作,支持 CQRS 路由) + * + * @param entity 查询条件 + * @return Optional 包装的实体 + */ @Override public Optional queryOneOptional(T entity) { Optional

p = executeReadOperation( @@ -128,6 +195,12 @@ public Optional queryOneOptional(T entity) { return p.map(this::toEntity); } + /** + * 条件查询列表(读操作,支持 CQRS 路由) + * + * @param entity 查询条件,为 null 时查询全部 + * @return 实体列表,永远不为 null + */ @Override public List queryList(T entity) { List

poList = executeReadOperation( @@ -136,11 +209,20 @@ public List queryList(T entity) { if (poList == null || poList.isEmpty()) { return List.of(); } + // PO 列表批量转换为 Entity 列表 return poList.stream() .map(this::toEntity) .toList(); } + /** + * 分页查询(读操作,支持 CQRS 路由) + *

+ * 由于分页结果结构 {@link ResPage} 与实体类型绑定,需要逐项转换 PO → Entity。 + * + * @param reqPage 分页参数 + * @return 分页结果,代理返回 null 时本方法也返回 null + */ @Override public ResPage queryPage(ReqPage reqPage) { ResPage

poPage = executeReadOperation( @@ -149,6 +231,7 @@ public ResPage queryPage(ReqPage reqPage) { if (poPage == null) { return null; } + // 复制分页元数据,仅对 records 进行 PO → Entity 转换 ResPage tPage = new ResPage<>(); tPage.setCurrent(poPage.getCurrent()); tPage.setPages(poPage.getPages()); @@ -162,11 +245,18 @@ public ResPage queryPage(ReqPage reqPage) { return tPage; } + /** + * 批量保存(写操作,走 baseDelegate) + * + * @param entities 实体列表 + * @return 保存后的实体列表 + */ @Override public List saveBatch(List entities) { if (entities == null || entities.isEmpty()) { return List.of(); } + // Entity 列表 → PO 列表,批量持久化后再回转 List

poList = entities.stream() .map(this::toPo) .toList(); @@ -176,11 +266,22 @@ public List saveBatch(List entities) { .toList(); } + /** + * 根据主键批量删除(写操作,走 baseDelegate) + * + * @param ids 主键列表 + */ @Override public void removeBatchByIds(List ids) { baseDelegate.removeBatchByIds(ids); } + /** + * 根据主键列表批量查询(读操作,支持 CQRS 路由) + * + * @param ids 主键列表 + * @return 实体列表,永远不为 null + */ @Override public List listByIds(List ids) { List

poList = executeReadOperation( @@ -194,6 +295,12 @@ public List listByIds(List ids) { .toList(); } + /** + * 统计数量(读操作,支持 CQRS 路由) + * + * @param entity 查询条件 + * @return 记录数量 + */ @Override public long count(T entity) { return executeReadOperation( @@ -201,6 +308,12 @@ public long count(T entity) { () -> baseDelegate.count(toPo(entity))); } + /** + * 判断是否存在(写操作路径,走 baseDelegate) + * + * @param entity 查询条件 + * @return true 表示存在 + */ @Override public boolean exists(T entity) { return baseDelegate.exists(toPo(entity)); @@ -238,6 +351,16 @@ protected interface ReadOperation { R execute(); } + /** + * PO → Entity 转换 + *

+ * 通过反射调用无参构造函数创建 Entity 实例,并使用 {@link BeanUtils#copyProperties} + * 复制同名属性。子类可重写以实现自定义映射逻辑。 + * + * @param po 持久化对象,为 null 时返回 null + * @return 领域实体 + * @throws RuntimeException 反射创建实例或属性复制失败时抛出 + */ protected T toEntity(P po) { if (po == null) { return null; @@ -251,6 +374,16 @@ protected T toEntity(P po) { } } + /** + * Entity → PO 转换 + *

+ * 通过反射调用无参构造函数创建 PO 实例,并使用 {@link BeanUtils#copyProperties} + * 复制同名属性。子类可重写以实现自定义映射逻辑。 + * + * @param entity 领域实体,为 null 时返回 null + * @return 持久化对象 + * @throws RuntimeException 反射创建实例或属性复制失败时抛出 + */ protected P toPo(T entity) { if (entity == null) { return null; diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacadeFactoryBean.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacadeFactoryBean.java index eed7a4a..813ef6e 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacadeFactoryBean.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacadeFactoryBean.java @@ -3,14 +3,54 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.FactoryBean; +/** + * RepositoryFacade 的 Spring {@link FactoryBean} 实现 + *

+ * 用于以编程方式注册 {@link RepositoryFacade} Bean,封装了创建过程所需的元数据 + * (entity 类型、PO 类型、Delegate 类型、{@link RepositoryDefinition})。 + *

+ * 实际的 Delegate 注入不在此处完成,而是由 {@link RepositoryBeanPostProcessor} + * 在容器刷新事件中统一处理。 + * + * @param 领域实体类型 + * @param 主键类型 + * @param

持久化对象类型(PO) + * @param 基础委托类型 + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ @Slf4j public class RepositoryFacadeFactoryBean> implements FactoryBean> { + /** + * 领域实体类型 + */ private final Class entityClass; + + /** + * 持久化对象类型 + */ private final Class

poClass; + + /** + * 基础委托类型,用于在 BeanPostProcessor 中匹配 Delegate + */ private final Class delegateClass; + + /** + * 仓储定义元数据,封装 @Repository 注解的配置信息 + */ private final RepositoryDefinition definition; + /** + * 构造 FactoryBean + * + * @param entityClass 领域实体类型 + * @param poClass 持久化对象类型 + * @param delegateClass 基础委托类型 + * @param definition 仓储定义元数据 + */ public RepositoryFacadeFactoryBean(Class entityClass, Class

poClass, Class delegateClass, RepositoryDefinition definition) { this.entityClass = entityClass; this.poClass = poClass; @@ -18,6 +58,14 @@ public RepositoryFacadeFactoryBean(Class entityClass, Class

poClass, Class this.definition = definition; } + /** + * 创建 RepositoryFacade 实例 + *

+ * 仅创建 Facade 本身并注入 entityClass/poClass,Delegate 的注入由 + * {@link RepositoryBeanPostProcessor#onApplicationEvent} 完成。 + * + * @return RepositoryFacade 实例 + */ @Override public RepositoryFacade getObject() { log.debug("Creating RepositoryFacade for entity: {}, po: {}, delegate: {}", @@ -25,17 +73,32 @@ public RepositoryFacade getObject() { return new RepositoryFacade<>(entityClass, poClass); } + /** + * 返回 FactoryBean 产出的对象类型 + * + * @return RepositoryFacade 类型 + */ @Override @SuppressWarnings("unchecked") public Class getObjectType() { return RepositoryFacade.class; } + /** + * 声明为单例 Bean + * + * @return 始终返回 true + */ @Override public boolean isSingleton() { return true; } + /** + * 获取仓储定义元数据 + * + * @return 仓储定义 + */ public RepositoryDefinition getDefinition() { return definition; } diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryType.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryType.java index ce00375..4678751 100644 --- a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryType.java +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryType.java @@ -1,9 +1,12 @@ package cn.structure.infra.repository; /** + * 仓储类型枚举 *

- * 仓储类型 - *

+ * 标识底层持久化技术的种类,用于在 {@link RepositoryDelegateFactory} 创建 Delegate、 + * 以及在 {@link RepositoryBeanPostProcessor} 匹配 Delegate 时进行类型筛选。 + *

+ * {@link #AUTO} 表示由框架自动推断,匹配任意可用类型。 * * @author chuck * @version 1.0.1 @@ -11,21 +14,48 @@ */ public enum RepositoryType { + /** + * 原生 MyBatis + */ MYBATIS, + /** + * MyBatis-Plus 增强 + */ MYBATIS_PLUS, + /** + * JPA / Hibernate + */ JPA, + /** + * 原生 JDBC + */ JDBC, + /** + * 通用 NoSQL(泛指) + */ NOSQL, + /** + * Redis 缓存数据库 + */ REDIS, + /** + * MongoDB 文档数据库 + */ MONGODB, + /** + * Elasticsearch 搜索引擎 + */ ELASTICSEARCH, + /** + * 自动推断类型,匹配任意可用的 Delegate + */ AUTO } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamEventListener.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamEventListener.java index 9969f78..3784d46 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamEventListener.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamEventListener.java @@ -4,29 +4,105 @@ import java.lang.annotation.*; +/** + * 传统消息监听注解,用于将方法或类绑定为 Spring Cloud Stream 的消息监听器。 + * + *

设计意图: + *

    + *
  • 提供与 Spring Cloud Stream 原生 @Bean Function/Consumer 等价的声明式编程模型
  • + *
  • 通过 {@link EventListenerBeanPostProcessor} 在 Bean 初始化阶段自动扫描注解方法并注册到 {@code StreamEventManager}
  • + *
  • 通过 {@link StreamBindingBeanFactoryPostProcessor} 在 BeanFactory 阶段自动生成对应的 binding 配置
  • + *
+ * + *

协作关系: + *

    + *
  • 与 {@link StreamRouteHandler} 互为补充:本注解面向"按 binding 绑定的传统监听"场景, + * 而 {@link StreamRouteHandler} 面向"按 eventType/businessType 路由"场景
  • + *
  • 支持方法级与类级标注;类级标注时需提供 {@code handle(T)} 方法
  • + *
+ * + *

使用示例: + *

{@code
+ * @StreamEventListener("orderListener")
+ * public void onOrder(OrderEvent event) { ... }
+ * }
+ * + * @see StreamRouteHandler + * @see cn.structure.infra.stream.processor.EventListenerBeanPostProcessor + * @see cn.structure.infra.stream.processor.StreamBindingBeanFactoryPostProcessor + */ @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface StreamEventListener { + /** + * 绑定名称的快捷属性,与 {@link #bindingName()} 互为别名。 + *

当仅指定简单绑定名时使用,例如 @StreamEventListener("orderListener")。 + * + * @return 绑定名称,默认空字符串 + */ @AliasFor("bindingName") String value() default ""; + /** + * 显式绑定名称,与 {@link #value()} 互为别名。 + *

用于在 {@code StreamProperties.bindings} 中查找或注册对应的 binding 元数据。 + * + * @return 绑定名称,默认空字符串 + */ @AliasFor("value") String bindingName() default ""; + /** + * 目标 destination(即 exchange/topic),若不指定则由绑定名自动派生为 {name}-exchange。 + * + * @return destination 名称,默认空字符串 + */ String destination() default ""; + /** + * 消费者组名,用于同组内负载均衡、跨组广播。留空时使用全局 default-group。 + * + * @return group 名称,默认空字符串 + */ String group() default ""; + /** + * 消息内容类型,影响序列化/反序列化行为。 + * + * @return content-type,默认 {@code application/json} + */ String contentType() default "application/json"; + /** + * 事件负载类型,用于在 {@code StreamEventManager#dispatch} 时进行类型过滤。 + *

默认 {@code Object.class} 表示不限制类型,框架将回退到方法首个参数类型推断。 + * + * @return 事件负载 Class,默认 {@code Object.class} + */ Class eventType() default Object.class; + /** + * 消费者配置前缀,用于扩展生成 Spring Cloud Stream 的 consumer 属性键。 + * + * @return consumer 前缀,默认 {@code "consumer"} + */ String consumerPrefix() default "consumer"; + /** + * 生产者配置前缀,用于扩展生成 Spring Cloud Stream 的 producer 属性键。 + * + * @return producer 前缀,默认 {@code "producer"} + */ String producerPrefix() default "producer"; + /** + * SpEL 条件表达式,仅当表达式求值为 {@code true} 时才会派发到当前监听器。 + *

表达式中可通过 {@code #event} 引用事件对象,例如 #event.amount > 100。 + * + * @return SpEL 条件表达式,默认空字符串表示无条件 + */ String condition() default ""; } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamRouteHandler.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamRouteHandler.java index 30ebc16..02777b4 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamRouteHandler.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/annotation/StreamRouteHandler.java @@ -4,19 +4,72 @@ import java.lang.annotation.*; +/** + * 声明式路由处理器注解,标注在方法上即自动注册为 {@code StreamEventRouter} 的路由。 + * + *

设计意图: + *

    + *
  • 提供面向"事件类型 + 业务类型"的统一路由编程模型,与 Spring Cloud Stream 的 binding 模型解耦
  • + *
  • 通过 {@link cn.structure.infra.stream.router.RouteHandlerBeanPostProcessor} 在 Bean 初始化后 + * 自动扫描注解方法,并调用 {@code StreamEventRouter#registerRoute} 完成注册
  • + *
  • 路由匹配遵循 4 步规则:eventType 精确匹配 → businessType 通配匹配 → payloadType 类型匹配 → SpEL condition 条件求值
  • + *
+ * + *

协作关系: + *

    + *
  • {@link #eventType()} 必填,作为路由分发的第一维索引
  • + *
  • {@link #businessType()} 可选,支持 {@code "*"} 通配符,作为第二维筛选条件
  • + *
  • {@link #condition()} 可选,使用 SpEL 表达式,对 payload 进行精细化条件求值
  • + *
+ * + *

使用示例: + *

{@code
+ * @StreamRouteHandler(eventType = "order", businessType = "create")
+ * public void handleCreateOrder(OrderPayload payload) { ... }
+ * }
+ * + * @see cn.structure.infra.stream.router.StreamEventRouter + * @see cn.structure.infra.stream.router.RouteHandlerBeanPostProcessor + * @see StreamEventListener + */ @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface StreamRouteHandler { + /** + * 事件类型的快捷属性,与 {@link #eventType()} 互为别名。 + *

当仅需指定 eventType 时使用,例如 @StreamRouteHandler("order")。 + * + * @return 事件类型,默认空字符串 + */ @AliasFor("eventType") String value() default ""; + /** + * 事件类型,作为路由分发的第一维索引键,必填。 + *

路由器内部以 eventType 作为 Map key 索引所有候选路由。 + * + * @return 事件类型,默认空字符串(运行时由框架校验非空) + */ @AliasFor("value") String eventType() default ""; + /** + * 业务类型,作为路由匹配的第二维筛选条件,可选。 + *

支持 {@code "*"} 通配符表示匹配任意 businessType;留空同样表示不参与匹配。 + * + * @return 业务类型或通配符,默认空字符串 + */ String businessType() default ""; + /** + * SpEL 条件表达式,作为路由匹配的第四步精细化筛选,可选。 + *

表达式中可通过 {@code #payload} 引用事件负载对象, + * 例如 #payload.amount > 100。 + * + * @return SpEL 条件表达式,默认空字符串表示无条件 + */ String condition() default ""; } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/configuration/StreamAutoConfiguration.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/configuration/StreamAutoConfiguration.java index 7c8f739..f112d03 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/configuration/StreamAutoConfiguration.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/configuration/StreamAutoConfiguration.java @@ -16,12 +16,46 @@ import org.springframework.cloud.stream.function.StreamBridge; import org.springframework.context.annotation.Bean; +/** + * stream 模块的自动配置类,注册所有核心 Bean。 + * + *

设计意图: + *

    + *
  • 作为 Spring Boot 自动配置入口,统一装配 stream 模块所需的核心组件
  • + *
  • 通过 {@code @ConditionalOnClass(StreamBridge.class)} 保证仅当 Spring Cloud Stream 在类路径时才激活
  • + *
  • 通过 {@code @ConditionalOnProperty} 提供 enabled 全局开关,默认启用(matchIfMissing = true)
  • + *
  • 通过 {@code @EnableConfigurationProperties} 同时启用 {@link StreamProperties} 与 {@link RouterProperties}
  • + *
+ * + *

注册的 Bean: + *

    + *
  • {@link StreamEventManager}:事件管理器(默认实现 {@link DefaultStreamEventManagerImpl})
  • + *
  • {@link StreamEventRouter}:路由网关(默认实现 {@link DefaultStreamEventRouterImpl})
  • + *
  • {@link StreamBindingBeanFactoryPostProcessor}:BeanFactory 阶段的自动 binding 注册器(static)
  • + *
  • {@link EventListenerBeanPostProcessor}:Bean 阶段的自动监听器注册器
  • + *
+ * + *

注意:{@link cn.structure.infra.stream.router.RouteHandlerBeanPostProcessor} 与 + * {@link cn.structure.infra.stream.router.ConfigurableRouteInitializer} 通过自身 {@code @Component} + * 注解由组件扫描自动注册,故本配置类不再重复声明。 + * + * @see StreamProperties + * @see RouterProperties + */ @AutoConfiguration @ConditionalOnClass({StreamBridge.class}) @ConditionalOnProperty(prefix = "structure.infra.stream", name = "enabled", havingValue = "true", matchIfMissing = true) @EnableConfigurationProperties({StreamProperties.class, RouterProperties.class}) public class StreamAutoConfiguration { + /** + * 注册事件管理器 Bean,默认实现为 {@link DefaultStreamEventManagerImpl}。 + *

当容器中不存在 {@link StreamEventManager} 时才创建,允许用户自定义覆盖。 + * + * @param streamBridge Spring Cloud Stream 桥接器 + * @param streamProperties stream 主配置 + * @return 事件管理器实例 + */ @Bean @ConditionalOnMissingBean public StreamEventManager streamEventManager(StreamBridge streamBridge, @@ -29,21 +63,40 @@ public StreamEventManager streamEventManager(StreamBridge streamBridge, return new DefaultStreamEventManagerImpl(streamBridge, streamProperties); } + /** + * 注册路由网关 Bean,默认实现为 {@link DefaultStreamEventRouterImpl}。 + *

当容器中不存在 {@link StreamEventRouter} 时才创建,允许用户自定义覆盖。 + * + * @return 路由网关实例 + */ @Bean @ConditionalOnMissingBean public StreamEventRouter streamEventRouter() { return new DefaultStreamEventRouterImpl(); } + /** + * 注册 {@link StreamBindingBeanFactoryPostProcessor} Bean,用于在 BeanFactory 阶段扫描注解并自动注册 binding。 + *

声明为 static 是为了保证其在所有 Bean 实例化之前执行,避免提前触发 Bean 创建。 + * + * @return BeanFactory 后置处理器实例 + */ @Bean public static StreamBindingBeanFactoryPostProcessor streamBindingBeanFactoryPostProcessor() { return new StreamBindingBeanFactoryPostProcessor(); } + /** + * 注册 {@link EventListenerBeanPostProcessor} Bean,用于在 Bean 初始化阶段扫描 {@code @StreamEventListener} 注解并注册监听器。 + * + * @param streamEventManager 事件管理器 SPI + * @param streamProperties stream 主配置 + * @return Bean 后置处理器实例 + */ @Bean public EventListenerBeanPostProcessor eventListenerBeanPostProcessor(StreamEventManager streamEventManager, StreamProperties streamProperties) { return new EventListenerBeanPostProcessor(streamEventManager, streamProperties); } -} \ No newline at end of file +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/event/StreamEvent.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/event/StreamEvent.java index d7dabf2..75f4ac2 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/event/StreamEvent.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/event/StreamEvent.java @@ -4,20 +4,80 @@ import java.util.HashMap; import java.util.Map; +/** + * 统一事件信封(Event Envelope),承载路由器路由所需的所有元数据与业务负载。 + * + *

设计意图: + *

    + *
  • 作为 {@code StreamEventRouter} 与 {@code StreamEventManager} 的统一传输载体, + * 将路由元数据(eventType/businessType)与业务负载(payload)解耦
  • + *
  • 提供 {@link #eventId}、{@link #traceId} 等追踪字段,便于全链路日志关联
  • + *
  • headers 字段提供扩展能力,承载自定义元信息而不污染 payload 结构
  • + *
+ * + *

协作关系: + *

    + *
  • 由生产者通过 {@link #of(String, Object)} 或 {@link #builder()} 构建后投递
  • + *
  • 由 {@code StreamEventRouter#route} 解析 eventType/businessType/payload 后分发至匹配的处理器
  • + *
  • 泛型 T 表示业务负载类型,便于路由器做 payloadType 类型匹配(路由第 3 步)
  • + *
+ * + * @param 业务负载类型 + */ public class StreamEvent { + /** + * 事件唯一标识,默认由 UUID 自动生成,用于事件去重与全链路追踪。 + */ private String eventId; + /** + * 事件类型,作为路由匹配第 1 步的精确匹配键,与 {@code StreamEventRouter} 内部 Map key 对应。 + */ private String eventType; + /** + * 业务类型,作为路由匹配第 2 步的筛选条件,支持 {@code "*"} 通配符匹配。 + */ private String businessType; + /** + * 事件来源标识,可用于区分生产系统/模块。 + */ private String source; + /** + * 事件发生时间戳。 + */ private LocalDateTime timestamp; + /** + * 业务负载,作为路由匹配第 3 步 payloadType 类型检查的目标对象, + * 也是 SpEL condition 表达式中 {@code #payload} 变量所引用的对象。 + */ private T payload; + /** + * 自定义消息头,承载不影响路由匹配的扩展元信息。 + */ private Map headers = new HashMap<>(); + /** + * 链路追踪 ID,用于跨服务日志关联。 + */ private String traceId; + /** + * 默认构造方法,供框架反序列化或 Builder 使用。 + */ public StreamEvent() { } + /** + * 全参构造方法,构建完整的事件信封。 + * + * @param eventId 事件唯一标识 + * @param eventType 事件类型 + * @param businessType 业务类型,可为 null + * @param source 事件来源 + * @param timestamp 事件时间戳 + * @param payload 业务负载 + * @param headers 自定义消息头,为 null 时使用空 Map + * @param traceId 链路追踪 ID + */ public StreamEvent(String eventId, String eventType, String businessType, String source, LocalDateTime timestamp, T payload, Map headers, String traceId) { this.eventId = eventId; @@ -30,70 +90,127 @@ public StreamEvent(String eventId, String eventType, String businessType, String this.traceId = traceId; } + /** + * @return 事件唯一标识 + */ public String getEventId() { return eventId; } + /** + * @param eventId 事件唯一标识 + */ public void setEventId(String eventId) { this.eventId = eventId; } + /** + * @return 事件类型,路由匹配第 1 步键 + */ public String getEventType() { return eventType; } + /** + * @param eventType 事件类型 + */ public void setEventType(String eventType) { this.eventType = eventType; } + /** + * @return 业务类型,路由匹配第 2 步筛选条件 + */ public String getBusinessType() { return businessType; } + /** + * @param businessType 业务类型 + */ public void setBusinessType(String businessType) { this.businessType = businessType; } + /** + * @return 事件来源标识 + */ public String getSource() { return source; } + /** + * @param source 事件来源 + */ public void setSource(String source) { this.source = source; } + /** + * @return 事件时间戳 + */ public LocalDateTime getTimestamp() { return timestamp; } + /** + * @param timestamp 事件时间戳 + */ public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; } + /** + * @return 业务负载 + */ public T getPayload() { return payload; } + /** + * @param payload 业务负载 + */ public void setPayload(T payload) { this.payload = payload; } + /** + * @return 自定义消息头 Map,永不为 null + */ public Map getHeaders() { return headers; } + /** + * @param headers 自定义消息头,为 null 时置为空 Map + */ public void setHeaders(Map headers) { this.headers = headers != null ? headers : new HashMap<>(); } + /** + * @return 链路追踪 ID + */ public String getTraceId() { return traceId; } + /** + * @param traceId 链路追踪 ID + */ public void setTraceId(String traceId) { this.traceId = traceId; } + /** + * 快捷工厂方法:仅指定 eventType 与 payload,自动生成 UUID、当前时间戳、空 headers。 + *

businessType/source/traceId 均为 null,适用于不参与业务类型筛选的简单场景。 + * + * @param eventType 事件类型 + * @param payload 业务负载 + * @param 负载类型 + * @return 新构建的 StreamEvent 信封 + */ public static StreamEvent of(String eventType, T payload) { return new StreamEvent<>( java.util.UUID.randomUUID().toString(), @@ -107,6 +224,16 @@ public static StreamEvent of(String eventType, T payload) { ); } + /** + * 快捷工厂方法:指定 eventType、businessType 与 payload,自动生成 UUID、当前时间戳、空 headers。 + *

适用于参与 businessType 路由筛选的场景。 + * + * @param eventType 事件类型 + * @param businessType 业务类型 + * @param payload 业务负载 + * @param 负载类型 + * @return 新构建的 StreamEvent 信封 + */ public static StreamEvent of(String eventType, String businessType, T payload) { return new StreamEvent<>( java.util.UUID.randomUUID().toString(), @@ -120,10 +247,21 @@ public static StreamEvent of(String eventType, String businessType, T pay ); } + /** + * 创建一个 Builder 以便逐步构建复杂事件信封。 + * + * @param 负载类型 + * @return 新的 Builder 实例 + */ public static Builder builder() { return new Builder<>(); } + /** + * StreamEvent 的链式构建器,用于灵活组装事件信封的各字段。 + * + * @param 业务负载类型 + */ public static class Builder { private String eventId; private String eventType; @@ -134,46 +272,83 @@ public static class Builder { private Map headers = new HashMap<>(); private String traceId; + /** + * @param eventId 事件唯一标识 + * @return 当前 Builder + */ public Builder eventId(String eventId) { this.eventId = eventId; return this; } + /** + * @param eventType 事件类型 + * @return 当前 Builder + */ public Builder eventType(String eventType) { this.eventType = eventType; return this; } + /** + * @param businessType 业务类型 + * @return 当前 Builder + */ public Builder businessType(String businessType) { this.businessType = businessType; return this; } + /** + * @param source 事件来源 + * @return 当前 Builder + */ public Builder source(String source) { this.source = source; return this; } + /** + * @param timestamp 事件时间戳 + * @return 当前 Builder + */ public Builder timestamp(LocalDateTime timestamp) { this.timestamp = timestamp; return this; } + /** + * @param payload 业务负载 + * @return 当前 Builder + */ public Builder payload(T payload) { this.payload = payload; return this; } + /** + * @param headers 自定义消息头 + * @return 当前 Builder + */ public Builder headers(Map headers) { this.headers = headers; return this; } + /** + * @param traceId 链路追踪 ID + * @return 当前 Builder + */ public Builder traceId(String traceId) { this.traceId = traceId; return this; } + /** + * 终结方法,生成不可变 StreamEvent 实例。 + * + * @return 新构建的 StreamEvent + */ public StreamEvent build() { return new StreamEvent<>(eventId, eventType, businessType, source, timestamp, payload, headers, traceId); } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/handler/StreamEventHandler.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/handler/StreamEventHandler.java index e64b4ce..c53604e 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/handler/StreamEventHandler.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/handler/StreamEventHandler.java @@ -1,7 +1,30 @@ package cn.structure.infra.stream.handler; +/** + * 事件处理器函数接口,由 {@code StreamEventManager} 在 {@code dispatch} 时回调。 + * + *

设计意图: + *

    + *
  • 提供传统消息监听模型下的统一处理入口,与 {@code StreamEventRouter.StreamRouteHandler} 区分
  • + *
  • 使用函数接口风格,便于以 Lambda 形式注册到 {@code StreamEventManager#registerListener}
  • + *
  • 泛型 T 与监听器的 eventType 绑定,框架在派发前已完成类型过滤
  • + *
+ * + *

协作关系: + *

    + *
  • 由 {@link cn.structure.infra.stream.manager.ListenerRegistration} 持有
  • + *
  • 由 {@code EventListenerBeanPostProcessor} 在扫描 {@code @StreamEventListener} 方法时包装为 Lambda 实例
  • + *
+ * + * @param 事件负载类型 + */ public interface StreamEventHandler { + /** + * 处理单个事件。 + * + * @param event 事件负载,类型由监听器注册时声明的 eventType 决定 + */ void handle(T event); } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/DefaultStreamEventManagerImpl.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/DefaultStreamEventManagerImpl.java index 676a508..ce8742a 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/DefaultStreamEventManagerImpl.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/DefaultStreamEventManagerImpl.java @@ -18,20 +18,66 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +/** + * {@link StreamEventManager} 的默认实现,整合 Spring Cloud Stream 的 {@link StreamBridge} 投递能力 + * 与本地监听器注册表。 + * + *

设计意图: + *

    + *
  • 以 {@link ConcurrentHashMap} 维护 binding → 监听器列表的映射,保证并发注册/派发的线程安全
  • + *
  • 使用 {@link SpelExpressionParser} 对监听器 condition 求值,实现按条件过滤派发
  • + *
  • binding 元数据写入 {@link StreamProperties#getBindings()},与 publish/dispatch 共享同一份配置
  • + *
+ * + *

协作关系: + *

    + *
  • publish:通过 {@code StreamBridge.send} 投递到 {bindingName}-out-0 输出通道
  • + *
  • dispatch:在本地内存中迭代监听器并回调,不经过消息中间件
  • + *
  • 动态 binding 注册:当 publish 时发现 binding 缺失,会自动补注册
  • + *
+ * + * @see StreamEventManager + * @see ListenerRegistration + */ public class DefaultStreamEventManagerImpl implements StreamEventManager { private static final Logger log = LoggerFactory.getLogger(DefaultStreamEventManagerImpl.class); + /** + * Spring Cloud Stream 的桥接器,用于将消息发送到输出 binding。 + */ private final StreamBridge streamBridge; + /** + * stream 主配置,提供 binding 元数据与全局默认值兜底。 + */ private final StreamProperties streamProperties; + /** + * 监听器注册表:bindingName → 该 binding 下的所有监听器注册信息列表。 + */ private final Map>> registeredListeners = new ConcurrentHashMap<>(); + /** + * SpEL 表达式解析器,用于对监听器的 condition 进行求值。 + */ private final SpelExpressionParser expressionParser = new SpelExpressionParser(); + /** + * 构造方法,由 {@code StreamAutoConfiguration} 注入依赖。 + * + * @param streamBridge Spring Cloud Stream 桥接器 + * @param streamProperties stream 主配置 + */ public DefaultStreamEventManagerImpl(StreamBridge streamBridge, StreamProperties streamProperties) { this.streamBridge = streamBridge; this.streamProperties = streamProperties; } + /** + * {@inheritDoc} + * + *

实现说明:从 {@link StreamProperties#getBindings()} 查找 binding 元数据, + * 未找到时抛出 {@link IllegalArgumentException},找到后委托给 + * {@link #publish(String, String, String, Object)} 完成实际投递。 + */ @Override public void publish(String bindingName, T event) { StreamProperties.Binding binding = streamProperties.getBindings().get(bindingName); @@ -41,33 +87,65 @@ public void publish(String bindingName, T event) { publish(bindingName, binding.getDestination(), binding.getGroup(), event); } + /** + * {@inheritDoc} + * + *

实现说明:group 取自全局 {@code default-group}。 + */ @Override public void publish(String bindingName, String destination, T event) { String group = streamProperties.getDefaultGroup(); publish(bindingName, destination, group, event); } + /** + * {@inheritDoc} + * + *

实现说明: + *

    + *
  1. 调用 {@link #ensureBindingRegistered} 保证 binding 已注册(动态 binding 注册)
  2. + *
  3. 构建 {@link Message},通过 {@link StreamBridge#send} 投递到 {bindingName}-out-0 输出通道
  4. + *
+ */ @Override public void publish(String bindingName, String destination, String group, T event) { + // 动态 binding 注册:若 binding 缺失则补注册,确保 publish 链路可用 ensureBindingRegistered(bindingName, destination, group); + // Spring Cloud Stream 约定:输出 binding 名为 {bindingName}-out-0 String outputBindingName = bindingName + "-out-0"; Message message = MessageBuilder.withPayload(event).build(); streamBridge.send(outputBindingName, message); log.debug("Published event to binding: {}, destination: {}, group: {}", outputBindingName, destination, group); } + /** + * 保证指定 binding 在 StreamProperties 中已注册,未注册时调用 {@link #registerBinding} 补注册。 + * + * @param bindingName 绑定名称 + * @param destination 目标 destination + * @param group 消费者组 + */ private synchronized void ensureBindingRegistered(String bindingName, String destination, String group) { if (!streamProperties.getBindings().containsKey(bindingName)) { registerBinding(bindingName, destination, group); } } + /** + * {@inheritDoc} + */ @Override public void registerListener(String bindingName, Class eventType, StreamEventHandler handler) { registerListener(bindingName, eventType, "", handler); } + /** + * {@inheritDoc} + * + *

实现说明:从 {@link StreamProperties#getBindings()} 查找 binding 元数据, + * 取其 destination/group 委托给四参版本 {@link #registerListener(String, String, String, Class, String, StreamEventHandler)}。 + */ @Override public void registerListener(String bindingName, Class eventType, String condition, StreamEventHandler handler) { StreamProperties.Binding binding = streamProperties.getBindings().get(bindingName); @@ -77,13 +155,23 @@ public void registerListener(String bindingName, Class eventType, String registerListener(bindingName, binding.getDestination(), binding.getGroup(), eventType, condition, handler); } + /** + * {@inheritDoc} + */ @Override public void registerListener(String bindingName, String destination, String group, Class eventType, StreamEventHandler handler) { registerListener(bindingName, destination, group, eventType, "", handler); } + /** + * {@inheritDoc} + * + *

实现说明:生成 UUID 作为 listenerId,构建 {@link ListenerRegistration} 并加入注册表, + * 使用 {@link ConcurrentHashMap#computeIfAbsent} 保证并发安全追加。 + */ @Override public void registerListener(String bindingName, String destination, String group, Class eventType, String condition, StreamEventHandler handler) { + // 生成唯一 listenerId,便于后续按 ID 精确注销 String listenerId = UUID.randomUUID().toString(); ListenerRegistration registration = ListenerRegistration.builder() .listenerId(listenerId) @@ -94,18 +182,27 @@ public void registerListener(String bindingName, String destination, String .group(group) .build(); + // 并发安全地追加到 binding 对应的监听器列表 registeredListeners.computeIfAbsent(bindingName, k -> new ArrayList<>()).add(registration); log.info("Registered listener for binding: {}, listenerId: {}, destination: {}, group: {}, eventType: {}, condition: {}", bindingName, listenerId, destination, group, eventType.getName(), condition); } + /** + * {@inheritDoc} + */ @Override public void unregisterListener(String bindingName) { registeredListeners.remove(bindingName); log.info("Unregistered all listeners for binding: {}", bindingName); } + /** + * {@inheritDoc} + * + *

实现说明:在 binding 列表中按 listenerId 过滤移除;列表变空时联动从 Map 中移除该 binding 条目。 + */ @Override public void unregisterListener(String bindingName, String listenerId) { List> registrations = registeredListeners.get(bindingName); @@ -114,17 +211,31 @@ public void unregisterListener(String bindingName, String listenerId) { if (removed) { log.info("Unregistered listener: {} for binding: {}", listenerId, bindingName); } + // 列表为空时清理 Map 条目,避免留下空 entry if (registrations.isEmpty()) { registeredListeners.remove(bindingName); } } } + /** + * {@inheritDoc} + */ @Override public boolean isListenerRegistered(String bindingName) { return registeredListeners.containsKey(bindingName) && !registeredListeners.get(bindingName).isEmpty(); } + /** + * {@inheritDoc} + * + *

实现说明:迭代 binding 下的所有监听器,依次执行两步过滤: + *

    + *
  1. eventType 类型过滤:{@code registration.getEventType().isInstance(event)}
  2. + *
  3. SpEL condition 求值:通过 {@link #matchesCondition} 判断
  4. + *
+ * 命中后回调 handler,异常被捕获并打印日志,不影响后续监听器执行。 + */ @Override @SuppressWarnings("unchecked") public void dispatch(String bindingName, T event) { @@ -135,36 +246,54 @@ public void dispatch(String bindingName, T event) { } for (ListenerRegistration registration : registrations) { + // 第 1 步:eventType 类型过滤 if (!registration.getEventType().isInstance(event)) { continue; } + // 第 2 步:SpEL condition 条件求值 if (matchesCondition(registration.getCondition(), event)) { try { ((StreamEventHandler) registration.getHandler()).handle(event); log.debug("Dispatched event to listener: {} for binding: {}", registration.getListenerId(), bindingName); } catch (Exception e) { + // 单个监听器异常不影响其他监听器执行 log.error("Error handling event in listener: {} for binding: {}", registration.getListenerId(), bindingName, e); } } } } + /** + * {@inheritDoc} + */ @Override public List> getListeners(String bindingName) { return registeredListeners.getOrDefault(bindingName, new ArrayList<>()); } + /** + * {@inheritDoc} + */ @Override public void registerBinding(String bindingName, String destination) { registerBinding(bindingName, destination, streamProperties.getDefaultGroup()); } + /** + * {@inheritDoc} + */ @Override public void registerBinding(String bindingName, String destination, String group) { registerBinding(bindingName, destination, group, streamProperties.getDefaultContentType(), streamProperties.getDefaultConcurrency()); } + /** + * {@inheritDoc} + * + *

实现说明:使用 synchronized 保证并发注册的幂等性,已存在时仅打印告警并返回。 + * contentType/concurrency 为 null 时分别回退到全局默认值。 + */ @Override public void registerBinding(String bindingName, String destination, String group, String contentType, Integer concurrency) { synchronized (this) { @@ -176,6 +305,7 @@ public void registerBinding(String bindingName, String destination, String group StreamProperties.Binding binding = new StreamProperties.Binding(); binding.setDestination(destination); binding.setGroup(group); + // contentType/concurrency 为 null 时回退到全局默认值 binding.setContentType(contentType != null ? contentType : streamProperties.getDefaultContentType()); binding.setConcurrency(concurrency != null ? concurrency : streamProperties.getDefaultConcurrency()); @@ -186,32 +316,56 @@ public void registerBinding(String bindingName, String destination, String group } } + /** + * {@inheritDoc} + * + *

实现说明:从 {@link StreamProperties#getBindings()} 移除 binding 元数据, + * 并联动调用 {@link #unregisterListener} 清理其下所有监听器。 + */ @Override public void unregisterBinding(String bindingName) { synchronized (this) { StreamProperties.Binding removed = streamProperties.getBindings().remove(bindingName); if (removed != null) { + // 联动清理监听器,避免悬挂引用 unregisterListener(bindingName); log.info("Dynamically unregistered binding: {}", bindingName); } } } + /** + * {@inheritDoc} + */ @Override public boolean isBindingRegistered(String bindingName) { return streamProperties.getBindings().containsKey(bindingName); } + /** + * {@inheritDoc} + */ @Override public StreamProperties.Binding getBinding(String bindingName) { return streamProperties.getBindings().get(bindingName); } + /** + * {@inheritDoc} + */ @Override public Map getAllBindings() { return streamProperties.getBindings(); } + /** + * 对监听器 condition 进行 SpEL 求值,判断是否派发当前事件。 + * + * @param condition SpEL 条件表达式,null 或空串表示无条件(恒为 true) + * @param event 事件负载,作为 SpEL 上下文中的 {@code #event} 变量 + * @param 负载类型 + * @return 求值为 true 返回 true;求值异常返回 false,避免抛出中断派发 + */ private boolean matchesCondition(String condition, T event) { if (condition == null || condition.isEmpty()) { return true; @@ -220,6 +374,7 @@ private boolean matchesCondition(String condition, T event) { try { Expression expression = expressionParser.parseExpression(condition); EvaluationContext context = new StandardEvaluationContext(); + // 将事件作为 #event 变量暴露给 SpEL 表达式 context.setVariable("event", event); Boolean result = expression.getValue(context, Boolean.class); return Boolean.TRUE.equals(result); diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/ListenerRegistration.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/ListenerRegistration.java index b55a7c4..070bd53 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/ListenerRegistration.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/ListenerRegistration.java @@ -2,18 +2,68 @@ import cn.structure.infra.stream.handler.StreamEventHandler; +/** + * 监听器注册信息,承载单个 {@link StreamEventHandler} 在 {@link StreamEventManager} 中的全部上下文。 + * + *

设计意图: + *

    + *
  • 将监听器与其元数据(eventType/condition/destination/group/listenerId)打包为一等公民对象, + * 便于 {@link DefaultStreamEventManagerImpl#dispatch} 时统一迭代过滤
  • + *
  • 支持 Builder 模式构建,避免长参数构造方法的可读性问题
  • + *
  • listenerId 由 UUID 生成,作为精确注销的句柄
  • + *
+ * + *

协作关系: + *

    + *
  • 由 {@link DefaultStreamEventManagerImpl#registerListener} 创建并加入注册表
  • + *
  • 由 {@link DefaultStreamEventManagerImpl#dispatch} 在派发时读取 eventType 与 condition 进行过滤
  • + *
+ * + * @param 事件负载类型 + */ public class ListenerRegistration { + /** + * 监听器唯一 ID,注册时由 UUID 生成,用于精确注销。 + */ private String listenerId; + /** + * 事件负载类型,dispatch 时据此进行类型过滤(isInstance 判断)。 + */ private Class eventType; + /** + * 事件处理器回调。 + */ private StreamEventHandler handler; + /** + * SpEL 条件表达式,可通过 {@code #event} 引用事件,留空表示无条件。 + */ private String condition; + /** + * 目标 destination(exchange/topic)。 + */ private String destination; + /** + * 消费者组名。 + */ private String group; + /** + * 默认构造方法,供反序列化或 Builder 使用。 + */ public ListenerRegistration() { } + /** + * 全参构造方法。 + * + * @param listenerId 监听器唯一 ID + * @param eventType 事件负载类型 + * @param handler 事件处理器 + * @param condition SpEL 条件表达式 + * @param destination 目标 destination + * @param group 消费者组 + */ public ListenerRegistration(String listenerId, Class eventType, StreamEventHandler handler, String condition, String destination, String group) { this.listenerId = listenerId; @@ -24,58 +74,105 @@ public ListenerRegistration(String listenerId, Class eventType, StreamEventHa this.group = group; } + /** + * @return 监听器唯一 ID + */ public String getListenerId() { return listenerId; } + /** + * @param listenerId 监听器唯一 ID + */ public void setListenerId(String listenerId) { this.listenerId = listenerId; } + /** + * @return 事件负载类型 + */ public Class getEventType() { return eventType; } + /** + * @param eventType 事件负载类型 + */ public void setEventType(Class eventType) { this.eventType = eventType; } + /** + * @return 事件处理器 + */ public StreamEventHandler getHandler() { return handler; } + /** + * @param handler 事件处理器 + */ public void setHandler(StreamEventHandler handler) { this.handler = handler; } + /** + * @return SpEL 条件表达式 + */ public String getCondition() { return condition; } + /** + * @param condition SpEL 条件表达式 + */ public void setCondition(String condition) { this.condition = condition; } + /** + * @return 目标 destination + */ public String getDestination() { return destination; } + /** + * @param destination 目标 destination + */ public void setDestination(String destination) { this.destination = destination; } + /** + * @return 消费者组名 + */ public String getGroup() { return group; } + /** + * @param group 消费者组名 + */ public void setGroup(String group) { this.group = group; } + /** + * 创建一个 Builder 以便链式构建注册信息。 + * + * @param 负载类型 + * @return 新的 Builder 实例 + */ public static Builder builder() { return new Builder<>(); } + /** + * ListenerRegistration 的链式构建器。 + * + * @param 事件负载类型 + */ public static class Builder { private String listenerId; private Class eventType; @@ -84,36 +181,65 @@ public static class Builder { private String destination; private String group; + /** + * @param listenerId 监听器唯一 ID + * @return 当前 Builder + */ public Builder listenerId(String listenerId) { this.listenerId = listenerId; return this; } + /** + * @param eventType 事件负载类型 + * @return 当前 Builder + */ public Builder eventType(Class eventType) { this.eventType = eventType; return this; } + /** + * @param handler 事件处理器 + * @return 当前 Builder + */ public Builder handler(StreamEventHandler handler) { this.handler = handler; return this; } + /** + * @param condition SpEL 条件表达式 + * @return 当前 Builder + */ public Builder condition(String condition) { this.condition = condition; return this; } + /** + * @param destination 目标 destination + * @return 当前 Builder + */ public Builder destination(String destination) { this.destination = destination; return this; } + /** + * @param group 消费者组名 + * @return 当前 Builder + */ public Builder group(String group) { this.group = group; return this; } + /** + * 终结方法,生成 {@link ListenerRegistration} 实例。 + * + * @return 新构建的注册信息 + */ public ListenerRegistration build() { return new ListenerRegistration<>(listenerId, eventType, handler, condition, destination, group); } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/StreamEventManager.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/StreamEventManager.java index d33a7ec..1cf6bbb 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/StreamEventManager.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/manager/StreamEventManager.java @@ -6,44 +6,202 @@ import java.util.List; import java.util.Map; +/** + * 事件管理器 SPI,统一管理传统消息监听模型下的 binding 与 listener 生命周期。 + * + *

设计意图: + *

    + *
  • 封装 Spring Cloud Stream 的 {@code StreamBridge} 投递能力,对外暴露统一发布 API
  • + *
  • 维护 binding 元数据(destination/group/content-type/concurrency),支持运行时动态注册
  • + *
  • 维护每个 binding 下的监听器列表,提供 eventType 类型过滤与 SpEL condition 条件求值
  • + *
+ * + *

协作关系: + *

    + *
  • 由 {@link DefaultStreamEventManagerImpl} 提供默认实现
  • + *
  • 由 {@code EventListenerBeanPostProcessor} 在扫描 {@code @StreamEventListener} 时调用注册 API
  • + *
  • binding 元数据来源于 {@link StreamProperties#getBindings()},并与全局默认值兜底配合
  • + *
+ * + * @see DefaultStreamEventManagerImpl + * @see ListenerRegistration + */ public interface StreamEventManager { + /** + * 向指定 binding 发布事件,使用 binding 自身配置的 destination 与 group。 + * + * @param bindingName 绑定名称 + * @param event 事件负载 + * @param 负载类型 + * @throws IllegalArgumentException 当 bindingName 未注册时抛出 + */ void publish(String bindingName, T event); + /** + * 向指定 binding 与 destination 发布事件,group 取全局 default-group。 + * + * @param bindingName 绑定名称 + * @param destination 目标 destination + * @param event 事件负载 + * @param 负载类型 + */ void publish(String bindingName, String destination, T event); + /** + * 向指定 binding、destination、group 发布事件。 + *

若 binding 尚未注册,会触发动态 binding 注册。 + * + * @param bindingName 绑定名称 + * @param destination 目标 destination + * @param group 消费者组 + * @param event 事件负载 + * @param 负载类型 + */ void publish(String bindingName, String destination, String group, T event); + /** + * 注册监听器到指定 binding,使用 binding 自身的 destination/group,无 SpEL 条件。 + * + * @param bindingName 绑定名称 + * @param eventType 事件负载类型,dispatch 时据此过滤 + * @param handler 事件处理器 + * @param 负载类型 + * @throws IllegalArgumentException 当 bindingName 未注册时抛出 + */ void registerListener(String bindingName, Class eventType, StreamEventHandler handler); + /** + * 注册监听器到指定 binding,附加 SpEL 条件,使用 binding 自身的 destination/group。 + * + * @param bindingName 绑定名称 + * @param eventType 事件负载类型 + * @param condition SpEL 条件表达式,可通过 {@code #event} 引用事件,留空表示无条件 + * @param handler 事件处理器 + * @param 负载类型 + * @throws IllegalArgumentException 当 bindingName 未注册时抛出 + */ void registerListener(String bindingName, Class eventType, String condition, StreamEventHandler handler); + /** + * 注册监听器到指定 binding,显式指定 destination/group,无 SpEL 条件。 + * + * @param bindingName 绑定名称 + * @param destination 目标 destination + * @param group 消费者组 + * @param eventType 事件负载类型 + * @param handler 事件处理器 + * @param 负载类型 + */ void registerListener(String bindingName, String destination, String group, Class eventType, StreamEventHandler handler); + /** + * 注册监听器到指定 binding,显式指定 destination/group,附加 SpEL 条件。 + * + * @param bindingName 绑定名称 + * @param destination 目标 destination + * @param group 消费者组 + * @param eventType 事件负载类型 + * @param condition SpEL 条件表达式,留空表示无条件 + * @param handler 事件处理器 + * @param 负载类型 + */ void registerListener(String bindingName, String destination, String group, Class eventType, String condition, StreamEventHandler handler); + /** + * 注销指定 binding 下的全部监听器。 + * + * @param bindingName 绑定名称 + */ void unregisterListener(String bindingName); + /** + * 按 listenerId 精确注销监听器。 + * + * @param bindingName 绑定名称 + * @param listenerId 监听器唯一 ID(注册时由 UUID 生成) + */ void unregisterListener(String bindingName, String listenerId); + /** + * 判断指定 binding 下是否存在已注册的监听器。 + * + * @param bindingName 绑定名称 + * @return 存在且非空返回 true + */ boolean isListenerRegistered(String bindingName); + /** + * 将事件派发到指定 binding 下的所有匹配监听器。 + *

派发规则:eventType 类型过滤 → SpEL condition 条件求值 → 回调 handler。 + * + * @param bindingName 绑定名称 + * @param event 事件负载 + * @param 负载类型 + */ void dispatch(String bindingName, T event); + /** + * @param bindingName 绑定名称 + * @return 该 binding 下的所有监听器注册信息,不存在时返回空列表 + */ List> getListeners(String bindingName); + /** + * 动态注册 binding,group 使用全局 default-group。 + * + * @param bindingName 绑定名称 + * @param destination 目标 destination + */ void registerBinding(String bindingName, String destination); + /** + * 动态注册 binding,显式指定 group。 + * + * @param bindingName 绑定名称 + * @param destination 目标 destination + * @param group 消费者组 + */ void registerBinding(String bindingName, String destination, String group); + /** + * 动态注册 binding,显式指定全部参数。 + *

已存在时打印告警并跳过,保证幂等。 + * + * @param bindingName 绑定名称 + * @param destination 目标 destination + * @param group 消费者组 + * @param contentType 内容类型 + * @param concurrency 消费并发数 + */ void registerBinding(String bindingName, String destination, String group, String contentType, Integer concurrency); + /** + * 注销 binding,同时联动注销其下所有监听器。 + * + * @param bindingName 绑定名称 + */ void unregisterBinding(String bindingName); + /** + * 判断指定 binding 是否已注册。 + * + * @param bindingName 绑定名称 + * @return 已注册返回 true + */ boolean isBindingRegistered(String bindingName); + /** + * 获取指定 binding 的元数据。 + * + * @param bindingName 绑定名称 + * @return binding 元数据,不存在返回 null + */ StreamProperties.Binding getBinding(String bindingName); + /** + * @return 当前所有已注册的 binding 元数据 Map + */ Map getAllBindings(); } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/EventListenerBeanPostProcessor.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/EventListenerBeanPostProcessor.java index 0692d5b..69de97e 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/EventListenerBeanPostProcessor.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/EventListenerBeanPostProcessor.java @@ -16,20 +16,67 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * {@link StreamEventListener} 注解的扫描器与注册器,基于 Spring {@link BeanPostProcessor} 实现。 + * + *

设计意图: + *

    + *
  • 在 Bean 初始化完成后扫描方法级与类级 {@link StreamEventListener} 注解
  • + *
  • 解析注解元数据(bindingName/destination/group/eventType/condition),将方法包装为 + * {@link cn.structure.infra.stream.handler.StreamEventHandler} Lambda 并注册到 {@link StreamEventManager}
  • + *
  • 同时调用 {@link #ensureBindingRegistered} 将 binding 元数据写入 {@link StreamProperties}, + * 保证后续 {@code publish} 调用能查到 binding
  • + *
+ * + *

协作关系: + *

    + *
  • 依赖 {@link StreamEventManager} SPI 完成实际监听器注册
  • + *
  • 方法级注解:eventType 默认取方法首个参数类型,bindingName 默认取方法名
  • + *
  • 类级注解:通过反射调用类的 {@code handle(T)} 方法,bindingName 默认取类名
  • + *
+ * + * @see StreamEventListener + * @see StreamEventManager + */ public class EventListenerBeanPostProcessor implements BeanPostProcessor { private static final Logger log = LoggerFactory.getLogger(EventListenerBeanPostProcessor.class); + /** + * 事件管理器 SPI,用于实际注册监听器。 + */ private final StreamEventManager streamEventManager; + /** + * stream 主配置,提供 binding 元数据与全局默认值兜底。 + */ private final StreamProperties streamProperties; + /** + * 已注册监听器 Bean 缓存:bindingName → bean 实例。 + */ private final Map listenerBeans = new ConcurrentHashMap<>(); + /** + * 构造方法,由 {@code StreamAutoConfiguration} 注入依赖。 + * + * @param streamEventManager 事件管理器 SPI + * @param streamProperties stream 主配置 + */ public EventListenerBeanPostProcessor(StreamEventManager streamEventManager, StreamProperties streamProperties) { this.streamEventManager = streamEventManager; this.streamProperties = streamProperties; } + /** + * 在 Bean 初始化完成后扫描 {@link StreamEventListener} 注解并注册监听器。 + * + *

实现说明: + *

    + *
  1. 使用 {@link MethodIntrospector#selectMethods} 查找方法级注解
  2. + *
  3. 对每个方法级注解调用 {@link #registerListener(Object, Method, StreamEventListener)} 注册
  4. + *
  5. 若类本身标注了 {@link StreamEventListener},调用 {@link #registerClassListener} 注册类级监听器
  6. + *
+ */ @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { Class targetClass = bean.getClass(); @@ -43,6 +90,7 @@ public Object postProcessAfterInitialization(Object bean, String beanName) throw registerListener(bean, method, annotation); } + // 处理类级注解:类标注 @StreamEventListener 时通过反射调用 handle(T) 方法 if (targetClass.isAnnotationPresent(StreamEventListener.class)) { StreamEventListener annotation = targetClass.getAnnotation(StreamEventListener.class); registerClassListener(bean, targetClass, annotation); @@ -51,16 +99,33 @@ public Object postProcessAfterInitialization(Object bean, String beanName) throw return bean; } + /** + * 注册方法级监听器。 + * + *

实现说明: + *

    + *
  1. 解析 bindingName(注解显式值或方法名兜底)
  2. + *
  3. 解析 eventType(注解显式值或方法首个参数类型兜底)
  4. + *
  5. 调用 {@link #ensureBindingRegistered} 保证 binding 元数据存在
  6. + *
  7. 根据 destination 是否非空选择注册重载版本,包装方法为 Lambda 反射调用
  8. + *
+ * + * @param bean Bean 实例 + * @param method 标注方法 + * @param annotation 注解元数据 + */ private void registerListener(Object bean, Method method, StreamEventListener annotation) { String bindingName = resolveBindingName(annotation); String destination = annotation.destination(); String group = annotation.group(); Class eventType = annotation.eventType(); + // eventType 默认为 Object.class,回退到方法首个参数类型推断 if (eventType == Object.class && method.getParameterTypes().length > 0) { eventType = method.getParameterTypes()[0]; } + // bindingName 为空时回退到方法名 if (bindingName.isEmpty()) { bindingName = method.getName(); } @@ -91,12 +156,22 @@ private void registerListener(Object bean, Method method, StreamEventListener an log.info("Registered listener method: {} for binding: {}", method.getName(), bindingName); } + /** + * 注册类级监听器,通过反射调用类的 {@code handle(T)} 方法。 + * + *

实现说明:与 {@link #registerListener} 类似,区别在于回调时反射查找 {@code handle(eventType)} 方法。 + * + * @param bean Bean 实例 + * @param targetClass Bean 类型 + * @param annotation 注解元数据 + */ private void registerClassListener(Object bean, Class targetClass, StreamEventListener annotation) { String bindingName = resolveBindingName(annotation); String destination = annotation.destination(); String group = annotation.group(); Class eventType = annotation.eventType(); + // 类级注解的 bindingName 为空时回退到类名 if (bindingName.isEmpty()) { bindingName = targetClass.getSimpleName(); } @@ -137,6 +212,7 @@ private void ensureBindingRegistered(String bindingName, String destination, Str if (StringUtils.hasText(destination)) { binding.setDestination(destination); } else { + // destination 为空时由 bindingName 派生为 {name}-exchange binding.setDestination(toDestination(bindingName)); } binding.setGroup(StringUtils.hasText(group) ? group : streamProperties.getDefaultGroup()); @@ -145,10 +221,22 @@ private void ensureBindingRegistered(String bindingName, String destination, Str } } + /** + * 将名称转换为 destination,规则:将 {@code .} 与 {@code _} 替换为 {@code -},转小写后追加 {@code -exchange} 后缀。 + * + * @param name 原始名称 + * @return 派生的 destination + */ private String toDestination(String name) { return name.replace(".", "-").replace("_", "-").toLowerCase() + "-exchange"; } + /** + * 解析 bindingName:优先取注解的 {@code bindingName()},为空时回退到 {@code value()}。 + * + * @param annotation 注解元数据 + * @return 解析后的 bindingName,可能为空串 + */ private String resolveBindingName(StreamEventListener annotation) { String bindingName = annotation.bindingName(); if (bindingName.isEmpty()) { @@ -157,4 +245,4 @@ private String resolveBindingName(StreamEventListener annotation) { return bindingName; } -} \ No newline at end of file +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/StreamBindingBeanFactoryPostProcessor.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/StreamBindingBeanFactoryPostProcessor.java index aa38c06..56cf41b 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/StreamBindingBeanFactoryPostProcessor.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/processor/StreamBindingBeanFactoryPostProcessor.java @@ -24,24 +24,65 @@ * 在 Bean 实例化之前,扫描所有 BeanDefinition 中的 @StreamEventListener 和 @StreamRouteHandler 注解, * 自动注册 Spring Cloud Stream 绑定配置和 spring.cloud.function.definition。 * - * 这样用户只需在方法上标注 @StreamEventListener,框架会自动完成绑定创建。 + *

设计意图: + *

    + *
  • 在 BeanFactory 阶段(早于 BeanPostProcessor)扫描所有 BeanDefinition,避免 Bean 提前实例化
  • + *
  • 将自动生成的 binding 配置(destination/group/content-type/concurrency/binder)以 + * {@link MapPropertySource} 形式注入 Environment,与用户 YAML 配置等价
  • + *
  • 同时维护 {@code spring.cloud.function.definition},保证 Spring Cloud Stream 函数式模型可识别 binding
  • + *
+ * + *

协作关系: + *

    + *
  • 处理 {@link StreamEventListener}:按 bindingName 派生 input/output binding,注册到 spring.cloud.stream.bindings
  • + *
  • 处理 {@link StreamRouteHandler}:按 eventType 派生 bindingName,进一步派生 destination 与 binding
  • + *
  • binding 命名遵循 Spring Cloud Stream 约定:{bindingName}-in-0 / {bindingName}-out-0
  • + *
+ * + *

这样用户只需在方法上标注 @StreamEventListener,框架会自动完成绑定创建。 + * + * @see StreamEventListener + * @see StreamRouteHandler */ public class StreamBindingBeanFactoryPostProcessor implements BeanFactoryPostProcessor { private static final Logger log = LoggerFactory.getLogger(StreamBindingBeanFactoryPostProcessor.class); + /** + * Spring Cloud Stream binding 配置前缀:spring.cloud.stream.bindings。 + */ private static final String SPRING_BINDINGS_PREFIX = "spring.cloud.stream.bindings"; + /** + * Spring Cloud Function definition 属性键:spring.cloud.function.definition。 + */ private static final String FUNCTION_DEFINITION = "spring.cloud.function.definition"; + /** + * 在 BeanFactory 准备阶段扫描注解并注入自动 binding 配置。 + * + *

实现说明: + *

    + *
  1. 读取 structure.infra.stream.enabled,未启用则直接返回
  2. + *
  3. 读取全局默认值(default-group/default-content-type/default-binder/default-concurrency)
  4. + *
  5. 迭代所有 BeanDefinition,扫描方法级与类级 {@link StreamEventListener}、方法级 {@link StreamRouteHandler}
  6. + *
  7. 若用户未显式配置 spring.cloud.function.definition,自动拼接所有 bindingName
  8. + *
  9. 将生成的属性以 stream-auto-binding 命名的 {@link MapPropertySource} 注入 Environment
  10. + *
+ * + * @param beanFactory 可配置的 BeanFactory + * @throws BeansException 不会主动抛出 + */ @Override public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { ConfigurableEnvironment environment = beanFactory.getBean(ConfigurableEnvironment.class); + // 全局开关:未启用则跳过自动 binding 注册 Boolean enabled = environment.getProperty("structure.infra.stream.enabled", Boolean.class, Boolean.TRUE); if (!enabled) { return; } + // 读取全局默认值,用于 binding 字段兜底 String defaultGroup = environment.getProperty("structure.infra.stream.default-group", "default"); String defaultContentType = environment.getProperty("structure.infra.stream.default-content-type", "application/json"); String defaultBinder = environment.getProperty("structure.infra.stream.default-binder"); @@ -94,11 +135,31 @@ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) } if (!properties.isEmpty()) { + // 注入到 Environment 最前面,保证优先级高于其他源 environment.getPropertySources().addFirst( new MapPropertySource("stream-auto-binding", properties)); } } + /** + * 处理单个 {@link StreamEventListener} 注解,派生 bindingName 并注册 input/output binding。 + * + *

实现说明: + *

    + *
  1. bindingName 优先取 {@code bindingName()},回退到 {@code value()},均为空则跳过
  2. + *
  3. destination 优先取注解显式值,回退到由 bindingName 派生
  4. + *
  5. group/contentType 取注解显式值或全局默认值
  6. + *
  7. 调用 {@link #registerBinding} 注册 input/output binding,并加入 functionDefinitions
  8. + *
+ * + * @param annotation 注解元数据 + * @param defaultGroup 全局默认 group + * @param defaultContentType 全局默认 content-type + * @param defaultBinder 全局默认 binder + * @param defaultConcurrency 全局默认并发数 + * @param properties 待注入的属性 Map + * @param functionDefinitions 待拼接的 function definition 集合 + */ private void processStreamEventListener(StreamEventListener annotation, String defaultGroup, String defaultContentType, String defaultBinder, Integer defaultConcurrency, Map properties, Set functionDefinitions) { @@ -110,8 +171,8 @@ private void processStreamEventListener(StreamEventListener annotation, String d return; } - String destination = StringUtils.hasText(annotation.destination()) - ? annotation.destination() + String destination = StringUtils.hasText(annotation.destination()) + ? annotation.destination() : toDestination(bindingName); String group = StringUtils.hasText(annotation.group()) ? annotation.group() : defaultGroup; String contentType = StringUtils.hasText(annotation.contentType()) ? annotation.contentType() : defaultContentType; @@ -121,6 +182,19 @@ private void processStreamEventListener(StreamEventListener annotation, String d functionDefinitions.add(bindingName); } + /** + * 处理单个 {@link StreamRouteHandler} 注解,按 eventType 派生 bindingName 与 destination 并注册 binding。 + * + *

实现说明:路由场景下 bindingName 由 eventType 转换而来(替换 {@code .} / {@code _} 为 {@code -} 并小写)。 + * + * @param annotation 注解元数据 + * @param defaultGroup 全局默认 group + * @param defaultContentType 全局默认 content-type + * @param defaultBinder 全局默认 binder + * @param defaultConcurrency 全局默认并发数 + * @param properties 待注入的属性 Map + * @param functionDefinitions 待拼接的 function definition 集合 + */ private void processStreamRouteHandler(StreamRouteHandler annotation, String defaultGroup, String defaultContentType, String defaultBinder, Integer defaultConcurrency, Map properties, Set functionDefinitions) { @@ -132,6 +206,7 @@ private void processStreamRouteHandler(StreamRouteHandler annotation, String def return; } + // eventType 派生 bindingName 与 destination String bindingName = toBindingName(eventType); String destination = toDestination(eventType); @@ -139,9 +214,25 @@ private void processStreamRouteHandler(StreamRouteHandler annotation, String def functionDefinitions.add(bindingName); } + /** + * 注册单个 binding 的 input/output 配置到 properties Map。 + * + *

实现说明:遵循 Spring Cloud Stream 约定,每个 binding 同时生成 + * {bindingName}-in-0{bindingName}-out-0,配置 destination/content-type/group/binder/concurrency。 + * 已注册过的 binding(按 input destination key 判断)会被跳过以保证幂等。 + * + * @param bindingName 绑定名称 + * @param destination 目标 destination + * @param group 消费者组 + * @param contentType 内容类型 + * @param binder binder 名称,null 时跳过 + * @param concurrency 消费并发数,null 时跳过 + * @param properties 待注入的属性 Map + */ private void registerBinding(String bindingName, String destination, String group, String contentType, String binder, Integer concurrency, Map properties) { + // Spring Cloud Stream 约定:input/output binding 名分别为 {bindingName}-in-0 / {bindingName}-out-0 String inputBinding = bindingName + "-in-0"; String outputBinding = bindingName + "-out-0"; @@ -171,12 +262,24 @@ private void registerBinding(String bindingName, String destination, String grou bindingName, destination, group, contentType); } + /** + * 将 eventType 转换为 bindingName,规则:将 {@code .} 与 {@code _} 替换为 {@code -} 并转小写。 + * + * @param eventType 事件类型 + * @return 派生的 bindingName + */ private String toBindingName(String eventType) { return eventType.replace(".", "-").replace("_", "-").toLowerCase(); } + /** + * 将名称转换为 destination,规则:将 {@code .} 与 {@code _} 替换为 {@code -},转小写后追加 {@code -exchange} 后缀。 + * + * @param name 原始名称 + * @return 派生的 destination + */ private String toDestination(String name) { return name.replace(".", "-").replace("_", "-").toLowerCase() + "-exchange"; } -} \ No newline at end of file +} diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/properties/StreamProperties.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/properties/StreamProperties.java index 73916e5..3c894ed 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/properties/StreamProperties.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/properties/StreamProperties.java @@ -5,150 +5,330 @@ import java.util.HashMap; import java.util.Map; +/** + * stream 主配置属性,对应 YAML 配置项 structure.infra.stream。 + * + *

设计意图: + *

    + *
  • 集中管理 stream 模块的全局开关与默认值(enabled/auto-binding/default-group/default-content-type/default-binder/default-concurrency)
  • + *
  • 维护动态注册的 binding 元数据 Map:{@link #bindings},key 为 bindingName,value 为 {@link Binding}
  • + *
  • 作为 {@code StreamEventManager} 与 {@code EventListenerBeanPostProcessor} 的共享配置中心
  • + *
+ * + *

协作关系: + *

    + *
  • 由 {@code StreamAutoConfiguration} 通过 {@code @EnableConfigurationProperties} 启用
  • + *
  • 动态 binding 注册时写入 {@link #bindings};publish/dispatch 时读取 {@link #bindings}
  • + *
+ */ @ConfigurationProperties(prefix = "structure.infra.stream") public class StreamProperties { + /** + * 全局开关,控制是否启用 stream 模块,默认 true。 + */ private boolean enabled = true; + /** + * 是否启用自动 binding 注册(由 {@code StreamBindingBeanFactoryPostProcessor} 处理),默认 true。 + */ private boolean autoBinding = true; + /** + * 默认消费者组名,binding 未显式声明 group 时使用,默认 "default"。 + */ private String defaultGroup = "default"; + /** + * 默认内容类型,binding 未显式声明 content-type 时使用,默认 "application/json"。 + */ private String defaultContentType = "application/json"; + /** + * 默认 binder 名称,binding 未显式声明 binder 时使用,null 表示使用 Spring Cloud Stream 默认 binder。 + */ private String defaultBinder; + /** + * 默认消费并发数,binding 未显式声明 concurrency 时使用,默认 1。 + */ private Integer defaultConcurrency = 1; + /** + * binding 元数据 Map:bindingName → Binding 配置。 + *

初始来自 YAML,运行时可由 {@code StreamEventManager#registerBinding} 动态追加。 + */ private Map bindings = new HashMap<>(); + /** + * @return 是否启用 stream 模块 + */ public boolean isEnabled() { return enabled; } + /** + * @param enabled 是否启用 stream 模块 + */ public void setEnabled(boolean enabled) { this.enabled = enabled; } + /** + * @return 是否启用自动 binding 注册 + */ public boolean isAutoBinding() { return autoBinding; } + /** + * @param autoBinding 是否启用自动 binding 注册 + */ public void setAutoBinding(boolean autoBinding) { this.autoBinding = autoBinding; } + /** + * @return 默认消费者组名 + */ public String getDefaultGroup() { return defaultGroup; } + /** + * @param defaultGroup 默认消费者组名 + */ public void setDefaultGroup(String defaultGroup) { this.defaultGroup = defaultGroup; } + /** + * @return 默认内容类型 + */ public String getDefaultContentType() { return defaultContentType; } + /** + * @param defaultContentType 默认内容类型 + */ public void setDefaultContentType(String defaultContentType) { this.defaultContentType = defaultContentType; } + /** + * @return 默认 binder 名称 + */ public String getDefaultBinder() { return defaultBinder; } + /** + * @param defaultBinder 默认 binder 名称 + */ public void setDefaultBinder(String defaultBinder) { this.defaultBinder = defaultBinder; } + /** + * @return 默认消费并发数 + */ public Integer getDefaultConcurrency() { return defaultConcurrency; } + /** + * @param defaultConcurrency 默认消费并发数 + */ public void setDefaultConcurrency(Integer defaultConcurrency) { this.defaultConcurrency = defaultConcurrency; } + /** + * @return binding 元数据 Map + */ public Map getBindings() { return bindings; } + /** + * @param bindings binding 元数据 Map + */ public void setBindings(Map bindings) { this.bindings = bindings; } + /** + * 获取指定 binding 的元数据,不存在时自动创建空 Binding 并放入 Map。 + *

注意:此方法具有副作用,与 {@link #getBindings()}.get(key) 行为不同。 + * + * @param bindingName 绑定名称 + * @return 对应的 Binding 元数据(永不为 null) + */ public Binding getBinding(String bindingName) { return bindings.computeIfAbsent(bindingName, k -> new Binding()); } + /** + * 单个 binding 的元数据。 + * + *

对应 YAML 配置示例: + *

{@code
+     * structure:
+     *   infra:
+     *     stream:
+     *       bindings:
+     *         orderListener:
+     *           destination: order-exchange
+     *           group: order-group
+     *           content-type: application/json
+     *           concurrency: 2
+     * }
+ */ public static class Binding { + /** + * 目标 destination(exchange/topic)。 + */ private String destination; + /** + * 内容类型,默认 "application/json"。 + */ private String contentType = "application/json"; + /** + * 消费者组名。 + */ private String group; + /** + * binder 名称,null 表示使用默认 binder。 + */ private String binder; + /** + * 消费并发数,null 表示使用全局默认值。 + */ private Integer concurrency; + /** + * 消费者配置前缀,用于扩展生成 consumer 属性键,默认 "consumer"。 + */ private String consumerPrefix = "consumer"; + /** + * 生产者配置前缀,用于扩展生成 producer 属性键,默认 "producer"。 + */ private String producerPrefix = "producer"; + /** + * 默认构造方法。 + */ public Binding() { } + /** + * 仅指定 destination 的构造方法。 + * + * @param destination 目标 destination + */ public Binding(String destination) { this.destination = destination; } + /** + * 指定 destination 与 group 的构造方法。 + * + * @param destination 目标 destination + * @param group 消费者组 + */ public Binding(String destination, String group) { this.destination = destination; this.group = group; } + /** + * @return 目标 destination + */ public String getDestination() { return destination; } + /** + * @param destination 目标 destination + */ public void setDestination(String destination) { this.destination = destination; } + /** + * @return 内容类型 + */ public String getContentType() { return contentType; } + /** + * @param contentType 内容类型 + */ public void setContentType(String contentType) { this.contentType = contentType; } + /** + * @return 消费者组名 + */ public String getGroup() { return group; } + /** + * @param group 消费者组名 + */ public void setGroup(String group) { this.group = group; } + /** + * @return binder 名称 + */ public String getBinder() { return binder; } + /** + * @param binder binder 名称 + */ public void setBinder(String binder) { this.binder = binder; } + /** + * @return 消费并发数 + */ public Integer getConcurrency() { return concurrency; } + /** + * @param concurrency 消费并发数 + */ public void setConcurrency(Integer concurrency) { this.concurrency = concurrency; } + /** + * @return 消费者配置前缀 + */ public String getConsumerPrefix() { return consumerPrefix; } + /** + * @param consumerPrefix 消费者配置前缀 + */ public void setConsumerPrefix(String consumerPrefix) { this.consumerPrefix = consumerPrefix; } + /** + * @return 生产者配置前缀 + */ public String getProducerPrefix() { return producerPrefix; } + /** + * @param producerPrefix 生产者配置前缀 + */ public void setProducerPrefix(String producerPrefix) { this.producerPrefix = producerPrefix; } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/ConfigurableRouteInitializer.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/ConfigurableRouteInitializer.java index 4639b7f..187e221 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/ConfigurableRouteInitializer.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/ConfigurableRouteInitializer.java @@ -10,16 +10,54 @@ import java.lang.reflect.Method; +/** + * 配置驱动路由初始化器,在应用启动时读取 {@link RouterProperties} 并批量注册路由。 + * + *

设计意图: + *

    + *
  • 作为 {@link cn.structure.infra.stream.annotation.StreamRouteHandler} 注解的补充, + * 提供纯 YAML 配置的路由注册能力,无需修改 Java 代码
  • + *
  • 通过 Spring {@link CommandLineRunner} 在所有 Bean 初始化完成后执行
  • + *
  • 反射加载 payloadType、查找 handlerBean、定位 handlerMethod,包装为 + * {@link StreamEventRouter.StreamRouteHandler} 注册到 {@link StreamEventRouter}
  • + *
+ * + *

协作关系: + *

    + *
  • 依赖 {@link RouterProperties} 提供路由定义列表
  • + *
  • 依赖 {@link StreamEventRouter} 完成实际注册
  • + *
  • 依赖 {@link ApplicationContext} 通过 beanName 查找处理器 Bean
  • + *
+ * + * @see RouterProperties + * @see StreamEventRouter + */ @Component public class ConfigurableRouteInitializer implements CommandLineRunner { private static final Logger log = LoggerFactory.getLogger(ConfigurableRouteInitializer.class); + /** + * 路由配置属性。 + */ private final RouterProperties routerProperties; + /** + * 路由器 SPI,用于实际注册路由。 + */ private final StreamEventRouter eventRouter; + /** + * Spring 应用上下文,用于按 beanName 查找处理器 Bean。 + */ private final ApplicationContext applicationContext; - public ConfigurableRouteInitializer(RouterProperties routerProperties, + /** + * 构造方法,由 Spring 注入依赖。 + * + * @param routerProperties 路由配置属性 + * @param eventRouter 路由器 SPI + * @param applicationContext Spring 应用上下文 + */ + public ConfigurableRouteInitializer(RouterProperties routerProperties, StreamEventRouter eventRouter, ApplicationContext applicationContext) { this.routerProperties = routerProperties; @@ -27,6 +65,18 @@ public ConfigurableRouteInitializer(RouterProperties routerProperties, this.applicationContext = applicationContext; } + /** + * 应用启动入口:迭代 {@link RouterProperties#getRoutes()} 批量注册路由。 + * + *

实现说明: + *

    + *
  1. 若 {@link RouterProperties#isEnabled()} 为 false,跳过初始化
  2. + *
  3. 逐条调用 {@link #registerRoute} 注册,单条失败不影响其他路由
  4. + *
  5. 打印注册总数统计日志
  6. + *
+ * + * @param args 启动参数(未使用) + */ @Override public void run(String... args) { if (!routerProperties.isEnabled()) { @@ -42,6 +92,7 @@ public void run(String... args) { log.info("Registered route: id={}, eventType={}, handler={}.{}", route.getId(), route.getEventType(), route.getHandlerBean(), route.getHandlerMethod()); } catch (Exception e) { + // 单条路由注册失败不影响其他路由 log.error("Failed to register route: id={}, eventType={}", route.getId(), route.getEventType(), e); } } @@ -49,13 +100,32 @@ public void run(String... args) { log.info("========== 配置驱动路由初始化完成,共 {} 条路由 ==========", routerProperties.getRoutes().size()); } + /** + * 注册单条配置驱动路由。 + * + *

实现说明: + *

    + *
  1. 反射加载 payloadType 全限定类名
  2. + *
  3. 通过 {@link ApplicationContext#getBean(String)} 查找 handlerBean
  4. + *
  5. 反射定位 handlerMethod(参数类型为 payloadType),设置 accessible
  6. + *
  7. 包装为 {@link StreamEventRouter.StreamRouteHandler} Lambda
  8. + *
  9. 根据 businessType/condition 是否非空,选择四参/三参/双参版本注册
  10. + *
+ * + * @param route 路由定义 + * @throws Exception 当 payloadType 类加载失败、bean 查找失败或方法定位失败时抛出 + */ @SuppressWarnings({"rawtypes", "unchecked"}) private void registerRoute(RouterProperties.RouteDefinition route) throws Exception { + // 反射加载 payloadType Class payloadType = Class.forName(route.getPayloadType()); + // 按 beanName 查找处理器 Bean Object handlerBean = applicationContext.getBean(route.getHandlerBean()); + // 反射定位处理器方法(参数类型为 payloadType) Method handlerMethod = handlerBean.getClass().getDeclaredMethod(route.getHandlerMethod(), payloadType); handlerMethod.setAccessible(true); + // 包装为 StreamRouteHandler Lambda,反射调用 handlerMethod StreamEventRouter.StreamRouteHandler handler = (payload, event) -> { try { handlerMethod.invoke(handlerBean, payload); @@ -65,16 +135,21 @@ private void registerRoute(RouterProperties.RouteDefinition route) throws Except } }; + // 根据 businessType/condition 是否非空,选择对应的注册重载版本 if (route.getBusinessType() != null && !route.getBusinessType().isEmpty()) { if (route.getCondition() != null && !route.getCondition().isEmpty()) { + // businessType 与 condition 均非空:四参版本 eventRouter.registerRoute(route.getEventType(), route.getBusinessType(), payloadType, route.getCondition(), handler); } else { + // 仅 businessType 非空 eventRouter.registerRoute(route.getEventType(), route.getBusinessType(), payloadType, handler); } } else { if (route.getCondition() != null && !route.getCondition().isEmpty()) { + // 仅 condition 非空 eventRouter.registerRoute(route.getEventType(), payloadType, route.getCondition(), handler); } else { + // businessType 与 condition 均空:双参版本 eventRouter.registerRoute(route.getEventType(), payloadType, handler); } } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/DefaultStreamEventRouterImpl.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/DefaultStreamEventRouterImpl.java index 222626a..9d485ee 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/DefaultStreamEventRouterImpl.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/DefaultStreamEventRouterImpl.java @@ -13,29 +13,74 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * {@link StreamEventRouter} 的默认实现,基于内存注册表实现 4 步路由匹配规则。 + * + *

设计意图: + *

    + *
  • 使用 {@link ConcurrentHashMap} 维护 eventType → 路由列表的映射,保证并发注册/路由的线程安全
  • + *
  • 使用 {@link SpelExpressionParser} 对 condition 求值,对 payload 进行精细化筛选
  • + *
  • 使用 {@link java.util.concurrent.atomic.AtomicLong} 生成全局递增 handlerId 后缀,保证唯一性
  • + *
+ * + *

协作关系: + *

    + *
  • route:依次执行 businessType 通配匹配 → payloadType 类型匹配 → SpEL condition 求值
  • + *
  • eventType 已作为 Map key 完成第 1 步精确匹配,故 route 内部仅执行第 2~4 步
  • + *
  • 处理器异常被捕获并打印日志,不影响后续路由执行
  • + *
+ * + * @see StreamEventRouter + * @see RouteRegistration + */ public class DefaultStreamEventRouterImpl implements StreamEventRouter { private static final Logger log = LoggerFactory.getLogger(DefaultStreamEventRouterImpl.class); + /** + * 路由注册表:eventType → 该 eventType 下的所有路由注册信息列表。 + *

第 1 步 eventType 精确匹配即通过此 Map 的 key 查找完成。 + */ private final Map>> routeRegistrations = new ConcurrentHashMap<>(); + /** + * SpEL 表达式解析器,用于对路由 condition 求值(第 4 步)。 + */ private final SpelExpressionParser expressionParser = new SpelExpressionParser(); + /** + * 全局递增计数器,用于生成 handlerId 后缀以保证唯一性。 + */ private final java.util.concurrent.atomic.AtomicLong handlerCounter = new java.util.concurrent.atomic.AtomicLong(0); + /** + * {@inheritDoc} + */ @Override public void registerRoute(String eventType, Class payloadType, StreamRouteHandler handler) { registerRoute(eventType, "", payloadType, "", handler); } + /** + * {@inheritDoc} + */ @Override public void registerRoute(String eventType, Class payloadType, String condition, StreamRouteHandler handler) { registerRoute(eventType, "", payloadType, condition, handler); } + /** + * {@inheritDoc} + */ @Override public void registerRoute(String eventType, String businessType, Class payloadType, StreamRouteHandler handler) { registerRoute(eventType, businessType, payloadType, "", handler); } + /** + * {@inheritDoc} + * + *

实现说明:生成 handlerId,构建 {@link RouteRegistration},使用 + * {@link ConcurrentHashMap#computeIfAbsent} 保证并发安全追加到 eventType 对应的列表。 + */ @Override public void registerRoute(String eventType, String businessType, Class payloadType, String condition, StreamRouteHandler handler) { @@ -43,18 +88,27 @@ public void registerRoute(String eventType, String businessType, Class pa RouteRegistration registration = new RouteRegistration<>(handlerId, eventType, businessType, payloadType, condition, handler); + // eventType 作为第 1 步精确匹配的 Map key routeRegistrations.computeIfAbsent(eventType, k -> new ArrayList<>()).add(registration); log.info("Registered route: eventType={}, businessType={}, payloadType={}, condition={}", eventType, businessType, payloadType.getName(), condition); } + /** + * {@inheritDoc} + */ @Override public void unregisterRoute(String eventType) { routeRegistrations.remove(eventType); log.info("Unregistered all routes for eventType: {}", eventType); } + /** + * {@inheritDoc} + * + *

实现说明:在 eventType 列表中按 handlerId 过滤移除;列表变空时联动从 Map 中移除该 eventType 条目。 + */ @Override public void unregisterRoute(String eventType, String handlerId) { List> registrations = routeRegistrations.get(eventType); @@ -69,6 +123,17 @@ public void unregisterRoute(String eventType, String handlerId) { } } + /** + * {@inheritDoc} + * + *

实现说明:依次执行第 2~4 步匹配: + *

    + *
  1. 第 2 步 businessType 通配匹配:通过 {@link #matchesBusinessType} 判断(支持 {@code "*"} 通配符)
  2. + *
  3. 第 3 步 payloadType 类型匹配:{@code registration.getPayloadType().isInstance(event.getPayload())}
  4. + *
  5. 第 4 步 SpEL condition 求值:通过 {@link #matchesCondition} 判断
  6. + *
+ * 全部命中后回调 handler,异常被捕获并打印日志,不影响后续路由执行。 + */ @Override @SuppressWarnings("unchecked") public void route(StreamEvent event) { @@ -78,6 +143,7 @@ public void route(StreamEvent event) { } String eventType = event.getEventType(); + // 第 1 步:eventType 精确匹配(Map key 查找) List> registrations = routeRegistrations.get(eventType); if (registrations == null || registrations.isEmpty()) { @@ -89,6 +155,7 @@ public void route(StreamEvent event) { event.getEventId(), eventType, event.getBusinessType()); for (RouteRegistration registration : registrations) { + // 第 2~4 步:businessType 通配匹配 && payloadType 类型匹配 && SpEL condition 条件求值 if (matchesBusinessType(registration.getBusinessType(), event.getBusinessType()) && registration.getPayloadType().isInstance(event.getPayload()) && matchesCondition(registration.getCondition(), event.getPayload())) { @@ -96,33 +163,69 @@ public void route(StreamEvent event) { ((StreamRouteHandler) registration.getHandler()).handle(event.getPayload(), event); log.debug("Dispatched event to handler: {} for eventType: {}", registration.getHandlerId(), eventType); } catch (Exception e) { + // 单个路由处理器异常不影响其他路由执行 log.error("Error handling event in handler: {} for eventType: {}", registration.getHandlerId(), eventType, e); } } } } + /** + * {@inheritDoc} + */ @Override public boolean isRouteRegistered(String eventType) { return routeRegistrations.containsKey(eventType) && !routeRegistrations.get(eventType).isEmpty(); } + /** + * {@inheritDoc} + */ @Override public List> getRoutes(String eventType) { return routeRegistrations.getOrDefault(eventType, new ArrayList<>()); } + /** + * 生成全局唯一的 handlerId,格式为 {eventType}:{businessType}:{payloadType}:{counter}。 + * + * @param eventType 事件类型 + * @param businessType 业务类型,null 时使用 "default" + * @param payloadType 负载类型 + * @return 唯一 handlerId + */ private String generateHandlerId(String eventType, String businessType, Class payloadType) { return eventType + ":" + (businessType != null ? businessType : "default") + ":" + payloadType.getSimpleName() + ":" + handlerCounter.incrementAndGet(); } + /** + * businessType 通配符匹配(路由第 2 步)。 + *

匹配规则: + *

    + *
  • pattern 为 null/空串/{@code "*"} 时视为通配,匹配任意 businessType
  • + *
  • 否则要求精确相等
  • + *
+ * + * @param pattern 路由声明的 businessType 模式 + * @param businessType 事件实际的 businessType + * @return 匹配返回 true + */ private boolean matchesBusinessType(String pattern, String businessType) { + // 通配符 "*" 或留空均表示匹配任意 businessType if (pattern == null || pattern.isEmpty() || "*".equals(pattern)) { return true; } return pattern.equals(businessType); } + /** + * 对路由 condition 进行 SpEL 求值(路由第 4 步)。 + * + * @param condition SpEL 条件表达式,null 或空串表示无条件(恒为 true) + * @param payload 事件负载,作为 SpEL 上下文中的 {@code #payload} 变量 + * @param 负载类型 + * @return 求值为 true 返回 true;求值异常返回 false,避免抛出中断路由 + */ private boolean matchesCondition(String condition, T payload) { if (condition == null || condition.isEmpty()) { return true; @@ -131,6 +234,7 @@ private boolean matchesCondition(String condition, T payload) { try { Expression expression = expressionParser.parseExpression(condition); EvaluationContext context = new StandardEvaluationContext(); + // 将负载作为 #payload 变量暴露给 SpEL 表达式 context.setVariable("payload", payload); Boolean result = expression.getValue(context, Boolean.class); return Boolean.TRUE.equals(result); diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteHandlerBeanPostProcessor.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteHandlerBeanPostProcessor.java index c4c2c21..2921f1b 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteHandlerBeanPostProcessor.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteHandlerBeanPostProcessor.java @@ -9,28 +9,72 @@ import java.lang.reflect.Method; +/** + * {@link StreamRouteHandler} 注解的扫描器与注册器,基于 Spring {@link BeanPostProcessor} 实现。 + * + *

设计意图: + *

    + *
  • 在 Bean 初始化完成后扫描其方法上的 {@link StreamRouteHandler} 注解
  • + *
  • 解析注解元数据(eventType/businessType/condition/payloadType),将方法包装为 + * {@link StreamEventRouter.StreamRouteHandler} 并注册到 {@link StreamEventRouter}
  • + *
  • 支持通过 {@link StreamRouteHandler#value()} 与 {@link StreamRouteHandler#eventType()} 两种方式声明 eventType
  • + *
+ * + *

协作关系: + *

    + *
  • 依赖 {@link StreamEventRouter} SPI 完成实际注册
  • + *
  • 注册时 payloadType 取自方法首个参数类型(payloadType 类型匹配的第 3 步依据)
  • + *
  • 仅处理方法级注解,不处理类级注解(与 {@code EventListenerBeanPostProcessor} 不同)
  • + *
+ * + * @see StreamRouteHandler + * @see StreamEventRouter + */ @Component public class RouteHandlerBeanPostProcessor implements BeanPostProcessor { private static final Logger log = LoggerFactory.getLogger(RouteHandlerBeanPostProcessor.class); + /** + * 路由器 SPI,用于实际注册路由。 + */ private final StreamEventRouter eventRouter; + /** + * 构造方法,由 Spring 注入 {@link StreamEventRouter}。 + * + * @param eventRouter 路由器实例 + */ public RouteHandlerBeanPostProcessor(StreamEventRouter eventRouter) { this.eventRouter = eventRouter; } + /** + * 默认直接返回 Bean,不做任何处理。 + */ @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { return bean; } + /** + * 在 Bean 初始化完成后扫描方法上的 {@link StreamRouteHandler} 注解并注册路由。 + * + *

实现说明: + *

    + *
  1. 迭代 Bean 类的所有 declared methods
  2. + *
  3. 检测到 {@link StreamRouteHandler} 注解时,解析 eventType/businessType/condition
  4. + *
  5. eventType 必填,缺失时跳过并告警;方法必须至少有一个参数,否则跳过
  6. + *
  7. 取方法首个参数类型作为 payloadType,调用 {@link #registerRoute} 完成注册
  8. + *
+ */ @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { Class beanClass = bean.getClass(); for (Method method : beanClass.getDeclaredMethods()) { if (method.isAnnotationPresent(StreamRouteHandler.class)) { StreamRouteHandler annotation = method.getAnnotation(StreamRouteHandler.class); + // eventType 可通过 value() 或 eventType() 两种方式声明,优先取 eventType() String eventType = annotation.eventType(); if (eventType.isEmpty()) { eventType = annotation.value(); @@ -38,17 +82,20 @@ public Object postProcessAfterInitialization(Object bean, String beanName) throw String businessType = annotation.businessType(); String condition = annotation.condition(); + // eventType 必填校验,缺失时跳过当前方法 if (eventType.isEmpty()) { log.warn("Skipping method {} in bean {}: eventType is not specified", method.getName(), beanName); continue; } Class[] parameterTypes = method.getParameterTypes(); + // 方法必须至少有一个参数,作为 payloadType if (parameterTypes.length == 0) { log.warn("Skipping method {} in bean {}: no parameters found", method.getName(), beanName); continue; } + // payloadType 取首个参数类型(路由第 3 步类型匹配依据) Class payloadType = parameterTypes[0]; registerRoute(eventType, businessType, payloadType, condition, bean, method); @@ -57,11 +104,22 @@ public Object postProcessAfterInitialization(Object bean, String beanName) throw return bean; } + /** + * 将标注方法包装为 {@link StreamEventRouter.StreamRouteHandler} 并注册到路由器。 + * + * @param eventType 事件类型 + * @param businessType 业务类型 + * @param payloadType 负载类型 + * @param condition SpEL 条件表达式 + * @param bean 承载方法的 Bean 实例 + * @param method 标注方法 + */ @SuppressWarnings({"rawtypes", "unchecked"}) private void registerRoute(String eventType, String businessType, Class payloadType, String condition, Object bean, Method method) { try { method.setAccessible(true); + // 将方法反射调用包装为 StreamRouteHandler Lambda StreamEventRouter.StreamRouteHandler handler = (payload, event) -> { try { method.invoke(bean, payload); diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteRegistration.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteRegistration.java index 599e5bc..f1cab9c 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteRegistration.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouteRegistration.java @@ -1,17 +1,67 @@ package cn.structure.infra.stream.router; +/** + * 路由注册信息,承载单个路由处理器在 {@link StreamEventRouter} 中的全部上下文。 + * + *

设计意图: + *

    + *
  • 将路由处理器与其 4 个匹配维度(eventType/businessType/payloadType/condition)打包为一等公民对象, + * 便于 {@link DefaultStreamEventRouterImpl#route} 时统一迭代过滤
  • + *
  • handlerId 由 {@code DefaultStreamEventRouterImpl} 生成,作为精确注销的句柄
  • + *
  • 支持 Builder 模式构建
  • + *
+ * + *

协作关系: + *

    + *
  • 由 {@link DefaultStreamEventRouterImpl#registerRoute} 创建并加入注册表
  • + *
  • 由 {@link DefaultStreamEventRouterImpl#route} 在路由时读取 businessType/payloadType/condition 进行过滤
  • + *
+ * + * @param 负载类型 + */ public class RouteRegistration { + /** + * 路由唯一 ID,注册时生成,用于精确注销。 + */ private String handlerId; + /** + * 事件类型,路由匹配第 1 步精确匹配键(作为 Map key)。 + */ private String eventType; + /** + * 业务类型,路由匹配第 2 步筛选条件,支持 {@code "*"} 通配符。 + */ private String businessType; + /** + * 负载类型,路由匹配第 3 步类型检查依据(isInstance 判断)。 + */ private Class payloadType; + /** + * SpEL 条件表达式,路由匹配第 4 步求值依据,可通过 {@code #payload} 引用负载。 + */ private String condition; + /** + * 路由处理器回调。 + */ private StreamEventRouter.StreamRouteHandler handler; + /** + * 默认构造方法,供反序列化或 Builder 使用。 + */ public RouteRegistration() { } + /** + * 全参构造方法。 + * + * @param handlerId 路由唯一 ID + * @param eventType 事件类型 + * @param businessType 业务类型 + * @param payloadType 负载类型 + * @param condition SpEL 条件表达式 + * @param handler 路由处理器 + */ public RouteRegistration(String handlerId, String eventType, String businessType, Class payloadType, String condition, StreamEventRouter.StreamRouteHandler handler) { this.handlerId = handlerId; @@ -22,58 +72,105 @@ public RouteRegistration(String handlerId, String eventType, String businessType this.handler = handler; } + /** + * @return 路由唯一 ID + */ public String getHandlerId() { return handlerId; } + /** + * @param handlerId 路由唯一 ID + */ public void setHandlerId(String handlerId) { this.handlerId = handlerId; } + /** + * @return 事件类型 + */ public String getEventType() { return eventType; } + /** + * @param eventType 事件类型 + */ public void setEventType(String eventType) { this.eventType = eventType; } + /** + * @return 业务类型 + */ public String getBusinessType() { return businessType; } + /** + * @param businessType 业务类型 + */ public void setBusinessType(String businessType) { this.businessType = businessType; } + /** + * @return 负载类型 + */ public Class getPayloadType() { return payloadType; } + /** + * @param payloadType 负载类型 + */ public void setPayloadType(Class payloadType) { this.payloadType = payloadType; } + /** + * @return SpEL 条件表达式 + */ public String getCondition() { return condition; } + /** + * @param condition SpEL 条件表达式 + */ public void setCondition(String condition) { this.condition = condition; } + /** + * @return 路由处理器 + */ public StreamEventRouter.StreamRouteHandler getHandler() { return handler; } + /** + * @param handler 路由处理器 + */ public void setHandler(StreamEventRouter.StreamRouteHandler handler) { this.handler = handler; } + /** + * 创建一个 Builder 以便链式构建注册信息。 + * + * @param 负载类型 + * @return 新的 Builder 实例 + */ public static Builder builder() { return new Builder<>(); } + /** + * RouteRegistration 的链式构建器。 + * + * @param 负载类型 + */ public static class Builder { private String handlerId; private String eventType; @@ -82,36 +179,65 @@ public static class Builder { private String condition; private StreamEventRouter.StreamRouteHandler handler; + /** + * @param handlerId 路由唯一 ID + * @return 当前 Builder + */ public Builder handlerId(String handlerId) { this.handlerId = handlerId; return this; } + /** + * @param eventType 事件类型 + * @return 当前 Builder + */ public Builder eventType(String eventType) { this.eventType = eventType; return this; } + /** + * @param businessType 业务类型 + * @return 当前 Builder + */ public Builder businessType(String businessType) { this.businessType = businessType; return this; } + /** + * @param payloadType 负载类型 + * @return 当前 Builder + */ public Builder payloadType(Class payloadType) { this.payloadType = payloadType; return this; } + /** + * @param condition SpEL 条件表达式 + * @return 当前 Builder + */ public Builder condition(String condition) { this.condition = condition; return this; } + /** + * @param handler 路由处理器 + * @return 当前 Builder + */ public Builder handler(StreamEventRouter.StreamRouteHandler handler) { this.handler = handler; return this; } + /** + * 终结方法,生成 {@link RouteRegistration} 实例。 + * + * @return 新构建的注册信息 + */ public RouteRegistration build() { return new RouteRegistration<>(handlerId, eventType, businessType, payloadType, condition, handler); } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouterProperties.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouterProperties.java index 7951775..e4fbd25 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouterProperties.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/RouterProperties.java @@ -5,98 +5,226 @@ import java.util.ArrayList; import java.util.List; +/** + * 路由配置属性,对应 YAML 配置项 structure.infra.stream.router。 + * + *

设计意图: + *

    + *
  • 提供配置驱动的路由注册能力,避免必须使用 {@link cn.structure.infra.stream.annotation.StreamRouteHandler} 注解
  • + *
  • 每条路由由 {@link RouteDefinition} 描述,包含 id/eventType/businessType/payloadType/condition/handlerBean/handlerMethod
  • + *
  • 由 {@link ConfigurableRouteInitializer} 在应用启动时读取并批量注册到 {@link StreamEventRouter}
  • + *
+ * + *

协作关系: + *

    + *
  • 由 {@code StreamAutoConfiguration} 通过 {@code @EnableConfigurationProperties} 启用
  • + *
  • 由 {@link ConfigurableRouteInitializer} 消费 routes 列表
  • + *
+ * + * @see ConfigurableRouteInitializer + * @see RouteDefinition + */ @ConfigurationProperties(prefix = "structure.infra.stream.router") public class RouterProperties { + /** + * 是否启用配置驱动路由,默认 true。 + */ private boolean enabled = true; + /** + * 路由定义列表,每项对应一条 YAML 中声明的路由。 + */ private List routes = new ArrayList<>(); + /** + * @return 是否启用配置驱动路由 + */ public boolean isEnabled() { return enabled; } + /** + * @param enabled 是否启用配置驱动路由 + */ public void setEnabled(boolean enabled) { this.enabled = enabled; } + /** + * @return 路由定义列表 + */ public List getRoutes() { return routes; } + /** + * @param routes 路由定义列表 + */ public void setRoutes(List routes) { this.routes = routes; } + /** + * 单条路由定义,描述一条配置驱动的路由规则与处理器位置。 + * + *

对应 YAML 配置示例: + *

{@code
+     * structure:
+     *   infra:
+     *     stream:
+     *       router:
+     *         routes:
+     *           - id: order-create
+     *             event-type: order
+     *             business-type: create
+     *             payload-type: com.example.OrderPayload
+     *             condition: "#payload.amount > 100"
+     *             handler-bean: orderHandler
+     *             handler-method: handleCreate
+     * }
+ */ public static class RouteDefinition { + /** + * 路由唯一标识,用于日志与去重。 + */ private String id; + /** + * 事件类型,路由匹配第 1 步键。 + */ private String eventType; + /** + * 业务类型,路由匹配第 2 步筛选条件,支持 {@code "*"} 通配符。 + */ private String businessType; + /** + * 负载类型全限定类名,路由匹配第 3 步类型检查依据。 + */ private String payloadType; + /** + * SpEL 条件表达式,路由匹配第 4 步求值依据。 + */ private String condition; + /** + * 处理器 Bean 名称,由 {@link ConfigurableRouteInitializer} 通过 ApplicationContext 查找。 + */ private String handlerBean; + /** + * 处理器方法名,由 {@link ConfigurableRouteInitializer} 反射调用。 + */ private String handlerMethod; + /** + * 路由描述,仅用于文档与日志,不参与匹配。 + */ private String description; + /** + * @return 路由唯一标识 + */ public String getId() { return id; } + /** + * @param id 路由唯一标识 + */ public void setId(String id) { this.id = id; } + /** + * @return 事件类型 + */ public String getEventType() { return eventType; } + /** + * @param eventType 事件类型 + */ public void setEventType(String eventType) { this.eventType = eventType; } + /** + * @return 业务类型 + */ public String getBusinessType() { return businessType; } + /** + * @param businessType 业务类型 + */ public void setBusinessType(String businessType) { this.businessType = businessType; } + /** + * @return 负载类型全限定类名 + */ public String getPayloadType() { return payloadType; } + /** + * @param payloadType 负载类型全限定类名 + */ public void setPayloadType(String payloadType) { this.payloadType = payloadType; } + /** + * @return SpEL 条件表达式 + */ public String getCondition() { return condition; } + /** + * @param condition SpEL 条件表达式 + */ public void setCondition(String condition) { this.condition = condition; } + /** + * @return 处理器 Bean 名称 + */ public String getHandlerBean() { return handlerBean; } + /** + * @param handlerBean 处理器 Bean 名称 + */ public void setHandlerBean(String handlerBean) { this.handlerBean = handlerBean; } + /** + * @return 处理器方法名 + */ public String getHandlerMethod() { return handlerMethod; } + /** + * @param handlerMethod 处理器方法名 + */ public void setHandlerMethod(String handlerMethod) { this.handlerMethod = handlerMethod; } + /** + * @return 路由描述 + */ public String getDescription() { return description; } + /** + * @param description 路由描述 + */ public void setDescription(String description) { this.description = description; } diff --git a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/StreamEventRouter.java b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/StreamEventRouter.java index 6b2c9e0..bb68613 100644 --- a/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/StreamEventRouter.java +++ b/structure-infra-stream-starter/src/main/java/cn/structure/infra/stream/router/StreamEventRouter.java @@ -4,27 +4,123 @@ import java.util.List; +/** + * 路由网关 SPI,根据 eventType/businessType/payloadType/condition 将 {@link StreamEvent} 路由到匹配的处理器。 + * + *

设计意图: + *

    + *
  • 提供面向事件类型的统一路由入口,与 Spring Cloud Stream 的 binding 模型解耦
  • + *
  • 支持 4 步路由匹配规则:eventType 精确匹配 → businessType 通配匹配 → payloadType 类型匹配 → SpEL condition 条件求值
  • + *
  • 支持运行时动态注册/注销路由,便于扩展
  • + *
+ * + *

协作关系: + *

    + *
  • 由 {@link DefaultStreamEventRouterImpl} 提供默认实现
  • + *
  • 由 {@link RouteHandlerBeanPostProcessor} 扫描 {@code @StreamRouteHandler} 注解方法自动注册
  • + *
  • 由 {@link ConfigurableRouteInitializer} 根据 YAML 配置批量注册路由
  • + *
+ * + * @see DefaultStreamEventRouterImpl + * @see RouteRegistration + * @see StreamRouteHandler + */ public interface StreamEventRouter { + /** + * 注册路由,仅指定 eventType 与 payloadType,businessType 与 condition 留空。 + * + * @param eventType 事件类型 + * @param payloadType 负载类型 + * @param handler 路由处理器 + * @param 负载类型 + */ void registerRoute(String eventType, Class payloadType, StreamRouteHandler handler); + /** + * 注册路由,指定 eventType、payloadType 与 SpEL condition,businessType 留空。 + * + * @param eventType 事件类型 + * @param payloadType 负载类型 + * @param condition SpEL 条件表达式,可通过 {@code #payload} 引用负载 + * @param handler 路由处理器 + * @param 负载类型 + */ void registerRoute(String eventType, Class payloadType, String condition, StreamRouteHandler handler); + /** + * 注册路由,指定 eventType、businessType 与 payloadType,condition 留空。 + * + * @param eventType 事件类型 + * @param businessType 业务类型,支持 {@code "*"} 通配符 + * @param payloadType 负载类型 + * @param handler 路由处理器 + * @param 负载类型 + */ void registerRoute(String eventType, String businessType, Class payloadType, StreamRouteHandler handler); + /** + * 注册路由,指定全部四个匹配维度。 + * + * @param eventType 事件类型 + * @param businessType 业务类型,支持 {@code "*"} 通配符 + * @param payloadType 负载类型 + * @param condition SpEL 条件表达式 + * @param handler 路由处理器 + * @param 负载类型 + */ void registerRoute(String eventType, String businessType, Class payloadType, String condition, StreamRouteHandler handler); + /** + * 注销指定 eventType 下的全部路由。 + * + * @param eventType 事件类型 + */ void unregisterRoute(String eventType); + /** + * 按 handlerId 精确注销路由。 + * + * @param eventType 事件类型 + * @param handlerId 路由唯一 ID + */ void unregisterRoute(String eventType, String handlerId); + /** + * 路由事件,按 4 步规则匹配后回调处理器。 + * + * @param event 事件信封 + * @param 负载类型 + */ void route(StreamEvent event); + /** + * 判断指定 eventType 是否存在已注册的路由。 + * + * @param eventType 事件类型 + * @return 存在且非空返回 true + */ boolean isRouteRegistered(String eventType); + /** + * @param eventType 事件类型 + * @return 该 eventType 下的所有路由注册信息,不存在时返回空列表 + */ List> getRoutes(String eventType); + /** + * 路由处理器函数接口,与 {@link cn.structure.infra.stream.handler.StreamEventHandler} 区分: + * 本接口同时接收 payload 与完整事件信封,便于处理器在需要时访问路由元数据。 + * + * @param 负载类型 + */ interface StreamRouteHandler { + /** + * 处理路由事件。 + * + * @param payload 业务负载,已通过 payloadType 类型匹配 + * @param event 完整事件信封,可访问 eventType/businessType/headers 等 + */ void handle(T payload, StreamEvent event); } diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java index e898026..19621a2 100644 --- a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java @@ -13,12 +13,41 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * xxljob-starter 的 Spring Boot 自动配置类。 + * + *

该配置类负责装配 XXL-Job 调度体系的核心 Bean,包括:

+ *
    + *
  • {@link XxlJobTemplate}:XXL-Job 操作模板(默认实现 {@link XxlJobTemplateImpl})
  • + *
  • {@link TaskScheduler}:基于 XXL-Job 的 {@link XxlJobTaskScheduler}, + * 覆盖本地 {@code LocalThreadTaskScheduler}
  • + *
+ * + *

覆盖本地调度的关键机制:通过 {@code @AutoConfigureBefore} 将本配置类 + * 排在 schedule-starter 的 {@link AutoScheduleConfiguration} 之前装配。 + * 由于两个配置类都使用 {@code @ConditionalOnMissingBean(TaskScheduler.class)}, + * 本类注册的 {@link XxlJobTaskScheduler} 会先占用 {@link TaskScheduler} Bean 位, + * 使得 {@link AutoScheduleConfiguration} 中的本地调度器 Bean 不再创建, + * 从而实现"XXL-Job 启用时覆盖本地调度"的效果。

+ * + *

依赖说明:本配置类依赖外部提供的 {@link XxlJobClient} Bean + * (通常由 xxl-job-core 或项目内 xxljob-executor 模块自动装配)。

+ */ @Slf4j @Configuration @EnableConfigurationProperties(XxlJobProperties.class) @AutoConfigureBefore(cn.structure.infra.configuration.AutoScheduleConfiguration.class) public class AutoXxlJobConfiguration { + /** + * 装配 XXL-Job 操作模板。 + * + *

仅当容器中不存在自定义 {@link XxlJobTemplate} 时生效。

+ * + * @param xxlJobClient XXL-Job 远程调用客户端(外部提供) + * @param xxlJobProperties XXL-Job 配置属性 + * @return 默认实现 {@link XxlJobTemplateImpl} + */ @Bean @ConditionalOnMissingBean(XxlJobTemplate.class) public XxlJobTemplate xxlJobTemplate(XxlJobClient xxlJobClient, XxlJobProperties xxlJobProperties) { @@ -26,6 +55,17 @@ public XxlJobTemplate xxlJobTemplate(XxlJobClient xxlJobClient, XxlJobProperties return new XxlJobTemplateImpl(xxlJobClient, xxlJobProperties); } + /** + * 装配基于 XXL-Job 的 {@link TaskScheduler} 实现。 + * + *

仅当容器中不存在自定义 {@link TaskScheduler} 时生效。由于本配置类通过 + * {@code @AutoConfigureBefore} 排在 {@link AutoScheduleConfiguration} 之前, + * 此处注册的 {@link XxlJobTaskScheduler} 会优先占用 {@link TaskScheduler} Bean 位, + * 从而阻止本地 {@code LocalThreadTaskScheduler} 的创建,实现 XXL-Job 覆盖本地调度。

+ * + * @param xxlJobTemplate XXL-Job 操作模板 + * @return XXL-Job 任务调度器实例 + */ @Bean @ConditionalOnMissingBean(TaskScheduler.class) public TaskScheduler taskScheduler(XxlJobTemplate xxlJobTemplate) { diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/properties/XxlJobProperties.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/properties/XxlJobProperties.java index 83546c5..7102e2d 100644 --- a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/properties/XxlJobProperties.java +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/properties/XxlJobProperties.java @@ -3,12 +3,44 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; +/** + * xxljob-starter 的配置属性类,前缀 {@code structure.schedule.xxl-job}。 + * + *

该类承载 XXL-Job 集成相关的可配置项,由 {@code AutoXxlJobConfiguration} 通过 + * {@code @EnableConfigurationProperties} 装配,并注入到 {@code XxlJobTemplateImpl} 等组件中。

+ * + *

配置示例:

+ *
+ * structure:
+ *   schedule:
+ *     xxl-job:
+ *       enabled: true
+ *       job-group: 1
+ * 
+ * + *

启用机制说明:当 {@link #enabled} 为 {@code true} 时,{@code AutoXxlJobConfiguration} + * 会注册 {@code XxlJobTaskScheduler} 作为 {@link cn.structure.infra.schedule.TaskScheduler} SPI + * 的实现,并通过 {@code @AutoConfigureBefore} 在本地 {@code AutoScheduleConfiguration} 之前装配, + * 从而使 XXL-Job 实现覆盖本地调度实现。

+ */ @Data @ConfigurationProperties(prefix = "structure.schedule.xxl-job") public class XxlJobProperties { + /** + * 是否启用 XXL-Job 调度体系。 + * + *

启用后,{@code XxlJobTaskScheduler} 将覆盖本地 {@code LocalThreadTaskScheduler} + * 成为 {@link cn.structure.infra.schedule.TaskScheduler} 的实现 Bean。

+ */ private boolean enabled = true; + /** + * XXL-Job 的执行器分组 ID。 + * + *

对应 XXL-Job 调度中心中的 jobGroup,用于将任务归属到特定执行器分组下。 + * 默认值为 {@code 1},需根据实际调度中心配置调整。

+ */ private Integer jobGroup = 1; } \ No newline at end of file diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTaskScheduler.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTaskScheduler.java index 50f4e4b..4f91942 100644 --- a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTaskScheduler.java +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTaskScheduler.java @@ -8,28 +8,88 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * 基于 XXL-Job 的分布式任务调度器,实现 schedule-starter 模块的 {@link TaskScheduler} SPI。 + * + *

设计意图:将本地调度 SPI 透明地转发到 XXL-Job 分布式调度中心,使业务方 + * 在不修改调度 API 调用代码的前提下,从单机本地调度切换到分布式调度。当 xxljob-starter + * 启用时(通过 {@code @AutoConfigureBefore} 机制),本类将覆盖本地 + * {@code LocalThreadTaskScheduler} 成为 {@link TaskScheduler} 的实现 Bean。

+ * + *

核心机制:

+ *
    + *
  • taskId↔xxlJobId 双向映射:业务侧以 taskId 操作,XXL-Job 侧以 xxlJobId + * 操作,本类通过 {@link #taskIdToXxlJobIdMap} 维护 taskId 到 xxlJobId 的映射
  • + *
  • CRON 转换:FIXED_RATE/FIXED_DELAY 类型会被转换为 XXL-Job 6 位 CRON + * 表达式({@code 0/N * * * * ?}),CRON 类型直接透传
  • + *
  • 状态同步:本地 {@link #taskMap} 维护任务元信息与状态,与 XXL-Job 侧状态保持一致
  • + *
+ * + *

协作关系:依赖 {@link XxlJobTemplate} 完成对 XXL-Job 调度中心的所有远程操作。

+ * + *

CRON 转换说明:XXL-Job 使用 6 位 CRON(秒 分 时 日 月 周),无年字段; + * 本地 {@link ScheduleTask#getCronExpression()} 若为 7 位 CRON 含年字段,需调用方在 + * 传入前自行处理。本类对 CRON 类型直接透传,对 FIXED_RATE/FIXED_DELAY 通过 + * {@link #convertToCron(ScheduleTask)} 生成 6 位 CRON。

+ */ @Slf4j public class XxlJobTaskScheduler implements TaskScheduler { + /** + * XXL-Job 操作模板,封装对调度中心的远程调用。 + */ private final XxlJobTemplate xxlJobTemplate; + /** + * taskId 到 xxlJobId 的映射表。 + * + *

这是 taskId↔xxlJobId 映射的核心数据结构:业务侧以 taskId 为操作单元, + * 通过该 Map 查找对应的 XXL-Job 任务 ID 后再调用 {@link XxlJobTemplate} 完成远程操作。

+ */ private final Map taskIdToXxlJobIdMap = new ConcurrentHashMap<>(); + /** + * taskId 到任务元信息的映射表,用于本地状态管理和查询。 + */ private final Map taskMap = new ConcurrentHashMap<>(); + /** + * 构造方法。 + * + * @param xxlJobTemplate XXL-Job 操作模板 + */ public XxlJobTaskScheduler(XxlJobTemplate xxlJobTemplate) { this.xxlJobTemplate = xxlJobTemplate; log.info("XxlJobTaskScheduler initialized"); } + /** + * 通过 XXL-Job 调度一个任务。 + * + *

调度流程:

+ *
    + *
  1. 校验 task 字段
  2. + *
  3. 若 taskId 已存在则先 remove 旧任务(含 XXL-Job 侧)
  4. + *
  5. 将本地调度类型转换为 XXL-Job CRON 表达式
  6. + *
  7. 调用 {@link XxlJobTemplate#add} 在 XXL-Job 侧创建任务
  8. + *
  9. 建立 taskId→xxlJobId 映射,记录任务元信息,状态置为 RUNNING
  10. + *
+ * + * @param task 任务描述对象 + * @throws IllegalArgumentException 当 task 字段校验失败或 CRON 表达式为空时抛出 + * @throws RuntimeException 当 XXL-Job 远程调用失败时抛出 + */ @Override public void schedule(ScheduleTask task) { validateTask(task); + // 幂等调度:先移除同 taskId 的旧任务(含 XXL-Job 侧映射) remove(task.getTaskId()); + // CRON 转换:将本地调度类型转换为 XXL-Job 6 位 CRON 表达式 String cronExpression = convertToCron(task); + // 在 XXL-Job 调度中心添加任务,返回 xxlJobId String xxlJobId = xxlJobTemplate.add( task.getTaskName(), cronExpression, @@ -37,6 +97,7 @@ public void schedule(ScheduleTask task) { task.getHandlerParam() ); + // 建立 taskId→xxlJobId 映射,是 taskId↔xxlJobId 双向映射的核心数据 taskIdToXxlJobIdMap.put(task.getTaskId(), xxlJobId); task.setStatus(ScheduleTask.TaskStatus.RUNNING); taskMap.put(task.getTaskId(), task); @@ -45,12 +106,24 @@ public void schedule(ScheduleTask task) { task.getTaskId(), xxlJobId, task.getHandlerName()); } + /** + * 通过 XXL-Job 更新已有任务。 + * + *

若 taskId→xxlJobId 映射不存在(说明任务未在 XXL-Job 侧注册),则降级为新增调度; + * 否则通过映射查找 xxlJobId 并调用 {@link XxlJobTemplate#update}。

+ * + * @param task 新的任务描述对象 + * @throws IllegalArgumentException 当 task 字段校验失败时抛出 + * @throws RuntimeException 当 XXL-Job 远程调用失败时抛出 + */ @Override public void update(ScheduleTask task) { validateTask(task); + // 通过 taskId→xxlJobId 映射查找 XXL-Job 侧任务 ID String xxlJobId = taskIdToXxlJobIdMap.get(task.getTaskId()); if (xxlJobId == null) { + // 映射不存在则降级为新增调度 log.warn("XXL-Job task not found for update: {}", task.getTaskId()); schedule(task); return; @@ -72,8 +145,17 @@ public void update(ScheduleTask task) { log.info("Updated task via XXL-Job: taskId={}, xxlJobId={}", task.getTaskId(), xxlJobId); } + /** + * 通过 XXL-Job 移除任务。 + * + *

先通过 taskId→xxlJobId 映射查找 xxlJobId,再调用 {@link XxlJobTemplate#remove} + * 删除 XXL-Job 侧任务,并清理本地映射和元信息,状态置为 STOPPED。

+ * + * @param taskId 任务唯一标识 + */ @Override public void remove(String taskId) { + // 从映射中移除并获取 xxlJobId(taskId↔xxlJobId 映射清理) String xxlJobId = taskIdToXxlJobIdMap.remove(taskId); if (xxlJobId != null) { xxlJobTemplate.remove(xxlJobId); @@ -87,6 +169,14 @@ public void remove(String taskId) { log.info("Removed task via XXL-Job: taskId={}, xxlJobId={}", taskId, xxlJobId); } + /** + * 通过 XXL-Job 暂停任务调度。 + * + *

通过 taskId→xxlJobId 映射查找后调用 {@link XxlJobTemplate#pause}, + * 本地状态置为 PAUSED。映射关系保留以便恢复。

+ * + * @param taskId 任务唯一标识 + */ @Override public void pause(String taskId) { String xxlJobId = taskIdToXxlJobIdMap.get(taskId); @@ -102,6 +192,13 @@ public void pause(String taskId) { log.info("Paused task via XXL-Job: taskId={}, xxlJobId={}", taskId, xxlJobId); } + /** + * 通过 XXL-Job 恢复任务调度。 + * + *

仅当任务当前状态为 PAUSED 时才会调用 {@link XxlJobTemplate#start} 恢复调度。

+ * + * @param taskId 任务唯一标识 + */ @Override public void resume(String taskId) { String xxlJobId = taskIdToXxlJobIdMap.get(taskId); @@ -116,16 +213,36 @@ public void resume(String taskId) { log.info("Resumed task via XXL-Job: taskId={}, xxlJobId={}", taskId, xxlJobId); } + /** + * 根据任务 ID 查询任务信息。 + * + * @param taskId 任务唯一标识 + * @return 任务描述对象;若任务不存在则返回 {@code null} + */ @Override public ScheduleTask getTaskInfo(String taskId) { return taskMap.get(taskId); } + /** + * 获取当前调度器中所有已注册任务的快照列表。 + * + * @return 任务列表的不可变副本;若无任何任务则返回空列表 + */ @Override public List getAllTasks() { return List.copyOf(taskMap.values()); } + /** + * 校验任务字段合法性。 + * + *

注意:与本地调度器不同,本实现不校验 handler 是否已注册——因为 handler 的实际 + * 执行发生在 XXL-Job 执行器侧,而非本进程。

+ * + * @param task 待校验任务 + * @throws IllegalArgumentException 当 task、taskId、handlerName 为空或 scheduleType 为空时抛出 + */ private void validateTask(ScheduleTask task) { if (task == null || task.getTaskId() == null) { throw new IllegalArgumentException("Task and taskId cannot be null"); @@ -140,7 +257,24 @@ private void validateTask(ScheduleTask task) { } } + /** + * 将本地 {@link ScheduleTask} 的调度类型转换为 XXL-Job CRON 表达式。 + * + *

CRON 转换规则:

+ *
    + *
  • {@link ScheduleTask.ScheduleType#CRON}:直接透传 {@link ScheduleTask#getCronExpression()}, + * 调用方需保证表达式为 XXL-Job 6 位 CRON 格式(秒 分 时 日 月 周,无年字段)
  • + *
  • {@link ScheduleTask.ScheduleType#FIXED_RATE} / {@link ScheduleTask.ScheduleType#FIXED_DELAY}: + * 根据间隔毫秒数生成 6 位 CRON {@code 0/N * * * * ?}(每 N 秒触发一次), + * 不足 1 秒按 1 秒处理
  • + *
+ * + * @param task 任务描述对象 + * @return XXL-Job 6 位 CRON 表达式 + * @throws IllegalArgumentException 当 CRON 类型但表达式为空时抛出 + */ private String convertToCron(ScheduleTask task) { + // CRON 类型:直接透传,调用方需保证为 6 位 CRON(XXL-Job 无年字段) if (task.getScheduleType() == ScheduleTask.ScheduleType.CRON) { if (task.getCronExpression() == null || task.getCronExpression().isEmpty()) { throw new IllegalArgumentException("Cron expression cannot be null for CRON schedule type"); @@ -148,16 +282,19 @@ private String convertToCron(ScheduleTask task) { return task.getCronExpression(); } + // FIXED_RATE/FIXED_DELAY:取间隔毫秒数,FIXED_RATE 用 period,FIXED_DELAY 用 delay long milliseconds = task.getScheduleType() == ScheduleTask.ScheduleType.FIXED_RATE ? (task.getPeriod() != null ? task.getPeriod() : 1000L) : (task.getDelay() != null ? task.getDelay() : 1000L); + // 毫秒转秒,不足 1 秒按 1 秒处理(XXL-Job CRON 秒级精度) long seconds = milliseconds / 1000; if (seconds < 1) { seconds = 1; } + // 生成 6 位 CRON:0/N 表示从第 0 秒开始每 N 秒触发一次 return "0/" + seconds + " * * * * ?"; } } \ No newline at end of file diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplate.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplate.java index 8b64e5c..7c2783e 100644 --- a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplate.java +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplate.java @@ -1,16 +1,76 @@ package cn.structure.infra.schedule.xxljob; +/** + * XXL-Job 操作模板接口,封装对 XXL-Job 调度中心 API 的增删改查操作。 + * + *

设计意图:将 {@code XxlJobTaskScheduler} 与 XXL-Job 客户端 + * ({@code XxlJobClient})解耦。调度器仅依赖本接口即可完成任务的远程管理, + * 便于替换为不同实现(如 mock 测试、自定义 RPC 等),也便于对 XXL-Job 的 + * 调用进行统一封装和监控。

+ * + *

已知实现:{@link XxlJobTemplateImpl}(基于 {@code XxlJobClient} 的默认实现)。

+ * + *

与 taskId↔xxlJobId 映射的关系:本接口方法以 {@code jobId}(XXL-Job 侧 ID) + * 为操作单元,而 {@code XxlJobTaskScheduler} 在外部以 {@code taskId}(业务侧 ID)为操作单元。 + * 两者之间的映射由 {@code XxlJobTaskScheduler} 内部的 Map 维护,本接口不感知 taskId。

+ */ public interface XxlJobTemplate { + /** + * 在 XXL-Job 调度中心添加一个新任务。 + * + * @param jobName 任务名称(描述性信息) + * @param cronExpression CRON 表达式(XXL-Job 使用 6 位 CRON,无年字段) + * @param handlerName 执行器侧的 handler 名称 + * @param handlerParam handler 执行参数 + * @return 新创建的 XXL-Job 任务 ID(字符串形式) + * @throws RuntimeException 当 XXL-Job 调用失败时抛出 + */ String add(String jobName, String cronExpression, String handlerName, String handlerParam); + /** + * 更新已有的 XXL-Job 任务配置。 + * + * @param jobId XXL-Job 任务 ID + * @param jobName 任务名称 + * @param cronExpression CRON 表达式 + * @param handlerName 执行器侧的 handler 名称 + * @param handlerParam handler 执行参数 + * @throws RuntimeException 当 XXL-Job 调用失败时抛出 + */ void update(String jobId, String jobName, String cronExpression, String handlerName, String handlerParam); + /** + * 从 XXL-Job 调度中心移除任务。 + * + * @param jobId XXL-Job 任务 ID + * @throws RuntimeException 当 XXL-Job 调用失败时抛出 + */ void remove(String jobId); + /** + * 暂停 XXL-Job 任务调度。 + * + * @param jobId XXL-Job 任务 ID + * @throws RuntimeException 当 XXL-Job 调用失败时抛出 + */ void pause(String jobId); + /** + * 启动(或恢复)XXL-Job 任务调度。 + * + * @param jobId XXL-Job 任务 ID + * @throws RuntimeException 当 XXL-Job 调用失败时抛出 + */ void start(String jobId); + /** + * 根据 handler 名称查询 XXL-Job 任务 ID。 + * + *

当前实现返回 {@code null}(占位方法),可由子类按需实现。

+ * + * @param handlerName handler 名称 + * @return XXL-Job 任务 ID;若未找到返回 {@code null} + */ String getJobId(String handlerName); } \ No newline at end of file diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplateImpl.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplateImpl.java index 5c03f3c..7da75b6 100644 --- a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplateImpl.java +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/schedule/xxljob/XxlJobTemplateImpl.java @@ -11,18 +11,55 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +/** + * {@link XxlJobTemplate} 的默认实现,基于 {@link XxlJobClient} 完成对 XXL-Job 调度中心的 + * 添加/更新/删除/暂停/启动等远程操作。 + * + *

设计意图:统一封装 XXL-Job RPC 调用的细节,包括:

+ *
    + *
  • 构造 {@link XxlJobInfoDTO} 任务对象(默认执行器路由策略、阻塞策略、超时、重试次数等)
  • + *
  • 解析 {@link Response} 返回值,按 {@link XxlJobContext#HANDLE_CODE_SUCCESS} 判断成败
  • + *
  • 失败时抛出 {@link RuntimeException},由上层调度器统一处理
  • + *
+ * + *

协作关系:由 {@code AutoXxlJobConfiguration} 装配,注入到 {@link XxlJobTaskScheduler} + * 中作为底层 XXL-Job 操作通道。{@code XxlJobTaskScheduler} 通过本类将业务侧的 taskId 转换为 + * XXL-Job 侧的 jobId 后调用对应方法。

+ * + *

默认 job 配置:{@link #buildJobInfo(Integer, String, String, String, String)} 中 + * 设置了默认配置项(路由策略 FIRST、阻塞策略 SERIAL_EXECUTION、超时 300s、失败重试 1 次、 + * Glue 类型 BEAN),对应 XXL-Job 默认 job 配置表的初始值。

+ */ @Slf4j @AllArgsConstructor public class XxlJobTemplateImpl implements XxlJobTemplate { + /** + * XXL-Job 远程调用客户端。 + */ private final XxlJobClient xxlJobClient; + /** + * XXL-Job 配置属性,主要用于获取 jobGroup。 + */ private final XxlJobProperties jobProperties; + /** + * 在 XXL-Job 调度中心添加一个新任务。 + * + * @param jobName 任务名称 + * @param cronExpression CRON 表达式(XXL-Job 使用 6 位 CRON) + * @param handlerName 执行器侧的 handler 名称 + * @param handlerParam handler 执行参数 + * @return 新创建的 XXL-Job 任务 ID(字符串形式) + * @throws RuntimeException 当 XXL-Job 调用返回失败码时抛出 + */ @Override public String add(String jobName, String cronExpression, String handlerName, String handlerParam) { + // 构造 XxlJobInfoDTO(id 为 null 表示新增) XxlJobInfoDTO jobInfo = buildJobInfo(null, jobName, cronExpression, handlerName, handlerParam); Response returnT = xxlJobClient.add(jobInfo); + // 按 XXL-Job 成功码判断结果 if (returnT.getCode() == XxlJobContext.HANDLE_CODE_SUCCESS) { log.info("XXL-Job add success: jobName={}, handlerName={}, jobId={}", jobName, handlerName, returnT.getData()); return returnT.getData(); @@ -32,8 +69,19 @@ public String add(String jobName, String cronExpression, String handlerName, Str } } + /** + * 更新已有的 XXL-Job 任务配置。 + * + * @param jobId XXL-Job 任务 ID(字符串形式,内部转为 Integer) + * @param jobName 任务名称 + * @param cronExpression CRON 表达式 + * @param handlerName 执行器侧的 handler 名称 + * @param handlerParam handler 执行参数 + * @throws RuntimeException 当 XXL-Job 调用返回失败码时抛出 + */ @Override public void update(String jobId, String jobName, String cronExpression, String handlerName, String handlerParam) { + // jobId 字符串转 Integer,作为更新主键 XxlJobInfoDTO jobInfo = buildJobInfo(Integer.parseInt(jobId), jobName, cronExpression, handlerName, handlerParam); Response returnT = xxlJobClient.update(jobInfo); if (returnT.getCode() == XxlJobContext.HANDLE_CODE_SUCCESS) { @@ -44,6 +92,12 @@ public void update(String jobId, String jobName, String cronExpression, String h } } + /** + * 从 XXL-Job 调度中心移除任务。 + * + * @param jobId XXL-Job 任务 ID + * @throws RuntimeException 当 XXL-Job 调用返回失败码时抛出 + */ @Override public void remove(String jobId) { Response returnT = xxlJobClient.remove(jobId); @@ -55,6 +109,12 @@ public void remove(String jobId) { } } + /** + * 暂停 XXL-Job 任务调度。 + * + * @param jobId XXL-Job 任务 ID + * @throws RuntimeException 当 XXL-Job 调用返回失败码时抛出 + */ @Override public void pause(String jobId) { Response returnT = xxlJobClient.pause(jobId); @@ -66,6 +126,12 @@ public void pause(String jobId) { } } + /** + * 启动(或恢复)XXL-Job 任务调度。 + * + * @param jobId XXL-Job 任务 ID + * @throws RuntimeException 当 XXL-Job 调用返回失败码时抛出 + */ @Override public void start(String jobId) { Response returnT = xxlJobClient.start(jobId); @@ -77,11 +143,40 @@ public void start(String jobId) { } } + /** + * 根据 handler 名称查询 XXL-Job 任务 ID。 + * + *

当前为占位实现,始终返回 {@code null}。

+ * + * @param handlerName handler 名称 + * @return 始终返回 {@code null} + */ @Override public String getJobId(String handlerName) { return null; } + /** + * 构造 {@link XxlJobInfoDTO} 任务对象,填充默认 job 配置。 + * + *

默认配置项:

+ *
    + *
  • jobGroup:取自 {@link XxlJobProperties#getJobGroup()}
  • + *
  • author:固定为 {@code "system"}
  • + *
  • executorRouteStrategy:{@code FIRST}(第一个执行器)
  • + *
  • executorBlockStrategy:{@code SERIAL_EXECUTION}(串行执行)
  • + *
  • executorTimeout:300 秒
  • + *
  • executorFailRetryCount:1 次
  • + *
  • glueType:{@code BEAN}(Bean 模式)
  • + *
+ * + * @param id 任务 ID,新增时为 {@code null} + * @param jobName 任务名称 + * @param cronExpression CRON 表达式 + * @param handlerName handler 名称 + * @param handlerParam handler 参数 + * @return 填充完成的 XxlJobInfoDTO + */ private XxlJobInfoDTO buildJobInfo(Integer id, String jobName, String cronExpression, String handlerName, String handlerParam) { XxlJobInfoDTO jobInfo = new XxlJobInfoDTO(); jobInfo.setId(id); From f2eb5f33629009e83f8ab0280a04f17f79fb148f Mon Sep 17 00:00:00 2001 From: chuck <361648887@qq.com> Date: Mon, 6 Jul 2026 02:33:09 +0800 Subject: [PATCH 7/8] =?UTF-8?q?refactor(xxjob):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E6=B3=A8=E8=A7=A3=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?MongoDB=E7=B4=A2=E5=BC=95=E5=88=9B=E5=BB=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在AutoXxlJobConfiguration上添加ConditionalOnProperty注解控制启用状态 - 将MockMongoConfiguration中的ensureIndex方法调用改为createIndex - 移除MongoLowCodeStorage中未使用的FieldType导入和多余集合类型声明 - 优化MongoLowCodeStorage中索引创建逻辑,简化排序方向设置 - 更新分页查询中页码和页面大小的转换逻辑 - 简化Document到HashMap的转换方式 - 在pom.xml中添加spring-cloud-function-context依赖支持函数式编程 --- pom.xml | 6 ++++- .../mongodb/lowcode/MongoLowCodeStorage.java | 22 +++++-------------- .../config/MockMongoConfiguration.java | 2 +- .../AutoXxlJobConfiguration.java | 2 ++ 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/pom.xml b/pom.xml index 9367e80..fdca93a 100644 --- a/pom.xml +++ b/pom.xml @@ -180,7 +180,11 @@ spring-cloud-stream ${spring-cloud-stream.version} - + + org.springframework.cloud + spring-cloud-function-context + ${spring-cloud-stream.version} + org.projectlombok diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java index cd94286..5ac956e 100644 --- a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java @@ -4,7 +4,6 @@ import cn.structure.common.vo.ResPage; import cn.structure.infra.lowcode.model.AutoFillType; import cn.structure.infra.lowcode.model.FieldSchema; -import cn.structure.infra.lowcode.model.FieldType; import cn.structure.infra.lowcode.model.ResourceSchema; import cn.structure.infra.lowcode.repository.LowCodeStorage; import lombok.extern.slf4j.Slf4j; @@ -18,11 +17,7 @@ import org.springframework.data.mongodb.core.query.Update; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; /** * MongoDB 低代码仓储实现 @@ -100,15 +95,14 @@ private void createIndexes(String collectionName) { // 主键、索引、唯一字段均需创建索引 if (field.isPrimaryKey() || field.isIndex() || field.isUnique()) { Index index = new Index() - .on(field.getName(), field.isUnique() ? org.springframework.data.domain.Sort.Direction.ASC - : org.springframework.data.domain.Sort.Direction.ASC); + .on(field.getName(), Sort.Direction.ASC); // 唯一字段追加 unique 约束 if (field.isUnique()) { index.unique(); } - mongoTemplate.indexOps(collectionName).ensureIndex(index); + mongoTemplate.indexOps(collectionName).createIndex(index); log.debug("Created index for field: {} (unique={}, index={})", field.getName(), field.isUnique(), field.isIndex()); } @@ -279,8 +273,8 @@ public List> queryList(Map queryParams) { @Override public ResPage> queryPage(ReqPage reqPage) { // MongoTemplate 页码从 0 开始,业务页码从 1 开始,需减 1 - int pageNum = reqPage.getPage() != null ? reqPage.getPage().intValue() - 1 : 0; - int pageSize = reqPage.getSize() != null ? reqPage.getSize().intValue() : 10; + int pageNum = reqPage.getPage() != null ? reqPage.getPage() - 1 : 0; + int pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; Query query = buildQuery(null); long total = mongoTemplate.count(query, schema.getTableName()); @@ -445,11 +439,7 @@ private Map documentToMap(Document document) { return null; } // Document 本身即 Map 派生,此处拷贝为独立 HashMap 以隔离 MongoDB 驱动类型 - Map map = new HashMap<>(); - for (Map.Entry entry : document.entrySet()) { - map.put(entry.getKey(), entry.getValue()); - } - return map; + return new HashMap<>(document); } /** diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/config/MockMongoConfiguration.java b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/config/MockMongoConfiguration.java index 52af2e1..74df5c3 100644 --- a/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/config/MockMongoConfiguration.java +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/config/MockMongoConfiguration.java @@ -201,7 +201,7 @@ private MongoTemplate createMongoTemplate() { // indexOps IndexOperations indexOps = mock(IndexOperations.class); - when(indexOps.ensureIndex(any(Index.class))).thenReturn(""); + when(indexOps.createIndex(any(Index.class))).thenReturn(""); when(template.indexOps(anyString())).thenReturn(indexOps); // findOne with collectionName diff --git a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java index 19621a2..c7a5b1f 100644 --- a/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java +++ b/structure-infra-xxljob-starter/src/main/java/cn/structure/infra/configuration/AutoXxlJobConfiguration.java @@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.AutoConfigureBefore; 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 org.springframework.context.annotation.Configuration; @@ -37,6 +38,7 @@ @Configuration @EnableConfigurationProperties(XxlJobProperties.class) @AutoConfigureBefore(cn.structure.infra.configuration.AutoScheduleConfiguration.class) +@ConditionalOnProperty(prefix = "structure.schedule.xxl-job", name = "enabled", havingValue = "true") public class AutoXxlJobConfiguration { /** From 66c1be808d2660e25e292a8e15d0ab6bb393890d Mon Sep 17 00:00:00 2001 From: chuck <361648887@qq.com> Date: Tue, 7 Jul 2026 20:43:08 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat(schedule):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=9F=BA=E4=BA=8ESpring=20CronExpression=E7=9A=84=E7=B2=BE?= =?UTF-8?q?=E7=A1=AECRON=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换原有的1秒级轮询实现为基于Spring CronExpression的精确调度 - 采用递归一次性调度模式按下次触发时间精确触发任务 - 支持标准6字段cron表达式(秒分时日月周)及?字符语法 - 添加CronScheduledFuture包装器管理调度链的取消操作 - 实现computeNextDelayMs方法处理边界情况防止重复触发 - 更新LocalThreadTaskScheduler文档说明CRON调度的精确语义 - 为CRON任务类型添加完整的单元测试验证调度精度和取消功能 - 在多个sample模块的pom.xml中添加编译器配置抑制classfile告警 --- .../pom.xml | 17 ++ .../structure-infra-sample-jpa/pom.xml | 17 ++ .../structure-infra-sample-mongodb/pom.xml | 17 ++ structure-infra-schedule-starter/README.md | 4 +- .../schedule/LocalThreadTaskScheduler.java | 199 ++++++++++++++++-- .../infra/schedule/ScheduleTask.java | 13 +- .../LocalThreadTaskSchedulerTest.java | 87 ++++++++ 7 files changed, 327 insertions(+), 27 deletions(-) diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/pom.xml b/structure-infra-sample/structure-infra-sample-elasticsearch/pom.xml index 9d6ab73..70dd84a 100644 --- a/structure-infra-sample/structure-infra-sample-elasticsearch/pom.xml +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/pom.xml @@ -60,4 +60,21 @@ + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + -Xlint:none + + + + + + diff --git a/structure-infra-sample/structure-infra-sample-jpa/pom.xml b/structure-infra-sample/structure-infra-sample-jpa/pom.xml index 39bdc87..b01401a 100644 --- a/structure-infra-sample/structure-infra-sample-jpa/pom.xml +++ b/structure-infra-sample/structure-infra-sample-jpa/pom.xml @@ -58,4 +58,21 @@ + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + -Xlint:none + + + + + + diff --git a/structure-infra-sample/structure-infra-sample-mongodb/pom.xml b/structure-infra-sample/structure-infra-sample-mongodb/pom.xml index 8d6d0b7..5e75eba 100644 --- a/structure-infra-sample/structure-infra-sample-mongodb/pom.xml +++ b/structure-infra-sample/structure-infra-sample-mongodb/pom.xml @@ -62,4 +62,21 @@ + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + -Xlint:none + + + + + + diff --git a/structure-infra-schedule-starter/README.md b/structure-infra-schedule-starter/README.md index f8232ee..0402847 100644 --- a/structure-infra-schedule-starter/README.md +++ b/structure-infra-schedule-starter/README.md @@ -120,7 +120,7 @@ List getAllTasks(); - 通过 `Executors.newScheduledThreadPool(poolSize, threadFactory)` 创建调度器,线程为守护线程,命名为 `structure-schedule-` - `schedule(task)` 校验 → 先 `remove(taskId)` 取消旧任务 → 包装 Runnable 通过注册表查找 handler → 按 `scheduleType` 派发 - `FIXED_DELAY` / `FIXED_RATE` 默认间隔 1000ms -- `CRON` 当前为简化实现,按 1000ms 间隔执行(cron 表达式仅存储不解析) +- `CRON` 基于 Spring `CronExpression` 解析 6 字段 cron 表达式,按下次触发时间递归一次性调度,严格遵循 cron 语义 - `update(task)` 等同于 `schedule(task)`(先取消再调度) - `pause(taskId)` 取消 future 但保留任务信息 - `resume(taskId)` 仅当 `status == PAUSED` 时重新调度 @@ -270,7 +270,7 @@ public class JobManagerController { ## 注意事项 -- **CRON 表达式**:当前版本未接入 cron 解析器,CRON 类型任务固定按 1 秒间隔执行,`cronExpression` 仅存储不解析。如需严格 cron 调度,请使用 `structure-infra-xxljob-starter` 或自行集成 Spring `CronTrigger` +- **CRON 表达式**:基于 Spring `CronExpression` 解析标准 6 字段 cron(秒 分 时 日 月 周),支持 `?` / `L` / `W` / `#` 等 Quartz 风格语法。采用递归一次性调度模式按下次触发时间精确触发。如需分布式调度,请使用 `structure-infra-xxljob-starter` - **状态持久化**:任务状态存储在内存中(`ConcurrentHashMap`),JVM 重启后丢失 - **集群支持**:本模块为单机调度器,不支持分布式协调。如需分布式调度,请使用 `structure-infra-xxljob-starter` - **守护线程**:调度线程为守护线程,不会阻止 JVM 退出 diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java index d1a8dfc..2fd7b98 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/LocalThreadTaskScheduler.java @@ -1,14 +1,20 @@ package cn.structure.infra.schedule; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.support.CronExpression; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Delayed; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; /** * 基于 {@link ScheduledExecutorService} 的本地任务调度器默认实现。 @@ -24,8 +30,8 @@ *
  • 错误隔离:每个任务的执行都被 try-catch 包裹,单个任务的异常不会 * 影响其他任务或导致调度线程死亡
  • *
  • 幂等调度:{@link #schedule(ScheduleTask)} 在创建新任务前会先移除同 taskId 旧任务
  • - *
  • CRON 简化:本地不解析 CRON 表达式,而是采用 1 秒级粒度的固定频率轮询, - * 不支持秒级以下精度——这是出于实现简化的有意设计
  • + *
  • CRON 精确调度:基于 Spring {@link CronExpression} 解析 CRON 表达式, + * 通过递归一次性调度按下次触发时间精确触发,支持标准 6 字段 cron 语义(含 {@code ?} 字符)
  • * * *

    协作关系:依赖 {@link TaskHandlerRegistry} 完成 handler 查找; @@ -126,7 +132,7 @@ public void schedule(ScheduleTask task) { break; case CRON: - // CRON 简化实现:不解析 CRON 表达式,统一以 1 秒粒度轮询触发(不支持秒级以下精度) + // 基于 Spring CronExpression 解析 cron 表达式,按下次触发时间递归调度 if (task.getCronExpression() == null || task.getCronExpression().isEmpty()) { throw new IllegalArgumentException("Cron expression cannot be null for CRON schedule type"); } @@ -194,31 +200,184 @@ private void validateTask(ScheduleTask task) { } /** - * CRON 任务的简化调度实现。 + * CRON 任务的精确调度实现。 * - *

    CRON 简化说明:本地实现并不解析 CRON 表达式,而是固定以 1 秒粒度 - * 轮询触发任务。这意味着:

    - *
      - *
    • CRON 表达式最小触发单位为秒,不支持秒级以下精度
    • - *
    • 实际触发频率与 CRON 表达式可能不完全一致,仅作为"周期触发"使用
    • - *
    • 需要严格遵循 CRON 语义的场景请使用 XXL-Job 等专业调度器
    • - *
    + *

    实现原理:使用 Spring {@link CronExpression} 解析 cron 表达式, + * 采用"递归一次性调度"模式——每次执行完成后,根据 cron 表达式计算下次触发时间, + * 通过 {@link ScheduledExecutorService#schedule(Runnable, long, TimeUnit)} 安排下一次执行, + * 如此循环直至任务被取消。

    * - *

    同样使用 try-catch 包裹,避免任务异常导致调度线程死亡。

    + *

    相比固定频率轮询,该方式能严格遵循 cron 语义(如 {@code 0 0 12 * * ?} 每天 12 点触发), + * 不会出现"每秒都触发"的问题。支持标准 6 字段 cron 表达式(秒 分 时 日 月 周), + * day-of-month / day-of-week 字段可使用 {@code ?} 表示不指定。

    + * + *

    错误隔离由外层 {@link #wrapRunnable(ScheduleTask)} 保证,本方法仅负责调度编排。 + * 即便 handler 抛出异常,finally 块仍会安排下次执行,确保周期性调度不中断。

    * * @param task 任务描述对象 * @param wrappedRunnable 已包装错误隔离的 Runnable - * @return 调度 future + * @return 可取消的调度 future,cancel 时会终止整个调度链 + * @throws IllegalArgumentException 当 cron 表达式非法或不存在下次触发时间时抛出 */ private ScheduledFuture scheduleCronTask(ScheduleTask task, Runnable wrappedRunnable) { - return executorService.scheduleAtFixedRate(() -> { - try { - wrappedRunnable.run(); - } catch (Exception e) { - // 错误隔离:捕获任务异常仅记录日志,避免调度线程被杀死导致后续任务无法触发 - log.error("Cron task execution failed: id={}, error={}", task.getTaskId(), e.getMessage(), e); + CronExpression cronExpression; + try { + cronExpression = CronExpression.parse(task.getCronExpression()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid cron expression: " + task.getCronExpression(), e); + } + + // 取消标志:cancel 后阻止调度链继续递归 + AtomicBoolean cancelled = new AtomicBoolean(false); + // 持有当前一次性调度的 future,便于取消整个调度链中的最近一次待触发任务 + AtomicReference> currentFutureRef = new AtomicReference<>(); + + // 递归调度:每次执行完成后根据 cron 计算下次触发时间并安排一次性调度 + Runnable cronRunnable = new Runnable() { + @Override + public void run() { + try { + wrappedRunnable.run(); + } finally { + // handler 异常已被 wrapRunnable 隔离,这里仍确保调度下一次 + if (!cancelled.get()) { + scheduleNext(); + } + } } - }, 0, 1000, TimeUnit.MILLISECONDS); // 1 秒级粒度轮询 + + private void scheduleNext() { + if (cancelled.get()) { + return; + } + long delayMs = computeNextDelayMs(cronExpression); + if (delayMs < 0) { + // 没有下次触发时间,停止递归 + log.warn("Cron task has no next execution time, stopping: id={}, cron={}", + task.getTaskId(), task.getCronExpression()); + return; + } + ScheduledFuture nextFuture = executorService.schedule(this, delayMs, TimeUnit.MILLISECONDS); + currentFutureRef.set(nextFuture); + // 二次检查:防止在 schedule 与 cancel 并发时漏取消 + if (cancelled.get()) { + nextFuture.cancel(false); + } + } + }; + + // 计算首次触发时间 + long initialDelayMs = computeNextDelayMs(cronExpression); + if (initialDelayMs < 0) { + throw new IllegalArgumentException( + "Cron expression has no valid next execution time: " + task.getCronExpression()); + } + + ScheduledFuture initialFuture = executorService.schedule(cronRunnable, initialDelayMs, TimeUnit.MILLISECONDS); + currentFutureRef.set(initialFuture); + + log.info("Scheduled cron task: id={}, cron={}, firstFireIn={}ms", + task.getTaskId(), task.getCronExpression(), initialDelayMs); + + return new CronScheduledFuture(cancelled, currentFutureRef); + } + + /** + * 根据 cron 表达式计算从当前时刻到下次触发时刻的延迟(毫秒)。 + * + *

    边界处理(防止重复触发):

    + *
      + *
    • 确保返回的 next 严格晚于 now——若 {@link CronExpression#next} 在秒边界处 + * 返回了不晚于 now 的时刻,则继续向后推进到下一个匹配点,避免零延迟重排
    • + *
    • 亚毫秒级延迟向上取整为 1ms,避免 {@code Duration.toMillis()} 截断为 0 + * 导致任务立即重排、与上一次触发落在同一毫秒
    • + *
    + * + * @param cronExpression 已解析的 cron 表达式 + * @return 下次触发的延迟毫秒数(≥1);若无下次触发时间则返回 -1 + */ + private static long computeNextDelayMs(CronExpression cronExpression) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime next = cronExpression.next(now); + while (next != null && !next.isAfter(now)) { + next = cronExpression.next(next); + } + if (next == null) { + return -1L; + } + long delayMs = Duration.between(now, next).toMillis(); + if (delayMs <= 0) { + delayMs = 1; + } + return delayMs; + } + + /** + * CRON 调度链的 {@link ScheduledFuture} 包装器。 + * + *

    由于 CRON 任务采用递归一次性调度,底层 future 会随每次触发而更换。 + * 本包装器持有取消标志与当前 future 的原子引用,cancel 时设置标志并取消 + * 当前待触发的 future,从而终止整个调度链。

    + */ + private static final class CronScheduledFuture implements ScheduledFuture { + + private final AtomicBoolean cancelled; + private final AtomicReference> currentFutureRef; + + CronScheduledFuture(AtomicBoolean cancelled, AtomicReference> currentFutureRef) { + this.cancelled = cancelled; + this.currentFutureRef = currentFutureRef; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + cancelled.set(true); + ScheduledFuture current = currentFutureRef.get(); + if (current != null) { + return current.cancel(mayInterruptIfRunning); + } + return true; + } + + @Override + public boolean isCancelled() { + return cancelled.get(); + } + + @Override + public boolean isDone() { + return cancelled.get(); + } + + @Override + public Void get() { + return null; + } + + @Override + public Void get(long timeout, TimeUnit unit) { + return null; + } + + @Override + public long getDelay(TimeUnit unit) { + ScheduledFuture current = currentFutureRef.get(); + return current != null ? current.getDelay(unit) : 0; + } + + @Override + public int compareTo(Delayed o) { + if (o instanceof CronScheduledFuture) { + CronScheduledFuture other = (CronScheduledFuture) o; + ScheduledFuture current = currentFutureRef.get(); + ScheduledFuture otherCurrent = other.currentFutureRef.get(); + if (current != null && otherCurrent != null) { + return current.compareTo(otherCurrent); + } + } + ScheduledFuture current = currentFutureRef.get(); + return current != null ? current.compareTo(o) : -1; + } } /** diff --git a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java index 0c3f35e..33762d2 100644 --- a/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java +++ b/structure-infra-schedule-starter/src/main/java/cn/structure/infra/schedule/ScheduleTask.java @@ -17,9 +17,11 @@ *

    设计意图:使用不可变数据模型 + Builder 模式,统一封装不同调度语义 * (CRON、固定频率、固定延迟)所需参数,调用方按需填充对应字段即可。

    * - *

    CRON 限制说明:本地 {@link LocalThreadTaskScheduler} 实现 CRON 调度时 - * 采用 1 秒级粒度的轮询策略(不支持秒级以下精度),即 CRON 表达式最小触发单位为秒。 - * 若需要更精细的调度请改用 {@link ScheduleType#FIXED_RATE} 或 {@link ScheduleType#FIXED_DELAY}。

    + *

    CRON 限制说明:本地 {@link LocalThreadTaskScheduler} 通过 Spring + * {@code CronExpression} 解析 CRON 表达式,按下次触发时间精确调度。支持标准 6 字段 + * cron 语义(秒 分 时 日 月 周),最小触发粒度为秒。day-of-month / day-of-week + * 字段可使用 {@code ?} 表示不指定。若需要更精细的调度请改用 + * {@link ScheduleType#FIXED_RATE} 或 {@link ScheduleType#FIXED_DELAY}。

    * *

    字段使用约定:

    *
      @@ -66,7 +68,8 @@ public class ScheduleTask { /** * CRON 表达式,仅在 {@link #scheduleType} = {@link ScheduleType#CRON} 时使用。 * - *

      本地实现为秒级粒度轮询,不支持秒级以下精度。

      + *

      采用标准 6 字段格式(秒 分 时 日 月 周),由 Spring {@code CronExpression} 解析。 + * 例如 {@code 0/5 * * * * ?} 表示每 5 秒,{@code 0 0 12 * * ?} 表示每天 12 点。

      */ private String cronExpression; @@ -100,7 +103,7 @@ public class ScheduleTask { * 调度类型枚举。 * *
        - *
      • {@link #CRON}:基于 CRON 表达式(本地实现为秒级粒度轮询)
      • + *
      • {@link #CRON}:基于 CRON 表达式(由 Spring CronExpression 解析,按下次触发时间精确调度)
      • *
      • {@link #FIXED_DELAY}:固定延迟(上次执行结束后等待 delay 再触发下次)
      • *
      • {@link #FIXED_RATE}:固定频率(按固定间隔触发,与上次执行耗时无关)
      • *
      diff --git a/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/LocalThreadTaskSchedulerTest.java b/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/LocalThreadTaskSchedulerTest.java index 4775888..671f940 100644 --- a/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/LocalThreadTaskSchedulerTest.java +++ b/structure-infra-schedule-starter/src/test/java/cn/structure/infra/schedule/LocalThreadTaskSchedulerTest.java @@ -4,6 +4,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; @@ -208,6 +211,90 @@ void testUpdateTask() throws InterruptedException { registry.unregister(handlerName); } + @Test + void testScheduleCronTaskRespectsCronExpression() throws InterruptedException { + // 使用 0/2 * * * * ?(每 2 秒触发)验证 cron 表达式被真正解析, + // 而非旧实现那样固定每秒触发 + List timestamps = Collections.synchronizedList(new ArrayList<>()); + String handlerName = "cron-handler"; + registry.register(handlerName, param -> timestamps.add(System.currentTimeMillis())); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-cron") + .taskName("Cron Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.CRON) + .cronExpression("0/2 * * * * ?") + .build(); + + scheduler.schedule(task); + + // 等待 5 秒:cron 每 2 秒触发 → 预期 2~3 次; + // 若为旧实现(每秒触发)则会出现 5 次,从而被上界断言拦截 + Thread.sleep(5000); + + int count = timestamps.size(); + assertTrue(count >= 2, "Cron task should execute at least 2 times, got: " + count); + assertTrue(count <= 4, "Cron task should respect 2-second interval (not every second), got: " + count); + + // 校验前两次执行间隔接近 2 秒而非 1 秒 + if (timestamps.size() >= 2) { + long interval = timestamps.get(1) - timestamps.get(0); + assertTrue(interval >= 1500, "Interval between cron fires should be ~2000ms, got: " + interval); + } + + scheduler.remove("test-cron"); + registry.unregister(handlerName); + } + + @Test + void testScheduleCronTaskWithInvalidExpression() { + String handlerName = "invalid-cron-handler"; + registry.register(handlerName, param -> {}); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-invalid-cron") + .taskName("Invalid Cron Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.CRON) + .cronExpression("invalid cron expression") + .build(); + + assertThrows(IllegalArgumentException.class, () -> scheduler.schedule(task)); + registry.unregister(handlerName); + } + + @Test + void testCronTaskPauseStopsExecution() throws InterruptedException { + // 验证 CronScheduledFuture 的 cancel/pause 能终止整个递归调度链 + AtomicInteger counter = new AtomicInteger(0); + String handlerName = "cron-pause-handler"; + registry.register(handlerName, param -> counter.incrementAndGet()); + + ScheduleTask task = ScheduleTask.builder() + .taskId("test-cron-pause") + .taskName("Cron Pause Task") + .handlerName(handlerName) + .scheduleType(ScheduleTask.ScheduleType.CRON) + .cronExpression("0/1 * * * * ?") + .build(); + + scheduler.schedule(task); + + Thread.sleep(2500); + int countBeforePause = counter.get(); + assertTrue(countBeforePause >= 1, "Cron task should execute at least once before pause"); + + scheduler.pause("test-cron-pause"); + assertEquals(ScheduleTask.TaskStatus.PAUSED, scheduler.getTaskInfo("test-cron-pause").getStatus()); + + Thread.sleep(2500); + assertEquals(countBeforePause, counter.get(), "Cron task should not execute after pause"); + + scheduler.remove("test-cron-pause"); + registry.unregister(handlerName); + } + @Test void testScheduleWithNullTask() { assertThrows(IllegalArgumentException.class, () -> scheduler.schedule(null));