Skip to content

第26讲:服务版本更新策略是什么 ​

在一个软件系统的整个生命周期中,通常都需要持续的版本更新。相对于安装到客户本地环境上的应用来说,软件服务运行在云平台或虚拟机上,在进行版本更新时更加容易管理。对于微服务架构的应用来说,实际上运行的是多个独立的应用,这些应用可以有自己的版本更新周期,这就使得整个应用的更新方式变得复杂。本课时将介绍在微服务架构的应用中,不同服务的版本更新策略。

作为独立运行的应用,每个微服务的开发团队可以自主选择更新的频率。如果只是服务的内部实现发生了变化,则其他微服务完全不需要知道。只有当一个微服务的开放 API 发生了变化时,才需要与其他微服务进行协调。本课程将着重介绍 REST API 的更新方式。

API 更新 ​

开放 API 作为微服务的接口,一旦确定下来之后,更新起来并不是很容易,这主要是为了避免已有的 API 使用者产生问题。但是 API 的更新是无法避免的,不论是修复错误,或是添加新的功能,都会对已有的 API 产生影响。这些改动可以分成向后兼容和不兼容两种类型。向后兼容的改动不会对已有的 API 使用者产生影响,服务可以随时更新;不兼容的改动则只有在已有的 API 使用者更新代码之后才能正常使用。

向后兼容的改动只在已有 API 的基础上增加新的元素,包括请求中新的可选参数、响应中新的值和 API 中新的操作。当 API 版本升级之后,旧的客户端发送的请求中不包含新增的可选参数的值,API 实现可以使用默认值来替代;响应中新增的值也会被旧的客户端忽略。

当 API 的改动无法向后兼容时,则需要考虑如何更新,这里需要对外部 API 和内部 API 进行不同的处理。外部 API 指的是 Web 应用和移动客户端使用的 API,内部 API 指的是微服务之间相互调用的 API。对于外部 API,当 API 中产生不兼容的改动时,不可能要求所有的使用者都同时更新到新版本,这就要求新旧两个版本同时运行。对于内部 API,虽然开发团队对 API 的使用者的代码有完全控制,但是仍然无法做到同时更新 API 实现和所有的使用者。

现在的应用都要求零宕机时间,在 API 更新之后,使用者完成更新之前,使用者会发送旧的请求到更新之后的 API,从而产生错误。因此,外部和内部 API 都要求同时运行不同版本的 API,只不过在实现策略上有一些差异。

API 版本 ​

同时运行多个 API 版本的前提是为 API 设置版本。

语义化版本规范 ​

目前对版本的定义,通常都使用语义化版本规范(Semantic Versioning)。语义化版本规范使用三个部分作为版本号,具体的格式为主版本号.次版本号.修订号,如 1.2.3。当作了不同类型的改动时,需要递增其中的某一个部分,这三个部分的递增规则如下表所示:

名称英文名称说明
主版本号MajorAPI 产生了不兼容的改动
次版本号MinorAPI 添加了向后兼容的新特性
修订号PatchAPI 添加了向后兼容的错误修正

对于外部 REST API 来说,一般有四种不同的方式来指定版本号,如下表所示:

方式说明示例
URI 路径版本号添加在 URI 路径中http://www.myapp.com/api/v1/users
查询参数版本号作为查询参数的值http://www.myapp.com/api/users?version=2
HTTP 头版本号添加在自定义 HTTP 头中HTTP 头 Accepts-version
内容协商通过 HTTP 的 Accept 头进行内容协商Accept 头 application/vnd.myapp+json; version=1

这四种方式都只用到了主版本号;次版本号和修订号的修改,不会对使用者造成影响,因此使用者并不需要知道这两个版本号。如果使用 API 优先的策略,那么 OpenAPI 文档中的 version 属性可以记录 API 的实际版本。

同时运行新旧版本的 API 并不是太大的问题。每个构建版本都有与之对应的容器镜像,只需要为不同版本创建独立的部署,运行在 Kubernetes 上即可,每个 API 版本都有与之对应的 Kubernetes 服务。比如,行程管理服务的不同版本的 Kubernetes 服务名称可能是 tripservice-v1 和 tripservice-v2。内部 API 的使用者通过配置来选择需要使用的 API 版本;外部 API 通常使用 API 网关来管理不同版本与 Kubernetes 服务的对应关系。API 网关的相关内容将会在第 35 课时中介绍。

代码管理 ​

可以同时运行 API 的多个版本,这是微服务架构相对于单体应用的一个优势。在单体应用中,当 API 升级之后,新版本的 API 和旧版本的 API 都保留在代码中,一般以不同的 API 路径来区分,比如 /api/v1 和 /api/v2,当所要支持的版本变多时,代码变得复杂和难以管理。版本号被添加到了 Java 的包名中,不同版本的 Java 对象中存在大量的重复代码。微服务则没有这个问题,代码库中只有每个 API 版本的最新代码,多个版本的代码通过 Git 分支来管理。

在下面代码中,User 类是最早的 API 版本中的对象定义。

java
public class User {
  private String name;
  private int age;
}

在新的版本中,age 属性被替换成了表示出生日期的 dateOfBirth。这是一个不兼容的改动,需要创建新的 User 类。在单体应用中,通常的做法是创建一个 User2 类,如下面的代码所示。

java
public class User2 {
  private String name;
  private Date dateOfBirth;
}

两个版本的 API 使用不同的对象,路径为 /api/v1/user 的操作使用 User类,而路径为 /api/v2/user 的操作使用 User2 类。

在微服务中,当需要创建新版本时,可以创建一个新的 Git 分支,并直接更新 User 类,两个分支可以独立更新。在进行持续集成时,对两个分支进行构建并创建各自的容器镜像,并部署在 Kubernetes 上。

实现细节 ​

下面讨论一些 API 更新的实现细节。

如果在请求中增加了新的参数,那么这些参数的默认值要与之前的行为保持一致。比如,历史行程服务的旧 API 只支持以乘客或司机的标识符作为参数来查询,在新版本 API 中引入了新的查询参数 sort 来设置结果记录的排序方式,那么这个参数 sort 的默认值必须与之前的内部实现的排序方式保持一致。

如果以 JSON 作为请求和响应的格式,则需要注意对于 JSON 中不可识别的属性的处理。以 Jackson 为例,当解析时遇到未知的属性时,Jackson 会抛出 UnrecognizedPropertyException 异常,造成运行时错误。如果 API 的响应中新增了内容,可能会造成已有使用者出现错误。一种做法是配置 ObjectMapper 来忽略未知的属性,如下面的代码所示。

java
new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)

另外一种做法是使用 @JsonIgnoreProperties 注解在 POJO 类上添加声明,如下面的代码所示。

java
@JsonIgnoreProperties(ignoreUnknown = true)
public class MyObject {
}

如果以 Protocol Buffers 来作为请求和响应的格式,则并不需要进行额外的处理,Protocol Buffers v2 协议中使用 optional 来声明可选的字段。在下面的代码中,email 属性是可选的。在引入向后兼容的改动时,只需要添加可选的属性即可。当可选的属性值不存在时,用默认值替代即可。

js
message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
}

事件消息的版本化 ​

如果微服务之间使用异步消息传递来交互,消息对象也需要进行版本化。不过消息的版本化与 REST API 存在很大不同。在使用 REST API 时,API 的使用者需要知道被调用的 API 的访问地址。因此,当不同版本的 API 同时存在时,API 的使用者可以通过不同的访问地址来进行选择。而消息则不同,消息的发布者和接收者之间是解耦的。一个消息的发布者并不知道有多少接收者处理了这个消息,而且消息的接收者的数量会随着代码更新而不断变化。

对于向后兼容的消息格式的变化,可以通过修改载荷对象的格式来完成。在示例应用中,TripCreatedEvent 事件中行程的信息由 TripDetails 类来描述。在版本更新时,可以在 TripCreatedEvent 类,或是其包含的 TripDetails 类中添加新的可选属性。

对于不兼容的消息格式的变化,则需要创建新的事件类型,可以在事件名称后添加版本后缀,如 TripCreatedEventV2,也可以使用不同的包名。事件的处理器可以选择需要处理的事件类型,包括同一事件的不同版本。

Kubernetes 示例 ​

下面通过一个具体的示例来说明多版本的 REST API 如何在 Kubernetes 上运行。

API 的提供者是一个使用 Spring Boot 开发的微服务,该 API 用来获取版本信息。下面代码中的 VersionService 是服务的实现,其中的 getVersion 方法返回版本号。

java
@Service
public class VersionService {
  public String getVersion() {
    return "v1";
  }
}

下面代码中的 VersionController 类是相应的 Spring MVC 的控制器实现。需要注意的是,VersionController 的访问路径中并不包含任何 API 版本信息。

java
@RestController
@RequestMapping("/version")
public class VersionController {
  @Autowired
  VersionService versionService;
  @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
  public Version getVersion() {
    return new Version(this.versionService.getVersion());
  }
}

对于作为 API 提供者的微服务,我们使用 Spring Boot 的 Maven 插件创建它的容器镜像。这里需要注意的是容器镜像的标签,该标签同时包含了 API 的版本和应用的版本。API 的版本只有主要版本的数字。

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <parent>
    <artifactId>microservice-sample-code</artifactId>
    <groupId>io.vividcode</groupId>
    <version>1.0.0-SNAPSHOT</version>
  </parent>
  <modelVersion>4.0.0</modelVersion>
  <artifactId>sample-service-provider</artifactId>
  <version>1.0.0</version>
  <properties>
    <api_version>1</api_version>
  </properties>
  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <configuration>
          <image>
            <name>myapp/${project.artifactId}:${api_version}-${project.version}</name>
          </image>
        </configuration>
        <executions>
          <execution>
            <goals>
              <goal>build-image</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

API 提供者有对应的 OpenAPI 文档,从中可以生成访问 API 的 Java 客户端库,这个库的版本号与 OpenAPI 中声明的版本号保持一致。在 API 版本不断更新时,总是可以从 Maven 仓库中找到特定 API 版本对应的客户端。

API 的使用者通过 API 客户端来访问。下面代码中的 ServiceGateway 类使用 API 客户端中的 SampleApi 对象来调用服务。

java
@Service
public class ServiceGateway {
  @Autowired
  SampleApi sampleApi;
  public Version getVersion() throws ApiException {
    return this.sampleApi.getVersion();
  }
}

实际调用的 API 的路径由 Java 系统属性 provider.service.url 来配置,这个配置的值作为 API 客户端 ApiClient 对象的基本访问路径。

java
@Configuration
public class ApplicationConfig {
  @Bean
  public SampleApi sampleApi(
      @Value("${provider.service.url}") final String serviceUrl) {
    final ApiClient apiClient = new ApiClient();
    apiClient.setBasePath(serviceUrl);
    return new SampleApi(apiClient);
  }
}

下面的代码给出了 API 提供者的版本 1 的实现,以及在 Kubernetes 上的部署和服务的配置,通过 myapp/api-version 标签指定了 API 的版本号。Kubernetes 服务 provider-service-v1 使用该标签来作为 Pod 的选择器。

yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: provider-v1
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: provider
      app.kubernetes.io/version: '1.0.0'
  template:
    metadata:
      labels:
        app.kubernetes.io/name: provider
        app.kubernetes.io/version: '1.0.0'
        myapp/api-version: '1'
    spec:
      containers:
        - name: provider
          image: myapp/sample-service-provider:1-1.0.0
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: provider-service-v1
spec:
  ports:
    - port: 80
      name: web
      targetPort: 8080
  selector:
    app.kubernetes.io/name: provider
    myapp/api-version: '1'

下面的代码给出了 API 的一个使用者的实现,并在 Kubernetes 上的部署和服务的配置。通过环境变量 PROVIDER_SERVICE_URL 来传递 API 地址,只需要使用 API 提供者的 Kubernetes 服务的名称即可。

yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: consumer1
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: consumer1
      app.kubernetes.io/version: '1.0.0'
  template:
    metadata:
      labels:
        app.kubernetes.io/name: consumer1
        app.kubernetes.io/version: '1.0.0'
    spec:
      containers:
        - name: consumer
          image: myapp/sample-service-consumer:1.0.0
          env:
            - name: PROVIDER_SERVICE_URL
              value: http://provider-service-v1
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: consumer-service1
spec:
  type: NodePort
  ports:
    - port: 80
      name: web
      targetPort: 8080
  selector:
    app.kubernetes.io/name: consumer1

当 API 提供者有新的不兼容版本时,需要创建新的 Kubernetes 部署和服务,并使用标签 myapp/api-version 进行标识。不同的 API 使用者通过环境变量来配置 API 的访问地址。

总结 ​

在微服务架构的应用中,外部 API 和内部 API 通常都需要同时运行多个版本。Kubernetes 简化了服务的多个版本的运行方式。通过本课时的学习,你可以了解服务的版本更新策略,以及具体的实现细节,知道如何在 Kubernetes 上运行服务的多个版本。