7. 包

什么是包,为什么使用包?

到目前为止,我们看到的 Go 程序都只有一个文件,文件里包含一个 main 函数和几个其他的函数。在实际中,这种把所有源代码编写在一个文件的方法并不好用。以这种方式编写,代码的重用和维护都会很困难。而包(Package)解决了这样的问题。

包用于组织 Go 源代码,提供了更好的可重用性与可读性。由于包提供了代码的封装,因此使得 Go 应用程序易于维护。

例如,假如我们正在开发一个 Go 图像处理程序,它提供了图像的裁剪、锐化、模糊和彩色增强等功能。一种组织程序的方式就是根据不同的特性,把代码放到不同的包中。比如裁剪可以是一个单独的包,而锐化是另一个包。这种方式的优点是,由于彩色增强可能需要一些锐化的功能,因此彩色增强的代码只需要简单地导入(我们会在随后讨论)锐化功能的包,就可以使用锐化的功能了。这样的方式使得代码易于重用。

我们会逐步构建一个计算矩形的面积和对角线的应用程序。

通过这个程序,我们会更好地理解包。

main 函数和 main 包

所有可执行的 Go 程序都必须包含一个 main 函数。这个函数是程序运行的入口。main 函数应该放置于 main 包中。

package packagename 这行代码指定了某一源文件属于一个包。它应该放在每一个源文件的第一行。

下面开始为我们的程序创建一个 main 函数和 main 包。在 Go 工作区内的 src 文件夹中创建一个文件夹,命名为 geometry。在 geometry 文件夹中创建一个 geometry.go 文件。

在 geometry.go 中编写下面代码。

1
2
3
4
5
6
7
8
// geometry.go
package main

import "fmt"

func main() {
fmt.Println("Geometrical shape properties")
}

package main 这一行指定该文件属于 main 包。import "packagename" 语句用于导入一个已存在的包。在这里我们导入了 fmt包,包内含有 Println 方法。接下来是 main 函数,它会打印 Geometrical shape properties

键入 go install geometry,编译上述程序。该命令会在 geometry 文件夹内搜索拥有 main 函数的文件。在这里,它找到了 geometry.go。接下来,它编译并产生一个名为 geometry (在 windows 下是 geometry.exe)的二进制文件,该二进制文件放置于工作区的 bin 文件夹。现在,工作区的目录结构会是这样:

1
2
3
4
5
src
geometry
gemometry.go
bin
geometry

键入 workspacepath/bin/geometry,运行该程序。请用你自己的 Go 工作区来替换 workspacepath。这个命令会执行 bin 文件夹里的 geometry 二进制文件。你应该会输出 Geometrical shape properties

创建自定义的包

我们将组织代码,使得所有与矩形有关的功能都放入 rectangle 包中。

我们会创建一个自定义包 rectangle,它有一个计算矩形的面积和对角线的函数。

属于某一个包的源文件都应该放置于一个单独命名的文件夹里。按照 Go 的惯例,应该用包名命名该文件夹。

因此,我们在 geometry 文件夹中,创建一个命名为 rectangle 的文件夹。在 rectangle 文件夹中,所有文件都会以 package rectangle 作为开头,因为它们都属于 rectangle 包。

在我们之前创建的 rectangle 文件夹中,再创建一个名为 rectprops.go 的文件,添加下列代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// rectprops.go
package rectangle

import "math"

func Area(len, wid float64) float64 {
area := len * wid
return area
}

func Diagonal(len, wid float64) float64 {
diagonal := math.Sqrt((len * len) + (wid * wid))
return diagonal
}

在上面的代码中,我们创建了两个函数用于计算 AreaDiagonal。矩形的面积是长和宽的乘积。矩形的对角线是长与宽平方和的平方根。math 包下面的 Sqrt 函数用于计算平方根。

注意到函数 Area 和 Diagonal 都是以大写字母开头的。这是有必要的,我们将会很快解释为什么需要这样做。

导入自定义包

为了使用自定义包,我们必须要先导入它。导入自定义包的语法为 import path。我们必须指定自定义包相对于工作区内 src 文件夹的相对路径。我们目前的文件夹结构是:

1
2
3
4
5
src
geometry
geometry.go
rectangle
rectprops.go

import "geometry/rectangle" 这一行会导入 rectangle 包。

geometry.go 里面添加下面的代码:

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

import (
"fmt"
"geometry/rectangle" // 导入自定义包
)

func main() {
var rectLen, rectWidth float64 = 6, 7
fmt.Println("Geometrical shape properties")
/*Area function of rectangle package used*/
fmt.Printf("area of rectangle %.2f\n", rectangle.Area(rectLen, rectWidth))
/*Diagonal function of rectangle package used*/
fmt.Printf("diagonal of the rectangle %.2f ", rectangle.Diagonal(rectLen, rectWidth))
}

上面的代码导入了 rectangle 包,并调用了里面的 Area 和 Diagonal 函数,得到矩形的面积和对角线。Printf 内的格式说明符 %.2f会将浮点数截断到小数点两位。应用程序的输出为:

1
2
3
Geometrical shape properties  
area of rectangle 42.00
diagonal of the rectangle 9.22

导出名字(Exported Names)

我们将 rectangle 包中的函数 Area 和 Diagonal 首字母大写。在 Go 中这具有特殊意义。在 Go 中,任何以大写字母开头的变量或者函数都是被导出的名字。其它包只能访问被导出的函数和变量。在这里,我们需要在 main 包中访问 Area 和 Diagonal 函数,因此会将它们的首字母大写。

rectprops.go 中,如果函数名从 Area(len, wid float64) 变为 area(len, wid float64),并且在 geometry.go 中, rectangle.Area(rectLen, rectWidth) 变为 rectangle.area(rectLen, rectWidth), 则该程序运行时,编译器会抛出错误 geometry.go:11: cannot refer to unexported name rectangle.area。因为如果想在包外访问一个函数,它应该首字母大写。

init 函数

所有包都可以包含一个 init 函数。init 函数不应该有任何返回值类型和参数,在我们的代码中也不能显式地调用它。init 函数的形式如下:

1
2
func init() {  
}

init 函数可用于执行初始化任务,也可用于在开始执行之前验证程序的正确性。

包的初始化顺序如下:

  1. 首先初始化包级别(Package Level)的变量
  2. 紧接着调用 init 函数。包可以有多个 init 函数(在一个文件或分布于多个文件中),它们按照编译器解析它们的顺序进行调用。

如果一个包导入了另一个包,会先初始化被导入的包。

尽管一个包可能会被导入多次,但是它只会被初始化一次。

为了理解 init 函数,我们接下来对程序做了一些修改。

首先在 rectprops.go 文件中添加了一个 init 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// rectprops.go
package rectangle

import "math"
import "fmt"

/*
* init function added
*/
func init() {
fmt.Println("rectangle package initialized")
}
func Area(len, wid float64) float64 {
area := len * wid
return area
}

func Diagonal(len, wid float64) float64 {
diagonal := math.Sqrt((len * len) + (wid * wid))
return diagonal
}

我们添加了一个简单的 init 函数,它仅打印 rectangle package initialized

现在我们来修改 main 包。我们知道矩形的长和宽都应该大于 0,我们将在 geometry.go 中使用 init 函数和包级别的变量来检查矩形的长和宽。

修改 geometry.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
27
28
29
30
31
// geometry.go
package main

import (
"fmt"
"geometry/rectangle" // 导入自定义包
"log"
)
/*
* 1. 包级别变量
*/
var rectLen, rectWidth float64 = 6, 7

/*
*2. init 函数会检查长和宽是否大于0
*/
func init() {
println("main package initialized")
if rectLen < 0 {
log.Fatal("length is less than zero")
}
if rectWidth < 0 {
log.Fatal("width is less than zero")
}
}

func main() {
fmt.Println("Geometrical shape properties")
fmt.Printf("area of rectangle %.2f\n", rectangle.Area(rectLen, rectWidth))
fmt.Printf("diagonal of the rectangle %.2f ",rectangle.Diagonal(rectLen, rectWidth))
}

我们对 geometry.go 做了如下修改:

  1. 变量 rectLenrectWidth 从 main 函数级别移到了包级别。
  2. 添加了 init 函数。当 rectLen 或 rectWidth 小于 0 时,init 函数使用 log.Fatal 函数打印一条日志,并终止了程序。

main 包的初始化顺序为:

  1. 首先初始化被导入的包。因此,首先初始化了 rectangle 包。
  2. 接着初始化了包级别的变量 rectLenrectWidth
  3. 调用 init 函数。
  4. 最后调用 main 函数。

当运行该程序时,会有如下输出。

1
2
3
4
5
rectangle package initialized  
main package initialized
Geometrical shape properties
area of rectangle 42.00
diagonal of the rectangle 9.22

果然,程序会首先调用 rectangle 包的 init 函数,然后,会初始化包级别的变量 rectLenrectWidth。接着调用 main 包里的 init 函数,该函数检查 rectLen 和 rectWidth 是否小于 0,如果条件为真,则终止程序。我们会在单独的教程里深入学习 if 语句。现在你可以认为 if rectLen < 0 能够检查 rectLen 是否小于 0,并且如果是,则终止程序。rectWidth 条件的编写也是类似的。在这里两个条件都为假,因此程序继续执行。最后调用了 main 函数。

让我们接着稍微修改这个程序来学习使用 init 函数。

geometry.go 中的 var rectLen, rectWidth float64 = 6, 7 改为 var rectLen, rectWidth float64 = -6, 7。我们把 rectLen 初始化为负数。

现在当运行程序时,会得到:

1
2
3
rectangle package initialized  
main package initialized
2017/04/04 00:28:20 length is less than zero

像往常一样, 会首先初始化 rectangle 包,然后是 main 包中的包级别的变量 rectLen 和 rectWidth。rectLen 为负数,因此当运行 init 函数时,程序在打印 length is less than zero 后终止。

本代码可以在 github 下载。

使用空白标识符(Blank Identifier)

导入了包,却不在代码中使用它,这在 Go 中是非法的。当这么做时,编译器是会报错的。其原因是为了避免导入过多未使用的包,从而导致编译时间显著增加。将 geometry.go 中的代码替换为如下代码:

1
2
3
4
5
6
7
8
9
// geometry.go
package main

import (
"geometry/rectangle" // 导入自定的包
)
func main() {

}

上面的程序将会抛出错误 geometry.go:6: imported and not used: "geometry/rectangle"

然而,在程序开发的活跃阶段,又常常会先导入包,而暂不使用它。遇到这种情况就可以使用空白标识符 _

下面的代码可以避免上述程序的错误:

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

import (
"geometry/rectangle"
)

var _ = rectangle.Area // 错误屏蔽器

func main() {

}

var _ = rectangle.Area 这一行屏蔽了错误。我们应该了解这些错误屏蔽器(Error Silencer)的动态,在程序开发结束时就移除它们,包括那些还没有使用过的包。由此建议在 import 语句下面的包级别范围中写上错误屏蔽器。

有时候我们导入一个包,只是为了确保它进行了初始化,而无需使用包中的任何函数或变量。例如,我们或许需要确保调用了 rectangle 包的 init 函数,而不需要在代码中使用它。这种情况也可以使用空白标识符,如下所示。

1
2
3
4
5
6
7
8
package main 

import (
_ "geometry/rectangle"
)
func main() {

}

运行上面的程序,会输出 rectangle package initialized。尽管在所有代码里,我们都没有使用这个包,但还是成功初始化了它。

6. 函数

函数是什么?

函数是一块执行特定任务的代码。一个函数是在输入源基础上,通过执行一系列的算法,生成预期的输出。

函数的声明

在 Go 语言中,函数声明通用语法如下:

1
2
3
func functionname(parametername type) returntype {  
// 函数体(具体实现的功能)
}

函数的声明以关键词 func 开始,后面紧跟自定义的函数名 functionname (函数名)。函数的参数列表定义在 () 之间,返回值的类型则定义在之后的 returntype (返回值类型)处。声明一个参数的语法采用 参数名 参数类型 的方式,任意多个参数采用类似 (parameter1 type, parameter2 type) 即(参数1 参数1的类型,参数2 参数2的类型)的形式指定。之后包含在 {} 之间的代码,就是函数体。

函数中的参数列表和返回值并非是必须的,所以下面这个函数的声明也是有效的

1
2
3
func functionname() {  
// 译注: 表示这个函数不需要输入参数,且没有返回值
}

示例函数

我们以写一个计算商品价格的函数为例,输入参数是单件商品的价格和商品的个数,两者的乘积为商品总价,作为函数的输出值。

1
2
3
4
func calculateBill(price int, no int) int {  
var totalPrice = price * no // 商品总价 = 商品单价 * 数量
return totalPrice // 返回总价
}

上述函数有两个整型的输入 priceno,返回值 totalPricepriceno 的乘积,也是整数类型。

如果有连续若干个参数,它们的类型一致,那么我们无须一一罗列,只需在最后一个参数后添加该类型。 例如,price int, no int可以简写为 price, no int,所以示例函数也可写成

1
2
3
4
func calculateBill(price, no int) int {  
var totalPrice = price * no
return totalPrice
}

现在我们已经定义了一个函数,我们要在代码中尝试着调用它。调用函数的语法为 functionname(parameters)。调用示例函数的方法如下:

1
calculateBill(10, 5)

完成了示例函数声明和调用后,我们就能写出一个完整的程序,并把商品总价打印在控制台上:

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

import (
"fmt"
)

func calculateBill(price, no int) int {
var totalPrice = price * no
return totalPrice
}
func main() {
price, no := 90, 6 // 定义 price 和 no,默认类型为 int
totalPrice := calculateBill(price, no)
fmt.Println("Total price is", totalPrice) // 打印到控制台上
}

运行这个程序

该程序在控制台上打印的结果为

1
Total price is 540

多返回值

Go 语言支持一个函数可以有多个返回值。我们来写个以矩形的长和宽为输入参数,计算并返回矩形面积和周长的函数 rectProps。矩形的面积是长度和宽度的乘积, 周长是长度和宽度之和的两倍。即:

  • 面积 = 长 * 宽
  • 周长 = 2 * ( 长 + 宽 )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
)

func rectProps(length, width float64)(float64, float64) {
var area = length * width
var perimeter = (length + width) * 2
return area, perimeter
}

func main() {
area, perimeter := rectProps(10.8, 5.6)
fmt.Printf("Area %f Perimeter %f", area, perimeter)
}

运行这个程序

如果一个函数有多个返回值,那么这些返回值必须用 () 括起来。func rectProps(length, width float64)(float64, float64) 示例函数有两个 float64 类型的输入参数 lengthwidth,并返回两个 float64 类型的值。该程序在控制台上打印结果为

1
Area 60.480000 Perimeter 32.800000

命名返回值

从函数中可以返回一个命名值。一旦命名了返回值,可以认为这些值在函数第一行就被声明为变量了。

上面的 rectProps 函数也可用这个方式写成:

1
2
3
4
5
func rectProps(length, width float64)(area, perimeter float64) {  
area = length * width
perimeter = (length + width) * 2
return // 不需要明确指定返回值,默认返回 area, perimeter 的值
}

请注意, 函数中的 return 语句没有显式返回任何值。由于 areaperimeter 在函数声明中指定为返回值, 因此当遇到 return 语句时, 它们将自动从函数返回。

空白符

_ 在 Go 中被用作空白符,可以用作表示任何类型的任何值。

我们继续以 rectProps 函数为例,该函数计算的是面积和周长。假使我们只需要计算面积,而并不关心周长的计算结果,该怎么调用这个函数呢?这时,空白符 _ 就上场了。

下面的程序我们只用到了函数 rectProps 的一个返回值 area

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

import (
"fmt"
)

func rectProps(length, width float64) (float64, float64) {
var area = length * width
var perimeter = (length + width) * 2
return area, perimeter
}
func main() {
area, _ := rectProps(10.8, 5.6) // 返回值周长被丢弃
fmt.Printf("Area %f ", area)
}

运行这个程序

在程序的 area, _ := rectProps(10.8, 5.6) 这一行,我们看到空白符 _ 用来跳过不要的计算结果。

5. 常量

定义

在 Go 语言中,术语”常量”用于表示固定的值。比如 5-89I love Go67.89 等等。

看看下面的代码:

1
2
var a int = 50  
var b string = "I love Go"

在上面的代码中,变量 a 和 b 分别被赋值为常量 50 和 I love GO。关键字 const 被用于表示常量,比如 50I love Go。即使在上面的代码中我们没有明确的使用关键字 const,但是在 Go 的内部,它们是常量。

顾名思义,常量不能再重新赋值为其他的值。因此下面的程序将不能正常工作,它将出现一个编译错误: cannot assign to a.

1
2
3
4
5
6
package main

func main() {
const a = 55 // 允许
a = 89 // 不允许重新赋值
}

在线运行程序

常量的值会在编译的时候确定。因为函数调用发生在运行时,所以不能将函数的返回值赋值给常量。

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

import (
"fmt"
"math"
)

func main() {
fmt.Println("Hello, playground")
var a = math.Sqrt(4) // 允许
const b = math.Sqrt(4) // 不允许
}

在线运行程序

在上面的程序中,因为 a 是变量,因此我们可以将函数 math.Sqrt(4) 的返回值赋值给它(我们将在单独的地方详细讨论函数)。

1
b` 是一个常量,它的值需要在编译的时候就确定。函数 `math.Sqrt(4)` 只会在运行的时候计算,因此 `const b = math.Sqrt(4)` 将会抛出错误 `error main.go:11: const initializer math.Sqrt(4) is not a constant)

字符串常量

双引号中的任何值都是 Go 中的字符串常量。例如像 Hello WorldSam 等字符串在 Go 中都是常量。

什么类型的字符串属于常量?答案是他们是无类型的。

Hello World 这样的字符串常量没有任何类型。

1
const hello = "Hello World"

上面的例子,我们把 Hello World 分配给常量 hello。现在常量 hello 有类型吗?答案是没有。常量仍然没有类型。

Go 是一门强类型语言,所有的变量必须有明确的类型。那么, 下面的程序是如何将无类型的常量 Sam 赋值给变量 name 的呢?

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

import (
"fmt"
)

func main() {
var name = "Sam"
fmt.Printf("type %T value %v", name, name)

}

在线运行程序

答案是无类型的常量有一个与它们相关联的默认类型,并且当且仅当一行代码需要时才提供它。在声明中 var name = “Sam” , name需要一个类型,它从字符串常量 Sam 的默认类型中获取。

有没有办法创建一个带类型的常量?答案是可以的。以下代码创建一个有类型常量。

1
const typedhello string = "Hello World"

上面代码中, typedhello 就是一个 string 类型的常量。

Go 是一个强类型的语言,在分配过程中混合类型是不允许的。让我们通过以下程序看看这句话是什么意思。

1
2
3
4
5
6
7
8
9
package main

func main() {
var defaultName = "Sam" // 允许
type myString string
var customName myString = "Sam" // 允许
customName = defaultName // 不允许

}

在线运行程序

在上面的代码中,我们首先创建一个变量 defaultName 并分配一个常量 Sam常量 Sam 的默认类型是 string ,所以在赋值后defaultName 是 string 类型的。

下一行,我们将创建一个新类型 myString,它是 string 的别名。

然后我们创建一个 myString 的变量 customName 并且给他赋值一个常量 Sam 。因为常量 Sam 是无类型的,它可以分配给任何字符串变量。因此这个赋值是允许的,customName 的类型是 myString

现在,我们有一个类型为 string 的变量 defaultName 和另一个类型为 myString 的变量 customName。即使我们知道这个 myStringstring 类型的别名。Go 的类型策略不允许将一种类型的变量赋值给另一种类型的变量。因此将 defaultName 赋值给 customName 是不允许的,编译器会抛出一个错误 main.go:7:20: cannot use defaultName (type string) as type myString in assignmen

布尔常量

布尔常量和字符串常量没有什么不同。他们是两个无类型的常量 truefalse。字符串常量的规则适用于布尔常量,所以在这里我们不再重复。以下是解释布尔常量的简单程序。

1
2
3
4
5
6
7
8
9
package main

func main() {
const trueConst = true
type myBool bool
var defaultBool = trueConst // 允许
var customBool myBool = trueConst // 允许
defaultBool = customBool // 不允许
}

在线运行程序

上面的程序是自我解释的。

数字常量

数字常量包含整数、浮点数和复数的常量。数字常量中有一些微妙之处。

让我们看一些例子来说清楚。

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

import (
"fmt"
)

func main() {
const a = 5
var intVar int = a
var int32Var int32 = a
var float64Var float64 = a
var complex64Var complex64 = a
fmt.Println("intVar",intVar, "\nint32Var", int32Var, "\nfloat64Var", float64Var, "\ncomplex64Var",complex64Var)
}

在线运行程序

上面的程序,常量 a 是没有类型的,它的值是 5 。您可能想知道 a 的默认类型是什么,如果它确实有一个的话, 那么我们如何将它分配给不同类型的变量。答案在于 a 的语法。下面的程序将使事情更加清晰。

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

import (
"fmt"
)

func main() {
var i = 5
var f = 5.6
var c = 5 + 6i
fmt.Printf("i's type %T, f's type %T, c's type %T", i, f, c)

}

在线运行程序

在上面的程序中,每个变量的类型由数字常量的语法决定。5 在语法中是整数, 5.6 是浮点数,5+6i 的语法是复数。当我们运行上面的程序,它会打印出 i's type int, f's type float64, c's type complex128

现在我希望下面的程序能够正确的工作。

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

import (
"fmt"
)

func main() {
const a = 5
var intVar int = a
var int32Var int32 = a
var float64Var float64 = a
var complex64Var complex64 = a
fmt.Println("intVar",intVar, "\nint32Var", int32Var, "\nfloat64Var", float64Var, "\ncomplex64Var",complex64Var)
}

在线运行程序

在这个程序中, a 的值是 5a 的语法是通用的(它可以代表一个浮点数、整数甚至是一个没有虚部的复数),因此可以将其分配给任何兼容的类型。这些常量的默认类型可以被认为是根据上下文在运行中生成的。 var intVar int = a 要求 aint,所以它变成一个 int 常量。 var complex64Var complex64 = a 要求 acomplex64,因此它变成一个复数类型。很简单的:)。

数字表达式

数字常量可以在表达式中自由混合和匹配,只有当它们被分配给变量或者在需要类型的代码中的任何地方使用时,才需要类型。

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

import (
"fmt"
)

func main() {
var a = 5.9/8
fmt.Printf("a's type %T value %v",a, a)
}

在线运行程序

在上面的程序中, 5.9 在语法中是浮点型,8 是整型,5.9/8 是允许的,因为两个都是数字常量。除法的结果是 0.7375 是一个浮点型,所以 a 的类型是浮点型。这个程序的输出结果是: a's type float64 value 0.7375

4. 类型

下面是 Go 支持的基本类型:

  • bool
  • 数字类型
    • int8, int16, int32, int64, int
    • uint8, uint16, uint32, uint64, uint
    • float32, float64
    • complex64, complex128
    • byte
    • rune
  • string

bool

bool 类型表示一个布尔值,值为 true 或者 false。

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

import "fmt"

func main() {
a := true
b := false
fmt.Println("a:", a, "b:", b)
c := a && b
fmt.Println("c:", c)
d := a || b
fmt.Println("d:", d)
}

在线运行程序

在上面的程序中,a 赋值为 true,b 赋值为 false。

c 赋值为 a && b。仅当 a 和 b 都为 true 时,操作符 && 才返回 true。因此,在这里 c 为 false。

当 a 或者 b 为 true 时,操作符 || 返回 true。在这里,由于 a 为 true,因此 d 也为 true。我们将得到程序的输出如下。

1
2
3
a: true b: false  
c: false
d: true

有符号整型

int8:表示 8 位有符号整型
大小:8 位
范围:-128~127

int16:表示 16 位有符号整型
大小:16 位
范围:-32768~32767

int32:表示 32 位有符号整型
大小:32 位
范围:-2147483648~2147483647

int64:表示 64 位有符号整型
大小:64 位
范围:-9223372036854775808~9223372036854775807

int:根据不同的底层平台(Underlying Platform),表示 32 或 64 位整型。除非对整型的大小有特定的需求,否则你通常应该使用 int表示整型。
大小:在 32 位系统下是 32 位,而在 64 位系统下是 64 位。
范围:在 32 位系统下是 -2147483648~2147483647,而在 64 位系统是 -9223372036854775808~9223372036854775807。

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
var a int = 89
b := 95
fmt.Println("value of a is", a, "and b is", b)
}

在线运行程序

上面程序会输出 value of a is 89 and b is 95

在上述程序中,a 是 int 类型,而 b 的类型通过赋值(95)推断得出。上面我们提到,int 类型的大小在 32 位系统下是 32 位,而在 64 位系统下是 64 位。接下来我们会证实这种说法。

在 Printf 方法中,使用 %T 格式说明符(Format Specifier),可以打印出变量的类型。Go 的 unsafe 包提供了一个 Sizeof 函数,该函数接收变量并返回它的字节大小。unsafe 包应该小心使用,因为使用 unsafe 包可能会带来可移植性问题。不过出于本教程的目的,我们是可以使用的。

下面程序会输出变量 a 和 b 的类型和大小。格式说明符 %T 用于打印类型,而 %d 用于打印字节大小。

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

import (
"fmt"
"unsafe"
)

func main() {
var a int = 89
b := 95
fmt.Println("value of a is", a, "and b is", b)
fmt.Printf("type of a is %T, size of a is %d", a, unsafe.Sizeof(a)) // a 的类型和大小
fmt.Printf("\ntype of b is %T, size of b is %d", b, unsafe.Sizeof(b)) // b 的类型和大小
}

在线运行程序

以上程序会输出:

1
2
3
value of a is 89 and b is 95  
type of a is int, size of a is 4
type of b is int, size of b is 4

从上面的输出,我们可以推断出 a 和 b 为 int 类型,且大小都是 32 位(4 字节)。如果你在 64 位系统上运行上面的代码,会有不同的输出。在 64 位系统下,a 和 b 会占用 64 位(8 字节)的大小。

无符号整型

uint8:表示 8 位无符号整型
大小:8 位
范围:0~255

uint16:表示 16 位无符号整型
大小:16 位
范围:0~65535

uint32:表示 32 位无符号整型
大小:32 位
范围:0~4294967295

uint64:表示 64 位无符号整型
大小:64 位
范围:0~18446744073709551615

uint:根据不同的底层平台,表示 32 或 64 位无符号整型。
大小:在 32 位系统下是 32 位,而在 64 位系统下是 64 位。
范围:在 32 位系统下是 0~4294967295,而在 64 位系统是 0~18446744073709551615。

浮点型

float32:32 位浮点数
float64:64 位浮点数

下面一个简单程序演示了整型和浮点型的运用。

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

import (
"fmt"
)

func main() {
a, b := 5.67, 8.97
fmt.Printf("type of a %T b %T\n", a, b)
sum := a + b
diff := a - b
fmt.Println("sum", sum, "diff", diff)

no1, no2 := 56, 89
fmt.Println("sum", no1+no2, "diff", no1-no2)
}

在线运行程序

a 和 b 的类型根据赋值推断得出。在这里,a 和 b 的类型为 float64(float64 是浮点数的默认类型)。我们把 a 和 b 的和赋值给变量 sum,把 b 和 a 的差赋值给 diff,接下来打印 sum 和 diff。no1 和 no2 也进行了相同的计算。上述程序将会输出:

1
2
3
type of a float64 b float64  
sum 14.64 diff -3.3000000000000007
sum 145 diff -33

复数类型

complex64:实部和虚部都是 float32 类型的的复数。
complex128:实部和虚部都是 float64 类型的的复数。

内建函数 complex 用于创建一个包含实部和虚部的复数。complex 函数的定义如下:

1
func complex(r, i FloatType) ComplexType

该函数的参数分别是实部和虚部,并返回一个复数类型。实部和虚部应该是相同类型,也就是 float32 或 float64。如果实部和虚部都是 float32 类型,则函数会返回一个 complex64 类型的复数。如果实部和虚部都是 float64 类型,则函数会返回一个 complex128 类型的复数。

还可以使用简短语法来创建复数:

1
c := 6 + 7i

下面我们编写一个简单的程序来理解复数。

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

import (
"fmt"
)

func main() {
c1 := complex(5, 7)
c2 := 8 + 27i
cadd := c1 + c2
fmt.Println("sum:", cadd)
cmul := c1 * c2
fmt.Println("product:", cmul)
}

在线运行程序

在上面的程序里,c1 和 c2 是两个复数。c1的实部为 5,虚部为 7。c2 的实部为8,虚部为 27。c1 和 c2 的和赋值给 cadd ,而 c1 和 c2 的乘积赋值给 cmul。该程序将输出:

1
2
sum: (13+34i)  
product: (-149+191i)

其他数字类型

byte 是 uint8 的别名。
rune 是 int32 的别名。

在学习字符串的时候,我们会详细讨论 byte 和 rune。

string 类型

在 Golang 中,字符串是字节的集合。如果你现在还不理解这个定义,也没有关系。我们可以暂且认为一个字符串就是由很多字符组成的。我们后面会在一个教程中深入学习字符串。 下面编写一个使用字符串的程序。

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

import (
"fmt"
)

func main() {
first := "Naveen"
last := "Ramanathan"
name := first +" "+ last
fmt.Println("My name is",name)
}

在线运行程序

上面程序中,first 赋值为字符串 “Naveen”,last 赋值为字符串 “Ramanathan”。+ 操作符可以用于拼接字符串。我们拼接了 first、空格和 last,并将其赋值给 name。上述程序将打印输出 My name is Naveen Ramanathan

还有许多应用于字符串上面的操作,我们将会在一个单独的教程里看见它们。

类型转换

Go 有着非常严格的强类型特征。Go 没有自动类型提升或类型转换。我们通过一个例子说明这意味着什么。

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

import (
"fmt"
)

func main() {
i := 55 //int
j := 67.8 //float64
sum := i + j //不允许 int + float64
fmt.Println(sum)
}

在线运行程序

上面的代码在 C 语言中是完全合法的,然而在 Go 中,却是行不通的。i 的类型是 int ,而 j 的类型是 float64 ,我们正试图把两个不同类型的数相加,Go 不允许这样的操作。如果运行程序,你会得到 main.go:10: invalid operation: i + j (mismatched types int and float64)

要修复这个错误,i 和 j 应该是相同的类型。在这里,我们把 j 转换为 int 类型。把 v 转换为 T 类型的语法是 T(v)。

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

import (
"fmt"
)

func main() {
i := 55 //int
j := 67.8 //float64
sum := i + int(j) //j is converted to int
fmt.Println(sum)
}

在线运行程序

现在,当你运行上面的程序时,会看见输出 122

赋值的情况也是如此。把一个变量赋值给另一个不同类型的变量,需要显式的类型转换。下面程序说明了这一点。

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

import (
"fmt"
)

func main() {
i := 10
var j float64 = float64(i) // 若没有显式转换,该语句会报错
fmt.Println("j", j)
}

在线运行程序

在第 9 行,i 转换为 float64 类型,接下来赋值给 j。如果不进行类型转换,当你试图把 i 赋值给 j 时,编译器会抛出错误。

事件—理解事件循环机制

前言

Node.js 是基于V8引擎的javascript运行环境. Node.js具有事件驱动, 非阻塞I/O等特点. 结合Node API, Node.js 具有网络编程, 文件系统等服务端的功能, Node.js用libuv库进行异步事件处理.

线程

Node.js的单线程含义, 实际上说的是执行同步代码的主线程. 一个Node程序的启动, 不止是分配了一个线程,而是我们只能在一个线程执行代码. 当出现I/O资源调用, TCP连接等外部资源申请的时候, 不会阻塞主线程, 而是委托给I/O线程进行处理,并且进入等待队列. 一旦主线程执行完成,将会消费事件队列(Event Queue). 因为只有一个主线程, 只占用CPU内核处理逻辑计算, 因此不适合在CPU密集型进行使用.

注意,上图的EVENT_QUEUE 给人看起来是只有一个队列, 根据Node.js官方介绍, EventLoop有6个阶段, 同时每个阶段都有对应的一个先进先出的回调队列.

什么是事件循环(EventLoop) ?

In computer science, the event loop, message dispatcher, message loop, message pump, or run loop is a programming construct that waits for and dispatches events or messages in a program. – from wiki

大概含义: EventLoop 是一种常用的机制,通过对内部或外部的事件提供者发出请求, 如文件读写, 网络连接 等异步操作, 完成后调用事件处理程序. 整个过程都是异步阶段

Node.js的事件循环机制

When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop. – from node.js doc

大致含义: 当Node.js 启动, 就会初始化一个 event loop, 处理脚本时, 可能会发生异步API行为调用, 使用定时器任务或者nexTick, 处理完成后进入事件循环处理过程

事件循环阶段

Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现核心源码参考

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
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;

r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);

while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
// timers阶段
uv__run_timers(loop);
// I/O callbacks阶段
ran_pending = uv__run_pending(loop);
// idle阶段
uv__run_idle(loop);
// prepare阶段
uv__run_prepare(loop);

timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
// poll阶段
uv__io_poll(loop, timeout);
// check阶段
uv__run_check(loop);
// close callbacks阶段
uv__run_closing_handles(loop);

if (mode == UV_RUN_ONCE) {
uv__update_time(loop);
uv__run_timers(loop);
}

r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}

if (loop->stop_flag != 0)
loop->stop_flag = 0;

return r;
}

根据Node.js官方介绍,每次事件循环都包含了6个阶段,对应到 libuv 源码中的实现,如下图所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘

每一个阶段都有一个FIFO的callbacks队列, 每个阶段都有自己的事件处理方式. 当事件循环进入某个阶段时, 将会在该阶段内执行回调,直到队列耗尽或者回调的最大数量已执行, 那么将进入下一个处理阶段.

  • timers 阶段: 这个阶段执行setTimeout(callback) and setInterval(callback)预定的callback;
  • I/O callbacks 阶段: 执行除了close事件的callbacks、被timers(定时器,setTimeout、setInterval等)设定的callbacks、setImmediate()设定的callbacks之外的callbacks; (目前这个阶段)
  • idle, prepare 阶段: 仅node内部使用;
  • poll 阶段: 获取新的I/O事件, 适当的条件下node将阻塞在这里;
  • check 阶段: 执行setImmediate() 设定的callbacks;
  • close callbacks 阶段: 比如socket.on(‘close’, callback)的callback会在这个阶段执行.

timers阶段

一个timer指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定时间过后,timers会尽可能早地执行回调,但系统调度或者其它回调的执行可能会延迟它们。

注意:技术上来说,poll 阶段控制 timers 什么时候执行。

注意:这个下限时间有个范围:[1, 2147483647],如果设定的时间不在这个范围,将被设置为1。

I/O callbacks阶段

这个阶段执行一些系统操作的回调。比如TCP错误,如一个TCP socket在想要连接时收到ECONNREFUSED,
类unix系统会等待以报告错误,这就会放到 I/O callbacks 阶段的队列执行.
名字会让人误解为执行I/O回调处理程序, 实际上I/O回调会由poll阶段处理.

poll阶段

poll 阶段有两个主要功能:

执行下限时间已经达到的timers的回调,然后
处理 poll 队列里的事件。
当event loop进入 poll 阶段,并且 没有设定的timers(there are no timers scheduled),会发生下面两件事之一:

如果 poll 队列不空,event loop会遍历队列并同步执行回调,直到队列清空或执行的回调数到达系统上限;

如果 poll 队列为空,则发生以下两件事之一:

  1. 如果代码已经被setImmediate()设定了回调, event loop将结束 poll 阶段进入 check 阶段来执行 check 队列(里的回调)。
  2. 如果代码没有被setImmediate()设定回调,event loop将阻塞在该阶段等待回调被加入 poll 队列,并立即执行。

但是,当event loop进入 poll 阶段,并且 有设定的timers,一旦 poll 队列为空(poll 阶段空闲状态):

  1. event loop将检查timers,如果有1个或多个timers的下限时间已经到达,event loop将绕回 timers 阶段,并执行 timer队列。

check阶段

这个阶段允许在 poll 阶段结束后立即执行回调。如果 poll 阶段空闲,并且有被setImmediate()设定的回调,event loop会转到 check 阶段而不是继续等待。

setImmediate()实际上是一个特殊的timer,跑在event loop中一个独立的阶段。它使用libuv的API
来设定在 poll 阶段结束后立即执行回调。

通常上来讲,随着代码执行,event loop终将进入 poll 阶段,在这个阶段等待 incoming connection, request 等等。但是,只要有被setImmediate()设定了回调,一旦 poll 阶段空闲,那么程序将结束 poll 阶段并进入 check 阶段,而不是继续等待 poll 事件们 (poll events)。

close callbacks 阶段

如果一个 socket 或 handle 被突然关掉(比如 socket.destroy()),close事件将在这个阶段被触发,否则将通过process.nextTick()触发

简单的 EventLoop

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
const fs = require('fs');
let counts = 0;

function wait (mstime) {
let date = Date.now();
while (Date.now() - date < mstime) {
// do nothing
}
}

function asyncOperation (callback) {
fs.readFile(__dirname + '/' + __filename, callback);
}

const lastTime = Date.now();

setTimeout(() => {
console.log('timers', Date.now() - lastTime + 'ms');
}, 0);

process.nextTick(() => {
// 进入event loop
// timers阶段之前执行
wait(20);
asyncOperation(() => {
console.log('poll');
});
});

/**
* result:
* timers 21ms
* poll
*/

为了让setTimeout优先于fs.readFile 回调, 执行了process.nextTick, 表示在进入 timers阶段前, 等待20ms后执行文件读取.

nextTick 与 setImmediate

process.nextTick 不属于事件循环的任何一个阶段,它属于该阶段与下阶段之间的过渡, 即本阶段执行结束, 进入下一个阶段前, 所要执行的回调。有给人一种插队的感觉.

setImmediate的回调处于check阶段, 当poll阶段的队列为空, 且check阶段的事件队列存在的时候,切换到check阶段执行.

nextTick 递归的危害
由于nextTick具有插队的机制,nextTick的递归会让事件循环机制无法进入下一个阶段. 导致I/O处理完成或者定时任务超时后仍然无法执行, 导致了其它事件处理程序处于饥饿状态. 为了防止递归产生的问题, Node.js 提供了一个 process.maxTickDepth (默认 1000)。

递归nextTick

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const fs = require('fs');
let counts = 0;

function wait (mstime) {
let date = Date.now();
while (Date.now() - date < mstime) {
// do nothing
}
}

function nextTick () {
process.nextTick(() => {
wait(20);
nextTick();
});
}

const lastTime = Date.now();

setTimeout(() => {
console.log('timers', Date.now() - lastTime + 'ms');
}, 0);

nextTick();

此时永远无法跳到timer阶段, 因为在进入timers阶段前有不断的nextTick插入执行. 除非执行了1000次到了执行上限.

setImmediate
如果在一个I/O周期内进行调度,setImmediate()将始终在任何定时器之前执行.

setTimeout 与 setImmediate

  • setImmediate()被设计在 poll 阶段结束后立即执行回调;
  • setTimeout()被设计在指定下限时间到达后执行回调;

无 I/O 处理情况下

1
2
3
4
5
6
7
setTimeout(function timeout () {
console.log('timeout');
},0);

setImmediate(function immediate () {
console.log('immediate');
});

输出结果是 不确定 的!
setTimeout(fn, 0) 具有几毫秒的不确定性. 无法保证进入timers阶段, 定时器能够立即执行处理程序.

在I/O事件处理程序下

1
2
3
4
5
6
7
8
9
10
var fs = require('fs')

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})

此时 setImmediate 优先于 setTimeout 执行,因为 poll阶段执行完成后 进入 check阶段. timers阶段处于下一个事件循环阶段了.

参考

https://segmentfault.com/a/1190000012258592
http://lynnelv.github.io/js-event-loop-nodejs

3. 变量

变量是什么

变量指定了某存储单元(Memory Location)的名称,该存储单元会存储特定类型的值。在 Go 中,有多种语法用于声明变量。

声明单个变量

var name type 是声明单个变量的语法。

1
2
3
4
5
6
7
8
package main

import "fmt"

func main() {
var age int // 变量声明
fmt.Println("my age is", age)
}

在线运行程序

语句 var age int 声明了一个 int 类型的变量,名字为 age。我们还没有给该变量赋值。如果变量未被赋值,Go 会自动地将其初始化,赋值该变量类型的零值(Zero Value)。本例中 age 就被赋值为 0。如果你运行该程序,你会看到如下输出:

1
my age is 0

变量可以赋值为本类型的任何值。上一程序中的 age 可以赋值为任何整型值(Integer Value)。

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

import "fmt"

func main() {
var age int // 变量声明
fmt.Println("my age is", age)
age = 29 // 赋值
fmt.Println("my age is", age)
age = 54 // 赋值
fmt.Println("my new age is", age)
}

在线运行程序

上面的程序会有如下输出:

1
2
3
my age is  0  
my age is 29
my new age is 54

声明变量并初始化

声明变量的同时可以给定初始值。 var name type = initialvalue 的语法用于声明变量并初始化。

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
var age int = 29 // 声明变量并初始化

fmt.Println("my age is", age)
}

在线运行程序

在上面的程序中,age 是具有初始值 29 的 int 类型变量。如果你运行上面的程序,你可以看见下面的输出,证实 age 已经被初始化为 29。

1
my age is 29

类型推断(Type Inference)

如果变量有初始值,那么 Go 能够自动推断具有初始值的变量的类型。因此,如果变量有初始值,就可以在变量声明中省略 type

如果变量声明的语法是 var name = initialvalue,Go 能够根据初始值自动推断变量的类型。

在下面的例子中,你可以看到在第 6 行,我们省略了变量 ageint 类型,Go 依然推断出了它是 int 类型。

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
var age = 29 // 可以推断类型

fmt.Println("my age is", age)
}

在线运行程序

声明多个变量

Go 能够通过一条语句声明多个变量。

声明多个变量的语法是 var name1, name2 type = initialvalue1, initialvalue2

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
var width, height int = 100, 50 // 声明多个变量

fmt.Println("width is", width, "height is", heigh)
}

在线运行程序

上述程序将在标准输出打印 width is 100 height is 50

你可能已经想到,如果 width 和 height 省略了初始化,它们的初始值将赋值为 0。

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

import "fmt"

func main() {
var width, height int
fmt.Println("width is", width, "height is", height)
width = 100
height = 50
fmt.Println("new width is", width, "new height is ", height)
}

在线运行程序

上面的程序将会打印:

1
2
width is 0 height is 0  
new width is 100 new height is 50

在有些情况下,我们可能会想要在一个语句中声明不同类型的变量。其语法如下:

1
2
3
4
var (  
name1 = initialvalue1,
name2 = initialvalue2
)

使用上述语法,下面的程序声明不同类型的变量。

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

import "fmt"

func main() {
var (
name = "naveen"
age = 29
height int
)
fmt.Println("my name is", name, ", age is", age, "and height is", height)
}

在线运行程序

这里我们声明了 string 类型的 name、int 类型的 age 和 height(我们将会在下一教程中讨论 golang 所支持的变量类型)。运行上面的程序会产生输出 my name is naveen , age is 29 and height is 0

简短声明

Go 也支持一种声明变量的简洁形式,称为简短声明(Short Hand Declaration),该声明使用了 := 操作符。

声明变量的简短语法是 name := initialvalue

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
name, age := "naveen", 29 // 简短声明

fmt.Println("my name is", name, "age is", age)
}

在线运行程序

运行上面的程序,可以看到输出为 my name is naveen age is 29

简短声明要求 := 操作符左边的所有变量都有初始值。下面程序将会抛出错误 cannot assign 1 values to 2 variables,这是因为 age 没有被赋值

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
name, age := "naveen" //error

fmt.Println("my name is", name, "age is", age)
}

在线运行程序

简短声明的语法要求 := 操作符的左边至少有一个变量是尚未声明的。考虑下面的程序:

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

import "fmt"

func main() {
a, b := 20, 30 // 声明变量a和b
fmt.Println("a is", a, "b is", b)
b, c := 40, 50 // b已经声明,但c尚未声明
fmt.Println("b is", b, "c is", c)
b, c = 80, 90 // 给已经声明的变量b和c赋新值
fmt.Println("changed b is", b, "c is", c)
}

在线运行程序

在上面程序中的第 8 行,由于 b 已经被声明,而 c 尚未声明,因此运行成功并且输出:

1
2
3
a is 20 b is 30  
b is 40 c is 50
changed b is 80 c is 90

但是如果我们运行下面的程序:

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
a, b := 20, 30 // 声明a和b
fmt.Println("a is", a, "b is", b)
a, b := 40, 50 // 错误,没有尚未声明的变量
}

在线运行程序

上面运行后会抛出 no new variables on left side of := 的错误,这是因为 a 和 b 的变量已经声明过了,:= 的左边并没有尚未声明的变量。

变量也可以在运行时进行赋值。考虑下面的程序:

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

import (
"fmt"
"math"
)

func main() {
a, b := 145.8, 543.8
c := math.Min(a, b)
fmt.Println("minimum value is ", c)
}

在线运行程序

在上面的程序中,c 的值是运行过程中计算得到的,即 a 和 b 的最小值。上述程序会打印:

1
minimum value is  145.8

由于 Go 是强类型(Strongly Typed)语言,因此不允许某一类型的变量赋值为其他类型的值。下面的程序会抛出错误 cannot use "naveen" (type string) as type int in assignment,这是因为 age 本来声明为 int 类型,而我们却尝试给它赋字符串类型的值。

1
2
3
4
5
6
package main

func main() {
age := 29 // age是int类型
age = "naveen" // 错误,尝试赋值一个字符串给int类型变量
}

在线运行程序

2. Hello World

学习一种编程语言的最好方法就是去动手实践,编写代码。让我们开始编写第一个 Go 程序吧。

我个人推荐使用安装了 Go 扩展Visual Studio Code 作为 IDE。它具有自动补全、编码规范(Code Styling)以及许多其他的特性。

建立 Go 工作区

在编写代码之前,我们首先应该建立 Go 的工作区(Workspace)。

Mac 或 Linux 操作系统下,Go 工作区应该设置在 $HOME/go。所以我们要在 $HOME 目录下创建 go 目录。

而在 Windows 下,工作区应该设置在 C:\Users\YourName\go。所以请将 go 目录放置在 C:\Users\YourName

其实也可以通过设置 GOPATH 环境变量,用其他目录来作为工作区。但为了简单起见,我们采用上面提到的放置方法。

所有 Go 源文件都应该放置在工作区里的 src 目录下。请在刚添加的 go 目录下面创建目录 src

所有 Go 项目都应该依次在 src 里面设置自己的子目录。我们在 src 里面创建一个目录 hello 来放置整个 hello world 项目。

创建上述目录之后,其目录结构如下:

1
2
3
go
src
hello

在我们刚刚创建的 hello 目录下,在 helloworld.go 文件里保存下面的程序。

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
fmt.Println("Hello World")
}

创建该程序之后,其目录结构如下:

1
2
3
4
go
src
hello
helloworld.go

运行 Go 程序

运行 Go 程序有多种方式,我们下面依次介绍。

1.使用 go run 命令 - 在命令提示符旁,输入 go run workspacepath/src/hello/helloworld.go

上述命令中的 workspacepath 应该替换为你自己的工作区路径(Windows 下的 C:/Users/YourName/go,Linux 或 Mac 下的 $HOME/go)。

在控制台上会看见 Hello World 的输出。

2.使用 go install 命令 - 运行 go install hello,接着可以用 workspacepath/bin/hello 来运行该程序。

上述命令中的 workspacepath 应该替换为你自己的工作区路径(Windows 下的 C:/Users/YourName/go,Linux 或 Mac 下的 $HOME/go)。

当你输入 go install hello 时,go 工具会在工作区中搜索 hello 包(hello 称之为包,我们后面会更加详细地讨论包)。接下来它会在工作区的 bin 目录下,创建一个名为 hello(Windows 下名为 hello.exe)的二进制文件。运行 go install hello 后,其目录结构如下所示:

1
2
3
4
5
6
go
bin
hello
src
hello
helloworld.go

3.第 3 种运行程序的好方法是使用 go playground。尽管它有自身的限制,但该方法对于运行简单的程序非常方便。我已经在 playground 上创建了一个 hello world 程序。点击这里 在线运行程序。 你可以使用 go playground 与其他人分享你的源代码。

简述 hello world 程序

下面就是我们刚写下的 hello world 程序。

1
2
3
4
5
6
7
package main //1

import "fmt" //2

func main() { //3
fmt.Println("Hello World") //4
}

现在简单介绍每一行大概都做了些什么,在以后的教程中还会深入探讨每个部分。

package main - 每一个 Go 文件都应该在开头进行 package name 的声明(译注:只有可执行程序的包名应当为 main)。包(Packages)用于代码的封装与重用,这里的包名称是main

import “fmt” - 我们引入了 fmt 包,用于在 main 函数里面打印文本到标准输出。

func main() - main 是一个特殊的函数。整个程序就是从 main 函数开始运行的。main 函数必须放置在 main 包中{} 分别表示 main 函数的开始和结束部分。

fmt.Println(“Hello World”) - fmt 包中的 Println 函数用于把文本写入标准输出。

原文

https://studygolang.com/articles/11755

1. 介绍与安装

Golang 是什么

Go 亦称为 Golang(译注:按照 Rob Pike 说法,语言叫做 Go,Golang 只是官方网站的网址),是由谷歌开发的一个开源的编译型的静态语言。

Golang 的主要关注点是使得高可用性和可扩展性的 Web 应用的开发变得简便容易。(译注:Go 的定位是系统编程语言,只是对 Web 开发支持较好)

为何选择 Golang

既然有很多其他编程语言可以做同样的工作,如 Python,Ruby,Nodejs 等,为什么要选择 Golang 作为服务端编程语言?

以下是我使用 Go 语言时发现的一些优点:

  • 并发是语言的一部分(译注:并非通过标准库实现),所以编写多线程程序会是一件很容易的事。后续教程将会讨论到,并发是通过 Goroutines 和 channels 机制实现的。
  • Golang 是一种编译型语言。源代码会编译为二进制机器码。而在解释型语言中没有这个过程,如 Nodejs 中的 JavaScript。
  • 语言规范十分简洁。所有规范都在一个页面展示,你甚至都可以用它来编写你自己的编译器呢。
  • Go 编译器支持静态链接。所有 Go 代码都可以静态链接为一个大的二进制文件(译注:相对现在的磁盘空间,其实根本不大),并可以轻松部署到云服务器,而不必担心各种依赖性。

安装

Golang 支持三个平台:Mac,Windows 和 Linux(译注:不只是这三个,也支持其他主流平台)。你可以在 https://golang.org/dl/ 中下载相应平台的二进制文件。(译注:因为众所周知的原因,如果下载不了,请到 https://studygolang.com/dl 下载)

Mac OS

https://golang.org/dl/ 下载安装程序。双击开始安装并且遵循安装提示,会将 Golang 安装到 /usr/local/go 目录下,同时 /usr/local/go/bin 文件夹也会被添加到 PATH 环境变量中。

Windows

https://golang.org/dl/ 下载 MSI 安装程序。双击开始安装并且遵循安装提示,会将 Golang 安装到 C:\Go 目录下,同时 c:\Go\bin 目录也会被添加到你的 PATH 环境变量中。

Linux

https://golang.org/dl/ 下载 tar 文件,并解压到 /usr/local

请添加 /usr/local/go/binPATH 环境变量中。Go 就已经成功安装在 Linux 上了。

原文

https://studygolang.com/articles/11706

IO—深入理解Node.js Stream内部机制

相信很多人对 Node.js 的 Stream 已经不陌生了,不论是请求流、响应流、文件流还是 socket 流,这些流的底层都是使用 stream 模块封装的,甚至我们平时用的最多的 console.log 打印日志也使用了它,不信你打开 Node.js runtime 的源码,看看 lib/console.js

1
2
3
4
5
6
7
8
9
10
11
12
13
function write(ignoreErrors, stream, string, errorhandler) {
// ...
stream.once('error', noop);
stream.write(string, errorhandler);
//...
}

Console.prototype.log = function log(...args) {
write(this._ignoreErrors,
this._stdout,
`${util.format.apply(null, args)}\n`,
this._stdoutErrorHandler);
};

Stream 模块做了很多事情,了解了 Stream,那么 Node.js 中其他很多模块理解起来就顺畅多了。

stream 模块

如果你了解 生产者和消费者问题 的解法,那理解 stream 就基本没有压力了,它不仅仅是资料的起点和落点,还包含了一系列状态控制,可以说一个 stream 就是一个状态管理单元。了解内部机制的最佳方式除了看 Node.js 官方文档,还可以去看看 Node.js 的 源码

  • lib/module.js
  • lib/_stream_readable.js
  • lib/_stream_writable.js
  • lib/_stream_tranform.js
  • lib/_stream_duplex.js

ReadableWritable 看明白,Tranform 和 Duplex 就不难理解了。

Readable Stream

Readable Stream 存在两种模式,一种是叫做 Flowing Mode,流动模式,在 Stream 上绑定 ondata 方法就会自动触发这个模式,比如:

1
2
3
4
const readable = getReadableStreamSomehow();
readable.on('data', (chunk) => {
console.log(`Received ${chunk.length} bytes of data.`);
});

这个模式的流程图如下:

资源的数据流并不是直接流向消费者,而是先 push 到缓存池,缓存池有一个水位标记 highWatermark,超过这个标记阈值,push 的时候会返回 false,什么场景下会出现这种情况呢?

  • 消费者主动执行了 .pause()
  • 消费速度比数据 push 到缓存池的生产速度慢

有个专有名词来形成这种情况,叫做「背压」,Writable Stream 也存在类似的情况。

流动模式,这个名词还是很形象的,缓存池就像一个水桶,消费者通过管口接水,同时,资源池就像一个水泵,不断地往水桶中泵水,而 highWaterMark 是水桶的浮标,达到阈值就停止蓄水。
下面是一个简单的 Demo:

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
const Readable = require('stream').Readable;

// Stream 实现
class MyReadable extends Readable {
constructor(dataSource, options) {
super(options);
this.dataSource = dataSource;
}
// 继承了 Readable 的类必须实现这个函数
// 触发系统底层对流的读取
_read() {
const data = this.dataSource.makeData();
this.push(data);
}
}

// 模拟资源池
const dataSource = {
data: new Array(10).fill('-'),
// 每次读取时 pop 一个数据
makeData() {
if (!dataSource.data.length) return null;
return dataSource.data.pop();
}
};

const myReadable = new MyReadable(dataSource);
myReadable.setEncoding('utf8');
myReadable.on('data', (chunk) => {
console.log(chunk);
});

另外一种模式是 Non-Flowing Mode,没流动,也就是暂停模式,这是 Stream 的预设模式,Stream 实例的 _readableState.flow 有三个状态,分别是:

  • _readableState.flow = null,暂时没有消费者过来
  • _readableState.flow = false,主动触发了 .pause()
  • _readableState.flow = true,流动模式

当我们监听了 onreadable 事件后,会进入这种模式,比如:

1
2
3
const myReadable = new MyReadable(dataSource);
myReadable.setEncoding('utf8');
myReadable.on('readable', () => {});

监听 readable 的回调函数第一个参数不会传递内容,需要我们通过 myReadable.read() 主动读取,为啥呢,可以看看下面这张图:

资源池会不断地往缓存池输送数据,直到 highWaterMark 阈值,消费者监听了 readable 事件并不会消费数据,需要主动调用 .read([size]) 函数才会从缓存池取出,并且可以带上 size 参数,用多少就取多少:

1
2
3
4
5
6
7
8
const myReadable = new MyReadable(dataSource);
myReadable.setEncoding('utf8');
myReadable.on('readable', () => {
let chunk;
while (null !== (chunk = myReadable.read())) {
console.log(`Received ${chunk.length} bytes of data.`);
}
});

这里需要注意一点,只要数据达到缓存池都会触发一次 readable 事件,有可能出现「消费者正在消费数据的时候,又触发了一次 readable 事件,那么下次回调中 read 到的数据可能为空」的情况。我们可以通过 _readableState.buffer 来查看缓存池到底缓存了多少资源:

1
2
3
4
5
6
7
let once = false;
myReadable.on('readable', (chunk) => {
console.log(myReadable._readableState.buffer.length);
if (once) return;
once = true;
console.log(myReadable.read());
});

上面的代码我们只消费一次缓存池的数据,那么在消费后,缓存池又收到了一次资源池的 push 操作,此时还会触发一次 readable 事件,我们可以看看这次存了多大的 buffer。

需要注意的是,buffer 大小也是有上限的,默认设置为 16kb,也就是 16384 个字节长度,它最大可设置为 8Mb,没记错的话,这个值好像是 Node 的 new space memory 的大小。

上面介绍了 Readable Stream 大概的机制,还有很多细节部分没有提到,比如 Flowing Mode 在不同 Node 版本中的 Stream 实现不太一样,实际上,它有三个版本,上面提到的是第 2 和 第 3 个版本的实现;再比如 Mixins Mode 模式,一般我们只推荐(允许)使用 ondata 和 onreadable 的一种来处理 Readable Stream,但是如果要求在 Non-Flowing Mode 的情况下使用 ondata 如何实现呢?那么就可以考虑 Mixins Mode 了。

Writable Stream

原理与 Readable Stream 是比较相似的,数据流过来的时候,会直接写入到资源池,当写入速度比较缓慢或者写入暂停时,数据流会进入队列池缓存起来,如下图所示:

当生产者写入速度过快,把队列池装满了之后,就会出现「背压」,这个时候是需要告诉生产者暂停生产的,当队列释放之后,Writable Stream 会给生产者发送一个 drain 消息,让它恢复生产。下面是一个写入一百万条数据的 Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function writeOneMillionTimes(writer, data, encoding, callback) {
let i = 10000;
write();
function write() {
let ok = true;
while(i-- > 0 && ok) {
// 写入结束时回调
ok = writer.write(data, encoding, i === 0 ? callback : null);
}
if (i > 0) {
// 这里提前停下了,'drain' 事件触发后才可以继续写入
console.log('drain', i);
writer.once('drain', write);
}
}
}

我们构造一个 Writable Stream,在写入到资源池的时候,我们稍作处理,让它效率低一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
const Writable = require('stream').Writable;
const writer = new Writable({
write(chunk, encoding, callback) {
// 比 process.nextTick() 稍慢
setTimeout(() => {
callback && callback();
});
}
});

writeOneMillionTimes(writer, 'simple', 'utf8', () => {
console.log('end');
});

最后执行的结果是:

1
2
3
4
drain 7268
drain 4536
drain 1804
end

说明程序遇到了三次「背压」,如果我们没有在上面绑定 writer.once('drain'),那么最后的结果就是 Stream 将第一次获取的数据消耗完变结束了程序。

pipe

了解了 Readable 和 Writable,pipe 这个常用的函数应该就很好理解了,

1
readable.pipe(writable);

这句代码的语意性很强,readable 通过 pipe(管道)传输给 writable,pipe 的实现大致如下(伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Readable.prototype.pipe = function(writable, options) {
this.on('data', (chunk) => {
let ok = writable.write(chunk);
// 背压,暂停
!ok && this.pause();
});
writable.on('drain', () => {
// 恢复
this.resume();
});
// 告诉 writable 有流要导入
writable.emit('pipe', this);
// 支持链式调用
return writable;
};

上面做了五件事情:

  • emit(pipe),通知写入
  • .write(),新数据过来,写入
  • .pause(),消费者消费速度慢,暂停写入
  • .resume(),消费者完成消费,继续写入
  • return writable,支持链式调用

当然,上面只是最简单的逻辑,还有很多异常和临界判断没有加入,具体可以去看看 Node.js 的代码( /lib/_stream_readable.js)。

Duplex Stream

Duplex,双工的意思,它的输入和输出可以没有任何关系,

Duplex Stream 实现特别简单,不到一百行代码,它继承了 Readable Stream,并拥有 Writable Stream 的方法(源码地址):

1
2
3
4
5
6
7
8
9
10
11
12
const util = require('util');
const Readable = require('_stream_readable');
const Writable = require('_stream_writable');

util.inherits(Duplex, Readable);

var keys = Object.keys(Writable.prototype);
for (var v = 0; v < keys.length; v++) {
var method = keys[v];
if (!Duplex.prototype[method])
Duplex.prototype[method] = Writable.prototype[method];
}

我们可以通过 options 参数来配置它为只可读、只可写或者半工模式,一个简单的 Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var Duplex = require('stream').Duplex

const duplex = Duplex();

// readable
let i = 2;
duplex._read = function () {
this.push(i-- ? 'read ' + i : null);
};
duplex.on('data', data => console.log(data.toString()));

// writable
duplex._write = function (chunk, encoding, callback) {
console.log(chunk.toString());
callback();
};
duplex.write('write');

输出的结果为:

1
2
3
write
read 1
read 0

可以看出,两个管道是相互之间不干扰的。

Transform Stream

Transform Stream 集成了 Duplex Stream,它同样具备 Readable 和 Writable 的能力,只不过它的输入和输出是存在相互关联的,中间做了一次转换处理。常见的处理有 Gzip 压缩、解压等。

Transform 的处理就是通过 _transform 函数将 Duplex 的 Readable 连接到 Writable,由于 Readable 的生产效率与 Writable 的消费效率是一样的,所以这里 Transform 内部不存在「背压」问题,背压问题的源头是外部的生产者和消费者速度差造成的。

关于 Transfrom Stream,我写了一个简单的 Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const Transform = require('stream').Transform;
const MAP = {
'Barret': '靖',
'Lee': '李'
};

class Translate extends Transform {
constructor(dataSource, options) {
super(options);
}
_transform(buf, enc, next) {
const key = buf.toString();
const data = MAP[key];
this.push(data);
next();
}
}

var transform = new Translate();
transform.on('data', data => console.log(data.toString()));
transform.write('Lee');
transform.write('Barret');
transform.end();

小结

本文主要参考和查阅 Node.js 官网的文档和源码,细节问题都是从源码中找到的答案,如有理解不准确之处,还请斧正。关于 Stream,这篇文章只是讲述了基础的原理,还有很多细节之处没有讲到,要真正理解它,还是需要多读读文档,写写代码。

原文

http://taobaofed.org/blog/2017/08/31/nodejs-stream/

当Node.js遇见Docker

什么是Docker?

Docker是最流行的的容器工具,没有之一。本文并不打算深入介绍Docker,不过可以从几个简单的角度来理解Docker。

从进程的角度理解Docker

在Linux中,所有的进程构成了一棵树。可以使用pstree命令进行查看:

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
pstree
init─┬─VBoxService───7*[{VBoxService}]
├─acpid
├─atd
├─cron
├─dbus-daemon
├─dhclient
├─dockerd─┬─docker-containe─┬─docker-containe─┬─redis-server───2*[{redis-server}]
│ │ │ └─8*[{docker-containe}]
│ │ ├─docker-containe─┬─mongod───16*[{mongod}]
│ │ │ └─8*[{docker-containe}]
│ │ └─11*[{docker-containe}]
│ └─13*[{dockerd}]
├─6*[getty]
├─influxd───9*[{influxd}]
├─irqbalance
├─puppet───{puppet}
├─rpc.idmapd
├─rpc.statd
├─rpcbind
├─rsyslogd───3*[{rsyslogd}]
├─ruby───{ruby}
├─sshd─┬─sshd───sshd───zsh───pstree
│ ├─sshd───sshd───zsh
│ └─sshd───sshd───zsh───mongo───2*[{mongo}]
├─systemd-logind
├─systemd-udevd
├─upstart-file-br
├─upstart-socket-
└─upstart-udev-br

可知,init进程为所有进程的根(root),其PID为1。

Docker将不同应用的进程隔离了起来,这些被隔离的进程就是一个个容器。隔离是基于两个Linux内核机制实现的,Namesapce和Cgroups。

Namespace可以从UTD、IPC、PID、Mount,User和Network的角度隔离进程。比如,不同的进程将拥有不同PID空间,这样容器中的进程将看不到主机上的进程,也看不到其他容器中的进程。这与Node.js中模块化以隔离变量的命名空间的思想是异曲同工的。

通过Cgroups,可以限制进程对CPU,内存等资源的使用。简单地说,我们可以通过Cgroups指定容器只能使用1G内存。

从进程角度理解Docker,那每一个Docker容器就是被隔离的进程及其子进程。上文pstree的输出中可以分辨出2个容器: mongodb和redis。

从文件的角度理解Docker

基于Namespace与Cgroups的容器工具其实早已存在,例如Linux-VServerOpenVZLXC。然而,真正引爆容器技术的却是后来者Docker。为什么呢?个人觉得是因为Docker镜像以及Dockerfile

在Linux中,一切皆文件,进程的运行离不开各种各样的文件。跑一个简单的Node.js程序,传统的做法是手动安装各种依赖然后运行;而Docker则是将所有依赖(包括操作系统,Node,NPM模块,源代码)打包到一个Docker镜像中,然后基于这个镜像运行容器。

Docker镜像可以通过Docker仓库共享给其他人,这样他们只需要下载镜像即可运行程序。想象一下,当我们需要在另一台主机(比如生产服务器,新同事的机器)上运行一个Node.js应用,仅仅需要下载对应的Docker镜像就可以了,是不是很方便呢?

Docker镜像可以通过文本文件,即Dockerfile进行定义。不妨看一个简单的例子(由于不可抗力,这个Dockerfile构建大概会失败,仅作为参考):

1
2
3
4
5
6
7
8
9
10
11
# 基于Ubuntu
FROM ubuntu

# 安装Node.js与NPM
RUN apt-get update && apt-get -y install nodejs npm

# 安装NPM模块:Express
RUN npm install express

# 添加源代码
ADD app.js /

其中,FROMRUNADD为Dockerfile命令。结合注释,该Dockerfile的含义非常直白。基于这个Dockerfile,使用docker build命令就可以构建对应的Docker镜像。基于这个Docker镜像,就可以运行Docker容器来执行app.js:

1
2
3
4
5
6
7
8
9
var express = require("express");
var app = express();

app.get("/", function(req, res)
{
res.send("Hello Fundebug!\n");
});

app.listen(3000);

Dockerfile实际上是将Docker镜像代码化了,另一方面也是将安装依赖的过程代码化了,于是我们就可以像管理源码一样使用git对Dockerfile进行版本管理。

为啥用Docker?

当你的系统越来越复杂的时候,你会发现Docker的价值。

从应用架构角度理解Docker

刚开始,你只需要写一个Node.js程序,挂载一个静态网站;然后,你做了一个用户账号系统,这时需要数据库了,比如说MySQL; 后来,为了提升性能,你引入了Memcached缓存;终于有一天,你决定把前后端分离,这样可以提高开发效率;当用户越来越多,你又不得不使用Nginx做反向代理; 对了,随着功能越来越多,你的应用依赖也会越来越多…总之,你的应用架构只会越来越复杂。不同的组件的安装,配置与运行步骤各不相同,于是你不得不写一个很长的文档给新同事,只为了让他搭建一个开发环境

使用Docker的话,你可以为不同的组件逐一编写Dockerfile,分别构建镜像,然后运行在各个容器中。这样做,将复杂的架构统一了,所有组件的安装和运行步骤统一为几个简单的命令:

  • 构建Docker镜像: docker build
  • 上传Docker镜像: docker push
  • 下载Docker镜像: docker pull
  • 运行Docker容器: docker run
从应用部署角度理解Docker

通常,你会有开发测试生产服务器,对于某些应用,还会需要进行构建。不同步骤的依赖会有一些不同,并且在不同的服务器上执行。如果手动地在不同的服务器上安装依赖,是件很麻烦的事情。比如说,当你需要为Node.js应用添加一个新的npm模块,或者升级一下Node.js,是不是得重复操作很多次?友情提示一下,手动敲命令是极易出错的,有些失误会导致致命的后果(参考最近Gitlab误删数据库与AWS的S3故障)。

如果使用Docker的话,开发构建测试生产将全部在Docker容器中执行,你需要为不同步骤编写不同的Dockerfile。当依赖变化时,仅需要稍微修改Dockerfile即可。结合构建工具Jenkins,就可以将整个部署流程自动化。

另一方面,Dockerfile将Docker镜像描述得非常精准,能够保证很强的一致性。比如,操作系统的版本,Node.js的版本,NPM模块的版本等。这就意味着,在本地开发环境运行成功的镜像,在构建测试生产环境中也没有问题。还有,不同的Docker容器是依赖于不同的Docker镜像,这样他们互不干扰。比如,两个Node.js应用可以分别使用不同版本的Node.js。

从集群管理角度理解Docker

架构规模越来越大的时候,你有必要引入集群了。这就意味着,服务器由1台变成了多台,同一个应用需要运行多个备份来分担负载。当然,你可以手动对集群的功能进行划分: Nginx服务器,Node.js服务器,MySQL服务器,测试服务器,生产服务器…这样做的好处是简单粗暴;也可以说财大气粗,因为资源闲置会非常严重。还有一点,每次新增节点的时候,你就不得不花大量时间进行安装与配置,这其实是一种低效的重复劳动。

下载Docker镜像之后,Docker容器可以运行在集群的任何一个节点。一方面,各个组件可以共享主机,且互不干扰;另一方面,也不需要在集群的节点上安装和配置任何组件。至于整个Docker集群的管理,业界有很多成熟的解决方案,例如MesosKubernetesDocker Swarm。这些集群系统提供了调度服务发现负载均衡等功能,让整个集群变成一个整体。

如何用Docker?

编写Dockerfile

正确的Dockerfile是这样的:

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
# 使用DaoCloud的Ubuntu镜像
FROM daocloud.io/library/ubuntu:14.04

# 设置镜像作者
MAINTAINER Fundebug <help@fundebug.com>

# 设置时区
RUN sudo sh -c "echo 'Asia/Shanghai' > /etc/timezone" && \
sudo dpkg-reconfigure -f noninteractive tzdata

# 使用阿里云的Ubuntu镜像
RUN echo '\n\
deb http://mirrors.aliyun.com/ubuntu/ trusty main restricted universe multiverse\n\
deb http://mirrors.aliyun.com/ubuntu/ trusty-security main restricted universe multiverse\n\
deb http://mirrors.aliyun.com/ubuntu/ trusty-updates main restricted universe multiverse\n\
deb http://mirrors.aliyun.com/ubuntu/ trusty-proposed main restricted universe multiverse\n\
deb http://mirrors.aliyun.com/ubuntu/ trusty-backports main restricted universe multiverse\n\
deb-src http://mirrors.aliyun.com/ubuntu/ trusty main restricted universe multiverse\n\
deb-src http://mirrors.aliyun.com/ubuntu/ trusty-security main restricted universe multiverse\n\
deb-src http://mirrors.aliyun.com/ubuntu/ trusty-updates main restricted universe multiverse\n\
deb-src http://mirrors.aliyun.com/ubuntu/ trusty-proposed main restricted universe multiverse\n\
deb-src http://mirrors.aliyun.com/ubuntu/ trusty-backports main restricted universe multiverse\n'\
> /etc/apt/sources.list

# 安装node v6.10.1
RUN sudo apt-get update && sudo apt-get install -y wget

# 使用淘宝镜像安装Node.js v6.10.1
RUN wget https://npm.taobao.org/mirrors/node/v6.10.1/node-v6.10.1-linux-x64.tar.gz && \
tar -C /usr/local --strip-components 1 -xzf node-v6.10.1-linux-x64.tar.gz && \
rm node-v6.10.1-linux-x64.tar.gz

WORKDIR /app

# 安装npm模块
ADD package.json /app/package.json

# 使用淘宝的npm镜像
RUN npm install --production -d --registry=https://registry.npm.taobao.org

# 添加源代码
ADD . /app

# 运行app.js
CMD ["node", "/app/app.js"]

有几点值得注意的地方:

  • 使用国内DaoCloud的Docker仓库,阿里云的ubuntu镜像以及淘宝的npm镜像,否则会出事情的;
  • 将时区设为Asia/Shanghai,否则日志的时间会不大对劲;
  • 使用.dockerignore忽略不需要添加到Docker镜像的文件和目录,其语法与.gitigore一致;

更重要的一点是,package.json需要单独添加。Docker在构建镜像的时候,是一层一层构建的,仅当这一层有变化时,重新构建对应的层。如果package.json和源代码一起添加到镜像,则每次修改源码都需要重新安装npm模块,这样木有必要。所以,正确的顺序是: 添加package.json;安装npm模块;添加源代码。

构建Docker镜像

使用docker build命令构建Docker镜像

1
sudo docker build -t fundebug/nodejs .

其中,-t选项用于指定镜像的名称。

使用docker images命令查看Docker镜像

1
2
3
4
sudo docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
fundebug/nodejs latest 64530ce811a1 32 minutes ago 266.4 MB
daocloud.io/library/ubuntu 14.04 b969ab9f929b 9 weeks ago 188 MB

可知,fundebug/nodejs镜像的大小为266.4MB,在ubuntu镜像的基础上增加了80MB左右。

运行Docker容器

使用docker run命令运行Docker容器

1
sudo docker run -d --net=host --name=hello-fundebug fundebug/nodejs

其中,-d选项表示容器在后台运行;–net选项指定容器的网络模式,host表示与主机共享网络;–name指定了容器的名称。

使用docker ps命令查看Docker容器

1
2
3
sudo docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e8eb5473970c fundebug/nodejs "node /app/app.js" 37 minutes ago Up 37 minutes hello-

可知,COMMAND为”node /app/app.js”,表示容器中运行的命令。这是我们再Dockerfile中使用CMD指定的。不妨使用docker exec命令在容器内执行ps命令查看容器内的进程

1
2
3
sudo docker exec hello-fundebug ps -f
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 15:14 ? 00:00:00 node /app/app.js

可知,容器内的1号进程即为node进程node /app/app.js。在Linux中,PID为1进程按说是唯一的,即init进程。但是,容器使用了内核的Namespace机制,为容器创建了独立的PID空间,因此容器中也有1号进程。

测试

使用curl命令访问:

1
2
curl localhost:3000
Hello Fundebug!

是否用Docker?

一方面,使用Docker能够带来很大益处;另一方面,引入Docker必然会有很多挑战,需要熟悉Docker才能应对自如。想必这是一个艰难的决定。如果从长远的角度来看,Docker正在成为应用开发,部署,发布的标准技术,也许我们不得不用开放的心态对待它。

原文

https://blog.fundebug.com/2017/03/27/nodejs-docker/