spring_cloud config 配置中心及利用Github实现自动化热加载配置

Java框架

浏览数:139

2020-6-1

    spring_cloud有着强大的生态支持,其自带的分布式配置中心可以有效解决分布式环境中配置不统一的问题,提供一个中心化的配置中心。并且依靠其spring_bus(rabbitMq提供订阅)和github或者gitlab自带的webhook(钩子函数)可以实现将修改好后的配置push到远程git地址后,通过访问配置服务器的endPoints接口地址,便可将配置中心的变化推送到各个集群服务器中。

    Spring Cloud Config 是用来为分布式系统中的基础设施和微服务应用提供集中化的外部配置支持,它分为服务端与客户端两个部分。其中服务端也称为分布式配置中心,它是一个独立的微服务应用,用来连接配置仓库并为客户端提供获取配置信息、加密 / 解密信息等访问接口;而客户端则是微服务架构中的各个微服务应用或基础设施,它们通过指定的配置中心来管理应用资源与业务相关的配置内容,并在启动的时候从配置中心获取和加载配置信息。Spring Cloud Config 实现了对服务端和客户端中环境变量和属性配置的抽象映射,所以它除了适用于 Spring 构建的应用程序之外,也可以在任何其他语言运行的应用程序中使用。由于 Spring Cloud Config 实现的配置中心默认采用 Git 来存储配置信息,所以使用 Spring Cloud Config 构建的配置服务器,天然就支持对微服务应用配置信息的版本管理,并且可以通过 Git 客户端工具来方便的管理和访问配置内容。当然它也提供了对其他存储方式的支持,比如:SVN 仓库、本地化文件系统。

    话不多说,来看代码:

首先本次采用的spring_cloud版本是:Finchley.RELEASE。spring_boot版本是2.0.3.RELEASE,低版本的spring_cloud并没有actuator/bus-refresh这个endPoints接口地址,所以使用时要注意

首先是配置中心服务器,需要以下4个引用:

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-config-server</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-bus</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-stream-binder-rabbit</artifactId>
        </dependency>

其次是配置文件:

server.port=20000
#服务的git仓库地址
spring.cloud.config.server.git.uri=https://github.com/narutoform/springCloudConfig
#配置文件所在的目录
spring.cloud.config.server.git.search-paths=/**
#配置文件所在的分支
spring.cloud.config.label=master
#git仓库的用户名
spring.cloud.config.username=narutoform
#git仓库的密码
spring.cloud.config.password=*****
spring.application.name=springCloudConfigService
eureka.client.service-url.defaultZone=http://localhost:10000/eureka
eureka.instance.preferIpAddress=true
#rabbitmq
spring.rabbitmq.host=192.168.210.130
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
spring.rabbitmq.publisher-confirms=true
management.endpoints.web.exposure.include=bus-refresh

其中要注意将bus-refresh接口打开,并且用户名和密码只有访问需要权限的项目是才需要,例如gitlab,但github是不需要的,此外rabbitMq的配置如果不需要配置热更新是不需要写的

启动类:

package cn.chinotan;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.cloud.config.server.EnableConfigServer;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;

@SpringBootApplication
@EnableConfigServer
@EnableEurekaClient
@ServletComponentScan
public class StartConfigServerEureka {

    public static void main(String[] args) {
        SpringApplication.run(StartConfigServerEureka.class, args);
    }

}

需要将此配置中心注册到euerka上去

接下来就是配置中心的客户端配置,本次准备了两个客户端,组成集群进行演示

客户端需要的引用为:

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-bus</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-stream-binder-rabbit</artifactId>
        </dependency>

配置文件为:bootstrap.yml

#开启配置服务发现
spring.cloud.config.discovery.enabled: true
spring.cloud.config.enabled: true
#配置服务实例名称
spring.cloud.config.discovery.service-id: springCloudConfigService
#配置文件所在分支
spring.cloud.config.label: master
spring.cloud.config.profile: prod
#配置服务中心
spring.cloud.config.uri: http://localhost:20000/
eureka.client.service-url.defaultZone: http://localhost:10000/eureka
eureka.instance.preferIpAddress: true
management.endpoints.web.exposure.include: bus-refresh

注意配置中心必须写到bootstrap.yml中,因为bootstrap.yml要先于application.yml读取

下面是application.yml配置

server.port: 40000
spring.application.name: springCloudConfigClientOne
#rabbitmq
spring.rabbitmq.host: 192.168.210.130
spring.rabbitmq.port: 5672
spring.rabbitmq.username: guest
spring.rabbitmq.password: guest
spring.rabbitmq.publisher-confirms: true

注意客户端如果要热更新也需要引入spring_bus相关配置和rabbitmq相关配置,打开bus-refresh接口才行,客户端不需要输入远程git的地址,只需从刚刚配置好的服务器中读取就行,连接时需要配置配置服务器的erruka的serverId,本文中是springCloudConfigService,此外还可以指定label(分支)和profile(环境)

在配置中心服务器启动好后便可以启动客户端来读取服务器取到的配置

客户端启动如下:

可以看到客户端在启动时会去配置中心服务器去取服务器从远程git仓库取到的配置

在客户端中加入如下代码,便可以直接读取远程配置中心的配置了

package cn.chinotan.controller;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RefreshScope
public class ConfigClientController {

    @Value("${key}")
    private String key;

    @GetMapping("/key")
    public String getProfile() {
        return this.key;
    }
}

远程配置中心结构为:

 

要注意客户端需要在你希望改变的配置中加入@RefreshScope才能够进行配置的热更新,否则订阅的客户端不知道将哪个配置进行更新

此外客户端访问的那个地址,也可以get直接访问,从而判断配置中心服务器是否正常启动

通过访问http://localhost:20000/springCloudConfig/default接口就行

证明配置服务中心可以从远程程序获取配置信息。

http请求地址和资源文件映射如下:

/{application}/{profile}[/{label}]
/{application}-{profile}.yml
/{label}/{application}-{profile}.yml
/{application}-{profile}.properties
/{label}/{application}-{profile}.properties
现在我们在客户端上访问之前写的那个controller来得到配置文件中的配置

可见客户端能够从服务器拿到远程配置文件中的信息

其实客户端在启动时便会通过spring_boot自带的restTemplate发起一个GET请求,从而得到服务器的信息,源码如下:

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.");
		}

		Object[] args = new String[] { name, profile };
		if (StringUtils.hasText(label)) {
			if (label.contains("/")) {
				label = label.replace("/", "(_)");
			}
			args = new String[] { name, profile, label };
			path = path + "/{label}";
		}
		ResponseEntity<Environment> response = null;

		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 {
				HttpHeaders headers = new HttpHeaders();
				addAuthorizationToken(properties, headers, username, password);
				if (StringUtils.hasText(token)) {
					headers.add(TOKEN_HEADER, token);
				}
				if (StringUtils.hasText(state) && properties.isSendState()) {
					headers.add(STATE_HEADER, state);
				}

				final HttpEntity<Void> entity = new HttpEntity<>((Void) null, headers);
				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;
	}

之后,我们试试配置文件热更新

我们在启动服务器和客户端是,会发现,rabbitMq多了一个交换机和几个队列,spring_bus正是通过这这个topic交换机来进行变更配置的通知个推送的,效果如下:

在更改远程配置文件后,调用配置服务器的http://localhost:20000/actuator/bus-refresh接口后:

可以看到,进行了消息传递,将变化的结果进行了推送

 

其中调用http://localhost:20000/actuator/bus-refresh是因为服务器在启动时暴露出来了这个接口

可以看到这个是一个POST请求,而且其接口在调用之后什么也不返回,而且低版本spring_cloud中没有这个接口

这样是可以实现了客户端集群热更新配置文件,但是还的手动调用http://localhost:20000/actuator/bus-refresh接口,有什么办法可以在远程配置仓库文件更改后自动进行向客户端推送呢,答案是通过github或者是gitlab的webhook(钩子函数)进行,打开gitHub的管理界面可以看到如下信息,点击add webhook进行添加钩子函数

由于我没有公网地址,只能通过内网穿透进行端口映射,使用的是ngrok进行的

 

这样便可以通过http://chinogo.free.idcfengye.com这个公网域名访问到我本机的服务了

但是这样就可以了吗,还是太年轻

可以看到GitHub在进行post请求的同时默认会在body加上这么一串载荷(payload)

还没有取消发送载荷的功能,于是我们的spring boot因为无法正常反序列化这串载荷而报了400错误:

Failed to read HTTP message: org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize instance of `java.lang.String` out of START_ARRAY token; nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize instance of `java.lang.String` out of START_ARRAY token

于是自然而然的想到修改body为空来避免json发生转换异常,开始修改body,于是去HttpServletRequest中去寻找setInputStream方法,servlet其实为我们提供了一个HttpServletRequestMapper的包装类,我们通过继承该类重写getInputStream方法返回自己构造的ServletInputStream即可达到修改request中body内容的目的。这里为了避免节外生枝我直接返回了一个空的body。
自定义的wrapper类

package cn.chinotan.config;

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.ByteArrayInputStream;
import java.io.IOException;

/**
 * @program: test
 * @description: 过滤webhooks,清空body
 * @author: xingcheng
 * @create: 2018-10-14 17:56
 **/
public class CustometRequestWrapper extends HttpServletRequestWrapper {

    public CustometRequestWrapper(HttpServletRequest request) {
        super(request);
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        byte[] bytes = new byte[0];
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);

        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return byteArrayInputStream.read() == -1 ? true:false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {

            }

            @Override
            public int read() throws IOException {
                return byteArrayInputStream.read();
            }
        };
    }
}

实现过滤器

package cn.chinotan.config;

import org.springframework.core.annotation.Order;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @program: test
 * @description: 过滤器
 * @author: xingcheng
 * @create: 2018-10-14 17:59
 **/
@WebFilter(filterName = "bodyFilter", urlPatterns = "/*")
@Order(1)
public class MyFilter implements Filter {
    
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest)servletRequest;

        String url = new String(httpServletRequest.getRequestURI());

        //只过滤/actuator/bus-refresh请求
        if (!url.endsWith("/bus-refresh")) {
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }

        //使用HttpServletRequest包装原始请求达到修改post请求中body内容的目的
        CustometRequestWrapper requestWrapper = new CustometRequestWrapper(httpServletRequest);

        filterChain.doFilter(requestWrapper, servletResponse);
    }

    @Override
    public void destroy() {

    }
}

别忘了启动类加上这个注解:

@ServletComponentScan

这样便可以进行配置文件远程修改后,无需启动客户端进行热加载了

作者:chinotan