接着上一篇文章创建movies数据库模型,今天我们来完成POST请求处理。
从数据库模型的Insert()方法开始,更新该方法实现在movies表中创建一条新记录。具体来说,我们希望它执行以下SQL查询:
INSERT INTO movies (title, year, runtime, genres)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at, version
关于上面的SQL语句,有一些事情值得解释一下。
- 使用$N符号作为占位符,表示我们想要插入movies表中的数据参数。每次客户端向SQL数据库传递不可信的输入数据时,使用占位符参数来防止SQL注入攻击,这非常重要,除非您有非常明确的理由不使用它。
- 只要插入title,year,runtime和genres的值。movies表中的其余列将在插入时用系统生成的值填充——id将是一个自动递增的整数,created_at和version分别以当前时间和1来插入表中。
- 在查询的末尾,有一个returns子句。这是一个特定于postgresql的子句(它不是SQL标准),您可以使用它从INSERT、UPDATE或DELETE语句所操作的任何记录中返回对应列的值。在这个查询中,我们使用它来返回系统生成的id、created_at和version值。
执行SQL
在整个项目中,我们将坚持使用Go的database/sql包来执行我们的数据库SQL语句,而不是使用第三方ORM或其他工具。
通常使用Go的Exec()方法对数据库表执行INSERT语句。但是由于我们的SQL查询返回单行数据(多亏了returning子句),我们在这里需要使用QueryRow()方法。
回到internal/data/movies.go文件,更新代码如下:
File:internal/data/movies.go
package data
...
//添加一个占位符方法,用于在movies表中插入一条新记录。
func (m MovieModel) Insert(movie *Movie) error {
//定义SQL查询,插入一条新的movie数据,并返回系统生成数据
query := `
INSERT INTO movies (title, year, runtime, genres)
VALUES ($1, $2, $3, $4)
RETURNING id, create_at, version`
//创建args切片,包含占位符参数值。声明切片使得传入SQL中的值看起来更清晰
args := []interface{}{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}
//在连接池中使用QueryRow()方法执行SQL查询,传入args切片可变参数,并扫描生成的Id,create_at和version值到movie结构体中
return m.DB.QueryRow(query, args...).Scan(&movie.ID, &movie.CreateAt, &movie.Version)
}
该代码非常简洁,但是有一些重要的地方需要说明。
因为Insert()方法签名以*Movie指针作为参数,所以当我们调用Scan()来读取系统生成的数据时,我们正在更新参数所指向的位置的值。实际上,我们的Insert()方法改变了传递给它的Movie结构体,并将系统生成的值添加到对应字段。
接下来要讨论的是我们在args切片中声明的占位符参数输入,如下所示:
args := []interface{}{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}
将参数存放在切片中不是必须的,正如我们在代码注释说明的,能够使代码更简洁。就个人而言,我通常对具有三个以上占位符参数的SQL查询都这样做。
另外,注意到切片中的最终值了吗?为了存储movie.Genres值(它是一个字符串切片),在执行SQL查询之前,我们需要将它传递给pq.Array()适配器函数。这个函数作用就是对切片进行强制类型转换为可以直接插入数据库类型。
这里pq.Array()函数将[]string切片转换为pq.StringArray类型。因为pq.StringArray类型实现了driver.Valuer和sql.Scanner接口,它可以将原生字符串切片转换为PostgreSQL数据库可以理解的值并存储在text[]列中。
提示:你也可以在代码中使用pq.Array()函数对[]bool, []byte, []int32, []int64, []float32和[]float64进行转换。
连接到API处理程序
下面激动人心时刻到了,我们把Insert()方法连接到createMovieHandler接口处理程序中,这样POST /v1/movies接口就可以实现向数据库中添加movie数据。如下所示:
File: cmd/api/movies.go
package main
...
func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Year int32 `json:"year"`
Runtime data.Runtime `json:"runtime"`
Genres []string `json:"genres"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
movie := &data.Movie{
Title: input.Title,
Year: input.Year,
Runtime: input.Runtime,
Genres: input.Genres,
}
v := validator.New()
if data.ValidateMovie(v, movie); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
//调用movie模型的Insert()方法,传入校验后的movie指针结构体。
//将在数据库中创建movie条目,并用系统生成的信息更新结构体
err = app.models.Movies.Insert(movie)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
//当发送HTTP响应时,我们希望包含Location头信息,使客户端知道哪个URL可以查询新建对资源。
//可以创建一个空的http.header然后用Set()方法添加Location头。
//在URL中插入系统为movie生成的ID。
headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/movies/%d", movie.ID))
//使用201返回码以及更新后的movie结构体作为响应body
err = app.writeJSON(w, http.StatusCreated, envelope{"movie": movie}, headers)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
ok,我们来试试接口!
重启服务,然后打开第二个终端窗口,向POST /v1/movies端点发出如下请求:
$ BODY='{"title":"Moana","year":2016,"runtime":"107 mins", "genres":["animation","adventure"]}'
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/movies/1
Date: Sun, 28 Nov 2021 09:36:54 GMT
Content-Length: 140
{
"movie": {
"id": 1,
"title": "Moana",
"runtime": "107 mins",
"genres": [
"animation",
"adventure"
],
"Version": 1
}
}
看起来很完美。我们可以看到,JSON响应中包含movie的所有信息,包括系统生成的ID和版本号。响应还包括Location: /v1/movies/1头,指向URL可以查询到新添加到数据库的movie。
我们去数据库查看下,movie数据是否插入成功:
$ psql $GREENLIGHT_DB_DSN
psql (13.4)
Type "help" for help.
greenlight=> select * from movies;
id | create_at | title | year | runtime | genres | version
----+------------------------+-------+------+---------+-----------------------+---------
1 | 2021-11-28 17:36:54+08 | Moana | 2016 | 107 | {animation,adventure} | 1
(1 row)
创建其他数据库条目
让我们在系统中创建更多的记录,以帮助我们在构建过程中演示不同的功能。如果你跟随本系列文章操作,请执行以下命令在数据库中创建更多的movie数据:
$ BODY='{"title":"Black Panther","year":2018,"runtime":"134 mins","genres":["action","adventure"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{
"movie": {
"id": 2,
"title": "Black Panther",
"runtime": "134 mins",
"genres": [
"action",
"adventure"
],
"Version": 1
}
}
$ BODY='{"title":"Deadpool","year":2016, "runtime":"108 mins","genres":["action","comedy"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{
"movie": {
"id": 3,
"title": "Deadpool",
"runtime": "108 mins",
"genres": [
"action",
"comedy"
],
"Version": 1
}
}
$ BODY='{"title":"The Breakfast Club","year":1986, "runtime":"96 mins","genres":["drama"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{
"movie": {
"id": 4,
"title": "The Breakfast Club",
"runtime": "96 mins",
"genres": [
"drama"
],
"Version": 1
}
}
此时,您可能还需要查看PostgreSQL数据库,以确认记录是否已正确创建。您应该看到movies表的内容现在看起来与下面类似(在数组中包括适当的电影类型)。
$ psql $GREENLIGHT_DB_DSN
psql (13.4)
Type "help" for help.
greenlight=> select * from movies;
id | create_at | title | year | runtime | genres | version
----+------------------------+--------------------+------+---------+-----------------------+---------
1 | 2021-11-28 17:36:54+08 | Moana | 2016 | 107 | {animation,adventure} | 1
2 | 2021-11-28 18:34:07+08 | Black Panther | 2018 | 134 | {action,adventure} | 1
3 | 2021-11-28 18:37:13+08 | Deadpool | 2016 | 108 | {action,comedy} | 1
4 | 2021-11-28 18:38:42+08 | The Breakfast Club | 1986 | 96 | {drama} | 1
(4 rows)
附加内容
$N符号
PostgreSQL占位符参数$N符号的一个很好的特性是:您可以在SQL语句的多个位置使用相同的参数值。例如,像这样写代码是完全可以的:
// 这个SQL语句两次使用$1参数,`123`这个值将在$1占位符中使用两次。
stmt := "UPDATE foo SET bar = $1 + $2 WHERE bar = $1"
err := db.Exec(stmt, 123, 456)
if err != nil { ...
}
执行多条SQL语句
偶尔你可能会发现自己在一个数据库调用中需要执行多个SQL语句,就像这样:
stmt := `
UPDATE foo SET bar = true;
UPDATE foo SET baz = false;`
err := db.Exec(stmt)
if err != nil {
...
}
pq驱动支持在一个调用包含多条SQL语句,只要不包含任何占位符即可。如果确实包含占位符参数,那么你将在运行时收到以下错误消息:
pq: cannot insert multiple commands into a prepared statement
要解决这个问题,您需要将语句拆分为单独的数据库调用,或者如果不可能,您可以在PostgreSQL中创建一个自定义函数,作为要运行的多个SQL语句的包装器。