区块链开发七天实用指南(一)
原文:
zh.annas-archive.org/md5/af79b3f5ee1ede6c2518deb93a96c328译者:飞龙
前言
区块链是一项革命性技术,以太坊是一个运行智能合约的分散式区块链平台。
7 天实战区块链开发将教会你如何使用以太坊区块链构建一个在线游戏应用。本书的每一部分都将介绍与创建在线游戏相关的基本区块链编程概念,然后紧接着是实践性的练习,作为家庭作业来实施。通过本书,你将获得创建智能合约、与以太坊网络交互、构建用户界面以及将应用部署到互联网所需的核心区块链应用程序开发技能。本书提供了七个独立的课程,以实用、动手的方式进行教学。
通过本书,你将会惊讶于在一周内在以太坊网络上,你已经学到了多少关于区块链应用程序开发!
本书适合对象
本书适合渴望获得区块链应用程序开发技能并希望掌握区块链应用程序开发的软件工程师和 IT 专业人士。本书非常适合那些具有有限编程经验的人。
本书内容
第一章,第一天 - 应用程序介绍、安装和设置,带领我们完成了运行应用程序所需的基本环境设置。我们将了解我们的应用程序做什么以及它是如何运行的,还会学习我们将用于与区块链交互的各种工具。
第二章,第二天 - Solidity 变量和数据类型,教导我们有关 Solidity 编程语言的一切。它向我们展示了如何为 Solidity 编写代码,Solidity 中不同类型的变量以及如何实现它们。
第三章,第三天 - 在智能合约中实现业务逻辑,向我们展示了智能合约是什么,以及它们如何用于处理区块链应用。然后,我们将学习如何在这些智能合约中编写业务逻辑,以使我们的应用程序按预期运行。
第四章,第四天 - 创建测试,向我们展示了测试的重要性。在本书中,我们将广泛使用测试来确保我们的代码没有任何问题。本章向我们展示了各种类型的测试,如何创建它们以及如何使用它们来改进我们的应用程序。
第五章,第五天 - 构建用户界面,教会了我们关于 React 框架的一切,我们将使用它作为后端来创建应用程序的用户界面。我们将学习如何通过用户界面与我们的区块链网络交互,确保它正常运行,并学习如何通过用户界面将我们的应用程序连接到网络。
第六章,第六天 - 使用钱包,向我们展示了什么是区块链钱包,它们是如何工作的,以及为什么我们应该使用它们。我们将学习如何将我们的钱包与区块链网络联系起来,并使用它来管理我们应用程序中存在的各种交易。
第七章,第七天 - 部署到网络,教会了我们如何上传我们的应用程序供全球用户使用。我们将学习如何使用 Ropsten 测试网络来测试我们的应用程序,然后部署到真实的以太坊区块链上。我们将学习如何使用亚马逊网络服务(AWS)上传和托管我们的游戏用户界面,全球用户将使用它来玩游戏。
要充分利用本书
对于您理解和应用本书中的所有概念,一些关于编程的基本知识是必需的。有关区块链工具的基本知识将是额外的优势。
下载示例代码文件
您可以从您在 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-Blockchain-Development-in-7-Days。如果代码有更新,它将更新在现有的 GitHub 存储库中。
我们还提供了来自我们丰富图书和视频目录的其他代码包。请查看:github.com/PacktPublishing/。
下载彩色图像
我们还提供了一份 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在此处下载:www.packtpub.com/sites/default/files/downloads/9781838640101_ColorImages.pdf。
使用的约定
本书中使用了许多文本约定。
CodeInText:表示文本中的代码单词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入以及 Twitter 句柄。这是一个例子:“在上面的示例中,我们声明了一个无符号整数 (uint) 作为 foo,并将其可见性设置为 public。”
代码块设置如下:
function foo() public returns
(string) {
return "Hello";
}
function bar() external {
foo_ = foo(); //Not valid
foo_ = this.foo(); //Valid
}
当我们希望引起您对代码块的特定部分的注意时,相关行或项将以粗体显示:
function foo() public returns
(string) {
return "Hello";
}
function bar() external {
foo_ = foo(); //Not valid
foo_ = this.foo(); //Valid
}
任何命令行输入或输出均如下所示:
$ geth --testnet --syncmode "light" --rpc --rpcapi db, eth, net, web3, personal, admin --cache=1024 --rpcport 8545
粗体:表示一个新术语,一个重要词汇,或者屏幕上显示的词语。例如,菜单或对话框中的单词在文本中会以这样的形式出现。这是一个例子:“现在它将打开 Chrome Web Store。之后,点击 添加到 Chrome 按钮。”
警告或重要提示会出现在这样的形式下。
小贴士和技巧会以这样的形式出现。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在消息主题中提及书名,发送邮件至 customercare@packtpub.com。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误是不可避免的。如果您在本书中发现了错误,我们将不胜感激。请访问 www.packt.com/submit-erra…,选择您的书籍,点击勘误提交表单链接,然后输入详细信息。
盗版:如果您在互联网上发现了我们作品的任何形式的非法副本,请向我们提供位置地址或网站名称,我们将不胜感激。请通过邮件联系我们,邮箱为 copyright@packt.com,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个主题上拥有专业知识,并且对编写或为书籍做贡献感兴趣,请访问 authors.packtpub.com。
评论
请留下您的评论。一旦您阅读并使用了本书,为什么不在您购买它的网站上留下评论呢?潜在读者可以看到并使用您的客观意见做出购买决定,我们在 Packt 可以了解您对我们产品的看法,而我们的作者可以看到您对他们的书的反馈。谢谢!
欲了解更多关于 Packt 的信息,请访问 packt.com。
第一章:第一天 - 应用程序介绍、安装和设置
区块链是一个如此广阔的话题,在短短一周内就能变得高效,启动您的区块链项目,或者成为一名区块链开发人员,这真的可能吗?区块链技术正在出现在各个行业,从我们如何银行到我们如何旅行,我们如何证明我们的身份,甚至我们如何获得医疗保健等方面。所以,这让我们产生了一个问题,您真的可以在短短七天内学会创建一个区块链应用程序吗?
在这个项目中,您将学习如何使用以太坊区块链在七天内创建一个在线游戏应用程序!在此过程中,您将学习如何使用 Solidity 创建和使用变量,并使用函数执行业务逻辑;通过学习编写测试来消除代码中的错误和错误,您将与以太坊区块链交互,通过一个使用 React 和 Redux 的用户界面。您将编写代码来从您的去中心化应用程序发送和接收资金,最后您将学习如何将您的去中心化应用程序部署到以太坊网络和亚马逊网络服务上。在这本书中,我们将一切都拆解到基础,这样您就可以放心,您成功所需的唯一一件事就是想成为区块链开发人员的愿望和愿意付出努力。
本章将作为开始这个项目的基石,涵盖以下主题:
-
我们应用程序的介绍
-
安装所需的工具
-
创建我们的第一个智能合约
-
理解基本语法
-
写下您的第一个测试
我们应用程序的介绍
现在让我们来看一下我们将要构建的应用程序。我们将构建一个游戏应用程序,向玩家展示一个介于 0 和 9 之间的数字,然后他们可以选择下注一个神秘数字是否高于或低于显示的数字,然后可以下注他们愿意下注的以太币数量。他们游戏的结果显示在历史窗口中,当他们赢了或输了时,资金会自动从他们的以太坊账户中添加或扣除。以下截图展示了应用程序的演示:
要构建这个应用程序,您将使用 Solidity 创建包含并在区块链网络上执行游戏规则的合约。用户界面将使用 React 编写,您将使用 Solidity 编写合约代码。您将学习一个名为 Ganache 的工具,它允许您在本地工作站上托管自己的以太坊区块链。我们现在已经准备好开始构建应用程序了。让我们从在我们的工作站上设置所有必需的工具开始。
安装所需的工具
让我们来看看你将需要的所有工具,不仅是本书,还有作为一个区块链开发者成功所需的工具。我们将了解托管我们的应用程序、编译和迁移我们的代码以及测试我们的应用程序所使用的所有技术。所以让我们开始我们的第一个工具吧!
Visual Studio Code
我们将使用的第一个工具是 Visual Studio Code。在其最简单的形式中,它是一个文本编辑器,但是当你对它更加熟悉时,它将成为你作为区块链开发者的一部分。它包含了许多功能,这些功能将使你的生活更加轻松,比如语法高亮、智能感知以及针对特定编程语言的扩展,比如本书中将要使用的 JavaScript 和 Solidity。我们将使用 Visual Studio Code 使用 Solidity 编程语言创建我们的智能合约。
要安装它,可以访问以下网站:code.visualstudio.com/,并下载适合你操作系统的安装包。你应该看到一个类似于以下屏幕截图的首页:
这是唯一非必需的工具;你真正需要的只是一个你熟悉的代码编辑器,所以如果你已经有一个编辑器,你也可以继续在本书中使用它。
我们的智能合约为我们的应用程序提供了两条规则——它是用一种叫做 Solidity 的编程语言编写的,并且它存在于以太坊区块链网络上。另一种想象合约的方式是想象购买一辆汽车。这样做,你同意购买价格、首付款以及可能的融资条款。所有这些细节都输入到一个合约中,你签署以购买车辆。我们不会打印出那个合同然后在一张纸上签名,我们将这些细节放入一个智能合约中,你或者买方会进行加密签名。
我们还将使用 Visual Studio Code 编写渲染我们应用程序用户界面的 JavaScript。我们将具体使用 React 和 Redux 来创建用户界面。最终,我们希望的是某人访问我们的网站,并且服务器向他们发送一个包含我们应用程序的网页。我们将使用 Node.js 来实现这一点,所以你需要安装它。
Node.js
正如我们在前一节中提到的,我们将需要一个网络框架,可以用来发送和接收数据到和从我们的网站。Node.js 是全球范围内用于此类目的最流行的框架之一。
Node.js 主要用于实现后端 API,这些 API 可以在浏览器之外运行 JavaScript 代码。在本书中,我们将使用它来连接我们的合约、GUI 和编程后端到我们的网站。你可以通过访问 nodejs.org/en/download/ 并选择适合你操作系统的正确包来安装 Node.js。Node.js 的首页看起来类似于以下屏幕截图:
Truffle 框架和 Ganache
当用户在我们的应用程序中执行需要写入区块链的操作时,称为事务。事务不会立即写入;相反,它被发送到网络,在那里等待矿工确认为有效的事务。一旦矿工确认了它,它就会被写入区块链,此时我们可以向用户提供更新后的状态信息。
现在,以太坊网络上的所有这些都代表着数十万台服务器,但我们没有数十万台服务器闲置,并且您不希望在开发期间每次需要测试时都等待外部服务器。
因此,我们将使用 Ganache 模拟我们自己的以太坊网络。它是一个独立的应用程序,可以在您自己的工作站上创建一个以太坊测试网络。要安装 Ganache,我们将前往 truffleframework.com/ganache,并下载其安装程序包。以下截图显示了此的登陆页面:
一旦完成了这一步,我们需要安装 Truffle 框架。这是一个以太坊开发框架,使我们能够更轻松地完成一些事情,比如编译我们的合约、与区块链网络交互以及将我们的合约迁移到以太坊网络。
为此,我们将打开一个终端,无论是 Bash shell 还是 Windows 命令提示符,然后输入以下命令:
$ npm install -g truffle
注意,npm 被打包为 Node.js 的一部分,这意味着如果您跳过安装 Node.js 步骤,那么在此命令起作用之前,您需要返回并完成它。
现在,为了获取我们应用程序所需的代码,我们将使用以下命令:
$ truffle unbox github_url
此命令为您下载并设置代码。请确保在前面的块中用实际的 GitHub 仓库替换 github_url 值!
在运行 truffle 命令时,您可能会遇到一些错误。这是一个已知的问题,并作为官方文档的一部分嵌入其中。您可以参考以下链接找到解决方法:truffleframework.com/docs/truffle/reference/configuration#resolving-naming-conflicts-on-windows。
经过我们的先决条件,现在是时候进行更有趣的事情了,这包括编写一些代码。在下一节中,我们将看看写合约意味着什么,以及它如何作为区块链网络的一部分进行交互。
创建我们的第一个智能合约
在本节中,您将学习以太坊合约的基础知识。您将学习它们是什么,它们居住在哪里,如何创建它们以及如何将它们部署到以太坊网络。
合同是由其函数和数据或合同的当前状态表示的代码集合。它位于以太坊网络上的特定地址,并且需要记住的一件重要事情是,以太坊网络是公开的,这意味着任何人都可以查看您的合同及其数据。
这与你可能熟悉的传统应用程序不同。在传统应用程序中,代码通常存储在应用程序中,而数据则存储在其他地方,可以是磁盘上的文件或数据库中。现在,让我们看看合同包含什么。
分析合同
我们合同的源代码存储在contracts文件夹中。当我们编译它们时,它们将发送到以太坊区块链。编译还会创建一个存储在build/contracts文件夹中的合同元数据。以下截图显示了我们将要构建的应用程序的结构:
合同本身以pragma语句开头,pragma语句告诉编译器有关代码的信息。在我们的情况下,我们想告诉编译器,此代码是用 Solidity 编程语言编写的,并且使用了 Solidity 语言的0.5.0版本。因此,提及此内容的代码如下所示:
pragma solidity 0.5.0
在前面的代码块中看到的版本信息是SemVer或语义化版本。基本上,在 SemVer 中,第一个数字表示包的主要版本,中间数字表示次要版本,第三个数字表示补丁级别。
在升级时需要记住的一点是,升级版本可能会导致一些不兼容的情况;然而,遵循 SemVer 的应用程序中的一个好处是,如果引入的变化与以前的版本不兼容,可以增加主要版本号来告诉编译器使用较新的工具补丁。此外,虽然不太常见,但某些应用程序也会在次要补丁号中引入重大变更,这就是我们在代码中使用^符号的原因,用于 Solidity 的旧版本。这个^符号告诉编译器,可以使用 Solidity 编程语言的任何版本,从0.4.24一直到但不包括0.5.0。这确保了您使用的编译器版本与您编写的版本兼容。然而,由于我们在这里使用的是0.5.0,所以我们不会将其纳入考虑。
要声明一个合同,我们使用contract关键字后跟我们的合同名称,按照常规的惯例,这个合同名称跟随文件名,因此这个Gaming合同的文件将会是Gaming.sol。我们有一组左大括号{,然后如果需要包含注释,可以这样做,就像在 SQL 编程语言中一样,用/*符号,然后我们用右大括号}来结束我们的合同。这可以在以下代码片段中看到:
pragma solidity 0.5.0;
contract Gaming {
/* Our Online gaming contract*/
}
在 Solidity 中有一个称为构造函数的特殊函数,它在合同创建时仅运行一次。通常用于初始化合同数据。例如,让我们看看以下代码片段:
pragma solidity 0.5.0;
contract Gaming {
/* Our Online gaming contract*/
address owner;
bool online;
constructor() public {
owner = msg.sender;
online = true;
}
}
如前面的代码片段所示,我们有一个名为owner的变量和一个名为online的变量。当合同被创建时,我们将owner变量设为将合同推送到网络的以太坊地址,我们还将online变量设为true。
Solidity 是一种编译语言,为了在区块链上使用合同,它必须被编译和迁移到该网络。要编译它,我们将使用 Truffle 框架。我们可以用truffle compile命令来做到这一点,它会创建一个包含合同元数据的 JSON 文件,其中包含有关您的合同的信息,我们将使用该 JSON 文件来与合同交互并验证其源代码。
为了在区块链网络上使用我们的合同,我们必须将其从我们的工作站传输到网络上,这就是迁移。因为我们使用 Truffle 框架,我们可以使用truffle migrate命令很容易地完成这个过程。
现在,如果我们查看我们的目录布局,在migrations目录中,我们将找到一个名为1_initial_migration.js的文件。该文件是由 Truffle 框架提供的,它处理合同的部署。让我们来看看该文件中的代码:
var Migrations =
artifacts.require("./Migrations.sol");
module.exports = function(deployer){
deployer.deploy(Migrations);
};
有一个名为Migrations的变量,它需要 Truffle 库Migrations.sol,然后导出一个函数,该函数需要一个deployer对象来部署迁移。
我们还有一个名为2_deploy_contracts.js的文件,它包含在下载中。这个文件实际上是要迁移我们作为本书的一部分编写的合同。现在让我们来看看这个文件中的代码:
var Gaming =
artifacts.require("./Gaming.sol");
module.exports = function(deployer){
deployer.deploy(Gaming);
};
类似于前一个文件,有一个名为Gaming的变量,它需要我们创建的Gaming.sol合同文件,然后运行deploy方法来部署Gaming合同。
测试合同
为了帮助巩固迁移这个概念,我们实际上将迁移一些合同,然后分析网络会发生什么。为了做到这一点,我们将使用以下步骤:
- 我们的第一步是启动 Ganache 应用程序,这将使我们的私有区块链运行起来,如下面的屏幕截图所示:
正如前面的截图所示,我们有 Ganache 应用程序正在运行。让我们看一下余额,它目前为 100 以太,还没有被挖掘的区块,也没有交易。
- 切换到控制台,我们将输入以下命令将合同迁移到网络上:
$ truffle migrate
- 一旦迁移成功,我们将返回 Ganache,那里会看到类似这个截图的界面:
我们可以看到地址 0 或账户 0 已经花费了一些以太坊。这是用来支付合同迁移的费用。从区块来看,我们可以看到挖掘了四个区块,以及用于创建和迁移这些合同的四笔交易。
- 如果我们切换到代码编辑器,本例中为 Visual Studio Code,在
build/contracts文件夹中,您可以看到编译结果产生的合同元数据文件在这里:
如果我们打开Migrations.json文件并滚动到底部,您可以看到它存储了此合同已部署到的网络,以及部署合同的地址,如下截图所示:
祝贺!如果你已经到达这里,那么你已经成功创建并部署了你的第一个合同。你已经看到了一些语法的用法,因为我们指定了owner变量并构建了constructor函数。在下一节中,我们将讨论 Solidity 编程语言使用的一些语法和风格指南。
理解基本语法
我们不能在没有覆盖一些基本的 Solidity 代码开发语法指南的情况下开始我们的学习之旅。编写代码时的一致性目标并不是为了确定什么是对或错,而是为了提供指南,帮助确保代码总是相同的。这使得代码更易于阅读和理解。
这是一个重要的记住点——它并不是正确的方式或最好的方式,只是一个一致的方式。这并不意味着风格指南适用于每个情况。如有疑问,您应该查看其他脚本示例,然后做出最好的判断。
代码布局
对于代码布局,我们应该始终使用每级缩进四个空格。空格优先于制表符,但即使使用制表符,也要避免在同一文件中混合使用制表符和空格。让我们在接下来的代码块中看一个例子:
pragma solidity 0.5.0;
contract Gaming {
function determineWinner() public(){
if (guess == true){
return true;
}
else if (guess == false){
return false;
}
}
}
我们首先看到指定的contract,然后第一个声明的函数缩进了四个空格。if块本身从那里缩进了四个空格。
空行
建议用两个空行包围顶层声明,用单个空行包围函数级别的声明。这将帮助您比随机查找文件并希望找到所需行更快地发现错误。让我们使用以下示例来看看如何使用空行:
contract A {
function foo() public{
//...
}
function bar() public{
//...
}
}
contract B{
function foo() public{
//...
}
}
在前述的代码块中,我们声明了两个合同,在这两个合同之间有两个空行,这样就有了一个很好的大空白,方便区分。在我们的函数声明中,每个函数都用一个空行分隔开。这应该有助于轻松区分代码片段的每个元素。
行长度
对于行长度,建议最多 79 个字符。79 个字符的推荐值是在很久以前设定的,当时人们使用的 TTY 终端最大宽度为 80 个字符。现在看到最大宽度为 99 个字符实际上已经变得相当普遍了。让我们参考以下示例:
myReallyLongAndDescriptiveFunctionN
ame(
reallyLongVariableOne,
reallyLongVariableTwo,
reallyLongVariableThree,
reallyLongVariableFour
);
如图所示,第一个参数不附加在声明函数的行上,它只有一个缩进。此外,每行只有一个参数,最后,结束元素单独占一行。
函数布局
你的函数应该按特定顺序排列。这是正确的顺序:
-
构造函数
-
回退函数
-
外部函数
-
公共函数
-
内部函数
-
私有函数
我们还没有讨论函数,所以这些内容可能对你来说还有点模糊,但我们将在下一节中详细讨论这个问题,所以请耐心等待。
为了构造函数,我们将在同一行上打开括号,并且在与开始声明的唯一缩进水平的地方关闭该行。同样,开放的大括号之前应该有一个单一空格。让我们看一下下面的代码块:
pragma solidity 0.5.0;
contract Gaming {
function determineWinner() public() {
if (guess == true){
return true;
}
else if (guess == false){
return false;
}
}
}
如图所示,当我们声明Gaming合同时,我们的开放大括号之间有空格。同样,对于我们的determineWinner()函数,它在同一行上有开始大括号,然后我们的函数的结束大括号就在下面,函数的第一个字符开始的位置。
在声明变量时,我们更喜欢双引号而不是单引号来表示字符串。我们在操作符周围加上一个空格,因为这有助于在代码中突出显示它们,这样您就可以更轻松地识别它们。以下代码片段说明了这一点:
string str = "foo"; //This
string str='foo'; //Not this
但是,当您有优先级较高的运算符时,您可以省略周围的空间以表示其优先级。
命名约定
在命名变量时,应该始终避免使用单个字母的变量名,例如 int L,bool O 等。这样做的原因是它们看起来非常相似,对你的代码只会增加不必要的复杂性。
在命名合约、库、结构体和事件时,你应该使用 CapWords(或者 CamelCase),其中你将变量名的每个单词的第一个字母大写。以下片段展示了一个示例:
contracts SimpleGame {
//...
}
contracts MyPlayer {
//...
}
在命名函数、函数参数、变量和修饰器时,你应该使用 mixedCase,这与 CapWords 非常相似,只是你不要将第一个字母大写,如下面的代码块所示:
contracts SimpleGame {
function simpleGame () {
//...
}
}
在命名常量时,你应该使用全大写字母命名它们。
你可以使用下划线(_)符号来避免函数、变量和代码中的其他对象之间的命名冲突。以下代码块显示了一个示例:
function mysteryNumber() returns (uint) {
uint randomnumber = blockhash%10 + 1;
return randomnumber;
}
uint mysteryNumber_ = mysteryNumber();
在前面的代码块中,我有一个名为mysteryNumber()的函数,在稍后的代码中,当使用该函数时,我真的很合理地将我的变量命名为mysteryNumber,因为它是一个神秘的数字,但我不能重用该名称而导致名称冲突。因此,当我实际获取到神秘数字变量时,我在其末尾加了一个下划线,以便mysteryNumber_成为我从mysteryNumber()函数中获得的变量。这样很容易区分这两者,但非常清楚我从哪里获取了那个神秘数字。
到现在为止,你可能已经看到,实施一致的编写代码指南如何使代码更易读和维护。虽然这不会使代码运行更快或者保证它是正确的,但它确实使编码的人类因素更加愉快,这反过来可能会使合作和讨论函数而不是代码格式更容易。在下一节中,我们将看看为我们的代码编写测试以及为什么我们要这样做。
编写你的第一个测试
在前面的一节中,我们学习了如何高效编写代码。虽然这样做可以使代码更易读,但这并不意味着我们的代码就能正常工作,毕竟,破损的代码也应该看起来漂亮,对吧?
让我们稍微谈谈你目前的测试策略。在编写代码后,你是否通过使用它并检查输出来手动测试它,然后在发布最新版本之前重复相同的过程?嗯,让我问你这个问题,你是否曾经忘记过一步,结果导致你发布了一个 bug,如果你记得执行那一步,就可以捕捉到那个 bug?在进行任何更改之前运行测试是个好主意。我们都花了不少时间试图弄清楚我们的代码是如何破坏了测试的,只是后来才发现,在我们开始做任何更改之前,测试就已经破了。
在这本书中,让我们编写自动执行这项任务的测试,我们将利用节省的所有时间来编写更多优秀的代码。我们的测试可以用 Solidity 编写,也可以用 JavaScript 编写。它们可以自动验证代码是否按预期执行,并且应在每次代码更改之前和之后运行。
因此,为了测试Gaming合同,我们将命名我们的测试文件为TestGaming.sol,测试本身放在项目的test目录中。它们也是一个实际的 Solidity 合同,这使得它们相对容易编写,因为你使用与编写任何其他 Solidity 合同相同的技术。
让我们来看一下应用程序中的一个实际的示例测试合同以及实施它们的一些最佳实践。你可以通过在文本编辑器中打开TestGaming.sol文件来访问合同。
现在,让我们将这个合同分解成单独的部分。合同开始如下:
pragma solidity 0.5.0;
import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../contracts/Gaming.sol"
因此,我们有了pragma solidity语句和我们的 Solidity 版本。然后,我们从 Truffle 导入Assert库和DeployedAddresses库。除此之外,我们还将导入我们正在进行测试的实际合同,即Gaming.sol。下一步是定义我们的合同:
contract TestGaming {
uint public initialBalance = 10 ether;
Gaming gaming;
就像任何其他 Solidity 合同一样,我们定义我们的合同并给它命名为TestGaming。我们给它一些初始的ether来进行保存,我们可以在我们的测试中使用它,然后创建我们的gaming变量,这是Gaming合同的实例。
在我们的任何测试运行之前,我们先拿到我们部署的合同,因为在测试过程中,每次测试运行都会向测试网络部署一个全新的合同实例。以下代码显示了如何做到这一点:
function beforeAll() public {
gaming = Gaming(DeployedAddresses.Gaming());
}
然后,我们为我们想要测试的每一个测试场景创建函数,如下面的代码块所示:
function testPlayerWonGuessHigher() public {
bool expected = true;
bool result = gaming.determineWinner(5, 4, true);
Assert.equal(expected, result, "The player should have won by guessing the mystery number was higher than their number");
}
function testPlayerLostGuessLower() public {
bool expected = false;
bool result = gaming.determineWinner(5, 4, false);
Assert.equal(expected, result, "The player should have lost by guessing the mystery number was lower than their number");
}
testPlayerWonGuessHigher()函数测试玩家是否猜测数字应该更高,如果数字确实更高,那么他们应该赢了。
testPlayerLostGuessLower()函数测试当数字实际更高时猜测较低的玩家是否应该输掉。
我们在我们的测试中设置这些场景,定义我们期望发生的事情,然后使用这些断言来验证实际发生的情况。因此,我们只需要在需要感觉或者在部署之前运行这个测试,我们就可以自信地说,我们确定谁赢得比赛和谁输掉比赛的功能是准确工作的。
作业
在这本书的每一章中,我都会给你布置一项作业,要在你开始下一天或下一章之前完成。今天的作业主要是关于设置你的开发环境。这不仅将帮助你完成本书的剩余内容,而且我将向你展示的工具将帮助你处理你接手的每一个区块链项目。以下是你需要做的:
-
安装 Visual Studio Code。这是唯一可选的步骤,只有在你已经有一个你熟悉和喜爱的代码编辑器时才是可选的。
-
从nodejs.org安装 Node.js。
-
从 Truffle 框架网站安装 Ganache。
-
使用
npm模块安装 Truffle 框架本身。
如果您之前已安装了 Truffle,请确保您至少安装了 Truffle 版本 4.1,并在需要时进行更新。您可以使用 truffle version 命令随时检查您的 Truffle 版本。
-
最后,使用
truffle unbox命令和本书 GitHub 仓库的 URL 安装课程代码。 -
完成后,启动终端或命令提示符,根据您的操作系统,转到下载代码的目录,然后输入
truffle test。
如果一切顺利,Truffle 将会返回如下屏幕截图所示的成功消息:
总结
这就是本书第一天的内容了!在这一章中,我们学习了使用区块链和智能合约的所有基础知识。我们学会了如何设置环境来创建区块链应用程序。我们还学习了所有关于基本代码语法、命名约定、风格和结构的知识,以便达到最佳效率。我们学会了如何创建智能合约,以及如何测试它以确保其按预期工作。
在下一章,也就是第二天,我们将看看 Solidity 变量和数据类型,以及如何使用它们构建一些业务逻辑和数据。欢迎来到区块链的世界!
第二章:第二天 - Solidity 变量和数据类型
欢迎来到本书的第二天。在上一章中,我们设置了开发环境,并了解了本课程中要使用的工具。在本章中,我们将学习 Solidity 编程语言中的变量;我们将介绍它们是什么以及它们如何使用。本章涵盖以下主题:
-
理解 Solidity 变量
-
Solidity 中的数据类型
-
使用 Solidity 变量
-
理解 Solidity 操作符
-
使用 Solidity 操作符
理解 Solidity 变量
如果你是编程新手,这是一个需要掌握的关键概念。如果你是一位经验丰富的程序员,在本章中仍然有一些有价值的提示,因为我们将涵盖 Solidity 中变量特定的一些细微差别。
Solidity 是一种静态类型语言;这意味着,在声明变量时,必须声明变量的类型。考虑以下示例:
uint public foo = 4;
在上述示例中,我们声明了一个无符号整数 (uint) 作为 foo,并将其可见性设置为 public。我们可以选择为其分配一个值,然后以分号结束语句。
在 JavaScript 中,你可能会看到 foo 用 let 声明,或者作为一个常量,如果你在查看 ECMAScript 2016 之前的旧 JavaScript 代码,甚至可能会看到它声明为一个变量。
在下一节中,我们将详细介绍 Solidity 中所有常见类型,但现在,让我们专注于这个可见性标识符的含义。
理解可见性
Solidity 中的所有状态变量都必须声明其可见性,因此,我们有几种类型的可见性:
-
外部
-
公共
-
内部
-
私有
当可见性声明为 external 时,这意味着变量或函数成为合约接口的一部分。这暴露了函数或变量,以便可以从其他合约和交易中调用它们。例如,如果我们在智能合约中有一个函数,用于确定我们游戏的玩家是否赢了或输了一轮,并且我们需要从 React 应用程序中调用它,我们将不得不将其声明为 external,因为尽管我们的 React 应用程序位于我们的 Web 服务器上,但我们的智能合约位于以太坊网络上。
同样,具有 public 可见性的状态变量和函数是合约接口的一部分,可以从其他合约和交易中调用它们。它们无法在内部调用,而必须使用关键字 this,并且所有公开声明的变量都会自动生成一个自动获取器函数。
语句 can't be called internally without this 是什么意思?让我们使用以下示例:
function foo() public returns
(string) {
return "Hello";
}
function bar() external {
foo_ = foo(); //Not valid
foo_ = this.foo(); //Valid
}
如果我们的合约中有一个名为 foo() 的函数,它返回字符串 Hello,那么直接从该合约中调用 foo() 函数将失败。如果我们想要调用它,我们需要以 this.foo() 的方式调用它。在内部声明的变量和函数只能在当前合约及其派生合约中访问。
在同一个示例中,如果我们将 foo 函数的可见性更改为 internal,那么直接调用该函数而无需 this 关键字现在可以工作了。
私有函数与内部函数非常相似,它们在当前合约内可用,但它们的不同之处在于它们在派生自该合约的合约中不可用。但需要注意的是,这并不意味着您在 private 状态变量和函数中的代码是私有的,这只是意味着它不能被调用。
以太坊区块链上的所有内容都是公开可见的。为了强调这一点,通过使用诸如 Etherscan 这样的工具,我可以查看流行的去中心化应用程序(dApp)的源代码,例如 CryptoKitties,并查看所有源代码,包括私有函数和变量。
声明为 public 的状态变量将自动创建一个 getter 函数。这是一个节省时间的功能。毕竟,由于您将变量声明为 public,您可能期望您的客户在某个时候需要访问该变量的值。您不必花时间和精力来创建该函数,因为 Solidity 编译器会为您创建一个。让我们考虑以下示例:
contract A {
uint public foo = 42;
}
A.foo();
如前例所示,我们有一个名为 A 的合约,声明了一个 public 变量 foo。这意味着任何交易或合约都可以通过调用 A.foo() 函数来获取 foo 变量的值。您会注意到,您不需要编写任何代码来使其工作,因为编译器已经为您完成了。
现在,我们已经了解到 Solidity 是一种静态类型语言,我们在声明变量时必须声明我们正在使用的变量类型。有了这个理解,让我们进入下一节,学习 Solidity 中可用的不同数据类型以及它们的作用。
Solidity 中的数据类型
Solidity 中的数据类型分为两大类:
-
值类型
-
引用类型
值类型是按值传递的,这意味着每当一个变量引用与另一个相同时,值总是被复制。与此同时,引用类型表示更复杂的类型,可能不总能适应 256 位。由于它们的大小较大,复制有时可能会很昂贵,我们有两个不同的位置可用于存储它们:内存或存储。现在让我们来讨论一下值数据类型。
值类型
我们将讨论的第一个值类型是布尔值。 它可以有两个值之一,要么是true,要么是false。 在 Solidity 中,当创建时,一切都会初始化为默认值 0。
对于布尔值,0 对应默认值false。 布尔值可用的运算符如下:
-
逻辑否定:表示给定值的相反,并由
!符号表示。 例如,foo = !false。 -
逻辑合取:表示两个值的逻辑结果,并使用
&&符号表示。 例如,(foo && bar) > 1。 -
逻辑析取:表示两个值之间的结果使用或,并使用
||符号表示。 例如,(foo || bar) > 1。 -
相等性:用于将变量与某个固定值进行比较,并使用
==符号表示。 例如,foo_ == true。 -
不等式:用于检查变量的值是否不等于指定值,并使用
!=符号表示。 例如,foo_!= true。
下一个值类型是整数。 整数是整数,意味着没有小数点,它们可以是两种类型:
-
有符号整数(
int) -
无符号整数(
uint)
您可以分步声明整数的确切大小,从 8 到 256。 因此,值可以声明为uint8、uint16,一直到uint256。 如果不声明大小,只声明int或uint,则它是int256和uint256的别名。 此外,重要的是要知道,使用整数,除法总是截断的; 因此,两个整数的除法结果始终是一个整数。
尽管所有先前的数据类型都是其他编程语言共有的,但地址数据类型是 Solidity 独有的。 它是一个 20 字节的值,用于表示以太坊网络上的地址。 地址可以是用户的以太坊账户或部署到网络上的合约。
它有一个.balance()成员,可用于检查与该账户关联的余额,以及一个.transfer()成员,可用于向该地址转账。 还有一个.send()成员,它是.transfer()成员的低级对应。 如果使用.send(),您必须检查操作的返回值以确定成功或失败,但如果您使用.transfer(),转账成员会自动处理这个问题。
下一个值类型是字符串。 字符串只是一块文本,但从技术上讲,它是一个字节数组。 这意味着您可以对字符串执行与数组相同的操作。 在声明时,您可以使用单引号或双引号,但正如我们在第一章中学到的那样,第一天 - 应用程序介绍、安装和设置,双引号更受推荐。 它们可以隐式转换为字节。 此外,字符串值支持转义字符,例如换行(\n)和制表符(\t)。
现在我们完成了值类型。让我们来看看 Solidity 提供的引用类型。
引用类型
第一个是一个数组。数组是一组数据元素,可以通过它们的索引号来识别。它们可以是固定大小的或动态的。要声明一个数组,您需要指定数组将包含的数据类型,然后是变量名称,然后是方括号,如下面的示例所示:
uint foo[]
如果数组声明为 public,您将获得一个自动 getter 函数。它与我们在上一节中看到的示例不同,因为 getter 函数需要知道要返回数组的哪个元素,并且它不会返回整个数组。因此,此函数需要元素或索引号作为参数。
它有一个 .length() 成员,将返回数组的长度,还有一个 .push() 成员,用于将新项目添加到数组的末尾。
接下来的数据类型是结构体。结构体提供了一种定义新数据类型的方法,但结构体不能包含自己的数据类型。您不能定义由您正在定义的东西组成的东西。考虑以下示例:
struct Player {
address player;
string playerName;
uint playerBalance;
uint wins;
uint losses;
}
Player jim =
Player(msg.sender, "Jim", 0, 0, 0);
上述示例展示了如何创建一个数据类型来保存我们游戏中每个玩家的信息。我们首先将数据类型定义为 struct,将其命名为 Player,并打开大括号。在里面,我们定义了所有我们存储在玩家身上的属性(例如玩家的以太坊地址、玩家的姓名、他们当前的余额、他们赢的次数以及他们输的次数),然后关闭大括号。然后,为了为我们的游戏创建一个新玩家,我们定义一个新的 Player 类型变量,并使用代表新玩家的值初始化 Player 结构。
接下来是映射键类型。如果您使用过其他编程语言,可能会认识到映射类似于哈希表。这种数据类型是键类型到值类型的映射,当它被创建时,它被虚拟初始化,以使每个可能的键都存在,并且它被映射到一个其字节表示都是零的值。如果其可见性设置为 public,Solidity 将创建一个自动 getter。因此,基于我们先前为玩家创建的结构体的示例,我们可以创建所有玩家的映射,如下所示:
mapping (uint => Player) players;
在这里,我们将使用无符号整数作为玩家数据类型映射的键,这个对象将被称为 players。然后,我们可以通过指定映射的第一个元素并将新玩家分配给它来向映射中添加我们的第一个玩家。
现在我们了解了 Solidity 中可用的基本数据类型,让我们学习如何在 Solidity 合约中实际使用它们。
使用 Solidity 变量
现在我们已经了解了 Solidity 中变量的概念,让我们在合约中将它们实际应用起来。让我们看一下我们游戏中的合约:
pragma solidity 0.5.0;
contract Gaming {
/*Our Online Gaming Contract*/
address owner;
bool online;
}
在声明合同后面,声明了两个变量。第一个是owner,它是一个address数据类型,代表部署该合同到以太坊网络的人的以太坊地址。然后我们有一个bool数据类型的叫做online,我们将使用它来确定您的游戏是在线还是离线;如果需要,它允许我们将游戏设置为离线。
嗯,在以太坊合同中有一个特殊的函数叫做constructor,它在合同部署时执行一次且仅执行一次。在其中,我们将做两件事:我们将把我们在合同开头声明的owner变量设置为将该合同部署到网络的以太坊地址:
constructor() public payable {
owner = msg.sender;
online = true;
}
这意味着如果我部署了合同,我拥有这个游戏,如果你部署了合同,你就拥有这个游戏。这是您编写合同并将其交付给您的客户的一种方式,当他们部署它们时,他们将拥有合同积累的所有资产和货币。我们还将把我们的online变量设置为true;,我们稍后将使用它来有效地将我们的游戏设置为离线,如果需要的话。
我们还有一个叫做winOrLose()的函数,我们将从我们的 React 应用程序中调用它,并提供所需的参数来确定我们的玩家在这一轮中是赢还是输。还有另一个叫做isWinner()的函数,如果玩家赢了,则返回 true,如果输了,则返回 false。所以让我们使用以下代码片段来看一下它是如何工作的:
function winOrLose(uint display, bool guess, uint wager)
external payable returns (bool) {
if (isWinner == true ) {
/*Player won*/
msg.sender.transfer(wager*2);
return true;
} else if (isWinner == false) {
/*Player lost*/
return false;
}
}
在这里,我们有一个if语句,我们正在评估其中包含的条件。接下来,我们有包含要执行的代码的大括号。在括号内部,我们有我们的isWinner()变量,它是一个布尔数据类型,我们使用==符号来评估这个变量是否评估为布尔true。如果是,它将执行包含在块中的代码。
这段代码使用了一个特殊的消息sender变量,其中包含调用该函数的账户的地址。在这种情况下,就是我们的玩家。玩家赢了,所以我们将使用.transfer()成员将下注金额的两倍转移给玩家;我们之所以要翻倍,是因为玩家必须在此次交易中包含他们想要下注的金额,所以我们需要将它们归还给他们,再加上他们从该赌注中赢得的金额。
如果该语句的评估结果不为true,那么该代码块将不会执行,因此代码执行会继续到else if代码块。它的操作方式与if代码块相同。它将评估括号内的语句,如果isWinner()为 false,该代码块将返回 false 给我们的 React 客户端。
在我们的 React 代码中,我们将检查这个值,并根据情况更新 UI,以告知玩家他们在这一轮中失败了。
以太单位
当我们谈到变量时,让我们看看可以应用于变量的一些特殊单位。我们将从Ether单位开始。它们用于指定货币,并且可以应用于任何文字数字。它们还可以在 Ether 的子单位之间进行转换。
Ether 的子单位如下:
-
Wei:这是 Ether 中最小的货币单位
-
Szabo:也被称为微 Ether,等于 10 的 12 次方 Wei
-
Finney:也被称为毫 Ether,等于 10 的 15 次方 Wei
-
Ether:等于 10 的 18 次方 Wei
要使用这些单位,只需在任何文字数字的末尾指定单位,编译器就知道如何在不同的子单位之间进行转换。
其他特殊单位
我们还有时间单位可供使用,并且它们可以用于指定时间单位。它们的声明方式与货币一样:任何文字数字都可以附加所需的单位。这里可以使用的不同时间单位包括:
-
秒
-
分钟
-
小时
-
天
-
周
但要注意的是,不要在日历计算中使用它。并非每一天都有 24 小时,这是由于闰秒。闰秒类似于闰年,只是它们是秒。实际上,Solidity 过去也有一年的时间单位,但由于闰年的问题,已被废弃。
还有一些独特的变量。第一个是区块编号(block.number)。请记住,以太坊矿工始终在确认交易,将其写入区块,然后将这些区块添加到区块链中。这是从该操作中的当前区块编号;它用于跟踪当前正在挖掘的区块。
区块时间戳(block.timestamp)是当前区块的时间戳,报告自 1970 年 1 月 1 日以来经过的秒数。还有一个别名称为now,它指的是block.timestamp。
然而,这两者都不应该依赖,因为它们可以在一定程度上被恶意矿工操纵,这可能被用来利用您的合约的时间戳。一般来说,您可以确信当前时间戳大于上一个区块的时间戳,并且它将小于下一个区块的时间戳,但就这样了。
消息价值(msg.value)是随消息发送的货币数量。在构建我们的应用程序时,我们将更详细地探讨这一点。我们将使用它来收集我们游戏玩家的赌注。
你已经见过msg.sender并了解它是当前调用者的以太坊地址。还有一个tx.origin或传输起源,它是交易发送者的以太坊地址。你可能会觉得诱人,但总的来说,消息发送者可能是你想要的。tx.origin和mg.sender可能不是同一个东西,特别是在合同或功能调用其他合同或功能的情况下。
好了!现在,我们已经见过 Solidity 数据类型,也学习了它们在代码中的使用;接下来,我们将看看不同的运算符,这些运算符可以用来构建合同中的复杂业务规则和逻辑。
理解 Solidity 运算符
在本节中,我们将看看 Solidity 中可用的不同运算符。这样做将帮助您更好地理解可以编写到合同中实现业务逻辑的逻辑操作。
我们将要讨论的前三个运算符如下:
-
赋值
-
相等性
-
不等式
刚开始学编程时,你可能会觉得这些很困惑;然而,现在花一分钟将为你未来节省数小时的沮丧。
赋值用于将值赋给变量。例如,我们可以将变量foo赋值为bar,如下面的代码片段所示:
string foo = "bar";
foo == bar;
foo != bar;
接下来的运算符是相等性,我们用它来确定一个变量是否等于另一个变量。在上面的示例中,我们正在检查变量foo的值是否等于字符串bar,在我们的案例中,是的,所以这个表达式将评估为 true。
最后我们有不等式,它与等式完全相反。所以,在前一个示例的第三行中,我们正在检查foo是否不等于bar,但它是相等的,所以这个表达式将评估为 false。
我们还有一组比较运算符。我们可以使用这些来确定一个值大于(>)、大于或等于(>=)、小于(<)或小于或等于(<=)另一个值。这些运算符的工作方式与我们刚刚看过的等式和不等式运算符类似。
当您编写代码时,我们还有一些速记运算符可以节省您的时间。其中一些如下所示:
-
+=:这是加法的速记运算符 -
-=:这是减法的速记运算符 -
*=:这是乘法的速记运算符 -
/=:这是除法的速记运算符 -
%=:这是余数的速记运算符 -
|=:这是逻辑与的速记运算符 -
&=:这是逻辑或的速记运算符
写a += e等价于写a = a + e,但更短更容易输入。
我们还可以使用a++和a--运算符来递增或递减计数器 1。但是,在执行其中一个时,表达式将返回更改之前的a的值,因此如果a = 1,并且我们执行a++运算符,则表达式返回1的输出,但是a的值现在是2。
还有++a和--a,它们执行相同的操作。它们递增或递减 1,但它们返回变化后的实际值。所以,如果a = 1,并且我们执行++a,表达式将返回a的新值,即2。
我们可以使用delete运算符将变量分配给其类型的初始值。例如,如果foo是一个整数,执行delete foo将使foo的值设置为 0。
当运算符应用于不同类型时,编译器会尝试将其中一个操作数隐式转换为另一个的类型。如果在转换中语义上有意义,并且转换中没有丢失信息,则可以进行隐式转换。例如,8 位无符号整数(uint8)可以转换为uint16、uint28或uint256,但 8 位整数(int8)不能转换为无符号 256 位整数,因为uint256不能保存负数。
现在我们对运算符和变量有了一定的了解,以及如何在 Solidity 中使用它们,让我们看一些实际的例子,通过使用它们来在我们的合约中创建业务逻辑。
使用 Solidity 运算符
在本节中,我们将看一些使用 Solidity 运算符的实际示例。这样做将使您具备在去中心化应用的智能合约中开始实现自己的业务逻辑所需的技能。
让我们从声明一个变量开始;我们通过指定其类型来做到这一点,在本例中是无符号整数,并将变量命名为a。我们没有为其分配一个值,因此 Solidity 将其分配一个初始值为 0。我们可以使用=符号将值5赋给a,我们也可以像下面的代码片段中所示一样一行完成同样的事情:
uint a; //initialized as 0
a = 5; //assign a value of 5
uint b = 10; //create and assign value of 10
现在我们可以写出表达式a == b,以检查变量a和b是否相等。由于 5 不等于 10,因此该表达式返回false。如果我们写表达式a != b,则其求值为true,因为 5 不等于 10。我们还可以使用大于(>)运算符来查看a是否大于b,由于 5 不大于 10,它将返回false;使用小于(<)运算符,我们可以检查a是否小于b,由于 5 小于 10,该表达式返回 true。
让我们看另一个例子:
uint x[];
for (uint i = 0; i < 10; i++) {
x.push(i);
}
在这里,我们声明了一个变量x作为无符号整数数组,通过将类型指定为uint来指定无符号整数,然后将变量名分配为x,并包括方括号以指示它是一个数组。然后,我们创建了一个具有三个部分的for循环。
第一个是我们的初始变量,也就是我们循环的起点,所以它是一个我们初始化为 0 的无符号整数i。接下来是我们循环的条件,它告诉循环何时停止;在这里,我们希望它在i小于 10 时继续循环。最后,我们的增量是每次for循环迭代时使用的。我们使用++来递增i的值。然后,我们有大括号,其中包含每次循环执行的代码。在这些大括号中,我们想要将i的值推送到我们的数组x中。结果是我们的数组x被填充了十个值,每个值代表了在for循环中该实例中i的值。
我们应用中的运算符
现在,让我们深入了解一下我们将要构建的游戏中的一个函数。我们将在下一章节中详细讨论函数,即第三章Day Three - 在智能合约中实现业务逻辑,所以我们暂时跳过了解这个函数如何工作的细节,并专注于其中运算符的使用。以下代码片段显示了该函数:
function winOrLose(uint display, bool guess, uint wager)
external payable returns (bool) {
require(online == true);
require(msg.sender.balance > msg.value, "Insufficient
funds");
uint mysteryNumber_ mysteryNumber();
bool isWinner = determineWinner (mysteryNumber_,
display, guess);
if (isWinner == true ) {
/*Player won*/
msg.sender.transfer(wager*2);
return true;
} else if (isWinner == false) {
/*Player lost*/
return false;
}
}
在这里,我们声明了一个名为mysteryNumber_的无符号整数,它的值来自函数mysteryNumber():
function mysteryNumber() internal view returns (uint)
{
uint randomNumber = uint(blockhash(block.number-1))%10
+1;
return randomNumber;
}
我们还声明了一个名为randomNumber的无符号整数,并将其作为此函数的结果返回。为了生成我们的随机数,我们使用了之前学习过的一个特殊变量,block.number,它是当前正在挖掘的块,并从中减去了 1,因此我们得到了前一个块的编号。然后,我们使用 Solidity 的blockhash()函数来获取该块的哈希值,然后将其转换为无符号整数,并使用取余(%)运算符来获得将该blockhash除以 10 的余数。基本上,这给了我们blockhash的最后一个数字;我们将在该数字上加 1,这将是我们的随机数,并且函数将此作为其代码的最后一行返回。
回到之前的winOrLose()函数中,我们现在有了我们的mysteryNumber,所以我们声明了一个名为isWinner的布尔变量,它的值来自determineWinner()函数:
function determineWinner
(uint number, uint display, bool
guess)
public pure returns (bool) {
if (guess == true){
if (number > display) {
return true;
}
}else if (guess == false){
if (number > display) {
return false;
}
}
}
此函数接受三个参数,并确定我们的玩家本轮是赢了还是输了。这三个参数是我们刚生成的神秘数字、在我们的 React 应用程序中向玩家显示的数字以及玩家猜测的神秘数字是否比他们的数字更高或更低。我们函数中的第一件事是使用if-else if语句来确定玩家是否猜测更高还是更低,然后执行相应的逻辑。
这是一个复杂的情况,因为这是唯一的两种可能性,但是以这种方式构建可以防止玩家在猜测高或低以外的情况下操纵游戏并赢得胜利。当你编写代码时,要记住这个重要的概念:要明确。一旦我们评估了玩家的猜测,我们就评估他们是否正确,并在他们赢了时返回 true,在他们输了时返回 false。
你可能注意到这个函数还不完整。例如,如果玩家猜高了,而数字实际上更低,我们没有处理这种情况。为此,我们需要一个额外的else if条件。我们故意在这里省略了它,这样我们就可以专注于这个块,而不必在屏幕上显示太多代码,以至于混淆了概念。它肯定会出现在我们游戏的最终代码中。
事实上,剧透警告!你将编写代码!现在,当我们结束第二天时,是时候完成你的作业了,在这里,你将有机会应用我们今天学到的一切。
作业
好了!让我们来看看今天的作业。在这个作业中,你将有机会应用我们今天在 Solidity 中学到的一些关于变量的概念:
- 你要做的第一件事是切换到第二天的 Git 分支,在那里我设置了一些你需要访问的场景。为此,你将在下载了本书代码的目录中打开一个终端,并输入以下命令:
git checkout -b dayTwo
-
你还需要确保 Ganache 正在运行;你昨天安装了它,今天需要它来验证你是否正确完成了练习。
-
如果你运行命令
truffle test,你会看到一堆错误,如下面的截图所示:
我在这个分支中创建了一些测试,如果你正确完成作业,所有这些错误都将消失,你将看到四个通过的测试。
-
要获得那些通过的测试,你需要使用编辑器打开
contracts文件夹中的Gaming.solSolidity 合约。 -
在那个合约中,你将创建一个名为
online的public布尔变量,并查看同一文件中的owner变量,如果你需要提示。 -
接下来,我们将创建一个名为
Player的结构体,记住我们用大写字母命名结构体。创建具有以下成员的结构体:一个名为playerAddress的以太坊地址类型,一个名为playerName的字符串,一个名为playerBalance的无符号整数,一个名为playerWins的无符号整数,以及一个名为playerLosses的无符号整数。 -
一旦完成了这一步,我们将创建一个名为
players的public映射类型,将无符号整数作为键映射到类型Player。
最终结果是,这将给我们一个名为 players 的键值对对象,其中键是无符号整数,值是表示我们游戏中一个玩家的 player 结构的实例。
如果你遇到困难,请查看今天章节中关于 Solidity 中的数据类型 部分寻求帮助。
- 一旦你正确完成了所有这些步骤,你可以运行
truffle test命令,并查看如下截图所示的通过测试:
摘要
恭喜!你已经完成了本书的第二天。我们学习了 Solidity 中的各种数据类型以及它们的使用方法。我们还学习了 Solidity 中的运算符。最后,我们学会了如何在我们的游戏应用程序中实现它们。
给自己一个鼓励,并休息一下,因为在我们的下一章中,我们将深入探讨 Solidity 函数,这些函数是智能合约业务逻辑的基石。我们将使用它们来实现我们这里的小型在线游戏的规则,同时,我们将学习 Solidity 函数的基本原理。
第三章:第三天 - 在您的智能合约中实现业务逻辑
好了,今天就讲函数。我们将更深入地了解如何创建它们,它们是如何工作的,以及我们如何使用它们来创建使我们的智能合约工作的业务逻辑。
本章将涵盖以下主题:
-
Solidity 函数
-
向函数添加代码
-
函数可见性
-
使用函数执行业务逻辑
-
了解修饰符
Solidity 函数
函数是合约内的可执行代码单元。要创建一个函数,您需要指定function关键字,给函数命名,指定完成其工作所需的任何参数,设置其可见性,添加所需的任何修饰符,声明它是一个视图还是纯函数,如果它可以接收以太币则标记为可付款,并定义任何将产生的返回变量;然后,我们有开放和闭合的花括号,在这些花括号之间是我们将添加函数代码的地方。并非每个函数都具有所有这些选项 - 这只是您进行操作时它们的顺序。例如,您不能将函数标记为纯函数且可付款,因为纯函数不允许修改状态 - 而接收以太币会这样做。在今天的课程中,随着我们使用更多的函数,这将变得更加清晰:
function myCoolFunction(unit aNumber) external myModifier view payable returns (bool) {
//Write cool code here
}
我们在函数中使用return关键字表示函数将向调用方返回一个值。例如,我们可以声明一个名为sum的无符号整数,其值来自addThis函数的结果,该函数接受两个参数:
unit sum = addThis(4, 2);
我们声明它是一个函数,命名为addThis,指定它以两个无符号整数(unit a, unit b)作为参数,将其标记为internal,然后指定它返回一个无符号整数。所以,这个returns关键字不会返回任何东西,它只是指定了函数签名,只有在函数体中使用return关键字并实际将值返回给调用者时才会出现,本例中的return a + b即为变量sum。函数签名中的returns关键字和返回本身两个部分都是必需的。我们的加法函数可能如下所示:
function addThis (unit a, unit b) internal returns (unit) {
return a + b;
}
我们也可以像这样指定返回,即我们说这个函数将返回c变量,然后在函数块内部,我们将c变量分配给a + b的总和并返回它:
function addThis (unit a, unit b) internal returns (unit c) {
c = a + b;
return c;
}
向函数添加代码
函数为合约创建应用逻辑;这意味着它们必须执行某些操作,并告诉它们该做什么意味着编写代码。你编写的代码放在函数的花括号之间。花括号内部,代码的执行一次从上到下一行一行地进行。唯一的例外是如果你有条件逻辑,比如这个if语句。如果isWinner不为真,那么花括号内的这些代码行将被跳过,程序执行将在if块之后的第一行代码处恢复。当函数到达最后一行代码或返回语句时,函数退出:
function winOrLose(unit display, bool guess, unit wager) external payable returns (bool) {
/* Use true for a higher guess, false for a lower guess */
require(online == true);
require(msg.sender.balance > msg.value, "Insufficient funds");
unit mysteryNumber_ = mysteryNumber();
bool isWinner = determineWinner(mysteryNumber_, display, guess);
if (isWinner == true) {
/* Player won */
msg.sender.transfer(wager * 2);
return true;
} else if (isWinner == false) {
/* Player lost */
return false;
}
}
变量作用域
这带我们到一个非常有趣的点:叫做变量作用域的东西。看一下这个。我们有一个叫做saySomething的变量,值为"hello"。在这个doStuff函数内部,我们有另一个叫做saySomething的变量,值为"goodbye"。那么,当我们在这个函数内部时,你觉得saySomething的值是什么?如果你说是 goodbye,你是对的,在函数内部的saySomething变量被称为遮蔽了函数外部相同变量名的变量,并且你可以看到这是个坏事。当这个函数退出时,saySomething的值现在恢复到了原始值"hello";这是因为在这个函数内声明的变量只存在于函数内部。一旦函数退出,这些变量就消失了。在doStuff函数之外,甚至没有一个叫做saySomethingElse的东西可以被访问。在构建函数时,记住一个重要的点:函数内部需要哪些变量,以及函数退出后需要哪些数据:
string saySomething = "hello";
function doStuff() internal {
string saySomething = "goodbye";
string saySomething = "I have nothing else to say";
}
//saySomething is "hello"
//saySomethingElse doesn't exist
事件
还有一种特殊类型的函数叫做事件;它是我们使用以太坊虚拟机日志设施的一种方式。这对我们很重要,因为,如果你记得的话,当我们的玩家在游戏中采取行动时,它并不是实时的;它会发送到以太坊网络,在那里等待矿工确认,然后才会被写入区块链。当发生这种情况时,与该交易相关的任何事件都会被触发。我们可以利用这些来调用 JavaScript 回调函数并更新玩家的 UI。事件是合约的可继承成员,这意味着任何写入合约的事件都可以被从中继承的任何合约使用。最后,事件参数本身存储在交易日志中;这是区块链的一个特殊数据结构,我们可以看到其中哪些事件作为交易的一部分触发了。
让我们来看一个真实的事件,以更好地理解我的意思。在我们的合约内部,我们使用event关键字定义一个事件,给它一个名字——注意这里的名字以大写字母开头:PlayerWon——然后为我们想要索引的数据点添加参数:
event PlayerWon(address player, unit amount);
在我们的winOrLose函数中,一旦我们确定玩家赢了,我们就可以省略玩家获胜事件,将玩家的地址和他们赢得的金额写入事务日志。在我们应用的 JavaScript 中,我们可以监听此事件,当我们收到时让玩家知道这个好消息:
function winOrLose(unit display, bool guess, unit wager) external payable returns (bool) {
/* Use true for a higher guess, false for a lower guess */
require(online == true);
require(msg.sender.balance > msg.value, "Insufficient funds");
unit mysteryNumber_ = mysteryNumber();
bool isWinner = determineWinner(mysteryNumber_, display, guess);
if (isWinner == true) {
/* Player won */
emit PlayerWon(msg.sender, msg.value);
msg.sender.transfer(wager * 2);
return true;
} else if (isWinner == false) {
/* Player lost */
return false;
}
}
构造函数
我想介绍给你的另一个特殊函数是构造函数。当合约被创建时调用它,它只能执行一次。一旦合约被创建,它就永远不能被再次调用。它通常用于设置合约使用的变量的初始状态。你会看到一些例子,每个合约只允许一个构造函数,因此不支持重载。
在这里,我们有我们的游戏合约的一部分,在合约内部,我们声明了两个变量,owner和online:
contract Gaming {
address owner;
bool online;
}
接下来,我们声明我们的构造函数,这样当这个合约被创建时,我们将把owner变量设置为部署此合约的人的地址。同时,我们将把online变量设置为true,表示我们的游戏正在营业。这两个变量对我们很重要,使用构造函数在合约创建后尽快设置它们的值使我们能够尽快锁定它们的值。你将看到的另一种变体是一个与合约同名的函数。所以,不是这个constructor函数,你会看到一个名为Gaming的函数。它做同样的事情,但使用contract名称作为函数名来创建构造函数已经被弃用,不应再使用。不过,很多代码仍然在使用,所以我想让你知道,这样你在看到它时就能识别出来:
constructor() public {
owner = msg.sender;
online = true;
}
Fallback 函数
最后一个我们要讨论的特殊函数是fallback 函数。合约可以有一个未命名函数,即我们的 fallback 函数。它不能有任何参数,也不能返回任何东西,如果合约被调用而合约中没有与调用匹配的函数,则执行它。当合约接收到以太币但没有数据时也会执行它。你可能会觉得这听起来在这一点上没什么用,但让我给你举个例子,说明为什么你可能需要包含一个。
想象一下我们的游戏合约。假设有人向这个合约发送了以太币,但没有附带任何数据。我们的合约不知道该怎么办,所以就撤销了交易,以太币退还给调用者。但如果这是另一个应用程序,而且在该应用程序中,您需要能够通过直接从外部账户进行转账(例如直接从某人的以太坊钱包)接受以太币怎么办呢?唯一的方法就是使用降级函数。创建一个降级函数,并将其标记为payable,可以使您的合约接收以太币的直接转账。然而,这样做的缺点是您需要考虑如何处理这些以太币:主要是,您必须有一种方法将其取出。例如,如果有人错误地将以太币发送到您的合约,而他们本来是想把它发送到另一个地址,如果您的降级函数被标记为payable,您将收到这笔以太币,如果没有一种允许您提取它的功能,它就会永远被困在那里:
contract Gaming {
function() public payable {
}
}
好的,这就是函数的基础知识。在下一节中,我们将介绍函数的能见度。让我们深入探讨能见度如何影响函数的功能。
函数能见度
在定义函数时,visibility关键字是一个必需的元素。通过指定能见度,我们可以控制谁可以调用它,谁可以继承它。我们还可以选择性地定义函数是否应读取状态变量,或者根本不查看它们。
能见度简要说明
我们昨天定义了能见度修饰符,今天我们将使用这个表格来巩固它们之间的区别:
| 外部调用 | 内部调用 | 可继承 | 自动获取器 | |
|---|---|---|---|---|
| 外部 | 是 | 否 | 是 | 否 |
| 公共 | 是 | 是 | 是 | 是 |
| 内部 | 否 | 是 | 是 | 否 |
| 私有 | 否 | 是 | 否 | 否 |
外部函数可以从外部调用。它们也可以使用this关键字在内部调用,但因为这有点绕过的方式,我没有选择内部调用;它们是可继承的,所以您可以在继承的合约中访问所有外部函数,并且任何从您继承的合约都将包含在您的合约中定义的外部函数。公共函数既可以从外部调用,也可以从内部调用。像外部函数一样,它们是可继承的,而且对于定义为公共的变量,您会得到一个免费的获取器函数。内部函数只能从内部调用,并且它们是可继承的。最后,私有函数只能从内部调用,但请记住,这并不意味着数据是私有的,这只是意味着它不能被调用或继承;在区块链上,它仍然是对观察者可见的。
让我们看看一个外部函数。这是我们游戏中的 winOrLose 函数:它由我们的 React 应用程序调用,以确定玩家是否赢得了本轮游戏。它被标记为外部,因为我们将从我们的 UI 中调用它,并且它被标记为可支付,因为玩家将在此函数调用中包含他们的赌注。它执行此逻辑以确定玩家赢还是输,然后如果他们赢则返回布尔值 true,如果他们输则返回布尔值 false。因此,此函数也可以标记为公共并保留相同的功能。此外,我们将能够从合约内部调用该函数,而无需使用 this 关键字。因此,要从合约内部调用该函数,我们只需调用 winOrLose,但是由于它被标记为外部,如果我们想从合约内部调用它,我们将不得不调用 this.winOrLose。由于此函数没有理由从合约内部调用,我将可见性设置为 external:
function winOrLose(unit display, bool guess, unit wager) public payable returns (bool) {
/* Use true for a higher guess, false for a lower guess */
require(online == true);
require(msg.sender.balance > msg.value, "Insufficient funds");
unit mysteryNumber_ = mysteryNumber();
bool isWinner = determineWinner(mysteryNumber_, display, guess);
if (isWinner == true) {
/* Player won */
emit PlayerWon(msg.sender, msg.value);
msg.sender.transfer(wager * 2);
return true;
} else if (isWinner == false) {
/* Player lost */
return false;
}
}
我们的 mysteryNumber 函数是私有的。它由我们的 winOrLose 函数调用,用于生成玩家押注的神秘数字。它仅在合约内部被 winOrLose 函数调用,因此不需要外部或公共可见性。另外,我不希望继承的合约能够访问这个函数,因此将其标记为 private。这里有一个新关键字 view。将此函数标记为 view 表示该函数不会尝试修改、创建或更新状态:
function mysteryNumber() private view returns (unit) {
unit randomNumber = unit(blockhash(block.number-1))%10 + 1;
return randomNumber;
}
视图函数
在以下列表中,我们可以看到被视为修改状态含义的事物,并且如果您的函数是视图函数,则不允许这些事物:
-
写入状态变量
-
发出事件
-
创建其他合约
-
使用自毁
-
通过调用发送以太币
-
调用任何未标记为视图或纯的其他函数
-
使用低级别调用
-
使用包含两个操作码的内联汇编
低级别调用和内联汇编操作码超出了本课程的范围,因此我们不会在此处涵盖它们。
最后,我们有一个内部函数,我们的 determineWinner 函数。此函数评估本轮的条件,即神秘数字、显示给玩家的数字以及他们猜测的高低。它的返回值是一个布尔值,当调用 winOrLose 函数时返回给它。除了 winOrLose 函数以外,没有理由让其他任何函数调用此函数,尤其是外部调用,因此将其定义为外部或公共是不可能的,而且我不介意继承我的合约的人使用此函数,因此我将其标记为 internal。它还被标记为 pure:
function determineWinner(unit number, unit display, bool guess)
internal pure returns (bool) {
if (guess == true} {
if (number > display) {
return true;
}
} else if (guess == false) {
if (number > display) {
return false;
}
}
}
纯函数
纯函数非常类似于视图函数,因为它承诺不修改状态,但它更进一步承诺甚至不读取状态。
如果将函数标记为纯函数,则不能执行以下操作:
-
读取状态变量
-
访问账户余额
-
访问任何块、tx 或消息的成员
-
调用任何未标记为纯函数的函数
-
使用包含特定操作码的内联汇编
在下一节中,我们将看到如何将所有内容结合起来创建我们应用程序的业务逻辑。
使用函数执行业务逻辑
因此,我们对函数如何工作以及如何创建它们有了一些信心,现在让我们将这些放入实际情境中,看看我们如何使用它们来实现智能合约的业务逻辑。回想一下我们的应用程序,我们将在 UI 中向玩家显示一个随机数,他们将对他们认为的神秘数字是更高还是更低下注。显示的数字和他们的赌注被发送到我们的智能合约,我们在其中使用 Solidity 代码执行游戏规则:
让我们再次看看我们的winOrLose函数。自从上次看到它以来它有些变化。当玩家准备玩一轮时,他们会下注,应用程序将调用这个函数。当它这样做时,将发送给玩家显示的数字,玩家的猜测,以及他们的赌注作为特殊的msg.value变量附加到此交易。由于这是从我们的 UI 调用的,必须标记为 external,而且由于它以以太形式收到他们的赌注,必须标记为 payable。
我们定义返回两个对象:一个布尔值和一个无符号整数。布尔值表示他们赢了还是输了,无符号整数将返回他们下注对手的mysteryNumber_。这将允许我们在告诉玩家他们赢了或输了时显示mysteryNumber_:
function winOrLose(unit display, bool guess, unit wager) external payable returns (bool, unit) {
/* Use true for a higher guess, false for a lower guess */
require(online == true);
require(msg.sender.balance > msg.value, "Insufficient funds");
unit mysteryNumber_ = mysteryNumber();
bool isWinner = determineWinner(mysteryNumber_, display, guess);
if (isWinner == true) {
/* Player won */
emit PlayerWon(msg.sender, msg.value);
msg.sender.transfer(msg.value * 2);
return (true, mysteryNumber_);
} else if (isWinner == false) {
/* Player lost */
return (false, mysteryNumber_);
}
}
在我们的函数内部,我们有两个 require 语句。我会跳过这些,因为我们将在下一节详细介绍它们。
我们有一个无符号整数从名为mysteryNumber的函数获得其值;这意味着我们的mysteryNumber函数必须返回一个无符号整数 - 在定义函数时我们声明这一点 - 而且由于我们的合约外部不需要访问这个函数,我们将其标记为私有。我们的函数也不对状态进行任何修改。它只是返回一个数字,因此我们也可以将其标记为view函数:
function mysteryNumber() private view returns (unit) {
unit randomNumber = unit(blockhash(block.number-1))%10 + 1;
return randomNumber;
}
现在我们已经拥有确定玩家本轮是否赢得或输掉所需的所有数据。因此,我们声明一个名为isWinner的布尔值,通过将所有所需信息发送到determineWinner函数来获取其值,该函数如下所示:
function determineWinner(unit number, unit display, bool guess)
internal pure returns (bool) {
if (guess == true} {
if (number > display) {
return true;
}
} else if (guess == false) {
if (number > display) {
return false;
}
}
}
它有参数接受一个神秘数字,显示的数字和玩家的猜测。再次强调,合约之外没有任何原因调用这个函数,因此它被标记为internal,并且因为它不读取或修改状态,我们将其标记为pure。接下来,我们遍历所有不同的获胜和失败的组合,一旦我们有足够的信息来确定这一轮是赢还是输,我们使用return退出函数,然后true表示赢,false表示输。当函数返回时,它留下了我们在这里,我们可以评估isWinner变量,然后根据赢或输采取适当的行动,这包括发出事件表示该轮的状态,将赢得的任何钱返还给玩家,并返回玩家看到的结果。让我们用视觉来看这个来帮助巩固这些关系。我们的应用程序调用winOrLose函数,然后从mysteryNumber_函数获得一个新的神秘数字,然后将数据发送到determineWinner函数,以查看玩家是否赢了或输了,然后采取适当的行动,最后通知玩家结果。接下来,我们将讨论修饰符。它们是强大的函数,允许您在何时执行函数时放置约束。
理解修饰符
好吧,修饰符是函数中的重要组成部分:它们允许我们快速轻松地强制执行规则和限制。修饰符用于以声明方式改变函数行为。这意味着我们获得了一种重复简洁的方式来强制执行规则。修饰符通常用于在执行函数之前检查条件;这在区块链开发中非常重要。我们支付矿工以气体形式执行我们的函数,所以如果函数将违反约束,最好尽快失败。
还有可继承的属性,这意味着在合同中定义的修饰符也可在任何从中派生的合同中使用。修饰符的一个重要组成部分是一个方便的函数称为require。因此,在我们深入了解修饰符之前,让我们首先了解require函数。
require 函数
require是我们在 Solidity 中可以使用的方便函数来处理错误。它是一个状态回滚异常,这意味着在异常发生之前对状态的任何更改都将自动回滚;这是一个很棒的功能,因为它确保我们的所有交易完成,或者都没有完成,从而防止我们不得不猜测哪些部分完成了的情况发生。我们通常使用require来确保满足有效条件,例如输入或合同状态变量。并且,可选地,当require语句失败时,我们可以包含一个返回给调用者的字符串消息,以告知调用者发生了什么。对我来说,虽然这个流消息是可选的,但它是一个要求;在这个特性存在之前,我甚至都无法猜测我花了多少小时去排除一个函数失败的原因,只是后来才了解到这是由于我在函数中放置的要求。要定义一个require,我们使用require关键字,后跟括号中的条件。
如果您还记得我们的合同定义,我们设置了一个名为online的变量,然后在构造函数中将其值设置为true,因此此语句检查online变量是否仍为true。如果不是,则程序执行停在此处,这是我们如何有效地使我们的游戏离线的方法:
require(online == true);
顺序在require语句中也起着重要作用。通常,您希望将它们放在函数的顶部,以便如果条件不满足,函数能够尽快失败。在这里,您可以看到两个require语句,一个用于验证游戏是否在线,第二个用于确保玩家有足够的资金来支付他们的赌注,就是这样。require语句非常简单直接。
我们可以使用require语句来构建修饰符。修饰符是使用modifier关键字创建的,给它一个名称,然后在括号中加上可选的参数。这与函数非常相似。在修饰符中,我们添加条件。在这里,我们要求消息发送者与存储在owner变量中的地址相同。这个修饰符的效果是限制使用它的任何函数只能由合同的所有者执行。修饰符的最后一部分是这个下划线,它的位置非常重要,因为它决定了调用此修饰符的代码何时应该执行:
modifier isOwner() {
require(msg.sender == owner);
_;
}
让我举个例子来进一步解释。这个函数允许调用者提取合同从游戏中赚取的资金。我们使用isOwner修饰符确保只有合同的所有者可以调用它:
function withdrawFunds() public isOwner {
msg.sender.transfer(address(this).balance);
}
现在让我们看看下划线的作用。下划线有两个有效的位置:
它可以出现在修饰符的开头:
modifier isOwner() {
_;
require(msg.sender == owner);
}
或者它可以出现在修饰符的末尾:
modifier isOwner() {
require(msg.sender == owner);
_;
}
区别在于函数中的代码何时运行。如果你将下划线放在修饰器的开头,当提取资金函数执行时,它会运行其函数中的所有代码,然后调用修饰器。如果下划线在结尾,修饰器首先执行其逻辑,然后函数执行其逻辑。
另一种看待这个问题的方式是下划线代表函数本身。在这个例子中,如果我们先放置下划线,函数首先执行并从合同中提取所有资金,然后修饰器检查是否是所有者这样做了。将下划线放在最后,修饰器首先检查调用是否来自所有者,然后在修饰器通过时执行函数。这对于函数的期望结果有很大的区别,所以在使用修饰器时要特别注意。
在下一节中,我们将看一些使用修饰器的实际例子,以更好地理解它们的能力。
在本节中,我们将看一些修饰器的不同用例。我非常喜欢修饰器,因为它们允许对执行函数进行很多控制,但它们仍然非常易读。让我们看看我们的第一个例子,一个名为onlyBy的修饰器。它将只允许调用它的函数在被所需地址调用时执行:
modifier onlyBy(address _account) {
if (msg.sender != _account) {
revert();
}
_;
}
我们可以在changeOwner函数中看到它的运作方式。当调用changeOwner时,onlyBy修饰器用于确保只有当前所有者才能为合同指定一个newOwner变量:
function changeOwner(address _newOwner) onlyBy(owner) {
owner = _newOwner;
}
这里有另一个例子,使用时间限制。如果当前时间小于作为参数传递的时间,修饰器将抛出异常结束程序执行。将其用于实践,我们可以看到disown函数使用onlyAfter修饰器确保只能在创建时间之后的 6 周内调用它。这是使用多个修饰器确保满足多个条件的绝佳示例。要指定多个修饰器,你只需在函数声明中依次列出它们。你可以像这样每行一个,或者在同一行用空格分隔每个修饰器:
modifier onlyAfter(unit _time) {
if (now < _time) revert();
_;
}
function disown()
onlyBy(owner)
onlyAfter(creationTime + 6 weeks)
{
delete owner;
}
让我们看一个更多的例子。这个修饰器要求在函数调用时支付一定的费用,所以现在我们可以收取更换所有者的费用。只要这个交易中包含所需的 200 以太币,交易就会执行。但这里有一个警告:如果调用者在函数正常退出时发送的以太币超过 200,多余的以太币将返回给调用者。不过,如果我们明确使用return,多余的以太币就不会被返回:
modifier costs(unit _amount) {
if (msg.value < _amount) {
revert;
_;
}
if (msg.value > _amount) {
msg.sender.send(amount - msg.value);
}
}
function forceOwnerChange(address _newOwner) costs(200 ether) {
owner = _newOwner;
if(unit(owner) & 0 == 1) {
return;
}
}
好了,我们已经讨论了函数,虽然我们还可以讨论更多,但这代表了你开始构建智能合约所需的基础知识。在下一节中,我将和你一起讨论今天的家庭作业。
作业
今天的作业与昨天的格式类似。在其中,你将写一些代码来使一些测试通过,在书的存储库中。这样做将允许你把今天获得的关于函数的知识应用到实践中。你首先要做的事情是:
-
为今天的作业搭建好你的环境。
-
打开终端并切换到包含你的应用程序代码的目录。
-
输入
git stash命令。这将把你对应用程序代码所做的任何更改都存储起来,以防你所做的工作干扰我为你设置的情景。 -
将你的工作存储起来后,输入
git checkout -b dayThree来获取今天作业的代码,你还需要确保 Ganache 在运行,所以在开始作业前确保你已经启动它。 -
运行
truffle test,你应该会看到类似于以下截图的内容:
要让所有这些测试通过,你需要做以下任务 - 在determineWinner()函数中,我们缺少一些逻辑。如果玩家猜测神秘数字将低于他们屏幕上显示的数字,他们就应该赢,但这个函数目前不会执行此操作,所以我需要你编写代码来实现这一点。
-
接下来,我们将创建两个新事件:一个是
playerWon,另一个是playerLost。事件应该接受玩家的地址、他们押注的金额、他们所押注的神秘数字以及向玩家显示的数字的参数。 -
现在你有了你的事件,每当玩家赢了或输了,我们就会省略出正确的事件。
最后,还有一个名为players的映射,将玩家的地址映射到一个Player结构中,其中我们可以存储关于他们的胜利和失败的信息。
- 我们将从该映射中获取正确玩家的
Player结构,并且如果他们赢了,则增加赢的计数器,如果他们输了,则增加输的计数器。
当你完成所有工作后,你应该会拥有这六个通过的测试,这些测试为第四章 第四天—创建测试做好了铺垫。
这里有个很酷的地方,就是在过去的几天里,为了确保你的代码正常运行,你一直在依赖测试,所以你可能会对它们的重要性以及它们如何能够帮助你编写更少 bug 的更好的代码有所认识。
摘要
在本章中,我们学习了有关函数的所有知识。我们还看了函数是如何创建的,它们是如何工作的,以及它们如何用于使智能合约生效。我们首先学习了 Solidity 中的函数,然后我们看到如何向这些函数中添加代码。然后,我们学习了如何修改函数的可见性。然后,我们看到了如何使用这些函数来执行业务逻辑。最后,我们学习了如何在函数中使用修饰符。
在下一章中,我们将学习如何创建测试,以帮助调试代码,使游戏功能无故障!
第四章:第四天 - 创建测试
你一直依赖测试来确保你的代码正常运行,所以你可能很欣赏它们的重要性,以及它们如何帮助你写出更好、更少错误的代码。在本章中,我们将看看如何创建适当的测试,以帮助我们的游戏顺利运行,没有任何问题。本章将涵盖以下主题:
-
理解单元测试和集成测试
-
不同应用程序的测试策略
-
在 Solidity 中创建单元测试
-
同一个要测试的函数有多个测试
-
在 JavaScript 中创建集成测试
-
运行测试套件
理解单元测试和集成测试
今天,我们将讨论有关测试的所有内容。在过去的几天里,你实际上体验到了测试的一些好处。我编写了一个测试,检查了我们智能合约中的特定行为,它失败了,然后你编写了一段代码,当测试通过时,你知道它提供了预期的结果。
为什么要写测试?
现在,想象一下规模更大的情况。你正在作为一个庞大的区块链开发团队的一部分编写代码,向世界交付最新的去中心化应用程序。作为团队的一部分,你如何知道你团队中其他开发人员编写的代码是否按照预期执行?当出现错误时,你如何确保你的代码不是导致错误的代码?或者这样想:如果有人更新了你的代码,你如何确保它仍然执行其预期的功能?请记住,当我们处理区块链应用程序时,我们正在处理人们的金钱,所以所有这些都是重要的问题,而所有这些问题的答案都是一样的:测试。
单元测试
今天我们将讨论两种不同类型的测试,所以我想先向你介绍它们,以便你了解每种测试的作用。单元测试是由开发人员编写的,用于测试相对较小的代码片段,以确保其按预期执行。想象一个将两个数字相加的函数:
function sum(unit a, unit b) returns
(unit) {
return a + b;
}
一个测试可能看起来像这样:
function testAddCorrect() public {
unit expected = 4;
unit result = myContact.sum(1, 4);
Assert.equal(expected, result);
}
它给函数两个数字,然后测试正确的结果。
以下是单元测试的一些常见特征:
-
范围较窄
-
易于阅读和编写
-
无依赖关系
这意味着测试本身完全自包含,不需要数据库、网络连接、手动干预或除了测试和被测试代码之外的任何其他东西。在 Solidity 中,我们实际上没有真正的单元测试,因为即使是基本的测试也需要 Ganache 或本地区块链网络才能正常运行。尽管如此,我们仍然以这种方式编写测试来确保我们的代码的正确运行。让我们来看看我已经编写的一个单元测试,以便更好地了解一个真正运行的测试是什么样子的:
在上述屏幕截图中,左侧是我们过去几天一直在使用的determineWinner函数。右侧是其测试。我们使用function关键字,为测试赋予以小写test单词开头的名称,然后在内部声明一个名为expected的变量,这是当函数正常工作时我们期望的结果,然后我们有另一个名为result的变量,这是函数的实际结果。然后最后一部分是我们使用一个断言或创建一个assert语句,比较预期结果或预期答案和实际结果,以确保它们相等,如果不相等,则显示消息,以便运行测试的人知道出了什么问题。
集成测试
另一方面,集成测试用于证明系统的不同部分如何协同工作。为了更好地说明集成测试,让我向您展示我为作业编写的测试代码:
it('Should record player losses', async() => {
const gameRound = await gaming.winOrLose(10, true, {
from: player1,
value: web3.utils.toWei('1', 'ether')
})
const playerStats = await gaming.players(player1)
assert.equal(playerStats[1].toNumber(), 1, 'The player should have 1 toss')
})
此测试以 JavaScript 编写。我们首先调用winOrLose函数,模拟从我们的 React 应用程序中使用所需参数进行调用。我们使用await关键字等待该调用写入区块链。此测试正在检查是否正确记录了玩家的统计信息。当他的代码完成时,玩家应该有一次记录的失败。因此,现在我们调用区块链并执行函数以获取玩家的统计信息,并验证记录的损失值是否等于一。
因此,在这个测试中,我们进行了两次区块链访问,并且我们依赖区块链网络正确运行以使测试通过。这是一个集成测试。以下是集成测试的一些常见特征。
它们表明了系统的不同部分是如何一起工作的。通常,它们涵盖整个应用程序,您可能还会听到它们被称为端到端测试。它们需要比单元测试更多的工作量来组合,而且它们还需要外部资源,例如数据库、硬件或在我们的情况下是区块链网络,并且它们更接近我们的用户预期执行的操作。
在下一节中,您将学习使用 Solidity 和 JavaScript 创建单元测试和集成测试的基础知识。有了这些技能,您将能够创建测试来确保您的合约确实执行了它应该执行的操作。
各种应用程序的测试策略
您知道应该编写测试,但当您面对空白屏幕时,有时很难知道从哪里开始。因此,在本章中,我们将探讨一些策略,帮助制定测试内容以及测试方法。我们将讨论四个不同的事项:
-
测试成功情况
-
测试失败情况
-
使用 Solidity 进行测试
-
使用 JavaScript 进行测试
在进行测试时,有很多不同的方法,但我们不要陷入信息的海洋中,让我们保持简单。任何测试总比没有测试好,所以让我们专注于为我们的合约编写一些测试,稍后我们可以随时在学到更多知识的情况下改进方法和测试。
测试成功
最容易入手的地方是测试成功,我指的是编写测试来确保你的组件在提供正确输入时执行其预期功能。考虑以下代码片段:
function sum(uint a, uint b) returns
(uint) {
return a + b;
}
这里,我们有一个函数,它将两个数字相加。你如何检查操作是否完全按预期工作?让我们编写一个测试来确保如果它被提供两个数字,它会产生正确的答案。为此,我们将创建一个名为testAddCorrect()的函数,考虑一个预期值和合约的结果值,并交叉检查它们以确保函数产生正确的答案。以下代码片段进一步说明了这个测试的工作原理:
function testAddCorrect() public {
uint expected = 4;
uint result = myContract.sum(1, 4);
Assert.equal(expected, result);
}
编写这些测试应该成为你开发工作流程的自然一部分。事实上,有一种称为测试驱动开发的策略,你首先编写测试,看到它失败,然后编写代码使该测试通过。一旦它通过了,你再编写另一个失败的测试,然后跟着编写使其通过的代码。通过编写每一个失败的测试,你确保代码做了它应该做的事情,然后通过使每一个测试通过,你专注于编写使你的应用程序工作所需的最少代码量。这是我最经常使用的策略,效果很好,但测试失败也是有意义的。
测试失败
如果你的函数在提供无效输入时不执行正确操作呢?考虑以下 JavaScript 代码片段:
function sum(a, b) {
return a + b;
}
这里,如果你给函数提供两个数字,它会把它们相加,但是,如果我们给它两个字符串呢?
我们可能要求它向调用者返回一个错误,说明只有数字是有效输入,但实际上,它会返回两个输入字符串连接在一起的结果。这就是我所说的测试失败。你需要一些额外的测试来覆盖当你的组件被提供无效输入时会发生什么。
大多数时候,错误和安全漏洞都来自于以一种未曾预期的方式使用组件。
使用 Solidity 进行测试
我们的第三个主题是使用 Solidity 进行测试。如果这本书是你对编程世界的第一次介绍,这可能是你最舒适的地方。
在 Solidity 中编写测试几乎与编写合约相同,因为它是相同的编程语言,事实上,您的测试只是由 Truffle 使用的另一个 Solidity 合约,用于执行您的测试。使用 Solidity 编写的测试类似于单元测试。每个测试套件或测试合约都在干净的环境中运行。这意味着,在运行每个测试套件之前,合约将重新部署到测试网络,这样您就知道您是从已知状态开始的。
由于每次运行测试时都会进行部署,因此对本地网络进行测试是有意义的,这是我们使用 Ganache 的主要原因之一。如果我们必须部署到实时网络,然后等待矿工挖掘每个交易,那么获得我们的测试结果将需要大量时间,如果我们对自己诚实,我们不会像应该那样经常运行测试。
Solidity 测试使用 Chai Assertion 库,用于编写逻辑以通过或失败我们的测试。您将在即将到来的部分在 Solidity 中创建单元测试中看到如何做到这一点。但从测试的角度来看,Solidity 的功能相当有限,它非常适用于测试单个函数,并确保函数返回正确的响应,并测试异常,但对于测试合约的整体行为来说效果不佳。为此,我们将使用 JavaScript 测试。
使用 JavaScript 进行测试
JavaScript 测试为我们提供了一种完全测试合约行为的方法,正如客户端将看到的那样。我们可以访问测试帐户,这要归功于注入到测试运行器中的web3提供程序,您将在第六章中了解到web3是什么,第六天:使用钱包。
Truffle 使用 Mocha 测试框架和 Chai 断言来进行 JavaScript 测试。如果您之前已经写过 JavaScript,您可能对 Mocha 比较熟悉;这里唯一的区别是 Truffle 使用contract函数而不是 Mocha 的described函数。这使得前面提到的干净环境特性成为可能,以确保我们每个测试套件都从一个新的合约开始。
使用 Chai Assertion 库是一个不错的选择,因为它是我们 Solidity 测试中使用的相同断言库,这使得我们作为区块链开发者的生活稍微容易一些。现在,让我们深入探讨将合约部署到测试网络中。
在 Solidity 中创建单元测试
我们的第一个测试将使用 Solidity 编写。从 Solidity 开始可能会通过使用您已经了解的语言提供对这个陌生概念的熟悉度。
Solidity 测试约定
Solidity 中有一些关于 Solidity 测试的约定:比如文件必须具有.sol扩展名,合约名称必须以大写字母T开头的单词Test开头,函数必须以小写字母t开头,而test应该放在应用程序代码的test文件夹中。
要编写测试,我们必须首先进行一些清理工作。我们定义我们的合同然后导入truffle/Assert.sol库:我们将导入truffle/DeployedAddresses.sol库。如果你习惯编写 node 应用程序,这两者可能有点奇怪,因为通常这意味着库被导入并且是在node modules文件夹中找到的文件,但是你不会在那里找到它,因为它是由 Truffle 直接导入的。我们还需要导入我们将要测试的合同。对于我们的应用程序,这是我们的游戏合同。这真的是您想要停止导入东西的地方,因为我们希望保持我们的测试简约:
pragma solidity 0.5.0;
import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../contracts/Gaming.sol";
在此之后导入其他库只会引入复杂性和错误的潜在可能性,因此就像我们之前编写的合同一样,我们将定义一个新的合同。因为这是一个测试合同,我们将以 test 开头的名字开始。
现在我们可以做的一个很酷的事情是创建一个名为initialBalance的变量并为其分配一些以太。当我们的合同部署时,它将被资助指定金额,使其可供在我们的合同中使用。这很酷,对吧?然后我们创建一个名为gaming的变量,这是我们的大写字母 G 的Gaming合同的实例。看一下下面的代码:
contract TestGaming {
uint public initialBalance = 10 ether;
Gaming gaming;
}
我们可以定义一个名为beforeAll()的函数,注意它并不以单词test开头,尽管我刚才说函数必须以它开头。这是因为这是一个特殊的函数:它将在我们的测试套件中的任何测试之前运行。在其中,我们将获取部署的合同实例,并在我们的其余测试中使用它:
function beforeAll() public {
gaming = Gaming(DeployedAddresses.Gaming());
}
我们可以使用的其他特殊函数包括beforeEach,afterAll和afterEach。那么,现在,让我们开始编写一些测试。我们的第一个测试将测试我们的determineWinner函数。它被winOrLose函数调用,但我们将单独测试它以确保它正好完成其预期的工作,这样我们就知道winOrLose函数可以依赖它返回正确的响应。我们首先定义我们的函数,并以以test开头的单词给出名称,然后在我们的函数内部,我们将声明一个名为expected的变量。这是我们期望作为我们测试的结果找到的结果。结果通过调用determineWinner函数进行填充,并传递一些参数给它以执行所需功能:
function testPlayerWonGuessHigher() public {
bool expected = true;
bool result = gaming.determineWinner(5, 4, true);
}
现在,我们要介绍的是我们的 Chai 断言库。我们调用assert库,然后调用equal函数,传递我们的expected值,结果和当测试失败时要显示的消息。现在,这个消息非常重要:它将是您或任何其他开发人员在此测试失败时得到的唯一线索。确保它清晰而具体。这也是进行代码审查的好地方,因为让其他人对这些消息进行评审可以帮助使它们清晰易懂:
Assert.equal(expected, result, "The player should have won");
相同测试函数的多个测试
再看几个例子,除了我们刚写的测试之外,我们还有另外三个例子。在每个测试中,存在不同变量参数的不同变体,我们可以将它们提供给determineWinner函数,这使我们能够检查我们的determineWinner函数可能遇到的每种情况。这就是为什么 Solidity 的测试器受欢迎的原因:它们很容易编写,很容易阅读,并且它们使用与我们的合同完全相同的编程语言。不幸的是,如果您尝试做的事情超出了这些,乐趣和兴奋很快就会消失:
要进入测试的下一个级别,我们将使用 JavaScript。它具有许多 Solidity 不可用的功能。访问这些功能将赋予我们使用 JavaScript 进行端到端测试的能力和灵活性,这很方便,因为我们也将使用 JavaScript 编写我们的 UI。
在 JavaScript 中创建集成测试
在 JavaScript 中编写测试时,我们不仅可以模拟合同调用,就像我们在 Truffle 测试中所做的那样,还可以选择不同的帐户,检查余额等等。让我们深入了解一些测试,你会对我所说的有更好的理解。我们将从创建一个新的测试文件开始。对于我们的 Truffle 测试,文件名以大写的 T 字母开头,并以.sol扩展名结尾。
我们的 JavaScript 测试以被测试的合同名称开头,后跟一个以大写 T 开头的单词test,并以.js扩展名结尾。它仍然放在与我们的 Solidity 测试相同的test文件夹中,这意味着无论这些测试使用哪种语言编写,都只需查看一个地方。
在我们的文件中,我们将创建一个与要测试的合同同名的常量,并使用需要该合同内容的 artifacts。从这里开始,我们的测试看起来很像 Mocha 测试,如果您熟悉的话;不过,我们将使用 Truffle 关键字contract而不是使用笔:
const Gaming = artifacts.require('./Gaming.sol')
这启用了 Truffle 的清洁房间功能,这意味着每次将此文件作为测试运行时,Truffle 框架都会将合同的新实例部署到网络上,确保我们从已知状态开始:
contract('Gaming', async (accounts) => {
现在我要声明一些变量。gaming 变量将代表部署到网络上的我们合约的版本,然后我将创建两个常量,owner 和 player 1。这两个常量都从一个叫做 accounts 的数组中获取。accounts 变量是通过 Truffle 框架免费提供的,数组中的项目代表了应用程序启动时 Ganache 为我们创建的 accounts,所以这个叫做 owner 的变量被设置为帐户数组中的第一项,也就是你在 Ganache 中查看时看到的第一个帐户,而 player 1 变量则是第二个列出的帐户。这相当强大,因为访问这些帐户允许我们以这些帐户的身份采取行动,然后与 Ganache 回顾以确保事情按照我们的期望发生。它允许我们测试一些只适用于特定帐户而不适用于其他帐户的功能,比如我们的 is owner 函数:
let gaming
const owner = accounts[0]
const player1 = accounts[1]
现在我们将有一个 before 函数,和 Truffle 中的对应函数一样,这个函数将在这个文件中编写的任何测试之前运行。如果你有其他测试文件,它们将被视为单独的运行,这里的 before 函数不会应用:
before(async () => {
gaming = await Gaming.deployed()
})
然后我们将使用 async 和 await 来从以太坊网络中获取我们合约的部署版本。所以,让我给你解释一下 async 和 await,以防你之前没有见过。假设我们有一行 JavaScript 代码:它将使用我们导入的代表合约的 artifact 来获取部署在以太坊网络上的实际合约实例,但 JavaScript 的工作方式是一旦我们调用了这个函数,它就认为已经完成并且从这里继续了:它是异步的。所以,即使我们调用了部署函数并且它还没有返回值,JavaScript 也会继续向前移动。直到这个调用完成,gaming 变量实际上是未定义的,这会在你尝试弄清楚为什么这个变量有时有值,有时没有值时带来很多头疼:
gaming = Gaming.deployed()
const fundGame = gaming.fundGame()
为了避免这种痛苦,我们使用 async 和 await。它的工作方式是我们在这里使用 async 关键字声明这个匿名函数,然后在函数内部,每当我们需要等待的函数或调用时,我们使用 await 关键字。现在,在幕后有比这更复杂的事情,但这是你需要了解的最基本的知识。除了 async 和 await,你可能会看到的其他模式包括回调和 promises。
现在我们又声明了一个变量,一个名为fundGame的常量。这个函数让我可以向合约发送一些初始的以太币,这样当我们开始测试我们的合约时,合约就有一些资金来支付任何赢家的奖金。如果没有这些以太币,任何导致获胜场景的测试都会失败,因为合约没有足够的资金来支付奖金。看看这个:它也使用了await关键字,因为一旦我们调用这个函数,执行并不意味着完成。我们需要等待该块被挖掘,然后操作才被视为成功:
const fundGame = await gaming.fundGame({from: owner, value: web3.utils.toWei('10', 'ether')})
})
现在,我们终于准备好编写一些 JavaScript 测试了。我们的测试以单词it开头,然后是一句描述应该发生什么的句子。这里通常惯例是实际使用单词should,这样它就像一句句子一样阅读;在这种情况下,它应该记录玩家的损失,所以让我们看看我们如何做到这一点。我们声明了一个名为gameRound的常量,然后我们再次使用await调用我们游戏合约中的winOrLose函数。请记住,这是我们的 UI 将要调用的同一个函数,因为我们的玩家在玩游戏,所以我们实际上在这里模拟真实的用户行为。我们的winOrLose函数接受两个参数:显示给玩家的屏幕上的数字以及他们对于神秘数字是更高还是更低的猜测。我希望这个测试确保当玩家输掉时记录的损失数量增加;这意味着我需要确保当winOrLose函数返回时,这是一个输掉的回合。我可以通过向用户显示数字为 10,并指示他们猜测神秘数字将更高来实现这一点。
嗯,因为我们的神秘数字是一个从0到9的个位数,所以它不可能比十更高,确保我们的测试玩家总是会输。这个函数调用的下一个重要部分是一个可选的第三个参数。前两个参数在我们的函数调用中定义。这第三个参数来自 Solidity,并且采用 JavaScript 对象的形式。在其中,我们指定我们的from账户,表示我希望这个交易来自哪个账户,这就是我们的玩家 1。我还可以附加一个代表玩家赌注的值。现在所有发送到以太坊网络的资金都以 Wei 为单位,如果你还记得第一天,这意味着十的十八次方 Wei 等于一个以太币。但是与其自己计算这些数学,Truffle 在测试时为我们提供了一个 Web3 实例来使用。
Web3 是一个用于与以太坊网络上的智能合约交互的 JavaScript 实用程序库,所以我们可以使用web3.utils.toWei函数将一个以太币转换为 Wei,并保持可读的代码。这启动了游戏的一轮与我们的玩家。由于这个await关键字,我们的代码的执行将在这里等待该轮完成,一旦完成,我们就可以创建一个新的常量叫做player stats。这是您昨天创建的用于增加胜利和失败次数的结构:
it('Should record player losses', async() => {
const initialBalance = await gaming.winOrLose(10, true, {
from: player1,
value: web3.utils.toWei('1', 'ether')
})
Players是一个将地址映射到player结构的映射,这意味着它以以太坊地址作为参数获取正确玩家的筹码。我们可以在这里使用player1变量名,Truffle 会自动将其转换为所需的地址参数。现在,我们可以最终使用assert来验证我们预期的数字是否等于 1。我们还可以在这里包括一条消息,如果测试失败,它将被显示。在这里,你可能会对这个感到好奇。我们的 players 映射返回一个包含玩家胜利和失败的结构,但 JavaScript 对结构一无所知,并且它从结构转换为数组,根据在结构声明中列出的变量的顺序进行处理。因此,我们知道当这个数组返回时,数组的第一项将是胜利,第二项将是失败:
const postBalance = await gaming.players(player1)
assert.equal(playerStats[1].toNumber(), 1, 'The player should have 1 loss')
})
在 Solidity 和 JavaScript 中,数字也有一些类型差异。当我们从 Solidity 中获取一个数字时,无论它是有符号还是无符号整数,它都是一个大数。这实际上是 JavaScript 类型,不是我说它是一个大数,所以我们需要将其转换为 JavaScript 数字,这样我们就可以在我们的应用程序中使用它,我们使用toNumber函数进行转换。
所以,让我们再做一件事。既然我们在这里,让我们验证一下,当这个玩家输掉时,我们拿走了他们的钱。这是经营赌博业务的重要部分,我希望有一些测试来确保它工作正确。在我们玩这一轮之前,让我们获取玩家的账户余额;我们将使用web3.eth.getBalance函数,并提供我们玩家的地址:
const initialBalance = await web3.eth.getBalance(player1).toNumber()
现在,在我们玩这一轮之后,我们知道玩家已经输了,我们可以使用以下代码再次获取余额:
const postBalance = await web3.eth.getBalance(player1).toNumber()
现在,我们可以使用isAtLeast函数进行断言。我使用isAtLeast是因为除了玩家刚刚输掉的 10 个以太币外,他们还必须支付一些 gas 作为交易费用。因此,初始余额应该大于最终余额加上下注金额。他们的余额应该减少了些许,因为他们下注了 10 个以太币加上了 gas。这不是一个确切的数字,但足够接近,以确认玩家确实失去了我们期望他们失去的金额:
assert.isAtLeast(initialBalance, postBalance + 10, 'some message here')
我们可以在我们一直在使用的同一个函数中执行这个操作。在同一个函数中有多个断言是完全可以接受的,只要它们在测试你代码中的同一组件或函数。现在我们可以测试我们的函数,评估和断言结果,并在我们的测试网络中检查不同账户的余额。在下一节中,让我们看看如何让它们都协同工作。
运行测试套件
今天到目前为止,我们花了很多时间编写测试,但没有时间运行测试。当我编写代码时,我通常采取的方法是编写一个单独的测试,运行测试套件以确保它失败,然后编写必要的代码使其通过;这意味着我经常运行测试,而且很重要的是它们能够快速完成。你已经在运行测试了:你每天都使用它们来验证你的作业。当你运行 Truffle 测试时,它会运行测试,现在你知道这些测试是从哪里来的。
你也意识到,为了使这些测试通过,你必须让 Ganache 运行,所以必须存在 Truffle 测试和 Ganache 之间的某种通信:
好吧,让我们来看看幕后的情况。Truffle 能够与 Ganache 通信的原因在于这个文件——truffle.js——特别是Network部分。当你运行 Truffle 测试时,除非另有说明,它会假定我们的开发配置为开发网络:我们指定了 localhost 的地址和端口为 7545,这是 Ganache 运行的端口。
最后,我们告诉它使用 Ganache 提供的任何网络 ID,这将不同于你将在第七章中了解到的其他配置,第七天 - 部署到网络。如果你更改了 Ganache 中的任何设置,或者决定使用其他本地以太坊客户端,你需要在这里更新这些设置,以确保 Truffle 知道如何与其通信。至于运行测试套件,就是这样了。键入以下 Truffle 测试:
module.exports = {
migrations_directory: "./migrations",
solc: {
optimizer: {
enabled: true,
runs: 2000
}
},
networks: {
development: {
host: "127.0.0.1",
port: 7545,
network_id: "*" // Match any network id
},
ropsten: {
host: "127.0.0.1",
port: 8545,
network_id: 3,
from: "0xe7d6c3f43d7859d7d6d045f9ac460eedffd3eae6"
}
}
};
但既然我们在这里,让我问你一下:如果测试失败了,你会怎么做?在你考虑如何处理时,让我向你展示使用 Truffle 时我最喜欢的功能之一,即调试器。
让我们快速看一下我们的winOrLose函数。我要在这里添加一个新的require语句,require(1 != 1),对于正常的情况来说,是一个愚蠢的事情,但这将确保我们的函数调用失败,让我可以向你展示如何调试它:
function winOrLose(unit display, bool guess) extrenal payable returns (bool, unit) {
/* Use true for a higher guess, false for a lower guess*/
require(online == true, "The game is not online");
require(msg.sender.balance > msg.value, "Insufficient funds");
require(1 != 1);
unit mysteryNumber_ = mysteryNumber_, display, guess);
if (isWinner == true) {
/* Player won */
msg.sender.transfer(msg.value * 2);
return (true, mysteryNumber_);
} else if (isWinner == false) {
/* Player lost */
return (false, mysteryNumber_);
}
}
我将切换到一个终端会话并启动一个truffle develop控制台。当你这样做时,Truffle 将启动,并且会带着它自己的以太坊网络,你可以在这里看到它每次启动时的情况。我们会得到一些账户私钥和助记词,如果我们想要将钱包连接到它,我们可以使用这些信息:
我也将打开第二个控制台窗口,并在其中运行truffle develop --log命令。在我们的开发控制台内,我们将compile我们的合约,然后我们将键入migrate --reset,将其迁移到此本地以太坊网络:
现在,我将执行这个命令,调用我们合约上的一个函数;但是当所有内容都在一行时,这看起来很混乱,所以让我们一块一块地拆开来,这样我们就能理解这里发生了什么。所以,这是同样的事情 - 它只是在控制台上写成一行,但在这里,我们将把它拆分成多行来演示每个部分是什么。我们有我们的游戏合约,这是我们的以太坊合约,我们正在调用deployed方法,就像我们在测试中做的那样。然后我们有一个 promise,所以当承诺实现时,我们调用 doc,或者我们有一个函数,接收合约作为实例的变量名,并在该函数内部返回instance.winOrLose函数,该函数执行我们智能合约中的 winOrLose 函数。当完成时,我们将有另一个点,然后或者我们调用另一个函数来将来自以太坊网络的响应写入我们的控制台:
Gaming.deployed()
.then(function(instance) {
return instance.winOrLose(5, true);
})
.then(function(value) {
console.log(value)
});
所以,现在我们可以执行它。每当我们执行它时,我们都会得到 VM 异常处理事务回滚,所以它失败了:
但我们想要看到的部分是,这就是我在这里使用开发控制台的原因,我们得到了我们的交易 ID。所以,现在我可以拿到那个交易 ID,输入debug,然后粘贴那个交易 ID,它将引导我浏览该交易执行的一切。您在这里看到的是,它将逐行地浏览代码;我们有一些命令可以在这里输入,我们将一次逐行浏览它,要跳过,我们还可以设置断点和监视表达式。它实际上是一个功能齐全的调试器:
它在这里显示给我,下划线表示将执行的代码行;所以当我们准备执行它时,我们可以按Enter,然后它会继续执行我们的winOrLose函数。现在它将评估该函数中的每个参数。我们已经进入了我们函数内的第一行代码,我们的 require 语句。它正在评估变量online,这是true,并将变量评估为true,现在它将评估语句作为一个整体,现在它将评估require语句,我们将做同样的事情来确保msg.sender.balance大于消息值。这里非常冗长对吧:
现在我们重定向到1!=1,所以它刚刚将我们踢出来,并显示出一个运行时错误。非常酷的一点是,现在我们确切地知道了合约中的哪一行代码导致执行失败。
现在你知道如何用 Solidity 和 JavaScript 编写你的测试了。你知道如何运行它们,也学会了在出现问题时如何使用 Truffle 交互式调试器。让我们开始今天的作业吧。
作业
在过去几天里,你一直在编写代码,并依靠测试来告诉你代码何时正确。今天,我们将改变这种情况。合约中有一个名为withdrawFunds的函数。它不带参数,将合约的余额转移到消息发送者。我希望你编写一个测试,获取我们测试中定义的所有者的合约余额,调用withdrawFunds函数,然后验证所有者的余额增加了 10 以太币。
作为额外的作业,你还可以编写一个附加断言,以确保提款后合约余额为零。现在,你会想在 JavaScript 中进行这些测试,因为不能使用 Solidity 测试访问以太坊账户。如果遇到困难,可以看看我们 JavaScript 测试文件中已经编写的一些现有测试。实际上,使用这些测试作为灵感并没有错:阅读他人编写的代码是增加自己对特定主题理解的好方法。
总结
我们已经到达了本章的末尾!我们看到了单元测试与集成测试之间的比较。然后我们看了写测试背后的原因。之后,我们测试了各种应用的策略,例如,Solidity 和 JavaScript。接下来,我们创建了一个单元测试,并了解了 Solidity 测试约定。我们学会了如何为同一个函数创建多个测试,并在 JavaScript 中创建了集成测试。最后,我们运行了测试套件。
在下一章中,我们将为我们的应用构建用户界面。
第五章:第五天 - 构建用户界面
在这一章中,我们将专注于构建用户界面(UI)。这是我们的应用程序的一部分,我们的最终用户将会看到和使用它。如果你过去构建过网站,你会看到一些熟悉的东西在这里发生,你将学习与区块链应用程序交互的复杂性。如果你以前从未建过网站,那也没关系,因为你将了解我们将要做什么,以及我们为什么需要这样做。
在本章中,我们将涵盖以下主题:
-
理解 JavaScript 和 React 在 DApp 中的作用
-
从模板创建一个 React 应用程序
-
将游戏状态展示给玩家
-
从 UI 获取玩家输入
-
为玩家提供反馈
-
在网络应用程序中实现 JavaScript 的 promise
-
使用 Web3.js 与以太坊网络通信
-
在 UI 中实现 JavaScript 函数
理解 JavaScript 和 React 在 DApp 中的作用
以下屏幕截图显示了我们今天将要构建的 UI。我们将使用 React,一个流行的 JavaScript 框架来构建它:
我们将构建 UI 中看到的组件,然后编写代码,允许玩家将他们的赌注和猜测提交给 Ganache。然后,Ganache 将执行我们的智能合约函数,并确定玩家是赢了还是输了,根据结果,它要么保留玩家发送的资金,要么支付该轮比赛的赢利。在任何情况下,我们都将向用户提供反馈,让他们知道他们是赢了还是输了。
在我们的 Web 应用程序中,我们将使用 React,但我们还将使用 Redux。React是一个用于构建 UI 的 JavaScript 库,它很好地允许我们设计构成我们的应用程序的组件和应该影响它们的状态。Redux是一个可预测的状态容器,这意味着我们将从以太坊网络获取大量数据,例如,每轮比赛的结果。当其中一些信息发生变化时,我们需要更新 UI 以让玩家知道这一点。Redux 提供了一个模式来做到这一点,今天你将学习如何使用它。
当你考虑组件时,想象一下构成 UI 的离散元素。所以我们的应用程序是一个大组件,但它由其他组件组成,比如显示玩家数字的组件,显示下注控件的组件,以及显示玩家游戏历史的组件。玩家的游戏历史组件还由更小的组件组成。有容器瓷砖组件和每个游戏历史项目的列表重复组件。所有这些都有状态。历史组件状态存储玩家历史,下注窗口状态存储我们的玩家和他们的猜测的赌注金额,React 处理所有这些。
现在,有些事情会在我们的应用程序之外发生,我们需要更新状态并让应用知道已经发生了,并据此响应 React。后者很容易解决,因为我们选择了 React 作为我们的工具。React 在跟踪状态方面做得很好,当状态改变时,它会更新受其影响的屏幕上的内容。
为了更新状态,我们使用了 Redux。在我们的 React 组件中,我们将执行一些触发动作的操作,比如点击“LET'S PLAY!”按钮。在点击该按钮时,它会使用 web3.js 库调用我们的合约,并执行我们之前创建的 winnerLose() 函数。当我们的合约函数执行该函数时,它会返回一些数据,这些数据可能是成功响应或错误消息。我们将获取该响应并将其分派到一个 reducer 中,该 reducer 将使用新信息更新 Redux 存储。
更新的信息以 props 或属性的形式发送回我们的应用程序,在那里 React 可以评估需要在 UI 中更新的内容。整个过程可以总结如下图所示:
因此,我们清楚地将组成 DApp 的不同部分进行了分离。
我们有以太坊网络,我们的合约在**以太坊虚拟机(EVM)**上运行,然后我们的 React 应用在 Web 服务器上运行。我们应用的这部分可以是任何东西;我选择了一个 React 应用,但它同样可以是一个安卓或 iOS 应用,一个 Python 应用,甚至是一个老旧的 COBOL 主机应用。
React 应用程序与以太坊网络之间的连接是通过一个名为 web3.js 的实用程序库来完成的,以进行通信。该库提供了允许我们与以太坊节点通信的实用工具,如 Ganache,以及以太坊主网和测试网络上的节点。web3.js 有四个主要模块,分别是:
-
web3-eth:以太坊区块链和合约 -
web3-shh:点对点和广播的 Whisper 协议 -
web3-bzz:用于去中心化文件存储的 Swarm 协议 -
web3-utils:辅助函数
在本书中我们将使用的模块是用于与区块链和我们的合约通信的 web3-eth,以及一些实用函数的 web3-utils,例如转换以太币的不同面额。
Web3 允许您使用回调和 promises,并提供事件发射器来尝试提供您需要的所有选项。我们将广泛使用 promises,并在接下来的内容中详细介绍它们。在您可以编写实际向您的客户、公司或客户端交付值的第一行代码之前,必须放置大量的样板代码。幸运的是,有一些快捷方式可以减少这段时间,我将在下一节中向您展示它们。
从模板创建 React 应用
在这一部分,我们将看一些快捷方式来启动一个新的 React 应用。这是一件好事,因为启动一个新应用是一项繁重的工作,需要很多时间,本该用来编写代码来完成你的应用。
一种方法是使用 Facebook 创建的一个名为create-react-app的工具。它只是使用一些预先配置的选项快速创建一个 React 项目的板块或空白项目。你也可以只是复制另一个项目。如果你有一个类似的项目,里面的一切都按照你需要的方式设置好了,你可以克隆、复制或 fork 该应用程序,删除你不需要的部分,然后从那里开始。
还有第三种方法,那就是从零开始构建所有东西。如果你真的想要了解 React 的内部工作,这是一个很好的练习,但如果你在工作期限前工作,我不建议这样做。
优缺点
每种方法都有其优缺点。create-react-app工具使用 React JSX 和 ESX 创建一个新的项目。
使用这个工具启动一个新项目就像输入以下命令一样简单:
npx create-react-app my-app
其中my-app应该被你的应用程序的名称替换。
这创建了应用程序,并预先配置了babel和webpack,这样你就不必自己配置,更新通常很简单并有很好的文档说明,这样你就可以轻松地使你的应用程序保持最新功能和安全补丁。
不过,它的观点很明确,为了实现这样一个项目,必须如此。你添加的任何额外依赖项必须符合项目的格式,否则你会遇到挑战。你可以定制任何预先配置的工具,但这样做可能会使其处于你要负责维护的状态,因为它将不再接收官方包的更新。
复制或 fork 另一个项目有时可能是开始的好方法,特别是如果新项目与许多相同的依赖关系,它可能已经为你的使用案例配置好了,还可以访问支持资源,比如团队中的其他开发人员,如果你在使用相同的代码库。
这确实意味着你也会继承该项目的所有问题,比如过时的依赖项。你可能会不得不删除任何不被你的应用程序使用的不需要的代码,有时会导致一些问题。经常发现自己 fork 了一个项目吗?那么你最终会在多个项目中复制相同的代码,这可能会导致在更新依赖项或修补安全漏洞时需要进行大量额外的工作。
对我来说,当我学习新东西时,很容易看到一个似乎在解决同样问题的项目,并将其作为起点。我觉得这对于在一边尝试不同的事情来说是很好的。然而,随着时间的推移,这些项目几乎总是最难维护的。所以,如今,如果我正在构建一个将与实际用户发布的项目,几乎总是从 Facebook 工具开始。唯一的例外是当有一个具体的用例需要一个与其父项目共享代码库的高度定制组件时。
现在你对如何为 DApp 创建自己的 React 应用有了一些背景信息,让我们继续在我们的应用程序上工作。是时候开始构建用户界面了,这样我们就可以开始看到与我们的 Solidity 合同进行交互的视觉界面了。
向玩家显示游戏状态
到目前为止,我们一直在讨论 React 的工作原理。现在让我们把这些知识付诸实践,开始构建我们的用户界面,同时学习 React 如何使用状态来更新页面上的组件。
在我们的应用中,有一个名为 index.html 的文件;你可能知道这是当用户访问网站时,网页服务器默认提供的文档。在我们的索引页内部,有一个名为 index.js 的 JavaScript 文件被调用,它又添加了一个名为 app 的组件,这就是我们的 React 应用程序。下面的图表显示了应用的样子:
React 的一个主要目标是构建独立的组件。在 app 内部,它获取组成我们游戏的组件。这些组件包括头部和游戏组件。这些组件在文件系统上是单独的文件,让我们进入代码编辑器来感受一下它们的样子。让我们看看下面的截屏:
在我们的应用中,有一个 src 源文件夹、一个 index.html 文件和一个 index.js 文件。当你查看 index.js 文件时,你可以看到我们通过导入 configureStore 来创建 Redux store,然后我们从 containers/App 文件夹中导入我们的 App 组件,并在页面上创建该 app 的实例,如下代码片段所示:
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from 'core/store/configureStore';
import App from 'containers/App';
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<App/>
</Provider>
document.getElementbyId('root')
);
App 组件存储在我们的 containers 文件夹中;当你转到那里时,会看到一个名为 App 的文件夹和一个名为 index.js 的文件,其中存放着它的代码。它导入了头部和游戏,游戏本身是 containers 下的另一个文件夹,它有自己的 index.js 文件,在这个文件中,我们将定义游戏的所有组件。
这是我们如何为您的 UI 定义这些组件的方法。将显示的整个屏幕部分是我们的游戏容器,它是app.js文件中引用的组件。它由三个较小的组件组成——显示玩家数字的组件,显示投注控件的组件,以及包含玩家游戏历史记录的组件,如以下示意图所示:
为了创建所有这些容器和控件,我使用了 Material-UI 库。这个库使创建高质量、专业外观的 UI 组件变得非常容易。
我们的游戏组件首先从几个库开始导入:
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
如这里所示,我们需要React本身以及组件库来创建组件类,还需要PropTypes、connect和bindActionCreators用于 Redux,我们将在接下来的章节中详细讨论它们的角色。
然后我们创建一个名为Game的类,在其中我们有一个constructor函数。这与我们在 Solidity 合约中创建的构造函数的工作方式类似,它在类初始化时运行一次,以设置初始状态。以下代码片段显示了该类包含的内容:
export class Game extends Component {
constructor(props) {
super(props)
this.state = {
playerNumber: '';
highLow: '';
wager: '';
history: []
}
}
render() {
return (
)
}
}
如前面的代码块所示,我们将为一些我们需要的变量设置初始状态,例如本轮显示给玩家的数字,他们对更高或更低的猜测,他们在本轮下注的金额,以及我们将存储之前几轮结果的数组。React 组件有一个必需的函数,render()。该函数在组件渲染时被调用。
现在我们准备开始布置这个组件的 UI 元素了。我们将从index.html页面上构建我们的第一个组件开始,即显示给玩家本轮数字的显示窗口。我们将从 Material-UI 库中定义一个卡片,然后定义一个带有标题和副标题的卡片头部,为玩家提供有关他们正在查看的内容的一些信息,最后我们有一个标题元素来显示数字本身。可以用以下代码片段来概括:
<Card style={style}>
<CardHeader
title="Player's Number"
subtitle="Will the mystery number be higher or lower than this number?"
/>
<h1 style={displayNumber}>{this.state.playerNumber}</h1>
</Card>
这应该生成一个看起来与以下截图类似的窗口:
卡片中显示的数字是我们在构造函数中定义的状态变量。由于我们在组件中添加了来自 Material-UI 库的卡片和卡片头部,我们必须将其一起导入,以便 React 知道从哪里获取这些组件。我们通过将卡片和卡片头部作为导入内容添加到我们在文件顶部声明的其他导入中来实现这一点。
让我们回到在h1中使用的命令。它是如何从一串文本变成你在屏幕上看到的数字的?在 React 中,当你用花括号括起一个字符串时,它就具有了特殊的含义,但在那一点上,它实际上只是 JavaScript,所以我们可以做任何我们可以在 JavaScript 中做的事情。这意味着this被创建为一个变量,该变量的结果就是显示在屏幕上的内容。
有一个特殊的 React 生命周期函数叫做componentDidMount(),在我们的组件挂载后由 React 调用。挂载意味着它已经在 DOM 中呈现,并且可以通过编程方式调用。将我们的代码放在这里,确保我们在实际存在于 DOM 中之前不会尝试访问组件。我们将调用this.setState()并分配playerNumber状态变量。该函数的结果生成当前玩家号码。generatePlayerNumber()函数如下所示,只返回 0 到 9 之间的一个随机数。最终结果是,我们页面上的组件呈现了玩家看到的随机数字。以下是代码片段:
componentDidMount() {
this.setState({
playerNumber: this.generatePlayerNumber()
})
generatePlayerNumber() {
return Math.floor(Math.random() * 10)
}
}
接下来,我们有我们的下注窗口,它是另一个带有卡头的卡片,就像我们的玩家显示组件一样。它有一个单选按钮组,其中包含两个单选按钮,供玩家选择更高还是更低:
<RadioButtonGroup
name="highLow"
defaultSelected={this.state.highLow}
onChange={this.handleChange('highLow')}
>
<RadioButton
value="higher"
label="Higher"
style={elementStyle}
/>
<RadioButton
value="lower"
label="Lower"
style={elementStyle}
/>
</RadioButtonGroup>
注意所选值如何读取highLow变量的状态值,并且在更改时调用handleChange()函数。
我们还有一个文本字段,供玩家指示他们想要下注多少,以及一个按钮,当他们准备好开始游戏时执行。我们已经导入了卡片和卡头,所以现在我们将以相同的方式导入单选按钮组、单选按钮、文本字段和凸起按钮。
无论你在哪一步感到困惑,你都可以随时参考你在书中得到的源代码。对于单选按钮组和文本字段,我们调用handleChange()函数,其代码类似于以下内容:
handleChange = name => event => {
this.setState({
[name]: event.target.value
})
}
这个函数接收要更新的状态变量的名称作为参数,然后用调用该函数的控件的值更新状态。所有这些都归结为类似于这样的东西:
我们的最终组件是历史窗口,就像其他组件一样,它是一个带有卡头的卡片,这真正突显了使用库的好处之一。我们在这个组件中多次重复使用了这个库,因为它很容易定义,而且我们不需要编写任何代码来完成它。接下来,我们有一个列表,我们从状态中获取历史记录,这是一个数组,数组中的每个项目都是以前游戏中的结果。因此,我们对其进行映射,并为数组中的每个元素创建一个列表项。这在下面的代码片段中进行了总结:
<CardHeader
title="History"
/>
<List>
{this.state.history.map((round) =>
<ListItem key={round.transactionHash}
primaryText={`Result:\t${round.result}`}
secondaryText={`You ${round.result} ${round.wager} by guessing ${round.playerNumber} would be ${round.guess} than ${round.mysteryNumber}!`}
/>
)}
</List>
</Card>
这导致以下输出:
现在,让我们再次跳转到我们的代码编辑器,并看看所有这些部分如何组合成一个单一的 React 类。所以,在我们的src/containers/Game文件夹中,我们有我们的index.js文件,现在让我们来看一下。
在顶部,我们从 React、Redux 和 Material-UI 库导入构建此页面所需的所有内容。我们在这里有一点 CSS,使页面的格式看起来漂亮,然后我们有一个扩展了 React 组件的游戏类。在其中,您将找到设置初始状态变量的constructor,然后是 React 生命周期组件。然后,有我们的渲染方法,其中返回在页面上呈现的所有元素。
我们在玩家编号的卡片标题中有一个卡片;我们有第二个卡片代表投注窗口,然后我们有第三个卡片代表玩家的游戏历史记录。在此之后,我们有处理更改事件、启动游戏并生成随机数的用户定义函数。
所以,一切开始变得像一个真正的应用程序了。让我们再推进一点,跳到下一节,在那里我们将剖析“开始游戏!”按钮,看看我们如何从 UI 获取输入,并将其转换为用户的游戏。
从 UI 获取玩家输入
在这里,我们将继续执行代码,当我们的玩家点击“开始游戏!”按钮时。下面的代码片段展示了它的工作原理:
playGame = () => {
const { actions } = this.props
actions.game.playRound(
this.state.wager,
this.state.playerNumber,
this.state.highLow
)
this.setState({
playerNumber: this.generatePlayerNumber()
})
}
在这里,我们正在定义一个从this.props获取的动作,然后我们调用该动作中存在的一个函数,最后,我们使用一个新的随机数更新playerNumber状态变量。
接下来,我们有从游戏组件中调用的函数,即playRound()。这个函数接受赌注、玩家编号和猜测作为参数,然后返回一个dispatch参数。dispatch()函数返回一个 JSON 对象,其中 type 是我们常量之一,并且参数是我们传递给函数的参数。我们的类型在types.js中定义。在其中,我们定义了我们的初始状态,它表示应用程序启动时设置的状态变量,这样当应用程序启动时就不会出现变量未定义错误。
然后,我们导出我们的游戏 reducer 函数,其中包含基于我们在动作中提供的动作类型的 switch 语句。当在我们的 reducer 中找到匹配类型时,它将返回定义给 Redux 存储的对象。
这个谜题的最后一部分是rootReducer。这个函数将我们应用程序中的所有 reducer 组合起来。下面的代码片段展示了它的内容:
import { combineReducers } from 'redux'
import { providerReducer } from 'core/reducers/reducer-provider'
import { gameReducer } from 'core/reducers/reducer-game'
const rootReducer = combineReducers({
provider: providerReducer,
game: gameReducer
})
export default rootReducer
因此,让我们再次将这个问题概括一下,这次引用我们刚刚学到的内容。从我们的游戏组件中,我们调用playRound()函数,这是由props提供的。playRound()函数接受来自玩家的参数并将它们分派到core/reducers/reduce-game.js中的 reducer。reducer 使用core/types.js中提供的常量将接收到的分派与要执行的工作进行匹配,并将结果发送到 Redux 存储。然后,Redux 存储将新数据作为props发送到我们的应用程序,当props发生变化时,React 注意到并使用新数据更新我们的历史组件。可以用下面的图表来概括这个过程:
让 React 如此强大的是,当发生这种情况时,屏幕上唯一发生变化的是历史组件更新,而其他任何东西都没有发生变化。在下一节中,我们将实时地逐步进行所有这些,因此如果现在还不清楚,我认为在下一节中,当您自己测试它时,您会理解的。
向玩家提供反馈
我们现在已经布置好了代码,将我们的 React 组件连接到 Redux,以管理我们应用程序的状态。这可能是一个难以理解的概念,因此在本节中,我们将使用 Visual Code 调试器来玩我们的游戏,并实时地逐步执行代码。这样做将使您能够在执行时准确地看到应用程序的行为。
知道如何调试可能是您可以学习的最有用的技术之一,因为它可以在您最需要时为您提供新的信息。使用 Visual Code 设置它非常容易。您只需在 Visual Code 的扩展面板中安装微软的Debugger for Chrome工具:
然后,您将在调试菜单中创建一个启动配置,其中包含以下代码:
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}
现在,我们将导航到index.js文件中playGame()函数内的第一行。由于playGame()是一个函数,当玩家点击游戏中的“Let's Play!”按钮时,它将被执行。我们现在在这里设置一个调试点。
现在我们将切换到 Visual Code 中的调试菜单,并点击那里的绿色播放按钮。当浏览器启动时,我们应该会看到类似这样的东西:
我们有一个玩家编号窗口,编号为 3,和我们的投注窗口。
我们可以输入赌注,所以让我们去做吧,然后点击“Let's Play!”。所以,立即,这会将我们切换回 Visual Code 并触发我们设置的调试点。我们可以使用顶部的控件播放到下一个调试点或逐个跳过每个函数。
当我们逐步执行每个函数时,您会看到在调试窗口中,这些函数的参数实时填充其值。您还可以在代码窗口中将鼠标悬停在它们上面,会弹出一个小窗口,告诉您相关信息。因此,您可以实时检查变量。
当我们从actions对象的playRound()函数到达时,我们不是要跳过它,而是要跟进它,以便通过该函数跟踪代码执行,这将带我们到我们的actions-game.js文件,我们在其中有我们的playRound()函数,返回dispatch参数。然后我们进入其中,现在它已经准备好返回我们将要分派到 reducer 的 JSON 对象。
然后我们进入我们游戏 reducer 的函数,有 switch 语句,如果我们悬停在操作类型上,我们可以看到该变量的结果等于Round_Results。所以我们将这些键值对分配给 Redux 存储。我们的 Redux 存储然后将这些更新后的属性推送回我们的游戏index.js文件作为props,然后我们将返回到我们的浏览器,看到我们更新的历史记录填充到历史窗格中,如下截图所示:
现在循环已经全面完成,我们的应用刚刚从 React 收到了新的props。那么我们如何利用它们?我们将通过使用另一个 React 生命周期方法componentDidUpdate()来实现。它是在发送新 props 后触发的函数。它有一个叫做 previous props 或prevProps的参数,这样您就可以引用刚刚收到的 props 之前的 props,并确定是否需要做出响应。这是非常重要的,因为您的应用程序将一直收到新的 props,但并非所有 props 都需要做出响应,所以我们将使用条件来识别我们关心的那些。
我们将比较前一个游戏对象的时间戳和当前游戏对象的时间戳;如果它们不同,我们就知道我们收到了数组的新历史项。在这种情况下,我们将复制当前的历史和状态,然后将props游戏对象的数据推送到其中。完成此操作后,我们将用刚刚创建的新历史数组来替换历史对象,现在我们将回到显示游戏历史记录的卡片。
请记住,我们将所有项映射到状态历史数组。React 将注意到我们刚刚更新了该状态变量,并重新渲染此组件,使其更新并显示我们上一轮的结果。在这里,我们只是立即发送了游戏发送的参数,但这并不是我们真正想要的。我们真正想要的是调用我们的智能合约,将这些游戏参数传递给它,并让它确定我们的玩家是赢了还是输了。为了做到这一点,我们需要我们的动作调用智能合约,然后等待这些结果。所以,在下一节中,我们将探讨 promises,它们的作用,工作原理以及我们将如何使用它们来与我们的以太坊网络进行通信。
在网络应用程序中实现 JavaScript Promise
在我们的最后一节中,我们看到了如何从我们游戏的玩家那里获取输入,并将其通过 Redux 发送以更新应用程序状态,然后刷新用户界面。但正如我们在第一天学到的,当我们提交调用以太坊网络时,我们并不会立即得到响应,会有一段延迟。不幸的是,我们在没有得到响应之前无法继续进行,那么我们该怎么办呢?
在我们回答这个问题之前,让我向你展示一下潜在的问题是什么。它源自 JavaScript 的工作方式。让我们看看这段代码:
console.log('1')
setTimeout(() => {console.log('2')}, 3000)
console.log('3')
setTimeout(() => {console.log('4')}, 1000)
在上面的代码中,我们有四行,每行都打印出数字 1 到 4。那么当我们运行这个代码时会发生什么呢?如果你预测它会打印出 1,3,4,2,那么你是对的,因为 JavaScript 执行第一行时,它打印出数字 1,然后执行第二行。这一行是问题的起点,因为它执行了 setTimeout() 函数,但并不知道我们希望它等待定时器完成。所以它调用了 setTimeout() 函数,然后移到下一行,打印出 3,然后移动到最后一行,有另一个定时器。一秒钟后,它打印出数字四,两秒钟后,当指定的 3000 毫秒延迟过期时,它最终打印出数字 2。
现在,想象一下,如果我们不是将一个数字写到控制台日志中,而是等待来自我们的 Solidity 合约返回的数据,以便在我们的应用程序中使用它,那么会不会导致问题,因为我们会在数据实际存在之前尝试使用它呢?那么,我们如何使用 JavaScript 的 Promise 来解决这个问题呢?
Promise 的工作原理
JavaScript 的 Promise 是一个对象,本质上是在未来产生一个值或输出,就像现实生活中的承诺一样。一个 Promise 可以有三种状态:
-
已完成
-
已拒绝
-
待定
我们将通过上一个代码块中的场景作为示例进一步理解这一点。
我们将创建一个返回 promise 的新函数,以处理我们延迟的事件。promise 接受一个参数,这是一个接受两个参数resolve和reject的函数。在我们的 promise 中,我们有我们的setTimeout()函数。下面的代码片段展示了我们如何使用 promise 来解决我们之前的定时器问题:
function waitForMe (input, delay) {
return new Promise ((resolve, reject) => {
setTimeout(() => {
resolve(input)
}, delay)
})
}
此函数确保编译器等待计时器完成,打印出数字 2,然后继续打印数字 3。
大多数情况下,它会是一些不会立即返回的东西,比如在 Solidity 合同上执行一个函数。当我们的setTimeout函数完成后,我们将调用我们 promise 的一个参数resolve()函数;这表示 promise 已成功完成。同样,如果出现问题,我们可以调用reject函数。
现在,我们可以重写我们的原始代码。我们的第一行保持不变,但对于我们的第二行,我们将使用waitForMe()函数,并将两个参数传递给它,即输入 (2) 和延迟 (3000)。这确保当函数被调用时,编译器会等待三秒钟,然后打印出数字 2,最后继续下一步。就像之前一样,我们会直接从控制台打印出 3,然后我们将再次使用相同的waitForMe()函数来获取数字 4。下面的代码片段展示了修改后的代码:
console.log('1')
waitForMe('2', 3000).then(result => {console.log(result )
console.log('3')
waitForMe('4', 1000).then(result => {console.log(result )})
让我们快速回顾一下 promise。promise 提供了某事将会被做的保证,但并不确定何时会被完成。不像真实的承诺,JavaScript promise 通过解决 promise 来实现它,或者通过拒绝来结束它。这样可以让你决定在任何情况下该做什么。现在,我们已经准备好解决与以太坊网络上的合同通信的问题了。
使用 Web3.js 与以太坊网络通信
在之前,我们看到了如何从我们游戏的玩家那里获取输入,并通过 Redux 来更新应用程序状态,然后刷新用户界面。在前一节中,我们了解了 JavaScript promise。现在,我们将学习如何让这两者共同工作,来调用我们在合同中创建的函数。
我们将重新设计我们应用程序的actions-game.js文件中的playRound()函数。
我们将使用dispatch函数,并将其移出到自己的函数中。然后,我们将从以太坊网络中获取我们合同的一个实例,并创建一个调用我们合同中函数并等待响应的 promise。当它获得响应后,它将调用我们的新dispatch函数。
要开始,我们需要将一些新的依赖项引入到我们的应用程序中,比如从 Truffle 合同中获取的contract库,我们从contract/Gaming.json中的Gaming文件,以及web3库,如下面的代码片段中所示:
import contract from 'truffle-contract'
import Gaming from '../../../build/contracts/Gaming.json'
import web3 from 'web3'
我们现在将修改我们的playRound()函数。
我们将dispatch函数移入两个新函数,dispatchRoundComplete()和dispatchRoundFailed()。我们用一个常数web3Provider替换它,该常数来自 Redux 存储中的状态。然后,通过调用我们的contract库并传递给它我们的合约的 JSON 表示来声明我们的游戏合约。我们在这里做了一些设置;我们将合约的提供者设置为web3Provider中指定的提供者。这是 MetaMask 被注入的地方,我们的应用程序会自动检测 MetaMask 是否被注入并设置提供者。下面的截图展示了我们的playRound()函数现在的样子:
明天您将学习更多关于 MetaMask 的知识。MetaMask 允许您选择不同的账户,就像我们在编写单元测试时所做的一样。使用web3Provider.eth.defaultAccount对象可以确保我们从当前选择的账户发送交易。我们创建一个新的 promise 并定义两个参数resolve和reject。当我们在以太坊合约中完成我们的函数调用时,我们将检查其状态。如果成功,我们将调用 resolved 函数,如果失败,我们将调用 reject 函数。这样,当这个 promise 返回时,我们就知道它是否成功。在 promise 内部,我们调用winOrLose()函数,这是一个新函数,但您可能会认识到这个名称。
让我们来看看它,然后我们将回来看看前面函数中的其余部分发生了什么:
如果你觉得函数名称winOrLose听起来很熟悉,那么你是对的:这是我们在 Solidity 合约中创建的函数名称。在我的 React 应用中,我给调用智能合约的函数取和智能合约中的函数相同的名称。因此,现在看着我们的 UI 代码,我们知道这将调用winOrLose()函数,而这个函数在我们的合约中已经存在了。这只是帮助我们追踪事物。
我们传递给这个函数的一个参数是GamingContract,这是我们刚刚创建的在线合约的实例。它有一个deployed方法,返回一个 promise。它的作用是获取我们合约的部署版本,这意味着它获取与以太坊网络通信所需的信息。它找出它所在的网络、它的部署地址是什么,以及如何与它通信。由于它是一个 promise,我们可以通过使用then等待其完成;这意味着我们应该执行这段代码,而每当它完成时,继续执行。
gameContract.winOrLose()函数是与我们的 Solidity 合同进行通信的实际函数。请记住,在 Solidity 中,我们始终以Wei为单位处理货币。以太坊的大多数用户都熟悉ether单位,因此在我们的界面中,我们允许他们用以太币下注,然后我们使用web3工具将其从以太币转换为 Wei,然后将其附加到我们的交易中。这整个部署的函数也是一个承诺。
当这个完成时,我们会进入then函数,将交易的结果作为名为results的变量传递。当它解析时,我们将调用我们在调用函数时传递的resolve函数。在那个函数中,我们将返回result.logs[0],看起来非常具体。让我们仔细看看发生了什么:
这是我们 Solidity 合同的核心部分。我在上图中突出了两行具体的内容,这就是我们现在要看的内容。当我们的玩家赢了或输了,我们就会发出RoundComplete()事件,并提供交易的详细信息,赢了或输了多少钱,向玩家显示的数字,我们合同生成的神秘数字,玩家猜测的高或低,以及他们赢了还是输了这一轮。请记住当交易被写入区块链时,事件会被发出。下图显示了我们从交易中得到的实际结果:
这里涉及了很多内容,让我们来看一下日志数组。数组中的第 0 项具有一个args键,如果你仔细看,就会发现这是我们正在发出的事件,包含了我们刷新玩家界面所需的所有细节。所以,当我们解析这个函数时,我们会剥离一切,然后将这个键返回给我们的resolve函数。
winOrLose()函数中的最后一部分是一个catch语句,如果承诺中的任何内容失败,它就会被执行。当发生这种情况时,我们捕捉错误并使用reject函数将其发送回原始承诺,从而回到我们的playRound()函数。根据我们调用resolve还是reject函数,我们将执行then函数或catch函数。
当它成功解析时,我们就会从包含我们事件的日志中返回该对象,并将其传递给另一个新函数dispatchRoundComplete(),并调用我们的参数事件。这个函数也许会让你感到熟悉:它就是我们在上一节创建的派发函数,但我们稍微修改了一下,如下图所示:
我们仍然有相同的类型,但我们将时间戳键替换为 transactionHash。这个字段存在的原因是因为我们在 UI 中映射数组以填充游戏历史记录表。React 要求数组中的每个元素都有一个唯一的键,以便在更新时知道要更新哪一个。
以前,我们没有一个唯一的键,所以我只是把一个时间戳作为唯一值放在那里,但现在我们有了我们交易的交易哈希,这绝对保证是唯一的,所以我们不再需要那个时间戳。我们有了我们的赌注和玩家号码,并且我们添加了玩家下注的神秘数字。然后我们有他们的猜测以及该轮的结果。
我们这里还有一个布尔值 success 键,我们可以用它来切换此次交易是否成功。当 promise 成功完成时,我们将 success 设置为 true,如果 promise 被拒绝,那么调度往返失败函数将会将 success 设置为 false。我们的 dispatch 的行为和以前一样,并通知接收 dispatch 动作并将其发送到 Redux 存储的 reducer,而 Redux 存储现在将通过一个新的 props 文件通知我们的 UI,我们将在下一节中使用它来更新用户界面。
在 UI 中实现 JavaScript 函数
到目前为止,您已经了解了如何从 UI 上的用户控件获取输入,将它们通过 Redux 生命周期进行处理,并将最终产品显示回 UI 中作为新的 props。在本节中,我们将深入研究与用户控件相关的 UI 中的函数。
当我们的 reducer 接收到 dispatch 函数的动作时,它会创建并发送此对象到 Redux 存储,并且还会发送所有其他可能存在的 reducer,因此我们有一个 rootReducer 常量,它组合了所有这些 reducer。以下截图显示了这是什么样子:
在这里,您可以看到我们在 rootReducer 中定义了一个名为 game 的新键,并且它将用来填充来自 gameReducer 的数据。这就是发送到 Redux 存储的内容,我们知道 Redux 存储将把这个内容作为 props 发送到我们的组件中。因此,在我们的游戏 UI 的 JavaScript 文件中,我们有这个函数 mapStateToProps(),正如你从名称中猜到的那样,这是将 Redux 状态映射到组件中的 props 的函数。
因此,我们发送到 Redux 存储的所有值都可以在我们的组件中按如下方式访问和读取:
this.props.game.wager
this.props.game.playerNumber
this.props.game.mysteryNumber
所有的 props 都是只读的,您不能更改它们。我们将在一分钟内看看如何更改它们,但是当它们在后端更改时呢?如果这些键中的一个值在 Redux 存储中更改了,我们的 UI 如何更新?
嗯,这取决于你是使用 React 15 还是 React 16,有两种方法可以使用,行为都相似,但是从名称可以看出,它们在不同的时间触发。在 React 16 中,我们使用componentDidUpdate(),它接收一个名为prevProps的参数,允许我们将 props 的值与它们的先前值进行比较,然后相应地采取行动。在 React 15 中,我们将使用componentWillReceiveProps(),它在组件接收新属性之前触发。参数名称也反映了这一点。它是一个名为nextProps的参数,其中包含 props 的新传入值。您可以在 React 16 中使用componentWillReceiveProps(),因此,如果您将现有的 React 15 应用程序升级到 React 16,这仍将起作用。
在下一个版本的 React 中,componentWillReceiveProps()将被标记为已弃用,然后在其后的版本中将被删除。当我们向我们的组件添加这些函数时,我们需要评估为什么调用它。它会被很多东西调用,而且是你不关心的东西,所以你需要评估条件来看你是否关心它。让我们看看以下例子:
我们将以一个if语句开始,检查nextProps参数中的game.transactionHash是否与this.props中的不同。如果是,那么告诉我们我们有一个新的交易哈希,而由于交易哈希特定于一个交易,我们知道玩家已经完成了一轮游戏。接下来,我们设置我们的success键;记住,这是我们设置为true的键,如果包裹我们的合同交易的 promise 成功完成,我们设置为false,如果 promise 被拒绝。这给了我们一个机会来传递错误消息,如果我们需要的话。如果我们的 promise 成功解决,那么我们将有一些新的交易细节添加到我们的游戏历史窗口中。我将该游戏历史存储为组件状态中的一个数组。
所以,让我们为我们的历史创建一个新变量,然后将我们最新交易的所有细节推送到该数组中。我们不能直接更新组件状态,所以我们调用this.setState并将history值设置为我们刚刚创建的新历史数组。最后,我们有一个snackbar控件,这是一个小型的弹出式控件,其值也存储在组件状态中,因此我们可以更新它们并为它们分配值。
当这个渲染时,它会转化为一个句子,类似于失去十个以太币,或者正确的值是什么,然后将该轮结果添加到历史窗口中,如下面的截图所示:
除了更新状态变量之外,我们不需要做任何操作就可以使我们的 UI 组件处理页面上的更新,因为我们将控件绑定到了状态变量上。当它们发生变化时,React 知道重新渲染它们。因为 React 只想渲染发生变化的变量,所以在映射我们的历史数组时有这个键是很重要的。这个键允许 React 唯一地识别数组中的每个项目,并且只渲染那些已经改变的项目。现在,是我们今天的最后一个部分了,作业。
作业
好了!是时候将今天学到的知识付诸实践了。这个作业将强化我们使用 React 和 Redux 进行状态管理以及使用 promises 处理潜在网络请求的概念。你将在我们的游戏 UI 中添加一个记分牌,显示玩家当前的记录,如下面的截图所示:
你可以在页面上创建一个新组件来显示它,也可以将其内联添加到类似于前面组件的现有组件中。现在,回想一下第二天,我们为我们的玩家创建了一个包含此信息的结构体,并创建了一个将玩家地址映射到结构体的映射。请记住,公共变量和 Solidity 自带一个免费的 getter 函数,如果该变量是一个映射,则映射键是一个必需的参数。
在我们的情况下,这是我们玩家的地址。解决这个问题有多种方法,只要你的解决方案满足每次变化时更新玩家分数的条件,那就是一个很好的解决方案。如果是我,我会在我们的代码中找到一些已经在做某些事情的地方,因为分数已经改变了。你可能会在下面的代码片段中找到一些灵感:
componentWillReceiveProps (nextProps) {
if (nextProps.game.transactionHash !==
this.props.game.transactionHash) {
if (nextProps.game.success == false){
console.log(nextProps.game.error)
}else {
const newHistory = this.state.history
newHistory.push({
transactionHash: nextProps.game.transactionHash;
wager: nextProps.game.wager;
playerNumber: nextProps.game.mysteryNumber;
guess: nextProps.game.guess;
result: nextProps.game.result
})
this.setState({
history: newHistory,
snackbar: true,
message: '${nextProps.game.result}
${nextProps.game.wager} ether'
})
}
}
你可以使用上面的代码块,并按照应用程序中已经存在的代码的模式和示例来使用。你从 UI 调用一个动作,该动作将返回一个 promise,调用一个包含我们统计数据的 Solidity 函数;该 promise 将解析,然后我们可以调度我们的合同函数结果,将它们发送到一个 reducer,该 reducer 将更新 Redux 存储,这将更新呈现给 UI 的 props,UI 将更新以反映新的 props。
在明天之前你将无法完成这个作业,因为我们缺少一个测试它的部分,但是今天要写好代码,明天我们会检查剩下的部分,因为你需要一个钱包来测试这个。
总结
在本章中,我们学习了为我们的应用程序实现 UI 的所有内容。我们学习了如何使用 React 来创建我们的 UI,以及 JavaScript 如何在 DApp 中发挥重要作用。我们学习了如何从模板创建 React 应用程序并应用它们,以及如何修改应用程序中的各种状态,使一切都按照预期运行。我们学会了如何接受用户输入并向他们提供反馈,以及如何使用 Web3.js 将这些操作传达到以太坊网络。最后,我们学会了如何将所有功能部署到 React UI 中。
在下一章中,我们将学习不同类型的钱包,并且学习如何配置 MetaMask 来与我们的应用程序一起使用。