这是本节的多页打印视图。 点击此处打印.

返回本页常规视图.

杂谈

关于编程语言模块的一些介绍

1 - csdn自动跳转清除

find .  -name "*.html" -exec sed -i '/window.location.href/d' {} \;

2 - Git

Git支持多种协议,包括https,但通过ssh支持的原生git协议速度最快。 使用https除了速度慢以外,还有个最大的麻烦是每次推送都必须输入口令,但是在某些只开放http端口的公司内部就无法使用ssh协议而只能用https。

分支操作(默认的主分支名为 master)

创建分支并切换: git checkout -b 分支名称
等同于下面两句话
(
1.创建分支: git branch 分支名称
2.切换到某分支: git checkout 分支名称
)
本地创建分支并关联远程分支: git checkout -b 本地分支名 origin/远程分支名
建立本地分支和远程分支的关联,使用: git branch --set-upstream 本地分支名 origin/远程分支名

合并指定分支到当前分支: git merge 目标分支名

查看所有本地分支(当前分支前会有一个*号): git branch

删除某个分支: git branch -d 分支名

强行删除分支: git branch -D 分支名
用带参数的git log也可以看到分支的合并情况: git log --graph --pretty=oneline --abbrev-commit  (用git log --graph命令可以看到分支合并图)

带参数的合并,并产生一个新的commit: git merge --no-ff -m "merge with no-ff" dev

存储当前工作空间,解决其他问题,并还原
    1. git stash "贮藏描述" (存储当前工作区)
    2. 其他操作,例如修复bug要创建新的分支,提交合并并且删除bug分支
    3. git stash list (查看工作区)
    (会输出)
        stash@{0}: WIP on master: 2b5faea new info text
        stash@{1}: WIP on dev: b0f1f6a conflict fixed
        stash@{2}: WIP on dev: b0f1f6a conflict fixed
    4. git shash apply stash@{n} (恢复到哪个工作区)
    5. git shash drop(删除缓冲区序号最小的工作区)
    (6.  45两步可以用 git stash pop 会直接还原到序号最小的工作区并将其删除)

拉取: git pull 如果有冲突,要先处理冲突。

标签 Git的标签虽然是版本库的快照,但其实它就是指向某个commit的指针(跟分支很像对不对?但是分支可以移动,标签不能移动),所以,创建和删除标签都是瞬间完成的。 打一个新的标签: git tag 标签名 给某一个历史提交打标签: git tag 标签名 提交Hash

查看标签列表: git tag

查看具体标签: git show tagName

创建带有说明的标签,用-a指定标签名,-m指定说明文字: git tag -a v0.1 -m "标签说明内容" 提交Hash

通过-s用私钥签名一个标签: git tag -s v0.2 -m "标签说明内容" 提交Hash (签名采用PGP签名,因此,必须首先安装gpg(GnuPG),如果没有找到gpg,或者没有gpg密钥对,就会报错)


删除本地标签: git tag -d tagName

推送标签: git push origin v1.0

推送所有标签: git push origin --tags

删除服务器上的标签: 
1.先删除本地标签
2. git push origin :refs/tags/tagName

一些基本的操作 指定你的信息 - git config –global user.name “Your Name” - git config –global user.email “email@example.com” 查看信息 - git config –global user.name - git config –global user.email 创建管理仓库: git init

把文件添加到仓库: git add path/文件1 path/文件2 ... 
交互式添加: git add -i  

查看提交日志: git log (--pretty=oneline)

查看最后一次提交: git log -1

提交
    - git commit -m "提交日志记录"
    - git commit -a  可以不用写提交记录,但是强烈不建议这么做

查看哪个文件被改动过: git status

查看具体文件具体怎么改动的
    - git diff //查看当前所有的更改
    - git diff path/文件 //查看某个文件的改动
    - git diff --cached  // 查看将要提交哪些文件去commit 
    - git diff master..test   //查询master和test两个分支的差异
    - git diff master...test  //查询 master和test两个分支的共有父分支  和 test分支的差异
    - git diff -- 目录 // (-- 目录 一定要连着, 尽量写在最后), 查询某个目录下的差异
    - git diff --stat  //简要的差异, 不输出详细差异内容, 只看看改了那些文件

回溯到上个版本(上上个版本|某个版本): git reset --hard HEAD^(HEAD^^|commit id)
如果你后悔了一个推送,撤销远程分支的提交: git reset --hard origin/远程分支  //这个操作很危险, 不得已不要做

还原更改
    让这个文件回到最近一次git commit或git add时的状态
    git checkout -- file (命令中的--很重要,没有--,就变成了“切换到另一个分支”的命令)

清除最近一次的add操作(添加到缓存区操作): git reset HEAD file

让Git显示颜色,会让命令输出看起来更醒目: git config --global color.ui true

忽略特殊文件
    1.   创建  .gitignore 文件
    2.   如果要忽略python  那么写  *.py *.py[cod]
配置别名
--global是针对当前用户起作用的,如果不加,那只针对当前的仓库起作用,全局git的配置文件是用户主目录下的 .gitconfig文件
    - git config --global alias.st status
    - git config --global alias.unstage 'reset HEAD'
    - git config --global alias.lg "log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit"
    
查看命令历史: git reflog

于服务器的交互操作 关联一个远程库 使用ssh协议添加库,这个需要添加ssh_key 来交互认证不用用户名,自己建服务器都是用这种方式 git remote add origin git@server-name:path/repo-name.git 使用https添加库,每次推送代码都会要求写用户名密码来认证,在github上可以用,自己的服务器不清楚 git remote add origin https://path/proname.git 推送到远程仓库 git push -u origin 分支名 第一次推送master分支的所有内容 git push origin 分支名 推送最新修改 git push -f origin 分支名 //强制推送–慎用 git push origin +分支名 //在push之前拉取内容 克隆远程仓库 git clone git@server-name:path/repo-name.git //github用法 git clone https://path/proname.git 删除远程分支 git push origin :dev(这个空格很重要)

其他操作 桌面平台显示项目提交记录:gitk //执行了gitk后会有一个很漂亮的图形的显示项目的历史,windows桌面环境有 git clone 远程地址(本地其他仓库,只要是有.git) 本地目录

辅助操作 创建ssh_key ssh-keygen -t rsa -C “ssh-keygen@163.com” 在用户主目录会生成 .ssh目录,里面有id_rsa和id_rsa.pub两个文件

3 - 代码编写指南

01

细节即是架构

下面是原文摘录,我有类似观点,但是原文就写得很好,直接摘录。

一直以来,设计(Design)和架构(Architecture)这两个概念让大多数人十分迷惑–什么是设计?什么是架构?二者究竟有什么区别?二者没有区别。一丁点区别都没有!“架构"这个词往往适用于"高层级"的讨论中,这类讨论一般都把"底层"的实现细节排除在外。而"设计"一词,往往用来指代具体的系统底层组织结构和实现的细节。但是,从一个真正的系统架构师的日常工作来看,这些区分是根本不成立的。以给我设计新房子的建筑设计师要做的事情为例。新房子当然是存在着既定架构的,但这个架构具体包含哪些内容呢?首先,它应该包括房屋的形状、外观设计、垂直高度、房间的布局,等等。

但是,如果查看建筑设计师使用的图纸,会发现其中也充斥着大量的设计细节。譬如,我们可以看到每个插座、开关以及每个电灯具体的安装位置,同时也可以看到某个开关与所控制的电灯的具体连接信息;我们也能看到壁炉的具体位置,热水器的大小和位置信息,甚至是污水泵的位置;同时也可以看到关于墙体、屋顶和地基所有非常详细的建造说明。总的来说,架构图里实际上包含了所有的底层设计细节,这些细节信息共同支撑了顶层的架构设计,底层设计信息和顶层架构设计共同组成了整个房屋的架构文档。

软件设计也是如此。底层设计细节和高层架构信息是不可分割的。他们组合在一起,共同定义了整个软件系统,缺一不可。所谓的底层和高层本身就是一系列决策组成的连续体,并没有清晰的分界线。

我们编写、review 细节代码,就是在做架构设计的一部分。我们编写的细节代码构成了整个系统。我们就应该在细节 review 中,始终带着所有架构原则去审视。你会发现,你已经写下了无数让整体变得丑陋的细节,它们背后,都有前人总结过的架构原则。

02

把代码和文档绑在一起(自解释原则)

写文档是个好习惯。但是写一个别人需要咨询老开发者才能找到的文档,是个坏习惯。这个坏习惯甚至会给工程师们带来伤害。比如,当初始开发者写的文档在一个犄角旮旯(在 wiki 里,但是阅读代码的时候没有在明显的位置看到链接),后续代码被修改了,文档已经过时,有人再找出文档来获取到过时、错误的知识的时候,阅读文档这个同学的开发效率必然受到伤害。所以,如同 Golang 的 godoc 工具能把代码里“按规范来”的注释自动生成一个文档页面一样,我们应该:

▶︎ 按照 godoc 的要求好好写代码的注释。

▶︎ 代码首先要自解释,当解释不了的时候,需要就近、合理地写注释。

▶︎ 当小段的注释不能解释清楚的时候,应该有 doc.go 来解释,或者在同级目录的 ReadMe.md 里注释讲解。

▶︎ 文档需要强大的富文本编辑能力,Down 无法满足,可以写到 wiki 里,同时必须把 wiki 的简单描述和链接放在代码里合适的位置。让阅读和维护代码的同学一眼就看到,能做到及时的维护。

以上总结起来就是,解释信息必须离被解释的东西越近越好。代码能做到自解释,是最棒的。

图片

03

ETC 价值观(easy to change)

ETC 是一种价值观念,不是一条原则。价值观念是帮助你做决定的: 我应该做这个,还是做那个?当你在软件领域思考时,ETC 是个向导,它能帮助你在不同的路线中选出一条。就像其他一些价值观念一样,你应该让它漂浮在意识思维之下,让它微妙地将你推向正确的方向。

敏捷软件工程,所谓敏捷,就是要能快速变更,并且在变更中保持代码的质量。所以,持有 ETC 价值观看待代码细节、技术方案,我们将能更好地编写出适合敏捷项目的代码。这是一个大的价值观,不是一个基础微观的原则,所以没有例子。本文提到的所有原则,或直接,或间接,都要为 ETC 服务。

04

DRY 原则(don not repeat yourself)

我认为 DRY 原则是编码原则中最重要的编码原则,没有之一(ETC 是个观念)。不要重复!不要重复!不要重复!

图片

05

正交性原则(全局变量的危害)

“正交性”是几何学中的术语。我们的代码应该消除不相关事物之间的影响。这是一个简单的道理。我们写代码要“高内聚、低耦合”,这是大家都在提的。

但是,你有为了使用某个 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 之间的耦合变得复杂、隐秘、难以定位。

图片

06

单例就是全局变量

后面有“共享状态就是不正确的状态”原则,会进一步讲到。我先给出解决方案,可以通过管道、消息机制来替代共享状态/使用全局变量/使用单例。仅仅能获取此刻最新的状态,通过消息变更状态。要拿到最新的状态,需要重新获取。在必要的时候,引入锁机制。

07

可逆性原则

可逆性原则是很少被提及的一个原则。可逆性,就是你做出的判断,最好都是可以被逆转的。再换一个容易懂的说法,你最好尽量少认为什么东西是一定的、不变的。比如,你认为你的系统永远服务于用 32 位无符号整数(比如 QQ 号)作为用户标识的系统。你认为你的持久化存储就选型 SQL 存储了。当这些一开始你认为一定的东西,被推翻的时候,你的代码却很难去 change,那么,你的代码就是可逆性做得很差。书里有一个例证,我觉得很好,直接引用过来。

写下这段文字的时间是2019年。自世纪之交以来,我们看到了以下“服务端架构的最佳实践”:

* 大铁块

* 大铁块的组合

* 带负载均衡的商用硬件集群大

* 将程序运行在云虚拟机中大

* 把服务运行在云虚拟机中

* 把云虚拟机换成容器再来一遍

* 基于云的无服务器架构大

* 最后,无可避免的,有些任务又回到了大铁块

…(省略了文字)… 你能为这种架构的变化提前准备吗? 做不到。你能做的就是让修改更容易一点。将第三方 API 隐藏在自己的抽象层之后。将代码分解成多个组件:即使最终会把它们部署到单个大型服务器上,这种方法也比一开始做成庞然大物,然后再切分要容易得多。

与其认为决定是被刻在石头上的,还不如把它们想像成写在沙滩的沙子上。一个大浪随时都可能袭来,卷走一切。腾讯也确实在 20 年内经历了“大铁块”到“云虚拟机换成容器”的几个阶段。几次变化都是伤筋动骨,消耗大量的时间,甚至总会有一些上一个时代残留的服务。就机器数量而论,还不小,一到裁撤季,就很难受。就最近,我看到某个 trpc 插件,直接从环境变量里读取本机 IP,仅仅因为 STKE(Tencent Kubernetes Engine)提供了这个能力。这个细节设计就是不可逆的,将来会有人为它买单,可能价格还不便宜。

08

依赖倒置原则(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 javaimpl
import "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 pythonimpl
import "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 main
import (    "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 的核心科技,并不复杂,其实也没有多么“高不可攀”。在“动态代码”优于“配置”的今天,这种通过配置实现的依赖注入,反而有点过时了。

09

将知识用纯文本来保存

这也是一个生僻的原则。指代码操作的数据和方案设计文稿,如果没有充分的必要使用特定的方案,就应该使用人类可读的文本来保存、交互。对于方案设计文稿,你能不使用 office 格式,就不使用(office 能极大提升效率才用),最好是原始 text。这是《Unix 编程艺术》也提到了的 Unix 系产生的设计信条。简而言之一句话,当需要确保有一个所有各方都能使用的公共标准,才能实现交互沟通时,纯文本就是这个标准。它是一个接受度最高的通行标准,如果没有必要的理由,我们就应该使用纯文本。

10

契约式设计

如果你对契约式设计(Design by Contract, DBC)还很陌生,我相信,你和其他端的同学(web、client、后端)联调需求应该是一件很花费时间的事情。你自己编写接口自动化,也会是一件很耗费精力的事情。你先看看它的 wiki 解释吧。grpc + grpc-gateway + swagger 是个很香的东西。

代码是否不多不少刚好完成它宣称要做的事情,可以使用契约加以校验和文档化。TDD 就是全程在不断调整和履行着契约。TDD(Test-Driven Development)是自底向上的编码过程,其实会耗费大量的精力,并且对于一个良好的层级架构没有帮助。TDD 不是强推的规范,但是同学们可以用一用,感受一下。TDD 方法论实现的接口、函数,自我解释能力一般来说比较强,因为它就是一个实现契约的过程。

抛开 TDD 不谈。我们的函数、API,你能快速抓住它描述的核心契约么?它的契约简单么?如果不能、不简单,那你应该要求被 review 的代码做出调整。如果你在指导一个后辈,你应该帮他思考一下,给出至少一个可能的简化、拆解方向。

11

尽早崩溃

Erlang 发明者、《Erlang 程序设计》作者乔·阿姆斯特朗有一句反复被引用的话:“防御式编程是在浪费时间,让它崩溃。”

尽早崩溃不是说不容错,而是程序应该被设计成允许出故障,有适当的故障监管程序和代码,及时告警,告知工程师,哪里出问题了,而不是尝试掩盖问题,不让程序员知道。当最后程序员知道程序出故障的时候,已经找不到问题出现在哪里了。

特别是一些 recover 之后什么都不做的代码,这种代码简直是毒瘤!当然,崩溃,可以是早一些向上传递 error,不一定就是 panic。同时,我要求大家不要在没有充分的必要性的时候 panic,应该更多地使用向上传递 error,做好 metrics 监控。合格的 Golang 程序员,都不会在没有必要的时候无视 error,会妥善地做好 error 处理、向上传递、监控。一个死掉的程序,通常比一个瘫痪的程序,造成的损害要小得多。

崩溃但是不告警,或者没有补救的办法,不可取。尽早崩溃的题外话是,要在问题出现的时候做合理的告警,有预案,不能掩盖,不能没有预案:

图片

12

解耦代码让改变容易

这个原则,显而易见,大家自己也常常提,其他原则或多或少都和它有关系。但是我也再提一提。我主要是描述一下它的症状,让同学们更好地警示自己“我这两块代码是不是耦合太重,需要额外引入解耦的设计了”。症状如下:

▶︎ 不相关的 pkg 之间古怪的依赖关系;

▶︎ 对一个模块进行的“简单”修改,会传播到系统中不相关的模块里,或是破坏了系统中的其他部分;

▶︎ 开发人员害怕修改代码,因为他们不确定会造成什么影响;

▶︎ 会议要求每个人都必须参加,因为没有人能确定谁会受到变化的影响。

13

只管命令不要询问

看看如下三段代码:

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 关联的方法里,其他人不要去操心。一旦其他人操心了,当需要做修改的时候,就要操心了这个细节的人都一起参与进修改过程。

14

不要链式调用方法

看下面的例子:

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,让工程师惧怕去修改。这一条原则,和上一条关系很紧密,问题常常同时出现。同样,特别容易出现在客户端代码里。

15

继承税(多用组合)

继承就是耦合。不仅子类耦合到父类,以及父类的父类等,而且使用子类的代码也耦合到所有祖先类。有些人认为继承是定义新类型的一种方式。他们喜欢设计图表,会展示出类的层次结构。他们看待问题的方式,与维多利亚时代的绅士科学家们看待自然的方式是一样的,即将自然视为须分解到不同类别的综合体。不幸的是,这些图表很快就会为了表示类之间的细微差别而逐层添加,最终可怕地爬满墙壁。由此增加的复杂性,可能使应用程序更加脆弱,因为变更可能在许多层次之间上下波动。因为一些值得商榷的词义消歧方面的原因,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。你还愿意再继续支付继承税么?

16

共享状态是不正确的状态

你坐在最喜欢的餐厅。吃完主菜,问男服务员还有没有苹果派。他回头一看,陈列柜里还有一个,就告诉你“还有”。点到了苹果派,你心满意足地长出了一口气。与此同时,在餐厅的另一边,还有一个顾客也问了女服务员同样的问题。她也看了看,确认有一个,让顾客点了单。总有一个顾客会失望的。

问题出在共享状态。餐厅里的每一个服务员都查看了陈列柜,却没有考虑到其他服务员。你们可以通过加互斥锁来解决正确性的问题,但是,两个顾客有一个会失望或者很久都得不到答案,这是肯定的。

所谓共享状态,换个说法,就是: 由多个人查看和修改状态。这么一说,更好的解决方案就浮出水面了: 将状态改为集中控制。预定苹果派,不再是先查询,再下单。而是有一个餐厅经理负责和服务员沟通,服务员只管发送下单的命令/消息,经理看情况能不能满足服务员的命令。

这种解决方案,换一个说法,也可以说成“用角色实现并发性时不必共享状态”。我们引入了餐厅经理这个角色,赋予了他职责。当然,我们仅仅应该给这个角色发送命令,不应该去询问他。前面讲过了,“只管命令不要询问”,你还记得么。

同时,这个原则就是 golang 里大家耳熟能详的谚语: “不要通过共享内存来通信,而应该通过通信来共享内存”。作为并发性问题的根源,内存的共享备受关注。但实际上,在应用程序代码共享可变资源(文件、数据库、外部服务)的任何地方,问题都有可能冒出来。当代码的两个或多个实例可以同时访问某些资源时,就会出现潜在的问题。

17

缄默原则

如果一个程序没什么好说,就保持沉默。过多的正常日志,会掩盖错误信息。过多的信息,会让人根本不再关注新出现的信息,“更多信息”变成了“没有信息”。每人添加一点信息,就变成了输出很多信息,最后等于没有任何信息。

▶︎ 不要在正常 case 下打印日志。

▶︎ 不要在单元测试里使用 fmt 标准输出,至少不要提交到 master。

▶︎ 不打不必要的日志。当错误出现的时候,会非常明显,我们能第一时间反应过来并处理。

▶︎ 让调试的日志停留在调试阶段,或者使用较低的日志级别,你的调试信息,对其他人根本没有价值。

▶︎ 即使低级别日志,也不能泛滥。不然,日志打开与否都没有差别,日志变得毫无价值。

图片

18

错误传递原则

我不喜欢 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 客户端。至少不能直接展示给用户看到。

图片

19

SOLID

SOLID 原则,是以下几个原则的集合体:

▶︎ SRP:单一职责原则;

▶︎ OCP:开闭原则;

▶︎ LSP:里氏替换原则;

▶︎ ISP:接口隔离原则;

▶︎ DIP:依赖反转原则。

这些年来,这几个设计原则在很多不同的出版物里都有过详细描述。它们太出名了,我这里就不做详解了。我想说的是,这 5 个原则环环相扣,前 4 个原则,要么就是同时做到,要么就是都没做到,很少有说,做到其中一点其他三点都不满足。ISP 就是做到 LSP 的常用手段。ISP 也是做到 DIP 的基础。只是,它刚被提出来的时候,是主要针对“设计继承树”这个目的的。现在,它们已经被更广泛地使用在模块、领域、组件这种更大的概念上。

SOLI 都显而易见,DIP 原则是最值得注意的一点,我在其他原则里也多次提到了它。如果你还不清楚什么是 DIP,一定去看明白。这是工程师最基础、必备的知识点之一了。

要做到 OCP 开闭原则,其实,就是要大家要通过后面讲到的“不要面向需求编程”才能做好。如果你还是面向需求、面向 UI、交互编程,你永远做不到开闭,并且不知道如何才能做到开闭。

如果你对这些原则确实不了解,建议读一读《架构整洁之道》。该书的作者 Bob 大叔,就是第一个提出 SOLID 这个集合体的人(20 世纪 80 年代末,在 USENET 新闻组)。

20

一个函数不要出现多个层级的代码

// 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 去,成为子函数。

21

Unix 哲学基础

这一句话改成:Unix 的设计哲学,值得大家深入阅读学习。最后我也想挑几条原则展开跟大家分享一下这些经典的智慧。

▶︎ 模块原则:使用简洁的接口拼合简单的部件;

▶︎ 清晰原则:清晰胜于技巧;

▶︎ 组合原则:设计时考虑拼接组合;

▶︎ 分离原则:策略同机制分离,接口同引擎分离;

▶︎ 简洁原则:设计要简洁,复杂度能低则低;

▶︎ 吝啬原则:除非确无它法,不要编写庞大的程序;

▶︎ 透明性原则:设计要可见,以便审查和调试;

▶︎ 健壮原则:健壮源于透明与简洁;

▶︎ 表示原则:把知识叠入数据以求逻辑质朴而健壮;

▶︎ 通俗原则:接口设计避免标新立异;

▶︎ 缄默原则:如果一个程序没什么好说,就保持沉默;

▶︎ 补救原则:出现异常时,马上退出并给出足量错误信息;

▶︎ 经济原则:宁花机器一分,不花程序员一秒;

▶︎ 生成原则:避免手工 hack,尽量编写程序去生成程序;

▶︎ 优化原则:雕琢前先得有原型,跑之前先学会走;

▶︎ 多样原则:绝不相信所谓"不二法门"的断言;

▶︎ 扩展原则:设计着眼未来,未来总比预想快。

Keep It Simple Stupid!

KISS 原则,大家应该是如雷贯耳了。但是,你真的在遵守?什么是 Simple?简单?Golang 语言主要设计者之一的 Rob Pike 说“大道至简”,这个“简”和简单是一个意思么?

首先,简单不是面对一个问题,我们印入眼帘第一映像的解法为简单。我说一句,感受一下。“把一个事情做出来容易,把事情用最简单有效的方法做出来,是一个很难的事情。”比如,做一个三方授权,oauth2.0 很简单,所有概念和细节都是紧凑、完备、易用的。你觉得要设计到 oauth2.0 这个效果很容易么?要做到简单,就要对自己处理的问题有全面的了解,然后需要不断积累思考,才能做到从各个角度和层级去认识这个问题,打磨出一个通俗、紧凑、完备的设计,就像 ios 的交互设计。简单不是容易做到的,需要大家在不断的时间和 Code Review 过程中去积累思考,pk 中触发思考,交流中总结思考,才能做得愈发地好,接近“大道至简”。

两张经典的模型图,简单又全面,感受一下,没看懂,可以立即自行 Google 学习一下:RBAC:

图片

logging:

图片

原则3 组合原则: 设计时考虑拼接组合

关于 OOP,关于继承,我前面已经说过了。那我们怎么组织自己的模块?对,用组合的方式来达到。linux 操作系统离我们这么近,它是怎么架构起来的?往小里说,我们一个串联一个业务请求的数据集合,如果使用 BaseSession,XXXSession inherit BaseSession 的设计,其实,这个继承树,很难适应层出不穷的变化。但是如果使用组合,就可以拆解出 UserSignature 等等各种可能需要的部件,在需要的时候组合使用,不断添加新的部件而没有对老的继承树的记忆这个心智负担。

使用组合,其实就是要让你明确清楚自己现在所拥有的是哪个部件。如果部件过于多,其实完成组合最终成品这个步骤,就会有较高的心智负担,每个部件展开来,琳琅满目,眼花缭乱。比如 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,“香”很多。

原则 6 吝啬原则: 除非确无它法, 不要编写庞大的程序

可能有些同学会觉得,把程序写得庞大一些才好拿得出手去评高级职称。leader 们一看评审方案就容易觉得:很大,很好,很全面。但是,我们真的需要写这么大的程序么?

我又要说了“那么古尔丹,代价是什么呢?”。代价是代码越多,越难维护,难调整。C 语言之父 Ken Thompson 说“删除一行代码,给我带来的成就感要比添加一行要大”。我们对于代码,要吝啬。能把系统做小,就不要做大。腾讯不乏 200w+行的客户端,很大,很牛。但是,同学们自问,现在还调整得动架构么。能小做的事情就小做,寻求通用化,通过 duck interface(甚至多进程,用于隔离能力的多线程)把模块、能力隔离开,时刻想着删减代码量,才能保持代码的可维护性和面对未来的需求、架构,调整自身的活力。客户端代码,UI 渲染模块可以复杂吊炸天,非 UI 部分应该追求最简单,能力接口化,可替换、重组合能力强。

落地到大家的代码,review 时,就应该最关注核心 struct 定义,构建起一个完备的模型,核心 interface,明确抽象 model 对外部的依赖,明确抽象 model 对外提供的能力。其他代码,就是要用最简单、平平无奇的代码实现模型内部细节。

原则 7 透明性原则: 设计要可见,以便审查和调试

首先,定义一下,什么是透明性和可显性。

“如果没有阴暗的角落和隐藏的深度,软件系统就是透明的。透明性是一种被动的品质。如果实际上能预测到程序行为的全部或大部分情况,并能建立简单的心理模型,这个程序就是透明的,因为可以看透机器究竟在干什么。

如果软件系统所包含的功能是为了帮助人们对软件建立正确的“做什么、怎么做”的心理模型而设计,这个软件系统就是可显的。因此,举例来说,对用户而言,良好的文档有助于提高可显性;对程序员而言,良好的变量和函数名有助于提高可显性。可显性是一种主动品质。在软件中要达到这一点,仅仅做到不晦涩是不够的,还必须要尽力做到有帮助。”

我们要写好程序,减少 bug,就要增强自己对代码的控制力。你始终做到,理解自己调用的函数/复用的代码大概是怎么实现的。不然,你可能就会在单线程状态机的 server 里调用有 IO 阻塞的函数,让自己的 server 吞吐量直接掉到底。进而,为了保证大家能对自己代码能做到有控制力,所有人写的函数,就必须具备很高的透明性。而不是写一些看了一阵看不明白的函数/代码,结果被迫使用你代码的人,直接放弃了对掌控力的追取,甚至放弃复用你的代码,另起炉灶,走向了’制造重复代码’的深渊。

透明性其实相对容易做到的,大家有意识地锻炼一两个月,就能做得很好。可显性就不容易了。有一个现象是,你写的每一个函数都不超过 80 行,每一行我都能看懂,但是你层层调用,很多函数调用,组合起来怎么就实现了某个功能,看两遍,还是看不懂。第三遍可能才能大概看懂。大概看懂了,但太复杂,很难在大脑里构建起你实现这个功能的整体流程。结果就是,阅读者根本做不到对你的代码有好的掌控力。

可显性的标准很简单,大家看一段代码,懂不懂,一下就明白了。但是,如何做好可显性?那就是要追求合理的函数分组,合理的函数上下级层次,同一层次的代码才会出现在同一个函数里,追求通俗易懂的函数分组分层方式,是通往可显性的道路。

当然,复杂如 linux 操作系统,office 文档,问题本身就很复杂,拆解、分层、组合得再合理,都难建立心理模型。这个时候,就需要完备的文档了。完备的文档还需要出现在离代码最近的地方,让人“知道这里复杂的逻辑有文档”,而不是其实文档,但是阅读者不知道。再看看上面 Golang 标准库里的 http.Request,感受到它在可显性上的努力了么?对,就去学它。

原则 10 通俗原则: 接口设计避免标新立异

设计程序过于标新立异的话,可能会提升别人理解的难度。

一般,我们这么定义一个“点”,使用 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()的操作,有人使用了你的接口,错误地把数据覆盖了。

原则 11 缄默原则: 如果一个程序没什么好说的,就沉默

这个原则,应该是大家最经常破坏的原则之一。一段简短的代码里插入了各种“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%,没任何问题!

原则 12 补救原则: 出现异常时,马上退出并给出足够错误信息

其实这个问题很简单,如果出现异常,异常并不会因为我们尝试掩盖它,它就不存在了。所以,程序错误和逻辑错误要严格区分对待。这是一个态度问题。

“异常是互联网服务器的常态”。逻辑错误通过 metrics 统计,我们做好告警分析。对于程序错误 ,我们就必须要严格做到在问题最早出现的位置就把必要的信息搜集起来,高调地告知开发和维护者“我出现异常了,请立即修复我!”。可以是直接就没有被捕获的 panic 了。也可以在一个最上层的位置统一做好 recover 机制,但是在 recover 的时候一定要能获得准确异常位置的准确异常信息。不能有中间 catch 机制,catch 之后丢失很多信息再往上传递。

很多 Java 开发的同学,不区分程序错误和逻辑错误,要么都很宽容,要么都很严格,对代码的可维护性是毁灭性的破坏。“我的程序没有程序错误,如果有,我当时就解决了。”只有这样,才能保持程序代码质量的相对稳定,在火苗出现时扑灭火灾是最好的扑灭火灾的方式。当然,更有效的方式是全面自动化测试的预防:)

本文主要阐述了研发人员在日常工作和职业生涯中,或多或少都会去学习并运用的知名架构原则,并从我个人的角度去做了深入的发散阐述。在下一篇文章中,我将从程序员的自我修养和不能上升到原则的几个常见案例来继续阐述程序员修炼之道的未尽事宜。

4 - 华为C语言编码规范

作为程序开发者,避免不了阅读别人代码,那么就会涉及到到一门语言的编程规范。规范虽然不是语言本身的硬性要求,但是已经是每一个语言使用者约定俗成的一个规范。

按照编程规范编写的代码,至少在代码阅读时,给人一种愉悦的心情,特别是强迫症患者。另一方面,统一的编程风格,可以减少编写错误,利于后期维护。

因为最近又开始进行纯C语言的开发,并且是基于SDK的开发,所以添加的每一行代码都应该与原来风格保持一致,不能因为一颗老鼠屎坏了一锅汤。一个良好的编程规范也可以看出编程人员的细心程度与代码质量。

之前待过的两家公司,也都有各自总结的编程规范,但都不约而同的一致,适用本公司的软件开发。这几天有幸可以参阅华为技术有限公司的C语言编程规范,相比之下,写的更加详细。

至少接触到了,在这个编程规范中体现了,并且还扩充了很多,我觉得有必要归纳总结,一遍日后查阅。先是学习规范,然后再积累规范,最后才是依规范编写。

1、清晰第一

清晰性是易于维护、易于重构的程序必需具备的特征。代码首先是给人读的,好的代码应当可以像文章一样发声朗诵出来。

2.、简洁为美

简洁就是易于理解并且易于实现。代码越长越难以看懂,也就越容易在修改时引入错误。写的代码越多,意味着出错的地方越多,也就意味着代码的可靠性越低。因此,我们提倡大家通过编写简洁明了的代码来提升代码可靠性。废弃的代码(没有被调用的函数和全局变量)要及时清除,重复代码应该尽可能提炼成函数。

3、选择合适的风格,与代码原有的风格保持一致

产品所有人共同分享同一种风格所带来的好处,远远超出为了统一而付出的代价。在公司已有编码规范的指导下,审慎地编排代码以使代码尽可能清晰,是一项非常重要的技能。如果重构/修改其他风格的代码时,比较明智的做法是根据现有代码的现有风格继续编写代码,或者使用格式转换工具进行转换成公司内部风格。

一、头文件

原则1.1 头文件中适合放置接口的声明,不适合放置实现。

说明:头文件是模块(Module)或单元(Unit)的对外接口。头文件中应放置对外部的声明,如对外提供的函数声明、宏定义、类型定义等。

原则1.2 头文件应当职责单一。

说明:头文件过于复杂,依赖过于复杂是导致编译时间过长的主要原因。很多现有代码中头文件过大,职责过多,再加上循环依赖的问题,可能导致为了在.c中使用一个宏,而包含十几个头文件。

原则1.3 头文件应向稳定的方向包含。

说明:头文件的包含关系是一种依赖,一般来说,应当让不稳定的模块依赖稳定的模块,从而当不稳定的模块发生变化时,不会影响(编译)稳定的模块。

规则1.1 每一个.c文件应有一个同名.h文件,用于声明需要对外公开的接口。

说明:如果一个.c文件不需要对外公布任何接口,则其就不应当存在,除非它是程序的入口,如main函数所在的文件。

规则1.2 禁止头文件循环依赖。

说明:头文件循环依赖,指a.h包含b.h,b.h包含c.h,c.h包含a.h之类导致任何一个头文件修改,都导致所有包含了a.h/b.h/c.h的代码全部重新编译一遍。

而如果是单向依赖,如a.h包含b.h,b.h包含c.h,而c.h不包含任何头文件,则修改a.h不会导致包含了b.h/c.h的源代码重新编译。

规则1.3 .c/.h文件禁止包含用不到的头文件。

说明:很多系统中头文件包含关系复杂,开发人员为了省事起见,可能不会去一一钻研,直接包含一切想到的头文件,甚至有些产品干脆发布了一个god.h,其中包含了所有头文件,然后发布给各个项目组使用,这种只图一时省事的做法,导致整个系统的编译时间进一步恶化,并对后来人的维护造成了巨大的麻烦。

规则1.4 头文件应当自包含。

说明:简单的说,自包含就是任意一个头文件均可独立编译。如果一个文件包含某个头文件,还要包含另外一个头文件才能工作的话,就会增加交流障碍,给这个头文件的用户增添不必要的负担。

规则1.5 总是编写内部#include保护符(#define 保护)。

说明:多次包含一个头文件可以通过认真的设计来避免。如果不能做到这一点,就需要采取阻止头文件内容被包含多于一次的机制。

注: 没有在宏最前面加上 _ ,即使用 FILENAME_H代替  FILENAME_H ,是因为一般以 _ 和  __ 开头的标识符为系统保留或者标准库使用,在有些静态检查工具中,若全局可见的标识符以 _ 开头会给出告警。

定义包含保护符时,应该遵守如下规则:

1)保护符使用唯一名称;

2)不要在受保护部分的前后放置代码或者注释。

规则1.6 禁止在头文件中定义变量。

说明:在头文件中定义变量,将会由于头文件被其他.c文件包含而导致变量重复定义。

规则1.7 只能通过包含头文件的方式使用其他.c提供的接口,禁止在.c中通过extern的方式使用外部函数接口、变量。

说明:若a.c使用了b.c定义的foo()函数,则应当在b.h中声明extern int foo(int input);并在a.c中通过#include <b.h>来使用foo。禁止通过在a.c中直接写extern int foo(int input);来使用foo,后面这种写法容易在foo改变时可能导致声明和定义不一致。这一点我们因为图方便经常犯的。

规则1.8 禁止在extern “C"中包含头文件。

说明:在extern “C"中包含头文件,会导致extern “C"嵌套,Visual Studio对extern “C"嵌套层次有限制,嵌套层次太多会编译错误。

建议1.1 一个模块通常包含多个.c文件,建议放在同一个目录下,目录名即为模块名。为方便外部使用者,建议每一个模块提供一个.h,文件名为目录名。

建议1.2 如果一个模块包含多个子模块,则建议每一个子模块提供一个对外的.h,文件名为子模块名。

建议1.3 头文件不要使用非习惯用法的扩展名,如.inc。

建议1.4 同一产品统一包含头文件排列方式。

二、函数

原则2.1 一个函数仅完成一件功能。

说明:一个函数实现多个功能给开发、使用、维护都带来很大的困难。

原则2.2 重复代码应该尽可能提炼成函数。

说明:重复代码提炼成函数可以带来维护成本的降低。

规则2.1 避免函数过长,新增函数不超过50行(非空非注释行)。

说明:本规则仅对新增函数做要求,对已有函数修改时,建议不增加代码行。

规则2.2 避免函数的代码块嵌套过深,新增函数的代码块嵌套不超过4层。

说明:本规则仅对新增函数做要求,对已有的代码建议不增加嵌套层次。

规则2.3 可重入函数应避免使用共享变量;若需要使用,则应通过互斥手段(关中断、信号量)对其加以保护。

规则2.4 对参数的合法性检查,由调用者负责还是由接口函数负责,应在项目组/模块内应统一规定。缺省由调用者负责。

规则2.5 对函数的错误返回码要全面处理。

规则2.6 设计高扇入,合理扇出(小于7)的函数。

说明:扇出是指一个函数直接调用(控制)其它函数的数目,而扇入是指有多少上级函数调用它。如下图:

扇入扇出示意图

规则2.7 废弃代码(没有被调用的函数和变量)要及时清除。

建议2.1 函数不变参数使用const。

说明:不变的值更易于理解/跟踪和分析,把const作为默认选项,在编译时会对其进行检查,使代码更牢固/更安全。

建议2.2 函数应避免使用全局变量、静态局部变量和I/O操作,不可避免的地方应集中使用。

建议2.3 检查函数所有非参数输入的有效性,如数据文件、公共变量等。

说明:函数的输入主要有两种:一种是参数输入;另一种是全局变量、数据文件的输入,即非参数输入。函数在使用输入参数之前,应进行有效性检查。

建议2.4 函数的参数个数不超过5个。

建议2.5 除打印类函数外,不要使用可变长参函数。

建议2.6 在源文件范围内声明和定义的所有函数,除非外部可见,否则应该增加static关键字。

三、 标识符命名与定义

目前比较常用的如下几种命名风格:

unix like风格:单词用小写字母,每个单词直接用下划线_分割,,例如text_mutex,kernel_text_address。

Windows风格:大小写字母混用,单词连在一起,每个单词首字母大写。不过Windows风格如果遇到大写专有用语时会有些别扭,例如命名一个读取RFC文本的函数,命令为ReadRFCText,看起来就没有unix like的read_rfc_text清晰了。

原则3.1 标识符的命名要清晰、明了,有明确含义,同时使用完整的单词或大家基本可以理解的缩写,避免使人产生误解。

原则3.2 除了常见的通用缩写以外,不使用单词缩写,不得使用汉语拼音。

建议3.1 产品/项目组内部应保持统一的命名风格。

建议3.2 尽量避免名字中出现数字编号,除非逻辑上的确需要编号。

建议3.3 标识符前不应添加模块、项目、产品、部门的名称作为前缀。

建议3.4 平台/驱动等适配代码的标识符命名风格保持和平台/驱动一致。

建议3.5 重构/修改部分代码时,应保持和原有代码的命名风格一致。

建议3.6 文件命名统一采用小写字符。

规则3.2 全局变量应增加“g_”前缀。

规则3.3 静态变量应增加“s_”前缀。

规则3.4 禁止使用单字节命名变量,但允许定义i、j、k作为局部循环变量。

建议3.7 不建议使用匈牙利命名法。

说明:变量命名需要说明的是变量的含义,而不是变量的类型。在变量命名前增加类型说明,反而降低了变量的可读性;更麻烦的问题是,如果修改了变量的类型定义,那么所有使用该变量的地方都需要修改。

建议3.8 使用名词或者形容词+名词方式命名变量。

建议3.9 函数命名应以函数要执行的动作命名,一般采用动词或者动词+名词的结构。

建议3.10 函数指针除了前缀,其他按照函数的命名规则命名。

规则3.5 对于数值或者字符串等等常量的定义,建议采用全大写字母,单词之间加下划线„_‟的方式命名(枚举同样建议使用此方式定义)。

规则3.6 除了头文件或编译开关等特殊标识定义,宏定义不能使用下划线„_‟开头和结尾。

四、变量

原则4.1 一个变量只有一个功能,不能把一个变量用作多种用途。

原则4.2 结构功能单一;不要设计面面俱到的数据结构。

原则4.3 不用或者少用全局变量。

规则4.1 防止局部变量与全局变量同名。

规则4.2 通讯过程中使用的结构,必须注意字节序。

规则4.3 严禁使用未经初始化的变量作为右值。

建议4.1 构造仅有一个模块或函数可以修改、创建,而其余有关模块或函数只访问的全局变量,防止多个不同模块或函数都可以修改、创建同一全局变量的现象。

建议4.2 使用面向接口编程思想,通过API访问数据:如果本模块的数据需要对外部模块开放,应提供接口函数来设置、获取,同时注意全局数据的访问互斥。

建议4.3 在首次使用前初始化变量,初始化的地方离使用的地方越近越好。

建议4.4 明确全局变量的初始化顺序,避免跨模块的初始化依赖。

说明:系统启动阶段,使用全局变量前,要考虑到该全局变量在什么时候初始化,使用全局变量和初始化全局变量,两者之间的时序关系,谁先谁后,一定要分析清楚,不然后果往往是低级而又灾难性的。

建议4.5 尽量减少没有必要的数据类型默认转换与强制转换。

说明:当进行数据类型强制转换时,其数据的意义、转换后的取值等都有可能发生变化,而这些细节若考虑不周,就很有可能留下隐患。

五、 宏、常量

规则5.1 用宏定义表达式时,要使用完备的括号。

说明:因为宏只是简单的代码替换,不会像函数一样先将参数计算后,再传递。

规则5.2 将宏所定义的多条表达式放在大括号中。

说明:更好的方法是多条语句写成do while(0)的方式。

规则5.3 使用宏时,不允许参数发生变化。

规则5.4 不允许直接使用魔鬼数字。

说明:使用魔鬼数字的弊端:代码难以理解;如果一个有含义的数字多处使用,一旦需要修改这个数值,代价惨重。使用明确的物理状态或物理意义的名称能增加信息,并能提供单一的维护点。

建议5.1 除非必要,应尽可能使用函数代替宏。

说明:宏对比函数,有一些明显的缺点:宏缺乏类型检查,不如函数调用检查严格。

建议5.2 常量建议使用const定义代替宏。

建议5.3 宏定义中尽量不使用return、goto、continue、break等改变程序流程的语句。

六、质量保证

原则6.1 代码质量保证优先原则

(1)正确性,指程序要实现设计要求的功能。

(2)简洁性,指程序易于理解并且易于实现。

(3)可维护性,指程序被修改的能力,包括纠错、改进、新需求或功能规格变化的适应能力。

(4)可靠性,指程序在给定时间间隔和环境条件下,按设计要求成功运行程序的概率。

(5)代码可测试性,指软件发现故障并隔离、定位故障的能力,以及在一定的时间和成本前提下,进行测试设计、测试执行的能力。

(6)代码性能高效,指是尽可能少地占用系统资源,包括内存和执行时间。

(7)可移植性,指为了在原来设计的特定环境之外运行,对系统进行修改的能力。

(8)个人表达方式/个人方便性,指个人编程习惯。

原则6.2 要时刻注意易混淆的操作符。比如说一些符号特性、计算优先级。

原则6.3 必须了解编译系统的内存分配方式,特别是编译系统对不同类型的变量的内存分配规则,如局部变量在何处分配、静态变量在何处分配等。

原则6.4 不仅关注接口,同样要关注实现。

说明:这个原则看似和“面向接口”编程思想相悖,但是实现往往会影响接口,函数所能实现的功能,除了和调用者传递的参数相关,往往还受制于其他隐含约束,如:物理内存的限制,网络状况,具体看“抽象漏洞原则”。

规则6.1 禁止内存操作越界。

坚持下列措施可以避免内存越界:

  • 数组的大小要考虑最大情况,避免数组分配空间不够。

  • 避免使用危险函数sprintf /vsprintf/strcpy/strcat/gets操作字符串,使用相对安全的函数snprintf/strncpy/strncat/fgets代替。

  • 使用memcpy/memset时一定要确保长度不要越界

  • 字符串考虑最后的’\0’, 确保所有字符串是以’\0’结束

  • 指针加减操作时,考虑指针类型长度

  • 数组下标进行检查

  • 使用时sizeof或者strlen计算结构/字符串长度,,避免手工计算

坚持下列措施可以避免内存泄漏:

  • 异常出口处检查内存、定时器/文件句柄/Socket/队列/信号量/GUI等资源是否全部释放

  • 删除结构指针时,必须从底层向上层顺序删除

  • 使用指针数组时,确保在释放数组时,数组中的每个元素指针是否已经提前被释放了

  • 避免重复分配内存

  • 小心使用有return、break语句的宏,确保前面资源已经释放

  • 检查队列中每个成员是否释放

规则6.3 禁止引用已经释放的内存空间。

  • 坚持下列措施可以避免引用已经释放的内存空间:

  • 内存释放后,把指针置为NULL;使用内存指针前进行非空判断。

  • 耦合度较强的模块互相调用时,一定要仔细考虑其调用关系,防止已经删除的对象被再次使用。

  • 避免操作已发送消息的内存。

  • 自动存储对象的地址不应赋值给其他的在第一个对象已经停止存在后仍然保持的对象(具有更大作用域的对象或者静态对象或者从一个函数返回的对象)

规则6.4 编程时,要防止差1错误。

说明:此类错误一般是由于把“<=”误写成“<”或“>=”误写成“>”等造成的,由此引起的后果,很多情况下是很严重的,所以编程时,一定要在这些地方小心。当编完程序后,应对这些操作符进行彻底检查。使用变量时要注意其边界值的情况。

建议6.1 函数中分配的内存,在函数退出之前要释放。

说明:有很多函数申请内存,,保存在数据结构中,要在申请处加上注释,说明在何处释放。

建议6.2 if语句尽量加上else分支,对没有else分支的语句要小心对待。

建议6.3 不要滥用goto语句。

说明:goto语句会破坏程序的结构性,所以除非确实需要,最好不使用goto语句。

建议6.4 时刻注意表达式是否会上溢、下溢。

七、 程序效率

原则7.1 在保证软件系统的正确性、简洁、可维护性、可靠性及可测性的前提下,提高代码效率。

原则7.2 通过对数据结构、程序算法的优化来提高效率。

建议7.1 将不变条件的计算移到循环体外。

建议7.2 对于多维大数组,避免来回跳跃式访问数组成员。

建议7.3 创建资源库,以减少分配对象的开销。

建议7.4 将多次被调用的 “小函数”改为inline函数或者宏实现。

八、 注释

原则8.1 优秀的代码可以自我解释,不通过注释即可轻易读懂。

说明:优秀的代码不写注释也可轻易读懂,注释无法把糟糕的代码变好,需要很多注释来解释的代码往往存在坏味道,需要重构。

原则8.2 注释的内容要清楚、明了,含义准确,防止注释二义性。

原则8.3 在代码的功能、意图层次上进行注释,即注释解释代码难以直接表达的意图,而不是重复描述代码。

规则8.1 修改代码时,维护代码周边的所有注释,以保证注释与代码的一致性。不再有用的注释要删除。

规则8.2 文件头部应进行注释,注释必须列出:版权说明、版本号、生成日期、作者姓名、工号、内容、功能说明、与其它文件的关系、修改日志等,头文件的注释中还应有函数功能简要说明。

规则8.3 函数声明处注释描述函数功能、性能及用法,包括输入和输出参数、函数返回值、可重入的要求等;定义处详细描述函数功能和实现要点,如实现的简要步骤、实现的理由、设计约束等。

规则8.4 全局变量要有较详细的注释,包括对其功能、取值范围以及存取时注意事项等的说明。

规则8.5 注释应放在其代码上方相邻位置或右方,不可放在下面。如放于上方则需与其上面的代码用空行隔开,且与下方代码缩进相同。

规则8.6 对于switch语句下的case语句,如果因为特殊情况需要处理完一个case后进入下一个case处理,必须在该case语句处理完、下一个case语句前加上明确的注释。

规则8.7 避免在注释中使用缩写,除非是业界通用或子系统内标准化的缩写。

规则8.8 同一产品或项目组统一注释风格。

建议8.1 避免在一行代码或表达式的中间插入注释。

建议8.2 注释应考虑程序易读及外观排版的因素,使用的语言若是中、英兼有的,建议多使用中文,除非能用非常流利准确的英文表达。对于有外籍员工的,由产品确定注释语言。

建议8.3 文件头、函数头、全局常量变量、类型定义的注释格式采用工具可识别的格式。

说明:采用工具可识别的注释格式,例如doxygen格式,方便工具导出注释形成帮助文档。

九、 排版与格式

规则9.1 程序块采用缩进风格编写,每级缩进为4个空格。

说明:当前各种编辑器/IDE都支持TAB键自动转空格输入,需要打开相关功能并设置相关功能。编辑器/IDE如果有显示TAB的功能也应该打开,方便及时纠正输入错误。

规则9.2 相对独立的程序块之间、变量说明之后必须加空行。

规则9.3 一条语句不能过长,如不能拆分需要分行写。一行到底多少字符换行比较合适,产品可以自行确定。

换行时有如下建议:

  • 换行时要增加一级缩进,使代码可读性更好;

  • 低优先级操作符处划分新行;换行时操作符应该也放下来,放在新行首;

  • 换行时建议一个完整的语句放在一行,不要根据字符数断行

规则9.4 多个短语句(包括赋值语句)不允许写在同一行内,即一行只写一条语句。

规则9.5 if、for、do、while、case、switch、default等语句独占一行。

规则9.6 在两个以上的关键字、变量、常量进行对等操作时,它们之间的操作符之前、之后或者前后要加空格;进行非对等操作时,如果是关系密切的立即操作符(如->),后不应加空格。

建议9.1 注释符(包括„/‟„//‟„/‟)与注释内容之间要用一个空格进行分隔。

建议9.2 源程序中关系较为紧密的代码应尽可能相邻。

十、 表达式

规则10.1 表达式的值在标准所允许的任何运算次序下都应该是相同的。

建议10.1 函数调用不要作为另一个函数的参数使用,否则对于代码的调试、阅读都不利。

建议10.2 赋值语句不要写在if等语句中,或者作为函数的参数使用。

建议10.3 赋值操作符不能使用在产生布尔值的表达式上。

十一、 代码编辑、编译

规则11.1 使用编译器的最高告警级别,理解所有的告警,通过修改代码而不是降低告警级别来消除所有告警。

规则11.2 在产品软件(项目组)中,要统一编译开关、静态检查选项以及相应告警清除策略。

规则11.3 本地构建工具(如PC-Lint)的配置应该和持续集成的一致。

规则11.4 使用版本控制(配置管理)系统,及时签入通过本地构建的代码,确保签入的代码不会影响构建成功。

建议11.1 要小心地使用编辑器提供的块拷贝功能编程。

十二、 可测性

原则12.1 模块划分清晰,接口明确,耦合性小,有明确输入和输出,否则单元测试实施困难。

说明:单元测试实施依赖于:

  • 模块间的接口定义清楚、完整、稳定;

  • 模块功能的有明确的验收条件(包括:预置条件、输入和预期结果);

  • 模块内部的关键状态和关键数据可以查询,可以修改;

  • 模块原子功能的入口唯一;

  • 模块原子功能的出口唯一;

  • 依赖集中处理:和模块相关的全局变量尽量的少,或者采用某种封装形式。

规则12.1 在同一项目组或产品组内,要有一套统一的为集成测试与系统联调准备的调测开关及相应打印函数,并且要有详细的说明。

规则12.2 在同一项目组或产品组内,调测打印的日志要有统一的规定。

说明:统一的调测日志记录便于集成测试,具体包括:

  • 统一的日志分类以及日志级别;

  • 通过命令行、网管等方式可以配置和改变日志输出的内容和格式;

  • 在关键分支要记录日志,日志建议不要记录在原子函数中,否则难以定位;

  • 调试日志记录的内容需要包括文件名/模块名、代码行号、函数名、被调用函数名、错误码、错误发生的环境等。

规则12.3 使用断言记录内部假设。

规则12.4 不能用断言来检查运行时错误。

说明:断言是用来处理内部编程或设计是否符合假设;不能处理对于可能会发生的且必须处理的情况要写防错程序,而不是断言。如某模块收到其它模块或链路上的消息后,要对消息的合理性进行检查,此过程为正常的错误检查,不能用断言来实现。

建议12.1 为单元测试和系统故障注入测试准备好方法和通道。

十三、 安全性

原则13.1 对用户输入进行检查。

说明:不能假定用户输入都是合法的,因为难以保证不存在恶意用户,即使是合法用户也可能由于误用误操作而产生非法输入。用户输入通常需要经过检验以保证安全,特别是以下场景:

  • 用户输入作为循环条件

  • 用户输入作为数组下标

  • 用户输入作为内存分配的尺寸参数

  • 用户输入作为格式化字符串

  • 用户输入作为业务数据(如作为命令执行参数、拼装sql语句、以特定格式持久化)

这些情况下如果不对用户数据做合法性验证,很可能导致DOS、内存越界、格式化字符串漏洞、命令注入、SQL注入、缓冲区溢出、数据破坏等问题。

可采取以下措施对用户输入检查:

  • 用户输入作为数值的,做数值范围检查

  • 用户输入是字符串的,检查字符串长度

  • 用户输入作为格式化字符串的,检查关键字“%”

  • 用户输入作为业务数据,对关键字进行检查、转义

规则13.1 确保所有字符串是以NULL结束。

说明: C语言中‟\0‟作为字符串的结束符,即NULL结束符。标准字符串处理函数(如strcpy()、 strlen())

依赖NULL结束符来确定字符串的长度。没有正确使用NULL结束字符串会导致缓冲区溢出和其它未定义的行为。

为了避免缓冲区溢出,常常会用相对安全的限制字符数量的字符串操作函数代替一些危险函数。如:

  • 用strncpy()代替strcpy()

  • 用strncat()代替strcat()

  • 用snprintf()代替sprintf()

  • 用fgets()代替gets()

这些函数会截断超出指定限制的字符串,但是要注意它们并不能保证目标字符串总是以NULL结尾。如果源字符串的前n个字符中不存在NULL字符,目标字符串就不是以NULL结尾。

规则13.2 不要将边界不明确的字符串写到固定长度的数组中。

说明:边界不明确的字符串(如来自gets()、getenv()、scanf()的字符串),长度可能大于目标数组长度,直接拷贝到固定长度的数组中容易导致缓冲区溢出。

规则13.3 避免整数溢出。

说明:当一个整数被增加超过其最大值时会发生整数上溢,被减小小于其最小值时会发生整数下溢。带符号和无符号的数都有可能发生溢出。

规则13.4 避免符号错误。

说明:有时从带符号整型转换到无符号整型会发生符号错误,符号错误并不丢失数据,但数据失去了原来的含义。

带符号整型转换到无符号整型,最高位(high-order bit)会丧失其作为符号位的功能。如果该带符号整数的值非负,那么转换后值不变;如果该带符号整数的值为负,那么转换后的结果通常是一个非常大的正数。

规则13.5:避免截断错误。

说明:将一个较大整型转换为较小整型,并且该数的原值超出较小类型的表示范围,就会发生截断错误,原值的低位被保留而高位被丢弃。截断错误会引起数据丢失。使用截断后的变量进行内存操作,很可能会引发问题。

规则13.6:确保格式字符和参数匹配。

说明:使用格式化字符串应该小心,确保格式字符和参数之间的匹配,保留数量和数据类型。格式字符和参数之间的不匹配会导致未定义的行为。大多数情况下,不正确的格式化字符串会导致程序异常终止。

规则13.7 避免将用户输入作为格式化字符串的一部分或者全部。

说明:调用格式化I/O函数时,不要直接或者间接将用户输入作为格式化字符串的一部分或者全部。攻击者对一个格式化字符串拥有部分或完全控制,存在以下风险:进程崩溃、查看栈的内容、改写内存、甚至执行任意代码。

规则13.8 避免使用strlen()计算二进制数据的长度。

说明:strlen()函数用于计算字符串的长度,它返回字符串中第一个NULL结束符之前的字符的数量。因此用strlen()处理文件I/O函数读取的内容时要小心,因为这些内容可能是二进制也可能是文本。

规则13.9 使用int类型变量来接受字符I/O函数的返回值。

规则13.10 防止命令注入。

说明:C99函数system()通过调用一个系统定义的命令解析器(如UNIX的shell,Windows的CMD.exe)来执行一个指定的程序/命令。类似的还有POSIX的函数popen()。

十四、 单元测试

规则14.1 在编写代码的同时,或者编写代码前,编写单元测试用例验证软件设计/编码的正确。

建议14.1 单元测试关注单元的行为而不是实现,避免针对函数的测试。

说明:应该将被测单元看做一个被测的整体,根据实际资源、进度和质量风险,权衡代码覆盖、打桩工作量、补充测试用例的难度、被测对象的稳定程度等,一般情况下建议关注模块/组件的测试,尽量避免针对函数的测试。尽管有时候单个用例只能专注于对某个具体函数的测试,但我们关注的应该是函数的行为而不是其具体实现细节。

十五、 可移植性

规则15.1 不能定义、重定义或取消定义标准库/平台中保留的标识符、宏和函数。

建议15.1 不使用与硬件或操作系统关系很大的语句,而使用建议的标准语句,以提高软件的可移植性和可重用性。

建议15.2 除非为了满足特殊需求,避免使用嵌入式汇编。

说明:程序中嵌入式汇编,一般都对可移植性有较大的影响。

5 - 开发技术选型

综合技术选型

存储

关系型

  1. MySQL

    • 连接池选择
      • Druid
      • HikariCP(spring默认连接池)
  2. Oracle(除了政府项目,一般用不到)

  3. Sqlite(客户端项目比较好用)

非关系型

  1. redis

    • 客户端选型
      • Ridisson(98)
      • RedisTemplate(80)
      • Jedis(60)
  2. ElesticSearch

    • 客户端选型
      • Spring Data ElasticSearch(95)
      • bboss-elasticsearch(75)
      • elasticsearch-sql(60)

数据库管理

SQL审核

  1. Yearning http://yearning.io/
  2. Archery https://archerydms.com/

binlog解析

  1. MyFlash
  2. binlog2sql
  3. Archery工具包也具有binlog解析能力

Percona Toolkit 工具包

Percona Toolkit 工具包是一组高级的管理 MySQL 的工具包集,可以用来执行各种通过手工执行非常复杂和麻烦的系统任务。简称 PT 工具,由 Percona 公司开发维护,是广大数据库维护人员的好帮手。

  • pt-archiver:主要用于清理、归档历史数据。
  • pt-duplicate-key-checker:列出并删除重复的索引和外键。
  • pt-kill:杀掉符合条件的数据库连接。
  • pt-online-schema-change:在线修改表结构,常用于大表 DDL 。
  • pt-query-digest:分析 MySQL 日志,并产生报告,常用于慢日志分析。
  • pt-table-checksum:校验主从复制一致性。

MQ

  1. RocketMQ(95)
    1. 支持延迟消息
    2. 吞吐量也够大
  2. RabbitMQ(85)
    1. 支持延迟消息
    2. 吞吐量略小(但对于一般项目足够了)
  3. Kafka(75)
    1. 不支持延迟消息
    2. 吞吐量大,适用于数据量很大的场景,比如:日志收集
  4. activeMQ

监控及链路追踪

  1. SkyWalking:

Skywalking是一个分布式追踪系统,可以跟踪整个分布式系统的请求流程,并记录每个组件之间的调用关系和时间消耗。Skywalking被广泛应用于微服务架构中,帮助用户快速定位分布式事务链路上的问题。 Skywalking比较适合跟踪分布式事务链路

  1. Prometheus

Prometheus是一个开源监控系统,可以收集并存储各种指标数据,并提供强大的查询语言和可视化界面。Prometheus被广泛应用于分布式系统监控、服务质量保障等方面。 Prometheus比较适合收集指标数据并进行分析

代码管理

  1. github

可能需要坐飞机,私有仓库,是需要付费的

  1. gitee

国内平台,企业版支持5人组队开发

  1. gitlib

可实现完全私有化的git管理,私密性更强

镜像管理

  1. dockerhub

国内镜像源:阿里云、网易云、docker官方 访问源站可能需要翻墙

  1. aliyun

阿里云镜像源,可构建 maven, docker, node 等私有仓库

  1. harbor

是为企业用户设计的容器镜像仓库开源项目,包括了权限管理(RBAC)、LDAP、审计、安全漏洞扫描、镜像验真、管理界面、自我注册、HA 等企业必需的功能,同时针对中国用户的特点,设计镜像复制和中文支持等功能。 我之前用过的是使用其docker仓库功能,部署在服务器上,可实现私有仓库

  1. Nexus

Nexus 是一个私有 Maven 仓库管理器,主要用于公司内部,用于搭建私服,可实现镜像管理 也提供yum、pypi、npm、docker、nuget、rubygems 等私有仓库

Java方向

框架选型

  1. SpringBoot(单体架构)
  2. Spring Cloud Alibaba (微服务架构)

持久层框架

  1. MyBatis-Plus
    1. 好处:开发速度快、兼容MyBatis
    2. 缺点:个别场景的多表联查不如JPA。(但可以使用MyBatis)

分布式锁

  1. Redisson(95分)

分布式定时任务

  1. XXL-JOB(90分)
    1. 很流行;很好用
  2. Quartz(50分)
    1. 功能很全,但是上手难度高,新手不友好,没可视化界面
  3. Spring自带
    1. 无可视化页面

分布式事务

  1. Seata(95分)
    1. 阿里开发,很流行

线上问题排查应用程序诊断

  1. arthas

Arthas是一个Java诊断工具,可以实时查看应用程序的运行状态、调用堆栈、方法耗时等信息,并进行动态修改代码或者配置。Arthas被广泛应用于线上故障排查和性能优化中,同时也支持离线日志分析。 Arthas比较适合快速排查线上问题

接口文档工具

  1. knife4j
  2. swagger
  3. apidoc

工具类

工具类优先使用Spring自带的(稳定、基本没bug)。Spring自带的工具基本都够用,非必要不要用其他乱七八糟的工具类(不稳定、bug多)。

JSON工具

  1. Jackson(99)
    1. Spring自带,效率和稳定性都很好
  2. FastJson(60)
    1. bug多,经常爆出问题
  3. gson(50)
    1. 不流行

HTTP客户端

  1. WebClient

基于Reactor模型能能更优,如果是新项目推荐使用

  1. RestTemplate(一个同步的http客户端)

Spring早期的http客户端,是使用阻塞线程模型

  1. HttpRequest(80)
    1. hutool的,灵活性好

String工具

  1. Spring自带的StringUtils

Spring自带,稳定性好,能满足大多数场景,目前已知的唯一痛点是不支持 equals

  1. 其他:包括hutool、commons-lang3、apache-commons-lang3、apache-commons-text等

数据采集

  1. kettle引擎

Golang方向