Go语言编程笔记14:处理请求
图源:wallpapercave.com
上一篇Go语言编程笔记13:处理器中我们讨论了如何创建一个Web应用并接收请求,本篇文章探讨如何来处理请求。
Request
在Go语言编程笔记12:web基础中我们说过了,一个HTTP请求实际上就是一个HTTP请求报文,内容主要由首行、报文头、空行、报文体四个部分组成。
在http 库中,请求报文被抽象为http.Request 这个结构体:
type Request struct {
Method string
URL *url.URL
Proto string
ProtoMajor int
ProtoMinor int
Header Header
Body io.ReadCloser
GetBody func() (io.ReadCloser, error)
ContentLength int64
TransferEncoding []string
Close bool
Host string
Form url.Values
PostForm url.Values
MultipartForm *multipart.Form
Trailer Header
RemoteAddr string
RequestURI string
TLS *tls.ConnectionState
Cancel <-chan struct{}
Response *Response
ctx context.Context
}
这个结构体并没有完全按照请求报文的结构来定义,但其中比较重要的依然是请求报文中包含的这几个概念:
URL
Request.URL 代表请求报文的首行,即包含URL和HTTP协议的那部分。其具体定义为一个url.URL 类型的结构体:
type URL struct {
Scheme string
Opaque string
User *Userinfo
Host string
Path string
RawPath string
ForceQuery bool
RawQuery string
Fragment string
RawFragment string
}
这个结构体中的字段其实对应下面这样的URL:
scheme://[userinfo@]host/[path][?query][#fragment]
[xxx] 表示可选结构。userinfo 的部分为HTTP协议早期规定的可以使用URL进行身份验证的部分,即可以通过类似http://name:passwd@sample.com/index.php 这样的URL进行身份验证和登录,显然这种做法已经过时,虽然依然遗留在HTTP协议中,但出于安全考虑并不会有网站使用这样的方式进行身份验证。
除了上边这种常见的URL结构,还可以对应一种不常见的:
scheme:opaque[?query][#fragment]
因为的确不常见到,所以这里不做讨论。
我们可以使用以下代码来观察Request.URL 的内容:
package main
import (
"fmt"
"net/http"
)
func helloHandleFunc(rw http.ResponseWriter, r *http.Request) {
fmt.Fprintf(rw, "%#v", r.URL)
fmt.Fprintf(rw, "%#v", r.Proto)
}
func main() {
serverMux := http.NewServeMux()
server := http.Server{
Addr: "127.0.0.1:8080",
Handler: serverMux,
}
serverMux.HandleFunc("/hello/", helloHandleFunc)
server.ListenAndServe()
}
如果使用浏览器请求http://127.0.0.1:8080/hello/lalala?name=icexmoon&id=123#here ,就会显示:
&url.URL{Scheme:"", Opaque:"", User:(*url.Userinfo)(nil), Host:"", Path:"/hello/lalala", RawPath:"", ForceQuery:false, RawQuery:"name=icexmoon&id=123", Fragment:"", RawFragment:""}"HTTP/1.1"
比较奇怪的是这里的Request.URL.Scheme 是一个空字符串,无论是使用HTTP连接还是HTTPS连接都是如此,但是Request.Proto 属性中的值的确是HTTP/1.1 。
虽然我们在URL中指定了页面锚点#here ,但服务端并没有获取到Request.URL.Fragment ,这是因为在传输过程中浏览器自动“抛弃”了这个信息,这点从请求报文中可以得到验证:
GET /hello/lalala?name=icexmoon&id=123 HTTP/1.1
Host: 127.0.0.1:8080
...
但是如果请求的客户端传递了这个信息(比如使用自己用http 库编写的Web客户端),服务器是可以正常接收的。
虽然我们可以通过解析字符串的方式从Request.URL.RawQuery 中获取查询字符串中包含的查询参数,但这显然很不方便,对此http 库提供了其它更方便的方式,比如formValue 函数等,这会在之后介绍。
header
请求中除了URL相关信息,报文头也很重要,HTTP传输控制以及内容编码等信息都是通过报文头的方式进行传递的,此外Cookie也以报文头的形式发送给服务端。
http 库中表示请求报文头的内容是Request.Header ,这是一个http.Header 类型的数据,其实是一个映射:
type Header map[string][]string
这个映射的key是字符串,value是一个字符串切片。之所以这样设计是因为报文头并非是简单的key-value结构,每一个key对应的内容可以是单个信息(比如Cache-Control: no-cache ),也可以是多条信息(比如Accept-Encoding: gzip, deflate, br ),对于多条信息的报文头条目,请求报文会以, 分隔的方式进行发送。相应的,服务端也会将其转换为字符串切片进行保存。
同样的,我们可以在页面打印报文头信息:
...
func helloHandleFunc(rw http.ResponseWriter, r *http.Request) {
fmt.Fprintf(rw, "%#v", r.Header)
}
...
如果要观察某个报文头条目,可以:
...
func helloHandleFunc(rw http.ResponseWriter, r *http.Request) {
fmt.Fprintf(rw, "%#v", r.Header["Accept-Encoding"])
}
...
但和我想的不一样的是,Request.Header 并不会将Accept-Encoding: gzip, deflate, br 这样的报文头存储为[]string{"gzip","deflate","br"} ,也就是说按, 切割后保存,而是依然存储为一整个字符串[]string{"gzip, deflate, br"} 。这似乎和将报文头的结构设定为map[string][]string 的方式是不相符的,目前这种实现完全可以用map[string]string 这样的映射来实现。
为了验证我这种猜测,我尝试用一段代码对报文头信息筛选:
...
func helloHandleFunc(rw http.ResponseWriter, r *http.Request) {
for key, value := range r.Header {
if len(value) > 1 {
fmt.Fprintf(rw, "%s\n", key)
}
}
}
...
结果证实了我的猜测,的确没有切片元素超过1的报文信息。
通过后面的Cookie相关学习我发现我这里的想法是错误的,事实上HTTP请求和响应报文中,同一个报文头可能存在多条,就像后文中设置两个以上的Set-Cookie 报文头:
HTTP/1.1 200 OK
Set-Cookie: first_cookie=cookie1
Set-Cookie: second_cookie=cookie2
Date: Sat, 25 Dec 2021 05:51:05 GMT
Content-Length: 0
此时显然就需要使用一个切片来对应同一个名称的报文头了。
除了直接使用下标来获取报文内容,比如r.Header["Accept-Encoding"][0] ,还可以使用Request.Header 的Get 方法:
...
func helloHandleFunc(rw http.ResponseWriter, r *http.Request) {
ae := r.Header.Get("Accept-Encoding")
fmt.Fprintf(rw, "%s", ae)
}
...
body
除了可以利用URL中的路径和查询字符串传递少量信息以外,大部分用户提交的信息都是以请求报文体的方式传递,这不仅包含文本信息,还包含可能上传的二进制文件。
http 中表示请求报文体的变量是Request.Body ,这是一个io.ReadCloser 接口:
Body io.ReadCloser
在Go语言编程笔记6:接口中我们展示过Go语言中接口如何套用,并仿照io.Reader 等接口作为示例,实际上这里的io.ReadCloser 接口与彼时介绍的用法类似,同样支持read 和close 方法,我们可以利用read 方法读取请求报文体的内容。
...
func helloHandleFunc(rw http.ResponseWriter, r *http.Request) {
len := r.ContentLength
body := make([]byte, len)
if _, err := r.Body.Read(body); err != nil && err != io.EOF {
log.Fatal(err)
}
fmt.Fprintln(rw, string(body))
}
...
Read 方法返回的是一个读取的字节数和错误类型,需要注意的是处理错误时不能只使用err != nil ,还需要考虑err != io.EOF ,因为当Read 方法读取完所有数据后,它会返回io.EOF 这个错误。
一般来说我们要往服务端传送带有报文体的HTTP请求,需要使用一个带有form 表单的HTML页面。但也可以使用其它的HTTP客户端工具,比如curl :
? curl -id "name=icexmoon&id=123" 127.0.0.1:8080/hello/index
HTTP/1.1 200 OK
Date: Thu, 23 Dec 2021 07:22:39 GMT
Content-Length: 21
Content-Type: text/plain; charset=utf-8
name=icexmoon&id=123
curl是一个支持多个平台的命令行网络调试工具,像上面展示的那样,可以利用它来发送带报文体的HTTP请求。其中-i 参数表示返回信息包含报文头,-d 表示后边跟的内容是POST 方法发送的数据(即表单数据)。
更多详细的参数和使用方式可以通过curl --help 进行查看。
获取数据
事实上通过Request.URL 、Request.Body 、Request.Header 我们就可以获取到HTTP请求的全部内容。但显然这并不会很方便,因为这些内容都没有经过解析分组,我们不能便捷得获取到单个表单元素的值或者单个查询参数的值。
所以http 包提供了一些更为方便的方式。
查询字符串
通过查询字符串来传递和获取信息是最为简单的Web编程方式:
...
func helloHandleFunc(rw http.ResponseWriter, r *http.Request) {
r.ParseForm()
fmt.Fprintf(rw, "%#v", r.Form)
}
...
对于http://127.0.0.1:8080/hello/lalala?name=icexmoon&id=123#here 这样的请求,会输出:
url.Values{"id":[]string{"123"}, "name":[]string{"icexmoon"}}
可以看到Request.Form 是一个url.Values 类型的映射,其实际类型是map[string][]string ,和Request.Header 是一样的。可能有些对Web开发理解不多的人会疑惑,为什么这里会使用[]string 作为查询参数的值,而不是string 。但实际上查询字符串是可以包含多个同样命名的查询参数的,比如:
http://127.0.0.1:8080/hello/lalala?name=icexmoon&name=apple&id=123#here
对这样的请求,输出结果是:
url.Values{"id":[]string{"123"}, "name":[]string{"icexmoon", "apple"}}
所以Request.Form 的类型是map[string][]string ,而非map[string]string 。
虽然一般情况下不太会在网站链接中生成重名查询参数的情况,但表单元素实际上也可以通过查询字符串进行传递,而表单元素中的某些多选组件(比如checkbox )就是重名的。
所以我们可以使用下标方式获取具体的查询参数的值(准确的说是第一个值):
...
func helloHandleFunc(rw http.ResponseWriter, r *http.Request) {
r.ParseForm()
fmt.Fprintf(rw, "%s\n", r.Form["name"][0])
fmt.Fprintf(rw, "%s\n", r.Form["id"][0])
}
...
当然这样同样不方便,所以:
...
func helloHandleFunc(rw http.ResponseWriter, r *http.Request) {
r.ParseForm()
fmt.Fprintf(rw, "%s\n", r.Form.Get("name"))
fmt.Fprintf(rw, "%s\n", r.Form.Get("id"))
}
...
之所以这里使用的是Request.Form 而不是Request.QueryParams 之类的命名,是因为实际上默认情况下的表单元素都是以查询字符串的方式进行编码后通过报文体传递的,所以一般情况下两者都可以通过Request.Form 来获取。
最后提醒一下,在使用Request.Form 之前,要确保手动调用过Request.ParseForm() 方法,这个方法的作用是将查询字符串和报文体中的表单元素进行解析,然后保存到Request.Form 中。
表单数据
Form
除了查询字符串以外,重要的数据一般会以表单的方式提交给服务端:
<html>
<head></head>
<body>
<form action="/user_submit" method="post">
name:<input type="text" name="name" value="icexmoon"/><br/>
age:<input type="text" name="age" value="12"/></br>
<input type='submit'/>
</form>
</body>
</html>
对应的服务端代码:
package main
import (
"fmt"
"log"
"net/http"
"text/template"
)
func userSubmit(rw http.ResponseWriter, r *http.Request) {
r.ParseForm()
fmt.Fprintf(rw, "%s\n", r.Form.Get("name"))
fmt.Fprintf(rw, "%s\n", r.Form.Get("age"))
}
func userInfo(rw http.ResponseWriter, r *http.Request) {
t, err := template.ParseFiles("user.html")
if err != nil {
log.Fatal(err)
}
t.Execute(rw, nil)
}
func main() {
serverMux := http.NewServeMux()
server := http.Server{
Addr: "127.0.0.1:8080",
Handler: serverMux,
}
serverMux.HandleFunc("/user_submit", userSubmit)
serverMux.HandleFunc("/user_info", userInfo)
server.ListenAndServe()
}
这里userInfo 处理器函数中加载HTML页面的相关代码可以先忽略,下篇笔记会进行说明。
HTML中的action 和type 属性表明,表单在提交后会使用POST的方式传递到/user_info 这个URL。在服务端,由userSubmit 这个处理器函数处理该请求,与之前提取查询字符串值类似,同样可以使用Request.Form 提取表单元素中的数据。
PostForm
虽然不常见,但是其实是可以同时使用查询字符串和表单提交数据的,甚至字段名称可能重复,比如:
<!DOCTYPE html>
<html>
<head></head>
<body>
<form action="/user_submit?name=guest&age=10" method="post">
name:<input type="text" name="name" value="icexmoon"/><br/>
age:<input type="text" name="age" value="12"/></br>
<input type="submit" value="submit"/>
</form>
</body>
</html>
此时如果在服务端打印Request.Form :
...
func userSubmit(rw http.ResponseWriter, r *http.Request) {
r.ParseForm()
fmt.Fprintf(rw, "%#v", r.Form)
}
...
会发现它同时包含查询字符串和表单的内容:
url.Values{"age":[]string{"12", "10"}, "name":[]string{"icexmoon", "guest"}}
并且对于同名字段,表单内容在前,查询字符串内容在后。
如果我们仅需要提取表单传递的内容,而非查询字符串,可以使用Request.PostForm :
...
func userSubmit(rw http.ResponseWriter, r *http.Request) {
r.ParseForm()
fmt.Fprintf(rw, "%#v\n", r.PostForm)
fmt.Fprintf(rw, "%s\n", r.PostForm.Get("name"))
fmt.Fprintf(rw, "%s\n", r.PostForm.Get("age"))
}
...
输出:
url.Values{"age":[]string{"12"}, "name":[]string{"icexmoon"}}
icexmoon
12
MultipartForm
你可能注意到了,上边这种情况下提交表单时,实际上表单内容是是以URL Encoding方式编码后在报文体内传输。具体表单提交时数据以何种方式编码其实由表单的enctype 属性决定:
application/x-www-form-urlencoded ,默认值,以url encode 方式编码。multipart/form-data ,以MIME格式编码。text/plain ,不进行编码,仅会将空格替换为+ 。
下面演示以multipart/form-data 方式提交表单内容和服务端捕获:
<!DOCTYPE html>
<html>
<head></head>
<body>
<form action="/user_submit" method="post" enctype="multipart/form-data">
name:<input type="text" name="name" value="icexmoon"/><br/>
age:<input type="text" name="age" value="12"/></br>
<input type="submit" value="submit"/>
</form>
</body>
</html>
用浏览器开发者工具观察就能发现,实际上请求报文体中的内容为:
------WebKitFormBoundaryDuyBzmaT9gbsqyLg
Content-Disposition: form-data; name="name"
icexmoon
------WebKitFormBoundaryDuyBzmaT9gbsqyLg
Content-Disposition: form-data; name="age"
12
------WebKitFormBoundaryDuyBzmaT9gbsqyLg--
这就是用MIME格式编码后的表单数据。
此时是无法使用Requeset.ParseForm 和Request.Form 的方式获取表单信息的,需要使用以下方式:
...
func userSubmit(rw http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1024); err != nil {
log.Fatal(err)
}
fmt.Fprintf(rw, "%#v\n", r.MultipartForm)
fmt.Fprintf(rw, "%s\n", r.MultipartForm.Value["name"][0])
fmt.Fprintf(rw, "%s\n", r.MultipartForm.Value["age"][0])
}
...
此时需要调用Request.ParseMultipartForm 函数以MIME格式解析表单内容,然后从Request.MultipartForm 属性获取相应的表单元素值。
ParseMultipartForm 函数接收一个参数,表示解析报文体内容的字节长度,通常可以指定一个比较长的数字,比如1024。- 如果需要,
ParseMultipartForm 函数会主动调用ParseForm 函数。
提交表单后的输出是:
&multipart.Form{Value:map[string][]string{"age":[]string{"12"}, "name":[]string{"icexmoon"}}, File:map[string][]*multipart.FileHeader{}}
icexmoon
12
可以看到Request.MultipartForm 的类型是*multipart.Form ,其包含两个属性:Value 和File 。Value 的结构和Request.Form 是一样的,File 属性的结构是map[string][]*multipart.FileHeader ,通过File 属性可以获取通过请求报文发送的文件。
下面展示一个发送文件的示例:
...
<body>
<form action="/user_submit?name=guest&age=10" method="post" enctype="multipart/form-data">
name:<input type="text" name="name" value="icexmoon" /><br />
age:<input type="text" name="age" value="12" /><br />
file:<input type="file" name="upload"/><br />
<input type="submit" value="submit" />
</form>
</body>
...
使用表单发送文件时,表单编码必须是enctype="multipart/form-data" 。
服务端使用Request.MultipartForm.File 读取文件:
...
func userSubmit(rw http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1024); err != nil {
log.Fatal(err)
}
fh := r.MultipartForm.File["upload"][0]
file, err := fh.Open()
if err != nil {
log.Fatal(err)
}
contents, err := ioutil.ReadAll(file)
if err != nil && err != io.EOF {
log.Fatal(err)
}
fmt.Fprintln(rw, string(contents))
}
...
这里读取到内容后将其原样输出给页面。
FormValue
除了上面说的方式获取表单和查询字符串内容以外,还可以用更好用的FormValue 方法:
...
func userSubmit(rw http.ResponseWriter, r *http.Request) {
fmt.Fprintf(rw, "%s\n", r.FormValue("id"))
fmt.Fprintf(rw, "%s\n", r.FormValue("name"))
fmt.Fprintf(rw, "%s\n", r.FormValue("age"))
}
...
Request.FormValue 方法可以自动调用Request.ParseForm 方法和Request.ParseMultipartForm 方法,并从Request.Form 属性中获取相应的Key的第一个值。
类似的,如果只需要提取表单元素,可以使用Request.PostFormValue 方法:
...
func userSubmit(rw http.ResponseWriter, r *http.Request) {
fmt.Fprintf(rw, "%s\n", r.PostFormValue("id"))
fmt.Fprintf(rw, "%s\n", r.PostFormValue("name"))
fmt.Fprintf(rw, "%s\n", r.PostFormValue("age"))
}
...
在Go的早期版本中,存在一个bug:如果表单内容以MIME方式编码,则服务端通过Request.PostFormValue 获取信息就会失败,原因是此时数据只会保存在Request.MultipartForm 中,Request.PostForm 中没有表单数据,而PostFormValue 只会尝试从后者获取数据。该问题已经在较新版本中解决。
此外,对于从表单提取上传文件,也存在一个类似的便捷方法FormFile :
...
func userSubmit(rw http.ResponseWriter, r *http.Request) {
file, _, err := r.FormFile("upload")
if err != nil {
log.Fatal(err)
}
...
}
...
Request.FormFile 方法返回三个信息:打开的文件、文件头、错误。实际上其等价于:
...
func userSubmit(rw http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1024); err != nil {
log.Fatal(err)
}
fh := r.MultipartForm.File["upload"][0]
file, err := fh.Open()
if err != nil {
log.Fatal(err)
}
...
}
对于一个表单元素只上传一个文件的情况,无疑使用FormFile 方法获取文件更为方便。
总结
可能你会和我一样,觉得http 包提供的获取HTTP请求数据的方式显得很乱,这里我绘制了一张表作为总结:
Ajax和Json
除了上面介绍的一般方式以外,Web开发中还经常会使用Ajax来传递数据:
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.staticfile.org/jquery/1.10.2/jquery.min.js"></script>
<script>
function send_json() {
$.ajax({
url: "/user_submit",
type: "PUT",
data: {
name: $("input[name=name]").val(),
age: $("input[name=age]").val()
},
dataType: "json",
contentType: "application/x-www-form-urlencoded",
async: true,
success: function (result, status, xhr) {
console.log(result);
},
error: function (xhr, status, error) {
console.log(error);
}
});
}
</script>
</head>
<body>
name:<input type="text" name="name" value="icexmoon" /><br />
age:<input type="text" name="age" value="19" /><br />
<button onclick="send_json()">send json.</button>
</body>
</html>
使用Jquery发送Ajax请求时,其中的contentType 表示发送的数据将以何种方式进行编码,默认是application/x-www-form-urlencoded ,此时Ajax请求中的data 数据将会被编码为查询字符串的方式附加在请求报文体中进行发送,所以此时我们是可以按照之前介绍的方式在服务端提取信息的:
...
type result struct {
Result string `json:"result"`
Name string `json:"name"`
Age string `json:"age"`
}
func userSubmit(rw http.ResponseWriter, r *http.Request) {
name := r.FormValue("name")
age := r.FormValue("age")
resp, err := json.Marshal(result{Result: "ok", Name: name, Age: age})
if err != nil {
log.Fatal(err)
}
fmt.Fprintf(rw, "%s", string(resp))
}
...
除此之外,更常见的Ajax请求会将数据编码为Json格式后发送:
...
<script>
function send_json() {
$.ajax({
url: "/user_submit",
type: "PUT",
data: JSON.stringify({
name: $("input[name=name]").val(),
age: $("input[name=age]").val()
}),
dataType: "json",
contentType: "application/json",
...
});
}
</script>
...
注意此时的contentType 是application/json ,其实写成json 也是可以的,因为这两种值的情况下服务端都不会进行“预处理”,都需要开发者手动解析。
此时就需要在服务端“手动”从报文体中读取数据后进行Json解析以获取信息:
...
type person struct {
Name string `json:"name"`
Age string `json:"age"`
}
func userSubmit(rw http.ResponseWriter, r *http.Request) {
body := make([]byte, r.ContentLength)
if _, err := r.Body.Read(body); err != nil && err != io.EOF {
log.Fatal(err)
}
p := new(person)
if err := json.Unmarshal(body, p); err != nil {
log.Fatal(err)
}
name := p.Name
age := p.Age
resp, err := json.Marshal(result{Result: "ok", Name: name, Age: age})
if err != nil {
log.Fatal(err)
}
fmt.Fprintf(rw, "%s", string(resp))
}
...
返回数据
就像之前展示的那样,如果要向Web客户端回写信息,需要使用处理器收到的http.ResponseWriter 类型的参数,实际上该类型是一个接口:
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
这个接口包含三个方法:
分别用于设置响应报文的报文头、报文体、首行。
设置首行
通常情况下我们无需手动设置返回报文的首行,因为默认情况下会自动设置为200 ok ,但某些情况下这样做是有意义的,比如对于一个未完成开发的接口,我们可能需要返回一个特定的HTTP状态码以告诉客户端,这个接口还没有实现:
...
func userSubmit(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(501)
}
...
使用curl 请求就能看到这样的返回报文:
? curl -i 127.0.0.1:8080/user_submit
HTTP/1.1 501 Not Implemented
Date: Sat, 25 Dec 2021 03:46:18 GMT
Content-Length: 0
需要说明的是,调用Request.WriteHeader 设置了响应报文的首行后,就不能再尝试设置报文头了,所以设置响应报文首行的操作应当放在设置完响应报文头之后。
设置报文头
设置报文头的操作在Web开发中非常常见,比如设置客户端Cookie、设置响应报文的ContentType 以让客户端正确处理响应结果、让客户端进行页面跳转(重定向)等。
这里举一个很常见的重定向例子:
...
func redirect(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("location", "/user_info")
rw.WriteHeader(301)
}
func main() {
...
serverMux.HandleFunc("/redirect", redirect)
server.ListenAndServe()
}
...
如果使用浏览器请求http://127.0.0.1:8080/redirect ,就会发现页面会跳转到http://127.0.0.1:8080/user_info 。
这里ResponseWriter.Header 方法返回的是一个http.Header 类型,其具体定义为:
type Header map[string][]string
实际上是一个代表了响应报文头的映射,我们可以直接使用映射修改相应报文头:
...
func redirect(rw http.ResponseWriter, r *http.Request) {
header := rw.Header()
header["location"] = append(header["location"], "/user_info")
rw.WriteHeader(301)
}
...
但显然更方便的做法是使用该类型的Set 方法。
如果需要给一个报文头追加两个及以上的内容,可以在使用完Set 方法后使用Add 方法。
设置报文体
实际上之前示例中fmt.Fprintf(rw,...) 的做法就是直接将信息写入报文体,这里是利用了fmt 包的Fprint 系列方法接受一个io.Writer 接口的参数,并调用相应的Write 方法写入信息:
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
p := newPrinter()
p.doPrintf(format, a)
n, err = w.Write(p.buf)
p.free()
return
}
这么做的好处在于一来Go的开发者对fmt 包相当熟悉,可以用类似输出到控制台的语法来将信息回写到Web客户端。二来fmt 的相关打印方法支持格式化字符串,可以利用相关格式化参数满足一些特别的需要。
当然我们也可以使用更“原始”的方式直接调用ResponseWriter 的Write 方法来回写信息:
...
func userSubmit(rw http.ResponseWriter, r *http.Request) {
name := r.PostFormValue("name")
age := r.PostFormValue("age")
respStr := fmt.Sprintf("name:%s\nage:%s\n", name, age)
rw.Write([]byte(respStr))
}
...
当我们在响应报文体中写入数据,并返回响应报文后,http客户端如何显示和处理响应报文取决于响应报文的Content-Type 报文头。默认情况下,http 包会根据响应报文体内的数据内容来设置相应的报文头:
...
func hello(rw http.ResponseWriter, r *http.Request) {
respStr := `
<html>
<body>
<h1>hello world!</h1>
</body>
</html>
`
rw.Write([]byte(respStr))
}
func main() {
...
serverMux.HandleFunc("/hello", hello)
server.ListenAndServe()
}
在上边的示例中,通过Write 方法写入响应报文体内的信息是html 页面,所以相应的报文头Content-Type 会被设置为text/html :
? curl -i 127.0.0.1:8080/hello
HTTP/1.1 200 OK
Date: Sat, 25 Dec 2021 05:26:51 GMT
Content-Length: 63
Content-Type: text/html; charset=utf-8
当然我们也可以根据需要手动设置Content-Type 报文头。
cookie
之前接触过Web开发的童鞋肯定对cookie不陌生,简单地说就是因为HTTP协议天生是一个无状态协议,所以没法保存会话状态,为了弥补这个问题,诞生了客户端存储技术cookie和服务端存储技术session。这两个技术都可以看作是给HTTP协议打的“补丁”。
结合使用cookie和session就可以实现完整的会话状态控制,通常网站的登录状态等都是以这样的方式实现的。
下面来看如何通过http 包使用cookie:
写入cookie
http 包对Cookie的抽象是http.Cookie 这个结构体:
type Cookie struct {
Name string
Value string
Path string
Domain string
Expires time.Time
RawExpires string
MaxAge int
Secure bool
HttpOnly bool
SameSite SameSite
Raw string
Unparsed []string
}
其中比较重要的属性有:
- Name,Cookie名称。
- Value,Cookie的值。
- Expires,过期日期,如果设定,Cookie将会在指定日期后失效。
- MaxAge,Cookie在HTTP客户端创建后的有效时间(秒数),超过该时间后Cookie失效。
如果Cookie没有设置有效时间,则只在“会话”内有效,即浏览器关闭后Cookie就自动失效,这样的Cookie被称为会话Cookie。如果设置了有效时间,则在有效期内Cookie都是有效的,这样的Cookie被称为长效Cookie。
Cookie的Expires和MaxAge属性都可以用来指定Cookie的有效时间,并且HTTP1.1协议中已经将Expires属性作废,推荐使用MaxAge属性。但因为历史原因,目前主流浏览器依然支持Expires属性,所以考虑兼容性问题,推荐单独使用Expires属性或者同时使用Expires和MaxAge属性。
利用http.Cookie 我们可以创建并设置Cookie:
package main
import (
"net/http"
)
func writeCookie(rw http.ResponseWriter, r *http.Request) {
c1 := http.Cookie{
Name: "first_cookie",
Value: "cookie1",
}
c2 := http.Cookie{
Name: "second_cookie",
Value: "cookie2",
}
rw.Header().Set("Set-Cookie", c1.String())
rw.Header().Add("Set-Cookie", c2.String())
}
func main() {
serverMux := http.NewServeMux()
server := http.Server{
Addr: "127.0.0.1:8080",
Handler: serverMux,
}
serverMux.HandleFunc("/write_cookie", writeCookie)
server.ListenAndServe()
}
Cookie.string() 方法可以将Cookie实例转化为字符串形式,然后通过响应报文头Set-Cookie 返回给HTTP客户端写入Cookie。
使用curl 工具查看返回报文信息:
? curl -i 127.0.0.1:8080/write_cookie
HTTP/1.1 200 OK
Set-Cookie: first_cookie=cookie1
Set-Cookie: second_cookie=cookie2
Date: Sat, 25 Dec 2021 05:51:05 GMT
Content-Length: 0
如果使用浏览器请求,就会查看到浏览器端生成相应的Cookie:
除了上边这种通过响应报文头来设置Cookie的“传统方式”以外,http 包还提供一个工具函数,可以“稍微”让设置Cookie变得简便一些:
...
func writeCookie(rw http.ResponseWriter, r *http.Request) {
c1 := http.Cookie{
Name: "first_cookie",
Value: "cookie1",
}
c2 := http.Cookie{
Name: "second_cookie",
Value: "cookie2",
}
http.SetCookie(rw, &c1)
http.SetCookie(rw, &c2)
}
...
需要注意的是,SetCookie 函数第二个参数是Cookie 类型的指针,所以需要传递&c1 而非c1 。
接收cookie
当浏览器端存在当前网站的有效Cookie时,就会在发起到服务端的请求时在请求报文头中附带上Cookie信息:
GET /get_cookie HTTP/1.1
Host: 127.0.0.1:8080
...
Accept-Language: zh-CN,zh-TW;q=0.9,zh-HK;q=0.8,zh;q=0.7,en;q=0.6,und;q=0.5
Cookie: first_cookie=cookie1; second_cookie=cookie2
所以我们可以通过http.Request 实例中的Header 获取Cookie:
...
func getCookie(rw http.ResponseWriter, r *http.Request) {
fmt.Fprintf(rw, "%s\n", r.Header["Cookie"][0])
}
func main() {
...
serverMux.HandleFunc("/get_cookie", getCookie)
server.ListenAndServe()
}
但这样获取到的是以一定格式编码后的Cookie:
first_cookie=cookie1; second_cookie=cookie2
并不能方便地获取某个Cookie的值,所以更常见的做法是使用Request.Cookie 方法:
...
func getCookie(rw http.ResponseWriter, r *http.Request) {
c1, _ := r.Cookie("first_cookie")
c2, _ := r.Cookie("second_cookie")
fmt.Fprintf(rw, "first_cookie:%s\n", c1.Value)
fmt.Fprintf(rw, "second_cookie:%s\n", c2.Value)
}
...
如果想获取到所有的Cookie 实例,则可以使用Request.Cookies 方法:
...
func getCookie(rw http.ResponseWriter, r *http.Request) {
cookies := r.Cookies()
for _, c := range cookies {
fmt.Fprintf(rw, "%s:%s\n", c.Name, c.Value)
}
}
...
闪回消息
Cookie有很多用途,其中之一是实现“闪回消息”。
假设我们现在要做一个登陆页,用户如果正常登陆,则跳转到网站的首页,并显示一条欢迎语句。如果登陆失败,则不跳转,依然停留在登录页,并显示登录失败等信息。
当然具体实现这个功能可以有很多方式,但这里主要是学习Cookie,所以用Cookie实现。
package main
import (
"encoding/base64"
"log"
"net/http"
"text/template"
"time"
)
const FLASH_MSG_COOKIE_NAME = "flash"
func writeFlashMsg(rw http.ResponseWriter, msg string) {
flashCookie := http.Cookie{
Name: FLASH_MSG_COOKIE_NAME,
Value: base64.StdEncoding.EncodeToString([]byte(msg)),
}
http.SetCookie(rw, &flashCookie)
}
func readFlashMsg(rw http.ResponseWriter, r *http.Request) (msg string, err error) {
flashCookie, err := r.Cookie(FLASH_MSG_COOKIE_NAME)
if err != nil {
if err == http.ErrNoCookie {
return "", nil
}
return
}
bytes, err := base64.StdEncoding.DecodeString(flashCookie.Value)
if err != nil {
return
}
msg = string(bytes)
flashCookie = &http.Cookie{
Name: FLASH_MSG_COOKIE_NAME,
Expires: time.Unix(1, 0),
MaxAge: -1,
}
http.SetCookie(rw, flashCookie)
return
}
func checkLogin(rw http.ResponseWriter, r *http.Request) {
name := r.PostFormValue("name")
pasword := r.PostFormValue("password")
if name == "icexmoon" && pasword == "12345" {
writeFlashMsg(rw, "hello! welcome to our web site!")
rw.Header().Set("location", "/index")
rw.WriteHeader(301)
} else {
writeFlashMsg(rw, "username or password error!")
rw.Header().Set("location", "/login")
rw.WriteHeader(301)
}
}
func login(rw http.ResponseWriter, r *http.Request) {
t, err := template.ParseFiles("login.html")
if err != nil {
log.Fatal(err)
}
msg, err := readFlashMsg(rw, r)
if err != nil {
log.Fatal(err)
}
t.Execute(rw, msg)
}
func index(rw http.ResponseWriter, r *http.Request) {
t, err := template.ParseFiles("index.html")
if err != nil {
log.Fatal(err)
}
msg, err := readFlashMsg(rw, r)
if err != nil {
log.Fatal(err)
}
t.Execute(rw, msg)
}
func main() {
serverMux := http.NewServeMux()
server := http.Server{
Addr: "127.0.0.1:8080",
Handler: serverMux,
}
serverMux.HandleFunc("/index", index)
serverMux.HandleFunc("/login", login)
serverMux.HandleFunc("/check_login", checkLogin)
server.ListenAndServe()
}
这里的主要逻辑是,在表单提交到/check_login URL进行验证的时候,如果用户名密码正确,服务端就向客户端Cookie中写入一个闪回消息“hello! welcome…”,然后让客户端跳转到/index 页面。否则服务端向客户端Cookie写入一个“login error”消息,然后让客户端跳转回/login 页面。
要让/index 和/login 页面能显示可能存在的闪回消息,就需要在加载页面的时候服务端从客户端发送过来的Cookie信息中读取,如果有,就进行显示,同时重新将该Cookie设置为过期,以从客户端进行清除。
这样做的好处在于这种“闪回消息”只会出现一次,比如登录成功后,在首页看到相关欢迎信息,但如果此时按F5 刷新一下首页,就能发现该消息没了。可以将这种“闪回消息”看做是某种“一次性消息”,就像我在前面说的,这实际上是利用读写Cookie和设置Cookie的过期时间来实现的。
好了,关于处理HTTP请求的内容就到这里了,我好久没写过这么长的文章了。
谢谢阅读。
参考资料
|