缓存
用
IDistributedCache统一读写入口,在不改业务调用方式的前提下接入缓存能力。
适用场景
- Chat 应用里有回复结果、验证码、会话状态这类“读多写少”的数据。
- 业务代码不想直接依赖某个具体缓存 SDK。
- 需要给缓存项补上统一的过期策略,而不是在各处散落失效逻辑。
- 应用已经接入依赖注入,希望缓存和其他基础设施一样通过容器解析。
如果这些问题已经出现,真正要统一的不是”某个缓存产品的用法”,而是”业务代码如何读写缓存”这一层接口。
核心问题
soulsoft_extensions_caching 当前提供两层能力:
IDistributedCache:统一定义get、set、refresh、remove这组基础操作。MemoryDistributedCache:基于内存的默认实现,可直接使用,也可通过 DI 注册成IDistributedCache。
这层抽象的价值不在于“少写几个方法名”,而在于把业务代码和具体实现解耦。
这张图只表达一条主线:业务代码依赖接口,当前内置实现是 MemoryDistributedCache,如果后续需要接入别的存储,应继续复用 IDistributedCache 这一层。
信息
当前源码内置的只有 MemoryDistributedCache 和 addDistributedMemoryCache()。文档中的扩展能力只描述接口层已经预留的替换点,不把未提供的具体注册 API 写成现状。
相关依赖
| 包名 | 作用 | 什么时候需要 |
|---|---|---|
soulsoft_extensions_caching | 提供 IDistributedCache、MemoryDistributedCache、DistributedCacheEntryOptions 等核心类型 | 始终需要 |
soulsoft_extensions_options | 提供 Options.create() 创建默认配置对象 | 直接创建 MemoryDistributedCache 时需要 |
快速开始
先引入 soulsoft_extensions_caching 和 soulsoft_extensions_options,按“创建实例 → 写入 → 读取 → 删除”这条最小路径走一遍。
import soulsoft_extensions_caching.*
import soulsoft_extensions_options.*
main(): Int64 {
// 创建缓存实例,使用默认配置
let cache = MemoryDistributedCache(Options.create(DistributedCacheOptions()))
cache.setString("chat:last-reply", "hello")
println(cache.getString("chat:last-reply") ?? "None") // 键存在,输出 hello
cache.remove("chat:last-reply")
println(cache.getString("chat:last-reply") ?? "None") // 键已删除,返回 None
return 0
}删除后再次读取会返回 None。
| 角色 | 职责 | 常用入口 |
|---|---|---|
MemoryDistributedCache | 当前默认实现 | MemoryDistributedCache(Options.create(...)) |
IDistributedCache | 统一缓存接口 | get() / set() / refresh() / remove() |
DistributedCacheOptions | 全局缓存配置 | expirationScanFrequency |
信息
本页仓颉示例已在 E:\gitcode\demo 中通过 cjpm build 验证。
核心概念
统一接口
IDistributedCache 把缓存读写收敛成两组方法:
| 数据形式 | 读取 | 写入 |
|---|---|---|
| 字节数组 | get(key) | set(key, value) |
| 字符串 | getString(key) | setString(key, value) |
其中:
getString()内部基于get()读取字节数组后再做 UTF-8 转换。setString()内部会把字符串转成字节数组,再调用set()。
这意味着如果业务本身就是字符串场景,优先直接使用 getString() / setString();如果要缓存原始二进制载荷,再使用 get() / set()。
过期判断
缓存项是否过期由 DistributedCacheEntryOptions 决定。当前支持三种条件:
| 字段 | 含义 | 适合场景 |
|---|---|---|
slidingExpiration | 距离上次访问超过指定时长后过期 | 会话状态、在线状态 |
absoluteExpiration | 到达指定 UTC 时间点后过期 | 某个固定时间必须失效的数据 |
absoluteExpirationRelativeToNow | 距离创建时间超过指定时长后过期 | 验证码、临时 token |
任一条件满足,缓存项都会被判定为过期。
这样设计的原因是:很多业务既需要“空闲太久就失效”,也需要“最长只能活多久”。把三种条件拆开后,可以单独使用,也可以组合使用。
刷新与过期清理
refresh(key) 有两个行为:
- 如果条目仍然有效,更新最近访问时间。
- 如果条目已经过期,直接删除该条目。
get(key) 会先调用 refresh(key),再返回缓存值。因此读取本身也会更新滑动过期窗口;这就是滑动过期能够“持续访问就续期”的原因。
除了访问时的被动清理,MemoryDistributedCache 还会按 DistributedCacheOptions.expirationScanFrequency 触发后台扫描,异步移除已经过期但长期未被访问的条目。默认扫描间隔是 Duration.minute。
常见用法
读写字符串和字节数组
先看方法名:字符串场景直接用 setString() / getString(),二进制场景用 set() / get()。
import soulsoft_extensions_caching.*
import soulsoft_extensions_options.*
let cache = MemoryDistributedCache(Options.create(DistributedCacheOptions()))
// 字符串场景直接使用 setString / getString
cache.setString("chat:user", "alice")
println(cache.getString("chat:user") ?? "None")
// 二进制场景:写入前手动转换,读取后手动还原
cache.set("chat:payload", "hello".toArray())
if (let Some(bytes) <- cache.get("chat:payload")) {
println(String.fromUtf8(bytes))
}
// 键不存在时 get() 和 getString() 都返回 None,不会抛异常如果键不存在,两种读取方式都会返回 None,不会抛异常。
配置过期策略
先看 DistributedCacheEntryOptions(...) 里的三个字段,它们分别对应三种过期条件。
import soulsoft_extensions_caching.*
import soulsoft_extensions_options.*
import std.time.*
let cache = MemoryDistributedCache(Options.create(DistributedCacheOptions()))
// 相对过期:从创建时刻起算,5 分钟后失效
cache.setString(
"chat:code",
"9527",
DistributedCacheEntryOptions(
absoluteExpirationRelativeToNow: Some(Duration.minute * 5)
)
)
// 绝对时刻过期:盯住固定时间点,适合每日重置等场景
cache.setString(
"chat:daily-summary",
"ready",
DistributedCacheEntryOptions(
absoluteExpiration: Some(DateTime.nowUTC() + Duration.hour)
)
)
// 滑动过期:每次访问都会重置窗口
cache.setString(
"chat:session",
"alice",
DistributedCacheEntryOptions(
slidingExpiration: Some(Duration.minute * 30)
)
)这三段代码分别演示了相对过期、绝对时刻过期和滑动过期。区别要点只有一个:相对过期从创建时刻起算,绝对过期盯住固定时间点,滑动过期盯住“最近一次访问”。
组合滑动过期和绝对过期
如果既希望“持续访问就续期”,又希望“最长只能活一段时间”,就把两个条件放在同一个 DistributedCacheEntryOptions 里。
import soulsoft_extensions_caching.*
import soulsoft_extensions_options.*
import std.time.*
let cache = MemoryDistributedCache(Options.create(DistributedCacheOptions()))
// 两个条件同时生效:空闲 5 分钟或最长存活 10 分钟,任一先满足即过期
cache.setString(
"chat:challenge",
"value",
DistributedCacheEntryOptions(
slidingExpiration: Some(Duration.minute * 5),
absoluteExpirationRelativeToNow: Some(Duration.minute * 10)
)
)两个条件中任一先满足,条目就会过期。
与依赖注入集成
实际项目里更常见的做法是把缓存注册进 ServiceCollection,让业务服务直接依赖 IDistributedCache。先看 CachedChatClient 的构造函数和 getReply 方法,再看底部注册代码。
import soulsoft_extensions_caching.*
import soulsoft_extensions_injection.*
import std.time.*
class CachedChatClient {
private let cache: IDistributedCache // 依赖接口,不依赖 MemoryDistributedCache
public init(cache: IDistributedCache) {
this.cache = cache // 构造函数注入,由 DI 容器自动解析
}
public func getReply(prompt: String): String {
let key = "chat:reply:${prompt}"
if (let Some(reply) <- cache.getString(key)) {
return reply // 缓存命中,直接返回
}
// 缓存未命中:计算新值并写入缓存,同时配置过期策略
let reply = "ok"
cache.setString(
key,
reply,
DistributedCacheEntryOptions(
absoluteExpirationRelativeToNow: Some(Duration.minute * 5)
)
)
return reply
}
}
// 注册阶段:装配 IDistributedCache 和业务服务
let services = ServiceCollection()
services.addDistributedMemoryCache { options => // IDistributedCache 注册为单例
options.expirationScanFrequency = Duration.minute * 30
}
services.addTransient<CachedChatClient, CachedChatClient>()
let provider = services.build()
let cache = provider.getOrThrow<IDistributedCache>() // 从容器解析同一个缓存实例
cache.setString("chat:settings", "loaded")
let client = provider.getOrThrow<CachedChatClient>() // DI 自动注入 IDistributedCache
println(client.getReply("hello"))addDistributedMemoryCache() 把 IDistributedCache 注册进容器,CachedChatClient 只依赖接口。
这样组织的原因是:缓存实现和业务服务的装配关系统一留在启动阶段,业务代码只关心“我能不能读写缓存”,不关心实例是怎么创建的。当前注册方式使用单例生命周期,因此同一个根容器解析到的是同一个缓存实例。
最佳实践
- 业务服务优先依赖
IDistributedCache,不要把MemoryDistributedCache直接写进构造函数。这样做的目的是把替换空间留在注册阶段,而不是把具体实现散落到业务层。 - 字符串场景优先使用
getString()/setString()。只有在需要原始二进制载荷时,再退回get()/set(),这样调用点更容易读。 - 只在写入时配置过期策略,不要把”多久失效”散落到读取逻辑里。过期规则跟缓存项本身绑定,排错时才容易定位。
expirationScanFrequency只影响后台扫描节奏,不改变单次get()/refresh()对过期条目的即时判断。也就是说,调大扫描间隔不会让过期数据在读取时继续命中。
推荐组织方式:业务服务通过构造函数接收 IDistributedCache,写入时携带过期策略。
class ChatReplyService {
private let cache: IDistributedCache
public init(cache: IDistributedCache) {
this.cache = cache
}
public func getOrCompute(key: String): String {
if (let Some(hit) <- cache.getString(key)) {
return hit
}
let value = “computed”
// 过期策略只在写入时指定,不在读取路径中
cache.setString(
key,
value,
DistributedCacheEntryOptions(
absoluteExpirationRelativeToNow: Some(Duration.minute * 5)
)
)
return value
}
}限制与边界
- 当前内置实现只有
MemoryDistributedCache,数据保存在当前进程内。 addDistributedMemoryCache()也是当前唯一提供的容器注册入口。- 文档能确认的扩展点是“你可以自己实现
IDistributedCache并按IDistributedCache注册进容器”;但具体外部缓存产品的实现和注册 API,并不属于当前源码已提供的现状。 getString()依赖 UTF-8 转换。如果缓存的是非文本二进制数据,应使用get()读取原始字节数组。
警告
MemoryDistributedCache 适合本地开发、单进程应用或接口抽象层验证。它不负责跨进程、跨节点共享数据,因此不能把“内存实现”直接等同于“多实例共享缓存”。
常见问题
getString() 返回 None 是什么情况?
常见只有三种情况:键从未写入、条目已被删除、条目已经过期。当前实现对这三种情况都返回 None,不会区分错误类型。
refresh() 会不会把绝对过期时间一起延后?
不会。refresh() 只更新最近访问时间,因此只对 slidingExpiration 有意义。绝对过期时间和相对创建时间过期都不会因为 refresh() 而被推迟。
什么时候需要调整 expirationScanFrequency?
只有当你明确在权衡“后台扫描频率”和“清理滞后成本”时才需要调整。默认值已经是 Duration.minute,多数场景可以直接使用默认配置。
怎么接入自己的缓存实现?
先实现 IDistributedCache 的四个基础方法:get、set、refresh、remove,再按 IDistributedCache 注册到容器里。这样业务层仍然只依赖统一接口,不需要跟着你的实现一起改构造函数和调用方式。