Go 语言 RESTful JSON API 创建
Go 语言开发 RESTFul JSON API#
RESTful API 在 Web 项目开发中广泛使用,本文针对 Go 语言如何一步步实现 RESTful JSON API 进行讲解, 另外也会涉及到 RESTful 设计方面的话题。
也许我们之前有使用过各种各样的 API, 当我们遇到设计很糟糕的 API 的时候,简直感觉崩溃至极。希望通过本文之后,能对设计良好的 RESTful API 有一个初步认识。
JSON API 是什么?#
JSON 之前,很多网站都通过 XML 进行数据交换。如果在使用过 XML 之后,再接触 JSON, 毫无疑问,你会觉得世界多么美好。这里不深入 JSON API 的介绍,有兴趣可以参考 jsonapi。
基本的 Web 服务器#
从根本上讲,RESTful 服务首先是 Web 服务。 因此我们可以先看看 Go 语言中基本的 Web 服务器是如何实现的。下面例子实现了一个简单的 Web 服务器,对于任何请求,服务器都响应请求的 URL 回去。
package main
import (
"fmt"
"html"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
上面基本的 Web 服务器使用 Go 标准库的两个基本函数 HandleFunc
和 ListenAndServe
。
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
运行上面的基本 Web 服务,就可以直接通过浏览器访问 http://localhost:8080 来访问。
> go run basic_server.go
添加路由#
虽然标准库包含有 router, 但是我发现很多人对它的工作原理感觉很困惑。 我在自己的项目中使用过各种不同的第三方 router 库。 最值得一提的是 Gorilla Web ToolKit 的 mux router。
另外一个流行的 router 是来自 Julien Schmidt 的叫做 httprouter 的包。
package main
import (
"fmt"
"html"
"log"
"net/http"
"github.com/gorilla/mux"
)
func main() {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/", Index)
log.Fatal(http.ListenAndServe(":8080", router))
}
func Index(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
}
要运行上面的代码,首先使用 go get 获取 mux router 的源代码:
> go get github.com/gorilla/mux
上面代码创建了一个基本的路由器,给请求 "/" 赋予 Index 处理器,当客户端请求 http://localhost:8080 / 的时候,就会执行 Index 处理器。
如果你足够细心,你会发现之前的基本 web 服务访问 http://localhost:8080/abc 能正常响应: 'Hello, "/abc"', 但是在添加了路由之后,就只能访问 http://localhost:8080 了。 原因很简单,因为我们只添加了对 "/" 的解析,其他的路由都是无效路由,因此都是 404。
创建一些基本的路由#
既然我们加入了路由,那么我们就可以再添加更多路由进来了。
假设我们要创建一个基本的 ToDo 应用, 于是我们的代码就变成下面这样:
package main
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/mux"
)
func main() {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/", Index)
router.HandleFunc("/todos", TodoIndex)
router.HandleFunc("/todos/{todoId}", TodoShow)
log.Fatal(http.ListenAndServe(":8080", router))
}
func Index(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Welcome!")
}
func TodoIndex(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Todo Index!")
}
func TodoShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
todoId := vars["todoId"]
fmt.Fprintln(w, "Todo Show:", todoId)
}
在这里我们添加了另外两个路由: todos 和 todos/{todoId}。
这就是 RESTful API 设计的开始。
请注意最后一个路由我们给路由后面添加了一个变量叫做 todoId。
这样就允许我们传递 id 给路由,并且能使用具体的记录来响应请求。
基本模型#
路由现在已经就绪,是时候创建 Model 了,可以用 model 发送和检索数据。在 Go 语言中,model 可以使用结构体来实现,而其他语言中 model 一般都是使用类来实现。
package main
import (
"time"
)
type Todo struct {
Name string
Completed bool
Due time.Time
}
type Todos []Todo
上面我们定义了一个 Todo 结构体,用于表示待做项。 另外我们还定义了一种类型 Todos, 它表示待做列表,是一个数组,或者说是一个分片。
稍后你就会看到这样会变得非常有用。
返回一些 JSON#
我们有了基本的模型,那么我们可以模拟一些真实的响应了。我们可以为 TodoIndex 模拟一些静态的数据列表。
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/gorilla/mux"
)
// ...
func TodoIndex(w http.ResponseWriter, r *http.Request) {
todos := Todos{
Todo{Name: "Write presentation"},
Todo{Name: "Host meetup"},
}
json.NewEncoder(w).Encode(todos)
}
// ...
现在我们创建了一个静态的 Todos 分片来响应客户端请求。注意,如果你请求 http://localhost:8080/todos, 就会得到下面的响应:
[
{
"Name": "Write presentation",
"Completed": false,
"Due": "0001-01-01T00:00:00Z"
},
{
"Name": "Host meetup",
"Completed": false,
"Due": "0001-01-01T00:00:00Z"
}
]
更好的 Model#
对于经验丰富的老兵来说,你可能已经发现了一个问题。响应 JSON 的每个 key 都是首字母答写的,虽然看起来微不足道,但是响应 JSON 的 key 首字母大写不是习惯的做法。 那么下面教你如何解决这个问题:
type Todo struct {
Name string `json:"name"`
Completed bool `json:"completed"`
Due time.Time `json:"due"`
}
其实很简单,就是在结构体中添加标签属性, 这样可以完全控制结构体如何编排 (marshalled) 成 JSON。
拆分代码#
到目前为止,我们所有代码都在一个文件中。显得杂乱, 是时候拆分代码了。我们可以将代码按照功能拆分成下面多个文件。
我们准备创建下面的文件,然后将相应代码移到具体的代码文件中:
- main.go: 程序入口文件。
- handlers.go: 路由相关的处理器。
- routes.go: 路由。
- todo.go: todo 相关的代码。
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gorilla/mux"
)
func Index(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Welcome!")
}
func TodoIndex(w http.ResponseWriter, r *http.Request) {
todos := Todos{
Todo{Name: "Write presentation"},
Todo{Name: "Host meetup"},
}
if err := json.NewEncoder(w).Encode(todos); err != nil {
panic(err)
}
}
func TodoShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
todoId := vars["todoId"]
fmt.Fprintln(w, "Todo show:", todoId)
}
package main
import (
"net/http"
"github.com/gorilla/mux"
)
type Route struct {
Name string
Method string
Pattern string
HandlerFunc http.HandlerFunc
}
type Routes []Route
func NewRouter() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
for _, route := range routes {
router.
Methods(route.Method).
Path(route.Pattern).
Name(route.Name).
Handler(route.HandlerFunc)
}
return router
}
var routes = Routes{
Route{
"Index",
"GET",
"/",
Index,
},
Route{
"TodoIndex",
"GET",
"/todos",
TodoIndex,
},
Route{
"TodoShow",
"GET",
"/todos/{todoId}",
TodoShow,
},
}
package main
import "time"
type Todo struct {
Name string `json:"name"`
Completed bool `json:"completed"`
Due time.Time `json:"due"`
}
type Todos []Todo
package main
import (
"log"
"net/http"
)
func main() {
router := NewRouter()
log.Fatal(http.ListenAndServe(":8080", router))
}
更好的 Routing#
我们重构的过程中,我们创建了一个更多功能的 routes 文件。 这个新文件利用了一个包含多个关于路由信息的结构体。 注意,这里我们可以指定请求的类型,例如 GET, POST, DELETE 等等。
输出 Web 日志#
在拆分的路由文件中,我也包含有一个不可告人的动机。稍后你就会看到,拆分之后很容易使用另外的函数来修饰 http 处理器。
首先我们需要有对 web 请求打日志的能力,就像很多流行 web 服务器那样的。 在 Go 语言中,标准库里边没有 web 日志包或功能,因此我们需要自己创建。
package logger
import (
"log"
"net/http"
"time"
)
func Logger(inner http.Handler, name string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
inner.ServeHTTP(w, r)
log.Printf(
"%s\t%s\t%s\t%s",
r.Method,
r.RequestURI,
name,
time.Since(start),
)
})
}
上面我们定义了一个 Logger 函数,可以给 handler 进行包装修饰。
这是 Go 语言中非常标准的惯用方式。其实也是函数式编程的惯用方式。 非常有效,我们只需要将 Handler 传入该函数, 然后它会将传入的 handler 包装一下,添加 web 日志和耗时统计功能。
应用 Logger 修饰器#
要应用 Logger 修饰符, 我们可以创建 router, 我们只需要简单的将我们所有的当前路由都包到其中, NewRouter 函数修改如下:
func NewRouter() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
for _, route := range routes {
var handler http.Handler
handler = route.HandlerFunc
handler = Logger(handler, route.Name)
router.
Methods(route.Method).
Path(route.Pattern).
Name(route.Name).
Handler(handler)
}
return router
}
现在再次运行我们的程序,我们就可以看到日志大概如下:
2014/11/19 12:41:39 GET /todos TodoIndex 148.324us
这个路由文件太疯狂... 让我们重构它吧#
路由 routes 文件现在已经变得稍微大了些, 下面我们将它分解成多个文件:
- routes.go
- router.go
package main
import "net/http"
type Route struct {
Name string
Method string
Pattern string
HandlerFunc http.HandlerFunc
}
type Routes []Route
var routes = Routes{
Route{
"Index",
"GET",
"/",
Index,
},
Route{
"TodoIndex",
"GET",
"/todos",
TodoIndex,
},
Route{
"TodoShow",
"GET",
"/todos/{todoId}",
TodoShow,
},
}
package main
import (
"net/http"
"github.com/gorilla/mux"
)
func NewRouter() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
for _, route := range routes {
var handler http.Handler
handler = route.HandlerFunc
handler = Logger(handler, route.Name)
router.
Methods(route.Method).
Path(route.Pattern).
Name(route.Name).
Handler(handler)
}
return router
}
另外再承担一些责任#
到目前为止,我们已经有了一些相当好的样板代码 (boilerplate), 是时候重新审视我们的处理器了。我们需要稍微多的责任。 首先修改 TodoIndex,添加下面两行代码:
func TodoIndex(w http.ResponseWriter, r *http.Request) {
todos := Todos{
Todo{Name: "Write presentation"},
Todo{Name: "Host meetup"},
}
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(todos); err != nil {
panic(err)
}
}
这里发生了两件事。 首先,我们设置了响应类型并告诉客户端期望接受 JSON。第二,我们明确的设置了响应状态码。
Go 语言的 net/http 服务器会尝试为我们猜测输出内容类型 (然而并不是每次都准确的), 但是既然我们已经确切的知道响应类型,我们总是应该自己设置它。
稍等片刻,我们的数据库在哪里?#
很明显,如果我们要创建 RESTful API, 我们需要一些用于存储和检索数据的地方。然而,这个是不是本文的范围之内, 因此我们将简单的创建一个非常简陋的模拟数据库 (非线程安全的)。
我们创建一个 repo.go 文件,内容如下:
package main
import "fmt"
var currentId int
var todos Todos
// Give us some seed data
func init() {
RepoCreateTodo(Todo{Name: "Write presentation"})
RepoCreateTodo(Todo{Name: "Host meetup"})
}
func RepoFindTodo(id int) Todo {
for _, t := range todos {
if t.Id == id {
return t
}
}
// return empty Todo if not found
return Todo{}
}
func RepoCreateTodo(t Todo) Todo {
currentId += 1
t.Id = currentId
todos = append(todos, t)
return t
}
func RepoDestroyTodo(id int) error {
for i, t := range todos {
if t.Id == id {
todos = append(todos[:i], todos[i+1:]...)
return nil
}
}
return fmt.Errorf("Could not find Todo with id of %d to delete", id)
}
给 Todo 添加 ID#
我们创建了模拟数据库,我们使用并赋予 id, 因此我们相应的也需要更新我们的 Todo 结构体。
package main
import "time"
type Todo struct {
Id int `json:"id"`
Name string `json:"name"`
Completed bool `json:"completed"`
Due time.Time `json:"due"`
}
type Todos []Todo
更新我们的 TodoIndex#
要使用数据库,我们需要在 TodoIndex 中检索数据。修改代码如下:
func TodoIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(todos); err != nil {
panic(err)
}
}
POST JSON#
到目前为止,我们只是输出 JSON, 现在是时候进入存储一些 JSON 了。
在 routes.go 文件中添加如下路由:
Route{
"TodoCreate",
"POST",
"/todos",
TodoCreate,
},
Create 路由#
func TodoCreate(w http.ResponseWriter, r *http.Request) {
var todo Todo
body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576))
if err != nil {
panic(err)
}
if err := r.Body.Close(); err != nil {
panic(err)
}
if err := json.Unmarshal(body, &todo); err != nil {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(422) // unprocessable entity
if err := json.NewEncoder(w).Encode(err); err != nil {
panic(err)
}
}
t := RepoCreateTodo(todo)
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(t); err != nil {
panic(err)
}
}
首先我们打开请求的 body。 注意我们使用 io.LimitReader。这样是保护服务器免受恶意攻击的好方法。假设如果有人想要给你服务器发送 500GB 的 JSON 怎么办?
我们读取 body 以后,我们解构 Todo 结构体。 如果失败,我们作出正确的响应,使用恰当的响应码 422, 但是我们依然使用 json 响应回去。 这样可以允许客户端理解有错发生了, 而且有办法知道到底发生了什么错误。
最后,如果所有都通过了,我们就响应 201 状态码,表示请求创建的实体已经成功创建了。 我们同样还是响应回代表我们创建的实体的 json, 它会包含一个 id, 客户端可能接下来需要用到它。
POST 一些 JSON#
我们现在有了伪 repo, 也有了 create 路由,那么我们需要 post 一些数据。 我们使用 curl 通过下面的命令来达到这个目的:
curl -H "Content-Type: application/json" -d '{"name": "New Todo"}' http://localhost:8080/todos
如果你再次通过 http://localhost:8080/todos 访问,大概会得到下面的响应:
[
{
"id": 1,
"name": "Write presentation",
"completed": false,
"due": "0001-01-01T00:00:00Z"
},
{
"id": 2,
"name": "Host meetup",
"completed": false,
"due": "0001-01-01T00:00:00Z"
},
{
"id": 3,
"name": "New Todo",
"completed": false,
"due": "0001-01-01T00:00:00Z"
}
]
我们还没有做的事情#
虽然我们已经有了很好的开端,但是还有很多事情没有做:
- 版本控制:如果我们需要修改 API, 结果完全改变了怎么办?可能我们需要在我们的路由开头加上 /v1/prefix?
- 授权:除非这些都是公开 / 免费 API, 我们可能还需要授权。 建议学习 JSON web tokens 的东西。
eTag - 如果你正在构建一些需要扩展的东西,你可能需要实现 eTag。
还有什么?#
对于所有项目来说,开始都很小,但是很快就变得失控了。但是如果我们想要将它带到另外一个层次, 让他生产就绪, 还有一些额外的事情需要做:
- 大量重构 (refactoring).
- 为这些文件创建几个包,例如一些 JSON 助手、修饰符、处理器等等。
- 测试, 使得,你不能忘记这点。这里我们没有做任何测试。对于生产系统来说,测试是必须的。
源代码#
https://github.com/corylanou/...
总结#
对我来说,最重要的,需要记住的是我们要建立一个负责任的 API。 发送适当的状态码,header 等,这些是 API 广泛采用的关键。我希望本文能让你尽快开始自己的 API。
推荐文章: