使用Go构建RESTful的JSON API

原文地址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))
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

编译执行这个示例将运行这个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))
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

运行上面的示例,首先需要安装包“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)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

现在我们又在上一个示例的基础上增加了两个routes,分别是:

这就是一个RESTful设计的开始。注意,最后一个路由我们增加了一个名为todoId的变量。这将允许我们向route传递变量,然后获得合适的响应记录。

基本样式

有了路由后,就可以创建一些基本的TODO样式用于发送和检索数据。在一些其他语言中使用类(class)来达到这个目的,Go中使用struct。

package main

import “time”

type Todo struct {
    Name        string
    Completed   tool
    Due         time.time
}

type Todos []Todo   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

注:

最后一行定义的类型TodosTodo的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)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

这样就创建了一个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"
    }
]
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

一个稍微好点的样式

可能你已经发现了,基于前面的样式,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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

上面的代码示例在原来的基础上增加了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)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

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,
    },
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52

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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

main.go

package main

import (
        "log"
        "net/http"
)

func main() {

    router := NewRouter()

    log.Fatal(http.ListenAndServe(":8080", router))
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

更好的路由

上面重构的一部分就是创建了一个更详细的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),
        )
    })
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

这样,如果你访问http://localhost:8080/todos,你将会看到console中有如下log输出。

2014/11/19 12:41:39 GET /todos  TodoIndex       148.324us
  • 1
  • 2

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,
    },
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

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
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

做更多的事情

现在我们已经有了一个不错的模板,是时候重新考虑我们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)
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

新增的两行代码让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)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41

为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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

更新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)
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

Posting JSON

前面所有的API都是相应GET请求的,只能输出JSON。这节将增加一个上传和存储JSON的API。在routes.go文件中增加如下route:

Route{
    "TodoCreate",
    "POST",
    "/todos",
    TodoCreate,
},
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

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)
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

上面的代码中,首先我们获取用户请求的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"
    }
]
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

我们未做的事情

现在我们已经有了一个好的开头,后面还有很多事情要做。下面是我们还未做的事情:

  • 版本控制 - 如果我们需要修改API,并且这将导致重大的更改?也许我们可以从为所有的routes添加/v1这样的前缀开始。
  • 身份认证 - 除非这是一个自由/公开的API,否则我们可能需要添加一些认证机制。建议学习JSON web tokens
  • eTags - 如果你的构建需要扩展,你可能需要实现eTags

还剩些啥?

所有的项目都是开始的时候很小,但是很快就会发展开始变得失控。如果我想把这件事带到下一个层级,并准备使其投入生产,则还有如下这些额外的事情需要做:

  • 很多的重构
  • 将这些文件封装成一些package,如JSON helpers,decorators,handlers等等。
  • 测试…是的,这个不能忽略。目前我们还没有做任何的测试,但是对于一个产品,这个是必须的。

如何获取源代码

如果你想获取本文示例的源代码,repo地址在这里:https://github.com/corylanou/tns-restful-json-api

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值