B站大型开播平台重构
本期作者
赵书彬
哔哩哔哩高级开发工程师
王清培
哔哩哔哩资深开发工程师
傅志超
哔哩哔哩资深开发工程师
郭宇霆
哔哩哔哩资深开发工程师
朱德江
哔哩哔哩资深开发工程师
1. 背景
"凡事预则立,不预则废。"——《礼记·中庸》
在文章的开头,我们可以先来了解一下直播业务的大致业务架构。将直播业务简单分为两大类场景"看播"、"开播",前者主要面向C端观看用户,后者主要面向B端开播主播。主播通过"开播工具"的开播产品功能,经由"开播平台"完成一系列开播动作,最后将媒体信息采集推送到多媒体服务器,C端观看用户就可以从CDN看到直播的视频流内容。
从数据流向来讲,"开播"场景是产生数据和触发关键事件的源头。这些数据或事件会涉及多个领域,如安全合规信息、房间信息、主播信息、开播场次信息、安全审计信息、多媒体信息等。
打个不太准确的比喻。开播系统对于直播平台的重要性,等同于订单系统对于交易平台的重要性。开播工具作为播端功能入口,直接面向官方开播工具(直播姬、粉版大加号、三方工具如OBS开播)的用户以及内部平台方的用户(其他业务线、产品&运营),对开播体验负责。开播平台在其中的职责,是向开播工具和其他平台方提供开播相关的平台化业务能力,如开关播、开通直播间、切换分区等。
同时,开播平台与同级的业务平台一起协作,才能支撑起完整的开播工具产品能力,如语聊房业务需要开播工具管理平台(开播工具类支持)、主播互动平台(主播互动能力支持)、流媒体服务端共同参与才能完成,从不同的维度帮助开播工具生态完善化。
一些涉及到的业务/技术名词,在此我们也做出列举并做出简单介绍:
名词 |
名词简述 |
领域驱动设计(DDD) | DDD 是 Domain-Driven Design 的缩写,是 Eric Evans 于 2004 年提出的一种软件设计方法和理念。
其主要的思想是,利用确定的业务模型来指导业务与应用的设计和实现。主张开发人员与业务人员持续地沟通和模型的持续迭代式演化,以保证业务模型与代码实现的一致性,从而实现有效管理业务复杂度,优化软件设计的目的。 |
领域知识 |
(领域驱动设计概念)指能准确传达业务规则的描述,也是领域中业务知识的集中体现。 理想状态下,领域专家和编码人员对业务的认知应该完全一致,就算不同的人写代码也应该偏差不大。 |
领域事件(Domain Event) | 领域事件,是在业务上真实发生的客观事实,这些事实对系统会产生关键影响,是观察业务系统变化的关键点。对于开播而言,”房间已经被主播主动流转为开播了”就是一个领域事件。 |
视频云(直播) | 一般指广义的直播流媒体业务,提供主播推流、观众拉流的基础能力。 |
看播 | 一般指广义的直播观看业务域,涉及进房、弹幕互动、礼物打赏等业务场景,一般面向C端(观众) |
直播姬 | 一般指移动端APP”哔哩哔哩直播姬”,以及Windows系统下的”哔哩哔哩直播姬”应用。(注:哔哩哔哩粉版APP和Web页面也提供了开播能力) |
测试驱动开发(TDD) | 测试驱动开发是一种软件开发过程中的应用方法,由极限编程倡导,以其倡导先写测试程序,然后编码实现其功能得名。本文中主要涉及ATDD集成测试驱动和UTDD单元测试驱动。 |
战略设计 | 战略设计也称为战略建模,是指对业务进行高层次的抽象和归类。主要手段包括理清上下文和进行子域的划分。 |
战术设计 | 战术设计也称为战术建模,是指对特定上下文下的模型进行详细设计。我们对开播新的微服务中各个模块职责的编排,就是战术设计的一部分。 |
开播平台/开播服务平台 | 一般指狭义的后端业务,即提供开播房间状态流转、直接对客户端提供推流信息的服务端业务。提供如开关播接口、推流地址获取的通用业务接口。 |
开播工具 | 一般指广义的可进行开播的业务场景,如直播姬、Web主播中心、粉APP开播等,相较于开播更偏向于端到端场景 |
开播 | 一般指广义的开播业务域,涉及开播、关播等业务场景,一般面向B端(主播) |
事件风暴(Event Storming) | 事件风暴是一种捕获行为需求的方法,类似传统软件的开发用例分析。所有人员(领域专家和技术专家) 对业务行为进行一次发散,并最终收敛达到业务的统一。 |
事件溯源(Event Sourcing) | 事件溯源是一种用事件日志追溯状态的方法,因此事件溯源的关键在于事件日志。对于开播而言只是借用了”溯源”这种思想,用于保证新旧开播链路的关键状态完全一致。 |
SOP | Standard Operating Procedure,标准作业程序。本次重构过程,发布、应急处理、故障处理都使用此种方式进行推进。 |
room / room-service | PHP历史服务,本次被迁移的主角。众多历史业务在该服务中。 |
live-streaming / streaming | 新开播微服务,今后承载开播领域主要业务的落地实体。 |
app-blink | 开播工具网关层微服务,直接承载客户端的请求。 |
1.1 现状和挑战
直播开播系统,伴随着B站直播的成长贯穿始终。
发展初期所有的直播业务基本都在一套php代码里完成,包括开播部分。之后的直播高速发展中,很多模块已经顺利完成迁移。
开播部分也尝试过迁移,但是未能成功完成。还不太幸运的出了比较严重的线上故障。(这给后面的再次重构积累了宝贵的经验。)
1.1.1 债务清单
-
业务积累厚:最初的代码大致是从2017年开始的,要问里面的门道究竟有多少,可能另起一篇文章也难以详尽。
-
代码可读性差:php是弱类型+动态类型特点,代码可读性方面有非常大的挑战。同时因为涉及到跨语言迁移,需要有机制能检查两边逻辑和数据的一致性。
-
开发模式陈旧:php代码在整个开发架构上,也是偏"事务脚本模式"。多个领域混杂在一起,互相耦合调用,解耦异常困难。
-
质量配套欠缺:单元测试和自动化测试方面也比较缺乏。要想顺利完成重构迁移,这块是重要的前置工作。
-
技术栈滞后:php技术栈,已经不符合公司的整个技术栈主路线。各种lib、中间件支持方面欠缺,急需技术栈升级。
1.1.2 遗留系统特征
业界对遗留系统的普遍定义中有4个关键字:旧、过时、重要、仍在使用。
事实并非完全如此:有些系统时间虽长,但如果一直坚持现代化的开发方式,在代码质量、架构合理性、测试策略、DevOps 等方面都保持先进性,这样的系统就像陈年的老酒一样,历久弥香。而有些系统虽然刚刚开发完成,但如果在上述几个方面都做得不好,我们也可以把它叫做遗留系统。遗留系统在维护成本、合规性、安全性、集成性等方面都会给企业造成巨大的负担,但同时也蕴含着丰富的数据和业务资产。我们应该对遗留系统进行现代化,让它重新焕发青春。
显然在知晓了旧开播系统有诸多历史债务后,我们可以认为它确实是一个摇摇欲坠的遗留系统。而我们本次的目标,就是将开播平台这个重要的遗留系统进行重构,让它"焕发新生",并让他在可预见的未来中都维持现代化系统的标准。
1.2 安全生产
在开播系统的维护、迭代、演进中,我们也致力于系统的"安全生产"问题:
-
如何降低研发的业务认知成本、沟通成本,降低复杂度,从而提高"卡车系数",保证团队内部能保证形成快速backup?
-
如何通过技术演进,增加开播系统的可拓展性/鲁棒性/可测试性?
-
迁移新系统时,新老系统如何优雅安全切换、过程中的新旧系统数据是否可以进行白盒对比?
2.开播系统架构演进
每个士兵在上战场前必须清楚的明白,他这场小小的战斗在大局中起的作用。——伯纳德 · L · 蒙哥马利(英国)
2.1 审视:问题出在哪里?
在着手进行改造升级之前,不妨先从整体业务的迭代流程和已有架构中找到问题,以确定真正值得树立的目标,避免陷入"只见树木不见森林"的狭小视野中。
我们不难发现,这个日积月累的遗留系统当中,它的业务研发流程种种令人难以忽视的问题:业务知识、业务架构的认识遗失、产研语言的不统一等等。
2.1.1 业务知识与业务架构的生命周期
开播域作为播端的核心业务域,由于其悠久的历史和维护团队同学的变更,在几经周折后,领域知识已经处于混沌状态。这种情况下,显然比起遗留代码和不合理的实现逻辑而言,更大的bug可能最终会发生在人身上,也就是我们对业务知识本身的认识:对业务知识缺乏了解,往往是拖慢业务迭代甚至是酿成线上事故的罪魁祸首。
对于业务架构的认知遗失,则会导致业务域内职责的混乱:“这个新增业务是否应该由我们负责?”。落实到开发者身上就变成了应用架构的混乱:“这个业务我们到底应该写在哪个微服务里?”
最明显最集中的问题会爆发在端到端用例中:战略设计上一个实体上业务行为的不清晰往往代表着一个甚至多个端到端用例的认知缺失,映射到战术实现上就会演变成灾难性的"需求引入变更时,未考虑到某个用户用例",最终在上线前的验收环节甚至是上线后,发现这个需求的引入导致了bug的产生。
我们当然可以把这种事故归结为“历史遗留问题”,但是对于功能的使用者而言,这种糟糕体验会直接让平台被贴上“不专业”的负面标签。对平台本身而言,这种灾难性的错误堆砌也只会让系统不断熵增,复杂程度愈发不可收拾,最终花费在处理问题、历史代码考古上的人力一增再增缺无济于事。
这部分无疑是开播重构项目中,最迫切需要解决的问题。
2.1.2 描述语言不统一
在业务人员和产品的角度来看,"开播"这个用例往往和各端开发人员所说的"开播"又有着某种微妙的差别。业务视角下的开播,往往是用户一次完整的开播体验,比如,打开移动直播姬,调整好各种用户设置,点击开播,最终看到自己的画面被正确投放到b站直播间,并且可以完成后续和观众的互动。
而在技术视角下的开播,"开播"是各执行方的横切面组成的:客户端完成最直接的ui/ux互动、直播服务端进行用户请求校验、视频流和直播业务数据的协调、视频云负责接收用户的上行视频流;每一方对"开播"的这个词解释就产生了差异:客户端进入到直播界面并点击开播叫开播,服务端的开播接口被调用了也被视为开播,视频流被推送到视频云上行服务器的时候也可能被视为开播。
泛泛而谈的话,各方的解释都没有太大问题,但是这样的解释无法确切指定它在业务里处于哪一部分,会造成什么结果。最终呈现在一位新进入技术团队的同学的眼中可能是这样的场景:
举例
客服:主播反馈线上无法开播。【问题平台】PC直播姬; 【一级分类】开播; 【二级分类】无法开播; 【问题描述】主播反馈进入移动直播姬开播界面后,点击开播后,不能正常推流;
开发1:是不是开播了多次?
客服:不是,主播开播了一次
开发1:那可以让用户重试
开发2:是不是视频云推流服务出了问题?@视频云
客服:用户已经重试了,还是不能正常开播(其实是在另一台设备上已经推流了,还在尝试使用其他设备推流)
开发3:视频云看到用户推流是正常的 (推流监控图)
开发1:哦,原来是重复开播了
假设我是一位团队新成员,在看到最终输出的"重复开播"结论之前,得到的都是点状的信息,没有完整的用例以供参考,难以理解线上问题的症结在何处。如果这个时候甚至没有文档来描述开播领域相关业务,或者是开播流程的场景快照,那更是一场新人的灾难——可能需要专门请教团队中熟悉开播领域的资深开发为他进行讲解才能瞥见开播业务的一隅,且授课效果还要取决于讲述人的结构化叙述能力,这是我们从效率考量上不愿意见到的。
可以举一个贴近实际开发人员的例子,"请教了3位同事才知道了开播记录是怎么产生的"、"请教了3位同事才本地构建成功",诸如此类的尴尬在日常工作中屡见不鲜的,实际上这类问题只会对程序员了解业务和编码的积极性,以及商业化产品开发落地的效率起反作用。
2.1.3 对程序员的"人文关怀"
一个贴近实际开发人员的例子,"请教了3位同事才知道了开播记录是怎么产生的"、"请教了3位同事才本地构建历史服务成功",诸如此类的尴尬在日常工作中屡见不鲜的,实际上这类问题只会对程序员了解业务和编码的积极性、产品开发落地的效率起反作用。
要解决这种效率抑或积极性问题,还是需要解决根源上的"知识共享"问题。
2.2 引入领域驱动设计
在前文叙述问题的时候,熟悉的读者可能就已经想到了某个热度经久不衰的架构思想:领域驱动设计。是的,我们在开播平台的重构中决定使用这种方式来解决现有的诸多痛点。依靠领域驱动设计的设计思想,通过事件风暴建立领域模型,合理划分领域逻辑和物理边界,建立与现实世界相映射的领域对象和服务架构图,定义符合DDD分层架构思想的代码结构模型,保证业务模型与代码模型的一致性。
相对的,对于最终的效果,也是可以预期到的:
-
统一业务模型和代码模型,领域知识全体共享,提升协助效率;
-
通过边界划分将复杂业务领域简单化,设计出清晰的领域和应用边界,实现业务和技术统一的架构演进,提高人效,拒绝一加功能排期一个月;
-
通过职责划分合理的职责边界,降低架构腐败速度;
2.3 领域驱动视角看开播
回到"开播"这个待解决的问题域本身,对开播业务中最核心的"开播"用例,核心的业务问题包括以下几点需要明确:
-
如何确定房间是否可以开始直播。
-
如何让房间开始直播。
-
如何通知外界房间已经开始直播。
也有一些非功能性的考虑:
-
如何使技术实现贴近现实中的业务原貌,从而降低认知成本
-
如何提高我们对业务和产品的认知程度和积极性
-
如何提高开播功能的鲁棒性和性能
首先,我们可以采用事件驱动开发方法,结合领域驱动设计中的事件风暴方法论,来梳理开播用例中的关键事件和参与者:
事件风暴的核心流程就是由用户执行了命令,从而产生了事件。基于这个事件的结果,与之前相同或是其他的用户会执行另一个命令,产生新类型的事件,以此类推。而顺序是按照业务逻辑而定的。所以我们在整理开播涉及的时间风暴时,工作流如下:
-
确定用例的发起者,即主语。在开播场景中,可以是主播或者运营。
-
确定主语的动作,比如"开启直播"。
-
确定动作的流程中涉及的命令以及执行命令产生的事件、后果,例如"新场次已被主播创建"。
-
补充流程中涉及的业务知识,例如"房间"、各种各样的"检查规则"以及外部系统。
-
当一个完整的业务流程通过上述方式写完之后,对于每个用户,命令,事件进行组合,就能获得聚合,用事件风暴的描述开播场次创建就是"主播在场次聚合上进行了创建场次操作,导致了新场次创建事件",此事件发生后,用户会在房间聚合上执行房间开播状态流转操作的新命令。
事件风暴中"定义领域模型"是最重要的一步,这一步需要了解实际业务形态后团队内大量讨论,从而达成共识。在这个阶段中我们提炼了诸多的业务表现并且屏蔽技术实现细节,提取出了关键的实体、值对象、聚合根。紧接着就可以着手对事件风暴中的概念进行进一步的归纳。
通过以上步骤,我们可以清晰地梳理出开播用例中的关键事件和参与者,为后续的设计和开发工作奠定基础。
业务描述拆解成主语和动词的形式后,可以发现"房间"和"场次"是这个问题域的两个主要元素。在领域驱动设计中,需要将这两个支撑域进行集成,最终形成"开播域"的基本解决方案。为了确保开播业务流程的完整性,还需要将"安全管控"、"分区"、"账号"等子域或外部系统的知识参与其中,并将其作为业务规则和值对象等等的形式进行表达。
根据近一年的业务现状,我们参考领域驱动设计模式,进行了领域上下文的划分:
子域 | 能力 | 业务子域重点 |
[核心域] 开播域 |
集成开播相关的所有上下文,形成对开播场景下业务用例的实现方案。如开播、关播等。 | 开播 = 直播场次被创建 + 房间状态变为开播 + 开播领域事件被发出 |
安全管控 = 开播常态化管控能力 + 应急管控能力,如仅允许官方直播间开播 | ||
[通用域] 房间域 | 基础业务能力,仅处理播端房间基础业务,如变更房间开播状态。 | 房间状态的增删改查 |
发送开关播领域事件 | ||
[通用域] 场次域 | 基础业务能力,仅处理开播领域产生的开关播场次信息 | 开关播场次信息、子场次信息(同一场直播下不同分区) |
[支撑子域] 房间管理域 | 保障房间域和开播域之间集成业务的业务完整性,如房间的推流管理。 | 房间关键依赖对账:保障新开通房间存在视频云上行推流地址 |
直播CQRS对账:保障播端、观看端的房间基础信息一致 | ||
视频云流管理能力封装 | ||
[支撑子域] 开播安全管控域 |
开通直播间、预开播、开播、切换分区、推流管控策略 | 提供可配置、产品化的常态化策略管控能力,如是否允许某些地区开播,某分区是否需要延迟 |
必须具备快速应对重要事件的能力。 | ||
外部域 | 视频云管理 | 视频云所属领域,提供上行推流相关能力。开播主要使用上行推流管理、查询上行推流地址 |
直播分区 | 看端所属领域,提供分区基础知识。开播仅使用其查询分区元信息,作为开播安全管控的输入 | |
账号上下文 | 主站账号所属领域,提供实名、等级相关知识。开播需要使用账号信息、粉丝数等,作为开播安全管控的输入 |
明确领域上下文和解决域的划分后,紧接着就可以进行DDD指导下的解决域战术落地了。
领域划分落实到战术上的一个方案就是微服务,微服务将直接作为开播域这个核心域与其他子域的实际界限。
在下文中,我们会讲述如何将当前的PHP遗留服务,这个不满足领域驱动设计的开发架构,演进为受领域驱动设计指导的、贴合业务的、使用Golang搭建的整洁架构。
3.开发架构
设计不只是感观,设计就是产品的工作方式。——史蒂夫·乔布斯
开门见山地讲,经过了多年积累后的旧版开播的遗留代码是工程导向的,里面不乏炫技的代码、大段冗长而缺乏业务注释的代码,这让"开播"这个重要的业务领域在技术实现上,与业务方的实际描述渐行渐远。每当我们提及一些业务场景,都需要绞尽脑汁才能回想起这个场景到底与哪些代码有一些联系。这样的开发架构和技术实现方式,不论对团队的知识共享还是对业务的正常迭代,都是一笔不可忽视的成本。
同时,由于陈年代码经手多代程序员,导致代码风格不统一、领域逻辑和UI逻辑耦合的情况几乎随处可见,DAO代码更是可能随时出现在各个层次,这样的耦合对可拓展性和可测试性都带来了不小的麻烦。
本次重构在战术落地层面所面临的挑战,就是如何在保证业务逻辑几乎不变的情况下,让业务描述与代码实现更贴切、认知负荷更低从而加强业务知识的地位,以及如何优雅地解耦原本杂乱耦合的各层次代码,让他们变得整洁、可测试、可拓展。
3.1 设计模式
我们不妨先管中窥豹,看一个简化版的新旧版本开播的时序图对比:
很显然,前者的描述充斥着纯技术属性的描述,大部分篇幅集中于诸如area_id、uid之类的属性,难以直接和实际业务中的描述对应上。
而后者的描述则是有业务上的主客体描述的,如"房间是否属于该用户"、"分区是否允许该房间开播"。在代码的编排描述上,很容易就可以看出,后者的可理解性要比前者高出一截,这便引出了下文要讨论的话题:新旧版本开播服务的设计模式。
3.1.1 旧版设计模式:事务脚本
事务脚本模式也叫做面条代码或者胶水代码;它有一些显著的特点:面向过程,易于编写,难以应对变更,复杂事务脚本可读性低&可维护性低。显然,旧版php开播代码在多年缺乏系统性维护的业务迭代后,已经几乎退化为这样的模式。
根据Fowler在PoEAA中对事务脚本的描述,我们用上文的旧版开播进行分析。初次读这段代码的体验,可能是如下的:
-
从表示层/服务层获得输入(开播请求的一些参数,比如room_id、uid)
-
中间有大量的过程是用来做单纯的获取某一条数据(area_info分区信息)
-
获取对这些获取的单一数据进行某些字段的判断,或者多个单条数据联合判断(如,检查房间开播状态、检查分区状态是否为online)
-
之后调用其他系统或者存储数据到数据库(多次更新房间的多条信息,live_start_time/area_id)
-
过程中,不断将单条操作后新增的数据合并到响应值中
开播这个动作,在旧版代码中乍一看,是一个过程驱动,由许多仅有技术含义的动作完成的纯技术操作,缺乏了对业务的基本感知和描述。这样的模式,对业务中的场景业务归纳能力较弱,当我们提到某个场景时,往往需要把这些生硬的代码在脑海中转译一次,才能对应上业务方的实际描述。
当我们拥有了更多动作时,就会有若干过程需要做相似的动作,通常就要使多个过程中包含某些相同的代码,这些类似的副本会让应用程序变成一张极度杂乱无章的网。
换一个角度,从OOP的角度上看该模式,实体的概念并不能完全表现,甚至只是充当了业务逻辑层和数据访问层之间的辅助角色,只空有属性,没有行为。这样实体在 业务行为上难以和代码实现 对应,更难以复用。
3.1.2 新版设计模式:领域模型
反之,对比起原来的事务脚本模式,我们的新服务中,包含有多个有血有肉的对象,比如房间和账号。
对于应用服务需要完成的开播用例而言,相比起纯过程的各个字段和子过程的串联,更关心每一个对象应该做出什么行为。
3.2 战术设计
3.2.1 战术设计的思考:引入六边形架构
既然是遗留系统现代化演进,我们不妨先提一些工程质量方面的提升预期:
业务领域的边界更加清晰、更好的可扩展性、对测试的友好支持、更容易实施DDD…
看到既定目标,再结合领域驱动设计指导的前情提要,相信对此熟悉的读者已经会心一笑了,解决方案呼之欲出:六边形架构。
3.2.2 领域模型与六边形架构
怎么写开发架构相对整洁、看起来就可测试的代码?相信大家或多或少都了解过"六边形架构"、"整洁架构"或者"洋葱架构"。我们再来稍微复习一下它的定义:
六边形架构,也被称为端口和适配器架构(Ports and Adapters Architecture),是由Alistair Cockburn于2005年首次提出的。这个架构模式的主要目标是将应用程序的核心业务逻辑与外部依赖分离开来,从而提高可测试性、可维护性和可扩展性。
在六边形架构中,应用程序被划分为以下几个关键部分:
-
应用程序核心:这是应用程序的主要业务逻辑,它包含了所有的用例和业务规则。核心不依赖于具体的外部组件或技术,因此它是高度可测试的。
-
端口:端口是定义应用程序与外部依赖之间的接口。它们定义了应用程序需要的功能,但不实现具体的实现细节。
-
适配器:适配器是实际实现端口的组件,它们负责将外部依赖集成到应用程序中。适配器将外部依赖的细节隐藏在内部,以确保核心业务逻辑保持独立性。
通过将应用程序核心与外部依赖分离,六边形架构提供了以下优势:
-
可测试性:由于核心业务逻辑与外部依赖分离,开发人员可以轻松地编写单元测试,而无需依赖外部资源。
-
可维护性:应用程序的核心业务逻辑保持简单和独立,因此更容易理解和维护。
-
可扩展性:通过添加新的端口和适配器,您可以轻松地扩展应用程序,以满足不断变化的需求。
本次我们新搭建的开播平台,遵循了端口和适配器的架构风格,将服务拆分为了以下的层次:
-
Transporter Layer 外部请求适配器,适配外部用例
-
Data Source Adapters 内部资源适配器,适配内部资源(Repository、Infrastructure)
-
Application 应用程序层,集成领域逻辑为用例,如"用户使用直播姬开播"
-
Domain 领域层,业务逻辑核心,众多重要逻辑在这里实现,如"房间状态流转为开播"
解决的问题
-
在PHP的老设计中,开播接口的ui/领域内业务逻辑耦合较重,大量客户端参数校验、特定客户端返回特定响应的逻辑耦合在多个地方,可能是controller,也可能是service,甚至可能是dao层。这部分与领域知识本身无关,在新版本的开播代码中,需要与应用程序层和领域层隔离起来,保护后者的逻辑不受污染。
-
历史遗留代码中,DAO耦合在代码中,对业务逻辑本身的可拓展性和可测试性产生了阻碍;新版代码中也需要将这部分解耦,以便未来技术演进和单元测试的开展。
3.2.3 模块设计
按照上文的开发架构设计,本次新开播服务的代码分包结构代码实现如下。
因为Golang本身OOP的鸭子类型特性和诸多原因,我们的编码风格显得没有那么严格,选择了相对松散的代码分包结构。大致区分为了领域层、防腐层、应用程序层、仓储/基础设施层
Domain 领域层
作为领域驱动设计指导下的工程,我们的首要目标就是保障领域逻辑的正确性。
最核心的领域层,svc/pkg/domain包含了领域服务和各个领域对象的interface契约声明和具体实现。开播涉及到的领域对象都在这里集中实现。
在开播的战略设计中,我们提到了几个上下文,在这里会作为聚合或对象的方式进行实现,他们的关系如下:
Facade 防腐层
对应设计中的Transporter Layer 外部请求适配器,适配外部用例:
用例上,开播和用于调试开播的请求,都需要在用例层面适配他们,所以自然需要适配器来适配他们的grpc请求,以及考虑到今后多种接口形式的接入( http or mq),如果之后grpc定义出现变更,或者新请求形式的接入,不会对 Application 层的用例带来渗透和影响。
设计原则
需要提供多组外部适配器,适配各种场景的开播请求(理论上可能是grpc/http/mq …,本文仅限于直播姬开播的场景),并转化为应用程序层可接受的用例级别请求。并且作为防腐层,不应该有过多业务逻辑,仅实现必要的特定端到端场景的UI逻辑。
-
处于防腐层的适配器需要有自己的合法校验逻辑(必要的静态参数检查)。
-
防腐层约定上层(controller的 grpc server)以及下层应用的(application)的交互协议,开播中对应开播的gRPC请求转换为Application层可接受的请求。
-
历史逻辑中只与UI相关的逻辑也需要在这一层收敛。(etc.开播失败情况下的端上提示、弹窗的相关返回值组装)
对外契约:
-
适配网关层开播接口的请求:需要有静态参数检查、将请求构造为application层可接受的ACL请求。
-
开播返回结构体组装:将application层开播用例的结果,根据端到端的UI逻辑,组装成业务预期中的返回值(防止客户端UI相关的逻辑渗透到下面的层次)
Application 应用程序层
应用层作为场景用例的主体部分,充当了实体、聚合、领域服务的胶水层,将房间、场次、账号的行为集成到一起,最终形成"直播姬开播"用例的业务逻辑。最终,每一个用例会对应application的一个接口,如"直播姬开播"、"直播姬关播"、"后台开播"、"后台关播"等等,包装成用例提供到外部。
Repository / Infrastructure 仓储/ 基础设施
对应Data Source Adapters 内部资源适配器。同样的,六边形架构中,对下游依赖的约束也是依靠 接口与适配器 这一风格进行解耦和契约。
设计原则
-
内部资源适配器应该依照上层契约实现
-
内部资源适配器实现的变更不会影响到声明的契约本身,其交付的能力应当是不变的
-
仓储 Repository,按照Domain层协商的仓储能力进行实现,该层只对领域逻辑要求的仓储能力负责,如"发布开播领域事件"
-
基础设施层 Infrastructure,领域无关的基础设施,如分布式锁、上报组件等
3.3 测试驱动开发 TDD
当露营结束离开的时候,要打扫营地,让它比你来的时候更干净。—— 童子军原则,《97 Things Every Programmer Should Know》
3.3.1 动机
从项目角度出发,可以提供持续的项目进度反馈。开播平台重构作为一个大型项目,需要从业务和项目的量化成一个个可操作的任务写到 to-do list,然后使用测试驱动编码,可以在每一个预期用例完成后进行标记,那么我们的工作目标将变得非常清晰,因为工期、待办事项、难点都非常明确,可以在持续细微的反馈中有意识地做一些适当的调整,比如添加新的任务,删除冗余的测试;还有一点更加让人振奋,可以知道大概什么时候可以完工,对开发进度可以更精确的把握。
从工程角度出发,可以确保代码质量,也保障重构的安全性。一个软件的自动化测试,可以从内部表达这个软件的质量,我们通常管它叫做内建质量(Build Quality In)。开发人员如果忽视编写自动化测试,就放弃了将质量内建到软件(也就是自己证明自己质量)的机会,把质量的控制完全托付给了测试人员。这种靠人力去保证质量的方式,永远也不可能代表"技术先进性"。在用例级别保障了内建质量后,倘若将来有一天需要重构,由于有全面的测试套件作为保障,开发人员可以放心地对代码进行优化、改进结构或增加新功能,而不用担心引入潜在的问题。
3.3.2 实践
一句话来概括就是先设计用例,再写代码。
鉴于六边形架构符合 端口与适配器 风格的契约,我们很容易知道:
-
一个端口(interface)提供的能力是什么
-
业务逻辑应该使用什么端口(interface),他们应该在业务中表现如何
那么以下的TDD工作流就应该被遵守:
-
先明确interface的能力,定义所需的行为,并编写可读性良好的注释文档来声明它们的作用;
-
根据上一步的契约,编写interface的测试用例。
-
实现interface的业务逻辑,并实现接口以使其能够通过。
-
业务逻辑测试,并使用上文编写的用例进行测试,验证预期行为是否在待测的interface中产生。
-
根据结果调整代码,直到可以通过测试用例。
由于六边形架构中,接口与其实现天然存在接缝(seam),对于某个业务逻辑中对Repository甚至领域对象的情况,我们也可以轻松通过mock的方式进行依赖处理。
以房间聚合的开播状态流转作为举例:
第一步,我们根据"开播状态流转"这个领域对象的动作,进行需求分析,得出该动作的目的就是"将房间状态流转为开播中",一些关联的知识就包括,"必须声明开播时间"、"状态流转为开播的同时需要与一场直播绑定"、"开播的房间必须已经选择了分区"。明确需求后,从业务逻辑中得到想要的用例可以得到如下的用例:
-
完全符合预期,开播的动作中包含所选的开播时间、场次、分区,所以房间状态可以流转为开播
-
空操作,不合法输入,失败
-
没有声明开播时间,不合法输入,失败
-
没有选择分区,不合法输入,失败
-
没有绑定一场直播,不合法输入,失败
-
Repository仓储方法调用错误,失败
同时也可以注意到,在开播状态流转中,我们只关注依赖的Repository的仓储方法是否失败,而不关心它如何实现的、为何失败的。因为这对于房间对象而言并不是职责范围内的知识,而是仓储方法的职责范围,所以在这个场景下,我们只关心仓储是否交付成功即可。
简单举例,测试用例代码如下:
第二步,根据这些已知用例实现"房间状态流转为开播",也就是实现 IRoomStatus 这个对象的 ChangeRoomStatusToStartLive 方法。
第三步,运行第一步编写的测试用例,查看是否符合预期。对于领域对象具体依赖的Repository,由于我们事先在六边形架构中声明了依赖的interface契约,所以可以较为简单地使用mock处理这些依赖。
第四步,运行完整的测试用例集合,如果不符合预期,则重回第二步,开始新一轮的修改和测试流程。
至此,一套完整的 UTDD 流程就良好地运作起来了,在实际的开发过程中,我们的每一个领域对象、仓储方法、基础设施的实现流程都是按照该流程进行的,在很大程度上保障了新开播的内建质量。
对于更为大型的场景,比如application层对开播接口的测试,本质上在六边形架构中也可以将集成的多个领域对象通过端口-适配器的解耦,将涉及的领域对象直接进行mock,从而以较低的心智成本编写出可读性较高的集成测试,一个典型的集成测试集合如下:
在TDD思想的指导和开发流程下,我们的新服务整体单元测试覆盖率达到了70+%,部分关键领域逻辑的覆盖率达到100%。
如此的覆盖率,不论在业务理解层面还是内建质量方面都产生了莫大的帮助——不必担心一些改动导致的重要影响无法被开发者捕捉到,这无疑在未来的业务迭代和进一步重构中都会起到关键作用。
4 安全的系统迁移
兵马未动,粮草先行。——《孙子兵法》
一艘巨轮建造完成后终究需要下水,而往往船下水的方案设计是先于船体本身的建造的。开播能被称为遗留系统,那么它背后的历史逻辑和技术债务一定不容小觑,我们对新开播系统"完工下水"这件事,显然就要谨慎对待了,从新开播的实现本身、到中间的开发执行和验证,以及最后的部署灰度,都需要进行细致的考虑,保证这艘新船能顺利接触水面。
前期对业务逻辑进行最细致的归纳,这其中包括了代码的逐行校对和每个逻辑分支的业务逻辑梳理,甚至也包括了PHP和Golang基础组件的源码对比。
中期在代码编写的过程中逐步明确"检查点"和事件溯源的全貌,设计并完善了验证方案:流量复制和事件溯源,并构建完善的新旧开播检查点对比系统,保证关键的逻辑节点上,新旧服务的表现完全一致。
后期在服务部署和灰度策略上,也做了最周密的准备,包括网关级别万分位的灰度放量规则和业务级别的重要房间退避方案。
4.1 业务逻辑
业务逻辑通常是最没有逻辑的东西。—— Martin Fowler,《企业应用架构模式》
4.1.1 历史逻辑
面对已存在多年的业务逻辑,不论它是否容易阅读、我们是否熟悉跨语言的写法,都应该心存敬畏,逐个分支、逐个业务场景进行盘点,最终形成对此业务场景的正确理解。
面对这种高准确度要求的表达诉求,我们选择了已有的接口自动化测试用例结合手动端到端验证 + 逐行阅读对比代码的方式进行梳理验证,最后以时序图的方式,将旧开播服务的PHP实现逻辑呈现到施工方案中。
(涉及具体业务流程,仅展示缩略图)
既然是"重构",我们选择尽量保持原有的逻辑流和数据流,先将主逻辑大部分迁移完成,再进行下一步的改造。
所以在新版本的重构中,涉及的业务逻辑流,实际上并没有过大的改变,从而保障了逻辑分支在端到端表现上可以完全一致。
4.1.2 转化漏斗图
针对上述逻辑分支众多的用例场景,我们也尝试使用最直观的图形形式,展现给对开播领域不甚熟悉的研发同学,甚至是产运同学进行参考,最终选择了漏斗图的形式。
最顶层为开播接口的入口,对应直播姬点击"开始直播"按钮后对服务端开播接口的请求。而后的一系列漏斗层,则代表了服务端的行为,中途不断有检查项拦截不符合开播条件的请求,直到底部的成功开播。
4.2 流量复制 & 事件溯源
以上文归纳的"业务逻辑"为指导,我们着手构建了一套为开播业务逻辑迁移量身打造的流量复制和数据验证方案。
作为核心场景,开播日均承载的流量大,且逻辑流具有不确定性:不同的开播账号、开播场景,甚至是网络环境,都可能会导致会走入某一个上述复杂的开播逻辑分支中,可能有业务逻辑拒绝开播直接中断开播流程的情况,也有可能发生内部错误但继续执行开播流程的情况。所以如何在众多的业务分支中识别出新旧开播服务的数据流和逻辑流完全一致,是本次工程中的难点之一。
对此,我们设计了一套"流量复制"和"事件溯源"的验证方案。
4.2.1 流量复制
在旧版和新版开播正式进行切换之前,必须保证新版旧版开播逻辑和数据链路和业务逻辑一致,为此我们设计了"流量复制"和"事件溯源/对账"的机制。
-
流量复制:网关层复制开播接口请求,分发给旧服务和新服务
-
事件溯源/对账:
-
开播接口逻辑中的重要事件节点(包括了开播接口最终返回到端上的响应值)上报到数据平台
-
对每一条开播请求的事件进行新旧服务对比
对于复制过来的一组流量,我们期望它是幂等的,不能对下游数据产生任何影响。
在上文开发架构中提到,Repository和Infrastructure在六边形架构中,可以通过不同的方式实现契约,那么对于"不真实执行"这一实现方式,是天然可以实现支持的——新增一组"假写"的适配器即可。
为保证这些检查点在新旧服务完全一致,在验证方案中设计了以下三个阶段:
-
旧服务进行开播事件上报
-
主要逻辑仍然由旧服务处理,网关服务复制流量到新服务。新服务只执行幂等逻辑,不进行真实的写操作。新旧服务均上报关键事件检查点,统一在数据平台进行每一条请求的检查。
-
重构部分的关键事件检查点验证完成后,旧服务不再上报,而新服务切换为真实写入的模式,并且继续保留关键事件上报能力。
4.2.2 事件溯源
借助战略设计章节中的"事件风暴"整理出的关键路径和事件,稍加整理就可以得到一组关键事件链路,借助事件溯源(Event Sourcing)的思想,我们可以将开播流程中的重要节点上报并持久化。
根据事件风暴和业务逻辑的时序图,我们设定了以下关键事件检查点:
根据这些检查点,我们在新旧版本的开播代码中进行改造,在对应的点位埋桩进行数据上报。由于他们可以被聚合在同一条trace下,所以针对每一条开播接口的请求,都可以被完整地记录在案。
从事件溯源中,我们也可以获取到一个意料之中的收获:开播链路在服务端链路的业务可观测性。
4.3 自动化测试
4.3.1 UTDD 单元测试&集成测试
如 3.3(测试驱动开发)部分所述,新开播服务在开发时就采用了 TDD 工作流,单测覆盖率70%以上,关键逻辑的行覆盖率达到100%。
单元测试覆盖率检查集成到CI中,保证后续业务迭代质量。
4.3.2 ATDD 测试共建自动化测试用例
在本次重构中,我们与测试团队持续合作,共建了200+条开播接口的自动化集成测试用例,覆盖了大部分的请求参数检查、用户身份和状态、特殊开播场景、安全管控策略、分区和场次状态等正常和异常用例,并对对应预期接口返回结果、数据和消息写入结果等检查。同时在自动化测试中引入diff能力,相同参数输入下新旧服务接口响应进行对比,覆盖80%以上开播场景。
整个重构的迁移过程中,我们通过接口自动化测试,发现并修复问题10+个。
4.4 部署计划
整体上线(包括流量复制&实际灰度阶段)分为了三个阶段:
-
旧开播服务事件上报
-
新旧开播服务,线上流量复制对比
-
新开播服务正式灰度切流
整个部署发布不同阶段,都严格制定SOP按照计划执行,避免遗漏或切换过程中对线上开播服务的影响:
4.5 结果
在精细的验证计划、部署计划和严格的流程把控下,开播在整个迁移过程中未出现任何事故。
其中一些验证操作的功效是很直观的:
-
ATDD 接口自动化发现差异:10+个
-
流量复制&事件溯源发现差异:20+个
-
历史逻辑&代码对比发现差异:30+个
同时我们在一个月时间内,逐步进行了精细到单个用户粒度-万分位-千分位-十分位-全量的灰度,在途中也优化了10+性能问题。
最终顺利全量上线。
5 生产配套
“君之所以明者,兼听也;其所以暗者,偏信也。”——汉·王符《潜夫论·明暗》
一个运作良好的系统首先必须具备良好的可观测性,倘若都无法观测到各个零件运作是否良好,又谈何算得上一辆好车。
对于开播这种不容有失的系统,万万不可写完代码就万事大吉。我们需要更加谨慎地将系统的运作状态观测纳入设计考虑,让观测变得更加直观,使潜在的系统性风险可以快速暴露,也便于在紧急情况下做出恰当的决策。
5.1 系统监控
对于开播服务的整体链路,我们通过前文的事件溯源上报方案结合司内的监控解决方案,对开播成功、开播拒绝的情况进行了上报统计,对开播整体大盘的开播成功率、被拒绝开播的原因和发生率形成直观感受。
若开播系统出现了某种业务异动,比如被拒绝开播的突增,我们可以借助监控大盘和告警体系在第一时间感知到。
5.2 系统排障
伴随着"事件溯源"体系的建设,自然可以衍生出众多提升系统可观测性的辅助工具。这些工具在未来的业务运维和业务迭代过程中可以节省大量的人力。
如可以实时验证是否指定房间是否满足开播条件的"模拟开播":
以及针对每一条历史开播请求可以追溯关键事件,排查开播为何成功/失败的"开播事件问诊":
6 结果
回顾文章开篇时提到的历史债务上来,我们从业务层面和技术层面来进行一些简单的复盘。
6.1 业务收益
知识共享:在开播平台重构的一系列工作中,首当其冲的是对开播历史逻辑的完整梳理,这无疑提高了产研对开播业务的理解程度,降低沟通成本;在过程中,我们也已与产品沟通了众多不曾关注到的功能细节,帮助产品更好地建设开播工具生态。伴随着产研对业务知识的理解成本降低,一些客诉问题的排查也会变得容易起来——从前一些只有代码编写者才能描述的边缘情况,现在更容易被产品甚至熟悉的运营所得知,进而减低对开播功能的疑惑,最终使产研协作效率提升。
开发提效:在PHP旧服务的开发过程中,用例梳理、PHP代码晦涩的Coding过程、复杂代码的反复Review、PHP的远古工具链使用都会占用大量的开发时间;相较旧版,新版开播接口不存在这些历史包袱,极大提高了开发效率。
业务SRE:"开播事件溯源"提供的接口请求级别的问诊能力,不同于以往排查开播问题时需要手动翻阅每一条关键日志,新版本的一键查询溯源记录能力可以大大降低研发的问题排查成本。
6.2 系统性风险优化
在过去,开播系统运行于"房间服务"的 PHP 服务之中,该服务除了承载开播业务,也承载了大量和直播有关的周边业务接口;
从技术角度,跨语言的迁移解决了较多的风险:
-
一个相当典型的案例:原先的框架基于 Swoole 二次开发(Worker 模式),在突发并发流量较大时会出现单实例 Worker 满载的情况,造成请求超时;且由于请求堆积、Worker 释放和重建、内存回收之间存在一定时间差,瞬时 Swoole Worker 进程超出内存限制导致请求失败时有发生。该问题造成了原开播系统的稳定性不足,无法支撑直播业务快速发展的需要,构成了系统性风险。而这种在PHP框架下难以根治的问题,迁移到Golang后就自然不存在了。
-
重构后,开播业务属于单独的 Go 微服务,性能和可用性上有了大幅提升,接口可用性 SLA 从 99.xx% 提升到 99.99xx%,接口 P99 响应速度提高50+%。
-
从语言层面,由于 PHP 本身是弱类型和解释型语言,在开发和编译过程中较难发现潜在问题,导致研发自测和测试成本上升,在过去也曾因为这些特性的处理不慎导致开播系统的线上问题;Go服务中对单元测试、故障测试有较好的支持,可以及时发现问题。
-
PHP 的内部工具链相对缺少维护,与之对比的 Golang 是公司后端研发最主流的选型,公司级监控、告警、观测、服务治理、持续集成等系统都为 Go 提供了相对更好的支持,这也使得新的开播系统也有更好的可维护性和事件响应能力。
从业务角度看,也提高了业务的系统稳定性:
-
重构过程中,梳理了整个开播链路中的服务、接口、配置、存储依赖,并将数十个依赖项区分为强依赖和弱依赖。
-
对于强依赖场景,梳理了对应的失败表现,并编写了应急SOP;对于弱依赖调用失败的场景,采用补偿任务等手段处理,不阻塞用户开播,进一步提升了开播系统的可用性。
6.3 技术资产
一个好的技术项目,不仅需要达成业务和技术上的硬性目标,还需要有所积累和成长。我们在开播重构的旅途中,也摸索出了一套行之有效、可复用的观测模式和迁移模式。
6.3.1 更细粒度的业务可观测
上文中提到的业务链路可观测,沉淀后也成为了开播问诊台的通用事件溯源功能。
可查看某个房间过往不同开播场次,过程 & 结果关键事件的数据信息,更快的定位到线上每一次具体开播的情况。
6.3.2 可复用的迁移模式
经过本次开播接口迁移的历练,开播平台获得了可复用的PHP转Go的工程经验,我们也可以尝试用DDD的观点来总结:
一些沉淀的能力如下:
-
业务流
-
业务逻辑梳理:逻辑分支级别梳理,落实到标准设计图上(时序图、数据流图),分支级别的测试用例覆盖(判定表、单测、自动化测试)
-
事件溯源:按照事件风暴、测试用例标定的关键事件,进行开播流程中的事件上报,保证请求可完全回溯。
-
工程保障
-
流量复制/切换:新旧接口的流量复制和流量控制。
-
SOP:严格的部署计划以及SOP,减少人为因素的不稳定性。
那么套用回“开播平台迁移”这个问题域汇总,我们可以得到以下的解法:
-
统一领域知识:对于产研测需要达成的共识,一套可读性高的业务逻辑梳理可以满足诉求。
-
白盒验证:业务逻辑梳理提供单元测试、自动化测试用例;事件溯源和流量复制共同提供新旧服务的关键事件上报、数据对比。
-
服务部署:SOP提供严格的准出和操作步骤;流量复制/切换提供新旧服务的切换能力。
-
战术落地:借助于上述的所有能力,逐步完善开播系统。
一个完整的迭代可能是这样的:确保项目组内产研认知一致后,按照TDD方法编写出初版代码;通过众多测试用例后,进行流量复制和事件溯源,通过关键事件对比保障关键检查点和数据链路完全一致,最终按照SOP进行上线。如果中途发现了修改点,需要回退到初版代码编写,乃至同一领域知识的步骤进行项目组认知的对齐。
通过业务流的完整评估,再有严谨的工程验证计划保障,在事实上极大降低了出现严重迁移事故的概率(开播迁移过程中未出现PX以上事故)。
7 后日谈:可演进的“遗留”系统
重构和微服务的缔造者,软件开发领域的泰斗,Martin Fowler 曾经说过这样一句话:
Let’s face it, all we are doing is writing tomorrow’s legacy software today.
是的,可以毫不夸张地说,你现在所写的每一行代码,都是未来的遗留系统。这听上去有点让人沮丧,但却是血淋淋的事实,一个软件系统的生命周期终归会符合业务演进的客观规律。
不过大可不必气馁,回到我们在引言中谈到的遗留系统定义,有些系统时间虽长,但如果一直坚持现代化的开发方式,在代码质量、架构合理性、测试策略、DevOps 等方面都保持先进性,就算将来需要进行架构的进一步演进,这样"整洁"的老系统也会帮助我们规避众多的问题,甚至可以让演进周期缩短、演进风险降低。
相信我们今天在开播平台迁移中花费的心血和留下的基石,终会为"历久弥新"的系统打下基础。
参考:
[1] Vernon, V. (2013) Implementing domain-driven design.
[2] Martraire, C. (2019) Living documentation: Continuous knowledge sharing by design. Boston: Addison-Wesley.
[3] Just enough software architecture: A risk-driven approach. Boulder: Marshall & Brainerd, 2010.
[4] Fowler, M. (2019) Refactoring: Improving the design of existing code. Boston: Addison-Wesley.
[5] Qilin, Y. (2021) 遗留系统现代化实战, 极客时间. Available at: https://time.geekbang.org/column/intro/100111101 (Accessed: 22 November 2023).