Sa-Token 集成 MongoDB(二):Spring Data MongoDB 反序列化 SaSession 与 SaTokenDao 完整实现
【免费下载链接】Sa-Token✨ 开源、免费、一站式 Java 权限认证框架,让鉴权变得简单、优雅!—— 登录认证、权限认证、分布式 Session 会话、微服务网关鉴权、SSO 单点登录、OAuth2.0 统一认证、jwt 集成、API Key 秘钥授权、API 参数签名项目地址: https://gitcode.com/GitHub_Trending/sa/Sa-Token
导读
本文基于 Sa-Token 官方文档「集成 MongoDB 参考二」展开,深入讲解在 Spring Boot 环境下通过Spring Data MongoDB将 Sa-Token 的 Token、Session 等数据持久化到 MongoDB 的完整方案。与「参考一」直接存储SaSession对象的思路不同,本方案以 String / Object 两种基础类型存储为骨架,专门解决 Spring Data MongoDB 反序列化SaSession时因缺少dataMapsetter 而抛出的异常,并利用 MongoDB 原生TTL 索引实现数据的自动过期清理。读完本文,你将掌握:自定义 SaSession 子类规避反序列化陷阱、重写SaStrategy.instance.createSession让自定义 Session 生效、以及完整实现SaTokenDao接口接入 MongoDB 的工程化写法。
一、集成前的先决条件与依赖引入
本方案以Spring Boot + Spring Data MongoDB为运行前提,数据持久化基于MongoTemplate完成。首先需要引入官方 Starter 依赖:
:::tabs == Maven 方式
<!-- 提供MongoDB依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb</artifactId> </dependency>== Gradle 方式
// 提供MongoDB依赖 implementation 'org.springframework.boot:spring-boot-starter-data-mongodb':::
引入依赖后,Spring Boot 会自动装配MongoTemplate,后续的SaTokenDao实现将通过构造器注入使用它。
二、问题背景:SaSession 反序列化为什么会失败
Spring Data MongoDB 在读取文档并映射回 Java 对象时,使用的是基于属性的持久化(Property-based Persistence):目标类型必须提供对应的 setter、wither,或者该属性属于持久化构造器的参数,否则会抛出类似下面的异常:
Cannot set property dataMap because no setter, no wither and it's not part of the persistence constructor public cn.dev33.satoken.session.SaSession()查看SaSession的源码(sa-token-core/src/main/java/cn/dev33/satoken/session/SaSession.java):
private Map<String, Object> dataMap = new ConcurrentHashMap<>();dataMap字段在类中既没有对应名称的 setter(只有setDataMap(Map)和refreshDataMap(Map)两个方法,见该文件 L568-L580),也没有被包含进任何持久化构造器参数,因此当 MongoDB 需要将dataMap反序列化回对象时就会失败。
文档给出的解决思路是:创建一个SaSession的子类,在其中补充一个严格匹配dataMap属性名的 setter,从而满足 Spring Data MongoDB 的属性映射要求。
三、步骤一:创建自定义 MySaSession 子类
继承SaSession并新增一个与字段名严格对应的 setter:
public class MySaSession extends SaSession { public MySaSession(String id) { super(id); } public void setDataMap(Map<String, Object> dataMap) { refreshDataMap(dataMap); } }这里有一个容易踩坑的细节:refreshDataMap与setDataMap的行为并不相同。从源码看(SaSession.java):
setDataMap(Map):改变底层对象引用,将this.dataMap整体替换为新 Map;refreshDataMap(Map):不改变底层对象引用,先clear()再putAll(),原地替换所有数据。
自定义 setter 内部调用refreshDataMap而非直接赋值,可以保持内部ConcurrentHashMap实例不变,避免并发环境下引用被整体替换带来的读写一致性问题,这也是文档示例的推荐写法。
四、步骤二:重写 SaStrategy.instance.createSession
仅有自定义子类还不够,Sa-Token 框架默认创建 Session 时使用的是内置的SaSession。框架的创建策略定义在SaStrategy中(sa-token-core/src/main/java/cn/dev33/satoken/strategy/SaStrategy.java):
public SaCreateSessionFunction createSession = (sessionId) -> { return new SaSession(sessionId); };因此需要在 Spring Boot 启动入口处,将createSession策略重写为返回我们的MySaSession:
@SpringBootApplication public class SpringApplication { public static void main(String[] args) { // 重写 SaStrategy.instance.createSession 方法 SaStrategy.instance.createSession = (sessionId) -> { return new MySaSession(sessionId); }; SpringApplication.run(SpringApplication.class, args); } }这样框架在新建 Account-Session / Token-Session 时,都会经由该策略工厂产出MySaSession实例。需要说明的是:示例中主类名为SpringApplication是为了简洁演示,实际项目中请使用你自己的启动类类名。
五、步骤三:实现 SaTokenDao 接入 MongoDB
SaTokenDao是 Sa-Token 定义的数据持久层接口(sa-token-core/src/main/java/cn/dev33/satoken/dao/SaTokenDao.java),不同实现类可将数据存储至不同位置,如内存 Map、Redis 等;自定义存储策略(例如本文的 MongoDB)也需要通过实现此接口完成。
5.1 理解 SaTokenDao 的常量与三类读写能力
在动手实现前,先掌握接口中两个关键常量:
| 常量 | 值 | 语义 |
|---|---|---|
NEVER_EXPIRE | -1 | 永不过期 |
NOT_VALUE_EXPIRE | -2 | 系统中不存在该缓存(对不存在的 key 获取剩余存活时间时返回) |
接口同时定义了字符串读写(get/set/update/delete/getTimeout/updateTimeout)、对象读写(getObject/setObject/updateObject/deleteObject/getObjectTimeout/updateObjectTimeout)、Session 读写(getSession/setSession/updateSession/deleteSession/getSessionTimeout/updateSessionTimeout,默认复用对象读写方法)以及搜索(searchData)和生命周期(init/destroy)共四大类能力。
timeout参数统一语义为:值大于 0 时限时存储,值 = -1 时永久存储,值 = 0 或小于等于 -2 时不存储。
5.2 定义 MongoDB 文档模型 SaTokenWrap
首先定义一个@Document注解的实体类,用于在 MongoDB 中保存 Sa-Token 的数据:
// 定义一个类用于保存 SaSession @Document public class SaTokenWrap { private String id; private String value; private Object object; // 这里利用 MongoDB 的 TTL 索引,当过期时 MongoDB 会自动删除过期的数据,同时如果 timeout 如果为 null 那么视为永不删除 @Indexed(expireAfterSeconds = 1, background = true) private Date timeout; public boolean live() { return getTimeout() == null || getTimeout().after(new Date()); } }设计要点:
- 用
id存储 Sa-Token 的 key,value存储 String 类型数据(如 token 值),object存储对象类型数据(如 SaSession); - 核心是
timeout字段上的TTL 索引(@Indexed(expireAfterSeconds = 1)):MongoDB 的 TTL 后台线程会定期扫描并删除过期文档,从而天然实现数据自动清理,无需应用层定时任务; timeout为null时表示该记录永不删除,对应 Sa-Token 的「永久存储」语义;live()方法用于读取时判断记录是否仍存活:永不过期(null)或未到过期时间即为有效。注意expireAfterSeconds = 1表示 TTL 线程实际清理存在最长约 1 秒的延迟,因此读取时必须配合live()做逻辑过滤,避免读到「物理上尚未被 TTL 删除、但逻辑上已过期」的数据。
5.3 完整实现 SaTokenDaoMongo
// SaTokenDao 实现 @Component public class SaTokenDaoMongo implements SaTokenDao { private final MongoTemplate mongoTemplate; public SaTokenDaoMongo(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } Optional<SaTokenWrap> getByKey(String key) { SaTokenWrap tokenWrap = mongoTemplate.findById(key, SaTokenWrap.class); return Optional.ofNullable(tokenWrap).filter(SaTokenWrap::live); } Date timeoutToDate(long timeout) { return new Date(timeout * 1000 + System.currentTimeMillis()); } void upsertByPath(String key, String path, Object value, long timeout) { if (timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE) { return; } Update update = Update.update(path, value); if (timeout != SaTokenDao.NEVER_EXPIRE) { update.set("timeout", timeoutToDate(timeout)); } else { update.unset("timeout"); } mongoTemplate.upsert( Query.query(Criteria.where("id").is(key)), update, SaTokenWrap.class ); } void updateByPath(String key, String path, Object value) { mongoTemplate.updateFirst( Query.query(Criteria.where("id").is(key).and("timeout").gte(new Date())), Update.update(path, value), SaTokenWrap.class ); } // ------------------------ String 读写操作 @Override public String get(String key) { return getByKey(key).map(SaTokenWrap::getValue).orElse(null); } @Override public void set(String key, String value, long timeout) { upsertByPath(key, "value", value, timeout); } @Override public void update(String key, String value) { updateByPath(key, "value", value); } @Override public void delete(String key) { mongoTemplate.remove(Query.query(Criteria.where("id").is(key))); } @Override public long getTimeout(String key) { SaTokenWrap tokenWrap = mongoTemplate.findById(key, SaTokenWrap.class); if (tokenWrap == null) { return SaTokenDao.NOT_VALUE_EXPIRE; } if (tokenWrap.getTimeout() == null) { return SaTokenDao.NEVER_EXPIRE; } long expire = tokenWrap.getTimeout().getTime(); long timeout = (expire - System.currentTimeMillis()) / 1000; // 小于零时,视为不存在 if (timeout < 0) { mongoTemplate.remove(Query.query(Criteria.where("id").is(key))); return SaTokenDao.NOT_VALUE_EXPIRE; } return timeout; } @Override public void updateTimeout(String key, long timeout) { Update update = new Update(); if (timeout == SaTokenDao.NEVER_EXPIRE) { update.unset("timeout"); } else { update.set("timeout", timeoutToDate(timeout)); } mongoTemplate.upsert( Query.query(Criteria.where("id").is(key)), update, SaTokenWrap.class ); } // ------------------------ Object 读写操作 @Override public Object getObject(String key) { return getByKey(key).map(SaTokenWrap::getObject).orElse(null); } @Override public void setObject(String key, Object object, long timeout) { upsertByPath(key, "object", object, timeout); } @Override public void updateObject(String key, Object object) { updateByPath(key, "object", object); } @Override public void deleteObject(String key) { delete(key); } @Override public long getObjectTimeout(String key) { return getTimeout(key); } @Override public void updateObjectTimeout(String key, long timeout) { updateTimeout(key, timeout); } // ------------------------ Session 读写操作 // 使用接口默认实现 // --------- 会话管理 @Override public List<String> searchData(String prefix, String keyword, int start, int size, boolean sortType) { List<SaTokenWrap> wrapList = mongoTemplate.find( Query.query(Criteria.where("id").regex(prefix + "*" + keyword + "*").and("timeout").gte(new Date())), SaTokenWrap.class ); List<String> list = wrapList.stream().map(SaTokenWrap::getValue).filter(StringUtils::hasText).collect(Collectors.toList()); return SaFoxUtil.searchList(list, start, size, sortType); } }六、关键设计细节解析
6.1 基础读写:upsert 与 update 的分工
实现将 String 和 Object 两类数据统一映射到同一个SaTokenWrap文档,通过path参数区分写入value还是object字段:
upsertByPath用于「写入 / 覆盖」(set、setObject):不存在则插入,存在则更新。写入时根据 timeout 语义决定设置还是移除timeout字段;updateByPath用于「仅更新值、不改变过期时间」(update、updateObject):查询条件额外追加and("timeout").gte(new Date()),即只更新尚未过期的记录,过期记录自然跳过。
timeout字段的写入规则值得单独强调:
| timeout 取值 | 对timeout字段的操作 | 含义 |
|---|---|---|
timeout == 0 或 timeout <= NOT_VALUE_EXPIRE(-2) | 直接 return,不执行任何写入 | 不存储该数据 |
timeout == NEVER_EXPIRE(-1) | update.unset("timeout") | 永久存储(TTL 索引不删除) |
timeout > 0 | update.set("timeout", timeoutToDate(timeout)) | 限时存储,到期由 TTL 清理 |
6.2 剩余存活时间:getTimeout 的三种返回分支
if (tokenWrap == null) { return SaTokenDao.NOT_VALUE_EXPIRE; // 记录不存在:-2 } if (tokenWrap.getTimeout() == null) { return SaTokenDao.NEVER_EXPIRE; // 永不过期:-1 } // 计算剩余秒数,小于零视为不存在,并顺手物理删除这种分支设计完整对齐了SaTokenDao接口的常量契约(SaTokenDao.java),是框架内部判断「是否需要续期、是否已过期」的依据。
6.3 Session 读写:直接复用接口默认实现
注意代码注释「// 使用接口默认实现」——SaTokenDaoMongo没有单独重写getSession/setSession/updateSession/deleteSession/getSessionTimeout/updateSessionTimeout六个方法,而是依赖SaTokenDao接口中的默认实现(SaTokenDao.java),它们内部默认复用 Object 读写方法。这意味着 Session 数据会以Object类型落到SaTokenWrap.object字段中。也正因为 Session 要经过 Object 反序列化,才有了前文自定义MySaSession的必要性——两处设计环环相扣。
6.4 searchData:模糊搜索与分页排序
searchData是框架用于「根据关键字搜索 Token 列表」等会话管理场景的方法:
- 使用 MongoDB 正则
prefix + "*" + keyword + "*"对id字段做模糊匹配,并限定只查未过期记录; - 从结果中取出
value(即 token 值),过滤空串; - 最后交给
SaFoxUtil.searchList(list, start, size, sortType)完成分页与排序(start为开始索引,size为获取数量,-1代表取到末尾,sortType为true正序、false反序)。
七、两种 MongoDB 集成方案的取舍
本仓库文档目录下同时存在两篇 MongoDB 集成参考(integ-spring-mongod-1.md 与 integ-spring-mongod-2.md),二者的核心差异在于:
| 对比项 | 参考一 | 参考二(本文) |
|---|---|---|
| 存储粒度 | 单个SaTokenMongoData文档同时持有string与session字段 | String 存value字段、Object 存object字段,读写路径分开 |
| SaSession 处理 | 文档字段直接声明为SaSession类型 | 通过自定义MySaSession+ 重写createSession策略解决反序列化 |
| 过期机制 | @Indexed(expireAfterSeconds = 0)的LocalDateTime expireAt | @Indexed(expireAfterSeconds = 1)的Date timeout,并用live()过滤 |
| 适合场景 | 少量改造即可完成集成,代码更少 | 结构更清晰、读写路径更聚焦,适合对 Session 反序列化有强控制需求的场景 |
八、总结与注意事项
至此,一个基于 MongoDB 的 Sa-Token 数据持久层就完整落地了。工程落地时还需注意以下几点:
- 数据模型必须使用
@Document并显式声明@Indexed,TTL 索引由 MongoDB 首次启动时自动创建;若集合已存在且索引变更,需要人工调整索引或换集合名; - TTL 清理存在延迟,业务读取务必走
getByKey(内部filter(SaTokenWrap::live))做逻辑过期判断,getTimeout中也要对「剩余时间为负」做兜底删除; - Session 反序列化问题是集成 MongoDB 的核心坑点,如果项目实际并未把 Session 存入 MongoDB(例如仅持久化 token 字符串),则可以跳过
MySaSession与createSession重写两步; - 接口未强制要求的默认方法(Session 读写、
init/destroy)可按需覆盖,示例中采用「使用接口默认实现」的写法已能满足基本功能; - 本文代码示例可直接对照 SaTokenDao 接口定义 与 SaStrategy 策略类 阅读,理解每个方法在框架内部的调用时机,便于后续按需增强(如支持
getObject(key, Class)指定反序列化类型等接口新能力)。
【免费下载链接】Sa-Token✨ 开源、免费、一站式 Java 权限认证框架,让鉴权变得简单、优雅!—— 登录认证、权限认证、分布式 Session 会话、微服务网关鉴权、SSO 单点登录、OAuth2.0 统一认证、jwt 集成、API Key 秘钥授权、API 参数签名项目地址: https://gitcode.com/GitHub_Trending/sa/Sa-Token
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考