Skip to content

Latest commit

 

History

History
1048 lines (785 loc) · 52 KB

File metadata and controls

1048 lines (785 loc) · 52 KB

六、通过 RESTful 数据 Web 服务 API 公开数据和功能

在前一章中,我们构建了一个服务,该服务从 Twitter 读取推文,统计标签投票,并将结果存储在 MongoDB 数据库中。我们还使用 MongoDB shell 添加投票并查看投票结果。如果我们是唯一使用我们的解决方案的人,那么这种方法是好的,但是如果我们发布了我们的项目,并期望用户直接连接到我们的 MongoDB 实例以使用我们构建的服务,那将是疯狂的。

因此,在本章中,我们将构建一个 RESTful 数据服务,通过它可以公开数据和功能。我们还将创建一个使用新 API 的简单网站。然后,用户可以使用我们的网站创建和监控民意调查,或者在我们发布的 web 服务之上构建自己的应用程序。

提示

本章中的代码依赖于第 5 章构建分布式系统和使用灵活数据中的代码,因此建议您先完成本章中的代码,特别是因为本章中的代码涵盖了如何设置运行的环境。

具体而言,您将学习:

  • 包装http.HandlerFunc类型如何为我们的 HTTP 请求提供一个简单但功能强大的执行管道
  • 如何在 HTTP 处理程序之间安全地共享数据
  • 编写负责公开数据的处理程序的最佳实践
  • 小的抽象可以让我们现在就编写最简单的实现,但在以后不改变接口的情况下留下改进的空间
  • 向项目中添加简单的助手函数和类型将如何防止(或至少延迟)添加对外部包的依赖关系

RESTful API 设计

要将API 视为 RESTful,它必须遵循一些原则,这些原则必须与 Web 背后的原始概念保持一致,并且大多数开发人员都知道这些原则。这种方法使我们能够确保我们没有在 API 中构建任何奇怪或不寻常的东西,同时也让我们的用户在使用它时处于领先地位,因为他们已经熟悉它的概念。

一些最重要的 RESTful 设计概念是:

  • HTTP 方法描述要采取的操作类型,例如,GET方法将只读取数据,而POST请求将创建某些内容
  • 数据表示为资源的集合
  • 操作表示为对数据的更改
  • URL 用于引用特定数据
  • HTTP 头用于描述进出服务器的表示类型

有关 RESTful 设计的这些和其他细节的深入概述,请参阅维基百科文章http://en.wikipedia.org/wiki/Representational_state_transfer

下表显示了表示我们将在 API 中支持的操作的 HTTP 方法和 URL,以及我们打算如何使用调用的简要说明和示例用例:

|

要求

|

描述

|

用例

| | --- | --- | --- | | GET /polls/ | 阅读所有投票 | 向用户显示轮询列表 | | GET /polls/{id} | 阅读民意测验 | 显示特定投票的详细信息或结果 | | POST /polls/ | 创建投票 | 创建一个新的投票 | | DELETE /polls/{id} | 删除投票 | 删除特定的投票 |

{id}占位符表示轮询的唯一 ID 在路径中的位置。

处理程序之间共享数据

如果我们想要保持我们的处理程序像 Go 标准库中的http.Handler接口一样纯净,同时仍然将公共功能提取到我们自己的方法中,我们需要一种在处理程序之间共享数据的方法。下面的HandlerFunc签名告诉我们,我们只允许传递http.ResponseWriter对象和http.Request对象,其他什么都不允许:

type HandlerFunc func(http.ResponseWriter, *http.Request)

这意味着我们不能在一个地方创建和管理数据库会话对象,并将它们传递给我们的处理程序,这正是我们理想的做法。

相反,我们将实现每个请求数据的内存映射,并为处理程序访问它提供一种简单的方法。在twittervotescounter文件夹旁边,创建一个名为api的新文件夹,并在其中创建一个名为vars.go的新文件。将以下代码添加到文件中:

package main
import (
  "net/http"
  "sync"
)
var vars map[*http.Request]map[string]interface{}
var varsLock sync.RWMutex

这里我们声明一个vars映射,它的键是指向http.Request类型的指针,值是另一个映射。我们将存储与变量所属的请求实例一起键入的变量映射。varsLock互斥体很重要,因为我们的处理程序都将在处理许多并发 HTTP 请求的同时尝试访问和更改vars映射,我们需要确保它们安全地执行此操作。

接下来,我们将添加OpenVars函数,该函数允许我们准备vars映射以保存特定请求的变量:

func OpenVars(r *http.Request) {
  varsLock.Lock()
  if vars == nil {
    vars = map[*http.Request]map[string]interface{}{}
  }
  vars[r] = map[string]interface{}{}
  varsLock.Unlock()
}

此函数首先锁定互斥锁,以便我们可以安全地修改映射,然后再确保vars包含非 nil 映射,否则当我们试图访问其数据时会导致恐慌。最后,它使用指定的http.Request指针作为密钥分配一个新的空map值,然后解锁互斥锁,从而释放其他处理程序与互斥锁交互。

一旦处理完请求,我们需要一种方法来清理我们在这里使用的内存;否则,代码的内存占用将不断增加(也称为内存泄漏)。为此,我们添加了一个CloseVars函数:

func CloseVars(r *http.Request) {
  varsLock.Lock()
  delete(vars, r)
  varsLock.Unlock()
}

此功能安全地删除请求的vars映射中的条目。只要我们在尝试与变量交互之前调用OpenVars,在完成交互之后调用CloseVars,我们就可以自由地安全地存储和检索每个请求的数据。但是,我们不希望我们的处理程序代码在需要获取或设置某些数据时不得不担心锁定和解锁映射,因此让我们添加两个帮助函数,GetVarSetVar

func GetVar(r *http.Request, key string) interface{} {
  varsLock.RLock()
  value := vars[r][key]
  varsLock.RUnlock()
  return value
}
func SetVar(r *http.Request, key string, value interface{}) {
  varsLock.Lock()
  vars[r][key] = value
  varsLock.Unlock()
}

GetVar函数将使我们很容易从映射中获取指定请求的变量,SetVar允许我们设置一个变量。注意,GetVar函数调用RLockRUnlock,而不是LockUnlock;这是因为我们使用的是sync.RWMutex,这意味着只要不发生写操作,就可以安全地同时进行多次读取。这对于可以安全地同时从中读取的项目的性能有好处。对于正常的互斥锁,Lock会阻止执行,等待锁定它的东西解锁,而RLock不会。

包装处理函数

在 Go 中构建 web 服务和网站时,需要学习的最有价值的模式之一是我们在第 2 章中已经使用过的模式,添加了身份验证,其中我们通过将http.Handler类型与其他http.Handler类型包装在一起来装饰它们。对于我们的 RESTful API,我们将把同样的技术应用于http.HandlerFunc函数,以提供一种极其强大的方式,在不破坏标准func(w http.ResponseWriter, r *http.Request)接口的情况下模块化我们的代码。

API 密钥

大多数webAPI 要求客户端为其应用程序注册 API 密钥,并要求客户端在每次请求时发送该密钥。这些密钥有很多用途,从简单地识别请求来自哪个应用程序,到在某些应用程序只能根据用户的允许做有限的事情的情况下解决授权问题。虽然我们实际上不需要为我们的应用程序实现 API 键,但我们将要求客户机提供一个 API 键,这将允许我们稍后添加一个实现,同时保持接口不变。

在您的api文件夹中添加必要的main.go文件:

package main
func main(){}

接下来我们将在main.go的底部添加第一个名为withAPIKeyHandlerFunc包装函数:

func withAPIKey(fn http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    if !isValidAPIKey(r.URL.Query().Get("key")) {
      respondErr(w, r, http.StatusUnauthorized, "invalid API key")
      return
    }
    fn(w, r)
  }
}

如您所见,我们的withAPIKey函数都将http.HandlerFunc类型作为参数并返回一个参数;这就是我们在本文中所说的包装。withAPIKey函数依赖于我们尚未编写的许多其他函数,但您可以清楚地看到发生了什么。我们的函数立即返回一个新的http.HandlerFunc类型,该类型通过调用isValidAPIKey来检查查询参数key。如果密钥被视为无效(通过返回false,我们将以invalid API key错误进行响应。要使用此包装器,我们只需将一个http.HandlerFunc类型传递到此函数中,即可启用key参数检查。由于它也返回一个http.HandlerFunc类型,因此结果可以传递到其他包装器中,或者直接提供给http.HandleFunc函数,以实际将其注册为特定路径模式的处理程序。

下面我们添加isValidAPIKey函数:

func isValidAPIKey(key string) bool {
  return key == "abc123"
}

现在,我们只是将 API 密钥硬编码为abc123;其他任何内容都将返回false,因此被视为无效。稍后,我们可以修改此函数以查阅配置文件或数据库,以检查密钥的真实性,而不影响我们如何使用isValidAPIKey方法,或者实际上是withAPIKey包装器。

数据库会话

现在我们可以确信一个请求有一个有效的 API 密钥,我们必须考虑处理器将如何连接到数据库。一种选择是让每个处理程序拨打自己的连接,但这并不是很枯燥不要重复自己的操作),并为潜在的错误代码留出空间,例如一旦完成数据库会话,就会忘记关闭数据库会话的代码。相反,我们将创建另一个HandlerFunc包装器来为我们管理数据库会话。在main.go中增加以下功能:

func withData(d *mgo.Session, f http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    thisDb := d.Copy()
    defer thisDb.Close()
    SetVar(r, "db", thisDb.DB("ballots"))
    f(w, r)
  }
}

withData函数使用mgo包接受 MongoDB 会话表示,并根据模式接受另一个处理程序。返回的http.HandlerFunc类型将复制数据库会话,延迟该副本的关闭,并使用我们的SetVar助手将对ballots数据库的引用设置为db变量,最后调用下一个HandlerFunc。这意味着在此之后执行的任何处理程序都可以通过GetVar函数访问托管数据库会话。一旦处理程序完成执行,会话将延迟关闭,这将清理请求使用的所有内存,而不需要单个处理程序担心。

每个请求变量

我们的模式允许我们非常轻松地代表实际的处理者执行常见任务。请注意,其中一个处理程序正在调用OpenVarsCloseVars,这样就可以使用GetVarSetVar,而无需单独的处理程序来设置和拆除它们。该函数将返回一个http.HandlerFunc,该函数首先调用OpenVars请求,推迟对CloseVars的调用,并调用指定的处理函数。任何使用withVars包装的处理程序都可以使用GetVarSetVar

将以下代码添加到main.go

func withVars(fn http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    OpenVars(r)
    defer CloseVars(r)
    fn(w, r)
  }
}

使用此模式可以解决许多其他问题;每当您发现自己在处理程序中复制常见任务时,就值得考虑处理程序包装函数是否有助于简化代码。

跨浏览器资源共享

同源安全策略规定,web 浏览器中的 AJAX 请求只能用于托管在同一域上的服务,这将使我们的 API 相当有限,因为我们不一定托管所有使用我们的 web 服务的网站。CORS 技术绕过了同源策略,允许我们构建一个能够服务于托管在其他域上的网站的服务。为此,我们只需设置Access-Control-Allow-Origin头以响应*。当我们在 create poll 调用中使用Location头时,我们也将允许客户端访问该头,这可以通过在Access-Control-Expose-Headers头中列出它来实现。将以下代码添加到main.go

func withCORS(fn http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Access-Control-Allow-Origin", "*")
    w.Header().Set("Access-Control-Expose-Headers", "Location")
    fn(w, r)
  }
}

这是迄今为止最简单的包装函数;它只是在ResponseWriter类型上设置适当的头并调用指定的http.HandlerFunc类型。

提示

在本章中,我们将明确地处理 CORS,以便我们能够准确地理解正在发生的事情;对于实际的生产代码,您应该考虑使用开源的解决方案,如 AutoT0.https://github.com/fasterness/cors

响应

任何 API 的很大一部分都是通过状态码、数据、错误和有时头的组合来响应请求。通过net/http包,这一切都非常容易做到。我们有一个选择,它仍然是小型项目甚至大型项目早期阶段的最佳选择,就是直接在处理程序中构建响应代码。然而,随着处理程序数量的增加,我们最终会在整个项目中重复大量代码并分散表示决策。一种更具伸缩性的方法是将响应代码抽象为助手函数。

对于我们的 API 的第一个版本,我们将只使用 JSON,但是如果需要,我们希望以后能够灵活地添加其他表示。

创建一个名为respond.go的新文件,并添加以下代码:

func decodeBody(r *http.Request, v interface{}) error {
  defer r.Body.Close()
  return json.NewDecoder(r.Body).Decode(v)
}
func encodeBody(w http.ResponseWriter, r *http.Request, v interface{}) error {
  return json.NewEncoder(w).Encode(v)
}

这两个函数分别从RequestResponseWriter对象中提取数据的解码和编码。解码器还关闭请求主体,这是推荐的。虽然我们没有在这里添加太多功能,但这意味着我们不需要在代码中的任何其他地方提到 JSON,如果我们决定添加对其他表示的支持或切换到二进制协议,我们只需要接触这两个功能。

接下来,我们将添加更多的助手,这将使响应更加容易。在respond.go中,添加以下代码:

func respond(w http.ResponseWriter, r *http.Request,
  status int, data interface{},
) {
  w.WriteHeader(status)
  if data != nil {
    encodeBody(w, r, data)
  }
}

此函数可以使用我们的encodeBody助手轻松地将状态代码和一些数据写入ResponseWriter对象。

处理错误是另一个值得抽象的重要方面。添加以下respondErr助手:

func respondErr(w http.ResponseWriter, r *http.Request,
  status int, args ...interface{},
) {
  respond(w, r, status, map[string]interface{}{
    "error": map[string]interface{}{
      "message": fmt.Sprint(args...),
    },
  })
}

这个方法给我们提供了一个类似于respond函数的接口,但是写入的数据将被封装在error对象中,以表明出了问题。最后,我们可以添加一个 HTTP 错误特定帮助器,它将使用 Go 标准库中的http.StatusText函数为我们生成正确的消息:

func respondHTTPErr(w http.ResponseWriter, r *http.Request,
  status int,
) {
  respondErr(w, r, status, http.StatusText(status))
}

请注意,这些函数都是 dogfooding,这意味着它们相互使用(比如,吃你自己的狗粮),这很重要,因为我们希望实际响应只发生在一个地方,因为我们是否(或者更可能在何时)需要进行更改。

理解请求

http.Request对象让我们能够访问我们可能需要的关于底层 HTTP 请求的每一条信息,因此值得浏览文档以真正了解其功能。示例包括但不限于:

  • URL、路径和查询字符串
  • HTTP 方法
  • 曲奇饼
  • 文件夹
  • 形式值
  • 请求者的推荐人和用户代理
  • 基本身份验证详细信息
  • 请求主体
  • 标题信息

有几件事它没有解决,我们需要自己解决,或者寻求外部软件包来帮助我们。URL 路径解析就是这样一个例子,虽然我们可以通过http.Request类型的URL.Path字段以字符串的形式访问路径(如/people/1/books/2),但没有简单的方法可以提取路径中编码的数据,例如1的人员 ID 或2的图书 ID。

一些项目很好地解决了这个问题,比如 Goweb 或 Gorillz 的mux包。它们允许您映射路径模式,其中包含值的占位符,然后从原始字符串中提取这些占位符并提供给您的代码。例如,您可以映射一个模式/users/{userID}/comments/{commentID},该模式将映射路径,例如/users/1/comments/2。在处理程序代码中,您可以通过放在大括号内的名称获取值,而不必自己解析路径。

由于我们的需求很简单,我们将组合一个简单的路径解析实用程序;如果有必要,我们可以在以后使用不同的包,但这意味着要向我们的项目添加依赖项。

创建一个名为path.go的新文件,并插入以下代码:

package main
import (
  "strings"
)
const PathSeparator = "/"
type Path struct {
  Path string
  ID   string
}
func NewPath(p string) *Path {
  var id string
  p = strings.Trim(p, PathSeparator)
  s := strings.Split(p, PathSeparator)
  if len(s) > 1 {
    id = s[len(s)-1]
    p = strings.Join(s[:len(s)-1], PathSeparator)
  }
  return &Path{Path: p, ID: id}
}
func (p *Path) HasID() bool {
  return len(p.ID) > 0
}

这个简单的解析器提供了一个NewPath函数来解析指定的路径字符串,并返回一个Path类型的新实例。前斜杠和后斜杠被修剪(使用strings.Trim),剩余路径被PathSeparator常量分割(使用strings.Split),该常量只是一个正斜杠。如果有多个段(len(s) > 1,则最后一个段被视为 ID。我们使用s[len(s)-1]重新切片字符串,为 ID 选择最后一个项,并使用s[:len(s)-1]为路径的其余部分选择其余项。在同一行上,我们还使用PathSeparator常量重新连接路径段,以形成一个包含没有 ID 的路径的字符串。

这支持任何collection/id对,这就是我们的 API 所需要的全部。下表显示了给定原始路径字符串的Path类型的状态:

|

原始路径字符串

|

路径

|

身份证件

|

哈西德

| | --- | --- | --- | --- | | / | / | nil | false | | /people/ | people | nil | false | | /people/1/ | people | 1 | true |

为我们的 API 提供服务的简单主函数

web 服务只不过是一个简单的 Go 程序,它绑定到特定的 HTTP 地址和端口并提供请求,因此我们可以使用所有的命令行工具编写知识和技术。

提示

我们还希望确保我们的main函数尽可能简单和适度,这始终是编码的目标,尤其是在 Go 中。

在编写main函数之前,让我们先看看 API 程序的几个设计目标:

  • 我们应该能够指定 API 侦听的 HTTP 地址和端口以及 MongoDB 实例的地址,而无需重新编译程序(通过命令行标志)
  • 我们希望程序在终止时能够正常关闭,允许飞行中的请求(当终止信号发送到我们的程序时仍在处理的请求)完成
  • 我们希望程序注销状态更新并正确报告错误

main.go文件顶部,将main函数占位符替换为以下代码:

func main() {
  var (
    addr  = flag.String("addr", ":8080", "endpoint address")
    mongo = flag.String("mongo", "localhost", "mongodb address")
  )
  flag.Parse()
  log.Println("Dialing mongo", *mongo)
  db, err := mgo.Dial(*mongo)
  if err != nil {
    log.Fatalln("failed to connect to mongo:", err)
  }
  defer db.Close()
  mux := http.NewServeMux()
  mux.HandleFunc("/polls/", withCORS(withVars(withData(db, withAPIKey(handlePolls)))))
  log.Println("Starting web server on", *addr)
  graceful.Run(*addr, 1*time.Second, mux)
  log.Println("Stopping...")
}

这个函数是我们 APImain函数的全部,即使我们的 API 在增长,我们也需要添加一点膨胀。

我们要做的第一件事是指定两个命令行标志,addrmongo,使用一些合理的默认值,并要求flag包解析它们。然后,我们尝试在指定的地址拨打 MongoDB 数据库。如果我们失败,我们将通过呼叫log.Fatalln中止。假设数据库正在运行并且我们能够连接,在延迟关闭连接之前,我们将引用存储在db变量中。这确保了我们的程序在结束后正确地断开连接并进行整理。

然后,我们创建一个新的http.ServeMux对象,它是 Go 标准库提供的请求多路复用器,并为所有以路径/polls/开头的请求注册一个处理程序。

最后,我们利用泰勒·邦内尔的优秀Graceful软件包,可在找到 https://github.com/stretchr/graceful 启动服务器。此包允许我们在运行任何http.Handler(例如我们的ServeMux处理程序)时指定time.Duration,这将允许任何飞行中的请求在函数退出前一段时间内完成。Run功能将一直阻塞,直到程序终止(例如,当有人按下Ctrl+C时)。

使用处理函数包装器

当我们在处理程序上调用HandleFunc时,我们正在使用我们的处理程序函数包装器,行为:

withCORS(withVars(withData(db, withAPIKey(handlePolls)))))

由于每个函数都将一个http.HandlerFunc类型作为参数,并且还返回一个参数,因此我们可以像前面一样通过嵌套函数调用来链接执行。因此,当请求以/polls/路径前缀进入时,程序将采用以下执行路径:

  1. withCORS被调用为,设置合适的头。
  2. 调用withVars,调用OpenVars并延迟CloseVars请求。
  3. 然后调用withData,复制作为第一个参数提供的数据库会话,并延迟该会话的关闭。
  4. 下一步调用withAPIKey,检查 API 密钥的请求,如果无效则中止,否则调用下一个处理函数。
  5. 然后调用handlePolls,它可以访问变量和数据库会话,并且可以使用respond.go中的 helper 函数向客户端写入响应。
  6. 执行回到刚刚退出的withAPIKey
  7. 执行返回到退出的withData,因此调用延迟会话Close函数并清除数据库会话。
  8. 执行返回到退出的withVars,因此调用CloseVars并整理它。
  9. 执行最终回到刚刚退出的withCORS

我们嵌套包装函数的顺序很重要,因为withData使用SetVar将每个请求的数据库会话放入该请求的变量映射中。所以withVars一定在withData之外。如果不遵守这一点,代码可能会死机,您可能需要添加一个检查,以便死机对其他开发人员更有意义。

处理端点

谜题的最后一部分是handlePolls函数,该函数将使用助手理解传入的请求并访问数据库,并生成有意义的响应,该响应将发送回客户端。我们还需要对我们在前一章中使用的民意调查数据进行建模。

创建一个名为polls.go的新文件,并添加以下代码:

package main
import "gopkg.in/mgo.v2/bson"
type poll struct {
  ID      bson.ObjectId  `bson:"_id" json:"id"`
  Title   string         `json":"title""`
  Options []string       `json:"options"`
  Results map[string]int `json:"results,omitempty"`
}

在这里,我们定义了一个名为poll的结构,它有三个字段,依次描述我们在上一章中编写的代码创建和维护的轮询。每个字段也有一个标记(在ID中有两个),这允许我们提供一些额外的元数据。

使用标签向结构添加元数据

标记是在同一行代码上的struct类型中遵循字段定义的字符串。我们使用反勾号字符表示文字字符串,这意味着我们可以在标记字符串本身中自由使用双引号。reflect包允许我们提取与任何键相关的值;在我们的例子中,bsonjson都是键的示例,它们都是由空格字符分隔的键/值对。encoding/jsongopkg.in/mgo.v2/bson包都允许您使用标记来指定将用于编码和解码的字段名(以及一些其他属性),而不是让它从字段本身的名称推断值。我们使用 BSON 与 MongoDB 数据库通信,使用 JSON 与客户机通信,因此我们实际上可以指定相同struct类型的不同视图。例如,考虑 ID 字段:

ID bson.ObjectId `bson:"_id" json:"id"`

Go 中的字段名称为ID,JSON 字段为id,BSON 字段为_id,这是 MongoDB 中使用的特殊标识符字段。

使用单个处理程序执行多个操作

因为我们的简单路径解析解决方案只关心路径,所以在查看客户端正在进行的 RESTful 操作时,我们必须做一些额外的工作。具体来说,我们需要考虑 HTTP 方法,所以我们知道如何处理请求。例如,对我们的/polls/路径的GET调用应该读取 polls,其中POST调用将创建一个新的 polls。有些框架可以为您解决这个问题,它允许您根据路径以外的内容映射处理程序,例如 HTTP 方法或请求中是否存在特定的头。因为我们的案例非常简单,所以我们将使用一个简单的switch案例。在polls.go中增加handlePolls功能:

func handlePolls(w http.ResponseWriter, r *http.Request) {
  switch r.Method {
  case "GET":
    handlePollsGet(w, r)
    return
  case "POST":
    handlePollsPost(w, r)
    return
  case "DELETE":
    handlePollsDelete(w, r)
    return
  }
  // not found
  respondHTTPErr(w, r, http.StatusNotFound)
}

我们打开 HTTP 方法并根据是GETPOST还是DELETE来分支代码。如果 HTTP 方法是其他方法,我们只会以一个404 http.StatusNotFound错误进行响应。要编译此代码,可以在handlePolls处理程序下面添加以下函数存根:

func handlePollsGet(w http.ResponseWriter, r *http.Request) {
  respondErr(w, r, http.StatusInternalServerError, errors.New("not implemented"))
}
func handlePollsPost(w http.ResponseWriter, r *http.Request) {
  respondErr(w, r, http.StatusInternalServerError, errors.New("not implemented"))
}
func handlePollsDelete(w http.ResponseWriter, r *http.Request) {
  respondErr(w, r, http.StatusInternalServerError, errors.New("not implemented"))
}

提示

在本节中,我们学习了如何手动解析请求的元素(HTTP 方法)并在代码中做出决策。这对于简单的情况来说是很好的,但是对于解决这些问题的一些更强大的方法,值得一看 Goweb 或 Gorilla 的mux包。然而,将外部依赖性保持在最低限度是编写良好且包含的 Go 代码的核心理念。

阅读民意测验

现在是实现我们的 web 服务功能的时候了。在GET案例中,添加以下代码:

func handlePollsGet(w http.ResponseWriter, r *http.Request) {
  db := GetVar(r, "db").(*mgo.Database)
  c := db.C("polls")
  var q *mgo.Query
  p := NewPath(r.URL.Path)
  if p.HasID() {
    // get specific poll
    q = c.FindId(bson.ObjectIdHex(p.ID))
  } else {
    // get all polls
    q = c.Find(nil)
  }
  var result []*poll
  if err := q.All(&result); err != nil {
    respondErr(w, r, http.StatusInternalServerError, err)
    return
  }
  respond(w, r, http.StatusOK, &result)
}

我们在每个子句柄函数中做的第一件事就是使用GetVar获取mgo.Database对象,这将允许我们与 MongoDB 交互。由于这个处理程序嵌套在withVarswithData中,我们知道当执行到达我们的处理程序时,数据库将可用。然后,我们使用mgo创建一个对象,引用数据库中的polls集合,如果您还记得的话,这就是我们的民意测验所在地。

然后我们通过解析路径建立一个mgo.Query对象。如果存在 ID,我们在polls集合上使用FindId方法,否则我们将nil传递给Find方法,这表示我们要选择所有投票。我们正在使用ObjectIdHex方法将 ID 从字符串转换为bson.ObjectId类型,以便我们可以使用数字(十六进制)标识符引用轮询。

由于All方法希望生成轮询对象的集合,因此我们将结果定义为[]*poll,或轮询类型的指针片段。对查询调用All方法将导致mgo使用其与 MongoDB 的连接来读取所有轮询并填充result对象。

对于小规模的项目,例如少量的投票,这种方法是很好的,但是随着投票的数量增长,我们需要考虑对结果进行寻呼,甚至在查询上使用 AUTYT0ED 方法对它们进行迭代,因此我们不试图将太多的数据加载到内存中。

现在我们已经添加了一些功能,让我们第一次试用我们的 API。如果您使用的是我们在上一章中设置的同一个 MongoDB 实例,那么您应该已经在polls集合中有了一些数据;要查看我们的 API 是否正常工作,您应该确保数据库中至少有两个轮询。

提示

如果您需要向数据库添加其他轮询,请在终端中运行mongo命令以打开一个数据库 shell,该 shell 将允许您与 MongoDB 交互。然后输入以下命令以添加一些测试轮询:

> use ballots
switched to db ballots
> db.polls.insert({"title":"Test poll","options":["one","two","three"]})
> db.polls.insert({"title":"Test poll two","options":["four","five","six"]})

在终端中,导航到您的api文件夹,构建并运行项目:

go buildo api
./api

现在通过在浏览器中导航到http://localhost:8080/polls/?key=abc123/polls/端点发出GET请求;请记住包含尾部斜杠。结果将是 JSON 格式的轮询数组。

复制并粘贴轮询列表中的一个 ID,并将其插入浏览器中的?字符之前,以访问特定轮询的数据;例如,http://localhost:8080/polls/5415b060a02cd4adb487c3ae?key=abc123。请注意,它不是返回所有的民意测验,而是只返回一个。

提示

通过删除或更改密钥参数来测试 API 密钥功能,以查看错误的外观。

您可能还注意到,尽管我们只返回一个轮询,但该轮询值仍然嵌套在数组中。这是一个深思熟虑的设计决策,有两个原因:第一个也是最重要的原因是嵌套使 API 用户更容易编写代码来使用数据。如果用户总是期望 JSON 数组,那么他们可以编写描述该期望的强类型,而不是一种类型用于单个轮询,另一种类型用于轮询集合。作为一名 API 设计师,这是您的决定。我们将对象嵌套在数组中的第二个原因是,它使 API 代码更简单,允许我们只更改mgo.Query对象,而将其余代码保持不变。

创建投票

客户应能够向/polls/发出POST请求以创建投票。让我们在POST案例中添加以下代码:

func handlePollsPost(w http.ResponseWriter, r *http.Request) {
  db := GetVar(r, "db").(*mgo.Database)
  c := db.C("polls")
  var p poll
  if err := decodeBody(r, &p); err != nil {
    respondErr(w, r, http.StatusBadRequest, "failed to read poll from request", err)
    return
  }
  p.ID = bson.NewObjectId()
  if err := c.Insert(p); err != nil {
    respondErr(w, r, http.StatusInternalServerError, "failed to insert poll", err)
    return
  }
  w.Header().Set("Location", "polls/"+p.ID.Hex())
  respond(w, r, http.StatusCreated, nil)
}

这里,我们首先尝试解码请求主体,根据 RESTful 原则,请求主体应该包含客户端想要创建的轮询对象的表示。如果出现错误,我们使用respondErr助手将错误写入用户,并立即返回函数。然后,我们为轮询生成一个新的唯一 ID,并使用mgo包的Insert方法将其发送到数据库中。根据 HTTP 标准,我们随后设置响应的Location头,并使用201 http.StatusCreated消息进行响应,指向新创建的轮询可能访问的 URL。

删除投票

我们将在 API 中包含的最后一项功能是删除投票的功能。通过使用DELETEHTTP 方法向轮询的 URL(如/polls/5415b060a02cd4adb487c3ae发出请求,我们希望能够从数据库中删除轮询并返回200 Success响应:

func handlePollsDelete(w http.ResponseWriter, r *http.Request) {
  db := GetVar(r, "db").(*mgo.Database)
  c := db.C("polls")
  p := NewPath(r.URL.Path)
  if !p.HasID() {
    respondErr(w, r, http.StatusMethodNotAllowed, "Cannot delete all polls.")
    return
  }
  if err := c.RemoveId(bson.ObjectIdHex(p.ID)); err != nil {
    respondErr(w, r, http.StatusInternalServerError, "failed to delete poll", err)
    return
  }
  respond(w, r, http.StatusOK, nil) // ok
}

类似于GET案例,我们解析路径,但这次如果路径不包含 ID,我们将以错误响应。目前,我们不希望人们能够通过一个请求删除所有轮询,因此使用合适的StatusMethodNotAllowed代码。然后,使用我们在前面案例中使用的相同集合,我们调用RemoveId,在将 ID 转换为bson.ObjectId类型后在路径中传递 ID。假设一切顺利,我们会以http.StatusOK信息回应,没有尸体。

CORS 支持

为了使我们的DELETE能够在 CORS 上工作,我们必须做一些额外的工作来支持 CORS 浏览器处理一些 HTTP 方法的方式,比如DELETE。CORS 浏览器实际上会发送一个飞行前请求(HTTP 方法为OPTIONS),请求允许发出一个DELETE请求(列在Access-Control-Request-Method请求头中),API 必须做出适当的响应才能使请求生效。在OPTIONSswitch语句中添加另一个案例:

case "OPTIONS":
  w.Header().Add("Access-Control-Allow-Methods", "DELETE")
  respond(w, r, http.StatusOK, nil)
  return

如果浏览器请求发送DELETE请求的权限,API 将通过将Access-Control-Allow-Methods头设置为DELETE来响应,从而覆盖我们在withCORS包装处理程序中设置的默认*值。在现实世界中,Access-Control-Allow-Methods报头的值将根据请求而改变,但由于DELETE是我们支持的唯一情况,我们现在可以对其进行硬编码。

CORS 的详细信息超出了本书的范围,但如果您打算构建真正可访问的 web 服务和 API,建议您在线研究这些细节。前往http://enable-cors.org/ 开始吧。

使用 curl 测试我们的 API

curl是一个命令行工具,允许我们向我们的服务发出 HTTP请求,这样我们就可以访问它,就好像我们是一个真正的应用程序或使用该服务的客户一样。

默认情况下,Windows 用户没有访问curl的权限,需要寻找其他选项。退房http://curl.haxx.se/dlwiz/?type=bin 或在网上搜索“Windowscurl备选方案”。

在终端中,让我们通过 API 读取数据库中的所有轮询。导航到您的api文件夹,构建并运行项目,同时确保 MongoDB 正在运行:

go buildo api
./api

然后,我们执行以下步骤:

  1. 输入下面的命令,该命令使用-X标志表示我们要向指定的 URL 发出GET请求:

    curl -X GET http://localhost:8080/polls/?key=abc123
  2. 点击输入

    [{"id":"541727b08ea48e5e5d5bb189","title":"Best Beatle?","options":["john","paul","george","ringo"]},{"id":"541728728ea48e5e5d5bb18a","title":"Favorite language?","options":["go","java","javascript","ruby"]}]

    后打印输出

  3. 虽然它并不漂亮,但您可以看到 API 从数据库返回轮询。发出以下命令以创建新的轮询:

    curl --data '{"title":"test","options":["one","two","three"]}' -X POST http://localhost:8080/polls/?key=abc123
  4. 再次获取列表以查看包含的新投票:

    curl -X GET http://localhost:8080/polls/?key=abc123
  5. 复制并粘贴其中一个 ID,并调整 URL 以专门引用该轮询:

    curl -X GET http://localhost:8080/polls/541727b08ea48e5e5d5bb189?key=abc123
    [{"id":"541727b08ea48e5e5d5bb189",","title":"Best Beatle?","options":["john","paul","george","ringo"]}]
  6. 现在我们只看到选中的投票Best Beatle。让我们发出DELETE请求删除投票:

    curl -X DELETE http://localhost:8080/polls/541727b08ea48e5e5d5bb189?key=abc123
  7. 现在,当我们再次获得所有投票时,我们将看到Best Beatle投票已经结束:

    curl -X GET http://localhost:8080/polls/?key=abc123
    [{"id":"541728728ea48e5e5d5bb18a","title":"Favorite language?","options":["go","java","javascript","ruby"]}]

因此,现在我们知道我们的 API 按预期工作,是时候构建一些正确使用 API 的东西了。

使用 API 的 web 客户端

我们将组装一个超简单的 web 客户端,它使用通过我们的 API 公开的功能和数据,允许用户与我们在上一章和本章前面构建的轮询系统进行交互。我们的客户将由三个网页组成:

  • 显示所有投票的index.html页面
  • 显示特定民意测验结果的view.html页面
  • 允许用户创建新投票的new.html页面

api文件夹旁边新建一个名为web的文件夹,并将以下内容添加到main.go文件中:

package main
import (
  "flag"
  "log"
  "net/http"
)
func main() {
  var addr = flag.String("addr", ":8081", "website address")
  flag.Parse()
  mux := http.NewServeMux()
  mux.Handle("/", http.StripPrefix("/", 
    http.FileServer(http.Dir("public"))))
  log.Println("Serving website at:", *addr)
  http.ListenAndServe(*addr, mux)
}

这几行 Go 代码真正突出了语言和 Go 标准库的美。它们代表了一个完整的、高度可扩展的静态网站托管程序。该程序使用一个addr标志,并使用熟悉的http.ServeMux类型为名为public的文件夹中的静态文件提供服务。

提示

在构建 UI 的同时构建接下来的几个页面包括编写大量 HTML 和 JavaScript 代码。由于这不是 Go 代码,如果您不想全部输入,请随时前往 GitHub 存储库获取本书,并从复制并粘贴它 https://github.com/matryer/goblueprints

显示民意测验列表的索引页

web中创建文件夹,并在其中写入以下 HTML 代码后添加index.html文件:

<!DOCTYPE html>
<html>
<head>
  <title>Polls</title>
  <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
</head>
<body>
</body>
</html>

我们将再次使用 Bootstrap 使简单的 UI 看起来更漂亮,但是我们需要在 HTML 页面的body标记中添加两个额外的部分。首先,添加将显示轮询列表的 DOM 元素:

<div class="container">
  <div class="col-md-4"></div>
  <div class="col-md-4">
    <h1>Polls</h1>
    <ul id="polls"></ul>
    <a href="new.html" class="btn btn-primary">Create new poll</a>
  </div>
  <div class="col-md-4"></div>
</div>

在这里,我们使用 Bootstrap 的网格系统来集中调整我们的内容,该内容由一个投票列表和一个指向new.html的链接组成,用户可以在这里创建新的投票。

接下来,在前面的代码下面添加以下script标记和 JavaScript:

<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
<script>
  $(function(){
    var update = function(){
      $.get("http://localhost:8080/polls/?key=abc123", null, null, "json")
        .done(function(polls){
          $("#polls").empty();
          for (var p in polls) {
            var poll = polls[p];
            $("#polls").append(
              $("<li>").append(
                $("<a>")
                  .attr("href", "view.html?poll=polls/" + poll.id)
                  .text(poll.title)
              )
            )
          }
        }
      );
      window.setTimeout(update, 10000);
    }
    update();
  });
</script>

我们正在使用 jQuery 的$.get函数向我们的 web 服务发出 AJAX 请求。我们也在对 API URL 进行硬编码。实际上,您可能会反对这种做法,但至少应该使用域名对其进行抽象。加载轮询后,我们使用 jQuery 建立一个包含指向view.html页面的超链接的列表,并将轮询 ID 作为查询参数传递。

创建新投票的页面

要允许用户创建新的投票,请在public文件夹中创建一个名为new.html的文件,并将以下 HTML 代码添加到该文件中:

<!DOCTYPE html>
<html>
<head>
  <title>Create Poll</title>
  <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
</head>
<body>
  <script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
  <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
</body>
</html>

我们将为 HTML 表单添加元素,这些元素将捕获创建新投票时所需的信息,即投票标题和选项。在body标签内添加以下代码:

<div class="container">
  <div class="col-md-4"></div>
  <form id="poll" role="form" class="col-md-4">
    <h2>Create Poll</h2>
    <div class="form-group">
      <label for="title">Title</label>
      <input type="text" class="form-control" id="title" placeholder="Title">
    </div>
    <div class="form-group">
      <label for="options">Options</label>
      <input type="text" class="form-control" id="options" placeholder="Options">
      <p class="help-block">Comma separated</p>
    </div>
    <button type="submit" class="btn btn-primary">Create Poll</button> or <a href="/">cancel</a>
  </form>
  <div class="col-md-4"></div>
</div>

由于我们的 API 讲 JSON,我们需要做一些工作,将 HTML 表单转换为 JSON 编码的字符串,并将逗号分隔的选项字符串分解为一个选项数组。添加以下script标签:

<script>
  $(function(){
    var form = $("form#poll");
    form.submit(function(e){
      e.preventDefault();
      var title = form.find("input[id='title']").val();
      var options = form.find("input[id='options']").val();
      options = options.split(",");
      for (var opt in options) {
        options[opt] = options[opt].trim();
      }
      $.post("http://localhost:8080/polls/?key=abc123",
        JSON.stringify({
          title: title, options: options
        })
      ).done(function(d, s, r){
        location.href = "view.html?poll=" + r.getResponseHeader("Location");
      });
    });
  });
</script>

在这里,我们向表单的submit事件添加一个监听器,并使用 jQuery 的val方法收集输入值。我们用逗号分割选项,并在使用$.post方法向适当的 API 端点发出POST请求之前修剪掉空格。JSON.stringify允许我们将数据对象转换为 JSON 字符串,我们使用该字符串作为请求的主体,正如 API 所期望的那样。成功后,我们拉出Location标题并将用户重定向到view.html页面,将对新创建的轮询的引用作为参数传递。

显示投票详情的页面

我们需要完成的应用程序的最后一个页面是view.html页面,用户可以在该页面查看投票的详细信息和实况结果。在public文件夹中创建一个名为view.html的新文件,并向其中添加以下 HTML 代码:

<!DOCTYPE html>
<html>
<head>
  <title>View Poll</title>
  <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
</head>
<body>
  <div class="container">
    <div class="col-md-4"></div>
    <div class="col-md-4">
      <h1 data-field="title">...</h1>
      <ul id="options"></ul>
      <div id="chart"></div>
      <div>
        <button class="btn btn-sm" id="delete">Delete this poll</button>
      </div>
    </div>
    <div class="col-md-4"></div>
  </div>
</body>
</html>

本页面与其他页面基本相似;它包含用于显示投票标题、选项和饼图的元素。我们将把谷歌的可视化 API 和我们的 API 混合在一起,展示结果。在view.html中的最终div标签下方(在结束body标签上方),添加以下script标签:

<script src="//www.google.com/jsapi"></script>
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
<script>
google.load('visualization', '1.0', {'packages':['corechart']});
google.setOnLoadCallback(function(){
  $(function(){
    var chart;
    var poll = location.href.split("poll=")[1];
    var update = function(){
      $.get("http://localhost:8080/"+poll+"?key=abc123", null, null, "json")
        .done(function(polls){
          var poll = polls[0];
          $('[data-field="title"]').text(poll.title);
          $("#options").empty();
          for (var o in poll.results) {
            $("#options").append(
              $("<li>").append(
                $("<small>").addClass("label label-default").text(poll.results[o]),
                " ", o
              )
            )
          }
          if (poll.results) {
            var data = new google.visualization.DataTable();
            data.addColumn("string","Option");
            data.addColumn("number","Votes");
            for (var o in poll.results) {
              data.addRow([o, poll.results[o]])
            }
            if (!chart) {
              chart = new google.visualization.PieChart(document.getElementById('chart'));
            }
            chart.draw(data, {is3D: true});
          }
        }
      );
      window.setTimeout(update, 1000);
    };
    update();
    $("#delete").click(function(){
      if (confirm("Sure?")) {
        $.ajax({
          url:"http://localhost:8080/"+poll+"?key=abc123",
          type:"DELETE"
        })
        .done(function(){
          location.href = "/";
        })
      }
    });
  });
});
</script>

我们包括为页面、jQuery 和 Bootstrap 提供动力所需的依赖项,以及 Google JavaScript API。代码从 Google 加载适当的可视化库,并等待 DOM 元素加载,然后通过在poll=上拆分从 URL 提取轮询 ID。然后我们创建一个名为update的变量,它表示负责生成页面视图的函数。采取这种方法是为了便于我们使用window.setTimeout定期发出更新视图的调用。在update函数中,我们使用$.get/polls/{id}端点发出GET请求,将{id}替换为之前从 URL 中提取的实际 ID。加载投票后,我们更新页面上的标题,并迭代选项以将其添加到列表中。如果有结果(记得在上一章中,results映射只在选票开始计数时添加到数据中),我们创建一个新的google.visualization.PieChart对象,并构建一个包含结果的google.visualization.DataTable对象。在图表上调用draw会导致它呈现数据,从而用最新的数字更新图表。然后我们使用setTimeout命令代码在另一秒钟内再次调用update

最后,我们绑定到我们添加到页面的delete按钮的click事件,在询问用户是否确定后,向轮询 URL 发出DELETE请求,然后将其重定向回主页。正是这个请求将导致首先发出OPTIONS请求,请求许可,这就是为什么我们在前面的handlePolls函数中添加了对它的明确支持。

运行解决方案

在过去的两章中,我们构建了许多组件,现在是时候看到它们一起工作了。本节包含运行所有项目所需的所有内容,假设您已按照上一章开头所述正确设置了环境。本节假设您有一个包含四个子文件夹的文件夹:apicountertwittervotesweb

假设未运行任何操作,请执行以下步骤(每个步骤在其自己的终端窗口中):

  1. 在顶级文件夹中,启动nsqlookupd守护程序:

    nsqlookupd
  2. 在同一目录中,启动nsqd守护程序:

    nsqd --lookupd-tcp-address=localhost:4160
  3. 启动 MongoDB 守护进程:

    mongod
  4. 导航到counter文件夹,构建并运行它:

    cd counter
    go buildo counter
    ./counter
  5. 导航到twittervotes文件夹,构建并运行它。确保设置了适当的环境变量,否则在运行程序时会看到错误:

    cd ../twittervotes
    go buildo twittervotes
    ./twittervotes
  6. 导航到api文件夹,构建并运行它:

    cd ../api
    go buildo api
    ./api
  7. 导航到web文件夹,构建并运行它:

    cd ../web
    go buildo web
    ./web

现在一切都在运行,打开浏览器,前往http://localhost:8081/。使用用户界面,创建一个名为Moods的轮询,并将选项输入为happy,sad,fail,and success。这些词很常见,我们可能会在 Twitter 上看到一些相关的活动。

创建投票后,您将进入查看页面,在那里您将开始看到结果。等待几秒钟,当 UI 实时更新并显示实时结果时,享受您辛勤工作的成果。

Running the solution

总结

在本章中,我们通过一个高度可扩展的 RESTful API 为我们的社交轮询解决方案公开了数据,并构建了一个使用该 API 的简单网站,为用户提供了一种与之交互的直观方式。该网站只包含静态内容,没有服务器端处理(因为 API 为我们做了繁重的工作)。这使得我们能够非常便宜地在静态托管网站(如bitballoon.com)上托管网站,或者将文件分发到内容交付网络。

在我们的 API 服务中,我们学习了如何在处理程序之间共享数据,而不破坏或混淆标准库中的处理程序模式。我们还了解了编写包装处理程序函数如何以非常简单直观的方式构建功能管道。

我们编写了一些基本的编码和解码函数,这些函数目前只是简单地包装了encoding/json包中的对应函数,但稍后可以进行改进,以支持一系列不同的数据表示,而无需更改代码的内部接口。我们编写了一些简单的帮助函数,使响应数据请求变得容易,同时提供了同样的抽象,使我们能够在以后改进 API。

我们看到,对于简单的情况,切换到 HTTP 方法是一种为单个端点支持许多函数的优雅方式。我们还看到,通过几行额外的代码,我们能够构建对 CORS 的支持,从而允许运行在不同域上的应用程序与我们的服务交互,而不需要像 JSONP 这样的黑客。

本章中的代码与我们在前一章中所做的工作相结合,提供了一个现实世界中的生产就绪解决方案,该解决方案实现了以下流程:

  1. 用户点击网站上的创建投票按钮,输入投票标题和选项。
  2. 浏览器中运行的 JavaScript 将数据编码为 JSON 字符串,并将其以POST请求的形式发送给我们的 API。
  3. API 接收请求,在验证 API 密钥、设置数据库会话并将其存储在变量映射中后,调用handlePolls函数处理请求并将新轮询存储在 MongoDB 数据库中。
  4. API 将用户重定向到新创建的轮询的view.html页面。
  5. 同时,twittervotes程序加载数据库中的所有民意测验,包括新的民意测验,并打开与 Twitter 的连接,在代表民意测验选项的哈希标签上进行过滤。
  6. 随着选票的到来,twittervotes将他们推到 NSQ。
  7. counter程序正在收听相应的频道,并注意到选票的到来,对每个选票进行计数,并定期更新数据库。
  8. 当网站不断向 API 端点发出所选投票的GET请求时,用户会看到view.html页面上显示(并刷新)的结果。

在下一章中,我们将改进 API 和 web 技能,以构建一个名为 Meander 的全新初创应用程序。我们将看到如何用几行 Go 代码编写一个完整的静态 web 服务器,并探索一种有趣的方法,用一种官方不支持枚举数的语言来表示枚举数!