2.0.0 新特性介绍: 弱类型契约

"以契约为中心" 是 Java Chassis 的核心设计理念。 “契约” 扮演着用户与开发者,开发者与开发者之间的沟通桥梁。 举几个简单的例子。

开发者发布了一个微服务A,同时发布了一份契约文件:

swagger: '2.0'
info:
  title: rest api
  version: 1.0.0

basePath: /controller
produces:
  - application/json

paths:
  /add:
    get:
      operationId: add
      parameters:
        - name: a
          in: query
          required: true
          type: integer
          format: int32
        - name: b
          in: query
          required: true
          type: integer
          format: int32
      responses: 
        "200":
          description: add numer
          schema: 
            type: integer
            format: int32

微服务A的用户可以基于这份契约,使用浏览器访问add接口,也可以在自己的微服务B中,使用RestTemplate 调用add接口,用户不需要知道微服务A的实现细节,也不需要依赖任何微服务A提供的接口,微服务B保持完全 与微服务A独立。只要契约不变,微服务B就可以保持不变。

作为微服务A的其他开发者,可以基于契约开发治理功能,不需要知道add接口的实现细节。比如可以给add 接口增加流量控制 servicecomb.flowcontrol.Provider.qps.limit.[ServiceA].[Controller].[add]=1000, 限制其流量为1000 TPS;还可以基于 add 接口的参数做灰度发布,在灰度发布代码里面可能会存在下面的代码片段: invocation.getSwaggerArgument("a") 来获取参数值。

"以契约为中心"是保持微服务"独立性"非常重要的手段,“独立开发、独立交付”是微服务被引入,提升软件工程能力 最重要的价值。

本文重点介绍2.0.0版本引入的“弱类型契约”,以及它与之前“强类型契约”之间的差别与联系。

弱类型契约 vs 强类型契约

弱类型契约和强类型契约的共同点都是"以契约为中心",因此弱类型契约并没有改变 Java Chassis 的整体设计理念, 而是在实现层面发生了一些变化,进而影响到部分开发体验。2.0.0之前的版本是强类型契约。产生强类型契约和一些技术背景 有关系,讨论的起点可以从 Java Chassis Highway 的 ProtoBuffer 讲起。

熟悉 ProtoBuffer 的开发者都知道, ProtoBuffer 主要被用于 gRPC, 其他处理 ProtoBuffer 编解码的库还有 ProtoStuff。 gRPC 需要写 IDL 文件, 然后根据 IDL 生成运行时的代码,在gRPC的运行过程中,处理编解码的类在编译时间就已经确定,无法 更改。ProtoStuff 提供的类库比 gRPC 更加灵活,对于同样的 IDL 文件,可以在运行时指定不同的类进行序列化和反序列化。 无论如何,在一个微服务里面,如果存在一个契约(IDL文件),那么必须在编译时就确定这个契约对应的类是什么。这个类可以 有一个,也可以有多个,但是必须在编译的时候确定类的类型。

强类型契约可以定义为:在一个微服务里面,契约必须存在一个或者多个编译时确定的类型与它对应。在2.0.0版本之前,这个对应 的类型是在契约文件里面指定的,比如x-java-interface: org.apache.servicecomb.demo.controller.Controller 。 开发者 可能注意到在消费端代码,可以不依赖任何提供端的类,这是因为消费端的代码会根据契约描述,采用 javassist 工具动态生成 一个类型与契约对应,不同版本的契约需要生成不一样的类型,如果灰度环境版本过多,就可能导致内存过大的问题,特别是在边缘 (Edge Service)服务里面。

强类型契约的一个外在体现就是下面的代码:

Person result = restTemplate.postForObject("/getPerson", null, Person.class);

可能抛出 ClassCastException 异常。 因为 getPerson 的返回值的类型在编译时已经确定, 如果返回值 Person 类型 与编译时的类型不一样,就会报告 ClassCastException 异常。

了解了强类型契约后,弱类型契约的定义就很明显了: 在一个微服务里面,契约可以存在一个或者多个编译时确定的类型与它对应, 也可以不存在编译时确定的类型与它对应。 引入弱类型契约后,下面的代码:

Person1 result = restTemplate.postForObject("/getPerson", null, Person1.class);
Person2 result = restTemplate.postForObject("/getPerson", null, Person2.class);

都不会报告 ClassCastException 异常,只需要 Person1Person2 的定义都能够和契约对应。 由此可以看出,弱类型 契约在使用方式上更加灵活,去掉了动态创建类的过程,降低了内存占用,缩短了微服务启动时间。

利用弱类型契约增强写代码的灵活性

弱类型契约不要求提供者与消费者使用一样的类型,在代码书写上提供了很大的方便。比如提供者有如下接口:

@RestSchema(schemaId = "weakSpringmvc")
@RequestMapping(path = "/weakSpringmvc", produces = MediaType.APPLICATION_JSON_VALUE)
public class WeakSpringmvc {
  @GetMapping(path = "/diffNames")
  @ApiOperation(value = "differentName", nickname = "differentName")
  public int diffNames(@RequestParam("x") int a, @RequestParam("y") int b) {
    return a * 2 + b;
  }

  @GetMapping(path = "/genericParams")
  @ApiOperation(value = "genericParams", nickname = "genericParams")
  public List<List<String>> genericParams(@RequestParam("code") int code, @RequestBody List<List<String>> names) {
    return names;
  }

  @GetMapping(path = "/genericParamsModel")
  @ApiOperation(value = "genericParamsModel", nickname = "genericParamsModel")
  public GenericsModel genericParamsModel(@RequestParam("code") int code, @RequestBody GenericsModel model) {
    return model;
  }

  @GetMapping(path = "/specialNameModel")
  @ApiOperation(value = "specialNameModel", nickname = "specialNameModel")
  public SpecialNameModel specialNameModel(@RequestParam("code") int code, @RequestBody SpecialNameModel model) {
    return model;
  }
}

而消费者只需要访问其中一个接口diffNames,只需要定义一个非常简单的接口:

interface DiffNames {
  int differentName(int x, int y);
}

@RpcReference(microserviceName = "springmvc", schemaId = "weakSpringmvc")
private DiffNames diffNames;

其中接口名称是和契约里面的接口名称一致 differentName, 而不是和服务端的代码一致 diffNames。 契约包含 xy 两个参数, 并且契约的参数是顺序无关的, 下面的代码也是可以访问同一个接口的:

interface DiffNames2 {
  int differentName(int y, int x);
}

@RpcReference(microserviceName = "springmvc", schemaId = "weakSpringmvc")
private DiffNames2 diffNames2;

需要注意的是,2.0.0 版本要求保留参数名称, 在编译代码的时候,需要加上 -parameters 编译参数。

开发者还可以通过 RestTemplate 调用这个接口:

restTemplate.getForObject("cse://springmvc/weakSpringmvc/diffNames?x=2&y=3", Integer.class)

或者采用 InvokerUtils 调用:

Map<String, Object> args = new HashMap<>();
args.put("x", 2);
args.put("y", 3);
InvokerUtils.syncInvoke("springmvc", "weakSpringmvc", "differentName", args);

弱类型契约的治理功能

弱类型契约在 Invocation 里面提供了独立的方法,让开发者开发治理功能更加容易。

  public Map<String, Object> getInvocationArguments() {
    return this.invocationArguments;
  }

  public Map<String, Object> getSwaggerArguments() {
    return this.swaggerArguments;
  }

  public Object getInvocationArgument(String name) {
    return this.invocationArguments.get(name);
  }

  public Object getSwaggerArgument(String name) {
    return this.swaggerArguments.get(name);
  }

getSwaggerArguments 始终获取的是和契约对应的参数,如果契约为 xy 两个 query 参数, 那么得到的参数就是 包含 xy 的 Map 。 getInvocationArgument 获取的是实际类型参数, 在服务提供者,这个参数列表的个数和类型 和实际的 Method 的列表对应, 比如可能包含 InvocationContext , HttpServletRequest 等注入参数。 还有很多情况, 契约参数和类型参数不对应,比如聚合参数的情况,契约参数是多个 query 参数, 而类型参数是一个 POJO; 再比如 POJO 接口 定义的时候, 类型参数可能是多个, 而契约参数只有一个 body 参数。 在服务消费者,如果用户采用 POJO 方式调用服务提供者, 两个接口的返回的值与服务提供者类似,存在语义差别;如果采用 RestTemplate 或者 InvokerUtils 调用, 那么两个接口返回的 内容一样,都是契约参数。在边缘服务, 两个接口返回的都是契约参数。 这种行为体现了弱类型契约的语义: 是否存在编译时 类型与契约对应。

弱类型契约带来的一些变更

总体而言,对于 REST 通信模式, 弱类型契约不仅增强了写代码的灵活性, 还完整保留了强类型契约的写代码方式,几乎不 存在用户需要感知的变更。 对于 HIGHWAY 通信模式, 由于底层采用 ProtoBuffer 编码, 而 ProtoBuffer 天然就是一种 强类型契约的编解码过程, java-chassis 为了支持弱类型契约, 做了大量努力, 在一些边界条件处理上与弱类型契约存在 变更,两个版本的编解码是不兼容的,需要同时升级提供者和消费者。 在编码方式上,差异主要体现在对于缺省值的处理,对于 null 的处理等问题上, 详细参考1.3.0 升级 2.0.0指导