掃描文末二維碼或者微信搜尋公眾號
菜鳥飛呀飛
,即可關注微信公眾號,閱讀更多Spring原始碼分析文章
1. 前言
在筆者的上一篇文章中(點選此處跳轉檢視)介紹了@Import註解的使用場景和原理,以及@EnableXXX註解的實現原理,這一篇文章將通過一個自定義的@Enable註解來實現一個Redis和Spring整合的外掛。
- 以前我們在Spring中整合Redis時(非SpringBoot和redis整合,看完本文,
spring-boot-starter-data-redis
這個包的原理也基本明白了),通常第一步是先引入redis客戶端的jar包,第二步通過XML方式或者@Bean方式配置一個Jedis或者JedisCluster物件,第三步就是在程式碼中注入Jedis或者JedisCluter。這三步中,最為麻煩的是第二步。那麼有沒有一種方法能像@EnableXXX那樣滿足我們的需求呢?
2. 思路
要想完成Spring和redis的整合,我們就需要向Spring容器中新增一個Jedis或者JedisCluster這樣的bean,然後還需要為redis的客戶端設定屬性,如連線地址,埠號等。那麼我們可以自定義一個@EnableJedisClient或者@EnableJedisClusterClient註解,讓這兩個註解來達到我們的目的。
- 自定義@EnableJedisClient和@EnableJedisClusterClient註解,讓這兩個註解來向容器中註冊Jedis和JedisCluster。
- 由於@Enable註解通常是結合@Import註解使用的,而@Import註解能幫助我們向容器中註冊Bean,但是沒辦法為Bean的屬性賦值,因為Import註解的處理只能干預BeanFactory的建造過程,不能參與Bean的建立過程,例如不能參與為Bean的屬性賦值等操作。
- 既然想要參與Bean的建立過程,為Bean的屬性賦值,那麼我們可以通過BeanPostProcessor來參與Bean的建立過程,建立JedisClientBeanPostProcessor和JedisClusterClientBeanPostProcessor類分別為Jedis和JedisCluster來設定屬性,這兩個類均實現了BeanPostProcessor介面。
3. 程式碼實現
先實現Jedis的整合,再實現JedisCluster的整合。前者是針對單機版的redis,後者是針對叢集版的redis。
- pom依賴
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.1.8.RELEASE</version>
<!-- 如果該專案是準備作為一個第三方外掛的話,這裡對spring的依賴範圍最好指定為provided-->
<!--<scope>provided</scope>-->
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.1.0</version>
</dependency>
複製程式碼
3.1 @EnableJedisClient
- 首先定義一個EnableJedisClient註解,在註解中通過Import註解匯入了JedisClientImportRegistrar類。並且為EnableJedisClient新增了一個屬性:namespace,新增該屬性的目的是為了讓專案中能同時引入多個redis。例如:在專案中需要同時連線兩個不同的redis機器,那麼這個時候就可以通過namespace來區分。
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(JedisClientImportRegistrar.class)
public @interface EnableJedisClient {
String namespace() default "default";
}
複製程式碼
- JedisClientImportRegistrar類實現了ImportBeanDefinitionRegistrar介面,重寫了registerBeanDefinitions()方法,在方法中向Spring容器中註冊了兩個Bean,一個是Jedis,一個是JedisClientBeanPostProcessor後置處理器,註冊該後置處理器是為了在後面Jedis初始化的過程中,為jedis設定連線地址,埠號等屬性。
- Jedis在容器中的beanName是 namespace + "Jedis",namespace的值是從EnableJedisClient註解中獲取到的。例如如下示例使用:那麼此時的namespce的值為demo,如果不指定,則為default。
@EnableJedisClient(namespace = "demo")
public class AppConfig {
}
複製程式碼
- 原始碼如下
public class JedisClientImportRegistrar implements ImportBeanDefinitionRegistrar {
public void registerBeanDefinitions(AnnotationMetadata annotationMetadata, BeanDefinitionRegistry beanDefinitionRegistry) {
Map<String, Object> annotationAttributes = annotationMetadata.getAnnotationAttributes(EnableJedisClient.class.getName());
AnnotationAttributes attributes = AnnotationAttributes.fromMap(annotationAttributes);
String namespace = attributes.getString("namespace");
// 建立jedis的BeanDefinition,然後註冊進容器中,beanName為namespace + "Jedis"
BeanDefinitionBuilder jedisBeanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(Jedis.class);
AbstractBeanDefinition jedisBeanDefinition = jedisBeanDefinitionBuilder.getBeanDefinition();
beanDefinitionRegistry.registerBeanDefinition(namespace+Jedis.class.getSimpleName(),jedisBeanDefinition);
// 向容器註冊一個Jedis的後置處理器,這是為了讓後置處理器為Jedis的屬性賦值
BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(JedisClientBeanPostProcessor.class);
beanDefinitionRegistry.registerBeanDefinition(JedisClientBeanPostProcessor.class.getSimpleName(),beanDefinitionBuilder.getBeanDefinition());
}
}
複製程式碼
- JedisClientBeanPostProcessor實現了BeanPostProcessor和EnvironmentAware介面,實現EnvironmentAware介面是為了獲取到配置檔案中相關配置。重寫了postProcessBeforeInitialization(),在該方法中,先讀取了配置檔案中的redis配置,然後為Jedis物件賦值。原始碼如下:
public class JedisClientBeanPostProcessor implements BeanPostProcessor,EnvironmentAware {
private static String JEDIS_ADDRESS_PREFIX = "jedis.url";
private static String JEDIS_PORT_PREFIX = "jedis.port";
private Environment environment;
public void setEnvironment(Environment environment) {
this.environment = environment;
}
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if(bean instanceof Jedis){
// 通過beanName獲取到namespace
String prefix = beanName.substring(0, beanName.indexOf(Jedis.class.getSimpleName()));
// 獲取配置檔案的配置,配置的規則為: namespace + "." + jedis.url + address|port
// 示例:demo.jedis.url.address = 127.0.0.1
String addressKey = prefix + "." + JEDIS_ADDRESS_PREFIX;
String address = environment.getProperty(addressKey);
Assert.isTrue(!StringUtils.isEmpty(address),String.format("%s can not be null!!! value = %s",addressKey,address));
String portKey = prefix + "." + JEDIS_PORT_PREFIX;
String port = environment.getProperty(portKey);
Assert.isTrue(!StringUtils.isEmpty(port),String.format("%s can not be null!!! value = = %s",portKey,port));
// 如果有需要,可以在從配置中新增redis的配置,然後在此處獲取即可。
JedisPool jedisPool = new JedisPool(address,Integer.parseInt(port));
((Jedis)bean).setDataSource(jedisPool);
}
return bean;
}
}
複製程式碼
- 測試
@Configuration
// 開啟單機版redis的功能
@EnableJedisClient(namespace = "demo")
// 匯入配置檔案,如果專案中配置是在Apollo或者SpringCloudConfig等配置中心,則不用匯入
@PropertySource("config.properties")
public class AppConfig {
}
複製程式碼
- 配置檔案config.properties
### 單機版redis配置,配置的字首注意要和namespace中的值一樣
### redis地址和埠號改為自己的即可
demo.jedis.url = 127.0.0.1
demo.jedis.port = 6379
複製程式碼
- 啟動類
public class MainApplication {
public static void main(String[] args) {
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
System.out.println("============ 測試單機版redis =================");
Jedis jedis = applicationContext.getBean(Jedis.class);
String key = "test:key1";
String value = "value1";
String writeResult = jedis.setex(key, 3600, value);
System.out.println("向redis中寫入資料,result = " + writeResult);
String readResult = jedis.get(key);
System.out.println("從redis中讀取資料,result = " + readResult);
}
}
複製程式碼
- 控制檯列印的結果
3.2 @EnableJedisClusterClient
@EnableJedisClusterClient註解是用來開啟redis叢集功能的註解。邏輯與@EnableJedisClient一樣,只不過針對叢集,需要額外做一些處理,需要提供根據key計算槽位,然後根據槽位獲取Jedis例項的方法。
- 首先定義一個EnableJedisClusterClient註解,在註解中通過Import註解匯入了JedisClusterClientImportRegistrar類,為EnableJedisClusterClient新增了一個屬性:namespace,新增該屬性的目的是為了讓專案中能同時引入多個redis叢集。例如:在專案中需要同時連線兩個不同的redis叢集機器,那麼這個時候就可以通過namespace來區分,不通的redis叢集指定不同的namespace。
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(JedisClusterClientImportRegistrar.class)
public @interface EnableJedisClusterClient {
String namespace() default "default";
}
複製程式碼
- JedisClusterClientImportRegistrar 類實現了ImportBeanDefinitionRegistrar介面,重寫了registerBeanDefinitions()方法,在方法中向Spring容器中註冊了兩個Bean,一個是JedisClusterClient,一個是JedisClusterClientBeanPostProcessor後置處理器,該後置處理器是為了在後面JedisClusterClient初始化的過程中,為JedisClusterClient設定連線地址,埠號等屬性。
- JedisClusterClient是自定義的一個類,該類持有了對JedisCluster的引用。為什麼要自定義這個類呢?因為對於redis叢集,我們需要根據槽位來獲取jedis物件,不清楚redis叢集的朋友,可以先百度查閱一下關於redis叢集的知識,後面會寫一些關於redis文章的。
- JedisClusterClient在容器中的beanName是 namespace + "JedisClusterClient",namespace的值是從EnableJedisClusterClient註解中獲取到的。例如如下示例使用:那麼此時的namespce的值為demo-cluster,如果不指定,則為default。
@EnableJedisClusterClient(namespace = "demo-cluster")
public class AppConfig {
}
複製程式碼
- JedisClusterClientImportRegistrar原始碼如下
public class JedisClusterClientImportRegistrar implements ImportBeanDefinitionRegistrar {
public void registerBeanDefinitions(AnnotationMetadata annotationMetadata, BeanDefinitionRegistry beanDefinitionRegistry) {
// 註冊JedisCluster的後置處理器,用來填充屬性
BeanDefinitionBuilder postProcessorBuilder = BeanDefinitionBuilder.genericBeanDefinition(JedisClusterClientBeanPostProcessor.class);
beanDefinitionRegistry.registerBeanDefinition(JedisClusterClientBeanPostProcessor.class.getSimpleName(),postProcessorBuilder.getBeanDefinition());
// 獲取namespace,用來指定JedisCluster的beanName
AnnotationAttributes attributes = AnnotationAttributes.fromMap(annotationMetadata.getAnnotationAttributes(EnableJedisClusterClient.class.getName()));
String namespace = attributes.getString("namespace");
// 註冊jedisCluster
BeanDefinitionBuilder clusterBuilder = BeanDefinitionBuilder.genericBeanDefinition(JedisClusterClient.class);
beanDefinitionRegistry.registerBeanDefinition(namespace+JedisClusterClient.class.getSimpleName(),clusterBuilder.getBeanDefinition());
}
}
複製程式碼
- JedisClusterClientBeanPostProcessor實現了BeanPostProcessor和EnvironmentAware介面,在重寫的方法中,讀取了配置檔案中和redis叢集相關的配置,然後呼叫了JedisClusterClient的有參構造方法,new了一個JedisClusterClient。在JedisClusterClient的有參構造方法中,完成了一些對redis叢集客戶端的初始化操作。
public class JedisClusterClientBeanPostProcessor implements BeanPostProcessor,EnvironmentAware {
private static String JEDIS_ADDRESS_PREFIX = "jedis.cluster.address";
private static String JEDIS_MIN_IDEL_PREFIX = "jedis.cluster.minIdel";
private static String JEDIS_MAX_IDEL_PREFIX = "jedis.cluster.maxIdel";
private static String JEDIS_MAX_TOTAL_PREFIX = "jedis.cluster.maxTotal";
private Environment environment;
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if(bean instanceof JedisClusterClient){
String namespace = beanName.substring(0,beanName.indexOf(JedisClusterClient.class.getSimpleName()));
String addressKey = namespace + "." + JEDIS_ADDRESS_PREFIX;
String address = environment.getProperty(addressKey);
Assert.isTrue(!StringUtils.isEmpty(address),String.format("%s can not be mull!!!! value = %s",addressKey,address));
// 可以從配置檔案中獲取到redis的maxIdle、maxTotal、minIdle等配置,然後封裝到poolConfig中
JedisPoolConfig poolConfig = new JedisPoolConfig();
Integer minIdel = environment.getRequiredProperty(namespace + "." + JEDIS_MIN_IDEL_PREFIX, Integer.class);
Integer maxIdel = environment.getRequiredProperty(namespace + "." + JEDIS_MAX_IDEL_PREFIX, Integer.class);
Integer maxTotal = environment.getRequiredProperty(namespace + "." + JEDIS_MAX_TOTAL_PREFIX, Integer.class);
poolConfig.setMinIdle(minIdel);
poolConfig.setMaxIdle(maxIdel);
poolConfig.setMaxTotal(maxTotal);
// TODO 還有其他的一些屬性,也可以在這兒設定
JedisClusterClient jedisClusterClient = new JedisClusterClient(address,poolConfig);
return jedisClusterClient;
}
return bean;
}
public void setEnvironment(Environment environment) {
this.environment = environment;
}
}
複製程式碼
- JedisClusterClient類實現了JedisClusterCommands,MultiKeyJedisClusterCommands,JedisClusterScriptingCommands這三個介面,這三個介面是redis的jar包提供的類,裡面提供了redis的所有和資料相關操作的方法。在JedisClusterClient中我們需要重寫這些介面中的方法,由於方法太多,這裡只展示一部分程式碼。
public class JedisClusterClient implements JedisClusterCommands,
MultiKeyJedisClusterCommands, JedisClusterScriptingCommands {
private JedisCluster jedisCluster;
private JedisPoolConfig jedisPoolConfig;
private JedisSlotBasedConnectionHandler handler;
private final int defaultConnectTimeout = 2000;
private final int defaultConnectMaxAttempts = 20;
/**
* 為什麼在這裡提供一個無參構造器呢?
* 因為在Spring在例項化bean時,是推斷出類的構造器,然後根據類的構造器來反射建立bean,
* 如果不提供預設的無參構造器,那麼Spring就會使用JedisClusterClient的有參構造器。
* 然而,有參構造器中需要namespace,address,poolConfig等引數。
* 此時,Spring就會從Spring容器中根據引數的型別去獲取Bean,獲取不到就會報錯。
* 所以這裡特意提供了一個無參構造器
*/
public JedisClusterClient(){}
public JedisClusterClient(String address, JedisPoolConfig poolConfig) {
this.jedisPoolConfig = poolConfig;
// 解析redis配置的地址
String[] addressArr = address.split(",");
Set<HostAndPort> hostAndPortSet = new HashSet<HostAndPort>(addressArr.length);
for (String url : addressArr) {
String[] split = url.split(":");
String host = split[0];
int port = Integer.parseInt(split[1]);
hostAndPortSet.add(new HostAndPort(host,port));
}
// 例項化jedisCluster
this.jedisCluster = new JedisCluster(hostAndPortSet,defaultConnectTimeout,defaultConnectMaxAttempts,jedisPoolConfig);
try {
// 根據反射獲取到connectionHandler的值
// 目的是為了在後面通過它根據槽位獲取redis例項
Field connectionHandlerField = BinaryJedisCluster.class.getDeclaredField("connectionHandler");
connectionHandlerField.setAccessible(true);
this.handler = (JedisSlotBasedConnectionHandler) connectionHandlerField.get(jedisCluster);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
/**
* 在redis叢集中,根據key計算出key在哪一個slot,然後獲取該slot所屬於哪一個臺redis機器
* @param key
* @return
*/
public Jedis getResource(String key){
int slot = JedisClusterCRC16.getSlot(key);
return handler.getConnectionFromSlot(slot);
}
public String set(String key, String value) {
return jedisCluster.set(key,value);
}
public String set(String key, String value, SetParams params) {
return jedisCluster.set(key, value, params);
}
public String get(String key) {
return jedisCluster.get(key);
}
// 其他的方法都是直接呼叫jedisCluster對應的方法即可
}
複製程式碼
- 測試redis叢集
@Configuration
// 開啟叢集版redis的功能,namespace指定為demo-cluster,所以配置連線地址等屬性時,需要以demo-cluster為字首
@EnableJedisClusterClient(namespace = "demo-cluster")
@PropertySource("config.properties")
public class AppConfig {
}
複製程式碼
- 配置檔案config.properties
demo-cluster.jedis.cluster.address = redis001:6379,redis003:6379,redis003:6379
demo-cluster.jedis.cluster.minIdel = 1
demo-cluster.jedis.cluster.maxIdel = 10
demo-cluster.jedis.cluster.maxTotal = 30
複製程式碼
- 啟動類
public class MainApplication {
public static void main(String[] args) {
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
// 測試jedis-cluster
System.out.println("============= 測試叢集版redis ================");
JedisClusterClient jedisCluster = applicationContext.getBean(JedisClusterClient.class);
String clusterKey = "test:{1000005}";
String clusterValue = "" + System.currentTimeMillis();
String res1 = jedisCluster.setex(clusterKey, 3600, clusterValue);
System.out.println("向redis叢集中寫資料,result = " + res1);
String res2 = jedisCluster.get(clusterKey);
System.out.println("從redis叢集中獲取資料,result = " + res2);
// 測試redis事務
System.out.println("============= 測試redis叢集事務 ==============");
Jedis resource = jedisCluster.getResource(clusterKey);
try {
if(resource.watch(clusterKey).equalsIgnoreCase("OK")){
Transaction transaction = resource.multi();
String tmp = System.currentTimeMillis() + "";
System.out.println("tmp = " + tmp);
transaction.setex(clusterKey, 3600, tmp);
List<Object> exec = transaction.exec();
System.out.println(exec);
}
}finally {
if(resource != null){
resource.unwatch();
resource.close();
}
}
// 在此獲取clusterKey的值,驗證通過事務是否更新了快取中的值
System.out.println("after watch, result = " + jedisCluster.get(clusterKey));
}
}
複製程式碼
- 控制檯列印結果
4. 總結
- 本文通過自定義兩個註解@EnableJedisClient和@EnableJedisClusterClient,實現了Redis和Spring的整合,並對其進行了測試。
- 程式碼中存在的不足之處,因為只是簡易版的外掛,所以在兩個後置處理中,對配置屬性的讀取和賦值,不夠靈活,例如在本文中,想要對redis客戶端的test-on-return屬性配置指定值,則在本文是沒有實現的。解決辦法可以是,利用SpringBoot中的Binder來實現屬性的繫結,這樣只要在配置檔案中配置了,就能夠對JedisPool中對應的屬性賦值了。
- 另外在本文中是利用了兩個BeanPostProcessor對Jedis和JedisCluster進行了初始化操作,實際上還可以有其他的解決辦法。例如:在通過ImportBeanDefinitionRegistrar向容器中註冊Bean時,我們可以註冊一個FactoryBean,如自定義一個JedisFactoryBean。然後在JedisFactoryBean的getObject()方法中去完成對Jedis的初始化操作。這樣在第一次從容器中獲取Jedis物件的時候,就會呼叫到JedisFactoryBean的getObject()方法,這樣Jedis就完成了初始化操作。(注意:FactoryBean的getObject()方法返回的Bean,是在第一次獲取Bean的時候才進行的例項化操作,例項化完成後,會放入到Spring的一個快取中),關於FactoryBean的知識,感興趣的朋友可以先自行百度一下,後面也會單獨寫文章分析。
5. 原始碼地址
- 本文中所有原始碼都已上傳至GitHub。地址為:github.com/TianTang201…
- 或者點選此處跳轉:跳轉至GitHub
6. 推薦
最後推薦一款本人所在公司開源的效能監控工具——
Pepper-Metrics
- 地址: github.com/zrbcool/pep…
- 或者 點選此處跳轉
Pepper-Metrics
是坐我對面的兩位同事一起開發的開源元件,主要功能是通過比較輕量的方式與常用開源元件(jedis/mybatis/motan/dubbo/servlet
)整合,收集並計算metrics
,並支援輸出到日誌及轉換成多種時序資料庫相容資料格式,配套的grafana dashboard
友好的進行展示。專案當中原理文件齊全,且全部基於SPI
設計的可擴充套件式架構,方便的開發新外掛。另有一個基於docker-compose
的獨立demo
專案可以快速啟動一套demo
示例檢視效果https://github.com/zrbcool/pepper-metrics-demo
。如果大家覺得有用的話,麻煩給個star
,也歡迎大家參與開發,謝謝:)
掃描下方二維碼即可關注微信公眾號
菜鳥飛呀飛
,一起閱讀更多Spring原始碼。