还记得当年的百度贴吧吗? 今天有人写了一个去中心化的...
今天科技圈最大的新闻莫过于百度李彦宏被“浇水”一事了,微博、微信、今日头条可谓是炸开了锅,但想想要是10年前,讨论最火的地方可能不是这些 app,无疑是百度贴吧了,但可能却面临删帖的危险...
这时,区块链的不可篡改行就帮上了大忙!
今天营长就使用 DApp 开发框架 Embark,手把手教你构建一个去中心化的社交新闻网站,从主要分以下三个部分:
明确 DApp 需求,部署智能合约;
使用 DApp 开发框架 Embark 的 JavaScript 程序库 EmbarkJS 测试智能合约;
使用 JavaScript 用户界面框架 React 构建 DApp 的前端。
明确 DApp 需求,部署智能合约。
百度贴吧是一个功能非常复杂的平台,因此我们无法做到把它全部推倒重建,我们只会构建出 百度贴吧的一些核心功能,并在构建中详细介绍如何使用 Embark 框架构建 DApp 。
我们的构想非常简单:首先我们给 DApp 取名为 DReddit (去中心化的百度贴吧),它允许用户在其中发布帖子,而其他用户可以凭兴趣以及帖子的质量对帖子进行好评和差评的投票。为了简化开发,DReddit 直接使用以太坊钱包账户作为用户帐户,也就是说每个以太坊钱包账户都是该应用程序的有效帐户,用户可以使用基于浏览器的以太坊轻钱包 Metamask 等扩展程序进行身份认证。
我们将创建一个智能合约来实现发布帖子以及对帖子投票的功能。同时为了简化用户的交互过程,我们还会使用 React 框架构建一个用户界面。
1、应用程序设置
首先,安装 Embark 框架,命令如下:
npm install -g embark
使用 new 命令来创建并设置应用程序:
embark new dredditcd dreddit
使用 cd 命令进入文件夹之后,我们可以看到应用程序的文件结构,在其中最重要的文件夹是用来存放智能合约的 contracts ,以及用来存放前端程序的 app。
2、创建智能合约
使用 Solidity 语言编写智能合约,在其中加入创建帖子功能和投票功能。
在 contracts 文件夹下创建智能合约文件 DReddit.sol,添加如下代码:
pragma solidity ^0.5.0;contract DReddit {}
一个帖子至少应该包含创建日期,内容和创建者信息,本文帖子创建者用以太坊地址来指代,用于存储帖子的帖子结构体应该是这样:
struct Post { uint creationDate; bytes description; address owner;}
上述结构体只能用来存储单个帖子,在多个帖子场景中,我们需要添加一个数组来存储多个帖子结构体,代码如下:
Post [] public posts;
a)新建帖子
创建函数 createPost,其中参数 _description 是用来表示帖子内容的字节型数据。
function createPost(bytes _description) public { uint postId = posts.length ; posts[postId] = Post({ creationDate: block.timestamp, description: _description, owner: msg.sender });}
在函数中,我们为存储的帖子创建一个序号 id ,然后使用刚刚定义的帖子结构体 Post 创建一个新的实例。
b)发布帖子
创建一个新的事件类型 NewPost,代码如下:
event NewPost( uint indexed postId, address owner, bytes description)
定义完成后,在新建帖子函数 createPost 中使用所需的数据执行 NewPost:
function createPost(bytes _description) public { ... emit NewPost(postId, msg.sender, _description);}
c)好评/差评
DReddit 允许用户对帖子进行好评差评投票。为实现这一功能,我们需要使用投票计数器来扩展之前定义的帖子结构体 Post,并引入一个代表投票类型的枚举结构。为了方便前端应用程序调用,我们需要添加一个新建投票事件 NewVote。完成后,我们还需要添加一个用来执行投票的方法。
首先,定义一个表示投票种类的枚举类型 Ballot,其中可选的投票类型包括好评 UPVOTE、差评 DOWNVOTE、不投票 NONE:
enum Ballot { NONE, UPVOTE, DOWNVOTE }
为存储每个帖子中的投票纪录,我们需要在帖子结构体 Post 中相应地加入“好评”投票计数器和“差评”投票计数器。为确保用户不会重复投票,我们还需要添加一个用来存储所有已投票用户以及投票的映射:
struct Post { ... uint upvotes; uint downvotes; mapping(address => Ballot) voters;}
现在的新建投票事件 NewVote 应该如下所示:
event NewVote( uint indexed postId, address owner, uint8 vote);
由于帖子结构体 Post 中加入了投票计数器,需要用新的结构体更新 createPost() :
function createPost(bytes _description) public { ... posts[postId] = Post({ ... upvotes: 0, downvotes: 0 });}
现在万事俱备,只欠投票函数 vote() 了!!!
函数的参数 _vote 就是我们刚刚定义的投票枚举类型 Ballot ,它的取值为 0、1、2 这三个无符号整数,分别对应三种类型的投票。
使用 Solidity 的 require() 语句确保用户只能对实际存在的帖子进行投票及用户不能对同一个帖子多次投票。
在函数中,我们用当前的投票类型更新“好评”投票计数器或“差评”投票计数器,存储已投票用户的信息并发出新建投票事件 NewVote:
function vote(uint _postId, uint8 _vote) public { Post storage post = posts[_postId]; require(post.creationDate != 0, "Post does not exist"); require(post.voters[msg.sender] == Ballot.NONE, "You already voted on this post"); Ballot ballot = Ballot(_vote); if (ballot == Ballot.UPVOTE) { post.upvotes ; } else { post.downvotes ; } post.voters[msg.sender] = ballot; emit NewVote(_postId, msg.sender, _vote);}
d)判断用户是否可以投票
在前端中,我们希望向用户展示自己是否已经对帖子进行了投票。为此,定义一个可以判断用户能否对帖子投票的 API 将大大简化这个过程。判断用户是否可以投票的过程非常简单,只需要判断该帖子中是否存在该用户的投票,判断代码如下:
function canVote(uint _postId) public view returns (bool) { if (_postId > posts.length - 1) return false; Post storage post = posts[_postId]; return (post.voters[msg.sender] == Ballot.NONE);}
e)获取投票信息
如果你想浏览自己过去的投票信息怎么办?很简单,一个简单的函数 getVote() 就可以实现,代码如下:
function getVote(uint _postId) public view returns (uint8) { Post storage post = posts[_postId]; return uint8(post.voters[msg.sender]);}
到这里,部署智能合约大功告成!
使用 EmbarkJS 测试智能合约
前面已经部署了 DReddit 智能合约,并在智能合约中实现了发布帖子和给帖子投票的功能,接下来就需要使用 Embark 框架为智能合约编写一些测试。
1、编写第一个测试
先从最简单的功能开始测试。
首先,我们需要在测试文件夹 test 中创建一个测试文件 DReddit_spec.js,然后在测试文件中添加一个智能合约代码块 contract(),在这个代码块中编写测试用例,结构如下:
contract('DReddit', () => {});
你可以将智能合约函数 contract() 视为分组测试的“分组”功能。为检查测试设置是否能够正常工作,需要先添加一个简单的测试:
contract('DReddit', () => { it ('should work', () => { assert.ok(true); });});
运行测试命令 embark test ,输出如下:
所有测试都成功通过,接下来测试一些实际的功能!
2、测试帖子的创建过程
测试创建帖子:首先以某种方式在 JavaScript 中导入 DReddit 智能合约的实例,然后调用智能合约中的各个方法测试它们能否正常工作,同时我们还需要配置测试环境来正确创建智能合约的实例。
a)导入智能合约实例
在运行测试时, Embark 框架会在全局范围加入一些必要的自定义函数和对象。其中一个就是自定义获取函数 require() ,它可以帮助我们从特定的 Embark 路径中导入智能合约实例。
就比如说,为了在测试中导入 DReddit 智能合约的实例,我们需要在 spec 文件中添加如下的命令:
const DReddit = require('Embark/contracts/DReddit');
DReddit 现在被指定为一个 EmbarkJS 的智能合约实例,我们需要使用设置函数 config() 让 Embark 框架知道,我们需要的智能合约都有哪些。设置函数 config(),以便 Embark 框架知道我们需要哪些智能合约:
config({ contracts: { DReddit: {} }});
这个操作与配置智能合约的操作非常相似,实际上它就相当于在测试环境中配置智能合约。我们将所需的智能合约作为参数,通过配置对象将它传递给设置函数 config()。在我们这个应用程序中,需要设置的参数只有 DReddit,这是因为我们的智能合约并不需要构造函数。
b)测试创建帖子函数 createPost()
导入好智能合约实例之后,我们就可以测试智能合约的创建帖子函数 createPost() 了。不过在定义 createPost 函数时,我们指定了帖子的描述为字节形式,如何测试呢?
首先我们需要说明的是为什么要用字节形式的数据。我们都知道,帖子的长短不好控制,有些帖子很长,有些帖子很短,所以最好的方案就是将帖子的描述(内容)存储在一个并不在意数据大小的地方,而在智能合约之中存储的只是帖子描述的哈希值。通过使用哈希值我们可以保证数据的索引与数据一一对应,同时智能合约中存储的数据索引始终具有相同的长度,所以我们将帖子真正的描述存储在 IPFS 中,而创建帖子函数 createPost 中的帖子描述实际上是帖子描述的 IPFS 哈希值。
在得到帖子描述的哈希值后(代码中选用之前准备好的哈希值),我们可以使用 Web3 程序库的 fromAscii() 工具函数将该哈希值转换为字节,然后使用智能合约的创建帖子函数 createPost 将它发送出去。在测试时,我们可以检索刚才发出的事件,并检查它的返回值,这些操作的代码如下所示:
...const ipfsHash = 'Qmc5gCcjYypU7y28oCALwfSvxCBskLuPKWpK4qpterKC7z';contract('DReddit', () => { ... it ('should be able to create a post and receive it via contract event', async () => { const receipt = await DReddit.methods.createPost(web3.utils.fromAscii(ipfsHash)).send(); const event = receipt.events.NewPost; postId = event.returnValues.postId; assert.equal(web3.utils.toAscii(event.returnValues.description), ipfsHash); });});
运行测试命令 embark test ,两条测试都测试通过!
3、测试数据的正确性
需要测试的另外一个功能是,存储的数据(帖子的描述,所有者)是否能解析回正确的数据。这就要用到先前定义的全局可见的帖子序号 postId。我们还需要执行与先前测试类似的检查,如果要测试帖子的所有者数据是否正确,我们首先需要访问创建帖子的帐户。
Embark 框架的设置函数 config 可以让我们轻松地访问钱包帐户,我们所要做的就是将一个解析处理程序加入到设置函数 config 中并存储传递的值:
...let accounts = [];config({ contracts: { DReddit: {} }}, (err, _accounts) => { accounts = _accounts;});
完成了操作后,测试代码如下:
it ('post should have correct data', async () => { const post = await DReddit.methods.posts(postId).call(); assert.equal(web3.utils.toAscii(post.description), ipfsHash); assert.equal(post.owner, accounts[0]);});
注意到,代码中引用了帐户 accounts[0],但仅仅通过查看代码,我们无法确定账户 account [0] 是否是我们指定的那个账户。而 Embark 框架可以帮助我们解决这个问题,在设置完帐户后,Embark 框架会自动将钱包的第一个帐户(accounts [0])设置为用于发起交易的默认帐户。这种特性让我们可以确定,账户 accounts [0] 会是帖子的所有者。
另一种方法是将所有帐户发送给智能合约的 send() 函数,在这种情况下,我们可以决定使用哪个账户发起交易。
4、测试能否投票函数 canVote()
接下来我们来测试能否投票函数 canVote() 是否按预期的方式工作。很简单,用户不能给不存在的帖子投票,因此测试只需要用能否投票函数判断不存在的帖子序号 postId。测试代码如下:
it('should not be able to vote in an unexisting post', async () => { const userCanVote = await DReddit.methods.canVote("123").call(); assert.equal(userCanVote, false);});
不过,当用户确实可以给某个帖子投票时,我们要确保能否投票函数 canVote() 的返回值是能 true ,我们需要用该函数来判断之前存储的帖子序号 postId:
it('should be able to vote in a post if account has not voted before', async () => { const userCanVote = await DReddit.methods.canVote(postId).call(); assert.equal(userCanVote, true);});
很棒,我们现在完成了 5 个测试!
5、测试投票函数 vote()
投票功能可谓是我们应用程序的核心功能,因而对它的测试是重中之重。我们有许多种不同的方法验证投票函数 vote() 的功能是否符合预期,但在本教程中,我们只检查新建投票事件 NewVote 发出投票的所有者帐户是否与真正执行投票的帐户相同,在代码实现中我们可以借鉴先前的测试:
it("should be able to vote in a post", async () => { const receipt = await DReddit.methods.vote(postId, 1).send(); const Vote = receipt.events.NewVote; assert.equal(Vote.returnValues.owner, accounts[0]);});
5、测试每个用户每个帖子只能投一票
在智能合约定义中,我们设置了每个用户对每个帖子只能投一票,因而最后一个也是最必要的一个测试就是检查智能合约是否允许用户在同一帖子上多次投票。这个测试中我们又用到了 async / await 异步操作的方法,同时还用到了 try / catch 来更好地进行测试。当用户对一个已经投过票的帖子再次进行投票时,投票函数 vote() 将执行失败,这个操作我们可以使用断言( assert )方法来实现:
it('should not be able to vote twice', async () => { try { const receipt = await DReddit.methods.vote(postId, 1).send(); assert.fail('should have reverted'); } catch (error){ assert(error.message.search('revert') > -1, 'Revert should happen'); }});
代码看起来可能会让你有些困惑,但实际上它的逻辑非常直接。如果投票函数 vote() 执行失败,我们不应该调用函数 assert.fail() ,而应该立即进入 catch() 部分。如果结果不是这样,那么就说明测试发现了问题,这种测试方法其实就是大名鼎鼎的负向( Negative )测试。
到这里,也就是我们最后一次运行 embark test 进行测试了,如果一切正常的话,测试的输出应该如下所示,也就是说,我们已经完全覆盖了所有的测试!快为自己点个赞!
❯ embark testCompiling contracts DReddit ✓ should work (0ms) - [0 gas] ✓ should be able to create a post and receive it via contract event (60ms) - [160689 gas] ✓ post should have correct data (18ms) - [0 gas] ✓ should not be able to vote in an unexisting post (14ms) - [0 gas] ✓ should be able to vote in a post if account hasn't voted before (12ms) - [0 gas] ✓ should be able to vote in a post (42ms) - [65115 gas] ✓ shouldn't be able to vote twice (37ms) - [22815 gas] 7 passing (5s) - [Total: 3130955 gas] > All tests passed
由于下一部分篇幅过长,我们将在下一篇文章中介绍如何使用 React 框架作为客户端前端 JavaScript 库来构建 DReddit 前端界面。主要包括以下 5 部分:
渲染组件
构建创建帖子组件 CreatePost
构建帖子组件 Post
构建帖子列表组件 List
添加投票功能
老铁们,敬请期待