Skip to content

Latest commit

 

History

History
678 lines (466 loc) · 24.7 KB

File metadata and controls

678 lines (466 loc) · 24.7 KB

四、在 Go 中设计 API

我们现在已经学习了 REST、处理 URL 路由和 Go 中的多路复用的基础知识,可以是直接的,也可以是通过一个框架。

我们希望,创建 API 的框架是有用的和信息丰富的,但是如果我们要设计一个功能正常的 REST 兼容 web 服务,我们需要填补一些主要空白。首先,我们需要以一种优雅、简单的方式处理版本、所有端点和OPTIONS头以及多种格式,以便今后进行管理。

我们将充实我们想要为基于 API 的应用程序布局的端点,该应用程序允许客户端获得他们需要的关于我们的应用程序的所有信息,并创建和更新用户,以及与这两个端点相关的有价值的错误信息。

在本章结束时,您还应该能够在 REST 和 WebSocket 应用程序之间切换,因为我们将构建一个带有内置客户端测试接口的非常简单的 WebSocket 示例。

在本章中,我们将介绍以下主题:

  • 概述和设计我们完整的社交网络 API
  • 处理代码组织和 API 版本控制的基础知识
  • 允许 API 使用多种格式(XML 和 JSON)
  • 更详细地了解 WebSocket 并在 Go 中实现它们
  • 创建更健壮、更具描述性的错误报告
  • 通过 API 更新用户记录

在本章的末尾,您应该能够优雅地处理 RESTWeb 服务的多种格式和版本,并更好地理解如何在 Go 中使用 WebSocket。

设计我们的社交网络 API

现在我们已经在我们的 web 服务中使用 Go 输出数据,这让我们的脚有点湿了,现在需要采取的一个重要步骤是完全充实我们希望主要项目的 API 所做的事情。

因为我们的应用程序是一个社交网络,所以我们不仅需要关注用户信息,还需要关注连接和消息传递。我们需要确保新用户可以与某些组共享信息、建立和修改连接以及处理身份验证。

考虑到这一点,让我们确定以下潜在 API 端点的范围,以便继续构建应用程序:

|

端点

|

方法

|

描述

| | --- | --- | --- | | /api/users | GET | 返回具有可选参数的用户列表 | | /api/users | POST | 创建用户 | | /api/users/XXX | PUT | 更新用户信息 | | /api/users/XXX | DELETE | 删除用户 | | /api/connections | GET | 返回基于用户的连接列表 | | /api/connections | POST | 在用户之间创建连接 | | /api/connections/XXX | PUT | 修改连接 | | /api/connections/XXX | DELETE | 删除用户之间的连接 | | /api/statuses | GET | 获取状态列表 | | /api/statuses | POST | 创建一个状态 | | /api/statuses/XXX | PUT | 更新状态 | | /api/statuses/XXX | DELETE | 删除状态 | | /api/comments | GET | 获取评论列表 | | /api/comments | POST | 创建注释 | | /api/comments/XXX | PUT | 更新评论 | | /api/comments/XXX | DELETE | 删除评论 |

在本例中,XXX存在的任何地方都是我们提供唯一标识符作为 URL 端点的一部分的地方。

您会注意到,我们已移动到所有复数端点。这在很大程度上取决于偏好,许多 API 同时使用这两种端点(或仅使用单一端点)。多个端点的优点与命名结构的一致性有关,这允许开发人员进行可预测的调用。使用单一端点可以作为表示 API 调用将只处理单个记录的简写方式。

这些端点中的每一个都反映了与数据点的潜在交互。我们还将包括另一组端点,它们不反映与数据的交互,而是允许我们的 API 客户端通过 OAuth 进行身份验证:

|

端点

|

方法

|

描述

| | --- | --- | --- | | /api/oauth/authorize | GET | 返回具有可选参数的用户列表 | | /api/oauth/token | POST | 创建一个用户 | | /api/oauth/revoke | PUT | 更新用户的信息 |

如果您不熟悉 OAuth,现在不要担心它,因为我们将在稍后引入身份验证方法时深入研究它。

提示

OAuth开放式身份验证的简称,诞生于需要创建一个使用 OpenID 对用户进行身份验证的系统,这是一个分散的身份系统。

到 OAuth2 出现时,该系统已经进行了大量的重组,以提高安全性,同时减少对特定集成的关注。今天,许多 API 依赖并要求 OAuth 通过第三方代表用户访问和进行更改。

互联网工程任务组的整个规范文件(RFC6749)可在找到 http://tools.ietf.org/html/rfc6749

前面提到的端点代表了构建完全在 web 服务上运行的最低限度社交网络所需的一切。我们也将为此构建一个基本的接口,但主要是在 web 服务级别构建、测试和调优我们的应用程序。

我们在这里不讨论的一件事是PATCH请求,正如我们在前一章中提到的,它指的是数据的部分更新。

在下一章中,我们将扩展我们的 web 服务,以允许PATCH更新,并且我们将概述所有端点,作为OPTIONS响应的一部分。

处理我们的 API 版本

如果你花大量时间在互联网上处理 web 服务和 API,你会发现各种服务如何处理它们的 API 版本有很大的差异。

并非所有这些方法都是特别直观的,它们经常会破坏前向和后向兼容性。您应该尽可能以最简单的方式避免这种情况。

考虑一个 API,默认情况下,使用版本控制作为 URI:ORDT0 的一部分。

你会发现这很常见;例如,这就是 Twitter 处理 API 请求的方式。

这种方法有一些优点和缺点,所以你应该考虑你的 URI 方法的潜在缺点。

在明确定义 API 版本的情况下,没有默认版本,这意味着用户总是拥有他们要求的版本。这样做的好处是,升级不一定会破坏任何人的 API。糟糕的是,如果不明确检查或验证描述性 API 消息,用户可能不知道哪个版本是最新的。

您可能知道,Go 不允许有条件的导入。尽管这是一个设计决策,可以让go fmtgo fix等工具快速而优雅地工作,但它有时会妨碍应用程序设计。

例如,在 Go 中不可能直接执行类似的操作:

if version == 1 {
  import "v1"
} else if version == 2 {
  import "v2"
}

不过我们可以在这方面即兴发挥一下。假设我们的应用程序结构如下:

socialnetwork.go
/{GOPATH}/github.com/nkozyra/gowebservice/v1.go
/{GOPATH}/github.com/nkozyra/gowebservice/v2.go

然后,我们可以按如下方式导入每种产品:

import "github.com/nkozyra/gowebservice/v1"
import "github.com/nkozyra/gowebservice/v2"

当然,这也意味着我们需要在应用程序中使用它们,否则 Go 将触发编译错误。

维护多个版本的例子如下:

package main

import
(
  "nathankozyra.com/api/v1"
  "nathankozyra.com/api/v2"
)

func main() {

  v := 1

  if v == 1 {
    v1.API()
    // do stuff with API v1
  } else {
    v2.API()
    // do stuff with API v2
  }

}

这个设计决策的不幸现实是,您的应用程序将违反编程基本规则之一:不要重复代码

这当然不是一条硬性规定,但重复代码会导致功能爬行、碎片化和其他令人头痛的问题。只要我们使用主要方法在不同版本之间执行相同的操作,我们就可以在一定程度上缓解这些问题。

在本例中,我们的每个 API 版本都将导入我们的标准 API 服务和路由文件,如以下代码所示:

package v2

import
(
  "nathankozyra.com/api/api"
)

type API struct {

}

func main() {
  api.Version = 1
  api.StartServer()
}

当然,我们的 v2 版本看起来与另一个版本几乎相同。从本质上讲,我们使用它们作为包装器,引入重要的共享数据,如数据库连接、数据封送等。

为了证明这一点,我们可以将几个基本变量和函数放入api.go文件中:

package api

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"`
}

type User struct {
  ID int "json:id"
  Name  string "json:username"
  Email string "json:email"
  First string "json:first"
  Last  string "json:last"
}

func StartServer() {

  db, err := sql.Open("mysql", "root@/social_network")
  if err != nil {
  }
  Database = db
  routes := mux.NewRouter()

  http.Handle("/", routes)
  http.ListenAndServe(":8080", nil)
}

如果这看起来很熟悉的话,这是因为它是我们上一章第一次尝试 API 时的核心内容,在这里,我们去掉了一些路由以获得空间。

现在也是提一提处理基于 JSON 的 REST API 的有趣的第三方包-JSON API 服务器JAS)。JAS 位于 HTTP 之上(和我们的 API 类似),但通过自动将请求定向到资源来自动化许多路由。

提示

JSON API 服务器或 JAS 允许在 HTTP 包之上使用一组简单的 JSON 特定 API 工具,以最小的影响增强 web 服务。

您可以在上阅读更多关于此的信息 https://github.com/coocood/jas

您可以使用以下命令通过 Go 安装:go get github.com/coocood/jas。以多种格式交付我们的 API

在这个阶段,将我们处理多种格式的方式形式化是有意义的。在本例中,我们处理的是 JSON、RSS 和通用文本。

我们将在下一章讨论模板时讨论通用文本,但现在,我们需要能够分离 JSON 和 RSS 响应。

做到这一点最简单的方法是将任何资源视为接口,然后根据请求参数协商数据封送。

有些 API 直接在 URI 中定义格式。在我们的 mux 路由中,我们也可以相当容易地做到这一点(如下例所示):

  Routes.HandleFunc("/api.{format:json|xml|txt}/user", UsersRetrieve).Methods("GET")

前面的代码将允许我们直接从 URL 参数提取请求的格式。然而,对于 REST 和 URI 来说,这也是一个有点棘手的问题。尽管双方都有争议,但出于我们的目的,我们将使用该格式作为查询参数。

在我们的api.go文件中,我们需要创建一个名为Format的全局变量:

var Format string

以及一个我们可以用于确定每个请求格式的函数:

func GetFormat(r *http.Request) {

  Format = r.URL.Query()["format"][0]

}

我们将在每个请求中调用此。尽管前面的选项自动限制为 JSON、XML 或文本,但我们也可以将其构建到应用程序逻辑中,如果它与可接受的选项不匹配,则可以包含一个回退到Format

我们可以使用通用的SetFormat函数根据当前请求的数据格式封送数据:

func SetFormat( data interface{} )  []byte {

  var apiOutput []byte
  if Format == "json" {
    output,_ := json.Marshal(data)
    apiOutput = output
  }else if Format == "xml" {
    output,_ := xml.Marshal(data)
    apiOutput = output
  }
  return apiOutput
}

在我们的任何端点函数中,我们都可以返回作为接口传递给SetFormat()的任何数据资源:

func UsersRetrieve(w http.ResponseWriter, r *http.Request) {
  log.Println("Starting retrieval")
  GetFormat(r)
  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 := SetFormat(Response)
  fmt.Fprintln(w,string(output))
}

这允许我们从响应函数中删除封送处理。现在,我们已经掌握了将数据编组为 XML 和 JSON 的方法,让我们重新讨论另一种用于服务 web 服务的协议。

并发 WebSocket

如前一章所述,WebSocket 是一种在客户端和服务器之间保持开放连接的方法,通常用于替换从浏览器到客户端的多个 HTTP 调用,也用于替换可能需要保持半可靠的固定连接的两台服务器之间的 HTTP 调用。

在 API 中使用 WebSocket 的优点是减少了客户端和服务器的延迟,并且为长轮询应用程序构建客户端解决方案的体系结构通常不那么复杂。

概括一下优势,考虑下面两个表述;第一个标准 HTTP 请求:

Concurrent WebSockets

现在将其与 TCP 上更精简的 WebSocket 请求进行比较,后者消除了多次握手和状态控制的开销:

Concurrent WebSockets

您可以看到,传统 HTTP 呈现的冗余和延迟级别可能会妨碍长期应用程序。

诚然,只有 HTTP 1 在严格意义上存在这个问题。HTTP 1.1 引入了在连接中保持有效性或持久性。虽然这在协议方面起作用,但大多数非通用的[T0]web 服务器将难以进行资源分配。例如,Apache 默认情况下保持活动超时非常低,因为长时间的连接会占用线程,并阻止将来的请求以合理的方式完成。

HTTP 的现在和未来为 WebSocket 提供了一些替代方案,即主要由谷歌开发的 SPDY 协议带来的一些大选项。

虽然 HTTP 2.0 和 SPDY 提供了在不关闭连接的情况下多路复用连接的概念,特别是在 HTTP 管道方法中,但还没有对它们的广泛客户端支持。目前,如果我们从 web 客户端访问 API,WebSocket 提供了更多的客户端可预测性。

应该注意的是,跨 web 服务器和负载平衡器的 SPDY 支持在很大程度上仍处于试验阶段。警告买主。

虽然 REST 仍然是我们 API 和演示的主要目标,但您可以在下面的代码中找到一个非常简单的 WebSocket 示例,它接受消息并返回消息的长度:

package main

import (

    "fmt"
    "net/http"
    "code.google.com/p/go.net/websocket"
    "strconv"
)

var addr = ":12345"

func EchoLengthServer(ws *websocket.Conn) {

    var msg string

    for {
      websocket.Message.Receive(ws, &msg)
      fmt.Println("Got message",msg)
      length := len(msg)
      if err := websocket.Message.Send(ws, strconv.FormatInt(int64(length), 10) )  ; err != nil {
          fmt.Println("Can't send message length")
          break
        }
    }

注意这里的循环;必须在EchoLengthServer函数中保持此循环运行,否则您的 WebSocket 连接将在客户端立即关闭,从而阻止将来的消息。

}

func websocketListen() {

    http.Handle("/length", websocket.Handler(EchoLengthServer))
    err := http.ListenAndServe(addr, nil)
    if err != nil {
        panic("ListenAndServe: " + err.Error())
    }

}

这是我们的主要套接字路由器。我们正在监听端口12345并评估传入消息的长度,然后返回。请注意,我们本质上将处理程序转换为websocket处理程序。如下所示:

func main() {

    http.HandleFunc("/websocket", func(w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "websocket.html")
    })
    websocketListen()

}

最后一部分,除了实例化 WebSocket 部分外,还提供了一个平面文件。由于一些跨域策略问题,测试 WebSocket 示例的客户端访问和功能可能会很麻烦,除非这两个示例在同一域和端口上运行。

要管理跨域请求,必须启动协议握手。这超出了演示的范围,但是如果您选择继续,请知道这个特定的包确实提供了一个引用ReadHandshakeAcceptHandshake方法的serverHandshaker接口的功能。

提示

websocket.go的握手机制来源于https://code.google.com/p/go/source/browse/websocket/websocket.go?repo=net

由于这是一个在/length端点完全基于 WebSocket 的演示,如果您试图通过 HTTP 访问它,您将得到一个标准错误,如以下屏幕截图所示:

Concurrent WebSockets

因此,平面文件将返回到相同的域和端口。在前面的代码中,我们只包含 jQuery 和以下浏览器中存在的内置 WebSocket 支持:

  • Chrome:版本 21 及更高版本
  • Safari:版本 6 及更高版本
  • Firefox:版本 21 及更高版本
  • IE:版本 10 及以上版本
  • 歌剧:版本 22 及更高版本

现代 Android 和 iOS 浏览器现在也可以处理 WebSocket。

连接到服务器的 WebSocket 端并测试一些消息的代码如下。请注意,此处不测试 WebSocket 支持:

<html>
<head>
  <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
</head>

<body>

<script>
  var socket;

  function update(msg) {

    $('#messageArea').html(msg)

  }

此代码返回我们从 WebSocket 服务器获得的消息:

  function connectWS(){

    var host = "ws://localhost:12345/length";

    socket = new WebSocket(host);
    socket.onopen = function() {
      update("Websocket connected")
    }

    socket.onmessage = function(message){

      update('Websocket counted '+message.data+' characters in your message');
    }

    socket.onclose = function() {
      update('Websocket closed');
    }

  }

  function send() {

    socket.send($('#message').val());

  }

  function closeSocket() {

    socket.close();
  }

  connectWS();
</script>

<div>
  <h2>Your message</h2>
  <textarea style="width:50%;height:300px;font-size:20px;" id="message"></textarea>
  <div><input type="submit" value="Send" onclick="send()" /> <input type="button" onclick="closeSocket();" value="Close" /></div>
</div>

<div id="messageArea"></div>
</body>
</html>

当我们在浏览器中访问/websocketURL 时,我们将获得允许我们从客户端向 WebSocket 服务器发送消息的文本区域,如以下屏幕截图所示:

Concurrent WebSockets

分离我们的 API 逻辑

正如我们在前面版本控制一节中提到的,实现版本和格式一致性的最佳方法是将 API 逻辑与整个版本和交付组件分开。

我们已经在我们的GetFormat()SetFormat()函数中看到了一点,它们跨越了所有端点和版本。

扩展我们的错误消息

在最后一章中,我们简要介绍了通过 HTTP 状态码发送错误消息。在本例中,当客户机试图使用数据库中已经存在的电子邮件地址创建用户时,我们传递了一个 409 状态冲突。

http包提供了一组非综合性的状态代码,可用于标准 HTTP 问题以及 REST 特定消息。这些代码是非综合性的,因为其中一些代码附带了一些附加消息,但以下列表满足 RFC 2616 建议:

|

错误

|

数字

| | --- | --- | | StatusContinue | 100 | | StatusSwitchingProtocols | 101 | | StatusOK | 200 | | StatusCreated | 201 | | StatusAccepted | 202 | | StatusNonAuthoritativeInfo | 203 | | StatusNoContent | 204 | | StatusResetContent | 205 | | StatusPartialContent | 206 | | StatusMultipleChoices | 300 | | StatusMovedPermanently | 301 | | StatusFound | 302 | | StatusSeeOther | 303 | | StatusNotModified | 304 | | StatusUseProxy | 305 | | StatusTemporaryRedirect | 307 | | StatusBadRequest | 400 | | StatusUnauthorized | 401 | | StatusPaymentRequired | 402 | | StatusForbidden | 403 | | StatusNotFound | 404 | | StatusMethodNotAllowed | 405 | | StatusNotAcceptable | 406 | | StatusProxyAuthRequired | 407 | | StatusRequestTimeout | 408 | | StatusConflict | 409 | | StatusGone | 410 | | StatusLengthRequired | 411 | | StatusPreconditionFailed | 412 | | StatusRequestEntityTooLarge | 413 | | StatusRequestURITooLong | 414 | | StatusUnsupportedMediaType | 415 | | StatusRequestedRangeNotSatisfiable | 416 | | StatusExpectationFailed | 417 | | StatusTeapot | 418 | | StatusInternalServerError | 500 | | StatusNotImplemented | 501 | | StatusBadGateway | 502 | | StatusServiceUnavailable | 503 | | StatusGatewayTimeout | 504 | | StatusHTTPVersionNotSupported | 505 |

您可能还记得,我们以前硬编码过此错误消息;我们的错误处理仍然应该保持在 API 版本的上下文之上。例如,在我们的api.go文件中,ErrorMessage函数中有一个开关控件,它明确定义了 409 HTTP 状态码错误。我们可以通过http包本身中定义的常量和全局变量来补充这一点:

func ErrorMessages(err int64) (int, int, string) {
  errorMessage := ""
  statusCode := 200;
  errorCode := 0
  switch (err) {
    case 1062:
      errorMessage = http.StatusText(409)
      errorCode = 10
      statusCode = http.StatusConflict
  }

  return errorCode, statusCode, errorMessage

}

您可能还记得,这会对应用程序的其他组件中的错误进行一些转换;在本例中,1062 是一个 MySQL 错误。我们也可以在这里直接自动地实现 HTTP 状态代码,作为交换机中的默认代码:

    default:
      errorMessage = http.StatusText(err)
      errorCode = 0
      statusCode = err

通过 web 服务更新我们的用户

当我们允许用户通过 web 服务进行更新时,我们有一种能力来呈现另一个潜在的错误点。

为此,我们将通过添加路由向/api/users/XXX端点添加端点:

  Routes.HandleFunc("/api/users/{id:[0-9]+}", UsersUpdate).Methods("PUT")

在我们的UsersUpdate函数中,我们将首先检查所述用户 ID 是否存在。如果它不存在,我们将返回 404 错误(文档未找到错误),这是与未找到的资源记录最接近的近似值。

如果用户确实存在,我们将尝试通过查询更新其电子邮件 ID;如果失败,我们将返回冲突消息(或其他错误)。如果没有失败,我们将返回 200 和一条 JSON 格式的成功消息。以下是UserUpdates功能的开始:

func UsersUpdate(w http.ResponseWriter, r *http.Request) {
  Response := UpdateResponse{}
  params := mux.Vars(r)
  uid := params["id"]
  email := r.FormValue("email")

  var userCount int
  err := Database.QueryRow("SELECT COUNT(user_id) FROM users WHERE user_id=?", uid).Scan(&userCount)
  if userCount == 0 {

      error, httpCode, msg := ErrorMessages(404)
      log.Println(error)
      log.Println(w, msg, httpCode)
      Response.Error = msg
      Response.ErrorCode = httpCode
      http.Error(w, msg, httpCode)

  }else if err != nil {
    log.Println(error)
  } else {

    _,uperr := Database.Exec("UPDATE users SET user_email=?WHERE user_id=?",email,uid)
    if uperr != nil {
      _, errorCode := dbErrorParse( uperr.Error() )
      _, httpCode, msg := ErrorMessages(errorCode)

      Response.Error = msg
      Response.ErrorCode = httpCode
      http.Error(w, msg, httpCode)
    } else {
      Response.Error = "success"
      Response.ErrorCode = 0
      output := SetFormat(Response)
      fmt.Fprintln(w,string(output))
    }
  }
}

我们将对此进行一点扩展,但现在,我们可以创建一个用户,返回一个用户列表,并更新用户的电子邮件地址。

提示

在使用 API 时,现在是提到两个基于浏览器的工具的好时机:PostmanPoster,它们允许您从浏览器中直接使用 REST 端点。

欲了解更多关于 Chrome 邮递员的信息,请点击https://chrome.google.com/webstore/detail/postman-rest-client/fdmmgilgnpjigdojojpjoooidkmcomcm?hl=en

有关 Firefox 中海报的更多信息,请访问https://addons.mozilla.org/en-US/firefox/addon/poster/

两种工具的作用基本相同;它们允许您直接与 API 交互,而无需开发特定的 HTML 或基于脚本的工具,也无需直接从命令行使用 cURL。

总结

通过本章,我们已经确定了社交网络服务的范围,并准备好填写。我们已经向您展示了如何创建和概述了如何更新用户,以及在无法更新用户时返回有价值的错误信息。

本章花了大量时间介绍了这种应用程序的基础结构、格式和端点。关于前者,我们主要研究了 XML 和 JSON,但在下一章中,我们将探讨模板,以便您可以以您认为必要的任意格式返回数据。

我们还将深入研究身份验证,通过 OAuth 或简单的 HTTP 基本身份验证,这将允许我们的客户端安全地连接到我们的 web 服务,并发出保护敏感数据的请求。为了做到这一点,我们还将针对一些请求将应用程序锁定为 HTTPS。

此外,我们将重点关注 REST 方面,我们仅通过[T0]动词简要概述了 web 服务的行为。最后,我们将更仔细地研究如何使用头来近似 web 服务的服务器端和接收端的状态。