三笠

springcloud 总集:https://blog.fleyx.com/blog/detail/2019-02-28-11-33

本篇中 Zuul 版本为 1.x,目前最新的是 2.x,二者在过滤器的使用上有较大区别

超长警告

项目代码见文章结尾

一、背景

  微服务架构将一个应用拆分为很多个微小应用,这样会导致之前不是问题的问题出现,比如:

  1. 安全问题如何实现?
  2. 日志记录如何实现?
  3. 用户跟踪如何实现?

上面的问题在传统的单机应用很容易解决,只需要当作一个功能实现即可。但是在微服务中就行不通了,让每个服务都实现一份上述功能,那是相当不现实的,费时,费力还容易出问题。

  为了解决这个问题,需要将这些横切关注点(分布式系统级别的横切关注点和 spring 中的基本一个意思)抽象成一个独立的且作为应用程序中所有微服务调用的过滤器和路由器的服务。这样的服务被称为——服务网管(service gateway),服务客户端不再直接调用服务。取而代之的是,服务网关作为单个策略执行点(Policy Enforcement Point,PEP) , 所有调用都通过服务网管进行路由,然后送到目的地。

二、服务网关

1、什么是服务网关

  之前的几节中我们是通过 http 请求直接调用各个服务,通常在实际系统中不会直接调用。而是通过服务网关来进行服务调用。服务网关充当了服务客户端和被调用服务间的中介。服务客户端仅与服务网关管理的单个 url 进行对话。下图说了服务网关在一个系统中的作用:

服务网关

服务网关位于服务客户端和相应的服务实例之间。所有的服务调用(内部和外部)都应流经服务网关。

2、功能

  由于服务网关代理了所有的服务调用,因此它还能充当服务调用的中央策略执行点(PEP),通俗的说就能能够在此实现横切关注点,不用在各个微服务中实现。主要有以下几个:

  • 静态路由——服务网关将所有的服务调用放置在单个 URL 和 API 路由后,每个服务对应一个固定的服务端点,方便开发人员的服务调用。

  • 动态路由——服务网关可以检测传入的请求,根据请求数据和请求者执行职能路由。比如将一部分的调用路由到特定的服务实例上,比如测试版本。

  • 验证和授权——所有服务调用都经过服务网关,显然可以在此进行权限验证,确保系统安全。

  • 日志记录——当服务调用经过服务网关时,可以使用服务网关来收集数据和日志信息(比如服务调用次数,服务响应时间等)。还能确保在用户请求上提供关键信息以确保日志统计(比如给每个用户请求加一个 url 参数,每个服务中可通过该参数将关键信息对应到某个用户请求)。

看到这儿可能会有这样的疑问:所有调用都通过服务网关,难道服务网关不是单点故障和潜在瓶颈吗?

1. 在单独的服务器前,负载均衡器是很有用的。将负载均衡器放到多个服务网关前面是比较好的设计,确保服务网关可以实现伸缩。但是如果将负载均衡器置于所有服务前便不是一个好主意,会造成瓶颈。

2. 服务网关的代码应该是无状态的。有状态的应用实现伸缩性较为麻烦

3. 服务网关的代码应该轻量的。服务网关是服务调用的“阻塞点”,不易在服务网关处耽误较长的时间,比如进行同步数据库操作

三、实战

  使用 Netflix Zuul 来构建服务网关,配合之前的代码,让服务网关来管理服务调用。

在生产环境中不建议使用 zuul,该组件性能较弱,且已经停止更新

1、创建 zuulsvr 项目

  详细过程不赘述,和之前一样(注意 spring cloud 版本要和之前一致),主要 pom 依赖如下:

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

2、配置 zuul

  首先在启动加入注解开启 zuul 并注册到 eureka 中

开启zuul

  然后编写配置文件:

spring:
  application:
    name: zuulservice
#服务发现配置
eureka:
  instance:
    prefer-ip-address: true
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 5555

这样便以默认配置启动了 zuul 服务网关。

3、路由配置

  Zuul 核心就是一个反向代理。在微服务架构下,Zuul 从客户端接受微服务调用并将其转发给下游服务。要和下游服务进行沟通,Zuul 必须知道如何将进来的调用映射到下游路由中。Zuul 有一以下几种路由机制:

  • 通过服务发现自动映射路由
  • 通过服务发现手动映射路由
  • 使用静态 URL 手动映射

1)、服务发现自动映射

默认情况下,Zuul 根据服务 ID 来进行自动路由。先将组织服务中的延时去掉

注释延时代码

启动之前的所有服务实例,然后通过 postman 访问localhost:5555/organizationservice/organization/12,得到结果如下:

访问结果

说明服务网关自动路由成功。

  如果要查看 Zuul 服务器管理的路由,可以通过访问 Zuul 服务器上的/routes,返回结果如下:

{
  "/confsvr/**": "confsvr",
  "/licensingservice/**": "licensingservice",
  "/organizationservice/**": "organizationservice"
}

左边的路由由基于 Eureka 的服务 ID 自动创建的,右边为路由所有映射的 Eureka 服务 ID。

2)、服务发现手动手动

  如果觉得自动路由不好用,我们还可以更细粒度地明确定义路由映射。例如想要缩短组织服务名称来简化路由,可在application.yml配置中定义路由映射,在配置文件中加入如下配置:

zuul:
  routes:
    organizationservice: /org/**

  上面的配置将org开头的路径映射到组织服务上了。重启服务器,访问localhost:5555/org/organization/12,仍然能够获取到数据。

  现在访问/routes端点可以看到如下结果:

{
  "/org/**": "organizationservice",
  "/confsvr/**": "confsvr",
  "/licensingservice/**": "licensingservice",
  "/organizationservice/**": "organizationservice"
}

可以看到不光有自定义的组织路由,自动映射的组织路由也存在,如果想要排除自动映射的路由可配置ignored-services属性,用法如下:

zuul:
  routes:
    organizationservice: /org/**
  # 使用","分隔,“*”表示全部忽略
  ignored-services: "organizationservice"

  服务网关有一种常见模式是通过使用/api之类的标记来为所有服务调用添加前缀,可通过配置prefix属性来支持。用法如下:

zuul:
  routes:
    organizationservice: /org/**
  # 使用","分隔,“*”表示全部忽略
  ignored-services: "organizationservice"
  prefix: /api

配置后再次访问/routes端点可以看到路径前都加上了/api

3)、静态 URL 手动映射

  如果系统系统中还存在一些不受 Eureka 管理的服务,可以建立 Zuul 直接路由到一个静态定义的 URL。假设许可证服务是其他语言编写的 web 项目,并且希望通过 Zuul 来代理,可这样配置:

zuul:
  routes:
    #用于内部识别关键字
    licensestatic:
      path: /licensestatic/**
      url: http://localhost:8091

配置完成后重启 zuul 访问/routes端点如下所示,静态路由已经加入:

{
  "/api/licensestatic/**": "http://localhost:8091",
  "/api/org/**": "organizationservice",
  "/api/confsvr/**": "confsvr",
  "/api/licensingservice/**": "licensingservice",
  "/api/zuulservice/**": "zuulservice"
}

  licensestatic 端点不再使用 Eureka,直接将请求路由到localhost:8091。但是这里存在一个问题,如果许可证服务有多个实例,该如何用到负载均衡?这里只能配置一条路径指向请求。这里又有一个配置项来禁用 Ribbon 与 Eureka 集成,然后列出许可证服务的所有实例,配置如下:

#zuul配置
zuul:
  routes:
    #用于内部识别关键字
    licensestatic:
      path: /licensestatic/**
      serviceId: licensestatic
    organizationservice: /org/**
  # 使用","分隔,“*”表示全部忽略
  ignored-services: "organizationservice"
  prefix: /api

ribbon:
  eureka:
    #禁用Eureka支持
    enabled: false

licensestatic:
  ribbon:
    #licensestatic服务将会路由到下列地址
    listOfServers: http://localhost:10011,http://localhost:10012

配置完毕后,访问/routes端点发现licensestatic/**映射到了 licensestatic 服务上,相当于 Zuul 模拟了一个服务出来。但是 Eureka 上是没有这个服务的,所以需要禁用掉 Ribbon 的 Eureka 支持,不然是无法访问成功的(Ribbon 向 Eureka 查询该服务不存在,报错)。现在 x=连续访问localhost:5555//api/licensestatic/licensing/12,可以发现正常响应和 404 交替出现(10011 上能否访问成功,10012 报错 404),说明配置的多个地址生效了。

问题又来了

  禁用 eureka 支持会导致所有服务的地址都需要手动指定,ribbon 不会再从 eureka 中获取服务实例信息。所以没办法混合使用

  目前有两种办法来规避这个问题:

  1. 对于不能用 Eureka 管理的应用,可以建立一个单独的 Zuul 服务器来处理这些路由。

  2. 建立一个 Spring Cloud Sidecar 实例。Spring Cloud Sidecar 允许开发使用 Eureka 实例注册非 JVM 服务,然后再通过 Zuul 代理,相当于曲线救国

4、动态重载路由

  zuul 还有一个动态加载路由的功能,也就是在不重启 zuul 服务的情况下刷新路由。

  直接修改application.yml将 prefix 从/api改为/apis注意这里修改后要让修改生效需编译一次 application.yml 让修改替换到 target 文件中(idea 如此,eclipse 应该类似),或者直接到编译文件夹下修改 application.yml

  然后访问/refresh路径,可以看到如下返回值:

动态刷新

响应表明更新 prefix。然后访问/routes路径会发现前缀变成了apis

  这个功能与 spring cloud config 配合,用起来就是爽。

5、服务超时

  Zuul 使用 Netflix 的 Hystrix 和 Ribbon 库来进行 http 请求。so 也是有超时机制存在的。配置方法和前面的一篇类似。但是只能通过配置文件来进行,无法通过注解(这是 Zuul 管理的没有地方给你写注解)。通过配置hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds属性来实现。如果要为特定的服务配置只需将 default 替换为服务名就行了。

注意还要只有有另一个超时机制。虽然覆盖了 hystrix 的超时,但是 Ribbon 也会超时任何超过 5s 的调用。so 如果超时时间大于 5s 还要配置 Ribbon 的超时,配置方式如下:

#对所有服务生效
ribbon.readTimeout: 7000
#对组织服务生效
licensingservice.ribbon.readTimeout: 7000

6、重点:过滤器

  这才是服务网关真正重要的东西。有了过滤器才能实现自定义的通用处理逻辑。可在此进行通用的安全验证日志服务跟踪等操作。和 springboot 中的过滤器概念类似,这里就不做说明了。

  Zuul 支持以下四种过滤器:

  • 前置过滤器——在将请求发送到目的地之前被调用。通常进行请求格式检查、身份验证等操作。

  • 后置过滤器——在目标服务被调用被将响应发回调用者后被调用。通常用于记录从目标服务返回的响应、处理错误或审核敏感信息。

  • 路由过滤器——在目标服务被调用之前拦截调用。通常用来做动态路由。

  • 错误过滤器——在产生错误是调用,用于对错误进行统一处理。

下图展示了在处理客户端请求时,各种过滤器时如何工作的:

过滤器

下面说说如何来使用这些过滤器:

a、前置过滤器

  这里我们来实现一个过滤器-IdFilter,对每个请求检查请求头中是否有一个关联 id,无 id 生成一个 id 加入到 header 中。代码如下:

@Component
public class IdFilter extends ZuulFilter {

    private static final Logger LOGGER = LoggerFactory.getLogger(IdFilter.class);

    /**
     * 返回过滤器类型 ;pre:前置过滤器。post:后置过滤器。routing:路由过滤器。error:错误过滤器
     */
    @Override
    public String filterType() {
        return "pre";
    }

    /**
     * 过滤器执行顺序
     */
    @Override
    public int filterOrder() {
        return 1;
    }

    /**
     * 是否启动此过滤器
     */
    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        String id = ctx.getRequest().getHeader("id");
        //如果request找不到,再到zuul的方法中找id.request不允许直接修改response中的header,
        // 所以为了让后续的过滤器能够获取到id才有下面的语法
        if(id==null){
            id = ctx.getZuulRequestHeaders().get("id");
        }
        if (id == null) {
            id = UUID.randomUUID().toString();
            LOGGER.info("{} 无id,生成id:{}",ctx.getRequest().getRequestURI(), id);
            ctx.addZuulRequestHeader("id", id);
        } else {
            LOGGER.info("{}存在id:{}", ctx.getRequest().getRequestURI(), id);
        }
        return null;
    }
}

要在 Zuul 中实现过滤器,必须拓展 ZuulFilter 类(2.x 版本中不是这样的),然后覆盖上述 4 个方法。

  要给请求头加入一个 header 需要在ctx.addZuulRequestHreader("","")(上面代码中的 RequestContext 是 zuul 重写的,在其中加入了一些方法)方法中操作,zuul 会在发出请求是把 header 加到请求头中。(因为 Zuul 本质是一个代理,它截取请求,然后自己再发送这个请求,所有不能也没有必要在原来的 request 上加 header。

  重启项目 Zuul,访问localhost:5555/apis/licensestatic/licensing/12,可以看到控制台有如下打印:

前置过滤器

说明前置过滤器生效。

  现在从 zuul 服务网关发往许可证服务的 http 请求已经携带了 id。

b、后置过滤器

  后置过滤器通常用于进行敏感信息过滤和响应记录。这里我们实现一个后置过滤器,将许可证服务请求的响应内容打印到控制台上同时把idheader 插入到服务客户端请求的 response 中。

@Component
public class ResponseFilter extends ZuulFilter {

    private static final Logger LOGGER = LoggerFactory.getLogger(ResponseFilter.class);

    /**
     * 返回过滤器类型 ;pre:前置过滤器。post:后置过滤器。routing:路由过滤器。error:错误过滤器
     */
    @Override
    public String filterType() {
        return "post";
    }

    /**
     * 过滤器执行顺序
     */
    @Override
    public int filterOrder() {
        return 1;
    }

    /**
     * 是否启动此过滤器
     */
    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run(){
        RequestContext ctx = RequestContext.getCurrentContext();
        String id = ctx.getZuulRequestHeaders().get("id");
        ctx.getResponse().addHeader("id", id);
        try {
            BufferedReader reader = new BufferedReader(new InputStreamReader(ctx.getResponseDataStream()));
            String response = reader.readLine();
            LOGGER.info("响应为:{}", response);
            //写到输出流中,本来可以由zuul框架来操作,但是我们已经读取了输入流,zuul读不到数据了,所以要手动写响应到response
            ctx.getResponse().setHeader("Content-Type","application/json;charset=utf-8");
            ctx.getResponse().getWriter().write(response);
        } catch (Exception e) {
        }
        return null;
    }
}

经过这样一波操作,就能达到目的了。访问:localhost:5555/apis/licensestatic/licensing/12。控制台打印如下:

控制台打印

请求响应如下:

请求响应

c、路由过滤器

  路由过滤器用起来有点复杂,这里不写具体的实际代码,只是写一个思路。具体代码可以参考spring 微服务

  1. 获取当前请求路径
  2. 判断是否需要进行特殊路由
  3. 如需要进行特殊路由,在此进行 http 调用
  4. 将 http 调用的 response 写入到当前请求的 response 中

结束

  终于写完了,微服务的基础学习又近了一步,加油!

本篇代码存放于:github