Python-区块链开发实用指南(四)

11 阅读1小时+

Python 区块链开发实用指南(四)

原文:zh.annas-archive.org/md5/E6FBF7D7A6EED49747FB2B635A55F938

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:加密货币钱包

在本章中,您将学习如何构建一个桌面加密货币钱包。您仍将使用相同的 GUI 库,Qt for Python 或 PySide2 来创建桌面应用程序。这个加密货币钱包可以发送以太币和 ERC20 代币。在构建这个加密货币钱包之前,您将学习 PySide2 库的高级功能,比如标签、组合框、大小策略以及添加拉伸以控制布局中小部件的分布。此外,您还将把测试集成到应用程序中。

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

  • PySide2 库的高级功能

  • Pytest Qt,用于测试 Python 应用程序的 Qt 库

  • 如何构建加密货币钱包

  • 构建加密货币钱包时需要考虑的一些因素

PySide2 库的高级功能

本章需要读者对 PySide2 库有一定的了解。如果您还没有这样做,您应该先阅读第七章 前端去中心化应用,因为本章是基于那一章的。如果您已经熟悉了使用PySide2构建 GUI,那么您就具备了构建桌面加密货币钱包所需的技能,至少从用户界面UI)的角度来看。但是,您构建的应用程序对用户来说可能会很刺耳。例如,如果您在水平布局中加入了一个按钮,并且该按钮是水平布局中唯一的小部件,当您调整具有水平布局的窗口大小时,按钮将被拉伸到右侧和左侧。如果这不是您想要发生的,您需要一种方法告诉按钮保持其宽度。

因此,让我们从PySide2库中学习其他功能,比如标签、大小策略和网格布局,这样我们就有了制作应用程序 UI 更具吸引力的技能。我们的应用程序可能不会赢得苹果最佳设计奖,但至少对用户来说不会那么刺耳。

此外,在第七章 前端去中心化应用中,我们忽略了测试。由于加密货币钱包应用程序是处理人们的资金的应用程序,错误是代价高昂的。因此,我们需要在用户之前捕捉到任何错误。因此,我们应该为我们的加密货币钱包编写适当的测试。但是,我们将专注于对加密货币钱包的 UI 部分进行测试。我们不会专注于测试内部方法。换句话说,我们的测试将是集成测试。

如果您还没有安装Qt库,请阅读第七章 前端去中心化应用,了解如何安装。之后,使用以下命令为您的项目创建一个虚拟环境:

$ virtualenv -p python3.6 wallet-venv
$ source wallet-venv/bin/activate
(wallet-venv) $ pip install PySide2
(wallet-venv) $ pip install web3==4.7.2

我们还希望安装一个测试库来测试我们的应用程序,可以通过以下命令来完成:

(wallet-venv) $ pip install pytest-qt

现在所有的库都已经设置好了,让我们写一个简单的应用程序来测试一下。

测试应用程序

创建一个名为advanced_course_qt的目录。我们可以把所有的教程文件放在这里。将第一个脚本命名为button_and_label.py,并使用以下代码为该按钮创建一个按钮和一个标签(请参考以下 GitLab 链接上的代码文件获取完整代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/button_and_label.py):

from PySide2.QtWidgets import QWidget, QApplication, QLabel, QPushButton, QVBoxLayout
from PySide2.QtCore import Qt
import sys

class ButtonAndLabel(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    button_and_label = ButtonAndLabel()
    button_and_label.show()
    sys.exit(app.exec_())

运行上述代码,看看这个应用程序是关于什么的。该应用程序由一个按钮和一个标签组成:

如果您点击按钮,标签上的文本将会改变,如下图所示:

让我们测试一下这个应用程序。将测试命名为test_button_and_label.py,并将其放在相同的目录中。使用以下代码块进行测试应用程序:

from button_and_label import ButtonAndLabel
from PySide2 import QtCore

def test_button_and_label(qtbot):
    widget = ButtonAndLabel()
    qtbot.addWidget(widget)

    assert widget.label.text() == "label: before clicked"

    qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)

    assert widget.label.text() == "label: after clicked

使用以下命令运行测试:

(wallet-venv) $ pytest test_button_and_label.py

请注意,(wallet-venv) $ python test_button_and_label.py命令是一个微不足道的错误,经常用于运行测试。不要上当!

在这个测试脚本中,我们导入我们的widget类。然后,我们创建一个以test_开头的测试方法。这个方法有一个名为qtbot的参数。不要更改它的名称。qtbot是一个特殊的名称,不得更改。在这个方法内部,qtbot可以用来与widget类交互。

首先,我们实例化一个我们想要测试的widget类。然后,我们使用qtbotaddWidget方法添加该widget实例:

qtbot.addWidget(widget)

然后,在单击按钮之前,我们测试label变量上的text

assert widget.label.text() == "label: before clicked"

正如您所看到的,我们可以从widget中访问label。这是因为我们在button_and_label.py中使用以下代码声明了label变量:

self.label = QLabel("label: before clicked")

如果您在button_and_label.py中使用以下代码声明标签:

label = QLabel("label: before clicked")

然后,您将无法从测试中的widget实例访问label。当然,您可以通过创建一个变量来保存标签的文本来规避这种情况。然而,将label作为widget实例属性是测试标签文本的最简单方法。您将在所有后续测试中使用这种策略。简而言之,如果您想测试小部件(如标签、按钮或组合框),请将该widget作为其父小部件实例的属性。然后,我们继续讨论如何单击按钮小部件:

qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)

在测试期间单击按钮,您可以使用qtbotmouseClick方法。qtbotmouseClick方法的第一个参数是一个按钮小部件,或者是接受点击事件的东西。第二个参数是检测鼠标点击事件性质的选项。在这种情况下,测试将只接受左键点击。

以下代码是测试并显示单击按钮后标签的文本:

assert widget.label.text() == "label: after clicked"

在构建 GUI 应用程序时,我们偶尔需要显示对象列表。在我们的加密货币钱包中,列表可以包含账户。因此,让我们为这种情况编写一个测试。但首先,我们必须创建一个脚本来显示对象列表。将脚本命名为button_and_list.py,并使用以下代码块进行脚本(有关完整代码,请参考以下 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/button_and_list.py):

from PySide2.QtWidgets import QWidget, QApplication, QLabel, QPushButton, QVBoxLayout
from PySide2.QtCore import Qt
import sys

class ButtonAndList(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    button_and_list = ButtonAndList()
    button_and_list.show()
    sys.exit(app.exec_())

运行脚本以查看应用程序的外观。以下是单击按钮之前的按钮的屏幕截图:

以下显示了单击按钮的结果:

这里只有一个按钮,如果您单击它,将会出现一个新的标签,文本简单地显示为1。如果您再次单击按钮,将会在底部出现一个新的标签,文本显示为2,依此类推。

单击按钮后出现的新标签是垂直框布局的一部分。这意味着我们需要将垂直框布局作为小部件实例的属性,以便在测试中访问它。

让我们为这个 GUI 脚本编写一个测试,如下面的代码块所示,并将其命名为test_button_and_list.py

from button_and_list import ButtonAndList
from PySide2 import QtCore

def test_button_and_list(qtbot):
    widget = ButtonAndList()
    qtbot.addWidget(widget)

    qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)
    qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)
    qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)

    label_item = widget.v_layout.takeAt(2)
    assert label_item.widget().text() == "3"

    label_item = widget.v_layout.takeAt(1)
    assert label_item.widget().text() == "2"

    label_item = widget.v_layout.takeAt(0)
    assert label_item.widget().text() == "1"

正如我们在前面的代码块中所看到的,在qtbotmouseClick方法第三次执行之后,我们使用以下代码从垂直框布局中获取标签:

label_item = widget.v_layout.takeAt(2)

我们通过takeAt方法获取小部件的子小部件。在这种情况下我们使用的参数是2。这意味着我们想要获取第三个子小部件,也就是最后一个。然后,我们使用以下代码测试小部件的文本:

assert label_item.widget().text() == "3"

让我们创建一个更复杂的场景。到目前为止,我们测试的所有内容都在一个窗口内,但如果有一个输入对话框怎么办?我们如何测试对话框?

让我们创建一个具有对话框的 GUI 脚本,并将其命名为button_and_dialog.py:(有关完整代码,请参考以下 GitLab 链接的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/button_and_dialog.py):

from PySide2.QtWidgets import QWidget, QApplication, QLabel, QPushButton, QVBoxLayout, QInputDialog, QLineEdit
from PySide2.QtCore import Qt
import sys

class ButtonAndDialog(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    button_and_dialog = ButtonAndDialog()
    button_and_dialog.show()
    sys.exit(app.exec_())

运行代码以查看应用程序。有一个按钮和空白空间在其下方:

点击按钮,然后会出现一个对话框,之后您应该在输入对话框中输入任何文本,然后点击 OK:

您输入的文本将出现在按钮下方:

让我们看一下以下代码块中的另一个测试脚本,以了解如何处理涉及两个不同窗口的流程。在这个测试方法中,除了qtbot之外,我们还有另一个参数叫做monkeypatch。将测试文件命名为test_button_and_dialog.py

from button_and_dialog import ButtonAndDialog
from PySide2.QtWidgets import QInputDialog
from PySide2 import QtCore

def test_button_and_dialog(qtbot, monkeypatch):
    widget = ButtonAndDialog()
    qtbot.addWidget(widget)

    monkeypatch.setattr(QInputDialog, 'getText', lambda *args: ("New Text", True))
    qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)

    assert widget.label.text() == "New Text"

monkeypatch用于覆盖对话框输入。这意味着在测试中,QInputDialoggetText方法将返回一个("New Text", True)元组。记得QInputDialog的 API 吗?这返回一个元组。这个元组包含两个参数——我们在对话框中输入的文本,以及我们是否点击了 OK 或取消按钮。

QInputDialoggetText方法接受四个参数:此对话框基于的窗口实例、标题、输入字段之前的标签和输入字段的类型。当您在输入字段中输入文本,例如To the moon!,然后点击OK按钮时,它会返回一个元组,其中包含字符串To the moon!和您是否点击了OK按钮的boolean值:

new_text, ok = QInputDialog.getText(self, "Write A Text", "New Text:", QlineEdit.Normal)

然而,monkeypatch会修补这个方法,因此在测试中,不会启动对话框。我们绕过它们。就好像启动对话框的行被以下代码替换了一样:

new_text, ok = ("New Text", True)

对于所有这些测试,我们总是使用一个按钮类型的小部件来启动某些操作(更改标签上的文本)。让我们使用另一种类型的小部件来更改标签,如下面的代码块所示,并将脚本命名为combobox_and_label.py

from PySide2.QtWidgets import QWidget, QApplication, QLabel, QComboBox, QVBoxLayout
from PySide2.QtCore import Qt
import sys

class ComboBoxAndLabel(QWidget):

    def __init__(self):
        super(ComboBoxAndLabel, self).__init__()

        self.combobox = QComboBox()
        self.combobox.addItems(["Orange", "Apple", "Grape"])
        self.combobox.currentTextChanged.connect(self.comboboxSelected)

        self.label = QLabel("label: before selecting combobox")

        layout = QVBoxLayout()
        layout.addWidget(self.combobox)
        layout.addWidget(self.label)

        self.setLayout(layout)

    def comboboxSelected(self, value):
        self.label.setText(value)

if __name__ == "__main__":

    app = QApplication(sys.argv)
    combobox_and_label = ComboBoxAndLabel()
    combobox_and_label.show()
    sys.exit(app.exec_())

这个 GUI 脚本使用组合框来更改标签上的文本。它使用所选标签的文本设置标签上的文本。运行脚本以查看它的外观:

现在,让我们创建一个测试脚本来测试这个组合框小部件,并将其命名为test_combobox_and_label.py

from combobox_and_label import ComboBoxAndLabel
from PySide2 import QtCore

def test_combobox_and_label(qtbot):
    widget = ComboBoxAndLabel()
    qtbot.addWidget(widget)

    assert widget.label.text() == "label: before selecting combobox"

    qtbot.keyClicks(widget.combobox, "Grape")

    assert widget.label.text() == "Grape"

我们可以从这里得到的关键点是使用qtbot更改combobox的选定选项的方式:

qtbot.keyClicks(widget.combobox, "Grape")

这个方法的名称不直观;它接受两个参数。第一个是小部件,或者在这种情况下是组合框。第二个是组合框中的选项文本。这个keyClicks方法不仅用于选择组合框中的选项。它也可以用于在行编辑中输入文本。只需将行编辑小部件放在第一个参数中。

这些测试知识足以测试我们的加密货币钱包。在我们开始构建加密货币钱包之前,让我们了解一下PySide2的一些其他功能,包括网格布局、选项卡和大小策略。

Qt for Python 或 PySide2 的高级功能

我们想要在这里学习的第一件事是拉伸。我们知道如何将小部件添加到框布局(垂直或水平)。但是,我们可以在一定程度上配置如何分配这些添加到框布局中的小部件。我们应该拉伸小部件,将小部件放在水平布局的顶部,并让空间吞噬其余部分吗?

让我们创建一个脚本来解释在框布局中小部件分布的配置,并将脚本命名为add_stretch.py(请参考以下 GitLab 链接的完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/add_stretch.py):

from PySide2.QtWidgets import QFrame, QLabel, QWidget, QApplication, QPushButton, QHBoxLayout, QVBoxLayout, QSizePolicy, QSizePolicy
from PySide2.QtCore import Qt
import sys

class AddStretch(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    widget = AddStretch()
    widget.resize(500, 500)
    widget.show()
    sys.exit(app.exec_())

运行脚本查看效果:

如果您在垂直容器的末尾添加拉伸,它会将小部件推到垂直容器的开头,并让其余部分成为空白空间。如果您在开头添加拉伸,它会将小部件推到垂直容器的末尾,并让其余部分成为空白空间。如果您不添加任何拉伸,小部件将在布局中均匀分布。

就应用的功能而言,这并没有什么区别。但是,如果您选择正确的选项,它可以使 UI 更具吸引力。

我们总是使用框布局(垂直框布局或水平框布局)。在大多数情况下,框布局就足够了。然而,偶尔您可能想要使用更复杂的布局。Qt 有一个比框布局更强大的网格布局。

让我们创建一个脚本来探索网格布局的强大功能,并将脚本命名为create_grid_window.py(请参考以下 GitLab 链接的完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/create_grid_window.py):

from PySide2.QtWidgets import QWidget, QApplication, QLabel, QGridLayout
from PySide2.QtCore import Qt
import sys

class GridWindow(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    gridWindow = GridWindow()
    gridWindow.show()
    sys.exit(app.exec_())

运行脚本查看网格布局如何管理其子窗口:

网格就像一个表格或电子表格。与水平布局的行或垂直布局的列不同,您将小部件添加到由行和列组成的表格中。

如果您想要将小部件添加到第一行和第一列,请使用以下语句:

layout.addWidget(label, 0, 0)

第一个参数表示行。第二个参数表示列。因此,如果您想要将小部件添加到第二行和第一列,使用以下语句:

layout.addWidget(label, 1, 0)

网格布局的addWidget方法接受可选的第三和第四个参数。第三个参数指示您希望此小部件延伸到多少行。第四个参数指示您希望此小部件延伸到多少列:

layout.addWidget(label, 1, 1, 2, 2)

如果您拉伸窗口,您将看到类似以下截图的内容:

看一下标签 G。这可以延伸到两行和两列。

现在,让我们谈谈如果增加包含小部件的父窗口的大小会发生什么。小部件是否应随之调整大小?小部件是否应保持不变,并允许边距变宽?您可以使用大小策略来决定调整大小的配置。让我们创建一个名为button_with_sizepolicy.py的脚本来演示大小配置策略(请参考以下 GitLab 链接的完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/button_with_sizepolicy.py):

from PySide2.QtWidgets import QWidget, QApplication, QPushButton, QVBoxLayout, QSizePolicy
from PySide2.QtCore import Qt
import sys

class ButtonWithSizePolicy(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    button_with_size_policy_widget = ButtonWithSizePolicy()
    button_with_size_policy_widget.resize(500, 200)
    button_with_size_policy_widget.show()
    sys.exit(app.exec_())

运行脚本以查看每个按钮如何根据不同的大小策略显示不同:

然后,尝试调整窗口大小以理解大小策略配置:

QSizePolicy.Maximum表示小部件不能比大小提示更大,或者在这种情况下按钮的内容。如果您希望按钮保持其原始大小,请使用此大小策略。QSizePolicy.Preferred表示它更喜欢大小提示,但它可以更大或更小。QSizePolicy.Expanding表示小部件应尽可能扩展。QSizePolicy.Minimum表示小部件可以扩展,但不能小于大小提示。QSizePolicy.MinimumExpanding表示小部件不能小于大小提示,但尽可能扩展。

在创建 GUI 应用程序时,大多数情况下您不会将所有功能/小部件放在一个窗口中。否则,窗口将比显示器的屏幕分辨率大。

您可以启动一个对话框,其中包含一个按钮来容纳更多功能/小部件。这当然有效。但是您真正想要的是类似控制器的东西。在 Qt 中,您有StackView。StackView 可以包含许多窗口,但一次只显示一个窗口。

我们不会直接使用 StackView。相反,我们使用选项卡视图。选项卡视图在幕后使用 StackView。让我们创建一个脚本来使用选项卡视图,并将其命名为tabbed_window.py

from PySide2.QtWidgets import QTabWidget, QApplication, QWidget
import sys
from button_and_label import ButtonAndLabel

class TabbedWindow(QTabWidget):

    def __init__(self, parent=None):
        super(TabbedWindow, self).__init__(parent)
        widget1 = QWidget()
        self.widget2 = ButtonAndLabel()
        widget3 = QWidget()
        self.addTab(widget1, "Tab 1")
        self.addTab(self.widget2, "Tab 2")
        self.addTab(widget3, "Tab 3")

if __name__ == "__main__":

    app = QApplication(sys.argv)
    tabbedWindow = TabbedWindow()
    tabbedWindow.show()
    sys.exit(app.exec_())

这个选项卡窗口有三个选项卡。每个选项卡都包含一个小部件。第二个选项卡甚至包含一个我们在单独的脚本button_and_label.py中创建的小部件。这个小部件在第二个选项卡中有一个按钮和一个标签。要向选项卡窗口添加选项卡,您可以使用addTab方法。第一个参数是小部件,第二个参数是选项卡的标题。

运行脚本查看选项卡视图的工作原理。在下面的截图中,我们看到选项卡 1:

在下面的截图中,我们看到选项卡 2 和来自button_and_label.py的小部件:

构建加密货币钱包

现在您已经了解了 Qt for Python 库的其他功能,让我们开始构建一个桌面加密货币钱包。由于这是一个复杂的应用程序,我们不应该将所有东西都放在一个文件中;相反,我们将其分成许多文件。我们甚至将许多文件分成不同的目录。我们还希望将此应用程序保持足够基本,以用于教程的目的。因此,我们不会在此应用程序中放置很多功能。这个加密货币钱包可以创建新账户,向另一个账户发送以太币,并监视 ERC20 代币,以便稍后将一些代币发送到另一个账户。但它不会具有您从正式加密货币钱包中期望的完整功能。

首先,让我们使用以下命令创建项目目录及其内部目录:

$ mkdir wallet
$ mkdir wallet/icons
$ mkdir wallet/images
$ mkdir wallet/tests
$ mkdir wallet/tools
$ mkdir wallet/wallet_threads
$ mkdir wallet/wallet_widgets

主应用程序、主库及其配置文件放在主目录wallet中。一些用于提升应用程序 UI 的图标放在icons目录中。头像图像放在images目录中。测试文件放在tests目录中。与区块链和 UI 无关的库文件放在tools目录中。线程类放在wallet_threads目录中。最后,主小部件的子小部件放在wallet_widgets目录中。

区块链类

让我们在wallet中创建一个名为blockchain.py的区块链接口代码文件。这个文件负责连接到区块链。它的职责包括检查账户余额,获取本地账户,发送交易以及获取代币信息。通过将所有区块链功能放在一个类或文件中,我们更容易调试问题,测试实现,并开发功能。转到gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_09/wallet并参考本节的blockchain.py代码文件。

这个区块链类有 10 个方法来与区块链交互。此外,它还具有 ERC20 代币的通用json接口。

让我们逐行讨论这个区块链类文件:

from web3 import Web3, IPCProvider
from web3.exceptions import ValidationError
from populus.utils.wait import wait_for_transaction_receipt
from collections import namedtuple
from os.path import exists
import json

SendTransaction = namedtuple("SendTransaction", "sender password destination amount fee")
TokenInformation = namedtuple("TokenInformation", "name symbol totalSupply address")

在导入所需的库之后,我们创建了两个命名元组。那么,为什么我们要创建这些命名元组呢?基本上,我们这样做是为了避免错误。在加密货币钱包中出现错误是很昂贵的。

想象一下你有以下函数:

def send_transaction(sender, password, destination, amount, fee):
    // the code to create transaction

你可以这样执行这个函数:

send_transaction("427af7b53b8f56adf6f13932bb17c42ed2a53d04", “password”, "6ad2ffd2e08bd73f5c50db60fdc82a58b0590b99", 3, 2)

如果你交换了发送方和接收方,在最坏的情况下,你会得到一个未处理的异常,程序会停止,因为私钥与发送方不匹配。但是,如果你交换了金额和费用呢?在这种情况下,你会向某人发送少量的代币,但支付了非常高的费用。有许多方法可以避免这个错误。例如,你可以使用关键字参数,就像下面的代码块中所示,或者你可以使用命名元组:

send_transaction(SendTransaction(sender="0xaksdfkas", password="password", destination="0xkkkkkk", amount=3, fee=2))

现在,让我们继续讨论 ERC20 代币智能合约的json接口。当我们想发送以太币时,这是不需要的:只有当我们想发送代币时才需要。

true = True
false = False
erc20_token_interface = [
            {
                "anonymous": false,
                "inputs": [
                    {
                        "indexed": true,
                        "name": "_from",
                        "type": "address"
                    },
                    {
                        "indexed": true,
                        "name": "_to",
                        "type": "address"
                    },
                    {
                        "indexed": false,
                        "name": "_value",
                        "type": "uint256"
                    }
                ],
                "name": "Transfer",
                "type": "event"
            },
...

正如你所知,为了与智能合约交互,你需要智能合约的json接口(abi)。你可能想知道我们如何获得这个json接口。这是通过 ERC20 代币智能合约的编译输出实现的。不管名称、小数点数量和符号是什么,只要接口来自满足 ERC20 标准的智能合约,我们就应该得到正确的接口。我决定将接口放在与Blockchain类相同的文件中,以简化事情。但是,你可以将接口放在json文件中,然后在Blockchain类文件中加载json文件。然后,我们继续定义Blockchain类:

class Blockchain:

    tokens_file = 'tokens.json'

    def __init__(self):
        self.w3 = Web3(IPCProvider('/tmp/geth.ipc'))

在这里,我们开始Blockchain类。在它的初始化方法中,我们构建一个w3变量来连接区块链。我们使用 IPC 提供程序硬编码连接到区块链。如果你使用HTTPProvider,或者使用不同的IPC文件路径,你可以更改这个配置。tokens_file变量是保存我们监视的所有代币的文件。

让我们来看看以下代码行:

    def get_accounts(self):
        return map(lambda account: (account, self.w3.fromWei(self.w3.eth.getBalance(account), 'ether')), self.w3.eth.accounts)

我们使用w3.eth.accounts获取所有本地账户,然后使用w3.eth.getBalance从每个账户获取余额。本地账户是你在本地节点创建的账户。通常,文件保存在keystore目录中。

以下代码用于在本地区块链节点中创建一个新账户:

    def create_new_account(self, password):
        return self.w3.personal.newAccount(password)

账户文件将使用我们提供的密码进行加密。要查看私钥,我们需要用密码解密账户文件。但是,这除了备份目的外是不必要的。

使用以下代码从一个地址获取余额:

    def get_balance(self, address):
        return self.w3.fromWei(self.w3.eth.getBalance(address), 'ether')

余额以 wei 为单位。然后,我们将 wei 中的余额转换为以太币中的余额。

下面的代码块旨在获取 ERC20 代币的余额,而不是以太币的余额:

    def get_token_balance(self, account_address, token_information):
        try:
            token_contract = self.w3.eth.contract(address=token_information.address, abi=erc20_token_interface)
            balance = token_contract.functions.balanceOf(account_address).call()
        except ValidationError:
            return None
        return balance

首先,我们获取接受两个参数的合同对象——智能合同的地址和 json 接口。如果您还记得在第八章中学到的内容,在以太坊中创建代币,ERC20 代币需要有一个balanceOf方法。此方法的目的是从账户地址获取代币的余额。

以下代码块用于创建发送以太币的交易:

    def create_send_transaction(self, tx):
        nonce = self.w3.eth.getTransactionCount(tx.sender)
        transaction = {
          'from': tx.sender,
          'to': Web3.toChecksumAddress(tx.destination),
          'value': self.w3.toWei(str(tx.amount), 'ether'),
          'gas': 21000,
          'gasPrice': self.w3.toWei(str(tx.fee), 'gwei'),
          'nonce': nonce
        }

        tx_hash = self.w3.personal.sendTransaction(transaction, tx.password)
        wait_for_transaction_receipt(self.w3, tx_hash)

首先,您获取nonce,然后构建一个交易对象。要使用密码而不是私钥发送此交易,您需要使用w3.personal对象的sendTransaction方法。然后,您等待交易得到确认。

在学习涉及发送以太币的交易之后,让我们继续下一个代码块,这是用于创建发送 ERC20 代币的交易的方法:

    def create_send_token_transaction(self, tx, token_information):
        nonce = self.w3.eth.getTransactionCount(tx.sender)
        token_contract = self.w3.eth.contract(address=token_information.address, abi=erc20_token_interface)
        transaction = token_contract.functions.transfer(tx.destination, int(tx.amount)).buildTransaction({
                  'from': tx.sender,
                  'gas': 70000,
                  'gasPrice': self.w3.toWei(str(tx.fee), 'gwei'),
                  'nonce': nonce
              })

        tx_hash = self.w3.personal.sendTransaction(transaction, tx.password)
        wait_for_transaction_receipt(self.w3, tx_hash)

首先,您获取nonce,然后构建一个合同对象。然后,您调用此智能合同对象的transfer方法。请记住,ERC20 代币需要有一个transfer方法来转移代币,该方法接受两个参数——目的地和代币数量。然后,通过从该方法构建交易来执行此方法,然后将其传递给w3.personal对象的sendTransaction方法。最后,我们等待此交易得到确认。

以下代码块用于从代币智能合同中获取信息:

    def get_information_of_token(self, address):
        try:
            token_contract = self.w3.eth.contract(address=address, abi=erc20_token_interface)
            name = token_contract.functions.name().call()
            symbol = token_contract.functions.symbol().call()
            total_supply = token_contract.functions.totalSupply().call()
        except ValidationError:
            return None
        token_information = TokenInformation(name=name.decode('utf-8'),
                                             symbol=symbol.decode('utf-8'),
                                             totalSupply=total_supply,
                                             address=address)
        return token_information

首先,我们创建一个合同对象。然后,为了获取名称、符号和总供应量,我们从智能合同中访问namesymboltotalSupply方法。因为名称和符号是字节对象,我们需要将其解码为字符串。我们将这些信息封装在一个名为TokenInformation的元组中。

以下代码是一种方便的方式,用于在命名的tuple中封装代币信息字典:

    def get_token_named_tuple(self, token_dict, address):
        return TokenInformation(name=token_dict['name'],
                                totalSupply=token_dict['total_supply'],
                                symbol=token_dict['symbol'],
                                address=address)

以下代码用于从配置文件中获取我们正在观察的所有代币:

    def get_tokens(self):
        tokens = {}
        if exists(self.tokens_file):
            with open(self.tokens_file) as json_data:
                tokens = json.load(json_data)
        return tokens

有许多代币的智能合同,但我们只想使用其中的一些。因此,我们将与这些代币智能合同相关的信息保存到一个json文件中。然后,我们转到文件的最后一行,即构造一个Blockchain类实例:

blockchain = Blockchain()

我们这样做是为了让导入此模块的任何文件都可以立即获得区块链对象,并且两个不同的文件将获得相同的对象。这类似于单例模式。

线程类

现在,让我们编写线程对象,以便访问区块链。在区块链中创建交易时,通常希望使用线程或非阻塞函数。因此,每当我们想要广播一个交易时,我们使用这些线程类。这些线程类将使用我们之前描述的区块链对象。

使用以下代码块在wallet_threads目录中创建一个balance_thread.py文件:

from PySide2.QtCore import QThread, Signal
from time import sleep
from blockchain import blockchain

class BalanceThread(QThread):

    get_balance_transaction = Signal(map)

    def __init__(self, parent=None):
        super(BalanceThread, self).__init__(parent)
        self.quit = False

    def kill(self):
        self.quit = True

    def run(self):
        while True:
            sleep(2)
            if self.quit:
                break
            accounts = blockchain.get_accounts()
            self.get_balance_transaction.emit(accounts)

这个线程类不会在区块链中创建任何交易;它的目的是读取每个账户中以太币的余额。那么,为什么我们需要一个线程来读取余额呢?读取余额应该是快速的吗?想象一下,当您启动您的加密货币钱包时,您看到您的余额是 10 个以太币。然后,有人给您发送了一些以太币。您希望您的余额尽快反映出来,对吧?这就是这个线程的目的;它将每 2 秒检查一次每个账户的余额。kill方法旨在关闭应用程序并停止线程的工作。这不是强制性的,但如果您不这样做,当您关闭应用程序时,您会收到一个令人讨厌的警告,指出应用程序在线程仍在运行时被销毁。

现在,让我们在wallet_threads目录中创建另一个线程类,并将其命名为send_thread.py

from PySide2.QtCore import QThread, Signal
from blockchain import blockchain

class SendThread(QThread):

    send_transaction = Signal()

    def __init__(self, parent=None):
        super(SendThread, self).__init__(parent)

    def prepareTransaction(self, tx):
        self.tx = tx

    def run(self):
        blockchain.create_send_transaction(self.tx)
        self.send_transaction.emit()

这个线程类的目的是调用区块链对象的create_send_transaction方法。在运行线程之前,我们需要使用SendTransaction命名的tuple参数调用这个线程类的prepareTransaction方法。

现在,让我们在wallet_threads目录中创建另一个线程类,并将其命名为send_token_thread.py

from PySide2.QtCore import QThread, Signal
from blockchain import blockchain

class SendTokenThread(QThread):

    send_token_transaction = Signal()

    def __init__(self, parent=None):
        super(SendTokenThread, self).__init__(parent)

    def prepareTransaction(self, tx, token_information):
        self.tx = tx
        self.token_information = token_information

    def run(self):
        blockchain.create_send_token_transaction(self.tx, self.token_information)
        self.send_token_transaction.emit()

这与SendThread类类似。这个线程的目的是调用create_send_token_transaction方法,这次接受两个参数,一个名为SendTransaction的元组,另一个名为TokenInformation的元组。

Identicon 和图标

现在,让我们了解一下 identicon 库是什么。identicon 库的目的是根据特定字符串的哈希生成一个自定义头像图像(如分形)。如果您登录 StackOverflow 并且没有设置个人资料图片,您的头像将由 identicon 库生成。

截图将会出现如下:

或者会出现这样的情况:

这是可选的。我们的加密货币钱包可以在没有这些头像图像的情况下正常运行。这只是为了提升用户界面的吸引力。

gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/wallet/tools/identicon.py下载文件到tools目录。这是 Shin Adachi 的出色工作。我已经修改过了,使其能够在 Python 3 中运行。您不必理解这个文件;把它当作一个第三方库。

然后,在tools目录中创建一个文件,使用以下代码块来使用这个库,并将其命名为util.py

from os.path import isdir, exists
from os import mkdir
from tools.identicon import render_identicon

def render_avatar(code):
    code = int(code, 16)
    img_filename = 'images/%08x.png' % code
    if exists(img_filename):
        return img_filename
    img = render_identicon(code, 24)
    if not isdir('images'):
        mkdir('images')
    img.save(img_filename, 'PNG')
    return img_filename

基本上,这种方法可以使用账户地址来渲染头像图像。这有助于使应用程序更具吸引力。因此,当您创建一个账户时,您会得到一个根据您的地址而独一无二的头像。

然后,在icons文件夹中下载一些图标。您需要其中的两个:ajax-loader.gifcopy.svg。您可以从免费图标网站下载copy.svg。任何显示复制操作的图标都可以。然后,您可以从ajaxload.info/下载ajax-loader.gif

构建小部件

让我们使用以下代码块创建我们的主应用程序。这是我们的加密货币钱包的主入口。将其命名为wallet.py

from PySide2.QtWidgets import QTabWidget, QApplication
import sys

from wallet_widgets.account_widget import AccountWidget
from wallet_widgets.send_widget import SendWidget
from wallet_widgets.token_widget import TokenWidget

class WalletWidget(QTabWidget):

    def __init__(self, parent=None):
        super(WalletWidget, self).__init__(parent)
        self.account_widget = AccountWidget()
        self.send_widget = SendWidget()
        self.token_widget = TokenWidget()
        self.addTab(self.account_widget, "Account")
        self.addTab(self.send_widget, "Send")
        self.addTab(self.token_widget, "Token")

    def killThreads(self):
        self.account_widget.kill()

if __name__ == "__main__":

    app = QApplication(sys.argv)
    wallet_widget = WalletWidget()
    wallet_widget.show()
    return_app = app.exec_()
    wallet_widget.killThreads()
    sys.exit(return_app)

WalletWidget是一个带有选项卡的窗口。有三个选项卡:

  • 第一个选项卡旨在容纳一个账户小部件。该小部件负责管理账户(列出账户和创建新账户)。

  • 第二个选项卡旨在容纳一个小部件,用户可以使用它来创建发送以太币或 ERC20 代币的交易。任何涉及发送以太币或代币的操作都是在这个小部件中完成的。

  • 第三个选项卡用于容纳令牌小部件。该小部件负责监视 ERC20 代币。监视 ERC20 代币意味着从 ERC20 自定义代币智能合约获取信息,并使这些代币能够在发送交易小部件中被花费。

这三个小部件将在其他文件中定义,稍后将讨论。

killThreads方法是可选的。如果您不使用这个方法,当您关闭应用程序时,您将会收到警报,因为应用程序创建的线程尚未完成其任务。

账户小部件

现在,让我们在选项卡窗口的第一个选项卡中创建第一个小部件。将文件放入wallet_widgets目录,并将其命名为account_widget.py。然后,您可以从以下链接获取完整的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_09/wallet/wallet_widgets

如前所述,此小部件将显示在钱包选项卡窗口的第一个选项卡中。在此选项卡中,您将获得列出帐户和创建新帐户功能。

使用以下代码从PySide2导入许多类型的小部件和类:

from PySide2.QtWidgets import (QWidget,
                               QGridLayout,
                               QVBoxLayout,
                               QHBoxLayout,
                               QPushButton,
                               QLabel,
                               QInputDialog,
                               QLineEdit,
                               QToolTip,
                               QApplication,
                               QSizePolicy)
from PySide2.QtCore import Slot, SIGNAL, QSize
from PySide2.QtGui import QPixmap, QIcon, QCursor, QClipboard
from time import sleep
from blockchain import blockchain
from tools.util import render_avatar
from wallet_threads.balance_thread import BalanceThread

我们还导入了blockchain对象和render_avatar方法等其他内容。此外,我们将使用balance_thread实例,这是一个用于更新帐户余额的线程。

使用以下代码块创建一个按钮,允许我们在小部件内创建一个帐户:

class AccountWidget(QWidget):

    balance_widgets = {}

    def __init__(self, parent=None):
        super(AccountWidget, self).__init__(parent)

        self.create_account_button = QPushButton("Create Account")
        self.create_account_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        self.connect(self.create_account_button, SIGNAL('clicked()'), self.createNewAccount)

        self.accounts_layout = QVBoxLayout()

        accounts = blockchain.get_accounts()

        for account, balance in accounts:
            self._addAccountToWindow(account, balance)

        layout = QGridLayout()

        layout.addWidget(self.create_account_button, 0, 0)
        layout.addLayout(self.accounts_layout, 1, 0)

        self.setLayout(layout)

        self.balance_thread = BalanceThread()
        self.balance_thread.get_balance_transaction.connect(self._updateBalances)
        self.balance_thread.start()

所有这些帐户将放在accounts_layout垂直框布局中。我们从区块链对象中获取所有本地帐户,然后使用addAccountToWindow方法将此帐户放入 accounts 布局中。之后,我们将按钮和accounts_layout放入主布局中。最后,我们将BalanceThread线程实例的槽连接到_updateBalances方法并运行该线程。

使用以下代码启动输入对话框并请求密码:

    @Slot()
    def createNewAccount(self):
        password, ok = QInputDialog.getText(self, "Create A New Account",
                 "Password:", QLineEdit.Normal)
        if ok and password != '':
            new_account = blockchain.create_new_account(password)
            self._addAccountToWindow(new_account, 0, resize_parent=True)

在这里,我们调用blockchain对象的create_new_account方法。新帐户的地址将被发送到_addAccountToWindow方法,该方法将在垂直框布局中包含新帐户信息。

接下来,我们使用以下代码块将帐户的地址复制到剪贴板:

    def copyAddress(self, address):
        QToolTip.showText(QCursor.pos(), "Address %s has been copied to clipboard!" % address)
        clipboard = QApplication.clipboard()
        clipboard.setText(address)

在这里,我们获取剪贴板对象并将内容复制到其中。因此,在每个帐户信息中,都将有一个按钮连接到此方法。然而,我们将首先在工具提示中显示此复制操作的信息。Qcursor.pos()是我们鼠标的位置。QtoolTipshowText方法用于显示工具提示。

有四个主要的小部件——帐户地址标签、复制帐户地址的按钮、此帐户余额的标签和头像图片。为了显示头像图片,我们可以使用一个标签。但是我们使用setPixmap方法而不是setText方法,如下面的代码块中所提供的:

    def _addAccountToWindow(self, account, balance, resize_parent=False):
        wrapper_layout = QVBoxLayout()
        account_layout = QHBoxLayout()
        rows_layout = QVBoxLayout()
        address_layout = QHBoxLayout()
        account_label = QLabel(account)
...
...
        avatar.setPixmap(pixmap)
        account_layout.addWidget(avatar)
        account_layout.addLayout(rows_layout)
        wrapper_layout.addLayout(account_layout)
        wrapper_layout.addSpacing(20)
        self.accounts_layout.addLayout(wrapper_layout)

        if resize_parent:
            sizeHint = self.sizeHint()
            self.parentWidget().parentWidget().resize(QSize(sizeHint.width(), sizeHint.height() + 40))

setPixmap接受Qpixmap对象。如果resize_parent为 true,则我们将增加窗口的高度。我们使用名为parentWidget的方法访问主窗口,即选项卡窗口。这必须链接并调用两次,如self.parentWidget().parentWidget()。第一个父窗口是堆栈视图。选项卡小部件是使用堆栈视图构建的。

使用以下代码调用BalanceThread实例的kill()方法:

    def kill(self):
        self.balance_thread.kill()
        sleep(2)

这将告诉线程停止其任务。

下一个方法是由线程实例用于更新余额的:

    @Slot()
    def _updateBalances(self, accounts):
        for account, balance in accounts:
            self.balance_widgets[account].setText('Balance: %.5f ethers' % balance)

balance_widgets[account]保存特定帐户的余额标签。

发送交易小部件

第二个小部件是SendWidget。在wallet_widgets目录中创建一个名为send_widget.py的文件。此小部件负责从 ERC20 令牌发送以太币或硬币。有关此部分的完整代码,请转到以下 GitLab 链接:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_09/wallet/wallet_widgets

此小部件是选项卡窗口中最复杂的小部件。在此小部件中,我们需要选择发送方的帐户,然后根据该帐户显示与此帐户相关的以太币或 ERC20 令牌的余额。余额显示为以太币或 ERC20 令牌取决于在此小部件的另一部分中选择了以太坊还是 ERC20 令牌。我们还需要添加一个行编辑,以便人们可以填写目标地址。此外,我们需要一种选择费用的方式,因为有时人们不介意支付更高的费用,以便他们的交易能够更快地得到处理。然后,有一个按钮启动输入对话框,请求密码,以便我们可以创建交易。

要从PySide2库导入小部件和类,请使用以下代码块:

from PySide2.QtWidgets import (QWidget,
                               QGridLayout,
                               QVBoxLayout,
                               QHBoxLayout,
                               QPushButton,
                               QLabel,
                               QInputDialog,
                               QLineEdit,
                               QToolTip,
                               QComboBox,
                               QApplication,
                               QSlider,
                               QSizePolicy)
from PySide2.QtCore import Slot, SIGNAL, QSize, Qt
from PySide2.QtGui import QPixmap, QMovie, QPalette, QColor
from os.path import isdir, exists
from os import mkdir
from tools.util import render_avatar
from blockchain import blockchain, SendTransaction
from wallet_threads.send_thread import SendThread
from wallet_threads.send_token_thread import SendTokenThread

我们还导入了其他东西,比如渲染头像的工具,与区块链交互的方法,以及创建交易和检索有关代币信息的线程类。

使用以下代码初始化SendWidget类:

class SendWidget(QWidget):

    tokens_file = 'tokens.json'

    def __init__(self, parent=None):
        super(SendWidget, self).__init__(parent)

        self.token_name = 'Ethereum'

        self.setupSenderSection()
        self.setupDestinationSection()
        self.setupTokenSection()
        self.setupProgressSection()
        self.setupSendButtonSection()
        self.setupFeeSection()

        self.send_thread = SendThread()
        self.send_thread.send_transaction.connect(self.sendTransactionFinished)
        self.send_token_thread = SendTokenThread()
        self.send_token_thread.send_token_transaction.connect(self.sendTransactionFinished)

        layout = QGridLayout()

        layout.addLayout(self.sender_layout, 0, 0)
        layout.addLayout(self.destination_layout, 0, 1)
        layout.addLayout(self.progress_layout, 1, 0, 1, 2, Qt.AlignCenter)
        layout.addLayout(self.token_layout, 2, 0)
        layout.addLayout(self.send_layout, 2, 1)
        layout.addLayout(self.slider_layout, 3, 0)

        self.setLayout(layout)

tokens_file保存了tokens.json文件。这个配置文件包含了我们监视的所有 ERC20 代币。token_name最初设置为Ethereum,因为默认情况下,我们的加密货币钱包应该处理以太坊交易,而不是 ERC20 代币。在这个小部件中,我们可以发送以太币或自定义代币。然后,我们调用六种方法来建立六种内部布局。这个小部件由六种布局组成。发送方布局用于选择发送方的账户。目的地布局是一个设计用来保存交易目的地账户的字段。进度布局默认情况下是隐藏的,用于显示在发送交易后交易仍在确认中。代币布局用于选择您是要发送 ERC20 代币还是以太币。此外,发送布局用于保存发送按钮,滑块布局用于保存选择交易费用的滑块。我们还创建了两个线程实例——第一个用于发送以太币,而第二个用于发送 ERC20 代币。对于主布局,我们使用网格布局。这种布局更容易布置我们的小部件。

以下代码块是用于设置创建交易小部件的发送方布局部分的方法:

    def setupSenderSection(self):
        accounts = blockchain.get_accounts()

        sender_label = QLabel("Sender")
        sender_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)

        self.balance_label = QLabel("Balance: ")
        self.balance_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)

        self.avatar = QLabel()

        self.sender_combo_box = QComboBox()
        self.sender_items = []
        for account, balance in accounts:
            self.sender_items.append(account)
        self.sender_combo_box.addItems(self.sender_items)
        self.sender_combo_box.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        self.sender_combo_box.currentTextChanged.connect(self.filterSender)

        first_account = self.sender_items[0]
        self.filterSender(first_account)
        self.setAvatar(first_account, self.avatar)

        self.sender_layout = QVBoxLayout()
        sender_wrapper_layout = QHBoxLayout()
        sender_right_layout = QVBoxLayout()
        sender_right_layout.addWidget(sender_label)
        sender_right_layout.addWidget(self.sender_combo_box)
        sender_right_layout.addWidget(self.balance_label)
        sender_wrapper_layout.addWidget(self.avatar)
        sender_wrapper_layout.addLayout(sender_right_layout)
        sender_wrapper_layout.addStretch()

        self.sender_layout.addLayout(sender_wrapper_layout)
        self.sender_layout.addStretch()

在这里,您有一个下拉框来选择本地账户,一个头像图像和一个余额标签。如果您更改下拉框的值,这将自动更改余额标签和头像图像上的文本。

以下代码块是用于设置目的地布局部分的方法:

    def setupDestinationSection(self):
        self.destination_layout = QVBoxLayout()

        destination_label = QLabel("Destination")
        destination_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)

        self.destination_line_edit = QLineEdit()
        self.destination_line_edit.setFixedWidth(380);
        self.destination_line_edit.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)

        self.destination_layout.addWidget(destination_label)
        self.destination_layout.addWidget(self.destination_line_edit)
        self.destination_layout.addStretch()

这个方法主要是一个行编辑。您可以在这个行编辑中粘贴或输入目的地的地址。

以下代码块是用于设置代币布局部分的方法:

    def setupTokenSection(self):
        token_label = QLabel("Token")
        token_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)

        token_combo_box = QComboBox()

        tokens = blockchain.get_tokens()
        first_token = 'Ethereum'
        items = [first_token]
        self.token_address = {'Ethereum': '0xcccccccccccccccccccccccccccccccccccccccc'}
        self.token_informations = {}

        for address, token_from_json in tokens.items():
            token_information = blockchain.get_token_named_tuple(token_from_json, address)
            self.token_informations[token_information.name] = token_information
            self.token_address[token_information.name] = token_information.address
            items.append(token_information.name)

        self.amount_label = QLabel("Amount (in ethers)")

        token_combo_box.addItems(items)
        token_combo_box.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        token_combo_box.currentTextChanged.connect(self.filterToken)

        self.token_avatar = QLabel()

        self.filterToken(first_token)
        token_address = self.token_address[first_token]
        self.setAvatar(token_address, self.token_avatar)

        self.token_layout = QVBoxLayout()
        token_wrapper_layout = QHBoxLayout()
        token_right_layout = QVBoxLayout()
        token_right_layout.addWidget(token_label)
        token_right_layout.addWidget(token_combo_box)
        token_wrapper_layout.addWidget(self.token_avatar)
        token_wrapper_layout.addLayout(token_right_layout)
        token_wrapper_layout.addStretch()
        self.token_layout.addLayout(token_wrapper_layout)

该部分有一个代币的头像,下拉框可以选择以太坊或其他 ERC20 代币,以及 ERC20 代币的总供应量。如果我们改变下拉框的值,它将改变头像和总供应量标签。代币的头像来自代币智能合约的地址。然而,以太坊没有地址,因为它本身就是平台。因此,对于以太坊,我们使用以下虚拟地址:0xcccccccccccccccccccccccccccccccccccccccc

以下代码块是用于设置进度布局部分的方法:

    def setupProgressSection(self):
        self.progress_layout = QHBoxLayout()
        progress_vertical_layout = QVBoxLayout()
        progress_wrapper_layout = QHBoxLayout()
        self.progress_label = QLabel()
        movie = QMovie('icons/ajax-loader.gif')
        self.progress_label.setMovie(movie)
        movie.start()
        self.progress_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        self.progress_description_label = QLabel()
        self.progress_description_label.setText("Transaction is being confirmed. Please wait!")
        self.progress_description_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        progress_wrapper_layout.addWidget(self.progress_label)
        progress_wrapper_layout.addWidget(self.progress_description_label)
        progress_vertical_layout.addLayout(progress_wrapper_layout, 1)
        self.progress_layout.addLayout(progress_vertical_layout)
        self.sendTransactionFinished()

基本上,这是一个标签,用于显示交易正在确认中。在这一部分,有一个标签用于显示加载活动指示器。首先,我们初始化一个接受gif文件的QMovie对象。然后,通过调用该标签的setMovie方法,将这个Qmovie设置到一个标签上。

以下代码块是用于设置发送布局部分的方法,用于创建交易的小部件:

    def setupSendButtonSection(self):
        self.send_layout = QVBoxLayout()
        self.amount_line_edit = QLineEdit()
        self.send_button = QPushButton("Send")
        self.send_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        self.send_button.clicked.connect(self.sendButtonClicked)
        pal = self.send_button.palette()
        pal.setColor(QPalette.Button, QColor(Qt.green))
        self.send_button.setAutoFillBackground(True)
        self.send_button.setPalette(pal)
        self.send_button.update()
        self.send_layout.addWidget(self.amount_label)
        self.send_layout.addWidget(self.amount_line_edit)
        self.send_layout.addWidget(self.send_button)

这一部分用于保存一个发送按钮,与一个回调连接。这个发送按钮是定制的,通过使用背景颜色使其看起来更有吸引力。更改按钮的颜色的方法很简单:

使用以下代码从按钮获取调色板对象,然后将颜色设置为该调色板对象:

        pal = self.send_button.palette()
        pal.setColor(QPalette.Button, QColor(Qt.green))

在这里,我们使用预定义的颜色。

以下代码块用于创建滑块和指示我们在滑块中选择了什么值的标签:

    def setupFeeSection(self):
        self.slider_layout = QVBoxLayout()
        fee_label = QLabel("Fee")
        self.fee_slider = QSlider(Qt.Horizontal)
        self.fee_slider.setRange(1, 10)
        self.fee_slider.setValue(3)
        self.fee_slider.valueChanged.connect(self.feeSliderChanged)
        self.gwei_label = QLabel()
        self.feeSliderChanged(3)
        self.slider_layout.addWidget(fee_label)
        self.slider_layout.addWidget(self.fee_slider)
        self.slider_layout.addWidget(self.gwei_label)

滑块的目的是选择交易的费用。如果您选择更高的费用,交易将更快地处理。

以下代码块用于选择以太坊或 ERC20 代币:

    def filterToken(self, token_name):
        address = self.token_address[token_name]
        token_information = None
        if token_name != 'Ethereum':
            token_information = self.token_informations[token_name]
            self.amount_label.setText("Amount")
        else:
            self.amount_label.setText("Amount (in ethers)")
        self.updateBalanceLabel(token_name, self.sender_account, token_information)
        self.setAvatar(address, self.token_avatar)
        self.token_name = token_name

这是当我们更改代币组合框的值时将执行的回调。我们在这里更新帐户的以太币或代币余额。这样做后,我们更改代币的头像。我们还更新了代币的总供应量。

以下代码块用于选择发送方帐户:

    def filterSender(self, account_address):
        self.sender_account = account_address
        token_information = None
        if self.token_name != 'Ethereum':
            token_information = self.token_informations[self.token_name]
        self.updateBalanceLabel(self.token_name, account_address, token_information)
        self.setAvatar(account_address, self.avatar)

这是当我们更改发送者组合框的值时将执行的回调。在这里,我们更新帐户的以太币或代币余额,然后根据地址更改帐户的头像。

以下代码块是用于将帐户余额设置到标签的方法:

    def updateBalanceLabel(self, token_name, account_address, token_information=None):
        if token_name == 'Ethereum':
            self.balance_label.setText("Balance: %.5f ethers" % blockchain.get_balance(account_address))
        else:
            self.balance_label.setText("Balance: %d coins" % blockchain.get_token_balance(account_address, token_information))

updateBalanceLabel方法中,如果我们使用以太坊,我们使用blockchain对象的get_balance方法设置balance_label的文本。如果我们使用 ERC20 代币,我们使用blockchainget_token_balance方法。

以下代码块是用于设置头像的方法:

    def setAvatar(self, code, avatar):
        img_filename = render_avatar(code)
        pixmap = QPixmap(img_filename)
        avatar.setPixmap(pixmap)

此方法用于设置代币和帐户的头像。

以下代码块是当我们更改费用滑块的值时将执行的回调:

    def feeSliderChanged(self, value):
        self.gwei_label.setText("%d GWei" % value)
        self.fee = value

以下代码块是单击发送按钮时将执行的方法:

    def sendButtonClicked(self):
        password, ok = QInputDialog.getText(self, "Create A New Transaction",
                 "Password:", QLineEdit.Password)
        if ok and password != '':
            self.progress_label.setVisible(True)
            self.progress_description_label.setVisible(True)
            tx = SendTransaction(sender=self.sender_account,
                                 password=password,
                                 destination=self.destination_line_edit.text(),
                                 amount=self.amount_line_edit.text(),
                                 fee=self.fee)
            token_information = None
            if self.token_name != 'Ethereum':
                token_information = self.token_informations[self.token_name]
                self.send_token_thread.prepareTransaction(tx, token_information)
                self.send_token_thread.start()
            else:
                self.send_thread.prepareTransaction(tx)
                self.send_thread.start()

在这里,我们将被要求在输入对话框中提供密码。如果我们点击确定,那么我们将设置进度标签和加载活动指示器为可见。我们构造了一个名为SendTransaction的元组,然后将其发送到处理发送以太币或 ERC20 代币交易的线程类对象。最后,我们运行线程。

以下代码块用于在交易完成时隐藏进度标签(加载指示器):

    def sendTransactionFinished(self):
        self.progress_label.setVisible(False)
        self.progress_description_label.setVisible(False)

此方法将在线程实例完成工作后被调用(无论是发送以太币还是作为 ERC20 代币发送硬币)。

代币小部件

最后的小部件是代币小部件。该小部件负责监视 ERC20 代币。在wallet_widgets目录中创建token_widget.py。转到提供完整代码文件的以下 GitLab 链接:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_09/wallet/wallet_widgets

这个最后的小部件在主小部件的第三个选项卡中。这里的目的是监视 ERC20 代币并列出所有已监视的 ERC20 代币。有一个按钮来启动输入对话框,一个按钮来请求 ERC20 智能合约代币的地址,然后有一个垂直布局来显示所有 ERC20 代币:

from PySide2.QtWidgets import (QWidget,
                               QGridLayout,
                               QVBoxLayout,
                               QHBoxLayout,
                               QPushButton,
                               QLabel,
                               QInputDialog,
                               QLineEdit,
                               QToolTip,
                               QComboBox,
                               QApplication,
                               QSlider,
                               QSizePolicy)
from PySide2.QtCore import Slot, SIGNAL, QSize, Qt
from PySide2.QtGui import QPixmap, QMovie, QPalette, QColor
from os.path import isdir, exists
from os import mkdir
from time import sleep
import json
from tools.util import render_avatar
from blockchain import blockchain, SendTransaction, TokenInformation

像往常一样,我们导入了许多东西,比如渲染头像工具、区块链对象以建立有关区块链上代币的信息,以及一些处理文件系统的库。除此之外,我们还从PySide2导入了 UI 类,比如许多类型的小部件,以及用于将回调附加到小部件的类。除了 UI 类,我们还从 PySide2 导入了非 UI 类,比如slotsignal

使用以下代码块进行初始化方法:

class TokenWidget(QWidget):

    tokens_file = 'tokens.json'

    def __init__(self, parent=None):
        super(TokenWidget, self).__init__(parent)

        self.watch_token_button = QPushButton("Watch Token")

        tokens = blockchain.get_tokens()

...
...

        self.watch_token_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        self.connect(self.watch_token_button, SIGNAL('clicked()'), self.watchNewToken)

        layout.addWidget(self.watch_token_button, 0, 0)
        layout.addLayout(self.tokens_layout, 1, 0)

        self.setLayout(layout)

在这个初始化方法中,我们创建一个按钮,链接到watchNewToken方法,然后创建一个垂直框布局来容纳所有代币信息。我们还声明了tokens_file对象,它保存了tokens.json配置文件。该文件跟踪所有与 ERC20 代币相关的信息。

使用以下代码块为每个代币信息创建一个头像图像、代币名称标签、代币符号标签和代币总供应标签:

    def _addTokenToWindow(self, token_information, resize_parent=False):
        wrapper_layout = QVBoxLayout()
        token_layout = QHBoxLayout()
        rows_layout = QVBoxLayout()
        token_label = QLabel(token_information.name)
...
...
        if resize_parent:
            sizeHint = self.size()
            self.parentWidget().parentWidget().resize(QSize(sizeHint.width(), sizeHint.height() + 100))

如果resize_parent为 true,这意味着我们通过对话框添加代币信息。换句话说,我们要求父窗口增加其高度。如果resize_parentfalse,这意味着此方法是从开始调用的。

以下代码块是用于使用对话框请求智能合约地址的方法:

    @Slot()
    def watchNewToken(self):
        address, ok = QInputDialog.getText(self, "Watch A New Token",
                 "Token Smart Contract:", QLineEdit.Normal)
        if ok and address != '':
            token_information = blockchain.get_information_of_token(address)
            self._addTokenToWindow(token_information, resize_parent=True)
            token_data = {}
            if exists(self.tokens_file):
                with open(self.tokens_file) as json_data:
                    token_data = json.load(json_data)
            token_data[token_information.address] = {'name': token_information.name,
                                                     'symbol': token_information.symbol,
                                                     'total_supply': token_information.totalSupply}
            with open(self.tokens_file, 'w') as outfile:
                json.dump(token_data, outfile)

如果用户确认了智能合约的地址,我们将使用blockchain对象的get_information_of_token方法获取代币信息。然后,将这个代币的信息放在垂直框布局中。稍后,我们将代币的信息保存在 json 文件中。这样做是为了在重新启动应用程序时可以加载这些代币信息。

在启动加密货币钱包之前,请确保首先运行私有链,然后在私有链中部署一个或两个 ERC20 智能合约。您可以在第八章中使用 ERC20 智能合约源代码,在以太坊中创建代币。完成此操作后,使用以下命令运行桌面加密货币钱包:

(wallet-venv) $ python wallet.py

您将在以下截图中看到最终输出:

在上述截图中,我们看到账户选项卡显示了每个账户的余额。确保至少有两个账户。如果没有,请通过单击“创建账户”按钮从此选项卡创建一个。

以下截图显示了发送选项卡,我们可以向任何我们选择的账户发送以太币:

在第二个选项卡中,尝试发送以太币。在交易确认之前需要一些时间。因此,尝试向另一个账户发送 ERC20 代币(但您必须首先在第三个选项卡中添加 ERC20 代币),如下截图所示:

最后,在第三个选项卡中,尝试观察一个代币智能合约。单击“观察代币”按钮时,在对话框中放入智能合约地址:

您的代币将在第二个选项卡中反映出来。

测试

让我们为这个 GUI 应用程序编写测试。这些测试不应该是详尽无遗的。我们将创建三个测试,每个选项卡一个。我们不会为应用程序的非 UI 部分创建测试。本节只是为了演示如何测试 UI 应用程序。

第一个选项卡的第一个测试是一个账户小部件测试。将测试命名为test_account.py,并将其保存在tests目录中。以下代码块是测试脚本:

import sys, os
sys.path.append(os.path.realpath(os.path.dirname(__file__)+"/.."))

from wallet import WalletWidget
from PySide2.QtWidgets import QInputDialog
from PySide2 import QtCore

def test_account(qtbot, monkeypatch):
    wallet = WalletWidget()
    qtbot.addWidget(wallet)

    old_accounts_amount = wallet.account_widget.accounts_layout.count()

    monkeypatch.setattr(QInputDialog, 'getText', lambda *args: ("password", True))
    qtbot.mouseClick(wallet.account_widget.create_account_button, QtCore.Qt.LeftButton)

    accounts_amount = wallet.account_widget.accounts_layout.count()
    assert accounts_amount == old_accounts_amount + 1

    wallet.killThreads()

在这个测试中,我们测试了在点击按钮、启动对话框、填写密码,然后点击确定之前,账户布局有多少子项,然后我们再次检查创建新账户后子项的数量。数量应该增加一个。为了进行这个测试,我们对对话框进行了补丁,以便更容易进行测试。

这个测试并不全面。我们没有测试失败的情况。我将把这留给读者作为练习。

第二个选项卡的测试是发送交易小部件测试。将测试文件命名为test_send.py,并将其保存在tests目录中。测试脚本如下所示(有关完整代码,请参考以下 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/wallet/tests/test_send.py):

import sys, os
sys.path.append(os.path.realpath(os.path.dirname(__file__)+"/.."))
from time import sleep

from wallet import WalletWidget
from PySide2.QtWidgets import QInputDialog
from PySide2 import QtCore

...
...

    qtbot.keyClicks(wallet.send_widget.sender_combo_box, second_account)
    balance_of_second_account = int(float(wallet.send_widget.balance_label.text().split()[1]))

    assert balance_of_second_account - old_balance_of_second_account == 10

    wallet.killThreads()

在这个测试中,我们检查了组合框中第二个账户的余额。这个第二个账户将是目标账户。在这里,我们从标签中读取余额,然后将组合框的值改回第一个账户,这将是发送方。之后,我们将目标账户的地址设置为目标行编辑。然后我们在金额行编辑中设置以太币的数量,并点击发送按钮,但请记住,我们需要对输入对话框进行补丁。最后,在将账户组合框的值再次更改为第二个账户之前,我们等待大约 20 秒。我们从标签中检索余额,然后比较旧值和新值之间的差异,应该是 10 以太币。

第三个选项卡的测试是为了测试令牌小部件。将其命名为test_token.py并保存在tests目录中。此测试的测试脚本如下所示:

import sys, os
sys.path.append(os.path.realpath(os.path.dirname(__file__)+"/.."))

from wallet import WalletWidget
from PySide2.QtWidgets import QInputDialog
from PySide2 import QtCore

def test_token(qtbot, monkeypatch):
    wallet = WalletWidget()
    qtbot.addWidget(wallet)

    old_tokens_amount = wallet.token_widget.tokens_layout.count()

    address = None
    with open('address.txt') as f:
        address = f.readline().rstrip()

    monkeypatch.setattr(QInputDialog, 'getText', lambda *args: (address, True))
    qtbot.mouseClick(wallet.token_widget.watch_token_button, QtCore.Qt.LeftButton)

    tokens_amount = wallet.token_widget.tokens_layout.count()
    assert tokens_amount == old_tokens_amount + 1

    wallet.killThreads()

首先,我们将令牌智能合约的地址加载到address.txt文件中,因为我们不希望在测试文件中将其硬编码。策略与账户小部件测试中的相同。我们检查垂直框布局有多少子项。完成后,我们点击按钮,启动对话框,填写智能合约的地址,然后点击确定。接下来,我们再次检查垂直框布局有多少子项。这个数字应该再次增加 1。

就像我说的,这个测试实际上并不完整。我们也应该测试令牌信息。但是,这个测试是一个很好的开始。

您可以使用以下命令运行前述测试:

(wallet-venv) $ pytest tests

构建加密货币钱包时需要考虑的因素

您现在已经创建了一个桌面加密货币钱包。但是,这个钱包还没有完全完成。加密货币钱包是一个广泛的话题,变化如此之快,以至于可以单独写一本书来讨论这个话题。您可以将其他功能实现到加密货币钱包应用程序中,比如已确认的区块数量。在我们的应用程序中,我们只等待一个交易,但有些用户可能想要先确认几个区块。如果交易仅经过一个区块的确认,那么它有一点可能被更长的区块替换。然而,经过 12 个区块后,区块中的交易基本上是安全且不可逆的,如下面的链接所解释的:ethereum.stackexchange.com/questions/319/what-number-of-confirmations-is-considered-secure-in-ethereum

我们的加密货币钱包是一个纯粹的加密货币钱包。但是,您也可以向我们的加密货币钱包添加与钱包功能无关的其他功能。例如,Mist 加密货币钱包不仅仅是一个钱包;它还是一个去中心化应用程序浏览器。它还可以编译智能合约的源代码并将其部署到区块链上。

如果您想要制作一个完整的加密货币钱包,您应该实现许多功能。一些想法包括生成二维码、导出加密私钥的选项、导入私钥、使用种子短语生成账户、输入验证以及记住密码一段时间。

在这里,我们正在构建一个桌面加密货币钱包。桌面应用程序可以拥有大量的内存和存储空间。但是,如果您正在构建一个移动加密货币钱包,情况就不同了。例如,比特币桌面加密货币钱包可以在本地访问完整节点。但是,您不能将完整的比特币节点放在手机上,因为它太大了。当然,您可以将完整的比特币节点放在云上,让移动加密货币钱包应用程序访问它。但是,大多数人不想在云上设置完整节点。因此,比特币移动加密货币钱包的任何开发者通常使用简化支付验证(SPV)。这样,比特币移动加密货币钱包就不需要在手机上存储完整的比特币节点。

如果您想要构建加密货币钱包或为现有的加密货币钱包做出贡献,您需要牢记两件事:安全和用户体验(UX)。

安全

加密货币钱包处理资金,因此您需要确保其安全。安全是一个复杂的话题,在这里我们将简要讨论。

不要仅仅因为可以而安装第三方库;每个库都是另一个攻击向量。在应用程序中慎重打包第三方库。我们的加密货币钱包使用来自以太坊 GitHub 的库,如 web3.py 和 Populus。这应该没问题,因为它们是核心库。我们还使用 Qt 公司的PySide2库。这个库是必不可少的,因为没有 GUI 库就不能有 GUI 应用程序。我们还使用第三方库来生成 identicon 头像图像。在这里我们需要小心。这个库是一个单一文件,我已经完全阅读过以确保没有隐藏的恶意软件。因此,我可以自信地将其整合到我们的应用程序中。

在宣布交易完成之前,请使用最少数量的确认。确认次数足够多取决于您的威胁和风险建模。通常,12 次确认会使撤销交易变得不切实际。Mist 钱包使用 12 次确认,而 ZCash 钱包使用 10 次确认。

您还可以在创建加密货币钱包的帐户时强制用户创建一个良好的密码,因为大多数用户倾向于使用糟糕的密码创建帐户。但在这里要小心;您不希望过分打扰他们。

用户体验

如果一个应用程序非常安全但使用起来非常困难,那就没有用了。因此,我们需要让用户感到不那么害怕。比特币的创造者中本聪在构建软件时非常注重用户体验。以前,人们使用 base64 格式将二进制转换为文本。然而,中本聪使用 base58 来表示比特币地址。Base58 类似于 base64,但在打印时不会引起混淆的字符,比如 I(大写 i)和 l(小写 L)。

Zcash 发布了一个关于设计加密货币钱包的用户体验指南,可以在以下链接找到:zcash.readthedocs.io/en/latest/rtd_pages/ux_wallet_checklist.html。这里并非所有内容都可以实施,因为 Zcash 有以太坊没有的私人交易。然而,其他建议可以实施;例如,市场信息。不管喜欢与否,人们将加密货币价格挂钩到法定货币上,向人们展示 1 个以太的市场价格是个好主意。如果网络拥挤,您也应通知用户。您可以建议用户等待或增加交易费用。

如果您要构建 iOS 加密货币钱包,应遵循苹果人机界面指南。如果您要构建 Android 加密货币钱包,应遵循 Material Design 指南。在选择排版和颜色时要小心。在设计加密货币钱包时,应进行用户访谈。用户体验是一个广泛的主题。平衡用户体验和安全是一门微妙的艺术。在构建加密货币钱包时不应忽视用户体验。

摘要

在本章中,我们已经熟悉了PySide2的标签视图、大小策略和网格布局。然后,我们还学习了如何测试 Qt 应用程序。接下来,我们开始构建一个桌面加密货币钱包。我们将应用程序分成许多部分:区块链、线程、小部件、标识工具和测试。加密货币钱包的区块链部分是基于web3Populus库的,其目的是在区块链中读取和创建交易。线程是在创建交易时,UI 部分和区块链对象之间的中间人。标识工具用于根据特定字符串(通常是账户地址或代币智能合约地址)创建头像图像。小部件部分是一个带有三个选项卡的选项卡小部件。第一个选项卡是账户小部件,第二个选项卡是发送交易小部件,第三个选项卡是代币小部件。最后,我们为这个应用程序创建了测试。

在下一章中,我们将开始学习区块链技术范围之外的主题。这项技术被称为 IPFS。它仍然是分散式技术的一部分,但这项技术将克服与区块链技术相关的弱点;换句话说,它的存储是昂贵的。

第五部分:去中心化文件系统

本节是对星际文件系统的介绍,人们可以以分布式的方式存储文件。我们还将构建一个去中心化的类似 Youtube 的应用程序。

接下来的章节将在本节中涵盖:

  • [第十章],星际网络-一个崭新的文件系统

  • [第十一章],使用 ipfsapi 与 IPFS 进行交互

  • [第十二章],使用 IPFS 实现去中心化应用程序

第十章:星际文件系统-一个崭新的文件系统

在本章中,我们将学习星际文件系统IPFS)。IPFS 实际上并不是区块链技术的一部分;相反,它是对其的补充。IPFS 与区块链是天作之合。正如你在之前的章节中学到的,区块链中的存储是昂贵的。通常,人们在区块链中保存文件的链接,并将实际文件保存在普通存储中,比如云存储。但这种策略遭受了中心化的命运。IPFS 为区块链开发者提供了一种避免这种情况的方法。

在本章中,你将学习以下内容:

  • IPFS 背后的动机

  • Merkle DAG

  • 点对点网络

IPFS 背后的动机

IPFS 不是一个普通的文件系统,比如fat32ntfsext3。它更类似于 Dropbox。它是一个跨设备的文件系统。你可以把文件保存在这个文件系统中,全世界的人都可以像在自己的电脑上一样轻松地访问它。如果以太坊可以被认为是世界上唯一的操作系统,那么 IPFS 可以被认为是世界上唯一的存储!

IPFS 网站的口号是IPFS 是分布式网络。IPFS 试图取代,或者至少补充 HTTP。HTTP 协议已经服务了我们很长时间,超过 20 年,但它被认为不足以应对即将出现的挑战,比如带宽需求增加或文件冗余。HTTP 使用客户端-服务器模型。你只能选择这两种角色中的一种:要么是服务器,要么是客户端。

这种架构存在一些问题:

  • 第一个问题是,要担任服务器角色,我们必须有足够的资源。如果没有,如果服务器被大量请求淹没,它可能会迅速崩溃。处理每分钟一百万个请求所需的资源对许多普通人来说是不可及的。

  • 第二个问题是,服务器和客户端的架构在某些情况下并不高效。想象一下,你坐在公园里的一位老奶奶旁边,你们两个都在从同一个 URL 观看一只可爱熊猫的视频(类似example.com/cute_panda.mp4)。假设这个视频的大小是 20MB。这意味着服务器必须两次发送一个 20MB 的文件到两个不同的位置,即使这两个不同的位置距离很近,相隔一米。换句话说,服务器使用了 40MB 的带宽。然而,想象一下,如果你可以不是从服务器,而是从坐在你旁边的老奶奶那里获取文件(在这种情况下,假设老奶奶比你早两分钟观看了这个可爱熊猫的视频)。这不是更高效吗?

Juan Benet 在 2013 年末受到启发建立了 IPFS。当时,他正在研究知识工具,这个术语指的是可以有效地从论文中获取知识的软件。比如,一个科学家读了很多论文。如果科学家能更快地获取这些知识就更好了。Benet 遇到的问题是数据集分发需要太多的工作。没有简单的方法来处理数据集的版本控制。他看了各种工具,比如 Git 和 BitTorrent,并想知道它们是否可以结合起来解决这个问题。结果,IPFS 诞生了。BitTorrent 启发了 IPFS 在节点之间分发文件和查找文件。Git 启发了 IPFS 保持文件的完整性和将保存的文件转换为存储。

IPFS 是一个点对点的超媒体协议,使网络更快、更安全、更开放。IPFS 的目标是务实的和理想化的。除了节省带宽,它的另一个目标是增加文件的寿命。在服务器上保存文件很长时间(比如十年)需要大量的资源。我们希望文件保持存活的原因通常是因为它对服务器所有者有某种经济利益;例如,如果是博客文章,可以通过广告实现货币化。如果不是,文件有可能会被存储服务器的所有者销毁。这就是当 Geocities 关闭时发生的情况。

Geocities 是一个允许人们创建自己个人网站的网站。它类似于wordpress.commedium.com。一些服务器所有者会保持文件存活,即使没有广告,就像维基百科一样,它靠捐赠维持运转。除此之外,文件就没有那么幸运了。

IPFS 的其他目标更加理想化,涉及到如何使我们提供内容的方式更加民主化。目前,内容非常集中。我们通常只会去一些网站,比如 Facebook、Instagram、Reddit、Medium、Netflix、Amazon、Google、Wikipedia 等等。这种信息的寡头垄断阻碍了互联网的创新,因为信息实际上是由少数几家公司控制的。除了维基百科,大多数,如果不是全部,公司都受到富有的股东的约束。这种情况与 10 年前形成了鲜明对比,当时互联网被认为是财富和信息的伟大均等化者,类似于印刷技术。

这种重度集中化的另一个缺点是提供的信息容易受到审查。例如,Google 是一家位于加利福尼亚州山景城的公司,因此受美国法律约束。大多数有权做决定的人(高级管理人员和 C 级别人员)都是美国人,因此在他们对世界的看法中存在美国偏见。在欧洲大多数国家都是合法的事情,在美国可能因为被认为是亵渎或危险而被审查。IPFS 项目的创始人将这种情况比作被国家或强大机构认为危险而被焚烧的书籍。IPFS 项目的一个目标是增加文件对审查的抵抗力。IPFS 使人们更容易地镜像和提供危险文件。我们将在本章的后面讨论 IPFS 如何实现这一目标。

IPFS 的最终目标更加务实,涉及到我们脆弱的互联网基础设施,由计算机网络和由光纤电缆连接的核心路由器组成。如果连接的电缆意外或故意受损,一个区块或区域可能会离线。2011 年,一名女子用铁锹损坏了为亚美尼亚带来互联网的电缆,当时她在挖掘寻找金属出售。IPFS 项目并不能完全解决这个问题,但它可以在一定程度上减轻损害。

你可以在这里找到关于那位女士和她的铁锹的事件:web.archive.org/web/20141225063937/http://www.wsj.com/articles/SB10001424052748704630004576249013084603344.

Merkle DAG

如果你已经了解了 Git 的内部工作原理,Merkle 有向无环图(DAG)就不会太陌生。作为一个版本控制系统软件,Git 需要保留文件的许多版本并轻松地将它们分发给其他人。它还需要能够快速检查文件的完整性。

Merkle DAG 由两个词组成:Merkle 和 DAG。让我们先讨论 Merkle。实际上,在这个上下文中,Merkle 的完整词是 Merkle 树。 Merkle 树是一种快速检查部分数据是否被篡改的方法。

Merkle 树

让我们看一个 Merkle 树的例子以便理解它。假设您有八个数据。在这种情况下,我们将使用动物的名称作为我们的数据,但在使用 Merkle 树的比特币中,数据通常是交易。回到 Merkle 树:按顺序放置数据,所以在这种情况下,猫是第一条数据,狗是第二条,蚂蚁是第三条,依此类推:

我们获取每个数据的哈希,例如猫,狗,蚂蚁等。在这个演示中,我们使用哈希函数 SHA256。由于空间有限,我们在图中截断了完整的哈希结果。现在,我们将数据从左到右排序,所以“猫”字符串的哈希是Data 1,“狗”字符串的哈希是Data 2,“蚂蚁”字符串的哈希是Data 3,依此类推。

现在是有趣的部分。对于Data 1Data 2,我们合并哈希并对结果进行哈希。合并哈希意味着连接它。对Data 3Data 4Data 5Data 6Data 7Data 8也是如此。

这可能让您想起淘汰赛。我们现在进入半决赛阶段。我们现在有Hash 1(来自Data 1Data 2),Hash 2(来自Data 3Data 4),Hash 3(来自Data 5Data 6),以及Hash 4(来自Data 7Data 8)。

然后我们连接Hash 1Hash 2,对结果进行哈希,并将其命名为Hash 5。然后我们对Hash 3Hash 4做同样的事情。将结果命名为Hash 6

我们现在进入最后阶段。合并Hash 5Hash 6,然后对结果进行哈希。结果就是Root Hash。这个Root Hash可以保证所有数据的完整性(从Data 1Data 8)。如果更改任何数据,Root Hash将会不同。

您可能会问为什么我们不只是从一开始就连接所有数据(从Data 1Data 8)然后对结果进行哈希。然而,事实证明,Merkle 树比仅仅连接所有数据然后对其进行哈希(这种技术称为哈希列表,在某些情况下使用)具有一些优点。其中一个好处是,当我们使用 Merkel 树时,检查部分数据的完整性更容易和更便宜。

在 Merkle 树中,要检查Data 5的完整性,您只需要下载Data 5Data 6Hash 4Hash 5Root Hash,如下图所示。您不需要下载所有数据:

如果您使用天真的方法,您需要下载所有数据的哈希值(Data 1Data 8)和Root Hash。在这个例子中,我们只有八个数据。想象一下,如果我们有 100 个数据,你需要下载整个数据集。Merkle 树使这个过程更有效,因为我们不需要下载完整的数据集。

如果我们有奇数个节点,比如七个,一般规则(比特币实现的规则)是克隆最后一个节点,所以Data 8Data 7的副本。然而,您也可以使用另一条规则;我见过 Merkle 树的一种实现,其中单个数据(在我们的例子中是Data 7)被简单地提升到顶部。在这种情况下,Hash 4就是Data 7

这就是当人们使用简化支付验证时比特币所做的事情。使用移动应用程序下载完整节点是困难的。为了发送比特币交易,用户只下载节点的重要部分而不是完整节点。Merkle 树使这一过程成为可能。

在下一节中,我们将继续学习 DAGs。

指令丙烯酸图(DAGs)

指令丙烯酸图DAGs),顾名思义,是图,其中每个顶点(或节点)都可以有指向其他顶点的边,如下图所示:

箭头的方向并不重要,只要保持一致即可:

规则是这些边不应该构成一个循环。在下图中,我们可以看到顶点 A、C 和 D 构成一个循环,这违反了 DAG 的规则:

现在,如果你结合 Merkle 树和 DAG,你会得到一个 Merkle DAG。这是 Git 和 IPFS 使用的数据结构。

在 Merkle 树中,只有叶节点保存数据。然而,在 Merkle DAG 中,任何节点都可以保存数据。在 Merkle 树中,树必须是平衡的,但在 Merkle DAG 中没有这样的限制。

在我们深入 Merkle DAG 之前,让我们先了解内容寻址,因为 Merkle DAG 依赖于这个特性。

内容寻址

在链表中,你用指针将节点(或块)链接在一起。指针是指向内存的数据类型。例如,假设我们有两个节点,节点 A 和节点 B。节点 A 是头,节点 B 是尾。节点的结构有两个重要的组成部分。第一个组成部分是数据组成部分,你在其中存储数据。在 Git 中,这个数据可以是文件的内容。第二个组成部分是指向另一个节点的链接。在链表中,这是指向节点地址的指针。

但是,使用内容寻址时,我们不仅仅添加一个指针,还会添加目标的哈希值(在这种情况下是节点 B)。你可能会认出这个概念;这正是区块链中发生的事情。然而,Merkle DAG 不是一个线性延伸的链表。Merkle DAG 是一个可以有分支的树。

这是一个链表。它用于区块链的数据结构:

现在,考虑这种情况。我们有三个节点:节点 A1 和 A2 都是指向节点 B 的头。我们不是把指针放在节点 A1 和节点 A2 上,而是把指针放在节点 B 上。节点 B 现在有两个指针。节点 B 对节点 A1 和节点 A2 进行哈希,然后连接两个哈希再次进行哈希。这样,节点 B 可以保持节点 A1 和节点 A2 的内容的完整性。如果有人改变了节点 A1 或节点 A2 的内容,节点 B 保存的哈希将无效:

IPFS 在获取文档方面与 HTTP 不同。HTTP 使用链接,它们像指针一样工作。例如,假设我们有以下链接:example.com/cute_panda.png。这使用一个位置来获取名为cute_panda.png的文档。只有一个提供者可以提供这个文档,那就是example.com。然而,IPFS 不使用 URL 链接。相反,它使用哈希链接,比如ipfs://QmYeAiiK1UfB8MGLRefok1N7vBTyX8hGPuMXZ4Xq1DPyt7。当你访问这个哈希链接时,IPFS 软件将找到文档,当哈希后,将给出相同的哈希输出。因为哈希是一个单向函数,IPFS 必须有一些其他信息来定位文档。基本上,它会将请求广播到附近具有这个哈希输出的文档的节点。如果附近的节点没有这些文件,它们会将请求转发给它们附近的节点。这种对等查找请求非常复杂。IPFS 使用 S/Kademlia 分布式哈希表,我们将在本章的后面部分讨论。

有趣的是,当你使用内容寻址时,可能会有多个提供者可以提供这个文档。在cute_panda.png文档的情况下,可能有四个以上的节点可以提供这个文档。我们可以选择最近的节点来使下载过程更有效率。这个特性也使得审查变得更加困难。在 HTTP 的情况下,一个行为者可以禁止服务器example.com。然而,在 IPFS 的情况下,任何人都可以启动一个新节点并提供文档。现在,IPFS 是透明的,也许太过透明。请求文档的节点可以看到提供文档的节点的 IP 地址,反之亦然。行为者可以禁止 IP 地址以阻止这个文档的传播。然而,使 IPFS 与 Tor 一起工作的开发,Tor 是一种允许用户匿名浏览网站的软件,仍处于早期阶段。

如果你从example.com/cute_panda.png下载一个文档,那么你在那一刻得到的文档可能与你的朋友昨天从同样的 URL 下载的文档不同。可能是服务器的管理员在你今天下载之前改变了文档。

然而,通过内容寻址系统,你从 IPFS 哈希链接ipfs://QmYeAiiK1UfB8MGLRefok1N7vBTyX8hGPuMXZ4Xq1DPyt7获取的文档,无论何时何地下载,都是相同的。这个哈希链接保证了没有人可以篡改文档。如果你改变文档并上传到 IPFS,IPFS 的 URL 或哈希将会不同。

我们可以创建一个简单的 Python 脚本来说明这种情况。在这个目录中创建一个名为ipfs_tutorial的目录。在这个目录中创建三个示例文件。第一个示例文件是hello.txt,内容是I am a good boy.\n。第二个示例文件是hello2.txt,内容是I am a good girl.\n。第三个示例文件是hello3.txt,内容是I am a good horse.\n。第四个示例文件是hello4.txt,内容是I am a good girl.\n。第二个和第四个文件具有相同的内容是故意的。如果你愿意,你可以创建不同的文件,但请确保其中至少有两个文件具有相同的内容。

创建一个 Python 脚本,如下面的代码块所示,并将其命名为create_hash_from_content.py

from os import listdir
from hashlib import sha256

files = [f for f in listdir('.') if 'hello' in f]

hashes = {}

for file in files:
    with open(file) as f:
        content = f.read().encode('utf-8')
        hash_of_content = sha256(content).hexdigest()
        hashes[hash_of_content] = content

content = hashes['20c38a7a55fc8a8e7f45fde7247a0436d97826c20c5e7f8c978e6d59fa895fd2']
print(content.decode('utf-8'))

print(len(hashes))

这个脚本列出了同一目录中所有以hello开头的文件。如果你的示例文件不是以hello开头的,你可以修改这部分。长哈希是hello2.txt内容的哈希值。

当你运行这个脚本时,你会得到以下结果:

I am a good girl.

3

正如你所看到的,有四个文件,但最终输出是三个,而不是四个。这是因为有三个文件具有独特的内容,而不是四个。这就是内容寻址的工作原理。它不关心文件名,只关心内容。文件名是hello1.txt还是hello2.txthello4.txt都无关紧要,重要的是内容I am a good girl.\n是相同的。从技术上讲,这是一个“善意的谎言”;有一种情况下 IPFS 必须考虑文件名,不能忽视它。我将在本章后面解释这个问题的真相。

在前面的例子中,我们看到的是正常的哈希。没有 Markle DAG 甚至 Merkle 树。现在让我们创建一个更复杂的场景,有一个大文件。对大文件进行哈希不是有效的。通常,我们将文件分割成多个相同大小的小块。例如,一个 900KB 的文件会变成四个文件。第一、第二和第三个文件的大小为 250KB。第四个文件的大小为 150KB。然后,我们对每个较小的文件进行哈希,并与 Merkle 树结合。

为了说明目的,我们不会使用一个大文件,但我们会设置一些虚构的限制。我们不希望对跨越多行的内容进行哈希。如果文本文件有四行,我们会将它们分成四个更小的文件。

在项目目录中,创建一个名为hello_big.txt的文件,并输入以下内容:

I am a big boy.
I am a tall girl.
I am a fast horse.
I am a slow dragon.

在创建一个脚本来对这个大文件进行哈希之前,让我们创建一个非常简单的 Merkle 树库,并将其命名为merkle_tree.py。有关完整代码文件,请参考 GitLab 链接:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_10

让我们从初始化开始讨论这个 Merkle 树库:

    def __init__(self, leaf_nodes : List[str]):
        self.hash_nodes : List[str] = []
        self.leaf_nodes : List[str] = leaf_nodes
        self._turn_leaf_nodes_to_hash_nodes()
        if len(leaf_nodes) < 4:
            self.root_hash = self._hash_list()
        else:
            self.root_hash = self._build_root_hash()

确保至少有四个节点。如果没有,我们可以使用哈希列表技术。leaf_nodes是原始数据节点。它们是字符串列表,比如['cat', 'dog', 'unicorn', 'elephant']hash_nodes是数据节点的哈希列表,比如[hash of 'cat', hash of 'dog', hash of 'unicorn', hash of 'elephant']['77af778...', 'cd6357e...', 'c6cb50e...', 'cd08c4c...']

如果节点少于四个,我们使用_hash_list()方法对数据进行哈希列表。我们在对它们进行哈希之前将所有数据拼接起来:

    def _hash_list(self):
        long_node = "".join(self.hash_nodes)
        return self._hash(long_node.encode('utf-8'))

_turn_leaf_nodes_to_hash_nodes()方法中,我们根据leaf_nodes填充hash_nodes。这是一对一的映射:

    def _turn_leaf_nodes_to_hash_nodes(self):
        for node in self.leaf_nodes:
            self.hash_nodes.append(self._hash(node.encode('utf-8')))

_hash()方法中,我们包装了sha256哈希函数。这是为了使类的定制更容易,因为我们可能想使用不同的哈希函数:

    def _hash(self, data : bytes) > bytes:
        return sha256(data).hexdigest()

以下代码块显示了如何从哈希节点中获取根节点:


    def _build_root_hash(self) > bytes:
        parent_amount = ceil(len(self.hash_nodes) / 2)
        nodes : List[str] = self.hash_nodes

        while parent_amount > 1:
            parents : List[bytes] = []
            i = 0
            while i < len(nodes):
                node1 = nodes[i]
                if i + 1 >= len(nodes):
                    node2 = None
                else:
                    node2 = nodes[i+1]
                parents.append(self._convert_parent_from_two_nodes(node1, node2))
                i += 2
            parent_amount = len(parents)
            nodes = parents

        return parents[0]

在这里,我们对哈希节点进行多次迭代。每次迭代都会跳过两步。对于每次迭代,它都会处理两个节点。它连接这两个节点的哈希,然后对结果进行哈希。得到的哈希是这两个节点的父节点。这个父节点成为将要再次迭代的哈希节点的一部分。这个父节点和它的邻居将再次连接并进行哈希,依此类推。如果哈希节点数为奇数,最后一个节点将在进行哈希之前与自身连接。如果只有一个父节点,我们返回该节点的哈希,即根哈希

    def _convert_parent_from_two_nodes(self, node1 : bytes, node2) -> bytes:
        if node2 == None:
            return self._hash((node1 + node1).encode('utf-8'))
        return self._hash((node1 + node2).encode('utf-8'))

_convert_parent_from_two_nodes()方法允许我们从两个子节点获取父哈希。我们将这两个节点连接起来并对它们进行哈希。如果第二个节点是None,表示节点数为奇数或者我们正在处理最后一个节点,我们只需将节点与自身连接再进行哈希。

现在 Merkle 树库已经准备好了,我们将创建一个 Python 脚本来对hello_big.txt文件进行哈希,并将其命名为hash_big_file.py

from os import listdir
from hashlib import sha256
from merkle_tree import MerkleTree

hashes = {}

file = 'hello_big.txt'
with open(file) as f:
    lines = f.read().split('\n')
    hash = []
    hash_of_hash = []
    merkle_tree = MerkleTree(lines)
    root_hash = merkle_tree.root_hash

hashes[root_hash] = []
for line in lines:
    hashes[root_hash].append(line)

print(hashes)

如果执行这个 Python 脚本,你将得到以下输出:

{'ba7a7738a34a0e60ef9663c669a7fac406ae9f84441df2b5ade3de1067c41808': ['I am a big boy.', 'I am a tall girl.', 'I am a fast horse.', 'I am a slow dragon.', '']}

如果文件很大,你不会直接对其进行哈希,因为这可能会导致内存耗尽。相反,你会将文件分割。在这里,我们根据换行符分割文本文件。如果处理二进制文件,你会逐块读取文件并将该块保存到一个较小的文件中。当然,在将它们输入 Merkle 树之前,你需要将二进制数据序列化为文本数据。一旦完成了这一步,你就可以将数据片段输入 Merkle 树。你会得到根哈希,它将保护原始文件的完整性。如果在数据片段中更改了一个位,根哈希将会不同。

Merkle DAG 数据结构

我们使用内容寻址来处理文件。如果文件很大,我们可以对其进行分割,并使用 Merkle 树获取根哈希。在这种情况下,我们只关心文件的内容;我们甚至不保存它的名称。

然而,有一种情况,文件名确实很重要。例如,假设您想保存一个包含 100 张可爱熊猫图片的文件目录。在这种情况下,文件的名称并不重要;我们关心的是内容,即可爱熊猫的图片!然而,如果这是一个编程项目的目录,文件的名称就很重要。如果一个 Python 文件尝试导入另一个包含在不同文件中的 Python 库,我们必须保留文件的名称。假设我们有一个名为main.py的 Python 文件,其内容如下:

from secret_algorithm import SuperSecretAlgorithm

# execute it
SuperSecretAlgorithm()

main.py文件依赖于同一目录中的另一个名为secret_algorithm.py的文件。重要的不仅是secret_algorithm.py文件的内容,还有它的名称。如果文件名更改,main.py将无法导入该库。

为了保存内容和文件名,我们需要使用 Merkle DAG 数据结构。如前所述,Merkle DAG 和 Merkle 树之间的一个区别是,Merkle DAG 中的任何节点都可以保存数据,而不仅仅是叶节点,这是 Merkle 树中的情况。

让我们创建一个包含示例文件和一个嵌套目录的示例目录:

$ mkdir sample_directory
$ cd sample_directory
$ // Create some files
$ mkdir inner_directory
$ cd inner_directory
$ // Create some files

然后,创建一个 Python 脚本来解释这种新的数据结构。在项目目录中创建一个名为merkle_dag.py的文件。有关完整代码文件,请参考 GitLab 链接:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_10

让我们讨论MerkleDAGNode类,从其初始化方法开始:

    def __init__(self, filepath : str):
        self.pointers = {}
        self.dirtype = isdir(filepath)
        self.filename = Path(filepath).name
        if not self.dirtype:
            with open(filepath) as f:
                self.content = f.read()
            self.hash = self._hash((self.filename + self.content).encode('utf-8'))
        else:
            self.content = self._iterate_directory_contents(filepath)
            nodes_in_str_array = list(map(lambda x: str(x), self.content))
            if nodes_in_str_array:
                self.hash = self._hash((self.filename + MerkleTree(nodes_in_str_array).root_hash).encode('utf-8'))
            else:
                self.hash = self._hash(self.filename.encode('utf-8'))

_init_()方法接受文件路径作为参数。这可以是文件或目录的路径。我们假设这是一个有效的路径,而不是一个符号链接。self.pointers将在_iterate_directory_contents()方法的部分中解释。self.dirtype用于区分目录或文件。self.filename用于保存文件或目录的名称。

如果参数是文件的路径(而不是目录),我们将内容读入self.content。为了演示目的,我们假设文件的内容很小,我们不尝试像之前那样拆分文件。然后,我们根据文件名和内容计算哈希值。

如果参数是目录的路径,则内容将是该目录内部文件的MerkleDAGNode对象数组。为了计算哈希值,我们使用 Merkle 树来获取其子节点的根哈希。但是,我们需要在再次对其进行哈希之前将其与目录的名称连接起来:

    def _hash(self, data : bytes) -> bytes:
        return sha256(data).hexdigest()

_hash()sha256哈希函数的包装方法。

_iterate_directory_contents()方法用于迭代目录的内部子项。我们将该目录内的每个文件或目录转换为MerkleDAGNode对象。self.pointers对象用于更轻松地根据文件名访问MerkleDAGNode。基本上,这就像一个递归函数,特别是当我们遇到一个目录时:

    def _iterate_directory_contents(self, directory : str):
        nodes = []
        for f in listdir(directory):
            merkle_dag_node = MerkleDAGNode(directory + '/' + f)
            nodes.append(merkle_dag_node)
            self.pointers[f] = merkle_dag_node
        return nodes

_repr_()方法用于更轻松地打印对象以进行调试:

    def __repr__(self):
        return 'MerkleDAGNode: ' + self.hash + ' || ' + self.filename

_eq_()方法是必需的,以便我们可以将MerkleDAGNode对象与其他MerkleDAGNode对象进行比较。这在测试过程中非常有用:

    def __eq__(self, other):
        if isinstance(other, MerkleDAGNode):
            return self.hash == other.hash
        return False

让我们创建一个hash_directory.py文件来演示这种数据结构的强大功能:

from merkle_dag import MerkleDAGNode

outer_directory = 'sample_directory'

node = MerkleDAGNode(outer_directory)
print(node)
print(node.content)

如果执行脚本,您将获得以下结果:

MerkleDAGNode: ec618189b9de0dae250ab5fa0fd9bf1abc158935c66ff8595446f5f9b929e037 || sample_directory
[MerkleDAGNode: 97b97507c37bd205aa15073fb65367b45eb11a975fe78cd548916f5a3da9692a || hello2.txt, MerkleDAGNode: 8ced218a323755a7d4969187449177bb2338658c354c7174e21285b579ae2bca || hello.txt, MerkleDAGNode: c075280aef64223bd38b1bed1017599852180a37baa0eacce28bb92ac5492eb9 || inner_directory, MerkleDAGNode: bc908dfb86941536321338ff8dab1698db0e65f6b967a89bb79f5101d56e1d51 || hello3.txt]

输出是 Merkle DAG 节点的模式。

这就是 Git 保存文件的方式。我们的实现只是为了教育目的,不适合生产目的。在现实世界中,你应该有很多优化。你可以实现的一个优化是使用数据的引用,就像 Git 一样。如果有两个不同的文件具有相同的内容(但文件名不同),则内容只保存一次。另一个优化是 Git 使用压缩。下图说明了 Git 的概念,我们有两个文件文件 B文件 D。这两个文件都有相同的内容内容 xxx文件 B只保存一次在目录 A中。文件 D保存在目录 C中,有一个不同的内容内容 yyy目录 C也保存在目录 A中。但文件 B文件 D的内容内容 xxx只保存一次:

现在我们知道了如何使用 Merkle DAG 保存文件目录,如果我们想要更改文件的内容怎么办?我们应该放弃这个 Merkle DAG 节点并创建一个全新的节点吗?解决这个问题的更有效的方法是使用一个版本控制系统。一个文件可以有版本 1、版本 2、版本 3 等。实现版本控制的最简单方法是使用链表,如下图所示:

点对点网络

我们了解了如何在 IPFS 中保存文件。关键是哈希。值是文件或目录的名称和文件或目录的内容。如果我们正在构建一个集中式系统,我们的故事就结束了。我们只需要添加一些其他东西来创建一个保存文件并根据哈希搜索文件的软件。这个软件类似于数据库,比如 SQLite 或 LevelDB。IPFS 都不是这些;它是一个像数据库一样分散在各处的点对点文件系统。换句话说,它是一个分布式哈希表。

IPFS 使用 S/Kademlia,Kademlia 的扩展版本,作为分布式哈希表。在讨论 Kademlia 之前,让我们讨论它的前身。

首先,想象一个哈希表,就像 Python 中的字典,如下表所示:

2
5独角兽
9大象
11
4犀牛
101蓝鹦鹉
33

在 IPFS 中,关键是哈希,而不是数字。但为了演示目的,让我们把它变成一个简单的整数。值只是动物的简单名称,而不是文件的内容或目录中文件的内容。

现在,想象你有四个节点。一个节点可以是位于不同大陆的计算机。

让我们定义哪个节点持有哪些键:

节点
A2, 9, 11
B5
C4, 33
D101

你把这个表保存在一个中央服务器上。其中一个节点将是中央节点。这意味着如果有人想要访问键五,他们必须在收到答案之前向中央服务器询问,节点 B。之后,请求可以被定向到节点 B。节点 B 会向数据请求者返回“独角兽”。

这种方法非常高效;没有时间被浪费。Napster,点对点音乐共享系统,使用了这种方法。缺点是中央服务器是单点故障。对手(不喜欢这些信息传播的人;在 Napster 的情况下,这可能是大音乐公司)可能会攻击中央服务器。

一个解决方案是询问所有节点关于哪个节点保存了密钥,而不是将这些信息保存在中央节点中。这就是 Gnutella 所做的。这种设置对审查和对手的攻击是有抵抗力的,但对于请求数据的节点和人来说会增加难度。当接收到许多请求时,节点必须努力工作。这种设置称为泛洪。它适用于比特币,但不适用于 IPFS。

这就是分布式哈希表技术被创建的原因。有几种分布式哈希表算法,其中之一是 Kademlia。这个算法是由 Petar Maymounkov 和 David Mazières 于 2002 年创建的。后来被 eDonkey 文件共享平台使用。

数据和节点的接近概念

在分布式哈希表中,我们不会将数据放在每个节点上。我们根据接近的概念将数据放在某些节点上。我们希望将数据放在附近的节点上。这意味着我们不仅在节点之间有距离的概念,而且在数据和节点之间也有距离的概念。

想象一下,在这个分布式哈希表中启动或创建的每个节点都被赋予 1 到 1000 之间的 ID。每个节点 ID 都是唯一的,因此最多可以有 1,000 个节点。在现实世界中可能会有超过 1,000 个节点,但这将作为一个例子。假设我们有 10 个节点:

节点 ID
5
13
45
48
53
60
102
120
160
220

我们还有一些数据。为了简单起见,这种情况下的数据只是一些字符串:

数据
独角兽
飞马

为了能够判断这些数据与某些节点的接近程度或远离程度,我们需要将这些数据转换为 1 到 1000 之间的数字。在现实世界中,您可以对数据进行哈希。但是对于我们的实际演示,我们将只分配一个随机数:

密钥数据
54独角兽
2飞马
100
900
255

如果我们想要将独角兽数据存储在最近的四个节点中(四只是一个配置数字),可以按以下方式进行。首先,您检查密钥,即 54。然后,我们想要获取最接近 54 的四个节点。如果您检查节点 ID 列表,最近的四个节点是 45、48、53 和 60。因此,我们将独角兽数据存储在这四个节点中。如果我们想要存储猫数据,从其密钥 100 的最近邻居是 53、60、102 和 120,因此我们将猫数据存储在这四个节点中。

在计算距离时,我们将数据视为节点。这就是我们在分布式哈希表中查找数据的方式。数据和节点共享相同的空间。

XOR 距离

然而,在 Kademlia 中,我们不是通过十进制减法来测量距离的。为了清楚起见,十进制减法只是普通的减法。45 和 50 之间的距离是 5。53 和 63 之间的距离是 10。

在 Kademlia 中,通过 XOR 距离来测量距离。3 和 6 之间的 XOR 距离是 5,而不是 3。下面是如何计算它的方法:

3 的二进制版本是 011。6 的二进制版本是 110。我所说的二进制版本是指二进制中的数字。XOR 表示“异或”。使用 XOR 运算,1 XOR 0 是 1,1 XOR 1 是 0,0 XOR 0 是 0,0 XOR 1 是 1。如果两个操作数相同,则结果为 0。如果两个操作数不同,则结果为 1。

011
110
---xor
101

101 是 5 的二进制版本。

XOR 距离具有一些有用的特性,促使 Kademlia 论文的作者选择 XOR 距离来衡量节点之间的距离。

第一个特性是节点与自身的 XOR 距离为 0。与 ID 为 5 的节点最接近的节点是另一个 ID 为 5 的节点,或者是它自己。5 的二进制版本是 0101:

0101
0101
----xor
0000

只有当我们测量节点与自身之间的距离时,才可能出现 0 距离。

第二个属性是不同节点之间的距离是对称的。4 和 8 之间的异或距离与 8 和 4 之间的异或距离相同。4 的二进制版本是 0100,8 的二进制版本是 1000。所以,如果我们使用它们的二进制值计算它们之间的距离,我们会得到相同的值。4 和 8 之间的异或距离如下:

0100
1000
----xor
1100

8 和 4 之间的异或距离如下:

1000
0100
----xor
1100

如果你习惯使用十进制减法距离,这对你来说会很直观。

最后一个有用的属性是节点 X 和节点 Z 之间的距离小于或等于节点 X 和节点 Y 之间的距离加上节点 Y 和节点 Z 之间的距离。这个属性很重要,因为 Kademlia 分布式哈希表中的节点不保存所有其他节点的地址。它只保存一些节点的地址。但是一个节点可以通过中间节点到达另一个节点。节点 X 知道节点 Y 的地址,但不知道节点 Z 的地址。节点 Y 知道节点 Z 的地址。节点 X 可以从节点 Y 查询节点 Y 的邻居节点。然后,节点 X 可以到达节点 Z,知道到节点 Z 的距离小于或等于节点 X 和节点 Y 的距离加上节点 Y 和节点 Z 的距离。

如果这个属性不成立,节点 X 搜索节点的时间越长,特定节点之间的距离就越远,这不是我们想要的。但是有了这个属性,其他节点的邻居节点的地址可能比组合距离小,甚至相同。

当你考虑使用异或距离时,你应该想到两个数字共享的前缀越多,这两个数字之间的距离就越短。例如,如果数字共享三个公共前缀,比如五和四,距离就是一:

0101
0100
----xor
0001

同样,对于数字 14 和 15,距离也是 1:

1110
1111
----xor
0001

但是,如果位的差异在左边,比如 5 和 13 的情况,距离可能很大,在这种情况下是 8:

0101
1101
----xor
1000

4 和 5 之间的异或距离是 1,但 5 和 6 之间的异或距离是 3。如果你习惯十进制减法距离,这是违反直觉的。为了更容易解释这个概念,让我们创建一个由 1 到 15 的数字组成的二叉树:

仔细看这棵树。4 和 5 之间的异或距离是 1,但 5 和 6 之间的异或距离是 3。如果你看图片,4 和 5 在一个直接的分支下,而 5 和 6 在一个更大的分支下,这意味着更大的距离。直接的分支对应右边的位。直接分支的父分支对应第二右边的位。顶部分支对应左边的位。所以,如果数字被顶部分支分开,距离至少是 8。8 的二进制版本是 1000。

这只是为了理解目的;这不是严格的数学定义。如果你看从 5 到 11 和 5 到 13 的路径,你应该得到大致相同的距离,但事实并非如此。5 和 13 的异或距离是 8,但 5 和 11 的异或距离是 14。

在 Python 中,你可以使用^运算符对两个数字进行异或运算:

>> 5 ^ 11
14

你可以使用bin函数将任何十进制数转换为它的二进制版本:

>>> bin(5)
'0b101'

然后,如果你想将二进制数转换回十进制数,使用int函数:

>>> int('0b101', 2)
5

int函数的第二个参数表示第一个参数的基数。二进制是基数 2。

现在我们已经了解了异或距离,我们将看一下节点如何保存其他节点的地址。一个节点在分布式哈希表中不保存所有其他节点。一个节点可以保存的节点数量取决于节点中的位数和k配置数字。让我们逐一讨论这些。

记得我们之前看到的树图片吗?它有 16 个叶子。现在想象最小的树:

它有两片叶子。让我们再加倍这棵树:

树现在有四片叶子。让我们再次加倍:

树现在有八片叶子。如果再次加倍,你会得到一棵像我们之前的树一样有 16 片叶子的树。

我们可以看到的进展是 2,4,8,16。如果我们继续这个过程,数字将是 32,64,128,等等。这可以写成 2^(1,) 2^(2,) 2^(3,) 2^(4,) 2^(5 )... 2^n。

让我们专注于一棵有 16 片叶子的树。当我们表示叶子编号时,我们必须使用一个 4 位的二进制数,比如 0001 或者 0101,因为最大的数字是 15,或者 1111。如果我们使用一棵有 64 片叶子的树,我们必须使用一个 6 位的数字,比如 000001,010101,因为最大可能的数字是 63 或者 111111。位数越大,节点在其地址簿中保存的节点数量就越多。

然后,我们有k配置数字。k决定了一个节点在一个桶中可以保存的最大节点数量。桶的数量与分布式哈希表中使用的位数相同。在有 16 片叶子的树中,桶的数量是 4。在有 64 片叶子的树中,桶的数量是 6。每个桶对应一个位。假设我们有一棵有 16 片叶子的树,所以每个数字有 4 位,比如 0101 或者 1100。这意味着节点有四个桶。

第一个桶对应于从左边开始的第一个位。第二个桶对应于从左边开始的第二个位。第三个桶对应于从左边开始的第三个位。第四个桶对应于从左边开始的第四个位。

让我们看一个在有 16 片叶子的树中 ID 为 3 的节点的例子。现在,我们假设在有 16 片叶子的树中有 16 个节点。在现实世界中,树会是稀疏的,很多分支会是空的。

在描述 Kademlia 的论文中,作者使用了 160 个桶或者 160 位地址。这棵树中的叶子数量是巨大的。作为对比,2⁷⁸是可见宇宙中的原子数量。在这篇论文中,k配置数字选择为 20,因此一个节点的地址簿中最多可以有 3,200 个节点。

在这个例子中,假设k数字是 2。这意味着对于每个桶,节点保存另外两个节点。第一个桶对应于第一个位,对应于树的另一半,节点不驻留在这里。我们在树的这一半有八个节点,但是我们只能保存其中的两个,因为k数字是 2。让我们选择节点 11 和 14 放入这个桶。如何选择哪些节点放入哪些桶将在后面描述。

然后,让我们把包含节点的一半的树分成两个分支。第一个分支包括 ID 为 0 的节点,ID 为 1 的节点,ID 为 2 的节点和 ID 为 3 的节点。第二个分支包括 ID 为 4 的节点,ID 为 5 的节点,ID 为 6 的节点和 ID 为 7 的节点。第二个分支是第二个桶。这个分支有四个节点,但我们只能保存两个节点。让我们选择 ID 为 4 的节点和 ID 为 5 的节点。

然后,让我们把包含我们的节点(ID 为 3 的节点)的分支分成两个小分支。第一个小分支包括 ID 为 0 的节点和 ID 为 1 的节点。第二个小分支包括 ID 为 2 的节点和 ID 为 3 的节点。所以第三个桶是第一个小分支。这里只有两个节点,ID 为 0 的节点和 ID 为 1 的节点,所以我们都保存。

最后,让我们把包含我们的节点(ID 为 3 的节点)的小分支分成两个小分支。第一个小分支包括 ID 为 2 的节点,第二个小分支包括 ID 为 3 的节点。第四个桶,或者最后一个桶,将是包含节点 3 的分支。

我们只保存了一个节点,因为它小于k配置数字:

以下图表显示了完整的四个桶。每个桶都是源节点不在的分支的一半。不同节点的桶配置是不同的。ID 为 11 的节点可能有以下桶配置:

让我们看一个例子,说明一个特定节点如何找到不在其地址簿中的另一个节点。假设k配置号为 1。源节点是树中具有 16 个叶子的 ID 为 3 的节点。对于第一个桶(由 ID 为 8 到 ID 为 15 的节点组成的最大分支),ID 为 3 的节点保存了 ID 为 10 的节点。但是 ID 为 3 的节点想要找到 ID 为 13 的节点。ID 为 3 的节点向 ID 为 10 的节点发送请求:“你能帮我找到 ID 为 13 的节点吗?”ID 为 10 的节点在其相应的桶中保存了 ID 为 14 的节点(由 ID 为 12、13、14 和 15 的节点组成的分支)。ID 为 10 的节点将 ID 为 14 的节点交给 ID 为 3 的节点。ID 为 3 的节点向 ID 为 14 的节点提出同样的问题:“你能帮我找到 ID 为 13 的节点吗?”ID 为 14 的节点没有,但它在其桶中保存了 ID 为 12 的节点(由 ID 为 12 和 ID 为 13 的节点组成的分支)。ID 为 14 的节点将 ID 为 12 的节点交给 ID 为 3 的节点。ID 为 3 的节点再次向 ID 为 12 的节点提出同样的问题。这次,ID 为 12 的节点可以将目标节点或 ID 为 13 的节点交给 ID 为 3 的节点。大团圆!

以下图表显示了节点:

你注意到 ID 为 3 的节点必须重复请求多少次了吗?四次。如果这个数字听起来很熟悉,那是因为这棵树有 16 个叶子,即 2^(4.)在计算机科学中,到达目的地所需的跳数的最坏情况是 2 log n + cn是树有多少叶子,c是常数。

你刚刚看到的树有满的节点;没有空叶子或空分支。然而,在现实世界中,有空分支和空叶子。想象一下,你有一棵具有 1,024(2¹⁰)个叶子的树,k号为 3。你启动 ID 为 0 的第一个节点。这个节点将是源节点。我们将从 ID 为 0 的节点的角度看树:

然后,启动 ID 为 800 的节点:

树将被分成两个桶。然后,启动 ID 为 900 的节点和 ID 为 754 的节点:

如果我们向桶中添加另一个节点会怎样?让我们启动 ID 为 1011 的节点。ID 为 0 的节点将 ping 最近未使用的节点,即 ID 为 800 的节点,以查看其是否仍然存活。如果是,它将检查其他节点。如果 ID 为 754 的节点不存活,那么此节点将被 ID 为 1011 的节点替换。如果所有节点仍然存活,则 ID 为 1011 的节点将被拒绝进入桶。这样做的原因是为了避免新节点淹没系统。我们假设运行时间更长的节点是值得信赖的,我们更喜欢这些节点而不是新节点。假设我们拒绝 ID 为 1011 的节点。

首先,我们启动 ID 为 490 的节点。然后,我们分割 ID 为 0 的节点所在的分支:

现在,让我们添加 ID 为 230 的节点:

让我们添加 ID 为 60 的节点:

...等等。每次我们在源节点所在的分支中添加一个节点,它都会将桶分成两部分,直到达到最低级别。如果我们在源节点不在的其他分支中添加一个节点,我们会一直添加节点,直到达到k号。

现在,您已经基本了解了 Kademlia 的工作原理。然而,这还不是全部。如果插入一个节点,节点需要告诉旧节点它的存在。该节点还需要从旧节点获取联系人。我提到,当节点插入到源节点所在的分支时,分支会分裂,但即使源节点不驻留在那里,也有分支分裂的情况。这是因为要求节点在至少有k个节点的分支中保留所有有效节点,即使这意味着源节点不驻留的分支也必须分裂。

除了路由算法之外,Kademlia 还有其他重要方面。节点需要每小时重新发布密钥和值(数据),以预期旧节点离开和新节点加入系统。这些节点更接近,因此更适合保存数据。还有一种加速查找算法,这样当一个节点正在寻找另一个节点时,我们可以使用更少的步骤。

您可以参考 Kademlia 论文获取完整的规范。pdos.csail.mit.edu/~petar/papers/maymounkov-kademlia-lncs.pdf

IPFS 使用 S/Kademlia,这是 Kademlia 的扩展版本。它与原始的 Kademlia 算法不同之处在于 S/Kademlia 有一些安全要求。并非所有节点加入 Kademlia 分布式哈希表都是出于崇高的目的。因此,在 S/Kademlia 中,为了生成节点的 ID,需要节点生成一个密码学密钥对,这样很难篡改节点之间的通信。其他要求包括在节点能够生成其 ID 之前使用工作证明(就像比特币和以太坊中一样)。还有一些路由算法的调整,以确保节点在对手中间能够与其他节点通信,比如那些在网络中发送垃圾信息的节点。

总结

在本章中,我们学习了 IPFS。我们首先研究了 IPFS 项目的动机和历史。虽然 IPFS 不是区块链技术的一部分,但它类似于区块链,因为它是区块链技术的补充。然后,我们了解了 IPFS 文件系统中保存的内容的数据结构。这个数据结构是基于 Merkle 树的 Merkle 有向无环图(DAG)。我们创建了简单的 Merkle 树和 Merkle DAG 库,以了解这些数据结构的独特性。Merkle 树提供了一种简单的方法来检查部分数据的完整性,而 Merkle DAG 在我们想要保存一个带有文件的目录并且想要保留文件名时使用。然后,我们了解了 Kademlia 分布式哈希表的点对点网络方面。节点之间的距离是基于 XOR 距离。节点也被保存在对应于位寻址的桶中。最后,我们展示了节点如何通过跳转桶来找到其他节点。

在下一章中,我们将使用 IPFS 软件并以编程方式与其交互。