0. 前言
Spring框架提供了一系列丰富的接口帮助我们更快捷的开发应用程序,很多功能仅需要在配置文件声明一下或者在代码写几行就能够实现了,能够使我们更注重于应用的开发上,某种意义上助长了我们的“偷懒”行为。关于缓存,很多时候我们使用Hibernate或Mybatis框架的二级缓存结合Ehcache缓存框架来提高执行效率,配置使用起来也很简单;又或者使用Redis内存型数据库,利用Jedis连接操作数据在内存中的读写,同样用起来也很简单。
然而上述两种方式的缓存,前者的范围太广(如Mybatis是mapper级别的缓存),后者又太细(字符串型的键值对)。于是,在这里,我稍微往回走一点,研究一下Spring从3.1版本出现的自定义缓存实现机制,并使用效率更高的Lettuce连接Redis,实现方法级自定义缓存。即用Lettuce做Redis的客户端连接,使用Redis作为底层的缓存实现技术,在应用层或数据层的方法使用Spring缓存标签进行数据缓存,结合Redis的可视化工具还可以看到缓存的数据信息。
1.1部分可能相当一部分人都认识,那就重点看下1.2部分的,欢迎指点。
1. 技术准备
涉及技术:
Spring 3.x 缓存注解
Lettuce 4.x Redis连接客户端
Redis 3.x +
Spring 3.x +
序列化和反序列化
1.1 Spring 3.x 缓存注解
Spring 缓存注解,即Spring Cache,作用在方法上。当我们在调用一个缓存方法时会把该方法参数和返回结果作为一个键值对存放在缓存中,等到下次利用同样的参数来调用该方法时将不再执行该方法,而是直接从缓存中获取结果进行返回。所以在使用Spring Cache的时候我们要保证我们缓存的方法对于相同的方法参数要有相同的返回结果。
要使用Spring Cache,我们需要做两件事:
在需要缓存的方法上使用缓存注解;
在配置文件声明底层使用什么做缓存。
对于第一个问题,这里我就不做介绍了,网上已经有十分成熟的文章供大家参考,主要是@Cacheable、@CacheEvict、@CachePut以及自定义键的SpEL(Spring 表达式语言,Spring Expression Language)的使用,相信部分人有从Spring Boot中了解过这些东西,详细可参考以下文章:
对于第二个问题,简单的说下,知道的可以跳过,这里有三种配置方法:
使用Spring自带的缓存实现
使用第三方的缓存实现
使用自定缓存实现
无论哪种配置方法都是在Spring的配置文件进行配置的(不要问我Spring的配置文件是什么)。首先,由于我们使用的是注解的方式,对Spring不陌生的话,都知道应该要配置个注解驱动,代码如下:
cache-manager属性的默认值是cacheManager,所以可以显示写出,在后续的CacheManage实现类的配置中使用是cacheManager作为id即可。
还有个属性proxy-target-class,默认为false,因为我们编程经常使用接口,而注解也可能用到接口上,当使用缺省配置时,注解用到接口还是类上都没有问题,但如果proxy-target-class声明为true,就只能用到类上了。
三种方法的不同体现在CacheManager类以及Cache类的实现上:
Spring自带一个SimpleCacheManager的实现,配合ConcurrentMap的配置方法如下:
使用第三方的缓存实现,如常用的EhCache:
自定义缓存实现,这里着重讲,配制方法与第一种类似,只不过实际的使用的CacheManager类以及Cache类由我们自己定义。实现CacheManager有两种方式,一是直接实现org.springframework.cache.CacheManager请输入代码`接口,该接口有两个方法:
public interface CacheManager {
/**
* Return the cache associated with the given name.
* @param name the cache identifier (must not be {@code null})
* @return the associated cache, or {@code null} if none found
*/
Cache getCache(String name);
/**
* Return a collection of the cache names known by this manager.
* @return the names of all caches known by the cache manager
*/
Collection getCacheNames();
}
很直白易懂的两个方法,根据Cache的名字获取CaChe以及获取所有Cache的名字,恰当利用好在配置文件中配置Cache时,对相应的name及实现的Cache类进行注入,在CacheManager的实现中使用成员变量,如简单的HashMap对实现的Cache进行保存即可,对Spring比较熟悉的话,其实非常的简单,当然,可以根据业务需求实现自己的逻辑,这里只是简单举例。另一种方式是继承抽象类org.springframework.cache.support.AbstractCacheManager,观看源码可发现,这是提供了一些模板方法、实现了CacheManager接口的模板类,,只需要实现抽象方法protected abstract Collection extends Cache> loadCaches();即可,下面给出我自己的一个简单实现(观看源码后惊奇的发现与SimpleCacheManager的实现一模一样):
import java.util.Collection;
import org.springframework.cache.Cache;
import org.springframework.cache.support.AbstractCacheManager;
public class RedisCacheManager extends AbstractCacheManager {
private Collection extends Cache> caches;
public void setCaches(Collection extends Cache> caches) {
this.caches = caches;
}
@Override
protected Collection extends Cache> loadCaches() {
return this.caches;
}
}
说完CacheManager,自然到了Cache的实现,方法就是直接实现Spring的接口org.springframework.cache.Cache,接口的方法有点多,网上也有不少相关文章,这里我只说下自己的看法,代码如下:
// 简单直白,就是获取Cache的名字
String getName();
// 获取底层的缓存实现对象
Object getNativeCache();
// 根据键获取值,把值包装在ValueWrapper里面,如果有必要可以附加额外信息
ValueWrapper get(Object key);
// 和get(Object key)类似,但返回值会被强制转换为参数type的类型,但我查了很多文章,
// 看了源码也搞不懂是怎么会触发这个方法的,取值默认会触发get(Object key)。
T get(Object key, Class type);
// 从缓存中获取 key 对应的值,如果缓存没有命中,则添加缓存,
// 此时可异步地从 valueLoader 中获取对应的值(4.3版本新增)
// 与缓存标签中的sync属性有关
T get(Object key, Callable valueLoader);
// 存放键值对
void put(Object key, Object value);
// 如果键对应的值不存在,则添加键值对
ValueWrapper putIfAbsent(Object key, Object value);
// 移除键对应键值对
void evict(Object key);
// 清空缓存
void clear();
下面给出的实现不需要用到 T get(Object key, Class type);和 T get(Object key, Callable valueLoader);,只是简单的输出一句话(事实上也没见有输出过)。另外存取的时候使用了序列化技术,序列化是把对象转换为字节序列的过程,对实际上是字符串存取的Redis来说,可以把字节当成字符串存储,这里不详述了,当然也可以使用GSON、Jackson等Json序列化类库转换成可读性高的Json字符串,不过很可能需要缓存的每个类都要有对应的一个Cache,可能会有十分多的CaChe实现类,但转换效率比JDK原生的序列化效率高得多,另外也可以使用简单的HashMap,方法很多,可以自己尝试。
说多一句,由于使用Lettuce连接,redis连接对象的操作和jedis或redisTemplate不同,但理解起来不难。
import java.io.UnsupportedEncodingException;
import java.util.List;
import java.util.concurrent.Callable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
import com.lambdaworks.redis.api.StatefulRedisConnection;
import com.lambdaworks.redis.api.sync.RedisCommands;
public class RedisCache implements Cache {
private String name;
private static JdkSerializationRedisSerializer redisSerializer;
@Autowired
private StatefulRedisConnection redisConnection;
public RedisCache() {
redisSerializer = new JdkSerializationRedisSerializer();
name = RedisCacheConst.REDIS_CACHE_NAME;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
// 返回redis连接看似奇葩,但redis连接就是操作底层实现缓存的对象
return getRedisConnection();
}
@Override
public ValueWrapper get(Object key) {
RedisCommands redis = redisConnection.sync();
String redisKey = (String) key;
String serializable = redis.get(redisKey);
if (serializable == null) {
System.out.println("-------缓存不存在------");
return null;
}
System.out.println("---获取缓存中的对象---");
Object value = null;
// 序列化转化成字节时,声明编码RedisCacheConst.SERIALIZE_ENCODE(ISO-8859-1),
// 否则转换很容易出错(编码为UTF-8也会转换错误)
try {
value = redisSerializer
.deserialize(serializable.getBytes(RedisCacheConst.SERIALIZE_ENCODE));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return new SimpleValueWrapper(value);
}
@Override
public T get(Object key, Class type) {
System.out.println("------未实现get(Object key, Class type)------");
return null;
}
@Override
public T get(Object key, Callable valueLoader) {
System.out.println("---未实现get(Object key, Callable valueLoader)---");
return null;
}
@Override
public void put(Object key, Object value) {
System.out.println("-------加入缓存------");
RedisCommands redis = redisConnection.sync();
String redisKey = (String) key;
byte[] serialize = redisSerializer.serialize(value);
try {
redis.set(redisKey, new String(serialize, RedisCacheConst.SERIALIZE_ENCODE));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
@Override
public ValueWrapper putIfAbsent(Object key, Object value) {
System.out.println("---未实现putIfAbsent(Object key, Object value)---");
return null;
}
@Override
public void evict(Object key) {
System.out.println("-------删除缓存 key=" + key.toString() + " ------");
RedisCommands redis = redisConnection.sync();
String redisKey = key.toString();
// RedisCacheConst.WILDCARD是Redis中键的通配符“*”,用在这里使键值删除也能使用通配方式
if (redisKey.contains(RedisCacheConst.WILDCARD)) {
List caches = redis.keys(redisKey);
if (!caches.isEmpty()) {
redis.del(caches.toArray(new String[caches.size()]));
}
} else {
redis.del(redisKey);
}
}
@Override
public void clear() {
System.out.println("-------清空缓存------");
RedisCommands redis = redisConnection.sync();
redis.flushdb();
}
public void setName(String name) {
this.name = name;
}
public StatefulRedisConnection getRedisConnection() {
return redisConnection;
}
public void setRedisConnection(StatefulRedisConnection redisConnection) {
this.redisConnection = redisConnection;
}
}
RedisCacheConst常量类
public class RedisCacheConst {
public final static String REDIS_CACHE_NAME = "Redis Cache";
public final static String SERIALIZE_ENCODE = "ISO-8859-1";
public final static String WILDCARD = "*";
public final static String SPRING_KEY_TAG = "'";
// SpEL中普通的字符串要加上单引号,如一个键设为kanarien,应为key="'kanarien'"
}
Spring配置文件
1.2 Lettuce 4.x Redis连接客户端
1.1部分讲的有点多了,我真正想讲的也就是自定义那部分,但其他部分也不能不说,咳咳。
Lettuce,在Spring Boot 2.0之前几乎没怎么听说过的词语,自Spring Boot 2.0渐渐进入国人的视野(Lettuce 5.x),因为Spring Boot 2.0默认采用Lettuce 5.x + Redis 方式实现方法级缓存,很多文章都有这么强调过。Lettuce为什么会受到Spring Boot开发人员的青睐呢?简单说来,Lettuce底层使用Netty框架,利用NIO技术,达到线程安全的并发访问,同时有着比Jedis更高的执行效率与连接速度。
Lettuce还支持使用Unix Domain Sockets,这对程序和Redis在同一机器上的情况来说,是一大福音。平时我们连接应用和数据库如Mysql,都是基于TCP/IP套接字的方式,如127.0.0.1:3306,达到进程与进程之间的通信,Redis也不例外。但使用UDS传输不需要经过网络协议栈,不需要打包拆包等操作,只是数据的拷贝过程,也不会出现丢包的情况,更不需要三次握手,因此有比TCP/IP更快的连接与执行速度。当然,仅限Redis进程和程序进程在同一主机上,而且仅适用于Unix及其衍生系统。
事实上,标题中所说的简单实现,适用于中小项目,因为中小项目不会花太多资源在硬件上,很可能Redis进程和程序进程就在同一主机上,而我们所写的程序只需要简单的实现就足够了,本篇文章介绍的东西都适用于中小项目的,而且正因为简单才易于去剖析源码,边写边学。
另外,为什么这里说的是Lettuce 4.x而不是Lettuce 5.x呢?
因为我写项目那时还没Lettuce 5.x啊,只是写这篇文章有点晚了,技术日新月异啊。4和5之间的差别还是挺大的,代码中对Redis连接方式就变了(好像?),之后再去研究下。详细可见官方文档,这里不再班门弄斧了。
下面是Lettuce 4.x的客户端连接代码(兼用TCP/IP与UDS连接方式,后者不行自动转前者),由于涉及了逻辑判断,使用了Java类进行配置而不是在xml中配置:
import java.nio.file.Files;
import java.nio.file.Paths;
import javax.annotation.PostConstruct;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import com.lambdaworks.redis.RedisClient;
import com.lambdaworks.redis.RedisURI;
import com.lambdaworks.redis.RedisURI.Builder;
import com.lambdaworks.redis.api.StatefulRedisConnection;
import com.lambdaworks.redis.resource.ClientResources;
import com.lambdaworks.redis.resource.DefaultClientResources;
@Primary
@Configuration
public class LettuceConfig {
private static RedisURI redisUri;
private final Logger log = LoggerFactory.getLogger(getClass());
@Value("${redis.host:127.0.0.1}")
private String hostName;
@Value("${redis.domainsocket:}")
private String socket;
@Value("${redis.port:6379}")
private int port;
private int dbIndex = 2;
@Value(value = "${redis.pass:}")
private String password;
@Bean(destroyMethod = "shutdown")
ClientResources clientResources() {
return DefaultClientResources.create();
}
@Bean(destroyMethod = "close")
StatefulRedisConnection redisConnection(RedisClient redisClient) {
return redisClient.connect();
}
private RedisURI createRedisURI() {
Builder builder = null;
// 判断是否有配置UDS信息,以及判断Redis是否有支持UDS连接方式,是则用UDS,否则用TCP
if (StringUtils.isNotBlank(socket) && Files.exists(Paths.get(socket))) {
builder = Builder.socket(socket);
System.out.println("connect with Redis by Unix domain Socket");
log.info("connect with Redis by Unix domain Socket");
} else {
builder = Builder.redis(hostName, port);
System.out.println("connect with Redis by TCP Socket");
log.info("connect with Redis by TCP Socket");
}
builder.withDatabase(dbIndex);
if (StringUtils.isNotBlank(password)) {
builder.withPassword(password);
}
return builder.build();
}
@PostConstruct
void init() {
redisUri = createRedisURI();
log.info("连接Redis成功!\n host:" + hostName + ":" + port + " pass:" + password + " dbIndex:" + dbIndex);
}
@Bean(destroyMethod = "shutdown")
RedisClient redisClient(ClientResources clientResources) {
return RedisClient.create(clientResources, redisUri);
}
public void setDbIndex(int dbIndex) {
this.dbIndex = dbIndex;
}
public void setHostName(String hostName) {
this.hostName = hostName;
}
public void setPassword(String password) {
this.password = password;
}
public void setPort(int port) {
this.port = port;
}
public void setSocket(String socket) {
this.socket = socket;
}
}
Java属性文件:redis.properties(仅供参考)
redis.pool.maxIdle=100
redis.pool.maxTotal=10
redis.pool.testOnBorrow=true
redis.pool.testOnReturn=true
redis.host=127.0.0.1
#redis.pass=yourpassword
redis.port=6379
redis.expire=6000
redis.domainsocket=/tmp/redis.sock
注解用得很多,说明下:
@Primary,表示优先级关系,由于源程序中涉及数据到Redis的加载,所以要设定,视情况可以不加;
@Configuration,表名这是个配置的Bean,能被Spring扫描器识别;
@Value,与Java属性文件有关,自动读取属性文件的值,括号中的内容就是键,冒号后面的是默认值;
@PostConstruct,在类加载完、依赖注入完之后才执行所修饰的方法,注意要在Spring配置文件中;
@Bean,不解释。
最后,该类要被Spring扫描识别。
1.3 Redis 3.x +
关于Redis的介绍,直接去看我的笔记,里面有一些简单又不失全面的介绍,比如Unix Domain Socket相关、一些Redis的基本配置和可视化界面等等。
2. 补充
必要的代码都给出来了,就不贴源码了,Lettuce的TCP、UDS二选一连接方式也可以单独拿出来用。
欢迎大家的指点!
Copyright © 2018, GDUT CSCW back-end Kanarien, All Rights Reserved