摘要
对于公有云服务,API 是客户接入使用的主路径之一,API 的质量和持续稳定性至关重要,在使用者角度,代表着云服务的质量和稳定性。如何妥善管理 API 以及基于 API 的 SDK, 如何在产品快速发展的同时,持续的保障 API 的稳定性、可靠性,是一个非常大的挑战。
UCloud 接入产品开发团队在 2019 年下半年,通过工程化的手段,打通了 API 管理、测试管理、网关日志、Gitlab CI 等内部系统,以 API 团队的契约为中心改造了整条工具链产品的发布链路,对 API&SDK 进行闭环校验,持续保证质量。并对基于 Github 的开源项目构建了通用的发布管道和版本策略。
当前工程化共支持了 16 个产品 300+ API SDK 的日常发布,以及 SDK 侧长达半年的每日回归测试。从需要 1 人天 /产品的人工开发成本,到 5 分钟完成一次新产品 SDK 代码生成,工程效率产生了质的飞跃。
本文介绍了 UCloud 在 API/SDK 工程化改造上的一些实践,以供大家交流与参考。
概览
1、为什么需要进行围绕 API/SDK 的工程体系建设
UCloud 接入产品团队负责研发工具链产品与网关,探索控制台以外的基础设施接入方式。陆续发布了包括 Terraform、Packer、CLI、移动网络探测等接入工具并全部开源。工具链产品由于便于使用,更符合现代软件开发的理念等特点,成为了客户 DevOps 团队接入的最主要手段。
怎么定义工具链产品?首先对于云厂商来说,与 Restful 等面向资源的 API ( Resource-Oriented )风格不同,UCloud 开放的 OpenAPI 大多是面向行为( Action-Oriented )的。OpenAPI 除了实现 CRUD 之外,还负责发送各种各样控制平面( Control Plane )的指令,如资源的伸缩启停。
那么对于 UCloud 来说,工具链产品的价值在于,API 实现最小颗粒度的行为,工具链产品组合这些行为,进行更高层次的抽象。比如 Terraform 对于资源依赖图的抽象,CLI 对行为的级联和批量操作,Packer 对镜像的生命周期管理等等。工具链产品构建在 API/SDK 之上,是 API/SDK 的高层次封装。
图 1:工具链产品示例
工具链产品区别于控制台的特点在于,工具链产品实践了基础设施即代码( IaC,Infrastructure as Code )这一理念,设施与行为都可以使用代码来严格地描述与版本化,易于集成。同时工具链产品在运行时的生命周期是全自动的,在实际的应用场景中,当我们定义好一个资源拓扑,或者基于工具链产品编写了一个脚本之后,我们期望它的运行过程是全自动的,不需要人工干预。
由于上述两个特点,工具链产品对外的接口必须保证是严格的,不能随意变动,这就要求 API 也必须是严格的。同时对于公有云 API/SDK 可用性的重度依赖,使得我们不得不寻求一些工程上的方法,来确保工具链产品的整个生命周期都是可用的。
2、面临的问题
• 开发容易变更难,工具链产品包括几乎所有 IaaS 产品的流量接入,日常变更十分繁琐,需要自动化变更。
• 工具链产品全线开源,作为知名的云厂商,必须保证开源社区的核心指标,即 90% 覆盖率,A 以上的代码风格评级,需要有成熟的机制来自动化验证。
• 相比友商,我们的团队规模小很多,需要尽可能复用现有设施,对系统变更最频繁的部分做自动化生成,充分发挥技术带来的效率红利。
全景图
为了解决上述问题,团队在 2919 年下半年开始进行 API/SDK 工程体系的建设,第一期的目标是使工具链产品所依赖的 SDK 实现全自动化的代码生成。
图 2:API/SDK 工程体系建设全景图
从图中可以看出,工具链产品团队构建了一个通用的发布代理服务,用来执行 CI Job、处理相关的发布任务,并通过机器人与 Github 做了打通。
相关技术解析
1、API 建模
为了使 API 能够得到统一管理,同时防止产品间竖井式的信息隔离,UCloud 测试平台部在 2017 年底打造了公共的 API 管理平台「优效」,将所有现网 API 的定义收敛至统一的 API 注册中心上,使用自定义的格式来形式化地描述 API。
在接下来的一年时间里,优效添加了测试管理功能,支持了一种基于 DSL 表达式的行为驱动测试开发模式,测试团队在优效上编辑测试步骤,使用 DSL 完成数据的抽取转换和对比,构建测试场景并执行。
2018 年下半年,我们团队开始进行工具链产品的研发,包括资源编排、CLI 及其依赖的全线 IaaS 产品控制平面 OpenAPI SDK,发布了第一版基于优效自动生成的 Go SDK。
2019 年下半年,我们通过跨部门协作对测试表达式进行了中间表示生成,实现了将 DSL 表达式完整地转译成各语言 SDK 调用代码,完成了与测试团队的工作流整合。
图 3:API、SDK、测试相互依存
这一系列工作的意义在于,API 作为企业与云厂商之间交互的契约,需要具有实际的效力,而这个效力是需要一些工程上的手段来确立的。API、测试与 SDK 相互作用,构成一个有机的整体,才能使这个契约具有实际的效力。
2、SDK 代码生成
代码生成是编译器领域的一个典型问题,目前各界对于相关技术已经有了很充分的研究,而 SDK 生成使用的技术并不复杂。这里只是简单分享一下,UCloud 在代码生成过程中使用了哪些技术,以及如何将一个不规则的遗留问题,通过形式化的方法转化为一个已知领域的问题的思路,希望对大家有所启发。
2.1 调用方代码生成
由于云服务 SDK 对于编程语言类型系统中,类型安全的需要,云服务的 SDK 除了提供一种万能的,泛化调用方式(就像各语言的 HTTP Client 一样),还会将每一个 API 的参数和返回通过类型系统显式的定义出来。比如声明成 Python/Java 中的类,Go 中的结构体。
得益于测试平台部在优效上的早期工作,调用方代码只需使用模版引擎将目标代码渲染出来即可。对于一些版本割裂比较严重的语言,如 Python 2/3,我们选择利用语言原生的抽象语法树来对代码进行二次剪枝处理。
由于众所周知的原因,Python 2 原计划于 2020 年 1 月停止维护,虽然受一些原因影响,停止维护的时间向后延期了一段时间,但 Python 2 的寿终正寝已成定局。但作为云厂商而言,依然存在不少客户的存量系统依然在使用 Python 2,其中不乏大体量的客户,存量系统改造对他们来说,是一个风险收益很难权衡的问题,所以云厂商依然要做好长期支持 Python 2 的准备。
图 4:Python 3 to 2 语法树剪枝
UCloud 对于 Python 2/3 SDK 的生成方式是,首先生成 Python 3 的代码,之后将 Python 3 的代码转换成抽象语法树( AST,Abstract Syntax Tree ),之后遍历这棵 AST,移除其中 Python 3 Only 的节点,如 type hints,添加一些兼容性的节点,例如新式类 object 基类,utf8 header 等等,最终从这棵树中重建出 Python 2 SDK 的代码。
2.2 测试代码生成
优效测试模块由测试团队于 2018 年开发上线,使用一种可视化配置来编写测试,遵循行为驱动开发的理念( BDD,Behavior-driven development ),使测试定义与实现解藕。
由于接入产品团队对于 API 的重度依赖,如果想保证工具链产品的可靠性,必须对依赖的 SDK 进行充分测试。而且由于 SDK 原则上要覆盖所有公有云 IaaS 产品,出于成本和责权划分等因素的考虑,复用现有存量的测试用例是唯一可行的验证手段。
图 5:测试结构抽象
类似于 ThoughtWorks 的 3S ( Specification、Scenario、Step )抽象,UCloud 将测试抽象为测试解决方案( Solution )、测试集( Set )、测试步骤( Step ) 三个层次。多个测试步骤( Step )构成一个测试集( Set ),顺序执行;多个测试集构成一个解决方案( Solution ),并行执行。
每一个测试步骤由请求、绑定和验证三个部分构成,并包含一些控制类属性如重试、延时、快速终止等。以处理片状测试( Flaky Testing )的场景。
测试步骤中请求、绑定和验证的值都可以使用一种 DSL 表达式来编写,可以处理一些复杂逻辑,如获取时间戳、四则运算、数据抽取等。
测试代码生成的难点就在于,原有的测试执行引擎是使用 Python 解释执行的,类型系统比较薄弱,同时由于没有通过形式化的文法定义表达式,随着时间的推移,表达式变得越来越不规范,如何将 DSL 表达式,转译成 SDK 的测试代码,成了一个巨大的挑战。
一个测试表达式的示例如下,可以看出这个表达式的语法还是比较简单的。
${u_eval(${u_get_timestamp(10)} - 3600)}
对于形如上式的简单表达式解析,有两种思路,一种是直接手写一个递归下降的解析器,将词法分析和语法分析一并完成,我们早期也是这么处理的。
但是随着时间的推移,我们发现不仅仅只有上述那一种表达式形式,更多不规范的表达式写法被挖掘出来,我们意识到,一味改 Parser 并不是一个长久的办法,应该将文法清晰地定义出来,形成一个共识,只复用那些较为规范的测试集,这样才能应用在多种不同的编程语言上。所以我们回归了传统的写法,手写 Lexer,通过 Yacc 生成 Parser。
图 6:语法分析构造语法树
Golang 写 Lexer,参考了 Rob Pike 2011 年的 Slide,《 Lexical Scanning in Go 》,这里不再赘述,其核心思想就是将状态转移的动作抽象成函数,描述出在 One Pass 过程中,遇到每个字符时的状态转移动作。下图是 Lexer 过程中的状态转移:
图 7:Lexer 状态机
Parser 的部分我们使用了 goyacc,通过定义好的文法,生成解析器代码,解析器的输入是词法分析阶段产生的 Token,输出是一棵 JSON 格式的表达式语法树,下图是文法和语法树的示例:
图 8:语法树示例
有了 JSON 格式的中间表示,我们在任何语言中,读出这棵语法树,之后用简单的模版引擎,就可以渲染出想要的目标代码,生成 SDK 的测试代码。
3、运行时抽象
在 SDK 的代码中,有一部分代码是公共的,并且极少变更,实现了请求序列化、重试、日志、错误处理等等,对于这部分代码需要一个统一的运行时抽象来减少开发成本。SDK 的本质是请求的生命周期管理,该场景有一个典型的抽象:
图 9:洋葱圈模型
图片来源:Egg.js 文档《异步编程模型》小节
洋葱圈模型是面向切面编程( AOP )的经典应用,业务逻辑作为洋葱的一层表皮,当一个请求发起的时候,经过一层一层的前侧的洋葱表皮,例如参数注入,签名算法等,到达洋葱中心,请求响应时,从洋葱中心再经过后侧的洋葱表皮例如日志、重试、错误处理等,返回结果。
这样做的好处在于,业务逻辑与请求生命周期是完全正交的,可以更加灵活地添加或删除业务逻辑。例如客户可以自定义 API 返回错误时它的公共处理行为,注入自定义模块等等,保持业务逻辑是易理解,可测试的。
4、持续发布
OpenAPI SDK 项目包含几乎所有 IaaS 产品的 API 变更,作为一个典型的高频变更业务,项目早期面临以下问题:
• 手工发布有出错风险,虽然可以补救,但会给客户造成困扰
• 研发负担大,开源项目发布非常繁琐,需要变更版本号、标签、README、Release Note 等,浪费研发资源
• 仅有自动化测试,没有自动化变更,造成二者会产生不一致现象
因为相比友商,UCloud 的人员数量少很多,所以更需要充分发挥技术带来的效率红利。接入产品部最终选择了使用 Gitlab CI + Github Bot 来对 SDK 等开源工具链产品进行全自动化发布。
5、流水线设计
首先,我们将 SDK 发布拆分为日常发布与窗口发布,将大量具有中断性质的审查任务移动到每周的固定时间进行处理,保证团队在高工作负载下依然能够保证足够的研发能力。并对两种发布任务分别设计了全自动化的发布流水线,如下图所示:
图 10:日常发布流程
图 11:窗口发布流程
日常发布任务会执行日常的代码生成和测试工作,合入代码仓库。在窗口发布任务中会使用 Github 机器人进行版本冻结和发布工作,并推送 SDK 到各语言官方制品仓库中。
6、版本策略
可以看到,在每周的发布窗口内,如果有发布任务,SDK 将对版本进行自动化变更,版本号遵循以下策略自动计算:
• 合并 PR 前,引导代码贡献者提供格式化的描述信息
• 每次发布时,归并上次发布之间所有 PR 的描述信息
• 通过 Pull Request Comment 计算版本
• 新特性,新产品:主版本+1
• 特性增强,新 API:次版本+1
• 问题修复,更新 API:补丁版本+1
• 如果是预览版,则主版本号锁定为零,从次版本号开始递增
例如:
图 12:版本自动计算规则
7、服务化
在做 SDK 相关能力的时候,常常有团队希望使用 SDK 与兄弟团队联调,或在产品内测时使用 SDK 自我验证,这就需要 SDK 能够提供主动发布能力,由外部团队触发构建,并生成预览版。
对于这种场景,接入产品团队对发布流程进行了服务化改造,提供了一个简单的代理服务,用来触发 CI Job:
图 13:代理服务拓扑
CI/CD 服务化带来的收益包括,发布流程的标准化、模块化,可复用性显著提升,发布流水线本身作为业务领域统一建模,使得发布本身是可维、可测的。并且给将来更进一步的改造打下坚实的基础。
总结与展望
在过去的半年中,UCloud 重新梳理了 API 模型,添加了 SDK 通用抽象,使用编译器前端和模版技术对 SDK 代码生成问题进行了统一建模,将发布流水线自动化,完成了 API/SDK 能力的第一期工程体系建设。
下一阶段的目标是,将 API/SDK 工程化的能力集成到 Git 工作流中,使得任何人都可以自助地进行发布,而无需特定的团队参与。我们在每一个阶段的工作都会通过文章记录并分享出来,给业界有类似场景的小伙伴分享实际落地的经验以供参考,希望可以对大家有所帮助。