同构的-Go-应用(全)

同构的 Go 应用(全)

原文:zh.annas-archive.org/md5/70B74CAEBE24AE2747234EE512BCFA98

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

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想象成一个更简单、更轻量级的npmnpm是 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 应用程序架构

随着XMLHttpRequestXHR)对象的出现,异步 JavaScript 和 XMLAJAX)时代开始了。图 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 clientthin 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 应用架构中发现的所有缺点提供了解决方案。让我们盘点一下我们在愿望清单上放置的项目:

  1. 为了增强用户体验,在网站上点击链接不应导致全页重新加载。

  2. 为了增加可维护性,应该有一个单一、统一的项目代码库,使用单一编程语言实现。

  3. 为了提高效率,应该有一种分布式模板渲染的机制。

  4. 为了提高生产力,应该有一种方式在不同环境中共享和重用代码,以避免重复劳动。

  5. 为了给出最好的第一印象,网站应该能够迅速向用户显示内容。

  6. 为了提高可发现性,网站应提供易于搜索引擎机器人消费的格式良好的 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 将包括以下步骤:

  1. 安装 Go

  2. 设置您的 Go 工作区

  3. 构建和运行程序

安装 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 包括以下步骤:

  1. 安装 GopherJS

  2. 安装必要的 GopherJS 绑定

  3. 在命令行上熟悉 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.jsclient.js.mapclient.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 的解决方案的不同之处在于,在执行即时启动时,它考虑了 gogopherjs 命令。它还考虑了对模板文件的更改,使其成为同构 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/goclient/tests/jsclient/tests/screenshotsgo子文件夹包含 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函数,并注意当在datastoreTypeswitch块中遇到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对象的DBTemplateSet字段赋值(赋值以粗体显示)。出于说明目的,我们省略了一些代码,并在此处显示了部分代码清单:

  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对象的引用作为此函数的唯一输入参数提供(以粗体显示)。此时,我们已成功传播了RedisDatastoreTemplateSet实例,并使它们可用于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.Globaldom包的调用起别名来节省一些输入,就像这样:

对于 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源文件中定义的ShowIsomorphicGopherHideIsomorphicGopher函数:

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

ShowIsomorphicGopherHideIsomorphicGopher函数都调用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>

我们声明了一个idbultinDemoButton的按钮。现在,让我们在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文本字段分配了一个idtextToLowercase。然后我们声明一个带有idlowercaseTransformButton的按钮。当点击此按钮时,我们将启动一个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.ResponseWriterw上调用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函数内部,我们创建了nanoambassadoromni——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函数,传入buffcars切片的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-dataURL。我们调用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对象执行一些常见操作。这些操作包括以下内容:

  1. 设置键值对

  2. 获取给定键的值

  3. 获取所有键值对

  4. 清除所有条目

在下一节中,我们将向您展示如何使用 GopherJS 执行相同的操作,以一个完全充实的示例。

设置键值对

要将项目存储到本地存储中,我们调用localStorage对象的setItem方法,并将键和值作为参数传递给该方法:

localStorage.setItem("foo", "bar"); 

在这里,我们提供了一个"foo"键,带有一个"bar"值。

获取给定键的值

要从本地存储中获取项目,我们调用localStorage对象的getItem方法,并将键作为该方法的单个参数传递:

var x = localStorage.getItem("foo");

在这里,我们提供了"foo"键,并且我们期望x变量的值将等于"bar"

获取所有键值对

我们可以使用for循环从本地存储中检索所有键值对,并使用localStorage对象的keygetItem方法访问键和值的值:

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模板。您将在第四章中了解更多关于渲染同构模板的内容,同构模板

实现客户端功能

实现本地存储检查器的客户端功能包括以下步骤:

  1. 初始化本地存储检查器网页

  2. 实现本地存储检查器

初始化本地存储检查器网页

为了初始化本地存储检查器网页上的事件处理程序,我们需要在client.go源文件中的initializePage函数内的localstorage-demo情况下添加以下代码行:

localstoragedemo.InitializePage()

调用localstoragedemo包中定义的InitializePage函数将为保存和清除所有按钮添加事件监听器。

实现本地存储检查器

本地存储检查器的实现可以在client/localstoragedemo目录中的localstorage.go源文件中找到。

import分组中,我们包括了jsdom包(以粗体显示):

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函数,并将itemKeyitemValue的值作为输入参数传递给函数。

然后,我们将itemKeyitemValueValue属性都设置为空字符串,以清除输入文本字段,这样用户可以轻松地在以后添加新条目而无需手动清除这些字段中的文本。

最后,我们调用DisplayStorageContents函数,该函数负责显示本地存储中的所有当前条目。

让我们来看看SetKeyValuePair函数:

func SetKeyValuePair(itemKey string, itemValue string) {
  localStorage.Call("setItem", itemKey, itemValue)
}

在这个函数内部,我们只需调用localStorage对象的setItem方法,将itemKeyitemValue作为输入参数传递给函数。此时,键值对条目将保存到 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对象的keygetItem方法分别获取itemKeyitemValue

我们使用dt元素(dtElement)来显示键。dt元素用于定义描述列表中的术语。我们使用dd元素(ddElement)来显示值。dd元素用于描述描述列表中的术语。使用描述列表及其相关元素来显示键值对,我们使用了一种语义友好的方法来在网页上显示键值对。我们通过调用其AppendChild方法将dtdd元素附加到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 线框设计

通过将网页结构组织成这些个别区域,我们可以划分出每个区域在整个网页结构中所扮演的独特功能。让我们继续检查构成页面结构的每个个别区域。

  1. 页眉

  2. 主要内容区域

  3. 页脚

页眉

图 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 &copy; 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函数。我们创建一个包含此映射的模板函数映射;我们将在本章后面介绍如何在不同环境中使用模板函数映射。

templatestemplatedatatemplatefuncs这三个子文件夹位于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类型值的mapitems映射起着重要作用,它是将在服务器端进行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"

}

WebAppRootWebServerPort变量分别从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包的WebAppRootTemplateFilesPathStaticAssetsPath变量的导出变量。通过调用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.mapmap文件。我们注册了一个/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值是否为interactivecompleteinteractive状态表示文档已经完成加载,但可能还有一些资源,如图像或样式表,尚未完全加载。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对象。我们将创建一个包含rubyformatunixformat自定义函数的函数映射。然后,我们将从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对象的WindowDocument属性填充为它们各自的值:

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函数中,有两个特别感兴趣的任务:

  1. 创建客户端路由

  2. 注册客户端路由

创建客户端路由

首先,我们将创建isokit路由对象的新实例,并将其分配给r变量:

 r := isokit.NewRouter()

注册客户端路由

第二行代码注册了客户端/about路由,以及与之关联的客户端处理函数AboutHandler,来自handlers包。

 r.Handle("/about", handlers.AboutHandler(env))

我们将在第五章中更详细地介绍registerRoutes函数的其余部分,端到端路由

初始化网页上的交互元素

initializePage函数将在网页首次加载时调用一次。它的作用是初始化使用户能够与客户端 Web 应用程序进行交互的功能。这将是给定网页的相应initialize函数,负责初始化事件处理程序和可重用组件(齿轮)。

initializePage函数内部,我们将从窗口位置对象的PathName属性中提取routeNamehttp://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字段代表产品的价格,类型为float64Route字段是给定产品的服务器相对路径到产品详细页面。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}路由。在定义所有路由之后,我们调用路由器对象rListen方法。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,并设置PageTitleProducts字段的相应字段。之后,我们在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函数,提供productChannelproductTitle作为函数的输入参数。请注意,我们将函数作为 goroutine 调用,成功运行函数后,我们将通过productChannel发送一个产品对象。

我们设置模板数据对象,为PageTitleProduct字段指定值。然后我们将页面标题设置为产品名称。完成后,我们调用模板集对象的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.DBGetProducts方法来获取将在客户端产品页面上显示的产品切片。然后,我们设置一个标头来指示服务器响应将以 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.DBGetProductDetail方法,以从数据存储中检索相应的Product对象。我们设置一个标头来指示服务器响应将以 JSON 格式返回,并使用 JSON 编码器将Product对象编码为 JSON 数据,然后使用http.ResponseWriter w将其发送到 Web 客户端。

我们现在已经达到了一个重要的里程碑。我们以一种对人类和机器都友好的方式实现了与产品相关的页面。当用户最初访问产品列表页面时,通过在 Web 浏览器中输入 URL(http://localhost:8080/products),页面在服务器端呈现,并将 Web 页面响应发送回客户端。用户能够立即看到网页,因为网页响应是预先呈现的。这种行为展现了经典 Web 应用程序架构的期望特征。

当人类用户发起后续交互时,通过单击产品项目,产品详细页面将从客户端呈现,并且用户无需经历完整页面重新加载。这种行为展现了 SPA 架构的期望特征。

机器用户(搜索引擎爬虫)也满意,因为他们可以遍历产品页面上的每个产品项目的链接并轻松索引网站,因为我们使用了语义化的 URL 以及搜索引擎爬虫可以理解的良好形式的 HTML 标记。

验证客户端路由功能

为了确保客户端路由正常运行,您可以执行以下过程:

  1. 在您的 Web 浏览器中访问产品页面并打开 Web 浏览器的检查器。

  2. 点击网络选项卡以查看网络流量,并确保过滤 XHR 调用。现在,点击产品项目以进入产品的详细页面。

  3. 通过点击导航菜单上的“产品”链接返回产品页面。

重复此过程多次,您应该能够看到后台进行的所有 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 策略

同构交接程序包括以下四个步骤:

  1. 编码

  2. 注册

  3. 解码

  4. 附加

我们可以使用缩写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 &lt; 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 &lt; 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 := &lt;-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 &lt;- 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 := &lt;-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 &lt;- 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源文件中:

&lt;h1&gt;Shopping Cart&lt;/h1&gt;

{{if .Products }}
{{range .Products}}
  &lt;div class="productCard"&gt;
    &lt;a href="{{.Route}}"&gt;
    &lt;div class="pricebox"&gt;&lt;span&gt;${{.Price}}&lt;/span&gt;&lt;/div&gt;
    &lt;div class="productCardImageContainer"&gt;
      &lt;img src="img/{{.ThumbnailPreviewURI}}"&gt;
    &lt;/div&gt;
    &lt;/a&gt;
    &lt;div class="productContainer"&gt;

    &lt;h3&gt;&lt;b&gt;{{.Name}}&lt;/b&gt;&lt;/h3&gt; 

    &lt;p&gt;{{.Description}}&lt;/p&gt; 

    &lt;div class="productQuantity"&gt;&lt;span&gt;Quantity: {{.Quantity}}&lt;/span&gt;&lt;/div&gt;

    &lt;div class="pure-controls"&gt;
      &lt;button class="removeFromCartButton pure-button pure-button-primary" data-sku="{{.SKU}}"&gt;Remove From Cart&lt;/button&gt;
    &lt;/div&gt;

    &lt;/div&gt;
  &lt;/div&gt;

{{end}}
{{else}}
&lt;h2&gt;Your shopping cart is empty.&lt;/h2&gt;
{{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) &gt; 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.ResponseWriterw,并将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 := &lt;-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 &lt;- 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模板,这将打印生成页面的doctypehtmlbody标记的标记。这个模板将专门在服务器端使用。

使用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 方法,FieldsErrorsFormParamsPrefillFields,都有相应的 setter 方法,SetFieldsSetErrorsSetFormParamsSetPrefillFields

实现联系表单

现在我们知道表单接口的样子,让我们开始实现联系表单。在我们的导入分组中,请注意我们包括了验证包和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实例cSetFields方法,并传递fields变量。

我们调用SetFieldsSetErrors方法,并分别传入fieldserrors变量。我们调用cSetFormParams方法来设置传入构造函数的表单参数。最后,我们返回新的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函数处理,将接受使用GETPOST方法的 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实例,提供ResponseWriterRequest字段的值:

    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对象contactFormValidate方法的结果的值。

如果validationResult的值为 true,表单验证成功。我们调用submissions包中的ProcessContactForm函数,传入env对象和ContactForm对象。ProcessContactForm函数负责处理成功的联系表单提交。然后我们调用DisplayConfirmation函数,传入env对象,http.ResponseWriterw*http.Requestr

如果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.DBCreateContactRequest方法,并将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 调用,传入通道contactFormErrorsChannelcontactForm对象。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文件夹中。

既然我们已经制定了使用大猩猩网络聊天示例作为起点来实现实时聊天功能的行动计划,让我们开始实施吧。

我们将创建的修改后的网络聊天应用程序包含两种主要类型:HubClient

中心类型

聊天中心负责维护客户端连接列表,并指示聊天机器人向相关客户端广播消息。例如,如果 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,表示客户端已连接。客户端通过broadcastmsgregisterunregister通道与中心通信。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来注册新客户端。我们将调用chatbotGreeting方法获取要返回给客户端的greeting消息。一旦我们得到了问候语(字符串值),我们调用SendMessage方法,传入client和转换为byte切片的greeting

如果通过 hub 的unregister通道传入了一个Client的指针,hub 将删除给定clientmap中的条目,并关闭客户端的send通道,这表示该client不会再向服务器发送任何消息。

如果通过 hub 的broadcastmsg通道传入了一个ClientMessage的指针,hub 将把客户端的message(作为字符串值)传递给chatbot对象的Reply方法。一旦我们得到了来自代理的reply(字符串值),我们调用SendMessage方法,传入client和转换为byte切片的reply

客户端类型

Client类型充当Hubwebsocket连接之间的代理。

以下是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 方法:SetNameSetTitleSetThumbnailPath

通过定义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对象的nametitlethumbnailPath字段。

这是用于创建新的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实例,设置nametitlethumbnailPath字段。然后我们调用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中重复出现的单词,如theandweb,将具有较小的权重。转换器是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)

现在我们已经准备好了lsiqueryVector,是时候找到最匹配查询术语的文档了。我们通过计算我们语料库中每个文档与查询的余弦相似度来实现这一点:

  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对象。请注意,chatbotbot.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.WebSocketws变量和类型为map[string]stringagentInfo变量:

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 对象wsSend方法,将用户的问题发送到 Web 服务器。然后调用UpdateChatBox函数将用户的消息呈现到聊天框的对话容器中。我们将env对象、用户编写的messagesender字符串作为输入值传递给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 树进行协调。这允许cogreactive,意味着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包含两个重要的导出变量,ReactivityEnabledVDOMEnabled。这两个导出变量都是bool类型,默认情况下都设置为true

当变量ReactivityEnabled设置为true时,cogs 将在其 props 更改时重新渲染。如果ReactivityEnabled设置为false,则必须显式调用cogRender方法来重新渲染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 的类型定义中嵌入UXCogUXCog类型的以下方法(为简洁起见,仅呈现方法签名)对我们来说特别重要:

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。cogdiv容器被称为其挂载点

SetID方法是一个 setter 方法,用于设置 DOM 中cogdiv容器的 ID。

CogInit方法用于将cog与应用程序的TemplateSet对象关联起来。该方法有两个重要目的。首先,该方法用于在服务器端注册cog,以便所有给定cog的模板都包含在由isokit内置的静态资产捆绑系统生成的模板包中。其次,在客户端调用cogCogInit方法允许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 的包名称匹配。例如,对于 cogwidget,模板源文件的名称必须是 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 设置为值barProps映射将自动用作传入 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。我们必须调用cogCogInit方法,将应用程序的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容器的解析树。如果我们通过调用cogSetProp方法更新了"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方法在cogProps map中设置 prop。

请注意,在调用齿轮的Start方法之前调用齿轮的SetProp方法将不会渲染cog。只有在通过调用其Start方法将cog呈现到挂载点后,才会在调用其SetProp方法后重新呈现cog

调用CogStart方法将激活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实例,并立即调用cogCogInit方法,将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时,我们必须为cogdiv容器提供一个 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 cogdiv容器。

现在我们已经看到了如何在关于页面上声明cogdiv容器,让我们来看看如何实现时间过去的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 cogstruct

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 时间戳值。回想一下,我们使用自定义模板函数unixformatstarttimeunix自定义数据属性填充为地鼠的开始时间的 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只更新一次时间,即在调用cogStart方法时,很难欣赏到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.CleanupSetCleanUp方法包含在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!")
  }

这些值用于初始化位置变量。如果这些属性中的任何一个未提供,将返回一个错误。

如果这两个属性都存在,我们继续将实时时钟cogticker属性分配给一个新创建的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,我们首先获取本地区域名称和本地时区偏移量。然后我们创建一个名为lcLiveClock 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 源文件放在cogstatic文件夹中的jscss文件夹中(分别)。

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 的功能请求。我们创建了一个名为DatePickerParamsstruct,它作为日期选择器小部件的输入参数:

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 的字段一样,我们还为FirstDayfirstDay)、MinDateminDate)、MaxDatemaxDate)和YearRangeyearRange)声明了字段。

阅读 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方法设置datePickerLabeldatepickerInputIDdatepickerMinDatedatepickerMaxDate属性。我们调用 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.cssstyles.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的用户是否设置了contentItemscarouselContentIDprops。contentItemsprop 是应该出现在轮播图中的图像的服务器相对路径的字符串切片。carouselContentIDprop 是包含轮播图内容的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属性提供了一个字符串切片,使用PLAYTEXTSTOPTEXT常量分别表示播放和停止按钮的文本符号。我们将Controls属性设置为false,这样默认情况下图像轮播中将不会出现上一个和下一个按钮。

我们继续迭代cog的用户提供的所有属性,访问每个属性,包括propNamestring)和propValueinterface{}):

 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创建一个新的轮播cogc。我们调用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/cssshared/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是事件驱动的。例如,当从客户端应用程序的任何页面触发自定义成功通知事件时,将显示成功通知。我们定义了两个字段,successNotificationEventListenererrorNotificationEventListener,它们都是函数,以 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实例,因此我们返回一个指示无法启动通知cogerror

如果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。然后我们调用cognotifySuccess方法来在网页上显示成功通知消息。

我们遵循类似的程序来设置displayErrorNotification的事件监听器。我们将事件监听器函数分配给cogerrorNotificationEventListener属性。我们从event对象中提取detail属性,并将其分配给message变量。我们调用cognotifyError方法来在网页上显示错误通知消息。

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方法来移除处理displaySuccessNotificationdisplayErrorNotification自定义事件的事件监听函数。

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 表单,可以被有更高辅助功能需求的用户访问。我们需要确保联系表单的验证功能正常运行,并且我们可以成功发送有效的联系表单提交。

因此,在服务器端,我们将测试的基本功能包括以下项目:

  1. 验证服务器端路由和模板呈现

  2. 验证联系表单的验证功能

  3. 验证成功的联系表单提交

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,对于每次迭代,我们将routeexpectedString传递给checkRoute函数。

checkRoute函数负责访问给定路由,读取其响应正文并验证expectedString是否存在于响应正文中。有三种情况可能导致测试失败:

  1. 当无法连接到路由 URL 时

  2. 如果无法读取从服务器检索到的响应正文

  3. 如果从服务器返回的网页响应中不存在预期的字符串令牌

如果发生这三种错误中的任何一种,测试将失败。否则函数将正常返回。

我们可以通过发出以下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方法填充表单字段值。请注意,我们为firstNamelastNamemessageBody字段提供了空的string值。我们还为email字段提供了格式不正确的电子邮件地址。

我们使用http包中找到的NewRequest函数使用 HTTP POST 请求提交表单。

我们创建一个http.Clienthc,并通过调用它的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 端点,因为页面内容只需要渲染模板。一个这样的例子是当用户通过点击导航栏中的联系链接访问联系表单时。在这种情况下,我们只需渲染联系表单模板,并在主要内容区域显示内容。

让我们花点时间思考一下我们需要在客户端测试的所有基本功能。我们需要验证客户端路由是否正常运行,并且对于每个路由都会呈现正确的页面,类似于我们在上一节中验证服务器端路由的方式。除此之外,我们还需要确认客户端表单验证对联系表单是否有效,并测试有效表单提交的情况。目前,添加和移除购物车中物品的功能仅在客户端实现。这意味着我们必须编写测试来验证此功能是否按预期工作。目前仅在客户端可用的另一个功能是实时聊天功能。我们必须验证用户能否与实时聊天机器人进行通信,机器人是否回复,并且在用户导航到网站的不同部分时,对话是否保持。

最后,我们必须测试我们的齿轮集合。我们必须确保时间齿轮以人类可理解的格式显示时间实例。我们必须验证实时时钟齿轮是否正常运行。我们必须验证当点击时间敏感日期字段时,日期选择器齿轮是否出现。我们必须验证主页上是否出现了轮播齿轮。最后,我们必须验证当向购物车中添加和移除物品时,通知齿轮是否正确显示通知。

因此,在客户端,我们将测试的基线功能包括以下项目:

  1. 验证客户端路由和模板呈现

  2. 验证联系表单

  3. 验证购物车功能

  4. 验证实时聊天功能

  5. 验证时间齿轮

  6. 验证实时时钟齿轮

  7. 验证日期选择器齿轮

  8. 验证轮播齿轮

  9. 验证通知齿轮

为了在客户端执行自动化测试,包括用户交互,我们需要一个内置 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 毫秒的延迟,以便页面内容有足够的时间显示在网页上。

请注意,每当调用caspertester模块中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实例,并填充了FirstNameLastNameEmailMessageBody字段。特别注意,我们为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实例,并注意到我们为LastNameMessageBody字段提供了空的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函数,传入localZonenamelocalOffset来获取位置。我们创建一个新的时区实例,并使用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 在生产模式下运行时将发生三种重要的行为:

  1. 在头部部分模板中包含客户端应用程序的 JavaScript 外部<script>标签将请求位于$IGWEB_APP_ROOT/static/js/client.min.js的缩小 JavaScript 源文件。

  2. 当 Web 服务器实例启动时,cogs(cogimport.csscogimport.js)的静态资产将不会自动生成。相反,包含捆绑静态资产的缩小源文件将分别位于$IGWEB_APP_ROOT/static/css/cogimports.min.css$IGWEB_APP_ROOT/static/js/cogimports.min.js

  3. 与依赖于$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.jsclient.min.js)。回想一下,在运行kick的开发环境中,client.jsclient.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 实例与三个关键组件igwebnginxredis-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 二进制文件,此链接将为您提供所有可能的 GOOSGOARCH 值的完整列表:golang.org/doc/install/source#environment

准备部署包

除了发布 igweb 可执行文件,我们还需要发布存放所有 IGWEB 静态资产的静态文件夹的内容。

准备部署包的静态资产包括以下步骤:

  1. 转换客户端应用程序

  2. 生成静态资产包(模板包、CSS 和 JavaScript)

  3. 缩小 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]部分用于指定服务的配置。USERGROUP指令指定命令应该以什么用户和组身份运行。我们使用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 应用程序是一个三步过程:

  1. 从中可以创建一个 IGWEB docker 镜像的Dockerfile

  2. docker-compose.yml文件中定义组成 IGWEB 的服务

  3. 运行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

如果对Dockerfiledocker-compose.yml文件进行进一步更改,必须再次运行docker-compose build命令来重建服务:

$ docker-compose build

在后台运行容器的docker-compose up -d非常方便,但现在我们知道最好将多容器 Docker 应用程序转换为systemd服务。

设置 docker 化的 IGWEB 服务

设置 docker 化的igwebsystemd服务非常简单。以下是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函数。其次,我们可以通过注意对象的属性是否出现(例如membersFuncsbundle)来验证通过templateSetChannel接收到的TemplateSet对象的健康状况。第三个,也是最后一个打印语句,还验证了env对象准备就绪后TemplateSet对象的健康状况。此打印语句通过显示TemplateSet对象未初始化的情况,揭示了问题的来源,因为我们在打印语句中看不到对象的任何属性。

posted @ 2024-05-04 22:37  绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报