從原始碼研究如何不重啟Springboot專案實現redis配置動態切換

強哥叨逼叨發表於2020-06-04

上一篇Websocket的續篇暫時還沒有動手寫,這篇算是插播吧。今天講講不重啟專案動態切換redis服務。

背景

多個專案或微服務場景下,各個專案都需要配置redis資料來源。但是,每當運維搞事時(修改redis服務地址或埠),各個專案都需要進行重啟才能連線上最新的redis配置。服務一多,修改各個專案配置然後重啟專案就非常蛋疼。所以我們想要找到一個可行的解決方案,能夠不重啟專案的情況下,修改配置,動態切換redis服務。

如何實現切換redis連線

剛遇到這個問題的時候,想必如果對spring-boot-starter-data-redis不是很熟悉的人,首先想到的就是去百度一下(安慰下自己:不要重複造輪子嘛)。

可是一陣百度之後,你找到的結果可能都是這樣的:

public ValueOperations updateRedisConfig() {
    JedisConnectionFactory jedisConnectionFactory = (JedisConnectionFactory) stringRedisTemplate.getConnectionFactory();
    jedisConnectionFactory.setDatabase(db);
    stringRedisTemplate.setConnectionFactory(jedisConnectionFactory);
    ValueOperations valueOperations = stringRedisTemplate.opsForValue();
    return ValueOperations;

沒錯,絕大多數都是切換redis db的程式碼,而沒有切redis服務地址或賬號密碼的。而且天下程式碼一大抄,大多數部落格都是一樣的內容,這就讓人很噁心。

沒辦法,網上沒有,只能自己造輪子了。不過,從強哥這種懶人思維來說,上面的程式碼既然能切庫,那是不是host、username、password也同樣可以,於是我們加入如下程式碼:

public ValueOperations updateRedisConfig() {
    JedisConnectionFactory jedisConnectionFactory = (JedisConnectionFactory) stringRedisTemplate.getConnectionFactory();
    jedisConnectionFactory.setDatabase(db);
    jedisConnectionFactory.setHostName(host);
    jedisConnectionFactory.setPort(port);
    jedisConnectionFactory.setPassword(password);
    stringRedisTemplate.setConnectionFactory(jedisConnectionFactory);
    ValueOperations valueOperations = stringRedisTemplate.opsForValue();
    return valueOperations;
}

話不多說,改完重啟一下。額,執行結果並沒有讓我們見證奇蹟的時刻。在呼叫updateRedisConfig方法的之後,使用redisTemplate還是隻能切換db,不能進行服務地址或賬號密碼的更新。

這就讓人頭疼了,不過想也沒錯,如果可以的話,網上不應該找不到類似的程式碼。那麼,現在該咋辦嘞?

強哥的想法是:redisTemplate每次獲取ValueOperations執行get/set方法的時候,都會去連線redis伺服器,那麼我們就從這兩個方法入手看看能不能找得到解決方案。

接下來就是原始碼研究的過程啦,有耐心的小夥伴就跟著強哥一起找,只想要結果的就跳到文末吧~

首先來看看入手工具方法set:

 
public boolean set(final String key, Object value) {
  boolean result = false;
  try {
          ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
          operations.set(key, value);
          result = true;
      } catch (Exception e) {
          logger.error("set cache error:", e);
      }
  return result;
}

我們進入到operations.set(key, value);的set方法實現:

public boolean set(String key, Object value) {
        boolean result = false;
    try {
        ValueOperations<Serializable, Object> operations = this.redisTemplate.opsForValue();
        operations.set(key, value);
        result = true;
    } catch (Exception var5) {
      this.logger.error("set error:", var5);
    }
    return result;
}

哦,走的是execute方法,進去看看,具體呼叫的是AbstractOperations的RedisTemplate的execute方法(中間跳過幾個過載方法跳轉):

public <T> T execute(RedisCallback<T> action, boolean exposeConnection, boolean pipeline) {
    Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it");
    Assert.notNull(action, "Callback object must not be null");
    RedisConnectionFactory factory = getConnectionFactory();
    RedisConnection conn = null;
    try {
      if (enableTransactionSupport) {
// only bind resources in case of potential transaction synchronization
        conn = RedisConnectionUtils.bindConnection(factory, enableTransactionSupport);
      } else {
        conn = RedisConnectionUtils.getConnection(factory);
      }
      boolean existingConnection = TransactionSynchronizationManager.hasResource(factory);
      RedisConnection connToUse = preProcessConnection(conn, existingConnection);
      boolean pipelineStatus = connToUse.isPipelined();
      if (pipeline && !pipelineStatus) {
        connToUse.openPipeline();
      }
      RedisConnection connToExpose = (exposeConnection ? connToUse : createRedisConnectionProxy(connToUse));
      T result = action.doInRedis(connToExpose);
      // close pipeline
      if (pipeline && !pipelineStatus) {
        connToUse.closePipeline();
      }
      // TODO: any other connection processing?
      return postProcessResult(result, connToUse, existingConnection);
    } finally {
      RedisConnectionUtils.releaseConnection(conn, factory);
    }
}

方法內容很長,不過大致可以看出前面是獲取一個RedisConnection物件,後面應該就是命令的執行,為什麼說應該?因為強哥也沒去細看後面的實現,因為我們要關注的就是怎麼拿到這個RedisConnection物件的。

那麼我們走RedisConnectionUtils.getConnection(factory);這句程式碼進去看看,為什麼我知道是走這句而不是上面那句,因為強哥沒開事務,如果大家有打斷點,應該預設也是走的這句,跳到具體的實現方法:RedisConnectionUtils.doGetConnection(……):

public static RedisConnection doGetConnection(RedisConnectionFactory factory, boolean allowCreate, boolean bind,
boolean enableTransactionSupport) {
    Assert.notNull(factory, "No RedisConnectionFactory specified");
    RedisConnectionHolder connHolder = (RedisConnectionHolder) TransactionSynchronizationManager.getResource(factory);
    if (connHolder != null) {
      if (enableTransactionSupport) {
        potentiallyRegisterTransactionSynchronisation(connHolder, factory);
      }
      return connHolder.getConnection();
    }
    if (!allowCreate) {
      throw new IllegalArgumentException("No connection found and allowCreate = false");
    }
    if (log.isDebugEnabled()) {
      log.debug("Opening RedisConnection");
    }
    RedisConnection conn = factory.getConnection();
    if (bind) {
      RedisConnection connectionToBind = conn;
      if (enableTransactionSupport && isActualNonReadonlyTransactionActive()) {
        connectionToBind = createConnectionProxy(conn, factory);
      }
      connHolder = new RedisConnectionHolder(connectionToBind);
      TransactionSynchronizationManager.bindResource(factory, connHolder);
      if (enableTransactionSupport) {
        potentiallyRegisterTransactionSynchronisation(connHolder, factory);
      }
      return connHolder.getConnection();
    }
    return conn;
  }

程式碼還是很長,話不多說,斷點走的這句:RedisConnection conn = factory.getConnection();那就看看其實現方法吧:JedisConnectionFactory.getConnection(),這個是個關鍵方法:

public RedisConnection getConnection() {
 if (cluster != null) {
   return getClusterConnection();
 }
 Jedis jedis = fetchJedisConnector();
 JedisConnection connection = (usePool ? new JedisConnection(jedis, pool, dbIndex, clientName)
     : new JedisConnection(jedis, null, dbIndex, clientName));
 connection.setConvertPipelineAndTxResults(convertPipelineAndTxResults);
 return postProcessConnection(connection);
}

看到了,程式碼很短,但是我們從中可以獲取到的內容卻很多:

第一個判斷是是否有叢集,這個強哥專案暫時沒用,所以不管;如果大家有用到,可能要要考慮下里面的程式碼。

Jedis物件是在這裡建立的,熟悉redis的應該都知道:Jedis是Redis官方推薦的Java連線開發工具。直接用它就能執行redis命令。

usePool 這個變數,說明我們連線的redis伺服器的時候可能用到了連線池;不知道大家看到usePool會不會有種恍然醒悟的感覺,很可能就是因為我們使用了連線池,所以即使我們之前的程式碼中切換了賬號密碼,連線池的連線還是沒有更新導致的處理無效。

我們先看看fetchJedisConnector方法實現:

protected Jedis fetchJedisConnector() {
  try {
    if (usePool && pool != null) {
      return pool.getResource();
    }
 
    Jedis jedis = new Jedis(getShardInfo());
  // force initialization (see Jedis issue #82)
    jedis.connect();
  
    potentiallySetClientName(jedis);
    return jedis;
  } catch (Exception ex) {
throw new RedisConnectionFailureException("Cannot get Jedis connection", ex);
  }
}

哦,可以看到,Jedis物件是根據getShardInfo()構建出來的:

public BinaryJedis(JedisShardInfo shardInfo) {
  this.client = new Client(shardInfo.getHost(), shardInfo.getPort(), shardInfo.getSsl(), shardInfo.getSslSocketFactory(), shardInfo.getSslParameters(), shardInfo.getHostnameVerifier());
  this.client.setConnectionTimeout(shardInfo.getConnectionTimeout());
  this.client.setSoTimeout(shardInfo.getSoTimeout());
  this.client.setPassword(shardInfo.getPassword());
  this.client.setDb((long)shardInfo.getDb());
}

那就是說,只要我們掌握了這個JedisShardInfo的由來,我們就可以實現redis相關配置的切換。而這個getShardInfo()方法就是返回了JedisConnetcionFactory類的JedisShardInfo shardInfo屬性:

public JedisShardInfo getShardInfo() {
  return shardInfo;
}

那麼如果我們知道了這個shardInfo是如何建立的,是不是就可以干預到RedisConnect的建立了呢?我們來找找它被建立的地方:
image.png
走的JedisConnectionFactory的afterPropertiesSet()進去看看:

/*
  * (non-Javadoc)
  * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet()
  */
public void afterPropertiesSet() {
 if (shardInfo == null) {
   shardInfo = new JedisShardInfo(hostName, port);
   if (StringUtils.hasLength(password)) {
     shardInfo.setPassword(password);
   }  
   if (timeout > 0) {
       setTimeoutOn(shardInfo, timeout);
     }
   }

   if (usePool && clusterConfig == null) {
     this.pool = createPool();
   }
 
   if (clusterConfig != null) {
     this.cluster = createCluster();
   }
}

哦吼~,整篇博文最關鍵的程式碼終於出現了。我們可以看到,JedisShardInfo的所有資訊都是從JedisConnetionFactory的屬性中來的,包括hostName、port、password、timeout等。而且,如果JedisShardInfo為null時,呼叫afterPropertiesSet方法會幫我們建立出來。然後,該方法還會幫我們建立新的連線池,簡直完美。最最重要的是,這個方法是public的。

所以,嘿嘿,綜上,我們總結改造的幾個點:

1.連線redis用到了連線池,需要先給他銷燬;

2.建立Jedis的時候,將JedisShardInfo先設為null;

3.手動設定JedisConnetionFactory的hostName、port、password等資訊;

4.呼叫JedisConnetionFactory的afterPropertiesSet方法建立JedisShardInfo;

5.給RedisTemplate設定處理後的JedisConnetionFactory,這樣在下次使用set或get方法的時候就會去建立新改配置的連線池啦。

實現如下:

public void updateRedisConfig() {
  RedisTemplate template = (RedisTemplate) applicationContext.getBean("redisTemplate");
  JedisConnectionFactory redisConnectionFactory = (JedisConnectionFactory) template.getConnectionFactory();
//關閉連線池
  redisConnectionFactory.destroy();
  redisConnectionFactory.setShardInfo(null);
  redisConnectionFactory.setHostName(host);
  redisConnectionFactory.setPort(port);
  redisConnectionFactory.setPassword(password);
  redisConnectionFactory.setDatabase(database);
  //重新建立連線池
  redisConnectionFactory.afterPropertiesSet();
  template.setConnectionFactory(redisConnectionFactory);
}

重啟專案之後,呼叫這個方法,就可以實現redis庫及服務地址、賬號密碼的切換而無需重啟專案了。

如何實現動態切換

強哥這裡就使用同一配置中心Apollo來進行動態配置的。

首先不懂Apollo是什麼的同學,先Apollo官網半日遊吧(直接看官網教程,比看其他部落格強)。簡單的說就是一個統一配置中心,將原來配置在專案本地的配置(如:Spring中的application.properties)遷移到Apollo上,實現統一的管理。

使用Apollo的原因,其實就是因為其接入簡單,且具有實時更新回撥的功能,我們可以監聽Apollo上的配置修改,實現針對修改的配置內容進行相應的回撥監聽處理。

因此我們可以將redis的配置資訊配置在Apollo上,然後監聽這些配置。當Apollo上的這些配置修改時,我們在ConfigChangeListener中,呼叫上面的updateRedisConfig方法就可以實現redis配置的動態切換了。

接入Apollo程式碼非常簡單:

Config redisConfig = ConfigService.getConfig("redis");
ConfigChangeListener listener = this::updateRedisConfig;
redisConfig.addChangeListener(listener);

這樣,我們就可以實現具體所謂的動態更新配置啦~

當然,其他有相同功能的配置中心其實也可以,只是強哥專案中暫時用的就是Apollo就拿Apollo來講了。

考慮到篇幅已經很長了,就不多解釋Apollo的使用了,用過的自然看得懂上面的方法,有不懂的也可以留言提問哦。

好了,就到這吧,原創不易,怎麼支援你們知道,那麼下次見啦

關注公眾號獲取更多內容,有問題也可在公眾號提問哦:
image

強哥叨逼叨

叨逼叨程式設計、網際網路的見解和新鮮事

相關文章