在程序员的日常工作中,解决技术问题往往是最后要做的事情,而在此之前总是要面临诸多跨服聊天的无效沟通:你这个文档怎么没更新?变更了我怎么不知道?这乱七八糟的错误码都是啥意思?我们拉个会对齐一下?
这些问题往往牵扯着大量的研发精力,却事倍功半。有没有一种办法能从架构、系统的层面上去做一些规避、约束?这是本文想去尝试解决的问题。
12 月 5 日晚 7:30,腾讯云开发者视频号「鹅厂程序员面对面」直播间,我们邀请了作者来为你分析《契约平台的设计与思考》,预约观看有机会抢鹅厂周边好礼!
我有两个朋友:小明和小红,一天他们在一起做项目,遇到了一些问题...
1.1 假文档?
1.2 变更了、我怎么不知道?
经过了一段时间反反复复的验证,小红和小明的代码终于上线了,可是上线后没多久就出了故障...
1.3 错误码是啥意思啊?
在服务上线后的第二天半夜二点三十五,小明在梦中被告警电话吵醒,打眼一看报错信息,下游返回失败,收到了如下信息:
{
"code": 71756425,
"message":"系统繁忙"
}
小明心想:这什么玩意?于是找到了下游的同学 A,下游 A 同学一看,这也不是我的报错啊,这是下游的错误码,我是透传,于是三更半夜开始了找人活动…
1.4 尝试去解决
以上的场景,对于开发同学来说:懂得都懂,它们如此常见,却如此难搞,让我们再分析一下
说到这里,可能有的读者就会说,貌似这些问题也不难啊,我们只要养成好习惯,例如接口先行、及时的更新文档,以及提醒要关注的人;变更接口时,保证自己的服务一定是兼容式更新;对于错误码,我们一定要把所有的错误码写清楚,下游服务错误码的返回一定要确认清楚,给上游抛什么也要定义好....
这种方式咋一听,确实有道理,但是细想,完全不行,因为字里行间都写满了两个字靠人,但不幸的是,是人就会犯错 , 开发是人,不是神!有的时候不是不知道解决方案,而是要对抗自己人性中的弱点,去做都懂但是很难做到的事,就像我知道晚上吃宵夜不好,但有的时候还是会去吃!
所以,我们就想,有没有这样一款产品,可以很好地解决开发中的这些痛点。
和很多优秀的产品一样,人们通过观察和思考去捕捉生活中的现象,从中发现问题,然后通过创造“产品”去解决问题,从而产生价值,下面我们也按照这两点展开。
2.1 洞察
在第一节(开发的窘境),我们列举了在开发过程中常见的痛点,但这些很可能只是冰山一角,我们不禁问自己,冰山下面还有什么?本质的问题又是什么?所以,在解决问题之前,我们需要先清晰地定义问题,我给出的参考答案是:在团队合作沟通中,信息有效传递的问题!为什么是沟通问题?我们来想象这几个合作的场景:
康威定律告诉我们:“设计系统的组织必然会产生与其沟通结构相匹配的设计”,这意味着组织架构直接影响技术架构的形成。反应到实际中,这种影响最终也会显现到接口调用上,导致信息的失真,最终导致线上故障。
下图是组织的沟通架构和技术架构的映射模型,我们可以清晰地认识到一件事:信息的沟通和接口调用本质上就是一回事,即在信息传递的过程中,如何把信息(领域知识)有结构的、规范的、完整的、清晰的 沉淀下来、以及信息是否可以在整个研发流程中无损地映射,这才是本质的问题所在!
2.2 创造
答案往往藏匿在问题之中, 当问题定义的越精确,解决问题的过程反而变得简单,遇到复杂问题,分而治之,就是一个字:拆,拆成一个个可解决的小问题,一一解决后,再把它们整合到一起,那怎么拆分呢,这里我给出一个拆分的参考答案:
2.2.1 关于信息
试想你现在正在和你的好兄弟一起开发项目,当对齐接口协议时,对方反手就是一个 pb 文件,如下:
message GetUserBalanceReq {
string user_id = 1;
string user_type = 2;
}
message GetUserBalanceRsp {
int32 code = 1;
string message = 2;
int32 balance=3;
}
service testsvr {
rpc GetUserBalance(GetUserBalanceReq) returns (GetUserBalanceRsp) {};
}
你用自己大学6级时的英语水平去理解每个字段的意思...哦,user_id 大概是用户id、user_type 貌似是类型,balance 是余额的意思...不知道屏幕前的你有没有遇到过这样的场景,但是我,真真的遇到过(尤其是在项目很紧张的时候),但是细想 protobuf 只有最基础的接口定义,但是一份真正的协议(契约)远远不止接口的定义,还包括使用场景、使用限制、错误码、示例、安全、调用环境等。
所以,我们需要提出一个问题,并尝试去解答:什么样的协议算是一份优质的协议呢?在回答这个问题只前,我们先去了解一下,业界用的最多的两种协议承载方式:Protobuf 和 OpenApi。
ProtoBuf VS OpenApi
ProtoBuf 是 Google 于 2008 年对外发布的一种数据格式,旨在解决机器之间的沟通问题。起初,Google 的工程师们发现,使用 XML 和 JSON 进行数据传输的速度慢且数据量也大,因此,他们就想发明了一种通用的“语言”,能让计算机之间能够更快速的“对话”,就此 Protobuf 诞生,所以很明显,ProtoBuf 设计之初并不是让开发们去对齐接口协议,但是随着快速发展,因为其简单易懂,很多场景下就充当了文档的角色。
OpenAPI (Swagger)是2010年发布,旨在解决人与协议文档(契约)的沟通问题。起初,开发者在编写 API 时,必须手动撰写文档,这种方式既繁琐又容易出错。于是,有人提出了一个创新的想法:能否实现文档的自动生成?这就像有一个智能助手,为你自动撰写 API 的说明书。这个创意工具迅速落地,后来逐渐成为行业标准,帮助无数开发者更轻松地管理 API。
当我们清楚了背景后,很自然地意识到:ProtoBuf 并不是解决对齐协议的问题,但是随着它快速发展,逐渐变成一种开发之间默认的沟通方式。 相比之下 Openapi 则是面向用户阅读场景信息承载体,但是这距"完美"还有一段距离,如下图所示,细心的同学已经发现:他们最大的不同就是完整的信息应该有可以承载领域知识的能力!
当我们去北京中关村里给店员说:“老板,帮我拿一个最新的苹果”,店员会立马给你介绍最新的苹果16手机,而当我们去菜市场的时候,同样的帮我拿一个最新的苹果时,老板则会说:“你要什么品种呢?”。
同样的“苹果”两个字,不同的场景下,含义完全不同。我们软件开发过程中,我们依然可以借助“场景”来准确描述一个词的含义(在领域驱动中“限界上下文”、“统一语言” 也是想做类似的事)但是光有这种描述还不够,比如对于苹果手机,还有苹果16、苹果16pro、也有老一代的15,它们的价钱也不同,对于吃的苹果,也分大小、品种,所以语言概念的背后天然应该还有一种规则来描述其物理属性。
如果把这种模式迁移到接口协议上,那每一个字段应该也和“苹果“一样,有着对应的“场景”和“物理规则”,例如我们想描述用户支付接口下时间这个字段概念,可以这样表达语意:在用户支付场景下的一个时间概念,它的物理规则可能是一个时间戳、也可能是一个年月日的字符串...。我们通过“概念”+“规则”的方式去传达信息,已达到信息在传递的时候不丢失的目的,而“概念”+“规则”其实就是领域知识。
结合上文所说,接口信息其实是研发某种沟通中的一种形式沉淀,我们通过概念以及规则去沉淀大脑中的领域知识,从而达到信息的无损传递,下图是使用 uml 结构化表达了接口和概念的领域知识。
结合领域知识,下面给出这章节一开始提出的问题,一份优质的接口协议(契约)应该包括什么?
2.2.2 关于沟通
开发中沟通中的问题,往往会呈现在接口上,最后导致故障的发生,本小节讲讨论一个核心问题:为什么沟通如此之难?
首先我们看看在通信领域,机器之间是如何沟通的,如下图所示,发送源的信息是:你吃了吗?接受源如果接受的信息也是你吃了吗?就可以说信息传递是无损的,成功发送。此过程中的影响因素有三个:编码系统、解码系统、噪声源,抛开噪声源这个因素,我们可以得到一个结论:信息无损传递是因为编码系统和解码系统是配套的。
我们把这个模式迁移一下,如果我们把发送源和接收源换成人,我们就可以解释:为什么沟通很难,因为每个人的家庭背景、性格和成长经历的不同使得每个人的编码系统和解码系统不同,从而导致了信息失真。
所以统一编码和解码系统才是解决问题的关键,回到对齐协议(契约)的问题上,答案自然很明显了:在一个领域内,所有的涉众人群使用同一套标准去协作!例如我们要对一份接口契约进行评审,评审的标准就应该一致。
2.2.3 关于约束
这里就是变数比较大的部分,不同的业务场景会有不同的约束,就拿微信支付来说,在金融领域有很多的合规的要求,必须满足,没有发挥的空间,有的设计必须这样来,没有太多为什么,照做就可以。
1996年6月4日,欧洲航天局发射的阿丽亚娜5号火箭在升空仅37秒后偏离轨道并自毁,造成约5亿美元的损失。事后复盘发现,悲剧的根源在于软件中一个关键的的接口处理:将64位浮点数转换为16位整数时发生了溢出。这个“小小的”失误最终引发了这场灾难,类似的事故数不胜数,所有我们不禁问自己,有没有一种设计模式可以有效地规避类似事故的发生发生呢?答案肯定是有的,下来我们来介绍:契约式设计。
3.1 核心理念:划分责任与义务
契约式设计(Design by contract)是伯特兰·梅耶(Bertrand Meyer)在1980年 ,在开发 Eiffel 编程语言时引入的理念,目的就是打造高可靠的软件系统,而契约一词(Contract)其实来从于法律中的概念,指一种有法律约束力的协议,旨在明确协议双方应该承担的责任和义务,如果把这个概念迁移到软件设计中,目的就是为了明确各个系统在交互中应该明确的责任和义务。(推荐阅读:https://en.wikipedia.org/wiki/Design_by_contract )
契约式设计的本质就是通过结构化的描述方式去清晰表达领域知识,做到领域的沉淀,帮助系统更好的沟通,
契约式设计另一个好处就是:通过结构化的表达,可以培养开发同学对于对细节的关注,逼迫开发同学去想清楚、说清楚、写清楚!这种关注可以帮助我们有效维持复杂系统中的秩序,从而降低系统熵!如何做呢?契约式设计提出三个核心的概念,即前置条件、后置条件、不变式,开发人员在定义协议的时候首先需要反问自己三个问题,从而得到这三种结构化定义。
3.2 举个栗子
我们举一个简单的例子来说明其概念:实现一个开根号的函数,如何使用契约式定义和开发,代码描述如下:
/**
* @brief 计算非负数的平方根
*
* @param number 要计算平方根的非负数
* @return double 输入数的平方根
*
* @Precondition 输入数必须是非负的(number >= 0)
* @Postcondition 返回值必须是非负的,并且其平方应等于输入数
* @Invariant 输入数在函数执行过程中保持不变
*
* @throws std::invalid_argument 如果输入数为负
* @throws std::logic_error 如果计算结果不符合预期
*
* @Example
* 例如,calculateSquareRoot(4.0) 将返回 2.0,因为 2 的平方等于 4。
*/
double calculateSquareRoot(double number) {
// 前置条件: 输入数必须是非负的
if (number < 0) {
throw std::invalid_argument("输入数必须是非负的");
}
// 不变式: 输入数在计算过程中保持不变
double result = std::sqrt(number);
// 后置条件: 结果必须是非负的,并且其平方应等于输入数
if (result < 0 || std::abs(result * result - number) > 1e-9) {
throw std::logic_error("计算结果不符合预期");
}
return result;
}
在这个过程中,我们的目标是实现一个开根号的函数,通过提问的方式问自己函数的期望是什么?需要保证什么?需要维护什么?从而推导出接口契约,再而提供完整的接口定义、以及代码实现。
3.3 防御性编程与 AI 时代的解法
如果用一句话概括契约式设计就是:使用结构化的方法控制好输入和输出,前置条件就是控制好输入的体现,对于调用系统来说,需要满足其前置条件,才可以完成预期的功能,对于被调系统来说,应当采用互不信任的原则,依据前置条件进行防御性编程。而防御性编程其实就是底线思维的一种体现,逼迫我们去反向思考软件异常情况,我们最不能容忍的是什么?从而更好地构建高可靠的软件系统,以下是契约式设计给出系统之间的交互模型,如下:
在大多数软件事故的复盘中,我们发现问题往往不是由于缺乏优秀的架构设计,而是因为一些最基本的校验没有做到位。比如,一个接口有三十个字段,需要为每个字段编写对应的异常处理代码。很多时候,我们倾向于先实现正向的功能流程,确认无误后再补充异常处理。然而,这些异常处理往往被遗忘,导致上线后出现问题。妥妥的人性的弱点啊!
在过去,我们常常需要编写一些简单、繁琐但重要的代码,但到现在,在大模型时代,这类工作完全可以交给 AI 来处理,像 Copilot 和 Cursor 这样的辅助工具能够很好地完成这些任务。这些工具就像是你的助手,帮助你处理那些相对简单但重要的事情。你的角色从编写代码的人转变为审视代码的人,这种角色转换的感觉非常奇妙。不得不说,AI 一定程度上帮助了我们克服了人性的弱点。
不光是一些简单的代码,一份优秀的契约+大模型可以发挥出更大的作用。已契约为数据,大模型可以自动帮你生成测试代码、业务代码,以及接口契约,大量重复的工作将被替代,开发人员只需要在业务代码上做出少量的补充即可。
3.4 局部与整体:广义的契约
上文中,我们聚焦在“接口契约”上,讨论了如何使用契约式设计来构建高可靠的软件,现在我们把视角再放大一些,站在整个研发流程上,去考虑“契约”这一概念。
很明显,“接口契约”只是整个研发流程中设计的其中一环,而整个设计应该细化为:业务建模、分析、设计三阶段,这三个阶段对应着三个核心问题:组织应该为用户提供什么样的价值?为了实现这些价值,系统应该具备什么功能?以及实现这些功能应该使用什么技术?这些问题层层递进,而回答如何设计这个问题的结构化表达,都是契约!
作为一个开发同学,很多时候我们可能接收到的是一个模糊甚至错误的需求,惯性思维下,我们第一反应可能是立马陷入到了如何实现,比如用什么存储、怎么保证事务、一致性、如何处理异常...这种在局部去考虑问题的方式,很容易让我们忽略真正的需求是什么?我们到底要为谁解决什么问题。如果从一个错误的需求出发,越努力死的越快,局部越优,整体越差,我们缺少质疑需求的勇气,质疑需求不是为了挑战,而是为了逼迫自己去思考真正的需求是什么?。
契约式设计,广义上来说,它给我们提供了一种方法论:可以让我们有序的思考真正的问题是什么?并针对此问题进行有序的解决,让研发流程中的各个环节,有法可依、有迹可循!
-End-
原创作者|吕昊俣
你是怎么解决日常工作中的沟通问题的?欢迎评论留言。