Dawn's Blogs

分享技术 记录成长

0%

Go Web编程学习笔记 (2) 表单和访问数据库

表单

处理表单输入

编写 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)