本章,我将介绍微服务架构中的另一个非常重要的组件——API网关。网关在分布式系统中实在太重要了,可以提供动态路由、灰度发布、授权认证、性能监控、限流熔断等功能:
目前开源界有很多可供选择的API网关,云原生软件基金会(CNCF)的全景图就是一个很好的参考:
本章,我会先讲解一个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)一般会包含三部分内容:即匹配的条件、绑定的插件和上游,如下图:
在 API 和上游很多的情况下,会有很多重复的配置。这时候,我们一般需要 Service 和 Upstream 这两个概念来做一层抽象。
1.2 Service
Service是某类 API 的抽象,也可以理解为一组 Route 的抽象,它通常与上游服务是一一对应的,而 Route 与 Service 之间通常是 N:1 的关系:
通过 Service 的这层抽象,我们就可以把重复的插件和上游剥离出来。这样,在插件和上游发生变更的时候,我们只需要修改 Service 就可以了,而不用去修改多个 Route 上绑定的数据。
1.3 Upstream
如果两个 Route 中的上游是一样的,但是绑定的插件各自不同,那么我们就可以把上游单独抽象出来,如下图所示:
这样,在上游节点发生变更时,Route 是完全无感知的,它们都在 Upstream 内部进行了处理。其实,从这三个主要概念的衍生过程中,我们也可以看到,这几个抽象都基于用户的实际场景,而不是生造出来的。自然,它们适用于所有的 API 网关,和具体的技术方案无关。
当微服务 API 网关的这些关键组件都确定了之后,用户请求的处理流程,也就随之尘埃落定了。下面这张图可以表示这整个流程:
从这个图中我们可以看出:
- 当一个用户请求进入 API 网关时,首先,会根据请求的方法、uri、host、请求头等条件,去路由规则中进行匹配,如果命中了某条路由规则,就会从 etcd 中获取对应的插件列表;
- 然后,和本地开启的插件列表进行交集,得到最终可以运行的插件列表;
- 再接着,根据插件的优先级,逐个运行插件;
- 最后,根据上游的健康检查和负载均衡算法,把这个请求发送给上游。
当架构设计完成后,我们就可以去编写具体的代码了。
上图其实是基于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。
Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。
它的内容包括:
- 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
- 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
- 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
- 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
- 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
- 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
- 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
- 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw
目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:
想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询
同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。