前言:git
雖然強烈推薦選擇使用國內開源的配置中心,如攜程開源的 Apollo 配置中心、阿里開源的 Nacos 註冊&配置中心。程序員
但實際架構選型時,根據實際項目規模、業務複雜性等因素,有的項目仍是會選擇 Spring Cloud Config,也是 Spring Cloud 官網推薦的。特別是對性能要求也不是很高的場景,Spring Cloud Config 還算是好用的,基本可以知足需求,經過 Git 自然支持版本控制方式管理配置。github
並且,目前 github 社區也有小夥伴針對 Spring Cloud Config 一些「缺陷」,開發了簡易的配置管理界面,而且也已開源,如 spring-cloud-config-admin,也是超哥(程序員DD)傑做,該項目地址:https://dyc87112.github.io/spring-cloud-config-admin-doc/spring
本文所使用的 Spring Cloud 版本:Edgware.SR3,Spring Boot 版本:1.5.10.RELEASE數組
問題分析:服務器
我的認爲這個問題是有表明性的,也能基於該問題,瞭解到官網是如何改進的。使用 Spring Cloud Config 過程當中,若是遇到配置中心服務器遷移,可能會遇到 DD 這篇博客所描述的問題:http://blog.didispace.com/Spring-Cloud-Config-Server-ip-change-problem/網絡
我這裏大概簡述下該文章中提到的問題:架構
當使用的 Spring Cloud Config 配置中心節點遷移或容器化方式部署(IP 是變化的),Config Server 端會由於健康檢查失敗報錯,檢查失敗是由於使用的仍是遷移以前的節點 IP 致使。app
本文結合這個問題做爲切入點,繼續延伸下,並結合源碼探究下緣由以及改進措施。負載均衡
前提條件是使用了 DiscoveryClient 服務註冊發現,若是咱們使用了 Eureka 做爲註冊中心,其實現類是 EurekaDiscoveryClient客戶端經過 Eureka 鏈接配置中心,須要作以下配置:
spring.cloud.config.discovery.service-id=config-server
spring.cloud.config.discovery.enabled=true複製代碼
這裏的關鍵是 spring.cloud.config.discovery.enabled 配置,默認值是 false,設置爲 true 表示激活服務發現,最終會由 DiscoveryClientConfigServiceBootstrapConfiguration 啓動配置類來查找配置中心服務。
接下來咱們看下這個類的源碼:
@ConditionalOnProperty(value = "spring.cloud.config.discovery.enabled", matchIfMissing = false)
@Configuration
// 引入工具類自動配置類
@Import({ UtilAutoConfiguration.class })
// 開啓服務發現
@EnableDiscoveryClient
public class DiscoveryClientConfigServiceBootstrapConfiguration {
@Autowired
private ConfigClientProperties config;
@Autowired
private ConfigServerInstanceProvider instanceProvider;
private HeartbeatMonitor monitor = new HeartbeatMonitor();
@Bean
public ConfigServerInstanceProvider configServerInstanceProvider(
DiscoveryClient discoveryClient) {
return new ConfigServerInstanceProvider(discoveryClient);
}
// 上下文刷新事件監聽器,當服務啓動或觸發 /refresh 或觸發消息總線的 /bus/refresh 後都會觸發該事件
@EventListener(ContextRefreshedEvent.class)
public void startup(ContextRefreshedEvent event) {
refresh();
}
// 心跳事件監聽器,這個監聽事件是客戶端從Eureka中Fetch註冊信息時觸發的。
@EventListener(HeartbeatEvent.class)
public void heartbeat(HeartbeatEvent event) {
if (monitor.update(event.getValue())) {
refresh();
}
}
// 該方法從註冊中心獲取一個配合中心的實例,而後將該實例的url設置到ConfigClientProperties中的uri字段。
private void refresh() {
try {
String serviceId = this.config.getDiscovery().getServiceId();
ServiceInstance server = this.instanceProvider
.getConfigServerInstance(serviceId);
String url = getHomePage(server);
if (server.getMetadata().containsKey("password")) {
String user = server.getMetadata().get("user");
user = user == null ? "user" : user;
this.config.setUsername(user);
String password = server.getMetadata().get("password");
this.config.setPassword(password);
}
if (server.getMetadata().containsKey("configPath")) {
String path = server.getMetadata().get("configPath");
if (url.endsWith("/") && path.startsWith("/")) {
url = url.substring(0, url.length() - 1);
}
url = url + path;
}
this.config.setUri(url);
}
catch (Exception ex) {
if (config.isFailFast()) {
throw ex;
}
else {
logger.warn("Could not locate configserver via discovery", ex);
}
}
}
}複製代碼
這裏會開啓一個上下文刷新的事件監聽器 @EventListener(ContextRefreshedEvent.class),因此當經過消息總線 /bus/refresh 或者直接請求客戶端的 /refresh 刷新配置後,該事件會自動被觸發,調用該類中的 refresh() 方法從 Eureka 註冊中心獲取配置中心實例。
這裏的 ConfigServerInstanceProvider 對 DiscoveryClient 接口作了封裝,經過以下方法獲取實例:
@Retryable(interceptor = "configServerRetryInterceptor")
public ServiceInstance getConfigServerInstance(String serviceId) {
logger.debug("Locating configserver (" + serviceId + ") via discovery");
List<ServiceInstance> instances = this.client.getInstances(serviceId);
if (instances.isEmpty()) {
throw new IllegalStateException(
"No instances found of configserver (" + serviceId + ")");
}
ServiceInstance instance = instances.get(0);
logger.debug(
"Located configserver (" + serviceId + ") via discovery: " + instance);
return instance;
}複製代碼
以上源碼中看到經過 serviceId 也就是 spring.cloud.config.discovery.service-id 配置項獲取全部的服務列表, instances.get(0) 從服務列表中獲得第一個實例。每次從註冊中心獲得的服務列表是無序的。
從配置中心獲取最新的資源屬性是由 ConfigServicePropertySourceLocator 類的 locate() 方法實現的,繼續深刻到該類的源碼看下具體實現:
@Override
@Retryable(interceptor = "configServerRetryInterceptor")
public org.springframework.core.env.PropertySource<?> locate(
org.springframework.core.env.Environment environment) {
// 獲取當前的客戶端配置屬性,override做用是優先使用spring.cloud.config.application、profile、label(若是配置的話)
ConfigClientProperties properties = this.defaultProperties.override(environment);
CompositePropertySource composite = new CompositePropertySource("configService」);
// resetTemplate 能夠自定義,開放了公共的 setRestTemplate(RestTemplate restTemplate) 方法。若是未設置,則使用默認的 getSecureRestTemplate(properties) 中的定義的resetTemplate。該方法中的默認超時時間是 3分5秒,相對來講較長,若是須要縮短這個時間只能自定義 resetTemplate 來實現。
RestTemplate restTemplate = this.restTemplate == null ? getSecureRestTemplate(properties)
: this.restTemplate;
Exception error = null;
String errorBody = null;
logger.info("Fetching config from server at: " + properties.getRawUri());
try {
String[] labels = new String[] { "" };
if (StringUtils.hasText(properties.getLabel())) {
labels = StringUtils.commaDelimitedListToStringArray(properties.getLabel());
}
String state = ConfigClientStateHolder.getState();
// Try all the labels until one works
for (String label : labels) {
// 循環labels分支,根據restTemplate模板請求config屬性配置中的uri,具體方法能夠看下面。
Environment result = getRemoteEnvironment(restTemplate,
properties, label.trim(), state);
if (result != null) {
logger.info(String.format("Located environment: name=%s, profiles=%s, label=%s, version=%s, state=%s",
result.getName(),
result.getProfiles() == null ? "" : Arrays.asList(result.getProfiles()),
result.getLabel(), result.getVersion(), result.getState()));
……
if (StringUtils.hasText(result.getState()) || StringUtils.hasText(result.getVersion())) {
HashMap<String, Object> map = new HashMap<>();
putValue(map, "config.client.state", result.getState());
putValue(map, "config.client.version", result.getVersion());
// 設置到當前環境中的Git倉庫最新版本號。
composite.addFirstPropertySource(new MapPropertySource("configClient", map));
}
return composite;
}
}
}
…… // 忽略部分源碼
}複製代碼
根據方法內的 uri 來源看到是從 properties.getRawUri() 獲取的。
從配置中心服務端獲取 Environment 方法:
private Environment getRemoteEnvironment(RestTemplate restTemplate, ConfigClientProperties properties,
String label, String state) {
String path = "/{name}/{profile}";
String name = properties.getName();
String profile = properties.getProfile();
String token = properties.getToken();
String uri = properties.getRawUri();
……// 忽略部分源碼
response = restTemplate.exchange(uri + path, HttpMethod.GET,
entity, Environment.class, args);
}
…...
Environment result = response.getBody();
return result;
}複製代碼
上述分析看到從遠端配置中心根據 properties.getRawUri(); 獲取的固定 uri,經過 restTemplate 完成請求獲得最新的資源屬性。
源碼中看到的 properties.getRawUri() 是一個固化的值,當配置中心遷移或者使用容器動態獲取 IP 時爲何會有問題呢?
緣由是當配置中心遷移後,當超過了註冊中心的服務續約失效時間(Eureka 註冊中心默認是 90 秒,其實這個值也並不許確,官網源碼中也已註明是個 bug,這個能夠後續單獨文章再說
)會從註冊中心被踢掉,當咱們經過 /refresh 或 /bus/refresh 觸發這個事件的刷新,那麼這個 uri 會更新爲可用的配置中心實例,此時 ConfigServicePropertySourceLocator 是新建立的實例對象,因此會經過最新的 uri 獲得屬性資源。
但由於健康檢查 ConfigServerHealthIndicator 對象以及其所依賴的ConfigServicePropertySourceLocator 對象都沒有被從新實例化,仍是使用服務啓動時初始化的對象,因此 properties.getRawUri() 中的屬性值也沒有變化。
這裏也就是 Spring Cloud Config 的設計缺陷,由於即便刷新配置後可以獲取其中一個實例,可是並不表明必定請求該實例是成功的,好比遇到網絡不可達等問題時,應該經過負載均衡方式,重試其餘機器獲取數據,保障最新環境配置數據一致性。
解決姿式:
github 上 spring cloud config 的 2.x.x 版本中已經在修正這個問題。實現方式也並無使用相似 Ribbon 軟負載均衡的方式,猜想可能考慮到減小框架的耦合。
在這個版本中 ConfigClientProperties 類中配置客戶端屬性中的 uri 字段由 String 字符串類型修改成 String[] 數組類型,經過 DiscoveryClient 獲取到全部的可用的配置中心實例 URI 列表設置到 uri 屬性上。
而後 ConfigServicePropertySourceLocator.locate() 方法中循環該數組,當 uri 請求不成功,會拋出 ResourceAccessException 異常,捕獲此異常後在 catch 中重試下一個節點,若是全部節點重試完成仍然不成功,則將異常直接拋出,運行結束。
同時,也將請求超時時間 requestReadTimeout 提取到 ConfigClientProperties 做爲可配置項。部分源碼實現以下:
private Environment getRemoteEnvironment(RestTemplate restTemplate,
ConfigClientProperties properties, String label, String state) {
String path = "/{name}/{profile}";
String name = properties.getName();
String profile = properties.getProfile();
String token = properties.getToken();
int noOfUrls = properties.getUri().length;
if (noOfUrls > 1) {
logger.info("Multiple Config Server Urls found listed.");
}
for (int i = 0; i < noOfUrls; i++) {
Credentials credentials = properties.getCredentials(i);
String uri = credentials.getUri();
String username = credentials.getUsername();
String password = credentials.getPassword();
logger.info("Fetching config from server at : " + uri);
try {
......
response = restTemplate.exchange(uri + path, HttpMethod.GET, entity,
Environment.class, args);
}
catch (HttpClientErrorException e) {
if (e.getStatusCode() != HttpStatus.NOT_FOUND) {
throw e;
}
}
catch (ResourceAccessException e) {
logger.info("Connect Timeout Exception on Url - " + uri
+ ". Will be trying the next url if available");
if (i == noOfUrls - 1)
throw e;
else
continue;
}
if (response == null || response.getStatusCode() != HttpStatus.OK) {
return null;
}
Environment result = response.getBody();
return result;
}
return null;
}複製代碼
總結:
本文主要從 Spring Cloud Config Server 源碼層面,對 Config Server 節點遷移後遇到的問題,以及對此問題過程進行剖析。同時,也進一步結合源碼,瞭解到 Spring Cloud Config 官網中是如何修復這個問題的。
固然,如今通常也都使用最新版的 Spring Cloud,默認引入的 Spring Cloud Config 2.x.x 版本,也就不會存在本文所描述的問題了。
若是你選擇了 Spring Cloud Config 做爲配置中心,建議你在正式上線到生產環境前,按照 「CAP理論模型」作下相關測試,確保不會出現不可預知的問題。
你們感興趣可進一步參考 github 最新源碼實現:
https://github.com/spring-cloud/spring-cloud-config
歡迎關注個人公衆號,掃二維碼關注解鎖更多精彩文章,與你一同成長~