背景

随着业务增长,代码逐渐复杂,前端有些时候不能通过自测保证代码质量,而测试同学从用户角度进行端到端的case测试,也有可能存在测试case不够全面或重复覆盖的情况。而测试覆盖质量只能通过测试case评审来保证,没有量化的、直观的客观数据。

然而前端场景和后端不同,UI测试因为业务形态原因,变更会更频繁,编写和维护单测成本比较高。因此目前且短期内前端工程还是以传统的用户角度的端到端测试为主。另外,在SSR项目中,前端开发也会书写API层,这部分代码也需要测试覆盖到。目前前端生态还没有一个能同时覆盖UI层和API层的覆盖率统计框架,需要借助已有的工具实现这点。

解决问题

提升开发自测质量和提测质量,避免case覆盖不全的问题,同时有直观的统计指标衡量。

预期效果

分3个阶段:

第一阶段:基本可用版本,能够采集UI层和API层的代码覆盖率,覆盖率采集过程对前端开发透明,无需开发业务以外的开发成本。能够打开浏览器页面直观查看各个代码覆盖率。

第二阶段:丰富统计数据,在第一阶段基础上,能够记录不同分支甚至不同版本历史的覆盖率,能够借助gitlab只查看增量文件的覆盖率。

第三阶段:打通QA平台,在第二阶段基础上,接入QA已有平台或自建平台,在第三方页面查看数据;形成一套覆盖率采集方案。

使用场景

本地、特性分支上使用

技术选型

覆盖率采集建立在代码插桩基础上。前端生态里比较成熟的工具是istanbuljs,该库的0.x.x版本位于istanbul库。关于该库的科普介绍,可以阅读ruanyifeng的科普文

istanbul提供两种插桩方式:编译时运行时

  • 编译时
  • 运行时,需要借助istanbul-middleware中间件的帮助
    • im.hookLoader,适用于服务端文件的动态插桩,方法利用istanbul-lib-hook中的hookRequire方法,hook被require引入的js文件,返回插桩后的js文件。因此需要在业务代码require前引入
    • im.createClientHandler,用于客户端js文件的动态插桩,它会把指定根路径下的js文件请求拦截,返回插桩后的代码。

在后台项目中,服务端代码使用.ts书写,本地使用ts-node启动,boe和线上使用tsc编译后的js文件启动。前端代码使用.ts书写,本地使用webpack + babel预编译成js。结合这个场景看,在node侧使用hookLoader,在UI测使用babel-plugin-istanbul插件更合适。

总体来讲,工作分两步:代码插桩可视化

代码插桩

规划里一切功能的基础和本质都来自代码插桩。

client端

client端可以直接使用目前仍良好维护的babel-plugin-istanbul插件。在.babelrc中引入相关配置:

1
2
3
4
5
6
7
{
"env": {
"test": {
"plugins": ["istanbul"]
}
}
}

仅在test环境下开启该插件。可以用cross-env这样的工具设置NODE_ENV=test

1
2
3
4
5
{
"scripts": {
"test": "cross-env NODE_ENV=test npm run start"
}
}

插件还有includeexcludeuseInlineSourceMaps这样的配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"env": {
"test": {
"plugins": [
["istanbul", {
"useInlineSourceMaps": false,
"exclude": [
"**/*.spec.js"
]
}]
]
}
}
}

插桩后的业务代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
var cov_ac7rkuoyv = function () {
var path = "/Users/test/shenlvmeng/nyc-demo/src/App.js";
var hash = "7dec600464f484deef063d183319f809a7c25687";
var global = new Function("return this")();
var gcv = "__coverage__";
var coverageData = {
path: "/Users/shenlvmeng/nyc-demo/src/App.js",
statementMap: {
"0": {
start: {
line: 8,
column: 2
},
end: {
line: 14,
column: 9
}
}
// ...
},
fnMap: {
"0": {
name: "App",
decl: {
start: {
line: 7,
column: 9
},
end: {
line: 7,
column: 12
}
},
loc: {
start: {
line: 7,
column: 15
},
end: {
line: 33,
column: 1
}
},
line: 7
},
// ...
},
branchMap: {},
s: {
"0": 0,
// ...
},
f: {
"0": 0,
// ...
},
b: {},
_coverageSchema: "43e27e138ebf9cfc5966b082cf9a028302ed4184",
hash: "7dec600464f484deef063d183319f809a7c25687"
};
var coverage = global[gcv] || (global[gcv] = {});

if (coverage[path] && coverage[path].hash === hash) {
return coverage[path];
}

return coverage[path] = coverageData;
}();

var _jsxFileName = "/Users/test/shenlvmeng/nyc-demo/src/App.js";

function App() {
cov_ac7rkuoyv.f[0]++;
cov_ac7rkuoyv.s[0]++;
Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => {
cov_ac7rkuoyv.f[1]++;
cov_ac7rkuoyv.s[1]++;

(async () => {
cov_ac7rkuoyv.f[2]++;
cov_ac7rkuoyv.s[2]++;
console.log(window.__coverage__);
cov_ac7rkuoyv.s[3]++;
axios__WEBPACK_IMPORTED_MODULE_1___default.a.defaults.headers.post['Access-Control-Allow-Origin'] = '*';
cov_ac7rkuoyv.s[4]++;
axios__WEBPACK_IMPORTED_MODULE_1___default.a.post('http://localhost:4000/coverage/client', window.__coverage__);
})();
}, []);
cov_ac7rkuoyv.s[5]++;
return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", {
className: "App",
__source: {
fileName: _jsxFileName,
lineNumber: 16
},
__self: this
}, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("header", {
className: "App-header",
__source: {
fileName: _jsxFileName,
lineNumber: 17
},
__self: this
}, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", {
src: _logo_svg__WEBPACK_IMPORTED_MODULE_2___default.a,
className: "App-logo",
alt: "logo",
__source: {
fileName: _jsxFileName,
lineNumber: 18
},
__self: this
}), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("p", {
__source: {
fileName: _jsxFileName,
lineNumber: 19
},
__self: this
}, "Edit ", react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("code", {
__source: {
fileName: _jsxFileName,
lineNumber: 20
},
__self: this
}, "src/App.js"), " and save to reload."), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("a", {
className: "App-link",
href: "https://reactjs.org",
target: "_blank",
rel: "noopener noreferrer",
__source: {
fileName: _jsxFileName,
lineNumber: 22
},
__self: this
}, "Learn React")));
}

可以看到针对每个文件,插桩后的代码新建了一个函数,用于更新这个文件的覆盖率信息。在业务代码执行时,会不断更新对应的计数器。前端代码run起来后,可以通过window.__coverage直接得到当前项目的覆盖率情况。

覆盖率计算原理

拿到代码字符串后,使用AST解析库解析出语法树,在对应树节点插入覆盖率语句,之后将语法树转成插桩后的代码。这个过程在0.x.x版本的实现可以参加这里

0.x.x版本的instanbul使用esprimaescodegen。在最新的istanbuljs的istanbul-lib-instrument中使用babel相关的包完成解析、生成等功能,但插桩思路不变。

node端

node端使用istanbul-middleware中间件的hookLoader完成动态插桩。该中间件为instanbul作者所写,所依赖的istanbul版本是目前已经不再维护的0.x.x版本,同时中间件也是基于express所写,年代较老。中间件的设计和我们的场景有些距离,因此需要在其基础上进行改造。

在预期场景下,我们主要使用im.hookLoaderim.createHandler两个方法。

im.hookLoader

im.hookLoader只是在instanbul hook的基础上做的简单封装。看文档可以发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function hookRequire(matcher, transformer, options) {
options = options || {};
var fn = transformFn(matcher, transformer, options.verbose),
postLoadHook = options.postLoadHook &&
typeof options.postLoadHook === 'function' ? options.postLoadHook : null;

Module._extensions['.js'] = function (module, filename) {
var ret = fn(fs.readFileSync(filename, 'utf8'), filename);
if (ret.changed) {
module._compile(ret.code, filename);
} else {
originalLoader(module, filename);
}
if (postLoadHook) {
postLoadHook(filename);
}
};
}

hook require是利用CommonJS的模块加载规范实现的。这里介绍下模块加载流程相关的背景。

模块加载流程

require和module来自nodejs的Modules模块。无需显式引入(原因下面会提)即可使用里面的module, require, exports的关键字。在require一个包时,分为了解析、加载、封装、求值、缓存几步。

  1. 解析一步交给module.require(modulename)方法实现,该方法调用静态方法Module._load加载模块。
  2. 加载时首先调用Module._resolveFilename解析路径,解析过程大致分为粗筛和精确定位
  3. 粗筛,原生模块则直接返回模块名,否则从当前目录逐步向上寻找node_modules目录下的模块文件夹
  4. 精确定位,首先在上面的目录下寻找有无没有拓展名的同名文件,再寻找以jsjsonnode拓展名结尾的文件;再寻找同名目录下package.jsonmain字段指定的路径,最后寻找同名目录下的index.js
  5. 得到路径后,先试图从Module._cache中寻找有无模块缓存,若没有,则新建模块对象并缓存,之后调用module.load()方法加载该模块
  6. node默认只能load以js, json, node结尾的文件,除此之外均视为.js文件
1
2
3
4
5
6
7
8
9
Module.prototype.load = function(filename) {
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));
var extension = path.extname(filename) || '.js';

if (!Module._extensions[extension]) extension = '.js';
Module._extensions[extension](this, filename);
this.loaded = true;
};
  1. Module._extensions键值对默认只包含对上述三种文件的处理。其中json和node文件较简单
  • json,读取文件 => JSON.parse => 注入到module.exports
  • node,调用process.dlopen加载
  • js,调用module._compile()处理文件内容
  1. module._compile执行js文件编译,编译前调用Module.wrap方法将模块封装在函数内,这也是modulerequireexports__filename__dirname可以直接在模块内使用,且模块间的modulerequireexports不相互干扰的原因。
1
2
3
4
5
6
7
Module.wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
Module.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
  1. wrap后,调用vm.runInThisContext将字符串转为可执行的js函数。最后一句执行封装的函数,注入当前module的相关信息到模块中
1
2
3
4
5
6
7
8
9
10
11
12
13
Module.prototype._compile = function(content, filename) {
// ...
var wrapper = Module.wrap(content);
var compiledWrapper = vm.runInThisContext(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true
});
// ...
// 执行
result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
return result;
}

实际上,可以在REPL中输入require.extensions看到nodejs支持的3种文件拓展。而hookRequire所做的就是hook了加载.js的步骤,在交由module._compile处理前,前进行了插桩处理。

注:ts-node能够import.ts文件也是因为在module._extendsion中添加了.ts的处理方法

综上,在本地测试时,由于通过ts-node启动,im.hookLoader只会hook.js文件(0.x.x版本),会导致没有服务端代码覆盖率,上线前tsc后,就可以顺利注入,从global.__coverage__中拿到覆盖率数据。

数据采集

服务端覆盖率数据采集借助上述的im.hookLoader就已完成。前端页面的代码覆盖率需要自行周期性上报。im.createHandler提供的API可以接收前端覆盖率,该方法创建的路由回调会调用utils.mergeFileCoverage最终将增量覆盖率数据累加在global.__coverage__中。

可视化

可视化需要借助istanbul(0.x版本)本身提供的相关API,又或者直接更方便地使用im.createHandler。其方便封装了一些istanbul的API,实现的简单的可视化功能,更多介绍可以直接参考istanbul-middleware使用文档。

im.createHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function createHandler(opts) {
// ...
var app = express();
// ...

//send static file for /asset/asset-name
app.use('/asset', express.static(ASSETS_DIR));
app.use('/asset', express.static(path.join(ASSETS_DIR, 'vendor')));

app.use(bodyParser.urlencoded(urlOptions));
app.use(bodyParser.json(jsonOptions));

app.get('/', function (req, res) {
// ...
core.render(null, res, origUrl);
});
// ...
}

从上面的使用上看,im.createHandler的使用是和express绑定的,并不适配koa的风格,要想使用在非express服务端场景里,或者增加额外功能,就需要重构这个方法。不过,由于一些API的使用从0.x.x版本迁移到新的monorepo的版本并不平滑,且新版本API暂无文档。所以尽管instanbul已经不再更新,对istanbul-middleware中间件的重构还只能保持对旧版本istanbul的依赖。

重构设计

为了兼容koa环境,从istanbul-middleware库fork新版本istanbul-koa-middleware,使用ts重写。去掉无用的代码,重点需要改造的就是createKoaHandler方法。

为减少istanbul-koa-middleware使用方的依赖,考虑使用类似istanbul-middleware的形式,在createKoaHandler内部定义koa应用,使用方只需引入koa-mount和istanbul-koa-middleware即可在任意路由上挂载覆盖率可视化相关子路由。

除此之外:

  • 使用koa-static替代express.static
  • 使用koa-mount替代app.use('/some/path', someMiddleware)
  • 使用koa-router替代app.getapp.post
  • 定义WebFramework接口,掩盖对回调中res使用方式的不同
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface WebFramework {
setHeader: (key: string, value: string) => void;
status: (code: number) => void;
write: (content: any) => void;
end: (content?: string) => void;
}

function genWebFrameworkCtxFromKoaCtx(ctx: Koa.ParameterizedContext): WebFramework {
return {
setHeader(key, value) {
ctx.set(key, value);
},
status(code) {
ctx.status = code;
},
write(content) {
ctx.res.write(content);
},
end(content) {
ctx.res.end(content);
}
};
}

这样,重构后的istanbul-koa-middleware就可以帮我们实现最简单的覆盖数据可视化了。后续的新增功能,也都建立在对createHandler函数的改造上。

已知问题

在上面工作完成后,第一阶段目标基本已经实现。

效果图1
效果图2

但是,还有很多可以优化的地方,这也是后续阶段需要解决的问题:

  • 服务端代码如果没有正确设置source-map,覆盖率展示的文件是tsc之后的js文件,可读性差
  • 需要区分环境,在特定环境下才对代码文件插桩
  • 本地环境下可以访问到原始的ts文件,上传测试环境后,由于不会打包上传源文件,覆盖率报告将看不了原始ts文件的覆盖详情
  • 覆盖率报告功能太简单,不能以分支、版本、仓库等空间维度或以历史信息、趋势等时间维度查看
    ,也没有diff内容覆盖率功能
  • 出现了一些意义不大的文件,需要剔除

更多

基本概念

Extension是由HTML、CSS、JavaScript和图片等其他资源文件组成的压缩包。它可以增强浏览器体验,实现个性化。

Files

Extension没有做目录的约定,但是它们需要已配置文件的形式写在manifest中。manifest.json中描述了Extension的基本信息、使用能力和重要文件。

manifest.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
{
// Required
"manifest_version": 2,
"name": "My Extension",
"version": "versionString",

// Recommended
"default_locale": "en",
"description": "A plain text description",
"icons": {...},

// Pick one (or none)
"browser_action": {...},
"page_action": {...},

// Optional
"action": ...,
"author": ...,
"automation": ...,
"background": {
// Recommended
"persistent": false,
// Optional
"service_worker":
},
"chrome_settings_overrides": {...},
"chrome_ui_overrides": {
"bookmarks_ui": {
"remove_bookmark_shortcut": true,
"remove_button": true
}
},
"chrome_url_overrides": {...},
"commands": {...},
"content_capabilities": ...,
"content_scripts": [{...}],
"content_security_policy": "policyString",
"converted_from_user_script": ...,
"current_locale": ...,
"declarative_net_request": ...,
"devtools_page": "devtools.html",
"event_rules": [{...}],
"externally_connectable": {
"matches": ["*://*.example.com/*"]
},
"file_browser_handlers": [...],
"file_system_provider_capabilities": {
"configurable": true,
"multiple_mounts": true,
"source": "network"
},
"homepage_url": "http://path/to/homepage",
"import": [{"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],
"incognito": "spanning, split, or not_allowed",
"input_components": ...,
"key": "publicKey",
"minimum_chrome_version": "versionString",
"nacl_modules": [...],
"oauth2": ...,
"offline_enabled": true,
"omnibox": {
"keyword": "aString"
},
"optional_permissions": ["tabs"],
"options_page": "options.html",
"options_ui": {
"chrome_style": true,
"page": "options.html"
},
"permissions": ["tabs"],
"platforms": ...,
"replacement_web_app": ...,
"requirements": {...},
"sandbox": [...],
"short_name": "Short Name",
"signature": ...,
"spellcheck": ...,
"storage": {
"managed_schema": "schema.json"
},
"system_indicator": ...,
"tts_engine": {...},
"update_url": "http://path/to/updateInfo.xml",
"version_name": "aString",
"web_accessible_resources": [...]
}
  • manifest_version表明使用manifest的格式版本号,目前是整数2
  • icons推荐使用PNG格式
  • browser_actionpage_action类型二选一,前者用于所有页面,后者用于特定一些页面。
  • background可以指定在后台运行的脚本
  • chrome打头的三个配置可以定制浏览器本身的UI或行为
  • commands快捷键配置
  • content_scripts描述需要声明式插入的规则
  • devtools_page描述自定义的devtools选项卡
  • externally_connectable描述其他能够连接到该Extension的url规则
  • omnibox配置关键词当用户在地址栏输入特定字符时,变成与Extension交互
  • permissions显式声明Extension需要使用的权限

Extension中的文件路径类似HTML中,通常使用相对路径访问。在使用绝对路径时,需要使用chrome-extension://<extensionID>/<pathToFile>风格的路径,可以使用chrome.runtime.getURL()得到某资源的绝对路径。

browser_action

下面是一个browser_action的manifest样例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// browser action
{
"name": "My extension",
...
"browser_action": {
"default_icon": { // optional
"16": "images/icon16.png", // optional
"24": "images/icon24.png", // optional
"32": "images/icon32.png" // optional
},
"default_title": "Google Mail", // optional; shown in tooltip
"default_popup": "popup.html" // optional
},
...
}

browser_action的UI可以包括icon、tooltip、badge、popup。

  • icon,可以是图片文件或HTML5 canvas元素。后者可以动态创建,以提供更流畅的效果。
  • tooltip,即配置文件中的title
  • badge,用于描述Extension工作状态的徽章,最长4个字符,可以调用browserAction API动态修改内容或背景色
  • popup,点击Extension按钮弹窗的窗口,可以包含任意HTML内容,可以在default_popup中定义,或调用API动态修改

browser_action有下面一些最佳实践:

  • 在Extension作用于大多数页面时使用,在作用于少数页面时使用page_action
  • 使用更多彩和重的图标,体现出和轻量级page_action的区别
  • 不要模仿chrome内置图标,会造成误解
  • 注意图标在不同主题背景色下的表现形态
  • 不要使用动图,会引起用户焦虑

page_action

更轻量级。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// page action
{
"name": "My extension",
...
"page_action": {
"default_icon": { // optional
"16": "images/icon16.png", // optional
"24": "images/icon24.png", // optional
"32": "images/icon32.png" // optional
},
"default_title": "Google Mail", // optional; shown in tooltip
"default_popup": "popup.html" // optional
},
...
}

UI上没有badge,其余和browser_action一致。另外,在非指定页面下,会有灰度展示。最佳实践同上。

架构

除了manifest描述项目结构外,需要另外一些组件组成完整的Extension功能。

  • background script,Extension的事件handler,用于监听对于Extension来说重要的浏览器事件
  • UI元素,有多种体现形式,如右键菜单,omnibox,点击按钮的弹出层等
  • content scripts,用于和页面交互的js脚本。它可以传递消息给Extension的其他部分
  • options page,用于配置Extension的页面

下面是一些详细介绍

background scripts

有效率的后台脚本应该由浏览器事件触发,执行命令之后卸载。这部分脚本在manifest的background中声明。

1
2
3
4
5
6
7
8
9
{
"name": "Awesome Test Extension",
...
"background": {
"scripts": ["background.js"],
"persistent": false
},
...
}

scripts可以指定需要执行的多个后台脚本。persistent需要指定为false。只有使用了chrome.webRequestAPI的后台脚本才指定persistent为true。

使用上,

  1. 在Extension加载时,初始化一次性配置。
  2. 添加监听函数,一些监听函数还提供更多参数便于筛选事件。
  3. 在监听函数内,实现业务逻辑
  4. 在卸载前,执行持久化数据、释放请求等操作

content scripts

Content scripts运行在页面的执行环境下,通过DOM访问页面元素并和所在的Extension交互。它还可以调用Chrome APIs完成一些原生操作。Content scripts执行在和页面JS脚本相隔离的环境里,两者共享1个DOM。

Content scripts有两种执行方式,命令式插入或声明式插入。前者通过chrome.tabs.executeScript实现,后者通过在manifest中声明实现在访问特定url时自动加载js文件:

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "My extension",
...
"content_scripts": [
{
"matches": ["http://*.nytimes.com/*"],
"css": ["myStyles.css"],
"js": ["contentScript.js"]
}
],
...
}

Content scripts和页面脚本虽然是隔离开的,但共享一个DOM,可以通过window.postMessage沟通和传递消息。

Chrome API

Extension使用特定的Chrome API在浏览器的环境下执行原生操作。API绝大多数都是异步的,这意味着如果想知道操作的结果,需要在回调函数中进行操作。Chrome的所有API都整合在Chrome这个namespace下,根据类型拆分成多个子模块,如chrome.runtime。

页面通信

由于content scripts运行在网页环境下,它通常需要和Extension本身进行通信。

  • 一次性通信,content scripts端使用chrome.runtime.sendMessage,Extension端使用chrome.tabs.sendMessage。接收侧一律使用chrome.runtime.onMessage.addListener
  • 持久性连接,类似上面使用chrome.runtime.connecttabs.connect,详见文档
  • 跨Extension通信,使用chrome.onMessageExternalruntime.onConnectExternal接收事件,发送事件可以使用上面的一次性通信或持久性连接的方式

又一年的开头,又一年的结尾,又一年的回首,又一年的新的惊喜。

回头看看去年写的,除了一些认为一定会发生的果然发生了之外,还出现了更多没想到的情况。又或许生活本就这么多意外,真是有趣。

2019

flag验收

好的 & 不好的

  • 看书面变广是好事,书也是好书,不过数目还不够
  • 穿搭进步太多,也有了自己想法。不过局限在休闲风,可以尝试更多风格(如成熟干练)
  • 身体健康和作息习惯退步
  • 找女朋友这一目标又拖了一年
  • 对关系亲密的人有时反应太寡淡。容易伤人
  • 生活情趣上更丰富,但还可以再多探索,另外热情不足,积累不足
  • 工作事业比预想中顺利且乐观,要继续保持,适应新环境

2020

下面是2020的展望:

  • 内在素质:在编程相关领域上有更多积累,以应对未来的职业挑战,要能形成知识体系则更好
    • 看完囤积的7本书(碰巧又和去年一样),形成研发知识体系积累
      • 学至少1门新语言
      • 了解至少1个其他领域
    • 看7本书之外的内容,积累通用素质和能力
  • 个人形象:探索新风格,补充基本款,调整作息,维持体重
    • 每天最晚1点半前睡觉,9点起床
    • 控制饮食,体重回到<80
    • 补全基本款
      • 更多色系
      • 衬衫
      • 休闲西服、西裤、马甲、皮鞋
      • 篮球鞋、运动鞋
    • 尝试新发型
  • 社交,更热情,在找对象上努努力
  • 生活情趣
    • 去不止1个城市,最好是国外
    • 骑行:青海湖、百里画廊
    • 拉琴:有最少1首能拿出来展示的曲目
    • 探索新爱好
      • livehouse
      • 艺术展、灯光秀
      • 探店
  • 工作事业
    • 能带小团队、带好团队
    • 能想清楚自己优势、定位
    • 将手里业务方向做到优秀

写了这么多,希望不是flag。我们明年拭目以待咯~

Bye~

更多:go官网

题解:https://github.com/shenlvmeng/go-learning-exercise

历史

Go语言构想与2007年9月,于2009年11月发布。主要思想来自3种语言:

  • C,基础语法和编译
  • Pascal,包概念
  • CSP(Communication Sequential Process),并发思想

Go项目诞生是为了解决Google中系统复杂性太高的问题。因此,简单性是Go思想的重要部分。设计上,Go

  • 没有隐式类型转换
  • 没有构造和析构函数
  • 没有运算符重载
  • 没有形参默认值
  • 没有继承
  • 没有泛型
  • 没有异常
  • 没有宏(macro)
  • 没有函数注记
  • 没有线程局部存储

快速开始

范例1:Hello world

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
fmt.Println("Hello world!")
}

在诸多语言中,C对Go的影响是最深的。.go文件需要经过编译成二进制文件才可以运行。

  • go run可以直接运行.go文件
  • go build可以编译生成二进制文件,并在之后直接执行

在代码结构上,

  • 先声明当前包名,其中命名为main的包名代表代码是可执行程序,而非一个库文件
  • import依赖包,go自带100+内置包。在编译时,编译器会抛弃未被使用的包,减少体积
  • 接下来是程序代码,命名为main的函数是执行的入口

Go代码有着标准的代码格式,并可以通过gofmt格式化代码。代码中不需要在行尾写分号,后面紧跟特定token的换行符会自动转成分号。因此,Go代码中换行会影响代码编译。

范例2:命令行参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// version 1
package main

import (
"fmt"
"os"
)
func main() {
var s, sep string
for i:= 1; i < len(os.Args); i++ {
s += setp + os.Args[i]
setp = " "
}
fmt.Println(s)
}
  • 切片(slice)是序列数组元素的表示方式,可以用s[i]s[m:n](m或n缺失是表示头和尾元素位置)获取1或n-m个元素。使用len(s)获取长度。
  • 注释以//开头
  • import多个库时,可以用()包裹列表的形式声明,这种写法更为常见
  • 使用var开头表示变量声明,未指定初始值的变量会隐式初始化为当前类型的“零值”(0或’’等)
  • :=式的声明可以省去var更快地为一组变量初始化
  • go中的for循环是唯一的循环语句,分为以下三部分。缺失initialization和condition时可以表示while循环
1
2
3
for initialization; condition; post {
// zero or more statements
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// version 2
package main

import (
"fmt"
"os"
)

func main() {
s, sep := "", ""
for _, arg := os.Args[1:] {
s += sep + arg
sep = " "
}
fmt.Println(s)
}
  • 在循环中,range可以生产一对值,index和value
  • _专门用来替代不需要使用的变量名,否则go会报错
  • 另外也可以直接用strings.Join方法实现效果

范例3:寻找重复行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"bufio"
"os"
)

func main() {
counts = make(map[string]int)
input = bufio.NewScanner(os.Stdin)
for input.Scan() {
counts[input.Text()]++
}
for line, n := range counts {
if n > 1 {
fmt.Printf("%d\t%s\n", n, line)
}
}
}
  • for循环一样,if语句也不需要()包裹
  • 内置的make函数可以创建一个新的map。map也可以被for循环遍历,每次循环的pair分别是key和value
  • counts[input.Text()]中当key不存在时,会返回零值0
  • bufio库可以更方便地帮忙处理程序的输入(input)和输出(output)
    • input.Scan()获取下一行,并自动去掉末尾换行符,在没有内容时返回false
    • input.Text()获取当前位置的文本
  • Printf和C语言风格类似,里面行如%s%v的特殊符号称为verbs

从文件中寻找代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func main() {
counts := make(map[string]int)
files := os.Args[1:]
if len(files) == 0 {
countlines(os.Stdin, counts)
} else {
for _, file := range files {
f, err := os.Open(file)
if err != nil {
fmt.Fprintf(os.Stderr, "dup: %v\n", err)
continue
}
countLines(f, counts)
f.Close()
}
}
for line, n := range counts {
if n > 1 {
fmt.Printf("%d\t%s\n", n, lines)
}
}
}

func countlines(f *os.File, counts map[string]int) {
input := bufio.NewScanner(f)
for input.Scan() {
counts[input.Text()]++
}
// error handling
}

除了上面的流模式读取文件外,还可以直接把整个文件直接读进内存,再将二进制数据string化并处理。此处可以使用io/ioutil中的ReadFile方法。转换过程用string(data)完成。

日常使用时,通常借助bufio,ioutil等高层级API就可以完成任务,而不需要深入实现内部。

范例4:Gif

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package main

import (
"image"
"image/color"
"image/gif"
"io"
"math"
"math/rand"
"os"
)

var palette = []color.Color{color.White, color.Black}

const (
whiteIndex = 0
blackIndex = 1
)

func main() {
lissajous(os.Stdout)
}

func lissajous(out io.Writer) {
const (
cycles = 5
res = 0.001
size = 100
nframes = 64
delay = 8
)
freq := rand.Float64() * 3.0
anim := gif.GIF{LoopCount: nframes}
phase := 0.0
for i := 0; i < nframes; i++ {
rect := image.Rect(0, 0, 2*size+1, 2*size+1)
img := image.NewPaletted(rect, palette)
for t := 0.0; t < cycles*2*math.Pi; t += res {
x := math.Sin(t)
y := math.Sin(t*freq + phase)
img.SetColorIndex(size+int(x*size+0.5), size+int(y*size+0.5), blackIndex)
}
phase += 0.1
anim.Delay = append(anim.Delay, delay)
anim.Image = append(anim.Image, img)
}
gif.EncodeAll(out, &anim)
}
  • 使用const声明常量,常量的值只能是number,string或boolean
  • gif.GIF{...}是合成字面量的写法,其类型是struct,可以字面量声明其field,未声明fields均为零值(zero value)
  • image库API可以操作图像

范例5:fetch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"fmt"
"io"
"net/http"
"os"
"strings"
)

const prefix = "http://"

func main() {
for _, url := range os.Args[1:] {
if !strings.HasPrefix(url, prefix) {
url = prefix + url
}
resp, err := http.Get(url)
if err != nil {
fmt.Fprintf(os.Stderr, "fetch: %v\n", err)
os.Exit(1)
}
_, err = io.Copy(os.Stdout, resp.Body)
resp.Body.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "fetch: reading %s: %v\n", url, err)
}
}
}
  • 和网络相关的API都位于net库中,如http.Get(url)
  • os.Exit(1)代表异常退出

范例6:并行fetch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"time"
)

func main() {
start := time.Now()
ch := make(chan string)
for _, url := range os.Args[1:] {
go fetch(url, ch)
}
for range os.Args[1:] {
fmt.Println(<-ch)
}
fmt.Printf("%.2fs elapse\n", time.Since(start).Seconds())
}

func fetch(url string, ch chan<- string) {
start := time.Now()
res, err := http.Get(url)
if err != nil {
ch <- fmt.Sprint(err)
return
}
nbytes, err := io.Copy(ioutil.Discard, res.Body)
res.Body.Close()
if err != nil {
ch <- fmt.Sprintf("while reading %s: %v", url, err)
return
}
secs := time.Since(start).Seconds()
ch <- fmt.Sprintf("%.2fs %7d %s", secs, nbytes, url)
}
  • goroutine是go中并行执行函数的表示,channel是goroutine间相互沟通的方式,传递特定类型数据。goroutine相互沟通时,沟通的两者会对其他goroutine block,保证没有冲突
    • goroutine使用go创建,channel使用chan创建,ch <-表示向channel发送,<- ch表示从channel接收
  • ioutil.Discard输出流会直接丢弃流内容

范例7:web server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"log"
"net/http"
)

func main() {
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe("localhost:8000", nil))
}

func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Path = %q\n", r.URL.Path)
}
  • 使用http库的HandleFuncListenAndServer可以便捷地启动一个服务器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"fmt"
"log"
"net/http"
"sync"
)

var mu sync.Mutex
var count int

func main() {
http.HandleFunc("/", handle)
http.HandleFunc("/count", counter)
log.Fatal(http.ListenAndServe("localhost:8000", nil))
}

func handle(w http.ResponseWriter, r *http.Request) {
mu.Lock()
count++
mu.Unlock()
fmt.Fprintf(w, "path = %q\n", r.URL.Path)
}

func counter(w http.ResponseWriter, r *http.Request) {
mu.Lock()
fmt.Fprintf(w, "count %d\n", count)
mu.Unlock()
}
  • server会为每个请求创建一个新的goroutine处理,为了避免并发读写count时的bug,使用了mutex锁保证读写是都是串行的
  • os.Stdout,ioutil.Discard, http.ResponseWriter都实现了io.Writer接口,因此可以用在任何需要输出流的地方
  • if语句前可以增加前置语句,如if err:=xxx; err != nil

杂项

  • go中的switch没有fall through机制,若需要,需要显示声明fallthroughcase支持表达式,switch后支持没有操作数,此时的switch称为tagless switch。break, continue, goto命令如常
  • go中有命名类型,类似ts中的interface,行如type Point struct { X, Y int }
  • go中有指针,*表示去指针对应的值,&表示取变量的指针,另外不支持指针上的算术运算
  • go中的方法指命名类型上的函数,interface意义如常
  • 可以去这里寻找标准库的包,或去这里寻找社区贡献的包
  • 注释风格同其他语言,//表示单行注释,/* */表示多行注释。不支持嵌套注释

程序结构

命名

和JS类似

  • 以**Unicode字母或下划线_**开头
  • 后跟Unicode字母或数字或下划线
  • 大小写敏感

go目前(2020/01/01)有25个不允许用来命名的关键字,其中几个可能是对前端较难想到的是

  • select
  • defer
  • chan
  • range
  • fallthrough

另外,还有一些预定义常量、类型、函数可以用来命名,但很容易造成误解,下面举些例子:

  • 常量:true iota nil
  • 类型:int complex128 uintptr rune error
  • 函数:make len imag panic close

包名始终小写,在函数域内命名的函数只在函数域内可见,否则在整个包域内可见。整个包内声明的变量用首字母区分可见性:

  • 首字母大写的可以被其他包访问,如fmt.FPrintf
  • 反之则只在包内可见

命名长度没有限制,但建议scope越大的变量命名越长。Go使用驼峰风格的变量命名,首字母缩略词和首字母同大写或同小写

声明

声明有4钟:

  • var 变量
  • const 常量
  • type 类型
  • func 函数

声明在函数域内可见,或在整个包域内可见。函数返回可以是一组变量。

变量

1
var name type = expression

Go中的变量声明如上所示,其中的type部分或expression部分可以省略,但是不能同时省略

  • type缺失时,name的类型由expression字面量或返回值决定
  • expression缺失时,name的值自动设置为type类型的“零值”(zero value)
    • 数值零值为0,字符串零值为"",布尔类型零值为false
    • 其余接口或引用类型零值为nil,如指针、map、切片、函数、channel
    • 聚合类型的零值即其所有组成元素的零值

所以,Go中不存在未初始化的变量。包级别变量在main函数开始前初始化,局部变量在声明过程中初始化。一组变量可以同时被初始化。

1
2
var b, f, s = true, 1.3, "string"
var f, err = os.Open(name)

简写式

函数域内的局部变量声明可以使用简写式,即:=。在已知变量初始值时可以省去写var。在初始值并不重要或最好显式写明类型时,还是使用var foo type的形式比较好。和var声明一样,也可以同时用简写式声明多个局部变量。但要注意,不要把这种写法和元组赋值(tuple assignment)搞混了。

1
2
3
4
5
// multiple initialzier expression
i, j := 1, true

// tuple assignment
i, j = j, i

另外,简写式声明里可以写部分已经声明的局部变量,在这里会当做赋值处理。但是简写式声明中要至少包含一个未声明变量

1
2
in, err := os.OpenFile(infile)
out, err := os.OpenFile(outfile)

指针

Go中的指针和C中类似,用&表示取一个变量的地址,用*表示访问某个地址所在的位置。指针的零值为nil,因此可以用p != nil来判断指针是否指向变量。

new函数

可以通过new函数,声明类型T创建新的匿名变量,函数返回变量的指针即*T类型。这在不需要变量名时很好用。每次调用new函数新建变量时,返回的地址不同除非类型不附加任何信息,如struct {}[0]int

1
2
p := new(int)
*p = 2

另外,由于new只是预定义函数,所以可以用来做变量名。

生命周期

生命周期即变量从创建到被回收的时间。包级别的变量会在整个程序执行过程中存在。局部变量则会在未被引用(unreachable)时释放内存。Go中的垃圾回收机制会自动帮你完成这件事。但是如果有下面这种情况出现,则会阻止垃圾回收释放内存。

1
2
3
4
5
6
var global *int
func f() {
var x int
x = 1
global = &x
}

在上述情况下,x局部变量从f函数中逃逸,并不会在f函数返回时被回收,持久存储在堆(heap)中。应尽量避免这种情况带来的额外内存损耗。

赋值

和其余语言赋值没什么太大区别。

不同的是,额外增加了元组赋值=右侧的一组变量会先求值,再赋给左侧变量。建议在不需要复杂运算时使用。同时,有些表达式和函数也会返回一组值,此时需要用元组赋值的方式接收。在不需要某个变量时,可以使用_占位。

1
2
3
4
5
6
x, y= y, x

f, err = os.Open("foo.txt")
v, ok = m[key]
v, ok = x.(T)
v, ok = <- ch

可赋值性

除了一些显式的赋值外,还有函数返回、字面量声明等。Go中的赋值当且仅当=左右的值和变量类型相同才可进行(对于==!=的判断也是这样)。nil可以赋值给任何复杂类型或引用类型。

类型声明

Go中可以定义类型。Go中的类型定义储存值的符号、它们的大小、固有操作以及方法,使用type name underlying-name声明。它通常出现在包级别,有些也会通过首字母大写的形式export出去。

1
2
3
4
5
6
7
8
9
type Celsius float64
type Fahrenheit float64

func CTOF(c Celsius) Fahrenheit {
return Fahrenheit(c * 9 / 5 + 32)
}
func FTOC(f Fahrenheit) Celsius {
return Celsius((f - 32) * 5 / 9)
}

两个有着相同底层类型的命名类型并不是同一种类型,也不能直接相互赋值和比较。但是可以使用强制类型转换转换到想同类型来比较。所有的类型T都有对应的强制类型转换操作T(x)。两个有相同类层类型或指向相同底层类型的未命名指针可以相互强制转换。另外,Go中的强制类型转换从不会在运行时出错。

比较特别的是,类型上还可以声明方法。

1
2
3
func (c Celsius) String() string {
return fmt.Sprintf("%g°C", c)
}

包和文件

Go中的包即其他语言中的库、模块。以实现模块化、封装、分发和重用。和Java类似,一个包的代码可以存放在多个文件内,通常位于同一个文件夹下。每个包都有相互隔离的命名空间,需要用·image.Decode的形式使用。需要export的变量、类型、函数使用首字母大写的形式。

建议在每个export出去的变量、类型、函数前使用注释说明。另外,建议在包开头留下doc comment,或将更多注释放在doc.go中。

import

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main
import (
"fmt"
"os"
"strconv"
"shenlvmeng/learning/tempconv"
)

func main() {
for _, arg := range os.Args[1:] {
t, err := strconv.ParseFloat(arg, 64)
if err != nil {
fmt.Fprintf(os.Strerr, "convert error: %v\n", err)
os.Exit(1)
}
f := temconv.Fahrenheit(t)
c := tempconv.Celsius(c)
fmt.Printf("%s = %s, %s = %s\n", f, tempconv.FTOC(f), c, tempconv.CTOF(c))
}
}

每一个包都有自己的import路径,Go语言标准并不定义如何解释import路径,这一步交给解释工具完成。每个包的包名通常和路径的最后一段同名。为避免包名冲突,import可以指定包的别名。

在引用了未被使用的包时会报错。,借助goimports等工具和正确的IDE配置,可以在保存代码时自动标准化代码格式。

包初始化

1
2
3
var a = b + c //third
var b = f() // second
var c = 1 // first

初始化时,先按照依赖的顺序初始化包级别变量。而.go文件的处理顺序则按照传给go编译器的顺序。初始化的过程是自底向上的,即当所有依赖包都初始化完成后,才会初始化main包,再执行main函数。对于初始化过程复杂的变量,可以在init函数中声明,而init是在程序启动时,按照声明的顺序一个一个执行的。

作用域

作用域是编译时的,和运行时的生命周期概念相对应。作用域描述一个声明的可见范围。和C系列语言类似,用大括号{}包裹会形成词法块作用域。Go在全局作用域下预定义了一些常量、函数、类型等。在函数外声明的作用域是包级别的,import进来的包作用域是文件级别的。局部声明只在块作用域内。内部作用域会覆盖外部作用域的同名声明。

另外,Go中还有一些隐式的作用域,比如forifswitch表达式中的作用域。

1
2
3
4
5
6
7
8
9
10
func main() {
x := "Hello"
for i:= 0; i < len(x); i++ {
x := x[i]
if x != "o" {
x := x + 'A' - 'a' // 类似upperCase
fmt.Prinf("%c", x)
}
}
}

上面的forif内部的x是一个单独的作用域。另外注意,简写式中会声明局部变量,会覆盖外部的同名变量,可能会带来意料之外的结果。可以通过var xxx type的形式声明变量。

1
2
3
4
5
6
7
var cwd string
func main() {
cwd, err := os.Getwd() // compile error: unused cwd
if err != nil {
log.Fatalf("os.Getwd failed: %v", err)
}
}

基础数据结构

Go有4大类数据类型:

  • 基础类型,即数字、字符串、布尔值
  • 聚合类型,即数组、struct
  • 引用类型,包括函数、指针、slice、map、channel
  • 接口类型

这一部分先说基础类型

整型

Go的数字类型包含了不同size的整型、浮点数和复数,以及它们的有无符号性。

整型有8、16、32、64四种长度,以及对应的signed和unsigned。组合一下即下面8种:

  • int8
  • int16
  • int32
  • int64
  • uint8
  • uint16
  • uint32
  • uint64

另外,runeint32的别称,通常用来表示1个Unicode字符,byteunit8的别称。uintptr用来表示一个可以承载任意指针值的无符号整型。

Go中对整型的处理和C风格很像。

  • 用首位表示符号位(signed int中)
  • 类型承载范围和C一样,如int8表示-128到127
  • 和C一样的操作符以及优先级,唯一区别是&^表示位清除,x &^ y表示根据y各位将x各位清0
  • <<左移位,空位取0,>>右移位,无符号数补零,有符号数补符号位
  • 整型间除法会清除小数部分
  • 取余%运算结果符号和被除数有关
  • 超过位数的会溢出
  • 0开头表示八进制(通常用来表示POSIX系统中文件权限设置),0x表示十六进制

不一样的是:

  • 相同类型才可比较大小,否则需要用int()强制转换为1种类型再比较。某些类型转换只改变值的使用方式,有些则会改变值,如intfloat之间的转换
  • %d, %o, %x分别表示整型、八进制和十六进制数
  • %c表示显示对应的Unicode字符,%q显示带引号版本

浮点数

浮点数有float32float64两种类型,服从IEEE754标准。为保证精确性,通常使用flaot64。另外,还有以下特点

  • .前后的0可以省略
  • %g, %e%f分别打印最合适展示精确度版本、指数版本和原始版本
  • +Inf, -InfNaN特殊值,表现类似JS

复数

Go有两种复数类型:complex64complex128,它们其实是由float32float64组成的。复数可以通过complex内置函数初始化,或者直接使用字面量。

1
2
var x complex128 = complex(1, 2) // 1 + 2i
y := 3 - 4i

复数间可以判断相等性,无法判断大小。math/cmplx包里包含一些复数的数学运算。

布尔类型

即type bool,和其他语言类似,有短路行为,&&||优先级更高。bool类型和整型之间不能相互隐式转换。

字符串

字符串表示一组不可修改的比特位序列,但通常用来承载可读的Unicode编码点。len返回字符串长度,[i]返回第i字节处的值。越界读取会导致panic。

  • s[i:j]表示自带的substring操作,其中ij均可省略
  • 字符串间可以比较大小和相等性,其中大小通过逐字节子母序比较
  • +可表示字符串拼接
  • 不可变性:不允许修改字符串的值(如s[0] = 'L'),这使得Go可以在底层复用字符串,节省内存

字面量字符串

用双引号"包裹,UTF-8编码。双引号中的反斜线\有特殊含义。如

  • \n表示换行
  • \t表示制表符
  • \x表示后接十六进制的高低位
  • \ooo表示三个八进制位

由反引号`` `包裹的表示纯文本字面量,其中的换行和格式也会被跨平台保留。可以用来书写多行字符串。

Unicode表示

Go中使用UTF-8变长编码:

  • 0xxxxxxx表示ASCII码
  • 11xxxxxx 10xxxxxx 表示两字节长度
  • 110xxxxx 10xxxxxx 10xxxxxx表示三字节长度
  • 1110xxxx 10xxxxxx 10xxxxxx 10xxxxxx 表示四字节长度

可以由\uhhhh表示16比特或\U表示32比特,如世界:\u4e16\u754cunicode包和unicode/utf8包提供了编解码工具。utf6.DecodeRuneInString可以读取一个自然字符的数据,而非一个字节一个字节读取,utf8.RuneCountInString返回字符串的自然字符长度。幸运的是,range循环会自动调用utf8解码其中的自然字符。

1
2
3
4
5
import "unicode/uft8"

s := "Hello, 世界"
fmt.Println(len(s)) // 13
fmt.Println(utf8.RuneCountInString(s) // 9)

当Go Unicode解析失败时,会使用特殊的Unicode占位符\ufffd,显示为带有问号的特殊字符。另外,rune[]可以直接将字符串转成编码后的每个Unicode编码点。这个rune数组进行string()强制类型转换后即原始字符串。当然你也可以直接string()装换一个整型数,不合规的整形数会得到上面提到的特殊字符。

1
2
3
4
5
6
s := "世界"
r := []rune(s)
fmt.Println(string(r))

fmt.Println(string(65))
fmt.Printlf(string(12341234))

字符串和Byte Slices

bytes, strings, strconv, unicode是和string相关的几个包。strings提供基本的字符串搜索、比较、修改等操作,bytes提供修改字节数组的一些操作。有时,使用byte.Buffer类型,在操作字符串字节时会更有效率。strconv提供了将其他类型转成字符串和修饰字符串的操作函数。unicode提供了一些以rune为中心的函数,如IsDigit, IsLetter, isUpper等。

1
2
3
4
5
6
7
8
9
// basename removes directory and filename suffix
func basename(s string) string {
slash := strings.LastIndex(s, "/")
s = s[slash+1:]
if dot := strings.LastIndex(s, "."); dot >= 0 {
s = s[:dot]
}
return s
}

pathpath/filepath包提供了更多文件夹和目录的操作函数。

尽管字符串中的字节序列是不可更改的。其对应的字节序列数组则是可以自由修改的[]byte(s)会分配一个字符串s的字节序列拷贝,也可以对应用string(b)还原。bytes包提供的Buffer类型可以很方便地承载[]byte类型。

1
2
3
4
5
6
7
8
9
10
11
12
func intsToString(values []int) string {
var buf bytes.Buffer
buf.WriteByte('[')
for i, v := range values {
if i > 0 {
buf.WriteString(", ")
}
fmt.Fprintf(&buf, "%d", v)
}
buf.WriteByte(']')
return buf.String()
}

上述函数中,WriteStringWriteByte用于向Buffer中写入字节或字节序列,该类型还有许多其他应用场景。

字符串和整型间的转换

  • 字符串 -> 整型fmt.Sprintfstrconv.Itoa
  • 整型 -> 字符串strconv.FormatIntstrconv.FormatUintstrconv.ParseIntAtoi

常量

常量有以下几个基本特点:

  • 编译时即对编译器可知
  • 必须是基础类型:boolean,string或number

常量使用const声明,形式看起来和使用var类似,不过值是常量。对常量进行的所有操作,如数学运算、逻辑运算、比较、内置函数求值,都是在编译期就确定了。

常量可以组声明,声明时可以不显式声明类型,此时将使用右侧操作数推断常量类型。

1
2
3
4
5
6
7
const (
noDelay time.Duration = 0
timeout = 5 * time.Minute
)

fmt.Printf("%T %[1]v\n", noDelay) // "time.Duration 0"
fmt.Printf("%T %[1]v\n", timeout) // "time.Duration 5m0s"

还有个不常用的点:组声明时,除了第一个常量,剩下的常量可以不写右侧操作数,此时会使用上一个常量来初始化。

1
2
3
4
5
6
const (
a = 1
b
c = 2
d
)

常量生成器iota

iota即常量生成器,它从0开始,每次常量声明后加一。利用这个规律可以方便地生成一组常量枚举。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const (
Sunday Weekday = iota
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
)

const (
const _ = 1 << (10 * itoa)
KB
MB
GB
TB
PB
EB
ZB
YB
)

无类型常量

常量和变量不一样的点在,常量是可以不声明类型的,根据常量限定的类型,有下面一些类型:

  • 无类型整型
  • 无类型Boolean
  • 无类型rune
  • 无类型浮点数
  • 无类型复数
  • 无类型字符串

常量在使用时,会隐式转换成需要的类型,并在无法转换时抛出错误。

1
2
3
4
var f float64 = 3 + i // complex -> float64
f = 2 // integer -> float64
f = 1e123 // float -> float64
f = 'a' // rune -> float64

实际上,这些无类型常量有一个隐含类型,如:

  • 无类型整型 -> int
  • 无类型浮点数 -> float64
  • 无类型复数 -> complex128
  • 无类型rune -> int32rune

聚合类型

基本类型是数据结构的组成“原子”。原子的组合就构成了“分子”——聚合类型:

  • array
  • slices
  • maps
  • structs

其中array和structs是聚合类型的基础,它们都有着固定大小。而slice和map则是动态大小。

array

1
2
3
var a [3]int
fmt.Println(a[0])
fmt.Println(f[len(a)-1])

类似C风格,array表示由0或多个同一类型元素组成的定长序列。声明数组时,需要使用常量表达式作为数组长度。当数组元素全部列出时,可以用...代替长度。元素未声明初始值时,按零值(zero value)处理。

1
2
3
var q [3]int = [3]int{1, 2}
fmt.Println(1[2]) // 0
q = [...]int{3, 4, 5}

另外,当元素较多时,还可以用index到value的键值对形式声明,未声明的值为零值。下面的例子中,r长度100,除了最后一个元素为-1之外,其余都为0.

1
r := [...]int{99: -1}

若数组数组具有可比性,则数组也具有可比性。另外,不同长度的数组是不同类型。[4]int[3]int不是同一类型。

1
2
3
4
5
6
7
import "crypto/sha256"

func main() {
c1 := sha256.Sum256("x")
c2 := sha256.Sum256("X")
fmt.Printf("\t\n", c1 == c2)
}

Go中将数组作为参数传递时,传递的是复制的新数组,而不是传入数组的引用,这是Go和其他语言不大一样的地方。当然可以通过传入数组指针的方式,实现在函数内修改数组内容。由于数组是定长的,在更多时候,函数参数使用slice类型传入。

slice

slice和array类型紧密相关,使用[]T声明。每个slice的底层都基于一个array。slice只是一个指针指向array中的某一个元素作为开始,除此之外,它还有lencap函数分别用来表示切片长度,和切片最大容量(从切片开始到底层array结尾)。

因此不同slice可以共享同一个array,它们之间可以相互重叠。s[i:j]是从创建slice的方式,遵从左闭右开原则,ij均可省略,省略时分别表示0和数组最末尾元素。创建可以基于一个array变量或一个array指针或其他slice。创建超过array范围会引起panic,只超过len(s)则会拓展这个slice。因为string实际上是[]byte切片,所以s[i:j]substring是一个意思。

从上面可以看到,slice即一个指向数组元素的指针,所以传递一个slice时,可以修改底层array的值。下面这个反转数组的函数不限数组长度:

1
2
3
4
5
6
7
8
9
func reverse(s []int) {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] := s[j], s[i]
}
}

s := []int{1, 2, 3, 4 ,5}
reverse(s)
fmt.Println(s) // [5 4 3 2 1]

上面的s是一个切片字面量,和array的区别在于没有声明长度。这种写法实际上会生成以后面值为全部元素的数组,并把切片指向这个数组。类似地,还可以使用make创建一个切片。

1
2
make([]T, len)
make([]T, len, cap)

由于切片只是引用,从效率和可理解性上考虑,切片间不具有可比性。不过切片可以和nil比较,nil表示空切片,而非“没有元素”的切片。不过Go中slice相关的函数对待这两种切片行为一样。

1
2
3
4
var s []int // s == nil
S = nil // s == nil
s = []int(nil) // s == nil
s = []int{} // len(s) == 0, s != nil

appendcopy

append函数可以操作slice。如果append之后,slice长度超过了底层array的长度,append会自动拓展底层array长度。另外,append不仅可以追加单个元素,还可以追加任意个元素,或解构后的slice。

1
2
3
4
5
6
7
8
9
10
var runes []rune
for _, r := range "Hello, 世界" {
runes = append(runes, r)
}

fmt.Printf("%q\n", runes)
var x []int
x = append(x, 1)
x = append(x, 2, 3)
x = append(x, x...)

在不借助append实现类似append功能时,就需要自己借助cap(x)make完成底层array的长度扩充。如同下面的一段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func appendInt(x []int, y ...int)  {
var z []int
zlen := len(x) + len(y)
if zlen <= cap(x) {
z = x[:zlen]
} else {
zcap := zlen
if zcap < 2 * len(x) {
zcap := 2 * len(x)
}
z = make([]int, zlen, zcap)
copy(z, x)
}
copy(z[len(x)], y)
return z
}

上面的...表示剩余参数,

借助copy还能实现一些slice的原址操作。

1
2
3
4
func remove(slice []int, i int) []int {
copy(slice[i:], slice[i+1:])
return slice[:len(slice)-1]
}

map

map即键值对,其中key要求具有可比较性。map有两种构造方式:make或字面量:

1
2
3
4
5
ages := make(map[string]int)
ages := map[string]int {
"Alice": 18,
"Bob": 27
}

map使用下标访问,使用delete删除键。另外,访问不存在的key时,值是value类型的零值。因此可以免去一些多余的初始化步骤。由于map的值并不是变量,所以不能用&获取地址。

1
2
3
4
5
6
ages["Cindy"] = 23
delete(ages, "bob")
// 新的key
ages["Dred"] += 1 // 1

_ := &ages["Cindy"]

map在遍历时,顺序是随机的。因此如果需要确定顺序,需要事先手动排序。

1
2
3
4
5
6
7
8
9
10
import "sort"

var names []string
for name := range ages {
names = append(names, name)
}
sort.Strings(names)
for _, names := range names {
fmt.Printf("%s\t%d\n", name, ages[name])
}

map的deletelenrange和取值操作都可以对零值nil进行,但是存储到nilmap时会报错。由于访问map不存在的key会返回默认的零值,所以下标操作用第二个参数返回是否对应的key,*且参数通常命名ok*。

1
2
3
if age, ok := ages["Ed"]; !ok {
// ...
}

Go中没有set类型,可以用map[string]bool等价。当key可能不可比较时(如用slice做key),可以用额外的序列化使用。

struct

struct类似ts中的interface。由或多个fields组成,每个field使用来访问。struct和field都是变量,所以可以用&获取地址。对地址也可以使用点来访问field。

1
2
3
4
5
6
7
8
9
10
11
12
type Employee struct {
Id int
Name string
Address string
DoB time.Time
Position string
Salary int
}
var e Employee
e.Salary = 1000
pos := &e.Position
*pos = "Senior " + *pos

相同类型的两个key可以在一起声明。在Go的struct中,field的组合和排序都意味着不同的type。和包一样,大写的field被导出可被访问,这也是Go的一种通用的设计。

1
2
3
4
5
6
7
type Employee2 struct {
Id int
Name, Address string
DoB time.Time
Position string
Salary int
}

struct类型的field不能自指,但是允许包含自己类型的指针,比如最经典的二叉树场景。

1
2
3
4
type tree struct {
value int
left, right *tree
}

struct的零值由各field零值组成,不是nil,没有field的空struct写作struct{}。不携带信息,但可能在有些地方会有用。

字面量struct

两种声明方式:

1
2
3
4
type Point struct{ X, Y int }
p := Point{1, 2}

anim := git.GIF{LoopCount: nframes}
  • 将所有fields按顺序声明,struct的fields有任何改动都需要修改,所以通常只在小规模struct以及包内部使用
  • 使用键值对方式声明,可以省略field,且对顺序不敏感

另外,在Go中,所有的函数参数传递都是传值。因此,如果函数内部需要修改struct时,不能传递struct类型,而需要传递指针。由于struct传递指针的场景比较多,所以提供了类似p := &Point{1, 1}的简写语法糖。

1
2
3
func AwardAnnualPrize(e *Employee) {
e.Salary = e.Salary * 2 + 1000
}

如果struct的所有field都具有可比性,则struct也具有可比性,可以比较是否相等。因此,struct在有些情况可以用来作为map的key。

struct嵌入与匿名域

匿名域用于struct之间的组合,可以达到类似类继承的效果。在struct声明中,如果field类型是有名称的,则可以忽略掉field名,得到一个匿名域。匿名域类型或类型内的各field对应用struct可见。有点类似TS中interfaceextends

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Point struct {
X, Y int
}
type Circle struct {
Point
Radius: int
}
type Wheel struct {
Circle
Spokes int
}

var w Wheel
w.X = 8
w.Y = 10
w.Radius = 5
w.Spokes = 20

换种说法,匿名域就是向下访问时可以省去不写的中间域。即使中间域类型是首字母小写不对外可见的,只要剩下域对外可见也可以访问。struct这种组合思想是Go在面向对象上的核心。

JSON

编解码JSON数据的方法都位于encoding/json,其中编解码整块数据的函数分别为json.Marshaljson.Unmarshal,前者传入Go数据结构,返回压缩后的JSON字符串,使用json.MarshalIndent可以返回美化后的JSON字符串。编码时,只有被导出的域才会出现在JSON字符串中。且field之后的field tag可以作为metadata修改JSON行为,如指定被JSON字符串化之后的key名。或用下面的omitempty忽略掉零值的key。

1
2
3
4
5
6
type Movie struct {
Title string
Year int `json:"released"`
Color bool `json:"color,omitempty"`
Actors []string
}

相反,在解码JSON数据时,需要显式声明struct结构来接收JSON数据。json.Unmarshal方法的第二个入参即struct的指针。在解析JSON时,对key是不区分大小写的,因此只需要对a_b类型的JSON key指定field tag

对于stream格式的JSON数据,使用json.Encodejson.Decode编解码。

HTML和文本模板

text/templatehtml/template用于文本模板和HTML模板。它们都使用双花括号包裹带有逻辑的简单语句。其中,html/template还会默认对文本做escape脱敏处理(对template.HTML不会escape)。

  • 使用template.New创建模板
  • template.Funcs向模板内插入函数
  • template.Must保证模板有内容
  • template.Parse解析模板
  • 使用模板的Execute方法生成解析后内容
1
2
3
4
5
6
7
8
9
10
11
12
var report = template.Must(template.New("issueList")).Funcs(
template.FuncMap("daysAgo": daysAgo)
).Parse(templ)
func main() {
result, err := github.SearchIssues(os.Args[1:])
if err != nil {
log.Fatal(err)
}
if err := report.Execute(os.Stdout, result); err != nil {
log.Fatal(err)
}
}

函数

声明

1
2
3
func add (x, y int) (z int) { z = x - y; return }
func first(x int, _ int) int { return x }
func zero (int, int) int { return 0 }
  • 相同类型入参可以聚合x, y int
  • 返回值为多个时,需要用()包裹
  • 返回值也可以给予变量名,这种情况下,相当于提前为返回值声明变量
  • 入参是传值,即入参会复制一份传递给函数内部,只有像slice、map、function、channel这种引用实现的类型在函数内改变会影响外部值
  • 只有函数声明,没有函数体的函数表示函数由其他语言实现,如func Sin(x float64) float64

递归

Go的递归和其他语言无异。不同的是,传统语言的函数递归借助定长的栈实现,大小从64KB到2MB不等,而Go使用变长栈实现,避免的栈溢出的情况。

多返回值

1
func Size(rect image.Rectangle) (width, height int)

Go支持同时返回多个返回值。同类型返回值可以压缩,还可以声明有名称的返回值。在多返回值时,还可以直接传递给需要多个入参的函数,

1
2
3
4
5
log.Println(findLinks(url))

// 等同于
links, err := findLinks(url)
log.Println(links, err)

返回值有名称时,会作为函数体内最外层变量出现。因此,不需要显示return返回值,这种现象也称为“裸返回”(bare return)。由于裸返回时,return后不会跟随返回值,不利于代码可读性,所以只在需要的时候使用它。

错误

函数返回错误在Go中是普遍现象。有时,错误类型只需要有1种,这时通常用bool类型的ok表示。如对map类型的变量的访问。但大多数时候,错误原因可能要有比较多种,这时可以用error类型的err表示。

在Go中较少使用exception表示失败(尽管Go也有exception机制),Go只在真正的bug处,才使用异常打印stack trace信息。在Go中较常出现的是普通的error类型,它只作为普通控制流的一部分。

处理策略

error处理由调用方负责,有5种策略:

  • 向上传递,在没有error时,可以用fmt.Errorf制造一个自定义错误信息的错误。Go建议仔细设计错误信息内容,不使用大写字母,不使用换行。建议函数的每一层补充上更多信息。
  • 重试,在有些场景下,如测试服务端连接
  • 退出,严重问题时,可以用os.Exit(1)退出,或者用log.Fatalf打印错误信息后退出
  • 打印日志后继续,对于简单问题,可以打印日志后继续流程
  • 忽略,在特殊情况下,可以直接忽略,如错误确实不会影响功能实现

Go建议是使用函数时考虑错误处理的场景。

EOF

EOF(End Of File)是一种特殊的错误类型,io.EOF表示输入流没有更多内容了。

1
2
3
4
5
6
7
8
in := bufio.NewReader(os.Stdin)
for {
r, _, err := is.ReadRune()
if err == io.EOF {
break
}
// ...
}

作为值的函数

这一章很类似JS或TS

Go中函数是一级成员。这意味着,它可以作为一种类型,传递给变量、入参或者返回,就像其他值的类型一样。函数是一种引用类型,所以可以为nil,但是执行nil会导致panic

1
2
var f func(int) nil
f(2) // call of nil panic

再次基础上,就可以对函数做更灵活而精准的设计,拆分函数关注点和抽象层次。构造出更灵活的程序。以strings.Map为例

1
2
func add1(r rune) rune { return r + 1 }
fmt.Println(strings.Map(add1, "Admin")) // Benjo

匿名函数

Go中只能在包级别声明有名函数,而匿名函数可以在块作用域、函数作用域内声明。因此,高阶函数闭包等概念Go中也有。由于这些概念JS中也有,这里就不再赘述。

循环变量捕获

JS也有类似问题,不过原因不同

1
2
3
4
5
6
7
8
// 一段会有问题的代码
var rmdirs []func()
for _, dir := range tempDirs() {
os.MkdirAll(dir, 0755)
rmdirs = append(rmdirs, func() {
os.RemoveAll(dir)
})
}

上面的for循环中,循环变量dirappend的回调中有使用,我们回忆一下,for循环中循环变量位于for语句块外,在整个for循环后才销毁。所以这会导致每一个回调执行时,dir都被更新为最新的值。将dir在循环体内再次赋值即可。

1
2
3
4
5
6
7
8
9
var rmdirs []func()
for _, dir := range tempDirs() {
// 可以运行
dir := dir
os.MkdirAll(dir, 0755)
rmdirs = append(rmdirs, func() {
os.RemoveAll(dir)
})
}

变长参数

1
2
3
4
5
6
7
func sum(vals ...int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}

类似JS中的剩余参数,Go中也使用rest ...type表示函数的剩余入参。rest需要声明类型,rest为slice类型。要注意的是,这种函数和直接传入一个slice参数的函数类型并不一样。另外,在剩余参数类型不明确时,可以用interface{}表示。

1
2
3
// 在变量后使用`...`表示解构
val := []int{1,2,3}
fmt.Println(sum(values...))

延迟函数调用(Deferred Function Calls)

在语句前加上defer标识符,会让defer后的函数调用推迟到所在函数的**return之后**执行。defer后的函数和表达式会立即求值。defer的函数调用在函数panic后仍然会被调用。可以用来执行一些释放资源的操作,如以下场景:

  • open和close
  • connect和disconnect
  • lock和unlock
1
2
3
4
5
6
7
var mu sync.Mutex
var m = make(map[string]int)
func lookup(key string) int {
mu.Lock()
defer mu.Unlock()
return m[key]
}

最合适的使用时机是在刚刚获得资源之后。还可以利用defer完成进入离开函数的成对操作做一些调试。

1
2
3
4
5
6
7
8
9
10
11
12
func bigSlowOperation() {
defer trace("bigSlowOperation")()
// ...
time.Sleep(10 * time.Second)
}
func trace(msg string) func() {
start := time.Now()
log.Printf("enter %s", msg)
return func() {
log.Printf("exit %s (%s)", msg, time.Since(start))
}
}

由于defer在函数最后执行的特点,甚至可以在defer中获取和修改函数返回值

1
2
3
4
5
func triple(x int) (result int) {
defer func() { result += x }()
return double(x)
}
fmt.Println(triple(4)) // "12"

同时也由于defer的这个特点,在for循环中使用defer一定要谨慎。

panic

Go中的panic类似于其他语言的exception,它一般代表程序中存在bug和不应该出现的情况。panic后,正常程序执行停止,defer的函数被倒序执行,然后函数崩溃并带有错误信息。

除了系统触发的panic,还可以直接通过panic("certain message")手动触发一个panic。一些包中以Must开头的API通常表示,在不符合规范的时候API会panic。建议只在内部可信任环境下使用这种API

recover

就像其他语言中的try catch一样,Go中的panic同样有机制去妥善处理。Go有内置的recover函数,可以用于在panic中恢复。

  • recover需要在defer的函数中使用
  • recover函数会返回panic的value,在没有panic的情况下,该函数返回nil

下面是一些使用recover的建议:

  • 不要毫无条件地从panic中recover,这可能会掩盖一些潜在的bug或资源泄露
  • 在panic后,可以使用runtime.Stack这样的方法打印一下错误的详细信息,再recover
  • 可以定义一些外部不可见的类型,在调用panic时传入,在recover返回时判断类型,从而做到针对特定情况panic执行recover,其余情况仍旧panic
  • 对于预期中的error不使用panic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func soleTitle(doc *html.Node) (title string, err error) {
type bailout struct{}
defer func() {
switch p := recover(); p {
case nil:
// no panic
case bailout{}:
err = fmt.Errorf("multiple titles")
default:
panic(p) // resume panic
}
}()
// ...
}

方法

Go也有OOP的特性,即对象上具有方法,方法需要关联在一个特定类型上。

声明

1
2
3
4
5
6
import "math"

type Point struct{ X, Y float64 }
func (p Point) Distance(q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}

声明里,在普通声明的函数名前,增加函数绑定的类型receiver,即完成了方法的声明。Go中没有thisself这样的保留字。类型receiver中的变量,即方法可以访问的类型变量。变量名由于会比较常用,所以通常取类型首字母。

其他方法的行为类似其他OOP语言:诸如方法名和函数名不在一个命名空间,所以可以重名;方法名之间不能重名;方法名不能和属性名相同。由于Go中声明命名类型比较自由,而方法可以很方便绑定在命名类型上,所以可以给基础类型,如数字、字符串等,增加新方法。

指针receiver

上面提到,访问方法需要一个receiver。除了变量本身,指针也可以作为receiver。在Go中函数入参都是传值的,也就是传入值的复制。所以除了map、slice这种引用类型,其余类型的值在方法内改变并不会影响到外部。如果需要方法改变receiver本身的话,可以指定将方法绑定在指针类型上。

1
2
3
4
5
6
7
8
func (p *Point) ScaleBy(factor float64) {
p.X *= factor
p.Y *= factor
}

t := &Point{1, 2}
r.ScaleBy(2)
fmt.Println(*r)

通常会规定,如果类型上有方法是指针类型的receiver,所有的方法都需要有一个指针类型receiver。类似struct一节介绍的,如果变量具有类型T,而方法的receiver是*T,我们可以直接使用简写的p.ScaleBy(2)而不需要写成(&p).ScaleBy(2)。相反地,receiver要求类型T,变量传入*T也是可以的。

但是,直接将字面量传入给指针类型的receiver是不允许的,如Point{1, 2}.ScaleBy(2)

Nil是合法的Receiver

Go中,nil在很多时候是合法的零值。同样也可以作为receiver。当然在你的命名类型中,最好对合法的nil类型加以说明。Go的内置类型和操作,如slice,map、struct、append、make等也可以正常地处理nil

组合和struct embedding

在此前的struct一节中,已经介绍了Go的struct embedding设计。这里结合方法继续讨论一下。首先我们先回忆下struct embedding是啥。

1
2
3
4
5
6
7
8
9
10
11
12
13
import "image/color"

type Point struct{ X, Y float64 }
type ColoredPoint struct {
Point
Color color.RGBA
}

var cp ColoredPoint
cp.X = 1
fmt.Println(cp.Point.X)
cp.ScaleBy(2)
fmt.Print;n(cp.Y)

匿名的field会直接将field类型中的成员和方法组合(composite)进当前类型中(和TS中的extends有点像)。如上面的ColoredPoint就直接拥有了PointDistanceScaleBy功能(当然也可以访问Point)。Go更希望用组合(composition)而非派生(derivation)构造更复杂的类型。比如,上面的ColoredPoint并不是一个Point,并不能当做一个Point访问和使用。

匿名field如果是指针类型,除了上面的特性,还能实现让两个变量共享一个底层的结构。

1
2
3
4
5
6
7
type ColoredPoint struct {
*Point
Color color.RGBA
}
p := ColoredPoint{&Point{1, 1}, red}
q := ColoredPoint{&Point{3, 4}, blue}
p.Point = q.Point

在访问receiver上的方法时,Go首先会去直接声明的field中寻找,然后再去embedded的field中寻找,再向下寻找。方法只能在命名类型和其指针类型上定义,但是借助struct embedding也可以实现,将功能聚合在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var (
mu sync.Mutex
mapping = make(map[string]string)
)
func Lookup(key string) string {
mu.Lock()
v := mapping[key]
mu.Unlock()
return v
}

// 重构后
var cache = struct {
sync.Mutex
mapping map[string]string
} {
mapping: make(map[string]string),
}

func Lookup(key string) string {
cache.Lock()
v := cache.mapping[key]
cache.Unlock()
return v
}

上面重写之后的代码表现力明显更好了。

方法值(method value)和方法表达式(method expression)

1
2
3
4
5
6
p := Point{1, 2}
q := Point{4, 5}
distanceFromP := p.Distance
fmt.Println(distanceFromP(q))

time.AfterFunc(10 * time.Second(), r.Launch)

p.Distance会得到一个method value,它是一个绑定到了特定receiver上的一个方法,本身也是一个函数。可以当做函数类型的值用作入参或返回值。这个和JS还比较像。

类似的,Go中还有method expression的概念。即直接用类型名加点(.)访问方法得到一个method expression。它也是一个函数,可以看做是一个没有绑定receiver的方法。调用函数时,传入的第一个入参会当做receiver,后续的作为方法入参。这个特性在需要根据情况灵活选择方法时很好用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (p Point) Add(q Point) Point { return Point{p.X + q.X, p.Y + q.Y} }
func (p Point) Sub(q Point) Point { return Point{p.X - q.X, p.Y - q.Y} }

type path []Point
func (path Path) TranslateBy(offset Point, add bool) {
var op func(p, q Point) Point
if add {
op = Point.Add
} else {
op = Point.Sub
}
for i := range path {
path[i] = op(path[i], offset)
}
}

一个实例:bitset

  • bytes.Buffer经常用来拼接字符串
  • fmt的print打印字符串时会调用变量的String方法

封装

首先,Go的封装细粒度只到package一层,package内不控制可见性。所以当你想要控制可见性时,需要用拆分package实现。

封装用来掩盖不需要像使用者展示的信息和细节。Go中唯一用来控制可见性的手段是一个大小写约定:大写表示从包中导出,小写表示包内可见,对于struct中的field以及类型的method也是如此(包外访问不了,包内随便访问)。通常来讲,当需要封装对象时,我们都会使用struct

1
2
3
4
5
6
// 建议
type IntSet struct {
words []uint64
}
// 不建议
type IntSet []uint64

使用struct封装本来就很简单的类型有以下几个原因:

  • 使用方无法修改对象值,这样只用查阅更少的声明,就能得到对象值大致的可能范围
  • 对使用方掩盖实现细节,可以避免让使用方依赖那些可能改变的内容,也给开发者重构空间,开发者可以在不改变API兼容性的情况下灵活调整内部实现
  • 避免使用方任意修改对象值,造成更多边缘情况,提高程序编写难度和程序不稳定性

有时,封装也会暴露出一些gettersetter。通常命名上,会直接使用field的首字母大写形式,省去不必要的GetFetchLookup前缀。实际上,Go并不禁止导出field,只是在有些情况下,导出field会影响代码可靠性。

封装并不总是必要的。有时,底层数据结构是目标数据结构的充要表示,不多不少,场景变数不多,这时可以不用struct封装。但是,像IntSet这种,实现细节变数多,程序实现稳定性低,需要被保护起来,避免影响使用者。从而要采取封装的方式,把那些变数变得“不透明”。

接口(interface)

如其他OOP语言一样,Go中也有用于标识抽象类型的接口描述。不同的是,Go中的接口都是隐式满足的,松耦合。

作为约定的接口

之前介绍过的所有类型都是具体类型(concrete type),具体类型指数据表示和行为实现在类型确定后,就已一清二楚。为了保证语言灵活性,Go中还有接口类型(interface type)。这种类型不暴露内在结构和实现细节,而是给出接口输入输出,作为一种约定交由具体实现方完成,从而实现依赖反转(DI)。这一概念的设计上和其他OOP语言无二。不过在使用上,Go并不要求实现方明确依赖关系,只需实现约定即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package fmt

func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)

func Printf(format string, args ...interface{}) (int, error) {
return Fprintf(os.Stdout, format, args...)
}
func Sprintf(format string, args ...interface {}) string {
var buf bytes.Buffer
Fprintf(&buf, format, args...)
return buf.String()
}

package io

type Writer interface {
// ...comments
Write(p []byte) (n int, err error)
}

同样的,实现String方法也让类型隐式满足了fmt.Stringer的定义。Go中单方法interface的命名,通常以动词的名词形态为主。

1
2
3
4
5
package fmt

type Stringer interface {
String() string
}

接口中也有类似struct embedding的嵌入式写法,简化interface的组合成本。另外,interface中方法的顺序不影响interface类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
package io

type Reader interface {
Read(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}

type ReadWriter interface {
Reader
Writer
}

接口的满足

Go中变量可以是接口类型,在给接口类型变量赋值时,需要检查值的方法是否满足了接口的类型定义,这一点和其他OOP语言相似。要注意,尽管Go有将变量T转成*T的隐式转换,但是类型T的方法和*T的方法receiver并不同。而通常具体类型中会在接口声明的方法中做一些写操作,因而指定receiver为指针类型,这有可能会导致无法满足接口定义。

接口覆盖了其包裹的内部类型,所以,即使内部类型满足其他方法,赋值给接口类型后,也只能方法接口拥有的方法

1
2
3
4
var w io.Writer
w = os.Stdout
w.Write([]byte("hello")) // OK
w.Close() // compile error: io.Writer lacks Close method

Go中还有一个通用的不可或缺的类型interface{}它表示对类型没有任何要求,同时也意味着该类型变量上无法执行任何操作,类似ts中的Unknown

1
2
3
4
5
var any interface{}
any = true
any = 12.34
any = "hello"
any = new(bytes.Buffer)

Go中具体类型对接口类型的满足都是隐式的,无需显式声明。所以一个具体类型可能会同时满足很多接口类型。可以把接口类型认为是将一些具体类型中公共的部分抽象出来的共同行为,将之作为grouping出来的共性。

使用flag.Value解析命令行参数

  • fmt.Sscanf可以从输入中按格式解析出特定类型参数

接口值

1
2
3
4
var w io.Writer
w = os.Stdout
w = new(bytes.Buffer)
w = nil

Go中,接口类型可以作为变量的合法类型。接口类型值因此具有动态类型动态值。在Go中可以近似用类型描述符(type descriptor)表示,其中type表示具体类型,value表示具体值。在初始化时,type和value都是nil

1
2
3
4
5
        +-----------+
type | nil |
+-----------+
value | nil |
+-----------+

而在第二和第三行,为w赋值为os.Stdout以及*bytes.Buffer类型时,type分别会变成os.Stdout*bytes.Buffer,同时value也会被设置为对应初始值的指针。这个过程会完成类似于io.Writer(os.Stdout)的隐式类型转换。此时访问w的方法,会被动态分配到value上实现。而在最后又将w还原为初始值nil

接口类型之间不一定可以比较,当接口值都为nil或接口值对应的具体类型相同以及具体值相同时,接口值相同。然而,如果具体类型不可比较时(如slice,function等),接口类型也不可比较。Go的fmt中,可以用%T打印变量类型。

陷阱:nil值可以存在于非nil的接口值中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const debug = true
func main() {
var buf *bytes.Buffer
if debug {
buf = new(bytes.Buffer)
}
f(buf)
if debug {
// ...
}
}

func f(out io.Writer) {
if out != nil {
out.Write([]byte("woops!\n")) // panic: nil pointer derefence
}
}

在上面的判断中,out已经有了具体的类型,因此接口类型的out不等于nil,然而out的具体值却是nil,这使得Write行为无法保证。解决办法是,在一开始为buf声明为io.Writer类型即可。

sort.Interface

Go使用sort包中的sort.Interface实现排序功能。同时对于常见类型string、int等也有事先封装好的sort.Strings()sort.Int()。对于自定义类型,在实现sort.Interface接口后,也可使用sort.Sort排序。接口定义如下:

1
2
3
4
5
6
7
package sort

type Interface interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}

三个方法分别用来返回长度比较大小交换顺序。这也是排序的几个基本操作。下面给出了字符串排序的内部实现:

1
2
3
4
5
type StringSlice []string

func (s StringSlice) Len() int { return len(s) }
func (s StringSlice) Less(i, j int) bool { return p[i] < p[j] }
func (s StringSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }

在排序struct等复杂类型slice时,建议定义指针类型数组,这样可以让swap时速度更快

sort还有一个方便的反向排序方法sort.Reverse,它借助了struct embedding,用一个内部类型reverse封装了外部实现接口的类型,另外,直接在reverse上定义了Less,覆盖了Interface的实现,从而实现了反向排序:

1
2
3
4
5
6
package sort
type reverse struct { Interface }
func (r reverse) Less (i, j int) bool { return r.Interface.Less(j, i) }
func Reverse(i Interface) Interface { return reverse{i} }

sort.Sort(sort.Reverse(byArtist(tracks)))

除了slice类型外,其他实现了sort.Interface接口的类型一样可以排序:

1
2
3
4
5
6
7
8
type customSort struct {
t []*Track
less func(x, y *Track) bool
}

func (x customSort) Len() int { return len(x.t) }
func (x customSort) Less(i, j int) bool { return x.less(x.t[i], x.t[j]) }
func (x customSort) Swap(i, j int) { x.t[i], x.t[j] = x.t[j], x.t[i] }

http.Handler接口

1
2
3
4
5
6
package http

type Handler interface {
ServeHTTP(w ResponseWriter, r *Request)
}
func ListenAndServe(address string, h Handler) error

实现了这个接口的可以传递给ListenAndServe。但通常用不到这种原始的方式。Go的http包提供的ServeMux类型可以给请求分路,聚合一堆http.Handlers。写起来像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
db := database{"shoe": 50, "socks": 5}
mux := http.NewServeMux()
mux.Handle("/list", http.HandlerFunc(db.list))
mux.Handle("/price", http.HandlerFunc(db.price))
}
type database map[string]int
func (db database) list(w http.ResponseWriter, req *http.Request) {
for item, price := range db {
fmt.Fprintf(w, "%s: %s\n", item, price)
}
}
func (db database) price(w http.ResponseWriter, req *http.Request) {
// ...
}

其中http.HandlerFunc将传入的函数包裹成了满足Handler接口的类型。

1
2
3
4
5
6
7
package http

type HandlerFunc func(w ResponseWriter, r *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}

对于上面的使用还是要写一些模板代码,对此可以将mux.Handler简写成mux.HandlerFunc("list", db.list)。可以再减少一点代码。实际上,http还提供了一个全局的ServeMux对象实例DefaultServeMux,不需要手动创建。

1
2
3
4
5
6
func main() {
db := database{"shoes": 50, "socks": 5}
http.HandlerFunc("/list", db.list)
http.HandlerFunc("/price", db.price)
log.Fatal(http.ListenAndServe("localhost:8000", nil)
}

go中每一个handler都在一个单独的goroutine上,要妥善处理好并发的情况。

error接口

1
2
3
type error interface {
Error() string
}

error类型实现了error接口。整个errors包都围绕这个接口设计,除了errors.New()方法,还可以直接通过fmt.Errorf返回一个格式化后的error值。

1
2
3
4
5
6
7
8
9
10
11
12
package errors
func New(text string) error { return &errorString{text} }
type errorString struct { text string }
func (e *errorString) Error() string { return e.text }

package fmt

import "errors"

func Errorf(format string, arags ...interface{}) error {
return errors.New(Sprintf(format, args...))
}

简单的数学表达式求值器

递归的AST解析

略。

类型断言

类型断言(type assertion),写作x.(T),通常用来将动态类型限定到更严格的类型。

  • T是具体类型时,会判断x类型是否和T一致,是则将x类型设置为T,否则panic
  • T是抽象类型interface时,会判断x是否满足T接口,是则将x类型设置为接口T,否则panic
1
2
3
4
5
6
7
var w io.Writer
w = os.Stdout
f := w.(*os.File)
c := w.(*bytes.Buffer) // panic

w = new(ByteCounter)
rw := w.(io.ReadWriter) // panic

当对nil进行类型断言时时,断言一定失败。另外,类型断言可以支持第二个返回参数ok表示是否成功,此时不会panic。

1
2
3
if w, ok := w.(*os.File); ok {
// ...use w...
}

应用:错误类型区分

借助类型断言,可以将判断抛出的具体错误类型,os包提供了IsExistisNotExistisPermission用来区分文件已存在,文件不存在,不允许几种错误。我们以文件不存在为例,此时抛出的PathError类型错误包含了具体的错误类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
type PathError struct {
Op string
Path string
Err error
}
// 满足Error接口
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}

_, err := os.Open("/no/such/path")
fmt.Printf("%#v\n", err)
// Output: &os.PathError{Op:"open", Path:"/no/such/path", Err:0x2}

使用断言后,就可以从err中拿到具体错误类型,从而判断是否是文件不存在导致的:

1
2
3
4
5
6
7
8
9
10
import (
"errors"
"syscall"
)
func IsNotExist(err error) bool {
if pe, ok := err.(*PathError); ok {
err = pe.Err
}
return err == syscall.ENOENT || err == ErrNotExist
}

另外,建议在错误抛出时就进行检测,在聚合后,原始错误的数据结构可能会丢失从而无法判断。

方法查询

抽象类型如io.Writer可能缺少使用者需要的方法如io.WriteString(尽管满足io.Writer的大多数具体类型除了必须满足的Write方法外,都对写入字符串支持了WriteString方法)。

可以定义一个临时接口类型,判断满足抽象类型的变量是否具有指定方法。因为Go中接口的满足是隐式的(类似鸭子类型),不像许多强类型语言一样,需要显式声明。之前使用弱类型语言的可能能很好接受。

1
2
3
4
5
6
7
8
9
func writeString(w io.Writer, s string) (n int, err error) {
type stringWriter interface {
WriteString(string) (n int, err error)
}
if sw, ok := w.(stringWriter); ok {
return sw.WriteString(s) // 更有效率的方案
}
return w.Write([]byte(s)) // 兜底方案
}

实际上,fmt.Sprintf打印不同类型的变量时,也借助了类型断言,对于特定类型调用特定方法,最后再使用反射处理其他类型。

1
2
3
4
5
6
7
8
9
10
11
package fmt

func formatOnValue(x interface{}) string {
if err, ok := x.(error); ok {
return err.Error()
}
if str, ok := x.(Stringer); ok {
return str.String()
}
// ...
}

Type switch

interface除了之前说的让多个具体类型有一致表现的用法外,还可以作为可区分具体类型的合集来使用。这种时候需要结合type switch的用法。如下所示:

1
2
3
4
5
6
7
switch x.(type) {
case nil: //...
case int, uint: //...
case bool: //...
case string: //...
default: //...
}

通常在确定了x的类型后,还需要直接使用x。此时可以写作switch x:= x.(type)

这种用法和之前的用法不同在于:这里接口不作为有一致表现而存在,它只是用来暂存将要区分开的具体类型,而这些具体类型往往时有不同表现的。所以这种用法里的接口几乎没有方法。换一种说法,之前的用法里,接口背后的具体类型细节需要被掩盖来使用,而这里需要使用具体类型的细节。

一些忠告

和上一章方法类似,接口是一种很好使用的面向对象的特性。但不建议上来就从定义一堆接口开始,这样通常会产生一大堆只有一个具体类型实现的接口。接口是抽象类型,是通过具体类型抽象得来的。通常是在需要用统一的方式处理不同类型时,拿来使用。

同时,大多数Go程序中,接口往往小且包含比较少的方法。像是io.Writerfmt.Stringer。和方法一章一样,它们虽然是面向对象的特性,但是不是Go中一定要使用的语言特性。只在需要的时候使用。大多数时候,直接使用函数就足够了。在书中,方法如input.Write的使用就远不如函数如fmt.Printf来得频繁。

goroutine和信道

Go支持两种并发编程的风格,第一种在本章介绍,通过goroutines和channels支持通信顺序进程(Communicating sequential processes,CSP),这种情况下,值会在goroutine间来回传递,而变量在多数情况下被限制自单个活动中。下一章介绍的共享变量风格的并发编程更接近传统的并发风格。

认识goroutine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
go spinner(100 * time.Millisecond)
const n = 45
fibN := fib(n) // slow
fmt.Printf("\rFibonacci(%d) = %d\n", n, fibN)
}

func spinner(delay time.Duration) {
for {
for _, r = range `-\|/` {
fmt.Printf("\r%c", r)
time.Sleep(delay)
}
}
}
func fib(x int) int {
if x < 2 {
return x
}
return fib(x-1) + fib(x-2)
}
  • goroutine类似线程,有着定量而非定性的差异
  • main函数也会启动一个main goroutine
  • goroutine通过go启动一个函数或方法调用,并在声明后立即返回
  • 除了main函数返回或程序结束(os.Exit)外,一个goroutine没有办法直接停止另一个,但可以通过传值的方式间接实现。

简单示例

服务器处理请求是最典型的并发场景。

1. Clock Server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
"io"
"log"
"net"
"time"
)

func main() {
listener, err := net.Listen("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Print(err)
continue
}
handleConn(conn) // go handleConn(conn)
}
}

func handleConn(c net.Conn) {
defer c.Close()
for {
_, err := io.WriteString(c, time.Now().Format("15:04:05\n"))
if err != nil {
return // e.g. client disconnect
}
time.Sleep(1 * time.Second)
}
}
  • listener.Accept会在接收到TCP连接请求前一直阻塞
  • time.Format方法通过一个特殊的样例(15:04:05)表示要格式化的格式,time.Parse也是如此
  • client端可以用net.Dial发起一个TCP连接请求

上述的服务端是串行处理client的请求,并每秒打印当前时间,在handleConn(conn)前加上go关键字后,即可让服务端并行处理client的请求。

2. Echo Server

上面的例子是在一个连接中使用一个goroutine,当然每个连接也可以创建多个goroutine。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// server
func echo(c net.Conn, shout string, delay time.Duration) {
fmt.Fprintln(c, "\t", strings.ToUpper(shout))
time.Sleep(delay)
fmt.Fprintln(c, "\t", shout)
time.Sleep(delay)
fmt.Fprintln(c, "\t", strings.ToLower(shout))
}
func handleConn(c net.Conn) {
input := bufio.NewScanner(c)
for input.Scan() {
echo(c, input.Text(), 1*time.Second) // go echo(...)
}
c.Close()
}

// client
func main() {
conn, err := net.Dial("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
go mustCopy(os.Stdout, conn)
mustCopy(conn, os.Stdin)
}

echo前加上go即可让服务器同时相应多个请求,返回“回声”。同理,在client端打印服务端返回的代码前加上go即可让使用者输入的同时打印返回的“回声”。

信道(channel)

go启动并行的活动,信道作为活动间通信的通道,借助它可以发送和接收消息。信道通过make构造,需要指定传输消息的类型,作为信道类型。可以使用close关闭信道。后续的发送操作会panic,接收操作会得所有到已发送的值,而再之后的后续接收操作只能得到信道类型对应的零值

1
2
3
4
5
6
7
8
ch := make(chan int)

ch <- x
x = <-ch
// 直接丢弃channel来的值
<-ch

close(ch)

信道还分为有缓冲区和无缓冲区两种类型,上述的make构造的都是无缓冲区的信道,指定第二个参数可以构造有缓冲区的信道。

1
2
3
ch = make(chan int) // 无缓冲区
ch = make(chan int, 0) // 无缓冲区
ch = make(chan int, 3) // 有缓冲区

无缓冲信道(Unbuffered Channels)

向无缓冲区发送消息阻塞发送所在的goroutine,直到对应的goroutine在同一个信道上执行接收操作。相反地,接收消息在先的话,也会阻塞直到同一个信道上执行了发送操作。这种机制会同步两个goroutine的执行进度。如果发送信息在先,则接收信息会在发送所在的goroutine之前发生。从而,我们可以基于这个假设的前提保证一些事实。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
conn, err := net.Dial("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
done := make(chan struct{})
go func() {
io.Copy(os.Stdout, conn)
log.Println("done")
done <- struct{}{} // signal the main goroutine
}()
mustCopy(conn, os.Stdin)
conn.Close()
<-done // wait for background goroutine to finish
}

上述程序里,会在接收完服务端返回后,才会关闭客户端。这里需要的是一个事件,使用的信道类型其实并不重要,所以使用了struct{}。实际应用中会使用bool或是int这样的简单类型。

流水线

借助上面提到的无缓冲区信道,可以实现多个goroutine之间的接续传递,也可以叫做流水线

1
2
3
+-----------+         +-----------+         +-----------+
| Counter | --> | Squarer | --> | Printer |
+-----------+ +-----------+ +-----------+
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
naturals: make(chan int)
squares: make(chan int)
// Counter
go func() {
for x := 0; x < 100 ;x++ {
naturals <- x
}
close(naturals)
}()
// Squarer
go func() {
for {
x := <-naturals
squares <- x * x
}
}
for {
fmt.Println(<-squares)
}
}

上面的流水线中,Counter在打印100个自然数后,会关闭信道。会有之前所说的一些特性:

  • 向关闭信道写入消息会panic
  • 从关闭信道读取信息会得到所有未发送的消息,再之后只能得到零值
  • 关闭信道不会影响其他goroutine执行

所以在上面的程序中,Printer会继续打印0,只有Counter正常退出。Go中没有直接的获取信道是否关闭的方法,但是对于从信道中读取消息有第二个ok参数,为false时表示信道已关闭且读取完所有消息

1
2
x, ok := naturals
if !ok {}

上面的模板代码,go用range已封装好,不必重复书写。

1
2
3
4
5
6
7
8
go func() {
for x := range naturals {
squares <- x * x
}
}()
for x := range squares {
fmt.Println(x)
}

不是所有信道在不用后都要显式关闭,只在需要传达信道关闭信息时再手动close关闭。其余的信道会在gc过程中回收。但这不意味着文件读取也可以不显式关闭:文件的读写操作后一定要执行关闭操作。关闭一个已关闭的信道会panic,关闭nil的信道也是一样。

单向信道

以上一小节为例,有三个goroutine,函数签名如下:

1
2
3
func counter(out chan int)
func squarer(out, in chan int)
func printer(in chan int)

其中的信道入参分别用来接收发送消息(绝大多数信道也是如此)。因此对于这两种信道的细分,go类型系统提供了单向信道类型,即只读或只写。同时提供了类型助记符:

  • chan<-表示只读,只可读取消息,不可关闭
  • <-chan表示只写,只可发送消息和关闭

违背只读只写上述规则,会在编译期间抛出错误。同时,双向信道可以隐式covert到单向信道,反之不可以

1
2
3
4
5
6
7
func main() {
naturals := make(chan int)
squares := make(chan int)
go counter(naturals)
go squarer(squares, naturals)
printer(squares)
}

缓冲信道(Buffered Channel)

1
ch = make(chan string, 3)

可以用队列类别缓冲信道,不同的是缓冲信道和goroutine是紧密相连的。

  • 写操作会在队列充满时阻塞
  • 读操作会在队列为空时阻塞

通过caplen可以查看缓冲信道的实时容量和长度。虽然缓冲信道可以按队列去理解,但是不要把它拿去当队列来用。那么和无缓冲信道相比,缓冲信道应用场景有什么不同呢?

我们用流水线举例,流水线上的各道工序复杂程度有难有易,如果工作空间有限,每一道工序后都需要在下一道工序空闲时才能交付,一些简单工序就需要等待。这时就像无缓冲信道。假设工作空间宽裕,每道工序完成后,如果下游还未就绪,可以先放在空闲空间下,直接继续工作。这就是缓冲信道,多出来的工作空间即缓冲区,工序即goroutine。缓冲区可以弥补上下游工序工作效率的些微差异,缓冲区越大,可以容忍的效率差异就越大。如果工序间有明显差异,比如始终更快或更慢,此时增加缓冲区无法提供帮助,可以采用增加工序工人来提高工作效率,即在同一信道上使用更多goroutine

从上面的比喻,可以得出两种信道的区别:

  • 无缓冲信道重点在同步,它可以确保上下游goroutine的同步性
  • 缓冲信道则使用了队列来解耦上下游goroutine,使之不因为阻塞影响工作效率

所以,我们假设有多个网站镜像来为网络请求提供服务,就可以使用缓冲信道,优先响应的可以直接提供服务,且在响应后可以继续工作。

1
2
3
4
5
6
7
8
func mirroredQuery() string {
responses := make(chan string, 3)
go func() { responses <- request("asia.gopl.io") }()
go func() { responses <- request("europe.gopl.io") }()
go func() { responses <- request("america.gopl.io") }()
return <-responses
}
func request(hostname string) (res string) { /* ... */ }

并行循环

有些任务可以拆分成等效的相互独立的小任务,这种情况也被称为“令人尴尬的并行”,是最简单的并行工作场景,它的工作量和并行数呈线性关系。我们假设有一个并行处理图片缩小的程序,能返回缩小后的文件总体积,并在合适的时候停止。在程序编写过程中,会遇到一些问题:

  • 有错误出现时,未关闭剩余信道,导致goroutine泄露,并造成程序不响应或内存耗尽
  • for循环结合延迟执行代码时,循环描述体中的变量陷阱
  • 要支持任意长度的图片列表,不能写死缓存信道的容量
  • 无从直接得知goroutine是否执行完成

最终得到下面的最终版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func makeThumbnails(filenames <-chan string) int64 {
sizes := make(chan int64)
var wg sync.WaitGroup
for f := range filenames {
wg.Add(1)
// worker
// avoid loop variable pitfall
go runc(f string) {
// 记录任务完成
defer wg.Done()
thumb, err := thumbnail.ImageFile(f)
if err != nil {
log.Println(err)
return
}
info, _ = os.Stat(thumb)
sizes <- info.Size()
}
}
// closer
// 必须写成goroutine的形式
go func() {
wg.Wait()
close(sizes)
}()
var total int64
for size := range sizes {
total += size
}
return total
}

上面程序里面有几点需要特别说明:

  • 第一个for循环会将信道输入的图片文件列表转成任务的列表,然后再启动一个goroutine负责关闭信道,最后从信道中拿出所有的大小加总返回
  • 关闭函数必须写成goroutine的形式。因为sizes的range结束依赖于sizes信道的关闭,同时sizes信道又必须等待所有图片处理任务执行完之后再关闭。等待和加总图片大小需要并行,所以需要一个新的goroutine去做
  • 任务完成借助sync.WaitGroup完成,wg.Wait()会阻塞直到wg.Done()将所有任务清零

样例:并发web爬虫

将第5章中的worklistslice改为channel,让爬取网页内容的过程并发执行即可得到一个并发的web爬虫。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func crawl(url string) []string {
fmt.Println(url)
list, err := links.Extract(url)
if err != nil {
log.Print(err)
}
return list
}
func main() {
// 无缓冲信道,也可以使用缓冲信道
worklist := make(chan []string)
// 初始化channel
go func() { worklist <- os.Args[1:] }()

seen := make(map[string]bool)
// 从channel中读取
for list := range worklist {
for _, link := range list {
if !seen[link] {
seen[link] = true
go func(link string) {
worklist <- crawl(link)
}(link)
}
}
}
}

上面的程序在执行了一段时间后,会因为客观限制出现报错信息。这是因为程序过于并发了。由于硬件资源的限制,当并发数超过一定界限后,程序性能反而不如以前甚至会无法运行。因此需要手动限制并发量。这里有两种思路:

  • 通过限制发放许可证(token)的方式限制爬取goroutine是否执行,许可证数量有限,许可证用完后,阻止goroutine执行。当然作为信道的token,是在多个爬取goroutine间共享的。
  • 限制爬取goroutine总数,只创建固定个数的goroutine
1
2
3
4
5
6
7
8
// 信号量,占有表示被使用中
var tokens = make(chan struct{}, 20)
func crawl(url) []string {
fmt.Println(url)
token <- struct{}{}
list, err := links.Extract(url)
<-tokens
}

将上面代码main函数中的worklist延迟在for循环内赋值,使用n记录当前任务中的正在执行的任务数,可以实现在所有任务执行完成后退出程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func main() {
// ...
var n int
n++
// ...
seen := make(map[string]bool)
for ; n < 0; n-- {
list := <-worklist
for _, link := range list {
if !seen[link] {
seen[link] = true
n++
go func(link string) {
worklist <- crawl(link)
}(link)
}
}
}
}

select实现多工

在之前的例子里,从信道中读取/写入值,会阻塞当前goroutine进度。如果需要同时接收两个信道的值,需要select语句块。select语句块使用类似switch

1
2
3
4
5
6
7
8
9
10
select {
case <- ch1:
// ...
case x := <- ch2
// ...
case ch3 <- y:
// ...
default:
// ...
}

每一个case可以是接收或是发送消息的语句,select语句在其中一个case发生后,才会继续(select{}会一直等待程序执行)。default可以指定没有任何一个case发生时的处理方式。

原文中给出的time.Tick例子会返回一个channel,并以设定的时间间隔发送消息。但是,再不从channel读取信息后,会造成goroutine泄露。因此只在整个生命周期都需要时才会使用。倒计时这种场景下建议使用更复杂的方式:

1
2
3
ticker := time.NewTicker(1 * time.Second)
<- ticker.C
ticker.Stop()

对于一个nil信道的发送和接收会一直阻塞,select中的case也不会被选中。利用这个特性可以实现取消等功能。

并发目录遍历

借助ioutil.ReadDir可以实现遍历根文件夹下所有文件体积的功能。下面是一个纯单线程版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func walkDir(dir string, fileSizes chan<- int64) {
for _, entry := range dirents(dir) {
if entry.IsDir() {
subdir := filepath.Join(dir, entry.Name())
walkDir(subdir, fileSizes)
} else {
fileSIzes <- entry.Size()
}
}
}
func dirents(dir string) []os.FileInfo {
entries, err := ioutil.ReadDir(dir)
if err != nil {
fmt.Fprintf(os.Stderr, "du1: %v\n", err)
return nil
}
return entries
}

上面的版本可以实现功能,但是速度很慢,而且不能实时显示进度。这里我们用time.Ticker定时打印进度,同时通过命令行参数p控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var progress = flag.Bool("v", false, "show progress")
func main() {
// ...
var tick <-chan time.Time
if *progress {
tick = time.Tick(500 * time.Millisecond)
}
var nFiles, nBytes int64
loop:
for {
select {
case size, ok := fileSizes:
if !ok {
break loop
}
nFiles++
nBytes += size
}
case <-tick:
printDiskUsage(nFiles, nBytes)
}
printDiskUsage(nFiles, nBytes) // final print
}

其中if *progress语句在没有传递-p参数时,不会为tick赋值,而nil的tick值会让select中永远不会进入这个case,从而不打印进度。

在遍历根目录下的递归调用walkDir中,也可以使用goroutine,并通过sync.WaitGroup保证执行完成后关闭fileSizes信道。当然,无限制的创建goroutine会出现和上上小节一样的问题,所以也需要信号量(semaphore),保证不至于创建过多goroutine。

1
2
3
4
5
6
var sema = make(chan struct{}, 20)

func dirents(dir string) []os.FileInfo {
sema <- struct{}{} // 获取token
defer func() { <-sema }() // 释放token
}

取消

一样的,一个goroutine没有直接关闭另一个goroutine的办法。按照之前提到的通过信道传递消息的思路,但是一个信道只会被消费一次,我们这里的场景需要广播更合适。

之前提到,一个被关闭的信道在传递完信道内的消息后,后续再从这个信道获取值,会立即返回一个零值。可以利用这个特性,在执行取消操作后,将信道关闭即可,可以写出下面这样的函数。

1
2
3
4
5
6
7
8
9
var done = make(chan struct{})
func cancelled () bool {
select {
case <- done:
return true;
default:
return false;
}
}

然后在程序的瓶颈处,检查这个函数的返回值,一旦返回true则立即中止程序。比如,之前提到获取token的函数里。

1
2
3
4
5
6
7
8
9
func dirents(dir string) []os.FileInfo {
select {
case sema <- struct{}{}: // acquire token
case <-done:
return nil;
}
defer func() { <-sema }()
// ...
}

按上面这种方式退出程序后,有可能出现goroutine还没有妥善关闭的情况,可以在调试时,程序的最后用panic打印系统信息,查看具体情况。

样例:聊天服务器

聊天服务器也是并发和各种信道常用的场景,它包括:

  • 用户的接入、退出
  • 用户信息的广播
  • 用户session的维护

我们可以用一个信道表示一个接入的用户,在一个全局的文件中处理用户登入、登出,即信道的信息维护,这里可以用map表示,对于接收到的消息,像注册的所有信道逐个发送,即广播。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type client chan<- string // 只写信道

var (
entering = make(chan client)
messages = make(chan string)
leaving = make(chan client)
)

func broadcaster() {
clients := make(map[client]bool) // 用户session维护
for {
select {
case cli := <-entering:
clients[cli] = true
case cli := <-leaving:
delete(clients, cli)
close(cli)
case msg := <-messages:
for cli := range clients {
cli <- msg
}
}
}
}

同时,启动一个tcp服务器,单独启动一个goroutine负责上面的信道管理,另外对于每一个接入的连接,启动一个独立的goroutine处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
listener, err := net.Listen("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
go broadcaster()
for {
conn, err := listener.Accept()
if err != nil {
log.Print(err)
continue
}
go handleConn(conn)
}
}

在处理tcp连接的函数里,负责接入客户端,同时将连接中的内容写入到messages信道中,以便广播给其他客户端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func handleConn(conn net.Conn) {
ch := make(chan string)
go clientWriter(conn, ch)

// 唯一标识生成,也可以使用其他规则
who := conn.RemoteAddr().String()
ch <- "You are " + who
messages <- who + " has arrived"
entering <- ch

input := bufio.NewScanner(conn)
for input.Scan() {
messages <- who + ": " + input.Text()
}

// ...
leaving <- ch
messages <- who + " has left"
conn.Close()
}

func clientWriter(conn net.Conn, ch chan string) {
for msg := range ch {
fmt.Fprintln(conn, msg)
}
}

上面的map没有使用lock操作,是因为它的读写都限制在了一个goroutine内,因此是并发安全的,其他并发使用的信道和net.Conn也是并发安全的。

并发和共享变量

使用信道在goroutine间沟通是一种并发的范式,其中也略过了一些关键而细小的问题,这些在后面这种并发编程模式中会经常讨论。

竞险(race conditions)

1
2
3
4
5
6
7
8
package bank

var balance int

func Deposit(amount int) {
balance += amount
}
func Balance() int { return balance }

上面以银行为例,实际上给出了一个可以读写的变量。在串行执行场景下,不会有问题。在并发场景下,对balance读写的同时进行,就会造成一些问题。这种情况也叫数据争用(data race),即有两个goroutine并发访问一个变量,且至少有一个是写操作。这种数据争用有时候还会带来未定义的行为。

在使用共享变量的模式并发编程时,如果不小心处理,很容易遇到数据争用的情况。然而,绝大多数的数据争用都“来者不善”,以至于我们要留心发生数据争用的场景:有两个goroutine并发访问一个变量,且至少有一个是写操作。下面有三种方式去避免:

  • 不要写变量,比如将变量初始化好之后,使之只读或不可变
  • 避免在多个goroutine上操作变量,将操作限制在一个goroutine上,就像前一章中的broadcaster,这样的goroutine也叫做调度者goroutine。Go中有句箴言总结的很好:不要通过共享变量传递消息,通过传递消息来共享变量。这里的传递消息就是指通过信道发送和接收。当实在无法限制多个goroutine访问一个变量,也尽量限制访问,通过信道传递给其他goroutine,达到串行限制(serial confinement)的效果。
  • 在同一时间仅允许一个goroutine访问变量,即后面会提到的互斥锁

互斥锁(sync.Mutex

互斥锁和之前提到的信号量(counting semaphore)很类似,更像是一个容量为1的信号量,即二进制信号量(binary semaphore)。每次执行后续操作前,都需要从一个全局信道中获取token,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var (
sema = make(chan struct{}, 1)
balance int
)
func Deposit(amount int) {
sema <- struct{}{} // 占用token
balance = balance + amount
<-sema // 释放token
}
func Balance() int {
sema <- struct{}{} // 占用token
b := balance
<-sema // 释放token
return b
}

将上面的信号量表示使用sync.Mutex替代就是互斥锁的使用方式:

  • var sema = make(chan struct{}, 1) => var mu sync.Mutex
  • sema <- struct{}{} => mu.Lock()
  • <-sema => mu.Unlock()

通常来说,互斥锁使用的范围很小,这一区域也叫临界区(critical section),被mutex守护的共享变量会紧跟在Lock之后。在程序较长时,为了避免在所有返回处显式Unlock可以使用defer,这会稍微增加一些显式Unlock的成本,但会让代码更简洁。

另外,互斥锁是不可重入的,即不能对一个已经上锁的共享变量上锁,这会导致死锁,因此确保互斥锁和其守护的变量不被导出。

读/写互斥锁(sync.RWMutex

1
2
3
4
5
6
7
8
var mu sync.RWMutex
var balance int

func Balance() int {
mu.RLock()
defer mu.RUnlock()
return balance
}

sync.RWMutex可以限制写操作,而允许多个读操作同时进行。RLock方法开启,RUnlock关闭互斥锁。注意,只在确定没有对共享变量写操作发生的时候使用RLock方法,我们不能简单的假设一个逻辑读操作,在背后没有注入写入缓存或更新计数器等行为。如果不确定,请使用完整的互斥锁。

同时,sync.RWMutex只在大多数读操作在争用锁时会比较合适。其更复杂的实现,让它在其他场景下工作慢于普通的互斥锁。

内存同步

上面提到的对于Balance这个只读的函数也使用的互斥锁或者信道来限制多个goroutine访问共享变量,其中一个明显的原因是:读取操作发生在写操作如WithdrawDeposit中间时,也会造成问题。另一个不那么明显的原因是,类似互斥锁、信道这种同步操作也会同步内存。

简单点说,在现代CPU架构中,多个处理器内很可能有缓存,每个goroutine对共享变量的修改很可能在多个缓存中,而对其他goroutine不可见,直到同步操作把缓存中的修改同步到主内存中,保证对所有goroutine可见且一致。

同一个goroutine内部是串行稳定的,但goroutine之间无法保证顺序。还有一种错误认识,goroutine的代码会逐行交错(interleaving)执行。但在现代的CPU架构和编译器中,并不是这么实现的。总而言之,把对变量的使用限制在同一个goroutine内,对其他变量使用互斥锁。

懒初始化(sync.Once

1
2
3
4
5
6
7
8
9
10
var icons map[string]image.Image
func loadIcons() {
// 初始化写操作
}
func Icon(name string) image.Image {
if icons == nil {
loadIcons()
}
return icons[name]
}

通常来说,我们会推迟一个计算量比较大的初始化操作到使用时才进行,如上面Icon函数做的那样。很显然Icon函数不是并发安全的。在其中混有读写操作,且和外界共享icons变量。这时我们需要在初始化的时候对loadIcons函数加锁。加锁时要区分icons的是否初始化状态,可以对只读操作使用读/写锁,再对写入操作使用互斥锁。像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var mu sync.RWMutex
func Icon(name string) image.Image {
mu.RLock()
if icons == nil {
icon := icons[name]
mu.RUnlock()
return icon
}
mu.RUnlock()

mu.Lock()
// 因为在Lock前,有短暂的Unlock的时间段,有可能会被其他goroutine初始化,因此需要再次判断
if icons == nil {
loadIcons()
}
icon := icons[name]
mu.Unlock()
return icon
}

实际上,上面就是一个只做一次的操作(通常是初始化操作),为了维护一个是否完成的bool值,额外增加了一些操作,较容易出错。go对这种情况提供了sync.Once支持,在Do方法中传入只执行的函数,这个互斥锁会在第一次执行时上锁并将对变量的改动同步到其他goroutine中,同时维护一个bool值,在后续的执行中,直接跳过这一步。重写之后的Icon变得简单了很多。

1
2
3
4
5
6
7
8
var loadIconsOnce sync.Once
var icons map[string]image.Image

// 并发安全
func Icon(name string) image.Image {
loadIconsOnce.Do(loadIcons)
return icons[name]
}

竞险检测器

很多时候goroutine访问共享变量导致的竞险并不那么容易发现和避免。Go的配套工具链提供了-race标志位用来检查程序中可能存在的竞险情况,在go run, go build, go test后添加都可以。它可以记录对共享变量所做的所有读写操作以及对应的goroutine,还有程序中由sync和信道触发的同步操作。竞险检测器在分析这些事件流的时候可以给出包含共享变量和对其读写goroutine报告。在绝大多数情况下已经足够你查问题了。

竞险检测器只能报告程序覆盖到代码的竞险情况,所以尽量让测试覆盖到所有代码。竞险检查会稍微占用更多时间和内存,但是是可以接受的。

样例:并发无阻塞缓存

实现这么一个并发数据结构,大抵有两种构建思路:

  • 使用有锁的共享变量
  • 借助信道和通信实现串行化

不同场景下,他们实现的复杂度可能会稍有不同。

goroutine和线程

goroutine和线程有些很多小地方上的区别,这些区别让goroutine和线程有着较大区别。

  • 线程的栈一般是固定的(通常是2MB),goroutine的栈是灵活的,从较少的大小开始(通常是2KB),可以扩大和缩小
  • goroutine有自己的调度机制(m:n调度),把m个goroutine复用或调度到n的操作系统的线程
  • GOMAXPROCS环境变量决定了Go代码可以使用多少个操作系统线程

最后,goroutine没有其他操作系统或编程语言中用来支持多线程的为每个线程添加一个唯一标识的设计。这个是特别设计的,用来避免线程池的滥用。Go更推荐只由入参显式决定函数表现的风格,避免让函数收到执行线程的影响。

包和Go工具

如今中小型的程序可能会包含10000个函数,其中绝大多数来自其他人的包。包可以将程序中相互关联的特性整理到独立的单元中,进而在组织或社区中重用、分享。包名和导出的类型、变量、函数名都应简短清晰,Go使用首字母大小写控制可见性,从而掩盖实现细节,保证稳定性或实现互斥锁。

Go的构建速度算是比较快的。主要有3个原因:

  • Go在每个文件开头都显式列出了引入的文件,无需读取整个文件
  • Go中没有引入是一个有向无环图,因此可以并行编译
  • 编译好的Go包的目标文件会包含自身和自身的依赖,每一次的import只需读取一遍目标文件即可

import路径

Go的语言规范并没有规定import路径,路径的实现是由相关工具决定的。但是为了避免冲突,除了标准包以外的包,都需要以域名或组织名开头,如encoding/jsongolang.org/x/net/html

声明和import

每一个Go文件的开头,都需要以package xxx的形式标识包名。通常,包名是import路径的最后一段。但是有3个例外情况:

  • main包名表示告诉go build需要构建一个可执行文件
  • 目录下包含_test后缀文件名的,在执行go test时会额外被构建用于测试的包
  • 有的报名后面会跟版本号,这个时候以没有版本号的作为包名

在import时,如果引入包较多时,可以用圆括号列表形式引入。列表间可以用空行隔开,通常用来分组域名,这个在按照字母顺序排序import时有用——排序会在每组内进行。如果不同域内的包名一样,如math/randcrypto/rand,可以用重命名引入(renaming import)。像下面这样

1
2
3
4
import (
"crypto/rand"
mrand "math/rand"
)

这种重命名只在当前文件内有效。重命名通常可以避免包名冲突,或者简化一些复杂的包名,在简化时,注意对同样的原名,使用同样的缩写名。go build会报告编译中循环依赖。

空导入

有的时候,我们的引入的目的在包的副作用,如其中全局变量的初始化或init函数的执行。这个时候可以用空白标识符_进行重命名即可,如import _ "image/png"。这种即空导入。

在书中例子里,对image/png的空导入,实现了png解码相关配置的全局注册(image.RegisterFormat),从而可以解码png图片。类似的思路在database/sql包中也有用到。

命名

对于包名,有下面一些建议的命名规范:

  • 使用简短明了的包名
  • 使用有描述性且没有歧义的名字,且最好不要使用经常会用来命名局部变量的包名,如path
  • 包名通常使用单数形式,需要和其他情况区分开时,如stringsbytes
  • 避免使用具有隐藏含义的名称,如temp

对于包中的成员名:

  • 考虑和包名一起构成完整的含义,不需要包含包名,如flat.Inthttp.Get
  • 有的包名表示一种类型,在内部会有对应的类型定义和New操作
  • 即使对于有着很多成员的包,其最关键的几个方法仍然是最简单的

Go工具

Go工具像一个瑞士军刀,它的各个子命令提供了诸如包管理器(go get)、构建系统(go build, go run, go install)、测试驱动(go test)等等。

工作区组织

日常经常使用的是GOPATH环境变量,用于说明当前工作区的根路径。GOROOT表示go源码的根路径,GOOS表示操作系统,GOARCH表示处理器架构。更多配置可以执行go env查看。

包下载

执行go get下载,下载时不仅包含源码的拷贝,还包含源码的版本控制信息。Go工具会自动判断流行的代码托管方式。对于不那么有名的托管网站,需要自己显式说明保本控制的协议,可以通过go help importpath查看细节。

Go工具在访问包的导入路径域名如golang.org时,会试图从网页的<meta>标签中寻找类似下面这样指示目标路径的信息。

1
<meta name="go-import" content="golang.org/x/net git https://go.googlesource.com/net">

另外,执行go get -u时会获取所有包的最新版本,在需要锁定版本时比较麻烦,可以借助vendor解决,在go help gopath中有介绍。

包构建

  • 使用go build构建时,对于库类型代码只会检查错误,对于main包,则会构建可执行文件
  • 一个目录包含一个包,因此导入时,要么指定导入路径,要么指定相对路径,否则会以当前目录为基础构建。构建得到的可执行文件名称是go文件的前缀。
  • go build构建时,会丢弃已编译的代码,只保留可执行文件。
  • go install构建时,会保留已编译的代码,编译好的包位于$GOPATH/pkg下,编译得到的执行文件位于$GOPATH/bin下。再之后,go buildgo install不会编译未被改变的包或命令,从而让执行过程更快。go build -i可以安装项目的所有依赖
  • 修改GOOSGOARCH可以改变包目标的平台和架构,默认只会针对当前平台和架构打包。
  • 文件末尾以诸如_linux.goaxm_amd64.s结尾时,只会在打对应平台包的时候才会编译此文件。另外还可以以// +build linux darwin的注释形式做到更好的细粒度。// +build ignore表示编译时跳过该文件。

go doc go/build下有更多介绍。

包文档

Go建议在导出的包成员和包声明前使用描述用途和用法的注释。注释宜简单且清晰,对于大段的注释,使用同名的文档文件(通常名为doc.go)进行说明。如果行为本身就足够明显,就不需要写注释。

go doc命令可以查看包、包成员、包方法的文档。还有个很相似的命令godoc,它可以托管一个能够查看当前工作目录下文档的服务器。

内部包

有些包可能希望导出只对个别信任的包可见,对于这种包,导入路径中需要包含internal。这些内部包只对internal的父目录下文件可见,如net/http/internal/chunkednet/http/httputil可见,但对net/url不可见。

查询包

go list工具可以查询包的导入路径。使用...通配符可以查到更多内容。

1
2
3
4
go list github.com/go-sql-driver/mysql
go list ...
go list gopl.io/ch3/...
go list ...xml...

结合-json可以打印json格式的包详情,或者结合-f加上text/template语法打印特定格式的字符串。

更多使用方式查看go help list

测试

同行评审和测试是两种避免代码错误的方式。Go尽量让写自动化测试代码不是一件很困难的事。在Go中进行测试,你需要了解的只不过是普通的Go语法规范和一些约定而已。

go test工具

Go的测试都借助go test完成。所有和测试相关的文件必须以_test.go结尾,这些文件不会在打包时包括进去,只会在运行测试时运行。在文件中有三类函数会被特殊处理:

  • 测试函数:必须以Test开头,表示检测一些逻辑的正确性,运行后会给出PASSFAIL
  • 基准测试函数:必须以Benchmark开头,表示测量一些操作的性能,运行后会给出运行时间
  • 样例函数:必须以Example开头,表示提供一些格式化的文档

go test运行完成时,会生成一个临时的main包,构建并运行,最后给出结果并清理现场

测试函数

测试函数均以Test开头,函数入参是test包提供的用来打印错误或其他日志的工具集。

1
2
3
import "testing"

func TestSin(t *test.T) { /* ... */}

接着就像写普通Go代码一样去执行case就行了。

1
2
3
4
5
6
7
8
9
package word

import "testing"

func TestPalindrome(t *testing.T) {
if !IsPalindrome("kayak") {
t.Error(`IsPalindrome("kayak") = false`)
}
}

运行时,结合-v标记可以打印详细信息,结合-run标识可以只运行符合指定模式的case。

1
go test -v -run="French|Canal" 

case之间的代码相似性很高,建议用配置的方式批量运行case,减少模板代码书写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func TestPalindrome(t *tesing.T) {
var tests = []struct{
input string
want bool
} {
{"", true},
{"a", true},
{"ab", false},
{"A man, a plan, a canal: Panama": true}
}
for _, test := range tests {
if got := IsPalindrome(test.input); got != test.want {
t.Errorf("IsPalindrome(%q) = %v", test.input, got)
}
}
}

go test在运行测试时,每个case的错误不会中断其他case的执行,也不会panic,来保证一次运行能获得所有case的执行结果。对于需要中断的情况,可以使用t.Fatal或者t.Fatalf

随机化测试

不同于上面提到的选择特定case进行测试。随机化测试可以覆盖更广的范围。在验证随机化测试是否符合预期上,有两种思路:

  • 使用另外一种方式给出结果,对比两种方式的结果是否相同
  • 使用固定的模式生成随机化case,使其预期结果可以事先推导出来

另外,在随机化测试时还要考虑能否再次复现问题case的情况。

测试一个命令

对于go test而言,main包只是一个库,可以将main函数中需要测试的逻辑抽离成函数,在*_test.go中测试即可。最好将log.Fatal或者os.Exit这种中断程序执行的语句放在main函数中,避免中断测试流程。

白盒测试

白盒测试即在对测试对象有清楚认识的情形下进行测试;黑盒测试则相反,更站在客户端的角度去测试包。在白盒测试下,我们可以修改原先包的一些实现方式,使之更易被测试。比如,可以将其中会有副作用的部分,如发邮件、写数据库、发短信的函数覆盖。(类似mock的思路)

但是,在覆盖后,别忘了还原回去,避免影响后续测试。类似下面这样:

1
2
3
4
5
6
7
8
9
10
func TestCheckQuotaNotificationUser(t *testing.T) {
saved := notifyUser
defer func() { notifyUser = saved }()

//...
notifyUser = func(user, msg string) {
notifiedUser, notifiedMsg = user, msg
}
// ...
}

这种覆盖方式正常情况下不会有风险,因为go test通常不会并行运行多个测试。

外部测试包

上面提到的都是直接在包下新建*_test.go文件的方式进行测试。有些情况下,如果测试文件内需要引用更高层包,会产生循环引用,这是上一章提到不允许的。这时可以定义为外部包。如:

net/url下的测试文件导入了net/http包,而net/http包中又导入了net/url。这个时候在net/url下的测试文件使用package url_test声明,表示是另一个包net/url_test。然后,通过导入net/urlnet/http的方式进行测试。就可以避免循环引用。

可以通过go list -f指定.GoFiles.TestGoFiles.XTestGoFiles分别查看包中的源文件、测试文件和外部测试包文件。

然而,外部测试包并不能访问到包内对外不可见的变量或函数。这个时候,可以在包内创建一个后门测试文件,用于导出一些内部变量或函数对外部包测试可见,通常命名为export_test.go。这类文件内不包含实际的测试。如fmt包下的export_test.go

1
2
3
package fmt

var IsSpace = isSpace

写高效的测试

Go在设计上和其他很多语言不同,并不包含一个大而全的测试框架,也没有创建、清除操作,和常用的断言、判断方法等。Go认为写case是作者自己的事,而且就像写普通的程序一样,不要有死记硬背和长篇大论,只需简明扼要地表达测试意图。

在写测试代码时,避免过早抽象,先想着把功能实现,然后再想怎么通过抽象减少重复和复杂度。

避免“脆弱”的测试

有两种应用:一种是真正bug很多的(buggy),另一种是合理改动也过不了case的(brittle)。而这里过不了case可能只是因为判断逻辑写的不够宽容,死抠细节导致很容易过时。避免这种情况一个很直接的办法是只检查你关心的特性,使用更简单和时间稳定的方式检查,如不要依赖字符串匹配。去检查本质。

覆盖率

Testing shows the presence, not the absence of bugs —— Edsger Dijkstra

覆盖率一定程度上能对测试的覆盖程度有启发性的指示作用。使用go test -coverprofile可以指定覆盖率数据输出,如果不需要输出,只看摘要,可以只用go test -cover。使用go tool cover可以显示覆盖率使用介绍。

最后要说明的是,被覆盖到的代码并不是没有bug,测试是一种务实的努力。它是在写测试代价和失败代价的中间的一个折中。

性能测试函数

这类函数都以Benchmark开头,和测试函数类似,函数入参是*testing.B类型的变量。默认情况下,不会执行任何性能测试,需要指定-bench值,去匹配对应函数执行,“.”表示匹配所有。如go test -bench=.性能测试函数写法如下:

1
2
3
4
5
6
import "testing"
func BenchmarkIsPalindrome(b *testing.B) {
for i := 0; i < b.N; i++ {
IsPalindrome("A man, a plan, a canal: Panama")
}
}

之所以需要自己在基准测试函数中写循环,而不集成在测试驱动中,是避免一些一次性操作影响执行时间测量。-benchmem标识会显示内存分配的使用情况。性能测试函数可以用来对比两种策略或算法的相对时间优劣,以及通过调整循环次数,整体上考察代码设计。

性能侧写(Profilling)

性能测试函数能帮你发现整体的性能好坏,但不能告诉你哪里做得不够好。

Knuth曾说过“不要过早优化”,然而结合上下文的原话的意思则是,寻找性能优化点并不那么容易,程序员们在写需求前浪费了大量时间在寻找优化点上,先把事情做出来,不要杞人忧天过早优化。但是优秀的程序员会努力找到优化点并改善之。

寻找关键点的方式就叫profiling。profile通过采样的方式给出占用时间、资源最多的对象,从而可以对应去优化。Go提供3种profile

  • CPU profile,标记占用CPU时间最长的函数
  • heap profile,标记分配内存最多的声明
  • blocking profile,标记阻塞goroutine时间最久的操作

对应在go test上的标识为-cpuprofile-memprofile-blockprofile。借助go tool pprof可以打印侧写数据,以及可视化数据。

样例函数

1
2
3
4
5
6
7
func ExampleIsPalindrome() {
fmt.Println(IsPalindrome("A man, a plan, a canal: Panama"))
fmt.Println(IsPalindrome("palindrome"))
// Output:
// true
// false
}

最后一种会被go test特殊处理的是样例函数,这类函数以Example开头,并没有入参,也没有返回。它的作用主要有以下3点:

  • 文档记录,且更能传达意图,同时由于样例函数是实际的Go代码,对比文档,随着代码演化,不会有过期风险。命名单纯叫Example的函数作为整个包的样例函数。
  • 函数最火包含// Output:注释的话,go test会检查标准输出是否能匹配注释中的输出
  • godoc中可以作为playground,提供给用户动态编辑、运行的功能

反射

反射能在运行时不知道变量类型情况下去修改和查询变量值。反射还能让我们将类型作为第一成员的值来使用。类似fmt.Sprintftext/template中就有用到这个特性

reflect.Typereflect.Value

reflect.Typereflect.Value分别表示变量的类型和值。其中类型通过reflect.TypeOf得到,得到的reflect.Type可以保存任何类型值。

1
2
3
t := reflect.TypeOf(3) // a reflect.Type
fmt.Println(t.String()) // "int"
fmt.Println(t) // "int"

返回的类型总是interface的动态类型,所以总是确切类型。

reflect.ValueOf可以得到任意类型的变量值。返回的reflect.Value满足fmt.Stringer接口,不过打印出来的是变量类型。

1
2
3
4
v := reflect.ValueOf(3) // a reflect.Value
fmt.Println(v) // "3"
fmt.Printf("%v\n", v) // "3"
fmt.Println(v.String()) // "<int Value>"

reflect.Value.Interface方法返回一个保存相同值的interface{}类型。它和reflect.Value不同在于,一个interface{}类型的变量掩盖了外部表现和内部实现细节,因此无从对其操作。``reflect.ValueKind`方法可以返回类型的底层表示方法,因此使用时,可以只关心Go中定义的类型。

递归值输出函数Display

利用上面提到的Kind方法,可以实现递归打印任意类型值的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func display(path string, v reflect.Value) {
switch v.Kind(){
case reflect.Invalid:
fmt.Printf("%s = invalid\n", path)
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
display(fmt.Sprintf("%s[%d]", path, i), v.Index(i))
}
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
fieldPath := fmt.Sprintf("%s.%s", path, v.Type().Field(i).Name)
display(fieldPath, v.Field(i))
}
case reflect.Map:
for _, key := range v.MapKeys() {
display(fmt.Sprintf("%s[%s]", path, formatAtom(key), v.MapIndex(key)))
}
case reflect.Ptr:
if v.IsNil() {
fmt.Printf("%s = nil\n", path)
} else {
display(fmt.Sprintf("(*%s)", path), v.Elem())
}
case reflect.Interface:
if v.IsNil() {
fmt.Printf("%s = nil\n", path)
} else {
fmt.Printf("%s.type = %s\n", path, v.Elem().Type())
display(path+".value", v.Elem())
}
default: // 基础类型、信道、函数
fmt.Printf("%s = %s\n", path, formatAtom(v))
}
}

上面用到了许多reflect.Value的方法,不是所有的都安全:

  • v.Index()v.Len()类似len()[i]下标取值
  • v.NumbField()返回结构体中的字段数目,v.Field(i)则返回第i位的reflect.Value类型值
  • v.MapKeys()返回无序的map key
  • v.IsNil()v.Elem()分别判断是否为空和获取值

上述方法在遇到有环的数据结构时,会无限打印,可以借助下一章里的unsafe包解决。

使用reflect.Value设置变量

Go中的变量都是有地址的,可以通过这个地址去修改变量的值。

1
2
3
4
5
x := 2
a := reflect.ValueOf(2)
b := reflect.ValueOf(x)
c := reflect.ValueOf(&x)
d := c.Elem() // variable

上面的d即变量x。借助这个方式我们可以用Addr()获取地址,用Interface()获取interface{}类型的值,再使用类型断言转成具体的变量类型。像下面这样。

1
2
3
px := d.Addr().Interface().(*int)
*px = 3
fmt.Println(x) // 3

又或者,可以通过Set方法设置一个reflect.Value。针对特定类型,还有SetIntSetUintSetString这样的方法。注意,这些方法只使用在特定类型上,对于interface{}或其他类型使用,会引起panic。

1
2
3
4
5
6
d.Set(reflect.ValueOf(4))

var y interface{}
ry := reflect.ValueOf(&y).Elem()
ry.SetInt(2) // panic: SetInt called on interface Value
ry.SetInt(reflect.Value(3)) // OK, y = int(3)

另外,反射不能更新那些没有对外导出的结构体字段,尽管这些字段可以在发射中读取到。CanSet()可以判断一个reflect.Value是否可以修改,类似的,CanAddr()可以判断一个reflect.Value是否可以获取到地址。

利用上面的特性,可以实现encoding/json中类似的解析JSON字符串的效果。

访问结构体的field tag

1
2
3
4
5
var data struct {
Labels []string `http:"l"`
MaxResults int `http:"max"`
Exact bool `http:"x"`
}

我们在JSON一节提到,可以在结构体后使用field tag作为JSON解析过程中的metadata。实际上,除了json还可以设置其他tag。这个tag也可以通过反射特性拿到。

reflect.TypeField()方法可以返回一个reflect.StructField类型,其中包含了字段名、字段类型以及可选的标签。其中Tag字段即field tag对应的字符串,它的Get方法可以返回特定标识后的标签值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func Unpack(req *http.Request, ptr interface{}) error {
if err := req.ParseForm(); err != nil {
return err
}

fields := make(map[string]reflect.Value)
v := reflect.ValueOf(ptr).Elem()
for i := 0; i < v.NumField(); i++ {
fieldInfo := v.Type().Field(i) // reflect.StructField类型
tag := fieldInfo.Tag // reflect.StructTag类型
name := tag.Get("http") // 获取http标识
if name == "" {
name = strings.ToLower(fieldInfo.Name)
}
field[name] = v.Field(i)
}
// ...
}

展示类型的方法

reflect.Typereflect.Value都有一个Method()方法。reflect.Type中的方法返回reflect.Method实例,结构体中包含方法名和方法类型。reflect.Value中的Method()方法则返回一个reflect.Value类型,即一个绑定到receiver上的方法。

1
2
3
4
5
6
7
8
9
10
func Print(x interface{}) {
v := reflect.ValueOf(x)
t := v.Type()
fmt.Println("type %s\n", t)

for i := 0; i < v.NumMethod(); i++ {
methType := v.Method(i).Type()
fmt.Printf("func (%s) %s%s\n", t, t.Method(i).Name, strings.TrimPrefix(methType.String(), "func"))
}
}

一些忠告

反射在规范的类型系统外,引入了更高自由度和编程的灵活性,但同时也带来了弱类型解释型语言(没错,JS就是你)的弊病:编译期问题会变成运行时问题、代码可读性变差、性能更差。

反射虽然提供了很强大的功能,但是失去了类型的保护,需要额外处理类型的边界case,否则很容易在运行时出现panic。而这些在使用特定类型时会在编译期就被发现。因此,在使用时,建议将包中使用反射的部分完全封装在内,不对外暴露,同时做一些额外的动态检查。同时,在出错时,给出类型上更友好的提示。

1
fmt.Printf("%d %s\n", "hello", 42) // "%!d(string=hello) %!s(int=42)"

另外,interface{}类型和大量出现的反射代码会让代码安逸理解,需要辅以更加完善的文档和注释来解释。

最后,基于反射的函数执行速度比普通基于特定类型的函数慢至少一两个级别。因此,尽量不要在代码执行的关键路径上使用反射实现,类似测试代码这种小数据量和执行覆盖频率的代码就可以使用。

低阶特性

Go已经尽量掩盖了它在底层的实现,用来避免出现难以调试的神秘问题。但在有些时候,比如为了追求性能,或者希望和操作系统底层交互,可能希望绕开这个限制。这一章的内容介绍的unsafe包提供了这么一个窗口,cgo工具可以将创建C库和Go的绑定关系。

unsafe.Sizeofunsafe.Alignofunsafe.Offsetof

这三个API能让你了解一些Go在内存结构上的一些细节。其中

  • Sizeof返回操作数在内存中占用的大小
  • Alignof返回操作数“对齐”需要的内存大小
  • Offsetof返回结构体中字段在结构体内存的偏移量

这几个API并不像它们名字里写的不安全,对于了解底层的内存表示是有帮助的,比如在需要优化内存性能时。

unsafe.Pointer

unsafe.Pointer是一个可以指向任意类型变量的指针,同时也可以把unsafe.Pointer类型指针转换回特定类型指针

1
2
3
4
package math

func Float64bits(f float64) uint64 { return *(*uint64)(unsafe.Pointer(&f)) }
fmt.Printf("%#016x\n", Float64bits(1.0)) // "03xff00000000000000"

同时unsafe.Pointer可以转换为uintptr类型,这个类型用整数表示了地址。这个整数类型足够大,足以表示任何类型的指针。但在使用时要多加注意,因为Go的垃圾回收机制使得一个变量的地址很可能会在运行过程中改变,从而使之前的uintptr类型变量失去意义。建议尽可能减少unsafe.Pointeruintptr和对uintptr的使用。如果有包返回了一个uintptr类型,建议立即将其转换为unsafe.Pointer类型,确保指针能指向同一个变量。

cgo

使用cgo可以在go中使用C语言,反之亦然,这里从略,具体参考https://golang.org/cmd/cgo。

再一些忠告

unsafe包和reflect包很像,提供了一些高级特性,但是更甚。它绕开了语言对不可控因素的隔离,会带来一些风险。所以,在特殊场景下,经过仔细考量和验证证实,使用unsafe确实会带来关键性能提升时,再在代码的关键位置使用unsafe,同时,尽量保证对代码其他地方透明。

最后,忘掉最后两章吧,先去踏踏实实写一些Go程序,在能用上reflectunsafe的时候,你自然回想起来的。

祝,happy Go programming。

-END-

The only valid measurement of code quality: WTFs/minute

在成为一个程序员的初期,实现功能还需磕磕绊绊的阶段,我们大抵没有精力操心代码风格的问题;而在能够搞定环境和API使用的时间段,大部分人又会沉湎于使用一门语言让想象实现的成就感,而没有发现暗藏在迭代后的危机。往往迭代了一段时间后,才发现之前埋下的巨坑已经让自己无从下手。这时一部分人醒悟过来,意识到一个优良的代码风格对于项目推进的长远意义。这也是《Clean Code》这本书的宗旨。它较之《程序员修炼之道》更为具体,较之《重构》更为宏观。对于工作一段时间后的程序员来说,是一个很好的提醒和反思归纳的建议。让代码work的方式是千万种,而让代码可持续,可扩展,长久work的方式也许需要前辈指引些方法。

观念

Later equals never —— Leblanc Law

糟糕的代码会让人难以下手,拖慢进度,若无人着手改善,混乱会持续增加,进而降低团队生产力,降低人效,然后搞砸整个项目。为什么不一开始就打好基础,写出整洁代码呢?

下面是一些大师对“整洁代码”的界定

  • “代码逻辑直截了当,缺陷难以隐藏;减少依赖关系,从而便于维护;性能调优,省得引人做出没规矩的优化,干出蠢事;整洁的代码只干一件事” —— Bjarne Stroustrup
  • “代码简单直接,如同优美的散文;从不隐藏设计者的意图,充满干净利落的抽象和直截了当的控制语句” —— Grady Booch
  • “可由作者外的人阅读和扩展,应该有单元测试和验收测试;只使用有意义的命名;提供尽量正交的使用方法(一种而非多种做一件事的方法);尽量少的API;尽量少的依赖关系,且要明确定义和清晰提供;代码应从字面意义上表达其含义” —— Dave Thomas
  • “整洁的代码总是看起来像某位特别在意的人写的,几乎没有改进的余地,所有的改进都会回到原点” —— Michael Feather
  • “能通过所有测试;没有重复代码,表达力强大;体现系统中的全部设计理念;包括尽量少的实体,如类、方法、函数。” —— Ron Jeffries
  • “整洁代码让每个例程都深合己意;漂亮代码让语言看起来像是专门为解决那个问题而存在” —— Ward Cunningham

编写代码的难度,取决于读周边代码的难度,要想干得快,就先让代码易读。

让营地比你来时更干净 —— 童子军军规

命名

好的命名,可以让人一眼就明白代码的逻辑。看下面两段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public List<int[]> getThem() {
List<int[]> list1 = new ArrayList<int[]>();
for (int[] x : theList)
if (x[0] == 4)
list1.add(x)
return list1;
}

public List<Cell> getFlaggedCells() {
List<Cell> flaggedCells = new ArrayList<Cell>();
for (Cell cell : gameBoard)
if (cell.isFlagged)
flaggedCells.add(cell);
return flaggedCells;
}

下面是一些准则:

  • 名副其实,一旦发现更好的,就替换掉旧的。如果名称还需要注释补充,那就不算名副其实
  • 避免误导,提防使用不同之处较小的名称,比如UsernameListEmptyFilterUsernameListNullFilter,不要混用1和l,以及0和O
  • 做有意义的区分,少废话,反例是a1a2nameStringname
  • 使用能读出来的名称,方便程序员的沟通
  • 使用可搜索的名称,仅在块作用域或短函数内使用单字母名称
  • 不把类型信息放在名称中
  • 减少不必要的前缀和后缀
  • 类名应该是名词,方法名应该是动词或动词短语
  • 别玩梗
  • 标准化语素,为每一个抽象概念选择一个统一的词
  • 别用有多重含义的词汇,这会增加使用者顾虑和理解成本
  • 只在没有明确定义的场景下使用语境(类,前缀……)

函数

下面是一些准则:

  • 短小,更短小
  • 只做一件事,只做一件事,只做一件事,重要的事情说三遍
    • 还有一种方式可以帮助判断函数是不是只做了一件事:函数语句是否在一个抽象层级上
    • 只做一件事的函数无法被继续合理拆分成多段
  • 让代码拥有从上到下的自然的阅读顺序,尽量避免跳来跳去的阅读顺序
  • 为函数使用描述性的名称
  • 函数最多3个入参,最理想是没有参数,其次是1个,再其次是2个,要避免3个参数
    • 布尔类型的参数会让你的函数难以理解
    • 使用二元参数时,最好保证前后顺序不敏感
    • 对于复杂的入参,可以用对象封装起来
  • 函数要么只做副作用(做什么事),要么没有副作用(回答什么事),而且能从名称中一目了然地看到
  • 用异常代替错误码,错误处理也是一件事
  • 别重复自己(Don’t repeat yourself, DRY)。重复是软件中一切邪恶的根源,软件开发领域的所有创新都在不断尝试从源代码中消灭重复

函数是语言的动词,类是名词。大师级的程序员把系统当做故事来讲,而不是程序来写。他们使用特定编程语言提供的工具构建一种更丰富和更具表达力的语言。好的函数必须干净利落的组合在一起,形成清晰明确的语言,帮你讲好故事。这个过程不是一蹴而就的,你可以现象什么就写什么,然后一点点打磨它。

注释

使用注释 = 承认自己无法用代码表达清楚意图

列举注释的准则前,必须摆正观念。注释是一种必须的恶,如果编程语言足够有表达力,或你长于用语言表达自己的意图,那么就不需要注释。注释的恰当用法是弥补我们用代码表达意图时遭遇的失败。注释存在的时间越久,具体所描述的事实就越远。原因很简单,程序员不可能坚持维护注释。

的确,程序员应该让注释保持可维护而精准,但最好能直接写清楚代码,保证无须编写注释。真实只在一处:代码,只有代码能忠实告诉你它做的事。

  • 注释无法挽救糟糕的代码,与其为糟糕代码补充大量注释,不如花时间写出整洁有表达力的代码
  • 用代码代替注释
  • 有些无法避免且合理存在的注释
    • 法律信息
    • 对函数名的补充
    • 对稍微反常规意图的解释,避免误解
    • 糟糕的代码来自外部库或外部API
    • 敏感代码的警告
    • TODO、FIXME
    • Javadoc
  • 下面则是一些很常见的糟糕注释,它们只是糟糕代码的借口
    • 只有自己看得懂的注释
    • 对函数名的复述
    • 误导性注释
    • 日志型注释,如Milestone记录
    • 废话
    • 位置标记,如====================
    • 代码署名
    • 大多数注释掉的代码
    • 百科式的介绍
    • 私有代码的Javadoc

格式

格式即代码风格,可以利用lint这样的自动化工具完成,需要在团队内保持一致。

垂直格式

在从上到下的组织上,

  • 可以向报纸一样,先大纲,再粗线条概述,再给出细节,越往下细节越多
  • 空行分隔概念
  • 靠近的代码行暗示了代码间的紧密关系
  • 应避免迫使读者在源文件和类之间跳来跳去
    • 变量声明应尽量靠近使用位置
    • 循环中的控制变量总在循环语句中声明
    • 类成员在类顶部声明
    • 如果某个函数调用了另一个,就应该把它们放一起
    • 概念相关的代码应该放在一起,如getHourgetMinute
    • 如果可以,最好把被调用的函数放在执行调用的函数下面

横向格式

  • 水平字符的上限,100或120
  • 使用空格分隔概念
  • 不需要水平对齐,以为从左到右的阅读顺序优先于从上到下
  • 学会用缩进表现层级

对象和数据结构

  • 对象把数据隐藏于抽象的后面,暴露操作数据的函数,数据结构暴露数据,不提供有意义的函数。
  • 过程式代码难以添加新的数据结构,因为必须修改所有函数;面向对象代码难以添加新函数,因为必须修改所有类
  • 得墨忒尔律:类C的方法f只应该调用以下对象的方法:C、f创建的对象、作为参数传给f的对象、C的成员所持有的对象
  • 数据结构只简单地拥有公有变量,没有函数;而对象则只拥有私有变量和函数。一半是对象一半是数据结构的混淆会增加添加新函数和数据结构的难度
  • DTO(Data Transfer Objects,数据传输对象)就是只有公有变量,没有函数的类,Active Record就是DTO的一种

错误处理

错误处理很重要,但要是它搞乱了代码逻辑,那就是错误的用法。

  • 返回异常而不是错误码
  • try-catch-finally语句块就像事务,可以帮你定义用户应该期待些什么
  • 在Java中,可控异常违反开闭原则带来的成本要高于收益
  • 打包第三方API,一方面降低了依赖的覆盖面,另一方面也有助于模拟第三方调用
  • 可以把抛出错误封装成特定的函数
  • 别返回和传递null值

边界

边界即我们代码和第三方代码的连接处。

  • 通过编写测试来概览和理解第三方代码的形式叫做学习性测试(learning tests)。它可以帮助我们快速试错和反馈,从而对第三方API快速上手。
  • 在第三方代码尚未就绪时,编写我们想要的接口,可以使我们能保持代码在自己控制中,并在未来通过编写adapter的形式无痛迁移
  • 应尽量避免过多依赖第三方的特定信息,更多依靠你能控制的东西,好过依靠你控制不了的东西,免得日后受其控制

单元测试

  • TDD(Test-Driven Development)三定律
    • 在编写不能通过的单元测试前,不编写生产代码
    • 只编写刚好无法通过的单元测试,不能编译也算
    • 只编写刚好足已通过失败测试的生产代码
  • 测试代码和生产代码一样重要。正是单元测试让你的代码可扩展、可维护、可复用
  • 整洁的测试代码一样要求可读性。大多数测试代码可以总结为构造-操作-检验(Build-Operate-Check)模式。第一个环节构造测试数据,第二个环节操作数据,第三个环节验证是否得到期望的结果
  • 每个测试中的断言数量应该尽量少,且只测试一个概念
  • FIRST原则
    • Fast,测试应该能够快速运行
    • Independent,测试间应该相互独立
    • Repeatable,测试应该在任何环境下可重复通过
    • Self-Validating,测试应该有布尔值输出
    • Timely,测试应及时编写

  • 类应该由一组成员开始,从静态到普通,从共有到私有。且很少会有公有成员。
  • 类应该短小,类的名称应该能描述其权责。类名无法精确明明时,类大概就太长了。类名越含糊,类越有可能拥有过多权责。类名应该控制在25个字母内,且不应该包含连词。
  • 单一权责原则(Single Responsibility Principle,SRP)认为,类和模块应有且仅有一条加以修改的原因。这个原则可以帮助创造更好的抽象。它也是OO设计中最重要的概念之一。
  • 内聚:类应该只有少量实体变量,且所有方法都应该操作其中一些。当类的每个变量都被每个方法使用时,我们认为该类具有最大的内聚性。当发现类逐渐丧失内聚性时,尽早拆分它!让它变成多个短小的类。这个拆分的过程也是权责的拆分过程。
  • 通过基类和子类,可以在不修改类的同时,保持类对新功能的开放。在理想系统中,我们通过扩展系统而非修改现有代码来添加新特性。可以通过抽象类和接口隔离细节修改带来的影响。
  • 降低类之间的连接耦合,可以采用依赖倒置原则(Dependency Inversion Principle,DIP),让类依赖于抽象(接口)而不是具体细节(自行构造类)

系统

这一章的Java概念较多

  • 分开系统的构造和使用
  • 依赖注入是控制反转的一种思路,它将第二权责从对象中拿出来,转移到专门的对象中去,从而遵循单一权责原则
  • 我们应该专注于今天的用户故事,并且持续适当切分我们的关注面。书中举了Java AOP、AspectJ框架的例子
  • 实现时,使用大致可工作的最简单方案。只要软件构架有效切分了关注面,就比较好做根本性改动

迭代

Kent Beck关于测试的4个原则:

  • 运行所有测试,全面测试并持续通过所有测试的系统,就是可测试的系统。测试也能减少重构时可能破坏代码的顾虑。
  • 不可重复,使用模板生成或继承等高级概念
  • 表达程序员的意图。代码应当清晰表达作者的意图。使用好名称、保持类和函数的短小,以及之前章节提到的各种方法
  • 尽可能减少类和方法的数目,避免前两条规范的矫枉过正

并发编程

并发是一种解耦策略,帮助我们分解开做什么(目的)何时(时机)

  • 并发有时能改善性能,会在编写额外代码上带来额外开销
  • 正确的并发是复杂的
  • 并发会带来系统结构的变化

有些防御并发代码问题的原则:

  • 单一权责:分离并发代码和其他代码
  • 限制对可能共享的数据的访问
  • 线程应尽可能独立

并发执行模式:

  • 生产者-消费者模式:数据通过队列传递,队列本身是一种限定资源
  • 读者-作者模式
  • 宴席哲学家问题

还有一些需要注意的事情:

  • 警惕同步方法间的依赖
  • 尽可能减小sychronized区域
  • 尽早考虑程序关闭问题
  • 测试线程代码

3个实例

书中以三个实例的重构过程向我们表现了一些将之前思路应用于优化代码的方式。

命令行参数解析:args

编程是一种技术甚于科学的东西,要编写整洁代码,必须先写肮脏代码,然后再清理它

在你的初稿,当代码糟糕透顶时甚至是前几稿中,很可能还是会存在烂摊子:成员多得吓人,奇怪命名的魔法字符串,一大堆的try-catch-finally代码。程序员们不都是蠢人,这堆糟糕透顶的代码其实是从最初看起来很合理但是扩展性差的代码一步步演化来的。

需要使用一些总结和抽象,来简明地表达你的目的。另外,在重构前,“我”(其实是作者)要不厌其烦地强调TDD的必要性,它能保证你重构的每一步,系统都可以工作。在重构过程中,放进拿出是常见的事,小步幅、保持测试通过,你可能会不断移动各种东西。

优秀的代码设计,大都关乎分隔——创建合适的空间防止不同种类的代码。对关注面的分隔让代码更易于理解和维护(减少理解所需要的大脑缓存)

JUnit

  • 不必要的编码前缀(f_
  • 未封装的条件判断
  • 建议使用肯定式代替否定式判断
  • 奇怪的不直观的函数名
  • 易造成理解困难的变量名
  • 拆分违反SRP原则的函数
  • 避免隐式时序耦合的函数,用hardcode的形式显示表现时序耦合

SerialDate重构

再强调一遍,重构前要有一个完整的验证可行性的测试。然后开始重构:

  • 没有描述力的类名和术语名
  • 使用枚举代替常量类
  • 抽象类中不应知道实现细节
  • 基类不宜知道子类的情况
  • 多余的注释
  • 变量声明应该放在尽量靠近使用的地方
  • 如果有专业术语,就不要自己命名了
  • 不要写无用的模板代码
  • 如果函数对成员进行操作,它就不应该是静态的
  • 解释临时变量的方式,让大段的代码更为简化和有表达力
  • 消除魔术数

味道和启发

作者在这里对《重构:既有代码设计的改善》里提到的味道做了自己的一些扩充,也可以作为对上面章节的回顾。

“味道”,即那些看起来不大对劲的代码

注释

  • 不恰当的信息,如修改记录
  • 过时的注释
  • 多余的废话
  • 错误的有误导性的注释
  • 注释掉的代码

环境

  • 多步才能完成的构建
  • 多步才能完成的测试

函数

  • 过多的入参
  • 布尔类型参数
  • 从未被调用的函数
  • 用于返回的参数

一般性问题

  • 源文件中有多种语言
  • 明显违背字面意义的直觉
  • 不考虑边界情况
  • 忽视安全问题
  • 重复,这也是最常见的问题。每次看到重复代码都代表遗漏了抽象。有一些常见的设计模式可以帮助你。
  • 代码的抽象层级有问题,或混杂。抽象类用来容纳高层级概念,子类用来容纳低层级概念。不同层级概念放在不同容器中。
  • 基类依赖于子类。通常来说,基类对子类应该一无所知
  • 信息过多,违背SRP
  • 从未使用的代码
  • 不恰当的垂直分隔
  • 语素前后不一致
  • 基于巧合、预设假设的耦合。异或是两个没有直接目的之间的模块的耦合。
  • 特性依恋,类的方法只应对自身的成员和方法感兴趣,不应关注其他类的成员和方法
  • 使用boolean或枚举参数让一个函数表现多态。使用多个函数通常由于向单个函数传递代码来选择函数行为
  • 晦涩的意图,如魔术数、魔术字符串、过度简写的表达式
  • 位置错误的权责
  • 不恰当的静态方法,如完全不需要多态的函数
  • 使用自解释的变量名
  • 使用自解释的函数名
  • 理解算法
  • 把逻辑依赖(脑海中的限制/已知条件)改为物理依赖
  • 使用if/else、switch前想想有没有多态的实现方法
  • 遵循团队lint规则
  • 足够准确
  • 未封装的条件判断
  • 未封装的边界条件检测
  • 避免否定性条件
  • 函数应该只做一件事
  • 函数应该只在一个抽象层级上
  • 隐蔽的时序性耦合
  • 别随意,先好好思考再下手
  • 应该在较高层级放置可配置数据
  • 避免传递浏览,即遵守德墨忒尔律

Java

  • 使用通配符避免过长的导入清单
1
import package.*
  • 不要继承常量,使用静态导入
1
import static EmployeeConstants.*
  • 在可以的情况下,用枚举代替常量

名称

  • 使用描述性名称
  • 名称应该与抽象层级相符
  • 使用标准化语素
  • 使用无歧义的名称
  • 在较大作用范围使用较长名称,较小作用范围可以使用较短名称
  • 名称应该明确说明有副作用存在

测试

  • 要有足够的测试
  • 使用覆盖率工具
  • 别放过小测试
  • 被忽略的测试是对不确定事物的疑问
  • 测试边界条件
  • 测试失败的模式(pattern)会有启发性
  • 测试覆盖率的模式会有启发性
  • 测试应该快速

并发编程示例

客户端/服务端

  • 如果吞吐量与I/O有关,则并发编程可以提升运行效率
  • 保持并发系统整洁,把线程管理隔离到一个位置

可能的执行路径

深入到字节码和汇编语句的执行上,有些并非线程安全的操作中,不同的执行路径会带来不同结果。

了解类库

  • Executor框架
  • 非锁定方案:AtomicBoolean,AtomicInteger和AtomicReference
  • 数据库连接、java.util中的容器、Servlet天生不是线程安全的

提升吞吐量

  • synchronized代码块最好能限制在小范围内

死锁

死锁需要满足4个条件:

  • 互斥,即资源数量有限,或无法在同一时间为多个线程公用
  • 上锁及等待,从线程获取资源到完成工作前,不会释放这个资源
  • 无抢先机制,线程无法从其他线程处夺取资源
  • 循环等待

相反地,有4种避免死锁的方式:

  • 不互斥,使用允许同时使用的资源,或增加资源数目
  • 不上锁及等待,如果有等待情况就释放所有资源从新来过
  • 满足抢先机制
  • 不做循环等待

测试多线程代码

  • 复现问题可能很难,可以借助工具(如ConTest)帮助

–END–

0%