问:为什么需要 API 网关服务 SpringCloud Zuul?
答:根据我们之前所学的知识,实现某个系统的功能是完全没问题的。但随着业务不断增加,随之而来以下两个问题。
1、运维人员。从运维角度来看,他们一般是要手工维护 F5 或者 Nginx 这样的设施里的路由规则和服务列表,当有实例增加或者减少或者IP地址变动的情况发生时,他们还需要手工地去同步修改这些信息,以保持实例信息与我们微服务的中间件配置内容一致。设想一下假如一个公司有100个实例需要维护,运维人员就成了“重复造轮子”的人了,而且极易出错。显然,我们需要一套机制来有效减低维护路由规则与服务实例列表的难度。
2、开发人员。一个好的系统都会有一些身份、权限校验机制。有时候为了防止客户端在发起请求时被篡改等安全方面的考虑,还会有一些签名校验的机制存在。而我们实施的是微服务架构,就将原本处于一个应用中的多个模块拆分成多个应用,我们就需要在每个应用中都实现一套相同的校验逻辑。这将是一个糟糕的架构设计。所以,我们还需要一套机制能够很好的解决微服务接口访问前置校验的冗余问题。
为了解决上面常见的架构问题,API 网关的概念应运而生。它的定义类似于面向对象设计模式中的 Facade 模式,相当于整个微服务架构系统的门面,所有的外部客户端访问都需要经过它来进行调度和过滤。它除了要实现请求路由、负载均衡、校验过滤等功能,还需要有更多的能力,比如与服务治理的结合、请求转发时的熔断机制、服务的聚合等一系列高级功能。
问:SpringCloud Zuul 如何解决上面的两个普遍问题?
答:Zuul 将自身注册为 Eureka 服务治理下的应用,同时从 EUreka 中获得所有其他微服务的实例信息。对路由规则的维护,Zuul 默认会将以服务名作为 ContextPath 的方式来创建路由映射。其次,对于类似签名的校验、登录校验在微服务中的冗余问题,理论上说,这些校验逻辑在本质上与微服务应用自身的业务并没有太多的关系,所以它们可以独立成一个单独的服务存在。它被剥离出来后,不是给各个微服务调用,而是在 API 网关服务上进行统一调用来对微服务接口做前置过滤,以实现对微服务接口的拦截和校验。Zuul 提供了一套过滤器机制,可以指定哪些规则的请求需要执行校验逻辑,只有通过校验的才会被路由到具体的微服务接口,不然就返回错误信息。这样改造,每个微服务应用就不需要进行非业务性质的校验逻辑了,可以更专注于业务逻辑的开发。所以,在微服务架构中,API 网关的使用几乎成为必然选择。
构建网关
我们先构建一个 SpringBoot 工程,起名:zuul-gateway。并在 pom.xml 中引入 spring-cloud-starter-zuul 依赖。
完整的 pom.xml 配置如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>MyProject</artifactId>
<groupId>com.study</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>zuul-gateway</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
<version>${netflix.eureka.client.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
</dependencies>
</project>
等待依赖下载完毕,我们跟进 zuul 依赖,看下它的依赖内容(Ctrl + 鼠标左键):
我们看到 pom 文件是:spring-cloud-starter-netflix-2.0.2.RELEASE.pom
说明:
1、spring-cloud-starter-netflix-ribbon 依赖:该依赖用来实现在网关服务进行路由转发时候的客户端负载均衡以及请求重试。
2、spring-cloud-starter-netflix-hystrix 依赖:该依赖用来在网关服务中实现对微服务转发时候的保护机制,通过线程隔离和断路器,防止微服务的故障引发 API 网关资源无法释放,从而影响其他服务。
3、spring-boot-starter-actuator 依赖:该依赖用来提供常规的微服务管理端点。
4、spring-cloud-starter-netflix-archaius 依赖:该依赖提供对配置信息的快速及线程安全访问,可用于从许多不同来源收集配置属性的框架。
接下来,创建一个启动类:ZuulGatewayApplication,并添加 @EnableZuulProxy 注解开启 Zuul 的 API 网关服务功能。
先创建一个包 com.study,注意启动类的位置。
完整的 ZuulGatewayApplication 代码如下:
package com.study;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
/**
* @author biandan
* @signature 让天下没有难写的代码
* @create 2019-10-27 下午 5:48
*/
@EnableZuulProxy
@SpringBootApplication
@EnableDiscoveryClient
@EnableEurekaClient
public class ZuulGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ZuulGatewayApplication.class,args);
}
}
接下来,创建 application.yml 配置文件,为了测试 zuul 的路由功能,须有路由规则的配置。我们可以使用之前的服务来做测试。具体内容如下:
server:
port: 7000
eureka:
instance:
hostname: main.study.com
lease-renewal-interval-in-seconds: 30
lease-expiration-duration-in-seconds: 90
client:
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:8000/eureka/
# 服务的名字
spring:
application:
name: eureka-zuul-gateway
# 路由规则的配置
zuul:
routes:
api-ribbon: # 针对 ribbon-consumer 服务的配置
path: /api-ribbon-url/**
serviceId: eureka-ribbon-client
api-feign: # 针对 feign-consumer 服务的配置
path: /api-feign-url/**
serviceId: eureka-feign-client
说明:
1、端口号 = 7000(可自定义)
2、配置路由规则的时候,我们分别定义两个名为 api-ribbon 和 api-feign 的路由来映射它们,同时将自己注册成服务,同时可以获取 eureka-ribbon-client 和 eureka-feign-client 两个服务的实例清单,这样就可以实现 path 映射服务,最后从服务中挑选实例来进行请求转发的完整路由机制。url 中符合 /api-ribbon/** 和 /api-feign/** 规则的,才由 api-ribbon 和 api-feign 负责转发。
注意,我们先把 eureka-client 模块的 EurekaClientApplication 启动类的 sayHello() 方法的线程休眠代码注释掉(不注释的话容易导致请求超时,返回 504):
测试 zuul 的路由转发功能,我们依次启动服务:注册中心(EurekaServerApplication)、注册中心集群(EurekaServerClusterApplication 可不启动)、路由网关(ZuulGatewayApplication)、服务提供者(EurekaClientApplication 9000端口、然后修改端口号=9001,再启动,一共2个服务。具体方法见之前的博客)、Ribbon服务消费者(RibbonConsumerApplication)、Feign服务消费者(FeignConsumerApplication)【考验电脑性能的时候到了】。
①我们先进入浏览器地址:http://main.study.com:8000/ 查看注册中心已注册的服务列表
②我们向 路由网关 发起以下请求(就是我们之前测试的在每个服务的 controller 定义的方法):
http://main.study.com:7000/api-ribbon-url/ribbon 和 http://main.study.com:7000/api-feign-url/feign 浏览器显示:
eureka-client-biandan 说:让天下没有难写的代码!from port =9000
eureka-client-biandan 说:让天下没有难写的代码!from port =9001
说明 Zuul 起到了网关路由的作用。
请求过滤
实现路由功能之后,我们的微服务应用接口就可以通过统一的 API 网关入口被客户端访问到了。但是每个客户端的用户请求微服务接口时,它们的权限一般都有限制,系统不想将所有的请求都对它们开放。举一个生活的简单例子:一个学校门口有保安,所有人进出校门保安都可以拦截到(放行和不放两种情况)。如果有人需要问路,保安会给他们指路,因为保安手里或者脑子里已经有整个学校的所有地点列表了。
为了实现对客户端请求的安全校验和权限控制(鉴权),比较好的做法就是将鉴权的微服务剥离出来成一个独立的服务,在这个微服务中实现鉴权的逻辑即可,在客户端的请求到达网关时进行鉴权,微服务应用就可以去除各种复杂的过滤器和拦截器了。这就是我们将要介绍的“请求过滤”。Zuul 允许开发者在 API 网关上通过过滤器来实现对请求的拦截和过滤,做法就是继承 ZuulFilter 抽象类并实现它定义的 4 个抽象函数即可。
我们在 zuul-gateway 模块中,创建一个过滤器类,继承 ZuulFilter ,然后重写 4 个抽象函数。
1、创建一个包 filter,在 filter 包里创建 MyFilter 类
当我们写完代码 extends ZuulFilter 时,发现编译器报错,意思是我们还需要重写 4 个抽象方法。介绍一下快捷键:Alt + Enter
鼠标点到 ZuulFilter 文字,按下快捷键 Alt + Enter(这个快捷键是默认的),然后点中“Implement methods”。
这样,4 个抽象方法就被重写了。只是逻辑我们还需要处理。MyFilter 完整代码如下:
package com.study.filter;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.http.HttpServletRequest;
import java.util.Objects;
/**
* @author biandan
* @signature 让天下没有难写的代码
* @create 2019-10-27 下午 8:06
*/
public class MyFilter extends ZuulFilter {
private static Logger LOGGER = LoggerFactory.getLogger(MyFilter.class);
/**
* 过滤器的类型,它决定过滤器在请求的哪个生命周期中进行
* @return
*/
@Override
public String filterType() {
return "pre";//这里定义为 pre,代表会在请求被路由之前执行,pre 是固定值。
}
/**
* 过滤器的执行顺序,当请求在一个阶段中存在多个过滤器时,需要根据该方法返回的值依次执行
* @return
*/
@Override
public int filterOrder() {
return 0;
}
/**
* 判断该过滤器是否需要被执行。
* @return
*/
@Override
public boolean shouldFilter() {
return true;//返回true,该过滤器对所有请求都会生效
}
/**
* 过滤器的具体逻辑
* @return
* @throws ZuulException
*/
@Override
public Object run() throws ZuulException {
RequestContext context = RequestContext.getCurrentContext();//获取请求上下文
HttpServletRequest request = context.getRequest();//获取请求内容
LOGGER.info("请求到过滤器了。request url = {},request method = {}",request.getRequestURL().toString(),request.getMethod());
Object accessToken = request.getParameter("accessToken");
//判断客户端传递的 accessToken 的值是否不为空且等于123
if(Objects.nonNull(accessToken) && "123".equals(accessToken)){
LOGGER.info("access token is ok,and accessToken ="+accessToken);
}else{
LOGGER.warn("access token is empty or not equals 123!");
context.setSendZuulResponse(false);//设置false,表示不对其进行路由
context.setResponseStatusCode(401);//自定义错误码
}
return null;
}
}
说明:
1、filterType:返回的字符串代表过滤器的类型,Zuul 定义了 4 种不同的生命周期类型
①pre:路由之前
②routing:路由发生时
③post:路由之后
④error:发生错误调用时
2、我们在过滤器的具体逻辑里判断客户端传递的 accessToken 是否等于 123,如果不等于则返回错误信息。在实际项目中,这里的逻辑可以写得很复杂,可以是操作数据库、NoSql(非关系型数据库)、缓存等来判断权限。
3、实现了自定义的过滤器之后,它并没有直接生效,我们还需要为其创建具体的 Bean 才能启动该过滤器。我们在启动类里增加如下内容即可:
@Bean
public MyFilter myFilter(){
return new MyFilter();
}
ZuulGatewayApplication 启动类完整的代码如下:
package com.study;
import com.study.filter.MyFilter;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
import org.springframework.context.annotation.Bean;
/**
* @author biandan
* @signature 让天下没有难写的代码
* @create 2019-10-27 下午 5:48
*/
@EnableZuulProxy
@SpringBootApplication
@EnableDiscoveryClient
@EnableEurekaClient
public class ZuulGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ZuulGatewayApplication.class,args);
}
@Bean
public MyFilter myFilter(){
return new MyFilter();
}
}
接下来,我们重启 zuul-gateway 服务,浏览器地址输入:
http://main.study.com:7000/api-ribbon-url/ribbon 和 http://main.study.com:7000/api-feign-url/feign
发现都返回了 401,如截图:
我们去 zuul-gateway 服务的控制台看下信息,说明都拦截了不正确的请求且返回错误信息:
接下来,我们在请求的参数里增加 accessToken=123,再次测试:
http://main.study.com:7000/api-ribbon-url/ribbon?accessToken=123
http://main.study.com:7000/api-feign-url/feign?accessToken=123
返回结果:
eureka-client-biandan 说:让天下没有难写的代码!from port =9000
eureka-client-biandan 说:让天下没有难写的代码!from port =9001
我们去 zuul-gateway 服务的控制台看下信息(打印的 url 不会把参数打印出来):
说明 API 网关对客户端的请求进行了过滤。
我们对 Zuul 的 API 网关做个小结:
1、它作为系统的统一入口,屏蔽了系统内部的其它微服务细节。
2、它可以与微服务治理 Eureka 无缝结合,实现自动化的服务实例维护以及负载均衡的路由转发。
3、它可以实现接口权限校验与微服务业务的逻辑解耦分离。
4、通过服务网关中的过滤器,在各生命周期中去校验请求的内容,将原本在对外服务层做的校验前移,保证了微服务的无状态性,让服务本身更集中关注业务逻辑的处理(通俗点说,就是让微服务不需要做更多鉴权的逻辑处理)。