22、扩展函数与高阶函数在缓存中的应用:内存缓存的DSL封装、磁盘缓存的扩展函数、多级缓存策略
缓存这东西,说白了就是拿空间换时间。我在项目里见过不少团队,一开始觉得缓存简单,不就是存个数据嘛。结果业务一复杂,内存、磁盘、网络三层搅在一起,代码写得跟意大利面条似的。今天我们就用Kotlin的扩展函数和高阶函数,把缓存这件事理清楚。
为什么缓存需要DSL和扩展函数?
你想想看,传统的缓存代码长什么样?
// 传统写法
val cache = MemoryCache()
val data = cache.get("user_123")
if (data == null) {
val newData = fetchFromNetwork()
cache.put("user_123", newData)
}
这种写法有什么问题?每次都要手动检查null、手动回填。业务一多,到处都是重复的模板代码。我个人习惯用扩展函数把这些逻辑封装起来,调用方只需要关心「我要什么数据」,剩下的交给函数内部处理。
内存缓存的DSL封装
先看内存缓存。Kotlin的DSL能力特别适合用来配置缓存策略——过期时间、最大容量、淘汰策略,这些参数用DSL写出来,读起来就像在描述需求。
class MemoryCache<K, V> {
private val map = LinkedHashMap<K, CacheEntry<V>>()
private var maxSize: Int = 100
private var expireTime: Long = 60_000L // 默认60秒
// DSL配置函数
fun config(block: MemoryCache<K, V>.() -> Unit) {
block()
}
fun maxSize(size: Int) {
this.maxSize = size
}
fun expireTime(millis: Long) {
this.expireTime = millis
}
fun put(key: K, value: V) {
if (map.size >= maxSize) {
val oldest = map.keys.first()
map.remove(oldest)
}
map[key] = CacheEntry(value, System.currentTimeMillis())
}
fun get(key: K): V? {
val entry = map[key] ?: return null
if (System.currentTimeMillis() - entry.timestamp > expireTime) {
map.remove(key)
return null
}
return entry.value
}
private data class CacheEntry<V>(val value: V, val timestamp: Long)
}
// 使用DSL配置
val userCache = MemoryCache<String, User>().apply {
config {
maxSize(200)
expireTime(120_000L)
}
}
嗯,这里要注意一点:DSL的config块其实就是一个高阶函数,它接收一个MemoryCache.() -> Unit类型的lambda。这种写法让配置变得特别直观,团队成员一看就知道这个缓存能存多少、过期多久。
磁盘缓存的扩展函数
磁盘缓存跟内存缓存不一样,它涉及序列化、文件读写、IO异常处理。用扩展函数可以把这些脏活累活藏起来。
// 为任意类型添加磁盘缓存能力
inline fun <reified T> Any.diskCache(
key: String,
cacheDir: File,
expireTime: Long = 24 * 60 * 60_000L,
crossinline fetcher: () -> T?
): T? {
val cacheFile = File(cacheDir, key.hashCode().toString())
// 先读磁盘
if (cacheFile.exists()) {
val lastModified = cacheFile.lastModified()
if (System.currentTimeMillis() - lastModified < expireTime) {
try {
val bytes = cacheFile.readBytes()
return deserialize<T>(bytes)
} catch (e: Exception) {
cacheFile.delete() // 损坏就删掉
}
} else {
cacheFile.delete() // 过期就删掉
}
}
// 磁盘没有,执行fetcher获取数据
val data = fetcher()
if (data != null) {
try {
val bytes = serialize(data)
cacheFile.writeBytes(bytes)
} catch (e: Exception) {
// 写磁盘失败,不影响业务
}
}
return data
}
// 序列化/反序列化辅助函数
inline fun <reified T> serialize(value: T): ByteArray {
// 这里可以用Gson、Kotlin Serialization等
return value.toString().toByteArray()
}
inline fun <reified T> deserialize(bytes: ByteArray): T? {
// 反序列化实现
return null
}
这个扩展函数有意思的地方在于:它是挂载在Any类型上的。也就是说,任何对象都能调用diskCache。我曾经在一个项目里直接用activity.diskCache("user_list")来缓存列表数据,写起来特别顺手。
crossinline不能少。因为fetcher是一个lambda,可能会在非局部返回的场景下使用。不加crossinline,编译器会报错。我曾经在这个坑里卡了半小时,最后发现是lambda的返回标签问题。
多级缓存策略
单级缓存不够用怎么办?内存+磁盘+网络,三级联动才是生产级的方案。我们用高阶函数把这三层串起来。
class MultiLevelCache<K, V>(
private val memoryCache: MemoryCache<K, V>,
private val diskCacheDir: File
) {
// 核心:多级缓存读取
fun get(
key: K,
expireTime: Long = 60_000L,
fetcher: () -> V?
): V? {
// 第一级:内存
memoryCache.get(key)?.let {
return it
}
// 第二级:磁盘
val diskKey = key.toString()
val diskResult = diskCache<V>(diskKey, diskCacheDir, expireTime) {
// 如果磁盘也没有,才执行fetcher
fetcher()
}
if (diskResult != null) {
// 回填内存
memoryCache.put(key, diskResult)
return diskResult
}
// 第三级:网络(由fetcher触发)
val networkResult = fetcher()
if (networkResult != null) {
memoryCache.put(key, networkResult)
// 异步写磁盘
Thread {
diskCache<V>(diskKey, diskCacheDir, expireTime) { networkResult }
}.start()
}
return networkResult
}
}
// 使用示例
val cache = MultiLevelCache<String, User>(memoryCache, diskDir)
val user = cache.get("user_123", expireTime = 120_000L) {
// 这里放网络请求
api.fetchUser("123")
}
这个设计的关键在于:每一级缓存都只关心自己的职责。内存管快,磁盘管持久,网络管最新。调用方只需要传一个fetcher,剩下的各级缓存自动协作。
核心思想:多级缓存不是简单的if-else嵌套,而是用高阶函数把「查找-回填」的逻辑抽象成一条流水线。每一级都尝试返回数据,如果命中就提前终止;如果没命中,就调用下一级,同时把结果回填到上一级。
知识体系总览
下面这张图把本章的核心逻辑串起来了,你可以看到扩展函数和高阶函数在每一层扮演的角色。
从这张图可以看得很清楚:调用方只跟MultiLevelCache.get()打交道,传入一个fetcher。内部用高阶函数依次尝试内存、磁盘、网络三级,命中就返回,没命中就继续往下走,同时把结果回填到上一级。每一级的实现细节——内存的DSL配置、磁盘的扩展函数——都被封装得干干净净。
总结一下
扩展函数和高阶函数在缓存中的应用,本质上是在做一件事:把「怎么存」和「怎么取」的细节,从业务代码中剥离出去。内存缓存用DSL配置参数,磁盘缓存用扩展函数处理序列化,多级缓存用高阶函数串联流程。每一层都只暴露最简洁的接口给调用方。
我在项目中实践这套方案后,缓存相关的bug减少了至少一半。以前经常出现的「缓存穿透」「缓存雪崩」问题,因为有了统一的过期时间和淘汰策略,基本没再出现过。说白了,好的设计不是功能多强大,而是让使用者不容易犯错。