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 上运行服务的多个版本。