原文地址http://thenewstack.io/make-a-restful-json-api-go/
这篇文章不仅仅讨论如何使用Go构建RESTful的JSON API,同时也会讨论如何设计好的RESTful API。如果你曾经遭遇了未遵循良好设计的API,那么你最终将写烂代码来使用这些垃圾API。希望阅读这篇文章后,你能够对好的API应该是怎样的有更多的认识。
JSON API是啥?
在JSON前,XML是一种主流的文本格式。笔者有幸XML和JSON都使用过,毫无疑问,JSON是明显的赢家。本文不会深入涉及JSON API的概念,在jsonapi.org可以找到的详细的描述。
Sponsor Note
SpringOne2GX是一个专门面向App开发者、解决方案和数据架构师的会议。议题都是专门针对程序猿(媛),架构师所使用的流行的开源技术,如:Spring IO Projects,Groovy & Grails,Cloud Foundry,RabbitMQ,Redis,Geode,Hadoop and Tomcat等。
一个基本的Web Server
一个RESTful服务本质上首先是一个Web service。下面的是示例是一个最简单的Web server,对于任何请求都简单的直接返回请求链接:
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))
}
编译执行这个示例将运行这个server,监听8080端口。尝试使用http://localhost:8080访问server。
增加一个路由
当大多数标准库开始支持路由,我发现大多数人都搞不清楚它们是如何工作的。我在项目中使用过几个第三方的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))
}
运行上面的示例,首先需要安装包“github.com/gorilla/mux”.可以直接使用命令go get
遍历整个source code安装所有未安装的依赖包。
译者注:
也可以使用
go get "github.com/gorilla/mux"
直接安装包。
上面的示例创建了一个简单的router,增加了一个“/”路由,并分配Index handler响应针对指定的endpoint的访问。这是你会发现在第一个示例中还能访问的如http://localhost:8080/foo这类的链接在这个示例中不再工作了,这个示例将只能响应链接http://localhost:8080.
创建更多的基本路由
上一节我们已经有了一个路由,是时候创建更多的路由了。假设我们将要创建一个基本的TODO app。
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)
}
现在我们又在上一个示例的基础上增加了两个routes,分别是:
- ToDo index route:http://localhost:8080/todos
- ToDo show route: http://localhost:8080/todos/{todoId}
这就是一个RESTful设计的开始。注意,最后一个路由我们增加了一个名为todoId
的变量。这将允许我们向route传递变量,然后获得合适的响应记录。
基本样式
有了路由后,就可以创建一些基本的TODO样式用于发送和检索数据。在一些其他语言中使用类(class)来达到这个目的,Go中使用struct。
package main
import “time”
type Todo struct {
Name string
Completed tool
Due time.time
}
type Todos []Todo
注:
最后一行定义的类型
Todos
是Todo
的slice。稍后你将会看到怎么使用它。
返回JSON
基于上面的基本样式,我们可以模拟真实的响应,并基于静态数据列出TodoIndex。
func TodoIndex(w http.ResponseWriter, r *http.Request) {
todos := Todos{
Todo{Name: "Write presentation"},
Todo{Name: "Host meetup"},
}
json.NewEncoder(w).Encode(todos)
}
这样就创建了一个Todos的静态slice,并被编码响应用户请求。如果这时你访问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"
}
]
一个稍微好点的样式
可能你已经发现了,基于前面的样式,todos返回的并不是一个标准的JSON数据包(JSON格式定义中不包含大写字母)。虽然这个问题有那么一点微不足道,但是我们还是可以解决它:
package main
import "time"
type Todo struct {
Name string `json:"name"`
Completed bool `json:"completed"`
Due time.Time `json:"due"`
}
type Todos []Todo
上面的代码示例在原来的基础上增加了struct tags,这样可以指定JSON的编码格式。
文件拆分
到此我们需要对这个项目稍微做下重构。现在一个文件包含了太多的内容。我们将创建如下几个文件,并重新组织文件内容:
- main.go
- handlers.go
- routes.go
- todo.go
handlers.go
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)
}
routes.go
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,
},
}
todo.go
package main
import "time"
type Todo struct {
Name string `json:"name"`
Completed bool `json:"completed"`
Due time.Time `json:"due"`
}
type Todos []Todo
main.go
package main
import (
"log"
"net/http"
)
func main() {
router := NewRouter()
log.Fatal(http.ListenAndServe(":8080", router))
}
更好的路由
上面重构的一部分就是创建了一个更详细的route文件,新文件中使用了一个struct包含了更多的有关路由的详细信息。尤其是,我们可以通过这个struct指定请求的动作,如GET、POST、DELETE等。
记录Web Log
前面的拆分文件中,我还有一个更长远的考虑。稍后你将会看到,拆分后我将能够很轻松的使用其他函数装饰我的http handlers。这一节我们将使用这个功能让我们的web能够像其他现代的网站一样为web访问请求记Log。在Go中,目前还没有一个web logging package,也没有标准库提供相应的功能。所以我们不得不自己实现一个。
在前面拆分文件的基础上,我们创建一个叫logger.go
的新文件,并在文件中添加如下代码:
package main
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),
)
})
}
这样,如果你访问http://localhost:8080/todos
,你将会看到console中有如下log输出。
2014/11/19 12:41:39 GET /todos TodoIndex 148.324us
Routes file开始疯狂…继续重构
基于上面的拆分,你会发现继续照着这个节奏发展,routes.go文件将变得越来越庞大。所以我们继续拆分这个文件。将其拆分为如下两个文件:
- router.go
- routes.go
routes.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,
},
}
router.go
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
}
做更多的事情
现在我们已经有了一个不错的模板,是时候重新考虑我们handlers了,让handler能做更多的事情。首先我们在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)
}
}
新增的两行代码让TodoIndex handler多做两件事。首先返回client期望的json,并告知内容类型。然后明确的设置一个状态码。
Go的net/http server在Header中没有显示的说明内容类型时将尝试为我们猜测内容类型,但是并不是总是那么准确。所以在我们知道content类型的情况下,我们应该总是自己设置类型。
等等,数据库在哪儿?
如果我们继续构造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
现在我们已经有了一个粗糙的数据库。我们可以为Todo创建一个ID,用于标识和见识Todo item。数据结构更新如下:
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 handler
数据存储在数据库后,不必在handler中生成数据,直接通过ID检索数据库即可得到相应内容。修改handler如下:
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)
}
}
Posting JSON
前面所有的API都是相应GET请求的,只能输出JSON。这节将增加一个上传和存储JSON的API。在routes.go
文件中增加如下route:
Route{
"TodoCreate",
"POST",
"/todos",
TodoCreate,
},
The Create endpoint
上面创建了一个新的router,现在为这个新的route创建一个endpoint。在handlers.go
文件增加TodoCreate
handler。代码如下:
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。注意,在获取body时我们使用了io.LimitReader
,这是一个防止你的服务器被恶意攻击的好方法。试想如果有人给你发送了一个500GB的json。
读取body后,将其内容解码到Todo struct中。如果解码失败,我们要做的事情不仅仅是返回一个‘422’这样的状态码,同时还会返回一段包含错误信息的json。这能够使客户端不仅知道有错误发生,还能了解错误发生在哪儿。
最后,如果一切顺利,我们将向客户端返回状态码201,同时我们还向客户端返回创建的实体内容,这些信息客户端在后面的操作中可能会用到。
Post JSON
所有的工作的完成后,我们就可以上传下json string测试一下了。Sample及返回结果如下所示:
curl -H "Content-Type: application/json" -d ‘{"name":"New Todo"}‘ http://localhost:8080/todos
Now, if you go to http://localhost/todos we should see the following response:
[
{
"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,并且这将导致重大的更改?也许我们可以从为所有的routes添加/v1这样的前缀开始。
- 身份认证 - 除非这是一个自由/公开的API,否则我们可能需要添加一些认证机制。建议学习JSON web tokens
- eTags - 如果你的构建需要扩展,你可能需要实现eTags
还剩些啥?
所有的项目都是开始的时候很小,但是很快就会发展开始变得失控。如果我想把这件事带到下一个层级,并准备使其投入生产,则还有如下这些额外的事情需要做:
- 很多的重构
- 将这些文件封装成一些package,如JSON helpers,decorators,handlers等等。
- 测试…是的,这个不能忽略。目前我们还没有做任何的测试,但是对于一个产品,这个是必须的。
如何获取源代码
如果你想获取本文示例的源代码,repo地址在这里:https://github.com/corylanou/tns-restful-json-api