Zuul路由详解
写在前面
在前面我们学习了Spring Cloud Zuul中的两类路由功能:传统路由方式和面向服务的路由方式的简单使用,接下来将进一步学习路由的相关知识。
传统路由配置
所谓的传统路由配置其实就是不依赖于服务发现机制的情况,也就是直接在配置文件中来具体指定每个路由表达式与服务实例的映射关系,进而实现API网关对外部请求的路由。
既然不依赖服务治理框架提供的服务发现功能,那么在实际情况中需要根据服务实例的数量来采取不同的配置方式,进而实现路由规则。
单实例配置
对于单实例的配置,我们可以通过zuul.routes.<route>.path
和zuul.routes.<route>.url
参数对的方式来进行配置,如下所示:
1 | zuul: |
通过上面的配置,只要是符合/user-service/**
规则的请求路径,都会被转发到http://localhost:8080/
地址。举个例子,假设当一个请求http://localhost:3005/user-service/hello
被发送到API网关上,由于/user-service/hello
能够被上述配置的path规则匹配,所以API网关会将请求转发到http://localhost:8080/hello
地址上。
多实例配置
对于多实例配置,则可以使用zuul.routes.<route>.path
和zuul.routes.<route>.serviceId
参数对的方式来进行配置,如下所示:
1 | zuul: |
同样该配置实现了对符合/user-service/**
规则的请求路径,都会被转发到http://localhost:8080/
和http://localhost:8081/
这两个实例地址的路由规则。
可以发现它的配置方式与服务路由的配置方式一样,都采用了zuul.routes.<route>.path
和zuul.routes.<route>.serviceId
参数对的映射方式,只是这里的serviceId是用户手动设置的服务名称,需要配合ribbon.listOfServers
参数来实现服务与实例的维护。
同时由于这里存在多个实例,那么API网关在进行路由转发的时候需要实现负载均衡策略,因此还需要借助于Spring Cloud Ribbon的支持。但是在前面我们也说过,Spring Cloud Zuul中自带了对Ribbon的依赖,因此只需配置上述示例参数即可,上述关于Ribbon的各个配置的作用简单介绍如下:
(1)ribbon.eureka.enabled=false
参数。由于我们在zuul.routes.<route>.serviceId
中设置的是服务名称,而默认情况下Ribbon会根据服务发现机制来获取配置服务名对应的实例清单。但是上面的例子中,并没有整合Eureka之类的服务治理框架,所以该配置其实是无法生效的,故将该参数设置为false。如果不这么做的话,那么前面配置的serviceId是获取不到对应实例的清单。
(2)user-service.ribbon.listOfServers
参数。请注意该参数内容必须与zuul.routes.<route>.serviceId
的配置相对应,开头的user-service对应serviceId的值,这两个参数的配置相当于在该应用内部手工维护了服务与实例的对应关系。
其实通过上面的学习,开发者很容易发现无论是单实例还是多实例的配置方式,都需要为每一对映射关系指定一个名称,也就是前面配置中的
面向服务的路由配置
之所以学习传统的路由配置方式,是为了预防服务注册中心挂掉,还需要政策提供服务的情况发生。但是在正常情况下,我们优先选择使用接下来即将学习的面向服务的路由配置。
在前面的入门例子中,我们知道Spring Cloud Zuul通过整合Spring Cloud Eureka,实现了对服务实例的发现与注册,即自动化维护,因此在进行服务路由配置的时候,不需要像传统方式那些来给serviceId指定具体的服务实例地址。不过依然还是通过zuul.routes.<route>.path
和zuul.routes.<route>.serviceId
参数对的方式来进行配置,其中
举个例子,假设我们要实现对符合/user-service/**
规则的请求路径都转发到名为user-service的服务实例上的路由规则,可以如下配置:
1 | zuul: |
这种方式和前面传统路由配置中,存在多实例的情况的配置是一样的。需要说明的是面向服务的路由配置除了使用path和serviceId映射的配置方式外,还有一种非常简单的方式,即zuul.routes.<serviceId>=<path>
,其中
1 | zuul: |
传统路由的映射方式比较直观且容易理解,API网关直接根据请求的URL路径找到最匹配的path表达式,直接转发给该表达式对应的url或者对应serviceId下配置的实例地址,以实现外部请求的路由:
那么问题来了,当采用path与serviceId组合,且使用在面向服务的路由配置方式实现时,在没有配置任何实例地址的情况下,外部请求经过API网关的时候,它是如何被解析并转发到服务具体实例的呢?
在前面我们就介绍过Zuul整合Eureka来实现面向服务的路由,实际上我们可以直接将API网关也看做Eureka服务治理体系下的一个普通微服务应用。这样它除了将自己注册到Eureka服务注册中心上之外,还会从服务注册中心获取所有服务以及它们的实例清单。因此借助于Eureka服务治理体系,API网关服务本身就已经维护了系统中所有serviceId与实例地址的映射关系。当有外部请求到达API网关的时候,根据请求的URL路径找到最佳匹配的path规则,API网关就可以知道要将该请求路由到哪个具体的serviceId上去。由于在API网关中已经知道serviceId对应服务实例的地址清单,那么只需要通过Ribbon的负载均衡策略,直接在这些清单中选择一个具体的实例进行转发就能完成路由工作了。
服务路由的默认规则
通过Eureka和Zuul的整合,我们只需维护“服务名”和“请求路径匹配表达式”之间的映射关系,但是在实际开发过程中发现大部分的路由规则几乎都会采用服务名来作为外部请求的前缀。举个例子来说,假设配置如下信息:
1 | zuul: |
是不是很尴尬,path路径的前缀使用了user-service,而serviceId(对应的服务名称)也是user-service。
可以发现这是一个非常有规律性的配置内容,因此希望可以自动完成,是的,当我们为Spring Cloud Zuul构建的API网关服务引入Spring Cloud Eureka之后,它就会为Eureka中的每一个服务都自动创建一个默认的路由规则,这些默认规则的path会使用serviceId配置的服务名作为请求前缀,就像上面例子所介绍的那样。
也就是说默认情况下所有的Eureka上的服务都会被Zuul自动地创建映射关系来进行路由,这会使得一些我们并不希望对外开放的服务也可能被外部访问的到。此时Zuul提供了另一个配置参数zuul.ignored-services
来设置一个服务匹配表达式,该表达式会定义不自动创建路由的规则,即Zuul在自动创建服务路由的时候会根据该表达式来进行判断,如果服务名匹配该表达式,则Zuul会跳过该服务,不为其创建路由规则。自然,当设置为zuul.ignored-services=*
的时候,Zuul将对所有的服务都不会自动创建路由规则,即关闭了自动创建默认路由功能。但是通常我们是不会这么干的,正常都是这样:先设置对所有服务都不自动创建默认路由,然后在配置文件中逐个为需要路由的服务添加映射规则,当然钱这里添加规则有两种方式:(1)使用path和serviceId组合的配置方式;(2)使用zuul.routes.<serviceId>=<path>
的配置方式。只有在配置文件中出现的映射规则会被创建路由,而从Eureka中获取其他的服务,Zuul将不会再为它们创建路由规则。
自定义路由映射规则
前面介绍了服务路由的默认规则,在正常情况下它是无法满足我们需求的,因此还需要学习如何自定义路由映射规则。
通常我们在进行微服务系统业务逻辑开发的时候,为了兼容外部不同版本的客户端程序(尽量不去强迫用户去升级客户端),一般都会采用开闭原则来进行设计与开发。这使得系统在迭代过程中,有时候会需要我们为一组互相配合的微服务定义一个版本标识来方便管理它们的版本信息,根据这个标识我们可以很容易的知道这些服务需要一起启动并配合使用。一般我们会采用下面类似的命名:userservice-v1、userservice-v2、userservice-v3等方式,而Zuul默认情况下自动会采用服务名作为前缀来创建路由表达式,如针对前面所列举的userservice-v1、userservice-v2、userservice-v3则创建的路由表达式为/userservice-v1、/userservice-v2和/userservice-v3,但是这样生成出来的表达式规则较为单一,不利于通过路径规则来进行管理。
对于上面那种情况,我们通俗的做法是为这些不同版本的微服务应用生成以版本代号作为路由前缀定义的路由规则,比如/v1/userservice/。此时通过这样具有版本号前缀的URL路径,我们就可以很容易的通过路径表达式来归类和管理这些具有版本信息的微服务。
也就是说如果我们的各个微服务应用都遵循了类似于userservice-v1的命名规则(即通过-
分隔的规范来定义服务名和服务版本标识),这样我们就可以使用Zuul中自定义服务与路由映射关系的功能,来为符合上述规则的微服务自动化的创建类似于/v1/userservice/**
的路由匹配规则。那么问题来了,如何实现上述功能呢,其实非常简单,只需在API网关程序的入口类中添加创建PatternServiceRouteMapper对象的方法,如下代码:
1 | @Bean |
PatternServiceRouteMapper对象可以让开发者通过正则表达式来自定义服务与路由映射的生成关系。其中构造函数中有两个参数,第一个是用于匹配服务名称是否符合该自定义规则的正则表达式,第二个参数则是定义根据服务名中定义的内容转换出的路径表达式规则。
当开发者在API网关中定义了PatternServiceRouteMapper之后,那么只要符合第一个参数定义规则的服务名,都会优先使用该实现构建出的路径表达式;没有匹配上的服务则依旧使用默认的路由映射规则,也就是采用完整服务名作为前缀的路径表达式。
路径匹配
通过上面的学习,我们知道无论是采用传统的路由配置方式还是服务路由的配置方式,都需要为每个路由规则定义相匹配的表达式,也就是前面多次提到的path参数。在Zuul中,路由匹配的路径表达式使用了Ant风格定义。Ant风格定义路径表达式使用起来非常简单,一共存在三种通配符:?
、*
和**
。接下来学习三种通配符的具体用法,如下表所示:
通配符 | 说明 |
---|---|
? | 匹配任意单个字符 |
* | 匹配任意数量的字符 |
** | 匹配任意数量的字符,支持多级目录 |
为了更好的理解上述表格的含义,这里通过一个例子来加深印象: |
URL路径 | 说明 |
---|---|
/hello-service/? |
它可以匹配/hello-service/ 之后拼接一个任意字符的路径,如/hello-service/a 、/hello-service/b 或者/hello-service/c 等 |
/hello-service/* |
它可以匹配/hello-service/ 之后拼接任意字符的路径,如/hello-service/a 、/hello-service/aa 或者/hello-service/bbb 等,但是它无法匹配诸如/hello-service/a/b 等路径 |
/hello-service/** |
它不仅可以匹配/hello-service/* 包含的内容之外,还可以匹配诸如/hello-service/a/b 之类的多级目录路径 |
注意当我们使用通配符的时候,经常会碰到这样的情况:一个URL路径可能会被多个不同路由的表达式匹配上。举个例子,假设一开始系统建设初期实现了hello-service服务,并配置了如下的路由规则:
1 | zuul: |
但是后期随着版本的迭代,我们对hello-service服务进行了一些功能拆分,将原来属于hello-service服务的某些功能拆分到了另外一个全新的服务hello-service-one中,而这些拆分的外部调用URL路径希望能够符合规则/hello-service/one/**
,此时我们就需要在配置文件中增加一个路由规则,完整的配置如下所示:
1 | zuul: |
此时假设用户调用hello-service-one服务的URL路径时,实际上同时会被/user-service/**
和/user-service/one/**
这两个表达式所匹配。而我们肯定是希望API网关服务能优先选择后者,即/user-service/one/**
路由,然后再匹配/user-service/**
路由才能实现上述需求。如果开发者使用的是property格式的配置文件则是无法保证上述路由顺序,而使用YAML格式的配置文件则是可以的,因此这里有必要了解路由匹配算法。
打开SimpleRouteLocator类,可以看到里面有一个getZuulRoute方法,查看一下该方法的源码如下所示:
1 | protected ZuulRoute getZuulRoute(String adjustedPath) { |
从上面的路由匹配算法中可以知道,它通过线性遍历的方式来使用路由规则匹配请求路径,然后在请求路径获取到第一个匹配的路由规则后就返回并结束匹配过程。因此当存在多个匹配的路由规则时,匹配结果完全取决于路由规则的书写顺序。
接下来查看一下其中的路由加载方法locateRoutes的源码,如下所示:
1 | protected Map<String, ZuulRoute> locateRoutes() { |
上面就是基础的路由规则加载算法,我们可以看到这些路由规则是通过LinkedHashMap来保存的,也就是说路由规则的保存是有一定顺序的。其内容的加载则是通过遍历配置文件中的路由规则来依次加入的,而保存顺序则是遍历顺序,所以配置文件中路由规则的书写顺序非常重要。而property格式的配置文件无法保证内容的顺序,而使用YAML格式的配置文件则是可以的,因此如果想实现有序的路由规则,建议选择使用YAML格式的配置文件。
此时application.yml配置文件中的信息为:
1 | zuul: |
这样就实现了上述需求。
忽略表达式
尽管通过path参数定义的Ant风格的表达式已经能完成API网关上的路由规则配置功能,但是当我们不希望某个URL表达式被API网关路由的时候,此时应该如何操作呢?Zuul在设计的时候出于细粒度和灵活配置的考虑,提供了一个忽略表达式参数zuul.ignored-patterns
来设置忽略URL表达式,使之不会被API网关路由。
举个例子,假设我们不希望任何包含hello目录的链接被路由,如/hello-service/hello
、/hello-service/a/hello
//hello-service/hello/v1
等,此时可以在配置文件中添加如下信息:
1 | zuul: |
为了更好的演示这个效果,可以修改上述application.yml配置文件信息为:
1 | zuul: |
然后启动服务注册中心(项目名称为eureka-server)、服务提供者(项目名称为eureka-client,服务名称为hello-service),以及这里的api-gateway项目。首先在浏览器中访问http://localhost:8081/hello
,可以看到这是服务提供者的接口,此时信息为:
接着尝试通过网关来访问hello-service服务的/hello接口,即在浏览器中访问http://localhost:3005/hello-service/hello
地址,尽管该访问路径完全符合path参数定义的/hello-service/**
规则,但是由于该路由又符合zuul.ignored-patterns
参数设置的规则,所以该路径不会被正确路由,用户在控制台中还能看到如下提示没有匹配路由的输出信息:
1 | o.s.c.n.z.f.pre.PreDecorationFilter : No route found for uri: /hello-service/hello |
然后修改上述配置文件,将其中的zuul.ignored-patterns
参数给注释掉,然后重启api-gateway项目,在浏览器中访问http://localhost:3005/hello-service/hello
链接,可以看到结果如下所示:
这就说明之前所配置的忽略表达式确实生效了。需要说明的是,该参数在使用时要注意它的范围不是针对某个路由,而是对所有的路由都生效,因此在设置的时候需要全面考虑URL规则,防止忽略一些不该被忽略的URL路径。
路由前缀
现在又有一个需求,就是我想为网关上的路由规则都增加/api
前缀,那么这个需求该如何实现呢?为了方便全局地为路由规则增加前缀信息,Zuul提供了zuul.prefix
参数。对于前面的需求,只需在配置文件中增加zuul.prefix=/api
即可。
另外,对于代理前缀会默认从路径中移除这一问题,开发者可以通过设置zuul.stripPrefix=false
参数来关闭该移除代理前缀的动作。当然了,如果开发者只是想对指定路由移除代理前缀的动作,那么可以使用zuul.routes.<route>.strip-prefix=true
参数来进行设置。
请注意在使用zuul.prefix
参数的时候,需要谨慎使用或者避开一些引发Bug的配置规则,那么哪些规则会引发Bug,注意笔者使用SpringCloud版本为Hoxton.SR7
。
在application.yml配置文件中添加如下信息:
1 | zuul: |
可以看到我们配置了三个路由关系:/api/a/**
、/api-b/**
和/cccc/**
,这三个路径规则都会被路由到hello-service服务上去,即开发者在浏览器地址栏中访问http://localhost:3005/api/a/hello
、http://localhost:3005/api-b/hello
和http://localhost:3005/cccc/hello
这三个链接时,其实就相当于访问http://localhost:8081/hello
这个链接。
但是当我们在上述配置中添加了zuul.prefix=/api
这一参数后,那么再次访问三个链接可以得到如下的结果:
再来将之前的api-b中的配置由/api-b/**
,修改为/api-b/api/**
1 | zuul: |
然后重启项目,再次访问http://localhost:3005/api/api-b/api/hello
,可以看到输出结果为:
再次将之前的api-b中的配置由/api-b/api/**
,修改为/api-b/api/api/**
,然后重启项目,再次访问http://localhost:3005/api/api-b/api/api/hello
,可以看到输出结果为:
通过上面的验证,这就说明开头包含两级api的链接是无法访问的,其实就是路由不到正确的服务接口,只有开头不包含两级api的链接才可以使用,但是三级、四级这种由于用的较少,因此笔者就不验证了。
本地跳转
在Zuul实现的API网关路由功能中,还支持forward形式的服务端跳转配置。这种服务端跳转配置非常容易实现,只需通过使用path与url的配置方式即可完成url中使用forward来指定需要跳转的服务器资源路径。
举个例子,看下面一段applicatiom.yml配置文件中的配置信息:
1 | zuul: |
解释一下上述配置信息的含义:它其实是配置实现了两个路由规则,其中api-a路由实现了将符合/api-a/**
规则的请求转发到http://localhost:8001
这一地址上;而api-b路由则实现了将符合/api-b/**
规则的请求转发到以/local
为前缀的请求上,由API网关进行本地处理。
举个例子,假设当API网关接收到请求/api-b/hello
时,它符合api-b的路由规则,所以该请求会被API网关转发到网关的/local/hello
请求上进行本地处理。既然要在API网关上实现本地跳转,所以我们也需要为本地跳转实现对应的请求接口,即在API网关上增加一个/local/hello
的接口,才能让api-b路由规则生效。在api-gateway项目目录下新建一个controller包,并在里面新建一个HelloController类,其中的代码为:
1 | @RestController |
如果不提供一个/local/hello
接口,那么Zuul在进行forward转发的时候会因为找不到该请求而返回404错误。
Cookie与头信息
默认情况下,Spring Cloud Zuul在请求路由时会过滤掉HTTP请求头中的一些敏感信息,防止它们被传递到下游的外部服务器。默认的敏感头信息是通过zuul.sensitive-headers
参数来定义的,查看该配置信息,可以看到它是一个Set类型:
1 | private Set<String> sensitiveHeaders = new LinkedHashSet(Arrays.asList("Cookie", "Set-Cookie", "Authorization")); |
且从源码可以看出它默认的属性包括Cookie、Set-Cookie和Authorization等。所以当我们在开发Web应用时常用的Cookie,它在Spring Cloud Zuul网关中默认是不会传递的,这就会引发一个常见的问题:如果我们要将使用了Spring Security、Shiro等安全框架构建的Web应用通过Spring Cloud Zuul构建的网关来进行路由时,由于Cookie信息无法传递,那么我们的Web应用将无法实现登录和鉴权功能。
那么如何解决这个问题呢?解决办法很多,这里推荐几种方法:(1)通过设置全局参数为空来覆盖默认值。这种方法较为粗暴,但也是比较容易想到的。配置文件中的信息设置如下:
1 | zuul: |
但是个人并不推荐大家使用这种方式,尽管它可以实现Cookie的传递,但是破坏了默认设置的用意。在微服务架构的API网关中,对于无状态的RESTful API请求肯定是要远多于这些Web类应用请求的,甚至还有一些架构设计会将Web类应用和App客户端都一样都归为API网关之外的客户端应用。
因此第二种方法(2)就是通过指定路由的参数来配置,存在两种配置方法:(a)对指定路由开启自定义敏感头:
1 | zuul: |
(b)将指定路由的敏感头设置为空:
1 | zuul: |
个人比较推荐使用第二种方法中的两种方式,它们仅仅针对指定的Web应用开启对敏感信息的传递,影响范围较小,不至于引起其他服务的信息泄露问题。
重定向问题
在解决了Cookie问题后,我们已经可以通过网关来访问并登录到Web应用,但是这时候又会出现另一个问题:虽然可以通过网关访问登录页面并发起登陆请求,但是登录成功后,我们跳转到的页面URL却是具体Web应用实例的地址,而不是通过网关的路由地址。这个问题非常严重,因为使用API网关的一个重要原因就是要将网关作为统一入口,从而不暴露所有的内部服务细节。那么究竟是什么原因导致这一问题的发生呢?
通过浏览器开发工具查看登录以及登录之后的请求详情,可以发现引起问题的大致原因是:由于Spring Security或Shiro在登录完成后,通过重定向的方式跳转到登录后的页面,此时登录后的请求结果状态码为302,请求响应头信息中的Location指向了具体的服务实例地址,而请求头信息中的Host也指向了具体的服务实例IP地址和端口。所以,该问题的根本原因在于Spring Cloud Zuul在路由请求时,并没有将最初的Host信息设置正确。解决问题的办法就是在配置文件中添加如下参数:
1 | zuul: |
这样就使得网关在进行路由转发前为请求设置Host头信息,以标识最初的服务端请求地址。
Hystrix和Ribbon支持
在前面我们说过spring-cloud-starter-netflix-zuul
依赖本身就包含了对spring-cloud-starter-netflix-hystrix
和spring-cloud-starter-netflix-ribbon
模块的依赖,所以Zuul本身就拥有线程隔离和断路器的自我保护功能,以及对服务调用的客户端负载均衡功能。
需要注意的是,当使用path和url的映射关系来配置路由规则的时候,对于路由转发的请求不会采用HystrixCommand来包装,所以这类路由请求没有线程隔离和断路器的保护,并且也不会有负载均衡的能力。因此,我们在使用Zuul的时候,尽量使用path和serviceId的组合来进行配置,这样不仅可以保证API网关的健壮和稳定,也能使用到Ribbon的客户端负载均衡功能。
我们在使用Zuul搭建API网关的时候,可以通过Hystrix和Ribbon的参数来调整路由请求的各种超时时间等配置,如下面这些参数的设置:
(1)用于设置API网关中路由转发请求的HystrixCommand执行超时时间,单位为毫秒:
1 | hystrix: |
当路由转发请求的命令执行时间超过该配置值以后,Hystrix会将该执行命令标记为TIMEOUT并抛出异常,Zuul会对该异常进行处理,并返回如下JSON信息给外部调用方:
1 | { |
(2)用于设置路由转发请求的时候,创建请求连接的超时时间,单位为毫秒:
1 | ribbon: |
请注意当ribbon.ConnectTimeout
参数设置的值小于hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds
配置的值的时候,若出现路由请求连接超时的情况时,它会自动进行重试路由请求,如果重试依然失败,则Zuul会返回如下JSON信息给外部调用方:
1 | { |
如果ribbon.ConnectTimeout
参数设置的值大于hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds
配置的值的时候,若出现路由请求连接超时的情况时,由于此时对于路由转发的请求命令已经超时,所以不会进行重试路由请求,而是直接按请求命令超时处理,返回TIMEOUT的错误信息。
(3)用于设置路由转发请求的超时时间,单位为毫秒:
1 | ribbon: |
它的处理与ribbon.ConnectTimeout
类似,只是它的超时是对请求连接建立之后的处理时间。
如果ribbon.ReadTimeout
参数设置的值小于hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds
配置的值的时候,若路由请求的处理时间超过该配置值且依赖服务的请求还未响应的时候,会自动进行重试路由请求。如果重试后依然没有获得请求响应,Zuul会返回NUMBEROF_RETRIES_NEXTSERVER_EXCEEDED
错误。
如果ribbon.ReadTimeout
参数设置的值大于hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds
配置的值的时候,若路由请求的处理时间超过该配置值且依赖服务的请求还未响应的时候,不会进行重试路由请求,而是直接按请求命令超时处理,返回TIMEOUT的错误信息。
通过上面的学习我们知道,在使用Zuul的服务路由时,如果路由转发请求发生超时(连接超时或者处理超时),只要超时时间的设置小于Hystrix的命令超时时间,那么它就会自动发起重试。
但是在有些情况下,我们可能需要关闭该重试机制,那么此时则可以通过下面的两个参数来进行设置:
1 | zuul: |
请注意其中的zuul.retryable=false
参数用来全局关闭重试机制,而zuul.routes.<route>.retryable=false
则是指定路由关闭重试机制。