手寫一個Redis和Spring整合的外掛

天堂同志發表於2019-09-21

掃描文末二維碼或者微信搜尋公眾號菜鳥飛呀飛,即可關注微信公眾號,閱讀更多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);
    }
}
複製程式碼
  • 控制檯列印的結果
    單機版redis測試結果

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));

    }
}
複製程式碼
  • 控制檯列印結果
    叢集版redis測試結果

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. 原始碼地址

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原始碼。

微信公眾號

相關文章