第31讲:如何设计与实现 API 组合

从现在开始,我们将进入到 API 组合这一模块,该模块分为 3 个课时,分别介绍 API 组合的相关概念和具体的实现技术。而此次课时主要介绍 API 组合的设计与实现。

在介绍 API 组合之前,首先介绍一下 API 网关(Gateway)。

API 网关

这里先区分一下外部 API 和内部 API。外部 API 是提供给 Web 应用、移动客户端和第三方客户端来调用的;而内部 API 是提供给其他微服务来调用的。

如果把微服务架构的后台当成一个黑盒子,那么外部 API 就是外部用户与这个黑盒子交互的方式,这两者之间交互的桥梁,就是 API 网关。所有外部的 API 访问请求,都需要通过这个网关进入到后台。

下图给出了 API 网关的示意图,其本质上是一个反向代理(Reverse Proxy)。

DevOps 云原生 云原生api_DevOps 云原生

作为外部访问请求的唯一入口,API 网关所能提供的功能非常丰富,具体如下。

API 网关负责把外部的访问请求路由到具体的服务,在进行路由时,通常根据访问请求的路径、查询参数和 HTTP 头来确定。

比如,可以把路径以 /trip/ 开头的请求路由到行程管理服务,而把以 /passenger/ 开头的请求路由到乘客管理服务。而当服务支持多个版本同时运行时,请求路由的逻辑会更加复杂一些,比如路径前缀 /trip/v1/ 和 /trip/v2/ 的请求分别路由到不同版本的行程管理服务。API 网关的路由通过静态或动态的方式进行配置,有些 API 网关支持通过开放 API 在运行时动态修改路由配置。

API 组合把来自不同服务的数据组合在一起,形成新的组合 API,这也是本模块需要介绍的内容。如果把不同服务 API 所提供的数据当成是数据库的表格,那么 API 组合就是表之间的连接操作。在组合 API 时,可以对 API 返回的结果进行投影、转换和充实。

  • 边界功能用来在接收到请求之后,在请求发送给后台服务之前,对请求进行处理。边界功能通常满足应用的一些横切需要,包括身份认证、授权管理、请求速率限制、缓存、性能指标数据收集、请求日志等。由于 API 网关实现了这些功能,可以简化后台服务的开发,这也是大部分 API 网关产品的卖点所在。
  • 协议翻译指的是把外部 API 的协议转换成内部服务之间使用的协议。外部 API 为了兼容性,一般使用 REST API 作为协议,而应用内部的服务之间,出于性能的考虑,可能使用 gRPC,甚至是私有的协议。API 网关负责在两种 API 协议之间进行转换。

由于 API 网关的重要性,云平台通常都提供了相关的服务。除此之外,还有很多开源和商用的产品,比较流行的产品包括 WSO2、Netflix Zuul、Spring Cloud Gateway、Kong 和 SwaggerHub 等。

从上述说明中可以看到,API 组合也属于 API 网关的一种功能。只不过 API 组合与应用的逻辑紧密相关,无法通过简单的配置来实现,一般需要编写代码或脚本来完成。

API 组合

在微服务架构的应用中,应用的功能被分散到多个微服务中。来自一个微服务的 API 并不能满足外部使用者的需求,因为一个微服务只能提供部分数据。比如,示例应用中的乘客管理界面需要使用来自乘客管理服务、地址管理服务、行程管理服务和行程派发服务的数据。因此,需要一种方式来提供给使用者所需要的全部数据。

第一种做法是由客户端根据需要来直接调用不同微服务的 API,这种做法在客户端和微服务之间建立了紧密的耦合关系,增加了客户端使用 API 的难度。当展示一个页面时,可能需要调用多次 API,相应的性能也会比较差。

另外一种做法是使用第 22 课时介绍的 CQRS 技术,针对客户端的不同需求,创建相应的查询服务,这种做法可以避免多次 API 调用的性能问题。不过 CQRS 技术使用的范围较窄,技术的门槛较高,在实践中的应用也比较少。

相比前两种做法,更好的做法是使用 API 组合,在应用内部创建进行 API 组合的服务。对客户端发送的 API 请求,该组合服务调用后台的多个微服务的 API,并把得到的数据进行整合,再返回给客户端。API 组合的好处是对微服务 API 的调用发生在系统内部,调用的延迟很小,也免去了客户端的多次调用。

Backend For Frontend 模式

当应用所要支持的客户端种类变多时,使用单一的通用 API 变得不再适用,这是由于不同客户端的差异性造成的。

桌面客户端的屏幕大、一般使用的是高速的 ADSL 或光纤网络;移动客户端屏幕小、网络速度较慢,而且对电池消耗有要求。

这就意味着在移动客户端上需要严格控制 API 请求的数量和响应的大小。移动客户端上的用户体验也与 Web 界面有很大差异,满足用户界面需求的 API 也相应地存在很大不同。如果使用单一的 API 为这两类客户端服务,那么这些差异性会使得 API 的维护成本变高。

Backend For Frontend 模式指的是为每一种类型的前端创建其独有的后端 API。这个 API 专门为前端设计,完全满足其需求,通常由该前端的团队来维护。

下图是 Backend For Frontend 模式的示意图,其中移动客户端和桌面客户端使用专门为它们设计的 API,这些 API 使用同样的后台服务作为数据来源。

DevOps 云原生 云原生api_云原生_02

在微服务架构的应用中,这种模式实际上更加适用,因为微服务已经把系统的功能进行了划分,在实现前端需要的 API 时,只需要把微服务的 API 进行整合即可,同时也对前端屏蔽了后端 API 的细节。

API 组合的实现

API 组合有很多种不同的实现方式,最简单的做法是基于已有的工具进行配置,复杂的实现则需要自己编写代码开发。

本课时介绍的是为管理乘客的 Web 界面创建的 API 组合,完整的实现请参考示例应用源代码中的 happyride-passenger-web-api 模块。

乘客管理 Web 界面在管理乘客时,需要用到乘客的地址信息,地址管理服务负责对地址进行统一管理,提供了地址的搜索和查询 API,地址管理服务对应的聚合的根实体是地址对象。在乘客管理服务中,表示用户地址的 UserAddress 对象只包含了地址对象的标识符,并没有包含其他信息。这就意味着当乘客查看或编辑地址时,对应的 API 需要调用地址管理服务的 API 来获取地址的详细数据。

Web 界面需要的这个 API 负责返回乘客的所有相关信息,包括每个地址的详细信息。而对于地址搜索和查询相关的请求,则直接转发给内部的地址管理服务。

示例应用使用 Spring Cloud Gateway 来实现该 API 组合。

1. Spring Cloud Gateway

Spring Cloud Gateway 是 Spring 框架提供的 API 网关的实现,基于 Spring Boot 2、Spring WebFlux 和 Project Reactor。在使用 Spring Cloud Gateway 之前,需要对反应式编程的概念有基本的了解。

反应式编程是一套完整的编程体系,既有其指导思想,又有相应地框架和库的支持,并且在生产环境中有大量实际的应用。Java 9 中把反应式流规范以 java.util.concurrent.Flow 类的方式添加到了 Java 标准库中,Spring 5 对反应式编程模型提供了支持,尤其是反应式 Web 应用开发使用的 WebFlux,Spring 5 默认的反应式框架是 Reactor。

Reactor 是一个完全基于反应式流规范的库,两个最核心的类是 Flux 和 Mono,用来表示流:

  • Flux 表示包含 0 到无限个元素的流;
  • Mono 则表示最多一个元素的流。

Flux 和 Mono 的强大之处来源于各种不同的操作符,可以对流中的元素进行不同的处理。

Spring Cloud Gateway 中有 3 个基本的概念,分别是路由、断言和过滤器。

  • 路由是网关的基本组成部分,由标识符、目的地 URI、断言的集合和过滤器的集合组成。
  • 断言用来判断是否匹配 HTTP 请求,本质上是一个 Java 中的 Predicate 接口的对象,进行判断时的输入类型是 Spring 的 ServerWebExchange 对象。
  • 过滤器用来对 HTTP 请求和响应进行处理,它们都是 GatewayFilter 接口的对象,多个过滤器串联在一起,组成过滤器链。前一个过滤器的输出作为下一个过滤器的输入,这一点与 Servlet 规范中的过滤器是相似的。

当客户端的请求发送到网关时,网关会通过路由的断言来判断该请求是否与某个路由相匹配。如果找到了对应的路由,请求会由该路由的过滤器链来处理,过滤器既可以在请求发送到目标服务之前进行处理,也可以对目标服务返回的响应进行处理。

Spring Cloud Gateway 提供了两种方式来配置路由,一种方式是通过配置来声明,另一种是通过代码来完成。

Spring Cloud Gateway 提供了大量内置的断言和过滤器的工厂实现。以断言来说,可以通过 HTTP 请求的头、方法、路径、查询参数、Cookie 和主机名等来进行匹配;以过滤器来说,内置的过滤器工厂可以对 HTTP 请求的头、路径、查询参数和内容进行修改,也可以对 HTTP 响应的状态码、头和内容进行修改,还可以添加请求速率限制、自动重试和断路器等功能。

2. 具体实现

对于本课时要实现的 API 组合来说,地址相关的 API 只需要转发给地址管理服务即可,这可以通过 Spring Cloud Gateway 的配置来完成。下表是 API 组合所提供的功能。

API 路径

说明

/address/**

转发给地址管理服务

/passenger/{passengerId}

获取乘客的详细信息,组合来自乘客管理服务和地址管理服务的数据

在下面的配置中,标识符为 address_service 的路由的目的地 URI 由配置项 destination.address 来确定,使用的断言是 Path 类型,也就是根据请求的路径来判断,即以 /address 开头的全部请求。使用的过滤器是 StripPrefix,也就是去掉 URI 的路径中的一些前缀。在使用这个过滤器之后,路径 /address/search 会被替换为 /search,与地址管理服务的 API 路径相匹配。

spring:
  cloud:
    gateway:
      routes:
        - id: address_service
          uri: ${destination.address}
          predicates:
            - Path=/address/**
          filters:
            - StripPrefix=1

在实现获取乘客详细信息的 API 时,需要对乘客管理服务返回的乘客信息进行修改,添加地址的详细信息。获取地址信息需要访问地址管理服务的 API,这里使用的是 Spring WebFlux 提供的反应式客户端 WebClient 对象。下面代码 AddressServiceProxy 中的 getAddresses 方法用来访问地址管理服务的 API,配置对象 DestinationConfig 中包含了地址管理服务的地址。

当访问 API 出现错误时,getAddresses 方法返回的 Mono 对象中包含的是一个空的列表,这样做可以保证乘客 API 在地址管理服务出现问题时,仍然可以返回有价值的部分数据。这也是错误处理的一种常见策略。

@Service
public class AddressServiceProxy {
  @Autowired
  DestinationConfig destinationConfig;
  public Mono<List<AddressVO>> getAddresses(final String addressIds) {
    return WebClient.create(this.destinationConfig.getAddress())
        .get()
        .uri(uriBuilder -> uriBuilder.path("/addresses/{addressIds}")
            .build(ImmutableMap.of("addressIds", addressIds)))
        .retrieve()
        .bodyToMono(new ParameterizedTypeReference<List<AddressVO>>() {})
        .onErrorReturn(Collections.emptyList());
  }
}

@Service
public class AddressServiceProxy {
  @Autowired
  DestinationConfig destinationConfig;
  public Mono<List<AddressVO>> getAddresses(final String addressIds) {
    return WebClient.create(this.destinationConfig.getAddress())
        .get()
        .uri(uriBuilder -> uriBuilder.path("/addresses/{addressIds}")
            .build(ImmutableMap.of("addressIds", addressIds)))
        .retrieve()
        .bodyToMono(new ParameterizedTypeReference<List<AddressVO>>() {})
        .onErrorReturn(Collections.emptyList());
  }
}

在实现乘客 API 时,需要用到修改 HTTP 响应内容的过滤器,该过滤器只能通过代码来配置,如下面的代码所示。RouteLocatorBuilder 构建器用来创建包含路由的 RouteLocator 对象,路由的标识符是 enrich_passenger,使用的断言基于请求的路径进行匹配。第一个过滤器 stripPrefix 去掉 /passenger 前缀,第二个过滤器 modifyResponseBody 声明了原始的响应内容的类型是 PassengerVO 对象,而修改之后的内容的类型是 PassengerResponse 对象。

在进行修改时,把 PassengerVO 对象中包含乘客的所有地址的标识符以逗号分隔并连接起来之后,调用 AddressServiceProxy 对象的 getAddresses 方法来批量获取地址的信息。最后把这两部分数据组合在 PassengerResponse 对象中,作为最终的响应。

@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {
  @Autowired
  AddressServiceProxy addressServiceProxy;
  @Autowired
  DestinationConfig destinationConfig;
  @Bean
  public RouteLocator routes(final RouteLocatorBuilder builder) {
    return builder.routes()
        .route("enrich_passenger", r -> r.path("/passenger/{passengerId}")
            .filters(f -> f
                .stripPrefix(1)
                .modifyResponseBody(PassengerVO.class, PassengerResponse.class,
                    (exchange, passenger) -> {
                      final String addressIds = passenger.getUserAddresses()
                          .stream()
                          .map(UserAddressVO::getAddressId)
                          .collect(Collectors.joining(","));
                      return this.addressServiceProxy.getAddresses(addressIds)
                          .map(addresses ->
                              PassengerResponse
                                  .fromPassengerAndAddresses(passenger,
                                      addresses));
                    }))
            .uri(this.destinationConfig.getPassenger())).build();
  }
}

@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {
  @Autowired
  AddressServiceProxy addressServiceProxy;
  @Autowired
  DestinationConfig destinationConfig;
  @Bean
  public RouteLocator routes(final RouteLocatorBuilder builder) {
    return builder.routes()
        .route("enrich_passenger", r -> r.path("/passenger/{passengerId}")
            .filters(f -> f
                .stripPrefix(1)
                .modifyResponseBody(PassengerVO.class, PassengerResponse.class,
                    (exchange, passenger) -> {
                      final String addressIds = passenger.getUserAddresses()
                          .stream()
                          .map(UserAddressVO::getAddressId)
                          .collect(Collectors.joining(","));
                      return this.addressServiceProxy.getAddresses(addressIds)
                          .map(addresses ->
                              PassengerResponse
                                  .fromPassengerAndAddresses(passenger,
                                      addresses));
                    }))
            .uri(this.destinationConfig.getPassenger())).build();
  }
}

下面的 JSON 代码展示了乘客管理服务返回的乘客信息的数据。

{
    "email": "test@test.com",
    "id": "55af028b-6bd3-4266-b8db-70d2b0d2dc07",
    "mobilePhoneNumber": "13812345678",
    "name": "test",
    "userAddresses": [
        {
            "addressId": "c258ac5f-c86c-4fd0-b046-a17c047ba6a3",
            "id": "b11db379-f652-4aa9-ac4a-0cb0c0224b30",
            "name": "home"
        },
        {
            "addressId": "ba629ecf-3f92-4953-afe3-766a6586bbb5",
            "id": "63992a22-411d-49b0-893c-c5742d43d970",
            "name": "office"
        }
    ]
}

在经过 API 组合之后,乘客 API 返回的乘客信息的数据如下所示。从中可以看到,userAddresses 属性的值进行了修改,包含了地址的详细信息。

{
    "email": "33",
    "id": "54d4dd20-09dd-4105-b9aa-48560c841ca1",
    "mobilePhoneNumber": "33",
    "name": "bob",
    "userAddresses": [
        {
            "addressId": "585d747e-8605-442f-93bb-043aac15ea7e",
            "addressLine": "王府井社区居委会-0",
            "areaId": 16,
            "areas": [],
            "id": "83e5597d-dd8f-4906-ba8d-abf24a7754c2",
            "lat": 39.914211,
            "lng": 116.414808,
            "name": "xyz"
        },
        {
            "addressId": "df7eccb3-06d6-4400-b0e3-975be546c691",
            "addressLine": "王府井社区居委会-1",
            "areaId": 16,
            "areas": [],
            "id": "a604a3d3-6d01-497f-99c4-6a3de311cd9f",
            "lat": 39.914293,
            "lng": 116.414966,
            "name": "def"
        }
    ]
}

总结

为了满足不同客户端的需求,微服务架构的应用中通常需要使用 API 组合来创建客户端独有的 API。通过本课时的学习,你可以了解到 API 网关和 API 组合的基本概念,以及 Backend For Frontend 模式在实际开发中的作用,最后还可以掌握如何使用 Spring Cloud Gateway 来组合 API。


第32讲:如何使用 Netflix Falcor 组合 API

上一课时介绍了 API 组合的基本概念,以及如何用 Spring Cloud Gateway 来实现 API 组合,不过 Spring Cloud Gateway 的做法,本质上与一般的 REST API 并没有区别,REST API 的特点是,对于特定的请求,所对应的响应结构是固定的。在设计 REST API 时,就已经严格定义了请求和响应的结构,也是调用者和提供者之间的交互协议。这一点在 OpenAPI 规范中可以清楚地看到。这种结构上的确定性,虽然方便了使用者,但也带来了一定的局限性。

在大部分情况下,REST API 所返回的数据结构,与使用者对数据的要求并不完全匹配。当 API 所提供的数据多于使用者的需要时,处理方式还比较简单,只需要忽略多余的数据即可,但是传输多余的数据也会导致更长时间的网络延迟和更多的内存消耗。这些消耗对桌面客户端还可以接受,但是对移动客户端就不能轻易忽略,影响的不仅仅是流量,还包括电池消耗。

如果一个 API 所提供的数据不能满足需求,就需要使用第 31 课时介绍的技术来组合多个 API。Backend For Frontend 模式可以解决一部分的问题,但仍然免不了需要根据客户端的需求,对 API 进行调整和维护。

造成这种问题的根源在于 API 的使用者无法随意地控制 API 返回的数据,当使用者的需求发生变化时,总是需要 API 的提供者首先做出修改,然后使用者再消费新版本的 API。API 的版本化,并没有从根本上解决这个问题,只是让 API 的变化更加容易管理。从使用者的角度来说,如果能够根据使用的需要,自主的选择所要查询的数据,那么当使用的需求发生改变时,并不需要 API 提供者做出改变,这无疑可以极大地提升开发效率。这种需求催生了新技术的出现。

本课时要介绍的 Netflix Falcor 和下一课时要介绍的 GraphQL,它们的特点都是允许使用者自主选择所要的数据,这就给了使用者最大限度的灵活性。API 的提供者不再需要为了满足特定使用者的需求而做出改动,只是负责提供数据。这种做法在带来灵活性的同时,也增加了使用者的复杂度,下面举例说明。

以示例应用为例,乘客 App 中包含一个视图来显示当前乘客的基本信息。在这个视图中,只显示了用户地址的名称,如“家庭”和“公司”之类的。

在使用了 Backend For Frontend 模式之后,乘客 App 所使用的 API 仅提供了这些数据。如果在新版本中,需要增加显示完整的地址,如“北京市海淀区XX路XX号”,那么首先需要修改 API 来提供新增的数据,App 再进行修改。

但如果使用的是 Falcor 或 GraphQL 的模式,乘客 App 只需要修改它获取数据的查询即可,后端并不需要修改。

Netflix Falcor——数据即 API

Netflix Falcor 的核心理念:数据即 API。这种理念描述起来也很简单,因为对于使用者来说,其根本在乎的是提供者所开放的数据,而 API 只是获取数据的一种方式。

一般的 REST API 虽然对使用者开放了提供者内部的数据,但从另外一个角度来说,也限制了对数据的使用方式,这种限制造成了使用者和提供者之间的紧密耦合。

但 Falcor 中所公开的是数据本身,以及通用的获取和更新数据的方式,具体的使用则完全由客户端来确定。

在 Falcor 的架构中,数据由一个抽象的 JSON 图来表示。这个 JSON 图中包含了提供者所能开放的全部数据,并以图的形式表示出来。这种图的表示形式,与数据库中的实体关系模型、面向对象中的对象关系图,以及领域驱动设计中的聚合的引用关系,在本质上都是相似的,都是把数据抽象成实体,以及实体之间的引用关系。这些实体及其关联关系,来自应用所在的领域,组成了应用的模型。

在示例应用中,我们抽象出了乘客、地址、司机、行程和行程派发等多个相互引用的实体,以及这些实体之间的关系。这些实体和关系组成了示例应用所能提供的数据。

Falcor 使用 JSON 来描述数据。由于 JSON 实际上是一种树形结构,无法直接表达图中的引用关系。Falcor 对 JSON 进行了扩展,增加了新的基本类型来描述图相关的信息。Falcor 实际上由对 JSON 图对象进行操作的一系列协议组成。

JSON 图

1. 路径

JSON 图(JSON Graph)中的每个实体都有唯一的路径(Path),这个路径是实体唯一的保存路径,也是其他实体进行引用时的路径,这个路径称为该实体的身份路径(Identity Path)。

  • 键(Key)

JSON 图中的路径是一系列的序列,从 JSON 对象的根开始。路径可以通过两种方式来表示,一种是键的数组,另外一种是字符串。数组的形式类似于 ["a", "b", "c"],而字符串的形式则类似于 a.b.c。

合法的键的类型包括字符串、布尔类型、数字和 null,可以使用数字来表示 JSON 数组的下标,如 ["passengers", 0, "name"] 表示 passengers 数组中第一个元素的 name 属性。在开发中,推荐使用数组的形式,因为字符串形式实际上也是先转换为数组形式来使用的。直接使用数组可以避免额外的解析操作,因此性能更好。

  • 路径集合(Path Set)

多个路径可以组成路径集合。路径集合除了可以简单地把多个路径组织在一起,还支持更加复杂的语法。除了键之外,还可以使用范围和键的数组。

在下面的代码中,第一个路径集合表示的是 addresses 数组中的第 1 和第 4 个元素,而第二个路径集合则表示的是 addresses 数组中的第 1 到第 4 个元素。

["addresses", [0, 3], "addressLine"] //键的数组
["addresses", {from: 0, to: 3}, "addressLine"] // 范围

["addresses", [0, 3], "addressLine"] //键的数组
["addresses", {from: 0, to: 3}, "addressLine"] // 范围

2. 基本类型

JSON 图增加了 3 种基本类型,即引用(Reference)、原子(Atom)和错误(Error),这些类型实际上都是 JSON 图中的对象,只不过包含了表示类型的 $type 属性和表示具体值的 value 属性。这 3 个基本类型的值,只能作为一个整体来替换,不能进行修改。

基本类型的 $type 和 value 属性的说明,如下表所示:

类型

$type 属性

value 属性

引用

ref

表示路径的数组

原子

atom

JSON 中的值

错误

error

错误消息

(1)引用

引用对象的作用是引用其他的实体,value 的值是被引用实体的身份路径。下面的代码是引用类型的示例。

{
  "$type":"ref",
  "value":[
    "passengersById",
    "xyz123"
  ]
}

(2)原子

原子类型的作用是为 JSON 中的值添加元数据。客户端模型在处理数据时需要使用这些元数据。在下面的代码中,JSON 中的 string 类型的值被转换成原子类型。

{
  "$type":"atom",
  "value":"home"
}

{
  "$type":"atom",
  "value":"home"
}

(3)错误

错误类型表示的是数据操作的错误。JSON 图中的数据可能来自远端的服务,因此数据操作可能出现与网络或后台相关的错误。当出现错误时,对应的值可以用错误对象来替代。如果需要对多个值进行操作,一个值的错误不会影响到其他正常完成的值。下面代码中是错误类型的示例。

{
  "$type":"error",
  "value":"Resource not found"
}

3. 操作

JSON 图支持 3 种不同的抽象操作,即读取(Get)、设置(Set)和调用(Call)。

读取操作从 JSON 图中获取基本类型的值。读取操作的输入是任意数量的路径,而输出则是 JSON 图的一个子集,包含这些路径所对应的值。读取操作会自动处理 JSON 图中的引用关系。

设置操作修改 JSON 图中的值。设置操作的输入是路径和值的对,而输出则是 JSON 图的一个子集,包含了被修改的路径和对应的值。

当需要对 JSON 图中的多个值进行复杂的修改时,应该使用调用操作。调用操作是作用于 JSON 图上的函数,也是 JSON 图的一部分,该函数在执行时可以接受 4 个参数,如下表所示。

参数

说明

callPath

需要调用的函数在 JSON 图对象中的路径

args

函数调用时的参数

refPaths

从函数调用的返回值中获取数据的路径

extraPaths

函数执行之后额外获取的数据的路径

调用函数的返回值是一个 JSON 对象,可以包含下表中给出的属性。

属性

说明

jsonGraph

包含执行结果 JSON 图的子集

invalidated

函数执行之后改变的路径,调用者需要作废这些路径的缓存值

paths

执行结果的 JSON 图的子集中包含的全部路径

JSON 图是一个抽象的结构,在实际的开发中,需要使用的是具体的数据源、模型和路由器。接下来我会对这三者展开讲解。下图是 Falcor 中不同组成部分的架构图。

DevOps 云原生 云原生api_Istio_03

数据源

数据源用来把 JSON 图暴露给模型,每个数据源都与一个 JSON 图关联。模型通过执行 JSON 图的抽象操作来访问数据源所提供的 JSON 图。

下表给出了数据源接口 DataSource 中的方法,这 3 个方法的返回值类型都是 Observable<JSONGraphEnvelope>。这 3 个操作与 JSON 图中的抽象操作相对应。

方法

参数

说明

get

pathSets: Array

读取

set

JSONGraphEnvelope

设置

call

callPath: Path args: Array refPaths: Array extraPaths: Array

调用

模型

在有了数据源之后,客户端理论上可以直接使用数据源提供的接口来访问 JSON 图。不过更好的做法是通过模型作为视图与数据源之间的中介。模型在数据源的基础上,提供了一些实用的功能,包括把 JSON 图中的数据转换成 JSON 对象,在内存中缓存数据以及进行批量处理。相对于数据源,模型所提供的接口更加易用。

下面代码给出了作为示例 JSON 图的内容,其中包含了乘客和地址两类实体。

{
  "passengersById": {
    "p1": {
      "name": "Passenger 1",
      "email": "passenger1@test.com",
      "userAddresses": [
        {
          "id": "ua1",
          "name": "Home",
          "address": {
            "$type": "ref",
            "value": ["addressesById", "a1"]
          }
        }
      ]
    },
    "p2": {
      "name": "Passenger 2",
      "email": "passenger2@test.com"
    }
  },
  "addressesById": {
    "a1": {
      "addressLine": "Address 1",
      "lat": 0,
      "lng": 0
    },
    "a2": {
      "addressLine": "Address 2",
      "lat": 1,
      "lng": 1
    }
  }
}

模型在创建时需要提供一个 DataSource 接口的对象,或者作为缓存的 JSON 对象。在下面的代码中,从 JSON 对象中创建了一个 Model 对象。

const falcor = require('falcor');
const jsonGraph = require('./sample_json_graph.json');
const model = new falcor.Model({
  cache: jsonGraph
});

const falcor = require('falcor');
const jsonGraph = require('./sample_json_graph.json');
const model = new falcor.Model({
  cache: jsonGraph
});

下面代码是基本的获取和设置操作的示例,第二个 getValue 方法的调用,展示了 JSON 图中引用对象的自动解析功能。

model.getValue(["passengersById", "p1", "name"]).then(debug); // "Passenger 1"
model.getValue(["passengersById", "p1", "userAddresses", 0, 'address', 'addressLine']).then(debug); // "Address 1"
model.setValue(jsong.pathValue(["passengersById", "p1", "name"], "new name")).then(debug); // "new name"

model.getValue(["passengersById", "p1", "name"]).then(debug); // "Passenger 1"
model.getValue(["passengersById", "p1", "userAddresses", 0, 'address', 'addressLine']).then(debug); // "Address 1"
model.setValue(jsong.pathValue(["passengersById", "p1", "name"], "new name")).then(debug); // "new name"

路由器

路由器是 DataSource 接口的实现,一般运行在服务器端用来给模型提供数据。在微服务架构的应用中,路由器扮演了 API 组合的角色。路由器由一系列的路由组成,每个路由匹配 JSON 图中的路径集合,对于每个路由,需要定义它所支持的操作,以及每个操作具体的实现。

Falcor 提供了基于 Node.js 的路由器实现库,本课时通过 Falcor 来实现乘客管理 API 的组合,完整的代码请参考 GitHub 上源代码中的 happyride-passenger-web-api-falcor 模块。

下面的代码给出了路由器中两个重要路由的实现,每个路由的 route 属性表示匹配的路径。与 REST API 中的路由不同的是,Falcor 中的路由匹配的是 JSON 图的路径,而不是 URI 路径。除了 route 属性之外,还可以添加 get、set 或 call 属性来声明该路由支持的操作。

第一个路由的路径用来获取乘客的基本信息,比如路径 passengersById['p01'].name 用来获取标识符为 p01 乘客的 name 属性的值。在实现这个路由时,使用 getPassenger 方法调用乘客管理服务的 API,再把得到的返回值中的属性值提取出来,保存在 JSON 图中。函数 toEntityJsonGraph 封装了相关的逻辑。

第二个路由实现了 call 操作来为乘客添加新的地址,调用时需要提供 3 个参数,即乘客标识符、地址名称和地址标识符。实际的添加操作通过 addUserAddress 方法调用地址管理服务的 API 来完成。需要注意的是返回值中的 invalidated 属性声明了缓存中需要作废的路径。

app.use(
  "/model.json",
  falcorExpress.dataSourceRoute(function (req, res) {
    return new Router([
      {
        route:
          "passengersById[{keys:ids}]['name', 'email', 'mobilePhoneNumber', 'userAddresses']",
        get: function (pathSet) {
          return toEntityJsonGraph(
            "passengersById",
            pathSet.ids,
            pathSet[2],
            getPassenger
          );
        },
      },
      {
        route: "passengersById.addUserAddress",
        call: function (callPath, args) {
          return addUserAddress(args[0], args[1], args[2]).then(
            (response) => {
              return {
                jsonGraph: {},
                paths: [],
                invalidated: [["passengersById", response.id, "userAddresses"]],
              };
            }
          );
        },
      },
    ]);
  })
);

app.use(
  "/model.json",
  falcorExpress.dataSourceRoute(function (req, res) {
    return new Router([
      {
        route:
          "passengersById[{keys:ids}]['name', 'email', 'mobilePhoneNumber', 'userAddresses']",
        get: function (pathSet) {
          return toEntityJsonGraph(
            "passengersById",
            pathSet.ids,
            pathSet[2],
            getPassenger
          );
        },
      },
      {
        route: "passengersById.addUserAddress",
        call: function (callPath, args) {
          return addUserAddress(args[0], args[1], args[2]).then(
            (response) => {
              return {
                jsonGraph: {},
                paths: [],
                invalidated: [["passengersById", response.id, "userAddresses"]],
              };
            }
          );
        },
      },
    ]);
  })
);

下面的代码展示了在模型中如何调用路由器中的函数来添加用户地址。第一个参数是函数的路径,第二个参数是调用时的参数,第三个参数为空,第四个参数是返回值中需要额外获取的路径。当函数调用成功之后,返回值中会包含乘客的全部地址,包括新添加的地址。

model
  .call(
    ["passengersById", "addUserAddress"],
    [passengerId, addressName, addressId],
    [],
    [[passengerId, "userAddresses"]]
  )
  .then(successCallback)
  .catch(errorCallback);

model
  .call(
    ["passengersById", "addUserAddress"],
    [passengerId, addressName, addressId],
    [],
    [[passengerId, "userAddresses"]]
  )
  .then(successCallback)
  .catch(errorCallback);

总结

Netflix Falcor 把后端的数据以 JSON 图的形式来开放,允许客户端以更加灵活的方式来对数据进行查询和修改。通过本课时的学习,你可以了解如何更好地让客户端来使用开放数据,以及 Falcor 中的基本概念,并使用 Falcor 来设计和实现复杂的 API。


第33讲:如何使用 GraphQL 组合 API

在第 32 课时中介绍了 REST API 在使用上的局限性。由于请求和响应格式的固定,当 API 的使用者的需求发生改变时,需要 API 提供者先做出修改,API 使用者才能进行所需的改动。这种耦合关系降低了整体的开发效率,对于开放 API 来说,这种情况会更加严重。当 API 的使用者很多时,不同使用者的需求可能会产生冲突。从 API 实现者的角度来说,只能在这些冲突的需求中进行取舍,客观上也造成了部分 API 使用者的困难。Backend For Frontend 模式和 API 版本化可以解决一部分问题,但也使得 API 的维护变得更加复杂。

对于 REST API 的问题,我们需要新的解决方案,GraphQL 和 Netflix Falcor 都是可以替代的方案,这两种方案对客户端都提供了更高的要求。REST API 的优势在于对客户端的要求很低,使得它有很强的兼容性,这也是 REST API 流行的一个重要原因。随着 JavaScript 的广泛使用,客户端可以承担更多的职责,这使得 GraphQL 这样的技术有了流行起来的基础。本课时将对 GraphQL 进行基本的介绍,并用 GraphQL 实现乘客管理界面所需的 API。

GraphQL

GraphQL 这个名称的含义是图查询语言(Graph Query Language),其中的图与 Netflix Falcor 中的 JSON 图,有着异曲同工之妙。图这种数据结构,表达能力强,适用于各种不同的场景。

GraphQL 是为 API 设计的查询语言,提供了完整的语言来描述 API 所提供的数据的模式(Schema)。模式在 GraphQL 中扮演了重要的作用,类似于 REST API 中的 OpenAPI 规范。有了模式之后,客户端可以方便地查看 API 所提供的查询,以及数据的格式;服务器可以对查询请求进行验证,并根据模式来对查询的执行进行优化。

根据 GraphQL 的模式,客户端发送查询到服务器,服务器验证并执行查询,同时返回相应的结果。查询的结果完全由请求来确定,这就意味着客户端对获取的数据有完全的控制。

GraphQL 使用图来描述实体与实体之间的关系,还可以自动处理实体之间的引用关系。在一个查询中可以包含相互引用的多个实体。

GraphQL 使得 API 的更新变得容易。在 API 的 GraphQL 模式中可以增加新的类型和字段,也可以把已有的字段声明为废弃的。已经废弃的字段不会出现在模式的文档中,可以鼓励使用者使用最新的版本。

GraphQL 非常适用于微服务架构应用的 API 接口,可以充分利用已有微服务的 API。GraphQL 最早由 Facebook 开发,目前有开源的规范和不同平台上的前端和后端的实现,而且已经被 Facebook、GitHub、Pinterest、Airbnb、PayPal、Twitter 等公司采用。

查询和修改

GraphQL 中定义了类型和类型中的字段。在示例应用中,我们可以定义乘客和地址等类型,以及类型中的字段,最简单的查询是选择对象中的字段。如果对象中有嵌套的其他对象,可以同时选择嵌套对象中的字段。

下面是一个 GraphQL 的查询代码示例,其中,passengers 表示查询乘客对象的列表,内嵌的字段 id、name、email 和 mobilePhoneNumber 用来查询乘客对象中的属性;userAddresses 是乘客对象中内嵌的用户地址列表,嵌套的 name 字段用来查询用户地址的名称。

{
  passengers {
    id
    name
    email
    mobilePhoneNumber
    userAddresses {
      name
    }
  }
}

{
  passengers {
    id
    name
    email
    mobilePhoneNumber
    userAddresses {
      name
    }
  }
}

该查询的执行结果如下面的代码所示,从中可以看出来,查询结果的格式与查询是完全匹配的。如果从查询中删除掉 passengers 中的 email 和 mobilePhoneNumber,那么对应的查询结果也不会包含这两个字段。

{
  "data": {
    "passengers": [
      {
        "id": "ae31bb42-540e-4cdc-a088-1bc6e2f9f78d",
        "name": "bob",
        "email": "bob@test.com",
        "mobilePhoneNumber": "13400003413",
        "userAddresses": [
          {
            "name": "test"
          },
          {
            "name": "new"
          }
        ]
      },
      {
        "id": "4d609afe-a193-4c4f-a062-146dd3c6c86b",
        "name": "alex",
        "email": "alex@test.com",
        "mobilePhoneNumber": "13455353535",
        "userAddresses": [
          {
            "name": "home"
          },
          {
            "name": "office"
          }
        ]
      }
    ]
  }
}

在上述的 GraphQL 查询中,我们实际上省略了 query 关键词和查询的名称,query 关键词表示操作的类型。GraphQL 中支持 3 种不同类型的操作,分别是查询(Query)、修改(Mutation)和订阅(Subscription),对应的关键词分别是 query、mutation 和 subscription。操作的名称由客户端提供,作为操作的描述,可以增强查询的可读性。

在操作上可以声明变量,并在执行时提供实际的值,这与编程语言中的函数或方法中的参数是相似的。在下面代码的 GraphQL 查询中,操作的名称是 passengerById,并且有一个类型为 ID 的变量 passengerId,该变量作为字段 passenger 的参数 id 的值。

query passengerById($passengerId: ID!) {
  passenger(id: $passengerId) {
    name
    email
    mobilePhoneNumber
  }
}

query passengerById($passengerId: ID!) {
  passenger(id: $passengerId) {
    name
    email
    mobilePhoneNumber
  }
}

在执行查询时,需要提供变量的实际值,变量一般以 JSON 的格式传递,如下面的代码所示:

{
  "passengerId": "ae31bb42-540e-4cdc-a088-1bc6e2f9f78d"
}

查询的结果如下面的代码所示:

{
  "data": {
    "passenger": {
      "name": "bob",
      "email": "bob@test.com",
      "mobilePhoneNumber": "13400003413"
    }
  }
}

在查询时,有些字段的组合可能会重复出现多次。为了复用这些字段的组合,可以使用 GraphQL 中的片段(Fragment)。在下面的代码中,fragment 用来声明片段,on 表示片段对应的对象类型,片段可以直接用在查询中。

query passengerById($passengerId: ID!) {
  passenger(id: $passengerId) {
    ...passengerFields
  }
}
fragment passengerFields on Passenger {
  name
  email
  mobilePhoneNumber
}

query passengerById($passengerId: ID!) {
  passenger(id: $passengerId) {
    ...passengerFields
  }
}
fragment passengerFields on Passenger {
  name
  email
  mobilePhoneNumber
}

模式和类型

GraphQL 使用语言中性的模式语言来描述数据的结构,每个 GraphQL 服务都通过这个模式语言来定义所开放的数据的类型系统。GraphQL 规范中已经定义了一些内置的类型,每个服务提供者也需要创建自己的类型。

GraphQL 中的类型分成下表中给出的几类,不同的类型使用不同的关键词来创建。

分类

关键词

说明

对象类型

type

服务所提供的对象,对象类型定义了对象中包含的字段及其类型

标量类型

scalar

表示具体的值,不包含字段

枚举类型

enum

限定了类型的可选值

接口类型

interface

定义了对象类型中必须包含的字段

联合类型

union

多个具体类型的联合

输入类型

input

作为参数传递的复杂对象

GraphQL 中最基本的类型是对象类型以及其中包含的字段。对象类型通常表示 API 中的不同实体,其中的字段则与实体中的属性相对应。每个字段需要声明名称和类型,字段的类型可以是标量类型、枚举类型或是其他自定义的类型。

GraphQL 中提供了内置的标量类型 Int、Float、String、Boolean 和 ID,也允许不同的实现提供自定义的标量类型。ID 表示唯一的标识符,在使用上类似 String。

GraphQL 中的枚举类型与 Java 中的枚举类型是相似的。下面的代码给出了枚举类型的示例。

enum TrafficColor {
    RED
    GREEN
    YELLOW
}

enum TrafficColor {
    RED
    GREEN
    YELLOW
}

除了对象、标量和枚举类型之外,还可以通过感叹号来声明非空(Non-Null)类型,如 String! 表示值不能为 null 的 String 类型。非空类型可以用在字段中声明该字段的值不可能为 null,也可以用在参数声明中,用来声明该参数的实际值不能为 null。

当以方括号来封装某个类型时,就得到了该类型的列表形式,如 [String] 表示 String 列表,而 [String!] 表示元素为非空 String 的列表。

GraphQL 中的接口与 Java 中的接口作用类似,用来声明不同对象类型所共有的字段。联合类型则把多个具体的对象类型组合在一起,其值可以是任何一个对象类型。对于一个联合类型的对象,可以使用 __typename 字段来查看其实际的类型,在查询时,可以使用内联片段来根据不同的类型,选择相应的字段。

GraphQL 中的参数可以使用复杂对象,这些对象的类型通过输入类型来声明,如下面的代码所示:

input CreateUserAddressRequest {
    name: String!,
    addressId: ID!
}

查询执行

当 GraphQL 的查询发送到服务器时,由服务器负责查询的执行,查询的执行结果的结构与查询本身的结构相匹配。查询在执行时需要依靠类型系统的支持。GraphQL 查询中的每个字段都可以看成是它类型上的一个函数或方法,该函数或方法会返回一个新的类型。

每个类型的每个字段,在服务器上都有一个函数与之对应,称为解析器(Resolver)。当需要查询某个字段时,这个字段对应的解析器会被调用,从而返回下一个需要处理的值,这个过程会递归下去,直到解析器返回的是标量类型的值。GraphQL 的查询过程,总是以标量值作为结束点。

如果字段本来就是对象中的属性,那么获取这些字段的解析器的实现非常简单,并不需要开发人员显式提供。大部分的 GraphQL 服务器的实现库,都提供了对这种解析器的支持。如果一个字段没有对应的解析器,则默认为读取对象中同样名称的属性值。

实现 GraphQL 服务

下面介绍如何使用 GraphQL 来实现乘客管理界面的 API。后台实现使用的是 Java 语言,基于 GraphQL 的 Java 实现库 graphql-java,以及相应的 Spring Boot 集成库 graphql-spring-boot。在实际的数据获取时,使用的是不同微服务 API 的 Java 客户端。完整的实现请参考 GitHub 上源代码中示例应用的 happyride-passenger-web-api-graphql 模块。下图是 GraphQL 服务的架构示意图。

DevOps 云原生 云原生api_Netflix Falcor_04

模式

在实现之前,需要先定义服务的 GraphQL 模式,下面的代码是 API 的 GraphQL 模式文件的内容。在模式中,首先定义了乘客、用户地址、地址和区域等 4 个对象类型,对应于 API 所提供的数据中的实体;接下来的 Query 类型中定义了 API 所提供的查询操作,包括查询乘客列表、查询单个乘客和地址,以及搜索地址;最后的 Mutation 类型中定义了 API 所提供的修改操作,即添加新的用户地址。Query 和 Mutation 类型中定义的字段,是整个 GraphQL 服务的入口。

type Passenger {
    id: ID!
    name: String!
    email: String!
    mobilePhoneNumber: String
    userAddresses: [UserAddress!]
}
type UserAddress {
    id: ID!
    name: String!
    address: Address!
}
type Address {
    id: ID!
    areaId: Int!
    addressLine: String!
    lat: Float!
    lng: Float!
    areas: [Area!]
}
type Area {
    id: Int!
    level: Int!
    parentCode: Int!
    areaCode: Int!
    zipCode: String!
    cityCode: String!
    name: String!
    shortName: String!
    mergerName: String!
    pinyin: String!
    lat: Float!
    lng: Float!
    ancestors: [Area!]
}
type Query {
    passengers(page: Int = 0, size: Int = 10): [Passenger]
    passenger(id: ID!): Passenger
    address(id: ID!, areaLevel: Int = 0): Address
    searchAddress(areaCode: String!, query: String!): [Address]
}
input CreateUserAddressRequest {
    name: String!,
    addressId: ID!
}
type Mutation {
    addUserAddress(passengerId: ID!, request: CreateUserAddressRequest!): Passenger
}

查询

为了实现查询操作,需要提供 Query 类型的解析器。下面代码中的 Query 类实现了 GraphQLQueryResolver 接口,其中的每个方法都对应查询模式中的一个字段。以获取乘客列表的 passengers 方法为例,它的参数 page 和 size 对应于字段的同名参数,而返回值类型 List 则与字段的类型 [Passenger] 相对应。在 passengers 方法的实现中,使用了乘客管理服务提供的 Java 客户端 PassengerApi 对象来调用 API 并获取结果。Query 类中的其他方法的实现,也采用类似的方式,与查询模式中的其他字段相对应。

@Component
public class Query implements GraphQLQueryResolver {
  @Autowired
  PassengerApi passengerApi;
  @Autowired
  AddressApi addressApi;
  public List<Passenger> passengers(final int page, final int size)
      throws ApiException {
    return this.passengerApi.listPassengers(page, size).stream()
        .map(ServiceApiHelper::fromPassengerVO)
        .collect(Collectors.toList());
  }
  public Passenger passenger(final String id) throws ApiException {
    return Optional.ofNullable(this.passengerApi.getPassenger(id))
        .map(ServiceApiHelper::fromPassengerVO)
        .orElse(null);
  }
  public AddressVO address(final String id, final int areaLevel)
      throws io.vividcode.happyride.addressservice.client.ApiException {
    return this.addressApi.getAddress(id, areaLevel);
  }
  public List<AddressVO> searchAddress(final String areaCode,
      final String query)
      throws io.vividcode.happyride.addressservice.client.ApiException {
    return this.addressApi.searchAddress(Long.valueOf(areaCode), query);
  }
}

@Component
public class Query implements GraphQLQueryResolver {
  @Autowired
  PassengerApi passengerApi;
  @Autowired
  AddressApi addressApi;
  public List<Passenger> passengers(final int page, final int size)
      throws ApiException {
    return this.passengerApi.listPassengers(page, size).stream()
        .map(ServiceApiHelper::fromPassengerVO)
        .collect(Collectors.toList());
  }
  public Passenger passenger(final String id) throws ApiException {
    return Optional.ofNullable(this.passengerApi.getPassenger(id))
        .map(ServiceApiHelper::fromPassengerVO)
        .orElse(null);
  }
  public AddressVO address(final String id, final int areaLevel)
      throws io.vividcode.happyride.addressservice.client.ApiException {
    return this.addressApi.getAddress(id, areaLevel);
  }
  public List<AddressVO> searchAddress(final String areaCode,
      final String query)
      throws io.vividcode.happyride.addressservice.client.ApiException {
    return this.addressApi.searchAddress(Long.valueOf(areaCode), query);
  }
}

修改

对于模式中的修改操作,需要提供 Mutation 类型的解析器。下面代码中的 Mutation 类实现了 GraphQLMutationResolver 接口,其中的 addUserAddress 方法对应于修改模式中的同名字段。

@Component
public class Mutation implements GraphQLMutationResolver {
  @Autowired
  PassengerApi passengerApi;
  public Passenger addUserAddress(final String passengerId,
      final CreateUserAddressRequest request)
      throws ApiException {
    return ServiceApiHelper
        .fromPassengerVO(this.passengerApi.createAddress(passengerId, request));
  }
}

@Component
public class Mutation implements GraphQLMutationResolver {
  @Autowired
  PassengerApi passengerApi;
  public Passenger addUserAddress(final String passengerId,
      final CreateUserAddressRequest request)
      throws ApiException {
    return ServiceApiHelper
        .fromPassengerVO(this.passengerApi.createAddress(passengerId, request));
  }
}

自定义解析器

在查询模式的解析器中,字段对应的方法直接返回了 Passenger 和 AddressVO 对象。对于 GraphQL模式中的 Passenger 和 Address 类型中的字段,如果对应的 Java 对象中有同名的属性,那么 GraphQL 服务器可以自动进行解析。比如,Passenger 类型中的 name 和 email 字段,会直接解析成对应的 Java 中的 Passenger 对象中的 name 和 email 属性。

在 GraphQL 模式中,UserAddress 类型中的 address 字段的类型是 Address,而乘客管理服务 API 只提供了地址的标识符,需要调用地址管理服务的 API 才能获取到实际的地址信息。在这种情况下,需要使用自定义的解析器来获取 address 字段的值。

下面的代码是 Address 对象类型对应的 Java 类,其中的 getAddress 方法用来解析 address 字段,该方法的 DataFetchingEnvironment 类型的参数表示的是获取数据时的上下文环境。当 GraphQL 服务器执行该字段的查询时,会提供 DataFetchingEnvironment 接口的实现对象。

DataFetchingEnvironment 接口的 getContext 方法可以获取到与本次查询相关的上下文对象。从该上下文对象中获取到包含了 DataLoader 对象的 DataLoaderRegistry 对象,再查找到对应的 DataLoader 对象来进行实际的地址获取操作。DataLoader 是 GraphQL 服务器实现中获取数据的通用接口。需要注意的是,getAddress 方法返回的是 CompletableFuture<AddressVO>对象,表示这是一个异步获取操作。

@Data
public class UserAddress {
  private String id;
  private String name;
  private String addressId;
  public CompletableFuture<AddressVO> getAddress(
      final DataFetchingEnvironment environment) {
    final GraphQLContext context = environment.getContext();
    return context.getDataLoaderRegistry()
        .map(
            registry -> registry.
                <String, AddressVO>getDataLoader(USER_ADDRESS_DATA_LOADER)
                .load(this.addressId))
        .orElse(CompletableFuture.completedFuture(null));
  }

@Data
public class UserAddress {
  private String id;
  private String name;
  private String addressId;
  public CompletableFuture<AddressVO> getAddress(
      final DataFetchingEnvironment environment) {
    final GraphQLContext context = environment.getContext();
    return context.getDataLoaderRegistry()
        .map(
            registry -> registry.
                <String, AddressVO>getDataLoader(USER_ADDRESS_DATA_LOADER)
                .load(this.addressId))
        .orElse(CompletableFuture.completedFuture(null));
  }

下面代码中的 UserAddressLoader 类是获取地址操作的 BatchLoader 接口的实现。数据加载是异步完成的,同时也是批量进行的。这里通过 AddressApi 的异步调用方法 getAddressesAsync 来调用 API。

@Component
public class UserAddressLoader implements BatchLoader<String, AddressVO> {
  @Autowired
  AddressApi addressApi;
  @SneakyThrows(ApiException.class)
  @Override
  public CompletionStage<List<AddressVO>> load(final List<String> keys) {
    final CompletableFuture<List<AddressVO>> future = new CompletableFuture<>();
    this.addressApi.getAddressesAsync(
        new AddressBatchRequest(keys),
        new ApiCallback<List<AddressVO>>() {
          @Override
          public void onFailure(final ApiException e, final int statusCode,
              final Map<String, List<String>> responseHeaders) {
            future.completeExceptionally(e);
          }
          @Override
          public void onSuccess(final List<AddressVO> result,
              final int statusCode,
              final Map<String, List<String>> responseHeaders) {
            future.complete(result);
          }
          @Override
          public void onUploadProgress(final long bytesWritten,
              final long contentLength, final boolean done) {
          }
          @Override
          public void onDownloadProgress(final long bytesRead,
              final long contentLength, final boolean done) {
          }
        });
    return future;
  }
}

@Component
public class UserAddressLoader implements BatchLoader<String, AddressVO> {
  @Autowired
  AddressApi addressApi;
  @SneakyThrows(ApiException.class)
  @Override
  public CompletionStage<List<AddressVO>> load(final List<String> keys) {
    final CompletableFuture<List<AddressVO>> future = new CompletableFuture<>();
    this.addressApi.getAddressesAsync(
        new AddressBatchRequest(keys),
        new ApiCallback<List<AddressVO>>() {
          @Override
          public void onFailure(final ApiException e, final int statusCode,
              final Map<String, List<String>> responseHeaders) {
            future.completeExceptionally(e);
          }
          @Override
          public void onSuccess(final List<AddressVO> result,
              final int statusCode,
              final Map<String, List<String>> responseHeaders) {
            future.complete(result);
          }
          @Override
          public void onUploadProgress(final long bytesWritten,
              final long contentLength, final boolean done) {
          }
          @Override
          public void onDownloadProgress(final long bytesRead,
              final long contentLength, final boolean done) {
          }
        });
    return future;
  }
}

Spring 配置

为了启动 GraphQL 服务,需要通过 Spring 的配置来创建 GraphQLSchema 类型的 bean,如下面的代码所示。首先使用 SchemaParser 来解析 GraphQL 模式文件,然后设置查询和修改的两个解析器,最后创建出 GraphQLSchema 对象。其他的相关工作,由 Spring Boot 的自动配置功能来完成。

@Configuration
public class SchemaConfig {
  @Autowired
  Query query;
  @Autowired
  Mutation mutation;
  @Bean
  public GraphQLSchema graphQLSchema() {
    return SchemaParser.newParser()
        .file("passenger-api.graphqls")
        .resolvers(this.query, this.mutation)
        .build()
        .makeExecutableSchema();
  }
}

@Configuration
public class SchemaConfig {
  @Autowired
  Query query;
  @Autowired
  Mutation mutation;
  @Bean
  public GraphQLSchema graphQLSchema() {
    return SchemaParser.newParser()
        .file("passenger-api.graphqls")
        .resolvers(this.query, this.mutation)
        .build()
        .makeExecutableSchema();
  }
}

在启动 Spring Boot 应用之后,通过路径 /graphql 可以访问 GraphQL 服务。除此之外,如果添加了对 GraphQL 的工具 GraphiQL 的依赖,还可以通过路径 /graphiql 来访问该工具。很多工具都提供了对 GraphQL 的支持,可以在开发中使用,包括 Postman 和 Insomnia。

下图是使用 Insomnia 查询 GraphQL 时的截图。

DevOps 云原生 云原生api_DevOps 云原生_05

总结

作为一个新的开放 API 的方式,GraphQL 释放了客户端的查询能力,已经得到了广泛的流行。通过本课时的学习,你可以了解 GraphQL 中的基本概念,包括查询和修改,以及如何编写 GraphQL 模式,还可以掌握如何使用 Java 来实现基于 Spring Boot 的 GraphQL 服务器。


第34讲:如何安装与配置 Itio

从本课时开始,我们将进入与服务网格相关的模块,并将示例应用部署到 Kubernetes 集群中,该模块将结合示例应用来具体介绍服务网格实现 Istio 的使用。在第 05 课时中已经对服务网格的基本概念进行了介绍,包括服务网格的意义、边车(Sidecar)模式和服务代理等。本课时将先回顾一下服务网格。

服务网格的意义

对于习惯了开发单体应用的开发人员来说,在迁移到微服务架构的应用开发时,最大的挑战是微服务架构所带来的在开发、部署和运维上的复杂性。从单体应用迁移到多个微服务应用之后,开发团队要考虑的不仅仅是微服务之间的交互,还有统一的日志管理、服务之间的调用追踪、错误处理等。

不过微服务架构也带来了很多的优势。每个微服务都是独立运行的应用,可以自由选择实现的编程语言或平台,也可以有自己的独立存储。不管是 Java、Node.js 或是 Go,都可以在微服务开发中找到用武之地。这种灵活性对开发人员来说有很强的吸引力,这意味着可以使用最新的技术栈和拥有对产品的自主控制,对提升团队的技术热情和主观能动性非常有好处。

从开发人员的角度来说,他们一方面希望享受微服务架构带来的好处,另一方面又不希望过多地了解微服务架构本身带来的复杂性的实现细节。在早期微服务架构的实现中,底层的实现细节对代码实现并不是完全透明的。

比如,在 Netflix 的微服务架构技术栈中,对于其他微服务 API 的调用,需要封装在 Hystrix 的命令对象中,通过这样的方式才能实现服务调用的错误处理、结果缓存和断路器等功能。同样的,当进行服务发现时,也需要与 Eureka 进行交互。

Kubernetes 的出现,为微服务架构的部署和运行提供了良好的基础,容器化技术使得应用和支撑服务的部署变得简单。同时,Kubernetes 也提供了对服务发现、故障恢复和水平扩展等功能的支持。服务网格的出现则往前更进一步,以透明的方式处理服务之间的 API 调用,调用其他服务的 API 只需要使用标准的客户端发送 REST 或 gRPC 请求即可,并不需要额外的封装。与服务调用相关的功能由服务代理来完成。这意味着开发人员可以关注更少的底层细节,而把更多的精力花在业务逻辑的实现中,从而提高开发效率。

接下来我们就进入本课时的正题,为你详细讲解 Istio 的相关知识。

Istio 介绍

目前已经有一些流行的服务网格实现,Istio 只是其中之一,其他的实现包括 Linkerd 和 Maesh 等。Istio 的优势在于背后有 Google 和 IBM 的支持,且功能强大,使用和配置也相对复杂。随着 1.5 版本把多个微服务整合成单一的应用之后,Istio 的复杂度降低了很多,不同规模的应用都可以使用 Istio。

1.核心功能

Istio 提供了 4 个与服务相关的核心功能,分别是连接(Connect)、安全(Secure)、控制(Control)和观察(Observe)。

  • 连接功能指的是控制服务之间的流量和 API 调用。只需要简单的配置,就可以实现服务之间的超时处理、自动重试和断路器模式,还可以通过基于百分比的流量分离来实现 A/B 测试、红黑部署和金丝雀发布等。Istio 还提供了错误恢复功能,可以增强服务的健壮性。
  • 安全功能指的是自动保护服务的安全,支持服务之间的身份认证、授权管理和通信加密。Istio 提供了一个证书权威机构(Certificate Authority,CA)来管理密钥和证书。在身份认证方面,Istio 支持双向 TLS 认证,以及基于 JWT 的请求身份认证,并可以与 OpenID Connect 提供者进行集成。在授权管理方面,Istio 支持使用 Kubernetes 的自定义资源来配置授权策略。除此之外,Istio 的审计功能可以记录安全相关的历史操作。
  • 控制指的是通过策略来对服务进行配置。Istio 提供了一系列 Kubernetes 中的自定义资源定义来配置 Istio 的不同组件的行为。
  • 观察指的是服务运行时行为的可见性。Istio 收集的性能指标数据可以监控应用的性能。分布式追踪数据可以查看每个请求在服务之间的调用关系,从而更好地了解服务之间的依赖关系。Istio 可以在日志中记录每个请求的源和目标的元数据。

2.组件

Istio 由很多不同的组件组成,如下表所示。

名称

说明

base

基础组件

pilot

服务发现和流量控制

proxy

服务代理

sidecarInjector

服务代理的边车容器的自动注入

telemetry

遥测数据收集

policy

策略管理

citadel

密钥和证书管理

nodeagent

安全相关的节点代理

galley

配置管理

ingressGateway

入口网关

egressGateway

出口网关

cni

容器网络接口(Container Network Interface,CNI)插件

除了这些基本的组件之外,Istio 还提供了一些附加组件,如下所示。

组件

说明

kiali

仪表盘和用户界面

grafana

Grafana 集成

tracing

调用追踪

prometheus

性能指标数据收集

在 1.5 版本之前,Istio 控制平面的组件以独立的微服务方式来打包和部署,上面表格中的 pilot、galley、citadel 组件都是独立运行的。这种部署模式造成了 Istio 运维的一些问题,使得多个组件难以维护和管理。从 1.5 版本开始,Istio 改变了之前的打包和部署方式,控制平面的组件被统一成单一的应用,也就是 istiod。只需要一个 Pod 就可以运行 Istio 的全部功能。

学习过 Istio 的基本概念后,我们就可以开始了解它的组件,并且操作它了。

Istio 安装

在本地开发时,可以使用 Minikube 或 Docker Desktop,也可以使用云平台上的 Kubernetes 集群。前提要求是可以使用 kubectl 与 Kubernetes 集群交互。

在 1.4 版本之前,Istio 使用 Helm 来安装。1.4 版本引入了命令行工具 istioctl 来进行安装和配置。在 Linux 和 MacOS 上,可以使用下面的命令安装 istioctl。

curl -L https://istio.io/downloadIstio | sh -

curl -L https://istio.io/downloadIstio | sh -

当上述命令执行完成之后,会在当前目录创建 istio-1.6.4 文件夹,其中包含了 istioctl 工具、概要配置文件和示例应用。在 Windows 上可以直接从 GitHub 上下载 Istio 的发布版本

1.配置概要文件

Istio 包含了很多组件,每个组件都有自己的配置项。为了简化安装,Istio 提供了一些预置的安装配置概要文件,作为安装配置的基础。下面的命令可以列出全部概要文件的名称。

istioctl profile list

istioctl profile list

不同的概要文件的区别在于默认启用的组件并不相同,如下表所示。

概要文件名称

启用的组件

default

istiod、ingressGateway、prometheus

demo

istiod、ingressGateway、egressGateway、prometheus、grafana、tracing、kaili

minimal

istiod

remote

ingressGateway

empty


preview

处于试验阶段的预览组件

下面的命令使用默认的概要文件来安装 Istio,默认安装在名称空间 istio-system 中。

istioctl install

istioctl install

而下面的命令则使用概要文件 minimal 来安装。

istioctl install --set profile=minimal

istioctl install --set profile=minimal

可以使用下面的命令来查看每个概要文件的实际内容。

istioctl profile dump minimal

istioctl profile dump minimal

2.修改配置

在安装 Istio 之后,可以使用 istioctl 来修改配置,只需要使用 “--set” 参数来提供配置文件中不同属性的值即可。比如,下面代码中的命令通过设置 enabled 属性的值,启用了 Istio 的附加组件 Kiali 和 Grafana。

istioctl install --set addonComponents.kiali.enabled=true --set addonComponents.grafana.enabled=true

istioctl install --set addonComponents.kiali.enabled=true --set addonComponents.grafana.enabled=true

如果要修改的配置项很多,通过 “--set” 的方式来传递会变得难以管理,使用 Istio 的自定义资源是更好的做法。下面是进行同样配置的 YAML 文件的内容。

apiVersion: install.istio.io/v1alpha1
kind: IstioOperator
spec:
  addonComponents:
    grafana:
      enabled: true
    kiali:
      enabled: true

通过下面的命令来应用 YAML 文件中的配置。

istioctl install -f custom-config.yml

istioctl install -f custom-config.yml

3.自定义资源定义

Istio 使用 Kubernetes 上的操作员(Operator)模式来实现。在安装之后会生成一系列的 Kubernetes 自定义资源定义。对 Istio 的配置,实际上是创建和更新 Istio 的自定义资源。

通过 kubectl get crd 命令可以查看全部的自定义资源定义,如下面的代码所示。所有以 istio.io 结尾的自定义资源定义都来自 Istio。

adapters.config.istio.io                   
attributemanifests.config.istio.io         
authorizationpolicies.security.istio.io    
clusterrbacconfigs.rbac.istio.io           
destinationrules.networking.istio.io       
envoyfilters.networking.istio.io           
gateways.networking.istio.io               
handlers.config.istio.io                   
httpapispecbindings.config.istio.io        
httpapispecs.config.istio.io               
instances.config.istio.io                  
istiooperators.install.istio.io            
peerauthentications.security.istio.io      
quotaspecbindings.config.istio.io          
quotaspecs.config.istio.io                 
rbacconfigs.rbac.istio.io                  
requestauthentications.security.istio.io   
rules.config.istio.io                      
serviceentries.networking.istio.io         
servicerolebindings.rbac.istio.io          
serviceroles.rbac.istio.io                 
sidecars.networking.istio.io               
templates.config.istio.io                  
virtualservices.networking.istio.io        
workloadentries.networking.istio.io

与 Istio 的安装和配置相关的是名为 istiooperators.install.istio.io 的自定义资源定义。从本质上来说,配置概要文件是 Istio 提供的自定义资源类型 IstioOperator 的资源定义。在安装之后,可以在 Kubernetes 中看到名为 installed-state 的 IstioOperator 类型的资源,使用下面的命令来查看。

kubectl get istiooperator -n istio-system

kubectl get istiooperator -n istio-system

4.Kubernetes 资源清单

在实际的安装中,IstioOperator 的资源定义会被转换成 Kubernetes 中的不同资源。比如,当启用了 Grafana 组件之后,Istio 会创建 Grafana 对应的 Kubernetes 部署。通过下面的命令可以查看由 istioctl 产生的 Kubernetes 的资源清单。

istioctl manifest generate -f custom-config.yml // 从配置文件中生成
istioctl manifest generate --set profile=demo // 从概要文件中生成

istioctl manifest generate -f custom-config.yml // 从配置文件中生成
istioctl manifest generate --set profile=demo // 从概要文件中生成

在每次改变 Istio 的部署之后,可以保存与该部署相对应的资源清单,istioctl 可以比较不同资源清单之间的差异,从而追踪不同安装之间的变化。

在下面的代码中,对于不同版本的配置文件,生成并保存了对应的资源清单。通过 istioctl manifest diff 命令可以进行比较。

istioctl manifest generate -f custom-config-v1.yml > istio-v1.yml
istioctl manifest generate -f custom-config-v2.yml > istio-v2.yml
istioctl manifest diff istio-v1.yml istio-v2.yml

那么,在使用 Istio 之前,我们还需要了解什么呢?

注入边车容器

Istio 有自己的服务代理实现 istio-proxy,在 Envoy 的基础上增加了一些功能。为了使用 Istio 提供的功能,Pod 需要以边车容器的形式来运行 Istio 的服务代理。Istio 支持两种不同的方式在 Pod 中注入边车的容器,分别是自动注入和手动注入。

自动注入的方式并不要求对已有的 Kubernetes 部署进行修改,而是在 Pod 创建时自动进行修改。只需要在名称空间上添加 istio-injection=enabled 标签,那么该名称空间上所创建的 Pod 都会自动注入服务代理的边车容器。

下面的代码对名称空间 happyride 启用了自动注入。

kubectl label namespace happyride istio-injection=enabled

由于自动注入只在 Pod 创建时生效,在启用了自动注入之后,名称空间中已经运行的 Pod 需要删除并重新创建来应用修改。

在名称空间已经启用了自动注入的情况下,仍然可以在 Kubernetes 部署中使用注解 sidecar.istio.io/inject 来改变注入行为。这个注解的值可以是 true 或 false。

在下面代码中的 Kubernetes 部署中,通过使用这个注解,禁用了边车容器的注入。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: address
spec:
  template:
    metadata:
      annotations:
        sidecar.istio.io/inject: "false"

如果不希望使用自动注入,可以对单个 Kubernetes 部署启用注入功能。对于一个部署文件,可以通过下面的命令来产生启用注入之后新的部署文件。

istioctl kube-inject -f app.yml

istioctl kube-inject -f app.yml

生成的新的部署文件可以用 kubectl 来应用。下面的命令在更新部署的声明之后直接应用该部署。

istioctl kube-inject -f app.yml | kubectl apply -f -

istioctl kube-inject -f app.yml | kubectl apply -f -

在注入边车容器之前,地址管理服务的 Pod 中只包含一个容器。下面的代码显示了 Pod 的状态。

kubectl get pod -l app.kubernetes.io/instance=address-service

kubectl get pod -l app.kubernetes.io/instance=address-service

上述命令的执行结果如下所示,其中 1/1 表示只有一个容器。

NAME                              READY   STATUS    RESTARTS   AGE
address-service-5ffbd7cb4-ksng4   1/1     Running   0          51m

NAME                              READY   STATUS    RESTARTS   AGE
address-service-5ffbd7cb4-ksng4   1/1     Running   0          51m

当注入了 Istio 的边车代理之后,同样命令的执行结果如下所示,其中的 2/2 表示有两个容器,新增的是服务代理的容器,名称为 istio-proxy。

NAME                              READY   STATUS    RESTARTS   AGE
address-service-5ffbd7cb4-nkbnv   2/2     Running   0          4m41s

NAME                              READY   STATUS    RESTARTS   AGE
address-service-5ffbd7cb4-nkbnv   2/2     Running   0          4m41s

通过查看 Kubernetes 的部署,可以查看边车注入之后的 Pod 的声明。除了服务代理的容器之外,还有一个名为 istio-init 的初始化容器,该容器的作用是修改 Pod 中网络的 iptables 来启用服务代理。通过 iptables 的设置,发送到服务端口 8080 的数据会被转发到服务代理的 15001 端口。

边车注入的相关配置保存在 Kubernetes 中的配置表 istio-sidecar-injector 中。通过修改该配置表中的内容,可以对注入的行为进行更细粒度的控制。比如,通过修改属性 neverInjectSelector 的值,可以根据已有的标签来禁用注入行为。

概念和安装要点都讲完了,下面我们来看一个应用案例吧。

示例应用

由于对示例应用所部署的名称空间启用了 Istio 的边车容器的自动注入,示例应用中的不同服务都会自动添加服务代理。当示例应用部署在 Kubernetes 上之后,Istio 的服务代理及相关功能会自动生效。下图中的 Kaili 界面展示了乘客界面的 GraphQL API 服务、乘客管理服务和地址管理服务之间的依赖关系。这些信息由 Istio 的服务代理自动进行收集,并不需要对应用本身进行修改。

DevOps 云原生 云原生api_云原生_06

通过下面的命令可以访问 Kiali 的界面。

istioctl dashboard kiali

istioctl dashboard kiali

在下面的课时中,将会对 Istio 所提供的功能进行具体的讲解。

总结

服务网格技术可以简化微服务架构应用的开发和运维,Istio 是目前流行的服务网格技术的实现之一。通过本课时的学习,你可以了解到服务网格技术的意义,对 Istio 有基本的了解,并掌握如何安装和配置 Istio 的不同组件。

对于 Istio 你还有什么想要了解的?欢迎随时留言讨论。