首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Contracts - 如何进行架构契约决策

Contracts - 如何进行架构契约决策

作者头像
小坤探游架构笔记
发布2025-11-20 12:29:57
发布2025-11-20 12:29:57
220
举报

点击上方小坤探游架构笔记可以订阅哦

今天我想来聊聊软件中的契约, 即我们服务或者组件之间的连接方式, 在软件架构中, 它几乎是贯穿始终且影响架构决策方方面面的恒定因素.或许你还会觉得还有点陌生, 那么我可以换一个说法, 即在我们软件开发设计流程中, 我们会设计REST、gRPC、XML-RPC等格式来实现不同服务、组件之间在通信上使用“统一语言”进行交流. 今天先从架构层面阐述契约决策, 后面会单独写契约具体实现需要注意的细节.

架构层面契约的决策

像刚刚所说的gRPC、REST等格式, 它都是属于设计实现层面的技术, 那么在架构层面我们该如何进行契约的决策呢?

问题一: 为何架构层面需要进行契约决策? -- What & Why


在我们要在架构层面上对契约进行决策之前, 我想我们都需要先了解一个问题: 为何在架构层面也需要考虑不同服务(组件)之间的连接方式, 如果我们不在架构层面去考虑这个问题, 它会给我们带来什么问题呢?

在一个微服务架构中, 我们知道软件之所以能够运作完成一系列的需求目标, 正是通过我们软件中不同架构量子之间的静态、动态耦合一同协作完成, 而这个协作从静态耦合层面上看表达了依赖关系、从动态耦合看表达了信息传递方式.

那么在软件架构中, 从广义上来讲我们用“契约”来描述架构中各个组成部分(不仅是架构量子, 也可以是服务或者是组件)的集成点, 即这个集成点要么是传递信息、要么是表达依赖关系, 从而实现系统各个组成部分(架构量子/服务/组件)之间的连接, 并通过具体的契约格式来表达. 正如《Software Architecture: The Hard Parts》提到:

The format used by parts of an architecture to convey information or dependencies. (架构各组成部分用于传递信息或表达依赖关系所采用的格式。)

在软件架构上契约定义涵盖了用于 “连接系统各组成部分” 的所有技术,包括框架与库的传递依赖、内部及外部集成点、缓存,以及系统各组成部分之间的其他各类通信方式。

由此可见, 在微服务架构中, 如何选择传递信息或者表达依赖的格式也将影响到架构的静态耦合与动态耦合特性. 而在微服务架构设计目标主要是解耦合, 因此我们在架构层面上对契约进行决策最终的目的是设计并降低系统组成部分之间的耦合, 通过降低耦合我们可以通过控制变化频率带来的影响.

问题二: 如何在架构层面进行契约决策 -- How


理解了架构契约决策的What & Why问题, 那么我们要如何进行契约决策呢? 在《Software Architecture: The Hard Parts》一书提到了Strict & Loose Contracts, 即严格与松散的契约类型, 比如以传递信息的契约格式为例, 其由严格到松散的契约从左到右如下:

可以看到RPC是一种严格的契约格式, 其次是到REST方式, 最后是以Key-Value键值对的松散契约格式, 逐渐由严格到松散的过程.

Strict Contracts

首先我们需要先了解什么是Strict Contracts? Strict Contracts要求严格遵循名称、类型、顺序以及所有其他细节, 不能存在任何的歧义, 在软件领域最严格的契约类型示例是我们熟悉的RPC方式, 比如两个服务之间采用gRPC协议来定义客户资料如下:

代码语言:javascript
复制
// proto2
service ProfileService {
    Profile queryProfile(ProfileRequest request);
}


message ProfileRequest {
   required string profileId = 1;
   required repated ProfileConditions conditions = 2; 
}

message ProfileConditions {
   required string key = 1;
   required string sign = 2;
   required string value = 3;
}

message Profile {
   required string id = 1;
   reqiured string name = 2;
   reqiured int32 age = 3;
   reqiured string addr1 = 4;
   reqiured string country = 5;
   reqiured string email = 6;
   //....
}

这里我采用pb2的方式主要为了更好的说明严格契约的问题. 从上述我们可以看到采用gRPC调用实现就如同本地方法调用一般, 需要严格匹配类型、参数以及对应的方法名称且均为required属性, 这就是我们软件领域的严格契约类型.

从上述我们也可以看到严格契约类型会导致架构在各个组成部分集成过程中出现脆弱性: 如果其中某一个元素频繁发生变更, 又被架构中多个独立的组件使用, 尤其是作为分布式架构的连接枢纽, 契约的变更频率越高, 给其他服务带来的连锁问题就越多, 比如我们常见的用户授权服务会经常被集团内部多个系统使用:

如果我们的用户服务与上述的服务之间都共用同一套契约且为严格的契约类型, 那么假如A服务突然要向用户服务增加一个客户端的身份证信息, 那么所有依赖用户服务的其他服务.

比如B、C都需要同步更新这份契约, 这就是我们看到连锁影响.也许这个时候我们会说把这个身份信息设置为optional不就可以了吗? 在某种程度上来说增加optional的目的是为了做向后兼容, 但在架构层面上仍然是没有解决根本问题, 即脆弱性仍然存在, 比如A服务增加身份信息, B服务需要增加工作年限字段, 那么这个时候至少A、B服务都需要变更, 如果其中有一个要求是required, 那么所有的服务都需要进行契约的更新.

Loose Contracts

为了在架构层面上降低集成点的耦合性, 那么在契约设计上我们妥协一部分, 降低要求, 相比RPC格式, 我们可以采用基于资源进行建模, 采用REST格式进行通信, 那么这个时候我们会针对对应的服务设计对应的资源REST格式, 比如对于上述A以及B服务,我们基于资源进行建模, 采用对应的REST定义如下:

代码语言:javascript
复制
// 基于go的定义
type ProfileResourceA struct {
    id string `json:"id"`
    name string `json:"name"`
    identify string `json:"identify"`
}

type ProfileResourceB struct {
    id string `json:"id"`
    name string `json:"name"`
    workingYears string `json:"workingYears"`
}

同样地, 我们会针对对应的其他服务也会采用相同的资源进行建模, 最后我们可以针对相同的资源进行整合, 相比RPC的格式, REST格式是基于面向资源的聚合建模设计,但是这个时候也许你会觉得如果服务太多了,我岂不是都得定义建立对应的聚合数据来定义对应的REST格式?

这时我们就会建立更为松散的契约类型, 比如是我们熟知的KV形式, 那么这个时候对于A服务以及B服务对应的KV数据返回格式如下:

代码语言:javascript
复制
{
   "id": 1,
   "name": "customerName1",
   "ideneity": "440930X1"
}

{
   "id": 1,
   "name": "customerName1",
   "workingYears": 10
}

虽然采用上述的松散耦合契约能够在微服务架构中实现高度解耦合, 但是越松散的契约就越缺乏验证与不确定性,增加了系统不可预测的复杂度, 因此我们在微服务架构中采用松散耦合的契约需要增加契约适配函数(contract fitness functions)来解决这个问题.

Strict & Loose Contracts 对比

Customer Driven Contracts

在上面我们讨论到微服务架构如果采用松散的契约类型, 比如像KV形式, 那么必然需要有一个模式验证契约的准确性, 如下面的客户服务采用松散契约KV方式返回, 我们要如何验证其准确性呢?

第一方式我们可以为上述的JSON对应的KV增加scheme约束, 如下:

代码语言:javascript
复制
{
    "$schema": "http://json-schema.org/draft-04/schema#",
    "properties": {
      "id": {"type": "number"},
      "name": {"type": "string"},
      "age": {"type": “number", "maximum": 100},
      "country": {"type": "string"}
   },
    "required": ["id", "name"]
}

通过上述定义的scheme可以为我们在服务通信过程中提供了相关的元数据, 然而在微服务架构中还有一种基于Customer Driven Contracts模式.

在《Software Architecture: The Hard Parts》关于Customer Driven Contracts是这样阐述:

The concept of a consumer-driven contract inverses that relationship into a pull model; here, the consumer puts together a contract for the items they need from the provider, and passes the contract to the provider, who includes it in their build and keeps the contract test green at all times.

什么意思呢? 我们可以先向看下面RPC服务通信中同一个服务提供者与N个消费者服务之间通信的交互(也称之下游与上游服务)如下:

在我们平时的开发过程中, 一般都是服务提供者或者称之下游服务提供协议内容给到对应的消费者服务, 我们称之为推送契约模式.

而消费者驱动契约则是将这个关系进行反转, 也就是上述提到: 转变为 “拉取模式”, 在此模式下,消费者会根据自身需求,制定一份包含所需信息项的契约并传递给服务提供者;服务提供者需将该契约纳入自身构建流程,并确保契约测试始终处于 “通过”(green)状态

即使存在部分信息重叠, 但服务提供者则会将这些契约测试纳入持续集成(continuous integration)或持续部署(continuous deployment)流水线中。这样一来,每个团队都能根据需求灵活选择契约的严格程度或松散程度,同时在构建流程中确保契约准确性.

这里我们针对CDC的优劣势以及Trade-Off绘制表格如下:

契约设计的耦合 - Stamp Coupling

过度耦合增加架构脆弱性

我想我们在工作中应该会存在以下的情况, 这里我们来看微服务架构中两个架构量子之间的通信如下(引用《Software Architecture: The Hard Parts》 的愿望清单与资料服务):

在上面我们发现愿望清单服务实际仅需要资料属性name, 其他的属性对于愿望清单服务基本没任何作用, 从上述可以看到两个架构量子之间是采用严格契约类型进行信息传递, 然而资料服务定义很多清单服务不需要的额外属性.

也许在架构层面上我们认为是为未来提供了可扩展性, 但是增加了架构的脆弱性,偏离了合理的设计方向, 如果我们的资料服务提供的字段发生变更, 即使愿望清单服务仅需要使用属性name, 也会导致我们的契约失效.

契约(contract)在分布式系统里本质是消费者与提供者之间的行为约定,通常包含以下几个方面:

  • 哪些字段(属性)会出现在响应中;
  • 字段的类型与语义(含义);
  • 字段是否必须(presence);
  • 字段的可接受取值范围或约束;
  • 请求/响应的语义(例如出错时返回什么)。

契约有效意味着:消费者在不知晓提供者内部实现细节的前提下,能可靠地获得满足其期望的数据并正常工作;并且当提供者演进时,契约能通过向后/向前兼容规则保证消费者不会被意外破坏。

同样以上述我们采用PB2定义的Profile为例说明, 以下为我们上述定义的严格契约类型如下:

代码语言:javascript
复制
// proto2
message Profile {
   required string id = 1;
   reqiured string name = 2;
   reqiured int32 age = 3;
   reqiured string addr1 = 4;
   reqiured string country = 5;
   reqiured string email = 6;
   //....
}

比如我们上述的country字段需要改用枚举方式, 或者是我们想往Profile新增一个required属性, 那么这个时候愿望清单在接收响应返回的时候就会存在解析失效, 无法实现向后兼容.

为了解决这个问题, 我们会采用稍微宽松一些的gRPC方式, 在软件架构中我们的PB格式会采用pb3的格式, 仅保留optional + version的方式, 于是上述的Profile在pb3的格式定义会调整为:

代码语言:javascript
复制
// proto3, 默认都是optional
message Profile {
   string id = 1;
   string name = 2;
   int32 age = 3;
   string addr1 = 4;
   string country = 5;
   string email = 6;
   //....
}

因此上述我们的契约无效主要原因是契约把与消费者无关的实现细节固定为规则/约束,从而使提供者的微小变化导致消费者中断 —— 即架构脆弱性(brittleness)。

增加额外带宽成本

同样以上述的Profile为例, 假设每秒有 2000 次请求。若每个请求的有效负载(payload)为 500KB,那么仅这一项请求每秒所需的带宽就高达 1,000,000KB(即 1GB)!显然,这种毫无必要的带宽占用方式是极不合理的。

相反,若 “愿望清单服务(Wishlist)” 与 “个人资料服务(Profile)” 之间的耦合仅包含 “姓名” 这一必要信息,那么每秒的额外数据传输量会降至 200 字节,总带宽需求也仅为 400KB,这一数值是完全合理的。

支持分散复杂工作流

难道增加额外信息就毫无价值了吗, 并不是. 在我们的分布式事务Saga模式, 如果我们是采用分散的工作流方式进行编排, 也就是我们将一个长事务拆分为每一个独立的小事务, 如果我们在每个工作流中保留每个小事务的完整信息, 那么在整个工作流运行之后我们就能够得到每个环节的完整信息.

如果我们想要保证事务的原子性, 则系统中的领域服务应将契约重新广播至之前已交互过的服务,以恢复原子一致性, 采用一份完整的契约记录经过每个架构量子的事务执行情况,如下:

对此我们对契约设计的Stamp Coupling(关于Stamp Coupling并不打算翻译, 有的称之为邮戳耦合, 有的称之为印记耦合, 感觉中文翻译有点别扭, 直接用英文表示)有了新的认识, 因此我这里基于上述的分析与理解进行定义如下:

什么是Stamp Coupling: 在服务通信中传递信息包含了“大对象/全量实体/包含大量字段的复合结构”.

那么在架构层面上, 对于Stamp Coupling我们要如何进行Trade-Off? 现总结如下:

总结

今天的话题也是基本来自《Software Architecture: The Hard Parts》一书, 当我们在软件架构层面去考虑契约的设计, 或者通俗讲我们会称之为协议, 在架构层面上严格契约与松散契约各有优势, 一般我们在架构量子内部会采用严格契约方式, 但这种严格契约方式我们需要考虑向后以及向前兼容.

而在面向资源层面, 我们更倾向采用宽松的契约模型, 主要目的是降低变更频率, 比如移动端如果采用严格的契约模式, 那么当后端服务发生变更, 对应的移动端程序也要做相应变更, 其中要发布到应用商品会耗费比较长的时间, 因此采用更宽松的契约, 我们就能够提升架构的灵活性,降低架构的脆弱性, 也降低了变更的发布频率.

你好,我是疾风先生, 主要从事互联网搜广推行业, 技术栈为java/go/python, 记录并分享个人对技术的理解与思考, 欢迎关注我的公众号, 致力于做一个有深度,有广度,有故事的工程师,欢迎成长的路上有你陪伴,关注后回复greek可添加私人微信,欢迎技术互动和交流,谢谢!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-11-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 小坤探游架构笔记 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档