我去,你竟然还不会用API网关!

即将开播:5月14日,Jenkins在K8S下的三种部署流程和实战演示

我去,你竟然还不会用API网关!

图片来自 Pexels

同时为了应对业务的细分以及高并发的挑战,微服务的架构被广泛使用,由于微服务架构中应用会被拆分成多个服务。

为了方便客户端对这些服务的调用于是引入了 API 的概念。今天我们就来看看API 网关的原理以及它是如何应用的。

API 网关的定义

网关一词最早出现在网络设备,比如两个相互独立的局域网之间通过路由器进行通信, 中间的路由被称之为网关。

落实在开发层面来说,就是客户端与微服务系统之间存在的网关。从业务层面来说,当客户端完成某个业务的时候,需要同时调用多个微服务。

如图 1 所示,当客户端发起下单请求需要调用:商品查询、库存扣减以及订单更新等服务。

我去,你竟然还不会用API网关!

图1 :API 网关加入前后对比

如果这些服务需要客户端分别调用才能完成,会增加请求的复杂度,同时也会带来网络调用性能的损耗。因此,针对微服务的应用场景就推出了 API 网关的调用。

在客户端与微服务之间加入下单 API 网关,客户端直接给这个 API 网关下达命令,由于后者完成对其他三个微服务的调用并且返回结果给客户端。

从系统层面来说,任何一个应用系统如果需要被其他系统调用,就需要暴露 API,这些 API 代表着的功能点。

正如上面下单的例子中提到的,如果一个下单的功能点需要调用多个服务的时候,在这个下单的 API 网关中就需要聚合多个服务的调用。

这个聚合的方式有点像设计模式中的门面模式(Facade),它为外部的调用提供了一个统一的访问入口。

不仅如此,如图 2 所示,API 网关还可以协助两个系统的通信,在系统之间加上一个中介者协助 API 的调用。

我去,你竟然还不会用API网关!

图 2:对接两个系统的 API 网关

从客户端类型层面来说,为了屏蔽不同客户端调用差异也可以加入 API 网关。

如图 3 所示,在实际开发过程中 API 网关还可以根据不同的客户端类型(iOS、Android、PC、小程序),提供不同的 API 网关与之对应。

我去,你竟然还不会用API网关!

图 3:对接客户端和服务端的 API 网关

由于 API 网关所处的位置是客户端与微服务交界的地方,因此从功能上它还包括:路由,负载均衡,限流,缓存,日志,发布等等。

Spring Cloud Gateway 概念与定义

API 网关的定义中我们提到了为什么要使用 API 网关,是为了解决客户端对多个微服务进行访问的问题。

由于服务的切分导致一个操作需要同时调用多个服务,因此为这些服务的聚合提供一个统一的门面,这个门面就是 API 网关。

针对于 API 网关有很多的实现方式,例如:Zuul,Kong 等等。这里我们以及 Spring Cloud Gateway 为例展开给大家介绍其具体实现。

一般来说,API 网关对内将微服务进行集合,对外暴露的统一 URL 或者接口信息供客户端调用。

那么客户端是如何与微服务进行连接,并且进行沟通的,需要引入下面几个重要概念 。

我去,你竟然还不会用API网关!

图 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 工作原理

说完了 Spring Cloud Gateway 定义和要素,再来看看其工作原理。总的来说是对客户端请求的处理过程。

我去,你竟然还不会用API网关!

图 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 路由断言处理器。

首先在 POM 文件中加入对应的依赖,如下:

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

加入如下代码,其中定义的 Path 的路径“/baidu”就是请求时的路径地址。对应的 URI,http://www.baidu.com/ 就是要跳转到的目标地址。

@Bean 

public RouteLocator routeLocator(RouteLocatorBuilder builder) { 
   return builder.routes() 
         .route(r ->r.path("/baidu") 
               .uri("http://www.baidu.com/").id("baidu_route") 
         ).build(); 
} 

同样上面的功能也可以在 yml 文件中实现。配置文件如下,说白了就是对 Path 和 URI 参数的设置,实现的功能和上面代码保持一致。

spring: 
  cloud: 
    gateway: 
      routes: 
      - id: baidu_route 
        uri: http://baidu.com:80/ 
        predicates: 
        - Path=/baidu 

此时启动 API 网关,假设网关的访问地址是“localhost:8080/baidu”,当用户请求这个地址的时候就会自动请求“www.baidu.com”这个网站。这个配置起来很简单,有 Nginx 基础的朋友应该很快就能上手。

权重路由

这个使用场景相对于上面的简单路由要多一些。由于每个微服务发布新版本的时候,通常会保持老版本与新版版同时存在。

然后通过网关将流量逐步从老版本的服务切换到新版本的服务。这个逐步切换的过程就是常说的灰度发布。

此时,API 网关就起到了流量分发的作用,通常来说最开始的老版本会承载多一些的流量,例如 90% 的请求会被路由到老版本的服务上,只有 10% 的请求会路由到新服务上去。

从而观察新服务的稳定性,或者得到用户的反馈。当新服务稳定以后,再将剩下的流量一起导入过去。

我去,你竟然还不会用API网关!

图 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 

在两个配置中对应的 URI 分别是新老两个服务的访问地址,通过“http://localhost:8888/v1”和“http://localhost:8888/v2”来区别。

在 Predicates(断言)中定义了的 Path 是想通的都是“/gatewaytest”,也就是说对于客户端来说访问的路径都是一样的,从路径上客户不会感知他们访问的是新服务或者是老服务。

主要参数是在 Weight,针对老/新服务分别配置的是 90 和 10。也就是有 90% 的流量会请求老服务,有 10% 的流量会请求新服务。

简单点说,如果有 100 次请求,其中 90 次会请求 v1(老服务),另外的 10 次会请求 v2(新服务)。

限流

当服务在短时间内迎来高并发,并发量超过服务承受的范围就需要使用限流。例如:秒杀、抢购、下单服务。

通过请求限速或者对一个时间窗口内的请求进行限速来保护服务。当达到限制速率则可以拒绝请求,返回错误代码,或者定向到友好页面。

一般的中间件都会有单机限流框架,支持两种限流模式:

  • 控制速率
  • 控制并发

这里通过 Guava 中的 Bucket4j 来实现限流操作。按照惯例引入 Bucket4j 的依赖:

<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,每两秒放入令牌,每次放入一个令牌。

那么当用户访问 rateLimit 路径的时候就会根据客制化的 Filter 进行限流。

@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

其中 Predicates 和 Filters 包含在 Route 中。实际上就是 Route 实体的定义,针对 Route 进行路由规则的配置。

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 方法。

说白了就是对其进行了一次包装,让外部系统可以调用,并且修改路由的配置。

经过简化以后的代码如下,这里只对 add 方法进行了包装,关于 update 和 delete 方法在这里不展开说明,调用方式类似 add。

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 访问当前的路由信息,由于现在没有配置路由这个信息是空。

那么通过 http://localhost:8888/route/add 方式添加一条路由规则,这里选择 Post 请求,输入类型为 Json 如下:

{ 
    "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 的网站。

此时如果需要修改路由配置,可以通过访问 http://localhost:8888/route/update 的 API 接口,通过 Post 方式传入 Json 结构,例如:

{ 
    "filter":[], 
    "id":"CTO_route", 
    "order":0, 
    "predicates":[{ 
        "args":{ 
            "pattern":"/CTO" 
        }, 
        "name":"Path" 
    }], 
    "uri":"https://www.51CTO.com" 
} 

通过上面四步操作,即使不重启 Spring Cloud Gateway 服务也可以动态更改路由的配置信息。

总结

由于微服务的盛行,API 网关悄然兴起。针对 API 网关本身讲述了其存在的原因,它不仅提供了服务的门面,而且可以协调不同的系统之间的通讯以及服务不同的客户端接口。

针对 API 网关的最佳时间 Spring Cloud Gateway 的定义和概念的解释,其实现了路由、过滤器、断言,针对不同的客户端请求可以路由到不同的微服务,以及其中几个组件是如何分工合作完成路由工作的。