精通以太坊(四)

98 阅读35分钟

精通以太坊(四)

原文:zh.annas-archive.org/md5/119107c4466aa665f9e9ebea52f51e20

译者:飞龙

协议:CC BY-NC-SA 4.0

第三部分:以太坊实现

本节的目标是利用区块链技术创建高级生产级项目,以便我们可以建立履历或进行众筹。

本节包括以下章节:

  • 第十章,以太坊区块链上的机器学习

  • 第十一章,创建基于区块链的社交媒体平台

  • 第十二章,创建基于区块链的电子商务市场

  • 第十三章,创建去中心化银行和借贷平台

第十章:以太坊区块链上的机器学习

区块链和人工智能是近年来最有趣的话题,这有充分的理由:它们是最先进的技术,已经被创造出来以颠覆大多数已建立的业务。我们能够教会计算机自己学习是一件非常强大的事情,这意味着未来的机器学习系统将继续发展。同样的道理适用于区块链:分布式计算领域刚刚开始,它将成为未来大多数问题的默认解决方案。那么为什么不将两者结合起来进行革命性的发明呢?事实证明它们很好地结合在一起,我们可以创建非常有趣的 dApps,从两个领域中受益,特别是通过利用它们来创建分布式市场,以解决机器学习问题,并奖励用户的计算能力。

在本章中,我们将涵盖以下主题:

  • 理解机器学习

  • 分布式机器学习市场

  • 构建智能合约机器学习市场

理解机器学习

机器学习ML)是人工智能AI)的一个子集,而后者又是数据科学更广泛主题中的一个领域。ML 专注于创建能够自己学习以解决特定问题的程序,而无需编写所有逻辑;我们只需要给它们大量的输入。试错是主要机制,机器慢慢地学会如何解决问题的正确输出。

计算机诞生的时刻,科学家们就问自己,“我们如何让这台机器像人类一样思考和行动?”这就是为什么了解计算机如何学习始于了解人类如何看待世界。

想一想:你认为动物和人类如何学会在我们生活的危险而混乱的世界中生存?通过向他人学习吗?嗯,那是一种有效的学习系统,但我们真正了解的一切都来自于在面对不确定性时进行实验。想象一下这样的场景:你处于一个原始世界,语言尚未发明——我们谈论的是数千年前。你在地上看到一个扁平而闪亮的红色物体,这对你来说是完全新的。你如何开始理解它呢?它可能是能够杀死你的东西,也可能是能够为你提供新材料的东西。你还不知道,所以你开始尝试不同的事情,始终保持警惕,因为你的主要目标是生存。你用一根棍子触摸它:没有反应。你用手触摸它:感觉温暖。你抓住它:感觉坚固,于是你试图打破它,没有成功。经过更多的实验,你得出结论:你手里拿着的是一个坚固的、自然形成的金属圆盘,你可以利用它的力量用太阳烹饪食物。

所有这些具体的知识都是通过试验使用试错机制得来的。我的观点是,这就是我们发现了当前世界上我们所知道的一切的方式,也是机器学习算法用来自行解决问题的系统。你给它们大量信息,它们就用它们的工具进行实验,这些工具通常是图像的逐像素读数和数据的字节,以生成结果。它们被用来预测未来,考虑到一些初始条件,以理解无法用经典编程解决的复杂问题,并创建帮助我们做得更好的工具。

从技术上讲,创建一个机器学习系统有三个步骤,如下所示:

  1. 收集关于一个主题的大量信息,例如 2,000,000 张独特的水瓶图片。

  2. 开发一个机器学习模型,生成所需的输出。在我们的例子中,假设我们想要创建一个模型,根据它们的形状、大小、颜色、化学成分和纯度对水瓶进行分类,因为我们需要找到最适合人类使用的水。这些属性被称为标签,因为它们是对每个组成部分的精确描述。

  3. 模型在一个称为训练的过程中消耗所有这些数据,在这个过程中,它调整了我们水瓶的每个组件的重要性,以计算哪些因素决定了最佳水的可能性。在某个时候,它将被训练,意味着它将理解构成最佳水的属性,生成一个我们可以使用的程序,以快速确定特定新水瓶的好坏。

这只是一个例子,说明了我们如何使用机器学习来提供复杂问题的解决方案,例如,我可以为了最佳健康状况消费什么样的水?一个危险的人看起来像什么?我怎样教我的相机判断它所看到的是狗还是猫?

一般来说,步骤是获取数据 -> 创建使用该数据创建程序的模型 -> 将程序用于特定情况。还有许多其他不同的系统,程序通过试错来自行获取数据。其他有趣的机器学习算法在生物水平上工作,教导机器人像真实生活中的动物一样行动,以便像它们一样学习和看待世界。

这是一个非常热门的话题,在未来几年将继续增长,以回答人类可能提出的最复杂的问题和疑问。这就是为什么我建议你探索广阔的人工智能世界。在将其与区块链相结合之前,看看有什么可以利用的东西。

分散式机器学习市场

我们将建立一个市场,用于购买和出售具有强大 GPU 的用户的计算能力,并希望帮助其他人执行机器学习,以教会他们的算法根据监督学习完成任务,其中程序学会根据给定目标从大量输入中生成所需输出,以便自我编程。

当我们需要处理我们的 ML 市场中发生的交易的永久记录以及买家根据其参数请求的训练模型时,以太坊就会介入其中,以便随时可以访问。其理念是创建一个地方,让全世界的人们可以开始通过新的硬件用途来赚钱,作为挖矿的替代方案,同时还提供了一个安全的 ML 算法系统。

我们将使用 GPU 来训练我们的机器学习程序,因为它们非常擅长同时处理大量并行操作,这样我们就可以快速处理大批输入,比使用 CPU 更快。我们还将使用以太坊作为默认支付货币,以便轻松处理分散式交易。

如今,大多数机器学习模型都是基于神经网络NN)的,这是对人脑工作原理的抽象,转化为计算机语言。它基于虚拟的个体神经元,接收输入并在满足条件时产生输出。例如,假设一个简单的神经元包含以下语句:

if(input > 10) return output = true;

如果输入大于 10,则该语句将返回一个正值。这个函数被称为激活函数,因为只有在函数满足条件时才会激活。我们可以使用不同的参数和配置将许多这些神经元组合在一起,得到所谓的神经网络,它可以处理复杂的输入以生成精确的输出。在训练时,我们会重新调整激活函数以更好地适应我们的目标。一旦设置好了我们的模型,这一切都会自动完成。最终,我们得到了一个经过训练的程序,能够回答复杂的问题,而无需编写每个特定情景的代码。

一旦模型从我们的训练数据集中调整好,我们就可以用来自不同来源的新输入来测试它,以确定它是否生成了最优输出。这很重要,因为存在过拟合的风险,即机器学习程序进行了过多的优化,变得过于特定于我们的初始输入,这样就无法从新数据中产生有效结果。这就像一个必须从头开始成为全科医生的外科医生:它不会产生很好的结果,因为它太专业化了。

一些著名的激活函数是 Sigmoid 和 ReLU。深度学习是将多层神经元堆叠在一起的过程,以便神经元的输出传递到另一个神经元,从而获得更高级的结果。这些网络被称为深度神经网络DNNs),因为它们由多层组成。一定要自己探索神奇的神经网络世界,了解未来技术是如何塑造的。

在这里我们不会使用神经网络,因为由于区块链的限制,从头开始在Solidity上实现它们很困难,所以我们将使用您可以根据需要扩展的更简单的算法。这是我们的协议将如何工作的简要说明:

  1. 用户向智能合约以以太币的形式发布一组数据、一个评估函数(我们的机器学习模型)以及完成任务的奖励。

  2. 那些希望完成任务的人将从第一个用户那里下载发布的数据,以训练给定的机器学习模型,以生成一个训练良好的程序,然后将其返回给智能合约。

  3. 外部用户将查看针对该特定任务发布的所有解决方案,以确定谁是赢家。买家将根据自己的偏好确定赢家。

从这个协议中,我们可以建立用户将遵循的以下流程:

  1. 买家,即想要训练他们的模型的人,部署一个智能合约,其中包含以下数据:

    • 构造函数中的模型定义——例如,DNN。

    • 要训练的数据集——例如,由 30 x 30 像素制成的手写数字图像数组。每个图像都是一个 30 x 30 像素(900 像素)的数组,其中每个像素又是另一个数组,包含有关像素位置以及它是黑色还是白色的信息(我们不希望在这个图像中使用颜色,以避免复杂性)——例如 [[0, true], [1, false]] 将表示一个 2 x 1 像素的图像,其中第一个像素是黑色,而另一个是白色。这些数据集将发布到一个外部网站,人们可以自由访问以训练模型。在我们的构造函数中,我们将提供一个 URL,即https://example.com/dataset

    • 训练模型的奖励以以太坊支付,并在可支付的构造函数中设置了此安排。

  2. 合约被发布,卖家开始参与训练模型的任务。从数据集中,90%的数据将用于训练模型,而剩下的 10%将用于测试程序的结果,以验证其准确性。为了确保卖家不会彼此抄袭,将向不同的参与者提供不同的随机数据集。

  3. 买家决定哪个模型对他们最有效,并选择一个赢家。如果到达到期时间而买家尚未选择赢家,则第一个参与者将获得奖励。

对于我们的机器学习市场,我们将在 Solidity 中使用一个简单的线性回归机器学习算法。用户将提交包含名称和两个数字参数以进行预测的数据。线性回归是两个因素之间的关系,例如,网站销售量与访问者数量。在这种情况下,我们可以建立一个模型,使我们能够预测给定访问者数量的销售量。

简单线性回归模型可以应用于许多领域,其中一个变量取决于另一个变量,它是可用的最简单的机器学习系统之一。这就是为什么我们将使用它的原因,因为重要的是能够在 Solidity 中重新创建它,以验证其他用户提供的解决方案。理想情况下,我们将实现一个 NN 或更复杂的模型,但考虑到区块链的限制,这将需要太多的时间来开发。您可以借鉴本章的教训来扩展市场。在下一节中,您将学习如何创建市场所需的代码。

构建智能合约机器学习市场

我们的机器学习市场将专门使用线性回归算法,以简化流程,让您了解它们如何紧密联系。我鼓励您扩展解决方案,以练习您的机器学习和区块链技能。要应用简单的线性回归算法,我们需要以下内容:

  • 一个预测函数,用于从数据中生成预测

  • 一个组合预测结果的成本函数

  • 用于训练我们的算法的优化算法使用梯度下降,这将微调预测以获得更精确的结果

  • 一个训练函数来改善我们的算法

预测函数

首先,您需要了解,我们的简单线性回归算法使用以下函数预测值:

y = weight * x + bias

如果我们根据网站访问者数量预测销售量,我们的预测函数将如下所示:

Sales = weight * visitors + bias

我们的目标是获得固定的权重和偏差值,优化我们的预测函数,以便我们获得销售的真实估计。例如,经过训练的线性回归会是这样的:

Sales = 0.43 * visitors + 0.9

我们在给定数据集上训练后得到了0.43的权重和0.9的偏差。我们应该能够使用该优化函数来为我们特定的需求做出准确的预测,从而取得出色的结果。我们需要在 Python 和 Solidity 中实现预测函数,因为卖家将使用 Python 训练模型,而我们将使用 Solidity 来验证这些卖家给出的结果。以下是我们市场的 Python 和 Solidity 中的prediction函数的样子:

# Python implementation
def prediction(x, weight, bias):
    return weight * x + bias

供您参考,这是我们将添加的 Solidity 函数,允许卖家和买家通过进行预测来验证模型的准确性:

// Solidity implementation
function prediction(uint256 _x, uint256 _weight, uint256 _bias) public pure returns(uint256) {
 return _weight * _x + _bias;
}

成本函数

要训练我们的线性回归算法以生成准确的预测,我们需要一个成本函数。成本函数是分析我们的预测函数在数据集中工作效果如何的一种方法。它给了我们一个错误率,这实际上是真实结果与预测之间的差异。错误越小,我们做出的预测就越好。成本函数将真实结果和预测作为输入,输出我们模型的错误,如下所示:

error = result - prediction

在我们的案例中,有许多不同类型的成本函数。在这种情况下,我们将使用均方误差MSE)成本函数,它看起来像这样:

error = sum((result - prediction)2) / numberOfDataPoints

为了使其更清晰,我们可以添加具有所有参数的预测函数,以便您可以看到变量在成本函数中的作用,如下面的代码所示:

error = sum((result - prediction(x, weight, bias))2) / numberOfDataPoints

在这里,sum()是所有真实结果减去预测的平方的总和,所有结果数据集的总和。所有这些都被数据点的数量除以。请记住,result是我们试图预测的实际值。例如,回到我们之前的例子,我们试图预测每位访客将获得多少销售额,result将是10个销售额,这来自 200 位访客,而预测是我们从权重和偏差得出的估计值。

为了帮助您更好地理解该函数,考虑下面的假想数据集的示例:一个国家的假枪支持有和每个国家的犯罪数;在这个例子中,我们有兴趣了解枪支数量如何影响每个国家的犯罪数。利用这些数据,我们可以预测犯罪,以便我们可以调动特定数量的警察来处理这些情况。请记住,这是虚假数据,用来说明成本函数的工作原理:

国家枪支总数每年犯罪数量
德国3,52020
爱沙尼亚1923
巴哈马910
巴西9,27188

我们首先用随机权重和偏差初始化我们的预测函数,如下所示的代码:

// Our prediction function definition for you to remember how it looked like
y = weight * x + bias

// Our prediction function with random weight and bias
prediction = 0.1 * x + 0.4

德国的犯罪预测如下所示:

prediction = 0.1 * 3520 + 0.4 = 352.4 crimes per year

我们得到了352.4起犯罪,我们可以近似为 352,因为用小数点谈论犯罪没有意义。正如你所看到的,我们使用该权重和偏差的预测比每年 20 起犯罪的真实结果要高,因为我们的模型尚未训练,所以预计会有巨大的差异。

然后我们计算所有这些值的成本函数。让我们看看德国的情况如何:

// Our cost function definition for you to remember how it looked like
error = sum((result - prediction)2) / numberOfDataPoints

// Our cost function for the initial dataset
error = sum((20 - 352)2) / 1

我们正在将成本函数应用于一个数据点,以查看初始预测的错误,以便您可以看到它是如何应用的。这是结果:

error = (20 - 352)2 / 1 = 110224 

误差为110224,这是一个巨大的数字,因为我们将其应用于一个数据点,而且我们的模型尚未训练。现在对所有数据点执行相同操作,直到您为整个数据集生成误差。希望您可以通过该示例了解计算该误差的过程。

我们需要计算误差,以优化我们的预测函数,以便稍后进行更准确的预测。现在概念已经清楚,我们可以在 Python 中实现该函数。在 Solidity 中,我们希望它能够从特定市场解决方案计算误差,以便丢弃那些具有过大误差的市场解决方案。Python 中的cost函数将被买家用于验证其训练结果,并且将被 Solidity 中的卖家用于验证提交。让我们看看以下代码:

# The cost function implemented in python
def cost(results, weight, bias, xs):
    error = 0.0
    numberOfDataPoints = len(xs)
    for i in range(numberOfDataPoints):
        error += (results[i] - (weight * xs[i] + bias)) ** 2
    return error / numberOfDataPoints

xs 参数是独立变量的数组,x——我们在预测函数中看到的。在 Solidity 中它看起来是这样的;因为它是一个纯函数,我们不用担心燃气成本,因为一切都将在本地执行,而不必从区块链修改状态:

// The cost function implemented in solidity
function cost(int256[] memory _results, int256 _weight, int256 _bias, int256[] memory _xs) public pure returns(int256) {
    require(_results.length == _xs.length, 'There must be the same number of _results than _xs values');
    int256 error = 0; // Notice the int instead of uint since we want negative values too
    uint256 numberOfDataPoints = _xs.length;
    for(uint256 i = 0; i < numberOfDataPoints; i++) {
        error += (_results[i] - (_weight * _xs[i] + _bias)) * (_results[i] - (_weight * _xs[i] + _bias));
    }
    return error / int256(numberOfDataPoints);
}

正如您所看到的,我们将预测函数包含在for循环中,以计算结果减去预测的平方,以便我们可以从cost函数计算误差。这将由希望优化买家的特定线性回归的卖家使用,以进行准确的预测。

优化算法

现在我们可以在给定一些参数的情况下进行预测,并使用成本函数计算这些预测的精度,我们必须努力改进这些预测,通过减小由成本函数生成的误差。我们如何减小成本函数生成的误差?通过使用优化算法调整我们的预测函数的权重和偏差。在这种情况下,我们将使用梯度下降,这使我们能够不断减小误差。以下是说明其工作原理的图表:

我们从由随机权重和偏差值引起的高误差开始,然后通过优化这些参数来减小误差,直到我们达到足够好的预测模型,即图表中的局部最小值。想法是计算权重偏差的偏导数,看它们如何影响最终预测,直到我们达到最小值。我们不会探讨计算这些偏导数的数学,因为它可能导致混淆,因此带有偏导数的结果函数如下所示:

weightDerivative = sum(-2x * (result - (x * weight + bias))) / numberOfDataPoints

biasDerivative = sum(-2 * (result - (x * weight + bias))) / numberOfDataPoints

让我们来看看更新机器学习算法的权重和偏差的这些函数的实现:

# Python implementation, returns the optimized weight and bias for that step
def optimizeWeightBias(results, weight, bias, xs, learningRate):
    weightDerivative = 0
    biasDerivative = 0
    numberOfDataPoints = len(results)
    for i in range(numberOfDataPoints):
        weightDerivative += (-2 * xs[i] * (results[i] - (xs[i] * weight + bias)) / numberOfDataPoints)
        biasDerivative += (-2 * (results[i] - (xs[i] * weight + bias)) / numberOfDataPoints)

    weight -= weightDerivative * learningRate
    bias -= biasDerivative * learningRate
    return weight, bias

在 Solidity 中,它看起来像这样:

// Solidity implementation
function optimize(int256[] memory _results, int256 _weight, int256 _bias, int256[] memory _xs, int256 _learningRate) public pure returns(int256, int256) {
    require(_results.length == _xs.length, 'There must be the same number of _results than _xs values');
    int256 weightDerivative = 0;
    int256 biasDerivative = 0;
    uint256 numberOfDataPoints = _xs.length;
    for(uint256 i = 0; i < numberOfDataPoints; i++) {
        weightDerivative += (-2 * _xs[i] * (_results[i] - (_xs[i] * _weight + _bias)) / int256(numberOfDataPoints));
        biasDerivative += (-2 * (_results[i] - (_xs[i] * _weight + _bias)) / int256(numberOfDataPoints));
    }
    _weight = weightDerivative * _learningRate;
    _bias = biasDerivative * _learningRate;
    return (_weight, _bias);
}

如你所见,我们通过使用前面代码块中描述的函数来计算两个导数,以便我们可以使用优化后的值更新权重和偏置。学习速率是我们达到图表最小点的步长大小。如果我们迈出的步子太大,我们可能会错过最小值,如果我们迈出的步子太小,可能需要太长时间才能到达那个最小值。无论如何,最好保持一个平衡的学习速率并尝试不同的步长。现在我们有了改进我们预测函数的方法。

训练函数

我们可以开始通过一个新的函数来改进我们的模型,该函数循环执行多个优化调用,直到达到最小值,此时模型将完全优化。代码如下所示:

# Python implementation
def train(results, weight, bias, xs, learningRate, iterations):
 error = 0 for i in range(iterations):
    weight, bias = optimizeWeightBias(results, weight, bias, xs, learningRate)
    error = cost(results, weight, bias, xs)
    print("Iteration: {}, weight: {:.4f}, bias: {:.4f}, error: {:.2}".format(i, weight, bias, error))
 return weight, bias

Solidity 实现看起来非常相似,尽管我们必须确保结果和独立变量的值具有相同的长度,以避免错误,如下面的代码所示:

// Solidity implementation
function train(int256[] memory _results, int256 _weight, int256 _bias, int256[] memory _xs, int256 _learningRate, uint256 _iterations) public pure returns(int256, int256) {
    require(_results.length == _xs.length, 'There must be the same number of _results than _xs values');
    int256 error = 0;
    for(uint256 i = 0; i < _iterations; i++) {
        (_weight, _bias) = optimize(_results, _weight, _bias, _xs, _learningRate);
        error = cost(_results, _weight, _bias, _xs);
    }
    return (_weight, _bias);
}

如你所见,我们正在使用优化函数和成本函数连续减少误差,通过更新权重和偏置参数来进行指定次数的迭代。

现在你应该能够创建和训练线性回归模型,使用train函数训练你的模型后,使用预测函数进行预测。以下是完整的 Python 代码供你参考,尽管你可以在官方 GitHub 上查看更新版本,链接为github.com/merlox/machine-learning-ethereum/blob/master/linearRegression.py

我们首先创建构造函数,该构造函数将使用uniform库训练模型,并使用初始随机值,因为它返回 0 到 1 之间的浮点数,如下面的代码所示:

from random import uniform

class LinearRegression:
    xs = [3520, 192, 91, 9271]
    results = [20, 3, 0, 88]

    def __init__(self):
        initialWeight = uniform(0, 1)
        initialBias = uniform(0, 1)
        learningRate = 0.00000004
        iterations = 2000
        print('Initial weight {}, Initial bias {}, Learning rate {}, Iterations {}'.format(initialWeight, initialBias, learningRate, iterations))
        finalWeight, finalBias = self.train(self.results, initialWeight, initialBias, self.xs, learningRate, iterations)
        finalError = self.cost(self.results, finalWeight, finalBias, self.xs)
        print('Final weight {:.4f}, Final bias {:.4f}, Final error {:.4f}, Prediction {:.4f} out of {}, Prediction Two {:.4f} out of {}'.format(finalWeight, finalBias, finalError, self.prediction(self.xs[1], finalWeight, finalBias), self.results[1], self.prediction(self.xs[3], finalWeight, finalBias), self.results[3]))

然后,我们实现predictioncost函数,就像你刚学的一样,放在构造函数下面,如下面的代码所示:

    # Python implementation
    def prediction(self, x, weight, bias):
        return weight * x + bias

    # The cost function implemented in python
    def cost(self, results, weight, bias, xs):
        error = 0.0
        numberOfDataPoints = len(xs)
        for i in range(numberOfDataPoints):
            error += (results[i] - (weight * xs[i] + bias)) ** 2
        return error / numberOfDataPoints

然后,我们添加了优化的权重和偏置函数,如下面的代码所示:

    # Python implementation, returns the optimized weight and bias for that step
    def optimizeWeightBias(self, results, weight, bias, xs, learningRate):
        weightDerivative = 0
        biasDerivative = 0
        numberOfDataPoints = len(results)
        for i in range(numberOfDataPoints):
            weightDerivative += -2 * xs[i] * (results[i] - (xs[i] * weight + bias))
            biasDerivative += -2 * (results[i] - (xs[i] * weight + bias))

        weight -= (weightDerivative / numberOfDataPoints) * learningRate
        bias -= (biasDerivative / numberOfDataPoints) * learningRate

        return weight, bias

最后,我们通过在类的作用域之外创建train函数并初始化类来完成代码,如下面的代码所示:

    # Python implementation
    def train(self, results, weight, bias, xs, learningRate, iterations):
        error = 0
        for i in range(iterations):
            weight, bias = self.optimizeWeightBias(results, weight, bias, xs, learningRate)
            error = self.cost(results, weight, bias, xs)
            print("Iteration: {}, weight: {:.4f}, bias: {:.4f}, error: {:.2f}".format(i, weight, bias, error))
        return weight, bias

# Initialize the class
LinearRegression()

如你所见,我们创建了一个 Python 类,在构造函数中运行train函数。如果你对 Python 不熟悉,不要担心;你只需要理解这段代码正在训练我们的线性回归算法进行更精确的计算。创建一个名为linearRegression.py的文件,并将代码写入其中。然后你可以用以下命令行运行它:

python linearRegression.py

你会看到程序不断通过向最小值迈出小步骤来减少误差,直到它达到一个不太改善的程度。这没关系:我们希望它能做出精确的预测,但不一定 100% 准确。然后,你可以用最终的权重和偏置来对那个机器学习模型进行预测。

让我们看一下智能合约市场,看看用户将如何与之交互。我们的目标是提供一个地方,让机器学习开发人员可以上传其模型,并以以太币支付,目的是从几个卖家中获得解决方案,然后根据错误或买家的选择选择一个赢家。让我们看一下以下代码:

pragma solidity 0.5.5;

contract MachineLearningMarketplace {}

我们可以开始添加变量来创建我们想要的应用程序,如下所示:

pragma solidity 0.5.5;

contract MachineLearningMarketplace {
    event AddedJob(uint256 indexed id, uint256 indexed timestamp);
    event AddedResult(uint256 indexed id, uint256 indexed timestamp, address indexed sender);
    event SelectedWinner(uint256 indexed id, uint256 indexed timestamp, address indexed winner, uint256 trainedIdSelected);

    struct Model {
        uint256 id;
        string datasetUrl;
        uint256 weight;
        uint256 bias;
        uint256 payment;
        uint256 timestamp;
        address payable owner;
        bool isOpen;
    }
    mapping(uint256 => Model) public models;
    mapping(uint256 => Model[]) public trainedModels;
    uint256 public latestId;
}

我们添加了三个事件来通知用户已添加了新作业或结果,以及何时选择了提案的获胜者。这样,人们就会在他们的提案被更新时收到通知。然后,我们有一个名为 Model 的结构体,它代表我们希望的线性回归 ML 模型,其中包括数据集、权重、偏差和支付等重要变量。最后,我们添加了一对映射,以对买家创建的模型(那些支付来让他们的模型训练)和卖家创建的模型进行排序,后者训练数据集并上传特定的权重和偏差,以便在被买家选中时赢取。latestId 是一个标识符,表示哪个模型是最新的。

开放的模型意味着它仍在运行,因此您可以发送提案并参与其中,以获得被选中的机会。如果它已关闭,您仍然可以参与,但要知道您将无法获胜,因为获胜者已经被选定。

让我们继续讨论我们 ML 市场的三个最重要的功能。上传作业功能如下所示:

/// @notice To upload a model in order to train it
/// @param _dataSetUrl The url with the json containing the array of data
function uploadJob(string memory _dataSetUrl) public payable {
    require(msg.value > 0, 'You must send some ether to get your model trained');
    Model memory m = Model(latestId, _dataSetUrl, 0, 0, msg.value, now, msg.sender, true);
    models[latestId] = m;
    emit AddedJob(latestId, now);
    latestId += 1;
}

这是上传结果功能,其中添加了一些文档以澄清内部使用的参数:

/// @notice To upload the result of a trained model
/// @param _id The id of the trained model
/// @param _weight The final trained weight, it must be with 10 decimals meaning that 1 weight is 1e10 so that you can do smaller fractions such as 0.01 which would be 1e8 or 100000000
/// @param _bias The final trained bias, it must be with 10 decimals as the weight
function uploadResult(uint256 _id, uint256 _weight, uint256 _bias) public {
    Model memory m = Model(_id, models[_id].datasetUrl, _weight, _bias, models[_id].payment, now, msg.sender, true);
    trainedModels[_id].push(m);
    emit AddedResult(_id, now, msg.sender);
}

最后,这是选择结果功能,因为我们必须确保作业是开放的,并且尚未选择赢家,所以这个函数相当冗长。如果三天后没有选择获胜者,第一个申请人将赢得奖励,以避免失去以太币:

/// @notice To choose a winner by the sender
/// @param _id The id of the model
/// @param _arrayIdSelected The array index of the selected winner
function chooseResult(uint256 _id, uint256 _arrayIdSelected) public {
    Model memory m = models[_id];
    Model[] memory t = trainedModels[_id];
    require(m.isOpen, 'The job must be open to choose a result');
    // If 3 days have passed the winner will be the first one, otherwise the owner is allowed to choose a winner before 3 full days
    if(now - m.timestamp < 3 days) {
        require(msg.sender == m.owner, 'Only the owner can select the winner');
        t[_arrayIdSelected].owner.transfer(m.payment);
        models[_id].isOpen = false;
        emit SelectedWinner(_id, now, t[_arrayIdSelected].owner, t[_arrayIdSelected].id);
    } else {
        // If there's more than one result, send it to the first
        if(t.length > 0) {
            t[0].owner.transfer(m.payment);
            emit SelectedWinner(_id, now, t[0].owner, t[0].id);
        } else {
            // Send it to the owner if none applied to the job
            m.owner.transfer(m.payment);
            emit SelectedWinner(_id, now, msg.sender, 0);
        }
        models[_id].isOpen = false;
    }
}

uploadJob 函数将由买家使用,以发布他们的数据集和付款,以便让全世界的参与者训练他们的模型。uploadResult 函数将由卖家使用,以获取有关训练指定数据集直到错误最小化的作业的信息。最后,chooseResult 函数是由买家用于选择确定作业的赢家提案的函数。作业的创建者有三天的时间选择获胜提案。如果三天后没有人申请,那么支付将退还给所有者。如果有参与者,但所有者尚未选择获胜者,则奖励将作为对速度的补偿发送给第一个参与者;在这种情况下,此函数必须由外部用户执行以执行支付。

这些是构成我们 ML 市场的主要组件;然而,我们需要一些函数来帮助人们与之交互。以下是添加到 ML 市场的新函数,为了更好地帮助您理解,将它们分解成片段。

首先,我们创建了完整文档的成本函数,这样我们就能理解它的作用:

/// @notice The cost function implemented in solidity
/// @param _results The resulting uint256 for a particular data element
/// @param _weight The weight of the trained model
/// @param _bias The bias of the trained model
/// @param _xs The independent variable for our trained model to test the prediction
/// @return int256 Returns the total error of the model
function cost(int256[] memory _results, int256 _weight, int256 _bias, int256[] memory _xs) public pure returns(int256) {
    require(_results.length == _xs.length, 'There must be the same number of _results than _xs values');
    int256 error = 0; // Notice the int instead of uint since we want negative values too
    uint256 numberOfDataPoints = _xs.length;
    for(uint256 i = 0; i < numberOfDataPoints; i++) {
        error += (_results[i] - (_weight * _xs[i] + _bias)) * (_results[i] - (_weight * _xs[i] + _bias));
    }
    return error / int256(numberOfDataPoints);
}

然后我们有获取模型函数来检索结构模型中包含的变量,因为我们目前无法原样返回结构。我们必须做这些类型的技巧来独立获取结构值。以下代码显示了该函数:

/// @notice To get a model dataset, payment and timestamp
/// @param id The id of the model to get the dataset, payment and timestamp
/// @return Returns the dataset string url, payment and timestamp
function getModel(uint256 id) public view returns(string memory, uint256, uint256) {
    return (models[id].datasetUrl, models[id].payment, models[id].timestamp);
}

然后我们添加另一个获取器函数,它为特定 ID 的所有经过训练的模型提供了,如下所示的代码。对于想要查看他们特定作业收到了什么提案的卖家来说,这是很有用的。如果我们要在一个 dApp 中实现这个机器学习市场,我们将不得不为作业和其他映射添加一些更多的获取器:

/// @notice To get all the proposed trained models for a particular id
/// @param _id The id of the model created by the buyer
/// @return uint256[], uint256[], uint256[], uint256[], address[] Returns all those trained models separated in arrays containing ids, weights, biases, timestamps and owners
function getAllTrainedModels(uint256 _id) public view returns(uint256[] memory, uint256[] memory, uint256[] memory, uint256[] memory, address[] memory) {
    uint256[] memory ids;
    uint256[] memory weights;
    uint256[] memory biases;
    uint256[] memory timestamps;
    address[] memory owners;
    for(uint256 i = 0; i < trainedModels[_id].length; i++) {
        Model memory m = trainedModels[_id][i];
        ids[i] = m.id;
        weights[i] = m.weight;
        biases[i] = m.bias;
        timestamps[i] = m.timestamp;
        owners[i] = m.owner;
    }
    return (ids, weights, biases, timestamps, owners);
}

我们有一个cost函数,用于快速验证由拟议销售方上传的结果,一个getModel函数,主要由想要获取有关模型更多具体信息的卖家使用,以及一个getAllTrainedModels函数,返回特定工作的参与者。请注意,我们返回结构中最重要的变量而不是整个结构。我们这样做的简单原因是,目前在 Solidity 中我们无法返回结构,所以我们必须分开每个变量,并为每个变量返回一个数组。

这个市场的一般工作流程如下:

  1. 拥有要训练的机器学习模型的买家使用uploadJob函数将其数据集和付款上传到市场。

  2. 生成了一个AddedJob事件,通知对该市场新工作感兴趣的用户。他们可以使用web3或外部 dApps 来监听这些事件,因为合约是开源的。

  3. 卖家使用getModel函数读取模型数据,特别是时间戳,因为那是最重要的信息片段,使用他们从事件中收到的id模型。然后他们开始使用我们之前构建的 Python 应用程序或他们自己的应用程序进行模型训练,因为有许多不同的方法可以训练线性回归算法。

  4. 他们使用uploadResult函数将他们训练好的权重和偏置上传到该作业作为一个新的提案。这将触发AddedResult事件,通知买方是否在听取更新,以便他们可以选择获胜者。

  5. 在任务创建后不到三天之内,买家会浏览提案,比较每个提案产生的错误与cost函数或他们自己的实现。他们几乎肯定会选择错误最小的结果,尽管他们可以选择任何一个。选择完毕后,模型的状态将变为isOpen = false,这意味着赢家已选定,并且会触发SelectedWinner事件。

就是这样!您现在能够在区块链上上传和训练线性回归模型了。

总结

在这一章中,您学到了结合区块链和机器学习的基本实用性,因为它们几乎是对立的,这意味着它们互补,可以很好地实现最佳的安全性和性能。我们从机器学习的一般解释开始,这样您就可以通过快速了解生成和训练机器学习模型的过程来理解所有的炒作。然后我们深入探讨了应用的技术功能,这样您就能清楚地看到机器学习和区块链的交汇点。最后,我们建立了机器学习市场,因为这是两种技术的绝佳结合。您看到了线性回归算法如何在 Python 和 Solidity 中逐步实现。我们建立了市场,全世界的用户可以在这里为每个任务训练和交换计算资源,创建了一个伟大的安全开放源代码平台,人们在这里可以自由互动,没有审查、费用或中心化。

在下一章中,我们将探索类似于本章中所见的高级以太坊实现,但涉及到不同的行业,从一个基于区块链的社交媒体平台开始,它将分散化和互联网上的社交互动结合在一起。

第十一章:创建基于区块链的社交媒体平台

掌握以太坊开发始于大量的理论和技术,但在某一时刻,你必须跨出一步,开始将你最近获得的知识应用于构建你的投资组合的实际情况。这就是为什么我们要创建一个基于区块链的社交媒体平台,因为它是区块链技术的最佳用例之一,因为我们提供了人们的信任。不幸的是,许多中心化的社交媒体公司正在滥用这种信任,通过窃取和变现用户的隐私。诸如 Twitter 或 Facebook 等社交媒体平台之所以著名,是因为它们赋予了人们在一个界面上与许多个体保持联系的权力,利用了互联网的能力。

本章将带您了解如何创建一个完全基于区块链而无需中心化服务器的动态社交媒体平台的挑战。您将了解如何使用 React 创建一个漂亮的用户界面。然后,您将探索如何更好地组织信息,以便使用智能合约允许人们找到他们想要的内容。最后,您将使用 web3 将所有内容联系在一起,并能够使用您的社交媒体平台。

在本章中,我们将涵盖以下主题:

  • 理解分散的社交媒体

  • 创建用户界面

  • 构建智能合约

  • 完成 dApp

理解分散的社交媒体

当涉及基于以太坊的社交媒体 dApp 时,我们帮助人们解决了许多目前中心化公司尚无法有效解决的问题。我们可以帮助解决以下问题:

  • 在分散的区块链上保护用户的隐私

  • 通过不允许来自外部中心化实体的审查来保证完全的自由,因为区块链上的信息是永久的

  • 一个不变的、固定的存储系统,在几十年后仍然可以访问创建的内容

然而,当我们考虑构建一个分散的社交媒体平台时,我们会失去一些对现代应用程序至关重要的以下重要方面:

  • 速度:用户无法像使用普通的中心化应用程序一样快速使用 dApp,因为它依赖于一个庞大而缓慢的互联网络。

  • 存储限制:以太坊的空间有限,因此每个字节都很昂贵,导致在区块链上可以存储的内容受到巨大限制,因此我们必须找到克服这些自然限制的方法,同时尽可能保持分散。

  • Gas 费用:普通的中心化应用程序不必为系统上每个操作支付 gas 费用,因为它们了解到所有这些成本都是在中心化服务器上支付的。在区块链上,每个交易都有一定的成本,这可能是显着的。我们将通过使用测试网络来解决这个问题,在测试网络上,gas 没有价值,直到最终的应用程序创建完成。

另一个大问题是我们无法在区块链上存储图像和视频;如果我们希望保留主系统的去中心化,我们将不得不依赖于分布式存储解决方案,例如 IPFS,但这并不是强制性的。

初始概念

我们的目标是创建一个有效的社交媒体平台,克服或完全避免区块链的限制。为了简化我们的 dApp 的复杂性,我们将构建一个类似于 Twitter 的应用程序,用户只分享文本消息,而不提供分享图像或视频的选项。

由于我们是开发人员,我们将创建一个面向程序员、设计师、开发人员和各种技术相关领域的 Twitter,让人们可以在共同兴趣的社区中感到受欢迎。我们希望它具有以下功能:

  • 仅受智能合约容量限制的文本字符串分享能力

  • 为每个内容添加标签的能力

  • 通过点击标签来查看人们在他们的内容中包含的标签的功能

  • 订阅标签的功能

我们不希望人们关注其他人,我们只会给他们提供关注标签的能力,这样他们就会专注于内容而不是信使。让我们开始着手设计用户界面,这将成为我们的社交媒体 dApp,供技术爱好者通过标签而不是特定用户来关注内容。

创建用户界面

此特定项目的用户界面将围绕内容和标签展开,因为标签是用户发现新趋势内容的方式。用户将能够订阅特定标签,以在他们的订阅中接收来自这些主题的内容。

像往常一样,我们首先用 Truffle 设置一个新项目。按照以下步骤设置你的项目:

  1. 克隆初创仓库(github.com/merlox/dapp),其中包含了在你的 React dApp 上工作的初始配置:
git clone https://github.com/merlox/dapp
  1. 将仓库重命名为social-media-dapp以整理内容:
mv dapp/ social-media-dapp/
  1. 通过访问 GitHub 创建一个新的空仓库(不包含许可证或.gitignore,因为它们已经包含在你的项目中),并使用以下命令来更新拉取/推送 URL:
git config remote.origin.url https://<YOUR-USERNAME>:<YOUR-PASSWORD>@github.com/<YOUR-USERNAME>/social-media-dapp
  1. 推送第一个提交。使用npm i安装依赖项,并使用webpack -wd运行webpack

  2. 通过运行静态服务器http-server dist/来打开你的应用程序,并访问http://localhost:8080,查看是否一切都设置正确。

现在你可以开始创建你的用户界面了。你已经知道如何做了,那为什么不先自己试试呢?你会惊讶于此时你能够做到的事情,所以我鼓励你尝试建立自己的系统。我们的想法是通过指导你的步骤来一起构建这个 dApp,直到你拥有一个高质量的 dApp,可以用来建立你的简历或进一步为 ICO 或作为人类进步的开源软件的开发。

配置 webpack 样式

最后,你将必须有两个部分:一个是最受欢迎的标签,这将来自我们智能合约中的映射,另一个是你可以在其中阅读更多关于每个具体标签的内容,同时能够发布内容。你可能想设置样式加载程序以能够在你的 dApp 上使用 CSS,这在你刚刚克隆的默认 dApp 上并没有设置。为了这样做,在停止 webpack 后安装以下依赖项:

npm i -S style-loader css-loader

现在你已经安装了所需的库,可以在项目中使用 CSS 文件,你可以通过在css文件的loaders块中添加一个新的 loader 来更新 webpack 配置文件。请注意,我们使用了两个 loaders,style-loader排在第一个。否则它将无法工作:

{
    test: /\.css$/,
    exclude: /node_modules/,
    use: [
        { loader: 'style-loader' },
        { loader: 'css-loader' }
    ]
}

建立初始结构

打开index.js文件,并开始创建你的用户界面。首先,通过创建一些以后会用到的必要变量来设置构造函数:

  1. 为任何 React 项目设置所需的导入,以及我们现在可以通过样式和 css 加载器导入的css文件:
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
  1. 设置构造函数并填入一些虚拟数据,以查看最终应用程序在填入来自智能合约的变量后的外观:
class Main extends React.Component {
    constructor() {
        super()

        this.state = {
            content: [{
                author: '0x211824098yf7320417812j1002341342342341234',
                message: 'This is a test',
                hashtags: ['test', 'dapp', 'blockchain'],
                time: new Date().toLocaleDateString(),
            }, {
                author: '0x211824098yf7320417812j1002341342342341234',
                message: 'This is another test',
                hashtags: ['sample', 'dapp', 'Ethereum'],
                time: new Date().toLocaleDateString(),
            }],
            topHashtags: ['dapp', 'Ethereum', 'blockchain', 'technology', 'design'],
            followedHashtags: ['electronics', 'design', 'robots', 'futurology', 'manufacturing'],
            displaySubscribe: false,
            displaySubscribeId: '',
        }
    }
  1. 创建带有ReactDOM渲染的render()函数:
    render() {
        return (
            <div className="main-container">
            </div>
        )
    }
}

ReactDOM.render(<Main />, document.querySelector('#root'))

如你所见,我们应用程序的状态包含了content对象,其中包含了一个以太坊地址作为该文章的作者,消息,标签和时间。我们以后可能会更改它,但现在已经足够好了。我们还添加了两个数组,其中包含了最热门的标签和这个特定用户关注的标签。这些显示订阅变量是为了在用户悬停在标签上时显示一个订阅按钮,以便他们有选择关注以改善 dApp 的互动性。

渲染标签

现在我们可以创建带有所有逻辑的渲染函数,但要警告你:由于我们要显示状态中的所有数组,所以有点复杂,请耐心地分段查看代码以理解。按照以下步骤进行:

  1. 创建一个新的函数来生成标签的 HTML,因为我们希望在按钮上添加可变逻辑,以确保hashtag文本对用户展示订阅或取消订阅按钮有反应。记住,我们希望用户能够关注标签;这就是我们需要订阅和取消订阅按钮的原因:
generateHashtags(hashtag, index) {
    let timeout
    return (
        <span onMouseEnter={() => {
            clearTimeout(timeout)
            this.setState({
                displaySubscribe: true,
                displaySubscribeId: `subscribe-${hashtag}-${index}`,
            })
        }} onMouseLeave={() => {
            timeout = setTimeout(() => {
                this.setState({
                    displaySubscribe: false,
                    displaySubscribeId: '',
                })
            }, 2e3)
        }}>
            <a className="hashtag" href="#">#{hashtag}</a>
            <span className="spacer"></span>
            <button ref={`subscribe-${hashtag}-${index}`} className={this.state.displaySubscribe && this.state.displaySubscribeId == `subscribe-${hashtag}-${index}` ? '' : 'hidden'} type="button">Subscribe</button>
            <span className="spacer"></span>
        </span>
    )
}
  1. 更新render()函数以生成内容和标签块,因为我们需要一种简单的方法来创建要显示的内容;所有逻辑将在render()函数中执行:
render() {
    let contentBlock = this.state.content.map((element, index) => (
        <div key={index} className="content">
            <div className="content-address">{element.author}</div>
            <div className="content-message">{element.message}</div>
            <div className="content-hashtags">{element.hashtags.map((hashtag, i) => (
                <span key={i}>
                    {this.generateHashtags(hashtag, index)}
                </span>
            ))}
            </div>
            <div className="content-time">{element.time}</div>
        </div>
    ))
  1. 添加标签块,其唯一工作是创建将显示给用户的 JSX 对象,使用我们刚刚使用的generateHashtags()函数:
let hashtagBlock = this.state.topHashtags.map((hashtag, index) => (
    <div key={index}>
        {this.generateHashtags(hashtag, index)}
    </div>
))
let followedHashtags = this.state.followedHashtags.map((hashtag, index) => (
    <div key={index}>
        {this.generateHashtags(hashtag, index)}
    </div>
))
  1. render()函数的末尾,添加带有我们刚刚设置的块变量的return块:
    return (
        <div className="main-container">
            <div className="hashtag-block">
                <h3>Top hashtags</h3>
                <div className="hashtag-container">{hashtagBlock}</div>
                <h3>Followed hashtags</h3>
                <div className="hashtag-container">{followedHashtags}</div>
            </div>
            <div className="content-block">
                <div className="input-container">
                    <textarea placeholder="Publish content..."></textarea>
                    <input type="text" placeholder="Hashtags separated by commas..."/>
                    <button type="button">Publish</button>
                </div>

                <div className="content-container">
                    {contentBlock}
                </div>
            </div>
        </div>
    )
}

我们添加了一个名为generateHashtags的函数,因为我们必须在许多地方添加相同的逻辑来显示订阅按钮,所以制作一个只在需要时执行此操作而不重复这些长代码块的函数是有意义的。然后,在render()函数中,您可以看到我们在许多地方使用该函数来生成标签逻辑。在返回之前,我们有三个变量,只是使用我们的状态数据动态生成 JSX 组件。最后,render()函数很好地显示了这些块。

改善外观

我还导入了index.css文件,其中包含用于以最佳方式显示我们的应用程序的网格组件,具有干净的结构,易于维护:

  1. 添加主要组件的一般样式到您的应用程序,例如 body 和按钮,以使它们看起来更好:
body {
    margin: 0;
    background-color: whitesmoke;
    font-family: sans-serif;
}

button {
    background-color: rgb(201, 47, 47);
    color: white;
    border-radius: 15px;
    border: none;
    cursor: pointer;
}

button:hover {
    background-color: rgb(131, 0, 0);
}
  1. 将一般隐藏和间隔样式添加到隐藏元素并创建动态间隔:
.hidden {
    display: none;
}

.spacer {
    margin-right: 5px;
}
  1. 将容器的样式添加到位置上,该位置现在在所有主要浏览器上都被接受的网格系统中:
.main-container {
    display: grid;
    grid-template-columns: 30% 70%;
    margin: auto;
    width: 50%;
    grid-column-gap: 10px;
}

.input-container {
    margin-bottom: 10px;
    padding: 30px;
    display: grid;
    grid-template-columns: 80% 1fr;
    grid-template-rows: 70% 30%;
    grid-gap: 10px;
}
  1. 格式化输入和文本区域,以创建一个外观更好,易于使用的设计:
.input-container textarea {
    padding: 10px;
    border-radius: 10px;
    font-size: 11pt;
    font-family: sans-serif;
    border: 1px solid grey;
    grid-column: 1 / 3;
}

.input-container input {
    padding: 10px;
    border-radius: 10px;
    font-size: 11pt;
    font-family: sans-serif;
    border: 1px solid grey;
}
  1. 为内容块提供一个看起来很棒的设计,类似于 Twitter 中的推文:
.content {
    background-color: white;
    border: 1px solid grey;
    margin-bottom: 10px;
    padding: 30px;
    box-shadow: 4px 4px 0px 0 #cecece;
}
.content-address {
    color: grey;
    margin-bottom: 5px;
}
.content-message {
    font-size: 16pt;
    margin-bottom: 5px;
}
.content-hashtags {
    margin-bottom: 5px;
}
.content-time {
    color: grey;
    font-size: 12pt;
}
  1. 格式化这些标签,以将它们放置在正确的位置,并增加它们的大小:
.hashtag-block {
    text-align: center;
}

.hashtag-container {
    line-height: 30px;
}

.hashtag {
    font-size: 15pt;
}
  1. 如果您希望实现相同的外观,可以复制并粘贴该 css。这是 dApp 目前的外观:

  1. 您可以在 GitHub 上查看完成的代码,网址是github.com/merlox/social-media-dapp/tree/master/src

我试图模拟一种简单的卡通设计,以使可视化更有趣,同时保持一个清晰的界面,人们可以轻松阅读而不会混淆。注意您创建的用户界面,因为它们是每个 dApp 的主要组成部分。外观专业的 dApp 会引起更多关注。更多的关注通常会转化为更多的收入,因为您能够在正确的时刻引导人们的注意力到正确的地方。

构建智能合约

我们要构建的智能合约将作为我们的分散式应用的后端,存储所有消息、主题标签和用户。在我们的应用中,我们希望保持用户的匿名性;这就是为什么他们被表示为地址而不是用户名——将人们的注意力集中在谈论的内容上,而不是消息的发布者。

正如您已经知道的,我们将创建一个以主题为中心的社交媒体平台,不包含图像或视频。这就是为什么我们所有的数据将存储在映射和数组的组合中。

规划设计流程

在直接进入代码之前,我想让您了解我们将遵循的优化整个流程、避免混乱并通过清晰地了解需要完成的工作来节省时间的过程。该过程如下所示:

  1. 创建一个智能合约文件,并在注释中写下合约的目的描述,例如函数的工作方式以及谁将使用它。尽量简洁,因为这将有助于您和维护人员了解它的全部内容。

  2. 开始创建变量和函数签名,即,没有主体的函数,只有名称和参数。使用 NatSpec 格式为每个函数编写文档以进行额外的澄清。

  3. 独立实现每个函数,直到所有函数都完成。如果需要,您可以添加更多函数。

  4. 通过将合约复制粘贴到 remix 或任何其他 IDE 中手动测试合约,以快速发现问题并在虚拟 EVM 中运行所有函数,在那里您无需支付任何 gas 费用或等待确认。理想情况下,您会编写 Truffle 测试来验证一切是否正常工作,但有时可以跳过以节省时间。

这是该流程的图形表示,以便您牢记:

这种类型的流程是我用来最大化生产力而不至于因规格而疯狂的流程。如果您立即开始编写解决方案,您可能会陷入一个需要重新制作整个代码库的地方,同时在此过程中创建不必要的错误。这就是为什么规划如此重要。此外,确切地知道该做什么以及何时做将使您的生活变得轻松得多。

现在我们可以开始创建我们的智能合约,描述其背后的想法。在您的contracts/文件夹中创建一个名为SocialMusic.sol的文件,并在文件顶部的注释中写下该合约最终版本应该包含的内容的描述。在查看我的解决方案之前,请尝试自己完成,因为学习的唯一方法就是自己练习:

// This is a social media smart contract that allows people to publish strings of text in short formats with a focus on hashtags so that they can follow, read and be in touch with the latest content regarding those hashtags. There will be a mapping of the top hashtags. A struct for each piece of content with the date, author, content and array of hashtags. We want to avoid focusing on specific users that's why user accounts will be anonymous where addresses will the be the only identifiers.

pragma solidity ⁰.5.5;

contract SocialMedia {}

无论您是否意识到,通过编写描述,您的思维都得到了很大程度的澄清。现在您可以开始创建函数和变量了。鉴于您已经有了用户界面,您将想将该界面分解为块,并创建将提供这些块中显示的数据的函数;例如,看一下您应用程序的以下块:

当你看着界面时,你显然能看到顶部的标签和一些随机的标签。当你看着界面时,你必须问自己,我需要在我的智能合约中实现什么来使这成为可能?嗯,这似乎显而易见,但往往并不那么容易。在这种情况下,你必须创建一个函数来检索顶部的标签。该函数将从排序数组或映射中获取数据并将其发送给用户,也许还有一个参数,用于确定在任何时刻要检索多少顶部标签,以便你可以尝试不同的数量。要创建该函数,你必须实现某种排序机制,可能是一个不消耗 gas 的纯函数或视图函数来进行处理。另一方面,你如何确定这些标签的顺序?可能是一个增加每个标签值的分数系统,具体取决于使用情况。

你看,从我们整个应用程序中一个小明显的部分,你意识到你需要以下内容:

  • 包含需要排序的顶部标签的数组或映射。

  • 一个用于检索那些标签的函数,还可以使用可选参数来确定要实验的标签数量。

  • 对现有标签进行排序的函数,考虑到区块链的限制,必须是一个纯函数或视图函数,以避免过高的 gas 成本。

  • 为每个标签分配一个分数的系统,这样我们可以根据它们的受欢迎程度对它们进行排序。

你必须为应用程序的每个组件进行相同的分析过程。无论它看起来多么显而易见,试着在脑海中描述这些部分,这样你就可以预先可视化所需和可能的内容,从而节省你数小时的沮丧和错误代码。

设置数据结构

在进行必要的规划之后,可以依次执行以下步骤为所有所需部分编写函数签名:

  1. 首先用结构体和事件定义以后要使用的变量:
struct Content {
    uint256 id;
    address author;
    uint256 date;
    string content;
    bytes32[] hashtags;
}

event ContentAdded(uint256 indexed id, address indexed author, uint256 indexed date, string content, bytes32[] hashtags);
  1. 添加映射、数组和剩余的状态变量:
mapping(address => bytes32[]) public subscribedHashtags;
mapping(bytes32 => uint256) public hashtagScore; // The number of times this hashtag has been used, used to sort the top hashtags
mapping(bytes32 => Content[]) public contentByHashtag;
mapping(uint256 => Content) public contentById;
mapping(bytes32 => bool) public doesHashtagExist;
mapping(address => bool) public doesUserExist;
address[] public users;
Content[] public contents;
bytes32[] public hashtags;
uint256 public latestContentId;
  1. 定义函数签名:
function addContent(string memory _content, bytes32[] memory _hashtags) public {}
function subscribeToHashtag(bytes32 _hashtag) public {}
function unsubscribeToHashtag(bytes32 _hashtag) public {}
function getTopHashtags(uint256 _amount) public view returns(bytes32[] memory) {}
function getFollowedHashtags() public view returns(bytes32[] memory) {}
function getContentIdsByHashtag(bytes32 _hashtag, uint256 _amount) public view returns(uint256[] memory) {}
function getContentById(uint256 _id) public view returns(uint256, address, uint256, string memory, bytes32[] memory) {}
function sortHashtagsByScore() public view returns(bytes32[] memory) {}
function checkExistingSubscription(bytes32 _hashtag) public view returns(bool) {}

你是否对我们在一瞬间想出的函数和变量的数量感到惊讶?在进行这个过程时,你可能没有考虑到像checkExistingSubscriptiongetContentIdsByHashtag这样的函数。老实说,我在编写合同之前并不知道这些函数是必需的;只是在创建整个代码之后,它们变得必要起来。如果你在创建代码之前没有想出所有必需的变量和函数,也没关系。它们将在适当的时刻浮出水面,当你开发时,你不必事先编写所有函数并计划每一个函数和变量;那将是疯狂的。所以要有耐心,并且知道,在实施你的初始函数之后,你可能需要添加一些额外的函数来实现所需的功能。

记录未来函数

那些功能还不够清晰,为什么不为它们中的每一个编写 NatSpec 文档?这是一个繁琐的过程,但在编码时会提醒您自己在做什么,所以您会感谢自己的。这是我的版本,包含了文档:

  1. 从添加内容、订阅和取消订阅函数开始:
/// @notice To add new content to the social media dApp. If no hashtags are sent, the content is added to the #general hashtag list.
/// @param _content The string of content
/// @param _hashtags The hashtags used for that piece of content
function addContent(string memory _content, bytes32[] memory _hashtags) public {}

/// @notice To subscribe to a hashtag if you didn't do so already
/// @param _hashtag The hashtag name
function subscribeToHashtag(bytes32 _hashtag) public {}

/// @notice To unsubscribe to a hashtag if you are subscribed otherwise it won't do nothing
/// @param _hashtag The hashtag name
function unsubscribeToHashtag(bytes32 _hashtag) public {}
  1. 用于顶部和已关注标签的获取器函数。我们需要这些函数将它们显示在用户界面的侧边栏上:
/// @notice To get the top hashtags
/// @param _amount How many top hashtags to get in order, for instance the top 20 hashtags
/// @return bytes32[] Returns the names of the hashtags
function getTopHashtags(uint256 _amount) public view returns(bytes32[] memory) {}

/// @notice To get the followed hashtag names for this msg.sender
/// @return bytes32[] The hashtags followed by this user
function getFollowedHashtags() public view returns(bytes32[] memory) {}
  1. 通过 ID 的获取器函数。我们需要它们将结构变量分解为单独的部分返回:
/// @notice To get the contents for a particular hashtag. It returns the ids because we can't return arrays of strings and we can't return structs so the user has to manually make a new request for each piece of content using the function below.
/// @param _hashtag The hashtag from which get content
/// @param _amount The quantity of contents to get for instance, 50 pieces of content for that hashtag
/// @return uint256[] Returns the ids of the contents so that you can get each piece independently with a new request since you can't return arrays of strings
function getContentIdsByHashtag(bytes32 _hashtag, uint256 _amount) public view returns(uint256[] memory) {}

/// @notice Returns the data for a particular content id
/// @param _id The id of the content
/// @return Returns the id, author, date, content and hashtags for that piece of content
function getContentById(uint256 _id) public view returns(uint256, address, uint256, string memory, bytes32[] memory) {}
  1. 辅助函数用于对标签进行排序和检查现有订阅情况。当用户订阅以更新整个标签的分数并根据分数排序它们时,将使用这些函数:
/// @notice Sorts the hashtags given their hashtag score
/// @return bytes32[] Returns the sorted array of hashtags
function sortHashtagsByScore() public view returns(bytes32[] memory) {}

/// @notice To check if the use is already subscribed to a hashtag
/// @return bool If you are subscribed to that hashtag or not
function checkExistingSubscription(bytes32 _hashtag) public view returns(bool) {}

NatSpec 文档描述了所有函数的基本描述、参数和其他程序员的返回值,以便他们可以维护您的代码。它们还帮助您理解代码基础增长时发生的情况。

接下来,我们必须逐一实现所有函数,直到所有函数都完成。这是最耗时的过程,因为考虑到 Solidity 的限制,一些部分比其他部分更难。在执行此操作之前,尽量保持积极。如果您设置了一个一到两小时的计时器,在完成之前不能分心,您会比预期完成得更早。这就是著名的番茄工作法,以最大程度地提高生产力,我建议您使用它以在较短时间内完成更多工作。

实现添加内容功能。

添加内容功能是我们正在构建的 dApp 中最复杂的,因为我们需要完成以下任务:

  1. 检查用户提供的内容是否有效。

  2. 将新内容添加到正确的状态变量中。

  3. 增加包含在内容片段中的标签的分数。

  4. 将内容动态存储在general标签中,人们可以使用它来查找未排序的随机内容。

  5. 如果是新客户,则将用户添加到用户数组中。

由于我们必须实现的函数很多,该函数不可避免地会很复杂。这就是为什么要花时间做好它很重要,因为我们很容易创建消耗所有可用燃气的燃气陷阱。在看到我的解决方案之前,请先去您的计算机上实施它们,尽量在自己的计算机上执行以下步骤:

  1. 添加require()检查以确保内容有效:
/// @notice To add new content to the social media dApp. If no hashtags are sent, the content is added to the #general hashtag list.
/// @param _content The string of content
/// @param _hashtags The hashtags used for that piece of content
function addContent(string memory _content, bytes32[] memory _hashtags) public {
    require(bytes(_content).length > 0, 'The content cannot be empty');
    Content memory newContent = Content(latestContentId, msg.sender, now, _content, _hashtags);
    // If the user didn't specify any hashtags add the content to the #general hashtag
  1. 根据用户是否添加了标签,我们将执行相应的功能来对这些标签进行排序并增加其值:
    if(_hashtags.length == 0) {
        contentByHashtag['general'].push(newContent);
        hashtagScore['general']++;
        if(!doesHashtagExist['general']) {
            hashtags.push('general');
            doesHashtagExist['general'] = true;
        }
    } else {
        for(uint256 i = 0; i < _hashtags.length; i++) {
            contentByHashtag[_hashtags[i]].push(newContent);
            hashtagScore[_hashtags[i]]++;
            if(!doesHashtagExist[_hashtags[i]]) {
                hashtags.push(_hashtags[i]);
                doesHashtagExist[_hashtags[i]] = true;
            }
        }
    }
  1. 使用前面描述的函数按分数对数组进行排序,并在创建用户时发出正确的事件:
    hashtags = sortHashtagsByScore();
    contentById[latestContentId] = newContent;
    contents.push(newContent);
    if(!doesUserExist[msg.sender]) {
        users.push(msg.sender);
        doesUserExist[msg.sender] = true;
    }
    emit ContentAdded(latestContentId, msg.sender, now, _content, _hashtags);
    latestContentId++;
}

这是我在那个函数中逐步完成的拆分:

  1. 我检查了包含消息的 _content 变量是否为空,方法是将其转换为字节并检查其长度。这是检查字符串是否为空的一种方法,因为无法获取字符串类型的长度。

  2. 我使用所需的参数创建了 Content 结构体实例,并开始填充使用该结构体的映射,以便稍后找到该内容。

  3. 用户可以选择不指定任何标签,此时内容将被添加到 #general 标签中,以某种方式为希望从应用程序获取一般信息的人组织起来。请记住,我们主要通过标签进行交互,因此将每条消息组织到一个标签中至关重要。

  4. 如果用户指定了一些标签,我们将内容添加到所有这些标签中,同时创建新的标签供人们关注。目前,我们对人们可以使用多少个标签没有任何限制,因为我们正在尝试应用程序的工作方式。如果我们决定设置此类限制,我们以后可以关注这些细节。

  5. 将用户添加到用户数组中,并发出 ContentAdded 事件,以通知其他人有关新内容的情况。

创建推广引擎

我们需要一种方法告诉用户哪些帐户表现最佳,方法是创建一个增加标签价值的评分系统。这就是我们创建 hashtagScore 映射的原因,作为衡量正在使用的标签受欢迎程度的方法。推广引擎只是一种按照受欢迎程度评分标签的方法。因此,当有人订阅该标签或为该标签添加新内容时,该标签的分数将会增加。当有人取消订阅时,分数将减少。这一切都是不可见的,所以用户只会看到热门标签。

让我们继续编写订阅函数,让人们有权关注他们感兴趣的特定主题。要实现推广引擎,我们只需在订阅和取消订阅函数中更新正在使用的特定标签的分数。再次强调,在看解决方案之前,尝试自己实现它,以锻炼你的技能并获取经验。以下是订阅函数,它增加了该特定用户选择的标签的分数:

/// @notice To subscribe to a hashtag if you didn't do so already
/// @param _hashtag The hashtag name
function subscribeToHashtag(bytes32 _hashtag) public {
    if(!checkExistingSubscription(_hashtag)) {
        subscribedHashtags[msg.sender].push(_hashtag);
        hashtagScore[_hashtag]++;
        hashtags = sortHashtagsByScore();
    }
}

然后我们有取消订阅函数,它减少了标签的价值,因为它变得不太相关:

/// @notice To unsubscribe to a hashtag if you are subscribed otherwise it won't do nothing
/// @param _hashtag The hashtag name
function unsubscribeToHashtag(bytes32 _hashtag) public {
    if(checkExistingSubscription(_hashtag)) {
        for(uint256 i = 0; i < subscribedHashtags[msg.sender].length; i++) {
            if(subscribedHashtags[msg.sender][i] == _hashtag) {
                delete subscribedHashtags[msg.sender][i];
                hashtagScore[_hashtag]--;
                hashtags = sortHashtagsByScore();
                break;
            }
        }
    }
}

subcribeToHashtag 函数简单地检查用户是否已订阅,以便将新主题添加到他们的兴趣列表中,同时对标签进行排序,因为该标签的分数已经增加。在我们的智能合约中,标签的价值取决于使用情况。订阅该标签的人越多,为该标签创建的内容越多,其排名就越高。

unsubscribeToHashtag函数循环遍历该特定用户的所有标签,并从其列表中移除选定的标签。此循环不应引起任何 gas 问题,因为我们不期望用户关注数十万个主题。无论如何,正确的做法是限制可订阅标签的数量,以避免 gas 错误。我会把这交给你。最后,我们降低该标签的评分,并对所有标签进行排序处理。

实现 getter 函数

接下来,让我们看看我们将用来向用户显示数据的 getter 函数。这些函数不需要任何 gas 费用,因为它们是从已下载和同步的区块链中读取数据,始终可用,而不依赖于互联网连接。让我们看看以下步骤:

  1. 创建getTopHashtags()函数,以 bytes32 格式返回用户可见的名称列表,以便查看哪些标签正在流行。这是发现新内容的主要系统:
/// @notice To get the top hashtags
/// @param _amount How many top hashtags to get in order, for instance the top 20 hashtags
/// @return bytes32[] Returns the names of the hashtags
function getTopHashtags(uint256 _amount) public view returns(bytes32[] memory) {
    bytes32[] memory result;
    if(hashtags.length < _amount) {
        result = new bytes32[](hashtags.length);
        for(uint256 i = 0; i < hashtags.length; i++) {
            result[i] = hashtags[i];
        }
    } else {
        result = new bytes32[](_amount);
        for(uint256 i = 0; i < _amount; i++) {
            result[i] = hashtags[i];
        }
    }
    return result;
}
  1. 添加获取已关注标签的函数,这很简单,因为它使用subscribedHashtags[]映射返回指定列表:
/// @notice To get the followed hashtag names for this msg.sender
/// @return bytes32[] The hashtags followed by this user
function getFollowedHashtags() public view returns(bytes32[] memory) {
    return subscribedHashtags[msg.sender];
}
  1. 实现getContentIdsByHashtag()函数。这将负责返回包含用户可能订阅的特定标签的所有内容片段的 ID 数组:
/// @notice To get the contents for a particular hashtag. It returns the ids because we can't return arrays of strings and we can't return structs so the user has to manually make a new request for each piece of content using the function below.
/// @param _hashtag The hashtag from which get content
/// @param _amount The quantity of contents to get for instance, 50 pieces of content for that hashtag
/// @return uint256[] Returns the ids of the contents so that you can get each piece independently with a new request since you can't return arrays of strings
function getContentIdsByHashtag(bytes32 _hashtag, uint256 _amount) public view returns(uint256[] memory) {
    uint256[] memory ids = new uint256[](_amount);
    for(uint256 i = 0; i < _amount; i++) {
        ids[i] = contentByHashtag[_hashtag][i].id;
    }
    return ids;
}
  1. 添加简单的getContentById()函数,用于将 ID 结构转换为可理解的单独变量,因为我们目前无法返回结构体:
/// @notice Returns the data for a particular content id
/// @param _id The id of the content
/// @return Returns the id, author, date, content and hashtags for that piece of content
function getContentById(uint256 _id) public view returns(uint256, address, uint256, string memory, bytes32[] memory) {
    Content memory c = contentById[_id];
    return (c.id, c.author, c.date, c.content, c.hashtags);
}

前面的函数相当简单。getContentIdsByHashtag 函数有点棘手,因为通常情况下我们不需要它,但由于 Solidity 不允许我们返回结构体数组或字符串数组,所以我们必须获得这些 ID,以便稍后可以使用getContentById函数逐个获取各个内容片段,该函数可以成功返回每个变量。

以下是我们需要使一切成为可能的最后两个辅助函数:

  • sortHashtagsByScore()函数用于返回按照每个标签的受欢迎程度排序的标签列表,因为我们正在读取每个标签的值:
/// @notice Sorts the hashtags given their hashtag score
/// @return bytes32[] Returns the sorted array of hashtags
function sortHashtagsByScore() public view returns(bytes32[] memory) {
    bytes32[] memory _hashtags = hashtags;
    bytes32[] memory sortedHashtags = new bytes32[](hashtags.length);
    uint256 lastId = 0;
    for(uint256 i = 0; i < _hashtags.length; i++) {
        for(uint j = i+1; j < _hashtags.length; j++) {
            // If it's a buy order, sort from lowest to highest since we want the lowest prices first
            if(hashtagScore[_hashtags[i]] < hashtagScore[_hashtags[j]]) {
                bytes32 temporaryhashtag = _hashtags[i];
                _hashtags[i] = _hashtags[j];
                _hashtags[j] = temporaryhashtag;
            }
        }
        sortedHashtags[lastId] = _hashtags[i];
        lastId++;
    }
    return sortedHashtags;
}
  • checkExistingSubscription()函数返回用户是否已订阅的布尔值:
/// @notice To check if the use is already subscribed to a hashtag
/// @return bool If you are subscribed to that hashtag or not
function checkExistingSubscription(bytes32 _hashtag) public view returns(bool) {
    for(uint256 i = 0; i < subscribedHashtags[msg.sender].length; i++) {
        if(subscribedHashtags[msg.sender][i] == _hashtag) return true;
    }
    return false;
}

排序函数因其明显的复杂性而难以阅读。尽管如此,它只是一对for循环,一个正常的循环和一个内部的倒序循环,连续将得分较高的标签移到顶部,直到最好的标签位于我们的sortedHashtags数组的第一个位置。这将用于替换过去的、未排序状态hashtags数组。

checkExistingSubscription 函数循环遍历所有已订阅的标签,并在提供的标签在列表中时返回true。这对订阅函数很重要,以保持数组清洁,避免重复订阅。

完整更新的代码可以在我的 GitHub 上查看,网址为github.com/merlox/social-media-dapp

现在剩下的是测试所有这些功能是否正常工作。将代码粘贴到 Remix 或任何其他 IDE 中,以便它指出必须修复的错误。然后将合同部署到 JavaScript VM 中,这不会产生任何费用,并逐一运行这些函数。注意,你将需要将bytes32变量转换为十六进制,如果你安装了 MetaMask,则可以在浏览器的开发者工具中使用web3.toHex()函数进行转换。

理想情况下,你可以在 Truffle 中编写测试,自动检查由新更改引起的错误。我会留下这个决定给你。

合约准备就绪后,下一步是在你的 dApp 中实施它,以便信息来自我们刚刚创建的去中心化后端。在下一节中看看如何实现它。

完成 dApp

你的 React.js Web 应用程序看起来很棒,剩下的就是连接智能合约到你的应用程序中的功能,以便它们互相交流,同时保持去中心化,因为任何人都可以在不依赖于集中式服务器的情况下自由使用 React 应用程序。

连接智能合约与 Web 应用程序的第一步是安装 web3.js,因为它是以太坊和 Web 浏览器之间的桥梁,尽管我们已经有了 MetaMask,你可能不需要它,但是重要的是选择一个稳定版本,不会为我们的 dApp 更改。请在项目文件夹中运行npm i -S web3

设置智能合约实例

在 React 应用程序中实施智能合约时,必须首先完成合约实例,以便我们可以在整个去中心化应用程序中调用该合约的方法。我们将使用 Truffle 提供的编译合约和其地址。让我们执行以下步骤:

  1. 将 web3 导入到你的项目中:
import Web3Js from 'web3'

你觉得为什么我将变量命名为 Web3Js 而不直接用 Web3 呢?因为 MetaMask 注入了自己版本的 web3,准确地命名为 Web3,因此当我们开发时,可能会使用 MetaMask 注入的 web3 版本,而不是我们想要导入的版本。为了避免与 MetaMask 注入的 web3 发生干扰,重要的是使用略微不同的名称。

  1. 全局使用当前提供程序设置 web3,这样你就可以在整个应用程序中使用它,而不必担心范围问题。

  2. 创建一个名为setup()的函数,其中包含 MetaMask 设置逻辑。这个函数将在构造函数中执行,页面加载时执行:

class Main extends React.Component {
    constructor() {
        // Previous code omitted for simplicity

        this.setup()
    }

 async setup() {
 window.web3js = new Web3Js(ethereum)
 try {
 await ethereum.enable();
 } catch (error) {
 alert('You must approve this dApp to interact with it, reload it to approve it')
 }
 }
}

我们创建了一个新的设置函数,因为我们无法在构造函数上使用 await,因为它不是一个异步函数。在其中,我们创建了一个名为web3js的全局变量,该变量不叫做web3(小写),因为 MetaMask 已经使用了该变量名,我们有可能使用错误的版本。如您所见,在本例中,提供程序称为ethereum,这是来自 MetaMask 的全局变量,其中包含了我们启动使用 web3 所需的一切;这是一种新的初始化 web3 实例的方式,与旧版 dApp 兼容,因为 MetaMask 团队对安全性进行了一些更改。然后我们等待enable()函数获得用户的许可以注入 web3,因为我们不希望在没有用户同意的情况下暴露用户密钥。如果用户不允许,我们会显示一个错误,让他们知道我们需要他们授予权限以使此 dApp 正常工作。

  1. 设置智能合约实例。因为我们已经安装了 Truffle,我们可以编译我们的智能合约以生成包含 ABI 的 JSON 文件,该文件是使用该应用程序所必需的。然后我们可以将合约部署到ropsten
truffle compile

truffle deploy --network ropsten --reset

您可能会收到以下消息:

"Unknown network "ropsten". See your Truffle configuration file for available networks."
  1. 这意味着您没有正确设置 Truffle 配置文件以使用ropsten网络。使用npm i -S truffle-hdwallet-provider安装钱包提供程序。然后使用以下代码修改truffle-config.js
const HDWalletProvider = require('truffle-hdwallet-provider')
const infuraKey = "https://ropsten.infura.io/v3/8e12dd4433454738a522d9ea7ffcf2cc"

const fs = require('fs')
const mnemonic = fs.readFileSync(".secret").toString().trim()

module.exports = {
  networks: {
    ropsten: {
      provider: () => new HDWalletProvider(mnemonic, infuraKey),
      network_id: 3, // Ropsten's id
      gas: 5500000, // Ropsten has a lower block limit than mainnet
      confirmations: 2, // # of confs to wait between deployments. (default: 0)
      timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
      skipDryRun: true // Skip dry run before migrations? (default: false for public nets )
    }
  }
}
  1. 告诉 Truffle 通过在migrations/文件夹中创建一个名为2_deploy_contract.js的文件来部署您的合约,其中包含以下代码:
const SocialMedia = artifacts.require("./SocialMedia.sol")

module.exports = function(deployer) {
  deployer.deploy(SocialMedia);
}
  1. 如您所见,我们只有最小的配置参数,因此保持它简洁。在您的项目文件夹中创建一个.secret文件,并粘贴您的以太坊种子短语,您可以通过重置 MetaMask 或在另一个浏览器中安装它来获取该种子短语,如果您担心使您的种子公开。该种子短语将被 Truffle 用于部署合约,所以请确保您的第一个账户中有足够的ropsten以太币。然后再次运行truffle deploy --network ropsten --reset

  2. 使用以下内容更新您的setup函数以创建合约实例:

async setup() {
    window.web3js = new Web3Js(ethereum)
    try {
        await ethereum.enable();
    } catch (error) {
        alert('You must approve this dApp to interact with it, reload it to approve it')
    }
 const user = (await web3js.eth.getAccounts())[0]
 const contract = new web3js.eth.Contract(ABI.abi, ABI.networks['3'].address, {
 from: user
 })
 await this.setState({contract, user})
}

我们已经设置了应用程序状态中的用户账户,以便在需要时轻松访问它。

将您的数据去中心化

要完全实现智能合约,我们必须查看网站的每个部分,以使用智能合约中的数据更新其内容。让我们从左上角到右下角进行。按照顺序,我们必须首先去中心化顶部的标签部分,使用getTopHashtags()函数:

async setup() {
    window.web3js = new Web3Js(ethereum)
    try {
        await ethereum.enable();
    } catch (error) {
        alert('You must approve this dApp to interact with it, reload it to approve it')
    }
    const user = (await web3js.eth.getAccounts())[0]
    window.contract = new web3js.eth.Contract(ABI.abi, ABI.networks['3'].address, {
        from: user
    })
 await this.setState({contract, user})
}

当您没有任何热门标签时,您还必须更新您的render()函数,因为您刚刚部署了智能合约。我们将从另一个名为getContent()的函数中获取内容:

render() {
    return (
        <div className="main-container">
            <div className="hashtag-block">
                <h3>Top hashtags</h3>
                <div className="hashtag-container">{this.state.topHashtagBlock}</div>
                <h3>Followed hashtags</h3>
                <div className="hashtag-container">{this.state.followedHashtagsBlock}</div>
            </div>
            <div className="content-block">
                <div className="input-container">
                    <textarea ref="content" placeholder="Publish content..."></textarea>
                    <input ref="hashtags" type="text" placeholder="Hashtags separated by commas without the # sign..."/>
                    <button onClick={() => {
                        this.publishContent(this.refs.content.value, this.refs.hashtags.value)
                    }} type="button">Publish</button>
                </div>

                <div className="content-container">
                    {this.state.contentsBlock}
                </div>
            </div>
        </div>
    )
}

修改后的代码如下所示:

让我们更新获取内容函数,以根据用户是否有任何活动订阅来生成数据:

  1. 要获取用户将要看到的所有内容,我们需要获取 latestContentId,这是一个表示当前时刻可用多少个内容片段的数字,以防用户尚未订阅任何标签:
async getContent() {
    const latestContentId = await this.state.contract.methods.latestContentId().call()
    const amount = 10
    const amountPerHashtag = 3
    let contents = []
    let counter = amount
  1. 如果用户正在关注标签,则通过循环遍历所有 ID 获取内容片段:
    // If we have subscriptions, get content for those subscriptions 3 pieces per hashtag
    if(this.state.followedHashtags.length > 0) {
        for(let i = 0; i < this.state.followedHashtags.length; i++) {
            // Get 3 contents per hashtag
            let contentIds = await this.state.contract.methods.getContentIdsByHashtag(this.bytes32(this.state.followedHashtags[i]), 3).call()
            let counterTwo = amountPerHashtag
            if(contentIds < amountPerHashtag) counterTwo = contentIds
            for(let a = counterTwo - 1; a >= 0; a--) {
                let content = await this.state.contract.methods.getContentById(i).call()
                content = {
                    id: content[0],
                    author: content[1],
                    time: new Date(parseInt(content[2] + '000')).toLocaleDateString(),
                    message: content[3],
                    hashtags: content[4],
                }
                content.message = web3js.utils.toUtf8(content.message)
                content.hashtags = content.hashtags.map(hashtag => web3js.utils.toUtf8(hashtag))
                contents.push(content)
            }
        }
    }
  1. 如果用户尚未订阅任何标签,则更新 counter 变量以反向循环,以便首先获取最新的内容片段:
    // If we don't have enough content yet, show whats in there
    if(latestContentId < amount) counter = latestContentId
    for(let i = counter - 1; i >= 0; i--) {
        let content = await this.state.contract.methods.getContentById(i).call()
        content = {
            id: content[0],
            author: content[1],
            time: new Date(parseInt(content[2] + '000')).toLocaleDateString(),
            message: content[3],
            hashtags: content[4],
        }
        content.message = web3js.utils.toUtf8(content.message)
        content.hashtags = content.hashtags.map(hashtag => web3js.utils.toUtf8(hashtag))
        contents.push(content)
    }
  1. 生成 contentsBlock,其中包含创建内容片段的所有元素,类似于推特或 Facebook 的帖子:
    let contentsBlock = await Promise.all(contents.map(async (element, index) => (
        <div key={index} className="content">
            <div className="content-address">{element.author}</div>
            <div className="content-message">{element.message}</div>
            <div className="content-hashtags">{element.hashtags.map((hashtag, i) => (
                <span key={i}>
                    <Hashtag
                        hashtag={hashtag}
                        contract={this.state.contract}
                        subscribe={hashtag => this.subscribe(hashtag)}
                        unsubscribe={hashtag => this.unsubscribe(hashtag)}
                    />
                </span>
            ))}
            </div>
            <div className="content-time">{element.time}</div>
        </div>
    )))

    this.setState({contentsBlock})
}

getContent() 函数检查用户是否有任何活动订阅,以便它可以获取每个标签最多三个内容片段。它还将获取 dApp 上载的最近 10 篇文章。它相当庞大,因为它根据智能合约上可用的标签数量生成数据。如果您关注 100 个标签,您将看到 300 个新的内容片段,因为我们在 feed 中每个标签获取 3 篇文章。我们还将添加 10 个随机内容,这些内容将从智能合约中的 contents 数组中取出。

创建标签组件

每个标签都是一个小型机器,包含了大量的逻辑来检测用户是否已订阅。这可能看起来很简单,但请记住,我们需要获取每个用户对每个标签的状态,这意味着我们必须执行大量请求,可能会减慢我们的 dApp 的性能。创建函数时要保持清洁,以便它们运行顺畅。

我们正在使用一个名为 Hashtag 的新组件,它是一个 HTML 对象,返回一个交互式的标签文本,可以点击进行订阅或取消订阅。这是创建这种功能的最简洁方式,以减少复杂性:

  1. 创建构造函数,带有一些状态变量,根据用户的行为显示或隐藏标签:
class Hashtag extends React.Component {
 constructor(props) {
        super()
        this.state = {
            displaySubscribe: false,
            displayUnsubscribe: false,
            checkSubscription: false,
            isSubscribed: false,
        }
 }
  1. 创建 bytes32()checkExistingSubscription() 函数来检查当前用户是否已经关注了特定的标签:
 componentDidMount() {
        this.checkExistingSubscription()
 }

 bytes32(name) {
        let nameHex = web3js.utils.toHex(name)
        for(let i = nameHex.length; i < 66; i++) {
            nameHex = nameHex + '0'
        }
        return nameHex
 }

 async checkExistingSubscription() {
        const isSubscribed = await this.props.contract.methods.checkExistingSubscription(this.bytes32(this.props.hashtag)).call()
        this.setState({isSubscribed})
    }
  1. render() 函数相当庞大,因此我们将其分解为两个主要部分:检测用户是否已订阅的功能,以及显示正确按钮的功能:
 render() {
        return (
            <span onMouseEnter={async () => {
                if(this.state.checkSubscription) await this.checkExistingSubscription()
                if(!this.state.isSubscribed) {
                    this.setState({
                        displaySubscribe: true,
                        displayUnsubscribe: false,
                    })
                } else {
                    this.setState({
                        displaySubscribe: false,
                        displayUnsubscribe: true,
                    })
                }
            }} onMouseLeave={() => {
                this.setState({
                    displaySubscribe: false,
                    displayUnsubscribe: false,
                })
            }}>
  1. 实现订阅或取消订阅按钮,当用户悬停在标签上时显示:
                <a className="hashtag" href="#">#{this.props.hashtag}</a>
                <span className="spacer"></span>
                <button onClick={() => {
                    this.props.subscribe(this.props.hashtag)
                    this.setState({checkSubscription: true})
                }} className={this.state.displaySubscribe ? '' : 'hidden'} type="button">Subscribe</button>
                <button onClick={() => {
                    this.props.unsubscribe(this.props.hashtag)
                    this.setState({checkSubscription: true})
                }} className={this.state.displayUnsubscribe ? '' : 'hidden'} type="button">Unsubscribe</button>
                <span className="spacer"></span>
            </span>
 )
 }
}

render() 函数显示标签,当鼠标悬停时显示订阅或取消订阅按钮。checkExistingSubscription() 函数获取特定标签订阅的状态,以显示适合取消订阅的用户的按钮类型。

创建标签获取器

当页面加载时,我们现在可以创建一个函数,从智能合约中获取顶级标签和已关注的标签。我们将通过检索已关注的和顶级标签来完成。这些标签将通过循环遍历它们直到界面填满数据,显示给用户。

一旦完成,请尝试自行实现并查看以下结果:

  1. 定义创建结果标签 JSX 所需的变量:
async getHashtags() {
    let topHashtagBlock
    let followedHashtagsBlock
    const amount = 10
    const topHashtags = (await contract.methods.getTopHashtags(amount).call()).map(element => web3js.utils.toUtf8(element))
    const followedHashtags = (await this.state.contract.methods.getFollowedHashtags().call()).map(element => web3js.utils.toUtf8(element))
  1. 开始循环遍历标签块,直到我们填满顶部标签列表:
    if(topHashtags.length == 0) {
        topHashtagBlock = 'There are no hashtags yet, come back later!'
    } else {
        topHashtagBlock = topHashtags.map((hashtag, index) => (
            <div key={index}>
                <Hashtag
                    hashtag={hashtag}
                    contract={this.state.contract}
                    subscribe={hashtag => this.subscribe(hashtag)}
                    unsubscribe={hashtag => this.unsubscribe(hashtag)}
                />
            </div>
        ))
    }
  1. 如果用户没有关注任何标签,我们将显示一条消息。如果他们有,我们将循环遍历所有关注的标签,生成具有所需数据的 Hashtag 组件。使用刚刚创建的新块更新状态,以便在 render() 函数中显示它们:
    if(followedHashtags.length == 0) {
        followedHashtagsBlock = "You're not following any hashtags yet"
    } else {
        followedHashtagsBlock = followedHashtags.map((hashtag, index) => (
            <div key={index}>
                <Hashtag
                    hashtag={hashtag}
                    contract={this.state.contract}
                    subscribe={hashtag => this.subscribe(hashtag)}
                    unsubscribe={hashtag => this.unsubscribe(hashtag)}
                />
            </div>
        ))
    }
    this.setState({topHashtagBlock, followedHashtagsBlock, followedHashtags})
}

创建发布功能

发布新的内容是一个简单的任务,需要验证所有输入是否包含有效的文本字符串。由于我们将标签存储在一个 bytes32 变量中,所以需要正确格式化用户输入的标签,以便智能合约能够安全处理它们。

让我们让发布功能起作用,这样我们就可以开始生成内容,执行以下步骤:

  1. 如果尚未这样做,请创建 bytes32() 函数,因为我们很快会需要它:
bytes32(name) {
    let nameHex = web3js.utils.toHex(name)
    for(let i = nameHex.length; i < 66; i++) 
    {
        nameHex = nameHex + '0'
    }
    return nameHex
}
  1. 添加 publishContent() 函数来处理带有标签的消息。标签将以字符串格式给出,其中包含不带散列符号(#)的逗号分隔字符串列表。确保标签被正确分隔和格式化,以供合约安全处理:
async publishContent(message, hashtags) {
    if(message.length == 0) alert('You must write a message')
    hashtags = hashtags.trim().replace(/#*/g, '').replace(/,+/g, ',').split(',').map(element => this.bytes32(element.trim()))
    message = this.bytes32(message)
    try {
        await this.state.contract.methods.addContent(message, hashtags).send({
            from: this.state.user,
            gas: 8e6
        })
    } catch (e) {console.log('Error', e)}
    await this.getHashtags()
    await this.getContent()
}

这是我们刚刚添加的两个函数的解释:

  • bytes32(): 这个函数用于将普通字符串转换为 Solidity 可用的十六进制,因为新的更新强制 web3 用户在处理 bytes 类型的变量时将数据转换为十六进制。

  • publishContent(): 这个函数看起来有点凌乱,因为我们正在使用正则表达式将用户输入的标签转换为有效的清晰字符串数组。它正在执行一些操作,比如删除空格、删除重复逗号和标签符号,然后将字符串拆分成一个有效的数组,可以在我们的智能合约中使用。

  1. 记得更新你的 setup() 函数,以便在加载时获取最新的内容:
async setup() {
    window.web3js = new Web3Js(ethereum)
    try {
        await ethereum.enable();
    } catch (error) {
        alert('You must approve this dApp to interact with it, reload it to approve it')
    }
    const user = (await web3js.eth.getAccounts())[0]
    window.contract = new web3js.eth.Contract(ABI.abi, ABI.networks['3'].address, {
        from: user
    })
    await this.setState({contract, user})
 await this.getHashtags()
 await this.getContent()
}
  1. 是时候专注于创建订阅功能了。它们将在用户点击订阅或取消订阅时执行,取决于当前状态。尝试自己实现它们,然后一旦完成,请返回比较你的解决方案和我的。记住,这是尝试和失败,直到代码变得足够好的过程。这是我的解决方案:
async subscribe(hashtag) {
    try {
        await this.state.contract.methods.subscribeToHashtag(this.bytes32(hashtag)).send({from: this.state.user})
    } catch(e) { console.log(e) }
    await this.getHashtags()
    await this.getContent()
}

async unsubscribe(hashtag) {
    try {
        await this.state.contract.methods.unsubscribeToHashtag(this.bytes32(hashtag)).send({from: this.state.user})
    } catch(e) { console.log(e) }
    await this.getHashtags()
    await this.getContent()
}

这两个函数都相当简单。当用户按下标签名称旁边的按钮时,它们运行适当的订阅或取消订阅函数。注意我们如何使用 try catch 避免在调用合约时出现故障时破坏整个应用程序;这也是因为有时它有一个奇怪的故障系统,在没有原因的情况下停止执行。当你觉得需要时,只需添加 try catch 块。

你可以在 GitHub 上找到更新版本,网址为 github.com/merlox/social-media-dapp,其中包含完整的实现代码供您参考。就是这样!现在你的区块链开发简历上有了一个新项目,你可以向雇主展示,或者在此基础上构建一个更好的去中心化社交媒体平台来筹集资金。

总结

当涉及到为用户自由发布内容创建一个完全去中心化的社交媒体平台时,就是这些了。在本章中,您了解了在区块链上创建这种类型的应用程序与在集中式系统上创建它之间的优势。然后,您通过使用 Truffle 和 React 从头开始设置了用户界面。之后,您开发了智能合约并将其连接到 dApp 以使其交互式。总的来说,您获得了一大块经验,可以将其扩展为创建具有各种有趣功能的不同类型的社交媒体平台,例如关注用户和添加用于与不同 API 交互的 Oracle。

在下一章中,我们将探讨在区块链上构建去中心化电子商务市场的构建过程,您将为您的业务创建一个完全功能的商店。

第十二章:创建基于区块链的电子商务市场

区块链技术最佳应用案例之一是分散的电子商务市场,原因很简单,你不必支付费用,也不必把数据委托给那些会为了利润出售数据的强大企业。Ethereum 为此提供了一个出色的解决方案,新的 ERC-721 代币标准已经为您在区块链上生成数字化物体。在本章中,你将学习如何处理个人用户数据,以便为每个个体保护数据,鉴于 Ethereum 是一个公共系统。

在第一部分中,我们将研究电子商务网站应该如何构建,使用户可以像在真实商店一样与之互动。你将构建用户界面,用于显示使用 ERC-721 约定标识的独特产品。然后,你将实现 React 路由器模块,以在用户友好的界面中组织不同的视图。最后,你将创建实施 ERC-721 代币并创建管理分散式产品所需功能的智能合约。

此外,在本章中,你将学习如何在 Ethereum 上为您的企业创建一个完整的电子商务市场,学习以下主题:

  • 创建用户界面

  • 理解 ERC-721 代币

  • 开发电子商务智能合约

  • 完成 dApp

创建用户界面

这类指南的最大优点是,你可以将在这里学到的关于分散电子商务的知识应用到扩展这些想法上,创造一个更高级的产品,提供一个复杂的解决方案以募集资金,或者简单地以此来建立一个业务。

计划市场界面

该市场几乎拥有无限的选择,因为你不必面对许多区块链的限制。每个产品都是一个独立的实例,可以根据需要进行修改,因此你可以自由添加尽可能多的功能,比如以下功能:

  • 将产品加入购物车的购物系统,从而实现较大的综合采购,而不是直接购买

  • 动态的发货地址功能,以添加多个不同的地址,以便您可以通过保存您的首选位置快速向多个地点发送订单

  • 创建用于用户产品拍卖的竞标系统

  • 为更好的用户互动而创建的个人资料和评价功能

在这个项目中,我们不会实现任何那些高级功能,因为它们会花费太多时间来开发,尽管你可以在基本产品完成后自己添加它们。这就是为什么我们将创建一个具有以下功能的简单接口:

  • 通过 Ethereum 直接购买实物和数字产品的购买系统

  • 作为独立卖家,在市场上发布产品的销售功能

  • 作为买家和卖家查看待处理订单的订单展示功能

通常情况下,用户将能够像使用信用卡一样作为普通在线商店与 MetaMask 进行直接付款交互。与像亚马逊这样收取约 15%总付款费用的电子商务商店相比,该市场不会向用户收取费用,这真的很费钱。另一个重要的点是,不会有任何审查或需要遵循的规则,这意味着用户可以自由发布产品,而不必担心被来自中心化实体的禁令所影响,这是一个经常发生的问题,导致卖家损失了数千美元的锁定资金和撤销的订单。

不会有多个数量的单个产品,因为我们将使用独特的不可替代令牌NFT),这意味着每个产品都必须是唯一的。由于我们将从一个用户向另一个用户交换令牌,所以我们将无法拥有同一产品的多个副本。然而,你可以实现一个 ERC-20 代币或一个系统,用于为同一产品的多个数量生成相同的令牌 ID 的多个副本。

让我们首先通过克隆基础存储库(github.com/merlox/dapp)或自行配置npm和 Truffle 来设置项目。在设置 Truffle 或克隆存储库后,你应该有以下文件夹和初始文件:

  • contracts/

  • dist/

  • migrations/

  • node_modules/(在克隆存储库后记得使用npm install

  • src/

    • index.js

    • index.htmlindex.ejs,根据你的喜好

    • index.cssindex.styl,根据你的喜好

  • .babelrc

  • .gitignore

  • LICENSE

  • package.json

  • README.md

  • truffle-config.js

  • webpack.config.js(记得设置好你的 webpack 配置)

在你的src/文件夹内,创建一个名为components/的新文件夹,其中将包含每个 JavaScript 组件的文件,因为这是一个较大的 dApp,我们将有许多不同的组件。因为我们将有多个页面,我们希望使用 React 路由器来管理历史位置和 URL,以便用户可以在页面之间进行导航。通过在终端上运行以下命令来安装 React 路由器和web3库:

npm i -S web3 react-router-dom

设置索引页面

打开你的index.js文件,导入所需的库,并使用一些虚拟数据设置初始状态,以查看最终设计的外观。我们通过以下步骤来实现这一点:

  1. 导入所需的库。我们需要来自react-router库的几个组件,如下所示的代码:
import React from 'react'
import ReactDOM from 'react-dom'
import MyWeb3 from 'web3'
import { BrowserRouter, Route, withRouter } from 'react-router-dom'
  1. 创建构造函数,并添加一些具有必要属性的产品,以尽可能多地向用户显示信息,如下所示的代码。标题、描述、ID 和价格等属性是必须的:
class Main extends React.Component {
    constructor(props) {
        super(props)

        this.state = {
            products: [{
                id: 1,
                title: 'Clasic trendy shoes',
                description: 'New unique shoes for sale',
                date: Date.now(),
                owner: '',
                price: 12,
                image: 'https://cdn.shopify.com/s/files/1/2494/8702/products/Bjakin-2018-Socks-Running-Shoes-for-Men-Lightweight-Sports-Sneakers-Colors-Man-Sock-Walking-Shoes-Big_17fa0d5b-d9d9-46a0-bdea-ac2dc17474ce_400x.jpg?v=1537755930'
            }
            productsHtml: [],
            productDetails: [],
            product: {},
        }
    }
  1. 您可以通过复制 product 对象并更改一些参数使其看起来独特来添加更多产品。然后添加 bytes32() 函数将字符串转换为有效的十六进制以及 render() 函数,如下所示:
    bytes32(name) {
        return myWeb3.utils.fromAscii(name)
    }

    render() {
        return (
            <div>
                <Route path="/" exact render={() => (
                    <div>The dApp has been setup</div>
                )} /> 
            </div>
        )
    }
}
  1. 使用 React 路由器提供的 withRouter() 函数为我们的 Main 组件提供历史属性,这对于在您的 dApp 中在页面之间导航是必要的。如下所示:
// To be able to access the history in order to redirect users programmatically when opening a product
Main = withRouter(Main)
  1. 添加来自 React 路由器的 BrowserRouter 组件以初始化路由器对象,如下所示:
ReactDOM.render(
    <BrowserRouter>
        <Main />
    </BrowserRouter>,
document.querySelector('#root'))

BrowserRouter 组件是用于初始化路由器的主要组件,以便它们可以管理不同的页面。我们使用 withRouter 导入来访问导航历史,以便我们可以编程方式更改页面。基本上,我们需要它在我们的 dApp 中在特定时间重定向用户到不同页面。然后我们在 this.state 对象中设置一些基本产品与不同的属性。注意图片是一个 URL 而不是文件。由于我们没有处理文件的服务器,我们需要卖家在某种公共服务上托管自己的图片,比如 Imgur。

React 路由库将使用多个 Route 实例来确定在什么时间加载哪个页面。我们还必须在我们的 Main 组件顶部添加高级 BrowserRouter 组件来激活路由器。注意我们如何使用 exact path="/" 渲染单个路由,显示设置文本以确认应用程序配置成功加载后。

配置 webpack 开发服务器

创建 Main 组件后,您将想要运行应用程序以查看其外观,但在这种情况下,我们将使用 webpack-dev-server 扩展,该扩展会在我们开发时自动重新加载网站,以便我们不必不断手动重新加载它并在后端编译文件。因此,而不是设置 webpack 观察者和静态服务器,它全部包含在一个单独的命令中。使用以下命令在本地安装 webpack 服务器:

npm i -S webpack-dev-server

然后在 package.json 文件的 scripts 部分下更新新的脚本(如下所示);否则,它将无法工作,因为我们需要从项目内部执行此命令:

{
  "name": "dapp",
  "version": "1.0.0",
  "description": "",
  "main": "truffle-config.js",
  "directories": {
    "test": "test"
  },
 "scripts": {
 "dev": "webpack-dev-server -d"
 }
}

这只是使用 -d 标志运行 webpack-dev-server 命令,该标志将模式设置为开发模式,允许您从未压缩的文件中看到完整的错误消息。如果愿意,可以添加 -o 标志,在运行命令时打开浏览器。通过运行以下命令行来执行它:

npm run dev

如果一切正确,您将能够访问 localhost:8080 并看到已设置路由器的页面。

创建头部组件

我们的应用程序将有几个页面用于买家、卖家和订单。这就是为什么尽可能将每个组件尽可能地分离成可以在需要时导入的唯一块的重要性,通过执行以下步骤来完成:

  1. src/components/ 文件夹内创建一个新的组件,用于显示我们网站的标题,并创建一个名为 Header.js 的文件,放在你的 components 文件夹内,如下面的代码所示:
import React from 'react'
import { Link } from 'react-router-dom'

function Header() {
    return (
        <div className="header">
            <Link to="/">ECOMMERCE</Link>
            <div>
                <Link to="/">Home</Link>
                <Link to="/sell">Sell</Link>
                <Link to="/orders">Orders</Link>
            </div>
        </div>
    )
}

export default Header
  1. 使用 export default Header 导出,以便其他文件可以访问你的组件。然后按照下面的代码将其导入到你的 index.js 页面中,以在导入的库下方显示它,以保持它们的顺序:
import React from 'react'
import ReactDOM from 'react-dom'
import MyWeb3 from 'web3'
import { BrowserRouter, Route, withRouter } from 'react-router-dom'
import Header from './components/Header'
  1. 使用组件实例更新你的 render() 函数,如下面的代码所示:
render() {
    return (
        <div>
            <Route path="/" exact render={() => (
                <Header />
            )} />
        </div>
    )
}

你将看到你的标题会自动加载,无需刷新你的 webpack 服务,如下面的截图所示:

  1. 目前看起来不太好,所以让我们用一些 stylus CSS 来改进设计。如果你还没有配置它,请使用以下命令安装 stylusstylus-loader 库:
npm i -S stylus stylus-loader
  1. 根据以下内容更新你的 webpack 配置:
require('babel-polyfill')
const webpack = require('webpack')
const html = require('html-webpack-plugin')
const path = require('path')

module.exports = {
    entry: ['babel-polyfill', './src/index.js'],
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader'
                }
            }, {
 test: /\.styl$/,
 exclude: /node_modules/,
 use: [
 {loader: 'style-loader'},
 {loader: 'css-loader'},
 {loader: 'stylus-loader'}
 ]
 }
        ]
    },
    plugins: [
        new webpack.HotModuleReplacementPlugin(),
        new html({
            title: "dApp project",
            template: './src/index.ejs',
            hash: true
        })
    ]
}

这里是我们在 webpack 文件中进行的主要更改:

  • 我们导入 webpack,以便我们可以使用 webpack.HotModuleReplacementPlugin() 在进行更改时部分重新加载页面。而不是重新加载整个页面,只会重新加载更改的组件。

  • 然后我们设置 stylus 加载器来加载 styl 文件。

  1. 创建 index.styl,采用以下设计,尽管最终你的电子商务商店的外观是由你决定的:
productPadding = 20px

body
    background-color: whitesmoke
    font-family: sans-serif
    margin: 0

button
    border: none
    background-color: black
    color: white
    cursor: pointer
    padding: 10px
    width: 200px
    height: 50px

    &:hover
        opacity: 0.9

input, textarea
    padding: 20px
    border: 1px solid black

.header
    background-color: black
    color: white
    padding: 15px
    margin-bottom: 20px
    text-align: center
    display: flex
    justify-content: space-around

    a
        color: white
        text-decoration: none
        margin-right: 10px

        &:hover
            color: lightgrey
  1. 注意顶部的 productPadding 变量。Stylus 允许我们创建变量,以便我们可以轻松地在样式文件中配置相同值的多个实例;我们稍后会使用该变量。然后像这样在你的 index.js 文件中导入 stylus 文件:
import './index.styl'

现在检查你的应用在浏览器中的外观;由于你更新了 webpack 配置,你可能需要重新加载你的 webpack 服务器:

创建主页组件

Home 组件将包含显示用户首次打开 dApp 时看到的第一个页面的逻辑,以便他们可以开始购买产品。该组件将是管理剩余页面的核心组件。

创建一个默认设计的 Home 组件,用于主页;它将包含一个干净设计的最新产品。以下是位于组件文件夹中的 Home.js 文件的代码:

import React from 'react'
import MyWeb3 from 'web3'
import Header from './Header'

class Home extends React.Component {
    constructor() { super() }
    render() {
        return (
            <div>
                <Header />
                <div className="products-container">{this.props.productsHtml}</div>
                <div className="spacer"></div>
            </div>
        )
    }
}

export default Home

你可以将其导入到你的 index.js 文件中,这将是主要的数据和功能来源。同时,从索引中删除 Header 的导入,因为它已经包含在 Home 组件中了。下面的步骤显示了你需要做的更改,以便将 Home 组件包含在你的 dApp 中:

  1. 在文件开头导入组件,同时移除Header组件,因为我们已经在Home组件中包含了它:
import React from 'react'
import ReactDOM from 'react-dom'
import MyWeb3 from 'web3'
import { BrowserRouter, Route, withRouter } from 'react-router-dom'
import Home from './components/Home'
import './index.styl'
  1. 为了简化事情,我创建了Array对象的原型 JavaScript 方法。这是一种可以用来改变某些函数工作方式的 JavaScript 方法的高级实现。特别地,我创建了一个异步的for循环,可以被await,以确保它在继续执行代码的其余部分之前完成,如下面的代码片段所示。基本上,这是一种干净的运行循环的方法:
Array.prototype.asyncForEach = function (callback) {
 return new Promise(resolve => {
 for(let i = 0; i < this.length; i++) {
 callback(this[i], i, this)
 }
 resolve()
 })
}
  1. 在您的构造函数中,包括一个setup()函数调用,如下面的代码片段所示:
constructor(props) {
    super(props)
    // State object omitted for simplicity
    this.setup()
}
  1. 使用以下代码片段展示的代码来实现setup()函数,并启动 web3 实例和显示产品:
async setup() {
    // Create the contract instance
    window.myWeb3 = new MyWeb3(ethereum)
    try {
        await ethereum.enable();
    } catch (error) {
        console.error('You must approve this dApp to interact with it')
    }
    const user = (await myWeb3.eth.getAccounts())[0]
    let products = []
    for(let i = 0; i < this.state.products.length; i++) {
        products[i] = this.state.products[i]
        products[i].owner = user
    }
    this.setState({products})
    this.displayProducts()
}
  1. 我们添加了对displayProducts()函数的调用,这将用于通过循环遍历我们state对象中的产品数组来显示产品,如下面的代码片段所示:
async displayProducts() {
    let productsHtml = []
    await this.state.products.asyncForEach(product => {
        productsHtml.push((
            <div key={product.id} className="product">
                <img className="product-image" src={product.image} />
                <div className="product-data">
                    <h3 className="product-title">{product.title}</h3>
                    <div className="product-description">{product.description.substring(0, 50) + '...'}</div>
                    <div className="product-price">{product.price} ETH</div>
                    <button onClick={() => {
                        this.setState({product})
                        this.redirectTo('/product')
                    }} className="product-view" type="button">View</button>
                </div>
            </div>
        ))
    })
    this.setState({productsHtml})
}
  1. 修改render()函数,并包括一个名为redirectTo()的函数,当用户使用 React 路由器点击按钮时,将允许您更改页面,如下面的代码片段所示:
    redirectTo(location) {
 this.props.history.push({
 pathname: location
 })
 }

    render() {
        return (
            <div>
                <Route path="/" exact render={() => (
 <Home
 productsHtml={this.state.productsHtml}
 />
                )} />
            </div>
        )
    }
}

我们对此索引文件进行了以下重要的添加:

  • 首先,我们为Array对象设置了一个自定义的原型函数,名为asyncForEach。你可能对 JavaScript 的深层原理不熟悉,但你必须了解所有类型的变量都是带有名为prototype的属性的对象,该属性包含该变量类型的方法。默认的forEach方法在 JavaScript 的某个地方被定义为Array.prototype.forEach = function() {...};这样做的目的是创建一个自定义的for循环,我们可以在这里使用await,以充分利用async函数。因此,我们可以用await array.asyncForEach()来代替for(let i = 0; i < array.length; i++) {},这样更容易阅读,且减少了混乱的代码。这只是我想要用来提高代码可读性和增加可用性的一种实现。

  • 然后我们导入了Home组件而不是Header组件,并在Routerender()函数内替换了它。

  • redirectTo函数通过使用我们之前看到的withRouter历史对象加载新页面来更改我们当前所见的Route。当用户点击displayProducts函数内的View按钮时,将使用此函数。

  • 在此之后,我们添加了一个setup函数,该函数配置 MetaMask 并将所有这些示例产品的所有者地址添加到其中,这样你就可以看到谁拥有这些物品。

  • 最后,我们创建了一个名为displayProducts()的函数,用于为每个产品生成 HTML,并将其推入产品数组并更新状态。Home组件然后将这些产品作为prop接收,并显示每个产品。

现在我们可以添加一些 CSS 代码来改善主页的外观,如下所示:

.products-container
    display: grid
    width: 80%
    margin: auto
    grid-template-columns: 1fr 1fr 1fr
    justify-items: center
    margin-top: 50px

    .product
        width: 400px
        border: 1px solid black

        .product-image
            width: 100%
            grid-column: 1 / 3
            box-shadow: 0 3px 0px 0 lightgrey

        .product-data
            display: grid
            grid-template-columns: 1fr 1fr
            grid-template-rows: 50px 20px 40px
            align-items: center
            padding: 10px productPadding
            grid-column-gap: productPadding
            background-color: white

            .product-description
                font-size: 10pt

            .product-price
                font-size: 11pt

            .product-view
                width: 200px
                grid-column: 2 / 3
                margin-top: 50px
                height: 50px

.spacer
    height: 200px
    width: 100%

现在网页的外观如下所示:

正如您所见,我们正在快速进展!对于这些类型的复杂应用程序,初始设置需要一些时间,但之后它是一件很棒的事情,因为您可以轻松更新每个单独的部分,同时为未来的改进保证了良好的可维护性因素。电子商务商店的主题与许多鞋店相似:它使用扁平设计和黑色调,同时也弹出一个元素,如按钮,以赋予它三维感。它让我想起了时尚杂志。

创建产品组件

现在我们有了一个基本的设计,当用户点击“查看”按钮时,我们可以创建产品页面,以便用户可以详细了解有关特定产品的更多信息。用户将能够在产品页面内购买产品。让我们按照以下步骤进行:

  1. 在您的组件内添加一个新的 Product.js 文件,并使用以下代码,尽管我总是建议您在查看解决方案之前自己尝试:
import React from 'react'
import Header from './Header'

class Product extends React.Component {
    constructor() { super() }
    render() {
        return (
            <div>
                <Header />
                <div className="product-details">
                    <img className="product-image" src={this.props.product.image} />
                    <div className="product-data">
                        <h3 className="product-title">{this.props.product.title}</h3>
                        <ul className="product-description">
                            {this.props.product.description.split('\n').map((line, index) => (
                                <li key={index}>{line}</li>
                            ))}
                        </ul>
                        <div className="product-data-container">
                            <div className="product-price">{this.props.product.price} ETH</div>
                            <div className="product-quantity">{this.props.product.quantity} units available</div>
                        </div>
                        <button onClick={() => {
                            this.props.redirectTo('/buy')
                        }} className="product-buy" type="button">Buy</button>
                    </div>
                </div>
            </div>
        )
    }
}

export default Product
  1. 我们需要一个新的页眉,因为当我们更改页面时,将加载一个新的组件(在本例中是 Product 组件),所以我们需要仅向 Product 组件显示必要的信息。然后,我们可以将其导入到索引文件中的新 Route 中,如下面的代码所示:
import React from 'react'
import ReactDOM from 'react-dom'
import MyWeb3 from 'web3'
import { BrowserRouter, Route, withRouter } from 'react-router-dom'
import Home from './components/Home'
import Product from './components/Product'
import './index.styl'

class Main extends React.Component {
    // Omitted previous code to keep the demonstration short 

    render() {
        return (
            <div>
                <Route path="/" exact render={() => (
                    <Home
                        productsHtml={this.state.productsHtml}
                    />
                )} />
 <Route path="/product" render={() => (
 <Product
 product={this.state.product}
 />
                )} />
            </div>
        )
    }
}
  1. 当您单击“查看”按钮时,应该能够访问自定义产品页面,假设我们已设置所需的历史功能。当用户单击“查看”按钮时,Product 组件的 product 属性也会设置。添加以下 CSS 代码以修复产品页面的设计:
.product-details
    display: grid
    width: 70%
    margin: auto
    grid-template-columns: 70% 30%
    grid-template-rows: 1fr
    margin-bottom: 50px
    grid-column-gap: 40px

    .product-image
        grid-column: 1 / 2
        justify-self: center

    .product-title, .product-description, .product-price, .product-buy
        grid-column: 2 / 3

    .product-description
        white-space: pre-wrap
        line-height: 20pt

    .product-data-container
        display: flex
        justify-content: space-between
        margin-bottom: 20px
  1. 您可以打开您的 dApp,点击产品的“查看”按钮,查看详细的产品页面,其中显示更大的图片和完整的描述,如下面的截图所示:

剩下的是添加购买、销售和订单页面。以下是我们如何使用 Buy 组件的方法,当用户单击位于产品页面中的“购买”按钮时,它将被显示:

  1. 导入所需的库,使用以下代码:
import React, { Component } from 'react'
import Header from './Header'
  1. Buy 组件内定义构造函数,其中状态变量为空,这样您就知道整个组件中将使用哪些变量,您可以使用以下代码完成此操作:
class Buy extends Component {
    constructor() {
        super()
        this.state = {
            nameSurname: '',
            lineOneDirection: '',
            lineTwoDirection: '',
            city: '',
            stateRegion: '',
            postalCode: '',
            country: '',
            phone: '',
        }
    }
  1. render 页面函数将显示一些基本的产品信息,以通知买家他们将获得什么,如下所示的代码:
    render() {
        return (
            <div>
                <Header />
                <div className="product-buy-page">
                    <h3 className="title">Product details</h3>
                    <img className="product-image" src={this.props.product.image} />
                    <div className="product-data">
                        <p className="product-title">{this.props.product.title}</p>
                        <div className="product-price">{this.props.product.price} ETH</div>
                    </div>
                </div>
  1. 包含一个区块,用于用户输入其地址以便可以免费获得产品的运输信息,如下面的代码所示:
                <div className="shipping-buy-page">
                    <h3>Shipping</h3>
                    <input onChange={e => {
                        this.setState({nameSurname: e.target.value})
                    }} placeholder="Name and surname..." type="text" />
                    <input onChange={e => {
                        this.setState({lineOneDirection: e.target.value})
                    }} placeholder="Line 1 direction..." type="text" />
                    <input onChange={e => {
                        this.setState({lineTwoDirection: e.target.value})
                    }} placeholder="Line 2 direction..." type="text" />
                    <input onChange={e => {
                        this.setState({city: e.target.value})
                    }} placeholder="City..." type="text" />
                    <input onChange={e => {
                        this.setState({stateRegion: e.target.value})
                    }} placeholder="State or region..." type="text" />
                    <input onChange={e => {
                        this.setState({postalCode: e.target.value})
                    }} placeholder="Postal code..." type="number" />
                    <input onChange={e => {
                        this.setState({country: e.target.value})
                    }} placeholder="Country..." type="text" />
                    <input onChange={e => {
                        this.setState({phone: e.target.value})
                    }} placeholder="Phone..." type="number" />
                    <button>Buy now to this address</button>
                </div>
            </div>
  1. 导出组件,以便可以将其导入到您的路由管理器中,如下面的代码所示:
export default Buy

我们只需要显示一个带有用户地址参数的表单,因为这是我们唯一需要的信息。我们可以假设运费都是免费的,已包含在价格中。我们将更新此Buy组件的状态以包含详细信息,以便稍后将这些数据提交给智能合约。然后在索引文件的开头导入Buy组件。我已经为您突出显示了新的导入位置,以便您看到Buy组件应该位于何处,如下面的代码所示:

import React from 'react'
import ReactDOM from 'react-dom'
import MyWeb3 from 'web3'
import { BrowserRouter, Route, withRouter } from 'react-router-dom'
import Home from './components/Home'
import Product from './components/Product'
import Buy from './components/Buy'
import './index.styl'

然后在您的render函数中添加新的Routeprops参数到刚刚导入的Buy组件中。更改已突出显示,以便您可以更快地找到它们,如下面的代码所示:

class Main extends React.Component {
    // Omitted the other functions to keep it short

    render() {
        return (
            <div>
                <Route path="/" exact render={() => (
                    <Home
                        productsHtml={this.state.productsHtml}
                    />
                )} />
                <Route path="/product" render={() => (
                    <Product
                        product={this.state.product}
                        redirectTo={location => this.redirectTo(location)}
                    />
                )} />
 <Route path="/buy" render={() => (
 <Buy
 product={this.state.product}
 />
 )} />
            </div>
        )
    }
}

我们只需要将state.product发送到此组件,以便我们可以看到正在购买的产品。添加一些 CSS 代码,通过执行以下步骤使其看起来好看:

  1. 使用以下代码为Buy组件的产品部分添加 CSS 代码:
.product-buy-page
    display: grid
    margin: auto
    width: 50%
    padding: 20px
    padding-top: 0
    grid-template-columns: 50% 50%
    grid-template-rows: auto 1fr
    margin-bottom: 50px
    grid-column-gap: 40px
    border: 1px solid black
    background-color: white

    .title
        grid-column: 1 / 3
        justify-self: center

    .product-image
        grid-column: 1 / 2
        height: 150px
        justify-self: end

    .product-title
        margin-bottom: 25px

    .product-price
        font-size: 15pt
        font-weight: bold
  1. 添加Buy组件的运输表单的 CSS 代码,如下面的代码所示:
.shipping-buy-page
    display: grid
    flex-direction: column
    justify-items: center
    width: 50%
    margin: auto
    margin-bottom: 200px

    input
        margin-bottom: 10px
        width: 100%

创建Sell组件

我们正在构建一个分散式市场,全世界的用户都可以通过发布自己的产品免费加入。不会收取任何费用,并且购买将以加密货币完成。因此,我们需要一个专门为这些卖家的页面,我们将通过以下步骤创建一个Sell组件:

  1. 导入必要的库以创建 React 组件,并包含Header:
import React from 'react'
import Header from './Header'
  1. 创建具有空构造函数的Sell类,其中包含用户将出售的产品的titledescriptionimagepricestate对象,如下面的代码所示:
class Sell extends React.Component {
    constructor() {
        super()
        this.state = {
            title: '',
            description: '',
            price: '',
            image: '',
        }
    }
}
  1. 创建具有整洁形式的render()函数,允许用户访问公共产品,如下面的代码所示。请注意,图像是一个字符串,因为我们将使用外部 URL 来提供图像,而不是自己托管文件:
render() {
    return (
        <div>
            <Header />
            <div className="sell-page">
                <h3>Sell product</h3>
                <input onChange={event => {
                    this.setState({title: event.target.value})
                }} type="text" placeholder="Product title..." />
                <textarea placeholder="Product description..." onChange={event => {
                    this.setState({description: event.target.value})
                }}></textarea>
                <input onChange={event => {
                    this.setState({price: event.target.value})
                }} type="text" placeholder="Product price in ETH..." />
                <input onChange={event => {
                    this.setState({image: event.target.value})
                }} type="text" placeholder="Product image URL..." />
                <p>Note that shipping costs are considered free so add the shipping price to the cost of the product itself</p>
                <button onClick={() => {
                    this.props.publishProduct(this.state)
                }} type="button">Publish product</button>
            </div>
        </div>
    )
}
  1. 使用以下代码导出此新组件,以便其他文件可以导入它:
export default Sell

在保存Sell组件之后,将其导入到您的索引 JavaScript 文件中。我们将添加一个名为publishProduct的函数,它将调用相应的智能合约函数。

下面的步骤显示了需要对索引文件进行的更改(以提高清晰度),以便导入此Sell组件:

  1. Buy组件导入下面直接导入Sell组件,如下面的代码所示:
import React from 'react'
import ReactDOM from 'react-dom'
import MyWeb3 from 'web3'
import { BrowserRouter, Route, withRouter } from 'react-router-dom'
import Home from './components/Home'
import Product from './components/Product'
import Buy from './components/Buy'
import Sell from './components/Sell'
import './index.styl'
  1. render()函数中包含Sell组件及其自己的route对象,同时定义一个publishProduct()函数,如下所示的函数:
class Main extends React.Component {
    // Omitted the other functions to keep it short

 async publishProduct(data) {}

    render() {
        return (
            <div>
                <Route path="/" exact render={() => (
                    <Home
                        productsHtml={this.state.productsHtml}
                    />
                )} />
                <Route path="/product" render={() => (
                    <Product
                        product={this.state.product}
                        redirectTo={location => this.redirectTo(location)}
                    />
                )} />
                <Route path="/buy" render={() => (
                    <Buy
                        product={this.state.product}
                    />
                )} /> <Route path="/sell" render={() => (
                    <Sell
                        publishProduct={data => this.publishProduct(data)}
                    />
                )} />
            </div>
        )
    }
}
  1. 添加一些 CSS 代码以改善此页面的设计,如下所示的函数:
.sell-page
    display: grid
    flex-direction: column
    justify-items: center
    width: 50%
    margin: auto
    margin-bottom: 200px

    input, textarea
        width: 100%
        margin-bottom: 10px

您可以通过单击页眉中的Sell按钮来查看其外观,该按钮将重定向到/sellURL,加载Sell组件。

创建订单组件

通过以下步骤添加最终的Orders.js组件。在看解决方案之前,试着自己做一下,以便你练习一下使用一些stylus CSS 完成设计的技能。你会发现这比预期的时间要长,但是一切都是值得的:

  1. 导入所需的库,如下代码所示:
import React, { Component } from 'react'
import Header from './Header'
  1. 定义构造函数并添加一些虚构的订单,以便你可以看到它的外观,如下代码所示:
class Orders extends Component {
    constructor() {
        super()

        // We'll separate the completed vs the pending based on the order state
        this.state = {
            sellOrders: [{
                id: 1,
                title: 'Classic trendy shoes',
                description: 'New unique shoes for sale',
                date: Date.now(),
                owner: '',
                price: 12,
                image: 'https://cdn.shopify.com/s/files/1/2494/8702/products/Bjakin-2018-Socks-Running-Shoes-for-Men-Lightweight-Sports-Sneakers-Colors-Man-Sock-Walking-Shoes-Big_17fa0d5b-d9d9-46a0-bdea-ac2dc17474ce_400x.jpg?v=1537755930',
                purchasedAt: Date.now(),
                state: 'completed',
            }],
            pendingSellOrdersHtml: [],
            pendingBuyOrdersHtml: [],
            completedSellOrdersHtml: [],
            completedBuyOrdersHtml: [],
        }

        this.displayOrders()
    }
  1. 我们需要一个函数来通过从智能合约获取数据来获取用户的订单,同时标记订单为已完成。我们暂时不会实现这些函数,因为我们首先必须创建智能合约,如下代码所示:
    async getUserOrders() {}

    async markAsCompleted(product) {}
  1. 添加这些空函数,然后创建一个名为displayOrders()的函数,它将使用状态数据来输出生成的 HTML。首先定义内部使用的数组,如下代码所示:
async displayOrders() {
    let pendingSellOrdersHtml = []
    let pendingBuyOrdersHtml = []
    let completedSellOrdersHtml = []
    let completedBuyOrdersHtml = []
}
  1. 阅读不同顺序的对象以循环遍历它们,并生成结果有效的 JSX。根据产品状态分类产品,如下代码所示:
await this.state.sellOrders.asyncForEach(product => {
    if(product.state == 'pending') {
        pendingSellOrdersHtml.push(
            <div key={product.id} className="product">
                <img className="product-image" src={product.image} />
                <div className="product-data">
                    <h3 className="small-product-title">{product.title}</h3>
                    <div className="product-state">State: {product.state}</div>
                    <div className="product-description">{product.description.substring(0, 15) + '...'}</div>
                    <div className="product-price">{product.price} ETH</div>
                    <button className="small-view-button" onClick={() => {
                        this.props.setState({product})
                        this.props.redirectTo('/product')
                    }} type="button">View</button>
                    <button className="small-completed-button" onClick={() => {
                        this.markAsCompleted(product)
                    }} type="button">Mark as completed</button>
                </div>
            </div>
        )
  1. 如果卖单的状态是已完成,将其推入completedSellOrders数组中,因为我们想要根据它们的状态分类订单,如下代码所示。创建一个新的 HTML 块,因为它会略有不同,因为我们想要使用一个按钮来标记产品为已完成:
} else {
        completedSellOrdersHtml.push(
            <div key={product.id} className="product">
                <img className="product-image" src={product.image} />
                <div className="product-data">
                    <h3 className="product-title">{product.title}</h3>
                    <div className="product-state">State: {product.state}</div>
                    <div className="product-description">{product.description.substring(0, 15) + '...'}</div>
                    <div className="product-price">{product.price} ETH</div>
                    <button onClick={() => {
                        this.props.setState({product})
                        this.props.redirectTo('/product')
                    }} className="product-view" type="button">View</button>
                </div>
            </div>
        )
    }
})
  1. 使用相同的过程来设计buyOrders数组的 HTML,以循环遍历数组,如下代码所示:
await this.state.buyOrders.asyncForEach(product => {
    let html = (
        <div key={product.id} className="product">
            <img className="product-image" src={product.image} />
            <div className="product-data">
                <h3 className="product-title">{product.title}</h3>
                <div className="product-state">State: {product.state}</div>
                <div className="product-description">{product.description.substring(0, 15) + '...'}</div>
                <div className="product-price">{product.price} ETH</div>
                <button onClick={() => {
                    this.props.setState({product})
                    this.props.redirectTo('/product')
                }} className="product-view" type="button">View</button>
            </div>
        </div>
    )

    if(product.state == 'pending') pendingBuyOrdersHtml.push(html)
    else completedBuyOrdersHtml.push(html)
})
  1. 使用生成的 HTML 对象更新组件的状态,如下代码所示:
this.setState({pendingSellOrdersHtml, pendingBuyOrdersHtml, completedSellOrdersHtml, completedBuyOrdersHtml})
  1. 创建render()函数来展示这些生成的订单,如下代码所示:
    render() {
        return (
            <div>
                <Header />
                <div className="orders-page">
                    <div>
                        <h3 className="order-title">PENDING ORDERS AS A SELLER</h3>
                        {this.state.pendingSellOrdersHtml}
                    </div>
                    <div>
                        <h3 className="order-title">PENDING ORDERS AS A BUYER</h3>
                        {this.state.pendingBuyOrdersHtml}
                    </div>
                    <div>
                        <h3 className="order-title">COMPLETED SELL ORDERS</h3>
                        {this.state.completedSellOrdersHtml}
                    </div>
                    <div>
                        <h3 className="order-title">COMPLETED BUY ORDERS</h3>
                        {this.state.completedBuyOrdersHtml}
                    </div>
                </div>
            </div>
        )
    }
}
  1. 导出Orders组件对象,如下代码所示:
export default Orders

这是一个很长的代码,因为我们在状态对象中添加了一些样本订单数据,以显示订单页面的真实视图。你可以看到我们为每个产品添加了一个state属性,它显示了订单是待定还是已完成。这将在智能合约中设置。displayOrders函数生成每种类型订单的 HTML 对象,因为我们想要分离已完成和待定以及买入和卖出的订单,以便你可以看到所有重要信息。当实现智能合约时,订单将来自getUserOrders函数。添加一些 CSS 使其看起来不错。你可以在官方 GitHub 上检查到我的设计,网址为github.com/merlox/ecommerce-dapp,在src/文件夹内。

最后,你将会得到一个很酷的订单页面,如下截图所示:

关于 React 中的用户界面就是这样了!只是为了确保,一旦所有组件创建完成,你应该在src/文件夹里有以下文件:

  • components/

    • Buy.js

    • Header.js

    • Sell.js

    • Product.js

    • Home.js

    • Orders.js

  • index.ejs

  • index.js

  • index.styl

理解 ERC-721 代币

这种新类型的代币用于在我们的智能合约中生成独特的产品。ERC-721 标准已被官方以太坊团队批准,这意味着您可以将其用于各种应用程序,而且知道它将与依赖于此标准的工具和智能合约兼容。就像 ERC-20 代币催生了去中心化代币交易所一样,我们可以预期会创建去中心化 ERC-721 交易所和数字以及实物产品市场。

解释 ERC-721 函数

要理解 ERC-721 代币的工作原理,最好查看定义 ERC-721 代币的函数,这样你就可以理解它们的内部工作方式。以下是描述这些函数的列表:

  • balanceOf(owner): 返回给定地址所有代币数量的计数,该地址用户拥有的代币。

  • ownerOf(tokenId): 返回拥有特定代币 ID 的地址。

  • safeTransferFrom(from, to, tokenId, data): 给定授权后,将代币从一个地址发送到另一个地址,就像这个短语对 ERC-20 代币所做的那样。它被称为安全,因为如果接收方是一个合约,它会检查合约是否能够接收 ERC-721 代币,这意味着接收合约已实现了onERC721Received函数,这样你就不会把代币丢失给不能管理这些类型代币的合约。data参数可以省略,它只是包含您可能想要发送到to接收方地址的额外字节信息。from地址必须是当前所有者,所以您可以将此函数用作普通的transfer函数或transferFrom函数(您可能熟悉使用 ERC-20 代币)来批准向另一个地址发送代币。

  • transferFrom(from, to, tokenId): 这与前一个函数相同,但它不确保接收地址能够管理这些类型的代币,如果它是一个智能合约的话。

  • approve(to, tokenId): 用于向另一个所有者批准特定代币,以便他们可以随意使用它。

  • setApprovalForAll(operator, approved): 这是为另一个地址,即operator地址,创建您所有代币的授权,以便其管理您的整个余额。您可以通过将approved参数设置为false来撤销对特定操作员的访问权限。

  • getApproved(tokenId): 返回具有此代币授权的地址。

  • isApprovedForAll(owner, operator): 如果operator可以访问所有所有者的代币,则返回true

注意他们从 ERC-20 规范中删除了我们熟悉的transfer函数,因为它通过允许使用transferFromsafeTransferFrom函数作为普通转账或已批准的转账来简化了流程,从而省去了标准transfer函数的需要。

_mint(owner, tokenId)_burn(tokenId) 内部函数用于生成和删除代币;然而,它们在标准的 ERC721.sol 智能合约中不可用,因为它们是内部函数,这意味着你需要创建一个新合约,继承该 ERC-721 合约并实现自定义的 mint(owner, tokenId)burn(tokenId) 函数(去掉下划线),根据需要进行任何修改,因为我们希望限制谁能创建或删除代币。

你能想象每个人都能随心所欲地生成代币吗?那将违背拥有有价值代币的目的,所以他们强迫你使用有限访问权限创建自己的铸造函数,可能还带有 onlyOwner 修饰符。在我们的案例中,我们将允许卖家为其产品铸造新类型的 ERC-721 代币。

我们去中心化电子商务商店中的每个产品将代表一个唯一的 ERC-721 代币;这就是为什么我们不想为每个产品添加多个数量,因为我们将不得不创建几个唯一的 ERC-721 实例。另外,NFT 意味着每个代币在其不同的属性上都是独一无二的。与 ERC-20 相比,其中每个代币都是相同的,ERC-721 标准旨在用于唯一物品,如家庭产品、手工制品、艺术品或独特的数字资产,如游戏中的皮肤。有趣的是,你可以根据需要组合这两种标准以创建独特的代币,同时还能够生成相同的多个实例。

ERC-721 智能合约

现在你已经了解了这些类型的 NFT 如何工作,让我们来看一下 ERC-721 合约接口。实现可在 GitHub 上找到 github.com/merlox/ecommerce-dapp/blob/master/contracts/ERC721.sol,因为完整的代码太长无法在此显示:

pragma solidity ⁰.5.0;

contract IERC721{
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
    function balanceOf(address owner) public view returns (uint256 balance);
    function ownerOf(uint256 tokenId) public view returns (address owner);
    function approve(address to, uint256 tokenId) public;
    function getApproved(uint256 tokenId) public view returns (address operator);
    function setApprovalForAll(address operator, bool _approved) public;
    function isApprovedForAll(address owner, address operator) public view returns (bool);
    function transferFrom(address from, address to, uint256 tokenId) public;
    function safeTransferFrom(address from, address to, uint256 tokenId) public;
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public;
}

这个合约与 ERC-20 合约非常相似,因为它们背后的基本思想是相同的。该合约用于生成许多具有独特的代币,其挖矿功能必须单独实现,因为你想控制谁能够创建代币,谁能够销毁它们。

在你的 contracts/ 文件夹中创建名为 ERC721.sol 的文件,并添加以下代码,我们马上就会用到它。我们将创建一个合约,该合约继承 ERC-721 智能合约以实现 mint() 函数,因为默认的 ERC-721 实现不可访问。在那里创建一个名为 Ecommerce.sol 的新文件,并使用以下代码导入 ERC721.sol 合约:

pragma solidity ⁰.5.0;

import './ERC721.sol';

Solidity 版本并不重要,只要功能相同即可。创建一个自定义实现你自己的 ERC-721 智能合约,继承这个合约,如下所示:

pragma solidity ⁰.5.0;

import './ERC721.sol';

/// @notice The Ecommerce Token that implements the ERC721 token with mint function
/// @author Merunas Grincalaitis <merunasgrincalaitis@gmail.com>
contract EcommerceToken is ERC721 {
 address public ecommerce;
 bool public isEcommerceSet = false;
    /// @notice To generate a new token for the specified address
    /// @param _to The receiver of this new token
    /// @param _tokenId The new token id, must be unique
 function mint(address _to, uint256 _tokenId) public {
 require(msg.sender == ecommerce, 'Only the ecommerce contract can mint new tokens');
 _mint(_to, _tokenId);
 }

    /// @notice To set the ecommerce smart contract address
 function setEcommerce(address _ecommerce) public {
 require(!isEcommerceSet, 'The ecommerce address can only be set once');
 require(_ecommerce != address(0), 'The ecommerce address cannot be empty');
 isEcommerceSet = true;
 ecommerce = _ecommerce;
 }
}

此代币合约将仅允许电子商务合约生成新代币,在购买完成后将其转移到买家名下;在你能够铸造代币之前,必须设置 setEcommerce 函数。

开发电子商务智能合约

开发与 ERC-721 代币交互的智能合约很简单,因为我们只需确保用户的产品关联有一个代币 ID。如果用户希望这样做,他们将能够独立与他们的代币进行交互。对于我们的市场,我们将专注于创建购买和销售功能,以创建和销毁代币。像往常一样,我们还将创建多个 getter 从智能合约中提取数据供用户界面使用。

让我们开始创建电子商务合同,将所有市场逻辑放在同一个文件中,因为它不会占用太多空间:

  1. 定义智能合约所需的变量,从您需要的结构开始,如下面的代码所示:
/// @notice The main ecommerce contract to buy and sell ERC-721 tokens representing physical or digital products because we are dealing with non-fungible tokens, there will be only 1 stock per product
/// @author Merunas Grincalaitis <merunasgrincalaitis@gmail.com>
contract Ecommerce {
    struct Product {
        uint256 id;
        string title;
        string description;
        uint256 date;
        address payable owner;
        uint256 price;
        string image;
    }
    struct Order {
        uint256 id;
        address buyer;
        string nameSurname;
        string lineOneDirection;
        string lineTwoDirection;
        bytes32 city;
        bytes32 stateRegion;
        uint256 postalCode;
        bytes32 country;
        uint256 phone;
        string state; // Either 'pending', 'completed'
    }
  1. 添加映射、数组、变量和构造函数,如下面的代码所示:
    // Seller address => products
    mapping(address => Order[]) public pendingSellerOrders; // The products waiting to be fulfilled by the seller, used by sellers to check which orders have to be filled
    // Buyer address => products
    mapping(address => Order[]) public pendingBuyerOrders; // The products that the buyer purchased waiting to be sent
    mapping(address => Order[]) public completedOrders;
    // Product id => product
    mapping(uint256 => Product) public productById;
    // Product id => order
    mapping(uint256 => Order) public orderById;
    Product[] public products;
    uint256 public lastId;
    address public token;

    /// @notice To setup the address of the ERC-721 token to use for this contract
    /// @param _token The token address
    constructor(address _token) public {
        token = _token;
    }
}

我们必须首先设置变量,从结构开始,这种情况下是ProductOrder。每个订单都将通过 ID 引用特定的产品,在这两种情况下 ID 将是相同的,这意味着每个产品将与具有相同 ID 的订单对应。将有映射用于尚未完成的待处理订单,并有其他映射用于已完成的订单,以便我们有已完成订单的参考。构造函数将接收令牌地址,以便电子商务合约可以创建新的代币。

创建发布功能

创建一个功能来发布新产品,以便用户可以通过以下代码自行出售产品。图像 URL 将是图像所在的位置:

/// @notice To publish a product as a seller
/// @param _title The title of the product
/// @param _description The description of the product
/// @param _price The price of the product in ETH
/// @param _image The image URL of the product
function publishProduct(string memory _title, string memory _description, uint256 _price, string memory _image) public {
    require(bytes(_title).length > 0, 'The title cannot be empty');
    require(bytes(_description).length > 0, 'The description cannot be empty');
    require(_price > 0, 'The price cannot be empty');
    require(bytes(_image).length > 0, 'The image cannot be empty');

    Product memory p = Product(lastId, _title, _description, now, msg.sender, _price, _image);
    products.push(p);
    productById[lastId] = p;
    EcommerceToken(token).mint(address(this), lastId); // Create a new token for this product which will be owned by this contract until sold
    lastId++;
}

此功能将检查参数,以便在设置参数的同时铸造新的代币。

创建购买功能

现在用户可以发布要出售的产品后,您可以开始编写buy功能来购买产品:

/// @notice To buy a new product, note that the seller must authorize this contract to manage the token
/// @param _id The id of the product to buy
/// @param _nameSurname The name and surname of the buyer
/// @param _lineOneDirection The first line for the user address
/// @param _lineTwoDirection The second, optional user address line
/// @param _city Buyer's city
/// @param _stateRegion The state or region where the buyer lives
/// @param _postalCode The postal code of his location
/// @param _country Buyer's country
/// @param _phone The optional phone number for the shipping company
function buyProduct(uint256 _id, string memory _nameSurname, string memory _lineOneDirection, string memory _lineTwoDirection, bytes32 _city, bytes32 _stateRegion, uint256 _postalCode, bytes32 _country, uint256 _phone) public payable {
    // The line 2 address and phone are optional, the rest are mandatory
    require(bytes(_nameSurname).length > 0, 'The name and surname must be set');
    require(bytes(_lineOneDirection).length > 0, 'The line one direction must be set');
    require(_city.length > 0, 'The city must be set');
    require(_stateRegion.length > 0, 'The state or region must be set');
    require(_postalCode > 0, 'The postal code must be set');
    require(_country > 0, 'The country must be set');

    Product memory p = productById[_id];
    require(bytes(p.title).length > 0, 'The product must exist to be purchased');
    Order memory newOrder = Order(_id, msg.sender, _nameSurname, _lineOneDirection, _lineTwoDirection, _city, _stateRegion, _postalCode, _country, _phone, 'pending');
    require(msg.value >= p.price, "The payment must be larger or equal than the products price");

    // Delete the product from the array of products
    for(uint256 i = 0; i < products.length; i++) {
        if(products[i].id == _id) {
            Product memory lastElement = products[products.length - 1];
            products[i] = lastElement;
            products.length--;
        }
    }

    // Return the excess ETH sent by the buyer
    if(msg.value > p.price) msg.sender.transfer(msg.value - p.price);
    pendingSellerOrders[p.owner].push(newOrder);
    pendingBuyerOrders[msg.sender].push(newOrder);
    orderById[_id] = newOrder;
    EcommerceToken(token).transferFrom(address(this), msg.sender, _id); // Transfer the product token to the new owner
    p.owner.transfer(p.price);
}

首先,buy功能必须是可支付的,以便用户可以用以太币发送所需的价格,这些价格将被发送给卖方,除了燃气成本之外没有任何费用。购买产品时,买方需要发送所有地址详细信息,以便卖方可以处理发货;这就是为什么buy功能中有这么多参数的原因,其中电话号码和第二地址行是可选的。products数组会删除产品,以便用户界面显示最新的产品。将创建一个新的order结构实例,并将订单添加到待处理映射中。

创建标记订单功能

创建订单后,我们需要一种方法告诉客户产品已经发货。我们可以通过一个名为markOrderCompleted的新功能来做到这一点,如下面的代码所示:

/// @notice To mark an order as completed
/// @param _id The id of the order which is the same for the product id
function markOrderCompleted(uint256 _id) public {
    Order memory order = orderById[_id];
    Product memory product = productById[_id];
    require(product.owner == msg.sender, 'Only the seller can mark the order as completed');
    order.state = 'completed';

    // Delete the seller order from the array of pending orders
    for(uint256 i = 0; i < pendingSellerOrders[product.owner].length; i++) {
        if(pendingSellerOrders[product.owner][i].id == _id) {
            Order memory lastElement = orderById[pendingSellerOrders[product.owner].length - 1];
            pendingSellerOrders[product.owner][i] = lastElement;
            pendingSellerOrders[product.owner].length--;
        }
    }
    // Delete the seller order from the array of pending orders
    for(uint256 i = 0; i < pendingBuyerOrders[order.buyer].length; i++) {
        if(pendingBuyerOrders[order.buyer][i].id == order.id) {
            Order memory lastElement = orderById[pendingBuyerOrders[order.buyer].length - 1];
            pendingBuyerOrders[order.buyer][i] = lastElement;
            pendingBuyerOrders[order.buyer].length--;
        }
    }
    completedOrders[order.buyer].push(order);
    orderById[_id] = order;
}

这个函数从各自的数组中移除了待处理订单,并将它们移到 completedOrders 映射中。我们不使用 delete 函数,而是减少数组的长度来删除 Order,因为 delete 函数实际上并不从数组中删除用户订单,而是留下一个空的订单实例。当我们将要 delete 的元素移动到数组的最后位置并减少其长度时,我们完全删除了它,而不会留下任何空洞,因为 delete 函数保持数组完整。

创建 getter 函数

剩下的就是添加所需的 getter 函数来返回这些数组的长度,因为公共数组变量不会公开数组长度,我们需要知道有多少产品和订单以向用户显示最新内容,让我们使用以下代码来设置:

/// @notice Returns the product length
/// @return uint256 The number of products
function getProductsLength() public view returns(uint256) {
    return products.length;
}

/// @notice To get the pending seller or buyer orders
/// @param _type If you want to get the pending seller, buyer or completed orders
/// @param _owner The owner of those orders
/// @return uint256 The number of orders to get
function getOrdersLength(bytes32 _type, address _owner) public view returns(uint256) {
    if(_type == 'seller') return pendingSellerOrders[_owner].length;
    else if(_type == 'buyer') return pendingBuyerOrders[_owner].length;
    else if(_type == 'completed') return completedOrders[_owner].length;
}

getOrdersLength() 函数将被用于卖家、买家或已完成的三种订单类型,以避免创建多个相似的函数。这就是整个合同。如果你想查看更新版本,请访问我的 GitHub:github.com/merlox/ecommerce-dapp

部署智能合约

了解部署过程是很重要的,以确保成功执行,因为,让我们面对现实吧,Truffle 可能会让人感到困惑。在之前的章节中,你已经看到了使用这个框架部署智能合约需要做什么,但再过一遍这个过程也无妨,只是为了确保你理解了它。

首先,打开你的 truffle-config.js 文件,并修改它以使用 ropsten,这是我们将用于部署我们 dApp 的初始版本的网络。以下是它的样子,使用你自己的 INFURA 密钥:

const HDWalletProvider = require('truffle-hdwallet-provider');
const infuraKey = "v3/<YOUR-INFURA-KEY-HERE>;
const fs = require('fs');
const mnemonic = fs.readFileSync(".secret").toString().trim();

module.exports = {
  networks: {
    development: {
     host: "127.0.0.1", // Localhost (default: none)
     port: 8545, // Standard Ethereum port (default: none)
     network_id: "*", // Any network (default: none)
    },
    ropsten: {
      provider: () => new HDWalletProvider(mnemonic, `https://ropsten.infura.io/${infuraKey}`),
      network_id: 3, // Ropsten's id
      gas: 5500000, // Ropsten has a lower block limit than mainnet
      confirmations: 2, // # of confs to wait between deployments. (default: 0)
      timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
      skipDryRun: true // Skip dry run before migrations? (default: false for public nets )
    }
  }
}

我保留了开发网络的可用性,因为在将合同部署到 ropsten 之前,你可能需要在使用 ganache-cli 生成的本地测试网络上检查部署过程。在这样做时,请确保你的 .secret 文件中的种子短语的第一个帐户中有足够的 ropsten 以太币。记得安装 Truffle 钱包,以使以下代码的部署过程正常工作:

npm i -S truffle-hdwallet-provider

然后,在你的 migrations/ 文件夹中创建一个 .secret 文件,并创建一个名为 2_deploy_contracts.js 的文件,告诉 Truffle 在部署合同时需要做什么,主要是设置构造函数参数,如下面的代码所示。如果没有这个文件,Truffle 将无法部署:

const Token = artifacts.require("./EcommerceToken.sol")
const Ecommerce = artifacts.require("./Ecommerce.sol")
let token

module.exports = function(deployer, network, accounts) {
    deployer.deploy(
        Token,
        { gas: 8e6 }
    ).then(tokenInstance => {
        token = tokenInstance
        return deployer.deploy(Ecommerce, token.address, {
            gas: 8e6
        })
    }).then(async ecommerce => {
        await token.contract.methods.setEcommerce(ecommerce.address).send({
            from: accounts[0]
        })
        console.log('Is set?', await token.contract.methods.isEcommerceSet().call())
        console.log('Deployed both!')
    })
}

您的迁移文件夹应该有1_initial_migrations.js2_deploy_contracts.js文件。语法有点混乱,但重要的是我们使用了deployer.deploy()函数,该函数返回一个 promise 来获取令牌地址,并从令牌合约运行setEcommerce()函数,以便我们可以立即开始使用合约。请注意,我们通过将第三个参数添加到主函数来访问accounts,这是运行setEcommerce()函数所必需的,第一个以太坊地址。最后,我通过调用令牌中的isEcommerceSet()公共变量来检查电子商务合约是否已正确设置。

运行以下部署命令:

truffle deploy --network ropsten --reset

如果您想要测试一切是否正常运行而无需等待ropsten,您可以通过运行以下命令快速在ganache-cli私有区块链上部署它:

truffle deploy --network development --reset

部署您的合约后,您会在build/contract/Ecommerce.json文件夹中找到地址和 ABI。

完成 dApp

要完成 dApp,我们必须修改 React 代码以集成智能合约更改,同时理解我们如何使用正确的方法从区块链接收信息并正确显示该数据的方式。在此之前,请确保您的合约已部署到ropsten,如前几个步骤所示。

设置合约实例

因为我们使用 webpack,所以我们可以从 React 文件中访问源文件夹中的所有文件,这意味着我们可以获取已部署的智能合约 ABI 和已部署的合约地址,以及创建合约实例所需的参数。这在以下代码中显示:

import React from 'react'
import ReactDOM from 'react-dom'
import MyWeb3 from 'web3'
import { BrowserRouter, Route, withRouter } from 'react-router-dom'
import Home from './components/Home'
import Product from './components/Product'
import Sell from './components/Sell'
import Header from './components/Header'
import Buy from './components/Buy'
import Orders from './components/Orders'

import './index.styl'
import ABI from '../build/contracts/Ecommerce.json'

当您使用 Truffle 成功部署您的智能合约时,将创建build文件夹,其中包含我们可能需要的重要智能合约参数。修改您的设置函数以全局访问合约对象,使外部组件更容易。我已经在下面的代码中突出显示了合约实例,供您查找更改:

async setup() {
    // Create the contract instance
    window.myWeb3 = new MyWeb3(ethereum)
    try {
        await ethereum.enable();
    } catch (error) {
        console.error('You must approve this dApp to interact with it')
    }
 window.user = (await myWeb3.eth.getAccounts())[0]
 window.contract = new myWeb3.eth.Contract(ABI.abi, ABI.networks['3'].address, {
 from: user
 })
    await this.getLatestProducts(9)
    await this.displayProducts()
}

注意我们如何将state对象减少为几个元素,而没有任何虚拟数据,因为我们将使用真实的智能合约数据。合约实例是通过使用abi和合约地址创建的,这些信息也包含在构建的 JSON 文件中。在设置函数的末尾,我们调用了getLatestProducts()displayProducts()函数,正如您即将看到的,这些函数是必要的,以便从合约中获取数据并正确显示它。

更新索引文件

现在我们有一个可工作的合约实例,我们可以在索引文件中工作,以便将功能保持在较小的组件中,如下面的代码所示:

  1. 实现displayProducts()函数以按属性排序显示产品:
async displayProducts() {
    let productsHtml = []
    if(this.state.products.length == 0) {
        productsHtml = (
            <div key="0" className="center">There are no products yet...</div>
        )
    }
    await this.state.products.asyncForEach(product => {
        productsHtml.push((
            <div key={product.id} className="product">
                <img className="product-image" src={product.image} />
                <div className="product-data">
                    <h3 className="product-title">{product.title}</h3>
                    <div className="product-description">{product.description.substring(0, 50) + '...'}</div>
                    <div className="product-price">{product.price} ETH</div>
                    <button onClick={() => {
                        this.setState({product})
                        this.redirectTo('/product')
                    }} className="product-view" type="button">View</button>
                </div>
            </div>
        ))
    })
    this.setState({productsHtml})
}
  1. 添加更新后的重定向功能,如下所示的代码所示:
redirectTo(location) {
  this.props.history.push({
    pathname: location
  })
}
  1. 实现从智能合约获取产品的功能,方法是获取这些产品的长度,并循环每一个:
async getLatestProducts(amount) {
    // Get the product ids
    const productsLength = parseInt(await contract.methods.getProductsLength().call())
    let products = []
    let condition = (amount > productsLength) ? 0 : productsLength - amount

    // Loop through all of them one by one
    for(let i = productsLength; i > condition; i--) {
        let product = await contract.methods.products(i - 1).call()
        product = {
            id: parseInt(product.id),
            title: product.title,
            date: parseInt(product.date),
            description: product.description,
            image: product.image,
            owner: product.owner,
            price: myWeb3.utils.fromWei(String(product.price)),
        }
        products.push(product)
    }
    this.setState({products})
}

在我们的主页上,我们将展示其他卖家添加的最新产品,以便您可以立即开始购买。因此,我们将使用getLatestProducts(),它接收要显示的产品数量作为参数,同时从区块链获取数据。那么,我们如何在没有getter函数的情况下获取所有产品数据呢?好吧,流程是这样的:

  1. 我们获取产品数组的长度。我们使用getProductsLength()函数,因为如果没有适当的getter函数,我们无法获取数组的长度。

  2. 一旦我们知道智能合约中有多少产品可用,我们就通过循环该大小来运行products()函数,该函数可用于我们的产品数组,这意味着它自动为其创建了getter函数。公共数组必须逐个访问;这就是为什么我们使用反向for循环的原因。

  3. 我们需要一个反向循环来首先获取最新的产品。关于for循环的工作原理,因为可能出现我们要显示9时产品已用尽的情况,这是由于当我们想要显示9时,我们从零产品开始。这就是为什么我们创建了condition变量-它检查要求显示的产品数量是否实际可用;如果不可用,我们只需获取所有可用的产品,无论它们有多少。

另一方面,一旦state对象被填充了包含在我们的智能合约中的产品,我们就使用displayProducts()函数,该函数负责生成每个产品所需的正确 HTML,同时更新productsHtml状态数组。

最后,我们有render函数,这些新更新的组件略有修改,如下所示的代码所示:

render() {
    return (
        <div>
            <Route path="/product" render={() => (
                <Product
                    product={this.state.product}
                    redirectTo={location => this.redirectTo(location)}
                />
            )}/>
            <Route path="/sell" render={() => (
                <Sell
                    publishProduct={data => this.publishProduct(data)}
                />
            )}/>
            <Route path="/buy" render={() => (
                <Buy
                    product={this.state.product}
                />
            )} />
            <Route path="/orders" render={() => (
                <Orders
                    setState={state => this.setState(state)}
                    redirectTo={location => this.redirectTo(location)}
                />
            )} />
            <Route path="/" exact render={() => (
                <Home
                    productsHtml={this.state.productsHtml}
                />
            )} />
        </div>
    )
}

在进行实现更改后,请查看整个索引文件,可在 GitHub 上找到,网址为 github.com/merlox/ecommerce-dapp

更新购买组件

让我们转向Buy.js文件,因为Home.jsProduct.js组件将保持原样,无需任何必要的修改,考虑到产品数据将具有相同的预期格式。在Buy组件中,我们需要添加一个购买产品的函数,该函数将事务发送到智能合约,以下是该函数:

async buyProduct() {
    await contract.methods.buyProduct(this.props.product.id, this.state.nameSurname, this.state.lineOneDirection, this.state.lineTwoDirection, this.bytes32(this.state.city), this.bytes32(this.state.stateRegion), this.state.postalCode, this.bytes32(this.state.country), this.state.phone).send({
        value: myWeb3.utils.toWei(this.props.product.price)
    })
}

bytes32(name) {
    return myWeb3.utils.fromAscii(name)
}

buyProduct()函数获取与用户地址相关的所有状态数据,并将具有所需产品价格的交易作为交易的支付发送。bytes32函数是必需的,以将一些字符串值转换为bytes32,以节省 gas 成本。这就是此特定组件所需的所有更改。在更新的 GitHub 上检查整个组件的最终实现:github.com/merlox/ecommerce-dapp/blob/master/src/components/Buy.js

更新出售组件

让我们来创建Sell.js功能所需的功能,这样你就可以开始向市场添加可购买的产品了。在这种情况下,我们需要添加一个函数,该函数将从智能合约中调用publishProduct()函数。下面是更新后的publish函数的样子:

async publishProduct() {
    if(this.state.title.length == 0) return alert('You must set the title before publishing the product')
    if(this.state.description.length == 0) return alert('You must set the description before publishing the product')
    if(this.state.price.length == 0) return alert('You must set the price before publishing the product')
    if(this.state.image.length == 0) return alert('You must set the image URL before publishing the product')

    await contract.methods.publishProduct(this.state.title, this.state.description, myWeb3.utils.toWei(this.state.price), this.state.image).send()
}

注意我们如何检查所有必需的参数,以便让用户知道何时缺少某些内容。你可以添加一些额外的检查,以确保提供的图片 URL 实际上是一个可以在市场上显示的有效图片。这部分就交给你了。不应该花费你超过10 分钟的时间,这是一个练习你的 JavaScript 技能的好机会。

最终更新的版本在 GitHub 上可用:github.com/merlox/ecommerce-dapp/blob/master/src/components/Sell.js

更新订单组件

现在让我们更新Orders.js组件,这是最复杂的组件,因为我们必须生成多个产品。让我们从创建一个函数开始,以获取与当前用户相关的所有订单,如下所示:

async getOrders(amount) {
    const pendingSellerOrdersLength = parseInt(await contract.methods.getOrdersLength(this.bytes32('seller'), user).call())
    const pendingBuyerOrdersLength = parseInt(await contract.methods.getOrdersLength(this.bytes32('buyer'), user).call())
    const completedOrdersLength = parseInt(await contract.methods.getOrdersLength(this.bytes32('completed'), user).call())

    const conditionSeller = (amount > pendingSellerOrdersLength) ? 0 : pendingSellerOrdersLength - amount
    const conditionBuyer = (amount > pendingBuyerOrdersLength) ? 0 : pendingBuyerOrdersLength - amount
    const conditionCompleted = (amount > completedOrdersLength) ? 0 : completedOrdersLength - amount

    let pendingSellerOrders = []
    let pendingBuyerOrders = []
    let completedOrders = []

    // In reverse to get the most recent orders first
    for(let i = pendingSellerOrdersLength; i > conditionSeller; i--) {
        let order = await contract.methods.pendingSellerOrders(user, i - 1).call()
        pendingSellerOrders.push(await this.generateOrderObject(order))
    }

    for(let i = pendingBuyerOrdersLength; i > conditionBuyer; i--) {
        let order = await contract.methods.pendingBuyerOrders(user, i - 1).call()
        pendingBuyerOrders.push(await this.generateOrderObject(order))
    }

    for(let i = completedOrdersLength; i > conditionCompleted; i--) {
        let order = await contract.methods.completedOrders(user, i - 1).call()
        completedOrders.push(await this.generateOrderObject(order))
    }

    this.setState({pendingSellerOrders, pendingBuyerOrders, completedOrders})
}

我们通过遵循与索引文件中产品相同的程序生成了三个不同的数组。我们具有相同的条件运算符,但用于不同类型的订单。然后,我们为每个所需订单运行一个逆序的for循环,以便获得最近的订单。由于智能合约返回的数据有些混乱,我们创建了一个名为generateOrderObject()的函数,该函数接收一个订单对象,并返回一个已清理的对象,其中包含已转换为可读文本的十六进制值。下面是它的样子:

async generateOrderObject(order) {
    let productAssociated = await contract.methods.productById(parseInt(order.id)).call()
    order = {
        id: parseInt(order.id),
        buyer: order.buyer,
        nameSurname: order.nameSurname,
        lineOneDirection: order.lineOneDirection,
        lineTwoDirection: order.lineTwoDirection,
        city: myWeb3.utils.toUtf8(order.city),
        stateRegion: myWeb3.utils.toUtf8(order.stateRegion),
        postalCode: String(order.postalCode),
        country: myWeb3.utils.toUtf8(order.country),
        phone: String(order.phone),
        state: order.state,
        date: String(productAssociated.date),
        description: productAssociated.description,
        image: productAssociated.image,
        owner: productAssociated.owner,
        price: myWeb3.utils.fromWei(String(productAssociated.price)),
        title: productAssociated.title,
    }
    return order
}

将重复的代码分离到外部函数中以保持代码整洁是很重要的。正如你所看到的,这个函数将变量的字节类型转换为可读的utf8字符串,同时将大数转换为整数,以便它们可以在我们的用户界面中正确显示。

在使用最新订单更新状态对象之后,我们可以创建一个函数,通过以下步骤生成每个元素的正确 HTML:

  1. 设置所需的数组变量,这种情况下更简单,因为我们要为不同类型的订单创建三个块:
async displayOrders() {
    let pendingSellerOrdersHtml = []
    let pendingBuyerOrdersHtml = []
    let completedOrdersHtml = []
  1. 如果没有每种类型订单,我们希望显示一条消息,让用户知道没有订单,使用以下代码:
    if(this.state.pendingSellerOrders.length == 0) {
        pendingSellerOrdersHtml.push((
            <div key="0" className="center">There are no seller orders yet...</div>
        ))
    }
    if(this.state.pendingBuyerOrders.length == 0) {
        pendingBuyerOrdersHtml.push((
            <div key="0" className="center">There are no buyer orders yet...</div>
        ))
    }
    if(this.state.completedOrders.length == 0) {
        completedOrdersHtml.push((
            <div key="0" className="center">There are no completed orders yet...</div>
        ))
    }
  1. 使用以下代码添加地址部分来更新待处理订单:
    await this.state.pendingSellerOrders.asyncForEach(order => {
        pendingSellerOrdersHtml.push(
            <div key={order.id} className="product">
                <img className="product-image" src={order.image} />
                <div className="product-data">
                    <h3 className="small-product-title">{order.title}</h3>
                    <div className="product-state">State: {order.state}</div>
                    <div className="product-description">{order.description.substring(0, 15) + '...'}</div>
                    <div className="product-price">{order.price} ETH</div>
                    <button className="small-view-button" onClick={() => {
                        this.props.setState({product: order})
                        this.props.redirectTo('/product')
                    }} type="button">View</button>
                    <button className="small-completed-button" onClick={() => {
                        this.markAsCompleted(order.id)
                    }} type="button">Mark as completed</button>
                </div>
  1. 在产品数据下面,使用以下代码添加地址信息,以便卖家可以履行这些订单:
                <div className="order-address">
                    <div>Id</div>
                    <div className="second-column" title={order.id}>{order.id}</div>
                    <div>Buyer</div>
                    <div className="second-column" title={order.buyer}>{order.buyer}</div>
                    <div>Name and surname</div>
                    <div className="second-column" title={order.nameSurname}>{order.nameSurname}</div>
                    <div>Line 1 direction</div>
                    <div className="second-column" title={order.lineOneDirection}>{order.lineOneDirection}</div>
                    <div>Line 2 direction</div>
                    <div className="second-column" title={order.lineTwoDirection}>{order.lineTwoDirection}</div>
                    <div>City</div>
                    <div className="second-column" title={order.city}>{order.city}</div>
                    <div>State or region</div>
                    <div className="second-column" title={order.stateRegion}>{order.stateRegion}</div>
                    <div>Postal code</div>
                    <div className="second-column">{order.postalCode}</div>
                    <div>Country</div>
                    <div className="second-column" title={order.country}>{order.country}</div>
                    <div>Phone</div>
                    <div className="second-column">{order.phone}</div>
                    <div>State</div>
                    <div className="second-column" title={order.state}>{order.state}</div>
                </div>
            </div>
        )
    })
  1. 对于待处理买家订单,我们采取相同的做法:我们首先显示产品数据,使用以下代码:
 await this.state.pendingBuyerOrders.asyncForEach(order => {
        pendingBuyerOrdersHtml.push(
            <div key={order.id} className="product">
                <img className="product-image" src={order.image} />
                <div className="product-data">
                    <h3 className="product-title">{order.title}</h3>
                    <div className="product-state">State: {order.state}</div>
                    <div className="product-description">{order.description.substring(0, 15) + '...'}</div>
                    <div className="product-price">{order.price} ETH</div>
                    <button onClick={() => {
                        this.props.setState({product: order})
                        this.props.redirectTo('/product')
                    }} className="product-view" type="button">View</button>
                </div>
  1. 地址数据将完全相同,因此将其复制并粘贴到待处理买家订单循环中。我们使用相同的代码,因为我们需要更新每个 HTML 块的外观,但类名必须不同。使用以下代码将for循环添加到已完成订单数组中:
    await this.state.completedOrders.asyncForEach(order => {
        completedOrdersHtml.push(
            <div key={order.id} className="product">
                <img className="product-image" src={order.image} />
                <div className="product-data">
                    <h3 className="product-title">{order.title}</h3>
                    <div className="product-state">State: {order.state}</div>
                    <div className="product-description">{order.description.substring(0, 15) + '...'}</div>
                    <div className="product-price">{order.price} ETH</div>
                    <button onClick={() => {
                        this.props.setState({product: order})
                        this.props.redirectTo('/product')
                    }} className="product-view" type="button">View</button>
                </div>
  1. 将地址块粘贴到产品数据下方。使用setState()方法更新此组件的状态:
    this.setState({pendingSellerOrdersHtml, pendingBuyerOrdersHtml, completedOrdersHtml})

这是一个大函数,因为我们为了保持简单而有重复的功能。我们有三个循环用于三个订单数组,这样我们可以将订单信息提供给用户。没有太多花哨的东西,只是干净的设计中的数据。我们将该数据添加到state对象中,以便我们可以轻松显示它。

  1. 创建一个setup()函数,在组件加载时运行这两个函数,如下所示:
bytes32(name) {
    return myWeb3.utils.fromAscii(name)
}

async setup() {
    await this.getOrders(5)
    await this.displayOrders()
}
  1. 在这种情况下,我们每种类型请求五个订单,因为我们不想让用户被信息压倒,这很容易根据您的喜好进行更改。您甚至可以在 UI 中添加一个滑块,以便用户更改显示的项目数量。render()函数也已更新以反映买家的地址数据,如下所示:
render() {
    return (
        <div>
            <Header />
            <div className="orders-page">
                <div>
                    <h3 className="order-title">PENDING ORDERS AS A SELLER</h3>
                    {this.state.pendingSellerOrdersHtml}
                </div>

                <div>
                    <h3 className="order-title">PENDING ORDERS AS A BUYER</h3>
                    {this.state.pendingBuyerOrdersHtml}
                </div>

                <div className="completed-orders-container">
                    <h3 className="order-title">COMPLETED ORDERS</h3>
                    {this.state.completedOrdersHtml}
                </div>
            </div>
        </div>
    )
}

这就是Orders组件的全部更改。请查看官方 GitHub 链接中的更新实现:github.com/merlox/ecommerce-dapp/blob/master/src/components/Orders.js

您可以在github.com/merlox/ecommerce-dapp/blob/master/src/index.styl找到更新后的 CSS 代码,您将获得完全相同的设计。

这就是整个电子商务 dApp!这是它的外观,只是为了让您看到这个简单而又功能强大的应用程序的潜力:

记得将你的智能合约部署到ropsten并运行npm run dev来启动 webpack 服务器,以便您可以与其交互。这是以太坊电子商务部门的一个原型;现在您理解了智能合约如何与用户界面交互,您可以在此基础上构建自己的想法。

请务必查看本章节代码的 GitHub 链接:github.com/merlox/ecommerce-dapp

摘要

在本章中,你首先学习了使用 ERC-721 代币利用去中心化智能合约技术创建独特产品市场的潜力,以便你可以轻松管理用户自由创建的 NFT。然后,你建立了一个清晰的界面来显示最重要的数据,使用户有一个舒适的地方与底层智能合约进行交互。接下来,你通过学习 NFT 代币的工作原理(包括所有功能)来构建了智能合约。你部署了你自己的 ERC-721 标准版本,然后创建了包含发布产品到公共市场所需逻辑的电子商务智能合约,以便其他人可以用真正的以太币购买它们。最后,你通过创建与 React 用户界面交互所需的必要功能将所有内容整合在一起。

在下一章中,我们将进一步构建一个去中心化银行和借贷平台,实现复杂的智能合约系统,以确保人们可以访问安全资金储备,并为他们提供用户界面进行交互。