0%

Encapsulate Behavior, not Just State

在系统理论中,包含是处理大型复杂系统结构的最有用的架构之一。在软件工程领域,包含和封装的价值已被认可。编程语言的架构,包含通过如子程序和函数、模块、包、类等方式来得到支持。

模块和包用于处理更大层面的封装需求,而类、子程序、函数则处理更多细节层面的事情。近些年我发现,类似乎成了开发人员最难封装的结构之一。一个类拥有3000多行的main方法,或者一个类仅有set/get方法用于它的原始属性的情况并不罕见。这些例子说明开发者还没有充分理解面向对象思想,没有发挥对象建模的优势。对于开发者熟悉的术语POJO以及POCO(Plain Old Java/C# Object),这相当于模型的范例,回归到了面向对象的基础——对象是朴实而简单的,但不是哑巴!

(译注: POJO是指仅有属性及其get/set方法的类,没有任何业务行为,常用于数据库的实体映射,在DDD中是典型的贫血模型)

一个对象要同时封装状态和行为,其中行为由实际的状态定义。设想一个“门”对象。它有四种状态:关着、开着、关上、打开。它提供了两种操作:开和关。基于这些状态,开和关操作将产生不同行为。一个对象的内在特性使得设计过程在概念上变得简单。可以归结为两个简单的任务:给不同的对象分配和委托职责,包括对象间的通信协议。

通过一个例子可以最好的说明在工作中要如何运用。让我们设想有三个类:Customer, Order, 和Item。Customer对象是信用限额与信用验证规则的天然占位符。一个Order对象知道它和Customer的关系,它的addItem操作通过调用customer.validateCredit(item.price())委托实际的信用验证。如果这个方法的后置条件失败,抛出异常并终止交易。

经验不足的面向对象开发者可能会决定把所有的业务规则打包到通常称为OrderManagerOrderService的对象当中。在这些设计中,Order,Customer,Item被用作记录类型。所有的逻辑都是从类里分解出来并捆绑在一个大的、有大量if-then-else构成的程序方法内。这些方法很容易被破坏,而且几乎不可能维护。原因?封装被破坏了呀。

什么是GO语言的标准库?就是放在$GOROOT//usr/local/go/pkg目录下的那些文件,它们由GO语言社区共同维护的,经过良好设计,确保代码稳定且易用。每次发布GO的新版本时,都会将这些库打包成.a静态库文件。

标准库中有非常多的基本功能,不用在为业务开发而重新造轮子,比如我们最熟悉的fmt,以及log、json、http、网络、图像处理、加密算法等等。

本章仅对log、json、io三个包的调用方式及基本原理做总结,乍一看可能会觉得本章只是带着你了解一下几个函数的基本用法,别不以为然,我个人的理解,本章最最最精华的内容就是最后那句话——阅读标准库的代码时熟悉GO语言习惯的好方法

所以,没事多看看官方文档:http://golang.org/pkg/

定制自己的Logger

日志是每个程序开发最常用的功能了,一般来说C/C++/Java/C#都会有第三方的日志框架实现,用起来都挺顺手。不过,GO语言标准库中已经包含了日志框架——log包,不用再满世界地去比较到底哪个框架好用了。

先来看看log包的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
引用自 golang.org/src/log/log.go
const (
Ldate = 1 << iota // 日期: 2009/01/23
Ltime // 时间: 01:23:23
Lmicroseconds // 毫秒级时间: 01:23:23.123123. 覆盖 Ltime.
Llongfile // 完整的源码文件路径及行号: /a/b/c/d.go:23
Lshortfile // 短路径及行号: d.go:23. 会覆盖 Llongfile
LUTC // 如果设置了Ldata或Ltime,采用UTC取代本地时区
LstdFlags = Ldate | Ltime // 标准日志初始值
)
*/

// 初始化后,所有直接调用log包的日志输出都会受影响
func init() {
// 设置日志的前缀信息
log.SetPrefix("[LogPrefix] ")
// 设置日志的中段标示,参考上边的注释
log.SetFlags(log.Ldate | log.Lmicroseconds | log.Lshortfile)
}

上边的代码很简单(前提是忽略掉注释部分),在init函数中先初始化log的基本格式,就是在输出到屏幕时的样式。

调用更简单:

1
2
3
4
5
6
7
8
9
10
package main

import "log"

func main() {
log.Println("hello world")
}

// ------------以下是程序输出--------------
[LogPrefix] 2019/03/09 14:34:06.763601 main.go:6: hello world

init函数中,log被设置了前缀[LogPrefix] 以及中间部分的完整时间日志+文件名及行号,所以一个简单的helloworld日志信息前会自动追加很多有效调试信息。

这里只需要记住一点,log包一旦被设置,全局有效!

那么问题来了,如果我需要两种以上不同的日志格式怎么办?答——log.Logger

先定义一套自己的Logger规则:

1
2
3
4
5
6
var (
Trace *log.Logger // 普通跟踪调试信息
Info *log.Logger // 特殊信息
Warning *log.Logger // 警告日志
Error *log.Logger // 错误日志(输出到文件)
)

如上,定义了4个logger对象,分别用于:

  • 普通信息:程序正式发布时,该部分不再输出
  • 特殊信息:总是打印到屏幕上
  • 警告:类似于“特殊信息”,你要愿意,也可以修改其字体颜色
  • 错误:同时将日志打印到屏幕,保存至文件

有了这4套机制的需求后,再来看看它们是如何被实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
func init() {
file, err := os.OpenFile("error.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Fatalln("Failed to open error file: ", err)
}

// 因为ioutil.Discard,所有通过Trace打印的日志都不会输出
Trace = log.New(ioutil.Discard, "TRACE: ", log.Ldate|log.Ltime|log.Lshortfile)
Info = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)
Warning = log.New(os.Stdout, "WARNING: ", log.Ldate|log.Ltime|log.Lshortfile)
// io.MultiWriter表示多种输出渠道,即同时打印到屏幕和文件
Error = log.New(io.MultiWriter(file, os.Stderr), "ERROR: ", log.Ldate|log.Ltime|log.Llongfile)
}

核心只有一个:log.New(out io.Writer, prefix string, flag int)函数,会根据参数设置好logger的输出目的地、前缀信息、日志标示等,并将其返回。

留意一下,Trace这个logger的输出对象是ioutil.Discard,其实就是不输出的意思;而另一处logger的对象Error关于io.MultiWriter()表示多目标输出,可以看到,既要输出到file,还要输出到stderr。

好了,至于这4个logger的调用方式嘛,和标准库中的log是一模一样的。

从Github上获取用户信息

为了学习调用GO的标准库进行json编解码,书中是用Google的API请求来举例……所以我觉得还是用GitHub来演示,效果会好一点。anyway anywhere,只要明白json序列化和反序列化即可。

反序列化

先用浏览器随便GET个用户信息试试,比如就我自己的地址:https://api.github.com/users/philon

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"login": "Philon",
"id": 2968783,
"node_id": "MDQ6VXNlcjI5Njg3ODM=",
"avatar_url": "https://avatars0.githubusercontent.com/u/2968783?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/Philon",
...
"public_repos": 4,
"public_gists": 0,
"followers": 15,
"following": 0,
"created_at": "2012-12-05T06:28:25Z",
"updated_at": "2019-03-06T12:47:11Z"
}

多句嘴,我觉得GitHub的RESTful设计得相当不错!

可以看到GitHub返回了我的个人账户信息,并且是json格式,如果在GO程序中同样可以采用http.Get()函数获取到这些信息,只可惜get到的全部是一对字符串,如何将其变成一个便于处理和调用的数据结构呢?两种方式:

方式一、类型映射

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
// User Github用户账户信息
// 每个变量最后用反引号标示的字符串是——标签
// 标签可为之后的json.Decoder提供映射依据
type User struct {
Login string `json:"login"`
ID int64 `json:"id"`
AvatarURL string `json:"avatar_url"`
GravatarID string `json:"gravatar_id"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
FollowersURL string `json:"followers_url"`
FollowingURL string `json:"following_url"`
GistsURL string `json:"gists_url"`
StarredURL string `json:"starred_url"`
SubscriptionsURL string `json:"subscriptions_url"`
OrganizationsURL string `json:"organizations_url"`
ReposURL string `json:"repos_url"`
EventsURL string `json:"events_url"`
RecievedEventsURL string `json:"recieved_events_url"`
Type string `json:"type"`
SiteAdmin bool `json:"site_admin"`
Name string `json:"name"`
Company string `json:"company"`
Blog string `json:"blog"`
Location string `json:"location"`
Email string `json:"email"`
Hireable string `json:"hireable"`
Bio string `json:"bio"`
PublicRepos int32 `json:"public_repos"`
PublicGists int32 `json:"public_gists"`
Followers int32 `json:"followers"`
Following int32 `json:"following"`
CreateAt string `json:"create_at"`
UpdateAt string `json:"update_at"`
}

// DeserializeToType 获取用户信息,并反序列化为User类型
func DeserializeToType(name string) {
// 根据用户名从GitHub获取对应用户的json信息
resp, err := http.Get("https://api.github.com/users/" + name)
if err != nil {
log.Println("ERROR: ", err)
}

defer resp.Body.Close()

var user User
// 通过Decode将响应内容反序列化为对象
err = json.NewDecoder(resp.Body).Decode(&user)
if err != nil {
log.Println("ERROR: ", err)
}

fmt.Printf("user: %v\n", user)
}

以上的代码很长,但归根结底就两个部分:

  1. 在struct类型定义中,通过name type tag的标准格式定义结构类型中的每个属性,注意最后反引号框起来的标签,它用于之后给json反序列化提供映射依据——如果仔细比对就会发现,每个tag中的名称都和GitHub响应返回的json中的键名严格一致。
  2. DeserializeToType函数其实就是具体的反序列化过程了,只需要记住json.NewDecoder().Decode(&user)这个标准库中的函数即可,该函数会根据第1部分的tag将json数据解析后填入对象中。

方式二、字典映射

有的时候,其实没必要为每个json消息都定义类型,不然得累死,所以GO提供了一种更为灵活的方式——字典。还是以获取GitHub用户信息为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func DeserializeToMap(name string) {
resp, err := http.Get("https://api.github.com/users/" + name)
if err != nil {
log.Fatalln("ERROR: ", err)
}

defer resp.Body.Close()

// 读取响应Body并转化为[]byte数据结构
content, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatalln("ERROR: ", err)
}

var user map[string]interface{}
// 通过Unmarshal将[]byte转换为字典
err = json.Unmarshal(content, &user)
if err != nil {
log.Fatalln("ERROR: ", err)
}

fmt.Println("Username: ", user["login"])
fmt.Println("Followers: ", user["followers"])
}

在上述代码中,先通过ioutil.ReadAll将服务器端的响应读取出来并转换为[]byte字节流形式,然后在通过json.Unmarshal把这个字节流转换成map[string]interface{}的数据字典形式,注意字典的类型必须是这种,不要随意更换。

如果要根据json中的键来获取值信息就非常简单了,就是上述代码的最后两行user["key"]即可。

序列化

明白了反序列化的处理方式,还要懂得序列化,毕竟请求/应答不分家,那么如何将一个具体的数据结构转化为json字符串呢?方式只有一种json.Marshal,但要注意数据类型一般是两种:对象和字典。

类型对象的序列化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func SerializeType() {
user := &User{
Login: "张三",
ID: 9527,
URL: "https://api.github.com/users/ZhangSan",
}

// 带缩进格式的序列化,缩进为4个空格
data, err := json.MarshalIndent(user, "", " ")
if err != nil {
log.Fatalln("ERROR: ", err)
}

fmt.Println(string(data))
}

上述代码定义了一个User对象,并通过json.MarshalIndent非常容易就将其转换为data字节流,为了输出我们把它强转为string类型。

留意一下,func MarshalIndent(v interface{}, prefix, indent string) 函数是带格式化的转换,也就是说,默认情况下,json字符串其实没有空格和换行的,这个函数可以根据你的喜好从新将其格式化。

字典的序列化:

1
2
3
4
5
6
7
8
9
10
11
12
13
func SerializeMap() {
c := make(map[string]interface{})
c["name"] = "张三"
c["id"] = 9527

// 不带缩进格式化的序列化
data, err := json.Marshal(c)
if err != nil {
log.Fatalln("ERROR: ", err)
}

fmt.Println(string(data))
}

字典无需过多重复,和反序列化那部分基本一样。

输入输出

关于io.Readerio.Writer就我个人而言,没有太多值得牢记的地方,最多也就一句话:但凡实现io.Reader/Writer接口的类型,都可以被标准库中的io调用

以书中的例子来说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() { 
// 创建一个Buffer值,并将一个字符串写入Buffer
// 使用实现io.Writer的Write方法
var b bytes.Buffer
b.Write([]byte("Hello "))

// 使用Fprintf来将一个字符串拼接到Buffer里
// 将bytes.Buffer的地址作为io.Writer类型值传入
fmt.Fprintf(&b, "World!")

// 将Buffer的内容输出到标准输出设备
// 将os.File值的地址作为io.Writer类型值传入
b.WriteTo(os.Stdout)
}

上述代码中,首先Buffer类型实现了Write方法,所以变量b可以被标准的fmt.Fprintf接受。而Buffer.WriteTo(*File)函数是写到一个文件中,os.Stdout就是标准输出文件。

Reader.Read()接口几乎也是同样的道理,只要实现Read接口的类型,都可以被标准库接受。

我觉得书中本章最开始的话也充分反映了GO语言的思想:GO开发者会比其它语言的开发者更依赖标准库里的包。为什么要非常熟悉GO语言的标准库,为什么要充分掌握其接口的实现原则。从上边的三个例子中就可以看出,每当我们需要增加新业务时,完全可以仅实现标准库中的某个接口,就可以近乎完美地衔接进整个GO生态。

比如我为某个结构类型实现了Writer.Write()接口,我几乎可以肯定,这个类型同时“继承”了log、json包里的诸多功能。

小结一下

  • 标准库有特殊的保证,并且被社区广泛应用。
  • 使用标准库的包会让代码易于管理,更加受信任。
  • 标准库放在$GOROOT/pkg下,以静态库形式存放。
  • log.Logger可以定制自己的日志形式。
  • json包可以通过结构类型的标签,实现序列化和反序列化
  • map[string]interface{}也可以用于json编解码
  • 接口允许代码组合已有的功能,得接口者得全标准库
  • 熟悉标准库!熟悉标准库!熟悉标准库!

Don’t Touch that Code!

在我们每个人身上都发生过。你的代码放到预演服务器上用于系统测试,然后测试经理反馈她发现了个问题。你的第一反应就是“快,让我修复它——我知道错误在哪儿。”

从更大的意义上来说,尽管,作为一个开发者犯的错,你觉得你应该去访问预演服务器。

大多数基于web的开发环境中,架构可以像这样分解:

  • 本地开发,并在开发者机器上完成单元测试
  • 手动或自动完成在开发服务器上集成测试
  • QA团队和用户访问预演服务器完成测试
  • (发布到)生产服务器

是的,那里还有其它的服务器和服务,就像源码控制和票据,但你获得了注意。使用这个模型,一个程序员——甚至是一个有资历的程序员——应该永远不要访问除了开发服务器意外的东西。很多开发是在开发者本地机器用他们喜欢的IDE、虚拟机上完成的,为了好运,还会撒上适量的黑魔法。

一旦进入SCC检查,不论是自动还是手动,都应该将其从开发服务器转交出去,并可以在其它地方被测试和调整,以确保一切工作正常。尽管,从这点来看,开发者在此过程中只能是一个观众。

预演经理应该为QA团队打包并转移代码到预演服务器。就像开发者不需要访问任何开发服务器意外的事情一样,QA团队和用户也要碰任何在开发服务器上的东西。如果它准备接受测试,切出发布(分支)并移交,不要再让用户到开发服务器上“只是看一点东西,真的很快”。记住,除非整个工程代码都是你写的,否则其他人会在上面改代码,并且他们可能并没有准备好让用户看。发布经理才是唯一应该访问这两者的人。

在任何,所有情况下——都不应该——让一个开发人员去访问生产服务器。如果又问题,你的技术支持人员要么修复它,要么请求修复它。在SCC检入之后,它们将在此滚动一个补丁。我曾目睹过一些超大型的编程灾难发生,由于某些人(咳咳)违反了这条规则。如果它破掉了,生产可不是修复它的地方。

我大概是在某个播客节目中听到本书的名字,同时因为对文革、网络喷子、游行示威、打砸等历史或现象多少有些兴趣,便怀着好奇心翻开了这本书。从《乌合之众:大众心理研究》的书名都能猜到,这本书就是讲群众行为和心理的,不论题材和角度都能,或多或少有些发人深省的地方。所以,想要对某些群体心理多些洞察力,这本书还是值得一读的。

但是,从文体内容而言,我感觉这本书就想一本心理学论文,不乏很多枯燥的长篇论述,食之无味。如果我是一名心理学家或者社会学家,那这本书也许能持续挑逗我的精神味蕾,可偏偏我就是个门外汉,那些长篇大论除了帮助我增加丝丝睡意外,好像也没什么能吸引我的地方。

阅读全文 »

我们可以通过goroutine和channel机制非常方便地编写并发业务,但就和面向对象与设计模式的关系一样,是一种思想具体落实到行动方针的过程,在牛逼的战略,没有基本的战术指导,也只是空谈。

因此,第七章并发模式,并没有太多语法上的新东西,而是利用goroutine和channel介绍了三种并发模式,分别适用于三种不同的业务场景。

  1. runner——给每个并发任务设置deadline,管理并发任务的生命周期
  2. pool——利用有缓冲通道创建资源池,统一管理并发时的资源访问
  3. work——利用无缓冲通道创建goroutine池,统一管理并发

runner

先假设一个场景需求,比如http服务的并发,我们要为每个来自客户端的请求创建一个临时的并发响应任务,但这个最好在某个规定的时间内完成响应,否则就强制它退出,这样可以很好地避免某些情况下,一些并发任务卡死的情况,同时可以很好地管理每个并发的生命周期。

runner就是为这样的场景应用而生的,runner可以理解为是一个运行管理器,所有的并发任务都要叫给它负责管理,它负责并发任务的启动、超时监控、强制中断等。

(由于我个人在阅读原著的时候是先讲runner的内部实现,再看实际应用,总感觉云里雾里的,觉得还是先通篇看一下如何运用runner,再来看其内部的实现,可能效果会好一点)

先来看runner的示例:

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
func testRunner() {
log.Println("Runner test starting work...")

// 新建一个runner,并强制每个并发任务的超时时间为5秒
r := runner.New(3 * time.Second)
// 循环创建10个并发任务,并将其丢给runner管理
for i := 0; i < 5; i++ {
r.Add(func(id int) {
// 这里只是模拟,每个并发任务都是睡眠它自身id的秒数
log.Printf("Processor - Task #%d.\n", id)
time.Sleep(time.Duration(id) * time.Second)
log.Printf("Task #%d done.\n", id)
})
}

// 一次性启动runner内部的全部并发任务
if err := r.Start(); err != nil {
switch err {
case runner.ErrTimeout:
// 当并发任务中有任务执行超时,就立即返回
log.Println("Terminating due to timeout.")
os.Exit(1)
case runner.ErrInterrupt:
// 当程序被ctrl+c时,强制结束所有并发任务
log.Println("Terminating due to interrupt.")
os.Exit(2)
}
}

log.Println("Process end.")
}

//-------------程序输出---------------
2019/03/03 14:48:41 Runner test starting work...
2019/03/03 14:48:41 Processor - Task #2.
2019/03/03 14:48:41 Processor - Task #4.
2019/03/03 14:48:41 Processor - Task #3.
2019/03/03 14:48:41 Processor - Task #0.
2019/03/03 14:48:41 Task #0 done.
2019/03/03 14:48:41 Processor - Task #1.
2019/03/03 14:48:42 Task #1 done.
2019/03/03 14:48:43 Task #2 done.
// ----第3个及以后的任务因为要睡3秒以上,肯定会超时----
2019/03/03 14:48:44 Terminating due to timeout.
// ----如何运行过程中按ctrl+c,会安全退出并提示----
^C2019/03/03 14:49:36 Terminating due to interrupt.

可以看到,runner就是一个类型,需要用其创建对象后才能具体使用。而在外部,我们只需要定义好每个任务的函数,并简单的将它们添加到runner当中即可,剩下的全部交由runner自行管理。

现在再来看看runner类型是如何实现的:

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
// Runner 在指定的超时时间内完成一组任务
// 并且在这个时间周期内接收系统的中断信号来结束这组任务
type Runner struct {
// 从系统接收中断信号的通道
interrupt chan os.Signal
// 任务已完成的报告通道
complete chan error
// 任务超时的报告通道
timeout <-chan time.Time
// 任务列表
tasks []func(id int)
}

// ErrTimeout 任务执行超时时返回
var ErrTimeout = errors.New("received timeout")

// ErrInterrupt 收到系统中断信号时返回
var ErrInterrupt = errors.New("received interrupt")

// New 创建Runner的工厂函数
func New(d time.Duration) *Runner {
return &Runner{
interrupt: make(chan os.Signal, 1),
complete: make(chan error),
timeout: time.After(d),
}
}

// Add Runner的方法,将多个任务添加到Runner的任务列表中
func (r *Runner) Add(tasks ...func(int)) {
r.tasks = append(r.tasks, tasks...)
}

// Start Runner的方法,启动所有任务,并监听通道事件
func (r *Runner) Start() error {
// 开始接收系统的中断通知
signal.Notify(r.interrupt, os.Interrupt)

// 通过gorouting并行启动所有任务列表中的任务
var wg sync.WaitGroup
wg.Add(len(r.tasks))
for i, t := range r.tasks {
go func(id int, task func(int)) {
task(id)
wg.Done()
}(i, t)
}
// 等待所有任务执行完成,并给“已完成通道”一个报告
go func() {
wg.Wait()
r.complete <- nil
}()

// 获取无缓冲通道数据时,如果没准备好,会被阻塞
select {
case err := <-r.complete: // 任务正常实行完返回任务自身的“错误标示”
return err
case <-r.timeout: // 任务执行超时,返回超时错误
return ErrTimeout
case <-r.interrupt: // 如果收到ctrl+C则停止接收后续的信号
signal.Stop(r.interrupt)
return ErrInterrupt
}
}

由于只是原型演示,runner的内部实现不算复杂,只需要记住一个核心思想无缓冲通道在没有数据读写的时候,会被阻塞。说千道万,runner就是利用了这个特性才得以在select语句中完成了:

  • 并行接收系统的中断信号——interrupt通道。
  • 并行接收定时器的超时信号——timeout通道。

pool

这里的pool是指资源池的意思,如果熟悉Java/C#中的“数据连接池”的概念,那这里的池大体就是这个意思了。

换而言之,在并发场景下,难免会遇到并发任务争夺临界资源的情况,还是以数据库访问为例:如果有1000个并发任务要去访问数据库,每个并发都需要完成建立连接——认证——查询——断开连接等操作,那不论是应用服务器还是数据库服务器,无疑都是巨大的负担。因此,通过创建10个数据库连接,并把这些“连接”当作资源放入“池”中,给所有的并发任务共享,每个并发在需要的时候从池中取出连接,完成查询后再放回池中,不仅能大幅降低CPU的负载,也能减少内存的开销。(但我个人觉得最爽的地方是,你的代码可以更专注地去query,而不必考虑connection本身😂)

同样,先来看看pool的运用过程:

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
const (
maxGoroutines = 25 // 要使用的goroutine的数量
pooledResource = 5 // 池中的资源的数量
)

// dbConnection 模拟要共享的资源
type dbConnection struct {
ID int32
}

// Close 实现io.Closer.Close接口,释放资源
// 让其可以被Pool管理
func (dbConn *dbConnection) Close() error {
log.Println("Close: Connection", dbConn.ID)
return nil
}

// idCounter 用来给每个连接分配唯一id
var idCounter int32

// createConnection 创建唯一id的连接
func createConnection() (io.Closer, error) {
id := atomic.AddInt32(&idCounter, 1)
log.Println("Create: New Connection", id)

return &dbConnection{id}, nil
}

// testPool Pool测试用例
func testPool() {
var wg sync.WaitGroup
wg.Add(maxGoroutines)

// 创建管理连接池,并创建N个“连接”资源,加入池中
p, err := pool.New(createConnection, pooledResource)
if err != nil {
log.Println(err)
}

// 创建M个并发任务,模拟查询数据库
for query := 0; query < maxGoroutines; query++ {
go func(q int) {
performQueries(q, p)
wg.Done()
}(query)
}

wg.Wait()
log.Println("Shutdown Program")
p.Close() // 关闭池
}

func performQueries(query int, p *pool.Pool) {
conn, err := p.Acquire()
if err != nil {
log.Println(err)
return
}

// 完成查询后,将资源释放会池里
defer p.Release(conn)

// 用随机睡眠1000微妙内的时长,来模拟查询中的耗时
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
log.Printf("QID[%d] CID[%d]\n", query, conn.(*dbConnection).ID)
}

(可能是我没学习到位,我个人觉得pool模式并不是特别容易掌握,思想是很好理解的,但牵扯太多接口实现、有/无缓冲通道的特性等内容,所以代码可能要再多消化几遍。)

再看看pool包的实现:

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
// Pool 资源池
// 管理一组资源,可以安全地在多个goroutine共享
// 实现 io.Closer接口
type Pool struct {
m sync.Mutex
resource chan io.Closer
factory func() (io.Closer, error)
closed bool
}

// ErrPoolClosed 资源池已关闭的错误标示
var ErrPoolClosed = errors.New("Pool has been closed")

// New 创建Pool的工厂函数
func New(fn func() (io.Closer, error), size uint) (*Pool, error) {
if size <= 0 {
return nil, errors.New("size value too small")
}

return &Pool{
factory: fn,
resource: make(chan io.Closer, size),
}, nil
}

// Acquire 从资源池中获取一个资源
func (p *Pool) Acquire() (io.Closer, error) {
select {
case r, ok := <-p.resource:
// 检查是否有空闲资源
log.Println("Acquire:", "Shared Resource")
if !ok {
return nil, ErrPoolClosed
}
return r, nil
default:
// 如果没有可用资源,就创建一个
log.Println("Acquire:", "New Resource")
return p.factory()
}
}

// Release 释放一个资源,将其放回资源池
func (p *Pool) Release(r io.Closer) {
p.m.Lock()
defer p.m.Unlock()

// 如果该资源已经被关闭,销毁这个资源
if p.closed {
r.Close()
return
}

select {
case p.resource <- r:
// 试图将该资源加入队列
log.Println("Release:", "In Queue")
default:
// 如果队列已满,关闭这个资源
log.Println("Release:", "Closing")
r.Close()
}
}

// Close 关闭资源池中的所有资源,并停止工作
func (p *Pool) Close() {
p.m.Lock()
defer p.m.Unlock()

if p.closed {
return
}

p.closed = true

close(p.resource)

// 关闭所有资源
for r := range p.resource {
r.Close()
}
}

pool资源池实现的核心思想是有缓冲通道读写时不会引起阻塞,select语句在通道内没有数据的情况下会自动执行default选项

work

work模式就是创建一个goroutine池,管理池中所有的goroutine统一执行。但它有别于runner模式,runner其实是负责监控池中的每个并发任务的生命周期的,而work则是负责池中的每个并发任务的执行顺序,即任务队列。

这个模式的好处在于,可以很好地控制程序运行的负载,比如突发情况下,某台服务器的http请求一瞬间到达100万,如果为了响应所有请求也在一瞬起启动100万个响应任务,那估计服务器就冒烟了。所以最好的方式就是限制并发任务数量,比如每次最多启动1万个响应,剩下的排队慢慢来。

因此,work就是一个并发任务的队列池,还是先看看如何运用的:

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
var names = []string{
"steve",
"bob",
"mary",
"therese",
"jason",
}

type namePrinter struct {
name string
}

// Task 实现Worker接口
func (m *namePrinter) Task() {
log.Println(m.name)
time.Sleep(time.Second)
}

func testWork() {
// 设置工作池的“工位”为2,即每次只能有两个人工作
p := work.New(2)

var wg sync.WaitGroup
wg.Add(100 * len(names)) // 每人肩负100项任务,共5人

// 一次性把5 * 100个任务全部丢到工作池中
// 相当于创建了500个goroutine
for i := 0; i < 100; i++ {
for _, name := range names {
np := namePrinter{name: name}
go func() {
p.Run(&np) // 将对象的任务丢到工作池中统一管理执行
wg.Done()
}()
}
}

// 等待所有任务在工作池中被完成
wg.Wait()
p.Shutdown()
}

如上代码,namePrinter实现了work包内规定的Task接口之后,work的工作池就能够统一管理namePrinter对象了。这个namePrinter可以理解为某个业务的模拟,比如上面说的http响应任务(这里仅是简单地做个打印)。

而后,不论创建多少个namePrinter相关的goroutine(并发),都只需简单地将其丢到工作池中Run(p.Run并没有立刻启动任务,工作池会根据情况自行安排)。

最后在看看work包的实现:

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
// Worker 必须满足接口,才能使用工作池
type Worker interface {
Task()
}

// Pool 工作池,相当于goroutines池管理
type Pool struct {
work chan Worker
wg sync.WaitGroup
}

// New 创建工作池的工厂函数
func New(maxGoroutines int) *Pool {
p := Pool{
work: make(chan Worker),
}

p.wg.Add(maxGoroutines)
for i := 0; i < maxGoroutines; i++ {
go func() {
// p.work是通道,所有创建goroutine之后
// for循环会被阻塞,直到p.work被关闭为止
for w := range p.work {
w.Task()
}
p.wg.Done()
}()
}

return &p
}

// Run 提交工作到工作池
func (p *Pool) Run(w Worker) {
p.work <- w
}

// Shutdown 等待所有goroutines结束
func (p *Pool) Shutdown() {
close(p.work)
p.wg.Wait()
}

work的实现其实是非常简单的,核心思想是for-range循环时,无缓冲通道会阻塞,工作池是一个无缓冲通道,而每个for-range都相当于一个队列,当池中有数据是,所有的for-range都会争夺这个输入数据来处理,但如果某个队列本身已经在工作时,就没空再争夺通道内的数据。可以说是最简单有效的负载均衡。

小结一下

  • 无缓冲通道在读写时会引起阻塞,可以用来控制程序生命周期
  • 带default分支的select语句会尝试读写通道,而不会阻塞
  • 可以利用无缓冲通道创建一个工作池,统一管理goroutine并发任务
  • 可以利用有缓冲通道创建一个资源池,统一管理并发时的资源访问

Don’t Repeat Yourself

编程的所有原则中,不要重复自我(DRY)或许是最基本的原则之一。该原则被Andy Hunt和Dave Thomas的《程序员修炼之道》一书中提出,便成了很多为知名软件开发的最佳实践和设计模式的基础。开发人员学习辨认副本,并理解如何通过适当的实践和抽象来消除这些重复,相比于一直被传染多余的重复代码的应用程序,可以产出更简洁的代码。

复制是种浪费

应用程序的代码中的每行代码都将会维护,也将会成为潜在的bug。重复会使代码库不必要的臃肿,结果让bug有机可乘,并增加系统的耦合度。重复的臃肿加入系统也会让开发者难以彻底理解系统全貌而,还有一点可以确定,另外为了确保它们能正常工作,修改了某处的逻辑也需要修改另一处的复制。DRY需要“系统内每处的知识都必须具有单一、明确、权威的表示”。

过程中的重复要自动化

很多软件开发中的流程都是简单而重复的自动化。DRY原则适用于这些应用程序源码中的上下文。手动测试太慢了,容易出错,也很难重复操作,所以如果可能的话,自动化测试套件应该被用起来。如果手动进行软件集成可能会耗时且容易出错,因此构建过程应尽可能频繁地运行,理想情况下每次签入(代码库)时都跑一遍。只要存在能被自动化的痛苦的人工流程,都应该被自动化和标准化。其目标是确保仅有唯一的方式来完成任务,并且要尽可能做到无痛。

逻辑中的重复要抽象

逻辑中的重复会有很多形式。复制粘贴if-then或switch-case逻辑是最容易检查并纠正的。很多设计模式都有明确的目标,要降低或消除应用程序中的重复。如果一个对象在使用前需要同时具备几个事情的发生,就可以用抽象工厂或者工厂方法来完成。如果一个对象的行为会有很多可能的变化,这些行为就可以通过策略模式来注入,要好于大量的if-then结构。事实上,设计模式本身的制定是为了降低常见问题带来的重复,并讨论这些解决方案。此外,DRY可以适用于数据结构,如数据库模式,从而更规范。

一个原则问题

其它的软件原则也和DRY相关。有且仅有一次原则,仅适用于代码功能行为,可以被认为是DRY的一个子集。开闭原则,“软件中的实体应对扩展开放,对修改封闭”的情况,仅在遵循DRY时才能在实践中起作用。同样众所周知的单一职责原则,需要一个类“只有一个改变原因”依赖于DRY。

当遵循结构、逻辑、流程、以及函数时,DRY原则能为开发人员提供基本指导,并帮助创建更简单,更易于维护,更高质量的应用。尽管有一些情况下,必须用重复来满足性能或其它需求(例如数据库中的非规范化数据),它应该被直接用于解决实际而非想象的问题。

Don’t Rely on “Magic Happens Here”

如果你看过足够多的任何活动、流程、或纪律,它们都看起来很简单。一个没有开发经验的经理会认为程序员做的事情很简单,同样的,一个没有管理经验的程序员也会这么看待经理所做的事情。

编程是一些人在一些时候做的事情。其中困难的部分——思考——是最不容易被外行看到和赞赏的。几十年来,已经有许多尝试想要移除这些熟练思维的需求。最近的也是最难忘的一个是格蕾丝·赫柏(Grace Hopper)致力于使编程语言不那么神秘——一些人语言这将消除对专业程序员的需求。结果(COBOL语言)在随后的几十年中为大量的专业程序员的收入作出了贡献。

消除编程以简化软件开发是个长久的愿景,对于熟知其(内部的)复杂程序的程序员而言,显然很天真。但是导致这些错误的心理过程是人性的一部分,程序员也会想他人一样容易出现。

在任何项目中,有很多潜在的事情是个别程序员没有积极参与的:从用户中引出需求,获得预算审批,设置构建服务器,部署应用到质量保证和生产环境,从旧的流程或程序中迁移业务,等等。

当你无法积极参与到某件事中时,就会无意识地趋于假设它们都很简单或着会有“魔法”出现。尽管魔法持续出现让一切安稳。但是——它们通常是“何时”而不是“如果”——魔法的停止了,该项目就会陷入困境。

我知道项目于会消耗掉数周开发者的时间,因为没有一个人了解该怎样加载它们依赖的“正确”版本的dll。当事情开始断断续续地出错时,在有人注意到正在加载“一个错误”版本的dll之前,团队成员还在任何地方查找。

其它部门运转平稳——项目准时交付,没有推迟夜间调试会议,没有紧急修复。如此平稳,事实上,高级经理都觉得这事能“自己运转”,它们都不需要管理者了。然而6个月内,该部门的项目看起来就像组织的多余部分一样——太迟了,疯狂连续的打补丁。

你不需要理解所有的让你项目工作的魔法,但理解它的一部分也没坏处——或者感激那些懂得你未知领域的人。

最重要的是,确保当魔法停止的时候,它可能再次启动。

首先要肯定,这本书的阅读体验非常好,本书总结了52种生活“常见”的思维误区,每一种都是一千字左右的总结,其内容浅显易懂但又略带颠覆,加上一些配图,读起来干净利落。

不过阅读归阅读,这种观点“零散”的内容不利于写书评,我也不打算给每种思维错误都写个总结——毕竟就一千多字,还不如读原文。所以我只是简单地穿插其中的一些我认为比较重要的观点,整理成文。

阅读全文 »

但凡复杂一点的业务,并发基本跑不了,其实说白了无非是多线程/多进程架构,可一旦涉及并发模式,少不了调度、同步、互斥、资源访问等一堆堆问题,解决这些问题又需要一堆堆代码,这些代码不仅维护难度高,而且可以说和业务本身没有半毛钱关系(纯粹的技术问题有木有)。

But!GO语言对并发的运用相比其它语言还是相当愉快的。根据本章内容可以学到,GO语言的“多线程”机制goroutine,以及多线程之间的通信方式channel。

GO运行时(runtime)

在说GO的并发之前需要先搞清楚一件事,否则后面会一头雾水。

由于前几章并没有特别说明,加上go build命令可以直接生成一个独立的可执行文件(而且没有任何依赖),会让人误以为go程序是类似c/c++一样的机器码。其实不然,GO的可执行文件内部嵌入了runtime,本质上和Java/.Net一样跑在虚拟机之上

GO的运行时负责内存管理、垃圾回收、栈处理等等,而其中一个很重要的功能便是goroutine和channel的管理。

通常情况下,GO运行时默认给整个应用程序分配一个逻辑处理器,逻辑处理器会绑定到物理处理器上。一个GO程序默认最多创建10000个线程,但可以通过runtime包的SetMaxThreads方法来修改,程序里的线程超出最大线程数会导致程序崩溃。

goroutine

所以通过上述要搞懂,goroutine不是系统分配的线程,更不归操作系统调度,一切都是靠运行时分配和调度。但不论如何,为了便于前期的学习和理解并发,这里默认goroutine就是线程。

先来创建10个线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
var wg sync.WaitGroup
wg.Add(10) // WaitGroup计数加10

for i := 0; i < 10; i++ {
// 创建goroutine
go func(id int) {
fmt.Printf("Goroutine-%d\n", id)
wg.Done() // WaitGroup计数减1
}(i)
}

wg.Wait() // 等待,直到WaitGroup计数为0
}

代码输出:

1
2
3
4
5
6
7
8
9
10
Goroutine-2
Goroutine-9
Goroutine-1
Goroutine-3
Goroutine-5
Goroutine-8
Goroutine-4
Goroutine-7
Goroutine-6
Goroutine-0

从上面代码可以看出,for循环只是顺序创建了10个goroutine,但输出是并行的,没有顺序。

再强调一遍,goroutine不是线程

尽管从效果上看,goroutine就是线程,但事实是上边的程序只有一个线程,交由go运行时维护,线程内部会自动负责多个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
var count int // 声明一个全局变量

func main() {
var wg sync.WaitGroup
wg.Add(5)

for i := 0; i < 5; i++ {
go func() {
defer wg.Done() // 👈匿名函数退出时调用

var num = count // 读全局变量
fmt.Printf("num = %d\n", num)
count = num + 1 // 写全局变量
}()
}

wg.Wait()
// 所有goroutine结束后,count值并不为5
fmt.Printf("count = %d\n", count)
}
// ----------程序输出结果(每次都是随机的)--------
num = 0
num = 1
num = 0
num = 0
num = 0
count = 1

上述这段代码可以非常明显的看到,5个goroutine都对全局变量count做了加1的运算,结果count最终值却是1而非5。这也说明,在没有措施的情况下并发去访问全局变量会出现诡异的结果。

道理很简单,一个goroutine再访问某个资源时另一个goroutine可能正在写,导致访问结果不符合预期,或者你前脚刚写,后脚就被他人覆盖了。要解决这个问题,GO语言提供了两种传统思路:

  1. 原子操作函数,确保每次访问都是完整的读写

    1
    2
    3
    // atomic包里还有很多如读取、写入等安全访问函数
    // 这里仅使用加法计算
    atomic.AddInt64(&count, 1)
  2. 互斥锁,我在访问的时候你不准访问

    1
    2
    3
    4
    var mutex sync.Mutex // 用来定义代码临界区
    mutex.Lock() // 加锁,其它goroutine会被阻塞
    ...
    mutex.Unlock() // 解锁,其它goroutine继续运行

通道

原子函数和互斥锁都可以很好地解决资源共享的问题,但它们都不够优秀,因为你不得不考虑程序的运行逻辑、优先级之类的问题。仔细想想,其实我们访问共享资源无非是为了生产/消费数据,只是为了确保数据能被安全访问才引入这样那样的竞争机制。那有没有一种办法能让开发者专注处理数据,不要去操心那些毫不相关的业务逻辑。答案就是GO的通道机制。

简单来说,一个goroutine需要读数据的时候,就从通道里去拿,处理完了就放回通道,至于那些资源互斥等问题,运行时已经处理得很完美了。

通道的基本使用

  • makeclose来创建和关闭通道
  • 通道一般运行在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
var wg sync.WaitGroup

// 接收端goroutine
func receive(data chan int) {
num := <-data // 将通道数据读取到变量
fmt.Printf("received number: %d\n", num)
wg.Done()
}

// 发送端goroutine
func send(data chan int, num int) {
fmt.Printf("sent number: %d\n", num)
data <- num // 将数据写入通道
wg.Done()
}

// UseChannel 通道的基本使用
func UseChannel() {
channel := make(chan int) // 创建一个通道
wg.Add(2)
go receive(channel)
go send(channel, 20)
wg.Wait()
close(channel) // 关闭通道
}

如上代码所示,通过channel := make(chan int)创建了一个int类型的通道,且通过该通道实现在receviesend两个goroutine之间的数据通信,注意channel <- value表示写通道,value <- channel表示读通道。

另外,GO提供两种通道机制,无缓冲通道和有缓冲通道。

无缓冲通道

顾名思义,无缓冲就是在通道内不没有缓冲空间,对于两个goroutine而言,需要双方同时做好准备才能进行数据传递,否则先做好准备的一方就会阻塞,等待另一方做好准备。如下图所示:

无缓冲通道示意图

其实最开始关于sendrecevie的例子就是典型的无缓冲通道,所以具体的用法就不再赘述了。

留意一下两个函数中fmt.Printf的顺序,发送者是在发送数据之前打印,而接受者是在接收数据之后打印。不过,两个函数是goroutine,理论上来说独自运行,打印没有先后次序,但上边的例子不论运行多少次都是先打印”sent number: xx"再打印”received number: xx"。由此可见,因为没有缓冲,num := <-data的时候,如果data通道的对面没有在写入,这里就会被阻塞。

有缓冲通道

同理,有缓冲就是在通道内有缓冲空间,对于两个goroutine而言,无所谓对方有没有做好准备,它们只需要关系通道内的缓存有没有数据,如下图所示:

有缓冲通道示意图

下面这段代码也展示了如果运用有缓冲通道,其实非常简单,就是在创建通道的时候指定一下通道的缓存长度make(chan <type>, <length>)即可,其它地方几乎不用变。

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
func BufferChannel() {
// 创建一个长度为10的有缓冲通道
bufferedChannel := make(chan int, 10)
wg.Add(10)

// 启动5个goroutine接收数字
for i := 0; i < 5; i++ {
go receive(bufferedChannel)
}
// 启动5个goroutine发送随机5个数字
for i := 0; i < 5; i++ {
go send(bufferedChannel, rand.Intn(100))
}

wg.Wait()
close(bufferedChannel)
}

//---------------程序输出结果------------
sent number: 81
received number: 81
sent number: 81
sent number: 87
sent number: 47
received number: 81
received number: 87
received number: 47
sent number: 59
received number: 59

从这样的程序输出结果可以明显看到,receivesend的执行根本互不影响,不存在阻塞的情况,否则就不会出现连续发送和连续接收的打印了。

但是需要注意一点,发送数字和接收数字的顺序确实一样的,也就是说有缓冲通道内部,数据是按照先进先出的方式在管理。

小结一下

  • GO语言并发是指goroutine,由GO的运行时负责管理
  • 使用go关键字来创建goroutine
  • sync/atomicsync.Mutex可以解决并发时的资源竞争问题
  • 相比于原子函数和互斥锁,GO语言的通道机制可以更好地处理共享数据
  • 使用make(chan <type>)创建无缓冲通道
  • 使用make(chan <type>, <length>)创建有缓冲通道

Don’t Nail Your Program into the Upright Position

我有次写了一个C++恶搞比赛,我讽刺地建议了以下的异常处理策略:

凭借遍布我们整个代码库的try...catch的结构,我们有时能够阻止程序的终止。我们把这种状态认为是“直立钉尸”。

尽管我的草率,实际上我还是总结了从这位祥林嫂(Dame Bitter Experience——或者叫艰苦岁月女?🥵)膝上学到的一课。

这是我们自制的C++库,是应用程序的一个基类。多年以后它被千“猿”所指:没有谁的手是干净的。它包含的代码把任何事情的异常都做了避开处理。通过Yossarian的第22条军规(Catch-22)作为指导,我们决定,或者说倾向于这个类的一个实例要么总是活着,要么赶紧死掉。(决定暗示更多想法,而不仅仅是进入这只怪物的结构)

为此,我们把多个异常交织在一起处理。我们把Windows的结构化异常与原生的类型混合在一起处理(记住C++中的__try..__except?我都没有)。当有意外抛出时,我们尝试再次调用,更难压入参数。回头看,我喜欢把一个try...catch处理写到另一个catch语句中,鬼使神差的让我可能从原本良好做法的高速公路走到了一条支路,进入一条芳香四溢但很不健康的疯狂车道。然而,这很可能是事后诸葛亮。

不用说,程序无论何时出错了,基本都是因为这个类,它们的消失就像是在码头的黑手党遇害者,只留下没用的气泡,鬼才知道发生了什么,尽管据说转储例程会记录这场灾难。最终——很长的最终——我们盘点了所做的事情,并因此感到羞愧。我们用一个小巧且健壮的报告机制替换了这个肮脏的家伙。但这是十分让人崩溃的。

我不会打扰你——去确认还有谁会向我们一样蠢——但我最近在线上和一个家伙讨论问题,他的学术职称表明他应该很牛逼。我们一起讨论了Java代码关于远程传输。如果代码失败了,他认为,应该在原地捕获并阻止异常。(“接下来做什么?” 我问。“拿去做夜宵么?”)

他引用了UI设计师的法则:永远不要让用户看到异常报告(大写),看似好像解决了问题,但这些大写的和任何内容又是什么。我想知道他是否会对这段代码负责:就是大量ATM机中的一台蓝屏了的照片,并造成了永久性创伤。无论如何,如果你见到了他,点头、微笑、不用声张,就像你走向这道门一样。