Go 作为构建 Web 应用程序的首选语言越来越受欢迎。
这在很大程度上归功于它的速度和应用程序性能,以及它的可移植性。互联网上有很多资源可以教你如何用 Go 构建端到端的 Web 应用程序,但大多数情况下,它们要么以孤立的博客文章的形式散落各处,要么以书籍的形式提供了过多的细节。
本教程希望找到一个中间地带,使用一个最简单的案例,讲述了如何在 Go 中制作一个完整堆栈的 web 应用程序,以及单元测试方法。
阅读本教程的唯一前提是对 Go 编程语言有初学者水平的理解。
“全栈” ?
我们将建立一个 MBTI 名人录。本网站将:
- 显示用户提交的条目,包括名人的姓名和该名人的MBTI人格类型。
- 允许任何人发布关于他们所知道的名人的 MBTI 人格类型的新内容。
此应用程序将需要三个组件:
- web 服务器
- 前端(客户端)应用程序
- 数据库
创建项目目录
您要做的第一件事是为您的项目创建一个新目录。对于这个例子,我们称之为 blogpost-demo-mbticelebrities
:
mkdir blogpost-demo-mbticelebrities
此后都将在此目录中执行的所有文件和命令。
让我们为我们的项目初始化一个新模块。这将帮助我们安装和维护运行应用程序所需的依赖项:
go mod init github.com/axiaoxin-com/blogpost-demo-mbticelebritie
在这里,我正在使用我自己的路径为我的 Go 服务初始化一个新模块。您应该根据你自己代码库位置更改对应的模块名称。
这条命令执行后,应该在您的文件夹中创建 go.mod
和 go.sum
文件。
启动 HTTP 服务器
在您的项目目录中创建一个名为 main.go 的文件:
touch main.go
该文件将包含启动服务器的代码:
// 这是我们 package 的名称
// 使用这个包名的所有内容都可以看到同一个包中的其他内容,
// 不管它们在哪个文件中
package main
// 这些是我们将要使用的库
// "fmt"和"net"都是Go标准库的一部分
import (
// "fmt"有格式化I/O操作的方法(比如打印到控制台)
"fmt"
// "net/http"库有实现HTTP客户端和服务器的方法
"net/http"
)
func main() {
// 在DefaultServeMux中为给定模式注册处理函数
// HandleFunc方法接受路径和函数作为参数,
// 我们可以将函数作为参数传递,在 Go 中甚至可以将它们视为变量
// 然而,handler函数必须有适当的函数签名(如下面的"handler"函数)
http.HandleFunc("/", handler)
// 在定义了我们的服务器后,我们最终在端口8080上“监听和服务”
// 第二个参数是处理函数,我们将在后面讲到,但现在它被保留为nil,
// 在这种情况下将会使用上面通过"HandleFunc"中注册到DefaultServeMux的 handler 处理函数
http.ListenAndServe(":8080", nil)
}
// "handler"是我们的处理函数。它必须遵循ResponseWriter和Request类型的函数签名作为参数。
func handler(w http.ResponseWriter, r *http.Request) {
// 在本示例中,我们将始终把“Hello World”输送到ResponseWriter中
fmt.Fprintf(w, "Hello World!")
}
与您可能知道的其他“printf”语句不同,fmt.Fprintf
将“writer”作为其第一个参数。第二个参数是通过管道输入该 writer 的数据。
因此,输出会根据 writer 重定向它的出现位置。在我们的例子中,ResponseWriter w
将输出内容写入到对用户请求的响应中。
您现在可以运行此文件:
go run main.go
并在浏览器中打开 http://localhost:8080,或运行以下命令:
curl localhost:8080
你将会在浏览器中看到输出的:“Hello World!”
您现在已经成功地在 Go 中启动了一个 HTTP 服务器。
使用路由
我们的服务器运行起来了,但是,您可能会注意到无论我们命中的路由是什么或我们使用的 HTTP 方法是什么,我们都得到的响应是相同的“Hello World!”。 你可以执行验证查看,运行以下 curl 命令,并观察服务器给您的响应:
curl localhost:8080/path-to-a
curl -XPOST localhost:8080/path-to-b
curl -XPUT localhost:8080/mbti
三个命令都会给你返回“Hello World!”
我们想让我们的服务器更智能一些,这样我们就可以处理各种请求路径和请求方法。这就是路由发挥作用的地方。
虽然您可以使用 Go 的 net/http
标准库来实现这一点,但还有其他库可以提供更惯用和声明性的方式来处理 http 路由。
安装外部库
在本教程中,我们将通过安装一些外部库来获得那些我们需要但标准库又没有提供给我们的功能。 当我们安装库时,我们需要一种方法来确保其他使用我们代码的人也拥有与我们相同的库版本。
为此,我们可以使用 go get
命令,它可以帮助我们安装我们选择的库,并将其版本信息添加到 go.mod
和 go.sum
文件中。
让我们安装我们的路由库:
go get github.com/gorilla/mux
这会将 gorilla/mux
库添加到项目中。
现在,我们可以修改我们的代码以利用该库提供的功能:
package main
import (
"fmt"
"net/http"
// 导入我们刚刚安装的 gorilla/mux 库
"github.com/gorilla/mux"
)
func main() {
// 声明一个新的路由器
r := mux.NewRouter()
// 这是路由器有用的地方,它允许我们声明路径,和对其有效的请求方法
r.HandleFunc("/hello", handler).Methods("GET")
// 在声明所有路由后,我们可以将这个路由器传递给这个方法
// (之前我们是将第二个参数保留为nil)
http.ListenAndServe(":8080", r)
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World!")
}
测试
测试是使任何应用程序达到“生产质量”的重要组成部分。它确保了我们的应用程序按照我们期望的方式工作。
让我们从测试我们的 handler 开始。创建一个名为 main_test.go
的文件:
// main_test.go
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHandler(t *testing.T) {
// 这里,我们创建一个新的HTTP请求实例。这个请求将被传递给 handler。
// 第一个参数是请求方法,第二个参数是路由(我们暂时把它留空),第三个参数是请求体,在本例中没有。
req, err := http.NewRequest("GET", "", nil)
// 如果在创建请求实例时出现错误,我们会失败并停止测试
if err != nil {
t.Fatal(err)
}
// 我们使用 Go 的 httptest 库来创建一个 http 记录器。
// 这个记录器将作为我们 http 请求的目标(你可以把它想象成一个迷你浏览器,它将接受我们发出的http请求的结果)。
recorder := httptest.NewRecorder()
// 使用我们的 handler 创建一个 HTTP 处理函数。 “handler”是我们要测试的 main.go 文件中定义的处理函数
hf := http.HandlerFunc(handler)
// 将HTTP请求发送到我们的记录器。这一行实际上执行了我们想要测试的 handler 处理函数
hf.ServeHTTP(recorder, req)
// 检查状态码是否符合我们的预期。
if status := recorder.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
// 检查响应 body 是否符合我们的预期。
expected := `Hello World!`
actual := recorder.Body.String()
if actual != expected {
t.Errorf("handler returned unexpected body: got %v want %v", actual, expected)
}
}
测试文件名必须为 *_test.go
模式,go test
命令会自动读取源码目录下名为 *_test.go
的文件,生成并运行测试用的可执行文件。
要运行这个测试,只需在你的项目根目录运行:
go test ./...
您应该会看到一条温和的消息,告诉您一切 ok。
可测试的路由
如果您注意到在前面的代码片段中,我们在使用 http.newRequest
创建模拟请求时将“路由”留空。实际上,这个测试只是测试我们的处理函数,而不是到处理函数的路由。简单地说,这意味着上面的测试确保传入的请求将得到正确的路由处理,前提是请求会被交付给正确的处理函数。
在本节中,我们将测试路由,以确保每个处理函数都映射到正确的 HTTP 路由。
在我们继续测试我们的路由之前,有必要确保我们的代码支持进行该测试。修改 main.go
文件如下:
package main
import (
"fmt"
"net/http"
"github.com/gorilla/mux"
)
// 这个函数创建并返回路由器。
// 因此我们可以使用这个函数在main函数之外实例化和测试路由器
func newRouter() *mux.Router {
r := mux.NewRouter()
r.HandleFunc("/hello", handler).Methods("GET")
return r
}
func main() {
// 现在通过调用上面定义的 newRouter 构造函数来创建路由器。
// 其余代码保持不变
r := newRouter()
http.ListenAndServe(":8080", r)
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World!")
}
分离路由构造函数后,让我们测试一下路由:
func TestRouter(t *testing.T) {
// 使用前面定义的构造函数实例化路由器
r := newRouter()
// 使用“httptest”库的 `NewServer` 方法创建一个新服务器
// 文档:https://golang.org/pkg/net/http/httptest/#NewServer
mockServer := httptest.NewServer(r)
// mockServer 运行了一个服务器,通过它的 URL 属性对外暴露了服务器地址
// 我们向路由器中定义的“hello”路由发起一个GET请求
resp, err := http.Get(mockServer.URL + "/hello")
// 处理任何意外错误
if err != nil {
t.Fatal(err)
}
// 我们希望状态码是 200 (ok)
if resp.StatusCode != http.StatusOK {
t.Errorf("Status should be ok, got %d", resp.StatusCode)
}
// 在接下来的几行中,读取响应体,并将其转换为字符串
defer resp.Body.Close()
// 将body读入一串字节中(b)
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
// 将 bytes 转换为 string
respString := string(b)
expected := "Hello World!"
// 我们希望我们的响应与我们的处理函数中定义的响应相匹配。
// 如果它碰巧是“Hello world!”,那么确认路由是正确的
if respString != expected {
t.Errorf("Response should be %s, got %s", expected, respString)
}
}
现在每次我们命中 GET /hello
路由时,我们都会得到 hello world 的响应。如果我们命中任何其他路由,它会以 404 响应。
让我们编写一个测试验证请求不存在的路由:
func TestRouterForNonExistentRoute(t *testing.T) {
r := newRouter()
mockServer := httptest.NewServer(r)
// 大部分代码都是相似的。唯一的区别是现在我们向一个没有定义的路由发出请求,比如 POST /hello 路由。
resp, err := http.Post(mockServer.URL+"/hello", "", nil)
if err != nil {
t.Fatal(err)
}
// 我们希望我们的状态为 405(方法不允许)
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("Status should be 405, got %d", resp.StatusCode)
}
// 测试 body 的代码也基本相同,但这次,我们期望得到的是一个空 body
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
respString := string(b)
expected := ""
if respString != expected {
t.Errorf("Response should be %s, got %s", expected, respString)
}
}
现在我们已经学会了如何创建一个简单的 http 服务器,接下来,我们可以让它提供静态文件供我们的用户进行交互。
提供静态文件
“静态文件”是构成网站所需的 HTML、CSS、JavaScript、图像和其他静态资产文件。
为了使我们的服务器为这些静态资产提供服务,我们需要执行 3 个步骤。
- 创建静态资产
- 修改我们的路由器以服务静态资产
- 添加测试以验证我们的新服务器是否可以提供静态文件
创建静态资产
要创建静态资产,请在项目根目录中创建一个目录,并将其命名为 assets
:
mkdir assets
接下来,在这个目录中创建一个HTML文件。这是我们要提供的文件,以及assets目录下的其他文件:
touch assets/index.html
修改路由器
只需在路由器中添加 3 行代码即可启用整个文件服务器。新的路由器构造函数将如下所示:
func newRouter() *mux.Router {
r := mux.NewRouter()
r.HandleFunc("/hello", handler).Methods("GET")
// 声明静态文件目录并将其指向我们刚刚创建的目录
staticFileDirectory := http.Dir("./assets/")
// 声明处理函数,将请求路由到它们各自的文件名。
// 文件服务器被包装在 `stripPrefix` 方法中,因为我们想在查找文件时去掉“/assets/”前缀。
// 例如,如果我们在浏览器中键入“/assets/index.html”,文件服务器将仅在上面声明的目录中查找“index.html”。
// 如果我们不去除前缀,文件服务器将查找“./assets/assets/index.html”,并产生错误
staticFileHandler := http.StripPrefix("/assets/", http.FileServer(staticFileDirectory))
// “PathPrefix”方法充当匹配器,匹配所有以“/assets/”开头的路由
r.PathPrefix("/assets/").Handler(staticFileHandler).Methods("GET")
return r
}
测试静态文件服务器
在对某个功能进行测试之前,您不能真正说您已经完成了该功能。我们可以通过向 main_test.go
添加另一个测试函数来测试静态文件服务器:
func TestStaticFileServer(t *testing.T) {
r := newRouter()
mockServer := httptest.NewServer(r)
// 我们想要点击 `GET /assets/` 路由来获取 index.html 文件响应
resp, err := http.Get(mockServer.URL + "/assets/")
if err != nil {
t.Fatal(err)
}
// 希望状态码是 200 (ok)
if resp.StatusCode != http.StatusOK {
t.Errorf("Status should be 200, got %d", resp.StatusCode)
}
// 测试 HTML 文件的全部内容是不明智的。
// 我们测试 content-type 标头是“text/html; charset=utf-8”,这样我们就知道已经提供了一个 html 文件
contentType := resp.Header.Get("Content-Type")
expectedContentType := "text/html; charset=utf-8"
if expectedContentType != contentType {
t.Errorf("Wrong content type, expected %s, got %s", expectedContentType, contentType)
}
}
要实际测试您的工作,请运行服务器:
go run main.go
并在浏览器中访问 http://localhost:8080/assets/
制作一个简单的浏览器应用
由于我们需要创建我们的 MBTI 名人录,让我们创建一个简单的 HTML 文档来显示名人列表,并在页面加载时从 API 获取列表,并提供一个表单来更新名人列表:
<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<title>MBTI 名人录</title>
</head>
<body>
<h1>MBTI 名人录</h1>
<!--
这个区域将用于显示名人列表及其MBTI类型
-->
<table>
<tr>
<th>人名</th>
<th>MBTI人格</th>
</tr>
<td>徐志摩</td>
<td>INFP</td>
</tr>
</table>
<br/>
<!--
此部分包含表单,将用于点击我们将在下一节中构建的 `POST /items` API
-->
<form action="/items" method="post">
人名:
<input type="text" name="name">
<br/> MBTI人格:
<input type="text" name="mbti">
<br/>
<input type="submit" value="提交">
</form>
<!--
最后,是在每次页面加载时运行的脚本,以获取名人MBTI列表并将它们添加到我们现有的 table 表中
-->
<script>
celebritiesTable = document.querySelector("table")
/*
使用浏览器的 `fetch` API 对 /items 进行 GET 调用
我们希望响应是 MBTI 名人录的 JSON 列表,格式如下:
[
{"name":"...","mbti":"..."},
{"name":"...","mbti":"..."}
]
*/
fetch("/items")
.then(response => response.json())
.then(items => {
// 一旦我们获取了列表,我们就遍历它
items.forEach(item => {
// 创建表行
row = document.createElement("tr")
// 为人名和 MBTI 人格类型列创建表数据元素
nameTd = document.createElement("td")
nameTd.innerHTML = item.name
mbtiTd = document.createElement("td")
mbtiTd.innerHTML = item.mbti
// 将数据元素添加到行中
row.appendChild(nameTd)
row.appendChild(mbtiTd)
// 最后,将行元素添加到 table 中
celebritiesTable.appendChild(row)
})
})
</script>
</body>
效果如图:
添加 items REST API 处理函数
如我们所见,我们需要实现两个 API 才能使该应用程序正常工作:
GET /items
- 这将获取系统中当前所有名人的列表POST /items
- 这将添加一个条目到我们现有的名人列表中
为此,我们将编写相应的处理函数。
在 main.go
文件旁边创建一个名为 items_handlers.go
的新文件。
首先,我们将添加 Item
结构体的定义和 items
变量:
package main
type Item struct {
Name string `json:"name"`
MBTI string `json:"mbti"`
}
var items []Item
接下来,定义获取所有名人的处理函数:
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type Item struct {
Name string `json:"name"`
MBTI string `json:"mbti"`
}
var items []Item
func getItemsHandler(w http.ResponseWriter, r *http.Request) {
// 将“items”变量转换为 json
itemsBytes, err := json.Marshal(items)
// 如果有错误,打印到控制台,并返回一个服务器错误给用户
if err != nil {
fmt.Println(fmt.Errorf("Error: %v", err))
w.WriteHeader(http.StatusInternalServerError)
return
}
// 如果一切顺利,将名人 MBTI 的 JSON 列表写入响应
w.Write(itemsBytes)
}
接下来,创建 items 的新条目的处理函数:
func createItemHandler(w http.ResponseWriter, r *http.Request) {
// 创建 Item 实例
item := Item{}
// 我们使用 `ParseForm` 方法,解析请求发送过来的 HTML 表单数据
err := r.ParseForm()
// 如果出现任何错误,我们将返回错误给用户
if err != nil {
fmt.Println(fmt.Errorf("Error: %v", err))
w.WriteHeader(http.StatusInternalServerError)
return
}
// 从表单信息中获取对应字段的信息
item.Name = r.Form.Get("name")
item.MBTI = r.Form.Get("mbti")
// 将新的 item 添加我们现有的 items 列表
items = append(items, item)
// 最后,我们使用 http 库的 `Redirect` 方法将用户重定向到原始的 HTMl 页面(位于 `/assets/`)
http.Redirect(w, r, "/assets/", http.StatusFound)
}
最后一步,是将这些处理函数添加到我们的路由器,以便我们的应用程序能够使用它们:
// 在返回 r 之前添加这些行到 newRouter() 函数中
r.HandleFunc("/bird", getBirdHandler).Methods("GET")
r.HandleFunc("/bird", createBirdHandler).Methods("POST")
return r
这些处理函数和涉及的路由的测试类似于我们之前为 GET /hello
和静态文件服务器的测试,留给读者作为练习。
如果你确实很懒,你也可以在源代码中看到测试。
添加数据库
到目前为止,我们已经为我们的应用程序添加了持久性,可以存储和查询名人的MBTI信息。
然而,这种持久性是短暂的,因为数据保存在内存中。这意味着只要我们重新启动应用程序,所有数据都会被清除。为了添加真正的持久性,我们需要将数据库添加到我们的堆栈中。
到目前为止,我们的代码很容易理解和测试,因为它是一个独立的应用程序。添加数据库将添加另一层通信,我们暂不在本文进行讲解。
你可以在这里找到这篇文章的源代码