一、为何需要 Dapp
上一节我们讲了如何调用已部署在以太坊链上的合约。

通过 Etherscan 这个网站不仅可以查看合约代码,还可以调用合约的读取和写入方法。可见,调用一个合约是非常简单的。

但是对于普通用户来说,这种调用方式就有点耍流氓了。大家平时上网发个微博,也没说要调用一个JSON REST API。如果发微博必须调用 API,那微博用户肯定个个都是开发高手,根本没空撕来撕去。
所以,要求普通用户自己去 Etherscan 调用合约不现实。本节我们就来介绍如何开发一个 Dapp,让用户通过页面,点击按钮来调用合约。
二、Dapp 架构
一个 Dapp 的架构实际上包含以下部分:

以太坊的区块链网络实际上是一个由若干节点构成的 P2P 网络,所谓读写合约,实际上是向网络中的某个节点发送 JSON-RPC 请求。当我们想要做一个基于 Vote 合约的 Dapp 时,我们需要开发一个页面,并连接到浏览器的 MetaMask 钱包,这样,页面的 JavaScript 就可以通过 MetaMask 读写 Vote 合约,页面效果如下:

可以访问 https://michaelliao.github.io/vote-dapp/ 查看页面并与合约交互。
编写 Dapp 的页面可以按以下步骤进行:
第一步,引入相关库,这里我们引入 ethers.js 这个库,它封装了读写合约的逻辑。在页面中用 script 引入如下:
https://cdn.jsdelivr.net/npm/ethers@5.0.32/dist/ethers.umd.min.js
第二步,我们需要获取 MetaMask 注入的 Web3,可以通过一个简单的函数实现:
function getWeb3Provider() {
if (!window.web3Provider) {
if (!window.ethereum) {
console.error("there is no web3 provider.");
return null;
}
window.web3Provider = new ethers.providers.Web3Provider(window.ethereum, "any");
}
return window.web3Provider;
}
第三步,在用户点击页面“Connect Wallet”按钮时,尝试连接 MetaMask:
async function () {
if (window.getWeb3Provider() === null) {
console.error('there is no web3 provider.');
return false;
}
try {
// 获取当前连接的账户地址:
let account = await window.ethereum.request({
method: 'eth_requestAccounts',
});
// 获取当前连接的链ID:
let chainId = await window.ethereum.request({
method: 'eth_chainId'
});
console.log('wallet connected.');
return true;
} catch (e) {
console.error('could not get a wallet connection.', e);
return false;
}
}
最后一步,当我们已经连接到 MetaMask 钱包后,即可写入合约。写入合约需要合约的 ABI(Application Binary Interface)信息,即合约函数调用的接口信息,这些信息在 Remix 部署时产生。我们需要回到 Remix,在 contracts-artifacts 目录下找到 Vote.json 文件,它是一个JSON,右侧找到"abi": [...],把 abi 对应的部分复制出来:

以常量的形式引入 Vote 合约的地址和 ABI:
const VOTE_ADDR = '0x5b2a057e1db47463695b4629114cbdae99235a46';
const VOTE_ABI = [{ "inputs": [{ "internalType": "uint256", "name": "_endTime", "type": "uint256" }], ...
现在,我们就可以在页面调用 vote() 写入函数了:
async function vote(proposal) {
// TODO: 检查MetaMask连接信息
// 根据地址和ABI创建一个Contract对象:
let contract = new ethers.Contract(VOTE_ADDR, VOTE_ABI, window.getWeb3Provider().getSigner());
// 调用vote()函数,并返回一个tx对象:
let tx = await contract.vote(proposal);
// 等待tx落块,并至少1个区块确认:
await tx.wait(1);
}
以上就是调用合约的全部流程。
我们需要明确几个要点:
页面的 JavaScript 代码无法直接访问以太坊网络的 P2P 节点,只能间接通过 MetaMask 钱包访问;
钱包之所以能访问以太坊网络的节点,是因为它们内置了某些公共节点的域名信息;
如果用户的浏览器没有安装 MetaMask 钱包,则页面无法通过钱包读取合约或写入合约。
因此引出了第二个问题:一个 Dapp 到底需不需要服务器端?
对于大多数的 Dapp 来说,是需要服务器端的,这是因为,当用户浏览器没有安装钱包,或者钱包并没有连接到 Dapp 期待的网络时,页面将无法获得合约的任何数据。例如,上述 Dapp 就无法读取到三种投票的数量,因此无法在页面上绘制对比图。
如果部署一个服务器端,由服务器连接 P2P 网络的节点并读取合约,然后以 JSON API 的形式给前端提供相关数据,则可以实现一个更完善的 Dapp。因此,完整的 Dapp 架构如下:

为 Dapp 搭建后端服务器时要严格遵循以下规范:
后端服务器只读取合约,不存储任何私钥,因此无法写入合约,保证了安全性;
后端服务器要读取合约,就必须连接到 P2P 节点,要么选择公共的节点服务(例如 Infura),要么自己搭建一个以太坊节点(维护的工作量较大);
后端服务器应该通过合约产生的日志(即合约运行时触发的 event)监听合约的状态变化,而不是定期扫描。监听日志需要通过 P2P 节点创建 Filter 并获取 Filter 返回的日志;
后端服务器应该将从日志获取的数据做聚合、缓存,以便前端页面能快速展示相关数据。
因此,设计 Dapp 时,既要考虑将关键业务逻辑写入合约,又要考虑日志输出有足够的信息让后端服务器能聚合历史数据。前端、后端和合约三方开发必须紧密配合。
不同的编程语言实现后端服务时,可以选择封装好的第三方库,例如 Java 可以使用 Web3j,Python 可以使用 Web3.py,详细的说明可以参考官方文档。
三、Graph
还有一种托管的后端服务:The Graph。它本身也可看作是一个基础设置。The Graph 可以让我们部署一个 Graph 查询服务,如何定义表结构以及如何更新则由我们提供一个预编译的 WASM。整个配置、WASM 代码以及查询服务都托管在 The Graph 中,无需自己搭建服务器,非常方便。
因此,使用 Graph 的一个完整的 DApp 架构如下:

我们在 Vote 这个 Dapp 中并未提供后端服务,请自行实现。
四、小结
一个 Dapp 应用通常由前端、后端和部署的合约三部分构成。
不懂前端的后端不是一个合格的合约开发者。
区块链 第11.5章 编写Dapp