Go-全栈开发实用指南-全-
Go 全栈开发实用指南(全)
原文:
zh.annas-archive.org/md5/35b3d704c72006256ff85e7d951247c8译者:飞龙
前言
Go 编程语言已被开发者迅速采用,用于构建 Web 应用程序。凭借其令人印象深刻的性能和易于开发的特点,Go 语言得到了各种开源框架的支持,用于构建可扩展和高性能的 Web 服务和应用程序。本书是一本全面的指南,涵盖了使用 Go 语言进行全栈开发的各个方面。随着你在本书中的学习进展,你将逐步从头开始构建一个在线乐器商店应用程序。
这本内容清晰、示例丰富的书籍从对 Go 开发的实际接触开始,涵盖了 Go 的构建块以及 Go 的强大并发特性。然后,我们将探索流行的 React 框架,并使用它来构建应用程序的前端。从那里,你将使用 Gin 框架构建 RESTful Web API,Gin 是一个非常强大且流行的 Go 框架,用于 Web API。之后,我们将深入探讨重要的软件后端概念,例如使用 对象关系映射(ORM)连接数据库、为你的服务设计路由、保护你的服务,甚至使用流行的 Stripe API 来处理信用卡支付。我们还将介绍如何在生产环境中高效地测试和基准测试你的应用程序。在结论章节中,我们将通过学习 GopherJS 来介绍纯 Go 中的同构开发。
在阅读完本书之后,你将自信地承担使用 Go 语言构建全栈 Web 应用程序的任务。
这本书面向谁
本书将吸引那些计划开始使用 Go 语言构建功能齐全的全栈 Web 应用程序的开发者。本书假定读者具备 Go 语言和 JavaScript 的基本知识。也期望读者对 HTML 和 CSS 有一定的了解。本书的目标读者是那些希望转向 Go 语言的 Web 开发者。
本书涵盖的内容
第一章,欢迎来到全栈 Go,介绍了本书涵盖的主题,以及本书将构建的应用程序架构概述。本章还为我们提供了对本书内容的预期一瞥。
第二章,Go 语言构建块,介绍了 Go 语言的构建块,以及如何利用它们构建简单应用程序。它从介绍 Go 的变量、条件语句、循环和函数的语法开始。然后,它涵盖了如何在 Go 中构建数据结构,以及如何将方法附加到它们上。最后,它通过学习如何编写可以描述我们程序行为的接口来结束。
第三章,Go 并发,涵盖了 Go 语言的并发特性。它涵盖了 Go 的并发原语,如 goroutines、channels 和 select 语句,然后转向涵盖一些对生产并发软件重要的概念,如锁和等待组。
第四章,使用 React.js 的前端开发,涵盖了极受欢迎的 React.js 框架的构建块。它首先查看 React 组件,这是 React 框架的基础。从那里,它涵盖了用于向组件传递数据的 props。最后,它描述了处理状态和使用开发者工具。
第五章,构建 GoMusic 的前端,使用上一章获得的知识构建 GoMusic 的前端。它构建了 GoMusic 商店所需的 React 组件,并利用 React 的开发者工具来调试前端。本章将涵盖大部分前端代码。
第六章,使用 Gin 框架的 Go 语言 RESTful Web APIs,为您介绍了 RESTful Web APIs 和 Gin 框架。接着深入探讨了 Gin 的关键构建块,并解释了如何使用它开始编写 Web APIs。它还涵盖了如何在 Gin 中执行 HTTP 请求路由以及如何分组 HTTP 请求路由。
第七章,使用 Gin 和 React 的高级 Web Go 应用,讨论了关于 Gin Web 框架和我们的后端 Web API 的更高级主题。它涵盖了重要的实用主题,例如如何通过使用中间件扩展功能、如何实现用户认证、如何附加日志以及如何向我们的模型绑定添加验证。它还涵盖了 ORM 的概念,以及如何通过 Go ORM 将我们的 Web API 后端连接到 MySQL 数据库。最后,它将涵盖一些剩余的 React 代码,然后我们将讨论如何将 React 应用程序连接到我们的 Go 后端。本章还涵盖了如何构建和部署 React 应用程序到生产环境。
第八章,测试和基准测试您的 Web API,讨论了如何测试和基准测试 Go 应用程序。它将帮助我们了解可以使用测试包使用的类型和方法,以创建可以与代码集成的单元测试。然后它将专注于学习如何基准测试我们的代码以检查其性能。
第九章,使用 GopherJS 介绍同构 Go,涵盖了 GopherJS,这是一个非常流行的开源项目,它帮助将 Go 代码转换为 JavaScript,从而实际上允许我们用 Go 编写前端代码。如果你想在 Go 而不是 JavaScript 中编写前端,GopherJS 是这样做的方法。本章将讨论 GopherJS 的基础知识,然后介绍一些示例和用例。我们还将讨论如何通过在 GopherJS 中构建一个简单的 React 应用程序来将 GopherJS 与 React.js 结合使用。
第十章,从这里开始去哪里?提供了从这里继续学习旅程的建议。它讨论了云原生架构、容器和 React Native 移动应用开发。
为了充分利用这本书
要最大限度地从这本书中获益,最好的方法是逐章阅读。尝试在章节中练习代码示例。大多数章节都包含一个概述章节中代码所需工具和软件的部分。每个章节的代码将在 GitHub 上提供。
下载示例代码文件
你可以从www.packt.com的账户下载这本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给你。
你可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书名,并遵循屏幕上的说明。
一旦文件下载完成,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
Windows 版的 WinRAR/7-Zip
-
Mac 版的 Zipeg/iZip/UnRarX
-
Linux 版的 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。你可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781789130751_ColorImages.pdf。
使用的约定
在本书中使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“我们还将介绍一些特定于 Go 的概念,例如 slice、panic 和 defer。”
代码块设置如下:
package mypackage
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
type Student struct{
Person
studentId int
}
func (s Student) GetStudentID()int{
return s.studentId
}
任何命令行输入或输出都应如下编写:
go install
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“这被称为类型推断,因为您从提供的值中推断变量类型。”
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并将邮件发送至 customercare@packtpub.com。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,请访问 www.packt.com/submit-errata,选择您的书,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了本书,为何不在购买该书的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对本书的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packt.com。
第一部分:Go 语言
本节的目标是向读者介绍 Go 语言。在本节中,读者将获得如何在 Go 中构建有效软件的实用知识。
本节包含以下章节:
-
第一章,欢迎来到全栈 Go
-
第二章,Go 语言的基本构建块
-
第三章,Go 并发
第一章:欢迎来到全栈 Go
本书是努力为您提供的一个非常实用且简洁的学习工具,用于在 Go 中构建全栈网络应用。学习如何在像 Go 这样的强大语言中构建全栈网络应用可以是一个非常宝贵的技能,因为它允许你独立编写完全功能化和可扩展的应用程序。Go 在业界以其性能和相对易用性而闻名。这确保了你的应用程序可以承受不断增长的数据负载和用户数量的扩展,而不会过早地出现可扩展性问题。
全栈开发者是软件创业公司的主要推动者;这是因为他们能够从零开始快速且高效地构建产品。他们通常也是大型公司中的关键成员或领域专家(SMEs),因为他们帮助设计软件系统,从用户界面到后端代码。即使作为一个独立开发者,学习全栈网络编程也能帮助你快速构建你的产品想法,只需周末或两天时间。
在本章中,我们将涵盖以下主题:
-
什么是全栈开发?
-
我们将构建什么?
-
本书的大纲
什么是全栈开发?
在我们采取任何进一步步骤之前,我们首先需要回答一个简单的问题;成为一名全栈开发者究竟意味着什么?全栈开发者可以被定义为一位软件工程师,他拥有在应用的前端和后端工作的技能。
网络应用的前端基本上与应用的用户界面相关。对于网络应用,构建用户界面所需的技术包括 HTML、CSS 和 JavaScript。在生产环境中,一个应用可以根据用户查看网络应用的设备类型支持不同类型的用户界面。例如,移动智能手机上的前端可能需要不同的规则来适应设备有限的屏幕尺寸,与大型桌面显示器相比。
为了使应用执行其预期完成的任务,网络应用的后端包括所有需要与应用前端通信的软件层。后端包括数据库层、安全层、用户请求处理层、所有 API 层等。网络应用的后端可以用任何成熟的编程语言编写。显然,我们将使用 Go 作为本书的后端语言。
我们将构建什么?
在整本书中,我们将从头开始构建一个全栈网络应用。该应用将被命名为 GoMusic;它是一个用 React.js 和 Go 编写的乐器商店。以下截图展示了主页的样式:

用户将能够浏览商店中的乐器,用他们的信用卡购买他们喜欢的乐器,并登录他们的账户查看他们现有的订单。
让我们来看看本书将要构建的应用程序架构。
应用程序架构
我们的应用程序架构将相对简单——我们将使用极受欢迎的 React.js 框架来编写前端代码,然后我们将使用强大的 Gin 框架来编写后端代码。Gin 附带了一系列有用的包,我们将使用这些包来构建我们的 Web 应用程序。我们还将利用Go 对象关系映射(GORM)包,这是 Go 语言中最受欢迎的对象关系映射(ORM)层之一:

我们将逐步构建我们的应用程序,从前端开始,然后转向后端。我们将涵盖现代 Web 应用程序领域的一些非常重要的概念,例如响应式 UI、RESTful API、安全性、ORM、信用卡处理、测试、基准测试等。
在介绍这些不同主题的同时,我们将涵盖构建应用程序所涉及的大多数代码。
在下一节中,我们将游览本书的概要,以及每一章将涵盖的内容。
本书概要
本书将涵盖众多实用主题,以帮助您获得全栈开发者所需的深入技能:
-
在第二章,Go 的构建块,和第三章,Go 并发中,我们将深入而实际地探讨 Go 语言。您将了解语言的一些关键特性以及一些流行的设计模式。
-
在第四章,使用 React.js 的前端,和第五章,为 GoMusic 构建前端中,我们将介绍如何使用 React.js 框架构建美观且响应式的应用程序。这也是我们开始构建 GoMusic Web 应用程序的地方。我们将涵盖 React 的构建块、设计模式、最佳实践等。本书项目的前端代码的大部分内容将在第四章,使用 React.js 的前端,和第五章,为 GoMusic 构建前端中介绍;然而,我们不会涵盖每一条 JavaScript 代码,以免分散注意力。所有代码都将包含在本书的 GitHub 仓库中。
-
在第六章,使用 Gin 框架的 Go 语言 RESTful Web API和第七章,使用 Gin 和 React 的高级 Web Go 应用,我们将开始使用 Gin 框架构建我们的后端代码。我们将涵盖 RESTful API、ORM、安全 Web 连接等内容。
-
在第八章,测试和基准测试您的 Web API,我们将学习如何使用 Go 的测试包和行业最佳实践来测试和基准测试我们的 Go 代码。
-
在第九章,使用 GopherJS 的等价 Go 语言介绍,我们将快速学习等价 Go 编程。等价 Go 编程是指在前后端都使用 Go 语言的实践。这是通过 GopherJS 框架实现的。第九章,使用 GopherJS 的等价 Go 语言介绍是一个独立的章节,因为它并不尝试使用等价 Go 重新构建 GoMusic 应用。然而,本章涵盖了如何在等价 Go 中构建 React 应用。
-
在第十章,从这里开始往哪里去?我们将涉及一些读者应该继续探索的主题,以扩展他们的知识范围,超越本书的内容。
让我们开始学习之旅吧!
第二章:Go 语言的基本构建块
欢迎来到我们旅程的第一章,我们将学习 Go 语言的完整栈开发。本章是为那些还不熟悉 Go 语言的读者准备的。如果你已经精通 Go 语言,你可以跳过这一章。我们将简要但实用地介绍构成 Go 语言基础的基本构建块。然后,我们将向你展示 Go 语言的函数和循环等基本编程结构的语法。我们还将涵盖一些 Go 语言特有的概念,如切片、panic 和 defer。本章假设你对编程概念(如变量、函数、循环和条件语句)有一定的了解。本章还假设你对终端、命令行以及环境变量的概念有一定的了解。
一个从零开始学习 Go 语言的极好资源可以在 tour.golang.org 找到。
在本章中,我们将涵盖以下主题:
-
基本概念——包、变量、数据类型和指针
-
函数和闭包
-
条件语句和循环
-
panic、recover和defer -
Go 数据结构
-
Go 接口
技术要求
为了跟随本章的内容,你可以执行以下操作之一:
-
前往 play.golang.org,这将允许你在网上运行或测试你的代码。
-
下载 Go 编程语言,以及兼容的集成开发环境(IDE)
如果你还没有下载 Go,你可以通过访问 golang.org/dl/ 下载 Go 语言,下载适合你操作系统的 Go 版本,然后进行安装。
对于本地 IDE,我更喜欢 Visual Studio Code (code.visualstudio.com),以及其流行的 Go 插件 (code.visualstudio.com/docs/languages/go)。
Go 的游乐场
Go 编程语言的游乐场是一个相当受欢迎的网站,它允许 Go 社区在线测试 Go 代码示例。该网站可在 play.golang.org 找到。无论何时你想快速测试一段简单的 Go 代码,请访问该网站并运行你的代码。
设置 Go 的工作空间
为了在你的计算机上编写 Go 代码,你需要设置一个 Go 工作空间。Go 工作空间是一个文件夹,你将在其中编写 Go 代码。设置 Go 工作空间相对简单。以下是你需要做的事情:
-
首先,请确保你已经安装了 Go。正如我们之前提到的,你可以通过访问
golang.org/dl/下载并安装 Go。 -
安装 Go 后,在你的计算机中为 Go 工作空间创建一个新的文件夹。我的文件夹名为
GoProjects。 -
在您的 Go 工作空间文件夹内,您必须创建三个主要文件夹:
src、pkg和bin。在您的 Go 工作空间文件夹内创建具有这些确切名称的文件夹非常重要。以下是这三个文件夹为什么重要的原因:-
src文件夹将托管所有您的代码文件。每次您决定开始一个新的程序时,您只需前往src文件夹并创建一个以您新程序名称命名的新文件夹。 -
pkg文件夹通常托管您的代码的编译包文件。 -
bin文件夹通常托管由您的 Go 程序生成的二进制文件。
-
-
您需要设置两个环境变量:
-
第一个环境变量被称为
GoRoot,它将包含您的 Go 安装路径。通常,GoRoot应由 Go 安装程序处理。但是,如果它缺失,或者您想将您的 Go 安装移动到不同的位置,那么您需要设置GoRoot。 -
第二个环境变量被称为
GoPath。GoPath包含了您 Go 的工作空间文件夹的路径。默认情况下,如果没有设置,GoPath在 Unix 系统上假定是$HOME/go,在 Windows 系统上假定是%USERPROFILE%\go。有一个整个 GitHub 页面涵盖了在不同操作系统上设置GoPath的方法,可以在github.com/golang/go/wiki/SettingGOPATH找到。
-
一旦您的 Go 环境设置完成,您就可以使用 Go 工具,该工具与 Go 语言一起安装,以便您编译和运行您的 Go 程序。
我们将在下一节中查看 Go 语言的一些基本构建块。
包、变量、数据类型和指针
包、变量、数据类型和指针代表了 Go 语言最基本的构建块。在本节中,我们将从实际的角度逐一介绍它们。
包
任何 Go 程序都由一个或多个包组成。每个包基本上是一个文件夹,其中包含一个或多个 Go 文件。您所写的每个 Go 代码文件都必须属于一个包。包文件夹位于您的 Go 工作空间文件夹的 src 文件夹内。
当您编写 Go 代码时,您需要在您的 Go 文件顶部声明您的包名。以下是代码中的样子:
package mypackage
在这里,mypackage 是我的 Go 文件所属的包的名称。在 Go 中,使用小写字母的包名是惯例。通常,将包文件夹的名称与包名称相同是首选的做法。因此,当您创建一个新的包时,只需创建一个以您的包名称命名的新文件夹,然后在那个文件夹内创建您的包文件。
要导入外部包并在自己的包中使用它,你需要使用import关键字。例如,Go 标准库中的一个流行包是fmt包,它允许你将数据写入标准输出(即写入你的控制台屏幕)。假设我们想在我们的包中使用fmt包。代码将如下所示:
package mypackage
import "fmt"
一些包文件夹可以存在于其他包的文件夹中。例如,Go 中包含rand包的文件夹,该包用于生成随机数,存在于包含math包的文件夹中。要导入这样的包,你需要使用以下语法:
import "math/rand"
现在,如果我们想一次性导入多个包,这很简单——语法将看起来像这样:
import (
"fmt"
"math/rand"
)
Go 不允许你导入一个包然后不使用它,以确保你的代码干净简洁。然而,有一些情况(我们将在本书的后面部分讨论)你可能想要加载一个包,但又不直接使用它。这可以通过在导入语句中包名前添加一个下划线来实现。这将如下所示:
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
最著名的包名是main。你的主包是运行在 Go 程序中的第一个包。
要编译 Go 程序,你需要在控制台中导航到你的main包所在的文件夹,然后输入以下命令:
go install
此命令将编译你的 Go 程序,并将生成的二进制文件放置在你的工作区的bin文件夹中。
或者,你可以运行以下命令:
go build
此命令将编译并部署生成的二进制文件到当前文件夹。
如果你想要指定输出路径和文件名,你可以运行以下命令:
go build -o ./output/myexecutable.exe
这将编译你的代码,然后在指定的输出文件夹中打包成一个名为myexecutable.exe的可执行文件。如果你的操作系统不是 Windows,你可以忽略前面的例子中的exe扩展名。
变量和数据类型
变量是 Go 语言的基本构建块之一。在 Go 中,要声明变量,你可以简单地使用var关键字。这看起来是这样的:
var s string
显然,string是数据类型。假设我们想在同一个语句中声明多个字符串类型的变量。这看起来是这样的:
var s1,s2,s3 string
要使用初始值初始化变量,Go 提供了一些选项。一个选项是在指定变量类型的同时初始化变量。这看起来是这样的:
var s1,s2,s3 string = "first-string", "second-string", "third-string"
另一个选项是不指定数据类型来初始化变量。这看起来是这样的:
var s1,s2,s3 = "first-string", "second-string", "third-string"
我们可以使用以下语法混合数据类型:
var s,i,f = "mystring",12,14.53
同时声明和初始化多个变量的流行方式如下:
var (
s = "mystring"
i = 12
f = 14.53
)
如果你在一个函数内部声明和初始化变量,甚至不需要使用 var 关键字。相反,你可以使用 :=。这被称为 类型推断,因为你可以从提供的值中推断变量类型。以下是使用类型推断声明和初始化 s、i 和 f 变量的方法:
s := "mystring"
i := 12
f:=14.53
然而,var 关键字提供了更多的控制,因为它允许你显式指定你想要为变量使用的数据类型。
现在,让我们来讨论数据类型。Go 语言有一组标准的数据类型,这些数据类型与任何其他静态类型编程语言中的数据类型非常相似。以下是 Go 语言标准数据类型的总结:
| 数据类型 | 描述 |
|---|---|
bool |
布尔值(要么是 true,要么是 false)。 |
string |
string 是 byte 的集合,可以存储任何字符。字符串是只读的(不可变),所以每次你需要向字符串中添加或删除字符时,实际上是在创建一个新的字符串。 |
int、int8、int16、int32 和 int64 |
有符号整数类型。它们表示非十进制数,可以是正数或负数。从类型名称中你可能已经猜到,你可以显式指定它可以允许的位数。如果你选择 int 类型,它将选择与你的环境相对应的位数。对于大多数现代 CPU 架构,它将选择 64 位,除非你正在使用较小的 CPU 或较旧的环境。对于较小的 CPU 或较旧的环境,选择变为 32 位。 |
uint、uint8、uint16、uint32、uint64 和 uintptr |
无符号整数类型。它们表示非十进制数,只能为正数。除了符号外,它们与它们的带符号兄弟相似。uintptr 类型是一个足够大的无符号整数类型,可以存储内存地址。 |
byte |
uint8 的别名,它包含 8 位,基本上代表一个字节的内存。 |
rune |
int32 的别名,通常用于表示 Unicode 字符。 |
float32 和 float64 |
简单的十进制数。对于较小的十进制数,使用 float32 类型,因为它只允许 32 位的数据。对于较大的十进制数,使用 float64 类型,因为它只允许 64 位的数据。 |
complex64 和 complex128 |
复数。这些数据类型在需要严肃数学的程序中非常有用。第一种类型 complex64 是一个复数,其实部是一个 32 位浮点数,虚部也是一个 32 位浮点数。第二种类型 complex128 是一个复数,其实部是一个 64 位浮点数,而虚部也是一个 64 位浮点数。 |
没有显式初始值的变量会被分配所谓的 零值。以下是零值的表格:
| 类型 | 零值 |
|---|---|
| 数值类型 | 0 |
| 布尔类型 | false |
| 字符串类型 | "" |
| 指针 | nil |
指针
指针的概念很简单——指针是一种语言类型,它表示您的值的内存位置。Go 中的指针无处不在,这是因为它们给程序员提供了很多对代码的控制权。例如,访问内存中的值允许您从代码的不同部分更改原始值,而无需复制您的值。
在 Go 中,要创建一个指针,只需在您的值的数据类型前添加 * 即可。例如,这里是一个指向 int 值的指针:
var iptr *int
正如我们在上一节中提到的,指针的零值是 nil。nil 的行为类似于 Java 等语言中的 null,也就是说,如果您尝试使用 nil 指针,将会抛出一个错误。
现在,假设我们有一个名为 x 的 int 类型的值:
var x int = 5
我们还想要一个指针指向 x 的地址,以供以后使用:
var xptr = &x
这里的 & 运算符意味着我们想要 x 的地址。每次在变量前添加 & 运算符时,它基本上意味着我们想要该变量的地址。
如果我们有一个指针,并且我们想要检索它指向的值呢?这个操作称为 解引用,下面是如何进行操作的示例:
y := *xptr
在前面的代码中,我们解引用了指针 xptr 来获取它指向的值,然后我们将值的副本存储在一个名为 y 的新变量中。
如果我们想要更改指针指向的值呢?我们仍然可以使用解引用来做到这一点,下面是这个操作的示例:
*xptr = 4
完美!有了这些,您应该具备足够的知识来在代码中使用 Go 指针。
如果您已经从 C 或 C++ 等不同的编程语言中获得了指针的经验,您可能熟悉指针算术的概念。这是指在指针上进行算术运算(如加法或减法),以到达不同的内存地址。默认情况下,Go 不支持我们对本节中描述的纯指针进行指针算术。然而,有一个名为 unsafe 的包允许您这样做。unsafe 包仅存在于为您提供权力的目的,如果您确实需要,但强烈建议除非绝对必要,否则不要使用它。
现在,让我们探索 Go 中的函数和闭包。
函数和闭包
现在是时候讨论函数和闭包了,所以请坐稳并享受这段旅程。函数被认为是任何编程语言中的关键构建块之一,因为它们允许您在代码中定义操作。
让我们来讨论函数的基础知识。
函数 – 基础
下面是如何在 Go 中编写函数的示例:
func main(){
//do something
}
main 函数几乎总是您 Go 程序中首先执行的功能。main 函数需要位于 main 包中,因为 main 是您 Go 程序的入口点包。
下面是一个带有参数的函数的示例:
func add(a int, b int){
//a+b
}
由于前面的代码中的 a 和 b 参数类型相同,我们也可以这样做:
func add(a,b int){
//a+b
}
现在,假设我们想从我们的函数中返回一个值。这看起来会是这样:
func add(a,b int)int{
return a+b
}
Go 语言还允许多重返回,所以你可以这样做:
func addSubtract(a,b int)(int,int){
return a+b,a-b
}
在 Go 语言中,有一个称为 named returns 的概念,这基本上意味着你可以在函数头部命名你的返回值。这看起来是这样的:
func addSubtract(a,b int)(add,sub int){
add = a+b
sub = a-b
return
}
在 Go 语言中,函数也是一等公民,这意味着你可以将一个函数赋值给一个变量,并使用它作为值。以下是一个示例:
var adder = func(a,b int)int{
return a+b
}
var subtractor = func(a,b int) int{
return a-b
}
var addResult = adder(3,2)
var subResult = subtractor(3,2)
由于这个原因,你还可以将函数作为参数传递给其他函数:
func execute(op func(int,int)int, a,b int) int{
return op(a,b)
}
这里是一个示例,展示了我们如何使用之前定义的 execute 函数:
var adder = func(a, b int) int {
return a + b
}
execute(adder,3,2)
Go 语言还支持可变参数函数的概念。一个 可变参数函数 是一个可以接受任意数量参数的函数。以下是一个 adder 函数的示例,它接受任意数量的 int 参数并将它们相加:
func infiniteAdder(inputs ...int) (sum int) {
for _, v := range inputs {
sum += v
}
return
}
前面的函数接受任意数量的 int 参数,然后将它们全部相加。我们将在本章的 条件语句和循环 部分稍后介绍 for..range 语法。然后我们可以使用以下语法调用我们的新函数:
infiniteAdder(1,2,2,2) // 1 + 2 + 2 + 2
在下一节中,我们将探讨如何从其他包中访问函数。
函数 – 从其他包访问函数
在本章的早期,我们介绍了包的概念,以及一个 Go 程序由多个连接的包组成的事实。那么,我们究竟是如何连接包的呢?我们通过能够从其他包中调用函数和检索类型来连接包。但随之而来的问题是,我们如何将一个函数暴露给其他包?
在 Go 语言中,没有像大多数其他静态类型编程语言那样的 private 或 public 关键字。如果你想使你的函数 public,你只需要将函数名以大写字母开头。在 Go 语言中,这被称为使你的函数 exported。相反,如果你的函数以小写字母开头,那么你的函数被认为是 unexpected。
为了理解前面的两段内容,让我们通过一些代码来实践。这里有一个名为 adder 的包,它包含一个名为 Add 的单个函数:
package adder
func Add(a,b int)int {
return a+b
}
现在,假设我们想从一个不同的包中调用 Add。以下是我们会做的事情:
package main
//get the adder package
import "adder"
func main() {
result := adder.Add(4, 3)
//do something with result
}
在前面的代码中,我们在主包的 main 函数中调用了导出的函数 Add。我们做了两件事:
-
使用
import关键字加载adder包 -
在主函数中,我们调用了
adder.Add(..)
如演示所示,要调用导出的函数,你需要使用以下语法:
<package name>.<exported function name>
如果在 adder 包中我们将函数命名为 add 而不是 Add,那么前面的代码将不会工作。这是因为当函数以小写字母开头时,它会被认为是意外的,这实际上意味着它对其他包是不可见的。
让我们看看 Go 标准包中的几个例子。
Go 标准包中的一个非常流行的包是fmt包。fmt包可以写入你的环境的标准输出。它还可以格式化字符串并从标准输入扫描数据,等等。以下是一个简单但非常常用的代码片段:
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello Go world!!")
}
在前面的代码中,我们调用了一个名为Println的函数,该函数位于fmt包中。Println函数将接受你的字符串消息并将其打印到标准输出。前面程序的输出如下:
Hello Go world!!
在 Go 的世界中,另一个流行的包是math/rand,我们可以用它来生成随机数。正如我们在本章前面的包部分提到的,包名不是rand而是math/rand,这仅仅是因为rand包文件夹位于math包文件夹之下。所以,尽管rand更像是子包,但我们只需要在需要调用属于它的导出函数时使用包名。以下是一个简单的例子:
package main
import (
"fmt"
"math/rand"
)
func main() {
fmt.Println("Let's generate a random int", rand.Intn(10))
}
在前面的代码中,我们导入了两个包——fmt包和math/rand包。然后,我们从每个包中调用了两个函数。我们首先调用了属于fmt包的Println函数,将其输出到标准输出。然后,我们调用了属于math/rand包的Intn函数,以生成介于零和九之间的随机数。
现在,让我们看看闭包的构成。
闭包
函数也可以是闭包。闭包是一个与外部变量绑定的函数值。这意味着闭包可以访问和改变这些变量的值。没有例子很难理解闭包。以下是对加法函数的另一种风格的示例,它返回一个闭包:
func adder() func(int) int {
sum := 0
return func(x int) int {
sum += x
return sum
}
}
在前面的例子中,闭包可以访问sum变量,这意味着它会记住sum变量的当前值,并且还能改变sum变量的值。再次强调,这最好通过另一个例子来解释:
func adder() func(int) int {
sum := 0
return func(x int) int {
sum += x
return sum
}
}
func main() {
// when we call "adder()",it returns the closure
sumClosure := adder() // the value of the sum variable is 0
sumClosure(1) //now the value of the sum variable is 0+1 = 1
sumClosure(2) //now the value of the sum variable is 1+2=3
//Use the value received from the closure somehow
}
我们已经涵盖了 Go 的基础知识。在下一节中,我们将继续讨论 Go 数据结构。
Go 数据结构
在本节中,我们将讨论 Go 语言的一些关键概念。现在是时候探索构建 Go 语言非平凡程序所需的基础数据结构了。
在接下来的几节中,我们将讨论 Go 的各种数据结构,包括数组、切片、映射、Go 结构和方法。
数组
数组是任何编程语言中都存在的常见数据结构。在 Go 中,数组是具有相同数据类型和预定义大小的值的集合。
这是如何在 Go 中声明数组的示例:
var myarray [3]int
前面的数组是int类型,大小为3。
然后,我们可以这样初始化数组:
myarray = [3]int{1,2,3}
或者,我们可以这样做:
//As per the array declaration, it has only 3 items of type int
myarray[0] = 1 //value at index 0
myarray[1] = 2 //value at index 1
myarray[2] = 3 //value at index 2
或者,类似于其他变量,我们可以在同一行声明和初始化数组,如下所示:
var myarray = [3]int{1,2,3}
或者,如果我们是在函数内部声明和初始化数组,我们可以使用:=符号:
myarray := [3]int{1,2,3}
Go 提供了一个名为len()的内置函数,它返回数组的大小/长度。例如,假设我们运行以下代码:
n := len(myarray)
fmt.Println(n)
输出将简单地是3,因为myarray的大小是3。
Go 还允许你捕获主数组中的子数组。要做到这一点,你需要遵循以下语法:
array[<index1>:<index2>+1]
例如,假设我声明了一个看起来像这样的新数组:
myarray := [5]int{1,2,3,4,5}
我可以使用以下语法从我的数组索引二到索引三获取一个子数组:
myarray[2:4]
前面代码的输出如下:
[3 4]
传递给前面语法的两个索引是2,表示我们想要从索引二开始,然后是4,表示我们想要在索引四停止(3+1=4)。
在前面的语法中,方括号内你也可以留空任意一边。比如说,你让左边留空,就像这样:
myarray[:4]
这表示你想要从索引零到索引三的子数组。
然而,假设你让右边留空,就像这样:
myarray[2:]
这表示子数组将从索引二开始,直到你的原始数组末尾。
假设你做了类似这样的事情:
mySubArray := myarray[2:4]
mySubArray不仅仅是myarray子部分的副本。实际上,这两个数组将指向相同的内存。让我们通过一个例子来详细说明。以下是一个简单的程序:
package main
import (
"fmt"
)
func main() {
myarray := [5]int{1,2,3,4,5}
mySubArray := myarray[2:4]
mySubArray[0] = 2
fmt.Println(myarray)
}
这个程序输出myarray,但它是在我们更改mySubArray中的值之后输出的。正如你在前面的代码中所看到的,myarray中的原始值是1、2、3、4和5。然而,因为我们更改了mySubArray中索引0的值,也就是myarray中索引2的值,所以输出最终会变成以下这样:
[1 2 2 4 5]
完美!我们现在对如何在 Go 中使用数组有了清晰的认识。让我们继续学习切片。
切片
Go 的数组数据结构有一个非常明显的限制——你必须在声明新数组时指定大小。在现实生活中,有许多场景我们事先不知道期望的元素数量。几乎每种现代编程语言都有自己的数据结构来满足这一需求。在 Go 中,这个特殊的数据结构被称为切片。
从实际的角度来看,你可以把切片简单地看作是动态数组。从语法的角度来看,切片看起来与数组非常相似,只是你不需要指定大小。以下是一个例子:
var mySlice []int
如你所见,切片声明与数组声明非常相似,只是切片不需要指定元素数量。
这里是我们用一些初始值初始化前面幻灯片的例子:
mySlice = []int{1,2,3,4,5}
让我们一次性声明并初始化这个数组的一些初始值:
var mySlice = []int{1,2,3,4,5}
由于切片可以增长大小,我们也可以初始化一个空的切片:
var mySlice = []int{}
如果你想在切片中设置初始元素数量而不必手动编写初始值,你可以利用一个名为 make 的内置函数:
var mySlice = make([]int,5)
前面的代码将声明并初始化一个初始长度为 5 个元素的 int 切片。
要编写高效的 Go 代码并从切片中受益,你首先需要了解切片是如何在内部工作的。
可以简单地将切片视为指向数组一部分的指针。切片包含三个主要信息:
-
指向切片指向的子数组第一个元素的指针。
-
切片暴露的子数组的长度。
-
容量,即原始数组中剩余的项目数量。容量始终等于长度或更大。
这听起来太理论化了,所以让我们利用代码和一些可视化来提供一个关于切片实际工作方式的实际解释。
假设我们创建了一个新的切片:
var mySlice = []int{1,2,3,4,5}
我们内部创建的新切片指向一个包含我们设置的 5 个初始值的数组:

如前图所示,mySlice 包含了三个信息:
-
第一个是数组底部的指针,它包含数据
-
第二个是切片的长度,在这个例子中是
5 -
第三个是切片的完整容量,在这个例子中也是
5
然而,前图并没有真正阐明切片的容量如何与长度不同。为了揭示长度和容量之间的实际差异,我们需要深入挖掘。
假设我们决定从原始切片中提取一个子切片:
var subSlice = mySlice[2:4]
重新切片 mySlice 不会产生数组底下的新、更小的副本。相反,前面的代码行将产生以下切片:

由于 subSlice 包含 mySlice 的索引二和索引三的元素,因此 subSlice 的长度是两个(记住数组索引从零开始,这就是为什么索引二是第三个元素而不是第二个)。然而,容量不同,这是因为原始数组从索引二开始还剩下三个元素,所以容量是三个,而不是两个,尽管长度是两个。
因此,换句话说,subSlice 的长度是两个,因为 subSlice 只关心两个元素。然而,容量是三个,因为从索引二开始,原始数组中还有三个元素,这是 subSlice 数组指针指向的索引。
有一个名为 cap 的内置函数,我们可以用它来获取切片的容量:
cap(subSlice) //this will return 3
我们用于数组的内置函数 len 也可以用于切片,因为它会给你切片的长度:
len(subSlice) //this will return 2
到现在为止,你可能想知道,为什么我应该关心长度和容量的区别?我只需要使用长度,完全忽略容量即可,因为容量只提供了关于隐藏内部数组的信息。
答案非常简单——内存利用率。如果mySlice有 100,000 个元素而不是仅仅五个呢?这意味着内部数组也将有 100,000 个元素。这个巨大的内部数组将存在于我们程序的内存中,只要我们使用从mySlice提取的任何子切片,即使我们使用的子切片只关心两个元素。
为了避免这种内存膨胀,我们需要明确地将我们关心的较少元素复制到一个新的切片中。通过这样做,一旦我们停止使用原始的大切片,Go 的垃圾回收器就会意识到巨大的数组不再需要,并将其清理掉。
那么,我们如何实现这一点呢?这可以通过一个内置函数copy来完成:
//let's assume this is a huge slice
var myBigSlice = []int{1,2,3,4,5,6}
//now here is a new slice that is smaller
var mySubSlice = make([]int,2)
//we copy two elements from myBigSlice to mySubSlice
copy(mySubSlice,myBigSlice[2:4])
完美!有了这个,你应该对切片的内部结构和如何在切片中避免内存膨胀有了相当实际的理解。
我们一直说切片就像动态数组,但我们还没有看到如何实际扩展切片。Go 提供了一个简单的内置函数append,用于向切片中添加值。如果你达到了切片容量的极限,append函数将创建一个新的切片,其中包含更大的内部数组来存储你正在扩展的数据。append是一个可变参数函数,因此它可以接受任意数量的参数。下面是这个过程的示例:
var mySlice = []int{1,2} //our slice holds 1 and 2
mySlice = append(mySlice,3,4,5) //now our slice holds 1,2,3,4,5
最后要提到的一个重要的事情是内置函数make。我们之前已经介绍了make函数以及它是如何用来初始化切片的:
//Initialize a slice with length of 3
var mySlice = make([]int,3)
上述代码中的参数3代表切片的长度。但我们还没有提到的是,make也可以用来指定切片的容量。这可以通过以下代码实现:
//initialize a slice with length of 3 and capacity of 5
var mySlice = make([]int,3,5)
如果我们不向make()函数提供容量,长度参数的值也将成为容量,换句话说,我们得到以下结果:
//Initialize a slice with length of 3, and capacity of 3
var mySlice = make([]int,3)
现在,是时候讨论 map 了。
Maps
HashMaps 在任何编程语言中都是非常流行且极其重要的数据结构。一个map是一个键值对的集合,你使用键来获取与之对应的值。使用 map 可以大大加快你的软件速度,因为使用 map 通过键获取值是一个非常快速的操作。
在 Go 中,你可以这样声明一个 map:
var myMap map[int]string
上述代码声明了一个 map,其中键的类型为int,值类型为string。
你可以使用make函数来初始化一个 map:
myMap = make(map[int]string)
在初始化之前你不能使用 map,否则会抛出错误。这里还有另一种初始化 map 的方法:
myMap = map[int]string{}
如果你想用一些值初始化 map,你可以这样做:
myMap = map[int]string{1: "first", 2: "Second", 3: "third"}
要向现有的 map 中添加值,你可以这样做:
myMap[4] = "fourth"
要从一个 map 中获取值,你可以这样做:
//x will hold the value in "myMap" that corresponds to key 4
var x = myMap[4]
你还可以使用以下语法检查映射中是否存在键,假设你的代码在一个函数块中:
//If the key 5 is not in "myMap", then "ok" will be false
//Otherwise, "ok" will be true, and "x" will be the value
x,ok := myMap[5]
你可以使用内置的 delete 函数从映射中删除值:
//delete key of value 4
delete(myMap,4)
结构体
在 Go 语言中,struct 是一种由字段组成的数据结构,其中每个字段都有一个类型。下面是一个 Go struct 的样子:
type myStruct struct{
intField int
stringField string
sliceField []int
}
上一段代码创建了一个名为 myStruct 的 struct 类型,它包含三个字段:
-
intField类型为int -
stringField类型为string -
sliceField类型为[]int
然后,你可以在你的代码中初始化和使用该 struct 类型:
var s = myStruct{
intField: 3,
stringField: "three",
sliceField : []int{1,2,3},
}
上述初始化方法也被称为 结构字面量。它有一个更简短的版本,如下所示:
var s = myStruct{3,"three",[]int{1,2,3}}
你还可以使用所谓的 点符号,如下所示:
var s = myStruct{}
s.intField = 3
s.stringField = "three"
s.sliceField= []int{1,2,3}
你可以通过以下方式获得 struct 的指针:
var sPtr = &myStruct{
intField:3,
stringField:"three",
sliceField: []int{1,2,3},
}
可以使用点符号与 Go struct 指针一起使用,因为 Go 会理解需要做什么,而无需进行任何指针解引用:
var s = &myStruct{}
s.intField = 3
s.stringField = "three"
s.sliceField= []int{1,2,3}
如果 Go struct 字段名以小写字母开头,它们将对外部包不可见。如果你想让你的 struct 或其字段对其他包可见,请以大写字母开头命名 struct 和/或字段。
现在,让我们谈谈 Go 中的方法。
方法
方法基本上是一个附加到类型的函数。例如,假设我们有一个名为 Person 的 struct 类型:
type Person struct{
name string
age int
}
Go 允许我们以这种方式给该类型附加一个方法:
func (p Person) GetName()string{
return p.name
}
关键字 func 和函数名 GetName() 之间的部分被称为 方法接收者。
假设我们声明一个 Person 类型的值,如下所示:
var p = Person{
name: "Jason",
age: 29,
}
现在,我们可以按照以下方式调用值 p 的 GetName 方法:
p.GetName()
让我们创建另一个名为 GetAge() 的方法,它返回附加的 person 的 age。以下是实现此功能的代码:
func (p Person) GetAge()int{
return p.age
}
现在,我们将看到类型嵌入是什么。
类型嵌入
但如果你想让一个 struct 继承另一个 struct 的方法,Go 语言提供的最接近继承概念的功能被称为 类型嵌入。这个特性最好通过一个例子来解释。让我们回到 Person struct 类型:
type Person struct{
name string
age int
}
func (p Person) GetName()string{
return p.name
}
func (p Person) GetAge()int{
return p.age
}
现在,假设我们想要创建一个新的名为 Student 的 struct 类型,它具有 Person 类型所有的属性和方法,还有一些额外的功能:
type Student struct{
Person
studentId int
}
func (s Student) GetStudentID()int{
return s.studentId
}
注意,在上一段代码中,我们在 Student 类型的结构定义中包含了 Person 类型,但没有指定字段名。这将有效地使 Student 类型继承 Person struct 类型的所有导出方法和字段。换句话说,我们可以直接从 Student 类型的对象中访问 Person 的方法和字段:
s := Student{}
//This code is valid, because the method GetAge() belongs to the embedded type 'Person':
s.GetAge()
s.GetName()
在 Go 语言中,当一个类型被嵌入到另一个类型中时,嵌入类型的导出方法和字段被称为被 提升 到父或嵌入类型。
在下一节中,我们将探讨如何在 Go 中构建接口。
接口
在介绍了方法之后,我们必须介绍接口,接口在 Go 语言中通过方法产生高效和可扩展的代码。
一个接口可以非常简单地描述为 Go 语言中包含一组方法的类型。
这里有一个简单的例子:
type MyInterface interface{
GetName()string
GetAge()int
}
前面的接口定义了两个方法——GetName()和GetAge()。
之前,我们将两个具有相同签名的函数附加到了名为Person的类型上:
type Person struct{
name string
age int
}
func (p Person) GetName()string{
return p.name
}
func (p Person) GetAge()int{
return p.age
}
在 Go 语言中,接口可以被其他类型实现,例如 Go 结构体。当一个 Go 类型实现接口时,接口类型的值可以包含该 Go 类型的数据。我们很快就会看到这意味着什么。
Go 语言中的一个非常特殊的功能是,为了实现或继承接口,类型只需要实现该接口的方法。
换句话说,前面代码中的Person结构体类型实现了myInterface接口类型。这是因为Person类型实现了GetName()和GetAge(),这些方法与myInterface中定义的方法相同。
那么,当Person实现MyInterface时,它意味着什么呢?
这意味着我们可以这样做:
var myInterfaceValue MyInterface
var p = Person{}
p.name = "Jack"
p.age = 39
// some code
myInterfaceValue = p
myInterfaceValue.GetName() //returns: Jack
myInterfaceValue.GetAge() //returns: 39
我们也可以这样做:
func main(){
p := Person{"Alice",26}
printNameAndAge(p)
}
func PrintNameAndAge(i MyInterface){
fmt.Println(i.GetName(),i.GetAge())
}
接口在 API 和可扩展软件中得到了广泛的应用。它们允许你构建具有灵活功能的软件。这里有一个如何帮助构建灵活软件的简单例子。
假设我们想要创建一个新的person类型,该类型将头衔追加到名字中:
type PersonWithTitle {
name string
title string
age int
}
func (p PersonWithTitle) GetName()string{
//This code returns <title> <space> <name>
return p.title + " " + p.name
}
func (p PersonWithTitle) GetAge() int{
return p.age
}
前面的类型也实现了MyInterface,这意味着我们可以这样做:
func main(){
pt := PersonWithTitle{"Alice","Dr.",26}
printNameAndAge(pt)
}
func PrintNameAndAge(i MyInterface){
fmt.Println(i.GetName(),i.GetAge())
}
PrintNameAndAge()函数签名不需要改变,因为它依赖于接口而不是具体类型。然而,由于我们将具体的struct类型从Person改为PersonWithTitle,行为会有所不同。这种能力允许你编写灵活的 API 和包,无需在代码中添加更多具体类型时进行更改。
有时候,你可能想从一个接口值中获取具体的类型值。Go 语言包含一个名为类型断言的功能,可以用于此目的。以下是类型断言最有用的形式:
person, ok := myInterfaceValue.(Person)
前面的代码假设我们处于函数块内部。如果myInterfaceValue不包含Person类型的值,前面的代码将返回第一个返回值为空的结构体,第二个返回值为 false。因此,ok将为 false,而Person将为空。
另一方面,如果myInterfaceValue包含Person类型的值,那么ok将变为 true,Person变量将包含从myInterfaceValue检索到的数据。
现在,让我们通过介绍条件语句和循环来探索如何给我们的代码添加逻辑。
条件语句和循环
在 Go 语言中,有两个关键字用于条件语句——if和switch。让我们逐一实际看看它们。
如果语句
if语句看起来像这样:
if <condition>{
}
因此,让我们假设我们想要比较一个值,x,是否等于10。下面是这个语法的样子:
if x == 10{
}
在 Go 语言中,你还可以在if语句中执行一些初始化。下面是这个语法的样子:
if x := getX(); x == 5{
}
和其他编程语言一样,一个if语句如果没有else子句就不完整。下面是 Go 语言中if else的样子:
if x==5{
}else{
}
那么一个带有条件的else子句呢?
if x == 5{
}else if x >10{
} else {
}
switch语句
现在,让我们看看switch语句。下面是这个样子:
switch x {
case 5:
fmt.Println("5")
case 6:
fmt.Println("6")
default:
fmt.Println("default case")
}
如果你还没有注意到,没有break关键字。在 Go 语言中,每个 case 会自动跳出,不需要明确告诉它这样做。
和if语句类似,你可以在switch语句中进行初始化:
switch x := getX();x {
case 5:
fmt.Println("5")
case 6:
fmt.Println("6")
default:
fmt.Println("default case")
}
在 Go 语言中,一个switch语句可以像一组if else一样工作。这让你能够用更简洁的代码编写长的if else链:
switch{
case x == 5:
//do something
case x > 10:
// do something else
default:
//default case
}
在某些情况下,你可能想让switch的 case 不自动跳出,而是继续到下一个 case。为此,你可以使用fallthrough关键字:
switch{
case x > 5:
//do something
fallthrough
case x > 10:
// do something else. If x is greater than 10, then the first case will execute first, then this case will follow
default:
//default case
}
在条件语句之后,让我们看看循环。
循环
在 Go 语言中,当你想要写一个循环时,你可以使用一个单独的关键字——for。在 Go 语言中没有其他关键字来表示循环。
让我们看看下面的代码。假设我们想要从1循环到10;下面是如何做到这一点的方法:
for i:=1;i<=10;i++{
//do something with i
}
和其他语言一样,你的for语句需要包含以下内容:
-
代码中的初始值(
i:=1)——这是可选的 -
一个条件来指示是否继续迭代(
i<=10) -
下一个迭代的值(
i++)
如果我们有一个切片或数组,并且我们想要在循环中遍历它,Go 语言通过for .. range的概念来提供帮助。假设我们有一个名为myslice的切片,并且我们想要遍历它。下面是这个代码的样子:
myslice := []string{"one","two","three","four"}
for i,item := range myslice{
//do something with i and item
}
在前面的代码片段中,i代表当前迭代的索引。例如,如果我们处于myslice的第二项,那么i的值将是 1(因为索引从 0 开始)。另一方面,item变量代表当前迭代切片项的值。例如,如果我们处于切片的第三项,那么我们处于项值three。
有时候我们并不关心索引。为此,我们可以使用以下语法:
for _,item := range myslice{
//do something with item
}
如果我们只关心索引怎么办?为此,我们可以这样做:
for i := range myslice{
//do something with item
}
有人可能会问,为什么我只需要索引而不是切片本身的项目?答案是简单的——当你从for..range语句中获取项目时,你只获取了一个项目的副本,这意味着如果你需要的话,你将无法改变切片中存在的原始项目。然而,当你获取索引时,这给了你改变切片中项目的权力。在某些情况下,当你迭代切片时,你可能需要更改切片中的值。这就是你使用索引的时候。以下是一个简单的例子:
myslice := []string{"one","two","three","four"}
for i := range myslice {
myslice[i] = "other"
}
fmt.Println(myslice)
//output is: other other other other
但关于while循环呢?如果你来自除了 Go 以外的任何编程语言,你肯定完全了解while循环的概念。正如我们之前提到的,在 Go 中,所有循环都使用for关键字,所以换句话说,for也是 Go 中的while。以下是一个例子:
for i>5{
//do something
}
正如其他编程语言一样,Go 支持break和continue关键字。在循环内部的break关键字会导致循环中断,即使它还没有完成。另一方面,continue关键字会强制循环跳到下一个迭代。
现在,我们将讨论panic、recover和defer
panic、recover和defer
在 Go 中,有一个特殊的内置函数叫做panic。当你调用panic时,你的程序会被中断,并返回一个panic消息。如果panic被触发而你又没有及时捕捉到,你的程序将停止执行并退出,所以当你使用panic时要非常小心。以下是一个代码示例:
func panicTest(p bool) {
if p {
panic("panic requested")
}
}
在前面的例子中,我们编写了一个检查标志p的函数。如果p为真,那么我们就抛出一个panic。panic函数的参数是希望panic返回的消息。以下是一个更完整的程序,你可以在 Go 的 playground 中运行(play.golang.org):
package main
import "fmt"
func main() {
panicTest(true)
fmt.Println("hello world")
}
func panicTest(p bool) {
if p {
panic("panic requested")
}
}
当我在 Go 的 playground(play.golang.org)中的主函数中执行那段代码时,我得到了以下错误:
panic: panic requested
goroutine 1 [running]:
main.panicTest(0x128701, 0xee7e0)
/tmp/sandbox420149193/main.go:12 +0x60
main.main()
/tmp/sandbox420149193/main.go:6 +0x20
这个panic导致程序终止,这就是为什么hello world从未被打印出来。相反,我们得到了panic信息。
因此,既然我们已经了解了恐慌是如何工作的,一个显而易见的问题就出现了——我们如何捕捉一个panic并防止它杀死我们的程序?
在回答这个问题之前,我们首先需要介绍defer的概念。defer关键字可以用来表示一段代码必须在周围函数返回后才能执行。和往常一样,在查看代码示例之后,这会更容易理解:
func printEnding(message string) {
fmt.Println(message)
}
func doSomething() {
//In here we use the keyword "defer"
//This will call printEnding() right after doSomething()
defer printEnding("doSomething() just ended")
//In here, we just print values from 0 to 5
for i := 0; i <= 5; i++ {
fmt.Println(i)
}
}
在前面的代码中,当我们使用defer时,我们实际上要求printEnding()函数在doSomething()执行完毕后立即执行。
defer语句基本上是将一个函数调用推送到一个列表中,并且当周围函数返回后,这个保存的调用列表会被执行。defer最常用于清理资源,比如关闭文件处理器等。
下面是前面程序的完整版本:
package main
import (
"fmt"
)
func main() {
doSomething()
}
func printEnding(message string) {
fmt.Println(message)
}
func doSomething() {
defer printEnding("doSomething() just ended")
for i := 0; i <= 5; i++ {
fmt.Println(i)
}
}
下面是这个程序的输出:
0
1
2
3
4
5
doSomething() just ended
现在,如果我们多次在函数中使用 defer 呢?
package main
import (
"fmt"
)
func main() {
doSomething()
}
func printEnding(message string) {
fmt.Println(message)
}
func doSomething() {
defer printEnding("doSomething() just ended 2")
defer printEnding("doSomething() just ended")
for i := 0; i <= 5; i++ {
fmt.Println(i)
}
}
defer 语句通常进入一个栈数据结构,这意味着它们根据先进后出的规则执行。所以,这基本上意味着代码中的第一个 defer 语句将最后执行,而下一个将直接在其之前执行,依此类推。为了更清晰地说明,让我们看看程序的输出:
0
1
2
3
4
5
doSomething() just ended
doSomething() just ended 2
完美!我们现在可以回答我们之前的问题——如何在程序终止之前捕获和处理 panic?我们现在知道了 defer 以及它是如何确保我们选择的代码块在周围函数退出后立即执行的。所以,defers 可以用来在 panic 发生后插入代码,但 defers 足够吗?答案是:不够——有一个内置的函数叫做 recover(),我们可以用它来捕获 panic 并返回 panic 的消息。
再次强调,一个代码片段胜过千言万语:
package main
import "fmt"
func main() {
panicTest(true)
fmt.Println("hello world")
}
func checkPanic() {
if r := recover(); r != nil {
fmt.Println("A Panic was captured, message:", r)
}
}
func panicTest(p bool) {
// in here we use a combination of defer and recover
defer checkPanic()
if p {
panic("panic requested")
}
}
上述代码将产生以下输出:
A Panic was captured, message: panic requested
hello world
如你所见,我们利用了 defer 和 recover() 函数的组合来捕获 panic,以防止它终止我们的程序。如果没有发生 panic,recover() 函数将返回 nil。否则,recover() 函数将返回 panic 的错误值。如果我们单独使用 recover(),它不会有效,除非与 defer 结合使用。
摘要
本章带你踏上了 Go 语言构建块的实际学习之旅。我们涵盖了你在任何 Go 程序中可能看到的所有 Go 的基本特性。随着我们的进展,你将看到我们在本章中涵盖的构建块被反复利用。
在下一章中,我们将通过深入了解如何在 Go 中处理并发来介绍 Go 语言最受欢迎的特性之一。
问题
-
GoPath是用来做什么的? -
你如何在 Go 中编写
while循环? -
什么是命名结果?
-
函数和方法之间的区别是什么?
-
什么是类型断言?
-
defer是用来做什么的? -
Go 中的
panic是什么? -
我们如何从
panic中恢复? -
Go 中数组和切片的区别是什么?
-
什么是 interface?
-
什么是 struct?
-
什么是 map?
进一步阅读
关于本章所涵盖的内容的更多信息,你可以查看以下链接:
-
Go 网站: golang.org
-
安装 Go:
golang.org/doc/install -
Go 标准包:
golang.org/pkg/ -
如何编写 Go 代码:
golang.org/doc/code.html -
Go 导航: tour.golang.org
-
使用 Go 进行组合:
www.ardanlabs.com/blog/2015/09/composition-with-go.html
第三章:Go 并发
欢迎来到我们学习 Go 全栈开发的旅程的第二章。在本章中,我们将继续探讨 Go 语言的基石,通过涵盖 Go 语言中的重要主题 并发 来实现。Go 语言的并发特性可能是同类语言中最有效且易于使用的。许多转向 Go 的开发者正是因为 Go 的并发特性。本章假设你对编程和线程的概念有一些基本了解。与上一章类似,我们将主要关注最重要的和基础的概念。
本章将涵盖以下主题:
-
什么是并发?
-
Goroutines
-
Go 通道
-
select语句 -
sync包
什么是并发?
那么,什么是并发?这个术语在软件行业中使用得很频繁,尽管并不是所有开发者都理解其含义。在本节中,我们将尝试从 Go 语言的视角揭示并发的实际含义,以及为什么它对你来说是有用的。
在 Go 中,并发意味着你的程序能够将自己分割成更小的部分,然后能够在不同的时间运行不同的独立部分,目标是根据可用的资源量尽可能快地执行所有任务。
前面的定义可能(对某些人来说)看起来像是在定义线程。然而,并发的概念比线程的概念更广泛。如果你对线程的概念不太熟悉,让我们首先简要地定义一下线程。
线程是操作系统提供的一个功能,它允许你并行运行程序的一部分。假设你的程序由两个主要部分组成,第一部分和第二部分,你编写代码使得第一部分在线程一上运行,而第二部分在线程二上运行。在这种情况下,你的程序的两个部分将同时并行运行;以下图表说明了这将如何看起来:

所有这些都听起来很好;然而,在现代软件中,真正独立的线程数量与你的程序需要执行的同时并发部分数量之间存在差距。在现代软件中,你可能需要数千个程序部分同时独立运行,尽管你的操作系统可能只提供了四个线程!
并发在现代软件中非常重要,因为需要尽可能快地执行独立的代码片段,而不干扰程序的总体流程。让我们以一个简单的 Web 服务器为例;Web 服务器通常接受来自 Web 客户端的请求,例如 Web 浏览器。假设一个请求来自住在欧洲的 Jack,同时另一个请求来自住在亚洲的 Chin,这个请求同时到达 Web 服务器。你不希望因为 Jack 的请求同时到达而延迟 Chin 的请求。他们的请求应该尽可能同时且独立地处理。这正是为什么并发在现代生产软件中是一个不可或缺的功能。
在 Go 和其他现代编程语言中,这个问题通过将程序切割成许多小而独立的片段,并在可用的线程之间复用这些片段来解决。通过视觉表示,这一点会更加清晰。
假设我们有一个由 10 个不同部分组成的软件,我们希望它们并发运行,尽管我们只有两个真实的操作系统线程。Go 有将这 10 个不同的部分取出来,安排每个部分的最佳运行时间,然后根据一些非常聪明的算法将它们分配到可用线程的能力。以下是这种做法的一个简单视图:

你的程序中的 10 个部分将感觉像是在同时运行,尽管实际上,它们被巧妙地分配,以便根据可用资源尽快完成任务。Go 负责所有关于在可用线程上调度和分配这 10 段代码的复杂性,同时为你提供一个非常干净的 API,隐藏了所有算法的复杂性。这使得你可以专注于编写满足你需求的强大软件,而不必担心诸如线程管理、低级资源分配和调度等底层概念。
让我们在下一节中看看 goroutines。
Goroutines
现在是时候深入挖掘 Go 提供的干净 API,以便轻松编写并发软件了。
goroutine 可以简单地定义为程序中可以使用的轻量级线程;它不是一个真正的线程。在 Go 语言中,当你将一段代码定义为新的 goroutine 时,你实际上是在告诉 Go 运行时,你希望这段代码与其他 goroutine 并发运行。
在 Go 中,每个函数都存在于某个 goroutine 中。例如,我们在上一章中讨论的 main 函数,通常是程序的入口点函数,它在所谓的main goroutine上运行。
那么,如何创建一个新的 goroutine 呢?你只需在你希望并发运行的函数之前添加go关键字。语法相当简单:
go somefunction()
在这里,somefunction() 是你希望并发运行的代码片段。每次你创建一个新的 goroutine,它都会被调度以并发运行,并且不会阻塞当前的 goroutine。
下面是一段简单但更完整的代码片段,帮助我们理解 goroutine 的概念:
package main
import (
"fmt"
"time"
)
func runSomeLoop(n int) {
for i := 0; i < n; i++ {
fmt.Println("Printing:", i)
}
}
func main() {
go runSomeLoop(10)
//block the main goroutine for 2 seconds
time.Sleep(2 * time.Second)
fmt.Println("Hello, playground")
}
上述代码是一个简单的程序,它在新的 goroutine 上运行一个名为 runSomeLoop() 的函数。这意味着 runSomeLoop() 将与 main() 函数并发运行。在程序中,我们使用了 time 包中的一个名为 Sleep() 的函数。这个函数会阻塞主 goroutine,以便给 runSomeLoop() 运行并完成的机会。如果我们在这个例子中不阻塞主 goroutine,主 goroutine 很可能先完成,然后程序在 runSomeLoop() 完全运行之前退出。在某些情况下,这是由于 goroutines 并发的事实所导致的副作用,这就是为什么调用新的 goroutine 不会阻塞当前 goroutine。
程序的输出将如下所示:
Printing: 0
Printing: 1
Printing: 2
Printing: 3
Printing: 4
Printing: 5
Printing: 6
Printing: 7
Printing: 8
Printing: 9
Hello, playground
这表明 runSomeLoop() goroutine 在主 goroutine 睡眠时成功并发运行。当主 goroutine 唤醒时,它在退出之前打印了 Hello, playground。
那么,如果我们移除了阻塞主 goroutine 的 time.Sleep() 函数会怎样?看看下面的代码块:
package main
import (
"fmt"
)
func runSomeLoop(n int) {
for i := 0; i < n; i++ {
fmt.Println("Printing:", i)
}
}
func main() {
go runSomeLoop(10)
fmt.Println("Hello, playground")
}
你将得到以下结果:
Hello, playground
你可以看到,在主 goroutine 退出之前,runSomeLoop() 没有机会运行。
从内存和资源的角度来看,goroutines 非常轻量;一个生产级的 Go 程序通常会运行数百甚至数千个 goroutines。据许多 Go 用户所说,能够通过如此简单的 API 产生 goroutines 是 Go 语言最强大的特性之一。
让我们在下一节中看看 Go channels。
Go channels
现在可以解决一个重要的问题;如果我们需要在两个不同的 goroutine 之间共享一些数据怎么办?
在使用多个线程的程序中,不同线程之间共享数据的常见方法是将共享的变量进行锁定。这通常被称为共享内存****方法。*以下图示展示了两个线程如何通过共享一个名为X的变量来共享内存:

在 Go 中,有一个非常流行的格言:
“不要通过共享内存来通信;相反,通过通信来共享内存。”
这是什么意思呢?这仅仅意味着 Go 通常不倾向于通过锁定方法在线程之间共享内存(尽管有例外)。相反,Go 更倾向于通过 Go channels 在一个 goroutine 和另一个 goroutine 之间通信。这个 通信 部分是通过 Go channels 实现的。以下图示展示了这种视觉上的效果:

让我们看一下下一节中的常规和缓冲通道。
常规通道
在 Go 中声明通道,你只需使用make关键字,如下所示:
myChannel := make(chan int)
在前面的代码中,我们创建并初始化了一个名为myChannel的通道,它可以存储int类型的值。然后,这个通道可以被用来从一个 goroutine 向另一个 goroutine 发送int类型的值。
这是从通道接收值的步骤:
//myIntValue will host the value received from the channel
myIntValue := <-myChannel
这是向通道发送值的步骤:
myChannel <- 4
无论何时你在常规 Go 通道上执行发送或接收操作,你的 goroutine 都会阻塞,直到值完全发送或接收。这简单意味着,如果你通过通道发送一个值,但没有其他 goroutine 在另一端等待它,你的 goroutine 将会阻塞。另一方面,如果你尝试通过通道接收一个值,但没有其他 goroutine 在另一端发送它,你的 goroutine 也会阻塞。这种行为确保了你的代码是同步的,你的值是新鲜且最新的,并且避免了在其他编程语言中使用锁时可能遇到的许多问题。
让我们看一下展示两个 goroutine 通信的完整程序,以便更多地了解 Go 通道:
package main
import (
"fmt"
"time"
)
func runLoopSend(n int, ch chan int) {
for i := 0; i < n; i++ {
ch <- i
}
close(ch)
}
func runLoopReceive(ch chan int) {
for {
i, ok := <-ch
if !ok {
break
}
fmt.Println("Received value:", i)
}
}
func main() {
myChannel := make(chan int)
go runLoopSend(10, myChannel)
go runLoopReceive(myChannel)
time.Sleep(2 * time.Second)
}
在前面的代码中,我们创建了一个名为myChannel的通道,然后将其传递给两个 goroutine:runLoopSend()和runLoopReceive()。runLoopSend()函数将不断向这个通道发送值,而runLoopReceive()函数将不断从这个通道接收值。
以下代码将提供以下输出:
Received value: 0
Received value: 1
Received value: 2
Received value: 3
Received value: 4
Received value: 5
Received value: 6
Received value: 7
Received value: 8
Received value: 9
让我们首先关注runLoopSend(),因为在这里我们展示了一个新的概念。看看以下代码行:
close(ch)
这个语法可以用来关闭通道。一旦通道被关闭,就不能再向它发送数据,否则将发生 panic。
现在,让我们看看runLoopReceive,特别是以下这一行:
i, ok := <-ch
前面的行是一个特殊的语法,用于检查通道是否已关闭。如果通道未关闭,ok的值将为 true,而i将获取通过通道发送的值。另一方面,如果通道已关闭,ok将为 false。在runLoopReceive goroutine 中,如果ok为 false,我们将跳出for循环。
实际上,还有另一种更优雅的方式来编写这个for循环:
for {
i, ok := <-ch
if !ok {
break
}
fmt.Println("Received value:", i)
}
我们可以用以下代码替换前面的代码:
for i := range ch {
fmt.Println("Received value:", i)
}
for..range语法在通道上是允许的,因为它允许你从通道接收数据,直到通道被关闭。
程序的输出将简单地如下所示:
Received value: 0
Received value: 1
Received value: 2
Received value: 3
Received value: 4
Received value: 5
Received value: 6
Received value: 7
Received value: 8
Received value: 9
缓冲通道
缓冲通道是一种特殊的通道类型,它包含一个缓冲区,可以存储多个项目。与普通通道不同,缓冲通道只有在以下情况下才会阻塞:
-
通道的缓冲区为空,我们正在尝试从通道接收一个值。
-
通道的缓冲区已满,我们正在尝试向通道发送一个值。
要声明一个缓冲通道,我们使用以下语法:
myBufferedChannel := make(chan int,10)
之前的语法创建了一个可以容纳 10 个int值的缓冲通道。
要向缓冲通道发送一个值,我们使用与常规通道相同的语法。每次发送操作都将一个项目添加到缓冲区中,如下所示:
myBufferedChannel <- 10
要从缓冲通道接收一个值,我们也使用相同的语法。每次接收操作都将从缓冲区中移除一个项目,如下所示:
x := <-myBufferedChannel
让我们在下一节中查看select语句结构。
select语句
select语句是 Go 语言中的一个重要结构。它允许你同时控制多个通道。使用select,你可以向不同的通道发送或接收值,然后根据第一个解除阻塞的通道执行代码。
这将通过一个示例来最好地解释;让我们看一下以下代码片段:
select {
case i := <-ch:
fmt.Println("Received value:", i)
case <-time.After(1 * time.Second):
fmt.Println("timed out")
}
在前面的例子中,我们使用了select语句来控制两个不同的通道。第一个通道被称为ch,我们尝试从中接收一个值。相比之下,第二个通道是由time.After()函数产生的。time.After()函数在 Go 语言中非常流行,尤其是在select语句中。该函数生成一个通道,仅在指定超时后接收值,实际上产生了一个预定时间段的阻塞通道。你可以在代码中使用time.After()与select语句结合,在你想超时另一个通道的接收或发送操作时。
这里是另一个发送带有超时通道的select语句的示例,但这次是接收和发送操作的组合:
select {
case i := <-ch1:
fmt.Println("Received value on channel ch1:", i)
case ch2 <- 10:
fmt.Println("Sent value of 10 to channel ch2")
case <-time.After(1 * time.Second):
fmt.Println("timed out")
}
前面的代码将在三个通道之间进行同步:ch1、ch2和time.After()通道。select语句将等待这三个通道,然后根据哪个通道先完成,执行相应的select情况。
select语句还支持default情况。如果没有通道准备好,default情况将立即执行;以下是一个示例:
select {
case i := <-ch1:
fmt.Println("Received value on channel ch1:", i)
case ch2 <- 10:
fmt.Println("Sent value of 10 to channel ch2")
default:
fmt.Println("No channel is ready")
}
在前面的代码中,如果ch1和ch2都因time而阻塞,则执行select语句,然后触发default情况。
如果在select语句的控制下多个通道同时完成,则随机选择要执行的通道情况。
让我们在下一节中查看sync包。
sync包
本章我们将要讨论的最后一个主题是sync包。当你绝对需要在 Go 中创建互斥锁时,你会使用sync包。尽管我们提到 Go 更倾向于使用通道在 goroutines 之间通信数据,但在某些情况下,锁或互斥对象(mutex)是不可避免的。一个在 Go 标准包中使用锁的例子是http包,其中使用锁来保护特定http服务器对象的监听器集合。这个监听器集合可以从多个 goroutines 中访问,因此它们被互斥锁保护。
在计算机编程的世界里,单词mutex指的是一个允许多个线程访问相同资源(如共享内存)的对象。互斥锁之所以这样命名,是因为它允许一次只有一个线程访问数据。
互斥锁在软件中的工作流程通常如下:
-
一个线程获取互斥锁
-
只要有一个线程拥有互斥锁,其他线程就不能获取互斥锁
-
获取互斥锁的线程可以无干扰地访问一些资源,而其他线程不会干扰
-
当其任务完成时,获取互斥锁的线程会释放互斥锁,以便其他线程可以再次竞争它
在 Go 中,你使用 goroutines 而不是完整的线程。所以,当你使用 Go 中的互斥锁时,它们将管理 goroutines 之间的资源访问。
让我们看看下一节中的简单互斥锁、读写互斥锁和等待组。
简单互斥锁
在 Go 中,一个简单的锁是互斥锁struct类型的指针,它属于sync包。我们可以创建一个互斥锁如下:
var myMutex = &sync.Mutex{}
假设我们有一个名为myMap的map[int]int类型的 map,我们希望保护它免受多个 goroutines 的并发访问:
myMutex.Lock()
myMap[1] = 100
myMutex.Unlock()
如果我们确保所有需要编辑myMap的 goroutines 都能访问myMutex,我们就可以保护myMap免受多个 goroutines 同时更改的影响。
读写互斥锁
Go 还支持读写锁。读写锁区分读和写操作。所以,当你只执行并发读操作时,goroutines 不会阻塞。然而,当你执行写操作时,所有其他读和写都会阻塞,直到写锁释放。像往常一样,以下是一个示例,可以最好地解释这一点:
var myRWMutex = &sync.RWMutex{}
Go 中的读写锁由指向sync.RWMutex类型 Go 结构的指针表示,这是我们之前代码片段中初始化的。
要执行读操作,我们使用 Go 结构体的RLock()和RUnlock()方法:
myRWMutex.RLock()
fmt.Println(myMap[1])
myRWMutex.RUnlock()
要执行写操作,我们使用Lock()和Unlock()方法:
myRWMutex.Lock()
myMap[2] = 200
myRWMutex.Unlock()
*sync.RWMutex类型在 Go 的标准包中到处都可以找到。
等待组
等待组的概念对于在 Go 中构建生产级软件非常重要;它允许你在继续编写其余代码之前等待多个 goroutine 完成。
为了完全掌握等待组的优势,让我们回到一个早期的代码示例:
package main
import (
"fmt"
"time"
)
func runLoopSend(n int, ch chan int) {
for i := 0; i < n; i++ {
ch <- i
}
close(ch)
}
func runLoopReceive(ch chan int) {
for {
i, ok := <-ch
if !ok {
break
}
fmt.Println("Received value:", i)
}
}
func main() {
myChannel := make(chan int)
go runLoopSend(10, myChannel)
go runLoopReceive(myChannel)
time.Sleep(2 * time.Second)
}
在前面的代码中,我们必须让主 goroutine 休眠两秒钟,以便等待其他两个 goroutine 完成。然而,如果其他 goroutine 耗时超过两秒呢?这种简单的休眠方式并不能保证产生我们寻求的结果。相反,我们可以这样做:
package main
import (
"fmt"
"sync"
)
// Create a global waitgroup:
var wg = &sync.WaitGroup{}
func main() {
myChannel := make(chan int)
//Increment the wait group internal counter by 2
wg.Add(2)
go runLoopSend(10, myChannel)
go runLoopReceive(myChannel)
//Wait till the wait group counter is 0
wg.Wait()
}
func runLoopSend(n int, ch chan int) {
//Ensure that the wait group counter decrements by one after //our function exits
defer wg.Done()
for i := 0; i < n; i++ {
ch <- i
}
close(ch)
}
func runLoopReceive(ch chan int) {
//Ensure that the wait group counter decrements after our //function exits
defer wg.Done()
for {
i, ok := <-ch
if !ok {
break
}
fmt.Println("Received value:", i)
}
}
Go 中的WaitGroup结构体类型是一种包含内部计数器的类型;只要内部计数器不为0,等待组就会阻塞您的 goroutine。在前面的代码中,我们创建了一个指向WaitGroup的全局指针变量,我们称之为wg。这个变量将在这个简单的程序中的所有函数中可见。在我们触发两个 goroutine 之前,我们使用wg.Add(2)方法将等待组内部计数器增加2。之后,我们继续创建我们的两个 goroutine。对于每个添加的 goroutine,我们添加以下代码:
defer wg.Done()
这使用了defer和wg.Done()方法的组合,以确保每当 goroutine 函数执行完毕时,wg.Done()都会被调用。wg.Done()方法将内部等待组计数器减一。
最后,在我们的主 goroutine 结束时,我们调用wg.Wait(),这将阻塞当前 goroutine,直到等待组的内部计数器为零。这将反过来迫使主 goroutine 等待直到我们程序中的所有 goroutine 完成执行。
前面代码的最终输出如下:
Received value: 0
Received value: 1
Received value: 2
Received value: 3
Received value: 4
Received value: 5
Received value: 6
Received value: 7
Received value: 8
Received value: 9
摘要
在本章中,我们介绍了生产级 Go 编程世界中的关键概念。我们从实用的角度讲解了并发,然后深入探讨了 Go 提供的 API,以允许您以最小的复杂性编写高效的并发软件。
在下一章中,我们将从 Go 编程切换到前端编程,通过介绍流行的 React 框架的构建块来开始新的主题。
问题
-
什么是并发?
-
什么是线程?
-
并发的概念与并行线程的概念有何不同?
-
什么是 goroutine?
-
“通过通信共享内存”是什么意思?
-
什么是 Go 通道?
-
正规的 Go 通道和带缓冲的 Go 通道之间的区别是什么?
-
你应该在什么情况下使用
select语句? -
sync.Mutex和sync.RWMutex之间的区别是什么? -
你应该在什么情况下使用等待组?
进一步阅读
更多信息,您可以查看以下链接:
-
Golang 中的并发:
www.minaandrawos.com/2015/12/06/concurrency-in-golang/ -
包
sync:golang.org/pkg/sync/
第二部分:前端
在本节中,读者将开始一段学习之旅,探索构建全栈软件关键的现代前端技术。读者还将学习如何通过 GopherJS 框架在前端使用 Go 语言。在这一部分,我们将开始构建我们的 GoMusic 商店。本部分将涵盖全栈开发的前半部分。
本节包含以下章节:
-
第四章,使用 React.js 的前端
-
第五章,为 GoMusic 构建前端
第四章:使用 React.js 的前端
在本章中,我们将迈出第一步,了解全栈的上半部分,即前端。为了正确构建任何软件产品,你需要有足够的实践经验来构建一个美观的用户界面。这对于构建其他人乐于使用的软件产品至关重要。
我们将讨论强大的 React 框架,它是目前市场上最受欢迎的前端框架之一。React 允许你构建能够实时响应数据变化的动态网站。这使得你可以构建响应式和智能的网站。今天互联网上大多数流行的网站都是用 React 构建的。我们将从非常实用的角度来介绍 React,因此我们将直接深入探讨如何编写 React 应用程序,而不是处理理论或相关主题。
在本章中,我们将讨论以下主题:
-
如何构建 React 应用程序
-
安装 React
-
JSX 和 React 元素
-
React 组件
-
属性
-
状态
-
React 开发者工具
先决条件和技术要求
React 框架简单来说就是一组我们可以用来构建美观、响应式 UI 的 JavaScript 模块。正因为如此,你需要一些 JavaScript 知识来理解本章内容。
一个非常好的资源,可以帮助你重新熟悉 JavaScript,可以在 developer.mozilla.org/en-US/docs/Web/JavaScript/A_re-introduction_to_JavaScript 找到。
在本章中,我们将主要使用 ES6,这可以简单地被认为是 JavaScript 的新版本。
在本章中,我们将遇到 ES6 的四个核心特性:
-
类:在 JavaScript 中,类可以被看作是一个特殊函数,在其中你可以定义内部方法。更多信息,请访问
developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes。 -
箭头函数:箭头函数是 JavaScript 的匿名函数版本。更多信息,请访问
developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions。 -
let 关键字:
let关键字用于声明块作用域局部变量。更多信息,请访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let。 -
const 关键字:
const关键字用于声明局部作用域的常量变量,其初始值不能改变。更多信息,请访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const。
GoMusic 项目
在本章中,我们将利用 React 的力量为 GoMusic 商店构建一个产品页面。GoMusic 商店是我们将在整本书中构建的主要全栈项目,它基本上是一个乐器在线商店。前端将使用 React 构建,而后端将使用 Go 构建。
下面是我们今天正在构建的产品页面:

我们将采用逐步的方法来构建这个页面。这个网页将不会是普通的。我们将利用强大的 React 开发工具,在每次我们做出更改时实时更新这个页面。为了进一步展示 React 及其工具的能力,这个网页将依赖于一个 JSON 文件,该文件将包含我们试图出售的乐器信息,包括图片、名称、价格和描述。每次我们更新 JSON 文件时,网页将自动使用 JSON 文件中的新信息进行更新。
要构建 GoMusic 网站,我们需要 Node.js 和节点包管理器(npm),我们将在下一节中看到。
Node.js 和 npm
我们还将使用 npm。npm 是一个非常流行的包,它托管了几乎所有知名的 Node.js 和 JavaScript 包。我们需要 npm 来安装 React 以及我们将用于构建 React 应用程序的辅助工具。
npm 通常与 Node.js 一起分发。所以,如果你已经在你的计算机上安装了较新的 Node.js 版本,npm 也应该在那里。
如果你还没有安装 Node.js,请访问nodejs.org/en/在你的计算机上安装 Node.js。确保你有涉及工具的最新版本。所以,如果你有 Node,但它是旧版本,请继续将其更新到新版本。
你可以在www.npmjs.com/get-npm找到有关 npm 安装的更多信息。
让我们在下一节中看看 HTML、CSS 和 Bootstrap。
HTML、CSS 和 Bootstrap
本章主要关注 React,并假设读者熟悉前端基础知识,如 HTML 和 CSS。
对 HTML 和 CSS 有一些基本了解应该足以让你理解这一章。HTML 是用于构建网页的语言,而 CSS 是用于美化网页的语言(添加颜色等)。
如果你以前接触过 HTML 和 CSS,那就足够了。然而,如果你在继续之前更喜欢更深入地探索 HTML,请查看developer.mozilla.org/en-US/docs/Learn/HTML/Introduction_to_HTML。对于探索 CSS,这个链接是一个很好的资源:developer.mozilla.org/en-US/docs/Learn/CSS/Introduction_to_CSS。
我们将利用强大的 Bootstrap 4 框架来构建我们的前端视图,因此熟悉 Bootstrap 也会有助于本章的学习。
你可以在 getbootstrap.com/docs/4.1/getting-started/introduction/ 找到有关 Bootstrap 的信息。
开始使用 Bootstrap 的一种最实用的方法是使用他们的启动模板,你可以在 getbootstrap.com/docs/4.1/getting-started/introduction/#starter-template 找到它。
项目代码
对于本项目我们将涵盖的代码,有一个 GitHub 仓库,你可以在 github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/tree/master/Chapter04 找到它。
让我们在下一节中看看 React 框架。
React 框架
React (或 React.js) 是一个用于构建网页用户界面的 JavaScript 库;它于 2013 年首次由 Facebook 发布,自那时以来其受欢迎程度呈指数级增长。该库目前由 Facebook、Instagram 以及一个充满热情的社区维护。React 可以构建性能优异且交互性强的网站。
在下一节中,我们将看到如何构建 React 应用程序。
如何构建 React 应用程序
要理解和构建 React 应用程序,你必须首先了解库的工作原理以及协同工作以组成你的 React 应用程序的各个部分。在本节中,我们将解释你需要遵循的步骤序列来构建一个 React 应用程序。
这里是解释如何构建 React 应用的最简单方法:
-
为你的应用程序创建 React 元素:
-
元素是 React 中最基本的构建块之一。它代表了一块视觉用户界面,例如一张图片、带有粗体字的线条或一个按钮。
-
你可以通过混合 JSX、CSS 和 JavaScript 来创建一个元素。
-
-
使用 React 组件包裹你的 元素:
-
使用 props 在你的 React 组件之间传递数据:
-
Prop 允许一个组件向另一个组件发送数据。
-
在我们的项目中,我们通过 props 将产品图片、名称、价格和描述传递给产品卡片组件。
-
-
使用 state 来管理和更改你 React 组件内部的数据:
-
与 prop 不同,React 的
state对象是 React 组件内部的。 -
当你的
state对象中的数据发生变化时,React 库会重新渲染受数据变化影响的应用程序部分。 -
在我们的项目中,
state对象是每当新产品添加到我们的产品页面时发生变化的。
-
在下一节中,我们将设置我们的 React 项目。
设置项目
是时候设置我们的 React 项目了。在本节中,我们将安装启动项目所需的工具。
安装 React
现在,我们需要安装 React。幸运的是,通过引入一个名为 Create React App 的强大工具,这一步变得简单。这个工具捆绑了许多功能,允许我们创建新的 React 应用程序,实时构建它们,然后构建它们以便它们可以用于生产。
该工具可以通过 npm 软件包管理器获取。要安装工具,您需要运行以下命令:
npm install -g create-react-app
此命令将在全局范围内安装工具,这将允许您从任何地方使用该工具。
现在,在终端中,转到您希望工具运行的位置。要创建该文件夹中的新 React 应用程序,请运行以下命令:
create-react-app first-react-tutorial
确保您已安装最新版本的 Node 和 npm,以避免错误。
这将创建一个名为 first-react-tutorial 的新应用程序,位于一个同名的新文件夹下。
一旦创建了应用程序,我们就准备好编写一些代码了。但首先,让我们看看这个新应用程序的外观;导航到应用程序文件夹,然后运行 npm start 脚本。以下是它的样子:
cd first-react-tutorial
npm start
这将在本地端口 3000 上运行您的新 React 应用程序,它也应该打开一个浏览器来查看新应用程序,URL 将是 http://localhost:3000。以下是一个纯 React 应用程序的外观:

在下一节中,让我们准备新的项目。
准备新项目
使用 Create React App 工具生成的 React 应用程序包含了许多功能。为了本章的目的,我们只需要编写一个简单的 React 应用程序;以下是生成应用程序文件夹结构的样子:

在本章中,我们将主要关注构建一个简单的 React 应用程序,因此我们不需要一些与生成应用程序一起提供的花哨功能。为了简化问题,让我们删除生成应用程序 src 文件夹内的所有文件和文件夹。之后,让我们只创建一个名为 index.js 的单个空 JavaScript 文件。以下是它的样子:

完美——现在,我们准备好编写我们的简单产品页面了。第一步是将 Bootstrap 包含到我们的 HTML 中,这样我们就可以利用 Bootstrap 强大的样式。如果您打开 public 文件夹,您将找到一个 index.html 文件。此文件包含一些初始 HTML,您可以在您的 React 应用程序中使用它。我们将稍微修改此 HTML 文件以集成 Bootstrap,并且我们还将包含一个 ID 为 root 的 div。以下是修改后的 index.html 应该看起来像这样:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous">
<meta name="theme-color" content="#000000">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script src="img/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="img/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="img/bootstrap.min.js" integrity="sha384-smHYKdLADwkXOn1EmN1qk/HfnUcbVRZyYmZ4qpPea6sjB/pTJ0euyQp0Mk8ck+5T" crossorigin="anonymous"></script>
</body>
</html>
您可以直接复制前面的代码并将其粘贴到 public 文件夹内的 index.html 文件中。
下一步是将乐器的图片放入您的项目中。您可以从本项目的 GitHub 页面下载图片:github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/tree/master/Chapter04/public/img。
在您的public文件夹内创建一个名为img的新文件夹;将所有图片复制到该文件夹中。
下一步是创建一个 JSON 文件,描述我们希望在产品页面上显示的每个乐器。正如我们之前提到的,我们的 React 应用程序将依赖于这个 JSON 文件来确定要查看哪些产品。该文件名为cards.json,以下是我们应该最初放入的内容:
[{
"id" : 1,
"img" : "img/strings.png",
"imgalt":"string",
"desc":"A very authentic and beautiful instrument!!",
"price" : 100.0,
"productname" : "Strings"
}, {
"id" : 2,
"img" : "img/redguitar.jpeg",
"imgalt":"redg",
"desc":"A really cool red guitar that can produce super cool music!!",
"price" : 299.0,
"productname" : "Red Guitar"
},{
"id" : 3,
"img" : "img/drums.jpg",
"imgalt":"drums",
"desc":"A set of super awesome drums, combined with a guitar, they can product more than amazing music!!",
"price" : 17000.0,
"productname" : "Drums"
},{
"id" : 4,
"img" : "img/flute.jpeg",
"imgalt":"flute",
"desc":"A super nice flute combined with some super nice musical notes!!",
"price" : 210.0,
"productname" : "Flute"
}]
现在,我们的public文件夹应该看起来像这样:

让我们在下一节中看看 JSX 和 React 元素。
JSX 和 React 元素
JSX 可以被定义为类似于 HTML 的 JavaScript 扩展。JSX 可以在 React 中用于创建 React 元素。基本上,如果您知道 HTML,您就知道了 JSX!在 React 文档中,React 元素被简单地定义为屏幕上想要看到的内容的描述。
React 实际上并不需要 JSX 来工作——您可以用一些纯 JavaScript 替换 JSX。然而,当开发 React 应用程序时,推荐使用 JSX,并且它被 React 社区的绝大多数人所接受。让我们看看一些例子。
记得产品页面上的购买按钮吗?

这个按钮是一个我们可以用 JSX 描述的视觉元素,如下所示:
<a href="#" className="btn btn-primary">Buy</a>
您可以看到 JSX 看起来与 HTML 相似。在本章中,我们将注意到的两个主要区别之一是,在 JSX 中,当我们定义元素所属的类名时,我们使用className关键字而不是class。在前面的代码片段中,我使用了 Bootstrap 的 CSS 来设置我的元素的样式。
JSX 与 HTML 之间的另一个区别是 JSX 使用了驼峰式命名约定。例如,HTML 中的tabindex属性在 JSX 中变为tabIndex。
Bootstrap 按钮组件可以在getbootstrap.com/docs/4.1/components/buttons/找到。href属性并不指向一个真实的链接,因为我们只构建前端部分。
由于 JSX 是 JavaScript 的扩展,您可以轻松地将它集成到 JavaScript 代码中。例如,您可以简单地这样做:
const btnElement = <a href="#" className="btn btn-primary">Buy</a>;
您可以通过使用花括号在 JSX 中嵌入 JavaScript 代码。例如,我们可以这样做:
const btnName = "Buy";
const btnElement = <a href="#" className="btn btn-primary">{btnName}</a>;
或者,您可以像以下代码片段那样做:
const btnName = "Buy";
const btnClass = "btn btn-primary";
const btnElement = <a href="#" className={btnClass}>{btnName}</a>;
一个元素可以有子元素,就像我们会想到 HTML 元素一样。例如,以下是一个包含按钮元素的父div元素的示例:
<div className="card-body">
<a href="#" className="btn btn-primary">Buy</a>
</div>
由于 React 元素只是普通的对象,因此它们易于创建。要将 React 元素渲染到 文档对象模型 (DOM) 中,我们使用 ReactDOM.render(),如下所示:
const btnElement = <a href="#" className="btn btn-primary">Buy</a>;
ReactDOM.render(btnElement,document.getElementById('root'));
前面的代码会将按钮元素渲染到根 DOM 节点。
现在,我们有了足够的信息来使用 JSX 构建我们的产品卡片。作为提醒,以下是产品卡片的样子:

假设我们已经有了产品信息作为变量,如下所示:
const img = "img/strings.png";
const imgalt = "string";
const desc = "A very authentic and beautiful instrument!!";
const price = 100;
const productName = "Strings";
利用我们手头的信息,以下是我们在 JSX 中如何编写一个 React 元素来表示产品卡片:
<div className="col-md-6 col-lg-4 d-flex align-items-stretch">
<div className="card mb-3">
<img className="card-img-top" src={img} alt={imgalt} />
<div className="card-body">
<h4 className="card-title">{productname}</h4>
Price: <strong>{price}</strong>
<p className="card-text">{desc}</p>
<a href="#" className="btn btn-primary">Buy</a>
</div>
</div>
</div>
除了使用 className 而不是 class,以及使用花括号 {} 来承载 JavaScript 代码之外,前面的代码看起来就像一段 HTML 代码。我们只是简单地构建了一个带有几个子元素的 div 元素。
在前面的代码片段中,我们使用了 div 标签、一个 img 标签、一个 h4 标签和一个 a 标签来构建我们的 React 元素。所有这些标签都是任何前端开发者可能每天都会遇到的熟悉的 HTML 标签。
对于样式,我使用了 Bootstrap 的力量来使我们的产品卡片看起来很漂亮。我们使用了 Bootstrap 的网格系统来确保卡片在浏览器屏幕上的位置合适。
我们还使用了 Boostrap 的出色卡片组件,可以在以下位置找到:getbootstrap.com/docs/4.1/components/card/。
所有图片都是从 www.pexels.com 获得的,该网站提供免费图片供你在项目中使用。
让我们在下一节中看看 React 组件。
React 组件
在构建代表你视觉视图的元素之后,你需要用 React 组件将它们包裹起来,以便能够在你的 React 项目中正确使用它们。
一个 React 组件通常由以下部分组成:
-
我们在 JSX 和 React 元素 部分讨论过的 React 元素
-
Props,我们将在 Props 部分讨论
-
State,我们将在 State 部分讨论
在我们深入探讨 props 和 state 之前,让我们先了解一些基础知识。一个组件通常被编写为一个 JavaScript 类或函数。让我们编写一个简单的组件来感受它们到底是什么。
记得我们在 JSX 和 React 元素 部分提到的卡片元素吗?下面是它的样子:
<div className="col-md-6 col-lg-4 d-flex align-items-stretch">
<div className="card mb-3">
<img className="card-img-top" src={img} alt={imgalt} />
<div className="card-body">
<h4 className="card-title">{productname}</h4>
Price: <strong>{price}</strong>
<p className="card-text">{desc}</p>
<a href="#" className="btn btn-primary">Buy</a>
</div>
</div>
</div>
在一个 React 生产应用中,我们需要编写一个组件来承载这个元素。以下是 React 组件的样子:
import React from 'react';
class Card extends React.Component {
render() {
const img = "img/strings.png";
const imgalt = "string";
const desc = "A very authentic and beautiful instrument!!";
const price = 100;
const productName = "Strings";
return (
<div className="col-md-6 col-lg-4 d-flex align-items-stretch">
<div className="card mb-3">
<img className="card-img-top" src={img} alt={imgalt} />
<div className="card-body">
<h4 className="card-title">{productname}</h4>
Price: <strong>{price}</strong>
<p className="card-text">{desc}</p>
<a href="#" className="btn btn-primary">Buy</a>
</div>
</div>
</div>
);
}
}
在我们项目的 src 文件夹中,将前面的代码复制到 index.js。
现在,让我们检查前面的代码。以下是你需要知道的信息:
-
一个 React 组件是一个继承自
React.Component类型的 JavaScript 类。 -
在 React 组件类中,最重要的方法是
render(),因为这个方法返回你的组件生成的 React 元素。 -
组件名称以大写字母开头,这就是为什么
Card以 C 开头而不是 c。
你可能想知道为什么组件名称必须以大写字母开头。
这是因为在 React 中,一旦你创建了一个组件,你就可以在 JSX 中使用它作为一个 DOM 标签。所以为了区分原生 DOM 标签,例如div,和组件 DOM 标签,例如Card,我们使用大写字母作为组件的首字母。
由于你可以在 JSX 中使用组件作为 DOM 标签,你可以使用reactDOM.render()来渲染它们,如下所示:
reactDOM.render(<Card/>,document.getElementById('root'));
上述代码将在我们的 HTML 文档的root div下渲染我们的组件。
让我们在下一节中看看 React 应用程序的设计。
React 应用程序设计
现在我们已经了解了组件,是时候讨论如何利用组件的力量来设计一个 React 应用程序了。一个 React 应用程序由多个相互通信的组件组成。应该有一个主要容器组件,它作为其他组件的入口点。React 社区建议不要编写继承自其他组件的组件。相反,社区建议使用组合。所以,当我们谈论父组件和子组件时,我们并不是说子组件继承父组件的类,我们只是说父组件包含一个或多个子组件。
组合意味着你的所有组件类都应该继承自React.Component,然后父组件渲染子组件来构建你的 Web 应用程序。这最好通过一个例子来解释。
对于我们的产品页面,合适的设计将涉及两个组件:一个CardContainer组件,它将托管我们试图查看的产品卡片列表,以及一个Card组件,它将代表单个产品卡片。
CardContainer组件是父组件,而Card组件是子组件:

这两个对象都将继承自React.Component。CardContainer组件将渲染一系列Card组件来构建支持多个产品的产品页面。然而,在我们深入到CardContainer的代码之前,我们需要了解CardContainer将如何将乐器产品数据传递给Card,这正是下一节将要解决的问题。
让我们看看下一节中的 props 和 state。
Props
所以,这一切看起来都很不错。然而,在现实中,由于我们有多个产品而不是一个,我们需要一个Card组件的列表,而不仅仅是一个。将此组件硬编码多次以对应每个产品是没有意义的。因此,我们希望只编写一次卡片组件。我们在React 应用程序设计部分讨论的CardContainer组件应该将产品信息传递给Card组件。所以,我们基本上有一个需要向子组件传递一些信息的父组件。
你可能会想知道:为什么我们需要CardContainer组件将数据传递给Card组件,而不是让Card组件自己查找数据?答案是简单的:发现 React 应用的最佳设计是在数据有意义的最高层父组件中处理数据状态,然后根据需要将数据片段传递给较小的子组件。这种设计允许子组件相互同步,并与它们的父组件同步。
在我们的应用中,处理所有产品信息数据的最高层组件是CardContainer,而只需要访问单个产品信息的较小子组件由Card组件处理。在 React 的世界里,这种从父组件到子组件的数据传递是通过props对象完成的。Props简单来说就是属性。
要访问任何 React 组件内的属性,我们只需调用this.props。假设产品信息已经通过 props 传递给了我们的Card组件。下面是Card代码现在的样子:
import React from 'react';
class Card extends React.Component {
render() {
return (
<div className="col-md-6 col-lg-4 d-flex align-items-stretch">
<div className="card mb-3">
<img className="card-img-top" src={this.props.img} alt={this.props.imgalt} />
<div className="card-body">
<h4 className="card-title">{this.props.productname}</h4>
Price: <strong>{this.props.price}</strong>
<p className="card-text">{this.props.desc}</p>
<a href="#" className="btn btn-primary">Buy</a>
</div>
</div>
</div>
);
}
}
在前面的代码中,我们简单地通过this.props访问了props对象,并且因为我们假设产品信息已经通过 props 传递给了我们,所以props对象包含了我们所需的所有信息。
所以,现在来回答一个重要的问题:信息是如何通过 props 从父组件(在我们的例子中是CardContainer)传递到子组件(在我们的例子中是Card)的?
答案相当简单;由于组件名称在 JSX 中成为 DOM 标签,我们可以在 JSX 中简单地表示一个组件,如下所示:
<Card img="img/strings.png" alt="strings" productName="Strings" price='100.0' desc="A very authentic and beautiful instrument!!" />
在前面的代码中,我们使用了 JSX 创建了一个代表单个产品卡片组件的 React 元素。元素标签名是Card,而 props 作为属性传递到 JSX 中。如果你看看我们在这之前覆盖的Card组件代码,你会发现 props 的名称与我们之前在创建的 React 元素中传递的属性名称相对应。换句话说,props 是img、alt、productName、price和desc。这些名称与前面 React 元素中的属性名称相同。
因此,让我们创建一个非常简单的CardContainer组件,它只包含两个卡片,看看效果如何。根据我们目前所知,要创建一个 React 组件,你需要做以下事情:
-
创建一个继承自
React.Component的类 -
覆盖
React.Component的render()方法
下面是代码的样式:
class CardContainer extends React.Component{
render(){
return(
<div>
<Card key='1' img="img/strings.png" alt="strings" productName="Strings" price='100.0' desc="A very authentic and beautiful instrument!!" />
<Card key='2' img="img/redguitar.jpeg" alt="redg" productName="Red Guitar" price='299.0' desc="A really cool red guitar that can produce super cool music!!" />
</div>
);
}
}
从前面的代码中,我们需要涵盖两个重要的问题:
-
我们将两个卡片放在一个父
div元素中。这很重要,因为render()方法需要返回一个单一的 React 元素。 -
即使它不是
Card组件的一部分,我们也添加了一个key属性。在 React 中,key属性是一个保留属性。在使用元素列表时,key属性非常重要。每个项目都必须有唯一的key。与它的同级项目相比,key只需要在项目上是唯一的。在我们的例子中,我们有一个卡片列表,所以我们使用了keyprop。React 使用key来确定哪些项目需要重新渲染,哪些项目保持不变。React 组件不能通过props对象访问key属性。React 监控key是否被添加、删除或更改。然后,它决定哪些组件需要重新渲染,哪些组件不需要被修改。
前面的代码中的 render() 方法实际上还可以进一步重构:
render() {
//hardcoded card list
const cards = [{
"id" : 1,
"img" : "img/strings.png",
"imgalt":"string",
"desc":"A very authentic and beautiful instrument!!",
"price" : 100.0,
"productname" : "Strings"
}, {
"id" : 2,
"img" : "img/redguitar.jpeg",
"imgalt":"redg",
"desc":"A really cool red guitar that can produce super cool music!!",
"price" : 299.0,
"productname" : "Red Guitar"
}];
//get a list of JSX elements representing each card
const cardItems = cards.map(
card => <Card key={card.id} img={card.img} alt={card.imgalt} productName={card.productname} price={card.price} desc={card.desc} />
);
return (
<div>
{cardItems}
</div>
);
}
在前面的代码中,我们为了简单起见仍然硬编码了产品卡片信息,但这次我们使用了 JavaScript 的 map() 方法来创建一个 React 元素列表。然后我们将这些元素包含在一个父 div 元素中,并将其作为 render() 方法的返回结果。
前面的代码实际上还可以进一步重构,因为它需要从卡片项中为每个属性赋值,这有点冗长。相反,React 支持以下语法:
const cardItems = cards.map(
card => <Card key={card.id} {...card} />
);
在前面的语法中,我们只是使用了 ... 来将卡片项的所有属性传递给 Card 组件。这段代码之所以能工作,是因为卡片对象的属性名与 Card 组件通过 props 对象期望的属性名相同。我们仍然显式地分配了 key 属性,因为 Card 对象没有 key 属性——它有一个 id 属性。
CardContainer 组件变成了我们产品页面的入口组件,而不是 Card 组件。这意味着我们应该在我们的 HTML 文档的 root div 下渲染 CardContainer 而不是 Card:
ReactDOM.render(
<CardContainer />,
document.getElementById('root')
);
状态
在 React 库中,我们需要讨论的最后一个重要主题是 state 对象。我们已经了解到我们使用 props 对象从一个组件传递数据到另一个组件。然而,我们不能像在 Props 部分那样在生产级应用中硬编码我们的数据。乐器产品的信息需要从某处获取,而不是硬编码在我们的 React 代码中。在实际应用中,数据应该来自服务器端 API。然而,为了本章的目的,我们将从名为 cards.json 的 JSON 文件中获取产品信息。在 React 中,我们的应用程序数据需要存储在 state 对象中。
让我们看看如何在下一节中初始化和设置我们的 state 对象。
初始化状态对象
React 组件类的构造函数中应该初始化需要此状态的 state 对象。对于我们的产品页面,我们需要存储的数据仅仅是产品信息。以下是我们的初始化 state 对象的示例:
class CardContainer extends React.Component {
constructor(props) {
//pass props to the parent component
super(props);
//initialize the state object for this component
this.state = {
cards: []
};
}
/*
Rest of the card container code
*/
}
组件对象的构造函数期望props作为参数,所以我们需要做的第一件事是将props传递给父React.Component对象。
第二步是初始化我们的组件的state对象。state对象是组件内部的,因此我们在这里初始化的state对象不会与其他组件共享。我们的state对象将包含一个名为cards的列表,我们将在这里存储我们的乐器产品卡片列表。
设置我们的状态
下一个明显的步骤是用产品卡片列表设置我们的state对象。要在 React 的state对象中设置数据,我们必须使用一个称为setState()的方法,它属于我们的组件对象。以下是我们使用两个产品卡片的信息设置我们的state对象的例子:
this.setState({
cards: [{
"id" : 1,
"img" : "img/strings.png",
"imgalt":"string",
"desc":"A very authentic and beautiful instrument!!",
"price" : 100.0,
"productname" : "Strings"
}, {
"id" : 2,
"img" : "img/redguitar.jpeg",
"imgalt":"redg",
"desc":"A really cool red guitar that can produce super cool music!!",
"price" : 299.0,
"productname" : "Red Guitar"
}]
});
我们的CardContainer组件的render()方法需要改变,如下所示:
render(){
const cards = this.state.cards;
let items = cards.map(
card => <Card key={card.id} {...card} />
);
return (
<div className='container pt-4'>
<h3 className='text-center text-primary'>Products</h3>
<div className="pt-4 row">
{items}
</div>
</div>
);
}
在前面的代码中,我们做了两件主要的事情:
-
从我们的
state对象中获取了产品卡片信息 -
使用 Bootstrap 框架添加了一些额外的样式,使我们的产品页面看起来更好
显然,我们还在组件代码中硬编码我们的产品数据,这是不正确的。正如我们之前提到的,我们的产品卡片数据列表需要放在一个名为cards.json的文件中,我们在准备新项目部分创建了它。我们需要添加一些代码来从该文件中获取数据,然后相应地更改状态。我们将使用现代浏览器中常见的fetch()方法来获取cards.json文件的內容,然后我们将使用获取到的数据填充状态:
fetch('cards.json')
.then(res => res.json())
.then((result) => {
this.setState({
cards: result
});
});
前面的代码已经足够填充我们的state对象。在一个真正的生产应用中,我们会获取一个 API 地址而不是本地文件。但我们应该把代码放在哪里?
在 React 组件中,有一些生命周期方法是被支持的。生命周期方法是在组件生命周期事件发生时被调用的方法。一个组件生命周期事件的例子是当组件被挂载到树中时——每当这个事件发生时被调用的方法称为componentDidMount()。当你重写这个方法时,你在这里写的任何代码都会在组件挂载时执行。建议在componentDidMount()中编写涉及从远程位置加载数据的初始化代码。在我们的例子中,我们实际上并没有从远程位置加载数据,因为card.json文件位于我们的应用中,然而在一个真正的生产应用中,数据将存在于远程位置。所以,让我们在componentDidMount()中编写我们的初始化代码,并理解fetch()将来需要改变以从远程 API 获取数据:
componentDidMount() {
fetch('cards.json')
.then(res => res.json())
.then((result) => {
this.setState({
cards: result
});
});
}
完美——这是我们完成CardContainer组件所需的最后一部分代码。下面是这个类的完整样子:
class CardContainer extends React.Component {
constructor(props) {
super(props);
this.state = {
cards: []
};
}
componentDidMount() {
fetch('cards.json')
.then(res => res.json())
.then((result) => {
this.setState({
cards: result
});
});
}
render() {
const cards = this.state.cards;
let items = cards.map(
card => <Card key={card.id} {...card} />
);
return (
<div className='container pt-4'>
<h3 className='text-center text-primary'>Products</h3>
<div className="pt-4 row">
{items}
</div>
</div>
);
}
}
我们已经覆盖了足够的内容,可以学习如何在 React 中构建一个工作的应用。
开发者工具
React 社区确实是一个非常热情的社区。Facebook 已经发布了一系列可用于调试和排查 React 应用的工具;你可以在github.com/facebook/react-devtools找到开发工具的仓库。这些开发工具可以作为 Chrome 扩展、Firefox 扩展或独立应用程序使用。
为了本章的目的,我们将简要介绍 Chrome 扩展,你可以在chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi找到它。
一旦你安装了 Chrome React 开发者工具扩展,你就可以从 Chrome 的开发者工具中运行它:

一旦你打开 Chrome 的开发者工具,你将在你的开发者工具面板中找到一个 React 选项卡:

你将在该窗口中找到你的 React 组件。React 组件进入我们的产品页面是 CardContainer,如前一张截图所示。
对于通过开发工具暴露的每个组件,你可以深入了解子组件及其属性:

这将允许你进一步检查你的 React 应用程序并正确地排查问题,而不会带来太多复杂性。我建议你花点时间亲自尝试一下开发工具并探索它们能做什么。
摘要
在本章中,我们学习了如何设计和构建一个可工作的 React 应用程序。我们涵盖了使 React 运作的所有关键部分,例如 React 元素、组件、props 和状态。我们还了解了 JSX 以及它与 HTML 的相似之处。一旦你学会了 React,你会发现它是一个非常令人愉快的框架,可以用来构建网络产品。这也是为什么它在全球开发者中如此受欢迎的原因。
在下一章中,我们将利用在这里获得的知识来构建 GoMusic 网站的前端。
问题
-
什么是 React?
-
JSX 是什么?
-
什么是 React 元素?
-
什么是 props?
-
什么是 React 状态?
-
什么是 React 组件?
-
在组件构造函数内部你应该做哪两件事?
-
在 React 组件类中最重要的方法是什么?
-
生命周期方法是什么意思?
-
key属性是什么,为什么它很重要?
进一步阅读
关于本章涵盖的内容的更多信息,你可以查看以下链接:
-
React 网站:
reactjs.org/ -
React 文档:
reactjs.org/docs/hello-world.html -
React 教程:
reactjs.org/tutorial/tutorial.html
第五章:构建 GoMusic 的前端
现在是时候构建本书项目的第一个主要部分了。如第四章,使用 React.js 的前端所述,我们将构建一个在线乐器商店,我们将称之为 GoMusic。在本章中,我们将利用 React 框架的强大功能构建在线商店的大部分前端。我们的 GoMusic 商店将支持任何在线商店的基本功能:
-
用户应能够购买他们喜欢的任何产品。
-
用户应能够访问一个促销页面,该页面提供当前的销售和促销信息。
-
用户应能够创建自己的账户,并登录以获得更个性化的体验。
以下是本章我们将学习的我们前端的主要三个组件:
-
主页面,所有我们的网络应用程序的用户都应该看到
-
模态对话框窗口,有助于购买产品、创建账户和登录
-
用户页面,显示登录用户的个性化页面
在本章中,我们将涵盖以下主题:
-
编写非平凡的 React 应用程序
-
使用 Stripe 将信用卡服务集成到我们的前端中
-
在我们的代码中编写模态窗口
-
在我们的代码中设计路由
前置条件和技术要求
在第四章,使用 React.js 的前端,我们介绍了如何构建 React.js 应用程序的基础知识,所以在尝试跟随本章内容之前请先阅读它。
本章的要求与之前相同。以下是所需知识和工具的快速回顾:
-
npm.
-
React 框架。
-
你可以使用以下命令简单地安装 Create React App 工具:
npm install -g create-react-app
-
Bootstrap 框架。
-
熟悉 ES6、HTML 和 CSS。在本章中,我们将使用 HTML 表单在多个组件中。
本章的代码和文件可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/tree/master/Chapter05.
构建 GoMusic
现在是时候构建我们的在线商店了。我们的第一步是使用 Create React App 工具创建一个新的 React 应用程序。打开你的终端,导航到你希望 GoMusic 应用程序存在的文件夹,然后运行以下命令:
create-react-app gomusic
此命令将创建一个新的文件夹,命名为gomusic,其中将包含一个等待构建的 React 应用程序骨架。
现在通过以下命令导航到你的gomusic文件夹:
cd gomusic
在内部,你可以找到三个文件夹:node_modules、public和src。在我们开始编写应用程序之前,我们需要从src文件夹中删除一些文件。
从src文件夹中删除以下文件:
-
app.css -
index.css -
logo.svg
接下来,我们需要进入public文件夹。为了简化事情,用我们项目 GitHub 页面上的内容替换您的public文件夹内容:github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/tree/master/Chapter05/public。
GitHub 页面包含的内容包含我们将在项目中使用的图像,描述我们数据的 JSON 文件,以及包含对 jQuery 和 Bootstrap 框架支持的修改后的 HTML 文件。
在下一节中,我们将查看应用中的主要页面。
主要页面
我们 GoMusic 应用的主要页面是所有用户都应该看到的页面,无论他们是否登录了 GoMusic 账户。有三个主要页面:
-
产品页面,也就是我们的主页
-
促销页面
-
关于页面
第一页是产品页面。以下是它的样子:

如前截图所示,我们将支持一个导航菜单,允许我们在三个主要页面之间导航。主页选项将托管产品页面,所有用户都应该看到。
第二页是我们的促销页面,它应该看起来与主页非常相似,除了它将展示更少的产品和更低的价格。这个页面中的价格应该以红色显示,以强调促销:

第三页是我们的关于页面,它应该只显示一些关于 GoMusic 商店的信息:

你可能也注意到了导航菜单最右边的登录选项;这个选项将打开一个模态对话框窗口,允许我们的用户创建账户并登录。我们将在“模态对话框窗口和信用卡处理”部分中介绍模态对话框窗口。对于我们的每个主要页面,我们将创建一个 React 组件来表示它。
在src文件夹中创建以下文件:
-
Navigations.js:这个文件将包含导航菜单组件的代码。 -
ProductCards.js:这个文件将包含主页和促销页面组件的代码。 -
About.js:这个文件将包含关于页面组件的代码。
现在,让我们从导航菜单组件开始。
导航菜单
我们需要构建的第一个组件是将所有主要页面连接在一起,这个组件将是导航菜单:

在 React 框架中,为了轻松构建一个功能齐全的导航菜单,我们需要利用一个名为react-router-dom的包的强大功能。要安装此包,打开您的终端,然后运行以下命令:
npm install --save react-router-dom
一旦安装了此包,我们就可以在我们的代码中使用它。现在,让我们打开Navigation.js文件并开始编写一些代码。
我们需要做的第一件事是导入我们构建菜单所需的包。我们将利用两个包:
-
react包 -
react-router-dom包
我们需要从react-router-dom导出一个名为NavLink的类。NavLink类是一个 React 组件,我们可以在代码中使用它来创建可以导航到其他 React 组件的链接:
import React from 'react';
import {NavLink} from 'react-router-dom';
接下来,我们需要创建一个新的 React 组件,名为Navigation。它的样子如下:
export default class Navigation extends React.Component{
}
在我们的新组件内部,我们需要覆盖render()方法,正如第四章中提到的,使用 React.js 的前端,以便编写组件的视图:
export default class Navigation extends React.Component{
render(){
//The code to describe how our menu would look like
}
}
在我们的render()方法内部,我们将结合使用 Bootstrap 框架和导入的NavLink组件来构建我们的导航菜单。以下是render()方法内部的代码应该看起来像这样:
export default class Navigation extends React.Component{
render(){
//The code to describe how our menu would look like
return (
<div>
<nav className="navbar navbar-expand-lg navbar-dark bg-success fixed-top">
<div className="container">
<button type="button" className="navbar-brand order-1 btn btn-success" onClick={() => { this.props.showModalWindow();}}>Sign in</button>
<div className="navbar-collapse" id="navbarNavAltMarkup">
<div className="navbar-nav">
<NavLink className="nav-item nav-link" to="/">Home</NavLink>
<NavLink className="nav-item nav-link" to="/promos">Promotions</NavLink>
<NavLink className="nav-item nav-link" to="/about">About</NavLink>
</div>
</div>
</div>
</nav>
</div>
);
}
}
代码使用 Bootstrap 框架来设置和构建导航栏。此外,当登录按钮被点击时,我们调用一个名为showModalWindow()的函数,该函数预期作为 React 属性传递给我们。这个函数的职责是显示登录模态窗口:
<button type="button" className="navbar-brand order-1 btn btn-success" onClick={() => { this.props.showModalWindow();}}>Sign in</button>
完美:在上述代码之外,我们现在有一个可以用来显示导航菜单的功能组件。我们将在用户页面导航菜单部分探讨这个函数。
让我们在下一节中查看产品和促销页面。
产品和促销页面
现在让我们转向编写产品的页面组件。代码与我们在第四章中编写的产品页面类似,使用 React.js 的前端。让我们打开ProductsCards.js文件,然后编写以下代码:
import React from 'react';
class Card extends React.Component {
render() {
const priceColor = (this.props.promo)? "text-danger" : "text-dark";
const sellPrice = (this.props.promo)?this.props.promotion:this.props.price;
return (
<div className="col-md-6 col-lg-4 d-flex align-items-stretch">
<div className="card mb-3">
<img className="card-img-top" src={this.props.img} alt={this.props.imgalt} />
<div className="card-body">
<h4 className="card-title">{this.props.productname}</h4>
Price: <strong className={priceColor}>{sellprice}</strong>
<p className="card-text">{this.props.desc}</p>
<a className="btn btn-success text-white" onClick={()=>{this.props.showBuyModal(this.props.ID,sellPrice)}}>Buy</a>
</div>
</div>
</div>
);
}
}
上述代码代表一个单独的产品卡片组件。它使用 Bootstrap 来设置我们的卡片样式。
代码几乎与我们在第四章中构建的产品卡片相同,使用 React.js 的前端,除了少数几个差异:
-
我们通过使用 Bootstrap 的
.btn-success类将购买按钮的颜色更改为绿色。 -
我们添加了一个通过名为
priceColor的变量来更改价格颜色的选项;该变量查看一个名为promo的属性。如果promo为真,我们将使用红色;如果promo属性为假,我们将使用黑色。 -
这里的购买按钮通过调用
showBuyModal()函数打开一个模态窗口。我们将在模态对话框窗口和信用卡处理部分更详细地讨论模态窗口。
根据promo属性的值,代码将产生两种产品卡片风格。如果promo属性为假,产品卡片将看起来像这样:

如果promo属性为真,产品卡片将看起来像这样:

接下来,我们需要在 ProductsCards.js 文件中编写的下一件事是 CardContainer 组件。这个组件将负责在一个页面上一起显示产品卡片。以下是我们的卡片容器在行动中的样子:

让我们创建这个组件:
export default class CardContainer extends React.Component{
//our code
}
该组件的外观应该与我们之前在 第四章,使用 React.js 的前端 中编写的组件非常相似。下一步是编写我们组件的构造函数。这个组件将依赖于一个存储卡片信息的 state 对象。以下是构造函数的示例:
export default class CardContainer extends React.Component{
constructor(props) {
super(props);
this.state = {
cards: []
};
}
}
根据 第四章,使用 React.js 的前端,我们将卡片信息放在一个名为 cards.json 的文件中,该文件现在应该存在于我们的 public 文件夹中。该文件包含一个包含对象的 JSON 数组,其中每个对象包含有关一张卡片的数据,例如 ID、图片、描述、价格和产品名称。以下是文件中的示例数据:
[{
"id" : 1,
"img" : "img/strings.png",
"imgalt":"string",
"desc":"A very authentic and beautiful instrument!!",
"price" : 100.0,
"productname" : "Strings"
}, {
"id" : 2,
"img" : "img/redguitar.jpeg",
"imgalt":"redg",
"desc":"A really cool red guitar that can produce super cool music!!",
"price" : 299.0,
"productname" : "Red Guitar"
},{
"id" : 3,
"img" : "img/drums.jpg",
"imgalt":"drums",
"desc":"A set of super awesome drums, combined with a guitar, they can product more than amazing music!!",
"price" : 17000.0,
"productname" : "Drums"
}]
在 GoMusic 的 public 文件夹中,我们还添加了一个名为 promos.json 的文件,该文件包含有关销售和促销的数据。promos.json 中的数据与 cards.json 的数据格式相同。
现在,随着 CardContainer 构造函数的完成,我们需要重写 componentDidMount() 方法,以便编写从 cards.json 或 promos.json 获取卡片数据的代码。当显示主产品页面时,我们将从 cards.json 获取我们的产品卡片数据。而当我们显示促销和销售页面时,我们将从 promos.json 获取我们的产品卡片数据。由于卡片数据的来源不是唯一的,我们将使用一个 prop 来实现这个目的。让我们称这个 prop 为 location。以下是代码的示例:
componentDidMount() {
fetch(this.props.location)
.then(res => res.json())
.then((result) => {
this.setState({
cards: result
});
});
}
在前面的代码中,我们使用了流行的 fetch() 方法从存储在 this.props.location 中的地址检索数据。如果我们正在查看主产品页面,location 的值将是 cards.json。如果我们正在查看促销页面,location 的值将是 promos.json。一旦我们检索到卡片数据,我们就会将其存储在我们的 CardContainer 组件的 state 对象中。
最后,让我们编写 CardContainer 组件的 render() 方法。我们将从组件的 state 对象中获取产品卡片,然后将产品卡片的数据作为 props 传递给我们的 Card 组件。以下是代码的示例:
render(){
const cards = this.state.cards;
let items = cards.map(
card => <Card key={card.id} {...card} promo={this.props.promo} showBuyModal={this.props.showBuyModal} />
);
return (
<div>
<div className="mt-5 row">
{items}
</div>
</div>
);
}
我们还向卡片组件传递了 showBuyModal prop;这个 prop 代表我们在 创建父 StripeProvider 组件 部分中将要实现的函数,用于打开购买模态窗口。showBuyModal 函数将期望接收以卡片形式表示的产品 ID 以及产品的销售价格作为输入。
上述代码与我们在第四章,“使用 React.js 的前端”中编写的CardContainer代码非常相似。唯一的区别是我们现在还向Card组件传递了一个promo属性。promo属性让我们知道相关的产品卡片是否是促销。
让我们看一下下一节中的About页面。
关于页面
现在让我们添加一个关于页面。以下是它的样子:

让我们在我们的项目中导航到src文件夹。创建一个名为About.js的新文件。我们将首先导入react包:
import React from 'react';
接下来,我们需要编写一个 React 组件。通常,我们会创建一个新的类,该类将继承自React.Component。然而,我们将探索一种更适合关于页面的不同编码风格。
对于简单的组件,不需要完整类的情况下,我们简单地使用所谓的功能组件。以下是将About组件编写为功能组件时的样子:
export default function About(props){
return (
<div className="row mt-5">
<div className="col-12 order-lg-1">
<h3 className="mb-4">About the Go Music Store</h3>
<p>Go music is a modern online musical instruments store</p>
<p>Explore how you can combine the power of React and Go, to build a fast and beautiful looking online store.</p>
<p>We will cover how to build this website step by step.</p>
</div>
</div>);
}
功能组件只是一个接受 props 对象作为参数的函数。该函数返回一个 JSX 对象,代表我们希望组件显示的视图。前面的代码等同于编写一个继承自React.Component的类,然后重写render()方法以返回我们的视图。
在 React 的较新版本中,功能组件可以通过名为 React Hooks的功能支持state对象。React Hook 让你能够在功能组件中初始化和使用状态。以下是从 React 文档中摘取的一个简单的state计数器示例:
import React, { useState } from 'react';
function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
我们在这里的代码中不使用 React Hooks。然而,如果你对这个功能感兴趣,你可以通过访问reactjs.org/docs/hooks-intro.html来探索它。
在我们进入下一节之前,值得提一下,Card组件也可以被编写为一个功能组件,因为它相对简单,不需要构造函数或任何超出render()方法的特殊逻辑。
现在,让我们谈谈如何在我们的 React 应用程序中构建对话框窗口。
模态对话框和信用卡处理
现在是时候介绍我们网站中的模态窗口了。模态窗口是一个覆盖在您主网站上的小临时窗口。我们需要构建两个主要的模态窗口:
-
购买物品模态窗口
-
登录模态窗口
购买物品模态窗口概要
让我们从购买物品的模态窗口开始。当用户点击产品卡上的购买按钮时,这个模态窗口应该出现;换句话说,当你点击购买按钮时:

一旦点击购买按钮,以下模态窗口应该出现:

如您所见,模态窗口基本上是一个出现在我们主网站上的小窗口。它允许用户在返回主网站之前输入一些重要数据。模态窗口是任何现代网站中非常强大的工具,所以让我们开始编写一个。我们今天构建的模态窗口需要能够处理信用卡信息。
在我们开始编写代码之前,我们需要安装一个名为 reactstrap 的重要包。这个包通过 React 组件暴露了 Bootstrap 框架提供的功能;它有一个非常实用的 Modal 组件,我们可以用它来构建响应式的模态窗口。让我们从我们最喜欢的终端运行以下命令。该命令可以从项目的主文件夹中执行:
npm install --save reactstrap
第一步是进入 src 文件夹,然后创建一个名为 modalwindows.js 的新文件。这个文件是我们将编写所有模态窗口的地方。接下来,让我们导入 React 库:
import React from 'react';
然后,我们从 reactstrap 包中导入与模态相关的组件:
import { Modal, ModalHeader, ModalBody } from 'reactstrap';
由于我们是从“购买项目”模态窗口开始的,让我们编写一个名为 BuyModalWindow 的 React 组件:
export class BuyModalWindow extends React.Component{
}
我们在这里使用 export 关键字,因为这个类需要导出到其他文件。再次,我们将利用 Bootstrap 前端框架的力量来构建我们的模态窗口。当我们编写我们的 Card 组件时,我们设计了“购买”按钮,使其通过 #buy ID 打开一个模态窗口。所以,这就是我们的 #buy 模态窗口:
export class BuyModalWindow extends React.Component{
render() {
return (
<Modal id="buy" tabIndex="-1" role="dialog" isOpen={props.showModal} toggle={props.toggle}>
<div role="document">
<ModalHeader toggle={props.toggle} className="bg-success text-white">
Buy Item
</ModalHeader>
<ModalBody>
{/*Credit card form*/}
</ModalBody>
</div>
</Modal>
);
}
}
在前面的代码中,我们构建了一个封装了绿色标题、关闭按钮和空体的漂亮模态窗口的 React 组件。信用卡表单 代码尚未包含;我们将在 *使用 React 和 Stripe 处理信用卡* 部分中介绍。该代码使用了由 reactstrap 包提供的 Modal 组件。reactstrap 包还提供了一个 ModalHeader 组件,让我们指定模态窗口的标题外观,以及一个 ModalBody 组件来定义模态窗口的内容。
Modal 组件包含两个我们需要处理的非常重要的 React 属性:
-
isOpen属性:一个布尔值,当需要模态窗口显示时需要设置为 true,否则值为 false -
toggle属性:一个回调函数,当需要时用于切换isOpen的值
让我们先对我们的代码进行快速调整。由于 BuyModalWindow 组件除了 render() 方法外不包含任何其他方法,我们可以将其编写为一个函数式组件:
export function BuyModalWindow(props){
return (
<Modal id="buy" tabIndex="-1" role="dialog" isOpen={props.showModal} toggle={props.toggle}>
<div role="document">
<ModalHeader toggle={props.toggle} className="bg-success text-white">
Buy Item
</ModalHeader>
<ModalBody>
{/*Credit card form*/}
</ModalBody>
</div>
</Modal>
);
}
太好了,现在让我们填充模态窗口的空体。我们需要编写一个 信用卡表单 作为模态窗口的体。
在下一节中,我们将查看我们应用程序的信用卡处理。
使用 React 和 Stripe 处理信用卡
建立一个仅接受信用卡信息的表单的想法可能一开始看起来很简单。然而,这个过程不仅仅涉及构建一些文本框。在生产环境中,我们需要能够验证输入到信用卡的信息,并需要找出一种安全的方式来处理信用卡数据。由于信用卡信息极其敏感,我们不能像对待其他任何数据一样处理它。
幸运的是,有几种服务可供您在前端代码中处理信用卡。在本章中,我们将使用 Stripe (stripe.com/),这是处理信用卡支付最受欢迎的服务之一。就像生产环境中的几乎所有其他网络服务一样,您需要访问其网站,创建一个账户,并获取用于产品代码的 API 密钥。使用 Stripe,注册还涉及提供您的企业银行账户,以便他们可以将钱存入您的账户。
然而,他们也提供了一些测试 API 密钥,我们可以利用这些密钥进行开发和初步测试,这正是我们今天将要使用的。
Stripe 帮助您在应用程序中处理信用卡收费的每个步骤。Stripe 验证信用卡,按照您提供的批准金额进行收费,然后将这笔钱存入您的企业银行账户。
为了完全集成 Stripe 或几乎任何其他支付服务与您的代码,您需要在前端和后端编写代码。在本章中,我们将介绍前端所需的大部分代码。我们将在稍后的章节中再次讨论此主题,当时我们将处理后端,以编写完整的集成。让我们开始吧。
由于 React 前端框架的巨大流行,Stripe 提供了特殊的 React 库和 API,我们可以使用它们来设计可以接受信用卡数据的视觉元素。这些视觉元素被称为 React Stripe 元素 (github.com/stripe/react-stripe-elements)。
React Stripe 元素提供以下功能:
-
他们提供了一些可以接受信用卡数据的 UI 元素,例如信用卡号码、到期日期、CVC 码和 ZIP 码。
-
他们可以对输入的数据进行高级验证。例如,对于信用卡字段,他们可以判断是否正在输入 MasterCard 或 Visa。
-
在 Stripe 元素接受它们提供的数据后,它们会为您提供代表相关信用卡的令牌 ID,然后您可以在后端输入此令牌 ID 以在您的应用程序中使用这张卡。
完美。既然我们已经对 Stripe 有足够的了解,让我们开始编写一些代码。
您必须在代码中涵盖一系列步骤,以正确地将信用卡处理与前端代码集成:
-
创建一个 React 组件来承载您的
Credit card form代码。让我们称它为childReact 组件;您很快就会看到为什么它是一个child组件。 -
在该组件内部,使用 Stripe 元素,这些元素是 Stripe 提供的一些 React 组件,用于构建信用卡输入字段。这些字段只是接受信用卡信息的文本框,例如信用卡号码和到期日期。
-
在这个
child组件内部,编写代码以将验证过的信用卡令牌提交到后端。 -
创建另一个 React 组件。这个组件将作为承载 Stripe 元素的 React 组件的父组件。父 React 组件需要执行以下操作:
-
承载处理 Stripe API 密钥的 stripe 组件,也称为
StripeProvider。 -
在
StripeProvider组件内部,您需要承载childReact 组件。 -
在您能够承载
childReact 组件之前,您需要使用特殊的 Stripe 代码将其注入,该代码将 Stripe 属性和函数包裹在组件周围。将组件注入 Stripe 代码的方法称为injectStripe。
-
让我们一步一步地实现前面的步骤。
创建一个承载 Stripe 元素的子 React 组件
首先,我们需要安装 Stripe react 包。在终端中,我们需要导航到我们的 gomusic 项目文件夹,然后运行以下命令:
npm install --save react-stripe-elements
接下来,让我们访问位于 frontend/public/index.html 的 index.html 文件。然后,在 HTML 结束标签之前,也就是在 </head> 之前的一行,输入 <script src="img/"></script>。这将确保当最终用户在浏览器中加载我们的 GoMusic 应用程序时,Stripe 代码将被加载。
现在,让我们编写一些代码。在我们的 src 文件夹中,让我们创建一个名为 CreditCards.js 的新文件。我们首先导入我们需要的包,以便我们的代码能够正常工作:
import React from 'react';
import { injectStripe, StripeProvider, Elements, CardElement } from 'react-stripe-elements';
是时候编写我们的 child React 组件了,它将承载我们的信用卡表单:
class CreditCardForm extends React.Component{
constructor(props){
super(props);
}
}
为了使我们的代码尽可能真实,我们需要遵循与处理信用卡相关的三种状态:
-
初始状态:尚未处理任何卡。
-
成功状态:卡已处理并成功。
-
失败状态:卡已处理但失败。
下面是表示这三种状态的代码:
const INITIALSTATE = "INITIAL", SUCCESSSTATE = "COMPLETE", FAILEDSTATE = "FAILED";
class CreditCardForm extends React.Component{
constructor(props){
super(props);
}
}
接下来,让我们编写三个方法来表示三种状态:
const INITIALSTATE = "INITIAL", SUCCESSSTATE = "COMPLETE", FAILEDSTATE = "FAILED";
class CreditCardForm extends React.Component{
constructor(props){
super(props);
}
renderCreditCardInformation() {}
renderSuccess() {}
renderFailure(){}
}
这三个方法将根据我们的当前状态被调用。我们需要在我们的 React state 对象中保存我们的当前状态,这样我们就可以在任何时候在组件内部检索它:
const INITIALSTATE = "INITIAL", SUCCESSSTATE = "COMPLETE", FAILEDSTATE = "FAILED";
class CreditCardForm extends React.Component{
constructor(props){
super(props);
this.state = {
status: INITIALSTATE
};
}
renderCreditCardInformation() {}
renderSuccess() {}
renderFailure(){}
}
状态将根据信用卡交易的成功或失败而改变。现在是时候编写我们的 React 组件的 render() 方法了。我们的 render() 方法将简单地通过检查 this.state.status 来查看当前状态,然后根据状态,渲染相应的视图:
const INITIALSTATE = "INITIAL", SUCCESSSTATE = "COMPLETE", FAILEDSTATE = "FAILED";
class CreditCardForm extends React.Component{
constructor(props){
super(props);
this.state = {
status: INITIALSTATE
};
}
renderCreditCardInformation() {}
renderSuccess() {}
renderFailure(){}
render() {
let body = null;
switch (this.state.status) {
case SUCCESSSTATE:
body = this.renderSuccess();
break;
case FAILEDSTATE:
body = this.renderFailure();
break;
default:
body = this.renderCreditCardInformation();
}
return (
<div>
{body}
</div>
);
}
}
剩下的就是编写三个渲染方法的代码了。让我们从最复杂的开始,即renderCreditCardInformation()方法。这里我们将使用 Stripe 元素组件。以下是该方法需要生成的视图:

我们将首先编写代表使用已保存卡按钮的 JSX 元素,它在开始处,以及接近结尾处的记住卡复选框。我们将单独编写这些元素,因为稍后我们需要隐藏它们,不让未登录的用户看到:
renderCreditCardInformation() {
const usersavedcard = <div>
<div className="form-row text-center">
<button type="button" className="btn btn-outline-success text-center mx-auto">Use saved card?</button>
</div>
<hr />
</div>
const remembercardcheck = <div className="form-row form-check text-center">
<input className="form-check-input" type="checkbox" value="" id="remembercardcheck" />
<label className="form-check-label" htmlFor="remembercardcheck">
Remember Card?
</label>
</div>;
//return the view
}
在前面的代码中,我们再次使用了 Bootstrap 框架来设计按钮和复选框。
利用 Stripe 元素处理信用卡信息
现在是时候设计信用卡支付信息的用户界面了。这是我们需要构建的部分:

最有趣的部分是卡信息字段:

这是我们将使用 Stripe 元素组件的地方,以便将 Stripe 的 UI 和验证集成到我们的用户界面中。如果你还记得,我们在CreditCards.js文件的开始处导入了一个名为CardElement的包。CardElement只是一个由 Stripe 提供的 React 组件,用于在 React 应用程序中构建信用卡字段 UI。这是我们将在代码中使用的 Stripe 元素。我们可以像使用任何其他组件一样简单地利用它:
<CardElement\>
Stripe 元素组件支持一个名为style的属性,它允许你定义元素的外观样式。style属性接受一个 JavaScript 对象,该对象定义了 Stripe 元素的外观样式。以下代码显示了一个看起来与 Bootstrap 框架视觉上相得益彰的style对象:
const style = {
base: {
'fontSize': '20px',
'color': '#495057',
'fontFamily': 'apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'
}
};
为了让我们的卡片 Stripe 元素接受前面的style对象,我们只需要这样做:
<CardElement style={style}/>
完美。现在让我们构建renderCreditCardInformation()方法的其余部分。随着 Stripe 卡片元素的解决,我们需要构建一个 HTML 表单,该表单将托管 Stripe 卡片元素以及信用卡支付模态窗口中的“卡上姓名”字段。以下是 UI 的 JSX 代码:
<div>
<h5 className="mb-4">Payment Info</h5>
<form>
<div className="form-row">
<div className="col-lg-12 form-group">
<label htmlFor="cc-name">Name On Card:</label>
<input id="cc-name" name='cc-name' className="form-control" placeholder='Name on Card' onChange={this.handleInputChange} type='text' />
</div>
</div>
<div className="form-row">
<div className="col-lg-12 form-group">
<label htmlFor="card">Card Information:</label>
<CardElement id="card" className="form-control" style={style} />
</div>
</div>
</form>
</div>
前面的代码只显示了一个托管“卡上姓名”以及卡片元素视觉组件的 HTML 表单。我们还使用了一个名为handleInputChange()的方法,该方法在输入“卡上姓名”字段时触发。该方法根据 HTML 表单的新“卡上姓名”值更改我们组件的state对象。这是推荐的 React 处理表单的方式——创建状态以对应 HTML 表单的值:
handleInputChange(event) {
this.setState({
value: event.target.value
});
}
是时候编写信用卡信息窗口的完整代码了,包括记住卡和使用已保存卡选项。下面是完整的renderCreditCardInformation()方法应该看起来像什么:
renderCreditCardInformation(){
const style = {
base: {
'fontSize': '20px',
'color': '#495057',
'fontFamily': 'apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'
}
};
const usersavedcard = <div>
<div className="form-row text-center">
<button type="button" className="btn btn-outline-success text-center mx-auto">Use saved card?</button>
</div>
<hr />
</div>
const remembercardcheck = <div className="form-row form-check text-center">
<input className="form-check-input" type="checkbox" value="" id="remembercardcheck" />
<label className="form-check-label" htmlFor="remembercardcheck">
Remember Card?
</label>
</div>;
return (
<div>
{usersavedcard}
<h5 className="mb-4">Payment Info</h5>
<form onSubmit={this.handleSubmit}>
<div className="form-row">
<div className="col-lg-12 form-group">
<label htmlFor="cc-name">Name On Card:</label>
<input id="cc-name" name='cc-name' className="form-control" placeholder='Name on Card' onChange={this.handleInputChange} type='text' />
</div>
</div>
<div className="form-row">
<div className="col-lg-12 form-group">
<label htmlFor="card">Card Information:</label>
<CardElement id="card" className="form-control" style={style} />
</div>
</div>
{remembercardcheck}
<hr className="mb-4" />
<button type="submit" className="btn btn-success btn-large" >{this.props.operation}</button>
</form>
</div>
);
}
上述代码使用了我们的remembercardcheck和usersavedcard元素。我们还假设存在一个名为handleSubmit的方法,该方法将在我们的 HTML 表单提交时触发。handleSubmit方法将在提交信用卡令牌到后端部分进行讨论。
完美。现在让我们编写CreditCardForm组件中剩余的方法:renderSuccess()和renderFailure()。我们首先从renderSuccess()开始:

代码很简单:
renderSuccess(){
return (
<div>
<h5 className="mb-4 text-success">Request Successfull....</h5>
<button type="submit" className="btn btn-success btn-large" onClick={() => { this.props.toggle() }}>Ok</button>
</div>
);
}
上述代码通过toggle方法与购买模态窗口相关联,该toggle方法将作为 prop 传递给我们的组件。如前所述,toggle方法可以用来打开或关闭模态窗口。由于当此代码执行时模态窗口将打开,因此当按下确定按钮时,模态窗口将关闭。toggle方法的完整语法将在我们代码的后续部分定义,具体来说,当我们在App.js文件中介绍主要代码时。
下面是如何查看renderFailure()方法的:

上述 UI 的代码如下所示:
renderFailure(){
return (
<div>
<h5 className="mb-4 text-danger"> Credit card information invalid, try again or exit</h5>
{this.renderCreditCardInformation()}
</div>
);
}
将信用卡令牌提交到后端
现在让我们回到handleSubmit()方法,它应该在信用卡 HTML 表单提交时触发。当你使用 Stripe 元素组件时,它不仅验证信用卡信息,还返回一个代表输入的信用卡的token对象。这个token对象是你将在后端用来扣款的。
handleSubmit()方法代码需要处理许多事情:
-
获取与输入的信用卡对应的令牌
-
将令牌发送到我们的后端服务器
-
根据结果渲染成功或失败状态
以下是代码的样式:
async handleSubmit(event){
event.preventDefault();
console.log("Handle submit called, with name: " + this.state.value);
//retrieve the token via Stripe's API
let { token } = await this.props.stripe.createToken({ name: this.state.value });
if (token == null) {
console.log("invalid token");
this.setState({ status: FAILEDSTATE });
return;
}
let response = await fetch("/charge", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify({
token: token.id,
operation: this.props.operation,
})
});
console.log(response.ok);
if (response.ok) {
console.log("Purchase Complete!");
this.setState({ status: SUCCESSSTATE });
}
}
如果你仔细查看前面的代码,你会注意到我们使用了名为this.props.stripe.createToken()的方法,我们假设它是嵌入在我们的 props 中的,以便检索信用卡令牌:
let { token } = await this.props.stripe.createToken({ name: this.state.value });
该方法被命名为createToken()。我们传递了卡上姓名值作为参数(该值存储在我们的state对象中)。只有当我们用 Stripe 代码注入我们的 React 组件时,createToken()方法才可用。我们将在下一节中看到如何做到这一点。
我们还使用了 JavaScript 的fetch()方法,以便向相对 URL 发送 HTTP POST请求。POST请求将包括我们的 Stripe 令牌 ID 以及请求的操作类型。我们传递一个操作类型是因为我想将来使用这个请求要么从卡上取钱,要么保存卡以供以后使用。当涉及到后端代码时,我们将在适当的时候更多地讨论POST请求的另一端。
创建一个父 StripeProvider 组件
下一步是创建一个父组件来托管我们的CreditCardForm组件。以下是我们需要做的事情:
-
使用
injectStripe()方法将 Stripe API 代码注入到CreditCardForm组件中。 -
将我们的 Stripe API 密钥提供给组件。这是通过 Stripe 提供的
StripeProviderReact 组件完成的。 -
使用
Elements组件在我们的父组件中托管CreditCardForm组件。这是通过injectStripe()方法完成的。
当我们看到代码时,这会更有意义:
export default function CreditCardInformation(props){
if (!props.show) {
return <div/>;
}
//inject our CreditCardForm component with stripe code in order to be able to make use of the createToken() method
const CCFormWithStripe = injectStripe(CreditCardForm);
return (
<div>
{/*stripe provider*/}
<StripeProvider apiKey="pk_test_LwL4RUtinpP3PXzYirX2jNfR">
<Elements>
{/*embed our credit card form*/}
<CCFormWithStripe operation={props.operation} />
</Elements>
</StripeProvider>
</div>
);
}
上述代码应存在于我们的 CreditCards.js 文件中,该文件还包含了我们的 CreditCardForm 组件代码。我们还传递了一个操作作为属性,后来我们用它提交了信用卡请求到我们的后端。注意 export default 出现在我们的 CreditCardInformation 组件定义的开始处。这是因为我们将在其他文件中导入和使用此组件。
现在我们已经遵循了所有步骤来编写一个可以与 Stripe 集成的信用卡表单,现在是时候回到我们的购买模式窗口,将其嵌入信用卡表单中。我们的购买模式窗口存在于 modalwindows.js 文件中。作为提醒,以下是之前我们覆盖的购买模式窗口的代码:
export function BuyModalWindow(props) {
return (
<Modal id="buy" tabIndex="-1" role="dialog" isOpen={props.showModal} toggle={props.toggle}>
<div role="document">
<ModalHeader toggle={props.toggle} className="bg-success text-white">
Buy Item
</ModalHeader>
<ModalBody>
{/*Credit card form*/}
</ModalBody>
</div>
</Modal>
);
}
首先,我们需要将 CreditCardInformation 组件导入到 modalwindows.js 文件中。因此,我们需要在文件中添加此行:
import CreditCardInformation from './CreditCards';
我们现在需要做的就是将 CreditCardInformation 组件嵌入到模式体中:
export function BuyModalWindow(props) {
return (
<Modal id="buy" tabIndex="-1" role="dialog" isOpen={props.showModal} toggle={props.toggle}>
<div role="document">
<ModalHeader toggle={props.toggle} className="bg-success text-white">
Buy Item
</ModalHeader>
<ModalBody>
<CreditCardInformation show={true} operation="Charge" toggle={props.toggle} />
</ModalBody>
</div>
</Modal>
);
}
有了这些,我们就完成了购买模式窗口。现在让我们转到登录窗口。
登录和注册模式窗口
在我们跳入代码之前,让我们首先探索登录和注册模式窗口应该如何看起来。点击导航栏中的登录按钮:

应该出现以下模式窗口:

如果我们点击 新用户?注册 链接,模式窗口应该展开以显示新用户的注册表单:

为了正确构建那些模式窗口,我们需要了解如何在 React 框架中正确处理具有多个输入的表单。
在 React 框架中处理表单
由于 React 框架依赖于控制你的前端状态,因此表单代表了这一愿景的挑战。这是因为,在 HTML 表单中,每个输入字段根据用户输入处理自己的状态。假设我们有一个文本框,用户对其进行了更改;文本框将根据用户输入更改其状态。然而,在 React 中,更倾向于使用 setState() 方法在 state 对象中处理任何状态变化。在 React 中处理表单有多种方法。
我们将使用各种处理 HTML 表单的方法。React 鼓励所谓的 受控组件 方法,这仅仅意味着你以这种方式设计你的组件,即你的 state 对象成为唯一的真相来源。
但如何实现呢?答案是简单的:
-
您在组件内部监控您的 HTML 表单输入字段。
-
每当表单输入字段更改时,您将更改您的
state对象以保存表单输入字段的新值。 -
您的
state对象现在将保存您表单输入的最新值。
登录页面
让我们从登录页面开始。作为提醒,它看起来是这样的:

这基本上是一个包含两个文本输入字段、一个按钮、一个链接和一些标签的表单。第一步是创建一个 React 组件来托管登录表单。在modalwindows.js文件中,让我们创建一个新的组件,命名为SignInForm:
class SignInForm extends React.Component{
constructor(){
super(props);
}
}
接下来,我们需要在我们的组件中创建一个state对象:
class SignInForm extends React.Component{
constructor(){
super(props);
this.state = {
errormessage = ''
};
}
}
我们当前的state对象包含一个名为errormessage的单个字段。每当登录过程失败时,我们将错误信息填充到errormessage字段中。
之后,我们需要绑定两个方法:一个用于处理表单提交,另一个用于处理表单输入更改:
class SignInForm extends React.Component{
constructor(){
super(props);
//this method will get called whenever a user input data into our form
this.handleChange = this.handleChange.bind(this);
//this method will get called whenever the HTML form gets submitted
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
errormessage = ''
};
}
}
现在是时候编写我们的render()方法了。render()方法需要执行以下任务:
-
以表单形式显示标志,收集用户输入,然后提交表单
-
如果登录失败,显示错误消息,然后允许用户再次登录
以下是代码的样式:
render(){
//error message
let message = null;
//if the state contains an error message, show an error
if (this.state.errormessage.length !== 0) {
message = <h5 className="mb-4 text-danger">{this.state.errormessage}</h5>;
}
return (
<div>
{message}
<form onSubmit={this.handleSubmit}>
<h5 className="mb-4">Basic Info</h5>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input name="email" type="email" className="form-control" id="email" onChange={this.handleChange}/>
</div>
<div className="form-group">
<label htmlFor="pass">Password:</label>
<input name="password" type="password" className="form-control" id="pass" onChange={this.handleChange} />
</div>
<div className="form-row text-center">
<div className="col-12 mt-2">
<button type="submit" className="btn btn-success btn-large">Sign In</button>
</div>
<div className="col-12 mt-2">
<button type="submit" className="btn btn-link text-info" onClick={() => this.props.handleNewUser()}> New User? Register</button>
</div>
</div>
</form>
</div>
);
}
}
从前面的代码中,我们需要讨论一些重要点:
-
对于每个输入元素,都有一个名为
name的属性。这个属性用于标识每个输入元素。这很重要,因为当我们设置我们的state对象以反映每个输入的值时,我们需要识别输入名称。 -
对于每个输入元素,还有一个名为
onChange的属性。这个属性是我们如何调用我们的handleChange()方法,每当用户在我们的 HTML 表单中输入数据时。 -
在我们的表单末尾,如果用户决定点击“新用户?注册”链接,我们将调用一个名为
handleNewUser()的方法,该方法通过组件属性传递给我们。我们将在下一节中介绍此方法。
让我们谈谈handleChange()方法,它将使用户输入到 HTML 登录表单中的数据填充我们的state对象。为此,我们将使用一个现代 JavaScript 特性,称为计算属性名称。以下是代码的样式:
handleChange(event){
const name = event.target.name;
const value = event.target.value;
this.setState({
[name]: value
});
}
在前面的代码中,我们使用了事件目标的name属性,这对应于我们在 HTML 表单中分配的名称属性。在用户输入用户名和密码后,我们的state对象最终将看起来像这样:
state = {
'email': 'joe@email.com',
'password': 'pass'
}
我们SignInForm的最后一部分将是handleSubmit()方法。我们将在第六章中详细介绍此方法,即使用 Gin 框架的 Go 语言中的 RESTful Web APIs。因此,现在这里是一个handleSubmit()方法的占位符:
handleSubmit(event){
event.preventDefault();
console.log(JSON.stringify(this.state));
}
注册表单
我们需要讨论的下一个表单是注册表单。作为提醒,它看起来是这样的:

此表单将与登录表单位于同一文件中,即modalwindows.js。注册表单的代码将与我们刚才覆盖的登录表单的代码非常相似。区别在于注册表单比登录表单有更多字段,否则代码非常相似。以下是注册表单的代码:
class Registeration extends React.Component{
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
errormessage: ''
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
event.preventDefault();
const name = event.target.name;
const value = event.target.value;
this.setState({
[name]: value
});
}
handleSubmit(event) {
event.preventDefault();
console.log(this.state);
}
render() {
let message = null;
if (this.state.errormessage.length !== 0) {
message = <h5 className="mb-4 text-danger">{this.state.errormessage}</h5>;
}
return (
<div>
{message}
<form onSubmit={this.handleSubmit}>
<h5 className="mb-4">Registeration</h5>
<div className="form-group">
<label htmlFor="username">User Name:</label>
<input id="username" name='username' className="form-control" placeholder='John Doe' type='text' onChange={this.handleChange} />
</div>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input type="email" name='email' className="form-control" id="email" onChange={this.handleChange} />
</div>
<div className="form-group">
<label htmlFor="pass">Password:</label>
<input type="password" name='pass1' className="form-control" id="pass1" onChange={this.handleChange} />
</div>
<div className="form-group">
<label htmlFor="pass">Confirm password:</label>
<input type="password" name='pass2' className="form-control" id="pass2" onChange={this.handleChange} />
</div>
<div className="form-row text-center">
<div className="col-12 mt-2">
<button type="submit" className="btn btn-success btn-large">Register</button>
</div>
</div>
</form>
</div>
);
}
}
完美!这样一来,我们已经完成了登录表单和注册表单。我们只需要编写包含模态窗口代码,该代码将托管登录表单或注册表单。模态窗口需要完成以下任务:
-
显示登录表单
-
如果用户点击“新用户?注册”选项,则应显示注册表单
让我们在modalwindows.js文件中创建一个新的 React 组件,并命名为SignInModalWindow:
export class SignInModalWindow extends React.Component{
}
为了正确设计这个组件,我们需要考虑这样一个事实,即这个组件有两种模式——具体来说,它应该为现有用户显示登录页面,还是为新用户显示新的注册页面。在 React 的世界里,我们需要利用我们的state对象,以跟踪我们是否显示登录页面或注册页面。初始状态是显示登录页面,然后如果用户点击“新用户?注册”链接,我们改变我们的状态到注册页面:
export class SignInModalWindow extends React.Component{
constructor(props) {
super(props);
this.state = {
showRegistrationForm: false
};
this.handleNewUser = this.handleNewUser.bind(this);
}
在前面的代码中,除了初始化我们的state对象外,我们还绑定了一个名为handleNewUser()的方法。这个方法是我们当用户点击“新用户?注册”链接以加载注册表单而不是登录页面时调用的。这个方法应该改变我们state对象的值,以反映我们现在需要加载注册表单的事实:
handleNewUser() {
this.setState({
showRegistrationForm: true
});
}
这听起来不错。然而,“新用户?注册”链接存在于SignInForm React 组件中,那么我们如何在SignInForm组件中调用handleNewUser()方法呢?
答案很简单:我们将方法作为属性传递给SignInForm,然后SignInForm在点击“新用户?注册”链接时调用该方法。如果你回顾几页之前的代码,你会发现我们确实在SignInForm React 组件中将“新用户?注册”链接链接到了一个名为handleNewUser()的函数属性上,该属性在点击链接时被调用。当时我们说我们会稍后介绍这个属性,现在我们就在这里。
现在剩下的SignInModalWindow组件部分是必需的render()方法,它将总结我们需要做什么。以下是render()方法需要执行的操作:
-
检查
state对象。如果它显示我们需要显示注册表单,则加载RegistrationForm组件,否则保持SignInForm。 -
对于
SignInForm,将handleNewUser()方法作为属性传递。这是 React 世界中的一个常见设计模式。 -
加载模态窗口代码。像往常一样,我们将利用强大的 Bootstrap 框架来美化我们的表单。
-
根据我们的
state对象指示,在模态窗口中包含SignInForm或RegistrationForm:
render(){
let modalBody = <SignInForm handleNewUser={this.handleNewUser} />
if (this.state.showRegistrationForm === true) {
modalBody = <RegisterationForm />
}
return (
<Modal id="register" tabIndex="-1" role="dialog" isOpen={this.props.showModal} toggle={this.props.toggle}>
<div role="document">
<ModalHeader toggle={this.props.toggle} className="bg-success text-white">
Sign in
{/*<button className="close">
<span aria-hidden="true">×</span>
</button>*/}
</ModalHeader>
<ModalBody>
{modalBody}
</ModalBody>
</div>
</Modal>
);
}
}
在下一节中,我们将查看我们应用程序中的用户页面。
用户页面
现在是时候讨论用户页面了——用户登录我们的应用程序后应该看到什么?以下是他们应该看到的内容:
-
他们的名字在导航菜单中,有一个登出选项
-
他们现有的订单列表
让我们看看它是如何呈现的。
导航菜单应更改为如下所示:

现在它允许导航到一个名为“我的订单”的页面,该页面将显示用户的先前订单。另一个不同之处在于,我们不再看到“登录”按钮,而是看到一个欢迎

这是登出按钮,用户需要点击它来退出会话。
接下来,让我们看看“我的订单”页面:

这是一个相对简单的页面,显示用户现有的订单列表。
现在,让我们为订单页面编写一些代码。
订单页面
让我们从编写代表“我的订单”页面的 React 组件开始。与“我的订单”页面相关的有两个组件:
-
单个订单卡片组件
-
一个父容器组件,它托管所有的订单卡片组件
让我们从单个订单卡片组件开始。这是它的样子:

在 src 文件夹内创建一个名为 orders.js 的新文件。在那里,我们将编写我们的新组件。在文件的顶部,我们需要导入 React:
import React from 'react';
对于单个订单卡片组件,我们可以简单地称之为 Order。这个组件将会很简单。因为它将被包含在一个父容器组件中,我们可以假设所有的订单信息都作为 props 传递。另外,由于我们不需要创建任何特殊的方法,让我们将其制作成一个函数式组件:
function Order(props){
return (
<div className="col-12">
<div className="card text-center">
<div className="card-header"><h5>{props.productname}</h5></div>
<div className="card-body">
<div className="row">
<div className="mx-auto col-6">
<img src={props.img} alt={props.imgalt} className="img-thumbnail float-left" />
</div>
<div className="col-6">
<p className="card-text">{props.desc}</p>
<div className="mt-4">
Price: <strong>{props.price}</strong>
</div>
</div>
</div>
</div>
<div className="card-footer text-muted">
Purchased {props.days} days ago
</div>
</div>
<div className="mt-3" />
</div>
);
}
如往常一样,Bootstrap 框架使得对我们的组件进行样式设计变得轻而易举。
现在让我们转向订单容器父组件。这个组件将会更复杂一些,因为我们需要在组件的 state 对象中存储我们的订单:
export default class OrderContainer extends React.Component{
constructor(props) {
super(props);
this.state = {
orders: []
};
}
}
订单列表需要从我们应用程序的后端部分获取。正因为如此,state 对象应根据与我们的应用程序后端的交互而改变。现在我们先不考虑这部分,因为当我们在第六章“使用 Gin 框架的 Go 语言 RESTful Web APIs”设计应用程序的后端时,我们还需要涉及这部分。现在,让我们跳转到 render() 方法:
render(){
return (
<div className="row mt-5">
{this.state.orders.map(order => <Order key={order.id} {...order} />)}
</div>
);
}
前面的代码很简单。我们遍历我们的state对象中的订单,然后为每个订单对象加载一个Order组件。记住,当我们处理列表时,我们应该使用 React 框架的key属性来正确处理列表的变化,如第四章,使用 React.js 的前端开发中提到的。
如果你查看本章的 GitHub 代码,你会注意到我实际上是从一个静态文件中获取订单数据,以便更新state对象中的orders列表。这是临时的,因为我需要一些数据来展示视觉效果。
用户页面导航菜单
让我们回到位于我们的src文件夹中的navigation.js文件。我们在导航菜单部分已经写了一个Navigation React 组件,其中包含了如果用户未登录时的默认导航菜单。我们现在需要添加当用户登录时应显示的部分。第一步是编写一个新的方法,命名为buildLoggedInMenu(),它将显示欢迎 <用户> 下拉按钮,以及注销选项。
在Navigation React 组件内部,让我们添加一个新的方法:
buildLoggedInMenu(){
return (
<div className="navbar-brand order-1 text-white my-auto">
<div className="btn-group">
<button type="button" className="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Welcome {this.props.user.name}
</button>
<div className="dropdown-menu">
<a className="btn dropdown-item" role="button">Sign Out</a>
</div>
</div>
</div>
);
}
该方法使用 Bootstrap 和 JSX 来构建我们的下拉按钮和注销选项。我们假设用户名作为属性传递给我们。
现在,我们需要修改render()方法,以便在用户登录时更改导航菜单。我们假设一个属性被传递给我们,指定用户是否已登录。修改后的render()方法还需要一个新的导航链接指向我的订单页面:
render(){
return (
<div>
<nav className="navbar navbar-expand-lg navbar-dark bg-success fixed-top">
<div className="container">
{
this.props.user.loggedin ?
this.buildLoggedInMenu()
: <button type="button" className="navbar-brand order-1 btn btn-success" onClick={() => { this.props.showModalWindow();}}>Sign in</button>
}
<div className="navbar-collapse" id="navbarNavAltMarkup">
<div className="navbar-nav">
<NavLink className="nav-item nav-link" to="/">Home</NavLink>
<NavLink className="nav-item nav-link" to="/promos">Promotions</NavLink>
{this.props.user.loggedin ? <NavLink className="nav-item nav-link" to="/myorders">My Orders</NavLink> : null}
<NavLink className="nav-item nav-link" to="/about">About</NavLink>
</div>
</div>
</div>
</nav>
</div>
);
}
}
在前面的代码中,我们假设已经传给我们一个属性,告诉我们用户是否已登录(this.props.user.loggedin)。如果用户已登录,我们做两件事:
-
调用
buildLoggedInMenu()方法在导航菜单的末尾加载用户下拉按钮。 -
添加一个指向
/myorders路径的链接。这个链接将连接到OrderContainer组件。我们将在下一节中介绍如何将此链接连接到 React 组件。
将所有内容整合 – 路由
我们现在需要编写一个 React 组件,将所有前面的组件连接在一起。你可能认为我们已经编写了导航菜单——难道不应该将所有东西都链接起来吗?简单的答案是:还不行。在导航菜单组件中,我们使用了链接指向其他页面。然而,我们没有将这些链接连接到实际的 React 组件;/about链接需要连接到About React 组件,/myorders链接需要连接到OrderContainer React 组件,等等。
我们使用了一个名为NavLink的 React 组件来创建我们的链接。NavLink是从react-router-dom包中获得的,我们在导航菜单部分安装了它。NavLink组件是将链接连接到 React 组件的第一步,而第二步是另一种类型,称为BrowserRouter。让我们看看它是如何工作的。
创建一个名为App.js的新文件;它应该存在于我们的src文件夹中。这个文件将托管一个组件,它将作为所有其他组件的入口点。因此,我们需要在这里导入我们迄今为止创建的所有主要组件:
import React from 'react';
import CardContainer from './ProductCards';
import Nav from './Navigation';
import { SignInModalWindow, BuyModalWindow } from './modalwindows';
import About from './About';
import Orders from './orders';
接下来,我们需要从react-router-dom包中导入一些组件:
import { BrowserRouter as Router, Route } from "react-router-dom";
现在让我们编写我们的新组件:
class App extends React.Component{
}
由于这个组件将能够访问所有其他组件,我们需要在这里存储全局信息。对于我们应用程序来说,最重要的信息之一是用户是否已登录,因为这会影响我们的应用程序页面的外观。因此,这个组件的state对象需要反映用户是否已登录:
class App extends React.Component{
constructor(props) {
super(props);
this.state = {
user: {
loggedin: false,
name: ""
}
};
}
}
现在让我们不要担心这个状态是如何填充的,因为这将需要在编写应用程序的后端部分时解决。目前,让我们专注于如何将每个NavLink连接到 React 组件。
这涉及到三个步骤:
-
添加一个
BrowserRouter类型的组件。在我们的例子中,我们只是简单地将其命名为Router以简化。 -
在
BrowserRouter内部放置所有实例NavLink。在我们的例子中,所有的NavLink实例都在Navigation组件中;我们在这里将Navigation组件导入为Nav以简化。 -
在
BrowserRouter内部,使用我们从react-router-dom包中导入的Route组件将 URL 路径链接到 React 组件。每个 URL 路径将对应一个NavLink路径。
前面的步骤可以在我们的render()方法中实现。以下是代码的样式:
render(){
return (
<div>
<Router>
<div>
<Nav user={this.state.user} />
<div className='container pt-4 mt-4'>
<Route exact path="/" render={() => <CardContainer location='cards.json' />} />
<Route path="/promos" render={() => <CardContainer location='promos.json' promo={true}/>} />
{this.state.user.loggedin ? <Route path="/myorders" render={()=><Orders location='user.json'/>}/> : null}
<Route path="/about" component={About} />
</div>
<SignInModalWindow />
<BuyModalWindow />
</div>
</Router>
</div>
);
}
}
前面的代码实现了我们之前提到的三个步骤。它还包括SignInModalWindow和BuyModalWindow。任一模态窗口只有在用户激活它们时才会显示。
我们使用了两种不同的方式将NavLink实例连接到 React 组件:
- 如果一个组件需要一个属性作为输入,我们使用
render:
<Route path="/promos" render={() => <CardContainer location='promos.json' promo={true}/>} />
- 如果一个组件不需要属性作为输入,我们可以使用
Route组件:
<Route path="/about" component={About} />
为了使路由概念同步,让我们看看AboutReact 组件发生了什么:
-
在我们的导航菜单组件(
Navigation在Navigation.js中),我们使用了从react-router-dom获得的NavLink类型来创建一个名为/about:<NavLink className="nav-item nav-link" to="/about">About</NavLink>的路径。 -
在我们的
App组件中,我们将/about路径链接到AboutReact 组件:
<Router>
<div>
<Nav user={this.state.user} />
<div className='container pt-4 mt-4'>
{/*other routes*/}
<Route path="/about" component={About} />
</div>
{/*rest of the App component*/}
</div>
</Router>
现在,我们需要定义购买和登录模态窗口的toggle方法和show方法。show方法基本上是调用以显示购买或登录模态窗口的方法。最直接的方法是使用我们组件的state对象来指定模态窗口应该开启还是关闭。我们的应用程序设计为一次只能打开一个模态窗口,这就是为什么我们将从App组件控制它们的开启/关闭状态。
让我们先来探索购买和登录模态窗口的show方法:
showSignInModalWindow(){
const state = this.state;
const newState = Object.assign({},state,{showSignInModal:true});
this.setState(newState);
}
showBuyModalWindow(id,price){
const state = this.state;
const newState = Object.assign({},state,{showBuyModal:true,productid:id,price:price});
this.setState(newState);
}
在这两种情况下,代码都会克隆我们的state对象,同时添加并设置一个布尔字段,表示目标模态窗口应该开启。在登录模态窗口的情况下,布尔字段将被称为showSignInModal,而在购买模态窗口的情况下,布尔字段将被称为showBuyModal。
现在,让我们看看登录和购买模态窗口的toggle方法。如前所述,toggle方法用于切换模态窗口的状态。在我们的案例中,我们只需要反转表示我们的模态窗口是否打开的state布尔字段:
toggleSignInModalWindow() {
const state = this.state;
const newState = Object.assign({},state,{showSignInModal:!state.showSignInModal});
this.setState(newState);
}
toggleBuyModalWindow(){
const state = this.state;
const newState = Object.assign({},state,{showBuyModal:!state.showBuyModal});
this.setState(newState);
}
我们App组件的构造函数需要绑定我们添加的新方法,以便在代码中使用:
constructor(props) {
super(props);
this.state = {
user: {
loggedin: false,
name: ""
}
};
this.showSignInModalWindow = this.showSignInModalWindow.bind(this);
this.toggleSignInModalWindow = this.toggleSignInModalWindow.bind(this);
this.showBuyModalWindow = this.showBuyModalWindow.bind(this);
this.toggleBuyModalWindow = this.toggleBuyModalWindow.bind(this);
}
接下来,我们需要将新方法作为 prop 对象传递给需要它们的组件。我们还需要传递state.showSignInModal和state.showBuyModal标志,因为这是我们的模态窗口组件知道模态窗口是否应该可见的方式:
render() {
return (
<div>
<Router>
<div>
<Nav user={this.state.user} showModalWindow={this.showSignInModalWindow}/>
<div className='container pt-4 mt-4'>
<Route exact path="/" render={() => <CardContainer location='cards.json' showBuyModal={this.showBuyModalWindow} />} />
<Route path="/promos" render={() => <CardContainer location='promos.json' promo={true} showBuyModal={this.showBuyModalWindow}/>} />
{this.state.user.loggedin ? <Route path="/myorders" render={()=><Orders location='user.json'/>}/> : null}
<Route path="/about" component={About} />
</div>
<SignInModalWindow showModal={this.state.showSignInModal} toggle={this.toggleSignInModalWindow}/>
<BuyModalWindow showModal={this.state.showBuyModal} toggle={this.toggleBuyModalWindow} productid={this.state.productid} price={this.state.price}/>
</div>
</Router>
</div>
);
}
}
本章中还有两个代码片段需要介绍。
第一件事是使App组件可导出,因为该组件将成为我们其他组件的入口点。在App.js文件的末尾,让我们添加以下行:
export default App;
我们需要编写的第二段代码是将AppReact 组件链接到我们的模板 HTML 代码的root元素。创建一个名为index.js的文件,在其中添加以下代码:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();
这段代码使用了与 Create React App 工具一起加载的工具。
完美!随着最后一段代码的完成,我们的章节就结束了。
概述
本章深入探讨了如何使用 React 框架构建合适的前端应用程序。我们在构建应用程序的过程中涵盖了多个主题,例如路由、处理信用卡和表单以及典型的 React 框架设计方法。到这一点,你应该有足够的知识来在 React 框架中构建非平凡的应用程序。
在下一章中,我们将转换主题,重新回顾 Go。我们将开始介绍如何使用 Go 开源框架 Gin 构建我们的 GoMusic 应用程序的后端。
问题
-
什么是
react-router-dom? -
什么是
NavLink? -
什么是 Stripe?
-
我们如何在 React 中处理信用卡?
-
什么是受控组件?
-
什么是
BrowserRouter? -
Stripe 元素是什么?
-
injectStripe()方法是什么? -
我们如何在 React 中处理路由?
进一步阅读
关于本章涵盖的主题的更多信息,请查看以下链接:
-
React 路由包:
reacttraining.com/react-router/ -
Stripe:
stripe.com/ -
Stripe React 元素:
stripe.com/docs/recipes/elements-react -
在 React 中处理表单:
reactjs.org/docs/forms.html
第三部分:Go 语言中的 Web API 和中间件
在本节中,我们将通过利用流行的 Gin 框架,深入探讨使用 Go 语言构建 Web API 和中间件。本节将通过涵盖 Gin 框架,涵盖后端技术的基本和高级概念。它还将涵盖重要的实践主题,例如测试和性能分析生产应用程序。本节将涵盖全栈的下半部分。
本节包含以下章节:
-
第六章,使用 Gin 框架在 Go 语言中实现 RESTful Web API
-
第七章,使用 Gin 框架的高级 Go Web 应用程序
-
第八章,测试和基准测试您的 Web API
-
第九章,使用 GopherJS 介绍同构 Go
-
第十章,从这里开始去哪里?
第六章:使用 Gin 框架在 Go 中构建 RESTful Web API
在前面的章节中,我们探讨了如何利用流行的 React 框架构建我们应用程序的引人入胜的前端。
现在是时候介绍如何在 Go 编程语言中构建高效的后端代码,以便与我们的前端一起工作。在本章中,我们将使用超快的 Gin 框架构建本书项目(即 GoMusic 商店)所需的某些 API。
在本章中,我们将介绍以下主题:
-
RESTful API
-
Gin 框架
-
模型和绑定
-
HTTP 处理器
技术要求
本章的代码可以在以下位置找到:github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/tree/master/Chapter06。
RESTful API
任何后端软件系统都需要一组 API 来与前端通信。全栈软件简单地说是由一侧的前端组件和另一侧的后端组件交换消息组成的。全栈软件中最流行的 API 类型之一是 RESTful API。
让我们在下一节中概述 RESTful API。
概述
RESTful API 可以简单地定义为用于构建 Web 服务的一组规则,其中你可以检索或操作资源。资源通常是一种文档——它可能是一个 HTML 文档(例如网页),一个 JSON 文档(用于纯信息共享),或其它类型的文档。JSON 代表 JavaScript 对象表示法;这是因为它基本上指的是你如何在 JavaScript 中编写对象。它非常流行并且被广泛使用。
对于大多数 RESTful API,HTTP 被用作 API 的通信层:

这个主题可能非常冗长;然而,有一些简单的事实和概念你需要了解,以便正确地编写 RESTful API。在接下来的几节中,我们将概述 RESTful API 背后的关键构建块。
在接下来的几节中,我们将探讨客户端-服务器架构、URL 和 HTTP 方法。
客户端-服务器架构
RESTful API 依赖于客户端-服务器架构。这仅仅意味着对于 RESTful API,你需要两个主要组件——客户端和服务器。客户端向服务器发送 HTTP 请求,服务器向客户端回复 HTTP 响应。单个服务器通常同时处理多个客户端:

以下要点可以帮助解释前面的图表:
-
客户端是发起 API 请求的组件。客户端要么从服务器请求资源,要么向服务器发送资源。
-
服务器是接收请求并对其进行处理的组件。服务器在客户端请求时发送资源,或者在客户端请求这样做时添加/修改资源。
URLs
URL 可以简单地定义为特定 RESTful API 资源的地址。
当客户端向服务器发送请求时,客户端会将请求发送到服务器正在监视的 URL 地址。任何 RESTful API 交互都涉及客户端向 URL 地址发送消息。
为了理解 URL 由什么组成,让我们以http://www.example.com/user?id=1为例。
前面的 URL 包含三个主要组成部分,我们需要注意如下:
-
服务器位置:这基本上是协议和服务器域名的组合,
http://www.example.com/。 -
相对 URL 路径:这是从服务器地址开始的相对 URL 地址,
/user。 -
查询:这是一个用于识别我们寻求哪些资源的查询,例如
?id=1。
前两个组件存在于绝大多数 RESTful API 交互中,而第三个组件用于更专业的 API 调用。
HTTP 方法
如前所述,客户端向服务器发送请求。请求可以用来从服务器检索资源,或者操作由服务器托管的资源。然而,我们如何判断特定请求的意图?这就是 HTTP 方法发挥作用的地方。HTTP 方法基本上是客户端如何向服务器表明其意图。
HTTP 请求可以支持多种方法类型;然而,由于本书主要关注实用性,我们将讨论在 RESTful API 领域中最为常用的三种请求方法:
-
GET 请求方法:当客户端的意图是从服务器检索资源时,使用
GETHTTP 请求方法。每次你打开一个网络浏览器,例如 Google Chrome,并输入www.google.com时,你的网络浏览器就充当一个 HTTP 客户端,向www.google.comURL 发送一个GETHTTP 请求。然后,Google 服务器接收你的客户端请求,并响应 Google 的主页,这是一个简单的 HTML 文档,然后由你的网络浏览器翻译成漂亮的界面。 -
POST 请求方法:当客户端的意图是向服务器发送数据时,使用
POSTHTTP 请求方法。当客户端发送POST请求时,它还必须在请求的消息体中包含服务器应该接收的数据。请求的 URL 地址标识了我们要添加或更改的资源。还有一个名为PUT的 HTTP 方法,可以用来添加或替换资源。然而,在我们的代码中我们将使用POST。 -
删除请求方法:当客户端的意图是从服务器删除资源时,会使用删除 HTTP 请求方法。请求的 URL 将标识我们想要删除的资源。
现在我们已经涵盖了 REST API,是时候探索 Gin 框架了,这是我们将在 Go 代码中构建 REST API 的方式。
Gin 框架
Gin 是一个非常流行的 Go 开源框架,主要用于构建超高性能的 RESTful API。该项目可以在 github.com/gin-gonic/gin 找到。Gin 不仅速度快,而且拥有简单、易于使用的 API,这使得构建生产级别的 RESTful API 变得轻而易举。
在本节中,我们将学习如何通过 Gin 框架构建一个 Web RESTful API,通过开始实现支持我们的 GoMusic 商店的必要后端代码。
让我们看看下一节中的模型和数据库层。
模型和数据库层
我们的后端显然需要一个数据库来存储我们的 RESTful API 应该公开的所有数据。让我们把数据库交互代码称为我们的数据库层。在后端软件系统的世界中,编写数据库层时需要谨慎和周到的设计。这是因为数据库层对于后端系统中的几乎所有主要功能都是至关重要的。
让我们在下一节中详细了解模型。
模型
构建良好设计的数据库层的第一步是为数据构建模型。数据模型可以简单地描述为表示我们从数据库检索并用于我们的 API 的信息的数据结构。这最好通过一个例子来解释。
在我们的 GoMusic 应用程序中,因为它只是一个简单的在线商店,出售产品,我们可以确定我们的应用程序需要支持的不同模型,如下所示:
-
一个产品
-
一个客户
-
一个客户订单
让我们开始编写一些代码;在你的项目根目录下,你需要创建一个名为 backend 的新文件夹。在这个文件夹下面,创建一个名为 src 的文件夹,然后在 src 文件夹下面,创建一个名为 models 的文件夹。现在,在 models 文件夹中,创建一个名为 models.go 的新文件。这就是我们将编写我们的模型的地方。我们首先需要做的是定义一个包,如下所示:
package models
接下来,让我们编写我们的 Product 数据结构,如下所示:
type Product struct{
Image string `json:"img"`
ImagAlt string `json:"imgalt"`
Price float64 `json:"price"`
Promotion float64 `json:"promotion"`
ProductName string `json:"productname"`
Description string `json:"desc"`
}
你可能对我们的 Go 结构体中奇怪的 `json:"..."` 语法感到困惑;这种语法被称为结构体标签。在我们的例子中,结构体标签用于指示相关字段在 JSON 文档中的外观。JSON 是一个非常流行的数据序列化格式,通常用于在 RESTful API 中共享数据。前面的 Go 结构体在 JSON 格式下将看起来像以下代码片段:
{
"img": "/path/to/img.jpeg",
"imgalt": "image alt",
"price": 100,
"promotion":80,
"productname": "guitar",
"desc": "A black guitar with with amazing sounds!!"
}
从前面的 JSON 数据块中,你可以看出在 JSON 中表示数据是多么容易。
如果在我们的 Go 结构体中不使用 JSON 结构字段,Go 在将我们的 Go 结构体字段名转换为 JSON 字段名时会做出一些默认假设。例如,Go 结构体字段中的所有大写首字母将转换为 JSON 文档中的小写首字母。通常,使用 JSON 结构体标签是为了完全控制 JSON 文档的外观。
完美;现在,让我们编写我们的Customer数据结构,如下所示:
type Customer struct {
FirstName string `json:"firstname"`
LastName string `json:"lastname"`
Email string `json:"email"`
LoggedIn bool `json:"loggedin"`
}
接下来,让我们编写我们的Order数据结构,如下所示:
type Order struct{
Product
Customer
CustomerID int `json:"customer_id"`
ProductID int `json:"product_id"`
Price float64 `json:"sell_price"`
PurchaseDate time.Time `json:"purchase_date"`
}
前面的数据结构使用了 Go 的一项特性,称为嵌入。在我们的例子中,嵌入简单意味着我们在当前 Go 结构体中包含了另一个 Go 结构体的所有字段。我们在Order Go 结构体中嵌入了Product和Customer Go 结构体。这意味着所有产品客户 Go 结构体字段,如img和firstname,现在都是Order结构体的一部分。
让我们看一下下一节中的数据库层接口。
数据库层接口
设计良好的数据库层的另一个重要部分是数据库层接口。为了完全理解数据库接口层的必要性,让我们想象一个快速场景。假设你构建的后端使用数据库 X,并且所有代码都依赖于对数据库 X 的直接调用。现在,如果数据库 X 证明非常昂贵,而你发现了一个更便宜、更易于维护的数据库可以在代码中使用,我们可以将这个新数据库称为数据库 Y。你现在必须重新审视所有查询数据库 X 的代码,并进行更改,这可能会影响比数据库层更多的代码。
那么,我们该怎么做呢?我们只需创建一个接口,定义我们从数据库层期望的所有行为。数据库层之外的所有代码应该只使用此接口提供的方法,而不使用其他任何方法。现在,如果我们想从数据库 X 迁移到数据库 Y,我们可以简单地编写一个新的数据库层,它可以与数据库 Y 通信,同时仍然支持现有的数据库层接口。通过这样做,我们确保数据库层之外的大量现有代码将保持不变,并按预期运行。
我们下一步是编写 GoMusic 应用的数据库层接口。为此,我们必须首先确定我们希望从数据库层获得的行为,如下所示:
-
获取所有产品的列表
-
获取所有促销活动的列表
-
通过客户的首名和姓氏获取客户信息
-
通过客户的
id获取客户信息 -
通过产品的
id获取产品信息 -
将用户添加到数据库中
-
在数据库中将用户标记为已登录
-
在数据库中将用户标记为已注销
-
通过客户的
id获取客户订单列表
在backend/src文件夹内,让我们创建一个名为dblayer的新文件夹。在这个文件夹内,我们将创建一个名为dblayer.go的新文件。这是我们编写数据库层接口的地方。我们通过声明包名和导入模型包开始我们的代码,如下所示:
package dblayer
import (
"github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/Chapter06/backend/src/models"
)
接下来,我们编写我们的接口,它封装了本节中涵盖的所有行为点,如下所示:
type DBLayer interface{
GetAllProducts() ([]models.Product, error)
GetPromos() ([]models.Product, error)
GetCustomerByName(string, string) (models.Customer, error)
GetCustomerByID(int) (models.Customer, error)
GetProduct(uint) (models.Product, error)
AddUser(models.Customer) (models.Customer, error)
SignInUser(username, password string) (models.Customer, error)
SignOutUserById(int) error
GetCustomerOrdersByID(int) ([]models.Order, error)
}
在下一章中,我们将回到数据库层继续其实现。但到目前为止,让我们专注于我们的 REST API 层及其使用 Gin 框架的实现。
使用 Gin 框架实现 RESTful API
如前所述,GoMusic 这样的全栈应用程序的后端代码需要通过 RESTful API 与前端组件交互。这仅仅意味着我们后端代码的一个主要部分是 RESTful API 层。从现在起,我们将讨论这个层,直到本章结束。
在开始编写代码之前,我们首先需要就我们的需求达成一致。任何精心设计的 RESTful API 后端的第一步是首先弄清楚前端组件和后端组件之间的不同交互。
这是我们的 RESTful API 需要做的事情:
-
我们的后端需要向前端提供一个可用的产品列表。
-
我们的后端需要向前端提供一个可用的促销列表。
-
我们的前端需要将用户信息发送到我们的后端,以便登录现有用户或添加新用户。
-
我们的前端需要将用户注销请求发送到后端。
-
我们的后端需要提供一个特定用户的现有订单列表。
-
我们的前端需要将信用卡令牌信息发送到后端以处理收费。
通过查看前面的点,我们可以猜测每个点应使用哪种 HTTP 方法:
-
对于第一、第二和第五点,我们使用
GETHTTP 请求,因为服务器只需要提供资源(在我们的例子中,这些是 JSON 文档)作为对客户端请求的响应。 -
对于第三、第四和第六点,我们使用
POSTHTTP 请求,因为服务器预计将根据客户端请求添加或操作资源。
让我们看看如何在下一节中定义路由。
定义路由
实现 RESTful API 的下一步是定义对应于我们需要发生的不同 API 动作的不同 URL。这也被称为定义路由,因为 URL 是我们 API 资源的路由。
我们将逐一通过 RESTful API 交互并定义它们的路由。但首先,让我们为我们的 RESTful API 创建一个新文件。
在backend/src文件夹中,创建一个名为rest的新文件夹。在rest文件夹内,创建一个名为rest.go的文件。这是我们开始使用 Gin 框架的地方。在你的终端中,输入以下命令以部署和安装 Gin 框架到你的开发环境:
go get -u github.com/gin-gonic/gin
在rest.go文件中,首先声明包名并导入 Gin 框架,如下所示:
package rest
import (
"github.com/gin-gonic/gin"
)
然后,声明一个作为我们 RESTful API 入口点的函数。这是我们定义 RESTful API 的 HTTP 路由的地方:
func RunAPI(address string) error{
}
前面的方法接受一个参数,该参数将托管我们的 RESTful API 服务器的地址。
为了使用 Gin,我们首先需要获取一个Gin 引擎。Gin 引擎是对象类型,它为我们提供了将 HTTP 方法分配给 URL 以执行操作的能力:
func RunAPI(address string) error{
r := gin.Default()
}
接下来,我们需要开始将 HTTP 方法映射到 URL 以执行操作。为此,我们需要使用我们刚刚创建的 Gin 引擎对象。以下代码块是一个简单的示例,展示了我们如何使用 Gin 引擎接受一个到达相对 URL /relativepath/to/url 的GET请求:
func RunAPI(address string) error{
r := gin.Default()
r.GET("/relativepath/to/url", func(c *gin.Context) {
//take action
})
}
在前面的代码中,匿名函数func(c *gin.Context){}是我们定义当收到满足我们条件(/relativepath/to/url相对路径和GET HTTP 方法)的客户端请求时想要执行的操作的地方。
*gin.Context类型是由 Gin 框架提供的。它为我们提供了所有工具,不仅可以帮助我们探索传入的请求,还可以采取行动并提供适当的响应。我们将在下一节中更详细地讨论*gin.Context类型,但现在,让我们专注于构建路由。让我们回顾一下 API 交互列表,并编写一些代码来表示每个交互:
- 我们的后端需要通过一个
GET请求向前端提供一个可用的产品列表:
//get products
r.GET("/products",func(c *gin.Context) {
//return a list of all products to the client
}
)
- 我们的后端需要通过一个
GET请求向前端提供一个可用的促销列表:
//get promos
r.GET("/promos",func(c *gin.Context) {
//return a list of all promotions to the client
}
)
- 我们的前端需要通过一个
POST请求将用户信息发送到我们的后端,以便登录现有用户或添加新用户:
//post user sign in
r.POST("/users/signin", func(c *gin.Context) {
//sign in a user
}
)
//add user
r.POST("/users",func(c *gin.Context){
//add a user
}
)
- 我们的前端需要通过一个
POST请求将用户注销请求发送到后端:
//post user sign out
/*
In the path below, our relative url needs to include the user id
Since the id will differ based on the user, the Gin framework allows us to include a wildcard. In Gin, the wildcard will take the form ':id' to indicate that we are expecting a parameter here with the name 'id'
*/
r.POST("/user/:id/signout",func(c *gin.Context) {
//sign out a user with the provided id
}
)
- 我们的后端需要通过一个
GET请求提供一个特定用户的现有订单列表:
//get user orders
r.GET("/user/:id/orders", func(c *gin.Context) {
//get all orders belonging to the provided user id
}
)
- 我们的前端需要将信用卡令牌信息发送到后端以处理一笔交易:
//post purchase charge
r.POST("/users/charge", func(c *gin.Context) {
//charge credit card for user
}
)
让我们看看如何在下一节中构建一个 HTTP 处理器。
创建处理器
在构建我们的 RESTful API 的下一步逻辑步骤中,我们需要定义当收到客户端请求时需要执行的操作。这也被称为构建处理器。所以,让我们开始吧。
在backend/src/rest文件夹中,创建一个名为handler.go的新文件。在这个文件中,我们将编写处理服务器预期接收的不同 API 请求所需操作所需的代码。
像往常一样,我们首先需要做的是声明包并导入我们需要的外部包,如下所示:
package rest
import (
"fmt"
"log"
"net/http"
"strconv"
"github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/Chapter06/backend/src/dblayer"
"github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/Chapter06/backend/src/models"
"github.com/gin-gonic/gin"
)
为了编写可扩展的干净代码,让我们编写一个表示处理器需要支持的所有方法的接口,如下所示:
type HandlerInterface interface {
GetProducts(c *gin.Context)
GetPromos(c *gin.Context)
AddUser(c *gin.Context)
SignIn(c *gin.Context)
SignOut(c *gin.Context)
GetOrders(c *gin.Context)
Charge(c *gin.Context)
}
接下来,让我们创建一个名为Handler的结构体类型;这将包含我们所有的Handler方法。Handler需要访问数据库层接口,因为我们的所有Handler方法都需要检索或更改数据:
type Handler struct{
db dblayer.DBLayer
}
为了遵循良好的设计原则,我们应该为Handler创建一个构造函数。我们现在将创建一个简单的构造函数,如下所示:
func NewHandler() (*Handler, error) {
//This creates a new pointer to the Handler object
return new(Handler), nil
}
前面的构造函数将需要在将来进化以初始化数据库层。然而,让我们现在专注于Handler方法。
在接下来的几个部分中,让我们逐点关注我们的 API 需要做什么,然后创建相应的处理程序。每个小节将代表我们需要实现的 API 功能。
获取所有可用产品的完整列表
首先,让我们创建一个名为GetProducts的方法,它接受*gin.Context类型作为参数:
func (h *Handler) GetProducts(c *gin.Context) {
}
接下来,我们需要确保我们的数据库接口已初始化且不是nil,然后我们使用数据库层接口来获取产品列表:
func (h *Handler) GetProducts(c *gin.Context) {
if h.db == nil {
return
}
products, err := h.db.GetAllProducts()
}
那么,如果调用返回错误会发生什么?我们需要返回一个包含错误的 JSON 文档给客户端。客户端的响应还需要包括一个 HTTP 状态码,以指示请求失败。HTTP 状态码是一种在 HTTP 通信中报告错误的方式。这就是我们开始使用*gin.Context类型的地方,它包括一个名为JSON()的方法,我们可以使用它来返回 JSON 文档:
func (h *Handler) GetProducts(c *gin.Context) {
if h.db == nil {
return
}
products, err := h.db.GetAllProducts()
if err != nil {
/*
First argument is the http status code, whereas the second argument is the body of the request
*/
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
最后,如果没有错误,我们将返回从数据库检索到的产品列表。由于我们在数据模型中定义了 JSON 结构标签,我们的数据模型将转换为定义的 JSON 文档格式:
func (h *Handler) GetProducts(c *gin.Context) {
if h.db == nil {
return
}
products, err := h.db.GetAllProducts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, products)
}
获取促销列表
此处理程序将与GetProducts处理程序非常相似,但它将使用不同的数据库调用来检索促销列表,而不是产品列表:
func (h *Handler) GetPromos(c *gin.Context) {
if h.db == nil {
return
}
promos, err := h.db.GetPromos()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, promos)
}
登录新用户或添加新用户
我们将在本节中创建的方法处理一个POST请求;在这里,我们期望从客户端接收一个 JSON 文档,在我们处理请求之前需要对其进行解码。让我们假设客户端发送了一个表示客户的 JSON 文档。解码 JSON 对象的代码将类似于以下代码块:
func (h *Handler) SignIn(c *gin.Context) {
if h.db == nil {
return
}
var customer models.Customer
err := c.ShouldBindJSON(&customer)
}
c.ShouldBindJSON(...)方法是由*gin.Context类型提供的。其主要目的是从我们的 HTTP 请求体中提取 JSON 文档,然后将其解析到提供的参数中。在我们的例子中,提供的参数是一个类型为*models.Customer的变量,这是我们的客户/用户数据模型。
我们SignIn方法的其余部分很简单——如果没有错误从 JSON 文档解码到数据模型,我们调用SignInUser数据库层方法来登录或把客户添加到数据库中:
func (h *Handler) SignIn(c *gin.Context) {
if h.db == nil {
return
}
var customer models.Customer
err := c.ShouldBindJSON(&customer)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
customer,err = h.db.SignInUser(customer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, customer)
}
要添加用户,逻辑将非常相似,只是我们添加用户而不是登录某人:
func (h *Handler) AddUser(c *gin.Context) {
if h.db == nil {
return
}
var customer models.Customer
err := c.ShouldBindJSON(&customer)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
customer,err = h.db.AddUser(customer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, customer)
}
登出请求
对于此处理程序,我们期望一个带有参数的 URL,例如 /user/:id/signout。以下是处理程序需要执行的操作:
- 提取代表我们要注销的用户 ID 的参数。这可以通过调用一个名为
Param()的方法来实现,该方法属于*gin.Context类型;以下代码块演示了这将如何显示:
func (h *Handler) SignOut(c *gin.Context) {
if h.db == nil {
return
}
p := c.Param("id")
// p is of type string, we need to convert it to an integer type
id,err := strconv.Atoi(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
- 接下来,我们需要调用
SignOutUserById数据库层方法,以便在数据库中标记用户已注销:
func (h *Handler) SignOut(c *gin.Context) {
if h.db == nil {
return
}
p := c.Param("id")
id, err := strconv.Atoi(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err = h.db.SignOutUserById(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
获取特定用户的订单
我们也期望这里有一个带有参数的 URL,例如 /user/:id/orders。:id 参数代表我们试图检索订单的用户 ID。代码将如下所示:
func (h *Handler) GetOrders(c *gin.Context) {
if h.db == nil {
return
}
// get id parameter
p := c.Param("id")
// convert the string 'p' to integer 'id'
id, err := strconv.Atoi(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// call the database layer method to get orders from id
orders, err := h.db.GetCustomerOrdersByID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, orders)
}
收费信用卡
此处理程序涉及的功能不仅限于读取请求和调用数据库。这是因为我们需要与 Stripe 的 API 交互,以向客户的信用卡收费。我们将在下一章中更详细地介绍此方法。现在,让我们创建一个空处理程序,如下所示,以便在我们的代码中使用:
func (h *Handler) Charge(c *gin.Context) {
if h.db == nil {
return
}
}
整合所有内容
在创建我们的处理程序后,让我们回到 ./backend/src/rest/rest.go。而不是将我们的路由映射到空处理程序,让我们将我们的路由映射到上一节中创建的处理程序:
func RunAPI(address string) error {
//Get gin's default engine
r := gin.Default()
//Define a handler
h, _ := NewHandler()
//get products
r.GET("/products", h.GetProducts)
//get promos
r.GET("/promos", h.GetPromos)
//post user sign in
r.POST("/users/signin", h.SignIn)
//add a user
r.POST("/users",h.AddUser)
//post user sign out
r.POST("/user/:id/signout", h.SignOut)
//get user orders
r.GET("/user/:id/orders", h.GetOrders)
//post purchase charge
r.POST("/users/charge", h.Charge)
//run the server
return r.Run(address)
}
观察我们函数中的最后一行:r.Run(address)。我们必须在定义完我们的 API 路由和处理程序后调用此方法,以便我们的 RESTful API 服务器开始监听来自 HTTP 客户端的传入请求。
由于我们的一些路由以 /user/ 和 /users 开头,前面的代码可以使用名为 Group() 的方法进一步重构:
func RunAPI(address string,h HandlerInterface) error {
//Get gin's default engine
r := gin.Default()
//get products
r.GET("/products", h.GetProducts)
//get promos
r.GET("/promos", h.GetPromos)
/*
//post user sign in
r.POST("/user/signin", h.SignIn)
//post user sign out
r.POST("/user/:id/signout", h.SignOut)
//get user orders
r.GET("/user/:id/orders", h.GetOrders)
//post purchase charge
r.POST("/user/charge", h.Charge)
*/
userGroup := r.Group("/user")
{
userGroup.POST("/:id/signout", h.SignOut)
userGroup.GET("/:id/orders", h.GetOrders)
}
usersGroup := r.Group("/users")
{
usersGroup.POST("/charge", h.Charge)
usersGroup.POST("/signin", h.SignIn)
usersGroup.POST("", h.AddUser)
}
return r.Run(address)
}
上述技术有时被称为 分组路由。这是当我们将具有部分相对 URL 相同的 HTTP 路由组合到一个公共代码块中时。
为了使我们的代码更简洁,让我们将前面的函数重命名为 RunAPIWithHandler(),因为 handler 可以作为参数传递给它:
func RunAPIWithHandler(address string,h HandlerInterface) error{
//our code
}
然后,让我们创建一个具有旧名称 RunAPI() 的函数,它代表 RunAPIWithHandler() 的默认状态。这是当我们为我们的 HandlerInterface 使用默认实现时:
func RunAPI(address string) error {
h, err := NewHandler()
if err != nil {
return err
}
return RunAPIWithHandler(address, h)
}
现在,在我们的 main.go 文件中,它应该位于我们的项目 backend/src 文件夹中,我们可以简单地调用 RunAPI(),如下所示:
func main() {
log.Println("Main log....")
log.Fatal(rest.RunAPI("127.0.0.1:8000"))
}
但如何将我们的 React 前端与新建的后端连接呢?这很简单;在我们的 React 应用 root 文件夹中,有一个名为 package.json 的文件。在 package.json 文件中,我们需要添加以下字段:
"proxy": "http://127.0.0.1:8000/"
此字段将转发任何前端请求到作为代理指定的地址。如果我们的 Web 服务器监听 127.0.0.1:8000,这在我们的 RunAPI() 函数中由 address 参数表示,那么我们的 Web 服务器将接收来自前端的前入请求。
摘要
在本章中,我们从 RESTful API 的概述开始。然后我们深入到实际主题,如数据建模、定义路由、分组路由和创建处理器。我们涵盖了编写功能性的 Go Web API 所需的知识。
我们也首次接触到了强大的 Gin 框架,它非常流行于编写生产级别的 RESTful API。
在下一章中,我们将更深入地探讨后端 Web API。我们将涵盖更高级的主题,如 ORM 和安全性。我们还将回顾我们的应用程序的前端,并讨论它如何连接到我们构建的后端。
问题
-
什么是 Gin?
-
什么是 HTTP?
-
什么是 RESTful API?
-
什么是 URL?
-
什么是处理器?
-
什么是 JSON?
-
Param()方法用于什么? -
c.JSON()方法用于什么? -
Group()方法用于什么?
进一步阅读
更多信息,您可以查看以下链接:
第七章:使用 Gin 和 React 的高级 Web Go 应用程序
在本章中,我们将继续构建我们的 GoMusic Web 应用程序。本章将涵盖一些高级概念,因为它将在深入探讨如何构建高级后端软件之前,增加我们在上一章中构建的内容。我们将涵盖一些重要且实用的主题,例如数据库层将我们的应用程序连接到生产数据库,对象关系映射(ORM)简化我们的数据库层代码,中间件在 Web API 处理器上添加功能,认证以保护我们的 Web 应用程序,以及信用卡收费。我们还将回顾 GoMusic 应用程序的前端,以了解我们的前端如何连接到后端。
具体来说,本章将涵盖以下主题:
-
数据库层和 ORM
-
中间件
-
安全性、认证和授权
-
信用卡收费
-
代理 React 应用程序
-
从 React 应用程序中进行授权和认证
本章将重新审视我们应用程序的前端层,以便将现有的 React 应用程序升级以利用新的后端功能。
技术要求
对于本章,我们建议您安装以下软件:
-
Go 语言
-
代码编辑器或 IDE,如 VS Code
-
npm 和 Node.js
-
React
需要了解以下主题:
-
Go (第二章,Go 语言构建块,和第三章,Go 并发)
-
JavaScript
-
React (第四章,使用 React.js 进行前端开发,和第五章,为 GoMusic 构建前端)
-
对关系型数据库和 MySQL 有一定的了解
本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/tree/master/Chapter07。
数据库层
在上一章中,我们开始编写我们的数据库层。作为提醒,我们的数据库层托管在backend\src\dblayer文件夹中。我们的第一步是编写数据库层接口,它定义了我们期望从数据库层获得的所有功能。数据库层接口看起来是这样的:
type DBLayer interface {
GetAllProducts() ([]models.Product, error)
GetPromos() ([]models.Product, error)
GetCustomerByName(string, string) (models.Customer, error)
GetCustomerByID(int) (models.Customer, error)
GetProduct(int) (models.Product, error)
AddUser(models.Customer) (models.Customer, error)
SignInUser(username, password string) (models.Customer, error)
SignOutUserById(int) error
GetCustomerOrdersByID(int) ([]models.Order, error)
}
现在,我们需要实现这些方法,以便在我们的数据库层中获得一些稳定的功能。
在开始实现此功能之前,我们首先需要准备一个数据库。
关系型数据库
数据库是任何应用程序后端的关键组成部分。这是后端层可以持久化和检索数据的地方。
关系型数据库可以简单地描述为存储数据在多个表中,然后在这些表之间建立关系的数据库。
当配置数据库时,我们需要定义三件事:
-
数据库名称:在我们的案例中,它将被称为 GoMusic。
-
表名称:在我们的案例中,为了简单起见,我们将使用三个表:
-
一个
customer表:此表是我们存储应用程序用户信息的地方 -
一个
orders表:此表应将客户映射到他们购买的产品 -
一个
products表:此表将包含 GoMusic 可用产品的列表
-
-
索引和表之间的关系:在我们的案例中,订单表将指向
customer和products表。两个表都需要一个id索引。索引用于使查询更高效和更快。
MySQL 是一个知名的数据库,我们将在这本书中使用它。MySQL 是一个非常流行的开源数据库引擎,被用于众多大小项目。
这是我们的架构图,其中数据库为应用程序的后端提供数据:

在下一节中,我们将为我们的应用程序设置 MySQL。
设置
在我们开始创建数据库和表之前,我们首先需要安装 MySQL。MySQL 有企业版和社区版。MySQL 的社区版是我们将在类似我们的项目中使用的版本。这是因为社区版是免费的,可以用于学习和探索项目。要安装 MySQL 社区版服务器,您需要从以下链接下载它:dev.mysql.com/downloads/。
一旦我们安装了 MySQL,我们需要安装客户端工具来使用数据库。MySQL 通常附带一个名为 MySQL Workbench 的工具,如下面的截图所示:

MySQL Workbench 工具页面
您可以使用 MySQL Workbench 中的工具创建 MySQL 数据库、表、索引和关系。让我们逐个查看这些表。
客户表
如我们之前提到的,这是我们将存储我们的应用程序用户信息的表。以下是 customer 表的样式:

表由以下十个列组成:
-
id: 这是每个客户的唯一 ID。 -
firstname: 这是客户的姓名。 -
lastname: 这是客户的姓氏。 -
email: 这是客户的电子邮件。 -
pass: 这是客户的密码。此字段必须以散列形式存储。 -
cc_customerid: 这是一个代表客户信用卡的 ID。我们将在本章后面介绍此字段。 -
loggedin: 此标志指定用户是否已登录。 -
created_at: 此字段指定了客户被添加的日期。 -
updated_at: 此字段指定了行/客户最后更新时间。 -
deleted_at: 此字段指定了行最后被删除的时间。
此表将支持两个索引。如前所述,索引用于使查询更高效和更快。这是通过识别我们预期将用作查询搜索键的列来实现的。索引还可以用于识别唯一且不得重复的字段。

主键是客户id字段;它是对每个客户的唯一识别号。唯一键是email字段。我们不能有两个或更多具有相同电子邮件地址的客户。
订单表
现在,让我们看看orders表,它将托管 GoMusic 可用的产品列表:

此表由以下 8 列组成:
-
id: 订单的唯一 ID -
customer_id: 下订单的客户的 ID -
product_id: 客户购买产品的 ID -
price: 购买价格 -
purchase_date: 购买日期 -
created_at: 行创建的日期/时间 -
updated_at: 行最后更新的日期/时间 -
deleted_at: 如果有的话,行被删除的日期/时间
此表将支持一个索引,如下面的截图所示:

索引仅仅是唯一 ID 索引。每个订单都将有自己的唯一 ID。
产品表
最后,让我们看看products表。此表将映射客户及其购买的产品:

该表由以下 10 列组成:
-
id: 产品的唯一 ID -
image: 产品图像的相对位置 -
imgalt: 图像的替代名称 -
description: 产品描述 -
productname: 产品名称 -
price: 产品的原始价格 -
promotion: 产品的促销价格 -
created_at: 行创建的时间 -
updated_at: 行最后更新时间 -
deleted_at: 如果有的话,行被删除的时间
此表仅支持一个索引,即我们的唯一产品id字段:

通过这样,我们应该有一个足够好的数据库来支持我们的应用程序。我们的数据库相对简单,但它足以展示我们在本章需要涵盖的概念。
现在我们已经了解了我们的数据库将是什么样子,是时候讨论如何设计与我们的数据库交互的代码了。
ORM
为了设计与我们的数据库交互的代码,我们将利用一种称为ORM的方法。ORM 允许您使用面向对象范式与数据库交互。ORM 生成代表数据库表作为代码对象的代码,并将查询表示为您的首选编程语言中的方法。
在 Go 语言的情况下,我们需要创建 Go 结构体来表示每个表。我们已经在上一章中开始编写我们的模型,即product、customer和order。
在我们继续编写代码之前,让我们首先讨论一下Go 对象关系映射(GORM),这是一个 Go 开源包,提供了 ORM 的支持。
GORM
最受欢迎的 Go ORM 包之一是 GORM 包,可以在gorm.io/找到。GORM 提供了一些成熟的功能,使得编写后端数据库层变得轻而易举。让我们通过利用 ORM,一步一步地继续编写我们的数据库层:
首先,我们需要检索 GORM 包:
go get -u github.com/jinzhu/gorm
然后,我们需要演进我们的模型,以便它们可以被 GORM 正确使用。
ORM 需要模型对象来准确反映 ORM 库预期读取和/或操作的数据库表列。ORM 还可以使用一些元信息,例如,行最后一次更新、删除或创建的时间,以确保在运行时数据库和应用程序之间正确同步。
在 GORM 的情况下,有一个名为gorm.Model的数据类型,它只是一个 Go 结构体,包含表示行id字段、created_at时间、updated_at时间和deleted_at时间的字段。建议将gorm.Model嵌入表示你数据的 Go 结构体中。对于customers表,Go 结构体看起来会是这样:
type Customer struct {
gorm.Model
Name string `json:"name"`
FirstName string `gorm:"column:firstname" json:"firstname"`
LastName string `gorm:"column:lastname" json:"lastname"`
Email string `gorm:"column:email" json:"email"`
Pass string `json:"password"`
LoggedIn bool `gorm:"column:loggedin" json:"loggedin"`
Orders []Order `json:"orders"`
}
前述代码还展示了大量的struct tags。前述示例中的gorm结构体标签用于标识与字段名称对应的列名。例如,结构体字段FirstName由名为firstname的列表示。这是通过gorm:"column:firstname"这一行来标识的。
我们还使用了前述代码中的json struct标签来标识字段在 JSON 格式中的样子。理论上,我们并不总是需要为每个字段分配struct标签;然而,我发现这样做更实用,可以避免混淆。
但 GORM 是如何识别Customer Go 结构体对应我们数据库中的customers表的?通常,GORM 会将我们的 Go 结构体名称的第一个字母转换为小写,然后在末尾添加一个's',这样就将Customer转换成了customers。然而,GORM 也赋予我们显式声明与 Go 结构体对应的表名的权力。这可以通过一个方法实现,其签名是TableName()string。换句话说,我们可以使用以下方法显式指定我们的表名:
func (Customer) TableName() string {
return "customers"
}
太好了!现在,在我们的项目文件夹内的backend\src\models\models.go文件中,让我们像之前做的那样,逐步演进products和orders表的数据模型:
type Product struct {
gorm.Model
Image string `json:"img"`
ImagAlt string `json:"imgalt" gorm:"column:imgalt"`
Price float64 `json:"price"`
Promotion float64 `json:"promotion"` //sql.NullFloat64
PoructName string `gorm:"column:productname" json:"productname"`
Description string
}
func (Product) TableName() string {
return "products"
}
type Order struct {
gorm.Model
Product
Customer
CustomerID int `gorm:"column:customer_id"`
ProductID int `gorm:"column:product_id"`
Price float64 `gorm:"column:price" json:"sell_price"`
PurchaseDate time.Time `gorm:"column:purchase_date" json:"purchase_date"`
}
func (Order) TableName() string {
return "orders"
}
让我们在下一节中实现数据库层。
实现数据库层
现在,我们必须实现数据库层的功能。
在上一章中,我们设计了一个数据库层接口,该接口定义了我们预期需要的所有数据库操作。它看起来是这样的:
type DBLayer interface {
GetAllProducts() ([]models.Product, error)
GetPromos() ([]models.Product, error)
GetCustomerByName(string, string) (models.Customer, error)
GetCustomerByID(int) (models.Customer, error)
GetProduct(int) (models.Product, error)
AddUser(models.Customer) (models.Customer, error)
SignInUser(username, password string) (models.Customer, error)
SignOutUserById(int) error
GetCustomerOrdersByID(int) ([]models.Order, error)
}
在我们的项目文件夹中,让我们在 dblayer 同一文件夹中创建一个名为 orm.go 的新文件。该文件将位于 {our_project_folder}/backend/src/dblayer 文件夹中。
GORM 包依赖于插件来连接 GORM 支持的不同数据库。插件是需要在 GORM 被使用的包中静默导入的 Go 包。
要在 Go 中静默导入插件包,包括 GORM 包,我们可以使用以下语法:
import (
_ "github.com/go-sql-driver/mysql"
"github.com/jinzhu/gorm"
)
我们的插件是 github.com/go-sql-driver/mysql 包。如果你还没有在你的机器上安装它,你需要使用你最喜欢的终端中的 go get 命令来检索它:
go get github.com/go-sql-driver/mysql
接下来,我们需要创建一个 Go struct 类型,该类型将实现我们的 DBLayer 接口。
我们的 Go 结构体将托管一个名为 *gorm.DB 的数据类型。*gorm.DB 类型是我们使用 GORM 功能的入口点。以下是代码的样子:
type DBORM struct {
*gorm.DB
}
我们需要为我们的新类型创建一个构造函数。构造函数将初始化我们嵌入的 *gorm.DB 类型。
要获取初始化的 *gorm.DB 类型,我们需要使用一个名为 gorm.Open() 的函数。这个函数接受两个参数——我们的数据库类型名称,在我们的例子中是 mysql,以及我们的连接字符串。连接字符串基本上包含有关如何连接我们试图访问的特定数据库的信息。为了使我们的构造函数灵活,我们不会硬编码数据库名称或连接字符串。相反,我们将允许这些信息传递给构造函数。以下是代码:
func NewORM(dbname, con string) (*DBORM, error) {
db, err := gorm.Open(dbname, con)
return &DBORM{
DB: db,
}, err
}
现在是时候开始实现 DBLayer 接口的方法了。
我们将首先利用 GORM 的有用方法,这将使我们免于编写显式的查询。要实现的第一种方法是 GetAllProducts()。该方法简单地返回所有产品的列表,这相当于一个 select * SQL 语句。这可以通过使用 GORM 的 db.Find() 方法实现,该方法属于 *gorm.DB 类型。以下是代码:
func (db *DBORM) GetAllProducts() (products []models.Product, err error) {
return products, db.Find(&products).Error
}
你可以看到使用像 GORM 这样的 ORM 可以产生极其高效的代码。前面方法中的单行代码在 products 表上执行了一个 select * from products 查询,然后返回了所有结果。Find() 方法能够检测我们寻求产品表,因为我们向它提供了一个类型为 []models.Product 的参数。
接下来,我们编写 GetPromos() 方法,该方法返回一个促销字段不为空的产品的列表。
这只是一个带有 where 子句的选择语句。GORM 允许你通过使用一个名为 Where() 的方法,结合我们之前提到的 Find() 方法来实现这一点。以下是代码:
func (db *DBORM) GetPromos() (products []models.Product, err error) {
return products, db.Where("promotion IS NOT NULL").Find(&products).Error
}
再次强调,这很简单且高效。前面的方法只是简单地执行了以下查询的等效操作:
select * from products where promotion IS NOT NULL
Where() 方法也可以接受一个 Go 结构体值,它代表查询中的条件。我们将在下一个 DBLayer 方法中看到这一点,即 GetCustomerByName 方法。此方法接受客户的姓和名,然后返回客户信息。以下是代码:
func (db *DBORM) GetCustomerByName(firstname string, lastname string) (customer models.Customer, err error) {
return customer, db.Where(&models.Customer{FirstName: firstname, LastName: lastname}).Find(&customer).Error
}
此方法与 GetPromos() 方法非常相似,只是 Where() 方法接收一个包含姓和名的 Go 结构体值,而不是字符串 where 子句。以下查询的等效操作被执行:
select * from customers where firstname='..' and lastname='..'
接下来,我们将实现 GetCustomerByID() 方法,它将通过在数据库中使用客户的 ID 来检索客户。
这次,我们不再使用 Where 和 Find 的组合,而是将使用一个名为 First 的方法,它可以获取符合特定条件的第一条结果:
func (db *DBORM) GetCustomerByID(id int) (customer models.Customer, err error) {
return customer, db.First(&customer, id).Error
}
接下来,我们将实现一个通过 ID 获取产品的方法,这与 GetCustomerByID() 非常相似,但这次结果是产品而不是客户:
func (db *DBORM) GetProduct(id int) (product models.Product, error error) {
return product, db.First(&product, id).Error
}
到目前为止,我们一直在编写执行查询和检索结果的方法。但现在,是时候开始编写添加或更新行的方法了。
我们下一个方法是 AddUser(),它基本上是将新用户添加到数据库中。
此方法还将对用户的密码进行散列(我们将在后面的 安全 部分介绍),并将用户设置为已登录。GORM 提供了一个非常方便的方法 Create(),这样我们就可以将行添加到我们的数据库中:
func (db *DBORM) AddUser(customer models.Customer) (models.Customer, error) {
//we will cover the hashpassword function later
hashPassword(&customer.Pass)
customer.LoggedIn = true
return customer, db.Create(&customer).Error
}
接下来,我们需要实现 SignInUser 方法,它基本上更新了我们客户表中代表特定客户的行中的 loggedin 字段。
SignInUser 方法将根据用户的电子邮件识别刚刚登录的用户。然后我们将验证用户的密码。如果密码正确,我们将更新数据库。以下是代码的示例:
func (db *DBORM) SignInUser(email, pass string) (customer models.Customer, err error) {
//Verify the password, we'll cover this function later
if !checkPassword(pass) {
return customer, errors.New("Invalid password")
}
//Obtain a *gorm.DB object representing our customer's row
result := db.Table("Customers").Where(&models.Customer{Email: email})
//update the loggedin field
err = result.Update("loggedin", 1).Error
if err != nil {
return customer, err
}
//return the new customer row
return customer, result.Find(&customer).Error
}
前面的代码涵盖了之前介绍过的许多方法,但有两个地方除外:
-
result := db.Table("Customers").Where(&models.Customer{Email: email}):这是我们获取我们感兴趣的行所表示的对象的方法 -
result.Update("loggedin", 1):这是我们更新我们行的方法
SignOutUserById() 方法用于通过用户 ID 注销用户。这将遵循我们之前介绍过的相同技术:
func (db *DBORM) SignOutUserById(id int) error {
//Create a customer Go struct with the provided if
customer := models.Customer{
Model: gorm.Model{
ID: uint(id),
},
}
//Update the customer row to reflect the fact that the customer is not logged in
return db.Table("Customers").Where(&customer).Update("loggedin", 0).Error
}
最后,我们实现 GetCustomerOrdersByID() 方法,通过 customer_id 获取客户订单:
func (db *DBORM) GetCustomerOrdersByID(id int) (orders []models.Order, err error) {
return orders, db.Table("orders").Select("*")
.Joins("join customers on customers.id = customer_id")
.Joins("join products on products.id = product_id")
.Where("customer_id=?", id).Scan(&orders).Error
}
上述代码与之前的方法略有不同。这是因为我们需要执行几个连接来生成我们所需的结果。我们需要连接三个表:orders表、customers表和products表。从customers表,我们只想获取与提供的客户 ID 对应的客户。对于products表,我们只想获取产品 ID 与当前订单的产品 ID 相对应的产品。幸运的是,GORM 包提供了一个名为Joins的方法,可以用来连接表。上述代码将转换为以下查询(假设我们有一个值为'1'的customer_id):
SELECT * FROM `orders` join customers on customers.id = customer_id join products on products.id = product_id WHERE (customer_id='1')
有了这些,我们的数据库层几乎就完成了。让我们在下一节看看什么是中间件。
中间件
中间件是现代网络应用世界中的一个重要且有趣的话题。在软件开发行业中,中间件这个词可以意味着很多不同的东西。然而,为了这本书的目的,我们只关心一个定义。中间件可以简单地定义为在收到 HTTP 请求和你的处理代码执行该请求之间运行的代码。这最好通过一个例子来解释。
在我们为 GoMusic 应用程序构建的 RESTful API 中,让我们挑选我们的一个 API 端点——/products 相对 URL。以下是用于将此相对 URL 分配给动作或函数处理器的代码:
r.GET("/products", h.GetProducts)
这是GetProducts处理器的代码:
func (h *Handler) GetProducts(c *gin.Context) {
if h.db == nil {
return
}
products, err := h.db.GetAllProducts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, products)
}
到目前为止,一切顺利。以下是此 API 资源的流程:
-
在
/products相对 URL 地址收到一个 HTTPGET请求。 -
GetProducts()方法将被执行。
我们的 Web API 中间件只是我们可以在步骤 1和步骤 2之间,甚至更远的地方注入的一些代码。技术上讲,中间件只是一个包装我们自己的处理函数的 HTTP 处理函数。换句话说,它是一个将封装GetProducts()方法的函数,这将允许你在你的方法前后插入功能。
Gin 网络框架默认预加载了两件中间件。框架还允许你在需要时定义自己的自定义中间件。
Gin 网络服务器中注入的两个默认中间件是日志中间件和恢复中间件。日志中间件简单地记录了应用程序生命周期中的 API 活动。如果你在所选的终端中运行由 Gin 驱动的 Go 网络应用程序,你会看到类似以下的内容:
[GIN] 2018/12/29 - 13:33:19 |?[97;42m 200 ?[0m| 2.7849836s | 127.0.0.1 |?[97;44m GET ?[0m /products
[GIN] 2018/12/29 - 13:33:19 |?[97;42m 200 ?[0m| 65.82ms | 127.0.0.1 |?[97;44m GET ?[0m /img/redguitar.jpeg
[GIN] 2018/12/29 - 13:33:19 |?[97;42m 200 ?[0m| 65.82ms | 127.0.0.1 |?[97;44m GET ?[0m /img/drums.jpg
[GIN] 2018/12/29 - 13:33:19 |?[97;42m 200 ?[0m| 67.4312ms | 127.0.0.1 |?[97;44m GET ?[0m /img/strings.png
[GIN] 2018/12/29 - 13:33:19 |?[97;42m 200 ?[0m| 9.4939ms | 127.0.0.1 |?[97;44m GET ?[0m /img/flute.jpeg
[GIN] 2018/12/29 - 13:33:19 |?[97;42m 200 ?[0m| 9.9734ms | 127.0.0.1 |?[97;44m GET ?[0m /img/saxophone.jpeg
[GIN] 2018/12/29 - 13:33:19 |?[97;42m 200 ?[0m| 18.3846ms | 127.0.0.1 |?[97;44m GET ?[0m /img/blackguitar.jpeg
这基本上是 Gin 的日志中间件在起作用。
另一方面,Gin 的恢复中间件确保在必要时从恐慌中恢复应用程序,并在响应中写入 HTTP 错误代码500。
对于 Gin,有许多开源的中间件选项可供选择。一份支持的中间件列表可以在github.com/gin-gonic/contrib找到。
在下一节中,我们将看看如何编写自定义中间件。
自定义中间件
如我们之前提到的,Gin 允许你编写自己的中间件,以便你可以在你的 Web 应用程序中嵌入一些功能。在 Gin 中编写自定义中间件相对简单,正如我们可以在以下步骤中看到的那样。
第一步是编写中间件的实际代码。
如我们之前提到的,Web API 中间件简单来说就是一个封装其他 HTTP 处理函数的 HTTP 处理函数。以下是这个代码的示例:
func MyCustomMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
//The code that is to be executed before our request gets handled, starts here
// Set example variable
c.Set("v", "123")
//We can then retrieve the variable later via c.Get("v")
// Now we our request gets handled
c.Next()
// This code gets executed after the handler gets handled
// Access the status we are sending
status := c.Writer.Status()
// Do something with the status
}
}
让我们编写一个非常简单的中间件,它会在请求前后打印 ************************************:
func MyCustomLogger() gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println("************************************")
c.Next()
fmt.Println("************************************")
}
}
下一步是将此中间件添加到我们的 Gin 引擎中。这可以通过两种方式完成:
- 如果我们想保留 Gin 的默认中间件,但同时又想添加
MyCustomLogger(),我们可以这样做:
func RunAPIWithHandler(address string, h HandlerInterface) error {
//Get gin's default engine
r := gin.Default()
r.Use(MyCustomLogger())
/*
The rest of our code
*/
}
- 如果我们想忽略 Gin 的默认中间件,只启用我们的自定义中间件,我们可以使用以下代码路径:
r := gin.New()
r.Use(MyCustomLogger())
如果我们想启用多个中间件,我们只需将它们作为 Use() 方法的参数添加,如下所示:
r := gin.New()
r.Use(MyCustomLogger1(),MyCustomLogger2(),MyCustomLogger3())
在下一节中,我们将讨论如何确保我们的 Web 应用程序的安全。
安全性
当涉及到将 Web 应用程序部署到生产环境时,安全性是一个非常重要的话题。这个话题非常庞大,可能需要章节甚至书籍来涵盖。由于本书的目的是涵盖实用的动手操作主题,并且要简洁明了,我们将涵盖构建安全 Web 应用程序所需的最关键的知识点。
安全的 Web 应用程序主要依赖于加密客户端(浏览器)和服务器之间的数据。换句话说,它们依赖于加密前端和后端之间的数据。
如我们之前提到的,HTTP 是客户端和服务器之间使用的协议。HTTP 可以通过一个名为 TLS(传输层安全性)的协议来保护。HTTP 和 TLS 的组合通常被称为 HTTPS。
另有一个名为 SSL 的协议,它也被用来保护 HTTP。然而,TLS 更新且更安全。
在我们讨论代码之前,让我们首先了解一些关于 HTTPS 以及它是如何工作的背景知识。
证书和私钥
HTTPS 的工作原理如下:
-
客户端和服务器之间通过握手以及证书和私钥建立信任。我们将在稍后详细讨论这一点。
-
客户端和服务器商定一个加密密钥。
-
客户端和服务器将使用他们商定的加密密钥加密他们的通信。
在客户端和服务器之间建立信任
我们在 证书和私钥 部分的 步骤 1 中提到的证书和私钥是另一回事。要理解它们,你首先必须理解公钥加密或非对称加密的概念。
公钥用于加密数据,并且可以安全地与其他方共享。然而,公钥不能解密数据。需要不同的密钥来解密数据。这个密钥被称为私钥,并且不能共享。任何人都可以使用公钥加密数据。然而,只有拥有与公钥相对应的私钥的人才能将数据解密回原始的可读形式。公钥和私钥是使用复杂的计算算法生成的。利用公钥和私钥的组合被称为非对称加密。
在“证书和私钥”部分的步骤 1中,在 Web 客户端和 Web 服务器之间使用非对称加密来协商一个共享的加密密钥(也称为共享秘密或会话密钥),然后用于对称加密(步骤 2和步骤 3)。Web 客户端和 Web 服务器之间发生握手,客户端表明其意图与服务器开始一个安全的通信会话。通常,这包括就加密方式的一些数学细节达成一致。然后服务器回复一个数字证书*。
数字证书(或公钥证书)是证明公钥所有权的电子文档。
数字证书是由可信第三方实体签发的数字文档。文档包含一个公钥加密密钥、密钥所属的服务器名称以及验证信息正确性和公钥属于预期的密钥所有者(也称为证书的签发者)的可信第三方实体名称。
发出证书的可信第三方实体被称为证书颁发机构(CA)。有多个已知的 CA 负责颁发证书并验证企业和组织的身份。大多数 CA 会对其服务收费;现在有一些是免费的,例如 Let's Encrypt (letsencrypt.org/)。对于较大的组织或政府机构,它们会颁发自己的证书;这个过程被称为自签名,因此它们的证书被称为自签名证书。证书可能有到期日期,届时证书需要更新;这是为了在之前拥有证书的实体发生变化时提供额外的保护。
一个网络客户端通常包含它所知道的 CA 列表。因此,当客户端尝试连接到网络服务器时,网络服务器会响应一个数字证书。网络客户端会查找证书的发行者,并将发行者与它所知道的 CA 列表进行比较。如果网络客户端知道并信任证书发行者,那么它将继续与该服务器建立连接,并使用证书中的公钥。从服务器获得的公钥然后将被用来加密通信,以安全地协商一个共享的加密密钥(或会话密钥或共享密钥),然后用于网络客户端和网络服务器之间的对称加密通信。
有多种算法可以用来生成这个会话密钥,但它们超出了本章的范围。我们需要知道的是,一旦会话密钥达成一致,网络客户端和服务器之间的初始握手将结束,从而允许在共享会话密钥的保护下安全地进行实际的通信会话。
为了让我们的网络服务器支持 HTTPS,它需要一个证书和一个私钥来建立初始的 HTTPS 握手,正如证书和私钥部分中步骤 1所述。
达成一致并使用加密密钥
加密密钥是一段代码,它依赖于一些复杂的数学来加密数据。加密数据简单来说就是将人类可读的数据转换成人类无法阅读的形式,从而保护你的数据。然后还需要另一个密钥来将这个不可读的数据转换回人类可读的形式。
在上一节中的步骤 2和步骤 3中使用的加密密钥有时被称为对称加密算法。这仅仅意味着客户端和服务器使用相同的密钥来加密和解密它们之间的数据。这也被称为对称加密。
大多数时候,这个密钥在开发过程中对你来说是不可见的。
在下一节中,我们将探讨如何在 Gin 中支持 HTTPS。
在 Gin 中支持 HTTPS
在上一节中,我们获得了一些关于 HTTPS 实际工作原理的有价值知识。但我们如何在代码中支持它呢?实际上相当简单。在上一章中,我们使用了以下代码片段来建立一个由 Gin 支持的 HTTP 网络服务器:
//Get gin's default engine
r := gin.Default()
/*
Our code
*/
//Start listening to incoming HTTP requests...
r.Run(address)
我们需要在之前的代码中进行一个更改以支持 HTTPS 而不是 HTTP。在这里,我们将使用一个名为RunTLS()的方法。这个方法需要三个主要参数:
-
我们希望我们的后端网络服务监听的 HTTPS 地址
-
(上一节中提到的)证书文件
-
(上一节中提到的)私钥文件
下面是代码的示例:
r.RunTLS(address, "cert.pem", "key.pem")
但我们如何生成一个证书文件和私钥?显然,如果这是一个学习项目或周末项目,我们就不需要从 CA 获取一个完全合法的证书。
在这种情况下,我们使用所谓的自签名证书。当你使用这样的证书时,显然你的网络浏览器不会信任它,因为证书不会属于知名 CA 列表。出于测试目的,你可能需要配置你的浏览器以信任该证书,或者只是忽略警告并继续。
但我们如何生成一个自签名证书呢?
有多种方法可以做到这一点。大多数人依赖于 OpenSSL (www.openssl.org/) 工具来生成测试自签名证书。然而,因为我们使用 Go,我们可以利用 Go 语言标准库中提供的一个工具。
在你的项目文件夹中,只需运行以下命令:
go run %GOROOT%/src/crypto/tls/generate_cert.go --host=127.0.0.1
这是一个 Go 在tls包文件夹中提供的简单工具。你可以使用它来生成证书。在这里,%GOROOT%代表你的 Go 根环境变量。如果你在 Linux 上运行此命令,你需要使用$GOROOT代替。
之前的命令将为127.0.0.1主机(环回本地主机)生成一个证书。这个工具有更多的标志/选项,你可以进行配置,例如以下内容:
-ca
whether this cert should be its own Certificate Authority
-duration duration
Duration that certificate is valid for (default 8760h0m0s)
-ecdsa-curve string
ECDSA curve to use to generate a key. Valid values are P224, P256 (recommended), P384, P521
-host string
Comma-separated hostnames and IPs to generate a certificate for
-rsa-bits int
Size of RSA key to generate. Ignored if --ecdsa-curve is set (default 2048)
-start-date string
Creation date formatted as Jan 1 15:04:05 2011
命令将生成证书和私钥文件,并将它们放置在你的当前文件夹中。然后你可以将它们复制到任何你想要的地方,并在你的代码中引用它们。
要在测试时让你的 React 应用程序支持 HTTPS 模式,你需要在启动 React 应用程序时运行以下命令:
set HTTPS=true&&npm start
让我们在下一节中看看密码散列。
密码散列
另一个非常重要的安全措施被称为密码散列。密码散列是一种用于保护我们应用程序处理的用户账户密码的方法。
让我们以我们的 GoMusic 应用程序为例——我们有一个customers表,其中包含客户的用户名和密码。那么,我们应该将客户的密码以纯文本形式存储吗?答案是响亮而坚定的不!
我们应该怎么做呢?我们如何保护客户的密码,同时在用户登录时验证他们的密码?这就是密码散列发挥作用的地方。它包括两个简单的步骤:
-
当保存新客户的密码时,对密码进行散列然后保存散列。散列可以简单地定义为单向加密——你可以加密密码,但永远无法解密它。散列函数依赖于复杂的数学来实现这一点。
-
当需要验证现有客户的密码时,对登录请求中提供的密码进行散列,然后将新传入密码的散列与原始密码保存的散列进行比较。
密码哈希可以保护客户的密码,因为它确保即使恶意黑客入侵您的数据库,也无法访问它们。
但是,在密码从网络客户端传输到我们的网络服务器的过程中如何保护密码呢?这就是 HTTPS 发挥作用的地方。从客户端发送的密码将在到达我们的后端网络服务器之前完全加密。
现在,让我们开始在我们的代码中实现一些密码哈希。第一步是进入我们的数据库层并实现以下逻辑:
-
在将新用户的密码保存到数据库之前进行哈希处理
-
当现有用户登录时,比较提供的密码与保存的哈希密码
在接下来的几节中,我们将探讨如何实现密码哈希和验证传入的密码。
实现密码哈希
在我们的数据库层中,我们将为无效密码错误定义一个新的类型。在我们的 dblayer.go 文件中,我们将添加以下行:
var ErrINVALIDPASSWORD = errors.New("Invalid password")
然后,在我们的 orm.go 文件中,我们将添加以下函数:
func hashPassword(s *string) error {
if s == nil {
return errors.New("Reference provided for hashing password is nil")
}
//convert password string to byte slice so that we can use it with the bcrypt package
sBytes := []byte(*s)
//Obtain hashed password via the GenerateFromPassword() method
hashedBytes, err := bcrypt.GenerateFromPassword(sBytes, bcrypt.DefaultCost)
if err != nil {
return err
}
//update password string with the hashed version
*s = string(hashedBytes[:])
return nil
}
上述代码使用了名为 bcrypt 的包,这在密码哈希方面非常受欢迎。bcrypt 代表一个在 1990 年代末设计的流行密码哈希函数。bcrypt 是 OpenBSD 操作系统的默认密码哈希函数,并在多种编程语言中得到支持。
该包还提供了比较哈希密码与其可读版本的方法。
如果该包尚未安装到您的环境中,请运行以下命令:
go get golang.org/x/crypto/bcrypt
我们已经在 AddUser() 方法中遇到了 hashPassword() 方法,它将哈希密码并将其保存到数据库中。
将以下 HashPassword () 方法添加到您的代码中:
func (db *DBORM) AddUser(customer models.Customer) (models.Customer, error) {
//pass received password by reference so that we can change it to its hashed version
hashPassword(&customer.Pass)
customer.LoggedIn = true
err := db.Create(&customer).Error
customer.Pass = ""
return customer, err
}
上述代码将执行密码哈希部分。在我们返回客户对象之前,我们需要将密码设置为空,因为出于安全原因,此信息不需要再次共享。
比较密码
现在,让我们编写将保存的哈希密码与尝试登录的用户提供的密码进行比较的代码。
我们只需要 bcrypt 包中的另一个方法来比较我们保存的哈希与传入的密码:
func checkPassword(existingHash, incomingPass string) bool {
//this method will return an error if the hash does not match the provided password string
return bcrypt.CompareHashAndPassword([]byte(existingHash), []byte(incomingPass)) == nil
}
接下来,我们需要向 SignInUser 方法添加代码,以便我们可以检索尝试登录的客户的哈希密码。然后,我们可以比较密码。如果密码不匹配,我们返回一个错误。如果它们匹配,我们继续通过将登录标志设置为 true 来记录用户的登录:
func (db *DBORM) SignInUser(email, pass string) (customer models.Customer, err error) {
//Obtain a *gorm.DB object representing our customer's row
result := db.Table("Customers").Where(&models.Customer{Email: email})
//Retrieve the data for the customer with the passed email
err = result.First(&customer).Error
if err != nil {
return customer, err
}
//Compare the saved hashed password with the password provided by the user trying to sign in
if !checkPassword(customer.Pass, pass) {
//If failed, returns an error
return customer, ErrINVALIDPASSWORD
}
//set customer pass to empty because we don't need to share this information again
customer.Pass = ""
//update the loggedin field
err = result.Update("loggedin", 1).Error
if err != nil {
return customer, err
}
//return the new customer row
return customer, result.Find(&customer).Error
}
最后,我们需要在我们的 HTTP 网络处理程序中添加一个微小的更改来处理失败的登录。
让我们回到 handlers.go 文件并稍微编辑一下 SignIn 方法。代码的编辑部分以粗体显示:
func (h *Handler) SignIn(c *gin.Context) {
if h.db == nil {
return
}
var customer models.Customer
err := c.ShouldBindJSON(&customer)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
customer, err = h.db.SignInUser(customer.Email, customer.Pass)
if err != nil {
//if the error is invalid password, return forbidden http error
if err == dblayer.ErrINVALIDPASSWORD {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, customer)
}
让我们看看下一节中的信用卡处理。
信用卡处理
我们已经在 第五章 中实现了前端信用卡逻辑,即构建 GoMusic 的前端。现在是时候实现信用卡处理的后端部分了。
对于前端信用卡处理,我们使用了 Stripe API (stripe.com/docs/api)。在产品环境中,您需要创建一个 Stripe 账户并获取一个 API 密钥来用于您的应用程序。然而,出于测试目的,我们可以使用测试 API 密钥和测试信用卡号码,Stripe 高兴地为开发者提供这些,以便构建能够处理信用卡的生产力应用程序。要了解更多关于测试信用卡号码和 Stripe 提供的测试令牌的信息,请查看 stripe.com/docs/testing。
在我们的前端代码中,我们使用了以下 Stripe API 代码来创建一个令牌:
let { token } = await this.props.stripe.createToken({ name: this.state.name });
我们还使用了一个非常流行的框架,称为 Stripe elements,它将收集我们所有的信用卡信息,并将其与创建令牌请求合并,以便我们得到一个代表我们试图处理的信用卡的令牌。
然后,我们将此令牌发送到后端,以便它可以进行处理和/或保存。
为了处理信用卡支付,我们需要一些关键信息:
-
由 Stripe API 提供的信用卡令牌
-
进行购买的客户 ID
-
客户试图购买的产品 ID
-
产品售价
-
是否应该记住卡片以供将来使用
-
是否使用预存卡片
我修改了前端代码,使其能够收集这些信息并将其传递给绑定到我们后端的 HTTP 请求。以下是前端代码中创建和提交我们请求的部分。以下代码位于 creditcards.js 文件中:
async handleSubmit(event) {
event.preventDefault();
let id = "";
//If we are not using a pre-saved card, connect with stripe to obtain a card token
if (!this.state.useExisting) {
//Create the token via Stripe's API
let { token } = await this.props.stripe.createToken({ name: this.state.name });
if (token == null) {
console.log("invalid token");
this.setState({ status: FAILEDSTATE });
return;
}
id = token.id;
}
//Create the request, then send it to the back-end
let response = await fetch("/users/charge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: id,
customer_id: this.props.user,
product_id: this.props.productid,
sell_price: this.props.price,
rememberCard: this.state.remember !== undefined,
useExisting: this.state.useExisting
})
});
//If response is ok, consider the operation a success
if (response.ok) {
console.log("Purchase Complete!");
this.setState({ status: SUCCESSSTATE });
} else {
this.setState({ status: FAILEDSTATE });
}
}
上述代码使用了 fetch() 方法来向后端发送 POST 请求。该 POST 请求将包含一个 JSON 消息,其中包含我们后端需要处理请求的所有数据。
在下一节中,我们将探讨信用卡处理的后端是如何工作的。
后端信用卡处理
我们可以使用 Stripe API 按以下方式处理信用卡交易:
-
创建一个
*stripe.CustomerParams类型的对象,然后它可以接受前端提供的信用卡令牌。信用卡令牌是通过名为SetToken()的方法摄取的。 -
创建一个
*stripe.Customer类型的对象,它将stripe.CustomerParams类型的对象作为输入。这是通过customer.New()函数完成的。 -
创建一个
*stripe.ChargeParams类型的对象,它包含有关我们交易的信息,例如金额、货币和购买描述。 -
*stripe.ChargeParams对象还必须接收一个字段,该字段代表 Stripe 客户端 ID。这由我们在 步骤 2 中提到的*stripe.Customer对象提供。
记住,Stripe 客户端 ID 与我们数据库中引用客户的实际客户 ID 是不同的。
-
如果我们想保存这张信用卡并在以后使用,我们可以简单地存储 Stripe 客户端 ID 字符串以供以后使用。
-
最后,我们可以通过调用
charge.New()来对信用卡进行扣款,该函数需要一个*stripe.ChargeParams对象作为输入。
如果前面的步骤听起来太难理解,不要担心。一旦我们开始查看代码,它就会变得清晰起来。
我们的大部分代码将位于 charge 处理方法中,该方法位于 handler.go 文件内。以下是该方法的当前状态:
func (h *Handler) Charge(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server database error"})
return
}
}
该方法目前还没有做任何事情。这是我们希望它执行的操作:
-
使用传入的 HTTP 请求获取交易信息。
-
如果请求要求我们使用现有的已保存卡,那么我们需要检索保存的客户 Stripe ID 并对卡进行扣款。
-
如果请求要求我们在扣款之前记住当前提供的信用卡信息,那么我们将在扣款之前将其保存到数据库中。
首先,我们需要创建一个类型,它可以接受我们从前端接收到的数据。下面是这个类型的示例:
request := struct {
models.Order
Remember bool `json:"rememberCard"`
UseExisting bool `json:"useExisting"`
Token string `json:"token"`
}{}
在前面的代码中,我们声明了 Go 结构体并在同一代码块中初始化它。这是在 Go 中快速创建和使用 struct 类型的快捷方式。
接下来,我们需要将传入的 JSON 有效负载解析到 request 变量中:
err := c.ShouldBindJSON(&request)
//If an error occurred during parsing, report it then return
if err != nil {
c.JSON(http.StatusBadRequest, request)
return
}
现在,是时候开始编写一些 Stripe 代码了。首先,我们需要声明 stripe API 密钥——在这种情况下,我们将使用一个测试 API 密钥。然而,在生产环境中,您需要使用自己的 Stripe 密钥,并且要非常小心地保护它:
stripe.Key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc"
接下来,我们需要创建一个 *stripe.ChargeParams 对象,我们在本节开头 步骤 3 中已经介绍过。下面是这个对象的示例:
chargeP := &stripe.ChargeParams{
//the price we obtained from the incoming request:
Amount: stripe.Int64(int64(request.Price)),
//the currency:
Currency: stripe.String("usd"),
//the description:
Description: stripe.String("GoMusic charge..."),
}
接下来,我们初始化 Stripe 客户端 ID 字符串。这是本节开头 步骤 4 中提到的关键字段:
stripeCustomerID:=""
现在,如果传入的请求要求我们使用现有的卡,我们将从数据库中检索保存的 Stripe 客户端 ID 并使用它。以下代码使用了一个新的数据库方法,该方法从数据库中检索 Stripe 客户端 ID。我们将在本节的后面实现此方法:
if request.UseExisting {
//use existing
log.Println("Getting credit card id...")
//This is a new method which retrieve the stripe customer id from the database
stripeCustomerID, err = h.db.GetCreditCardCID(request.CustomerID)
if err != nil {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
否则,我们可以创建一个 *stripe.CustomerParams 对象,该对象用于创建 *stripe.Customer 对象。这为我们提供了 Stripe 客户端 ID:
...else {
cp := &stripe.CustomerParams{}
cp.SetSource(request.Token)
customer, err := customer.New(cp)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
stripeCustomerID = customer.ID
如果要求我们记住这张卡,我们只需将 Stripe 客户端 ID 存储到数据库中:
if request.Remember {
//save the stripe customer id, and link it to the actual customer id in our database
err = h.db.SaveCreditCardForCustomer(request.CustomerID, stripeCustomerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
接下来,我们尝试对信用卡进行扣款:
/*
we should check if the customer already ordered the same item or not but for simplicity, let's assume it's a new order
*/
//Assign the stipe customer id to the *stripe.ChargeParams object:
chargeP.Customer = stripe.String(stripeCustomerID)
//Charge the credit card
_, err = charge.New(chargeP)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
最后,我们将订单添加到我们的数据库中:
err = h.db.AddOrder(request.Order)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
至此,charge() 方法就完成了。
目前还有一些工作要做。添加到 charge() 处理器的代码使用了几个我们尚未创建的数据库方法:
-
GetCreditCardCID(): 从数据库检索保存的 Stripe 客户 ID -
SaveCreditCardForCustomer(): 将 stripe 客户 ID 保存到我们的数据库 -
AddOrder(): 向数据库添加订单
我们需要在我们的dblayer.go文件中数据库层接口中添加这三个方法:
type DBLayer interface {
GetAllProducts() ([]models.Product, error)
GetPromos() ([]models.Product, error)
GetCustomerByName(string, string) (models.Customer, error)
GetCustomerByID(int) (models.Customer, error)
GetProduct(int) (models.Product, error)
AddUser(models.Customer) (models.Customer, error)
SignInUser(username, password string) (models.Customer, error)
SignOutUserById(int) error
GetCustomerOrdersByID(int) ([]models.Order, error)
AddOrder(models.Order) error
GetCreditCardCID(int) (string, error)
SaveCreditCardForCustomer(int, string) error
}
我们将三个方法添加到我们DBLayer接口的具体实现中。这是在orm.go文件中完成的:
//Add the order to the orders table
func (db *DBORM) AddOrder(order models.Order) error {
return db.Create(&order).Error
}
//Get the id representing the credit card from the database
func (db *DBORM) GetCreditCardCID(id int) (string, error) {
cusomterWithCCID := struct {
models.Customer
CCID string `gorm:"column:cc_customerid"`
}{}
return cusomterWithCCID.CCID, db.First(&cusomterWithCCID, id).Error
}
//Save the credit card information for the customer
func (db *DBORM) SaveCreditCardForCustomer(id int, ccid string) error {
result := db.Table("customers").Where("id=?", id)
return result.Update("cc_customerid", ccid).Error
}
现在,我们需要回到我们的handler.go文件,并修改我们的handler构造函数,使其能够连接到我们的数据库:
func NewHandler(db,constring string) (HandlerInterface, error) {
db, err := dblayer.NewORM(db, constring)
if err != nil {
return nil, err
}
return &Handler{
db: db,
}, nil
}
完美!有了这个,我们的 GoMusic 应用程序的后端代码已经完成了 99%,让我们在下一节中重新审视我们的前端代码。
重新审视前端代码
在前端代码方面,我们最后深入探讨的是第五章,为 GoMusic 构建前端。第四章,使用 React.js 的前端和第五章,为 GoMusic 构建前端,旨在构建关于如何构建可工作的 React 应用程序的坚实和实用的基础知识,这可以用作我们后端 Go 应用程序的前端。
这两个章节涵盖了 GoMusic 前端大约 85%的内容。然而,它们并没有涵盖所有必要的 JavaScript 代码,这些代码用于将我们在第四章,使用 React.js 的前端和第五章,为 GoMusic 构建前端中构建的不同 React 组件粘合在一起。在本节中,我们将概述整体前端架构,并填补一些空白。
在下一节中,我们将查看我们应用程序前端的应用结构。
应用程序结构
我们的前端组件分为以下文件:
-
index.js: 我们 React 应用程序的入口点,它调用App组件。 -
App.js: 我们 React 应用程序的主要组件。它将所有其他组件组合成我们的应用程序。在App.js文件中托管的App组件负责一些重要的中心任务,例如用户的登录或登出。 -
modalwindows.js: 这负责我们应用程序中的所有模态窗口。这包括登录、新用户注册和购买模态窗口。 -
Navigation.js: 这负责我们 React 应用程序中的导航菜单,这是我们如何从一个屏幕移动到另一个屏幕的方式。 -
creditcards.js: 这负责处理信用卡的前端部分。 -
productcards.js: 这负责显示产品卡片列表。这包括普通产品卡片和促销。 -
orders.js: 当用户登录时,这显示客户订单列表。 -
about.js: 这显示关于页面。
在下一节中,我们将了解如何将我们的前端和后端相互连接。
前端和后端之间的交互
在第四章,使用 React.js 的前端中,我们依赖于包含一些模拟数据的 JSON 文件来授权和运行我们的前端应用程序,而无需实际工作的后端。现在,我们需要替换所有依赖于 JSON 样本数据文件的代码,并改为向后端发送完整的 HTTP 请求。
为了让我们的 React 应用程序将请求路由到我们的后端,我们首先需要将一个名为proxy的字段添加到我们的 React 应用程序的package.json文件中。代理字段需要指向后端 API 地址。在我们的例子中,我们的应用程序的后端组件监听本地端口8000:
"proxy": "http://127.0.0.1:8000/",
package.json文件将存在于你的 React 应用程序的主文件夹中。此文件由node包管理器用于确定你的node应用程序的全局设置、脚本和依赖项。
每当我们向后端发送请求时,我们都会使用强大的fetch()方法。此方法可以向相对 URL 发送 HTTP 请求。以下是一个获取客户订单请求的示例:
fetch(this.props.location) //send http request to a location. The location here is /products
.then(res => res.json())
.then((result) => {
this.setState({
orders: result
});
});
在下一节中,我们将探讨如何在我们的应用程序中使用 Cookies。
使用 Cookies
由于我们的应用程序不再依赖于全局样本数据文件来回答诸如用户是否当前已登录等重要问题,以及用户信息是什么等问题,因此前端应用程序现在使用浏览器 Cookies 来完成这些。浏览器 Cookies 的简单定义是,它们是使用网络浏览器存储在用户设备上的小块信息。这些信息可以在需要时简单地从 Cookies 中检索。
前端代码利用 Cookies 轻松保存和检索用户信息,以便填充需要这些信息的不同屏幕和 React 组件。这是通过使用js-cookie JavaScript 包来实现的。可以使用以下命令安装此包:
npm install js-cookie --save
以下是一个设置 Cookies 的示例:
cookie.set("user", userdata);
以下是从 Cookies 中检索数据的示例:
const user = cookie.getJSON("user");
Cookies 也用于检查用户是否当前已登录或已登出。自我们在第五章,为 GoMusic 构建前端中覆盖的代码以来,已添加了处理用户登出时发生的状态变化的登出处理代码。当用户登出时,我们从 Cookies 中删除现有用户的信息,只保留没有人登录的事实。以下是执行此操作的代码:
cookie.set("user",{loggedin:false});
关于我们如何在 React 应用程序中使用 Cookies 以及如何处理用户登出,请查看我们应用程序的 App React 组件:github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/blob/master/Chapter07/Frontend/src/App.js
现在,让我们探索如何将我们的前端应用程序部署到生产环境中。
部署前端应用程序
一旦你完成了前端代码,它需要转换成一个可以在不需要重新安装开发过程中所需的 Node.js 工具和依赖项的情况下共享和复制到生产服务器环境中的形式。
当我们在 第四章、“使用 React.js 的前端”和 第五章、“为 GoMusic 构建前端”中创建 React 应用程序时,我们使用了一个名为 create-react-app 的工具来创建我们的应用程序并设置工具链。该工具支持一些我们可以用来运行和构建 React 应用程序的脚本。我们之前已经提到了 npm start 命令,该命令用于以开发模式运行我们的 React 应用程序,这样我们就可以在开发应用程序的同时实时运行和调试我们的代码。
有一个对于使我们的 React 应用程序准备好生产使用至关重要的脚本,它被称为 build。要运行此脚本,我们只需从 React 应用程序的主文件夹中输入 npm run build 命令。此命令将编译我们的整个应用程序到一些静态文件中,然后我们可以直接从我们的 Go 应用程序中提供这些文件。构建脚本的输出将进入一个名为 build 的文件夹,该文件夹将创建在我们的 React 应用程序根文件夹中。
让我们将这个文件夹称为 React 的 build 文件夹。然后我们可以将这个 build 文件夹复制到任何地方,并让我们的 Go 应用程序利用它来提供 GoMusic 应用程序的前端。
这里是我们需要在我们的 Go 代码中遵循的步骤,以便能够提供 React 输出 build 文件夹。
首先,我们需要导入一个名为 static 的 Gin 中间件,我们可以通过执行以下命令来完成:
go get github.com/gin-contrib/static
我们需要进入我们的 rest.go 文件,该文件托管了我们的 HTTP 路由定义。这个文件可以在 backend\src\rest.go 中找到。在 rest.go 内部,我们将导入 Gin 静态中间件:
import (
"fmt"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
)
在包含所有 HTTP 路由定义的 RunAPIWithHandler 函数内部,我们将用从 React 应用程序生成的 build 文件夹中提供所有静态文件的代码替换用于提供我们的 img 文件夹的代码。这将看起来像这样:
//remove this line: r.Static("/img", "../public/img")
//This assumes the React app 'build' folder exists in the relative path '../public'
r.Use(static.ServeRoot("/", "../public/build"))
static.ServeRoot() 函数的第一个参数是我们 Web 应用程序的相对 HTTP 根 URL,而第二个参数基本上是 React 构建文件夹的位置。
我们还需要将包含我们所有乐器图片的 img 文件夹移动到 build 文件夹内部。现在,build 文件夹包含了我们 Web 应用程序的所有资产。
这就是我们需要做的全部。现在,我们可以通过使用 go build 或 go install 命令将我们的 Go 应用程序构建成一个单独的可执行文件。然后,我们可以将我们的可执行 web 应用程序以及 React 构建文件夹复制到我们想要部署 web 应用程序的地方。
摘要
本章涵盖了大量的内容。在本章中,我们学习了如何为我们的后端服务设计并实现数据库层。我们了解了网络 API 中间件的概念以及如何利用它。我们还深入探讨了诸如 TLS 和密码散列等实际安全概念。然后,我们学习了如何通过强大的 Stripe API 在后端服务中处理信用卡。有了这些知识,你现在具备了构建现代生产级 Go 网络应用程序的能力。
在下一章中,我们将介绍如何通过编写单元测试来测试我们的应用程序,以及如何通过运行基准测试来衡量其性能。
问题
-
中间件是什么?
-
什么是 Stripe 客户端 ID?
-
什么是对象关系映射(ORM)?
-
什么是 GORM?
-
我们如何使用 GORM 编写连接查询?
-
什么是 TLS?
-
密码散列的含义是什么?
-
什么是
bcrypt?
进一步阅读
-
Stripe API:
stripe.com/docs/api -
GORM:
gorm.io/ -
包
bcrypt:godoc.org/golang.org/x/crypto/bcrypt -
传输层安全性:
www.cloudflare.com/learning/ssl/transport-layer-security-tls/
第八章:测试和基准测试你的 Web API
在生产软件环境中,测试至关重要。应用程序不仅需要测试功能,还需要进行基准测试和性能分析,以便我们可以检查应用程序的性能。本章将提供广泛的实用信息,介绍如何正确测试和基准测试你的应用程序。
在本章中,我们将涵盖以下主题:
-
Go 语言中的模拟类型
-
Go 语言中的单元测试
-
Go 语言中的基准测试
本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/tree/master/Chapter08。
Go 语言中的测试
任何软件测试过程中的一个构建块被称为单元测试。单元测试在几乎任何编程语言中都是一个非常流行的概念,并且有众多的软件框架和语言扩展,允许你尽可能高效地执行单元测试。
单元测试的想法是单独测试你的软件中的每个单元或组件。单元可以简单地定义为你的软件中最小的可测试部分。
Go 语言配备了测试包,以及一些 Go 命令,使单元测试过程更加容易。该包可以在golang.org/pkg/testing/找到。
在本节中,我们将更深入地探讨如何在 Go 语言中构建单元测试。然而,在我们开始编写 Go 语言的单元测试之前,我们首先需要了解模拟的概念。
模拟
模拟的概念在单元测试软件领域非常流行。它最好通过一个例子来描述。假设我们想要对 GoMusic 应用程序的 HTTP 处理函数之一进行单元测试。GetProducts()方法是一个展示我们例子的好方法,因为该方法的目的就是返回 GoMusic 商店中所有可销售产品的列表。以下是GetProducts()方法的代码:
func (h *Handler) GetProducts(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server database error"})
return
}
products, err := h.db.GetAllProducts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
fmt.Printf("Found %d products\n", len(products))
c.JSON(http.StatusOK, products)
}
此方法只是从我们的数据库中检索所有产品,然后以 HTTP 响应的形式返回结果。此方法使用了h.db.GetAllProducts()方法从我们的数据库中检索数据。
因此,当需要对GetProducts()进行单元测试时,我们应该能够测试该方法的功能,而无需实际数据库。此外,我们还应该能够注入一些错误场景,例如让h.db.GetAllProducts()失败,并确保GetProducts()按预期反应。
你可能会想知道,为什么能够在不需要真实数据库的情况下测试像GetProducts()这样的方法很重要?答案是简单的——单元测试只关注你当前正在测试的单元,即GetProducts()方法,而不是你的数据库连接。
模拟对象类型可以被定义为对象类型,你可以使用它来模拟或伪造某种行为。换句话说,在h.db.GetAllProducts()方法的例子中,我们不是使用连接到真实数据库的对象类型,而是可以使用一个不连接到真实数据库但可以给我们提供所需结果的模拟类型,以执行GetProducts()方法的单元测试。
让我们回到记忆的深处,回忆一下h.db.GetAllProducts()是如何构建的。这段代码的数据库部分只是一个名为DBLayer的接口,我们用它来描述我们需要从数据库层获取的所有行为。
下面是DBLayer接口的样子:
type DBLayer interface {
GetAllProducts() ([]models.Product, error)
GetPromos() ([]models.Product, error)
GetCustomerByName(string, string) (models.Customer, error)
GetCustomerByID(int) (models.Customer, error)
GetProduct(int) (models.Product, error)
AddUser(models.Customer) (models.Customer, error)
SignInUser(username, password string) (models.Customer, error)
SignOutUserById(int) error
GetCustomerOrdersByID(int) ([]models.Order, error)
AddOrder(models.Order) error
GetCreditCardCID(int) (string, error)
SaveCreditCardForCustomer(int, string) error
}
要为我们的数据库层创建一个模拟类型,我们只需要创建一个具体的类型,该类型将实现DBLayer接口,但不会连接到真实的数据库。
模拟对象需要返回一些模拟数据,这些数据我们用于我们的测试。我们可以简单地在这个模拟对象内部存储这些数据,以切片或映射的形式。
现在我们已经知道了什么是模拟,让我们创建我们的模拟数据库类型。
创建模拟数据库类型
在我们的backend/src/dblayer文件夹中,让我们添加一个名为mockdblayer.go的新文件。在这个新文件中,让我们创建一个名为MockDBLayer的类型:
package dblayer
import (
"encoding/json"
"fmt"
"strings"
"github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/tree/master/Chapter08/backend/src/models"
)
type MockDBLayer struct {
err error
products []models.Product
customers []models.Customer
orders []models.Order
}
MockDBLayer类型包含四种类型:
-
err:这是一个错误类型,我们可以在需要模拟错误场景时随意设置。我们将在编写单元测试时查看如何使用它。 -
products:这是我们存储模拟产品列表的地方。 -
customers:这是我们存储模拟客户列表的地方。 -
orders:这是我们存储模拟订单列表的地方。
接下来,让我们为我们的模拟类型编写一个构造函数:
func NewMockDBLayer(products []models.Product, customers []models.Customer, orders []models.Order) *MockDBLayer {
return &MockDBLayer{
products: products,
customers: customers,
orders: orders,
}
}
构造函数接受三个参数:一个products列表、一个customers列表和一个orders列表。这给其他开发者提供了定义他们自己的测试数据的机会,这对于灵活性来说是个好事情。然而,其他开发者应该能够使用一些预加载的模拟数据来初始化MockDBLayer类型:
func NewMockDBLayerWithData() *MockDBLayer {
PRODUCTS := `[
{
"ID": 1,
"CreatedAt": "2018-08-14T07:54:19Z",
"UpdatedAt": "2019-01-11T00:28:40Z",
"DeletedAt": null,
"img": "img/strings.png",
"small_img": "img/img-small/strings.png",
"imgalt": "string",
"price": 100,
"promotion": 0,
"productname": "Strings",
"Description": ""
},
{
"ID": 2,
"CreatedAt": "2018-08-14T07:54:20Z",
"UpdatedAt": "2019-01-11T00:29:11Z",
"DeletedAt": null,
"img": "img/redguitar.jpeg",
"small_img": "img/img-small/redguitar.jpeg",
"imgalt": "redg",
"price": 299,
"promotion": 240,
"productname": "Red Guitar",
"Description": ""
},
{
"ID": 3,
"CreatedAt": "2018-08-14T07:54:20Z",
"UpdatedAt": "2019-01-11T22:05:42Z",
"DeletedAt": null,
"img": "img/drums.jpg",
"small_img": "img/img-small/drums.jpg",
"imgalt": "drums",
"price": 17000,
"promotion": 0,
"productname": "Drums",
"Description": ""
},
{
"ID": 4,
"CreatedAt": "2018-08-14T07:54:20Z",
"UpdatedAt": "2019-01-11T00:29:53Z",
"DeletedAt": null,
"img": "img/flute.jpeg",
"small_img": "img/img-small/flute.jpeg",
"imgalt": "flute",
"price": 210,
"promotion": 190,
"productname": "Flute",
"Description": ""
},
{
"ID": 5,
"CreatedAt": "2018-08-14T07:54:20Z",
"UpdatedAt": "2019-01-11T00:30:12Z",
"DeletedAt": null,
"img": "img/blackguitar.jpeg",
"small_img": "img/img-small/blackguitar.jpeg",
"imgalt": "Black guitar",
"price": 200,
"promotion": 0,
"productname": "Black Guitar",
"Description": ""
},
{
"ID": 6,
"CreatedAt": "2018-08-14T07:54:20Z",
"UpdatedAt": "2019-01-11T00:30:35Z",
"DeletedAt": null,
"img": "img/saxophone.jpeg",
"small_img": "img/img-small/saxophone.jpeg",
"imgalt": "Saxophone",
"price": 1000,
"promotion": 980,
"productname": "Saxophone",
"Description": ""
}
]
`
ORDERS := `[
{
"ID": 1,
"CreatedAt": "2018-12-29T23:35:36Z",
"UpdatedAt": "2018-12-29T23:35:36Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 1,
"product_id": 1,
"sell_price": 90,
"purchase_date": "2018-12-29T23:34:32Z"
},
{
"ID": 2,
"CreatedAt": "2018-12-29T23:35:48Z",
"UpdatedAt": "2018-12-29T23:35:48Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 1,
"product_id": 2,
"sell_price": 299,
"purchase_date": "2018-12-29T23:34:53Z"
},
{
"ID": 3,
"CreatedAt": "2018-12-29T23:35:57Z",
"UpdatedAt": "2018-12-29T23:35:57Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 1,
"product_id": 3,
"sell_price": 16000,
"purchase_date": "2018-12-29T23:35:05Z"
},
{
"ID": 4,
"CreatedAt": "2018-12-29T23:36:18Z",
"UpdatedAt": "2018-12-29T23:36:18Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 2,
"product_id": 1,
"sell_price": 95,
"purchase_date": "2018-12-29T23:36:18Z"
},
{
"ID": 5,
"CreatedAt": "2018-12-29T23:36:39Z",
"UpdatedAt": "2018-12-29T23:36:39Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 2,
"product_id": 2,
"sell_price": 299,
"purchase_date": "2018-12-29T23:36:39Z"
},
{
"ID": 6,
"CreatedAt": "2018-12-29T23:38:13Z",
"UpdatedAt": "2018-12-29T23:38:13Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 2,
"product_id": 4,
"sell_price": 205,
"purchase_date": "2018-12-29T23:37:01Z"
},
{
"ID": 7,
"CreatedAt": "2018-12-29T23:38:19Z",
"UpdatedAt": "2018-12-29T23:38:19Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 3,
"product_id": 4,
"sell_price": 210,
"purchase_date": "2018-12-29T23:37:28Z"
},
{
"ID": 8,
"CreatedAt": "2018-12-29T23:38:28Z",
"UpdatedAt": "2018-12-29T23:38:28Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 3,
"product_id": 5,
"sell_price": 200,
"purchase_date": "2018-12-29T23:37:41Z"
},
{
"ID": 9,
"CreatedAt": "2018-12-29T23:38:32Z",
"UpdatedAt": "2018-12-29T23:38:32Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 3,
"product_id": 6,
"sell_price": 1000,
"purchase_date": "2018-12-29T23:37:54Z"
},
{
"ID": 10,
"CreatedAt": "2019-01-13T00:44:55Z",
"UpdatedAt": "2019-01-13T00:44:55Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 19,
"product_id": 6,
"sell_price": 1000,
"purchase_date": "2018-12-29T23:37:54Z"
},
{
"ID": 11,
"CreatedAt": "2019-01-14T06:03:08Z",
"UpdatedAt": "2019-01-14T06:03:08Z",
"DeletedAt": null,
"img": "",
"small_img": "",
"imgalt": "",
"price": 0,
"promotion": 0,
"productname": "",
"Description": "",
"name": "",
"firstname": "",
"lastname": "",
"email": "",
"password": "",
"loggedin": false,
"orders": null,
"customer_id": 1,
"product_id": 3,
"sell_price": 17000,
"purchase_date": "0001-01-01T00:00:00Z"
}
]
`
CUSTOMERS := `[
{
"ID": 1,
"CreatedAt": "2018-08-14T07:52:54Z",
"UpdatedAt": "2019-01-13T22:00:45Z",
"DeletedAt": null,
"name": "",
"firstname": "Mal",
"lastname": "Zein",
"email": "mal.zein@email.com",
"password": "$2a$10$ZeZI4pPPlQg89zfOOyQmiuKW9Z7pO9/KvG7OfdgjPAZF0Vz9D8fhC",
"loggedin": true,
"orders": null
},
{
"ID": 2,
"CreatedAt": "2018-08-14T07:52:55Z",
"UpdatedAt": "2019-01-12T22:39:01Z",
"DeletedAt": null,
"name": "",
"firstname": "River",
"lastname": "Sam",
"email": "river.sam@email.com",
"password": "$2a$10$mNbCLmfCAc0.4crDg3V3fe0iO1yr03aRfE7Rr3vdfKMGVnnzovCZq",
"loggedin": false,
"orders": null
},
{
"ID": 3,
"CreatedAt": "2018-08-14T07:52:55Z",
"UpdatedAt": "2019-01-13T21:56:05Z",
"DeletedAt": null,
"name": "",
"firstname": "Jayne",
"lastname": "Ra",
"email": "jayne.ra@email.com",
"password": "$2a$10$ZeZI4pPPlQg89zfOOyQmiuKW9Z7pO9/KvG7OfdgjPAZF0Vz9D8fhC",
"loggedin": false,
"orders": null
},
{
"ID": 19,
"CreatedAt": "2019-01-13T08:43:44Z",
"UpdatedAt": "2019-01-13T15:12:25Z",
"DeletedAt": null,
"name": "",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@bla.com",
"password": "$2a$10$T4c8rmpbgKrUA0sIqtHCaO0g2XGWWxFY4IGWkkpVQOD/iuBrwKrZu",
"loggedin": false,
"orders": null
}
]
`
var products []models.Product
var customers []models.Customer
var orders []models.Order
json.Unmarshal([]byte(PRODUCTS), &products)
json.Unmarshal([]byte(CUSTOMERS), &customers)
json.Unmarshal([]byte(ORDERS), &orders)
return NewMockDBLayer(products, customers, orders)
}
前面的函数内部有一些硬编码的数据,这些数据随后被传递给MockDBLayer构造函数。这允许开发者使用MockDBLayer类型,这样他们就可以立即使用它,而无需首先想出数据。
接下来,我们需要提供方法来暴露MockDBLayer类型正在使用的数据:
func (mock *MockDBLayer) GetMockProductData() []models.Product {
return mock.products
}
func (mock *MockDBLayer) GetMockCustomersData() []models.Customer {
return mock.customers
}
func (mock *MockDBLayer) GetMockOrdersData() []models.Order {
return mock.orders
}
现在,我们需要一个方法,使我们能够完全控制MockDBLayer方法返回的错误。这很重要,因为在我们的单元测试中,我们很可能会需要测试如果发生错误,代码将如何表现。当我们开始编写单元测试时,我们将重新审视这个概念。现在,让我们编写一个方法,使我们能够设置由我们的模拟类型返回的错误:
func (mock *MockDBLayer) SetError(err error) {
mock.err = err
}
现在,是时候实现DBLayer接口的方法了。让我们从GetAllProducts()方法开始。它将看起来像这样:
func (mock *MockDBLayer) GetAllProducts() ([]models.Product, error) {
//Should we return an error?
if mock.err != nil {
return nil, mock.err
}
//return products list
return mock.products, nil
}
我们首先需要检查的是MockDBLayer类型是否返回错误。如果需要返回错误,我们就直接返回错误。否则,我们返回我们保存在模拟类型中的产品列表。
接下来,让我们看看GetPromos()方法:
func (mock *MockDBLayer) GetPromos() ([]models.Product, error) {
if mock.err != nil {
return nil, mock.err
}
promos := []models.Product{}
for _, product := range mock.products {
if product.Promotion > 0 {
promos = append(promos, product)
}
}
return promos, nil
}
在前面的代码中,我们首先检查是否应该返回错误,就像我们之前做的那样。然后我们遍历产品列表,选择具有促销的产品。
接下来,让我们探索GetProduct(id)方法。此方法应该能够根据提供的id检索产品。它看起来是这样的:
func (mock *MockDBLayer) GetProduct(id int) (models.Product, error) {
result := models.Product{}
if mock.err != nil {
return result, mock.err
}
for _, product := range mock.products {
if product.ID == uint(id) {
return product, nil
}
}
return result, fmt.Errorf("Could not find product with id %d", id)
}
与其他方法一样,我们首先需要检查是否需要返回错误,如果是的话,我们就返回错误并退出方法。否则,我们检索此方法查询的数据。在GetProduct(id)的情况下,我们遍历产品列表,然后返回具有请求id的产品。如果我们把产品存储在列表中而不是映射中,这个循环可以被简单的映射检索所替代。在生产环境中,您需要决定您希望在模拟对象(映射和/或切片)中如何表示您的数据。在这种情况下,我决定为了简单起见使用切片。在更复杂的模拟对象中,您可能希望对于返回所有数据的函数存储数据在切片中,而对于返回特定项的函数存储数据在映射中。
模拟对象的其余代码将继续实现DBLayer接口方法。
这里是按名称获取客户的代码:
func (mock *MockDBLayer) GetCustomerByName(first, last string) (models.Customer, error) {
result := models.Customer{}
if mock.err != nil {
return result, mock.err
}
for _, customer := range mock.customers {
if strings.EqualFold(customer.FirstName, first) && strings.EqualFold(customer.LastName, last) {
return customer, nil
}
}
return result, fmt.Errorf("Could not find user %s %s", first, last)
}
这里是按 ID 获取客户的代码:
func (mock *MockDBLayer) GetCustomerByID(id int) (models.Customer, error) {
result := models.Customer{}
if mock.err != nil {
return result, mock.err
}
for _, customer := range mock.customers {
if customer.ID == uint(id) {
return customer, nil
}
}
return result, fmt.Errorf("Could not find user with id %d", id)
}
这里是添加用户的代码:
func(mock *MockDBLayer) AddUser(customer models.Customer) (models.Customer, error){
if mock.err != nil {
return models.Customer{}, mock.err
}
mock.customers = append(mock.customers, customer)
return customer, nil
}
这里是登录用户的代码:
func (mock *MockDBLayer) SignInUser(email, password string) (models.Customer, error) {
if mock.err != nil {
return models.Customer{}, mock.err
}
for _, customer := range mock.customers {
if strings.EqualFold(email, customer.Email) && customer.Pass == password {
customer.LoggedIn = true
return customer, nil
}
}
return models.Customer{}, fmt.Errorf("Could not sign in user %s", email)
}
这里是按 ID 注销用户的代码:
func (mock *MockDBLayer) SignOutUserById(id int) error {
if mock.err != nil {
return mock.err
}
for _, customer := range mock.customers {
if customer.ID == uint(id) {
customer.LoggedIn = false
return nil
}
}
return fmt.Errorf("Could not sign out user %d", id)
}
这里是按 ID 获取客户订单的代码:
func (mock *MockDBLayer) GetCustomerOrdersByID(id int) ([]models.Order, error) {
if mock.err != nil {
return nil, mock.err
}
for _, customer := range mock.customers {
if customer.ID == uint(id) {
return customer.Orders, nil
}
}
return nil, fmt.Errorf("Could not find customer id %d", id)
}
这里是添加订单的代码:
func (mock *MockDBLayer) AddOrder(order models.Order) error {
if mock.err != nil {
return mock.err
}
mock.orders = append(mock.orders, order)
for _, customer := range mock.customers {
if customer.ID == uint(order.CustomerID) {
customer.Orders = append(customer.Orders, order)
return nil
}
}
return fmt.Errorf("Could not find customer id %d for order", order.CustomerID)
}
最后,以下方法只是信用卡处理逻辑的占位符。在本章中我们将探讨的单元测试不会覆盖信用卡处理,为了简化问题;让我们现在就先将其作为占位符:
//The credit card related mock methods will need more work. They are out of scope of this chapter.
func (mock *MockDBLayer) GetCreditCardCID(id int) (string, error) {
if mock.err != nil {
return "", mock.err
}
return "", nil
}
func (mock *MockDBLayer) SaveCreditCardForCustomer(int, string) error {
if mock.err != nil {
return mock.err
}
return nil
}
值得注意的是,在 Go 语言中存在一些第三方开源项目,可以帮助创建和使用模拟对象。然而,在本章中,我们已经构建了自己的。
现在我们已经创建了一个模拟数据库类型,让我们来谈谈 Go 语言中的单元测试。
Go 语言中的单元测试
现在是时候探索 Go 语言中的单元测试并利用我们在上一节中构建的MockDBLayer类型了。
在 Go 语言中编写单元测试的第一步是在您想要测试的包所在的同一文件夹中创建一个新文件。文件名必须以_test.go结尾。在我们的例子中,由于我们想要测试rest包中的GetProducts()方法,我们将在同一文件夹中创建一个新文件,并将其命名为handler_test.go。
此文件仅在运行单元测试时构建和执行,而在常规构建期间不会执行。你可能想知道,我如何在 Go 中运行单元测试?答案是简单的——你使用go test命令!每次你运行go test时,只有以_test.go结尾的文件才会构建和运行。
如果你从不同于你想要测试的包的文件夹运行go test命令,那么你只需指向你想要测试的包:
go test <Your_Package_Path>
例如,如果我们想运行rest包的单元测试,命令将看起来像这样:
go test github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/tree/master/Chapter08/backend/src/rest
现在,让我们进一步深入到handler_test.go文件。我们需要做的第一件事是声明包:
package rest
接下来,我们需要编写一个表示我们的单元测试的函数。在 Go 中,你需要遵循一些特定的规则来确保你的函数作为go test命令产生的单元测试的一部分执行:
-
你的函数必须以单词
Test开头 -
在
Test之后的首字母必须大写 -
函数需要以
*testing.T类型作为参数
*testing.T类型提供了一些重要的方法,将帮助我们表明测试是否失败或通过。该类型还提供了一些我们可以使用的日志功能。当我们开始编写测试代码时,我们将很快看到*testing.T类型的实际应用。
因此,遵循前面的三个规则,我们将创建一个名为TestHandler_GetProducts的新函数,用于托管我们 HTTP 处理器中GetProducts()方法的单元测试代码。这个函数将看起来像这样:
func TestHandler_GetProducts(t *testing.T) {
}
我们需要做的第一件事是启用 Gin 框架的测试模式。Gin 框架的测试模式可以防止过多的日志记录:
func TestHandler_GetProducts(t *testing.T) {
// Switch to test mode so you don't get such noisy output
gin.SetMode(gin.TestMode)
}
接下来,让我们初始化我们的mockdbLayer类型。我们将使用包含一些硬编码数据的构造函数:
func TestHandler_GetProducts(t *testing.T) {
// Switch to test mode so you don't get such noisy output
gin.SetMode(gin.TestMode)
mockdbLayer := dblayer.NewMockDBLayerWithData()
}
我们在本节中寻求测试的GetProducts()方法是一个 HTTP 处理器函数,它预期返回 GoMusic 商店可用的产品列表。正如我们在前面的章节中所述,HTTP 处理器函数可以定义为当向特定的相对 URL 发送 HTTP 请求时执行的操作。HTTP 处理器将处理 HTTP 请求并通过 HTTP 返回响应。
在我们的单元测试中,我们需要测试GetProducts()作为方法的功能,以及它对 HTTP 请求的反应。
我们需要定义一个相对 URL,该 URL 将激活该 HTTP 处理器函数。让我们称它为/products并将其设为一个常量:
func TestHandler_GetProducts(t *testing.T) {
// Switch to test mode so you don't get such noisy output
gin.SetMode(gin.TestMode)
mockdbLayer := dblayer.NewMockDBLayerWithData()
h := NewHandlerWithDB(mockdbLayer)
const productsURL string = "/products"
}
我们将在下一章中看到如何利用这个常数。
在下一节中,我们将介绍一个称为表驱动开发的重要概念。
表驱动开发
在典型的实际单元测试中,我们试图测试某个函数或方法,看看它将如何响应某些输入和错误条件。这意味着单元测试的代码需要多次调用我们试图测试的函数或方法,并使用不同的输入和错误条件。而不是编写大量的大规模if语句来使用不同的输入进行调用,我们可以遵循一个非常流行的设计模式,称为表驱动开发。
测试驱动开发背后的思想很简单——我们将使用 Go 结构体的数组或映射来表示我们的测试。结构体数组或映射将包含我们想要传递给正在测试的函数/方法的输入和错误条件的信息。然后我们将遍历 Go 结构体数组并调用当前输入和错误条件下的待测试方法/函数。这种方法将在主单元测试下产生多个子测试。
在我们的单元测试中,我们将使用 Go 结构体的数组来表示我们的不同子测试。以下是数组的结构:
tests := []struct {
name string
inErr error
outStatusCode int
expectedRespBody interface{}
}{
}
让我们把前面的代码称为我们的test表。以下是 Go 结构字段将表示的内容:
-
name: 这是子测试的名称。 -
inErr: 这是输入错误。我们将把这个错误注入到模拟数据库层类型中,并监控GetProducts()方法的行为。 -
outStatusCode: 这是调用GetProducts()HTTP 处理器产生的预期 HTTP 状态码。如果从调用GetProducts()作为 HTTP 处理器返回的 HTTP 状态码不匹配此值,则单元测试失败。 -
expectedRespBody: 这是从调用GetProducts()HTTP 处理器返回的预期 HTTP 响应体。如果返回的 HTTP 体不匹配此值,则单元测试失败。该字段是interface{}类型,因为它可以是产品切片或错误消息。Go 语言中的interface{}类型可以表示任何其他数据类型。
表驱动测试设计模式的美丽之处在于它的灵活性;你可以简单地添加更多字段来测试更多条件。
从GetProducts() HTTP 处理器可以产生两个预期的 HTTP 响应体——我们要么得到一些产品列表,要么得到一个错误消息。错误消息采用以下格式:{error:"the error message"}。
在我们从测试表中开始运行子测试之前,让我们定义一个struct类型来表示错误消息,这样我们就可以在测试中使用它:
type errMSG struct {
Error string `json:"error"`
}
接下来,我们需要定义我们的子测试列表。为了简单起见,我们将选择两种不同的场景进行测试:
tests := []struct {
name string
inErr error
outStatusCode int
expectedRespBody interface{}
}{
{
"getproductsnoerrors",
nil,
http.StatusOK,
mockdbLayer.GetMockProductData(),
},
{
"getproductswitherror",
errors.New("get products error"),
http.StatusInternalServerError,
errMSG{Error: "get products error"},
},
}
在前面的代码中,我们定义了两个不同的子测试:
-
第一个方法称为
getproductsnoerrors。它代表一个直接执行场景,其中没有发生错误,一切正常。我们没有向模拟数据库层类型注入任何错误,因此我们预期GetProducts()方法不会返回任何错误。我们预期 HTTP 响应状态为OK,并且我们预期将获得存储在模拟数据库层中的产品数据列表作为 HTTP 响应体。我们之所以预期获得存储在模拟数据库层中的产品数据列表作为我们的输出,是因为模拟数据库类型将是我们的GetProducts()方法的数据库层。 -
第二个方法称为
getproductswitherror。它代表一个发生错误的执行场景。我们将一个名为"get products error"的错误注入到模拟数据库层类型中。这个错误预期将作为GetProducts()处理函数调用的 HTTP 响应体返回。预期的 HTTP 状态码将是StatusInternalServerError。
我们单元测试代码的其余部分将遍历测试表并执行我们的测试。*testing.T类型,它作为参数传递给我们的单元测试函数,提供了我们可以用来在单元测试中定义子测试的方法,然后我们可以并行运行这些子测试。
首先,为了在我们的单元测试中定义一个子测试,我们必须使用以下方法:
t.Run(name string,f func(t *T))bool
第一个参数是子测试的名称,而第二个参数是一个表示我们想要运行的子测试的函数。在我们的情况下,我们需要遍历我们的test表并对每个子测试调用t.Run()。下面是这个过程的示例:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
//run our sub-test
}
}
现在,让我们专注于运行子测试的代码。我们首先需要做的是将一个错误注入到模拟类型中:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
//set the input error
mockdbLayer.SetError(tt.inErr)
}
}
接下来,我们需要创建一个测试 HTTP 请求来表示将被我们的GetProducts() HTTP 处理程序接收到的 HTTP 请求。同样,Go 通过一个名为httptest的标准包来提供帮助。这个包使你能够创建特殊的数据类型,允许你测试与 HTTP 相关的功能。httptest提供的一个函数是名为NewRequest()的函数,它返回一个我们可以用于测试的 HTTP 请求类型:
//Create a test request
req := httptest.NewRequest(http.MethodGet, productsURL, nil)
函数接受三个参数:HTTP 方法的类型、预期发送 HTTP 请求的相对 URL,以及请求体(如果有)。
在GetProducts() HTTP handler方法的案例中,它期望一个针对/products相对 URL 的 HTTP GET请求。我们已经在productsURL常量中存储了/products值。
httptest 包还提供了一个名为 ResponseRecorder 的数据类型,可以用来捕获 HTTP 处理函数调用的 HTTP 响应。ResponseRecorder 数据类型实现了 Go 的 http.ResponseWriter 接口,这使得 ResponseRecorder 可以注入到任何使用 http.ResponseWriter 的代码中。我们需要获取这个数据类型的一个值,以便在测试中使用它:
//create an http response recorder
w := httptest.NewRecorder()
接下来,我们需要创建 Gin 框架引擎的一个实例,以便在我们的测试中使用。这是因为我们试图测试的 GetProducts() 方法是一个 Gin 引擎路由器的 HTTP 处理函数,所以它需要一个 *gin.Context 类型的输入。下面是这个函数签名的样子:
GetProducts(c *gin.Context)
幸运的是,Gin 框架已经为我们准备了一个名为 CreateTestContext() 的函数,专门用于创建 Gin 上下文实例和 Gin 引擎实例,以便在测试中使用。CreateTestContext() 函数接受一个 http.ResponseWriter 接口作为输入,这意味着我们可以传递我们的 httptest.ResponseRecorder 作为输入,因为它实现了 http.ResponseWriter 接口。下面是代码的示例:
//create an http response recorder
w := httptest.NewRecorder()
//create a fresh gin engine object from the response recorder, we will ignore the context value
_, engine := gin.CreateTestContext(w)
如我们之前提到的,CreateTestContext() 函数返回两个值:一个 Gin 上下文实例和一个 Gin 引擎实例。在我们的情况下,我们不会使用 Gin 上下文实例,这就是为什么在之前的代码中没有接收到它的值。我们不使用 Gin 上下文实例的原因是我更喜欢使用 Gin 引擎实例进行测试,因为它允许我测试 HTTP 请求被完全处理的工作流程。
接下来,我们将使用 Gin 引擎实例将我们的 GetProducts() 方法映射到 productsURL 相对 URL 地址,通过一个 HTTP GET 请求。下面是这个过程的示例:
//configure the get request
engine.GET(productsURL, h.GetProducts)
现在,是时候让我们的 Gin 引擎处理 HTTP 请求,并将 HTTP 响应传递给我们的 ResponseRecorder:
//serve the request
engine.ServeHTTP(w, req)
这实际上会将我们的测试 HTTP 请求发送到 GetProducts() 处理方法,因为测试请求的目标是 productsURL。然后,GetProducts() 处理方法将处理请求并通过 w(我们的 ResponseRecorder)发送 HTTP 响应。
现在是时候测试 GetProducts() 如何处理 HTTP 请求了。我们首先需要做的是从 w 中提取 HTTP 响应。这是通过 ResponseRecorder 对象类型的 Result() 方法完成的:
//test the output
response := w.Result()
然后,我们需要测试结果的 HTTP 状态码。如果它不等于预期的 HTTP 状态码,那么我们将失败测试用例,并记录失败的原因:
if response.StatusCode != tt.outStatusCode {
t.Errorf("Received Status code %d does not match expected status code %d", response.StatusCode, tt.outStatusCode)
}
如前述代码所示,*testing.T类型自带一个名为Errorf的方法,可以用来记录消息然后失败测试。如果我们想要记录消息而不失败测试,我们可以使用名为Logf的方法。如果我们想要立即失败测试,我们可以调用名为Fail的方法。t.Errorf方法是t.Logf和t.Fail的组合。
接下来,我们需要捕获响应的 HTTP 正文,以便我们能够将其与预期此子测试的 HTTP 响应正文进行比较。有两种情况需要考虑:要么在我们的子测试中注入了错误,这意味着预期结果是错误消息,要么没有注入错误,这意味着预期 HTTP 响应正文是一个产品列表:
/*
Since we don't know the data type to expect from the http response, we'll use interface{} as the type
*/
var respBody interface{}
//If an error was injected, then the response should decode to an error message type
if tt.inErr != nil {
var errmsg errMSG
json.NewDecoder(response.Body).Decode(&errmsg)
//Assign decoded error message to respBody
respBody = errmsg
} else {
//If an error was not injected, the response should decode to a slice of products data types
products := []models.Product{}
json.NewDecoder(response.Body).Decode(&products)
//Assign decoded products list to respBody
respBody = products
}
我们最后需要做的事情是将预期的 HTTP 响应正文与实际收到的 HTTP 响应正文进行比较。要进行比较,我们需要利用 Go 的reflect包中的一个非常有用的函数。这个函数叫做reflect.DeepEqual(),它帮助我们完全比较两个值并确定它们是否是彼此的副本。如果两个值不相等,那么我们将记录一个错误并失败测试。以下是代码的样子:
if !reflect.DeepEqual(respBody, tt.expectedRespBody) {
t.Errorf("Received HTTP response body %+v does not match expected HTTP response Body %+v", respBody, tt.expectedRespBody)
}
这样,我们的单元测试就完成了!让我们看看整体测试代码将是什么样子:
func TestHandler_GetProducts(t *testing.T) {
// Switch to test mode so you don't get such noisy output
gin.SetMode(gin.TestMode)
mockdbLayer := dblayer.NewMockDBLayerWithData()
h := NewHandlerWithDB(mockdbLayer)
const productsURL string = "/products"
type errMSG struct {
Error string `json:"error"`
}
// Use table driven testing
tests := []struct {
name string
inErr error
outStatusCode int
expectedRespBody interface{}
}{
{
"getproductsnoerrors",
nil,
http.StatusOK,
mockdbLayer.GetMockProductData(),
},
{
"getproductswitherror",
errors.New("get products error"),
http.StatusInternalServerError,
errMSG{Error: "get products error"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
//set the input error
mockdbLayer.SetError(tt.inErr)
//Create a test request
req := httptest.NewRequest(http.MethodGet, productsURL, nil)
//create an http response recorder
w := httptest.NewRecorder()
//create a fresh gin context and gin engine object from the response recorder
_, engine := gin.CreateTestContext(w)
//configure the get request
engine.GET(productsURL, h.GetProducts)
//serve the request
engine.ServeHTTP(w, req)
//test the output
response := w.Result()
if response.StatusCode != tt.outStatusCode {
t.Errorf("Received Status code %d does not match expected status code %d", response.StatusCode, tt.outStatusCode)
}
//Since we don't know the data type to expect from the http response, we'll use interface{} as the type
var respBody interface{}
//If an error was injected, then the response should decode to an error message type
if tt.inErr != nil {
var errmsg errMSG
json.NewDecoder(response.Body).Decode(&errmsg)
respBody = errmsg
} else {
//If an error was not injected, the response should decode to a slice of products data types
products := []models.Product{}
json.NewDecoder(response.Body).Decode(&products)
respBody = products
}
if !reflect.DeepEqual(respBody, tt.expectedRespBody) {
t.Errorf("Received HTTP response body %+v does not match expected HTTP response Body %+v", respBody, tt.expectedRespBody)
}
})
}
}
值得注意的是,Go 给你提供了运行你的子测试相互并行的能力。你可以在子测试中调用t.Parallel()来触发这种行为。以下是它的样子:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
//your concurrent code
}
}
然而,当你运行并发子测试时,你必须确保它们共享的任何数据类型在并行时不会改变状态或行为,否则你的测试结果将不可靠。例如,在我们的代码中,我们使用了一个单独的模拟数据库层类型对象,该对象是在子测试外部初始化的。这意味着每次我们在子测试中更改模拟数据库层的错误状态时,它可能会影响同时运行的其它子测试,并使用相同的模拟数据库层对象。
本节剩余的任务是运行我们的单元测试并见证结果。正如我们之前提到的,你可以从包含你想要测试的包的文件夹内部运行go test命令,或者你可以从你的包文件夹外部使用go test <your_package_path>命令。如果单元测试通过,你将看到类似以下的输出:
PASS
ok github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/tree/master/Chapter08/backend/src/rest 0.891s
默认输出显示了被测试的包的完整名称以及运行测试所需的时间。
如果你想看到更多信息,你可以运行go test -v。这个命令将返回以下内容:
c:\Programming_Projects\GoProjects\src\github.com\PacktPublishing\Hands-On-Full-Stack-Development-with-Go\8-Testing-and-benchmarking\backend\src\rest>go test -v
=== RUN TestHandler_GetProducts
=== RUN TestHandler_GetProducts/getproductsnoerrors
=== RUN TestHandler_GetProducts/getproductswitherror
--- PASS: TestHandler_GetProducts (0.00s)
--- PASS: TestHandler_GetProducts/getproductsnoerrors (0.00s)
--- PASS: TestHandler_GetProducts/getproductswitherror (0.00s)
PASS
ok github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/8-Testing-and-benchmarking/backend/src/rest 1.083s
-v标志显示详细输出——它会显示正在运行的单元测试的名称,以及单元测试中正在运行的子测试的名称。
让我们看看下一节的基准测试。
基准测试
在测试软件的世界中,另一个关键主题是基准测试。基准测试是衡量你代码性能的实践。Go 的 testing 包为你提供了执行强大基准测试的能力。
让我们先针对一段代码,展示如何使用 Go 进行基准测试。一个很好的基准测试函数是 hashpassword() 函数,它被我们的数据库层使用。这个函数可以在 backend/src/dblayer/orm.go 文件中找到。它接受一个字符串的引用作为参数,然后使用 bcrypt 散列来散列字符串。以下是代码:
func hashPassword(s *string) error {
if s == nil {
return errors.New("Reference provided for hashing password is nil")
}
//converd password string to byte slice
sBytes := []byte(*s)
//Obtain hashed password
hashedBytes, err := bcrypt.GenerateFromPassword(sBytes, bcrypt.DefaultCost)
if err != nil {
return err
}
//update password string with the hashed version
*s = string(hashedBytes[:])
return nil
}
假设我们想要测试这个函数的性能。我们应该如何开始?
第一步是创建一个新文件。文件名应以 _test.go 结尾。该文件需要存在于与 dblayer 包相同的文件夹中,该包是我们想要测试的函数所在的位置。让我们称这个文件为 orm_test.go。
如我们之前提到的,以 _test.go 结尾的文件将不会成为常规构建过程的一部分。相反,它们会在我们通过 go test 命令运行测试时激活。
接下来,在文件内部,我们首先声明该文件所属的 Go 包,即 dblayer。然后,我们需要导入我们将在代码中使用的测试包:
package dblayer
import "testing"
现在,是时候编写基准测试 hashpassword() 函数的代码了。要在 Go 中编写基准函数,我们需要遵循以下规则:
-
函数名必须以单词
Benchmark开头。 -
在单词
Benchmark之后的第一封信需要大写。 -
该函数接受
*testing.B作为参数。*testing.B类型提供了便于基准测试我们代码的方法。
遵循这三个规则,我们将使用以下签名构建基准函数:
func BenchmarkHashPassword(b *testing.B) {
}
接下来,我们将初始化一个要散列的字符串:
func BenchmarkHashPassword(b *testing.B) {
text := "A String to be Hashed"
}
要使用类型为 *testing.B 的对象来基准测试一段代码,我们需要运行目标代码 b.N 次。N 是 *testing.B 类型中的一个字段,它会调整其值,直到目标代码可以被可靠地测量。代码将如下所示:
func BenchmarkHashPassword(b *testing.B) {
text := "A String to be Hashed"
for i := 0; i < b.N; i++ {
hashPassword(&text)
}
}
前面的代码将运行 hashPassword(),直到它被基准测试。要运行基准测试,我们可以使用带有 -bench 标志的 go test 命令:
go test -bench .
-bench 标志需要提供一个正则表达式,以指示我们想要运行的基准函数。如果我们想运行所有可用的内容,我们可以使用 . 来表示所有。否则,如果我们只想运行包含 HashPassword 术语的基准测试,我们可以修改命令,如下所示:
go test -bench HashPassword
输出将如下所示:
goos: windows
goarch: amd64
pkg: github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/8-Testing-and-benchmarking/backend/src/dblayer
BenchmarkHashPassword-8 20 69609530 ns/op
PASS
ok github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go/8-Testing-and-benchmarking/backend/src/dblayer 1.797s
输出简单地说明 hashPassword 函数运行了 20 次,并且它的速度大约是每循环 69,609,530 纳秒。
在我们的例子中,我们在运行基准测试之前只初始化了一个字符串,这是一个非常直接且简单的操作:
func BenchmarkHashPassword(b *testing.B) {
text := "A String to be Hashed"
for i := 0; i < b.N; i++ {
hashPassword(&text)
}
}
然而,如果你的初始化非常复杂并且需要一些时间来完成,建议你在完成初始化后、进行基准测试之前运行 b.ResetTimer()。以下是一个示例:
func BenchMarkSomeFunction(b *testing.B){
someHeavyInitialization()
b.ResetTimer()
for i:=0;i<b.N;b++{
SomeFunction()
}
}
b.Run(name string, f func(b *testing.B))
*testing.B 类型还附带一个名为 RunParallel() 的额外方法,可以在并行设置中测试性能。这与一个名为 go test -cpu 的标志协同工作:
b.RunParallel(func(pb *testing.PB){
for pb.Next(){
//your code
}
})
概述
本章介绍了任何软件开发者都应该具备的关键技能,即在生产中进行适当的软件测试。我们关注了 Go 语言提供的功能,以支持 Go 代码的测试。
我们首先介绍了如何构建模拟类型以及为什么它们在软件测试中很重要。然后我们介绍了如何在 Go 中进行单元测试以及如何对你的软件进行基准测试。
在下一章中,我们将通过介绍 GopherJS 框架来探索同构 Go 编程的概念。
问题
-
模拟类型的定义是什么?为什么它对软件测试有用?
-
Go 中的测试包是什么?
-
*testing.T类型用于什么? -
*testing.B类型用于什么? -
*testing.T.Run()方法用于什么? -
*testing.T.Parallel()方法用于什么? -
基准测试是什么意思?
-
*testing.B.ResetTimer()方法用于什么?
进一步阅读
如需更多信息,您可以查阅以下链接:
- Go 测试包:
golang.org/pkg/testing/
第九章:GopherJS 同构 Go 入门
到目前为止,我们已经介绍了如何使用 JavaScript 编写我们的前端。然而,如果你想在前端使用 Go,有一个选择。这个选择被称为 GopherJS,这是一个流行的 Go 包,结合了一套只有一个目的的命令:将 Go 代码编译成 JavaScript(也称为转译)。一旦 Go 代码被编译成 JavaScript,代码就可以在前端组件中像 JavaScript 一样使用。依赖于相同编程语言的前端和后端的应用程序称为同构应用程序。
就像任何其他软件开发方法一样,编写同构应用有其自身的优缺点。主要优点是使用你非常擅长的单一编程语言进行大部分代码编写所带来的便利性和开发速度。主要缺点是调试非平凡问题比较困难,因为你将不得不深入到生成的 JavaScript 代码中。
本章是同构 Web 开发的入门介绍。我们将介绍 GopherJS 的一些关键构建块,以及如何利用它编写可以与 Web 浏览器和 Node.js 模块交互的代码。我们还将介绍如何使用 GopherJS 编写一个简单的 React 应用程序,以及一些开源项目。
本章将涵盖以下主题:
-
GopherJS 基础
-
GopherJS 与 React
技术要求
要跟随本章内容,你需要以下工具:
-
安装了 Go 语言(
golang.org/doc/install) -
Node.js 和 npm (
nodejs.org/en/) -
一个代码编辑器,例如 VS Code (
code.visualstudio.com/)
本章假设读者熟悉 JavaScript、HTML、React 和 Go。
如果你还不熟悉 React,请参阅第三章Go 并发和第四章使用 React.js 的前端。
本章的代码可以在github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Go找到。
GopherJS 基础
GopherJS 是一套工具、数据类型和 Go 包,它允许你将 Go 代码编译成 JavaScript。将一种编程语言的代码编译成另一种语言也称为转译。GopherJS 对于不太擅长 JavaScript 的 Go 开发者非常有用,因为它允许你用 Go 编写可以与 JavaScript 模块集成的代码。这意味着你可以用 Go 编写增强应用程序前端的代码,或者可以与 Node.js 模块集成,提供 JavaScript 的灵活性与 Go 的强大功能。
GopherJS 是一个非常强大的软件,被广泛应用于众多应用中。然而,为了有效地利用 GopherJS,你需要了解其构建模块。第一步是使用go get命令检索包:
go get -u github.com/gopherjs/gopherjs
此外,为了能够运行一些 GopherJS 命令,我们需要安装source-map-support节点模块:
npm install --global source-map-support
这允许你在需要时从 Go 代码中调试你的代码。这在编写 GopherJS 中的非平凡应用时非常有用。
太好了,现在我们准备好更深入地探索这个包了。GopherJS 提供了一个游乐场,你可以在其中测试你的 GopherJS 代码gopherjs.github.io/playground/。
现在我们已经设置了 GopherJS,让我们来看看 GopherJS 的类型。
GopherJS 类型
GopherJS 包含一个名为js的子包。此包提供了在 Go 和 JavaScript 之间桥接所需的功能。该包可以在godoc.org/github.com/gopherjs/gopherjs/js找到。
js包提供的关键功能是将 Go 类型转换为 JavaScript 类型,反之亦然。
当我们考虑数据类型时,需要支持两大类:基本类型(int、float和string)和构造类型(结构体和接口)。以下表格显示了 GopherJS 支持的基本类型和 JavaScript 类型之间的类型映射:
| Go 类型 | JavaScript 类型 |
|---|---|
bool |
Boolean |
int和float |
Number |
string |
String |
[]int8 |
Int8Array |
[]int16 |
Int16Array |
[]int32, []int |
Int32Array |
[]uint8 |
Uint8Array |
[]uint16 |
Uint16Array |
[]uint32, []uint |
Uint32Array |
[]float32 |
Float32Array |
[]float64 |
Float64Array |
例如,如果你使用 GopherJS 编译包含 Go int类型的代码片段,则int类型将变为 JavaScript 的Number类型。建议你坚持使用int类型,而不是uint8/uint16/uint32/uint64类型,以提高转换代码的性能。还建议使用float64而不是float32。
现在我们知道了不同的 GopherJS 类型,让我们继续到对象类型。
对象类型
基本类型很好;然而,它们只是任何真实代码片段的简单组件。那么 Go 的结构体、接口、方法、函数和 goroutine 呢?js包让你有能力将这些类型转换为 JavaScript。
js包提供的一个关键构建模块是*js.Object类型。这种类型只是一个本地 JavaScript 对象的容器。GopherJS 的大部分代码都涉及将 Go 对象转换为*js.Object或反之亦然。JavaScript 模块在我们的 Go 代码中作为*js.Object暴露。
现在,让我们在下一节中探讨如何从我们的 Go 代码中调用 JavaScript 函数。
从你的 Go 代码中调用 JavaScript 函数
通常,JavaScript 代码要么在 Node.js 上运行,要么在浏览器中运行。在 Node.js 上运行的任何代码都应该能够访问被称为Node.js 全局对象的内容(nodejs.org/api/globals.html)。如果你的代码最终在 Node.js 环境中运行,GopherJS 通过js.Global变量为你提供了访问全局对象的方式,该变量返回一个*js.Object,它承载着你的全局变量。然后你可以使用名为Get的方法访问特定的对象,然后使用名为Call的方法调用对象方法。让我们通过一个例子来更好地解释这一段落。
运行以下代码:
package main
import (
"github.com/gopherjs/gopherjs/js"
)
func main() {
//the console variable is of type *js.Object
console := js.Global.Get("console")
/*
the *js.Object support a method called Call which can access the methods of console.
*/
console.Call("log", "Hello world!!")
}
这将等同于编写一段类似以下样式的 Node.js JavaScript 代码:
console.log("Hello World!!");
js.Global对象开启了非常有趣的可能性,因为它允许你访问 Node.js 模块并在你的 Go 代码中使用它们。例如,假设我们向我们的 node 项目中导入了一个名为prettyjson的 Node.js 模块,并且我们想在 Go 代码中使用它。prettyjson是一个真正的包,它有一个名为render()的方法,可以将对象转换为美观的 JSON。这在上面的代码中有所展示:
package main
import (
"fmt"
"github.com/gopherjs/gopherjs/js"
)
func main() {
//Some data type
type MyType struct {
Name string
Projects []string
}
//A value from our data type
value := MyType{Name: "mina", Projects: []string{"GopherJS", "ReactJS"}}
/*
Call the prettyjson module, this is equivalent to the following code in JavaScript:
var prettyjson = require("prettyjson");
*/
prettyjson := js.Global.Call("require", "prettyjson")
// The line below is equivalent to 'prettyjson.render(value);' in JavaScript
result := prettyjson.Call("render", value)
/*
Do something with result
*/
}
如前所述,JavaScript 代码也可以在浏览器上运行。浏览器可用的全局对象不同。例如,以下代码在浏览器上可以正常运行,但如果尝试在 Node.js 上运行,则不会高兴:
package main
import (
"github.com/gopherjs/gopherjs/js"
)
func main() {
document := js.Global.Get("document")
document.Call("write", "Hello world!!")
}
这是因为"document"是一个几乎在所有浏览器中都可用的全局对象。
在下一节中,我们将查看 GopherJS 命令。
GopherJS 命令
我们现在拥有了足够的知识来开始探索 GopherJS 提供的命令,以便将 Go 代码编译成 JavaScript。对于你运行的任何 GopherJS 命令,确保GOOS标志设置为darwin或linux。如果你在 Windows 上运行,你需要从终端会话中运行以下命令:
set GOOS=linux
接下来我们需要做的是——让我们首先准备一个环境。在安装了 GopherJS 和source-map-support模块之后,如之前在GopherJS 基础知识部分所述,在你的GOPATH中的 Gosrc文件夹内创建一个新的文件夹。让我们将这个新文件夹命名为9-Isomorphic-GO。在这个新文件夹内,创建另一个名为node的文件夹。这是我们编写预期与 node 包交互的代码的地方。
现在在node文件夹内创建一个名为main.go的文件。然后,将以下代码输入到文件中:
package main
import (
"github.com/gopherjs/gopherjs/js"
)
func main() {
console := js.Global.Get("console")
console.Call("log", "Hello world!!")
}
下一步是利用 GopherJS 的强大功能将前面的代码转换为 JavaScript。这可以通过使用gopherjs build命令简单地完成。所以,在控制台中,导航到node文件夹,然后输入以下内容:
gopherjs build main.go
这将创建一个名为main.js的新文件,该文件将包含您的转换后的 JavaScript 代码。您会注意到main.js文件中有大量的 JavaScript 代码。这是因为 GopherJS 在生成的 JavaScript 文件中重新实现了 Go 运行时的关键部分,以便能够支持大量的 Go 应用程序和包。
就像任何其他 Node.js 文件一样,您只需在新的文件中输入以下命令来运行 JavaScript 代码:
node main.js
GopherJS 还支持install命令。运行以下命令:
gopherjs install
这样做将在您的bin文件夹中生成一个 JavaScript 文件。这类似于go install命令所做的工作,只不过在这种情况下结果是 JavaScript 文件,而不是可执行文件。
GopherJS 命令支持一个标志,允许我们输出压缩后的 JavaScript,这个标志是-m。压缩 JavaScript 涉及删除所有不必要的字符,例如空白、换行符和注释。
如果您想直接运行代码,并且已经安装了 Node.js 的source-map-support模块,您可以直接使用gopherjs run命令,它看起来是这样的:
gopherjs run main.go
如果我们想尝试一些浏览器代码呢?
让我们回到我们创建的父文件夹,它被称为9-Isomorphic-GO。在该文件夹下,创建一个名为browser的新文件夹,然后在下面创建一个名为main.go的新文件。在main.go文件中,编写以下代码:
package main
import (
"github.com/gopherjs/gopherjs/js"
)
func main() {
document := js.Global.Get("document")
document.Call("write", "Hello world!!")
}
上述代码显然预期在浏览器上运行,因为它使用了document对象。我们仍然可以使用gopherjs build命令在这里将其转换为 JavaScript。然而,我们还有另一个选择。
在browser文件夹中运行以下命令:
gopherjs serve
将启动一个网络服务器,默认情况下,它将在localhost:8080地址上提供服务。您对main.go文件所做的任何更改都将反映在提供的网页上;然而,您可能需要刷新网页才能看到更改。
如果您的 Go 代码位于运行gopherjs serve命令的子文件夹中,您的页面将在localhost:8080/your/sub/folder上提供服务,其中your/sub/folder指的是main.go文件的文件夹路径。例如,如果您的代码位于/test/main.go,您的页面将在localhost:8080/test上提供服务。
现在,让我们探索 GopherJS 为我们提供的 Go 和 JavaScript 之间的绑定。
Go 绑定
到目前为止,我们已经探讨了如何通过 GopherJS 将 JavaScript 包嵌入到我们的 Go 代码中。然而,这可能会变得繁琐,尤其是由于 JavaScript 和 Go 包之间存在许多共享功能。幸运的是,GopherJS 支持将 Go 的大部分标准包转换为 JavaScript。兼容的 Go 包列表可以在github.com/gopherjs/gopherjs/blob/master/doc/packages.md找到。
一些包,如 os 包,仅在 Node.js 环境中受支持。这是因为大多数包操作都不适用于浏览器。
例如,如果你查看兼容的 Go 包列表,你会发现 encoding/csv、fmt 和 string 包是一些受支持的包。让我们用 Go 编写以下程序:
package main
import (
"encoding/csv"
"fmt"
"strings"
)
func main() {
//sample csv data
data := "item11,item12,item13\nitem21,item22,item23\nitem31,item32,item33\n"
//create a new csv reader
csvReader := csv.NewReader(strings.NewReader(data))
i := 0
for {
row, err := csvReader.Read()
if err != nil {
break
}
i++
fmt.Println("Line", i, "of CSV data:", row)
}
}
上述代码将产生以下输出:
Line 1 of CSV data: [item11 item12 item13]
Line 2 of CSV data: [item21 item22 item23]
Line 3 of CSV data: [item31 item32 item33]
如果代码用 GopherJS 编译,它将生成一个 JavaScript 文件,该文件将产生相同的结果。这是 GopherJS 中一个非常强大的功能,因为我们甚至不需要在这个项目中导入 GopherJS 包来使其与 JavaScript 兼容。
让我们看看如何在下一节中从 Go 代码中导出 JavaScript 模块。
导出代码
当使用 GopherJS 时,一个有趣的用例是编写 Go 代码模块,然后期望这些模块被 JavaScript 模块使用。
在我们探索如何导出从 Go 代码生成的 JavaScript 模块之前,让我们先通过一些纯 JavaScript 代码来了解该语言中模块导出是如何工作的。
在我们的 node 文件夹内,创建一个名为 calc 的新文件夹。在那个文件夹中,我们将编写一个简单的 JavaScript 模块,它将允许我们添加和/或减去一些数字。
在 calc 文件夹内,创建一个名为 addsub.js 的文件。在那里,我们将创建两个函数,add() 和 sub():
function add(i,j){
return i+j;
}
function sub(i,j){
return i-j;
}
下一步需要做的是将这些两个函数导出,以便其他 JavaScript 模块可以调用它们。这是通过将两个函数赋值给 module.exports 来实现的:
module.exports={
Add: add,
Sub: sub
}
上述代码将暴露两个函数为 Add() 和 Sub(),这样它们就可以被其他 JavaScript 文件导入和调用。
让我们创建一个名为 calc.js 的新文件。这就是我们将从 addsub.js 文件中调用导出函数的地方。要访问 addsub.js 中的导出函数,我们只需执行以下代码:
var calc = require('./addsub.js');
然后,我们可以非常简单地执行我们的导出函数,如下所示:
//Call Add() then save result in the add variable
var add = calc.Add(2,3);
//Call Sub() then save result in the sub variable
var sub = calc.Sub(5,2);
然后,我们可以像这样打印输出:
console.log(add);
console.log(sub);
现在,我们如何在 Go 中编写与 addsub.js 模块等效的代码?
这很简单——我们首先在 Go 中编写我们的函数。让我们创建一个名为 addsubgo.go 的新文件,并在其中编写以下代码:
package main
import (
"github.com/gopherjs/gopherjs/js"
)
//The Add function
func Add(i, j int) int {
return i + j
}
//The Sub function
func Sub(i, j int) int {
return i - j
}
现在,在 Go 的主函数中,我们将利用 GopherJS 提供的一个变量,该变量被称为 js.Module。这个变量让你可以访问由 Node.js 设置的 module 变量。让我们输入以下代码:
js.Module.Get("exports")
这在 JavaScript 代码中相当于 module.exports。
与 GopherJS 的大多数变量一样,js.Module 是 *js.Object 类型,这意味着我们可以调用 Get 或 Set 来获取或设置对象。考虑以下 Go 代码:
exports := js.Module.Get("exports")
exports.Set("Add", Add)
exports.Set("Sub", Sub)
这在 JavaScript 中相当于以下代码:
module.exports={
Add: add,
Sub: sub
}
这就是你需要知道的关键知识,以便通过 GopherJS 在 Go 中编写可导出的 JavaScript 代码。整个 Go 文件将看起来像这样:
package main
import (
"github.com/gopherjs/gopherjs/js"
)
func main() {
exports := js.Module.Get("exports")
exports.Set("Add", Add)
exports.Set("Sub", Sub)
}
func Add(i, j int) int {
return i + j
}
func Sub(i, j int) int {
return i - j
}
我们需要通过 GopherJS 构建前面的代码,以便将其编译成 JavaScript:
gopherjs build addsubgo.go
这将生成一个名为 addsubgo.js 的新文件,我们现在可以将其导入或与其他 JavaScript 文件一起使用。如果我们回到 calc.js,我们可以稍作修改,使其看起来像这样:
//We import the compiled JavaScript file here
var calc = require('./addsubgo.js');
//Call Add() then save result in the add variable
var add = calc.Add(2,3);
//Call Sub() then save result in the sub variable
var sub = calc.Sub(5,2);
console.log(add);
console.log(sub);
前面的代码将产生我们预期的相同结果。
如果我们想编写一个期望对象或多个对象作为参数的函数呢?例如,看看这个:
function formatnumbers(Obj){
return "First number: " + Obj.first + " second number: " + Obj.second;
}
这是一个非常简单的函数,它接受一个对象作为参数。然后它返回一个包含对象字段的 string。该对象预期包含两个字段:first 和 second。当这个函数被调用时,我们需要传递一个对象作为参数给它。以下是调用函数的样子:
//Call FormatWords then save the result in the fw variable
var fw = calc.FormatNumbers({
first: 10,
second: 20,
});
使用 GopherJS 在 Go 中编写等效代码非常容易,多亏了它。
由于 Go 是一种静态类型编程语言,我们首先需要定义我们的对象参数的数据类型。让我们继续在 addsubgo.go 文件中编写代码。以下是它在 Go 中的样子:
type Obj struct {
/*
For any struct type expected to be processed by GopherJS, we need to embed the *js.Object type to it, like below:
*/
*js.Object
/*
We then define the fields of our object
*/
First int `js:"first"` //struct tag represents the field name in JavaScript
Second int `js:"second"` //struct tag represents the field name in JavaScript
}
struct 类型是按照两个规则构建的:
-
在 Go struct 中嵌入
*js.Object类型 -
为任何预期将被转换为 JavaScript 的字段名分配
jsstruct 标签
完美——下一步是在 Go 中编写我们的函数:
func FormatNumbers(o Obj) string {
return fmt.Sprintf("First number: %d second number: %d", o.First, o.Second)
}
这个函数将能够通过 GopherJS 转换为 JavaScript,因为我们创建 Obj 类型时遵循了这两个规则。
接下来,我们导出 FormatNumbers() 函数:
func main() {
exports := js.Module.Get("exports")
exports.Set("Add", Add)
exports.Set("Sub", Sub)
//Make the FormatNumbers function exportable as a JavaScript module
exports.Set("FormatNumbers", FormatNumbers)
}
一旦我们使用 gopherjs build addsubgo.go 命令构建了这段代码,我们的新函数就可以从 JavaScript 模块中调用了。
现在我们知道了如何导出我们的代码,让我们在下一节中看看 Go 方法和 goroutines。
Go 方法
如果我们想将一个带有方法的 Go 类型暴露给 JavaScript 呢?
让我们探索一个 Go 类型。以下代码有一个 struct 类型,它代表一种乐器,并有一些 getter 和 setter 方法:
type MI struct {
MIType string
Price float64
Color string
Age int
}
func (mi *MI) SetMIType(s string) {
mi.MIType = s
}
func (mi *MI) GetMIType() string {
return mi.MIType
}
func (mi *MI) SetPrice(f float64) {
mi.Price = f
}
func (mi *MI) GetPrice() float64 {
return mi.Price
}
func (mi *MI) SetColor(c string) {
mi.Color = c
}
func (mi *MI) GetColor() string {
return mi.Color
}
func (mi *MI) SetAge(a int) {
mi.Age = a
}
func (mi *MI) GetAge() int {
return mi.Age
}
假设我们想让这个类型对 JavaScript 代码可访问。GopherJS 通过一个名为 js.MakeWrapper() 的函数来提供帮助。这个函数可以接受一个 Go 类型作为参数,然后返回一个 *js.Object,它代表了具有所有可导出方法的 Go 类型。
为我们的 MI struct 类型创建一个构造函数。它看起来像这样:
func New() *js.Object {
return js.MakeWrapper(&MI{})
}
在我们的 main 函数中,我们可以通过将其添加到 Global 对象中来使这个构造函数对 JavaScript 可用:
func main() {
//musicalInstruments is the namespace, 'New' is the available function
js.Global.Set("musicalInstruments", map[string]interface{}{
"New": New,
})
}
前面的代码将创建一个名为 New() 的 JavaScript 函数,位于名为 musicalInstruments 的命名空间下。
我们本可以通过模块导出或通过 js.Module 变量来使 New() 构造函数可用。但为了简单起见,目前我们将其添加到 Global 对象中。
假设这个代码所在的文件名为 mi.go。将此代码编译成 JavaScript 的 GopherJS 命令看起来像这样:
gopherjs build mi.go
将生成一个名为mi.js的新文件,JavaScript 可以通过导入该文件简单地访问MI struct类型,然后从musicalinstruments命名空间调用New()函数:
require("./mi.js");
var mi = musicalInstruments.New();
mi.SetAge(20);
console.log(mi.GetAge());
这将创建一个新的乐器对象。然后我们可以设置它的年龄。最后,我们获取年龄并将其记录到标准输出。
Goroutines
GopherJS 支持 goroutines,因此你可以在 Go 代码中使用 goroutines,GopherJS 将处理其余部分。
一个重要的要求是,如果你需要从外部 JavaScript 调用一些阻塞代码,必须使用 goroutines。
例如,考虑以下在浏览器中运行的 JavaScript 代码:
document.getElementById("myBtn").addEventListener("click", function(){
/*SOME BLOCKING CODE*/
});
以下代码定义了一个回调函数,该函数在按钮被点击时执行。
这是如何在 Go 的帮助下使用 GopherJS 来处理这种情况:
js.Global.Get("document").Call("getElementById", "mybtn").Call("addEventListener","call", func() {
go func() {
/*SOME BLOCKING CODE*/
}()
})
如前述代码片段所示,我们必须在事件监听器回调代码中使用 goroutine,因为它预期要运行一些阻塞代码。
现在我们已经了解了 GopherJS 的基础知识,让我们使用 GopherJS 与 React 一起工作。
GopherJS 与 React
在第四章中,我们介绍了流行的 React.js 框架,React.js 前端。由于 GopherJS 的力量,现在有几个开源项目允许你用 Go 编写 React 应用程序。在本章中,我们将通过一个示例来介绍这些开源项目之一,以提供关于如何使用 Go 构建实际 React 应用程序的想法。
项目
在本章中,我们将使用 React 构建一个非常简单的交互式 Web 应用程序。该应用程序包含一个输入文本和一个按钮:

每当我们输入一个名字然后点击提交,它就会被添加到屏幕上的一个列表中,旁边是单词 Hello:

文本输入是交互式的。因此,当你输入文本时,它将实时显示在屏幕上。这就是 React 所知名的这种反应性。
让我们看一下下一节中的项目架构。
项目申请的架构
我们即将实现的 React 应用程序很简单,所以我们不需要超过一个组件。我们的单个组件将包括输入文本、提交按钮、交互式文本和名字列表。以下是我们的组件:

为了在本节中涵盖所有关键 React 概念,我们的组件将使用 React 元素、状态、属性和表单。
该表单将包括输入文本和提交按钮:

我们的 React state对象将包含两个值:
-
当前正在写入的名字
-
名字列表:

我们组件的 prop 值将是显示的名字旁边的通用消息。换句话说,我们的 prop 是单词 Hello:

让我们继续在下一节中构建这个 Go 中的 React 应用程序。
在 Go 中构建 React 应用程序
现在是时候开始用 Go 编写我们的 React 应用程序了。我们将使用一个名为 myitcv.io/react 的流行包。这个包为 React 框架提供了一些 GopherJS 绑定。该包的文档可以在 github.com/myitcv/x/tree/master/react 找到。
我们需要做的第一件事是获取 myitcv.io/react 包,以便在我们的代码中使用它:
go get -u myitcv.io/react
获取一个名为 reactGen 的工具,这个工具可以简化在 Go 中构建 React 应用程序。它可以用来自动构建骨架应用程序,这些应用程序可以作为更复杂应用程序的构建块:
go get -u myitcv.io/react myitcv.io/react/cmd/reactGen
打开一个终端窗口,然后导航到 reactGen 文件夹:
//In windows:
cd %GOPATH%\src\myitcv.io\react\cmd\reactGen
//or in other operating systems:
cd $GOPATH\src\myitcv.io\react\cmd\reactGen
输入 go install 命令。这应该将 reactGen 工具编译并部署到 %GOPATH%\bin 文件夹。请确保该路径存在于您的 PATH 环境变量中。
输入以下命令来检查是否已安装 reactGen:
reactGen -help
一旦安装了 reactGen,我们就可以开始编写我们的应用程序了。前往 9-Isomorphic-Go 文件夹。在里面,我们将创建一个名为 reactproject 的新文件夹。在终端中,导航到 reactproject 文件夹,然后输入以下命令:
reactGen -init minimal
这将为我们的 React 应用程序创建一个骨架。让我们探索生成的应用程序——里面有四个文件:
-
main.go:我们的应用程序的入口点。 -
index.html:我们的应用程序的入口 HTML 文件。 -
app.go:我们的 React 应用程序的App组件——这将是第一个在我们的应用程序中渲染的组件。 -
gen_App_reactGen.go:此文件是从app.go自动生成的。对于任何我们编写的组件,之后都会生成一些自动生成的代码,这些代码将包含使我们的组件正常工作所需的所有管道代码。这种代码生成使我们能够专注于构建 React 组件中的重要部分,如 props、states 和 elements。
在我们开始编写我们的 React 组件之前,让我们探索使用 reactGen 工具在 app.go 文件中创建的 App 组件:
// Template generated by reactGen
package main
import (
"myitcv.io/react"
)
type AppDef struct {
react.ComponentDef
}
func App() *AppElem {
return buildAppElem()
}
func (a AppDef) Render() react.Element {
return react.Div(nil,
react.H1(nil,
react.S("Hello World"),
),
react.P(nil,
react.S("This is my first GopherJS React App."),
),
)
}
上述代码创建了一个名为 AppDef 的 Go 结构体,它充当 React 组件。为了使 Go struct 类型符合 React 组件的要求,它需要满足三个条件:
-
Go 结构体的名称必须以
Def后缀结尾。 -
Go 结构体必须嵌入
react.ComponentDef类型。 -
struct类型必须实现Render()方法,这相当于 React 的render()方法。
与 React.js 类似,Render() 方法必须返回 React 元素。myitcv.io/react 框架提供了与 React 元素相对应的方法。从前面的代码中,我们看到 Render() 返回以下内容:
react.Div(nil,
react.H1(nil,
react.S("Hello World"),
),
react.P(nil,
react.S("This is my first GopherJS React App."),
),
)
上述代码对应以下 React JSX:
<div>
<h1>Hello World</h1>
<p>This is my first GopherJS React App.</p>
</div>
每个 JSX 元素都对应于 Go 中的 react.<element type> 函数。总共有三个元素。第一个是 <div> 元素,它托管了其他两个元素。在 Go 中,这翻译为 react.Div(nil,...other_elements)。第一个参数是我们的元素 props。由于我们没有包含任何 props,第一个参数最终变成了 nil。如果我们需要添加一个 React prop——比如说,className prop——可以简单地这样做:
react.Div(&react.DivProps{
ClassName:"css_class_name"
},...other_elements)
第二个元素是 h1 元素。在 Go 中,我们将其表示为 react.H1(nil,react.S("Hello World"))。第一个参数表示传递给元素的 props。react.S("") 函数简单地表示一个字符串。
第三个元素是 P 元素。在 Go 中,它看起来是这样的:
react.P(nil, react.S("This is my first GopherJS React App."))
现在,让我们看看这段代码的实际效果。如果你使用的是 Windows,请将 GOOS 环境变量设置为 linux:
set GOOS=linux
从我们的 reactproject 文件夹中,在终端中运行以下命令:
gopherjs serve
这将在端口 8080 上为我们提供 React 应用程序。如果我们打开一个网页浏览器并访问 localhost:8080/<src 中的 Go 项目文件夹>,我们将看到这个简单应用程序:

现在,我们准备构建我们的自定义组件,我们将在下一节中完成。
构建自定义组件
在 reactproject 文件夹下,创建一个名为 hello_message 的新文件夹。在文件夹内,我们将创建一个名为 hello_message.go 的新文件。在文件中,我们将调用 hellomessage 包:
package hellomessage
我们然后创建一个 Go 结构体来表示我们的 React 组件:
import "myitcv.io/react"
type HelloMessageDef struct {
react.ComponentDef
}
现在,是时候定义我们的 props 了。这可以通过包含我们期望的 props 的 struct 类型来完成。如前所述,我们的 prop 是消息字符串:
//Naming convention is *props
type HelloMessageProps struct {
Message string
}
定义 state 对象与 props 非常相似。需要创建一个 struct 类型,其中包含预期的 React state 对象字段。我们的 state 对象字段是当前写入文本输入框的名称,以及迄今为止写入的名称列表:
//Naming convention is *State
type HelloMessageState struct {
CurrName string
Names []string
}
如 第四章 中所述,使用 React.js 的前端,React 框架会在 React 检测到 state 对象已更改时决定重新渲染你的组件。由于我们这里的 state 对象包含一个 Go 切片,未来的状态和当前状态不能简单地使用 == 操作符进行比较。在这种情况下,强烈建议为 React 提供一种方式来决定 React 对象是否已更改。这是通过 Equals 方法实现的,该方法由 state Go 结构体实现。以下是它的样子:
func (c HelloMessageState) Equals(v HelloMessageState) bool {
//compare CurrName between current and future states
if c.CurrName != v.CurrName {
return false
}
//compare Names between current and future states
/*there are other ways to compare slices, below is a very simplistic approach*/
if len(c.Names) != len(v.Names) {
return false
}
for i := range v.Names {
if v.Names[i] != c.Names[i] {
return false
}
}
return true
}
到目前为止,我们需要在终端中运行 go generate 命令来生成一些辅助代码,我们可以使用这些代码来编写组件的其余部分。运行 go generate 命令后,你会注意到为我们生成了一个新文件,名为 gen_HelloMessage_reactGen.go。不要编辑此文件。
生成的文件将为你提供一个新数据类型来使用:*HelloMessageElem。此类型代表我们的组件的 React 元素。
让我们回到 hello_message.go 中的代码,下一步是编写我们新 React 组件的构造函数。构造函数需要接受属性作为参数,并返回 React 元素作为结果。以下是它的样子:
func HelloMessage(p HelloMessageProps) *HelloMessageElem {
fmt.Println("Building element...")
return buildHelloMessageElem(p)
}
由于我们的代码通过 GopherJS 编译成 JavaScript,fmt.Println() 函数将被翻译为 console.log(),这在 Go 绑定 部分已有说明。
接下来,我们需要定义我们组件的 Render() 方法。Render() 方法需要在我们的组件 Go 结构的非指针类型中定义。以下是一个空的 Render() 方法:
func (r HelloMessageDef) Render() react.Element {
return nil
}
现在,我们必须用以下内容填充 Render() 方法:
-
包含一个输入文本框和一个提交按钮的表单
-
一个字符串用于存储当前正在写入的名字
-
一系列字符串,代表输入的名字的历史记录
作为复习,请查看以下图表:

红色矩形代表我们的 React 状态,绿色矩形代表我们的属性,蓝色矩形代表我们的整个 React 组件。
回到我们的 Render() 方法,首先,我们需要编写文本输入元素。它是一个 "text" 类型的 HTML 表单输入元素。以下是它的样子:
InputName := react.Input(&react.InputProps{
Type: "text",
Key: "FirstName",
Placeholder: "Mina",
Value: r.State().CurrName,
OnChange: r,
}, nil)
上述代码代表一个 React input 元素,由 myitcv.io/react 包提供。第一个参数是输入元素的属性;第二个参数是 nil,因为我们不需要为这个元素提供任何子元素。输入属性与我们在 JSX 格式中使用的属性相同。这里有两个值得注意的属性,我们在这里使用了:
Value: r.State().CurrName,
OnChange: r,
Value 属性是输入文本的当前值。通过将 CurrName 字段的 State 对象分配给输入文本的 Value 字段,我们确保输入文本将根据你输入的名字而改变。
OnChange 属性代表每当我们的输入文本发生变化时采取的动作。该属性必须指向实现 OnChange(event) 方法的类型。由于我们将其分配给 r,我们必须实现 OnChange。以下是它的样子:
func (r HelloMessageDef) OnChange(e *react.SyntheticEvent) {
//we need to import "honnef.co/go/js/dom" for this to work
//get target: our input text HTML element
target := e.Target().(*dom.HTMLInputElement)
//get current state
currState := r.State()
//change state to include new value in our input text component, as well as the existing history of names
r.SetState(HelloMessageState{CurrName: target.Value, Names: currState.Names})
}
上述代码是自解释的:
-
Go React 框架提供了一个名为
*react.SyntheticEvent的类型,它代表传递给OnChange方法的事件。 -
我们检索正在写入输入文本的值。
-
我们检索当前的 React 状态。这是通过使用
State()方法完成的。 -
我们使用
SetState()方法更改我们的 React 状态以表示新的名字。
现在,让我们回到我们的 Render() 方法。下一步是编写提交按钮组件。它也是一个 HTML 表单输入元素,但它是 "Submit" 类型。一个 "Submit" 类型的 HTML 表单输入元素是一个按钮。每当提交按钮被按下时,表单将被提交:
InputBtn := react.Input(&react.InputProps{
Type: "Submit",
Value: "Submit",
}, nil)
接下来,我们需要编写我们的 React 表单。表单元素将作为文本和按钮元素的父元素。我们的表单元素还将包含一个"Name:"字符串。
如前所述,每当提交按钮被按下时,表单将被提交。通常,当 HTML 表单被提交时,其输入数据会被发送到服务器,在那里表单的输入数据会被处理。在我们的情况下,我们想要捕获提交事件,然后而不是表单提交的默认行为,我们想要改变我们的state对象,将新的输入名称添加到我们的state.Names列表中。
在我们深入探讨如何定义表单提交时采取的操作之前,让我们回到render方法,并定义我们的表单:
Form := react.Form(&react.FormProps{
OnSubmit: r,
},
react.S("Name: "),
InputName,
InputBtn)
注意,我们定义了一个OnSubmit React 表单属性。这是我们如何在 Go 代码中定义表单提交时采取的操作。传递给OnSubmit属性的我们必须实现一个具有OnSubmit(*react.SyntheticEvent)签名的OnSubmit方法。让我们在我们的代码中实现这个方法:
func (r HelloMessageDef) OnSubmit(e *react.SyntheticEvent) {
//Prevent the default form submission action
e.PreventDefault()
//Add the new name to the list of names in the state object
names := r.State().Names
names = append(names, r.State().CurrName)
/*
Change the state so that the current name is now empty, and the new name gets added to the existing list of names
*/
r.SetState(HelloMessageState{CurrName: "", Names: names})
}
完美——现在我们只需要完成Render()方法。以下是我们的自定义表单的Render()方法剩余任务:
-
从我们的
state对象中获取已保存的名称列表。 -
对于列表中每个已保存的名称,将其转换为
Li元素。这是一个表单列表元素。 -
返回一个包含以下内容的
Div对象:-
定义好的表单
-
一个包含属性消息的字符串,与
state对象中当前保存的名称结合 -
现有名称列表
-
下面是其余代码的样式:
names := r.State().Names
fmt.Println(names)
entries := make([]react.RendersLi, len(names))
for i, name := range names {
entries[i] = react.Li(nil, react.S(r.Props().Message+" "+name))
}
return react.Div(nil,
Form,
react.S(r.Props().Message+" "+r.State().CurrName),
react.Ul(nil, entries...),
)
下面是整个Render()方法:
func (r HelloMessageDef) Render() react.Element {
InputName := react.Input(&react.InputProps{
Type: "text",
Key: "FirstName",
Placeholder: "Mina",
Value: r.State().CurrName,
OnChange: r,
}, nil)
InputBtn := react.Input(&react.InputProps{
Type: "Submit",
Value: "Submit",
}, nil)
Form := react.Form(&react.FormProps{
OnSubmit: r,
},
react.S("Name: "),
InputName,
InputBtn)
names := r.State().Names
fmt.Println(names)
entries := make([]react.RendersLi, len(names))
for i, name := range names {
entries[i] = react.Li(nil, react.S(r.Props().Message+" "+name))
}
return react.Div(nil,
Form,
react.S(r.Props().Message+" "+r.State().CurrName),
react.Ul(nil, entries...),
)
}
现在,我们可以运行go generate。
我们组件已完成;然而,还有一些工作要做。我们需要从位于app.go文件中的App组件调用我们新创建的组件。这将通过我们之前创建的HelloMessage(p HelloMessageProps) *HelloMessageElem构造函数来完成。构造函数接受 props 作为参数,并返回我们的自定义 React 元素。我们需要修改的代码位于我们的App组件的Render()方法下。属性对象中有一个名为Message的字段。我们想要传递的消息值仅仅是"Hello":
func (a AppDef) Render() react.Element {
/*
Return a react div that hosts a title, as well as our custom hello message component
*/
return react.Div(nil,
react.P(nil,
react.S("This is my first GopherJS React App."),
),
react.H1(nil,
hellomessage.HelloMessage(hellomessage.HelloMessageProps{Message: "Hello"}),
),
)
}
这就是我们的代码。如果你从reactproject文件夹的终端运行gopherjs serve命令,项目将在浏览器中的localhost:8080/<your project folder from src>地址下可访问。
当你准备好将你的 React 项目转换为 JavaScript 时,只需从reactproject文件夹中运行gopherjs build命令。这将生成一个reactproject.js文件,该文件可以从项目文件夹中的index.html文件中使用。如果你查看项目文件夹中的index.html文件,你会找到一个名为reactproject.js的脚本。如果你在执行构建步骤后从浏览器打开index.html,你会发现你的应用程序按预期工作。
摘要
在本章中,我们专注于使用 Go 构建同构应用。我们涵盖了将 Go 代码转换为 JavaScript 代码的一些关键主题。我们深入探讨了 GopherJS 中的 Go 绑定,并考虑了它如何帮助我们连接这两种语言。
我们还利用 GopherJS 框架构建了与 JavaScript 集成的 Go 应用程序,无论是在前端还是服务器端。我们探讨了重要的话题,例如并发性和方法。
我们还介绍了 Go React 框架,并介绍了在 Go 中构建简单 React 应用程序的过程。
在下一章中,我们将涵盖诸如云原生应用和 React Native 框架等主题,以便您可以进一步磨练您的技能。
问题
-
什么是 transpling?
-
什么是 GopherJS?
-
*js.Object类型是什么? -
js.Global变量是什么? -
js.Module变量是什么? -
js.MakeWrapper()函数的作用是什么? -
jsGo 结构标签的作用是什么? -
构建 React 组件在 Go 中的主要步骤是什么?
进一步阅读
更多信息,请查看以下链接:
-
GopherJS:
github.com/gopherjs/gopherjs -
GopherJS 与 React:
github.com/myitcv/x/tree/master/react/_doc -
创建 GopherJS React 应用:
github.com/myitcv/x/blob/master/react/_doc/creating_app.md -
GopherJS React 示例:
blog.myitcv.io/gopherjs_examples_sites/examplesshowcase/
第十章:接下来去哪里?
欢迎来到本书的最后一章。在这本书中,我们介绍了如何使用强大的 Go 语言开发全栈 Web 软件的大量实用主题。在这一章中,我们将探讨读者应该探索的一些主题,以提升他们的技能并将他们的知识提升到新的水平。我们将重点关注两个主题:
-
云原生应用程序
-
React Native 框架
云原生应用程序
对于希望将应用程序扩展到无限可扩展以适应不断增长的用户需求和扩展的数据负载的人来说,云原生应用程序是一个重要的话题。云原生应用程序可以定义为在分布式和可扩展的基础设施上运行的应用程序。它们预计始终可用、可靠、能够实时更新,并且不会在压力下崩溃。它们通常依赖于冗余、负载均衡和各种其他技术来实现其目标。这个话题绝非微不足道。事实上,整本书都致力于如何构建云原生应用程序。在本节中,我们将介绍在构建云原生应用程序中使用的某些关键技术。
云原生应用程序并不总是部署到云中,如 AWS 或 Azure。它们也可以部署到支持可扩展性的组织内部基础设施上。
在接下来的章节中,我们将探讨微服务、容器、无服务器应用程序和持续交付,这些都是云原生应用程序的重要概念。
微服务
微服务是现代软件中一个非常流行的概念。它是指将你的应用程序的任务划分为小型自包含的软件服务。因此,而不是拥有一个涵盖广泛任务的大型应用程序,你可以为每个任务使用一个微服务。微服务使得你的应用程序非常可扩展。
微服务的概念与单体应用程序的概念相反,后者是一种所有任务都编码在一起的应用程序。
这里是一个表示事件预订应用程序的单体应用程序的例子:

下面是如何用微服务应用程序表示相同的事件预订应用程序:

前面图中的每个块代表一个负责一项特定任务的微服务。
你可以将你的微服务分散到多个服务器节点上,在多个系统上实现负载均衡。你可以使你的微服务冗余,这样如果某个服务宕机,另一个服务就会接管工作,就像什么都没发生一样。这种冗余在部署补丁和更新到你的应用程序时也非常有用。
下面是我们现在支持的冗余示例图,以确保应用程序始终可用。如果一个服务因维护或系统崩溃而关闭,另一个服务将接管:

尽管微服务在应用程序中提供了大量的可扩展性和灵活性,但它们在长期维护中可能会变得具有挑战性,特别是如果你的架构扩展到数百或数千个微服务。将需要一些特殊的监控工具来确保它们得到适当的维护和照顾。
在下一节中,我们将讨论容器概念。
容器
容器技术是一种相对较新的技术。然而,它已经变得非常受欢迎,以至于它已经成为云原生应用世界中关键的基础设施软件的一部分。容器允许你用隔离的用户空间或一个容器来包围你的软件。
容器在部署和运行可扩展的微服务中非常有用,因为它们允许你的微服务在一个包含所有微服务配置、环境变量、依赖项、运行时以及服务所需的任何其他文件或设置的隔离空间中运行。
容器允许软件开发者在同一服务器节点上部署隔离的服务,确保你的服务不受同一节点上其他服务的任何影响。容器还允许开发者通过容器镜像一次性部署一个包含微服务运行所需所有内容的微服务。容器不仅用于微服务;它们还可以用于任何可以从容器中受益的软件,例如数据库引擎。例如,使用容器镜像部署 MySQL 是部署和运行 MySQL 的一种流行方法,相对容易。目前最受欢迎的容器技术之一是 Docker(www.docker.com)。
下面是一个在服务器节点上运行的一些容器的例子:

让我们在下一节中看看无服务器应用程序。
无服务器应用程序
无服务器应用程序是云原生应用世界中另一种相对较新的技术。它们主要用于不需要持续运行的任务。为了正确理解无服务器应用程序的概念,让我们通过一个例子来探讨。
考虑亚马逊的 AWS Lambda 服务(aws.amazon.com/lambda/),该服务被全球众多应用程序使用。为了执行特定任务,该服务允许用户请求在远程运行一个函数。换句话说,你要求 AWS Lambda 为你运行一些代码。代码执行后,输出会返回给你。不维护任何有状态的数据。在 Lambda 上运行的代码或函数由你提供。
无服务器应用程序利用 AWS Lambda 等服务来运行间歇性任务,这些任务不是一直都需要运行。这为你的应用程序提供了可伸缩性,因为它减轻了仅为了执行临时任务而维护软件服务的需求。AWS Lambda 等服务也被称为 FaaS(函数即服务)。
这里有一个示例应用程序,其中一些服务被函数所替代:

让我们在下一节中看看持续交付。
持续交付
持续交付是一种理念,即软件应快速且频繁地以短周期发布,而不是以更长和更慢的周期发布。持续交付允许组织通过能够随时发布改进和修复来有效地持续改进其软件。
持续交付不仅仅是使用软件工具;它还涉及整个组织都需要遵守的一种思维方式。需要有一个流程,任何对软件的添加都可以快速构建、测试和部署/交付。
持续交付对于运行云原生应用程序的组织来说非常实用且有效,因为它允许增量且快速的改进,而不是可能导致生产环境中软件崩溃的巨大步骤变化。尽可能自动化流程以获得最大利益是很重要的。
让我们在下一节中简要了解一下流行的 React Native 框架,用于构建移动应用程序。
React Native
在这本书中,我们介绍了如何从头开始设计和编写一个 React 应用程序。这是现代软件中一个非常强大的技能。React 目前正在被用于驱动互联网上一些最受欢迎的网站。学习 React.js 的另一个强大优势是,你的技能可以通过 React Native 来构建跨平台移动应用程序。
什么是 React Native?
React Native (facebook.github.io/react-native/) 是由 Facebook 开发的一个开源项目。它利用 React.js 框架的功能来构建可以在 Android 或 iOS 上运行的跨平台移动应用程序。React Native 非常受欢迎,目前被大小组织用于构建实用的移动应用程序。
React Native 的一大优势是,你编写的几乎所有代码都可以在 Android 和 iOS 设备上运行。这意味着你几乎只需要关注你整个应用程序的单一代码库。另一个主要优势是,React Native 使用目标平台的本地 API。这就是为什么用 React Native 编写的移动应用程序通常比用其他 JavaScript 移动框架编写的应用程序性能更好。
在下一节中,我们将探讨 React.js 和 React Native 之间的一些区别。
React.js 与 React Native 的比较
尽管这两个框架都命名为 React,但它们之间还是有一些区别。让我们首先了解它们的相似之处,然后我们将讨论一些不同之处。
React.js 和 React Native 之间的相似之处
下面是 React.js 和 React Native 之间的一些相似之处:
-
它们都使用 JavaScript ES6
-
它们都依赖于 React 组件,包括
render()方法 -
它们都依赖于 React 元素
-
它们都使用 JSX 来构建视觉元素
React.js 和 React Native 之间的区别
下面是 React.js 和 React Native 之间的一些区别:
-
React Native 有自己特殊的 JSX 语法来构建 UI 组件。默认情况下,它不像 React.js 那样使用 CSS 和 HTML。
-
React Native 依赖于一些特殊的库来与移动设备接口。例如,你需要使用 React-Native 特定的包来编写围绕手机相机或加速度传感器的代码。
-
部署 React Native 应用程序与部署 React.js 的体验不同,因为需要将应用程序及其所有应用规则部署到移动设备上。
Expo
Expo (expo.io/) 是一个非常受欢迎的免费开源工具链,它允许用户相对容易地构建 React Native 移动应用程序。它提供了一个 SDK,可以暴露重要的功能,如相机访问、文件系统以及推送通知。当你作为一个初学者构建 React Native 应用程序时,Expo 是一个最佳的起点。
摘要
在本章中,我们介绍了一些非常流行和现代的技术。云原生应用程序现在非常受欢迎,尤其是在构建可靠、可扩展应用程序的需求不断增长的情况下。
React Native 是构建高性能、跨平台移动应用程序的关键框架。它利用了强大的 React 框架的原则和架构来实现这一点。
我们希望您在这本书的学习之旅中与我们一同享受,我们祝愿您在构建 Go 全栈应用程序时一切顺利。
问题
-
云原生应用程序是什么?
-
是否有必要将云原生应用程序部署到云端?
-
微服务是什么?
-
容器是什么?
-
Docker 是什么?
-
无服务器应用程序是什么?
-
React Native 是什么?
-
Expo 是什么?


浙公网安备 33010602011771号