在第 2 章服务和路由中,我们探讨了如何将 URL 路由到 web 应用程序中的不同页面。在这样做的过程中,我们构建了动态的 URL,并从(非常简单的)net/http
处理程序中得到动态响应。
我们刚刚初步了解了 Go 的模板可以做什么,我们还将继续探讨更多主题,但在本章中,我们尝试介绍了直接使用模板所必需的核心概念。
我们已经研究了简单变量以及应用程序中使用模板本身的实现方法。我们还探讨了如何绕过受信任内容的注入保护。
web 开发的表示方面很重要,但它也是最不受欢迎的方面。几乎任何框架都会提供其内置 Go 模板和路由语法的扩展。真正将我们的应用程序提升到下一个层次的是构建和集成用于一般数据访问的 API,以及允许更动态地驱动我们的表示层。
在本章中,我们将开发一个后端 API,用于以 RESTful 方式访问信息,并读取和操作底层数据。这将允许我们使用 Ajax 在模板中做一些更有趣、更动态的事情。
在本章中,我们将介绍以下主题:
- 设置基本 API 端点
- RESTful 体系结构和最佳实践
- 创建我们的第一个 API 端点
- 实施安全
- 使用 POST 创建数据
- 使用 PUT 修改数据
首先,我们将为页面和单个博客条目设置一个基本的 API 端点。
我们将为[T0]请求创建一个 Gorilla[T1]端点路由,该请求将返回有关我们页面的信息,另外一个请求将接受一个 GUID,该 GUID 匹配字母数字字符和连字符:
routes := mux.NewRouter()
routes.HandleFunc("/api/pages", APIPage).
Methods("GET").
Schemes("https")
routes.HandleFunc("/api/pages/{guid:[0-9a-zA\\-]+}", APIPage).
Methods("GET").
Schemes("https")
routes.HandleFunc("/page/{guid:[0-9a-zA\\-]+}", ServePage)
http.Handle("/", routes)
http.ListenAndServe(PORT, nil)
这里请注意,我们再次捕获 GUID,这次是针对我们的/api/pages/*
端点,它将镜像 web 端对应项的功能,返回与单个页面关联的所有元数据。
func APIPage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
pageGUID := vars["guid"]
thisPage := Page{}
fmt.Println(pageGUID)
err := database.QueryRow("SELECT page_title,page_content,page_date FROM pages WHERE page_guid=?", pageGUID).Scan(&thisPage.Title, &thisPage.RawContent, &thisPage.Date)
thisPage.Content = template.HTML(thisPage.RawContent)
if err != nil {
http.Error(w, http.StatusText(404), http.StatusNotFound)
log.Println(err)
return
}
APIOutput, err := json.Marshal(thisPage)
fmt.Println(APIOutput)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, thisPage)
}
前面的代码表示最简单的基于 GET 的请求,它从/pages
端点返回一条记录。现在让我们看看 REST,看看我们如何从 API 中构造和实现其他动词和数据操作。
在 web API 设计的世界中,存在着一系列迭代的、有时是相互竞争的工作,以找到一个标准的系统和格式来跨多个环境交付信息。
近年来,整个 web 开发社区似乎至少暂时将 REST 作为事实上的方法。REST 是在 SOAP 占据主导地位的几年之后出现的,它引入了一种更简单的数据共享方法。
RESTAPI 不绑定到格式,通常可缓存并通过 HTTP 或 HTTPS 交付。
一开始最大的收获是坚持 HTTP 动词;那些最初为 Web 指定的用户在其原始意图中得到尊重。例如,HTTP 动词,如DELETE
和PATCH
尽管非常明确地说明了它们的用途,但却被废弃多年。REST 是为正确目的使用正确方法的主要推动力。在 REST 之前,GET
和POST
请求可以互换使用,以完成 HTTP 设计中内置的无数事情,这种情况并不少见。
在 REST 中,我们采用类似于的创建读取更新删除(CRUD的方法来检索或修改数据。POST
主要用于创建,PUT
用作更新(虽然也可以用于创建),熟悉的GET
用于读取,DELETE
用于删除,就是这样。
也许更重要的是,RESTful API 应该是无状态的。我们的意思是,每个请求都应该独立存在,而服务器不必知道以前或将来可能的请求。这意味着会话的想法在技术上违反了这一理念,因为我们将在服务器本身存储一些状态感。有些人不同意;稍后我们将详细介绍这一点。
最后一个注意事项是关于 API URL 结构,因为该方法作为头的一部分烘焙到请求本身中,所以我们不需要在请求中显式地表达它。
换句话说,我们不需要什么,比如/api/blogs/delete/1
。相反,我们可以简单地使用DELETE
方法向api/blogs/1
发出请求。
URL 结构没有严格的格式,您可能会很快发现某些操作缺少有意义的 HTTP 特定动词,但简而言之,我们应该瞄准以下几点:
- 资源在 URL 中清晰地表示
- 我们正确地使用 HTTP 动词
- 我们根据请求的类型返回适当的响应
在本章中,我们的目标是通过 API 达到前面的三点。
如果有第四点的话,它会说我们保持了与 API 的向后兼容性。在这里检查 URL 结构时,您可能会想知道如何处理版本。这往往因组织而异,但一个好的策略是保持最新的 URL 规范化,并不推荐显式版本的 URL。
例如,即使可以在/api/comments
处访问我们的评论,但较旧的版本也可以在/api/v2.0/comments
处找到,其中2
显然代表我们的 API,因为它存在于2.0
版本中。
尽管 REST 在本质上相对简单且定义明确,但它是一个经常争论的话题,有足够的模糊性,可以开始很多争论,通常是为了更好。记住,休息不是标准;例如,W3C 没有也可能永远不会对 REST 是什么和不是什么进行权衡。如果你还没有,你会开始形成一些非常强烈的意见,你觉得什么是适当的休息。
考虑到我们想要从客户端以及从服务器到服务器访问数据,我们需要开始通过 API 访问其中一些数据。
对我们来说,最合理的做法是进行简单的读取,因为我们还没有在直接 SQL 查询之外创建数据的方法。我们在本章开始时使用APIPage
方法,通过/api/pages/{UUID}
端点进行路由。
这对于GET
请求非常有用,因为我们不处理数据,但是如果我们需要创建或修改数据,我们需要利用其他 HTTP 谓词和 REST 方法。为了有效地做到这一点,是时候在我们的 API 中调查一些身份验证和安全性了。
当你考虑使用我们刚刚设计的 API 创建数据时,你首先想到的是什么?如果是安全的话,那对你有好处。访问数据并不总是没有安全风险,但只有当我们允许修改数据时,我们才需要真正开始考虑安全问题。
在我们的例子中,读取的数据是完全良性的。如果有人可以通过GET
请求访问我们所有的博客条目,谁在乎呢?嗯,我们可能有一个关于禁运的博客,或者意外地暴露了某些资源上的敏感数据。
无论哪种方式,安全性都应该是一个问题,即使是像博客平台这样的小型个人项目,与我们正在构建的类似。
有两种方法可以区分这些关注点:
- 对 API 的请求是否安全且私有?
- 我们是否在控制对数据的访问?
让我们先处理步骤 2。如果我们想允许用户创建或删除信息,我们需要给他们特定的访问权限。
有几种方法可以做到这一点:
我们可以提供允许短期请求窗口的 API 令牌,该窗口可以通过共享秘密进行验证。这是 Oauth 的本质;它依靠一个共享秘密来验证加密编码的请求。如果没有共享机密,请求及其令牌将永远不会匹配,然后可以拒绝 API 请求。
cond
方法是一个简单的 API 键,它将我们带回到前面列表中的第 1 点。
如果我们允许明文 API 密钥,那么我们可能根本就没有安全性。如果我们的请求可以在不费吹灰之力的情况下通过网络嗅探出来,那么即使需要 API 密钥也没有什么意义。
因此,这意味着无论我们选择哪种方法,我们的服务器都应该通过 HTTPS 提供 API。幸运的是,Go 通过传输层安全(TLS)提供了一种非常简单的方式来利用 HTTP 或 HTTPS;TLS 是 SSL 的继承者。作为一名 web 开发人员,您必须已经熟悉 SSL,并了解其安全问题的历史,最近的一次是 2014 年暴露的狮子狗漏洞。
为了允许这两种方法,我们需要有一个用户注册模型,这样我们就可以有新的用户,他们可以有某种凭证来修改数据。要调用 TLS 服务器,我们需要一个安全的证书。因为这是一个小型的实验项目,所以我们不会太担心具有高信任度的真实证书。相反,我们将生成自己的。
创建自签名证书因操作系统而异,超出了本书的范围,所以让我们看看 OSX 的方法。
显然,自签名证书没有太多的安全价值,但它允许我们在不需要花费金钱或时间验证服务器所有权的情况下进行测试。很明显,对于任何你希望被认真对待的证书,你都需要做这些事情。
要在 OS X 中创建一组快速证书,请转到终端并输入以下三个命令:
openssl genrsa -out key.pem
openssl req -new -key key.pem -out cert.pem
openssl req -x509 -days 365 -key key.pem -in cert.pem -out certificate.pem
在本例中,我使用 Ubuntu 上的 OpenSSL 生成了证书。
注意:OpenSSL 预装在 OSX 和大多数 Linux 发行版上。如果您使用的是后者,请在查找 Linux 特定的指令之前尝试一下前面的命令。如果您使用的是 Windows,尤其是 8 等较新版本,您可以通过多种方式实现这一点,但最容易访问的方式可能是通过 MakeCert 工具,该工具由 Microsoft 通过 MSDN 提供。
阅读有关 MakeCert 的更多信息,请访问 https://msdn.microsoft.com/en-us/library/bfsktky3%28v=vs.110%29.aspx T1-T1
一旦您拥有了证书文件,请将它们放在文件系统中不在可访问的应用程序目录中的某个位置。
要从 HTTP 切换到 TLS,我们可以使用对这些证书文件的引用;除此之外,我们的代码基本上是相同的。让我们首先将证书添加到代码中。
注意:同样,您可以选择在同一个服务器应用程序中维护 HTTP 和 TLS/HTTPS 请求,但我们将全面切换我们的请求。
早些时候,我们通过以下线路启动了服务器:
http.ListenAndServe(PORT, nil)
现在,我们需要把事情扩大一点。首先,让我们加载我们的证书:
certificates, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
tlsConf := tls.Config{Certificates: []tls.Certificate{certificates}}
tls.Listen("tcp", PORT, &tlsConf)
注意:如果您发现您的服务器显然运行没有错误,但没有保持运行;您的证书可能有问题。请尝试再次运行上一代代码并使用新证书。
现在我们已经有了安全证书,我们可以切换到 TLS 进行GET
和其他请求的 API 调用。我们现在就开始吧。请注意,您可以为其余端点保留 HTTP,也可以在此时切换它们。
注意:只使用 HTTPS 的方式在很大程度上已经成为一种常见的做法,而且这可能是未来验证应用程序的最佳方式。这不仅仅适用于 API 或以明文形式发送明确和敏感信息的领域,隐私是最重要的;各大提供商和服务都在强调 HTTPS 的价值。
让我们在博客上添加一个简单的匿名评论部分:
<div id="comments">
<form action="/api/comments" method="POST">
<input type="hidden" name="guid" value="{{Guid}}" />
<div>
<input type="text" name="name" placeholder="Your Name" />
</div>
<div>
<input type="email" name="email" placeholder="Your Email" />
</div>
<div>
<textarea name="comments" placeholder="Your Com-ments"></textarea>
</div>
<div>
<input type="submit" value="Add Comments" />
</div>
</form>
</div>
这将允许任何用户在我们的任何博客项目上向我们的站点添加匿名评论,如以下屏幕截图所示:
但是所有的安全措施呢?现在,我们只想创建一个开放式评论区,任何人都可以将其有效、陈述良好的想法以及滥发的处方交易发布到该区。我们很快就会把它锁定;现在,我们只想演示并行 API 和前端集成。
显然,我们的数据库中需要一个comments
表,因此请确保在实现任何 API 之前创建该表:
CREATE TABLE `comments` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`page_id` int(11) NOT NULL,
`comment_guid` varchar(256) DEFAULT NULL,
`comment_name` varchar(64) DEFAULT NULL,
`comment_email` varchar(128) DEFAULT NULL,
`comment_text` mediumtext,
`comment_date` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `page_id` (`page_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
有了这个表,让我们采用我们的表单并POST
将其发送到我们的 API 端点。要创建一个通用的[T2]灵活的 JSON 响应,您可以添加一个基本上由[T3]哈希映射组成的[T1],如图所示:
type JSONResponse struct {
Fields map[string]string
}
然后我们需要一个 API 端点来创建注释,所以让我们将其添加到main()
下的路由中:
func APICommentPost(w http.ResponseWriter, r *http.Request) {
var commentAdded bool
err := r.ParseForm()
if err != nil {
log.Println(err.Error)
}
name := r.FormValue("name")
email := r.FormValue("email")
comments := r.FormValue("comments")
res, err := database.Exec("INSERT INTO comments SET comment_name=?, comment_email=?, comment_text=?", name, email, comments)
if err != nil {
log.Println(err.Error)
}
id, err := res.LastInsertId()
if err != nil {
commentAdded = false
} else {
commentAdded = true
}
commentAddedBool := strconv.FormatBool(commentAdded)
var resp JSONResponse
resp.Fields["id"] = string(id)
resp.Fields["added"] = commentAddedBool
jsonResp, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, jsonResp)
}
关于前面的代码,有几个有趣的事情:
首先,请注意我们将用作string
而不是bool
。我们这样做主要是因为 json 封送器不能优雅地处理布尔值,也因为不可能直接从布尔值转换为字符串。我们还使用strconv
及其FormatBool
来处理此翻译。
您可能还注意到,在本例中,我们将表单直接发布到 API 端点。虽然证明数据进入数据库是一种有效的方法,但在实践中使用它可能会强制使用一些 RESTful 反模式,例如允许重定向 URL 返回到调用页面。
更好的方法是通过客户端,通过公共库或通过XMLHttpRequest
本地调用 Ajax。
注意:虽然内部函数/方法名称在很大程度上取决于偏好,但我们建议根据资源类型和请求方法保持所有方法的不同。这里使用的实际约定是不相关的,但作为遍历代码的问题,诸如APICommentPost
、APICommentGet
、APICommentPut
和APICommentDelete
等内容为您提供了一种很好的分层方式来组织方法,以提高可读性。
根据前面的客户端和服务器端代码,我们可以看到用户点击我们的第二个博客条目时会出现什么情况:
如前所述,实际上在这里添加您的注释将直接将表单发送到 API 端点,在那里它将默默地成功(希望如此)。
根据您询问的对象,PUT
和POST
可以互换使用来创建记录。有些人认为两者都可以用于更新记录,大多数人认为两者都可以用于创建给定一组变量的记录。为了避免陷入一场有点混乱且经常是政治性的辩论,我们将两者分开如下:
- 创建新记录:
POST
- 更新现有记录,幂等:
PUT
根据这些指导原则,当我们希望对资源进行更新时,我们将使用[T0]动词。我们将允许任何人编辑评论,作为使用剩余[T1]动词的概念证明。
在第 6 章、会话和 Cookie中,我们将进一步锁定这一点,但我们也希望能够通过 RESTful API 演示内容的编辑;因此,这将代表一个不完整的存根,最终将更加安全和完整。
与创建新注释一样,这里没有安全限制。任何人都可以创建评论,任何人都可以编辑它。至少在这一点上,这是博客软件的狂野西部。
首先,我们希望能够看到我们提交的评论。为此,我们需要对我们的Page struct
做一些小的修改,并创建一个Comment struct
来匹配我们的数据库结构:
type Comment struct {
Id int
Name string
Email string
CommentText string
}
type Page struct {
Id int
Title string
RawContent string
Content template.HTML
Date string
Comments []Comment
Session Session
GUID string
}
由于之前发表的所有评论都进入了数据库,没有任何真正的宣传,因此博客帖子页面上没有实际评论的记录。为了解决这个问题,我们将添加一个简单的查询Comments
,并使用.Scan
方法将它们扫描到一个Comment struct
数组中。
首先,我们将查询添加到ServePage
:
func ServePage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
pageGUID := vars["guid"]
thisPage := Page{}
fmt.Println(pageGUID)
err := database.QueryRow("SELECT id,page_title,page_content,page_date FROM pages WHERE page_guid=?", pageGUID).Scan(&thisPage.Id, &thisPage.Title, &thisPage.RawContent, &thisPage.Date)
thisPage.Content = template.HTML(thisPage.RawContent)
if err != nil {
http.Error(w, http.StatusText(404), http.StatusNotFound)
log.Println(err)
return
}
comments, err := database.Query("SELECT id, comment_name as Name, comment_email, comment_text FROM comments WHERE page_id=?", thisPage.Id)
if err != nil {
log.Println(err)
}
for comments.Next() {
var comment Comment
comments.Scan(&comment.Id, &comment.Name, &comment.Email, &comment.CommentText)
thisPage.Comments = append(thisPage.Comments, comment)
}
t, _ := template.ParseFiles("templates/blog.html")
t.Execute(w, thisPage)
}
现在我们的Page struct
中已经打包了Comments
,我们可以在页面上显示注释:
由于我们允许任何人进行编辑,因此我们必须为每个项目创建一个表单,将允许修改。一般来说,HTML 表单允许GET
或POST
请求,因此我们的手被迫使用XMLHttpRequest
发送此请求。为了简洁起见,我们将使用 jQuery 及其ajax()
方法。
首先,对于我们模板的评论范围:
{{range .Comments}}
<div class="comment">
<div>Comment by {{.Name}} ({{.Email}})</div>
{{.CommentText}}
<div class="comment_edit">
<h2>Edit</h2>
<form onsubmit="return putComment(this);">
<input type="hidden" class="edit_id" value="{{.Id}}" />
<input type="text" name="name" class="edit_name" placeholder="Your Name" value="{{.Name}}" />
<input type="text" name="email" class="edit_email" placeholder="Your Email" value="{{.Email}}" />
<textarea class="edit_comments" name="comments">{{.CommentText}}</textarea>
<input type="submit" value="Edit" />
</form>
</div>
</div>
{{end}}
然后我们的 JavaScript 使用PUT
处理表单:
<script>
function putComment(el) {
var id = $(el).find('.edit_id');
var name = $(el).find('.edit_name').val();
var email = $(el).find('.edit_email').val();
var text = $(el).find('.edit_comments').val();
$.ajax({
url: '/api/comments/' + id,
type: 'PUT',
succes: function(res) {
alert('Comment Updated!');
}
});
return false;
}
</script>
要用[T0]动词处理这个调用,我们需要一个更新路由和函数。让我们现在添加它们:
routes.HandleFunc("/api/comments", APICommentPost).
Methods("POST")
routes.HandleFunc("/api/comments/{id:[\\w\\d\\-]+}", APICommentPut).
Methods("PUT")
这就启用了一个路由,所以现在我们只需要添加相应的函数,看起来与我们的POST
/Create
方法非常相似:
func APICommentPut(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
log.Println(err.Error)
}
vars := mux.Vars(r)
id := vars["id"]
fmt.Println(id)
name := r.FormValue("name")
email := r.FormValue("email")
comments := r.FormValue("comments")
res, err := database.Exec("UPDATE comments SET comment_name=?, comment_email=?, comment_text=? WHERE comment_id=?", name, email, comments, id)
fmt.Println(res)
if err != nil {
log.Println(err.Error)
}
var resp JSONResponse
jsonResp, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, jsonResp)
}
简言之,这采用了我们的形式,并将其转换为基于注释内部 ID 的数据更新。如前所述,它与我们的POST
路由方法没有完全不同,并且与该方法一样,它不会返回任何数据。
在本章中,我们已经从专门由服务器生成的 HTML 演示文稿转变为使用 API 的动态演示文稿。我们已经研究了 REST 的基础知识,并为我们的博客应用程序实现了一个 RESTful 接口。
虽然这需要更多的客户端修饰,但我们有GET
/POST
/PUT
请求,这些请求是功能性的,允许我们为博客帖子创建、检索和更新评论。
在第 6 章、会话和 Cookie中,我们将研究用户身份验证、会话和 Cookie,以及我们如何利用本章中构建的构建块,并将一些非常重要的安全参数应用到其中。在本章中,我们对评论进行了开放式创建和更新;我们将在下一节中将其限制为唯一用户。
在完成所有这些工作的过程中,我们将把概念验证注释管理变成可以在生产中实际使用的东西。