Spring Cloud 请求优先转发至本地服务方案

组件版本:

注册中心:spring-cloud-starter-alibaba-nacos-discovery: 2021.0.1.0

负载均衡组件:spring-cloud-loadbalancer: 3.1.3

一、背景

当多人将服务注册到 nacos 注册中心时,由于默认是负载均衡规则是 RoundRobinLoadBalancer(轮询),那么每次的调用都将可能调到其他人的服务,这样会存在以下几个问题:

  • 当你在 debug 时,必须在轮询到你本地的服务时,才能调用到本地的服务接口,这样一来严重影响到了改 bug 的效率!有些同学为了能够调到本地的服务会将其他人的服务下线,那其他同学就不能够同时改 bug,而且很多时候会忘记将下线的服务重新上线,此时前端同学就会给你反馈开发环境挂了。
  • 当你好不容易改了一个 bug,然后提交代码并发布开发环境,你以为已经改好了,去开发环境一看改的问题时好时坏;这是由于你提交了代码,但其他同学并没有拉你最新的提交导致调用的服务还是旧的代码。
  • 还有种情况就是你正在 debug,打了好几个断点(IDEA 断点 Suspend 选择了 Thread),但由于其他同学的操作导致调用到了你本地的服务,这时你会发现你的断点跳来跳去,请求的条件都不一样无法得知哪个才是你的断点,此时你觉得要崩溃了。

二、解决方案

  • 由于我们使用了 nacos 作为注册中心,nacos 支持我们使用命名空间进行服务隔离,在 debug 时只需要将服务注册到你自己的命名空间即可。但通常情况下,一个业务跑起来,并不是仅仅注册一两个服务就可以的,你还需要将网关、用户中心等服务跑起来,因此这种方案并不是最优解。
  • 第二种就是本次需要介绍的基于用户的请求 IP,通过自定义负载均衡策略,将请求转发到与请求 IP 一致的服务实例上。
    Spring Cloud 请求优先转发至本地服务方案_第1张图片

三、基于请求的 IP 负载方案原理调用链路

  1. 调用链路
    根据请求方的 IP 进行转发的关键节点是如何拿到请求方的 IP 地址,由于项目通过 nginx 进行转发,可以在 nginx 上将请求的 IP 加到请求头就能获取到客户端的请求 IP。
    下面的图为一次请求的调用链路:
    Spring Cloud 请求优先转发至本地服务方案_第2张图片
    从调用的链路可以知道,在每次转发时需要将请求头设置到对应的 Http 工具(HttpClient、RestTemplate、OpenFeign、OKHttpClient),以便下一级的服务能够获取到客户端的来源 IP 地址;
    同样的,自定义的负载均衡器也是作用于 Http 工具上,因此 Http 工具需要支持负载均衡策略或者能够添加拦截器(拦截请求修改请求的目标地址)。
  2. 自定义负载均衡器实现
    自定义负载均衡器主要是从请求头拿到客户端的 IP 地址,并在注册中心的服务集合中找到与客户端 IP 地址一致的服务,然后实现转发。实现很简单,就是从 Request 请求头中拿到客户端 IP 对应的服务实例,如拿不到再尝试从 MVC 的 HttpServletRequest 请求头中获取客户端 IP 对应的服务实例。
    关键代码:
@SneakyThrows
private Response<ServiceInstance> getInstanceResponse(Request<?> request, List<ServiceInstance> instances) {
    Response<ServiceInstance> response = new EmptyResponse();
    if (instances.isEmpty()) {
        log.warn("No servers available for service: " + this.serviceId);
        return response;
    }
    // 根据 ip 获取 ServiceInstance
    ServiceInstance serviceInstance = Optional.ofNullable(getServiceInstanceFromRequest(request, instances))
            .orElseGet(() -> getServiceInstanceFromMvc(instances));
    response = new DefaultResponse(serviceInstance);
 
    // 如果 ip 获取失败,则从委托(默认为 Nacos 的同源优先)中获取实例
    if (!response.hasServer() && Objects.nonNull(delegate)) {
        Response[] responseTmp = new Response[]{response};
        delegate.choose(request)
                .subscribe(resp -> responseTmp[0] = resp);
        response = responseTmp[0];
    }
    return response;
}

/**
 * 从 Request 调用中获取请求头的信息,从而获取 ServiceInstance
 *
 * @param request   请求
 * @param instances 服务实例集合
 * @return 符合条件的实例
 */
public ServiceInstance getServiceInstanceFromRequest(Request<?> request, List<ServiceInstance> instances) {
    Object context = request.getContext();
    // RequestData 类型才能获取到 Headers
    DefaultRequestContext requestContext = null;
    boolean canGetHeaders = context instanceof DefaultRequestContext
            && (requestContext = (DefaultRequestContext) context).getClientRequest() instanceof RequestData;
    if (!canGetHeaders) {
        return null;
    }
    RequestData clientRequest = (RequestData) requestContext.getClientRequest();
    HttpHeaders headers = clientRequest.getHeaders();
    String ipAddress = getIpAddress(headers);

    // 请求的真实 ip 不存在
    if (StringUtils.isBlank(ipAddress)) {
        return null;
    }
    return getServiceInstance(ipAddress, instances);
}

/**
 * 从 SpringMvc 的 RequestContextHolder 中获取 ip,从而得到 ServiceInstance
 *
 * @param instances 服务实例集合
 * @return 符合条件的实例
 */
public ServiceInstance getServiceInstanceFromMvc(List<ServiceInstance> instances) {
    ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    // 当异步调用将上下文设置到子线程,将导致 NPE
    if (Objects.isNull(requestAttributes)) {
        return null;
    }
    String ipAddress = getIpAddress(requestAttributes.getRequest());

    // 请求的真实 ip 不存在
    if (StringUtils.isBlank(ipAddress)) {
        return null;
    }
    return getServiceInstance(ipAddress, instances);
}

四、如何使用?

自定义负载均衡策略默认是不开启的,因此默认的情况下采用的是轮询方式,要开启需要在 nacos 配置中心或者配置文件中添加 router.call-local.enable=true 并重启服务后即可。
Spring Cloud 请求优先转发至本地服务方案_第3张图片

正常情况下只要将自己的服务注册到对应的环境,操作前端界面就能够调回本地的服务(刚启动的服务需要等待 nacos 将本地服务共享出去,其他服务默认情况下 30 秒会从 nacos 拉取服务列表,源码见 NacosWatch);

对于前端或者未启动对于服务的同学,本地是不存在服务的,为了让他们只调到开发环境,我们只需要在 nacos 上将开发环境的服务权重提高即可,因为在未找到 IP 地址对应服务的情况下会走另一个负载均衡策略,该负载均衡策略由 nacos 提供,同源优先策略,源码(NacosLoadBalancer)如下:

private Response<ServiceInstance> getInstanceResponse(
      List<ServiceInstance> serviceInstances) {
     // 略...
      ServiceInstance instance = NacosBalancer
            .getHostByRandomWeight3(instancesToChoose);
      return new DefaultResponse(instance);
     // 略...
}
 
 
/**
 * Spring Cloud LoadBalancer Choose instance by weight.
 * @param serviceInstances Instance List
 * @return the chosen instance
 */
public static ServiceInstance getHostByRandomWeight3(
      List<ServiceInstance> serviceInstances) {
    // 略...
}

当你在与前端的同学在联调,你不确定有没有修改成功想让前端的同学直接调你本地的服务,这时你可以叫前端同学在请求头中添加你的 IP 地址即可调到你本地的服务,等确认问题修改成功再发到开发环境;

这样指定请求头的方式优先级是最高的,你填写了开发环境的 IP,此时你本地也启动了一个服务,这样会一直调到开发环境直到你去掉这个请求头为止。
Spring Cloud 请求优先转发至本地服务方案_第4张图片

五、需要注意的点

  • 网关的 nginx 必须添加客户端 ip 的请求头。
server {
    listen       81;
    server_name  gateway;
    client_max_body_size 1024M;
 
    location /my/ {
        proxy_pass http://192.168.1.2:18000/;
         
        # 获取真实ip 
        proxy_set_header  X-Real-IP $remote_addr;
        proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header  Host $host;
        proxy_set_header X-Real-Port $remote_port;
    }
}
  • 在调用下级服务使用了异步的方式调用,别忘了使用RequestContextHolder.setRequestAttributes(RequestContextHolder.getRequestAttributes(), true); 将请求属性共享给子线程。
  • 在 RestTemplate、OpenFeign 转发时,记得将客户端 IP 设置到请求头中。
  • 假如同一个 IP 启动了多个实例,默认会随机从这些实例获取服务,因此当你想验证多个实例不同代码的区别时,这样是不适用的。

完整代码地址:点我下载源码

你可能感兴趣的:(spring,cloud,nacos,spring,cloud,java,spring)