前往小程序,Get更优阅读体验!
立即前往
发布
社区首页 >专栏 >代码千行不如架构图一张!论契约平台的设计与思考

代码千行不如架构图一张!论契约平台的设计与思考

作者头像
腾讯云开发者
发布2024-11-28 13:32:06
发布2024-11-28 13:32:06
5090
举报
文章被收录于专栏:【腾讯云开发者】

在程序员的日常工作中,解决技术问题往往是最后要做的事情,而在此之前总是要面临诸多跨服聊天的无效沟通:你这个文档怎么没更新?变更了我怎么不知道?这乱七八糟的错误码都是啥意思?我们拉个会对齐一下?

这些问题往往牵扯着大量的研发精力,却事倍功半。有没有一种办法能从架构、系统的层面上去做一些规避、约束?这是本文想去尝试解决的问题。

12 月 5 日晚 7:30,腾讯云开发者视频号「鹅厂程序员面对面」直播间,我们邀请了作者来为你分析《契约平台的设计与思考》,预约观看有机会抢鹅厂周边好礼!

01、开发的窘境

我有两个朋友:小明和小红,一天他们在一起做项目,遇到了一些问题...

1.1 假文档?

1.2 变更了、我怎么不知道?

经过了一段时间反反复复的验证,小红和小明的代码终于上线了,可是上线后没多久就出了故障...

1.3 错误码是啥意思啊?

在服务上线后的第二天半夜二点三十五,小明在梦中被告警电话吵醒,打眼一看报错信息,下游返回失败,收到了如下信息:

代码语言:javascript
复制
{    
"code": 71756425,
"message":"系统繁忙"
}

小明心想:这什么玩意?于是找到了下游的同学 A,下游 A 同学一看,这也不是我的报错啊,这是下游的错误码,我是透传,于是三更半夜开始了找人活动…

1.4 尝试去解决

以上的场景,对于开发同学来说:懂得都懂,它们如此常见,却如此难搞,让我们再分析一下

  • 假文档场景:这里暴露出了很多问题,比如在沟通协议的时候,没有标准的姿势,一个 json、一个 pb、一个简陋的 iwiki、甚至一句话,都可以是一个协议,开发过程中,协议如果发生了变动,“偷偷”一改,上游同学压根不知道,这无疑大大增加了线上的风险。
  • 变更场景:在变更场景中,服务的上游可能有很多,下游的服务协议变更,是否都符合线上的预期?影响面多大?不知道!例如协议中删除了一个字段或者加了一个必填参数,这势必涉及到上游系统的改动,但可怕的是,有的时候上游都有谁,你都不知道。一变更,故障就来了。
  • 错误码场景:服务的报错信息,压根不知道是啥意思,有些人喜欢把下游的错误码包装成自己的风格,有些人则直接透传下游的错误码,没有统一的规范,各个团队各自为营,到了线上出问题时,要搞清楚这些错误码到底来自哪里、是什么意思,过程简直如侦探破案。

说到这里,可能有的读者就会说,貌似这些问题也不难啊,我们只要养成好习惯,例如接口先行、及时的更新文档,以及提醒要关注的人;变更接口时,保证自己的服务一定是兼容式更新;对于错误码,我们一定要把所有的错误码写清楚,下游服务错误码的返回一定要确认清楚,给上游抛什么也要定义好....

这种方式咋一听,确实有道理,但是细想,完全不行,因为字里行间都写满了两个字靠人,但不幸的是,是人就会犯错 , 开发是人,不是神!有的时候不是不知道解决方案,而是要对抗自己人性中的弱点,去做都懂但是很难做到的事,就像我知道晚上吃宵夜不好,但有的时候还是会去吃!

所以,我们就想,有没有这样一款产品,可以很好地解决开发中的这些痛点。

02、像产品一样思考

和很多优秀的产品一样,人们通过观察和思考去捕捉生活中的现象,从中发现问题,然后通过创造“产品”去解决问题,从而产生价值,下面我们也按照这两点展开。

2.1 洞察

在第一节(开发的窘境),我们列举了在开发过程中常见的痛点,但这些很可能只是冰山一角,我们不禁问自己,冰山下面还有什么?本质的问题又是什么?所以,在解决问题之前,我们需要先清晰地定义问题,我给出的参考答案是:在团队合作沟通中,信息有效传递的问题!为什么是沟通问题?我们来想象这几个合作的场景:

  • 团队内部 在一个小型团队中,你和合作了五年的同事一起完成一个功能,由于彼此非常了解,配合默契,沟通变得简单高效。在对齐技术细节时,你们可能只需要一句话:“嘿,brother,在这个接口加两个字段就可以”不久后,新功能完成并顺利上线。
  • 跨团队 在跨团队项目中,沟通难度显著增加。信息经过多层传递容易失真,协调多方资源也变得复杂。不同的工具和流程增加了整合难度,进一步加剧了复杂性。这些问题可能导致信息传递错误,引发线上故障。
  • 跨组织 跨组织合作时,遇到的问题可能更为复杂,不同组织的文化不同、开发流程和技术栈不同,很多技术标准规范很可能不一致。如何更好的整合标准以及有效的信息传递变得无比重要,如果处理不当,就会导致数据失真,最终也会引发线上故障。

康威定律告诉我们:“设计系统的组织必然会产生与其沟通结构相匹配的设计”,这意味着组织架构直接影响技术架构的形成。反应到实际中,这种影响最终也会显现到接口调用上,导致信息的失真,最终导致线上故障。

下图是组织的沟通架构和技术架构的映射模型,我们可以清晰地认识到一件事:信息的沟通和接口调用本质上就是一回事,即在信息传递的过程中,如何把信息(领域知识)有结构的、规范的、完整的、清晰的 沉淀下来、以及信息是否可以在整个研发流程中无损地映射,这才是本质的问题所在!

2.2 创造

答案往往藏匿在问题之中, 当问题定义的越精确,解决问题的过程反而变得简单,遇到复杂问题,分而治之,就是一个字:,拆成一个个可解决的小问题,一一解决后,再把它们整合到一起,那怎么拆分呢,这里我给出一个拆分的参考答案:

2.2.1 关于信息

试想你现在正在和你的好兄弟一起开发项目,当对齐接口协议时,对方反手就是一个 pb 文件,如下:

代码语言:javascript
复制
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 关于约束

这里就是变数比较大的部分,不同的业务场景会有不同的约束,就拿微信支付来说,在金融领域有很多的合规的要求,必须满足,没有发挥的空间,有的设计必须这样来,没有太多为什么,照做就可以。

03、契约式设计

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 举个栗子

我们举一个简单的例子来说明其概念:实现一个开根号的函数,如何使用契约式定义和开发,代码描述如下:

代码语言:javascript
复制
/**
 * @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 局部与整体:广义的契约

上文中,我们聚焦在“接口契约”上,讨论了如何使用契约式设计来构建高可靠的软件,现在我们把视角再放大一些,站在整个研发流程上,去考虑“契约”这一概念。

很明显,“接口契约”只是整个研发流程中设计的其中一环,而整个设计应该细化为:业务建模、分析、设计三阶段,这三个阶段对应着三个核心问题:组织应该为用户提供什么样的价值?为了实现这些价值,系统应该具备什么功能?以及实现这些功能应该使用什么技术?这些问题层层递进,而回答如何设计这个问题的结构化表达,都是契约!

04、写在最后

作为一个开发同学,很多时候我们可能接收到的是一个模糊甚至错误的需求,惯性思维下,我们第一反应可能是立马陷入到了如何实现,比如用什么存储、怎么保证事务、一致性、如何处理异常...这种在局部去考虑问题的方式,很容易让我们忽略真正的需求是什么?我们到底要为谁解决什么问题。如果从一个错误的需求出发,越努力死的越快,局部越优,整体越差,我们缺少质疑需求的勇气,质疑需求不是为了挑战,而是为了逼迫自己去思考真正的需求是什么?。

契约式设计,广义上来说,它给我们提供了一种方法论:可以让我们有序的思考真正的问题是什么?并针对此问题进行有序的解决,让研发流程中的各个环节,有法可依、有迹可循!

-End-

原创作者|吕昊俣

你是怎么解决日常工作中的沟通问题的?欢迎评论留言。

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

本文分享自 腾讯云开发者 微信公众号,前往查看

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 01、开发的窘境
  • 02、像产品一样思考
  • 03、契约式设计
  • 04、写在最后
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档