Go语言编程笔记14:处理请求
上一篇Go语言编程笔记13:处理器中我们讨论了如何创建一个Web应用并接收请求,本篇文章探讨如何来处理请求。
Request
在Go语言编程笔记12:web基础中我们说过了,一个HTTP请求实际上就是一个HTTP请求报文,内容主要由首行、报文头、空行、报文体四个部分组成。
在http
库中,请求报文被抽象为http.Request
这个结构体:
type Request struct {
Method string
URL *url.URL
Proto string // "HTTP/1.0"
ProtoMajor int // 1
ProtoMinor int // 0
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 // encoded opaque data
User *Userinfo // username and password information
Host string // host or host:port
Path string // path (relative paths may omit leading slash)
RawPath string // encoded path hint (see EscapedPath method)
ForceQuery bool // append a query ('?') even if RawQuery is empty
RawQuery string // encoded query values, without '?'
Fragment string // fragment for references, without '#'
RawFragment string // encoded fragment hint (see EscapedFragment method)
}
这个结构体中的字段其实对应下面这样的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"][