介绍
本教程将说明如何在 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 数据库持续测试您的应用程序。
如果您有任何问题和意见,请随时将它们留在下面的部分。
- 登录 发表评论