导读
本文主要受《程序员修炼之道: 通向务实的最高境界》、《架构整洁之道》、《Unix 编程艺术》启发。我不是第一个发明这些原则的人,甚至不是第一个总结出来的人,别人都已经写成书了!务实的程序员对于方法的总结,总是殊途同归。
目录
1 细节即是架构
2 把代码和文档绑在一起
3 ETC 价值观
4 DRY 原则
……
19 SOLID
20 一个函数不要出现多个层级的代码
21 Unix 哲学基础
细节即是架构
下面是原文摘录,我有类似观点,但是原文就写得很好,直接摘录。
一直以来,设计(Design)和架构(Architecture)这两个概念让大多数人十分迷惑--什么是设计?什么是架构?二者究竟有什么区别?二者没有区别。一丁点区别都没有!"架构"这个词往往适用于"高层级"的讨论中,这类讨论一般都把"底层"的实现细节排除在外。而"设计"一词,往往用来指代具体的系统底层组织结构和实现的细节。但是,从一个真正的系统架构师的日常工作来看,这些区分是根本不成立的。以给我设计新房子的建筑设计师要做的事情为例。新房子当然是存在着既定架构的,但这个架构具体包含哪些内容呢?首先,它应该包括房屋的形状、外观设计、垂直高度、房间的布局,等等。
但是,如果查看建筑设计师使用的图纸,会发现其中也充斥着大量的设计细节。譬如,我们可以看到每个插座、开关以及每个电灯具体的安装位置,同时也可以看到某个开关与所控制的电灯的具体连接信息;我们也能看到壁炉的具体位置,热水器的大小和位置信息,甚至是污水泵的位置;同时也可以看到关于墙体、屋顶和地基所有非常详细的建造说明。总的来说,架构图里实际上包含了所有的底层设计细节,这些细节信息共同支撑了顶层的架构设计,底层设计信息和顶层架构设计共同组成了整个房屋的架构文档。
软件设计也是如此。底层设计细节和高层架构信息是不可分割的。他们组合在一起,共同定义了整个软件系统,缺一不可。所谓的底层和高层本身就是一系列决策组成的连续体,并没有清晰的分界线。
我们编写、review 细节代码,就是在做架构设计的一部分。我们编写的细节代码构成了整个系统。我们就应该在细节 review 中,始终带着所有架构原则去审视。你会发现,你已经写下了无数让整体变得丑陋的细节,它们背后,都有前人总结过的架构原则。
把代码和文档绑在一起(自解释原则)
写文档是个好习惯。但是写一个别人需要咨询老开发者才能找到的文档,是个坏习惯。这个坏习惯甚至会给工程师们带来伤害。比如,当初始开发者写的文档在一个犄角旮旯(在 wiki 里,但是阅读代码的时候没有在明显的位置看到链接),后续代码被修改了,文档已经过时,有人再找出文档来获取到过时、错误的知识的时候,阅读文档这个同学的开发效率必然受到伤害。所以,如同 Golang 的 godoc 工具能把代码里“按规范来”的注释自动生成一个文档页面一样,我们应该:
▶︎ 按照 godoc 的要求好好写代码的注释。
▶︎ 代码首先要自解释,当解释不了的时候,需要就近、合理地写注释。
▶︎ 当小段的注释不能解释清楚的时候,应该有 doc.go 来解释,或者在同级目录的 ReadMe.md 里注释讲解。
▶︎ 文档需要强大的富文本编辑能力,Down 无法满足,可以写到 wiki 里,同时必须把 wiki 的简单描述和链接放在代码里合适的位置。让阅读和维护代码的同学一眼就看到,能做到及时的维护。
以上总结起来就是,解释信息必须离被解释的东西越近越好。代码能做到自解释,是最棒的。
ETC 价值观(easy to change)
ETC 是一种价值观念,不是一条原则。价值观念是帮助你做决定的: 我应该做这个,还是做那个?当你在软件领域思考时,ETC 是个向导,它能帮助你在不同的路线中选出一条。就像其他一些价值观念一样,你应该让它漂浮在意识思维之下,让它微妙地将你推向正确的方向。
敏捷软件工程,所谓敏捷,就是要能快速变更,并且在变更中保持代码的质量。所以,持有 ETC 价值观看待代码细节、技术方案,我们将能更好地编写出适合敏捷项目的代码。这是一个大的价值观,不是一个基础微观的原则,所以没有例子。本文提到的所有原则,或直接,或间接,都要为 ETC 服务。
DRY 原则(don not repeat yourself)
我认为 DRY 原则是编码原则中最重要的编码原则,没有之一(ETC 是个观念)。不要重复!不要重复!不要重复!
正交性原则(全局变量的危害)
“正交性”是几何学中的术语。我们的代码应该消除不相关事物之间的影响。这是一个简单的道理。我们写代码要“高内聚、低耦合”,这是大家都在提的。
但是,你有为了使用某个 class 一堆能力中的某个能力而去派生它么?你有写过一个 helper 工具,它什么都做么?在腾讯,我相信你是做过的。你自己说,你这是不是为了复用一点点代码,而让两大块甚至多块代码耦合在一起,不再正交了?大家可能并不是不明白正交性的价值,只是不知道怎么去正交。手段有很多,但是首先我就要批判一下 OOP。它的核心是多态,多态需要通过派生/继承来实现。继承树一旦写出来,就变得很难 change,你不得不为了使用一小段代码而去做继承,让代码耦合。
你应该多使用组合,而不是继承。以及,应该多使用 DIP(Dependence Inversion Principle),依赖倒置原则。换个说法,就是面向 interface 编程,面向契约编程,面向切面编程,他们都是 DIP 的一种衍生。写 Golang 的同学就更不陌生了,我们要把一个 struct 作为一个 interface 来使用,不需要显式 implement/extend,仅仅需要持有对应 interface 定义了的函数。这种 duck interface 的做法,让 DIP 来得更简单。AB 两个模块可以独立编码,他们仅仅需要一个依赖 interface 签名,一个刚好实现该 interface 签名。并不需要显式知道对方 interface 签名的两个模块就可以在需要的模块、场景下被组合起来使用。代码在需要被组合使用的时候才产生了一点关系,同时,它们依然保持着独立。
说个正交性的典型案例。全局变量是不正交的!没有充分的理由,禁止使用全局变量。全局变量让依赖了该全局变量的代码段互相耦合,不再正交。特别是一个 pkg 提供一个全局变量给其他模块修改,这个做法会让 pkg 之间的耦合变得复杂、隐秘、难以定位。
单例就是全局变量
后面有“共享状态就是不正确的状态”原则,会进一步讲到。我先给出解决方案,可以通过管道、消息机制来替代共享状态/使用全局变量/使用单例。仅仅能获取此刻最新的状态,通过消息变更状态。要拿到最新的状态,需要重新获取。在必要的时候,引入锁机制。
可逆性原则
可逆性原则是很少被提及的一个原则。可逆性,就是你做出的判断,最好都是可以被逆转的。再换一个容易懂的说法,你最好尽量少认为什么东西是一定的、不变的。比如,你认为你的系统永远服务于用 32 位无符号整数(比如 QQ 号)作为用户标识的系统。你认为你的持久化存储就选型 SQL 存储了。当这些一开始你认为一定的东西,被推翻的时候,你的代码却很难去 change,那么,你的代码就是可逆性做得很差。书里有一个例证,我觉得很好,直接引用过来。
写下这段文字的时间是2019年。自世纪之交以来,我们看到了以下“服务端架构的最佳实践”:
* 大铁块
* 大铁块的组合
* 带负载均衡的商用硬件集群大
* 将程序运行在云虚拟机中大
* 把服务运行在云虚拟机中
* 把云虚拟机换成容器再来一遍
* 基于云的无服务器架构大
* 最后,无可避免的,有些任务又回到了大铁块
...(省略了文字)... 你能为这种架构的变化提前准备吗? 做不到。你能做的就是让修改更容易一点。将第三方 API 隐藏在自己的抽象层之后。将代码分解成多个组件:即使最终会把它们部署到单个大型服务器上,这种方法也比一开始做成庞然大物,然后再切分要容易得多。
与其认为决定是被刻在石头上的,还不如把它们想像成写在沙滩的沙子上。一个大浪随时都可能袭来,卷走一切。腾讯也确实在 20 年内经历了“大铁块”到“云虚拟机换成容器”的几个阶段。几次变化都是伤筋动骨,消耗大量的时间,甚至总会有一些上一个时代残留的服务。就机器数量而论,还不小,一到裁撤季,就很难受。就最近,我看到某个 trpc 插件,直接从环境变量里读取本机 IP,仅仅因为 STKE(Tencent Kubernetes Engine)提供了这个能力。这个细节设计就是不可逆的,将来会有人为它买单,可能价格还不便宜。
依赖倒置原则(DIP)
DIP 原则太重要了,我这里单独列一节来讲解。依赖倒置原则,全称是 Dependence Inversion Principle,简称 DIP。考虑下面这几段代码:
package dippackage diptype Botton interface { TurnOn() TurnOff()}type UI struct { botton Botton}func NewUI(b Botton) *UI { return &UI{botton: b}}func (u *UI) Poll() { u.botton.TurnOn() u.botton.TurnOff() u.botton.TurnOn()}
package javaimplimport "fmt"type Lamp struct {}func NewLamp() *Lamp { return &Lamp{}}func (*Lamp) TurnOn() { fmt.Println("turn on java lamp")}func (*Lamp) TurnOff() { fmt.Println("turn off java lamp")}
package pythonimplimport "fmt"type Lamp struct {}func NewLamp() *Lamp { return &Lamp{}}func (*Lamp) TurnOn() { fmt.Println("turn on python lamp")}func (*Lamp) TurnOff() { fmt.Println("turn off python lamp")}
package mainimport ( "javaimpl" "pythonimpl" "dip")func runPoll(b dip.Botton) { ui := NewUI(b) ui.Poll()}func main() { runPoll(pythonimpl.NewLamp()) runPoll(javaimpl.NewLamp())}
看代码,main pkg 里的 runPoll 函数仅仅面向 Botton interface 编码,main pkg 不再关心 Botton interface 里定义的 TurnOn、TurnOff 的实现细节。实现了解耦。这里,我们能看到 struct UI 需要被注入(inject)一个 Botton interface 才能逻辑完整。所以,DIP 经常换一个名字出现,叫做依赖注入(Dependency Injection)。
从这个依赖图观察。我们发现,一般来说,UI struct 的实现是要应该依赖于具体的 PythonLamp、JavaLamp、其他各种 Lamp,才能让自己的逻辑完整。那就是 UI struct 依赖于各种 Lamp 的实现,才能逻辑完整。但是,我们看上面的代码,却是反过来了。PythonLamp、JavaLamp、其他各种 Lamp 是依赖 Botton interface 的定义,才能用来和 UI struct 组合起来拼接成完整的业务逻辑。变成了,Lamp 的实现细节,依赖于 UI struct 对于 Botton interface 的定义。这个时候,你发现,这种依赖关系被倒置了!依赖倒置原则里的“倒置”,就是这么来的。在 Golang 里,'PythonLamp、JavaLamp、其他各种 Lamp 是依赖 Botton interface 的定义',这个依赖是隐性的,没有显式的 implement 和 extend 关键字。代码层面,pkg dip 和 pkg pythonimpl、javaimpl 没有任何依赖关系。他们仅仅需要被你在 main pkg 里组合起来使用。
在 J2EE 里,用户的业务逻辑不再依赖低具体低层的各种存储细节,而仅仅依赖一套配置化的 Java Bean 接口。Object 落地存储的具体细节,被做成了 Java Bean 配置,注入到框架里。这就是 J2EE 的核心科技,并不复杂,其实也没有多么“高不可攀”。在“动态代码”优于“配置”的今天,这种通过配置实现的依赖注入,反而有点过时了。
将知识用纯文本来保存
这也是一个生僻的原则。指代码操作的数据和方案设计文稿,如果没有充分的必要使用特定的方案,就应该使用人类可读的文本来保存、交互。对于方案设计文稿,你能不使用 office 格式,就不使用(office 能极大提升效率才用),最好是原始 text。这是《Unix 编程艺术》也提到了的 Unix 系产生的设计信条。简而言之一句话,当需要确保有一个所有各方都能使用的公共标准,才能实现交互沟通时,纯文本就是这个标准。它是一个接受度最高的通行标准,如果没有必要的理由,我们就应该使用纯文本。
契约式设计
如果你对契约式设计(Design by Contract, DBC)还很陌生,我相信,你和其他端的同学(web、client、后端)联调需求应该是一件很花费时间的事情。你自己编写接口自动化,也会是一件很耗费精力的事情。你先看看它的 wiki 解释吧。grpc + grpc-gateway + swagger 是个很香的东西。
代码是否不多不少刚好完成它宣称要做的事情,可以使用契约加以校验和文档化。TDD 就是全程在不断调整和履行着契约。TDD(Test-Driven Development)是自底向上的编码过程,其实会耗费大量的精力,并且对于一个良好的层级架构没有帮助。TDD 不是强推的规范,但是同学们可以用一用,感受一下。TDD 方法论实现的接口、函数,自我解释能力一般来说比较强,因为它就是一个实现契约的过程。
抛开 TDD 不谈。我们的函数、API,你能快速抓住它描述的核心契约么?它的契约简单么?如果不能、不简单,那你应该要求被 review 的代码做出调整。如果你在指导一个后辈,你应该帮他思考一下,给出至少一个可能的简化、拆解方向。
尽早崩溃
Erlang 发明者、《Erlang 程序设计》作者乔·阿姆斯特朗有一句反复被引用的话:“防御式编程是在浪费时间,让它崩溃。”
尽早崩溃不是说不容错,而是程序应该被设计成允许出故障,有适当的故障监管程序和代码,及时告警,告知工程师,哪里出问题了,而不是尝试掩盖问题,不让程序员知道。当最后程序员知道程序出故障的时候,已经找不到问题出现在哪里了。
特别是一些 recover 之后什么都不做的代码,这种代码简直是毒瘤!当然,崩溃,可以是早一些向上传递 error,不一定就是 panic。同时,我要求大家不要在没有充分的必要性的时候 panic,应该更多地使用向上传递 error,做好 metrics 监控。合格的 Golang 程序员,都不会在没有必要的时候无视 error,会妥善地做好 error 处理、向上传递、监控。一个死掉的程序,通常比一个瘫痪的程序,造成的损害要小得多。
崩溃但是不告警,或者没有补救的办法,不可取。尽早崩溃的题外话是,要在问题出现的时候做合理的告警,有预案,不能掩盖,不能没有预案:
解耦代码让改变容易
这个原则,显而易见,大家自己也常常提,其他原则或多或少都和它有关系。但是我也再提一提。我主要是描述一下它的症状,让同学们更好地警示自己“我这两块代码是不是耦合太重,需要额外引入解耦的设计了”。症状如下:
▶︎ 不相关的 pkg 之间古怪的依赖关系;
▶︎ 对一个模块进行的“简单”修改,会传播到系统中不相关的模块里,或是破坏了系统中的其他部分;
▶︎ 开发人员害怕修改代码,因为他们不确定会造成什么影响;
▶︎ 会议要求每个人都必须参加,因为没有人能确定谁会受到变化的影响。
只管命令不要询问
看看如下三段代码:
func applyDiscount(customer Customer, orderID string, discount float32) {
customer.
Orders.
Find(orderID).
GetTotals().
ApplyDiscount(discount)
}
func applyDiscount(customer Customer, orderID string, discount float32) {
customer.
FindOrder(orderID).
GetTotals().
ApplyDiscount(discount)
}
func applyDiscount(customer Customer, orderID string, discount float32) {
customer.
FindOrder(orderID).
ApplyDiscount(discount)
}
明显,最后一段代码最简洁。不关心 Orders 成员、总价的存在,直接命令 customer 找到 Order 并对其进行打折。当我们调整 Orders 成员、GetTotals()方法的时候,这段代码不用修改。还有一种更吓人的写法:
func applyDiscount(customer Customer, orderID string, discount float32) {
total := customer.
FindOrder(orderID).
GetTotals()
customer.
FindOrder(orderID).
SetTotal(total*discount)
}
它做了更多的查询,关心了更多的细节,变得更加 hard to change 了。我相信大家,特别是客户端同学,写过不少类似的代码。
最好的那一段代码,就是只管给每个 struct 发送命令,要求大家做事儿。怎么做,就内聚在和 struct 关联的方法里,其他人不要去操心。一旦其他人操心了,当需要做修改的时候,就要操心了这个细节的人都一起参与进修改过程。
不要链式调用方法
看下面的例子:
func amount(customer Customer) float32 {
return customer.Orders.Last().Totals().Amount
}
func amount(totals Totals) float32 {
return totals.Amount
}
第二个例子明显优于第一个,它变得更简单、通用、ETC。我们应该给函数传入它关心的最小集合作为参数。而不是我有一个 struct,当某个函数需要这个 struct 的成员的时候,我们把整个 struct 都作为参数传递进去。应该仅仅传递函数关心的最小集合。传进去的一整条调用链对函数来说,都是无关的耦合,只会让代码更 hard to change,让工程师惧怕去修改。这一条原则,和上一条关系很紧密,问题常常同时出现。同样,特别容易出现在客户端代码里。
继承税(多用组合)
继承就是耦合。不仅子类耦合到父类,以及父类的父类等,而且使用子类的代码也耦合到所有祖先类。有些人认为继承是定义新类型的一种方式。他们喜欢设计图表,会展示出类的层次结构。他们看待问题的方式,与维多利亚时代的绅士科学家们看待自然的方式是一样的,即将自然视为须分解到不同类别的综合体。不幸的是,这些图表很快就会为了表示类之间的细微差别而逐层添加,最终可怕地爬满墙壁。由此增加的复杂性,可能使应用程序更加脆弱,因为变更可能在许多层次之间上下波动。因为一些值得商榷的词义消歧方面的原因,C++在20世纪90年代玷污了多重继承的名声。结果许多当下的 OO 语言都没有提供这种功能。
因此,即使你很喜欢复杂的类型树,也完全无法为你的领域准确地建模。
Java 下一切都是类。C++里不使用类还不如使用 C。写 Python、PHP,我们也肯定要时髦地写一些类。写类可以,当你要去继承,你就得考虑清楚了。继承树一旦形成,就是非常 hard to change 的,在敏捷项目里,你要想清楚“代价是什么”,有必要么?这个设计“可逆”么?对于边界清晰的 UI 框架、游戏引擎,使用复杂的继承树,挺好的。对于 UI 逻辑、后台逻辑,可能,你仅仅需要组合、DIP(依赖反转)技术、契约式编程(接口与协议)就够了。写出继承树不是“就应该这么做”,它是成本,继承是要收税的!
在 Golang 下,继承税的烦恼被减轻了,Golang 从来说自己不是 OO 的语言,但是你 OO 的事情,我都能轻松地做到。更进一步,OO 和过程式编程的区别到底是什么?
面向过程,面向对象,函数式编程。三种编程结构的核心区别,是在不同的方向限制程序员,来做到好的代码结构(引自《架构整洁之道》):
▶︎ 结构化编程是对程序控制权的直接转移的限制。
▶︎ 面向对象是对程序控制权的间接转移的限制。
▶︎ 函数式编程是对程序中赋值操作的限制。
SOLID 原则(单一功能、开闭原则、里氏替换、接口隔离、依赖反转,后面会讲到)是 OOP 编程的最经典的原则。其中 D 是指依赖倒置原则(Dependence Inversion Principle),我认为,是 SOLID 里最重要的原则。J2EE 的 container 就是围绕 DIP 原则设计的。DIP 能用于避免构建复杂的继承树,DIP 就是'限制控制权的间接转移'能继续发挥积极作用的最大保障。合理使用 DIP 的 OOP 代码才可能是高质量的代码。
Golang 的 interface 是 duck interface,把 DIP 原则更进一步,不需要显式 implement/extend interface,就能做到 DIP。Golang 使用结构化编程范式,却有面向对象编程范式的核心优点,甚至简化了。这是一个基于高度抽象理解的极度精巧的设计。Google 把 abstraction 这个设计理念发挥到了极致。曾经,J2EE 的 container(EJB, Java Bean)设计是国内 Java 程序员引以为傲“架构设计”、“厉害的设计”。
在 Golang 里,它被分析、解构,以更简单、灵活、统一、易懂的方式呈现出来。写了多年 C++代码的腾讯后端工程师们,是你们再次审视 OOP 的时候了。我大学一年级的时候看的 C++教材,给我描述了一个美好却无法抵达的世界。目标我没有放弃,但我不再用 OOP,而是更多地使用组合(Mixin)。写 Golang 的同学,应该对 DIP 和组合都不陌生,这里我不再赘述。如果有人自傲地说他在 Golang 下搞起了继承,我只能说,“同志,你现在站在了广大 Gopher 的对立面”。现在,你站在哲学的云端,鸟瞰了 Structured Programming 和 OOP。你还愿意再继续支付继承税么?
共享状态是不正确的状态
你坐在最喜欢的餐厅。吃完主菜,问男服务员还有没有苹果派。他回头一看,陈列柜里还有一个,就告诉你“还有”。点到了苹果派,你心满意足地长出了一口气。与此同时,在餐厅的另一边,还有一个顾客也问了女服务员同样的问题。她也看了看,确认有一个,让顾客点了单。总有一个顾客会失望的。
问题出在共享状态。餐厅里的每一个服务员都查看了陈列柜,却没有考虑到其他服务员。你们可以通过加互斥锁来解决正确性的问题,但是,两个顾客有一个会失望或者很久都得不到答案,这是肯定的。
所谓共享状态,换个说法,就是: 由多个人查看和修改状态。这么一说,更好的解决方案就浮出水面了: 将状态改为集中控制。预定苹果派,不再是先查询,再下单。而是有一个餐厅经理负责和服务员沟通,服务员只管发送下单的命令/消息,经理看情况能不能满足服务员的命令。
这种解决方案,换一个说法,也可以说成“用角色实现并发性时不必共享状态”。我们引入了餐厅经理这个角色,赋予了他职责。当然,我们仅仅应该给这个角色发送命令,不应该去询问他。前面讲过了,“只管命令不要询问”,你还记得么。
同时,这个原则就是 golang 里大家耳熟能详的谚语: “不要通过共享内存来通信,而应该通过通信来共享内存”。作为并发性问题的根源,内存的共享备受关注。但实际上,在应用程序代码共享可变资源(文件、数据库、外部服务)的任何地方,问题都有可能冒出来。当代码的两个或多个实例可以同时访问某些资源时,就会出现潜在的问题。
缄默原则
如果一个程序没什么好说,就保持沉默。过多的正常日志,会掩盖错误信息。过多的信息,会让人根本不再关注新出现的信息,“更多信息”变成了“没有信息”。每人添加一点信息,就变成了输出很多信息,最后等于没有任何信息。
▶︎ 不要在正常 case 下打印日志。
▶︎ 不要在单元测试里使用 fmt 标准输出,至少不要提交到 master。
▶︎ 不打不必要的日志。当错误出现的时候,会非常明显,我们能第一时间反应过来并处理。
▶︎ 让调试的日志停留在调试阶段,或者使用较低的日志级别,你的调试信息,对其他人根本没有价值。
▶︎ 即使低级别日志,也不能泛滥。不然,日志打开与否都没有差别,日志变得毫无价值。
错误传递原则
我不喜欢 Java 和 C++的 exception 特性,它容易被滥用,它具有传染性(如果代码 throw 了 excepttion, 你就得 handle 它,不 handle 它,你就崩溃了。可能你不希望崩溃,你仅仅希望报警)。但是 exception(在 golang 下是 panic)是有价值的,参考微软的文章:
Exceptions are preferred in modern C++ for the following reasons:
* An exception forces calling code to recognize an error condition and handle it. Unhandled exceptions stop program execution.
* An exception jumps to the point in the call stack that can handle the error. Intermediate functions can let the exception propagate. They don't have to coordinate with other layers.
* The exception stack-unwinding mechanism destroys all objects in scope after an exception is thrown, according to well-defined rules.
* An exception enables a clean separation between the code that detects the error and the code that handles the error.
Google 的 C++规范在常规情况禁用 exception,理由包含如下内容:
Because most existing C++ code at Google is not prepared to deal with exceptions, it is comparatively difficult to adopt new code that generates exceptions.
从 google 和微软的文章中,我们不难总结出以下几点衍生的结论:
▶︎ 在必要的时候抛出 exception。使用者必须具备“必要性”的判断能力。
▶︎ exception 能一路把底层的异常往上传递到高函数层级,信息被向上传递,并且在上级被妥善处理。可以让异常和关心具体异常的处理函数在高层级和低层级遥相呼应,中间层级什么都不需要做,仅仅向上传递。
▶︎ exception 传染性很强。当代码由多人协作,使用 A 模块的代码都必须要了解它可能抛出的异常,做出合理的处理。不然,就都写一个丑陋的 catch,catch 所有异常,然后做一个没有针对性的处理。每次 catch 都需要加深一个代码层级,代码常常写得很丑。
我们看到了异常的优缺点。上面第二点提到的信息传递,是很有价值的一点。golang 在 1.13 版本中拓展了标准库,支持了Error Wrapping也是承认了 error 传递的价值。
所以,我们认为错误处理,应该具备跨层级的错误信息传递能力,中间层级如果不关心,就把 error 加上本层的信息向上透传(有时候可以直接透传),应该使用 Error Wrapping。exception/panic 具有传染性,大量使用,会让代码变得丑陋,同时容易滋生可读性问题。我们应该多使用 Error Wrapping,在必要的时候,才使用 exception/panic。每一次使用 exception/panic,都应该被认真审核。需要 panic 的地方,不去 panic,也是有问题的。参考本文的“尽早崩溃”。
额外说一点,注意不要把整个链路的错误信息带到公司外,带到用户的浏览器、native 客户端。至少不能直接展示给用户看到。
SOLID
SOLID 原则,是以下几个原则的集合体:
▶︎ SRP:单一职责原则;
▶︎ OCP:开闭原则;
▶︎ LSP:里氏替换原则;
▶︎ ISP:接口隔离原则;
▶︎ DIP:依赖反转原则。
这些年来,这几个设计原则在很多不同的出版物里都有过详细描述。它们太出名了,我这里就不做详解了。我想说的是,这 5 个原则环环相扣,前 4 个原则,要么就是同时做到,要么就是都没做到,很少有说,做到其中一点其他三点都不满足。ISP 就是做到 LSP 的常用手段。ISP 也是做到 DIP 的基础。只是,它刚被提出来的时候,是主要针对“设计继承树”这个目的的。现在,它们已经被更广泛地使用在模块、领域、组件这种更大的概念上。
SOLI 都显而易见,DIP 原则是最值得注意的一点,我在其他原则里也多次提到了它。如果你还不清楚什么是 DIP,一定去看明白。这是工程师最基础、必备的知识点之一了。
要做到 OCP 开闭原则,其实,就是要大家要通过后面讲到的“不要面向需求编程”才能做好。如果你还是面向需求、面向 UI、交互编程,你永远做不到开闭,并且不知道如何才能做到开闭。
如果你对这些原则确实不了解,建议读一读《架构整洁之道》。该书的作者 Bob 大叔,就是第一个提出 SOLID 这个集合体的人(20 世纪 80 年代末,在 USENET 新闻组)。
一个函数不要出现多个层级的代码
// IrisFriends 拉取好友func IrisFriends(ctx iris.Context, app *app.App) { var rsp sdc.FriendsRsp defer func() { var buf bytes.Buffer _ = (&jsonpb.Marshaler{EmitDefaults: true}).Marshal(&buf, &rsp) _, _ = ctx.Write(buf.Bytes()) }() common.AdjustCookie(ctx) if !checkCookie(ctx) { return } // 从cookie中拿到关键的登陆态等有效信息 var session common.BaseSession common.GetBaseSessionFromCookie(ctx, &session) // 校验登陆态 err := common.CheckLoginSig(session, app.ConfigStore.Get().OIDBCmdSetting.PTLogin) if err != nil { _ = common.ErrorResponse(ctx, errors.PTSigErr, 0, "check login sig error") return } if err = getRelationship(ctx, app.ConfigStore.Get().OIDBCmdSetting, NewAPI(), &rsp); err != nil { // TODO:日志 } return}
上面这一段代码,是我随意找的一段代码。逻辑非常清晰,因为除了最上面 defer 写回包的代码,其他部分都是顶层函数组合出来的。阅读代码,我们不会掉到细节里出不来,反而忽略了整个业务流程。同时,我们能明显发现它没写完,以及 common.ErrorResponse 和 defer func 两个地方都写了回包,可能出现发起两次 http 回包。TODO 也会非常显眼。
想象一下,我们没有把细节收归进 checkCookie()、getRelationship()等函数,而是展开在这里,但是总函数行数没有到 80 行,表面上符合规范。但是实际上,阅读代码的同学不再能轻松掌握业务逻辑,而是同时在阅读功能细节和业务流程。阅读代码变成了每个时刻心智负担都很重的事情。
显而易见,单个函数里应该只保留某一个层级(layer)的代码,更细化的细节应该被抽象到下一个 layer 去,成为子函数。
Unix 哲学基础
这一句话改成:Unix 的设计哲学,值得大家深入阅读学习。最后我也想挑几条原则展开跟大家分享一下这些经典的智慧。
▶︎ 模块原则:使用简洁的接口拼合简单的部件;
▶︎ 清晰原则:清晰胜于技巧;
▶︎ 组合原则:设计时考虑拼接组合;
▶︎ 分离原则:策略同机制分离,接口同引擎分离;
▶︎ 简洁原则:设计要简洁,复杂度能低则低;
▶︎ 吝啬原则:除非确无它法,不要编写庞大的程序;
▶︎ 透明性原则:设计要可见,以便审查和调试;
▶︎ 健壮原则:健壮源于透明与简洁;
▶︎ 表示原则:把知识叠入数据以求逻辑质朴而健壮;
▶︎ 通俗原则:接口设计避免标新立异;
▶︎ 缄默原则:如果一个程序没什么好说,就保持沉默;
▶︎ 补救原则:出现异常时,马上退出并给出足量错误信息;
▶︎ 经济原则:宁花机器一分,不花程序员一秒;
▶︎ 生成原则:避免手工 hack,尽量编写程序去生成程序;
▶︎ 优化原则:雕琢前先得有原型,跑之前先学会走;
▶︎ 多样原则:绝不相信所谓"不二法门"的断言;
▶︎ 扩展原则:设计着眼未来,未来总比预想快。
KISS 原则,大家应该是如雷贯耳了。但是,你真的在遵守?什么是 Simple?简单?Golang 语言主要设计者之一的 Rob Pike 说“大道至简”,这个“简”和简单是一个意思么?
首先,简单不是面对一个问题,我们印入眼帘第一映像的解法为简单。我说一句,感受一下。“把一个事情做出来容易,把事情用最简单有效的方法做出来,是一个很难的事情。”比如,做一个三方授权,oauth2.0 很简单,所有概念和细节都是紧凑、完备、易用的。你觉得要设计到 oauth2.0 这个效果很容易么?要做到简单,就要对自己处理的问题有全面的了解,然后需要不断积累思考,才能做到从各个角度和层级去认识这个问题,打磨出一个通俗、紧凑、完备的设计,就像 ios 的交互设计。简单不是容易做到的,需要大家在不断的时间和 Code Review 过程中去积累思考,pk 中触发思考,交流中总结思考,才能做得愈发地好,接近“大道至简”。
两张经典的模型图,简单又全面,感受一下,没看懂,可以立即自行 Google 学习一下:RBAC:
logging:
使用组合,其实就是要让你明确清楚自己现在所拥有的是哪个部件。如果部件过于多,其实完成组合最终成品这个步骤,就会有较高的心智负担,每个部件展开来,琳琅满目,眼花缭乱。比如 QT 这个通用 UI 框架,看它的 Class 列表,有 1000 多个。如果不用继承树把它组织起来,平铺展开,组合出一个页面,将会变得心智负担高到无法承受。OOP 在“需要无数元素同时展现出来”这种复杂度极高的场景,有效的控制了复杂度 。“那么古尔丹,代价是什么呢?”代价就是,一开始做出这个自上而下的设计,牵一发而动全身,每次调整都变得异常困难。
实际项目中,各种职业级别不同的同学一起协作修改一个 server 的代码,就会出现,职级低的同学改哪里都改不对,根本没能力进行修改,高级别的同学能修改对,也不愿意大规模修改,整个项目变得愈发不合理。对整个继承树没有完全认识的同学都没有资格进行任何一个对继承树有调整的修改,协作变得寸步难行。代码的修改,都变成了依赖一个高级架构师高强度监控继承体系的变化,低级别同学们束手束脚的结果。组合,就很好的解决了这个问题,把问题不断细分,每个同学都可以很好地攻克自己需要攻克的点,实现一个 package。产品逻辑代码,只需要去组合各个 package,就能达到效果。
这是 golang 标准库里 http request 的定义,它就是 Http 请求所有特性集合出来的结果。其中通用/异变/多种实现的部分,通过 duck interface 抽象,比如 Body io.ReadCloser。你想知道哪些细节,就从组合成 request 的部件入手,要修改,只需要修改对应部件。[这段代码后,对比.NET 的 HTTP 基于 OOP 的抽象]
// A Request represents an HTTP request received by a server
// or to be sent by a client.
//
// The field semantics differ slightly between client and server
// usage. In addition to the notes on the fields below, see the
// documentation for Request.Write and RoundTripper.
type Request struct {
// Method specifies the HTTP method (GET, POST, PUT, etc.).
// For client requests, an empty string means GET.
//
// Go's HTTP client does not support sending a request with
// the CONNECT method. See the documentation on Transport for
// details.
Method string
// URL specifies either the URI being requested (for server
// requests) or the URL to access (for client requests).
//
// For server requests, the URL is parsed from the URI
// supplied on the Request-Line as stored in RequestURI. For
// most requests, fields other than Path and RawQuery will be
// empty. (See RFC 7230, Section 5.3)
//
// For client requests, the URL's Host specifies the server to
// connect to, while the Request's Host field optionally
// specifies the Host header value to send in the HTTP
// request.
URL *url.URL
// The protocol version for incoming server requests.
//
// For client requests, these fields are ignored. The HTTP
// client code always uses either HTTP/1.1 or HTTP/2.
// See the docs on Transport for details.
Proto string // "HTTP/1.0"
ProtoMajor int // 1
ProtoMinor int // 0
// Header contains the request header fields either received
// by the server or to be sent by the client.
//
// If a server received a request with header lines,
//
// Host: example.com
// accept-encoding: gzip, deflate
// Accept-Language: en-us
// fOO: Bar
// foo: two
//
// then
//
// Header = map[string][]string{
// "Accept-Encoding": {"gzip, deflate"},
// "Accept-Language": {"en-us"},
// "Foo": {"Bar", "two"},
// }
//
// For incoming requests, the Host header is promoted to the
// Request.Host field and removed from the Header map.
//
// HTTP defines that header names are case-insensitive. The
// request parser implements this by using CanonicalHeaderKey,
// making the first character and any characters following a
// hyphen uppercase and the rest lowercase.
//
// For client requests, certain headers such as Content-Length
// and Connection are automatically written when needed and
// values in Header may be ignored. See the documentation
// for the Request.Write method.
Header Header
// Body is the request's body.
//
// For client requests, a nil body means the request has no
// body, such as a GET request. The HTTP Client's Transport
// is responsible for calling the Close method.
//
// For server requests, the Request Body is always non-nil
// but will return EOF immediately when no body is present.
// The Server will close the request body. The ServeHTTP
// Handler does not need to.
Body io.ReadCloser
// GetBody defines an optional func to return a new copy of
// Body. It is used for client requests when a redirect requires
// reading the body more than once. Use of GetBody still
// requires setting Body.
//
// For server requests, it is unused.
GetBody func() (io.ReadCloser, error)
// ContentLength records the length of the associated content.
// The value -1 indicates that the length is unknown.
// Values >= 0 indicate that the given number of bytes may
// be read from Body.
//
// For client requests, a value of 0 with a non-nil Body is
// also treated as unknown.
ContentLength int64
// TransferEncoding lists the transfer encodings from outermost to
// innermost. An empty list denotes the "identity" encoding.
// TransferEncoding can usually be ignored; chunked encoding is
// automatically added and removed as necessary when sending and
// receiving requests.
TransferEncoding []string
// Close indicates whether to close the connection after
// replying to this request (for servers) or after sending this
// request and reading its response (for clients).
//
// For server requests, the HTTP server handles this automatically
// and this field is not needed by Handlers.
//
// For client requests, setting this field prevents re-use of
// TCP connections between requests to the same hosts, as if
// Transport.DisableKeepAlives were set.
Close bool
// For server requests, Host specifies the host on which the
// URL is sought. For HTTP/1 (per RFC 7230, section 5.4), this
// is either the value of the "Host" header or the host name
// given in the URL itself. For HTTP/2, it is the value of the
// ":authority" pseudo-header field.
// It may be of the form "host:port". For international domain
// names, Host may be in Punycode or Unicode form. Use
// golang.org/x/net/idna to convert it to either format if
// needed.
// To prevent DNS rebinding attacks, server Handlers should
// validate that the Host header has a value for which the
// Handler considers itself authoritative. The included
// ServeMux supports patterns registered to particular host
// names and thus protects its registered Handlers.
//
// For client requests, Host optionally overrides the Host
// header to send. If empty, the Request.Write method uses
// the value of URL.Host. Host may contain an international
// domain name.
Host string
// Form contains the parsed form data, including both the URL
// field's query parameters and the PATCH, POST, or PUT form data.
// This field is only available after ParseForm is called.
// The HTTP client ignores Form and uses Body instead.
Form url.Values
// PostForm contains the parsed form data from PATCH, POST
// or PUT body parameters.
//
// This field is only available after ParseForm is called.
// The HTTP client ignores PostForm and uses Body instead.
PostForm url.Values
// MultipartForm is the parsed multipart form, including file uploads.
// This field is only available after ParseMultipartForm is called.
// The HTTP client ignores MultipartForm and uses Body instead.
MultipartForm *multipart.Form
// Trailer specifies additional headers that are sent after the request
// body.
//
// For server requests, the Trailer map initially contains only the
// trailer keys, with nil values. (The client declares which trailers it
// will later send.) While the handler is reading from Body, it must
// not reference Trailer. After reading from Body returns EOF, Trailer
// can be read again and will contain non-nil values, if they were sent
// by the client.
//
// For client requests, Trailer must be initialized to a map containing
// the trailer keys to later send. The values may be nil or their final
// values. The ContentLength must be 0 or -1, to send a chunked request.
// After the HTTP request is sent the map values can be updated while
// the request body is read. Once the body returns EOF, the caller must
// not mutate Trailer.
//
// Few HTTP clients, servers, or proxies support HTTP trailers.
Trailer Header
// RemoteAddr allows HTTP servers and other software to record
// the network address that sent the request, usually for
// logging. This field is not filled in by ReadRequest and
// has no defined format. The HTTP server in this package
// sets RemoteAddr to an "IP:port" address before invoking a
// handler.
// This field is ignored by the HTTP client.
RemoteAddr string
// RequestURI is the unmodified request-target of the
// Request-Line (RFC 7230, Section 3.1.1) as sent by the client
// to a server. Usually the URL field should be used instead.
// It is an error to set this field in an HTTP client request.
RequestURI string
// TLS allows HTTP servers and other software to record
// information about the TLS connection on which the request
// was received. This field is not filled in by ReadRequest.
// The HTTP server in this package sets the field for
// TLS-enabled connections before invoking a handler;
// otherwise it leaves the field nil.
// This field is ignored by the HTTP client.
TLS *tls.ConnectionState
// Cancel is an optional channel whose closure indicates that the client
// request should be regarded as canceled. Not all implementations of
// RoundTripper may support Cancel.
//
// For server requests, this field is not applicable.
//
// Deprecated: Set the Request's context with NewRequestWithContext
// instead. If a Request's Cancel field and context are both
// set, it is undefined whether Cancel is respected.
Cancel <-chan struct{}
// Response is the redirect response which caused this request
// to be created. This field is only populated during client
// redirects.
Response *Response
// ctx is either the client or server context. It should only
// be modified via copying the whole Request using WithContext.
// It is unexported to prevent people from using Context wrong
// and mutating the contexts held by callers of the same request.
ctx context.Context
}
看看.NET 里对于 web 服务的抽象,仅仅看到末端,不去看完整个继承树的完整图景,我根本无法知道我关心的某个细节在什么位置。进而,我要往整个 http 服务体系里修改任何功能,都无法抛开对整体完整设计的理解和熟悉,还极容易没有知觉地破坏者整体的设计。
说到组合,还有一个关系很紧密的词,叫插件化。大家都用 VS code 用得很开心,它比 Visual Studio 成功在哪里?如果 VS code 通过添加一堆插件达到 Visual Studio 具备的能力,那么它将变成另一个和 Visual Studio 差不多的东西,叫做 VS Studio 吧。大家应该发现问题了,我们很多时候其实并不需要 Visual Studio 的大多数功能,而且希望灵活定制化一些比较小众的能力,用一些小众的插件。甚至,我们希望选择不同实现的同类型插件。这就是组合的力量,各种不同的组合,它简单,却又满足了各种需求,灵活多变,要实现一个插件,不需要事先掌握一个庞大的体系。体现在代码上,也是一样的道理。至少后端开发领域,组合,比 OOP,“香”很多。
可能有些同学会觉得,把程序写得庞大一些才好拿得出手去评高级职称。leader 们一看评审方案就容易觉得:很大,很好,很全面。但是,我们真的需要写这么大的程序么?
我又要说了“那么古尔丹,代价是什么呢?”。代价是代码越多,越难维护,难调整。C 语言之父 Ken Thompson 说“删除一行代码,给我带来的成就感要比添加一行要大”。我们对于代码,要吝啬。能把系统做小,就不要做大。腾讯不乏 200w+行的客户端,很大,很牛。但是,同学们自问,现在还调整得动架构么。能小做的事情就小做,寻求通用化,通过 duck interface(甚至多进程,用于隔离能力的多线程)把模块、能力隔离开,时刻想着删减代码量,才能保持代码的可维护性和面对未来的需求、架构,调整自身的活力。客户端代码,UI 渲染模块可以复杂吊炸天,非 UI 部分应该追求最简单,能力接口化,可替换、重组合能力强。
落地到大家的代码,review 时,就应该最关注核心 struct 定义,构建起一个完备的模型,核心 interface,明确抽象 model 对外部的依赖,明确抽象 model 对外提供的能力。其他代码,就是要用最简单、平平无奇的代码实现模型内部细节。
首先,定义一下,什么是透明性和可显性。
“如果没有阴暗的角落和隐藏的深度,软件系统就是透明的。透明性是一种被动的品质。如果实际上能预测到程序行为的全部或大部分情况,并能建立简单的心理模型,这个程序就是透明的,因为可以看透机器究竟在干什么。
如果软件系统所包含的功能是为了帮助人们对软件建立正确的“做什么、怎么做”的心理模型而设计,这个软件系统就是可显的。因此,举例来说,对用户而言,良好的文档有助于提高可显性;对程序员而言,良好的变量和函数名有助于提高可显性。可显性是一种主动品质。在软件中要达到这一点,仅仅做到不晦涩是不够的,还必须要尽力做到有帮助。”
我们要写好程序,减少 bug,就要增强自己对代码的控制力。你始终做到,理解自己调用的函数/复用的代码大概是怎么实现的。不然,你可能就会在单线程状态机的 server 里调用有 IO 阻塞的函数,让自己的 server 吞吐量直接掉到底。进而,为了保证大家能对自己代码能做到有控制力,所有人写的函数,就必须具备很高的透明性。而不是写一些看了一阵看不明白的函数/代码,结果被迫使用你代码的人,直接放弃了对掌控力的追取,甚至放弃复用你的代码,另起炉灶,走向了'制造重复代码'的深渊。
透明性其实相对容易做到的,大家有意识地锻炼一两个月,就能做得很好。可显性就不容易了。有一个现象是,你写的每一个函数都不超过 80 行,每一行我都能看懂,但是你层层调用,很多函数调用,组合起来怎么就实现了某个功能,看两遍,还是看不懂。第三遍可能才能大概看懂。大概看懂了,但太复杂,很难在大脑里构建起你实现这个功能的整体流程。结果就是,阅读者根本做不到对你的代码有好的掌控力。
可显性的标准很简单,大家看一段代码,懂不懂,一下就明白了。但是,如何做好可显性?那就是要追求合理的函数分组,合理的函数上下级层次,同一层次的代码才会出现在同一个函数里,追求通俗易懂的函数分组分层方式,是通往可显性的道路。
当然,复杂如 linux 操作系统,office 文档,问题本身就很复杂,拆解、分层、组合得再合理,都难建立心理模型。这个时候,就需要完备的文档了。完备的文档还需要出现在离代码最近的地方,让人“知道这里复杂的逻辑有文档”,而不是其实文档,但是阅读者不知道。再看看上面 Golang 标准库里的 http.Request,感受到它在可显性上的努力了么?对,就去学它。
设计程序过于标新立异的话,可能会提升别人理解的难度。
一般,我们这么定义一个“点”,使用 x 表示横坐标,用 y 表示纵坐标:
type Point struct {
X float64
Y float64
}
你就是要不同、精准:
type Point struct {
VerticalOrdinate float64
HorizontalOrdinate float64
}
很好,你用词很精准,一般人还驳斥不了你。但是,多数人读你的 VerticalOrdinate 就是没有读 X 理解来得快,来得容易懂、方便。你是在刻意制造协作成本。
上面的例子常见,但还不是最小立异原则最想说明的问题。想想一下,一个程序里,你把用“+”这个符号表示数组添加元素,而不是数学“加”,“result := 1+2” --> “result = []int{1, 2}”而不是“result=3”,那么,你这个标新立异,对程序的破坏性,简直无法想象。"最小立异原则的另一面是避免表象想死而实际却略有不同。这会极端危险,因为表象相似往往导致人们产生错误的假定。所以最好让不同事物有明显区别,而不要看起来几乎一模一样。" -- Henry Spencer。
你实现一个 db.Add()函数却做着 db.AddOrUpdate()的操作,有人使用了你的接口,错误地把数据覆盖了。
这个原则,应该是大家最经常破坏的原则之一。一段简短的代码里插入了各种“log("cmd xxx enter")”, “log("req data " + req.String())”,非常害怕自己信息打印得不够。害怕自己不知道程序执行成功了,总要最后“log("success")”。但是,我问一下大家,你们真的耐心看过别人写的代码打的一堆日志么?不是自己需要哪个,就在一堆日志里,再打印一个日志出来一个带有特殊标记的日志“log("this_is_my_log_" + xxxxx)”?结果,第一个作者打印的日志,在代码交接给其他人或者在跟别人协作的时候,这个日志根本没有价值,反而提升了大家看日志的难度。
一个服务一跑起来,就疯狂打日志,请求处理正常也打一堆日志。滚滚而来的日志,把错误日志淹没在里面。错误日志失去了效果,简单地 tail 查看日志,眼花缭乱,看不出任何问题,这不就成了“为了捕获问题”而让自己“根本无法捕获问题”了么?
沉默是金。除了简单的 stat log,如果你的程序'发声'了,那么它抛出的信息就一定要有效!打印一个 log('process fail')也是毫无价值,到底什么 fail 了?是哪个用户带着什么参数在哪个环节怎么 fail 了?如果发声,就要把必要信息给全。不然就是不发声,表示自己好好地 work 着呢。不发声就是最好的消息,现在我的 work 一切正常!
“设计良好的程序将用户的注意力视为有限的宝贵资源,只有在必要时才要求使用。”程序员自己的主力,也是宝贵的资源!只有有必要的时候,日志才跑来提醒程序员“我有问题,来看看”,而且,必须要给到足够的信息,让一把讲明白现在发生了什么。而不是程序员还需要很多辅助手段来搞明白到底发生了什么。
每当我发布程序 ,我抽查一个机器,看它的日志。发现只有每分钟外部接入、内部 rpc 的个数/延时分布日志的时候,我就心情很愉悦。我知道,这一分钟,它的成功率又是 100%,没任何问题!
其实这个问题很简单,如果出现异常,异常并不会因为我们尝试掩盖它,它就不存在了。所以,程序错误和逻辑错误要严格区分对待。这是一个态度问题。
“异常是互联网服务器的常态”。逻辑错误通过 metrics 统计,我们做好告警分析。对于程序错误 ,我们就必须要严格做到在问题最早出现的位置就把必要的信息搜集起来,高调地告知开发和维护者“我出现异常了,请立即修复我!”。可以是直接就没有被捕获的 panic 了。也可以在一个最上层的位置统一做好 recover 机制,但是在 recover 的时候一定要能获得准确异常位置的准确异常信息。不能有中间 catch 机制,catch 之后丢失很多信息再往上传递。
很多 Java 开发的同学,不区分程序错误和逻辑错误,要么都很宽容,要么都很严格,对代码的可维护性是毁灭性的破坏。“我的程序没有程序错误,如果有,我当时就解决了。”只有这样,才能保持程序代码质量的相对稳定,在火苗出现时扑灭火灾是最好的扑灭火灾的方式。当然,更有效的方式是全面自动化测试的预防:)
本文主要阐述了研发人员在日常工作和职业生涯中,或多或少都会去学习并运用的知名架构原则,并从我个人的角度去做了深入的发散阐述。在下一篇文章中,我将从程序员的自我修养和不能上升到原则的几个常见案例来继续阐述程序员修炼之道的未尽事宜。
-End-
原创作者|林强
你做了程序员之后有哪些学习心得?欢迎分享。我们将选取1则最有意义的评论,送出腾讯云开发者-马克杯1个(见下图)。10月7日中午12点开奖。
欢迎加入腾讯云开发者社群,社群专享券、大咖交流圈、第一手活动通知、限量鹅厂周边等你来~
(长按图片立即扫码)