Choerodon 的微服务之路(二):微服务网关

本文是 Choerodon 猪齿鱼微服务系列文章的第二篇。在《Choerodon的微服务之路(一):如何迈出关键的第一步》中,咱们了解到在微服务架构中,一个完整的单体应用被拆分红多个有着独立部署能力的业务服务,每一个服务可使用不一样的编程语言,不一样的存储介质,来保持最低限度的集中式管理。本篇将介绍Choerodon在搭建微服务网关时考虑的一些问题以及两种常见的微服务网关。前端

▌文章的主要内容包括:java

  • 为何要使用API Gateway
  • 两种Gateway 模式
  • Choerodon 的网关

对于Choerodon 而言,前端经过ReactJs实现,后端服务则经过Java,GoLang等多种语言实现。咱们经过将后端拆分红许多个单独的业务服务,选择不一样的语言切实地帮助咱们来实现系统功能,这种面向服务的模式给咱们带来了开发的便捷性,可是也带来了新的问题。服务之间如何作到相互通讯,前端与后端又是如何进行通讯的,是咱们须要去解决的问题。nginx

回到微服务架构的领域,若是要解决基本的通讯问题,基本上只要解决下面三个问题就能够了。git

  • 服务网络通讯能力
  • 服务间的数据交互格式
  • 服务间如何相互发现与调用

除了这些基本的问题之外,由于整个Choerodon是一个分布式的系统,开始时看似清晰的服务拆分,实则杂乱无章,有时候完成一个业务逻辑须要到不一样的服务区调取接口,这是一件很痛苦的事,同时咱们又不得不面对分布式的一些问题。包括负载均衡,链路追踪,限流,熔断,链路加密,服务鉴权等等一大堆的问题。因而一个面向服务治理、服务编排的组件——微服务网关,是咱们首要考虑的解决方案。github

为何要使用API Gateway

咱们回到文章开始时的三个问题,咱们来考虑如何解决服务间的通讯。编程

为何使用HTTP?后端

HTTP是互联网上应用最为普遍的一种网络协议,是一个客户端和服务器端请求和应答的标准(TCP),不管在哪一种语言中几乎都有原生的支持,即便没有,也有第三方库来支持。经过HTTP 减小网络传输,来解决服务间网络的通讯问题。api

为何选择JSON 做为数据交互格式?缓存

由于 JSON 自己轻量、简洁,无论是编写,传输,仍是解析都更加高效,并且相对来讲,每一个语言支持地都比较好。经过对JSON 的序列化和反序列化来实现网络请求中的数据交互。安全

为何使用K8S 来进行服务发现?

对于负载均衡而言,业内已经有多种成熟的解决方案了,也大可能是经过DNS 的方式去发现服务。不管是使用硬件F5 来解决,或者软件nginx,甚至Spring Cloud 也提供了对Eureka、Consul 等多种服务发现的支持。不过因为Choerodon 使用K8s 来做为服务编排引擎,基于K8s Client 来实现服务发现则更符合咱们的切实需求。

固然对于Choerodon 而言,咱们须要的不只仅是一个简单的通讯方式,而是一个完整的微服务解决方案。

API Gateway(API 网关)做为微服务体系里面的一部分,其须要解决的问题和 Choerodon 须要解决的问题很是相似。顾名思义,是企业 IT 在系统边界上提供给外部访问内部接口服务的统一入口。在微服务概念的流行以前,API网关的实体就已经诞生了,例如银行、证券等领域常见的前置机系统,它也是解决访问认证、报文转换、访问统计等问题的。

API 网关是一个服务器,是系统的惟一入口。从面向对象设计的角度看,它与 Facade 模式相似。API 网关封装了系统内部架构,为每一个客户端提供一个定制的API。它可能还具备其它职责,如身份验证、监控、负载均衡、缓存、请求分片与管理、静态响应处理。

若是没有 API 网关,你们可能想到的一向作法是经过前端客户端与后端服务直接通讯。这样会存在如下一些问题:

  • 客户端直连各个服务,耦合度过高
  • 全部的服务接口都须要暴露给客户端,会存在安全隐患
  • 当服务增多时,后端服务对于客户端而言则是一个噩梦
  • 屡次访问不一样的服务,请求数量过多,影响效率
  • 权限、身份校验等须要每一个服务都实现,重复造轮子

Choerodon 经过使用 Spring Cloud Zuul,将全部的后端都经过统一的网关接入微服务体系中,并在网关层处理全部的非业务功能,同时提供统一的REST/HTTP 方式对外提供API。

单节点Gateway 模式

所谓的单节点Gateway 模式,也就是提供一个单一的Gateway 来支持不一样的客户端访问。

这种模式下,你们会使用一个自定义 API 网关服务面对多个不一样客户端应用程序。其中最大的好处就是全部的请求都受统一网关的控制,实现简单。对于请求的身份认证、负载均衡、监控等均可以在统一的网关中实现。

伴随这一好处的同时,也会带来必定的风险。由于随着后端服务的增多,网关的API 将针对不一样客户端发展,愈来愈多。同时,因为接口权限、身份验证等都在网关中实现,统一网关也会变得愈来愈庞大,相似于一个单独的应用程序或者单体应用。

除此以外,也会引入一个新的问题,即资源隔离的问题。假设后端的一个服务忽然变慢,因为全部的请求都使用同一个网关入口,可能会将网关拖垮,进而影响到其余服务接口的访问。

要解决这个问题,有两种方式能够去解决,一种是作线程池的隔离,能够给一些重要的业务一些单独的线程池,不重要的业务再放到一个大的单独的线程池里面。另外一种就是给不一样的业务设置不一样的网关。

Spring Cloud 能够经过修改 ZUUL 和 hystrix 的配置,将信号量隔离修改成线程池隔离,提升性能。

zuul:
 ribbonIsolationStrategy: THREAD

hystrix:
 command:
 default:
 execution:
 isolation:
 strategy: THREAD #hystrix隔离策略,默认为THREAD
 thread:
 timeoutInMilliseconds: 20000 #hystrix超时时间
 threadpool:
 default:
 coreSize: 100 #并发执行的最大线程数
 maximumSize: 5000 #最大线程池大小
 allowMaximumSizeToDivergeFromCoreSize: true #容许maximumSize 配置生效
 maxQueueSize: -1 #设置最大队列大小,为-1时,使用SynchronousQueue
复制代码

线程池隔离仅仅作到了线程池的隔离,可是 CPU 和 Memory 之类资源的隔离其实并无作。若是想要更加完全的隔离方式,能够采用和线程池隔离相似的方式,给重要的服务用独立的网关来为其服务,不重要的服务,再给一个独立的网关来服务。这也就是多节点的Gateway 模式。

多节点Gateway 模式

多节点的Gateway 模式自己是一种BFF架构,即为不一样的设备提供不一样的API接口,引伸而来,也能够按照不一样的业务类型划分为多种业务场景下的网关。

上面这张图显示了一个简化版本的多API 网关。在这种状况下,每一个API 的边界是基于BFF 模式,所以只提供每一个客户端应用全部要的API。

这种模式带来的好处是根据不一样颗粒度的API网关,在性能上可以作到更精确的控制。可是在服务网关中能够完成一系列的横切功能,例如权限校验、限流以及监控等,则须要在每一个网关中重复实现。代码开发比较冗余。

能够说,这两种模式各有利弊,并不能单纯的比较其好坏,而应该根据实际的业务场景来选择适合本身的解决方案。

Choerodon 的网关

结合Choerodon 自身的核心业务,咱们在不考虑多终端的状况下,最终选择了单一网关,并在此基础上,作了插件化的开发。

Choerodon 认为,一个网关应该包含两部分。

服务网关 = 路由转发 + 过滤器

路由转发:将外部的请求,转发到对应的微服务上 过滤器:包含一系列非功能的横切需求。例如权限校验、限流、监控等

咱们在API 网关中保留了Spring Cloud Zuul 的路由转发,而后将权限校验等抽离到一个叫作gateway-helper 的服务中。以下图所示。

请求到达api-gateway 后,根据当前的HttpContext 上下文封装一个RibbonCommandContext 对象,该对象将包含了请求转发的gateway-helper 对应的信息。再由RibbonCommandFactory 根据RibbonCommandContext 对象生成一个RibbonCommand,由RibbonCommand 完成HTTP 请求的发送并的获得响应结果ClientHttpResponse。核心代码以下:

// GateWayHelperFilter.java
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {
    HttpServletRequest req = (HttpServletRequest) request;
    HttpServletResponse res = (HttpServletResponse) response;
    RibbonCommandContext commandContext = buildCommandContext(req);
    try (ClientHttpResponse clientHttpResponse = forward(commandContext)) {
        if (clientHttpResponse.getStatusCode().is2xxSuccessful()) {
            request.setAttribute(HEADER_JWT, clientHttpResponse.getHeaders().getFirst(HEADER_JWT));
            chain.doFilter(request, res);
        } else {
            setGatewayHelperFailureResponse(clientHttpResponse, res);
        }
    } catch (ZuulException e) {
        res.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        res.setCharacterEncoding("utf-8");
        try (PrintWriter out = res.getWriter()) {
            out.println(e.getMessage());
            out.flush();
        }
    }
}

private RibbonCommandContext buildCommandContext(HttpServletRequest req) {
    Boolean retryable = gatewayHelperProperties.isRetryable();
    String verb = getVerb(req);
    MultiValueMap<String, String> headers = buildZuulRequestHeaders(req);
    MultiValueMap<String, String> params = buildZuulRequestQueryParams(req);
    InputStream requestEntity;
    long contentLength;
    String requestService = gatewayHelperProperties.getServiceId();
    requestEntity = new ByteArrayInputStream("".getBytes());
    contentLength = 0L;
    return new RibbonCommandContext(requestService, verb, req.getRequestURI(), retryable,
            headers, params, requestEntity, this.requestCustomizers, contentLength);
}

private ClientHttpResponse forward(RibbonCommandContext context) throws ZuulException {
    RibbonCommand command = this.ribbonCommandFactory.create(context);
    try {
        return command.execute();
    } catch (HystrixRuntimeException ex) {
        throw new ZuulException(ex, "Forwarding gateway helper error", 500, ex.getMessage());
    }
}
复制代码

能够看到,咱们在api-gateway 服务中完成了对请求的首次转发。请求到达gateway-helper。在gateway-helper 中,针对配置进行判断,若是有自定义的helper,则会重定向到自定义的helper 上进行后续的处理。不然的话按照默认的逻辑进行权限校验。核心代码以下:

// RequestRootFilter.java
public boolean filter(final HttpServletRequest request) {
    String uri = RequestRibbonForwardUtils.buildZuulRequestUri(request);
    String service = RequestRibbonForwardUtils.getHelperServiceByUri(helperZuulRoutesProperties, uri);
    if (StringUtils.isEmpty(service)) {
        return requestPermissionFilter.permission(request) && requestRatelimitFilter.through(request);
    }
    return customGatewayHelperFilter(request, service, uri);
}

private boolean customGatewayHelperFilter(final HttpServletRequest request, final String service, final String uri) {
    ClientHttpResponse clientHttpResponse = null;
    try {
        RibbonCommandContext commandContext = RequestRibbonForwardUtils.buildCommandContext(request, requestCustomizers, service, uri);
        clientHttpResponse = RequestRibbonForwardUtils.forward(commandContext, ribbonCommandFactory);
        return clientHttpResponse.getStatusCode().is2xxSuccessful();
    } catch (Exception e) {
        LOGGER.warn("error.customGatewayHelperFilter");
        return false;
    } finally {
        if (clientHttpResponse != null) {
            clientHttpResponse.close();
        }
    }
}   
复制代码

RequestPermissionFilter 中,咱们对请求进行权限校验,来判断该用户是否有对应资源的操做权限。核心代码以下:

//RequestPermissionFilterImpl.java
public boolean permission(final HttpServletRequest request) {
    if (!permissionProperties.isEnabled()) {
        return true;
    }
    //若是是文件上传的url,以/zuul/开否,则去除了/zuul再进行校验权限
    String requestURI = request.getRequestURI();
    if (requestURI.startsWith(ZUUL_SERVLET_PATH)) {
        requestURI = requestURI.substring(5, requestURI.length());
    }
    //skipPath直接返回true
    for (String skipPath : permissionProperties.getSkipPaths()) {
        if (matcher.match(skipPath, requestURI)) {
            return true;
        }
    }
    //若是获取不到该服务的路由信息,则不容许经过
    ZuulRoute route = ZuulPathUtils.getRoute(requestURI, helperZuulRoutesProperties.getRoutes());
    if (route == null) {
        LOGGER.info("error.permissionVerifier.permission, can't find request service route, "
                + "request uri {}, zuulRoutes {}", request.getRequestURI(), helperZuulRoutesProperties.getRoutes());
        return false;
    }
    String requestTruePath = ZuulPathUtils.getRequestTruePath(requestURI, route.getPath());
    final RequestInfo requestInfo = new RequestInfo(requestURI, requestTruePath,
            route.getServiceId(), request.getMethod());
    final CustomUserDetails details = DetailsHelper.getUserDetails();
    //若是是超级管理员用户,且接口非内部接口,则跳过权限校验
    if (details != null && details.getAdmin() != null && details.getAdmin()) {
        return passWithinPermissionBySql(requestInfo);
    }
    //判断是否是public接口获取loginAccess接口
    if (passPublicOrLoginAccessPermissionByMap(requestInfo, details)
            || passPublicOrLoginAccessPermissionBySql(requestInfo, details)) {
        return true;
    }
    if (details == null || details.getUserId() == null) {
        LOGGER.info("error.permissionVerifier.permission, can't find userDetail {}", requestInfo);
        return false;
    }
    //其余接口权限权限审查
    if (passSourcePermission(requestInfo, details.getUserId())) {
        return true;
    }
    LOGGER.info("error.permissionVerifier.permission when passSourcePermission {}", requestInfo);
    return false;
}
复制代码

经过上述的代码片断能够看到。在Choerodon 中,能够自主实现本身的geteway-helper,来对请求进行更复杂的控制。

Choerodon 支持在页面上对路由信息进行配置和修改,控制路由的动态调整。以下图所示。

以看到,经过页面对路由进行修改后,路由动态更新到 api-gateway及gateway-heler。经过配置中心实时生效,避免了修改代码从新部署带来的麻烦。

总结

回顾一下这篇文章,咱们介绍了Choerodon 在搭建微服务网关时考虑的一些问题以及两种常见的微服务网关,而且经过代码介绍了Choerodon 的网关时如何实现的。这些都是咱们实践过程当中的一些作法和体会,但愿你们能够结合本身的业务来参考。

更多关于微服务系列的文章,欢迎点击阅读 ▼

关于Choerodon猪齿鱼

Choerodon猪齿鱼是一个开源企业服务平台,是基于Kubernetes的容器编排和管理能力,整合DevOps工具链、微服务和移动应用框架,来帮助企业实现敏捷化的应用交付和自动化的运营管理的开源平台,同时提供IoT、支付、数据、智能洞察、企业应用市场等业务组件,致力帮助企业聚焦于业务,加速数字化转型。

你们也能够经过如下社区途径了解猪齿鱼的最新动态、产品特性,以及参与社区贡献:

欢迎加入Choerodon猪齿鱼社区,共同为企业数字化服务打造一个开放的生态平台。

相关文章
相关标签/搜索