介绍
本教程将说明如何在 Go 中构建由 PostgreSQL 支持的 REST API,使用 Gorilla Mux 进行路由。本教程将采用测试驱动开发,最后将解释如何在开发过程中对数据库进行持续测试。
目标
在本教程结束时,您将:
- 熟悉 Gorilla Mux,并且
- 了解如何使用持续集成 (CI) 针对数据库测试您的应用程序。
先决条件
本教程假设:
- 基本熟悉 Go 和 PostgreSQL,以及
- 你有工作的 Go 和 PostgreSQL 安装。您可以使用 Docker 轻松运行测试数据库。
您将在此存储库中找到演示的完整代码。
应用程序简介
在深入了解细节之前,让我们先简要了解一下我们将在本教程中构建的示例应用程序。
应用程序会做什么?
该应用程序将是一个简单的 REST API 服务器,它将公开端点以允许访问和操作“产品”。我们的端点将允许的操作包括:
- 创造新产品,
- 更新现有产品,
- 删除现有产品,
- 获取现有产品,以及
- 获取产品列表。
API规范
具体来说,我们的应用程序应该:
- 创建新产品以响应 /product 上的有效 POST 请求,
- 更新产品以响应 /product/{id} 处的有效 PUT 请求,
- 删除产品以响应 /product/{id} 处的有效 DELETE 请求,
- 获取产品以响应 /product/{id} 处的有效 GET 请求,以及
- 获取产品列表以响应 /products 上的有效 GET 请求。
上面某些端点中的 {id} 将确定请求将使用哪个产品。
有了这些要求,让我们开始设计我们的应用程序。
创建应用程序结构
在本节中,我们将创建最小的应用程序结构,作为编写测试和进一步开发应用程序的起点。
创建数据库结构
在这个简单的应用程序中,我们将有一个名为 products 的表。该表将包含以下字段:
- id - 此表中的主键,
name- 产品的名称,以及,price——产品的价格。
我们可以使用下面的 SQL 语句来创建表:
CREATE TABLE products
(
id SERIAL,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL DEFAULT 0.00,
CONSTRAINT products_pkey PRIMARY KEY (id)
)
这是一个最小且非常简单的表格,但它应该足以帮助实现本教程的目标。
获取依赖项
在开始编写应用程序之前,我们需要获取应用程序将依赖的两个包:
- mux – Gorilla Mux 路由器(也称为“HTTP 请求多路复用器”,它使 Gorilla 成为最强大的 Go 库之一),并且,
- pq – PostgreSQL 驱动程序。
在此之前,让我们在 GitHub 中创建一个存储库来存储我们的代码:
- 前往 GitHub 并登录或注册。
- 创建一个新的存储库。
- 选择 Go 作为语言
- 通过单击克隆或下载来获取存储库地址。
- 将存储库克隆到您的计算机:
$ git clone YOUR_REPO_URL $ cd YOUR_REPO_DIRECTORY
使用您的 GitHub 存储库地址初始化 Go 模块:
$ go mod init github.com/<your GitHub username>/<project name>
您可以使用以下命令获取 Go 模块。
$ go get -u github.com/gorilla/mux $ go get -u github.com/lib/pq
如果您使用其他机制来供应外部依赖项,请随意以适合您的方式获取和组织这些依赖项。例如,在 Go 参考文档中,您会找到使用 dep 的示例。
搭建一个最小的应用程序
在我们编写测试之前,我们需要创建一个可以用作测试基础的最小应用程序。当我们完成本教程时,我们将拥有以下文件结构。
┌── app.go ├── main.go ├── main_test.go ├── model.go ├── go.sum └── go.mod
让我们首先定义一个结构 App 来保存我们的应用程序:
type App struct {
Router *mux.Router
DB *sql.DB
}
此结构公开对应用程序使用的路由器和数据库的引用。为了有用和可测试,App 将需要两个方法来初始化和运行应用程序。
这些方法将具有以下签名:
func (a *App) Initialize(user, password, dbname string) { }
func (a *App) Run(addr string) { }
Initialize 方法将获取连接到数据库所需的详细信息。它将创建一个数据库连接并连接路由以根据要求进行响应。
Run 方法将简单地启动应用程序。
我们将把它放在 app.go 中,在这个阶段应该包含以下内容:
// app.go
package main
import (
"database/sql"
"github.com/gorilla/mux"
_ "github.com/lib/pq"
)
type App struct {
Router *mux.Router
DB *sql.DB
}
func (a *App) Initialize(user, password, dbname string) { }
func (a *App) Run(addr string) { }
请注意,我们在这里导入了 pq,因为我们需要我们的应用程序与 PostgreSQL 一起工作。
我们还将创建 main.go,其中将包含我们应用程序的入口点。它应该包含以下代码:
// main.go
package main
import "os"
func main() {
a := App{}
a.Initialize(
os.Getenv("APP_DB_USERNAME"),
os.Getenv("APP_DB_PASSWORD"),
os.Getenv("APP_DB_NAME"))
a.Run(":8010")
}
这假设您使用环境变量 APP_DB_USERNAME、APP_DB_PASSWORD 和 APP_DB_NAME 来分别存储数据库的用户名、密码和名称。
我们将使用 PostgreSQL 默认参数进行测试:
export APP_DB_USERNAME=postgres export APP_DB_PASSWORD= export APP_DB_NAME=postgres
我们还需要另一个结构来表示“产品”。让我们定义如下:
type product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
我们可以将处理单个产品的函数定义为该结构上的方法,如下所示:
func (p *product) getProduct(db *sql.DB) error {
return errors.New("Not implemented")
}
func (p *product) updateProduct(db *sql.DB) error {
return errors.New("Not implemented")
}
func (p *product) deleteProduct(db *sql.DB) error {
return errors.New("Not implemented")
}
func (p *product) createProduct(db *sql.DB) error {
return errors.New("Not implemented")
}
我们还将定义一个获取产品列表的独立函数,如下所示:
func getProducts(db *sql.DB, start, count int) ([]product, error) {
return nil, errors.New("Not implemented")
}
将以上所有代码组合到一个文件 model.go 中,您应该会得到类似于以下内容的内容:
// model.go
package main
import (
"database/sql"
"errors"
)
type product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
func (p *product) getProduct(db *sql.DB) error {
return errors.New("Not implemented")
}
func (p *product) updateProduct(db *sql.DB) error {
return errors.New("Not implemented")
}
func (p *product) deleteProduct(db *sql.DB) error {
return errors.New("Not implemented")
}
func (p *product) createProduct(db *sql.DB) error {
return errors.New("Not implemented")
}
func getProducts(db *sql.DB, start, count int) ([]product, error) {
return nil, errors.New("Not implemented")
}
有了这个,我们现在可以开始编写测试了。
根据 API 和应用程序需求编写测试
在本节中,我们将根据我们之前提出的要求编写测试。
设置和清理测试数据库
鉴于我们将对数据库运行测试,我们需要确保在运行任何测试之前正确设置数据库,并在所有测试完成后进行清理。我们将在所有其他测试之前执行的 TestMain 函数中执行此操作,如下所示。我们假设 a 变量引用了主应用程序:
func TestMain(m *testing.M) {
a.Initialize(
os.Getenv("APP_DB_USERNAME"),
os.Getenv("APP_DB_PASSWORD"),
os.Getenv("APP_DB_NAME"))
ensureTableExists()
code := m.Run()
clearTable()
os.Exit(code)
}
我们定义了一个全局变量 a 来代表我们要测试的应用程序。
初始化应用程序后,我们使用 ensureTableExists 函数来确保我们需要测试的表可用。这个函数可以定义如下。该功能需要导入日志模块:
func ensureTableExists() {
if _, err := a.DB.Exec(tableCreationQuery); err != nil {
log.Fatal(err)
}
}
tableCreationQuery 是一个常量,定义如下:
const tableCreationQuery = `CREATE TABLE IF NOT EXISTS products
(
id SERIAL,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL DEFAULT 0.00,
CONSTRAINT products_pkey PRIMARY KEY (id)
)`
所有的测试都是通过调用 m.Run() 来执行的,然后我们调用 clearTable() 来清理数据库。这个函数可以定义如下:
func clearTable() {
a.DB.Exec("DELETE FROM products")
a.DB.Exec("ALTER SEQUENCE products_id_seq RESTART WITH 1")
}
在这个阶段, main_test.go 应该包含以下内容。请注意,您需要在此文件中引用您的模块名称,因此请根据需要替换最后一个导入。
// main_test.go
package main_test
import (
"os"
"testing"
"log"
"net/http"
"net/http/httptest"
"strconv"
"encoding/json"
"bytes"
"github.com/<github username>/<project name>"
)
var a main.App
func TestMain(m *testing.M) {
a.Initialize(
os.Getenv("APP_DB_USERNAME"),
os.Getenv("APP_DB_PASSWORD"),
os.Getenv("APP_DB_NAME"))
ensureTableExists()
code := m.Run()
clearTable()
os.Exit(code)
}
func ensureTableExists() {
if _, err := a.DB.Exec(tableCreationQuery); err != nil {
log.Fatal(err)
}
}
func clearTable() {
a.DB.Exec("DELETE FROM products")
a.DB.Exec("ALTER SEQUENCE products_id_seq RESTART WITH 1")
}
const tableCreationQuery = `CREATE TABLE IF NOT EXISTS products
(
id SERIAL,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL DEFAULT 0.00,
CONSTRAINT products_pkey PRIMARY KEY (id)
)`
为了运行测试,我们需要在 app.go 中实现 App 的 Initialize 方法,与数据库建立连接并初始化路由器。
将 app.go 中的空 Initialize 函数替换为以下代码:
func (a *App) Initialize(user, password, dbname string) {
connectionString :=
fmt.Sprintf("user=%s password=%s dbname=%s sslmode=disable", user, password, dbname)
var err error
a.DB, err = sql.Open("postgres", connectionString)
if err != nil {
log.Fatal(err)
}
a.Router = mux.NewRouter()
}
注意:除非您的编辑器/IDE 设置为自动导入所需的依赖项,否则您必须手动将 fmt 和日志包添加到导入列表中。
当前的 app.go 应该如下所示:
// app.go
package main
import (
"database/sql"
"fmt"
"log"
"github.com/gorilla/mux"
_ "github.com/lib/pq"
)
type App struct {
Router *mux.Router
DB *sql.DB
}
func (a *App) Initialize(user, password, dbname string) {
connectionString :=
fmt.Sprintf("user=%s password=%s dbname=%s sslmode=disable", user, password, dbname)
var err error
a.DB, err = sql.Open("postgres", connectionString)
if err != nil {
log.Fatal(err)
}
a.Router = mux.NewRouter()
}
func (a *App) Run(addr string) { }
在这个阶段,虽然我们没有任何测试,但我们应该能够在我们的应用程序上运行 go test 而不会遇到任何运行时错误。
在第一次运行测试之前,请确保您有一个正在运行的 PostgreSQL 实例。启动测试数据库实例的最简单方法是使用 Docker:
docker run -it -p 5432:5432 -d postgres
在您的项目目录中,执行以下命令:
go test -v
注意:如前所述,我们假设数据库的访问详细信息是在上述环境变量中设置的。
执行此命令应导致如下所示:
testing: warning: no tests to run PASS ok github.com/tomfern/go-mux 0.012s
为 API 编写测试
让我们从使用空表测试对 /products 端点的响应开始。该测试可以如下实现。我们必须添加 net/http 模块才能使其工作:
func TestEmptyTable(t *testing.T) {
clearTable()
req, _ := http.NewRequest("GET", "/products", nil)
response := executeRequest(req)
checkResponseCode(t, http.StatusOK, response.Code)
if body := response.Body.String(); body != "[]" {
t.Errorf("Expected an empty array. Got %s", body)
}
}
此测试从 products 表中删除所有记录,并向 /products 端点发送 GET 请求。我们使用 executeRequest 函数来执行请求。然后我们使用 checkResponseCode 函数来测试 HTTP 响应代码是否符合我们的预期。最后,我们检查响应的正文并测试它是否是空数组的文本表示。
executeRequest 函数可以如下实现。这个需要 net/httptest 模块:
func executeRequest(req *http.Request) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
a.Router.ServeHTTP(rr, req)
return rr
}
此函数使用应用程序的路由器执行请求并返回响应。
checkResponseCode 函数可以实现如下:
func checkResponseCode(t *testing.T, expected, actual int) {
if expected != actual {
t.Errorf("Expected response code %d. Got %d\n", expected, actual)
}
}
如果您现在再次运行测试,您应该会得到如下内容:
$ go test -v === RUN TestEmptyTable --- FAIL: TestEmptyTable (0.01s) main_test.go:73: Expected response code 200. Got 404 main_test.go:58: Expected an empty array. Got 404 page not found FAIL exit status 1 FAIL github.com/tomfern/go-mux 0.015s
正如预期的那样,测试失败了,因为我们还没有实现任何东西。
我们可以用与上述测试类似的方式来实现其余的测试。
1.获取一个不存在的产品
获取不存在的产品时检查响应的测试可以实现如下。此功能需要 encoding/json 模块:
func TestGetNonExistentProduct(t *testing.T) {
clearTable()
req, _ := http.NewRequest("GET", "/product/11", nil)
response := executeRequest(req)
checkResponseCode(t, http.StatusNotFound, response.Code)
var m map[string]string
json.Unmarshal(response.Body.Bytes(), &m)
if m["error"] != "Product not found" {
t.Errorf("Expected the 'error' key of the response to be set to 'Product not found'. Got '%s'", m["error"])
}
}
此测试尝试在端点访问不存在的产品并测试两件事:
- 状态码为 404,表示未找到该产品,并且
- 响应包含错误消息“未找到产品”。
2. 创建产品
创建产品的测试可以如下实现。我们需要它的字节模块:
func TestCreateProduct(t *testing.T) {
clearTable()
var jsonStr = []byte(`{"name":"test product", "price": 11.22}`)
req, _ := http.NewRequest("POST", "/product", bytes.NewBuffer(jsonStr))
req.Header.Set("Content-Type", "application/json")
response := executeRequest(req)
checkResponseCode(t, http.StatusCreated, response.Code)
var m map[string]interface{}
json.Unmarshal(response.Body.Bytes(), &m)
if m["name"] != "test product" {
t.Errorf("Expected product name to be 'test product'. Got '%v'", m["name"])
}
if m["price"] != 11.22 {
t.Errorf("Expected product price to be '11.22'. Got '%v'", m["price"])
}
// the id is compared to 1.0 because JSON unmarshaling converts numbers to
// floats, when the target is a map[string]interface{}
if m["id"] != 1.0 {
t.Errorf("Expected product ID to be '1'. Got '%v'", m["id"])
}
}
在此测试中,我们手动将产品添加到数据库中,然后访问相关端点以获取该产品。然后我们测试以下内容:
- HTTP 响应的状态码为 201,表示资源已创建,并且
- 响应包含一个 JSON 对象,其内容与有效负载的内容相同。
3.获取产品
获取产品的测试可以实现如下:
func TestGetProduct(t *testing.T) {
clearTable()
addProducts(1)
req, _ := http.NewRequest("GET", "/product/1", nil)
response := executeRequest(req)
checkResponseCode(t, http.StatusOK, response.Code)
}
此测试只是将产品添加到表中,并测试访问相关端点会导致 HTTP 响应表示成功,状态码为 200。
在这个测试中,我们使用了 addProducts 函数,该函数用于将一条或多条记录添加到表中进行测试。该功能可以如下实现。它需要 strconv 模块:
func addProducts(count int) {
if count < 1 {
count = 1
}
for i := 0; i < count; i++ {
a.DB.Exec("INSERT INTO products(name, price) VALUES($1, $2)", "Product "+strconv.Itoa(i), (i+1.0)*10)
}
}
4. 更新产品
更新产品的测试可以实现如下:
func TestUpdateProduct(t *testing.T) {
clearTable()
addProducts(1)
req, _ := http.NewRequest("GET", "/product/1", nil)
response := executeRequest(req)
var originalProduct map[string]interface{}
json.Unmarshal(response.Body.Bytes(), &originalProduct)
var jsonStr = []byte(`{"name":"test product - updated name", "price": 11.22}`)
req, _ = http.NewRequest("PUT", "/product/1", bytes.NewBuffer(jsonStr))
req.Header.Set("Content-Type", "application/json")
response = executeRequest(req)
checkResponseCode(t, http.StatusOK, response.Code)
var m map[string]interface{}
json.Unmarshal(response.Body.Bytes(), &m)
if m["id"] != originalProduct["id"] {
t.Errorf("Expected the id to remain the same (%v). Got %v", originalProduct["id"], m["id"])
}
if m["name"] == originalProduct["name"] {
t.Errorf("Expected the name to change from '%v' to '%v'. Got '%v'", originalProduct["name"], m["name"], m["name"])
}
if m["price"] == originalProduct["price"] {
t.Errorf("Expected the price to change from '%v' to '%v'. Got '%v'", originalProduct["price"], m["price"], m["price"])
}
}
该测试首先将产品直接添加到数据库中。然后它使用端点用新的细节更新这个记录。我们最终测试了以下内容:
- 状态码为200,表示成功,
- 响应包含具有更新详细信息的产品的 JSON 表示。
5. 删除产品
删除产品的测试可以实现如下:
func TestDeleteProduct(t *testing.T) {
clearTable()
addProducts(1)
req, _ := http.NewRequest("GET", "/product/1", nil)
response := executeRequest(req)
checkResponseCode(t, http.StatusOK, response.Code)
req, _ = http.NewRequest("DELETE", "/product/1", nil)
response = executeRequest(req)
checkResponseCode(t, http.StatusOK, response.Code)
req, _ = http.NewRequest("GET", "/product/1", nil)
response = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, response.Code)
}
在这个测试中,我们首先创建一个产品并测试它是否存在。 然后我们使用端点删除产品。 最后,我们尝试在适当的端点访问产品并测试它不存在。
此时,main_test.go 应该如下所示:
// main_test.go
package main
import (
"os"
"testing"
"log"
"net/http"
"net/http/httptest"
"bytes"
"encoding/json"
"strconv"
)
var a App
func TestMain(m *testing.M) {
a.Initialize(
os.Getenv("APP_DB_USERNAME"),
os.Getenv("APP_DB_PASSWORD"),
os.Getenv("APP_DB_NAME"))
ensureTableExists()
code := m.Run()
clearTable()
os.Exit(code)
}
func ensureTableExists() {
if _, err := a.DB.Exec(tableCreationQuery); err != nil {
log.Fatal(err)
}
}
func clearTable() {
a.DB.Exec("DELETE FROM products")
a.DB.Exec("ALTER SEQUENCE products_id_seq RESTART WITH 1")
}
const tableCreationQuery = `CREATE TABLE IF NOT EXISTS products
(
id SERIAL,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL DEFAULT 0.00,
CONSTRAINT products_pkey PRIMARY KEY (id)
)`
func TestEmptyTable(t *testing.T) {
clearTable()
req, _ := http.NewRequest("GET", "/products", nil)
response := executeRequest(req)
checkResponseCode(t, http.StatusOK, response.Code)
if body := response.Body.String(); body != "[]" {
t.Errorf("Expected an empty array. Got %s", body)
}
}
func executeRequest(req *http.Request) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
a.Router.ServeHTTP(rr, req)
return rr
}
func checkResponseCode(t *testing.T, expected, actual int) {
if expected != actual {
t.Errorf("Expected response code %d. Got %d\n", expected, actual)
}
}
func TestGetNonExistentProduct(t *testing.T) {
clearTable()
req, _ := http.NewRequest("GET", "/product/11", nil)
response := executeRequest(req)
checkResponseCode(t, http.StatusNotFound, response.Code)
var m map[string]string
json.Unmarshal(response.Body.Bytes(), &m)
if m["error"] != "Product not found" {
t.Errorf("Expected the 'error' key of the response to be set to 'Product not found'. Got '%s'", m["error"])
}
}
func TestCreateProduct(t *testing.T) {
clearTable()
var jsonStr = []byte(`{"name":"test product", "price": 11.22}`)
req, _ := http.NewRequest("POST", "/product", bytes.NewBuffer(jsonStr))
req.Header.Set("Content-Type", "application/json")
response := executeRequest(req)
checkResponseCode(t, http.StatusCreated, response.Code)
var m map[string]interface{}
json.Unmarshal(response.Body.Bytes(), &m)
if m["name"] != "test product" {
t.Errorf("Expected product name to be 'test product'. Got '%v'", m["name"])
}
if m["price"] != 11.22 {
t.Errorf("Expected product price to be '11.22'. Got '%v'", m["price"])
}
// the id is compared to 1.0 because JSON unmarshaling converts numbers to
// floats, when the target is a map[string]interface{}
if m["id"] != 1.0 {
t.Errorf("Expected product ID to be '1'. Got '%v'", m["id"])
}
}
func TestGetProduct(t *testing.T) {
clearTable()
addProducts(1)
req, _ := http.NewRequest("GET", "/product/1", nil)
response := executeRequest(req)
checkResponseCode(t, http.StatusOK, response.Code)
}
// main_test.go
func addProducts(count int) {
if count < 1 {
count = 1
}
for i := 0; i < count; i++ {
a.DB.Exec("INSERT INTO products(name, price) VALUES($1, $2)", "Product "+strconv.Itoa(i), (i+1.0)*10)
}
}
func TestUpdateProduct(t *testing.T) {
clearTable()
addProducts(1)
req, _ := http.NewRequest("GET", "/product/1", nil)
response := executeRequest(req)
var originalProduct map[string]interface{}
json.Unmarshal(response.Body.Bytes(), &originalProduct)
var jsonStr = []byte(`{"name":"test product - updated name", "price": 11.22}`)
req, _ = http.NewRequest("PUT", "/product/1", bytes.NewBuffer(jsonStr))
req.Header.Set("Content-Type", "application/json")
response = executeRequest(req)
checkResponseCode(t, http.StatusOK, response.Code)
var m map[string]interface{}
json.Unmarshal(response.Body.Bytes(), &m)
if m["id"] != originalProduct["id"] {
t.Errorf("Expected the id to remain the same (%v). Got %v", originalProduct["id"], m["id"])
}
if m["name"] == originalProduct["name"] {
t.Errorf("Expected the name to change from '%v' to '%v'. Got '%v'", originalProduct["name"], m["name"], m["name"])
}
if m["price"] == originalProduct["price"] {
t.Errorf("Expected the price to change from '%v' to '%v'. Got '%v'", originalProduct["price"], m["price"], m["price"])
}
}
func TestDeleteProduct(t *testing.T) {
clearTable()
addProducts(1)
req, _ := http.NewRequest("GET", "/product/1", nil)
response := executeRequest(req)
checkResponseCode(t, http.StatusOK, response.Code)
req, _ = http.NewRequest("DELETE", "/product/1", nil)
response = executeRequest(req)
checkResponseCode(t, http.StatusOK, response.Code)
req, _ = http.NewRequest("GET", "/product/1", nil)
response = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, response.Code)
}
如果您现在在项目目录中运行 go test -v,您应该会得到类似于以下内容的响应:
$ go test -v
=== RUN TestEmptyTable
--- FAIL: TestEmptyTable (0.01s)
main_test.go:75: Expected response code 200. Got 404
main_test.go:60: Expected an empty array. Got 404 page not found
=== RUN TestGetNonExistentProduct
--- FAIL: TestGetNonExistentProduct (0.00s)
main_test.go:91: Expected the 'error' key of the response to be set to 'Product not found'. Got ''
=== RUN TestCreateProduct
--- FAIL: TestCreateProduct (0.00s)
main_test.go:75: Expected response code 201. Got 404
main_test.go:111: Expected product name to be 'test product'. Got '<nil>'
main_test.go:115: Expected product price to be '11.22'. Got '<nil>'
main_test.go:121: Expected product ID to be '1'. Got '<nil>'
=== RUN TestGetProduct
--- FAIL: TestGetProduct (0.01s)
main_test.go:75: Expected response code 200. Got 404
=== RUN TestUpdateProduct
--- FAIL: TestUpdateProduct (0.01s)
main_test.go:75: Expected response code 200. Got 404
main_test.go:175: Expected the name to change from '<nil>' to '<nil>'. Got '<nil>'
main_test.go:179: Expected the price to change from '<nil>' to '<nil>'. Got '<nil>'
=== RUN TestDeleteProduct
--- FAIL: TestDeleteProduct (0.01s)
main_test.go:75: Expected response code 200. Got 404
main_test.go:75: Expected response code 200. Got 404
FAIL
exit status 1
FAIL github.com/tomfern/go-mux 0.066s
在这个阶段,我们所有的测试都失败了,因为我们还没有实现任何东西。但是,现在我们的测试已经到位,我们可以开始在我们的应用程序中实现所需的功能。
添加应用程序功能
在本节中,我们将完成我们的应用程序以满足规范和测试。
实现数据库查询
我们将从在产品上实现这些方法开始。实现相对简单,只包括发出查询和返回结果。这些方法可以在model.go中实现如下:
func (p *product) getProduct(db *sql.DB) error {
return db.QueryRow("SELECT name, price FROM products WHERE id=$1",
p.ID).Scan(&p.Name, &p.Price)
}
func (p *product) updateProduct(db *sql.DB) error {
_, err :=
db.Exec("UPDATE products SET name=$1, price=$2 WHERE id=$3",
p.Name, p.Price, p.ID)
return err
}
func (p *product) deleteProduct(db *sql.DB) error {
_, err := db.Exec("DELETE FROM products WHERE id=$1", p.ID)
return err
}
func (p *product) createProduct(db *sql.DB) error {
err := db.QueryRow(
"INSERT INTO products(name, price) VALUES($1, $2) RETURNING id",
p.Name, p.Price).Scan(&p.ID)
if err != nil {
return err
}
return nil
}
让我们也实现 getProducts 函数,如下所示:
func getProducts(db *sql.DB, start, count int) ([]product, error) {
rows, err := db.Query(
"SELECT id, name, price FROM products LIMIT $1 OFFSET $2",
count, start)
if err != nil {
return nil, err
}
defer rows.Close()
products := []product{}
for rows.Next() {
var p product
if err := rows.Scan(&p.ID, &p.Name, &p.Price); err != nil {
return nil, err
}
products = append(products, p)
}
return products, nil
}
此函数从 products 表中获取记录。它根据 count 参数限制记录数。 start 参数确定在开始时跳过多少条记录。如果您有很多记录并想要翻阅它们,这会派上用场。
注意:除非您的编辑器/IDE 设置为管理依赖项,否则您必须手动从 model.go 的导入列表中删除错误包。
编辑完成后,您应该会找到 model.go,如下所示:
// model.go
package main
import (
"database/sql"
)
type product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
func (p *product) getProduct(db *sql.DB) error {
return db.QueryRow("SELECT name, price FROM products WHERE id=$1",
p.ID).Scan(&p.Name, &p.Price)
}
func (p *product) updateProduct(db *sql.DB) error {
_, err :=
db.Exec("UPDATE products SET name=$1, price=$2 WHERE id=$3",
p.Name, p.Price, p.ID)
return err
}
func (p *product) deleteProduct(db *sql.DB) error {
_, err := db.Exec("DELETE FROM products WHERE id=$1", p.ID)
return err
}
func (p *product) createProduct(db *sql.DB) error {
err := db.QueryRow(
"INSERT INTO products(name, price) VALUES($1, $2) RETURNING id",
p.Name, p.Price).Scan(&p.ID)
if err != nil {
return err
}
return nil
}
func getProducts(db *sql.DB, start, count int) ([]product, error) {
rows, err := db.Query(
"SELECT id, name, price FROM products LIMIT $1 OFFSET $2",
count, start)
if err != nil {
return nil, err
}
defer rows.Close()
products := []product{}
for rows.Next() {
var p product
if err := rows.Scan(&p.ID, &p.Name, &p.Price); err != nil {
return nil, err
}
products = append(products, p)
}
return products, nil
}
创建路由和路由处理程序
让我们首先为获取单个产品的路由创建处理程序 getProduct。这个处理程序可以在 app.go 中实现如下:
func (a *App) getProduct(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid product ID")
return
}
p := product{ID: id}
if err := p.getProduct(a.DB); err != nil {
switch err {
case sql.ErrNoRows:
respondWithError(w, http.StatusNotFound, "Product not found")
default:
respondWithError(w, http.StatusInternalServerError, err.Error())
}
return
}
respondWithJSON(w, http.StatusOK, p)
}
您需要将 net/http 和 strconv 模块添加到 app.go。
此处理程序从请求的 URL 中检索要获取的产品的 id,并使用在上一节中创建的 getProduct 方法来获取该产品的详细信息。
如果未找到产品,则处理程序以状态码 404 进行响应,指示无法找到请求的资源。如果找到产品,则处理程序以产品响应。
该方法使用 respondWithError 和 respondWithJSON 函数来处理错误和正常响应。这些功能可以如下实现。它们需要编码/json:
func respondWithError(w http.ResponseWriter, code int, message string) {
respondWithJSON(w, code, map[string]string{"error": message})
}
func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) {
response, _ := json.Marshal(payload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(response)
}
我们可以以类似的方式实现其余的处理程序。
1. 获取产品列表的处理程序
这个处理程序可以在 app.go 中实现如下:
func (a *App) getProducts(w http.ResponseWriter, r *http.Request) {
count, _ := strconv.Atoi(r.FormValue("count"))
start, _ := strconv.Atoi(r.FormValue("start"))
if count > 10 || count < 1 {
count = 10
}
if start < 0 {
start = 0
}
products, err := getProducts(a.DB, start, count)
if err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondWithJSON(w, http.StatusOK, products)
}
此处理程序使用查询字符串中的 count 和 start 参数来获取产品的 count 个数,从数据库中的 start 位置开始。默认情况下,start 设置为 0,count 设置为 10。如果未提供这些参数,此处理程序将响应前 10 个产品。
2. 创建产品的处理程序
该处理程序可以按如下方式实现:
func (a *App) createProduct(w http.ResponseWriter, r *http.Request) {
var p product
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&p); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request payload")
return
}
defer r.Body.Close()
if err := p.createProduct(a.DB); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondWithJSON(w, http.StatusCreated, p)
}
此处理程序假定请求正文是一个 JSON 对象,其中包含要创建的产品的详细信息。它将该对象提取到产品中,并使用 createProduct 方法创建具有这些详细信息的产品。
3. 更新产品的处理程序
该处理程序可以按如下方式实现:
func (a *App) updateProduct(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid product ID")
return
}
var p product
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&p); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid resquest payload")
return
}
defer r.Body.Close()
p.ID = id
if err := p.updateProduct(a.DB); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondWithJSON(w, http.StatusOK, p)
}
与前面的处理程序类似,此处理程序从请求正文中提取产品详细信息。它还从 URL 中提取 id 并使用 id 和 body 来更新数据库中的产品。
4. 删除产品的处理程序
该处理程序可以按如下方式实现:
func (a *App) deleteProduct(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid Product ID")
return
}
p := product{ID: id}
if err := p.deleteProduct(a.DB); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondWithJSON(w, http.StatusOK, map[string]string{"result": "success"})
}
此处理程序从请求的 URL 中提取 id 并使用它从数据库中删除相应的产品。
创建处理程序后,我们现在可以定义将使用它们的路由,如下所示:
func (a *App) initializeRoutes() {
a.Router.HandleFunc("/products", a.getProducts).Methods("GET")
a.Router.HandleFunc("/product", a.createProduct).Methods("POST")
a.Router.HandleFunc("/product/{id:[0-9]+}", a.getProduct).Methods("GET")
a.Router.HandleFunc("/product/{id:[0-9]+}", a.updateProduct).Methods("PUT")
a.Router.HandleFunc("/product/{id:[0-9]+}", a.deleteProduct).Methods("DELETE")
}
如您所见,路由是根据我们之前创建的规范定义的。例如,我们使用 a.getProducts 处理程序在 /products 端点处理 GET 请求。
同样,我们使用 a.deleteProduct 处理程序在 /product/{id} 端点处理 DELETE 请求。路径的 {id:[0-9]+} 部分表示 Gorilla Mux 应该仅在 id 是数字时处理 URL。对于所有匹配的请求,Gorilla Mux 然后将实际数值存储在 id 变量中。这可以在处理程序中访问,如上所示,在处理程序中。
现在剩下的就是实现 Run 方法并从 Initialize 方法调用 initializeRoutes。 这可以按如下方式实现:
func (a *App) Initialize(user, password, dbname string) {
connectionString :=
fmt.Sprintf("user=%s password=%s dbname=%s sslmode=disable", user, password, dbname)
var err error
a.DB, err = sql.Open("postgres", connectionString)
if err != nil {
log.Fatal(err)
}
a.Router = mux.NewRouter()
a.initializeRoutes()
}
func (a *App) Run(addr string) {
log.Fatal(http.ListenAndServe(":8010", a.Router))
}
app.go 的最终版本应该包含以下代码:
// app.go
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"strconv"
"encoding/json"
"github.com/gorilla/mux"
_ "github.com/lib/pq"
)
type App struct {
Router *mux.Router
DB *sql.DB
}
func (a *App) Initialize(user, password, dbname string) {
connectionString :=
fmt.Sprintf("user=%s password=%s dbname=%s sslmode=disable", user, password, dbname)
var err error
a.DB, err = sql.Open("postgres", connectionString)
if err != nil {
log.Fatal(err)
}
a.Router = mux.NewRouter()
a.initializeRoutes()
}
func (a *App) Run(addr string) {
log.Fatal(http.ListenAndServe(":8010", a.Router))
}
func (a *App) getProduct(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid product ID")
return
}
p := product{ID: id}
if err := p.getProduct(a.DB); err != nil {
switch err {
case sql.ErrNoRows:
respondWithError(w, http.StatusNotFound, "Product not found")
default:
respondWithError(w, http.StatusInternalServerError, err.Error())
}
return
}
respondWithJSON(w, http.StatusOK, p)
}
func respondWithError(w http.ResponseWriter, code int, message string) {
respondWithJSON(w, code, map[string]string{"error": message})
}
func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) {
response, _ := json.Marshal(payload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(response)
}
func (a *App) getProducts(w http.ResponseWriter, r *http.Request) {
count, _ := strconv.Atoi(r.FormValue("count"))
start, _ := strconv.Atoi(r.FormValue("start"))
if count > 10 || count < 1 {
count = 10
}
if start < 0 {
start = 0
}
products, err := getProducts(a.DB, start, count)
if err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondWithJSON(w, http.StatusOK, products)
}
func (a *App) createProduct(w http.ResponseWriter, r *http.Request) {
var p product
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&p); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request payload")
return
}
defer r.Body.Close()
if err := p.createProduct(a.DB); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondWithJSON(w, http.StatusCreated, p)
}
func (a *App) updateProduct(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid product ID")
return
}
var p product
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&p); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid resquest payload")
return
}
defer r.Body.Close()
p.ID = id
if err := p.updateProduct(a.DB); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondWithJSON(w, http.StatusOK, p)
}
func (a *App) deleteProduct(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid Product ID")
return
}
p := product{ID: id}
if err := p.deleteProduct(a.DB); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondWithJSON(w, http.StatusOK, map[string]string{"result": "success"})
}
func (a *App) initializeRoutes() {
a.Router.HandleFunc("/products", a.getProducts).Methods("GET")
a.Router.HandleFunc("/product", a.createProduct).Methods("POST")
a.Router.HandleFunc("/product/{id:[0-9]+}", a.getProduct).Methods("GET")
a.Router.HandleFunc("/product/{id:[0-9]+}", a.updateProduct).Methods("PUT")
a.Router.HandleFunc("/product/{id:[0-9]+}", a.deleteProduct).Methods("DELETE")
}
运行测试
实现应用程序功能后,我们现在可以再次运行测试:
$ go test -v
这应该会导致所有测试通过,如下所示:
=== RUN TestEmptyTable --- PASS: TestEmptyTable (0.01s) === RUN TestGetNonExistentProduct --- PASS: TestGetNonExistentProduct (0.00s) === RUN TestCreateProduct --- PASS: TestCreateProduct (0.01s) === RUN TestGetProduct --- PASS: TestGetProduct (0.01s) === RUN TestUpdateProduct --- PASS: TestUpdateProduct (0.01s) === RUN TestDeleteProduct --- PASS: TestDeleteProduct (0.01s) PASS ok github.com/tomfern/go-mux 0.071s
使用信号量(Semaphore)设置持续集成
持续集成 (CI) 是一种加快开发周期的技术。通过建立一个持续测试每个代码更新的短反馈周期,可以在错误出现时立即检测到,团队可以更频繁地安全地合并。
持续集成不需要复杂或昂贵的使用。在本节中,我们将学习如何在几分钟内使用 Semaphore 免费设置它。
将您的存储库添加到 Semaphore
要在存储库中安装 CI/CD 管道,请执行以下步骤:
- 转到 Semaphore 并使用 Sign up with GitHub 按钮注册一个免费帐户。
- 单击 + Create new 以将您的存储库添加到 Semaphore。
- 在列表中找到您的存储库,然后单击选择:

选择 Go starter 工作流程并首先单击自定义它:

当我们选择自定义时,Semaphore 会弹出 Workflow Editor,其中包含以下元素:

- 管道:管道实现特定目标,例如测试,并组织执行流程。管道由从左到右执行的块组成。
- 代理:代理是为管道提供动力的虚拟机。我们有三种机器类型可供选择。该机器运行优化的 Ubuntu 18.04 映像,并带有多种语言的构建工具。
- 块:块是一组可以共享命令和配置的类似作业。块内的作业是并行执行的。一旦一个块中的所有作业都完成了,下一个块就开始了。
- 作业:作业定义完成工作的命令。他们从父块继承他们的配置。
我们需要对启动器工作流程进行一次修改:
单击测试块。
在右侧,您会找到 Job 命令框。在开头添加以下行:
sem-service start postgres
让我们使用 Go 版本 1.16。将第二行更改为:sem-version go 1.16
并加载测试环境变量。结帐后添加以下行:source env-sample
完整的作业应如下所示:
sem-service start postgres sem-version go 1.16 export GO111MODULE=on export GOPATH=~/go export PATH=/home/semaphore/go/bin:$PATH checkout source env-test go get ./… go test ./… go build -v .

单击运行工作流程,然后单击开始:

就是这样,Semaphore 将立即开始运行管道:
启动一个测试 PostgreSQL 实例。
下载 Go 模块。
运行测试代码。
几秒钟后,我们应该得到测试结果:

改善管道
入门管道在测试代码方面做得很好。但是,这只是一个起点,而不是最终目的地。只需进行一些修改,我们就可以使管道更好地执行和扩展:
- 缓存模块:现在,每次运行都会重新下载并安装 Go 模块。我们可以通过添加缓存来避免这种情况。
- 单独的块:我们应该将下载和测试阶段分成两个单独的块。这样,当出现错误时,我们可以更好地确定问题出在哪里。
- Build:我们可以在管道中编译程序,并保存在工件存储中。
- 但首先,让我们检查一下 Semaphore 提供的一些内置命令:
- checkout:checkout 命令会克隆 GitHub 存储库的正确版本并更改目录。它通常是作业中的第一个命令。
- sem-version:使用 sem-version,我们可以切换一种语言的活动版本。 Semaphore 完全支持多种语言,包括 Go。
- 缓存:缓存命令提供对信号量缓存的读写访问,这是一个项目范围的作业存储。
- sem-service:这个工具可以启动多个数据库实例和其他服务。查看管理服务页面以查找受支持的服务。我们可以用一个命令启动一个 PostgreSQL 数据库:
sem-service start postgres 11
所以,让我们让这些命令工作:
单击 Edit Workflow 按钮以再次打开 Workflow Editor:
将块的名称更改为“安装”。
将作业名称更改为“下载模块”。
打开右侧的环境变量部分。创建以下变量。这些变量告诉 Go 将模块存储在本地目录而不是 GOPATH 中。
GO111MODULE=on
GOFLAGS=-mod=vendor
清除作业命令框的内容并输入:
sem-version go 1.16 checkout cache restore go mod vendor cache store

如您所见,第一个块只负责将模块下载到 vendor/ 目录(go mod vendor)并将它们存储在缓存中。
下一个块运行测试:
单击+添加块虚线按钮以创建一个新块。
将块和作业称为“测试”。
打开环境变量并像以前一样创建 GO111MODULE 和 GOFLAGS 变量。
打开序言并键入以下命令。 序言在块中的每个作业之前执行:
ssem-version go 1.13 sem-service start postgres checkout cache restore go mod vendor source env-sample
在命令框中键入以下命令:
go test ./...

最后一个块构建 Go 可执行文件:
添加一个新块。
将块和作业称为“构建”。
重复上一个块中的环境变量和序言步骤。
在框中键入以下命令。 artifact 命令允许我们在项目的工件存储之一中存储和检索文件。
go build -v -o go-mux.bin artifact push project --force go-mux.bin

单击运行工作流程,然后单击开始。
管道应该在几分钟内完成:

导航到项目的顶层以找到 Project Artifacts 按钮:
您应该在那里找到已编译的二进制文件:
好工作! 现在,您可以对 Semaphore 不断测试您的代码充满信心地处理该项目。
注意:Semaphore 还有一个简洁的测试报告功能,可以让您查看哪些测试失败,找到测试套件中最慢的测试,并查找跳过的测试。 阅读有关该功能以及它如何帮助您的团队的更多信息。
结论
本教程说明了如何使用 Gorilla Mux 和 Postgres 通过 Go 构建 REST API。 我们还了解了如何使用 Semaphore 针对实时 PostgreSQL 数据库持续测试您的应用程序。
如果您有任何问题和意见,请随时将它们留在下面的部分。
- 登录 发表评论