一家之言

读万卷书,行万里路

上图来自 go proverbs

概述

在实际工程项目中,我们希望通过程序的错误信息快速定位问题,但是又不喜欢错误处理代码写的冗余而又啰嗦。Go语言没有提供像JavaC#语言中的try...catch异常处理方式,而是通过函数返回值逐层往上抛。这种设计,鼓励工程师在代码中显式的检查错误,而非忽略错误,好处就是避免漏掉本应处理的错误。但是带来一个弊端,让代码啰嗦。

Go标准包提供的错误处理功能

error是个interface:

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

如何创建error:

1
2
3
4
5
6
7
8
9
10
11
12
// example 1
func Sqrt(f float64) (float64, error) {
if f < 0 {
return 0, errors.New("math: square root of negative number")
}
// implementation
}

// example 2
if f < 0 {
return 0, fmt.Errorf("math: square root of negative number %g", f)
}

如何自定义error:

1
2
3
4
5
6
7
8
// errorString is a trivial implementation of error.
type errorString struct {
s string
}

func (e *errorString) Error() string {
return e.s
}

自定义error类型可以拥有一些附加方法。比如net.Error定义如下:

1
2
3
4
5
6
7
package net

type Error interface {
error
Timeout() bool // Is the error a timeout?
Temporary() bool // Is the error temporary?
}

网络客户端程序代码可以使用类型断言判断网络错误是瞬时错误还是永久错误。比如,一个网络爬虫可以在碰到瞬时错误的时候,等待一段时间然后重试。

1
2
3
4
5
6
7
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
time.Sleep(1e9)
continue
}
if err != nil {
log.Fatal(err)
}

如何处理错误

go标准包提供的错误处理方式虽然简单,但是在实际项目开发、运维过程中,会经常碰到如下问题:

  • 函数该如何返回错误,是用值,还是用特殊的错误类型
  • 如何检查被调用函数返回的错误,是判断错误值,还是用类型断言
  • 程序中每层代码在碰到错误的时候,是每层都处理,还是只用在最上层处理,如何做到优雅
  • 日志中的异常信息不够完整、缺少stack strace,不方便定位错误原因

我一直思考Go语言中该如何处理错误。下面的内容介绍了生产级Go语言代码中如何处理错误。

以下内容,主要来自于Dave Cheney 写的一个演讲文档

Go语言中三种错误处理策略

go语言中一般有三种错误处理策略:

  • 返回和检查错误值:通过特定值表示成功和不同的错误,上层代码检查错误的值,来判断被调用func的执行状态
  • 自定义错误类型:通过自定义的错误类型来表示特定的错误,上层代码通过类型断言判断错误的类型
  • 隐藏内部细节的错误处理:假设上层代码不知道被调用函数返回的错误任何细节,直接再向上返回错误

返回和检查错误值

这种方式在其它语言中,也很常见。比如,C Error Codes in Linux

go标准库中提供一些例子:

  • io.EOF: 参考这里
  • syscall.ENOENT: 参考这里
  • go/build.NoGoError: 参考这里
  • path/filepath.SkipDir: 参考这里

这种策略是最不灵活的错误处理策略,上层代码需要判断返回错误值是否等于特定值。如果想修改返回的错误值,则会破坏上层调用代码的逻辑。

1
2
3
4
5
6
buf := make([]byte, 100)
n, err := r.Read(buf) // 如果修改 r.Read,在读到文件结尾时,返回另外一个 error,比如 io.END,而不是 io.EOF,则所有调用 r.Read 的代码都必须修改
buf = buf[:n]
if err == io.EOF {
log.Fatal("read failed:", err)
}

另外一种场景也属于这类情况,上层代码通过检查错误的Error方法的返回值是否包含特定字符串,来判定如何进行错误处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func readfile(path string) error {
err := openfile(path)
if err != nil {
return fmt.Errorf("cannot open file: %v", err)
}
//...
}

func main() {
err := readfile(".bashrc")
if strings.Contains(error.Error(), "not found") {
// handle error
}
}

error interface 的 Error 方法的输出,是给人看的,不是给机器看的。我们通常会把Error方法返回的字符串打印到日志中,或者显示在控制台上。永远不要通过判断Error方法返回的字符串是否包含特定字符串,来决定错误处理的方式。

如果你是开发一个公共库,库的API返回了特定值的错误值。那么必须把这个特定值的错误定义为public,写在文档中。

“高内聚、低耦合”是衡量公共库质量的一个重要方面,而返回特定错误值的方式,增加了公共库和调用代码的耦合性。让模块之间产生了依赖。

自定义错误类型

这种方式的典型用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 定义错误类型
type MyError struct {
Msg string
File string
Line int
}

func (e *MyError) Error() string {
return fmt.Sprintf("%s:%d: %s", e.File, e.Line, e.Msg)
}


// 被调用函数中
func doSomething() {
// do something
return &MyError{"Something happened", "server.go", 42}
}

// 调用代码中
err := doSomething()
if err, ok := err.(SomeType); ok { // 使用 类型断言 获得错误详细信息
//...
}

这种方式相比于“返回和检查错误值”,很大一个优点在于可以将 底层错误 包起来一起返回给上层,这样可以提供更多的上下文信息。比如os.PathError

1
2
3
4
5
6
7
8
9
// PathError records an error and the operation
// and file path that caused it.
type PathError struct {
Op string
Path string
Err error
}

func (e *PathError) Error() string

然而,这种方式依然会增加模块之间的依赖。

隐藏内部细节的错误处理

这种策略之所以叫“隐藏内部细节的错误处理”,是因为当上层代码碰到错误发生的时候,不知道错误的内部细节。

作为上层代码,你需要知道的就是被调用函数是否正常工作。 如果你接受这个原则,将极大降低模块之间的耦合性。

1
2
3
4
5
6
7
8
9
import “github.com/quux/bar”

func fn() error {
x, err := bar.Foo()
if err != nil {
return err
}
// use x
}

上面的例子中,Foo这个方法不承诺返回的错误的具体内容。这样,Foo函数的开发者可以不断调整返回错误的内容来提供更多的错误信息,而不会破坏Foo提供的协议。这就是“隐藏内部细节”的内涵。

最合适的错误处理策略

上面我们提到了三种错误处理策略,其中第三种策略耦合性最低。然而,第三种方式也存在一些问题:

  • 如何获得更详细错误信息,比如stack trace,帮助定位错误原因
  • 如何优雅的处理错误
    • 有些场景需要了解错误细节,比如网络调用,需要知道是否是瞬时的中断
    • 是否每层捕捉到错误的时候都需要处理

如何输出更详细的错误信息

1
2
3
4
5
6
7
func AuthenticateRequest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return err // No such file or directory
}
return nil
}

上面这段代码,在我看来,在顶层打印错误的时候,只看到一个类似于”No such file or directory”的文字,从这段文字中,无法了解到错误是哪行代码产生的,也无法知道当时出错的调用堆栈。

我们调整一下代码,如下:

1
2
3
4
5
6
7
func AuthenticateRequest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return fmt.Errorf("authenticate failed: %v", err) // authenticate failed: No such file or directory
}
return nil
}

通过fmt.Errorf创建一个新的错误,添加更多的上下文信息到新的错误中,但这样仍不能解决上面提出的问题。

这里,我们通过一个很小的包github.com/pkg/errors来试图解决上面的问题。这个包提供这样几个主要的API:

1
2
3
4
5
// Wrap annotates cause with a message.
func Wrap(cause error, message string) error

// Cause unwraps an annotated error.
func Cause(err error) error

goErrorHandlingSample这个repo中的例子演示了,不同错误处理方式,输出的错误信息的区别。

1
2
3
4
> go run sample1/main.go

open /Users/caichengyang/.settings.xml: no such file or directory
exit status 1
1
2
3
4
> go run sample2/main.go

could not read config: open failed: open /Users/caichengyang/.settings.xml: no such file or directory
exit status 1
1
2
3
4
> go run sample3/main.go

could not read config: open failed: open /Users/caichengyang/.settings.xml: no such file or directory
exit status 1
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
> go run sample4/main.go

open /Users/caichengyang/.settings.xml: no such file or directory
open failed
main.ReadFile
/Users/caichengyang/go/src/github.com/ethancai/goErrorHandlingSample/sample4/main.go:15
main.ReadConfig
/Users/caichengyang/go/src/github.com/ethancai/goErrorHandlingSample/sample4/main.go:27
main.main
/Users/caichengyang/go/src/github.com/ethancai/goErrorHandlingSample/sample4/main.go:32
runtime.main
/usr/local/Cellar/go/1.9.2/libexec/src/runtime/proc.go:195
runtime.goexit
/usr/local/Cellar/go/1.9.2/libexec/src/runtime/asm_amd64.s:2337
could not read config
main.ReadConfig
/Users/caichengyang/go/src/github.com/ethancai/goErrorHandlingSample/sample4/main.go:28
main.main
/Users/caichengyang/go/src/github.com/ethancai/goErrorHandlingSample/sample4/main.go:32
runtime.main
/usr/local/Cellar/go/1.9.2/libexec/src/runtime/proc.go:195
runtime.goexit
/usr/local/Cellar/go/1.9.2/libexec/src/runtime/asm_amd64.s:2337
exit status 1

sample4/main.go中将出错的代码行数也打印了出来,这样的日志,可以帮助我们更方便的定位问题原因。

为了行为断言错误,而非为了类型

在有些场景下,仅仅知道是否出错是不够的。比如,和进程外其它服务通信,需要了解错误的属性,以决定是否需要重试操作。

这种情况下,不要判断错误值或者错误的类型,我们可以判断错误是否实现某个行为。

1
2
3
4
5
6
7
8
type temporary interface {
Temporary() bool // IsTemporary returns true if err is temporary.
}

func IsTemporary(err error) bool {
te, ok := err.(temporary)
return ok && te.Temporary()
}

这种实现方式的好处在于,不需要知道具体的错误类型,也就不需要引用定义了错误类型的三方package。如果你是底层代码的开发者,哪天你想更换一个实现更好的error,也不用担心影响上层代码逻辑。如果你是上层代码的开发者,你只需要关注error是否实现了特定行为,不用担心引用的三方package升级后,程序逻辑失败。

习大大说:“听其言、观其行”。小时候父母总是教导:“不要以貌取人”。原来生活中的道理,在程序开发中也是适用的。

不要忽略错误,也不要重复处理错误

遇到错误,而不去处理,导致信息缺失,会增加后期的运维成本

1
2
3
func Write(w io.Writer, buf []byte) {
w.Write(buf) // Write(p []byte) (n int, err error),Write方法的定义见 https://golang.org/pkg/io/#Writer
}

重复处理,添加了不必要的处理逻辑,导致信息冗余,也会增加后期的运维成本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func Write(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
log.Println("unable to write:", err) // 第1次错误处理

return err
}
return nil
}

func main() {
// create writer and read data into buf

err := Write(w, buf)
if err != nil {
log.Println("Write error:", err) // 第2次错误处理
os.Exit(1)
}

os.Exit(0)
}

总结

  • 使用“隐藏内部细节的错误处理”
    • 使用errors.Wrap封装原始error
    • 使用errors.Cause找出原始error
  • 为了行为而断言,而不是类型
  • 尽量减少错误值的使用

参考

前言

一个人不会两次掉进同一个坑里,但是如果他(她)忘记了坑的位置,那就不一定了。

这篇文章记录了最近使用Golang处理JSON遇到的一些坑。

1号坑:omitempty的行为

C#中最常用的JSON序列化类库Newtonsoft.Json中,把一个类的实例序列化成JSON,如果我们不想让某个属性输出到JSON中,可以通过property annotation或者ShouldSerialize method等方法,告知序列化程序。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 通过ShouldSerialize method指示不要序列化ObsoleteSetting属性
class Config
{
public Fizz ObsoleteSetting { get; set; }

public bool ShouldSerializeObsoleteSetting()
{
return false;
}
}

// 通过JsonIgnore的annotation指示不需要序列化ObsoleteSetting属性
class Config
{
[JsonIgnore]
public Fizz ObsoleteSetting { get; set; }

public Bang ReplacementSetting { get; set; }
}

关于Newtonsoft.Json的Conditional Property Serialization的更多内容参考:

开始使用Golang的时候,以为omitempty的行为和C#中一样用来控制是否序列化字段,结果使用的时候碰了一头钉子。回头阅读encoding/json package的官方文档,找到对omitempty行为的描述:

Struct values encode as JSON objects. Each exported struct field becomes a member of the object unless

  • the field’s tag is “-“, or
  • the field is empty and its tag specifies the “omitempty” option.

The empty values are false, 0, any nil pointer or interface value, and any array, slice, map, or string of length zero. The object’s default key string is the struct field name but can be specified in the struct field’s tag value. The “json” key in the struct field’s tag value is the key name, followed by an optional comma and options. Examples:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Field is ignored by this package.
Field int `json:"-"`

// Field appears in JSON as key "myName".
Field int `json:"myName"`

// Field appears in JSON as key "myName" and
// the field is omitted from the object if its value is empty,
// as defined above.
Field int `json:"myName,omitempty"`

// Field appears in JSON as key "Field" (the default), but
// the field is skipped if empty.
// Note the leading comma.
Field int `json:",omitempty"`

Golang中,如果指定一个field序列化成JSON的变量名字为-,则序列化的时候自动忽略这个field。这种用法,才是和上面JsonIgnore的用法的作用是一样的。

omitempty的作用是当一个field的值是empty的时候,序列化JSON时候忽略这个fieldNewtonsoft.Json的类似用法参考这里例子)。这里需要注意的是关于emtpty的定义:

The empty values are false, 0, any nil pointer or interface value, and any array, slice, map, or string of length zero.

通过下面的例子,来加深对empty values的了解:

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
package main

import (
"bytes"
"encoding/json"
"log"
"os"
)

type S1 struct {
I1 int
I2 int `json:",omitempty"`

F1 float64
F2 float64 `json:",omitempty"`

S1 string
S2 string `json:",omitempty"`

B1 bool
B2 bool `json:",omitempty"`

Slice1 []int
Slice2 []int `json:",omitempty"`
Slice3 []int `json:",omitempty"`

Map1 map[string]string
Map2 map[string]string `json:",omitempty"`
Map3 map[string]string `json:",omitempty"`

O1 interface{}
O2 interface{} `json:",omitempty"`
O3 interface{} `json:",omitempty"`
O4 interface{} `json:",omitempty"`
O5 interface{} `json:",omitempty"`
O6 interface{} `json:",omitempty"`
O7 interface{} `json:",omitempty"`
O8 interface{} `json:",omitempty"`

P1 *int
P2 *int `json:",omitempty"`
P3 *int `json:",omitempty"`
P4 *float64 `json:",omitempty"`
P5 *string `json:",omitempty"`
P6 *bool `json:",omitempty"`
P7 *[]int `json:",omitempty"`
P8 *map[string]string `json:",omitempty"`
}

func main() {

p3 := 0
p4 := float64(0)
p5 := ""
p6 := false
p7 := []int{}
p8 := map[string]string{}

s1 := S1{
I1: 0,
I2: 0,

F1: 0,
F2: 0,

S1: "",
S2: "",

B1: false,
B2: false,

Slice1: []int{},
Slice2: nil,
Slice3: []int{},

Map1: map[string]string{},
Map2: nil,
Map3: map[string]string{},

O1: nil,
O2: nil,
O3: int(0),
O4: float64(0),
O5: "",
O6: false,
O7: []int{},
O8: map[string]string{},

P1: nil,
P2: nil,
P3: &p3,
P4: &p4,
P5: &p5,
P6: &p6,
P7: &p7,
P8: &p8,
}

b, err := json.Marshal(s1)
if err != nil {
log.Printf("marshal error: %v", err)
return
}

var out bytes.Buffer
json.Indent(&out, b, "", "\t")
out.WriteTo(os.Stdout)
//Output:
//{
// "I1": 0,
// "F1": 0,
// "S1": "",
// "B1": false,
// "Slice1": [],
// "Map1": {},
// "O1": null,
// "O3": 0,
// "O4": 0,
// "O5": "",
// "O6": false,
// "O7": [],
// "O8": {},
// "P1": null,
// "P2": 0
//}%
}

点击这里执行上面的程序

关于empty value的定义,这里面隐藏了一些坑。下面通过一个例子来说明。

假设我们有一个社交类App,通过Restful API形式从服务端获取当前登录用户基本信息及粉丝数量。如果服务端对Response中User对象的定义如下:

1
2
3
4
5
type User struct {
ID int `json:"id"` // 用户id
// 其它field
FansCount int `json:"fansCount,omitempty"` // 粉丝数
}

如果正在使用App时一个还没有粉丝的用户,访问Restful API的得到Response如下:

1
2
3
4
{
"id": 1000386,
...
}

这时候你会发现Response的User对象中没有fansCount,因为fansCount是个int类型且值为0,序列化的时候会被忽略。语义上,User对象中没有fansCount应该理解为粉丝数量未知,而不是没有粉丝

如果我们希望做到能够区分粉丝数未知没有粉丝两种情况,需要修改User的定义:

1
2
3
4
5
type User struct {
ID int `json:"id"` // 用户id
// 其它field
FansCount *int `json:"fansCount,omitempty"` // 粉丝数
}

FansCount修改为指针类型,如果为nil,表示粉丝数未知;如果为整数(包括0),表示粉丝数。

这么修改语义上没有漏洞了,但是代码中要给FansCount赋值的时候却要多一句废话。必须先将从数据源查询出粉丝数赋给一个变量,然后再将变量的指针传给FansCount。代码读起来实在是啰嗦:

1
2
3
4
5
6
7
8
// FansCount是int类型时候
user := dataAccess.GetUserInfo(userId)
user.FansCount = dataAccess.GetFansCount(userId)

// FansCount是*int类型的时候
user := dataAccess.GetUserInfo(userId)
fansCount := dataAccess.GetFansCount(userId)
user.FansCount = &fansCount

2号坑:JSON反序列化成interface{}对Number的处理

JSON的规范中,对于数字类型,并不区分是整型还是浮点型。

对于如下JSON文本:

1
2
3
4
{
"name": "ethancai",
"fansCount": 9223372036854775807
}

如果反序列化的时候指定明确的结构体和变量类型

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
package main

import (
"encoding/json"
"fmt"
)

type User struct {
Name string
FansCount int64
}

func main() {
const jsonStream = `
{"name":"ethancai", "fansCount": 9223372036854775807}
`
var user User // 类型为User
err := json.Unmarshal([]byte(jsonStream), &user)
if err != nil {
fmt.Println("error:", err)
}

fmt.Printf("%+v \n", user)
}
// Output:
// {Name:ethancai FansCount:9223372036854775807}

点击这里执行上面的程序

如果反序列化不指定结构体类型或者变量类型,则JSON中的数字类型,默认被反序列化成float64类型:

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
package main

import (
"encoding/json"
"fmt"
"reflect"
)

func main() {
const jsonStream = `
{"name":"ethancai", "fansCount": 9223372036854775807}
`
var user interface{} // 不指定反序列化的类型
err := json.Unmarshal([]byte(jsonStream), &user)
if err != nil {
fmt.Println("error:", err)
}
m := user.(map[string]interface{})

fansCount := m["fansCount"]

fmt.Printf("%+v \n", reflect.TypeOf(fansCount).Name())
fmt.Printf("%+v \n", fansCount.(float64))
}

// Output:
// float64
// 9.223372036854776e+18

点击这里执行上面的程序

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
package main

import (
"encoding/json"
"fmt"
)

type User struct {
Name string
FansCount interface{} // 不指定FansCount变量的类型
}

func main() {
const jsonStream = `
{"name":"ethancai", "fansCount": 9223372036854775807}
`
var user User
err := json.Unmarshal([]byte(jsonStream), &user)
if err != nil {
fmt.Println("error:", err)
}

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

// Output:
// {Name:ethancai FansCount:9.223372036854776e+18}

点击这里执行上面的程序

从上面的程序可以发现,如果fansCount精度比较高,反序列化成float64类型的数值时存在丢失精度的问题。

如何解决这个问题,先看下面程序:

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
package main

import (
"encoding/json"
"fmt"
"reflect"
"strings"
)

func main() {
const jsonStream = `
{"name":"ethancai", "fansCount": 9223372036854775807}
`

decoder := json.NewDecoder(strings.NewReader(jsonStream))
decoder.UseNumber() // UseNumber causes the Decoder to unmarshal a number into an interface{} as a Number instead of as a float64.

var user interface{}
if err := decoder.Decode(&user); err != nil {
fmt.Println("error:", err)
return
}

m := user.(map[string]interface{})
fansCount := m["fansCount"]
fmt.Printf("%+v \n", reflect.TypeOf(fansCount).PkgPath() + "." + reflect.TypeOf(fansCount).Name())

v, err := fansCount.(json.Number).Int64()
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("%+v \n", v)
}

// Output:
// encoding/json.Number
// 9223372036854775807

点击这里执行上面的程序

上面的程序,使用了func (*Decoder) UseNumber方法告诉反序列化JSON的数字类型的时候,不要直接转换成float64,而是转换成json.Number类型。json.Number内部实现机制是什么,我们来看看源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// A Number represents a JSON number literal.
type Number string

// String returns the literal text of the number.
func (n Number) String() string { return string(n) }

// Float64 returns the number as a float64.
func (n Number) Float64() (float64, error) {
return strconv.ParseFloat(string(n), 64)
}

// Int64 returns the number as an int64.
func (n Number) Int64() (int64, error) {
return strconv.ParseInt(string(n), 10, 64)
}

json.Number本质是字符串,反序列化的时候将JSON的数值先转成json.Number,其实是一种延迟处理的手段,待后续逻辑需要时候,再把json.Number转成float64或者int64

对比其它语言,Golang对JSON反序列化处理真是易用性太差(“蛋疼”)。

JavaScript中所有的数值都是双精度浮点数(参考这里),反序列化JSON的时候不用考虑数值类型匹配问题。这里多说两句,JSON的全名JavaScript Object Notation(从名字上就能看出和JavaScript的关系非常紧密),发明人是Douglas Crockford,如果你自称熟悉JavaScript而不知道Douglas Crockford是谁,就像是自称是苹果粉丝却不知道乔布斯是谁。

C#语言的第三方JSON处理library Json.NET反序列化JSON对数值的处理也比Golang要优雅的多:

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
using System;
using Newtonsoft.Json;

public class Program
{
public static void Main()
{
string json = @"{
'Name': 'Ethan',
'FansCount': 121211,
'Price': 99.99
}";

Product m = JsonConvert.DeserializeObject<Product>(json);

Console.WriteLine(m.FansCount);
Console.WriteLine(m.FansCount.GetType().FullName);

Console.WriteLine(m.Price);
Console.WriteLine(m.Price.GetType().FullName);

}
}

public class Product
{
public string Name
{
get;
set;
}

public object FansCount
{
get;
set;
}

public object Price
{
get;
set;
}
}

// Output:
// 121211
// System.Int64
// 99.99
// System.Double

点击这里执行上面的程序

Json.NET在反序列化的时候自动识别数值是浮点型还是整型,这一点对开发者非常友好。

3号坑:选择什么格式表示日期

JSON的规范中并没有日期类型,不同语言的library对日期序列化的处理也不完全一致:

Go语言:

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
package main

import (
"encoding/json"
"fmt"
"os"
"time"
)

func main() {
type Product struct {
Name string
CreatedAt time.Time
}
pdt := Product{
Name: "Reds",
CreatedAt: time.Now(),
}
b, err := json.Marshal(pdt)
if err != nil {
fmt.Println("error:", err)
}
os.Stdout.Write(b)
}
// Output
// {"Name":"Reds","CreatedAt":"2016-06-27T07:40:54.69292134+08:00"}

JavaScript语言:

1
2
3
4
5
➜  ~ node
> var jo = { name: "ethan", createdAt: Date.now() };
undefined
> JSON.stringify(jo)
'{"name":"ethan","createdAt":1466984665633}'

C#语言:

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
using System;
using Newtonsoft.Json;

public class Program
{
public static void Main()
{
Product product = new Product();
product.Name = "Apple";
product.CreatedAt = DateTime.Now;

string json = JsonConvert.SerializeObject(product,
Newtonsoft.Json.Formatting.Indented,
new JsonSerializerSettings {
NullValueHandling = NullValueHandling.Ignore
});
Console.WriteLine(json);
}
}

public class Product
{
public string Name
{
get;
set;
}

public DateTime CreatedAt
{
get;
set;
}
}
// Output:
// {
// "Name": "Apple",
// "CreatedAt": "2016-06-26T23:46:57.3244307+00:00"
// }

Go的encoding/json package、C#的Json.NET默认把日期类型序列化成ISO 8601标准的格式,JavaScript默认把Date序列化从1970年1月1日0点0分0秒的毫秒数。但JavaScript的dateObj.toISOString()能够将日期类型转成ISO格式的字符串,Date.parse(dateString)方法能够将ISO格式的日期字符串转成日期。

个人认为ISO格式的日期字符串可读性更好,但序列化和反序列化时的性能应该比整数更低。这一点从Go语言中time.Time的定义看出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Time struct {
// sec gives the number of seconds elapsed since
// January 1, year 1 00:00:00 UTC.
sec int64

// nsec specifies a non-negative nanosecond
// offset within the second named by Seconds.
// It must be in the range [0, 999999999].
nsec int32

// loc specifies the Location that should be used to
// determine the minute, hour, month, day, and year
// that correspond to this Time.
// Only the zero Time has a nil Location.
// In that case it is interpreted to mean UTC.
loc *Location
}

具体选择哪种形式在JSON中表示日期,有如下几点需要注意:

  • 选择标准格式。曾记得.NET Framework官方序列化JSON的方法中,会把日期转成如"\/Date(1343660352227+0530)\/"的专有格式,这样的专有格式对跨语言的访问特别不友好。
  • 如果你倾向性能,可以使用整数。如果你倾向可读性,可以使用ISO字符串。
  • 如果使用整数表示日期,而你的应用又是需要支持跨时区的,注意一定要是从1970-1-1 00:00:00 UTC开始计算的毫秒数,而不是当前时区的1970-1-1 00:00:00

参考

文章:

第三方类库:

工具:

  • JSON-to-Go: instantly converts JSON into a Go type definition

目录

  • 前言
  • dlog的用途
  • dlog的一些非功能性需求
  • 碰到问题及解决方案
    • 何时使用panic,何时使用return error
    • 如何实现一个logger只能接收对应类型的data log
    • 如何实现批量发送data log
    • 如何实现对Logger.Log方法的调用超时机制
    • 如何在logger没有收到新msg情况下,保证buf中的数据依然会定期发送给AWS Kinesis
    • 如何向程序外部暴露运行指标
    • 如何在单元测试中实现SetupTearDown
    • 如何实现kinesisMock
    • 如何模拟AWS Kinesis响应慢或者不可用
    • 提交到代码库中的测试代码是否可以保留log.Print
  • 踩过的一些坑
  • 未来可以优化的地方
  • 参考

前言

本文记录了前段时间我和王益使用Go语言合作开发一个log组件dlog的过程中学到的一些知识。在整个合作开发的过程中,王益严谨认真的态度,对开发质量的严格要求,给我留下了极其深刻的印象。能够和王益这样的顶级工程师切磋技艺,对我学习Go语言帮助非常大。也谨以此文表达对王益的感谢。

注:本文假设读者已经对Go语法已经有基本了解。

dlog的用途

首先引用项目readme文档的第一段文字介绍一下dlog的用途:

dlog is a Go package for distributed structure logging using Amazon AWS Kinesis/Firehose.

更多介绍和设计请阅读readme文档

dlog主要是用来记录程序的data log的这样一个Golang package,那什么是data log?这里先简要解释一下。一般程序运行过程中主要产生两类日志:

  • status log:主要用于帮助调试、定位程序Bug、或者找到性能瓶颈,比如方法调用日志、错误日志、方法执行时间日志等
  • data log:主要用于记录用户行为,收集的data log用于后期的个性化搜索、智能推荐等,比如搜索行为、点击行为等

dlog的一些非功能性需求

  • 每一种类型的data log对应一种logger,一个logger只能记录对应类型的data log
  • dlog内部发生的错误,不能影响调用的程序代码的执行
    • 应考虑到AWS Kinesis服务响应慢或者不可用的场景(暂未实现)
  • 程序代码中通过调用dlog的方法记录data logdlog的方法不能阻塞调用的程序代码的执行(这一点dlog暂时未满足要求,需要后期改进)
  • AWS Kinesis提供两个API接收数据,一个是PutRecord, 另一个是PutRecords,为了减少对Kinesis的调用次数,采用后者批量发送data log
    • PutRecords对一次调用的record数量限制是500,每个record大小必须小于等于1MB,整个request的大小必须小于等于5MB
    • 每一个Kinesis Stream能够承受的最大TPS和写数据量,与这个stream拥有的shard的数量有关。一个shard支持最大TPS是1000 records per second, 写数据量是1MB per second
  • 通过单元测试保证功能正确性

碰到问题及解决方案

何时使用panic,何时使用return error

先看看panicreturn error的执行机制。

panic的执行机制

panic会中断当前goroutine的执行,如果不对panic的错误进行recover,那么整个进程都会崩溃。

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

import (
"fmt"
"log"
"time"
)

func main() {
go func() {
log.Panic("some error before work2")
fmt.Println("do some work2")
}()

time.Sleep(time.Second)
fmt.Println("do some work1")
}

执行上面代码请点击这里

可以通过recover捕捉当前goroutinepanic的错误并进行错误处理,整个进程的正常运行不受影响。

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

import (
"fmt"
"log"
"time"
)

func main() {
go func() {
defer func() {
if err := recover(); err != nil {
log.Printf("error: %v", err)
}
}()
log.Panic("some error before work2")
fmt.Println("do some work2")
}()

time.Sleep(time.Second)
fmt.Println("do some work1")
}

执行上面代码请点击这里

我们可以发现Go语言中的panicrecover机制,和Java、.NET中的throwtry...catch机制非常类似。

return error的执行机制

return error是利用Go语言函数的多值返回的特性,通过函数的其中一个返回值(一般是第一个或者最后一个),向caller返回函数执行过程中产生的异常,其它值返回执行结果。

这种方式的问题,主要在于:如果函数调用层次比较多,每一层函数都通过return error方式返回错误,都需要处理被调用函数的return error,增加代码复杂度。对于无法恢复的错误也没有必要一层一层往上抛,直接panic/recover更加简洁。

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
package main

import (
"errors"
"log"
)

type R struct {
}

func f1() (error, *R) {
return errors.New("an error"), nil
}

func f2() (error, *R) {
err, r := f1()
if err != nil {
return err, nil
}

return nil, r
}

func f3() (error, *R) {
err, r := f2()
if err != nil {
return err, nil
}

return nil, r
}

func main() {
err, _ := f3()
if err != nil {
log.Print(err)
}
}

执行上面代码请点击这里

dlog错误处理原则

使用panic还是return error的方式处理错误,要区分不同的场景。重要是不论使用panic还是return error,都需要符合架构上更高层面错误处理需求。

dlog是一个日志记录package,暴露给其它程序调用的方法如下:

  • func NewLogger(example interface{}, opts *Options) (*Logger, error)
  • func (l *Logger) Log(msg interface{}) error

这两个方法的使用场景并不一样,错误处理原则也不完全一致:

  • NewLogger方法一般是在程序初始化的时候调用,用于创建记录程序运行过程中产生的data log的记录器。通过NewLogger创建一个logger的时候,如果传入参数不正确,使用panic方式,在上层调用程序不处理错误情况下会导致程序崩溃,所以使用return error方式向caller报告错误。大多数Golang package也是按此原则处理。
  • 上层程序调用logger.Log时,如果Log方法内部发生的错误,不能影响调用的代码的执行,所以这里绝对不能用panic方式抛出错误。日志记录是辅助功能,如果日志记录行为失败,导致业务逻辑代码执行不下去,估计负责业务逻辑开发的工程师会和你拼命。
    • logger.Log可以使用return error方式返回msg校验类的错误
    • logger.Log发送日志采用的是异步批量方式向AWS Kinesis发送数据,向AWS Kinesis发送数据相关的错误无法通过panic或者return error方式直接报告给调用程序。最好的方式是允许调用程序向logger注册发送失败处理的handler,出现发送失败错误时执行handler逻辑。(暂未实现)

如何实现一个logger只能接收对应类型的data log

要实现一个logger只能接收对应类型的data log,主要思路如下:

  • Logger的定义中通过属性msgType reflect.Type记住能够接受的消息类型
  • 通过NewLogger方法创建logger的时候,指定logger可以接受的消息类型
  • Log方法中首先校验msg的类型是否是创建logger时指定的类型

以下是相关代码:

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
// msgType保存Logger能够接受的消息类型
type Logger struct {
...
msgType reflect.Type
...
}

// 获得msg的reflect.Type
func msgType(msg interface{}) (reflect.Type, error) {
t := reflect.TypeOf(msg)

if t.Kind() == reflect.Ptr {
t = t.Elem()
}

if t.Kind() != reflect.Struct {
return nil, fmt.Errorf("dlog message must be either *struct or struct")
}

return t, nil
}

func NewLogger(example interface{}, opts *Options) (*Logger, error) {
t, e := msgType(example)
if e != nil {
return nil, e
}

...

l := &Logger{
...
msgType: t,
...
}

...
return l, nil
}

func (l *Logger) Log(msg interface{}) error {
if t, e := msgType(msg); e != nil {
return e
} else if !t.AssignableTo(l.msgType) {
return fmt.Errorf("parameter (%+v) not assignable to %v", msg, l.msgType)
}

...
}

Log方法中为什么要用AssignableTo,而不是直接判断两个类型相等。其实都可以,在msgstruct情况下,AssignableTo返回True意味着两个类型相等。参考下面的例子:

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
package main

import (
"log"
"reflect"
)

func main() {
type Fn func(int) int
id := func(x int) int {
return x
}
var zeroFn Fn
log.Println(reflect.TypeOf(id).AssignableTo(reflect.TypeOf(zeroFn)))

type MyInt int
mi := 1
log.Println(reflect.TypeOf(2).AssignableTo(reflect.TypeOf(mi)))

type S1 struct {
name string
}
type S2 S1

s1 := S1{
name: "ethan",
}
s2 := S2{
name: "ethan",
}
// s2 = s1 // if uncomment this line, will report "cannot use s1 (type S1) as type S2 in assignment" when compile
log.Println(reflect.TypeOf(s1).AssignableTo(reflect.TypeOf(s2)))
}

执行上面代码请点击这里

如何实现批量发送data log

要实现批量发送,首先我们可以想到应该要有个buffer用来收集一定数量的的message,等待buffer中的数据积累到一定程度后,一次性发送给AWS Kinesis。设计buffer结构不难,难点在于如何解决多线程(goroutine)并发读写buffer的问题,主要的解决方案有两种:

  • 基于锁机制实现对buffer访问控制
  • 基于channel实现对buffer的访问控制

前者对于有Java、.NET等语言的并发编程经验的工程师来说,非常熟悉。而后者则体现了CSP(Communicating Sequential Processes)并发编程模型的优势。

dlogLog方法把收到的msg写到名字叫bufferchannel中,另外一个单独的goroutinechannel的另一头收集编码后的日志信息,然后保存到buf := make([][]byte, 0)中。当buf中的数据量要达到一次向AWS Kinesis发送的最大量时,调用flush方法向AWS Kinesis发送数据。由于只有一个goroutinebuf进行访问,所以不需要通过锁机制控制对buf的读写。

具体代码实现:

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
func NewLogger(example interface{}, opts *Options) (*Logger, error) {
...

go l.sync() // 启动sync goroutine
return l, nil
}

func (l *Logger) Log(msg interface{}) error {
...

en := encode(msg) // 对msg进行编码
...
select {
case l.buffer <- en: // 向buffer channel写入编码后的msg
...
}
...
return nil
}

func (l *Logger) sync() {
...

buf := make([][]byte, 0) // 用于收集从buffer channel读取的日志数据
bufSize := 0

for {
select {
case msg := <-l.buffer:
if bufSize+len(msg)+partitionKeySize >= maxBatchSize { // 如果buf的大小接近一次批量发送的最大数据量
l.flush(&buf, &bufSize) // 向AWS Kinesis批量发送数据
}

buf = append(buf, msg) // 将从buffer channel读取日志数据保存到buf中
bufSize += len(msg) + partitionKeySize

...
}
}

如何实现对Logger.Log方法的调用超时机制

如果一个IO操作耗时较长,并且调用比较频繁的情况下,不仅会阻塞caller的执行,还会消耗大量系统资源。我们通常会使用超时机制,避免程序长时间等待或者对系统资源大量占用。

Logger.Log方法利用Go语言channel非常简洁的实现了超时机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (l *Logger) Log(msg interface{}) error {
...

var timeout <-chan time.Time
if l.WriteTimeout > 0 {
timeout = time.After(l.WriteTimeout) // 初始化时长为l.WriteTimeout的计时器
}

...
select {
case l.buffer <- en:
case <-timeout: // 如果上一行代码一直阻塞,timeout计时器时间到点后会触发执行当前case下的代码
return fmt.Errorf("dlog writes %+v timeout after %v", msg, l.WriteTimeout)
}
...
return nil
}

对比Java、.NET语言中超时机制的实现方法,Go语言的实现简洁的令人发指:

如何在logger没有收到新msg情况下,保证buf中的数据依然会定期发送给AWS Kinesis

dlogLogger.sync()方法中通过一个定时器,定期将buf中数据发送给AWS Kinesis。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (l *Logger) sync() {
if l.SyncPeriod <= 0 {
l.SyncPeriod = time.Second
}
ticker := time.NewTicker(l.SyncPeriod) // l.SyncPeriod是定期发送的数据的时间间隔,ticker定时触发器

buf := make([][]byte, 0)
bufSize := 0

for { // 无限循环保证sync goroutine一直工作
select {
case msg := <-l.buffer:
...

case <-ticker.C: // ticker.C的类型是<-chan Time,每隔l.SyncPeriod时间会触发执行当前case的代码
if bufSize > 0 {
l.flush(&buf, &bufSize)
}
}
}
}

通过tickerdlog保证了即使没有收到新的msg的时候,保存在buf中的数据最长l.SyncPeriod时间后也会发送给AWS Kinesis。

互联网产品的生产环境的上线,通常的做法是,将现有服务分组,然后交替切流量、升级。如果没有类似的机制,那么在服务程序断掉流量,没有收到新的访问时候,保存在内存中的数据就不会发送出去,升级时就可能导致数据丢失。

如何向程序外部暴露运行指标

Go语言的官方Package expvar提供一种标准化的接口,允许程序暴露公开访问的变量。expvar通过HTTP地址/debug/vars提供访问入口,并以JSON格式展示这些变量。下面是关于expvar常见用法的一个例子:

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
package main

import (
"encoding/json"
"expvar"
"fmt"
"net/http"
"sync"
"time"
)

// Stats is used to collect runtime metrics
type Stats struct {
sync.Mutex
TotalHit int
ErrorNums int
}

func (s *Stats) IncreaseTotalHit(i int) {
s.Lock()
defer s.Unlock()

s.TotalHit += i
}

func (s *Stats) IncreaseErrorNums(i int) {
s.Lock()
defer s.Unlock()

s.ErrorNums += i
}

func (s *Stats) String() string {
s.Lock()
defer s.Unlock()

b, err := json.Marshal(*s)
if err != nil {
return "{}"
} else {
return string(b)
}
}

var (
stats *Stats
hits *expvar.Map
)

func init() {

expvar.Publish("now", expvar.Func(func() interface{} {
return time.Now().Format("\"2006-01-02 15:04:05\"")
}))

stats = &Stats{}
expvar.Publish("stats", stats)

hits = expvar.NewMap("hits").Init()
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path[1:]
hits.Add(p, 1)
stats.IncreaseTotalHit(1)
fmt.Fprintf(w, "Hey! I love %s! hits: %v\n", p, hits.Get(p))
}

func errHandler(w http.ResponseWriter, r *http.Request) {
stats.IncreaseErrorNums(1)
fmt.Fprintf(w, "Error Nums: %v\n", stats.ErrorNums)
}

func main() {
http.HandleFunc("/err", errHandler)
http.HandleFunc("/", homeHandler)
http.ListenAndServe(":8080", nil)
}

按照如下步骤测试运行效果:

  • go run expvarexample.go运行例子代码
  • 在浏览器中访问http://localhost:8080/ethan
  • 在浏览器中访问http://localhost:8080/err
  • 在浏览器中访问http://localhost:8080/debug/vars,得到如下结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"cmdline": ["/var/folders/jf/65ft181j33j_d75ktgv67bsc0000gn/T/go-build467453980/command-line-arguments/_obj/exe/expvarsample"],
"hits": {
"ethan": 1,
"favicon.ico": 2
},
"memstats": { ... },
"now": "\"2016-04-19 20:17:40\"",
"stats": {
"TotalHit":3,
"ErrorNums":1
}
}

expvarmon是一个帮助查看expvar暴露运行指标的工具,用法如下:

  • 安装:go get github.com/divan/expvarmon
  • 运行:expvarmon -ports="8080" -vars="hits.ethan,stats.TotalHit,stats.ErrorNums,now"
  • 效果如下:

dlog使用expvar向程序外部(比如监控程序)暴露运行指标,目前dlog中定义的运行指标包括:

  • writtenRecords: 成功写到AWS Kinesis的msg数量
  • writtenBatches: 成功调用AWS Kinesis批量写数据API的次数
  • failedRecords: 写到AWS Kinesis失败的msg数量
  • tooBigMesssages: 编码后体积过大(加上partitionKeySize大于1MB)的msg数量

未来还需要根据运维的需求对运行指标进行调整,当前的用法也有一些问题,后期需要重构。

如何在单元测试中实现SetupTearDown

Go语言提供一种轻量级的单元测试框架(无需第三方工具或者程序包)。通过使用go test命令和testing package,可以非常快速的实现单元测试。先借用官方文档中的例子回顾一下Go单元测试框架的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//$GOPATH/src/github.com/user/stringutil/reverse_test.go
package stringutil

import "testing"

func TestReverse(t *testing.T) {
cases := []struct {
in, want string
}{
{"Hello, world", "dlrow ,olleH"},
{"Hello, 世界", "界世 ,olleH"},
{"", ""},
}
for _, c := range cases {
got := Reverse(c.in)
if got != c.want {
t.Errorf("Reverse(%q) == %q, want %q", c.in, got, c.want)
}
}
}

运行测试只需要简单的输入命令:

1
2
$ go test github.com/user/stringutil
ok github.com/user/stringutil 0.165s

很多情况下,要执行单元测试,我们需要依赖一些外部资源,比如已完成初始化数据的数据库、公有云上的一些IaaS服务等。这些依赖资源,我们希望在单元测试执行前,能够自动的被初始化;单元测试完成后,能够自动的被清理。testify/suite package就提供这样的支持。通过testify/suite,你可以构建一个测试集struct,建立测试集的setup(初始化)/teardown(清理)方法,和最终实现测试用例逻辑的方法。而运行测试,仍然只需要一句简单的go test

以下是使用testify/suite实现测试集的常见模式:

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
package suite

import (
"testing"

"github.com/stretchr/testify/assert"
)

type SuiteTester struct {
// Include our basic suite logic.
Suite

// Other properties
propertyN string
}

// The SetupSuite method will be run by testify once, at the very
// start of the testing suite, before any tests are run.
func (suite *SuiteTester) SetupSuite() {
// ...
}

// The TearDownSuite method will be run by testify once, at the very
// end of the testing suite, after all tests have been run.
func (suite *SuiteTester) TearDownSuite() {
// ...
}

// The SetupTest method will be run before every test in the suite.
func (suite *SuiteTester) SetupTest() {
// ...
}

// The TearDownTest method will be run after every test in the suite.
func (suite *SuiteTester) TearDownTest() {
// ...
}

// a test method
func (suite *SuiteTester) TestOne() {
// ...
}

// another test method
func (suite *SuiteTester) TestTwo() {
// ...
}

// TestRunSuite will be run by the 'go test' command, so within it, we
// can run our suite using the Run(*testing.T, TestingSuite) function.
func TestRunSuite(t *testing.T) {
suiteTester := new(SuiteTester)
Run(t, suiteTester)
}

dlog中为了测试Logger.Log方法能否正常工作,按照上面的模式编写了相应的测试代码:

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
package dlog

//...

type WriteLogSuiteTester struct {
suite.Suite

options *Options
seachLogger *Logger
clickLogger *Logger
streamNames []string // save the created AWS Kinesis Streams, which will be removed in TearDownSuite()
}

// The SetupSuite method will be run by testify once, at the very
// start of the testing suite, before any tests are run.
func (s *WriteLogSuiteTester) SetupSuite() {

//...

// create stream 1
err = s.seachLogger.kinesis.CreateStream(s.seachLogger.streamName, testingShardCount)
s.Nil(err)

// create stream 2
err = s.clickLogger.kinesis.CreateStream(s.clickLogger.streamName, testingShardCount)
s.Nil(err)

s.streamNames = []string{s.seachLogger.streamName, s.clickLogger.streamName}

for { // waiting created stream's status to be active
time.Sleep(1 * time.Second)
resp1, err1 := s.seachLogger.kinesis.DescribeStream(s.seachLogger.streamName)
s.Nil(err1)

resp2, err2 := s.seachLogger.kinesis.DescribeStream(s.clickLogger.streamName)
s.Nil(err2)

status1 := strings.ToLower(string(resp1.StreamStatus))
status2 := strings.ToLower(string(resp2.StreamStatus))
if status1 == "active" && status2 == "active" {
break
}
}
}

// The TearDownSuite method will be run by testify once, at the very
// end of the testing suite, after all tests have been run.
func (s *WriteLogSuiteTester) TearDownSuite() {
if s.streamNames == nil || len(s.streamNames) == 0 {
return
}

for _, streamName := range s.streamNames {
err := s.seachLogger.kinesis.DeleteStream(streamName)
s.Nil(err)
}
}

func (s *WriteLogSuiteTester) TestWriteLog() {
defer func() { // Recover if panicking to make sure TearDownSuite will be executed
if r := recover(); r != nil {
s.Fail(fmt.Sprint(r))
}
}()

//...
}

func TestRunWriteLogSuite(t *testing.T) {
suiteTester := new(WriteLogSuiteTester)
suite.Run(t, suiteTester)
}

注:

  • 很多场景下,测试程序自动创建依赖的资源需要运维部门的授权,所以实现前有必要先和运维部门沟通。
  • 云环境下,出于安全上的考虑,需要对创建、删除测试资源的账户管理严格管理
    • 账户信息不能写在可以公开访问的测试代码、配置文件中
    • 只给账户分配必要资源的最小权限
    • 为账户能够创建的资源设定配额

如何实现kinesisMock

上一节我们提到在测试执行前初始化依赖资源,现实场景中,并不是任何情况下都能够获得依赖的测试资源,或者测试资源也会出现不可用的情况。通过Mock技术,可以减少测试代码对其它资源(或模块)的依赖。

dlog的测试代码中,首先定义了一个KinesisInterface:

1
2
3
4
5
6
type KinesisInterface interface {
PutRecords(streamName string, records []kinesis.PutRecordsRequestEntry) (resp *kinesis.PutRecordsResponse, err error)
CreateStream(name string, shardCount int) error
DescribeStream(name string) (resp *kinesis.StreamDescription, err error)
DeleteStream(name string) error
}

KinesisInterface包含了dlog用到的github.com/AdRoll/goamz/kinesis/kinesis的所有方法。因为Go语言interface实现非侵入式的特点,github.com/AdRoll/goamz/kinesis/kinesis自动实现了KinesisInterface,我们再定义一个kinesisMock实现KinesisInterface

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
type kinesisMock struct {
// Mapping from steam name to batches of batches
storage map[string][][]kinesis.PutRecordsRequestEntry

// simulate lantency that sync to Kinesis
putRecordLatency time.Duration

// created streams' names
streamNames []string

// lock to solve concurrent call
lock sync.RWMutex
}

func newKinesisMock(putRecordsLatency time.Duration) *kinesisMock {
return &kinesisMock{
storage: make(map[string][][]kinesis.PutRecordsRequestEntry),
putRecordLatency: putRecordsLatency,
streamNames: make([]string, 0),
}
}

func (mock *kinesisMock) PutRecords(streamName string, records []kinesis.PutRecordsRequestEntry) (resp *kinesis.PutRecordsResponse, err error) {
// ...
}

func (mock *kinesisMock) CreateStream(name string, shardCount int) error {
// ...
}

func (mock *kinesisMock) DescribeStream(name string) (resp *kinesis.StreamDescription, err error) {
// ...
}

func (mock *kinesisMock) DeleteStream(name string) error {
// ...
}

然后,把业务代码中所有类型kinesis的变量,替换成KinesisInterface类型。

1
2
3
4
5
type Logger struct {
//...
kinesis KinesisInterface
//...
}

测试代码中,在构造Logger时传入kinesisMock,而不是真实的kinesis,这样就做到了“狸猫换太子”。

1
2
3
4
5
6
7
8
9
10
11
func TestLoggingToMockKinesis(t *testing.T) {
assert := assert.New(t)

l, e := NewLogger(&impression{}, &Options{
// ...
UseMockKinesis: true,
MockKinesis: newKinesisMock(0),
})

// ...
}

如何模拟AWS Kinesis响应慢或者不可用

kinesisMock完全是我们“虚构”出来的一个kinesis,在它的基础上,我们完全可以模拟响应慢或者不可用的情况。

上一节中,不知道大家注意到没有,kinesisMock有个属性叫putRecordLatency,用来模拟调用PutRecords方法的延迟时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type kinesisMock struct {
// ...

// simulate lantency that sync to Kinesis
putRecordLatency time.Duration

// ...
}

func (mock *kinesisMock) PutRecords(streamName string, records []kinesis.PutRecordsRequestEntry) (resp *kinesis.PutRecordsResponse, err error) {
//...

time.Sleep(mock.putRecordLatency) // 模拟延迟

//...
}

模拟不可用的kinesis则重新定义了一个brokenKinesisMock

1
2
3
4
5
6
7
8
9
10
11
12
13
type brokenKinesisMock struct {
*kinesisMock
}

func newBrokenKinesisMock() *brokenKinesisMock {
return &brokenKinesisMock{
kinesisMock: newKinesisMock(0),
}
}

func (mock *brokenKinesisMock) PutRecords(streamName string, records []kinesis.PutRecordsRequestEntry) (resp *kinesis.PutRecordsResponse, err error) {
return nil, fmt.Errorf("Kinesis is broken")
}

kinesisMockbrokenKinesisMock的嵌入structbrokenKinesisMock会自动拥有kinesisMock的所有公开方法,这样也就实现了KinesisInterface

提交到代码库中的测试代码是否可以保留log.Print

结论是“不可以”,原因总结如下:

  • 测试代码中的log.Print,一般用于调试代码,或者在stdout打印出一些信息帮助判断测试失败原因。不论哪种目的,这样的代码目的都仅仅是为了辅助开发,而不应该出现在最终交付的产品代码中。
  • go test命令会在控制台输出失败的测试方法,如果加上-v标志会打印出所有测试方法的执行结果,log.Print会影响执行结果的展示效果。团队合作开发,如果每个人都在测试代码中加上自己的log.Print,那么控制台打印出来的测试结果就没法看了。

踩过的一些坑

  • AWS Kinesis API - CreateStream是异步创建Stream,而且耗时10+秒,才能完成一个Stream的创建。开始以为是同步创建,结果执行测试逻辑的时候总是出错。
  • github.com/AdRoll/goamz/aws/regions.go中缺少中国区AWS Kinesis的URL地址,调用中国区AWS Kinesis会出错。
  • Travis CI会Kill掉执行时间超过1分钟的CI过程,而不是如它文档中介绍的“10分钟”

未来可以优化的地方

  • 发送失败的错误事件机制
  • 实现Kinesis服务不可用或者响应慢的场景下dlog的容错处理

参考

招聘消息

我所在的奥阁门科技有限公司正在招聘后端、运维工程师,想加入的朋友、或者有朋友可以推荐的都可以联系我(ethancai@qq.com)。

后端工程师 / Backend Engineer

职责

  • 研讨和设计产品功能特性;
  • 设计研发系统后端的一个或多个独立服务(micro-service)模块;
  • 设计研发业务运营管理系统;
  • Code Review。

要求

  • 有良好的编程习惯和代码风格;
  • 精通至少一种后台开发语言,包括但不限于Go、Node.js、C++、Python;
  • 对RESTful、RPC等架构有深刻理解和运用经验;
  • 有丰富的web service、web app开发经验;使用过著名的开源应用框架,并完整阅读过源代码;
  • 对Mysql、Redis、MongoDB或同类数据存储技术有丰富的使用经验;
  • 有提交代码到著名开源库或创建过开源项目者优先;
  • 能熟练查阅英文技术文档;
  • 有开放、坦诚的沟通心态,乐于分享;
  • 5年以上工作经验,3年以上后台系统开发经验。

高级系统运维工程师 / Senior Ops Engineer

职责

  • 负责日常业务系统基础实施(AWS)、网络及各子系统的管理维护。
  • 负责设计并部署相关应用平台,并提出平台的实施、运行报告。
  • 负责配合开发搭建测试平台,协助开发设计、推行、实施和持续改进。
  • 负责相关故障、疑难问题排查处理,编制汇总故障、问题,定期提交汇总报告。
  • 负责网络监控和应急反应,以确保网络系统有7*24小时的持续运作能力。
  • 负责日常系统维护,及监控,提供IT方面的服务和支持,保证系统的稳定。

要求

  • 深入理解Linux/Unix操作系统并能熟练使用,了解Linux系统内核,有相关操作系统调优经验优先;
  • 熟悉计算机网络基础知识,了解TCP/IP、HTTP等网络协议;
  • 熟悉系统服务的管理和维护,例如:Nginx、DNS服务器、NTP服务等;
  • 熟悉一种或者多种脚本语言,例如:Shell、Python、Perl 、Ruby等;
  • 熟练掌握Linux管理相关命令行工具,例如:grep、awk、sed、tmux、vim等;
  • 对数据库系统(MySQL)运维管理有一定的了解;
  • 熟悉常见分布式系统系统架构部署管理,熟悉基础设施管理、并具有较强的故障排查和解决问题的能力;
  • 具有 2 年以上中大型互联网系统或亚马逊AWS管理经验者优先;
  • 有DevOps经验者优先;
  • 学习能力和沟通能力较强,具有良好的团队协作精神;
  • 工作中需要胆大心细,具备探索创新精神;
  • 具有良好的文档编写能力;
  • 具有一定的英文技术文档阅读能力。

“体育运动有规则,而对格斗来说,是没有规则的。所以为了迎接格斗,你应该训练身体的所有部位。”
——李小龙

前言

我参加工作是2006年,去的第一家公司是四维图新。那时在技术部从事软件开发工作,功能需求是由技术部的需求组提供的。业务部门要做什么东西,会先给需求组提需求,然后需求组将业务需求整理好,以需求单的形式提供给各个系统开发组。那时候,大家开会说“产品”,指的是公司出版的地图数据,而不是技术部门开发的各个系统、软件工具。学习的内容也是CMMI(软件能力成熟度模型)、RUP(统一软件开发过程)、MSF(微软解决方案框架结构)、UML。

2011年我加入艺龙旅行网,那时艺龙有这么两个部门,一个是网站产品组,一个是业务流程改进组。网站产品负责给面向消费者的预订网站提需求,而业务流程改进组负责给内部呼叫中心使用的系统、面向酒店的业务系统提需求。这两个组后来合并成一个产品团队。我在艺龙负责Ebooking产品的开发和运维工作,和产品经理打了3年的交道,期间自己学习了产品经理的相关知识并且担任一段时间的产品经理。

在艺龙期间以及后来的工作中,我看到一些团队的产品经理很优秀,开发人员技术也很强,但是有时候一个小的需求变更导致技术上的大调整,整个产品开发过程“鸡飞狗跳”,反倒有时候不如以前和一些经验丰富的需求分析师合作时候,产品开发过程更顺畅。

“产品设计”和“需求分析”比较

在传统软件开发企业和互联网企业都工作过的软件工程师,会发现“传统”需求分析师、“互联网”产品经理这两种角色工作内容有着一些明显的区别。

我们通过下面一个测试来了解一下大家所认为的这两个角色的工作内容。下面给出一些概念,如果你认为更接近产品设计,就在产品设计列打“x”,反之在需求分析列下打“x”。

名词 产品设计 需求分析
商业模式
用户体验
用户心智
用户习惯
用户愿景
用户需求驱动
业务需求驱动
渉众
用户角色
业务执行者
业务工人
以用户价值为中心
以业务需求为中心
甲方、乙方
数据分析
信息建模
信息架构
面向对象方法
UML流程图、状态图、时序图、类图
E-R关系图
需求文档
产品原型
Rational
Axure RP
Sketch
UI、UE
视觉设计
交互设计
业务用例
推广策略

以上概念基本可以分为“关注点”、“考虑问题的模式和方法”、“工作内容”、“常用工具”、“工作交付物”这么几类。相信大部分人选择的接近下面这个答案:

名词 产品设计 需求分析
商业模式 x
用户体验 x
用户心智 x
用户习惯 x
用户愿景 x
用户需求驱动 x
业务需求驱动 x
渉众 x
用户角色 x
业务执行者 x
业务工人 x
以用户价值为中心 x
以业务需求为中心 x
甲方、乙方 x
数据分析 x x
信息建模 x
信息架构 x
面向对象方法 x
UML流程图、状态图、时序图、类图 x
E-R关系图 x
需求文档 x
产品原型 x
Rational x
Axure RP x
Sketch x
UI、UE x
视觉设计 x
交互设计 x
业务用例 x
推广策略 x

不知道大家做完这个测试后,是不是觉得做产品设计比做需求分析牛逼多了。很多产品经理的书籍中都会将“产品设计”描述的高大上:

  • 产品设计是用户价值驱动,需求分析是业务需求驱动。
  • 所谓设计,就是通过创造与交流来认识我们生活在其中的世界。好的认识和发现,会让我们感到喜悦和骄傲。
  • 互联网产品设计绝不是编写产品交互说明书,也不是创造优美的界面,本质是创造一连串的体验,使用户能感知到产品的文化、价值和内涵,从而引发集群效应、创造社会价值。

以上的话说的都对,绝对正确。在乔老爷子的引领下,如今很多公司重视产品设计,这是好事。但是于此同时产生另外一种做法:不重视需求分析,甚至直接忽略。老板、产品经理这么做可以理解,工程师如果也这么附和,就是做事不过大脑。

需求分析是否有继续存在的价值

产品设计要取代需求分析,只可能是下面两种情况:

  • 产品设计涵盖了需求分析的所有工作内容
  • 属于需求分析但是不属于产品设计的工作内容已不再重要,可以不用去做

第一种情况,需求分析中的业务建模、画E-R关系图、UML流程图、时序图、状态图,在大多数公司中,是由需求分析师或者工程师来做,不属于产品设计的工作范畴。产品设计显然不涵盖需求分析的所有工作内容。

第二种情况,面对复杂业务场景,通过业务建模、画E-R关系图、UML流程图、时序图、状态图等手段做需求分析,是前辈软件工程师总结出来宝贵方法和经验。产品经理一般会将整理后的产品需求通过各种方式(口述、文档、UI/UE设计等)传递给工程团队,但是这些信息一般都是站在业务边界或系统边界描述的产品功能需求,和工程实现存在信息断层。产品需求如何映射到代码实现,需要一个系统化的需求分析、设计过程,产品设计并不解决过去几十年软件开发行业发明各种软件需求分析方法所解决的问题。任何脑袋没有烧掉的公司都不会说软件需求分析工作不重要、不需要做。

需求分析应继续得到坚持和重视

如果只关注产品设计,更多的时候,你会认为设计出来的界面很美观,交互也很舒服,该说的需求都告诉工程团队了,但是工程开发阶段,出现各种问题:概念不明确、架构无法设计、逻辑混乱、后期变更困难。

如果只关注需求分析,更多的时候,你会发现不知道如何提产品需求,产品开发出来了,认为做很牛逼,但是客户觉得不好用,产品不好卖。

所有公司都希望做出成功的产品。但是未经仔细思考,就忽略软件需求分析方法,不是一个经得起推敲的做法。只有产品设计、需求分析都做好了,产品才能成功。

参考

0%