2021-02-06   


学习一下Halo的缓存实现和使用场景

Halo的缓存实现的相关类位于run/halo/app/cache下,主要应用场景:缓存token,验证码等业务数据和接口限流。

UML类图

uml

CacheStore

缓存的顶层接口类,定义使用行为

    @NonNull
    Optional<V> get(@NonNull K key);
    void put(@NonNull K key, @NonNull V value, long timeout, @NonNull TimeUnit timeUnit);
    void put(@NonNull K key, @NonNull V value);
    /**
     * 当缓存值过期了或者key不存在就放入值
     */
    Boolean putIfAbsent(@NonNull K key, @NonNull V value, long timeout, @NonNull TimeUnit timeUnit);
    void delete(@NonNull K key);

AbstractCacheStore

缓存的抽象实现类,定义通用方法,该类没有定义具体的缓存实现,只是定义了put时包装缓存值和get时清理过期值的行为。

@Slf4j
public abstract class AbstractCacheStore<K, V> implements CacheStore<K, V> {
//省略部分代码
    /**
     * 定义取值时清理过期值的行为
     */
    @Override
    public Optional<V> get(K key) {
        Assert.notNull(key, "Cache key must not be blank");

        return getInternal(key).map(cacheWrapper -> {
            // Check expiration
            if (cacheWrapper.getExpireAt() != null
                && cacheWrapper.getExpireAt().before(run.halo.app.utils.DateUtils.now())) {
                // Expired then delete it
                log.warn("Cache key: [{}] has been expired", key);

                // Delete the key
                delete(key);

                // Return null
                return null;
            }

            return cacheWrapper.getData();
        });
    }
    //省略部分代码
    @Override
    public void put(K key, V value, long timeout, TimeUnit timeUnit) {
        putInternal(key, buildCacheWrapper(value, timeout, timeUnit));
    }

    @Override
    public void put(K key, V value) {
        putInternal(key, buildCacheWrapper(value, 0, null));
    }

    @Override
    public Boolean putIfAbsent(K key, V value, long timeout, TimeUnit timeUnit) {
        return putInternalIfAbsent(key, buildCacheWrapper(value, timeout, timeUnit));
    }
    /**
     * 包装缓存值
     */
    @NonNull
    private CacheWrapper<V> buildCacheWrapper(@NonNull V value, long timeout,
        @Nullable TimeUnit timeUnit) {
        Assert.notNull(value, "Cache value must not be null");
        Assert.isTrue(timeout >= 0, "Cache expiration timeout must not be less than 1");

        Date now = run.halo.app.utils.DateUtils.now();

        Date expireAt = null;

        if (timeout > 0 && timeUnit != null) {
            expireAt = DateUtils.add(now, timeout, timeUnit);
        }

        // Build cache wrapper
        CacheWrapper<V> cacheWrapper = new CacheWrapper<>();
        cacheWrapper.setCreateAt(now);
        cacheWrapper.setExpireAt(expireAt);
        cacheWrapper.setData(value);

        return cacheWrapper;
    }
}

AbstractStringCacheStore

缓存的抽象实现类,定义通用方法,该类不定义缓存具体实现,而是定义缓存的存取值过程中的序列、反序列化的实现,这里用的Jackson的ObjectMapper

@Slf4j
public abstract class AbstractStringCacheStore extends AbstractCacheStore<String, String> {

    protected Optional<CacheWrapper<String>> jsonToCacheWrapper(String json) {
        Assert.hasText(json, "json value must not be null");
        CacheWrapper<String> cacheWrapper = null;
        try {
            cacheWrapper = JsonUtils.jsonToObject(json, CacheWrapper.class);
        } catch (IOException e) {
            log.debug("Failed to convert json to wrapper value bytes: [{}]", json, e);
        }
        return Optional.ofNullable(cacheWrapper);
    }

    public <T> void putAny(String key, T value) {
        try {
            put(key, JsonUtils.objectToJson(value));
        } catch (JsonProcessingException e) {
            throw new ServiceException("Failed to convert " + value + " to json", e);
        }
    }

    public <T> void putAny(@NonNull String key, @NonNull T value, long timeout,
        @NonNull TimeUnit timeUnit) {
        try {
            put(key, JsonUtils.objectToJson(value), timeout, timeUnit);
        } catch (JsonProcessingException e) {
            throw new ServiceException("Failed to convert " + value + " to json", e);
        }
    }

    public <T> Optional<T> getAny(String key, Class<T> type) {
        Assert.notNull(type, "Type must not be null");

        return get(key).map(value -> {
            try {
                return JsonUtils.jsonToObject(value, type);
            } catch (IOException e) {
                log.error("Failed to convert json to type: " + type.getName(), e);
                return null;
            }
        });
    }
}

CacheWrapper

缓存值包装对象

    /**
     * Cache data
     */
    private V data;

    /**
     * Expired time.
     */
    private Date expireAt;

    /**
     * Create time.
     */
    private Date createAt;

InMemoryCacheStore

缓存的内存缓存实现,实现用的是ConcurrentHashMap,并发安全

@Slf4j
public class InMemoryCacheStore extends AbstractStringCacheStore {

    /**
     * Cleaner schedule period. (ms)
     */
    private static final long PERIOD = 60 * 1000;

    /**
     * Cache container.
     */
    private static final ConcurrentHashMap<String, CacheWrapper<String>> CACHE_CONTAINER =
        new ConcurrentHashMap<>();

    private final Timer timer;

    /**
     * Lock.
     */
    private final Lock lock = new ReentrantLock();
    //创建定时器,每分钟遍历一次ConcurrentHashMap,清理过期值
    public InMemoryCacheStore() {
        // Run a cache store cleaner
        timer = new Timer();
        timer.scheduleAtFixedRate(new CacheExpiryCleaner(), 0, PERIOD);
    }

    @Override
    @NonNull
    Optional<CacheWrapper<String>> getInternal(@NonNull String key) {
        Assert.hasText(key, "Cache key must not be blank");

        return Optional.ofNullable(CACHE_CONTAINER.get(key));
    }

    @Override
    void putInternal(@NonNull String key, @NonNull CacheWrapper<String> cacheWrapper) {
        Assert.hasText(key, "Cache key must not be blank");
        Assert.notNull(cacheWrapper, "Cache wrapper must not be null");

        // Put the cache wrapper
        CacheWrapper<String> putCacheWrapper = CACHE_CONTAINER.put(key, cacheWrapper);

        log.debug("Put [{}] cache result: [{}], original cache wrapper: [{}]", key, putCacheWrapper,
            cacheWrapper);
    }

    @Override
    Boolean putInternalIfAbsent(@NonNull String key, @NonNull CacheWrapper<String> cacheWrapper) {
        Assert.hasText(key, "Cache key must not be blank");
        Assert.notNull(cacheWrapper, "Cache wrapper must not be null");

        log.debug("Preparing to put key: [{}], value: [{}]", key, cacheWrapper);

        lock.lock();
        try {
            // Get the value before
            Optional<String> valueOptional = get(key);

            if (valueOptional.isPresent()) {
                log.warn("Failed to put the cache, because the key: [{}] has been present already",
                    key);
                return false;
            }

            // Put the cache wrapper
            putInternal(key, cacheWrapper);
            log.debug("Put successfully");
            return true;
        } finally {
            lock.unlock();
        }
    }

    @Override
    public void delete(@NonNull String key) {
        Assert.hasText(key, "Cache key must not be blank");

        CACHE_CONTAINER.remove(key);
        log.debug("Removed key: [{}]", key);
    }

    @PreDestroy
    public void preDestroy() {
        log.debug("Cancelling all timer tasks");
        timer.cancel();
        clear();
    }

    private void clear() {
        CACHE_CONTAINER.clear();
    }

    /**
     * 清理缓存用的定时器
     */
    private class CacheExpiryCleaner extends TimerTask {

        @Override
        public void run() {
            CACHE_CONTAINER.keySet().forEach(key -> {
                if (!InMemoryCacheStore.this.get(key).isPresent()) {
                    log.debug("Deleted the cache: [{}] for expiration", key);
                }
            });
        }
    }
}

InMemoryCacheStore是Halo的默认缓存提供类,可以看到在父抽象类的基础上,多了:定时器去定时清理过期值、putInternalIfAbsent的具体实现。
putInternalIfAbsent方法是Halo实现接口限流的重要方法,通过key判断值是否存在或过期,来达到指定时间内只能访问一次的功能。putInternalIfAbsent方法使用了ReentrantLock锁,这是因为虽然ConcurrentHashMap本身是线程安全的,但是这里做限流功能的方法实现里,有个取值然后比较的操作,这个操作不是原子的。当多个线程进入方法取值进行判断时,可能出现if (valueOptional.isPresent())都为false的判断,然后都会put值然后返回true,这样就结果就不正确了,就无法起到限流的作用。

LevelCacheStore

缓存的内嵌式数据库实现,除了实现是Google的LevelDB数据库外,其他方法跟InMemoryCacheStore区别不大,不再描述

CacheLock、CacheLockInterceptor、CacheParam

缓存的使用:接口限流
CacheLock注解作为切点,CacheLockInterceptor为切面类,CacheParam为使用限流时的额外参数,具体的使用看Halo登陆模块详解

自定义Redis缓存实现

看了Halo的缓存模块后,虽然实现挺简单的,但是类设计上值得学习,思路清晰。不像自己有时候写着写着类的功能要么冗余了要么职责不清了。
最近在学习Redis的使用,就打算在Halo上再加一个Redis的缓存实现。用来练习一下。
新建一个RedisCacheStore类。我打算还是用Jackson的方法进行序列、反序列化,就继承与AbstractStringCacheStore吧。需要实现以下抽象方法:

    @Override
    Optional<CacheWrapper<String>> getInternal(String key) {
        return Optional.empty();
    }

    @Override
    void putInternal(String key, CacheWrapper<String> cacheWrapper) {
    }

    @Override
    Boolean putInternalIfAbsent(String key, CacheWrapper<String> cacheWrapper) {
        return null;
    }

    @Override
    public void delete(String key) {
    }

操作Redis我打算用Spring提供的RedisTemplate,由于RedisTemplate需要自己提供序列化转化器,不提供的话默认使用的是JdkSerializationRedisSerializer,它只支持实现了序列化接口的对象,如果放入String的话,存入的是String的序列化对象,可读性跟String的byte数组比差太多。于是使用RedisTemplate的子类StringRedisTemplate

在Halo的java config类run/halo/app/config/HaloConfiguration.java里加一个bean定义:

    @Bean
    public StringRedisTemplate stringRedisTemplate() {
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        // 最大空闲数
        poolConfig.setMaxIdle(50);
        // 最大连接数
        poolConfig.setMaxTotal(100);
        // 最大等待毫秒数
        poolConfig.setMaxWaitMillis(20000);
        StringRedisTemplate redisTemplate = new StringRedisTemplate();
        redisTemplate.setConnectionFactory(new JedisConnectionFactory(poolConfig));
        return redisTemplate;
    }

build.gradle加入依赖

    implementation 'org.springframework.data:spring-data-redis:2.5.0'
    implementation 'redis.clients:jedis:3.6.0'

加上实现细节

public class RedisCacheStore extends AbstractStringCacheStore {

    private StringRedisTemplate stringRedisTemplate;

    public RedisCacheStore(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    Optional<CacheWrapper<String>> getInternal(String key) {
        Assert.hasText(key, "Cache key must not be blank");
        String valueJson = stringRedisTemplate.opsForValue().get(key);
        return StringUtils.isEmpty(valueJson) ? Optional.empty() :
            jsonToCacheWrapper(valueJson);
    }

    @Override
    public Optional<String> get(String key) {
        return getInternal(key).map(cacheWrapper -> cacheWrapper.getData());
    }

    @Override
    void putInternal(String key, CacheWrapper<String> cacheWrapper) {
        try {
            if (cacheWrapper.getExpireAt() != null) {
                stringRedisTemplate.opsForValue().set(key, JsonUtils.objectToJson(cacheWrapper),
                    getDuration(cacheWrapper.getCreateAt(), cacheWrapper.getExpireAt()));
            } else {
                stringRedisTemplate.opsForValue().set(key, JsonUtils.objectToJson(cacheWrapper));
            }
        } catch (JsonProcessingException e) {
            throw new ServiceException("Failed to convert " + cacheWrapper + " to json",
                e);
        }
    }

    Duration getDuration(Date start, Date end) {
        LocalDateTime s = LocalDateTime.ofInstant(start.toInstant(), ZoneId.systemDefault());
        LocalDateTime e = LocalDateTime.ofInstant(end.toInstant(), ZoneId.systemDefault());
        return Duration.between(s, e);
    }

    @Override
    Boolean putInternalIfAbsent(String key, CacheWrapper<String> cacheWrapper) {
        try {
            if (cacheWrapper.getExpireAt() != null) {
                return stringRedisTemplate.opsForValue()
                    .setIfAbsent(key, JsonUtils.objectToJson(cacheWrapper),
                        getDuration(cacheWrapper.getCreateAt(), cacheWrapper.getExpireAt()));
            } else {
                return stringRedisTemplate.opsForValue()
                    .setIfAbsent(key, JsonUtils.objectToJson(cacheWrapper));
            }

        } catch (JsonProcessingException e) {
            throw new ServiceException("Failed to convert " + cacheWrapper + " to json",
                e);
        }
    }

    @Override
    public void delete(String key) {
        stringRedisTemplate.delete(key);
    }
}

能看到Redis的缓存实现没有定时器,因为清理过期值的工作交给了redis,不用自己写个定时器定时遍历和get取值时去判断过期值。
最后修改配置:

        switch (haloProperties.getCache()) {
            case "level":
                stringCacheStore = new LevelCacheStore(this.haloProperties);
                break;
            case "redis":
                stringCacheStore = new RedisCacheStore(stringRedisTemplate);
                break;
            case "memory":
            default:
                //memory or default
                stringCacheStore = new InMemoryCacheStore();
                break;
        }

启动后登录看下结果:
redis

总结

  1. 功能模块在设计时要有良好的抽象,便于后续功能扩展和具体实现的变更。
  2. 缓存除了缓存业务数据,内存缓存还能做限流访问的标识,因为内存速度快。
  3. Halo的内存缓存实现符合Halo的使用场景,没有什么高并发,只缓存很少的业务数据,所以以下缺点可忽略:
  • 因为是开一个线程去遍历清理过期对象,缓存多了后会时间变长
  • 缓存越来越多了后,内存吃紧时没有能让GC释放掉的机会,会OOM
    如果要实现一个避开以上缺点的缓存,可以用DelayQueue和SoftReference

Q.E.D.


我并不是什么都知道,我只是知道我所知道的。