项目架构与模块介绍
在正式开始开发之前先分析一下我们需要那些模块。 首先是传统的Common模块,这个模块我在开发中都会有一个,用来存放一些公共的通用的类。 之后,我们分析一下如果我们要自己开发一个配置中心,那么就意味着我们需要提供一个服务端,这个服务端提供给用户直接进行使用,用户运行服务端代码之后,就可以使用控制台的方式去操作我们的配置中心中的配置。 因此,我们需要一个Server模块,这个模块提供配置中心的各种API。 之后,我们还需要为用户提供一个客户端模块Client,用来帮助用来在开发过程中方便的加载到配置中心的配置,对配置中心进行监听、处理。 Client模块要求实现基于Server模块暴露的API,然后使用HTTP/RPC/Netty的方式来与Server端进行联系,从而取得配置、修改配置。 也就是说,Client模块要求提供一系列API,来帮助用户屏蔽底层的对配置中心的访问,帮助用户简单的完成配置管理等功能。 也就是说,Client是一种提供用户手动使用代码编程的方式操作配置中心的模块。 同时,在基于SpringCloud环境开发的项目,都会需要引入一些SpringCloud相关的组件。 而单纯的Client模块只提供了对接配置中心的功能(根据单一职责原则)。 这里开发一个Core模块,Core模块引入Client模块的功能。从而实现项目对配置中心的连接等功能。 同时,Core模块基于SpringBoot和SpringCloud的各种特性,使得用户引入这个模块之后,只需要根据配置的方式就可以连接到配置中心,并且会开始监听配置变更以及刷新本地配置。 也就是说,Core模块是对Client模块的进一步封装,从而更好的融入Spring生态。 Common:项目通用类模块 Server:项目服务端模块,提供基于Web方式对配置中心的访问,提供RESTful风格的对配置中心操作的API。 Client:项目客户端模块,基于Server模块暴露的API,通过HTTP/RPC/Netty等方式来连接配置中心,从而对其进行操作。 Core:项目核心模块,基于Client模块进行了封装,并对SpringCloud进行了整合。从而实现了配置动态变更,配置监听等功能。
因此,其实最重要的是先开发出来Server模块。 因为Server《Client《Core 【《表示依赖】
Server
Server要求提供基于RESTful风格的API。因此,Server模块是一个基础的Web项目。 使用SpringBoot开发Web项目比较容易,这里不多赘述。 我们主要是分析Server模块需要做到那些事情。 Server模块首先需要提供RESTFul风格的API来帮助用户使用网页的方式CRUD我们的配置信息。
配置变更消息推送
同时,我们知道,由于Server端和Client(亦或者是Core)模块并不会出现在同一个项目中,因此,如果我们希望Client端能接收到Server端配置文件的变更,有如下几种方式
- 引入MQ等新中间件,缺点是项目耦合了新的模块,优点是实现非常简单。
- 让Client模块引入MySQL、Redis等数据库,通过一些手段也可以更新Client模块的配置,缺点同上。
- 基于JDK提供的一些API实现长连接。我们知道JDK提供了一些API能帮助我们hold住请求连接,并且直到特定时候才会返回数据给客户端,而其他时候这个连接都会被Server端维持。
- 可以考虑使用Netty来实现长连接,也是一种新方法。但是也需要引入新组件,并且需要自己定义实现方法,比较麻烦。 综上,我们考虑使用第三种方法来实现配置的变更。 对于第三种方法,我想到了如下几种解决方法: 消息推送
- 长轮询(Long Polling):客户端发送一个请求到服务器,服务器保持连接打开,直到有新数据可发送。一旦发送了数据,客户端会立刻发起新的请求,这样过程反复进行。
- 服务器推送(Server-Sent Events,SSE):这是一个基于 HTTP 的单向通信协议,允许服务器主动向客户端发送数据。
- WebSocket:这是一种双向通信协议,允许服务器和客户端之间建立一个持久的连接,并通过此连接双向传输数据。
- DeferredResult:是Spring框架中用于实现异步请求处理的一个类,属于Spring MVC。它允许你从另一个线程返回结果,这对于长时间运行的异步操作特别有用。使用 DeferredResult,你可以在另一个线程中处理业务逻辑,而不会阻塞Servlet容器的线程。
这里的第四种方案当初在面试蔚来汽车的时候用过,其实这个方案用的很少,但是如果你能提到并且说出对他的了解,也会给面试加分,至少我当初的面试官因为我用到了这个技术对我还是很赞赏的。 这里我的代码使用了1/4两种方式来实现。 对于第一种,我是用的是基于HttpServletRequest的AsyncContext来实现的。 如下是一个简单的Service示例。
@Service
public class LongPollingService {
private final Map<String, AsyncContext> contextsMap = new ConcurrentHashMap();
/**
* 添加订阅者
* @param namespace
* @param group
* @param configId
* @param request
* @param response
*/
public void addSubscriber(String namespace,String group, String configId, HttpServletRequest request,
HttpServletResponse response) {
String key = namespace + SEPARATOR + group + SEPARATOR+configId;
AsyncContext context = request.startAsync();
// Set timeout, e.g., 10 seconds
context.setTimeout(10000);
contextsMap.put(key, context);
}
/**
* 通知监听当前配置文件的请求,并进行响应
* @param namespace
* @param group
* @param configId
* @param content
*/
public void notifySubscriber(String namespace,String group,String configId, String content) {
String key = namespace + SEPARATOR + group + SEPARATOR+configId;
AsyncContext context = contextsMap.get(key);
try {
context.getResponse().getWriter().write(content);
context.complete();
} catch (Exception e) {
e.printStackTrace();
}
}
}
我们来看看演示效果。
当我们发送一个请求,来进行基于某个配置文件的订阅的时候。
我们的请求会进行等待,而不是直接返回数据,可以发现请求在转圈。
之后,当我发布一个配置变更事件之后,这个等待的请求会马上返回。
对于DeferredResult,实现的方式和AsyncContext差不多。
private final Map<String, DeferredResult<String>> requestsMap = new ConcurrentHashMap<>();
@GetMapping("/subscribe-deferred")
public DeferredResult<String> subscribeDeferred(String namespace, String group, String configId,
HttpServletRequest request, HttpServletResponse response) {
String key = namespace + SEPARATOR + group + SEPARATOR + configId;
DeferredResult<String> result = new DeferredResult<>(10000L, "Timeout");
requestsMap.put(key, result);
result.onCompletion(() -> requestsMap.remove(result));
return result;
}
@PostMapping("/publish-deferred")
public String publishDeferred(String namespace, String group, String configId, String content) {
String key = namespace + SEPARATOR + group + SEPARATOR + configId;
DeferredResult<String> result = requestsMap.get(key);
result.setResult(content);
return "Message sent to subscribers.";
}
也是一样的,发送一个请求的时候会进行阻塞,当配置发生变更之后,会马上返回数据。
因此,客户端这边最让人头疼的一个长轮询的实现方式,我们就简单的实现了。
之后,我们先按照常规的CRUD的方式,来完成Web接口。
那么在上面,我们就已经简单的完成了长轮询功能。
之后,我们可以考虑使用一个Map的方式来保存监听事件。
只要对应的配置文件发生了变更,就会从Map中取出对应的长轮询链接,然后进行事件的发布和处理。
比如这里,我们发生一个配置变更的请求。
关键代码就是在修改完毕配置之后,发布一个事件让Core模块去监听到,只要Core模块能拿到这个事件, 那么Core模块就有办法根据事件的内容去刷新本地的配置信息。
//配置发布之后同时提示对应的监听请求
longPollingService.notifySubscriber(EventTypeEnum.PUBLISH,namespace,group,configId,content);
}
这里我简单的写了一个13。
/**
* 通知监听当前配置文件的请求,并进行响应
*
* @param namespace
* @param group
* @param configId
* @param content
*/
public void notifySubscriber(EventTypeEnum eventTypeEnum , String namespace,
String group, String configId, String content) {
String key = namespace + SEPARATOR + group + SEPARATOR + configId;
AsyncContext context = contextsMap.get(key);
if (Objects.isNull(context)){
return;
}
switch (eventTypeEnum){
case PUBLISH -> {
try {
Result<String> result = Result.ok(content, null, EventTypeEnum.PUBLISH.getValue());
String jsonString = JSON.toJSONString(result);
context.getResponse().getWriter().write(jsonString);
context.complete();
} catch (Exception e) {
e.printStackTrace();
}finally {
contextsMap.remove(key);
}
}
case REMOVE -> {
try {
//告诉客户端是哪一个配置文件被移除了
Result<String> result = Result.ok(key, null, EventTypeEnum.REMOVE.getValue());
String jsonString = JSON.toJSONString(result);
context.getResponse().getWriter().write(jsonString);
context.complete();
} catch (Exception e) {
e.printStackTrace();
}finally {
contextsMap.remove(key);
}
}
//测试用
case IGNORE -> {
try {
context.getResponse().getWriter().write("ignore-event:"+key+",this is test respose");
context.complete();
} catch (Exception e) {
e.printStackTrace();
}
finally {
contextsMap.remove(key);
}
}
}
}
到此为止,Server模块已经完成了他的最重要的功能,就是完成前面的长轮询,并且发布一个事件。 我会在Client模块介绍我是如何实现事件发布以及处理的。
Client
Client项目我在上面已经说到过,这个项目通过HTTP等请求的方式,向Server端发送请求得到数据。Client端其实更加类似于一个连接配置中心的一个简单的客户端,他提供一系列简单的和Server端对应的API来帮助我们轻松的操作Server端。 由于Client端不需要引入Spring相关的依赖,而只是一个非常纯粹的Java原生项目。 Client端提供了基于客户端方式向配置中心获取配置、发布配置、删除配置的API。 并且,Core模块是基于SpringBoot的,Core模块对项目配置的变更,依赖于Client模块发布的事件,因此,我们需要Client模块提供一个机制,使得Client模块在发生配置变更事件的时候,能通知道Core模块,让Core模块知道。 这里,我的方式是通过在Client模块提供的API的接口中设定一个参数,Publish接口。 这样子Core模块就可以通过实现Publish接口的方式,来自定义配置发生变更的时候的事件了。
/**
* 订阅监听配置中心的某一个配置文件
* 并且在这里,需要有一个机制,通过比对MD5之后
* 如果发现配置文件变更,还需要可以发布一个事件来更新项目的Environment
*
* @param group
* @param configId
*/
@Override
public void subscribeConfigChangeEvent(String group, String configId, Publish publish) {
String url = buildSubScribeUrl(group, configId);
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(HttpResponse::body).thenAccept(content -> {
System.out.println("Received content: " + content);
// 处理收到的内容
Result result = JSON.parseObject(content, Result.class);
String data = (String) result.getData();
String key = namespace + SEPARATOR + group + SEPARATOR + configId;
String eventType = result.getExt();
//当前是一个配置删除事件
if (StringUtils.equals(EventTypeEnum.REMOVE.getValue(),eventType)){
handleRemoveEvnet(key, publish);
subscribeConfigChangeEvent(group,configId, publish);
}else if(StringUtils.equals(EventTypeEnum.PUBLISH.getValue(),eventType)){
String newMd5 = MD5Util.toMD5(data);
//TODO 比较缓存中的MD5 如果改变
ConfigCache configCache = cacheMap.get(key);
String lastCallMd5 = configCache.getLastCallMd5();
if (!StringUtils.equals(lastCallMd5,newMd5)){
configCache.setContent(data);
configCache.setLastCallMd5(newMd5);
configCache.setModifyTimestamp(new Date());
handlePublishEvent(key,configCache, publish);
}
//配置发生改变
//就需要发生一个变更事件 通知项目修改Environment的值
//并且需要对@Value的值进行刷新
//TODO 这里需要考虑的是 Client模块是可以不整合SpringBoot的
//那么就意味着不能直接使用ApplicationListener/Event了
// 立即再次发起长轮询请求
subscribeConfigChangeEvent(group, configId, publish);
}
}).exceptionally(e -> {
e.printStackTrace();
// 立即再次发起长轮询请求
subscribeConfigChangeEvent(group, configId, publish);
return null;
});
}
ent模块的代码比较简单好理解,就直接跳过了。
这里由于我的代码写的比较快,因此很多东西都是马上想到就写了,所以一开始的代码不会很严谨。
重点是我们的Core模块。
我会在下一片文章分析如何编写Core模块的代码。