一个简单可参考的API网关架构设计

网关一词较早出如今网络设备里面,好比两个相互独立的局域网段之间经过路由器或者桥接设备进行通讯, 这中间的路由或者桥接设备咱们称之为网关。html

相应的 API 网关将各系统对外暴露的服务聚合起来,全部要调用这些服务的系统都须要经过 API 网关进行访问,基于这种方式网关能够对 API 进行统一管控,例如:认证、鉴权、流量控制、协议转换、监控等等。前端

API 网关的流行得益于近几年微服务架构的兴起,本来一个庞大的业务系统被拆分红许多粒度更小的系统进行独立部署和维护,这种模式势必会带来更多的跨系统交互,企业 API 的规模也会成倍增长,API 网关(或者微服务网关)就逐渐成为了微服务架构的标配组件。java

以下是咱们整理的 API 网关的几种典型应用场景:数据库

一、面向 Web 或者移动 App编程

这类场景,在物理形态上相似先后端分离,前端应用经过 API 调用后端服务,须要网关具备认证、鉴权、缓存、服务编排、监控告警等功能。后端

二、面向合做伙伴开放 APIapi

这类场景,主要为了知足业务形态对外开放,与企业外部合做伙伴创建生态圈,此时的 API 网关注重安全认证、权限分级、流量管控、缓存等功能的建设。缓存

三、企业内部系统互联互通安全

对于中大型的企业内部每每有几10、甚至上百个系统,尤为是微服务架构的兴起系统数量更是急剧增长。系统之间相互依赖,逐渐造成网状调用关系不便于管理和维护,须要 API 网关进行统一的认证、鉴权、流量管控、超时熔断、监控告警管理,从而提升系统的稳定性、下降重复建设、运维管理等成本。微信

设计目标

  1. 纯 Java 实现;
  2. 支持插件化,方便开发人员自定义组件;
  3. 支持横向扩展,高性能;
  4. 避免单点故障,稳定性要高,不能由于某个 API 故障致使整个网关中止服务;
  5. 管理控制台配置更新可自动生效,不须要重启网关;

应用架构设计

整个平台拆分红 3 个子系统,Gateway-Core(核心子系统)、Gateway-Admin(管理中心)、Gateway-Monitor(监控中心)。

  • Gateway-Core 负责接收客户端请求,调度、加载和执行组件,将请求路由到上游服务端,处理上游服务端返回的结果等;
  • Gateway-Admin 提供统一的管理界面,用户可在此进行 API、组件、系统基础信息的设置和维护;
  • Gateway-Monitor 负责收集监控日志、生成各类运维管理报表、自动告警等;

系统架构设计

说明:

  1. 网关核心子系统经过 HAProxy 或者 Nginx 进行负载均衡,为避免正好路由的 LB 节点服务不可用,能够考虑在此基础上增长 Keepalived 来实现 LB 的失效备援,当 LB Node1 中止服务,Keepalived 会将虚拟 IP 自动飘移到 LB Node2,从而避免由于负载均衡器致使单点故障。DNS 能够直接指向 Keepalived 的虚拟 IP。
  2. 网关除了对性能要求很高外,对稳定性也有很高的要求,引入 Zookeeper 及时将 Admin 对 API 的配置更改同步刷新到各网关节点。
  3. 管理中心和监控中心能够采用相似网关子系统的高可用策略,若是嫌麻烦管理中心能够省去 Keepalived,相对来讲管理中心没有这么高的可用性要求。
  4. 理论上监控中心须要承载很大的数据量,好比有 1000 个 API,平均每一个 API 一天调用 10 万次,对于不少互联网公司单个 API 的量远远大于 10 万,若是将每次调用的信息都存储起来太浪费,也没有太大的必要。能够考虑将 API 每分钟的调用状况汇总后进行存储,好比 1 分钟的平均响应时间、调用次数、流量、正确率等等。
  5. 数据库选型能够灵活考虑,原则上网关在运行时要尽量减小对 DB 的依赖,不然 IO 延时会严重影响网关性能。能够考虑首次访问后将 API 配置信息缓存,Admin 对 API 配置更改后经过 Zookeeper 通知网关刷新,这样一来 DB 的访问量能够忽略不计,团队可根据自身偏好灵活选型。

非阻塞式 HTTP 服务

管理和监控中心能够根据团队的状况采用本身熟悉的 Servlet 容器部署,网关核心子系统对性能的要求很是高,考虑采用 NIO 的网络模型,实现纯 HTTP 服务便可,不须要实现 Servlet 容器,推荐 Netty 框架(设计优雅,大名鼎鼎的 Spring Webflux 默认都是使用的 Netty,更多的优点就不在此详述了),内部测试在相同的机器上分别经过 Tomcat 和 Netty 生成 UUID,Netty 的性能大约有 20% 的提高,若是后端服务响应耗时较高的话吞吐量还有更大的提高。(补充:Netty4.x 的版本便可,不要采用 5 以上的版本,有严重的缺陷没有解决)

采用 Netty 做为 Http 容器首先须要解决的是 Http 协议的解析和封装,好在 Netty 自己提供了这样的 Handler,具体参考以下代码:

一、构建一个单例的 HttpServer,在 SpringBoot 启动的时候同时加载并启动 Netty 服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int  sobacklog = Integer.parseInt(AppConfigUtil.getValue( "netty.sobacklog" ));
 
ServerBootstrap b =  new  ServerBootstrap();
 
b.group(bossGroup, workerGroup)
 
.channel(NioServerSocketChannel. class )
 
.localAddress( new  InetSocketAddress( this .portHTTP))
 
.option(ChannelOption.SO_BACKLOG, sobacklog)
 
.childHandler( new  ChannelHandlerInitializer( null ));
 
// 绑定端口
 
ChannelFuture f = b.bind( this .portHTTP).sync();
 
logger.info( "HttpServer name is "  + HttpServer. class .getName() +  " started and listen on "  + f.channel().localAddress());

  

二、初始化 Handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
 
protected  void  initChannel(SocketChannel ch)  throws  Exception {
 
ChannelPipeline p = ch.pipeline();
 
p.addLast( new  HttpRequestDecoder());
 
p.addLast( new  HttpResponseEncoder());
 
int  maxContentLength =  2000 ;
 
try  {
 
maxContentLength = Integer.parseInt(AppConfigUtil.getValue( "netty.maxContentLength" ));
 
catch  (Exception e) {
 
logger.warn( "netty.maxContentLength 配置异常,系统默认为:2000KB" );
 
}
 
p.addLast( new  HttpObjectAggregator(maxContentLength *  1024 )); // HTTP 消息的合并处理
 
p.addLast( new  HttpServerInboundHandler());
 
}

  

HttpRequestDecoder 和 HttpResponseEncoder 分别实现 Http 协议的解析和封装,Http Post 内容超过一个数据包大小会自动分组,经过 HttpObjectAggregator 能够自动将这些数据粘合在一块儿,对于上层收到是一个完整的 Http 请求。

三、经过 HttpServerInboundHandler 将网络请求转发给网关执行器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Override
 
public  void  channelRead0(ChannelHandlerContext ctx, Object msg)
 
throws  Exception {
 
try  {
 
if  (msg  instanceof  HttpRequest && msg  instanceof  HttpContent) {
 
CmptRequest cmptRequest = CmptRequestUtil.convert(ctx, msg);
 
CmptResult cmptResult =  this .gatewayExecutor.execute(cmptRequest);
 
FullHttpResponse response = encapsulateResponse(cmptResult);
 
ctx.write(response);
 
ctx.flush();
 
}
 
catch  (Exception e) {
 
logger.error( "网关入口异常,"  \+ e.getMessage());
 
e.printStackTrace();
 
}
 
}

  

设计上建议将 Netty 接入层代码跟网关核心逻辑代码分离,不要将 Netty 收到 HttpRequest 和 HttpContent 直接给到网关执行器,能够考虑作一层转换封装成本身的 Request 给到执行器,方便后续能够很容易的将 Netty 替换成其它 Http 容器。(如上代码所示,CmptRequest 即为自定义的 Http 请求封装类,CmptResult 为网关执行结果类)

组件化及自定义组件支持

组件是网关的核心,大部分功能特性均可以基于组件的形式提供,组件化能够有效提升网关的扩展性。

先来看一个简单的微信认证组件的例子:

以下实现的功能是对 API 请求传入的 Token 进行校验,其结果分别是认证经过、Token 过时和无效 Token,认证经过后再将微信 OpenID 携带给上游服务系统。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
 
* 微信 token 认证,token 格式:
 
* {appID: '' ,openID: '' ,timestamp: 132525144172 ,sessionKey:  '' }
 
*
 
public  class  WeixinAuthTokenCmpt  extends  AbstractCmpt {
 
private  static  Logger logger = LoggerFactory.getLogger(WeixinAuthTokenCmpt. class );
 
private  final  CmptResult SUCCESS_RESULT;
 
public  WeixinAuthTokenCmpt() {
 
SUCCESS_RESULT = buildSuccessResult();
 
}
 
@Override
 
public  CmptResult execute(CmptRequest request, Map<String, FieldDTO> config) {
 
if  (logger.isDebugEnabled()) {
 
logger.debug( "WeixinTokenCmpt ......" );
 
}
 
CmptResult cmptResult =  null ;
 
//Token 认证超时间 (传入单位: 分)
 
long  authTokenExpireTime = getAuthTokenExpireTime(config);
 
WeixinTokenDTO authTokenDTO =  this .getAuthTokenDTO(request);
 
logger.debug( "Token="  + authTokenDTO);
 
AuthTokenState authTokenState = validateToken(authTokenDTO, authTokenExpireTime);
 
switch  (authTokenState) {
 
case  ACCESS: {
 
cmptResult = SUCCESS_RESULT;
 
Map<String, String> header =  new  HashMap<>();
 
header.put(HeaderKeyConstants.HEADER\_APP\_ID_KEY, authTokenDTO.getAppID());
 
header.put(CmptHeaderKeyConstants.HEADER\_WEIXIN\_OPENID_KEY, authTokenDTO.getOpenID());
 
header.put(CmptHeaderKeyConstants.HEADER\_WEIXIN\_SESSION_KEY, authTokenDTO.getSessionKey());
 
cmptResult.setHeader(header);
 
break ;
 
}
 
case  EXPIRED: {
 
cmptResult = buildCmptResult(RespErrCode.AUTH\_TOKEN\_EXPIRED,  "token 过时, 请从新获取 Token!" );
 
break ;
 
}
 
case  INVALID: {
 
cmptResult = buildCmptResult(RespErrCode.AUTH\_INVALID\_TOKEN,  "Token 无效!" );
 
break ;
 
}
 
}
 
return  cmptResult;
 
}
 
...
 
}

  

上面例子看不懂不要紧,接下来会详细阐述组件的设计思路。

一、组件接口定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public  interface  ICmpt {
     /**       
      * 组件执行入口
      *
      * @param request
      * @param config,组件实例的参数配置
      * @return
      */   
     CmptResult execute(CmptRequest request, Map<String, FieldDTO> config);
 
     /**
      * 销毁组件持有的特殊资源,好比线程。
      */
     void  destroy();
}

  

execute 是组件执行的入口方法,request 前面提到过是 http 请求的封装,config 是组件的特殊配置,好比上面例子提到的微信认证组件就有一个自定义配置 -Token 的有效期,不一样的 API 使用该组件能够设置不一样的有效期。

FieldDTO 定义以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public  class  FieldDTO {
 
private  String title;
 
private  String name;
 
private  FieldType fieldType = FieldType.STRING;
 
private  String defaultValue;
 
private  boolean  required;
 
private  String regExp;
 
private  String description;
 
}

  

CmptResult 为组件执行后的返回结果,其定义以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public  class  CmptResult {
 
RespErrMsg respErrMsg; // 组件返回错误信息
 
private  boolean  passed; // 组件过滤是否经过
 
private  byte \[\] data; // 组件返回数据
 
private  Map<String, String> header =  new  HashMap<String, String>(); // 透传后端服务响应头信息
 
private  MediaType mediaType; // 返回响应数据类型
 
private  Integer statusCode =  200 ; // 默认返回状态码为 200
 
}

  

二、组件类型定义

执行器须要根据组件类型和组件执行结果判断是要直接返回客户端仍是继续往下面执行,好比认证类型的组件,若是认证失败是不能继续往下执行的,但缓存类型的组件没有命中才继续往下执行。固然这样设计存在一些缺陷,好比新增组件类型须要执行器配合调整处理逻辑。(Kong 也提供了大量的功能组件,没有研究过其网关框架是如何跟组件配合的,是否支持用户自定义组件类型,知道的朋友详细交流下。)

初步定义以下组件类型:

认证、鉴权、流量管控、缓存、路由、日志等。

其中路由类型的组件涵盖了协议转换的功能,其负责调用上游系统提供的服务,能够根据上游系统提供 API 的协议定制不一样的路由组件,好比:Restful、WebService、Dubbo、EJB 等等。

三、组件执行位置和优先级设定

执行位置:Pre、Routing、After,分别表明后端服务调用前、后端服务调用中和后端服务调用完成后,相同位置的组件根据优先级决定执行的前后顺序。

四、组件发布形式

组件打包成标准的 Jar 包,经过 Admin 管理界面上传发布。

附 - 组件可视化选择 UI 设计

组件热插拔设计和实现

JVM 中 Class 是经过类加载器 + 全限定名来惟一标识的,上面章节谈到组件是以 Jar 包的形式发布的,但相同组件的多个版本的入口类名须要保持不变,所以要实现组件的热插拔和多版本并存就须要自定义类加载器来实现。

大体思路以下:

网关接收到 API 调用请求后根据请求参数从缓存里拿到 API 配置的组件列表,而后再逐一参数从缓存里获取组件对应的类实例,若是找不到则尝试经过自定义类加载器载入 Jar 包,并初始化组件实例及缓存。

附 - 参考示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public  static  ICmpt newInstance( final  CmptDef cmptDef) {
     ICmpt cmpt =  null ;
     try  {
         final  String jarPath = getJarPath(cmptDef);
         if  (logger.isDebugEnabled()) {
             logger.debug( "尝试载入 jar 包,jar 包路径: "  + jarPath);
         }
         // 加载依赖 jar
         CmptClassLoader cmptClassLoader = CmptClassLoaderManager.loadJar(jarPath,  true );
         // 建立实例
         if  ( null  != cmptClassLoader) {
             cmpt = LoadClassUtil.newObject(cmptDef.getFullQualifiedName(), ICmpt. class , cmptClassLoader);
         else  {
             logger.error( "加载组件 jar 包失败! jarPath: "  + jarPath);
         }
     catch  (Exception e) {
         logger.error( "组件类加载失败,请检查类名和版本是否正确。ClassName="  + cmptDef.getFullQualifiedName() +  ", Version="  + cmptDef.getVersion());
         e.printStackTrace();
     }
     return  cmpt;
}

  

补充说明:

自定义类加载器可直接须要继承至 URLClassLoader,另外必须指定其父类加载器为执行器的加载器,不然组件无法引用网关的其它类。

API 故障隔离及超时、熔断处理

在详细阐述设计前先讲个实际的案例,大概 12 年的时候某公司自研了一款 ESB 的中间件(企业服务总线跟 API 网关很相似,当年 SOA 理念大行其道的时候都推崇的是 ESB,侧重服务的编排和异构系统的整合。),刚开始用的还行,但随着接入系统的增多,忽然某天运维发现大量 API 出现缓慢甚至超时,初步检查发现 ESB 每一个节点的线程几乎消耗殆尽,起初判断是资源不够,紧急扩容后仍是很快线程占满,最终致使上百个系统瘫痪。

最终找到问题的症结是某个业务系统自身的缘由致使服务不可用,下游业务系统请求大量堆积到 ESB 中,从而致使大量线程堵塞。

以上案例说明了一个在企业应用架构设计里面的经典原则 - 故障隔离,因为全部的 API 请求都要通过网关,必须隔离 API 之间的相互影响,尤为是个别 API 故障致使整个网关集群服务中断。

接下来分别介绍故障隔离、超时管控、熔断的实现思路。

一、故障隔离

有两种方式能够实现,一是为每一个 API 建立一个线程池,每一个线程分配 10~20 个线程,这也是经常使用的隔离策略,但这种方式有几个明显的缺点:

  • 线程数会随着 API 接入数量递增,1000 个 API 就须要 2 万个线程,光线程切换对 CPU 就是不小的开销,而其线程还须要占用必定的内存资源;
  • 平均分配线程池大小致使个别访问量较大且响应时间相对较长的 API 吞吐量上不去;
  • Netty 自己就有工做线程池了,再增长 API 的线程池,致使某些须要 ThreadLocal 特性的编程变得困难。

二是用信号量隔离,直接复用 Netty 的工做线程,上面线程池隔离提到的 3 个缺点均可以基本避免, 建议设置单个 API 的信号量个数小于等于 Netty 工做线程池数量的 1/3,这样既兼顾了单个 API 的性能又不至于单个 API 的问题致使整个网关堵塞。

具体实现能够考虑直接引用成熟的开源框架,推荐 Hystrix,能够同时解决超时控制和熔断。

参考配置以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey(groupKey))
         .andCommandKey(HystrixCommandKey.Factory.asKey(commandKey ))
         .andCommandPropertiesDefaults(HystrixCommandProperties.Setter()
                 // 舱壁隔离策略 - 信号量
                 .withExecutionIsolationStrategy(HystrixCommandProperties.ExecutionIsolationStrategy.SEMAPHORE)
                 // 设置每组 command 能够申请的信号量最大数
                 .withExecutionIsolationSemaphoreMaxConcurrentRequests(CmptInvoker.maxSemaphore)
                 /* 开启超时设置 */
                 .withExecutionIsolationThreadInterruptOnTimeout( true )
                 /* 超时时间设置 */
                 .withExecutionIsolationThreadTimeoutInMilliseconds(timeout)
                 .withCircuitBreakerEnabled( true ) // 开启熔断
                 .withCircuitBreakerSleepWindowInMilliseconds(Constants.DEFAULT_CIRCUIT_BREAKER_SLEEP_WINDOW_IN_MILLISECONDS) // 5 秒后会尝试闭合回路

  

二、超时管控

API 的超时控制是必需要作的,不然上游服务即使是间歇性响应缓慢也会堵塞大量线程(虽然经过信号量隔离后不会致使整个网关线程堵塞)。

其次,每一个 API 最好能够单独配置超时时间,但不建议可让用户随意设置,仍是要有个最大阈值。(API 网关不适合须要长时间传输数据的场景,好比大文件上传或者下载、DB 数据同步等)

实现上能够直接复用开源组件的功能,好比:HttpClient 能够直接设置获取链接和 Socket 响应的超时时间,Hystrix 能够对整个调用进行超时控制等。

三、熔断

熔断相似电路中的保险丝,当超过负荷或者电阻被击穿的时候自动断开对设备起到保护做用。在 API 网关中设置熔断的目的是快速响应请求,避免没必要要的等待,好比某个 API 后端服务正常状况下 1s 之内响应,但如今由于各类缘由出现堵塞大部分请求 20s 才能响应,虽然设置了 10s 的超时控制,但让请求线程等待 10s 超时不只没有意义,反而会增长服务提供方的负担。

为此咱们能够设置单位时间内超过多少比例的请求超时或者异常,则直接熔断链路,等待一段时间后再次尝试恢复链路。

实现层面能够直接复用 Hystrix。

运行时配置更新机制

前面章节提到过出于性能考虑网关在运行时要尽量减少对 DB 的访问,设计上能够将 API、组件等关键内容进行缓存,这样一来性能是提高了,但也带来了新的问题,好比 Admin 对 API 或者组件进行配置调整后如何及时更新到集群的各个网关节点。

解决方案不少,好比引入消息中间件,当 Admin 调整配置后就往消息中心发布一条消息,各网关节点订阅消息,收到消息后刷新缓存数据。

咱们在具体实现过程当中采用的是 Zookeeper 集群数据同步机制,其实现原理跟消息中间件很相似,只不过网关在启动的时候就会向 ZK 节点进行注册,也是被动更新机制。

性能考虑

性能是网关一项很是重要的衡量指标,尤为是响应时间,客户端原本能够直连服务端的,如今增长了一个网关层,对于一个自己耗时几百毫秒的服务接入网关后增长几毫秒,影响却是能够忽略不计;但若是服务自己只须要几毫秒,由于接入网关再增长一倍的延时,用户感觉就会比较明显。

建议在设计上须要遵循以下原则:

  1. 核心网关子系统必须是无状态的,便于横向扩展。
  2. 运行时不依赖本地存储,尽可能在内存里面完成服务的处理和中转。
  3. 减少对线程的依赖,采用非阻塞式 IO 和异步事件响应机制。
  4. 后端服务若是是 HTTP 协议,尽可能采用链接池或者 Http2,测试链接复用和不复用性能有几倍的差距。(TCP 创建链接成本很高)

附 -HttpClient 链接池设置:

1
2
3
4
5
6
7
PoolingHttpClientConnectionManager cmOfHttp =  new  PoolingHttpClientConnectionManager();
cmOfHttp.setMaxTotal(maxConn);
cmOfHttp.setDefaultMaxPerRoute(maxPerRoute);
httpClient = HttpClients.custom()
         .setConnectionManager(cmOfHttp)
         .setConnectionManagerShared( true )
         .build();

  

说明:

httpClient 对象能够做为类的成员变量长期驻留内存,这个是链接池复用的前提。

结语

API 网关做为企业 API 服务的汇聚中心,其良好的性能、稳定性和可扩展性是基础,只有这个基础打扎实了,咱们才能在上面扩展更多的特性。

这篇文章主要介绍网关的整体架构设计, 后面的篇幅在详细探讨下各类组件的具体设计和实现。

 

 

 

 

 

转自:https://www.cnblogs.com/kaleidoscope/p/9648004.html

相关文章
相关标签/搜索