1 | # http://nginx.org/en/linux_packages.html#RHEL-CentOS |
1 | # 备份并修改配置文件 |
1 | ... |
1 | echo "<html> |
1 | location /doc { |
打开配置文件 隐藏版本设置 Server_tokens off;
trace 请求用于网络诊断,会暴露信息,只允许 GET、HEAD、POST 请求,其他请求直接返回 444 状态码 (444 是 nginx 定义的响应状态码,会立即断开连接,没有响应正文,TRACE 请求 nginx 内置 405 拒绝)
1 | if ($request_method !~ ^(GET|HEAD|POST)$ ) { return 444; } |
1 | proxy_hide_header -Powered- ; |
1 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; |
1 | - #gzip on; |
生产环境机器发现内存占用很高:
1 | [root@node132 /run/log/journal]# free -h |
排查发现,是 systemd-journal 占用了过高的内存:
1 | [root@node173 /run/log/journal]# top -o %MEM -n 1 -b | head -n17 |
查看相关配置发现,systemd-journal 服务没有启用持久化,日志保留在内存,因此只需要启用持久化即可解决该问题。
1. 首先将日志目录移动到 /data 路径下
1 | mkdir /data/log |
2. 修改服务配置,重启服务
1 | vim /etc/systemd/journald.conf |
重启服务后,日志将会被直接持久化到磁盘,不再占用过多内存。
要保证数据安全和系统稳定可用,我们应当全方位地对系统进行保护,这里主要分为两个层面。
一是系统的安全方面,这主要是面向非法入侵、非法请求的。我们需要阻止和屏蔽不信任的请求源访问,我们要保证数据的安全可靠,不被人窃取。
二是系统的健壮性方面,面向合法、信任的请求源,为了保证系统的可用性,我们需要在请求并发高于系统设计容量时,拦截和丢弃超载的请求(有损服务),以避免因为请求过大发生雪崩效应,导致整个系统都不可用。
对于系统的安全防范,需要从全方位、多角度做工作,以确保整个业务链路、整个体系范围都能保证安全。以下就从多个角度来说明如何进行系统安全设计。
传输加密
我们一般开放 Web 访问,走 HTTP 协议。要对外公开接口或页面时,面向外网应当以 HTTPS 的形式公开。
网络隔离
通过网络互不连通来防止入侵,这是最严格也是最暴力的安全解决方案。在我们的生产实践中,主要体现在如下几个方面:
网关
在我们需要对外公开 HTTP/HTTPS 接口时,需要使用网关收敛对外公开的接触面。设计独立的接入层专门负责网络请求的接入,不得将非接入层的机器公开出去。
容器
我们的应用目前主要基于容器部署。在启动容器时,我们应当为每个不同的项目创建和指定不同的网络,从而隔离不同的项目服务。
对于使用应用的实体,无论是人还是系统程序,都应当做到对每个请求都应当能找到对应的责任实体。因此,面向人我们需要做到严格的登录鉴权,面向应用我们应当做到严格的接口签名和数据校验。我们通常将这一层称之为 ACL(Access Control Layer)。
目前能够落地实践的方案,有如下几个值得推荐:
群体 | 外网 | 内网 |
---|---|---|
员工 | 接入 OA 登录 | 接入 OA 登录 |
外部用户 | 接入微信、企业微信、QQ 登录 | 不允许外部用户直接登录/访问内网(*) |
TIP: 不允许外部用户直接登录内网,必需在外网部署登录验证服务,验证通过之后,才能由外网的服务通过应用网关发起对 OA 服务的请求。所有外部请求均只落到外网区域,不允许通过反向代理将请求直接转发到应用网关继而将未经过登录验证的请求路由到 OA 服务器上。
如果要自建登录验证模块,那么需要注意如下几个方面:
我们的服务大多需要集群部署,因此需要是无状态的。虽然智能网关和大多数反向代理一样都支持有状态服务,但为了管理方便和更高的可扩展性,我们的最佳实践是保证开发部署的所有业务服务都是无状态的。而登录态本身是有状态的,因此登录态本身需要从业务服务中剥离出来,以避免导致有状态服务的出现。此时我们通常会将登录凭据保存到 Redis 中。因此,我们需要要求:
另一方面,业务侧从 ACL 拿到登录凭据之后,需要注意如下两个方面:
请求签名
我们应当始终对外部请求持谨慎态度,包括请求的来源,请求的数据,都要进行谨慎的处理。只有通过接口签名验证的请求,才信任为合法的请求。
对请求的接口签名设计,可参考学习 QQ、微信的相关接口。也可直接接入应用网关,使用应用网关的签名机制。我们也可以设计自己的接口签名算法,但需要重点保证用来签名的关键信息(如 app_secret)不得通过请求本身传递,也要告知和约束用户保证用来签名的关键信息不得泄露。
TIP: 对请求签名除了保证能区分合法和非法请求,也能通过签名追述每一次请求具体的来源应用。
数据加密
对于敏感数据,不得明文传输和明文存储。
对外方面,不得明文传输。我们可以采用 map hash 的方式做映射,如 ACL 返回的身份凭据,我们不应当直接写入客户端 Cookie,而是应当生成临时 hash,以映射的关系与客户端关联;也可以对数据本身进行加密传输,如启用 HTTPS,或者将关键信息通过加密之后再传输。
对内方面,不得明文存储。我们可以加密后再存储,如密码;也可以直接脱敏处理,如日志。
容器
在我们打包容器时,应当使用安全可靠的基础镜像进行打包。
理想状况下我们不应当登录服务器进行任何操作,但实际生产却不是这样的。由于运维工具功能的不完善和滞后性,我们往往不得不直接登录到服务器上去直接执行命令,进行日志维护、服务进程维护、服务器维护等操作。由于已经直接触达服务器本身,因此这也是整个系统安全最高危的部分。
运行账户
为减少由于我们的服务被攻击之后造成的损失,减小影响面。在运行应用时,不得以 root 权限运行,而应当另外创建专有用户,并授予最小权限。这样即使应用本身被攻击成功,黑客也需要有一个提权的过程,提高了攻击的门槛。
运维账户和运维审计
对于运维操作,账户本身应当得到严密的管控,严密管控 root 账号。每一个运维人员都应该采用实名登录,并且在每一次登录、操作、登出都进行完善的记录以备审计。
对于高危操作,需要提升运维人员的意识,避免发生由于粗心大意造成的严重后果。
高危命令
谨慎执行高危命令,在必要时使用alias
指令重命名高危命令。
参考:
数据的安全是重中之重,以上所有的措施其根本也都是为了保证数据的安全。同时,对于数据本身,我们还有一些可以做的事情。
容器隔离
容器化部署时,我们往往会挂载外部目录到容器中。此时我们应当确保:
租户隔离
随着 SaaS 化系统越来越多,我们也采用了更多的多租户设计。在系统交互、数据读写方面,务必强要求区分租户。不得因为租户信息被泄露,导致跨租户的其他数据被泄露,必须严控影响面。
离线票据
跨系统交互时,我们可能会涉及到一些敏感票据,比如 app_key/app_secret、pub_key/private_key 等。其中往往 app_key、pub_key 是公开的,允许通过请求传递,而 app_secret、private_key 是需要严格保密的,不得直接通过请求传递。对于这些需要严格保密的敏感票据,应当通过线下、脱离于当前系统的方式进行传递,如打印并邮寄,如单独的、人工的邮件等等。
日志脱敏
日志的敏感性是很容易被忽略的。可能我们花了很大的力气做了系统安全加固,缺忘记了日志里往往也存在大量的敏感信息,导致信息通过日志被泄露了。那么在开发时,我们需要时刻注意日志内容,不应当有敏感信息。在日志传输和存储时,也应当对日志内容进行脱敏。
数据备份
和日志一样,数据的备份文件也是容易被忽视的地方,我们应当花足够大的精力来保证备份文件不被窃取。
对于我们的业务来讲,政策合规性一般不存在问题。但从设计上,也应当留有多个方面的合规审计空间。
操作审计
记录用户的操作对象和操作历史。
权限审计
对权限的分配和变更,做严格的控制和记录,避免授予的权限不必要的扩大。同时对于已经失效的用户,应当即使回收权限。
数据审计
对关键数据的读、写,都应当有权限和审批流程进行控制。实际的示例比如 HRC 和 TOF 中,对员工信息的读取,是需要进行权限申请和审批的。
敏感词审计
应当接入或建立敏感词库,对于 UGC 内容,务必确保拦截和过滤敏感词,以避免不合规的内容展示到系统中,导致政策和舆论风险。
对系统的保护,主要是要使得系统具有更高的健壮性(鲁棒性),要求系统在输入错误、磁盘故障、网络过载或有意攻击情况下,能否不死机、不崩溃。这其中包含的内容较为广泛,可做的事情也很多,此处只简略说两个部分。
除了代码及的防御性编程,我们还应当有接口层面的保护。这方面主要致力于解决在网络过载情况下的防护,不至于因为客户端密集高并发调用时,拖垮整个系统,影响接入平台的其他应用。
目前常规的做法是基于微服务架构设计,接入 api 网关来做接口保护。包括“腾讯里约”、“ASF”在内,以及市面上的大多数成熟 api 网关都有相关的功能:
TIP: 成熟的 Api 网关不仅具有上述功能,一般还具有自定义路由、健康检查、高可用接入、协议适配等其他功能。
有了以上功能,我们的服务在需要提供给外部系统访问时就可以通过 api 网关来公开,使得所有进入的请求都流经 api 网关。实际操作上,我们对自身业务系统的容量有了合理评估之后,可以在 api 网关上设置合适的阈值。
通过配置 IP 白名单、消费者鉴权,可以将非法的请求拒之门外;
通过配置消费限额、快速拒绝,可以将过量的、非预期的请求拒之门外,同时避免(通过了消费鉴权的请求)消耗无谓的系统资源;
通过配置服务 QPS 预警和限制,可以在服务能力即将达到临界时,向运维人员发送告警提醒,在实际达到临界时,拒绝更多请求压垮服务;
通过配置服务熔断,可以在我们的服务达到容量极限无法支撑时,保护系统不再受到更多请求流量的冲击;
通过配置防重复和接口缓存,可以在一定程度上减少涌向服务的请求数,避免过多的资源消耗。
以上,不论是来自外部系统的正常还是非正常高并发请求时,过载的网络请求都不会直接涌至我们的服务,而是被 api 网关给拦截或拒绝了回去,从而实现对我们自身业务系统和平台的保护,不至于由于网络过载而导致请求雪崩,系统不可用。
跟上文“应用安全-请求签名”一节中一样,我们除了对接口请求应当持不信任的态度之外,我们也应该对内部 api、method 的调用方持不信任的态度。此时就需要防御性设计和防御性编程的思想,以此来保证我们的程序能够适应更广泛的输入错误,不至于在意外输入时而崩溃。
以上简略地描述了做好系统安全设计和系统保护设计需要做的工作,具体没有展开。在实际工作中由于各个项目的安全级别、网络环境等不用,也可能导致实际能够落地的部分难以面面俱到,此时需要仔细分析我们系统的薄弱和风险环节,有针对性地采取措施,详细深入地做好对应的防护。
]]>(网上很多教程,略)
如果 Windows 10 本身就通过代理上网,则子系统默认是无法上网的,仍然需要设置代理。
1 | echo 'Acquire::http::Proxy "http://myproxy.com:8080";' | sudo tee /etc/apt/apt.conf.d/my-proxy.conf |
1 | echo "export http_proxy='myproxy.com:8080'" | sudo tee -a ~/.bashrc |
安装 protoc,下载地址:https://github.com/protocolbuffers/protobuf/releases
根据自己的系统下载相应的 protoc,windows 用户统一下载 win32 版本。
配置 protoc 到系统的环境变量中,执行如下命令查看是否安装成功:
1 | $ protoc --version |
安装 ProtoBuf 相关的 golang 依赖库
1 | # 用于根据 protobuf 生成 golang 代码,语法 protoc --go_out=. *.proto |
安装语法支持、代码高亮和代码格式化插件
安装 VSC 插件:
安装命令行工具
npm install -g clang-format
编写 protobuf 代码 ./pb/user/profile-service.proto
。(语法参考)
详细代码示例见下文。
生成 *.pb.go 代码。
1 | # 生成 message |
详细命令参数见:https://github.com/golang/protobuf
通过以上命令把./pb/user/profile-service.proto
生成了./api/user/profile-service.pb.go
文件。至此,我们生成了 go 代码,其中生成的 struct 和相关的方法都可以在 go 工程里正常使用。
生成 *.pb.go
代码。
您可能已经发现了,上面生成的代码中找不到我们定义的 service ProfileService
,其中的两个方法也找不到。这是为什么呢?是因为我们执行 protoc 命令时,没有指定支持 grpc 的插件,指定支持 grpc 的插件之后,即会生成服务相关代码:
1 | # 生成 message 和 service(支持gRPC) |
执行上述命令之后,您会发现生成的 go 文件里,多出了与 ProfileServiceServer
、ProfileServiceClient
相关的 struct 和 interface,也多出了我们 GetProfile
、Logout
两个方法相关的代码。
编写服务端和客户端代码
服务端代码:./grpc/server/server.go
,客户端代码:./grpc/grpc-client/client.go
,详细代码见下文。
运行效果
1 | # 运行 gRPC 服务 |
至此,基于 gRPC 通信的服务端和客户端均已经建立起来。
如果我们的 gRPC 服务希望通过 HTTP 协议公开出来,以供现有的其他服务调用,那么我们就需要用到 gRPC Gateway。它是 Google 官方提供的一个反向代理,核心功能是提供 HTTP 接口,将接收到的 JSON 请求解码再编码为 pb 二进制格式,然后通过 gRPC 调用服务端,服务端返回了 pb 二进制的响应之后,它再次解码编码为 HTTP 响应体返回到客户端。
如下为实现的过程示例。
在 proto 文件中添加google.api.http
相关描述
执行 protoc 命令
1 | protoc \ |
至此,除了生成了上文所说的./api/user/profile-service.pb.go
,也生成了./api/user/profile-service.pb.gw.go
文件,该文件即可用于创建 HTTP 反向代理。
创建 HTTP 反向代理服务 ./grpc/grpc-gateway/http_proxy.go
。详细代码见下文。
运行效果
1 | # 运行 gRPC 服务 |
发起请求,执行 Login:
1 | POST /v1/login |
1 | 200 |
发起请求,执行 GetProfile:
1 | GET /v1/profile |
1 | 200 |
至此,支持 HTTP 请求的 gRPC Gateway 搭建成功,可以通过它访问 gRPC 服务了。
1 | // ./pb/user/profile-service.proto |
1 | // ./grpc/server/server.go |
1 | // ./grpc/grpc-client/client.go |
1 | // ./grpc/grpc-gateway/http_proxy.go |
开发时时而在 MAC、Linux 上,时而在 Windows 上,脚本执行环境无法统一是个大问题。所幸我们使用的 Git 中带有 MINGW32(或安装 CYGWIN),这里面已经有很多的非 Windows 平台命令了。
但是在使用过程中,还是有一些命令是缺失的,比如 zip
,此时我们可以自行下载他们(下载地址),下载之后,将可执行的 exe 文件和相关依赖文件,放到 MING32/CYGWIN 的 bin 目录中即可。
附:
创建用户映射 (例如 users.txt) ,将 SVN 用户和 Git 用户对应起来,为保留提交记录做准备。
进入 SVN 的目录,执行如下命令,注意其中 $1\@your-company.com
部分应当替换为你实际的映射关系。
1 | $ svn log --xml | grep -P "^<author" | sort -u | \ |
注意,生成的 users.txt 文件应当以 ANSI 编码保存,并且使用 CRLF 换行,您可以使用记事本另存为的功能选择该编码。
创建一个用于存放 Git 本地仓库的文件夹,如:E:/git
,进入该文件夹,执行 clone
命令。
1 | cd /e/git |
该步骤除了 clone 代码之外,还将导入所有的提交记录,执行较慢,需要耐心等待。中途出现找不到作者的情况时,可以修改 users.txt
补充该作者,重新再执行 git svn clone
命令。
详细命令参数,请参考 git svn help clone
。
执行完成之后,会在 E:/git
目录下创建以你 SVN path 最后一节为名称的子目录,可以进入该目录下的 .git
文件夹,执行 git log
命令查看提交记录。
这个步骤和常规地创建 GIT 仓库没有任何区别,直接添加远程仓库地址,push 即可:
1 | git remote add origin http://your/remote/git/path.git |
至此,代码及提交记录从 SVN 迁移到 GIT 的工作全部完成。
参考链接:
]]>本文为 QCon 2018 上海站主题演讲嘉宾、Heptio 资深工程师、著名 Go 语言专家 David Cheney 关于 Go 语言实践的英文分享。为方便大家阅读,在此由 Austin Luo 翻译为中文,在文中难以理解之处,也特别增加了译者的理解说明。翻译水平有限,如有偏颇之处,烦请联系我(uonun@163.com)更正。转载请注明出处,保留本节译者注。
目录
引言
接下来这两场我将给大家一些编写 Go 代码的最佳实践。
今天这是一个研讨会风格的演讲,我会摒弃那些绚丽的 PPT,而是使用您们可以直接带走的文档。
您可以在这里找到这个演讲最新的在线版本:
https://dave.cheney.net/practical-go/presentations/qcon-china.html
我们要谈论在一个编程语言中的最佳实践,那么我们首先应该明确什么是“最佳”。如果您们听了我昨天那场讲演的话,您一定看到了来自 Go 团队的 Russ Cox 讲的一句话:
软件工程,是您在编程过程中增加了工期或者开发人员之后发生的那些事。 — Russ Cox
Russ 是在阐述软件“编程”和软件“工程”之间的区别,前者是您写的程序,而后者是一个让更多的人长期使用的产品。软件工程师会来来去去地更换,团队也会成长或者萎缩,需求也会发生变化,新的特性也会增加,bug 也会被修复,这就是软件“工程”的本质。
我可能是现场最早的 Go 语言用户,但与其说我的主张来自我的资历,不如说我今天讲的是真实来自于 Go 语言本身的指导原则,那就是:
您可能已经注意到,我并没有提性能或者并发性。实际上有不少的语言执行效率比 Go 还要高,但它们一定没有 Go 这么简单。有些语言也以并发性为最高目标,但它们的可读性和生产率都不好。
性能和并发性都很重要,但它们不如简单性、可读性和生产率那么重要。
为什么我们要力求简单,为什么简单对 Go 语言编程如此重要?
我们有太多的时候感叹“这段代码我看不懂”,是吧?我们害怕修改一丁点代码,生怕这一点修改就导致其他您不懂的部分出问题,而您又没办法修复它。
这就是复杂性。复杂性把可读的程序变得不可读,复杂性终结了很多软件项目。
简单性是 Go 的最高目标。无论我们写什么程序,我们都应该能一致认为它应当简单。
Readability is essential for maintainability. — Mark Reinhold, JVM language summit 2018
可读性对于可维护性至关重要。
为什么 Go 代码的可读性如此重要?为什么我们应该力求可读性?
Programs must be written for people to read, and only incidentally for machines to execute. — Hal Abelson and Gerald Sussman, Structure and Interpretation of Computer Programs
程序应该是写来被人阅读的,而只是顺带可以被机器执行。
可阅读性对所有的程序——不仅仅是 Go 程序,都是如此之重要,是因为程序是人写的并且给其他人阅读的,事实上被机器所执行只是其次。
代码被阅读的次数,远远大于被编写的次数。一段小的代码,在它的整个生命周期,可能被阅读成百上千次。
The most important skill for a programmer is the ability to effectively communicate ideas. — Gastón Jorquera ^1
程序员最重要的技能是有效沟通想法的能力。
可读性是弄清楚一个程序是在做什么事的关键。如果您都不知道这个程序在做什么,您如何去维护这个程序?如果一个软件不可用被维护,那就可能被重写,并且这也可能是您公司最后一次在 GO 上面投入了。
如果您仅仅是为自己个人写一个程序,可能这个程序是一次性的,或者使用这个程序的人也只有您一个,那您想怎样写就怎样写。但如果是多人合作贡献的程序,或者因为它解决人们的需求、满足某些特性、运行它的环境会变化,而在一个很长的时间内被很多人使用,那么程序的可维护性则必须成为目标。
编写可维护的程序的第一步,那就是确保代码是可读的。
Design is the art of arranging code to work today, and be changeable forever. — Sandi Metz
设计是一门艺术,要求编写的代码当前可用,并且以后仍能被改动。
我想重点阐述的最后一个基本原则是生产率。开发者的生产率是一个复杂的话题,但归结起来就是:为了有效的工作,您因为一些工具、外部代码库而浪费了多少时间。Go 程序员应该感受得到,他们在工作中可以从很多东西中受益了。(Austin Luo:言下之意是,Go 的工具集和基础库完备,很多东西触手可得。)
有一个笑话是说,Go 是在 C++ 程序编译过程中被设计出来的。快速的编译是 Go 语言用以吸引新开发者的关键特性。编译速度仍然是一个不变的战场,很公平地说,其他语言需要几分钟才能编译,而 Go 只需要几秒即可完成。这有助于 Go 开发者拥有动态语言开发者一样的高效,但却不会面临那些动态语言本身可靠性的问题。
Go 开发者意识到代码是写来被阅读的,并且把阅读放在编写之上。Go 致力于从工具集、习惯等方面强制要求代码必须编写为一种特定样式,这消除了学习项目特定术语的障碍,同时也可以仅仅从“看起来”不正确即可帮助开发者发现潜在的错误。
Go 开发者不会整日去调试那些莫名其妙的编译错误。他们也不会整日浪费时间在复杂的构建脚本或将代码部署到生产中这事上。更重要的是他们不会花时间在尝试搞懂同事们写的代码是什么意思这事上。
当 Go 语言团队在谈论一个语言必须扩展时,他们谈论的就是生产率。
我们要讨论的第一个议题是标识符。标识符是一个名称的描述词,这个名称可以是一个变量的名称、一个函数的名称、一个方法的名称、一个类型的名称或者一个包的名称等等。
Poor naming is symptomatic of poor design. — Dave Cheney
拙劣的名称是拙劣的设计的表征。
鉴于 Go 的语法限制,我们为程序中的事物选择的名称对我们程序的可读性产生了过大的影响。良好的可读性是评判代码质量的关键,因此选择好名称对于 Go 代码的可读性至关重要。
Obvious code is important. What you can do in one line you should do in three. — Ukiah Smith
代码要明确这很重要,您在一行中能做的事,应该拆到三行里做。
Go 不是专注于将代码精巧优化为一行的那种语言,Go 也不是致力于将代码精炼到最小行数的语言。我们并不追求源码在磁盘上占用的空间更少,也不关心录入代码需要多长时间。
Good naming is like a good joke. If you have to explain it, it’s not funny. — Dave Cheney
好的名称就如同一个好的笑话,如果您需要去解释它,那它就不搞笑了。
这个清晰度的关键就是我们为 Go 程序选择的标识符。让我们来看看一个好的名称应当具备什么吧:
接下来让我们深入地讨论一下。
有时候人们批评 Go 风格推荐短变量名。正如 Rob Pike 所说,“Go 开发者想要的是合适长度的标识符”。^1
Andrew Gerrand 建议通过使用更长的标识符向读者暗示它们具有更高的重要性。
The greater the distance between a name’s declaration and its uses, the longer the name should be. — Andrew Gerrand ^2
标识符的声明和使用间隔越远,名称的长度就应当越长。
据此,我们可以归纳一些指导意见:
让我们来看一个示例:
1 | type Person struct { |
在这个示例中,范围变量p
在定义之后只在接下来的一行使用。p
在整页源码和函数执行过程中都只生存一小段时间。对p
感兴趣的读者只需要查看两行代码即可。
与之形成对比的是,变量people
在函数参数中定义,并且存在了 7 行,同理的还有sum
和count
,这他们使用了更长的名称,读者必须关注更广泛的代码行。
我也可以使用s
而不是sum
,用c
(或n
)而不是count
,但这会将整个程序中的变量都聚集在相同的重要性上。我也可以使用p
而不是people
,但是这样又有一个问题,那就是for ... range
循环中的变量又用什么?单数的 person
看起来也很奇怪,生存时间极短命名却比导出它的那个值更长。
Austin Luo:这里说的是,若数组
people
用变量名p
,那么从数组中获取的每一个元素取名就成了问题,比如用person
,即使使用person
看起来也很奇怪,一方面是单数,一方面person
的生存周期只有两行(很短),命名比生存周期更长的p
(people
)还长了。
小窍门:跟使用空行在文档中分段一样,使用空行将函数执行过程分段。在函数
AverageAge
中有按顺序的三个操作。第一个是先决条件,检查当people
为空时我们不会除零,第二个是累加总和和计数,最后一个是计算平均数。
绝大多数的命名建议都是根据上下文的,意识到这一点很重要。我喜欢称之为原则,而不是规则。
i
和index
这两个标识符有什么不同?我们很难确切地说其中一个比另一个好,比如:
1 | for index := 0; index < len(s); index++ { |
上述代码的可读性,基本上都会认为比下面这段要强:
1 | for i := 0; i < len(s); i++ { |
但我表示不赞同。因为无论是i
还是index
,都是限定于for
循环体的,更冗长的命名,并没有让我们更容易地理解这段代码。
话说回来,下面两段代码那一段可读性更强呢?
1 | func (s *SNMP) Fetch(oid []int, index int) (int, error) |
或者
1 | func (s *SNMP) Fetch(o []int, i int) (int, error) |
在这个示例中,oid
是SNMP
对象 ID 的缩写,因此将其略写为 o
意味着开发者必须将他们在文档中看到的常规符号转换理解为代码中更短的符号。同样地,将index
简略为i
,减少了其作为SNMP
消息的索引的含义。
小窍门:在参数声明中不要混用长、短不同的命名风格。
正如您给宠物取名一样,您会给狗取名“汪汪”,给猫取名为“咪咪”,但不会取名为“汪汪狗”、“咪咪猫”。出于同样的原因,您也不应在变量名称中包含其类型的名称。
变量命名应该体现它的内容,而不是类型。我们来看下面这个例子:
1 | var usersMap map[string]*User |
这样的命名有什么好处呢?我们能知道它是个 map,并且它与*User
类型有关,这可能还不错。但是 Go 作为一种静态类型语言,它并不会允许我们在需要标量变量的地方意外地使用到这个变量,因此Map
后缀实际上是多余的。
现在我们来看像下面这样定义变量又是什么情况:
1 | var ( |
现在这个范围内我们有了三个 map 类型的变量了:usersMap
,companiesMap
,以及 productsMap
,所有这些都从字符串映射到了不同的类型。我们知道它们都是 map,我们也知道它们的 map 声明会阻止我们使用一个代替另一个——如果我们尝试在需要map[string]*User
的地方使用companiesMap
,编译器将抛出错误。在这种情况下,很明显Map
后缀不会提高代码的清晰度,它只是编程时需要键入的冗余内容。(Austin Luo:陈旧的思维方式)
我的建议是,避免给变量加上与类型相关的任何后缀。
小窍门:如果
users
不能描述得足够清楚,那usersMap
也一定不能。
这个建议也适用于函数参数,比如:
1 | type Config struct { |
将*Config
参数命名为config
是多余的,我们知道它是个*Config
,函数签名上写得很清楚。
在这种情况建议考虑conf
或者c
——如果生命周期足够短的话。
如果在一个范围内有超过一个*Config
,那命名为conf1
、conf2
的描述性就比original
、updated
更差,而且后者比前者更不容易出错。
NOTE:不要让包名占用了更适合变量的名称。
导入的标识符是会包含它所属包的名称的。
例如我们很清楚context.Context
是包context
中的类型Context
。这就导致我们在我们自己的包里,再也无法使用context
作为变量或类型名了。func WriteLog(context context.Context, message string)
这无法编译。这也是为什么我们通常将context.Context
类型的变量命名为ctx
的原因,如:func WriteLog(ctx context.Context, message string)
一个好名字的另一个特点是它应该是可预测的。阅读者应该可以在第一次看到的时候就能够理解它如何使用。如果遇到一个约定俗称的名字,他们应该能够认为和上次看到这个名字一样,一直以来它都没有改变意义。
例如,如果您要传递一个数据库句柄,请确保每次的参数命名都是一样的。与其使用d *sql.DB
,dbase *sql.DB
,DB *sql.DB
和database *sql.DB
,还不如都统一为:
1 | db *sql.DB |
这样做可以增进熟悉度:如果您看到db
,那么您就知道那是个*sql.DB
,并且已经在本地定义或者由调用者提供了。
对于方法接收者也类似,在类型的每个方法中使用相同的接收者名称,这样可以让阅读者在跨方法阅读和理解时更容易主观推断。
Austin Luo:“接收者”是一种特殊类型的参数。^2
比如func (b *Buffer) Read(p []byte) (n int, err error)
,它通常只用一到两个字母来表示,但在不同的方法中仍然应当保持一致。
注意:Go 中对接收者的短命名规则惯例与目前提供的建议不一致。这只是早期做出的选择之一,并且已经成为首选的风格,就像使用
CamelCase
而不是snake_case
一样。
小窍门:Go 的命名风格规定接收器具有单个字母名称或其派生类型的首字母缩略词。有时您可能会发现接收器的名称有时会与方法中参数的名称冲突,在这种情况下,请考虑使参数名称稍长,并且仍然不要忘记一致地使用这个新名称。
最后,某些单字母变量传统上与循环和计数有关。例如,i
,j
,和k
通常是简单的for
循环变量。n
通常与计数器或累加器有关。 v
通常是某个值的简写,k
通常用于映射的键,s
通常用作string
类型参数的简写。
与上面db
的例子一样,程序员期望i
是循环变量。如果您保证i
始终是一个循环变量——而不是在for
循环之外的情况下使用,那么当读者遇到一个名为i
或者j
的变量时,他们就知道当前还在循环中。
小窍门:如果您发现在嵌套循环中您都使用完
i
,j
,k
了,那么很显然这已经到了将函数拆得更小的时候了。
Go 中至少有 6 种声明变量的方法(Austin Luo:作者说了 6 种,但只列了 5 种)
var x int = 1
var x = 1
var x int; x = 1
var x = int(1)
x := 1
我敢肯定还有更多我没想到的。这是 Go 的设计师认识到可能是一个错误的地方,但现在改变它为时已晚。有这么多不同的方式来声明变量,那么我们如何避免每个 Go 程序员选择自己个性独特的声明风格呢?
我想展示一些在我自己的程序里声明变量的建议。这是我尽可能使用的风格。
只声明,不初始化时,使用var
。在声明之后,将会显式地初始化时,使用var
关键字。
1 | var players int // 0 |
var
关键字表明这个变量被有意地声明为该类型的零值。这也与在包级别声明变量时使用var
而不是短声明语法(Austin Luo::=
)的要求一致——尽管我稍后会说您根本不应该使用包级变量。
既声明,也初始化时,使用:=
。当同时要声明和初始化变量时,换言之我们不让变量隐式地被初始化为零值时,我建议使用短声明语法的形式。这使得读者清楚地知道:=
左侧的变量是有意被初始化的。
为解释原因,我们回头再看看上面的例子,但这一次每个变量都被有意初始化了:
1 | var players int = 0 |
第一个和第三个示例中,因为 Go 没有从一种类型到另一种类型的自动转换,赋值运算符左侧和右侧的类型必定是一致的。编译器可以从右侧的类型推断出左侧所声明变量的类型。对于这个示例可以更简洁地写成这样:
1 | var players = 0 |
由于0
是players
的零值,因此为players
显式地初始化为0
就显得多余了。所以为了更清晰地表明我们使用了零值,应该写成这样:
1 | var players int |
那第二条语句呢?我们不能忽视类型写成:
1 | var things = nil |
因为nil
根本就没有类型^2。相反,我们有一个选择,我们是否希望切片的零值?
1 | var things []Thing |
或者我们是否希望创建一个没有元素的切片?
1 | var things = make([]Thing, 0) |
如果我们想要的是后者,这不是个切片类型的零值,那么我们应该使用短声明语法让阅读者很清楚地明白我们的选择:
1 | things := make([]Thing, 0) |
这告诉了读者我们显式地初始化了things
。
再来看看第三个声明:
1 | var thing = new(Thing) |
这既显式地初始化了变量,也引入了 Go 程序员不喜欢而且很不常用的new
关键字。如果我们遵循短命名语法的建议,那么这句将变成:
1 | thing := new(Thing) |
这很清楚地表明,thing
被显式地初始化为new(Thing)
的结果——一个指向Thing
的指针——但仍然保留了我们不常用的new
。我们可以通过使用紧凑结构初始化的形式来解决这个问题,
1 | thing := &Thing{} |
这和new(Thing)
做了同样的事——也因此很多 Go 程序员对这种重复感觉不安。不过,这一句仍然意味着我们为thing
明确地初始化了一个Thing{}
的指针——一个Thing
的零值。
在这里,我们应该意识到,thing
被初始化为了零值,并且将它的指针地址传递给了json.Unmarshall
:
1 | var thing Thing |
注意:当然,对于任何经验法则都有例外。比如,有些变量之间很相关,那么与其写成这样:
var min int
max := 1000
不如写成这样更具可读性:min, max := 0, 1000
综上所述:
var
。:=
。小窍门:使得机巧的声明更加显而易见。
当某件事本身很复杂时,应当使它看起来就复杂。var length uint32 = 0x80
这里的length
可能和一个需要有特定数字类型的库一起使用,并且length
被很明确地指定为uint32
类型而不只是短声明形式:length := uint32(0x80)
在第一个例子中,我故意违反了使用var
声明形式和显式初始化程序的规则。这个和我惯常形式不同的决定,可以让读者意识到这里需要注意。
我谈到了软件工程的目标,即生成可读,可维护的代码。而您的大部分职业生涯参与的项目可能您都不是唯一的作者。在这种情况下我的建议是遵守团队的风格。
在文件中间改变编码风格是不适合的。同样,即使您不喜欢,可维护性也比您的个人喜好有价值得多。我的原则是:如果满足gofmt
,那么通常就不值得再进行代码风格审查了。
小窍门:如果您要横跨整个代码库进行重命名,那么不要在其中混入其他的修改。如果其他人正在使用 git bisect,他们一定不愿意从几千行代码的重命名中“跋山涉水”地去寻找您别的修改。
在我们进行下一个更大的主题之前,我想先花几分钟说说注释的事。
Good code has lots of comments, bad code requires lots of comments. — Dave Thomas and Andrew Hunt, The Pragmatic Programmer
好的代码中附带有大量的注释,坏的代码缺少大量的注释。
代码注释对 Go 程序的可读性极为重要。一个注释应该做到如下三个方面的至少一个:
第一种形式适合公开的符号:
1 | // Open opens the named file for reading. |
第二种形式适合方法内的注释:
1 | // queue all dependant actions |
第三种形式,“为什么这么做”,这是独一无二的,无法被前两种取代,也无法取代前两种。第三种形式的注释用于解释更多的状况,而这些状况往往难以脱离上下文,否则将没有意义,这些注释就是用来阐述上下文的。
1 | return &v2.Cluster_CommonLbConfig{ |
在这个示例中,很难立即弄清楚把HealthyPanicThreshold
的百分比设置为零会产生什么影响。注释就用来明确将值设置为0
实际上是禁用了panic
阈值的这种行为。
我之前谈过,变量或常量的名称应描述其目的。向变量或常量添加注释时,应该描述变量的内容,而不是定义它的目的。
1 | const randomNumber = 6 // determined from an unbiased die |
这个示例的注释描述了“为什么”randomNumber
被赋值为 6,也说明了 6 这个值是从何而来的。但它没有描述randomNumber
会被用到什么地方。下面是更多的例子:
1 | const ( |
如在 RFC 7231 的第 6.2.1 节中定义的那样,在 HTTP 语境中 100 被当做StatusContinue
。
小窍门:对于那些没有初始值的变量,注释应当描述谁将负责初始化它们
// sizeCalculationDisabled indicates whether it is safe
// to calculate Types’ widths and alignments. See dowidth.
var sizeCalculationDisabled bool
这里,通过注释让读者清楚函数dowidth
在负责维护sizeCalculationDisabled
的状态。
小窍门:隐藏一目了然的东西
Kate Gregory 提到一点^3,有时一个好的命名,可以省略不必要的注释。
// registry of SQL drivers
var registry = make(map[string]*sql.Driver)
注释是源码作者加的,因为registry
没能解释清楚定义它的目的——它是个注册表,但是什么的注册表?
通过重命名变量名为sqlDrivers
,现在我们很清楚这个变量的目的是存储 SQL 驱动。
var sqlDrivers = make(map[string]*sql.Driver)
现在注释已经多余了,可以移除。
因为 godoc 将作为您的包的文档,您应该总是为每个公开的符号写好注释说明——包括变量、常量、函数和方法——所有定义在您包内的公开符号。
这里是 Go 风格指南的两条规则:
1 | package ioutil |
对这个规则有一个例外:您不需要为实现接口的方法进行文档说明,特别是不要这样:
1 | // Read implements the io.Reader interface |
这个注释等于说明都没说,它没有告诉您这个方法做了什么,实际上更糟的是,它让您去找别的地方的文档。在这种情况我建议将注释整个去掉。
这里有一个来自io
这个包的示例:
1 | // LimitReader returns a Reader that reads from r |
请注意,LimitedReader
的声明紧接在使用它的函数之后,并且LimitedReader.Read
又紧接着定义在LimitedReader
之后,即便LimitedReader.Read
本身没有文档注释,那和很清楚它是io.Reader
的一种实现。
小窍门:在您编写函数之前先写描述这个函数的注释,如果您发现注释很难写,那就表明您正准备写的这段代码一定难以理解。
Don’t comment bad code — rewrite it — Brian Kernighan
不要为坏的代码写注释——重写它
为粗制滥造的代码片段着重写注释是不够的,如果您遭遇到一段这样的注释,您应该发起一个问题(issue)从而记得后续重构它。技术债务只要不是过多就没有关系。
在标准库的惯例是,批注一个 TODO 风格的注释,说明是谁发现了坏代码。
1 | // TODO(dfc) this is O(N^2), find a faster way to do this. |
注释中的姓名并不意味着承诺去修复问题,但在解决问题时,他可能是最合适的人选。其他批注内容一般还有日期或者问题编号。
Good code is its own best documentation. As you’re about to add a comment, ask yourself, ‘How can I improve the code so that this comment isn’t needed?’ Improve the code and then document it to make it even clearer. — Steve McConnell
好的代码即为最好的文档。在您准备添加一行注释时,问自己,“我要如何改进这段代码从而使它不需要注释?”优化代码,然后注释它使之更清晰。
函数应该只做一件事。如果您发现一段代码因为与函数的其他部分不相关因而需要注释时,考虑将这段代码拆分为独立的函数。
除了更容易理解之外,较小的函数更容易单独测试,现在您将不相关的代码隔离拆分到不同的函数中,估计只有函数名才是唯一需要的文档注释了。
Write shy code - modules that don’t reveal anything unnecessary to other modules and that don’t rely on other modules’ implementations. — Dave Thomas
编写内敛的代码——模块不向外部透露任何不必要的信息,也不依赖外部模块的实现。
每个 Go Package 事实上自身都是一个小的 Go 程序。正如函数或方法的实现对其调用者不重要一样,构成公开 API 的函数、方法、类型的实现——其行为——对调用者也不重要。
一个好的 Go Package 应该致力于较低的源码级耦合,这样,随着项目的增长,对一个包的更改不会级联影响其他代码库。那些“世界末日”似的重构让代码的更新优化变得极其困难,也让工作在这样的代码库上的开发者的生产效率极度地受限。
在这一节中我会来谈一谈包的设计,包括包的命名、类型的命名,以及编写方法和函数的一些小技巧。
编写一个好的 Go 程序包从命名开始。好好思考您的软件包的名字,仅用一个词来描述它是什么。(Austin Luo:就如同“电梯游说”一样,您只能在极短的时间极少的话语的情况下描述您要表达的东西。)
正如我在上一节讲变量命名一样,包的名称也同样非常重要。以我的经验来看,我们应当思考的不是“我在这个包里应当放哪些类型”,而是“包提供的服务都应该做什么”。通常这个问题的答案不应该是“这个包提供了某某类型”,而是“这个包让您可以进行 HTTP 通信”。
小窍门:以包“提供”的东西来命名,而不是以“包含”的东西来命名。
在您的项目里,每个包名都应该是唯一的。这个建议很容易理解,也很容易遵守。包的命名应该源于它的目的——如果您发现有两个包需要取相同的名字,那可能是下面两种情况:
base
、common
、util
一个低劣的名称通常是“utility”。这些通常是随着时间推移沉淀下来的通用帮助类或者工具代码。这种包里通常混合有各种不相关的功能,并且因为其通用性,以至于难以准确地描述这个包都提供了些什么。这通常导致包名来源于这个包“包含”的东西——一堆工具。
像utils
或helpers
这样的名称,通常在一些大型项目中找到,这些项目中已经开发了较深的层次结构,并且希望在共享这些帮助类函数时,避免循环导入。虽然打散这些工具函数到新的包也能打破循环导入,但是因为其本身是源于项目的设计问题,包名称并未反映其目的,因此打散它也仅仅只起到了打破导入循环的作用而已。
针对优化utils
或helpers
这种包名,我的建议是分析它们是在哪里被使用,并且是否有可能把相关函数挪到调用者所在的包。即便这可能导致一些重复的帮助类代码,但这也比在两个包之间引入一个导入依赖来的更好。
[A little] duplication is far cheaper than the wrong abstraction. — Sandy Metz
(一点点的)重复远比错误的抽象更值得。
在多个地方使用工具类方法的情况下,优先选择多个包(的设计),每个包专注于一个单独的方面,而不是整个包。(Austin Luo:Separation Of Concerns。)
小窍门:使用复数形式命名工具包。比如
strings
是字符串的处理工具。
像base
或common
这样的名称,常用于一个通用的功能被分为两个或多个实现的情况,或者一些用于客户端、服务端程序,并且被重构为单独通用类型的包。我认为解决这个问题的方法是减少包的数量,把客户端、服务端的通用代码合并到一个统一包里。
具体例子,net/http
包总并没有client
和server
这两个子包,取而代之的是只有两个名为client.go
和server.go
的文件,每个文件处理各自的类型,以及一个transport.go
文件用于公共消息传输的代码。
小窍门:标识符的名称包括其包的名称
牢记标识符的名称包含其所在包的名称,这一点很重要
net/http
包中的Get
函数,在其他包引用时变成了http.Get
。strings
包中的Reader
类型,在其他包导入后变成了strings.Reader
。net
包中的Error
接口很明确地与网络错误相关。
正如 Go 并不使用异常来控制执行流程,也不需要深度缩进代码只为了在顶层结构添加一个try...catch...
块。与把成功执行的路径向右侧一层一层深度嵌套相比,Go 风格的代码是随着函数的执行,成功路径往屏幕下方移动。我的朋友 Mat Ryer 称这种方式为“视线”编码。^4
这是通过“保护条款”来实现的(Austin Luo: 类似我们常说的防御式编程):条件代码块在进入函数时立即断言前置条件。这里是bytes
包里的一个示例:
1 | func (b *Buffer) UnreadRune() error { |
一旦进入UnreadRune
,就会检查b.lastRead
,如果之前的操作不是ReadRune
就会立即返回错误。从这里开始,函数执行下去的其余部分,我们就能明确肯定b.lastRead
比opInvalid
大了。
与没有使用“保护条款”的相同功能代码对比看看:
1 | func (b *Buffer) UnreadRune() error { |
最通常的、成功的情况,被缩进到了第一个if
条件中了。并且成功的退出条件 return nil
,需要非常小心地与闭口括号(})对应。接下来,最后一行代码返回了一个错误,并且我们需要回退跟踪到函数的开口括号({)才知道执行控制流什么时候到达了这里。
对于读者和维护程序员来说,这更容易出错,因此 Go 更喜欢使用“保护条款”并尽早返回错误。
假设没有明确提供显示初始化器,每个变量声明之后都会被自动初始化为零内存对应的值,这就是零值。零值与其类型有关:数值类型为0
,指针为nil
,切片、映射、管道等也同样(为nil
)。
始终将值设置为已知默认值,对于程序的安全性和正确性非常重要,并且可以使 Go 程序更简单,更紧凑。这就是 Go 程序员在说“给您的结构一个有用的零值”时所表达的意思。
我们来看sync.Mutex
这类型。它有两个未导出的整数型字段,表示互斥锁的内部状态。由于零值,无论何时一个sync.Mutex
类型变量被声明后,这些字段都将被设置为0
。sync.Mutex
类被故意地编码为这样,使得它无需被显式初始化即可使得零值有意义。
1 | type MyInt struct { |
Austin Luo:原文为“useful”,我在此译为“有意义”而不是“有用”,意在强调其零值是符合业务的、符合逻辑的,并且也是初始的、默认的,而不是“不用管它,让它为零好了”。
这与变量的命名也息息相关,比如:isCacheEnabled bool
// 缓存是否被启用isCacheDisabled bool
// 缓存是否被禁用
对于上述两个变量,看起来都差不多,随意定义其中一个即可,唯一的差别只是一个表示启用一个表示禁用而已。但是结合考虑“业务要求默认启用缓存”和“bool 的零值为 false”,那么显然我们应该定义isCacheDisabled bool
而不是前者。一方面,调用者不显式赋值时默认零值为false
,另一方面值为false
时表达的含义与业务要求默认启用缓存一致。
这才使得零值真正地有意义,正如示例中注释的那行i.mu
一样,不显示初始化其代表的是默认锁是可用的。
另一个有意义零值的类型示例是bytes.Buffer
。您可以无需显式初始化地声明bytes.Buffer
然后立即开始向它写入数据。
1 | func main() { |
切片的一个有用性质是它的零值为nil
,我们只需要去看看切片的运行时定义即可理解它的合理性:
1 | type slice struct { |
此结构的零值将暗示len
和cap
的值为0
,并且指向内存的指针array
,保存切片背后数组的内容,其值也为nil
。这意味着您不需要显式make
切片,您只需声明它即可。
1 | func main() { |
NOTE:
var s []string
看起来和上面被注释掉的两行很像,但又不完全相同。要判断值为nil
的切片和长度为零的切片的区别是可以办到的,下面的代码将输出false
:
1 func main() {
var s1 = []string{}var s2 []stringfmt.Println(reflect.DeepEqual(s1, s2))
}
1 | 一个意外但是有用的惊喜是未初始化的指针——`nil`指针,您可以在`nil`值的类型上调用方法,这可以简单地用于提供默认值。 |
编写可维护的程序的一个关键方面是松耦合——更改一个包,应该把对没有直接依赖它的包的影响降到最低。
在 Go 中有两种很好的方法可以实现松散耦合:
在 Go 中,我们可以在函数或方法范围内声明变量,也可以在包的范围内声明变量。当变量是公开的,标识符首字母为大写,那么其范围实际上是整个程序——任何包都可以在任何时候观察到它的类型和存储的内容。
可变的全局状态在程序的独立部分之间引入了紧耦合,因为全局变量对于程序中的每个函数都是隐匿的参数!如果全局变量的类型变化了,那么任何依赖该变量的函数将会被打破。程序其他任何部分对变量值的修改,都将导致依赖该变量状态的函数被打破。
Austin Luo:全局变量对每个函数都是可见的,但开发者可能意识不到全局变量的存在(即隐匿的参数),即使意识到并使用了全局变量,也可能意识不到该变量可能在别处被修改,导致全局变量的使用不可靠,依赖该变量状态(值)的函数被打破。
如果您想减少全局变量带来的耦合,那么:
让我们来看看多个包合并在一起组成项目的情况。通常这应该是一个单独的 git 仓库,但在将来, Go 开发者将交替使用 module
和 project
。
和包一样,每个项目也应该有一个清晰的目的。如果您的项目是个库,那么它应该只提供一个东西,比如 XML 解析,或者日志记录。您应该避免将多个不同的目的混杂在同一个项目中,这有助于避免common
库的出现。
小窍门:根据我的经验,
common
库与其最大的消费者(使用者)紧密相连,这使得在不锁定步骤的情况下单独升级common
或者消费者以进行升级或者修复变得很困难,从而带来很多不相关的更改和 API 破坏。
如果您的项目是一个应用程序,比如您的 Web 应用,Kubernetes 控制器等等,那么在您的项目中可能有一个或多个 main
包。比如,我维护的那个 Kubernetes 控制器里有一个单独的 cmd/contour
包,用来提供到 Kubernetes 集群的服务部署,以及用于调试的客户端。
对于从其他语言过渡到 Go 的程序员来说,我倾向于在代码审查中提到的一件事是,他们倾向于过度使用包。
Go 没有提供建立可见性的详细方法:比如 Java 的 public
、protected
、private
和隐式 default
访问修饰符,也没有相当于 C++ 的friend
类的概念。
在 Go 中我们只有两种访问修饰符,公开和私有,这由标识符首字母的大小写决定。如果标识符是公开的,命名首字母就是大写的,则这个标识符可以被其他任何 Go 包引用。
注意:您可能听到有人说导出和非导出,那是公开和私有的同义词。
鉴于对包里的符号可见性控制手段的有限,Go 程序员要怎么做才能避免创建过于复杂的包层次结构呢?
小窍门:除
cmd/
和internal/
之外,每个包都应该包含一些源代码。
我反复建议的是偏向更少、更大的包。您的默认选项并不是创建新的包,那将导致为了创建宽而浅的 API 平面时您不得不公开太多的类型。
接下来的几节让我们更详细地探讨这些建议。
小窍门:来自 Java?
如果您有开发 Java 或 C# 的背景,考虑这样的经验规则:一个 Java 包等效于一个独立的.go
源文件;一个 Go 包等效于整个 Maven 模块或 .NET 程序集。
如果您根据包提供给调用者的功能来整理包,那么在 Go 包里整理源文件是不是也应该按相同的方式?您如何知道什么时候您应该将一个 .go
文件拆分成多个文件?您如何知道是不是过分拆分,而应当考虑整合多个 .go
文件?
这里是我用到的一些经验规则:
.go
文件开始,并且使用与包相同的名字。比如包 http
的第一个文件应该是 http.go
,并且放到名为 http
的文件夹中。Request
和 Response
类型拆分到 message.go
中,将 Client
类型拆分到 client.go
中,将 Server
类型拆分到 server.go
中。messages.go
可能负责网络相关的 HTTP 请求和响应编组,http.go
可能包含低级网络处理逻辑,client.go
和 server.go
实现 HTTP 请求创建或路由的业务逻辑,等等。小窍门:源文件名应当考虑名词。
注意:Go 编译器并行编译各个包。在包中,Go 编译器并行地编译各个函数(方法在 Go 中只是花哨的函数)。修改包源码中代码的排列分布不影响编译时间。
Go 工具集允许您在两处编写包的测试。假设您的包名是 http2
,您可以使用 package http2
声明并编写一个 http2_test.go
文件,这样做将会把 http2_test.go
中的代码当成 http2 包的一部分编译进去。这通常称为内部测试。
Go 工具集同样支持一个以 test 结尾的特定声明的包,例如 package http_test
,即使这些测试代码不会被视为正式代码一样编译到正式的包里,并且他们有自己独立的包名,也允许您的测试文件和源码文件一样放置在一起。这允许让您像在外部另外一个包里调用一样编写测试用例,这我们称之为外部测试。
在编写单元测试时我推荐使用内部测试。这让您可以直接测试每个函数或方法,避免外部测试的繁文缛节。
但是,您应该把 Example
测试函数放到外部测试中。这确保了在 godoc 中查看时,示例具有适当的包前缀,并且可以轻松地进行复制粘贴。
小窍门:避免复杂的包层次结构,克制分类的渴望
只有一个例外,这我们将在后面详述。对于 Go 工具集来讲,Go 包的层次结构是没有意义的。例如,net/http
并不是net
的子或子包。
如果您创建了不包含任何.go
文件的中间目录,则不适用此建议。
internal
包收敛公开的 API 表面如果您的项目包含多个包,则可能有一些导出的函数——这些函数旨在供项目中的其他包使用,却又不打算成为项目的公共 API 的一部分。如果有这样的情况,则 go 工具集会识别一个特殊的文件夹名——非包名—— internal/
,这用于放置那些对当前项目公开,但对其他项目私有的代码。
要创建这样的包,把代码放置于名为 internal/
的目录或子目录即可。 go 命令发现导入的包中包含 internal
路径,它就会校验执行导入的包是否位于以 internal
的父目录为根的目录树中。
例如,包 .../a/b/c/internal/d/e/f
只能被根目录树 .../a/b/c
中的代码导入,不能被 .../a/b/g
或者其他任何库中的代码导入。^5
main
函数和 main
包应当只做尽可能少的事情,因为 main.main
实际上是一个单例,整个应用程序都只允许一个 main
函数存在,包括单元测试。
由于 main.main
是一个单例,因此 main.main
的调用中有很多假定,而这些假定又只在 main.main
或 main.init
期间调用,并且只调用一次。这导致很难为 main.main
中的代码编写单元测试,因此您的目标应该是将您的业务逻辑从主函数中移出,最好是压根从主程序包中移出。
Austin Luo:这里主要是讲,由于整个程序(包括单元测试在内)只允许存在一个
main.main
,因此在main.main
中编写过多的代码将导致这些代码很难被测试覆盖,因此应当将这些代码从main.main
中——甚至从main
包中——独立出来,以便能够写单元测试进行测试。(文中的“假定”是针对测试而言,“假定” main 中的代码可以正常运行。)
小窍门:
main
应当解析标识,打开数据库连接,初始化日志模块等等,然后将具体的执行交给其他高级对象。
今天给出的最后一个设计建议是我认为最重要的一个。
到此为止我给出的所有建议,也仅仅是建议。这是我写 Go 程序时遵守的方式,但也并没有强制推行到代码评审中。
但是,在审查 API 时,我就不太宽容了。因为之前我所说的一切都可以在不破坏向后兼容性的情况下得到修正,他们大多只是实施细节而已。
但说到包的开放 API,在初始设计中投入大量精力是值得的,因为后续的更改将是破坏性的,特别是对于已经使用 API 的人来说。
APIs should be easy to use and hard to misuse. — Josh Bloch ^3
API 应当易用并且难以被误用
如果您从这个演讲中获得任何收益,那就应该是 Josh Bloch 的这个建议。如果 API 很难用于简单的事情,那么 API 的每次调用都会很复杂。当 API 的实际调用很复杂时,它将不那么明显,更容易被忽视。
一个看起来很简单,但实际很难正确使用的 API 的例子,就是具有两个及以上的相同类型参数的情况。让我们来对比如下两个函数签名:
1 | func Max(a, b int) int |
这两个函数有什么不同?很显然一个是返回两个数的最大数,另一个是复制文件,但这都不是重点。
1 | Max(8, 10) // 10 |
Max
是可交换的,参数的顺序无关紧要,8 和 10 比较无论如何都是 10 更大,不论是 8 与 10 比较,还是 10 与 8 比较。
但是,对于 CopyFile 就不具有这样的特性了:
1 | CopyFile("/tmp/backup", "presentation.md") |
哪条语句将 presentation.md 复制了一份,哪条语句又是用上周的版本覆盖了 presentation.md ?没有文档说明,您很难分辨。代码评审者在没有文档时也对您参数传入的顺序是否正确不得而知。
一个可行的解决方案是,引入一个帮助类,用来正确地调用 CopyFile
:
1 | type Source string |
这样 CopyFile
就总是可以被正确地调用——这也可以通过单元测试确定,也可以被设置为私有,进一步降低了误用的可能性。
小窍门:具有多个相同类型参数的 API 很难被正确使用。
几年前我做过一次关于使用功能选项^7使 API 在默认用例时更易用的报告^6。
本演讲的主旨是您应该为常见用例设计 API。另一方面,您的 API 不应要求调用者提供那些他们不关心的参数。
nil
作为参数我讲述本章开宗明义时建议您不要强迫 API 的调用者在他们不关心这些参数意味着什么的情况下为您提供那些参数。当我说针对默认用例的设计 API 时,这就是我的意思。
这里有个来自 net/http
包的示例:
1 | package http |
ListenAndServe
有两个参数,一个 TCP 地址用来监听传入连接,一个 http.Handler
用来处理传入的 HTTP 请求。Serve
允许第二个参数为 nil
,并且注意,调用者通常都会传入 nil
用来表示他们希望使用 http.DefaultServeMux
作为隐式参数。
现在Serve
的调用者就有两个方式来做同样的事情:
1 | http.ListenAndServe("0.0.0.0:8080", nil) |
两个方式都做完全一样的事情。
这种 nil
的行为是病毒式的。在 http
包中同样有个 http.Serve
帮助类,您可以合理地想象 ListenAndServe
是这样建立的:
1 | func ListenAndServe(addr string, handler Handler) error { |
因为ListenAndServe
允许调用者为第二个参数传递nil
,所以http.Serve
也支持这种行为。事实上,http.Serve
是“当 handler
为 nil
,则使用 DefaultServeMux
”这个逻辑的一个实现。允许其中一个参数传入 nil
可能导致调用者以为他们可以给两个参数都传入 nil
(Austin Luo:调用者可能想,既然第二个参数有默认实现,那第一个参数可能也有),但像这样调用:
1 | http.Serve(nil, nil) |
将导致一个丑陋的 panic 。
小窍门:在函数签名中不要混用可为
nil
和不可为nil
的参数。
http.ListenAndServe
的作者尝试在常规状况时让 API 的使用者更轻松,但可能反而导致这个包难于被安全地使用。
显示地指定 DefaultServeMux
或隐式地指定 nil
,并没有在代码行数上带来不同。
1 | const root = http.Dir("/htdocs") |
相较于
1 | const root = http.Dir("/htdocs") |
并且,仅仅为了节省一行代码,这样的混乱是值得的吗?
1 | const root = http.Dir("/htdocs") |
小窍门:认真考虑帮助类将节省程序员的时间。清晰比多个选择好。
小窍门:避免公开只用于测试的参数
避免公开导出仅在测试作用域上具有不同值的 API。相反,使用 Public 包装隐藏这些参数,使用在测试作用域的帮助类来设置测试范围中的属性。
编写一个处理切片的函数或方法是很常见的:
1 | func ShutdownVMs(ids []string) error |
这仅仅是我举的一个例子,但在我工作中更加常见。像这样的签名的问题是,他们假设被调用时会有多个实体。但是,我发现很多时候这些类型的函数却只有一个参数,为了满足函数签名的要求,它必须在一个切片内“装箱”。(Austin Luo:如示例,函数定义时预期会有多个 id,但实际调用时往往只有一个 id,为了满足前面,必须构造一个切片,并把 id 装进去。)
此外,由于 ids
是个切片,您可以向函数传入一个空的切片甚至 nil
,编译器也会允许。这就增加了更多的测试用例,因为您应当覆盖这些场景。
为构造一个这类型的 API 的例子,最近我重构了一条逻辑,如果一组参数中至少有一个非零则要求我设置一些额外的字段。这段逻辑看起来像这样:
1 | if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 { |
鉴于 if 语句变得非常长,我想将这个校验放到单独的函数中,这是优化的结果:
1 | // anyPostive indicates if any value is greater than zero. |
这使我能够向读者明确执行内部块的条件:
1 | if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) { |
但对于 anyPositive
还是有一个问题,有人可能会意外地像这样调用它:
1 | if anyPositive(){...} |
在这种情况下anyPositive
会返回false
,因为它将不会执行迭代并立即返回false
。这还不是世界上最糟糕的事情——(更糟糕的是)没有传入参数时这段代码的逻辑将会变成“anyPositive
是否返回true
?”。
然而,假如可以这样那就更好了:更改 anyPositive
的签名,使得强制调用者应该传递至少一个参数。我们可以像这样组合常规参数和可变参数:
1 | // anyPostive indicates if any value is greater than zero. |
现在anyPositive
的调用就不能少于一个参数了。
假设我们有个将文档保存写入磁盘的工作任务。
1 | // Save writes the contents of doc to the file f. |
我可以这样描述这个函数,Save
,它以一个 *os.File
作为目标来保存写入 Document
。但这有一些问题。
签名 Save
排除了将数据写入网络位置的可能。假设网络存储成为后续的需求,可能不得不更改函数签名,从而影响其所有调用者。
Save
也对测试不友好,因为这是直接对磁盘的文件进行操作。因此,为了验证其操作,测试用例不得不在文件被写入之后重新去读取写入的内容。而且我还必须确保 f
最终从临时位置被删除。
同时 *os.File
也定义了很多与 Save
无关的方法,比如读取目录,检查一个路径是否为符号链接等。如果 Save
函数的签名只描述 *os.File
的一部分就更好了。
我们可以怎么做呢?
1 | // Save writes the contents of doc to the supplied |
使用 io.ReadWriteCloser
我们可以遵循接口隔离原则重新定义 Save
,从而获得一个更常规的文件操作接口。
有了这个改变,io.ReadWriteCloser
接口的任何实现都可以替代前文的 *os.File
。
这使得 Save
的应用更加广泛,并且向 Save
的调用者澄清了哪些 *os.File
类型的方法与其操作相关。
并且,作为 Save
函数作者,我不再能调用 *os.File
其他那些不相关方法,它们都被 io.ReadWriteCloser
接口隐藏到了背后。
我们可以针对接口隔离原则谈得更深入些。
首先,如果 Save
遵循单一职责原则,它不太可能读取它刚刚编写的文件以校验其内容——这应该是另一段代码的责任。
1 | // Save writes the contents of doc to the supplied |
因此,我们可以将传递给 Save
的接口缩小到只是写和关闭两个方面。
其次,通过 Save
附带提供一种关闭其流的机制(Austin Luo:由于 io.WriteCloser
的存在,Save
隐含了关闭流的含义)。我们继承了这种机制,使其仍然看起来像一个文件,这就提出了在什么情况下 wc
会被关闭的问题。
可能 Save
会无条件地调用 Close
,或者在成功的情况下才调用 Close
。
这给 Save
的调用者带来一个问题,那就是希望在写入文档之后再向数据流写入其他数据时怎么办?
1 | // Save writes the contents of doc to the supplied |
一个更好的解决方案是,重新定义 Save
,只持有一个 io.Writer
,将除了向数据流写入数据之外的其他所有职责都完全剥离出来。
通过在 Save
函数上遵循接口隔离原则,其结果是实际需求的最核心描述同时作为一个函数——它只需要一个可写的对象——并且是最通常的情况,我们现在可以使用 Save
来向任何 io.Writer
的实现保存数据。
我已经做了好几场关于错误处理的演讲,在我的博客里也写了很多相关的内容,昨天的那一节我也讲了很多了,因此我不打算再赘述了。
相反,我想谈关于错误处理的其它两个方面。
您昨天可能听了我的讲演,我谈到了关于改进错误处理的建议草案。但是您知道有什么是比改进错误处理语法更好的吗?那就是根本不用处理错误。
注意:我并不是说“移除您的错误处理”。我建议的是,修改您的代码,从而无需处理错误。
本节是从 John Ousterhout 的新书《A philosophy of Software Design》^9中得到的启示。其中一章是“Define Errors Out of Existence”,我们来把这个建议放到 Go 中来看看。
让我们来写一个统计文件行数的函数。
1 | func CountLines(r io.Reader) (int, error) { |
由于我们要遵循上一节的建议,CountLines
持有了一个 io.Reader
,而非 *File
——提供要计数内容的 io.Reader
是调用者的职责。
我们构造了一个bufio.Reader
,并将它放到循环中调用ReadString
方法,累加一个计数器,直到文件末尾,然后我们返回读取到的行数。
至少这是我们期望的的代码,但这个函数因为错误处理变得更加复杂。例如,这里有个奇怪的结构:
1 | _, err = br.ReadString('\n') |
我们在判断错误之前累加了计数——这看起来很怪异。
我之所以写成这样,是因为ReadString
在遇到换行符之前如果遇到文件结尾则会返回一个错误,如果文件中没有最终换行符,则会发生这种情况。
为了修复这个问题,我们重新排列逻辑以累加行数,然后查看是否需要退出循环。
注意:这个逻辑依然不够完美,您能发现 bug 吗?
错误还没有检查完毕。ReadString
在遇到文件末尾时会返回io.EOF
。这是符合预期的,ReadString
需要某种方式“叫停,后面没有更多的东西可读取了”。因此在我们向CountLine
的调用者返回错误之前,我们需要检查错误不是io.EOF
,并且在这种情况下才将其进行传播,否则我们返回 nil
说一切正常。
Russ Cox 觉察到错误处理可能会模 糊函数操作,我想这就是个很好的例子。让我们来看一个优化的版本:
1 | func CountLines(r io.Reader) (int, error) { |
这个优化的版本选择使用 bufio.Scanner
而不是 bufio.Reader
。
在 bufio.Scanner
的封装下使用 bufio.Reader
,但它提供了一个很好的抽象层,帮助我们移除了 CountLines
操作模糊不清的错误。
注意:
bufio.Scanner
可以根据任何模式扫描,但默认只查找换行。
sc.Scan()
这个方法,在匹配到一行文本并且没有遇到错误时会返回 true
,因此,for
循环会在遇到文件结尾或者遇到错误时退出。类型 bufio.Scanner
会记录它遇到的第一个错误,一旦退出,我们可以使用 sc.Err()
方法获取到这个错误。
最后,sc.Err()
会合理处理 io.EOF
,并且在遇到文件结尾但没有其他错误时,将错误转化为 nil
。
小窍门:当您发现自己遇到难以消除的错误时,请尝试将某些操作提取到帮助类中。
我的第二个例子受到了博客文章“Errors are values”^10的启发。
之前的讲演中我们已经看过如何打开、写入和关闭文件。错误处理还存在,但不是那么难以消除,我们可以使用 ioutil.ReadFile
和 ioutil.WriteFile
来封装。但是当我们处理低级别的网络协议时,有必要通过 I/O 来构建响应,这就让错误处理可能变得重复。考虑构建 HTTP 响应的 HTTP 服务器的这个片段:
1 | type Header struct { |
首先我们使用 fmt.Fprintf
构造了状态行并且检查了错误。然后为每个请求头写入键和值,同样检查了错误。最后我们使用 \r\n
终结了请求头这一段,仍然检查了错误。接下来复制响应体到客户端。最后,尽管我们不用检查 io.Copy
的错误,但我们也需要将 io.Copy
的双返回值转换为 WriteResponse
所需的单返回值。
这有太多的重复工作了。我们可以通过引入一个小的封装类 errWriter
来让这件事变得更容易。
errWriter
满足 io.Writer
的契约,因此它可以用来包装现有的 io.Writer
。errWriter
将写入传递给底层的 Writer,直到检测到错误,从这开始,它会丢弃任何写入并返回先前的错误。
1 | type errWriter struct { |
使用 errWriter
替换 WriteResponse
可以显着提高代码的清晰度。每个操作不再需要用错误检查来自我修复。通过检查 ew.err
字段来将报告错误移动到函数的末尾,同时也避免因为 io.Copy 的多返回值而引起恼人的转换。
最后,我想提一下您应该只处理一次错误。处理错误意味着检查错误值并做出单一决定。
1 | // WriteAll writes the contents of buf to the supplied writer. |
如果错误您一次都不处理,那您就忽略了它。就像我们看到的这样,w.WriteAll
的错误完全被丢弃了。
但是对单一错误做出多次处理决定,也是有问题的。以下是我经常遇到的代码。
1 | func WriteAll(w io.Writer, buf []byte) error { |
在这个示例中,如果 w.Write
产生了一个错误,则会在日志文件中写一行日志,记录错误发生的文件和代码行,并且错误又同时被返回给了调用者,调用者又可能去记录日志,继续返回,直至回溯到程序的顶部。
调用者可能也会做同样的事,
1 | func WriteConfig(w io.Writer, conf *Config) error { |
到头来在您的日志中会出现重复的行,
1 | unable to write: io.EOF |
但在程序的顶部,您得到了一个没有上下文的原始错误,
1 | err := WriteConfig(f, &conf) |
我想进一步深入研究这一点,因为我不认为记录并且返回错误仅仅是个人偏好的问题。
1 | func WriteConfig(w io.Writer, conf *Config) error { |
我看到的很多问题是程序员忘记在错误处返回。正如我们之前谈到的那样,Go 风格应当使用保护条款,检查函数进行下去的前提条件,并提前返回。
在这个示例中,作者处理了错误,记录了日志,但忘记返回,这将导致一个难以觉察的 bug。
在 Go 的错误处理契约中,如果出现错误,您不能对其他返回值的内容做出任何假设。就像上例中如果 JSON 反序列化失败,buf
的内容未知,可能什么都不包含,但包含了 1/2 的 JSON 片段会更糟糕。
因为程序员在检查和日志记录了错误之后忘记返回,一个混乱的缓冲区被传递给了 WriteAll
,它又可能执行成功,这样配置文件就会被错误地覆盖了。但此时函数会正常返回,并且发生问题的唯一迹象只是单个日志行记录了 JSON 编码失败,而不是编写配置文件失败。
这个 bug 的发生是因为作者尝试向错误消息添加上下文信息。他们试图给自己留下一个线索,指引他们回到错误的源头。
让我们看看使用 fmt.Errorf
来做同样的事的另一种方法。
1 | func WriteConfig(w io.Writer, conf *Config) error { |
通过将错误的注释与返回组合到一行,则以就更难以忘记返回错误,从而避免意外继续。
如果写文件时发生一个 I/O 错误,错误对象的 Error()
方法将会报告如下信息:
1 | could not write config: write failed: input/output error |
github.com/pkg/errors
包装错误fmt.Errorf
模式适用于提示错误信息,但其代价是原始的错误类型被掩盖了。我认为,将错误视为不透明的值对于生成松散耦合的软件很重要,所以如果对错误值所做的唯一事情是如下两个方面的话,则原始错误是什么类型就无关紧要了。
nil
但是,在某些场景,可能并不常见,您确实需要恢复原始错误。在这种情况下,您可以使用类似我的 errors
包来备注这样的错误。
1 | func ReadFile(path string) ([]byte, error) { |
现在报告的错误将会是很好的 K&D ^11 风格的错误:
1 | could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory |
并且错误值保留了对原始原因的引用。
1 | func main() { |
从而您可以恢复原始的错误,并且打印其堆栈:
1 | original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory |
使用 errors
包让您得可以以人和机器都能检测到的方式向错误添加上下文。如果您昨天来看了我的讲演,就会知道 error
的包装正在进入即将发布的 Go 版本的标准库。
我们选择 Go 开发项目通常是因为其并发的特性。Go 团队已经竭尽全力使 Go 中的并发性廉价(在硬件资源方面)并具有高性能,但是使用 Go 的并发性写出既不高性能也不可靠的代码仍然是可能的。在我即将离开的时候,我想留下一些关于避免并发特性带来的陷阱的建议。
Go 特性支持的第一类并发是针对通道、select
语句和 go
语句的。如果你从书籍或者培训课程中正式地学习过,你可能注意到并发这一节总是在最后才会讲到。这里也不例外,我选择最后才讲并发,好像它是对于 Go 程序员来说应该掌握的常规技能之外的附加部分。
这有两个方面。一方面 Go 的主旨是简单、轻量的并发模型。作为一个产品,我们的语言几乎只靠这方面进行兜售。另一方面,有一种说法认为并发实际上并不容易使用,否则作者并不会放到一本书的最后一章,我们回首我们之前的努力时也不会带有遗憾。
本节讨论使用 Go 原生的并发特性时的一些陷阱。
这段程序有什么问题?
1 | package main |
这段代码按我们预期在执行,他开启了一个简单的 Web 服务。但它同时又干了些别的事情,那就是在一个无限循环中浪费 CPU。这是因为main
的最后一行for {}
循环阻塞了主的协程,因为它不做任何输入输出,也不等待锁,也不在通道上做发送或接收,或以其他方式与调度程序通信。
由于 Go 运行时主要是协同安排的,因此该程序将在单个 CPU 上无效地循环,并且可能最终被实时锁定。
我们要怎么修复它呢?这里有个建议:
1 | package main |
这可能看起来很愚蠢,但这是我看到的最通常的解决方案。这是不了解根本问题的症结所在。
现在,如果你对 Go 稍有经验,你可能写成这样:
1 | package main |
任何一个空的 select
语句都会永远阻塞在那。这是个很有用的性质,因为现在我们不想仅仅因为调用runtime.GoSched()
就让整个 CPU 都“旋转”起来。但这样做,我们只治了标,没有治本。
我想向你提出另一种解决方案,希望这一方案已经被采用了。与其让http.ListenAndServe
在一个协程中执行并带来一个“主协程中应该做什么”的问题,不如简单地由主协程自己来执行http.ListenAndServe
。
小窍门:Go 程序的
main.mian
函数退出,则 Go 程序都会无条件退出,不论其他协程正在做什么。
1 | package main |
总之,这是我的第一个简易:如果你的协程在其他协程返回结果之前什么事都不能干,通常就应该直接了当地自己做这件事,而不是委托其他协程去做。
这通常也消除了将结果从协程引导回其发起者所需的大量状态跟踪和通道操作。
小窍门:许多 Go 程序员滥用协程,特别是初学者。与生活中的所有事情一样,适度是成功的关键。
下面两个 API 的区别是什么?
1 | // ListDirectory returns the contents of dir. |
1 | // ListDirectory returns a channel over which |
首先,显著的区别是,第一个例子读取目录到切片,然后将整个切片返回,否则如果有问题则返回一个错误。这是同步发生的,ListDirectory
的调用者将被阻塞直到整个目录被读完。依赖于目录有多大,这个过程可能持续很长时间,也可能因为构建一个目录条目名称的切片,而分配大量的内存。
让我们来看看第二个例子。这更一个更像 Go,ListDirectory
返回了一个传输目录条目的通道,当通道关闭时,表明没有更多目录条目了。由于通道信息发生在ListDirectory
返回之后,ListDirectory
内部可能开启了一个协程。
注意:第二个版本实际上没有必要真的使用一个协程。它可以分配一个足以保存所有目录条目而不阻塞的通道,填充通道,关闭它,然后将通道返回给调用者。但这不太可能,因为这会消耗大量内存来缓冲通道中的所有结果。
通道版本的ListDirectory
还有两个进一步的问题:
ListDirectory
在中途遇到错误就无法告知调用者返回的集合是不完整的。调用者也无法区分空目录和一读取就产生错误的情况,这两种结果对于ListDirectory
返回的通道来说都是立即关闭。ListDirectory
的使用的一个严重限制。调用者必须花时间从通道读取数据,哪怕调用者已经接收到它想要的信息。就需要使用大量内存的中型到大型目录而言,它可能更有效,但这种方法并不比原始的基于切片的方法快。以上两个实现中的问题,其解决方案是使用回调。一个在每个目录条目上执行的函数。
1 | func ListDirectory(dir string, fn func(string)) |
毫不奇怪,filepath.WalkDir
就是这么做的。
小窍门:如果你的函数开启了一个协程,那么你必须给调用者提供一个停止协程的途径。将异步执行函数的决策留给该函数的调用者通常更容易。
上一个例子演示了没有必要的情况下使用协程。但使用 Go 的驱动原因之一是该语言提供的第一类并发功能。实际上,在许多情况下,您希望利用硬件中可用的并行性。为此,你必须使用协程。
这个简单的应用,在两个不同的端口上提供 http 服务,端口 8080 用于应用本身的流量,8081 用于访问 /debug/pprof
终结点。
1 | package main |
尽管这个程序不是很复杂,但它代表了一个基本的真实应用程序。
随着应用程序的增长,有一些问题也显现出来,让我们现在来解决其中的一些。
1 | func serveApp() { |
通过将 serveApp
和 serveDebug
的功能放到到各自的函数中,我们把他们从 main.main 分离出来。我们照样遵循了上面的建议,把 serveApp
和 serveDebug
的并发性留给了调用者。
但是这个程序有一些可操作性上的问题。如果serveApp
返回则main.main
会返回并导致程序关闭,最终由您正在使用的任何进程管理器重新启动。
小窍门:正如函数的并发性留给调用者一样,应用应该将状态监视、重启留给程序的唤起者。不要让你的应用程序担负重启自身的责任,这是一个最好从应用程序外部处理的过程。
但是,serveDebug
是在另一个协程中执行的,如果它退出,也仅仅是这个协程自身退出,程序的其他部分将继续运行。由于/debug
处理程序停止工作,您的操作人员会很不高兴地发现他们无法在应用程序中获取统计信息。
我们要确保的是,负责服务此应用程序的任何协程停止,都关闭应用程序。
1 | func serveApp() { |
现在我们通过必要时调用 log.Fatal
来检查 serverApp
和 serveDebug
从 ListenAndServe
返回的错误。由于两个处理器都是在协程中运行,我们使用 select{}
来阻塞主协程。
这种方法存在许多问题:
ListenAndServe
返回一个 nil
,log.Fatal
不会被调用,则对应的 HTTP 服务会停止,并且应用程序不会退出。log.Fatal
会调用 os.Exit
无条件终止进程,defer 不会被调用,其他协程不会被通知关闭,应用程序会停止。这会使得为这些函数编写测试用例变得很困难。小窍门:只在
main.main
或init
函数里使用log.Fatal
。
我们需要的是,把任何错误都传回协程的发起者,以便于我们弄清楚为什么协程会停止,并且可以干净地关闭进程。
1 | func serveApp() error { |
我们可以使用一个通道来收集协程返回的状态。通道的大小与我们要管理的协程数一致,从而使得向 done
通道发送状态时不会被阻塞,否则这将阻塞协程的关闭,导致泄漏。
由于没有办法安全地关闭 done
通道,我们不能使用 for range
循环通道知道所有协程都上报了信息,因此我们循环开启协程的次数,这也等于通道的容量。
现在我们有办法等待协程干净地退出,并且记录发生的日志。我们所需的仅仅是将一个协程的关闭信号,通知到其他协程而已。
其结果是,通知一个 http.Server
关闭这事被引入进来。所以我将这个逻辑转换为辅助函数。serve
帮助我们持有一个地址和一个 http.Handler
,类似 http.ListenAndServe
以及一个用于触发 Shutdown
方法的 stop
通道。
1 | func serve(addr string, handler http.Handler, stop <-chan struct{}) error { |
现在,每当我们从 done
通道接收到一个值,就关闭 stop
通道,从而导致所有等待在这个通道上的协程关闭 http.Server
。这将导致所有剩余的 ListenAndServe
协程返回。一旦我们启动的协程停止,main.main
便返回继而进程干净地停止了。
小窍门:自己写这个逻辑是重复和微妙的。考虑类似这个包的东西,https://github.com/heptio/workgroup 它将为你完成大部分工作。
【完】
]]>翻译水平有限,如有偏颇之处,烦请联系我更正。转载请注明出处。翻译:Austin Luo;邮箱:uonun@163.com
1 | { |
附:
我自己修改了当前用户的个人文件夹,通过上述方式可以解决 VSC 中 terminal 的 HOME 位置不对的问题(可以正常加载 .ssh 及相关的 key),但 VSC 默认自带的 Git 仍然会找到系统默认位置的个人文件夹,这个可以通过 junction 命令来解决:
1 | # 在系统默认的个人文件夹下创建迁移过的新位置的软连接 |
]]>通过注册表完整设置和迁移个人文件夹位置可能不会出现上述情况。
deploy.sh
1 |
|
pack.sh
1 |
|
MySQL 部署的机器不允许 SSH 连接,并且数据库端口只允许 localhost 或 10.0.0.1 这台跳板机访问,此时本机可以通过 ssh 连上跳板机,通过 ssh 建立的隧道端口转发连接上 MySQL 数据库。
1 | # 本机连接跳板机 |
参考:
http://mingxinglai.com/cn/2015/09/connect-mysql-via-ssh-tunnel/
]]>架构师的首要任务是尽最大可能找出所有利益相关者,业务方,产品经理,客户/用户,开发经理,工程师,项目经理,测试人员,运维人员,产品运营人员等等都有可能是利益相关者,架构师要充分和利益相关者沟通,深入理解他们的关注点和痛点,并出架构解决这些关注点。架构师常犯错误是漏掉重要的利益相关者,沟通不充分,都会造成架构有欠缺,不能满足利益相关者的需求。利益相关者的关注点是有可能冲突的,比如管理层(可管理性)vs技术方(性能),业务方(多快好省)vs 技术方(可靠稳定),这需要架构师去灵活平衡,如何平衡体现了架构师的水平和价值。
Architecture represents the significant design decisions that shape a system, where significant is measured by cost of change.
—- Grady Booch, UML的创始人之一
架构表示对一个系统的成型起关键作用的设计决策,这里的关键性是由“改变它”的成本来决定的。
微服务中每个服务可以独立演变,它的cost of change比较小,整体架构比较灵活,是一种支持创新的演化式架构。
Requirement | Feature | Description |
---|---|---|
Easy to separate | Autonomy | 易分离,自治性 |
Easy to understand | Understandablility | 可理解性 |
Easy to extend | Extensibility | 可延展性 |
Easy to change | Changeability | 可变性 |
Easy to replace | Replaceability | 可替换性 |
Easy to deploy | Deployability | 可部署性 |
Easy to scale | Scalability | 伸缩性,可扩展性 |
Easy to recover | Resilience | 可恢复性 |
Easy to connect | Uniform interface | 易连接,易集成,统一接口 |
Easy to afford | Cost-efficiency | 成本效率,性价比 |
架构的目标是用于管理复杂性、易变性和不确定性,以确保在长期的系统演化过程中,一部分架构的变化不会对架构的其它部分产生不必要的负面影响。这样做可以确保业务和研发效率的敏捷,让应用的易变部分能够频繁地变化,对应用的其它部分的影响尽可能的小。
]]>organizations which design systems … are constrained to produce designs which are copies of the communication structures of these organizations.
康威定律:设计系统的组织,其产生的设计和架构等价于组织间的沟通结构.
IP | 主机名 | 用途 |
---|---|---|
192.168.1.1 | k8s-master | master、etcd |
192.168.1.2 | k8s-node-1 | node1 |
192.168.1.3 | k8s-node-2 | node2 |
1 | # 在 master 上执行 |
在三台机器上设置 hosts,均执行如下命令:
1 | echo '192.168.1.1 k8s-master |
1 | setenforce 0 |
master 机
1 | firewall-cmd --permanent --add-port=6443/tcp |
node 机
1 | firewall-cmd --permanent --add-port=10250/tcp |
1 | cat <<EOF > /etc/yum.repos.d/kubernetes.repo |
1 | yum install -y etcd |
更改配置
1 | vi /etc/etcd/etcd.conf |
指引:https://kubernetes.io/docs/tasks/tools/install-kubectl/
腾讯云 CVM 未能启用驱动启动 minikube,安装驱动的过程可忽略。
CentOS:虚拟机不支持内部 VM,未在物理机上尝试
1 | cd /etc/yum.repos.d/ |
MAC 上可以直接下载安装:https://www.virtualbox.org/wiki/Downloads
安装成功,但
minikube start
时未能尝试成功
https://github.com/kubernetes/minikube/blob/master/docs/drivers.md#kvm2-driver
1 | yum -y install libvirt-daemon-kvm qemu-kvm |
附:查看虚拟机网络以及使用不同的网络启动 minikube
1 | yum -y install libvirt virt-install bridge-utils |
指引:https://github.com/kubernetes/minikube/blob/master/docs/drivers.md#kvm-driver
1 | # CentOS |
指引:https://github.com/kubernetes/minikube/releases
1 | # CentOS |
(使用 KVM2 驱动)运行之前
1 | # 启动服务 |
原本在 minikube start
命令中会自动拉取镜像,但是因为众所周知的原因,我们无法成功拉取到镜像。这里可以使用阿里 docker 容器镜像服务:https://dev.aliyun.com/search.html
1 | # 进入虚拟机拉取,而非本机 |
指引:https://kubernetes.io/docs/setup/minikube/#quickstart
1 | # 在代理下运行时,需要指定正确的代理,否则 minikube 在内部访问网络会有问题 |
https://github.com/kubernetes/dashboard/wiki/Creating-sample-user
该问题因为 docker 的配置和 kubelet 的配置不一致导致。
使用 docker info 打印 docker 信息:
1 | [root@VM_0_16_centos kubelet.service.d]# docker info | grep Driver |
而查看 kubelet 服务的启动参数(--cgroup-driver
),其设置为 cgroupfs
:
1 | [root@VM_0_16_centos kubelet.service.d]# more /etc/systemd/system/kubelet.service.d/10-kubeadm.conf |
此时,修改 docker 的服务参数(vi /usr/lib/systemd/system/docker.service
),将其中的 --exec-opt native.cgroupdriver
参数值改为 cgroupfs
。
然后,重启 docker,重启 kubelet
1 | systemctl daemon-reload && systemctl restart docker && systemctl restart kubelet |
解决方案:
1 | echo 1 > /proc/sys/net/bridge/bridge-nf-call-iptables |
1 | yum install -y kubernetes-cni |
![](/images/记一次 .NET Framework 不兼容 HTTP COOKIE 协议标准的问题跟踪-1.png)
消费系统不能直接请求业务系统的 HTTP 接口,需要由中间的 HttpHelper 代理请求。其中 HttpHelper 接受消费系统传入的各种参数,包括要请求的 URL、METHOD、HEAD、BODY 等,在实际生产中一直运行得很好,直到如下异常的出现:
1 | System.Net.CookieException: Cookie format error. |
跟进异常信息,很容易知道是设置 Cookie 时发生的异常。根据请求端传入的 HEAD 信息排查,我们很容易还原故障现场:传入 HTTP 头信息 Cookie: expires=Fri, 15 Jun 2018 15:19:14 GMT
![](/images/记一次 .NET Framework 不兼容 HTTP COOKIE 协议标准的问题跟踪-2.png)
这里是在设置 Cookie 的过期时间,并且这个时间看起来也正常,并没有格式错误或者时间不存在的错误。看起来问题不出在时间本身上。上网查查 HTTP 规范,根据 HTTP Cookie 协议,也是允许如下形式的字符串的,看起来也没有什么问题:
1 | Set-Cookie: sessionToken=abc123; Expires=Wed, 09 Jun 2021 10:18:14 GMT |
既然异常是微软代码抛出来的,也给了具体抛出异常的方法的地方,那么我们就来翻翻微软代码吧。终于,在微软代码 System.Net.Cookie.VerifySetDefaults:L382 发现了问题所在。在这里,微软在校验 Cookie 值时,如果发现指定的值字符串中有保留字符(“,”、”;”),则要求该值必须使用双引号引起来,否则就会抛出异常。查看我们请求的头,在 expires 的值“Fri, 15 Jun 2018 15:19:14 GMT”中,的确有“,”存在,并且值也并未使用双引号引起来。于是我尝试修改传入的 HTTP 头参数:
1 | Cookie: expires=“Fri, 15 Jun 2018 15:19:14 GMT” |
很自然,异常不再存在了,目前看起来的确是微软的这段代码导致了抛出异常。
你们我们来看看,这个值到底是不是可以去加双引号吧。继续上网翻文档,在 rfc2965#section-3.1
和 rfc6265#section-4.1.1
中提到,值可以是字符串或引号引起来的字符串(quoted-string
),这是 在 HTTP State Management Mechanism 中有所规定的。那么我们可以很放心地做这个兼容性处理了,即,当传入的 Cookie 值包含保留字符,并且未被双引号引起来时(一定会产生异常),我们自动地追加双引号,把值引起来,这样既可避免微软对值进行严格校验时抛出异常了。
但是,此事还没有到此为止,我们实际来试试,加了双引号之后,业务系统是否能够正确收到消费系统传入的头呢?收到的头,是否为消费系统的正确意图呢?
![](/images/记一次 .NET Framework 不兼容 HTTP COOKIE 协议标准的问题跟踪-3.png)
值得注意的是,在业务系统里获取到的 Cookie 值,是添加了引号的值,并不是严格地与消费系统里传入的文本一致。
到此为止,应对这个异常便有两个方案了:
在我们的项目中,由于情况特殊(使用场景为后端服务之间的通信交互),一般不涉及到 Cookie 的设置,并且消费服务可能由很多个不同的团队实现,而业务服务可以统一处理添加的双引号,因此我们采用了方案 1。至于其他场景,实际上个人偏向于采用方案 2,不掩盖任何问题——特别是因为加了双引号之后,请求接受端接收到的值,不能完全原样表达请求发起端设置的值。
作为一个软件系统,需要写日志,这是不言而喻的,这是大家都会不假思索地说“那当然”的事。不论是什么语言,写日志的专用框架也不一而足,写到文本的,写到数据库的,写到队列的,写到Redis的等等等等。对于日志的几个级别,Trace、Debug、Warning、Error、Fault,大家也能够如数家珍。但是,有多少人能够回答下面这几个问题呢?
为什么需要写日志?
什么时候写日志?
日志是写给谁看的?
日志里都要写什么?
日志的组织形式应该怎样?
接下来,我尝试一一地解答这些问题,说说我的理解。
首先我们要树立一个观点,那就是“不是为了记录日志而记录日志,日志也不是随意记的”。要实现能够只通过日志文件还原整个程序执行的过程,达到能透明地看到程序里执行情况,每个线程每个过程到底执行到哪的目的。日志就像飞机的黑匣子一样,应当能够复原异常的整个现场乃至细节。
作为程序员,我相信吐槽写注释、写文档的段子已经见得很多了。然而在我看来,写日志的重要性完全不亚于前两者,甚至在很多时候,比前两者更为重要。
我们说“软件工程”,很多人只着眼了“软件”两个字,而忽视了“工程”。软件本身是为解决问题服务的,作为一项工程,我们不单要考虑软件本身的开发环节(注释、文档),还应当考虑软件的运营环节(日志)。世界上没有100%完美的事,不存在没有 bug 的软件系统,一旦软件上线,作为开发人员的我们,就不再能够接触到了,如果系统发生了异常,我们却又对系统运行的状态一无所知,那我们就完全没有办法解决问题。有时候重启一下应用,重启一下系统什么的,也能临时解决,但这只是掩耳盗铃,该来的还会再来,出现过的异常,还会在某一天再次出现。
这个时候对“日志”的需求便应运而生。通过日志,记录程序在运行过程中的细节,记录发生异常时的现场,我们便能像庖丁解牛一样,对运行中的应用了如指掌了。
所谓“麻雀虽小五脏俱全”,当我们在建构一个软件系统框架时,所谓的几层结构,所谓的基础模块(通用模块、工具模块、数据访问模块……),包括写日志的模块,这些方法都是不用思考,放之四海而皆准的东西,直接搭建/拼凑起来再说。
然后呢?我想很多人就开始闷头做功能了——我是指只写功能代码,完成实际的业务逻辑。在做业务功能的时候,由于现代IDE的调试功能很强大,可以一行一行调试,看到运行状态、变量状态等等,所以此时对日志的需求和功用是极度不敏感,觉得此时可有可无。但是一旦业务功能做完了,基于各种各样的理由,工期紧啊,已经完成功能了啊,就直接转测不再继续完善了。其结果是,功能的确完成了,但是搭建的日志模块却几乎没有使用。完成了整个系统,只有不痛不痒的两三行日志,大多都是在搭建框架时写的“应用启动了”,“应用停止了”一类。后面就不用说了,都说回头补设计文档,有多少人补了?都说回头补注释,有多少人补了?既然已经转测了,既然已经上线了,就更不会再回头去写日志了。
后来,可想而知,当生产环境除了问题,由于没有日志,到底出了什么异常,就两眼一抹黑,没人能知道了。终于,临时解决了问题,意识到日志的重要性之后,急急回头翻出代码来写日志,可是当时做业务的逻辑、思考、陷阱、注意事项等等,都忘记的差不多了,写日志也就只能像看陌生代码一样,看表面,“进入了XXX方法”,“插入数据库完成”,只能写这样的日志了。而在我看来,这样的日志是不够的,这也是我最反感的写日志的方式,完全是为了写而写——也是不足以还原故障现场的,这个后续再说。
说到此可能你应该也看出来的,什么时候写日志是最佳时机呢?那就是在开发功能代码时。可以说,日志本身也是属于功能代码的一部分,只不过业务功能是给最终客户的,而日志是给运维、开发等用的。此时写日志有一个巨大的好处,那就是因为你是一边在思考,一边在开发,那么此时业务流转、可能的异常都会考虑得很完善,写日志的过程,也是反复思考和校验逻辑的过程,也就自然而然地会在日志中体现更多的关键信息。
在我看来,不论是运维,还是开发,再广泛地讲,包括白盒测试,都是需要看日志的。日志对不同的人,都有不同的使用价值。运维通过日志了解到程序基本的安装、环境依赖、加载情况、运行态信息等等;开发通过日志还可以了解到程序对业务处理的信息,每个业务的流程、环节、现场状态等等;白盒测试也可以通过日志了解到程序要求的非功能特性等等。
明确了“为什么要写日志”和“日志写给谁看的”了之后,要回答“日志里都要写什么”就容易得多了。我们需要从多个侧面来想这个问题。
正如前文说,日志可能面向运维、面向开发等等不同身份的人群。因此日志的内容上,就要为不同的人写入其关切的入不同内容。
这里的“业务”可能不一定是最终用户的业务,也可能是中间态的业务逻辑。当业务处理出现问题时,到底是程序的bug造成,还是错误的数据造成,或者是硬件、网络等资源的问题造成,这些都应当能够从日志中分析得出来。
一段日志,应当能够对故障现场的每一个细节都能在大脑思维里复盘。同时,日志不单单像流水账一样记录程序的运行过程,每行日志还不应该是孤立的,还应当是一个有机的整体,是有上下文的,有头有尾的。比如下面这一段日志:
日志中记录了一些关键事件以及一些关键信息。比如什么时候侦测到了新消息,有几个Handler可以去处理,每个Handler对应了哪个线程,每个线程对应在处理那种消息;有了某个处理过程的开始,就应当有对应的结束,如果涉及到多线程,还应当能够区分两行同样的日志,对应的不同线程和不同业务单据等等。而不仅仅是“侦测到消息”,“开始处理”,“处理完毕”这种简单的记录。
对于业务,一般我们需要后续的分析和运营,日志在这方面也有起到较大作用,因此这部分日志应当考虑以结构化、规范化的方式来记录,从而方便后续对日志进行自动化分析。
那么日志是越详细越好,记录得越多越好吗?是。也不是。
每一个软件系统都是有一定的生命周期的。刚测试上线的软件,一般最不稳定,最容易出现各种这样那样的问题,此时日志应当详细些。而长期运行了很久的软件,经过了时间、业务的大量考验,该出现的bug也已经修复得差不多了,几乎不会再出现什么问题,日志应当少一些。
怎么实现呢?很显然我们不能通过改代码,去掉写日志的代码之后重新发布来做这件事。常规地,我们通过日志分级来应对这个问题。通过不同的日志分级,以及日志输出开关,实时地调整日志输出的细节程度。因此,在我们编写输出日志的代码时,就务必要注意即将输出的日志应当是什么级别。Trace、Debug、Warning、Error、Fault,这些级别,应当对应不同重要程度、不同使用场景的细节。
考虑了以上三个方面,在开发完成之后,我们还应当切换不同的身份、角度以及配置不同的日志输出级别,来“设身处地”地审视输出的日志文件,是否能够满足要求,这样才能写一份好的日志记录。
既然叫“日志”,那我们通常的组织形式变是以时间为顺序来组织的一系列文件。除此之外,还可以将日志写入数据库、外部分析系统等等。
考虑“Separation of Concerns”,日志的组织形式,也可以根据上文考虑的不同方面来组织,比如将Error及以上的日志冗余独立记录,将面向运维的启停、加载信息独立记录,将软件运行日志和业务处理日志分开记录等等。关键是“Concern”,编写记录日志的代码时,我们应该对这段代码的功能、重要性、在整个系统的角色有深刻的认知,从而才能以不同的“关切”来考虑日志应该怎么输出,怎么组织。
当然,根据软件系统的不同,其日志的侧重点也可能不同。但总体来说,日志就如同飞机的黑匣子一样重要,我们应当重视日志输出的编写工作,而绝非仅仅是完成核心业务代码的编写。
]]>Other Tools and Frameworks
-> Visual Studio 2017
生成工具 并下载。--layout "F:\software\VS2017 BuildTool\Offline" --lang zh-CN
![](/images/创建 VS 2017 的离线安装包-1.png)
此时将开始下载安装所需文件:(如果断电断网或者关闭了下载窗口,没关系,输入命令重头来过,还是会继续下载的)
![](/images/创建 VS 2017 的离线安装包-2.png)
打开刚刚存放离线文件的路径,然后找到certificates文件夹并打开,依次安装该文件夹下的软件证书。
运行离线根目录下的安装程序,各版本位置如下:
大学时,左边放着个水杯,码一阵代码后开启调试的间歇喝一口水,结果杯里的水喝完了,举着空杯喝一口空气。杯子放下继续码代码,直到第二次…第N次举着空水杯喝空气之后,仍然没有去把杯子续上,然而续水很远吗?不,水壶就在右手边。这就是快感。
临毕业,自己写一个博客程序,生生推倒重写3次,每一次都是质变。期间一个结构设计问题思考酝酿了很久,最终是半夜做梦突然来了灵感醒来,兴奋的再也睡不着了,马上一个激灵起来实现。整个博客程序完成之后无意看到了head first设计模式,各种我操我操的感叹,尼玛怎么跟我想的一样?这就是快感。
毕业后,特别是做自己的练手项目,无论白天黑夜,总跟自己说,这个问题5分钟就能解决,然而第N个5分钟过去了,需求已经早扩大得无边无际了,还在继续,直到天亮仍然放不下。这就是快感。
这个清明节,做了两个 visual studio 插件,三天假两天都是凌晨5点睡觉的。刚刚才收到无警告报告(手上的项目每几个小时会上报运营报告,凌晨2点是个check point),一抬头,天就亮了。这就是快感。
非计算机专业,纯自学一路走来,唯有这快感激励我走到今天,提供我码每一行代码的动力。这么多年来,很多朋友、同事接私单业余做也收益可观,但我只做过两个帮忙性质的单子,是的,十年只两个。摸着良心说,挣钱还真不是我码代码的第一原因——虽然做过的小产品已经很多了,但从来都没有转化为经济。我还买不起房,并不是来体验生活的富二代。
这种示例还很多…五指朝上地说,没有半句虚言。
快感,就是着了魔。
]]>