如何在Go中制作HTTP服务器
作为 Write for DOnations 计划的一部分,作者选择了 Diversity in Tech Fund 来接受捐赠。
介绍
许多开发人员至少花费一些时间来创建服务器以在 Internet 上分发内容。 超文本传输协议 (HTTP) 服务于大部分内容,无论是对猫图像的请求还是加载您正在阅读的教程的请求。 Go 标准库为创建 HTTP 服务器以提供 Web 内容或向这些服务器发出 HTTP 请求提供内置支持。
在本教程中,您将使用 Go 的标准库创建一个 HTTP 服务器,然后扩展您的服务器以从请求的查询字符串、正文和表单数据中读取数据。 您还将更新您的程序以使用您自己的 HTTP 标头和状态代码来响应请求。
先决条件
要遵循本教程,您将需要:
- Go version 1.16 or greater installed. To set this up, follow the How To Install Go tutorial for your operating system.
- 能够使用 curl 发出 Web 请求。 要阅读 curl,请查看 如何使用 cURL 下载文件。
- 熟悉在 Go 中使用 JSON,可以在 如何在 Go 中使用 JSON 教程中找到。
- 使用 Go 的
context
包的经验,可以在教程 How To Use Contexts in Go 中获得。 - 运行goroutines和读取channels的经验,可以参考教程如何在Go中同时运行多个函数。
- 熟悉 HTTP 请求的组成和发送方式(推荐)。
设置项目
在 Go 中,大部分 HTTP 功能由标准库中的 net/http 包提供,而其余的网络通信由 net 包提供。 net/http
包不仅包括发出 HTTP 请求的能力,还提供了一个 HTTP 服务器,您可以使用它来处理这些请求。
在本节中,您将创建一个程序,该程序使用 http.ListenAndServe 函数来启动响应请求路径 /
和 /hello
的 HTTP 服务器。 然后,您将扩展该程序以在同一程序中运行多个 HTTP 服务器。
但是,在编写任何代码之前,您需要创建程序的目录。 许多开发人员将他们的项目保存在一个目录中以使它们井井有条。 在本教程中,您将使用一个名为 projects
的目录。
首先,创建 projects
目录并导航到它:
mkdir projects cd projects
接下来,为您的项目创建目录并导航到该目录。 在这种情况下,使用目录 httpserver
:
mkdir httpserver cd httpserver
现在您已经创建了程序的目录并且您在 httpserver
目录中,您可以开始实现您的 HTTP 服务器。
侦听请求并提供响应
Go HTTP 服务器包括两个主要组件:侦听来自 HTTP 客户端的请求的服务器和一个或多个响应这些请求的请求处理程序。 在本节中,您将首先使用函数 http.HandleFunc
告诉服务器调用哪个函数来处理对服务器的请求。 然后,您将使用 http.ListenAndServe
函数启动服务器并告诉它侦听新的 HTTP 请求,然后使用您设置的处理程序函数为它们提供服务。
现在,在您创建的 httpserver
目录中,使用 nano
或您喜欢的编辑器打开 main.go
文件:
nano main.go
在 main.go
文件中,您将创建两个函数,getRoot
和 getHello
,作为您的处理函数。 然后,您将创建一个 main
函数并使用它来设置带有 http.HandleFunc
函数的请求处理程序,方法是将 [X157X ] 处理函数和 getHello
处理函数的 /hello
路径。 设置处理程序后,调用 http.ListenAndServe
函数来启动服务器并侦听请求。
将以下代码添加到文件中以启动程序并设置处理程序:
main.go
package main import ( "errors" "fmt" "io" "net/http" "os" ) func getRoot(w http.ResponseWriter, r *http.Request) { fmt.Printf("got / request\n") io.WriteString(w, "This is my website!\n") } func getHello(w http.ResponseWriter, r *http.Request) { fmt.Printf("got /hello request\n") io.WriteString(w, "Hello, HTTP!\n") }
在第一段代码中,您为 Go 程序设置 package
,为您的程序设置所需的包 import
,并创建两个函数:getRoot
函数和getHello
功能。 这两个函数具有相同的函数签名,它们接受相同的参数:一个 http.ResponseWriter
值和一个 *http.Request
值。 此函数签名用于 HTTP 处理函数,定义为 http.HandlerFunc。 当向服务器发出请求时,它使用有关发出请求的信息设置这两个值,然后使用这些值调用处理函数。
在 http.HandlerFunc
中,http.ResponseWriter 值(在您的处理程序中命名为 w
)用于控制将响应信息写回发出请求的客户端,例如响应的正文或状态代码。 然后,*http.Request 值(在您的处理程序中命名为 r
)用于获取有关进入服务器的请求的信息,例如在POST
请求或有关发出请求的客户端的信息。
目前,在您的两个 HTTP 处理程序中,当请求进入处理程序函数时,您使用 fmt.Printf
打印,然后使用 http.ResponseWriter
向响应正文发送一些文本。 http.ResponseWriter
是 io.Writer,这意味着您可以使用任何能够写入该接口的东西来写入响应正文。 在这种情况下,您使用 io.WriteString 函数将响应写入正文。
现在,通过启动 main
函数继续创建程序:
main.go
... func main() { http.HandleFunc("/", getRoot) http.HandleFunc("/hello", getHello) err := http.ListenAndServe(":3333", nil) ...
在 main
函数中,您有两个对 http.HandleFunc
函数的调用。 对该函数的每次调用都会为默认服务器多路复用器中的特定请求路径设置一个处理函数。 服务器多路复用器是一个 http.Handler,它能够查看请求路径并调用与该路径关联的给定处理函数。 因此,在您的程序中,您告诉默认服务器多路复用器在有人请求 /
路径时调用 getRoot
函数,当有人请求 /hello
路径。
设置处理程序后,调用 http.ListenAndServe
函数,它告诉全局 HTTP 服务器使用可选的 http.Handler
侦听特定端口上的传入请求。 在你的程序中,你告诉服务器监听 ":3333"
。 通过在冒号前不指定 IP 地址,服务器将侦听与您的计算机关联的每个 IP 地址,并将侦听端口 3333
。 网口,比如这里的3333
,是一台电脑同时让多个程序相互通信的一种方式。 每个程序都使用自己的端口,因此当客户端连接到特定端口时,计算机知道将其发送到哪个程序。 如果您只想允许连接到 localhost
,即 IP 地址 127.0.0.1
的主机名,则可以改为使用 127.0.0.1:3333
。
您的 http.ListenAndServe
函数还为 http.Handler
参数传递了 nil
值。 这告诉 ListenAndServe
函数您要使用默认的服务器多路复用器,而不是您设置的那个。
ListenAndServe
是一个阻塞调用,这意味着你的程序在 ListenAndServe
完成运行之前不会继续运行。 但是,ListenAndServe
直到您的程序完成运行或 HTTP 服务器被告知关闭时才会完成运行。 即使 ListenAndServe
正在阻塞并且您的程序不包含关闭服务器的方法,但包含错误处理仍然很重要,因为调用 ListenAndServe
的几种方法可能会失败。 因此,在 main
函数中为 ListenAndServe
添加错误处理,如下所示:
main.go
... func main() { ... err := http.ListenAndServe(":3333", nil) if errors.Is(err, http.ErrServerClosed) { fmt.Printf("server closed\n") } else if err != nil { fmt.Printf("error starting server: %s\n", err) os.Exit(1) <^>} }
您要检查的第一个错误 http.ErrServerClosed 在服务器被告知关闭或关闭时返回。 这通常是一个预期的错误,因为您将自己关闭服务器,但它也可用于显示服务器在输出中停止的原因。 在第二次错误检查中,您检查任何其他错误。 如果发生这种情况,它会将错误打印到屏幕上,然后使用 os.Exit
函数以 1
的错误代码退出程序。
运行程序时您可能会看到的一个错误是 address already in use
错误。 当 ListenAndServe
无法侦听您提供的地址或端口时,可能会返回此错误,因为另一个程序已经在使用它。 有时,如果该端口是常用端口并且您计算机上的另一个程序正在使用它,则可能会发生这种情况,但如果您多次运行自己程序的多个副本,也会发生这种情况。 如果您在处理本教程时看到此错误,请确保您已从上一步停止您的程序,然后再次运行它。
注意: 如果您看到 address already in use
错误并且您没有运行程序的另一个副本,这可能意味着其他程序正在使用它。 如果发生这种情况,无论您在本教程中提到的任何地方看到 3333
,请将其更改为 1024 和 65535 以下的另一个数字,例如 3334
,然后重试。 如果您仍然看到错误,您可能需要继续尝试查找未使用的端口。 一旦你找到一个可以工作的端口,在本教程中的所有命令中使用它。
现在您的代码已准备就绪,保存您的 main.go
文件并使用 go run
运行您的程序。 与您可能编写的其他 Go 程序不同,该程序不会立即自行退出。 运行程序后,继续执行以下命令:
go run main.go
由于您的程序仍在您的终端中运行,您需要打开第二个终端与您的服务器进行交互。 当您看到与以下命令颜色相同的命令或输出时,表示在第二个终端中运行它。
在第二个终端中,使用 curl 程序向您的 HTTP 服务器发出 HTTP 请求。 curl
是许多系统默认安装的实用程序,可以向各种类型的服务器发出请求。 在本教程中,您将使用它来发出 HTTP 请求。 您的服务器正在侦听计算机端口 3333
上的连接,因此您需要在同一端口上向 localhost
发出请求:
curl http://localhost:3333
输出将如下所示:
OutputThis is my website!
在输出中,您将看到来自 getRoot
函数的 This is my website!
响应,因为您访问了 HTTP 服务器上的 /
路径。
现在,在同一个终端中,向同一个主机和端口发出请求,但将 /hello
路径添加到 curl
命令的末尾:
curl http://localhost:3333/hello
您的输出将如下所示:
OutputHello, HTTP!
这次您将看到来自 getHello
函数的 Hello, HTTP!
响应。
如果您返回运行 HTTP 服务器功能的终端,您现在有两行来自服务器的输出。 一个用于 /
请求,另一个用于 /hello
请求:
Outputgot / request got /hello request
由于服务器将继续运行,直到程序完成运行,您需要自己停止它。 为此,请按 CONTROL+C
向您的程序发送中断信号以停止它。
在本节中,您创建了一个 HTTP 服务器程序,但它使用默认服务器多路复用器和默认 HTTP 服务器。 使用默认值或全局值可能会导致难以复制的错误,因为程序的多个部分可能会在不同时间和不同时间更新它们。 如果这导致不正确的状态,则可能很难追踪该错误,因为它可能仅在以特定顺序调用某些函数时才存在。 因此,为避免此问题,您将更新您的服务器以使用您在下一节中自己创建的服务器多路复用器。
多路复用请求处理程序
在上一节中启动 HTTP 服务器时,您将 http.Handler
参数的 nil
值传递给 ListenAndServe
函数,因为您使用的是默认服务器多路复用器。 因为 http.Handler
是 接口,所以可以创建自己的 struct
来实现该接口。 但有时,您只需要一个基本的 http.Handler
来为特定请求路径调用单个函数,例如默认的服务器多路复用器。 在本节中,您将更新您的程序以使用 http.ServeMux、服务器多路复用器和 net/http
包提供的 http.Handler
实现,您可以将其用于这些情况.
http.ServeMux
struct
可以配置为与默认服务器多路复用器相同,因此您无需对程序进行太多更新即可开始使用您自己的而不是全局默认值。 要更新您的程序以使用 http.ServeMux
,请再次打开您的 main.go
文件并更新您的程序以使用您自己的 http.ServeMux
:
main.go
... func main() { mux := http.NewServeMux() mux.HandleFunc("/", getRoot) mux.HandleFunc("/hello", getHello) err := http.ListenAndServe(":3333", mux) ... }
在此更新中,您使用 http.NewServeMux
构造函数创建了一个新的 http.ServeMux
并将其分配给 mux
变量。 之后,您只需更新 http.HandleFunc
调用以使用 mux
变量而不是调用 http
包。 最后,您更新了对 http.ListenAndServe
的调用,为它提供了您创建的 http.Handler
(mux
) 而不是 nil
值。
现在您可以使用 go run
再次运行您的程序:
go run main.go
您的程序将像上次一样继续运行,因此您需要运行命令才能与另一个终端中的服务器进行交互。 首先,使用 curl
再次请求 /
路径:
curl http://localhost:3333
输出将如下所示:
OutputThis is my website!
你会看到这个输出和以前一样。
接下来,为 /hello
路径运行与之前相同的命令:
curl http://localhost:3333/hello
输出将如下所示:
OutputHello, HTTP!
此路径的输出也与以前相同。
最后,如果您返回原始终端,您将看到 /
和 /hello
请求的输出,如前所述:
Outputgot / request got /hello request
您对程序所做的更新在功能上是相同的,但这次您使用的是自己的 http.Handler
而不是默认的。
最后,再次按 CONTROL+C
退出您的服务器程序。
一次运行多台服务器
除了使用您自己的 http.Handler
之外,Go net/http
包还允许您使用默认服务器以外的 HTTP 服务器。 有时您可能希望自定义服务器的运行方式,或者您可能希望同时在同一个程序中运行多个 HTTP 服务器。 例如,您可能希望从同一个程序运行一个公共网站和一个私人管理网站。 由于您只能拥有一个默认 HTTP 服务器,因此您无法使用默认服务器执行此操作。 在本节中,您将更新您的程序以使用 net/http
包提供的两个 http.Server 值,用于此类情况 — 当您想要更多地控制服务器或需要多个服务器时同时。
在您的 main.go
文件中,您将使用 http.Server
设置多个 HTTP 服务器。 您还将更新处理程序函数以访问传入 *http.Request
的 context.Context。 这将允许您在 context.Context
变量中设置请求来自哪个服务器,因此您可以在处理函数的输出中打印服务器。
再次打开 main.go
文件并更新它,如下所示:
main.go
package main import ( // Note: Also remove the 'os' import. "context" "errors" "fmt" "io" "net" "net/http" ) const keyServerAddr = "serverAddr" func getRoot(w http.ResponseWriter, r *http.Request) { ctx := r.Context() fmt.Printf("%s: got / request\n", ctx.Value(keyServerAddr)) io.WriteString(w, "This is my website!\n") } func getHello(w http.ResponseWriter, r *http.Request) { ctx := r.Context() fmt.Printf("%s: got /hello request\n", ctx.Value(keyServerAddr)) io.WriteString(w, "Hello, HTTP!\n") }
在上面的代码更新中,您更新了 import
语句以包含更新所需的包。 然后,您创建了一个名为 keyServerAddr
的 const
string
值,作为 http.Request
上下文中 HTTP 服务器地址值的键。 最后,您更新了 getRoot
和 getHello
函数以访问 http.Request
的 context.Context
值。 获得值后,将 HTTP 服务器的地址包含在 fmt.Printf
输出中,这样您就可以看到两个服务器中的哪一个处理了 HTTP 请求。
现在,通过添加两个 http.Server
值中的第一个来开始更新 main
函数:
main.go
... func main() { ... mux.HandleFunc("/hello", getHello) ctx, cancelCtx := context.WithCancel(context.Background()) serverOne := &http.Server{ Addr: ":3333", Handler: mux, BaseContext: func(l net.Listener) context.Context { ctx = context.WithValue(ctx, keyServerAddr, l.Addr().String()) return ctx }, }
在更新后的代码中,您所做的第一件事是使用可用函数 cancelCtx
创建一个新的 context.Context
值,以取消上下文。 然后,定义 serverOne
http.Server
值。 此值与您已经使用的 HTTP 服务器非常相似,但不是将地址和处理程序传递给 http.ListenAndServe
函数,而是将它们设置为 Addr
和 Handler
值。
另一项更改是添加 BaseContext
函数。 BaseContext
是一种更改处理函数在调用 *http.Request
的 Context
方法时收到的 context.Context
部分的方法。 对于您的程序,您正在使用键 serverAddr
将服务器正在侦听的地址 (l.Addr().String()
) 添加到上下文中,然后将其打印到处理函数的输出中。
接下来,定义您的第二个服务器 serverTwo
:
main.go
... func main() { ... serverOne := &http.Server { ... } serverTwo := &http.Server{ Addr: ":4444", Handler: mux, BaseContext: func(l net.Listener) context.Context { ctx = context.WithValue(ctx, keyServerAddr, l.Addr().String()) return ctx }, }
此服务器的定义方式与第一个服务器相同,除了将 Addr
字段的 :3333
设置为 :4444
之外。 这样,一台服务器将监听端口 3333
上的连接,第二台服务器将监听端口 4444
。
现在,更新您的程序以在 goroutine 中启动第一个服务器 serverOne
:
main.go
... func main() { ... serverTwo := &http.Server { ... } go func() { err := serverOne.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { fmt.Printf("server one closed\n") } else if err != nil { fmt.Printf("error listening for server one: %s\n", err) } cancelCtx() }()
在 goroutine 中,你使用 ListenAndServe
启动服务器,和之前一样,但是这次你不需要像使用 http.ListenAndServe
那样为函数提供参数,因为 [ X195X] 值已配置。 然后,您执行与以前相同的错误处理。 在函数结束时,您调用 cancelCtx
以取消提供给 HTTP 处理程序和两个服务器 BaseContext
函数的上下文。 这样,如果服务器由于某种原因结束,上下文也将结束。
最后,更新您的程序以在 goroutine 中启动第二个服务器:
main.go
... func main() { ... go func() { ... }() go func() { err := serverTwo.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { fmt.Printf("server two closed\n") } else if err != nil { fmt.Printf("error listening for server two: %s\n", err) } cancelCtx() }() <-ctx.Done() }
这个 goroutine 在功能上与第一个相同,只是启动 serverTwo
而不是 serverOne
。 此更新还包括 main
函数的结尾,您在从 main
函数返回之前从 ctx.Done
通道读取。 这确保您的程序将保持运行,直到任一服务器 goroutine 结束并调用 cancelCtx
。 一旦上下文结束,您的程序将退出。
完成后保存并关闭文件。
使用 go run
命令运行您的服务器:
go run main.go
您的程序将再次继续运行,因此在您的第二个终端中运行 curl
命令以从侦听 [X182X 的服务器请求 /
路径和 /hello
路径],与之前的请求相同:
curl http://localhost:3333 curl http://localhost:3333/hello
输出将如下所示:
OutputThis is my website! Hello, HTTP!
在输出中,您将看到与之前看到的相同的响应。
现在,再次运行这些相同的命令,但这次使用端口 4444
,它对应于程序中的 serverTwo
:
curl http://localhost:4444 curl http://localhost:4444/hello
输出将如下所示:
OutputThis is my website! Hello, HTTP!
这些请求的输出与 serverOne
提供的端口 3333
上的请求相同。
最后,回头看看你的服务器运行的原始终端:
Output[::]:3333: got / request [::]:3333: got /hello request [::]:4444: got / request [::]:4444: got /hello request
输出看起来与您之前看到的类似,但这次它显示了响应请求的服务器。 前两个请求显示它们来自服务器侦听端口 3333
(serverOne
),后两个请求来自服务器侦听端口 4444
(serverTwo
)。 这些是从 BaseContext
的 serverAddr
值检索到的值。
您的输出也可能与上面的输出略有不同,具体取决于您的计算机是否设置为使用 IPv6。 如果是,您将看到与上面相同的输出。 如果没有,您将看到 0.0.0.0
而不是 [::]
。 这样做的原因是,如果已配置,您的计算机将通过 IPv6 与自身通信,而 [::]
是 0.0.0.0
的 IPv6 表示法。
完成后,再次使用 CONTROL+C
停止服务器。
在本节中,您使用 http.HandleFunc
和 http.ListenAndServe
创建了一个新的 HTTP 服务器程序来运行和配置默认服务器。 然后,您将其更新为对 http.Handler
使用 http.ServeMux
而不是默认的服务器多路复用器。 最后,您更新了程序以使用 http.Server
在同一程序中运行多个 HTTP 服务器。
虽然您现在有一个 HTTP 服务器正在运行,但它的交互性不是很强。 您可以添加它响应的新路径,但除此之外,用户并没有真正与之交互的方法。 HTTP 协议包括多种方式,用户可以通过路径与 HTTP 服务器进行交互。 在下一节中,您将更新您的程序以支持其中的第一个:查询字符串值。
检查请求的查询字符串
用户能够影响他们从 HTTP 服务器返回的 HTTP 响应的方法之一是使用 查询字符串 。 查询字符串是添加到 URL 末尾的一组值。 它以 ?
字符开头,并使用 &
作为分隔符添加附加值。 查询字符串值通常用作过滤或自定义 HTTP 服务器作为响应发送的结果的一种方式。 例如,一台服务器可能使用 results
值来允许用户指定类似 results=10
的内容,表示他们希望在结果列表中查看 10 个项目。
在本节中,您将更新 getRoot
处理函数以使用其 *http.Request
值来访问查询字符串值并将它们打印到输出。
首先,打开您的 main.go
文件并更新 getRoot
函数以使用 r.URL.Query
方法访问查询字符串。 然后,更新 main
方法以删除 serverTwo
及其所有相关代码,因为您将不再需要它:
main.go
... func getRoot(w http.ResponseWriter, r *http.Request) { ctx := r.Context() hasFirst := r.URL.Query().Has("first") first := r.URL.Query().Get("first") hasSecond := r.URL.Query().Has("second") second := r.URL.Query().Get("second") fmt.Printf("%s: got / request. first(%t)=%s, second(%t)=%s\n", ctx.Value(keyServerAddr), hasFirst, first, hasSecond, second) io.WriteString(w, "This is my website!\n") } ...
在 getRoot
函数中,您使用 getRoot
的 *http.Request
的 r.URL
字段来访问有关被请求 URL 的属性。 然后使用 r.URL
字段的 Query
方法访问请求的查询字符串值。 访问查询字符串值后,可以使用两种方法与数据进行交互。 Has
方法返回一个 bool
值,指定查询字符串是否具有带有提供的键的值,例如 first
。 然后,Get
方法返回一个 string
以及提供的键的值。
理论上,您总是可以使用 Get
方法来检索查询字符串值,因为它总是返回给定键的实际值或如果键不存在则返回空字符串。 对于许多用途来说,这已经足够了——但在某些情况下,您可能想知道用户提供空值或根本不提供值之间的区别。 根据您的用例,您可能想知道用户是否提供了 filter
值,或者他们根本没有提供 filter
。 如果他们提供的 filter
值没有,您可能希望将其视为“不向我显示任何内容”,而不提供 filter
值将意味着“向我显示所有内容”。 使用 Has
和 Get
可以区分这两种情况。
在 getRoot
函数中,您还更新了输出以显示 first
和 second
查询字符串值的 Has
和 Get
值.
现在,更新您的 main
函数以再次使用一台服务器:
main.go
... func main() { ... mux.HandleFunc("/hello", getHello) ctx := context.Background() server := &http.Server{ Addr: ":3333", Handler: mux, BaseContext: func(l net.Listener) context.Context { ctx = context.WithValue(ctx, keyServerAddr, l.Addr().String()) return ctx }, } err := server.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { fmt.Printf("server closed\n") } else if err != nil { fmt.Printf("error listening for server: %s\n", err) } }
在 main
函数中,您删除了对 serverTwo
的引用,并将 server
(以前的 serverOne
)从 goroutine 中移到 [ X152X] 函数,类似于您之前运行 http.ListenAndServe
的方式。 您也可以将其更改回 http.ListenAndServe
而不是使用 http.Server
值,因为您只有一台服务器再次运行,但是通过使用 http.Server
,如果您希望将来对服务器进行任何其他自定义。
现在,一旦您保存了更改,请使用 go run
再次运行您的程序:
go run main.go
您的服务器将再次开始运行,因此切换回您的第二个终端以运行带有查询字符串的 curl
命令。 在此命令中,您需要用单引号 ('
) 将 URL 括起来,否则终端的 shell 可能会将查询字符串中的 &
符号解释为“在后台运行此命令” 许多外壳都包含的功能。 在 URL 中,为 first
包含 first=1
的值,为 second
包含 second=
的值:
curl 'http://localhost:3333?first=1&second='
输出将如下所示:
OutputThis is my website!
您将看到 curl
命令的输出与之前的请求相比没有变化。
但是,如果您切换回服务器程序的输出,您将看到新输出包含查询字符串值:
Output[::]:3333: got / request. first(true)=1, second(true)=
first
查询字符串值的输出显示 Has
方法返回 true
因为 first
有一个值,而且 Get
返回1
的值。 second
的输出显示 Has
返回 true
因为 second
包括在内,但 Get
方法没有返回除一个空字符串。 您还可以尝试通过添加和删除 first
和 second
或设置不同的值来查看它如何更改这些函数的输出来发出不同的请求。
完成后,按 CONTROL+C
停止服务器。
在本节中,您更新了程序以再次仅使用一个 http.Server
,但您还添加了对从 [ 的查询字符串中读取 first
和 second
值的支持。 X178X] 处理函数。
不过,使用查询字符串并不是用户向 HTTP 服务器提供输入的唯一方式。 另一种向服务器发送数据的常用方法是在请求正文中包含数据。 在下一节中,您将更新程序以从 *http.Request
数据中读取请求正文。
阅读请求正文
在创建基于 HTTP 的 API(例如 REST API)时,用户可能需要发送超出 URL 长度限制的数据,或者您的页面可能需要接收与如何解释数据,例如过滤器或结果限制。 在这些情况下,通常在请求正文中包含数据并使用 POST
或 PUT
HTTP 请求发送。
在 Go http.HandlerFunc
中,*http.Request
值用于访问有关传入请求的信息,它还包括一种使用 Body
字段访问请求正文的方法。 在本节中,您将更新 getRoot
处理函数以读取请求的正文。
要更新您的 getRoot
方法,请打开您的 main.go
文件并更新它以使用 ioutil.ReadAll
来读取 r.Body
请求字段:
main.go
package main import ( ... "io/ioutil" ... ) ... func getRoot(w http.ResponseWriter, r *http.Request) { ... second := r.URL.Query().Get("second") body, err := ioutil.ReadAll(r.Body) if err != nil { fmt.Printf("could not read body: %s\n", err) } fmt.Printf("%s: got / request. first(%t)=%s, second(%t)=%s, body:\n%s\n", ctx.Value(keyServerAddr), hasFirst, first, hasSecond, second, body) io.WriteString(w, "This is my website!\n") } ...
在此更新中,您使用 ioutil.ReadAll
函数读取 *http.Request
的 r.Body
属性以访问请求的正文。 ioutil.ReadAll
函数是一个实用函数,它将从 io.Reader 读取数据,直到遇到错误或数据结束。 由于 r.Body
是 io.Reader
,因此您可以使用它来读取正文。 阅读正文后,您还更新了 fmt.Printf
以将其打印到输出。
保存更新后,使用 go run
命令运行服务器:
go run main.go
由于服务器将继续运行直到您停止它,请转到您的另一个终端使用带有 -X POST
选项的 curl
和使用 -d
选项。 您还可以使用之前的 first
和 second
查询字符串值:
curl -X POST -d 'This is the body' 'http://localhost:3333?first=1&second='
您的输出将如下所示:
OutputThis is my website!
您的处理程序函数的输出是相同的,但您会看到您的服务器日志已再次更新:
Output[::]:3333: got / request. first(true)=1, second(true)=, body: This is the body
在服务器日志中,您将看到之前的查询字符串值,但现在您还将看到 curl
命令发送的 This is the body
数据。
现在,按 CONTROL+C
停止服务器。
在本节中,您更新了程序以将请求的正文读入您打印到输出的变量中。 通过以这种方式读取正文与其他功能相结合,例如 encoding/json 将 JSON 正文解组为 Go 数据,您将能够创建用户可以以他们的方式与之交互的 API熟悉其他 API。
但是,并非所有从用户发送的数据都采用 API 的形式。 许多网站都有要求用户填写的表单,因此在下一节中,您将更新您的程序以读取除了您已有的请求正文和查询字符串之外的表单数据。
检索表单数据
长期以来,使用表单发送数据是用户向 HTTP 服务器发送数据并与网站交互的标准方式。 表单现在不像过去那么流行,但作为用户向网站提交数据的一种方式,它们仍然有很多用途。 http.HandlerFunc
中的 *http.Request
值也提供了一种访问此数据的方法,类似于它提供对查询字符串和请求正文的访问方式。 在本节中,您将更新您的 getHello
程序以从表单中接收用户的姓名并用他们的姓名回复他们。
打开 main.go
并更新 getHello
函数以使用 *http.Request
的 PostFormValue
方法:
main.go
... func getHello(w http.ResponseWriter, r *http.Request) { ctx := r.Context() fmt.Printf("%s: got /hello request\n", ctx.Value(keyServerAddr)) myName := r.PostFormValue("myName") if myName == "" { myName = "HTTP" } io.WriteString(w, fmt.Sprintf("Hello, %s!\n", myName)) } ...
现在在您的 getHello
函数中,您正在读取发布到处理函数的表单值并查找名为 myName
的值。 如果未找到该值或找到的值为空字符串,请将 myName
变量设置为 HTTP
的默认值,这样页面就不会显示空名称。 然后,您更新了用户的输出以显示他们发送的名称,如果他们没有发送名称,则为 HTTP
。
要使用这些更新运行您的服务器,请保存您的更改并使用 go run
运行它:
go run main.go
现在,在您的第二个终端中,将 curl
与 /hello
URL 的 -X POST
选项一起使用,但这次不是使用 -d
来提供数据主体,使用 -F 'myName=Sammy'
选项为表单数据提供 myName
字段,其值为 Sammy
:
curl -X POST -F 'myName=Sammy' 'http://localhost:3333/hello'
输出将如下所示:
OutputHello, Sammy!
在上面的输出中,您将看到预期的 Hello, Sammy!
问候语,因为您使用 curl
发送的表单显示 myName
是 Sammy
。
您在 getHello
函数中用于检索 myName
表单值的 r.PostFormValue
方法是一种特殊方法,它仅包含从请求正文中的表单发布的值. 但是,也可以使用 r.FormValue
方法,它包括表单正文和查询字符串中的任何值。 因此,如果您使用 r.FormValue("myName")
,您还可以删除 -F
选项并在查询字符串中包含 myName=Sammy
以查看 Sammy
也返回。 但是,如果您在不更改 r.FormValue
的情况下这样做,您会看到名称的默认 HTTP
响应。 小心从哪里检索这些值可以避免名称冲突或难以追踪的错误。 更严格并使用 r.PostFormValue
会很有用,除非您也希望灵活地将其放入查询字符串中。
如果你回顾你的服务器日志,你会看到 /hello
请求的记录类似于以前的请求:
Output[::]:3333: got /hello request
要停止服务器,请按 CONTROL+C
。
在本节中,您更新了 getHello
处理函数,以从发布到页面的表单数据中读取名称,然后将该名称返回给用户。
此时,在您的程序中,处理请求时可能会出现一些问题,并且不会通知您的用户。 在下一部分中,您将更新处理程序函数以返回 HTTP 状态代码和标头。
响应标题和状态码
HTTP 协议使用一些用户通常看不到的功能来发送数据以帮助浏览器或服务器进行通信。 其中一项功能称为 状态码 ,服务器使用它可以让 HTTP 客户端更好地了解服务器是否认为请求成功,或者服务器端是否出现问题,或客户端发送的请求。
HTTP 服务器和客户端通信的另一种方式是使用 标头字段 。 标头字段是客户端或服务器将发送给对方以让他们了解自己的键和值。 HTTP 协议预定义了许多标头,例如 Accept
,客户端使用这些标头告诉服务器它可以接受和理解的数据类型。 也可以通过在它们前面加上 x-
和名称的其余部分来定义自己的名称。
在本节中,您将更新程序以使 getHello
的 myName
表单字段成为必填字段。 如果没有为 myName
字段发送值,您的服务器将向客户端发送回“错误请求”状态代码并添加 x-missing-field
标头让客户端知道哪个字段是失踪。
要将此功能添加到您的程序,请最后一次打开您的 main.go
文件并将验证检查添加到 getHello
处理程序函数:
main.go
... func getHello(w http.ResponseWriter, r *http.Request) { ctx := r.Context() fmt.Printf("%s: got /hello request\n", ctx.Value(keyServerAddr)) myName := r.PostFormValue("myName") if myName == "" { w.Header().Set("x-missing-field", "myName") w.WriteHeader(http.StatusBadRequest) return } io.WriteString(w, fmt.Sprintf("Hello, %s!\n", myName)) } ...
在此更新中,当 myName
为空字符串时,您不会设置默认名称 HTTP
,而是向客户端发送错误消息。 首先,您使用 w.Header().Set
方法在响应 HTTP 标头中设置值为 myName
的 x-missing-field
标头。 然后,您使用 w.WriteHeader
方法将任何响应标头以及“错误请求”状态代码写入客户端。 最后,它会从处理函数中返回。 您要确保这样做,以免在错误信息之外意外向客户端写入 Hello, !
响应。
确保您设置标题并以正确的顺序发送状态代码也很重要。 在 HTTP 请求或响应中,必须在将正文发送到客户端之前发送所有标头,因此任何更新 w.Header()
的请求都必须在调用 w.WriteHeader
之前完成。 一旦 w.WriteHeader
被调用,页面的状态将与所有标题一起发送,之后只能写入正文。
保存更新后,您可以使用 go run
命令再次运行程序:
go run main.go
现在,使用您的第二个终端向 /hello
URL 发出另一个 curl -X POST
请求,但不要包含 -F
来发送表单数据。 您还需要包含 -v
选项来告诉 curl
显示详细输出,以便您可以查看请求的所有标头和输出:
curl -v -X POST 'http://localhost:3333/hello'
这次在输出中,由于详细的输出,您将在处理请求时看到更多信息:
Output* Trying ::1:3333... * Connected to localhost (::1) port 3333 (#0) > POST /hello HTTP/1.1 > Host: localhost:3333 > User-Agent: curl/7.77.0 > Accept: */* > * Mark bundle as not supporting multiuse < HTTP/1.1 400 Bad Request < X-Missing-Field: myName < Date: Wed, 02 Mar 2022 03:51:54 GMT < Content-Length: 0 < * Connection #0 to host localhost left intact
输出中的前几行显示 curl
正在尝试连接到 localhost
端口 3333
。
然后,以 >
开头的行显示了 curl
正在向服务器发出的请求。 它说 curl
正在使用 HTTP 1.1 协议以及其他一些标头向 /hello
URL 发出 POST
请求。 然后,它发送一个空的主体,如空的 >
行所示。
一旦 curl
发送请求,您可以看到它从服务器收到的带有 <
前缀的响应。 第一行表示服务器以 [X53X] 响应,也称为 400 状态码。 然后,您可以看到您设置的 X-Missing-Field
标头包含在 myName
的值中。 在发送了一些额外的标头后,请求完成而不发送任何正文,这可以通过长度为 0
的 Content-Length
(或正文)看出。
如果您再次查看服务器输出,您将看到 /hello
请求服务器在输出中处理:
Output[::]:3333: got /hello request
最后一次,按 CONTROL+C
停止您的服务器。
在本节中,您更新了 HTTP 服务器以向 /hello
表单输入添加验证。 如果名称未作为表单的一部分发送,则您使用 w.Header().Set
设置要发送回客户端的标头。 设置标头后,您使用 w.WriteHeader
将标头写入客户端,以及向客户端指示这是一个错误请求的状态代码。
结论
在本教程中,您使用 Go 标准库中的 net/http
包创建了一个新的 Go HTTP 服务器。 然后,您更新了程序以使用特定的服务器多路复用器和多个 http.Server
实例。 您还更新了服务器以通过查询字符串值、请求正文和表单数据读取用户输入。 最后,您更新了服务器以使用自定义 HTTP 标头和“错误请求”状态代码向客户端返回表单验证信息。
Go HTTP 生态系统的一件好事是,许多框架旨在巧妙地集成到 Go 的 net/http
包中,而不是重新发明大量已经存在的代码。 github.com/go-chi/chi 项目就是一个很好的例子。 Go 内置的服务器多路复用器是开始使用 HTTP 服务器的好方法,但它缺少大型 Web 服务器可能需要的许多高级功能。 chi
等项目能够在 Go 标准库中实现 http.Handler
接口,以适合标准 http.Server
,而无需重写代码的服务器部分。 这使他们可以专注于创建 中间件 和其他工具来增强可用功能,而不是处理基本功能。
除了 chi
之类的项目之外,Go net/http 包还包含许多本教程未涵盖的功能。 要探索有关使用 cookie 或服务 HTTPS 流量的更多信息,net/http
包是一个不错的起点。
本教程也是 DigitalOcean How to Code in Go 系列的一部分。 该系列涵盖了许多 Go 主题,从第一次安装 Go 到如何使用语言本身。