跳转到主要内容

介绍


本教程将说明如何在 Go 中构建由 PostgreSQL 支持的 REST API,使用 Gorilla Mux 进行路由。本教程将采用测试驱动开发,最后将解释如何在开发过程中对数据库进行持续测试。

目标


在本教程结束时,您将:

  • 熟悉 Gorilla Mux,并且
  • 了解如何使用持续集成 (CI) 针对数据库测试您的应用程序。

先决条件


本教程假设:

  • 基本熟悉 Go 和 PostgreSQL,以及
  • 你有工作的 Go 和 PostgreSQL 安装。您可以使用 Docker 轻松运行测试数据库。

您将在此存储库中找到演示的完整代码。

TomFern/go-mux-api


应用程序简介


在深入了解细节之前,让我们先简要了解一下我们将在本教程中构建的示例应用程序。

应用程序会做什么?


该应用程序将是一个简单的 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)
)


这是一个最小且非常简单的表格,但它应该足以帮助实现本教程的目标。

获取依赖项


在开始编写应用程序之前,我们需要获取应用程序将依赖的两个包:

  1. mux – Gorilla Mux 路由器(也称为“HTTP 请求多路复用器”,它使 Gorilla 成为最强大的 Go 库之一),并且,
  2. 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 数据库持续测试您的应用程序。

如果您有任何问题和意见,请随时将它们留在下面的部分。

文章链接