Go未来演进:基于共同目标和数据驱动的决策

本文永久链接 – https://tonybai.com/2023/12/10/go-changes

自从Go语言之父Rob Pike从Google退休并隐居澳洲后,Russ Cox便成为了Go语言团队的“带头大哥”,虽然其资历还无法与依旧奋战在一线的另外一位Go语言之父Robert Griesemer相比。如今,Russ Cox对Go语言未来的演化发展是很有“发言权”的,Go module的引入便是Russ Cox的重要决策之一。从Go社区来看,这些年来,以Russ Cox为首的Go团队对Go演进决策总体上是良性的、受欢迎的,比如Go module、Go泛型、Go对wasm的支持等,当然也有一些变化是受到质疑的,比如:Go 1.22版本很可能从试验特性到正式特性的loopvar等

注:我的极客时间《Go语言第一课》专栏中有对Go module和Go泛型的详细讲解,欢迎感兴趣的童鞋订阅阅读。

想必很多Gopher也和我一样,对Go团队就某一proposal的决策方式和依据很好奇 –到底他们是如何决定是否accept这个proposal的?Go语言后续该如何演化?向哪个方向发展演化?

今年9月份举办的GopherCon 2023上,Russ Cox代表Go团队做了名为“Go Changes”的主题演讲

在这个talk中,我们能找到一些答案。近期他重新录制了该演讲视频,并在其个人博客中放出。

本文就是基于这个视频内容进行整理加工后的文字稿,供国内广大gopher参考。


这是我在2023年GopherCon上做的一次演讲的重新录制视频。在这次演讲中,我和大家分享了三部分内容:为什么Go需要随着时间的推移而改变,我们如何应对Go的变化过程,以及为什么选择性遥测(opt-in telemetry)是这个过程中的一个重要且适当的部分。不过,这个演讲不是关于某个特定的Go特性变化,而是关于Go整体的变化过程,特别是我们是如何决定做出哪些改变的。

首先一个明显的问题是,为什么Go需要改变? 为什么我们不能对Go感到满意,然后将其束之高阁呢? 一个显而易见的答案是我们不可能一次就把事情做对,你对比一下上面图片中展示的第一版毛绒Go吉祥物和我们在GopherCon上发放的最终版本,你就能明白我的意思了。

但这里还有一个更深层次的答案:

我的一位前同事在他使用了多年的邮件签名中引用了生物学家兼科幻小说作家杰克·科恩(Jack Cohen)的一句名言。在这句名言中,科恩说:“我们生物学家使用的一个描述‘稳定(stable)’的专业词汇就是“死(dead)”。

所有的生命都在变化,适应新的环境,修复损伤等。编程环境也需要改变。除非我们想要Go死掉,否则它需要适应新的环境,比如新的协议、操作系统和重要用例。我们也需要发现并修复bug — 语言、库和生态系统的问题,这些问题只有随着时间的推移或Go发展到一定阶段和规模才会暴露出来。

Go必须改变,并与时俱进。这次演讲就是关于我们如何决定做出哪些改变

这次演讲分三个部分:

  • 第一部分是关于我们对Go的愿景和期望。
  • 第二部分是关于我们如何利用数据来决定做出哪些改变。
  • 第三部分是关于我们在Go工具链中增加选择性遥测的计划,以便更好地理解Go的使用情况和出现问题的地方。

到演讲结束时,你将了解我们考量和决定Go变化的过程,并了解数据在做出这些决定中的重要性,我希望你能理解为什么选择性遥测是一个很好的额外数据来源,甚至可能愿意在系统推出时就选择加入。

让我们从这个开始:我们希望Go发生什么样的变化?如果我们在这个基本问题上意见不一致,我们也就无法就具体的变化达成共识。

例如,我们是否应该在Go中添加一个Perl语句,让我们可以用Perl编写函数?

我认为我们不应该,但假设你有不同意见。为了解决这个问题,我们需要理解为什么我们持不同意见。

约翰·奥斯特豪特(John Ousterhout)写了一份名为“开放决策制定(Open Decision Making)”的好文档,内容虽然来自他在创业公司的经验,但它几乎完全适用于开源项目。

在这份文档中,他提出的最重要的观点之一是:如果一群聪明人面对同一个问题,并具有相同的信息,如果他们有相同的目标,那么他们很可能得出相同的结论

如果你和我在Go中是否要嵌入Perl这个问题上存在分歧,根本原因肯定是我们对Go目标有不同的理解,所以我们必须建立明确Go的目标。

Go的目标是更好的软件工程,特别是大规模软件工程。Go的独特设计决策几乎全部针对这个目标。我们已经多次阐述过这一点,包括在上述截图中的这两篇文章中。再说一次,Go的目标是更好的软件工程

现在我们来说说Perl。20年前,当我很年轻、甚至有些天真、Go还不存在的时候,我编写并部署了一个完全用Perl编写的大型分布式系统。我热爱Perl所擅长的东西,但它并不是以更好的软件工程为目标。如果我们在这一点上有分歧,那么我可能应该定义一下我所说的软件工程是什么意思。

注:如果要理解Go以更好软件工程为目标,或是Google的软件工程理念,可以阅读一下《Software Engineering at Google》这本佳作。

我喜欢说,当你给编程加入时间和其他程序员时,软件工程就出现了。编程意味着让一个程序工作。你有一个要解决的问题,你编写一些代码,运行它,调试它,得到答案,完成。这就是编程,这已经够难的了。

但是当那段代码不得不日复一日地继续工作时会发生什么,甚至和其他人一起对它进行维护?那么你需要添加测试,以确保你修正后的bug不会在6个月后由你自己或是一个不熟悉这段代码的新团队成员重新引入。这就是为什么Go从第一天开始就内置了对测试的支持,并建立了一种文化,那就是对任何bug的修复或新增代码都要添加测试。

那么随着时间的流逝,当代码必须在Go本身发生改变的情况下继续工作时会发生什么?那么我们需要强调兼容性,这是Go1版本以来一直在做的。事实上,Go 1.21版本发布了许多兼容性改进,我在2022年的GopherCon上对此有过介绍。

随着代码量的增长,如果需要某种全局清理时该怎么办?你需要工具,而不可避免的第一个绊脚石是那些工具需要模仿代码的格式化风格来编辑,以避免出现无关的差异。gofmt的存在是为了支持goimports、gorename、go fix和gopls等工具,以及你自己可能使用我们提供的包编写的自定义工具。

既然提到了软件包,当你使用其他人提供的软件包时,不可避免的第一个绊脚石是多个人会用相同的名字(比如sqlite或yaml)编写软件包。那么我们如何在一个给定的程序中识别究竟使用哪个了呢?为了在一个去中心化的方式无歧义地回答这个问题,Go使用URL作为包导入路径。

随着时间的推移,下一个问题是挑选使用特定软件包的哪个版本,并决定该版本是否与所有其他依赖项兼容。这就是为什么Go提供了modules、workspaces、Go modules mirror镜像和Go module校验和数据库。

接下来的问题是每个人的代码都有bug,包括安全bug。你需要了解关于最重要bug的信息,这样你就知道需要更新到已修复的版本。这就是为什么我们添加了Go漏洞数据库和govulncheck,Julie也在GopherCon上谈到了这一点,当有视频链接时我会在下面添加。

以上是较大的例子,但也有小的例子,比如添加新的协议如HTTP/3,移除对过时平台的支持,以及修复或废弃容易出错的API,以避免大型代码库中的常见错误。

这把我们带到了Go提案过程(Proposal Process),这是我们对是否接受(accept)和拒绝(decline)哪些变更做出决定的方式:

当我们考虑这些决定时,使用数据非常重要,这可以帮助我们达成共识。

简单地说,任何人都可以在Go的GitHub问题跟踪器上提出Go更改提案(Change Proposal)。然后,在该问题上进行讨论,我们试图在参与者之间就是否接受或拒绝该建议达成共识,或者该建议需要做出什么修改才能被接受。

随着时间的推移,我们越来越欣赏约翰·奥斯特豪特在他的观察中提出的第二句话的重要性:如果面对问题的人不仅共同的目标,还有共同的信息,他们很可能会达成共识。

在Go的早期,只有我们几个人做决定。我们根据技术判断和直觉做出决定,这些判断和直觉是基于我们过去的经验。那些经验就是我们使用的信息。由于我们的过去经验有足够的重叠,我们大多数时候能达成共识。大多数小项目都是这种工作方式。

随着决策涉及的人数大大增加,共享经验就会减少。我们需要一个新的共享信息来源。我们发现的最好信息来源是收集实际数据,然后将这些数据作为共享信息来做决策。但是我们从哪里获得这些数据呢?对Go来说,我们有许多潜在的来源,每一个都适合具体的决策类型。在这里,我将向你展示其中的一些。

一个数据来源是与Go用户交谈。我们以各种方式做到这一点:

首先是Go用户调查,我们从2016年开始每年做一次,最近开始一年做两次。调查非常适合了解Go最流行的用途以及人们面临的最常见问题。多年来,最常见的问题是缺乏依赖管理和泛型。我们使用这些信息将开发Go模块和泛型作为优先事项。

另一个数据来源是我们可以在VSCode中使用VSCode Go插件运行的调查。这些调查可以帮助我们了解VSCode Go体验的实效性。

来自用户的最后一个直接数据来源是我们全年进行的研究访谈和用户体验研究。这些研究允许我们从小规模的用户群体中识别模式或获取更多关于特定主题的信息。

调查和访谈通过与用户交谈来收集数据。另一个数据来源是阅读代码:我们可以分析已发布的开源Go module代码。

例如,在添加新的“go vet”检查之前,我们会在开源代码库的一个子集上运行它,然后读取一些随机样本的结果,看检查是否指出了真实的问题,以及它是否有太多的假阳性。

在Go 1.22版本,我们计划添加一个go vet检查,检查对append的调用是否没有append任何内容。这里有检查器标记的两段代码:

顶部的一段代码表明开发人员可能认为append总是复制其输入slice。底部的一段代码可能是正确的,但难于措辞来描述。

这里还有另外两段代码:

在顶部的一段中,或者for循环从未运行,或者它永远不会完成,因为e.Sigs的长度永远不会改变。底部的代码也似乎是一个清晰的bug:代码正在仔细决定将消息追加到哪个列表中,然后它没有将其追加到任何一个列表中。

由于我们对样本代码段进行的所有采样都是可疑的或完全错误的,我们决定添加该检查。在这里,数据比直觉更好。

所有这些方法都是在少量样本上工作。对于典型的代码分析,我喜欢手动检查100个样本,与世界上所有Go代码的量相比,这只是一个微小的比例。最后一份Go开发者调查有不到6000名受访者,而全世界可能有300万Go开发者,样本比例不到1%。

一个很好的问题是为什么这些极小的样本能告诉我们有关更大人群的信息?答案是抽样精度只依赖于样本数量,而不依赖于总体规模。

这乍一看似乎反直觉,但假设我有一个装有100万只Go吉祥物的大箱子,我随机拿出两个。首先我拿到一个蓝色的,然后我拿到一个粉红色的。根据这两个样本,我估计箱子中的吉祥物大约一半是蓝色的,一半是粉红色的。但如果我告诉你箱子里有粉红色、蓝色和灰色的吉祥物,你是否会感到十分惊讶? 不会非常惊讶!如果箱子正好分三分之一粉红色、蓝色和灰色,那么这9对颜色组合中的每一对都同样可能:

得到一个非灰色吉祥物的机会是2/3,得到两个的机会就是2/3的平方,即4/9。没看到灰色的情况出现概率将近一半。这就是为什么我们不会非常惊讶的原因。

现在假设我取出100只,有48只蓝色和52只粉红色。我再次估计箱子大约一半是蓝色,一半是粉红色。现在如果我告诉你箱子里有粉红色、蓝色和灰色的吉祥物,你会有多惊讶?你应该会非常惊讶。

事实上,你完全不应该相信我。如果那是真的,得到100只连续的非灰色吉祥物的机会是2/3的100次方,约等于10的负48次方:

随机出现这种情况的可能性为零。要么我在说谎,要么我没有随机抽取。可能所有的灰色吉祥物都在箱子底部,我没有抽取到足够深的地方。

请注意:这都不依赖于箱子中有多少只Go吉祥物,它只取决于我们取出了多少只。用于特定预测精度的数学更复杂,但具有相同的效果:只有样本数量重要,箱子中的吉祥物数目不重要

一般来说,手工计算这些数学太困难了,所以这里有一个表格,你可以在我的博客上找到:

它说明,如果你提取100个样本并根据这些样本估计百分比,那么90%的时间你的估计将在真实百分比的正负8%之内。99%的时间它们将在13%之内。如果像Go调查中那样有5000个样本,那么90%的时间估计误差在正负1%之内,99%的时间在正负2%之内。超过这个数量,我们实际上不需要更多样本。

有一个注意事项是样本需要是随机的, 或者至少与你正在估计的内容不相关。你不能只从箱子的顶部抽取吉祥物,然后对整个箱子做出断言。

如果你避免了这个错误, 那么当你试图估计一个新的API是否有用或者某个特定的vet check是否值得的时候, 花一个小时左右手动检查100个样本是合理的。如果是一个坏主意, 那将很快显现出来。而如果看起来是一个好主意, 再花几个小时检查更多的样本, 无论是手动检查还是用程序检查,都会大大提高你的估计准确性。与做出错误决策的代价相比,这是一个非常小的成本。

简而言之,采样的魔力在于将许多一次性估计转变为可以手动或用少量数据完成的工作。这就是为什么我们已经看到的所有数据来源都能够相当好地代表整个Go开发者群体的原因。

现在进入演讲的第三部分:Go工具链中的遥测(Telemetry):

遥测也将是Go开发者使用的一个小样本,但它应该是一个有代表性的样本,并且回答不同的问题,而不是调查和代码分析所做的问题。

遥测始终是一个有争议的话题,特别是对于开源项目来说,所以让我从最重要的细节开始说起:上传遥测报告是完全自愿和选择加入的:

除非你运行一个显式命令选择加入数据收集,否则不会上传任何数据。而且,这不是那种上传你的全部活动的详细跟踪的遥测系统。这种遥测也只适用于我们作为Go发行版的一部分分发的命令,比如gopls、go命令和编译器(compiler),它不会涉及你构建的任何程序

在我更详细地描述完这个系统之后,我希望你会发现你会愿意选择加入这个遥测系统。实际上,我们给自己设定的主要设计限制是,即使由其他人运行,我们也愿意选择加入该系统。

在我以2023年11月的录制这个内容时,该系统刚刚开始运行,只有少数人被要求在VSCode Go中选择加入gopls遥测。所以总体来说,你现在还不能选择加入。但希望很快你就可以了。

在我们深入了解细节之前,遥测的动机是它提供了与调查和代码分析不同的信息。它主要提供的两个类别是使用信息(Usage Information)和故障信息(Breakage Information)。调查让我们能够询问关于Go使用的广泛问题,但对于详细的使用信息来说并不好。那将是太多问题,对于调查对象来说,90%的问题要回答”no”是一种浪费时间。

这个幻灯片显示了我们在之前的版本中警告过即将删除的Go功能列表。列表中的最后一项,buildmode=shared,是我们试图移除的功能,但在事先警告后,至少有一个用户提出了异议,我们将其保留了下来。即便如此,buildmode=shared与Go module基本不兼容,所以它的使用可能非常有限。但我们没有数据,所以它仍然存在于代码库中。遥测可以为我们提供基本的使用信息,以便我们可以基于数据而不是猜测做出这些决策。

另一个重要的类别是故障信息:

如果Go工具链明显有问题,我们希望在GitHub上收到错误报告。但是Go工具链也可能以用户注意不到的微妙方式出现问题。一个例子是,在macOS上的Go 1.14到Go 1.19的版本中,标准库包的二进制文件在预先构建时使用了非默认的编译标志,这是一个意外,这使得它们看起来像是过时了,Go命令在运行时会重新编译它们,这意味着如果你的程序导入了net包,你需要安装Xcode中的C编译器来构建程序。我们希望Go能够自行构建纯Go程序,而无需其他工具链。因此,要求安装Xcode是一个bug。但是我们没有注意到这个问题,也没有用户在GitHub上报告它。遇到这个问题的人似乎只是安装了Xcode并继续进行了工作。遥测可以提供基本的性能指标,比如标准库缓存命中率,这样Go工具链的开发人员即使用户没有意识到这个问题,也能注意到这个问题。

另一个例子是编译器的内部崩溃:

Go编译器在程序的第一个错误处不会停止。它会继续进行,尽可能多地查找和报告不同的错误。但是有时,继续分析已知错误的程序会导致意外的panic。我们不希望向用户显示这样的崩溃。相反,编译器会从panic中恢复,并且仅报告已经发现的错误。这样,Go用户可以纠正这些错误,这也可能纠正隐藏的panic。用户的工作不会因为看到编译器崩溃而中断。这对用户来说是好的,但是Go工具链的开发人员仍然希望了解这个崩溃并修复这个错误。遥测可以确保即使用户不知道这个错误,但我们还能了解到这个错误。

为了收集使用情况和故障信息,Go遥测设计记录“计数器和崩溃”:

像go命令、Go编译器或gopls这样的Go工具链程序可以定义命名事件计数器,并在事件发生时递增计数器。事件还可以按堆栈跟踪单独计数。这些计数器在本地的磁盘文件中维护,每次保留一周的时间。在幻灯片上,gopls和其他工具正在将计数器写入每周的文件中。

每周一次,Go工具链中的上传程序(uploader)将从遥测服务器获取一个“上传配置”,其中列出了该周收集的特定事件名称。只有在遥测特定的提案审查过程达成共识后,才会更改该配置。该配置作为一个模块(module)提供,以保护下载的完整性,并保留过去配置的公共记录。然后,上传程序仅上传上传配置中列出的计数器。在幻灯片上,上传程序仅为gopls发送一份报告,仅包含少量计数器,即使磁盘上可能还有更多计数器。报告中包含关于使用gopls的编辑器的统计信息,以及关于完成请求的延迟的信息,还有一个发生了一次的gopls/bug事件,其中包含一个栈跟踪。

请注意,上传的数据中没有事件跟踪或任何用户数据,只有计数器、已在公共上传配置中列出的事件名称,以及Go工具链程序中的函数名称。还要注意,栈跟踪不包括任何函数的参数,只有函数名称,因此没有用户数据。

开源中的遥测可能会在拥有数据访问权限和没有数据访问权限的人之间产生信息失衡。我们希望避免这种情况。请记住奥斯特豪特规则:为了达成共识,我们需要每个人拥有相同的信息。由于Go的遥测上传不包含任何敏感数据,并且是在明确的选择同意的情况下收集的,我们可以完整地重新发布这些报告,以便任何人都可以进行任何数据分析。我们还将发布一些基本的图表,用于做出决策。我们唯一可能看到但没有重新发布的是报告来自哪些IP地址,我们的服务器会将这些信息与报告一起记录。

一个明显的问题是,是否有足够多的人选择启用遥测,以使数据足够准确以做出决策。幸运的是,采样的神奇之处在于可以帮助解决这个问题。

全球大约有300w Go开发者。当系统准备就绪并要求人们启用遥测时,即使只有千分之一的开发者选择参与,也会有3000名开发者,根据我们的图表显示,误差不到3%,置信度为99%。如果全球三分之二的Go开发者启用了遥测,那将是20000个样本,误差不到1%,置信度为99%。除此之外,我们实际上不需要更多的样本。如果我们持续获得更多的报告,我们可以调整上传配置,告诉系统在某个特定的周选择随机不上传任何东西。例如,如果有20万个系统选择了参与,我们可以告诉每个系统在任何给定的周上传的概率为10%。因此,即使我们预计选择参与率会很低,系统应该能够运行得很好,随着选择参与率的提高,Go遥测将从任何给定系统收集更少的数据。当然,这使得每个选择参与的人对我们来说更加重要。目前来说,Go遥测对于你们中的任何人来说都还没有准备好,但当准备好时,我希望你们会选择参与。

在结束之前,我希望你们从演讲中获得以下几点:

首先,Go需要不断变化,特别是随着计算世界的变化。

其次,任何改变的目标都是为了使Go在软件工程中变得更好,尤其是在规模化(scaling)方面。

第三,一旦我们确定了目标,达成共识的下一个最重要的部分是拥有共享数据来做出决策。

第四,Go工具链遥测是增补我们现有调查和代码分析数据的重要数据来源。

最后,在整个演讲中,虽然涉及到了数据和适当的统计,但我们评估的想法、假设和潜在的变化始终始于个人故事和对话。我们喜欢听到这些故事,并与你们所有人讨论如何使用Go,关于什么有效和什么无效。所以,请无论在什么情况下,无论是在会议上、邮件列表上还是在问题跟踪器上,请确保让我们知道Go对你们的工作情况以及存在的问题。我们总是很乐意听到这些。非常感谢。


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

有效表达软件架构的最小图集

本文永久链接 – https://tonybai.com/2023/12/06/a-minimum-set-of-diagrams-for-expressing-software-architecture

无论你是专职的软件架构师,还是在团队内兼职充当软件架构师角色的开发人员,一旦你处在软件架构师这个位置上,你自然就会遇到软件架构设计的三个困惑:

  • 如何更深刻地理解业务;
  • 如何更正确地取舍(包括技术性和业务性的);
  • 如何更有效地表达软件架构。

以上每个困惑展开来写都够写一本书的。而在这篇文章中,我仅聚焦最后一个困惑,聊聊我心目中表达软件架构的有效方式 — 最小图集(Minimum Diagram Set)。

1. 为什么软件架构需要有效表达

众所周知,软件架构承载着系统关键的技术决策和业务约束,指导着复杂软件的构建与演进,是实现软件系统的蓝图。但并不是说有了好的软件架构就一定可以做出好的软件系统,软件系统最终还是要经由开发人员来实现

如果说架构师是软件架构的生产者,那么开发人员可以理解为是软件架构的消费者。但和一件普通商品一样,往往消费者很难Get到产品设计者的全部idea,产品越复杂,消费者Get到的比例越低,于是商品的生产者就会绞尽脑汁地制作产品说明书、功能演示视频等,目的就是想从不同角度更多、更有效的表达自己的商品的特性。对于普通商品而言,消费者Get程度低顶多是少用几个功能特性;但对于架构师生产的“产品”:架构设计成果而言,如果其消费者开发人员Get的程度低,那影响就会很严重,甚至可能会导致软件系统的开发彻底失败。

并且更不幸的是:我们的软件系统都是“复杂产品”。这样,如何表达和解读软件架构,弥合生产者与消费者之间的Gap,让开发者更多更深刻的理解软件架构这件“产品”便成为了架构师的困惑,日常架构设计工作中的难题,也是业界探索的重要课题。

架构设计是架构师与开发者之间的协议,只有有效的、充分的表达,协议才能被共识理解和忠实执行。业界在有效表达软件架构这条路上摸索了很多年,下面简单说说架构设计表达的演进历程。

2. 软件架构表达方式演进简史

软件架构表达的目的就是要直观地传达架构设计人员的思想和意图,使开发团队可以达成对架构设计的一致理解,促进各个团队协作,并作为开发人员编写代码以及管理人员推进项目的重要指导与参考。

2.1 自然语言描述

在软件工程的早期阶段,软件架构设计通常使用自然语言(如英语)进行描述。架构师会使用文档、规范和书面记录来表达架构设计的概念、原则、结构、组件和交互。然而,自然语言描述存在歧义性、解释性不足、理解起来较慢的问题,可能导致误解和沟通障碍。

2.2 图形化表达

人类大脑中传输的信息90%是视觉信息,其处理图形的速度要比处理文字的速度快上万倍。于是随着软件架构的复杂性增加,人们开始采用更直观、更易理解的图形化方法来描述架构设计(并辅以自然语言的文字描述)。

提到图形化表达,最简单的方法就是使用一支笔+一张白纸,基于自己“创造”的符号绘制草图(Sketch,以下草图来自c4model.com):

这种非规范的框线草图虽然提供了灵活性,但付出的代价却是一致性,因为大家都在创造自己的制图符号,而不是使用统一的标准。

2.3 结构化的图形表达

结构化图是在设计表达迈向标准化方面走出的重要一步。结构化图包括数据流图、控制流图、层次图、组件图等,用于可视化表示系统的组件、模块、依赖关系和交互流程等(下图中元素来自维基百科)。

作为一种可以直观可视化描述与沟通架构设计的方式,结构化图形成为了表达架构设计的常见方法之一。不过,早期结构化表达的类型有限,无法涵盖所有环节,有的也没有形成标准,为了提高标准化程度,满足架构设计表达的全部需求,人们在二十世纪末推出了大一统的图形化建模语言UML。

2.4 统一建模语言(UML)

统一建模语言(Unified Modeling Language,UML)是一种通用的标准化、图形化建模语言,广泛用于软件架构和设计的表示,在软件架构表达方法方面具有里程碑意义:

UML第一次在规范层面对图形表示进行了标准化,它提供了一组规范化的图形符号,用于描述系统的结构、行为和交互。在那个Rational统一过程(RUP)以及面向对象设计方法如日中天的时代,人们每每进行设计时,言必称使用UML。UML在图形化、标准化表达设计图方面走到了至今为止都无人企及的高峰。

但是,20多年后的今天,UML并没有成为当时标准出品方期望的那个样子,没能成为表达软件系统设计的主流符号系统。也许是它的复杂性阻碍了有效沟通,让人们看到它的spec后就“望而却步”了。不过UML并没有死掉,它依然活着,UML规范中的一些图(Diagram)依然被大家常用,比如:序列图(Sequence Diagram)用例图(Use Case Diagram)类图(Class Diagram)等。

2.5 形式化表达

业界在寻求图形化表达标准化的同时,也有一个分支在寻求用自然语言的“标准化”表达方法,这就是软件架构设计的形式化表达,在这个领域形成的语言被称为架构描述语言(ADL)。ADL提供了一组特定的语法和语义规则,用于定义系统的组件、接口、依赖关系、行为和性能特征。ADL使架构师能够使用精确的语言来表达和分析架构设计,支持自动化的验证和分析工具,在学术研究这个小众领域还是很有受众的。不过,显然在大多数工程化淋雨,形式化表达门槛太高,对于软件架构在团队内快速有效建立共识起不到什么作用。

下面是一些ADL的实现,感兴趣的童鞋可以了解一下:

2.6 多视角的表达

有了UML这个前车之鉴后,人们似乎也放弃了在图记号“标准化”之路上的继续探索了,而是回归问题本源:怎么有效,就怎么来

在工程实践中,人们认清了一个事实:很难在一张大图(Diagram)中进行软件架构设计的有效表达。于是大家开始采用“盲人摸象”的策略,将一个架构按不同视角表达为不同的图(Diagram),这样当开发人员将多个视角形成的图都理解后,也就理解了整个架构设计

按照这个多视角表达的思路(也被称为是一种软件架构建模思路),业界先后出现了:

逻辑视图(Logical View)关注系统的功能和功能模块,描述系统中各个模块之间的关系、接口和行为。它展示了系统的静态结构和动态行为,以及模块之间的通信和信息流。

进程视图(Process View)描述系统的并发和分布式特性,关注系统中的进程、线程、任务以及它们之间的关系和通信。该视图展示了系统的并发性、性能、可伸缩性等方面。

物理视图(Physical View)描述系统在硬件和软件环境中的部署和分布情况,包括物理设备、网络拓扑、软件组件的部署位置等。它关注系统的部署架构、可靠性、安全性等方面。

开发视图(Development View)关注系统的软件开发过程和组织结构,描述软件模块的组织、构建、测试和部署过程。它展示了软件开发团队的组织结构、开发工具、版本控制等方面。

场景视图(Scenario View)描述系统在特定使用情境下的行为和交互,以用户场景、用例或故事来说明系统的功能和行为。它帮助验证和验证系统架构的正确性和适应性。

C4模型是一种简洁、易于理解的软件架构建模方法,由Simon Brown提出。它通过四个层次的视图来描述软件系统的不同方面,包括语境视图(Context Diagram,这里借鉴了《程序员必读之软件架构》)一书中对Context的翻译)、容器视图(Container Diagram)、组件视图(Component Diagram)和代码视图(Code Diagram),如下图所示:

语境视图是最高层级的视图,用于描述软件系统与外部实体之间的关系和交互。它展示了系统所处的环境和与外部实体(如用户、其他系统、第三方服务等)的关系,以及它们之间的交互方式。

容器视图关注系统内部的软件容器及其之间的关系和交互。容器可以是物理的、虚拟的或逻辑的,它们承载着系统中的组件或服务。容器可以是应用程序、数据库、消息队列、Web服务等。容器视图描述了系统的主要部件,以及它们之间的依赖关系和通信方式。

组件视图进一步展开容器视图中的组件,描述系统内部的组件及其之间的关系和交互。组件视图展示了系统的模块、类、库或其他可重用的软件单元,并显示它们之间的依赖关系、接口和通信方式。

代码视图是最底层的视图,关注具体的代码实现细节。它用于描述系统中的类、函数、方法等代码单元的结构、关系和实现细节。代码视图可以是面向对象的类图、模块图或其他代码组织结构的表示方式,用于帮助开发人员理解和浏览源代码。

下面示意图可以更直观的展示出语境、容器、组件以及代码之间这种逐渐“展开”的层次关系:

通过C4模型的这四个层次的视图,架构师可以逐渐深入地描述和表达软件系统的不同层次和组成部分,从整体到细节,帮助团队成员和利益相关者更好地理解和沟通软件架构。

Arc42是一种用于软件架构文档化的模板和方法,它提供了一套规范和指导原则来描述软件系统的架构。下面是Arc42的全景图:

我们看到:Arc42模板也包含了多个视图,每个视图都关注系统架构的不同方面,包括Context、Building Block View、Runtime View以及Deployment View等。

Context View:描述系统与其外部环境之间的关系和交互,强调边界的概念,分为技术Context与业务Context。

部署视图(Deployment View)描述了系统的部署架构和环境,包括物理设备、服务器、网络拓扑以及协议等信息。

构件视图(Building Block View)描述了系统内部的组件、模块、子系统、包等,并展示它们之间的关系和依赖。构件视图是源码结构的概览。

运行时视图(Runtime View)描述了系统在运行时的行为和交互以及具体场景下对其他构件的运行时依赖。使用序列图、状态图等方式可展示系统的运行时行为。

2.7 Diagrams As Code

架构设计不是一成不变的,需要不断演进,因此架构视图也需要“与时俱进”的更新。但直接更新图片格式似乎很不方便,也无法在形式上很好的达成一致,于是一些基于DSL语法生成架构设计图(Diagram)的工具便涌现了出来,比如:PlantUMLStructurizrMermaid等。有了这些工具,架构师便可以使用文本编辑器来“画图”,支持“所见即所得”。并且由于Diagrams As Code(代码即图),我们可以将架构设计图与版本控制系统很好地集成。

到这里,我们知道了基于多视角+“Diagrams As Code”是目前的主流的架构设计表达和实践方法,那么我们在软件架构表达实践中,究竟选择哪几个视角来表达呢?这个目前没有统一标准。调研了4+1 Views、C4 model以及Arc42后,我这里说说自己日常做架构表达时使用的最小视图集。

3. 最小图集

很多读者可能听说或学习过或实践过金字塔写作,金字塔写作原理是一种用于新闻报道和科技写作的写作方法,它的核心思想是将最重要的信息放在文章的开头,然后逐渐向下展开,提供更多的细节和背景信息。

金字塔写作的优势在于:

  • 它可以迅速吸引读者的注意力,让读者在最短时间内了解文章的核心内容;
  • 它还可确保信息传递:将最重要的信息放在开头,可以避免读者在阅读过程中错过关键信息或迷失在细枝末节中,确保信息有效地传达给读者;
  • 它还具备灵活性和可定制性,不要求严格按照一个固定的结构来组织文章,而是提供了一种基本的思路和原则,可以根据具体情况进行调整和定制,以适应不同的写作需求和读者群体。

我理解,金字塔写作方法之所以能够成功,其本质是站在了读者的角度去思考问题,想读者之所想,做读者之所需。

软件架构表达的目的也是让开发人员快速深入的理解架构,与设计人员达成共识,指导后续软件系统的实现。所以要想形成有效表达,我们就需要像金字塔写作那样站在开发人员的角度来考虑架构表达,借鉴金字塔原理,自上而下,先表达最重要的信息,然后逐渐向下展开,避免开发人员在理解过程中错过关键信息或迷失在细枝末节当中。

综合前面介绍的多种Views的方法,我们觉得软件架构表达的起点,即第一个图必须是语境图(Context Diagram)。

3.1 语境图(Context Diagram)

语境图表达的是系统最高的抽象层次,是最高视角,全局视角。通过语境图,可以解决开发人员在内心中提出的下面问题:

  • 我们构建的(或已经构建的)软件系统是什么(What)?
  • 谁会用它?
  • 如何融入已有的IT环境?
  • 系统的边界是什么?(业务的,技术的)

语境图不会也不应该展示太多细节,它是软件系统设计图的起点。后续的图都是用“放大镜”将我们的系统放大后的细节的表达。当牵涉到理解系统间接口的问题时,语境图还可以为你识别可能需要沟通的人提供了一个起点。

语境图向开发者展现的重点在于软件系统的范围以及与外部的交互行为(用户< – >系统、系统< – >系统等等)。下面是使用structurizr绘制的一个语境图的实例:

语境图中心蓝色的矩形框代表的是我们的软件系统,上方的user、role、actor是我们的软件系统的用户;client是与我们的软件系统交互的系统,是系统到系统交互的一个代表;在我们的软件系统、Inner System1和Inner System2之外有一个虚线框,代表了企业范围;而Inner System1和Inner System2是我们的软件系统在企业内部依赖的系统;同时,我们的软件系统还依赖企业外部的Outer System1和Outer System2。

上述语境图对应的structurizr dsl代码如下:

// system context diagrams

workspace {

    model {
        u = person "User"
        r = person "Role"
        a = person "Actor"
        c = softwareSystem "Client Software System" {
            tags "client"
        }

        enterprise = group "Enterprise A" {
            s = softwareSystem "Our Software System" {
                tags "server"
            }

            d1 = softwareSystem "Inner System1" {
                tags "dep"
            }

            d2 = softwareSystem "Inner System2" {
                tags "dep"
            }
        }
        d3 = softwareSystem "Outer System1" {
            tags "dep"
        }

        d4 = softwareSystem "Outer System2" {
            tags "dep"
        }

        u -> s "Uses"
        r -> s "Uses"
        a -> s "Uses"
        c -> s "Call"
        s -> d1 "Uses"
        s -> d2 "Uses"
        s -> d3 "Uses"
        s -> d4 "Uses"
    }

    views {
        systemContext s {
            include *
            autoLayout
        }

        styles {
            element "server" {
                background #1168bd
                color #ffffff
            }

            element "dep" {
                background #e5e4e2
                color #000000
            }

            element "client" {
                background #e5e4e2
                color #000000
            }

            element "Person" {
                shape person
                background #08427b
                color #ffffff
            }
        }

    }

}

基于语境图,就好比我们站在万米高空一览Our Software System。不过对于架构设计表达来说,这还不够,现在是时候下降高度让视野进入到系统内部去挖掘一些细节了。

3.2 容器图(Container Diagram)

在从万米高空的系统全局视角了解了我们的软件系统是什么后,我们将第一次进入到系统内部。我们现在所处的高度是100米,在这个高度上,可以清晰地看到软件系统的整体形态、内部脉络、技术选择、职责分布以及各个部分之间是如何交流的。我们将每个部分称为一个容器(container)。一个容器通常可以表示一个应用/服务或数据存储,如果你的软件系统采用了微服务架构,那么将每个服务作为一个容器通常是可行的。

针对每个容器,我们可以设置它的属性:名字(如Web App、API网关、关系数据库存储、订阅服务等)、实现技术(如mvc等)以及功能性的描述。在容器间的联系上我们可以附加上通信方式(json over http、gRPC、websocket等)。

下面是上面语境图中的My Software System的容器图:

在这个容器图中,我们看到了系统支持通过Web app和mobile app访问和使用;系统的入口使用了API网关;系统内部分为业务服务和基础服务,基础服务封装了到关系数据库、对象存储(oss)的接口(关系数据库和oss都是技术选择);业务服务可以调用企业内部服务,亦可调用企业外部服务,并且明确了调用方式。

下面是生成上述容器图的structurizr的代码:

// container diagrams

workspace {

    model {
        u = person "User"

        enterprise = group "Enterprise A" {
            s = softwareSystem "Our Software System" {
                tags "server"

                mobileApp = container "Mobile App" {
                    tags "container"
                }

                webApp = container "Web App" {
                     tags "container"
                }

                apiGw = container "API Gateway" {
                     tags "container"
                }

                biz1 = container "Business Service 1" {
                     tags "container"
                }

                biz2 = container "Business Service 2" {
                     tags "container"
                }

                biz3 = container "Business Service 3" {
                     tags "container"
                }

                base1 = container "Base Service 1" {
                     tags "container"
                }

                base2 = container "Base Service 2" {
                     tags "container"
                }

                base3 = container "Base Service 3" {
                     tags "container"
                }

                rds = container "Relational Database system" {
                     tags "container"
                }

                oss = container "Object Storage Service" {
                     tags "container"
                }
            }

            d1 = softwareSystem "Inner System1" {
                tags "dep"
            }

            d2 = softwareSystem "Inner System2" {
                tags "dep"
            }
        }

        d3 = softwareSystem "Outer System1" {
            tags "dep"
        }

        d4 = softwareSystem "Outer System2" {
            tags "dep"
        }

        u -> mobileApp "Uses"
        u -> webApp "Uses"
        mobileApp -> apiGw "Makes API calls to" "JSON/HTTPS"
        WEBApp -> apiGw "Makes API calls to" "JSON/HTTPS"
        apiGw -> biz1 "Route API calls to" "gRPC"
        apiGw -> biz2 "Route API calls to" "gRPC"
        apiGw -> biz3 "Route API calls to" "gRPC"
        biz1 -> base1 "Inner API calls to" "gRPC"
        biz1 -> base2 "Inner API calls to" "gRPC"
        biz2 -> base2 "Inner API calls to" "gRPC"
        biz2 -> base3 "Inner API calls to" "gRPC"
        biz3 -> base3 "Inner API calls to" "gRPC"
        base1 -> rds "Reads from and writes to" "Raw SQL"
        base1 -> oss "Reads from and writes to" "HTTPS"
        base2 -> rds "Reads from and writes to" "Raw SQL"
        base3 -> oss "Reads from and writes to" "HTTPS"
        biz1 -> d1 "Make API calls to" "HTTP"
        biz2 -> d3 "Make API calls to" "HTTP"
        biz3 -> d2 "Make API calls to" "HTTP"
        biz3 -> d4 "Make API calls to" "HTTP"
    }

    views {
        container s {
            include *
            autoLayout
        }

        styles {
            element "server" {
                background #1168bd
                color #ffffff
            }

            element "container" {
                background #1168bd
                color #ffffff
            }

            element "dep" {
                background #e5e4e2
                color #000000
            }

            element "Person" {
                shape person
                background #08427b
                color #ffffff
            }
        }

    }

}

注:在容器图这个层次上,group关键字没有起作用,导致企业内部服务与外部服务放在一起了。

按照C4 model的思路,接下来我们会再下降高度,来到10米的高空,进入到某个容器的内部。但容器内部的设计在我看来属于详细设计范畴,如果采用的是微服务架构,那么容器内部的设计就相当于某个服务的设计。所以这里,我并未将这部分作为架构表达的必需之图。

3.3 序列图(Sequence Diagram)

无论是语境图,还是容器图,从大类来看,都属于静态的结构图。但做过软件系统设计和研发的童鞋都知道,仅有静态的表达还是不够的,不足以传达软件系统的所有信息,我们还需要对动态行为的表达。这就是为什么我将序列图作为软件表达最小图集一份子的原因。

可能有些人将序列图作为需求分析阶段的产物,其实,序列图既可以在需求阶段产生,也可以在架构设计阶段产生。它在不同阶段有不同的应用和目的。

在需求阶段,序列图被用于描述系统的功能需求和行为。它可以帮助分析和定义系统的用例或用户故事,以及系统与外部实体(如用户、其他系统、服务等)之间的交互过程。通过序列图,需求分析人员和开发团队可以更清晰地理解系统的功能需求,并就用户与系统之间的交互进行沟通和确认。

在架构设计阶段,序列图被用于描述系统的结构和组件之间的交互。在这个阶段,序列图通常用于展示系统的运行时行为、组件之间的消息传递和调用关系。架构师使用序列图来验证系统的设计方案,确保系统的各个组件按预期互相协作,并满足功能和性能要求。

这里的序列图,可以对应前面的Arc42的Runtime View,以及C4 model的Dynamic Diagram

序列图也是UML语言中最常被使用的一种Diagram,即便是在UML不那么被提及的今天,我个人也推荐使用UML的序列图来表达,而不推荐用structurizr来画了,structurizr在序列图方面的表达能力还是弱了许多。

你可以用你最喜欢的画图工具来绘制UML序列图(比如我经常用的drawio),也可以选择plantuml这种基于DSL语法生成序列图的方式来绘制。plantuml对序列图的支持还是非常好的,支持了序列图的大多数元素,可以绘制出非常复杂的图来(下图来自plantuml官网):

针对一个复杂的软件系统,我们可能需要针对不同的Container(或更进一步的组件)绘制较多的序列图,至少要覆盖到软件系统各个Container的核心交互流程。

3.4 部署图(Deployment Diagram)

无论是C4模型,还是arc42,亦或是UML语言,都包含部署图。在软件架构表达时,准确表达部署设计,对开发人员后续的实现具有很好的指导作用。通过部署图,架构设计人员可以说明静态图中的软件系统和/或容器实例是如何部署到给定部署环境(如生产、暂存、开发等)中的基础设施上的,比如下面这个部署示意图(来自c4model.com):

我们看到部署图中的核心角色是部署节点(Node),它代表了软件系统/容器实例运行的位置;可能是物理基础设施(如物理服务器或设备)、虚拟化基础设施(如IaaS、PaaS、虚拟机)、容器化基础设施(如Docker容器)、执行环境(如数据库服务器、Java EE Web/应用服务器、Microsoft IIS)等,并且部署节点还可以嵌套。此外,右下角的”x N”表示需要多少个部署节点。

通过部署图还可以表达云基础架构的情况(下图来自c4model.com),可以包含DNS、负载均衡器以及防火墙等部署的基础设施的节点:

structurizr对于部署图支持的还不错,还可以像上图那样使用不同公有云提供商特色的Theme来绘制部署图。

到这里,我们已经“凑齐”了表达软件系统架构的最小图集:语境图、容器图、序列图和部署图。我们要学会灵活使用这些图。在软件系统十分复杂的情况下,我们可以将语境图分为System Landscape diagram和多个sub system的语境图,之后以此类推,对于每个sub system做容器图等。

4. 最小图集之外的图(可选)

有些公司或组织会将架构设计阶段延伸到container内部,这样对软件系统架构的表达就要延伸到详细设计,甚至是编码阶段时,我们就要考虑下面两个类型的Diagram了:组件图和代码图。

4.1 组件图(Component Diagram)

如果容器图阶段,你所在的高度是100米,那么组件图阶段,你将位于高度为10米的空中,这足以让你看清容器中每个组件(Component)的细节。

组件图就是容器内部的设计,它涉及到容器内部各个逻辑组件的结构与组件间的交互。在这个层次,你可以使用你擅长的面向对象设计方法,或者面向契约/接口的设计模式,你也可以使用一些成熟的企业应用设计模式,比如MVC等。

下面是一张组件图示例(来自c4model.com):

我们看到中间的部分就是API Application这个容器内部的逻辑组件结构与交互情况。有些时候在组件图这一层面,我们甚至可以对照初对应项目中的代码布局结构。

对于组件图中关键组件间的复杂交互流程,可辅以序列图的方式来表达。

此外,组件图可以使用structurizr绘制,语法和语境图、容器图十分相似。

4.2 代码图(Code Diagram)

再下降,我们来到离地面1米的高度,我们几乎要躬身入局,参与编码了。通常架构设计不会到达这个阶段,架构师们在100米或10米高度完成任务后,就可以去休息了。

但如果包含这个阶段,我们要给出的便是代码图(Code Diagram),再直白些,就是UML类图、E-R关系图等,下面是一个示意图:

这是一个直面开发人员的图,你可以看到编程语言中的那些机制:接口、继承、实现等等,开发人员甚至可以通过工具将这样的uml class图直接转换为项目的骨架代码。

4. 小结

本文首先介绍了为什么软件架构需要有效表达,以便开发者更好地理解架构设计。然后回顾了软件架构表达方式的演进历史,从自然语言描述到图形化表达,再到结构化图形表达、UML、形式化表达,最终发展到现在的多视角表达方式。

文章结合笔者实践经验,借鉴多个多视角软件架构模型,提出了最小图集的概念,笔者认为有效表达软件架构最关键的视角有四个,分别是:

  1. 语境图:描述系统的整体位置和边界
  2. 容器图:展示系统内部的容器及其关系
  3. 序列图:呈现容器内组件以及组件之间的交互行为
  4. 部署图:阐明系统在实际环境中的部署情况

此外,我认为还可根据需要补充组件图和代码图等更细节的视图。这套最小图集能较全面地表达软件系统的静态结构和动态行为,帮助开发者理解架构设计。

总的来说,该文章从工程实践的视角出发,提出了一套行之有效的软件架构表达方法,对于架构设计的团队沟通及实现具有很好的指导意义。

btw,在容器图或组件图设计阶段,如果要完善工程设计,还可以结合具体的接口文档予以表达,比如基于Swagger的API设计文档等。

5. 参考资料


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats