同构的-Go-应用(全)
同构的 Go 应用(全)
原文:
zh.annas-archive.org/md5/70B74CAEBE24AE2747234EE512BCFA98
译者:飞龙
前言
2017 年 2 月,我在 GopherCon India 上做了一个关于同构 Go 的演讲。同构 Go 是使用 Go 编程语言创建同构 Web 应用程序的方法论。同构 Web 应用程序架构提升了用户体验,同时也提高了搜索引擎的可发现性。我的同构 Go 演讲受到了好评,一群全栈工程师在演讲后联系了我。
我会见的工程师们对于维护全栈 Web 应用程序代码库的复杂性表示了不满。他们抱怨不得不在后端使用 Go 和在前端使用 JavaScript 这两种截然不同的编程语言之间来回切换。每当他们不得不用 JavaScript 开发解决方案时,他们渴望 Go 语言的语法简洁、强大的标准库、并发构造以及开箱即用的类型安全性。
他们传达给我的信息是清楚明了的。他们渴望能够完全使用 Go 创建全栈 Web 应用程序。能够在一个统一的 Go 代码库中编写前端和后端的 Go 代码,这的确是一个诱人的提议。
由于我从会议上收集到的反馈,我为自己制定了两个行动项。首先,需要演示同构 Go Web 应用程序的能力。其次,需要详细解释同构 Go 背后的所有主要概念。
第一个行动项,演示同构 Go,成为了同构 Go 和 UX 工具包开源项目的起源——在撰写本文时,这些是创建 Go 同构 Web 应用程序最先进的技术。第一个在 Go 中创建的同构 Web 应用程序是 IGWEB 演示(可在igweb.kamesh.com
找到),这也是本书中展示的 Web 应用程序。
第二个行动项,解释主要的同构 Go 概念,最终成为了这本书。在观看了我的同构 Go 演讲后,Packt Publishing 联系我,给了我写这本书的机会,我很高兴地接受了。能够写一本关于我非常热衷的新兴技术的书,真是一次令人振奋的经历。
写这本书给了我一个机会,可以提出之前在 Go 编程领域从未涉及的想法和概念,比如内存模板集、端到端路由、同构交接、同构 Web 表单、实时 Web 应用功能、使用 Go 的可重用组件、编写端到端自动化测试来测试客户端功能,以及使用 Go 编写的同构 Web 应用程序的部署。
这本书的广泛深度确保我履行了对你这位读者的重要责任,为你的投资提供了高价值。这尤为重要,因为这恰好是关于同构 Go 的第一本也是唯一一本书。
这本书的重点是教会你如何从零开始创建一个同构 Go Web 应用程序。这本书是一次旅程,从介绍使用 Go 创建同构 Web 应用程序的优势开始,到将多容器同构 Go Web 应用程序部署到云端结束。
我希望你喜欢阅读这本书,并且它将成为你多年来的宝贵资源。
你需要为这本书做好准备
要编译本书附带的代码,你需要一台安装了 Go 发行版的操作系统的计算机。支持的操作系统列表以及系统要求可以在golang.org/doc/install#requirements
找到。
这本书是为谁写的
本书面向具有 Go 编程语言先前经验并了解语言基本概念的读者。还假定读者具有基本网络开发的先前经验。不需要先前对等同构网络应用程序开发的知识。由于本书采用 Go 的成语化方法,因此读者不必具有使用 JavaScript 或 JavaScript 生态系统中的任何工具或库的先前经验。
约定
在本书中,您将找到一些区分不同信息类型的文本样式。以下是一些示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"让我们检查products_page.tmpl
源文件。"
代码块设置如下:
{{ define "pagecontent" }}
{{template "products_content" . }}
{{end}}
{{template "layouts/webpage_layout" . }}
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
func NewWidget() *Widget {
w := &Widget{}
w.SetCogType(cogType)
return f
}
任何命令行输入或输出都以以下方式编写:
$ go get -u github.com/uxtoolkit/cog
新术语和重要单词以粗体显示。例如,屏幕上显示的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:"这将向网页上的第一个“添加到购物车”按钮发送鼠标点击事件。"
警告或重要说明会以这种方式出现。
提示和技巧会以这种方式出现。
第一章:使用 Go 创建同构 Web 应用程序
同构 Web 应用程序是指 Web 服务器和 Web 浏览器(客户端)可能共享 Web 应用程序代码的全部或部分。同构 Web 应用程序允许我们从传统 Web 应用程序架构中获得最大的好处。它们提供了更好的用户体验,通过搜索引擎增强了可发现性,并通过在不同环境中共享 Web 应用程序代码的部分来降低运营成本。
成熟的企业,如 Airbnb、彭博社、Capital One、Facebook、谷歌、Netflix 和沃尔玛已经接受了同构 Web 应用程序开发,并且有充分的理由——财务底线。
沃尔玛的一项研究发现,他们每提高 1 秒的速度,就会增加 2%的转化率。此外,他们还发现,每提高 100 毫秒的速度,就会增加 1%的增量收入。来源:网站速度如何影响转化率(http://www.globaldots.com/how-website-speed-affects-conversion-rates/)。
同构 Go 是使用 Go 编程语言创建同构 Web 应用程序的方法论。在本书中,我们将深入探讨使用 Go 创建同构 Web 应用程序的过程。
本章将涵盖以下主题:
-
为什么你应该考虑使用同构 Go 来开发现代 Web 应用程序
-
传统 Web 应用程序架构概述
-
同构 Web 应用程序架构简介
-
何时实现同构 Web 应用程序
-
在学习同构 Go 之前你应该知道的事情
为什么选择同构 Go?
毫无疑问,JavaScript 是当前领先的技术,就市场份额和思想份额而言,用于创建同构 Web 应用程序。在客户端,JavaScript 已经包含在所有主要的 Web 浏览器中。由于 Node.js 的出现,JavaScript 现在也可以存在于服务器端。
如果是这样的话,那么为什么我们应该把注意力集中在使用 Go 来创建同构 Web 应用程序呢?这个问题的答案是多方面的。将这里提供的答案列表视为一个初始列表,这是我们讨论的起点:
-
Go 具有类型检查
-
即使是科技巨头也避免使用纯 JavaScript
-
将代码转换为纯 JavaScript 已经被广泛接受
-
Go 对前端 Web 开发有很多好处
Go 具有类型检查
Go 是一种包含内置静态类型检查的语言。这个事实的直接影响是,许多错误可以在编译时被捕获。
对许多 JavaScript 开发人员来说,最大的痛点是 JavaScript 缺乏静态类型检查。我个人曾在跨越数十万行代码的 JavaScript 代码库中工作过,亲眼看到最微不足道的错误是如何由于缺乏静态类型检查而产生的。
通过转换器避免纯 JavaScript
为了避免编写纯 JavaScript,科技巨头微软和谷歌分别创建了 TypeScript 和 Dart 作为语言和转换器。转换器是一种源代码到源代码的编译器。
编译器将人类可读的代码,写成一种编程语言,转换成机器代码。转换器用于将源代码从一种编程语言转换为另一种语言。输出可能或可能不可读取,这取决于转换器的意图。
诸如 TypeScript 和 Dart 之类的语言被转换为纯 JavaScript 代码,以便在支持 JavaScript 的 Web 浏览器中运行。在 TypeScript 的情况下,它本质上是 JavaScript 的超集,引入了静态类型检查。AngularJS 框架的创建者选择了 TypeScript 而不是纯 JavaScript 作为开发其框架下一个主要版本的语言。
通过使用另一种编程语言和转译器来规避 JavaScript,为开发人员创造了双赢局面。开发人员可以使用对他们最有效的编程语言进行编程,而最终,开发人员创建的代码将得以在 Web 浏览器中运行——这要归功于转译器。
转译代码
将代码转译为 JavaScript 已经成为一种被广泛接受的做法,甚至在 JavaScript 社区内部也是如此。例如,Babel 转译器允许开发人员编写尚未发布的 JavaScript 语言未来标准,将其转译为目前在主要 Web 浏览器中支持的标准 JavaScript 代码。
在这种情况下,在 Web 浏览器中运行被转译为 JavaScript 代码的 Go 程序并不奇怪或牵强。事实上,除了静态类型检查之外,还有许多其他好处可以从能够在前端运行 Go 中获得。
Go 在前端的好处
在前端使用 Go 具有许多优势,包括以下内容:
-
一个稳健的标准库
-
使用 Go 包进行代码模块化很容易
-
Go 附带了一个隐式构建系统
-
Go 的并发构造允许我们避免回调地狱
-
并发概念内置于 Go 中
-
Go 可用于同构 Web 应用程序开发
稳健的标准库
Go 附带了一个稳健的标准库,提供了许多强大的功能。例如,在 Go 中,我们可以渲染内联客户端模板,而无需包含任何第三方模板库或框架。我们将在第三章中考虑如何做到这一点,使用 GopherJS 在前端上使用 Go。
使用 Go 包促进模块化
Go 具有强大的包实现,促进了模块化,允许更大程度的代码重用和可维护性。此外,Go 工具链包括go get
命令,允许我们轻松获取官方和第三方 Go 包。
如果你来自 JavaScript 世界,把go get
想象成一个更简单、更轻量级的npm
(npm
是 Node 包管理器,一个第三方 JavaScript 包的存储库)。
隐式构建系统
在 JavaScript 生态系统中,现代开发人员仍然流行手动创建和维护项目构建文件。作为一种现代编程语言,Go 附带了一个隐式构建系统。
只要遵循 Go 的约定,并且一旦为 Go 应用程序发出go build
命令,隐式构建系统就会启动。它将通过检查应用程序 Go 源代码中找到的依赖项,自动构建和编译 Go 项目。这为开发人员提供了重大的生产力提升。
避免回调地狱
也许考虑使用 Go 进行同构 Web 开发最具吸引力的原因是避免回调地狱。JavaScript 是一种单线程编程语言。当我们想要在异步调用之后延迟执行特定任务时,我们会将这些任务的代码放在回调函数中。
很快,我们要延迟执行的任务列表将增长,嵌套回调函数的数量也将随之增长。这种情况被称为回调地狱。
我们可以使用 Go 的内置并发构造来避免回调地狱。
并发
Go 是一种现代编程语言,旨在在多核处理器和分布式系统的时代保持相关性。它的设计并不是将并发的重要性作为事后的想法。
事实上,并发对于 Go 的创建者来说非常重要,以至于他们将并发直接构建到语言本身中。在 Go 中,我们可以避免回调地狱,使用 Go 的内置并发构造:goroutines 和 channels。Goroutines是廉价、轻量级的线程。Channels是允许 goroutines 之间通信的通道。
使用 Go 进行等同于 Web 应用程序开发
在等同于 Web 应用程序开发方面,JavaScript 不再是唯一的选择。由于最近的技术进步,特别是GopherJS的创建,我们现在可以在前端使用 Go 编程语言;这使我们能够在 Go 中创建等同于 Web 应用程序。
等同于 Go是一种新兴技术,它为我们提供了创建等同于 Web 应用程序所需的基本要素,利用了 Go 编程语言提供的强大和高效的功能。在本书中,我们将使用 Go 标准库的功能和 Go 社区的第三方库来实现等同于 Web 应用程序。
Web 应用程序架构概述
为了理解和充分欣赏等同于 Web 应用程序的架构,有必要了解其前身的 Web 应用程序架构。我们将介绍过去 25 年在行业中流行的主要 Web 应用程序架构。
毕竟,直到我们完全承认我们所在的位置,我们才能真正欣赏到我们所到达的地方。随着多年来 Web 应用程序架构领域发生的重大变化,有很多值得承认的地方。
在介绍等同于 Web 应用程序架构之前,让我们花些时间回顾它之前的三种传统 Web 应用程序架构:
-
经典 Web 应用程序架构
-
AJAX Web 应用程序架构
-
单页应用程序(SPA)架构
我们将确定考虑的三种架构的优缺点。我们将根据我们为给定架构确定的每个缺点开始一个需求愿望清单。毕竟,缺点实际上是改进的机会。
经典 Web 应用程序架构
经典 Web 应用程序架构可以追溯到上世纪 90 年代初,当图形 Web 浏览器开始流行起来。当用户使用 Web 浏览器与 Web 服务器进行交互时,每个用户交互都会使用 HTTP 向 Web 服务器发出请求。图 1.1描述了经典 Web 应用程序架构。
图 1.1:经典 Web 应用程序架构
该图还描述了一个 HTTP 事务,其中包括用户的 Web 浏览器发送到 Web 服务器的请求。一旦 Web 服务器接受了请求,它将返回相应的响应。
通常,响应是一个 HTML 网页,它可能包含内联 CSS 和/或 JavaScript,或者调用外部 CSS 样式表和/或 JavaScript 源文件。
Web 服务器可以以响应的形式返回两种类型的资源:静态资源和动态资源。
静态资源是一个文件。例如,它可以是存储在 Web 服务器上的 HTML、JPEG、PDF 或 MP4 文件。服务器将在其响应主体中返回请求指定的文档。
动态资源是服务器动态生成的资源。动态资源的一个例子是搜索引擎的搜索结果页面。通常,动态请求的响应主体将以 HTML 格式进行格式化。
当涉及到 Web 应用程序时,我们处理动态资源。Web 服务器正在提供 Web 应用程序,通常 Web 应用程序包含一个控制器,该控制器包含将用户请求路由到服务器上执行的特定操作的逻辑。一旦 Web 服务器处理完用户的请求,服务器会以 Web 页面响应的形式将响应发送回客户端。
服务器端编程语言(如 Go、Perl、PHP、Python、Ruby 和 Java)用于处理从 Web 浏览器发送的请求。例如,让我们考虑一个用于电子商务网站的服务器端 Web 应用程序。
Web 应用程序可以通过使用服务器端的路由处理程序(如图 1.1所示)路由请求;/product-detail/swiss-army-knife
路由可以与产品详细信息控制器相关联,该控制器将提供包含瑞士军刀产品概要页面的 HTML 网页响应。
在经典的 Web 应用程序架构中,用于呈现 Web 页面的代码位于服务器端,通常整合到模板文件中。从一组模板呈现 Web 页面响应是由驻留在服务器上的模板渲染器执行的(如图 1.1所示)。
通常在这种范式中,JavaScript 可能会包含在呈现的 Web 页面中以增强用户体验。在这种 Web 应用程序架构中,实施 Web 应用程序的责任主要放在服务器端语言上,JavaScript 主要用于用户界面控件或网站的增强用户交互,放在次要位置。
优势
经典 Web 应用程序架构具有两个主要优势:
-
更快的初始页面加载
-
更好的搜索引擎可发现性
更好的搜索引擎可发现性
经典 Web 应用程序架构的第二个主要优势是这种架构对搜索引擎友好,因为 Web 应用程序提供了可以被搜索引擎机器人轻松消化的 HTML 网页响应。除此之外,服务器端路由处理程序允许创建与特定服务器端控制器相关联的搜索引擎友好的 URL。
使网站对搜索引擎友好的关键因素是可发现性。除了拥有优质内容外,搜索引擎友好的网站还需要永久链接 - 旨在永久保持服务的网页链接。描述性良好的 URL 可以在服务器端的路由器中注册为路由。这些路由最终成为永久链接,搜索引擎机器人爬虫可以在浏览网站时轻松索引。
目标是拥有美观的网站 URL,其中包含有意义的信息,可以轻松被搜索引擎的机器人爬虫索引,例如:http://igweb.kamesh.com/product-detail/swiss-army-knife
。
上述永久链接比以下链接更容易被搜索引擎索引和人类理解:http://igweb.kamesh.com/webapp?section=product-detail&product_id=052486
。
更快的初始页面加载
经典 Web 应用程序架构的第一个主要优势是用户认为页面加载速度快,因为整个页面一次性呈现。这是由于 Web 服务器在服务器端使用模板渲染器呈现 Web 页面响应的结果。
用户不会感知到缓慢,因为他们立即从服务器接收到呈现的页面。
请记住,如果服务器的响应时间延迟很高,那么用户交互将停滞不前。在这种情况下,快速的初始页面加载优势将丧失,因为用户必须盯着空白屏幕等待服务器完成处理。这种等待将以 Web 页面响应被交付给用户或 HTTP 请求超时而结束,以先到者为准。
主要的缺点
我们将在本章中考虑的传统 Web 应用程序架构中检查每种传统 Web 应用程序架构的主要缺点。本章的同构 Web 应用程序架构部分将向我们展示同构 Web 应用程序架构如何为每个提出的缺点提供解决方案,并收集每种传统 Web 应用程序架构提供的好处。
经典 Web 应用程序架构的主要缺点是,所有用户交互,甚至最微不足道的交互,都需要完整的页面重新加载。
这意味着文档对象模型(DOM),表示当前网页状态的树形数据结构以及组成它的元素,在每次用户交互时都会被完全清除,并重新创建:
图 1.2:新闻网站的布局图和评论部分的线框图
例如,让我们假设我们正在阅读新闻网站上的一篇文章。图 1.2描述了新闻网站的布局图(左侧的插图),网页底部是网站的评论部分。其他部分可能存在于布局中的负(空)空间中。
图 1.2还包括新闻评论部分的线框设计(右侧的插图),其中包含一些示例评论。省略号(...)表示出于简洁起见未列出的多个网站评论。
让我们考虑这样一个情景,这篇特定的新闻文章已经变得非常火爆,包含超过 10,000 条评论。评论是分页的,每页显示 50 条评论:
图 1.3:查看下一组评论需要整个网页刷新
图 1.3描述了新闻网站的网页被刷新(左侧的插图)。请注意,用户会感觉刷新很快,因为页面会立即加载(考虑到网络延迟很低)。图 1.3还描述了点击下一个链接后(右侧的插图)下一批 50 篇文章。
如果我们点击分页导航控件上的下一个链接,将导致整个页面重新加载,这将销毁 DOM 并重新创建。由于评论位于屏幕底部,在整个页面重新加载时,滚动位置也可能会回到网页顶部,导致用户体验不佳。
我们只想在页面底部看到下一组评论。我们并不打算整个网页重新加载,但它确实重新加载了,这就是经典 Web 应用程序架构的主要局限性。
愿望清单项目#1:为了增强用户体验,点击网站上的链接不应导致整个页面重新加载。
AJAX Web 应用程序架构
随着XMLHttpRequest(XHR)对象的出现,异步 JavaScript 和 XML(AJAX)时代开始了。图 1.4说明了 AJAX Web 应用程序架构。
客户端的初始请求后,服务器发送回一个包含 HTML、CSS 和 JavaScript 的网页响应。一旦网页加载完成,客户端的 JavaScript 应用程序可以通过 XHR 对象发起 HTTP 异步请求回到 Web 服务器。
一些观察者将 AJAX 的出现描述为Web 2.0 时代,在这个时代,网站变得更加互动,用户体验更加丰富,JavaScript 库的使用开始获得关注。
图 1.4:AJAX Web 应用程序架构
由于 XHR 调用是异步的,它们不会阻塞在 Web 浏览器中运行的单线程 JavaScript 应用程序。一旦从服务器收到给定 XHR 请求的响应,就可以对从服务器返回的数据采取行动。
主要优势
AJAX Web 应用程序架构的主要优势是它消除了执行完整页面重新加载的需要。
在我们考虑的新闻文章网页有 10,000 多条评论的情况下,我们可以编写 Web 应用程序,在按下“下一页”按钮时发起 XHR 调用,然后服务器可以发送包含要显示的下一组评论的 HTML 片段。一旦我们收到下一组评论,我们可以使用 JavaScript 动态更新 DOM,完全避免执行完整的页面重新加载!
图 1.5说明了这种方法。最左边的插图描述了评论部分中的评论。中间的插图只描述了更新的评论部分。最后,右边的插图描述了加载到评论部分的下一批评论:
图 1.5:当单击“下一页”链接时,只更新新闻网站的评论部分,避免了完整的页面刷新
正如您所看到的,这种方法的主要优势是我们避免了完整的页面重新加载,从而增强了用户体验。请记住,在某些情况下,例如浏览网站的不同部分,仍然可能发生完整的页面重新加载。
缺点
AJAX Web 应用程序架构具有以下缺点:
-
处理两种编程语言之间的心理上下文转换
-
通过逐步客户端渲染引入的复杂性
-
工作重复
心理上下文转换
当涉及到开发人员的生产力时,我们现在引入了一种心理上下文转换(也称为认知转换),假设后端服务器端语言不是 JavaScript。例如,让我们假设我们的后端应用程序是用 Go 实现的,前端应用程序是用 JavaScript 实现的。现在,开发人员将不得不精通服务器端语言(Go)和客户端语言(JavaScript),除了语法上的差异之外,它们可能具有不同的指导理念和习惯用法。
这对于负责维护代码库的全栈开发人员来说是一种心理上下文转换。组织立即解决心理上下文转换问题的一种方法是动用资金。如果组织有能力这样做,它可以承担增加的运营成本,并至少指定一个开发人员负责前端,一个开发人员负责后端。
愿望清单项目#2:为了增加可维护性,应该有一个单一的、统一的项目代码库,使用单一的编程语言实现。
增加的渲染复杂性
除了引入处理两种不同编程语言的心理上下文转换之外,我们现在增加了渲染复杂性的级别。在经典的 Web 应用程序架构中,从服务器响应接收到的渲染的网页从未被改变。事实上,一旦发起新的页面请求,它就被清除了。
现在,我们以逐步方式从客户端重新渲染网页的部分,这要求我们实现更多的逻辑来进行(并跟踪)对网页的后续更新。
愿望清单项目#3:为了增加效率,应该有一种机制来执行分布式模板渲染。
工作重复
AJAX Web 应用程序架构在服务器端和客户端之间引入了工作重复。比如,我们想要在新闻文章中添加新评论。填写表单后,为了添加新评论,我们可以发起一个 XHR 调用,将要添加的新评论发送到服务器。服务器端 Web 应用程序随后可以将新评论持久保存到数据库中,其中存储了所有评论。我们可以立即更新评论部分,以包括刚刚添加的新评论,而不是刷新整个网页。
计算机编程的一个基本原则,特别是在 Web 编程中,就是不要相信用户输入。让我们考虑一种情况,用户可能在评论框中输入了一组无效字符。我们将不得不实现一些类型的验证,既在客户端又在服务器端检查用户的评论。这意味着我们将不得不在 JavaScript 中实现客户端表单验证,并在 Go 中实现服务器端表单验证。
在这一点上,我们在两种不同的操作环境中引入了两种编程语言的工作重复。除了我们刚刚考虑的例子,可能还有其他需要在这种架构路径上进行工作重复的情况。这恰好是 AJAX Web 应用程序架构的一个主要缺点。
愿望清单项目#4:为了提高生产力,应该有一种方法在不同环境之间共享和重用代码,以避免工作重复。
单页应用程序(SPA)架构
2004 年,万维网联盟(W3C)开始制定新的 HTML 标准,这将是 HTML5 的前身。2010 年,HTML5 开始加速发展,规范中的功能开始进入主要的 Web 浏览器,HTML5 功能变得非常流行。
HTML5 的主要卖点是引入功能,使 Web 应用程序能够更像本机应用程序。通过 JavaScript 可以访问一组新的 API。这些 API 包括在用户设备上本地存储数据的功能,更好地控制前进和后退按钮(使用 Web 浏览器的历史 API),用于呈现图形的 2D 画布,以及包括比其前身更强大功能的 XHR 对象的第二个版本。
图 1.6:单页应用程序(SPA)架构
在 2010 年代初,开始出现了 JavaScript 框架,这有助于开发一种新型架构,即 SPA 架构。这种架构,如图 1.6所示,专注于fat client和thin server策略。其思想是从服务器端删除任何类型的模板渲染的责任,并将所有用户界面(UI)渲染分配给客户端。在这种架构中,服务器和客户端的职责有明确的分离。
SPA 架构消除了用户界面责任的工作重复。它通过将所有 UI 代码整合到客户端来实现这一点。这样做消除了服务器端在用户界面方面的工作重复。如图 1.6所示,用户界面的责任完全由客户端承担。
服务器最初返回一个包含 JavaScript 和客户端模板的有效负载。JavaScript 有效负载可能会被聚合,这意味着组成 Web 应用程序的所有 JavaScript 源文件可以合并成一个 JavaScript 源文件。除此之外,JavaScript 有效负载还可能被缩小。
缩小是从源代码中删除任何不必要字符的过程,这可能包括在不改变源代码功能的情况下重命名源代码中的标识符,以减少其存储占用空间。
一旦 Web 浏览器完全下载了 JavaScript 负载,JavaScript 代码的首要任务是在客户端上引导 JavaScript 应用程序,渲染用户界面。
搜索引擎可发现性降低
使用 SPA 架构可能会降低搜索引擎的可发现性。由于在客户端动态渲染内容的性质,一些 SPA 实现可能无法生成易于搜索引擎爬虫消费的格式良好的 HTML 内容,这些爬虫通常只用于消费初始网页响应。
搜索引擎爬虫可能无法渲染网页,因为它可能没有配备 JavaScript 运行时。没有完全渲染的网页内容,爬虫无法有效地执行其消费网页内容的职责。
除此之外,SPA 实现使用片段标识符处理路由,这种方法对搜索引擎不友好。
让我们回到我们的电子商务 Web 应用程序示例。在经典和 AJAX Web 应用程序架构中,我们的 Web 应用程序可能具有以下 URL:http://igweb.kamesh.com/product-detail/swiss-army-knife
。
在 SPA 实现的情况下,带有片段标识符的 URL 可能如下所示:
http://igweb.kamesh.com/#section=product_detail&product=swiss-army-knife
这个 URL 对于搜索引擎爬虫来说很难索引,因为片段标识符(#符号后面的字符)是用来指定给定网页内的位置的。
片段标识符旨在提供单个网页部分内的链接。片段标识符影响 Web 浏览器的历史,因为我们可以在 URL 上附加唯一标识符。这有效地防止用户遇到完整的页面重新加载。
这种方法的缺点是 HTTP 请求中不包括片段标识符,因此从 Web 服务器的角度来看,URL http://igweb.kamesh.com/webapp#orange
和 URL http://igweb.kamesh.com/webapp#apple
指向相同的资源:http://igweb.kamesh.com/webapp
。
搜索引擎爬虫必须以更复杂的方式实现,以处理包含片段标识符的网站的索引复杂性。尽管谷歌在解决这个问题上取得了相当大的进展,但实现不带片段标识符的 URL 仍然是推荐的最佳实践,以确保网站能够被搜索引擎轻松索引。
值得注意的是,在某些情况下,SPA 架构可能会通过使用更现代的实践来克服这一劣势。例如,更近期的 SPA 实现完全避免了片段标识符,而是使用 Web 浏览器的 History API 来拥有更友好的搜索引擎 URL。
愿望清单项目#6:为了促进可发现性,网站应提供易于搜索引擎爬虫消费的格式良好的 HTML 内容。网站还应包含易于搜索引擎爬虫索引的链接。
主要优势
SPA 架构的主要优势在于它提供了客户端路由,防止了整个页面的重新加载。客户端路由涉及拦截给定网页上超链接的点击事件,以便它们不会发起新的 HTTP 请求到 Web 服务器。客户端路由器将给定路由与负责处理路由的客户端路由处理程序相关联。
例如,让我们考虑一个实现了客户端路由的电子商务网站。当用户点击链接到瑞士军刀产品详情页面时,不会启动完全重新加载页面,而是向 Web 服务器的 REST API 端点发出 XHR 调用。端点以 JavaScript 对象表示法(JSON)格式返回有关瑞士军刀的配置数据,客户端应用程序用于呈现瑞士军刀产品详情页面的内容。
从用户的角度来看,体验是无缝的,因为用户不会经历在完全重新加载页面时遇到的突然的白屏。
缺点
SPA 架构具有以下缺点:
-
最初的页面加载被认为是较慢的
-
降低搜索引擎的可发现性
较慢的初始页面加载
基于 SPA 的 Web 应用程序的初始页面加载可能被认为是缓慢的。这种缓慢可能是由于初始下载聚合 JavaScript 有效载荷所需的时间而导致的。
传输控制协议(TCP)具有缓慢启动机制,其中数据以段的形式发送。JavaScript 有效载荷在完全传递到 Web 浏览器之前,需要在服务器和客户端之间进行多次往返:
图 1.7:由于用户被加载指示器所招呼,初始页面加载被认为是缓慢的,而不是呈现的网页
这导致用户必须等待 JavaScript 有效载荷完全获取,然后网页才能完全呈现。使用加载指示器(如旋转的轮子)是一种常见的用户体验(UX)实践,让用户知道用户界面仍在加载中。
图 1.7包括一个插图(左侧)显示加载指示器,以及一个插图(右侧)显示加载的网页布局。重要的是要注意,根据 SPA 的实现方式,可能会在构成网页的各个部分中分布多个加载指示器。
我相信,在您自己的网络浏览中,您可能已经使用过包含这些加载旋转器的 Web 应用程序。从用户的角度来看,我们可以同意,理想情况下,我们宁愿看到呈现的输出,而不是旋转的轮子。
愿望清单项目#5:为了给用户留下最好的第一印象,网站应该能够立即向用户显示内容。
同构 Web 应用程序架构
同构 Web 应用程序架构包括在服务器端和客户端分别实现两个 Web 应用程序,使用相同的编程语言并在两个环境中重用代码:
图 1.8:同构 Web 应用程序架构
如图 1.8 所示,业务逻辑可以在不同环境中共享。例如,如果我们定义了一个“产品”结构来模拟我们电子商务网站上的产品,服务器端和客户端应用程序都可以知道它。
除此之外,模板渲染器存在于服务器端和客户端,因此模板也可以在不同环境中进行渲染,使模板成为“同构”。
“同构”一词可用于描述可以在不同环境之间共享的任何内容(业务逻辑、模板、模板函数和验证逻辑)。
服务器端路由处理程序负责在服务器端服务路由,客户端路由处理程序负责在客户端服务路由。当用户最初访问使用同构 Web 应用程序架构实现的网站时,服务器端路由处理程序启动并使用服务器端模板渲染器生成网页响应。
网站的后续用户交互是在 SPA 模式下使用客户端路由进行的。客户端路由处理程序负责为给定的客户端路由提供服务,并使用客户端模板渲染器将内容呈现到网页(用户界面)上。
客户端应用程序可以发起 XHR 请求到 Web 服务器上的 Rest API 端点,从服务器的响应中检索数据,并使用客户端模板渲染器在网页上呈现内容。
同构的 Go Web 应用程序可以选择使用 WebSocket 连接,如图 1.8所示,用于 Web 服务器和 Web 浏览器之间的持久、双向通信。同构的 Go Web 应用程序还具有以gob
格式发送和接收数据的额外好处——gob
是 Go 的二进制编码数据格式。可以使用标准库中的encoding/gob
包对数据进行编码和解码为gob
格式。
Gob 编码的数据比 JSON 具有更小的数据存储占用空间。
gob
格式的主要优势是其较小的存储占用空间。JSON 数据是文本格式,众所周知,文本格式的数据在与二进制编码格式相比需要更大的存储占用空间。通过在客户端和服务器之间交换较小的数据负载,Web 应用程序在传输数据时可以获得更快的响应时间。
愿望清单已实现
同构的 Web 应用架构为三种传统 Web 应用架构中发现的所有缺点提供了解决方案。让我们盘点一下我们在愿望清单上放置的项目:
-
为了增强用户体验,在网站上点击链接不应导致全页重新加载。
-
为了增加可维护性,应该有一个单一、统一的项目代码库,使用单一编程语言实现。
-
为了提高效率,应该有一种分布式模板渲染的机制。
-
为了提高生产力,应该有一种方式在不同环境中共享和重用代码,以避免重复劳动。
-
为了给出最好的第一印象,网站应该能够迅速向用户显示内容。
-
为了提高可发现性,网站应提供易于搜索引擎机器人消费的格式良好的 HTML 内容。网站还应包含易于搜索引擎机器人索引的链接。
现在,是时候检查同构的 Web 应用架构如何满足我们愿望清单上的每一项了。
1. 提升用户体验
在初始服务器端呈现的网页响应之后,同构的 Web 应用架构通过以 SPA 模式运行来增强用户体验。客户端路由用于网站的后续用户交互,防止全页重新加载,并增强网站的用户体验。
2. 增加可维护性
由于同构的 Web 应用架构使用单一编程语言来实现客户端和服务器端的 Web 应用程序,因此项目代码库的可维护性得到了加强。这可以避免在不同环境中处理两种不同编程语言时发生的心理上下文转换。
3. 增加效率
同构的 Web 应用架构通过提供分布式模板渲染机制——同构模板渲染器,增加了呈现内容的效率。如图 1.8所示,由于服务器端和客户端都有模板渲染器,模板可以在不同环境中轻松重用。
4. 增加生产力
同构 Web 应用程序架构的标志是单一统一的代码库,提供了许多机会在不同环境之间共享代码。例如,表单验证逻辑可以在不同环境之间共享,允许在客户端和服务器端使用相同的验证逻辑验证 Web 表单。还可以在客户端和服务器端之间共享模型和模板。
6. 促进可发现性
同构 Web 应用程序架构促进了可发现性,因为它可以轻松提供格式良好的 HTML 内容。请记住,Go 模板的渲染输出是 HTML。
使用同构模板渲染器,HTML 内容可以在客户端和服务器端轻松渲染。这意味着我们可以为传统搜索引擎爬虫提供格式良好的 HTML 内容,这些爬虫只是简单地抓取网页内容,以及为可能配备 JavaScript 运行时的现代搜索引擎爬虫提供格式良好的 HTML 内容。
同构 Web 应用程序架构促进可发现性的另一种方式是应用程序的路由处理程序(服务器端和客户端)可以定义格式良好的 URL,并且这些 URL 可以轻松被搜索引擎爬虫索引。
这是可能的,因为客户端实现的路由处理程序利用 Web 浏览器的 History API 来匹配服务器端定义的相同路由。例如,瑞士军刀产品详情页面的/product-detail/swiss-army-knife
路由可以由服务器端和客户端路由器注册。
5. 给出最好的第一印象
同构 Web 应用程序架构使用服务器端渲染初始网页响应,确保用户在访问网站时立即看到内容。对于与用户的第一次接触,同构 Web 应用程序架构借鉴了经典 Web 应用程序架构的方法,提供初始网页响应。
这对用户来说是一个受欢迎的好处,因为内容会立即显示给他们,用户会感知到快速加载页面的结果。这与 SPA 架构形成鲜明对比,因为在 SPA 架构中,用户必须等待客户端应用程序引导完成后才能在屏幕上看到网页内容出现。
实时演示
现在是时候看同构 Web 应用程序架构的实际效果了。我们将在本书的过程中实施的网站 IGWEB 的实时演示可在igweb.kamesh.com
上找到。图 1.9是网站首页的截图:
图 1.9:IGWEB:使用同构 Go 实现的网站
请注意,在以上折叠区域(在浏览器窗口中可见的区域)中的内容会立即显示。此外,当通过导航菜单中的链接导航到网站的不同部分时,请注意网站的响应性。我们将在下一章为您详细介绍 IGWEB 项目。
在撰写本文时,IGWEB 已经验证可以在以下 Web 浏览器中运行:Google Chrome 版本 62.0,Apple Safari 版本 9.1.1,Mozilla Firefox 57.0 和 Microsoft Edge 15.0。建议您使用与此列表中提供的版本相同或更高版本的 Web 浏览器。
可衡量的好处
本书介绍的使用 Go 开发同构 Web 应用程序的方法已经被证明在提供增强用户体验方面具有可衡量的好处。
我们可以使用 Google PageSpeed Insights 工具(developers.google.com/speed/pagespeed/insights/
)来评估 IGWEB 首页的性能。该工具根据网页内容的组织、静态资产的大小和呈现网页所需的时间等各种标准,评估网页提供良好用户体验的程度,评分从 0 到 100。
图 1.10:通过 Google PageSpeed Insights 工具运行 IGWEB 首页的结果
图 1.10是一个屏幕截图,显示了评估 IGWEB 桌面版的结果。在撰写本文时,IGWEB 在桌面浏览体验方面得分为 97/100,在移动浏览体验方面得分为 91/100。根据该工具,桌面和移动版均达到 90+分,表明 IGWEB 首页应用了大多数性能最佳实践,并应该提供良好的用户体验。
命名
我在GopherCon India上的开场演讲中使用了“等同 Go”作为标题,主题是在 Go 中开发等同 Web 应用程序。我的演讲标题是受到“等同 JavaScript”一词的启发。术语“等同 JavaScript”是由 Charlie Robbins 在他 2011 年的博客文章中创造的(blog.nodejitsu.com/scaling-isomorphic-javascript-code/
),Scaling Isomorphic JavaScript Code。
“等同”一词源自数学。在希腊语中,iso 意为相等,morphosis 意为形成或塑造。
JavaScript 社区内存在关于使用术语“等同”的辩论,用来描述一个包含可以在客户端或服务器上运行的代码的 Web 应用程序。JavaScript 社区的一些成员更喜欢使用术语“universal”。
在我看来,术语“等同”更合适,而术语“universal”引入了歧义。这种歧义源于“universal”一词带有一些附加含义。
苹果广泛使用术语“通用二进制”来描述包含多个处理器架构的机器代码的 fat 二进制文件。现代 JavaScript 代码通过即时编译器编译为机器代码。
因此,使用术语“universal”是模棱两可的,并且需要额外的细节来确定其使用的上下文。因此,本书中将使用的首选术语是“等同”。
先决条件
本书侧重于教授如何使用 Go 编程语言创建等同 Web 应用程序。由于我们将采用一种以 Go 为重点的成语化方法,因此不需要事先熟悉 JavaScript 生态系统中的库和工具。
我们假设读者在 Go 或其他服务器端编程语言方面具有一定的先前编程经验。
如果您以前从未在 Go 中编程,我建议您参考tour.golang.org
上提供的《Go 之旅》。
要更深入地学习基本的 Go 概念,我建议您观看我的视频课程《全栈 Web 开发的 Go 基础》,Packt Publishing,可在www.packtpub.com/web-development/go-essentials-full-stack-web-development-video
上找到。
总结
在本章中,我们介绍了等同 Go。我们介绍了 Go 编程语言提供的许多优势,以及为什么它是创建等同 Web 应用程序的一个引人注目的选择。
我们回顾了传统的 Web 应用程序架构,包括经典的 Web 应用程序架构、AJAX 应用程序架构和 SPA 架构。我们确定了每种传统架构的优缺点。我们介绍了同构 Web 应用程序架构,并展示了它是如何解决传统架构的所有缺点的。
我们展示了 IGWEB 的现场演示,这是一个同构 Go 网站,并向您介绍了 Google PageSpeed Insight 工具,用于衡量网页性能。最后,我们为您提供了一些关于术语“同构”以及您需要了解的内容,以便充分理解本书涵盖的材料。
在第二章中,“同构 Go 工具链”,我们将向您介绍开发同构 Go Web 应用程序所使用的关键技术。我们还将向您介绍 IGWEB,这是一个同构 Go 网站,我们将在本书的过程中构建。
第二章:同构 Go 工具链
在上一章中,我们确定了同构网络应用架构提供的许多好处,以及使用 Go 编程语言构建同构网络应用的优势。现在,是时候探索使同构 Go 网络应用成为可能的基本要素了。
在本章中,我们将向您介绍同构 Go工具链。我们将研究构成工具链的关键技术——Go、GopherJS、同构 Go 工具包和 UX 工具包。一旦我们确定了如何获取和准备这些工具,我们将安装 IGWEB 演示——本书中将要实现的同构 Go 网络应用。随后,我们将深入研究 IGWEB 演示的解剖,检查项目结构和代码组织。
我们还将向您介绍一些有用和高效的技术,这些技术将贯穿整本书的使用,比如在服务器端实现自定义数据存储来满足我们的网络应用数据持久性需求,并利用依赖注入来提供常用功能。最后,我们将为 IGWEB 应用提供一个项目路线图,以规划我们在构建 Isomorphic Go 网络应用中的旅程。
在本章中,我们将涵盖以下主题:
-
安装同构 Go 工具链
-
设置 IGWEB 演示
-
IGWEB 演示简介
-
项目结构和代码组织
安装同构 Go 工具链
在本节中,我们将指导您完成安装和配置同构 Go 工具链的过程,这是一组技术,允许我们创建同构 Go 网络应用。以下是我们将要涵盖的关键技术:
-
Go
-
GopherJS
-
同构 Go 工具包
-
UX 工具包
我们将利用Go作为服务器端和客户端的编程语言来创建我们的网络应用。Go 允许我们使用简单易懂的语法创建可靠和高效的软件。它是一种现代的编程语言,设计用于多核处理器、网络系统、大规模计算集群和万维网的时代。由于 Go 是一种通用编程语言,它非常适合创建同构网络应用的理想技术。
GopherJS允许我们通过将 Go 代码转译为纯 JavaScript 代码,将 Go 引入客户端,这样可以在所有主要的 Web 浏览器中运行。GopherJS 提供了常见 JavaScript 功能的绑定,包括 DOM API、XHR、内置 JavaScript 函数/操作符和 WebSocket API。
同构 Go 工具包为我们提供了构建同构 Go 网络应用所需的技术。使用该项目提供的工具,我们可以实现同构网络应用所需的常见功能,如客户端路由、同构模板渲染和创建同构网络表单。
UX 工具包为我们提供了在 Go 中创建可重用组件的能力,这些组件被称为cogs。您可以将它们视为自包含的用户界面小部件,促进了可重用性。Cogs 可以作为纯 Go cogs 或混合 cogs 实现,可以利用现有的 JavaScript 功能。Cogs 在服务器端注册,并在客户端部署。
图 2.1展示了我们将作为 Venn 图使用的技术堆栈,清楚地指示了技术组件将驻留在哪个环境(或多个环境)中:
图 2.1:同构 Go 工具链:Go、GopherJS、同构 Go 工具包和 UX 工具包
既然我们已经确定了构成我们技术堆栈的关键组件,让我们继续安装/配置它们。
Go
如果您对 Go 不熟悉,值得花些时间参加 Go 之旅,网址为tour.golang.org
。
在继续之前,您需要在系统上安装 Go。在本节中,我们将提供安装 Go 和设置 Go 工作区的高级概述。如果您需要进一步帮助,可以访问golang.org/doc/install
获取安装 Go 的详细说明。
让我们前往 Go 网站,网址为golang.org
:
图 2.2:Go 网站
单击图 2.2中显示的下载 Go 链接,以进入下载页面(golang.org/dl/
),如图 2.3所示:
图 2.3:Go 网站上的下载页面
如您所见,Go 适用于所有主要操作系统。我们将在 Mac 上进行安装和配置过程。有关在其他操作系统上安装 Go 的信息可以在 Go 网站的入门文档中找到,网址为golang.org/doc/install
。
在下载页面上,单击链接以下载适用于您操作系统的 Go 分发。我单击了下载 Apple macOS 安装程序的链接。
使您的系统能够运行 Go 将包括以下步骤:
-
安装 Go
-
设置您的 Go 工作区
-
构建和运行程序
安装 Go
下载完成后,继续启动安装程序。Go 安装程序显示在图 2.4中:
图 2.4:Go 安装程序
按照安装程序的屏幕提示操作,如果安装程序要求您使 Go 对系统上的所有用户可用,请确保选择为系统的所有用户安装 Go。您可能还需要输入系统凭据(以便您可以为系统上的所有用户安装 Go)。再次,继续并提供您的系统凭据。
安装程序完成后,您应该从 Go 安装程序获得以下确认:
图 2.5:Go 安装程序报告安装成功
安装程序完成后,让我们打开命令提示符并检查安装程序安装文件的位置:
$ which go
/usr/local/go/bin/go
在 macOS 系统上,Go 分发安装到/usr/local/go
目录中,Go 分发附带的二进制文件安装在/usr/local/go/bin
目录中。
如果您是 Go 工具链的新手,您应该使用go help
命令来熟悉 Go 附带的各种命令:
$ go help
Go is a tool for managing Go source code.
Usage:
go command [arguments]
The commands are:
build compile packages and dependencies
clean remove object files
doc show documentation for package or symbol
env print Go environment information
bug start a bug report
fix run go tool fix on packages
fmt run gofmt on package sources
generate generate Go files by processing source
get download and install packages and dependencies
install compile and install packages and dependencies
list list packages
run compile and run Go program
test test packages
tool run specified go tool
version print Go version
vet run go tool vet on packages
Use "go help [command]" for more information about a command.
Additional help topics:
c calling between Go and C
buildmode description of build modes
filetype file types
gopath GOPATH environment variable
environment environment variables
importpath import path syntax
packages description of package lists
testflag description of testing flags
testfunc description of testing functions
Use "go help [topic]" for more information about that topic.
要确定系统上安装的 Go 版本,您可以使用go version
命令:
$ go version
go version go1.9.1 darwin/amd64
您应该在系统上安装最新版本的 Go,并且在继续之前,您需要有一个正确配置的 Go 工作区。
设置您的 Go 工作区
现在您已成功在系统上安装了 Go,您需要在继续之前拥有一个正确配置的 Go 工作区。我们将提供设置 Go 工作区的高级概述,如果您需要进一步帮助,可以阅读 Go 网站上提供的设置 Go 工作区的详细说明:golang.org/doc/code.html
。
使用您喜欢的文本编辑器打开您的主目录中的.profile
文件。如果您使用 Linux,您需要打开主目录中找到的.bashrc
文件。
我们将在文件中添加以下行以添加一些非常重要的环境变量:
export GOROOT=/usr/local/go
export GOPATH=/Users/kamesh/go
export GOBIN=${GOPATH}/bin
export PATH=${PATH}:/usr/local/bin:${GOROOT}/bin:${GOBIN}
我的用户名是kamesh
,您显然需要用您的用户名替换它。
$GOROOT
是一个环境变量,用于指定 Go 分发在系统上的安装位置。
$GOPATH
是一个环境变量,用于指定包含所有 Go 项目源代码的顶级目录。这个目录被称为我们的 Go 工作空间。我已经在我的家目录的go
文件夹中创建了我的工作空间:/Users/kamesh/go
。
让我们继续创建我们的 Go 工作空间,以及其中的三个重要目录:
$ mkdir go
$ mkdir go/src
$ mkdir go/pkg
$ mkdir go/bin
go/src
目录将包含 Go 源文件。go/pkg
目录将包含编译后的 Go 包。最后,go/bin
目录将包含编译后的 Go 二进制文件。
$GOBIN
是一个环境变量,用于指定 Go 应该安装编译后的二进制文件的位置。当我们运行go install
命令时,Go 会编译我们的源代码,并将新创建的二进制文件存储在$GOBIN
指定的目录中。
我们向$PATH
环境变量添加了两个额外的条目——$GOROOT/bin
和$GOBIN
目录。这告诉我们的 shell 环境在哪里找到与 Go 相关的二进制文件。将$GOROOT/bin
添加到$PATH
中,让 shell 环境知道 Go 分发的二进制文件位于何处。添加$GOBIN
告诉 shell 环境我们创建的 Go 程序的二进制文件位于何处。
构建和运行 Go 程序
让我们创建一个简单的“hello world”程序来检查我们的 Go 设置。
我们首先在 Go 工作空间的src
目录中创建一个新程序的目录,如下所示:
$ cd $GOPATH/src
$ mkdir hellogopher
现在,使用您喜欢的文本编辑器,在hellogopher
目录中创建一个hellogopher.go
源文件,内容如下:
package main
import "fmt"
func main() {
fmt.Println("Hello Gopher!")
}
要一步构建和运行此程序,您可以发出go run
命令:
$ go run hellogopher.go
Hello Gopher!
要生成一个存在于当前目录中的二进制可执行文件,您可以发出go build
命令:
$ go build
要构建一个二进制可执行文件并自动将其移动到您的$GOBIN
目录,您可以发出go install
命令:
$ go install
发出go install
命令后,您只需输入以下命令来运行它(假设$GOBIN
在您的$PATH
中已经指定):
$ hellogopher
Hello Gopher!
此时,我们已经成功安装、配置和验证了 Go 安装。现在是时候启动其他工具了,首先是 GopherJS。
GopherJS
GopherJS 是一个将 Go 代码转换为纯 JavaScript 代码的转换器。使用 GopherJS,我们可以用 Go 编写前端代码,这些代码将在支持 JavaScript 的所有主要 Web 浏览器上运行。这项技术使我们能够在 Web 浏览器中释放 Go 的力量,没有它,同构 Go 将是不可能的。
在本章中,我们将向您展示如何安装 GopherJS。我们将在第三章中更详细地介绍 GopherJS,使用 GopherJS 进行前端开发。
开始使用 GopherJS 包括以下步骤:
-
安装 GopherJS
-
安装必要的 GopherJS 绑定
-
在命令行上熟悉 GopherJS
安装 GopherJS
我们可以通过发出以下go get
命令来安装 GopherJS:
$ go get -u github.com/gopherjs/gopherjs
要查找系统上安装的gopherjs
的当前版本,使用gopherjs version
命令:
$ gopherjs version
GopherJS 1.9-1</strong>
Go 和 GopherJS 的主要版本必须在您的系统上匹配。在本书中,我们将使用 Go 的 1.9.1 版本和 GopherJS 的 1.9-1 版本。
您可以输入gopherjs help
来熟悉 GopherJS 提供的各种命令:
$ gopherjs
GopherJS is a tool for compiling Go source code to JavaScript.
Usage:
gopherjs [command]
Available Commands:
build compile packages and dependencies
doc display documentation for the requested, package, method or symbol
get download and install packages and dependencies
install compile and install packages and dependencies
run compile and run Go program
serve compile on-the-fly and serve
test test packages
version print GopherJS compiler version
Flags:
--color colored output (default true)
--localmap use local paths for sourcemap
-m, --minify minify generated code
-q, --quiet suppress non-fatal warnings
--tags string a list of build tags to consider satisfied during the build
-v, --verbose print the names of packages as they are compiled
-w, --watch watch for changes to the source files
Use "gopherjs [command] --help" for more information about a command.
安装必要的 GopherJS 绑定
现在我们已经安装了 GopherJS 并确认它可以工作,我们需要获取以下 GopherJS 绑定,这些绑定是我们前端网页应用开发所需的:
-
dom
-
jsbuiltin
-
xhr
-
websocket
dom
dom
包为我们提供了 JavaScript 的 DOM API 的 GopherJS 绑定。
我们可以通过发出以下命令来安装dom
包:
$ go get honnef.co/go/js/dom
jsbuiltin
jsbuiltin
包为常见的 JavaScript 运算符和函数提供了绑定。我们可以通过发出以下命令来安装jsbuiltin
包:
$ go get -u -d -tags=js github.com/gopherjs/jsbuiltin
xhr
xhr
包为XMLHttpRequest
对象提供了绑定。我们可以通过以下命令安装xhr
包:
$ go get -u honnef.co/go/js/xhr
websocket
websocket
包为 Web 浏览器的 WebSocket API 提供了绑定。我们可以通过以下命令安装websocket
包:
$ go get -u github.com/gopherjs/websocket
熟悉命令行上的 GopherJS
gopherjs
命令与go
命令非常相似。例如,要将 Go 程序转译为其 JavaScript 表示形式,我们发出以下gopherjs build
命令:
$ gopherjs build
要构建一个 GopherJS 项目并缩小生成的 JavaScript 源文件,我们需要在gopherjs build
命令中指定-m
标志:
$ gopherjs build -m
当我们执行构建操作时,GopherJS 将创建一个.js
源文件和一个.js.map
源文件。
.js.map
文件称为源映射。当我们使用 Web 浏览器控制台追踪错误时,此功能非常有用,可以将缩小的 JavaScript 源文件映射回其未构建状态。
由 GopherJS 生成的 JavaScript 源文件可以作为外部 JavaScript 源文件导入到 Web 页面中,使用script
标签。
等同 Go 工具包
等同 Go 工具包(isomorphicgo.org
)为我们提供了实现等同 Go Web 应用程序所需的技术。我们将使用等同 Go 工具包中的isokit
包来实现等同 Web 应用程序:
图 2.6:等同 Go 网站
安装 isokit
Isomorphic Go 工具包的isokit
包提供了通用的等同功能,可以在服务器端或客户端上使用。该软件包提供的一些显着优点包括等同模板渲染、客户端应用程序路由、自动静态资产捆绑以及创建等同 Web 表单的能力。
我们可以通过以下go get
命令安装isokit
包:
$ go get -u github.com/isomorphicgo/isokit
UX 工具包
UX 工具包(uxtoolkit.io
)允许我们实现齿轮,这些齿轮是用 Go 实现的可重用组件,可以在组成 IGWEB 的网页中使用。我们将在第九章中介绍可重用组件,齿轮-可重用组件。
安装齿轮包
我们可以通过以下go get
命令安装cog
包:
$ go get -u github.com/uxtoolkit/cog
现在我们已经安装了等同 Go 工具链,是时候设置 IGWEB 演示了,这是本书中我们将构建的等同 Web 应用程序。
设置 IGWEB 演示
您可以通过以下go get
命令获取本书的源代码示例:
$ go get -u github.com/EngineerKamesh/igb
IGWEB 演示网站的完成实现源代码位于igb/igweb
文件夹中。各章节的源代码清单可以在igb/individual
文件夹中找到。
设置应用程序根环境变量
IGWEB 演示依赖于应用程序根环境变量$IGWEB_APP_ROOT
的定义。Web 应用程序使用此环境变量来声明其所在位置。通过这样做,Web 应用程序可以确定其他资源的位置,例如静态资产(图像、css 和 javascript)。
您应该通过在 bash 配置文件中添加以下条目来设置$IGWEB_APP_ROOT
环境变量:
export IGWEB_APP_ROOT=${GOPATH}/src/github.com/EngineerKamesh/igb/igweb
要验证环境中是否存在$IGWEB_APP_ROOT
环境变量,可以使用echo
命令:
$ echo $IGWEB_APP_ROOT
/Users/kamesh/go/src/github.com/EngineerKamesh/igb/igweb
转译客户端应用程序
现在我们已经设置了$IGWEB_APP_ROOT
环境变量,我们可以访问client
目录,其中包含客户端 Web 应用程序:
$ cd $IGWEB_APP_ROOT/client
我们发出以下go get
命令来安装可能需要的任何其他依赖项,以确保我们的客户端应用程序正常运行:
$ go get ./..
最后,我们发出gopherjs build
命令来转译 IGWEB 客户端 Web 应用程序:
$ gopherjs build
运行命令后,应该生成两个文件——client.js
和client.js.map
。client.js
源文件是 IGWEB 客户端 Go 程序的 JavaScript 表示。client.js.map
文件是源映射文件,将与client.js
一起在 Web 浏览器中使用,以在 Web 控制台中提供详细信息,这在调试问题时非常方便。
现在我们已经转译了 IGWEB 客户端应用程序的代码,下一个逻辑步骤将是构建和运行 IGWEB 服务器端应用程序。在我们这样做之前,我们必须安装并运行本地 Redis 实例,这是我们将在下一节中做的事情。
设置 Redis
Redis 是一种流行的 NoSQL 内存数据库。由于整个数据库都存在于内存中,数据库查询非常快速。Redis 也以支持多种数据类型而闻名,它是一个多用途工具,可以用作数据库、内存缓存,甚至作为消息代理。
在本书中,我们将使用 Redis 来满足 IGWEB 的数据持久化需求。我们将在默认端口 6379 上运行我们的 Redis 实例。
我们发出以下命令来下载和安装 Redis:
$ wget http://download.redis.io/releases/redis-4.0.2.tar.gz
$ tar xzf redis-4.0.2.tar.gz
$ cd redis-4.0.2
$ make
$ sudo make install
使用wget
命令获取 Redis 的替代方法是从 Redis 下载页面获取,如图 2.7所示,网址为redis.io/download
:
图 2.7:Redis 网站上的下载部分
下载并安装 Redis 后,您可以通过发出redis-server
命令启动服务器:
$ redis-server
在另一个终端窗口中,我们可以打开 Redis 的命令行界面(CLI),使用redis-cli
命令连接到 Redis 服务器实例:
$ redis-cli
我们可以使用set
命令设置foo
键的bar
值:
127.0.0.1:6379> set foo bar
OK
我们可以使用get
命令获取foo
键的值:
127.0.0.1:6379> get foo
"bar"
您可以通过访问 Redis 网站的文档部分了解更多关于 Redis 的信息,网址为redis.io/documentation.
。阅读 Redis 快速入门文档,网址为redis.io/topics/quickstart
,也是有帮助的。现在我们已经安装了本地 Redis 实例,是时候构建和运行 IGWEB 演示了。
运行 IGWEB 演示
您可以通过首先将目录更改为$IGWEB_APP_ROOT
目录,然后发出go run
命令来运行 IGWEB Web 服务器实例:
$ cd $IGWEB_APP_ROOT
$ go run igweb.go
您可以通过访问http://localhost:8080/index
链接从您的 Web 浏览器访问 IGWEB 网站。您应该能够看到网站的主页,如图 2.8所示:
图 2.8:IGWEB 主页
我们安装过程的最后一步是使用示例数据集加载本地 Redis 实例。
加载示例数据集
提供的示例数据集用于填充产品列表和关于页面的数据。您可以通过访问http://localhost:8080/products
在浏览器中查看产品列表页面,您应该会看到图 2.9中显示的屏幕:
图 2.9:空产品部分,显示加载示例数据集的消息
继续点击网页上显示的链接以加载示例数据集。当您点击链接时,您应该会看到图 2.10中显示的屏幕:
图 2.10:确认已加载示例数据集
现在,如果您返回产品列表页面,您应该会看到页面上显示的产品,如图 2.11所示:
图 2.11:填充了产品的产品部分
现在我们已经启动并运行了 IGWEB 演示!
每当我们想要对服务器端的 Go 应用程序进行更改时,我们需要发出 go build
命令并重新启动 web 服务器实例。同样,每当我们对客户端的 Go 应用程序进行更改时,我们必须发出 gopherjs build
命令。在开发过程中不断发出这些命令可能会很烦人和低效。kick
命令为我们提供了一种更高效的方式。
使用 kick
kick
命令是一种轻量级机制,为 Go web 服务器实例提供了即时启动。当应用程序项目目录(或其任何子目录)中的 Go 源文件发生更改时,即时启动就会发生。
kick
命令为我们提供了一种自动化开发工作流程的手段,通过重新编译我们的 Go 代码并重新启动 web 服务器,每当我们对 Go 源文件进行更改时。
kick
提供的工作流程类似于使用动态脚本语言(如 PHP)开发 web 应用程序,每当对 PHP 源文件进行更改时,刷新浏览器中的网页会立即反映出更改。
在这个问题空间中,kick
与其他基于 Go 的解决方案的不同之处在于,在执行即时启动时,它考虑了 go
和 gopherjs
命令。它还考虑了对模板文件的更改,使其成为同构 web 应用程序开发的便捷工具。
安装 kick
要安装 kick
,我们只需发出以下 go get
命令:
$ go get -u github.com/isomorphicgo/kick
运行 kick
要了解如何使用 kick
,可以像这样发出 help
命令行标志:
$ kick --help
--appPath
标志指定 Go 应用程序项目的路径。--gopherjsAppPath
标志指定 GopherJS 项目的路径。--mainSourceFile
标志指定包含 Go 应用程序项目目录中 main
函数实现的 Go 源文件的名称。如果你仍然在终端窗口中使用 go run
命令运行 IGWEB,现在是退出程序并使用 kick
运行它的时候了。
要使用 kick
运行 IGWEB 演示,我们发出以下命令:
$ kick --appPath=$IGWEB_APP_ROOT --gopherjsAppPath=$IGWEB_APP_ROOT/client --mainSourceFile=igweb.go
验证 kick 是否正常工作
让我们打开关于页面(http://localhost:8080/about
)以及网络检查器。注意在网络控制台中显示的 IGWEB 客户端应用程序的消息,如 图 2.12 所示:
图 2.12:在网络控制台中打印的消息
让我们打开位于 client
目录中的 client.go
源文件。让我们用以下内容替换 run
函数中的第一行:
println("IGWEB Client Application - Kamesh just made an update.")
保存文件并查看终端窗口,在那里你正在运行 kick
,你应该能够看到以下消息出现:
Instant KickStart Applied! (Recompiling and restarting project.)
这是来自 kick
的确认,它已经检测到文件的更改,并执行了即时启动。现在,让我们重新加载网页,你应该能够看到更新后的消息,如 图 2.13 所示:
图 2.13:修改后的消息在网络控制台中打印出来
现在你已经成功使用 kick
在你的机器上运行 IGWEB 演示,现在是介绍项目的时候了。
IGWEB 演示简介
IGWEB 是由三个想要使用同构 Go 在网上构建简单商店演示的虚构科技初创公司。这些有进取心的 gopher 的想法是将在车库/庭院销售的常见二手产品在线销售。这个 gopher 团队选择在同构 Go 中实现 IGWEB 演示,不仅提供增强的用户体验,还能获得更大的搜索引擎可发现性。如果你还没有猜到,IGWEB 简单地代表同构 Go web 应用程序。
从头开始构建 IGWEB
为了理解构建同构 Web 应用程序涉及的基本概念,我们将在创建 IGWEB 时遵循惯用的 Go 方法。我们将利用标准库中的功能以及第三方包中发现的功能。
如果您有使用 Web 框架开发 Web 应用程序的经验,您可能会想知道为什么我们采取这种方法。在撰写本文时,没有基于 Go 的 Web 框架可以提供开箱即用的功能,用于创建符合上一章中介绍的同构 Web 应用程序架构的 Web 应用程序。
此外,Web 框架通常涉及遵循特定的规则和约定,这可能是特定于框架的。我们的重点是概念性的,不与特定的 Web 框架绑定。因此,我们的注意力将集中在创建同构 Web 应用程序涉及的基本概念上。
IGWEB 路线图
在构建 IGWEB 演示网站的每个部分和功能的过程中,我们将学习更多关于同构 Go 的知识。以下是 IGWEB 主要部分/功能的路线图,以及在书中实现该特定部分或功能的相应章节。
首页
除了包含精选产品的图像轮播和多个实时时钟之外,IGWEB 首页还包含一个链接到独立前端编码示例的部分。
独立示例包括各种前端编程示例,使用 GopherJS 进行内联模板渲染的示例,以及本地存储检查器的示例。这些示例将在第三章中进行介绍,使用 GopherJS 进行前端开发。图像轮播和实时时钟将在第九章中进行介绍,齿轮-可重用组件。
首页的位置:http://localhost:8080/index
。
关于页面
我们的 gopher 团队希望通过在 IGWEB 的关于页面上亮相来向世界展示自己。在实现这一目标的过程中,我们将学习同构模板渲染以及在不同环境中共享模板、模板数据和模板函数的能力。
关于页面将在第四章中进行介绍,同构模板。
关于页面的位置:http://localhost:8080/about
。
产品页面
产品列表页面展示了 IGWEB 网站上可供销售的产品。每个产品都有产品标题、图像缩略图预览、产品价格和简短描述。单击产品图像将带用户转到产品详细页面,在那里用户可以了解更多关于该特定产品的信息。通过实现产品列表和产品详细页面,我们将了解同构 Go 中的端到端应用程序路由。
产品页面将在第五章中进行介绍,端到端路由。
产品页面的位置:http://localhost:8080/products
。
购物车功能
产品页面中显示的每个产品卡都将包含一个“添加到购物车”按钮。该按钮也将出现在产品的详细页面上。我们将学习如何在执行购物车上的添加和删除操作时维护购物车的状态。
购物车功能将在第六章中进行介绍,同构交接。
位置:http://localhost:8080/shopping-cart
。
联系页面
联系页面将提供与 IGWEB 的 gopher 团队联系的方式。在实施联系表单的过程中,我们将了解如何实现一个同构 Web 表单,它在不同环境中共享验证逻辑。此外,我们还将学习 Web 表单如何在 Web 浏览器中禁用 JavaScript 的情况下保持弹性工作。
联系页面将在第七章中介绍,同构 Web 表单。联系表单的日期选择器cog
将在第九章中介绍,Cogs – 可重复使用的组件。
联系页面的位置:http://localhost:8080/contact
。
实时聊天功能
在需要更大的用户交互性的情况下,网站用户可以与实时聊天机器人进行交互。在构建实时聊天功能的过程中,我们将了解实时 Web 应用程序功能。实时聊天功能将在第八章中介绍,实时 Web 应用程序功能。
单击位于网页右上角的实时聊天图标即可激活实时聊天功能。
可重复使用的组件
通过实现各种可重复使用的组件,例如实时时钟和产品轮播图,我们将返回到主页,这些产品在 IGWEB 上可用。我们还将为联系页面构建日期选择器cog
,以及关于页面的时间组件。时间组件将以人类可读的格式表示时间。我们还将研究实现通知组件,用于向用户显示通知消息。
可重复使用的组件将在第九章中介绍,Cogs – 可重复使用的组件。
项目结构和代码组织
IGWEB 项目的代码可以在igweb
文件夹中找到,并且按照以下文件夹进行组织(按字母顺序列出):
⁃ bot
⁃ chat
⁃ client
⁃ carsdemo
⁃ chat
⁃ common
⁃ gopherjsprimer
⁃ handlers
⁃ localstoragedemo
⁃ tests
⁃ common
⁃ datastore
⁃ endpoints
⁃ handlers
⁃ scripts
⁃ shared
⁃ cogs
⁃ forms
⁃ models
⁃ templates
⁃ templatedata
⁃ templatefuncs
⁃ validate
⁃ static
⁃ css
⁃ fonts
⁃ images
⁃ js
⁃ templates
⁃ submissions
⁃ tests
bot
文件夹包含实现实时聊天功能的聊天机器人的源文件。
chat
文件夹包含实现实时聊天功能的聊天服务器的服务器端代码。
client
文件夹包含将使用 GopherJS 转译为 JavaScript 的客户端 Go 程序。
client/carsdemo
包含一个独立示例,演示使用 GopherJS 进行内联模板渲染。此示例将在第三章中介绍,使用 GopherJS 进行前端开发。
client/chat
文件夹包含实现聊天客户端的客户端代码。
client/common
文件夹包含实现客户端应用程序中使用的通用功能的客户端代码。
client/gopherjsprimer
包含独立的 GopherJS 示例,将在第三章中介绍,使用 GopherJS 进行前端开发。
client/handlers
文件夹包含客户端路由/页面处理程序。这些处理程序负责处理客户端页面的路由,防止完整页面重新加载。它们还负责处理给定网页的所有客户端用户交互。
client/localstoragedemo
包含本地存储检查器的实现,将在第三章中介绍,使用 GopherJS 进行前端开发。
client/tests
文件夹包含对客户端功能进行端到端测试的测试。该文件夹包括这三个文件夹:client/tests/go
,client/tests/js
和client/tests/screenshots
。go
子文件夹包含 CasperJS 测试,这些测试是模拟用户与使用 Go 实现的网站进行交互的自动化测试。运行scripts
文件夹中的build_casper_tests.sh
bash 脚本将每个 Go 源文件转译为其等效的 JavaScript 表示形式,并存储在js
子文件夹中。运行 CasperJS 测试时,将生成并保存截图在screenshots
子文件夹中。
common
文件夹包含实现服务器端应用程序中使用的通用功能的服务器端代码。
common/datastore
文件夹包含了实现 Redis 数据存储的服务器端代码,以满足应用程序的数据持久化需求。
endpoints
文件夹包含了负责为 Web 客户端发出的 XHR 调用提供服务的 Rest API 端点的服务器端代码。
handlers
文件夹包含了服务器端路由处理函数的服务器端代码,负责为特定路由提供服务。这些处理函数的主要责任是向客户端发送网页响应。它们用于初始网页加载,其中网页响应是使用经典的 Web 应用程序架构在服务器端呈现的。
scripts
文件夹包含了在命令行上运行的方便的 bash shell 脚本。
shared
文件夹包含了在服务器端和客户端之间共享的等同代码。查看这个文件夹可以让我们了解所有可以在各个环境中共享的 Go 代码。
shared/cogs
文件夹包含了可重复使用的组件(cogs),这些组件在服务器端注册并在客户端部署。
shared/forms
文件夹包含了等同 Web 表单。
shared/models
文件夹包含了我们用来模拟数据的等同类型(结构)在我们的等同 Web 应用程序中使用。
shared/templates
文件夹包含了可以在各个环境中渲染的等同模板。
shared/templatedata
文件夹包含了在渲染时要提供给等同模板的等同数据对象。
shared/templatefuncs
文件夹包含了可以在各个环境中使用的等同模板函数。
shared/validate
文件夹包含了通用的等同验证逻辑,可以被各个环境中的 Web 表单利用。
static
文件夹包含了等同 Web 应用程序的所有静态资产。
static/css
文件夹包含了 CSS 样式表源文件。
static/fonts
文件夹包含了 Web 应用程序使用的自定义字体。
static/images
文件夹包含了 Web 应用程序使用的图像。
static/js
文件夹包含了 Web 应用程序的 JavaScript 源代码。
submissions
文件夹存在于举例说明的目的。该文件夹包含了submissions
包,其中包含了在 Web 表单成功通过 Web 表单验证过程后要调用的逻辑。
tests
文件夹包含了对服务器端功能进行端到端测试的测试。
MVC 模式
IGWEB 的项目代码库可以被概念化为遵循模型-视图-控制(MVC)模式。MVC 模式在 Web 应用程序的创建中被广泛使用,并在图 2.14中描述:
图 2.14:模型视图控制器模式
在基于 MVC 的应用程序中有三个主要组件——模型、视图和控制器。模型的主要目的是为应用程序提供数据和业务规则。把模型想象成应用程序数据需求的守门人。IGWEB 的模型可以在shared/models
文件夹中找到。
视图负责用户所见的输出。视图的重点在于呈现和将模型渲染到用户界面中,以一种对用户有意义的方式。IGWEB 中的视图存在于shared/templates
文件夹中找到的模板中。
控制器实现系统的应用逻辑,它们基本上告诉应用程序应该如何行为。您可以将控制器概念化为应用程序模型和视图之间的代理。控制器接受来自视图的用户输入,并可以访问或改变模型的状态。控制器还可以改变视图当前呈现的内容。IGWEB 中的服务器端控制器是handlers
文件夹中的路由处理程序。IGWEB 中的客户端控制器是client/handlers
目录中的路由/页面处理程序。
当您阅读本书中的示例时,请注意相对提到的所有文件夹都是相对于igweb
文件夹的。
现在我们已经确定了 IGWEB 项目的代码是如何组织的,我们可以开始实现构成我们 Isomorphic Go web 应用程序的各个部分和功能的旅程。
自定义数据存储
为 IGWEB 演示网站实现了自定义数据存储。尽管我们将在本书中仅使用 Redis 作为独占数据库,但事实上,只要您创建一个实现Datastore
接口的自定义数据存储,您就可以自由地使用几乎任何数据库。
让我们来看看在common/datastore
文件夹中的datastore.go
源文件中定义Datastore
接口的部分:
type Datastore interface {
CreateGopherTeam(team []*models.Gopher) error
GetGopherTeam() []*models.Gopher
CreateProduct(product *models.Product) error
CreateProductRegistry(products []string) error
GetProducts() []*models.Product
GetProductDetail(productTitle string) *models.Product
GetProductsInShoppingCart(cart *models.ShoppingCart) []*models.Product
CreateContactRequest(contactRrequest *models.ContactRequest) error
Close()
}
我们将在各自处理特定部分或功能的章节中讨论Datastore
接口的各个方法,其中使用了该方法。请注意,实现Datastore
接口所需的最终方法是Close
方法(以粗体显示)。Close
方法确定数据存储如何关闭其连接(或清空其连接池)。
在common/datastore
文件夹中的redis.go
源文件中检查RedisDatastore
的实现,将会提供一个创建实现Datastore
接口的自定义数据存储所需的内容。
在datastore.go
源文件中进一步定义了NewDatastore
函数,该函数负责返回一个新的数据存储:
const (
REDIS = iota
)
func NewDatastore(datastoreType int, dbConnectionString string) (Datastore, error) {
switch datastoreType {
case REDIS:
return NewRedisDatastore(dbConnectionString)
default:
return nil, errors.New("Unrecognized Datastore!")
}
}
我们的数据存储解决方案是灵活的,因为我们可以用任何其他数据库替换 Redis 数据存储,只要我们的新自定义数据存储实现了Datastore
接口。请注意,我们在常量分组中使用iota
枚举器定义了REDIS
常量(以粗体显示)。检查NewDatastore
函数,并注意当在datastoreType
的switch
块中遇到REDIS
情况时,会返回一个新的RedisDatastore
实例(以粗体显示)。
如果我们想为另一个数据库添加支持,比如 MongoDB,我们只需在常量分组中添加一个新的常量条目MONGODB
。除此之外,我们还将在NewDatastore
函数的switch
块中为 MongoDB 添加一个额外的case
语句,该语句返回一个NewMongoDataStore
实例,并将连接字符串作为输入参数传递给该函数。NewMongoDBDatastore
函数将返回我们自定义数据存储类型MongoDBDataStore
的实例,该类型将实现Datastore
接口。
以这种方式实现自定义数据存储的一个巨大好处是,我们可以防止在特定数据库的情况下使我们的 Web 应用程序充斥着数据库驱动程序特定的调用。通过自定义数据存储,我们的 Web 应用程序变得对数据库不可知,并为我们提供了更大的灵活性来处理我们的数据访问和数据存储需求。
GopherFace 网络应用程序,来自使用 Go 视频系列的网络编程,实现了针对 MySQL、MongoDB 和 Redis 的自定义数据存储。使用这些数据库的自定义数据存储的示例可在github.com/EngineerKamesh/gofullstack/tree/master/volume2/section5/gopherfacedb/common/datastore
找到。
依赖注入
服务器端应用程序的主要入口点是igweb.go
源文件中定义的main
函数。客户端应用程序的主要入口点是client/client.go
源文件中定义的main
函数。在这两个主要入口点中,我们利用依赖注入技术在整个 Web 应用程序中共享通用功能。通过这样做,我们避免了使用包级全局变量。
在服务器端和客户端,我们在common
包中实现了自定义的Env
类型。您可以考虑Env
代表了从应用环境中访问的通用功能。
以下是在服务器端common/common.go
源文件中找到的Env
结构的声明:
package common
import (
"github.com/EngineerKamesh/igb/igweb/common/datastore"
"github.com/gorilla/sessions"
"github.com/isomorphicgo/isokit"
)
type Env struct {
DB datastore.Datastore
TemplateSet *isokit.TemplateSet
}
DB
字段将用于存储自定义数据存储对象。
TemplateSet
字段是指向TemplateSet
对象的指针。模板集允许我们以灵活的方式在各种环境中呈现模板,我们将在第四章中详细介绍它们,同构模板。
Store
字段是指向sessions.FilesystemStore
对象的指针。我们将使用 Gorilla 工具包中的sessions
包进行会话管理。
在igweb.go
源文件的main
函数中,我们将声明一个env
变量,一个common.Env
类型的对象:
env := common.Env{}
我们使用新创建的RedisDatastore
实例和新创建的TemplateSet
实例分别为env
对象的DB
和TemplateSet
字段赋值(赋值以粗体显示)。出于说明目的,我们省略了一些代码,并在此处显示了部分代码清单:
db, err := datastore.NewDatastore(datastore.REDIS, "localhost:6379")
ts := isokit.NewTemplateSet()
env.TemplateSet = ts
env.DB = db
我们将使用 Gorilla Mux 路由器来满足我们的服务器端路由需求。注意,我们将env
对象的引用作为输入参数(以粗体显示)传递给registerRoutes
函数:
func registerRoutes(env *common.Env, r *mux.Router) {
我们通过将env
对象作为输入参数包含在我们为特定路由注册的路由处理函数中,将env
对象传播给我们的请求处理程序函数,如下所示:
r.Handle("/index", handlers.IndexHandler(env)).Methods("GET")
通过调用 Gorilla Mux 路由器的Handle
方法,我们已经注册了/index
路由,并将handlers
包中的IndexHandler
函数关联为将为此路由提供服务的函数。我们将env
对象的引用作为此函数的唯一输入参数提供(以粗体显示)。此时,我们已成功传播了RedisDatastore
和TemplateSet
实例,并使它们可用于IndexHandler
函数。
让我们来检查handlers/index.go
源文件中定义的IndexHandler
函数的源代码:
package handlers
import (
"net/http"
"github.com/EngineerKamesh/igb/igweb/common"
"github.com/EngineerKamesh/igb/igweb/shared/templatedata"
"github.com/isomorphicgo/isokit"
)
func IndexHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
templateData := templatedata.Index{PageTitle: "IGWEB"}
env.TemplateSet.Render("index_page", &isokit.RenderParams{Writer: w, Data: templateData})
})
}
注意,handler
函数的处理逻辑被放置在一个闭包中,我们已经闭包了env
变量。这使我们能够满足handler
函数应该返回http.Handler
的要求,同时,我们可以提供对env
对象的访问权限给handler
函数。
这种方法的好处是,与使用包级全局变量相比,我们可以明确地看到这个处理程序函数需要env
对象才能正常工作,方法是检查函数的输入参数(以粗体显示)。
我们在客户端也采用类似的依赖注入策略。以下是在client/common/common.go
源文件中找到的客户端侧Env
类型的声明:
package common
import (
"github.com/isomorphicgo/isokit"
"honnef.co/go/js/dom"
)
type Env struct {
TemplateSet *isokit.TemplateSet
Router *isokit.Router
Window dom.Window
Document dom.Document
PrimaryContent dom.Element
Location *dom.Location
}
我们在客户端声明的Env
类型与我们在服务器端声明的不同。这是可以理解的,因为我们希望在客户端访问一组不同的通用功能。例如,客户端没有RedisDatastore
。
我们以与服务器端相同的方式声明了TemplateSet
字段。因为*isokit.TemplateSet
类型是同构的,它可以存在于服务器端和客户端。
Router
字段是指向客户端isokit.Router
实例的指针。
Window
字段是Window
对象,Document
字段是Document
对象。
PrimaryContent
字段表示我们将在客户端渲染页面内容的div
容器。我们将在第四章 同构模板中更详细地介绍这些字段的作用。
Location
字段是Window
对象的Location
对象。
在client.go
源文件中定义的registerRoutes
函数内部,我们使用isokit.Router
来处理客户端路由需求。我们将env
对象传递给客户端处理函数,如下所示:
r := isokit.NewRouter()
r.Handle("/index", handlers.IndexHandler(env))
让我们来检查在client/handlers/index.go
源文件中定义的客户端端IndexHandler
函数的源代码:
func IndexHandler(env *common.Env) isokit.Handler {
return isokit.HandlerFunc(func(ctx context.Context) {
templateData := templatedata.Index{PageTitle: "IGWEB"}
env.TemplateSet.Render("index_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
})
}
我们向这个处理函数提供env
对象的访问方式(以粗体显示)的方式与我们在服务器端所做的方式完全相同。处理函数的处理逻辑被放入闭包中,并且我们已经关闭了env
变量。这使我们能够满足客户端处理函数应返回isokit.Handler
的要求,同时我们可以为处理函数提供对env
对象的访问。
我们在这里使用的依赖注入技术是受 Alex Edwards 在组织数据库访问方面的博客文章的启发:www.alexedwards.net/blog/organising-database-access
。
总结
在本章中,我们向您介绍了安装同构 Go 工具链的过程。我们向您介绍了 IGWEB 项目,这是一个同构 Web 应用程序,我们将在本书中实现。我们还检查了 IGWEB 代码库的项目结构和代码组织。
我们向您展示了如何设置数据存储并将样本数据集加载到 Redis 实例中。我们演示了如何使用kick
来执行即时启动,以加快 Web 应用程序开发周期。我们还为 IGWEB 项目的功能和功能实现提供了路线图,并包括它们将被覆盖的各自章节。最后,我们演示了依赖注入技术,以在服务器端和客户端共享通用功能。
现在我们已经准备就绪,我们需要对在 Web 浏览器中使用 Go 有一个良好的理解。在第三章 使用 GopherJS 在前端使用 Go中,我们将更详细地探索 GopherJS,并学习如何使用 GopherJS 执行常见的 DOM 操作。
第三章:使用 GopherJS 在前端进行 Go 编程
自创建以来,JavaScript 一直是 Web 浏览器的事实标准编程语言。因此,它在前端 Web 开发领域长期占据主导地位。它一直是唯一具备操纵网页的文档对象模型(DOM)和访问现代 Web 浏览器中实现的各种应用程序编程接口(API)能力的工具。
由于这种独占性,JavaScript 一直是同构 Web 应用程序开发的唯一可行选项。随着 GopherJS 的推出,我们现在可以在 Web 浏览器中创建 Go 程序,这也使得使用 Go 开发同构 Web 应用程序成为可能。
GopherJS 允许我们使用 Go 编写程序,这些程序会转换为等效的 JavaScript 表示形式,适合在任何支持 JavaScript 的 Web 浏览器中运行。特别是在服务器端使用 Go 时,GopherJS 为我们提供了一种可行且有吸引力的替代方案,尤其是如果我们在前端和后端都使用 Go。有了 Go 覆盖前后端的情况,我们有了新的机会来共享代码,并消除在不同环境中使用不同编程语言时产生的心理上下文转换。
在本章中,我们将涵盖以下主题:
-
文档对象模型
-
基本的 DOM 操作
-
GopherJS 概述
-
GopherJS 示例
-
内联模板渲染
-
本地存储
文档对象模型
在我们深入研究 GopherJS 之前,重要的是让我们了解 JavaScript 以及扩展—GopherJS 为我们做了什么。JavaScript 具有的主要功能之一是其能够访问和操作DOM(文档对象模型的缩写)。DOM 是表示 Web 页面结构及其中存在的所有节点(元素)的树形数据结构。
DOM 的重要性在于它充当 HTML 文档的编程接口,具有访问 Web 页面样式、结构和内容的能力。由于 DOM 树中的每个节点都是一个对象,因此 DOM 可以被视为给定 Web 页面的面向对象表示。因此,可以使用 JavaScript 访问和更改对象及其属性。
图 3.1描述了给定 Web 页面的 DOM 层次结构。Web 页面上的所有元素都是html节点的子节点,由 Web 页面的 HTML 源代码中的<html>
标签表示:
图 3.1:Web 页面的 DOM 层次结构
head节点是html节点的子节点,包含两个子节点—meta(在 HTML 中使用<meta>
标签定义)和一个脚本节点(用于外部 CSS 或 JavaScript 源文件)。与 head 节点处于同一级别的是 body 节点,使用<body>
标签定义。
body 节点包含要在 Web 页面上呈现的所有元素。在 body 节点下面,我们有一个子节点,即标题节点(使用<h1>
标签定义),即 Web 页面的标题。此节点没有子元素。
在标题节点的同一级别,我们还有一个 div 节点(使用<div>
标签定义)。此节点包含一个 div 子节点,其有两个子节点—一个段落节点(使用<p>
标签定义),在此节点的同一级别存在一个图像节点(使用<img>
标签定义)。
图像节点没有子元素,段落节点有一个子元素—一个 span 节点(使用<span>
标签定义)。
Web 浏览器中包含的 JavaScript 运行时为我们提供了访问 DOM 树中各个节点及其相应值的功能。使用 JavaScript 运行时,我们可以访问单个节点,如果给定节点包含子节点,我们还可以访问所有父节点的子节点集合。
由于网页被表示为一组对象,使用 DOM,我们可以访问任何给定 DOM 对象的事件、方法和属性。事实上,document
对象代表了网页文档本身。
这是 MDN 网站上关于 DOM 的有用介绍:
developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Introduction
。
访问和操作 DOM
如前所述,我们可以使用 JavaScript 来访问和操作给定网页的 DOM。由于 GopherJS 转译为 JavaScript,我们现在有能力在 Go 的范围内访问和操作 DOM。图 3.2描述了一个 JavaScript 程序访问/操作 DOM 以及一个 Go 程序也访问/操作 DOM:
图 3.2:DOM 可以被 JavaScript 程序和/或 Go 程序(使用 GopherJS)访问和操作
现在,让我们看一些简单的编程片段,我们可以使用 Go 访问 JavaScript 功能,然后使用 JavaScript 进行一些基本的 DOM 操作,以及它们在 GopherJS 中的等效指令。暂时让我们预览一下使用 GopherJS 编码的样子。这些概念将在本章后面作为完整的例子进行进一步详细解释。
基本的 DOM 操作
在本节中,我们将看一些基本的 DOM 操作集合。每个呈现的 DOM 操作都包括在 JavaScript、GopherJS 和使用 DOM 绑定中执行的等效操作。
显示警报消息
JavaScript
alert("Hello Isomorphic Go!");
GopherJS
js.Global.Call("alert", "Hello Isomorphic Go!")
DOM 绑定
dom.GetWindow().Alert("Hello Isomorphic Go!")
我们可以执行的最基本的操作之一是在模态对话框中显示alert
消息。在 JavaScript 中,我们可以使用内置的alert
函数显示alert
消息:
alert("Hello Isomorphic Go!");
这行代码将在模态窗口对话框中打印出消息Hello Isomorphic Go!
。alert
函数会阻止进一步执行,直到用户关闭alert
对话框。
当我们调用alert
方法时,实际上是这样调用的:
window.alert("Hello Isomorphic Go!");
window
对象是一个全局对象,代表浏览器中打开的窗口。JavaScript 实现允许我们直接调用alert
函数以及其他内置函数,而不需要将它们显式地引用为窗口对象的方法,这是一种方便的方式。
我们使用js
包通过 GopherJS 访问 JavaScript 功能。我们可以将包导入到我们的 Go 程序中,如下所示:
import "github.com/gopherjs/gopherjs/js"
js
包为我们提供了与原生 JavaScript API 交互的功能。对js
包中的函数的调用直接转换为它们等效的 JavaScript 语法。
我们可以使用 GopherJS 在 Go 中以以下方式显示alert
消息对话框:
js.Global.Call("alert", "Hello Isomorphic Go!")
在前面的代码片段中,我们使用了js.Global
对象可用的Call
方法。js.Global
对象为我们提供了 JavaScript 的全局对象(window
对象)。
这是Call
方法的签名:
func (o *Object) Call(name string, args ...interface{}) *Object
Call
方法将调用全局对象的方法,并提供名称。提供给方法的第一个参数是要调用的方法的名称。第二个参数是要传递给全局对象方法的参数列表。Call
方法被称为可变函数,因为它可以接受interface{}
类型的可变数量的参数。
您可以通过查看 GopherJS 文档了解更多关于Call
方法的信息godoc.org/github.com/gopherjs/gopherjs/js#Object.Call
。
现在我们已经看到了如何使用js.Global
对象的Call
方法来显示alert
对话框窗口,让我们来看看 DOM 绑定。
dom
包为我们提供了方便的 GopherJS 绑定到 JavaScript DOM API。使用这个包的想法是,与使用js.Global
对象执行所有操作相比,DOM 绑定为我们提供了一种惯用的方式来调用常见的 DOM API 功能。
如果您已经熟悉用于访问和操作 DOM 的 JavaScript API,那么使用dom
包将对您来说感觉自然。我们可以使用GetWindow
函数访问全局窗口对象,就像这样:
dom.GetWindow()
使用dom
包,我们可以使用以下代码显示警报对话框消息:
dom.GetWindow().Alert("Hello Isomorphic Go!")
对这段代码片段的粗略观察表明,这更接近于调用alert
对话框的 JavaScript 方式:
window.alert("Hello Isomorphic Go!")
由于这种相似性,熟悉 JavaScript DOM API 是一个好主意,因为它将使您能够熟悉等效的函数调用,使用dom
包。
您可以通过查看包的文档来了解更多关于dom
包的信息godoc.org/honnef.co/go/js/dom
。
通过 ID 获取 DOM 元素
我们可以使用document
对象的getElementById
方法来访问给定id
的元素。在这些例子中,我们访问了具有id
为"primaryContent"
的主要内容div
容器。
JavaScript
element = document.getElementById("primaryContent");
GopherJS
element := js.Global.Get("document").Call("getElementById", "primaryContent")
DOM 绑定
element := dom.GetWindow().Document().GetElementByID("primaryContent")
尽管dom
包的方法调用与 JavaScript 的方法调用非常相似,但可能会出现细微的差异。
例如,注意在 JavaScript 中使用document
对象的getElementById
方法调用时的大写,以及使用 DOM 绑定时使用GetElementByID
方法调用时的大写。
为了在 Go 中导出GetElementByID
方法,我们必须大写第一个字母,这里是G。此外,注意在使用 JavaScript 方式时,Id的大小写的微妙差异,与使用 DOM 绑定时ID的大小写的微妙差异。
查询选择器
document
对象的querySelector
方法为我们提供了一种使用 CSS 查询选择器访问 DOM 元素的方法,类似于 jQuery 库。我们可以使用文档对象的querySelector
方法访问包含欢迎消息的h2
元素,在 IGWEB 主页上。
JavaScript
element = document.querySelector(".welcomeHeading");
GopherJS
element := js.Global.Get("document").Call("querySelector", ".welcomeHeading")
DOM 绑定
element := dom.GetWindow().Document().QuerySelector(".welcomeHeading")
更改元素的 CSS 样式属性
在我们之前涵盖的代码片段中,我们只考虑了访问 DOM 元素的例子。现在,让我们考虑一个例子,我们将改变一个元素的 CSS 样式属性。我们将通过改变div
元素的display
属性来隐藏主要内容div
容器中的内容。
我们可以通过给js.Global
和dom
包的调用起别名来节省一些输入,就像这样:
对于 GopherJS:
JS := js.Global
对于dom
包:
D := dom.GetWindow().Document()
为了改变主要内容 div 容器的显示属性,我们首先需要访问div
元素,然后将其display
属性更改为none
值。
JavaScript
element = document.GetElementById("primaryContent");
element.style.display = "none"
GopherJS
js := js.Global
element := js.Get("document").Call("getElementById"), "primaryContent")
element.Get("style").Set("display", "none")
DOM 绑定
d := dom.GetWindow().Document()
element := d.GetElementByID("welcomeMessage")
element.Style().SetProperty("display", "none", "")
您可以通过使用 GopherJS Playground 来体验使用 GopherJS,网址为gopherjs.github.io/playground/
。
GopherJS 概述
现在我们已经预览了使用 GopherJS,让我们来考虑一下 GopherJS 的工作原理的高级概述。图 3.3描述了一个同构的 Go 应用程序,其中包括一个使用 GopherJS 的 Go 前端 Web 应用程序和一个 Go 后端 Web 应用程序:
图 3.3:同构的 Go Web 应用程序包括一个使用 GopherJS 的 Go 前端 Web 应用程序和一个 Go 后端 Web 应用程序
在图 3.3 中,我们将通信方式描述为 HTTP 事务,但重要的是要注意,这不是客户端和 Web 服务器进行通信的唯一方式。我们还可以使用 Web 浏览器的 WebSocket API 建立持久连接,这将在第八章中介绍,即实时 Web 应用程序功能。
在前一节中,我们介绍了 GopherJS DOM 绑定的微例子,它们为我们提供了对 DOM API 的访问,这是在 Web 浏览器中实现的 JavaScript API。除了 DOM API 之外,还有其他 API,如 XHR(用于创建和发送 XMLHttpRequests)API 和 WebSocket API(用于与 Web 服务器创建双向持久连接)。XHR 和 WebSocket API 也有 GopherJS 绑定可用。
图 3.4 显示了左侧的常见 JavaScript API,右侧是它们对应的 GopherJS 绑定。有了 GopherJS 绑定,我们可以从 Go 编程语言中访问 JavaScript API 功能:
图 3.4:常见的 JavaScript API 及其等效的 GopherJS 绑定
GopherJS 转译器
我们使用 GopherJS 转译器将 Go 程序转换为 JavaScript 程序。图 3.5 描述了一个 Go 程序,不仅使用了 Go 标准库的功能,还使用了各种 JavaScript API 的功能,使用了等效的 GopherJS 绑定包:
图 3.5:使用标准库和 GopherJS 绑定转译为等效 JavaScript 程序的 Go 程序
我们使用gopherjs build
命令将 Go 程序转译为其等效的 JavaScript 表示。生成的 JavaScript 源代码不是供人类修改的。JavaScript 程序可以访问嵌入在 Web 浏览器中的 JavaScript 运行时,以及常见的 JavaScript API。
要了解类型是如何从 Go 转换为 JavaScript 的,请查看godoc.org/github.com/gopherjs/gopherjs/js
上的表格。
关于 IGWEB,我们将前端 Go Web 应用程序项目代码组织在client
文件夹中。这使我们可以将前端 Web 应用程序与后端 Web 应用程序清晰地分开。
图 3.6 显示了包含许多 Go 源文件的客户端项目文件夹:
图 3.6:客户端文件夹包含组成前端 Go Web 应用程序的 Go 源文件。GopherJS 转译器生成一个 JavaScript 程序(client.js)和一个源映射(client.js.map)
在client
文件夹中运行 GopherJS 转译器对 Go 源文件进行处理时,通过发出gopherjs build
命令,将创建两个输出文件。第一个输出文件是client.js
文件,代表等效的 JavaScript 程序。第二个输出文件是client.js.map
文件,这是用于调试目的的源映射。这个源映射在我们使用 Web 浏览器的控制台追踪错误时,通过提供详细的错误信息来帮助我们。
附录:调试同构 Go 包含了有关调试使用 Go 实现的同构 Web 应用程序的指导和建议。
gopherjs build
命令在行为上与其go build
对应命令相同。客户端项目文件夹可以包含任意数量的子文件夹,这些子文件夹也可能包含 Go 源文件。当我们执行gopherjs build
命令时,将创建一个 JavaScript 源程序和一个源map
文件。这类似于在发出go build
命令时创建的单个静态二进制文件。
在client
文件夹之外,服务器和客户端之间共享的代码可以通过在import
语句中指定共享包的正确路径来共享。shared
文件夹将包含要在各个环境中共享的代码,例如模型和模板。
我们可以使用<script>
标签将 GopherJS 生成的 JavaScript 源文件作为外部javascript
源文件包含在我们的 Web 页面中,如下所示:
<script type="text/javascript" src="img/client.js"></script>
请记住,当我们发出gopherjs build
命令时,我们不仅创建了我们正在编写的程序的 JavaScript 等效程序,还带来了我们的程序依赖的标准库或第三方包。因此,除了包含我们的前端 Go 程序外,GopherJS 还包括我们的程序依赖的任何依赖包。
并非所有来自 Go 标准库的包都可以在 Web 浏览器中使用。您可以参考 GopherJS 兼容性表,查看 Go 标准库中受支持的包的列表,网址为github.com/gopherjs/gopherjs/blob/master/doc/packages.md
。
这一事实的后果是,生成的 JavaScript 源代码文件大小将与我们在 Go 程序中引入的依赖关系数量成比例增长。这一事实的另一个后果是,如图 3.7所示,在同一个 Web 页面中包含多个 GopherJS 生成的 JavaScript 文件是没有意义的,因为依赖包(例如标准库中的常见包)将被多次包含,不必要地增加我们的总脚本负载,并且没有任何回报价值:
图 3.7:不要在单个 Web 页面中导入多个 GopherJS 生成的源文件
因此,一个 Web 页面最多应包含一个 GopherJS 生成的源文件,如图 3.8所示:
图 3.8:Web 页面中应包含一个 GopherJS 生成的源文件
GopherJS 示例
在本章的前面,我们预览了使用 GopherJS 编码的样子。现在我们将看一些完全充实的示例,以巩固我们对一些基本概念的理解。
如前所述,前端 Web 应用程序的源代码可以在client
文件夹中找到。
如果要手动转换客户端目录中的 Go 代码,可以在client
文件夹中发出gopherjs build
命令:
$ gopherjs build
如前所述,将生成两个源文件——client.js
JavaScript 源文件和client.js.map
源映射文件。
要手动运行 Web 服务器,可以进入igweb
文件夹并运行以下命令:
$ go run igweb.go
更方便的替代方法是使用kick
编译 Go 代码和 GopherJS 代码,命令如下:
$ kick --appPath=$IGWEB_APP_ROOT --gopherjsAppPath=$IGWEB_APP_ROOT/client --mainSourceFile=igweb.go
使用kick
的优势在于它将自动监视对 Go 后端 Web 应用程序或 GopherJS 前端 Web 应用程序所做的更改。如前一章所述,当检测到更改时,kick
将执行instant kickstart,这将加快您的迭代开发周期。
一旦您运行了igweb
程序,可以在以下网址访问 GopherJS 示例: http://localhost:8080/front-end-examples-demo
前端示例演示将包含一些基本的 GopherJS 示例。让我们打开igweb
文件夹中的igweb.go
源文件,看看一切是如何工作的。
在registerRoutes
函数中,我们注册以下路由:
r.Handle("/front-end-examples-demo", handlers.FrontEndExamplesHandler(env)).Methods("GET")
r.Handle("/lowercase-text", handlers.LowercaseTextTransformHandler(env)).Methods("POST")
/front-end-examples-demo
路由用于显示我们的前端示例网页。/lowercase-text
路由用于将文本转换为小写。我们将在稍后更详细地介绍第二个路由;首先,让我们看一下处理/front-end-examples-demo
路由的处理程序函数(位于handlers/frontendexamples.go
源文件中):
package handlers
import (
"net/http"
"github.com/EngineerKamesh/igb/igweb/common"
"github.com/isomorphicgo/isokit"
)
func FrontEndExamplesHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
env.TemplateSet.Render("frontend_examples_page", &isokit.RenderParams{Writer: w, Data: nil})
})
}
在这里,我们已经定义了我们的处理程序函数FrontEndExamplesHandler
,它接受一个env
对象的指针作为输入参数,并返回一个http.Handler
函数。我们已经定义了一个闭包来返回http.HandlerFunc
,它接受http.ResponseWriter
和*http.Request
作为输入参数。
我们在TemplateSet
对象上调用Render
方法来渲染前端示例页面。方法的第一个输入参数是模板的名称,即frontend_examples_page
。第二个输入参数是要使用的渲染参数。由于我们是从服务器端渲染模板,我们传递w
,即http.ResponseWriter
,负责写出网页响应(渲染的模板)。由于我们没有向模板传递任何数据,我们将RenderParams
结构体的Data
字段赋值为nil
。
在第四章中,同构模板,我们将解释模板集是如何工作的,以及我们如何使用isokit
包提供的同构模板渲染器在服务器端和客户端渲染模板。
在client.go
源文件中的initializePage
函数的部分源代码列表中,我们包含了以下代码行来初始化 GopherJS 代码示例(以粗体显示):
func initializePage(env *common.Env) {
l := strings.Split(env.Window.Location().Pathname, "/")
routeName := l[1]
if routeName == "" {
routeName = "index"
}
if strings.Contains(routeName, "-demo") == false {
handlers.InitializePageLayoutControls(env)
}
switch routeName {
case "front-end-examples-demo":
gopherjsprimer.InitializePage()
gopherjsprimer.InitializePage
函数负责向前端示例网页中的元素添加事件侦听器。在注册任何事件之前,我们首先检查页面是否已经访问了/front-end-examples
路由。如果用户正在访问不同路由的页面,例如/index
,则无需为前端示例页面设置事件处理程序。如果用户已经访问了/front-end-examples
路由,那么控制流将到达指定值为"front-end-examples-demo"
的case
语句,并且我们将通过调用gopherjsprimer.InitializePage
函数为网页上的 UI 元素设置所有事件处理程序。
让我们仔细看看client/gopherjsprimer/initpage.go
源文件中的InitializePage
函数:
func InitializePage() {
d := dom.GetWindow().Document()
messageInput := d.GetElementByID("messageInput").(*dom.HTMLInputElement)
alertButtonJS := d.GetElementByID("alertMessageJSGlobal").(*dom.HTMLButtonElement)
alertButtonJS.AddEventListener("click", false, func(event dom.Event) {
DisplayAlertMessageJSGlobal(messageInput.Value)
})
alertButtonDOM := d.GetElementByID("alertMessageDOM").(*dom.HTMLButtonElement)
alertButtonDOM.AddEventListener("click", false, func(event dom.Event) {
DisplayAlertMessageDOM(messageInput.Value)
})
showGopherButton := d.GetElementByID("showGopher").(*dom.HTMLButtonElement)
showGopherButton.AddEventListener("click", false, func(event dom.Event) {
ShowIsomorphicGopher()
})
hideGopherButton := d.GetElementByID("hideGopher").(*dom.HTMLButtonElement)
hideGopherButton.AddEventListener("click", false, func(event dom.Event) {
HideIsomorphicGopher()
})
builtinDemoButton := d.GetElementByID("builtinDemoButton").(*dom.HTMLButtonElement)
builtinDemoButton.AddEventListener("click", false, func(event dom.Event) {
builtinDemo(event.Target())
})
lowercaseTransformButton := d.GetElementByID("lowercaseTransformButton").(*dom.HTMLButtonElement)
lowercaseTransformButton.AddEventListener("click", false, func(event dom.Event) {
go lowercaseTextTransformer()
})
}
InitializePage
函数负责使用元素的AddEventListener
方法(以粗体显示)向前端示例网页中的元素添加事件侦听器。
显示警报消息
让我们从一个例子开始,显示一个警报对话框。在本章的前面,我们看到了如何使用js.Global
对象的Call
方法和 GopherJS DOM 绑定来显示警报对话框。图 3.9描述了我们第一个例子的用户界面:
图 3.9:显示警报消息示例
用户界面包括一个输入文本字段,用户可以在其中输入要显示在警报对话框中的自定义消息。文本字段后面是两个按钮:
-
第一个按钮将使用
js.Global
对象上的Call
方法显示警报对话框 -
第二个按钮将使用 GopherJS DOM 绑定显示警报对话框
前端示例的 HTML 标记可以在位于shared/templates/frontend_examples_page.tmpl
的模板文件中找到。
以下是警报消息示例的 HTML 标记:
<div class="example">
<form class="pure-form">
<fieldset class="pure-group">
<h2>Example: Display Alert Message</h2>
</fieldset>
<fieldset class="pure-control-group">
<label for="messageInput">Alert Message: </label>
<input id="messageInput" type="text" value="Hello Gopher!" />
</fieldset>
<fieldset class="pure-group">
<button id="alertMessageJSGlobal" type="button" class="pure-button pure-button-primary">Display Alert Message using js.Global</button>
<button id="alertMessageDOM" type="button" class="pure-button pure-button-primary">Display Alert Message using dom package</button>
</fieldset>
</form>
</div>
在这里,我们声明了两个按钮(用粗体显示)并为它们分配了唯一的 id。使用js.Global.Call
功能显示警报对话框的按钮具有alertMessageJSGlobal
的 id。使用 GopherJS DOM 绑定显示警报对话框的按钮具有alertMessageDOM
的 id。
在initpage.go
源文件中定义的InitializePage
函数中的以下代码片段负责为在示例中显示的Display Alert Message
按钮设置事件处理程序:
alertButtonJS := d.GetElementByID("alertMessageJSGlobal").(*dom.HTMLButtonElement)
alertButtonJS.AddEventListener("click", false, func(event dom.Event) {
DisplayAlertMessageJSGlobal(messageInput.Value)
})
alertButtonDOM := d.GetElementByID("alertMessageDOM").(*dom.HTMLButtonElement)
alertButtonDOM.AddEventListener("click", false, func(event dom.Event) {
DisplayAlertMessageDOM(messageInput.Value)
})
我们通过在document
对象上调用GetElementByID
函数来获取第一个按钮,将按钮的id
作为函数的输入参数传递。然后,我们调用按钮上的AddEventListener
方法来创建一个新的事件监听器,该监听器将监听点击事件。当第一个按钮被点击时,我们调用DisplayAlertMessagesJSGlobal
函数,并传递messageInput
文本字段的值,其中包含用户可以输入的自定义警报消息。
我们以类似的方式为第二个按钮设置了事件监听器,只是当检测到按钮上的点击事件时,我们调用DisplayAlertMessageDOM
函数,该函数调用使用 GopherJS DOM 绑定显示警报对话框的函数。同样,我们将messageInput
文本字段的值传递给函数。
现在,如果你点击任何一个按钮,你应该能够看到警报对话框。将警报消息更改为不同的内容,并注意你对警报消息文本字段所做的更改将反映在警报对话框中。图 3.10描述了具有自定义消息 Hello Isomorphic Gopher!的警报对话框:
图 3.10:显示具有自定义警报消息的示例
更改元素的 CSS 样式属性
现在我们将看一个例子,其中我们实际上通过改变元素的 CSS 样式属性来操作 DOM。这个例子的用户界面由等距地图鼹鼠的图像组成,正下方是两个按钮,如图 3.11所示。第一个按钮被点击时,如果它被隐藏,将显示等距地图鼹鼠图像。第二个按钮被点击时,如果它被显示,将隐藏等距地图鼹鼠图像。图 3.11显示了等距地图鼹鼠可见时的情况:
图 3.11:当等距地图鼹鼠图像可见时的用户界面
图 3.12描述了当等距地图鼹鼠图像不可见时的用户界面:
图 3.12:当等距地图鼹鼠图像不可见时的用户界面
以下是为此示例生成用户界面的 HTML 标记:
<div class="example">
<form class="pure-form">
<fieldset class="pure-group">
<h2>Example: Change An Element's CSS Style Property</h2>
</fieldset>
<fieldset class="pure-group">
<div id="igRacer">
<img id="isomorphicGopher" border="0" src="img/isomorphic_go_logo.png">
</div>
</fieldset>
<fieldset class="pure-group">
<button id="showGopher" type="button" class="pure-button pure-button-primary">Show Isomorphic Gopher</button>
<button id="hideGopher" type="button" class="pure-button pure-button-primary">Hide Isomorphic Gopher</button>
</fieldset>
</form>
</div>
在这里,我们声明了代表等距地图鼹鼠图像的图像标签,并为其分配了isomorphicGopher
的 id。我们声明了两个按钮(用粗体显示):
-
第一个按钮,具有
showGopher
的 id,将在点击时显示等距地图鼹鼠图像 -
第二个按钮,具有
hideGopher
的 id,将在点击时隐藏等距地图鼹鼠图像
InitializePage
函数中的以下代码片段负责为显示和隐藏等距地图鼹鼠图像的两个按钮设置事件处理程序:
showGopherButton := d.GetElementByID("showGopher").(*dom.HTMLButtonElement)
showGopherButton.AddEventListener("click", false, func(event dom.Event) {
ShowIsomorphicGopher()
})
hideGopherButton := d.GetElementByID("hideGopher").(*dom.HTMLButtonElement)
hideGopherButton.AddEventListener("click", false, func(event dom.Event) {
HideIsomorphicGopher()
})
如果点击显示等距地图鼹鼠按钮,我们调用ShowIsomorphicGopher
函数。如果点击隐藏等距地图鼹鼠按钮,我们调用HideIsomorphicGopher
函数。
让我们来看一下client/gopherjsprimer/cssexample.go
源文件中定义的ShowIsomorphicGopher
和HideIsomorphicGopher
函数:
package gopherjsprimer
import "honnef.co/go/js/dom"
func toggleIsomorphicGopher(isVisible bool) {
d := dom.GetWindow().Document()
isomorphicGopherImage := d.GetElementByID("isomorphicGopher").(*dom.HTMLImageElement)
if isVisible == true {
isomorphicGopherImage.Style().SetProperty("display", "inline", "")
} else {
isomorphicGopherImage.Style().SetProperty("display", "none", "")
}
}
func ShowIsomorphicGopher() {
toggleIsomorphicGopher(true)
}
func HideIsomorphicGopher() {
toggleIsomorphicGopher(false)
}
ShowIsomorphicGopher
和HideIsomorphicGopher
函数都调用toggleIsomorphicGopher
函数。唯一的区别是,ShowIsomorphicGopher
函数调用toggleIsomorphicGopher
函数并传入true
的输入参数,而HideIsomorphicGopher
函数调用toggleIsomorphicGopher
函数并传入false
的输入参数。
toggleIsomorphicGopher
函数接受一个布尔变量作为参数,指示是否应显示IsomorphicGopher
图像。
如果我们向函数传递true
的值,那么等距地图像将被显示,如图 3.11所示。如果我们向函数传递false
的值,那么等距地图像将不会被显示,如图 3.12所示。我们将Document
对象的值赋给d
变量。我们调用Document
对象的GetElementByID
方法来获取等距地图像。请注意,我们已经执行了类型断言(粗体显示),以断言d.GetElementByID("isomorphicGopher")
返回的值具有*dom.HTMLImageElement
的具体类型。
我们声明了一个if
条件块,检查isVisible
布尔变量的值是否为true
,如果是,我们将图像元素的Style
对象的display
属性设置为inline
。这将导致等距地图像出现,如图 3.11所示。
如果isVisible
布尔变量的值为false
,我们进入else
块,并将图像元素的Style
对象的display
属性设置为none
,这将防止等距地图像显示,如图 3.12所示。
JavaScript typeof 运算符功能
JavaScript 的typeof
运算符用于返回给定操作数的类型。例如,让我们考虑以下 JavaScript 代码:
typeof 108 === "number"
这个表达式将求值为布尔值true
。同样,现在考虑这段 JavaScript 代码:
typeof "JavaScript" === "string"
这个表达式也将求值为布尔值true
。
所以你可能会想,我们如何使用 Go 来使用 JavaScript 的typeof
运算符?答案是,我们将需要jsbuiltin
包,GopherJS 对内置 JavaScript 功能的绑定,其中包括typeof
运算符。
在这个例子中,我们将使用jsbuiltin
包使用 JavaScript 的typeof
运算符。图 3.13展示了这个例子的用户界面:
图 3.13:JavaScript typeof 示例的用户界面
以下是实现此示例用户界面的 HTML 标记:
<div class="example">
<h2>Example: JavaScript Builtin Functionality for typeof operation</h2>
<p>Note: The message should appear in the web console after clicking the button below.</p>
<button id="builtinDemoButton" type="button" class="pure-button pure-button-primary">Builtin Demo</button>
</div>
我们声明了一个id
为bultinDemoButton
的按钮。现在,让我们在InitializePage
函数中为内置演示按钮设置一个事件侦听器,以处理点击事件:
builtinDemoButton := d.GetElementByID("builtinDemoButton").(*dom.HTMLButtonElement)
builtinDemoButton.AddEventListener("click", false, func(event dom.Event) {
builtinDemo(event.Target())
})
我们通过在Document
对象d
上调用GetElementID
方法来获取button
元素。我们将返回的button
元素赋给builtinDemoButton
变量。然后我们向button
元素添加事件侦听器以检测其是否被点击。如果检测到点击事件,我们调用builtinDemo
函数并传入button
元素的值,这恰好是事件目标。
让我们检查client/gopherjsprimer
文件夹中的builtindemo.go
源文件:
package gopherjsprimer
import (
"github.com/gopherjs/jsbuiltin"
"honnef.co/go/js/dom"
)
func builtinDemo(element dom.Element) {
if jsbuiltin.TypeOf(element) == "object" {
println("Using the typeof operator, we can see that the element that was clicked, is an object.")
}
}
bulitindemo
函数接受dom.Element
类型的输入参数。在这个函数内部,我们通过调用jsbuiltin
包的TypeOf
函数(粗体显示)对传入函数的元素执行 JavaScript 的typeof
操作。我们检查传入的元素是否是对象。如果是对象,我们会在 Web 控制台上打印出一条消息,确认传入函数的元素是一个对象。图 3.14展示了在 Web 控制台上打印的消息:
图 3.14:在内置演示按钮被点击后在 Web 控制台上打印的消息
从表面上看,这是一个相当琐碎的例子。然而,它突出了一个非常重要的概念——在 Go 的范围内,我们仍然可以访问内置的 JavaScript 功能。
使用 XHR post 将文本转换为小写
现在我们将创建一个简单的小写文本转换器。用户输入的任何文本都将转换为小写。我们的小写文本转换器解决方案的用户界面如图 3.15所示。在图像中,输入文本为 GopherJS。当用户点击 Lowercase It!按钮时,文本字段中的文本将被转换为其小写等价物,即 gopherjs:
图 3.15:小写文本转换器示例
实际上,我们可以在客户端上应用文本转换;然而,看到一个示例,我们将输入文本以XHR Post
的形式发送到 Web 服务器,然后在服务器端执行小写转换会更有趣。一旦服务器完成将文本转换为小写,输入将被发送回客户端,并且文本字段将使用输入文本的小写版本进行更新。
这是用户界面的 HTML 标记:
<div class="example">
<form class="pure-form">
<fieldset class="pure-group">
<h2>Example: XHR Post</h2>
</fieldset>
<fieldset class="pure-control-group">
<label for="textToLowercase">Enter Text to Lowercase: </label>
<input id="textToLowercase" type="text" placeholder="Enter some text here to lowercase." value="GopherJS" />
</fieldset>
<fieldset class="pure-group">
<button id="lowercaseTransformButton" type="button" class="pure-button pure-button-primary">Lowercase It!</button>
</fieldset>
</form>
</div>
我们声明一个input
文本字段,用户可以在其中输入他们想要转换为小写的文本。我们为input
文本字段分配了一个id
为textToLowercase
。然后我们声明一个带有id
为lowercaseTransformButton
的按钮。当点击此按钮时,我们将启动一个XHR Post
到服务器。服务器将转换文本为小写并发送回输入文本的小写版本。
这是InitializePage
函数中的代码,用于设置按钮的事件监听器:
lowercaseTransformButton := d.GetElementByID("lowercaseTransformButton").(*dom.HTMLButtonElement)
lowercaseTransformButton.AddEventListener("click", false, func(event dom.Event) {
go lowercaseTextTransformer()
})
我们将button
元素分配给lowercaseTransformButton
变量。然后我们调用button
元素上的AddEventListener
方法来检测点击事件。当检测到点击事件时,我们调用lowercaseTextTransformer
函数。
这是在client/gopherjsprimer/xhrpost.go
源文件中定义的lowercaseTextTransformer
函数:
func lowercaseTextTransformer() {
d := dom.GetWindow().Document()
textToLowercase := d.GetElementByID("textToLowercase").(*dom.HTMLInputElement)
textBytes, err := json.Marshal(textToLowercase.Value)
if err != nil {
println("Encountered error while attempting to marshal JSON: ", err)
println(err)
}
data, err := xhr.Send("POST", "/lowercase-text", textBytes)
if err != nil {
println("Encountered error while attempting to submit POST request via XHR: ", err)
println(err)
}
var s string
err = json.Unmarshal(data, &s)
if err != nil {
println("Encountered error while attempting to umarshal JSON data: ", err)
}
textToLowercase.Set("value", s)
}
我们首先通过获取文本输入元素并将其分配给textToLowercase
变量来开始。然后,我们使用json
包中的Marshal
函数将输入到文本输入元素中的文本值编组为其 JSON 表示形式。我们将编组的值分配给textBytes
变量。
我们使用 GopherJS XHR 绑定来发送XHR Post
到 Web 服务器。XHR 绑定是通过xhr
包提供给我们的。我们调用xhr
包中的Send
函数来提交XHR Post
。函数的第一个参数是我们将用于提交数据的 HTTP 方法。这里我们指定POST
作为 HTTP 方法。第二个输入参数是要将数据提交到的路径。这里我们指定了/lowercase-text
路由,这是我们在igweb.go
源文件中设置的。第三个也是最后一个参数是要通过XHR Post
发送的数据,即textBytes
——JSON 编组的数据。
来自XHR Post
的服务器响应将存储在data
变量中。我们调用json
包中的Unmarshal
函数来解组服务器的响应,并将解组的值分配给string
类型的s
变量。然后我们使用textToLowercase
对象的Set
方法将文本输入元素的值设置为s
变量的值。
现在,让我们来看看负责在handlers/lowercasetext.go
源文件中进行小写转换的服务器端处理程序:
package handlers
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"strings"
"github.com/EngineerKamesh/igb/igweb/common"
)
func LowercaseTextTransformHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var s string
reqBody, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Print("Encountered error when attempting to read the request body: ", err)
}
reqBodyString := string(reqBody)
err = json.Unmarshal([]byte(reqBodyString), &s)
if err != nil {
log.Print("Encountered error when attempting to unmarshal JSON: ", err)
}
textBytes, err := json.Marshal(strings.ToLower(s))
if err != nil {
log.Print("Encountered error when attempting ot marshal JSON: ", err)
}
fmt.Println("textBytes string: ", string(textBytes))
w.Write(textBytes)
})
}
在LowercaseTextTransformHandler
函数中,我们调用ioutil
包中的ReadAll
函数来读取请求体。我们将reqBody
的字符串值保存到reqBodyString
变量中。然后我们对这个字符串进行 JSON 解组,并将解组后的值存储到string
类型的s
变量中。
我们使用strings
包中的ToLower
函数将s
字符串变量的值转换为小写,并将该值编组成 JSON 表示。然后我们在http.ResponseWriter
的w
上调用Write
方法,将字符串的 JSON 编组值写出为小写。
当我们在用户界面中点击 Lowercase It!按钮时,字符串 GopherJS 会被转换为其小写表示 gopherjs,如图 3.16所示:
图 3.16:当按钮被点击时,文本“GopherJS”被转换为小写的“gopherjs”
内联模板渲染
在这一部分,您将学习如何使用 GopherJS 在 Go 中执行客户端模板渲染。我们可以直接在 Web 浏览器中使用html/template
包来渲染模板。我们将使用内联模板来渲染汽车表格的各行。
汽车列表演示
在汽车列表演示中,我们将使用内联客户端端 Go 模板填充一张表格的行。在我们的示例中,表格将是汽车列表,我们将从汽车切片中获取要显示在表格中的汽车。然后我们使用gob
编码对汽车切片进行编码,并通过 XHR 调用将数据传输到 Web 服务器实例。
客户端模板渲染有很多好处:
-
Web 服务器上的 CPU 使用率是由服务器端模板渲染引起的
-
不需要完整页面重新加载来渲染客户端模板
-
通过在客户端端渲染模板来减少带宽消耗
让我们在shared/templates/carsdemo_page.tmpl
目录中打开cars.html
源文件:
{{ define "pagecontent" }}
<table class="mdl-data-table mdl-js-data-table mdl-shadow--2dp">
<thead>
<tr>
<th class="mdl-data-table__cell--non-numeric">Model Name</th>
<th class="mdl-data-table__cell--non-numeric">Color</th>
<th class="mdl-data-table__cell--non-numeric">Manufacturer</th>
</tr>
</thead>
<tbody id="autoTableBody">
</tbody>
</table>
{{end}}
{{template "layouts/carsdemolayout" . }}
这个 HTML 源文件包含了我们示例的网页内容,一个汽车表格,我们将使用内联模板来渲染表格的每一行。
我们使用table
标签声明了将在网页上显示的表格。我们声明了每一列的标题。由于我们将显示一张汽车表格,每辆车有三列;我们有一个列用于车型名称,一个列用于颜色,一个列用于制造商。
我们将要添加到表格中的每一行都将被追加到tbody
元素中(以粗体显示)。
请注意,我们使用carsdemolayout.tmpl
布局模板来布局汽车演示页面。让我们打开位于shared/templates/layouts
目录中的这个文件:
<html>
{{ template "partials/carsdemoheader" }}
<body>
<div class="pageContent" id="primaryContent">
{{ template "pagecontent" . }}
</div>
<script src="img/client.js"></script>
</body>
</html>
布局模板不仅负责渲染pagecontent
模板,还负责渲染位于templates/shared/partials
目录中的头部模板carsdemoheader.tmpl
。布局模板还负责导入由 GopherJS 生成的client.js
外部 JavaScript 源文件。
让我们来看一下carsdemoheader.tmpl
源文件:
<head>
<link rel="icon" type="image/png" href="/static/images/isomorphic_go_icon.png">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://code.getmdl.io/1.3.0/material.indigo-pink.min.css">
<script defer src="img/material.min.js"></script>
</head>
在这个头部模板文件中,我们导入了 CSS 样式表和 Material Design Library 的 JavaScript 源文件。我们将使用 Material Design Library 来使用默认的材料设计样式使我们的表格看起来漂亮。
在client.go
源文件的initializePage
函数中,我们包含了以下代码行来初始化汽车演示代码示例,当着陆在汽车演示网页上时:
carsdemo.InitializePage()
在client/carsdemo
目录中的cars.go
源文件中,我们声明了用于渲染给定汽车信息的内联模板:
const CarItemTemplate = `
<td class="mdl-data-table__cell--non-numeric">{{.ModelName}}</td>
<td class="mdl-data-table__cell--non-numeric">{{.Color}}</td>
<td class="mdl-data-table__cell--non-numeric">{{.Manufacturer}}</td>
`
我们声明了CarItemTemplate
常量,这是一个多行字符串,包括我们的内联模板。在模板的第一行中,我们渲染包含型号名称的列。在模板的第二行中,我们渲染汽车的颜色。最后,在模板的第三行中,我们渲染汽车的制造商。
我们声明并初始化了D
变量,使用了Document
对象,如下所示:
var D = dom.GetWindow().Document()
InitializePage
函数(在client/carsdemo/cars.go
源文件中找到)负责调用cars
函数:
func InitializePage() {
cars()
}
在cars
函数内部,我们创建了nano
,ambassador
和omni
——Car
类型的三个实例。就在这之后,我们使用汽车对象来填充cars
切片:
nano := models.Car{ModelName: "Nano", Color: "Yellow", Manufacturer: "Tata"}
ambassador := models.Car{ModelName: "Ambassador", Color: "White", Manufacturer: "HM"}
omni := models.Car{ModelName: "Omni", Color: "Red", Manufacturer: "Maruti Suzuki"}
cars := []models.Car{nano, ambassador, omni}
现在我们有了一个cars
切片来填充表格,是时候用以下代码生成表格的每一行了:
autoTableBody := D.GetElementByID("autoTableBody")
for i := 0; i < len(cars); i++ {
trElement := D.CreateElement("tr")
tpl := template.New("template")
tpl.Parse(CarItemTemplate)
var buff bytes.Buffer
tpl.Execute(&buff, cars[i])
trElement.SetInnerHTML(buff.String())
autoTableBody.AppendChild(trElement)
}
在这里,我们声明并初始化了autoTableBody
变量,这是表格的tbody
元素。这是我们将用来向表格追加新行的元素。我们遍历cars
切片,对于每个Car
结构,我们使用Document
对象的CreateElement
方法动态创建一个tr
元素。然后我们创建一个新模板,并解析汽车项目模板的内容。
我们声明了一个名为buff
的缓冲变量,用于保存执行模板的结果。我们在模板对象tpl
上调用Execute
函数,传入buff
和cars
切片的i
索引处的当前Car
记录,这将是传递给内联模板的数据对象。
然后我们在tr
元素对象上调用SetInnerHTML
方法,并传入buff
变量的字符串值,其中包含我们渲染的模板内容。
这是所有行都填充的汽车表的样子:
图 3.17:汽车表
这个例子对于说明目的是有用的,但在实际情况下并不是很实用。在 Go 源文件中混合使用 HTML 编写的内联模板可能会变得难以维护,因为项目代码库规模扩大。除此之外,如果我们有一种方法可以在客户端访问服务器端所有用户界面的模板,那将是很好的。事实上,我们可以做到,这将是我们在第四章中的重点,同构模板。
现在我们已经看到了如何渲染内联模板,让我们考虑如何将cars
切片作为二进制数据以gob
格式编码传输到服务器。
传输 gob 编码数据
encoding/gob
包为我们提供了管理 gob 流的功能,这些流是在编码器和解码器之间交换的二进制值。您可以使用编码器将值编码为gob
编码数据,然后使用解码器解码gob
编码数据。
通过在服务器端和客户端上使用 Go,我们创建了一个 Go 特定的环境,如图 3.18所示。这是使用encoding/gob
包进行客户端和服务器之间数据交换的理想环境:
图 3.18:Go 特定的环境
我们将要传输的数据包括cars
切片。Car
结构可以被认为是同构的,因为我们可以在客户端和服务器端都使用Car
结构。
请注意,在cars.go
源文件中,我们已经包含了encoding/gob
包(以粗体显示)在我们的导入分组中:
import (
"bytes"
"encoding/gob"
"html/template"
"github.com/EngineerKamesh/igb/igweb/shared/models"
"honnef.co/go/js/dom"
"honnef.co/go/js/xhr"
)
我们使用以下代码将cars
切片编码为gob
格式:
var carsDataBuffer bytes.Buffer
enc := gob.NewEncoder(&carsDataBuffer)
enc.Encode(cars)
在这里,我们声明了一个名为carsDataBuffer
的字节缓冲区,它将包含gob
编码的数据。我们创建了一个新的gob
编码器,并指定我们要将编码后的数据存储到carsDataBuffer
中。然后我们调用了gob
编码器对象上的Encode
方法,并传入了cars
切片。到这一步,我们已经将cars
切片编码到了carsDataBuffer
中。
现在我们已经将cars
切片编码成gob
格式,我们可以使用HTTP POST
方法通过 XHR 调用将gob
编码的数据传输到服务器:
xhrResponse, err := xhr.Send("POST", "/cars-data", carsDataBuffer.Bytes())
if err != nil {
println(err)
}
println("xhrResponse: ", string(xhrResponse))
我们在xhr
包中调用Send
函数,并指定我们要使用POST
方法,并将数据发送到/cars-data
URL。我们调用carsDataBuffer
上的Bytes
方法,以获取缓冲区的字节切片表示。正是这个字节切片,我们将发送到服务器,并且它是gob
编码的car
切片。
服务器的响应将存储在xhrResponse
变量中,并且我们将在网络控制台中打印出这个变量。
现在我们已经看到了程序的客户端部分,是时候来看看服务端处理程序函数了,它服务于/cars-data
路由。让我们来看看carsdata.go
源文件中定义的CarsDataHandler
函数,它位于 handlers 目录中:
func CarsDataHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var cars []models.Car
var carsDataBuffer bytes.Buffer
dec := gob.NewDecoder(&carsDataBuffer)
body, err := ioutil.ReadAll(r.Body)
carsDataBuffer = *bytes.NewBuffer(body)
err = dec.Decode(&cars)
w.Header().Set("Content-Type", "text/plain")
if err != nil {
log.Println(err)
w.Write([]byte("Something went wrong, look into it"))
} else {
fmt.Printf("Cars Data: %#v\n", cars)
w.Write([]byte("Thanks, I got the slice of cars you sent me!"))
}
})
}
在CarsDataHandler
函数内部,我们声明了一个cars
变量,它是一个Car
对象的切片。在这之下,我们有carsDataBuffer
,它将包含从客户端网页应用程序发送的 XHR 调用中接收到的gob
编码数据。
我们创建了一个新的gob
解码器,并指定gob
数据将存储在carsDataBuffer
中。然后我们使用ioutil
包中的ReadAll
函数来读取请求体并将所有内容保存到body
变量中。
然后我们创建一个新的字节缓冲区,并将body
变量作为输入参数传递给NewBuffer
函数。carsDataBuffer
现在包含了通过 XHR 调用传输的gob
编码数据。最后,我们调用dec
对象的Decode
函数,将gob
编码的数据转换回Car
对象的切片。
如果我们没有收到任何错误,我们将cars
切片打印到标准输出:
Cars Data: []models.Car{models.Car{ModelName:"Nano", Color:"Yellow", Manufacturer:"Tata"}, models.Car{ModelName:"Ambassador", Color:"White", Manufacturer:"HM"}, models.Car{ModelName:"Omni", Color:"Red", Manufacturer:"Maruti Suzuki"}}
除了将cars
切片打印到标准输出之外,我们还向网络客户端发送了一个响应,指示cars
切片已成功接收。我们可以在网络浏览器控制台中查看这条消息:
图 3.19:服务器对网络客户端的响应
本地存储
你知道吗,网络浏览器自带了一个内置的键值数据库吗?这个数据库的名字叫本地存储,在 JavaScript 中,我们可以将localStorage
对象作为window
对象的属性来访问。本地存储允许我们在网络浏览器中本地存储数据。本地存储是按域和协议划分的,这意味着来自相同来源的页面可以访问和修改共享数据。
以下是本地存储的一些好处:
-
它提供安全的数据存储
-
它的存储限制比 cookie 大得多(至少 5MB)
-
它提供低延迟的数据访问
-
对于不需要联网的网络应用程序非常有帮助
-
它可以用作本地缓存
常见的本地存储操作
我们将向您展示如何使用 JavaScript 代码对localStorage
对象执行一些常见操作。这些操作包括以下内容:
-
设置键值对
-
获取给定键的值
-
获取所有键值对
-
清除所有条目
在下一节中,我们将向您展示如何使用 GopherJS 执行相同的操作,以一个完全充实的示例。
设置键值对
要将项目存储到本地存储中,我们调用localStorage
对象的setItem
方法,并将键和值作为参数传递给该方法:
localStorage.setItem("foo", "bar");
在这里,我们提供了一个"foo"
键,带有一个"bar"
值。
获取给定键的值
要从本地存储中获取项目,我们调用localStorage
对象的getItem
方法,并将键作为该方法的单个参数传递:
var x = localStorage.getItem("foo");
在这里,我们提供了"foo"
键,并且我们期望x
变量的值将等于"bar"
。
获取所有键值对
我们可以使用for
循环从本地存储中检索所有键值对,并使用localStorage
对象的key
和getItem
方法访问键和值的值:
for (var i = 0; i < localStorage.length; i++) {
console.log(localStorage.key(i)); // prints the key
console.log(localStorage.getItem(localStorage.key(i))); // prints the value
}
我们在localStorage
对象上使用key
方法,传入数字索引i
,以获取存储中的第 i 个键。类似地,我们将i
数字索引传递给localStorage
对象的key
方法,以获取存储中第 i 个位置的键的名称。请注意,键的名称是通过localStorage.key(i)
方法调用获得的,并传递给getItem
方法以检索给定键的值。
清除所有条目
我们可以通过在localStorage
对象上调用clear
方法轻松地删除本地存储中的所有条目:
localStorage.clear();
构建本地存储检查器
根据上一节中关于如何利用localStorage
对象的信息,让我们继续构建本地存储检查器。本地存储检查器将允许我们执行以下操作:
-
查看当前存储在本地存储中的所有键值对
-
向本地存储添加新的键值对
-
清除本地存储中的所有键值对
图 3.20描述了本地存储检查器的用户界面:
图 3.20:本地存储演示用户界面
直接位于 LocalStorage Demo 标题下方的框是一个div
容器,负责保存当前存储在本地存储中的键值对列表。键输入文本字段是用户输入键的地方。值输入文本字段是用户输入键值对的值的地方。单击保存按钮将新的键值对条目保存到本地存储中。单击清除所有按钮将清除本地存储中的所有键值对条目。
创建用户界面
我们在shared/templates/layouts
文件夹中找到的localstorage_layout.tmpl
源文件中定义了本地存储演示页面的布局:
<!doctype html>
<html>
{{ template "partials/localstorageheader_partial" }}
<body>
<div class="pageContent" id="primaryContent">
{{ template "pagecontent" . }}
</div>
<script type="text/javascript" src="img/client.js"></script>
</body>
</html>
此布局模板定义了本地存储演示网页的布局。我们使用模板操作(以粗体显示)来呈现partials/localstorageheader_partial
头部模板和pagecontent
页面内容模板。
请注意,在网页底部,我们包含了 JavaScript 源文件client.js
,这是由 GopherJS 生成的,使用script
标签(以粗体显示)。
我们在shared/templates/partials
文件夹中找到的localstorageheader_partial.tmpl
源文件中定义了本地存储演示页面的头部模板。
<head>
<title>LocalStorage Demo</title>
<link rel="icon" type="image/png" href="/static/images/isomorphic_go_icon.png">
<link rel="stylesheet" href="https://unpkg.com/purecss@1.0.0/build/pure-min.css" integrity="sha384-nn4HPE8lTHyVtfCBi5yW9d20FjT8BJwUXyWZT9InLYax14RDjBj46LmSztkmNP9w" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="/static/css/igweb.css">
<link rel="stylesheet" type="text/css" href="/static/css/localstoragedemo.css">
</head>
此标题模板旨在呈现head
标签,我们在其中使用link
标签(以粗体显示)包含外部 CSS 样式表。
我们在shared/templates
文件夹中找到的localstorage_example_page.tmpl
源文件中定义了本地存储演示的用户界面的 HTML 标记:
{{ define "pagecontent" }}
<h1>LocalStorage Demo</h1>
<div id="inputFormContainer">
<form class="pure-form">
<fieldset class="pure-group" style="min-height: 272px">
<div id="storageContents">
<dl id="itemList">
</dl>
</div>
</fieldset>
<fieldset class="pure-control-group">
<label for="messageInput">Key: </label>
<input id="itemKey" type="text" value="" />
<label for="messageInput">Value: </label>
<input id="itemValue" type="text" value="" />
</fieldset>
<fieldset class="pure-control-group">
</fieldset>
<fieldset class="pure-group">
<button id="saveButton" type="button" class="pure-button pure-button-primary">Save</button>
<button id="clearAllButton" type="button" class="pure-button pure-button-primary">Clear All</button>
</fieldset>
</form>
</div>
{{end}}
{{template "layouts/localstorage_layout" . }}
具有"storageContents"
id 的div
元素将用于存储本地存储数据库中的项目条目列表。实际上,我们将使用具有"itemList"
id 的 dl(描述列表)元素来显示所有键值对。
我们为用户定义了一个输入文本字段以输入键,并且我们还为用户定义了一个输入文本字段以输入值。我们还为Save
按钮定义了标记,并且直接在其下方,我们定义了Clear All
按钮的标记。
设置服务器端路由
我们在igweb.go
源文件中的registerRoutes
函数中注册了/localstorage-demo
路由:
r.Handle("/localstorage-demo", handlers.LocalStorageDemoHandler(env)).Methods("GET")
我们已经定义了LocalStorageDemoHandler
服务器端处理程序函数,用于服务于localstorage-demo
服务器端路由,在handlers
文件夹中找到的localstoragedemo.go
源文件中:
package handlers
import (
"net/http"
"github.com/EngineerKamesh/igb/igweb/common"
"github.com/isomorphicgo/isokit"
)
func LocalStorageDemoHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
env.TemplateSet.Render("localstorage_example_page", &isokit.RenderParams{Writer: w, Data: nil})
})
}
LocalStorageDemoHandler
函数负责向客户端写入网页响应。它调用应用程序的TemplateSet
对象的Render
方法,以渲染localstorage_example_page
模板。您将在第四章中了解更多关于渲染同构模板的内容,同构模板。
实现客户端功能
实现本地存储检查器的客户端功能包括以下步骤:
-
初始化本地存储检查器网页
-
实现本地存储检查器
初始化本地存储检查器网页
为了初始化本地存储检查器网页上的事件处理程序,我们需要在client.go
源文件中的initializePage
函数内的localstorage-demo
情况下添加以下代码行:
localstoragedemo.InitializePage()
调用localstoragedemo
包中定义的InitializePage
函数将为保存和清除所有按钮添加事件监听器。
实现本地存储检查器
本地存储检查器的实现可以在client/localstoragedemo
目录中的localstorage.go
源文件中找到。
在import
分组中,我们包括了js
和dom
包(以粗体显示):
package localstoragedemo
import (
"github.com/gopherjs/gopherjs/js"
"honnef.co/go/js/dom"
)
我们已经定义了localStorage
变量,并将其赋值为附加到window
对象的localStorage
对象的值:
var localStorage = js.Global.Get("localStorage")
像往常一样,我们使用D
变量将Document
对象进行了别名,以节省一些输入。
var D = dom.GetWindow().Document().(dom.HTMLDocument)
InitializePage
函数负责为保存和清除所有按钮设置事件监听器:
func InitializePage() {
saveButton := D.GetElementByID("saveButton").(*dom.HTMLButtonElement)
saveButton.AddEventListener("click", false, func(event dom.Event) {
Save()
})
clearAllButton := D.GetElementByID("clearAllButton").(*dom.HTMLButtonElement)
clearAllButton.AddEventListener("click", false, func(event dom.Event) {
ClearAll()
})
DisplayStorageContents()
}
我们通过调用Document
对象的GetElementByID
方法并将id``saveButton
作为该方法的唯一输入参数来获取saveButton
元素。紧接着,我们在点击事件上添加一个事件监听器来调用Save
函数。调用Save
函数将保存一个新的键值对条目。
我们还通过调用Document
对象的GetElementByID
方法并将id``clearAllButton
作为该方法的唯一输入参数来获取clearAllButton
元素。紧接着,我们在点击事件上添加一个事件监听器来调用ClearAll
函数。调用ClearAll
函数将清除本地存储中当前存储的所有键值对。
Save
函数负责将键值对保存到 Web 浏览器的本地存储中:
func Save() {
itemKey := D.GetElementByID("itemKey").(*dom.HTMLInputElement)
itemValue := D.GetElementByID("itemValue").(*dom.HTMLInputElement)
if itemKey.Value == "" {
return
}
SetKeyValuePair(itemKey.Value, itemValue.Value)
itemKey.Value = ""
itemValue.Value = ""
DisplayStorageContents()
}
我们使用Document
对象的GetElementByID
方法获取键和值的文本输入字段(以粗体显示)。在if
条件块中,我们检查用户是否未为键输入文本字段输入值。如果他们没有输入值,我们就从函数中返回。
如果用户已经在键输入文本字段中输入了值,我们将继续。我们调用SetKeyValuePair
函数,并将itemKey
和itemValue
的值作为输入参数传递给函数。
然后,我们将itemKey
和itemValue
的Value
属性都设置为空字符串,以清除输入文本字段,这样用户可以轻松地在以后添加新条目而无需手动清除这些字段中的文本。
最后,我们调用DisplayStorageContents
函数,该函数负责显示本地存储中的所有当前条目。
让我们来看看SetKeyValuePair
函数:
func SetKeyValuePair(itemKey string, itemValue string) {
localStorage.Call("setItem", itemKey, itemValue)
}
在这个函数内部,我们只需调用localStorage
对象的setItem
方法,将itemKey
和itemValue
作为输入参数传递给函数。此时,键值对条目将保存到 Web 浏览器的本地存储中。
DisplayStorageContents
函数负责在itemList
元素(一个dl
(描述列表)元素)中显示所有本地存储中的键值对。
func DisplayStorageContents() {
itemList := D.GetElementByID("itemList")
itemList.SetInnerHTML("")
for i := 0; i < localStorage.Length(); i++ {
itemKey := localStorage.Call("key", i)
itemValue := localStorage.Call("getItem", itemKey)
dtElement := D.CreateElement("dt")
dtElement.SetInnerHTML(itemKey.String())
ddElement := D.CreateElement("dd")
ddElement.SetInnerHTML(itemValue.String())
itemList.AppendChild(dtElement)
itemList.AppendChild(ddElement)
}
}
我们调用SetInnerHTML
方法并输入空字符串来清除列表的内容。
我们使用for
循环遍历本地存储中的所有条目。对于每个键值对,我们通过调用localStorage
对象的key
和getItem
方法分别获取itemKey
和itemValue
。
我们使用dt
元素(dtElement
)来显示键。dt
元素用于定义描述列表中的术语。我们使用dd
元素(ddElement
)来显示值。dd
元素用于描述描述列表中的术语。使用描述列表及其相关元素来显示键值对,我们使用了一种语义友好的方法来在网页上显示键值对。我们通过调用其AppendChild
方法将dt
和dd
元素附加到itemList
对象上。
ClearAll
函数用于删除本地存储中保存的所有键值对:
func ClearAll() {
localStorage.Call("clear")
DisplayStorageContents()
}
我们调用localStorage
对象的clear
方法,然后调用DisplayStorageContents
函数。如果一切正常,所有项目应该被清除,一旦单击了清除所有按钮,我们应该看不到itemList
元素中出现任何值。
运行本地存储演示
您可以在http://localhost:8080/localstorage-demo
访问本地存储演示。
让我们向本地存储添加一个新的键值对。在键输入文本字段中,让我们添加"foo"
键,在值输入文本字段中,让我们添加"bar"
值。单击保存按钮将新的键值对添加到本地存储中。
图 3.21 显示了单击保存按钮后出现的新创建的键值对:
图 3.21:本地存储检查器显示了一个新添加的键值对
尝试刷新网页,然后尝试重新启动 Web 浏览器并返回网页。请注意,在这些情况下,本地存储仍然保留了保存的键值对。单击清除所有按钮后,您会注意到itemList
已被清除,如图 3.20 所示,因为本地存储已清空所有键值对。
我们刚刚创建的本地存储检查器特别方便,可以检查由第三方 JavaScript 解决方案填充的键值对,这些解决方案被我们的客户端 Web 应用程序使用。如果您在 IGWEB 主页上查看图像轮播后登陆本地存储演示页面,您会注意到 itemList 中填充了图 3.22 中显示的键值对:
图 3.22:本地存储演示显示了由图像轮播填充的键值对
这些键值对是由图像轮播填充的,我们将在第九章中实现为可重用组件。
总结
在本章中,我们向您介绍了使用 GopherJS 在前端进行 Go 编程。我们向您介绍了 DOM,并展示了如何使用 GopherJS 访问和操作 DOM。我们通过几个微例子来让您熟悉使用 GopherJS 编码的样子。然后我们继续展示了完全成熟的例子。
我们向您展示了如何显示警报对话框并显示自定义消息。我们还向您展示了如何更改元素的 CSS 样式属性。我们继续向您展示了如何在 Go 的限制范围内使用jsbuiltin
包调用 JavaScript 的typeof
运算符。我们向您展示了如何创建一个简单的小写文本转换器,并演示了如何使用xhr
包发送XHR Post
。我们还向您展示了如何渲染内联 Go 模板,最后,我们向您展示了如何构建本地存储检查器。
在第四章中,同构模板,我们将介绍同构模板,这些模板可以在服务器端或客户端上进行渲染。
第四章:同构模板
在上一章中,我们介绍了 GopherJS,并涵盖了执行各种前端操作的代码示例。我们在客户端执行的有趣任务之一是使用内联 Go 模板进行模板渲染。然而,在 Web 浏览器中呈现内联 Go 模板并不是一个可维护的解决方案。首先,将 HTML 代码与 Go 源代码混合在一起,随着项目代码库的增长,可能会变得难以维护。此外,现实世界的 Web 应用程序通常需要具有多个模板文件,这些文件通常以布局层次结构嵌套在一起。除此之外,Go 标准库中的模板包特别设计用于在服务器端呈现模板,因为它依赖于从文件系统访问模板文件。
为了充分发挥模板在各种环境中的功能,我们需要一个解决方案,提供更多灵活性,以在给定项目的一组模板中呈现任何模板。通过使用 Isomorphic Go 工具包中的isokit
包,可以找到这种灵活性。使用isokit
包的功能,我们可以在服务器端或客户端呈现属于模板集的模板,并且我们将在本章中向您展示如何实现这一点。
具体来说,本章将涵盖以下主题:
-
网页模板系统
-
IGWEB 页面结构
-
模板类别
-
自定义模板函数
-
向内容模板提供数据
-
同构模板渲染
网页模板系统
在 Web 编程中,网页模板是描述网页应如何呈现给用户的文本文档。在本书中,我们将专注于 Go 的html/template
包中的 Web 模板——该包实现了适用于 Web 应用程序的数据驱动模板。
Web 模板(我们将在以后简称为模板)是文本文档,通常以 HTML 实现,并可能包含嵌入其中的特殊命令。在 Go 中,我们将这些命令称为操作。我们通过将它们放在一对开放和关闭的双大括号中({{
和}}
)来表示模板中的操作。
模板是以直观和可接受的方式向用户呈现数据的手段。实际上,您可以将模板视为我们打扮数据的手段。
在本书中,我们将使用.tmpl
文件扩展名来指定 Go 模板源文件。您可能会注意到其他一些 Go 项目使用.html
扩展名。没有硬性规定要优先选择其中一个扩展名,只需记住一旦选择了要使用的文件扩展名,最好坚持使用它,以促进项目代码库的统一性。
模板与网页模板系统一起使用。在 Go 中,我们有强大的html/template
包来呈现模板。当我们使用术语呈现模板时,我们指的是通过模板引擎处理一个或多个模板以及数据对象的过程,生成 HTML 网页输出,如图 4.1所示:
图 4.1:网页模板系统如何呈现网页
图 4.1中的关键组件,模板引擎、模板数据对象和模板,可以被归类为网页模板系统的组成部分。每个组件在呈现网页输出方面都起着重要作用,在接下来的章节中,我们将考虑每个组件在生成要在 Web 浏览器中显示的 HTML 输出过程中所起的作用。在本章中,我们将构建 IGWEB 的关于页面。
模板引擎
模板引擎的主要责任是获取一个或多个模板文件以及一个数据对象,并生成文本输出。在我们特定的研究领域,等距网络开发中,这种文本输出是以 HTML 格式的,并且可以被 Web 客户端消耗。在 Go 中,html/template
包可以被视为我们的模板引擎。
当模板引擎激活时,由路由处理程序激活,当需要提供 HTML 输出时。从等距网络应用程序的角度来看,模板引擎可以由服务器端路由处理程序和客户端路由处理程序激活。
当模板引擎从服务器端路由处理程序激活时,生成的 HTML 网页输出将通过 Web 服务器实例使用http.ResponseWriter
写入到服务器响应中的 Web 客户端。这种活动通常发生在首次访问网站上的页面时,并且初始页面请求在服务器端得到服务。在这种情况下,从模板引擎返回的 HTML 描述了完整的 HTML 网页文档,并包括开头和结尾的<html>
和<body>
标签。
当模板引擎从客户端路由处理程序激活时,生成的 HTML 内容将呈现在完全呈现的网页的指定区域。我们将在 IGWEB 上的特定区域为给定网页的客户端呈现 HTML 内容,该区域称为主要内容区域。我们将在本章后面讨论主要内容区域,即<div>
容器。客户端模板呈现通常发生在用户与网站进行后续交互时,例如当用户单击导航栏中的链接以访问网站上的特定页面时。在这种情况下,从模板引擎返回的 HTML 仅代表 HTML 网页的一部分。
值得注意的是,Go 带有两个模板包。text/template
包用于生成文本,html/template
包用于生成 HTML 输出。html/template
包提供与text/template
包相同的接口。在本书中,我们特别关注生成 HTML 网页输出,这就是为什么我们将专注于html/template
包的原因。html/template
包通过生成安全的 HTML 输出提供了额外的安全性,而常规的text/template
包则不会这样做。这就是为什么最好使用html/template
包进行 Web 开发的目的。
模板数据对象
模板数据对象(或简称数据对象)的主要责任是为给定模板提供要呈现给用户的数据。在我们将要构建的“关于”页面中,有两个需要呈现的数据。第一个需求是微妙的,它是将显示在 Web 浏览器标题栏窗口中的网页标题,或作为包含网页的 Web 浏览器选项卡的标题。第二个数据需求更深刻,它是数据对象,应在“关于”页面上显示的土拨鼠列表。
我们将使用shared/templatedata/about.go
源文件中定义的templatedata
包中的以下About
结构来满足“关于”页面的数据需求:
type About struct {
PageTitle string
Gophers []*models.Gopher
}
PageTitle
字段表示应在 Web 浏览器标题栏中显示的网页标题(或作为 Web 浏览器选项卡的标题)。Gophers
字段是指向Gopher
结构的指针切片。Gopher
结构表示应在“关于”页面上显示的土拨鼠,即 IGWEB 团队的成员。
Gopher
结构的定义可以在shared/models
文件夹中的gopher.go
源文件中找到:
type Gopher struct {
Name string
Title string
Biodata string
ImageURI string
StartTime time.Time
}
Name
字段代表地鼠的姓名。Title
字段代表 IGWEB 组织赋予特定地鼠的头衔。Biodata
字段代表特定地鼠的简要个人资料。我们使用了 loren ipsum 生成器,在这个字段中生成了一些拉丁文的随机胡言乱语。ImageURI
字段是应该显示的地鼠图片的路径,相对于服务器根目录。地鼠的图片将显示在页面的左侧,地鼠的个人资料将显示在页面的右侧。
最后,StartTime
字段代表地鼠加入 IGWEB 组织的日期和时间。我们将以标准时间格式显示地鼠的开始时间,本章后面我们将学习如何通过实现自定义模板函数来使用 Ruby 风格格式显示开始时间。在第九章,齿轮-可重用组件中,我们将学习如何以人类可读的时间格式显示开始时间。
模板
模板负责以直观和易懂的方式向用户呈现信息。模板构成同构 Web 应用的视图层。Go 模板是标准 HTML 标记和轻量级模板语言的组合,它为我们提供了执行标记替换、循环、条件控制流、模板嵌套以及使用管道构造在模板中调用自定义模板函数的手段。所有上述活动都可以使用模板操作来执行,我们将在本书中使用它们。
IGWEB 项目的模板可以在shared/templates
文件夹中找到。它们被视为同构模板,因为它们可以在服务器端和客户端上使用。现在我们将探索 IGWEB 的网页布局组织,然后直接查看实现 IGWEB 网页结构所需的模板。
IGWEB 页面结构
图 4.2描绘了 IGWEB 网页结构的线框设计。该图为我们提供了网站的基本布局和导航需求的良好想法:
图 4.2:IGWEB 线框设计
通过将网页结构组织成这些个别区域,我们可以划分出每个区域在整个网页结构中所扮演的独特功能。让我们继续检查构成页面结构的每个个别区域。
-
页眉
-
主要内容区域
-
页脚
页眉
如图 4.2所示,页眉区域出现在网页顶部。它标志着网页的开始,并且对品牌、导航和用户交互很有用。它由顶部栏和导航栏组成。
顶部栏
如图 4.2所示,顶部栏是存在于页眉内的子区域。在顶部栏的最左侧是 IGWEB 的标志。除了用于品牌目的,标志还作为导航组件,因为用户点击它时,他们将返回到主页。在顶部栏的最右侧是辅助用户控件,用于激活特定功能——购物车和实时聊天功能。
导航栏
如图 4.2所示,导航栏是存在于页眉内的子区域。导航区域包括指向网站各个页面的链接。
主要内容区域
主要内容区域,如图 4.2所示,位于页眉区域和页脚区域之间。网页的内容将显示在这里。例如,关于页面将在主要内容区域显示 IGWEB 团队地鼠的图片和简介信息。
页脚
如图 4.2所示,页脚区域出现在网页底部。它包含网站的版权声明。页脚标志着网页的结束。
现在我们已经为 IGWEB 建立了网页结构,我们将学习如何使用预先计划的 Go 模板层次结构来实现结构。为了提高我们的理解,我们将根据它们的功能目的将模板组织成类别。
模板类别
根据功能目的将模板组织成类别,可以让我们在实现网页结构时更加高效。模板可以根据它们在实现网页结构中所起的作用,分为以下三类:
-
布局模板
-
部分模板
-
常规模板
布局模板描述整个网页的一般布局。它们为我们提供了页面结构的鸟瞰图,并让我们了解所有其他模板如何适应其中。
部分模板只包含网页的一部分,因此它们被称为部分。它们是部分性质的,因为它们旨在满足网页区域内的特定需求,比如显示网页的页脚。
常规模板包含特定网站部分的内容,并且这些内容应该显示在主要内容区域。在接下来的部分中,我们将检查每个模板类别,并考虑为每个类别执行的相应模板实现。
布局模板
页面布局模板,也称为布局模板,包含整个网页的结构。由于它们定义了网页的整体结构,它们需要其他模板(部分和常规)来完成。对于同构的网络应用程序,这些类型的模板用于在服务器端呈现网页,用于发送到客户端的初始网页响应。在 IGWEB 项目中,我们将布局模板放在shared/templates/layouts
文件夹中。
网页布局模板
以下是在shared/templates/layouts
目录中的webpage_layout.tmpl
源文件中找到的网页布局模板:
<!doctype html>
<html>
{{ template "partials/header_partial" . }}
<div id="primaryContent" class="pageContent">
{{ template "pagecontent" . }}
</div>
<div id="chatboxContainer">
</div>
{{ template "partials/footer_partial" . }}
</html>
请注意,布局模板覆盖了整个网页,从开头的<html>
标签到结束的</html>
标签。布局模板发出了渲染header
部分模板、pagecontent
常规模板和footer
部分模板的template
动作(以粗体显示)。
在partials/header_partial
模板名称和闭合的一对大括号}}
之间的点.
被称为动作。模板引擎认为这是一个命令,应该用在模板执行时传入的数据对象的值来替换。通过在这里放置点,我们确保页眉部分模板可以访问被传入模板的数据对象,这个模板负责显示网站页眉区域的内容。请注意,我们对pagecontent
模板和partials/footer_partial
模板也做了同样的操作。
部分模板
部分模板,也称为部分,通常包含网页特定区域的部分内容。部分模板的示例包括网页的页眉和页脚。当在页面布局模板中包含页眉和页脚时,页眉和页脚部分模板非常有用,因为页眉和页脚将预设在网站的所有网页上。让我们看看页眉和页脚部分模板是如何实现的。在 IGWEB 项目中,我们将部分模板放在shared/templates/partials
文件夹中。
页眉部分模板
以下是在shared/templates/partials
文件夹中的header_partial.tmpl
源文件中找到的页眉部分的示例:
<head>
<title>{{.PageTitle}}</title>
<link rel="icon" type="image/png" href="/static/images/isomorphic_go_icon.png">
<link rel="stylesheet" href="/static/css/pure.css">
<link rel="stylesheet" type="text/css" href="/static/css/cogimports.css">
<link rel="stylesheet" type="text/css" href="/static/css/alertify.core.css" />
<link rel="stylesheet" type="text/css" href="/static/css/alertify.default.css" />
<link rel="stylesheet" type="text/css" href="/static/css/igweb.css">
<script type="text/javascript" src="img/alertify.js" type="text/javascript"></script>
<script src="img/cogimports.js" type="text/javascript"></script>
<script type="text/javascript" src="img/client.js"></script>
</head>
<body>
<div id="topbar">{{template "partials/topbar_partial"}}</div>
<div id="navbar">{{template "partials/navbar_partial"}}</div>
在开头的<head>
和结尾的</head>
标签之间,我们包括网站图标以及外部 CSS 样式表和外部 JavaScript 源文件。igweb.css
样式表定义了 IGWEB 网站的样式(以粗体显示)。client.js
JavaScript 源文件是客户端 Web 应用程序的 JavaScript 源文件,它通过 GopherJS 转译为 JavaScript(以粗体显示)。
请注意,我们在头部部分模板中使用template
操作(以粗体显示)来呈现顶部栏和导航栏部分模板。我们在这里不包括点.
,因为这些部分模板不需要访问数据对象。顶部栏和导航栏的内容都在各自的<div>
容器中。
顶部栏部分模板
以下是在shared/templates/partials
文件夹中的topbar_partial.tmpl
源文件中找到的顶部栏部分模板:
<div id="topbar" >
<div id="logoContainer" class="neon-text"><span><a href="/index">igweb</a></span></div>
<div id="siteControlsContainer">
<div id="shoppingCartContainer" class="topcontrol" title="Shopping Cart"><a href="/shopping-cart"><img src="img/cart_icon.png"></a></div>
<div id="livechatContainer" class="topcontrol" title="Live Chat"><img id="liveChatIcon" src="img/msg_icon.png"></div>
</div>
</div>
顶部栏部分模板是一个静态模板的很好例子,其中没有动态操作。它没有在其中定义template
操作,它的主要目的是包含 HTML 标记以呈现网站标志、购物车图标和在线聊天图标。
导航栏部分模板
以下是在shared/templates/partials
文件夹中的navbar_partial.tmpl
源文件中找到的导航栏部分模板的示例:
<div id="navigationBar">
<ul>
<li><a href="/index">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</div>
导航栏部分模板也是一个静态模板。它包含一个div
容器,其中包含组成 IGWEB 导航栏的导航链接列表。这些链接允许用户访问主页、产品、关于和联系页面。
页脚部分模板
以下是在shared/templates/partials
文件夹中的footer_partial.tmpl
源文件中找到的页脚部分模板的示例:
<footer>
<div id="copyrightNotice">
<p>Copyright © IGWEB. All Rights Reserved</p>
</div>
</footer>
</body>
页脚部分模板也是一个静态模板,其当前唯一目的是包含 IGWEB 网站的版权声明的 HTML 标记。
现在我们已经涵盖了构成网页结构的所有部分模板,是时候来看看常规模板从服务器端和客户端的角度看是什么样子了。
常规模板
常规模板用于保存要在网页上显示的主要内容。例如,在关于页面中,主要内容将是关于 IGWEB 团队地鼹鼠的信息以及他们个人的图片。
在本章中,我们将构建关于页面。通过检查其线框设计(见图 4.3),我们可以清楚地看到关于页面主要内容区域中的内容:
图 4.3:关于页面的线框设计
对于 IGWEB 团队中的每只地鼹鼠,我们将显示地鼹鼠的图片、姓名、头衔以及关于其角色的简要描述(随机生成的拉丁文胡言乱语)。我们还将以几种不同的时间格式显示地鼹鼠加入 IGWEB 团队的日期/时间。
我们将以明显不同的方式呈现关于页面,具体取决于呈现是在服务器端还是客户端进行。在服务器端,当我们呈现关于页面时,我们需要一个页面模板,即一个常规模板,其中包含整个网页的布局,以及包含关于页面的内容。在客户端,我们只需要呈现关于页面中包含的内容以填充主要内容区域,因为网页已经在初始页面加载时为我们呈现出来。
在这一点上,我们可以定义常规模板的两个子类别:页面模板将满足我们的服务器端渲染需求,内容模板将满足我们的客户端渲染需求。在 IGWEB 项目中,我们将把常规模板放在shared/templates
文件夹中。
关于页面的页面模板
以下是关于页面的页面模板示例,来自shared/templates
文件夹中的about_page.tmpl
源文件:
{{ define "pagecontent" }}
{{ template "about_content" . }}
{{ end }}
{{ template "layouts/webpage_layout" . }}
我们在页面模板中使用define
操作来定义包含我们声明为pagecontent
部分的模板部分的区域。我们有一个相应的end
操作来标记pagecontent
部分的结束。请注意,在定义和结束操作之间,我们使用模板操作来包含名为about_content
的模板。还要注意,我们使用点(.
)操作将数据对象传递给about_content
模板。
此页面模板是一个很好的示例,显示了我们如何在常规模板中呈现布局模板。在模板的最后一行,我们声明了一个template
操作,以加载名为layouts/webpage_layout
的网页布局模板。再次注意,我们使用点(.
)操作将数据对象传递给网页布局模板。
现在我们已经检查了about_page
模板,是时候检查about_content
模板了。
关于页面的内容模板
以下是内容模板的示例,该模板被呈现到关于页面中的主要内容区域中,来自shared/templates
文件夹中的about_content.tmpl
源文件:
<h1>About</h1>
<div id="gopherTeamContainer">
{{range .Gophers}}
<div class="gopherContainer">
<div class="gopherImageContainer">
<img height="270" src="img/strong>">
</div>
<div class="gopherDetailsContainer">
<div class="gopherName"><h3><b>{{.Name}}</b></h3></div>
<div class="gopherTitle"><span>{{.Title}}</span></div>
<div class="gopherBiodata"><p>{{.Biodata}}</p></div>
<div class="gopherStartTime">
<p class="standardStartTime">{{.Name}} joined the IGWEB team on <span class="starttime">{{.StartTime}}).</p>
<p class="rubyStartTime">That's <span class="starttime">{{.StartTime | rubyformat}}</span> in Ruby date format.</p>
<div class="humanReadableGopherTime">That's <div id="Gopher-{{.Name}}" data-starttimeunix="{{.StartTime | unixformat}}" data-component="cog" class="humanReadableDate starttime"></div> in Human readable format.</div>
</div>
</div>
</div>
{{end}}
</div>
我们使用range
操作来遍历模板提供的数据对象的 Gophers 属性(以粗体显示)。请注意,我们使用点(.
)操作来访问数据对象的Gophers
属性。请记住,Gophers
属性是指向Gopher
结构的指针切片。我们使用end
操作来表示range
循环操作的结束(以粗体显示)。
需要注意的是,内容模板在服务器端和客户端都是必需的。请记住,在服务器端,需要呈现完整的网页布局,以及内容模板。在客户端,我们只需要呈现内容模板。
请注意,在最后两个打印StartTime
字段的地方,我们使用管道(|
)运算符使用自定义函数格式化StartTime
字段。首先,我们使用rubyformat
函数以 Ruby 日期/时间格式显示StartTime
值,然后我们使用unixformat
函数将"data-starttimeunix"
属性填充为StartTime
值的 Unix 时间表示。让我们看看这些自定义函数在 IGWEB 项目代码库中是如何定义的。
自定义模板函数
我们在shared/templatefuncs
文件夹中找到的funcs.go
源文件中定义了我们的自定义模板函数。
package templatefuncs
import (
"strconv"
"time"
)
func RubyDate(t time.Time) string {
layout := time.RubyDate
return t.Format(layout)
}
func UnixTime(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
}
RubyDate
函数使用time.RubyDate
常量指定的时间布局显示给定的时间。我们在模板中使用rubyformat
函数名称调用该函数。
如前所述,在关于内容模板(shared/templates/about_content.tmpl
)中,我们使用管道(|
)运算符将rubyformat
函数应用于StartTime
,如下所示:
<p class="rubyStartTime">That's <span class="starttime">{{.StartTime | rubyformat}}</span> in Ruby date format.</p>
通过这种方式,自定义模板函数为我们提供了灵活性,可以在模板中格式化值,以满足项目可能需要的独特需求。也许你会想,我们如何将rubyformat
名称映射到RubyDate
函数。我们创建一个包含此映射的模板函数映射;我们将在本章后面介绍如何在不同环境中使用模板函数映射。
templates
、templatedata
和templatefuncs
这三个子文件夹位于shared
文件夹中,这意味着这些文件夹中的代码可以在不同环境中使用。实际上,shared
文件夹及其子文件夹中包含的任何代码都是用于在不同环境中共享的代码。
我们将在第九章中介绍UnixTime
函数,模板中称为unixformat
函数,齿轮-可重用组件。
向内容模板提供数据
我们将要提供给关于内容模板的数据对象是指向代表 IGWEB 团队上每只地鼠的Gopher
结构体的指针切片。我们的模板数据对象的Gophers
属性将从 Redis 数据存储中获取地鼠切片,并与数据对象的PageTitle
属性一起填充到“关于”页面的模板数据对象中。
我们在数据存储对象上调用GetGopherTeam
方法,以获取属于 IGWEB 团队的地鼠切片。以下是在common/datastore
文件夹中找到的redis.go
源文件中GetGopherTeam
函数的声明:
func (r *RedisDatastore) GetGopherTeam() []*models.Gopher {
exists, err := r.Cmd("EXISTS", "gopher-team").Int()
if err != nil {
log.Println("Encountered error: ", err)
return nil
} else if exists == 0 {
return nil
}
var t []*models.Gopher
jsonData, err := r.Cmd("GET", "gopher-team").Str()
if err != nil {
log.Print("Encountered error when attempting to fetch gopher team data from Redis instance: ", err)
return nil
}
if err := json.Unmarshal([]byte(jsonData), &t); err != nil {
log.Print("Encountered error when attempting to unmarshal JSON gopher team data: ", err)
return nil
}
return t
}
GetGopherTeam
函数检查Redis
数据库中是否存在gopher-team
键。地鼠切片以 JSON 编码的数据形式存储在Redis
数据库中。如果gopher-team
键存在,我们尝试将 JSON 编码的数据解码为t
变量,这是指向Gopher
结构体的指针切片。如果我们成功解码了 JSON 数据,我们将返回t
变量。
到目前为止,我们已经创建了获取将显示在“关于”页面上的地鼠团队数据的方法。你可能会想,为什么我们不能只是用地鼠的切片作为数据对象,将其传递给关于内容模板,然后就完成了呢?为什么我们需要传递一个类型为templatedata.About
的数据对象给关于内容模板呢?
对这两个问题的一言以蔽之的答案是可扩展性。目前,“关于”部分不仅需要地鼠的切片,还需要一个页面标题,该标题将显示在网页浏览器的标题窗口和/或网页浏览器标签中。因此,对于 IGWEB 的所有部分,我们已经创建了相应的结构体,以在shared/templatedata
文件夹中为网站的每个页面建模个别数据需求。由于templatedata
包位于shared
文件夹中,因此templatedata
包是同构的,可以在各种环境中访问。
我们在shared/templatedata
文件夹中的about.go
源文件中定义了About
结构:
type About struct {
PageTitle string
Gophers []*models.Gopher
}
PageTitle
字段是string
类型的,是“关于”页面的标题。Gophers
字段是指向Gopher
结构体的指针切片。这个切片代表将在关于页面上显示的地鼠团队。正如我们在本章前面看到的,我们将在内容模板中使用range
操作来遍历切片并显示每只地鼠的个人资料信息。
回到可扩展性的话题,templatedata
包中定义的结构体字段并不是固定不变的。它们是为了随着时间的推移而改变,以适应特定网页的未来需求。
例如,如果 IGWEB 产品经理决定他们应该有地鼠团队成员在办公室工作、学习和玩耍的照片,以供公共关系用途,他们可以通过向About
结构体添加名为OfficeActivityImages
的新字段来轻松满足这一要求。这个新字段可以是一个字符串切片,表示应该在“关于”页面上显示的地鼠图片的服务器相对路径。然后,我们将在模板中添加一个新的部分,通过range
遍历OfficeActivityImages
切片,并显示每张图片。
到目前为止,我们已经满足了“关于”页面的数据需求,并且我们已经准备好了所有的模板。现在是时候专注于如何在服务器端和客户端执行模板的渲染了。这就是同构模板渲染发挥作用的地方。
同构模板渲染
等同模板渲染允许我们在不同环境中渲染和重用模板。在 Go 中渲染模板的传统程序依赖于通过文件系统访问模板,但这带来了一些限制,阻止我们在客户端上渲染相同的模板。我们需要承认这些限制,以充分理解等同模板渲染为我们带来的好处。
基于文件系统的模板渲染的限制
在与客户端共享模板渲染责任时,我们需要承认模板渲染工作流程中的某些限制。首先,模板文件是在 Web 服务器上定义的。
让我们考虑一个例子,遵循经典的 Web 应用程序架构,以充分理解我们面临的限制。以下是一个使用模板文件edit.html
进行服务器端模板渲染的示例,取自 Go 网站的编写 Web 应用程序文章(golang.org/doc/articles/wiki/
):
func editHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/edit/"):]
p, err := loadPage(title)
if err != nil {
p = &Page{Title: title}
}
t, _ := template.ParseFiles("edit.html")
t.Execute(w, p)
}
editHandler
函数负责处理/edit
路由。最后两行(以粗体显示)特别值得我们考虑。调用html/template
包中的ParseFiles
函数来解析edit.html
模板文件。模板解析后,调用html/template
包中的Execute
函数来执行模板以及p
数据对象,它是一个Page
结构。生成的网页输出然后使用http.ResponseWriter
w
作为网页响应写出到客户端。
Go 网站的编写 Web 应用程序文章是一篇了解使用 Go 进行经典的服务器端 Web 应用程序编程的优秀文章。我强烈建议您阅读这篇文章:golang.org/doc/articles/wiki/
。
以这种方式渲染模板的缺点是,我们被锚定在服务器端文件系统上,edit.html
模板文件所在的地方。我们面临的困境是,客户端需要访问模板文件的内容才能在客户端上渲染模板。在客户端无法调用ParseFiles
函数,因为我们无法访问本地文件系统上可以读取的任何模板文件。
现代 Web 浏览器中实施的强大安全沙箱阻止客户端从本地文件系统访问模板文件,这是正确的。相比之下,从服务器端调用ParseFiles
函数是有意义的,因为服务器端应用程序实际上可以访问服务器端文件系统,模板就驻留在那里。
那么我们如何克服这一障碍呢?isokit
包通过提供我们从服务器端文件系统中收集一组模板,并创建一个内存模板集的能力来拯救我们。
内存中的模板集
isokit
包具有以等同方式渲染模板的功能。为了以等同方式思考,在模板渲染时,我们必须摆脱以往在文件系统中渲染模板的思维方式。相反,我们必须考虑在内存中维护一组模板,我们可以通过给定的名称访问特定模板。
当我们使用术语“内存”时,我们并不是指内存数据库,而是指模板集在运行的应用程序本身中持续存在,无论是在服务器端还是客户端。模板集在应用程序运行时保持驻留在内存中供应用程序利用。
isokit
包中的Template
类型表示等同模板,可以在服务器端或客户端上呈现。在Template
的类型定义中,注意到*template.Template
类型被嵌入:
type Template struct {
*template.Template
templateType int8
}
嵌入*template.Template
类型允许我们利用html/template
包中定义的Template
类型的所有功能。templateType
字段指示我们正在处理的模板类型。以下是带有此字段所有可能值的常量分组声明:
const (
TemplateRegular = iota
TemplatePartial
TemplateLayout
)
正如你所看到的,常量分组声明已经考虑到我们将处理的所有模板类别:常规模板、部分模板和布局模板。
让我们看一下isokit
包中的TemplateSet
结构是什么样子的:
type TemplateSet struct {
members map[string]*Template
Funcs template.FuncMap
bundle *TemplateBundle
TemplateFilesPath string
}
members
字段是一个map
,键的类型是string
,值是指向isokit.Template
结构的指针。Funcs
字段是一个可选的函数映射(template.FuncMap
),可以提供给模板集,以在模板内调用自定义函数。bundle
字段是模板包。TemplateBundle
是一个map
,其中键表示模板的名称(string
类型),值是模板文件的内容(也是string
类型)。TemplateFilesPath
字段表示所有 Web 应用程序等同模板所在的路径。
TemplateBundle
结构如下:
type TemplateBundle struct {
items map[string]string
}
TemplateBundle
结构的items
字段只是一个具有string
类型键和string
类型值的map
。items
映射起着重要作用,它是将在服务器端进行gob
编码的数据结构,并且我们将通过服务器端路由/template-bundle
将其暴露给客户端,在那里可以通过 XHR 调用检索并解码,如图 4.4所示:
图 4.4 模板包中的项目如何传输到客户端
模板包类型发挥着关键作用,因为我们将其用作在客户端重新创建内存中的模板集的基础。这使我们能够为客户端提供完整的模板集。现在我们已经了解到可以利用模板集的概念来等同地呈现模板,让我们看看实际操作中是如何完成的。
在服务器端设置模板集
让我们来看一下igweb
文件夹中的igweb.go
源文件开头的变量声明:
var WebAppRoot string
var WebAppMode string
var WebServerPort string
var DBConnectionString string
var StaticAssetsPath string
此处声明的变量对于 Web 服务器实例的正常运行至关重要。WebAppRoot
变量负责指定igweb
项目文件夹的位置。WebServerPort
变量负责指定 Web 服务器实例应在哪个端口上运行。DBConnectionString
变量用于指定到数据库的连接字符串。StaticAssetsPath
变量用于指定包含项目的所有静态(非动态)资产的目录。这些资产可能包括 CSS 样式表、JavaScript 源文件、图像、字体以及任何不需要是动态的东西。
我们在init
函数中初始化变量:
func init() {
WebAppRoot = os.Getenv("IGWEB_APP_ROOT")
WebAppMode = os.Getenv("IGWEB_MODE")
WebServerPort = os.Getenv("IGWEB_SERVER_PORT")
DBConnectionString = os.Getenv("IGWEB_DB_CONNECTION_STRING")
// Set the default web server port if it hasn't been set already
if WebServerPort == "" {
WebServerPort = "8080"
}
// Set the default database connection string
if DBConnectionString == "" {
DBConnectionString = "localhost:6379"
}
StaticAssetsPath = WebAppRoot + "/static"
}
WebAppRoot
和WebServerPort
变量分别从IGWEB_APP_ROOT
和$IGWEB_SERVER_PORT
环境变量中获取。
我们将在第十一章中介绍WebAppMode
变量和$IGWEB_MODE
环境变量,部署等同 Go Web 应用程序。
如果$IGWEB_SERVER_PORT
环境变量未设置,默认端口设置为8080
。
DBConnectionString
变量被赋予值"localhost:6379"
, 这是 Redis 数据库实例运行的主机名和端口。
StaticAssetsPath
变量被分配给static
文件夹,该文件夹位于WebAppRoot
文件夹内。
让我们来看看main
函数的开头:
func main() {
env := common.Env{}
if WebAppRoot == "" {
fmt.Println("The IGWEB_APP_ROOT environment variable must be set before the web server instance can be started.")
os.Exit(1)
}
initializeTemplateSet(&env, false)
initializeDatastore(&env)
在main
函数的开头,我们检查WebAppRoot
变量是否已设置,如果没有设置,我们就退出应用程序。设置$IGWEB_APP_ROOT
环境变量的最大优势之一是,我们可以从系统上的任何文件夹中发出igweb
命令。
在main
函数中,我们初始化了env
对象。在调用initializeDatastore
函数初始化数据存储之后,我们调用initializeTemplateSet
函数(以粗体显示),将env
对象的引用传递给函数。这个函数,正如你从它的名字中猜到的那样,负责初始化模板集。我们将在第十一章中使用传递给函数的bool
类型的第二个参数,部署一个同构的 Go Web 应用程序。
让我们来看看initializeTemplateSet
函数:
func initializeTemplateSet(env *common.Env, oneTimeStaticAssetsGeneration bool) {
isokit.WebAppRoot = WebAppRoot
isokit.TemplateFilesPath = WebAppRoot + "/shared/templates"
isokit.StaticAssetsPath = StaticAssetsPath
isokit.StaticTemplateBundleFilePath = StaticAssetsPath + "/templates/igweb.tmplbundle"
ts := isokit.NewTemplateSet()
funcMap := template.FuncMap{"rubyformat": templatefuncs.RubyDate, "unixformat": templatefuncs.UnixTime}
ts.Funcs = funcMap
ts.GatherTemplates()
env.TemplateSet = ts
}
我们首先初始化isokit
包的WebAppRoot
、TemplateFilesPath
和StaticAssetsPath
变量的导出变量。通过调用isokit
包中的NewTemplateSet
函数,我们创建了一个新的模板集ts
。
在我们创建模板集对象ts
之后,我们声明了一个函数映射funcMap
。我们用两个自定义函数填充了我们的映射,这些函数将暴露给我们的模板。第一个函数的键是rubyformat
,值是templatefuncs
包中找到的RubyDate
函数。这个函数将返回给定时间值的 Ruby 格式。第二个函数的键是unixformat
,这个函数将返回给定时间值的 Unix 时间戳。我们用我们刚刚创建的funcMap
对象填充了模板集对象的Funcs
字段。现在,我们模板集中的所有模板都可以访问这两个自定义函数。
到目前为止,我们已经准备好了模板集,但还没有填充模板集的bundle
字段。为了做到这一点,我们必须调用TemplateSet
对象的GatherTemplate
方法,该方法将收集isokit.TemplateFilesPath
指定的目录及其所有子目录中找到的所有模板。模板文件的名称(不包括.tmpl
文件扩展名)将用作 bundle 映射中的键。模板文件的字符串内容将用作 bundle 映射中的值。如果模板是布局或部分,它们各自的目录名称将包含在名称中以引用它们。例如,partials/footer.tmpl
模板的名称将是partials/footer
。
现在我们的模板集已经准备好了,我们可以填充env
对象的TemplateSet
字段,这样我们的服务器端应用程序就可以访问模板集。这在以后会很方便,因为它允许我们从服务器端 Web 应用程序中定义的任何请求处理程序函数中访问模板集,从而使我们能够渲染模板集中存在的任何模板。
注册服务器端处理程序
在igweb.go
源文件的main
函数中初始化模板集之后,我们创建了一个新的 Gorilla Mux 路由器,并调用registerRoutes
函数来注册服务器端 Web 应用程序的所有路由。让我们来看看registerRoutes
函数中对客户端 Web 应用程序正常运行至关重要的行:
// Register Handlers for Client-Side JavaScript Application
r.Handle("/js/client.js", isokit.GopherjsScriptHandler(WebAppRoot)).Methods("GET")
r.Handle("/js/client.js.map", isokit.GopherjsScriptMapHandler(WebAppRoot)).Methods("GET")
// Register handler for the delivery of the template bundle
r.Handle("/template-bundle", handlers.TemplateBundleHandler(env)).Methods("POST")
我们为/js/client.js
路由注册了一个处理程序,并指定它将由isokit
包中的GopherjsScriptHandler
函数处理。这将把路由与通过在client
目录中运行gopherjs build
命令构建的client.js
JavaScript 源文件相关联。
我们以类似的方式处理client.js.map
的map
文件。我们注册了一个/js/client.js.map
路由,并指定它将由isokit
包中的GopherjsScriptMapHandler
函数处理。
现在我们已经注册了 JavaScript 源文件和 JavaScript 源map
文件的路由,这对我们的客户端应用程序的功能至关重要,我们需要注册一个路由来访问模板包。我们将在r
路由对象上调用Handle
方法,并指定/template-bundle
路由将由handlers
包中的TemplateBundleHandler
函数处理。客户端将通过 XHR 调用检索此路由,并且服务器将以gob
编码数据的形式发送模板包。
我们注册的最后一个路由,目前对我们来说特别重要的是/about
路由。以下是我们注册/about
路由并将其与handlers
包中的AboutHandler
函数关联的代码行:
r.Handle("/about", handlers.AboutHandler(env)).Methods("GET")
现在我们已经看到了如何在服务器端 Web 应用程序中设置模板集,以及如何注册对我们在本章中重要的路由,让我们继续查看服务器端处理程序,从handlers
包中的TemplateBundleHandler
函数开始。
提供模板包项目
以下是handlers
文件夹中templatebundle.go
源文件中的TemplateBundleHandler
函数:
func TemplateBundleHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var templateContentItemsBuffer bytes.Buffer
enc := gob.NewEncoder(&templateContentItemsBuffer)
m := env.TemplateSet.Bundle().Items()
err := enc.Encode(&m)
if err != nil {
log.Print("encoding err: ", err)
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(templateContentItemsBuffer.Bytes())
})
}
将数据编码为gob
格式的代码应该看起来很熟悉,就像我们在第三章的在前端使用 GopherJS中的传输 gob 编码数据部分中对 cars 切片进行 gob 格式编码一样。在TemplateBundleHandler
函数内部,我们首先声明templateContentItemsBuffer
,类型为bytes.Buffer
,它将保存gob
编码数据。然后我们创建一个新的gob
编码器enc
。紧接着,我们将创建一个m
变量,并将其赋值为模板包映射的值。我们调用enc
对象的Encode
方法,并传入对m
映射的引用。此时,templateContentItemsBuffer
应该包含代表m
映射的gob
编码数据。我们将写出一个内容类型标头,以指定服务器将发送二进制数据(application/octet-stream
)。然后我们将通过调用其Bytes
方法写出templateContentItemsBuffer
的二进制内容。在本章的在客户端设置模板集部分,我们将看到客户端 Web 应用程序如何获取模板包项目,并利用它在客户端上创建模板集。
从服务器端渲染 about 页面
现在我们已经看到了服务器端应用程序如何将模板包传输到客户端应用程序,让我们来看看handlers
文件夹中about.go
源文件中的AboutHandler
函数。这是负责渲染About
页面的服务器端处理程序函数:
func AboutHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gophers := env.DB.GetGopherTeam()
templateData := templatedata.About{PageTitle: "About", Gophers: gophers}
env.TemplateSet.Render("about_page", &isokit.RenderParams{Writer: w, Data: templateData})
})
}
AboutHandler
函数有三个职责:
-
从数据存储中获取 gophers
-
创建模板数据对象
-
渲染
About
页面模板
在函数中定义的第一行代码从数据存储中获取 gopher 对象,其中 gopher 对象表示单个 gopher 团队成员。在我们的示例数据集中,有三个 gophers:Molly,Case 和 Wintermute。
第二行代码用于设置templatedata.About
类型的模板数据对象。这是将被输入模板的数据对象。数据对象的PageTitle
属性用于显示页面标题,我们将使用对象的Gophers
属性填充从数据存储中检索到的 gopher 对象的切片。
在处理程序函数的第三行,我们调用模板集的Render
方法来呈现模板。传递给该方法的第一个参数是要呈现的模板的名称。在这种情况下,我们已经指定要呈现about_page
模板。请注意,这是一个页面模板,不仅会呈现关于页面内容,还会呈现整个网页布局,除了主要内容区域部分外,还包括网页的页眉、顶部栏、导航栏和页脚区域。
函数的第二个参数是模板渲染参数(isokit.RenderParams
)。我们已经用http.ResponseWriter
w
填充了Writer
字段。此外,我们已经用我们刚刚创建的templateData
对象填充了Data
字段,该对象表示应提供给模板的数据对象。
就是这样。现在我们可以在服务器端呈现此模板。我们现在已经实现了经典的 Web 应用程序架构流程,其中整个网页都是从服务器端呈现的。我们可以在http://localhost:8080/about
访问关于页面。以下是从服务器端呈现的关于页面的外观:
图 4.5 从服务器端呈现的关于页面
在客户端设置模板集
现在我们已经看到了 Web 模板如何在服务器端呈现,是时候关注 Web 模板如何在客户端呈现了。我们客户端 Web 应用程序的主要入口点是client.go
源文件中client
文件夹中定义的main
函数:
func main() {
var D = dom.GetWindow().Document().(dom.HTMLDocument)
switch readyState := D.ReadyState(); readyState {
case "loading":
D.AddEventListener("DOMContentLoaded", false, func(dom.Event) {
go run()
})
case "interactive", "complete":
run()
default:
println("Encountered unexpected document ready state value!")
}
}
首先,我们将文档对象分配给D
变量,我们在这里执行了通常的别名操作,以节省一些输入。然后,我们在文档对象的readyState
属性上声明了一个switch
块。我们通过在Document
对象上调用ReadyState
方法来获取文档对象的readyState
。
文档的 readyState 属性描述了文档的加载状态。您可以在 Mozilla 开发者网络上阅读有关此属性的更多信息:developer.mozilla.org/en-US/docs/Web/API/Document/readyState
。
在第一个case
语句中,我们将检查readyState
值是否为"loading"
,如果是,则表示文档仍在加载中。我们设置一个事件侦听器来监听DOMContentLoaded
事件。DOMContentLoaded
事件将告诉我们网页已完全加载,此时我们可以调用run
函数作为 goroutine。我们将run
函数作为 goroutine 调用,因为我们不希望run
函数内部的任何操作被阻塞,因为我们是从事件处理程序函数中调用它的。
在第二个case
语句中,我们将检查readyState
值是否为interactive
或complete
。interactive
状态表示文档已经完成加载,但可能还有一些资源,如图像或样式表,尚未完全加载。complete
状态表示文档和所有子资源都已完成加载。如果readyState
是交互式或完整的,我们将调用run
函数。
最后,default
语句处理意外行为。理想情况下,我们永远不应该达到default
情况,如果我们确实达到了,我们将在 Web 控制台中打印一条消息,指示我们遇到了意外的文档readyState
值。
我们在main
函数中创建的功能为我们提供了宝贵的好处,即能够从 HTML 文档的<head>
部分作为外部 JavaScript 源文件导入我们的 GopherJS 生成的 JavaScript 源文件client.js
,如下所示(用粗体显示):
<head>
<title>{{.PageTitle}}</title>
<link rel="icon" type="image/png" href="/static/images/isomorphic_go_icon.png">
<link rel="stylesheet" href="/static/css/pure.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/cogimports.css">
<link rel="stylesheet" type="text/css" href="/static/css/igweb.css">
<script src="img/cogimports.js" type="text/javascript" async></script>
<script type="text/javascript" src="img/client.js"></script>
</head>
这意味着我们不必在关闭</body>
标签之前导入外部 JavaScript 源文件,以确保网页已完全加载。在头部声明中包含外部 JavaScript 源文件的过程更加健壮,因为我们的代码特别考虑了readyState
。另一种更脆弱的方法对readyState
漠不关心,并且依赖于包含的<script>
标签在 HTML 文档中的位置才能正常工作。
在run
函数内,我们将首先在 Web 控制台中打印一条消息,指示我们已成功进入客户端应用程序:
println("IGWEB Client Application")
然后,我们将从本章前面设置的服务器端/template-bundle
路由中获取模板集:
templateSetChannel := make(chan *isokit.TemplateSet)
funcMap := template.FuncMap{"rubyformat": templatefuncs.RubyDate, "unixformat": templatefuncs.UnixTime, "productionmode": templatefuncs.IsProduction}
go isokit.FetchTemplateBundleWithSuppliedFunctionMap(templateSetChannel, funcMap)
ts := <-templateSetChannel
我们将创建一个名为templateSetChannel
的通道,类型为*isokit.TemplateSet
,我们将在其中接收TemplateSet
对象。我们将创建一个包含rubyformat
和unixformat
自定义函数的函数映射。然后,我们将从isokit
包中调用FetchTemplateBundleWithSuppliedFunctionMap
函数,提供我们刚刚创建的templateSetChannel
以及funcMap
变量。
FetchTemplateBundleWithSuppliedFunctionMap
函数负责从服务器端获取模板包项映射,并使用此映射组装模板集。除此之外,接收到的TemplateSet
对象的Funcs
属性将使用funcMap
变量填充,确保自定义函数对模板集中的所有模板都是可访问的。成功调用此方法后,模板集将通过templateSetChannel
发送。最后,我们将使用从templateSetChannel
接收到的*isokit.TemplateSet
值来分配ts
变量。
我们将创建Env
对象的新实例,我们将在整个客户端应用程序中使用它:
env := common.Env{}
然后,我们将TemplateSet
属性填充为我们刚刚创建的Env
实例:
env.TemplateSet = ts
为了避免每次需要访问Window
对象时都要输入dom.GetWindow()
,以及访问Document
对象时都要输入dom.GetWindow().Document()
,我们可以将env
对象的Window
和Document
属性填充为它们各自的值:
env.Window = dom.GetWindow()
env.Document = dom.GetWindow().Document()
当用户点击网站的不同部分时,我们将动态替换主要内容div
容器的内容。我们将填充env
对象的PrimaryContent
属性以保存主要内容div
容器:
env.PrimaryContent = env.Document.GetElementByID("primaryContent")
当我们需要从路由处理程序函数内访问此div
容器时,这将非常方便。它使我们免于每次在路由处理程序中需要时执行 DOM 操作来检索此元素。
我们将调用registerRoutes
函数,并将env
对象的引用作为函数的唯一输入参数提供给它:
registerRoutes(&env)
此函数负责注册所有客户端路由及其关联的处理程序函数。
我们将调用initializePage
函数,并将env
对象的引用提供给它:
initializePage(&env)
此函数负责为给定的客户端路由初始化网页上的交互元素和组件。
在registerRoutes
函数中,有两个特别感兴趣的任务:
-
创建客户端路由
-
注册客户端路由
创建客户端路由
首先,我们将创建isokit
路由对象的新实例,并将其分配给r
变量:
r := isokit.NewRouter()
注册客户端路由
第二行代码注册了客户端/about
路由,以及与之关联的客户端处理函数AboutHandler
,来自handlers
包。
r.Handle("/about", handlers.AboutHandler(env))
我们将在第五章中更详细地介绍registerRoutes
函数的其余部分,端到端路由。
初始化网页上的交互元素
initializePage
函数将在网页首次加载时调用一次。它的作用是初始化使用户能够与客户端 Web 应用程序进行交互的功能。这将是给定网页的相应initialize
函数,负责初始化事件处理程序和可重用组件(齿轮)。
在initializePage
函数内部,我们将从窗口位置对象的PathName
属性中提取routeName
;http://localhost:8080/about
URL 的路由名称将是"about"
。
l := strings.Split(env.Window.Location().Pathname, "/")
routeName := l[1]
if routeName == "" {
routeName = "index"
}
如果没有可用的routeName
,我们将把值赋给"index"
,即主页的路由名称。
我们将在routeName
上声明一个switch
块,以下是处理routeName
等于"about"
的情况的相应case
语句:
case "about":
handlers.InitializeAboutPage(env)
关于页面的指定initialize
函数是InitializeAboutPage
函数,它在handlers
包中定义。此函数负责在About
页面上启用用户交互。
既然我们已经在客户端设置了模板集,并注册了/about
路由,让我们继续看看客户端的About
页面处理函数。
从客户端渲染关于页面
以下是在client/handlers
文件夹中找到的about.go
源文件中AboutHandler
函数的定义:
func AboutHandler(env *common.Env) isokit.Handler {
return isokit.HandlerFunc(func(ctx context.Context) {
gopherTeamChannel := make(chan []*models.Gopher)
go FetchGopherTeam(gopherTeamChannel)
gophers := <-gopherTeamChannel
templateData := templatedata.About{PageTitle: "About", Gophers: gophers}
env.TemplateSet.Render("about_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
InitializeAboutPage(env)
})
}
我们首先创建一个名为gopherTeamChannel
的通道,我们将使用它来检索Gopher
实例的切片。我们将调用FetchGopherTeam
函数作为一个 goroutine,并将gopherTeamChannel
作为函数的唯一输入参数。
然后,我们将接收从gopherTeamChannel
返回的值,并将其赋给gophers
变量。
我们将声明并初始化templateData
变量,即about_content
模板的数据对象,其类型为templatedata.About
。我们将设置模板数据对象的PageTitle
属性,并使用我们刚刚创建的gophers
变量填充Gophers
属性。
我们将在模板集对象上调用Render
方法来渲染关于模板。我们传递给函数的第一个参数是模板的名称,即对应于关于内容模板的about_content
。在服务器端,我们使用了about_page
模板,因为我们还需要生成整个网页布局。由于我们是从客户端操作,这不是必要的,因为我们只需要用about_content
模板的渲染内容填充主要内容区域。
Render
方法的第二个和最后一个参数是isokit.RenderParams
类型的渲染参数。让我们检查一下在RenderParams
对象中设置的每个属性。
Data
属性指定模板将使用的模板数据对象。
Disposition
属性指定将相对于相关目标元素呈现的模板内容的处理方式。isokit.PlacementReplaceInnerContents
处理方式指示渲染器替换相关目标元素的内部内容。
Element
属性指定渲染器应该考虑的相关目标元素。我们将把模板的渲染内容放在主要内容div
容器中,因此我们将env.PrimaryContent
分配给Element
属性。
PageTitle
属性指定应该使用的网页标题。模板数据对象的PageTitle
属性在客户端端和服务器端一样重要,因为客户端渲染器有能力更改网页的标题。
最后,我们调用InitializeAboutPage
函数来启用需要用户交互的功能。如果“关于”页面是网站上渲染的第一个页面(从服务器端),则InitalizeAboutPage
函数将从client.go
源文件中的initializePage
函数中调用。如果我们随后点击导航栏上的“关于”链接而着陆在“关于”页面上,则请求将由客户端的AboutHandler
函数处理,并通过调用InitializeAboutPage
函数来启用需要用户交互的功能。
在“关于”页面的用户交互方面,我们只有一个可重用的组件,用于以人类可读的格式显示时间。我们不设置任何事件处理程序,因为在这个特定页面上没有任何按钮或用户输入字段。在这种情况下,我们将暂时跳过InitializeAboutPage
函数,并在第九章“齿轮-可重用组件”中返回它。我们将在第五章“端到端路由”中向您展示为特定网页设置事件处理程序的initialize
函数的示例。
FetchGopherTeam
函数负责对/restapi/get-gopher-team
Rest API 端点进行 XHR 调用,并检索出出现在“关于”页面上的地鼠列表。让我们来看看FetchGopherTeam
函数:
func FetchGopherTeam(gopherTeamChannel chan []*models.Gopher) {
data, err := xhr.Send("GET", "/restapi/get-gopher-team", nil)
if err != nil {
println("Encountered error: ", err)
}
var gophers []*models.Gopher
json.NewDecoder(strings.NewReader(string(data))).Decode(&gophers)
gopherTeamChannel <- gophers
}
我们通过从xhr
包中调用Send
函数来进行 XHR 调用,并指定我们将使用GET
HTTP 方法进行调用。我们还指定调用将被发往/restapi/get-gopher-team
端点。Send
函数的最后一个参数是nil
,因为我们不会从客户端向服务器发送任何数据。
如果 XHR 调用成功,服务器将以 JSON 编码的数据作出响应,表示地鼠的一个切片。我们将创建一个新的 JSON 解码器,将服务器的响应解码为gophers
变量。最后,我们将通过gopherTeamChannel
发送gophers
切片。
现在是时候检查一下负责处理我们的 XHR 调用以获取 IGWEB 团队地鼠的 Rest API 端点了。
Gopher 团队 Rest API 端点
/restapi/get-gopher-team
路由由endpoints
文件夹中的gopherteam.go
源文件中定义的GetGopherTeamEndpoint
函数处理:
func GetGopherTeamEndpoint(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gophers := env.DB.GetGopherTeam()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(gophers)
})
}
我们将声明并初始化gophers
变量,以调用env.DB
的 Redis 数据存储对象的GetGopherTeam
方法返回的值。然后,我们将设置一个标头,指示服务器将发送 JSON 响应。最后,我们将使用 JSON 编码器将地鼠的切片编码为 JSON 数据。数据通过http.ResponseWriter
w
发送到客户端。
我们现在已经设置好了从客户端渲染“关于”页面所需的一切。我们可以通过在导航栏上点击“关于”链接来查看我们的客户端渲染的“关于”页面。以下是客户端渲染的“关于”页面的样子:
图 4.6 从客户端渲染的“关于”页面
您能看出服务器端渲染的“关于”页面和客户端渲染的页面之间有什么区别吗?你不应该看到任何区别,因为它们实际上是相同的!我们通过简单地在主要内容区域div
容器中渲染“关于”页面内容,避免了用户必须观看完整页面重新加载。
看一下显示每个地鼠的开始时间。这里呈现的第一个时间遵循 Go 的默认时间格式。第二个时间是使用 Ruby 日期格式的时间。请记住,我们使用自定义函数以这种格式呈现时间。第三个开始时间以人类可读的格式显示。它使用可重用组件来格式化时间,我们将在第九章中介绍,齿轮-可重用组件。
现在我们知道如何同构渲染模板,我们将按照相同的流程处理 IGWEB 上的其他页面。
总结
在本章中,我们向您介绍了 Web 模板系统以及构成它的各个组件-模板引擎、模板数据对象和模板。我们探讨了 Web 模板系统的每个组件的目的,并为 IGWEB 设计了 Web 页面结构。我们涵盖了三种模板类别:布局模板、部分模板和常规模板。然后,我们将 IGWEB 页面结构的每个部分实现为模板。我们向您展示了如何定义自定义模板函数,以便在各种环境中重用。
然后,我们向您介绍了同构模板渲染的概念。我们确定了标准模板渲染的局限性,基于从文件系统加载模板文件,并介绍了由isokit
包提供的内存模板集,以同构方式渲染模板。然后,我们演示了如何在服务器端和客户端上设置模板集并渲染“关于”页面。
在本章中,我们简要介绍了路由,只是为了理解如何在服务器端和客户端注册/about
路由及其关联的处理程序函数。在第五章中,端到端路由,我们将更详细地探讨端到端应用程序路由。
第五章:端到端路由
端到端应用程序路由是使我们能够利用经典 Web 应用程序架构和单页面应用程序架构的优势的魔力。在实现现代 Web 应用程序时,我们必须在满足两个不同受众(人类和机器)的需求之间取得平衡。
首先让我们从人类用户的角度考虑体验。当人类用户直接访问我们在上一章演示的“关于”页面时,模板渲染首先在服务器端执行。这为人类用户提供了一个初始页面加载,因为网页内容是立即可用的,所以被认为是快速的。这是经典的 Web 应用程序架构的特点。对于用户与网站的后续交互采取了不同的方法。当用户从导航菜单点击“关于”页面的链接时,模板渲染在客户端执行,无需进行完整的页面重新加载,从而提供更流畅和流畅的用户体验。这是单页面应用程序架构的特点。
机器用户包括定期访问网站的各种搜索引擎爬虫。正如您在第一章中学到的,使用 Go 构建同构 Web 应用程序,单页面应用程序主要不利于搜索引擎,因为绝大多数搜索引擎爬虫没有智能来遍历它们。传统的搜索引擎爬虫习惯于解析已经呈现的格式良好的 HTML 标记。训练这些爬虫解析用于实现单页面应用程序架构的 JavaScript 要困难得多。如果我们希望获得更大的搜索引擎可发现性,我们必须满足我们的机器受众的需求。
在实现 IGWEB 的产品相关页面时,我们将学习如何在本章中实现这一目标,即在满足这两个不同受众的需求之间取得平衡。
在本章中,我们将涵盖以下主题:
-
路由视角
-
产品相关页面的设计
-
实现与产品相关的模板
-
建模产品数据
-
访问产品数据
-
使用 Gorilla Mux 注册服务器端路由
-
服务器端处理程序函数
-
使用 isokit 路由器注册客户端路由
-
客户端处理程序函数
-
Rest API 端点
路由视角
让我们从服务器端和客户端的角度考虑 Isomorphic Go Web 应用程序中的路由工作原理。请记住,我们的目标是利用端到端路由为机器用户提供网页内容访问,并为人类用户提供增强的用户体验。
服务器端路由
图 5.1描述了 Isomorphic Go 应用程序中的初始页面加载,实现了经典的 Web 应用程序架构。客户端可以是通过提供 URL 访问网站的 Web 浏览器或机器(机器)。URL 包含客户端正在访问的路由。例如,/products
路由将提供产品列表页面。/product-detail/swiss-army-knife
路由将提供网站上销售的瑞士军刀产品的产品详细页面。请求路由器负责将路由映射到其指定的路由处理程序函数。我们将在服务器端使用的请求路由器是 Gorilla Mux 路由器,它在mux
包中可用:
图 5.1:Isomorphic Go 应用程序中的初始页面加载
路由处理程序负责服务特定路由。它包含一组逻辑,用于执行给定路由的任务。例如,/products
路由的路由处理程序负责获取要显示的产品,从相关模板中呈现产品列表网页,并将响应发送回客户端。来自 Web 服务器的响应是一个 HTML 文档,其中包含与关联的 CSS 和 JavaScript 源文件的链接。返回的 Web 页面也可能包含内联的 CSS 或 JavaScript 部分。
请注意,尽管图表描绘了 Golang 在 Web 浏览器内运行,但实际上在 Web 浏览器内运行的是 Go 程序的 JavaScript 表示(使用 GopherJS 转译)。当客户端接收到服务器响应时,Web 页面将在 Web 浏览器内的用户界面中呈现。
客户端路由
图 5.2描述了从 Isomorphic Go 应用程序的客户端角度实现单页面应用程序架构的路由。
在图 5.1中,客户端只是简单地呈现 Web 页面服务器响应的角色。现在,除了显示呈现的 Web 页面外,客户端还包含请求路由器、路由处理程序和应用程序业务逻辑。
我们将使用isokit
包中的 isokit 路由器执行客户端路由。客户端路由器的工作方式与服务器端路由器类似,只是不是评估 HTTP 请求,而是拦截在网页上定义的超链接的点击,并将其路由到客户端自身定义的特定路由处理程序。服务特定路由的客户端路由处理程序通过 Rest API 端点与服务器交互,通过发出 XHR 请求访问。来自 Web 服务器的响应是可以采用各种格式的数据,如 JSON、XML、纯文本和 HTML 片段,甚至是 Gob 编码的数据。在本章中,我们将使用 JSON 作为数据交换的手段。应用程序的业务逻辑将决定数据的处理方式,并且可以在用户界面中显示。此时,所有渲染操作都可以在客户端上进行,从而可以防止整个页面重新加载:
图 5.2:端到端路由包括两端的路由器
产品相关页面的设计
IGWEB 的产品相关页面包括产品列表页面和产品详细页面。产品页面,也可以称为产品列表页面,将显示用户可以从网站购买的商品列表。如图 5.3所示的线框图,每个产品都包含产品的缩略图,产品价格,产品名称,产品的简要描述,以及将产品添加到购物车的按钮。点击产品图片将带用户进入给定产品的产品详细页面。访问产品列表页面的路由是/products
:
图 5.3:产品页面的线框设计
产品详细页面包含有关单个产品的信息。如图 5.4所示的线框设计,产品详细页面包括产品的全尺寸图像、产品名称、产品价格、产品的长描述以及将产品添加到购物车的按钮。访问产品详细页面的路由是/product-detail/{productTitle}
。{productTitle}
是产品的SEO(搜索引擎优化)友好名称,例如,瑞士军刀产品的{productTitle}
值将是"swiss-army-knife"
。通过在/product-detail
路由中定义 SEO 友好的产品名称,我们使搜索引擎机器人更容易索引网站,并从产品详细 URL 集合中推导出语义含义。事实上,搜索引擎友好的 URL 被称为语义 URL。
图 5.4:产品详细页面的线框设计
实现与产品相关的模板
实现与产品相关的模板包括实现产品列表页面的模板和产品详细页面的模板。产品列表页面如图 5.3所示,产品详细页面如图 5.4所示。我们将实现模板来实现这些线框设计。
实现产品列表页面的模板
让我们来看看shared/templates
目录中找到的products_page.tmpl
源文件:
{{ define "pagecontent" }}
{{template "products_content" . }}
{{end}}
{{template "layouts/webpage_layout" . }}
这是产品列表页面的页面模板。这个模板的主要目的是呈现products_content
模板的内容,并将其放置在网页布局中。
让我们来看看shared/templates
目录中找到的products_content.tmpl
源文件:
<h1>Products</h1>
<div id="productsContainer">
{{if .Products}}
{{range .Products}}
<div class="productCard">
<a href="{{.Route}}">
<div class="pricebox"><span>${{.Price}}</span></div>
<div class="productCardImageContainer">
<img src="img/{{.ThumbnailPreviewURI}}">
</div>
</a>
<div class="productContainer">
<h3><b>{{.Name}}</b></h3>
<p>{{.Description}}</p>
<div class="pure-controls">
<button class="addToCartButton pure-button pure-button-primary" data-sku="{{.SKU}}">Add To Cart</button>
</div>
</div>
</div>
{{end}}
{{else}}
<span>If you're not seeing any products listed here, you probably need to load the sample data set into your Redis instance. You can do so by <a target="_blank" href="/config/load-sample-data">clicking this link</a>.</span>
{{end}}
</div>
这是产品列表页面的内容模板。这个模板的目的是显示所有可供销售的产品。在productsContainer
div
元素内,我们指定了一个{{if}}
条件,检查是否有产品可供显示。如果有产品可用,我们使用{{range}}
模板动作来遍历所有可用的Product
对象,并生成每个产品卡所需的 HTML 标记。我们定义了一个锚(<a>
)标签,使图像可点击,这样用户可以直接点击产品图像进入产品详细页面。我们还定义了一个按钮,将产品添加到购物车中。
如果没有产品可供显示,我们会到达{{else}}
条件,并放置一个有用的消息,指示需要将产品从样本数据集加载到 Redis 数据库实例中。为了方便读者,我们提供了一个可以点击的超链接,点击后将样本数据填充到 Redis 实例中。
实现产品详细页面的模板
让我们来看看shared/templates
目录中找到的product_detail_page.tmpl
源文件:
{{ define "pagecontent" }}
{{template "product_detail_content" . }}
{{end}}
{{template "layouts/webpage_layout" . }}
这是产品详细页面的页面模板。其主要目的是呈现product_detail_content
模板的内容,并将其放置在网页布局中。
让我们来看看shared/templates
目录中找到的product_detail_content.tmpl
源文件:
<div class="productDetailContainer">
<div class="productDetailImageContainer">
<img src="img/{{.Product.ImagePreviewURI}}">
</div>
<div class="productDetailHeading">
<h1>{{.Product.Name}}</h1>
</div>
<div class="productDetailPrice">
<span>${{.Product.Price}}</span>
</div>
<div class="productSummaryDetail">
{{.Product.SummaryDetail}}
</div>
<div class="pure-controls">
<button class="addToCartButton pure-button pure-button-primary" data-sku="{{.Product.SKU}}">Add To Cart</button>
</div>
</div>
在这个模板中,我们定义了呈现产品详细页面的产品详细容器所需的 HTML 标记。我们呈现产品图像以及产品名称、产品价格和产品的详细摘要。最后,我们声明了一个按钮,将产品添加到购物车中。
对产品数据进行建模
我们在shared/models/product.go
源文件中定义了Product
结构来对产品数据进行建模。
package models
type Product struct {
SKU string
Name string
Description string
ThumbnailPreviewURI string
ImagePreviewURI string
Price float64
Route string
SummaryDetail string
Quantity int
}
SKU
字段代表产品的库存单位(SKU
),这是代表产品的唯一标识。在提供的样本数据集中,我们使用递增的整数值,但是这个字段是string
类型的,以便将来可以容纳包含字母数字的 SKU,以实现可扩展性。Name
字段代表产品的名称。Description
字段代表将包含在产品列表页面中的简短描述。ThumbnailPreviewURI
字段提供产品缩略图的路径。Price
字段代表产品的价格,类型为float64
。Route
字段是给定产品的服务器相对路径到产品详细页面。SummaryDetail
字段代表产品的长描述,将显示在产品详细页面中。最后,Quantity
字段是int
类型,代表目前在购物车中的特定产品数量。在下一章中,当我们实现购物车功能时,我们将使用这个字段。
访问产品数据
对于我们的产品数据访问需求,我们在 Redis 数据存储中定义了两种方法。GetProducts
方法将返回一个产品切片,并满足产品列表页面的数据需求。GetProductDetail
方法将返回给定产品的配置信息,满足产品详细页面的数据需求。
从数据存储中检索产品
让我们来看看在common/datastore/redis.go
源文件中定义的GetProducts
方法:
func (r *RedisDatastore) GetProducts() []*models.Product {
registryKey := "product-registry"
exists, err := r.Cmd("EXISTS", registryKey).Int()
if err != nil {
log.Println("Encountered error: ", err)
return nil
} else if exists == 0 {
return nil
}
var productKeys []string
jsonData, err := r.Cmd("GET", registryKey).Str()
if err != nil {
log.Print("Encountered error when attempting to fetch product registry data from Redis instance: ", err)
return nil
}
if err := json.Unmarshal([]byte(jsonData), &productKeys); err != nil {
log.Print("Encountered error when attempting to unmarshal JSON product registry data: ", err)
return nil
}
products := make([]*models.Product, 0)
for i := 0; i < len(productKeys); i++ {
productTitle := strings.Replace(productKeys[i], "/product-detail/", "", -1)
product := r.GetProductDetail(productTitle)
products = append(products, product)
}
return products
}
在这里,我们首先检查 Redis 数据存储中是否存在产品注册键"product-registry"
。如果存在,我们声明一个名为productKeys
的字符串切片,其中包含要显示在产品列表页面上的所有产品的键。我们在 Redis 数据存储对象r
上使用Cmd
方法来发出 Redis 的"GET"
命令,用于检索给定键的记录。我们将registryKey
作为方法的第二个参数。最后,我们将方法调用链接到.Str()
方法,将输出转换为字符串类型。
从数据存储中检索产品详细信息
Redis 数据存储中的产品注册数据是表示字符串切片的 JSON 数据。我们使用json
包中的Unmarshal
函数将 JSON 编码的数据解码为productKeys
变量。现在,我们已经获得了应该显示在产品列表页面上的所有产品键,是时候为每个键创建一个产品实例了。我们首先声明将成为产品切片的products
变量。我们遍历产品键并得出productTitle
值,这是产品的 SEO 友好名称。我们将productTitle
变量提供给 Redis 数据存储的GetProductDetail
方法,以获取给定产品标题的产品。我们将获取的产品赋给product
变量,并将其追加到products
切片中。一旦for
循环结束,我们将收集到应该显示在产品列表页面上的所有产品。最后,我们返回products
切片。
让我们来看看在common/datastore/redis.go
源文件中定义的GetProductDetail
方法:
func (r *RedisDatastore) GetProductDetail(productTitle string) *models.Product {
productKey := "/product-detail/" + productTitle
exists, err := r.Cmd("EXISTS", productKey).Int()
if err != nil {
log.Println("Encountered error: ", err)
return nil
} else if exists == 0 {
return nil
}
var p models.Product
jsonData, err := r.Cmd("GET", productKey).Str()
if err != nil {
log.Print("Encountered error when attempting to fetch product data from Redis instance: ", err)
return nil
}
if err := json.Unmarshal([]byte(jsonData), &p); err != nil {
log.Print("Encountered error when attempting to unmarshal JSON product data: ", err)
return nil
}
return &p
}
我们将productKey
变量声明为string
类型,并赋予产品详细页面的路由值。这涉及将"/product-detail"
字符串与给定产品的productTitle
变量连接起来。我们检查产品键是否存在于 Redis 数据存储中。如果不存在,我们从方法中返回;如果存在,我们继续声明p
变量为Product
类型。这将是函数将返回的变量。Redis 数据存储中存储的产品数据是Product
对象的 JSON 表示。我们将 JSON 编码的数据解码为p
变量。如果我们没有遇到任何错误,我们将返回p
,它代表了请求的productTitle
变量的Product
对象,该变量被指定为GetProductDetail
方法的输入参数。
到目前为止,我们已经满足了在/products
路由上显示产品列表和在/product-detail/{productTitle}
路由上显示产品概要页面的数据需求。现在是时候注册与产品相关页面的服务器端路由了。
使用 Gorilla Mux 注册服务器端路由
我们将使用 Gorilla Mux 路由器来处理服务器端应用程序的路由需求。这个路由器非常灵活,因为它不仅可以处理简单的路由,比如/products
,还可以处理带有嵌入变量的路由。回想一下,/product-detail
路由包含嵌入的{productTitle}
变量。
我们将首先创建一个 Gorilla Mux 路由器的新实例,并将其分配给r
变量,如下所示:
r := mux.NewRouter()
以下是在igweb.go
源文件中定义的registerRoutes
函数中的代码部分,我们在这里注册路由以及它们关联的处理函数:
r.Handle("/", handlers.IndexHandler(env)).Methods("GET")
r.Handle("/index", handlers.IndexHandler(env)).Methods("GET")
r.Handle("/products", handlers.ProductsHandler(env)).Methods("GET")
r.Handle("/product-detail/{productTitle}", handlers.ProductDetailHandler(env)).Methods("GET")
r.Handle("/about", handlers.AboutHandler(env)).Methods("GET")
r.Handle("/contact", handlers.ContactHandler(env)).Methods("GET", "POST")
我们使用Handle
方法将路由与负责处理该路由的处理函数关联起来。例如,当遇到/products
路由时,它将由handlers
包中定义的ProductsHandler
函数处理。ProductsHandler
函数将负责从数据存储中获取产品,使用产品记录从模板中呈现产品列表页面,并将网页响应发送回网页客户端。类似地,/product-detail/{productTitle}
路由将由ProductDetailHandler
函数处理。这个处理函数将负责获取单个产品的产品记录,使用产品记录从模板中呈现产品详细页面,并将网页响应发送回网页客户端。
服务器端处理函数
现在我们已经为与产品相关的页面注册了服务器端路由,是时候来检查负责处理这些路由的服务器端处理函数了。
产品列表页面的处理函数
让我们来看一下handlers
目录中找到的products.go
源文件:
package handlers
import (
"net/http"
"github.com/EngineerKamesh/igb/igweb/common"
"github.com/EngineerKamesh/igb/igweb/shared/templatedata"
"github.com/isomorphicgo/isokit"
)
func ProductsHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
products := env.DB.GetProducts()
templateData := &templatedata.Products{PageTitle: "Products", Products: products}
env.TemplateSet.Render("products_page", &isokit.RenderParams{Writer: w, Data: templateData})
})
}
在这里,我们通过在 Redis 数据存储对象env.DB
上调用GetProducts
方法来获取产品切片,该产品切片在产品页面上展示。我们声明了templatedata.Products
类型的templateData
变量,它代表将传递给模板引擎的数据对象,以及products_page
模板,以渲染产品页面。PageTitle
字段代表网页标题,Products
字段是要在产品页面上显示的产品切片。
在ProductsHandler
函数内部,我们调用数据存储对象的GetProducts
方法,从数据存储中获取可供显示的产品。然后,我们创建一个模板数据实例,其PageTitle
字段值为"Products"
,并将从数据存储中获取的产品分配给Products
字段。最后,我们从模板集中渲染products_page
模板。关于我们传递给env.TemplateSet
对象的Render
方法的RenderParams
对象,我们将Writer
属性设置为w
变量,即http.ResponseWriter
,并将Data
属性设置为templateData
变量,即将提供给模板的数据对象。此时,渲染的网页将作为服务器响应发送回 Web 客户端。
图 5.5 显示了在访问/products
路由后生成的产品页面,方法是访问以下链接:http://localhost:8080/products
:
图 5.5:产品页面
现在我们能够显示产品页面,让我们来看一下产品详细页面的处理函数。
产品详细页面的处理函数
让我们检查handlers
目录中找到的productdetail.go
源文件:
package handlers
import (
"net/http"
"github.com/EngineerKamesh/igb/igweb/common"
"github.com/EngineerKamesh/igb/igweb/shared/templatedata"
"github.com/gorilla/mux"
"github.com/isomorphicgo/isokit"
)
func ProductDetailHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
productTitle := vars["productTitle"]
product := env.DB.GetProductDetail(productTitle)
templateData := &templatedata.ProductDetail{PageTitle: product.Name, Product: product}
env.TemplateSet.Render("product_detail_page", &isokit.RenderParams{Writer: w, Data: templateData})
})
}
这是处理/product/{productTitle}
路由的处理函数。请记住,这是嵌入变量的路由。在ProductDetailHandler
函数内部,我们首先通过调用mux
包的Vars
函数来收集路由中定义的变量。我们将r
,即http.Request
的指针,作为Vars
函数的输入参数。该函数的结果是map[string]string
类型的映射,其中键是路由中变量的名称,值是该特定变量的值。例如,如果我们访问/product-detail/swiss-army-knife
路由,键将是"productTitle"
,值将是"swiss-army-knife"
。
我们获取路由中提供的productTitle
变量的值,并将其赋给productTitle
变量。然后,我们通过向数据存储对象的GetProductDetail
方法提供productTitle
变量来获取产品对象。然后,我们设置我们的模板数据对象,设置页面标题和产品记录的字段。最后,我们在模板集上调用渲染方法,指示我们要渲染product_detail_page
模板。我们将http
响应写入对象和模板数据对象分配给渲染params
对象的相应字段,该对象作为模板集的渲染方法的第二个参数传入。
此时,我们已经准备好渲染产品详细页面所需的一切。让我们访问http://localhost:8080/products/swiss-army-knife
上的瑞士军刀产品详细页面。以下是在 Web 浏览器中呈现的产品详细页面:
图 5.6:瑞士军刀的产品详细页面
现在我们已经使/products
和/product-title/{productTitle}
路由对人类和机器都可用,并且我们已经实现了经典的 Web 应用程序架构。我们的机器用户(搜索引擎机器人)将会满意,因为他们可以轻松地索引产品列表页面上所有产品的链接,并且可以轻松解析每个产品详细页面上的 HTML 标记。
然而,我们还没有完全满足我们的人类观众。您可能已经注意到,从产品列表页面点击单个产品会导致整个页面重新加载。在短暂的瞬间,屏幕可能会在离开一个页面并在 Web 浏览器中呈现下一个页面的过渡中变白。当我们从产品详细页面点击导航菜单中的产品链接返回到产品列表页面时,同样会发生完整的页面重新加载。我们可以通过在初始页面加载后实现单页面架构来增强用户在网页之间的过渡体验。为了做到这一点,我们需要定义客户端路由以及它们相关的客户端路由处理程序函数。
使用 isokit 路由器注册客户端路由
在客户端,我们使用 isokit 路由器来处理路由。isokit 路由器通过拦截超链接的单击事件并检查超链接是否包含在其路由表中定义的路由来工作。
我们可以使用 isokit 路由器对象的Handle
方法在路由表中注册路由。Handle
方法接受两个参数——第一个参数是路由,第二个参数是应该服务该路由的处理程序函数。请注意,声明和注册路由的代码与服务器端的 Gorilla Mux 路由器非常相似。由于这种相似性,使用 isokit 路由器在客户端注册路由是直接的,感觉像是第二天性。
以下是在client
文件夹中找到的client.go
源文件中定义的registerRoutes
函数的代码部分,该函数负责注册路由:
r := isokit.NewRouter()
r.Handle("/index", handlers.IndexHandler(env))
r.Handle("/products", handlers.ProductsHandler(env))
r.Handle("/product-detail/{productTitle}", handlers.ProductDetailHandler(env))
r.Handle("/about", handlers.AboutHandler(env))
r.Handle("/contact", handlers.ContactHandler(env))
r.Listen()
env.Router = r
在这里,我们首先通过从isokit
包中调用NewRouter
函数创建一个新的 isokit 路由器,并将其分配给r
变量。我们已经为产品列表页面定义了/products
路由,以及为产品详细页面定义了/product-data/{productTitle}
路由。在定义所有路由之后,我们调用路由器对象r
的Listen
方法。Listen
方法负责为所有超链接添加事件侦听器,以侦听单击事件。在路由器的路由表中定义的链接将在单击事件发生时被拦截,并且它们相关的客户端路由处理程序函数将为它们提供服务。最后,我们将r
路由器分配给env
对象的Router
字段,以便我们可以在客户端 Web 应用程序中访问路由器。
客户端处理程序函数
现在我们已经在客户端注册了与产品相关的页面的路由,让我们来看看负责服务这些路由的客户端路由处理程序函数。
产品列表页面的处理程序函数
让我们来看看client/handlers
目录中products.go
源文件中的ProductsHandler
函数:
func ProductsHandler(env *common.Env) isokit.Handler {
return isokit.HandlerFunc(func(ctx context.Context) {
productsChannel := make(chan []*models.Product)
go FetchProducts(productsChannel)
products := <-productsChannel
templateData := &templatedata.Products{PageTitle: "Products", Products: products}
env.TemplateSet.Render("products_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
InitializeProductsPage(env)
env.Router.RegisterLinks("#primaryContent a")
})
}
回想一下,在图 5.2中描述的图表中,客户端 Web 应用通过对 Rest API 端点的 XHR 调用访问服务器端功能。在这里,我们创建productsChannel
通道来检索Product
对象的切片。我们调用FetchProducts
函数,该函数将对服务器上负责检索要在产品页面上显示的可用产品列表的 Rest API 端点进行 XHR 调用。请注意,我们将FetchProducts
函数作为 goroutine 调用。我们必须这样做以确保 XHR 调用不会阻塞。我们将productsChannel
通道作为FetchProducts
函数的唯一输入参数。然后,我们通过productsChannel
通道检索产品列表并将其分配给products
变量。
我们创建一个新的模板数据对象实例templateData
,并设置PageTitle
和Products
字段的相应字段。之后,我们在env.TemplateSet
对象上调用Render
方法,指定我们要渲染products_content
模板。在我们提供给Render
函数的RenderParams
对象中,我们使用模板数据对象templateData
设置Data
字段。我们将Disposition
字段设置为isokit.PlacementReplaceInnerContents
,以指定渲染的位置应替换相关元素的内部 HTML 内容。我们将Element
字段设置为主要内容div
容器,其中主页面内容被渲染。我们调用InitializeProductsEventHandlers
函数来设置产品页面中找到的事件处理程序。对于产品页面,唯一需要事件处理程序的 DOM 元素是“添加到购物车”按钮,我们将在第六章 同构交接中介绍。
就客户端路由而言,ProductsHandler
函数中的最后一行代码是最重要的一行代码。当模板渲染器渲染每个产品卡时,我们需要拦截每个产品项的链接。我们可以通过提供一个查询选择器来告诉 isokit 路由器拦截这些链接,该查询选择器将定位主要内容div
容器中的链接。我们通过调用 isokit 路由器对象的RegisterLinks
方法并指定查询选择器应为"#primaryContent a"
来实现这一点。这将确保拦截所有产品项的链接,并且当我们单击产品项时,客户端路由处理程序将启动并服务请求,而不是执行完整的页面重新加载以到达/product-detail/{productTitle}
路由。
获取产品列表
现在我们已经看到了客户端路由处理程序函数的工作原理,让我们来看看FetchProducts
函数,该函数用于对服务器进行 XHR 调用并收集要在页面上显示的产品列表:
func FetchProducts(productsChannel chan []*models.Product) {
data, err := xhr.Send("GET", "/restapi/get-products", nil)
if err != nil {
println("Encountered error: ", err)
return
}
var products []*models.Product
json.NewDecoder(strings.NewReader(string(data))).Decode(&products)
productsChannel <- products
}
在这里,我们使用xhr
包来对服务器进行 XHR 调用。我们从xhr
包中调用Send
函数,并指定我们的请求将使用GET
方法,并且我们将对/restapi/get-products
端点进行请求。对于函数的第三个参数,我们传递了一个值nil
,以指示我们在 XHR 调用中不发送数据。如果 XHR 调用成功,我们将从服务器接收 JSON 数据,该数据将表示Product
对象的切片。我们创建一个新的 JSON 解码器来解码数据并将其存储在products
变量中,然后将其发送到productsChannel
。我们将在用于服务此 XHR 调用的 Rest API 端点部分中检查服务此 XHR 调用的 Rest API 端点。
此时,我们的 Web 应用程序已经实现了能够在与网站的后续交互中渲染产品页面而不引起完整页面重新加载的目标。例如,如果我们访问http://localhost:8080/about
上的关于页面,初始页面加载将在服务器端进行。如果我们通过单击导航菜单中的产品链接来启动后续交互,客户端路由将启动,并且产品页面将加载,而不会发生完整的页面重新加载。
在验证客户端路由功能部分,我们将向您展示如何使用 Web 浏览器的检查器验证客户端路由是否正常运行。现在是时候实现产品详细页面的客户端路由处理程序了。
产品详细页面的处理程序函数
让我们来看看client/handlers
目录中的productdetail.go
源文件中定义的ProductDetailHandler
函数:
func ProductDetailHandler(env *common.Env) isokit.Handler {
return isokit.HandlerFunc(func(ctx context.Context) {
routeVars := ctx.Value(isokit.RouteVarsKey("Vars")).(map[string]string)
productTitle := routeVars[`product-detail/{productTitle}`]
productChannel := make(chan *models.Product)
go FetchProductDetail(productChannel, productTitle)
product := <-productChannel
templateData := &templatedata.ProductDetail{PageTitle: product.Name, Product: product}
env.TemplateSet.Render("product_detail_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
InitializeProductDetailPage(env)
})
}
ProductDetailHandler
函数返回一个isokit.Handler
值。请注意,我们将isokit.HandlerFunc
指定为闭包,以便我们可以对我们的客户端处理程序函数执行依赖注入env
对象。请注意,isokit.HandlerFunc
的输入参数是context.Context
类型。这个上下文对象很重要,因为它包含嵌入在路由中的变量信息。通过在ctx
上下文对象上调用Value
方法,我们可以通过将"Vars"
键指定给上下文对象来获取路由变量。请注意,我们执行类型断言以指定从上下文对象获取的值是map[string]string
类型。我们可以通过提供product-detail/{productTitle}
键从路由中提取productTitle
的值。productTitle
的值很重要,因为我们将在向服务器发出 XHR 调用以获取产品对象时将其作为路由变量发送。
我们创建一个产品渠道productChannel
,用于发送和接收Product
对象。我们调用FetchProductDetail
函数,提供productChannel
和productTitle
作为函数的输入参数。请注意,我们将函数作为 goroutine 调用,成功运行函数后,我们将通过productChannel
发送一个产品对象。
我们设置模板数据对象,为PageTitle
和Product
字段指定值。然后我们将页面标题设置为产品名称。完成后,我们调用模板集对象的Render
方法,并指定要渲染product_detail_content
模板。我们设置渲染参数对象的字段,填充模板数据对象、位置和模板将被渲染到的相关元素的字段,这是主要内容<div>
容器。最后,我们调用InitializeProductDetailEventHanders
函数,该函数负责设置产品详情页面的事件处理程序。这个页面唯一需要处理程序的元素是“添加到购物车”按钮,我们将在下一章中介绍。
获取产品详情
让我们来看看client/handlers
文件夹中productdetail.go
源文件中定义的FetchProductDetail
函数:
func FetchProductDetail(productChannel chan *models.Product, productTitle string) {
data, err := xhr.Send("GET", "/restapi/get-product-detail"+"/"+productTitle, nil)
if err != nil {
println("Encountered error: ", err)
println(err)
}
var product *models.Product
json.NewDecoder(strings.NewReader(string(data))).Decode(&product)
productChannel <- product
}
这个函数负责向服务器端的 Rest API 端点发出 XHR 调用,以提供产品数据。该函数接受产品渠道和产品标题作为输入参数。我们通过调用xhr
包的Send
函数来进行 XHR 调用。请注意,在函数的第二个输入参数(我们发出请求的目的地)中,我们将productTitle
变量连接到/restapi/get-product-detail
路由。因此,例如,如果我们想请求瑞士军刀的产品对象,我们将指定路由为/restapi/get-product-detail/swiss-army-knife
,在这种情况下,productTitle
变量将等于"swiss-army-knife"
。
如果 XHR 调用成功,服务器将返回 JSON 编码的产品对象。我们使用 JSON 解码器解码从服务器返回的 JSON 数据,并将product
变量设置为解码的Product
对象。最后,我们通过productChannel
传递product
。
Rest API 端点
服务器端的 Rest API 端点非常方便。它们是在幕后向客户端 Web 应用程序提供数据的手段,我们将这些数据应用到相应的模板上,以显示页面内容,而无需进行完整的页面重新加载。
现在,我们将考虑创建这些 Rest API 端点所需的内容。我们首先必须在服务器端为它们注册路由。我们将遵循本章开头为产品列表页面和产品详细页面所做的相同过程。唯一的区别是我们的处理程序函数将在endpoints
包中而不是在handlers
包中。这里的根本区别在于handlers
包包含将完整网页响应返回给 Web 客户端的处理程序函数。另一方面,endpoints
包包含将数据返回给 Web 客户端的处理程序函数,很可能是以 JSON 格式返回。
以下是igweb.go
源文件中的代码部分,我们在其中注册了我们的 Rest API 端点:
r.Handle("/restapi/get-products", endpoints.GetProductsEndpoint(env)).Methods("GET")
r.Handle("/restapi/get-product-detail/{productTitle}", endpoints.GetProductDetailEndpoint(env)).Methods("GET")
请注意,驱动客户端产品页面的数据需求的/restapi/get-products
路由由endpoints
包中的GetProductsEndpoint
函数提供服务。
同样,驱动客户端产品详细页面的/restapi/get-product-detail/{productTitle}
路由由endpoints
包中的GetProductDetailEndpoint
函数提供服务。
获取产品列表的端点
让我们来看一下端点文件夹中的products.go
源文件:
package endpoints
import (
"encoding/json"
"net/http"
"github.com/EngineerKamesh/igb/igweb/common"
)
func GetProductsEndpoint(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
products := env.DB.GetProducts()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(products)
})
}
在GetProductsEndpoint
函数内部,我们首先通过调用数据存储对象env.DB
的GetProducts
方法来获取将在客户端产品页面上显示的产品切片。然后,我们设置一个标头来指示服务器响应将以 JSON 格式返回。最后,我们使用 JSON 编码器将产品切片编码为 JSON 数据,并使用http.ResponseWriter
w
将其写出。
获取产品详细信息的端点
让我们来看一下端点文件夹中的productdetail.go
源文件:
package endpoints
import (
"encoding/json"
"net/http"
"github.com/EngineerKamesh/igb/igweb/common"
"github.com/gorilla/mux"
)
func GetProductDetailEndpoint(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
productTitle := vars["productTitle"]
products := env.DB.GetProductDetail(productTitle)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(products)
})
}
在GetProductDetailEndpoint
函数内部,我们通过调用mux
包中的Vars
函数并将路由对象r
作为唯一输入参数来获取嵌入的路由变量。然后,我们获取{productTitle}
嵌入式路由变量的值并将其分配给变量productTitle
。我们将productTitle
提供给数据存储对象env.DB
的GetProductDetail
方法,以从数据存储中检索相应的Product
对象。我们设置一个标头来指示服务器响应将以 JSON 格式返回,并使用 JSON 编码器将Product
对象编码为 JSON 数据,然后使用http.ResponseWriter
w
将其发送到 Web 客户端。
我们现在已经达到了一个重要的里程碑。我们以一种对人类和机器都友好的方式实现了与产品相关的页面。当用户最初访问产品列表页面时,通过在 Web 浏览器中输入 URL(http://localhost:8080/products
),页面在服务器端呈现,并将 Web 页面响应发送回客户端。用户能够立即看到网页,因为网页响应是预先呈现的。这种行为展现了经典 Web 应用程序架构的期望特征。
当人类用户发起后续交互时,通过单击产品项目,产品详细页面将从客户端呈现,并且用户无需经历完整页面重新加载。这种行为展现了 SPA 架构的期望特征。
机器用户(搜索引擎爬虫)也满意,因为他们可以遍历产品页面上的每个产品项目的链接并轻松索引网站,因为我们使用了语义化的 URL 以及搜索引擎爬虫可以理解的良好形式的 HTML 标记。
验证客户端路由功能
为了确保客户端路由正常运行,您可以执行以下过程:
-
在您的 Web 浏览器中访问产品页面并打开 Web 浏览器的检查器。
-
点击网络选项卡以查看网络流量,并确保过滤 XHR 调用。现在,点击产品项目以进入产品的详细页面。
-
通过点击导航菜单上的“产品”链接返回产品页面。
重复此过程多次,您应该能够看到后台进行的所有 XHR 调用。图 5.7包括此过程的屏幕截图,以验证客户端路由是否正常运行:
图 5.7:Web 控制台中的 XHR 调用确认客户端路由正常运行
总结
在本章中,我们在构建与产品相关的页面时为 IGWEB 实现了端到端的应用程序路由。我们首先使用 Gorilla Mux 路由器注册了服务器端路由。我们将每个路由与相应的服务器端路由处理程序函数关联起来,该函数将为服务器端路由提供服务。然后,我们检查了产品相关页面的服务器端路由处理程序函数的实现。
在满足了实现初始页面加载的经典 Web 应用程序架构的需求后,我们通过首先在客户端注册与产品相关的页面的路由,使用 isokit 路由器,转向了客户端。就像我们在服务器端所做的那样,我们将每个客户端路由与相应的客户端路由处理程序函数关联起来,该函数将为客户端路由提供服务。您学会了如何实现客户端路由处理程序以及如何从中对服务器端 Rest API 端点进行 XHR 调用。最后,您学会了如何创建处理 XHR 请求并向客户端返回 JSON 数据的服务器端 Rest API 端点。
与数据存储的内容驱动的可用产品列表一样,与产品相关的页面具有持久状态。在用户与网站的交互改变了给定状态的情况下,我们如何维护状态?例如,如果用户向购物车中添加商品,我们如何维护购物车的状态并在服务器端和客户端之间进行同步?您将在第六章中了解同构交接,即在服务器端和客户端之间交接状态的过程。在此过程中,我们将为网站实现购物车功能。
第六章:同构交接
在同构 Go web 应用的开发中,前两章介绍了两个关键技术。首先,您学习了如何利用内存模板集在各种环境中呈现模板。其次,您学习了如何在客户端和服务器端执行端到端路由。客户端路由是使客户端 Web 应用程序以单页面模式运行的魔法。
上述技术现在为我们提供了在客户端本身导航到网站的不同部分并在各种环境中呈现任何给定模板的能力。作为同构 Go web 应用的实施者,我们的责任是确保在客户端和服务器之间维护状态。例如,在呈现产品页面时,如果产品列表在客户端和服务器端呈现方式不同,那就没有意义。客户端需要与服务器紧密合作,以确保状态(在这种情况下是产品列表)得到维护,这就是同构交接发挥作用的地方。
同构交接是指服务器将状态交接给客户端,客户端使用传递的状态在客户端呈现网页的过程。请记住,服务器传递给客户端的状态必须包括用于呈现服务器端网页响应的完全相同的状态。同构交接本质上允许客户端无缝地在服务器端中断的地方继续进行。在本章中,我们将重新访问与产品相关的页面,以了解状态如何从服务器端维护到客户端。此外,我们还将通过为这些页面中的“添加到购物车”按钮添加事件处理程序来完成产品相关页面的实施。
IGWEB 网站的购物车功能将在本章中实施,它将允许我们考虑用户可以通过向购物车中添加和删除商品来改变购物车状态的情景。我们将使用同构交接来确保购物车的当前状态在服务器和客户端之间无缝地维护。通过正确维护购物车的状态,我们可以保证从服务器端呈现的购物车页面始终与从客户端呈现的购物车页面匹配。
在本章中,我们将涵盖以下主题:
-
同构交接程序
-
为产品相关页面实现同构交接程序
-
为购物车实现同构交接
同构交接程序
同构 Web 应用程序开发中的一个重要主题是在服务器和客户端之间共享的能力。在同构 Web 应用程序中,服务器和客户端必须协同工作,以无缝地维护应用程序中特定工作流程的状态。为了做到这一点,服务器必须与客户端共享用于在服务器端呈现 Web 页面输出的当前状态。
ERDA 策略
同构交接程序包括以下四个步骤:
-
编码
-
注册
-
解码
-
附加
我们可以使用缩写ERDA(编码-注册-解码-附加)来轻松回忆每个步骤。事实上,我们可以将实施同构交接程序的步骤统称为ERDA 策略。
通过实施同构交接程序的四个步骤,如图 6.1所示,我们可以确保状态在服务器和客户端之间成功持久化:
图 6.1:实现同构交接的 ERDA 策略
第一步,编码,涉及将代表我们希望保留到客户端的状态的数据对象编码为数据交换格式(JSON、Gob、XML 等)。随后的步骤都在客户端上执行。第二步,注册,涉及注册客户端路由及其相应的处理程序函数。第三步,解码,涉及解码从服务器检索到的编码数据,通过 Rest API 端点,并利用它在客户端呈现网页的模板。第四步,附加,涉及将任何需要的事件处理程序附加到呈现的网页上,以实现用户交互。
图 6.2描述了在服务器端和客户端上涉及的关键模块,用于实现等同手 off 过程:
图 6.2:实现等同手 off 过程的关键模块
编码步骤是在服务器端 Web 应用程序中存在的 Rest API 端点内执行的。注册步骤是在客户端 Web 应用程序中存在的路由处理程序内执行的。解码步骤是在调用客户端模板渲染器之前执行的。附加步骤是通过在客户端实现 DOM 事件处理程序来执行的。
现在我们已经介绍了 ERDA 策略中的每个步骤,让我们详细探讨每个步骤。
编码步骤
我们的目标是在客户端重新生成状态,首先要识别代表我们希望保留的状态的数据对象,以便在特定网页中保持状态。要识别对象,我们只需要查看生成渲染的网页输出的服务器端处理程序函数。例如,在产品列表页面中,Product
对象的片段将是我们希望保留到客户端的数据对象,以便客户端呈现的网页呈现相同的产品列表。
我们可以通过实现 Rest API 端点(在图 6.2中描述)将Product
对象的片段暴露给客户端。编码步骤(在图 6.1中描述)包括将Product
对象的片段编码为通用数据交换格式。对于本章,我们将使用 JSON(JavaScript 对象表示)格式对对象进行编码。客户端 Web 应用程序可以通过向 Rest API 端点发出 XHR 调用来访问编码对象。
现在编码状态对象可用,实现等同手 off 过程的其余步骤发生在客户端。
注册步骤
为了完成注册步骤(在图 6.1中描述),我们必须首先注册客户端路由及其相应的处理程序函数(在图 6.2中的路由处理程序框中描述)。例如,对于产品页面,我们将注册/products
路由及其关联的处理程序函数ProductsHandler
。当用户从导航栏点击产品链接时,点击事件将被 isokit 路由拦截,并且与处理/products
路由的处理程序函数ProductsHandler
相关联的处理程序函数将被调用。路由处理程序函数扮演着执行等同手 off 过程的最后两个步骤——解码和附加的角色。
请记住,如果用户首先通过在 Web 浏览器中输入 URL 直接访问网页而着陆在/products
路由上,服务器端处理程序函数将启动,并且产品页面将在服务器端呈现。这使我们能够立即呈现网页,为用户提供被认为是快速的页面加载。
解码步骤
在路由处理程序函数中,我们发起一个 XHR 调用到 Rest API 端点,该端点将返回编码数据,表示我们希望在客户端保持的状态。一旦获取到编码数据,我们将执行等同交接过程的第三步解码(在图 6.1中描述)。在这一步中,我们将编码数据解码回对象实例。然后利用对象实例填充模板数据对象的相应字段,传递给模板渲染器(在图 6.2中描述),以便网页可以在客户端成功渲染,与在服务器端渲染的方式相同。
附加步骤
第四步也是最后一步,附加(在图 6.1中描述),负责将事件处理程序(在图 6.2中描述)附加到渲染的网页中存在的 DOM 元素上。例如,在产品页面中,我们需要将事件处理程序附加到网页上找到的所有“添加到购物车”按钮上。当按下“添加到购物车”按钮时,相应的产品将被添加到用户的购物车中。
到目前为止,我们已经铺设了实现给定网页的等同交接过程所需的基础工作。为了巩固我们对等同交接的理解,让我们考虑两个具体的例子,在这两个例子中我们实现了该过程的所有四个步骤。首先,我们将在产品相关页面实现等同交接过程,包括产品列表页面(/products
)和产品详情页面(/product-detail/{productTitle}
)。其次,我们将为购物车页面实现等同交接过程。第二个例子将更加动态,因为用户可以改变状态,用户可以随意添加和删除购物车中的商品。这种能力允许用户对购物车的当前状态施加控制。
为产品相关页面实现等同交接
如前所述,与产品相关的页面包括产品列表页面和产品详情页面。我们将遵循 ERDA 策略,为这些页面实现等同交接过程。
为产品模型实现排序接口
在开始之前,我们将在shared/models/product.go
源文件中定义一个名为Products
的新类型,它将是Product
对象的切片:
type Products []*Product
我们将Products
类型实现sort
接口,定义以下方法:
func (p Products) Len() int { return len(p) }
func (p Products) Less(i, j int) bool { return p[i].Price < p[j].Price }
func (p Products) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
通过检查Less
方法,您将能够看到我们将按照产品价格升序(从低到高)对产品列表页面上显示的产品进行排序。
乍一看,我们可能会认为从 Redis 数据库获取的产品已经按照某种预定顺序排序。然而,如果我们希望等同交接成功,我们不能凭假设操作;我们必须凭事实操作。为了做到这一点,我们需要一个可预测的产品排序标准。
这就是为什么我们要为Products
类型实现sort
接口的额外工作,以便我们有一个可预测的标准,按照这个标准在产品列表页面上列出产品。这为我们提供了一个基准,用于验证等同交接的成功,因为我们只需要确认客户端渲染的产品列表页面与服务器端渲染的产品列表页面相同即可。确实很有帮助,我们有一个共同的、可预测的标准,产品按价格升序排序。
我们在redis.go
源文件的GetProducts
方法中添加以下行(以粗体显示)以对产品进行排序:
func (r *RedisDatastore) GetProducts() []*models.Product {
registryKey := "product-registry"
exists, err := r.Cmd("EXISTS", registryKey).Int()
if err != nil {
log.Println("Encountered error: ", err)
return nil
} else if exists == 0 {
return nil
}
var productKeys []string
jsonData, err := r.Cmd("GET", registryKey).Str()
if err != nil {
log.Print("Encountered error when attempting to fetch product registry data from Redis instance: ", err)
return nil
}
if err := json.Unmarshal([]byte(jsonData), &productKeys); err != nil {
log.Print("Encountered error when attempting to unmarshal JSON product registry data: ", err)
return nil
}
products := make(models.Products, 0)
for i := 0; i < len(productKeys); i++ {
productTitle := strings.Replace(productKeys[i], "/product-detail/", "", -1)
product := r.GetProductDetail(productTitle)
products = append(products, product)
}
sort.Sort(products)
return products
}
为产品列表页面实现等同交接
首先,我们必须实现编码步骤。为此,我们需要决定必须持久化到客户端的数据。通过检查负责渲染产品列表网页的服务器端处理函数ProductsHandler
,我们可以轻松识别必须持久化到客户端的数据:
func ProductsHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
products := env.DB.GetProducts()
templateData := &templatedata.Products{PageTitle: "Products", Products: products}
env.TemplateSet.Render("products_page", &isokit.RenderParams{Writer: w, Data: templateData})
})
}
产品列表页面负责显示产品列表,因此,必须将products
变量(加粗显示)持久化到客户端,这是Product
对象的切片。
现在我们已经确定了需要持久化到客户端以维护状态的数据,我们可以创建一个 Rest API 端点GetProductsEndpoint
,负责以 JSON 编码形式将产品切片传递给客户端:
func GetProductsEndpoint(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
products := env.DB.GetProducts()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(products)
})
}
我们在服务器端完成了实现同构交接的工作,现在是时候转向客户端了。
要实现注册步骤,我们在client.go
源文件中的registerRoutes
函数中添加以下行,以注册/products
路由及其关联的处理函数ProductsHandler
:
r.Handle("/products", handlers.ProductsHandler(env))
解码和附加步骤在ProductsHandler
函数内执行:
func ProductsHandler(env *common.Env) isokit.Handler {
return isokit.HandlerFunc(func(ctx context.Context) {
productsChannel := make(chan []*models.Product)
go FetchProducts(productsChannel)
products := <-productsChannel
templateData := &templatedata.Products{PageTitle: "Products", Products: products}
env.TemplateSet.Render("products_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
InitializeProductsPage(env)
env.Router.RegisterLinks("#primaryContent a")
})
}
首先,我们调用FetchProducts
函数的 goroutine 来从服务器端的端点获取产品列表。解码步骤(加粗显示)在FetchProducts
函数内执行:
func FetchProducts(productsChannel chan []*models.Product) {
data, err := xhr.Send("GET", "/restapi/get-products", nil)
if err != nil {
println("Encountered error: ", err)
return
}
var products []*models.Product
json.NewDecoder(strings.NewReader(string(data))).Decode(&products)
productsChannel <- products
}
从 Rest API 端点获取编码数据后,我们使用 JSON 解码器将编码数据解码回Product
对象的切片。然后我们将结果发送到productsChannel
,在ProductsHandler
函数内接收。
现在我们有了用于填充产品列表页面上产品列表的数据对象,我们可以填充templatedata.Products
结构的Products
字段。回想一下,templateData
是将传递到env.TemplateSet
对象的Render
方法中的数据对象:
templateData := &templatedata.Products{PageTitle: "Products", Products: products}
env.TemplateSet.Render("products_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
到目前为止,我们已经完成了同构交接过程的第三步,这意味着我们可以有效地在客户端上渲染产品列表页面。但是,我们还没有完成,因为我们必须完成最后一步,即将 DOM 事件处理程序附加到渲染的网页上。
在ProductsHandler
函数内,有两个调用对执行附加步骤至关重要:
InitializeProductsPage(env)
env.Router.RegisterLinks("#primaryContent a")
首先,我们调用InitializeProductsPage
函数添加必要的事件处理程序,以启用产品列表页面的用户交互:
func InitializeProductsPage(env *common.Env) {
buttons := env.Document.GetElementsByClassName("addToCartButton")
for _, button := range buttons {
button.AddEventListener("click", false, handleAddToCartButtonClickEvent)
}
}
我们通过在env.Document
对象上调用GetElementsByClassName
方法,并指定"addToCartButton"
类名,来检索产品列表页面上存在的所有加入购物车按钮。
当单击“加入购物车”按钮时,将调用handleAddToCartButtonClickEvent
函数。在实现购物车功能时,我们将介绍这个函数。
让我们回到ProductsHandler
函数。我们将在 Isokit 路由器对象上调用RegisterLinks
方法,并指定 CSS 查询选择器"#primaryContent a"
:
env.Router.RegisterLinks("#primaryContent a")
这样可以确保在客户端渲染网页时,所有产品项链接的点击事件都将被客户端路由拦截。这将允许我们在客户端自身渲染产品详细页面,而无需执行完整的页面重新加载。
到目前为止,我们已经为产品列表页面实现了同构交接过程。要在客户端渲染产品列表页面,请在导航栏中单击产品链接。要在服务器端渲染产品列表页面,请直接在 Web 浏览器中输入以下 URL:http://localhost:8080/products
。图 6.3显示了在客户端上渲染的产品列表页面:
图 6.3:在客户端上渲染的产品列表页面
您还可以刷新网页以强制在服务器端呈现页面。我们可以通过比较在客户端加载的网页和在服务器端加载的网页来验证等同手交接程序是否正确实现。由于两个网页都是相同的,我们可以确定等同手交接程序已成功实现。
为产品详细页面实现等同手交接
成功在产品列表页面上使用 ERDA 策略实现了等同手交接程序后,让我们专注于为产品详细页面实现等同手交接。
要实现编码步骤,我们首先需要确定表示我们希望保存到客户端的状态的数据对象。我们通过检查handlers/productdetail.go
源文件中找到的ProductDetailHandler
函数来识别数据对象。这是负责服务/product-detail
路由的服务器端处理程序函数:
func ProductDetailHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
productTitle := vars["productTitle"]
product := env.DB.GetProductDetail(productTitle)
templateData := &templatedata.ProductDetail{PageTitle: product.Name, Product: product}
env.TemplateSet.Render("product_detail_page", &isokit.RenderParams{Writer: w, Data: templateData})
})
}
从 Redis 数据存储中获取产品对象(以粗体显示)。该对象包含将显示在产品页面上的产品数据;因此,这是我们需要保存到客户端的对象。
endpoints/productdetail.go
源文件中的GetProductDetailEndpoint
函数是负责向客户端提供 JSON 编码的Product
数据的 Rest API 端点:
func GetProductDetailEndpoint(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
productTitle := vars["productTitle"]
product := env.DB.GetProductDetail(productTitle)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(product)
})
}
在GetProductDetailEndpoint
函数内部,我们从 Redis 数据存储中获取产品对象,并将其编码为 JSON 格式数据。
现在我们已经处理了编码步骤,我们可以在客户端上实现接下来的三个步骤。
要实现注册步骤,我们在client.go
源文件中添加以下行,以注册/product-detail
路由及其关联的处理程序函数:
r.Handle("/product-detail/{productTitle}", handlers.ProductDetailHandler(env))
解码和附加步骤由ProductDetailHandler
函数执行:
func ProductDetailHandler(env *common.Env) isokit.Handler {
return isokit.HandlerFunc(func(ctx context.Context) {
routeVars := ctx.Value(isokit.RouteVarsKey("Vars")).(map[string]string)
productTitle := routeVars[`product-detail/{productTitle}`]
productChannel := make(chan *models.Product)
go FetchProductDetail(productChannel, productTitle)
product := <-productChannel
templateData := &templatedata.ProductDetail{PageTitle: product.Name, Product: product}
env.TemplateSet.Render("product_detail_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
InitializeProductDetailPage(env)
})
}
在ProductDetailHandler
函数内部,我们调用FetchProductDetail
函数作为一个 goroutine 来获取产品对象。解码步骤(以粗体显示)是在FetchProductDetail
函数内部实现的:
func FetchProductDetail(productChannel chan *models.Product, productTitle string) {
data, err := xhr.Send("GET", "/restapi/get-product-detail"+"/"+productTitle, nil)
if err != nil {
println("Encountered error: ", err)
println(err)
}
var product *models.Product
json.NewDecoder(strings.NewReader(string(data))).Decode(&product)
productChannel <- product
}
我们发出 XHR 调用到 Rest API 端点,以获取编码的Product
数据。我们使用 JSON 解码器将编码数据解码回Product
对象。我们将Product
对象发送到productChannel
,在那里它会在ProductDetailHandler
函数中接收到。
回到ProductDetailHandler
函数,我们使用产品数据对象来填充产品详细页面上的产品信息。我们通过填充templatedata.ProductDetail
对象的 Product 字段来实现这一点。再次回想一下,templateData
变量是将传递到env.TemplateSet
对象的Render
方法中的数据对象:
templateData := &templatedata.ProductDetail{PageTitle: product.Name, Product: product}
env.TemplateSet.Render("product_detail_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
到目前为止,我们已经完成了等同手交接程序的第三步,这意味着我们现在可以在客户端上呈现产品详细页面。现在,是时候完成程序的最后一步附加,通过将 DOM 事件处理程序附加到呈现的网页上的各自 UI 元素上。
我们调用InitializeProductDetailPage
函数来添加必要的事件处理程序,以启用产品列表页面的用户交互:
func InitializeProductDetailPage(env *common.Env) {
buttons := env.Document.GetElementsByClassName("addToCartButton")
for _, button := range buttons {
button.AddEventListener("click", false, handleAddToCartButtonClickEvent)
}
}
与InitializeProductsPage
函数类似,我们检索网页上的所有“Add To Cart”按钮,并指定事件处理程序函数handleAddToCartButtonClickEvent
,当单击“Add To Cart”按钮时将调用该函数。
到目前为止,我们已经为产品详细页面实现了等同手递手的过程。要在客户端渲染产品详细页面,请点击产品列表页面中的产品图片。要在服务器端渲染产品详细页面,请在网页浏览器中输入产品的 URL。例如,瑞士军刀的产品详细页面的 URL 是http://localhost:8080/product-detail/swiss-army-knife
。图 6.4描述了在客户端渲染的瑞士军刀产品详细页面:
图 6.4:在客户端渲染的产品详细页面
同样,我们可以通过确认在客户端渲染的网页与在服务器端渲染的网页相同来验证等同手递手过程是否正常运行。由于两个网页是相同的,我们可以得出结论,我们已经成功实现了产品详细页面的等同手递手过程。
实现购物车的等同手递手
现在我们已经为与产品相关的网页实现了等同手递手,是时候开始实现 IGWEB 的购物车功能了。我们将从设计购物车网页开始。
设计购物车页面
购物车页面的设计,如图 6.5中的线框设计所示,与产品列表页面非常相似。每个产品项目将包含产品的缩略图大小的图片,产品价格,产品名称和产品的简要描述,就像产品列表页面一样。除了这些字段,购物车页面还将有一个字段来显示数量,即购物车中特定产品的数量,以及一个“从购物车中移除”按钮,点击该按钮将从购物车中移除产品:
图 6.5:显示购物车中有产品的购物车页面的线框设计
请记住,第一个线框设计涵盖了购物车已经填充了物品的情况。我们还必须考虑当购物车为空时页面的设计。购物车可能在用户首次访问 IGWEB 网站时为空,或者当用户完全清空购物车时。图 6.6是购物车页面的线框设计,描述了购物车为空的情况:
图 6.6:当购物车为空时,购物车页面的线框设计
现在我们已经确定了购物车页面的设计,是时候实现模板来实现设计了。
实现购物车模板
我们将使用购物车页面模板来在服务器端渲染购物车。以下是购物车页面模板的内容,定义在shared/templates/shopping_cart_page.tmpl
源文件中:
{{ define "pagecontent" }}
{{template "shopping_cart_content" . }}
{{end}}
{{template "layouts/webpage_layout" . }}
正如您可能已经注意到的,购物车页面模板调用了一个shopping_cart_content
子模板,负责渲染购物车本身。
以下是购物车内容模板的内容,定义在shared/templates/shopping_cart_content.tmpl
源文件中:
<h1>Shopping Cart</h1>
{{if .Products }}
{{range .Products}}
<div class="productCard">
<a href="{{.Route}}">
<div class="pricebox"><span>${{.Price}}</span></div>
<div class="productCardImageContainer">
<img src="img/{{.ThumbnailPreviewURI}}">
</div>
</a>
<div class="productContainer">
<h3><b>{{.Name}}</b></h3>
<p>{{.Description}}</p>
<div class="productQuantity"><span>Quantity: {{.Quantity}}</span></div>
<div class="pure-controls">
<button class="removeFromCartButton pure-button pure-button-primary" data-sku="{{.SKU}}">Remove From Cart</button>
</div>
</div>
</div>
{{end}}
{{else}}
<h2>Your shopping cart is empty.</h2>
{{end}}
使用 if
操作,我们检查是否有任何商品要在购物车中显示。如果有,我们使用 range
操作来渲染每个购物车商品。我们渲染模板的名称、缩略图预览和描述,以及数量。最后,我们渲染一个按钮,以从购物车中移除产品。请注意,我们嵌入了一个名为 data-sku
的数据属性,将产品的唯一 SKU
代码与按钮元素一起包含在内。稍后,当我们通过单击此按钮调用 Rest API 端点来移除购物车商品时,这个值会派上用场。
如果购物车中没有要显示的商品,控制流将到达 else
操作。在这种情况下,我们将显示购物车为空的消息。
最后,我们将使用结束模板操作来表示 if-else
条件块的结束。
模板数据对象
将传递给模板渲染器的模板数据对象将是一个 templatedata.ShoppingCart
结构体(在 shared/templatedata/shoppingcart.go
源文件中定义):
type ShoppingCart struct {
PageTitle string
Products []*models.Product
}
PageTitle
字段将用于显示网页标题,Products
字段是 Product
对象的切片,将用于显示当前在购物车中的产品。
现在我们已经有了模板,让我们来看看如何对购物车进行建模。
对购物车进行建模
ShoppingCartItem
结构体在 shared/models/shoppingcart.go
源文件中定义,表示购物车中的商品:
type ShoppingCartItem struct {
ProductSKU string `json:"productSKU"`
Quantity int `json:"quantity"`
}
ProductSKU
字段保存产品的 SKU
代码(用于区分产品的唯一代码),Quantity
字段保存用户希望购买的特定产品的数量。每当用户在产品列表或产品详细页面上点击“添加到购物车”按钮时,该特定产品的数量值将在购物车中递增。
ShoppingCart
结构体,也在 shoppingcart.go
源文件中定义,表示购物车:
type ShoppingCart struct {
Items map[string]*ShoppingCartItem `json:"items"`
}
Items
字段是一个项目的映射,其键为 string
类型(将是产品 SKU
代码),值将是指向 ShoppingCartItem
结构体的指针。
NewShoppingCart
函数是一个构造函数,用于创建 ShoppingCart
的新实例:
func NewShoppingCart() *ShoppingCart {
items := make(map[string]*ShoppingCartItem)
return &ShoppingCart{Items: items}
}
ShoppingCart
类型的 ItemTotal
方法负责返回当前购物车中的商品数量:
func (s *ShoppingCart) ItemTotal() int {
return len(s.Items)
}
ShoppingCart
类型的 IsEmpty
方法负责告诉我们购物车是否为空:
func (s *ShoppingCart) IsEmpty() bool {
if len(s.Items) > 0 {
return false
} else {
return true
}
}
ShoppingCart
类型的 AddItem
方法负责向购物车中添加商品:
func (s *ShoppingCart) AddItem(sku string) {
if s.Items == nil {
s.Items = make(map[string]*ShoppingCartItem)
}
_, ok := s.Items[sku]
if ok {
s.Items[sku].Quantity += 1
} else {
item := ShoppingCartItem{ProductSKU: sku, Quantity: 1}
s.Items[sku] = &item
}
}
如果特定产品商品已经存在于购物车中,每次新请求添加产品商品时,Quantity
字段将递增一次。
同样,ShoppingCart
类型的 RemoveItem
方法负责从购物车中删除特定产品类型的所有商品:
func (s *ShoppingCart) RemoveItem(sku string) bool {
_, ok := s.Items[sku]
if ok {
delete(s.Items, sku)
return true
} else {
return false
}
}
ShoppingCart
类型的 UpdateItemQuantity
方法负责更新购物车中特定产品的数量:
func (s *ShoppingCart) UpdateItemQuantity(sku string, quantity int) bool {
_, ok := s.Items[sku]
if ok {
s.Items[sku].Quantity += 1
return true
} else {
return false
}
}
购物车路由
通过实现 ShoppingCart
类型,我们现在已经有了业务逻辑,可以驱动购物车功能。现在是时候注册服务器端路由,以实现购物车。
我们在 igweb.go
源文件中的 registerRoutes
函数中注册了 /shopping-cart
路由及其关联的处理程序函数 ShoppingCartHandler
:
r.Handle("/shopping-cart", handlers.ShoppingCartHandler(env))
路由处理程序函数 ShoppingCartHandler
负责在服务器端生成购物车页面的网页。
我们还注册了以下 Rest API 端点:
-
获取商品列表(
/restapi/get-cart-items
) -
添加商品(
/restapi/add-item-to-cart
) -
移除商品(
/restapi/remove-item-from-cart
)
获取商品列表
用于获取购物车中物品列表的,我们将注册/restapi/get-cart-items
端点:
r.Handle("/restapi/get-cart-items", endpoints.GetShoppingCartItemsEndpoint(env)).Methods("GET")
这个端点将由GetShoppingCartItemsEndpoint
处理函数处理。这个端点负责将购物车编码为 JSON 编码数据,并提供给客户端应用程序。请注意,我们使用 HTTP 的GET
方法来调用这个端点。
添加物品
用于将物品添加到购物车的,我们将注册/restapi/add-item-to-cart
端点:
r.Handle("/restapi/add-item-to-cart", endpoints.AddItemToShoppingCartEndpoint(env)).Methods("PUT")
这个路由将由AddItemToShoppingCartEndpoint
处理函数处理。请注意,由于我们在 web 服务器上执行了一个改变操作(添加购物车物品),所以在调用这个端点时,我们使用 HTTP 的PUT
方法。
移除物品
用于从购物车中移除特定产品类型的物品及其所有数量的,我们将注册/restapi/remove-item-from-cart
端点:
r.Handle("/restapi/remove-item-from-cart", endpoints.RemoveItemFromShoppingCartEndpoint(env)).Methods("DELETE")
这个端点将由RemoveItemFromShoppingCartEndpoint
处理函数处理。再次请注意,由于我们在 web 服务器上执行了一个改变操作(移除购物车物品),所以在调用这个端点时,我们使用 HTTP 的DELETE
方法。
会话存储
与产品记录存储在 Redis 数据库中不同,用户选择放入购物车的物品是瞬时的,并且是针对个人定制的。在这种情况下,将购物车的状态存储在会话中比存储在数据库中更有意义。
我们将使用 Gorilla 的sessions
包来创建会话并将数据存储到会话中。我们将利用session.NewFileSystemStore
类型将会话数据保存到服务器的文件系统中。
首先,我们将在common/common.go
源文件中的common.Env
结构体中添加一个新字段(以粗体显示),该字段将保存FileSystemStore
实例,以便在整个服务器端 web 应用程序中访问:
type Env struct {
DB datastore.Datastore
TemplateSet *isokit.TemplateSet
Store *sessions.FilesystemStore
}
在igweb.go
源文件中定义的main
函数内,我们将调用initializeSessionstore
函数并传入env
对象:
initializeSessionstore(&env)
initializeSessionstore
函数负责在服务器端创建会话存储:
func initializeSessionstore(env *common.Env) {
if _, err := os.Stat("/tmp/igweb-sessions"); os.IsNotExist(err) {
os.Mkdir("/tmp/igweb-sessions", 711)
}
env.Store = sessions.NewFilesystemStore("/tmp/igweb-sessions", []byte(os.Getenv("IGWEB_HASH_KEY")))
}
在if
条件中,我们首先检查会话数据将被存储的指定路径/tmp/igweb-sessions
是否存在。如果路径不存在,我们将调用os
包中的Mkdir
函数来创建文件夹。
我们将调用sessions
包中的NewFileSystemStore
函数来初始化一个新的文件系统会话存储,传入会话将被保存的路径和会话的身份验证密钥。我们将用新创建的FileSystemStore
实例填充env
对象的Store
属性。
现在我们已经准备好了会话存储,让我们实现服务器端的ShoppingCartHandler
函数。
服务器端购物车处理函数
在handlers/shoppingcart.go
中定义的ShoppingCartHandler
函数负责为/shopping-cart
路由提供服务。
func ShoppingCartHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
服务器端购物车处理函数的主要目的是为购物车网页生成输出。
回想一下,与产品相关页面的处理函数是从 Redis 数据存储中检索产品列表的。另一方面,购物车处理函数是从服务器端会话中获取购物车中物品列表的。
我们将声明templateData
对象和购物车的变量:
var templateData *templatedata.ShoppingCart
var cart *models.ShoppingCart
我们已经定义并初始化了gorilla.SessionStore
类型的igwSession
变量,它将保存我们的服务器端会话:
igwSession, _ := env.Store.Get(r, "igweb-session")
回想一下,我们可以通过访问env
对象的Store
属性来访问FileSystemStore
对象。我们调用会话存储对象的Get
方法,传入http.Request
的指针r
和会话的名称"igweb-session"
。
如果会话不存在,将自动为我们创建一个名为"igweb-session"
的新会话。
要访问会话中的值,我们使用igwSession
对象的Values
属性,它是一个键值对的映射。键是字符串,值是空接口interface{}
类型,因此它们可以保存任何类型(因为 Go 中的所有类型都实现了空接口)。
在if
条件块中,我们检查Values
映射中是否存在"shoppingCart"
会话键的值:
if _, ok := igwSession.Values["shoppingCart"]; ok == true {
// Shopping cart exists in session
decoder := json.NewDecoder(strings.NewReader(string(igwSession.Values["shoppingCart"].([]byte))))
err := decoder.Decode(&cart)
if err != nil {
log.Print("Encountered error when attempting to decode json data from session: ", err)
}
使用"shoppingCart"
键访问购物车对象的 JSON 编码值。如果会话中存在购物车,我们使用 JSON 解码器对象的Decode
方法解码 JSON 对象。如果成功解码 JSON 对象,则将解码后的对象存储在cart
变量中。
现在我们从会话中有了购物车对象,我们需要获取购物车中每个商品的产品信息。我们通过调用数据存储对象的GetProductsInShoppingCart
方法,并将cart
变量作为输入参数提供给该方法来实现。
products := env.DB.GetProductsInShoppingCart(cart)
该函数将返回要在购物车页面上显示的产品切片。请注意,我们使用从数据存储获取的产品切片来填充templatedata.ShoppingCart
对象的Products
字段:
templateData = &templatedata.ShoppingCart{PageTitle: "Shopping Cart", Products: products}
由于我们将利用这个产品切片来呈现服务器端的购物车模板页面,从GetProductsInShoppingCart
方法返回的产品切片是我们在实现同构交接时需要持久保存到客户端的状态数据。
如果会话中不存在"shoppingCart"
键,则控制流会进入else
块:
} else {
// Shopping cart doesn't exist in session
templateData = &templatedata.ShoppingCart{PageTitle: "Shopping Cart", Products: nil}
}
在这种情况下,我们将templatedata.ShoppingCart
结构体的Products
字段设置为nil
,以表示购物车中没有产品,因为购物车在会话中不存在。
最后,我们通过在模板集对象上调用Render
方法,传入我们希望呈现的模板的名称(shopping_cart_page
模板)以及呈现参数来呈现购物车页面:
env.TemplateSet.Render("shopping_cart_page", &isokit.RenderParams{Writer: w, Data: templateData})
})
}
请注意,我们已将RenderParams
对象的Writer
属性设置为http.ResponseWriter
,w
,并将Data
属性设置为templateData
变量。
让我们来看看在 Redis 数据存储中定义的GetProductsInShoppingCart
方法(在common/datastore/redis.go
源文件中找到):
func (r *RedisDatastore) GetProductsInShoppingCart(cart *models.ShoppingCart) []*models.Product {
products := r.GetProducts()
productsMap := r.GenerateProductsMap(products)
result := make(models.Products, 0)
for _, v := range cart.Items {
product := &models.Product{}
product = productsMap[v.ProductSKU]
product.Quantity = v.Quantity
result = append(result, product)
}
sort.Sort(result)
return result
}
该方法的作用是返回购物车中所有产品的Product
对象切片。ShoppingCart
结构体简单地跟踪产品的类型(通过其SKU
代码)以及购物车中该产品的Quantity
。
我们声明一个result
变量,它是Product
对象的切片。我们循环遍历每个购物车项目,并从productsMap
中检索Product
对象,提供产品的SKU
代码作为键。我们填充Product
对象的Quantity
字段,并将Product
对象追加到result
切片中。
我们调用 sort 包中的Sort
方法,传入result
切片。由于我们已经为Products
类型实现了排序接口,result
切片中的Product
对象将按价格升序排序。最后,我们返回result
切片。
购物车端点
此时,当我们完成服务器端功能以实现购物车功能时,我们也准备开始实现同构交接程序,遵循 ERDA 策略。
获取购物车中商品的端点
让我们来看看购物车的 Rest API 端点,这些端点帮助服务于客户端 Web 应用程序所依赖的操作。让我们从负责获取购物车中商品的端点函数GetShoppingCartItemsEndpoint
开始,这个端点函数执行了等同交接过程中的编码步骤。
以下是GetShoppingCartItemsEndpoint
函数的源代码列表:
func GetShoppingCartItemsEndpoint(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var cart *models.ShoppingCart
igwSession, _ := env.Store.Get(r, "igweb-session")
if _, ok := igwSession.Values["shoppingCart"]; ok == true {
// Shopping cart exists in session
decoder := json.NewDecoder(strings.NewReader(string(igwSession.Values["shoppingCart"].([]byte))))
err := decoder.Decode(&cart)
if err != nil {
log.Print("Encountered error when attempting to decode json data from session: ", err)
}
products := env.DB.GetProductsInShoppingCart(cart)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(products)
} else {
// Shopping cart doesn't exist in session
cart = nil
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cart)
return
}
})
}
在这个函数中,我们从会话中获取购物车。如果我们能够成功地从会话中获取购物车,我们就使用 JSON 编码器对ShoppingCart
对象进行编码,并使用http.ResponseWriter
w
进行写入。
如果会话中不存在购物车,我们就简单地对nil
的值进行 JSON 编码(在客户端等同于 JavaScript 的null
),并使用http.ResponseWriter
w
在响应中写出。
有了这段代码,我们已经完成了等同交接过程中的编码步骤。
添加商品到购物车的端点
我们在AddItemToShoppingCartEndpoint
中声明了一个m
变量(加粗显示),类型为map[string]string
,这是负责向购物车添加新商品的端点函数:
func AddItemToShoppingCartEndpoint(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
igwSession, _ := env.Store.Get(r, "igweb-session")
decoder := json.NewDecoder(r.Body)
var m map[string]string
err := decoder.Decode(&m)
if err != nil {
log.Print("Encountered error when attempting to decode json data from request body: ", err)
}
defer r.Body.Close()
var cart *models.ShoppingCart
我们使用 JSON 解码器来解码请求体,其中包含从客户端发送的 JSON 编码的映射。该映射将包含要添加到购物车的产品的SKU
值,给定"productSKU"
键。
我们将检查会话中是否存在购物车。如果存在,我们将把购物车的 JSON 数据解码回ShoppingCart
对象:
if _, ok := igwSession.Values["shoppingCart"]; ok == true {
// Shopping Cart Exists in Session
decoder := json.NewDecoder(strings.NewReader(string(igwSession.Values["shoppingCart"].([]byte))))
err := decoder.Decode(&cart)
if err != nil {
log.Print("Encountered error when attempting to decode json data from session: ", err)
}
如果购物车不存在,控制流将到达else
块,我们将创建一个新的购物车:
} else {
// Shopping Cart Doesn't Exist in Session, Create a New One
cart = models.NewShoppingCart()
}
然后我们将调用ShoppingCart
对象的AddItem
方法来添加产品项:
cart.AddItem(m["productSKU"])
要向购物车添加商品,我们只需提供产品的SKU
值,这个值可以从m
映射变量中获取,通过访问productSKU
键的映射值。
我们将把购物车对象编码为其 JSON 表示形式,并保存到会话中,会话键为"shoppingCart"
:
b := new(bytes.Buffer)
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(b).Encode(cart)
if err != nil {
log.Print("Encountered error when attempting to encode cart struct as json data: ", err)
}
igwSession.Values["shoppingCart"] = b.Bytes()
igwSession.Save(r, w)
w.Write([]byte("OK"))
})
然后我们将响应"OK"
写回客户端,以表明成功执行了向购物车添加新商品的操作。
从购物车中移除商品的端点
以下是RemoveItemFromShoppingCartEndpoint
的源代码列表,这个端点负责从购物车中移除特定产品的所有商品:
func RemoveItemFromShoppingCartEndpoint(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
igwSession, _ := env.Store.Get(r, "igweb-session")
decoder := json.NewDecoder(r.Body)
var m map[string]string
err := decoder.Decode(&m)
if err != nil {
log.Print("Encountered error when attempting to decode json data from request body: ", err)
}
defer r.Body.Close()
var cart *models.ShoppingCart
if _, ok := igwSession.Values["shoppingCart"]; ok == true {
// Shopping Cart Exists in Session
decoder := json.NewDecoder(strings.NewReader(string(igwSession.Values["shoppingCart"].([]byte))))
err := decoder.Decode(&cart)
if err != nil {
log.Print("Encountered error when attempting to decode json data from session: ", err)
}
} else {
// Shopping Cart Doesn't Exist in Session, Create a New One
cart = models.NewShoppingCart()
}
for k, v := range cart.Items {
if v.ProductSKU == m["productSKU"] {
delete(cart.Items, k)
}
}
b := new(bytes.Buffer)
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(b).Encode(cart)
if err != nil {
log.Print("Encountered error when attempting to encode cart struct as json data: ", err)
}
igwSession.Values["shoppingCart"] = b.Bytes()
igwSession.Save(r, w)
w.Write([]byte("OK"))
})
}
请记住,对于给定的产品,我们可以有多个数量。在当前的购物车实现中,如果用户点击“从购物车中移除”按钮,那么该产品(以及所有数量)将从购物车中移除。
我们首先从会话中获取 JSON 编码的购物车数据。如果存在,我们将 JSON 对象解码为一个新的ShoppingCart
对象。如果会话中不存在购物车,我们就简单地创建一个新的购物车。
我们遍历购物车中的商品,如果我们能够在购物车中找到包含与从客户端 Web 应用程序获取的m
映射变量中提供的相同产品SKU
代码的产品,我们将通过调用内置的delete
函数(加粗显示)从购物车对象的Items
映射中删除该元素。最后,我们将向客户端写出一个 JSON 编码的响应,表示操作已成功完成。
现在我们已经在服务器端设置了端点,是时候看看客户端需要实现购物车功能的最后部分了。
在客户端实现购物车功能
为了完成 ERDA 策略的注册步骤,我们将在client/client.go
源文件中的registerRoutes
函数中注册/shopping-cart
路由及其关联的处理函数ShoppingCartHandler
:
r.Handle("/shopping-cart", handlers.ShoppingCartHandler(env))
请记住,当用户点击导航栏中的购物车图标访问购物车时,将会触发此路由。点击购物车图标后,将调用ShoppingCartHandler
函数。
让我们来看一下ShoppingCartHandler
函数:
func ShoppingCartHandler(env *common.Env) isokit.Handler {
return isokit.HandlerFunc(func(ctx context.Context) {
renderShoppingCartItems(env)
})
}
这个函数的主要目的是调用renderShoppingCartItems
函数在客户端上渲染购物车。我们已经将渲染购物车及其内容的逻辑整合到renderShoppingCartItems
函数中,以便在用户从购物车中移除商品时重新渲染购物车页面。
渲染购物车
renderShoppingCartItems
函数负责执行 ERDA 策略的最后两个步骤,即解码和附加步骤。以下是renderShoppingCartItems
函数的源代码清单:
func renderShoppingCartItems(env *common.Env) {
productsChannel := make(chan []*models.Product)
go fetchProductsInShoppingCart(productsChannel)
products := <-productsChannel
templateData := &templatedata.ShoppingCart{PageTitle: "Shopping Cart", Products: products}
env.TemplateSet.Render("shopping_cart_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
InitializeShoppingCartPage(env)
env.Router.RegisterLinks("#primaryContent a")
}
在这个函数中,我们创建了一个名为productsChannel
的新通道,这是一个我们将用来发送和接收产品切片的通道。我们调用fetchProductsInShoppingCart
函数作为一个 goroutine,并将productsChannel
作为函数的输入参数。该函数负责通过执行 XHR 调用从服务器获取购物车中的产品项目。
以下是fetchProductsInShoppingCart
函数的源代码清单:
func fetchProductsInShoppingCart(productsChannel chan []*models.Product) {
data, err := xhr.Send("GET", "/restapi/get-cart-items", nil)
if err != nil {
println("Encountered error: ", err)
println(err)
}
var products []*models.Product
json.NewDecoder(strings.NewReader(string(data))).Decode(&products)
productsChannel <- products
}
在这个函数中,我们只是对 Rest API 端点/restapi/get-cart-items
进行 XHR 调用,该端点负责返回表示产品切片的 JSON 编码数据。我们使用 JSON 解码器将编码的产品切片解码到products
变量中。最后,我们通过productsChannel
发送products
变量。
让我们回到renderShoppingCartItems
函数,并从productsChannel
接收产品切片,然后我们将使用接收到的产品设置templateData
对象的Products
属性:
templateData := &templatedata.ShoppingCart{PageTitle: "Shopping Cart", Products: products}
然后我们将在客户端上渲染购物车模板:
env.TemplateSet.Render("shopping_cart_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
到目前为止,我们已经完成了 ERDA 策略的解码步骤。
为了完成 ERDA 策略的附加步骤,我们将调用InitializeShoppingCartEventHandlers
函数,以便将任何所需的事件监听器附加到购物车网页上。
以下是InitializeShoppingCartEventHandlers
函数的源代码清单:
func InitializeShoppingCartPage(env *common.Env) {
buttons := env.Document.GetElementsByClassName("removeFromCartButton")
for _, button := range buttons {
button.AddEventListener("click", false,
func(event dom.Event) {
handleRemoveFromCartButtonClickEvent(env, event)
})
}
}
这个函数负责在购物车网页上的每个产品容器中找到的所有“从购物车中移除”按钮上附加点击事件。当点击“从购物车中移除”按钮时,调用的事件处理函数是handleRemoveFromCartButtonClickEvent
函数。
通过在购物车网页上的“从购物车中移除”按钮上附加事件监听器,我们已经完成了 ERDA 策略的第四步,也是最后一步。购物车功能的同构交接实现已经完成。
从购物车中移除商品
让我们来看一下handleRemoveFromCartButtonClickEvent
函数,当点击“从购物车中移除”按钮时会调用该函数:
func handleRemoveFromCartButtonClickEvent(env *common.Env, event dom.Event) {
productSKU := event.Target().GetAttribute("data-sku")
go removeFromCart(env, productSKU)
}
在这个函数中,我们从事件目标元素的data-sku
属性中获取产品的SKU
代码。然后我们调用removeFromCart
函数作为一个 goroutine,传入env
对象和productSKU
。
以下是removeFromCart
函数的源代码清单:
func removeFromCart(env *common.Env, productSKU string) {
m := make(map[string]string)
m["productSKU"] = productSKU
jsonData, _ := json.Marshal(m)
data, err := xhr.Send("DELETE", "/restapi/remove-item-from-cart", jsonData)
if err != nil {
println("Encountered error: ", err)
notify.Error("Failed to remove item from cart!")
return
}
var products []*models.Product
json.NewDecoder(strings.NewReader(string(data))).Decode(&products)
renderShoppingCartItems(env)
notify.Success("Item removed from cart")
}
在removeFromCart
函数中,我们创建一个新地图m
,用于存储productSKU
。我们可以通过提供"productSKU"
键从m
地图中访问产品的SKU
值。我们打算通过请求主体将此地图发送到 Web 服务器。我们选择map
类型的原因是,我们希望使我们的解决方案具有可扩展性。将来,如果有任何其他信息应发送到服务器,我们可以将该值作为地图中的附加键值对的一部分包含进来。
我们将地图编码为其 JSON 表示,并对 Web 服务器进行 XHR 调用,发送地图 JSON 数据。最后,我们调用renderShoppingCartItems
函数来渲染购物车商品。请记住,通过调用此函数,我们将执行 XHR 调用以获取购物车中最新的产品(代表购物车的当前状态)。这确保了我们将拥有购物车的最新状态,因为我们再次使用服务器端会话(购物车状态存储在其中)作为我们的唯一真相来源。
将商品添加到购物车
“添加到购物车”按钮的功能以类似的方式实现。请记住,在与产品相关的页面上,如果单击任何“添加到购物车”按钮,将调用handleAddToCarButton
函数。以下是该函数的源代码列表:
func handleAddToCartButtonClickEvent(event dom.Event) {
productSKU := event.Target().GetAttribute("data-sku")
go addToCart(productSKU)
}
与handleRemoveFromCartButtonClickEvent
函数类似,在handleAddToCart
函数内,我们通过获取带有“data-sku”
键的数据属性,从事件目标元素中获取产品的SKU
代码。然后我们调用addToCart
函数作为一个 goroutine,并将productSKU
作为输入参数提供给函数。
以下是addToCart
函数的源代码列表:
func addToCart(productSKU string) {
m := make(map[string]string)
m["productSKU"] = productSKU
jsonData, _ := json.Marshal(m)
data, err := xhr.Send("PUT", "/restapi/add-item-to-cart", jsonData)
if err != nil {
println("Encountered error: ", err)
notify.Error("Failed to add item to cart!")
return
}
var products []*models.Product
json.NewDecoder(strings.NewReader(string(data))).Decode(&products)
notify.Success("Item added to cart")
}
在addToCart
函数中,我们对 Web 服务器上负责向购物车添加项目的 Rest API 端点进行 XHR 调用。在进行 XHR 调用之前,我们创建一个包含productSKU
的地图,然后将地图编码为其 JSON 表示。我们使用 XHR 调用将 JSON 数据发送到服务器端点。
我们现在可以在客户端显示购物车,还可以适应用户与购物车的交互,特别是将产品添加到购物车和从购物车中删除产品。
本章介绍的购物车实现仅用于说明目的。读者可以自行实现进一步的功能。
验证购物车功能
现在是时候验证购物车的状态是否从服务器到客户端保持不变,因为用户向购物车中添加和删除项目。
验证等价交接是否成功实施非常简单。我们只需要验证服务器端生成的购物车页面是否与客户端生成的购物车页面相同。通过单击购物车图标,我们可以看到客户端生成的网页。在购物车页面上单击刷新按钮,我们可以看到服务器端生成的网页。
一开始,购物车中没有放置任何物品。图 6.7是一个截图,描述了购物车处于空状态时的情况:
图 6.7:购物车为空时的购物车页面
在客户端渲染的购物车页面与服务器端渲染的页面匹配,表明购物车的空状态得到了正确维护。
现在,让我们通过点击导航栏上的产品链接来访问产品列表页面。通过点击“添加到购物车”按钮,向购物车中添加一些商品。然后点击网站顶部栏中的购物车图标返回到购物车页面。图 6.8是一个截图,显示了购物车中添加了一些商品:
图 6.8:购物车页面中有一些商品在购物车中
在检查客户端渲染的购物车页面与服务器端渲染的页面是否匹配后,我们可以确定购物车的状态已成功维护。
现在,通过点击每个产品上的“从购物车中移除”按钮,从购物车中移除所有商品。一旦购物车为空,我们可以执行相同的验证步骤,检查客户端渲染的页面与服务器端渲染的页面是否相同,以确定购物车状态是否成功维护。
在这一点上,我们可以确认等同手交程序已成功实现了购物车功能。
您可能已经注意到,当我们向购物车添加商品时,屏幕右下角会显示通知,如图 6.9所示。请注意,通知显示在网页的右下角,并指示产品已成功添加到购物车中。
图 6.9:当商品被添加到购物车时,通知出现在页面的右下角
请注意,当从购物车中移除商品时,也会出现类似的通知。我们利用了一个可重用的组件“齿轮”来生成这个通知。我们将在第九章中介绍负责生成这些通知的齿轮的实现,齿轮-可重用组件。
总结
在本章中,我们向您介绍了等同手交,即服务器将状态传递给客户端的方式。这是一个重要的过程,允许客户端在等同的网络应用程序中继续服务器中断的工作。我们演示了 ERDA 策略,以实现产品相关网页和购物车网页的等同手交。在实现购物车功能时,我们创建了一个服务器端会话存储,它充当了用户购物车当前状态的真相来源。我们实现了服务器端端点来实现从购物车获取商品、向购物车添加商品和从购物车删除商品的功能。最后,我们通过确认客户端渲染的网页与服务器端渲染的网页完全相同来验证等同手交是否成功实现。
我们还依赖服务器端的真相来源来维护与客户端的状态。对于与产品相关的页面,真相来源是 Redis 数据存储,对于购物车页面,唯一的真相来源是服务器端的会话存储。在第七章中,等同网络表单,我们将考虑如何处理超出基本用户交互的情况。您将学习如何接受客户端生成的数据,通过等同网络表单提交。您将学习如何验证和处理用户提交的数据,通过在 IGWEB 的联系网页上实现联系表单。
第七章:等同态网络表单
在上一章中,我们专注于如何使服务器端应用程序将数据移交给客户端应用程序,以无缝地维护状态,同时实现购物车功能。在[第六章](5759cf7a-e435-431d-b7ca-24a846d6165a.xhtml)等同态移交中,我们将服务器视为唯一的真相来源。服务器向客户端指示当前购物车状态。在本章中,我们将超越迄今为止考虑的简单用户交互,并步入接受通过等同态网络表单提交的用户生成数据的领域。
这意味着现在客户端有了发言权,可以决定应该存储在服务器上的用户生成数据,当然前提是有充分的理由(验证用户提交的数据)。使用等同态网络表单,验证逻辑可以在各个环境中共享。客户端应用程序可以参与并通知用户在提交表单数据到服务器之前已经犯了一个错误。服务器端应用程序拥有最终否决权,因为它将在服务器端重新运行验证逻辑(在那里,验证逻辑显然无法被篡改),并仅在成功验证结果时处理用户生成的数据。
除了提供共享验证逻辑和表单结构的能力外,等同态网络表单还提供了一种使表单更易访问的方法。我们必须解决网页客户端的可访问性问题,这些客户端可能没有 JavaScript 运行时,或者可能已禁用 JavaScript 运行时。为了实现这一目标,我们将为 IGWEB 的联系部分构建一个等同态网络表单,并考虑渐进增强。这意味着只有在实现了满足最低要求的表单功能,以满足禁用 JavaScript 的网页客户端场景后,我们才会继续实现在 JavaScript 配备的网页浏览器中直接运行的客户端表单验证。
到本章结束时,我们将拥有一个强大的等同态网络表单,使用单一语言(Go)实现,它将在各种环境中重用通用代码。最重要的是,等同态网络表单将对终端窗口中运行的最简化的网页客户端和具有最新 JavaScript 运行时的基于 GUI 的网页客户端都是可访问的。
在本章中,我们将涵盖以下主题:
-
了解表单流程
-
设计联系表单
-
验证电子邮件地址语法
-
表单界面
-
实现联系表单
-
可访问的联系表单
-
客户端考虑
-
联系表单 Rest API 端点
-
检查客户端验证
了解表单流程
图 7.1显示了一个图像,显示了仅具有服务器端验证的网页表单。表单通过 HTTP Post 请求提交到 Web 服务器。服务器提供完全呈现的网页响应。如果用户没有正确填写表单,错误将在网页响应中显示。如果用户正确填写了表单,将进行 HTTP 重定向到确认网页:
图 7.1:仅具有服务器端验证的网页表单
图 7.2显示了一个图像,显示了具有客户端和服务器端验证的 Web 表单。当用户提交 Web 表单时,表单中的数据将使用客户端验证进行验证。仅在成功的客户端验证结果时,表单数据将通过 XHR 调用提交到 Web 服务器的 Rest API 端点。一旦表单数据提交到服务器,它将经历第二轮服务器端验证。这确保了即使在客户端验证可能被篡改的情况下,表单数据的质量。客户端应用程序将检查从服务器返回的表单验证结果,并在成功提交表单时显示确认页面,或在提交表单不成功时显示联系表单错误:
图 7.2:在客户端和服务器端验证的 Web 表单
设计联系表单
联系表单将允许网站用户与 IGWEB 团队取得联系。成功完成联系表单将导致包含用户生成的表单数据的联系表单提交被持久化在 Redis 数据库中。图 7.3是描述联系表单的线框图:
图 7.3:联系表单的线框设计
图 7.4是线框图,描述了当用户未正确填写表单时显示表单错误的联系表单:
图 7.4:联系表单的线框设计,显示了错误消息
图 7.5是线框图,描述了成功提交联系表单后将显示给用户的确认页面:
图 7.5:确认页面的线框设计
联系表单将从用户那里征求以下必要信息:他们的名字,姓氏,电子邮件地址和给团队的消息。如果用户没有填写这些字段中的任何一个,点击表单上的联系按钮后,用户将收到特定于字段的错误消息,指示未填写的字段。
实施模板
在服务器端呈现联系页面时,我们将使用contact_page
模板(在shared/templates/contact_page.tmpl
文件中找到):
{{ define "pagecontent" }}
{{template "contact_content" . }}
{{end}}
{{template "layouts/webpage_layout" . }}
请记住,因为我们包含了layouts/webpage_layout
模板,这将打印生成页面的doctype
,html
和body
标记的标记。这个模板将专门在服务器端使用。
使用define
模板操作,我们划定了"pagecontent"
块,其中将呈现联系页面的内容。联系页面的内容在contact_content
模板内定义(在shared/template/contact_content.tmpl
文件中找到):
<h1>Contact</h1>
{{template "partials/contactform_partial" .}}
请记住,除了服务器端应用程序之外,客户端应用程序将使用contact_content
模板在主要内容区域呈现联系表单。
在contact_content
模板内,我们包含了包含联系表单标记的联系表单部分模板(partials/contactform_partial
):
<div class="formContainer">
<form id="contactForm" name="contactForm" action="/contact" method="POST" class="pure-form pure-form-aligned">
<fieldset>
{{if .Form }}
<div class="pure-control-group">
<label for="firstName">First Name</label>
<input id="firstName" type="text" placeholder="First Name" name="firstName" value="{{.Form.Fields.firstName}}">
<span id="firstNameError" class="formError pure-form-message-inline">{{.Form.Errors.firstName}}</span>
</div>
<div class="pure-control-group">
<label for="lastName">Last Name</label>
<input id="lastName" type="text" placeholder="Last Name" name="lastName" value="{{.Form.Fields.lastName}}">
<span id="lastNameError" class="formError pure-form-message-inline">{{.Form.Errors.lastName}}</span>
</div>
<div class="pure-control-group">
<label for="email">E-mail Address</label>
<input id="email" type="text" placeholder="E-mail Address" name="email" value="{{.Form.Fields.email}}">
<span id="emailError" class="formError pure-form-message-inline">{{.Form.Errors.email}}</span>
</div>
<fieldset class="pure-control-group">
<textarea id="messageBody" class="pure-input-1-2" placeholder="Enter your message for us here." name="messageBody">{{.Form.Fields.messageBody}}</textarea>
<span id="messageBodyError" class="formError pure-form-message-inline">{{.Form.Errors.messageBody}}</span>
</fieldset>
<div class="pure-controls">
<input id="contactButton" name="contactButton" class="pure-button pure-button-primary" type="submit" value="Contact" />
</div>
{{end}}
</fieldset>
</form>
</div>
这个部分模板包含了实现图 7.3所示线框设计所需的 HTML 标记。访问表单字段值及其对应错误的模板操作以粗体显示。我们为给定的input
字段填充value
属性的原因是,如果用户在填写表单时出错,这些值将被预先填充为用户在上一次表单提交尝试中输入的值。每个input
字段后面直接跟着一个<span>
标记,其中将包含该特定字段的相应错误消息。
最后的<input>
标签是一个submit
按钮。点击此按钮,用户将能够将表单内容提交到 Web 服务器。
验证电子邮件地址语法
除了所有字段必须填写的基本要求之外,电子邮件地址字段必须是格式正确的电子邮件地址。如果用户未能提供格式正确的电子邮件地址,字段特定的错误消息将通知用户电子邮件地址语法不正确。
我们将使用shared
文件夹中的validate
包中的EmailSyntax
函数。
const EmailRegex = `(?i)^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,3})+$`
func EmailSyntax(email string) bool {
validationResult := false
r, err := regexp.Compile(EmailRegex)
if err != nil {
log.Fatal(err)
}
validationResult = r.MatchString(email)
return validationResult
}
请记住,因为validate
包被策略性地放置在shared
文件夹中,该包旨在是等距的(跨环境使用)。EmailSyntax
函数的工作是确定输入字符串是否是有效的电子邮件地址。如果电子邮件地址有效,函数将返回true
,如果输入字符串不是有效的电子邮件地址,则函数将返回false
。
表单接口
等距网络表单实现了isokit
包中找到的Form
接口:
type Form interface {
Validate() bool
Fields() map[string]string
Errors() map[string]string
FormParams() *FormParams
PrefillFields()
SetFields(fields map[string]string)
SetErrors(errors map[string]string)
SetFormParams(formParams *FormParams)
SetPrefillFields(prefillFields []string)
}
Validate
方法确定表单是否已正确填写,如果表单已正确填写,则返回true
的布尔值,如果表单未正确填写,则返回false
的布尔值。
Fields
方法返回了所有表单字段的map
,其中键是表单字段的名称,值是表单字段的字符串值。
Errors
方法包含了在表单验证时填充的所有错误的map
。键是表单字段的名称,值是描述性错误消息。
FormParams
方法返回表单的等距表单参数对象。表单参数对象很重要,因为它确定了可以获取表单字段的用户输入值的来源。在服务器端,表单字段值是从*http.Request
获取的,在客户端,表单字段是从FormElement
对象获取的。
这是FormParams
结构的样子:
type FormParams struct {
FormElement *dom.HTMLFormElement
ResponseWriter http.ResponseWriter
Request *http.Request
UseFormFieldsForValidation bool
FormFields map[string]string
}
PrefillFields
方法返回一个字符串切片,其中包含表单字段的所有名称,如果用户在提交表单时出错,应保留其值。
考虑到最后四个 getter 方法,Fields
、Errors
、FormParams
和PrefillFields
,都有相应的 setter 方法,SetFields
、SetErrors
、SetFormParams
和SetPrefillFields
。
实现联系表单
现在我们知道表单接口的样子,让我们开始实现联系表单。在我们的导入分组中,请注意我们包括了验证包和isokit
包:
import (
"github.com/EngineerKamesh/igb/igweb/shared/validate"
"github.com/isomorphicgo/isokit"
)
请记住,我们需要导入验证包,以便使用包中定义的EmailSyntax
函数进行电子邮件地址验证功能。
我们之前介绍的实现Form
接口所需的大部分功能都由isokit
包中的BasicForm
类型提供。我们将类型BasicForm
嵌入到我们的ContactForm
结构的类型定义中:
type ContactForm struct {
isokit.BasicForm
}
通过这样做,我们大部分实现Form
接口的功能都是免费提供给我们的。但是,我们必须实现Validate
方法,因为BasicForm
类型中找到的默认Validate
方法实现将始终返回false
。
联系表单的构造函数接受一个FormParams
结构,并返回一个新创建的ContactForm
结构的指针:
func NewContactForm(formParams *isokit.FormParams) *ContactForm {
prefillFields := []string{"firstName", "lastName", "email", "messageBody", "byDateInput"}
fields := make(map[string]string)
errors := make(map[string]string)
c := &ContactForm{}
c.SetPrefillFields(prefillFields)
c.SetFields(fields)
c.SetErrors(errors)
c.SetFormParams(formParams)
return c
}
我们创建一个字符串切片,其中包含应保留其值的字段的名称,在prefillFields
变量中。我们为fields
变量和errors
变量分别创建了map[string]string
类型的实例。我们创建了一个新的ContactForm
实例的引用,并将其分配给变量c
。我们调用ContactForm
实例c
的SetFields
方法,并传递fields
变量。
我们调用SetFields
和SetErrors
方法,并分别传入fields
和errors
变量。我们调用c
的SetFormParams
方法来设置传入构造函数的表单参数。最后,我们返回新的ContactForm
实例。
正如前面所述,BasicForm
类型中的默认Validate
方法实现总是返回false
。因为我们正在实现自己的自定义表单,联系表单,我们有责任定义成功验证是什么,并通过实现Validate
方法来实现。
func (c *ContactForm) Validate() bool {
c.RegenerateErrors()
c.PopulateFields()
// Check if first name was filled out
if isokit.FormValue(c.FormParams(), "firstName") == "" {
c.SetError("firstName", "The first name field is required.")
}
// Check if last name was filled out
if isokit.FormValue(c.FormParams(), "lastName") == "" {
c.SetError("lastName", "The last name field is required.")
}
// Check if message body was filled out
if isokit.FormValue(c.FormParams(), "messageBody") == "" {
c.SetError("messageBody", "The message area must be filled.")
}
// Check if e-mail address was filled out
if isokit.FormValue(c.FormParams(), "email") == "" {
c.SetError("email", "The e-mail address field is required.")
} else if validate.EmailSyntax(isokit.FormValue(c.FormParams(), "email")) == false {
// Check e-mail address syntax
c.SetError("email", "The e-mail address entered has an improper syntax.")
}
if len(c.Errors()) > 0 {
return false
} else {
return true
}
}
我们首先调用RegenerateErrors
方法来清除当前显示给用户的错误。这个方法的功能只适用于客户端应用程序。当我们在客户端实现联系表单功能时,我们将更详细地介绍这个方法。
我们调用PopulateFields
方法来填充ContactForm
实例的字段map
。如果用户在填写表单时出现错误,这个方法负责预先填充用户已经输入的值,以免他们不得不再次输入这些值来重新提交表单。
在这一点上,我们可以开始进行表单验证。我们首先检查用户是否已经填写了名字字段。我们使用isokit
包中的FormValue
函数来获取表单字段firstName
的用户输入值。我们传递给FormValue
函数的第一个参数是联系表单的表单参数对象,第二个值是我们希望获取的表单字段的名称,即"firstName"
。通过检查用户输入的值是否为空字符串,我们可以确定用户是否已经在字段中输入了值。如果没有,我们调用SetError
方法,传递表单字段的名称,以及一个描述性错误消息。
我们执行完全相同的检查,以查看用户是否已经填写了必要的值,包括姓氏字段、消息正文和电子邮件地址。如果他们没有填写这些字段中的任何一个,我们将调用SetError
方法,提供字段的名称和一个描述性错误消息。
对于电子邮件地址,如果用户已经输入了电子邮件表单字段的值,我们将对用户提供的电子邮件地址的语法进行额外检查。我们将用户输入的电子邮件值传递给验证包中的EmailSyntax
函数。如果电子邮件不是有效的语法,我们调用SetError
方法,传入表单字段名称"email"
,以及一个描述性错误消息。
正如我们之前所述,Validate
函数基于表单是否包含错误返回一个布尔值。我们使用 if 条件来确定错误的数量是否大于零,如果是,表示表单有错误,我们返回一个布尔值false
。如果错误的数量为零,控制流将到达 else 块,我们返回一个布尔值true
。
现在我们已经添加了联系表单,是时候实现服务器端的路由处理程序了。
注册联系路由
我们首先添加联系表单页面和联系确认页面的路由:
r.Handle("/contact", handlers.ContactHandler(env)).Methods("GET", "POST")
r.Handle("/contact-confirmation", handlers.ContactConfirmationHandler(env)).Methods("GET")
请注意,我们注册的/contact
路由将由ContactHandler
函数处理,将接受使用GET
和POST
方法的 HTTP 请求。当首次访问联系表单时,将通过GET
请求到/contact
路由。当用户提交联系表单时,他们将发起一个POST
请求到/contact
路由。这解释了为什么这个路由接受这两种 HTTP 方法。
成功填写联系表单后,用户将被重定向到/contact-confirmation
路由。这是有意为之,以避免重新提交表单错误,当用户尝试刷新网页时,如果我们仅仅使用/contact
路由本身打印出表单确认消息。
联系路由处理程序
ContactHandler
负责在 IGWEB 上呈现联系页面,联系表单将驻留在此处:
func ContactHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
我们声明并初始化formParams
变量为新初始化的FormParams
实例,提供ResponseWriter
和Request
字段的值:
formParams := isokit.FormParams{ResponseWriter: w, Request: r}
然后我们声明并初始化contactForm
变量,通过调用NewContactForm
函数并传入对formParams
结构的引用,使用新创建的ContactForm
实例。
contactForm := forms.NewContactForm(&formParams)
我们根据 HTTP 请求方法的类型进行switch
:
switch r.Method {
case "GET":
DisplayContactForm(env, contactForm)
case "POST":
validationResult := contactForm.Validate()
if validationResult == true {
submissions.ProcessContactForm(env, contactForm)
DisplayConfirmation(env, w, r)
} else {
DisplayContactForm(env, contactForm)
}
default:
DisplayContactForm(env, contactForm)
}
})
}
如果 HTTP 请求方法是GET
,我们调用DisplayContactForm
函数,传入env
对象和contactForm
对象。DisplayContactForm
函数将在联系页面上呈现联系表单。
如果 HTTP 请求方法是POST
,我们验证联系表单。请记住,如果使用POST
方法访问/contact
路由,这表明用户已经向路由提交了联系表单。我们声明并初始化validationResult
变量,将其设置为调用ContactForm
对象contactForm
的Validate
方法的结果的值。
如果validationResult
的值为 true,表单验证成功。我们调用submissions
包中的ProcessContactForm
函数,传入env
对象和ContactForm
对象。ProcessContactForm
函数负责处理成功的联系表单提交。然后我们调用DisplayConfirmation
函数,传入env
对象,http.ResponseWriter
,w
和*http.Request
,r
。
如果validationResult
的值为false
,控制流进入else
块,我们调用DisplayContactForm
函数,传入env
对象和ContactForm
对象contactForm
。这将再次呈现联系表单,这次用户将看到与未填写或未正确填写的字段相关的错误消息。
如果 HTTP 请求方法既不是GET
也不是POST
,我们达到默认条件,简单地调用DisplayContactForm
函数来显示联系表单。
这是DisplayContactForm
函数:
func DisplayContactForm(env *common.Env, contactForm *forms.ContactForm) {
templateData := &templatedata.Contact{PageTitle: "Contact", Form: contactForm}
env.TemplateSet.Render("contact_page", &isokit.RenderParams{Writer: contactForm.FormParams().ResponseWriter, Data: templateData})
}
该函数接受env
对象和ContactForm
对象作为输入参数。我们首先声明并初始化templateData
变量,它将作为我们将要提供给contact_page
模板的数据对象。我们创建一个新的templatedata.Contact
结构的实例,并将其PageTitle
字段填充为"Contact"
,将其Form
字段填充为传入函数的ContactForm
对象。
这是templatedata
包中的Contact
结构的样子:
type Contact struct {
PageTitle string
Form *forms.ContactForm
}
PageTitle
字段代表网页的页面标题,Form
字段代表ContactForm
对象。
然后我们在env.TemplateSet
对象上调用Render
方法,并传入我们希望呈现的模板名称contact_page
,以及等同模板呈现参数(RenderParams
)对象。我们已经将RenderParams
对象的Writer
字段分配为与ContactForm
对象相关联的ResponseWriter
,并将Data
字段分配为templateData
变量。
这是DisplayConfirmation
函数:
func DisplayConfirmation(env *common.Env, w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/contact-confirmation", 302)
}
这个函数负责执行重定向到确认页面。在这个函数中,我们简单地调用http
包中可用的Redirect
函数,并执行302
状态重定向到/contact-confirmation
路由。
现在我们已经介绍了联系页面的路由处理程序,是时候看看联系表单确认网页的路由处理程序了。
联系确认路由处理程序
ContactConfirmationHandler
函数的唯一目的是呈现联系确认页面:
func ContactConfirmationHandler(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
env.TemplateSet.Render("contact_confirmation_page", &isokit.RenderParams{Writer: w, Data: nil})
})
}
我们调用TemplateSet
对象的Render
方法,并指定要呈现contact_confirmation_page
模板,以及传入的RenderParams
结构。我们已经将结构的Writer
字段填充为http.ResponseWriter
,并将Data
对象的值分配为nil
,以指示没有要传递给模板的数据对象。
处理联系表单提交
在联系表单成功完成后,我们在submission
包中调用ProcessContactForm
函数。如果填写联系表单的工作流程就像打棒球一样,那么对ProcessContactForm
函数的调用可以被认为是到达本垒并得分。正如我们将在联系表单 Rest API 端点部分中看到的那样,这个函数也将被联系表单的 Rest API 端点调用。既然我们已经确定了这个函数的重要性,让我们继续并检查它:
func ProcessContactForm(env *common.Env, form *forms.ContactForm) {
log.Println("Successfully reached process content form function, indicating that the contact form was filled out properly resulting in a positive validation.")
contactRequest := &models.ContactRequest{FirstName: form.GetFieldValue("firstName"), LastName: form.GetFieldValue("lastName"), Email: form.GetFieldValue("email"), Message: form.GetFieldValue("messageBody")}
env.DB.CreateContactRequest(contactRequest)
}
我们首先打印出一个日志消息,指示我们已成功到达该函数,表明用户已正确填写了联系表单,并且用户输入的数据值得被处理。然后我们声明并初始化contactRequest
变量,使用新创建的ContactRequest
实例。
ContactRequest
结构的目的是对从联系表单收集的数据进行建模。以下是ContactRequest
结构的外观:
type ContactRequest struct {
FirstName string
LastName string
Email string
Message string
}
正如您所看到的,ContactRequest
结构中的每个字段对应于联系表单中存在的表单字段。我们通过在联系表单对象上调用GetFieldValue
方法并提供表单字段的名称,将ContactRequest
结构中的每个字段填充为其对应的用户输入值。
如前所述,成功的联系表单提交包括将联系请求信息存储在 Redis 数据库中:
env.DB.CreateContactRequest(contactRequest)
我们调用我们自定义 Redis 数据存储对象env.DB
的CreateContactRequest
方法,并将ContactRequest
对象contactRequest
传递给该方法。这个方法将联系请求信息保存到 Redis 数据库中:
func (r *RedisDatastore) CreateContactRequest(contactRequest *models.ContactRequest) error {
now := time.Now()
nowFormatted := now.Format(time.RFC822Z)
jsonData, err := json.Marshal(contactRequest)
if err != nil {
return err
}
if r.Cmd("SET", "contact-request|"+contactRequest.Email+"|"+nowFormatted, string(jsonData)).Err != nil {
return errors.New("Failed to execute Redis SET command")
}
return nil
}
CreateContactRequest
方法接受ContactRequest
对象作为唯一输入参数。我们对ContactRequest
值进行 JSON 编组,并将其存储到 Redis 数据库中。如果 JSON 编组过程失败或保存到数据库失败,则返回错误对象。如果没有遇到错误,我们返回nil
。
可访问的联系表单
此时,我们已经准备好测试联系表单了。但是,我们不是首先在基于 GUI 的网页浏览器中打开联系表单,而是首先看看使用 Lynx 网页浏览器对视障用户来说联系表单的可访问性如何。
乍一看,我们使用一个 25 年历史的纯文本网页浏览器来测试联系表单可能看起来有些奇怪。然而,Lynx 具有提供可刷新的盲文显示以及文本到语音功能的能力,这使得它成为了一个值得称赞的供视障人士使用的网页浏览技术。因为 Lynx 不支持显示图像和运行 JavaScript,我们可以很好地了解联系表单对于需要更大可访问性的用户来说的表现。
如果您在 Mac 上使用 Homebrew,可以轻松安装 Lynx,方法如下:
$ brew install lynx
如果您使用 Ubuntu,可以通过发出以下命令安装 Lynx:
$ sudo apt-get install lynx
如果您使用 Windows,可以从这个网页下载 Lynx:lynx.invisible-island.net/lynx2.8.8/index.html
。
您可以在维基百科上阅读有关 Lynx Web 浏览器的更多信息en.wikipedia.org/wiki/Lynx_(web_browser)
。
使用--nocolor
选项启动 lynx 时,我们启动igweb
Web 服务器实例:
$ lynx --nocolor localhost:8080/contact
图 7.6显示了 Lynx Web 浏览器中联系表格的外观:
图 7.6:Lynx Web 浏览器中的联系表格
现在,我们将部分填写联系表格,目的是测试表单验证逻辑是否有效。在电子邮件字段的情况下,我们将提供一个格式不正确的电子邮件地址,如图 7.7所示:
图 7.7:联系表格填写不正确
点击“联系”按钮后,请注意我们收到有关未正确填写的字段的错误消息,如图 7.8所示:
图 7.8:电子邮件地址字段和消息文本区域显示的错误消息
还要注意,我们收到了错误消息,告诉我们电子邮件地址格式不正确。
图 7.9显示了我们纠正所有错误后联系表格的外观:
图 7.9:联系表格填写正确
提交更正的联系表格后,我们看到确认消息,通知我们已成功填写联系表格,如图 7.10所示:
图 7.10:确认页面
使用 redis-cli 命令检查 Redis 数据库,我们可以验证我们收到了表单提交,如图 7.11所示:
图 7.11:在 Redis 数据库中验证新存储的联系请求条目
在这一点上,我们可以满意地知道我们已经使我们的联系表格对视力受损用户可访问,并且我们的努力并不多。让我们看看在禁用 JavaScript 的 GUI 型 Web 浏览器中联系表格的外观。
联系表格可以在没有 JavaScript 的情况下运行
在 Safari Web 浏览器中,我们可以通过在 Safari 的开发菜单中选择禁用 JavaScript 选项来禁用 JavaScript:
图 7.12:使用 Safari 的开发菜单禁用 JavaScript
图 7.13显示了图形用户界面(GUI)-基于 Web 浏览器的联系表格的外观:
图 7.13:GUI 型 Web 浏览器中的联系表格
我们遵循与 Lynx Web 浏览器上执行的相同的测试策略。我们部分填写表格并提供一个无效的电子邮件地址,如图 7.14所示:
图 7.14:联系表格填写不正确
点击“联系”按钮后,错误消息显示在有问题的字段旁边,如图 7.15所示:
图 7.15:错误消息显示在有问题的字段旁边
提交联系表格后,请注意我们收到有关填写不正确的字段的错误。纠正错误后,我们现在准备再次点击“联系”按钮提交表格,如图 7.16所示:
图 7.16:准备重新提交的正确填写的联系表格
提交联系表格后,我们被转发到/contact-confirmation
路由,并收到确认消息,联系表格已正确填写,如图 7.17所示:
图 7.17:确认页面
我们已经实现的基于服务器端的联系表单即使在启用 JavaScript 的情况下也将继续运行。您可能会想为什么我们需要在客户端实现联系表单?我们不能只使用基于服务器端的联系表单并结束吗?
答案归结为为用户提供增强的用户体验。仅使用基于服务器端的联系表单,我们会破坏用户正在体验的单页应用架构。敏锐的读者会意识到,提交表单和重新提交表单都需要完整的页面重新加载。HTTP 重定向到/contact-confirmation
路由也会破坏用户体验,因为它也会导致完整的页面重新加载。
为了在客户端实现联系表单,需要实现以下两个目标:
-
提供一致、无缝的单页应用体验
-
在客户端提供验证联系表单的能力
第一个目标,提供一致、无缝的单页应用体验,可以通过使用同构模板集来轻松实现,以将内容呈现到主要内容区域的div
容器中,就像我们在之前的章节中展示的那样。
第二个目标是在客户端验证联系表单的能力,由于 Web 浏览器启用了 JavaScript,这是可能的。有了这个能力,我们可以在客户端验证联系表单本身。考虑这样一种情况,我们有一个用户,在填写联系表单时不断犯错。我们可以减少向 Web 服务器发出的不必要的网络调用。只有在用户通过第一轮验证(在客户端)之后,表单才会通过网络提交到 Web 服务器,在那里进行最终的验证(在服务器端)。
客户端考虑
令人惊讶的是,在客户端上启用联系表单并不需要我们做太多工作。让我们逐节检查client/handlers
文件夹中找到的contact.go
源文件:
func ContactHandler(env *common.Env) isokit.Handler {
return isokit.HandlerFunc(func(ctx context.Context) {
contactForm := forms.NewContactForm(nil)
DisplayContactForm(env, contactForm)
})
}
这是我们的ContactHandler
函数,它将为客户端上的/contact
路由提供服务。我们首先声明并初始化contactForm
变量,将其分配给通过调用NewContactForm
构造函数返回的ContactForm
实例。
请注意,当我们通常应该传递一个FormParams
结构时,我们将nil
传递给构造函数。在客户端,我们将填充FormParams
结构的FormElement
字段,以将网页上的表单元素与contactForm
对象关联起来。然而,在呈现网页之前,我们遇到了一个“先有鸡还是先有蛋”的情况。我们无法填充FormParams
结构的FormElement
字段,因为网页上还不存在表单元素。因此,我们的首要任务是呈现联系表单,目前,我们将联系表单的FormParams
结构设置为nil
以实现这一点。稍后,我们将使用contactForm
对象的SetFormParams
方法设置contactForm
对象的FormParams
结构。
为了在网页上显示联系表单,我们调用DisplayContactForm
函数,传入env
对象和contactForm
对象。这个函数对于我们保持无缝的单页应用用户体验是至关重要的。DisplayContactForm
函数如下所示:
func DisplayContactForm(env *common.Env, contactForm *forms.ContactForm) {
templateData := &templatedata.Contact{PageTitle: "Contact", Form: contactForm}
env.TemplateSet.Render("contact_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
InitializeContactPage(env, contactForm)
}
我们声明并初始化templateData
变量,这将是我们传递给模板的数据对象。templateData
变量被分配一个新创建的templatedata
包中的Contact
实例,其PageTitle
属性设置为“联系”,Form
属性设置为contactForm
对象。
我们调用env.TemplateSet
对象的Render
方法,并指定我们希望渲染"contact_content"
模板。我们还向Render
方法提供了等同渲染参数(RenderParams
),将Data
字段设置为templateData
变量,并将Disposition
字段设置为isokit.PlacementReplaceInnerContents
,声明了我们将如何相对于关联元素渲染模板内容。通过将Element
字段设置为env.PrimaryContent
,我们指定主要内容div
容器将是模板将要渲染到的关联元素。最后,我们将PageTitle
属性设置为动态更改网页标题,当用户从客户端着陆在/contact
路由时。
我们调用InitializeContactPage
函数,提供env
对象和contactForm
对象。回想一下,InitializeContactPage
函数负责为联系页面设置用户交互相关的代码(事件处理程序)。让我们检查InitializeContactPage
函数:
func InitializeContactPage(env *common.Env, contactForm *forms.ContactForm) {
formElement := env.Document.GetElementByID("contactForm").(*dom.HTMLFormElement)
contactForm.SetFormParams(&isokit.FormParams{FormElement: formElement})
contactButton := env.Document.GetElementByID("contactButton").(*dom.HTMLInputElement)
contactButton.AddEventListener("click", false, func(event dom.Event) {
handleContactButtonClickEvent(env, event, contactForm)
})
}
我们调用env.Document
对象的GetElementByID
方法来获取联系表单元素,并将其赋值给变量formElement
。我们调用SetFormParams
方法,提供一个FormParams
结构,并用formElement
变量填充其FormElement
字段。此时,我们已经为contactForm
对象设置了表单参数。我们通过调用env.Document
对象的GetElementByID
方法并提供id
为"contactButton"
来获取联系表单的button
元素。
我们在联系button
的点击事件上添加了一个事件监听器,它将调用handleContactButtonClickEvent
函数,并传递env
对象、event
对象和contactForm
对象。handleContactButtonClickEvent
函数非常重要,因为它将在客户端运行表单验证,如果验证成功,它将在服务器端发起 XHR 调用到 Rest API 端点。以下是handleContactButtonClickEvent
函数的代码:
func handleContactButtonClickEvent(env *common.Env, event dom.Event, contactForm *forms.ContactForm) {
event.PreventDefault()
clientSideValidationResult := contactForm.Validate()
if clientSideValidationResult == true {
contactFormErrorsChannel := make(chan map[string]string)
go ContactFormSubmissionRequest(contactFormErrorsChannel, contactForm)
我们首先抑制点击联系按钮的默认行为,这将提交整个网页表单。这种默认行为源于联系button
元素是一个input
类型为submit
的元素,当点击时默认行为是提交网页表单。
然后我们声明并初始化clientSideValidationResult
,一个布尔变量,赋值为调用contactForm
对象的Validate
方法的结果。如果clientSideValidationResult
的值为false
,我们进入else
块,在那里调用contactForm
对象的DisplayErrors
方法。DisplayErrors
方法是从isokit
包中的BasicForm
类型提供给我们的。
如果clientSideValidationResult
的值为 true,这意味着表单在客户端成功验证。此时,联系表单提交已经通过了客户端的第一轮验证。
为了开始第二(也是最后)一轮验证,我们需要调用服务器端的 Rest API 端点,负责验证表单内容并重新运行相同的验证。我们创建了一个名为contactFormErrorsChannel
的通道,这是一个我们将通过其发送map[string]string
值的通道。我们将ContactFormSubmissionRequest
函数作为一个 goroutine 调用,传入通道contactFormErrorsChannel
和contactForm
对象。ContactFormSubmissionRequest
函数将在服务器端发起 XHR 调用,验证服务器端的联系表单。一组错误将通过contactFormErrorsChannel
发送。
让我们在返回handleContactButtonClickEvent
函数之前快速查看ContactFormSubmissionRequest
函数:
func ContactFormSubmissionRequest(contactFormErrorsChannel chan map[string]string, contactForm *forms.ContactForm) {
jsonData, err := json.Marshal(contactForm.Fields())
if err != nil {
println("Encountered error: ", err)
return
}
data, err := xhr.Send("POST", "/restapi/contact-form", jsonData)
if err != nil {
println("Encountered error: ", err)
return
}
var contactFormErrors map[string]string
json.NewDecoder(strings.NewReader(string(data))).Decode(&contactFormErrors)
contactFormErrorsChannel <- contactFormErrors
}
在ContactFormSubmissionRequest
函数中,我们对contactForm
对象的字段进行 JSON 编组,并通过调用xhr
包中的Send
函数向 Web 服务器发出 XHR 调用。我们指定 XHR 调用将使用POST
HTTP 方法,并将发布到/restapi/contact-form
端点。我们将联系表单字段的 JSON 编码数据作为Send
函数的最后一个参数传入。
如果在 JSON 编组过程中或在进行 XHR 调用时没有错误,我们获取从服务器检索到的数据,并尝试将其从 JSON 格式解码为contactFormErrors
变量。然后我们通过通道contactFormErrorsChannel
发送contactFormErrors
变量。
现在,让我们回到handleContactButtonClickEvent
函数:
go func() {
serverContactFormErrors := <-contactFormErrorsChannel
serverSideValidationResult := len(serverContactFormErrors) == 0
if serverSideValidationResult == true {
env.TemplateSet.Render("contact_confirmation_content", &isokit.RenderParams{Data: nil, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent})
} else {
contactForm.SetErrors(serverContactFormErrors)
contactForm.DisplayErrors()
}
}()
} else {
contactForm.DisplayErrors()
}
}
为了防止在事件处理程序中发生阻塞,我们创建并运行一个匿名的 goroutine 函数。我们将错误的map
接收到serverContactFormErrors
变量中,从contactFormErrorsChannel
中。serverSideValidationResult
布尔变量负责通过检查错误map
的长度来确定联系表单中是否存在错误。如果错误的长度为零,表示联系表单提交中没有错误。如果长度大于零,表示联系表单提交中存在错误。
如果severSideValidationResult
布尔变量的值为true
,我们在等同模板集上调用Render
方法,渲染contact_confirmation_content
模板,并传入等同模板渲染参数。在RenderParams
对象中,我们将Data
字段设置为nil
,因为我们不会向模板传递任何数据对象。我们为Disposition
字段指定值isokit.PlacementReplaceInnerContents
,表示我们将对关联元素执行替换内部 HTML 操作。我们将Element
字段设置为关联元素,即主要内容div
容器,因为这是模板将要渲染到的位置。
如果serverSideValidationResult
布尔变量的值为false
,这意味着表单仍然包含需要纠正的错误。我们在contactForm
对象上调用SetErrors
方法,传入serverContactFormErrors
变量。然后我们在contactForm
对象上调用DisplayErrors
方法,将错误显示给用户。
我们几乎完成了,我们在客户端实现联系表单的唯一剩下的事项是实现服务器端的 Rest API 端点,对联系表单提交进行第二轮验证。
联系表单 Rest API 端点
在igweb.go
源文件中,我们已经注册了/restapi/contact-form
端点及其关联的处理函数ContactFormEndpoint
:
r.Handle("/restapi/contact-form", endpoints.ContactFormEndpoint(env)).Methods("POST")
ContactFormEndpoint
函数负责为/restapi/contact-form
端点提供服务:
func ContactFormEndpoint(env *common.Env) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var fields map[string]string
reqBody, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Print("Encountered error when attempting to read the request body: ", err)
}
err = json.Unmarshal(reqBody, &fields)
if err != nil {
log.Print("Encountered error when attempting to unmarshal json data: ", err)
}
formParams := isokit.FormParams{ResponseWriter: w, Request: r, UseFormFieldsForValidation: true, FormFields: fields}
contactForm := forms.NewContactForm(&formParams)
validationResult := contactForm.Validate()
if validationResult == true {
submissions.ProcessContactForm(env, contactForm)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(contactForm.Errors())
})
}
该函数的目的是提供联系表单的服务器端验证,并返回 JSON 编码的错误map
。我们创建一个fields
变量,类型为map[string]string
,表示联系表单中的字段。我们读取请求体,其中包含 JSON 编码的字段map
。然后我们将 JSON 编码的字段map
解封到fields
变量中。
我们创建一个新的FormParams
实例,并将其分配给变量formParams
。在FormParams
结构中,我们为ResponseWriter
字段指定了http.ResponseWriter
w
的值,为Request
字段指定了*http.Request
r
的值。我们将UseFormFieldsForValidation
字段设置为true
。这样做将改变默认行为,从请求中获取特定字段的表单值,而是从联系表单的formFields
map
中获取表单字段的值。最后,我们将FormFields
字段设置为fields
变量,即我们从请求体中 JSON 解组得到的字段map
。
我们通过调用NewContactForm
函数并传入formParams
对象的引用来创建一个新的contactForm
对象。为了进行服务器端验证,我们只需在contactForm
对象上调用Validate
方法,并将方法调用的结果分配给validationResult
变量。请记住,客户端上存在的相同验证代码也存在于服务器端,并且我们在这里并没有做什么特别的,只是从服务器端调用验证逻辑,假设它不会被篡改。
如果validationResult
的值为true
,这意味着联系表单已经通过了服务器端的第二轮表单验证,我们可以调用submissions
包中的ProcessContactForm
函数,传入env
对象和contactForm
对象。请记住,当成功验证联系表单时,调用ProcessContactForm
函数意味着我们已经到达了本垒并得分。
如果validationResult
的值为false
,我们无需做任何特别的事情。在调用对象的Validate
方法后,contactForm
对象的Errors
字段将被填充。如果没有错误,Errors
字段将只是一个空的map
。
我们向客户端发送一个头部,指示服务器将发送 JSON 对象响应。然后,我们将contactForm
对象的map
错误编码为其 JSON 表示,并使用http.ResponseWriter
w
将其写入客户端。
检查客户端验证
现在我们已经准备好联系表单的客户端验证。让我们打开启用了 JavaScript 的网络浏览器。同时打开网络检查器以检查网络调用,如图 7.18所示:
图 7.18:打开网络检查器的联系表单
首先,我们将部分填写联系表单,如图 7.19所示:
图 7.19:填写不正确的联系表单
点击联系按钮后,我们将在客户端触发表单验证错误,如图 7.20所示。请注意,当我们这样做时,无论我们点击联系按钮多少次,都不会向服务器发出网络调用:
图 7.20:执行客户端验证后显示错误消息。请注意,没有向服务器发出网络调用
现在,让我们纠正联系表单中的错误(如图 7.21所示)并准备重新提交:
图 7.21:填写完整的联系表单,准备重新提交
重新提交表单后,我们收到确认消息,如图 7.22所示:
图 7.22:进行 XHR 调用,包含表单数据,并在成功的服务器端表单验证后呈现确认消息
请注意,如图 7.23所示,发起了一个 XHR 调用到 Web 服务器。检查调用的响应,我们可以看到从端点响应返回的空对象({}
)表示errors
map
为空,表明表单提交成功:
图 7.23:XHR 调用返回了一个空的错误映射,表明表单成功通过了服务器端的表单验证
现在我们已经验证了客户端验证逻辑在联系表单上的工作,我们必须强调一个重要的观点,即在接受来自客户端的数据时非常重要的一点。服务器必须始终拥有否决权,当涉及到验证用户输入的数据时。在服务器端执行的第二轮验证应该是一个强制性的步骤。让我们看看为什么我们总是需要服务器端验证。
篡改客户端验证结果
让我们考虑这样一种情况,即我们有一个邪恶(而且聪明)的用户,他知道如何绕过我们的客户端验证逻辑。毕竟,这是 JavaScript,在 Web 浏览器中运行。没有什么能阻止一个恶意用户将我们的客户端验证逻辑抛到脑后。为了模拟这样的篡改事件,我们只需要在contact.go
源文件中将clientSideValidationResult
变量的布尔值赋值为true
。
func handleContactButtonClickEvent(env *common.Env, event dom.Event, contactForm *forms.ContactForm) {
event.PreventDefault()
clientSideValidationResult := contactForm.Validate()
clientSideValidationResult = true
if clientSideValidationResult == true {
contactFormErrorsChannel := make(chan map[string]string)
go ContactFormSubmissionRequest(contactFormErrorsChannel, contactForm)
go func() {
serverContactFormErrors := <-contactFormErrorsChannel
serverSideValidationResult := len(serverContactFormErrors) == 0
if serverSideValidationResult == true {
env.TemplateSet.Render("contact_confirmation_content", &isokit.RenderParams{Data: nil, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent})
} else {
contactForm.SetErrors(serverContactFormErrors)
contactForm.DisplayErrors()
}
}()
} else {
contactForm.DisplayErrors()
}
}
在这一点上,我们已经绕过了客户端验证的真正结果,强制客户端网络应用程序始终通过客户端进行的联系表单验证。如果我们仅在客户端执行表单验证,这将使我们陷入非常糟糕的境地。这正是为什么我们需要在服务器端进行第二轮验证的原因。
让我们再次打开 Web 浏览器,部分填写表单,如图 7.24所示:
图 7.24:即使禁用了客户端表单验证,服务器端表单验证也阻止了填写不正确的联系表单被提交
请注意,这一次,当单击联系按钮时,将发起 XHR 调用到服务器端的 Rest API 端点,返回联系表单中的错误map
,如图 7.25所示:
图 7.25:服务器响应中的错误映射填充了一个错误,指示电子邮件地址字段中输入的值具有不正确的语法
在服务器端执行的第二轮验证已经启动,并阻止了恶意用户能够到达本垒并得分。如果客户端验证无法正常工作,服务器端验证将捕获到不完整或格式不正确的表单字段。这是为什么您应该始终为您的网络表单实现服务器端表单验证的一个重要原因。
总结
在本章中,我们演示了构建一个可访问的、同构的网络表单的过程。首先,我们演示了同构网络表单在禁用 JavaScript 和启用 JavaScript 的情况下的流程。
我们向您展示了如何创建一个同构的网络表单,它具有在各种环境中共享表单代码和验证逻辑的能力。在表单包含错误的情况下,我们向您展示了如何以有意义的方式向用户显示错误。创建的同构网络表单非常健壮,并能够在 Web 浏览器中禁用 JavaScript 或 JavaScript 运行时不存在的情况下(例如 Lynx Web 浏览器),以及在启用 JavaScript 的 Web 浏览器中运行。
我们演示了使用 Lynx 网络浏览器测试可访问的同构网络表单,以验证该表单对需要更大可访问性的用户是否可用。我们还验证了即使在一个配备了 JavaScript 运行时的网络浏览器中,该表单也能正常运行,即使 JavaScript 被禁用。
在 JavaScript 启用的情况下,我们向您展示了如何在客户端验证表单并在执行客户端验证后将数据提交到 Rest API 端点。即使在方便且具有更高能力的客户端验证表单的情况下,我们强调了始终在服务器端验证表单的重要性,通过演示服务器端表单验证启动的情景,即使在潜在的情况下,客户端验证结果被篡改。
用户与联系表单之间的交互非常简单。用户必须正确填写表单才能将数据提交到服务器,最终表单数据将被处理。在下一章中,我们将超越这种简单的交互,考虑用户和网络应用程序以一种几乎类似对话的方式进行交流的情景。在第八章中,《实时网络应用功能》,我们将实现 IGWEB 的实时聊天功能,允许网站用户与聊天机器人进行简单的问答对话。
第八章:实时 Web 应用功能
在上一章中,我们考虑了如何通过 Web 表单验证和处理用户生成的数据。当用户正确填写联系表单时,它成功通过了两轮验证,并且用户会收到确认消息。一旦表单被提交,工作流程就完成了。如果我们想考虑一个更有吸引力的工作流程,一个用户可以以对话的方式与服务器端应用程序进行交互的工作流程呢?
今天的 Web 与蒂姆·伯纳斯-李(Tim Berners-Lee)在 1990 年代初设计的起步阶段的 Web 大不相同。当时,Web 的重点是超链接连接的文档。客户端和服务器之间的 HTTP 事务一直意味着短暂存在。
在 21 世纪初,这种情况开始发生变化。研究人员展示了服务器如何能够与客户端保持持久连接的手段。客户端的早期原型是使用 Adobe Flash 创建的,这是当时唯一可用的技术之一,用于在 Web 服务器和 Web 客户端之间建立持久连接。
与这些早期尝试并行的是,一种效率低下的时代诞生了,即 AJAX(XHR)长轮询。客户端将继续向服务器发出调用(类似于心跳检查),并检查客户端感兴趣的某些状态是否发生了变化。服务器将返回相同的、疲惫的响应,直到客户端感兴趣的状态发生变化,然后可以将其报告给客户端。这种方法的主要低效性在于 Web 客户端和 Web 服务器之间必须进行的网络调用数量。不幸的是,AJAX 长轮询的低效做法变得如此流行,以至于今天仍被许多网站广泛使用。
实时 Web 应用功能的理念是通过几乎实时地提供信息来提供更好的用户体验。请记住,由于网络延迟和物理定律对信号的限制,没有任何通信是真正的“实时”,而是“几乎实时”。
实现实时 Web 应用功能的主要组成部分是 WebSocket,这是一种允许 Web 服务器和 Web 客户端之间进行双向通信的协议。由于 Go 具有用于网络和 Web 编程的内置功能,因此 Go 是实现实时 Web 应用程序的理想编程语言。
在本章中,我们将构建一个实时 Web 应用程序功能的实时聊天应用程序,这将允许网站用户与一个基本的聊天机器人进行对话。当用户向机器人提问时,机器人将实时回复,并且用户与机器人之间的所有通信都将通过 Web 浏览器和 Web 服务器之间的 WebSocket 连接进行。
在本章中,我们将涵盖以下主题:
-
实时聊天功能
-
实现实时聊天的服务器端功能
-
实现实时聊天的客户端功能
-
与代理进行对话
实时聊天功能
当今,很常见看到聊天机器人(也称为代理)为网站用户提供各种目的的服务,从决定购买什么鞋子到提供有关哪些股票适合客户投资组合的建议。我们将构建一个基本的聊天机器人,为 IGWEB 用户提供有关同构 Go 的友好提示。
一旦激活了实时聊天功能,用户可以继续访问网站的不同部分,而不会因为使用网站上的导航菜单或链接而中断与机器人的对话。在现实世界的场景中,这种功能对于产品销售和技术支持的使用场景都是一个有吸引力的选择。例如,如果用户对网站上列出的某个产品有疑问,用户可以自由浏览网站,而不必担心失去与代理人的当前聊天对话。
请记住,我们将构建的代理具有较低的智能水平。这里仅用于说明目的,并且在生产需求中应该使用更健壮的人工智能(AI)解决方案。通过本章您将获得的知识,应该可以相当轻松地用更健壮的代理的大脑替换当前的代理,以满足实时聊天功能中的特定需求。
设计实时聊天框
以下图是 IGWEB 顶部栏的线框设计。最右边的图标在点击时将激活实时聊天功能:
图 8.1:IGWEB 顶部栏的线框设计
以下图是实时聊天框的线框设计。聊天框包括代理人“Case”的头像图像以及其姓名和职称。关闭按钮包括在聊天框的右上角。用户可以在底部文本区域输入他们的消息,该区域具有占位文本“在此输入您的消息”。与人类和机器人的对话将显示在聊天框的中间区域:
图 8.2:实时聊天框的线框设计
实现实时聊天框模板
为了在网站的所有部分中都有聊天框,我们需要将聊天框div
容器放置在网页布局模板(layouts/webpage_layout.tmpl
)中主要内容div
容器的正下方:
<!doctype html>
<html>
{{ template "partials/header_partial" . }}
<div id="primaryContent" class="pageContent">
{{ template "pagecontent" . }}
</div>
<div id="chatboxContainer" class="containerPulse">
</div>
{{ template "partials/footer_partial" . }}
</html>
聊天框将作为shared/templates/partials
文件夹中的chatbox_partial.tmpl
源文件中的部分模板来实现:
<div id="chatbox">
<div id="chatboxHeaderBar" class="chatboxHeader">
<div id="chatboxTitle" class="chatboxHeaderTitle"><span>Chat with {{.AgentName}}</span></div>
<div id="chatboxCloseControl">X</div>
</div>
<div class="chatboxAgentInfo">
<div class="chatboxAgentThumbnail"><img src="img/{{.AgentThumbImagePath}}" height="81px"></div>
<div class="chatboxAgentName">{{.AgentName}}</div>
<div class="chatboxAgentTitle">{{.AgentTitle}}</div>
</div>
<div id="chatboxConversationContainer">
</div>
<div id="chatboxMsgInputContainer">
<input type="text" id="chatboxInputField" placeholder="Type your message here...">
</input>
</div>
<div class="chatboxFooter">
<a href="http://www.isomorphicgo.org" target="_blank">Powered by Isomorphic Go</a>
</div>
</div>
这是实现实时聊天框图 8.2中所示线框设计所需的 HTML 标记。请注意,input
文本字段具有 id"chatboxInputField"
。这是用户将能够输入其消息的input
字段。创建的每条消息,无论是用户编写的消息还是机器人编写的消息,都将使用livechatmsg_partial.tmpl
模板:
<div class="chatboxMessage">
<div class="chatSenderName">{{.Name}}</div>
<div class="chatSenderMsg">{{.Message}}</div>
</div>
每条消息都在自己的div
容器中,其中有两个div
容器(以粗体显示),分别包含消息发送者的姓名和消息本身。
在实时聊天功能中不需要按钮,因为我们将添加一个事件侦听器来监听按下 Enter 键以通过 WebSocket 连接将用户的消息提交到服务器。
现在我们已经实现了用于呈现聊天框的 HTML 标记,让我们来检查在服务器端实现实时聊天功能所需的功能。
实现实时聊天的服务器端功能
当实时聊天功能激活时,我们将在 Web 客户端和 Web 服务器之间创建一个持久的 WebSocket 连接。Gorilla Web Toolkit 在其websocket
包中提供了对 WebSocket 协议的出色实现,该包可以在github.com/gorilla/websocket
找到。要获取websocket
包,可以发出以下命令:
$ go get github.com/gorilla/websocket
Gorilla Web Toolkit 还提供了一个有用的示例 Web 聊天应用程序:
github.com/gorilla/websocket/tree/master/examples/chat
。
我们将重新利用大猩猩的示例网络聊天应用程序,而不是重新发明轮子,以实现实时聊天功能。从网络聊天示例中需要的源文件已经复制到chat
文件夹中。
我们需要进行三个重大改变,以利用大猩猩提供的示例聊天应用程序实现实时聊天功能:
-
聊天机器人(代理)的回复应该针对特定用户,而不是发送给所有连接的用户
-
我们需要创建功能,允许聊天机器人向用户发送消息
-
我们需要在 Go 中实现聊天应用程序的前端部分
让我们更详细地考虑这三点。
首先,大猩猩的网络聊天示例是一个自由聊天室。任何用户都可以进来,输入消息,所有连接到聊天服务器的其他用户都能看到消息。实时聊天功能的一个主要要求是,聊天机器人和人之间的每次对话都应该是独占的。代理的回复必须针对特定用户,而不是所有连接的用户。
其次,大猩猩网络工具包中的示例网络聊天应用程序不会向用户发送任何消息。这就是自定义聊天机器人出现的地方。代理将直接通过已建立的 WebSocket 连接与用户通信。
第三,示例网络聊天应用程序的前端部分是作为包含内联 CSS 和 JavaScript 的 HTML 文档实现的。正如你可能已经猜到的那样,我们将在 Go 中实现实时聊天功能的前端部分,代码将驻留在client/chat
文件夹中。
既然我们已经制定了使用大猩猩网络聊天示例作为起点来实现实时聊天功能的行动计划,让我们开始实施吧。
我们将创建的修改后的网络聊天应用程序包含两种主要类型:Hub
和Client
。
中心类型
聊天中心负责维护客户端连接列表,并指示聊天机器人向相关客户端广播消息。例如,如果 Alice 问了“什么是同构 Go?*”,聊天机器人的答案应该发给 Alice,而不是 Bob(他可能还没有问问题)。
Hub
结构如下:
type Hub struct {
chatbot bot.Bot
clients map[*Client]bool
broadcastmsg chan *ClientMessage
register chan *Client
unregister chan *Client
}
chatbot
是一个实现Bot
接口的聊天机器人(代理)。这是将回答从客户端收到的问题的大脑。
clients
映射用于注册客户端。存储在map
中的键值对包括键,指向Client
实例的指针,值包括一个布尔值,设置为true
,表示客户端已连接。客户端通过broadcastmsg
、register
和unregister
通道与中心通信。register
通道向中心注册客户端。unregister
通道向中心注销客户端。客户端通过broadcastmsg
通道发送用户输入的消息,这是一个ClientMessage
类型的通道。这是我们引入的ClientMessage
结构:
type ClientMessage struct {
client *Client
message []byte
}
为了实现我们之前提出的第一个重大变化,即代理和用户之间的对话的独占性,我们使用ClientMessage
结构来存储Client
实例的指针,以及用户的消息本身(一个byte
切片)。
构造函数NewHub
接受实现Bot
接口的chatbot
,并返回一个新的Hub
实例:
func NewHub(chatbot bot.Bot) *Hub {
return &Hub{
chatbot: chatbot,
broadcastmsg: make(chan *ClientMessage),
register: make(chan *Client),
unregister: make(chan *Client),
clients: make(map[*Client]bool),
}
}
我们实现了一个导出的获取方法ChatBot
,以便从Hub
对象中访问chatbot
:
func (h *Hub) ChatBot() bot.Bot {
return h.chatbot
}
当我们实现一个 Rest API 端点来将机器人的详细信息(名称、标题和头像图像)发送给客户端时,这个行动将是重要的。
SendMessage
方法负责向特定客户端广播消息:
func (h *Hub) SendMessage(client *Client, message []byte) {
client.send <- message
}
该方法接受一个指向Client
的指针和message
,这是应该发送给特定客户端的byte
切片。消息将通过客户端的send
通道发送。
调用Run
方法启动聊天 hub:
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
greeting := h.chatbot.Greeting()
h.SendMessage(client, []byte(greeting))
case client := <-h.unregister:
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
case clientmsg := <-h.broadcastmsg:
client := clientmsg.client
reply := h.chatbot.Reply(string(clientmsg.message))
h.SendMessage(client, []byte(reply))
}
}
}
我们在for
循环内使用select
语句等待多个客户端操作。
如果通过 hub 的register
通道传入了一个Client
的指针,hub 将通过将client
指针(作为键)添加到客户端map
中并为其设置一个值为true
来注册新客户端。我们将调用chatbot
的Greeting
方法获取要返回给客户端的greeting
消息。一旦我们得到了问候语(字符串值),我们调用SendMessage
方法,传入client
和转换为byte
切片的greeting
。
如果通过 hub 的unregister
通道传入了一个Client
的指针,hub 将删除给定client
的map
中的条目,并关闭客户端的send
通道,这表示该client
不会再向服务器发送任何消息。
如果通过 hub 的broadcastmsg
通道传入了一个ClientMessage
的指针,hub 将把客户端的message
(作为字符串值)传递给chatbot
对象的Reply
方法。一旦我们得到了来自代理的reply
(字符串值),我们调用SendMessage
方法,传入client
和转换为byte
切片的reply
。
客户端类型
Client
类型充当Hub
和websocket
连接之间的代理。
以下是Client
结构的样子:
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
每个Client
值都包含指向Hub
的指针,指向websocket
连接的指针以及用于出站消息的缓冲通道send
。
readPump
方法负责将通过websocket
连接传入的入站消息中继到 hub:
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
log.Printf("error: %v", err)
}
break
}
message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
// c.hub.broadcast <- message
clientmsg := &ClientMessage{client: c, message: message}
c.hub.broadcastmsg <- clientmsg
}
}
我们不得不对这个函数进行轻微的更改,以满足实时聊天功能的要求。在 Gorilla Web 聊天示例中,仅仅是将消息中继到Hub
。由于我们正在将聊天机器人的响应发送回发送它们的客户端,我们不仅需要将消息发送到 hub,还需要将发送消息的客户端也发送到 hub。我们通过创建一个ClientMessage
结构来实现这一点:
type ClientMessage struct {
client *Client
message []byte
}
ClientMessage
结构包含字段,用于保存客户端的指针以及message
,一个byte
切片。
回到client.go
源文件中的readPump
函数,以下两行对于Hub
知道哪个客户端发送了消息至关重要。
clientmsg := &ClientMessage{client: c, message: message}
c.hub.broadcastmsg <- clientmsg
writePump
方法负责从客户端的send
通道中中继出站消息到websocket
连接:
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// The hub closed the channel.
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
// Add queued chat messages to the current websocket message.
n := len(c.send)
for i := 0; i < n; i++ {
w.Write(newline)
w.Write(<-c.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
return
}
}
}
}
ServeWS
方法旨在由 Web 应用程序注册为 HTTP 处理程序:
func ServeWs(hub *Hub) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
client.hub.register <- client
go client.writePump()
client.readPump()
})
}
该方法执行两个重要任务。该方法将普通的 HTTP 连接升级为websocket
连接,并将客户端注册到 hub。
现在我们已经为我们的 Web 聊天服务器设置了代码,是时候在我们的 Web 应用程序中激活它了。
激活聊天服务器
在igweb.go
源文件中,我们包含了一个名为startChatHub
的函数,它负责启动Hub
:
func startChatHub(hub *chat.Hub) {
go hub.Run()
}
我们在main
函数中添加以下代码来创建一个新的 chatbot,将其与Hub
关联并启动Hub
:
chatbot := bot.NewAgentCase()
hub := chat.NewHub(chatbot)
startChatHub(hub)
当我们调用registerRoutes
函数为服务器端 Web 应用程序注册所有路由时,请注意我们还向函数传递了hub
值:
r := mux.NewRouter()
registerRoutes(&env, r, hub)
在registerRoutes
函数中,我们需要hub
来为返回代理信息的 Rest API 端点注册路由处理程序:
r.Handle("/restapi/get-agent-info", endpoints.GetAgentInfoEndpoint(env, hub.ChatBot()))
我们将在向客户端公开代理信息部分介绍这个端点。
hub
还用于注册 WebSocket 路由/ws
的路由处理程序。我们注册ServeWS
处理程序函数,传入hub
:
r.Handle("/ws", chat.ServeWs(hub))
现在我们已经准备好激活聊天服务器,是时候专注于实时聊天功能的明星——聊天代理了。
代理的大脑
我们将用于实现实时聊天功能的聊天机器人类型AgentCase
将实现以下Bot 接口
:
type Bot interface {
Greeting() string
Reply(string) string
Name() string
Title() string
ThumbnailPath() string
SetName(string)
SetTitle(string)
SetThumbnailPath(string)
}
Greeting
方法将用于向用户发送初始问候,诱使他们与聊天机器人互动。
Reply
方法接受一个问题(字符串),并返回给定问题的回复(也是一个字符串)。
其余实现的方法纯粹是出于心理原因,让人类产生与某人交流而不是某物的错觉。
Name
方法是一个 getter 方法,返回聊天机器人的名称。
Title
方法是一个 getter 方法,返回聊天机器人的标题。
ThumbnailPath
方法是一个 getter 方法,返回聊天机器人的头像图像的路径。
每个 getter 方法都有一个对应的 setter 方法:SetName
、SetTitle
和SetThumbnailPath
。
通过定义Bot
接口,我们清楚地说明了聊天机器人的期望。这使我们能够在将来使聊天机器人解决方案具有可扩展性。例如,Case
展示的智能可能过于基础和限制。在不久的将来,我们可能希望实现一个名为 Molly 的机器人,其智能可能使用更强大的算法实现。只要 Molly 聊天机器人实现了Bot
接口,新的聊天机器人就可以轻松地插入到我们的 Web 应用程序中。
实际上,从服务器端 Web 应用程序的角度来看,这只是一行代码的更改。我们将实例化一个AgentMolly
实例,而不是实例化一个AgentCase
实例。除了智能上的差异,新的聊天机器人 Molly 将具有自己的名称、标题和头像图像,因此人类可以将其与Case
区分开来。
以下是AgentCase
结构:
type AgentCase struct {
Bot
name string
title string
thumbnailPath string
knowledgeBase map[string]string
knowledgeCorpus []string
sampleQuestions []string
}
我们已经将Bot
接口嵌入到struct
定义中,表明AgentCase
类型将实现Bot
接口。name
字段是代理的名称。title
字段是代理的标题。thumbnailPath
字段用于指定聊天机器人头像图像的路径。
knowledgeBase
字段是map
类型的map[string]string
。这本质上是代理的大脑。map
中的键是特定问题中发现的常见术语。map
中的值是问题的答案。
knowledgeCorpus
字段是一个字符串byte
切片,是机器人可能被问到的问题中存在的术语的知识语料库。我们使用knowledgeBase
映射的键来构建knowledgeCorpus
。语料库是用于进行语言分析的文本集合。在我们的情况下,我们将根据人类用户提供给机器人的问题(查询)进行语言分析。
sampleQuestions
字段是一个字符串byte
切片,其中包含用户可能向聊天机器人提出的示例问题列表。聊天机器人在问候用户时将向用户提供一个示例问题,以诱使人类用户进行对话。人类用户可以根据自己的喜好自由地改写示例问题或提出完全不同的问题。
initializeIntelligence
方法用于初始化 Case 的大脑:
func (a *AgentCase) initializeIntelligence() {
a.knowledgeBase = map[string]string{
"isomorphic go isomorphic go web applications": "Isomorphic Go is the methodology to create isomorphic web applications using the Go (Golang) programming language. An isomorphic web application, is a web application, that contains code which can run, on both the web client and the web server.",
"kick recompile code restart web server instance instant kickstart lightweight mechanism": "Kick is a lightweight mechanism to provide an instant kickstart to a Go web server instance, upon the modification of a Go source file within a particular project directory (including any subdirectories). An instant kickstart consists of a recompilation of the Go code and a restart of the web server instance. Kick comes with the ability to take both the go and gopherjs commands into consideration when performing the instant kickstart. This makes it a really handy tool for isomorphic golang projects.",
"starter code starter kit": "The isogoapp, is a basic, barebones web app, intended to be used as a starting point for developing an Isomorphic Go application. Here's the link to the github page: https://github.com/isomorphicgo/isogoapp",
"lack intelligence idiot stupid dumb dummy don't know anything": "Please don't question my intelligence, it's artificial after all!",
"find talk topic presentation lecture subject": "Watch the Isomorphic Go talk by Kamesh Balasubramanian at GopherCon India: https://youtu.be/zrsuxZEoTcs",
"benefits of the technology significance of the technology importance of the technology": "Here are some benefits of Isomorphic Go: Unlike JavaScript, Go provides type safety, allowing us to find and eliminate many bugs at compile time itself. Eliminates mental context-shifts between back-end and front-end coding. Page loading prompts are not necessary.",
"perform routing web app register routes define routes": "You can implement client-side routing in your web application using the isokit Router preventing the dreaded full page reload.",
"render templates perform template rendering": "Use template sets, a set of project templates that are persisted in memory and are available on both the server-side and the client-side",
"cogs reusable components react-like react": "Cogs are reuseable components in an Isomorphic Go web application.",
}
a.knowledgeCorpus = make([]string, 1)
for k, _ := range a.knowledgeBase {
a.knowledgeCorpus = append(a.knowledgeCorpus, k)
}
a.sampleQuestions = []string{"What is isomorphic go?", "What are the benefits of this technology?", "Does isomorphic go offer anything react-like?", "How can I recompile code instantly?", "How can I perform routing in my web app?", "Where can I get starter code?", "Where can I find a talk on this topic?"}
}
在这个方法中有三个重要的任务:
-
首先,我们设置 Case 的知识库。
-
其次,我们设置 Case 的知识语料库。
-
第三,我们设置 Case 将在问候人类用户时使用的示例问题。
我们必须处理的第一个任务是设置 Case 的知识库。这包括设置AgentCase
实例的knowledgeBase
属性。如前所述,map
中的键指的是问题中的术语,map
中的值是问题的答案。例如,"同构 go 同构 go web 应用程序"
键可以处理以下问题:
-
什么是同构 Go?
-
你能告诉我关于同构 Go 的情况吗?
它还可以处理不是问题的陈述:
-
介绍一下同构 Go
-
给我一个关于同构 Go 的概述
由于knowledgeBase
映射的地图文字声明中包含大量文本,我建议您在计算机上查看源文件agentcase.go
。
我们必须处理的第二个任务是设置 Case 的语料库,这是用于针对用户问题进行语言分析的文本集合。语料库是从knowledgeBase
映射的键构造的。我们将AgentCase
实例的knowledgeCorpus
字段属性设置为使用内置的make
函数创建的新的字符串byte
切片。使用for
循环,我们遍历knowledgeBase map
中的所有条目,并将每个键附加到knowledgeCorpus
字段切片中。
我们必须处理的第三个也是最后一个任务是设置Case
将呈现给人类用户的示例问题。我们简单地填充AgentCase
实例的sampleQuestions
属性。我们使用字符串文字声明来填充包含在字符串byte
切片中的所有示例问题。
这是AgentCase
类型的 getter 和 setter 方法:
func (a *AgentCase) Name() string {
return a.name
}
func (a *AgentCase) Title() string {
return a.title
}
func (a *AgentCase) ThumbnailPath() string {
return a.thumbnailPath
}
func (a *AgentCase) SetName(name string) {
a.name = name
}
func (a *AgentCase) SetTitle(title string) {
a.title = title
}
func (a *AgentCase) SetThumbnailPath(thumbnailPath string) {
a.thumbnailPath = thumbnailPath
}
这些方法用于获取和设置AgentCase
对象的name
,title
和thumbnailPath
字段。
这是用于创建新的AgentCase
实例的构造函数:
func NewAgentCase() *AgentCase {
agentCase := &AgentCase{name: "Case", title: "Resident Isomorphic Gopher Agent", thumbnailPath: "/static/images/chat/Case.png"}
agentCase.initializeIntelligence()
return agentCase
}
我们声明并初始化agentCase
变量为一个新的AgentCase
实例,设置name
,title
和thumbnailPath
字段。然后我们调用initializeIntelligence
方法来初始化 Case 的大脑。最后,我们返回新创建和初始化的AgentCase
实例。
问候人类
Greeting
方法用于在激活实时聊天功能时向用户提供首次问候:
func (a *AgentCase) Greeting() string {
sampleQuestionIndex := randomNumber(0, len(a.sampleQuestions))
greeting := "Hi there! I'm Case. You can ask me a question on Isomorphic Go. Such as...\"" + a.sampleQuestions[sampleQuestionIndex] + "\""
return greeting
}
由于问候将包括一个可以问 Case 的随机选择的示例问题,因此调用randomNumber
函数来获取示例问题的索引号。我们将最小值和最大值传递给randomNumber
函数,以指定生成的随机数应该在的范围内。
这是randomNumber
函数用于生成给定范围内的随机数:
func randomNumber(min, max int) int {
rand.Seed(time.Now().UTC().UnixNano())
return min + rand.Intn(max-min)
}
回到Greeting
方法,我们使用随机索引从sampleQuestions
字符串切片中检索示例问题。然后我们将示例问题分配给greeting
变量并返回greeting
。
回答人类的问题
现在我们已经初始化了聊天机器人的智能,并准备好迎接人类用户,是时候指导聊天机器人如何思考用户的问题,以便聊天机器人可以提供明智的回答了。
聊天机器人将发送给人类用户的回复仅限于AgentCase
结构的knowledgeBase
映射中的值。如果人类用户问的问题超出了聊天机器人所知道的范围(知识语料库),它将简单地回复消息“我不知道答案。”
为了分析用户的问题并为其提供最佳回复,我们将使用nlp
包,其中包含一系列可用于基本自然语言处理的机器学习算法。
您可以通过发出以下go get
命令来安装nlp
包:
$ go get github.com/james-bowman/nlp
让我们逐步了解Reply
方法,从方法声明开始:
func (a *AgentCase) Reply(query string) string {
该函数接收一个问题字符串,并返回给定问题的答案字符串。
我们声明result
变量,表示用户问题的答案:
var result string
result
变量将由Reply
方法返回。
使用nlp
包,我们创建一个新的vectoriser
和一个新的transformer
:
vectoriser := nlp.NewCountVectoriser(true)
transformer := nlp.NewTfidfTransformer()
vectoriser
将用于将知识语料库中的查询术语编码为术语文档矩阵,其中每列代表语料库中的一个文档,每行代表一个术语。它用于跟踪在特定文档中找到的术语的频率。对于我们的使用场景,您可以将文档视为在knowledgeCorpus
字符串切片中找到的唯一条目。
transformer
将用于消除knowledgeCorpus
中频繁出现术语的偏差。例如,knowledgeCorpus
中重复出现的单词,如the、and和web,将具有较小的权重。转换器是TFIDF(词频逆文档频率)转换器。
然后我们继续创建reducer
,这是一个新的TruncatedSVD
实例:
reducer := nlp.NewTruncatedSVD(4)
我们刚刚声明的reducer
很重要,因为我们将执行潜在语义分析(LSA),也称为潜在语义索引(LSI),以搜索和检索与用户查询术语相匹配的正确文档。LSA 帮助我们根据术语的共现来找到语料库中存在的语义属性。它假设频繁一起出现的单词必须具有一定的语义关系。
reducer
用于查找可能隐藏在文档特征向量中的术语频率下的语义含义。
以下代码是一个将语料库转换为潜在语义索引的管道,该索引适合于文档的模型:
matrix, _ := vectoriser.FitTransform(a.knowledgeCorpus...)
matrix, _ = transformer.FitTransform(matrix)
lsi, _ := reducer.FitTransform(matrix)
我们必须通过相同的管道运行用户的查询,以便它在相同的维度空间中被投影:
matrix, _ = vectoriser.Transform(query)
matrix, _ = transformer.Transform(matrix)
queryVector, _ := reducer.Transform(matrix)
现在我们已经准备好了lsi
和queryVector
,是时候找到最匹配查询术语的文档了。我们通过计算我们语料库中每个文档与查询的余弦相似度来实现这一点:
highestSimilarity := -1.0
var matched int
_, docs := lsi.Dims()
for i := 0; i < docs; i++ {
similarity := nlp.CosineSimilarity(queryVector.(mat.ColViewer).ColView(0), lsi.(mat.ColViewer).ColView(i))
if similarity > highestSimilarity {
matched = i
highestSimilarity = similarity
}
}
余弦相似度计算两个数值向量之间的角度差异。
与用户查询具有最高相似度的语料库中的文档将被匹配为最能反映用户问题的最佳文档。余弦相似度的可能值可以在 0 到 1 的范围内。0 值表示完全正交,1 值表示完全匹配。余弦相似度值也可以是NaN(不是数字)值。NaN 值表明根本没有匹配。
如果没有找到匹配,highestSimilarity
值将为-1
;否则,它将是 0 到 1 之间的值:
if highestSimilarity == -1 {
result = "I don't know the answer to that one."
} else {
result = a.knowledgeBase[a.knowledgeCorpus[matched]]
}
return result
在if
条件块中,我们检查highestSimilarity
值是否为-1
;如果是,用户的答案将是"I don't know the answer to that one."
。
如果我们到达else
块,表示highestSimilarity
是 0 到 1 之间的值,表示找到了匹配。回想一下,我们knowledgeCorpus
中的文档在knowledgeBase
map
中有对应的键。用户问题的答案是knowledgeBase
map
中提供的键的值,我们将result
字符串设置为这个值。在方法的最后一行代码中,我们返回result
变量。
实现聊天机器人智能的逻辑是受到 James Bowman 的文章《在 Go 中使用机器学习进行网页的语义分析》的启发(www.jamesbowman.me/post/semantic-analysis-of-webpages-with-machine-learning-in-go/
)。
向客户端公开代理的信息
现在我们已经实现了聊天代理AgentCase
,我们需要一种方法将 Case 的信息暴露给客户端,特别是其名称、标题和头像图像的路径。
我们创建一个新的 Rest API 端点GetAgentInfoEndpoint
,以向客户端 Web 应用程序公开聊天代理的信息:
func GetAgentInfoEndpoint(env *common.Env, chatbot bot.Bot) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m := make(map[string]string)
m["AgentName"] = chatbot.Name()
m["AgentTitle"] = chatbot.Title()
m["AgentThumbImagePath"] = chatbot.ThumbnailPath()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(m)
})
请注意,在GetAgentInfoEndpoint
函数的签名中,我们接受env
对象和chatbot
对象。请注意,chatbot
是bot.Bot
类型的接口类型,而不是AgentCase
类型。这使我们能够轻松地在将来将另一个机器人(例如AgentMolly
)替换为AgentCase
。
我们简单地创建一个map[string]string
类型的映射m
,其中包含机器人的姓名、职称和头像路径。我们设置一个标头以指示服务器响应将以 JSON 格式返回。最后,我们使用http.ResponseWriter
w
编写 JSON 编码的map
。
实现实时聊天的客户端功能
现在我们已经介绍了实现聊天机器人所需的服务器端功能,是时候从客户端 Web 应用程序的角度关注实时聊天功能了。
在InitialPageLayoutControls
函数内,我们在顶部栏中的实时聊天图标上添加了一个click
事件的event
监听器:
liveChatIcon := env.Document.GetElementByID("liveChatIcon").(*dom.HTMLImageElement)
liveChatIcon.AddEventListener("click", false, func(event dom.Event) {
chatbox := env.Document.GetElementByID("chatbox")
if chatbox != nil {
return
}
go chat.StartLiveChat(env)
})
如果实时聊天功能已经激活,则chatbox
div 元素将已经存在,即它将是一个非 nil 值。在这种情况下,我们从函数中返回。
然而,在实时聊天功能尚未激活的情况下,我们将调用位于chat
包中的StartLiveChat
函数作为 goroutine,传入env
对象。调用此函数将激活实时聊天功能。
创建实时聊天客户端
我们将使用gopherjs/websocket/websocketjs
包来创建一个 WebSocket 连接,该连接将用于连接到 Web 服务器实例。
您可以使用以下go get
命令安装此包:
$ go get -u github.com/gopherjs/websocket
实时聊天功能的客户端实现可以在client/chat/chat.go
源文件中找到。我们定义了类型为websocketjs.WebSocket
的ws
变量和类型为map[string]string
的agentInfo
变量:
var ws *websocketjs.WebSocket
var agentInfo map[string]string
我们还声明了一个代表 Enter 键的键码的常量:
const ENTERKEY int = 13
GetAgentInfoRequest
函数用于从/restapi/get-agent-info
端点获取代理信息:
func GetAgentInfoRequest(agentInfoChannel chan map[string]string) {
data, err := xhr.Send("GET", "/restapi/get-agent-info", nil)
if err != nil {
println("Encountered error: ", err)
}
var agentInfo map[string]string
json.NewDecoder(strings.NewReader(string(data))).Decode(&agentInfo)
agentInfoChannel <- agentInfo
}
一旦我们从服务器检索到 JSON 编码的数据,我们将其解码为map[string]string
类型的map
。然后我们通过通道agentInfoChannel
发送agentInfo map
。
getServerPort
函数是一个辅助函数,用于获取服务器运行的端口:
func getServerPort(env *common.Env) string {
if env.Location.Port != "" {
return env.Location.Port
}
if env.Location.Protocol == "https" {
return "443"
} else {
return "80"
}
}
此函数用于在StartLiveChat
函数内构造serverEndpoint
字符串变量,该变量表示我们将要建立 WebSocket 连接的服务器端点。
当用户点击顶部栏中的实时聊天图标时,StartLiveChat
函数将作为 goroutine 被调用:
func StartLiveChat(env *common.Env) {
agentInfoChannel := make(chan map[string]string)
go GetAgentInfoRequest(agentInfoChannel)
agentInfo = <-agentInfoChannel
首先,我们通过调用GetAgentInfoRequest
函数作为一个 goroutine 来获取代理的信息。代理的信息将作为map[string]string
类型的映射通过agentInfoChannel
通道发送。agentInfo
map
将被用作传递给partials/chatbox_partial
模板以显示代理的详细信息(姓名、职称和头像)的数据对象。
然后,我们继续创建一个新的 WebSocket 连接并连接到服务器端点:
var err error
serverEndpoint := "ws://" + env.Location.Hostname + ":" + getServerPort(env) + "/ws"
ws, err = websocketjs.New(serverEndpoint)
if err != nil {
println("Encountered error when attempting to connect to the websocket: ", err)
}
我们使用辅助函数getServerPort
来获取服务器运行的端口。服务器端口值用于构造serverEndpoint
字符串变量,该变量表示我们将连接到的服务器端点的 WebSocket 地址。
我们使用env.Document
对象的GetElementByID
方法来获取聊天容器div
元素,通过提供 ID 为"chatboxContainer"
。我们还添加了 CSS 动画样式,使聊天框容器在聊天机器人可以回答问题时产生戏剧性的脉动效果:
chatContainer := env.Document.GetElementByID("chatboxContainer").(*dom.HTMLDivElement)
chatContainer.SetClass("containerPulse")
env.TemplateSet.Render("partials/chatbox_partial", &isokit.RenderParams{Data: agentInfo, Disposition: isokit.PlacementReplaceInnerContents, Element: chatContainer})
我们调用模板集对象的Render
方法,渲染"partials/chatbox_partial"
模板并提供模板渲染参数。我们指定要提供给模板的数据对象将是agentInfo
映射。我们指定渲染的方式应该是用渲染模板输出替换相关元素的内部 HTML 内容。最后,我们指定要渲染到的相关元素是chatContainer
元素。
当实时聊天功能可用且与服务器的 WebSocket 连接已连接时,聊天框标题栏,包含聊天框标题的条纹,chatboxHeaderBar
,将被着绿色。如果 WebSocket 连接已断开或出现错误,则条纹将被着红色。默认情况下,当我们将chatboxHeaderBar
的默认 CSS 类设置为"chatboxHeader"
时,条纹将被着绿色:
chatboxHeaderBar := env.Document.GetElementByID("chatboxHeaderBar").(*dom.HTMLDivElement)
chatboxHeaderBar.SetClass("chatboxHeader")
初始化事件监听器
最后,我们调用InitializeChatEventHandlers
函数,传入env
对象,初始化实时聊天功能的事件处理程序:
InitializeChatEventHandlers(env)
InitializeChatEventHandlers
函数负责设置实时聊天功能所需的所有事件监听器。有两个需要用户交互的控件。第一个是消息input
字段,用户通过按下 Enter 键输入并发送问题。第二个是关闭按钮,即 X,位于聊天框右上角,用于关闭实时聊天功能。
为了处理用户与消息input
字段的交互,我们设置了keypress
事件监听器,它将检测消息input
文本字段内的keypress
事件:
func InitializeChatEventHandlers(env *common.Env) {
msgInput := env.Document.GetElementByID("chatboxInputField").(*dom.HTMLInputElement)
msgInput.AddEventListener("keypress", false, func(event dom.Event) {
if event.Underlying().Get("keyCode").Int() == ENTERKEY {
event.PreventDefault()
go ChatSendMessage(env, msgInput.Value)
msgInput.Value = ""
}
})
我们通过在env.Document
对象上调用GetElementByID
方法获取input
消息文本字段元素。然后我们为该元素附加了一个keypress
事件监听器函数。如果用户按下的键是 Enter 键,我们将阻止keypress
事件的默认行为,并调用ChatSendMessage
函数,作为一个 goroutine,传入env
对象和msgInput
元素的Value
属性。最后,我们通过将消息输入字段的Value
属性设置为空字符串值来清除文本。
关闭聊天控件
为了处理用户点击 X 控件关闭实时聊天功能时的交互,我们设置了一个事件监听器来处理关闭控件的点击事件:
closeControl := env.Document.GetElementByID("chatboxCloseControl").(*dom.HTMLDivElement)
closeControl.AddEventListener("click", false, func(event dom.Event) {
CloseChat(env)
})
我们通过在env.Document
对象上调用GetElementByID
方法,指定 ID 为"chatboxCloseControl"
,获取代表关闭控件的div
元素。我们在click
事件上为关闭控件附加一个事件监听器,该事件监听器将调用CloseChat
函数。
为 WebSocket 对象设置事件监听器
现在我们已经为用户交互设置了事件监听器,我们必须在 WebSocket 对象ws
上设置事件监听器。我们首先在message
事件上添加一个事件监听器:
ws.AddEventListener("message", false, func(ev *js.Object) {
go HandleOnMessage(env, ev)
})
当 WebSocket 连接上有新消息时,将触发message
事件监听器。这表明代理向用户发送消息。在这种情况下,我们调用HandleOnMessage
函数,将env
对象和事件对象ev
传递给函数。
WebSocket 对象中我们需要监听的另一个事件是close
事件。这个事件可能会在正常操作场景下触发,比如用户使用关闭控件关闭实时聊天功能。这个事件也可能在异常操作场景下触发,比如 Web 服务器实例突然宕机,中断 WebSocket 连接。我们的代码必须足够智能,只在异常连接关闭的情况下触发:
ws.AddEventListener("close", false, func(ev *js.Object) {6
chatboxContainer := env.Document.GetElementByID("chatboxContainer").(*dom.HTMLDivElement)
if len(chatboxContainer.ChildNodes()) > 0 {
go HandleDisconnection(env)
}
})
我们首先获取聊天框容器div
元素。如果聊天框容器中的子节点数量大于零,则意味着在用户使用实时聊天功能时连接异常关闭,我们必须调用HandleDisconnection
函数,作为一个 goroutine,将env
对象传递给该函数。
可能会有一些情况,关闭事件不会触发,比如当我们失去互联网连接时。WebSocket 连接正在通信的 TCP 连接可能仍然被视为活动的,即使互联网连接已经断开。为了使我们的实时聊天功能能够处理这种情况,我们需要监听env.Window
对象的offline
事件,当网络连接丢失时会触发该事件:
env.Window.AddEventListener("offline", false, func(event dom.Event) {
go HandleDisconnection(env)
})
}
我们执行与之前处理此事件相同的操作。我们调用HandleDisconnection
函数,作为一个 goroutine,将env
对象传递给该函数。请注意,最后的闭括号}
表示InitializeChatEventHandlers
函数的结束。
现在我们已经为实时聊天功能设置了所有必要的事件监听器,是时候检查刚刚设置的事件监听器调用的每个函数了。
在用户在消息input
文本字段中按下 Enter 键后,将调用ChatSendMessage
函数:
func ChatSendMessage(env *common.Env, message string) {
ws.Send([]byte(message))
UpdateChatBox(env, message, "Me")
}
我们调用 WebSocket 对象ws
的Send
方法,将用户的问题发送到 Web 服务器。然后调用UpdateChatBox
函数将用户的消息呈现到聊天框的对话容器中。我们将env
对象、用户编写的message
和sender
字符串作为输入值传递给UpdateChatBox
函数。sender
字符串是发送消息的人;在这种情况下,由于用户发送了消息,sender
字符串将是"Me"
。sender
字符串帮助用户区分用户发送的消息和聊天机器人回复的消息。
UpdateChatBox
函数用于更新聊天框对话容器区域:
func UpdateChatBox(env *common.Env, message string, sender string) {
m := make(map[string]string)
m["Name"] = sender
m["Message"] = message
conversationContainer := env.Document.GetElementByID("chatboxConversationContainer").(*dom.HTMLDivElement)
env.TemplateSet.Render("partials/livechatmsg_partial", &isokit.RenderParams{Data: m, Disposition: isokit.PlacementAppendTo, Element: conversationContainer})
scrollHeight := conversationContainer.Underlying().Get("scrollHeight")
conversationContainer.Underlying().Set("scrollTop", scrollHeight)
}
我们创建一个新的map[string]string
类型的映射,它将被用作传递给partials/livechatmsg_partial
模板的数据对象。该映射包括一个带有键"Name"
的条目,表示sender
,以及一个带有键"Message"
的条目,表示message
。"Name"
和"Message"
的值都将显示在聊天框的对话容器区域中。
我们通过调用env.Document
对象的GetElementByID
方法并指定id
值为"chatboxConversationContainer"
来获取conversationContainer
元素。
我们调用env.TemplateSet
对象的Render
方法,并指定要渲染partials/livechatmsg_partial
模板。在渲染参数(RenderParams
)对象中,我们将Data
字段设置为map
m
。我们将Disposition
字段设置为isokit.PlacementAppendTo
,以指定该操作将是一个append to操作,相对于关联元素。我们将Element
字段设置为conversationContainer
,因为这是将聊天消息追加到的元素。
函数中的最后两行将在渲染新消息时自动将conversationContainer
滚动到底部,以便始终显示最近的消息给用户。
除了ChatSendMessage
函数之外,UpdateChatBox
函数的另一个使用者是HandleOnMessage
函数:
func HandleOnMessage(env *common.Env, ev *js.Object) {
response := ev.Get("data").String()
UpdateChatBox(env, response, agentInfo["AgentName"])
}
请记住,此功能将在从 WebSocket 连接触发"message"
事件时调用。我们通过获取event
对象的data
属性的字符串值,从通过 WebSocket 连接传递的聊天机器人获取响应。然后我们调用UpdateChatBox
函数,传入env
对象、response
字符串和sender
字符串agentInfo["AgentName"]
。请注意,我们已经传递了代理的名称,即使用"AgentName"
键获取的agentInfo
map
中的值,作为sender
字符串。
CloseChat
函数用于关闭网络套接字连接并从用户界面中解除聊天框:
func CloseChat(env *common.Env) {
ws.Close()
chatboxContainer := env.Document.GetElementByID("chatboxContainer").(*dom.HTMLDivElement)
chatboxContainer.RemoveChild(chatboxContainer.ChildNodes()[0])
}
我们首先在 WebSocket 对象上调用Close
方法。我们获取chatboxContainer
元素并移除其第一个子节点,这将随后移除第一个子节点的所有子节点。
请记住,此功能将在用户点击聊天框中的 X 控件时调用,或者在打开实时聊天功能时遇到异常的 WebSocket 连接终止的情况下调用。
处理断开连接事件
这将引导我们到最后一个函数HandleDisconnection
,它在异常的 WebSocket 连接关闭事件或互联网连接断开时被调用,即当wenv.Window
对象触发offline
事件时:
func HandleDisconnection(env *common.Env) {
chatContainer := env.Document.GetElementByID("chatboxContainer").(*dom.HTMLDivElement)
chatContainer.SetClass("")
chatboxHeaderBar := env.Document.GetElementByID("chatboxHeaderBar").(*dom.HTMLDivElement)
chatboxHeaderBar.SetClass("chatboxHeader disconnected")
chatboxTitleDiv := env.Document.GetElementByID("chatboxTitle").(*dom.HTMLDivElement)
if chatboxTitleDiv != nil {
titleSpan := chatboxTitleDiv.ChildNodes()[0].(*dom.HTMLSpanElement)
if titleSpan != nil {
var countdown uint64 = 6
tickerForCountdown := time.NewTicker(1 * time.Second)
timerToCloseChat := time.NewTimer(6 * time.Second)
go func() {
for _ = range tickerForCountdown.C {
atomic.AddUint64(&countdown, ^uint64(0))
safeCountdownValue := atomic.LoadUint64(&countdown)
titleSpan.SetInnerHTML("Disconnected! - Closing LiveChat in " + strconv.FormatUint(safeCountdownValue, 10) + " seconds.")
}
}()
go func() {
<-timerToCloseChat.C
tickerForCountdown.Stop()
CloseChat(env)
}()
}
}
}
我们首先使用SetClass
方法将chatContainer
的 CSSclassname
值设置为空字符串,以禁用chatContainer
元素的脉动效果,以指示连接已中断。
然后,我们通过使用SetClass
方法将chatboxHeaderBar
元素的 CSSclassname
值设置为"chatboxHeader disconnected"
,将chatboxHeaderBar
的背景颜色更改为红色。
剩下的代码将向用户显示一条消息,指示连接已断开,并且实时聊天功能将自动启动倒计时。chatboxHeaderBar
将按秒显示倒计时 5-4-3-2-1,当实时聊天功能关闭时。我们使用两个 goroutine,一个用于倒计时计时器,另一个用于倒计时计时器。当倒计时计时器到期时,表示倒计时结束,我们调用CloseChat
函数,传入env
对象来关闭实时聊天功能。
与代理人交谈
此时,我们已经实现了服务器端和客户端功能,实现了实时聊天功能,展示了实时 Web 应用程序功能。现在是时候开始与聊天代理进行对话(问题和答案会话)了。
在网站顶部栏找到实时聊天图标后,我们会在网页的右下角看到聊天框。以下截图显示了带有聊天代理问候语的聊天框:
图 8.3:聊天框打开并显示聊天代理的问候
我们可以使用聊天框右上角的 X 控件关闭实时聊天框。我们可以通过再次点击顶部栏中的实时聊天图标来重新激活实时聊天功能。我们可以提供一个陈述,例如告诉我更多关于同构 Go,而不是向聊天代理提问,就像我们在以下截图中所示的那样:
图 8.4:即使不是问题,聊天代理也能理解信息请求
人类用户和聊天代理之间的问答会话可以持续多长时间,如下一张截图所示。这也许是聊天代理的最大优势——在与人类打交道时具有无限的耐心。
图 8.5:问题和答案会话可以持续多长时间取决于人类的意愿。
我们实现的聊天代理具有极其狭窄和有限的智能范围。当人类用户提出超出其智能范围的问题时,聊天代理将承认自己不知道答案,如下所示:
图 8.6:聊天代理对超出其智能范围的问题没有答案
一些人类用户可能对聊天代理粗鲁。这是聊天代理所服务的公共角色所带来的。如果我们调整语料库得当,我们的聊天代理可以展示一个风趣的回复。
图 8.7:聊天代理展示一个风趣的回复
正如前面所述,我们已经有策略地将聊天框容器放在网页布局的主要内容区域之外。这样做后,聊天框和与聊天代理的对话可以在我们自由导航 IGWEB 的链接时继续,如下所示:
图 8.8:用户在 IGWEB 中导航时,聊天对话将被保留
例如,如下所示,即使在单击咖啡杯产品图像以进入产品详细页面后,聊天对话仍在继续:
图 8.9:用户访问咖啡杯产品详细页面时,聊天对话已保留
实时网络应用取决于对互联网的持续连接。让我们看看实时聊天功能如何优雅地处理断开互联网连接的情况,如下所示:
图 8.10:关闭互联网连接
一旦网络连接被关闭,我们立即在聊天框的标题栏中得到断开连接的通知,如图 8.11所示。聊天框标题栏的背景颜色变为红色,并启动关闭实时聊天功能的倒计时。倒计时完成后,实时聊天功能将自动关闭:
图 8.11:关闭实时聊天功能的倒计时出现在聊天框的标题栏中
在实现实时网络应用功能时,始终重要考虑持久 WebSocket 连接中断的情况。当 Web 客户端和 Web 服务器之间的持久连接中断时,通过优雅地关闭实时聊天,我们有一种方式向用户提供提示,让用户与聊天代理解除联系。
总结
在本章中,我们以 IGWEB 的实时网络应用功能的形式实现了实时聊天功能。您学会了如何使用 WebSocket 在 Web 服务器和 Web 客户端之间建立持久连接。在服务器端,我们向您介绍了 Gorilla 工具包项目中的websocket
包。在客户端,我们向您介绍了 GopherJS 项目中的gopherjs/websocket/websocketjs
包。
我们创建了一个简单的初级聊天机器人,实时回答用户提出的问题,人类和机器人之间的对话通过建立的 WebSocket 连接进行中继。由于实时网络应用功能取决于持续连接,我们还添加了代码,以便在互联网连接中断的情况下自动关闭实时聊天功能。
我们使用nlp
包来实现初级聊天代理的大脑,以便它可以回答一些与同构 Go 相关的问题。我们使我们的聊天代理解决方案可扩展,未来可以通过定义Bot
接口来添加具有不同智能的新机器人。
在第九章中,Cogs– 可重复使用的组件,我们将探讨如何在整个 IGWEB 中实现可重复使用的接口小部件。可重复使用的组件提供了促进更大重用性的手段,它们可以以即插即用的方式使用。正如您将了解的那样,齿轮也是高效的,利用虚拟 DOM 根据需要重新渲染其内容。
第九章:Cogs - 可重用组件
在本书的前五章中,我们专注于为 IGWEB 上的特定网页或特定功能开发功能,例如我们在上一章中实现的实时聊天功能。到目前为止,我们所做的解决方案都为特定的个人目的服务。并没有考虑为特定的用户界面功能促进代码重用,因为我们没有需要创建多个实例。
可重用组件是用户界面小部件,提供了促进更大重用性的手段。它们可以以即插即用的方式使用,因为每个组件都是一个独立的用户界面小部件,包含自己的一组 Go 源文件和静态资产,例如 Go 模板文件,以及 CSS 和 JavaScript 源文件。
在本章中,我们将专注于创建可在同构 Go web 应用程序中使用的cogs——可重用组件。术语cog
代表Go 中的组件对象。Cogs 是可重用的用户界面小部件,可以纯粹由 Go 实现(纯齿轮),也可以使用 Go 和 JavaScript 实现(混合齿轮)。
我们可以创建多个cog
的实例,并通过提供输入参数(以键值对的形式)给cog
,即props,来控制 cog 的行为。当对 props 进行后续更改时,cog
是响应式的,这意味着它可以自动重新渲染自己。因此,cogs 具有根据其 props 的更改而改变外观的能力。
也许,cogs 最吸引人的特点是它们是可以立即重用的。Cogs 被实现为独立的 Go 包,包含一个或多个 Go 源文件以及 cog 实现所需的任何静态资产。
在本章中,我们将涵盖以下主题:
-
基本的
cog
概念 -
实现纯齿轮
-
实现混合齿轮
基本 cog 概念
Cogs(Go 中的组件对象)是在 Go 中实现的可重用组件。cogs 背后的指导理念是允许开发人员以成熟的方式在前端创建可重用组件。Cogs 是自包含的,定义为自己的 Go 包,这使得重用和维护它们变得容易。由于它们的自包含性质,cogs 可以用于创建可组合的用户界面。
Cogs 遵循关注点的清晰分离,其中cog
的表示层使用一个或多个 Go 模板实现,cog
的控制器逻辑在一个或多个 Go 源文件中实现,这些文件包含在一个 Go 包中。这些 Go 源文件可能导入标准库或第三方库的 Go 包。当我们在本章的实现纯齿轮部分实现时间之前的齿轮时,我们将看到一个例子。
Cogs 也可能有与之相关的 CSS 样式表和 JavaScript 代码,允许cog
开发者/维护者根据需要利用预构建的 JavaScript 解决方案,而不是直接将 JavaScript 小部件移植到 Go。这使得 cogs 与现有的 JavaScript 解决方案具有互操作性,并防止开发人员重复发明轮子,从而节省宝贵的时间。例如,Pikaday(github.com/dbushell/Pikaday
)是一个成熟的日历日期选择器 JavaScript 小部件。在本章的实现混合齿轮部分,我们将学习如何实现一个使用 Pikaday JavaScript 小部件提供的功能的日期选择器cog
。使用日期选择器cog
的 Go 开发人员不需要了解 JavaScript,并且可以仅使用他们对 Go 的知识来使用它。
每个cog
都带有一个虚拟 DOM 树,这是其实际 DOM 树的内存表示。操作cog
的内存虚拟 DOM 树要比操作实际 DOM 树本身更有效率。图 9.1是一个 Venn 图,描述了cog
的虚拟 DOM 树、两个树之间的差异以及实际 DOM 树:
图 9.1:显示虚拟 DOM、差异和实际 DOM 的 Venn 图
当对cog
的属性(props)进行更改时,cog
的渲染引擎将利用其虚拟 DOM 树来确定更改,然后将更改与实际 DOM 树进行协调。这允许cog
是reactive,意味着cog
可以在其 props 更新时自动重新渲染自身。通过这种方式,cogs 减少了更新用户界面时涉及的复杂性。
UX 工具包
UX 工具包提供了在cog
包中实现 cogs 的功能,可以使用以下go get
命令进行安装:
$ go get -u github.com/uxtoolkit/cog
所有 cogs 必须实现Cog
接口:
type Cog interface {
Render() error
Start() error
}
Render
方法负责在网页上渲染cog
。如果在渲染过程中出现任何错误,该方法将返回一个error
对象。
Start
方法负责激活cog
。如果cog
无法启动,该方法将返回一个error
对象。
cog
包含两个重要的导出变量,ReactivityEnabled
和VDOMEnabled
。这两个导出变量都是bool
类型,默认情况下都设置为true
。
当变量ReactivityEnabled
设置为true
时,cogs 将在其 props 更改时重新渲染。如果ReactivityEnabled
设置为false
,则必须显式调用cog
的Render
方法来重新渲染cog
。
当变量VDOMEnabled
设置为true
时,cogs 将利用cog
的虚拟 DOM 树进行渲染。如果VDOMEnabled
设置为false
,则将通过替换内部 HTML 操作使用实际 DOM 树来渲染cog
。这可能是一个昂贵的操作,可以通过利用cog
的虚拟 DOM 树来避免。
UXCog
类型实现了Cog
接口的Render
方法。以下是UXCog struct
的样子:
type UXCog struct {
Cog
cogType reflect.Type
cogPrefixName string
cogPackagePath string
cogTemplatePath string
templateSet *isokit.TemplateSet
Props map[string]interface{}
element *dom.Element
id string
hasBeenRendered bool
parseTree *reconcile.ParseTree
cleanupFunc func()
}
UXCog
类型提供了使 cogs 工作的基本功能。这意味着为了实现我们自己的 cogs,我们必须在我们创建的所有 cogs 的类型定义中嵌入UXCog
。UXCog
类型的以下方法(为简洁起见,仅呈现方法签名)对我们来说特别重要:
func (u *UXCog) ID() string
func (u *UXCog) SetID(id string)
func (u *UXCog) CogInit(ts *isokit.TemplateSet)
func (u *UXCog) SetCogType(cogType reflect.Type)
func (u *UXCog) SetProp(key string, value interface{})
func (u *UXCog) Render() error
ID
方法是一个 getter 方法,返回cog
在 DOM 中的div
容器的 ID。cog
的div
容器被称为其挂载点。
SetID
方法是一个 setter 方法,用于设置 DOM 中cog
的div
容器的 ID。
CogInit
方法用于将cog
与应用程序的TemplateSet
对象关联起来。该方法有两个重要目的。首先,该方法用于在服务器端注册cog
,以便所有给定cog
的模板都包含在由isokit
内置的静态资产捆绑系统生成的模板包中。其次,在客户端调用cog
的CogInit
方法允许cog
访问客户端应用程序的TemplateSet
对象,从而允许cog
在网页上进行渲染。
SetCogType
方法允许我们通过对新实例化的cog
执行运行时反射来动态设置cog
的类型。这为 isokit 的静态资产捆绑系统提供了所需的钩子,以捆绑与给定cog
相关的模板文件、CSS 源文件和 JavaScript 源文件。
SetProp
方法用于在 cog 的 Props
映射中设置键值对,该映射的类型为 map[string]interface{}
。映射的 key
表示 prop 的名称,值表示 prop 的值。
Render
方法负责将 cog
渲染到 DOM。如果在渲染后对 cog
进行更改(其 prop 值已更新),则将重新渲染 cog
。
您可以访问 UX 工具包网站,了解有关 cogs 的更多信息:uxtoolkit.io
。
现在我们已经了解了 UXCog
类型,是时候来检查 cog
的解剖学了。
cog 的解剖学
对于 IGWEB 项目,我们将在 $IGWEB_APP_ROOT/shared/cogs
文件夹中创建 cogs。当您阅读本节时,您可以查看 $IGWEB_APP_ROOT/shared/cogs/timeago
文件夹中找到的 time ago cog
的实现,以查看所述概念的具体实现。
仅用于说明的目的,我们将带您了解创建一个名为 widget
的简单 cog
的过程。
widget
文件夹中包含的小部件 cog
的项目结构以以下方式组织:
⁃ widget
⁃ widget.go
⁃ templates
⁃ widget.tmpl
widget.go
源文件将包含小部件 cog
的实现。
templates
文件夹包含用于实现 cog
的模板源文件。如果要在网页上呈现 cog
,至少必须存在一个模板源文件。模板源文件的名称必须与 cog
的包名称匹配。例如,对于 cog
包 widget
,模板源文件的名称必须是 widget.tmpl
。
在命名包名称和源文件时,cogs 遵循 约定优于配置 策略。由于我们选择了名称 widget
,因此我们必须在 widget.go
源文件中也声明一个名为 widget
的 Go 包:
package widget
所有 cogs 都需要在其导入分组中包含 errors
包、reflect
包和 cog
包:
import (
"errors"
"reflect"
"github.com/uxtoolkit/cog"
)
我们必须声明一个未导出的、包范围的变量,名为 cogType
:
var cogType reflect.Type
此变量表示 cog
的类型。我们在 cog
包的 init
函数中调用 reflect
包中的 TypeOf
函数,传入一个新创建的 cog
实例,以动态设置 cog
的类型:
func init() {
cogType = reflect.TypeOf(Widget{})
}
这为 isokit 的静态捆绑系统提供了一个钩子,以了解在哪里获取所需的静态资源来使 cog
函数正常运行。
cog
实现了特定类型。对于小部件,我们实现了 Widget
类型。这是 Widget struct
:
type Widget struct {
cog.UXCog
}
我们必须将 cog.UXCog
类型嵌入到 cog
中,以从 cog.UxCog
类型中获取所需的所有功能,以实现 cog
。
struct
可能包含其他字段定义,这些字段定义是实现 cog
所需的,具体取决于 cog
的用途。
每个 cog
实现都应包含一个构造函数:
func NewWidget() *Widget {
w := &Widget{}
w.SetCogType(cogType)
return f
}
与任何典型的构造函数一样,目的是创建 Widget
的新实例。
cog 的构造函数必须包含调用 SetCogType
方法的行(以粗体显示)。这是 isokit 的自动静态资源捆绑系统用作钩子,以捆绑 cog
所需的静态资源。
可以设置 Widget
类型的其他字段以初始化 cog
,这取决于 cog
的实现。
为了实现 Cog
接口的实现,所有 cogs 必须实现一个 Start
方法:
func (w *Widget) Start() error {
var allRequiredConditionsHaveBeenMet bool = true
Start
方法负责激活 cog
,包括将 cog
初始渲染到网页上。如果 cog
启动失败,Start
方法将返回一个 error
对象,否则将返回一个 nil
值。
仅用于说明,我们定义了一个包含名为 allRequiredConditionsHaveBeenMet
的布尔变量的 if
条件块:
if allRequiredConditionsHaveBeenMet == false {
return errors.New("Failed to meet all requirements, cog failed to start!")
}
如果满足了启动cog
的所有条件,这个变量将等于true
。否则,它将等于false
。如果它是false
,那么我们将返回一个新的error
对象,表示cog
由于未满足所有要求而无法启动。
我们可以通过调用SetProp
方法在 cog 的Props
映射中设置键值对:
w.SetProp("foo", "bar")
在这种情况下,我们已将名为foo
的 prop 设置为值bar
。Props
映射将自动用作传入 cog 模板的数据对象。这意味着Props
映射中定义的所有 prop 都可以被 cog 的模板访问。
按照惯例,cog 的模板源文件名称必须命名为widget.tmpl
,以匹配 cog 的包名称widget
,并且模板文件应该位于 cog 的文件夹widget
中的templates
文件夹中。
让我们快速看一下widget.tmpl
源文件可能是什么样子:
<p>Value of Foo is: {{.foo}}</p>
请注意,我们能够打印出模板中具有键foo
的 prop 的值。
让我们回到 widget cog 的Start
方法。我们调用 cog 的Render
方法来在 web 浏览器中渲染cog
:
err := w.Render()
if err != nil {
return err
}
如果在渲染cog
时遇到错误,Render
方法将返回一个error
对象,否则将返回一个值为nil
,表示cog
已成功渲染。
如果cog
成功渲染,cog 的Start
方法会返回一个值为nil
,表示cog
已成功启动:
return nil
为了将我们的cog
渲染到真实的 DOM 中,我们需要一个地方来渲染cog
。包含cog
渲染内容的div
容器被称为其挂载点。挂载点是cog
在 DOM 中渲染的位置。要在主页上渲染 widget cog
,我们需要将以下标记添加到主页的内容模板中:
<div data-component="cog" id="widgetContainer"></div>
通过将data-component
属性设置为"cog"
,我们表明div
元素将用作 cog 的挂载点,并且 cog 的渲染内容将包含在此元素内。
在客户端应用程序中,widget cog
可以这样实例化:
w := widget.NewWidget()
w.CogInit(env.TemplateSet)
w.SetID("widgetContainer")
w.Start()
w.SetProp("foo", "bar2")
我们创建一个新的Widget
实例,并将其分配给变量w
。我们必须调用cog
的CogInit
方法,将应用程序的TemplateSet
对象与cog
关联起来。cog
利用TemplateSet
来获取其关联的模板,这些模板是渲染cog
所需的。我们调用 cog 的SetID
方法,将id
传递给充当 cog 挂载点的div
元素。我们调用 cog 的Start
方法来激活cog
。由于Start
方法调用了 cog 的Render
方法,因此 cog 将在指定的挂载点div
元素中渲染,即"widgetContainer"
的 id。最后,当我们调用SetProp
方法并将"foo"
prop 的值更改为"bar2"
时,cog
将自动重新渲染。
现在我们已经检查了cog
的基本结构,让我们考虑如何使用虚拟 DOM 来渲染 cog。
虚拟 DOM 树
每个cog
实例都有一个与之关联的虚拟 DOM 树。这个虚拟 DOM 树是由 cog 的div
容器的所有子元素组成的解析树。
图 9.2是一个流程图,描述了将cog
渲染和重新渲染(通过协调应用)到 DOM 的过程:
图 9.2:描述了渲染和重新渲染 cog 的流程图
当cog
首次在 DOM 中渲染时,会执行替换内部 HTML 操作。在 DOM 中替换元素的内部 HTML 内容是一个昂贵的操作。因此,在cog
的后续渲染中不会执行此操作。
齿轮的Render
方法的所有后续调用将利用齿轮的虚拟 DOM 树。齿轮的虚拟 DOM 树用于跟踪齿轮当前虚拟 DOM 树与齿轮新虚拟 DOM 树之间的变化。当齿轮的 prop 值已更新时,cog
将有一个新的虚拟 DOM 树与其当前虚拟 DOM 树进行比较。
让我们考虑一个小部件齿轮的示例场景。调用小部件齿轮的Start
方法将执行cog
的初始渲染(因为Start
方法内部调用了Render
方法)。cog
将具有一个虚拟 DOM 树,该树将是包含cog
渲染内容的div
容器的解析树。如果我们通过调用cog
的SetProp
方法更新了"foo"
prop(该 prop 在cog
的模板中呈现),那么将自动调用Render
方法,因为cog
是响应式的。在对cog
执行后续渲染操作时,齿轮的当前虚拟 DOM 树将与齿轮的新虚拟 DOM 树(更新齿轮 prop 后创建的虚拟 DOM 树)进行差异比较。
如果当前虚拟 DOM 树和新虚拟 DOM 树之间没有变化,则无需执行任何操作。但是,如果当前虚拟 DOM 树和新虚拟 DOM 树之间存在差异,则必须将构成差异的更改应用于实际的 DOM。应用这些更改的过程称为协调。执行协调允许我们避免执行昂贵的替换内部 HTML 操作。成功应用协调后,齿轮的新虚拟 DOM 树将被视为齿轮的当前虚拟 DOM 树,以准备cog
进行下一个渲染周期:
图 9.3:齿轮的现有虚拟 DOM 树(左)和齿轮的新虚拟 DOM 树(右)
图 9.3在左侧描述了齿轮的现有虚拟 DOM 树,右侧描述了齿轮的新虚拟 DOM 树。在对两个虚拟 DOM 树(新的和现有的)进行diff
操作后,确定右侧的div
元素(包含ul
元素)及其子元素已更改,并且协调操作将仅更新实际 DOM 中的div
元素及其子元素。
齿轮的生命周期
图 9.4描述了cog
的生命周期,该生命周期始于服务器端,在那里我们首先注册cog
。必须在服务器端注册cog
的类型,以便cog
的关联模板以及其他静态资产可以自动捆绑并提供给客户端应用程序:
图 9.4:齿轮的生命周期
cog
生命周期中的后续步骤发生在客户端。我们通过引入一个div
元素,其 data-component 属性等于"cog"
,来声明cog
的挂载点,以指示该div
元素是cog
的挂载点。
下一步是通过调用其构造函数创建cog
的新实例。我们通过调用其CogInit
方法并传递客户端应用程序的TemplateSet
对象来初始化cog
。初始化cog
还包括通过调用其SetID
方法将挂载点与cog
关联起来(以便cog
知道在哪里进行渲染)。Cog
初始化还包括在调用Start
方法之前通过调用其SetProp
方法在cog
的Props map
中设置 prop。
请注意,在调用齿轮的Start
方法之前调用齿轮的SetProp
方法将不会渲染cog
。只有在通过调用其Start
方法将cog
呈现到挂载点后,才会在调用其SetProp
方法后重新呈现cog
。
调用Cog
的Start
方法将激活cog
并将cog
的内容呈现到指定的挂载点。
任何后续对齿轮的SetProp
方法的调用都将导致齿轮的重新渲染。
当用户在网站上导航到不同的页面时,包含cog
的容器将被移除,从而有效地销毁cog
。用户可以指定一个清理函数,在销毁cog
之前应该调用该函数。这可以帮助在cog
被销毁之前以负责任的方式释放资源。我们将在本章后面看到实现清理函数的示例。
实现纯 cogs
现在我们对 cogs 有了基本的了解,是时候在实践中实现一些 cogs 了。尽管 cogs 在客户端操作,但重要的是要注意,服务器端应用程序需要通过注册来承认它们的存在。出于这个原因,cogs 的代码被策略性地放置在shared/cogs
文件夹中。
纯 cogs 是专门用 Go 实现的。正如你将看到的,我们可以利用现有的 Go 包的功能来实现 cogs。
在igweb.go
源文件的主函数中,我们调用initailizeCogs
函数,传入应用程序的模板集:
initializeCogs(env.TemplateSet)
initializeCogs
函数负责初始化 Isomorphic Go web 应用程序中要使用的所有 cogs:
func initializeCogs(ts *isokit.TemplateSet) {
timeago.NewTimeAgo().CogInit(ts)
liveclock.NewLiveClock().CogInit(ts)
datepicker.NewDatePicker().CogInit(ts)
carousel.NewCarousel().CogInit(ts)
notify.NewNotify().CogInit(ts)
isokit.BundleStaticAssets()
}
请注意,initializeCogs
函数接受一个唯一的输入参数ts
,即TemplateSet
对象。我们调用齿轮的构造函数来创建一个新的cog
实例,并立即调用cog
的CogInit
方法,将TemplateSet
对象ts
作为输入参数传递给该方法。这允许cog
将其模板包含到应用程序的模板集中,以便随后要生成的模板包将包括与cog
相关的模板。
我们调用BundleStaticAssets
方法来生成每个cog
所需的静态资源(CSS 和 JavaScript 源文件)。将生成两个文件。第一个文件是cogimports.css
,其中包含所有 cogs 所需的 CSS 源代码,第二个文件是cogimports.js
,其中包含所有 cogs 所需的 JavaScript 源代码。
时间差 cog
现在我们已经看到了如何在服务器端初始化 cogs,是时候来看看制作cog
需要做些什么了。我们将从制作一个非常简单的cog
开始,即时间差cog
,它以人类可理解的格式显示时间。
是时候重新查看关于页面上的 Gopher 简介了。在第三章中的自定义模板函数部分,Go on the Front-End with GopherJS,我们学习了如何使用自定义模板函数以 Ruby 格式显示 Gopher 的开始日期时间。
我们将进一步展示开始日期时间的人类可理解格式,通过实现一个时间差cog
。图 9.5是一个示例,显示了 Molly 在默认 Go 格式、Ruby 格式和人类可理解格式的开始日期:
图 9.5:描绘时间差 cog 的插图,最后一行显示了人类可读格式的时间
Molly 于 2017 年 5 月 24 日加入了 IGWEB 团队,以人类可读的格式,即 6 个月前(在撰写时的当前时间)。
在about_content.tmpl
模板源文件中,我们为时间差cog
引入了一个div
容器:
<h1>About</h1>
<div id="gopherTeamContainer">
{{range .Gophers}}
<div class="gopherContainer">
<div class="gopherImageContainer">
<img height="270" src="img/{{.ImageURI}}">
</div>
<div class="gopherDetailsContainer">
<div class="gopherName"><h3><b>{{.Name}}</b></h3></div>
<div class="gopherTitle"><span>{{.Title}}</span></div>
<div class="gopherBiodata"><p>{{.Biodata}}</p></div>
<div class="gopherStartTime">
<p class="standardStartTime">{{.Name}} joined the IGWEB team on <span class="starttime">{{.StartTime}}.</p>
<p class="rubyStartTime">That's <span class="starttime">{{.StartTime | rubyformat}}</span> in Ruby date format.</p>
<div class="humanReadableGopherTime">That's
<div id="Gopher-{{.Name}}" data-starttimeunix="{{.StartTime | unixformat}}" data-component="cog" class="humanReadableDate starttime"></div>
in Human readable format.
</div>
</div>
</div>
</div>
{{end}}
</div>
请注意,我们已经分配了名为data-component
的属性,其值为cog
。这是为了表明这个div
容器将作为一个装载点,容纳cog
的渲染内容。我们将容器的id
属性设置为带有前缀"Gopher-"
的 Gopher 的名字。
稍后您将看到,当我们实例化一个cog
时,我们必须为cog
的div
容器提供一个 ID,以便cog
实例知道它的挂载点是cog
应该将其输出呈现到的位置。我们定义了另一个自定义数据属性starttimeunix
,并将其设置为 Gopher 开始为 IGWEB 工作时的 Unix 时间戳值。
请记住,该值是通过调用模板操作获得的,该操作将StartTime
属性通过管道传输到自定义模板函数unixformat
中获得的值。
unixformat
自定义模板函数是shared/templatefuncs/funcs.go
源文件中定义的UnixTime
函数的别名:
func UnixTime(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
}
此函数将返回给定Time
实例的 Unix 格式的时间作为string
值。
回到about_content.tmpl
源文件,注意提供给div
容器的humanReadableDate
CSS className
。我们稍后将使用这个 CSS className
来获取关于页面上所有timeago
cog
的div
容器。
现在我们已经看到了如何在关于页面上声明cog
的div
容器,让我们来看看如何实现时间过去的cog
。
时间过去的cog
是一个纯 Go cog
。这意味着它仅使用 Go 实现。Go 包go-humanize
为我们提供了显示时间的功能。我们将利用这个包来实现时间过去的cog
。这是go-humanize
包的 GitHub 页面的 URL:github.com/dustin/go-humanize
。
让我们检查shared/cogs/timeago/timeago.go
源文件。我们首先声明包名为timeago
:
package timeago
在我们的导入分组中,我们包括github.com/uxtoolkit/cog
,这个包为我们提供了实现cog
的功能(以粗体显示)。我们在导入分组中包括go-humanize
分组,并用名称"humanize"
进行别名(以粗体显示):
import (
"errors"
"reflect"
"time"
humanize "github.com/dustin/go-humanize"
"github.com/uxtoolkit/cog"
)
所有的齿轮都必须声明一个名为cogType
的未导出变量,其类型为reflect.Type
:
var cogType reflect.Type
在init
函数内,我们使用reflect.TypeOf
函数对新创建的TimeAgo
实例调用,将返回的值赋给cogType
变量:
func init() {
cogType = reflect.TypeOf(TimeAgo{})
}
对于我们实现的每个cog
,也需要初始化cogType
变量。正确设置cogType
允许静态资产捆绑系统考虑到齿轮在 Web 应用程序中的静态资产依赖关系。cogType
将被用于收集所有模板和静态资产,这些资产是使cog
函数正常运行所需的。
这是我们用来定义TimeAgo cog
的struct
:
type TimeAgo struct {
cog.UXCog
timeInstance time.Time
}
请注意,我们在struct
定义中嵌入了ux.UXCog
。正如前面所述,cog.UXCog
类型将为我们提供必要的功能,以允许我们呈现cog
。除了嵌入ux.UXCog
,我们还声明了一个未导出字段,名为timeInstance
,类型为time.Time
。这将包含我们将转换为人类可读格式的time.Time
实例。
我们创建一个名为NewTimeAgo
的构造函数,它返回一个新的TimeAgo cog
实例:
func NewTimeAgo() *TimeAgo {
t := &TimeAgo{}
t.SetCogType(cogType)
return t
}
我们在这里拥有的构造函数遵循 Go 中实现的任何其他构造函数的相同模式。请注意,我们将cogType
传递给新创建的TimeAgo
实例的SetCogType
方法。这是必需的,以便cog
的静态资产包含在 isokit 的静态资产捆绑系统生成的静态资产捆绑中。
我们为TimeAgo
结构的timeInstance
字段创建了一个 setter 方法,名为SetTime
:
func (t *TimeAgo) SetTime(timeInstance time.Time) {
t.timeInstance = timeInstance
}
客户端应用程序将使用此 setter 方法为TimeAgo
cog
设置时间。我们将使用SetTime
方法来设置 Gopher 加入 IGWEB 团队的开始日期。
为了实现Cog
接口,cog
必须定义一个Start
方法。Start
方法是cog
中操作发生的地方。通过阅读其Start
方法,您应该能够对cog
的功能有一个大致的了解。以下是TimeAgo
cog 的Start
方法:
func (t *TimeAgo) Start() error {
if t.timeInstance.IsZero() == true {
return errors.New("The time instance value has not been set!")
}
t.SetProp("timeAgoValue", humanize.Time(t.timeInstance))
err := t.Render()
if err != nil {
return err
}
return nil
}
Start
方法返回一个错误对象,通知调用者cog
是否正确启动。在执行任何活动之前,会检查timeInstance
值是否已设置。我们使用if
条件语句来检查timeInstance
值是否为其零值,表示它尚未设置。如果发生这种情况,该方法将返回一个新创建的error
对象,指示时间值尚未设置。如果timeInstance
值已设置,我们将继续向前。
我们调用 cog 的SetProp
方法,使用人类可理解的时间值设置timeAgoValue
属性。我们通过调用go-humanize
包(别名为humanize
)中的Time
函数,并传递 cog 的timeInstance
值来获取人类可理解的时间值。
我们调用 cog 的Render
方法来渲染cog
。如果在尝试渲染cog
时发生错误,则Start
方法将返回error
对象。否则,将返回nil
值,表示启动cog
时没有错误。
此时,我们已经实现了timeago
cog 的 Go 部分。为了使人类可读的时间出现在网页上,我们必须实现 cog 的模板。
timeago.tmpl
文件(位于shared/cogs/timeago/templates
目录中)是一个简单的单行模板。我们声明以下span
元素,并且有一个模板动作来渲染timeAgoValue
属性:
<span class="timeagoSpan">{{.timeAgoValue}}</span>
按照惯例,cog
包中的主要模板的名称必须与cog
包的相同。例如,对于timeago
包,cog
的主要模板将是timeago.tmpl
。您可以自由定义和使用已在应用程序模板集中注册的任何自定义模板函数,以及cog
模板。您还可以创建任意数量的子模板,这些子模板将由cog
的主要模板调用。
现在我们已经准备好在关于页面上实例化cog
的模板。
让我们来看看client/handlers/about.go
源文件中的InitializeAboutPage
函数:
func InitializeAboutPage(env *common.Env) {
humanReadableDivs := env.Document.GetElementsByClassName("humanReadableDate")
for _, div := range humanReadableDivs {
unixTimestamp, err := strconv.ParseInt(div.GetAttribute("data-starttimeunix"), 10, 64)
if err != nil {
log.Println("Encountered error when attempting to parse int64 from string:", err)
}
t := time.Unix(unixTimestamp, 0)
humanTime := timeago.NewTimeAgo()
humanTime.CogInit(env.TemplateSet)
humanTime.SetID(div.ID())
humanTime.SetTime(t)
err = humanTime.Start()
if err != nil {
println("Encountered the following error when attempting to start the timeago cog: ", err)
}
}
}
由于关于页面上列出了三个地鼠,页面上将运行总共三个TimeAgo
cog 实例。我们使用env.Document
对象上的GetElementByClassName
方法,提供humanReadableDate
类名,来收集 cog 的div
容器。然后我们循环遍历每个div
元素,这就是实例化cog
的所有操作发生的地方。
首先,我们从div
容器中包含的自定义数据属性中提取 Unix 时间戳值。回想一下,我们使用自定义模板函数unixformat
将starttimeunix
自定义数据属性填充为地鼠的开始时间的 Unix 时间戳。
然后我们使用time
包中可用的Unix
函数创建一个新的time.Time
对象,并提供我们从div
容器的自定义数据属性中提取的unixTimestamp
。用粗体显示了实例化和设置TimeAgo
cog 的代码。我们首先通过调用构造函数NewTimeAgo
来实例化一个新的TimeAgo
cog,并将其分配给humanTime
变量。
然后我们在humanTime
对象上调用CogInit
方法,并提供env.TemplateSet
对象。我们调用SetID
方法来注册div
容器的id
属性,以将其与cog
实例关联起来。然后我们在TimeAgo
cog 上调用SetTime
方法,传入我们从div
容器中提取的unixTimestamp
创建的time.Time
对象t
。
现在我们已经准备好通过调用其Start
方法启动cog
。我们将Start
方法返回的error
对象分配给err
。如果err
不等于nil
,则表示在启动cog
时发生了错误,在这种情况下,我们将在网页控制台中打印出有意义的消息。如果没有错误,cog
将呈现在网页上。图 9.6显示了 Molly 的启动时间的屏幕截图。
图 9.6:时间前cog
的操作
实时时钟cog
当我们在时间前调用Start
方法时,时间将使用虚拟 DOM 呈现在网页上,而不是进行替换内部 HTML 操作。由于时间前cog
只更新一次时间,即在调用cog
的Start
方法时,很难欣赏到cog
的虚拟 DOM 的作用。
在这个例子中,我们将构建一个实时时钟Cog
,它具有显示世界上任何地方的当前时间的能力。由于我们将显示到秒的时间,我们将每秒执行一次SetProp
操作以重新呈现实时时钟Cog
。
图 9.7是实时时钟的插图:
图 9.7:描绘实时时钟cog
的插图
我们将为四个地方渲染当前时间:您目前所在的地方、金奈、新加坡和夏威夷。在shared/templates/index_content.tmpl
模板源文件中,我们声明了四个div
容器,它们作为我们将实例化的四个实时时钟cog
的安装点。
<div data-component="cog" id="myLiveClock" class="liveclockTime"></div>
<div data-component="cog" id="chennaiLiveClock" class="liveclockTime"></div>
<div data-component="cog" id="singaporeLiveClock" class="liveclockTime"></div>
<div data-component="cog" id="hawaiiLiveClock" class="liveclockTime"></div>
再次注意,我们通过声明包含属性"data-component"
的div
容器来定义实时时钟的安装点,并将其值设置为"cog"
。我们为所有四个cog
容器分配了唯一的 ID。我们在div
容器中声明的类名liveclockTime
是用于样式目的。
现在我们已经为四个实时时钟cog
设置了安装点,让我们来看看如何实现实时时钟cog
。
实时时钟Cog
的实现可以在shared/cogs/liveclock
文件夹中的liveclock.go
源文件中找到。
我们为cog
的包名称声明了名称liveclock
:
package liveclock
请注意,在我们的导入分组中,我们包含了github.com/uxtoolkit/cog
包:
import (
"errors"
"reflect"
"time"
"github.com/uxtoolkit/cog"
)
我们定义了未导出的包变量cogType
:
var cogType reflect.Type
在init
函数内,我们将cogType
变量赋值为调用reflect.TypeOf
函数在新创建的LiveClock
实例上返回的值:
func init() {
cogType = reflect.TypeOf(LiveClock{})
}
这是实现cog
的必要步骤。
到目前为止,我们已经确定了声明和初始化cogType
是实现cog
的基本要求的一部分。
以下是LiveClock
cog 的结构:
type LiveClock struct {
cog.UXCog
ticker *time.Ticker
}
我们在cog
的结构定义中嵌入了cog.UXCog
类型。我们引入了一个ticker
字段,它是指向time.Ticker
的指针。我们将使用这个ticker
每秒进行一次实时时钟的滴答。
以下是LiveClock
cog 的构造函数:
func NewLiveClock() *LiveClock {
liveClock := &LiveClock{}
liveClock.SetCogType(cogType)
liveClock.SetCleanupFunc(liveClock.Cleanup)
return liveClock
}
NewLiveClock
函数充当实时时钟cog
的构造函数。我们声明并初始化liveClock
变量为一个新的LiveClock
实例。我们调用liveClock
对象的SetCogType
方法并传递cogType
。请记住,这是构造函数中必须存在的步骤(以粗体显示)。
然后我们调用liveClock
对象的SetCleanupFunc
方法,并提供一个清理函数liveClock.Cleanup
。SetCleanUp
方法包含在cog.UXCog
类型中。它允许我们指定一个在cog
从 DOM 中移除之前应该调用的清理函数。最后,我们返回LiveClock cog
的新实例。
让我们来看一下Cleanup
函数:
func (lc *LiveClock) Cleanup() {
lc.ticker.Stop()
}
这个函数非常简单。我们只需在 cog 的ticker
对象上调用Stop
方法来停止ticker
。
这是 cog 的Start
方法,其中ticker
将被启动:
func (lc *LiveClock) Start() error {
我们首先声明时间布局常量layout
,并将其设置为RFC1123Z
时间格式。我们声明一个location
变量,指向time.Location
类型:
const layout = time.RFC1123
var location *time.Location
在启动LiveClock
cog 之前,cog
的用户必须设置两个重要的属性,即"timezoneName"
和"timezoneOffset"
:
if lc.Props["timezoneName"] != nil && lc.Props["timezoneOffset"] != nil {
location = time.FixedZone(lc.Props["timezoneName"].(string), lc.Props["timezoneOffset"].(int))
} else {
return errors.New("The timezoneName and timezoneOffset props need to be set!")
}
这些值用于初始化位置变量。如果这些属性中的任何一个未提供,将返回一个错误。
如果这两个属性都存在,我们继续将实时时钟cog
的ticker
属性分配给一个新创建的time.Ticker
实例,它将每秒进行滴答:
lc.ticker = time.NewTicker(time.Millisecond * 1000)
我们在 ticker 的通道上使用range
来迭代每一秒,当值到达时,我们设置currentTime
属性,为其提供格式化的时间值(以粗体显示):
go func() {
for t := range lc.ticker.C {
lc.SetProp("currentTime", t.In(location).Format(layout))
}
}()
请注意,我们同时使用了位置和时间布局来格式化时间。一旦 cog 被渲染,每秒将自动调用SetProp
来调用Render
方法重新渲染 cog。
我们调用 cog 的Render
方法来将 cog 渲染到网页上:
err := lc.Render()
if err != nil {
return err
}
在方法的最后一行,我们返回一个nil
值,表示没有发生错误:
return nil
我们已经在liveclock.tmpl
源文件中定义了cog
的模板:
<p>{{.timeLabel}}: {{.currentTime}}</p>
我们打印出时间标签,以及当前时间。timeLabel
属性用于向cog
提供时间标签,并且将是我们想要知道当前时间的地方的名称。
现在我们已经看到了制作实时时钟cog
所需的内容,以及它如何显示时间,让我们继续在主页上添加一些实时时钟 cogs。
这是index.go
源文件中InitializeIndexPage
函数内部的代码部分,我们在其中为本地时区实例化实时时钟 cog:
// Localtime Live Clock Cog
localZonename, localOffset := time.Now().In(time.Local).Zone()
lc := liveclock.NewLiveClock()
lc.CogInit(env.TemplateSet)
lc.SetID("myLiveClock")
lc.SetProp("timeLabel", "Local Time")
lc.SetProp("timezoneName", localZonename)
lc.SetProp("timezoneOffset", localOffset)
err = lc.Start()
if err != nil {
println("Encountered the following error when attempting to start the local liveclock cog: ", err)
}
为了实例化本地时间的 cog,我们首先获取本地区域名称和本地时区偏移量。然后我们创建一个名为lc
的LiveClock cog
的新实例。我们调用CogInit
方法来初始化 cog。我们调用SetID
方法来注册 cog 的挂载点的id
,即div
容器,cog
将把其输出渲染到其中。我们调用SetProp
方法来设置"timeLabel"
、"timezoneName"
和"timezoneOffset"
属性。最后,我们调用Start
方法来启动LiveClock
cog。和往常一样,我们检查cog
是否正常启动,如果没有,我们在 web 控制台中打印出error
对象。
类似地,我们以与本地时间相同的方式实例化了 Chennai、新加坡和夏威夷的LiveClock
cogs,除了一件事。对于其他地方,我们明确提供了每个地方的时区名称和 GMT 时区偏移量:
// Chennai Live Clock Cog
chennai := liveclock.NewLiveClock()
chennai.CogInit(env.TemplateSet)
chennai.SetID("chennaiLiveClock")
chennai.SetProp("timeLabel", "Chennai")
chennai.SetProp("timezoneName", "IST")
chennai.SetProp("timezoneOffset", int(+5.5*3600))
err = chennai.Start()
if err != nil {
println("Encountered the following error when attempting to start the chennai liveclock cog: ", err)
}
// Singapore Live Clock Cog
singapore := liveclock.NewLiveClock()
singapore.CogInit(env.TemplateSet)
singapore.SetID("singaporeLiveClock")
singapore.SetProp("timeLabel", "Singapore")
singapore.SetProp("timezoneName", "SST")
singapore.SetProp("timezoneOffset", int(+8.0*3600))
err = singapore.Start()
if err != nil {
println("Encountered the following error when attempting to start the singapore liveclock cog: ", err)
}
// Hawaii Live Clock Cog
hawaii := liveclock.NewLiveClock()
hawaii.CogInit(env.TemplateSet)
hawaii.SetID("hawaiiLiveClock")
hawaii.SetProp("timeLabel", "Hawaii")
hawaii.SetProp("timezoneName", "HDT")
hawaii.SetProp("timezoneOffset", int(-10.0*3600))
err = hawaii.Start()
if err != nil {
println("Encountered the following error when attempting to start the hawaii liveclock cog: ", err)
}
现在,我们将能够看到实时时钟 cogs 的运行情况。图 9.8是主页上显示的实时时钟的屏幕截图。
图 9.8:实时时钟 cog 的运行情况
随着每一秒的流逝,每个实时时钟都会更新新的时间值。虚拟 DOM 会渲染出变化的部分,有效地在每秒重新渲染实时时钟。
到目前为止,我们实现的前两个 cogs 都是完全由 Go 实现的纯 cogs。如果我们想利用现有的 JavaScript 解决方案来提供特定功能,该怎么办?这将是需要实现混合 cog 的情况,一个由 Go 和 JavaScript 实现的cog
。
实现混合 cogs
JavaScript 已经存在了二十多年。在这段时间内,使用这种语言创建了许多强大的、可用于生产的解决方案。同构 Go 不能独立存在,我们必须承认 JavaScript 生态系统中有许多有用的现成解决方案。在许多情况下,我们可以通过利用现有的 JavaScript 解决方案来节省大量时间和精力,而不是以纯 Go 的方式重新实现整个解决方案。
混合 cogs 是使用 Go 和 JavaScript 实现的。混合 cogs 的主要目的是利用现有的 JavaScript 解决方案的功能,并将该功能公开为cog
。这意味着cog
实现者需要了解 Go 和 JavaScript 来实现混合 cogs。请记住,混合 cogs 的用户只需要了解 Go,因为 JavaScript 的使用是cog
的内部实现细节。这使得那些可能不熟悉 JavaScript 的 Go 开发人员可以方便地使用 cogs。
日期选择器 cog
让我们考虑一种需要实现混合cog
的情况。Molly,IGWEB 的事实产品经理,提出了一个提供更好客户支持的绝佳主意。她向技术团队提出的功能请求是允许网站用户在联系表单上提供一个可选的优先日期,通过这个日期,用户应该在 IGWEB 团队的 gopher 回复。
Molly 找到了一个独立的日期选择器小部件,使用纯 JavaScript 实现(没有框架/库依赖),名为 Pikaday:github.com/dbushell/Pikaday
。
Pikaday,JavaScript 日期选择器小部件,突出了本节开头提到的事实。JavaScript 不会消失,已经有许多有用的解决方案是用它创建的。这意味着,我们必须有能力在有意义的时候利用现有的 JavaScript 解决方案。Pikaday 日期选择器是一个特定的用例,更有利于利用现有的 JavaScript 日期选择器小部件,而不是将其作为纯cog
实现。
图 9.9:描述时间敏感日期输入字段和日历日期选择器小部件的线框设计
图 9.9是一个线框设计,描述了带有时间敏感输入字段的联系表单,当点击时,将显示一个日历日期选择器。让我们看看通过使用 Go 和 JavaScript 实现的日期选择器 cog 来满足 Molly 的请求需要做些什么。
我们首先将 Pikaday 日期选择器所需的 JavaScript 和 CSS 源文件放在cog
的static
文件夹中的js
和css
文件夹中(分别)。
在shared/templates/partials/contactform_partial.tmpl
源文件中,我们声明了日期选择器 cog 的挂载点(以粗体显示):
<fieldset class="pure-control-group">
<div data-component="cog" id="sensitivityDate"></div>
</fieldset>
div
容器满足所有cog
挂载点的两个基本要求:我们已经设置了属性"data-component"
,值为"cog"
,并为cog
容器指定了一个id
为"sensitivityDate"
。
让我们逐节检查日期选择器 cog 的实现,定义在shared/cogs/datepicker/datepicker.go
源文件中。首先,我们从声明包名开始:
package datepicker
这是 cog 的导入分组:
import (
"errors"
"reflect"
"time"
"github.com/gopherjs/gopherjs/js"
"github.com/uxtoolkit/cog"
)
注意我们在导入分组中包含了gopherjs
包(以粗体显示)。我们将需要gopherjs
的功能来查询 DOM。
在我们声明cogType
之后,我们将JS
变量初始化为js.Global
:
var cogType reflect.Type
var JS = js.Global
正如您可能还记得的那样,这为我们节省了一点输入。我们可以直接将js.Global
称为JS
。
从 Pikaday 项目网页github.com/dbushell/Pikaday
,我们可以了解日期选择器小部件接受的所有输入参数。输入参数作为单个 JavaScript 对象提供。日期选择器cog
将公开这些输入参数的子集,足以满足 Molly 的功能请求。我们创建了一个名为DatePickerParams
的struct
,它作为日期选择器小部件的输入参数:
type DatePickerParams struct {
*js.Object
Field *js.Object `js:"field"`
FirstDay int `js:"firstDay"`
MinDate *js.Object `js:"minDate"`
MaxDate *js.Object `js:"maxDate"`
YearRange []int `js:"yearRange"`
}
我们嵌入*js.Object
以指示这是一个 JavaScript 对象。然后我们为 JavaScript 输入对象的相应属性的struct
声明相应的 Go 字段。例如,名为Field
的字段是为field
属性而声明的。我们为每个字段提供的"js"
struct
标签允许 GopherJS 将struct
及其字段从其指定的 Go 名称转换为其等效的 JavaScript 名称。正如我们声明了名为 Field 的字段一样,我们还为FirstDay
(firstDay
)、MinDate
(minDate
)、MaxDate
(maxDate
)和YearRange
(yearRange
)声明了字段。
阅读 Pikaday 文档,github.com/dbushell/Pikaday
,我们可以了解每个输入参数的作用:
-
Field
- 用于将日期选择器绑定到表单字段。 -
FirstDay
- 用于指定一周的第一天。(0 代表星期日,1 代表星期一,依此类推)。 -
MinDate
- 可以在日期选择器小部件中选择的最早日期。 -
MaxDate
- 可以在日期选择器小部件中选择的最晚日期。 -
YearRange
- 要显示的年份范围。
现在我们已经定义了日期选择器的输入参数结构DatePickerParams
,是时候实现日期选择器cog
了。我们首先声明DatePicker
结构:
type DatePicker struct {
cog.UXCog
picker *js.Object
}
像往常一样,我们嵌入cog.UXCog
来带来我们需要的所有 UXCog 功能。我们还声明了一个字段picker
,它是指向js.Object
的指针。picker
属性将用于引用 Pikaday 日期选择器 JavaScript 对象。
然后我们为日期选择器cog
实现了一个名为NewDatePicker
的构造函数:
func NewDatePicker() *DatePicker {
d := &DatePicker{}
d.SetCogType(cogType)
return d
}
到目前为止,cog 构造函数对您来说应该很熟悉。它的职责是返回DatePicker
的新实例,并设置 cog 的cogType
。
现在我们的构造函数已经就位,是时候来检查日期选择器 cog 的Start
方法了:
func (d *DatePicker) Start() error {
if d.Props["datepickerInputID"] == nil {
return errors.New("Warning: The datePickerInputID prop need to be set!")
}
err := d.Render()
if err != nil {
return err
}
我们首先检查是否已设置"datepickerInputID"
属性。这是输入字段元素的id
,将用作DatePickerParams
struct
中的Field
值。在开始cog
之前,调用者必须设置此属性,这是一个硬性要求。未设置此属性将导致错误。
如果已设置"datepickerInputID"
属性,我们调用 cog 的Render
方法来渲染 cog。这将为日期选择器 JavaScript 小部件依赖的输入字段渲染 HTML 标记。
然后我们声明并实例化params
,这是一个 JavaScript 对象,将被传递给日期选择器 JavaScript 小部件:
params := &DatePickerParams{Object: js.Global.Get("Object").New()}
日期选择器输入参数对象params
是一个 JavaScript 对象。Pikaday JavaScript 对象将使用params
对象进行初始配置。
我们使用 cog 的Props
属性来遍历 cog 的属性。对于每次迭代,我们获取属性的名称(propName
)和属性的值(propValue
):
for propName, propValue := range d.Props {
我们声明的switch
块对于可读性很重要:
switch propName {
case "datepickerInputID":
inputFieldID := propValue.(string)
dateInputField := JS.Get("document").Call("getElementById", inputFieldID)
params.Field = dateInputField
case "datepickerLabel":
// Do nothing
case "datepickerMinDate":
datepickerMinDate := propValue.(time.Time)
minDateUnix := datepickerMinDate.Unix()
params.MinDate = JS.Get("Date").New(minDateUnix * 1000)
case "datepickerMaxDate":
datepickerMaxDate := propValue.(time.Time)
maxDateUnix := datepickerMaxDate.Unix()
params.MaxDate = JS.Get("Date").New(maxDateUnix * 1000)
case "datepickerYearRange":
yearRange := propValue.([]int)
params.YearRange = yearRange
default:
println("Warning: Unknown prop name provided: ", propName)
}
}
switch
块内的每个case
语句告诉我们日期选择器cog
接受的所有属性作为输入参数,这些参数将被传递到 Pikaday JavaScript 小部件。如果未识别属性名称,则在 Web 控制台中打印警告,说明该属性未知。
第一种情况处理了"datepickerInputID"
属性。它将用于指定激活 Pikaday 小部件的输入元素的id
。在这种情况下,我们通过在document
对象上调用getElementById
方法并将inputFieldID
传递给该方法来获取输入元素字段。我们将输入params
属性Field
设置为从getElementById
方法调用中获取的输入字段元素。
第二种情况处理了"datepickerLabel"
属性。"datepickerLabel"
属性的值将在 cog 的模板源文件中使用。因此,不需要处理这种特殊情况。
第三种情况处理了"datepickerMinDate"
属性。它将用于获取 Pikaday 小部件应显示的最小日期。我们将调用者提供的type time.Time
的"datepickerMinDate"
值转换为其 Unix 时间戳表示。然后,我们使用 Unix 时间戳创建一个新的 JavaScript date
对象,适用于minDate
输入参数。
第四种情况处理了"datepickerMaxDate"
属性。它将用于获取日期选择器小部件应显示的最大日期。我们在这里采用了与minDate
参数相同的策略。
第五种情况处理了"datepickerYearRange"
属性。它将用于指定显示的日历将覆盖的年份范围。年份范围是一个切片,我们使用属性的值填充输入参数对象的YearRange
属性。
如前所述,default
case
处理了调用者提供未知属性名称的情况。如果我们到达default
case
,我们将在 Web 控制台中打印警告消息。
现在我们可以实例化 Pikaday 小部件,并将输入参数对象params
提供给它:
d.picker = JS.Get("Pikaday").New(params)
最后,我们通过返回nil
值表示启动cog
时没有错误:
return nil
现在我们已经实现了日期选择器 cog,让我们来看看 cog 的主要模板,定义在shared/cogs/datepicker/templates/datepicker.tmpl
源文件中,是什么样子:
<label class="datepickerLabel" for="datepicker">{{.datepickerLabel}}</label>
<input class="datepickerInput" type="text" id="{{.datepickerInputID}}" name="{{.datepickerInputID}}">
我们声明一个label
元素,使用属性"datepickerLabel"
显示日期选择器 cog 的标签。我们声明一个input
元素,它将作为与 Pikaday 小部件一起使用的输入元素字段。我们使用"datepickerInputID"
属性指定输入元素字段的id
属性。
现在我们已经实现了日期选择器 cog,是时候开始使用它了。我们在client/handlers/contact.go
源文件中的InitializeContactPage
函数中实例化cog
:
byDate := datepicker.NewDatePicker()
byDate.CogInit(env.TemplateSet)
byDate.SetID("sensitivityDate")
byDate.SetProp("datepickerLabel", "Time Sensitivity Date:")
byDate.SetProp("datepickerInputID", "byDateInput")
byDate.SetProp("datepickerMinDate", time.Now())
byDate.SetProp("datepickerMaxDate", time.Date(2027, 12, 31, 23, 59, 0, 0, time.UTC))
err := byDate.Start()
if err != nil {
println("Encountered the following error when attempting to start the datepicker cog: ", err)
}
首先,我们创建一个DatePicker cog
的新实例。然后,我们调用 cog 的CogInit
方法,注册应用程序的模板集。我们调用SetID
方法设置 cog 的挂载点。我们调用 cog 的SetProp
方法设置datePickerLabel
、datepickerInputID
、datepickerMinDate
和datepickerMaxDate
属性。我们调用 cog 的Start
方法来激活它。如果启动cog
时出现任何错误,我们将错误消息打印到 Web 控制台。
这就是全部内容了!我们可以利用日期选择器混合cog
从 Pikaday 小部件中获取所需的功能。这种方法的优势在于,使用日期选择器cog
的 Go 开发人员不需要了解 Pikaday 小部件的内部工作(JavaScript),就可以使用它。相反,他们可以在 Go 的范围内使用日期选择器cog
向他们公开的功能。
图 9.10显示了日期选择器cog
的操作截图:
图 9.10:日历日期选择器小部件的操作
即使齿轮用户除了必需的datepickerInputID
之外没有提供任何自定义配置日期选择器cog
的 props,Pikaday 小部件也可以正常启动。但是,如果我们需要为cog
提供一组默认参数怎么办?在下一个示例中,我们将构建另一个混合cog
,一个轮播图(图像滑块)cog
,在其中我们将定义默认参数。
轮播图齿轮
在本示例中,我们将创建一个图像轮播图齿轮,如图 9.11中的线框设计所示。
图 9.11:描述轮播图齿轮的线框设计
轮播图齿轮将由 vanilla JavaScript 中实现的 tiny-slider 小部件提供动力。以下是 tiny-slider 项目的 URL:github.com/ganlanyuan/tiny-slider
。
我们将 tiny-slider 小部件的 JavaScript 源文件tiny-slider.min.js
放在齿轮的static/js
文件夹中。我们将与 tiny-slider 小部件相关的 CSS 文件tiny-slider.css
和styles.css
放在static/css
文件夹中。
我们将构建的轮播图齿轮将公开由 tiny-slider 小部件提供的以下输入参数:
container Node | String Default: document.querySelector('.slider').
container
参数表示滑块容器元素或选择器:
items Integer Default: 1.
items
参数表示正在显示的幻灯片数量:
slideBy Integer | 'page' Default: 1.
slideBy
参数表示一次“点击”要进行的幻灯片数量:
autoplay Boolean Default: false.
autoplay
参数用于切换幻灯片的自动更改:
autoplayText Array (Text | Markup) Default: ['start', 'stop'].
autoplayText
参数控制自动播放开始/停止按钮中显示的文本或标记。
controls Boolean Default: true.
controls
参数用于切换控件(上一个/下一个按钮)的显示和功能。
图像轮播图将显示 IGWEB 上可用的一组特色产品。我们在shared/templates/index_content.tmpl
源文件中声明了齿轮的挂载点:
<div data-component="cog" id="carousel"></div>
我们声明了作为轮播图齿轮挂载点的div
容器。我们声明了属性"data-component"
,并将其赋值为"cog"
。我们还声明了一个id
属性为"carousel"
。
轮播图齿轮实现在shared/cogs/carousel
文件夹中的carousel.go
源文件中。以下是包声明和导入分组:
package carousel
import (
"errors"
"reflect"
"github.com/gopherjs/gopherjs/js"
"github.com/uxtoolkit/cog"
)
tiny-slider 小部件使用输入参数 JavaScript 对象进行实例化。我们将使用CarouselParams struct
来建模输入参数对象:
type CarouselParams struct {
*js.Object
Container string `js:"container"`
Items int `js:"items"`
SlideBy string `js:"slideBy"`
Autoplay bool `js:"autoplay"`
AutoplayText []string `js:"autoplayText"`
Controls bool `js:"controls"`
}
在嵌入指向js.Object
的指针之后,我们在struct
中定义的每个字段都对应于其等效的 JavaScript 参数对象属性。例如,Container
字段映射到输入参数对象的container
属性。
以下是定义carousel
齿轮的struct
:
type Carousel struct {
cog.UXCog
carousel *js.Object
}
像往常一样,我们嵌入了cog.UXCog
类型,以借用UXCog
的功能。carousel
字段将用于引用 JavaScript 对象的 tiny-slider 小部件。
到目前为止,您应该能够猜到轮播图齿轮的构造函数是什么样子的:
func NewCarousel() *Carousel {
c := &Carousel{}
c.SetCogType(cogType)
return c
}
除了创建对Carousel
实例的新引用之外,构造函数还设置了齿轮的cogType
。
现在是时候检查轮播图齿轮实现的大部分内容了,这些内容可以在齿轮的Start
方法中找到:
func (c *Carousel) Start() error {
我们首先检查cog
的用户是否设置了contentItems
和carouselContentID
props。contentItems
prop 是应该出现在轮播图中的图像的服务器相对路径的字符串切片。carouselContentID
prop 是包含轮播图内容的div
容器的id
属性的值。
如果这些 props 中的任何一个都没有设置,我们将返回一个指示这两个 props 都必须设置的error
。如果这两个 props 已经设置,我们将继续渲染齿轮:
if c.Props["contentItems"] == nil || c.Props["carouselContentID"] == nil {
return errors.New("The contentItems and carouselContentID props need to be set!")
}
err := c.Render()
if err != nil {
return err
}
在这一时刻我们渲染cog
,因为网页上需要存在 HTML 标记才能使cog
正常工作。值得注意的是,包含轮播内容的div
容器,我们使用必需的carouselContentID
属性提供其id
。如果渲染cog
时出现错误,我们返回错误以表示无法启动cog
。如果在渲染cog
时没有遇到错误,我们继续实例化输入参数对象:
params := &CarouselParams{Object: js.Global.Get("Object").New()}
这个struct
代表了我们将在实例化时提供给 tiny-slider 对象的输入参数。
接下来的代码部分很重要,因为这是我们定义默认参数的地方:
// Set the default parameter values
params.Items = 1
params.SlideBy = "page"
params.Autoplay = true
params.AutoplayText = []string{PLAYTEXT, STOPTEXT}
params.Controls = false
当齿轮维护者查看这一段代码时,他们可以很容易地确定齿轮的默认行为。通过查看默认参数,可以知道滑块一次只会显示一个项目。滑块设置为按页模式滑动,并且滑块将自动开始幻灯片放映。我们为AutoplayText
属性提供了一个字符串切片,使用PLAYTEXT
和STOPTEXT
常量分别表示播放和停止按钮的文本符号。我们将Controls
属性设置为false
,这样默认情况下图像轮播中将不会出现上一个和下一个按钮。
我们继续迭代cog
的用户提供的所有属性,访问每个属性,包括propName
(string
)和propValue
(interface{}
):
for propName, propValue := range c.Props {
我们在propName
上声明了一个switch
块:
switch propName {
case "carouselContentID":
if propValue != nil {
params.Container = "#" + c.Props["carouselContentID"].(string)
}
case "contentItems":
// Do nothing
case "items":
if propValue != nil {
params.Items = propValue.(int)
}
case "slideBy":
if propValue != nil {
params.SlideBy = c.Props["slideBy"].(string)
}
case "autoplay":
if propValue != nil {
params.Autoplay = c.Props["autoplay"].(bool)
}
case "autoplayText":
if propValue != nil {
params.AutoplayText = c.Props["autoplayText"].([]string)
}
case "controls":
if propValue != nil {
params.Controls = c.Props["controls"].(bool)
}
default:
println("Warning: Unknown prop name provided: ", propName)
}
}
使用switch
块可以轻松看到每个case
语句中所有有效属性的名称。如果属性名称未知,则会进入default
情况,在那里我们会在 Web 控制台中打印警告消息。
第一个case
处理了必需的"carouselContentID"
属性。它用于指定将包含轮播内容项目的div
容器。
第二个case
处理了必需的"contentItems"
属性。这个属性是一个string
切片,用于在 cog 的模板中使用,因此我们不需要执行任何操作。
第三个case
处理了"items"
属性。这是处理 tns-slider 对象的items
参数的属性,它显示在同一时间显示的幻灯片数量。如果属性值不是nil
,我们将属性值的int
值分配给params.Items
属性。
第四个case
处理了slideBy
属性。如果属性值不是nil
,我们将属性值(断言为string
类型)分配给params
对象的SlideBy
属性。
第五个case
处理了"autoplay"
属性。如果属性值不是nil
,我们将属性值(断言为bool
类型)分配给params
对象的Autoplay
属性。
第六个case
处理了"autoplayText"
属性。如果属性值不是nil
,我们将属性值(断言为[]string
类型)分配给params
对象的AutoplayText
属性。
第七个case
处理了"controls"
属性。如果属性值不是nil
,我们将属性值(断言为bool
类型)分配给params
对象的Controls
属性。
如果属性名称不属于前面七个情况之一,它将由default case
处理。请记住,如果我们到达这个case
,这表示cog
的用户提供了一个未知的属性名称。
现在我们可以实例化 tiny-slider 小部件并将其分配给齿轮的carousel
属性:
c.carousel = JS.Get("tns").New(params)
Start
方法返回nil
值,表示启动cog
时没有遇到错误:
return nil
shared/cogs/carousel/templates/carousel.tmpl
源文件定义了 carousel cog
的模板:
<div id="{{.carouselContentID}}" class="carousel">
{{range .contentItems}}
<div><img src="img/strong>"></div>
{{end}}
</div>
我们声明一个div
容器来存放轮播图像。contentItems
中的每个项目都是到图像的服务器相对路径。我们使用range
模板操作来迭代contentItems
属性(一个string
切片),以打印出每个图像的地址,这些地址位于自己的div
容器内。请注意,我们将点(.
)模板操作作为img
元素的src
属性的值。点模板操作表示在迭代contentItems
切片时的当前值。
现在我们已经实现了轮播cog
并创建了其模板,是时候在主页上实例化和启动cog
了。我们将添加轮播cog
的代码到client/handlers/index.go
源文件的InitializeIndexPage
函数的开头。
c := carousel.NewCarousel()
c.CogInit(env.TemplateSet)
c.SetID("carousel")
contentItems := []string{"/static/images/products/watch.jpg", "/static/images/products/shirt.jpg", "/static/images/products/coffeemug.jpg"}
c.SetProp("contentItems", contentItems)
c.SetProp("carouselContentID", "gophersContent")
err := c.Start()
if err != nil {
println("Encountered the following error when attempting to start the carousel cog: ", err)
}
我们首先通过调用构造函数NewCarousel
创建一个新的轮播cog
,c
。我们调用CogInit
方法将应用程序的模板集与cog
关联起来。我们调用SetID
方法将cog
与其挂载点关联起来,即div
容器,cog
将在其中呈现其输出。我们使用string
切片文字将路径设置为图像文件的路径。我们调用SetProp
方法设置所需的contentItems
和所需的carouselContent
属性。我们不设置任何其他属性,因为我们对轮播cog
的默认行为感到满意。我们启动cog
并检查是否在此过程中遇到任何错误。如果遇到任何错误,我们将在 Web 控制台中打印错误消息。
图 9.12是渲染的轮播cog
的屏幕截图:
图 9.12:轮播cog
的运行情况
现在我们已经完成了轮播cog
,接下来我们将在下一节中创建一个通知cog
,用于在网页上显示动画通知消息。
通知cog
到目前为止,我们考虑的所有cog
实现都已将输出呈现到网页上。让我们考虑实现一个不将任何输出呈现到网页上的cog
。我们将要实现的通知cog
将利用 Alertify JavaScript 库在网页上显示动画通知消息。
图 9.13是一个插图,描述了当用户将商品添加到购物车时,出现在网页右下角的通知消息:
图 9.13:插图描述了一个通知
由于cog
将完全依赖 JavaScript 库进行渲染,因此我们不必为cog
实现模板,也不必为cog
声明挂载点。
我们将利用 Alertify JavaScript 库的功能来显示通知。以下是 Alertify 项目的 URL:github.com/MohammadYounes/AlertifyJS
。
查看shared/cogs/notify
文件夹,注意没有模板文件夹存在。我们已将 Alertify 的 CSS 和 JavaScript 源文件的静态资产放在shared/cogs/notify/static/css
和shared/cogs/notify/static/js
文件夹中。
通知cog
实现在shared/cogs/notify
文件夹中的notify.go
源文件中。由于对于客户端 Web 应用程序来说只有一个通知系统是有意义的,即由通知cog
提供的通知系统,因此只能启动一个cog
实例。为了跟踪并确保只能启动一个通知cog
实例,我们将声明alreadyStarted
布尔变量:
var alreadyStarted bool
Notify
结构定义了通知cog
的字段:
type Notify struct {
cog.UXCog
alertify *js.Object
successNotificationEventListener func(*js.Object)
errorNotificationEventListener func(*js.Object)
}
我们在这里输入cog.UXCog
以便带入实现Cog
接口所需的功能。alertify
字段用于引用alertify
JavaScript 对象。
我们正在构建的通知cog
是事件驱动的。例如,当从客户端应用程序的任何页面触发自定义成功通知事件时,将显示成功通知。我们定义了两个字段,successNotificationEventListener
和errorNotificationEventListener
,它们都是函数,以 JavaScript 对象指针作为输入变量。我们定义了这些字段,以便我们可以跟踪设置用于监听成功和错误通知的自定义事件监听器函数。当需要移除事件监听器时,因为它们是通知cog
实例的属性,所以很容易访问它们。
NewNotify
函数充当构造函数:
func NewNotify() *Notify {
n := &Notify{}
n.SetCogType(cogType)
n.SetCleanupFunc(n.Cleanup)
return n
}
请注意,我们已注册了一个清理函数(以粗体显示),该函数将在销毁cog
之前调用。
让我们来看一下Start
方法:
func (n *Notify) Start() error {
if alreadyStarted == true {
return errors.New("The notification cog can be instantiated only once.")
}
我们首先检查alreadyStarted
布尔变量的值,以查看是否已经启动了通知cog
实例。如果alreadyStarted
的值为true
,则表示先前已经启动了通知cog
实例,因此我们返回一个指示无法启动通知cog
的error
。
如果cog
尚未启动,我们继续实例化 Alertify JavaScript 对象:
n.alertify = js.Global.Get("alertify")
我们调用StartListening
方法来设置监听自定义成功和错误通知消息事件的事件监听器:
n.StartListening()
return nil
这是StartListening
方法:
func (n *Notify) StartListening() {
alreadyStarted = true
D := dom.GetWindow()
n.successNotificationEventListener = D.AddEventListener("displaySuccessNotification", false, func(event dom.Event) {
message := event.Underlying().Get("detail").String()
n.notifySuccess(message)
})
n.errorNotificationEventListener = D.AddEventListener("displayErrorNotification", false, func(event dom.Event) {
message := event.Underlying().Get("detail").String()
n.notifyError(message)
})
}
如果我们已经到达这个方法,这表明cog
已经成功启动,所以我们将alreadyStarted
布尔变量设置为true
。我们设置一个事件监听器,用于监听displaySuccessNotification
自定义事件。我们通过将其赋值给cog
实例的successNotificationEventListener
属性来跟踪我们正在创建的事件监听器函数。我们声明并实例化message
变量,并将其设置为event
对象的detail
属性,该属性将包含应在网页上显示给用户的string
message
。然后我们调用cog
的notifySuccess
方法来在网页上显示成功通知消息。
我们遵循类似的程序来设置displayErrorNotification
的事件监听器。我们将事件监听器函数分配给cog
的errorNotificationEventListener
属性。我们从event
对象中提取detail
属性,并将其分配给message
变量。我们调用cog
的notifyError
方法来在网页上显示错误通知消息。
notifySuccess
方法负责在网页上显示成功通知消息:
func (n *Notify) notifySuccess(message string) {
n.alertify.Call("success", message)
}
我们调用 alertify 对象的success
方法来显示成功通知消息。
notifyError
方法负责在网页上显示错误通知消息:
func (n *Notify) notifyError(message string) {
n.alertify.Call("error", message)
}
我们调用 alertify 对象的error
方法来显示错误通知消息。
CleanUp
方法只是调用StopListening
方法:
func (n *Notify) Cleanup() {
n.StopListening()
}
StopListening
方法用于在销毁cog
之前移除事件监听器:
func (n *Notify) StopListening() {
D := dom.GetWindow()
if n.successNotificationEventListener != nil {
D.RemoveEventListener("displaySuccessNotification", false, n.successNotificationEventListener)
}
if n.errorNotificationEventListener != nil {
D.RemoveEventListener("displayErrorNotification", false, n.errorNotificationEventListener)
}
}
我们调用 DOM 对象的RemoveEventListener
方法来移除处理displaySuccessNotification
和displayErrorNotification
自定义事件的事件监听函数。
notify
包的导出Success
函数用于广播自定义成功事件通知消息:
func Success(message string) {
var eventDetail = js.Global.Get("Object").New()
eventDetail.Set("detail", message)
customEvent := js.Global.Get("window").Get("CustomEvent").New("displaySuccessNotification", eventDetail)
js.Global.Get("window").Call("dispatchEvent", customEvent)
}
在函数内部,我们创建了一个名为eventDetail
的新 JavaScript 对象。我们将应该在网页上显示的string
message
分配给eventDetail
对象的detail
属性。然后,我们创建了一个名为customEvent
的新自定义event
对象。我们将自定义事件的名称displaySuccessNotification
以及eventDetail
对象作为输入参数传递给CustomEvent
类型的构造函数。最后,为了分发事件,我们在window
对象上调用dispatchEvent
方法,并提供customEvent
。
notify 包的导出Error
函数用于广播自定义错误事件通知消息:
func Error(message string) {
var eventDetail = js.Global.Get("Object").New()
eventDetail.Set("detail", message)
customEvent := js.Global.Get("window").Get("CustomEvent").New("displayErrorNotification", eventDetail)
js.Global.Get("window").Call("dispatchEvent", customEvent)
}
这个函数的实现几乎与Success
函数完全相同。唯一的区别是我们分发了一个displayErrorNotification
自定义事件。
我们在client/handlers/initpagelayoutcontrols.go
源文件中的InitializePageLayoutControls
函数中实例化和启动通知cog
(以粗体显示):
func InitializePageLayoutControls(env *common.Env) {
n := notify.NewNotify()
err := n.Start()
if err != nil {
println("Error encountered when attempting to start the notify cog: ", err)
}
liveChatIcon := env.Document.GetElementByID("liveChatIcon").(*dom.HTMLImageElement)
liveChatIcon.AddEventListener("click", false, func(event dom.Event) {
chatbox := env.Document.GetElementByID("chatbox")
if chatbox != nil {
return
}
go chat.StartLiveChat(env)
})
}
将添加商品到购物车的通知消息(成功或错误)放在client/handlers/shoppingcart.go
源文件中的addToCart
函数中:
func addToCart(productSKU string) {
m := make(map[string]string)
m["productSKU"] = productSKU
jsonData, _ := json.Marshal(m)
data, err := xhr.Send("PUT", "/restapi/add-item-to-cart", jsonData)
if err != nil {
println("Encountered error: ", err)
notify.Error("Failed to add item to cart!")
return
}
var products []*models.Product
json.NewDecoder(strings.NewReader(string(data))).Decode(&products)
notify.Success("Item added to cart")
}
如果商品无法添加到购物车,则调用notify.Error
函数(以粗体显示)。如果商品成功添加到购物车,则调用notify.Success
函数(以粗体显示)。
从client/handlers/shoppingcart.go
源文件中的removeFromCart
函数中找到从购物车中移除商品的通知消息:
func removeFromCart(env *common.Env, productSKU string) {
m := make(map[string]string)
m["productSKU"] = productSKU
jsonData, _ := json.Marshal(m)
data, err := xhr.Send("DELETE", "/restapi/remove-item-from-cart", jsonData)
if err != nil {
println("Encountered error: ", err)
notify.Error("Failed to remove item from cart!")
return
}
var products []*models.Product
json.NewDecoder(strings.NewReader(string(data))).Decode(&products)
renderShoppingCartItems(env)
notify.Success("Item removed from cart")
}
如果商品无法从购物车中移除,则调用notify.Error
函数(以粗体显示)。如果商品成功从购物车中移除,则调用notify.Success
函数(以粗体显示)。
图 9.14是通知 cog 在操作时的裁剪截图,当我们向购物车中添加产品时:
图 9.14:通知 cog 在操作中
摘要
在本章中,我们介绍了 cogs——可重复使用的组件,可以纯粹使用 Go(纯 cogs)实现,也可以使用 Go 和 JavaScript(混合 cogs)实现。Cogs 带来了许多好处。我们可以以即插即用的方式使用它们,创建它们的多个实例,由于它们的自包含性质,可以轻松地维护它们,并且可以轻松地重用它们,因为它们可以作为自己的 Go 包以及它们所需的静态资产(模板文件、CSS 和 JavaScript 源文件)存在。
我们向您介绍了 UX 工具包,它为我们提供了实现 cogs 的技术。我们研究了 cog 的解剖结构,并探讨了关于 Go、CSS、JavaScript 和模板文件放置的 cog 文件结构可能是什么样子。我们考虑了 cogs 如何利用虚拟 DOM 来呈现其内容,而不是执行昂贵的替换内部 HTML 操作。我们介绍了 cog 生命周期的各个阶段。我们向您展示了如何在 IGWEB 中实现各种 cogs,其中包括纯 cogs 和混合 cogs。
在第十章中,测试同构 Go Web 应用程序,我们将学习如何对 IGWEB 进行自动化的端到端测试。这将包括实现测试来在服务器端和客户端上执行功能。
第十章:测试同构 Go Web 应用
通过在上一章中对网站进行可重用组件(齿轮)的点缀,我们已经达到了一个项目里程碑——我们完成了第二章《同构 Go 工具链》中规划的 IGWEB 功能集。然而,我们还不能立即启动 IGWEB。在启动之前,我们必须通过验证它是否满足一定的基本功能要求来确保同构 Web 应用的质量。为此,我们必须实施端到端测试,跨环境(服务器端和客户端)测试同构 Web 应用的功能。
在本章中,您将学习如何为 IGWEB 提供端到端的测试覆盖。我们将使用 Go 的内置测试框架测试服务器端功能,并使用 CasperJS 测试客户端功能。通过实施一套端到端测试,我们不仅可以进行自动化测试,而且在编写的每个测试中还有一个有价值的项目工件,因为每个测试都传达了同构 Web 应用中预期功能的意图。到本章结束时,我们将创建一个端到端测试套件,为稳固的测试策略奠定基础,读者可以进一步构建。
在本章中,我们将涵盖以下主题:
-
使用 Go 的测试框架测试服务器端功能
-
使用 CasperJS 测试客户端功能
测试服务器端功能
正如我们在第一章《使用 Go 构建同构 Web 应用》中所学到的,同构 Web 应用架构利用了经典的 Web 应用架构,这意味着 Web 页面响应将在服务器端呈现。这意味着 Web 客户端无需启用 JavaScript 即可消费从服务器响应接收到的内容。这对于机器用户(如搜索引擎爬虫)尤为重要,他们需要爬行网站上找到的各种链接并对其进行索引。通常情况下,搜索引擎蜘蛛是不启用 JavaScript 的。这意味着我们必须确保服务器端路由正常运行,并且 Web 页面响应也正确呈现。
除此之外,我们在第七章《同构 Web 表单》中,付出了很大的努力,创建了一个可访问的、同构的 Web 表单,可以被有更高辅助功能需求的用户访问。我们需要确保联系表单的验证功能正常运行,并且我们可以成功发送有效的联系表单提交。
因此,在服务器端,我们将测试的基本功能包括以下项目:
-
验证服务器端路由和模板呈现
-
验证联系表单的验证功能
-
验证成功的联系表单提交
Go 的测试框架
我们将使用 Go 的内置测试框架编写一组测试,测试 IGWEB 的服务器端功能。所有服务器端测试都存储在tests
文件夹中。
如果您对 Go 内置的测试框架还不熟悉,可以通过此链接了解更多:golang.org/pkg/testing/
。
在运行go test
命令执行所有测试之前,您必须启动 Redis 服务器实例和 IGWEB(最好分别在它们自己的专用终端窗口或选项卡中)。
您可以使用以下命令启动 Redis 服务器实例:
$ redis-server
您可以使用以下命令在$IGWEB_APP_ROOT
文件夹中启动 IGWEB 实例:
$ go run igweb.go
要运行套件中的所有测试,我们只需在tests
文件夹中运行go test
命令:
$ go test
验证服务器端路由和模板呈现
我们创建了一个测试来验证 IGWEB 应用程序的所有服务器端路由。我们测试的每个路由都将与一个预期的字符串令牌相关联,该令牌在页面响应中呈现,特别是在主要内容div
容器中。因此,我们不仅能够验证服务器端路由是否正常运行,还能知道服务器端模板呈现是否正常运行。
以下是在tests
文件夹中找到的routes_test.go
源文件的内容:
package tests
import (
"io/ioutil"
"net/http"
"strings"
"testing"
)
func checkRoute(t *testing.T, route string, expectedToken string) {
testURL := testHost + route
response, err := http.Get(testURL)
if err != nil {
t.Errorf("Could not connect to URL: %s. Failed with error: %s",
testURL, err)
} else {
defer response.Body.Close()
contents, err := ioutil.ReadAll(response.Body)
if err != nil {
t.Errorf("Could not read response body. Failed with error: %s",
err)
}
if strings.Contains(string(contents), expectedToken) == false {
t.Errorf("Could not find expected string token: \"%s\", in
response body for URL: %s", expectedToken, testURL)
}
}
}
func TestServerSideRoutes(t *testing.T) {
routesTokenMap := map[string]string{"": "IGWEB", "/": "IGWEB",
"/index": "IGWEB", "/products": "Add To Cart", "/product-
detail/swiss-army-knife": "Swiss Army Knife", "/about": "Molly",
"/contact": "Enter your message for us here"}
for route, expectedString := range routesTokenMap {
checkRoute(t, route, expectedString)
}
}
我们定义的testHost
变量用于指定运行 IGWEB 实例的主机名和端口。
TestServerSideRoutes
函数负责测试服务器端路由,并验证预期的令牌字符串是否存在于响应正文中。在函数内部,我们声明并初始化了routesTokenMap
变量,类型为map[string]string
。此map
中的键表示我们正在测试的服务器端路由,给定键的值表示应该存在于从服务器返回的网页响应中的预期string
令牌。因此,这个测试不仅会告诉我们服务器端路由是否正常运行,还会让我们对模板呈现的健康状况有一个很好的了解,因为我们提供的预期string
令牌都是应该在网页正文中找到的字符串。然后,我们通过routesTokenMap
进行range
,对于每次迭代,我们将route
和expectedString
传递给checkRoute
函数。
checkRoute
函数负责访问给定路由,读取其响应正文并验证expectedString
是否存在于响应正文中。有三种情况可能导致测试失败:
-
当无法连接到路由 URL 时
-
如果无法读取从服务器检索到的响应正文
-
如果从服务器返回的网页响应中不存在预期的字符串令牌
如果发生这三种错误中的任何一种,测试将失败。否则函数将正常返回。
我们可以通过发出以下go test
命令来运行此测试:
$ go test -run TestServerSideRoutes
检查运行测试的输出显示测试已通过:
$ go test -run TestServerSideRoutes
PASS
ok github.com/EngineerKamesh/igb/igweb/tests 0.014s
我们现在已成功验证了访问服务器端路由并确保每个路由中的预期字符串在网页响应中正确呈现。现在,让我们开始验证联系表单功能,从表单验证功能开始。
验证联系表单的验证功能
我们将要实现的下一个测试将测试联系表单的服务器端表单验证功能。我们将测试两种类型的验证:
-
当未填写必填表单字段时显示的错误消息
-
当在电子邮件字段中提供格式不正确的电子邮件地址值时显示的错误消息
以下是在tests
文件夹中找到的contactvalidation_test.go
源文件的内容:
package tests
import (
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
)
func TestContactFormValidation(t *testing.T) {
testURL := testHost + "/contact"
expectedTokenMap := map[string]string{"firstName": "The first name
field is required.", "/": "The last name field is required.",
"email": "The e-mail address entered has an improper syntax.",
"messageBody": "The message area must be filled."}
form := url.Values{}
form.Add("firstName", "")
form.Add("lastName", "")
form.Add("email", "devnull@g@o")
form.Add("messageBody", "")
req, err := http.NewRequest("POST", testURL,
strings.NewReader(form.Encode()))
if err != nil {
t.Errorf("Failed to create new POST request to URL: %s, with error:
%s", testURL, err)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
hc := http.Client{}
response, err := hc.Do(req)
if err != nil {
t.Errorf("Failed to make POST request to URL: %s, with error: %s",
testURL, err)
}
defer response.Body.Close()
contents, err := ioutil.ReadAll(response.Body)
if err != nil {
t.Errorf("Failed to read response body contents with error: %s",
err)
}
for k, v := range expectedTokenMap {
if strings.Contains(string(contents), v) == false {
t.Errorf("Could not find expected string token: \"%s\" for field
\"%s\"", v, k)
}
}
}
TestContactFormValidation
函数负责测试联系表单的服务器端表单验证功能。我们声明并初始化了testURL
变量,该变量是 IGWEB 联系部分的 URL。
我们声明并初始化了expectedTokenMap
变量,类型为map[string]string
,其中map
中的键是表单字段的名称,每个键的值表示在提交表单时应返回的预期错误消息。
我们创建一个新表单,并使用表单对象的Add
方法填充表单字段值。请注意,我们为firstName
、lastName
和messageBody
字段提供了空的string
值。我们还为email
字段提供了格式不正确的电子邮件地址。
我们使用http
包中找到的NewRequest
函数使用 HTTP POST 请求提交表单。
我们创建一个http.Client
,hc
,并通过调用它的Do
方法提交 POST 请求。我们使用ioutil
包中的ReadAll
函数获取响应正文的内容。我们通过expectedTokenMap
进行range
,在每次迭代中,我们检查响应正文中是否包含预期的错误消息。
这些是可能导致此测试失败的四种可能条件:
-
如果无法创建 POST 请求
-
如果由于与 Web 服务器的连接问题而导致 POST 请求失败
-
如果网页客户端无法读取从 Web 服务器返回的网页响应的响应正文
-
如果在网页正文中找不到预期的错误消息
如果遇到任何这些错误中的一个,这个测试将失败。
我们可以通过发出以下命令来运行这个测试:
$ go test -run TestContactFormValidation
运行测试的输出显示测试已经通过:
$ go test -run TestContactFormValidation
PASS
ok github.com/EngineerKamesh/igb/igweb/tests 0.009s
验证成功的联系表单提交
我们将要实现的下一个测试将测试成功的联系表单提交。这个测试将与上一个测试非常相似,唯一的区别是我们将填写所有表单字段,并在email
表单字段中提供一个格式正确的电子邮件地址。
以下是tests
文件夹中contact_test.go
源文件的内容:
package tests
import (
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
)
func TestContactForm(t *testing.T) {
testURL := testHost + "/contact"
expectedTokenString := "The contact form has been successfully
completed."
form := url.Values{}
form.Add("firstName", "Isomorphic")
form.Add("lastName", "Gopher")
form.Add("email", "devnull@test.com")
form.Add("messageBody", "This is a message sent from the automated
contact form test.")
req, err := http.NewRequest("POST", testURL,
strings.NewReader(form.Encode()))
if err != nil {
t.Errorf("Failed to create new POST request to URL: %s, with error:
%s", testURL, err)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
hc := http.Client{}
response, err := hc.Do(req)
if err != nil {
t.Errorf("Failed to make POST request to URL: %s, with error: %s",
testURL, err)
}
defer response.Body.Close()
contents, err := ioutil.ReadAll(response.Body)
if err != nil {
t.Errorf("Failed to read response body contents with error: %s",
err)
}
if strings.Contains(string(contents), expectedTokenString) == false {
t.Errorf("Could not find expected string token: \"%s\"",
expectedTokenString)
}
}
再次,这个测试与我们之前实现的测试非常相似,只是我们填充了所有表单字段并提供了一个格式正确的电子邮件地址。我们声明并初始化expectedTokenString
变量,以确认我们期望在成功提交表单后在响应正文中打印出的确认字符串。函数的最后一个if
条件块检查响应正文是否包含expectedTokenString
。如果没有,那么测试将失败。
这些是可能导致此测试失败的四种可能条件:
-
如果无法创建 POST 请求
-
如果由于与 Web 服务器的连接问题而导致 POST 请求失败
-
如果网页客户端无法读取从 Web 服务器返回的网页响应的响应正文
-
如果在网页正文中找不到预期的确认消息
同样,如果遇到任何这些错误中的一个,这个测试将失败。
我们可以通过发出以下命令来运行测试:
$ go test - run TestContactForm
通过检查运行测试后的输出,我们可以看到测试已经通过:
$ go test - run TestContactForm
PASS
ok github.com/EngineerKamesh/igb/igweb/tests 0.012s
您可以通过在tests
目录中简单地发出go test
命令来运行测试套件中的所有测试:
$ go test
PASS
ok github.com/EngineerKamesh/igb/igweb/tests 0.011s
到目前为止,我们已经编写了测试来覆盖测试服务器端 Web 应用程序的基线功能集。现在,是时候专注于测试客户端应用程序了。
测试客户端功能
正如第一章中所述,《使用 Go 构建同构 Web 应用程序》,在初始页面加载后,网站上的后续导航使用单页面应用程序架构提供。这意味着会发起 XHR 调用到 Rest API 端点,以提供渲染内容所需的数据,这些内容将显示在网页上。例如,当客户端处理程序显示产品列表页面时,会利用 Rest API 端点来获取要显示的产品列表。在某些情况下,甚至不需要 Rest API 端点,因为页面内容只需要渲染模板。一个这样的例子是当用户通过点击导航栏中的联系链接访问联系表单时。在这种情况下,我们只需渲染联系表单模板,并在主要内容区域显示内容。
让我们花点时间思考一下我们需要在客户端测试的所有基本功能。我们需要验证客户端路由是否正常运行,并且对于每个路由都会呈现正确的页面,类似于我们在上一节中验证服务器端路由的方式。除此之外,我们还需要确认客户端表单验证对联系表单是否有效,并测试有效表单提交的情况。目前,添加和移除购物车中物品的功能仅在客户端实现。这意味着我们必须编写测试来验证此功能是否按预期工作。目前仅在客户端可用的另一个功能是实时聊天功能。我们必须验证用户能否与实时聊天机器人进行通信,机器人是否回复,并且在用户导航到网站的不同部分时,对话是否保持。
最后,我们必须测试我们的齿轮集合。我们必须确保时间齿轮以人类可理解的格式显示时间实例。我们必须验证实时时钟齿轮是否正常运行。我们必须验证当点击时间敏感日期字段时,日期选择器齿轮是否出现。我们必须验证主页上是否出现了轮播齿轮。最后,我们必须验证当向购物车中添加和移除物品时,通知齿轮是否正确显示通知。
因此,在客户端,我们将测试的基线功能包括以下项目:
-
验证客户端路由和模板呈现
-
验证联系表单
-
验证购物车功能
-
验证实时聊天功能
-
验证时间齿轮
-
验证实时时钟齿轮
-
验证日期选择器齿轮
-
验证轮播齿轮
-
验证通知齿轮
为了在客户端执行自动化测试,包括用户交互,我们需要一个内置 JavaScript 运行时的工具。因此,在测试客户端功能时,我们不能使用go test
。
我们将使用 CasperJS 在客户端执行自动化测试。
CasperJS
CasperJS 是一个自动化测试工具,它建立在 PhantomJS 之上,后者是用于自动化用户交互的无头浏览器。CasperJS 允许我们使用断言编写测试,并组织测试,以便它们可以按顺序一起运行。测试运行后,我们可以收到有关通过的测试数量与失败的测试数量的摘要。除此之外,CasperJS 可以利用 PhantomJS 内部的功能,在进行测试时获取网页截图。这使人类用户可以视觉评估测试运行。
为了安装 CasperJS,我们必须先安装 NodeJS 和 PhantomJS。
您可以通过从此链接下载适用于您操作系统的 NodeJS 安装程序来安装 NodeJS:nodejs.org/en/download/
。
安装 NodeJS 后,您可以通过发出以下命令来安装 PhantomJS:
$ npm install -g phantomjs
您可以通过发出以下命令来查看系统上安装的 PhantomJS 版本号,以验证phantomjs
是否已正确安装:
$ phantomjs --version
2.1.1
一旦您验证了系统上安装了 PhantomJS,您可以发出以下命令来安装 CasperJS:
$ npm install -g casperjs
要验证casperjs
是否已正确安装,您可以发出以下命令来查看系统上安装的 CasperJS 版本号:
$ casperjs --version
1.1.4
我们的客户端 CasperJS 测试将存放在client/tests
目录中。请注意client/tests
文件夹内的子文件夹:
⁃ tests
⁃ go
⁃ js
⁃ screenshots
我们将在 Go 中编写所有的 CasperJS 测试,并将它们放在go
文件夹中。我们将使用scripts
目录中的build_casper_tests.sh
bash 脚本来将在 Go 中实现的 CasperJS 测试转换为它们相应的 JavaScript 表示。生成的 JavaScript 源文件将放在js
文件夹中。我们将创建许多测试,这些测试将生成正在进行的测试运行的屏幕截图,并且这些屏幕截图图像将存储在screenshots
文件夹中。
你应该运行以下命令,使build_casper_tests.sh
bash 脚本可执行:
$ chmod +x $IGWEB_APP_ROOT/scripts/build_casper_tests.sh
每当我们在 Go 中编写 CasperJS 测试或对其进行更改时,都必须执行build_casper_tests.sh
bash 脚本。
$ $IGWEB_APP_ROOT/scripts/build_casper_tests.sh
在开始编写 CasperJS 测试之前,让我们看一下client/tests/go/caspertest
目录中的caspertest.go
源文件:
package caspertest
import "github.com/gopherjs/gopherjs/js"
type ViewportParams struct {
*js.Object
Width int `js:"width"`
Height int `js:"height"`
}
ViewportParams
结构将用于定义 Web 浏览器的视口尺寸。我们将使用 1440×960 的尺寸来模拟所有客户端测试的桌面浏览体验。设置视口尺寸的影响可以通过运行生成一个或多个屏幕截图的 CasperJS 测试后立即查看到。
现在,让我们开始使用 CasperJS 编写客户端测试。
验证客户端路由和模板渲染
我们在 Go 中实现的用于测试客户端路由的 CasperJS 测试可以在client/tests/go
目录中的routes_test.go
源文件中找到。
在导入分组中,请注意我们包含了caspertestjs
包,其中我们定义了ViewportParams
struct
,并且我们包含了js
包:
package main
import (
"strings"
"github.com/EngineerKamesh/igb/igweb/client/tests/go/caspertest"
"github.com/gopherjs/gopherjs/js"
)
我们将广泛使用js
包中的功能来利用 CasperJS 功能,因为目前尚无 GopherJS 绑定可用于 CasperJS。
我们将定义一个名为wait
的 JavaScript 函数,它负责等待,直到远程 DOM 中的主要内容div
容器加载完成:
var wait = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
this.Call("waitForSelector", "#primaryContent")
return nil
})
我们声明并初始化casper
变量为casper
实例,这是一个 JavaScript 对象,在执行 CasperJS 时已经在远程 DOM 中填充:
var casper = js.Global.Get("casper")
我们在main
函数中实现了客户端路由测试。我们首先声明了一个routesTokenMap
(类似于我们在服务器端路由测试中所做的),类型为map[string]string
:
func main() {
routesTokenMap := map[string]string{"/": "IGWEB", "/index": "IGWEB",
"/products": "Add To Cart", "/product-detail/swiss-army-knife":
"Swiss Army Knife", "/about": "Molly", "/contact": "Contact",
"/shopping-cart": "Shopping Cart"}
键表示客户端路由,给定键的值表示在访问给定客户端路由时应在网页上呈现的预期字符串标记。
使用以下代码,我们设置了 Web 浏览器的视口大小:
viewportParams := &caspertest.ViewportParams{Object: js.Global.Get("Object").New()}
viewportParams.Width = 1440
viewportParams.Height = 960
casper.Get("options").Set("viewportSize", viewportParams)
请注意,PhantomJS 使用默认视口为 400×300。由于我们将模拟桌面浏览体验,因此我们必须覆盖此值。
在编写测试时,我们将使用 CasperJS 的tester
模块。Tester
类提供了一个 API,用于单元测试和功能测试,并且可以通过casper
实例的test
属性访问。tester
模块的完整文档可在此链接找到:docs.casperjs.org/en/latest/modules/tester.html
。
我们调用test
对象的begin
方法来启动一系列计划测试:
casper.Get("test").Call("begin", "Client-Side Routes Test Suite", 7, func(test *js.Object) {
casper.Call("start", "http://localhost:8080", wait)
})
提供给begin
方法的第一个参数是测试套件的描述。我们提供了一个描述为"客户端路由测试套件"
。
第二个参数表示计划测试的数量。在这里,我们指定将进行总共七项测试,因为我们将测试七个客户端路由。如果计划测试的数量与实际执行的测试数量不匹配,那么 CasperJS 将认为这是一个可疑错误,因此始终要确保正确设置计划测试的数量是一个良好的做法。我们将向您展示如何在此示例中计算执行的测试数量。
第三个参数是一个包含将执行的测试套件的 JavaScript 回调函数。请注意,回调函数将test
实例作为输入参数。在此函数内部,我们调用casper
对象的start
方法。这将启动 Casper 并打开方法中指定的 URL。start
方法的第二个输入参数被认为是下一步,一个 JavaScript 回调函数,将在访问 URL 后立即运行。我们指定的下一步是我们之前创建的wait
函数。这将导致访问 IGWEB 主页的 URL,并等待直到远程 DOM 中的主要内容div
容器可用。
此时,我们可以开始我们的测试。我们通过routesTokenMap
中的每个路由和expectedString
进行range
:
for route, expectedString := range routesTokenMap {
func(route, expectedString string) {
我们调用casper
对象的then
方法向堆栈添加一个新的导航步骤:
casper.Call("then", func() {
casper.Call("click", "a[href^='"+route+"']")
})
在代表导航步骤的函数内部,我们调用了casper
对象的click
方法。click
方法将在与提供的 CSS 选择器匹配的元素上触发鼠标点击事件。我们为每个路由创建了一个 CSS 选择器,它将匹配网页正文中的链接。CSS 选择器允许我们模拟用户点击导航链接的情景。
不属于导航链接的两个路由是/
和/product-detail/swiss-army-knife
路由。/
路由的 CSS 选择器将匹配网页左上角标志的链接。当测试这种情况时,相当于用户点击网站标志。在瑞士军刀产品详情页面的链接/product-detail/swiss-army-knife
的情况下,一旦产品页面的内容被渲染,它将在主要内容区域 div 中找到。当测试这种情况时,相当于用户点击产品列表页面上的瑞士军刀图片。
在下一个导航步骤中,我们将生成测试用例的屏幕截图,并检查网页正文中是否找到了expectedString
:
casper.Call("then", func() {
casper.Call("wait", 1800, func() {
routeName := strings.Replace(route, `/`, "", -1)
screenshotName := "route_render_test_" + routeName + ".png"
casper.Call("capture", "screenshots/"+screenshotName)
casper.Get("test").Call("assertTextExists", expectedString,
"Expected text \""+expectedString+"\", in body of web page,
when accessing route: "+route)
})
})
}(route, expectedString)
}
在这里,我们调用casper
对象的capture
方法来提供生成的屏幕截图图像的路径。我们将为我们测试的每个路由生成一个屏幕截图,因此我们将从此测试中生成总共七个屏幕截图图像。
请注意,我们调用 casper 的wait
方法引入了 1800 毫秒的延迟,并提供了一个then
回调函数。在对话式英语中,我们可以解释这个调用为“等待 1800 毫秒,然后执行此操作。”在我们提供的then
回调函数中,我们调用了 casper 的test
对象(tester
模块)上的assertTextExists
方法。在assertTextExists
方法调用中,我们提供了应该存在于网页正文中的expectedString
,第二个参数是描述测试的消息。我们添加了 1800 毫秒的延迟,以便页面内容有足够的时间显示在网页上。
请注意,每当调用casper
的tester
模块中assert
方法系列中的任何一种assert
方法时,都算作一个单独的测试。回想一下,当我们调用测试模块的begin
方法时,我们提供了一个值为7
,表示预计将在此测试套件中进行 7 个预期测试。因此,您在测试中使用的assert
方法调用的数量必须与将进行的预期测试数量相匹配,否则在运行测试套件时将会出现可疑的错误。
我们调用casper
对象的run
方法来运行测试套件:
casper.Call("run", func() {
casper.Get("test").Call("done")
})
请注意,我们向 run 方法提供了一个回调函数。当所有步骤完成运行时,将调用此回调函数。在回调函数内部,我们调用 tester 模块的 done 方法来表示测试套件的结束。请记住,在 CasperJS 测试中,每当我们在 tester 模块上调用 begin 方法时,测试中必须有一个相应的地方调用 tester 模块的 done 方法。如果我们忘记留下对 done 方法的调用,程序将挂起,我们将不得不中断程序(使用 Ctrl + C 按键)。
我们必须将测试转换为其 JavaScript 等效形式,可以通过运行 build_casper_tests.sh bash 脚本来实现:
$ $IGWEB_APP_ROOT/scripts/build_casper_tests.sh
bash 脚本将转换位于 client/tests/go 目录中的 Go 中编写的所有 CasperJS 测试,并将生成的 JavaScript 源文件放在 client/tests/js 目录中。我们将在后续的测试运行中省略此步骤。只需记住,如果对任何测试进行更改,需要重新运行此脚本,以便更改生效,下次运行测试套件时。
我们可以通过发出以下命令来运行测试以检查客户端路由:
$ cd $IGWEB_APP_ROOT/client/tests
$ casperjs test js/routes_test.js
图 10.1显示了运行客户端路由测试套件的屏幕截图:
图 10.1:运行客户端路由测试套件
测试生成的屏幕截图可以在 client/tests/screenshots 文件夹中找到。屏幕截图非常有用,因为它们允许人类用户直观地查看测试结果。
图 10.2显示了测试/路由的屏幕截图:
图 10.2:测试/路由
图 10.3显示了测试/index 路由的屏幕截图。请注意,页面渲染与图 10.2相同,这是应该的:
图 10.3:测试/index 路由
请注意,通过提供 1800 毫秒的延迟时间,我们为轮播齿轮和实时时钟齿轮提供了足够的时间来加载。在本章后面,您将学习如何测试这些齿轮。
图 10.4显示了测试/products 路由的屏幕截图:
图 10.4:测试/products 路由
通过此测试,我们可以直观确认产品列表页面已经成功加载。下一步测试将点击瑞士军刀的图像,以导航到其产品详细信息页面。
图 10.5显示了测试/product-detail/swiss-army-knife 路由的屏幕截图:
图 10.5:测试/product-detail 路由
图 10.6显示了测试/about 路由的屏幕截图:
图 10.6:测试/about 路由
请注意,时间已经为所有三只地鼠正确渲染。
图 10.7显示了测试/contact 路由的屏幕截图:
图 10.7:测试/contact 路由
图 10.8显示了测试/shopping-cart 路由的屏幕截图。
图 10.8:测试/shopping-cart 路由
通过屏幕截图提供的视觉确认,我们现在可以确信客户端路由正在按预期工作。除此之外,生成的屏幕截图帮助我们在视觉上确认模板渲染正常运作。现在让我们来验证联系表单功能。
验证联系表单
我们实施的用于验证联系表单功能的测试可以在 client/tests/go 目录中的 contactform_test.go 源文件中找到。
在此测试中,我们定义了FormParams
结构,该结构表示在进行测试步骤时应填充联系表单的表单参数:
type FormParams struct {
*js.Object
FirstName string `js:"firstName"`
LastName string `js:"lastName"`
Email string `js:"email"`
MessageBody string `js:"messageBody"`
}
我们创建了一个 JavaScript 的wait
函数,以确保测试运行程序在运行其他步骤之前等待主要内容div
容器加载完成:
var wait = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
this.Call("waitForSelector", "#primaryContent")
return nil
})
我们将引入以下三个 JavaScript 函数来填充联系表单的字段,具体取决于我们正在进行的测试类型:
-
fillOutContactFormWithPoorlyFormattedEmailAddress
-
fillOutContactFormPartially
-
filloutContactFormCompletely
fillOutContactFormWithPoorlyFormattedEmailAddress
函数将向email
字段提供一个无效的电子邮件地址,正如其名称所示:
var fillOutContactFormWithPoorlyFormattedEmailAddress = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
params := &FormParams{Object: js.Global.Get("Object").New()}
params.FirstName = "Isomorphic"
params.LastName = "Gopher"
params.Email = "dev@null@test@test.com"
params.MessageBody = "Sending a contact form submission using CasperJS and PhantomJS"
this.Call("fill", "#contactForm", params, true)
return nil
})
请注意,我们创建了一个新的FormParams
实例,并填充了FirstName
、LastName
、Email
和MessageBody
字段。特别注意,我们为Email
字段提供了一个无效的电子邮件地址。
在这个函数的上下文中,this
变量代表tester
模块。我们调用tester
模块的fill
方法,提供联系表单的 CSS 选择器、params
对象,以及一个布尔值true
来指示应该提交表单。
在填写并提交表单后,我们期望客户端表单验证向我们呈现一个错误消息,指示我们提供了一个无效的电子邮件地址。
fillOutContactFormPartially
函数将部分填写联系表单,留下一些必填字段未填写,导致表单不完整。
var fillOutContactFormPartially = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
params := &FormParams{Object: js.Global.Get("Object").New()}
params.FirstName = "Isomorphic"
params.LastName = ""
params.Email = "devnull@test.com"
params.MessageBody = ""
this.Call("fill", "#contactForm", params, true)
return nil
})
在这里,我们创建一个新的FormParams
实例,并注意到我们为LastName
和MessageBody
字段提供了空的string
值。
在填写并提交表单后,我们期望客户端表单验证向我们呈现一个错误消息,指示我们没有填写这两个必填字段。
fillOutContactFormCompletely
函数将填写联系表单的所有字段,并包括一个格式正确的电子邮件地址:
var fillOutContactFormCompletely = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
params := &FormParams{Object: js.Global.Get("Object").New()}
params.FirstName = "Isomorphic"
params.LastName = "Gopher"
params.Email = "devnull@test.com"
params.MessageBody = "Sending a contact form submission using CasperJS and PhantomJS"
this.Call("fill", "#contactForm", params, true)
return nil
})
在这里,我们创建一个新的FormParams
实例,并填充了联系表单的所有字段。在Email
字段的情况下,我们确保提供了一个格式正确的电子邮件地址。
在填写并提交表单后,我们期望客户端表单验证通过,这在后台将启动一个 XHR 调用到 REST API 端点,以验证联系表单已经通过服务器端表单验证正确填写。我们期望服务器端验证也通过,结果是一个确认消息。如果我们能成功验证已获得确认消息,我们的测试将通过。
与前面的例子一样,我们首先声明视口参数,并设置 Web 浏览器的视口大小:
func main() {
viewportParams := &caspertest.ViewportParams{Object:
js.Global.Get("Object").New()}
viewportParams.Width = 1440
viewportParams.Height = 960
casper.Get("options").Set("viewportSize", viewportParams)
请注意,我们调用tester
模块的begin
方法来启动联系表单测试套件中的测试:
casper.Get("test").Call("begin", "Contact Form Test Suite", 4,
func(test *js.Object) {
casper.Call("start", "http://localhost:8080/contact", wait)
})
我们向begin
方法提供了测试的描述,“联系表单测试套件”。然后我们提供了这个套件中预期的测试数量,即4
。请记住,这个值对应于我们进行的测试数量。进行的测试数量可以通过我们对tester
模块的assert
系列方法之一进行调用的次数来确定。我们提供了then
回调函数,在其中我们调用casper
对象的start
方法,提供联系页面的 URL,并提供wait
函数以指示我们应该在进行任何测试步骤之前等待主要内容div
容器加载。
我们测试的第一个场景是在提供格式不正确的电子邮件地址时检查客户端验证:
casper.Call("then",
fillOutContactFormWithPoorlyFormattedEmailAddress)
casper.Call("wait", 450, func() {
casper.Call("capture",
"screenshots/contactform_test_invalid_email_error_message.png")
casper.Get("test").Call("assertSelectorHasText", "#emailError",
"The e-mail address entered has an improper syntax", "Display e-
mail address syntax error when poorly formatted e-mail entered.")
})
我们调用casper
对象的then
方法,提供fillOutContactFormWithPoorlyFormattedEmailAddress
JavaScript 函数作为then
回调函数。我们等待450
毫秒以获取结果,捕获测试运行的截图(显示在图 10.10中),然后在tester
模块上调用assertSelectorHasText
方法,提供了包含错误消息的元素的 CSS 选择器,以及错误消息应该显示的预期文本,然后是我们正在进行的测试的描述。
我们测试的第二个场景是在提交不完整的表单时检查客户端验证:
casper.Call("then", fillOutContactFormPartially)
casper.Call("wait", 450, func() {
casper.Call("capture",
"screenshots/contactform_test_partially_filled_form_errors.png")
casper.Get("test").Call("assertSelectorHasText", "#lastNameError",
"The last name field is required.", "Display error message when the
last name field has not been filled out.")
casper.Get("test").Call("assertSelectorHasText",
"#messageBodyError", "The message area must be filled.", "Display
error message when the message body text area has not been filled
out.")
})
我们调用casper
对象的then
方法,提供fillOutContactFormPartially
JavaScript 函数作为then
回调函数。我们等待450
毫秒以获取结果,捕获测试运行的截图(显示在图 10.11中),并在此场景中进行了两个测试。
在第一个测试中,我们在tester
模块上调用assertSelectorHasText
方法,提供了包含姓氏字段错误消息的元素的 CSS 选择器,以及预期文本,错误消息应该有的,然后是测试的描述。在第二个测试中,我们在tester
模块上调用assertSelectorHasText
方法,提供了包含消息正文文本区域错误消息的元素的 CSS 选择器,错误消息应该有的预期文本,然后是测试的描述。
我们测试的第三个场景是检查在正确填写联系表单后是否显示了确认消息:
casper.Call("then", fillOutContactFormCompletely)
casper.Call("wait", 450, func() {
casper.Call("capture",
"screenshots/contactform_confirmation_message.png")
casper.Get("test").Call("assertSelectorHasText", "#primaryContent
h1", "Confirmation", "Display confirmation message after submitting
contact form.")
})
我们调用casper
对象的then
方法,提供fillOutContactFormCompletely
JavaScript 函数作为then
回调函数。我们等待450
毫秒以获取结果,捕获测试运行的截图(显示在图 10.12中),并调用casper
对象的assertSelectorHasText
方法。我们提供 CSS 选择器"#primaryContent h1"
,因为确认消息将在<h1>
标签内。我们提供确认消息应包含的预期文本,即"Confirmation"
。最后,我们为assertSelectorHasText
方法的最后一个参数提供了测试的描述。
为了表示测试套件的结束,我们调用casper
对象的run
方法,并在then
回调函数内调用 tester 模块的done
方法:
casper.Call("run", func() {
casper.Get("test").Call("done")
})
假设您在client/tests
文件夹中,您可以发出以下命令来运行联系表单测试套件:
$ casperjs test js/contactform_test.js
图 10.9显示了运行联系表单测试套件的截图图像:
图 10.9:运行联系表单测试套件
图 10.10显示了运行第一个测试生成的截图图像,该测试检查客户端端表单验证是否正确检测到格式不正确的电子邮件地址:
图 10.10:测试电子邮件验证语法
图 10.11显示了运行第二个和第三个测试生成的截图图像,该测试检查客户端端表单验证是否正确检测到姓氏字段和消息正文文本区域是否未填写:
图 10.11:验证表单验证是否检测到未填写必填字段的测试
图 10.12显示了运行第四个测试生成的截图图像,该测试检查成功填写并提交联系表单后是否显示了确认消息:
图 10.12:验证确认消息的测试
现在我们已经验证了联系表单的客户端验证功能,让我们来研究为购物车功能实施 CasperJS 测试套件。
验证购物车功能
为了验证购物车功能,我们必须能够多次向购物车中添加产品,检查产品是否以正确的数量显示在购物车中,并且能够从购物车中移除产品。因此,我们需要购物车测试套件中的 3 个预期测试。
位于client/tests/go
目录中的shoppingcart_test.go
源文件中的main
函数实现了购物车测试套件:
func main() {
viewportParams := &caspertest.ViewportParams{Object:
js.Global.Get("Object").New()}
viewportParams.Width = 1440
viewportParams.Height = 960
casper.Get("options").Set("viewportSize", viewportParams)
casper.Get("test").Call("begin", "Shopping Cart Test Suite", 3,
func(test *js.Object) {
casper.Call("start", "http://localhost:8080/products", wait)
})
在main
函数内,我们设置了网页浏览器的视口大小。我们通过在casper
对象上调用begin
方法来启动一个新的测试套件。请注意,我们已经指示在这个测试套件中有 3 个预期测试。在begin
方法的最后一个参数中构成的then
回调函数内,我们调用casper
对象的start
方法,提供产品列表页面的 URL,并提供 JavaScript 的wait
函数作为then
回调函数。这将导致程序在进行任何测试之前等待,直到 DOM 中加载了主要内容div
容器。
通过以下代码,我们向购物车中添加了三把瑞士军刀:
for i := 0; i < 3; i++ {
casper.Call("then", func() {
casper.Call("click", ".addToCartButton:first-child")
})
}
请注意,我们已经通过casper
对象的click
方法传递了 CSS 选择器".addToCartButton:first-child"
,以确保点击瑞士军刀产品,因为它是产品列表页面上显示的第一个产品。
为了验证瑞士军刀是否正确放置在购物车中,我们需要导航到购物车页面:
casper.Call("then", func() {
casper.Call("click", "a[href^='/shopping-cart']")
})
我们的第一个测试包括验证购物车中存在正确的产品类型:
casper.Call("wait", 207, func() {
casper.Get("test").Call("assertTextExists", "Swiss Army Knife", "Display correct product in shopping cart.")
})
我们通过在tester
模块对象上调用assertTextExists
方法并提供预期文本值"Swiss Army Knife"
来检查购物车页面上是否存在"Swiss Army Knife"
文本。
我们的第二个测试包括验证购物车页面上存在正确的产品数量:
casper.Call("wait", 93, func() {
casper.Get("test").Call("assertTextExists", "Quantity: 3", "Display
correct product quantity in shopping cart.")
})
同样,我们调用tester
模块对象的assertTextExists
方法,传入预期文本"Quantity: 3"
。
我们生成了一个购物车的截图,这个截图(显示在图 10.14中)应该显示瑞士军刀的数量值为3
:
casper.Call("wait", 450, func() {
casper.Call("capture", "screenshots/shoppingcart_test_add_item.png")
})
我们的最后一个测试包括从购物车中移除一个项目。我们使用以下代码从购物车中移除产品:
casper.Call("then", func() {
casper.Call("click", ".removeFromCartButton:first-child")
})
为了验证产品是否成功从购物车中移除,我们需要检查购物车页面上是否存在指示购物车为空的消息:
casper.Call("wait", 5004, func() {
casper.Call("capture", "screenshots/shoppingcart_test_empty.png")
casper.Get("test").Call("assertTextExists", "Your shopping cart is
empty.", "Empty the shopping cart.")
})
请注意,在我们对tester
模块对象的assertTextExists
方法进行调用时,我们检查网页上是否存在"Your shopping cart is empty."
文本。在此之前,我们还生成了一个截图(显示在图 10.15中),它将显示购物车处于空状态。
最后,我们将用以下代码表示购物车测试套件的结束:
casper.Call("run", func() {
casper.Get("test").Call("done")
})
我们可以通过发出以下命令来运行购物车测试套件的 CasperJS 测试:
$ casperjs test js/shoppingcart_test.js
图 10.13显示了运行购物车测试套件的结果的截图:
图 10.13:运行购物车测试套件
图 10.14显示了生成的截图,显示了测试用例,其中3
把瑞士军刀已成功添加到购物车中:
图 10.14:将产品多次添加到购物车的测试用例
图 10.15显示了生成的截图,显示了测试用例,其中瑞士军刀产品已被移除,因此购物车为空:
图 10.15:验证清空购物车的测试
现在我们已经验证了购物车的功能,让我们来测试一下实时聊天功能。
验证实时聊天功能
实时聊天测试套件包括三个测试。首先,我们必须确保单击顶部栏上的实时聊天图标时,聊天框会打开。其次,我们必须确保当我们向它提问时,聊天机器人会回应我们。第三,我们必须确保在导航到网站的另一部分时,对话会被保留。
实时聊天测试套件实现在client/tests/go
目录中的livechat_test.go
源文件中。
waitChat
JavaScript 函数将用于等待聊天框打开:
var waitChat = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
this.Call("waitForSelector", "#chatbox")
return nil
})
askQuestion
JavaScript 函数将用于向聊天机器人发送问题:
var askQuestion = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
this.Call("sendKeys", "input#chatboxInputField", "What is Isomorphic
Go?")
this.Call("sendKeys", "input#chatboxInputField",
casper.Get("page").Get("event").Get("key").Get("Enter"))
return nil
})
请注意,我们使用tester
模块对象的sendKeys
方法(this
变量绑定到tester
模块对象)来输入“什么是同构 Go”问题,然后再次调用sendKeys
方法来发送enter
键(相当于在键盘上按下enter
键)。
在main
函数中,我们设置了 Web 浏览器的视口大小并开始测试套件:
func main() {
viewportParams := &caspertest.ViewportParams{Object:
js.Global.Get("Object").New()}
viewportParams.Width = 1440
viewportParams.Height = 960
casper.Get("options").Set("viewportSize", viewportParams)
casper.Get("test").Call("begin", "Live Chat Test Suite", 3, func(test
*js.Object) {
casper.Call("start", "http://localhost:8080/index", wait)
})
以下代码将通过模拟用户单击顶部栏上的实时聊天图标来激活实时聊天功能:
casper.Call("then", func() {
casper.Call("click", "#livechatContainer img")
})
以下代码将等待聊天框打开后再继续:
casper.Call("then", waitChat)
打开聊天框后,我们可以使用以下代码验证聊天框是否可见:
casper.Call("wait", 1800, func() {
casper.Call("capture",
"screenshots/livechat_test_chatbox_open.png")
casper.Get("test").Call("assertSelectorHasText", "#chatboxTitle
span", "Chat with", "Display chatbox.")
})
请注意,我们调用tester
模块对象的assertSelectorHasText
方法,提供 CSS 选择器"#chatboxTitle span"
来定位聊天框的标题span
元素。然后我们检查span
元素内是否存在"Chat with"
文本,以验证聊天框是否可见。
请注意,我们已生成了一个屏幕截图图像,应该显示聊天框已打开,并且聊天机器人提供了问候消息(图 10.17中显示)。
以下代码用于验证当我们向聊天机器人提问时,它是否会给出答案:
casper.Call("then", askQuestion)
casper.Call("wait", 450, func() {
casper.Call("capture",
"screenshots/livechat_test_answer_question.png")
casper.Get("test").Call("assertSelectorHasText",
"#chatboxConversationContainer", "Isomorphic Go is the methodology
to create isomorphic web applications", "Display the answer to
\"What is Isomorphic Go?\"")
})
我们调用askQuestion
函数来模拟用户输入“什么是同构 Go”问题并按下enter
键。我们等待 450 毫秒,然后生成一个屏幕截图,应该显示实时聊天机器人回答我们的问题(图 10.18中显示)。我们通过调用tester
模块对象的assertSelectorHasText
方法并向其提供 CSS 选择器来验证聊天机器人是否已经给出答案,该选择器用于访问包含对话和预期答案子字符串的div
容器。
目前,我们在主页上。为了测试在导航到网站的不同部分时对话是否保留,我们使用以下代码:
casper.Call("then", func() {
casper.Call("click", "a[href^='/about']")
})
casper.Call("then", wait)
在这里,我们指定导航到关于页面,然后等待直到主要内容div
容器加载完成。
我们等待 450 毫秒,拍摄一个屏幕截图(图 10.19中显示),然后进行我们测试套件中的最后一个测试:
casper.Call("wait", 450, func() {
casper.Call("capture",
"screenshots/livechat_test_conversation_retained.png")
casper.Get("test").Call("assertSelectorHasText",
"#chatboxConversationContainer", "Isomorphic Go is the methodology
to create isomorphic web applications", "Verify that the
conversation is retained when navigating to another page in the
website.")
})
这里的最后一个测试是前面进行的测试的重复。由于我们正在测试对话是否已保留,我们期望在上一个测试之后,聊天机器人给出的答案会保留在包含对话的div
容器中。
我们将通过模拟用户点击关闭控件(聊天框右上角的Χ)来关闭聊天框,以便正常关闭 websocket 连接:
casper.Call("then", func() {
casper.Call("click", "#chatboxCloseControl")
})
最后,我们将使用以下代码表示实时聊天测试套件的结束:
casper.Call("run", func() {
casper.Get("test").Call("done")
})
我们可以通过发出以下命令来运行实时聊天测试套件的 CasperJS 测试:
$ casperjs test js/livechat_test.js
图 10.16显示了运行实时聊天测试套件的结果的屏幕截图:
图 10.16:运行实时聊天测试套件
图 10.17显示了生成的屏幕截图,显示了测试用例,我们检查聊天框是否已打开:
图 10.17:验证聊天框是否出现的测试
图 10.18显示了生成的屏幕截图,显示了测试用例,我们在其中检查了聊天机器人是否回答了给定的问题:
图 10.18:验证聊天机器人是否回答问题
图 10.19显示了生成的屏幕截图,显示了测试用例,我们在其中检查了在网站上导航到不同页面后是否保留了聊天对话:
图 10.19:测试在导航到网站的不同部分后是否保留了聊天对话
现在我们已经验证了实时聊天功能,让我们来测试差齿轮,从时间差齿轮开始。
为了简洁起见,图 10.17、10.18、10.19、10.21、10.23、10.25、10.27 和 10.29 中显示的生成的屏幕截图已被裁剪。
验证时间差齿轮
测试时间差齿轮包括确定地鼠加入 IGWEB 团队的已知日期。我们将确定 2017 年 5 月 24 日为 Molly 的开始日期,并将其用作在关于页面上 Molly 的生物数据下显示的人类可理解时间的测试基础。
以下是时间差齿轮的测试套件,实现在client/tests/go
目录中的humantimecog_test.go
源文件中:
package main
import (
"time"
"github.com/EngineerKamesh/igb/igweb/client/tests/go/caspertest"
humanize "github.com/dustin/go-humanize"
"github.com/gopherjs/gopherjs/js"
)
var wait = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
this.Call("waitForSelector", "#primaryContent")
return nil
})
var casper = js.Global.Get("casper")
func main() {
viewportParams := &caspertest.ViewportParams{Object:
js.Global.Get("Object").New()}
viewportParams.Width = 1440
viewportParams.Height = 960
casper.Get("options").Set("viewportSize", viewportParams)
casper.Get("test").Call("begin", "Time Ago Cog Test Suite", 1,
func(test *js.Object) {
casper.Call("start", "http://localhost:8080/about", wait)
})
// Verify the human time representation of Molly's start date
casper.Call("then", func() {
mollysStartDate := time.Date(2017, 5, 24, 17, 9, 0, 0, time.UTC)
mollysStartDateInHumanTime := humanize.Time(mollysStartDate)
casper.Call("capture", "screenshots/timeago_cog_test.png")
casper.Get("test").Call("assertSelectorHasText", "#Gopher-Molly
.timeagoSpan", mollysStartDateInHumanTime, "Verify human time of
Molly's start date produced by the Time Ago Cog.")
})
casper.Call("run", func() {
casper.Get("test").Call("done")
})
}
在main
函数内,我们设置了视口大小并开始测试套件后,创建了一个名为mollysStartDate
的新time
实例,表示 Molly 加入 IGWEB 团队的时间。然后,我们将mollyStartDate
传递给go-humanize
包的Time
函数(请注意,我们已将此包别名为"humanize"
),并将开始日期的人类可理解值存储在mollysStartDateHumanTime
变量中。
我们生成了测试运行的屏幕截图(显示在图 10.21中)。然后,我们调用tester
模块对象的assertSelectorHasText
方法,传入包含 Molly 开始日期的div
容器的 CSS 选择器。我们还传入mollysStartDateInHumanTime
变量,因为这是应该存在于选择器中的预期文本。
我们将通过在tester
模块对象上调用done
方法来表示时间差齿轮测试套件的结束。
我们可以通过发出以下命令来运行时间差齿轮测试套件的 CasperJS 测试:
$ casperjs test js/humantimecog_test.js
图 10.20显示了运行时间差齿轮测试套件的结果的屏幕截图:
图 10.20:运行时间差齿轮测试套件
图 10.21显示了生成的屏幕截图,显示了关于页面,其中 Molly 的开始日期以人类可读的时间格式打印出来:
图 10.21:验证时间差齿轮
现在我们已经验证了时间差齿轮的功能,让我们来测试实时时钟差齿轮的功能。
验证实时时钟差齿轮
验证用户本地时间的实时时钟差齿轮的功能包括创建一个新的time
实例,根据本地区域名称和本地时区偏移量格式化的当前时间,并将其与主页上显示的myLiveClock
div
容器中的值进行比较。
以下是实时时钟差齿轮的测试套件,实现在client/tests/go
目录中的liveclockcog_test.go
源文件中:
package main
import (
"time"
"github.com/EngineerKamesh/igb/igweb/client/tests/go/caspertest"
"github.com/gopherjs/gopherjs/js"
)
var wait = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
this.Call("waitForSelector", "#myLiveClock div")
return nil
})
var casper = js.Global.Get("casper")
func main() {
viewportParams := &caspertest.ViewportParams{Object:
js.Global.Get("Object").New()}
viewportParams.Width = 1440
viewportParams.Height = 960
casper.Get("options").Set("viewportSize", viewportParams)
casper.Get("test").Call("begin", "Live Clock Cog Test Suite", 1,
func(test *js.Object) {
casper.Call("start", "http://localhost:8080/index", wait)
})
// Verify that the live clock shows the current time for the local
time zone
casper.Call("then", func() {
casper.Call("wait", 900, func() {
localZonename, localOffset := time.Now().In(time.Local).Zone()
const layout = time.RFC1123
var location *time.Location
location = time.FixedZone(localZonename, localOffset)
casper.Call("wait", 10, func() {
t := time.Now()
currentTime := t.In(location).Format(layout)
casper.Get("test").Call("assertSelectorHasText", "#myLiveClock
div", currentTime, "Display live clock for local timezone.")
})
})
})
casper.Call("then", func() {
casper.Call("capture", "screenshots/liveclock_cog_test.png")
})
casper.Call("run", func() {
casper.Get("test").Call("done")
})
}
设置了 Web 浏览器的视口大小并通过访问主页启动测试套件后,我们等待900ms
,然后收集用户的本地时区名称和本地时区偏移量。我们将根据 RFC1123 布局格式化时间。这恰好是实时时钟差齿轮用于显示时间的相同布局。
我们从time
包中调用FixedZone
函数,传入localZonename
和localOffset
来获取位置。我们创建一个新的时区实例,并使用location
和 RFC1123layout
对其进行格式化。我们使用tester
模块对象的assertSelectorHasText
方法来查看当前时间是否使用 RFC1123layout
和用户当前location
格式化,是否存在于指定给assertSelectorHasText
方法的选择器中。
我们生成测试运行的截图(显示在图 10.23中),然后在tester
模块对象上调用done
方法,表示测试套件的结束。
我们可以通过发出以下命令来运行实时时钟齿轮测试套件的 CasperJS 测试:
$ casperjs test js/liveclockcog_test.js
图 10.22显示了运行实时时钟齿轮测试套件的结果的截图:
图 10.22:运行实时时钟齿轮测试套件
图 10.23显示了在主页上显示实时时钟齿轮的生成截图:
图 10.23:在主页上测试实时时钟齿轮
现在我们已经验证了实时时钟齿轮的功能,让我们来测试日期选择器齿轮的功能。
验证日期选择器齿轮
验证日期选择器齿轮的功能包括导航到联系人页面,并单击时间敏感日期输入字段。这应该触发日历小部件的显示。
这是日期选择器齿轮的测试套件,它是在datepickercog_test.go
源文件中实现的,位于client/tests/go
目录中:
package main
import (
"github.com/EngineerKamesh/igb/igweb/client/tests/go/caspertest"
"github.com/gopherjs/gopherjs/js"
)
var wait = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
this.Call("waitForSelector", "#primaryContent")
return nil
})
var casper = js.Global.Get("casper")
func main() {
viewportParams := &caspertest.ViewportParams{Object:
js.Global.Get("Object").New()}
viewportParams.Width = 1440
viewportParams.Height = 960
casper.Get("options").Set("viewportSize", viewportParams)
casper.Get("test").Call("begin", "Date Picker Cog Test Suite", 1,
func(test *js.Object) {
casper.Call("start", "http://localhost:8080/contact", wait)
})
// Verify that the date picker is activated upon clicking the date
input field
casper.Call("then", func() {
casper.Call("click", "#byDateInput")
casper.Call("capture", "screenshots/datepicker_cog_test.png")
casper.Get("test").Call("assertVisible", ".pika-single", "Display
Datepicker Cog.")
})
casper.Call("run", func() {
casper.Get("test").Call("done")
})
}
在main
函数中,我们设置了 Web 浏览器的视口大小,并通过导航到联系人页面来启动测试套件。
然后,我们调用casper
对象的click
方法,并提供 CSS 选择器"#byDateInput"
,这将向时间敏感日期输入字段发送鼠标单击事件,这应该会显示日历小部件。
我们对测试运行进行截图(显示在图 10.25中),然后调用tester
模块对象的assertVisible
方法,将".pika-single"
选择器和测试名称作为输入参数传递给该方法。assertVisible
方法将断言至少有一个与提供的选择器表达式匹配的元素是可见的。
最后,我们在tester
模块对象上调用done
方法,表示测试套件的结束。
我们可以通过发出以下命令来运行日期选择器齿轮测试套件的 CasperJS 测试:
$ casperjs test js/datepickercog_test.js
图 10.24显示了运行日期选择器齿轮测试套件的结果的截图:
图 10.24:运行日期选择器齿轮测试套件
图 10.25显示了单击时间敏感日期输入字段后显示日历小部件的生成截图:
图 10.25:验证日期选择器是否出现
现在我们已经验证了日期选择器齿轮的功能,让我们来测试旋转齿轮的功能。
验证旋转齿轮
验证旋转齿轮的功能包括提供足够的时间来加载旋转齿轮的图像,并且第一张图像,即watch.jpg
图像文件出现在网页上。
这是旋转齿轮的测试套件,它是在carouselcog_test.go
源文件中实现的,位于client/tests/go
目录中:
package main
import (
"github.com/EngineerKamesh/igb/igweb/client/tests/go/caspertest"
"github.com/gopherjs/gopherjs/js"
)
var wait = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
this.Call("waitForSelector", "#carousel")
return nil
})
var casper = js.Global.Get("casper")
func main() {
viewportParams := &caspertest.ViewportParams{Object:
js.Global.Get("Object").New()}
viewportParams.Width = 1440
viewportParams.Height = 960
casper.Get("options").Set("viewportSize", viewportParams)
casper.Get("test").Call("begin", "Carousel Cog Test Suite", 1,
func(test *js.Object) {
casper.Call("start", "http://localhost:8080/index", wait)
})
// Verify that the carousel cog has been loaded.
casper.Call("wait", 1800, func() {
casper.Get("test").Call("assertResourceExists", "watch.jpg",
"Display carousel cog.")
})
casper.Call("then", func() {
casper.Call("capture", "screenshots/carousel_cog_test.png")
})
casper.Call("run", func() {
casper.Get("test").Call("done")
})
}
设置 Web 浏览器的视口大小并启动测试套件后,通过导航到主页,我们等待1800
毫秒,然后在tester
模块对象上调用assetResourceExists
方法,提供要检查的资源的名称,这恰好是"watch.jpg"
图像文件,以及测试的描述。assertResourceExists
函数检查"watch.jpg"
图像文件是否存在于加载在网页上的资源集中。
我们拍摄了测试运行的屏幕截图(如图 10.27 所示),然后在casper
对象上调用done
方法,表示测试套件的结束。
我们可以通过发出以下命令来运行旋转木马齿轮测试套件的 CasperJS 测试:
$ casperjs test js/carouselcog_test.js
图 10.26 显示了运行旋转木马齿轮测试套件的结果的屏幕截图:
图 10.26:运行旋转木马齿轮测试套件
图 10.27 显示了生成的屏幕截图,显示了旋转木马齿轮:
图 10.27:验证旋转木马齿轮是否出现的测试
现在我们已经验证了旋转木马齿轮的功能,让我们来测试通知齿轮的功能。
验证通知齿轮
验证通知齿轮的功能包括导航到产品列表页面,通过单击列出产品的“添加到购物车”按钮将商品添加到购物车,然后验证通知是否出现在网页上。
这是通知齿轮的测试套件,它是在client/test/go
目录中的notifycog_test.go
源文件中实现的:
package main
import (
"github.com/EngineerKamesh/igb/igweb/client/tests/go/caspertest"
"github.com/gopherjs/gopherjs/js"
)
var wait = js.MakeFunc(func(this *js.Object, arguments []*js.Object) interface{} {
this.Call("waitForSelector", "#primaryContent")
return nil
})
var casper = js.Global.Get("casper")
func main() {
viewportParams := &caspertest.ViewportParams{Object:
js.Global.Get("Object").New()}
viewportParams.Width = 1440
viewportParams.Height = 960
casper.Get("options").Set("viewportSize", viewportParams)
casper.Get("test").Call("begin", "Notify Cog Test Suite", 1,
func(test *js.Object) {
casper.Call("start", "http://localhost:8080/products", wait)
})
// Add an item to the shopping cart
casper.Call("then", func() {
casper.Call("click", ".addToCartButton:nth-child(1)")
})
// Verify that the notification has been displayed
casper.Call("wait", 450, func() {
casper.Get("test").Call("assertSelectorHasText", "#alertify-logs
.alertify-log-success", "Item added to cart", "Display Notify Cog
when item added to shopping cart.")
})
casper.Call("wait", 450, func() {
casper.Call("capture", "screenshots/notify_cog_test.png")
})
// Navigate to Shopping Cart page
casper.Call("then", func() {
casper.Call("click", "a[href^='/shopping-cart']")
})
// Remove product from shopping cart
casper.Call("wait", 450, func() {
casper.Call("click", ".removeFromCartButton:first-child")
})
casper.Call("run", func() {
casper.Get("test").Call("done")
})
}
设置了网页浏览器的视口并通过导航到产品列表页面开始测试套件后,我们调用casper
对象的click
方法,提供".addToCartButton:nth-child(1)"
选择器。这会向网页上的第一个“添加到购物车”按钮发送鼠标单击事件。
我们等待450
毫秒,然后调用tester
模块的assertSelectorHasText
方法,提供 CSS 选择器、应该存在于从选择器返回的元素中的文本,以及测试描述作为输入参数。
我们拍摄了测试运行的屏幕截图(如图 10.29 所示)。然后我们导航到购物车页面,并从购物车中移除该商品。
最后,我们在tester
模块对象上调用done
方法,表示测试套件的结束。
我们可以通过发出以下命令来运行通知齿轮测试套件的 CasperJS 测试:
$ casperjs test js/notifycog_test.js
图 10.28 显示了运行通知齿轮测试套件的结果的屏幕截图:
图 10.28:运行通知齿轮测试套件
图 10.29 显示了生成的屏幕截图,显示了通知消息如预期般显示在网页右下角:
图 10.29:运行测试以验证是否显示了通知消息
我们现在已经验证了通知齿轮的功能是否符合预期,这结束了我们对 IGWEB 客户端功能的测试。
图 10.30 显示了运行整个测试套件的屏幕截图,方法是运行以下命令:
$ casperjs test js/*.js
图 10.30:运行整个 CasperJS 测试套件
摘要
在本章中,您学习了如何执行端到端测试,以验证同构 Go Web 应用程序的功能。为了确保 IGWEB 的质量,在网站上线之前,我们首先收集了要测试的基线功能集。
为了验证服务器端功能,我们使用 Go 的标准库中的testing
包实现了测试。我们实现了验证服务器端路由/模板渲染、联系表单的验证功能以及成功的联系表单提交场景的测试。
为了验证客户端功能,我们使用 CasperJS 实施了测试,验证了多个用户交互场景。我们能够使用 CasperJS 执行自动化用户交互测试,因为它建立在 PhantomJS 之上,后者是一个配备 JavaScript 运行时的无头浏览器。我们实施了 CasperJS 测试来验证客户端路由/模板渲染、联系表单的客户端验证功能、客户端成功提交联系表单的场景、购物车功能以及实时聊天功能。我们还实施了 CasperJS 测试,验证了我们在第九章“齿轮-可重用组件”中实施的齿轮集合的功能。
在第十一章“部署同构 Go Web 应用”中,您将学习如何将 IGWEB 部署到云端。我们将首先探讨将网站发布到独立服务器的过程。之后,您将学习如何利用 Docker 将网站发布为多容器 Docker 应用程序。
第十一章:部署同构 Go Web 应用程序
通过我们在上一章中实施的自动化端到端测试,IGWEB 演示网站现在满足了一组预期功能的基线。现在是时候将我们的同构 Go Web 应用程序释放到网络中了。是时候专注于将 IGWEB 部署到生产环境了。
我们对同构 Go 生产部署的探索将包括将 IGWEB 作为静态二进制可执行文件以及静态资产部署到独立服务器(真实或虚拟)上,以及将 IGWEB 作为多 Docker 容器应用程序部署。
部署 Web 应用程序是一个广阔的主题,一个值得专门讨论的海洋,有许多专门讨论这个主题的书籍。现实世界的 Web 应用程序部署可能包括持续集成、配置管理、自动化测试、部署自动化工具和敏捷团队管理。这些部署可能还包括多个团队成员,在部署过程中扮演各种角色。
本章的重点将仅仅是通过单个个体部署同构 Go Web 应用程序。为了说明,部署过程将手动执行。
需要考虑一些特定的因素,以成功地准备一个用于生产的同构 Go web 应用程序,例如,对由 GopherJS 生成的 JavaScript 源文件进行缩小,并确保静态资产以启用 GZIP 压缩的方式传输到 Web 客户端。通过将本章中呈现的材料重点放在同构 Go 上,读者可以根据自己特定的部署需求来调整本章中呈现的概念和技术。
在本章中,我们将涵盖以下主题:
-
IGWEB 在生产模式下的运行方式
-
将同构 Go Web 应用程序部署到独立服务器。
-
使用 Docker 部署同构 Go Web 应用程序
IGWEB 在生产模式下的运行方式
在进行生产部署之前,我们需要了解当将服务器端 Web 应用程序igweb
放入生产模式时,它是如何运行的。可以通过在启动igweb
服务器端应用程序之前设置IGWEB_MODE
环境变量的值为"production"
来打开生产模式。
$ export IGWEB_MODE=production
IGWEB 在生产模式下运行时将发生三种重要的行为:
-
在头部部分模板中包含客户端应用程序的 JavaScript 外部
<script>
标签将请求位于$IGWEB_APP_ROOT/static/js/client.min.js
的缩小 JavaScript 源文件。 -
当 Web 服务器实例启动时,cogs(
cogimport.css
和cogimport.js
)的静态资产将不会自动生成。相反,包含捆绑静态资产的缩小源文件将分别位于$IGWEB_APP_ROOT/static/css/cogimports.min.css
和$IGWEB_APP_ROOT/static/js/cogimports.min.js
。 -
与依赖于
$IGWEB_APP_ROOT/shared/templates
文件夹中的模板不同,模板将从单个、gob 编码的模板捆绑文件中读取,该文件将持久保存在磁盘上。
我们将考虑服务器端 Web 应用程序如何响应这些行为。
由 GopherJS 生成的 JavaScript 源文件
在funcs.go
源文件中定义我们的模板函数的地方,我们引入了一个名为IsProduction
的新函数:
func IsProduction() bool {
if isokit.OperatingEnvironment() == isokit.ServerEnvironment {
return os.Getenv("IGWEB_MODE") == "production"
} else {
return false
}
}
这个函数是用于在服务器端使用的,如果当前操作模式是生产模式,则返回true
,否则返回false
。我们可以在模板中使用这个自定义函数来确定客户端 JavaScript 应用程序应该从哪里获取。
在非生产模式下运行时,client.js
源文件将从服务器相对路径/js/client.js
获取。在生产模式下,缩小的 JavaScript 源文件将从服务器相对路径/static/js/client.min.js
获取。
在头部部分模板中,我们调用productionmode
自定义函数来确定从哪个路径提供客户端 JavaScript 源文件,如下所示:
<head>
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{.PageTitle}}</title>
<link rel="icon" type="image/png" href="/static/images/isomorphic_go_icon.png">
<link rel="stylesheet" href="/static/css/pure.min.css">
{{if productionmode}}
<link rel="stylesheet" type="text/css" href="/static/css/cogimports.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/igweb.min.css">
<script type="text/javascript" src="img/client.min.js" async></script>
<script src="img/cogimports.min.js" type="text/javascript" async></script>
{{else}}
<link rel="stylesheet" type="text/css" href="/static/css/cogimports.css">
<link rel="stylesheet" type="text/css" href="/static/css/igweb.css">
<script src="img/cogimports.js" type="text/javascript" async></script>
<script type="text/javascript" src="img/client.js" async></script>
{{end}}
</head>
你可能会想为什么在非生产模式和生产模式之间包含不同的 JavaScript 源文件(client.js
与client.min.js
)。回想一下,在运行kick
的开发环境中,client.js
和client.js.map
源文件会在$IGWEB_APP_ROOT/client
文件夹中生成。在igweb.go
中,我们注册了路由处理函数,将/js/client.js
路径和/js/client.js.map
路径映射到$IGWEB_APP_ROOT/client
文件夹中的相应源文件:
// Register Handlers for Client-Side JavaScript Application
if WebAppMode != "production" {
r.Handle("/js/client.js", isokit.GopherjsScriptHandler(WebAppRoot)).Methods("GET")
r.Handle("/js/client.js.map", isokit.GopherjsScriptMapHandler(WebAppRoot)).Methods("GET")
}
这为我们提供了便利,我们可以让kick
在我们对应用程序代码进行更改时自动转换 JavaScript 代码。在非生产模式下,我们更喜欢不缩小 JavaScript 源文件,以便通过 Web 控制台获得更详细的调试信息,例如恐慌堆栈跟踪(在附录中介绍,调试同构 Go)。
在生产模式下,无需使用kick
。如果你检查client.js
源文件的文件大小,你会注意到它大约有 8.1MB!这确实是一个严重的震惊!在下一节中,我们将学习如何将这个笨重的文件大小缩小。
驯服 GopherJS 生成的 JavaScript 文件大小
在生产部署过程中,我们必须发出gopherjs build
命令,指定选项来缩小生成的 JavaScript 源文件,并将 JavaScript 源文件的输出保存到指定的目标位置。
我们必须缩小生成的 JavaScript 代码以减小文件大小。如前所述,未缩小的 JavaScript 源文件为 8.1MB!通过缩小它,使用gopherjs build
命令运行-m
选项,并指定--tags
选项值为clientonly
,我们可以将源文件的大小进一步减小到 2.9MB,如下所示:
$ gopherjs build -m --verbose --tags clientonly -o $IGWEB_APP_ROOT/static/js/client.min.js
clientonly
标签告诉 isokit 避免转换客户端应用程序未使用的源文件。-o
选项将把生成的输出 JavaScript 源文件放在指定的目标位置。
在运行gopherjs build
命令之前,执行$IGWEB_APP_ROOT/scripts
目录中找到的clear_gopherjs_cache.sh
bash 脚本总是一个好主意。它将清除从先前的gopherjs build
运行中缓存的项目构件。
提供一个将近 3MB 大的 JavaScript 源文件仍然是一个不可行的方案。通过启用 GZIP 压缩,我们可以进一步减小传输文件的大小。一旦使用 GZIP 压缩发送源文件,传输文件大小将约为 510KB。我们将在启用 GZIP 压缩部分学习如何在 Web 服务器上启用 GZIP 压缩。
生成静态资产
在部署服务器端 Go Web 应用程序时,通常不仅会推送 Web 服务器实例的二进制可执行文件,还会推送静态资产文件(CSS、JavaScript、模板文件、图像、字体等)和模板文件。在传统的 Go Web 应用程序中,我们必须将单独的模板文件推送到生产系统,因为传统的 Go Web 应用程序依赖于每个单独的文件可用以在服务器端呈现给定的模板。
由于我们利用了在运行应用程序中通过内存持久化的模板集的概念,因此无需将单独的模板文件带到生产环境中。这是因为我们生成内存模板集所需的一切只是一个gob
编码的模板捆绑文件,它被持久化在$IGWEB_APP_ROOT/static/templates
文件夹中。
通过在isokit
包中设置导出的StaticTemplateBundleFilePath
变量,我们指示 isokit 在我们提供的文件路径生成静态模板捆绑文件。以下是在igweb.go
源文件中的initializeTemplateSet
函数中设置变量的行:
isokit.StaticTemplateBundleFilePath = StaticAssetsPath + "/templates/igweb.tmplbundle"
在第九章中,Cogs-可重用组件,我们了解到当首次启动igweb
应用程序时,isokit 将所有 cogs 的 JavaScript 源文件捆绑到单个cogimports.js
源文件中。类似地,所有 cogs 的 CSS 样式表都捆绑到单个cogimports.css
源文件中。在非生产模式下运行 IGWEB 时,通过在igweb.go
源文件中的initailizeCogs
函数中调用isokit.BundleStaticAssets
函数(以粗体显示)自动捆绑静态资产:
func initializeCogs(ts *isokit.TemplateSet) {
timeago.NewTimeAgo().CogInit(ts)
liveclock.NewLiveClock().CogInit(ts)
datepicker.NewDatePicker().CogInit(ts)
carousel.NewCarousel().CogInit(ts)
notify.NewNotify().CogInit(ts)
isokit.BundleStaticAssets()
}
不应在生产环境中使用自动静态资产捆绑,因为捆绑 JavaScript 和 CSS 的动态功能取决于服务器上安装了配置了 Go 工作区的 Go 发行版,并且该 Go 工作区中必须存在 cogs 的源文件。
这立即消除了 Go 默认的优势之一。由于 Go 生成静态链接的二进制可执行文件,我们不需要在生产服务器上安装 Go 运行时即可部署我们的应用程序。
当我们以生产模式运行 IGWEB 时,可以通过在igweb.go
源文件中的initializeTemplateSet
函数中引入以下代码来阻止自动静态资产捆绑:
if WebAppMode == "production" && oneTimeStaticAssetsGeneration == false {
isokit.UseStaticTemplateBundleFile = true
isokit.ShouldBundleStaticAssets = false
}
我们指示 isokit 使用静态模板捆绑文件,并指示 isokit 不自动捆绑静态资产。
为了生成我们的同构 Go Web 应用程序所需的静态资产(CSS、JavaScript 和模板捆绑),我们可以在非生产系统上使用igweb
运行--generate-static-assets
标志:
$ igweb --generate-static-assets
此命令将生成必要的静态资产,然后退出igweb
程序。此功能的实现可以在igweb.go
源文件中定义的generateStaticAssetsAndExit
函数中找到:
func generateStaticAssetsAndExit(env *common.Env) {
fmt.Print("Generating static assets...")
isokit.ShouldMinifyStaticAssets = true
isokit.ShouldBundleStaticAssets = true
initializeTemplateSet(env, true)
initializeCogs(env.TemplateSet)
fmt.Println("Done")
os.Exit(0)
}
在指示igweb
生成静态资产后,将创建三个文件:
-
$IGWEB_APP_ROOT/static/templates/igweb.tmplbundle
(模板捆绑) -
$IGWEB_APP_ROOT/static/css/cogimports.min.css
(压缩的 CSS 捆绑包) -
$IGWEB_APP_ROOT/static/js/cogimports.min.js
(压缩的 JavaScript 捆绑包)
在执行生产部署时,可以将整个$IGWEB_APP_ROOT/static
文件夹复制到生产系统,确保三个前述的静态资产将在生产系统上提供。
此时,我们已经建立了 IGWEB 在生产模式下的操作方式。现在,是时候执行最简单的部署了-将同构 Go Web 应用程序部署到独立服务器。
将同构 Go Web 应用程序部署到独立服务器
为了演示独立的同构 Go 部署,我们将使用 Linode(www.linode.com
)托管的虚拟专用服务器(VPS)。此处提出的程序适用于任何其他云提供商,以及独立服务器恰好是位于服务器室中的真实服务器的情况。我们将概述的独立部署过程是手动执行的,以说明每个步骤。
为服务器提供服务
在本演示中的服务器,以及本章后续演示中提到的服务器将在 Linode 上运行 Ubuntu Linux 16.04 LTS 版本,Linode 是虚拟专用服务器(VPS)实例的提供商。我们将运行 Linode 的默认 Ubuntu 16.04 存储映像,而不进行任何内核修改。
当我们在本章中发出任何以sudo
为前缀的命令时,我们假设您的用户帐户是 sudoers 组的一部分。如果您使用服务器的 root 帐户,则无需在命令前加上sudo
。
我们将通过发出以下命令创建一个名为igweb
的权限较低的用户:
$ sudo adduser igweb
运行adduser
命令后,您将被提示为igweb
用户和密码输入附加信息。如果您没有提示输入用户密码,您可以通过发出以下命令来设置密码:
$ sudo passwd igweb
igweb
应用程序依赖于两个组件才能正常运行。首先,我们需要安装 Redis 数据库。其次,我们需要安装nginx
。我们将使用nginx
作为反向代理服务器,这将允许我们在为 Web 客户端提供静态资产时启用 GZIP 压缩。正如您将看到的,这在 GopherJS 生成的 JavaScript 源文件的文件大小方面有很大的区别(510 KB 与 3MB)。图 11.1描述了 Linode VPS 实例与三个关键组件igweb
、nginx
和redis-server
:
图 11.1:运行 igweb、nginx 和 redis-server 的 Linode VPS 实例
设置 Redis 数据库实例
您可以按照第二章中演示的相同过程来安装 Redis 数据库。在这之前,您应该发出以下命令来安装必要的构建工具:
$ sudo apt-get install build-essential tcl
安装了 Redis 数据库后,您应该通过发出以下命令来启动 Redis 服务器:
$ sudo redis-server --daemonize yes
--daemonize
命令行参数允许我们在后台运行 Redis 服务器。即使我们的会话结束后,服务器也将继续运行。
您应该通过添加足够的防火墙规则来保护 Redis 安装,以防止外部流量访问端口 6379,Redis 服务器实例的默认端口。
设置 NGINX 反向代理
虽然igweb
Web 服务器实例,一个 Go 应用程序,可以独自满足服务 IGWEB 的主要需求,但将igweb
Web 服务器实例置于反向代理之后更有利。
反向代理服务器是一种代理服务器类型,它将通过将请求分派到指定的目标服务器(在本例中为igweb
)来为客户端请求提供服务,从igweb
服务器实例获取响应,并将响应发送回客户端。
反向代理有几个方面的便利。释放 IGWEB 的即时好处最重要的原因是我们可以在出站静态资产上启用 GZIP 压缩。除此之外,反向代理还允许我们在需要时轻松添加重定向规则来控制流量。
NGINX 是一种流行的高性能 Web 服务器。我们将使用nginx
作为igweb
Web 服务器实例前面的反向代理。图 11.2描述了一个典型的反向代理配置,其中 Web 客户端将通过端口 80 发出 HTTP 请求,nginx
将通过端口 8080 将 HTTP 请求发送到igweb
服务器实例,从igweb
服务器检索响应,并通过端口 80 将响应发送回 Web 客户端:
图 11.2:反向代理配置
以下是我们将用于运行nginx
作为反向代理的nginx.conf
配置文件清单:
user igweb;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_min_length 1100;
gzip_buffers 16 8k;
gzip_types text/plain application/javascript text/css;
gzip_vary on;
gzip_comp_level 9;
server_tokens off;
server {
listen 80;
access_log /var/log/nginx/access.log main;
location / {
proxy_pass http://192.168.1.207:8080/;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
}
我们对我们感兴趣的两个设置部分,即启用 GZIP 压缩的部分和代理设置的部分。
启用 GZIP 压缩
让我们检查与启用 GZIP 压缩相关的nginx
配置设置。
我们将gzip
指令设置为on
以启用服务器响应的压缩。
gzip_min_length
指令允许我们指定将进行 gzip 压缩的响应的最小长度。
gzip_buffers
指令设置用于压缩响应的缓冲区的数量和大小。我们指定将使用 16 个缓冲区,内存页大小为 8K。
gzip_types
指令允许我们指定应在其上启用 GZIP 压缩的 MIME 类型,除了text/HTML之外。我们已指定纯文本文件、JavaScript 源文件和 CSS 源文件的 MIME 类型。
gzip_vary
指令用于启用或禁用Vary: Accept-Encoding响应头。Vary: Accept-Encoding响应头指示缓存存储网页的不同版本,如果头部有变化,则特别重要。对于不支持 GZIP 编码的 Web 浏览器,这个设置特别重要,以便正确接收文件的未压缩版本。
gzip_comp_level
指令指定将使用的 GZIP 压缩级别。我们指定了一个值为 9 的最大 GZIP 压缩级别。
代理设置
nginx
配置设置中的第二部分是反向代理设置。
我们在location
块内包括proxy_pass
指令,值为 web 服务器的地址和端口。这指定所有请求应发送到指定的代理服务器(igweb
),位于http://192.168.1.207:8080
。
请记住,将此示例中显示的 IP 地址 192.168.1.207 替换为运行您的igweb
实例的机器的 IP 地址。
反向代理将从igweb
服务器实例获取响应并将其发送回 Web 客户端。
proxy_set_header
指令允许我们重新定义(或追加)传递给代理服务器的请求头字段。我们已经包括了X-Forwaded-For头,以便代理服务器可以识别发起请求的 Web 客户端的原始 IP 地址。
为了支持 websockets 的正常运行(这是实时聊天功能所依赖的),我们包括以下代理设置。首先,我们指定使用proxy_http_version
指令,服务器将使用 HTTP 版本 1.1。默认情况下,"Upgrade"
和"Connection"
头不会传递给代理服务器。因此,我们必须使用proxy_set_header
指令将这些头发送到代理服务器。
我们可以通过以下命令安装nginx
:
$ sudo apt-get install nginx
安装nginx
后,Web 服务器通常会默认启动。但是如果没有启动,我们可以通过以下命令启动nginx
:
$ sudo systemctl start nginx
$IGWEB_APP_ROOT/deployments-config/standalone-setup
文件夹中找到的nginx.conf
文件可以放置在生产服务器的/etc/nginx
文件夹中。
图 11.3描述了当我们尝试访问igweb.kamesh.com
URL 时遇到的 502 Bad Gateway 错误:
图 11.3:502 Bad Gateway 错误
我们遇到了这个服务器错误,因为我们还没有启动igweb
。要让igweb
运行起来,我们首先需要在服务器上设置一个位置,用于存放igweb
二进制可执行文件和静态资产。
设置 IGWEB 根文件夹
IGWEB 根文件夹是生产服务器上igweb
可执行文件和静态资产所驻留的地方。我们使用以下命令在生产服务器上成为igweb
用户:
$ su - igweb
我们在igweb
用户的主目录中创建一个igweb
文件夹,如下所示:
mkdir ~/igweb
这是包含igweb
Web 服务器实例的二进制可执行文件和 IGWEB 演示网站所需的静态资产的目录。请注意,静态资产将驻留在~/igweb/static
文件夹中。
交叉编译 IGWEB
使用 go build
命令,我们实际上可以为不同的目标操作系统构建二进制文件,这种技术称为交叉编译。例如,在我的 macOS 机器上,我可以构建一个 64 位 Linux 二进制文件,然后将其推送到运行 Ubuntu Linux 的独立生产服务器上。在构建我们的二进制文件之前,我们通过设置 GOOS
环境变量来指定我们要构建的目标操作系统:
$ export GOOS=linux
通过将 GOOS
环境变量设置为 linux
,我们已经指定我们希望为 Linux 生成一个二进制文件。
为了指定我们希望二进制文件是 64 位二进制文件,我们设置 GOARCH
环境变量来指定目标架构:
$ export GOARCH=amd64
通过将 GOARCH
变量设置为 amd64
,我们已经指定我们需要一个 64 位二进制文件。
通过发出 mkdir
命令,在 igweb
文件夹内创建一个 builds
目录:
$ mkdir $IGWEB/builds
这个目录将作为包含各种操作系统的 igweb
二进制可执行文件的仓库。在本章中,我们只考虑构建 64 位 Linux 二进制文件,但在将来,我们可以在此目录中适应其他操作系统的构建,比如 Windows。
我们发出 go build
命令,并提供 -o
参数来指定生成的二进制文件应该位于哪里:
$ go build -o $IGWEB_APP_ROOT/builds/igweb-linux64
我们已经指示生成的 64 位 Linux 二进制文件应该创建在 $IGWEB_APP_ROOT/builds
文件夹中,并且可执行文件的名称将是 igweb-linux64
。
您可以通过发出 file
命令来验证生成的二进制文件是否为 Linux 二进制文件:
$ file builds/igweb-linux64
builds/igweb-linux64: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
从结果中,我们可以看到 go build
命令生成了一个 64 位 LSB
(Linux 标准基础)可执行文件。
如果您有兴趣为 Linux 以外的其他操作系统构建 Go 二进制文件,此链接将为您提供所有可能的 GOOS
和 GOARCH
值的完整列表:golang.org/doc/install/source#environment
。
准备部署包
除了发布 igweb
可执行文件,我们还需要发布存放所有 IGWEB 静态资产的静态文件夹的内容。
准备部署包的静态资产包括以下步骤:
-
转换客户端应用程序
-
生成静态资产包(模板包、CSS 和 JavaScript)
-
缩小 IGWEB 的 CSS 样式表
首先,我们转换客户端应用程序:
$ cd $IGWEB_APP_ROOT/client
$ $IGWEB_APP_ROOT/scripts/clear_gopherjs_cache.sh
$ gopherjs build --verbose -m --tags clientonly -o $IGWEB_APP_ROOT/static/js/client.min.js
其次,我们需要生成静态资产包:
$ $IGWEB_APP_ROOT/igweb --generate-static-assets
Generating static assets...Done
准备部署包的第三个也是最后一个步骤是压缩 CSS 样式表。
首先,我们需要通过发出以下命令来安装基于 Go 的缩小器:
$ go get -u github.com/tdewolff/minify/cmd/minify
$ go install github.com/tdewolff/minify
现在,我们可以压缩 IGWEB 的 CSS 样式表:
$ minify --mime="text/css" $IGWEB_APP_ROOT/static/css/igweb.css > $IGWEB_APP_ROOT/static/css/igweb.min.css
有了这些项目,我们现在准备创建一个部署包,一个 tarball,其中包括 igweb
Linux 二进制文件以及 static
文件夹。我们通过发出以下命令来创建 tarball:
$ cd $IGWEB_APP_ROOT
$ tar zcvf /tmp/bundle.tgz builds/igweb-linux64 static
我们将使用 scp
命令将包发送到远程服务器:
$ scp /tmp/bundle.tgz igweb@targetserver:/tmp/.
scp
命令将 tarball bundle.tgz
复制到具有主机名 targetserver
的服务器上的 /tmp
目录。现在部署包已放置在服务器上,是时候让 igweb
运行起来了。
部署包并启动 IGWEB
我们将安全复制到 /tmp
文件夹的模板包移动到 ~/igweb
文件夹,并提取 tarball 的内容:
$ cd ~/igweb
$ mv /tmp/bundle.tgz .
$ tar zxvf bundle.tgz
在我们提取 bundle.tgz
压缩包的内容后,通过发出 rm
命令来删除压缩包文件。
$ rm bundle.tgz
我们可以使用 mv
命令将二进制文件重新命名为 igweb
:
$ mv igweb-linux64 igweb
我们在本地机器上将 -linux64
附加到二进制文件的名称上,以便我们可以将其与其他操作系统/架构组合的构建区分开。
此时我们已经将包部署到生产服务器。现在是运行 igweb
的时候了。
运行 IGWEB
在运行igweb
可执行文件之前,我们必须在生产服务器上设置$IGWEB_APP_ROOT
和$IGWEB_MODE
环境变量:
$ export IGWEB_APP_ROOT=/home/igweb/igweb
$ export IGWEB_MODE=production
设置$IGWEB_APP_ROOT
环境变量允许igweb
应用程序知道指定的igweb
目录,该目录将包含依赖资源,如静态资产。
将$IGWEB_MODE
环境变量设置为production
允许我们以生产模式运行igweb
应用程序。
您应该在igweb
用户的.bashrc
配置文件中为这两个环境变量添加条目:
export IGWEB_APP_ROOT=/home/igweb/igweb
export IGWEB_MODE=production
在生产服务器上注销并重新登录,以使对.bashrc
所做的更改生效。
在前台运行 IGWEB
让我们启动igweb
Web 服务器实例:
$ cd $IGWEB_APP_ROOT
$ ./igweb
图 11.4显示了 IGWEB 在地址igweb.kamesh.com
上运行的独立服务器实例的屏幕截图:
图 11.4:IGWEB 在独立服务器实例上运行
当我们按下Ctrl + C组合键退出igweb
程序时,我们的 Web 服务器实例会因为一直在前台运行而停止。NGINX 将为任何客户端请求返回 502 Bad Gateway 服务器错误。我们需要一种方法来使igweb
以守护进程方式运行,以便在后台运行。
在后台运行 IGWEB
igweb
Web 服务器实例可以使用nohup
命令在后台运行:
$ nohup ./igweb 2>&1 &
nohup
命令用于在当前会话终止后继续运行igweb
程序。在类 Unix 系统上,2>&1
构造意味着将标准错误(stderr
)重定向到与标准输出(stdout
)相同的位置。igweb
程序的日志消息将通过尾随/var/log/syslog
文件进行查看。最后,命令中的最后一个&
表示在后台运行该程序。
我们可以通过首先获取PID(进程 ID)来停止igweb
进程:
$ ps -ef | grep igweb | grep -v grep
从运行此命令返回的输出中,PID 值将紧邻可执行文件igweb
的名称。一旦确定了进程的 PID,我们可以使用kill
命令并指定 PID 的值来停止igweb
进程:
$ kill PID
请注意,我们在前述kill
命令中放置了名称PID
,仅用于说明目的。您将需要使用从运行ps
命令返回的 PID 的数字值来为kill
命令提供 PID。
使用 systemd 在后台运行 IGWEB
这种运行igweb
的方法暂时有效,但是如果服务器重新启动会怎么样?我们需要一种方法使igweb
程序更具弹性。它必须能够在服务器重新上线后再次启动,并且nohup
不是实现此目标的合适选择。
我们真正需要的是将igweb
转换为系统服务的方法。我们可以使用sysytemd
来实现这一点,sysytemd
是一个可用于 Ubuntu 16.04 LTS 的初始化系统。使用systemd
,我们可以初始化、管理和跟踪系统服务。它可以在系统启动时或系统运行时使用。
您需要以root
用户身份运行以下命令,因为您需要成为root
用户才能添加新的系统服务。
为了将igweb
转换为服务,我们创建一个名为igweb.service
的单元文件,并将其放在/etc/systemd/system
目录中。以下是单元文件的内容:
[Unit]
Description=IGWEB
[Service]
USER=igweb
GROUP=igweb
Environment=IGWEB_APP_ROOT=/home/igweb/igweb
Environment=IGWEB_MODE=production
WorkingDirectory=/home/igweb/igweb
ExecStart=/home/igweb/igweb/igweb
Restart=always
[Install]
WantedBy=multi-user.target
指定.service
文件扩展名表示我们正在创建一个服务单元,描述如何在服务器上管理应用程序。这包括执行诸如启动或停止服务的操作,以及服务是否应在系统启动时启动。
单元文件分为多个部分,每个部分的开头用一对方括号[和]标示,括号之间包含部分的名称。
单元文件中的部分名称区分大小写!
第一部分是[Unit]
部分。这用于定义单元的元数据以及该单元与其他单元的关系。在[Unit]
部分中,我们已经为Description
指定了一个值,用于描述单元的名称。例如,我们运行以下命令:
$ systemctl status nginx
当我们运行它时,我们在nginx
的描述中看到的是使用Description
指令指定的描述。
[Service]
部分用于指定服务的配置。USER
和GROUP
指令指定命令应该以什么用户和组身份运行。我们使用Environment
指令来设置$IGWEB_APP_ROOT
环境变量,并再次使用它来设置$IGWEB_MODE
环境变量。
WorkingDirectory
指令设置了执行命令的工作目录。ExecStart
指令指定了要执行的命令的完整路径;在这种情况下,我们提供了igweb
可执行文件的完整路径。
Restart
指令用于指定systemd
将尝试重新启动服务的情况。通过提供always的值,我们指定服务应始终运行,如果出现某种原因停止,应该再次启动。
我们定义的最后一个部分是[Install]
部分。这个部分允许我们指定单元在启用或禁用时的行为。
在这个部分声明的WantedBy
指令告诉systemd
如何启用一个单元,也就是说,当启用服务时,该服务应该在什么系统运行级别下运行。通过将此指令的值设置为multi-user.target
,我们指定该服务在系统运行级别 3(多用户模式)下运行。
每当我们引入新的systemd
服务脚本或对现有脚本进行更改时,我们必须重新加载systemd
守护程序。我们可以通过发出以下命令来实现:
$ systemctl daemon-reload
我们可以指定,我们希望igweb
服务在启动时自动启动,方法是发出以下命令:
$ systemctl enable igweb
如果我们不希望igweb
服务在启动时自动启动,我们可以发出以下命令:
$ systemctl disable igweb
我们可以通过发出以下命令来启动igweb
服务:
$ systemctl start igweb
我们可以通过发出以下命令来停止igweb
服务:
$ systemctl stop igweb
我们现在已经完成了igweb
的独立部署。令人惊讶的是,我们可以在目标生产系统上运行igweb
应用程序,而无需安装 Go。
然而,这种方法对于负责保持 IGWEB 运行的 DevOps 团队来说相当不透明。我所说的不透明是指 DevOps 工程师无法通过检查静态二进制可执行文件和一堆静态资产来确定太多信息。
我们需要一种更简化的方式来部署 IGWEB,一种程序可以显示从头开始启动igweb
实例所需的所有依赖关系。为了实现这个目标,我们需要将 IGWEB 放入 Docker 容器中。
使用 Docker 部署同构 Go Web 应用程序
本节概述了在 Linode 云上将igweb
部署为多容器 Docker 应用程序的过程。Docker 是一种技术和平台,允许我们在单台机器上运行和管理多个 Docker 容器。您可以将 Docker 容器视为模块化、轻量级的虚拟机。我们可以通过将应用程序(如igweb
)打包为 Docker 容器,使其立即可移植。无论在哪个环境中运行,应用程序都保证在容器内以相同的方式运行。
您可以在以下链接了解有关 Docker 的更多信息:www.docker.com
。
大多数云提供商都支持 Docker,使其成为云部署的非常方便的工具。正如您将在本章后面看到的,将多容器 Docker 应用程序部署到 Linode 云上相对容易。
安装 Docker
在生产系统上安装 Docker 之前,我们首先需要安装一些先决条件:
$ sudo apt-get install dmsetup && dmsetup mknodes
现在,我们可以发出以下命令来安装 Docker:
$ sudo apt-get install docker-ce
要验证 Docker 是否已经在生产系统上正确安装,您可以发出以下命令:
$ docker --version
Docker version 17.09.0-ce, build afdb6d4
运行命令后,您应该看到安装的 Docker 版本。
Docker 化 IGWEB
docker 化igweb
的过程首先涉及创建一个Dockerfile
,该文件指定了如何创建 Docker 镜像的指令。然后将使用 Docker 镜像来创建 Docker 容器。
创建了 Dockerfile 之后,我们将使用docker-compose
工具来定义和运行多个容器,以支持 IGWEB 网站的运行。
将igweb
部署为多容器 Docker 应用程序是一个三步过程:
-
从中可以创建一个 IGWEB docker 镜像的
Dockerfile
-
在
docker-compose.yml
文件中定义组成 IGWEB 的服务 -
运行
docker-compose up
来启动多容器应用程序
Dockerfile
Dockerfile
描述了应该由igweb
docker 镜像制作的内容。该文件位于deployments-config/docker-single-setup
文件夹中。让我们检查Dockerfile
以了解它的工作原理。
FROM
指令指定了当前镜像派生的基本父镜像:
FROM golang
在这里,我们指定将使用基本的golang
docker 镜像。
有关golang
docker 镜像的更多信息可以在hub.docker.com/_/golang/
找到。
MAINTAINER
指令指定了Dockerfile
的维护者姓名以及他们的电子邮件地址:
MAINTAINER Kamesh Balasubramanian kamesh@kamesh.com
我们已经指定了一组ENV
指令,允许我们定义和设置所有必需的环境变量:
ENV IGWEB_APP_ROOT=/go/src/github.com/EngineerKamesh/igb/igweb
ENV IGWEB_DB_CONNECTION_STRING="database:6379"
ENV IGWEB_MODE=production
ENV GOPATH=/go
为了使igweb
应用程序正常运行,我们设置了$IGWEB_APP_ROOT
、$IGWEB_DB_CONNECTION
、$IGWEB_MODE
和$GOPATH
环境变量。
在这个块中,我们使用RUN
指令来获取igweb
应用程序所需的 Go 包:
RUN go get -u github.com/gopherjs/gopherjs
RUN go get -u honnef.co/go/js/dom
RUN go get -u -d -tags=js github.com/gopherjs/jsbuiltin
RUN go get -u honnef.co/go/js/xhr
RUN go get -u github.com/gopherjs/websocket
RUN go get -u github.com/tdewolff/minify/cmd/minify
RUN go get -u github.com/isomorphicgo/isokit
RUN go get -u github.com/uxtoolkit/cog
RUN go get -u github.com/EngineerKamesh/igb
这基本上是运行igweb
所需的 Go 包列表。
以下RUN
命令安装了一个基于 Go 的 CSS/JavaScript 缩小器:
RUN go install github.com/tdewolff/minify
我们使用另一个RUN
指令来转译客户端 Go 程序:
RUN cd $IGWEB_APP_ROOT/client; go get ./..; /go/bin/gopherjs build -m --verbose --tags clientonly -o $IGWEB_APP_ROOT/static/js/client.min.js
这个命令实际上是三个连续命令的组合,每个命令使用分号分隔。
第一个命令将目录更改为$IGWEB_APP_ROOT/client
目录。在第二个命令中,我们在当前目录和所有子目录中获取任何剩余所需的 Go 包。第三个命令将 Go 代码转译为一个缩小的 JavaScript 源文件client.min.js
,并将其放置在$IGWEB_APP_ROOT/static/js
目录中。
接下来的RUN
指令构建并安装服务器端 Go 程序:
>RUN go install github.com/EngineerKamesh/igb/igweb
请注意,go install
命令不仅会通过执行构建操作生成igweb
二进制可执行文件,还会将生成的可执行文件移动到$GOPATH/bin
。
我们发出以下RUN
指令来生成静态资产:
RUN /go/bin/igweb --generate-static-assets
这个RUN
指令缩小了 IGWEB 的 CSS 样式表:
RUN /go/bin/minify --mime="text/css" $IGWEB_APP_ROOT/static/css/igweb.css > $IGWEB_APP_ROOT/static/css/igweb.min.css
ENTRYPOINT
指令允许我们设置容器的主要命令:
# Specify the entrypoint
ENTRYPOINT /go/bin/igweb
这使我们能够像运行命令一样运行镜像。我们将ENTRYPOINT
设置为igweb
可执行文件的路径:/go/bin/igweb
。
我们使用EXPOSE
指令来通知 Docker 容器在运行时应监听的网络端口:
EXPOSE 8080
我们已经暴露了容器的端口8080
。
除了能够使用Dockerfile
构建 docker 镜像之外,该文件最重要的好处之一是它传达了意义和意图。它可以被视为一个一流的项目配置工件,以确切了解构建同构 Web 应用程序的过程,该应用程序由服务器端igweb
应用程序和客户端应用程序client.min.js
组成。通过查看Dockerfile
,DevOps 工程师可以轻松地确定成功从头开始构建整个同构 Web 应用程序的过程。
闭源项目的 Dockerfile
我们提出的Dockerfile
非常适合开源项目,但如果你的特定同构 Go 项目是闭源的,你该怎么办呢?你如何能够利用在云中运行 Docker 并同时保护源代码不被查看?我们需要对Dockerfile
进行轻微修改以适应闭源项目。
让我们考虑一个场景,igweb
的代码分发是闭源的。假设我们无法使用go get
命令获取它。
假设您已经在项目目录的根目录下创建了一个闭源友好的Dockerfile
,并且已经将闭源igweb
项目的 tarball 捆绑包从本地机器安全地复制到目标机器,并且已经解压了 tarball。
以下是我们需要对Dockerfile
进行的更改。首先,我们注释掉使用go get
命令获取igb
分发的相应RUN
指令:
# Get the required Go packages
RUN go get -u github.com/gopherjs/gopherjs
RUN go get -u honnef.co/go/js/dom
RUN go get -u -d -tags=js github.com/gopherjs/jsbuiltin
RUN go get -u honnef.co/go/js/xhr
RUN go get -u github.com/gopherjs/websocket
RUN go get -u github.com/tdewolff/minify/cmd/minify
RUN go get -u github.com/isomorphicgo/isokit
RUN go get -u github.com/uxtoolkit/cog
# RUN go get -u github.com/EngineerKamesh/igb
在一系列RUN
指令之后,我们立即引入了一个COPY
指令:
COPY . $IGWEB_APP_ROOT/.
这个COPY
指令将递归地将当前目录中的所有文件和文件夹复制到由$IGWEB_APP_ROOT/.
指定的目的地。就是这样。
现在我们已经深入研究了 IGWEB 的Dockerfile
的结构,我们必须承认igweb
web 服务器实例本身无法为 IGWEB 网站提供服务。它有一定的服务依赖性,我们必须考虑,比如 Redis 数据库用于数据持久性需求,以及 NGINX 反向代理以合理的 gzip 方式提供大型静态资产。
我们需要一个 Redis 的 Docker 容器,以及另一个 NGINX 的 Docker 容器。igweb
正在成为一个多容器的 Docker 应用程序。现在是时候把注意力转向docker-compose
,这是一个方便的工具,用于定义和运行多容器应用程序。
Docker compose
docker-compose
工具允许我们定义一个多容器的 Docker 应用程序,并使用单个命令docker-compose up
来运行它。
docker-compose
通过读取包含特定指令的docker-compose.yml
文件来工作,这些指令不仅描述了应用程序中的容器,还描述了它们各自的依赖关系。让我们来检查docker-compose.yml
文件中多容器igweb
应用程序的每个部分。
在文件的第一行,我们指示将使用 Docker Compose 配置文件格式的第 2 版:
version: '2'
我们在services
部分内声明了应用程序的服务。每个服务(以粗体显示)都被赋予一个名称,以指示它在多容器应用程序中的角色:
services:
database:
image: "redis"
webapp:
depends_on:
- database
build: .
ports:
- "8080:8080"
reverseproxy:
depends_on:
- webapp
image: "nginx"
volumes:
- ./deployments-config/docker-single setup/nginx.conf:/etc/nginx/nginx.conf
ports:
- "80:80"
我们已经定义了一个名为database
的服务,它将成为 Redis 数据库实例的容器。我们将 image 选项设置为redis
,以告诉docker-compose
基于 Redis 镜像运行一个容器。
紧接着,我们定义了一个名为webapp
的服务,它将成为igweb
应用程序的容器。我们使用depends_on
选项明确说明webapp
服务需要database
服务才能运行。如果没有database
服务,webapp
服务就无法启动。
我们指定build
选项告诉docker-compose
根据指定路径中的Dockerfile
构建镜像。通过指定相对路径.
,我们指示应使用当前目录中存在的Dockerfile
构建基础镜像。
在ports
部分,我们指定了8080:8080
(HOST:CONTAINER)的值,表示我们要在主机上打开端口8080
并将连接转发到 Docker 容器的端口8080
。
我们已经定义了名为reverseproxy
的服务,它将作为nginx
反向代理服务器的容器。我们将depends_on
选项设置为webapp
,以表示reverseproxy
服务在webapp
服务启动之前不能启动。我们将 image 选项设置为nginx
,告诉docker-compose
基于nginx
镜像运行容器。
在volumes
部分,我们可以定义我们的挂载路径,格式为 HOST:CONTAINER。我们定义了一个挂载路径,将位于./deployments-config/docker-single-setup
目录中的nginx.conf
配置文件挂载到容器内部的/etc/nginx/nginx.conf
路径。
由于reverseproxy
服务将为 HTTP 客户端请求提供服务,我们在ports
部分指定了值为80:80
,表示我们要在主机上打开端口80
(默认 HTTP 端口)并将连接转发到 Docker 容器的端口80
。
现在我们已经完成了 Docker Compose 配置文件,是时候使用docker-compose up
命令启动igweb
作为多容器 Docker 应用程序了。
运行 Docker Compose
我们发出以下命令来构建服务:
$ docker-compose build
运行docker-compose build
命令的输出如下(为了简洁起见,部分输出已省略):
database uses an image, skipping
Building webapp
Step 1/22 : FROM golang
---> 99e596fc807e
Step 2/22 : MAINTAINER Kamesh Balasubramanian kamesh@kamesh.com
---> Running in 107a99d5c4ee
---> 6facac83509e
Removing intermediate container 107a99d5c4ee
Step 3/22 : ENV IGWEB_APP_ROOT /go/src/github.com/EngineerKamesh/igb/igweb
---> Running in f009d8391fc4
---> ec1b1d15c6c3
Removing intermediate container f009d8391fc4
Step 4/22 : ENV IGWEB_DB_CONNECTION_STRING "database:6379"
---> Running in 2af5e98c71e2
---> 6748f0f5bc4d
Removing intermediate container 2af5e98c71e2
Step 5/22 : ENV IGWEB_MODE production
---> Running in 1a87b871f761
---> 9871fc511e80
Removing intermediate container 1a87b871f761
Step 6/22 : ENV GOPATH /go
---> Running in c6c2eff0ded2
---> 4dc456357dc9
Removing intermediate container c6c2eff0ded2
Step 7/22 : RUN go get -u github.com/gopherjs/gopherjs
---> Running in c8996108bd96
---> 6ae68fb84178
Removing intermediate container c8996108bd96
Step 8/22 : RUN go get -u honnef.co/go/js/dom
---> Running in a1ad103c4c10
---> abd1f7f3b8b7
Removing intermediate container a1ad103c4c10
Step 9/22 : RUN go get -u -d -tags=js github.com/gopherjs/jsbuiltin
---> Running in d7dc4ec21ee1
---> cd5829fb609f
Removing intermediate container d7dc4ec21ee1
Step 10/22 : RUN go get -u honnef.co/go/js/xhr
---> Running in b4e88d0233fb
---> 3fe4d470799e
Removing intermediate container b4e88d0233fb
Step 11/22 : RUN go get -u github.com/gopherjs/websocket
---> Running in 9cebc021cb34
---> 20cd1c09d6cd
Removing intermediate container 9cebc021cb34
Step 12/22 : RUN go get -u github.com/tdewolff/minify/cmd/minify
---> Running in 9875889cc267
---> 3c60c2de51b0
Removing intermediate container 9875889cc267
Step 13/22 : RUN go get -u github.com/isomorphicgo/isokit
---> Running in eb839d91588e
---> e952d6e6cbe2
Removing intermediate container eb839d91588e
Step 14/22 : RUN go get -u github.com/uxtoolkit/cog
---> Running in 3e6853ff7196
---> 3b00f78e5acf
Removing intermediate container 3e6853ff7196
Step 15/22 : RUN go get -u github.com/EngineerKamesh/igb
---> Running in f5082861ca8a
---> 93506a92526c
Removing intermediate container f5082861ca8a
Step 16/22 : RUN go install github.com/tdewolff/minify
---> Running in b0a72d9e9807
---> e3e49d9c2898
Removing intermediate container b0a72d9e9807
Step 17/22 : RUN cd $IGWEB_APP_ROOT/client; go get ./..; /go/bin/gopherjs build -m --verbose --tags clientonly -o $IGWEB_APP_ROOT/static/js/client.min.js
---> Running in 6f6684209cfd
Step 18/22 : RUN go install github.com/EngineerKamesh/igb/igweb
---> Running in 17ed6a871db7
---> 103f12e38c04
Removing intermediate container 17ed6a871db7
Step 19/22 : RUN /go/bin/igweb --generate-static-assets
---> Running in d6fb5ff48a08
Generating static assets...Done
---> cc7434fbb94d
Removing intermediate container d6fb5ff48a08
Step 20/22 : RUN /go/bin/minify --mime="text/css" $IGWEB_APP_ROOT/static/css/igweb.css > $IGWEB_APP_ROOT/static/css/igweb.min.css
---> Running in e1920eb49cc2
---> adbf78450b9c
Removing intermediate container e1920eb49cc2
Step 21/22 : ENTRYPOINT /go/bin/igweb
---> Running in 20246e214462
---> a5f1d978060d
Removing intermediate container 20246e214462
Step 22/22 : EXPOSE 8080
---> Running in 6e12e970dfe2
---> 4c7f474b2704
Removing intermediate container 6e12e970dfe2
Successfully built 4c7f474b2704
reverseproxy uses an image, skipping
构建完成后,我们可以通过以下命令运行多容器igweb
应用:
$ docker-compose up
图 11.5是 IGWEB 作为多容器应用程序运行的截图:
图 11.5:IGWEB 作为多容器应用程序运行
当我们运行docker-compose up
命令时,该命令会提供所有运行容器的实时活动输出。要退出程序,可以使用Ctrl + C组合键。请注意,这将终止docker-compose
程序,从而以一种优雅的方式关闭运行的容器。
另外,在启动多容器igweb
应用程序时,可以指定-d
选项以在后台运行,如下所示:
$ docker-compose up -d
如果要关闭多容器应用程序,可以发出以下命令:
$ docker-compose down
如果对Dockerfile
或docker-compose.yml
文件进行进一步更改,必须再次运行docker-compose build
命令来重建服务:
$ docker-compose build
在后台运行容器的docker-compose up -d
非常方便,但现在我们知道最好将多容器 Docker 应用程序转换为systemd
服务。
设置 docker 化的 IGWEB 服务
设置 docker 化的igweb
的systemd
服务非常简单。以下是igweb-docker.service
文件的内容,应放置在生产系统的/etc/systemd/system
目录中:
[Unit]
Description=Dockerized IGWEB
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/igb/igweb
ExecStart=/usr/bin/docker-compose -f /opt/igb/igweb/docker-compose.yml up -d
ExecStop=/usr/bin/docker-compose -f /opt/igb/igweb/docker-compose.yml down
[Install]
WantedBy=multi-user.target
在[Unit]
部分,我们使用After
指令设置了值为docker.service
。这表示docker
单元必须在igweb-docker
单元之前启动。Requires
指令也设置为值为docker.service
。这表示igweb-docker
单元依赖于docker
单元成功运行。如果无法启动docker
单元,将导致无法启动igweb-docker
单元。
在[Service]
部分,我们将Type
指令设置为oneshot
。这表明我们正在启动的可执行文件是短暂的。使用它是有道理的,因为我们将使用-d
标志指定(分离模式)运行docker-compose up
,以便容器在后台运行。
我们已经在RemainAfterExit
指令中指定了Type
指令。通过将RemainAfterExit
指令设置为yes
,我们表明igweb-docker
服务即使在docker-compose
进程退出后也应被视为活动状态。
使用ExecStart
指令,我们以分离模式启动docker-compose
进程。我们已经指定了ExecStop
指令,以指示停止服务所需的命令。
在[Install]
部分,通过将WantedBy
指令的值设置为multi-user.target
,我们指定了该服务在系统运行级别 3(多用户模式)下运行。
请记住,在将igweb-docker.service
文件放置在/etc/systemd/system
目录后,我们必须像这样重新加载systemd
守护程序:
$ systemctl daemon-reload
现在,我们可以启动 docker 化的igweb
应用程序:
$ systemctl start igweb-docker
您可以使用systemctl enable
命令指定igweb-docker
应该在系统启动时启动。
通过发出以下命令,我们可以关闭服务:
$ systemctl stop igweb-docker
到目前为止,我们已经演示了如何将igweb
应用程序作为托管在 Linode 云上的多容器 Docker 应用程序运行。再次强调,虽然我们使用的是 Linode,但我们演示的过程可以在您选择的首选云提供商上复制。
总结
在本章中,我们学习了如何将等同构 Web 应用程序部署到云上。我们介绍了igweb
服务器端应用程序在生产模式下的运行方式,向您展示了应用程序如何包含外部 CSS 和 JavaScript 源文件。我们还向您展示了如何控制 GopherJS 生成的 JavaScript 程序的文件大小。我们向您展示了如何为应用程序的模板包生成静态资产,以及要部署的齿轮使用的 JavaScript 和 CSS。
我们首先考虑了将等跨服务器部署等同构 Web 应用程序。这包括向服务器添加igweb
用户,设置redis-server
实例,使用启用了 GZIP 压缩的nginx
作为反向代理,并设置igweb
根文件夹。我们还向您展示了如何从开发系统(64 位 macOS)交叉编译 Go 代码到运行在生产系统上的操作系统(64 位 Linux)。我们指导您准备部署包的过程,然后部署包到生产系统。最后,我们向您展示了如何将igweb
设置为systemd
服务,以便可以轻松地启动、停止、重新启动,并在系统启动时自动启动。
然后,我们将注意力集中在将等同构 Web 应用程序部署为多容器 Docker 应用程序。我们向您展示了如何在生产系统上安装 Docker。我们带您完成了 dockerizing igweb
的过程,其中包括创建Dockerfile
,在docker-compose.yml
文件中定义组成 IGWEB 的服务,并运行docker-compose up
命令以将 IGWEB 作为多容器 Docker 应用程序启动。最后,我们向您展示了如何设置igweb-docker systemd
脚本来管理igweb
作为系统服务。
第十二章:调试同构 Go
调试同构 Go Web 应用程序包括以下内容:
-
识别编译器/转译器错误
-
检查恐慌堆栈跟踪
-
追踪代码以准确定位问题的来源
识别编译器/转译器错误
我们可以将编程看作是您(程序员)和机器(编译器/转译器)之间的对话。由于 Go 是一种类型化语言,我们可以在编译/转译时发现许多错误。这是编写纯 JavaScript 的明显优势,因为问题(由于缺乏类型检查而引起)可能隐藏并在最不合适的时候出现。编译器错误是机器向我们传达程序出现基本问题的手段,无论是纯粹的语法问题还是类型的不当使用。
kick
非常方便地用于显示编译器错误,因为它会显示 Go 编译器和 GopherJS 转译器的错误。一旦引入错误(编译器/转译器可以识别的错误)并保存源文件,您将在运行kick
的终端窗口中看到错误显示。
例如,让我们打开client/client.go
源文件。在run
函数中,让我们注释掉设置ts
变量为通过templateSetChannel
接收到的TemplateSet
对象的那一行:
//ts := <-templateSetChannel
我们知道ts
变量将在稍后用于填充env
对象的TemplateSet
字段。让我们通过引入以下代码将ts
变量设置为false
的布尔值:
ts := false
我们保存client.go
源文件的同时,kick
将立即给我们一个踢(双关语),关于我们刚刚引入的错误,如图 A1所示:
图 A1:保存 Go 源文件后,kick 命令立即显示给我们转译器错误
收到的编译器错误向我们显示了问题发生的确切行,从中我们可以诊断和纠正问题。从这个例子中可以学到的教训是,在开发同构 Go Web 应用程序时,同时在后台运行kick
的终端窗口非常方便。通过这样做,您将能够在引入错误时立即看到编译器/转译器错误。
检查恐慌堆栈跟踪
对于在转译时无法由转译器找到的运行时错误,通常会有一个有用的恐慌堆栈跟踪,它显示在 Web 浏览器的控制台中,并为我们提供了有价值的信息来诊断问题。GopherJS 生成的 JavaScript 源映射文件帮助 Web 浏览器将 JavaScript 指令映射到 Go 源文件中的相应行。
让我们引入一个运行时错误,即我们的客户端程序在语法上是正确的(它将通过转译器检查),但是代码在运行时会出现问题。
回到client/client.go
源文件中的run
函数,注意我们对ts
变量所做的以下代码更改:
func run() {
println("IGWEB Client Application")
// Fetch the template set
templateSetChannel := make(chan *isokit.TemplateSet)
funcMap := template.FuncMap{"rubyformat": templatefuncs.RubyDate, "unixformat": templatefuncs.UnixTime, "productionmode": templatefuncs.IsProduction}
go isokit.FetchTemplateBundleWithSuppliedFunctionMap(templateSetChannel, funcMap)
// ts := <-templateSetChannel
env := common.Env{}
// env.TemplateSet = ts
env.TemplateSet = nil
env.Window = dom.GetWindow()
env.Document = dom.GetWindow().Document()
env.PrimaryContent = env.Document.GetElementByID("primaryContent")
env.Location = env.Window.Location()
registerRoutes(&env)
initializePage(&env)
}
我们已经注释掉了ts
变量的声明和初始化,并且还注释掉了将ts
变量分配给env
对象的TemplateSet
字段的赋值。我们引入了一行代码,将nil
值赋给env
对象的TemplateSet
字段。通过采取这种行动,我们基本上禁用了客户端模板集,这将阻止我们能够在客户端渲染任何模板。这也阻止了任何齿轮的渲染,因为齿轮依赖于模板集来正常运行。
加载 IGWEB 主页后,会生成一个恐慌堆栈跟踪,并在 Web 浏览器的控制台中显示,如图 A2所示:
图 A2:Web 浏览器控制台中显示的恐慌堆栈跟踪
在您的前端调试过程中,您经常会遇到以下错误消息:
client.js:1412 Uncaught Error: runtime error: invalid memory address or nil pointer dereference
运行时错误:无效的内存地址或空指针解引用通常意味着我们试图对一个值(例如访问或改变属性)执行操作,该值等于 JavaScript 的null
值。
检查产生的 panic 堆栈跟踪有助于我们准确定位问题:
Uncaught Error: runtime error: invalid memory address or nil pointer dereference
at $callDeferred (client.js:1412)
at $panic (client.js:1451)
at throw$1 (runtime.go:219)
at Object.$throwNilPointerError (client.js:29)
at Object.$packages.github.com/isomorphicgo/isokit.TemplateSet.ptr.Members (templateset.go:37)
at Object.$packages.github.com/isomorphicgo/isokit.TemplateSet.ptr.Render (templateset.go:115)
at Object.$packages.github.com/uxtoolkit/cog.UXCog.ptr.RenderCogTemplate (uxcog.go:143)
at Object.$packages.github.com/uxtoolkit/cog.UXCog.ptr.Render (uxcog.go:179)
at Object.$packages.github.com/EngineerKamesh/igb/igweb/shared/cogs/carousel.Carousel.ptr.Start (carousel.go:47)
at Object.InitializeIndexPage (index.go:31)
at initializePage (client.go:45)
at run (client.go:100)
at main (client.go:112)
at $init (client.js:127543)
at $goroutine (client.js:1471)
at $runScheduled (client.js:1511)
at $schedule (client.js:1527)
at $go (client.js:1503)
at client.js:127554
at client.js:127557
panic 堆栈跟踪中感兴趣的区域以粗体显示。从 panic 堆栈跟踪中,我们可以确定轮播齿轮未能渲染,因为TemplateSet
似乎出了问题。通过进一步检查 panic 堆栈跟踪,我们可以确定在client.go
源文件的第 112 行调用了run
函数。run
函数是我们通过将env
对象的TemplateSet
字段设置为nil
而引入错误的地方。通过这次调试练习,我们可以看到在这种情况下,panic 堆栈跟踪没有揭示问题的确切行,但它为我们提供了足够的线索来纠正问题。
在客户端开发时要遵循的一个好习惯是始终保持网页浏览器的控制台打开,这样您就可以在问题发生时看到问题。
追踪代码以准确定位问题的来源
另一个良好的客户端调试实践是追踪,即在程序流程中打印关键步骤的实践。在调试场景中,这将包括在疑似有问题的代码区域周围有策略地调用println
(或fmt.Println
)函数。您可以使用网页浏览器的控制台验证是否达到了这些打印语句,这将让您更好地了解客户端程序在运行时的运行情况。
例如,在调试上一节中引入的问题时,我们可以在run
函数中放置以下println
调用:
func run() {
//println("IGWEB Client Application")
println("Reached the run function")
// Fetch the template set
templateSetChannel := make(chan *isokit.TemplateSet)
funcMap := template.FuncMap{"rubyformat": templatefuncs.RubyDate, "unixformat": templatefuncs.UnixTime, "productionmode": templatefuncs.IsProduction}
go isokit.FetchTemplateBundleWithSuppliedFunctionMap(templateSetChannel, funcMap)
// ts := <-templateSetChannel
println("Value of template set received over templateSetChannel: ", <-templateSetChannel)
env := common.Env{}
// env.TemplateSet = ts
env.TemplateSet = nil
env.Window = dom.GetWindow()
env.Document = dom.GetWindow().Document()
env.PrimaryContent = env.Document.GetElementByID("primaryContent")
env.Location = env.Window.Location()
println("Value of template set: ", env.TemplateSet)
registerRoutes(&env)
initializePage(&env)
}
我们通过在程序流程中打印关键步骤,通过进行策略性的println
函数调用来进行追踪。第一个println
调用用于验证我们是否到达了run
函数。第二个println
调用用于检查从模板集通道返回给我们的模板集的健康状况。第三个,也是最后一个println
调用,用于检查我们填充env
对象的字段后模板集的健康状况。
图 A3显示了网页控制台显示的打印语句,以及在client.go
源文件中进行println
调用的相应行号:
图 A3:网页控制台显示的打印语句
通过追踪练习,我们首先可以验证我们已成功到达run
函数。其次,我们可以通过注意对象的属性是否出现(例如members
,Funcs
和bundle
)来验证通过templateSetChannel
接收到的TemplateSet
对象的健康状况。第三个,也是最后一个打印语句,还验证了env
对象准备就绪后TemplateSet
对象的健康状况。此打印语句通过显示TemplateSet
对象未初始化的情况,揭示了问题的来源,因为我们在打印语句中看不到对象的任何属性。