微服务网关组件在58金融的实践


【编者的话】随着车金融业务的快速发展,单体架构的系统已经不能满足业务的快速发展的需要,在这种情况下,本文主要介绍微服务网关在金融的实践与演进过程。

随着车金融业务的快速发展,单体架构的系统已经不能满足业务的快速发展的需要,因此在2018年初,我们对车金融业务进行了微服务架构的升级改造, 整个系统拆分出40多个微服务。在重构过程中我们发现以下几个问题:
  • 每一个访问微服务系统的客户端都需要维护一份服务路由关系;
  • 一些通用的如身份鉴权、权限控制等功能,微服务中重复开发。


为了解决上述的痛点,方便统一调用微服务接口,所以在架构上引入了服务网关。

什么是网关

网关又称为API网关,是微服务系统的唯一流量入口。所有的客户端都通过网关访问微服务,API网关封装了系统的内部访问,同时提供了部分通用的功能,比如:身份验证、权限、负载均衡、限流、熔断、灰度发布等。

以电影场景举例来说:

顾客1观看3D电影,由检票员检票通过之后发放3D眼镜,并指引顾客进入3D观影厅;顾客2和顾客3观看2D电影,由检票员检票通过之后,指引顾客进入2D观影厅;在互联网领域中,顾客为流量,检票为身份鉴权,发放3D眼镜为对请求的扩展,指引顾客进入不同的观影厅为对请求的路由。
1.png

API网关优势

在不引入网关系统的情况下:
2.png

  1. 客户端会请求不同的微服务,会增加客户端复杂性
  2. 每个服务需要独立开发相同的非业务功能(身份认证)


引入网关系统后:
3.png

  1. 降低客户端访问微服务的复杂度,对路由配置统一管理
  2. 提供公共通用功能(如:权限控制,身份认证)


技术选型

业界网关解决方案有很多,包括商业的、开源的。例如Tyk(Tyk 是一个基于Go实现的网关服务)、Kong、Orange(和Kong类似,中国人开发,有比较有好的UI界面)、api-umbrella(Ruby实现的一个API网关)、apiaxle(Nodejs 实现的一个 网关)、Netflix Zuul、Nginx+Lua等;最终,由于金融的Java生态,并且基于spring体系的java架构,决定技术选型为Netflix Zuul作为金融的网关服务。

Zuul简介

Zuul是什么?Zuul是API网关的开源实现方案,主要包含了对请求的路由和过滤两个功能。Zuul是由Netflix开源的微服务网关,是基于JVM的路由器和服务端负载均衡器,可以和Consul、Ribbon、Hystrix等组件配合使用,并且Spring Cloud对Zuul进行了整合,使我们可以非常简洁方便的构建我们的API网关。

Zuul的核心是一系列的Filters,其作用可以类比Servlet框架的Filter,或者AOP;Zuul中定义了四种标准过滤器类型,这些过滤器类型对应于请求的典型生命周期。
  • PRE:这种过滤器在请求被路由之前调用。可利用这种过滤器实现身份验证、在集群中选择请求的微服务、记录调试信息等。
  • ROUTING:这种过滤器将请求路由到微服务。这种过滤器用于构建发送给微服务的请求,并使用Apache HttpClient或Netfilx Ribbon请求微服务。
  • POST:这种过滤器在路由到微服务以后执行。这种过滤器可用来为响应添加标准的HTTPHeader、收集统计信息和指标、将响应从微服务发送给客户端等。
  • ERROR:在其他阶段发生错误时执行该过滤器。


下图为过滤器的生命周期:
4.jpg

金融网关实践

网关建设初期

随着金融多业务线的不断发展,网关需要提供更多的功能,比如:灰度,白名单标签等。同时,不同的业务也需要搭建网关服务。所以网关面临下面三个问题:
  1. 新接入业务必须要修改静态路由配置文件,熟悉Spring的同学都知道就是yml文件,这样势必会引入线上重启的风险;
  2. 随着服务接入的增多,各个服务也会有各种拦截功能的调整,比如首页不需要登录拦截,基础数据不需要权限功能等,这时候需要修改网关中的源码来做到这种适配;
    3)伴随着金融各个业务线微服务架构调整,每个业务线都需要建设自己的网关,各个业务线的网关有许多相同的功能相互重叠,并且得不到复用,每个业务线也需要投入人力去开发与维护相关的工作;


基于上面的三个问题,我们对金融网关也进行了改造升级。

网关云演进过程

5.jpg

为了改造原有各个业务线重复建设导致的资源浪费,首先整合所有业务网关到单集群中,然后依托于集团云平台的流量分组能力,在网关内部对不同业务线做了流量隔离。引入数据库作为网关配置,把服务注册、路由配置以及功能组件作为动态配置项,提供可视化界面增加、修改配置信息,配置的修改会通过消息队列通知网关集群,网关修改相应的内部配置缓存;以此来支持网关功能组件的可插拔式配置;目前网关的内部架构可以灵活的支持不同业务线的业务拦截需求,对内部新业务的扩展也可以做到通过配置的形式支持。下面将详细介绍网关功能组件的动态配置及动态路由的改造过程。

网关动态配置演进:

网关对于不同的请求做不同的功能拦截操作,需要修改相关代码做一些适配工作。随着网关集群的业务线增加,每个业务线都需要一些需求调整,这时候会带来一些网关功能的调整,为了节省修改代码的人力成本和消除不必要的上线;因此,我们就思考如何才能把这些静态配置化操作转为动态化呢?
6.jpg

为了做动态化拦截功能配置。首先把拦截功能模块基于责任链模式做了拆分,拼接链的环节通过配置中心加载到内存中的配置,对不同的服务进行不同的责任链拼接,这样配置中心修改配置网关实时感知配置的变动,进行动态拦截功能模块的动态配置化改造。那么对于动态路由的改造呢?

动态路由:

由于Zuul在不引入注册中心的情况下只支持通过yml、properties获取路由信息,对于接入新服务非常的不友好,因为要修改静态配置文件然后进行上线升级操作。在第一版的演进过程中希望通过db暂时作为配置中心,而不引入注册中心。因此通过对相关的源码进行了查看(本文内相关源码及配置均有删减,代码出处见参考文献)。
@Override
public void service(javax.servlet.ServletRequest servletRequest, javax.servlet.ServletResponse servletResponse) throws ServletException, IOException {
        ...
        // 路由预处理(pre阶段)
        preRoute();
        ...
        // 路由阶段(route阶段)
        route();
        ...
        // 请求响应阶段(post阶段)
        postRoute();


在路由阶段(route阶段)请求会先经过RibbonRoutingFilter,然后经过SimpleHostRoutingFilter。

以下代码分别是两个Filter的执行条件:
//RibbonRoutingFilter
ctx.getRouteHost() == null && ctx.get(SERVICE_ID_KEY) != null&& ctx.sendZuulResponse());

// SimpleHostRoutingFilter
RequestContext.getCurrentContext().getRouteHost() != null
                    && RequestContext.getCurrentContext().sendZuulResponse();

通过以上代码,结合application.yml配置文件:
zuul:
routes:
    service1:
      path: /service1/**
      url: http://127.0.0.1:8080
    service2:
      path: /service2/**
      serviceId: service2

当调用到RibbonRoutingFilter时会去判断serviceId是否为空(执行路由条件),当调用到SimpleHostRoutingFilter时会校验host是否为空。

由此推断路由信息是在pre阶段确定下来的,然后定位到PreDecorationFilter会根据请求URI匹配相应的路由信息,然后获取静态配置中的路由信息解析出相应的RouteHost和serviceId。其源码(由于源码过长,请同学们自行查看)中RouteLocator即为我们的路由定位器,也就是我们要重写的部分。

路由定位器:

PreDecorationFilter通过RouteLocator根据URI获取Route,因此可以通过对RouteLocator的扩展来完成动态路由工作。Spring Cloud默认的路由定位器由SimpleRouteLocator来实现。

主要功能包含:
  • 通过properties获取所有路由;
  • 根据请求URI获取路由信息;


代码如下:
public class SimpleRouteLocator implementsRouteLocator, Ordered {

  // routes 用于存储路由信息
  private AtomicReference<Map<String,ZuulRoute>> routes = new AtomicReference<>();

  // 查找路由信息
  protected Map<String, ZuulRoute> locateRoutes() {
   LinkedHashMap<String, ZuulRoute>routesMap = new LinkedHashMap<>();
         // 提取ZuulProperties中的ZuulRoute
        for (ZuulRoute route :this.properties.getRoutes().values()) {
            routesMap.put(route.getPath(), route);
        }
        return routesMap;
  }

  // 根据请求匹配路由
  protected Route getSimpleMatchingRoute(final Stringpath) {
        // 确认初始化路由map完成
        getRoutesMap();

        // 对URI处理
        String adjustedPath = adjustPath(path);
        // 获取匹配路由
        ZuulRoute route = getZuulRoute(adjustedPath);
        return getRoute(route, adjustedPath);
  }


所以这里继承SimpleRouteLocator并重写了locateRoutes函数,由properties获取路由信息改为通过DB获取我们的路由信息。
@Override
public Map<String, ZuulRoute>loadLocateRoute() {
List<ZuulRouteDto> zuulRouteDtos =getZuulRoutes();
// 把DB获取的路由信息转为Map
Map<String, ZuulRoute> handle =handle(zuulRouteDtos);
return handle;
}

/**
* @authorpenghb
* @description 获取所有路由
* @date 8:37PM 2019/6/3
* @return 路由列表
**/
private List<ZuulRouteDto> getZuulRoutes() {
String cloudClusterGroup =System.getenv(SYSTEM_CLOUD_GROUP);
APIResponse<List<ZuulRouteDto>> all = zuulRouteService.findByCloudGroupCode(cloudClusterGroup);
return APIResponseUtils.getResultData(all);


路由动态刷新:

由于Spring Cloud默认的SimpleRouteLocator是不支持路由刷新的,但是自定义的动态路由是要支持路由的刷新功能的(当配置中心路由信息修改后,网关要实时的刷新路由信息),因此在继承SimpleRouteLocator的基础上,还要实现Zuul提供的RefreshableRouteLocator来支持动态路由刷新能力。

Zuul内部提供了ZuulRefreshListener,它会监听ApplicationEventPublisher发布的事件,如果事件为RoutesRefreshedEvent,则会调用routeLocator的refresh函数,在自定义的路由定位器中可以直接调用SimpleRouteLocator的doRefresh函数:
protected void doRefresh() {
this.routes.set(locateRoutes());


当路由信息在配置中心发生变化的时候,就通过ApplicationEventPublisher发布一个RoutesRefreshedEvent事件:
RoutesRefreshedEventroutesRefreshedEvent = new RoutesRefreshedEvent(routeLocator);
publisher.publishEvent(routesRefreshedEvent); 

这样动态刷新路由也实现了。

最后向IOC容器中注入自定义的路由定位器,去替换Spring Cloud的路由定位器。
@Bean
@ConditionalOnMissingBean(ZuulRouteDatabaseLocator.class)
public ZuulRouteDatabaseLocator zuulRouteDatabaseLocator() {
  return newZuulRouteDatabaseLocator(this.server.getServletPrefix(), this.zuulProperties);


这样完整的动态路由就实现完成了。

引入Consul作为注册中心

经过上面的改造后,发现在应用过程中新接入服务必须要经过人工配置,并且新服务都需要为接入网关而申请内网域名,为了解决人工配置和申请域名的人工介入,注册中心就粉墨登场了。

Consul是一种服务网络解决方案,可跨任何运行时平台以及公共或私有云连接和保护服务。
7.png

金融网关借助于集团的云平台,在每一个业务实例所在的Docker中,启动一个Consul的Agent进程(即Consul client),这个Agent会收集业务实例进程的相关信息(如:容器IP、业务进程端口等)上报给Consul Server集群,该Agent也负责做服务的健康检查相关的工作,并且随服务一起启动,一起销毁;然后网关会通过Consul Server获取服务路由信息进行路由。通过引入Consul彻底解决了服务的人工配置,做到了自动化的服务发现与路由。

网关内部线程模型

目前我们使用的Zuul版本为1.x,该版本中对一次请求的拦截与路由使用的是同步阻塞线程。
8.png

优势

首先在设计层面上架构设计简单,其次源码阅读上代码易于理解,最后是链路追踪比较方便,出现问题时易于排查。

缺点

Zuul内部本质上是一个同步的servlet,这样每一个请求servlet都会为其分配一个线程来处理这个请求,但是容器中的线程是有限的,一般会使用线程池,当后端服务响应缓慢时,线程资源会被持续占用,当线程被大量占用导致连接池满之后,新请求会被拒绝。

未来展望

对于网关目前存在的问题,首先在未来会基于Netty去改造金融网关;同时网关也是所有服务的入口,也会对服务的性能分析以及健康指标做一些相关的分析工作。

Zuul GitHub:https://github.com/Netflix/zuul/wiki

Zuul源码https://github.com/Netflix/zuul/tree/1.x

作者简介:彭海滨,金融公司车贷技术部开发工程师,负责金融公司网关建设和开发。

原文链接:https://mp.weixin.qq.com/s/2LGwtRCktOdqL2sx_LH_bw

0 个评论

要回复文章请先登录注册