Dawn's Blogs

分享技术 记录成长

0%

Session 和 Cookie

Cookie 是由浏览器维持的,保存在客户端的一段文本信息,伴随着用户请求和页面在 Web 服务器和浏览器之间传递。通过 Cookie,服务器就可以验证 Cookie 信息、记录用户状态。

img

Cookie 是有时间限制的,根据生命周期不同分为会话 Cookie 和持久 Cookie:

  • 会话 Cookie:不设置生命周期的默认值,表示这个 Cookie 的生命周期到浏览器关闭为止,会话 Cookie 一般保存在内存中。
  • 持久 Cookie:设置了过期时间时,浏览器就会将 Cookie 保存在硬盘上,有效期直到超过了过期时间。

Go 语言中,通过 net/http 包中的 SetCookie 来设置:

1
func SetCookie(w ResponseWriter, cookie *Cookie)

Cookie 结构体结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Cookie struct {
Name string
Value string
Path string
Domain string
Expires time.Time
RawExpires string
// MaxAge=0表示未设置Max-Age属性
// MaxAge<0表示立刻删除该cookie,等价于"Max-Age: 0"
// MaxAge>0表示存在Max-Age属性,单位是秒
MaxAge int
Secure bool
HttpOnly bool
Raw string
Unparsed []string // 未解析的“属性-值”对的原始文本
}

设置 Cookie 例子:

1
2
3
4
5
6
7
8
9
10
11
// 设置过期时间
expiration := time.Now()
expiration = expiration.AddDate(1, 0, 0)
// 实例化 Cookie对象
cookie := &http.Cookie{
Name: "username",
Value: "dawn",
Expires: expiration,
}
// 写入 HTTP 响应
http.SetCookie(w, cookie)

Go 语言中,使用 r.Cookie 根据 name 读取 Cookie:

1
func (r *Request) Cookie(name string) (*Cookie, error)

还可以通过 r.Cookies 一次性得到所有的 Cookie 信息:

1
func (r *Request) Cookies() []*Cookie

Session

Session 是一种服务器端的机制,服务器使用一种类似于散列表的结构来保存信息。服务器使用 Session id 来标识 Session 信息,它由服务器端产生,相当于一个密钥。可以借助 Cookie 或者 GET 请求方式传输 Session id。

img


总结

  • Session 和 Cookie 机制都是为了克服 HTTP 协议的无状态缺陷,用于记录用户状态。
  • Session 依托 Cookie 实现,将 Session id 保存在 Cookie 中。
  • Cookie 因为将所有信息都保存在客户端中,发起请求时会携带这些信息,所以有一定的安全隐患。

Go 实现 Session

Session 创建过程

服务器端在创建 Session 时会分为三个步骤:

  • 生成一个全局唯一的标识符 Session id
  • 开辟数据存储空间来存储 Session 信息:
    • 内存:速度快,系统一旦掉电,所有的会话数据就会丢失。
    • 文件或者数据库:增加 I/O 开销,但是可以实现某种程度上的 Session 持久化,以及 Session 的共享。
  • 将 Session id 发送给客户端
    • Cookie:服务端通过设置 Set-cookie 头就可以将 Session id 发送给客户端,而客户端此后的每一次请求都会带上这个 Session id。一般情况下,会将包含 Session id 的 Cookie 的过期时间设置为 0。
    • URL 重写:返回给用户的页面里的所有的 URL 后面追加 Session id,这样用户在收到响应之后,无论点击响应页面里的哪个链接或提交表单,都会自动带上 Session id。如果客户端禁用了 Cookie,这种方案就是首选。

Go 实现 Session 管理

目前 Go 标准包没有为 Session 提供任何支持,下面手动实现 Session 的管理和创建。

Session 管理器

定义一个全局的 Session 管理器,用于 Session 的管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Manager struct {
cookieName string
lock sync.Mutex
provider Provider
maxLifeTime int64
}

func NewManager(provideName, cookieName string, maxLifeTime int64) (*Manager, error) {
provider, ok := provides[provideName]
if !ok {
return nil, fmt.Errorf("session: unknown provide %q (forgotten import?)", provideName)
}

return &Manager{
cookieName: cookieName,
provider: provider,
maxLifeTime: maxLifeTime,
}, nil
}

Session 是保存在服务器端的数据,它可以以任何的方式存储,比如存储在内存、数据库或者文件中。因此我们抽象出一个 Provider 接口,用以表征 Session 管理器底层存储结构

  • SessionInit 实现 Session 的初始化。
  • SessionRead 返回 sid 所代表的 Session 变量,如果不存在,那么将以 sid 为参数调用 SessionInit 函数创建并返回一个新的 Session 变量。
  • SessionDestroy 销毁 sid 对应的 Session 变量。
  • SessionGC 根据 maxLifeTime 来删除过期的数据。
1
2
3
4
5
6
7
8
9
10
type Provider interface {
// SessionInit 实现 Session 的初始化
SessionInit(sid string) (Session, error)
// SessionRead 返回 sid 所代表的 Session 变量,如果不存在,那么将以 sid 为参数调用 SessionInit 函数创建并返回一个新的 Session 变量
SessionRead(sid string) (Session, error)
// SessionDestroy 销毁 sid 对应的 Session 变量
SessionDestroy(sid string) error
// SessionGC 根据 maxLifeTime 来删除过期的数据
SessionGC(maxLifeTime int64)
}

对 Session 的处理基本上就是设置值、读取值、删除值、获取当前 Session id 四种操作,所以 Session 接口定义如下:

1
2
3
4
5
6
type Session interface {
Set(key, value interface{}) error // set session value
Get(key interface{}) interface{} // get session value
Delete(key interface{}) error // delete session value
SessionID() string // back current sessionID
}

全局唯一的 Session id

Session id 用来标识每一个用户以及对应的 Session,所以必须是全局唯一的:

1
2
3
4
5
6
7
8
9
// 创建全局唯一的 session id
func (manager *Manager) sessionId() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return ""
}

return base64.URLEncoding.EncodeToString(b)
}

Session 的创建

SessionStart 用来检测是否已经有某个 Session 与当前来访用户发生了关联,如果没有则创建之

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SessionStart 用来检测是否已经有某个 Session 与当前来访用户发生了关联,如果没有则创建之
func (manager *Manager) SessionStart(w http.ResponseWriter, r *http.Request) (session Session) {
manager.lock.Lock()
defer manager.lock.Unlock()
cookie, err := r.Cookie(manager.cookieName)
if err != nil || cookie.Value == "" {
// 没有 Session,创建
sid := manager.sessionId()
session, _ = manager.provider.SessionInit(sid)
cookie := &http.Cookie{
Name: manager.cookieName,
Value: url.QueryEscape(sid),
Path: "/",
HttpOnly: true,
MaxAge: int(manager.maxLifeTime),
}
http.SetCookie(w, cookie)
} else {
// 发生了关联
sid, _ := url.QueryUnescape(cookie.Value)
session, _ = manager.provider.SessionRead(sid)
}
return
}

Session 销毁

在销毁 Session 时,除了从存储结构中删除 Session 信息之外,还需要设置 Cookie过期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (manager *Manager) SessionDestroy(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(manager.cookieName)
if err != nil || cookie.Value == "" {
return
}
// 上锁
manager.lock.Lock()
defer manager.lock.Unlock()
// 从存储结构中删除
manager.provider.SessionDestroy(cookie.Value)
// 设置 Cookie
expiration := time.Now()
cookie = &http.Cookie{
Name: manager.cookieName,
Path: "/",
HttpOnly: true,
Expires: expiration,
MaxAge: -1, // 立即删除session
}
http.SetCookie(w, cookie)
}

过期 Session 清理

GC 利用了 time 包的定时器功能,当超时之后调用 GC 清理过期数据。

1
2
3
4
5
6
7
8
9
// GC 删除过期数据
func (manager *Manager) GC() {
manager.lock.Lock()
defer manager.lock.Unlock()
manager.provider.SessionGC(manager.maxLifeTime)
time.AfterFunc(time.Duration(manager.maxLifeTime), func() {
manager.GC()
})
}

Go 实现 Session 存储

在上一节中,定义了 Session 管理器 session.Manager,以及两个接口 session.Providersession.Session,这两个接口用于存储 Session 数据。本节将实现一个基于内存的 Session 存储方式。

Provider 接口实现

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

import (
"container/list"
"go-web-demo/ch6/session"
"sync"
"time"
)

type Provider struct {
lock sync.Mutex // 用来锁
sessions map[string]*list.Element // 用来存储在内存
list *list.List // 用来做 gc,按照 SessionStore.timeAccessed 最近访问时间排序
}

// Provider 实现 session.Session 接口

// SessionInit 实现 Session 的初始化
func (p *Provider) SessionInit(sid string) (session.Session, error) {
p.lock.Lock()
defer p.lock.Unlock()
v := make(map[interface{}]interface{}, 0)
newSess := &SessionStore{sid: sid, timeAccessed: time.Now(), value: v}
element := p.list.PushFront(newSess)
p.sessions[sid] = element
return newSess, nil
}

// SessionRead 返回 sid 所代表的 Session 变量,如果不存在,那么将以 sid 为参数调用 SessionInit 函数创建并返回一个新的 Session 变量
func (p *Provider) SessionRead(sid string) (session.Session, error) {
if element, ok := p.sessions[sid]; ok {
// 已存在 Session
return element.Value.(*SessionStore), nil
}
// 未存在,新建Session
sess, err := p.SessionInit(sid)
return sess, err
}

// SessionDestroy 销毁 sid 对应的 Session 变量
func (p *Provider) SessionDestroy(sid string) error {
if element, ok := p.sessions[sid]; ok {
// Session 已存在
// 从字典中删除
delete(p.sessions, sid)
// 从链表中删除
p.list.Remove(element)
return nil
}

return nil
}

// SessionGC 根据 maxLifeTime 来删除过期的数据
func (p *Provider) SessionGC(maxLifeTime int64) {
p.lock.Lock()
defer p.lock.Unlock()

for {
element := p.list.Back()
if element.Value.(*SessionStore).timeAccessed.Unix()+maxLifeTime < time.Now().Unix() {
// 超时,清理 Session
// 从字典中删除
delete(p.sessions, element.Value.(*SessionStore).sid)
// 从链表中删除
p.list.Remove(element)
}
}
}

// SessionUpdate 用于更新最近访问时间
func (p *Provider) SessionUpdate(sid string) error {
p.lock.Lock()
defer p.lock.Unlock()

if element, ok := p.sessions[sid]; ok {
element.Value.(*SessionStore).timeAccessed = time.Now()
p.list.MoveToFront(element)
return nil
}
return nil
}

Session 接口实现

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

import (
"container/list"
"time"
)

type SessionStore struct {
sid string // session id
timeAccessed time.Time // 最后访问的时间
value map[interface{}]interface{} // session 存储的值
}

var (
p = &Provider{list: list.New()}
)

// Set 设置值
func (st *SessionStore) Set(key, value interface{}) error {
st.value[key] = value
return p.SessionUpdate(st.sid)
}

// Get 获取值
func (st *SessionStore) Get(key interface{}) interface{} {
p.SessionUpdate(st.sid)
if v, ok := st.value[key]; ok {
return v
}
return nil
}

// Delete 删除值
func (st *SessionStore) Delete(key interface{}) error {
delete(st.value, key)
return p.SessionUpdate(st.sid)
}

// SessionID 获取 sid
func (st *SessionStore) SessionID() string {
return st.sid
}

初始化以及注册 Provider

初始化以及在 session.Manager注册该基于内存的 Provider。

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

import (
"container/list"
"go-web-demo/ch6/session"
)

func init() {
p.sessions = make(map[string]*list.Element, 0)
session.Register("memory", p)
}

调用

当 import 的时候已经执行了 memory 函数里面的 init 函数,这样就已经注册到 session 管理器中,我们就可以使用了:

1
2
3
4
5
6
7
8
9
10
11
12
import (
"go-web-demo/ch6/session"
_ "go-web-demo/ch6/session/providers/memory"
)

var globalSessions *session.Manager

func init() {
globalSessions, _ = session.NewManager("memory", "gosessionid", 3600)
// 开一个线程启动 GC
go globalSessions.GC()
}

防范 Session 劫持

httponly 和 token

第一种方案通过 httponly + token 来预防 Session 劫持:

  • 可以设置 Session id 的值只能由 Cookie 进行设置,同时设置 httponly 为 true,这个属性可以防止通过客户端脚本访问到 Cookie,进而防止攻击者读取到 Cookie 中的 Session id。
  • 在每个请求里面加上 token,然后每一次验证 token,从而保证用户的请求都是唯一性。

间隔生成新的 Sesseion id

第二种方案是给 Session 额外设置一个创建时间的值,一旦过了一定的时间,就销毁这个 Session id,重新生成 Session。

1
2
3
4
5
6
7
8
9
10
11
// 设置过期时间 60s
expires := 60

createTime := sess.Get("createtime")
if createTime == nil {
sess.Set("createtime", time.Now().Unix())
} else if createTime.(int64) + expires < time.Now().Unix() {
// 当前 Session 过期,重新分配 Session
globalSessions.SessionDestroy(w, r)
sess = globalSessions.SessionStart(w, r)
}

这里为 Session 设置了一个值,用于记录生成 Session id 的时间。每次请求都判断是否过期,如果过期,则分配新的 Session id。

总结

上面两个手段的组合可以在实践中消除 session 劫持的风险:

  • Session id 的频繁改变,使得攻击者很难获取到有效的 Session id。

  • Session 只在 Cookie 中传递,并且设置了 httponly 选项,可以有效阻止通过客户端脚本访问到 Cookie,也可以预防 XSS 攻击获取 Cookie。

  • 还可以设置 MaxAge=0,这样 Cookie 就不会存储在浏览器的记录中,随着浏览器关闭 Cookie 也随机消失。

表单

处理表单输入

编写 login.gtpl 文件:

1
2
3
4
5
6
7
8
9
10
11
12
<html>
<head>
<title></title>
</head>
<body>
<form action="/login" method="post">
用户名:<input type="text" name="username">
密码:<input type="password" name="password">
<input type="submit" value="登录">
</form>
</body>
</html>

处理登录的逻辑,其中 r.ParseForm() 方法用于解析参数

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

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

func login(w http.ResponseWriter, r *http.Request) {
fmt.Println("method:", r.Method) // 获取请求的方法
if r.Method == http.MethodGet {
// 如果是 GET 请求
t, _ := template.ParseFiles("./login.gtpl")
log.Println(t.Execute(w, nil))
} else {
// 如果是 POST 请求
err := r.ParseForm()
if err != nil {
log.Fatal("ParseForm error:", err)
}
// 请求的是登录数据,那么执行登录的逻辑判断
fmt.Println("username:", r.Form["username"])
fmt.Println("password:", r.Form["password"])
}

}

func main() {
http.HandleFunc("/login", login) // 设置访问的路由
err := http.ListenAndServe(":9000", nil) // 设置监听端口
if err != nil {
log.Fatal("ListenAndServe error:", err)
}
}

r.Form 里面包含了所有请求的参数,比如 URL 中 query-stringPOST 的数据PUT 的数据,所以当你在 URL 中的 query-string 字段和 POST 冲突时,会保存成一个 slice,里面存储了多个值。

r.From 是一个 url.Values 类型的值,可以对其进行一些操作( Get、Set、Add、Del、Has ):

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
// Values maps a string key to a list of values.
// It is typically used for query parameters and form values.
// Unlike in the http.Header map, the keys in a Values map
// are case-sensitive.
type Values map[string][]string

// Get gets the first value associated with the given key.
// If there are no values associated with the key, Get returns
// the empty string. To access multiple values, use the map
// directly.
func (v Values) Get(key string) string {
if v == nil {
return ""
}
vs := v[key]
if len(vs) == 0 {
return ""
}
return vs[0]
}

// Set sets the key to value. It replaces any existing
// values.
func (v Values) Set(key, value string) {
v[key] = []string{value}
}

// Add adds the value to key. It appends to any existing
// values associated with key.
func (v Values) Add(key, value string) {
v[key] = append(v[key], value)
}

// Del deletes the values associated with key.
func (v Values) Del(key string) {
delete(v, key)
}

// Has checks whether a given key is set.
func (v Values) Has(key string) bool {
_, ok := v[key]
return ok
}

注意:

在处理表单的逻辑事,不要忘了对表单的输入的验证,不能信任用户输入的任何信息。


预防跨站脚本

对 XSS 的防护主要在于两方面:

  • 输入的验证,检测攻击。
  • 对所有输出数据进行适当的处理,以防止任何已成功注入的脚本在浏览器端运行。

对于对输出数据的处理,Go 的 html/template 里面带有下面几个函数可以转义

  • func HTMLEscape (w io.Writer, b [] byte):把 b 进行转义之后写到 w 中。
  • func HTMLEscapeString (s string) string:转义 s 之后返回结果字符串。
  • func HTMLEscaper (args ...interface {}) string:支持多个参数一起转义,返回结果字符串

如果需要输出 HTML 标签,可以使用 template.HTML 类型,它用于封装一个已知安全的 HTML 文档片段。它不应被第三方使用,也不能用于含有未闭合的标签或注释的 HTML 文本。

1
2
3
4
5
6
import "html/template"

...

t, err := template.New("foo").Parse(`{{define "T"}}Hello, {{.}}!{{end}}`)
err = t.ExecuteTemplate(out, "T", template.HTML("<script>alert('you have been pwned')</script>"))

防止多次提交表单

解决方案是在表单中添加一个带有唯一值的隐藏字段

1
<input type="hidden" name="token" value="{{.}}">

在验证表单时,先检查带有该唯一值( 如 MD5(时间戳) )的表单是否已经递交过了:

  • 如果是,拒绝再次递交。
  • 如果不是,则处理表单进行逻辑处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func login(w http.ResponseWriter, r *http.Request) {
fmt.Println("method:", r.Method) // 获取请求的方法
if r.Method == "GET" {
crutime := time.Now().Unix()
// 通过 md5(时间戳) 构造token
h := md5.New()
io.WriteString(h, strconv.FormatInt(crutime, 10))
token := fmt.Sprintf("%x", h.Sum(nil))

t, _ := template.ParseFiles("login.gtpl")
t.Execute(w, token)
} else {
// 请求的是登录数据,那么执行登录的逻辑判断
r.ParseForm()
token := r.Form.Get("token")
if token != "" {
// 验证 token 的合法性
} else {
// 不存在 token 报错
}
fmt.Println("username length:", len(r.Form["username"][0]))
fmt.Println("username:", template.HTMLEscapeString(r.Form.Get("username"))) // 输出到服务器端
fmt.Println("password:", template.HTMLEscapeString(r.Form.Get("password")))
template.HTMLEscape(w, []byte(r.Form.Get("username"))) // 输出到客户端
}
}

处理文件上传

form 的 enctype 属性

form 表单的 enctype 属性指明了发送到服务器时时浏览器使用的编码类型,有三种取值:

  • application/x-www-form-urlencoded默认编码类型,在发送前编码所有的字符。
  • multipart/form-data :不对字符进行编码,指定传输的数据为二进制类型,所以可以用于文件上传。
  • text/plain :空格转换为 “+” 加号,但不对特殊字符编码。

文件上传

创建名为 upload.gtpl 的模板文件用于文件上传:

1
2
3
4
5
6
7
8
9
10
11
12
<html>
<head>
<title>上传文件</title>
</head>
<body>
<form enctype="multipart/form-data" action="/upload" method="post">
<input type="file" name="uploadfile" />
<input type="hidden" name="token" value="{{.}}"/>
<input type="submit" value="upload" />
</form>
</body>
</html>

编写服务器文件:

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

import (
"crypto/md5"
"fmt"
"html/template"
"io"
"net/http"
"os"
"strconv"
"time"
)

func upload(w http.ResponseWriter, r *http.Request) {
fmt.Println("method:", r.Method) // 获取请求的方法
if r.Method == http.MethodGet {
// 若是 GET 请求
curTime := time.Now().Unix() // 获取当前时间的时间戳
h := md5.New()
_, err := io.WriteString(h, strconv.FormatInt(curTime, 10)) // 将时间戳进行 md5 加密
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
token := fmt.Sprintf("%x", h.Sum(nil))

// 渲染页面
t, _ := template.ParseFiles("upload.gtpl")
t.Execute(w, token)
} else {
// POST 请求
r.ParseMultipartForm(32 << 20)
file, handler, err := r.FormFile("uploadfile")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
fmt.Fprintf(w, "%v", handler.Header)
f, err := os.OpenFile("./test/"+handler.Filename, os.O_WRONLY|os.O_CREATE, 0666) // 此处假设当前目录下已存在test目录
if err != nil {
fmt.Println(err)
return
}
defer f.Close()
io.Copy(f, file)
}
}

func main() {
http.HandleFunc("/upload", upload)
http.ListenAndServe(":9000", nil)
}

处理文件上传需要调用 r.ParseMultipartForm( maxMemory ) 函数,其中 maxMemroy 表示文件存储在内存中的大小,如果文件大小超过了该值,则剩余的部分将存储在系统的临时文件中。使用 r.FormFile 函数可以获取文件句柄以及文件属性。

其中,文件 handler 的类型为 *multipart.FileHeader,结构体如下:

1
2
3
4
5
6
7
8
9
// A FileHeader describes a file part of a multipart request.
type FileHeader struct {
Filename string
Header textproto.MIMEHeader
Size int64
// 非导出字段
content []byte
tmpfile string
}

访问数据库

database/sql 接口

Go 定义了了一些标准的接口,开发者可以根据定义的接口来开发相应的数据库驱动。

sql.Register

1
func Register(name string, driver driver.Driver)

Register 注册并命名一个数据库,可以在 Open 函数中使用该命名启用该驱动。

mymysql、sqlite3的驱动通过 init() 函数注册自己的数据库驱动名称以及相应的 driver 实现:

1
2
3
4
5
6
7
8
9
10
11
12
// https://github.com/mattn/go-sqlite3 驱动
func init() {
sql.Register("sqlite3", &SQLiteDriver{})
}

// https://github.com/mikespook/mymysql 驱动
// Driver automatically registered in database/sql
var d = Driver{proto: "tcp", raddr: "127.0.0.1:3306"}
func init() {
Register("SET NAMES utf8")
sql.Register("mymysql", &d)
}

而在 database/sql 内部,通过一个 map 来存储用户定义的驱动,所以可以通过注册函数 Register 同时注册多个数据库驱动:

1
var drivers = make(map[string]driver.Driver)

sql.DB

DB 中 freeConn 是一个简易的连接池,它的实现相当的简单:

  • 当执行 db.prepare -> db.prepareDC 的时候会 defer dc.releaseConn
  • 调用db.putConn,也就是把这个连接放入连接池。
  • 每次调用 db.conn 的时候会先判断 freeConn 的长度是否大于 0,大于 0 说明有可以复用的 conn,直接拿出来用就是了,如果不大于 0,则创建一个 conn,然后再返回之。
1
2
3
4
5
6
7
type DB struct {
driver driver.Driver
dsn string
mu sync.Mutex // protects freeConn and closed
freeConn []driver.Conn
closed bool
}

driver.Driver

Driver 是一个数据库驱动接口,它定义了一个方法 Open,这个方法返回一个数据库 Conn 接口:

1
2
3
4
5
6
7
8
9
type Driver interface {
// Open返回一个新的与数据库的连接,参数name的格式是驱动特定的。
//
// Open可能返回一个缓存的连接(之前关闭的连接),但这么做是不必要的;
// sql包会维护闲置连接池以便有效的重用连接。
//
// 返回的连接同一时间只会被一个go程使用。
Open(name string) (Conn, error)
}

注意,返回的 Conn 连接同一时间只能被一个 goroutine 使用。

driver.Conn

Conn 是一个数据库连接的接口定义,他定义了一系列方法,这个 Conn 只能应用在一个 goroutine 里面,不能使用在多个 goroutine 里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Conn interface {
// Prepare返回一个准备好的、绑定到该连接的状态。
Prepare(query string) (Stmt, error)

// Close作废并停止任何现在准备好的状态和事务,将该连接标注为不再使用。
//
// 因为sql包维护着一个连接池,只有当闲置连接过剩时才会调用Close方法,
// 驱动的实现中不需要添加自己的连接缓存池。
Close() error

// Begin开始并返回一个新的事务。
Begin() (Tx, error)
}
  • Prepare 函数返回与当前连接相关的执行 SQL 语句的准备状态,可以进行查询、删除等操作。
  • Close 函数关闭当前的连接,执行释放连接拥有的资源等清理工作。因为驱动实现了 database/sql 里面建议的 conn pool,所以你不用再去实现缓存 conn 之类的,这样会容易引起问题。
  • Begin 函数返回一个代表事务处理的 Tx,通过它你可以进行查询,更新等操作,或者对事务进行回滚、递交。

driver.Stmt

Stmt 是一种准备好的状态,和 Conn 相关联( Stmt会绑定到一个连接 ),而且只能应用于一个 goroutine 中,不能应用于多个 goroutine:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Stmt interface {
// Close关闭Stmt。
//
// 和Go1.1一样,如果Stmt被任何查询使用中的话,将不会被关闭。
Close() error

// NumInput返回占位参数的个数。
//
// 如果NumInput返回值 >= 0,sql包会提前检查调用者提供的参数个数,
// 并且会在调用Exec或Query方法前返回数目不对的错误。
//
// NumInput可以返回-1,如果驱动占位参数的数量。
// 此时sql包不会提前检查参数个数。
NumInput() int

// Exec执行查询,而不会返回结果,如insert或update。
Exec(args []Value) (Result, error)

// Query执行查询并返回结果,如select。
Query(args []Value) (Rows, error)
}
  • Close 函数关闭当前的链接状态,但是如果当前正在执行 query,query 还是有效返回 rows 数据。
  • NumInput 函数返回当前预留参数的个数,当返回 >=0 时数据库驱动就会智能检查调用者的参数。当数据库驱动包不知道预留参数的时候,返回 -1。
  • Exec 函数执行 Prepare 准备好的 SQL,传入参数执行 update/insert 等操作,返回 Result 数据。
  • Query 函数执行 Prepare 准备好的 SQL,传入需要的参数执行 select 操作,返回 Rows 结果集。

driver.Tx

事务处理一般就两个过程,递交或者回滚。数据库驱动里面也只需要实现这两个函数就可以。Tx 代表一次事务

1
2
3
4
type Tx interface {
Commit() error
Rollback() error
}

driver.Execer

这是一个 Conn 可选择实现的接口:

1
2
3
type Execer interface {
Exec(query string, args []Value) (Result, error)
}

如果这个接口没有定义,那么在调用 DB.Exec,就会首先调用 Prepare 返回 Stmt,然后执行 StmtExec,然后关闭 Stmt

driver.Result

这个是执行 Update/Insert 等操作返回的结果接口定义:

1
2
3
4
5
6
7
type Result interface {
// LastInsertId返回insert等命令后数据库自动生成的ID
LastInsertId() (int64, error)

// RowsAffected返回被查询影响的行数
RowsAffected() (int64, error)
}
  • LastInsertId 函数返回由数据库执行插入操作得到的自增 ID 号

  • RowsAffected 函数返回 query 操作影响的数据条目数。

driver.Rows

Rows 是执行查询得到的结果的迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Rows interface {
// Columns返回各列的名称,列的数量可以从切片长度确定。
// 如果某个列的名称未知,对应的条目应为空字符串。
Columns() []string

// Close关闭Rows。
Close() error

// 调用Next方法以将下一行数据填充进提供的切片中。
// 提供的切片必须和Columns返回的切片长度相同。
//
// 切片dest可能被填充同一种驱动Value类型,但字符串除外。
// 所有string值都必须转换为[]byte。
//
// 当没有更多行时,Next应返回io.EOF。
Next(dest []Value) error
}
  • Columns 函数返回查询数据库表的字段信息,这个返回的 slice 和 sql 查询的字段一一对应,而不是返回整个表的所有字段。
  • Close 函数用来关闭 Rows 迭代器。
  • Next 函数用来返回下一条数据,把数据赋值给 dest。dest 里面的元素必须是 driver.Value 的值除了 string,返回的数据里面所有的 string 都必须要转换成 [] byte。如果最后没数据了,Next 函数最后返回 io.EOF。

driver.RowsAffected

RowsAffected 其实就是一个 int64 的别名,但是他实现了 Result 接口,用于 insertupdate 操作,这些操作会修改零到多行数据。:

1
2
3
4
5
type RowsAffected int64

func (RowsAffected) LastInsertId() (int64, error)

func (v RowsAffected) RowsAffected() (int64, error)

driver.Value

1
type Value interface{}

Value 是驱动必须能处理的值。它要么是nil,要么是如下类型的实例:

1
2
3
4
5
6
int64
float64
bool
[]byte
string [*] Rows.Next不会返回该类型值
time.Time

driver.ValueConverter

ValueConverter 接口定义了如何把一个普通的值转化成 driver.Value 的接口:

1
2
3
type ValueConverter interface {
ConvertValue(v interface{}) (Value, error)
}

在开发的数据库驱动包里面实现这个接口的函数在很多地方会使用到,这个 ValueConverter 有很多好处:

  • 转化 driver.value 到数据库表相应的字段,例如 int64 的数据如何转化成数据库表 uint16 字段。
  • 把数据库查询结果转化成 driver.Value 值。
  • 在 scan 函数里面如何把 driver.Value 值转化成用户定义的值。

driver.Valuer

Valuer 接口定义了返回一个 driver.Value 的方式,很多类型都实现了这个 Value 方法,用来自身与 driver.Value 的转化。

1
2
3
type Valuer interface {
Value() (Value, error)
}

MySQL数据库

github.com/go-sql-driver/mysql 为支持 MySQL 的驱动,它支持 database/sql 标准。

打开驱动

1
2
3
4
5
6
7
// 打开驱动
dsn := "user:password@tcp(ip:port)/dbname?charset=utf8"
db, err := sql.Open("mysql", dsn)
if err != nil {
panic(err)
}
defer db.Close()

插入数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 插入数据
stmt, err := db.Prepare("INSERT userinfo SET username=?,department=?,created=?")
if err != nil {
log.Fatal(err)
}

res, err := stmt.Exec("dawn", "研发部门", "2022-05-15")
if err != nil {
log.Fatal(err)
}

id, err := res.LastInsertId()
if err != nil {
log.Fatal(err)
}
fmt.Println(id)

更新数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 更新数据
stmt, err = db.Prepare("update userinfo set username=? where uid=?")
if err != nil {
log.Fatal(err)
}

res, err = stmt.Exec("zh", id)
if err != nil {
log.Fatal(err)
}

affect, err := res.RowsAffected()
if err != nil {
log.Fatal(err)
}
fmt.Println(affect)

查询数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 查询数据
rows, err := db.Query("SELECT * FROM userinfo")
if err != nil {
log.Fatal(err)
}

for rows.Next() {
var uid int
var username string
var department string
var created string
err = rows.Scan(&uid, &username, &department, &created)
if err != nil {
log.Fatal(err)
}
// ...
}

删除数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 删除数据
stmt, err = db.Prepare("delete from userinfo where uid=?")
if err != nil {
log.Fatal(err)
}

res, err = stmt.Exec(id)
if err != nil {
log.Fatal(err)
}

affect, err = res.RowsAffected()
if err != nil {
log.Fatal(err)
}

fmt.Println(affect)

Web 基础

Go 搭建一个 Web 服务器

Go 搭建一个 Web 服务器

使用 http 包可以轻松的搭建一个 Web 服务器:

  • http.HandleFunc 函数:用于绑定路由。
  • http.ListenAndServe 函数:设置监听的端口。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

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

func sayHelloName(w http.ResponseWriter, r *http.Request) {
r.ParseForm() // 解析参数
fmt.Println(r.Form) // 这些信息是输出到服务器端的打印信息
fmt.Println("path:", r.URL.Path)
fmt.Println("scheme:", r.URL.Scheme)

for k, v := range r.Form {
fmt.Println("key:", k)
fmt.Println("val:", strings.Join(v, ""))
}

fmt.Fprintf(w, "Hello astaxie!") // 这个写入到 w 的是输出到客户端的
}

func main() {
http.HandleFunc("/", sayHelloName) // 设置路由
err := http.ListenAndServe(":9000", nil) // 设置监听端口
if err != nil {
log.Fatal(err)
}
}

Go 中 Web 的工作方式

以下是服务器端的几个基本概念:

  • Request :用户的请求信息。
  • Response :服务器需要反馈给客户端的信息。
  • Conn :用户每次请求的链接。
  • Handler :处理请求和生成返回信息的处理逻辑。

http 包运行机制

Go 实现 Web 服务的工作模式如下:

  1. 创建 Listen Socket,监听指定的端口,等待客户端请求到来。
  2. Listen Socket 接收客户端的请求,得到 Client Socket,通过Client Socket 与客户端进行通信。
  3. 处理客户端的请求,交给 Handler进行处理,Handler 处理好数据之后通过 Client Socket 返回给客户端。

img

这整个的过程里面我们只要了解清楚下面三个问题,也就知道 Go 是如何让 Web 运行起来了:

  • 如何监听端口?

通过 http.ListenAndServe 函数,可以监听端口并分配handler,底层实现如下:初始化一个 server 对象,然后调用了 net.Listen("tcp", addr),也就是底层用 TCP 协议搭建了一个服务,然后监控我们设置的端口。

  • 如何接收客户端请求?

以下来自 http 包的源码,可以看到整个 http 的处理过程。

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 (srv *Server) Serve(l net.Listener) error {
defer l.Close()
var tempDelay time.Duration // how long to sleep on accept failure
for {
rw, e := l.Accept()
if e != nil {
if ne, ok := e.(net.Error); ok && ne.Temporary() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
log.Printf("http: Accept error: %v; retrying in %v", e, tempDelay)
time.Sleep(tempDelay)
continue
}
return e
}
tempDelay = 0
c, err := srv.newConn(rw)
if err != nil {
continue
}
go c.serve()
}
}

在监听完端口过后,调用了 srv.Serve(net.Listener) 方法,这个方法就是处理客户端的请求信息。在 Accept 之后,创建一个 Conn,然后开启一个协程来单独处理来自客户端的 http 请求:go c.serve()

  • 如何分配 handler?

Conn 首先会解析 request:c.readRequest(),然后获取相应的 handler:handler := c.server.Handerc.server.Handler 即为 http.ListenAndServe 的第二个参数;若为,则默认获取 handler = DefaultServeMux

DefaultServeMux 实际上是一个路由器,它用来匹配 url 跳转到其相应的 handle 函数,通过 http.HandleFunc 函数设置。DefaultServeMux 会调用 ServeHTTP方法,这个方法内部调用了 http.HandleFunc 函数定义的 handler。

和http包img

http 包

Go 的 http 有两个核心功能:Conn、ServeMux

Conn 的 goroutine

在 http 包中,当与客户端建立 TCP 连接之后,会建立一个协程来单独处理这一次用户的请求:

1
2
3
4
5
c, err := srv.newConn(rw)
if err != nil {
continue
}
go c.serve()

ServeMux

c.server.Handler时,则默认获取 handler = DefaultServeMux。这个 DefaullServeMux 是一个路由器,结构如下:

1
2
3
4
5
type ServeMux struct {
mu sync.RWMutex // 锁,由于请求涉及到并发处理,因此这里需要一个锁机制
m map[string]muxEntry // 路由规则,一个 string 对应一个 mux 实体,这里的 string 就是注册的路由表达式
hosts bool // 是否在任意的规则中带有 host 信息
}

muxEntry 的结构如下:

1
2
3
4
5
type muxEntry struct {
explicit bool // 是否精确匹配
h Handler // 这个路由表达式对应哪个 handler
pattern string // 匹配字符串
}

Handler 是一个接口:

1
2
3
type Handler interface {
ServeHTTP(ResponseWriter, *Request) // 路由实现器
}

但是用户自定义的 Handler 并没有实现 ServeHTTP 方法,所以在 http 包里面还定义了一个类型 HandlerFunc,这个类型实现了 ServeHTTP 方法,也就是 Handler 接口。调用了 http.HandlerFunc 函数之后,我们自定义的 Handler 会被强制类型转换为 HandleFunc 类型,这样就有了 ServeHTTP方法:

1
2
3
4
5
6
type HandlerFunc func(ResponseWriter, *Request)

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

路由器里面存储好了相应的路由规则之后,那么具体的请求又是怎么分发的呢?请看下面的代码,默认的路由器实现了 ServeHTTP

1
2
3
4
5
6
7
8
9
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
if r.RequestURI == "*" {
w.Header().Set("Connection", "close")
w.WriteHeader(StatusBadRequest)
return
}
h, _ := mux.Handler(r)
h.ServeHTTP(w, r)
}

在路由器收到请求之后,如果是 *,则关闭连接。否则调用 mux.Handler(r) 返回应设置路由的处理 Handler, 然后执行 h.ServeHTTP(w, r)

mux.Handler(r) 函数实现如下,它根据用户请求的 URL 和路由器里面存储的 map 去匹配的,当匹配到之后返回存储的 handler,调用这个 handler 的 ServeHTTP 接口就可以执行到相应的函数了。:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) {
if r.Method != "CONNECT" {
if p := cleanPath(r.URL.Path); p != r.URL.Path {
_, pattern = mux.handler(r.Host, p)
return RedirectHandler(p, StatusMovedPermanently), pattern
}
}
return mux.handler(r.Host, r.URL.Path)
}

func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
mux.mu.RLock()
defer mux.mu.RUnlock()

// Host-specific pattern takes precedence over generic ones
if mux.hosts {
h, pattern = mux.match(host + path)
}
if h == nil {
h, pattern = mux.match(path)
}
if h == nil {
h, pattern = NotFoundHandler(), ""
}
return
}

自定义路由器

ListenAndServe 函数的第二个参数可以用来配置外部路由器,它是一个 Handler 接口,即即外部路由器只要实现了 Handler 接口就可以。

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

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

type myMux struct {
}

func (p myMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
sayHello(w, r)
return
}

http.NotFound(w, r)
return
}

func sayHello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, I am Dawn.")
}

func main() {
mux := myMux{}
err := http.ListenAndServe(":9000", mux)
if err != nil {
log.Fatal(err)
}
}

总结

Go语言中 Web 的工作流程如下:

  • 首先调用 http.HandleFunc 函数:

    1. 调用 DefaultServeMux 的HandleFunc
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // HandleFunc registers the handler function for the given pattern
    // in the DefaultServeMux.
    // The documentation for ServeMux explains how patterns are matched.
    func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    DefaultServeMux.HandleFunc(pattern, handler)
    }

    // HandleFunc registers the handler function for the given pattern.
    func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    if handler == nil {
    panic("http: nil handler")
    }
    mux.Handle(pattern, HandlerFunc(handler))
    }
    1. 调用了 DefaultServeMux 的 Handle
    2. 往 DefaultServeMux 的 map[string]muxEntry增加对应条目
    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
    // Handle registers the handler for the given pattern.
    // If a handler already exists for pattern, Handle panics.
    func (mux *ServeMux) Handle(pattern string, handler Handler) {
    mux.mu.Lock()
    defer mux.mu.Unlock()

    if pattern == "" {
    panic("http: invalid pattern")
    }
    if handler == nil {
    panic("http: nil handler")
    }
    if _, exist := mux.m[pattern]; exist {
    panic("http: multiple registrations for " + pattern)
    }

    if mux.m == nil {
    mux.m = make(map[string]muxEntry)
    }
    e := muxEntry{h: handler, pattern: pattern}
    mux.m[pattern] = e
    if pattern[len(pattern)-1] == '/' {
    mux.es = appendSorted(mux.es, e)
    }

    if pattern[0] != '/' {
    mux.hosts = true
    }
    }
  • 其次调用 http.ListenAndServe 函数:

    1. 实例化 Server
    2. 调用 Server 的 ListenAndServe
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // ListenAndServe listens on the TCP network address addr and then calls
    // Serve with handler to handle requests on incoming connections.
    // Accepted connections are configured to enable TCP keep-alives.
    //
    // The handler is typically nil, in which case the DefaultServeMux is used.
    //
    // ListenAndServe always returns a non-nil error.
    func ListenAndServe(addr string, handler Handler) error {
    server := &Server{Addr: addr, Handler: handler}
    return server.ListenAndServe()
    }
    1. 调用 net.Listen(“tcp”, addr) 监听端口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // ListenAndServe listens on the TCP network address srv.Addr and then
    // calls Serve to handle requests on incoming connections.
    // Accepted connections are configured to enable TCP keep-alives.
    //
    // If srv.Addr is blank, ":http" is used.
    //
    // ListenAndServe always returns a non-nil error. After Shutdown or Close,
    // the returned error is ErrServerClosed.
    func (srv *Server) ListenAndServe() error {
    if srv.shuttingDown() {
    return ErrServerClosed
    }
    addr := srv.Addr
    if addr == "" {
    addr = ":http"
    }
    ln, err := net.Listen("tcp", addr)
    if err != nil {
    return err
    }
    return srv.Serve(ln)
    }
    1. 启动一个 for 循环,在循环体中 Accept 请求。对于每一个请求实例化一个 Conn,请开启一个 goroutine 运行服务 go c.serve()
    2. 读取每一个请求的内容 w, err := c.readRequest()
    3. 判断 handler 是否为空,如果为空则将 handler 设置为 DefaultServeMux
    4. 调用 handler 的 ServeHTTP,即 DefaultServeMux.ServeHTTP
    5. 根据请求选择 handler,并进入这个handler 的 ServeHTTPmux.handler(r).ServeHTTP(w, r)

Compose

Compose 项目负责实现对 Docker 容器集群的快速编排,用于快速部署分布式应用。

Compose 定位是 「定义和运行多个 Docker 容器的应用(Defining and running multi-container Docker applications)」

介绍

Compose 允许用户通过一个单独的 docker-compose.yml 模板文件(YAML 格式)来定义一组相关联的应用容器为一个项目(project)。 Compose 中有两个重要概念:

  • 服务 (service):一个应用的容器,实际上可以包括若干运行相同镜像的容器实例。
  • 项目 (project):由一组关联的应用容器组成的一个完整业务单元,在 docker-compose.yml 文件中定义。

一个项目可以由多个服务(容器)关联而成,Compose 面向项目进行管理。

Machine

Docker Machine 是 Docker 官方编排(Orchestration)项目之一,负责在多种平台上快速安装 Docker 环境。

Swarm mode

Swarm 提供 Docker 容器集群服务,使用它,用户可以将多个 Docker 主机封装为单个大型的虚拟 Docker 主机,快速打造一套容器云平台。

基本概念

节点

集群中的节点分为管理(manager)节点和工作(worker)节点:

  • 管理节点:用于集群的管理,一个 swarm 集群中可以有多个管理节点,但是只能有一个 leader 节点,leader 节点由 raft 算法选举出。docker swarm 命令基本只能在管理节点执行(除了 docker swarm leave 命令可以在工作节点执行)。
  • 工作节点:用于执行任务,管理节点将服务 (service) 下发至工作节点执行。管理节点也默认为工作节点。

img

服务和任务

  • 任务(Task)是 swarm中最小的调度单位,目前来说就是一个单一的容器。
  • 服务(Services)是指一组任务的集合,服务定义了任务的属性。服务有两种模式,通过 docker service create--mode 参数指定:
    • replicated services:按照一定规则在各个工作节点上运行指定个数的任务。
    • global services:每个工作节点上运行一个任务。

下图是容器、服务、任务的关系:

img

数据管理

在容器中管理数据主要有两种方式:

  • 数据卷(Volumes)
  • 挂载主机目录(Bind mounts)

数据卷

数据卷 是一个可供一个或多个容器使用的特殊目录,它绕过 UFS,可以提供很多有用的特性:

  • 数据卷 可以在容器之间共享和重用
  • 数据卷 的修改会立马生效
  • 数据卷 的更新,不会影响镜像
  • 数据卷 默认会一直存在,即使容器被删除

数据卷的操作

  • docker volume create 命令:创建数据卷。
1
2
C:\Users\63544>docker volume create my-vol
my-vol
  • docker volume ls 命令:查看所有的数据卷。
1
2
3
C:\Users\63544>docker volume ls
DRIVER VOLUME NAME
local my-vol
  • docker volume inspect 命令:查看指定数据卷。
1
2
3
4
5
6
7
8
9
10
11
12
C:\Users\63544>docker volume inspect my-vol
[
{
"CreatedAt": "2022-05-12T13:46:43Z",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/my-vol/_data",
"Name": "my-vol",
"Options": {},
"Scope": "local"
}
]
  • 在用 docker run 命令的时候,使用 --mount 标记来将 数据卷 挂载到容器里。在一次 docker run 中可以挂载多个 数据卷

  • 在用 docker run 命令的时候,使用 --mount 标记来将 数据卷 挂载到容器里。在一次 docker run 中可以挂载多个 数据卷

1
2
3
--mount source=数据卷名,target=容器目录
# 等价于
-v 数据卷名:容器目录
  • docker volume rm 命令:删除数据卷。docker volume prune 命令:删除所有无主的数据卷。如果需要在删除容器的同时移除数据卷。可以在删除容器的时候使用 docker rm -v 这个命令。

挂载主机目录

  • 使用 --mount 标记可以指定挂载一个本地主机的目录到容器中去。
1
2
3
--mount type=bind,source=主机目录,target=挂载点
# 等价于
-v 宿主机绝对路径目录:容器内挂载点

也可以挂载一个主机文件到容器中:

1
--mount type=bind,source=$HOME/.bash_history,target=/root/.bash_history

这样就可以记录在容器输入过的命令了。

docker 目录主机目录/数据卷出现 cannot open directory. Permission denied 错误,解决方法:

加上 –privileged=true 参数。在SELinux里面挂载目录被禁止了,如果要开启,我们一般使用–privileged=true命令,扩大容器的权限解决挂载目录没有权限的问题。即使用该参数,container内的root拥有真正的root权限;否则,container内的root只是外部的一个普通用户权限。

读写权限设置:

  • 读写(read write,rw):容器可以对主机目录/数据卷进行读写操作,默认。

    1
    -v /宿主机绝对路径目录或者数据卷名:/容器内目录:rw
  • 只读(read only,ro):容器实例内部被限制,只能读取不能写。

    1
    -v /宿主机绝对路径目录或者数据卷名:/容器内目录:ro

网络

当 Docker 启动时,会自动在主机上创建一个 docker0 虚拟网桥。同时,Docker 随机分配一个本地未占用的私有网段中的一个地址给 docker0 接口。此后启动的容器内的网口也会自动分配一个同一网段的地址。通过这种方式,主机可以跟容器通信,容器之间也可以相互通信。Docker 就创建了在主机和所有容器之间一个虚拟共享网络:

Docker 网络

介绍

作用

Docker 网络的作用如下:

  • 容器间的互联通信端口映射

  • 把多个容器放在同一个 Docker 网桥下,可以通过服务名(容器名)直接网络通信,而不使用 IP 地址。

外部访问容器

容器中可以运行一些网络应用,要让外部也可以访问这些应用,可以通过 -P-p 参数来指定端口映射:

  • 当使用 -P 标记时,Docker 会随机映射一个 49000~49900 的端口到内部容器开放的网络端口。
  • -p 则可以指定要映射的端口。

容器互联

  • docker network create -d bridge my-net 命令:创建网络。
  • docker run 运行容器时,可以使用 --network 加入到一个网络中。

多个容器可以加入到同一个网络中,实现容器之间的互联。

常用命令

docker 网络的常用命令如下:

  • 新建网络(网桥):docker network 网络名字

  • 查看网络:docker network ls

  • 查看网络信息:docker network inspect 网络名字

  • 删除网络:docker network rm 网络名字

网络模式

网络模式有四种,分别是:

  • bridge 模式
  • host 模式
  • none 模式
  • container 模式

bridge

使用 –network bridge 指定,默认使用 docker0。docker run 的时候,没有指定 network 的话默认使用的网桥模式就是 bridge,使用的就是 docker0。

Docker 在宿主机虚拟一个 Docker 容器网桥(默认 docker0),Docker 启动一个容器时会根据 Docker 网桥的网段分配给容器一个 IP 地址,称为 Container-IP,同时 Docker 网桥是每个容器的默认网关。因为在同一宿主机内的容器都接入同一个网桥,这样容器之间就能够通过容器的 Container-IP 直接通信

网桥创建一对对等虚拟设备接口一个叫 veth(网桥端),另一个叫 eth0(容器端),成对匹配。

image-20221228163656973

host

使用–network host 指定,该模式直接使用宿主机的 IP 地址与外界进行通信,不再需要额外进行NAT 转换。

容器将不会获得一个独立的 Network Namespace, 而是和宿主机共用一个 Network Namespace。容器将不会虚拟出自己的网卡而是使用宿主机的IP和端口。

image-20221228164308411

注意:

在使用 host 模式时,无法使用 -p 进行端口映射(会发生 warning),端口映射不起作用。

none

使用–network none 指定,在 none 模式下,并不为 Docker 容器进行任何网络配置

也就是禁用网络功能,只有lo标识(127.0.0.1,表示本地回环地址)。

container

使用 –network container:NAME或者容器ID 指定

新建的容器和已经存在的一个容器共享一个网络 ip 配置而不是和宿主机共享。新创建的容器不会创建自己的网卡,配置自己的IP,而是和一个指定的容器共享IP、端口范围等。同样,两个容器除了网络方面,其他的如文件系统、进程列表等还是隔离的。

image-20221228164705206

操作容器

列出容器

使用 docker ps [OPTIONS] 命令列出容器:

OPTIONS说明(常用):
-a:列出当前所有正在运行的容器+历史上运行过的容器。
-l:显示最近创建的容器。
-n:显示最近n个创建的容器。
-q:静默模式,只显示容器编号。

启动

启动容器有两种方式:

  • 一种是基于镜像新建一个容器并启动 docker run

    1
    docker run [OPTIONS] IMAGE [COMMAND] [ARG...]

    OPTIONS 说明:

    • –name=”容器新名字”: 为容器指定一个名称;

    • -d:后台运行容器并返回容器ID,也即启动守护式容器;

    • -i:以交互模式运行容器,通常与 -t 同时使用;

    • -t:为容器重新分配一个伪输入终端,通常与 -i 同时使用;

    • -P:随机端口映射;

    • -p:指定端口映射;

    image-20221223100302221

    启动交互式容器:

    1
    docker run -it ubuntu /bin/bash

    使用镜像ubuntu:latest以交互模式启动一个容器,在容器内执行 /bin/bash 命令。

  • 另外一个是将在终止状态(stopped)的容器重新启动 docker start

  • 重启容器:docker restart 容器名/容器ID

后台运行

可以通过 -d 参数实现容器的后台运行:

1
2
C:\Users\63544>docker run -d ubuntu:18.04 /bin/sh -c "while true; do echo hello world; sleep 1; done"
905b8470023fe399cdc97f579cfef7def7450cf62874685ca64e7c7d223b4e78

此时容器会在后台运行并不会把输出的结果打印到宿主机的STDOUT上面(输出结果可以用 docker logs 查看):

1
2
3
4
5
6
7
8
C:\Users\63544>docker container logs 905b
hello world
hello world
hello world
hello world
hello world
hello world
hello world

使用 -d 参数启动后会返回一个唯一的 id,也可以通过 docker container ls 命令来查看容器信息(docker ps 命令也可以查看容器信息):

1
2
3
C:\Users\63544>docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
905b8470023f ubuntu:18.04 "/bin/sh -c 'while t…" About a minute ago Up About a minute stupefied_pike

终止容器

可以使用 docker stop 来终止一个运行中的容器。

此外,当 Docker 容器中指定的应用终结时,容器也自动终止。

进入容器

在使用 -d 参数时,容器会在后台运行。那么可以使用 attach 或者 exec 命令进入容器:

  • docker attach 命令:如果从这个 stdin 中 exit,会导致容器的停止。

  • docker exec 命令:如果从这个 stdin 中 exit,不会导致容器的停止。推荐使用 exec 命令

    • -i 参数:只是打开容器的 STDIN,此时没有 Linux 命令提示符。
    • -it 参数:可以看到 Linux 命令提示符。

导入和导出容器

  • docker export 命令:可以导出容器。
1
docker export 7691a814370e > ubuntu.tar
  • docker import 命令:从容器快照文件中再导入为镜像。

删除容器

  • 可以使用 docker rm 来删除一个处于终止状态的容器。
    • 如果要删除一个运行中的容器,可以添加 -f 参数。
  • docker container prune 清除所有处于终止状态的容器。

重要操作

启动守护式容器

在大部分的场景下,我们希望 docker 的服务是在后台运行的,我们可以过 -d 指定容器的后台运行模式。

1
docker run -d 容器名

启动前台交互式容器

1
2
3
$ docker run -it 容器名 bashShell
UID PID PPID C STIME TTY TIME CMD
root 5007 4987 0 02:54 ? 00:00:00 bash

查看容器日志

1
docker logs 容器ID

查看容器内运行的进程

1
docker top 容器ID

查看容器内部细节

1
docker inspect 容器ID

进入正在运行的容器并以命令行交互

可以用两个命令:

  • docker exec -it 容器ID bashShell
  • docker attach 容器ID

两种命令的区别:

  • attach 直接进入容器启动命令的终端,不会启动新的进程。用 exit 退出,会导致容器的停止。
  • exec 是在容器中打开新的终端,并且可以启动新的进程。用 exit 退出,不会导致容器的停止

推荐使用 docker exec 命令,因为退出容器终端,不会导致容器的停止。

从容器内拷贝文件到主机上

1
docker cp 容器ID:容器内路径 目的主机路径

导入和导出容器

  • export:导出容器的内容留作为一个tar归档文件。

    1
    docker export 容器ID > 文件名.tar
  • import:从tar包中的内容创建一个新的文件系统再导入为镜像。

    1
    cat 文件名.tar | docker import - 镜像名:镜像版本号

访问仓库

Docker Hub

Docker 官方维护了一个公共仓库 Docker Hub:

  • docker search 命令可以查找官方仓库中的镜像。

  • docker pull 命令下载镜像到本地。

  • 在登录后,可以使用 docker push username/镜像名:标签 将自己的镜像推送到 Docker Hub 中。


若不想使用官方的 Docker Hub 仓库,也可以创建私有仓库。

使用镜像

搜索镜像

使用docker search命令可以搜索镜像:

1
2
3
4
5
6
7
8
$ docker search redis
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
redis Redis is an open source key-value store that… 11669 [OK]
bitnami/redis Bitnami Redis Docker Image 236 [OK]
redislabs/redisinsight RedisInsight - The GUI for Redis 75
redislabs/redisearch Redis With the RedisSearch module pre-loaded… 56
redislabs/rejson RedisJSON - Enhanced JSON data type processi… 51
redislabs/redis Clustered in-memory database engine compatib… 36

获取镜像

从 Docker 镜像仓库获取镜像的命令是 docker pull。其命令格式为:

1
docker pull [选项] [Docker Registry 地址[:端口号]/]仓库名[:标签]
  • Docker 镜像仓库地址:地址的格式一般是 <域名/IP>[:端口号]。默认地址是 Docker Hub。
  • 仓库名:如之前所说,这里的仓库名是两段式名称,即 <用户名>/<软件名>。对于 Docker Hub,如果不给出用户名,则默认为 library,也就是官方镜像。

使用 docker run 命令可以运行容器。

阅读全文 »

Docker介绍

什么是Docker

Docker是基于Go语言开发并实现的,基于Linux内核的cgroups(control groups,控制群组,是Linux内核的一个功能,用来限制、控制与分离一个进程组的资源,如CPU、内存等)、namespace(是Linux内核用来隔离内核资源的方式,通过namespace可以让一些进程只能看到与自己相关的一部分资源,而另外一些进程也只能看到与它们自己相关的资源)以及Union FS等技术,对进程进行封装隔离,属于操作系统层面的虚拟化技术

Docker和传统虚拟化技术的不同:

传统虚拟机技术是虚拟出一套硬件后,在其上运行一个完整操作系统,在该系统上再运行所需应用进程:

传统虚拟化

Docker容器内的应用进程直接运行于宿主的内核,容器内没有自己的内核,而且也没有进行硬件虚拟。因此容器要比传统虚拟机更为轻便:

Docker

为什么使用Docker

Docker与传统虚拟化技术相比,有如下优点

  • 更高效的利用系统资源:Docker不需要对硬件虚拟化以及运行完整的操作系统等额外开销,所以Docker对系统资源的利用更有效。在相同配置下,Docker可以运行更多的应用程序。
  • 更快速的启动时间:Docker容器运行于宿主内核,启动速度可以达到秒级。
  • 一致的运行环境:Docker镜像提供了除内核外完整的运行时环境,确保了应用运行环境一致性。
  • 持续交付和部署:一次创建或配置,可以在任意地方正常运行。
  • 更轻松的迁移:Docker确保了执行环境的一致性,使得应用的迁移更加容易。
  • 更轻松的维护和扩展:Docker使用的分层存储以及镜像的技术,使得应用重复部分的复用更为容易,也使得应用的维护更新更加简单,基于基础镜像进一步扩展镜像也变得非常简单。
特性 Docker容器 传统虚拟机
启动 秒级 分钟级
硬盘使用 一般为 MB 一般为 GB
性能 接近原生 弱于
系统支持量 单机支持上千个容器 一般几十个

基本概念

Docker包括三个基本概念:

  • 镜像(Image)
  • 容器(Container)
  • 仓库(Repository)

镜像

对于 Linux 而言,内核启动后,会挂载 root 文件系统为其提供用户空间支持。而 Docker 镜像(Image),就相当于是一个 root 文件系统。

Docker 镜像是一个特殊的文件系统,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。镜像不包含任何动态数据,其内容在构建之后也不会被改变。

分层存储

Docker设计时,就充分利用了Union FS(联合文件系统)技术,将其设计为分层存储的结构。

镜像只是一个虚拟的概念,其实际体现并非由一个文件组成,而是由一组(多层)文件系统组成。

镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除。在最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像。因此,在构建镜像的时候,需要额外小心,每一层尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉。

分层存储使得镜像的复用、定制更加容易,可以用之前构建好的镜像作为基础层,然后进一步添加新的层,以定制自己所需的内容,构建新的镜像。

docker镜像加载原理

docker的镜像实际上由一层一层的文件系统组成。

  • bootfs(boot file system)主要包含bootloader和kernel。bootloader主要是引导加载kernel,Linux刚启动时会加载bootfs文件系统,在Docker镜像的最底层是引导文件系统bootfs。这一层与典型的Linux/Unix系统是一样的,包含boot加载器和内核。当boot加载完成之后整个内核就都在内存中了,此时内存的使用权已由bootfs转交给内核,此时系统也会卸载bootfs。

  • rootfs(root file system),在bootfs之上。包含的就是典型 Linux 系统中的/dev、/proc、/bin、/etc等标准目录和文件。rootfs就是各种不同的操作系统发行版,如Ubuntu、CentOS等。

image-20221223111944166

对于一个精简的OS,rootfs可以很小,只需要包括最基本的命令、工具和程序库就可以了,因为底层直接用Host的kernel,自己只需要提供 rootfs 就行了。由此可见对于不同的linux发行版, bootfs基本是一致的, rootfs会有差别, 因此不同的发行版可以公用bootfs。

容器

镜像和容器的关系,就像是类和实例的关系。镜像是静态的定义,容器是镜像运行时的实体。容器可以被创建、启动、停止、删除、暂停等。

容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的命名空间。因此容器可以拥有自己的 root 文件系统、自己的网络配置、自己的进程空间,甚至自己的用户 ID 空间。容器内的经常运行在一个隔离的环境里,因此也更加的安全。

容器存储层

容器也使用分层存储,每一个容器运行时,以镜像为基础层,在其上创建一个当前容器的存储层用于容器运行时的读写,称为容器存储层。只有容器层是可写的,容器层下面的所有镜像层都是只读的

image-20221223112458661

容器存储层的生存周期和容器一样,容器消亡时,容器存储层也随之消亡。因此,任何保存于容器存储层的信息都会随容器删除而丢失。因此所有的文件写入操作,都应该使用数据卷(Volume),数据卷的生存周期独立于容器,容器消亡,数据卷不会消亡。因此,使用数据卷后,容器删除或者重新运行之后,数据却不会丢失。

仓库

Docker Registry用于提供集中的存储、分发镜像的服务。

一个Docker Registry中可以包含多个仓库(Repository),每个仓库包含多个标签(Tag,标签用于指明镜像的版本),每个标签对应一个镜像

可以通过<仓库名>:<标签>指定哪个版本的镜像,默认标签为latest

整数反转

整数反转

解题思路

通过模10的方法取出最低位数字,通过除以10去掉最低为数字。

解题代码

1
2
3
4
5
6
7
8
9
10
11
12
13
func reverse(x int) int {
var res int
for x != 0 {
res = res*10 + x%10
x = x/10
}

if res > math.MaxInt32 || res < math.MinInt32 {
return 0
}

return res
}
阅读全文 »

聚类

非监督学习

在非监督学习中,我们的数据没有附带任何标签,我们拿到的数据如下图。在这里我们有一系列点,却没有标签。

img

也就是说,在非监督学习中,我们需要将一系列无标签的训练数据,输入到一个算法中,然后我们告诉这个算法,快去为我们找找这个数据的内在结构给定数据。我们可能需要某种算法帮助我们寻找一种结构。图上的数据看起来可以分成两个分开的点集(称为簇),一个能够找到这些点集的算法,就被称为聚类算法。

当然,非监督算法不只是用来分簇,也可以为我们找到其他类型的结构或者其他的一些模式。

阅读全文 »