2023-08-12
原文作者:Ressmix 原文地址:https://www.tpvlog.com/article/187

本章,我将介绍微服务架构中的另一个非常重要的组件——API网关。网关在分布式系统中实在太重要了,可以提供动态路由、灰度发布、授权认证、性能监控、限流熔断等功能:

202308122222398701.png

目前开源界有很多可供选择的API网关,云原生软件基金会(CNCF)的全景图就是一个很好的参考:

202308122222466572.png

本章,我会先讲解一个API网关应当具备哪些核心组件,并分析如果我们要自己实现这样一个API网关,系统设计和架构的思路应当是怎样的,然后对几种常见的开源API网关框架进行介绍。最后,我会以Spring Cloud Zuul为例,介绍Zuul的基本使用。

一、核心组件

一个API网关,一般至少需要具备如下核心组件:

  • 路由: 定义一些规则来匹配客户端的请求,根据匹配的结果加载、执行相应的插件,并把请求转发到指定的上游。路由匹配规则可以由 host、uri、请求头等组成,比如Nginx 中的 location,就是路由的一种实现;
  • 插件: 提供身份认证、限流限速、IP 黑白名单、Prometheus、Zipkin 等功能,插件之间不能互相影响,应该支持插件的热加载;
  • schema: 对 API 的报文格式做校验,比如数据类型、字段内容、可空等,由schema来做统一、独立的定义和检查;
  • 存储: 存放用户的各种配置,并在有变更时负责推送到所有的API网关节点。

在这些核心组件之上,我们还需要抽象出几个 API 网关的常用概念,它们在不同的 API 网关之间都是通用的。

1.1 Route

路由(Route)一般会包含三部分内容:即匹配的条件、绑定的插件和上游,如下图:

202308122222573973.png

在 API 和上游很多的情况下,会有很多重复的配置。这时候,我们一般需要 Service 和 Upstream 这两个概念来做一层抽象。

1.2 Service

Service是某类 API 的抽象,也可以理解为一组 Route 的抽象,它通常与上游服务是一一对应的,而 Route 与 Service 之间通常是 N:1 的关系:

202308122222587984.png

通过 Service 的这层抽象,我们就可以把重复的插件和上游剥离出来。这样,在插件和上游发生变更的时候,我们只需要修改 Service 就可以了,而不用去修改多个 Route 上绑定的数据。

1.3 Upstream

如果两个 Route 中的上游是一样的,但是绑定的插件各自不同,那么我们就可以把上游单独抽象出来,如下图所示:

202308122223046905.png

这样,在上游节点发生变更时,Route 是完全无感知的,它们都在 Upstream 内部进行了处理。其实,从这三个主要概念的衍生过程中,我们也可以看到,这几个抽象都基于用户的实际场景,而不是生造出来的。自然,它们适用于所有的 API 网关,和具体的技术方案无关。


当微服务 API 网关的这些关键组件都确定了之后,用户请求的处理流程,也就随之尘埃落定了。下面这张图可以表示这整个流程:

202308122223134906.png

从这个图中我们可以看出:

  1. 当一个用户请求进入 API 网关时,首先,会根据请求的方法、uri、host、请求头等条件,去路由规则中进行匹配,如果命中了某条路由规则,就会从 etcd 中获取对应的插件列表;
  2. 然后,和本地开启的插件列表进行交集,得到最终可以运行的插件列表;
  3. 再接着,根据插件的优先级,逐个运行插件;
  4. 最后,根据上游的健康检查和负载均衡算法,把这个请求发送给上游。

当架构设计完成后,我们就可以去编写具体的代码了。

上图其实是基于OpenResty实现一个API网关的基本思路,关于源码实现,读者可以参考温铭的《OpenResty从入门到实战》中的内容,也可以直接从GitHub寻找基于APISIX框架的网关源码进行阅读并扩展其功能。

二、技术选型

介绍完了一个API网关应该具备的基本功能及核心组件,我们就来看看目前开源界由哪些产品我们供我们使用。

2.1 OpenResty

OpenResty 是一个兼具开发效率和性能的服务端开发平台,虽然它基于 NGINX 实现,但其适用范围,早已远远超出反向代理和负载均衡。它的核心是基于 NGINX 的一个 C 模块(lua-nginx-module),该模块将 LuaJIT 嵌入到 NGINX 服务器中,并对外提供一套完整的 Lua API,透明地支持非阻塞 I/O,提供了轻量级线程、定时器等高级抽象。

我们可以基于OpenResty开发自己的API网关,优点是抗并发的能力很强,少数几台机器部署一下,就可以抗很高的并发。缺点是需要精通Lua,而且OpenResty的很多API使用上都有性能上的坑,各类lua-resty-*包也都零零散散,缺乏统一的规范和整合,所以要想使用好门槛是比较高的,否则生产上很容易引发性能问题和莫名其妙的异常。

2.2 Kong

Kong 是由 Mashape 开发的并于2015年开源的一款API 网关,它是基于OpenResty(Nginx + Lua模块)和 Apache Cassandra/PostgreSQL 构建的,能提供易于使用的RESTful API来操作和配置API管理系统。Kong 可以水平扩展多个 Kong Server,通过前置的负载均衡配置把请求均匀地分发到各个Server,来应对大批量的网络请求。

一句话,Kong可以看出是基于OpenResty 的一个开源API网关框架。

2.3 APISIX

与Kong类似,也是是基于OpenResty 的一个开源API网关框架,但是它是基于etcd 来 做配置管理,相对于Kong 来说,更加轻量级。

2.4 Envoy

Envoy 最初是在Lyft上构建的一种高性能C ++分布式代理服务,可以作为大型微服务“Service Mesh”架构的通信总线。

Envoy 本身可以做反向代理和负载均衡,也就是说它可以完全替换掉NGINX。Envoy具备一个API网关的所有功能,目前Envoy的社区活跃度也非常高,我们可以基于Envoy来构建自己的API网关。

缺点是Envoy的中文文档目前还很少。

2.5 Zuul

Netflix开源的API网关,基于Java开发,功能比较简单,灰度发布、限流、动态路由之类的功能基本都需要自己做二次开发。另外,Zuul的并发能力不强,还要基于Tomcat来部署,好处是基于Java语言开发,可以直接把控源码,方便做二次开发。

2.6 Spring Cloud Gateway

Spring Cloud的一个全新的API网关项目,目的是为了替换掉Zuul。优点是Java源码,背靠Spring全家桶,缺点和Zuul差不多,抗不了超高并发,需要自己做定制。

2.7 自研网关

目前很多大公司基本都是自研API网关,有的直接OpenResty,有的基于Netty+Servlet来实现网关的核心功能。

三、Zuul网关:动态路由

分布式框架之可扩展:Spring Cloud一章中,我通过一个简单的电商示例介绍过Zuul网关的基本使用,当时是直接在yml中写死了路由配置:

    server:
      port: 9000
    
    spring:
      application:
        name: zuul-gateway
    
    eureka:
      instance:
        hostname: localhost
      client:
        serviceUrl:
          defaultZone: http://localhost:8761/eureka/
    
    # 所有访问/order/**这个URI的请求,全部转发给order-service服务处理
    zuul:
      retryable: true
      routes:
        order-service:
          path: /order/**

如果我们要新增服务提供方,难道每次都要重新修改网关配置,然后重启网关?显然不现实,所以一般都要针对网关做 动态路由

最常见的实现动态路由的方式是基于数据库(也可用Apollo配置中心、Redis、ZooKeeper等保存路由配置信息),本节我以Zuul为示例介绍API网关的动态路由功能。

3.1 路由表

首先创建一张路由配置表gateway_api_route

    CREATE TABLE `gateway_api_route` (
       `id` varchar(50) NOT NULL COMMMENT '自增主键',
       `path` varchar(255) NOT NULL COMMMENT '请求URI',
       `service_id` varchar(50) DEFAULT NULL COMMMENT '服务ID,唯一',
       `url` varchar(255) DEFAULT NULL COMMMENT '',
       `retryable` tinyint(1) DEFAULT NULL COMMMENT '是否允许重试:0-允许,1-不允许',
       `enabled` tinyint(1) NOT NULL COMMMENT '是否开启路由:0-关闭,1-开启',
       `strip_prefix` int(11) DEFAULT NULL COMMMENT '是否去除前缀路径:0-否,1-是',
       `api_name` varchar(255) DEFAULT NULL COMMMENT '',
       PRIMARY KEY (`id`)
     ) ENGINE=InnoDB DEFAULT CHARSET=utf8

数据示例
INSERT INTO gateway_api_route (id, path, service_id ,retryable, enabled, strip_prefix) VALUES ('1', '/order/**', 'order-service',0,1, NULL);

对于路由表的数据可以进行缓存,然后前端提供页面进行增删改查,以及缓存手动失效处理。

3.2 动态路由实现

首先,我们需要对Zuul网关应用的application.yml配置进行改写,去掉写死的路由配置:

    server:
      port: 9000
    
    spring:
      application:
        name: zuul-gateway
      datasource:
        url: jdbc:mysql://localhost:3306/test?autoReconnect=true&useUnicode=true&characterEncoding=utf-8
        username: root
        password: root
        driver-class-name: com.mysql.jdbc.Driver
    
    eureka:
      instance:
        hostname: localhost
      client:
        serviceUrl:
          defaultZone: http://localhost:8761/eureka/
        registryFetchIntervalSeconds: 3
        leaseRenewalIntervalInSeconds: 3
    
    zuul:
      retryable: true

然后,创建一个动态路由配置类:

    @Configuration
    public class DynamicRouteConfiguration {
    
        @Autowired
        private ZuulProperties zuulProperties;
        @Autowired
        private ServerProperties server;
        @Autowired
        private JdbcTemplate jdbcTemplate;
    
        @Bean
        public DynamicRouteLocator routeLocator() {
            DynamicRouteLocator routeLocator = new DynamicRouteLocator(
                    this.server.getServletPrefix(), this.zuulProperties);
            routeLocator.setJdbcTemplate(jdbcTemplate);
            return routeLocator;
        }
    
    }

DynamicRouteLocator是我们自己实现的动态路由逻辑:

    /**
     * 动态路由实现类
     */
    public class DynamicRouteLocator extends SimpleRouteLocator implements RefreshableRouteLocator {
    
        private JdbcTemplate jdbcTemplate;
        private ZuulProperties properties;
    
        public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
            this.jdbcTemplate = jdbcTemplate;
        }
    
        public DynamicRouteLocator(String servletPath, ZuulProperties properties) {
            super(servletPath, properties);
            this.properties = properties;
        }
    
        /**
         * Spring容器接收到RoutesRefreshedEvent事件后触发
         */
        @Override
        public void refresh() {
            // 内部会调用locateRoutes()方法
            doRefresh();
        }
    
        /**
         * Zuul网关启动后会调用该方法
         */
        @Override
        protected Map<String, ZuulProperties.ZuulRoute> locateRoutes() {
            LinkedHashMap<String, ZuulProperties.ZuulRoute> routesMap = new LinkedHashMap<>();
            // 加载application.yml中的路由表
            routesMap.putAll(super.locateRoutes());
            // 加载db中的路由表
            routesMap.putAll(locateRoutesFromDB());
    
            // 统一处理路由path的格式
            LinkedHashMap<String, ZuulProperties.ZuulRoute> values = new LinkedHashMap<>();
            for (Map.Entry<String, ZuulProperties.ZuulRoute> entry : routesMap.entrySet()) {
                String path = entry.getKey();
                if (!path.startsWith("/")) {
                    path = "/" + path;
                }
                if (StringUtils.hasText(this.properties.getPrefix())) {
                    path = this.properties.getPrefix() + path;
                    if (!path.startsWith("/")) {
                        path = "/" + path;
                    }
                }
                values.put(path, entry.getValue());
            }
    
            System.out.println("路由表:" + values); 
    
            return values;
        }
    
        /**
          * 从数据库查询路由信息,并转换成<K,V> = <服务URL,ZuulRoute>
          */
        private Map<String, ZuulProperties.ZuulRoute> locateRoutesFromDB() {
            Map<String, ZuulProperties.ZuulRoute> routes = new LinkedHashMap<>();
            // 仅做示例,生产用Mybatis
            List<GatewayApiRoute> results = jdbcTemplate.query(
                    "select * from gateway_api_route where enabled = true ", 
                    new BeanPropertyRowMapper<>(GatewayApiRoute.class));
    
            for (GatewayApiRoute result : results) {
                if (StringUtils.isEmpty(result.getPath()) ) {
                    continue;
                }
                if (StringUtils.isEmpty(result.getServiceId()) && StringUtils.isEmpty(result.getUrl())) {
                    continue;
                }
                ZuulProperties.ZuulRoute zuulRoute = new ZuulProperties.ZuulRoute();
                try {
                    BeanUtils.copyProperties(result, zuulRoute);
                } catch (Exception e) { 
                    e.printStackTrace();
                }
                routes.put(zuulRoute.getPath(), zuulRoute);
            }
            return routes;
        }
    }
    
    /**
     * 数据库Bean实体类,对应表gateway_api_route
     */
    public class GatewayApiRoute {
    
        private String id;
        private String path;
        private String serviceId;
        private String url;
        private boolean stripPrefix = true;
        private Boolean retryable;
        private Boolean enabled;
    
        public String getId() {
            return id;
        }
        public void setId(String id) {
            this.id = id;
        }
        public String getPath() {
            return path;
        }
        public void setPath(String path) {
            this.path = path;
        }
        public String getServiceId() {
            return serviceId;
        }
        public void setServiceId(String serviceId) {
            this.serviceId = serviceId;
        }
        public String getUrl() {
            return url;
        }
        public void setUrl(String url) {
            this.url = url;
        }
        public boolean isStripPrefix() {
            return stripPrefix;
        }
        public void setStripPrefix(boolean stripPrefix) {
            this.stripPrefix = stripPrefix;
        }
        public Boolean getRetryable() {
            return retryable;
        }
        public void setRetryable(Boolean retryable) {
            this.retryable = retryable;
        }
        public Boolean getEnabled() {
            return enabled;
        }
        public void setEnabled(Boolean enabled) {
            this.enabled = enabled;
        }
    
    }

最后,我们需要一个定时任务,每隔5分钟发布一个更新事件,让Zuul从数据库路由表加载路由信息到缓存中。

    /**
     * 定时任务,每隔5s触发一次RoutesRefreshedEvent事件
     */
    @Component
    @Configuration      
    @EnableScheduling   
    public class RefreshRouteTask {
    
        @Autowired
        private ApplicationEventPublisher publisher;
        @Autowired
        private RouteLocator routeLocator;
    
        @Scheduled(fixedRate = 5000) 
        private void refreshRoute() {
            System.out.println("定时刷新路由表");  
            RoutesRefreshedEvent routesRefreshedEvent = new RoutesRefreshedEvent(routeLocator);
            publisher.publishEvent(routesRefreshedEvent);
        }
    
    }

四、Zuul网关:灰度发布

灰度发布(又名金丝雀发布),是API网关的一项重要功能,主要是按照一定策略选取部分用户,让他们先行体验新版本的应用,通过收集这部分用户对新版本应用的反馈(如:微博、微信公众号留言或者产品数据指标统计、用户行为的数据埋点),以及对新版本功能、性能、稳定性等指标进行评论,进而决定继续放大新版本投放范围直至全量升级或回滚至老版本。

本节我继续以Zuul为例,介绍一种实现灰度发布的方式。

4.1 灰度配置表

首先,我们创建一张灰度配置表gray_release_config

    CREATE TABLE `gray_release_config` (
       `id` int(11) NOT NULL AUTO_INCREMENT,
       `service_id` varchar(255) DEFAULT NULL COMMMENT '服务ID,唯一',
       `path` varchar(255) DEFAULT NULL COMMMENT '服务URI',
       `enable_gray_release` int(11) DEFAULT NULL COMMMENT '是否启用:0-否,1-是',
       PRIMARY KEY (`id`)
     ) ENGINE=InnoDB DEFAULT CHARSET=utf8

数据示例
INSERT INTO gray_release_config(service_id,path,enable_gray_release) VALUES('order-service', '/order/**', 1)

4.2 灰度发布实现

首先,创建一个类,继承ZuulFilter,然后在shouldFilter方法中自定义路由规则:

    @Configuration
    public class GrayReleaseFilter extends ZuulFilter {
    
        @Autowired
        private GrayReleaseConfigManager grayReleaseConfigManager;
    
        @Override
        public int filterOrder() {
            return PRE_DECORATION_FILTER_ORDER - 1;
        }
    
        @Override
        public String filterType() {
            return PRE_TYPE;
        }
    
        /**
         * 所有web请求都会被Zuul拦截,并经过该方法
         */
        @Override
        public boolean shouldFilter() {
            RequestContext ctx = RequestContext.getCurrentContext();
            HttpServletRequest request = ctx.getRequest();
    
            // requestURI类似http://localhost:9000/order/order?xxxx
            String requestURI = request.getRequestURI();
    
            //  获取灰度配置
            Map<String, GrayReleaseConfig> grayReleaseConfigs = 
                    grayReleaseConfigManager.getGrayReleaseConfigs();
            for(String path : grayReleaseConfigs.keySet()) {
                // 如果请求路径命中
                if(requestURI.contains(path)) {
                    GrayReleaseConfig grayReleaseConfig = grayReleaseConfigs.get(path);
                    // 开启了灰度
                    if(grayReleaseConfig.getEnableGrayRelease() == 1) {
                        System.out.println("启用灰度发布功能");  
                        // 返回true表示将执行该Filter的run()方法
                        return true;
                    }
                }
            }
    
            System.out.println("不启用灰度发布功能");   
    
            return false;
        }
    
        /**
         * 这里实现灰度发布的逻辑
         */
        @Override
        public Object run() {
    
            // 生成一个0-99的随机数
            Random random = new Random();
            int seed = random.nextInt() * 100;
    
            if (seed == 50) {
                // 1%的流量转发给version==new的后台服务
                RibbonFilterContextHolder.getCurrentContext().add("version", "new");
            }  else {
                // 转发给version==current的后台服务
                RibbonFilterContextHolder.getCurrentContext().add("version", "current");
            }
    
            return null;
        }
    }
    
    /**
     * 数据库Bean实体类,对应表gray_release_config
     */
    public class GrayReleaseConfig {
        private int id;
        private String serviceId;
        private String path;
        private int enableGrayRelease;
    
        public int getId() {
            return id;
        }
        public void setId(int id) {
            this.id = id;
        }
        public String getServiceId() {
            return serviceId;
        }
        public void setServiceId(String serviceId) {
            this.serviceId = serviceId;
        }
        public String getPath() {
            return path;
        }
        public void setPath(String path) {
            this.path = path;
        }
        public int getEnableGrayRelease() {
            return enableGrayRelease;
        }
        public void setEnableGrayRelease(int enableGrayRelease) {
            this.enableGrayRelease = enableGrayRelease;
        }
    
    }

每个服务的yml.application配置中有一个eureka.instance.metadata-map参数,该参数的值是一个Map。上述示例中key为version,我们以此来区分新老服务。此外,上述run()逻辑将1%流量转发给新服务处理,我们也可以根据请求中特定参数、源IP等信息进行路由处理。

最后,我们需要一个定时任务,每隔1s从数据库查询灰度发布配置信息,并更新到内部的HashMap中:

    /**
     * 定时任务,每隔1s从数据库查询灰度发布配置信息,并更新到内部的HashMap中
     */
    @Component
    @Configuration      
    @EnableScheduling 
    public class GrayReleaseConfigManager {
    
        private Map<String, GrayReleaseConfig> grayReleaseConfigs = 
                new ConcurrentHashMap<String, GrayReleaseConfig>();
    
        @Autowired
        private JdbcTemplate jdbcTemplate;
    
        @Scheduled(fixedRate = 1000) 
        private void refreshRoute() {
            List<GrayReleaseConfig> results = jdbcTemplate.query(
                    "select * from gray_release_config", 
                    new BeanPropertyRowMapper<>(GrayReleaseConfig.class));
    
            for(GrayReleaseConfig grayReleaseConfig : results) {
                grayReleaseConfigs.put(grayReleaseConfig.getPath(), grayReleaseConfig);
            }
        }
    
        public Map<String, GrayReleaseConfig> getGrayReleaseConfigs() {
            return grayReleaseConfigs;
        }
    
    }

五、总结

本章,我介绍了API网关的作用和动态路由、灰度发布的基本原理。API网关一般是整个分布式系统的门户,所有流量首先需要经过网关处理,所以API网关的性能优化是至关重要的。

以笔者曾经做过的一个银行快捷支付系统为例,其架构简化后,主要分为网关模块、联机模块、批量模块,所有支付请求首先要经过F5、Nginx/LVS进行负载均衡,然后到达API网关,由API网关进行服务路由、鉴权、日志记录等处理。所以,生产环境一般都需要根据预判的业务量,提前对整个系统进行压测。

一般来说,8核16G的机器,部署Zuul作为网关的话,单台可以抗1500QPS。基本上10~20台机器就可以支撑2W左右的总QPS。

阅读全文