我去,你竟然还不会用API网关!
从应用程序架构的变迁过程可以发现,随着业务多变性、灵活性的不断提高,应用程序需要以更加灵活的组合来应对。
图片来自 Pexels
同时为了应对业务的细分以及高并发的挑战,微服务的架构被广泛使用,由于微服务架构中应用会被拆分成多个服务。
为了方便客户端对这些服务的调用于是引入了 API 的概念。今天我们就来看看API 网关的原理以及它是如何应用的。
API 网关的定义
网关一词最早出现在网络设备,比如两个相互独立的局域网之间通过路由器进行通信, 中间的路由被称之为网关。
落实在开发层面来说,就是客户端与微服务系统之间存在的网关。从业务层面来说,当客户端完成某个业务的时候,需要同时调用多个微服务。
图1 :API 网关加入前后对比
如果这些服务需要客户端分别调用才能完成,会增加请求的复杂度,同时也会带来网络调用性能的损耗。因此,针对微服务的应用场景就推出了 API 网关的调用。
在客户端与微服务之间加入下单 API 网关,客户端直接给这个 API 网关下达命令,由于后者完成对其他三个微服务的调用并且返回结果给客户端。
从系统层面来说,任何一个应用系统如果需要被其他系统调用,就需要暴露 API,这些 API 代表着的功能点。
正如上面下单的例子中提到的,如果一个下单的功能点需要调用多个服务的时候,在这个下单的 API 网关中就需要聚合多个服务的调用。
这个聚合的方式有点像设计模式中的门面模式(Facade),它为外部的调用提供了一个统一的访问入口。
图 2:对接两个系统的 API 网关
从客户端类型层面来说,为了屏蔽不同客户端调用差异也可以加入 API 网关。
图 3:对接客户端和服务端的 API 网关
由于 API 网关所处的位置是客户端与微服务交界的地方,因此从功能上它还包括:路由,负载均衡,限流,缓存,日志,发布等等。
Spring Cloud Gateway 概念与定义
API 网关的定义中我们提到了为什么要使用 API 网关,是为了解决客户端对多个微服务进行访问的问题。
由于服务的切分导致一个操作需要同时调用多个服务,因此为这些服务的聚合提供一个统一的门面,这个门面就是 API 网关。
针对于 API 网关有很多的实现方式,例如:Zuul,Kong 等等。这里我们以及 Spring Cloud Gateway 为例展开给大家介绍其具体实现。
一般来说,API 网关对内将微服务进行集合,对外暴露的统一 URL 或者接口信息供客户端调用。
图 4:路由、断言和过滤器
如图 4 所示,Spring Cloud Gateway 由三部分组成:
①路由(Route):任何一个来自于客户端的请求都会经过路由,然后到对应的微服务中。
每个路由会有一个唯一的 ID 和对应的目的 URL。同时包含若干个断言(Predicate)和过滤器(Filter)。
②断言(Predicate):当客户端通过 Http Request 请求进入 Spring Cloud Gateway 的时候,断言会根据配置的路由规则,对 Http Request 请求进行断言匹配。
说白了就是进行一次或者多次 if 判断,如果匹配成功则进行下一步处理,否则断言失败直接返回错误信息。
③过滤器( Filter):简单来说就是对流经的请求进行过滤,或者说对其进行获取以及修改的操作。注意过滤器的功能是双向的,也就是对请求和响应都会进行修改处理 。
一般来说 Spring Cloud Gateway 中的过滤器有两种类型:
Gateway Filter
Global Filter
Gateway Filter 用在单个路由和分组路由上。Global Filter 可以作用于所有路由,是一个全局的 Filter。
Spring Cloud Gateway 工作原理
图 5:Spring Cloud Gateway 处理请求流程图
如图 5 所示,当客户端向 Spring Cloud Gateway 发起请求,该请求会被 HttpWebHandlerAdapter 获取,并且对请求进行提取,从而组装成网关上下文。
将组成的上下文信息传递到 DispatcherHandler 组件。DispatcherHandler 作为请求分发处理器,主要负责将请求分发到对应的处理器进行处理。
这里请求的处理器包括 RoutePredicate HandlerMapping (路由断言处理映射器) 。
路由断言处理映射器用于路由的查找,以及找到 路由后返回对应的 FilteringWebHandler。
其负责组装 Filter 链表并执行过滤处理,之后再将请求转交给应用服务,应用服务处理完后,最后返回 Response 给客户端 。
其中 FilteringWebHandler 处理请求的时候会交给 Filter 进行过滤的处理。
这里需要注意的是由于 Filter 是双向的所以,当客户端请求服务的时候,会通过 Pre Filter 中的 Filter 处理请求。
当服务处理完请求以后返回客户端的时候,会通过 Post Filter 再进行一次处理。
Spring Cloud Gateway 最佳实践
上面介绍了 Spring Cloud Gateway 的定义和实现原理,下面根据几个常用的场景介绍一下 Spring Cloud Gateway 如何实现网关功能的。
我们会根据基本路由、权重路由、限流、动态路由几个方面给大家展开介绍。
基本路由
基本路由,主要功能就是在客户端请求的时候,根据定义好的路径指向到对应的 URI。这个过程中需要用到 Predicates(断言)中的 Path 路由断言处理器。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
@Bean
public RouteLocator routeLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route(r ->r.path("/baidu")
.uri("http://www.baidu.com/").id("baidu_route")
).build();
}
spring:
cloud:
gateway:
routes:
- id: baidu_route
uri: http://baidu.com:80/
predicates:
- Path=/baidu
权重路由
这个使用场景相对于上面的简单路由要多一些。由于每个微服务发布新版本的时候,通常会保持老版本与新版版同时存在。
然后通过网关将流量逐步从老版本的服务切换到新版本的服务。这个逐步切换的过程就是常说的灰度发布。
此时,API 网关就起到了流量分发的作用,通常来说最开始的老版本会承载多一些的流量,例如 90% 的请求会被路由到老版本的服务上,只有 10% 的请求会路由到新服务上去。
图 6:灰度发布,路由到新/老服务
如下代码所示,假设 API 网关还是采用 8080 端口,需要针对两个不同的服务配置路由权重。因此在 routes 下面分别配置 service_old 和 service_new。
server.port: 8080
spring:
application:
name: gateway-test
cloud:
gateway:
routes:
- id: service_old
uri: http://localhost:8888/v1
predicates:
- Path=/gatewaytest
- Weight=service, 90
- id: service_new
uri: http://localhost:8888/v2
predicates:
- Path=/gatewaytest
- Weight=service, 10
在 Predicates(断言)中定义了的 Path 是想通的都是“/gatewaytest”,也就是说对于客户端来说访问的路径都是一样的,从路径上客户不会感知他们访问的是新服务或者是老服务。
主要参数是在 Weight,针对老/新服务分别配置的是 90 和 10。也就是有 90% 的流量会请求老服务,有 10% 的流量会请求新服务。
简单点说,如果有 100 次请求,其中 90 次会请求 v1(老服务),另外的 10 次会请求 v2(新服务)。
限流
当服务在短时间内迎来高并发,并发量超过服务承受的范围就需要使用限流。例如:秒杀、抢购、下单服务。
通过请求限速或者对一个时间窗口内的请求进行限速来保护服务。当达到限制速率则可以拒绝请求,返回错误代码,或者定向到友好页面。
一般的中间件都会有单机限流框架,支持两种限流模式:
控制速率
控制并发
<dependency>
<groupId>com.github.vladimir-bukhtoyarov</groupId>
<artifactId>bucket4j-core</artifactId>
<version>4.0.0</version>
</dependency>
由于需要对于用户请求进行监控,因此通过实现 GatewayFilter 的方式自定义 Filter,然后再通过 Gateway API Application 应用这个自定义的 Filter。
这里我们使用的是令牌桶的方式进行限流,因此需要设置桶的容量(capacity),每次填充的令牌数量(refillTokens)以及填充令牌的间隔时间(refillDuration)。
初始化这三个参数以后,通过 createNewBucket 方法针对请求建立令牌桶(bucket),在 Filter 方法中实现限流的主要逻辑。
通过 ServerWebExchange 获取请求的上下文中的 IP 信息,针对 IP 建立对应的令牌桶,这个 IP 与令牌桶的对应关系放到了 LOCAL_CACHE 中。
每次请求经过的时候通过 tryConsume(1) 方法消费一个令牌,直到没有令牌的时候返回 HttpStatus.TOO_MANY_REQUESTS 的状态码(429),此时网关直接返回请求次数太多,即便是再有请求进来也不会路由到对应的服务了。
public class GatewayRateLimitFilterByIp implements GatewayFilter, Ordered {
private static final Map<String, Bucket> LOCAL_CACHE = new ConcurrentHashMap<>();
int capacity;
int refillTokens;
Duration refillDuration;
public GatewayRateLimitFilterByIp() {
}
public GatewayRateLimitFilterByIp(int capacity, int refillTokens, Duration refillDuration) {
this.capacity = capacity;
this.refillTokens = refillTokens;
this.refillDuration = refillDuration;
}
private Bucket createNewBucket() {
Refill refill = Refill.of(refillTokens, refillDuration);
Bandwidth limit = Bandwidth.classic(capacity, refill);
return Bucket4j.builder().addLimit(limit).build();
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
Bucket bucket = LOCAL_CACHE.computeIfAbsent(ip, k -> createNewBucket());
if (bucket.tryConsume(1)) {
return chain.filter(exchange);
} else {
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return exchange.getResponse().setComplete();
}
}
}
上面的代码定义了 Filter 其中针对访问的 IP 生成令牌桶,并且定义了桶的大小、每次放入桶令牌的个数、放入令牌的间隔时间。
并且通过 Filter 方法重写了过滤的逻辑,那么下面只需要将这个 Filter 应用到 Spring Cloud Gateway 的规则上去就可以了。通过下面代码定义网关的路由断言和过滤器。
在 Filters 中新建一个上面代码定义的过滤器,指定容量是 20,每两秒放入令牌,每次放入一个令牌。
@Bean
public RouteLocator testRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route(r -> r.path("/rateLimit")
.filters(f -> f.filter(new GatewayRateLimitFilterByIp(20,1,Duration.ofSeconds(2))))
.uri("http://localhost:8888/rateLimit")
.id("rateLimit_route")
).build();
}
这里的限流只是给大家提供一种思路,通过实现 GatewayFilter,重写其中的 Filter 方法,加入对流量的控制代码,然后在 Spring Cloud Gateway 中进行应用就可以了。
动态路由
由于 Spring Cloud Gateway 本身也是一个服务,一旦启动以后路由配置就无法修改了。
无论是上面提到的编码注入的方式还是配置的方式,如果需要修改都需要重新启动服务。
如果回到 Spring Cloud Gateway 最初的定义,我们会发现每个用户的请求都是通过 Route 访问对应的微服务,在 Route 中包括 Predicates 和 Filters 的定义。
只要实现 Route 以及其包含的 Predicates 和 Filters 的定义,然后再提供一个 API 接口去更新这个定义就可以动态地修改路由信息了。
按照这个思路需要做以下几步来实现:
①定义 Route、Predicates 和 Filters
public class FilterDefinition {
//Filter Name
private String name;
//对应的路由规则
private Map<String, String> args = new LinkedHashMap<>();
}
public class PredicateDefinition {
//Predicate Name
private String name;
//对应的断言规则
private Map<String, String> args = new LinkedHashMap<>();
}
public class RouteDefinition {
//断言集合
private List<PredicateDefinition> predicates = new ArrayList<>();
//路由集合
private List< FilterDefinition > filters= new ArrayList<>();
//uri
private String uri;
//执行次序
private int order = 0;
}
②实现路由规则的操作,包括添加,更新,删除
有了路由的定义(Route,Predicates,Filters),然后再编写针对路由定义的操作。
例如:添加路由,删除路由,更新路由之类的。编写 RouteServiceImpl 实现 ApplicationEventPublisherAware。
主要需要 override 其中的 setApplicationEventPublisher 方法,这里会传入 ApplicationEventPublisher 对象,通过这个对象发布路由定义的事件包括:add,update,delete。
@Service
public class RouteServiceImpl implements ApplicationEventPublisherAware {
@Autowired
private RouteDefinitionWriter routeDefinitionWriter;
private ApplicationEventPublisher publisher;
//添加路由规则
public String add(RouteDefinition definition) {
routeDefinitionWriter.save(Mono.just(definition)).subscribe();
this.publisher.publishEvent(new RefreshRoutesEvent(this));
return "success";
}
public String update(RouteDefinition definition) {
try {
this.routeDefinitionWriter.delete(Mono.just(definition.getId()));
} catch (Exception e) {
}
try {
routeDefinitionWriter.save(Mono.just(definition)).subscribe();
this.publisher.publishEvent(new RefreshRoutesEvent(this));
return "success";
} catch (Exception e) {
}
}
public String delete(String id) {
try {
this.routeDefinitionWriter.delete(Mono.just(id));
return "delete success";
} catch (Exception e) {
}
}
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
this.publisher = applicationEventPublisher;
}
③对外部提供 API 接口能够让用户或者程序动态修改路由规则
从代码上来说就是一个 Controller。这个 Controller 中只需要调用 routeServiceImpl 就行了,主要也是用到客制化路由实现类中的 add,update,delete 方法。
说白了就是对其进行了一次包装,让外部系统可以调用,并且修改路由的配置。
public class RouteController {
@Autowired
private routeServiceImpl routeService;
@PostMapping("/add")
public String add(@RequestBody RouteDefinition routeDefinition) {
try {
RouteDefinition definition = assembleRouteDefinition(routeDefinition);
return this.dynamicRouteService.add(definition);
} catch (Exception e) {
}
return "succss";
}
}
④启动程序进行路由的添加和更新操作
假设更新 API 网关配置的服务在 8888 端口上。于是通过 http://localhost:8888/actuator/gateway/routes 访问当前的路由信息,由于现在没有配置路由这个信息是空。
{
"filter":[],
"id":"baidu_route",
"order":0,
"predicates":[{
"args":{
"pattern":"/baidu"
},
"name":"Path"
}],
"uri":"https://www.baidu.com"
}
Json 中配置的内容和简单路由配置的内容非常相似。设置了 Route,当 Predicates 为 baidu 的时候,将请求引导到 www.baidu.com 的网站进行响应。
此时再通过访问 http://localhost:8888/baidu 的路径访问的时候,就会被路由到 www.baidu.com 的网站。
{
"filter":[],
"id":"CTO_route",
"order":0,
"predicates":[{
"args":{
"pattern":"/CTO"
},
"name":"Path"
}],
"uri":"https://www.51CTO.com"
}
在更新完成以后,再访问 http://localhost:8888/CTO 的时候就会把引导到 www.51CTO.com 的网站了。
通过上面四步操作,即使不重启 Spring Cloud Gateway 服务也可以动态更改路由的配置信息。
总结
由于微服务的盛行,API 网关悄然兴起。针对 API 网关本身讲述了其存在的原因,它不仅提供了服务的门面,而且可以协调不同的系统之间的通讯以及服务不同的客户端接口。
针对 API 网关的最佳时间 Spring Cloud Gateway 的定义和概念的解释,其实现了路由、过滤器、断言,针对不同的客户端请求可以路由到不同的微服务,以及其中几个组件是如何分工合作完成路由工作的。
在最佳实践的介绍中分别从:基本路由、权重路由、限流和动态路由几个方面进行了阐述。
01
秒杀高并发白话实战
如何构建高并发架构?请扫码关注我的专栏 [秒杀高并发白话实战]。
02
Jenkins在K8S下的三种部署流程和实战演示
本周四(明天)晚 8:00 线上免费直播,丁琦诗·前游族网络 Mob 运维负责人带你深入了解《Jenkins在K8S下的三种部署流程和实战演示》,扫码即可免费报名,我们不见不散~
简介:十六年开发和架构经验,曾担任过惠普武汉交付中心技术专家,需求分析师,项目经理,后在创业公司担任技术/产品经理。善于学习,乐于分享。目前专注于技术架构与研发管理。
编辑:陶家龙
征稿:有投稿、寻求报道意向技术人请联络 [email protected]
精彩文章推荐: