第4章 处理请求

本章主要内容

在前一章,我们学习了如何使用Go语言内置的net/http 库创建Web应用服务器,并藉此了解了处理器、处理器函数以及多路复用器。在学会了如何接收请求并将请求转发给相应的处理器之后,本章我们要学习的是如何使用Go提供的工具来处理请求,以及如何把响应回传给客户端。

本书的第1章对HTTP报文做了不少介绍,为了加深印象、防止遗忘,让我们先来回顾一下这方面的知识。HTTP报文是在客户端和服务器之间传递的消息,它分为HTTP请求和HTTP响应两种类型,并且这两种类型的报文都拥有相同的结构:

(1)请求行或者响应行;

(2)零个或多个首部;

(3)一个空行;

(4)一个可选的报文主体。

下面是一个GET 请求的例子:

GET /Protocols/rfc2616/rfc2616.html HTTP/1.1
Host: www.w3.org
User-Agent: Mozilla/5.0
(empty line)

Go语言的net/http 库提供了一系列用于表示HTTP报文的结构,为了学习如何使用这个库处理请求和发送响应,我们必须对这些结构有所了解。首先,让我们来看看net/http 库中代表HTTP请求报文的Request 结构。

Request 结构表示一个由客户端发送的HTTP请求报文。虽然HTTP请求报文是由一系列文本行组成的,但Request 结构并不是完全按照报文逐字逐句定义的。实际情况是,这个结构只包含了报文在经过语法分析之后,其中较为重要的信息;除此之外,这个结构还有一系列相应的方法可供使用。

Request 结构主要由以下部分组成:

通过Request 结构的方法,用户还可以对请求报文中的cookie、引用URL以及用户代理进行访问。当net/http 库被用作HTTP客户端的时候,Request 结构既可以用于表示客户端将要发送给服务器的请求,也可以用于表示服务器接收到的客户端请求。

Request 结构中的URL 字段用于表示请求行中包含的URL(请求行也就是HTTP请求报文的第一行),这个字段是一个指向url.URL 结构的指针,代码清单4-1展示了这个结构的定义。

代码清单4-1 URL 结构

type URL struct {
  Scheme  string
  Opaque  string
  User   *Userinfo
  Host   string
  Path   string
  RawQuery string
  Fragment string
}

URL的一般格式为:

scheme://[userinfo@]host/path[?query][#fragment]

那些在scheme 之后不带斜线的URL则会被解释为:

scheme:opaque[?query][#fragment]

在开发Web应用的时候,我们常常会让客户端通过URL的查询参数向服务器传递信息,而URL结构的RawQuery 字段记录的就是客户端向服务器传递的查询参数字符串。举个例子,如果客户端向地址http://www.example.com/post?id=123&thread_id=456 发送一个请求,那么RawQuery 字段的值就会被设置为id=123&thread_id=456 。虽然通过对RawQuery 字段的值进行语法分析可以获取到键值对格式的查询参数,但直接使用Request 结构的Form 字段来获取这些键值对会更方便一些。本章稍后就会对Request 结构的Form 字段、PostForm 字段和MultipartForm 字段进行介绍。

另外需要注意的一点是,如果请求报文是由浏览器发送的,那么程序将无法通过URL 结构的Fragment 字段获取URL的片段部分。本书在第1章中就提到过,浏览器在向服务器发送请求之前,会将URL中的片段部分剔除掉——因为服务器接收到的都是不包含片段部分的URL,所以程序自然也无法通过Fragment 字段去获取URL的片段部分了,造成这个问题的原因在于浏览器,与我们正在使用的net/http 库无关。URL 结构的Fragment 字段之所以会存在,是因为并非所有请求都来自浏览器:除了浏览器发送的请求之外,服务器还可能会接收到HTTP客户端库、Angular这样的客户端框架或者某些其他工具发送的请求;此外别忘了,不仅服务器程序可以使用Request 结构,客户端库也同样可以把Request 结构用作自己的一部分。

请求和响应的首部都使用Header 类型描述,这种类型使用一个映射来表示HTTP首部中的多个键值对。Header 类型拥有4种基本方法,这些方法可以根据给定的键执行添加、删除、获取和设置值等操作。

一个Header 类型的实例就是一个映射,这个映射的键为字符串,而键的值则是由任意多个字符串组成的切片。为Header 类型设置首部以及添加首部都是非常简单的,但了解这两种操作之间的区别有助于更好地理解Header 类型的构造:在对Header 执行设置操作时,给定键的值首先会被设置成一个空白的字符串切片,然后该切片中的第一个元素会被设置成给定的首部值;而在对Header 执行添加操作时,给定的首部值会被添加到字符串切片已有元素的后面,如图4-1所示。

04-01

图4-1 一个首部就是一个映射,这个映射的键为字符串,值为字符串切片

代码清单4-2展示了读取请求首部的方法。

代码清单4-2 读取请求首部

package main

import (
  "fmt"
  "net/http"
)

func headers(w http.ResponseWriter, r *http.Request) {
  h := r.Header
  fmt.Fprintln(w, h)
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/headers", headers)
  server.ListenAndServe()
}

这个代码清单中展示的服务器跟我们在第3章看到过的服务器基本上是一样的,唯一的区别在于这个服务器会把请求的首部打印出来。图4-2展示了在OS X系统的Safari浏览器上访问这个服务器的结果。

04-02

图4-2 在浏览器上展示的首部打印结果

如果想要获取的是某个特定的首部,而不是请求的所有首部,那么可以把服务器中的

h := r.Header

替换成

h := r.Header["Accept-Encoding"]

这样一来,程序就会得到"Accept-Encoding" 键的首部值:

[gzip, deflate]

除此之外,我们还可以使用以下语句:

h := r.Header.Get("Accept-Encoding")

并得到以下结果:

gzip, deflate

注意以上两条语句之间的区别:直接引用 Header 将得到一个字符串切片,而在Header 上调用Get 方法将返回字符串形式的首部值,其中多个首部值将使用逗号分隔。

请求和响应的主体都由Request 结构的Body 字段表示,这个字段是一个io.Read Closer接口,该接口既包含了Reader 接口,也包含了Closer 接口。其中Reader 接口拥有Read 方法,这个方法接受一个字节切片为输入,并在执行之后返回被读取内容的字节数以及一个可选的错误作为结果;而Closer 接口则拥有Close 方法,这个方法不接受任何参数,但会在出错时返回一个错误。同时包含Reader 接口和Closer 接口意味着用户可以对Body 字段调用Read 方法和Close 方法。作为例子,代码清单4-3展示了如何使用Read 方法读取请求主体的内容。

代码清单4-3 读取请求主体中的数据

package main

import (
  "fmt"
  "net/http"
)

func body(w http.ResponseWriter, r *http.Request) {
  len := r.ContentLength
  body := make([]byte, len)
  r.Body.Read(body)
  fmt.Fprintln(w, string(body))
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/body", body)
  server.ListenAndServe()
}

这段程序首先通过ContentLength 方法获取主体数据的字节长度,接着根据这个长度创建一个字节数组,然后调用Read 方法将主体数据读取到字节数组中。

因为GET 请求并不包含报文主体,所以如果我们想要测试这个服务器,就需要给它发送POST 请求。正如之前所说,浏览器一般需要通过HTML表单才能发送POST 请求,但是因为本书在下一节才会开始介绍HTML表单,所以这里我们暂且就先使用HTTP客户端来测试服务器。市面上可用的HTTP客户端非常多,既有桌面版的图形HTTP客户端,也有浏览器插件或者扩展,还有cURL等命令行程序可供选择。

作为例子,以下命令展示了如何使用cURL向服务器发送一条POST 请求:

$ curl -id "first_name=sausheong&last_name=chang" 127.0.0.1:8080/body

cURL在接收到响应之后将向用户返回一段完整并且未经处理的HTTP响应,其中位于空行之后的就是HTTP的主体。以下展示的就是上面的cURL命令返回的响应:

HTTP/1.1 200 OK
Date: Tue, 13 Jan 2015 16:11:58 GMT
Content-Length: 37
Content-Type: text/plain; charset=utf-8

first_name=sausheong&last_name=chang

因为Go语言提供了诸如FormValueFormFile 这样的方法来提取通过POST 方法提交的表单,所以用户一般不需要自行读取主体中未经处理的表单,本章接下来的一节就会介绍FormValueFormFile 等方法。

在学习如何从POST 请求中获取表单数据之前,让我们先来了解一下HTML表单。在绝大多数情况下,POST 请求都是通过HTML表单发送的,这些表单看上去通常会是下面这个样子:

<form action="/process" method="post">
 <input type="text" name="first_name"/>
 <input type="text" name="last_name"/>
 <input type="submit"/>
</form>

<form> 标签可以包围文本行、文本框、单选按钮、复选框以及文件上传等多种HTML表单元素,而用户则可以把想要传递给服务器的数据输入到这些元素里面。当用户按下发送按钮、又或者通过某种方式触发了表单的发送操作之后,用户在表单中输入的数据就会被发送至服务器。

用户在表单中输入的数据会以键值对的形式记录在请求的主体中,然后以HTTP POST 请求的形式发送至服务器。因为服务器在接收到浏览器发送的表单数据之后,还需要对这些数据进行语法分析,从而提取出数据中记录的键值对,因此我们还需要知道这些键值对在请求主体中是如何格式化的。

HTML表单的内容类型(content type)决定了POST 请求在发送键值对时将使用何种格式,其中,HTML表单的内容类型是由表单的enctype 属性指定的:

<form action="/process" method="post" enctype="application/x-www-form-urlencoded">
 <input type="text" name="first_name"/>
 <input type="text" name="last_name"/>
 <input type="submit"/>
</form>

enctype 属性的默认值为application/x-www-form-urlencoded 。浏览器至少需要支持application/x-www-form-urlencodedmultipart/form-data 这两种编码方式。除以上两种编码方式之外,HTML5还支持text/plain 编码方式。

如果我们把enctype 属性的值设置为application/x-www-form-urlencoded ,那么浏览器将把HTML表单中的数据编码为一个连续的“长查询字符串”(long query string):在这个字符串中,不同的键值对将使用& 符号分隔,而键值对中的键和值则使用等号= 分隔。这种编码方式跟我们在第1章看到过的URL编码是一样的,application/x-www-form- urlencoded编码名字中的urlencoded 一词也由此而来。换句话说,一个application/x- www- form-urlencoded 编码的HTTP请求主体看上去将会是下面这个样子的:

first_name=sau%20sheong&last_name=chang

但是,如果我们把enctype 属性的值设置为multipart/form-data ,那么表单中的数据将被转换成一条MIME报文:表单中的每个键值对都构成了这条报文的一部分,并且每个键值对都带有它们各自的内容类型以及内容配置(disposition)。以下是一个使用multipart/form-data 编码对表单数据进行格式化的例子:

------WebKitFormBoundaryMPNjKpeO9cLiocMw
 Content-Disposition: form-data; name="first_name"

sau sheong
 ------WebKitFormBoundaryMPNjKpeO9cLiocMw
 Content-Disposition: form-data; name="last_name"

 chang
 ------WebKitFormBoundaryMPNjKpeO9cLiocMw--

既然表单同时支持application/x-www-form-urlencoded 编码和multipart/form-data 编码,那么我们该选择使用哪种编码呢?答案是,如果表单传送的是简单的文本数据,那么使用URL编码格式更好,因为这种编码更为简单、高效,并且它所需的计算量要比另一种编码少。但是,如果表单需要传送大量数据(如上传文件)那么使用multipart /form- data编码格式会更好一些。在需要的情况下,用户还可以通过Base64编码,以文本方式传送二进制数据。

到目前为止,我们只讨论了如何通过POST 请求发送表单,但实际上通过GET 请求也是可以发送表单的——因为HTML表单的method 属性的值既可以是POST 也可以是GET ,所以下面这个HTML表单也是合法的:

<form action="/process" method="get">
 <input type="text" name="first_name"/>
 <input type="text" name="last_name"/>
 <input type="submit"/>
</form>

因为GET 请求并不包含请求主体,所以在使用GET 方法传递表单时,表单数据将以键值对的形式包含在请求的URL里面,而不是通过主体传递。

在了解了HTML表单向服务器传递数据的方法之后,让我们回到服务器一端,学习一下如何使用net/http 库来处理这些表单数据。

上一节曾经提到过,为了提取表单传递的键值对数据,用户可能需要亲自对服务器接收到的未经处理的表单数据进行语法分析。但事实上,因为net/http 库已经提供了一套用途相当广泛的函数,这些函数一般都能够满足用户对数据提取方面的需求,所以我们很少需要自行对表单数据进行语法分析。

通过调用Request 结构提供的方法,用户可以将URL、主体又或者以上两者记录的数据提取到该结构的FormPostFormMultipartForm 等字段当中。跟我们平常通过POST 请求获取到的数据一样,存储在这些字段里面的数据也是以键值对形式表示的。使用Request 结构的方法获取表单数据的一般步骤是:

(1)调用ParseForm 方法或者ParseMultipartForm 方法,对请求进行语法分析。

(2)根据步骤1调用的方法,访问相应的Form 字段、PostForm 字段或MultipartForm 字段。

代码清单4-4展示了一个使用ParseForm 方法对表单进行语法分析的例子。

代码清单4-4 对表单进行语法分析

package main

import (
  "fmt"
  "net/http"
)

func process(w http.ResponseWriter, r *http.Request) {
  r.ParseForm()
  fmt.Fprintln(w, r.Form)
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/process", process)
  server.ListenAndServe()
}

这段代码中最重要的就是下面这两行:

r.ParseForm()
fmt.Fprintln(w, r.Form)

如前所述,这段代码首先使用了ParseForm 方法对请求进行语法分析,然后再访问Form 字段,获取具体的表单。

现在,让我们来创建一个短小精悍的HTML表单,并使用它作为客户端,向代码清单4-4所示的服务器发送请求。请创建一个名为client.html 的文件,并将以下代码复制到该文件中:

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <title>GoWebProgramming</title>
 </head>
 <body>
  <form action=http://127.0.0.1:8080/process?hello=world&thread=123 
  ➥method="post" enctype="application/x-www-form-urlencoded">
   <input type="text" name="hello" value="sau sheong"/>
   <input type="text" name="post" value="456"/>
   <input type="submit"/>
  </form>
 </body>
</html>

这个HTML表单可以完成以下工作:

需要注意的是,这个表单为相同的键hello 提供了两个不同的值,其中,值world 是通过URL提供的,而值sau sheong 则是通过HTML表单中的文本输入行提供的。

因为客户端可以直接在浏览器上运行,所以我们并不需要使用服务器来为客户端提供服务:我们要做的就是使用浏览器打开client.html 文件,然后点击表单中的发送按钮。如果一切正常,浏览器应该会显示以下输出:

map[thread:[123] hello:[sau sheong world] post:[456]]

这是服务器在对请求进行语法分析之后,使用字符串形式显示出来的未经处理的Form 结构。这个结构是一个映射,它的键是字符串,而键的值是一个由字符串组成的切片。因为映射是无序的,所以你看到的键值对排列顺序可能和这里展示的有所不同。但是无论如何,这个映射总是会包含查询值hello=worldthread=123 ,还有表单值hello=sau sheongpost=456 。正如所见,这些值都进行了相应的URL解码,比如在sausheong 之间就能够正常地看到空格,而不是编码之后的%20

对上一节提到的post 这种只会出现在表单或者URL两者其中一个地方的键来说,执行语句r.Form["post"] 将返回一个切片,切片里面包含了这个键的表单值或者URL值,就像这样:[456] 。而对hello 这种同时出现在表单和URL两个地方的键来说,执行语句r.Form["hello"] 将返回一个同时包含了键的表单值和URL值的切片,并且表单值在切片中总是排在URL值的前面,就像这样:[sau sheong world]

如果一个键同时拥有表单键值对和URL键值对,但是用户只想要获取表单键值对而不是URL键值对,那么可以访问Request 结构的PostForm 字段,这个字段只会包含键的表单值,而不包含任何同名键的URL值。举个例子,如果我们把前面代码中的r.Form 语句改为r.PostForm 语句,那么程序将打印出以下结果:

map[post:[456] hello:[sau sheong]]

上面这个输出使用的是application/x-www-form-urlencoded 内容类型,如果我们修改一下客户端的HTML表单,让它使用multipart/form-data 作为内容类型,并对服务器代码进行调整,让它重新使用r.Form 语句而不是r.PostForm 语句,那么程序将打印出以下结果:

map[hello:[world] thread:[123]]

因为PostForm 字段只支持application/x-www-form-urlencoded 编码,所以现在的r.Form 语句将不再返回任何表单值,而是只返回URL查询值。为了解决这个问题,我们需要通过MultipartForm 字段来获取multipart/form-data 编码的表单数据。

为了取得multipart/form-data 编码的表单数据,我们需要用到Request 结构的ParseMultipartForm 方法和MultipartForm 字段,而不再使用ParseForm 方法和Form 字段,不过ParseMultipartForm 方法在需要时也会自行调用ParseForm 方法。现在,我们需要修改代码清单4-4中展示的服务器程序,把原来的ParseForm 方法调用以及打印语句替换成以下两条语句:

r.ParseMultipartForm(1024)
fmt.Fprintln(w, r.MultipartForm)

这里的第一行代码说明了我们想要从multipart编码的表单里面取出多少字节的数据,而第二行语句则会打印请求的MultipartForm 字段。修改后的服务器在执行时将打印以下结果:

&{map[hello:[sau sheong] post:[456]] map[]}

因为MultipartForm 字段只包含表单键值对而不包含URL键值对,所以这次打印出来的只有表单键值对而没有URL键值对。另外需要注意的是,MultipartForm 字段的值也不再是一个映射,而是一个包含了两个映射的结构,其中第一个映射的键为字符串,值为字符串组成的切片,而第二个映射则是空的——这个映射之所以会为空,是因为它是用来记录用户上传的文件的,关于这个映射的具体信息我们将会在接下来的一节看到。

除了上面提到的几个方法之外,Request 结构还提供了另外一些方法,它们可以让用户更容易地获取表单中的键值对。其中,FormValue 方法允许直接访问与给定键相关联的值,就像访问Form 字段中的键值对一样,唯一的区别在于:因为FormValue 方法在需要时会自动调用ParseForm 方法或者ParseMultipartForm 方法,所以用户在执行FormValue 方法之前,不需要手动调用上面提到的两个语法分析方法。

这意味着,如果我们把以下语句写到代码清单4-4所示的服务器程序中:

fmt.Fprintln(w,r.FormValue("hello"))

并将客户端表单的enctype 属性的值设置为application/x-www-form-urlencoded ,那么服务器将打印出以下结果:

sau sheong

因为FormValue 方法即使在给定键拥有多个值的情况下,也只会从Form 结构中取出给定键的第一个值,所以如果想要获取给定键包含的所有值,那么就需要直接访问Form 结构:

fmt.Fprintln(w, r.FormValue("hello"))
fmt.Fprintln(w, r.Form)

上面这两条语句将产生以下输出:

sau sheong
map[post:[456] hello:[sau sheong world] thread:[123]]

除了访问的是PostForm 字段而不是Form 字段之外,PostFormValue 方法的作用跟上面介绍的FormValue 方法的作用基本相同。下面是一个使用PostFormValue 方法的例子:

fmt.Fprintln(w, r.PostFormValue("hello"))
fmt.Fprintln(w, r.PostForm)

下面是这两行代码的输出结果:

sau sheong
map[hello:[sau sheong] post:[456]]

正如结果所示,PostFormValue 方法只会返回表单键值对而不会返回URL键值对。

FormValue 方法和PostFormValue 方法都会在需要时自动去调用ParseMultipartForm 方法,因此用户并不需要手动调用ParseMultipartForm 方法,但这里也有一个需要注意的地方(至少对于Go 1.4版本来说):如果你将表单的enctype 设置成了multipart/form-data ,然后尝试通过FormValue 方法或者PostFormValue 方法来获取键的值,那么即使这两个方法调用了ParseMultipartForm 方法,你也不会得到任何结果。

为了验证这一点,让我们再次修改服务器程序,给它加上以下代码:

fmt.Fprintln(w, "(1)", r.FormValue("hello"))
fmt.Fprintln(w, "(2)", r.PostFormValue("hello"))
fmt.Fprintln(w, "(3)", r.PostForm)
fmt.Fprintln(w, "(4)", r.MultipartForm)

以下是在表单的enctypemultipart/form-data 的情况下,服务器打印出的结果:

(1) world
(2)
(3) map[]
(4) &{map[hello:[sau sheong] post:[456]] map[]}

结果中的第一行返回的是键hello 的值,并且这个值来自URL而不是表单。至于结果中的第二行和第三行,则证明了前面提到的“使用PostFormValue 方法不会得到任何值”这一说法,而PostForm 字段为空则是引发这一现象的罪魁祸首。PostForm 字段之所以会为空,是因为FormValue 方法和PostFormValue 方法分别对应Form 字段和PostForm 字段,而表单在使用multipart/form-data 编码时,表单数据将被存储到MultipartForm 字段而不是以上两个字段中。结果的最后一行证明ParseMultipartForm 方法的确被调用了——用户只要访问MultipartForm 字段,就可以取得所有表单值。

本节介绍了Request 结构的很多相关字段以及方法,表4-1对它们进行了回顾,并阐述了各个方法之间的区别。除此之外,这个表还说明了调用哪个方法可以取得哪个字段的值,并阐述了这些值的来源以及这些值的类型。比如,表的第一行就说明了,通过以直接或间接的方式调用ParseForm 方法,用户可以将数据存储到Form 字段里面,然后用户只要访问Form 字段,就可以取得编码类型为application/x-www-form-urlencoded 的URL数据和表单数据。对表4-1中列出的字段以及方法来说,它们唯一令人感到遗憾的地方就是,这些字段以及方法的命名规范并不是特别让人满意,还有很多有待改善的地方。

表4-1 对比Form、PostFormMultipartForm 字段

字段

需要调用的方法或
需要访问的字段

键值对的来源

内容类型

URL

表单

URL编码

Multipart编码

Form

ParseForm 方法

PostForm

Form 字段

MultipartForm

ParseMultipartForm 方法

FormValue

PostFormValue

multipart/form-data 编码通常用于实现文件上传功能,这种功能需要用到file 类型的input 标签。代码清单4-5给出的就是之前展示过的客户端表单在实现了文件上传功能之后的样子,其中以加粗方式呈现的是新增或者经过修改的代码。

代码清单4-5 文件上传

< html>
 < head>
  < meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  < title>Go Web Programming< /title>
 < /head>
 < body>
  < form action="http://localhost:8080/process?hello=world&thread=123"
 method="post" enctype="multipart/form-data">


   < input type="text" name="hello" value="sau sheong"/>
   < input type="text" name="post" value="456"/>
   < input type="file" name="uploaded">


   < input type="submit">
  < /form>
 < /body>
< /html>

为了能够接收表单上传的文件,处理器函数也需要做相应的修改,具体见代码清单4-6。

代码清单4-6 通过MultipartForm 字段接收用户上传的文件

package main

import (
  "fmt"
  "io/ioutil"
  "net/http"
)

func process(w http.ResponseWriter, r *http.Request) {
  r.ParseMultipartForm(1024)
  fileHeader := r.MultipartForm.File["uploaded"][0]
  file, err := fileHeader.Open()
  if err == nil {
    data, err := ioutil.ReadAll(file)
    if err == nil {
      fmt.Fprintln(w, string(data))
    }
  }
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/process", process)
  server.ListenAndServe()
}

正如之前所说,服务器在处理文件上传时首先要做的就是执行ParseMultipartForm 方法,接着从MultipartForm 字段的File 字段里面取出文件头FileHeader ,然后通过调用文件头的Open 方法来打开文件。在此之后,服务器会将文件的内容读取到一个字节数组中,并将这个字节数组的内容打印出来。现在,如果我们向服务器上传一个纯文本文件,那么服务器将把这个文件的内容打印在浏览器上。

FormValue 方法和PostFormValue 方法类似,net/http 库也提供了一个FormFile 方法,它可以快速地获取被上传的文件:FormFile 方法在被调用时将返回给定键的第一个值,因此它在客户端只上传了一个文件的情况下,使用起来会非常方便。代码清单4-7展示了一个使用FormFile 方法的例子。

代码清单4-7 使用FormFile 方法获取被上传的文件

func process(w http.ResponseWriter, r *http.Request) {
  file, _, err := r.FormFile("uploaded")
  if err == nil {
    data, err := ioutil.ReadAll(file)
    if err == nil {
      fmt.Fprintln(w, string(data))
    }
  }
}

正如代码所示,FormFile 方法将同时返回文件和文件头作为结果。用户在使用FormFile 方法时,将不再需要手动调用ParseMultipartForm 方法,只需要对返回的文件进行处理即可。

因为前面的内容一直只使用HTML表单发送POST请求,所以到目前为止,我们考虑的都是如何处理请求主体中的键值对。但实际上,POST请求并不是只能通过HTML表单发送:诸如jQuery这样的客户端库,又或者是Angular、Ember这样的客户端框架,甚至是Adobe Flash、Microsoft Silverlight这样的技术,都能够发送POST请求,并且这种行为正在变得越来越常见。

需要注意的是,使用ParseForm 方法是无法从Angular客户端发送的POST请求中获取JSON数据的,但使用jQuery这样的JavaScript库却不会出现这样的问题。

造成这一区别的原因在于,不同客户端使用了不同的方式编码POST请求:jQuery会像HTML表单一样,使用application/x-www-form-urlencoded 对POST请求进行编码(具体做法是,jQuery会把POST请求的Content-Type 首部的值设置为application/x-www-form-urlencoded ),而Angular在编码POST请求时使用的却是application/json 。因为Go语言的ParseForm 方法只会对表单数据进行语法分析,它并不接受application/json 编码,所以使用这一编码发送POST请求的用户自然也无法通过ParseForm 方法获得任何数据。

这个问题跟库的实现无关,真正的罪魁祸首实际上是没有足够的文档对这种行为进行说明,而程序员又对他们使用的框架做了某种假设,这样一来,问题自然而然地也就出现了。

因为框架可以隐藏复杂性和实现细节,所以程序员应该使用框架。但与此同时,理解框架的工作方式,了解框架如何化繁为简,也是非常重要的。否则,在使用框架与其他程序进行对接的时候,就可能会出现各种各样的问题。

到目前为止,本章已经对“如何处理请求”这一问题做了足够多的介绍,现在,是时候讲讲如何向用户发送响应了。

首先创建一个Response 结构,接着将数据存储到这个结构里面,最后将这个结构返回给客户端——如果你认为服务器是通过这种方式向客户端返回响应的,那么你就错了:服务器在向客户端返回响应的时候,真正需要用到的是ResponseWriter 接口。

ResponseWriter 是一个接口,处理器可以通过这个接口创建HTTP响应。ResponseWriter 在创建响应时会用到http.response 结构,因为该结构是一个非导出(nonexported)的结构,所以用户只能通过ResponseWriter 来使用这个结构,而不能直接使用它。

为什么要以传值的方式将ResponseWriter传递给ServeHTTP

 

在阅读了本章前面的内容之后,有的读者可能会感到疑惑——ServeHTTP 为什么要接受ResponseWriter 接口和一个指向Request 结构的指针作为参数呢?接受Request 结构指针的原因很简单:为了让服务器能够察觉到处理器对Request 结构的修改,我们必须以传引用(pass by reference)而不是传值(pass by value)的方式传递Request 结构。但是另一方面,为什么ServeHTTP 却是以传值的方式接受ResponseWriter 呢?难道服务器不需要知道处理器对ResponseWriter 所做的修改吗?

对于这个问题,如果我们深入探究net/http 库的源码,就会发现ResponseWriter 实际上就是response 这个非导出结构的接口,而ResponseWriter 在使用response 结构时,传递的也是指向response 结构的指针,这也就是说,ResponseWriter 是以传引用而不是传值的方式在使用response 结构。

换句话说,实际上ServeHTTP 函数的两个参数传递的都是引用而不是值——虽然ResponseWriter 看上去像是一个值,但它实际上却是一个带有结构指针的接口。

ResponseWriter 接口拥有以下3个方法:

Write 方法接受一个字节数组作为参数,并将数组中的字节写入HTTP响应的主体中。如果用户在使用Write 方法执行写入操作的时候,没有为首部设置相应的内容类型,那么响应的内容类型将通过检测被写入的前512字节决定。代码清单4-8展示了Write 方法的用法。

代码清单4-8 使用Write 方法向客户端发送响应

package main

import (
  "net/http"
)

func writeExample(w http.ResponseWriter, r *http.Request) {
  str := `<html>
<head><title>Go Web Programming</title></head>
<body><h1>Hello World</h1></body>
</html>`
  w.Write([]byte(str))
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/write", writeExample)
  server.ListenAndServe()
}

这段代码通过调用Write 方法将一段HTML字符串写入了HTTP响应的主体中。通过向服务器发送以下命令:

curl -i 127.0.0.1:8080/write

我们可以得到以下响应:

HTTP/1.1 200 OK
Date: Tue, 13 Jan 2015 16:16:13 GMT
Content-Length: 95
Content-Type: text/html; charset=utf-8

<html>
<head><title>GoWebProgramming</title></head>
<body><h1>Hello World</h1></body>
</html>

注意,尽管我们没有亲自为响应设置内容类型,但程序还是通过检测自动设置了正确的内容类型。

WriteHeader 方法的名字带有一点儿误导性质,它并不能用于设置响应的首部(Header 方法才是做这件事的):WriteHeader 方法接受一个代表HTTP响应状态码的整数作为参数,并将这个整数用作HTTP响应的返回状态码;在调用这个方法之后,用户可以继续对ResponseWriter 进行写入,但是不能对响应的首部做任何写入操作。如果用户在调用Write 方法之前没有执行过WriteHeader 方法,那么程序默认会使用200 OK作为响应的状态码。

WriteHeader 方法在返回错误状态码时特别有用:如果你定义了一个API,但是尚未为其编写具体的实现,那么当客户端访问这个API的时候,你可能会希望这个API返回一个501 Not Implemented,状态码,代码清单4-9通过添加新的处理器实现了这一需求。顺带一提,千万别忘了使用HandleFunc 方法将新处理器绑定到DefaultServeMux 多路复用器里面!

代码清单4-9 通过WriteHeader 方法将状态码写入到响应当中

package main

import (
  "fmt"
  "net/http"
)

func writeExample(w http.ResponseWriter, r *http.Request) {
  str := `<html>
<head><title>Go Web Programming</title></head>
<body><h1>Hello World</h1></body>
</html>`
  w.Write([]byte(str))
}

func writeHeaderExample(w http.ResponseWriter, r *http.Request) {
  w.WriteHeader(501)
  fmt.Fprintln(w, "No such service, try next door")
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/write", writeExample)
  http.HandleFunc("/writeheader", writeHeaderExample)
  server.ListenAndServe()
}

通过cURL访问刚刚添加的新处理器:

curl -i 127.0.0.1:8080/writeheader

我们将得到以下响应:

HTTP/1.1 501 Not Implemented
Date: Tue, 13 Jan 2015 16:20:29 GMT
Content-Length: 31
Content-Type: text/plain; charset=utf-8

No such service, try next door

最后,通过调用Header 方法可以取得一个由首部组成的映射(关于首部的具体细节在4.1.3节曾经讲过),修改这个映射就可以俢改首部,修改后的首部将被包含在HTTP响应里面,并随着响应一同发送至客户端。

代码清单4-10 通过编写首部实现客户端重定向

package main

import (
  "fmt"
  "net/http"
)

func writeExample(w http.ResponseWriter, r *http.Request) {
  str := `<html>
<head><title>Go Web Programming</title></head>
<body><h1>Hello World</h1></body>
</html>`
  w.Write([]byte(str))
}

func writeHeaderExample(w http.ResponseWriter, r *http.Request) {
  w.WriteHeader(501)
  fmt.Fprintln(w, "No such service, try next door")
}

func headerExample(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Location", "http://google.com")
  w.WriteHeader(302)
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/write", writeExample)
  http.HandleFunc("/writeheader", writeHeaderExample)
  http.HandleFunc("/redirect", headerExample)
  server.ListenAndServe()
}

代码清单4-10向我们展示了如何实现一次HTTP重定向:除了将状态码设置成了302 之外,它还给响应添加了一个名为Location 的首部,并将这个首部的值设置成了重定向的目的地。需要注意的是,因为WriteHeader 方法在执行完毕之后就不允许再对首部进行写入了,所以用户必须先写入Location 首部,然后再写入状态码。现在,如果我们在浏览器里面访问这个处理器,那么浏览器将被重定向到Google。

另一方面,如果我们使用cURL访问这个处理器:

curl -i 127.0.0.1:8080/redirect

那么cURL将获得以下响应:

HTTP/1.1 302 Found
Location: http://google.com
Date: Tue, 13 Jan 2015 16:22:16 GMT
Content-Length: 0
Content-Type: text/plain; charset=utf-8

最后,让我们来学习一下通过ResponseWriter 直接向客户端返回JSON数据的方法。代码清单4-11展示了如何以JSON格式将一个名为Post 的结构返回给客户端。

代码清单4-11 编写JSON输出

package main

import (
  "fmt"
  "encoding/json"
  "net/http"
)

type Post struct {
  User  string
  Threads []string
}

func writeExample(w http.ResponseWriter, r *http.Request) {
  str := `<html>
<head><title>Go Web Programming</title></head>
<body><h1>Hello World</h1></body>
</html>`
  w.Write([]byte(str))
}

func writeHeaderExample(w http.ResponseWriter, r *http.Request) {
  w.WriteHeader(501)
  fmt.Fprintln(w, "No such service, try next door")
}

func headerExample(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Location", "http://google.com")
  w.WriteHeader(302)
}

func jsonExample(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Content-Type", "application/json")
  post := &Post{
    User:  "Sau Sheong",
    Threads: []string{"first", "second", "third"},
  }
  json, _ := json.Marshal(post)
  w.Write(json)
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/write", writeExample)
  http.HandleFunc("/writeheader", writeHeaderExample)
  http.HandleFunc("/redirect", headerExample)
  http.HandleFunc("/json", jsonExample)
  server.ListenAndServe()
}

这段代码中的jsonExample 处理器就是这次的主角。因为本书将在第7章进一步介绍JSON格式,所以不了解JSON格式的读者也不必过于担心,目前来说,你只需要知道变量json 是一个由Post 结构序列化而成的JSON字符串就可以了。

这段程序首先使用Header 方法将内容类型设置成application/json ,然后调用Write 方法将JSON字符串写入ResponseWriter 中。现在,如果我们执行cURL命令:

curl -i 127.0.0.1:8080/json

那么它将返回以下响应:

HTTP/1.1 200 OK
Content-Type: application/json
Date: Tue, 13 Jan 2015 16:27:01 GMT
Content-Length: 58

{"User":"Sau Sheong","Threads":["first","second","third"]}

本书在第2章曾经简单地介绍过如何使用cookie创建身份验证会话,本节将在前文的基础上,更加深入地研究cookie的使用方法,并把cookie应用在更为常见的客户端持久化场景中,而不仅仅用它创建会话。

cookie是一种存储在客户端的、体积较小的信息,这些信息最初都是由服务器通过HTTP响应报文发送的。每当客户端向服务器发送一个HTTP请求时,cookie都会随着请求被一同发送至服务器。cookie的设计本意是要克服HTTP的无状态性,虽然cookie并不是完成这一目的的唯一方法,但它却是最常用也最流行的方法之一:整个计算机行业的收入都建立在cookie机制之上,对互联网广告领域来说,更是如此。

cookie的种类有很多,其中一些还拥有非常有趣的名字,如超级cookie、第三方cookie以及僵尸cookie。但总的来说,大多数cookie都可以被划分为会话cookie和持久cookie两种类型,而其他类型的cookie通常都是持久cookie的变种。

cookie在Go语言里面用Cookie 结构表示,这个结构的定义如代码清单4-12所示。

代码清单4-12 Cookie 结构的定义

type Cookie struct {
 Name    string
 Value   string
 Path    string
 Domain   string
 Expires  time.Time
 RawExpires string
 MaxAge   int
 Secure   bool
 HttpOnly  bool
 Raw    string
 Unparsed  []string
}

没有设置Expires 字段的cookie通常称为会话cookie或者临时cookie,这种cookie在浏览器关闭的时候就会自动被移除。相对而言,设置了Expires 字段的cookie通常称为持久cookie,这种cookie会一直存在,直到指定的过期时间来临或者被手动删除为止。

Expires 字段和MaxAge 字段都可以用于设置cookie的过期时间,其中Expires 字段用于明确地指定cookie应该在什么时候过期,而MaxAge 字段则指明了cookie在被浏览器创建出来之后能够存活多少秒。之所以会出现这两种截然不同的过期时间设置方式,是因为不同浏览器使用了各不相同的cookie实现机制,跟Go语言本身的设计无关。虽然HTTP 1.1中废弃了Expires ,推荐使用MaxAge 来代替Expires ,但几乎所有浏览器都仍然支持Expires ;而且,微软的IE 6、IE 7和IE 8都不支持MaxAge 。为了让cookie在所有浏览器上都能够正常地运作,一个实际的方法是只使用Expires ,或者同时使用ExpiresMaxAge

Cookie 结构的String 方法可以返回一个经过序列化处理的cookie,其中Set-Cookie 响应首部的值就是由这些序列化之后的cookie组成的。代码清单4-13展示了如何使用String 方法去序列化cookie,以及如何将这些序列化之后的cookie发送至客户端。

代码清单4-13 向浏览器发送cookie

package main

import (
  "net/http"
)

func setCookie(w http.ResponseWriter, r *http.Request) {
  c1 := http.Cookie{
    Name:   "first_cookie",
    Value:  "Go Web Programming",
    HttpOnly: true,
  }
  c2 := http.Cookie{
    Name:   "second_cookie",
    Value:  "Manning Publications Co",
    HttpOnly: true,
  }
  w.Header().Set("Set-Cookie", c1.String())
  w.Header().Add("Set-Cookie", c2.String())
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/set_cookie", setCookie)
  server.ListenAndServe()
}

这段代码首先使用Set 方法添加第一个cookie,然后再使用Add 方法添加第二个cookie。现在,打开浏览器并访问http://127.0.0.1:8080/set_cookie,如果一切正常,你将在浏览器的Web Inspector(审查器)中看到图4-3所示的cookie。(图中展示的是Safari浏览器附带的Web Inspector,但无论使用的是什么浏览器,在相应工具中看到的cookie和这里展示的应该都是一样的。)

04-03

图4-3 使用Safari浏览器的Web Inspector查看之前设置的cookie

除了Set 方法和Add 方法之外,Go语言还提供了一种更为快捷方便的cookie设置方法,那就是使用net/http 库中的SetCookie 方法。作为例子,代码清单4-14展示了如何使用SetCookie 方法实现与代码清单4-13相同的设置操作,其中加粗展示的部分就是修改了的代码。

代码清单4-14 使用SetCookie 方法设置cookie

func setCookie(w http.ResponseWriter, r *http.Request) {
  c1 := http.Cookie{
    Name:   "first_cookie",
    Value:  "Go Web Programming",
    HttpOnly: true,
  }
  c2 := http.Cookie{
    Name: "second_cookie",
    Value: "Manning Publications Co",
    HttpOnly: true,
  }
  http.SetCookie(w, &c1)


  http.SetCookie(w, &c2)


}

这两种cookie设置方式区别并不大,唯一需要注意的是,在使用SetCookie 方法设置cookie时,传递给方法的应该是指向Cookie 结构的指针,而不是Cookie 结构本身。

在学习了如何将cookie存储到客户端之后,现在让我们来看看如何从客户端获取cookie,代码清单4-15展示了这一操作的具体实现方法。

代码清单4-15 从请求的首部获取cookie

package main

import (
  "fmt"
  "net/http"
)

func setCookie(w http.ResponseWriter, r *http.Request) {
  c1 := http.Cookie{
    Name:   "first_cookie",
    Value:  "Go Web Programming",
    HttpOnly: true,
  }
  c2 := http.Cookie{
    Name: "second_cookie",
    Value: "Manning Publications Co",
    HttpOnly: true,
  }
  http.SetCookie(w, &c1)
  http.SetCookie(w, &c2)
}

func getCookie(w http.ResponseWriter, r *http.Request) {
  h := r.Header["Cookie"]
  fmt.Fprintln(w, h)
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/set_cookie", setCookie)
  http.HandleFunc("/get_cookie", getCookie)
  server.ListenAndServe()
}

在重新编译并且重新启动这个服务器之后,使用浏览器访问http://127.0.0.1:8080/get_cookie,将会在浏览器上看到以下结果:

[first_cookie=Go Web Programming; second_cookie=Manning Publications Co]

语句r.Header["Cookie"] 返回了一个切片,这个切片包含了一个字符串,而这个字符串又包含了客户端发送的任意多个cookie。如果用户想要取得单独的键值对格式的cookie,就需要自行对r.Header["Cookie"] 返回的字符串进行语法分析。不过Go也提供了一些其他方法,让用户可以更容易地获取cookie,代码清单4-16展示了这一点。

代码清单4-16 使用Cookie 方法和Cookie 方法

package main

import (
  "fmt"
  "net/http"
)

func setCookie(w http.ResponseWriter, r *http.Request) {
  c1 := http.Cookie{
    Name: "first_cookie",
    Value: "Go Web Programming",
    HttpOnly: true,
  }
  c2 := http.Cookie{
    Name:   "second_cookie",
    Value:  "Manning Publications Co",
    HttpOnly: true,
  }
  http.SetCookie(w, &c1)
  http.SetCookie(w, &c2)
}

func getCookie(w http.ResponseWriter, r *http.Request) {
  c1, err := r.Cookie("first_cookie")


  if err != nil {
    fmt.Fprintln(w, "Cannot get the first cookie")
  }
  cs := r.Cookies()
  fmt.Fprintln(w, c1)
  fmt.Fprintln(w, cs)
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/set_cookie", setCookie)
  http.HandleFunc("/get_cookie", getCookie)
  server.ListenAndServe()
}

Go语言为Request 结构提供了一个Cookie 方法,正如代码清单4-16中的加粗行所示,这个方法可以获取指定名字的cookie。如果指定的cookie不存在,那么方法将返回一个错误。因为Cookie 方法只能获取单个cookie,所以如果想要同时获取多个cookie,就需要用到Request 结构的Cookies 方法:Cookies 方法可以返回一个包含了所有cookie的切片,这个切片跟访问Header 字段时获取的切片是完全相同的。在重新编译并且重新启动服务器之后,访问http://127.0.0.1:8080/get_cookie,浏览器将显示以下内容:

first_cookie=Go Web Programming
[first_cookie=Go Web Programming second_cookie=Manning Publications Co]

因为上面展示的代码在设置cookie时并没有为这些cookie设置相应的过期时间,所以它们都是会话cookie。为了证明这一点,我们只需要退出并重启浏览器(注意,不要只关闭浏览器的标签,一定要完全退出浏览器才可以),然后再次访问http://127.0.0.1:8080/get_cookie,就会发现之前设置的cookie已经消失了。

本书的第2章曾经介绍过如何使用cookie管理用户登录会话,在对cookie有了更多了解之后,现在是时候来考虑一下怎样把cookie应用到更多地方了。

为了向用户报告某个动作的执行情况,应用程序有时候会向用户展示一条简短的通知消息,比如说,如果一个用户尝试在论坛上发表一篇帖子,但是这篇帖子因为某种原因而发表失败了,那么论坛应该向这个用户展示一条帖子发布失败的消息。根据本书之前提到过的最小惊讶原则,这种通知消息应该出现在用户当前所在的页面,但是在通常情况下,用户在访问这个页面时却不应该看到这样的消息。因此,程序实际上要做的是在某个条件被满足时,才在页面上显示一条临时出现 的消息,这样用户在刷新页面之后就不会再看见相同的消息了——我们把这种临时出现的消息称为闪现消息 (flash message)。

实现闪现消息的方法有很多种,但最常用的方法是把这些消息存储在页面刷新时就会被移除的会话cookie里面,代码清单4-17展示了如何使用Go语言实现这一方法。

代码清单4-17 使用Go的cookie实现闪现消息

package main

import (
  "encoding/base64"
  "fmt"
  "net/http"
  "time"
)

func setMessage(w http.ResponseWriter, r *http.Request) {
  msg := []byte("Hello World!")
  c := http.Cookie{
    Name: "flash",
    Value: base64.URLEncoding.EncodeToString(msg),
  }
  http.SetCookie(w, &c)
}

func showMessage(w http.ResponseWriter, r *http.Request) {
  c, err := r.Cookie("flash")
  if err != nil {
    if err == http.ErrNoCookie {
      fmt.Fprintln(w, "No message found")
    }
  } else {
    rc := http.Cookie{
      Name: "flash",
      MaxAge: -1,
      Expires: time.Unix(1, 0),
    }
    http.SetCookie(w, &rc)
    val, _ := base64.URLEncoding.DecodeString(c.Value)
    fmt.Fprintln(w, string(val))
  }
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/set_message", setMessage)
  http.HandleFunc("/show_message", showMessage)
  server.ListenAndServe()
}

这段代码创建了setMessageshowMessage 两个处理器函数,并分别把它们与路径/set_message 以及/show_message 进行绑定。首先,让我们来看看setMessage 函数,它的定义非常简单直接,如代码清单4-18所示。

代码清单4-18 设置消息

func setMessage(w http.ResponseWriter, r *http.Request) {
  msg := []byte("Hello World!")
  c := http.Cookie{
    Name: "flash",
    Value: base64.URLEncoding.EncodeToString(msg),
  }
  http.SetCookie(w, &c)
}

setMessage 处理器函数的定义跟之前展示过的setCookie 处理器函数的定义非常相似,主要的区别在于setMessage 对消息使用了Base64URL编码,以此来满足响应首部对cookie值的URL编码要求。在设置cookie时,如果cookie的值没有包含诸如空格或者百分号这样的特殊字符,那么不对它进行编码也是可以的;但是因为在发送闪现消息时,消息本身通常会包含诸如空格这样的字符,所以对cookie的值进行编码就成了一件必不可少的事情了。

现在再来看看showMessage 函数的定义:

func showMessage(w http.ResponseWriter, r *http.Request) {
  c, err := r.Cookie("flash")
  if err != nil {
    if err == http.ErrNoCookie {
      fmt.Fprintln(w, "No message found")
    }
  } else {
    rc := http.Cookie{
      Name: "flash",
      MaxAge: -1,
      Expires: time.Unix(1, 0),
    }
    http.SetCookie(w, &rc)
    val, _ := base64.URLEncoding.DecodeString(c.Value)
    fmt.Fprintln(w, string(val))
  }
}

这个函数首先会尝试获取指定的cookie,如果没有找到该cookie,它就会把变量err 设置成一个http.ErrNoCookie 值,并向浏览器返回一条 No message found 消息。如果找到了这个cookie,那么它必须完成以下两个操作:

(1)创建一个同名的cookie,将它的MaxAge 值设置为负数,并且将Expires 值也设置成一个已经过去的时间;

(2)使用SetCookie 方法将刚刚创建的同名cookie发送至客户端。

初看上去,这两个操作的目的似乎是要替换已经存在的cookie,但实际上,因为新cookie的MaxAge 值为负数,并且Expires 值也是一个已经过去的时间,所以这样做实际上就是要完全地移除这个cookie。在设置完新cookie之后,程序会对存储在旧cookie中的消息进行解码,并通过响应返回这条消息。

现在,让我们实际运行这个服务器,然后打开浏览器并访问地址http://localhost:8080/set_ message。如果一切顺利,你将在WebInspector中看到图4-4所示的cookie。

04-04

图4-4 在Safari浏览器附带的WebInspector中查看已被编码的闪现消息

注意,因为图中cookie的值已经被Base64 URL编码过了,所以它初看上去就像乱码一样。不过我们只要使用浏览器访问http://localhost:8080/show_message,就可以看到解码之后的真正的消息:

Hello World!

如果你现在再去看WebInspector,就会发现之前设置的cookie已经消失了:通过设置同名的cookie,程序成功地使用新cookie代替了旧cookie;与此同时,因为新cookie的MaxAge 值为负数,并且它的Expires 值也是一个已经过去的时间,这相当于命令浏览器删除这个cookie,所以这个新设置的cookie也被移除了。

现在,如果刷新网页,或者再次访问http://localhost:8080/show_message,你将看到以下消息:

No message found

本章沿着上一章的脚步,介绍了net/http 在Web应用开发方面提供的服务器端功能,而接下来的一章将对Web应用的另一个主要组成部分——模板——进行介绍,我们将会了解到Go语言的模板以及模板引擎,并学会如何使用它们为客户端生成响应。