From 679874a600727f417d726c0a273a1950d54464f9 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Tue, 10 Nov 2026 19:33:14 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Groovy=20=E8=84=9A=E6=9C=AC=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=A2=9E=E5=8A=A0=E8=B7=A8=E5=AE=9E=E4=BE=8B=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=8F=AF=E6=8F=92=E6=8B=94?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E7=AD=96=E7=95=A5=20(8.x/springboot-starter-?= =?UTF-8?q?script)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 17.x 的 issue #214 修复,按 JDK 8 做代际适配: - 新增 GroovyScriptCacheEvent(ISyncEvent):脚本保存/删除后经 EventPusher 进程内分发,业务可通过 IHandler 监听脚本变更 - 新增 GroovyScriptCacheSync SPI(默认 LocalGroovyScriptCacheSync):多实例部署时可接入 Redis pub/sub 等实现跨实例广播,远端实例 receive 后失效本地缓存并回源仓储 - 新增 GroovyScriptCache 缓存容器接口与 DefaultGroovyScriptCache:LRU 容器抽象为可替换策略,支持 Redis 等共享缓存实现 - GroovyScript.save()/remove() 写路径发布缓存变更事件;GroovyScriptCacheContext.remove 改为纯本地失效 - 能力文档补充多实例部署章节与 Redis 实现方案示例 Ref #214 --- .../groovy-runtime.md | 179 ++++++++++++- .../springboot/script/GroovyScript.java | 15 ++ .../cache/DefaultGroovyScriptCache.java | 55 ++++ .../script/cache/GroovyScriptCache.java | 54 ++++ .../cache/GroovyScriptCacheContext.java | 53 ++-- .../script/cache/GroovyScriptCacheEvent.java | 88 +++++++ .../script/cache/GroovyScriptCacheSync.java | 19 ++ .../cache/GroovyScriptCacheSyncContext.java | 63 +++++ .../cache/LocalGroovyScriptCacheSync.java | 12 + .../GroovyScriptCacheEventBridgeTest.java | 89 +++++++ .../GroovyScriptCacheSyncContextTest.java | 240 ++++++++++++++++++ 11 files changed, 836 insertions(+), 31 deletions(-) create mode 100644 springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/DefaultGroovyScriptCache.java create mode 100644 springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCache.java create mode 100644 springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheEvent.java create mode 100644 springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSync.java create mode 100644 springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSyncContext.java create mode 100644 springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/LocalGroovyScriptCacheSync.java create mode 100644 springboot-starter-script/src/test/java/com/codingapi/springboot/script/cache/GroovyScriptCacheEventBridgeTest.java create mode 100644 springboot-starter-script/src/test/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSyncContextTest.java diff --git a/docs/capabilities/springboot-starter-script/groovy-runtime.md b/docs/capabilities/springboot-starter-script/groovy-runtime.md index 1331673c..64f66095 100644 --- a/docs/capabilities/springboot-starter-script/groovy-runtime.md +++ b/docs/capabilities/springboot-starter-script/groovy-runtime.md @@ -10,6 +10,11 @@ symbols: - GroovyScriptRuntime - GroovyScript - GroovyScriptCacheContext + - GroovyScriptCache + - GroovyScriptCacheSync + - GroovyScriptCacheSyncContext + - GroovyScriptCacheEvent + - LocalGroovyScriptCacheSync - GroovyScriptRuntimeContext - GroovyScriptController content_hash: deddb103b5a58509ca7c08ace4195760e618f5b07782cfcb857fc1326d4ae526 @@ -40,7 +45,10 @@ content_hash: deddb103b5a58509ca7c08ace4195760e618f5b07782cfcb857fc1326d4ae526 | `GroovyScriptRuntime` | 底层执行引擎:GroovyShell 封装、LRU 编译缓存、invoke/run 方法、事务控制 | | `GroovyScriptRuntimeContext` | Runtime 的单例上下文,从配置读取 `shellMaxCacheSize` 初始化 | | `GroovyScript` | 脚本领域对象:封装 key/script/method/returnType/binds 等元信息,提供 compile/run/invoke 快捷方法 | -| `GroovyScriptCacheContext` | 脚本对象的 LRU 缓存(最大 10240 条),三级查找:缓存 → 临时存储 → Repository | +| `GroovyScriptCacheContext` | 脚本对象的 LRU 缓存(最大 10240 条),三级查找:缓存 → 临时存储 → Repository;缓存容器可通过 `setCache` 替换 | +| `GroovyScriptCache` / `DefaultGroovyScriptCache` | 缓存容器接口与默认进程内 LRU 实现,多实例部署时可替换为 Redis 等共享缓存 | +| `GroovyScriptCacheEvent` | 脚本缓存变更事件(SAVE / REMOVE),实现 `ISyncEvent`,可被进程内 `IHandler` 监听 | +| `GroovyScriptCacheSync` / `GroovyScriptCacheSyncContext` | 缓存变更跨实例同步 SPI 与上下文:写路径发布事件,远端实例接收后失效本地缓存 | | `GroovyScriptController` | REST 端点 `/api/groovy-script/*`,提供 compile/getScript/getMetadata/save 接口 | ### 直接执行脚本 @@ -107,6 +115,173 @@ script.save(); | GET | `/api/groovy-script/getMetadata?key=` | 获取脚本元数据 | | POST | `/api/groovy-script/save` | 保存脚本(编译+持久化) | +## 多实例部署(重要) + +脚本对象缓存(`GroovyScriptCacheContext`)与临时脚本缓存都是**进程内**的。多实例部署(水平扩容、双端部署、多副本灰度)时,实例 A 修改脚本后,实例 B 的缓存不会自动感知——如果不做任何配置,B 将永久返回旧脚本(见 issue #214)。 + +### 部署前提 + +多实例部署**必须同时满足**以下两点,缺一不可: + +1. **共享仓储**:通过 `GroovyScriptRepositoryContext.getInstance().setRepository(...)` 注入基于数据库(或 Redis)的仓储实现。模块默认的 `DefaultGroovyScriptRepository` 是进程内 HashMap,不提供跨实例数据共享; +2. **跨实例失效同步**:实现 `GroovyScriptCacheSync` SPI 广播缓存变更事件,远端实例收到事件后自动失效本地缓存,下次读取回源仓储加载新版本。 + +单实例部署无需任何配置,默认 `LocalGroovyScriptCacheSync` 保持原有行为。 + +### Redis pub/sub 失效同步实现示例(推荐方案) + +数据正文保存在共享数据库中,Redis 仅承担"失效通知"的广播通道,失效后各实例自动回源数据库加载新版本。该方案实现简单、无需处理脚本内容的序列化兼容问题。 + +依赖:`spring-boot-starter-data-redis`(版本由 Spring Boot BOM 管理)+ `com.alibaba.fastjson2:fastjson2`。 + +```java +/** + * 基于 Redis pub/sub 的脚本缓存跨实例同步实现 + * 广播通道:脚本保存/删除事件 -> 其他实例失效本地缓存并回源仓储 + */ +@Component +public class RedisGroovyScriptCacheSync implements GroovyScriptCacheSync { + + public static final String CHANNEL = "groovy-script-cache-sync"; + + private final StringRedisTemplate redisTemplate; + + public RedisGroovyScriptCacheSync(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + public void publish(GroovyScriptCacheEvent event) { + // 事件包含 source(来源实例标识),远端实例 receive 时会自动过滤自身事件避免回环 + redisTemplate.convertAndSend(CHANNEL, JSON.toJSONString(event)); + } + + /** + * 订阅回调:收到远端事件后失效本地缓存 + * 由下方 RedisMessageListenerContainer 配置调用 + */ + public void onMessage(String payload) { + GroovyScriptCacheEvent event = JSON.parseObject(payload, GroovyScriptCacheEvent.class); + // receive 内部:过滤本实例事件 -> 按事件类型 remove 本地缓存 key + GroovyScriptCacheSyncContext.getInstance().receive(event); + } +} +``` + +```java +/** + * 注册 Redis 频道订阅 + */ +@Configuration +public class GroovyScriptCacheSyncConfiguration { + + @Bean + public RedisMessageListenerContainer groovyScriptCacheSyncListenerContainer( + RedisConnectionFactory connectionFactory, + RedisGroovyScriptCacheSync sync) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.addMessageListener((message, pattern) -> { + String payload = new String(message.getBody(), StandardCharsets.UTF_8); + sync.onMessage(payload); + }, new ChannelTopic(RedisGroovyScriptCacheSync.CHANNEL)); + return container; + } +} +``` + +> 说明:若事件序列化使用 JSON,`GroovyScriptCacheEvent` 的字段(type/key/source/updateTime)均为可序列化类型;`GroovyScriptCacheEvent` 同时实现了框架的 `ISyncEvent`,进程内业务可通过 `IHandler` 监听脚本变更,与跨实例同步互不影响。 + +### Redis 共享缓存容器实现示例(可选) + +如果希望连脚本对象的读取也走 Redis(例如仓储回源成本高的场景),可以实现 `GroovyScriptCache` 接口替换默认 LRU 容器: + +```java +/** + * 基于 Redis 的脚本缓存容器 + * 注意:GroovyScript 含 Class 类型字段(returnType/binds/requests), + * 序列化时需记录类名、反序列化时按类名反射加载,此处仅示意骨架 + */ +public class RedisGroovyScriptCache implements GroovyScriptCache { + + private final StringRedisTemplate redisTemplate; + private final Duration ttl = Duration.ofMinutes(30); + + public RedisGroovyScriptCache(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + private String cacheKey(String key) { + return "groovy-script:cache:" + key; + } + + @Override + public void save(GroovyScript script) { + if (script != null) { + redisTemplate.opsForValue().set(cacheKey(script.getKey()), serialize(script), ttl); + } + } + + @Override + public GroovyScript get(String key) { + String payload = redisTemplate.opsForValue().get(cacheKey(key)); + return payload == null ? null : deserialize(payload); + } + + @Override + public void remove(String key) { + redisTemplate.delete(cacheKey(key)); + } + + @Override + public List keys() { + // 生产环境建议使用 SCAN 遍历,避免 KEYS 阻塞 + List keys = new ArrayList(); + Set cachedKeys = redisTemplate.keys("groovy-script:cache:*"); + if (cachedKeys != null) { + for (String k : cachedKeys) { + keys.add(k.substring("groovy-script:cache:".length())); + } + } + return keys; + } + + @Override + public int count() { + return keys().size(); + } + + @Override + public void clear() { + for (String key : keys()) { + redisTemplate.delete("groovy-script:cache:" + key); + } + } + + // serialize/deserialize 需处理 Class 字段(记录类名 -> 反射加载),此处从略 +} +``` + +```java +// 启用时替换默认缓存容器(通常在配置类中执行一次) +GroovyScriptCacheContext.getInstance().setCache(new RedisGroovyScriptCache(redisTemplate)); +``` + +**方案选择建议**: + +| 方案 | 适用场景 | 复杂度 | +|------|---------|--------| +| Redis pub/sub 失效通知(推荐) | 脚本正文在共享数据库中,改动频率低、读取频率高 | 低 | +| Redis 共享缓存容器 + pub/sub | 仓储回源成本高,或数据库无法承载读取压力 | 中(需处理 `Class` 字段序列化) | + +### 行为说明与注意事项 + +- 收到远端 `SAVE`/`REMOVE` 事件后,本实例仅失效对应 key 的缓存,**下次读取自动回源仓储**,无需主动拉取脚本内容; +- 已编译 Script 的运行时缓存(`GroovyScriptRuntime`)以脚本内容 SHA256 为 key,内容变化后自动使用新 key 编译,无需额外失效; +- `GroovyScriptCacheEvent.source` 为各实例的 UUID 标识(`GroovyScriptCacheSyncContext.getId()`),`receive` 会忽略本实例的事件,避免广播回环; +- 临时脚本(`temp()`)与默认临时仓储均为进程内设计,临时编辑结果对其他实例不可见,属于预期行为; +- 默认 LRU 容器为进程内实现,容量上限 10240 条;多实例下即使不替换为 Redis 缓存容器,只要接入了失效同步,各实例也会在收到事件后回源加载最新脚本。 + ## 使用实例 以下示例展示一个完整的动态折扣计算场景: @@ -160,4 +335,4 @@ cached.save(); // 下次 invoke 自动使用新版本 ``` -缓存机制说明:`GroovyScriptRuntime` 内部以脚本内容的 SHA256 作为缓存 key,相同内容的脚本不会重复编译。当脚本内容变更后,SHA256 值改变,自动触发重新编译。LRU 策略确保内存占用可控,默认上限可通过配置项调整。 +缓存机制说明:`GroovyScriptRuntime` 内部以脚本内容的 SHA256 作为缓存 key,相同内容的脚本不会重复编译。当脚本内容变更后,SHA256 值改变,自动触发重新编译。LRU 策略确保内存占用可控,默认上限可通过配置项调整。脚本对象缓存的跨实例失效机制见上文[多实例部署](#多实例部署重要)章节(issue #214)。 diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/GroovyScript.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/GroovyScript.java index 5aa19e97..50ef1c90 100644 --- a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/GroovyScript.java +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/GroovyScript.java @@ -1,5 +1,8 @@ package com.codingapi.springboot.script; +import com.codingapi.springboot.framework.event.EventPusher; +import com.codingapi.springboot.script.cache.GroovyScriptCacheEvent; +import com.codingapi.springboot.script.cache.GroovyScriptCacheSyncContext; import com.codingapi.springboot.script.temp.TempGroovyScriptContext; import com.codingapi.springboot.script.meta.GroovyMetadata; import com.codingapi.springboot.script.repository.GroovyScriptRepositoryContext; @@ -161,21 +164,33 @@ public void temp() { /** * 保存对象 + * 保存后发布缓存变更事件,其他实例收到事件后失效本地缓存 */ public void save() { this.updateTime = System.currentTimeMillis(); GroovyScriptRepositoryContext.getInstance().save(this); // 清理临时数据 TempGroovyScriptContext.getInstance().remove(this.getKey()); + // 发布脚本缓存变更事件:跨进程广播由 SPI 实现,进程内监听由框架事件分发 + GroovyScriptCacheEvent event = GroovyScriptCacheEvent.save(this.getKey(), + GroovyScriptCacheSyncContext.getInstance().getId(), this.updateTime); + GroovyScriptCacheSyncContext.getInstance().publish(event); + EventPusher.push(event); } /** * 删除对象 + * 删除后发布缓存变更事件,其他实例收到事件后失效本地缓存 */ public void remove() { GroovyScriptRepositoryContext.getInstance().delete(this.getKey()); // 清理临时数据 TempGroovyScriptContext.getInstance().remove(this.getKey()); + // 发布脚本缓存变更事件:跨进程广播由 SPI 实现,进程内监听由框架事件分发 + GroovyScriptCacheEvent event = GroovyScriptCacheEvent.remove(this.getKey(), + GroovyScriptCacheSyncContext.getInstance().getId()); + GroovyScriptCacheSyncContext.getInstance().publish(event); + EventPusher.push(event); } /** diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/DefaultGroovyScriptCache.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/DefaultGroovyScriptCache.java new file mode 100644 index 00000000..2987a78f --- /dev/null +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/DefaultGroovyScriptCache.java @@ -0,0 +1,55 @@ +package com.codingapi.springboot.script.cache; + +import com.codingapi.springboot.script.GroovyScript; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 默认脚本缓存容器实现:进程内 LRU 缓存 + */ +public class DefaultGroovyScriptCache implements GroovyScriptCache { + + private final static int MAX_CACHE_SIZE = 10 * 1024; + + private final Map cache = new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_SIZE; + } + }; + + @Override + public void save(GroovyScript script) { + if (script != null) { + this.cache.put(script.getKey(), script); + } + } + + @Override + public GroovyScript get(String key) { + return this.cache.get(key); + } + + @Override + public void remove(String key) { + this.cache.remove(key); + } + + @Override + public List keys() { + return new ArrayList(this.cache.keySet()); + } + + @Override + public int count() { + return this.cache.size(); + } + + @Override + public void clear() { + this.cache.clear(); + } +} diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCache.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCache.java new file mode 100644 index 00000000..78034264 --- /dev/null +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCache.java @@ -0,0 +1,54 @@ +package com.codingapi.springboot.script.cache; + +import com.codingapi.springboot.script.GroovyScript; + +import java.util.List; + +/** + * 脚本缓存容器接口 + * 默认实现为进程内 LRU 缓存({@link DefaultGroovyScriptCache}), + * 多实例部署时可替换为共享存储实现(如 Redis 缓存),解决跨进程数据一致性问题 + */ +public interface GroovyScriptCache { + + /** + * 保存脚本对象 + * + * @param script 脚本对象 + */ + void save(GroovyScript script); + + /** + * 获取脚本对象 + * + * @param key 脚本key + * @return 脚本对象,不存在时返回 null + */ + GroovyScript get(String key); + + /** + * 移除脚本对象 + * + * @param key 脚本key + */ + void remove(String key); + + /** + * 获取脚本keys + * + * @return 脚本key列表 + */ + List keys(); + + /** + * 脚本总数量 + * + * @return 总数量 + */ + int count(); + + /** + * 清空缓存 + */ + void clear(); +} diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheContext.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheContext.java index 3a700e0e..d0682859 100644 --- a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheContext.java +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheContext.java @@ -6,30 +6,29 @@ import com.codingapi.springboot.script.temp.TempGroovyScriptContext; import lombok.Getter; -import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; /** * 脚本数据缓存上下文管理对象 + * 缓存容器支持替换(如 Redis 共享缓存实现),默认为进程内 LRU 缓存 */ public class GroovyScriptCacheContext { @Getter private final static GroovyScriptCacheContext instance = new GroovyScriptCacheContext(); - private final Map cache; - - private final static int MAX_CACHE_SIZE = 10 * 1024; + private GroovyScriptCache cache = new DefaultGroovyScriptCache(); private GroovyScriptCacheContext() { - this.cache = new LinkedHashMap(16, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_CACHE_SIZE; - } - }; + } + + /** + * 替换缓存容器实现,入参为 null 时回退为默认 LRU 实现 + * + * @param cache 缓存容器实现 + */ + public void setCache(GroovyScriptCache cache) { + this.cache = (cache != null) ? cache : new DefaultGroovyScriptCache(); } /** @@ -38,9 +37,7 @@ protected boolean removeEldestEntry(Map.Entry eldest) { * @param script 脚本对象 */ public void save(GroovyScript script) { - if (script != null) { - this.cache.put(script.getKey(), script); - } + this.cache.save(script); } @@ -50,29 +47,24 @@ public void save(GroovyScript script) { * @param script 脚本对象 */ public void cache(GroovyScript script) { - if (script != null) { - this.cache.put(script.getKey(), script); - } + this.cache.save(script); } /** - * 删除脚本 + * 移除脚本缓存(仅作用于本地缓存容器) * * @param key 脚本key */ public void remove(String key) { - GroovyScript groovyScript = this.getGroovyScript(key); - if (groovyScript != null) { - this.cache.remove(key); - } + this.cache.remove(key); } /** * 获取脚本keys */ public List keys() { - return new ArrayList<>(this.cache.keySet()); + return this.cache.keys(); } /** @@ -81,7 +73,7 @@ public List keys() { * @return 总数量 */ public int count() { - return this.cache.size(); + return this.cache.count(); } /** @@ -98,7 +90,7 @@ public GroovyScript getGroovyScript(String key) { if (groovyScript == null) { groovyScript = GroovyScriptRepositoryContext.getInstance().get(key); if (groovyScript != null) { - this.cache.put(key, groovyScript); + this.cache.save(groovyScript); } } } @@ -142,7 +134,7 @@ public void setBatchCache(List scriptList) { if (scriptList != null) { for (GroovyScript script : scriptList) { if (script != null) { - this.cache.put(script.getKey(), script); + this.cache.save(script); } } } @@ -154,8 +146,11 @@ public void setBatchCache(List scriptList) { * @param cache 是否缓存 */ public void compileAll(boolean cache) { - for (GroovyScript script : this.cache.values()) { - script.compile(cache); + for (String key : this.cache.keys()) { + GroovyScript groovyScript = this.cache.get(key); + if (groovyScript != null) { + groovyScript.compile(cache); + } } } diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheEvent.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheEvent.java new file mode 100644 index 00000000..00fceaa4 --- /dev/null +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheEvent.java @@ -0,0 +1,88 @@ +package com.codingapi.springboot.script.cache; + +import com.codingapi.springboot.framework.event.ISyncEvent; +import lombok.Getter; + +/** + * 脚本缓存变更事件(同步事件) + *

+ * 双通道设计: + * 1. 跨进程:脚本在某个实例上保存或删除后经 {@link GroovyScriptCacheSync} SPI 广播, + * 其他实例接收事件后失效本地缓存,下次读取自动回源仓储加载最新版本; + * 2. 进程内:写路径同时经 EventPusher 推送本事件(无 Spring 上下文时为安全 no-op), + * 业务代码可通过实现 IHandler<GroovyScriptCacheEvent> 监听脚本变更。 + */ +@Getter +public class GroovyScriptCacheEvent implements ISyncEvent { + + public enum Type { + /** + * 脚本保存 + */ + SAVE, + /** + * 脚本删除 + */ + REMOVE + } + + /** + * 事件类型 + */ + private final Type type; + + /** + * 脚本key + */ + private final String key; + + /** + * 事件来源实例标识(用于过滤本实例事件,避免回环) + */ + private final String source; + + /** + * 脚本更新时间 + */ + private final long updateTime; + + private GroovyScriptCacheEvent(Type type, String key, String source, long updateTime) { + this.type = type; + this.key = key; + this.source = source; + this.updateTime = updateTime; + } + + /** + * 构建脚本保存事件 + * + * @param key 脚本key + * @param source 来源实例标识 + * @param updateTime 更新时间 + * @return 缓存变更事件 + */ + public static GroovyScriptCacheEvent save(String key, String source, long updateTime) { + return new GroovyScriptCacheEvent(Type.SAVE, key, source, updateTime); + } + + /** + * 构建脚本删除事件 + * + * @param key 脚本key + * @param source 来源实例标识 + * @return 缓存变更事件 + */ + public static GroovyScriptCacheEvent remove(String key, String source) { + return new GroovyScriptCacheEvent(Type.REMOVE, key, source, 0); + } + + /** + * 判断事件是否来自指定实例 + * + * @param source 实例标识 + * @return 是否来自指定实例 + */ + public boolean isFrom(String source) { + return this.source != null && this.source.equals(source); + } +} diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSync.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSync.java new file mode 100644 index 00000000..a987aa30 --- /dev/null +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSync.java @@ -0,0 +1,19 @@ +package com.codingapi.springboot.script.cache; + +/** + * 脚本缓存同步 SPI + *

+ * 单进程部署使用默认实现 {@link LocalGroovyScriptCacheSync}; + * 多实例部署时实现该接口将缓存变更事件广播到其他实例 + * (如 Redis pub/sub、MQ 等),并在收到远端事件后调用 + * {@link GroovyScriptCacheSyncContext#receive(GroovyScriptCacheEvent)} 失效本地缓存。 + */ +public interface GroovyScriptCacheSync { + + /** + * 发布脚本缓存变更事件 + * + * @param event 缓存变更事件 + */ + void publish(GroovyScriptCacheEvent event); +} diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSyncContext.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSyncContext.java new file mode 100644 index 00000000..4eae0e90 --- /dev/null +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSyncContext.java @@ -0,0 +1,63 @@ +package com.codingapi.springboot.script.cache; + +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +/** + * 脚本缓存同步上下文对象 + *

+ * 写路径通过 {@link #publish(GroovyScriptCacheEvent)} 发布脚本缓存变更事件; + * 多实例部署时远端事件由同步实现回调 {@link #receive(GroovyScriptCacheEvent)} 失效本地缓存, + * 避免其他实例永久持有旧脚本。 + */ +public class GroovyScriptCacheSyncContext { + + @Getter + private final static GroovyScriptCacheSyncContext instance = new GroovyScriptCacheSyncContext(); + + /** + * 当前实例标识,用于过滤本实例发布的事件回环 + */ + @Getter + @Setter + private String id = UUID.randomUUID().toString(); + + @Setter + private GroovyScriptCacheSync sync = new LocalGroovyScriptCacheSync(); + + /** + * 发布脚本缓存变更事件(写路径调用,由同步实现负责广播) + * + * @param event 缓存变更事件 + */ + public void publish(GroovyScriptCacheEvent event) { + this.sync.publish(event); + } + + /** + * 接收远端实例广播的脚本缓存变更事件并失效本地缓存, + * 本实例自身发布的事件将被忽略以避免回环 + * + * @param event 缓存变更事件 + */ + public void receive(GroovyScriptCacheEvent event) { + if (event == null || event.isFrom(this.id)) { + return; + } + this.apply(event); + } + + /** + * 本地失效脚本缓存(收到事件后下次读取回源仓储) + * + * @param event 缓存变更事件 + */ + public void apply(GroovyScriptCacheEvent event) { + if (event == null) { + return; + } + GroovyScriptCacheContext.getInstance().remove(event.getKey()); + } +} diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/LocalGroovyScriptCacheSync.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/LocalGroovyScriptCacheSync.java new file mode 100644 index 00000000..41226018 --- /dev/null +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/LocalGroovyScriptCacheSync.java @@ -0,0 +1,12 @@ +package com.codingapi.springboot.script.cache; + +/** + * 默认脚本缓存同步实现:单进程场景,事件直接在本进程内生效 + */ +public class LocalGroovyScriptCacheSync implements GroovyScriptCacheSync { + + @Override + public void publish(GroovyScriptCacheEvent event) { + GroovyScriptCacheSyncContext.getInstance().apply(event); + } +} diff --git a/springboot-starter-script/src/test/java/com/codingapi/springboot/script/cache/GroovyScriptCacheEventBridgeTest.java b/springboot-starter-script/src/test/java/com/codingapi/springboot/script/cache/GroovyScriptCacheEventBridgeTest.java new file mode 100644 index 00000000..dccb143c --- /dev/null +++ b/springboot-starter-script/src/test/java/com/codingapi/springboot/script/cache/GroovyScriptCacheEventBridgeTest.java @@ -0,0 +1,89 @@ +package com.codingapi.springboot.script.cache; + +import com.codingapi.springboot.framework.event.IHandler; +import com.codingapi.springboot.script.GroovyScript; +import com.codingapi.springboot.script.TestApplication; +import com.codingapi.springboot.script.repository.GroovyScriptRepositoryContext; +import com.codingapi.springboot.script.temp.TempGroovyScriptContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * GroovyScriptCacheEvent 框架事件桥接测试(issue #214) + * 验证脚本保存/删除后,进程内业务可通过 IHandler<GroovyScriptCacheEvent> 监听脚本变更事件 + */ +@SpringBootTest(classes = TestApplication.class) +class GroovyScriptCacheEventBridgeTest { + + private static final List receivedEvents = new CopyOnWriteArrayList(); + + @Component + static class ScriptCacheChangedHandler implements IHandler { + + @Override + public void handler(GroovyScriptCacheEvent event) { + receivedEvents.add(event); + } + } + + private GroovyScript script(String key, String content) { + return GroovyScript.builder(key) + .script(content) + .method("run") + .returnType(Integer.class) + .build(); + } + + @BeforeEach + void setUp() { + receivedEvents.clear(); + GroovyScriptCacheContext.getInstance().clear(); + TempGroovyScriptContext.getInstance().clear(); + } + + @AfterEach + void tearDown() { + GroovyScriptCacheContext.getInstance().clear(); + TempGroovyScriptContext.getInstance().clear(); + GroovyScriptRepositoryContext.getInstance().delete("bridge-key"); + } + + @Test + void saveShouldPushFrameworkEventToListeners() { + GroovyScript s = script("bridge-key", "return 1;"); + GroovyScriptRepositoryContext.getInstance().save(s); + GroovyScriptCacheContext.getInstance().getGroovyScript("bridge-key"); + + s.setScript("return 2;"); + s.save(); + + assertEquals(1, receivedEvents.size()); + GroovyScriptCacheEvent event = receivedEvents.get(0); + assertEquals(GroovyScriptCacheEvent.Type.SAVE, event.getType()); + assertEquals("bridge-key", event.getKey()); + } + + @Test + void removeShouldPushFrameworkEventToListeners() { + GroovyScript s = script("bridge-key", "return 1;"); + GroovyScriptRepositoryContext.getInstance().save(s); + GroovyScriptCacheContext.getInstance().getGroovyScript("bridge-key"); + + s.remove(); + + assertEquals(1, receivedEvents.size()); + GroovyScriptCacheEvent event = receivedEvents.get(0); + assertEquals(GroovyScriptCacheEvent.Type.REMOVE, event.getType()); + assertEquals("bridge-key", event.getKey()); + assertTrue(GroovyScriptRepositoryContext.getInstance().get("bridge-key") == null); + } +} diff --git a/springboot-starter-script/src/test/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSyncContextTest.java b/springboot-starter-script/src/test/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSyncContextTest.java new file mode 100644 index 00000000..6afd341b --- /dev/null +++ b/springboot-starter-script/src/test/java/com/codingapi/springboot/script/cache/GroovyScriptCacheSyncContextTest.java @@ -0,0 +1,240 @@ +package com.codingapi.springboot.script.cache; + +import com.codingapi.springboot.script.GroovyScript; +import com.codingapi.springboot.script.repository.GroovyScriptRepositoryContext; +import com.codingapi.springboot.script.temp.TempGroovyScriptContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * GroovyScriptCacheSyncContext 单元测试 + * 覆盖:脚本保存/删除后的事件发布、远端事件触发本地缓存失效、 + * 本实例事件回环过滤、缓存策略可插拔替换(issue #214) + */ +class GroovyScriptCacheSyncContextTest { + + private static final String LOCAL_ID = "local-instance"; + private static final String REMOTE_ID = "remote-instance"; + + private final GroovyScriptCacheContext cacheContext = GroovyScriptCacheContext.getInstance(); + private final GroovyScriptCacheSyncContext syncContext = GroovyScriptCacheSyncContext.getInstance(); + + private RecordingSync recordingSync; + + /** + * 记录型同步实现,模拟跨实例广播通道 + */ + static class RecordingSync implements GroovyScriptCacheSync { + final List events = new ArrayList(); + + @Override + public void publish(GroovyScriptCacheEvent event) { + this.events.add(event); + } + } + + /** + * 基于 HashMap 的简单缓存策略,验证缓存容器可替换 + */ + static class MapGroovyScriptCache implements GroovyScriptCache { + private final Map cache = new HashMap(); + + @Override + public void save(GroovyScript script) { + if (script != null) { + cache.put(script.getKey(), script); + } + } + + @Override + public GroovyScript get(String key) { + return cache.get(key); + } + + @Override + public void remove(String key) { + cache.remove(key); + } + + @Override + public List keys() { + return new ArrayList(cache.keySet()); + } + + @Override + public int count() { + return cache.size(); + } + + @Override + public void clear() { + cache.clear(); + } + } + + private GroovyScript script(String key, String content) { + return GroovyScript.builder(key) + .script(content) + .method("run") + .returnType(Integer.class) + .build(); + } + + @BeforeEach + void setUp() { + cacheContext.clear(); + TempGroovyScriptContext.getInstance().clear(); + recordingSync = new RecordingSync(); + syncContext.setSync(recordingSync); + syncContext.setId(LOCAL_ID); + cacheContext.setCache(new DefaultGroovyScriptCache()); + } + + @AfterEach + void tearDown() { + // 恢复默认实现,避免影响其他测试 + syncContext.setSync(new LocalGroovyScriptCacheSync()); + cacheContext.setCache(new DefaultGroovyScriptCache()); + cacheContext.clear(); + TempGroovyScriptContext.getInstance().clear(); + GroovyScriptRepositoryContext.getInstance().delete("sync-key"); + } + + @Test + void saveShouldPublishSaveEvent() { + GroovyScript s = script("sync-key", "return 1;"); + GroovyScriptRepositoryContext.getInstance().save(s); + cacheContext.getGroovyScript("sync-key"); + + s.setScript("return 2;"); + s.save(); + + assertEquals(1, recordingSync.events.size()); + GroovyScriptCacheEvent event = recordingSync.events.get(0); + assertEquals(GroovyScriptCacheEvent.Type.SAVE, event.getType()); + assertEquals("sync-key", event.getKey()); + assertEquals(LOCAL_ID, event.getSource()); + assertEquals(s.getUpdateTime(), event.getUpdateTime()); + } + + @Test + void removeShouldPublishRemoveEvent() { + GroovyScript s = script("sync-key", "return 1;"); + GroovyScriptRepositoryContext.getInstance().save(s); + cacheContext.getGroovyScript("sync-key"); + + s.remove(); + + assertEquals(1, recordingSync.events.size()); + GroovyScriptCacheEvent event = recordingSync.events.get(0); + assertEquals(GroovyScriptCacheEvent.Type.REMOVE, event.getType()); + assertEquals("sync-key", event.getKey()); + assertEquals(LOCAL_ID, event.getSource()); + } + + @Test + void defaultLocalSyncShouldEvictCacheOnSaveSoNextReadReloadsFromRepository() { + // 恢复默认单进程同步实现 + syncContext.setSync(new LocalGroovyScriptCacheSync()); + + GroovyScript s = script("sync-key", "return 1;"); + GroovyScriptRepositoryContext.getInstance().save(s); + GroovyScript cached = cacheContext.getGroovyScript("sync-key"); + assertSame(cached, cacheContext.getGroovyScript("sync-key")); + + s.setScript("return 2;"); + s.save(); + + // 本地缓存已失效,重新回源仓储加载新版本内容 + assertEquals(0, cacheContext.count()); + GroovyScript reloaded = cacheContext.getGroovyScript("sync-key"); + assertEquals("return 2;", reloaded.getScript()); + } + + @Test + void receiveShouldEvictLocalCacheAndReloadNewVersionFromRepository() { + // 模拟远端实例保存了新版本脚本,本实例收到广播事件 + GroovyScript s = script("sync-key", "return 1;"); + GroovyScriptRepositoryContext.getInstance().save(s); + GroovyScript cached = cacheContext.getGroovyScript("sync-key"); + assertSame(cached, cacheContext.getGroovyScript("sync-key")); + + GroovyScript newVersion = script("sync-key", "return 2;"); + GroovyScriptRepositoryContext.getInstance().save(newVersion); + syncContext.receive(GroovyScriptCacheEvent.save("sync-key", REMOTE_ID, System.currentTimeMillis())); + + assertEquals(0, cacheContext.count()); + GroovyScript reloaded = cacheContext.getGroovyScript("sync-key"); + assertNotSame(cached, reloaded); + assertEquals("return 2;", reloaded.getScript()); + } + + @Test + void receiveShouldIgnoreEventFromSelf() { + GroovyScript s = script("sync-key", "return 1;"); + cacheContext.save(s); + assertEquals(1, cacheContext.count()); + + syncContext.receive(GroovyScriptCacheEvent.save("sync-key", LOCAL_ID, System.currentTimeMillis())); + + assertEquals(1, cacheContext.count()); + assertSame(s, cacheContext.getGroovyScript("sync-key")); + } + + @Test + void receiveShouldEvictLocalCacheAndReturnNullAfterRemoteRemove() { + GroovyScript s = script("sync-key", "return 1;"); + GroovyScriptRepositoryContext.getInstance().save(s); + cacheContext.getGroovyScript("sync-key"); + assertEquals(1, cacheContext.count()); + + // 模拟远端实例删除脚本:远端先删仓储,再广播删除事件 + GroovyScriptRepositoryContext.getInstance().delete("sync-key"); + syncContext.receive(GroovyScriptCacheEvent.remove("sync-key", REMOTE_ID)); + + assertEquals(0, cacheContext.count()); + assertNull(cacheContext.getGroovyScript("sync-key")); + } + + @Test + void receiveShouldIgnoreNullEvent() { + syncContext.receive(null); + assertEquals(0, cacheContext.count()); + } + + @Test + void publishShouldDelegateToConfiguredSync() { + syncContext.publish(GroovyScriptCacheEvent.remove("sync-key", LOCAL_ID)); + + assertEquals(1, recordingSync.events.size()); + assertEquals(GroovyScriptCacheEvent.Type.REMOVE, recordingSync.events.get(0).getType()); + } + + @Test + void setCacheShouldReplaceCacheStrategy() { + MapGroovyScriptCache customCache = new MapGroovyScriptCache(); + cacheContext.setCache(customCache); + + GroovyScript s = script("sync-key", "return 1;"); + cacheContext.save(s); + + assertEquals(1, customCache.count()); + assertSame(s, customCache.get("sync-key")); + assertSame(s, cacheContext.getGroovyScript("sync-key")); + + // null 回退为默认 LRU 实现 + cacheContext.setCache(null); + assertEquals(0, cacheContext.count()); + } +}