RESTful Services in Go

在本章中,我们将讨论以下议题:

  • 我们应用程序API的设计策略
  • REST基础
  • 其它web service体系和方法
  • 数据编码和选择数据格式
  • REST行为和它们可以做什么
  • 通过Gorilla mux创建终点
  • 处理你应用程序的版本

设计你的应用程序

当我们在开始创建我们的social network应用程序时,我们对数据和数据之间的关系有了大概的想法。当我们将这些扩展成服务,我们不仅仅是将数据的类型传递给终端,还包括它们之间的关系和行为.

比如,如果我们想要找到一个user, 我们将假设数据是保存在数据库中的,数据库表为users. 我们期望使用/api/users端点检索到所有的用户信息,这是很公平,但是,怎样找到指定的用户?怎么样查找到两个用户之间联系?怎么样编辑一个用户在另一个用户图片的注释?等等

这些事情都是我们应该考虑的,而不仅是在我们的应用程序中,而是应该web services中。

此时,我们应用程序的数据集非常简单,所以我们创建,检索,更新和删除用户以及用户之间的关系,即为一个传统社交网络.

首先,我们需要修改users表,我们之前仅设置了user_nickname为唯一索引。在这里我们增加user_email也为唯一索引。这主要是从安全的角度来想,一个用户即只有一个邮件地址。

ALTER TABLE `users`
    ADD UNIQUE INDEX `user_email` (`user_email`);

现在每个用户只有一个e-mail地址,这是有道理的,对不对?

下一步,让我们继续创建用户之前的关系。这不仅仅包含朋友/跟随,还包括阻止。所以对于这些关系,我们需要创建一张表。

CREATE TABLE `users_relationships` (
    `users_relationship_id` INT(13) NOT NULL,
    `from_user_id` INT(10) NOT NULL,
    `to_user_id` INT(10) unsigned NOT NULL,
    `users_relationship_type` VARCHAR(10) NOT NULL,
    `users_relationship_timestamp` DATETIME NOT NULL DEFAULT
    CURRENT_TIMESTAMP,
    PRIMARY KEY (`users_relationship_id`),
    INDEX `from_user_id` (`from_user_id`),
    INDEX `to_user_id` (`to_user_id`),
    INDEX `from_user_id_to_user_id` (`from_user_id`, `to_user_id`),
    INDEX `from_user_id_to_user_id_users_relationship_type` (`from_user_id`,
    `to_user_id`, `users_relationship_type`)
)

到目前为前,我们可以创建,检索,更新和删除用户和用户之间的关系信息。我们下一步创建API端点,使得web服务的客户可以创建,检索,更新和删除用户及用户之间的关系。

在上一章中,我们创建了第一个端点,/api/user/create和/api/user/read. 可是如果我们想完全控制我们刚刚讨论的数据,我们还要做更多的事情。

不过在那之前,让我们先讨论一些关于web service的重要概念,特别是关于REST的。

REST概述

什么是REST,它从哪来? REST表示Representational state transfer. 这非常重要,因为数据的表现(数据的元数据)是数据输送时的主要部分。

state有点岐义,无状态性(statelessness)其时才是整个体系的核心

简单的讲,REST非常简单,数据的表现是无状态的,能过HTTP传递,它们是具有统一资源标识符,也包含控制机制,比如缓存指令。

整个REST是Roy Fielding在加州大学欧文分校的论文提出的。标准化是由World Wide Web Consortium(W3)中确认。

一个REST应用或者API要求多个重要的组件,我们将描绘一下它的大纲

Making a representation in an API

API最重要的一个组件是数据,我们将它们作为我们web service的一部分进行传递。通常数据的格式是文本格式,比如JSON, RSS/XML,甚至是二进制数据。

出于设计一个web service的目的,最重要的是确认数据格式。举例来说,如果你创建了一个web service用来传递图片数据,你可以将数据塞进一个文本格式。比如将二进制数据翻译成Base64编码,然后通过JSON。但这不是很常见。

然而,API重要的一个考量是数据大小的节约。如果我们采用之前的例子,将图片数据转换为Base64, 我们将增加40%的大小。如果这样做,我们将会增加我们服务的延时和引入潜在的烦恼。所以如果我们可以可靠的传送已存在的数据,我们没有必须要这么做。

模型中的representation还应该提供一个重要的角色-满足所有的客户端需要,比如更新,删除,检索特定的资源。

Self-description

当我们在说self-description, 我们也可以描述它为自包含REST中的两个核心组件——其中一个是对于每个客户端的请求都应该包含一个响应所必要的信息,另一人是它应该包含(明确或非明确)如何处理的信息。

第二部分涉及到缓存规则,这在第一章有中简短的介绍,Our First API in Go.

API请求中包含的缓存信息的值非常重要,它消除了不必要的传输

这也就带来了REST中无状态性的概念,即每一个请求都必须包含足够的信息。

这意味着我们会需要放弃传统的web体系中的cookie和session. 从本质上讲,这不是RESTful. 比如,我们的客户端不支持cookie或者session.

The importance of a URI

URI或URL是一个好的API设计的重要因素,它包含以下几个原因:

  • URI应该是多信息性的。包含的信息不应该仅仅是数据端点。还应该包含我们期望返回的数据。比如,/api/users意味着我们期望用户的集合信息。而/api/users/12345表明,我们期望获得一个指定用户的信息
  • URL在未来不会失效。在不久后,我们将讨论版本的问题,这对于一个稳定的资源端点来说非常重要。如果你服务的客户端在它们的应用中发生错误或者无效链接,而没有任何警告,会导致很不好的用户体验。
  • 不管你对你开发的API或者web服务多有远见,事情都会改变。有鉴于此,我们应该利用HTTP的状态码,指出新的位置或者错误,而不是什么都不反应.

HATEOAS

HATEOAS表示Hypermedia as the Engine of Application State, 它是REST体系中URI的主要约束。它背后的核心原理是要求APIs不应该参考固定资源的名称或者说资源的实际层级,而是它们应该关注于描述媒体请求或者是定义应用程序的状态。

你可以阅读更多关于REST的资源,可以通过http://roy.gbiv.com/untangled/.

其它的API体系

除了REST,我们可以说说其它的常的API体系和web services. 比如SOAP协议和 XML API以及最新的异步的web socket等

RPC

Remote procedure calls, or RPC, 它是一个通信方法,存在了很长的时间,它后来成为了REST的骨架。但使用RPC依然还有很多的优点——特别是JSON——RPC——我们在这本书中不会详细讨论RPC。

如果你不是很熟习RPC. 它跟REST不同的在于,RPC只有单个的端点,会在请求中,定义web service的行为.

更多关于JSON——RPC, 可以查看http://json-rpc.org/.

选择格式

在当今,格式的选择是非常棘手的问题。在过去每种语言和开发者,都有不同的格式。而现在,格式慢慢变少。

随着Node和Javascript的崛起,JSON得到了广泛的使用。目前几乎所有的语言都支持JSON, Go也不除外.

JSON

以下这个例子,向我们演示了Go,如何发送和接收JSON数据

package main
import
(
    "encoding/json"
    "net/http"
    "fmt"
)
type User struct {
    Name string `json:"name"`
    Email string `json:"email"`
    ID int `json:"int"`
}
func userRouter(w http.ResponseWriter, r *http.Request) {
    ourUser := User{}
    ourUser.Name = "Bill Smith"
    ourUser.Email = "[email protected]"
    ourUser.ID = 100
    output,_ := json.Marshal(&ourUser)
    fmt.Fprintln(w, string(output))
}
func main() {
    fmt.Println("Starting JSON server")
    http.HandleFunc("/user", userRouter)
    http.ListenAndServe(":8080",nil)
}

在一个struct中,第三个参数称为tag. 它的主要作用是利用编码,因为它们会编码为JSON变量或者XML的标签。如果没有使用tag. 则变量的名称会直接返回.

XML

如之前提到的,XML是开发者使用的一种格式。虽然有点过时,但今天主要的API依然使用XML作为选择,特别是在RSS中。

以下是我们的使用XML格式

type User struct {
    Name string `xml:"name"`
    Email string `xml:"email"`
    ID int `xml:"id"`
}

ourUser := User{}
ourUser.Name = "Bill Smith"
ourUser.Email = "[email protected]"
ourUser.ID = 100
output,_ := xml.Marshal(&ourUser)
fmt.Fprintln(w, string(output))

YAML

YAML是最早的尝试创建人类可读的序列化格式,类似于JSON. 在Go语言中,需要使用第三方的goyaml插件。

你可以通过https://godoc.org/launchpad.net/goyaml.了解更多的信息。你也可以通过go get launchpad.net/goyaml命令来安装这个包。

跟内置的XML和JSON一样,我们可以使用Marshal和Unmarshal来编码和解码YAML数据。我们可以通过下面的例子来生成一个YAML文档

package main
import
(
"fmt"
"net/http"
"launchpad.net/goyaml"
)
type User struct {
    Name string
    Email string
    ID int
}
func userRouter(w http.ResponseWriter, r *http.Request) {
    ourUser := User{}
    ourUser.Name![](webservice_yaml.png) = "Bill Smith"
    ourUser.Email = "[email protected]"
    ourUser.ID = 100
    output,_ := goyaml.Marshal(&ourUser)
    fmt.Fprintln(w, string(output))
}
func main() {
    fmt.Println("Starting YAML server")
    http.HandleFunc("/user", userRouter)
    http.ListenAndServe(":8080",nil)
}

CSV

Comma Separated Values (CSV)还是存在于APIs中,特别是过时的APIs中。通常我们不推荐使用CSV格式,但它在商业应用程序中非常有用。你可以使用encoding/csv包来编码和解码数据。

比较HTTP行为和方法

REST中对于数据访问和维护应该受限于不同的verb/method(POST, GET).

举例来说,GET请求不应该允许用户修改,更新或者创建数据。DELETE也是一样。所以什么verb可以用于创建和更新呢?但是在HTTP中没有直接的verbs用于这个目的。

在这个问题上存在一些争论,但大部分接受的是,PUT用于更新,而POST用于创建它。

PATCH方法对比PUT方法

在2010年,有一个改变HTTP的提案,即增加PATCH方法。PATCH用于局部的改变一个资源,PUT被期望用于完成的改变一个资源。

目前为此,我们仅关注PUT,我们会在之后在说PATCH.特别是在我们深入OPTIONS方法。

Adding more endpoints

通过对HTTP verb的了解,我们对创建用户应该通过POST方法。在第一章节中,我们没有使用 POST请求,而是使用GET. 好的 API设计应该是通过单个URL可以用于创建,检索,更新和删除资源。

以下是API的端口设计

Endpoint Method Purpose
/api OPTIONS API可用的形为 Endpoint Method Purpose
/api/users GET 根据可选的过滤参数,返回用户集
/api/users POST 创建一个用户
/api/user/123 PUT 更新ID为123信息
/api/user/123 DELETE 删除ID为123的信息

现在让我们来修改第一章的API, 让它仅能通过POST创建用户

记住,我们可以使用Gorilla web toolkit来做路由。它对请求进行正则表达式中非常有用,同时它还能用于不同的HTTP verb/method

在我们的例子中,我们创建了/api/user/create和/api/user/read端点,但我们知道,它不是最好的REST原则。所以我们的目标是改变任何对一个用户的请求都到/api/users,并且限制POST方法用于创建一个用户,而GET用于检索。

routes := mux.NewRouter()
routes.HandleFunc("/api/users", UserCreate).Methods("POST")
routes.HandleFunc("/api/users", UsersRetrieve).Methods("GET")

以下看看我们应用程序的改变

package main
import (
    "database/sql"
    "encoding/json"
    "fmt"
    _ "github.com/go-sql-driver/mysql"
    "github.com/gorilla/mux"
    "net/http"
    "log"
)

var database *sql.DB

到目前为止所有的代码都是一样的,以下的代码会发生改变

type Users struct {
    Users []User `json:"users"`
}

我们创建了一个Users组,表示一般的GET请求时的返回。它是User{}struct类型的slice.

type User struct {
    ID int "json:id"
    Name string "json:username"
    Email string "json:email"
    First string "json:first"
    Last string "json:last"
}
func UserCreate(w http.ResponseWriter, r *http.Request) {
    NewUser := User{}
    NewUser.Name = r.FormValue("user")
    NewUser.Email = r.FormValue("email")
    NewUser.First = r.FormValue("first")
    NewUser.Last = r.FormValue("last")
    output, err := json.Marshal(NewUser)
    fmt.Println(string(output))
    if err != nil {
        fmt.Println("Something went wrong!")
    }
    sql := "INSERT INTO users set user_nickname='" + NewUser.Name + "',
    user_first='" + NewUser.First + "', user_last='" + NewUser.Last + "',
    user_email='" + NewUser.Email + "'"
    q, err := database.Exec(sql)
    if err != nil {
        fmt.Println(err)
    }
    fmt.Println(q)
}

这跟我们之前创建的方法没有什么改变,下一步,让我们看看如何检索数据

func UsersRetrieve(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Pragma","no-cache")
    rows,_ := database.Query("select * from users LIMIT 10")
    Response := Users{}
    for rows.Next() {
        user := User{}
        rows.Scan(&user.ID, &user.Name, &user.First, &user.Last, &user.Email )
        Response.Users = append(Response.Users, user)
        }
    output,_ := json.Marshal(Response)
    fmt.Fprintln(w,string(output))
}

在我们的UserRetrieve()函数中,我们现在遍布一个结果集,并且扫描它们到Users{}中。

func main() {
    db, err := sql.Open("mysql", "root@/social_network")
    if err != nil {
    }
    database = db
    routes := mux.NewRouter()
    routes.HandleFunc("/api/users", UserCreate).Methods("POST")
    routes.HandleFunc("/api/users", UsersRetrieve).Methods("GET")
    http.Handle("/", routes)
    http.ListenAndServe(":8080", nil)
}

处理API版本

对于我们的例子来说,我们可以通过GET来访问端点/api/users. 然而应该有一个克隆的版本。即/api/users应该跟/api/{current-version}/users一样。通过这种方式,如果我们移到另一个版本,我们的老版本依然支持,但不是在{current-version}的地址中。

所以,我们如何告诉用户升级呢?一种是通过HTTP状态码表明。这就允许客户端继续使用老版本访问我们的API,比如/api/2.0/users

我们将在第三章中创建一个新的版本API.

REST的中还有一个点很难处理,这涉及到无状态:如何请求下一个结果集

你可能认为,可以使用以下的方式

{ "payload": [ "item","item 2"], "next": "http://yourdomain.com/api/users?
page=2" }

虽然这可以工作,但这违反了REST原则。首先, 除非我们明确的返回超文本,很可能我们不会直接的使用这个URL.出于这个原因,我们不想在reponse主体中包含这个值。

其次,我们可能会访问其它的端点,或者其它行为的信息。

另外,如果我们输入http://localhost:8080/api. 我们的应该应该返回向客户端返回基础的信息,以及所有可用的端点。

其中一个解决方法是使用link header. link是 response中的一个key/value键值对。

JSON响应通常不被认为是RESTful, 因为它们不是超文本的格式。你将看到的APIs, 它应该可以嵌入自已,rel, next链接头。JSON最主要的缺点是不支持超qlpk接。这可以通过JSON-LD解决,它不仅支持链接,还支持已链接文档和无状态上下文。 Hypertext Application Language (HAL)尝试做同样的事情。JSON-LD已经得到W3C的支持。这两个格式都是扩展JSON. 但我们都不想深入的去了解它们。你可以修改response,返回这两者格式中的一种。 以下是/api/users GET请求

func UsersRetrieve(w http.ResponseWriter, r *http.Request) {
    log.Println("starting retrieval")
    start := 0
    limit := 10
    next := start + limit
    w.Header().Set("Pragma","no-cache")
    w.Header().Set("Link","<http://localhost:8080/api/users?
    start="+string(next)+"; rel=\"next\"")
    rows,_ := database.Query("select * from users LIMIT 10")
    Response := Users{}
    for rows.Next() {
        user := User{}
        rows.Scan(&user.ID, &user.Name, &user.First, &user.Last, &user.Email )
        Response.Users = append(Response.Users, user)
    }
    output,_ := json.Marshal(Response)
    fmt.Fprintln(w,string(output))
}

这就告诉客户端,在哪里进行分页。我们可以进一步修改,让它包含向前和后退的分页.