精通-Go-Web-服务(全)

精通 Go Web 服务(全)

原文:zh.annas-archive.org/md5/2D0D1F51B3626D3F3DD6A0D48080FBC1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

如果有一件事比其他任何事都更多地提到 Go 语言,那就是“Go 是一种服务器语言”。

毫无疑问,Go 被设计为一种理想的服务器语言,是 C、C++和 Java 的下一代迭代版本,旨在避免过度扩展和/或过度工程化。

这种语言已经发展壮大——在狂热的社区支持下——远远超出了服务器的范畴,进入了系统工具、图形甚至是新语言的编译器。然而,Go 的本质是为强大、并发和易于部署的跨平台服务器而设计的。这正是使该语言成为本书主题的理想选择。

《Go 中的 Web 服务精通》旨在成为构建可扩展的、可用于生产的 Web 服务和 API 的指南,重点放在安全性、可扩展性和遵循 RESTful 原则上。

在本书中,我们将为社交网络构建一个基本的 API,这将使我们能够深入了解一些基本概念,如将 Go 连接到其他服务以及保持服务器安全和高可用性。

本书结束时,您应该对构建健壮、可扩展、安全和生产就绪的网络服务的所有相关实例有所经验。

本书涵盖的内容

第一章,“Go 中的我们的第一个 API”,快速介绍了或重新介绍了与 Go 设置和使用相关的一些核心概念,以及http包。

第二章,“Go 中的 RESTful 服务”,侧重于 REST 架构的指导原则,并将其转化为我们整体 API 设计基础设施。

第三章,“路由和引导”,致力于将前一章的 RESTful 实践应用于我们 API 的内置、第三方和自定义路由器的搭建。

第四章,“在 Go 中设计 API”,探讨了整体 API 设计,同时考察了其他相关概念,如在 REST 架构中利用 Web 套接字和 HTTP 状态代码。

第五章,“Go 中的模板和选项”,涵盖了利用OPTIONS请求端点、实现 TLS 和身份验证以及在我们的 API 中标准化响应格式的方法。

第六章,“在 Go 中访问和使用网络服务”,探讨了集成其他网络服务以安全方式进行身份验证和身份识别的方法。

第七章,“使用其他网络技术”,侧重于引入应用架构的其他关键组件,如前端反向代理服务器和解决方案,以将会话数据保留在内存或数据存储中,以便快速访问。

第八章,“Web 的响应式 Go”,着眼于以消费者的方式表达我们 API 的价值,但利用前端、客户端库来解析和呈现我们的响应。

第九章,“部署”,介绍了部署策略,包括利用进程使我们的服务器保持运行、高度可访问,并与相关服务相互连接。

第十章,“性能最大化”,强调了在生产中保持我们的 API 活跃、响应迅速和快速的各种策略。我们将研究保存在磁盘和内存中的缓存机制,以及探索我们如何将这些机制分布到多台机器或镜像中的方法。

第十一章,“安全”,更侧重于确保应用程序和敏感数据受到保护的最佳实践。我们将消除 SQL 注入和跨站脚本攻击。

本书所需的内容

要使用本书中的示例,您可以使用 Windows,Linux 或 OS X 计算机中的任何一个,尽管您可能会发现 Windows 在使用一些我们将使用的第三方工具时会有一些限制。

您显然需要安装 Go 语言平台。最简单的方法是通过二进制文件,在 OS X 或 Windows 上可用。 Go 也可以通过多个 Linux 软件包管理器轻松获得,例如 yum 或 aptitude。

IDE 的选择在很大程度上是个人问题,但我们推荐 Sublime Text,它对 Go 有出色的支持,还支持其他语言。我们将花一些时间详细介绍其他常见 IDE 的优缺点,详见第一章,在 Go 中创建我们的第一个 API

我们将利用许多其他平台和服务,如 MySQL,MongoDB,Nginx 等。大多数应该在各个平台上都可用,但如果您使用 Windows,建议您考虑在虚拟机上运行 Linux 平台,最好是 Ubuntu 服务器,以确保最大的兼容性。

这本书适合谁

本书适用于那些在 Go 和服务器端 Web 服务和 API 开发方面有经验的开发人员。我们没有花时间介绍 Go 编程的基础知识,所以如果你在这方面感到不稳定,建议您在深入学习之前先进行复习。

目标读者对服务器级别的网络性能感到舒适,对 REST 作为 API 设计指导原则有一定了解,并且至少知道 Go 的本地服务器能力。

我们并不预期您对所有涉及的技术都是专家,但对 Go 的核心库有基本的理解是必要的,并且对网络服务器架构设置和维护有一般的理解是理想的。

约定

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 用户名显示如下:“现在在临时文件夹中下载julia-n.m.p-win64.exe文件。”

代码块设置如下:

package main

import (
  "fmt"
)
func main() {
  fmt.Println("Here be the code")
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示。

package main
import (
  "fmt"
)

func stringReturn(text string) string {
 return text
}

func main() {
  myText := stringReturn("Here be the code")
  fmt.Println(myText)
}

任何命令行输入或输出都以以下方式编写:

curl --head http://localhost:8080/api/user/read/1111
HTTP/1.1 200 OK
Date: Wed, 18 Jun 2014 14:09:30 GMT
Content-Length: 12
Content-Type: text/plain; charset=utf-8

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:“当用户点击接受时,我们将返回到我们的重定向 URL,并获得我们正在寻找的代码。”

注意

警告或重要说明显示在这样的框中。

提示

提示和技巧显示如下。

第一章:我们在 Go 中的第一个 API

如果您花费了时间在 Web 上开发应用程序(或者说,不管在哪里),您很快就会发现自己面临与 Web 服务或 API 进行交互的前景。

无论是您需要的库还是必须与之交互的另一个应用程序的沙盒,开发世界在很大程度上依赖于不同应用程序、语言和格式之间的合作。

毕竟,这就是我们拥有 API 的原因——允许任何两个给定平台之间的标准化通信。

如果您在 Web 上花费了很长时间,您会遇到糟糕的 API。所谓的糟糕是指不全面、不遵循最佳实践和标准、在语义上令人困惑或缺乏一致性的 API。您会遇到一些 API 在某些地方随意使用 OAuth 或简单的 HTTP 身份验证,而在其他地方则相反,或者更常见的是,API 忽略了 HTTP 动词的规定用途(我们将在本章后面更多地讨论这一点)。

谷歌的 Go 语言特别适用于服务器。具有内置的 HTTP 服务、数据的简单 XML 和 JSON 编码方法、高可用性和并发性,它是您的 API 的理想平台。

在本书中,我们不仅将探讨强大而干净的 API 开发,还将探讨其与其他 API 和数据源的交互,以及此类开发的最佳实践。我们将构建一个大型服务和一堆小型服务,用于个别、独立的课程。

最重要的是,到最后,您应该能够在 Go 中与任何网络 API 进行交互,并能够自己设计和执行一个完善的 API 套件。

本书至少需要对基于 Web 的 API 有一定的了解,并且需要具备初学者水平的 Go 能力,但是当我们讨论新概念时,我们会进行一些非常简要的介绍,并引导您获取更多信息,以便了解 Go 或 API 的这一方面。

我们还将稍微涉及 Go 中的并发性,但我们不会过于详细——如果您希望了解更多,请查看我撰写的书籍Mastering Concurrency in GoPackt Publishing

我们将在本章中涵盖以下主题:

  • 了解要求和依赖关系

  • 介绍 HTTP 包

  • 构建我们的第一个路由

  • 通过 HTTP 设置数据

  • 从数据存储器向客户端提供数据

了解要求和依赖关系

在本书中深入研究之前,我们最好先检查一下您需要安装的东西,以便处理我们开发、测试和部署 API 的所有示例。

安装 Go

不用说,我们需要安装 Go 语言。但是,为了完成本书中的所有操作,您还需要安装一些相关项目。

注意

Go 适用于 Mac OS X、Windows 和大多数常见的 Linux 变体。您可以在golang.org/doc/install下载二进制文件。

在 Linux 上,您通常可以通过发行版的软件包管理器获取 Go。例如,您可以通过简单的apt-get install golang命令在 Ubuntu 上获取它。大多数发行版都有类似的方法。

除了核心语言外,我们还将与 Google App Engine 一起工作,并且测试 App Engine 的最佳方法是安装软件开发工具包SDK)。这将允许我们在部署之前在本地测试我们的应用程序,并模拟 App Engine 上提供的许多功能。

注意

App Engine SDK 可以从developers.google.com/appengine/downloads下载。

虽然我们显然最感兴趣的是 Go SDK,但您还应该获取 Python SDK,因为有一些小的依赖关系可能仅在 Go SDK 中不可用。

安装和使用 MySQL

我们将使用许多不同的数据库和数据存储来管理我们的测试和真实数据,而 MySQL 将是其中之一。

我们将使用 MySQL 作为我们用户的存储系统;他们的消息和他们的关系将存储在我们的较大的应用程序中(我们稍后会更多地讨论这一点)。

注意

MySQL 可以从dev.mysql.com/downloads/下载。

您也可以轻松地从 Linux/OS X 的软件包管理器中获取它,方法如下:

  • Ubuntu:sudo apt-get install mysql-server mysql-client

  • OS X 与 Homebrew:brew install mysql

Redis

Redis 是我们将用于几种不同演示的两种 NoSQL 数据存储之一,包括从我们的数据库缓存数据以及 API 输出。

如果您对 NoSQL 不熟悉,我们将在示例中使用 Redis 和 Couchbase 进行一些非常简单的结果收集介绍。如果您了解 MySQL,那么 Redis 至少会感觉相似,您不需要完整的知识库来使用我们为我们的目的使用应用程序。

注意

Redis 可以从redis.io/download下载。

Redis 可以在 Linux/OS X 上使用以下方式下载:

  • Ubuntu:sudo apt-get install redis-server

  • OS X 与 Homebrew:brew install redis

Couchbase

正如前面提到的,Couchbase 将是我们将在各种产品中使用的第二个 NoSQL 解决方案,主要用于设置短暂或瞬时的键存储查找,以避免瓶颈,并作为内存缓存的实验。

与 Redis 不同,Couchbase 使用简单的 REST 命令来设置和接收数据,而且所有内容都以 JSON 格式存在。

注意

Couchbase 可以从www.couchbase.com/download下载。

  • 对于 Ubuntu(deb),请使用以下命令下载 Couchbase:
dpkg -i couchbase-server version.deb

  • 对于使用 Homebrew 的 OS X,请使用以下命令下载 Couchbase:
brew install https://github.com/couchbase/homebrew/raw/stable/Library/Formula/libcouchbase.rb

Nginx

尽管 Go 自带了运行高并发、高性能 Web 服务器所需的一切,但我们将尝试在我们的结果周围包装一个反向代理。我们主要这样做是为了应对关于可用性和速度的现实问题。Nginx 在 Windows 上不是原生可用的

注意

  • 对于 Ubuntu,请使用以下命令下载 Nginx:
apt-get install nginx

  • 对于使用 Homebrew 的 OS X,请使用以下命令下载 Nginx:
brew install nginx

Apache JMeter

我们将利用 JMeter 来对我们的 API 进行基准测试和调优。在这里您有一些选择,因为有几个模拟流量的压力测试应用程序。我们将涉及的两个是JMeter和 Apache 内置的Apache BenchmarkAB)平台。后者在基准测试中是一个坚定不移的选择,但在您可以向 API 发送的内容方面有些受限,因此更倾向于使用 JMeter。

在构建 API 时,我们需要考虑的一件事是其抵御高流量的能力(以及在无法抵御时引入一些缓解措施),因此我们需要知道我们的限制是什么。

注意

Apache JMeter 可以从jmeter.apache.org/download_jmeter.cgi下载。

使用预定义数据集

在本书的整个过程中,虽然没有必要一直使用我们的虚拟数据集,但是当我们构建社交网络时,将其引入可以节省大量时间,因为它充满了用户、帖子和图片。

通过使用这个数据集,您可以跳过创建这些数据来测试 API 和 API 创建的某些方面。

注意

我们的虚拟数据集可以从github.com/nkozyra/masteringwebservices下载。

选择 IDE

集成开发环境IDE)的选择是开发人员可以做出的最个人化的选择之一,很少有开发人员对自己喜欢的 IDE 不充满激情。

本书中没有任何内容需要特定的 IDE;事实上,Go 在编译、格式化和测试方面的大部分优势都在命令行级别。不过,我们至少想探索一些 Go 的更受欢迎的编辑器和 IDE 选择。

Eclipse

作为任何语言可用的最受欢迎和最广泛的 IDE 之一,Eclipse 是一个显而易见的首选。大多数语言都通过 Eclipse 插件获得支持,Go 也不例外。

这款庞大的软件也有一些缺点;它在某些语言上偶尔会出现错误,有些自动完成功能的速度明显较慢,并且比大多数其他可用选项更加沉重。

然而,它的优点是多方面的。Eclipse 非常成熟,并且有一个庞大的社区,您可以在出现问题时寻求支持。而且,它是免费的。

注意

Sublime Text

Sublime Text 是我们特别喜欢的,但它有一个很大的警告——它是这里列出的唯一一个不免费的。

这款软件更像是一个完整的代码/文本编辑器,而不是一个沉重的 IDE,但它包括代码完成选项,并且可以直接将 Go 编译器(或其他语言的编译器)集成到界面中。

尽管 Sublime Text 的许可证价格为 70 美元,但许多开发人员发现它的优雅和速度是非常值得的。您可以无限期地尝试该软件,以查看它是否适合您;除非您购买许可证,否则它将作为催告软件运行。

注意

Sublime Text 可以从www.sublimetext.com/2下载。

LiteIDE

LiteIDE 是比其他提到的 IDE 更年轻的一个,但它值得一提,因为它专注于 Go 语言。

它是跨平台的,并且在后台执行了很多 Go 的命令行魔术,使其真正集成。LiteIDE 还可以在 IDE 中直接处理代码自动完成、go fmt、构建、运行和测试,以及强大的包浏览器。

它是免费的,如果您想要一个精简且专门针对 Go 语言的工具,那么它绝对值得一试。

注意

LiteIDE 可以从code.google.com/p/golangide/下载。

IntelliJ IDEA

与 Eclipse 齐名的是 JetBrains 系列的 IDE,它涵盖了大约与 Eclipse 相同数量的语言。最终,两者都主要是以 Java 为主要考虑因素,这意味着有时其他语言的支持可能会次要。

这里的 Go 集成似乎相当强大和完整,因此如果您有许可证,那么它是值得一试的。如果您没有许可证,您可以尝试免费的 Community Edition。

注意

一些客户端工具

尽管我们将主要关注 Go 和 API 服务,但我们将对客户端与 API 的交互进行一些可视化。

因此,我们将主要关注纯 HTML 和 JavaScript,但对于更多的交互点,我们还将使用 jQuery 和 AngularJS。

注意

我们为客户端演示所做的大部分内容都可以在本书的 GitHub 存储库github.com/nkozyra/goweb的 client 目录下找到。

jQuery 和 AngularJS 都可以从 Google 的 CDN 动态加载,这样您就不必在本地下载和存储它们。托管在 GitHub 上的示例会动态调用它们。

要动态加载 AngularJS,请使用以下代码:

<script src="img/angular.min.js"></script>

要动态加载 jQuery,请使用以下代码:

<script src="img/jquery.min.js"></script>

查看我们的应用程序

在本书中,我们将构建许多小应用程序来演示要点、函数、库和其他技术。但是,我们也将专注于一个更大的项目,模拟一个社交网络,在其中我们通过 API 创建和返回用户、状态等。

尽管我们将致力于构建一个更大的应用程序来演示每个部分的拼图,但我们也将构建和测试独立的应用程序、API 和接口。

后一组将以快速入门为前缀,以让您知道它不是我们更大应用程序的一部分。

设置我们的数据库

如前所述,我们将设计一个几乎完全在 API 级别上运行的社交网络(至少起初是这样),作为本书中的主要项目。

当我们想到主要的社交网络(过去和现在),它们中有一些无处不在的概念,如下所示:

  • 创建用户并维护用户资料的能力

  • 分享消息或状态并基于它们进行对话的能力

  • 表达对所述状态/消息的喜好或厌恶,以决定任何给定消息的价值

这里还有一些其他功能,我们将从这里开始构建,但让我们从基础知识开始。让我们按以下方式在 MySQL 中创建我们的数据库:

create database social_network;

这将是本书中我们社交网络产品的基础。目前,我们只需要一个users表来存储我们的个人用户及其最基本的信息。随着我们的进展,我们将对其进行修改以包括更多功能:

CREATE TABLE users (
  user_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  user_nickname VARCHAR(32) NOT NULL,
  user_first VARCHAR(32) NOT NULL,
  user_last VARCHAR(32) NOT NULL,
  user_email VARCHAR(128) NOT NULL,
  PRIMARY KEY (user_id),
  UNIQUE INDEX user_nickname (user_nickname)
)

在本章中,我们不需要做太多事情,所以这就够了。我们将拥有用户的最基本信息——姓名、昵称和电子邮件,没有太多其他信息。

介绍 HTTP 包

我们的大部分 API 工作将通过 REST 处理,因此您应该对 Go 的http包非常熟悉。

除了通过 HTTP 提供服务外,http包还包括许多其他非常有用的实用程序,我们将详细了解这些实用程序。这些包括 cookie jars、设置客户端、反向代理等。

但目前我们感兴趣的主要实体是http.Server结构,它提供了我们服务器所有操作和参数的基础。在服务器内部,我们可以设置 TCP 地址、用于路由特定请求的 HTTP 多路复用、超时和标头信息。

Go 还提供了一些快捷方式来调用服务器,而不是直接初始化结构。例如,如果您有许多默认属性,您可以使用以下代码:

Server := Server {
  Addr: ":8080",
  Handler: urlHandler,
  ReadTimeout: 1000 * time.MicroSecond,
  WriteTimeout: 1000 * time.MicroSecond,
  MaxHeaderBytes: 0,
  TLSConfig: nil
}

您可以简单地使用以下代码执行:

http.ListenAndServe(":8080", nil)

这将为您调用一个服务器结构并仅设置AddrHandler属性。

当然,有时我们会想要更精细地控制我们的服务器,但目前这样就够了。让我们首次将这个概念输出一些 JSON 数据通过 HTTP。

快速入门-通过 API 说 Hello, World

正如本章前面提到的,我们将偏离原题,做一些我们将以快速入门为前缀的工作,以示它与我们更大的项目无关。

在这种情况下,我们只想激活我们的http包并向浏览器传递一些 JSON。毫不奇怪,我们只会向世界输出令人沮丧的Hello, world消息。

让我们使用所需的包和导入来设置这个:

package main

import
(
  "net/http"
  "encoding/json"
  "fmt"
)

这是我们需要通过 HTTP 输出简单的 JSON 字符串的最低要求。编组 JSON 数据可能比我们在这里看到的要复杂一些,所以如果我们的消息结构不立即让人明白,不要担心。

这是我们的响应结构,包含我们希望从 API 中获取并发送给客户端的所有数据:

type API struct {
  Message string "json:message"
}

显然这里还没有太多东西。我们只设置了一个消息字符串,显然命名为Message变量。

最后,我们需要设置我们的主要函数(如下所示)来响应路由并提供一个经过编组的 JSON 响应:

func main() {

  http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {

    message := API{"Hello, world!"}

    output, err := json.Marshal(message)

    if err != nil {
      fmt.Println("Something went wrong!")
    }

    fmt.Fprintf(w, string(output))

  })

  http.ListenAndServe(":8080", nil)
}

进入main()后,我们设置了一个路由处理函数,以响应在/api处初始化一个带有Hello, world!的 API 结构。然后我们将其编组为 JSON 字节数组output,并在将此消息发送到我们的iowriter类(在本例中为http.ResponseWriter值)后,将其转换为字符串。

最后一步是一种快速而粗糙的方法,通过一个期望字符串的函数发送我们的字节数组,但在这样做时几乎不会出现什么问题。

Go 通过将类型作为环绕目标变量的函数来简单处理类型转换。换句话说,我们可以通过简单地用int(OurInt64)函数将int64值转换为整数来进行类型转换。当然,也有一些例外情况——一些类型不能直接转换,还有一些其他陷阱,但这是一般的想法。在可能的例外情况中,一些类型不能直接转换为其他类型,有些需要像strconv这样的包来管理类型转换。

如果我们在浏览器中输入localhost:8080/api(如下截图所示),您应该会得到我们期望的结果,假设一切都正确:

快速命中-通过 API 说 Hello, World

构建我们的第一个路由

当我们谈论 Go 术语中的路由时,我们更准确地讨论的是多路复用器或mux。在这种情况下,多路复用器指的是将 URL 或 URL 模式转换为内部函数。

您可以将这看作是从请求到函数(或处理程序)的简单映射。您可能会设计出类似以下的东西:

/api/user  func apiUser
/api/message  func apiMessage
/api/status  func apiStatus

net/http包提供的内置 mux/router 存在一些限制。例如,您不能为路由提供通配符或正则表达式。

您可能期望能够像下面的代码片段中所讨论的那样做一些事情:

  http.HandleFunc("/api/user/\d+", func(w http.ResponseWriter, r *http.Request) {

    // react dynamically to an ID as supplied in the URL

  })

然而,这会导致解析错误。

如果您在任何成熟的 Web API 中花费了一些时间,您会知道这是行不通的。我们需要能够对动态和不可预测的请求做出反应。这意味着无法预料每个数字用户与函数的映射是不可行的。我们需要能够接受和使用模式。

对于这个问题有一些解决方案。第一个是使用具有这种强大路由功能的第三方平台。有一些非常好的平台可供选择,所以我们现在快速看一下这些。

Gorilla

Gorilla 是一个全面的 Web 框架,我们在本书中会经常使用它。它具有我们需要的精确的 URL 路由包(在其gorilla/mux包中),并且还提供一些其他非常有用的工具,如 JSON-RPC、安全 cookie 和全局会话数据。

Gorilla 的mux包让我们可以使用正则表达式,但它也有一些简写表达式,让我们定义我们期望的请求字符串类型,而不必写出完整的表达式。

例如,如果我们有一个像/api/users/309这样的请求,我们可以在 Gorilla 中简单地路由它如下:

gorillaRoute := mux.NewRouter()
gorillaRoute.HandleFunc("/api/{user}", UserHandler)

然而,这样做存在明显的风险——通过让这一切如此开放,我们有可能遇到一些数据验证问题。如果这个函数接受任何参数,而我们只期望数字或文本,这将在我们的基础应用程序中造成问题。

因此,Gorilla 允许我们使用正则表达式来澄清这一点,如下所示:

r := mux.NewRouter()
r.HandleFunc("/products/{user:\d+}", ProductHandler)

现在,我们只会得到我们期望的——基于数字的请求参数。让我们修改我们之前的示例,以演示这个概念:

package main

import (
  "encoding/json"
  "fmt"
  "github.com/gorilla/mux"
  "net/http"
)

type API struct {
  Message string "json:message"
}

func Hello(w http.ResponseWriter, r *http.Request) {

  urlParams := mux.Vars(r)
  name := urlParams["user"]
  HelloMessage := "Hello, " + name

  message := API{HelloMessage}
  output, err := json.Marshal(message)

  if err != nil {
    fmt.Println("Something went wrong!")
  }

  fmt.Fprintf(w, string(output))

}

func main() {

  gorillaRoute := mux.NewRouter()
  gorillaRoute.HandleFunc("/api/{user:[0-9]+}", Hello)
  http.Handle("/", gorillaRoute)
  http.ListenAndServe(":8080", nil)
}

提示

下载示例代码

您可以从您在www.packtpub.com的帐户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

通过这段代码,我们在路由级别上进行了一些验证。对/api/44的有效请求将给我们一个正确的响应,如下面的屏幕截图所示:

大猩猩

/api/nkozyra之类的无效请求将给我们一个 404 响应。

注意

路由

来自drone.io的 Routes,明确且专门用于 Go 的路由包。这使它比 Gorilla web 工具包更加专注。

在较小的应用程序中,URL 路由大多数情况下不会成为瓶颈,但随着应用程序规模的扩大,这是需要考虑的事情。对于我们的目的,例如 Gorilla 和 Routes 之间的速度差异是可以忽略不计的。

在 routes 中定义您的mux包非常干净简单。这是对我们的Hello world消息的一个变体,它响应 URL 参数:

func Hello(w http.ResponseWriter, r *http.Request) {

  urlParams := r.URL.Query()
  name := urlParams.Get(":name")
  HelloMessage := "Hello, " + name
  message := API{HelloMessage}
  output, err := json.Marshal(message)

  if err != nil {
    fmt.Println("Something went wrong!")
  }

  fmt.Fprintf(w, string(output))

}

func main() {

  mux := routes.New()
  mux.Get("/api/:name", Hello)
  http.Handle("/", mux)
  http.ListenAndServe(":8080", nil)
}

这里的主要区别(与 Gorilla 一样)是我们将我们的routes多路复用器传递给http,而不是使用内部的多路复用器。与 Gorilla 一样,我们现在可以使用可变的 URL 模式来更改我们的输出,如下所示:

路由

注意

您可以在github.com/drone/routes了解有关路由及其安装方法的更多信息。

运行以下命令安装路由:

go get github.com/drone/routes

通过 HTTP 设置数据

现在我们已经研究了如何处理路由,让我们尝试直接从 REST 端点向数据库中注入数据。

在这种情况下,我们将专门查看POST请求方法,因为在大多数情况下,当可能传输大量数据时,您希望避免GET请求所施加的长度限制。

提示

从技术上讲,PUT请求是在创建-读取-更新-删除(CRUD)概念中用于创建数据的语义上正确的方法,但多年来,PUT在很大程度上被边缘化为历史脚注。最近,一些支持将PUT(和DELETE)恢复到其适当位置的做法已经开始流行。Go(和 Gorilla)将乐意允许您将请求委托给任何一个,并且在我们继续前进时,我们将朝着更符合协议的语义发展。

连接到 MySQL

Go 具有一个内置的通用数据库连接设施,大多数第三方数据库连接包都会让步于它。Go 的默认 SQL 包是database/sql,它允许更一般的数据库连接,并具有一些标准化。

然而,我们暂时不会自己编写 MySQL 连接,而是使用第三方附加库。有几个可用的库,但我们将选择Go-MySQL-Driver

注意

您可以使用以下命令安装Go-MySQL-Driver(需要 Git):

go get github.com/go-sql-driver/mysql

在本例中,我们将假设您的 MySQL 在标准端口3306上以 localhost 运行。如果它没有运行,请相应地进行必要的调整。这里的示例也将使用无密码的 root 帐户,以便清晰起见。

我们的导入基本上保持不变,但有两个明显的添加:sql包(database/sql)和前面提到的仅用于副作用的 MySQL 驱动,通过在其前面加下划线导入:

package main

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

我们将使用 Gorilla 设置一个新的端点。您可能还记得,当我们打算设置或创建数据时,我们通常会推动PUTPOST动词,但出于演示目的,通过附加 URL 参数是推送数据的最简单方式。以下是我们设置这个新路由的方法:

  routes := mux.NewRouter()
  routes.HandleFunc("/api/user/create", CreateUser).Methods("GET")

注意

请注意,我们正在指定我们将接受此请求的动词。在实际使用中,这是推荐的GET请求。

我们的CreateUser函数将接受几个参数——useremailfirstlastUser代表一个简短的用户名,其余的应该是不言自明的。我们将在代码之前定义一个User结构体,如下所示:

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

现在让我们来看一下CreateUser函数本身:

func CreateUser(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)
}

当我们运行这个时,我们的路由 API 端点应该在localhost:8080/api/user/create可用。尽管如果你看一下调用本身,你会注意到我们需要传递 URL 参数来创建一个用户。我们还没有对我们的输入进行任何合理性检查,也没有确保它是干净的/转义的,但我们将按照以下方式访问 URL:http://localhost:8080/api/user/create?user=nkozyra&first=Nathan&last=Kozyra&email=nathan@nathankozyra.com

然后,我们将在我们的users表中创建一个用户,如下所示:

连接到 MySQL

从数据存储中向客户端提供数据

显然,如果我们开始通过 API 端点设置数据,尽管很简单,我们也希望通过另一个 API 端点检索数据。我们可以轻松地修改我们当前的调用,使用以下代码包括一个提供数据返回的新路由:

func GetUser(w http.ResponseWriter, r *http.Request) {

  urlParams   := mux.Vars(r)
  id       := urlParams["id"]
  ReadUser := User{}
  err := database.QueryRow("select * from users where user_id=?",id).Scan(&ReadUser.ID, &ReadUser.Name, &ReadUser.First, &ReadUser.Last, &ReadUser.Email )
  switch {
      case err == sql.ErrNoRows:
              fmt.Fprintf(w,"No such user")
      case err != nil:
              log.Fatal(err)
  fmt.Fprintf(w, "Error")
      default:
        output, _ := json.Marshal(ReadUser)
        fmt.Fprintf(w,string(output))
  }
}

我们在这里做了一些新的和值得注意的事情。首先,我们使用了QueryRow()方法而不是Exec()。Go 的默认数据库接口提供了一些稍有不同的查询机制。具体如下:

  • Exec(): 该方法用于查询(主要是INSERTUPDATEDELETE),不会返回行。

  • Query(): 该方法用于返回一个或多个行的查询。这通常用于SELECT查询。

  • QueryRow(): 该方法类似于Query(),但它只期望一个结果。这通常是一个基于行的请求,类似于我们在之前的例子中所做的。然后我们可以在该行上运行Scan()方法,将返回的值注入到我们结构体的属性中。

由于我们正在将返回的数据扫描到我们的结构体中,我们不会得到返回值。通过err值,我们运行一个开关来确定如何向用户或使用我们的 API 的应用程序传达响应。

如果我们没有行,很可能是请求中存在错误,我们会让接收方知道存在错误。

但是,如果有 SQL 错误,我们现在会保持安静。将内部错误暴露给公众是一种不好的做法。但是,我们应该回应出现了问题,而不要太具体。

最后,如果请求有效并且我们得到一条记录,我们将将其编组为 JSON 响应,并在返回之前将其转换为字符串。我们的下一个结果看起来像我们对有效请求的期望:

从数据存储中向客户端提供数据

然后,如果我们从我们的用户表中请求一个实际上不存在的特定记录,它将适当地返回错误(如下面的截图所示):

从数据存储中向客户端提供数据

设置标题以为客户端添加细节

随着我们继续前进,更多地使用 HTTP 头部来传达关于我们通过 API 发送或接受的数据的重要信息的想法将会更加突出。

我们可以通过对其运行curl请求来快速查看通过我们的 API 发送的标头。当我们这样做时,我们会看到类似于这样的东西:

curl --head http://localhost:8080/api/user/read/1111
HTTP/1.1 200 OK
Date: Wed, 18 Jun 2014 14:09:30 GMT
Content-Length: 12
Content-Type: text/plain; charset=utf-8

这是 Go 默认发送的一个相当小的头部集合。随着我们的前进,我们可能希望附加更多的信息头,告诉接收服务如何处理或缓存数据。

让我们非常简要地尝试设置一些头部,并将它们应用到我们的请求中,使用http包。我们将从更基本的响应头开始,并设置一个 Pragma。这是一个no-cache Pragma,告诉使用我们的 API 的用户或服务始终从我们的数据库请求最新版本。

最终,鉴于我们正在处理的数据,在这种情况下这是不必要的,但这是演示这种行为的最简单的方法。我们可能会发现,随着前进,端点缓存有助于性能,但它可能不会为我们提供最新的数据。

http包本身有一个非常简单的方法,既可以设置响应头,也可以获取请求头。让我们修改我们的GetUser函数,告诉其他服务他们不应该缓存这些数据:

func GetUser(w http.ResponseWriter, r *http.Request) {

  w.Header().Set("Pragma","no-cache")

Header()方法返回iowriterHeader结构,我们可以直接使用Set()添加,或者使用Get()获取值。

既然我们已经做到了,让我们看看我们的输出如何改变:

curl --head http://localhost:8080/api/user/read/1111
HTTP/1.1 200 OK
Pragma: no-cache
Date: Wed, 18 Jun 2014 14:15:35 GMT
Content-Length: 12
Content-Type: text/plain; charset=utf-8

正如我们所期望的,我们现在直接在 CURL 的头信息中看到我们的值,并且它正确地返回这个结果不应该被缓存。

当然,我们可以发送更有价值的响应头,与 web 服务和 API 一起发送,但这是一个很好的开始。随着我们的前进,我们将利用更多的这些,包括Content-EncodingAccess-Control-Allow-Origin和更多的头部,允许我们指定我们的数据是什么,谁可以访问它,以及他们应该期望的格式和编码。

总结

我们已经涉及了在 Go 中开发简单 web 服务接口的基础知识。诚然,这个特定版本非常有限且容易受攻击,但它展示了我们可以采用的基本机制,以产生可用的、正式的输出,可以被其他服务接收。

在这一点上,你应该已经掌握了开始完善这个过程和我们整个应用所需的基本工具。随着我们的推进,我们将应用更完整的设计到我们的 API 中,因为随机选择的两个 API 端点显然对我们没有太大帮助。

在下一章中,我们将深入研究 API 规划和设计,RESTful 服务的细节,以及如何将逻辑与输出分离。我们将简要涉及一些逻辑/视图分离的概念,并在第三章中向更健壮的端点和方法迈进,路由和引导

第二章:Go 中的 RESTful 服务

当人们通常设计 API 和 Web 服务时,他们通常将它们作为事后思考,或者至少作为大型应用程序的最后一步。

这背后有很好的逻辑——应用程序首先出现,当桌子上没有产品时满足开发人员并不太有意义。因此,通常当应用程序或网站创建时,那就是核心产品,任何额外的 API 资源都是其次的。

随着 Web 近年来的变化,这个系统也有了一些变化。现在,写 API 或 Web 服务然后再写应用程序并不是完全不常见。这通常发生在高度响应的单页应用程序或移动应用程序中,其中结构和数据比演示层更重要。

我们的总体项目——一个社交网络——将展示数据和架构优先的应用程序的性质。我们将拥有一个功能齐全的社交网络,可以在 API 端点上进行遍历和操作。然而,在本书的后面,我们将在演示层上玩一些有趣的东西。

尽管这背后的概念可能被视为完全示范性的,但现实是,这种方法是当今许多新兴服务和应用程序的基础。一个新站点或服务通常会使用 API 进行启动,有时甚至只有 API。

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

  • 设计我们的应用程序的 API 策略

  • REST 的基础知识

  • 其他 Web 服务架构和方法

  • 编码数据和选择数据格式

  • REST 动作及其作用

  • 使用 Gorilla 的 mux 创建端点

  • 应用程序版本控制的方法

设计我们的应用程序

当我们着手构建更大的社交网络应用程序时,我们对我们的数据集和关系有一个大致的想法。当我们将这些扩展到 Web 服务时,我们不仅要将数据类型转换为 API 端点,还要转换关系和操作。

例如,如果我们希望找到一个用户,我们会假设数据保存在一个名为users的数据库中,并且我们希望能够使用/api/users端点检索数据。这是合理的。但是,如果我们希望获取特定用户呢?如果我们希望查看两个用户是否连接?如果我们希望编辑一个用户在另一个用户的照片上的评论?等等。

这些是我们应该考虑的事情,不仅在我们的应用程序中,也在我们围绕它构建的 Web 服务中(或者在这种情况下,反过来,因为我们的 Web 服务首先出现)。

到目前为止,我们的应用程序有一个相对简单的数据集,所以让我们以这样的方式来完善它,以便我们可以创建、检索、更新和删除用户,以及创建、检索、更新和删除用户之间的关系。我们可以把这看作是在传统社交网络上“加为好友”或“关注”某人。

首先,让我们对我们的users表进行一些维护。目前,我们只在user_nickname变量上有一个唯一索引,但让我们为user_email创建一个索引。考虑到理论上一个人只能绑定一个特定的电子邮件地址,这是一个相当常见和合乎逻辑的安全点。将以下内容输入到您的 MySQL 控制台中:

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

现在我们每个电子邮件地址只能有一个用户。这是有道理的,对吧?

接下来,让我们继续创建用户关系的基础。这些将不仅包括加为好友/关注的概念,还包括屏蔽的能力。因此,让我们为这些关系创建一个表。再次,将以下代码输入到您的控制台中:

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 端点,让我们的网络服务的消费者能够做到这一点。

在上一章中,我们创建了我们的第一个端点,/api/user/create/api/user/read。然而,如果我们想要完全控制刚才讨论的数据,我们需要更多。

在那之前,让我们谈谈与网络服务相关的最重要的概念,特别是那些利用 REST 的概念。

看看 REST

那么,REST 到底是什么,它从哪里来?首先,REST 代表表述性状态转移。这很重要,因为数据(及其元数据)的表述是数据传输的关键部分。

缩写中的状态方面有点误导,因为无状态实际上是架构的核心组件。

简而言之,REST 提供了一种简单的、无状态的机制,用于通过 HTTP(以及其他一些协议)呈现数据,这种机制是统一的,并包括缓存指令等控制机制。

这种架构最初是作为罗伊·菲尔丁在加州大学尔湾分校的论文的一部分而产生的。从那时起,它已经被万维网联盟W3C)进行了编码和标准化。

一个 RESTful 应用程序或 API 将需要几个重要的组件,我们现在将概述这些组件。

在 API 中进行表述

API 最重要的组成部分是我们将作为网络服务一部分传递的数据。通常,它是 JSON、RSS/XML 格式的格式化文本,甚至是二进制数据。

为了设计一个网络服务,确保您的格式与您的数据匹配是一个好习惯。例如,如果您创建了一个用于传递图像数据的网络服务,很容易将这种数据塞进文本格式中。将二进制数据转换为 Base64 编码并通过 JSON 发送并不罕见。

然而,API 的一个重要考虑因素是数据大小的节俭。如果我们以前的例子并将我们的图像数据编码为 Base64,我们最终得到的 API 有效负载将增加近 40%。通过这样做,我们将增加服务的延迟并引入潜在的烦恼。如果我们可以可靠地传输数据,那就没有理由这样做。

模型中的表述也应该起到重要的作用——满足客户端更新、删除或检索特定资源的所有要求。

自我描述

当我们说自我描述时,我们也可以将其描述为自包含,以包括 REST 的两个核心组件——响应应该包括客户端每个请求所需的一切,并且应该包括(明确或隐含地)有关如何处理信息的信息。

第二部分涉及缓存规则,我们在第一章中简要提到了我们在 Go 中的第一个 API

提供有关 API 请求中包含的资源的有价值的缓存信息是重要的。这可以消除以后的冗余或不必要的请求。

这也引入了 REST 的无状态性概念。我们的意思是每个请求都是独立存在的。正如前面提到的,任何单个请求都应该包括满足该请求所需的一切。

最重要的是,这意味着放弃普通的 Web 架构的想法,其中您可以设置 cookie 或会话变量。这本质上不是 RESTful。首先,我们的客户端不太可能支持 cookie 或持续会话。但更重要的是,它减少了对任何给定 API 端点所期望的响应的全面和明确的性质。

提示

自动化流程和脚本当然可以处理会话,并且它们可以像 REST 的初始提案一样处理它们。这更多是一种演示而不是 REST 拒绝将持久状态作为其精神的一部分的原因。

URI 的重要性

出于我们稍后将在本章讨论的原因,URI 或 URL 是良好 API 设计中最关键的因素之一。有几个原因:

  • URI 应该是有信息的。我们不仅应该了解数据端点的信息,还应该知道我们可能期望看到的返回数据。其中一些是程序员的习惯用法。例如,/api/users会暗示我们正在寻找一组用户,而/api/users/12345则表示我们期望获取有关特定用户的信息。

  • URI 不应该在将来中断。很快,我们将讨论版本控制,但这只是一个地方,稳定的资源端点的期望非常重要。如果您的服务的消费者在时间上发现其应用程序中缺少或损坏的链接而没有警告,这将导致非常糟糕的用户体验。

  • 无论您在开发 API 或 Web 服务时有多少远见,事情都会发生变化。考虑到这一点,我们应该通过利用 HTTP 状态代码来对现有 URI 指示新位置或错误,而不是允许它们简单地中断。

HATEOAS

HATEOAS代表超媒体作为应用程序状态的引擎,是 REST 架构中 URI 的主要约束。其背后的核心原则要求 API 不应引用固定的资源名称或实际的层次结构本身,而应该专注于描述所请求的媒体和/或定义应用程序状态。

注意

您可以通过访问 Roy Fielding 的博客roy.gbiv.com/untangled/,阅读有关 REST 及其原始作者定义的要求的更多信息。

其他 API 架构

除了 REST,我们还将在本书中查看并实施一些其他常见的 API 和 Web 服务架构。

在大多数情况下,我们将专注于 REST API,但我们还将涉及 SOAP 协议和用于 XML 摄入的 API,以及允许持久性的较新的异步和基于 Web 套接字的服务。

远程过程调用

远程过程调用,或RPC,是一种长期存在的通信方法,构成了后来成为 REST 的基础。虽然仍然有一些使用 RPC 的价值,特别是 JSON-RPC,但我们不会在本书中花太多精力来适应它。

如果您对 RPC 不熟悉,与 REST 相比,其核心区别在于只有一个端点,请求本身定义了 Web 服务的行为。

注意

要了解有关 JSON-RPC 的更多信息,请访问json-rpc.org/

选择格式

使用的格式问题曾经是一个比今天更棘手的问题。我们曾经有许多特定于个人语言和开发人员的格式,但 API 世界已经导致这些格式的广度收缩了一些。

Node 和 JavaScript 作为数据传输格式的通用语言的崛起使大多数 API 首先考虑 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 = "bill.smith@example.com"
  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)

}

这里需要注意的是User结构中变量的 JSON 表示。每当您在重音符号(`)字符时,这都代表一个符文。虽然字符串用双引号表示,字符用单引号表示,但重音符号表示应该保持不变的Unicode数据。从技术上讲,该内容保存在int32值中。

在一个结构体中,变量/类型声明中的第三个参数被称为标签。这些对于编码是值得注意的,因为它们可以直接翻译为 JSON 变量或 XML 标签。

如果没有标签,我们将直接返回我们的变量名。

XML

正如前面提到的,XML 曾经是开发者的首选格式。尽管它已经退居幕后,但几乎所有的 API 今天仍然将 XML 作为一个选项呈现出来。当然,RSS 仍然是第一种选择的格式。

正如我们之前在 SOAP 示例中看到的,将数据编组成 XML 是简单的。让我们采用我们在先前 JSON 响应中使用的数据结构,并类似地将其编组成 XML 数据,如下例所示。

我们的 User 结构如下所示:


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

我们得到的输出如下:


ourUser:= User{}
ourUser.Name = "Bill Smith"
ourUser.Email = "bill.smith@example.com"
ourUser.ID = 100
output,_:= xml.Marshal(&ourUser)
fmt.Fprintln(w, string(output))

YAML

YAML 是早期尝试制定的一种类似于 JSON 的人类可读的序列化格式。在名为 goyaml 的第三方插件中存在一个友好的 Go 实现。

您可以在 godoc.org/launchpad.net/goyaml 上阅读更多关于 goyaml 的信息。要安装 goyaml,我们将调用 go get launchpad.net/goyaml 命令。

就像在 Go 中内置的默认 XML 和 JSON 方法一样,我们也可以在 YAML 数据上调用 MarshalUnmarshal。使用我们先前的示例,我们可以相当容易地生成一个 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 = "Bill Smith"
  ourUser.Email = "bill.smith@example.com"
  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)
}

所获得的输出如下所示:

YAML

CSV

逗号分隔值CSV)格式是另一种已经不太流行的老牌格式,但它仍然在一些 API 中存在,尤其是旧的 API。

通常,在当今时代我们不建议使用 CSV 格式,但它对业务应用程序可能特别有用。更重要的是,它是内置到 Go 中的另一种编码格式。

强制将数据转换为 CSV 与在 Go 中将其编组成 JSON 或 XML 没有根本上的区别,因为 encoding/csv 包使用与这些子包相同的方法。

比较 HTTP 动作和方法

REST 的核心思想之一是数据访问和操作应受动词/方法的限制。

例如,GET 请求不应允许用户修改、更新或创建其中的数据。这是有道理的。DELETE 也是相当直接的。那么,创建和更新呢?然而,在 HTTP 的命名中并不存在这样的直接翻译的动词。

对于处理这个问题存在一些争论,但通常接受的处理方法是使用 PUT 来更新资源,使用 POST 来创建资源。

注意

这是根据 W3C 协议的 HTTP 1.1 的相关信息:

POSTPUT 请求之间的基本区别反映在请求 URI 的不同含义上。POST 请求中的 URI 标识将处理封闭实体的资源。该资源可能是一个接受数据的进程、某种其他协议的网关,或者是一个接受注释的独立实体。相比之下,PUT 请求中的 URI 标识了请求中封闭的实体——用户代理知道预期使用的 URI,服务器不得尝试将请求应用于其他资源。如果服务器希望请求应用于不同的 URI,它必须发送 301(永久移动)响应;然后用户代理可以自行决定是否重定向请求。

因此,如果我们遵循这个规则,我们可以假设以下操作将转换为以下 HTTP 动词:

操作 HTTP 动词
检索数据 GET
创建数据 POST
更新数据 PUT
删除数据 DELETE

因此,对 /api/users/1234PUT 请求将告诉我们的 Web 服务,我们正在接受将更新或覆盖 ID 为 1234 的用户资源数据的数据。

/api/users/1234POST 请求将告诉我们,我们将根据其中的数据创建一个新的用户资源。

注意

把更新和创建方法颠倒是非常常见的,比如用POST来更新,用PUT来创建。一方面,无论哪种方式都不会太复杂。另一方面,W3C 协议相当明确。

PATCH 方法与 PUT 方法

那么,在经过上一节的学习后,你可能会认为一切都结束了,对吧?一清二楚?然而,一如既往,总会有一些问题、意想不到的行为和相互冲突的规则。

在 2010 年,有一个关于 HTTP 的提议修改,其中包括了一个 PATCH 方法。PATCHPUT 之间的区别有些微妙,但最简单的解释是,PATCH 旨在提供对资源的部分更改,而 PUT 则预期提供对资源的完整表示。

PATCH 方法还提供了潜力,可以将一个资源“复制”到另一个资源中,并提供修改后的数据。

现在,我们只关注PUT,但稍后我们将详细讨论 PATCH,特别是当我们深入研究 API 服务器端的 OPTIONS 方法时。

引入 CRUD

缩写CRUD 简单地表示创建、读取(或检索)、更新和删除。这些动词可能值得注意,因为它们与我们希望在应用程序中使用的 HTTP 动词非常相似。

正如我们在上一节讨论的那样,大多数这些动词似乎都直接对应着 HTTP 方法。我们说“似乎”,因为在 REST 中有一些点使其不能完全类似。我们稍后会在后面的章节中更详细地讨论这一点。

CREATE显然承担了POST方法的角色,RETRIEVE取代了GETUPDATE取代了PUT/PATCH,而DELETE则取代了,额,DELETE

如果我们想要对这些翻译非常认真,我们必须澄清PUTPOST不是UPDATECREATE的直接类比。从某种意义上说,这与PUTPOST应该提供哪些操作的混淆有关。这一切都取决于幂等性的关键概念,这意味着任何给定操作应在被调用无数次时以同样的方式作出响应。

提示

幂等性是数学和计算机科学中某些操作的性质,可以多次应用而不会改变结果超出初始应用。

现在,我们将坚持我们之前的翻译,稍后再回到PUTPOST的细节。

添加更多的端点

现在我们已经找到了一个优雅处理 API 版本的方式,让我们退一步重新审视用户创建。在本章的早些时候,我们创建了一些新数据集,并准备创建相应的端点。

现在你了解了 HTTP 动词的知识后,我们应该通过POST方法限制用户创建的访问。我们在第一章构建的示例并不完全只与POST请求一起使用。良好的 API 设计应规定我们有一个单一的 URI 用于创建、检索、更新和删除任何给定资源。

考虑到这一切,让我们列出我们的端点及它们应该允许用户实现的功能:

端点 方法 目的
/api OPTIONS 用来概括 API 中的可用操作
/api/users GET 返回带有可选过滤参数的用户
/api/users POST 创建用户
/api/user/123 PUT 用来更新 ID 为123的用户
/api/user/123 DELETE 删除 ID 为123的用户

现在,让我们对第一章中的初始 API 进行快速修改,只允许使用POST方法进行用户创建。

记住,我们使用了Gorilla web toolkit来进行路由。这对于处理请求中的模式和正则表达式非常有帮助,但现在它也很有帮助,因为它允许基于 HTTP 动词/方法进行区分。

在我们的例子中,我们创建了/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")

你会注意到我们还将我们的函数名称更改为UserCreateUsersRetrieve。随着我们扩展 API,我们需要易于理解并能直接与我们的资源相关联的方法。

让我们看一下我们的应用程序如何变化:


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

我们正在创建一个用于表示我们的通用GET请求/api/users的用户组的结构。这提供了一个User{}结构的切片:


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))
}

UsersRetrieve()函数中,我们现在正在获取一组用户并将它们扫描到我们的Users{}结构中。此时,还没有一个标题给出进一步的细节,也没有任何接受起始点或结果计数的方法。我们将在下一章中做这个。

最后,我们在主函数中有我们的基本路由和 MySQL 连接:


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)
}

正如前面提到的,main中最大的区别在于我们重新命名了我们的函数,并且现在正在使用HTTP方法将某些操作归类。因此,即使端点是相同的,我们也能够根据我们的请求是使用POST还是GET动词来指导服务。

当我们访问http://localhost:8080/api/users(默认情况下,是GET请求)现在在我们的浏览器中,我们将得到一个我们的用户列表(尽管从技术上讲我们仍然只有一个),如下面的截图所示:

添加更多端点

处理 API 版本

在我们继续进行 API 之前,值得注意的是对 API 进行版本控制。

当公司更新 API 并更改版本时,他们面临的一个常见问题是在不破坏先前版本的情况下更改版本。这不仅仅是关于有效的 URL,而且还涉及到 REST 和优雅升级的最佳实践。

以我们当前的 API 为例。我们有一个介绍性的GET动词来访问数据,例如/api/users端点。然而,这实际上应该是版本化 API 的克隆。换句话说,/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。因此,我们可能不希望将这个值包含在响应体中。

其次,我们应该能够执行更通用的请求,并获取有关其他操作和可用终端的信息。

换句话说,如果我们仅在http://localhost:8080/api请求我们的 API,我们的应用程序应向消费者返回有关可能的下一步和所有可用终端的一些基本信息。

实现这一点的方法之一是使用链接标头。链接标头只是你与响应一起设置的另一个标头键/值。

提示

因为 JSON 响应通常不被认为是 RESTful,因为它们不是超媒体格式。你会发现一些 API 直接在不可靠的格式中嵌入selfrelnext链接头。

JSON 的主要缺点是其无法原生支持超链接。这个问题由 JSON-LD 解决,其中包括联接文档和无状态上下文。

超文本应用语言HAL)试图做同样的事情。前者得到了 W3C 的支持,但两者都有支持者。这两种格式扩展了 JSON,虽然我们不会深入探讨任何一种,但你可以修改响应以产生任一格式。

下面是我们如何在/api/usersGET请求中实现它的方法:


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))
}

这告诉客户端去哪里进行进一步的分页。当我们进一步修改这段代码时,我们将包括向前和向后的分页,并响应用户参数。

总结

此时,您不仅应该熟悉在 REST 和其他一些协议中创建 API Web 服务的基本思想,还应该熟悉格式和协议的指导原则。

我们在本章中尝试了一些东西,我们将在接下来的几章中更深入地探讨,特别是在 Go 语言本身的各种模板实现中的 MVC。

在下一章中,我们将构建我们初始端点的其余部分,并探索更高级的路由和 URL muxing。

第三章:路由和引导

在过去的两章中,您应该已经熟悉了创建 API 端点、后端数据库来存储最重要信息以及通过 HTTP 请求路由和输出数据所需的机制。

对于最后一点,除了我们最基本的示例之外,我们已经使用了一个库来处理我们的 URL 多路复用器。这就是 Gorilla Web Toolkit。尽管这个库(及其相关框架)非常棒,但了解如何直接在 Go 中处理请求是值得的,特别是为了创建涉及条件和正则表达式的更健壮的 API 端点。

虽然我们简要提到了头信息对于 Web 服务消费者的重要性,包括状态代码,但随着我们继续扩展我们的应用程序,我们将开始深入研究一些重要的内容。

控制和指示状态的重要性对于 Web 服务至关重要,特别是(具有悖论性的)在无状态系统中,如 REST。我们说这是一个悖论,因为虽然服务器应该提供有关应用程序状态和每个请求的少量信息,但重要的是允许客户端根据我们所提供的绝对最小和标准机制来理解这一点。

例如,虽然我们可能在列表或 GET 请求中不提供页码,但我们希望确保消费者知道如何导航以获取更多或以前的结果集。

同样,我们可能不提供硬错误消息,尽管它存在,但我们的 Web 服务应该受到一些标准化的约束,因为它涉及我们可以在标头中提供的反馈。

在本章中,我们将涵盖以下主题:

  • 扩展 Go 的多路复用器以处理更复杂的请求

  • 查看 Gorilla 中更高级的请求

  • 在 Gorilla 中引入 RPC 和 Web 套接字

  • 处理应用程序和请求中的错误

  • 处理二进制数据

我们还将为我们的 Web 应用程序创建一些消费者友好的接口,这将允许我们与我们的社交网络 API 进行交互,以满足需要PUT/POST/DELETE的请求,以及稍后的OPTIONS

通过本章结束时,您应该已经熟悉了在 Go 中编写路由器以及扩展它们以允许更复杂的请求。

在 Go 中编写自定义路由器

如前所述,直到这一点,我们一直专注于使用 Gorilla Web Toolkit 来处理 URL 路由和多路复用器,主要是因为 Go 本身内部的mux包的简单性。

通过简单性,我们指的是模式匹配是明确的,不允许使用http.ServeMux结构进行通配符或正则表达式。

通过直接查看http.ServeMux代码的设置,您可以看到这可以使用更多的细微差别:

// Find a handler on a handler map given a path string
// Most-specific (longest) pattern wins
func (mux *ServeMux) match(path string) (h Handler, pattern string) {
  var n = 0
    for k, v := range mux.m {
      if !pathMatch(k, path) {
        continue
      }
      if h == nil || len(k) > n {
        n = len(k)
        h = v.h
        pattern = v.pattern
      }
    }
    return
}

这里的关键部分是!pathMatch函数,它调用另一个方法,专门检查路径是否与muxEntry映射的成员完全匹配:

func pathMatch(pattern, path string) bool {
  if len(pattern) == 0 {
   // should not happen
    return false
  }

  n := len(pattern)
  if pattern[n-1] != '/' {
   return pattern == path
  }
  return len(path) >= n && path[0:n] == pattern
}

当然,访问此代码的最好之处之一是,几乎可以毫不费力地扩展它。

有两种方法可以做到这一点。第一种是编写自己的包,几乎可以像扩展包一样使用。第二种是直接修改您的src目录中的代码。这种选择的缺点是在升级时可能会被替换并且随后被破坏。因此,这是一个基本上会破坏 Go 语言的选项。

考虑到这一点,我们将选择第一种选项。那么,我们如何扩展http包呢?简短的答案是,您实际上不能在不直接进入代码的情况下进行扩展,因此我们需要创建自己的代码,继承与我们将要处理的各种http结构相关的最重要的方法。

要开始这个过程,我们需要创建一个新的包。这应该放在你的 Golang src目录下的特定域文件夹中。在这种情况下,我们指的是传统意义上的域,但按照惯例也是指 web 目录的意义。

如果你曾经执行过go get命令来获取第三方包,你应该熟悉这些约定。你应该在src文件夹中看到类似以下截图的内容:

在 Go 中编写自定义路由器

在我们的情况下,我们只需创建一个特定于域的文件夹,用于保存我们的包。或者,你可以在你选择的代码存储库中创建项目,比如 GitHub,并直接从那里导入包,通过go get

不过,现在我们只需在该目录下创建一个子文件夹,我的情况下是nathankozyra.com,然后一个名为httpexhttpregex的混成词)的文件夹,用于http扩展。

根据你的安装和操作系统,你的导入目录可能不会立即显而易见。要快速查看你的导入包应该在哪里,运行go env内部工具。你会在GOPATH变量下找到目录。

提示

如果你发现你的go get命令返回GOPATH not set错误,你需要导出GOPATH变量。要这样做,只需输入export GOPATH=/your/directory(对于 Linux 或 OS X)。在 Windows 上,你需要设置一个环境变量。

最后一个警告是,如果你使用的是 OS X,并且在通过go get获取包时遇到困难,你可能需要在sudo调用之后包含-E标志,以确保你使用的是本地用户的变量,而不是 root 的变量。

为了节省空间,我们不会在这里包含所有必要的代码,以便改装允许正则表达式的http包。为此,重要的是将所有的ServeMux结构、方法和变量复制到你的httpex.go文件中。在大多数情况下,我们会复制所有内容。你需要一些重要的导入包;你的文件应该是这样的:

  package httpex

import
(
  "net/http"
  "sync"
  "sync/atomic"
  "net/url"
  "path"
  "regexp"
)

type ServeMux struct {
  mu    sync.RWMutex
  m     map[string]muxEntry
  hosts bool // whether any patterns contain hostnames
}

关键的变化发生在pathMatch()函数中,以前需要最长可能字符串的字面匹配。现在,我们将任何==相等比较改为正则表达式:

// Does path match pattern?
func pathMatch(pattern, path string) bool {
  if len(pattern) == 0 {
    // should not happen
    return false
  }
  n := len(pattern)
  if pattern[n-1] != '/' {
 match,_ := regexp.MatchString(pattern,path)
 return match
  }
 fullMatch,_ := regexp.MatchString(pattern,string(path[0:n]))
  return len(path) >= n && fullMatch
}

如果所有这些看起来都像是重复造轮子,重要的是——就像 Go 中的许多东西一样——核心包在大多数情况下提供了一个很好的起点,但当你发现某些功能缺失时,你不应该犹豫去增强它们。

还有另一种快速而简单的方法来创建自己的ServeMux路由器,那就是拦截所有请求并对它们进行正则表达式测试。就像上一个例子一样,这并不理想(除非你希望引入一些未解决的效率问题),但在紧急情况下可以使用。以下代码演示了一个非常基本的例子:

package main

import
(
  "fmt"
  "net/http"
  "regexp"
)

同样,我们包含了regexp包,以便我们可以进行正则表达式测试:

func main() {

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

      path := r.URL.Path
      message := "You have triggered nothing"

      testMatch,_ := regexp.MatchString("/testing[0-9]{3}",path); 

      if (testMatch == true) {
        // helper functions
        message = "You hit the test!"
      }

      fmt.Fprintln(w,message)
    })

在这里,我们不是为每个匹配项提供特定的处理程序,而是在单个处理程序中测试testing[3 digits]的匹配项,然后根据情况做出反应。

在这种情况下,我们告诉客户端,除非他们匹配模式,否则什么都没有。这个模式显然适用于/testing123请求,并且对于任何不匹配这个模式的东西都会失败:

    http.ListenAndServe(":8080", nil)
}

最后,我们启动我们的 web 服务器。

在 Gorilla 中使用更高级的路由器

现在我们已经玩弄了一下扩展内置包的多路复用,让我们看看 Gorilla 还提供了什么。

除了简单的表达式,我们还可以获取 URL 参数并将其应用到稍后使用的变量中。我们在之前的例子中做到了这一点,但没有提供很多关于我们正在生成的内容的解释。

这是一个示例,我们如何将一个表达式转化为一个变量,用于httpHandler函数中:

/api/users/3
/api/users/nkozyra

这两种方法都可以作为GET请求来处理users表中的特定实体。我们可以用以下代码来处理任何一种情况:

mux := mux.NewRouter()
mux.HandleFunc("/api/users/[\w+\d+]", UserRetrieve)

然而,我们需要保留最后一个值以供我们的查询使用。为此,Gorilla 允许我们将该表达式设置为映射中的一个键。在这种情况下,我们可以用以下代码来解决这个问题:

mux.HandleFunc("/api/users/{key}", UserRetrieve)

这将允许我们通过以下代码从我们的处理程序中提取该值:

variables := mux.Vars(r)
key := variables["key"]

你会注意到我们在这里使用了"key"而不是一个表达式。你可以在这里都做,这样你就可以将一个正则表达式设置为一个键。例如,如果我们的用户键变量由字母、数字和破折号组成,我们可以这样设置:

r.HandleFunc("/api/users/{key:[A-Za-z0-9\-]}",UserRetrieve

而且,在我们的UserRetrieve函数中,我们可以直接提取该键(或者我们添加到mux包中的任何其他键):

func UserRetrieve(w http.ResponseWriter, r *http.Request) {
  urlParams := mux.Vars(r)
  key := vars["key"]
}

使用 Gorilla 进行 JSON-RPC

你可能还记得第二章中我们简要介绍了 RPC,并承诺会回到它。

以 REST 作为我们的主要 Web 服务交付方法,我们将继续限制我们对 RPC 和 JSON-RPC 的了解。然而,现在是一个很好的时机来演示我们如何可以使用 Gorilla 工具包非常快速地创建 RPC 服务。

对于这个例子,我们将接受一个字符串,并通过 RPC 消息返回字符串中的总字符数:

package main

import (
  "github.com/gorilla/rpc"
  "github.com/gorilla/rpc/json"
  "net/http"
  "fmt"
  "strconv"
  "unicode/utf8"
)

type RPCAPIArguments struct {
  Message string
}

type RPCAPIResponse struct {
  Message string
}

type StringService struct{}

func (h *StringService) Length(r *http.Request, arguments *RPCAPIArguments, reply *RPCAPIResponse) error {
  reply.Message = "Your string is " + fmt.Sprintf("Your string is %d chars long", utf8.RuneCountInString(arguments.Message)) + " characters long"
  return nil
}

func main() {
  fmt.Println("Starting service")
  s := rpc.NewServer()
  s.RegisterCodec(json.NewCodec(), "application/json")
  s.RegisterService(new(StringService), "")
  http.Handle("/rpc", s)
  http.ListenAndServe(":10000", nil)
}

关于 RPC 方法的一个重要说明是,它需要被导出,这意味着一个函数/方法必须以大写字母开头。这是 Go 对一个概念的处理方式,它在某种程度上类似于public/private。如果 RPC 方法以大写字母开头,它就会被导出到该包的范围之外,否则它基本上是private

使用 Gorilla 进行 JSON-RPC

在这种情况下,如果你调用方法stringService而不是StringService,你会得到响应找不到服务 stringService

使用服务进行 API 访问

当涉及构建和测试我们的 Web 服务时,我们将迅速遇到的一个问题是直接处理POST/PUT/DELETE请求,以确保我们的特定于方法的请求能够按我们的预期进行。

有几种方法可以轻松处理这个问题,而不必移动到另一台机器或构建复杂的东西。

第一种方法是我们的老朋友 cURL。迄今为止,cURL 是最受欢迎的一种通过各种协议进行网络请求的方法,它简单易用,并且几乎支持你能想到的任何语言。

注意

Go 中没有单独的内置 cURL 组件。然而,这在很大程度上遵循了 Go 开发人员似乎最感兴趣的精简、集成的语言设计理念。

然而,你可以看一下一些第三方解决方案:

然而,为了测试,我们可以简单直接地从命令行使用 cURL。这很简单,所以构造请求既不难也不费力。

以下是我们可以使用POST http方法向/api/users的创建方法发出的示例调用:

curl http://localhost:8080/api/users --data "name=nkozyra&email=nkozyra@gmail.com&first=nathan&last=nathan"

请记住,我们已经在我们的数据库中有了这个用户,并且它是一个唯一的数据库字段,我们只需修改我们的UserCreate函数就可以返回一个错误。请注意,在下面的代码中,我们将我们的响应更改为一个新的CreateResponse结构,目前只包括一个错误字符串:

  type CreateResponse struct {
    Error string "json:error"
  }

现在,我们来调用它。如果我们从数据库得到一个错误,我们将把它包含在我们的响应中,至少目前是这样;不久之后,我们将研究翻译。否则,它将是空的,我们可以(目前)假设用户已经成功创建。我们说目前,因为根据我们的请求成功或失败,我们需要向我们的客户提供更多的信息:

  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!")
    }

    Response := CreateResponse{}
    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 {
      Response.Error = err.Error()
    }
    fmt.Println(q)
    createOutput,_ := json.Marshal(Response)
    fmt.Fprintln(w,string(createOutput))
  }

如果我们尝试通过 cURL 请求创建重复的用户,它看起来是这样的:

> curl http://localhost:8080/api/users –data "name=nkozyra&email=nkozyra@gmail.com&first=nathan&last=nathan"
{"Error": "Error 1062: Duplicate entry '' for key 'user nickname'"}

使用简单的接口访问 API

我们还可以通过一个简单的带有表单的网页迅速实现命中我们的 API 的接口。当然,这是许多 API 被访问的方式——直接由客户端访问而不是由服务器端处理。

尽管我们并不建议这是我们的社交网络应用程序在实践中应该工作的方式,但它为我们提供了一种简单的可视化应用程序的方式:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>API Interface</title>
    <script src="img/jquery.min.js"></script>
    <link href="http://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css" rel="stylesheet">
    <script src="img/bootstrap.min.js"></xscript>
    <link rel="stylesheet" href="style.css">
    <script src="img/script.js"></script>
  </head>
  <body>

  <div class="container">
      <div class="row">
  <div class="col-12-lg">
        <h1>API Interface</h1>
    <div class="alert alert-warning" id="api-messages" role="alert"></div>

    <ul class="nav nav-tabs" role="tablist">
      <li class="active"><a href="#create" role="tab" data-toggle="tab">Create User</a></li>
    </ul>

    <div class="tab-content">
      <div class="tab-pane active" id="create">

      <div class="form-group">
      <label for="createEmail">Email</label>
      <input type="text" class="form-control" id="createEmail" placeholder="Enter email">
      </div>
      <div class="form-group">
      <label for="createUsername">Username</label>
      <input type="text" class="form-control" id="createUsername" placeholder="Enter username">
      </div>
      <div class="form-group">
            <label for="createFirst">First Name</label>
      <input type="text" class="form-control" id="createFirst" placeholder="First Name">
      </div>
      <div class="form-group">
      <label for="createLast">Last Name</label>
      <input type="text" class="form-control" id="createLast" placeholder="Last Name">
      </div>

      <button type="submit" onclick="userCreate();" class="btn btn-success">Create</button>

      </div>

    </div>
  </div>
  </div>

  </div>

  <script>

  function userCreate() {
    action = "http://localhost:8080/api/users";
    postData = {};
    postData.email  = $('#createEmail').val();
    postData.user  = $('#createUsername').val();
    postData.first  = $('#createFirst').val();
    postData.last = $('#createLast').val();

    $.post(action,postData,function(data) {
      if (data.error) {
        $('.alert').html(data.error);
        $('.alert').alert();
      }
    },'jsonp');
  }

  $(document).ready(function() {
    $('.alert').alert('close');

  });
  </script>
  </body>
</html>

当这个被渲染时,我们将有一个快速的基本可视化表单,用于将数据输入到我们的 API 中,以及返回有价值的错误信息和反馈。

提示

由于跨域限制,您可能希望从与我们的 API 服务器相同的端口和域运行此文件,或者在服务器文件本身的每个请求中包含此标头:

w.Header().Set("Access-Control-Allow-Origin","http://localhost:9000")

这里,http://localhost:9000代表请求的来源服务器。

我们渲染的 HTML 演示如下:

使用简单的接口访问 API

返回有价值的错误信息

在上次请求中返回错误时,我们只是代理了 MySQL 错误并将其传递。不过这并不总是有帮助,因为似乎至少需要对 MySQL 有一定的了解才能为客户端提供有价值的信息。

当然,MySQL 本身有一个相当清晰和简单的错误消息系统,但关键是它是特定于 MySQL 而不是我们的应用程序。

如果您的客户端不理解“重复条目”是什么意思怎么办?如果他们不会说英语怎么办?您会翻译消息,还是会告诉所有依赖项每个请求返回什么语言?现在您可以看到为什么这可能会变得繁琐。

大多数 API 都有自己的错误报告系统,即使只是为了控制消息。虽然最理想的是根据请求头的语言返回语言,但如果不能,返回错误代码也是有帮助的,这样你(或其他方)可以在以后提供翻译。

然后还有通过 HTTP 状态代码返回的最关键的错误。默认情况下,我们使用 Go 的http包生成了一些这样的错误,因为对无效资源的任何请求都会提供一个标准的 404 未找到消息。

但是,还有一些特定于 REST 的错误代码,我们很快就会介绍。目前,有一个与我们的错误相关的错误代码:409。

注意

根据 W3C 的 RFC 2616 协议规范,我们可以发送一个表示冲突的 409 代码。以下是规范的说明:

由于资源的当前状态与请求的冲突,请求无法完成。此代码仅允许在预期用户可能能够解决冲突并重新提交请求的情况下使用。响应正文应包含足够的信息,以便用户识别冲突的来源。理想情况下,响应实体将包含足够的信息,以便用户或用户代理程序解决问题;但这可能是不可能的,也不是必需的。

冲突最有可能发生在对PUT请求的响应中。例如,如果正在使用版本控制,并且PUT的实体包含与之前(第三方)请求所做的更改冲突的资源更改,服务器可能使用 409 响应来指示它无法完成请求。在这种情况下,响应实体可能包含两个版本之间差异的列表,格式由响应Content-Type定义。

考虑到这一点,让我们首先检测一个指示现有记录并阻止创建新记录的错误。

不幸的是,Go 并没有返回特定的数据库错误代码,但至少对于 MySQL 来说,如果我们知道使用的模式,提取错误就足够简单了。

使用以下代码,我们将构建一个解析器,将 MySQL 错误字符串分割成两个组件并返回一个整数错误代码:

  func dbErrorParse(err string) (string, int64) {
    Parts := strings.Split(err, ":")
    errorMessage := Parts[1]
    Code := strings.Split(Parts[0],"Error ")
    errorCode,_ := strconv.ParseInt(Code[1],10,32)
    return errorMessage, errorCode
  }

我们还将用错误状态码来增强我们的CreateResponse结构,表示如下:

  type CreateResponse struct {
    Error string "json:error"
    ErrorCode int "json:code"
  }

我们还将把 MySQL 的响应和消息转换成一个CreateResponse结构,通过改变UsersCreate函数中的错误响应行为:

    if err != nil {
      errorMessage, errorCode := dbErrorParse( err.Error() )
      fmt.Println(errorMessage)
      error, httpCode, msg := ErrorMessages(errorCode)
      Response.Error = msg
      Response.ErrorCode = error
      fmt.Println(httpCode)
    }

您会注意到我们之前定义的dbErrorParse函数。我们将从中获取的结果注入到一个ErrorMessages函数中,该函数返回有关任何给定错误的细致信息,而不仅仅是数据库错误:

type ErrMsg struct {
    ErrCode int
    StatusCode int
    Msg string
}
func ErrorMessages(err int64) (ErrMsg) {
    var em ErrMsg{}
    errorMessage := ""
    statusCode := 200;
    errorCode := 0
    switch (err) {
      case 1062:
        errorMessage = "Duplicate entry"
        errorCode = 10
        statusCode = 409
    }

    em.ErrCode = errorCode
    em.StatusCode = statusCode
    em.Msg = errorMsg

    return em

  }

目前,这还比较简单,只处理一种类型的错误。随着我们的进展,我们将扩展这一点,并添加更多的错误处理机制和消息(以及尝试翻译表)。

关于 HTTP 状态码,我们还需要做最后一件事。设置 HTTP 状态码的最简单方法是通过http.Error()函数:

      http.Error(w, "Conflict", httpCode)

如果我们把这放在我们的错误条件块中,我们将返回从ErrorMessages()函数接收到的任何状态码:

    if err != nil {
      errorMessage, errorCode := dbErrorParse( err.Error() )
      fmt.Println(errorMessage)
            error, httpCode, msg := ErrorMessages(errorCode)
      Response.Error = msg
      Response.ErrorCode = error
      http.Error(w, "Conflict", httpCode)
    }

使用 cURL 和 verbose 标志(-v)再次运行这个命令,将会给我们提供关于错误的额外信息,如下面的截图所示:

返回有价值的错误信息

处理二进制数据

首先,我们需要在 MySQL 中创建一个新的字段来容纳图像数据。在这种情况下,我们可以选择BLOB数据,它接受大量的任意二进制数据。为此,我们可以假设(或强制)图像不应超过 16MB,因此MEDIUMBLOB将处理我们提供的所有数据:

ALTER TABLE `users`
  ADD COLUMN `user_image` MEDIUMBLOB NOT NULL AFTER `user_email`;

现在我们的图像列已经就位,我们可以接受数据。在我们的表单中添加另一个字段来存储图像数据:

<div class="form-group">
<label for="createLast">Image</label>
<input type="file" class="form-control" name="image" id="createImage" placeholder="Image">
</div>

在我们的服务器中,我们可以进行一些快速的修改来接受这个数据。首先,我们应该从表单中获取文件数据本身,如下所示:

    f, _, err := r.FormFile("image1")
    if err != nil { 
      fmt.Println(err.Error())
    }

接下来,我们想要读取整个文件并将其转换为一个字符串:

    fileData,_ := ioutil.ReadAll(f)

然后,我们将把它打包成一个base64编码的文本表示我们的图像数据:

    fileString := base64.StdEncoding.EncodeToString(fileData)

最后,我们在查询中加入新用户图像数据:

sql := "INSERT INTO users set user_image='" + fileString + "',  user_nickname='"

我们将在我们关于安全性的最后一章中回顾一下这里组装的一些 SQL 语句。

总结

三章之后,我们已经有了一个简单的社交网络应用程序的框架,我们可以在 REST 和 JSON-RPC 中复制。我们还花了一些时间来正确地将错误传递给 REST 中的客户端。

在我们的下一章中,《在 Go 中设计 API》,我们将真正开始完善我们的社交网络,并探索其他 Go 包,这些包对于拥有一个强大、健壮的 API 是相关的。

此外,我们将引入一些其他库和外部服务,以帮助在用户和他们的关系之间建立连接时提供详细的响应。

最后,我们还将开始尝试使用 Web 套接字,以便在 Web 上为客户端提供更交互式的体验。最后,我们将处理二进制数据,允许我们的客户端通过我们的 API 上传图像。

第四章:在 Go 中设计 API

我们现在已经完成了 REST 的基础知识,处理 URL 路由和在 Go 中进行多路复用,无论是直接还是通过框架。

希望创建我们的 API 的框架已经有所帮助和启发,但是如果我们要设计一个功能齐全的符合 REST 标准的 Web 服务,我们需要填补一些重要的空白。主要是,我们需要处理版本、所有端点和OPTIONS头,以及以一种优雅且易于管理的方式处理多种格式。

我们将完善我们想要为基于 API 的应用程序制定的端点,该应用程序允许客户端获取关于我们应用程序的所有信息,以及创建和更新用户,并提供与这些端点相关的有价值的错误信息。

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

在本章中,我们将涵盖以下主题:

  • 概述和设计我们完整的社交网络 API

  • 处理代码组织和 API 版本控制的基础知识

  • 允许我们的 API 使用多种格式(XML 和 JSON)

  • 仔细研究 WebSockets 并在 Go 中实现它们

  • 创建更健壮和描述性的错误报告

  • 通过 API 更新用户记录

在本章结束时,您应该能够优雅地处理 REST Web 服务的多种格式和版本,并更好地理解如何在 Go 中利用 WebSockets。

设计我们的社交网络 API

现在我们已经通过让 Go 输出我们 Web 服务中的数据来初步了解了一些,现在要采取的一个重要步骤是充分完善我们希望我们的主要项目的 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 用户的系统的需求,OpenID 是一个分散的身份系统。

OAuth2 出现时,系统已经大规模改进,更加安全,并且不再专注于特定的集成。如今,许多 API 依赖并要求 OAuth 来访问并代表用户通过第三方进行更改。

完整的规范文档(RFC6749)可以在互联网工程任务组的网站上找到:tools.ietf.org/html/rfc6749

前面提到的端点代表了我们构建一个完全基于 Web 服务运行的极简社交网络所需的一切。我们也将为此构建一个基本的界面,但主要是专注于在 Web 服务层面构建、测试和调优我们的应用程序。

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

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

处理我们的 API 版本

如果你花费了大量时间处理互联网上的 Web 服务和 API,你会发现各种服务处理其 API 版本的方式存在很大的差异。

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

考虑一个默认情况下在 URI 中使用版本控制的 API:/api/v1.1/users

你会发现这是相当常见的;例如,这就是 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 ServerJAS)。 JAS 位于 HTTP 之上(就像我们的 API 一样),但通过自动将请求定向到资源来自动化了许多路由。

提示

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

您可以在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 服务的协议。

并发 WebSockets

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

使用 WebSockets 的优势是减少客户端和服务器的延迟,并且对于构建长轮询应用程序的客户端解决方案来说,架构通常更少复杂。

为了概述优势,请考虑以下两种表示形式;第一个是标准 HTTP 请求:

并发 WebSockets

现在将这与更简化的 WebSocket 请求通过 TCP 进行比较,这消除了多次握手和状态控制的开销:

并发 WebSockets

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

可以肯定的是,严格意义上只有 HTTP 1 才有这个问题。HTTP 1.1 引入了保持活动或持久性连接。虽然这在协议方面起作用,但大多数非并发的 Web 服务器在资源分配方面会遇到困难。例如,默认情况下,Apache 会将保持活动超时设置得非常低,因为长时间的连接会占用线程并阻止未来的请求在合理的时间内完成。

HTTP 的现在和未来提供了一些 WebSocket 的替代方案,主要是由 Google 主要开发的 SPDY 协议提出的一些重要选项。

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

应该注意的是,跨 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并评估传入消息的长度,然后返回它。请注意,我们实质上将http处理程序转换websocket处理程序。这在这里显示:

func main() {

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

}

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

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

提示

websocket.go的握手机制源代码可以在code.google.com/p/go/source/browse/websocket/websocket.go?repo=net找到。

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

并发 WebSockets

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

  • Chrome:版本 21 及更高版本

  • Safari:版本 6 及更高版本

  • Firefox:版本 21 及更高版本

  • IE:版本 10 及更高版本

  • Opera:版本 22 及更高版本

现代 Android 和 iOS 浏览器现在也处理 WebSockets。

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

<html>
<head>
  <script src="img/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>

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

并发 WebSockets

分离我们的 API 逻辑

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

我们在GetFormat()SetFormat()函数中看到了一些这种情况,它们涵盖了所有的端点和版本。

扩展我们的错误消息

在上一章中,我们简要介绍了通过 HTTP 状态码发送错误消息。在这种情况下,当客户端尝试创建一个已经存在于数据库中的电子邮件地址的用户时,我们传递了一个 409 状态冲突。

http包提供了一组非全面的状态代码,您可以用它们来处理标准的 HTTP 问题以及特定于 REST 的消息。这些代码是非全面的,因为其中一些代码还有一些附加消息,但以下列表满足了 RFC 2616 提案:

Error Number
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函数中有一个 switch 控制,明确定义了我们的 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 错误。我们还可以直接自动地在 switch 中实现 HTTP 状态码作为默认值:

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

通过网络服务更新我们的用户

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

为此,我们将通过添加路由将一个端点添加到/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 中 Postman 的更多信息,请访问chrome.google.com/webstore/detail/postman-rest-client/fdmmgilgnpjigdojojpjoooidkmcomcm?hl=en

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

这两种工具本质上是做同样的事情;它们允许您直接与 API 进行接口,而无需开发特定的基于 HTML 或脚本的工具,也无需直接从命令行使用 cURL。

总结

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

本章在这样的应用程序基础设施——格式和端点——上投入了大量时间。在前者方面,我们主要关注了 XML 和 JSON,但在下一章中,我们将探索模板,以便您可以以您认为必要的任何任意格式返回数据。

我们还将深入探讨身份验证,无论是通过 OAuth 还是简单的 HTTP 基本身份验证,这将允许我们的客户端安全连接到我们的网络服务并发出保护敏感数据的请求。为此,我们还将锁定我们的应用程序以进行一些请求的 HTTPS。

此外,我们将专注于我们仅简要提及的 REST 方面——通过OPTIONS HTTP动词概述我们的网络服务的行为。最后,我们将更仔细地研究头部如何用于近似表示网络服务的服务器端和接收端的状态。

第五章:Go 中的模板和选项

在我们的社交网络网络服务的基础上,是时候将我们的项目从演示玩具变成实际可用的东西了,也许最终还可以投入生产。

为此,我们需要关注许多事情,其中一些我们将在本章中解决。在上一章中,我们看了一下如何确定我们的社交网络应用程序的主要功能。现在,我们需要确保从 REST 的角度来看,每一件事都是可能的。

为了实现这一点,在本章中,我们将看到:

  • 使用OPTIONS提供内置文档和我们资源端点目的的 REST 友好解释

  • 考虑替代输出格式以及如何实现它们的介绍

  • 为我们的 API 实施和强制安全性

  • 允许用户注册以使用安全密码

  • 允许用户从基于 Web 的界面进行身份验证

  • 近似于 OAuth 样式的身份验证系统

  • 允许外部应用代表其他用户发出请求

在实施这些事情之后,我们将拥有一个允许用户与之进行接口的服务的基础,无论是通过 API 直接接口还是通过第三方服务。

分享我们的选项

我们已经略微提到了OPTIONS HTTP 动词的价值和目的,因为它与 HTTP 规范和 REST 的最佳实践有关。

根据 RFC 2616,即 HTTP/1.1 规范,对OPTIONS请求的响应应返回有关客户端可以对资源和/或请求的端点进行的操作的信息。

注意

您可以在www.ietf.org/rfc/rfc2616.txt找到HTTP/1.1 请求注释 (RFC)。

换句话说,在我们早期的示例中,对/api/usersOPTIONS调用应返回一个指示,即GETPOSTPUTDELETE目前是该 REST 资源请求的可用选项。

目前,对于正文内容应该是什么样子或包含什么内容并没有预定义的格式,尽管规范表明这可能会在将来的版本中概述。这给了我们一些灵活性,可以在如何呈现可用操作方面有所作为;在大多数这样的情况下,我们都希望尽可能健壮和信息丰富。

以下代码是我们目前 API 的简单修改,其中包含了我们之前概述的有关OPTIONS请求的一些基本信息。首先,我们将在api.go文件的导出Init()函数中添加请求的特定处理程序:

func Init() {
  Routes = mux.NewRouter()
  Routes.HandleFunc("/api/users", UserCreate).Methods("POST")
  Routes.HandleFunc("/api/users", UsersRetrieve).Methods("GET")	
  Routes.HandleFunc("/api/users/{id:[0-9]+}",UsersUpdate).Methods("PUT")
  Routes.HandleFunc("/api/users", UsersInfo).Methods("OPTIONS")
}

然后,我们将添加处理程序:

func UsersInfo(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Allow","DELETE,GET,HEAD,OPTIONS,POST,PUT")
}

直接使用 cURL 调用这个命令会给我们我们所需要的东西。在下面的屏幕截图中,您会注意到响应顶部的Allow标头:

分享我们的选项

这一点单独就足以满足 REST 世界中OPTIONS动词的大多数普遍接受的要求,但请记住,正文没有格式,我们希望尽可能地表达。

我们可以通过提供一个特定于文档的包来做到这一点;在这个例子中,它被称为规范。请记住,这是完全可选的,但对于偶然发现它的任何开发人员来说,这是一个不错的礼物。让我们看看如何为自我记录的 API 设置这个:

package specification
type MethodPOST struct {
  POST EndPoint
}
type MethodGET struct {
  GET EndPoint
}
type MethodPUT struct {
  PUT EndPoint
}
type MethodOPTIONS struct {
  OPTIONS EndPoint
}
type EndPoint struct {
  Description string `json:"description"`
  Parameters []Param `json:"parameters"`
}
type Param struct {
  Name string "json:name"
  ParameterDetails Detail `json:"details"`
}
type Detail struct {
  Type string "json:type"
  Description string `json:"description"`
  Required bool "json:required"
}

var UserOPTIONS = MethodOPTIONS{ OPTIONS: EndPoint{ Description: "This page" } }
var UserPostParameters = []Param{ {Name: "Email", ParameterDetails: Detail{Type:"string", Description: "A new user's email address", Required: false} } }

var UserPOST = MethodPOST{ POST: EndPoint{ Description: "Create a user", Parameters: UserPostParameters } }
var UserGET = MethodGET{ GET: EndPoint{ Description: "Access a user" }}

然后,您可以直接在我们的api.go文件中引用它。首先,我们将创建一个包含所有可用方法的通用接口切片:

type DocMethod interface {
}

然后,我们可以在我们的UsersInfo方法中编译我们的各种方法:

func UsersInfo(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Allow","DELETE,GET,HEAD,OPTIONS,POST,PUT")

  UserDocumentation := []DocMethod{}
  UserDocumentation = append(UserDocumentation, Documentation.UserPOST)
  UserDocumentation = append(UserDocumentation, Documentation.UserOPTIONS)
  output := SetFormat(UserDocumentation)
  fmt.Fprintln(w,string(output))
}

您的屏幕应该看起来类似于这样:

分享我们的选项

实施替代格式

在查看 API 格式的世界时,您现在知道有两个主要的参与者:XMLJSON。作为人类可读格式,这两种格式在过去十多年中一直占据着格式世界。

通常情况下,开发人员和技术人员很少会满意地长期使用某种东西。在计算编码和解码的复杂性以及模式的冗长推动许多开发人员转向 JSON 之前,XML 很长一段时间是第一位的。

JSON 也不是没有缺点。没有一些明确的间距,它对人类来说并不那么可读,这会使文档的大小过分增加。它也不能默认处理注释。

还有许多替代格式在一旁。YAML,代表YAML Ain't Markup Language,是一种使用缩进使其对人类极易阅读的空白分隔格式。一个示例文档可能是这样的:

---
api:
  name: Social Network
  methods:
    - GET
    - POST
    - PUT
    - OPTIONS
    - DELETE

缩进系统作为模拟代码块的方法,对于有 Python 经验的人来说会很熟悉。

提示

Go 有许多 YAML 实现。最值得注意的是go-yaml,可以在github.com/go-yaml/yaml找到。

TOML,或Tom's Obvious, Minimal Language,采用了一种方法,对于任何使用.ini风格配置文件的人来说都会非常熟悉。

制定我们自己的数据表示格式

TOML 是一个很好的格式,可以用来构建我们自己的数据格式,主要是因为它的简单性使得在这种格式内部实现多种输出成为可能。

当设计像 TOML 这样简单的东西时,你可能会立即想到 Go 的文本模板格式,因为它本质上已经有了呈现它的控制机制。例如,考虑这个结构和循环:

type GenericData struct {
  Name string
  Options GenericDataBlock
}

type GenericDataBlock struct {
  Server string
  Address string
}

func main() {
  Data := GenericData{ Name: "Section", Options: GenericDataBlock{Server: "server01", Address: "127.0.0.1"}}

}

当结构被解析为文本模板时,它将精确地生成我们想要的内容:{{.Name}}

{{range $index, $value := Options}}
  $index = $value
{{end}}

这种方法的一个大问题是你没有固有的系统来解组数据。换句话说,你可以生成这种格式的数据,但你不能将其解开成 Go 结构的另一种方式。

另一个问题是,随着格式的复杂性增加,使用 Go 模板库中的有限控制结构来满足这种格式的所有复杂性和怪癖变得不太合理。

如果你选择自己的格式,你应该避免文本模板,而是查看编码包,它允许你生成和消费结构化数据格式。

我们将在接下来的章节中仔细研究编码包。

引入安全和认证

任何网络服务或 API 的一个关键方面是能够保持信息安全,并且只允许特定用户访问特定的内容。

在历史上,有许多方法可以实现这一点,最早的一种是 HTTP 摘要认证。

另一个常见的方法是包含开发人员凭据,即 API 密钥。这已经不再被推荐,主要是因为 API 的安全性完全依赖于这些凭据的安全性。然而,这在很大程度上是一种明显的允许认证的方法,作为服务提供商,它允许你跟踪谁在做特定的请求,还可以实现请求的限制。

今天的大玩家是 OAuth,我们很快会看一下。然而,首先,我们需要确保我们的 API 只能通过 HTTPS 访问。

强制使用 HTTPS

此时,我们的 API 开始使客户和用户能够做一些事情,比如创建用户,更新他们的数据,并为这些用户包含图像数据。我们开始涉足一些在现实环境中不希望公开的事情。

我们可以看一下的第一个安全步骤是强制 API 上的 HTTPS 而不是 HTTP。Go 通过 TLS 实现 HTTPS,而不是 SSL,因为从服务器端来看,TLS 被认为是更安全的协议。其中一个驱动因素是 SSL 3.0 中的漏洞,特别是 2014 年暴露的 Poodlebleed Bug。

提示

您可以在poodlebleed.com/了解更多关于 Poodlebleed 的信息。

让我们看看如何在以下代码中将任何非安全请求重定向到其安全对应项:

package main

import
(
  "fmt"
  "net/http"
  "log"
  "sync"
)

const (
  serverName = "localhost"
  SSLport = ":443"
  HTTPport = ":8080"
  SSLprotocol = "https://"
  HTTPprotocol = "http://"
)

func secureRequest(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w,"You have arrived at port 443, but you are not yet secure.")
}

这是我们(暂时)正确的端点。它还不是 TSL(或 SSL),所以我们实际上并没有监听 HTTPS 连接,因此会显示此消息。

func redirectNonSecure(w http.ResponseWriter, r *http.Request) {
  log.Println("Non-secure request initiated, redirecting.")
  redirectURL := SSLprotocol + serverName + r.RequestURI
  http.Redirect(w, r, redirectURL, http.StatusOK)
}

这是我们的重定向处理程序。您可能会注意到http.StatusOK状态码 - 显然我们希望发送 301 永久移动错误(或http.StatusMovedPermanently常量)。但是,如果您正在测试这个,您的浏览器可能会缓存状态并自动尝试重定向您。

func main() {
  wg := sync.WaitGroup{}
  log.Println("Starting redirection server, try to access @ http:")

  wg.Add(1)
  go func() {
    http.ListenAndServe(HTTPport,http.HandlerFunc(redirectNonSecure))
    wg.Done()
  }()
  wg.Add(1)
  go func() {
    http.ListenAndServe(SSLport,http.HandlerFunc(secureRequest))
    wg.Done()
  }()
  wg.Wait()
}

那么,为什么我们将这些方法包装在匿名的 goroutines 中呢?好吧,把它们拿出来,您会发现因为ListenAndServe函数是阻塞的,我们不能通过简单调用以下语句同时运行这两个方法:

http.ListenAndServe(HTTPport,http.HandlerFunc(redirectNonSecure))
http.ListenAndServe(SSLport,http.HandlerFunc(secureRequest))

当然,您在这方面有多种选择。您可以简单地将第一个设置为 goroutine,这将允许程序继续执行第二个服务器。这种方法提供了一些更细粒度的控制,用于演示目的。

添加 TLS 支持

在前面的示例中,显然我们并没有监听 HTTPS 连接。Go 使这变得非常容易;但是,像大多数 SSL/TLS 问题一样,处理您的证书时会出现复杂性。

对于这些示例,我们将使用自签名证书,Go 也很容易实现。在crypto/tls包中,有一个名为generate_cert.go的文件,您可以使用它来生成您的证书密钥。

通过转到您的 Go 二进制目录,然后src/pkg/crypto/tls,您可以通过运行以下命令生成一个可以用于测试的密钥对:

go run generate_cert.go --host localhost --ca true

然后,您可以将这些文件移动到任何您想要的位置,理想情况下是我们 API 运行的目录。

接下来,让我们删除http.ListenAndServe函数,并将其更改为http.ListenAndServeTLS。这需要一些额外的参数,包括密钥的位置:

http.ListenAndServeTLS(SSLport, "cert.pem", "key.pem", http.HandlerFunc(secureRequest))

为了更加明确,让我们稍微修改我们的secureRequest处理程序:

fmt.Fprintln(w,"You have arrived at port 443, and now you are marginally more secure.")

如果我们现在运行这个并转到我们的浏览器,希望会看到一个警告,假设我们的浏览器会保护我们:

添加 TLS 支持

假设我们信任自己,这并不总是明智的,点击通过,我们将看到来自安全处理程序的消息:

添加 TLS 支持

注意

当然,如果我们再次访问http://localhost:8080,我们现在应该会自动重定向,并显示 301 状态代码。

当您有访问支持 OpenSSL 的操作系统时,创建自签名证书通常是相当容易的。

如果您想要尝试使用真实证书而不是自签名证书,您可以通过多种服务免费获得一年期的签名(但未经验证)证书。其中比较流行的是 StartSSL(www.startssl.com/),它使得获取免费和付费证书变得简单。

让用户注册和认证

您可能还记得,作为我们 API 应用的一部分,我们有一个自包含的接口,允许我们为 API 本身提供 HTML 界面。如果我们不保护我们的用户,任何关于安全性的讨论都将毫无意义。

当然,实现用户身份验证安全的绝对最简单的方法是通过存储和使用带有哈希机制的密码。服务器以明文存储密码是非常常见的,所以我们不会这样做;但是,我们希望至少使用一个额外的安全参数来实现我们的密码。

我们希望不仅存储用户的密码,而且至少存储一个盐。这并不是一个绝对安全的措施,尽管它严重限制了字典和彩虹攻击的威胁。

为此,我们将创建一个名为password的新包,作为我们套件的一部分,它允许我们生成随机盐,然后加密该值以及密码。

我们可以使用GenerateHash()来创建和验证密码。

快速入门-生成盐

获取密码很简单,创建安全哈希也相当容易。为了使我们的身份验证过程更安全,我们缺少的是盐。让我们看看我们如何做到这一点。首先,让我们在我们的数据库中添加一个密码和一个盐字段:

ALTER TABLE `users`
  ADD COLUMN `user_password` VARCHAR(1024) NOT NULL AFTER `user_nickname`,
  ADD COLUMN `user_salt` VARCHAR(128) NOT NULL AFTER `user_password`,
  ADD INDEX `user_password_user_salt` (`user_password`, `user_salt`);

有了这个,让我们来看看我们的密码包,其中包含盐和哈希生成函数:

package password

import
(
  "encoding/base64"
  "math/rand"
  "crypto/sha256"
  "time"
)

const randomLength = 16

func GenerateSalt(length int) string {
  var salt []byte
  var asciiPad int64

  if length == 0 {
    length = randomLength
  }

  asciiPad = 32

  for i:= 0; i < length; i++ {
    salt = append(salt, byte(rand.Int63n(94) + asciiPad) )
  }

  return string(salt)
}

我们的GenerateSalt()函数生成一串特定字符集内的随机字符。在这种情况下,我们希望从 ASCII 表中的 32 开始,一直到 126。

func GenerateHash(salt string, password string) string {
  var hash string
  fullString := salt + password
  sha := sha256.New()
  sha.Write([]byte(fullString))
  hash = base64.URLEncoding.EncodeToString(sha.Sum(nil))

  return hash
}

在这里,我们基于密码和盐生成一个哈希。这不仅对于密码的创建有用,还对于验证密码也有用。以下的ReturnPassword()函数主要作为其他函数的包装器,允许您创建密码并返回其哈希值:

func ReturnPassword(password string) (string, string) {
  rand.Seed(time.Now().UTC().UnixNano())

  salt := GenerateSalt(0)

  hash := GenerateHash(salt,password)

  return salt, hash
}

在我们的客户端,您可能还记得我们通过 jQuery 通过 AJAX 发送了所有数据。我们在一个单独的 Bootstrap 标签上有一个单独的方法,允许我们创建用户。首先,让我们回顾一下标签设置。

现在,userCreate()函数中,我们添加了一些东西。首先,有一个密码字段,允许我们在创建用户时发送该密码。在没有安全连接的情况下,我们可能以前对此不太放心:

  function userCreate() {
    action = "https://localhost/api/users";
    postData = {};
    postData.email = $('#createEmail').val();
    postData.user = $('#createUsername').val();
    postData.first = $('#createFirst').val();
    postData.last= $('#createLast').val();
    postData.password = $('#createPassword').val();

接下来,我们可以修改我们的.ajax响应以对不同的 HTTP 状态代码做出反应。请记住,如果用户名或电子邮件 ID 已经存在,我们已经设置了冲突。因此,让我们也处理这个问题:

var formData = new FormData($('form')[0]);
$.ajax({

    url: action,  //Server script to process data
    dataType: 'json',
    type: 'POST',
    statusCode: {
      409: function() {
        $('#api-messages').html('Email address or nickname already exists!');
        $('#api-messages').removeClass('alert-success').addClass('alert-warning');
        $('#api-messages').show();
        },
      200: function() {
        $('#api-messages').html('User created successfully!');
        $('#api-messages').removeClass('alert-warning').addClass('alert-success');
        $('#api-messages').show();
        }
      },

现在,如果我们得到一个 200 的响应,我们知道我们的 API 端已经创建了用户。如果我们得到 409,我们会在警报区域向用户报告电子邮件地址或用户名已被使用。

在 Go 中检查 OAuth

正如我们在第四章中简要提到的,在 Go 中设计 API,OAuth 是允许应用使用另一个应用的用户身份验证与第三方应用进行交互的一种常见方式。

它在社交媒体服务中非常受欢迎;Facebook、Twitter 和 GitHub 都使用 OAuth 2.0 允许应用代表用户与其 API 进行交互。

这里值得注意的是,虽然有许多 API 调用我们可以放心地不受限制,主要是GET请求,但还有一些是特定于用户的,我们需要确保我们的用户授权这些请求。

让我们快速回顾一下我们可以实现的方法,以使我们的服务器类似于 OAuth:

Endpoint
/api/oauth/authorize
/api/oauth/token
/api/oauth/revoke

鉴于我们有一个小型的、主要基于演示的服务,我们长时间保持访问令牌活动的风险是很小的。长期有效的访问令牌显然会为客户端开放更多的不受欢迎的访问机会,因为它们可能没有遵守最佳的安全协议。

在正常情况下,我们希望对令牌设置一个到期时间,我们可以通过使用一个带有过期时间的 memcache 系统或密钥库来简单地实现这一点。这样可以使值自然死亡,而无需显式销毁它们。

我们需要做的第一件事是为客户端凭据添加一个表,即consumer_keyconsumer_token

CREATE TABLE `api_credentials` (
  `user_id` INT(10) UNSIGNED NOT NULL,
  `consumer_key` VARCHAR(128) NOT NULL,
  `consumer_secret` VARCHAR(128) NOT NULL,
  `callback_url` VARCHAR(256) NOT NULL
  CONSTRAINT `FK__users` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON UPDATE NO ACTION ON DELETE NO ACTION
)

我们将检查详细信息以验证凭据是否正确,并且如果正确,我们将返回一个访问令牌。

访问令牌可以是任何格式;鉴于我们对演示的低安全限制,我们将返回一个随机生成的字符串的 MD5 哈希。在现实世界中,即使对于短期令牌,这可能也不够,但它在这里能够达到目的。

提示

请记住,我们在password包中实现了一个随机字符串生成器。您可以通过调用以下语句在api.go中创建一个快速的密钥和密钥值:

  fmt.Println(Password.GenerateSalt(22))
  fmt.Println(Password.GenerateSalt(41))

如果您将此密钥和密钥值输入到先前创建的表中,并将其与现有用户关联,您将拥有一个活动的 API 客户端。请注意,这可能会生成无效的 URL 字符,因此我们将将我们对/oauth/token端点的访问限制为POST

我们的伪 OAuth 机制将进入自己的包中,并且它将严格生成我们将在 API 包中的令牌切片中保留的令牌。

在我们的核心 API 包中,我们将添加两个新函数来验证凭据和pseudoauth包:

  import(
  Pseudoauth "github.com/nkozyra/gowebservice/pseudoauth" 
  )

我们将添加的函数是CheckCredentials()CheckToken()。第一个将接受一个密钥、一个一次性号码、一个时间戳和一个加密方法,然后我们将与consumer_secret值一起对其进行哈希处理,以查看签名是否匹配。实质上,所有这些请求参数都与双方知道但未广播的秘密结合在一起,以创建一个以双方知道的方式进行哈希处理的签名。如果这些签名对应,应用程序可以发出请求令牌或访问令牌(后者通常用于交换请求令牌,我们将很快讨论更多内容)。

在我们的情况下,我们将接受consumer_key值、一次性号码、时间戳和签名,暂时假设 HMAC-SHA1 被用作签名方法。由于 SHA1 发生碰撞的可能性增加,它正在失去一些青睐,但是对于开发应用程序的目的,它将会并且可以在以后简单地替换。Go 还提供了 SHA224、SHA256、SHA384 和 SHA512。

一次性号码和时间戳的目的是专门增加安全性。一次性号码几乎肯定作为请求的唯一标识哈希,时间戳允许我们定期过期数据以保留内存和/或存储。我们这里不会这样做,尽管我们将检查以确保一次性号码以前没有被使用。

要开始验证客户端,我们在数据库中查找共享密钥。

func CheckCredentials(w http.ResponseWriter, r *http.Request)  {
  var Credentials string
  Response := CreateResponse{}
  consumerKey := r.FormValue("consumer_key")
  fmt.Println(consumerKey)
  timestamp := r.FormValue("timestamp")
  signature := r.FormValue("signature")
  nonce := r.FormValue("nonce")
  err := Database.QueryRow("SELECT consumer_secret from api_credentials where consumer_key=?", consumerKey).Scan(&Credentials)
    if err != nil {
    error, httpCode, msg := ErrorMessages(404)
    log.Println(error)	
    log.Println(w, msg, httpCode)
    Response.Error = msg
    Response.ErrorCode = httpCode
    http.Error(w, msg, httpCode)
    return
  }

在这里,我们获取consumer_key值并查找我们共享的consumer_secret令牌,然后将其传递给我们的ValidateSignature函数,如下所示:

  token,err := Pseudoauth.ValidateSignature(consumerKey,Credentials,timestamp,nonce,signature,0)
  if err != nil {
    error, httpCode, msg := ErrorMessages(401)
    log.Println(error)	
    log.Println(w, msg, httpCode)
    Response.Error = msg
    Response.ErrorCode = httpCode
    http.Error(w, msg, httpCode)
    return
  }

如果我们发现我们的请求无效(要么是因为凭据不正确,要么是因为存在的一次性号码),我们将返回未经授权的错误和 401 状态码:

  AccessRequest := OauthAccessResponse{}
  AccessRequest.AccessToken = token.AccessToken
  output := SetFormat(AccessRequest)
  fmt.Fprintln(w,string(output))
}

否则,我们将在 JSON 主体响应中返回访问代码。这是pseudoauth包本身的代码:

package pseudoauth
import
(
  "crypto/hmac"
  "crypto/sha1"
  "errors"
  "fmt"
  "math/rand"
  "strings"
  "time"
)

这里没有太多令人惊讶的地方!我们需要一些加密包和math/rand来允许我们进行种子生成:

type Token struct {
  Valid bool
  Created int64
  Expires int64
  ForUser int
  AccessToken string
}

这里比我们目前使用的要多一点,但你可以看到我们可以创建具有特定访问权限的令牌:

var nonces map[string] Token
func init() {
  nonces = make(map[string] Token)
}

func ValidateSignature(consumer_key string, consumer_secret string, timestamp string,  nonce string, signature string, for_user int) (Token, error) {
  var hashKey []byte
  t := Token{}
  t.Created = time.Now().UTC().Unix()
  t.Expires = t.Created + 600
  t.ForUser = for_user

  qualifiedMessage := []string{consumer_key, consumer_secret, timestamp, nonce}
  fullyQualified := strings.Join(qualifiedMessage," ")

  fmt.Println(fullyQualified)
  mac := hmac.New(sha1.New, hashKey)
  mac.Write([]byte(fullyQualified))
  generatedSignature := mac.Sum(nil)

  //nonceExists := nonces[nonce]

  if hmac.Equal([]byte(signature),generatedSignature) == true {

    t.Valid = true
    t.AccessToken = GenerateToken()
    nonces[nonce] = t
    return t, nil
  } else {
    err := errors.New("Unauthorized")
    t.Valid = false
    t.AccessToken = ""
    nonces[nonce] = t
    return t, err
  }

}

这是类似于 OAuth 这样的服务尝试验证签名请求的粗略近似;一次性号码、公钥、时间戳和共享私钥使用相同的加密进行评估。如果它们匹配,请求是有效的。如果它们不匹配,应该返回错误。

我们可以稍后使用时间戳为任何给定的请求提供一个短暂的窗口,以便在意外签名泄漏的情况下,可以将损害最小化:

func GenerateToken() string {
  var token []byte
  rand.Seed(time.Now().UTC().UnixNano())
  for i:= 0; i < 32; i++ {
    token = append(token, byte(rand.Int63n(74) + 48) )
  }
  return string(token)
}

代表用户进行请求

在代表用户进行请求时,OAuth2 过程中涉及一个关键的中间步骤,那就是用户的身份验证。显然,这不能在消费者应用程序中发生,因为这将打开一个安全风险,恶意或不恶意地,用户凭据可能会被泄露。

因此,这个过程需要一些重定向。

首先,需要一个初始请求,将用户重定向到登录位置。如果他们已经登录,他们将有能力授予应用程序访问权限。接下来,我们的服务将接受一个回调 URL 并将用户带回来,同时带上他们的请求令牌。这将使第三方应用程序能够代表用户进行请求,直到用户限制对第三方应用程序的访问为止。

为了存储有效的令牌,这些令牌本质上是用户和第三方开发人员之间的许可连接,我们将为此创建一个数据库:

CREATE TABLE `api_tokens` (
  `api_token_id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `application_user_id` INT(10) UNSIGNED NOT NULL,
  `user_id` INT(10) UNSIGNED NOT NULL,
  `api_token_key` VARCHAR(50) NOT NULL,
  PRIMARY KEY (`api_token_id`)
)

我们需要一些部件来使其工作,首先是一个登录表单,用于当前未登录的用户,依赖于sessions表。让我们现在在 MySQL 中创建一个非常简单的实现:

CREATE TABLE `sessions` (
  `session_id` VARCHAR(128) NOT NULL,
  `user_id` INT(10) NOT NULL,
  UNIQUE INDEX `session_id` (`session_id`)
)

接下来,我们需要一个授权表单,用于已登录用户,允许我们为用户和服务创建有效的 API 访问令牌,并将用户重定向到回调地址。

模板可以是一个非常简单的 HTML 模板,可以放置在/authorize。因此,我们需要将该路由添加到api.go中:

  Routes.HandleFunc("/authorize", ApplicationAuthorize).Methods("POST")
  Routes.HandleFunc("/authorize", ApplicationAuthenticate).Methods("GET")

POST的请求将检查确认,如果一切正常,就会传递这个:

<!DOCTYPE html>
<html>
  <head>
    <title>{{.Title}}</title>
  </head>
  <body>
  {{if .Authenticate}}
      <h1>{{.Title}}</h1>
      <form action="{{.Action}}" method="POST">
      <input type="hidden" name="consumer_key" value="{.ConsumerKey}" />
      Log in here
      <div><input name="username" type="text" /></div>
      <div><input name="password" type="password" /></div>
      Allow {{.Application}} to access your data?
      <div><input name="authorize" value="1" type="radio"> Yes</div>
      <div><input name="authorize" value="0" type="radio"> No</div>
      <input type="submit" value="Login" />
  {{end}}
  </form>
  </body>
</html>

Go 的模板语言在很大程度上没有逻辑,但并非完全没有逻辑。我们可以使用if控制结构将两个页面的 HTML 代码放在一个模板中。为了简洁起见,我们还将创建一个非常简单的Page结构,使我们能够构建非常基本的响应页面:

type Page struct {
  Title string
  Authorize bool
  Authenticate bool
  Application string
  Action string
  ConsumerKey string
}

目前我们不会维护登录状态,这意味着每个用户都需要在希望授权第三方代表他们进行 API 请求时登录。随着我们的进展,我们将对此进行微调,特别是在使用 Gorilla 工具包中可用的安全会话数据和 cookie 方面。

因此,第一个请求将包括一个带有consumer_key值的登录尝试,用于标识应用程序。您也可以在这里包括完整的凭据(nonce 等),但由于这将只允许您的应用程序访问单个用户,这可能是不必要的。

func ApplicationAuthenticate(w http.ResponseWriter, r *http.Request) {
  Authorize := Page{}
  Authorize.Authenticate = true
  Authorize.Title = "Login"
  Authorize.Application = ""
  Authorize.Action = "/authorize"

  tpl := template.Must(template.New("main").ParseFiles("authorize.html"))
  tpl.ExecuteTemplate(w, "authorize.html", Authorize)
}

所有请求都将发布到同一个地址,然后我们将验证登录凭据(记住我们password包中的GenerateHash()),如果它们有效,我们将在api_connections中创建连接,然后将用户返回到与 API 凭据关联的回调 URL。

这是一个确定登录凭据是否正确的函数,如果是的话,将使用我们创建的request_token值重定向到回调 URL:

func ApplicationAuthorize(w http.ResponseWriter, r *http.Request) {

  username := r.FormValue("username")
  password := r.FormValue("password")
  allow := r.FormValue("authorize")

  var dbPassword string
  var dbSalt string
  var dbUID string

  uerr := Database.QueryRow("SELECT user_password, user_salt, user_id from users where user_nickname=?", username).Scan(&dbPassword, &dbSalt, &dbUID)
  if uerr != nil {

  }

通过user_password值,user_salt值和提交的密码值,我们可以通过使用我们的GenerateHash()函数并进行直接比较来验证密码的有效性,因为它们是 Base64 编码的。

  consumerKey := r.FormValue("consumer_key")
  fmt.Println(consumerKey)

  var CallbackURL string
  var appUID string
  err := Database.QueryRow("SELECT user_id,callback_url from api_credentials where consumer_key=?", consumerKey).Scan(&appUID, &CallbackURL)
  if err != nil {

    fmt.Println(err.Error())
    return
  }

  expectedPassword := Password.GenerateHash(dbSalt, password)
  if dbPassword == expectedPassword && allow == "1" {

    requestToken := Pseudoauth.GenerateToken()

    authorizeSQL := "INSERT INTO api_tokens set application_user_id=" + appUID + ", user_id=" + dbUID + ", api_token_key='" + requestToken + "' ON DUPLICATE KEY UPDATE user_id=user_id"

    q, connectErr := Database.Exec(authorizeSQL)
    if connectErr != nil {

    } else {
      fmt.Println(q)
    }
    redirectURL := CallbackURL + "?request_token=" + requestToken
    fmt.Println(redirectURL)
    http.Redirect(w, r, redirectURL, http.StatusAccepted)

在将expectedPassword与数据库中的密码进行对比后,我们可以判断用户是否成功进行了身份验证。如果是,我们会创建令牌并将用户重定向回回调 URL。然后,其他应用程序有责任存储该令牌以备将来使用。

  } else {

    fmt.Println(dbPassword, expectedPassword)
    http.Redirect(w, r, "/authorize", http.StatusUnauthorized)
  }

}

现在我们在第三方端有了令牌,我们可以使用该令牌和我们的client_token值进行 API 请求,代表个人用户进行请求,例如创建连接(好友和关注者),发送自动消息或设置状态更新。

总结

我们开始本章时,看了一些带来更多 REST 风格选项和功能、更好的安全性以及基于模板的呈现的方法。为了实现这个目标,我们研究了 OAuth 安全模型的基本抽象,这使我们能够使外部客户端在用户的域内工作。

现在,我们的应用程序通过 OAuth 风格的身份验证并通过 HTTPS 进行了安全保护,我们现在可以扩展我们的社交网络应用程序的第三方集成,允许其他开发人员利用和增强我们的服务。

在下一章中,我们将更多地关注我们应用程序的客户端和消费者端,扩展我们的 OAuth 选项,并通过 API 赋予更多的操作,包括创建和删除用户之间的连接,以及创建状态更新。

第六章:在 Go 中访问和使用网络服务

在上一章中,我们简要涉及了 OAuth 2.0 过程,并在我们自己的 API 中模拟了这个过程。

我们将通过将我们的用户连接到一些提供 OAuth 2.0 连接的现有普遍服务来进一步探索这个过程,并允许我们的应用程序中的操作在他们的应用程序中创建操作。

一个例子是当您在一个社交网络上发布内容并被给予类似地在另一个社交网络上发布或交叉发布的选项。这正是我们将在这里进行实验的流程类型。

为了真正理解这一点,我们将在我们的应用程序中连接现有用户到另一个使用 OAuth 2.0 的应用程序(如 Facebook、Google+和 LinkedIn),然后在我们的系统和其他系统之间共享资源。

虽然我们无法让这些系统回报,但我们将继续前进,并模拟另一个试图在我们的应用程序基础设施内工作的应用程序。

在本章中,我们将探讨:

  • 作为客户端通过 OAuth 2.0 连接到其他服务

  • 让我们的用户从我们的应用程序分享信息到另一个网络应用程序

  • 允许我们的 API 消费者代表我们的用户发出请求

  • 如何确保我们在 OAuth 请求之外建立安全连接

在本章结束时,作为客户端,您应该能够使用 OAuth 将用户帐户连接到其他服务。您还应该能够进行安全请求,创建允许其他服务连接到您的服务的方式,并代表您的用户进行第三方请求。

将我们的用户连接到其他服务

为了更好地理解 OAuth 2.0 过程在实践中是如何工作的,让我们连接到一些流行的社交网络,特别是 Facebook 和 Google+。这不仅仅是一个实验项目;这是现代社交网络运作的方式,通过允许服务之间的互联和共享。

这不仅是常见的,而且当您允许不协调的应用程序之间无缝连接时,还往往会引起更高程度的采用。从诸如 Twitter 和 Facebook 之类的服务共享的能力有助于加速它们的流行。

当我们探索客户端方面时,我们将深入了解像我们这样的网络服务如何允许第三方应用程序和供应商在我们的生态系统内工作,并扩大我们应用程序的深度。

要开始这个过程,我们将获取一个现有的 Go OAuth 2.0 客户端。有一些可用的,但要安装 Goauth2,运行go get命令如下:

go get code.google.com/p/goauth2/oauth

如果我们想将对 OAuth 2.0 服务的访问分隔开,我们可以在我们的导入目录中创建一个独立的文件,让我们创建一个连接到我们的 OAuth 提供者并从中获取相关详细信息。

在这个简短的例子中,我们将连接一个 Facebook 服务,并从 Facebook 请求一个身份验证令牌。之后,我们将返回到我们的网络服务,获取并可能存储令牌:

package main

import (
  "code.google.com/p/goauth2/oauth"
  "fmt"
)

这就是我们需要创建一个独立的包,我们可以从其他地方调用。在这种情况下,我们只有一个服务;因此,我们将创建以下变量作为全局变量:

var (
  clientID     = "[Your client ID here]"
  clientSecret = "[Your client secret here]"
  scope        = ""
  redirectURL  = "http://www.mastergoco.com/codepass"
  authURL      = "https://www.facebook.com/dialog/oauth"
  tokenURL     = "https://graph.facebook.com/oauth/access_token"
  requestURL   = "https://graph.facebook.com/me"
  code         = ""
)

您将从提供者那里获得这些端点和变量,但它们在这里显然是模糊的。

redirectURL变量表示用户登录后您将捕获到的发送令牌的位置。我们将很快仔细研究一般流程。main函数编写如下:

func main() {

  oauthConnection := &oauth.Config{
    ClientId:     clientID,
    ClientSecret: clientSecret,
    RedirectURL:  redirectURL,
    Scope:        scope,
    AuthURL:      authURL,
    TokenURL:     tokenURL,
  }

  url := oauthConnection.AuthCodeURL("")
  fmt.Println(url)

}

如果我们获取生成的 URL 并直接访问它,它将带我们到类似于我们在上一页上构建的粗略版本的登录页面。这是 Facebook 呈现的身份验证页面:

将我们的用户连接到其他服务

如果用户(在这种情况下是我)接受此身份验证并点击,页面将重定向回我们的 URL 并传递一个 OAuth 代码,类似于这样:

www.mastergoco.com/codepass?code=h9U1_YNL1paTy-IsvQIor6u2jONwtipxqSbFMCo3wzYsSK7BxEVLsJ7ujtoDc

我们可以将此代码用作将来请求的半永久用户接受代码。如果用户撤销对我们应用程序的访问权限,或者我们选择更改应用程序希望在第三方服务中使用的权限,这将无效。

您可以开始看到一个非常连接的应用程序的可能性,以及为什么第三方身份验证系统,例如通过 Twitter、Facebook、Google+等进行注册和登录的能力,近年来已成为可行和吸引人的前景。

为了将其作为我们 API 的附加部分做任何有用的事情(假设每个社交网络的服务条款允许),我们需要做三件事:

首先,我们需要使其不再仅限于一个服务。为此,我们将创建一个OauthService结构的映射:

type OauthService struct {
  clientID string
  clientSecret string
  scope string
  redirectURL string
  authURL string
  tokenURL string
  requestURL string
  code string
}

然后,我们可以根据需要添加这个:

  OauthServices := map[string] OauthService{}

  OauthServices["facebook"] = OauthService {
    clientID:  "***",
    clientSecret: "***",
    scope: "",
    redirectURL: "http://www.mastergoco.com/connect/facebook",
    authURL: "https://www.facebook.com/dialog/oauth",
    tokenURL: "https://graph.facebook.com/oauth/access_token",
    requestURL: "https://graph.facebook.com/me",
    code: "",
  }
  OauthServices["google"] = OauthService {
    clientID:  "***.apps.googleusercontent.com",
    clientSecret: "***",
    scope: "https://www.googleapis.com/auth/plus.login",
    redirectURL: "http://www.mastergoco.com/connect/google",
    authURL: "https://accounts.google.com/o/oauth2/auth",
    tokenURL: "https://accounts.google.com/o/oauth2/token",
    requestURL: "https://graph.facebook.com/me",
    code: "",
  }

接下来,我们需要做的是将其变成一个实际的重定向,而不是将代码输出到我们的控制台。考虑到这一点,现在是将此代码集成到api.go文件中的时候了。这将允许我们注册的用户将他们在我们社交网络上的用户信息连接到其他人,以便他们可以在我们的应用程序上更广泛地广播他们的活动。这将带我们到我们的下一个最后一步,即接受每个相应的网络服务返回的代码:

func Init() {
  Routes = mux.NewRouter()
  Routes.HandleFunc("/interface", APIInterface).Methods("GET", "POST", "PUT", "UPDATE")
  Routes.HandleFunc("/api/users", UserCreate).Methods("POST")
  Routes.HandleFunc("/api/users", UsersRetrieve).Methods("GET")
  Routes.HandleFunc("/api/users/{id:[0-9]+}", UsersUpdate).Methods("PUT")
  Routes.HandleFunc("/api/users", UsersInfo).Methods("OPTIONS")
  Routes.HandleFunc("/authorize", ApplicationAuthorize).Methods("POST")
  Routes.HandleFunc("/authorize", ApplicationAuthenticate).Methods("GET")
  Routes.HandleFunc("/authorize/{service:[a-z]+}", ServiceAuthorize).Methods("GET")
  Routes.HandleFunc("/connect/{service:[a-z]+}", ServiceConnect).Methods("GET")
  Routes.HandleFunc("/oauth/token", CheckCredentials).Methods("POST")
}

我们将在Init()函数中添加两个端点路由;一个允许服务进行授权(即,发送到该站点的 OAuth 身份验证),另一个允许我们保留以下结果信息:

func ServiceAuthorize(w http.ResponseWriter, r *http.Request) {

  params := mux.Vars(r)
  service := params["service"]
  redURL := OauthServices.GetAccessTokenURL(service, "")
  http.Redirect(w, r, redURL, http.StatusFound)

}

在这里,我们将建立一个 Google+认证通道。毋庸置疑,但不要忘记用您的值替换您的clientIDclientSecretredirectURL变量:

OauthServices["google"] = OauthService {
  clientID:  "***.apps.googleusercontent.com",
  clientSecret: "***",
  scope: "https://www.googleapis.com/auth/plus.login",
  redirectURL: "http://www.mastergoco.com/connect/google",
  authURL: "https://accounts.google.com/o/oauth2/auth",
  tokenURL: "https://accounts.google.com/o/oauth2/token",
  requestURL: "https://accounts.google.com",
  code: "",
}

通过访问http://localhost/authorize/google,我们将被踢到 Google+的中间身份验证页面。以下是一个基本上与我们之前看到的 Facebook 身份验证基本相似的示例:

将我们的用户连接到其他服务

当用户点击接受时,我们将返回到我们的重定向 URL,并获得我们正在寻找的代码。

提示

对于大多数 OAuth 提供商,将从仪表板提供客户端 ID 和客户端密钥。

然而,在 Google+上,您将从他们的开发者控制台中检索您的客户端 ID,这允许您注册新应用程序并请求访问不同的服务。但他们并不公开提供客户端密钥,因此您需要下载一个包含不仅密钥,还包括其他相关数据的 JSON 文件,这些数据可能是您访问服务所需的格式类似于这样:

{"web":{"auth_uri":"https://accounts.google.com/o/oauth2/auth","client_secret":"***","token_uri":"https://accounts.google.com/o/oauth2/token","client_email":"***@developer.gserviceaccount.com","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/***@developer.gserviceaccount.com","client_id":"***.apps.googleusercontent.com","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs"}}

您可以直接从此文件中获取相关详细信息。

当然,为了确保我们知道是谁发出了请求以及如何存储它,我们需要一些状态。

使用 Web 服务保存状态

在单个 Web 请求中有很多保存状态的方法。然而,在这种情况下,当我们的客户端发出一个请求,然后被重定向到另一个 URL,然后回到我们的时候,情况往往会变得更加复杂。

我们可以在重定向的 URL 中传递关于用户的一些信息,例如,mastergoco.com/connect/google?uid=1;但这有点不够优雅,并且存在一个小的安全漏洞,中间人攻击者可以了解用户和外部 OAuth 代码。

这里的风险很小,但确实存在;因此,我们应该寻找其他地方。幸运的是,Gorilla 还提供了一个用于安全会话的不错的库。每当我们验证了用户或客户端的身份并将信息存储在 cookie 存储中时,我们可以使用这些。

首先,让我们创建一个sessions表:

CREATE TABLE IF NOT EXISTS `sessions` (
  `session_id` varchar(128) NOT NULL,
  `user_id` int(10) NOT NULL,
  `session_start_time` int(11) NOT NULL,
  `session_update_time` int(11) NOT NULL,
  UNIQUE KEY `session_id` (`session_id`)
)

接下来,包括sessions包:

go get github.com/gorilla/sessions

然后,将其移入我们的api.go文件的import部分:

import (
  ...
  "github.com/gorilla/mux"
  "github.com/gorilla/sessions"

现在我们还没有对服务进行身份验证,所以我们将在我们的ApplicationAuthorize(GET)处理程序上强制执行:

func ServiceAuthorize(w http.ResponseWriter, r *http.Request) {

  params := mux.Vars(r)
  service := params["service"]

  loggedIn := CheckLogin()
 if loggedIn == false {
 redirect = url.QueryEscape("/authorize/" + service)
 http.Redirect(w, r, "/authorize?redirect="+redirect, http.StatusUnauthorized)
 return
 }

  redURL := OauthServices.GetAccessTokenURL(service, "")
  http.Redirect(w, r, redURL, http.StatusFound)

}

现在,如果用户尝试连接到一个服务,我们将检查是否存在登录,如果不存在,将用户重定向到我们的登录页面。以下是检查这一点的测试代码:

func CheckLogin(w http.ResponseWriter, r *http.Request) bool {
  cookieSession, err := r.Cookie("sessionid")
  if err != nil {
    fmt.Println("no such cookie")
    Session.Create()
    fmt.Println(Session.ID)
    currTime := time.Now()
    Session.Expire = currTime.Local()
    Session.Expire.Add(time.Hour)

    return false
  } else {
    fmt.Println("found cookki")
    tmpSession := UserSession{UID: 0}
    loggedIn := Database.QueryRow("select user_id from sessions where session_id=?", cookieSession).Scan(&tmpSession.UID)
    if loggedIn != nil {
      return false
    } else {
      if tmpSession.UID == 0 {
        return false
      } else {

        return true
      }
    }
  }
}

这是一个相当标准的测试,查找一个 cookie。如果不存在,创建一个Session结构并保存一个 cookie,并返回 false。否则,如果在成功登录后 cookie 已保存在数据库中,则返回 true。

这也依赖于一个新的全局变量,Session,它是新的结构类型UserSession

var Database *sql.DB
var Routes *mux.Router
var Format string
type UserSession struct {
 ID              string
 GorillaSesssion *sessions.Session
 UID             int
 Expire          time.Time
}

var Session UserSession

func (us *UserSession) Create() {
 us.ID = Password.GenerateSessionID(32)
}

目前,我们的登录页面存在问题,这只是为了允许第三方应用程序允许我们的用户授权其使用。我们可以通过简单地根据 URL 中是否看到consumer_keyredirect_url来设置auth_type变量来解决这个问题。在我们的authorize.html文件中,进行以下更改:

<input type="hidden" name="auth_type" value="{{.PageType}}" />

在我们的ApplicationAuthenticate()处理程序中,进行以下更改:

  if len(r.URL.Query()["consumer_key"]) > 0 {
    Authorize.ConsumerKey = r.URL.Query()["consumer_key"][0]
  } else {
    Authorize.ConsumerKey = ""
  }
  if len(r.URL.Query()["redirect"]) > 0 {
    Authorize.Redirect = r.URL.Query()["redirect"][0]
  } else {
    Authorize.Redirect = ""
  }

if Authorize.ConsumerKey == "" && Authorize.Redirect != "" {
  Authorize.PageType = "user"
} else {
  Authorize.PageType = "consumer"
}

这还需要修改我们的Page{}结构:

type Page struct {
  Title        string
  Authorize    bool
  Authenticate bool
  Application  string
  Action       string
  ConsumerKey  string
  Redirect     string
  PageType     string
}

如果我们收到来自Page类型用户的授权请求,我们将知道这只是一个登录尝试。如果来自客户端,我们将知道这是另一个应用程序尝试为我们的用户发出请求。

在前一种情况下,我们将利用重定向 URL 在成功认证后将用户带回来,假设登录成功。

Gorilla 提供了一个闪存消息;这本质上是一个一次性的会话变量,一旦被读取就会被删除。你可能能看到这在这里是有价值的。我们将在重定向到我们的连接服务之前设置闪存消息,然后在返回时读取该值,此时它将被处理掉。在我们的ApplicationAuthorize()处理程序函数中,我们区分客户端和用户登录。如果用户登录,我们将设置一个可以检索的闪存变量。

  if dbPassword == expectedPassword && allow == "1" && authType == "client" {

    requestToken := Pseudoauth.GenerateToken()

    authorizeSQL := "INSERT INTO api_tokens set application_user_id=" + appUID + ", user_id=" + dbUID + ", api_token_key='" + requestToken + "' ON DUPLICATE KEY UPDATE user_id=user_id"

    q, connectErr := Database.Exec(authorizeSQL)
    if connectErr != nil {

        } else {
      fmt.Println(q)
    }
    redirectURL := CallbackURL + "?request_token=" + requestToken
    fmt.Println(redirectURL)
    http.Redirect(w, r, redirectURL, http.StatusAccepted)

  }else if dbPassword == expectedPassword && authType == "user" {
    UserSession, _ = store.Get(r, "service-session")
        UserSession.AddFlash(dbUID)
    http.Redirect(w, r, redirect, http.StatusAccepted)
  }

但这样仅仅不能保持一个持久的会话,所以我们现在要整合这个。当在ApplicationAuthorize()方法中发生成功的登录时,我们将在我们的数据库中保存会话,并允许一些持久连接给我们的用户。

使用其他 OAuth 服务的数据

成功连接到另一个服务(或多个服务,取决于您引入了哪些 OAuth 提供程序),我们现在可以相互交叉使用多个服务。

例如,在我们的社交网络中发布状态更新也可能需要在 Facebook 上发布状态更新。

为此,让我们首先设置一个状态表:

CREATE TABLE `users_status` (
  `users_status_id` INT NOT NULL AUTO_INCREMENT,
  `user_id` INT(10) UNSIGNED NOT NULL,
  `user_status_timestamp` INT(11) NOT NULL,
  `user_status_text` TEXT NOT NULL,
  PRIMARY KEY (`users_status_id`),
  CONSTRAINT `status_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON UPDATE NO ACTION ON DELETE NO ACTION
)

我们的状态将包括用户的信息、时间戳和状态消息的文本。现在还没有太复杂的东西!

接下来,我们需要为创建、读取、更新和删除状态添加 API 端点。因此,在我们的api.go文件中,让我们添加这些:

func Init() {
  Routes = mux.NewRouter()
  Routes.HandleFunc("/interface", APIInterface).Methods("GET", "POST", "PUT", "UPDATE")
  Routes.HandleFunc("/api/users", UserCreate).Methods("POST")
  Routes.HandleFunc("/api/users", UsersRetrieve).Methods("GET")
  Routes.HandleFunc("/api/users/{id:[0-9]+}", UsersUpdate).Methods("PUT")
  Routes.HandleFunc("/api/users", UsersInfo).Methods("OPTIONS")
 Routes.HandleFunc("/api/statuses",StatusCreate).Methods("POST")
 Routes.HandleFunc("/api/statuses",StatusRetrieve).Methods("GET")
 Routes.HandleFunc("/api/statuses/{id:[0-9]+}",StatusUpdate).Methods("PUT")
 Routes.HandleFunc("/api/statuses/{id:[0-9]+}",StatusDelete).Methods("DELETE")
  Routes.HandleFunc("/authorize", ApplicationAuthorize).Methods("POST")
  Routes.HandleFunc("/authorize", ApplicationAuthenticate).Methods("GET")
  Routes.HandleFunc("/authorize/{service:[a-z]+}", ServiceAuthorize).Methods("GET")
  Routes.HandleFunc("/connect/{service:[a-z]+}", ServiceConnect).Methods("GET")
  Routes.HandleFunc("/oauth/token", CheckCredentials).Methods("POST")
}

现在,我们将为PUT/UpdateDELETE方法创建一些虚拟处理程序:

func StatusDelete(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w, "Nothing to see here")
}

func StatusUpdate(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w, "Coming soon to an API near you!")
}

请记住,如果没有这些,我们将无法进行测试,同时还会收到编译器错误。在下面的代码中,您将找到StatusCreate方法,该方法允许我们为已授予我们令牌的用户发出请求。由于我们已经有了一个用户,让我们创建一个状态:

func StatusCreate(w http.ResponseWriter, r *http.Request) {

  Response := CreateResponse{}
  UserID := r.FormValue("user")
  Status := r.FormValue("status")
  Token := r.FormValue("token")
  ConsumerKey := r.FormValue("consumer_key")

  vUID := ValidateUserRequest(ConsumerKey,Token)

我们将使用密钥和令牌的测试来获取一个有效的用户,该用户被允许进行这些类型的请求:

  if vUID != UserID {
    Response.Error = "Invalid user"
    http.Error(w, Response.Error, 401)
  } else  {
    _,inErr := Database.Exec("INSERT INTO users_status set user_status_text=?, user_id=?", Status, UserID)
    if inErr != nil {
      fmt.Println(inErr.Error())
      Response.Error = "Error creating status"
      http.Error(w, Response.Error, 500)
      fmt.Fprintln(w, Response)
    } else {
      Response.Error = "Status created"
      fmt.Fprintln(w, Response)
    }
  }

}

如果用户通过密钥和令牌确认为有效,则将创建状态。

使用其他 OAuth 服务的数据

通过对 OAuth 的一般工作原理有所了解,并且在我们的 API 中已经有了一个近似的、低门槛版本,我们可以开始允许外部服务请求访问我们的用户帐户,以代表个别用户在我们的服务中执行。

我们在上一章中简要提到了这一点,但让我们用它做一些有用的事情。

我们将允许来自另一个域的另一个应用程序向我们的 API 发出请求,以为我们的用户创建一个状态更新。如果您使用单独的 HTML 界面,类似于我们在早期章节中使用的界面或其他内容,您可以避免返回跨域资源共享头部时遇到的跨域策略问题。

为此,我们可以在我们的api.go文件顶部创建一个允许访问我们的 API 的域的切片,并返回Access-Control-Allow-Origin头部。

var PermittedDomains []string

然后,我们可以在我们的api.go文件的Init()函数中添加这些:

func Init(allowedDomains []string) {
 for _, domain := range allowedDomains {
 PermittedDomains = append(PermittedDomains,domain)
 }

Routes = mux.NewRouter()
Routes.HandleFunc("/interface", APIInterface).Methods("GET", "POST", "PUT", "UPDATE")

然后,我们可以从我们当前的v1版本的 API 中调用它们。因此,在v1.go中,在调用api.Init()时,我们需要调用域列表:

func API() {
  api.Init([]string{"http://www.example.com"})

最后,在任何处理程序中,您希望遵守这些域规则,都可以通过循环遍历这些域并设置相关的头部来添加:

func UserCreate(w http.ResponseWriter, r *http.Request) {

...
 for _,domain := range PermittedDomains {
 fmt.Println ("allowing",domain)
 w.Header().Set("Access-Control-Allow-Origin", domain)
  }

首先,让我们通过上述任一方法创建一个新用户 Bill Johnson。在这种情况下,我们将回到 Postman,直接向 API 发送请求:

使用其他 OAuth 服务的数据

创建新用户后,我们可以按照伪 OAuth 流程,允许 Bill Johnson 访问我们的应用程序并生成状态。

首先,我们使用我们的consumer_key值将用户传递给/authorize。在成功登录并同意允许应用程序访问用户数据后,我们将创建一个token_key值并将其传递到重定向 URL。

有了这个密钥,我们可以像以前一样通过向/api/statuses端点发布我们的密钥、用户和状态来以编程方式发出状态请求。

在 Go 中作为客户端进行安全连接

您可能会遇到这样的情况,即不得不自行进行安全请求,而不是使用 OAuth 客户端。通常,Go 中的http包将确保包含的证书是有效的,并且会阻止您进行测试。

package main

import
(
  "net/http"
  "fmt"
)

const (
  URL = "https://localhost/api/users"
)

func main() {

  _, err := http.Get(URL)
  if err != nil {

    fmt.Println(err.Error())
  }

}
type Client struct {
        // Transport specifies the mechanism by which individual
        // HTTP requests are made.
        // If nil, DefaultTransport is used.
        Transport RoundTripper

这使我们能够注入自定义的Transport客户端,从而覆盖错误处理;在通过浏览器与我们(或任何)API 的交互中,这不建议超出测试,并且可能会引入来自不受信任来源的安全问题。

package main

import
(
  "crypto/tls"
  "net/http"
  "fmt"
)

const (
  URL = "https://localhost/api/users"
)

func main() {

  customTransport := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true} }
  customClient := &http.Client{ Transport: customTransport }
  response, err := customClient.Get(URL)
  if err != nil {
    fmt.Println(err.Error())
  } else {
    fmt.Println(response)
  }

}

然后,我们会得到一个有效的响应(带有头部,在结构体中):

  &{200 OK 200 HTTP/1.1 1 1 map[Link:[<http://localhost:8080/api/users?start= ; rel="next"] Pragma:[no
  -cache] Date:[Tue, 16 Sep 2014 01:51:50 GMT] Content-Length:[256] Content-Type:[text/plain; charset=
  utf-8] Cache-Control:[no-cache]] 0xc084006800 256 [] false map[] 0xc084021dd0}

这只是在测试中最好使用的东西,因为当忽略证书时,连接的安全性显然可能是一个可疑的问题。

摘要

在上一章中,我们已经开始了第三方集成应用程序的初始步骤。在本章中,我们稍微看了一下客户端,以了解如何将一个干净简单的流程整合进去。

我们使用其他 OAuth 2.0 服务对用户进行身份验证,这使我们能够与其他社交网络共享信息。这是使社交网络对开发人员友好的基础。允许其他服务使用我们用户和其他用户的数据也为用户创造了更沉浸式的体验。

在下一章中,我们将探讨将 Go 与 Web 服务器和缓存系统集成,构建一个高性能和可扩展架构的平台。

在这个过程中,我们还将推动 API 的功能,这将允许更多的连接和功能。

第七章:与其他 Web 技术合作

在上一章中,我们看了我们的 Web 服务如何通过 API 或 OAuth 集成与其他 Web 服务良好地配合和集成。

继续这个思路,我们将在开发我们的社交网络服务周围的技术时停下来,看看我们如何还可以独立于其他服务集成其他技术。

很少有应用程序仅在一个语言、一个服务器类型或甚至一个代码集上运行。通常有多种语言、操作系统和多个进程的指定目的。您可能在 Ubuntu 上使用 Go 运行 Web 服务器,这是运行 PostgreSQL 的数据库服务器。

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

  • 通过反向代理来提供我们的 Web 流量,以利用成熟的 HTTP 产品提供的更高级功能

  • 连接到 NoSQL 或键/值数据存储,我们可以将其用作我们的核心数据提供程序,或者用它来进行辅助工作,如缓存

  • 为我们的 API 启用会话,并允许客户和用户在不再指定凭据的情况下发出请求

  • 允许用户通过添加其他用户到他们的网络来互相连接

当我们完成所有这些时,您应该对如何将您的 Web 服务与不同于 MySQL 的 NoSQL 和数据库解决方案连接有所了解。我们将在以后利用数据存储来在第十章最大化性能中提供性能提升。

您还希望熟悉一些处理 API 的开箱即用解决方案,能够将中间件引入您的 Web 服务,并能够利用消息传递在不和谐或分离的系统之间进行通信。

让我们开始看看我们可以如何连接到其他 Web 服务器,以将一些额外的功能和故障处理引入我们目前仅由 Go 的net/http包提供服务的服务中。

通过反向代理进行服务

Go 内部 HTTP 服务器最突出的功能之一可能也引发了立即的怀疑回应:如果使用 Go 轻松启动应用程序服务,那么它是否与 Web 服务相关的功能齐全呢?

这是一个可以理解的问题,特别是考虑到 Go 与解释脚本语言的相似性。毕竟,Ruby on Rails、Python、NodeJS,甚至 PHP 都带有开箱即用的简单 Web 服务器。由于它们在功能集、安全更新等方面的限制,很少有人建议将这些简单服务器用作生产级服务器。

话虽如此,Go 的http包对于许多生产级项目来说已经足够强大;然而,通过将 Go 与具有更成熟的 Web 服务器的反向代理集成,您可能不仅会发现一些缺失的功能,还会发现一些可靠性。

“反向代理”是一个错误的名称,或者至少是一种笨拙的方式来说明一个内部的、传入的代理,它将客户端请求不透明地通过一个系统路由到另一个服务器,无论是在同一台机器还是网络中。事实上,出于这个原因,它通常被简单地称为网关。

潜在的优势是多方面的。这些包括能够使用一个众所周知、得到充分支持、功能齐全的 Web 服务器(而不仅仅是在 Go 中构建自己的构建块)、拥有庞大的社区支持,以及拥有大量预构建的可用插件和工具。

是否有必要或有利,或者投资回报率如何,这取决于个人偏好和所处情况,但通常可以帮助记录和调试 Web 应用程序。

使用 Go 与 Apache

Apache 的 Web 服务器是 Web 服务器中的元老。自 1996 年首次发布以来,它迅速成为了一支坚实的力量,截至 2009 年,它已为超过 1 亿个网站提供服务。自诞生后不久,它一直是世界上最受欢迎的 Web 服务器,尽管一些估计将 Nginx 列为新的第一名(我们将在稍后谈一些关于这个的更多内容)。

将 Go 放在 Apache 后面非常容易,但有一个注意事项;Apache 默认安装时是一个阻塞的、非并发的 Web 服务器。这与 Go 不同,Go 将请求划分为 goroutines 或 NodeJS 甚至 Nginx。其中一些绑定到线程,一些没有。Go 显然没有绑定,这最终影响了服务器的性能。

首先,让我们在 Go 中创建一个简单的hello world Web 应用程序,我们将其称为proxy-me.go

package main

import (
        "fmt"
        "log"
        "net/http"
)

func ProxyMe(w http.ResponseWriter, r *http.Request) {

        fmt.Fprintln(w, "hello world")
}

func main() {
        http.HandleFunc("/hello", ProxyMe)
        log.Fatal(http.ListenAndServe(":8080", nil))
}

这里没有太复杂的东西。我们在端口 8080 上监听,并且有一个非常简单的路由/hello,它只是说hello world。要让 Apache 作为透传的反向代理提供此服务,我们编辑我们的默认服务器配置如下:

ProxyRequests Off
ProxyPreserveHost On

<VirtualHost *:80>

        ServerAdmin webmaster@localhost
        DocumentRoot /var/www/html

        ProxyPass /  http://localhost:8080/
        ProxyPassReverse /  http://localhost:8080/

        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined

</VirtualHost>

提示

默认服务器配置通常存储在 Linux 的/etc/apache2/sites-enabled/和 Windows 的[驱动器]:/[apache 安装目录]/conf/中。

我们可以通过查看对/hello路由的请求的标头来验证我们看到的页面是由 Apache 提供而不是直接通过 Go 提供的。

当我们这样做时,我们不仅会看到服务器是Apache/2.4.7,还会看到我们传递的自定义标头。通常,我们会为其他目的使用X-Forwarded-For标头,但这足够类似,可以用作演示,如下面的屏幕截图所示:

使用 Go 与 Apache

Go 和 NGINX 作为反向代理

尽管 Apache 是 Web 服务器的老大哥,但近年来,它在某些方面的受欢迎程度已被 Nginx 超越。

Nginx 最初是作为解决 C10K 问题的方法编写的——提供 1 万个并发连接。这并不是一个不可能的任务,但以前需要昂贵的解决方案来解决它。

由于 Apache 默认会生成新的线程和/或进程来处理新请求,它经常在重负载下挣扎。

另一方面,Nginx 设计为采用异步事件模型,不会为每个请求生成新进程。在许多方面,这使得它与 Go 在 HTTP 包中的并发工作方式互补。

与 Apache 一样,将 Nginx 放在 Go 之后的好处如下:

  • 它有访问和错误日志。这是您需要使用 Go 中的日志包构建的内容。虽然这很容易做到,但这是一个更少的麻烦。

  • 它具有非常快的静态文件服务。事实上,Apache 用户经常使用 Nginx 专门用于提供静态文件。

  • 它具有 SPDY 支持。SPDY 是一种新的、有些实验性的协议,它操纵 HTTP 协议引入了一些速度和安全功能。有一些尝试实现 Go 的 HTTP 和 TLS 包库用于 SPDY,但还没有在 net/HTTP 包中构建出来。

  • 它具有内置的缓存选项和流行缓存引擎的钩子。

  • 它具有将一些请求委托给其他进程的灵活性。

我们将在第十章最大化性能中直接讨论在 Nginx 和 Go 中使用 SPDY。

值得注意的是,异步、非阻塞和并发的 HTTP 服务几乎总是受到技术外部因素的限制,比如网络延迟、文件和数据库阻塞等。

考虑到这一点,让我们来看一下快速将 Nginx 作为反向代理而不是 Go 的设置。

Nginx 允许通过简单修改默认配置文件进行透传。Nginx 目前还没有对 Windows 的原生支持;因此,在大多数*nix 解决方案中,可以通过导航到/etc/nginx/sites-enabled找到该文件。

提示

或者,您可以通过在/etc/nginx/nginx.conf中的.conf文件中进行更改来全局代理。

让我们看一个样本 Nginx 配置操作,让我们代理我们的服务器。

server {
        listen 80 default_server;
        listen [::]:80 default_server ipv6only=on;
        root /usr/share/nginx/html;
        index index.html index.htm;

        # Make site accessible from http://localhost/
        server_name localhost;

        location / {
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $remote_addr;
                proxy_set_header Host $host;
                proxy_pass http://127.0.0.1:8080;
                #       try_files $uri $uri/ =404;

        }

有了这个修改,您可以通过运行/etc/init.d/nginx来启动 Nginx,然后通过go run proxy-me.go来启动 Go 服务器。

如果我们访问本地主机实现,我们将看到与上次请求的标头非常相似,但代理服务器是 Nginx 而不是 Apache:

Go 和 NGINX 作为反向代理

为 API 启用会话

大多数情况下,我们会为机器暴露 API。换句话说,我们期望一些应用程序将直接与我们的网络服务进行交互,而不是用户。

然而,情况并非总是如此。有时,用户直接或通过 JavaScript 与 JSONP 和/或 AJAX 请求等方式使用浏览器与 API 进行交互。

事实上,Web 2.0 美学的基本原则在于为用户提供无缝的、类似桌面的体验。这在今天已经实现,并包括许多处理表示层的 JavaScript MVC 框架。我们将在下一章中解决这个问题。

Web 2.0 这个术语已经基本被取代,现在通常被称为单页应用SPA。曾经是一种混合了服务器生成(或提供)HTML 页面和一些通过 XML 和 JavaScript 构建或更新的部分,现在已经让位给了构建整个客户端应用程序的 JavaScript 框架。

这些几乎都依赖于底层 API,通常通过 HTTP/HTTPS 进行无状态请求访问,尽管一些较新的模型使用 Web 套接字来实现服务器和表示模型之间的实时通信。这也是我们将在下一章中看到的内容。

无论模型如何,您都不能简单地将此 API 暴露给世界而不进行一些身份验证。例如,如果 API 可以在没有身份验证的情况下从/admin请求访问,那么它很可能也可以从外部访问。您不能依赖用户的信息,比如 HTTP 引用者。

提示

语法学家可能会注意到上一句中引用者的拼写错误。然而,这不是一个打字错误。在最初的 HTTP 请求评论提案中,该术语的拼写中没有双* r*,自那时以来它基本上一直保持不变。

然而,当用户在每个页面上进行多次请求时,依赖每个 OAuth 请求就有些过度了。您可以在本地存储或 cookie 中缓存令牌,但前者的浏览器支持仍然有限,后者会限制令牌的撤销。

这方面的一个传统而简单的解决方案是允许基于 cookie 的身份验证会话。您可能仍然希望为主应用程序之外的访问开放 API,以便可以通过 API 密钥或 OAuth 进行身份验证,但它还应该允许用户直接使用客户端工具与其进行交互,以提供清晰的 SPA 体验。

RESTful 设计中的会话

值得注意的是,因为会话通常强制执行某种状态,它们并不被认为是 RESTful 设计的一部分。然而,也可以说会话仅用于身份验证而不是状态。换句话说,身份验证和会话 cookie 可以被单独用作验证身份的方法。

当然,您也可以通过在每个安全请求中传递用户名和密码来实现这一点。这本身并不是一种不安全的做法,但这意味着用户需要在每个请求中提供这些信息,或者这些信息需要被本地存储。这就是存储在 cookie 中的会话试图解决的问题。

正如前面提到的,这永远不会适用于第三方应用程序,因为它们大部分需要某种易于撤销的密钥来工作,很少有用户名和密码(尽管我们的用户名和密码与用户绑定,所以从技术上讲也有)。

最简单的方法是允许用户名和密码直接进入 URL 请求,有时你可能会看到这种情况。这里的风险是,如果用户意外地分享了完整的 URL,数据将会被泄露。事实上,这在新手 GitHub 用户中经常发生,因为可能会自动推送包含 GitHub 密码的配置文件。

为了减少这种风险,我们应该要求用户名和密码通过标头字段传递,尽管它仍然是明文的。假设一个可靠的 TSL(或 SSL)选项已经就位,请求标头中的明文并不是一个固有的问题,但如果应用程序随时可以切换到(或被访问到)不安全的协议,这可能会成为一个问题。这是一个有时间限制的令牌系统试图解决的问题。

我们可以将会话数据存储在任何地方。我们的应用目前使用的是 MySQL,但会话数据将经常被读取。因此,在数据库中存储几乎没有关系信息的信息并不理想。

记住,我们将存储一个活跃用户,他们会话的开始时间,最后更新时间(每次请求都会更改),以及他们在应用程序中的位置。我们的应用程序可以使用这些信息来告诉用户他们的朋友目前在我们的社交网络中做什么。

考虑到这些条件,依赖我们的主要数据存储并不是一个理想的解决方案。我们想要的是更加短暂、更快速、更具并发性的东西,可以在不影响我们的数据存储的情况下处理许多连续的请求。

如今处理会话的最流行解决方案之一是将关系数据库转移到包括文档和列存储或键值数据存储在内的 NoSQL 解决方案中。

在 Go 中使用 NoSQL

很久以前,数据存储和检索的世界几乎完全被限制在关系数据库的领域。在我们的应用程序中,我们使用的是 MySQL,主要是因为它一直是快速应用程序的通用语言,而且 SQL 在类似的数据库(如微软的 SQL Server、PostgreSQL、Oracle 等)之间相对容易转换。

然而,近年来,对 NoSQL 进行了大力推动。更准确地说,推动的是依赖于典型关系数据库结构和模式较少的数据存储解决方案,而更多地依赖于高性能的键值存储。

键值存储正是任何使用关联数组、哈希和映射(在 Go 中)的人所期望的,即与一个键相关联的一些任意数据。许多这些解决方案非常快,因为它们缺乏索引关系、减少了锁定,并且不太强调一致性。事实上,许多解决方案在开箱即用时不保证 ACID 性(但一些提供了可选的使用方法)。

注意

ACID指的是开发人员在数据库应用程序中期望的属性。在任何给定的 NoSQL 或键值数据存储解决方案中,这些属性可能有一些或全部缺失或是可选参数。ACID这个术语可以详细解释如下:

  • 原子性:这表示事务的所有部分必须成功才能成功

  • 一致性:这指的是事务完成之前,数据库在事务开始时的状态不会发生变化

  • 隔离性:这指的是防止访问处于事务状态的数据的表或行锁定机制

  • 持久性:这确保了成功的事务可以并且将在系统或应用程序故障时幸存

NoSQL 解决方案可以用于许多不同的事情。它们可以直接替代 SQL 服务器。它们可以用一些需要较少一致性的数据来补充数据。它们可以作为快速可访问的、自动过期的缓存结构。我们稍后会看到这一点。

如果您选择在应用程序中引入 NoSQL 解决方案,请考虑这可能给您的应用程序带来的潜在影响。例如,您可以考虑 ACID 属性的潜在权衡是否会被新解决方案提供的性能提升和水平可扩展性所抵消。

虽然几乎所有的 SQL 或传统关系数据库解决方案都与 Go 的database/sql包有一些集成,但对于需要某种包装器的键值存储来说,情况并非总是如此。

现在,我们将简要介绍一些最受欢迎的键值存储解决方案,当我们在下一节讨论缓存时,我们将回来使用 NoSQL 作为基本缓存解决方案。

注意

尽管最近有所复苏,但 NoSQL 并不是一个新概念。根据定义,任何避开 SQL 或关系数据库概念的东西都可以称为 NoSQL,自上世纪 60 年代以来就有数十种这样的解决方案。可能需要提到的是,我们不会花时间在这些解决方案上——比如 Ken Thompson 的 DBM 或 BerkeleyDB——而是更现代的故事。

在我们开始探索各种 NoSQL 解决方案来处理会话之前,让我们通过提供替代的用户名/密码身份验证来在我们的应用程序中启用它们。

您可能还记得当我们启用了第三方身份验证代理时,我们在CheckLogin()函数中启用了会话并将它们存储在我们的 MySQL 数据库中。这个函数只会在对ApplicationAuthorize函数的POST请求的响应中调用。我们将扩展到更多的方法。首先,让我们创建一个新函数叫做CheckSession(),如果它不存在的话,它将验证 cookie 的会话 ID,然后根据我们的会话存储进行验证:

func CheckSession(w http.ResponseWriter, r *http.Request) bool {

}

您可能还记得我们在api.go中有一个基本的会话结构和一个方法。我们也将把这些移到会话中:

var Session UserSession

这个命令变成了以下内容:

var Session Sessions.UserSession

为了创建我们的会话存储,我们将在 API 的子目录/会话中创建一个名为sessions.go的新包。这是一个没有任何 NoSQL 特定方法的骨架:

package SessionManager

import
(
  "log"
  "time"
  "github.com/gorilla/sessions"
  Password "github.com/nkozyra/api/password"
)

var Session UserSession

type UserSession struct {
  ID              string
  GorillaSesssion *sessions.Session
  UID             int
  Expire          time.Time
}

func (us *UserSession) Create() {
  us.ID = Password.GenerateSessionID(32)
}

type SessionManager struct {

}

func GetSession() {

  log.Println("Getting session")
}

func SetSession() {

  log.Println("Setting session")
}

让我们看一些与 Go 有强大第三方集成的简单 NoSQL 模型,以便检查我们如何保持这些会话分离,并以一种使客户端可以安全访问我们的 API 的方式启用它们。

Memcached

我们将从 Memcached 开始,特别是因为它不像我们的其他选择那样真正是一个数据存储。虽然从某种意义上说它仍然是一个键值存储,但它是一个维护数据仅在内存中的通用缓存系统。

由 Brad Fitzpatrick 为曾经非常流行的 LiveJournal 网站开发,旨在减少对数据库的直接访问量,这是 Web 开发中最常见的瓶颈之一。

Memcached 最初是用 Perl 编写的,但后来被重写为 C,并且已经达到了大规模使用的程度。

这些的优缺点已经显而易见——您可以获得内存的速度,而不会受到磁盘访问的拖累。这显然是巨大的,但它排除了使用应该是一致和容错的数据而不经过一些冗余处理。

因此,它非常适合缓存呈现层和会话的片段。会话本来就是短暂的,而 Memcached 的内置过期功能允许您为任何单个数据设置最大年龄。

也许 Memcached 最大的优势是它的分布式特性。这允许多个服务器在网络中共享内存值的数据。

注意

值得注意的是,Memcached 作为先进先出系统运行。过期只是为了编程目的而必要。换句话说,除非您需要在特定时间过期,否则没有必要强制设置最大年龄。

api.go文件中,我们将检查一个 cookie 是否与我们的 Memcached 会话代理匹配,或者我们将创建一个会话:

func CheckSession(w http.ResponseWriter, r *http.Request) bool {
  cookieSession, err := r.Cookie("sessionid")
  if err != nil {
    fmt.Println("Creating Cookie in Memcache")
    Session.Create()
    Session.Expire = time.Now().Local()
    Session.Expire.Add(time.Hour)
    Session.SetSession()
  } else {
    fmt.Println("Found cookie, checking against Memcache")
    ValidSession,err := Session.GetSession(cookieSession.Value)
    fmt.Println(ValidSession)
    if err != nil {
      return false
    } else {
      return true
    }

  }
  return true
}

然后,这是我们的sessions.go文件:

package SessionManager

import
(
  "encoding/json"
  "errors"
  "time"
  "github.com/bradfitz/gomemcache/memcache"
  "github.com/gorilla/sessions"	
  Password "github.com/nkozyra/api/password"	

)

var Session UserSession

type UserSession struct {
  ID              string `json:"id"`
  GorillaSesssion *sessions.Session `json:"session"`
  SessionStore  *memcache.Client `json:"store"`
  UID             int `json:"uid"`
  Expire          time.Time `json:"expire"`
}

func (us *UserSession) Create() {
  us.SessionStore = memcache.New("127.0.0.1:11211")
  us.ID = Password.GenerateSessionID(32)
}

func (us *UserSession) GetSession(key string) (UserSession, error) {
  session,err := us.SessionStore.Get(us.ID)
  if err != nil {
    return UserSession{},errors.New("No such session")
  } else {
    var tempSession = UserSession{}
    err := json.Unmarshal(session.Value,tempSession)
    if err != nil {

    }
    return tempSession,nil
  }
}

GetSession()尝试通过键获取会话。如果它存在于内存中,它将直接将其值传递给引用的UserSession。请注意,在验证以下代码中的会话时,我们进行了一些微小的更改。我们将 cookie 的到期时间增加了一个小时。这是可选的,但如果用户在最后一次操作后一个小时离开,它允许会话保持活动状态:

func (us *UserSession) SetSession() bool {
  jsonValue,_ := json.Marshal(us)
  us.SessionStore.Set(&memcache.Item{Key: us.ID, Value: []byte(jsonValue)})
  _,err := us.SessionStore.Get(us.ID)
  if err != nil {
      return false
  }
    Session.Expire = time.Now().Local()
    Session.Expire.Add(time.Hour)
    return true
}

注意

Brad Fitzpatrick 已经加入了 Google 的 Go 团队,因此他在 Go 中编写了一个 Memcached 实现应该不足为奇。同样,这也不足为奇,这是我们在这个示例中将使用的实现。

您可以在github.com/bradfitz/gomemcache了解更多信息,并使用go get github.com/bradfitz/gomemcache/memcache命令进行安装。

MongoDB

MongoDB 是后来 NoSQL 解决方案中较早的大名鼎鼎的一个;它是一个依赖于具有开放式模式的类 JSON 文档的文档存储。Mongo 的格式称为 BSON,即二进制 JSON。因此,可以想象,这打开了一些不同的数据类型,即 BSON 对象和 BSON 数组,它们都以二进制数据而不是字符串数据存储。

注意

您可以在bsonspec.org/了解有关二进制 JSON 格式的更多信息。

作为超集,BSON 不会提供太多的学习曲线,而且我们也不会使用二进制数据进行会话存储,但在某些情况下存储数据是有用且节省的。例如,在 SQL 数据库中的 BLOB 数据。

近年来,随着更新、功能更丰富的 NoSQL 解决方案的出现,MongoDB 已经赢得了一些批评者,但您仍然可以欣赏和利用它提供的简单性。

有一些不错的 MongoDB 和 Go 包,但最成熟的是 mgo。

注意

  • 有关 MongoDB 的更多信息和下载链接,请访问www.mongodb.org/

  • mgo 可以在labix.org/mgo找到,并且可以使用go get gopkg.in/mgo.v2命令进行安装

MongoDB 没有内置的图形用户界面,但有许多第三方界面,其中很多是基于 HTTP 的。在这里,我会推荐 Genghis (genghisapp.com/),它只使用一个文件,可以用于 PHP 或 Ruby。

让我们看看如何从身份验证跳转到使用 Mongo 进行会话存储和检索。

我们将用另一个示例取代我们之前的示例。创建第二个文件和另一个名为sessions2.go的包子目录。

在我们的api.go文件中,将导入调用从Sessions "github.com/nkozyra/api/sessions"更改为Sessions "github.com/nkozyra/api/sessionsmongo"

我们还需要用 mgo 版本替换"github.com/bradfitz/gomemcache/memcache"的导入,但由于我们只是修改存储平台,大部分内容仍然保持不变:

package SessionManager

import
(
  "encoding/json"
  "errors"

  "log"
  "time"
  mgo "gopkg.in/mgo.v2"
  _ "gopkg.in/mgo.v2/bson"
  "github.com/gorilla/sessions"
  Password "github.com/nkozyra/api/password"

)

var Session UserSession

type UserSession struct {
  ID              string `bson:"_id"`
  GorillaSesssion *sessions.Session `bson:"session"`
  SessionStore  *mgo.Collection `bson:"store"`
  UID             int `bson:"uid"`
  Value         []byte `bson:"Valid"`
  Expire          time.Time `bson:"expire"`
}

在这种情况下,我们结构的重大变化是将我们的数据设置为 BSON 而不是字符串文字属性中的 JSON。这实际上并不重要,它仍然可以与json属性类型一起使用。

func (us *UserSession) Create() {
 s, err := mgo.Dial("127.0.0.1:27017/sessions")
  defer s.Close()
  if err != nil {
    log.Println("Can't connect to MongoDB")
 } else {
 us.SessionStore = s.DB("sessions").C("sessions")
  }
  us.ID = Password.GenerateSessionID(32)
}

我们的连接方法显然会发生变化,但我们还需要在一个集合中工作(这类似于数据库术语中的表),因此我们连接到我们的数据库,然后连接到两者都命名为session的集合:

func (us *UserSession) GetSession(key string) (UserSession, error) {
  var session UserSession
  err := us.SessionStore.Find(us.ID).One(session)
  if err != nil {
    return UserSession{},errors.New("No such session")
  } 
    var tempSession = UserSession{}
    err := json.Unmarshal(session.Value,tempSession)
    if err != nil {

    }
    return tempSession,nil

}

GetSession()的工作方式几乎完全相同,除了数据存储方法被切换为Find()mgo.One()函数将单个文档(行)的值分配给一个接口。

func (us *UserSession) SetSession() bool {
  jsonValue,_ := json.Marshal(us)
 err := us.SessionStore.Insert(UserSession{ID: us.ID, Value: []byte(jsonValue)})
  if err != nil {
      return false
  } else {
    return true
  }
}

使用用户名和密码启用连接

为了允许用户输入他们自己的连接的用户名和密码,而不是依赖令牌或者开放 API 端点,我们可以创建一个可以直接调用到任何特定函数中的中间件。

在这种情况下,我们将进行几次身份验证。这是/api/users GET 函数中的一个例子,它之前是开放的:

  authenticated := CheckToken(r.FormValue("access_token"))

  loggedIn := CheckLogin(w,r)
  if loggedIn == false {
    authenticated = false
    authenticatedByPassword := MiddlewareAuth(w,r)
    if authenticatedByPassword == true {
        authenticated = true
    }
  } else {
    authenticated = true
  }

  if authenticated == false {
    Response := CreateResponse{}
    _, httpCode, msg := ErrorMessages(401)
    Response.Error = msg
    Response.ErrorCode = httpCode
    http.Error(w, msg, httpCode)
   return 
  }

您可以在这里看到我们所做的通行证。首先,我们检查令牌,然后检查现有会话。如果不存在,我们检查登录用户名密码并验证它们。

如果这三个都失败了,那么我们返回一个未经授权的错误。

现在,我们在代码的另一个部分中已经有了MiddlewareAuth()函数,名为ApplicationAuthorize(),所以让我们把它移动一下:

func MiddlewareAuth(w http.ResponseWriter, r *http.Request) (bool, int) {

  username := r.FormValue("username")
  password := r.FormValue("password")

  var dbPassword string
  var dbSalt string
  var dbUID string

  uerr := Database.QueryRow("SELECT user_password, user_salt, user_id from users where user_nickname=?", username).Scan(&dbPassword, &dbSalt, &dbUID)
  if uerr != nil {

  }

  expectedPassword := Password.GenerateHash(dbSalt, password)

  if (dbPassword == expectedPassword) {
    return true, dbUID
  } else {
    return false, 0
  }
}

如果用户通过GET方法访问/api/users端点,现在他们将需要一个用户名密码组合,一个access_token,或者在 cookie 数据中有一个有效的会话。

在有效的身份验证时,我们还返回预期的user_id,否则将返回值为 0。

允许我们的用户相互连接

让我们退一步,为我们的应用程序添加一些社交网络特有的功能——创建连接的能力,比如加好友。在大多数社交网络中,这将授予与好友相连的数据的读取权限。

由于我们已经有一个有效的视图来查看用户,我们可以创建一些新的路由来允许用户发起连接。

首先,让我们在api.go文件的Init()函数中添加一些端点:

for _, domain := range allowedDomains {
  PermittedDomains = append(PermittedDomains, domain)
}
Routes = mux.NewRouter()
Routes.HandleFunc("/interface", APIInterface).Methods("GET", "POST", "PUT", "UPDATE")
Routes.HandleFunc("/api/users", UserCreate).Methods("POST")
Routes.HandleFunc("/api/users", UsersRetrieve).Methods("GET")
Routes.HandleFunc("/api/users/{id:[0-9]+}", UsersUpdate).Methods("PUT")
Routes.HandleFunc("/api/users", UsersInfo).Methods("OPTIONS")
Routes.HandleFunc("/api/statuses", StatusCreate).Methods("POST")
Routes.HandleFunc("/api/statuses", StatusRetrieve).Methods("GET")
Routes.HandleFunc("/api/statuses/{id:[0-9]+}", StatusUpdate).Methods("PUT")
Routes.HandleFunc("/api/statuses/{id:[0-9]+}", StatusDelete).Methods("DELETE")
Routes.HandleFunc("/api/connections", ConnectionsCreate).Methods("POST")
Routes.HandleFunc("/api/connections", ConnectionsDelete).Methods("DELETE")
Routes.HandleFunc("/api/connections", ConnectionsRetrieve).Methods("GET")

注意

请注意,我们这里没有PUT请求方法。由于我们的连接是友谊和二进制的,它们不会被更改,但它们将被创建或删除。例如,如果我们添加一个阻止用户的机制,我们可以将其创建为一个单独的连接类型,并允许对其进行更改。

让我们设置一个数据库表来处理这些:

CREATE TABLE IF NOT EXISTS `users_relationships` (
  `users_relationship_id` int(13) NOT NULL,
  `from_user_id` int(10) NOT NULL,
  `to_user_id` int(10) NOT NULL,
  `users_relationship_type` varchar(10) NOT NULL,
  `users_relationship_timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `users_relationship_accepted` tinyint(1) NOT NULL DEFAULT '0',
  PRIMARY KEY (`users_relationship_id`),
  KEY `from_user_id` (`from_user_id`),
  KEY `to_user_id` (`to_user_id`),
  KEY `from_user_id_to_user_id` (`from_user_id`,`to_user_id`),
  KEY `from_user_id_to_user_id_users_relationship_type` (`from_user_id`,`to_user_id`,`users_relationship_type`)
)

有了这个设置,我们现在可以复制我们用来确保用户对我们的/api/connections POST方法进行身份验证的代码,并允许他们发起好友请求。

让我们看一下ConnectionsCreate()方法:

func ConnectionsCreate(w http.ResponseWriter, r *http.Request) {
  log.Println("Starting retrieval")
  var uid int
  Response := CreateResponse{}
  authenticated := false
  accessToken := r.FormValue("access_token")
  if accessToken == "" || CheckToken(accessToken) == false {
    authenticated = false
  } else {
    authenticated = true
  }

  loggedIn := CheckLogin(w,r)
  if loggedIn == false {
    authenticated = false
    authenticatedByPassword,uid := MiddlewareAuth(w,r)
    if authenticatedByPassword == true {
        fmt.Println(uid)
        authenticated = true
    }
  } else {
    uid = Session.UID
    authenticated = true
  }

  if authenticated == false {

    _, httpCode, msg := ErrorMessages(401)
    Response.Error = msg
    Response.ErrorCode = httpCode
    http.Error(w, msg, httpCode)
    return
  }

这与我们的/api/users GET函数的代码相同。在查看完整示例之后,我们将回到这里。

  toUID := r.FormValue("recipient")
  var count int
  Database.QueryRow("select count(*) as ucount from users where user_id=?",toUID).Scan(&count)

  if count < 1 {
    fmt.Println("No such user exists")
    _, httpCode, msg := ErrorMessages(410)
    Response.Error = msg
    Response.ErrorCode = httpCode
    http.Error(w, msg, httpCode)
    return

在这里,我们检查是否存在用户。如果我们试图连接到一个不存在的用户,我们返回一个 410:Gone 的 HTTP 错误。

  } else {
    var connectionCount int
    Database.QueryRow("select count(*) as ccount from users_relationships where from_user_id=? and to_user_id=?",uid, toUID).Scan(&connectionCount)
    if connectionCount > 0 {
      fmt.Println("Relationship already exists")
      _, httpCode, msg := ErrorMessages(410)
            Response.Error = msg
      Response.ErrorCode = httpCode
      http.Error(w, msg, httpCode)
      return

在这里,我们检查是否已经发起了这样的请求。如果是,我们还会传递一个 Gone 引用错误。如果没有满足这些错误条件中的任何一个,那么我们可以创建一个关系:

    } else {
      fmt.Println("Creating relationship")
      rightNow := time.Now().Unix()
      Response.Error = "success"
      Response.ErrorCode = 0
      _,err := Database.Exec("insert into users_relationships set from_user_id=?, to_user_id=?, users_relationship_type=?, users_relationship_timestamp=?",uid, toUID, "friend", rightNow)
      if err != nil {
        fmt.Println(err.Error())
      } else {
        output := SetFormat(Response)
        fmt.Fprintln(w, string(output))
      }
    }
  }
}

成功调用后,我们在认证用户和目标用户之间创建一个待处理的用户关系。

您可能已经注意到了这个函数中的代码重复。这通常是通过中间件解决的,Go 有一些可用的选项可以在这个过程中注入。在下一章中,我们将看一些框架和包,它们也可以帮助构建我们自己的中间件。

总结

现在我们有了一个功能齐全的社交网络,可以通过强制 TLS 的 Web 服务进行访问,用户可以进行身份验证,并且可以与其他用户进行交互。

在本章中,我们还研究了将会话管理转移到 NoSQL 数据库,并使用其他 Web 服务器代替 Go 来提供额外的功能和故障转移保护。

在下一章中,我们将进一步完善我们的社交网络,尝试从客户端与我们的 API 进行交互。有了这个基础,我们可以让用户直接通过客户端界面进行身份验证和与 API 进行交互,而不需要 API 令牌,同时保留使用第三方令牌的能力。

我们还将研究如何使用 Go 与补充的前端框架,比如 Go 和 Meteor,以提供更具响应性、类似应用的网络界面。

第八章:Web 的响应式 Go

如果您花费了任何时间在 Web 上(或者无论如何),开发应用程序,您很快就会发现自己面临从网站内部与 API 进行交互的前景。

在本章中,我们将通过允许浏览器直接通过一些技术作为我们的 Web 服务的传导器来弥合客户端和服务器之间的差距,其中包括谷歌自己的 AngularJS。

在本书的前面,我们为我们的 API 创建了一个临时的客户端接口。这几乎完全是为了通过一个简单的界面查看我们的 Web 服务的细节和输出而存在的。

然而,重要的是要记住,处理 API 的不仅是机器,还有由用户直接发起的客户端接口。因此,我们将考虑以这种格式应用我们自己的 API。我们将通过域名锁定并启用 RESTful 和非 RESTful 属性,使网站能够响应(不一定是移动意义上的响应),并且仅通过使用 HTML5 功能的 API 进行操作。

在本章中,我们将研究:

  • 使用像 jQuery 和 AngularJS 这样的客户端框架与我们的服务器端端点相结合

  • 使用服务器端框架创建 Web 界面

  • 允许我们的用户通过 Web 界面登录,查看其他用户,创建连接并发布消息到我们的 API

  • 扩展我们的 Web 服务的功能,并将其扩展为允许通过我们将在 Go 中构建的接口直接访问

  • 使用 HTML5 和几个 JavaScript 框架来补充我们的 Go 服务器端框架

创建前端界面

在开始之前,我们需要解决浏览器限制客户端到服务器信息流的一些问题。

我们还需要创建一个与我们的 API 一起工作的示例站点。最好在本地主机上的不同端口或另一台机器上进行,因为仅使用file://访问就会遇到额外的问题。

提示

为了构建 API,与之前的简单演示一样,将接口与 API 捆绑在一起是完全不必要的。

实际上,这可能会在 Web 服务增长时引入混乱。在这个例子中,我们将单独构建我们的界面应用程序,并在端口 444 上运行它。您可以选择任何可用的端口,假设它不会干扰我们的 Web 服务(443)。请注意,在许多系统上,访问端口 1024 及以下需要root/sudo

如果我们尝试在与我们的安全 Web 服务不同的端口上运行接口,我们将遇到跨域资源共享问题。确保我们为客户端和/或 JavaScript 消耗公开的任何端点方法都包括一个Access-Control-Allow-Origin头。

注意

您可以在developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS上阅读有关Access-Control-Allow-Origin的性质和机制的更多信息。

您可能会诱惑只是使用*通配符,但这将导致许多浏览器问题,特别是我们将要研究的前端框架。例如,让我们看看如果我们尝试通过GET访问/api/users端点会发生什么:

创建前端界面

结果可能不可靠,一些框架完全拒绝通配符。使用通配符还会禁用一些您可能感兴趣的关键功能,例如 cookies。

您可以看到我们用来尝试访问 Web 服务以引发此错误的以下代码。该代码是用 Angular 构建的,我们将很快更详细地研究它:

<html>
<head>
  <title>CORS Test</title>
  <script src="img/angular.js"></script>
  <script src="img/angular-route.min.js"></script>
  <script>
    var app = angular.module('testCORS', ['ngRoute']);
    app.controller('testWildcard', ['$scope', '$http', '$location', '$routeParams', function($scope,$http,$location,$routeParams) {
      $scope.messageFromAPI = '';
      $scope.users = [];
      $scope.requestAPI = function() {
        $http.get("https://localhost/api/users").success(function(data,status,headers,config) {
          angular.forEach(data.users, function(val,key) {
          $scope.users.push({name: val.Name});
    })
  });

在这里,我们正在向我们的 API 端点发出GET请求。如果成功,我们将将用户添加到$scope.users数组中,该数组将通过 AngularJS 循环进行迭代,如下所示。如果我们的客户端没有域来源允许,由于浏览器中的跨域政策,这将失败:

      };

      $scope.requestAPI();

    }]);
  </script>
</head>
<body ng-app="testCORS">

  <div ng-controller="testWildcard">
    <h1 ng-model="messageFromAPI">Users</h1>
    <div ng-repeat="user in users">
      {{user.name}}
    </div>

这是 AngularJS 处理循环的方式,允许您指定一个与特定于 DOM 的变量或循环直接关联的 JavaScript 数组。

  </div>
</body>
</html>

在这个例子中,由于权限问题,我们将得到零个用户。

幸运的是,我们之前在应用程序中通过在v1.go文件中引入了一个非常高级的配置设置来解决了这个问题:

  api.Init([]string{"http://www.example.com","http://www.mastergoco.com","http://localhost"})

您可能还记得Init()函数接受一个允许的域名数组,然后我们可以设置Access-Control-Allow-Origin头:

func Init(allowedDomains []string) {
  for _, domain := range PermittedDomains {
    fmt.Println("allowing", domain)
    w.Header().Set("Access-Control-Allow-Origin", domain)
  }

如前所述,如果我们设置一个*通配符域,一些浏览器和库会产生分歧,通配符来源会导致无法设置 cookie 或遵守 SSL 凭证的能力。我们可以更明确地指定域:

requestDomain := r.Header.Get("Origin")
if requestDomain != "" {
  w.Header.Set("Access-Control-Allow-Origin", requestDomain)
}

这使您能够保留 cookie 和 SSL 证书的设置,这些设置遵守了非通配符访问控制头的方面。这确实会带来一些与 cookie 相关的安全问题,因此您必须谨慎使用。

如果此循环在通过网络界面可访问的任何函数中被调用,它将防止跨域问题。

登录

与以前一样,我们将使用 Twitter 的 Bootstrap 作为基本的 CSS 框架,这使我们能够快速复制一个我们可能在任何地方在线看到的站点结构。

请记住,我们之前的例子打开了一个登录界面,只是将一个令牌传递给第三方,以便允许该应用程序代表我们的用户执行操作。

由于我们现在试图允许用户直接通过我们的 API(通过浏览器通道)进行接口,我们可以改变操作方式,允许会话作为认证方法。

以前,我们是直接通过 JavaScript 将登录请求发布到 API 本身,但现在我们使用完整的网络界面,没有理由这样做;我们可以直接发布到网络界面本身。这主要意味着放弃onsubmit="return false"onsubmit="userCreate();"方法,只需将表单数据发送到/interface/login

func Init(allowedDomains []string) {
  for _, domain := range allowedDomains {
   PermittedDomains = append(PermittedDomains, domain)
  }
  Routes = mux.NewRouter()
  Routes.HandleFunc("/interface", APIInterface).Methods("GET", "POST", "PUT", "UPDATE")
  Routes.HandleFunc("/interface/login", APIInterfaceLogin).Methods("GET")
  Routes.HandleFunc("/interface/login", APIInterfaceLoginProcess).Methods("POST")
  Routes.HandleFunc("/interface/register", APIInterfaceRegister).Methods("GET")
  Routes.HandleFunc("/interface/register", APIInterfaceRegisterProcess).Methods("POST")

这为我们提供了足够的内容,允许网络界面利用现有代码创建和登录到我们的帐户,同时仍然通过 API 进行。

使用 Go 的客户端框架

虽然我们在本书的大部分时间里构建了一个后端 API,但我们也一直在构建一个相对可扩展的基本服务器端框架。

当我们需要从客户端访问 API 时,我们受到 HTML、CSS 和 JavaScript 的限制。或者,我们可以作为消费者在服务器端呈现页面,并且我们也将在本章中展示这一点。

然而,大多数现代网络应用程序在客户端上运行,通常是在单页应用程序SPA中。这试图减少用户必须进行的“硬”页面请求的数量,使站点看起来不太像一个应用程序,而更像是一组文档。

这主要是通过异步 JavaScript 数据请求完成的,它允许 SPA 在响应用户操作时重新绘制页面。

起初,这种方法有两个主要缺点:

  • 首先,应用程序状态没有得到保留,因此如果用户采取行动并尝试重新加载页面,应用程序将重置。

  • 其次,基于 JavaScript 的应用在搜索引擎优化方面表现非常糟糕,因为传统的网络爬虫无法渲染 JavaScript 应用程序。它只会渲染原始的 HTML 应用程序。

但最近,一些标准化和技巧已经帮助减轻了这些问题。

在状态上,SPAs 已经开始利用 HTML5 中的一个新功能,使它们能够在浏览器中修改地址栏和/或历史记录,而无需重新加载,通常是通过使用内联锚点。您可以在 Gmail 或 Twitter 的 URL 中看到这一点,它可能看起来像mail.google.com/mail/u/0/#inbox/1494392317a0def6

这使用户能够通过 JavaScript 控制器分享或收藏 URL。

在 SEO 方面,这在很大程度上将 SPAs 局限于管理类型的界面或搜索引擎可访问性不是关键因素的领域。然而,随着搜索引擎开始解析 JavaScript,窗口已经打开,可以广泛使用而不会对 SEO 产生负面影响。

jQuery

如果你做任何前端工作或查看过地球上最流行的网站之一的源代码,那么你一定遇到过 jQuery。

根据 SimilarTech 的数据,jQuery 被大约 6700 万个网站使用。

jQuery 作为一种标准化 API 的方法发展起来,其中一致性曾经是一项几乎不可能的任务。在微软的 Internet Explorer 和各种程度上坚持标准的浏览器之间,编写跨浏览器代码曾经是一件非常复杂的事情。事实上,以前经常会看到这个网站最好使用标签来查看,因为即使使用了任何给定浏览器的最新版本,也无法保证功能。

当 jQuery 开始流行(在 Prototype、Moo Tools 和 Dojo 等其他类似框架之后),Web 开发领域终于找到了一种方法,可以使用单一接口覆盖大多数现有的现代 Web 浏览器。

使用 jQuery 消耗 API

使用 jQuery 处理我们的 API 非常简单。当 jQuery 开始出现时,AJAX 的概念真的开始流行起来。AJAX异步 JavaScriptXML是朝着利用XMLHttpRequest对象获取远程数据并将其注入到 DOM 的 Web 技术的第一次迭代。

具有一定讽刺意味的是,微软,通常被认为是最严重违反网络标准的公司,却在 Microsoft Exchange Server 中为XMLHttpRequest奠定了基础,从而导致了 AJAX 的出现。

当然,如今 XML 很少成为谜题的一部分,因为这些库中消耗的大部分内容都是 JSON。您仍然可以使用 XML 作为源数据,但您的响应可能会比必要的更冗长。

进行简单的GET请求非常简单,因为 jQuery 提供了一个简单的快捷函数,称为getJSON,您可以使用它从我们的 API 获取数据。

现在,我们将遍历我们的用户,并创建一些 HTML 数据注入到现有的 DOM 元素中:

<script>

  $(document).ready(function() {
    $.getJSON('/api/users',function() {
        html = '';
      $(data.users).each(function() {
        html += '<div class="row">';
        html += '<div class="col-lg-3">'+ image + '</div>';
        html += '<div class="col-lg-9"><a href="/connect/'+this.ID+'/" >'+ this.first + ' ' + this.last + '</a></div>';
        html += '</div>';
      });
    });
  });
</script>

然而,GET请求只能让我们走得更远。为了完全符合 RESTful 网络服务,我们需要能够执行GETPOSTPUTDELETEOPTIONS头请求。实际上,最后一种方法将很重要,以允许跨不同域的请求。

正如我们之前提到的,getJSON是内置的ajax()方法的简写函数,它允许您在请求中更具体。例如,$.getJSON('/api/users')转换为以下代码:

$.ajax({
  url: '/api/users',
  cache: false,
  type: 'GET', // or POST, PUT, DELETE
});

这意味着我们可以通过直接设置HTTP方法来技术上处理 API 中的所有端点和方法。

虽然XMLHttpRequest接受所有这些头部,但 HTML 表单(至少通过 HTML 4)只接受GETPOST请求。尽管如此,如果您打算在客户端 JavaScript 中使用PUTDELETEOPTIONSTRACE请求,进行一些跨浏览器测试总是一个好主意。

注意

您可以在jquery.com/下载并阅读 jQuery 提供的非常全面的文档。有一些常见的 CDN 可以让您直接包含库,其中最值得注意的是 Google Hosted Libraries,如下所示:

<script src="img/jquery.min.js"></script>

该库的最新版本可在developers.google.com/speed/libraries/devguide#jquery找到。

AngularJS

如果我们超越了 jQuery 提供的基本工具集,我们将开始深入研究合法的、完全成型的框架。在过去的五年里,这些框架如雨后春笋般涌现。其中许多是传统的模型-视图-控制器MVC)系统,有些是纯模板系统,有些框架同时在客户端和服务器端工作,通过 WebSockets 提供了独特的推送式接口。

与 Go 一样,Angular(或 AngularJS)是由 Google 维护的项目,旨在在客户端提供全功能的 MVC。请注意,随着时间的推移,Angular 已经在设计模式上有所偏离,更多地朝向 MVVM 或 Model View ViewModel,这是一种相关的模式。

Angular 远远超出了 jQuery 提供的基本功能。除了一般的 DOM 操作外,Angular 还提供了真正的控制器作为更大的应用程序的一部分,以及用于强大的单元测试。

除其他功能外,Angular 使得从客户端快速、轻松、愉快地与 API 进行交互成为可能。该框架提供了更多的 MVC 功能,包括能够从.html/template文件中引入单独的模板的能力。

注意

许多人预计实际的推送通知将成为 HTML5 的标准功能,随着规范的成熟。

在撰写本书时,W3C 对推送 API 有一个工作草案。您可以在www.w3.org/TR/2014/WD-push-api-20141007/了解更多信息。

目前,解决方法包括诸如 Meteor(稍后将讨论)等利用 HTML5 中的 WebSockets 来模拟实时通信,而不受其他浏览器相关限制的束缚,例如在非活动选项卡中的休眠进程等。

使用 Angular 消费 API

使 Angular 应用程序能够与 REST API 一起工作,就像 jQuery 一样,直接内置到框架的骨架中。

将此调用与我们刚刚查看的/api/users端点进行比较:

$http.$get('/api/users'.
  success(function(data, status, headers, config) {
    html += '<div class="row">';
    html += '<div class="col-lg-3">'+ image + '</div>';
    html += '<div class="col-lg-9"><a href="/connect/'+this.ID+'/" >'+ this.first + ' ' + this.last + '</a></div>';
    html += '</div>';	
  }).
  error(function(data, status, headers, config) {
    alert('error getting API!')
  });

除了语法外,Angular 与 jQuery 并没有太大的不同;它也有一个接受回调函数或承诺作为第二参数的方法。但是,与 jQuery 设置方法的属性不同,Angular 为大多数 HTTP 动词提供了简短的方法。

这意味着我们可以直接进行PUTDELETE请求:

$http.$delete("/api/statuses/2").success(function(data,headers,config) {
  console.log('Date of response:', headers('Date'))
  console.log(data.message)
}).error(function(data,headers,config) {
  console.log('Something went wrong!');
  console.log('Got this error:', headers('Status'));
});

请注意,在前面的示例中,我们正在读取标头值。为了使其跨域工作,您还需要设置一个标头,以便为其他域共享这些标头:

Access-Control-Expose-Headers: [custom values]

由于域名在Access-Control-Allow-Origin标头中被明确列入白名单,这控制了将可用于客户端而不是域的特定标头键。在我们的情况下,我们将为Last-ModifiedDate值设置一些内容。

注意

您可以在angularjs.org/阅读更多关于 Angular 并从那里下载它。您还可以直接从 Google Hosted Libraries CDN 包含该库,如下所示:

<script src="img/angular.min.js"></script>

您可以在developers.google.com/speed/libraries/devguide#angularjs找到该库的最新版本。

设置一个消费 API 的前端

为了使用 API,前端将几乎完全不包含内部逻辑。毕竟,整个应用程序都是通过 HTML 调用到 SPA 中的,所以我们除了一个或两个模板之外不需要太多东西。

这是我们的header.html文件,其中包含基本的 HTML 代码:

<html>
  <head>Social Network</title>

    <link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap.min.css" rel="stylesheet">
    <script src="img/jquery.min.js"></script>
    <script src="img/bootstrap.min.js"></script>
    <script src="img/angular.min.js"></script>
    <script src="img/react.min.js"></script>
    <script src="img/application.js"></script>
  </head>

  <body ng-app="SocialNetwork">

    <div ng-view></div>
  </body>

带有application.js的行很重要,因为那里将存在所有逻辑并利用下面的一个前端框架。

ng-view指令只是一个占位符,将根据控制器的路由值替换。我们很快会看到。

请注意,我们在此处调用了 AngularJS、jQuery 和 React。这些都是选项,您不一定需要全部导入。很可能会导致冲突。相反,我们将探讨如何使用它们处理我们的 API。

正如您所期望的,我们的页脚主要是闭合标签:

</body>
</html>

我们将利用 Go 的http模板系统生成我们的基本模板。这里的示例显示了这一点:

<div ng-controller="webServiceInterface">
  <h1>{{Page.Title}}</h1>
  <div ng-model="webServiceError" style="display:none;"></div>
  <div id="webServiceBody" ng-model="body">
    <!-- nothing here, yet -->

  </div>
</div>

这个模板的核心不会是硬编码的,而是由所选择的 JavaScript 框架构建的。

为 Web 服务创建客户端 Angular 应用程序

如前所述,ng-app元素中的ng-view指令是指根据将 URL 与控制器配对的路由动态引入的内容。

更准确地说,它连接了伪 URL 片段(我们之前提到的)构建在#锚标签之上。让我们首先通过以下代码片段设置应用程序本身。

var SocialNetworkApp = angular.module('SocialNetwork', ['ngSanitize','ngRoute']);
SocialNetworkApp.config(function($routeProvider) {
  $routeProvider
  .when('/login',
    {
      controller: 'Authentication',
      templateUrl: '/views/auth.html'
    }
  ).when('/users',
    {
      controller: 'Users',
      templateUrl: '/views/users.html'
    }
  ).when('/statuses',
    {
      controller: 'Statuses',
      templateUrl: '/views/statuses.html'
    }
  );
});

当访问这些 URL 时,Angular 会告诉它将控制器与模板配对,并将它们放在ng-view元素中。这就是允许用户在站点之间导航而不进行硬页面加载的原因。

这是auth.html,它位于我们的/views/目录中,允许我们登录并执行用户注册:

<div class="container">
  <div class="row">
    <div class="col-lg-5">
      <h2>Login</h2>
      <form>
        <input type="email" name="" class="form-control" placeholder="Email" ng-model="loginEmail" />
        <input type="password" name="" class="form-control" placeholder="Password" ng-model="loginPassword" />
        <input type="submit" value="Login" class="btn" ng-click="login()" />
      </form>
    </div>

    <div class="col-lg-2">
      <h3>- or -</h3>
    </div>

    <div class="col-lg-5">
      <h2>Register</h2>
      <form>
        <input type="email" name="" class="form-control" ng-model="registerEmail" placeholder="Email" ng-keyup="checkRegisteredEmail();" />
        <input type="text" name="" class="form-control" ng-model="registerFirst" placeholder="First Name" />
        <input type="text" name="" class="form-control" ng-model="registerLast" placeholder="Last Name" />
        <input type="password" name="" class="form-control" ng-model="registerPassword" placeholder="Password" ng-keyup="checkPassword();" />
        <input type="submit" value="Register" class="btn" ng-click="register()" />
      </form>
    </div>
  </div>
</div>

如前所述,用于控制这一切的 JavaScript 只是我们 API 周围的一个薄包装。这是Login()过程:

$scope.login = function() {
  postData = { email: $scope.loginEmail, password: $scope.loginPassword };
  $http.$post('https://localhost/api/users', postData).success(function(data) {

    $location.path('/users');

  }).error(function(data,headers,config) {
    alert ("Error: " + headers('Status'));
  });
};

这是Register()过程:

$scope.register = function() {
  postData = { user: $scope.registerUser, email: $scope.registerEmail, first: $scope.registerFirst, last: $scope.registerLast, password: $scope.registerPassword };
  $http.$post('https://localhost/api/users', postData).success(function(data) {

    $location.path('/users');

  }).error(function(data,headers,config) {
    alert ("Error: " + headers('Status'));
  });
};
  Routes.HandleFunc("/api/user",UserLogin).Methods("POST","GET")
  Routes.HandleFunc("/api/user",APIDescribe).Methods("OPTIONS")

我们想在这里注意OPTIONS头。这是 CORS 标准运作的重要部分;基本上,请求通过使用OPTIONS动词进行预检调用进行缓冲,返回有关允许的域、资源等信息。在这种情况下,我们在api.go中包括一个名为APIDescribe的 catchall:

func APIDescribe(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
  w.Header().Set("Access-Control-Allow-Origin", "*")
}

查看其他用户

一旦我们登录,我们应该能够向经过身份验证的用户展示其他用户,以允许他们发起连接。

这是我们如何快速查看我们users.html Angular 模板中的其他用户:

<div class="container">
  <div class="row">
    <div ng-repeat="user in users">
      <div class="col-lg-3">{{user.Name}} <a ng-click="createConnection({{user.ID}});">Connect</a></div>
      <div class="col-lg-8">{{user.First}} {{user.Last}}</div>
    </div>

  </div>
</div>

我们调用我们的/api/users端点,它返回一个已登录用户列表。您可能还记得我们在上一章中将其放在身份验证墙后面。

查看其他用户

这个视图没有太多的花哨。这只是一种方式,可以看到您可能有兴趣连接或在我们的社交应用中添加好友的人。

在 Go 中服务器端呈现框架

为了构建页面,呈现框架在很大程度上是学术性的,它类似于使用 JavaScript 预渲染页面并返回它们。

因此,我们的 API 消费者的总代码非常简单:

package main

import
(
  "github.com/gorilla/mux"
  "fmt"
  "net/http"
  "html/template"
)
var templates = template.Must(template.ParseGlob("templates/*"))

在这里,我们指定一个目录用于模板访问,这在这种情况下是惯用的模板。我们不使用views,因为我们将用它来放我们的 Angular 模板,那些 HTML 块被templateUrl调用。让我们首先定义我们的 SSL 端口并添加一个处理程序。

const SSLport = ":444"

func SocialNetwork(w http.ResponseWriter, r *http.Request) {
  fmt.Println("got a request")
  templates.ExecuteTemplate(w, "socialnetwork.html", nil)
}

这就是我们的端点。现在,我们只是显示 HTML 页面。这可以简单地用任何语言完成,并且可以轻松地与我们的 Web 服务进行接口:

func main() {

  Router := mux.NewRouter()
  Router.HandleFunc("/home", SocialNetwork).Methods("GET")
  Router.PathPrefix("/js/").Handler(http.StripPrefix("/js/", http.FileServer(http.Dir("js/"))))
  Router.PathPrefix("/views/").Handler(http.StripPrefix("/views/", http.FileServer(http.Dir("views/"))))

最后两行允许从目录中提供文件。如果没有这些,当我们尝试调用 JavaScript 或 HTML 包含文件时,我们将收到 404 错误。让我们接下来添加我们的 SSL 端口和证书。

  http.ListenAndServeTLS(SSLport, "cert.pem", "key.pem", Router)
  }

如前所述,端口的选择甚至是 HTTP 还是 HTTPS 都是完全可选的,只要您允许生成的域在v1.go中的允许域列表中。

创建状态更新

我们的最后一个例子允许用户查看他们的最新状态更新并创建另一个。它略有不同,因为它在单个视图中调用了两个不同的 API 端点——用于最新状态的循环和发布的能力,也就是创建一个新的状态。

statuses.html文件看起来有点像这样:

<div class="container">
  <div class="row">
    <div class="col-lg-12">
       <h2>New Status:</h2>
       <textarea class="form-control" rows="10" ng-mode="newStatus"></textarea>
       <a class="btn btn-info" ng-click="createStatus()">Post</a>

在这里,我们在控制器中调用createStatus()函数来发布到/api/statuses端点。这里显示的其余代码通过 ng-repeat 指令显示了先前状态的列表:

    </div>
  </div>
  <div class="row">
    <div class="col-lg-12">
      <h2>Previous Statuses:</h2>
      <div ng-repeat="status in statuses">
        <div>{{status.text}}></div>
      </div>
  </div>
</div>

前面的代码只是简单地显示返回的文本。

SocialNetworkApp.controller('Statuses',['$scope', '$http', '$location', '$routeParams', function($scope,$http,$location,$routeParams) {

  $scope.statuses = [];
  $scope.newStatus;

  $scope.getStatuses = function() {
    $http.get('https://www.mastergoco.com/api/statuses').success(function(data) {

    });
  };

  $scope.createStatus = function() {
    $http({
      url: 'https://www.mastergoco.com/api/statuses',
      method: 'POST',
      data: JSON.stringify({ status: $scope.newStatus }),
            headers: {'Content-Type': 'application/json'}

  }).success(function(data) {
      $scope.statuses = [];
      $scope.getStatuses();
    });
  }

  $scope.getStatuses();

}]);

创建状态更新

在这里,我们可以看到一个简单的演示,在添加新状态消息的表单下显示了先前的状态消息。

摘要

我们已经简要介绍了在 Go 中开发简单 Web 服务接口的基础知识。诚然,这个特定版本非常有限且容易受攻击,但它展示了我们可以采用的基本机制,以产生可用的、正式的输出,可以被其他服务接收。

在对 Web 的一些主要框架以及诸如 jQuery 之类的通用库进行了初步检查后,您有足够多的选择来测试您的 API 与 Web 界面并创建单页面应用程序。

在这一点上,您应该已经掌握了开始完善这个过程和我们整个应用程序所需的基本工具。我们将继续前进,并在推进过程中对我们的 API 应用更全面的设计。显然,随机选择的两个 API 端点对我们来说并没有太多作用。

在下一章中,我们将深入探讨 API 规划和设计,RESTful 服务的细节,以及如何将逻辑与输出分离。我们将简要涉及一些逻辑/视图分离的概念,并朝着更健壮的端点和方法迈进第三章, 路由和引导

第九章:部署

说到底,当您准备启动您的 Web 服务或 API 时,总会有一些需要考虑的事项,从代码存储库到分段,到实时环境,到停止、启动和更新策略。

部署编译应用程序总是比部署解释应用程序更加复杂。幸运的是,Go 被设计为一种非常现代的编译语言。这意味着,人们已经付出了大量的思考,以解决传统上困扰 C 或 C++构建的服务器和服务的问题。

考虑到这一点,在本章中,我们将探讨一些可用于轻松部署和更新应用程序的工具和策略,以最小化停机时间。

我们还将研究一些可以减少我们的 Web 服务内部负载的方法,例如将图像存储和消息传递作为部署策略的一部分。

在本章结束时,您应该掌握一些特定于 Go 的和一般的技巧,可以最大限度地减少部署 API 和 Web 服务时常见的烦恼,特别是那些需要频繁更新并需要最少停机时间的服务。

在本章中,我们将探讨:

  • 应用程序设计和结构

  • 云端部署选项和策略

  • 利用消息系统

  • 将图像托管与我们的 API 服务器分离,并将其连接到基于云的 CDN

项目结构

尽管应用程序的设计和基础设施是机构和个人偏好的问题,但您计划其架构的方式可能会对您用于将应用程序部署到云端或任何生产环境中的方法产生真正的影响。

让我们快速回顾一下我们应用程序的结构,记住除非我们打算为大规模跨平台使用而生产我们的应用程序,否则我们不需要包对象:

bin/
  api # Our API binary

pkg/

src/
  github.com/
    nkozyra/
    api/
      /api/api.go
        /interface/interface.go
        /password/password.go
        /pseudoauth/pseudoauth.go
        /services/services.go
        /specification/specification.go
        /v1/v1.go
        /v2/v2.go

我们的应用程序的结构可能会引人注目,具体取决于我们如何将其部署到云端。

如果在部署之前有一个处理构建、依赖管理和推送到实时服务器的传输过程,那么这个结构就不相关了,因为源代码和 Go 包依赖可以被二进制文件所取代。

然而,在整个项目被推送到每个应用服务器或服务器或 NFS/文件服务器的情况下,结构仍然是必不可少的。此外,正如前面所指出的,任何需要考虑跨平台分发的地方,都应该保留 Go 项目的整个结构。

即使这并非至关重要,如果构建机器(或机器)与目标机器不完全相同,这会影响您构建二进制文件的过程,尽管这并不排除仅处理该二进制文件。

在一个示例 GitHub 存储库中,如果存在任何开放目录访问,可能还需要对非二进制代码进行混淆,类似于我们的interface.go应用程序。

使用进程控制来保持您的 API 运行

处理版本控制和开发流程的方法超出了本书的范围,但在为 Web 构建和部署编译代码时,一个相当常见的问题是安装和重新启动这些进程的过程。

管理更新的方式,同时最大限度地减少或消除停机时间对于实时应用程序至关重要。

对于脚本语言和依赖外部 Web 服务器通过 Web 公开应用程序的语言来说,这个过程很容易。脚本要么监听更改并重新启动其内部 Web 服务,要么在未缓存时进行解释,并且更改立即生效。

对于长时间运行的二进制文件,这个过程变得更加复杂,不仅涉及更新和部署我们的应用程序,还涉及确保我们的应用程序处于活动状态,如果服务停止,不需要手动干预。

幸运的是,有几种简单的方法来处理这个问题。第一种是自动维护的严格进程管理。第二种是一个特定于 Go 的工具。让我们首先看看进程管理器以及它们如何与 Go Web 服务一起工作。

使用监督者

对于*nix 服务器来说,这里有几个大的解决方案,从非常简单到更复杂和细粒度的解决方案。它们的操作方式没有太大的区别,因此我们将简要地介绍如何使用 Supervisor 来管理我们的 Web 服务。

注意

其他一些值得注意的进程管理器如下:

这些直接监督初始化守护进程监控进程管理器的基本原则是监听运行的应用程序,如果没有根据一组配置的规则尝试重新启动它们。

值得指出的是,这些系统没有真正的分布式方法,允许您以聚合方式管理多个服务器的进程,因此通常需要依靠负载均衡器和网络监控来获取此类反馈。

在 Supervisor 的情况下,安装完成后,我们只需要一个简单的配置文件,通常可以通过导航到*nix 发行版上的/etc/supervisor/conf.d/来找到。以下是我们应用程序的一个示例文件:

[program:socialnetwork]
command=/var/app/api
autostart=true
autorestart=true
stderr_logfile=/var/log/api.log
stdout_logfile=/var/log/api.log

虽然您可以变得更加复杂,例如,将多个应用程序组合在一起以允许同步重启,这对升级非常有用,但这就是您需要保持我们长时间运行的 API 的全部内容。

当需要更新时,比如从 GIT 到暂存再到线上,可以手动触发一个重新启动服务的进程,也可以通过命令以编程方式触发,比如以下命令:

supervisorctl restart program:socialnetwork

这不仅可以使您的应用程序保持运行,还可以强制执行一个完整的更新过程,将您的代码推送到线上并触发进程的重新启动。这确保了最小的停机时间。

使用 Manners 创建更加优雅的服务器

虽然替代进程管理器在自己的工作中表现得很好,但它们在应用程序内部缺乏一些控制。例如,简单地杀死或重新启动 Web 服务器几乎肯定会中断任何活动的请求。

单独使用 Manners 时,缺少一些像goagain这样的进程的监听控制,它是一个将 TCP 监听器聚合到 goroutines 中,并允许通过 SIGUSR1/SIGUSR2 进程间自定义信号进行外部控制的库。

但是,您可以将两者结合使用来创建这样的进程。或者,您可以直接编写内部监听器,因为对于优雅地重新启动 Web 服务器的目的,goagain 可能会有点过度。

使用 Manners 作为net/http的替代/包装器的示例将如下所示:

package main

import
(
  "github.com/braintree/manners"
  "net/http"
  "os"
  "os/signal"
)

var Server *GracefulServer

func SignalListener() {
  sC := make(chan os.signal, 1)
  signal.Notify(sC, syscall.SIGUSR1, syscall.SIGUSR2)
  s := <- sC
  Server.Shutdown <- true
}

在 goroutine 中运行并阻塞的通道监听 SIGUSR1 或 SIGUSR2 时,当接收到这样的信号时,我们将布尔值传递给Server.Shutdown通道。

func Init(allowedDomains []string) {
  for _, domain := range allowedDomains {
    PermittedDomains = append(PermittedDomains, domain)
  }
  Routes = mux.NewRouter()
  Routes.HandleFunc("/interface", APIInterface).Methods("GET", "POST", "PUT", "UPDATE")
  Routes.HandleFunc("/api/user",UserLogin).Methods("POST","GET")
  ...
}

这只是我们在api.goInit()函数的重新处理。这注册了我们需要 Manners 包装的 Gorilla 路由器。

func main() {

  go func() {
    SignalListener()
  }()
  Server = manners.NewServer()
  Server.ListenAndServe(HTTPport, Routes)
}

main()函数中,我们不仅启动http.ListenAndServe()函数,还使用 Manners 服务器。

这将防止在发送关闭信号时断开开放的连接。

注意

  • 您可以使用go get github.com/braintree/manners来安装 Manners。

  • 您可以在github.com/braintree/manners了解更多关于 Manners 的信息。

  • 您可以使用go get github.com/rcrowley/goagain来安装 goagain。

  • 您可以在github.com/rcrowley/goagain了解更多关于 goagain 的信息。

使用 Docker 部署

在过去几年里,几乎没有什么服务器端产品能像 Docker 在技术世界中引起如此大的轰动。

Docker 创建了类似于易于部署、预配置的虚拟机,与 VirtualBox、VMWare 等传统虚拟机软件相比,对主机的影响要小得多。

它能够以比虚拟机更少的整体重量来实现这一点,通过利用 Linux 容器,这允许用户空间被包含,同时保留对操作系统本身的许多访问权限。这样一来,每个虚拟机就不需要成为操作系统和应用程序的完整镜像了。

为了在 Go 中使用,这通常是一个很好的选择,特别是如果我们为多个目标处理器创建构建,并希望轻松部署 Docker 容器到任何一个或所有这些处理器。更好的是,现在设置方面基本上是开箱即用的,因为 Docker 已经创建了语言堆栈,并在其中包含了 Go。

尽管在其核心,Docker 本质上只是一个典型 Linux 发行版镜像的抽象,但使用它可以使升级和快速配置变得轻而易举,甚至可能提供额外的安全性好处。最后一点取决于您的应用程序及其依赖关系。

Docker 使用非常简单的配置文件,使用语言堆栈,您可以轻松创建一个容器,可以启动并具有我们 API 所需的一切。

看看这个 Docker 文件示例,看看我们如何为我们的社交网络网络服务获取所有必要的包:

FROM golang:1.3.1-onbuild

RUN go install github.com/go-sql-driver/mysql
RUN go install github.com/gorilla/mux
RUN go install github.com/gorilla/sessions
RUN go install github.com/nkozyra/api/password
RUN go install github.com/nkozyra/api/pseudoauth
RUN go install github.com/nkozyra/api/services
RUN go install github.com/nkozyra/api/specification
RUN go install github.com/nkozyra/api/api

EXPOSE 80 443

然后可以使用简单的命令构建和运行该文件:

docker build -t api .
docker run --name api-running api -it --rm

您可以看到,至少在最低限度下,这将极大地加快 Go 更新过程,跨多个实例(或在这种情况下是容器)。

完整的 Docker 基础镜像也适用于 Google 云平台。如果您使用或希望测试 Google Cloud,这对于快速部署最新版本的 Go 非常有用。

在云环境中部署

对于那些还记得满屋子都是物理单用途服务器、毁灭性的硬件故障和极其缓慢的重建和备份时间的人来说,云托管的出现很可能是一大福音。

如今,一个完整的架构通常可以很快地从模板构建,自动扩展和监控也比以往更容易。现在,市场上也有很多参与者,从谷歌、微软和亚马逊到专注于简单、节俭和易用性的小公司,如 Linode 和 Digital Ocean。

每个网络服务都有自己的功能集和缺点,但大多数都共享一个非常常见的工作流程。为了探索 Golang 本身可能通过 API 提供的其他功能,我们将看看亚马逊云服务。

注意

请注意,类似的工具也适用于 Go 的其他云平台。甚至微软的平台 Azure 也有一个专为 Go 编写的客户端库。

亚马逊云服务

与前述的许多云服务一样,部署到亚马逊云服务或 AWS 基本上与部署到任何标准物理服务器的基础设施没有太大区别。

不过,AWS 有一些区别。首先是它提供的服务范围。亚马逊不仅仅处理静态虚拟服务器。它还处理一系列支持服务,如 DNS、电子邮件和短信服务(通过他们的 SNS 服务)、长期存储等等。

尽管迄今为止已经说了很多,但请注意,许多备选云服务提供类似的功能,可能与以下示例提供的功能类似。

使用 Go 直接与 AWS 进行接口

虽然一些云服务确实提供了某种形式的 API 与其服务配套,但没有一个像亚马逊云服务那样强大。

AWS API 提供了对其环境中的每一个可能操作的直接访问,从添加实例、分配 IP 地址、添加 DNS 条目等等。

正如您所期望的那样,直接与此 API 进行接口可以打开许多可能性,因为它涉及自动化应用程序的健康以及管理更新和错误修复。

要直接与 AWS 进行接口,我们将使用goamz包启动我们的应用程序:

package main
import (
    "launchpad.net/goamz/aws"
    "launchpad.net/goamz/ec2"
)

提示

要获取运行此示例所需的两个依赖项,请运行go get launchpad.net/goamz/aws命令和go get launchpad.net/goamz/ec2命令。

您可以在godoc.org/launchpad.net/goamz找到有关此的其他文档。goamz包还包括 Amazon S3 存储服务的包,以及 Amazon 的 SNS 服务和简单数据库服务的一些额外实验性包。

基于镜像启动一个新实例很简单。也许对于习惯于手动部署或通过受控、自动化或自动缩放过程部署的人来说,这太简单了。

    AWSAuth, err := aws.EnvAuth()
    if err != nil {
        fmt.Println(err.Error())
    }
    instance := ec2.New(AWSAuth, aws.USEast)
    instanceOptions := ec2.RunInstances({
        ImageId:      "ami-9eaa1cf6",
        InstanceType: "t2.micro",
    })

在这种情况下,ami-9eaa1cf6指的是 Ubuntu Server 14.04。

在我们的下一节中,拥有与亚马逊 API 的接口将是重要的,我们将把图像数据从我们的关系数据库中移出,并放入 CDN 中。

处理二进制数据和 CDN

您可能还记得在第三章中,路由和引导,我们看了如何以 BLOB 格式将二进制数据,特别是图像数据,存储在我们应用程序的数据库中。

当时,我们以一种非常基础的方式处理这个问题,只是简单地将二进制图像数据放入某种存储系统中。

Amazon S3 是 AWS 内容分发/交付网络方面的一部分,它基于桶的概念来收集数据,每个桶都有自己的访问控制权限。需要注意的是,AWS 还提供了一个名为 Cloudfront 的真正 CDN,但 S3 可以用作存储服务。

让我们首先看一下使用goamz包在给定存储桶中列出最多 100 个项目:

提示

在代码中用您的凭据替换-----------。

package main

import
(
  "fmt"
    "launchpad.net/goamz/aws"
    "launchpad.net/goamz/s3"
)

func main() {
  Auth := aws.Auth { AccessKey: `-----------`, SecretKey: `-----------`, }
  AWSConnection := s3.New(Auth, aws.USEast)

  Bucket := AWSConnection.Bucket("social-images")

    bucketList, err := Bucket.List("", "", "", 100)
    fmt.Println(AWSConnection,Bucket,bucketList,err)  
    if err != nil {
        fmt.Println(err.Error())
    }
    for _, item := range bucketList.Contents {
        fmt.Println(item.Key)
    }
}

在我们的社交网络示例中,我们将其作为/api/user/:id:端点的一部分处理。

 func UsersUpdate(w http.ResponseWriter, r *http.Request) {
  Response := UpdateResponse{}
  params := mux.Vars(r)
  uid := params["id"]
  email := r.FormValue("email")
  img, _, err := r.FormFile("user_image")
  if err != nil {
    fmt.Println("Image error:")
    fmt.Println(err.Error())

返回上传,而不是检查错误并继续尝试处理图像,或者我们继续前进。我们将在这里展示如何处理空值:

  }
  imageData, ierr := ioutil.ReadAll(img)
  if err != nil {
    fmt.Println("Error reading image:")
    fmt.Println(err.Error())

在这一点上,我们已经尝试读取图像并提取数据——如果我们不能,我们通过fmt.Printlnlog.Println打印响应并跳过剩余的步骤,但不要惊慌,因为我们可以以其他方式继续编辑。

  } else {
    mimeType, _, mimerr := mime.ParseMediaType(string(imageData))
    if mimerr != nil {
      fmt.Println("Error detecting mime:")
      fmt.Println(mimerr.Error())
    } else {
      Auth := aws.Auth { AccessKey: `-----------`, SecretKey: `-----------`, }
      AWSConnection := s3.New(Auth, aws.USEast)
      Bucket := AWSConnection.Bucket("social-images")
      berr := Bucket.Put("FILENAME-HERE", imageData, "", "READ")
      if berr != nil {
        fmt.Println("Error saving to bucket:")
        fmt.Println(berr.Error())
      }
    }
  }

在第三章中,路由和引导,我们接受了表单中上传的数据,将其转换为 Base64 编码的字符串,并保存在我们的数据库中。

由于我们现在要直接保存图像数据,我们可以跳过这最后一步。我们可以从我们请求中的FormFile函数中读取任何内容,并将整个数据发送到我们的 S3 存储桶,如下所示:

    f, _, err := r.FormFile("image1")
    if err != nil {
      fmt.Println(err.Error())
    }
    fileData,_ := ioutil.ReadAll(f)

对于这个图像,我们需要确保有一个唯一的标识符,避免竞争条件。

检查文件上传的存在

FormFile()函数实际上在底层调用ParseMultipartForm(),并为文件、文件头和标准错误返回默认值(如果不存在)。

使用 net/smtp 发送电子邮件

将我们的 API 和社交网络与辅助工具解耦是一个好主意,可以在我们的系统中创建特定性感,减少这些系统之间的冲突,并为每个系统提供更适当的系统和维护规则。

我们可以很容易地为我们的电子邮件系统配备一个套接字客户端,使系统能够直接监听来自我们 API 的消息。实际上,这只需要几行代码就可以实现:

package main

import
(
  "encoding/json"
  "fmt"
  "net"
)

const
(
  port = ":9000"
)

type Message struct {
  Title string `json:"title"`
  Body string `json:"body"`
  To string `json:"recipient"`
  From string `json:"sender"`
}

func (m Message) Send() {

}
func main() {

  emailQueue,_ := net.Listen("tcp",port)
  for {
    conn, err := emailQueue.Accept()
    if err != nil {

    }
    var message []byte
    var NewEmail Message
    fmt.Fscan(conn,message)
    json.Unmarshal(message,NewEmail)
    NewEmail.Send()
  }

}

让我们来看一下实际的发送函数,它将把我们 API 中注册过程中的消息发送到电子邮件服务器:

func (m Message) Send() {
  mailServer := "mail.example.com"
  mailServerQualified := mailServer + ":25"
  mailAuth := smtp.PlainAuth(
        "",
        "[email]",
        "[password]",
        mailServer,
      )
  recip := mail.Address("Nathan Kozyra","nkozyra@gmail.com")
  body := m.Body

  mailHeaders := make(map[string] string)
  mailHeaders["From"] = m.From
  mailHeaders["To"] = recip.toString()
  mailHeaders["Subject"] = m.Title
  mailHeaders["Content-Type"] = "text/plain; charset=\"utf-8\""
  mailHeaders["Content-Transfer-Encoding"] = "base64"
  fullEmailHeader := ""
  for k, v := range mailHeaders {
    fullEmailHeader += base64.StdEncoding.EncodeToString([]byte(body))
  }

  err := smtp.SendMail( mailServerQualified, mailAuth, m.From, m.To, []byte(fullEmailHeader))
  if err != nil {
    fmt.Println("could not send email")
    fmt.Println(err.Error())
  }
}

虽然这个系统可以很好地工作,因为我们可以监听 TCP 并接收告诉我们要发送什么和发送到什么地址的消息,但它本身并不特别容错。

我们可以通过使用消息队列系统轻松解决这个问题,接下来我们将使用 RabbitMQ 来看一下。

RabbitMQ with Go

Web 设计的一个方面,特别与 API 相关,但几乎是任何 Web 堆栈的一部分,是服务器和其他系统之间的消息传递的概念。

它通常被称为高级消息队列协议AMQP。它可以成为 API/web 服务的重要组成部分,因为它允许否则分离的服务相互通信,而无需使用另一个 API。

通过消息传递,我们在这里谈论的是可以或应该在发生重要事件时在不同的系统之间共享的通用事物被传递给相关的接收者。

再举个类比,就像手机上的推送通知。当后台应用程序有要通知您的事情时,它会生成警报并通过消息传递系统传递。

以下图表是该系统的基本表示。发送者(S),在我们的情况下是 API,将消息添加到堆栈,然后接收者(R)或电子邮件发送过程将检索这些消息:

RabbitMQ with Go

我们认为这些过程对 API 特别重要,因为通常有机构希望将 API 与基础设施的其余部分隔离开来。尽管这样做是为了防止 API 资源影响现场站点或允许两个不同的应用程序安全地在相同的数据上运行,但也可以用于允许一个服务接受多个请求,同时允许第二个服务或系统根据资源的允许情况进行处理。

这还为用不同编程语言编写的应用程序提供了非常基本的数据粘合剂。

在我们的 Web 服务中,我们可以使用 AMQP 解决方案告诉我们的电子邮件系统在成功注册后生成欢迎电子邮件。这使我们的核心 API 不必担心这样做,而是可以专注于我们系统的核心。

我们可以通过制定标准消息和标题并将其传递为 JSON 来形式化系统 A 和系统 B 之间的请求的多种方式之一:

type EmailMessage struct {
  Recipient string `json:"to"`
  Sender string `json:"from"`
  Title string `json:"title"`
  Body string `json:"body"`
  SendTime time.Time `json:"sendtime"`
  ContentType string `json:"content-type"`
}

以这种方式接收电子邮件,而不是通过开放的 TCP 连接,使我们能够保护消息的完整性。在我们之前的例子中,由于故障、崩溃或关闭而丢失的任何消息将永远丢失。

消息队列,另一方面,就像具有可配置耐久性级别的邮箱一样运作,这使我们能够决定消息应该如何保存,何时过期,以及哪些进程或用户应该访问它们。

在这种情况下,我们使用一个文字消息,作为一个包的一部分交付,将通过队列被我们的邮件服务摄取。在发生灾难性故障的情况下,消息仍将存在,供我们的 SMTP 服务器处理。

另一个重要特性是它能够向消息发起者发送“收据”。在这种情况下,电子邮件系统会告诉 API 或 Web 服务,电子邮件消息已成功从队列中被电子邮件进程取走。

这是在我们简单的 TCP 过程中复制的一些东西。我们必须构建的故障保护和应急措施的数量将使其成为一个非常沉重的独立产品。

幸运的是,在 Go 中集成消息队列是相当简单的:

func Listen() {

  qConn, err := amqp.Dial("amqp://user:pass@domain:port/")
  if err != nil {
    log.Fatal(err)
  }

这只是我们与 RabbitMQ 服务器的连接。如果检测到连接出现任何错误,我们将停止该过程。

  qC,err := qConn.Channel()
  if err != nil {
    log.Fatal(err)
  }

  queue, err := qC.QueueDeclare("messages", false, false, false, false, nil)
  if err != nil {
    log.Fatal(err)
  }

这里队列的名称有点像 memcache 键或数据库名称一样任意。关键是确保发送和接收机制搜索相同的队列名称:

  messages, err := qC.Consume( queue.Name, "", true, false, false, false, nil)
  waitChan := make(chan int)
  go func() {
    for m := range messages {
      var tmpM Message
      json.Unmarshal(d.Body,tmpM)
      log.Println(tmpM.Title,"message received")
      tmpM.Send()
    }

在我们的循环中,我们监听消息并在接收到消息时调用Send()方法。在这种情况下,我们传递的是 JSON,然后将其解组为Message结构,但这种格式完全取决于您:

  }()

  <- waitChan

}

而且,在我们的main()函数中,我们需要确保用调用 AMQP 监听器的Listen()函数替换我们的无限 TCP 监听器:

func main() {

  Listen()

现在,我们有能力从消息队列中接收消息(在电子邮件意义上),这意味着我们只需要在我们的 Web 服务中包含这个功能即可。

在我们讨论的示例用法中,新注册的用户将收到一封电子邮件,提示激活账户。这通常是为了防止使用虚假电子邮件地址进行注册。这并不是一个完全可靠的安全机制,但它确保我们的应用程序可以与拥有真实电子邮件地址的人进行通信。

发送到队列也很容易。

考虑到我们在两个独立应用程序之间共享凭据,将这些内容正式化为一个单独的包是有意义的:

package emailQueue

import
(
  "fmt"
  "log"
  "github.com/streadway/amqp"
)

const
(
  QueueCredentials = "amqp://user:pass@host:port/"
  QueueName = "email"
)

func Listen() {

}

func Send(Recipient string, EmailSubject string, EmailBody string) {

}

通过这种方式,我们的 API 和我们的监听器都可以导入我们的emailQueue包并共享这些凭据。在我们的api.go文件中,添加以下代码:

func UserCreate(w http.ResponseWriter, r *http.Request) {

  ...

  q, err := Database.Exec("INSERT INTO users set user_nickname=?, user_first=?, user_last=?, user_email=?, user_password=?, user_salt=?",NewUser.Name,NewUser.First, NewUser.Last,NewUser.Email,hash,salt)
  if err != nil {
    errorMessage, errorCode := dbErrorParse(err.Error())
    fmt.Println(errorMessage)
    error, httpCode, msg := ErrorMessages(errorCode)
    Response.Error = msg
        Response.ErrorCode = error
    http.Error(w, "Conflict", httpCode)
  } else {

    emailQueue.Send(NewUser.Email,"Welcome to the Social Network","Thanks for joining the Social Network!  Your personal data will help us become billionaires!")

  }

在我们的e-mail.go进程中:

emailQueue.Listen()

注意

AMQP 是一个更通用的消息传递接口,具有 RabbitMQ 扩展。您可以在github.com/streadway/amqp上阅读更多信息。

有关 Grab Rabbit Hole 的更多信息,请访问github.com/michaelklishin/rabbit-hole,或者可以使用go get github.com/michaelklishin/rabbit-hole命令进行下载。

摘要

通过将 API 的逻辑与我们托管的环境和辅助支持服务分开,我们可以减少功能蔓延和由于非必要功能而导致的崩溃的机会。

在本章中,我们将图像托管从数据库中移到云端,并将原始图像数据和结果引用存储到 S3,这是一个经常用作 CDN 的服务。然后,我们使用 RabbitMQ 演示了如何在部署中利用消息传递。

在这一点上,您应该掌握了将这些服务卸载以及更好地了解部署、更新和优雅重启的可用策略。

在下一章中,我们将开始完成社交网络的最终必要要求,并通过这样做,探索增加我们的 Web 服务的速度、可靠性和整体性能的一些方法。

我们还将引入一个次要服务,允许我们在 SPA 界面内进行社交网络聊天,并扩展我们的图像到 CDN 工作流程,以允许用户创建图库。我们将研究如何通过界面和 API 直接最大化图像呈现和获取的方式。

第十章:最大化性能

在讨论部署和启动应用程序的概念之后,我们将在本章中锁定 Go 和相关第三方包中的高性能策略。

随着您的网络服务或 API 的增长,性能问题可能会凸显出来。成功的网络服务的一个标志是需要更多的硬件支持;然而,通过编程最佳实践来减少这种需求比简单地为应用程序提供更多处理能力更好。

在本章中,我们将探讨:

  • 引入中间件来减少代码中的冗余,并为一些性能特性铺平道路

  • 设计缓存策略以保持内容新鲜并尽快提供

  • 使用基于磁盘的缓存

  • 使用内存缓存

  • 通过中间件对我们的 API 进行速率限制

  • 谷歌的 SPDY 协议倡议

在本章结束时,您应该知道如何将自己的中间件构建到您的社交网络(或任何其他网络服务)中,以引入额外的功能,从而提高性能。

使用中间件来减少冗余

在 Go 中处理 Web 时,内置的路由和处理程序的方法并不总是很适合直接使用中间件的清晰方法。

例如,尽管我们有一个非常简单的UsersRetrieve()方法,但如果我们想要阻止消费者到达那一点或在那之前运行一些东西,我们将需要在我们的代码中多次包含这些调用或参数:

func UsersRetrieve(w http.ResponseWriter, r *http.Request) {
  CheckRateLimit()

还有一个调用是:

func UsersUpdate( w http.ResponseWriter, r *http.Request) {
  CheckRateLimit()
  CheckAuthentication()
}

中间件允许我们更清晰地指导应用程序的内部模式,因为我们可以对速率限制和身份验证进行检查,就像前面的代码中所示的那样。如果有一些外部信号告诉我们应用程序应该暂时离线,而不是完全停止应用程序,我们也可以绕过调用。

考虑到可能性,让我们想一想如何在应用程序中有效地利用中间件。

最好的方法是找到我们通过重复插入了大量不必要代码的地方。一个容易开始的地方是我们的身份验证步骤,它存在于我们的api.go文件的许多代码部分中。

func UserLogin(w http.ResponseWriter, r *http.Request) {

  CheckLogin(w,r)

我们在整个应用程序中多次调用CheckLogin()函数,因此我们可以将其卸载到中间件中,以减少冗余和重复的代码。

另一种方法是访问控制头设置,允许或拒绝基于允许的域的请求。我们特别用于服务器端请求,这些请求受到 CORS 规则的约束:

func UserCreate(w http.ResponseWriter, r *http.Request) {

 w.Header().Set("Access-Control-Allow-Origin", "*")
 for _, domain := range PermittedDomains {
 fmt.Println("allowing", domain)
 w.Header().Set("Access-Control-Allow-Origin", domain)
  }

这也可以由中间件处理,因为它不需要基于请求类型的任何自定义。在我们希望设置允许的域的任何请求中,我们可以将这段代码移到中间件中。

总的来说,这代表了良好的代码设计,但有时候没有自定义中间件处理程序可能会有些棘手。

中间件的一种流行方法是链接,工作原理如下:

firstFunction().then(nextFunction()).then(thirdFunction())

这在 Node.js 世界中非常常见,next()then()use()函数在代码中广泛使用。在 Go 中也可以做到这一点。

有两种主要方法。第一种是在处理程序中包装处理程序。这通常被认为是丑陋的,不被推荐。

处理返回到其父级的包装处理程序函数可能会很难解析。

因此,让我们来看看第二种方法:链接。有许多框架包括中间件链接,但引入一个重型框架仅仅是为了中间件链接是不必要的。让我们看看如何在 Go 服务器中直接实现这一点:

package main

import
(
  "fmt"
  "net/http"
)

func PrimaryHandler(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w, "I am the final response")
}

func MiddlewareHandler(h http.HandlerFunc) http.HandlerFunc {
  fmt.Println("I am middleware")
  return func(w http.ResponseWriter, r *http.Request) {
    h.ServeHTTP(w, r)
  }
}

func middleware(ph http.HandlerFunc, middleHandlers ..func(http.HandlerFunc) (http.HandlerFunc) ) http.HandlerFunc {
  var next http.HandlerFunc = ph
  for _, mw := range middleHandlers {
    next = mw(ph)
  }
  return next
}

func main() {
  http.HandleFunc("/middleware", middleware(PrimaryHandler,MiddlewareHandler))
  http.ListenAndServe(":9000",nil)
}

正如前面提到的,在我们的代码和大多数基于服务器的应用程序中,中间件将非常有帮助。在本章后面,我们将看看如何将我们的认证模型移入中间件,以减少我们在处理程序中进行的重复调用。

然而,出于性能考虑,这种类型的中间件的另一个功能可以用作缓存查找的阻塞机制。如果我们想要避免GET请求中的潜在瓶颈,我们可以在请求和响应之间放置一个缓存层。

我们正在使用关系数据库,这是网络瓶颈最常见的来源之一;因此,在接受过时或不经常更改的内容是可以接受的情况下,将查询结果放在这样的屏障后面可以大大提高我们 API 的整体性能。

鉴于我们有两种主要类型的请求可以以不同的方式从中间件中受益,我们应该规定我们将如何处理各种请求的中间件策略。

以下图表是我们如何设计中间件的模型。它可以作为一个基本指南,指导我们在哪里为特定类型的 API 调用实现特定的中间件处理程序:

使用中间件减少冗余

所有请求都应该受到一定程度的速率限制,即使某些请求的限制比其他请求高得多。因此,GETPUTPOSTDELETE请求将在每个请求上至少通过一个中间件。

任何其他动词的请求(例如OPTIONS)应该绕过这一点。

GET请求应该受到缓存的影响,我们也将其描述为使它们返回的数据在一定程度上可以过时。

另一方面,显然不能对 PUT、POST 和 DELETE 请求进行缓存,因为这要么会导致我们的响应不准确,要么会导致重复尝试创建或删除数据。

让我们从GET请求开始,看看我们可以绕过瓶颈的两种相关方式,当可能提供服务器缓存的结果而不是访问我们的关系数据库时。

缓存请求

当然,有不止一种或两种方法可以在任何给定请求的生命周期内引入缓存。我们将在本节中探讨其中一些方法,以引入最高级别的非冗余缓存。

在脚本或浏览器级别有客户端缓存,它明显受到从服务器端发送给它的规则的约束。我们指的是遵守 HTTP 响应头,比如Cache-ControlExpiresIf-None-MatchIf-Modified-Since等等。

这些是您可以强制执行的最简单的缓存控制形式,它们也是作为 RESTful 设计的重要部分。然而,它们也有点脆弱,因为它们不允许对这些指令进行任何强制执行,并且客户端可以轻易地忽略它们。

接下来是基于代理的缓存——通常是第三方应用程序,它们要么提供任何给定请求的缓存版本,要么通过到原始服务器应用程序。我们在谈论在 API 前面使用 Apache 或 Nginx 时,已经提到了这个的前身。

最后,在应用程序级别有服务器级别的缓存。这通常是代理缓存的替代,因为两者往往遵循相同的规则集。在大多数情况下,诉诸于独立的代理缓存是最明智的选择,但有时这些解决方案无法适应特定的边缘情况。

从头开始设计这些也有一定的价值,以更好地理解代理缓存的缓存策略。让我们简要地看一下如何在基于磁盘和基于内存的方式上为我们的社交网络构建服务器端应用程序缓存,并看看我们如何利用这一经验来更好地定义代理级别的缓存规则。

简单的基于磁盘的缓存

不久以前,大多数开发人员处理缓存请求的方式通常是通过应用程序级别的磁盘缓存。

在这种方法中,围绕任何给定请求的缓存机制和限定条件设置了一些参数。然后,请求的结果被保存到一个字符串,然后保存到一个锁文件中。最后,锁文件被重命名。这个过程相当稳定,尽管有些过时,但足够可靠。

在 Web 早期,有一些不可逾越的缺点。

请注意,磁盘,特别是机械磁盘,一直以来都以存储和访问速度慢而著称,并且在查找、发现和排序方面很可能会导致文件系统和操作系统操作出现许多问题。

分布式系统也带来了一个明显的挑战,即需要共享缓存以确保平衡请求的一致性。如果服务器 A 更新其本地缓存,并且下一个请求从服务器 B 返回了缓存命中,您会看到根据服务器的不同而产生不同的结果。使用网络文件服务器可能会减少这种情况,但它会引入一些权限和网络延迟的问题。

另一方面,没有什么比将请求的版本保存到文件更简单。这个特点,再加上磁盘缓存在编程的其他领域中有着悠久的历史,使它成为一个自然的早期选择。

此外,完全公平地说磁盘缓存的时代已经结束也并不合适。更快的驱动器,通常是固态硬盘,重新打开了使用非短暂存储进行快速访问的潜力。

让我们快速看一下如何为我们的 API 设计一个基于磁盘的缓存中间件解决方案,以减少高流量中的负载和瓶颈。

首先要考虑的是要缓存什么。出于明显的原因,我们绝不希望允许PUTPOSTDELETE请求进行缓存,因为我们既不希望数据重复,也不希望DELETEPOST请求出现错误响应,表明资源已经被创建或删除,而实际上并没有。

因此,我们知道我们只缓存GET请求或数据列表。这是我们唯一的数据,可以在某种程度上过时,而不会对应用程序的运行方式产生重大变化。

让我们从我们最基本的请求/api/users开始,它返回我们系统中用户的列表,并引入一些用于将数据缓存到磁盘的中间件。让我们将其设置为一个框架,以解释我们如何评估:

package diskcache

import
(
)

type CacheItem struct {

}

我们的CacheItem结构是包中唯一真正的元素。它包括一个有效的缓存命中(以及关于缓存元素的最后修改时间、内容等信息)或一个缓存未命中。缓存未命中将返回给我们的 API,要么该项不存在,要么已经超过了生存时间(TTL)。在这种情况下,diskcache包将把缓存设置为文件:

func SetCache() {

}

这就是我们将要做的。如果一个请求没有缓存的响应,或者缓存无效,我们需要获取结果,以便保存它。这使得中间件部分有点棘手,但我们很快会向您展示如何处理这个问题。以下的GetCache()函数查找我们的缓存目录,找到并返回一个缓存项(无论是否有效),或者产生一个 false 值:

func GetCache() (bool, CacheItem) {

}

以下的Evaluate()函数将是我们的主要入口点,传递给GetCache(),可能稍后还会传递给SetCache(),如果我们需要创建或重新创建缓存条目的话:

func Evaluate(context string, value string, in ...[]string) (bool, CacheItem) {

}

在这个结构中,我们将利用上下文(以便我们可以区分请求类型)、结果值(用于保存)和一个开放的字符串可变参数,我们可以用作缓存条目的限定符。我们指的是强制生成唯一缓存文件的参数。比如,我们指定pagesearch作为这样的限定符。第 1 页的请求将与第 2 页的请求不同,并且它们将被分别缓存。搜索 Nathan 的第 1 页请求将与搜索 Bob 的第 1 页请求不同,依此类推。

这一点对硬文件来说非常严格,因为我们需要以一种可靠和一致的方式命名(和查找)我们的缓存文件,但当我们将缓存保存在数据存储中时,这也很重要。

考虑到所有这些,让我们来看看我们如何区分可缓存的条目

启用过滤

目前我们的 API 不接受任何特定参数来对我们的GET请求进行过滤,这些请求返回实体列表或实体的特定详细信息。例如,用户列表、状态更新列表或关系列表。

您可能会注意到我们的UsersRetrieve()处理程序目前根据start值和limit值返回下一页。目前这是在start值为0limit值为10的情况下硬编码的。

此外,我们设置了一个Pragma: no-cache头。显然我们不希望这样。因此,为了进行缓存准备,让我们添加一些额外的字段,客户端可以使用这些字段来查找他们想要的特定用户。

第一个是起始值和限制值,它决定了一种分页。我们现在有的是这样的:

  start := 0
  limit := 10

  next := start + limit

首先让我们通过接受一个起始值来使其对请求做出响应:

  start := 0
  if len(r.URL.Query()["start"]) > 0 {
    start = r.URL.Query()["start"][0]
  }
  limit := 10
  if len(r.URL.Query()["limit"]) > 0 {
    start = r.URL.Query()["limit"][0]
  }
  if limit > 50 {
    limit = 50
  }

现在,我们可以接受一个起始值和一个限制值。请注意,我们还对我们将返回的结果数量进行了限制。任何超过 50 的结果都将被忽略,最多返回 50 个结果。

将磁盘缓存转换为中间件

现在我们将把diskcache的框架转换为中间件调用,并开始加速我们的GET请求:

package diskcache

import
(
  "errors"
  "io/ioutil"
  "log"
  "os"
  "strings"
  "sync"
  "time"
)

const(
 CACHEDIR = "/var/www/cache/"
)

这显然代表了缓存文件的严格位置,但它也可以分成基于上下文的子目录,例如,在这种情况下,我们的 API 端点。因此,/api/usersGET请求中将映射到/var/www/cache/users/get/.这样可以减少单个目录中的数据量:

var MaxAge int64  = 60
var(
  ErrMissingFile = errors.New("File Does Not Exist")
  ErrMissingStats = errors.New("Unable To Get File Stats")
  ErrCannotWrite = errors.New("Cannot Write Cache File")
  ErrCannotRead = errors.New("Cannot Read Cache File")
)

type CacheItem struct {
  Name string
  Location string
  Cached bool
  Contents string
  Age int64
}

我们通用的CacheItem结构由文件名、物理位置、以秒为单位的年龄和内容组成,如下面的代码中所述:

func (ci *CacheItem) IsValid(fn string) bool {
  lo := CACHEDIR + fn
  ci.Location = lo

  f, err := os.Open(lo)
  defer f.Close()
  if err != nil {
    log.Println(ErrMissingFile)
    return false
  }

  st, err := f.Stat()
  if err != nil {
    log.Println(ErrMissingStats)
    return false
  }

  ci.Age := int64(time.Since(st.ModTime()).Seconds())
  return (ci.Age <= MaxAge)
}

我们的IsValid()方法首先确定文件是否存在且可读,如果它的年龄超过MaxAge变量。如果无法读取或者它太旧,那么我们返回 false,这告诉我们的Evaluate()入口创建文件。否则,我们返回 true,这将指示Evaluate()函数执行对现有缓存文件的读取。

func (ci *CacheItem) SetCache() {
  f, err := os.Create(ci.Location)
  defer f.Close()
  if err != nil {
    log.Println(err.Error())
  } else {
    FileLock.Lock()
    defer FileLock.Unlock()
    _, err := f.WriteString(ci.Contents)
    if err != nil {
      log.Println(ErrCannotWrite)
    } else {
      ci.Age = 0
    }
  }
  log.Println(f)
}

在我们的导入部分,您可能会注意到调用了sync包;SetCache()应该在生产中至少利用互斥锁来对文件操作进行加锁。我们使用Lock()Unlock()(在延迟中)来处理这个问题。

func (ci *CacheItem) GetCache() error {
  var e error
  d, err := ioutil.ReadFile(ci.Location)
  if err == nil {
    ci.Contents = string(d)
  }
  return err
}

func Evaluate(context string, value string, expireAge int64, qu ...string) (error, CacheItem) {

  var err error
  var ci CacheItem
  ci.Contents = value
  ci.Name = context + strings.Join(qu,"-")
  valid := ci.IsValid(ci.Name)

请注意,这里的文件名是通过连接qu可变参数中的参数生成的。如果我们想要对此进行微调,我们需要按字母顺序对参数进行排序,这将防止缓存丢失,如果参数以不同的顺序提供。

由于我们控制了起始调用,所以风险较低。然而,由于这是作为一个共享库构建的,因此行为应该是相当一致的。

  if !valid {
    ci.SetCache()
    ci.Cached = false
  } else {
    err = ci.GetCache()
    ci.Cached = true
  }

  return err, ci
}

我们可以使用一个简单的示例来测试这一点,该示例只是按值写文件:

package main

import
(
  "fmt"
  "github.com/nkozyra/api/diskcache"
)

func main() {
  err,c := diskcache.Evaluate("test","Here is a value that will only live for 1 minute",60)
  fmt.Println(c)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Println("Returned value is",c.Age,"seconds old")
  fmt.Println(c.Contents)
}

如果我们运行这个,然后改变这里是一个值...的值,并在 60 秒内再次运行它,我们将得到我们的缓存值。这表明我们的 diskcache 包保存并返回值,而不会触及可能成为后端瓶颈的内容。

因此,现在让我们在UsersRetrieve()处理程序前面加上一些可选参数。通过将我们的缓存设置为pagesearch作为可缓存的参数,我们将减轻对数据库的基于负载的影响。

分布式内存中的缓存

与基于磁盘的缓存类似,尽管这仍然是磁盘缓存的一个有用替代,但我们在简单的内存缓存中仍然受限于单个实体键。

用类似 Memcache(d)的东西替换磁盘将使我们能够非常快速地检索,但在键方面不会给我们带来任何好处。此外,大量重复的潜力意味着我们的内存存储通常比物理存储小,可能会成为一个问题。

然而,有许多方法可以潜入内存或分布式内存缓存。我们不会向您展示这种可替换的方法,但是通过与 NoSQL 解决方案的衔接,您可以轻松地将两种类型的缓存转换为严格的仅内存缓存选项。

使用 NoSQL 作为缓存存储

与 Memcache(d)不同,使用数据存储或数据库,我们可以根据非链接参数进行更复杂的查找。

例如,在我们的diskcache包中,我们将参数(如pagesearch)链接在一起,这样我们的键(在这种情况下是文件名)就变成了getusers_1_nathan.cache

这些键以一种一致可靠的方式生成以进行查找是至关重要的,因为任何更改都会导致缓存未命中而不是预期的命中,我们将需要重建我们的缓存请求,这将完全消除预期的好处。

对于数据库,我们可以对缓存请求进行非常详细的列查找,但是,考虑到关系数据库的性质,这并不是一个好的解决方案。毕竟,我们构建缓存层是非常具体的,以避免命中常见瓶颈,如关系数据库管理系统。

为了举例,我们将再次利用 MongoDB 来编译和查找我们的缓存文件,以实现高吞吐量和可用性,并具有参数相关查询所提供的额外灵活性。

在这种情况下,我们将添加一个基本文档,其中只有一个页面、搜索、内容和一个修改字段。最后一个字段将作为我们的时间戳进行分析。

尽管page看起来是一个明显的整数字段,但我们将其在 MongoDB 中创建为字符串,以避免在进行查询时进行类型转换。

package memorycache

出于明显的原因,我们将其称为memorycache,而不是 memcache,以避免任何潜在的混淆。

import
(
  "errors"
  "log"
  mgo "gopkg.in/mgo.v2"
  bson "gopkg.in/mgo.v2/bson"
  _ "strings"
  "sync"
  "time"
)

我们用 MongoDB 的包替换了任何基于操作系统和磁盘的包。BSON 包也包含在内,作为进行特定的Find()请求的一部分。

提示

在生产环境中,当寻找一个键值存储或内存存储用于这样的意图时,应该注意解决方案的锁定机制及其对读/写操作的影响。

const(
  MONGOLOC = "localhost"
)

var MaxAge int64  = 60
var Session mgo.Session
var Collection *mgo.Collection

var(
  ErrMissingFile = errors.New("File Does Not Exist")
  ErrMissingStats = errors.New("Unable To Get File Stats")
  ErrCannotWrite = errors.New("Cannot Write Cache File")
  ErrCannotRead = errors.New("Cannot Read Cache File")

  FileLock sync.RWMutex
)

type CacheItem struct {
  Name string
  Location string
  Contents string
  Age int64
  Parameters map[string] string
}

值得注意的是,MongoDB 具有数据过期的生存时间概念。这可能消除了手动过期内容的必要性,但在替代存储平台上可能无法使用。

type CacheRecord struct {
  Id bson.ObjectId `json:"id,omitempty" bson:"_id,omitempty"`
  Page string
  Search string
  Contents string
  Modified int64
}

请注意CacheRecord结构中的文字标识符;这些允许我们自动生成 MongoDB ID。如果没有这个,MongoDB 将抱怨_id_上的重复索引。以下的IsValid()函数实际上返回了有关我们的diskcache包中文件的信息。在memorycache版本中,我们只会返回一个信息,即请求的年龄内是否存在记录。

func (ci *CacheItem) IsValid(fn string) bool {
  now := time.Now().Unix()
  old := now - MaxAge

  var cr CacheRecord
  err := Collection.Find(bson.M{"page":"1", "modified": bson.M{"$gt":old} }).One(&cr)
  if err != nil {
    return false
  } else {
    ci.Contents = cr.Contents
    return true
  }

  return false
}

还要注意,我们没有删除旧记录。这可能是保持缓存记录敏捷的逻辑下一步。

func (ci *CacheItem) SetCache() {
  err := Collection.Insert(&CacheRecord{Id: bson.NewObjectId(), Page:ci.Parameters["page"],Search:ci.Parameters["search"],Contents:ci.Contents,Modified:time.Now().Unix()})
  if err != nil {
    log.Println(err.Error())
  }
}

无论我们是否找到记录,我们都会在前面的代码中插入一个新记录。这使我们在查找时获得最新的记录,也使我们在某种程度上具有修订控制的意识。您还可以更新记录以避免修订控制。

func init() {
  Session, err := mgo.Dial(MONGOLOC)
  if err != nil {
    log.Println(err.Error())
  }
  Session.SetMode(mgo.Monotonic, true)
  Collection = Session.DB("local").C("cache")
  defer Session.Ping()
}

func Evaluate(context string, value string, expireAge int64, param map[string]string) (error, CacheItem) {

  MaxAge = expireAge
  defer Session.Close()

  var ci CacheItem
  ci.Parameters = param
  ci.Contents = value
  valid := ci.IsValid("bah:")
  if !valid {
    ci.SetCache()
  }

  var err error

  return err, ci
}

这与diskcache的操作方式基本相同,只是我们在param哈希映射中提供键/值对,而不是无结构的参数名称列表。

因此,使用方法会有一些变化。这里有一个例子:

package main

import
(
  "fmt"
  "github.com/nkozyra/api/memorycache"
)

func main() {
  parameters := make( map[string]string )
  parameters["page"] = "1"
  parameters["search"] = "nathan"
  err,c := memorycache.Evaluate("test","Here is a value that will only live for 1 minute",60,parameters)
  fmt.Println(c)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Println("Returned value is",c.Age,"seconds old")
  fmt.Println(c.Contents)
}

当我们运行这个时,我们将在数据存储中设置我们的内容,这将持续 60 秒,然后变为无效,并在第二行重新创建缓存内容。

实施缓存作为中间件

为了将此缓存放置在我们所有GET请求的中间件链中,我们可以实现我们上面概述的策略并添加一个缓存中间件元素。

使用我们之前的示例,我们可以使用我们的middleware()函数在链的前面实现这一点:

  Routes.HandleFunc("/api/users", middleware(DiskCache, UsersRetrieve, DiskCacheSave) ).Methods("GET")

这使我们能够在UsersRetrieve()函数之前执行DiskCache()处理程序。但是,如果我们没有有效的缓存,我们还希望保存我们的响应,因此我们还将在最后调用DiskCacheSave()。如果DiskCache()中间件处理程序接收到有效的缓存,它将阻止链。它的工作原理如下:

func DiskCache(h http.HandlerFunc) http.HandlerFunc {
  start := 0
  q := r.URL.Query()
  if len(r.URL.Query()["start"]) > 0 {
    start = r.URL.Query()["start"][0]
  }
  limit := 10
  if len(r.URL.Query()["limit"]) > 0 {
    limit = q["limit"][0]
  }
  valid, check := diskcache.Evaluate("GetUsers", "", MaxAge, start, limit)
  fmt.Println("Cache valid",valid)
  if check.Cached  {
    return func(w http.ResponseWriter, r *http.Request) {
      fmt.Fprintln(w, check.Contents)
    }
  } else {
    return func(w http.ResponseWriter, r *http.Request) {
      h.ServeHTTP(w, r)
    }
  }
}

如果我们得到check.Cached为 true,我们只需提供内容。否则,我们继续。

在将内容传输到下一个函数之前,我们的主要函数需要进行一些小的修改:

  r.CacheContents = string(output)
  fmt.Fprintln(w, string(output))
}

然后,DiskCacheSave()基本上可以是DiskCache的副本,只是它将从http.Request函数设置实际内容:

func DiskCacheSave(h http.HandlerFunc) http.HandlerFunc {
  start := 0
  if len(r.URL.Query()["start"]) > 0 {
    start = r.URL.Query()["start"][0]
  }
  limit := 10
  if len(r.URL.Query()["limit"]) > 0 {
    start = r.URL.Query()["limit"][0]
  }
  valid, check := diskcache.Evaluate("GetUsers", r.CacheContents, MaxAge, start, limit)
  fmt.Println("Cache valid",valid)
  return func(w http.ResponseWriter, r *http.Request) {
    h.ServeHTTP(w, r)
  }

}

在 Go 前面使用前端缓存代理

我们工具箱中的另一个工具是利用前端缓存代理(就像我们在第七章中所做的那样,使用其他 Web 技术)作为我们面向请求的缓存层。

除了传统的 Web 服务器,如 Apache 和 Nginx,我们还可以使用专门用于缓存的服务,这些服务可以替代、放在前面或与上述服务器并行使用。

在不深入研究这种方法的情况下,我们可以从应用程序外部复制一些功能,以获得更好的性能。如果我们至少不提及这一点,那就不够完整。像 Nginx、Varnish、Squid 和 Apache 这样的工具都具有反向代理服务器的内置缓存。

对于生产级部署,这些工具可能更成熟,更适合处理这种级别的缓存。

提示

您可以在nginx.com/resources/admin-guide/caching/找到有关 Nginx 和反向代理缓存的更多信息。

Varnish 和 Squid 都主要用于在这个级别进行缓存。有关 Varnish 和 Squid 的更多详细信息,可以在www.varnish-cache.org/www.squid-cache.org/找到。

在 Go 中进行速率限制

向我们的 API 引入缓存可能是展示有效中间件策略的最简单方法。我们现在能够减轻重负流量的风险,并朝着

在 Web 服务中,这种中间件功能特别有用的地方是速率限制。

在具有高流量的 API 中存在速率限制,以允许消费者使用应用程序而不会滥用它。在这种情况下,滥用可能只是指可能影响性能的过度访问,或者可能意味着为大规模数据获取创建障碍。

通常,人们会利用 API 来创建整个数据集的本地索引,通过 API 有效地爬取网站。对于大多数应用程序,您将希望阻止这种访问。

在任何一种情况下,对我们应用程序中的某些请求施加一些速率限制是有意义的。并且,重要的是,我们希望这足够灵活,以便我们可以根据请求时间使用不同的限制。

我们可以使用许多因素来实现这一点,但最常见的两种方法如下:

  • 通过相应的 API 用户凭据

  • 通过请求的 IP 地址

理论上,我们也可以通过每个代理发出请求来对底层用户引入速率限制。在现实世界的情况下,这将减少第三方应用因其用户使用而受到惩罚的风险。

重要的因素是,在深入研究更复杂的调用之前,我们发现速率限制已超出标记,因为我们希望在速率限制已超出的那一点精确地打破中间件链。

对于这个速率限制中间件示例,我们将再次使用 MongoDB 作为请求存储,并基于从午夜到午夜的日历日进行限制。换句话说,我们每个用户的限制每天在凌晨 12:01 重置。

存储实际请求只是一种方法。我们也可以从 Web 服务器日志中读取或将它们存储在内存中。然而,最轻量级的方法是将它们保存在数据存储中。

package ratelimit

import
(
  "errors"
  "log"
  mgo "gopkg.in/mgo.v2"
  bson "gopkg.in/mgo.v2/bson"
  _ "strings"
  "time"
)

const(
  MONGOLOC = "localhost"
)

这只是我们的 MongoDB 主机或主机。在这里,我们有一个结构,用于定义日历日的开始和结束的边界:

type Requester struct {
  Id bson.ObjectId `json:"id,omitempty" bson:"_id,omitempty"`
  IP string
  APIKey string
  Requests int
  Timestamp int64
  Valid bool
}

type DateBounds struct {
  Start int64
  End int64
}

以下的CreateDateBounds()函数计算今天的日期,然后将86400秒添加到返回的Unix()值(实际上是 1 天)。

var (
  MaxDailyRequests = 15
  TooManyRequests = errors.New("You have exceeded your daily limit of requests.")
  Bounds DateBounds
  Session mgo.Session
  Collection *mgo.Collection
)

func createDateBounds() {
  today := time.Now()
  Bounds.Start = time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC).Unix()
  Bounds.End = Bounds.Start + 86400
}

通过以下的RegisterRequest()函数,我们只是简单地记录了对 API 的另一个请求。同样,我们只是将请求绑定到 IP,添加认证密钥、用户 ID 或

func (r *Requester) CheckDaily() {

  count, err := Collection.Find(bson.M{"ip": r.IP, "timestamp": bson.M{"$gt":Bounds.Start, "$lt":Bounds.End } }).Count()
  if err != nil {
    log.Println(err.Error())
  }
  r.Valid = (count <= MaxDailyRequrests)
}

func (r Requester) RegisterRequest() {
  err := Collection.Insert(&Requester{Id: bson.NewObjectId(), IP: r.IP, Timestamp: time.Now().Unix()})
  if err != nil {
    log.Println(err.Error())
  }

}

以下代码是一个简单的标准初始化设置,除了createDateBounds()函数,它只是设置了我们查找的开始和结束:

func init() {
  Session, err := mgo.Dial(MONGOLOC)
  if err != nil {
    log.Println(err.Error())
  }
  Session.SetMode(mgo.Monotonic, true)
  Collection = Session.DB("local").C("requests")
  defer Session.Ping()
  createDateBounds()
}

以下的CheckRequest()函数充当整个过程的协调函数;它确定任何给定请求是否超过了每日限制,并返回Valid状态属性:

func CheckRequest(ip string) (bool) {
  req := Requester{ IP: ip }
  req.CheckDaily()
  req.RegisterRequest()

  return req.Valid
}

实施速率限制作为中间件

与缓存系统不同,将速率限制器转换为中间件要容易得多。要么 IP 地址受到速率限制,要么没有,我们继续进行。

以下是更新用户的示例:

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

然后,我们可以引入一个RateLimit()中间件调用:

func RateLimit(h http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    if (ratelimit.CheckRequest(r.RemoteAddr) == false {
      fmt.Fprintln(w,"Rate limit exceeded")
    } else {
      h.ServeHTTP(w,r)
    }
  }
}

这使我们能够在我们的ratelimit.CheckRequest()调用失败时阻止中间件链,并防止调用 API 的更多处理密集型部分。

实施 SPDY

如果有一件事你可以说谷歌庞大的产品、平台和语言生态系统,那就是它们都有一个永恒的、一致的关注点——对速度的需求。

注意

我们在第七章中简要提到了 SPDY 伪协议,使用其他 Web 技术。您可以从其白皮书www.chromium.org/spdy/spdy-whitepaper中了解更多关于 SPDY 的信息。

随着谷歌(搜索引擎)从一个学生项目迅速发展成为地球上最受欢迎的网站,成为人们发现任何事物的事实方式,产品及其基础设施的扩展变得至关重要。

而且,如果你仔细想想,这个搜索引擎非常依赖于网站的可用性;如果网站速度快,谷歌的蜘蛛和索引器将更快,结果也将更加及时。

其中很多都是谷歌的让网络更快活动背后的原因,该活动旨在通过认识到速度是主要考虑因素并朝着速度推进,来帮助后端和前端开发人员。

谷歌也是 SPDY 伪协议的背后推手,它增强了 HTTP 并作为一组改进的临时措施,其中许多改进正在被纳入 HTTP/2 的标准化。

有很多为 Go 编写的 SPDY 实现,SPDY 似乎是一个特别受欢迎的项目,因为它尚未直接在 Go 中支持。大多数实现都可以替换net/http中的http,在大多数实际情况下,你可以通过简单地将 SPDY 留给 HAProxy 或 Nginx 等反向代理来获得这些好处。

注意

以下是一些实现了安全和非安全连接的 SPDY 实现,值得一看并进行比较:

Solomon Hykes 的spdy.go文件:github.com/shykes/spdy-go

Jamie Hall 的spdy文件:github.com/SlyMarbo

我们首先来看一下前面列表中的spdy.go。切换我们的ListenAndServe函数是最简单的第一步,这种实现 SPDY 的方法是相当常见的。

以下是如何在我们的api.go文件中使用spdy.go作为一个可替换的方法:

  wg.Add(1)
  go func() {
    //http.ListenAndServeTLS(SSLport, "cert.pem", "key.pem", Routes)
    spdy.ListenAndServeTLS(SSLport, "cert.pem", "key.pem", Routes)
    wg.Done()
  }()

相当简单,是吧?一些 SPDY 实现通过 SPDY 协议提供页面,而不是HTTP/HTTPS,在语义上是无法区分的。

对于一些 Go 开发者来说,这被视为一种惯用的方法。对于其他人来说,这些协议的差异足够大,以至于有单独的语义是合乎逻辑的。在这里的选择取决于您的偏好。

然而,还有一些其他考虑因素需要考虑。首先,SPDY 引入了一些我们可以利用的附加功能。其中一些是内置的,比如头部压缩。

检测 SPDY 支持

对于大多数客户端来说,检测 SPDY 并不是需要过多担心的事情,因为 SPDY 支持依赖于 TLS/SSL 支持。

总结

在本章中,我们讨论了一些对高性能 API 非常重要的概念。这些主要包括通过自定义中间件执行的速率限制和磁盘和内存缓存。

利用本章中的示例,您可以实现任意数量的依赖中间件的服务,以保持代码清晰,并引入更好的安全性、更快的响应时间和更多功能。

在接下来的最后一章中,我们将专注于安全特定的概念,这些概念应该锁定额外的关注点,包括速率限制、拒绝服务检测,以及减轻和预防代码和 SQL 注入的尝试。

第十一章:安全

在我们开始本章之前,绝对必要指出一件事——尽管安全是本书最后一章的主题,但它不应该是应用程序开发的最终步骤。在开发任何 Web 服务时,安全性应该在每一步骤中得到重视。通过在设计时考虑安全性,您可以限制应用程序启动后进行自上而下的安全审计的影响。

话虽如此,这里的意图是指出一些更大更猖獗的安全漏洞,并探讨我们如何使用标准的 Go 和一般安全实践来减轻它们对我们的 Web 服务的影响。

当然,Go 提供了一些出色的安全功能,它们被伪装成纯粹的良好编程实践。使用所有包和处理所有错误不仅有助于养成良好的习惯,而且还有助于确保应用程序的安全。

然而,没有一种语言可以提供完美的安全性,也无法阻止你自己给自己惹麻烦。事实上,最具表现力和实用性的语言往往使这变得尽可能容易。

在开发自己的设计与使用现有包(就像我们在整本书中所做的那样)之间也存在很大的权衡,无论是用于身份验证、数据库接口还是 HTTP 路由或中间件。前者可以提供快速解决方案,并减少错误和安全漏洞的曝光。

通过构建自己的应用程序,还可以提供一些安全性,但对安全更新的迅速响应以及整个社区的目光都胜过一个较小的闭源项目。

在本章中,我们将看到:

  • 处理安全目的的错误日志记录

  • 防止暴力尝试

  • 记录身份验证尝试

  • 输入验证和注入缓解

  • 输出验证

最后,我们将看一些生产就绪的框架,以了解它们处理 API 和 Web 服务集成以及相关安全性的方式。

处理安全目的的错误日志记录

在通往安全应用程序的道路上,关键的一步是使用全面的日志记录。您拥有的数据越多,就越能分析潜在的安全漏洞,并了解应用程序的使用方式。

即使如此,“记录所有”方法可能有些难以利用。毕竟,如果你有所有的干草,找到其中的针可能会特别困难。

理想情况下,我们希望将所有错误记录到文件,并具有将其他类型的一般信息(例如与用户和/或 IP 地址相关的 SQL 查询)分隔的能力。

在下一节中,我们将看一下记录身份验证尝试,但仅在内存/应用程序的生命周期中,以便检测暴力尝试。更广泛地使用日志包可以让我们保持对这些尝试的更持久的记录。

创建日志输出的标准方法是简单地设置一般日志Logger的输出,就像这样:

dbl, err := os.OpenFile("errors.log", os.O_CREATE | os.RDWR | os.O_APPEND, 0666)
  if err != nil {
    log.Println("Error opening/creating database log file")
  }
defer dbl.Close()

log.SetOutput(dbl)

这使我们能够指定一个新文件,而不是我们默认的stdout类,用于记录我们的数据库错误,以便以后分析。

然而,如果我们想要为不同的错误(例如数据库错误和身份验证错误)创建多个日志文件,我们可以将它们分成单独的记录器:

package main

import (
  "log"
  "os"
)

var (
  Database       *log.Logger
  Authentication *log.Logger
  Errors         *log.Logger
)

func LogPrepare() {
  dblog, err := os.OpenFile("database.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
  if err != nil {
    log.Println(err)
  }
  authlog, err := os.OpenFile("auth.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
  if err != nil {
    log.Println(err)
  }
  errlog, err := os.OpenFile("errors.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
  if err != nil {
    log.Println(err)
  }

  Database = log.New(dblog, "DB:", log.Ldate|log.Ltime)
  Authentication = log.New(authlog, "AUTH:", log.Ldate|log.Ltime)
  Errors = log.New(errlog, "ERROR:", log.Ldate|log.Ltime|log.Lshortfile)
}

在这里,我们使用特定格式为我们的日志文件实例化单独的记录器:

func main() {
  LogPrepare()

  Database.Println("Logging a database item")
  Authentication.Println("Logging an auth attempt item")
  Errors.Println("Logging an error")

}

通过以这种方式为应用程序的各个元素构建单独的日志,我们可以分而治之地进行调试过程。

关于记录 SQL,我们可以利用sql.Prepare()函数,而不是使用sql.Exec()sql.Query()在执行之前保留对查询的引用。

sql.Prepare()函数返回一个sql.Stmt结构,而查询本身,由变量 query 表示,不会被导出。但是,您可以在日志文件中使用结构的值本身:

  d, _ := db.Prepare("SELECT fields FROM table where column=?")
  Database.Println(d)

这将在日志文件中留下查询的详细账户。为了获得更多细节,IP 地址可以附加到Stmt类以获取更多信息。

将每个交易查询存储到文件中可能会对性能产生影响。将其限制为修改数据的查询和/或短时间内将允许您识别安全性潜在问题。

注意

有一些第三方库可以进行更强大和/或更漂亮的记录。我们最喜欢的是 go-logging,它实现了多种输出格式、分区调试桶和具有吸引人的格式的可扩展错误。您可以在github.com/op/go-logging上阅读更多信息,或通过go get github.com/op/go-logging命令下载文档。

防止暴力破解尝试

也许是绕过任何给定系统安全性的最常见、最低级别的尝试是暴力破解方法。

从攻击者的角度来看,这是有道理的。如果应用程序设计者允许无限次数的登录尝试而不受惩罚,那么这个应用程序执行良好的密码创建策略的可能性就很低。

这使得它成为一个特别容易受攻击的应用程序。即使密码规则已经制定,仍然有可能使用字典攻击来获取登录权限。

一些攻击者会查看彩虹表以确定哈希策略,但这至少在某种程度上被每个帐户使用唯一的盐所缓解。

实际上,在线下时代,暴力登录攻击通常更容易,因为大多数应用程序没有流程来自动检测和锁定使用无效凭据的帐户访问尝试。他们本来可以这样做,但那么也需要有一个检索权限流程——类似于“给我发邮件我的密码”。

对于像我们的社交网络这样的服务,锁定帐户或在一定程度后暂时禁用登录是非常有意义的。

第一个是一种更激进的方法,需要直接用户操作来恢复帐户;通常,这也需要更大的支持系统。

后者是有益的,因为它通过大大减慢尝试的速度来阻止暴力破解尝试,并使大多数攻击在实际目的上变得无用,而不一定需要用户操作或支持来恢复访问。

知道要记录什么

在记录日志时最难的事情之一是决定你需要知道什么。有几种方法可以做到这一点,从记录所有内容到仅记录致命错误。所有这些方法都伴随着自己的潜在问题,这在很大程度上取决于错过一些数据和浏览不可能的数据之间的权衡。

我们需要考虑的第一个问题是我们应该在内存中记录什么——只有失败的身份验证或针对 API 密钥和其他凭据的尝试。

记录针对不存在用户的登录尝试也可能是明智的。这将告诉我们,有人很可能在对我们的网络服务进行不正当的操作。

接下来,我们将希望设置一个较低的阈值或登录尝试的最大次数,然后再采取行动。

让我们首先介绍一个bruteforcedetect包:

package bruteforcedetect

import
(
)

var MaxAttempts = 3

我们可以直接将其设置为一个包变量,并在必要时从调用应用程序中进行修改。三次尝试可能比我们希望的一般无效登录阈值要低,特别是自动禁止 IP 的情况:

type Requester struct {
  IP string
  LoginAttempts int
  FailedAttempts int
  FailedInvalidUserAttempts int
}

我们的Requester结构将维护与任何给定 IP 或主机名相关的所有增量值,包括一般的登录尝试、失败的尝试以及请求的用户实际上并不存在于我们的数据库中的失败尝试:

func Init() {

}

func (r Requester) Check() {

}

我们不需要将其作为中间件,因为它只需要对一件事情做出反应——认证尝试。因此,关于认证尝试的存储,我们有选择。在现实环境中,我们可能希望给这个过程更长的寿命。我们可以直接将这些尝试存储到内存中、数据存储中,甚至存储到磁盘中。

然而,在这种情况下,我们将通过创建bruteforce.Requester结构的映射,让这些数据仅存在于应用程序的内存空间中。这意味着如果我们的服务器重新启动,我们将丢失这些尝试。同样,这意味着多个服务器设置不一定会知道其他服务器上的尝试。

这两个问题都可以通过在记录错误尝试的背后放置更少的短暂存储来轻松解决,但是为了演示的简单性,我们将保持简单。

在我们的api.go文件中,我们将引入bruteforce并在启动应用程序时创建我们的Requesters映射:

package main

import (
…
    "github.com/nkozyra/api/bruteforce"
)

var Database *sql.DB
var Routes *mux.Router
var Format string
var Logins map[string] bruteforce.Requester

然后,当我们的服务器启动时,当然要将这个从空映射变成一个初始化的映射:

func StartServer() {

  LoginAttempts = make(map[string] bruteforce.Requester)
OauthServices.InitServices()

我们现在准备开始记录我们的尝试。

如果您决定为登录尝试实现中间件,在这里进行调整,只需将这些更改放入中间件处理程序中,而不是最初调用的名为CheckLogin()的单独函数。

无论我们的认证发生了什么——无论是有效的用户、有效的认证;有效的用户、无效的认证;还是无效的用户——我们都希望将其添加到相应的Requester结构的LoginAttempts函数中。

我们将每个Requester映射绑定到我们的 IP 或主机名。在这种情况下,我们将使用 IP 地址。

注意

net包有一个名为SplitHostPort的函数,可以正确地从http.Request处理程序中的RemoteAddr值中分解出来,如下所示:

ip,_,_ := net.SplitHostPort(r.RemoteAddr)

您也可以只使用整个r.RemoteAddr值,这可能更全面:

func CheckLogin(w http.ResponseWriter, r *http.Request) bool {
  if val, ok := Logins[r.RemoteAddr]; ok {
    fmt.Println("Previous login exists",val)
  } else {
    Logins[r.RemoteAddr] = bruteforce.Requester{IP: r.RemoteAddr, LoginAttempts:0, FailedAttempts: 0, FailedValidUserAttempts: 0, }
  }

  Logins[r.RemoteAddr].LoginAttempts += 1

这意味着无论如何,我们都会对总数进行另一次尝试。

由于CheckLogin()总是会在不存在时创建映射的键,我们可以在认证流程的后面安全地对这个键进行评估。例如,在我们的UserLogin()处理程序中,首先调用UserLogin(),然后再检查提交的值:

func UserLogin(w http.ResponseWriter, r *http.Request) {

  w.Header().Set("Access-Control-Allow-Origin", "*")
  fmt.Println("Attempting User Login")

  Response := UpdateResponse{}
 CheckLogin(w,r)

如果我们在CheckLogin()调用之后检查最大的登录尝试次数,我们将永远不会在某一点之后允许数据库查找。

UserLogin()函数的以下代码中,我们将提交的密码的哈希与数据库中存储的密码哈希进行比较,并在不成功匹配时返回错误。让我们使用它来增加FailedAttempts值:

  if (dbPassword == expectedPassword) {
    // ...
  } else {
    fmt.Println("Incorrect password!")
    _, httpCode, msg := ErrorMessages(401)
    Response.Error = msg
    Response.ErrorCode = httpCode
    Logins[r.RemoteAddr].FailedAttempts = Logins[r.RemoteAddr].FailedAttempts + 1
    http.Error(w, msg, httpCode)
  }

这只是增加了我们的一般FailedAttempts整数值,每个 IP 的无效登录都会增加这个值。

然而,我们还没有对此做任何处理。为了将其作为一个阻塞元素注入,我们需要在CheckLogin()调用之后对其进行评估,以初始化映射的哈希(如果尚不存在):

提示

在前面的代码中,您可能会注意到由RemoteAddr绑定的可变FailedAttempts值在理论上可能会受到竞争条件的影响,导致不自然的增加和过早的阻塞。可以使用互斥锁或类似的锁定机制来防止这种行为。

func UserLogin(w http.ResponseWriter, r *http.Request) {

  w.Header().Set("Access-Control-Allow-Origin", "*")
  fmt.Println("Attempting User Login")

if Logins[r.RemoteAddr].Check() == false {
  return
}

这个对Check()的调用可以防止被禁止的 IP 地址甚至在登录端点访问我们的数据库,这仍然可能导致额外的压力、瓶颈和潜在的服务中断:

  Response := UpdateResponse{}
  CheckLogin(w,r)
  if Logins[r.RemoteAddr].Check() == false {
    _, httpCode, msg := ErrorMessages(403)
    Response.Error = msg
    Response.ErrorCode = httpCode
    http.Error(w, msg, httpCode)
    return
  }

为了更新我们的Check()方法,以防止暴力攻击,我们将使用以下代码:

func (r Requester) Check() bool {
  return r.FailedAttempts <= MaxAttempts
}

这为我们提供了一种短暂的方式来存储有关登录尝试的信息,但是如果我们想找出某人是否只是在测试帐户名和密码,比如“guest”或“admin”,该怎么办呢?

为此,我们只需在UserLogin()中添加额外的检查,以查看所请求的电子邮件帐户是否存在。如果存在,我们将继续。如果不存在,我们将增加FailedInvalidUserAttempts。然后我们可以决定是否应该在UserLogin()的登录部分下降到更低的阈值:

  var dbPassword string
  var dbSalt string
  var dbUID int
  var dbUserCount int
  uexerr := Database.QueryRow("SELECT count(*) from users where user_email=?",email).Scan(&dbUserCount)
  if uexerr != nil {

  }
  if dbUserCount > 0 {
    Logins[r.RemoteAddr].FailedInvalidUserAttempts = Logins[r.RemoteAddr].FailedInvalidUserAttempts + 1
  }

如果我们决定流量由完全失败的身份验证尝试(例如,无效用户)表示,我们还可以将该信息传递给 IP 表或我们的前端代理,以阻止流量甚至到达我们的应用程序。

在 Go 中处理基本身份验证

在第七章中,我们没有深入研究身份验证部分,与其他 Web 技术合作,基本身份验证。这是值得讨论的安全问题,特别是它可以是一种非常简单的方式,允许身份验证代替 OAuth、直接登录(带会话)或密钥。即使在后者中,完全可以利用 API 密钥作为基本身份验证的一部分。

基本身份验证最关键的方面是一个显而易见的一点——TLS。与涉及传递密钥的方法不同,在基本身份验证标头方法中几乎没有混淆,除了 Base64 编码之外,一切基本上都是明文。

当然,这为恶意方提供了一些非常简单的中间人机会。

在第七章中,与其他 Web 技术合作,我们探讨了使用共享密钥创建交易密钥并通过会话存储有效身份验证的概念。

我们可以直接从“授权”标头中获取用户名和密码或 API 密钥,并通过在我们的CheckLogin()调用顶部包含对该标头的检查来测量对 API 的尝试:

func CheckLogin(w http.ResponseWriter, r *http.Request) {
  bauth := strings.SplitN(r.Header["Authorization"][0], " ", 2)
  if bauth[0] == "Basic" {
    authdata, err := base64.StdEncoding.DecodeString(bauth[1])
    if err != nil {
      http.Error(w, "Could not parse basic auth", http.StatusUnauthorized)
      return
    }
      authparts := strings.SplitN(string(authdata),":",2)
      username := authparts[0]
      password := authparts[1]
    }else {
      // No basic auth header
    }

在这个例子中,我们可以允许我们的CheckLogin()函数利用要么从我们的 API 发布的数据来获取用户名和密码组合、API 密钥或身份验证令牌,要么我们也可以直接从标头中摄取这些数据。

处理输入验证和注入缓解

如果暴力攻击是一种相当不雅的坚持练习,攻击者没有访问、输入或注入攻击则相反。在这一点上,攻击者对应用程序有一定程度的信任,即使它很小。

SQL 注入攻击可以发生在应用程序管道的任何级别,但跨站点脚本和跨站点请求伪造更多地针对其他用户,而不是应用程序,针对漏洞暴露其数据或直接将其他安全威胁带到应用程序或浏览器。

在接下来的部分中,我们将通过输入验证来检查如何保持我们的 SQL 查询安全,然后转向其他形式的输入验证以及输出验证和净化。

使用 SQL 的最佳实践

在使用关系数据库时存在一些非常大的安全漏洞,其中大部分都适用于其他数据存储方法。我们已经看过一些这些漏洞,比如正确和唯一地加盐密码以及使用安全会话。即使在后者中,也总是存在一些会话固定攻击的风险,这允许共享或持久共享会话被劫持。

其中一个更普遍的攻击向量,现代数据库适配器 tend to 消除的是注入攻击。

注入攻击,特别是 SQL 注入,是最常见的,但也是最可避免的漏洞之一,可以暴露敏感数据,损害问责制,甚至使您失去对整个服务器的控制。

敏锐的眼睛可能已经注意到了,但在本书的前面,我们故意在我们的api.go文件中构建了一个不安全的查询,可以允许 SQL 注入。

这是我们原始的CreateUser()处理程序中的一行:

  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)

不言而喻,但是在几乎所有语言中,直接构造查询作为直接的 SQL 命令是不受欢迎的。

一个很好的经验法则是将所有外部生成的数据,包括用户输入、内部或管理员用户输入和外部 API 视为恶意。通过尽可能怀疑用户提供的数据,我们提高了捕捉潜在有害注入的几率。

我们的其他大部分查询都使用了参数化的Query()函数,该函数允许您添加与?标记对应的可变参数。

请记住,由于我们在数据库中存储用户的唯一盐(至少在我们的示例中),失去对 MySQL 数据库的访问权限意味着我们也失去了首次使用密码盐的安全好处。

这并不意味着在这种情况下所有账户的密码都会被暴露,但在这一点上,如果用户保持个人密码标准低劣,那么直接获取用户的登录凭据只有在利用其他服务方面才有用,也就是说,在服务之间共享密码。

验证输出

通常情况下,输出验证的概念似乎很陌生,特别是当数据在输入端进行了消毒时。

保留值的发送方式,并且仅在输出时对其进行消毒可能是有道理的,但这增加了这些值在传递给 API 消费者时可能未经消毒的几率。

有两种主要方式可以将有效负载传递给最终用户,一种是存储攻击,我们作为应用程序在服务器上保留向量,另一种是反射攻击,其中一些代码通过其他方法附加,例如包含有效负载的电子邮件消息。

API 和 Web 服务有时特别容易受到不仅XSS跨站脚本攻击的缩写)的影响,还有CSRF跨站请求伪造的缩写)。

我们将简要讨论这两种情况以及我们可以在 Web 服务中限制它们的有效性的方法。

防止 XSS 攻击

每当我们处理用户输入,以便稍后将其转换为其他用户消费的输出时,我们都需要警惕跨站脚本攻击或跨站请求伪造在生成的数据有效负载中的问题。

这不仅仅是输出验证的问题。这也应该在输入阶段进行处理。然而,我们的输出是我们在一个用户的任意文本和另一个用户消费该文本之间的最后防线。

传统上,最好通过以下假设性的恶意代码片段来说明这一点。用户通过POST请求击中我们的/api/statuses端点,经过选择的任何方法进行身份验证,并发布以下状态:

url -X POST -H "Authorization: Basic dGVzdDp0ZXN0" -H "Cache-Control: no-cache" -H "Postman-Token: c2b24964-c12d-c183-dd7f-5c1365f5ae81" -H "Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW" -F "status=Having a great day! <iframe src='somebadsite/somebadscript'></iframe>" https://localhost/api/statuses

如果像我们的界面示例一样呈现在模板中,那么使用 Go 的模板引擎将自动减轻这个问题。

让我们看看前面的示例数据在我们界面的用户配置文件页面上是什么样子:

防止 XSS 攻击

html/template包会自动转义 HTML 输出,以防止代码注入,并且需要覆盖以允许任何 HTML 标签原样输入。

然而,作为 API 提供者,我们对消费应用程序语言的类型以及对输入的消毒的支持或关注是中立的。

转义数据的责任是需要考虑的问题,也就是说,您的应用程序提供给客户端的数据是否应该预先经过消毒,或者是否应该附带有关消毒数据的使用说明?在几乎所有情况下,答案都是第一种选择,但根据您的角色和数据类型,情况可能有所不同。另一方面,在某些情况下(例如 API),在前端取消消毒数据意味着可能需要以多种方式重新格式化数据。

在本章的前面部分,我们向您展示了一些输入验证技术,用于允许或禁止某些类型的数据(如字符、标签等),您可以将这些技术应用到诸如/statuses之类的端点。

然而,更合理的做法是允许这些数据;但是,在将其保存到数据库/数据存储或通过 API 端点返回之前对其进行清理。以下是我们可以使用http/template包来执行这两种操作的方法。

首先,当我们通过/api/statuses端点接受数据时,我们可以利用html/template中的一个或多个函数来防止某些类型的数据被存储。这些函数如下:

  • template.HTMLEscapeString: 这将对 HTML 标签进行编码,并将生成的字符串呈现为非 HTML 内容

  • template.JSEscapeString(): 这将对字符串的 JavaScript 特定部分进行编码,以防止正确呈现

为了简化通过 HTML 输出的目的,我们可以只需对我们的数据应用HTMLEscapeString(),这将禁用任何 JavaScript 调用的执行:

func StatusCreate(w http.ResponseWriter, r *http.Request) {

  Response := CreateResponse{}
  UserID := r.FormValue("user")
  Status := r.FormValue("status")
  Token := r.FormValue("token")
  ConsumerKey := r.FormValue("consumer_key")

  Status = template.HTMLEscapeString(Status)

这使得数据在输入(StatusCreate)端进行转义。如果我们想要添加 JavaScript 转义(正如前面所述,可能并不需要),它应该在 HTML 转义之前进行,如下所示:

  Status = template.JSEscapeString(Status)
  Status = template.HTMLEscapeString(Status)

如果我们希望在输入端不进行转义,而是在输出端进行转义,那么可以在相应的状态请求 API 调用中进行相同的模板转义调用,比如/api/statuses

func StatusRetrieve(w http.ResponseWriter, r *http.Request) {
  var Response StatusResponse
  w.Header().Set("Access-Control-Allow-Origin", "*")
  loggedIn := CheckLogin(w, r)
  if loggedIn {

  } else {
    statuses,_ := Database.Query("select * from user_status where user_id=? order by user_status_timestamp desc",Session.UID)
    for statuses.Next() {

      status := Status{}
      statuses.Scan(&status.ID, &status.UID, &status.Time, &status.Text)
      status.Text = template.JSEscapeString(status.Text)
      status.Text = template.HTMLEscapeString(status.Text)
      Response.Statuses = append(Response.Statuses, status)
  }

如果我们想要尝试检测并记录尝试将特定 HTML 元素传递到输入元素中,我们可以为 XSS 尝试创建一个新的日志记录器,并捕获与<script>元素、<iframe>元素或其他任何元素匹配的任何文本。

这可以是一个像标记器或更高级的安全包一样复杂,也可以是一个像正则表达式匹配一样简单,我们将在接下来的示例中看到。首先,我们将查看我们日志设置中的代码:

var (
  Database       *log.Logger
  Authentication *log.Logger
  Errors         *log.Logger
  Questionable *log.Logger
)

我们初始化代码中的更改如下:

  questlog, err := os.OpenFile("injections.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
  if err != nil {
    log.Println(err)
  }
  Questionable = log.New(questlog, "XSS:", log.Ldate|log.Ltime)

然后,在我们应用程序的StatusCreate处理程序中进行以下更改:

  isinject, _ := regexp.MatchString("<(script|iframe).*",Status)
  if isinject  {

  }

通过正则表达式以这种方式检测标签既不是绝对可靠的,也不是本意。请记住,我们将在输入端或输出端对数据进行清理,因此如果我们可以通过这种方法捕捉到尝试,它将为我们提供一些关于对我们应用程序的潜在恶意尝试的见解。

如果我们想要更符合 Go 语言的习惯和更全面,我们可以简单地对文本进行清理并将其与原始文本进行比较。如果两个值不匹配,我们可以推断出 HTML 已被包含。

这意味着我们将对无害的 HTML 标签(如粗体标签或表格标签)进行转义。

在 Go 中使用服务器端框架

在详细介绍如何从头开始构建 Web 服务时,如果我们不至少触及集成或专门使用一些现有框架,那就不够周全了。

虽然通过插入这样一个框架来获得与从头开始设计一个框架相同的体验是不可能的,但出于实际目的,当您想要启动一个项目时,通常没有理由重新发明轮子。

Go 语言有一些现成的、成熟的 Web/HTML 框架,但也有一些特别为 Web 服务设计的值得注意的框架,其中一些提供了你可能期望看到的一些交付方法和额外的钩子。

根据某些标准,可以将 Gorilla 描述为一个框架;然而,正如其名称所暗示的那样,它有点基础。

无论您使用现有框架还是选择构建自己的框架(无论是出于经验还是出于完全定制业务需求),您都应该考虑做一些

我们将简要地看一下这些框架中的一些,以及它们如何简化小型基于 Web 的项目的开发。

Tiger Tonic

Tiger Tonic 是一个专门面向 API 的框架,因此我们将在本节中首先提到它。它采用了一种非常符合 Go 语言习惯的方式来开发 JSON Web 服务。

响应主要是以 JSON 格式为主,多路复用应该与 Gorilla 引入的风格非常相似。

Tiger Tonic 还提供了一些高质量的日志记录功能,允许您将日志直接导入 Apache 格式进行更详细的分析。最重要的是,它以一种方式处理中间件,允许根据中间件本身的结果进行一些条件操作。

注意

您可以在github.com/rcrowley/go-tigertonic了解更多关于 Tiger Tonic 的信息,或使用go get github.com/rcrowley/go-tigertonic命令下载文档。

Martini

Web 框架 Martini 是相对年轻的 Go 语言中较受欢迎的 Web 框架之一,这在很大程度上是因为它在设计上与Node.js框架 Express 和流行的 Ruby-on-Rails 框架 Sinatra 相似。

Martini 还与中间件非常搭配,以至于它经常被专门用于这个目的。它还带有一些标准的中间件处理程序,比如Logger()用于处理登录和退出的日志记录,Recovery()用于从 panic 中恢复并返回 HTTP 错误。

Martini 是为大量网络项目构建的,可能包括比简单的网络服务更多的内容;然而,它是一个非常全面的框架,值得一试。

注意

您可以在github.com/go-martini/martini了解更多关于 Martini 的信息,或使用go get github.com/go-martini/martini命令下载文档。

Goji

与 Martini 相比,Goji 框架是非常简约和精简的。Goji 的主要优势在于其非常快速的路由系统,额外垃圾收集的开销低,以及强大的中间件集成。

Goji 使用 Alice 作为中间件,我们在前面的章节中简要提到过。

注意

您可以在goji.io/了解更多关于 Goji 微框架的信息,并使用go get github.com/zenazn/gojigo get github.com/zenazn/goji/web命令下载它。

Beego

Beego 是一种更复杂的框架类型,已经迅速成为 Go 项目中较受欢迎的 Web 框架之一。

Beego 有许多功能,可以为网络服务提供便利,尽管其附加功能主要用于渲染网页。该框架配备了自己的会话、路由和缓存模块,并包括一个实时监控过程,允许您动态分析项目。

注意

您可以在beego.me/了解更多关于 Beego 的信息,或使用go get github.com/astaxie/beego命令下载它。

总结

在本章的最后,我们看了如何尽可能地使我们的网络服务免受常见的安全问题,并研究了如何在发生违规时减轻问题的解决方案。

随着 API 在受欢迎程度和范围上的扩展,确保用户及其数据的安全至关重要。

我们希望您已经(并将能够)利用这些安全最佳实践和工具来提高应用程序的整体可靠性和速度。

虽然我们的主要项目——社交网络——绝不是一个完整或全面的项目,但我们已经分解了这样一个项目的各个方面,以演示路由、缓存、身份验证、显示、性能和安全性。

如果您希望继续扩展项目,请随意增加、分叉或克隆github.com/nkozyra/masteringwebservices上的示例。我们很乐意看到该项目继续作为演示 Go 中与网络服务和 API 相关的功能和最佳实践。

posted @ 2024-05-04 22:37  绝不原创的飞龙  阅读(28)  评论(0编辑  收藏  举报