在接下来的几节中,我们将逐步构建我们的API,使接口看起来像这样:
Method | URL | 动作 |
---|---|---|
GET | /v1/healthcheck | 显示应用程序运行状况和版本信息 |
GET | /v1/movies | 显示所有电影的详情 |
POST | /v1/movies | 添加新的电影 |
GET | /v1/movies/:id | 根据id查询特定电影 |
PATCH | /v1/movies/:id | 更新特定电影 |
DELETE | /v1/movies/:id | 删除特定电影 |
如果您以前用REST风格构建过APIs,那么上面的表您可能非常熟悉,不需要太多解释。但如果你是新手,那么有几件重要的事情需要指出。
首先,具有相同URL模式的请求将基于HTTP请求方法路由到不同的处理程序。为了安全性和语义的正确性,我们为处理程序执行的操作使用适当的HTTP方法是很重要的。
总之:
Method | 用途 |
---|---|
GET | 用于只检索信息而不更改应用程序或任何数据状态的操作。 |
POST | 用于修改状态的非幂等操作。在REST API上下文中,POST通常用于创建新资源的操作。 |
PUT | 用于修改特定URL上资源状态的幂等操作。在REST API上下文中,PUT通常用于替换或更新现有资源的操作。 |
PATCH | 用于对特定URL上的资源部分更新操作。不管是幂等的还是非幂等的都是可以的。 |
DELETE | 用于删除特定URL上的资源的操作。 |
另一件重要的事情需要指出的是,我们的API接口将使用简洁URLs,在URL路径中插入参数。例如,要获取特定的电影信息,客户端将发送请求:/v1/movies/1,而不是使用电影ID到查询字符串参数中例如:GET /v1/movies?id=1。
选择路由
当你在Go中使用这种接口构建API时,你将遇到的第一个问题是http.ServeMux——Go标准库中的路由器——在功能上非常有限。特别是,它不允许基于请求方法(GET、POST等)将请求路由到不同的处理程序,也不支持url中插入参数值。
尽管您可以解决这些限制,或者实现您自己的url路由,但通常使用许多可用的第三方路由器会更容易。
在本章中,我们将httprouter包集成到我们的应用程序中。最重要的是httprouter稳定、经过良好测试,并提供了我们需要的功能——另外,由于使用了radix树来进行URL匹配,速度非常快。如果您正在构建一个对外开放的REST API,那么httprouter是一个可靠的选择。
如果你正跟随本书操作,请使用httpprouter的1.3.0版本,如下所示:
$ go get github.com/julienschmidt/[email protected]
go: downloading github.com/julienschmidt/httprouter v1.3.0
go get: added github.com/julienschmidt/httprouter v1.3.0
注意:如果你电脑上已经有其他项目使用httprouter v1.3.0包,那么可以使用已经存在的版本将不会看到go: downloading...这些信息
为了演示httprouter是如何工作的,我们首先将两个接口添加到代码库中,用于创建新电影并查询特定电影详情。在本章结束时,我们的API接口将看起来像这样:
Method | URL | 动作 |
---|---|---|
GET | /v1/healthcheck | 显示应用程序运行状况和版本信息 |
GET | /v1/movies | 显示所有电影的详情 |
POST | /v1/movies | 添加新的电影 |
封装API路由
为了防止main()函数随着API的增长而变得混乱,我们将所有的路由规则封装在一个新的cmd/api/routes.go文件中。
如果您按照前面的步骤操作,请创建这个新文件并添加以下代码:
$ touch cmd/api/routes.go
package main
import (
"github.com/julienschmidt/httprouter"
"net/http"
)
func (app *application) routes() http.Handler {
//初始化一个新的httprouter路由器实例
router := httprouter.New()
//使用HandlerFunc()函数为接口注册相关方法,URL模式和处理函数。
//注意http.MethodGet和http.MethodPost是常量,等价于字符串"GET"和"POST"
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)
//返回httprouter实例
return router
}
提示:httprouter包还提供了一个router.Handler()方法,当你想注册一个常规的http.handler(而不是处理程序函数,就像我们在上面的代码)。
以这种方式封装路由规则有几个好处。第一个好处是它使main()函数保持简洁,并确保所有路由都在一个地方定义。另一个好处是,现在我们可以通过初始化application实例并在其上调用routes()方法,轻松地在任何测试代码中访问路由器。
接下来需要更新main()函数来删除http.ServeMux声明,并使用app.routes()返回的httprouter实例作为服务器处理程序。像这样:
File: cmd/api/main.go
package main
...
func main() {
var cfg config
flag.IntVar(&cfg.port, "port", 4000, "API server port")
flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") flag.Parse()
logger := log.New(os.Stdout, "", log.Ldate|log.Ltime)
app := &application{ config: cfg,
logger: logger, }
// 使用http.router实例做服务器的处理程序handler
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.port), Handler: app.routes(),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second,
}
logger.Printf("starting %s server on %s", cfg.env, srv.Addr)
err := srv.ListenAndServe()
logger.Fatal(err)
}
添加新的handler函数
既然路由规则已经设置好了,我们需要为新增的接口创建createMovieHandler和showMovieHandler方法。这里的showMovieHandler比较特殊,因为作为URL的一部分,我们希望从URL中提取电影ID参数,并在HTTP响应中使用它。
继续并创建一个新的cmd/api/movies.go文件保存这两个新的处理程序:
$ touch cmd/api/movies.go
添加如下代码:
package main
import (
"fmt"
"net/http" "strconv"
"github.com/julienschmidt/httprouter"
)
// 为"POST /v1/movies"接口添加createMovieHandler方法,这里简单的返回文本
func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "create a new movie") }
// 为 "GET /v1/movies/:id" 接口添加showMovieHandler方法. 这里我们从请求url中解析出电影id,并在响应中返回。
func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {
// 当httprouter解析请求时,任何内置的URL参数都将存储在请求上下文中。
//可以使用ParamsFromContext()函数检索包含这些参数名称和值的切片。
params := httprouter.ParamsFromContext(r.Context())
// 然后我们可以使用ByName()方法从切片中获取“id”参数的值。在我们的项目中,所有的电影都有一个唯一的正整数ID
//但是ByName()返回的值总是一个字符串。所以我们尝试将它转换为一个以10为基数的整数(bit size为64)。
//如果参数不能被转换或者小于1,我们知道ID无效,所以我们使用http.NotFound()返回404 Not Found响应。
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
http.NotFound(w, r)
return
}
// 否则,在响应中插入电影ID
fmt.Fprintf(w, "show the details of movie %d\n", id)
}
有了这些,我们现在可以试一下了!重新启动API应用程序…
$ go run ./cmd/api
2021/04/06 08:57:25 starting development server on :4000
然后,在服务器运行时,打开第二个终端窗口,并使用curl向不同的接口发出请求。如果一切设置正确,你会看到一些类似这样的响应:
$ curl localhost:4000/v1/healthcheck
status: available environment: development version: 1.0.0
$ curl -X POST localhost:4000/v1/movies
create a new movie
$ curl localhost:4000/v1/movies/123
show the details of movie 123
注意,在最后一个例子中,电影id参数123的值是如何从URL中成功检索到并包含在响应中的。
您可能还想尝试使用不支持的HTTP方法对特定的URL发出一些请求。例如,我们尝试向/v1/healthcheck发送一个POST请求:
$ curl -i -X POST localhost:4000/v1/healthcheck
HTTP/1.1 405 Method Not Allowed
Allow: GET, OPTIONS
Content-Type: text/plain; charset=utf-8 X-Content-Type-Options: nosniff
Date: Tue, 06 Apr 2021 06:59:04 GMT Content-Length: 19
Method Not Allowed
看起来很不错。httprouter包自动发送了一个405 Method Not Allowed响应,包括一个Allow报头,它列出了接口支持的HTTP方法。
同样地,你可以向一个特定的URL发出一个OPTIONS请求,httprouter会返回一个带有Allow报头的响应,其中详细说明了所支持的HTTP方法。像这样:
$ curl -i -X OPTIONS localhost:4000/v1/healthcheck
HTTP/1.1 200 OK
Allow: GET, OPTIONS
Date: Tue, 06 Apr 2021 07:01:29 GMT Content-Length: 0
最后,您可能想尝试在URL中使用负数或非数字id值向GET /v1/movies/:id接口发出请求。这应该会得到 404 Not Found响应,类似于:
$ curl -i localhost:4000/v1/movies/abc
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8 X-Content-Type-Options: nosniff
Date: Tue, 06 Apr 2021 07:02:01 GMT Content-Length: 19
404 page not found
为读取请求参数ID创建辅助函数
从URL(如/v1/movies/:id)中提取id参数的代码是我们在应用程序中需要反复使用的,因此我们将此逻辑抽象为一个可重用的辅助方法。
创建一个新的文件:cmd/api/helper.go
$ touch cmd/api/helpers.go
并向application结构体添加一个新的readdparam()方法,如下所示:
File: cmd/api/helpers.go
package main
import ( "errors"
"net/http" "strconv"
"github.com/julienschmidt/httprouter"
)
// 从当前请求上下文检索“id”URL参数, 然后将其转换为一个整数并返回。如果操作不成功,则返回0和一个错误.
func (app *application) readIDParam(r *http.Request) (int64, error) {
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
return 0, errors.New("invalid id parameter")
}
return id, nil
}
readIDParam()方法不使用来自application结构体的任何依赖项,因此它可能只是一个常规函数,而不是application上的方法。但一般来说,我建议设置所有特定于应用程序的处理程序和帮助函数,以便它们是application上的方法。它有助于保持代码结构的一致性,并且在那些处理程序和帮助函数稍后更改并且它们确实需要访问依赖项时代码不会过时。
有了这个辅助方法,我们的showMovieHandler中的代码现在可以变得简单得多:
File: cmd/api/movies.go
package main
import ( "fmt"
"net/http"
)
...
func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
fmt.Fprintf(w, "show the details of movie %d\n", id)
}
附加说明
路径冲突
要意识到httprouter不允许可能匹配相同请求的冲突路由。因此,你不能注册一个像GET /foo/new这样的路由和另一个带有与之冲突的参数的路由,比如GET /foo/:id。
如果你使用标准REST结构来设计API的话,就不会有这种问题出现。因为不允许有冲突的路由,所以不需要担心路由优先级规则,这就减少了应用程序中出现错误和意外行为的风险。但是如果你需要支持这种冲突路由的话(例如,您可能需要完全复制现有API的接口以实现向后兼容性)那么我建议你看看pat, chi或者Gorilla mux。所有这些都是很好的第三方路由器包,它们允许冲突的路由。
定制httprouter行为
httprouter包提供了一些配置选项,您可以使用这些选项进一步定制应用程序的行为,包括启用末尾斜杠重定向和启用自动URL路径清理。更多可设置的信息可以参考这里。