处理表单 - Go Web 开发实战笔记

2,520 阅读19分钟

介绍

表单是编写 Web 应用常用的工具,通过表单我们可以方便的让客户端和服务器进行数据的交互。表单是一个包含表单元素的区域。表单元素是允许用户在表单中(比如:文本域、下拉列表、 单选框、复选框等等)输入信息的元素。表单使用表单标签(<form>)定义。

获取 HTTP Get 请求字段的值

以下是获取 http get 请求 url 问号后的参数的示例:
GetInfo.go

package main

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

/*
处理表单:获取 HTTP Get 请求字段值
*/

// GetInfoHandler 函数实现了处理器的签名,所以这是一个处理器函数
func GetInfoHandler(w http.ResponseWriter,r *http.Request)  {
	r.ParseForm()   // get请求就解析url传递的参数,POST则解析响应包的主体
	fmt.Println(r.Form)
	names,ok := r.Form["name"]
	if ok == true {
    	fmt.Println(names[0])
    	fmt.Println(r.Form["age"][0])
	}

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

func main()  {
	// 注册路由和路由函数,将url规则与处理器函数绑定做一个map映射存起来,并且会实现ServeHTTP方法,使处理器函数变成Handler函数
	http.HandleFunc("/",GetInfoHandler)

	fmt.Println("服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/a.html?name=Bill&age=12")

	// 启动 HTTP 服务,并监听端口号,开始监听,处理请求,返回响应
	err := http.ListenAndServe(":8900", nil)
	if err != nil {
		log.Fatal("ListenAndServe",err)
	}
}

执行以上程序后,在浏览器前后输入 http://localhost:8900/a.html?name=Bill&age=12 后,在控制台输出:

服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/a.html?name=Bill&age=12
map[name:[Bill] age:[12]]
Bill
12
key: name
[Bill]
val: Bill
key: age
[12]
val: 12

浏览器访问的网页显示:Hello Get

用表单提交用户登录信息

以下是 Web 表单登录的例子:
创建一个 login.tpl 文件,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
<form action="/login" method="post">
    <p>用户名:<input type="text" name="username" /></p>
    <p>密  码:<input type="password" name="password" /></p>
    <p><input type="submit" value="登录" /></p>
</form>
</body>
</html>

以上表单代码实现输入用户名和密码点击登录后,客户端就会以 post 请求方式提交表单数据到服务器地址 http://localhost:8900/login

页面预览:

服务端文件 PostRequest.go,代码如下:

package main

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

/*
用表单提交用户登录信息(POST 请求)
*/

func LoginHandler(w http.ResponseWriter, r *http.Request)  {
	r.ParseForm() // 解析url传递的参数,对于POST则解析响应包的主体(request body)
	fmt.Println(r.Form) // 输出到服务器端的打印信息
	fmt.Println("method:", r.Method) // 获取请求的方法
	if r.Method == "GET" {
		// 显示静态登录页面
		t, _ := template.ParseFiles("/Users/play/goweb/src/form/login.tpl")
		t.Execute(w, nil)
	} else {
		// 请求的是登录数据,那么执行登录的逻辑判断
		username := r.Form["username"][0]
		password := r.Form["password"][0]
		if username == "Bill" && password == "123456" {
			fmt.Fprintf(w,"登录成功")
		} else {
			fmt.Fprintf(w, "登录失败")
		}
	}
}

func main()  {
	// 注册路由和路由函数,将url规则与处理器函数绑定做一个map映射存起来,并且会实现ServeHTTP方法,使处理器函数变成Handler函数
	http.HandleFunc("/login",LoginHandler)

	fmt.Println("服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/login")

	// 启动 HTTP 服务,并监听端口号,开始监听,处理请求,返回响应
	err := http.ListenAndServe(":8900", nil)
	if err != nil {
		log.Fatal("ListenAndServe",err)
	}
}

以上服务器代码实现用户输入用户名:Bill、密码:123456 时登录成功,否则登录失败。

执行以上程序后,在浏览器表单页面输入用户名:Bill、密码:123456 点击登录后,页面提示登录成功;如果输入其它,如用户名:aa、密码:1234 则跳转页面后提示登录失败。
执行完以上操作后,服务器控制台输出:

服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/login
map[]
method: GET
map[username:[Bill] password:[123456]]
method: POST
map[]
method: GET
map[username:[aa] password:[1234]]
method: POST

验证表单的输入

开发 Web 的一个原则就是,不能信任用户输入的任何信息,验证和过滤用户的输入信息非常必要。平常编写 Web 应用主要有两方面的数据验证,一个是在页面端的 js 验证,一个是在服务器端的验证。

以下示例实现的是服务器端的表单元素验:

创建一个 form.tpl 文件,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>验证表单</title>
    <style type="text/css">
        body {
            padding: 10px 60px;
        }
        dl,dt,dd {
            padding: 0;
            margin: 0;
        }
        dl {
            display: block;
            clear: both;
            overflow: auto;
            margin-bottom: 10px;
        }
        dt,dd {
            float: left;
            color: #333333;
            font-size: 14px;
        }
        dt {
            width: 88px;
        }
        dd input[type="text"] {
            width: 200px;
        }
        p input {
            width: 100px;
            margin-left: 100px;
        }
        i {
            font-size: 12px;
            color: #cccccc;
        }
    </style>
</head>
<body>
<form action="/verifyForm" method="post">
    <dl>
        <dt>用户名:</dt>
        <dd>
            <input type="text" name="username" />
            <i>必填字段</i>
        </dd>
    </dl>

    <dl>
        <dt>年龄:</dt>
        <dd>
            <input type="text" name="age" />
            <i>数字</i>
        </dd>
    </dl>

    <dl>
        <dt>真实姓名:</dt>
        <dd>
            <input type="text" name="realname" />
            <i>中文</i>
        </dd>
    </dl>

    <dl>
        <dt>英文名:</dt>
        <dd>
            <input type="text" name="engname" />
            <i>英文</i>
        </dd>
    </dl>

    <dl>
        <dt>邮箱地址:</dt>
        <dd>
            <input type="text" name="email" />
            <i>电子邮件地址</i>
        </dd>
    </dl>

    <dl>
        <dt>手机号码:</dt>
        <dd>
            <input type="text" name="mobile" />
            <i>手机号码</i>
        </dd>
    </dl>

    <dl>
        <dt>水果:</dt>
        <dd>
            <select name="fruit">
                <option value="apple">apple</option>
                <option value="pear">pear</option>
                <option value="banane">banane</option>
            </select>
            <i>下拉菜单</i>
        </dd>
    </dl>

    <dl>
        <dt>性别:</dt>
        <dd>
            <label><input type="radio" name="gender" value="1">男</label>
            <label><input type="radio" name="gender" value="2">女</label>
            <i>单选按钮</i>
        </dd>
    </dl>

    <dl>
        <dt>爱好:</dt>
        <dd>
            <label><input type="checkbox" name="interest" value="football">足球</label>
                <label><input type="checkbox" name="interest" value="basketball">篮球</label>
                    <label><input type="checkbox" name="interest" value="tennis">网球</label>
            <i>复选框</i>
        </dd>
    </dl>

    <dl>
        <dt>身份证号:</dt>
        <dd>
            <input type="text" name="usercard" />
            <i>身份证号码</i>
        </dd>
    </dl>
    <p><input type="submit" value="提交表单"></p>
    </form>
</body>
</html>

以上表单代码实现输入各项点击提交表单后,客户端就会以 post 请求方式提交表单数据到服务器地址 http://localhost:8900/verifyForm
页面预览:

服务端文件 VerifyForm.go,代码如下:

package main

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

func VerifyFormHandler(w http.ResponseWriter, r *http.Request)  {
	r.ParseForm()	// 分析客户端的body数据

	fmt.Println(r.Form)	// 输出到服务器端的打印信息

	fmt.Println("method:", r.Method) // 获取请求的方法
	if r.Method == "GET" {
		// 显示静态页面
		t, _ := template.ParseFiles("/Users/play/goweb/src/form/form.tpl")
		t.Execute(w, nil)
	} else {

		// 必填字段
		if len(r.Form["username"][0])==0{
			// 为空的处理
			fmt.Fprintf(w,"用户名不能为空")
			return
		}

		// 数字
		if m, _ := regexp.MatchString("^[0-9]+$", r.Form.Get("age")); !m {
			fmt.Fprintf(w,"年龄必须是正数")
			return
		}

		// 中文
		if m, _ := regexp.MatchString("^\\p{Han}+$", r.Form.Get("realname")); !m {
			fmt.Fprintf(w,"真实姓名必须是中文")
			return
		}

		// 英文
		if m, _ := regexp.MatchString("^[a-zA-Z]+$", r.Form.Get("engname")); !m {
			fmt.Fprintf(w,"英文名必须是英文")
			return
		}

		// 电子邮件地址
		if m, _ := regexp.MatchString(`^([\w\.\_]{2,10})@(\w{1,}).([a-z]{2,4})$`, r.Form.Get("email")); !m {
			fmt.Fprintf(w,"邮箱无效")
			return
		}

		// 手机号码
		if m, _ := regexp.MatchString(`^(1[3|4|5|8][0-9]\d{4,8})$`, r.Form.Get("mobile")); !m {
			fmt.Fprintf(w,"手机号无效")
			return
		}

		// 下拉菜单
		slice := []string{"apple","pear","banane"}
		hasfruit := false
		for _, v := range slice {
			if v == r.Form.Get("fruit") {
				hasfruit = true
			}
		}
		if !hasfruit {
			fmt.Fprintf(w,"下拉菜单选项不存在")
			return
		}

		// 单选按钮
		slice2:=[]int{1,2}
		hasgender := false
		for _, v2 := range slice2 {
			getint,_:=strconv.Atoi(r.Form.Get("gender"))
			if v2 == getint {
				hasgender = true
			}
		}
		if !hasgender {
			fmt.Fprintf(w,"性别选项有误")
			return
		}

		// 复选框
		slice3 := []string{"football","basketball","tennis"}
		hasinterest := false
		for _, v3 := range slice3 {
			if v3 == r.Form.Get("interest") {
				hasinterest = true
			}
		}
		if !hasinterest {
			fmt.Fprintf(w,"爱好选项有误")
			return
		}

		// 身份证号码
		isusercard := false
		usercard := r.Form.Get("usercard")

		// 验证15位身份证,15位的是全部数字
		m1,_ := regexp.MatchString(`^(\d{15})$`, usercard)

		// 验证18位身份证,18位前17位为数字,最后一位是校验位,可能为数字或字符X。
		m2,_ := regexp.MatchString(`^(\d{17})([0-9]|X)$`, usercard)

		if m1 || m2 {
			isusercard = true
		}

		if (!isusercard) {
			fmt.Fprintf(w,"身份证号有误")
			return
		}

		fmt.Fprintf(w,"表单验证通过")

	}
}

func main()  {
	// 注册路由和路由函数,将url规则与处理器函数绑定做一个map映射存起来,并且会实现ServeHTTP方法,使处理器函数变成Handler函数
	http.HandleFunc("/verifyForm",VerifyFormHandler)

	fmt.Println("服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/verifyForm")

	// 启动 HTTP 服务,并监听端口号,开始监听,处理请求,返回响应
	err := http.ListenAndServe(":8900", nil)
	if err != nil {
		log.Fatal("ListenAndServe",err)
	}
}

执行以上程序后,在浏览器输入 http://localhost:8900/verifyForm 访问验证表单页面,服务器控制台输出:

服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/verifyForm
map[]
method: GET

以上服务器端的表单元素验证,如果全部验证通过,则页面提示表单验证通过,否则页面会提示相关验证不通过项。

总结:

  • 必填字段
    通过内置函数 len 来获取数据的长度来验证是否为必填字段,例如:

    if len(r.Form["username"][0])==0{
        //为空的处理
    }
    

    r.Form对不同类型的表单元素的留空有不同的处理, 对于空文本框、空文本区域以及文件上传,元素的值为空值,而如果是未选中的复选框和单选按钮,则根本不会在r.Form中产生相应条目,如果我们用上面例子中的方式去获取数据时程序就会报错。所以我们需要通过r.Form.Get()来获取值,因为如果字段不存在,通过该方式获取的是空值。但是通过r.Form.Get()只能获取单个的值,如果是map的值,必须通过上面的方式来获取。

  • 数字
    判断一个表单输入框中输入的是否是正整数,需要先转化成 int 类型,然后进行处理:

    getint,err:=strconv.Atoi(r.Form.Get("age"))
    if err!=nil{
        //数字转化出错了,那么可能就不是数字
    }
    
    还有一种方式就是正则匹配的方式
    if m, _ := regexp.MatchString("^[0-9]+$", r.Form.Get("age")); !m {
        return false
    }
    
  • 中文
    验证是否是中文,可以使用 unicode 包提供的 func Is(rangeTab *RangeTable, r rune) bool 来验证,也可以使用正则方式来验证,这里使用最简单的正则方式,如下代码所示:

    if m, _ := regexp.MatchString("^\\p{Han}+$", r.Form.Get("realname")); !m {
        return false
    }
    
  • 英文
    验证是否是英文,可以很简单的通过正则验证数据:

    if m, _ := regexp.MatchString("^[a-zA-Z]+$", r.Form.Get("engname")); !m {
        return false
    }
    
  • 电子邮件地址
    验证 Email 地址是否正确,通过如下方式:

    if m, _ := regexp.MatchString(`^([\w\.\_]{2,10})@(\w{1,}).([a-z]{2,4})$`, r.Form.Get("email")); !m {
        fmt.Println("no")
    }else{
        fmt.Println("yes")
    }
    
  • 手机号码
    通过正则方式验证手机号码是否正确:

    if m, _ := regexp.MatchString(`^(1[3|4|5|8][0-9]\d{4,8})$`, r.Form.Get("mobile")); !m {
        return false
    }
    
  • 下拉菜单
    如果想要判断表单里面元素生成的下拉菜单中是否有被选中的项目。有些时候黑客可能会伪造这个下拉菜单不存在的值发送给你,那么如何判断这个值是否是我们预设的值呢?
    select 可能是这样的一些元素

    <select name="fruit">
        <option value="apple">apple</option>
        <option value="pear">pear</option>
        <option value="banane">banane</option>
    </select>
    

    那么可以这样来验证

    slice:=[]string{"apple","pear","banane"}
    
    for _, v := range slice {
        if v == r.Form.Get("fruit") {
            return true
        }
    }
    return false
    
  • 单选按钮
    如果想要判断 radio 按钮获取的值是我们预设的值,而不是黑客可能会伪造的额外的值。
    radio 可能是这样的一些元素

    <input type="radio" name="gender" value="1">男
    <input type="radio" name="gender" value="2">女
    

    那么可以这样来验证

    slice:=[]int{1,2}
    
    for _, v := range slice {
        if v == r.Form.Get("gender") {
            return true
        }
    }
    return false
    
  • 复选框
    有一项选择兴趣的复选框,你想确定用户选中的和你提供给用户选择的是同一个类型的数据。

    <input type="checkbox" name="interest" value="football">足球
    <input type="checkbox" name="interest" value="basketball">篮球
    <input type="checkbox" name="interest" value="tennis">网球
    

    对于复选框我们的验证和单选有点不一样,因为接收到的数据是一个slice

    slice:=[]string{"football","basketball","tennis"}
    a:=Slice_diff(r.Form["interest"],slice)
    if a == nil{
        return true
    }
    
    return false
    

    上面这个函数 Slice_diff 包含在我开源的一个库里面(操作slice和map的库),github.com/astaxie/bee…

  • 身份证号码
    如果想验证表单输入的是否是身份证,通过正则也可以方便的验证,但是身份证有15位和18位,两个都需要验证

    //验证15位身份证,15位的是全部数字
    if m, _ := regexp.MatchString(`^(\d{15})$`, r.Form.Get("usercard")); !m {
        return false
    }
    
    //验证18位身份证,18位前17位为数字,最后一位是校验位,可能为数字或字符X。
    if m, _ := regexp.MatchString(`^(\d{17})([0-9]|X)$`, r.Form.Get("usercard")); !m {
        return false
    }
    

防止跨站脚本

动态站点会受到一种名为“跨站脚本攻击”(Cross Site Scripting, 安全专家们通常将其缩写成 XSS)的威胁,而静态站点则完全不受其影响。

攻击者通常会在有漏洞的程序中插入 JavaScript、VBScript、 ActiveX 或 Flash 以欺骗用户。一旦得手,他们可以盗取用户帐户信息,修改用户设置,盗取/污染 cookie 和植入恶意广告等。

对 XSS 最佳的防护应该结合以下两种方法:一是验证所有输入数据,有效检测攻击;另一个是对所有输出数据进行适当的处理,以防止任何已成功注入的脚本在浏览器端运行。

以下示例实现的是服务器端对所有输出数据进行适当的处理:

创建一个 xss.tpl 文件,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>预防跨站脚本</title>
    <style type="text/css">
        body {
            padding: 10px 20px;
        }
        dl,dt,dd {
            padding: 0;
            margin: 0;
        }
        dt,dd {
            color: #333333;
            font-size: 14px;
        }
        p input {
            width: 100px;
        }

    </style>
</head>
<body>
<form action="/xss" method="post">
    <dl>
        <dt>正文:</dt>
        <dd>
            <textarea style="width:400px; height:200px" name="code"></textarea>
        </dd>
    </dl>
    <p><input type="submit" value="提交"></p>
    </form>
</body>
</html>

以上表单代码实现输入各项点击提交后,客户端就会以post请求方式提交表单数据到服务器地址 http://localhost:8900/xss
页面预览:

服务端文件 XssRequest.go,代码如下:

package main

import (
	"fmt"
	"log"
	"net/http"
	"regexp"
	"text/template"
)

func XssHandler(w http.ResponseWriter, r *http.Request)  {
	r.ParseForm()	// 分析客户端的body数据
	if r.Method == "GET" {
		// 显示静态页面
		t, _ := template.ParseFiles("/Users/play/goweb/src/form/xss.tpl")
		t.Execute(w, nil)
	} else {
		code := r.Form.Get("code")
		fmt.Println(code)
		reg,_ := regexp.Compile(`<script[^>]*> </script>`)
		text := reg.ReplaceAllLiteralString(code,"")
		fmt.Println(code)
		t,_ := template.New("test").Parse(`<html>{{ . }}</html>`)
		t.ExecuteTemplate(w,"test", text)
	}
}

func main()  {
	// 注册路由和路由函数,将url规则与处理器函数绑定做一个map映射存起来,并且会实现ServeHTTP方法,使处理器函数变成Handler函数
	http.HandleFunc("/xss",XssHandler)

	fmt.Println("服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/xss")

	// 启动 HTTP 服务,并监听端口号,开始监听,处理请求,返回响应
	err := http.ListenAndServe(":8900", nil)
	if err != nil {
		log.Fatal("ListenAndServe",err)
	}
}

执行以上程序后,在浏览器输入 http://localhost:8900/xss 访问预防跨站脚本页面,服务器控制台输出:

服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/xss

在预防跨站脚本页面文本框输入:

hello world
<script type="text/javascript">alert("hello world")</script>
<h1>hello world</h1>

然后点击提交,页面显示

服务器控制台输出:

hello world
<script type="text/javascript">alert("hello world")</script>
<h1>hello world</h1>
hello world
<script type="text/javascript">alert("hello world")</script>
<h1>hello world</h1>

以上是使用正则表达的包 regexp.Compile 替换实现预防跨站脚本,Go 的 html/template 里面带有下面几个函数可以实现转义,以达到有效防护。

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

示例:

fmt.Println("username:", template.HTMLEscapeString(r.Form.Get("username"))) //输出到服务器端
template.HTMLEscape(w, []byte(r.Form.Get("username"))) //输出到客户端

如果输入的 username 是 alert() ,那么在浏览器上面看到输出如下所示:

&lt;script&gt;alert()&lt;/script&gt;

Go 的 html/template 包默认帮你过滤了 html 标签,但是有时候只想要输出这个 alert() 看起来正常的信息,该怎么处理?请使用 text/template。请看下面的例子:

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

输出

Hello, <script>alert('you have been pwned')</script>!

或者使用 template.HTML 类型

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>"))

输出

Hello, <script>alert('you have been pwned')</script>!

转换成 template.HTML 后,变量的内容也不会被转义
转义的例子:

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

转义之后的输出:

Hello, &lt;script&gt;alert(&#39;you have been pwned&#39;)&lt;/script&gt;!

防止多次重复提交表单

由于种种原因,用户经常会重复提交表单。因此开发表单功能时需要采取方案有效的防止用户多次提交相同的表单。

解决方案是在表单中添加一个带有唯一值的隐藏字段。在验证表单时,先检查带有该惟一值的表单是否已经提交过了。如果是,拒绝再次提交;如果不是,则对表单进行逻辑处理。另外,如果是采用了 Ajax 模式提交表单,当表单提交后,通过 javascript 来禁用表单的提交按钮。

创建一个 repeatpost.tpl 文件,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>防止重复提交表单</title>
    <style type="text/css">
        body {
            padding: 10px 20px;
        }
        dl,dt,dd {
            padding: 0;
            margin: 0;
        }
        dt,dd {
            color: #333333;
            font-size: 14px;
        }
        p input {
            width: 100px;
        }

    </style>
</head>
<body>
<form action="/repeatpost" method="post">
    <dl>
        <dt>正文:</dt>
        <dd>
            <textarea style="width:400px; height:200px" name="code"></textarea>
            <input type="hidden" name="token" value="{{ . }}"
        </dd>
    </dl>
    <p><input type="submit" value="提交"></p>
    </form>
</body>
</html>

以上 web 页面,在表单代码放置一个隐藏字段(`<input type="hidden" name="token" value="{{ . }}"``),页面每次加载后,服务端都会为其生成一个唯一的 Token。如果发现 Token 重复提交,那么就不会处理提交上来的数据,会直接报错。

页面预览:

服务端文件 repeatpost.go,代码如下:

package main

import (
	"crypto/sha512"
	"fmt"
	"io"
	"log"
	"net/http"
	"regexp"
	"strconv"
	"text/template"
	"time"
)

/*
防止多次重复提交表单(POST 请求)
*/

var count = 0
func repeatpostHandler(w http.ResponseWriter, r *http.Request)  {
	r.ParseForm()	// 分析客户端的body数据
	if r.Method == "GET" {
		// 生成 Token
		currentTime := time.Now().Unix()
		fmt.Println("当前时间戳:", currentTime)
		h := sha512.New()
		io.WriteString(h,strconv.FormatInt(currentTime,10))
		token := fmt.Sprintf("%x",h.Sum(nil))
		fmt.Println("token:", token)

		// 通过模版装载页面
		t, _ := template.ParseFiles("/Users/play/goweb/src/form/repeatpost.tpl")
		t.Execute(w, token)
	} else {
		token := r.Form.Get("token")
		if token != "" {
			fmt.Println("校验 Token,持有该 Token 的页面只能提交一次")
			if count > 0 {
				fmt.Fprintln(w, "您重复提交了")
				return
			}
			fmt.Println(count)
			count++
		} else {
			fmt.Fprintf(w,"Token 不存在")
		}

		code := r.Form.Get("code")
		reg,_ := regexp.Compile(`<script[^>]*> </script>`)
		text := reg.ReplaceAllLiteralString(code,"")
		t,_ := template.New("test").Parse(`<html>{{ . }}</html>`)
		t.ExecuteTemplate(w,"test", text)
	}
}

func main()  {
	// 注册路由和路由函数,将url规则与处理器函数绑定做一个map映射存起来,并且会实现ServeHTTP方法,使处理器函数变成Handler函数
	http.HandleFunc("/repeatpost",repeatpostHandler)

	fmt.Println("服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/repeatpost")

	// 启动 HTTP 服务,并监听端口号,开始监听,处理请求,返回响应
	err := http.ListenAndServe(":8900", nil)
	if err != nil {
		log.Fatal("ListenAndServe",err)
	}
}

执行以上程序后,在浏览器输入 http://localhost:8900/repeatpost 访问防止重复提交表单页面,服务器控制台输出:

服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/repeatpost
当前时间戳: 1562831164
token: b450ca0e5ffb9e17e23931a864e2ed34e597515987422867e656f2a06eda8d213537633ff1ac87e3d5f80d685ba8a1f5471f88a7c5757d7c35d99369cf11bc60

此时,在浏览器查看防止重复提交表单页面源码,<input />组件 token 隐藏域有值。如果不断的刷新页面,可以看到这个值在不断的变化,如下:


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>防止重复提交表单</title>
    <style type="text/css">
        body {
            padding: 10px 20px;
        }
        dl,dt,dd {
            padding: 0;
            margin: 0;
        }
        dt,dd {
            color: #333333;
            font-size: 14px;
        }
        p input {
            width: 100px;
        }

    </style>
</head>
<body>
<form action="/repeatpost" method="post">
    <dl>
        <dt>正文:</dt>
        <dd>
            <textarea style="width:400px; height:200px" name="code"></textarea>
            <input type="hidden" name="token" value="b450ca0e5ffb9e17e23931a864e2ed34e597515987422867e656f2a06eda8d213537633ff1ac87e3d5f80d685ba8a1f5471f88a7c5757d7c35d99369cf11bc60"
        </dd>
    </dl>
    <p><input type="submit" value="提交"></p>
    </form>
</body>
</html>

这样就保证了每次显示 form 表单都是唯一的,用户递交的表单保持了唯一性。

以上解决方案可以防止非恶意的攻击,并能使恶意用户暂时不知所措,然而,它却不能排除所有的欺骗性的动机,对此类情况还需要更复杂的工作。

文件上传

要使表单能够上传文件,首先第一步就是要添加 form 的 enctype 属性,enctype 属性有如下三种情况:

  • application/x-www-form-urlencoded 表示在发送前编码所有字符(默认)
  • multipart/form-data 不对字符编码,在使用包含文件上传控件的表单时,必须使用该值。
  • text/plain 空格转换为 "+" 加号,但不对特殊字符编码。

创建一个 uploadFile.tpl 文件,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>上传文件</title>
    <style type="text/css">
        body {
            padding: 10px 20px;
        }
        dl,dt,dd {
            padding: 0;
            margin: 0;
        }
        dt,dd {
            color: #333333;
            font-size: 14px;
        }
    </style>
</head>
<body>
<form enctype="multipart/form-data" action="/upload" method="post">
    <dl>
        <dt>上传文件:</dt>
        <dd>
            <input type="file" name="uploadfile">
        </dd>
    </dl>
    <p><input type="submit" value="提交"></p>
    </form>
</body>
</html>

页面预览:

服务端文件 uploadFile.go,代码如下:

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"text/template"
)

/*
上传文件经过如下 3 步:
1. 在 Web 页面选择一个文件,然后上传
2. 在服务端读取上传文件的数据(字节流
3. 将文件数据写到服务端的某一个文件中
*/

func uploadFileHandle(w http.ResponseWriter, r *http.Request)  {
	r.ParseMultipartForm(1024*1024)	// 最多在内存中一次处理 1 MB 的数据
	file, handler,err := r.FormFile("uploadfile")

	if err != nil {
		fmt.Println(err)
		return
	}

	defer file.Close()  // 延迟关闭文件(在uploadFile函数结束时关闭文件)
	fmt.Fprintf(w, "%v", handler.Header)

	// 打开服务器端文件
	f, err := os.OpenFile("./upload/" + handler.Filename, os.O_WRONLY | os.O_CREATE, 0666)

	if err != nil {
		fmt.Println(err)
		return
	}
	defer f.Close()
	io.Copy(f, file)
}

func showUploadfilePage(w http.ResponseWriter, r *http.Request)  {
	if r.Method == "GET" {
		t,_ := template.ParseFiles("/Users/play/goweb/src/form/uploadFile.tpl")
		t.Execute(w,nil)
	}
}

func main()  {
	http.HandleFunc("/",showUploadfilePage)
	http.HandleFunc("/upload",uploadFileHandle)

	fmt.Println("服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/")

	// 启动 HTTP 服务,并监听端口号,开始监听,处理请求,返回响应
	err := http.ListenAndServe(":8900", nil)
	if err != nil {
		log.Fatal("ListenAndServe",err)
	}
}

通过上面的代码可以看到,处理文件上传需要调用 r.ParseMultipartForm,里面的参数表示 maxMemory,调用 ParseMultipartForm 之后,上传的文件存储在 maxMemory 大小的内存里面,如果文件大小超过了 maxMemory,那么剩下的部分将存储在系统的临时文件中。可以通过 r.FormFile 获取上面的文件句柄,然后实例中使用了 io.Copy 来存储文件。

获取其它非文件字段信息的时候就不需要调用 r.ParseForm,因为在需要的时候 Go 自动会去调用。而且 ParseMultipartForm 调用一次之后,后面再次调用不会再有效果。

通过上面的实例可知上传文件主要有三步处理:

  1. 表单中增加 enctype="multipart/form-data"
  2. 服务端调用 r.ParseMultipartForm,把上传的文件存储在内存和临时文件中
  3. 使用 r.FormFile 获取文件句柄,然后对文件进行存储等处理

文件 handler 是 multipart.FileHeader,里面存储了如下结构信息

type FileHeader struct {
    Filename string
    Header   textproto.MIMEHeader
    // contains filtered or unexported fields
}

执行以上程序后,在浏览器输入 http://localhost:8900/ 访问上传文件页面,服务器控制台输出:

服务器已经启动,请在浏览器地址栏中输入 http://localhost:8900/

然后点击选择需要上传的文件:

点击提交按钮后,显示上传文件的信息如下:

最后,查看站点 upload 目录,发现文件已经上传完成