2216 字
11 分钟
在 Vert.x 中集成 SaToken

- 官方文档:Sa-Token.
- 参考教程:自定义 SaTokenContext 指南.
Vert.x 提供的 Request 对象不基于 ServletAPI 规范,所以需要手动实现 SaToken 依赖的 Servlet 容器。
导入依赖 build.sbt
:
// SaToken 核心依赖,注意:此处的依赖版本是 1.40.0 !!libraryDependencies += "cn.dev33" % "sa-token-core" % "1.40.0"// Hutool 的 JSON 工具依赖libraryDependencies += "cn.hutool" % "hutool-json" % "5.8.36"// Redis4jCats 依赖libraryDependencies += "dev.profunktor" %% "redis4cats-effects" % "1.7.2"// 提供 IO 异步支持依赖libraryDependencies += "org.typelevel" %% "cats-effect" % "3.6-623178c"// Vert.x 依赖libraryDependencies ++= Seq( // Vert.x 核心库 "io.vertx" % "vertx-core" % "4.5.13", // Vert.x 的 Web 支持 "io.vertx" % "vertx-web" % "4.5.13", // Vert.x 的 Scala 支持 "io.vertx" % "vertx-lang-scala_3" % "4.5.11", // Vert.x 的客户端支持 "io.vertx" % "vertx-web-client" % "4.5.13")
实现 SaRequest 接口
import cn.dev33.satoken.context.model.SaRequestimport io.vertx.core.http.HttpServerRequestimport io.vertx.ext.web.RoutingContextimport java.utilimport scala.jdk.CollectionConverters.*
// 请求对象,携带着一次请求的所有参数数据class VertxRequest(ctx: RoutingContext) extends SaRequest { val request: HttpServerRequest = ctx.request() override def getSource: HttpServerRequest = request
override def getParam(name: String): String = Option(request.getParam(name)).getOrElse("")
override def getParamNames: util.Collection[String] = request.params().names()
override def getParamMap: util.Map[String, String] = request.params().asScala.map(entry => entry.getKey -> entry.getValue).toMap.asJava
override def getHeader(name: String): String = Option(request.getHeader(name)).getOrElse("")
override def getCookieValue(name: String): String = Option(request.getCookie(name).getValue).getOrElse("")
override def getCookieFirstValue(name: String): String = Option(request.cookies(name).asScala.head.getValue).getOrElse("")
override def getCookieLastValue(name: String): String = Option(request.cookies(name).asScala.last.getValue).getOrElse("")
override def getRequestPath: String = Option(request.uri()).getOrElse("")
override def getUrl: String = Option(request.absoluteURI()).getOrElse("")
override def getMethod: String = Option(request.method().name()).getOrElse("")
// SaToken 1.41.0 需要新增实现以下方法 // override def getHost: String = request.authority().host()
// 请求转发 override def forward(path: String): AnyRef = { ctx.reroute(path) null }}
实现 SaResponse 接口
import cn.dev33.satoken.context.model.SaResponseimport io.vertx.core.http.HttpServerResponseimport io.vertx.ext.web.RoutingContext
// 响应对象,携带着对客户端一次响应的所有数据class VertxResponse(ctx: RoutingContext) extends SaResponse { val response: HttpServerResponse = ctx.response() override def getSource: HttpServerResponse = response
override def setStatus(sc: Int): SaResponse = { response.setStatusCode(sc) this }
override def setHeader(name: String, value: String): SaResponse = { response.putHeader(name, value) this }
override def addHeader(name: String, value: String): SaResponse = { response.putHeader(name, value) this }
// 重定向 override def redirect(url: String): AnyRef = ctx.redirect(url)}
实现 SaStorage 接口
import cn.dev33.satoken.context.model.SaStorageimport io.vertx.ext.web.RoutingContextimport java.util
// 请求上下文对象,提供 [一次请求范围内] 的上下文数据读写class VertxStorage(ctx: RoutingContext) extends SaStorage { val storage: util.Map[String, AnyRef] = ctx.data() override def getSource: util.Map[String, AnyRef] = storage
override def get(key: String): AnyRef = Option(storage.get(key)).orNull
override def set(key: String, value: AnyRef): SaStorage = { storage.put(key, value) this }
override def delete(key: String): SaStorage = { storage.remove(key) this }}
实现请求上下文对象
import cn.dev33.satoken.context.SaTokenContextimport cn.dev33.satoken.context.model.{SaRequest, SaResponse, SaStorage}import io.vertx.ext.web.RoutingContextimport scala.compiletime.uninitialized
// 单例对象object VertxTokenContext { // 懒加载创建单例实例 private lazy val instance: VertxTokenContext = new VertxTokenContext()
// 获取单例实例 def apply(ctx: RoutingContext): VertxTokenContext = { // 设置当前请求的 RoutingContext instance.SetRoutingContext(ctx) instance }}
// SaToken 上下文处理器class VertxTokenContext private extends SaTokenContext { // uninitialized 表示变量未初始化,访问未初始化的变量会抛出 UninitializedFieldError // 与 `= _` 不同,uninitialized 不会将变量初始化为默认值(如 null、0 等) // `= _` 是 Scala 2 中表示变量未初始化的方式 private var ctx: RoutingContext = uninitialized
// 设置当前请求的 RoutingContext def SetRoutingContext(ctx: RoutingContext): Unit = this.ctx = ctx
// 获取当前请求的 [Request] 对象 override def getRequest: SaRequest = VertxRequest(ctx)
// 获取当前请求的 [Response] 对象 override def getResponse: SaResponse = VertxResponse(ctx)
// 获取当前请求的 [存储器] 对象 override def getStorage: SaStorage = VertxStorage(ctx)
// 校验指定路由匹配符是否可以匹配成功指定路径 override def matchPath(pattern: String, path: String): Boolean = PathMatcher.MatchPath(pattern, path)}
路由匹配工具类
object PathMatcher { /** 判断:指定路由匹配符是否可以匹配成功指定路径 * * @param pattern 路由匹配符(被匹配的路径) * @param path 要匹配的路径 * @return 是否匹配成功 */ def MatchPath(pattern: String, path: String): Boolean = { // 去除查询参数 val patternWithoutQuery = RemoveQueryParams(pattern) val pathWithoutQuery = RemoveQueryParams(path) // 将 Spring 风格的路径模式转换为正则表达式 val regex = ConvertPatternToRegex(patternWithoutQuery) // 使用正则表达式匹配路径 pathWithoutQuery.matches(regex) }
// 使用 ? 分割字符串并取第一部分,从而移除查询参数 private def RemoveQueryParams(str: String): String = str.split("\\?").head
/** 将 Spring 风格的路径模式转换为正则表达式 * * @param pattern Spring 风格的路径模式 * @return 正则表达式 */ private def ConvertPatternToRegex(pattern: String): String = { // 替换特殊字符 val regex = pattern .replace("/**", "/.*") // 支持多级路径通配符 .replace("/*", "/[^/]*") // * 匹配任意非斜杠字符 .replace("?", ".") // ? 匹配单个字符 .replaceAll(":([^/]*)", "([^/]+)") // 处理 :id 形式的变量,不允许后续出现斜杠,Vert.x HTTP 路径参数的方式 // .replaceAll("\\{[^}]+}", "([^/]+)") // 处理 {id} 形式的变量,不允许后续出现斜杠,Spring 路径参数的方式 .replace("/", "\\/") // 转义斜杠 "^" + regex + "$" // 添加起始和结束锚点 }
def main(args: Array[String]): Unit = { // 测试示例 println(MatchPath("/test/test?id=1&pid=2", "/test/test")) // true println(MatchPath("/test/test", "/test/test/")) // false println(MatchPath("/test/test", "/test/test/extra")) // false println(MatchPath("/test/*", "/test/123")) // true println(MatchPath("/test/*", "/test/123/")) // false println(MatchPath("/test/:id", "/test/123")) // true println(MatchPath("/test/:pid", "/test/123/")) // false println(MatchPath("/test/:id/:pid", "/test/123/456")) // true }}
集成 Redis
需要实现 SaToken 的存储层接口:
import redis.RedisExample // 自定义 Redis 实例import cats.effect.IOimport cats.effect.kernel.Resourceimport cats.effect.unsafe.implicits.globalimport cn.dev33.satoken.dao.SaTokenDaoimport cn.dev33.satoken.session.SaSessionimport cn.dev33.satoken.util.SaFoxUtilimport cn.hutool.json.JSONUtilimport dev.profunktor.redis4cats.RedisCommandsimport dev.profunktor.redis4cats.effects.{KeyScanArgs, RedisType}import java.utilimport scala.concurrent.duration.*import scala.jdk.CollectionConverters.*
// 若是 SaToken 1.41.0,可以继承 SaTokenDaoDefaultImpl 类而不是实现 SaTokenDao 接口class RedisTokenDao extends SaTokenDao { // 此处引入自定义的 Redis 实例(Redis4jCats),此处的 Redis 实例是短连接, // 也可以使用 Jedis 连接池创建长连接实例 val redis: Resource[IO, RedisCommands[IO, String, String]] = RedisExample.api
override def update(key: String, value: String): Unit = { val expire = getTimeout(key) if (expire == SaTokenDao.NOT_VALUE_EXPIRE) return set(key, value, expire) }
override def getTimeout(key: String): Long = { redis .use { r => r.ttl(key).flatMap { case Some(duration) => IO.pure(duration.toSeconds) case None => IO.pure(0L) } } .unsafeRunSync() }
override def set(key: String, value: String, timeout: Long): Unit = { if (timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE) return // 判断是否为永不过期// 判断是否为永不过期 if (timeout == SaTokenDao.NEVER_EXPIRE) redis.use(r => r.set(key, value)).unsafeRunSync() else redis.use(r => r.setEx(key, value, timeout.seconds)).unsafeRunSync() }
override def getObject(key: String): AnyRef = { redis .use { r => r.get(key).flatMap { case Some(value) => IO.pure(SaSessionUtil.GetSession(JSONUtil.parse(value))) case None => IO.pure(null) } } .unsafeRunSync() }
override def updateObject(key: String, `object`: Any): Unit = { val expire = getTimeout(key) if (expire == SaTokenDao.NOT_VALUE_EXPIRE) return setObject(key, `object`, expire) }
// 此处的 `object` 即是 SaSession 对象,直接使用 Hutool JSON 序列化成字符串存入 Redis override def setObject(key: String, `object`: Any, timeout: Long): Unit = { if (timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE) return if (timeout == SaTokenDao.NEVER_EXPIRE) redis .use(r => r.set(key, JSONUtil.toJsonPrettyStr(`object`))) .unsafeRunSync() else redis .use(r => r.setEx(key, JSONUtil.toJsonPrettyStr(`object`), timeout.seconds) ) .unsafeRunSync() }
override def deleteObject(key: String): Unit = delete(key)
override def delete(key: String): Unit = redis.use(r => r.del(key)).unsafeRunSync()
override def getObjectTimeout(key: String): Long = getTimeout(key)
override def updateObjectTimeout(key: String, timeout: Long): Unit = updateTimeout(key, timeout)
override def updateTimeout(key: String, timeout: Long): Unit = { // 判断是否想要设置为永久 if (timeout == SaTokenDao.NEVER_EXPIRE) { val expire = getTimeout(key) if (expire != SaTokenDao.NEVER_EXPIRE) { // 如果尚未被设置为永久,那么再次set一次 this.set(key, this.get(key), timeout) } return } redis.use(r => r.expire(key, timeout.seconds)).unsafeRunSync() }
override def get(key: String): String = { redis .use { r => r.get(key).flatMap { case Some(value) => IO.pure(value) case None => IO.pure(null) } } .unsafeRunSync() }
/** 搜索数据,获取所有匹配的键,不是键值对 * * @param prefix 前缀 * @param keyword 关键字 * @param start 开始处索引 * @param size 获取数量 (-1代表从 start 处一直取到末尾) * @param sortType 排序类型(true=正序,false=反序) * @return 查询到的数据集合 */ override def searchData( prefix: String, keyword: String, start: Int, size: Int, sortType: Boolean ): util.List[String] = { val list: List[String] = redis .use { cmd => // s"$prefix*$keyword*":表示匹配前缀为 prefix,并且包含 keyword 的所有键 val pattern = s"$prefix*$keyword*" val keyScanArgs = KeyScanArgs(RedisType.String, pattern, 30) cmd.scan(keyScanArgs).map(cursor => cursor.keys) } .unsafeRunSync() SaFoxUtil.searchList(list.asJava, start, size, sortType) }
// 优先执行下面的方法而不是 getObject/setObject... 等方法 override def setSession(session: SaSession, timeout: Long): Unit = setObject(session.getId, session, timeout) override def updateSession(session: SaSession): Unit = updateObject(session.getId, session) override def deleteSession(sessionId: String): Unit = deleteObject(sessionId) override def getSessionTimeout(sessionId: String): Long = getObjectTimeout(sessionId) override def updateSessionTimeout(sessionId: String, timeout: Long): Unit = updateObjectTimeout(sessionId, timeout)
// SaToken 1.41.0 需要实现以下方法,可以通过直接继承 SaTokenDaoDefaultImpl 类重写调用父类方法, // 只有 1.41.0 的 SaTokenDaoDefaultImpl 才实现了 getObject[T](key: String, classType: Class[T]): T 方法 // override def getObject[T](key: String, classType: Class[T]): T = super.getObject(key, classType)}
此处需要序列化/反序列化 SaSession 对象,但是 SaSession 对象是 Java 类,而不是 Scala 类,为了方便起见,直接使用 Hutool 的 JSON 工具实现:
import cn.dev33.satoken.session.SaSession;import cn.dev33.satoken.session.TokenSign; // SaToken 1.41.0 没有该类!!import cn.hutool.json.JSON;import cn.hutool.json.JSONUtil;
public class SaSessionUtil { public static SaSession GetSession(JSON source) { return new SaSession() {{ setId(source.getByPath("id").toString()); setType(source.getByPath("type").toString()); setLoginType(source.getByPath("loginType").toString()); setLoginId(source.getByPath("loginId")); setCreateTime(Long.parseLong(source.getByPath("createTime").toString())); setDataMap(JSONUtil.parseObj(source.getByPath("dataMap"))); // SaToken 1.41.0 没有该属性!! setTokenSignList(JSONUtil.parseArray(source.getByPath("tokenSignList")).toList(TokenSign.class)); }}; }}
Redis4jCats 实例
import cats.effect.kernel.Resourceimport cats.effect.IOimport dev.profunktor.redis4cats.effect.Logimport dev.profunktor.redis4cats.effect.Log.NoOp.*import dev.profunktor.redis4cats.{Redis, RedisCommands}
object RedisExample: val api: Resource[IO, RedisCommands[IO, String, String]] = Redis[IO].utf8("redis://localhost:6379")
创建 Vert.x HTTP 服务
import cn.dev33.satoken.SaManagerimport cn.dev33.satoken.stp.StpUtilimport io.vertx.core.Vertximport io.vertx.ext.web.Routerimport io.vertx.ext.web.handler.BodyHandlerimport vertx.User
// https://sa-token.cc/doc.html#/fun/sa-token-contextobject Application extends App { val vertx = Vertx.vertx() // 创建 Vert.x 实例 val router: Router = Router.router(vertx) // 创建路由 router.route().handler(BodyHandler.create()) // 启用请求体解析
// 设置 SaToken 的存储层为 Redis SaManager.setSaTokenDao(RedisTokenDao())
router.route().handler(ctx => { // 设置 SaToken 上下文实例,经过测试每一次调用请求都需要设置上下文, // 此处的上下文对象使用单例确保全局只创建一次 SaManager.setSaTokenContext(VertxTokenContext(ctx)) ctx.next() })
router.get("/login").handler { ctx => // 会话登录 StpUtil.login("Dorothy", "PC") val user = User("Dorothy", 16) StpUtil.getSession().set("user", user.toString) ctx.response() .putHeader("Content-Type", "application/json") .end(s"""{"msg": "Hello, ${user.name}!"}""") }
router.get("/info").handler { ctx => // 判断是否登录 if StpUtil.isLogin then { val userStr = StpUtil.getSession().get("user").asInstanceOf[String] val user = userStr match { case s"User($name,$age)" => User(name, age.toByte) case _ => throw new IllegalArgumentException("转换失败!") } ctx.response() .putHeader("Content-Type", "application/json") .end( s"""{ |"name": "${user.name}", |"age": "${user.age}" |}""".stripMargin) } else ctx.response().putHeader("Content-Type", "application/json").end("""{"msg": "未登录"}""") }
// 启动服务器并监听 2234 端口 vertx.createHttpServer() .requestHandler(router) .listen(2234, "0.0.0.0", { result => if result.succeeded() then println("Server is now listening on http://127.0.0.1:2234!") else println(s"Failed to start server: ${result.cause()}") })}
Redis 中的数据示例:
{ "id": "satoken:login:session:Dorothy", "type": "Account-Session", "loginType": "login", "loginId": "Dorothy", "createTime": 1742651367502, "dataMap": { "user": "User(Dorothy,16)" }, "tokenSignList": [ { "value": "94f2c820-8a8a-4ecd-ba55-5c9e513bcc3f", "device": "PC" }, { "value": "38cfce02-dc79-45fe-a50d-79f427a846a8", "device": "PE" } ]}