Nacos中有CP和AP两种模式,而这两种模式在实现数据一致性方案上面是完全不一样的,对于CP模式而言,使用的是raft这种强一致性协议,对于AP模式而言,则是使用阿里自创的Distro协议,那么这里我们就来看看这个Distro协议在Nacos中是如何实现的
一.@CanDistro注解
在Nacos服务中会发现有很多接口上面加了@CanDistro这个注解,例如实例注册接口:
而@CanDistro这个注解有什么用呢?通过名字大概知道它应该是跟Distro协议有关的,所以我们通过idea去看一下这个注解哪里有被引用到,经过一番寻找之后,发现了一个很重要的核心类DistroFilter
二.DistroFilter
com.alibaba.nacos.naming.web.DistroFilter#doFilter
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
ReuseHttpRequest req = new ReuseHttpServletRequest((HttpServletRequest) servletRequest);
HttpServletResponse resp = (HttpServletResponse) servletResponse;
String urlString = req.getRequestURI();
if (StringUtils.isNotBlank(req.getQueryString())) {
urlString += "?" + req.getQueryString();
}
try {
String path = new URI(req.getRequestURI()).getPath();
String serviceName = req.getParameter(CommonParams.SERVICE_NAME);
// For client under 0.8.0:
if (StringUtils.isBlank(serviceName)) {
serviceName = req.getParameter("dom");
}
if (StringUtils.isNotBlank(serviceName)) {
serviceName = serviceName.trim();
}
// 获取到要请求的接口方法
Method method = controllerMethodsCache.getMethod(req);
// 请求方法为空,抛出异常
if (method == null) {
throw new NoSuchMethodException(req.getMethod() + " " + path);
}
String groupName = req.getParameter(CommonParams.GROUP_NAME);
if (StringUtils.isBlank(groupName)) {
groupName = Constants.DEFAULT_GROUP;
}
// use groupName@@serviceName as new service name.
// in naming controller, will use com.alibaba.nacos.api.naming.utils.NamingUtils.checkServiceNameFormat to check it's format.
String groupedServiceName = serviceName;
if (StringUtils.isNotBlank(serviceName) && !serviceName.contains(Constants.SERVICE_INFO_SPLITER)) {
groupedServiceName = groupName + Constants.SERVICE_INFO_SPLITER + serviceName;
}
// 条件成立:该接口方法上有@CanDistro注解,并且当前节点不负责处理该服务
// 说明当前节点不处理该请求,需要向其他服务器发出代理请求
if (method.isAnnotationPresent(CanDistro.class) && !distroMapper.responsible(groupedServiceName)) {
String userAgent = req.getHeader(HttpHeaderConsts.USER_AGENT_HEADER);
if (StringUtils.isNotBlank(userAgent) && userAgent.contains(UtilsAndCommons.NACOS_SERVER_HEADER)) {
// This request is sent from peer server, should not be redirected again:
Loggers.SRV_LOG.error("receive invalid redirect request from peer {}", req.getRemoteAddr());
resp.sendError(HttpServletResponse.SC_BAD_REQUEST,
"receive invalid redirect request from peer " + req.getRemoteAddr());
return;
}
// 获取负责处理该服务的节点地址
final String targetServer = distroMapper.mapSrv(groupedServiceName);
// 封装请求参数
List<String> headerList = new ArrayList<>(16);
Enumeration<String> headers = req.getHeaderNames();
while (headers.hasMoreElements()) {
String headerName = headers.nextElement();
headerList.add(headerName);
headerList.add(req.getHeader(headerName));
}
final String body = IoUtils.toString(req.getInputStream(), Charsets.UTF_8.name());
final Map<String, String> paramsValue = HttpClient.translateParameterMap(req.getParameterMap());
// 转发请求到其他节点
RestResult<String> result = HttpClient
.request("http://" + targetServer + req.getRequestURI(), headerList, paramsValue, body,
PROXY_CONNECT_TIMEOUT, PROXY_READ_TIMEOUT, Charsets.UTF_8.name(), req.getMethod());
String data = result.ok() ? result.getData() : result.getMessage();
try {
WebUtils.response(resp, data, result.getCode());
} catch (Exception ignore) {
Loggers.SRV_LOG.warn("[DISTRO-FILTER] request failed: " + distroMapper.mapSrv(groupedServiceName)
+ urlString);
}
}
// 条件成立:当前节点需要处理这个请求
else {
OverrideParameterRequestWrapper requestWrapper = OverrideParameterRequestWrapper.buildRequest(req);
requestWrapper.addParameter(CommonParams.SERVICE_NAME, groupedServiceName);
// 放行该请求,后面就会执行具体的接口
filterChain.doFilter(requestWrapper, resp);
}
} catch (AccessControlException e) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "access denied: " + ExceptionUtil.getAllExceptionMsg(e));
} catch (NoSuchMethodException e) {
resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED,
"no such api:" + req.getMethod() + ":" + req.getRequestURI());
} catch (Exception e) {
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"Server failed," + ExceptionUtil.getAllExceptionMsg(e));
}
}
DistroFilter这个类是实现了Filter接口,所以表明了它是一个过滤器,在请求来的时候,会经过doFilter方法,在doFilter方法中大概有下面4个过程:
1.根据请求路径从controllerMethodsCache中获取到对应的controller方法
2.判断这个controller方法是否有@CanDistro注解,如果有的话再调用distroMapper.responsible()方法去判断当前nacos节点是否需要处理这个请求
3.如果controller方法没有@CanDistro注解,或者有@CanDistro注解并且当前nacos节点需要处理这个请求,那么就直接放行这个请求到controller端
4.反之如果controller方法有@CanDistro注解并且当前nacos节点不需要处理这个请求,那么就会把这个请求转发到对应的其他节点去处理
其中第一点中从controllerMethodsCache中获取对应的controller方法,那么是怎么获取的呢?所以我们要看下ControllerMethodsCache中的getMethod方法
public Method getMethod(HttpServletRequest request) {
// 获取到请求路径
String path = getPath(request);
// 获取到请求方法类型
String httpMethod = request.getMethod();
// 构造一个urlKey
String urlKey = httpMethod + REQUEST_PATH_SEPARATOR + path.replaceFirst(EnvUtil.getContextPath(), "");
// 根据这个urlKey找到对应的RequestMappingInfo
List<RequestMappingInfo> requestMappingInfos = urlLookup.get(urlKey);
// 条件成立:说明并没有这个请求路径对应的controller方法,直接返回null
if (CollectionUtils.isEmpty(requestMappingInfos)) {
return null;
}
// 根据@RequestMapping注解中指定的params参数校验这个请求的参数是否合理
List<RequestMappingInfo> matchedInfo = findMatchedInfo(requestMappingInfos, request);
// 条件成立:说明这个请求的参数不合理,直接返回null
if (CollectionUtils.isEmpty(matchedInfo)) {
return null;
}
RequestMappingInfo bestMatch = matchedInfo.get(0);
if (matchedInfo.size() > 1) {
RequestMappingInfoComparator comparator = new RequestMappingInfoComparator();
matchedInfo.sort(comparator);
bestMatch = matchedInfo.get(0);
RequestMappingInfo secondBestMatch = matchedInfo.get(1);
if (comparator.compare(bestMatch, secondBestMatch) == 0) {
throw new IllegalStateException(
"Ambiguous methods mapped for '" + request.getRequestURI() + "': {" + bestMatch + ", "
+ secondBestMatch + "}");
}
}
// 最终返回对应的controller方法
return methods.get(bestMatch);
}
从getMethod方法中可以看到这个方法主要是根据构造出来的一个urlKey(请求方式 + “-->” + 类上的请求路径 + 方法上的请求路径)去找到对应的RequestMappingInfo,RequestMappingInfo这个对象就封装了@RequestMapping注解的一些匹配信息,然后通过这个RequestMappingInfo对象去校验请求的参数是否被@RequestMapping注解中指定的params参数值所匹配,如果匹配的话就再根据这个RequestMappingInfo对象去找到对应的controller方法。但是获取controller方法是从methods这个map中获取到的,那么这个map中的数据又是从哪里来的呢?
我们通过idea一步步地去找看是在什么时候给上面的methods这个map填充数据的
可以看到源头是在一个叫ConsoleConfig的类的init方法开始的,并且这个init方法加了@PostConstruct注解,表示在spring容器启动的时候就能够被调用该方法,在init方法中,调用了4次ControllerMethodsCache的initClassMethod方法,分别传了不同的包名,在initClassMethod方法中会根据传入的包名然后找到加了@RequestMapping注解的类,然后寻找每一个类中加了@RequestMapping注解的方法,然后构造出一个RequestMappingInfo对象,其中给这个RequestMappingInfo对象设置两个校验,一个是请求路径的校验,一个是请求参数的校验,然后把urlKey和RequestMappingInfo对象放到urlLookup这个map中,再把RequestMappingInfo对象和controller方法放到methods这个map中。所以经过上面的分析,我们可以做一个小总结,在spring容器启动的时候,nacos就会在指定的几个包名下找到所有加了@RequestMapping注解的controller类,然后再找到这些类下面加了@RequestMapping注解的方法,再构造出一个RequestMappingInfo校验对象用来对请求路径和请求参数进行校验匹配,而请求路径的检验是根据@RequestMapping注解指定的请求方式以及请求路径去构造出一个urlKey作为校验匹配的条件,请求参数校验则是根据@RequestMapping注解中的params属性作为检验匹配的条件,最终就会把这个RequestMappingInfo校验对象和对应的controller方法放到methods这个map中了。所以当有请求过来的时候,DistroFilter会进行拦截,首先会根据请求路径构造出urlKey,再根据urlKey找到对应的RequestMappingInfo检验对象,然后使用这个RequestMappingInfo校验对象对这个请求参数进行校验,如果校验不通过则返回null,校验通过则再根据这个RequestMappingInfo对象找到对应的controller方法
三.Distro弱一致性协议实现原理
通过上面我们知道在DistroFilter中会根据请求找到对应的controller方法,然后会去判断这个controller方法上是否有@CanDistro注解,如果有的话会再判断当前的nacos节点是否需要对这个请求进行处理,而这个判断就是通过distroMapper.responsible()这个方法去判断的,那么这个方法具体是干什么的呢?其实这个方法就是实现distro弱一致性协议的核心,我们看下这个方法
/**
* 判断当前nacos服务是否需要负责响应指定的service(比如是否需要心跳检查)
*
* @param serviceName 实例服务名称
* @return true表示当前nacos服务需要响应指定的service,反之不需要响应
*/
public boolean responsible(String serviceName) {
final List<String> servers = healthyList;
// 条件成立:没有开启distro协议,或者是nacos服务是单机模式
if (!switchDomain.isDistroEnabled() || EnvUtil.getStandaloneMode()) {
// 返回true表示需要响应处理这个service
return true;
}
if (CollectionUtils.isEmpty(servers)) {
// means distro config is not ready yet
return false;
}
// 获取到当前nacos服务在集群中的位置索引
// index和lastIndex通常都会相等
int index = servers.indexOf(EnvUtil.getLocalAddress());
int lastIndex = servers.lastIndexOf(EnvUtil.getLocalAddress());
if (lastIndex < 0 || index < 0) {
return true;
}
// target变量的范围:0 <= target <= servers.size() -1
// 对于同一个service来说,distroHash(serviceName)得到的结果都是相同的
int target = distroHash(serviceName) % servers.size();
// 所以在nacos集群中,只会有一个节点这里会返回true
return target >= index && target <= lastIndex;
}
private int distroHash(String serviceName) {
return Math.abs(serviceName.hashCode() % Integer.MAX_VALUE);
}
首先这个方法的作用是判断当前nacos节点是否需要负责处理指定的服务,如果不负责处理就返回true,反之就返回false。在开始的时候会去判断当前是否开启了distro协议,如果没有开启就返回true,以及会去判断这个nacos节点是否是单机模式,如果是单机模式就返回true,也就是说在单机模式下,distro协议是不起作用的,很好理解,因为distro协议就是解决了集群之间数据同步一致性的一种方案,而单机模式也没有所谓的数据同步,自然distro协议是不需要的。然后就是会去获取到当前nacos节点在整个nacos集群中的索引位置,并且对指定的服务名通过distroHash方法获取到一个值,把这个值与整个nacos集群节点数进行取模得到一个target值,如果这个target值是等于当前nacos节点所在集群的索引位置值,那么就返回true,反之就返回false。所以对于每一个服务,它都会通过上面这种方式分配到具体的nacos节点,也就是说每一个nacos节点都会负责一部分的服务,那么这这难道nacos集群是分布式集群吗 ?很显然不是的,虽然说每一个nacos节点只会负责一部分的服务请求,但是nacos之间会进行数据的同步,也就是nacos集群的每一个节点数据是最终一致性的,所以这也就是什么说distro协议是一个弱一致性的协议了。而如果这个服务请求根据distro协议的规则判断之后发现不归当前这个nacos节点负责处理怎么办呢?这时候就需要对这个服务请求进行转发了,此时会通过distro协议的规则重新计算找出负责处理这个服务请求的nacos节点,然后当前nacos节点就把这个请求重转发到指定的nacos节点,这样整个distro协议的实现流程就完成了