|
| 1 | +# 会话存档SDK生命周期重构方案 |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +当前实现(4.8.x)通过"共享SDK + 引用计数 + 7200秒过期"来管理会话存档SDK生命周期。 |
| 6 | +该方案存在以下核心问题: |
| 7 | + |
| 8 | +1. **频繁初始化/销毁**:每次调用 `releaseSdk()` 后引用计数归零即销毁SDK。对于"拉取→解密→下载媒体"这类典型串行调用链,每步操作都会触发重新初始化。 |
| 9 | +2. **7200秒过期规则无依据**:官方文档FAQ明确说"不需要每次new/init sdk,可以在多次拉取中复用同一个sdk",无任何7200秒过期说明。 |
| 10 | +3. **线程安全问题**:企微技术人员建议"一个线程一个SDK实例",当前设计多线程共享同一SDK实例,存在并发安全隐患。 |
| 11 | + |
| 12 | +--- |
| 13 | + |
| 14 | +## 推荐方案:ThreadLocal SDK 模式 |
| 15 | + |
| 16 | +> **核心原则**:每个线程拥有独立SDK实例,懒初始化,生命周期与线程绑定。 |
| 17 | +
|
| 18 | +### 设计要点 |
| 19 | + |
| 20 | +- 使用 `ThreadLocal<Long>` 为每个线程持有独立SDK |
| 21 | +- SDK在线程首次调用时初始化,后续所有操作复用(无需重复初始化) |
| 22 | +- 移除7200秒过期机制 |
| 23 | +- 移除引用计数机制(每线程独占,无需计数) |
| 24 | +- 提供显式清理接口:`closeThreadLocalSdk()`(线程结束时调)、`closeAllSdks()`(应用关闭时调) |
| 25 | + |
| 26 | +### 生命周期示意 |
| 27 | + |
| 28 | +``` |
| 29 | +Thread A: init SDK_A → getChatRecords → getDecryptChatData → downloadMediaFile → [任务结束后调closeThreadLocalSdk] |
| 30 | +Thread B: init SDK_B → getChatRecords → getDecryptChatData → downloadMediaFile → ... |
| 31 | +Thread C: init SDK_C → ... |
| 32 | +``` |
| 33 | + |
| 34 | +--- |
| 35 | + |
| 36 | +## 涉及文件 |
| 37 | + |
| 38 | +| 文件 | 变更类型 | |
| 39 | +|------|--------| |
| 40 | +| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java` | 主要重构 | |
| 41 | +| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java` | 新增接口方法 | |
| 42 | +| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java` | 废弃旧SDK管理方法 | |
| 43 | +| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java` | 废弃旧字段/方法 | |
| 44 | +| `weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/WxCpMsgAuditTest.java` | 补充测试 | |
| 45 | +| `docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md` | 更新文档 | |
| 46 | + |
| 47 | +--- |
| 48 | + |
| 49 | +## 详细变更 |
| 50 | + |
| 51 | +### 1. WxCpMsgAuditServiceImpl(主要变更) |
| 52 | + |
| 53 | +**新增字段:** |
| 54 | +```java |
| 55 | +/** 每个线程持有独立SDK实例 */ |
| 56 | +private final ThreadLocal<Long> threadLocalSdk = new ThreadLocal<>(); |
| 57 | + |
| 58 | +/** 跟踪所有已创建SDK,用于统一清理 */ |
| 59 | +private final Set<Long> managedSdks = ConcurrentHashMap.newKeySet(); |
| 60 | +``` |
| 61 | + |
| 62 | +**废弃字段/方法:** |
| 63 | +- 废弃常量 `SDK_EXPIRES_TIME = 7200`(无官方依据) |
| 64 | +- 废弃 `initSdk()`(由 `getOrInitThreadLocalSdk()` 替代) |
| 65 | +- 废弃 `acquireSdk()` / `releaseSdk()`(由ThreadLocal模式替代) |
| 66 | + |
| 67 | +**新增核心方法:** |
| 68 | + |
| 69 | +```java |
| 70 | +/** |
| 71 | + * 获取当前线程的SDK,不存在则创建。SDK在线程内跨调用复用,无需每次重新初始化。 |
| 72 | + */ |
| 73 | +private long getOrInitThreadLocalSdk() throws WxErrorException { |
| 74 | + Long sdk = threadLocalSdk.get(); |
| 75 | + if (sdk != null && sdk > 0) { |
| 76 | + return sdk; |
| 77 | + } |
| 78 | + long newSdk = createSdk(); |
| 79 | + threadLocalSdk.set(newSdk); |
| 80 | + managedSdks.add(newSdk); |
| 81 | + log.info("线程 [{}] 初始化会话存档SDK成功,sdk={}", Thread.currentThread().getName(), newSdk); |
| 82 | + return newSdk; |
| 83 | +} |
| 84 | + |
| 85 | +/** |
| 86 | + * 创建并初始化一个新SDK(私有,只在当前线程无SDK时调用) |
| 87 | + */ |
| 88 | +private long createSdk() throws WxErrorException { |
| 89 | + WxCpConfigStorage configStorage = cpService.getWxCpConfigStorage(); |
| 90 | + // ... 与现有 initSdk() 内的库加载+Finance.NewSdk()+Finance.Init() 逻辑一致 ... |
| 91 | + // 注意:Finance.loadingLibraries() 是幂等的(System.load内部防重复),多线程调用安全 |
| 92 | +} |
| 93 | + |
| 94 | +/** |
| 95 | + * 关闭当前线程持有的SDK,释放本地资源。 |
| 96 | + * 在线程任务结束时调用(如定时任务finally块,或线程池线程销毁时)。 |
| 97 | + */ |
| 98 | +public void closeThreadLocalSdk() { |
| 99 | + Long sdk = threadLocalSdk.get(); |
| 100 | + if (sdk != null && sdk > 0) { |
| 101 | + Finance.DestroySdk(sdk); |
| 102 | + managedSdks.remove(sdk); |
| 103 | + threadLocalSdk.remove(); |
| 104 | + log.info("线程 [{}] 关闭会话存档SDK,sdk={}", Thread.currentThread().getName(), sdk); |
| 105 | + } |
| 106 | +} |
| 107 | + |
| 108 | +/** |
| 109 | + * 关闭所有线程持有的SDK。应用关闭时调用(如Spring @PreDestroy / Shutdown Hook)。 |
| 110 | + */ |
| 111 | +public void closeAllSdks() { |
| 112 | + managedSdks.forEach(sdk -> { |
| 113 | + Finance.DestroySdk(sdk); |
| 114 | + log.info("关闭会话存档SDK,sdk={}", sdk); |
| 115 | + }); |
| 116 | + managedSdks.clear(); |
| 117 | + threadLocalSdk.remove(); |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +**更新新API方法(getChatRecords / getDecryptChatData / getChatRecordPlainText / downloadMediaFile):** |
| 122 | +- 调用 `getOrInitThreadLocalSdk()` 替代 `acquireSdk()` |
| 123 | +- 移除 try-finally 中的 `releaseSdk(sdk)` 调用(SDK不再每次释放) |
| 124 | +- 方法变得更简洁:直接使用sdk,无需包装计数 |
| 125 | + |
| 126 | +**保留旧API方法不变(getChatDatas / getDecryptData / getChatPlainText / getMediaFile):** |
| 127 | +- 保持 @Deprecated 标注 |
| 128 | +- 内部调用改为 `getOrInitThreadLocalSdk()` 以保持一致性(旧方法也受益于ThreadLocal) |
| 129 | +- 移除对 `initSdk()` 的依赖 |
| 130 | + |
| 131 | +### 2. WxCpMsgAuditService(接口新增) |
| 132 | + |
| 133 | +```java |
| 134 | +/** |
| 135 | + * 关闭当前线程持有的SDK,释放native资源。 |
| 136 | + * Finance.DestroySdk() 不会随线程结束自动执行,无论线程池还是独立线程, |
| 137 | + * 均应在任务结束的finally块中调用本方法,防止native内存、连接等资源泄漏。 |
| 138 | + */ |
| 139 | +void closeThreadLocalSdk(); |
| 140 | + |
| 141 | +/** |
| 142 | + * 关闭所有会话存档SDK实例。 |
| 143 | + * 适用于应用关闭时(如Spring Bean销毁阶段)统一释放资源。 |
| 144 | + */ |
| 145 | +void closeAllSdks(); |
| 146 | +``` |
| 147 | + |
| 148 | +### 3. WxCpConfigStorage(废弃旧SDK管理API) |
| 149 | + |
| 150 | +对以下方法标记 `@Deprecated`(保留实现,不做破坏性删除): |
| 151 | +- `getMsgAuditSdk()` / `updateMsgAuditSdk()` / `expireMsgAuditSdk()` / `isMsgAuditSdkExpired()` |
| 152 | +- `acquireMsgAuditSdk()` / `releaseMsgAuditSdk()` |
| 153 | +- `incrementMsgAuditSdkRefCount()` / `decrementMsgAuditSdkRefCount()` / `getMsgAuditSdkRefCount()` |
| 154 | + |
| 155 | +### 4. WxCpDefaultConfigImpl(废弃旧字段) |
| 156 | + |
| 157 | +- 将 `msgAuditSdk`、`msgAuditSdkExpiresTime`、`msgAuditSdkRefCount` 字段标记 `@Deprecated` |
| 158 | +- 对应的 getter/setter/acquire/release 方法标记 `@Deprecated` |
| 159 | +- 保留实现,确保向后兼容 |
| 160 | + |
| 161 | +--- |
| 162 | + |
| 163 | +## 使用示例(更新文档) |
| 164 | + |
| 165 | +```java |
| 166 | +// ✅ 典型用法(一次任务中串行调用,SDK在同线程内复用,无重复初始化) |
| 167 | +WxCpMsgAuditService msgAuditService = wxCpService.getMsgAuditService(); |
| 168 | + |
| 169 | +try { |
| 170 | + List<WxCpChatDatas.WxCpChatData> records = msgAuditService.getChatRecords(seq, 100L, null, null, 30L); |
| 171 | + for (WxCpChatDatas.WxCpChatData record : records) { |
| 172 | + WxCpChatModel model = msgAuditService.getDecryptChatData(record, 2); |
| 173 | + if ("image".equals(model.getMsgType())) { |
| 174 | + msgAuditService.downloadMediaFile(model.getImage().getSdkFileId(), null, null, 30L, "/tmp/img.jpg"); |
| 175 | + } |
| 176 | + } |
| 177 | +} finally { |
| 178 | + // 无论线程池还是独立线程,均建议在 finally 中显式调用。 |
| 179 | + // Finance.DestroySdk() 不会随线程结束自动执行,依赖 closeAllSdks() 兜底会造成 |
| 180 | + // native 内存/连接资源的延迟泄漏,对定时任务等长期运行场景尤其有害。 |
| 181 | + msgAuditService.closeThreadLocalSdk(); |
| 182 | +} |
| 183 | + |
| 184 | +// 应用关闭时(Spring @PreDestroy 或 Shutdown Hook) |
| 185 | +// msgAuditService.closeAllSdks(); |
| 186 | +``` |
| 187 | + |
| 188 | +--- |
| 189 | + |
| 190 | +## 注意事项 |
| 191 | + |
| 192 | +1. **线程池场景下必须调用 `closeThreadLocalSdk()`**:线程池中线程会被复用,如不主动清理,下次任务仍会使用旧线程的SDK。对于计划任务/批处理,建议在 finally 块中调用。 |
| 193 | +2. **独立线程同样建议显式关闭**:`Finance.DestroySdk()` 是 native 调用,不会随线程结束自动执行,JVM GC 也不会触发它。依赖 `closeAllSdks()` 兜底意味着 native 内存、网络连接等资源在整个应用运行期间一直持有,对定时任务等高频场景会持续积累,建议统一在 finally 块中调用 `closeThreadLocalSdk()`。 |
| 194 | +3. **多企业(多CorpId)场景**:`threadLocalSdk` 是实例字段(非static),不同 `WxCpMsgAuditServiceImpl` 实例(不同企业)的ThreadLocal独立,互不影响。 |
| 195 | +4. **库加载幂等性**:`Finance.loadingLibraries()` 底层调用 `System.load()`,JVM保证同一库不重复加载,多线程并发调用安全。 |
| 196 | + |
| 197 | +--- |
| 198 | + |
| 199 | +## 验证方式 |
| 200 | + |
| 201 | +1. **单元测试**:在 `WxCpMsgAuditTest` 中添加测试,验证同线程多次调用不触发重新初始化(可通过日志或mock Finance验证) |
| 202 | +2. **多线程压测**:多线程并发调用 `getChatRecords` + `getDecryptChatData`,观察无JVM崩溃 |
| 203 | +3. **线程池复用测试**:使用固定线程池多次提交任务,验证 `closeThreadLocalSdk()` 后下次任务能正确重新初始化SDK |
| 204 | +4. **应用关闭测试**:调用 `closeAllSdks()`,验证所有线程的SDK被正确销毁 |
0 commit comments