创建区块链应用意味着构建能够读写分布式账本的软件,通常通过智能合约加上一个通过 RPC 与节点通信的链下客户端来实现。本指南先梳理编写代码前的重要决策,然后提供一个可在任何 EVM 兼容网络上复现的可运行合约、部署脚本和客户端集成。它将有文档记录的协议行为、工程建议以及你必须自行运行的测量区分开来,并展示如何验证每一步,而不是假设成功。最终,你将拥有一个经过测试的合约、一个可用的 RPC 客户端,以及一份针对最常阻碍首次部署的故障的排查清单。
创建区块链应用实际涉及什么
区块链应用不是单一的产物。它至少由三个协同部分组成:一个链上程序(智能合约或原生模块),负责执行规则并存储状态;一个链下客户端,负责构建和签名交易;以及一个接受这些交易并提供读取服务的节点端点。账本提供排序和最终性;你的应用则围绕它提供业务逻辑和用户体验。
这种区分很重要,因为大多数初学者的失败并非合约漏洞,而是集成失败:客户端指向了错误的链 ID、在 nonce 推进之前发送了交易、读取由滞后的节点提供,或者从失败的部署中记录了合约地址。将链上和链下两半视为具有独立测试的独立系统。
范围说明:本文针对 EVM 兼容网络,因为其工具链统一且可复现。相同的思维模型适用于非 EVM 链,但合约语言、ABI 和客户端库有所不同。关于特定网络的端点选择,请使用 RPC Assistant,而不是在此硬编码提供商。
- 链上:合约代码、存储布局、事件、访问控制。
- 链下:签名、交易生命周期、重试、索引、UI。
- 传输:通过 HTTPS 或 WebSocket 向一个你信任其正确性和可用性的节点发送 JSON-RPC 请求。
判断用例是否真的需要区块链
在选择协议之前,用只有共享账本才能提供的特性来检验需求:多个独立参与方、对防篡改审计追踪的需求,以及应在没有单一可信运营者的情况下执行的规则。如果一个组织控制所有写入者和读取者,那么带有仅追加审计日志的传统数据库通常更便宜、更快速且更易于运营。
一个有用的筛选方法是问:谁必须能够在不需要信任你的情况下验证历史?如果答案是“我们团队之外的没有人”,那么区块链只会增加成本而不会增加信任。如果答案是“不信任我们服务器的交易对手、审计员或用户”,那么账本就有其价值。把这个答案写下来;它决定了后续关于公有部署还是许可部署以及多少数据应上链的每一个决策。
将大型负载保持在链下。在链上存储哈希、标识符和状态转换,并将文档、图像和个人数据保存在通过哈希引用的传统存储中。这降低了 gas 成本,并避免将受监管数据放在不可变的介质上。
选择网络、账户模型和端点策略
网络选择是在最终性时间、费用市场、工具成熟度以及谁可以读取你的状态之间的权衡。公共 EVM 网络共享工具链,因此为一个网络编写的合约通常只需更改配置即可移植。许可部署以开放性换取隐私和受控成员资格。在编写代码之前,决定你的用例筛选器要求哪一种。
端点策略是运营决策,而不是一行代码。单个公共端点对于教程来说没问题,但对于用户依赖的任何东西都不可接受。计划至少一个主端点和一个回退端点、一个健康检查,以及一个明确的规则来说明哪些读取可以容忍稍微过时的数据。多链 RPC 端点指南涵盖了故障转移模式,而RPC 定价有助于在你了解读写比例后估算请求量。
对于需要可预测吞吐量和隔离的生产工作负载,专用节点可以消除共享端点可能出现的嘈杂邻居效应。对于首次构建,托管端点就足够了;重要的是将 URL 保留在配置中,以便你可以在不触及应用程序代码的情况下进行更换。
- 记录你接触的每个网络的链 ID、原生货币和区块浏览器。
- 将 RPC URL 保存在环境变量中,绝不要放在已提交的源代码中。
- 在选择单个节点还是负载均衡池之前,确定读取一致性需求。
先决条件和可复现的本地设置
你需要 Node.js 18 或更高版本、一个包管理器以及一个已注资的测试网账户。以下步骤使用 Hardhat,因为它将编译、测试和部署脚本捆绑在一个工具链中。安装到一个空目录中,以便版本由生成的锁文件固定。
在开发过程中切勿使用主网私钥。为测试网生成一个新密钥,将其存储在排除在版本控制之外的 .env 文件中,并将任何粘贴到聊天或日志中的密钥视为已泄露。
mkdir chain-app && cd chain-app
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox dotenv
npx hardhat init
# choose: Create a JavaScript project
printf 'RPC_URL=\nPRIVATE_KEY=\n' > .env
echo '.env' >> .gitignore编写具有显式状态模型的最小合约
从一个足够小、可以完全推理的合约开始。下面的示例为每个发送者存储一个值,并在每次写入时发出一个事件。事件不是装饰:链下系统应该从事件重建状态,而不是轮询存储,因为事件查询成本更低,并且为你提供有序的历史记录。
注意访问规则。任何地址都可以写入自己的槽位,但没有地址可以写入他人的槽位。在这种规模下明确授权可以防止最常见的合约漏洞类别,即无保护的状态转换。
// contracts/Registry.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Registry {
mapping(address => string) private values;
event ValueSet(address indexed author, string value);
function setValue(string calldata value) external {
require(bytes(value).length > 0, "empty value");
values[msg.sender] = value;
emit ValueSet(msg.sender, value);
}
function getValue(address author) external view returns (string memory) {
return values[author];
}
}编译、测试和部署并包含验证步骤
至少编写一个测试,同时断言正常路径和拒绝路径。只检查正常路径的测试会在你的访问控制损坏时通过。在每次部署前运行测试套件;失败的测试比失败的交易成本更低。
下面的部署脚本从环境读取配置,进行部署,等待收据,并打印地址。等待收据就是验证步骤:在确认之前打印的地址可能属于一笔永远不会落地的交易。
// scripts/deploy.js
const hre = require("hardhat");
async function main() {
const [deployer] = await hre.ethers.getSigners();
console.log("deployer:", deployer.address);
const Registry = await hre.ethers.getContractFactory("Registry");
const registry = await Registry.deploy();
await registry.waitForDeployment();
const address = await registry.getAddress();
console.log("Registry deployed at:", address);
}
main().catch((e) => { console.error(e); process.exit(1); });
// hardhat.config.js excerpt
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: "0.8.24",
networks: {
target: {
url: process.env.RPC_URL,
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : []
}
}
};通过 JSON-RPC 连接链下客户端
客户端是大多数集成错误暴露的地方。下面的脚本读取当前区块号,调用一个视图函数,并发送一个改变状态的交易,同时检查收据状态。针对你部署的地址运行它,并确认每个打印的值与浏览器显示的一致。
收据状态字段是权威的成功信号。交易哈希存在并不意味着交易成功;回滚的交易仍然会被打包并仍然消耗 gas。在更新你自己的数据库之前,始终根据状态进行分支。
// scripts/client.js
const hre = require("hardhat");
async function main() {
const address = process.env.CONTRACT_ADDRESS;
const registry = await hre.ethers.getContractAt("Registry", address);
const block = await hre.ethers.provider.getBlockNumber();
console.log("block:", block);
const tx = await registry.setValue("hello-ledger");
const receipt = await tx.wait();
console.log("status:", receipt.status, "gasUsed:", receipt.gasUsed.toString());
const value = await registry.getValue(await tx.getSigner().getAddress());
console.log("stored:", value);
}
main().catch((e) => { console.error(e); process.exit(1); });自行运行的测量程序
不要信任针对你工作负载的已发布延迟或成功率数字;要测量它们。下面的程序生成一个结果表,你可以跨端点和跨时间段进行比较。对每个端点至少运行 100 次,并记录分布,而不仅仅是平均值,因为用户注意到的是尾部延迟。
为每个端点记录以下列,并重复运行三次:请求数、成功数、p50 延迟、p95 延迟以及观察到的任何错误代码。用你自己的测量值填写表格;下面的单元格故意留空,因为捏造的数字会误导你的容量规划。
- | 端点 | 请求数 | 成功数 | p50 (ms) | p95 (ms) | 错误 |
- | 主端点 | | | | | |
- | 回退端点 | | | | | |
// scripts/measure.js — run with: node scripts/measure.js
const url = process.env.RPC_URL;
const N = 100;
async function once() {
const t0 = performance.now();
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_blockNumber", params: [] })
});
const body = await res.json();
return { ms: performance.now() - t0, ok: res.ok && !body.error };
}
(async () => {
const samples = [];
for (let i = 0; i < N; i++) samples.push(await once());
const ok = samples.filter(s => s.ok).length;
const sorted = samples.map(s => s.ms).sort((a, b) => a - b);
const p = q => sorted[Math.floor(q * (sorted.length - 1))].toFixed(1);
console.log({ requests: N, successes: ok, p50: p(0.5), p95: p(0.95) });
})();预期的验证结果及其各自证明什么
一次成功的运行证明的不仅仅是“它工作了”。分别检查每个信号,以便失败时能指向正确的层。如果区块号打印出来但合约调用回滚,那么你的传输没问题,问题在合约层面。如果部署打印了一个地址但客户端在那里找不到代码,你可能在错误的网络上,或者部署回滚了。
通过查询 eth_getCode 确认部署的地址有字节码;空结果意味着在该链上的该地址没有部署任何东西。确认交易收据状态为 1。确认读回的值与你写入的值匹配。确认浏览器显示与你本地看到的相同的区块和交易。
- 两次调用之间区块号推进:传输和链 ID 正确。
- eth_getCode 返回非空:该链上该地址存在合约。
- 收据状态为 1:状态转换执行时没有回滚。
- 读回值与写入匹配:存储和 ABI 编码一致。
- 浏览器与本地输出一致:你不在分叉或滞后的节点上。
排查阻碍首次部署的故障
Nonce 和替换错误通常意味着前一笔交易仍在待处理。查询待处理的 nonce,然后要么等待,要么用更高的费用替换交易。不要盲目重发,因为具有相同 nonce 的重复提交可能会搁置后续交易。
测试网上的资金不足错误通常是水龙头或链 ID 不匹配,而不是真正的余额问题。验证你的客户端报告的链 ID 与你注资的网络匹配。如果读取返回过时数据,你可能遇到了负载均衡池中某个后端滞后的情况;将读取固定到单个节点或添加区块号新鲜度检查。
Gas 估算失败通常来自执行期间会发生的回滚。首先用 eth_call 调用函数以在发送交易之前暴露回滚原因。如需更深入的端点级诊断,请参阅RPC 端点指南和区块链节点托管指南。
- nonce 太低:前一笔交易已经消耗了它;从节点刷新。
- 替换费用过低:将费用提升到高于节点的最低值。
- 执行回滚:用 eth_call 复现以读取原因字符串。
- 读取过时:将服务节点的最新区块与第二个端点进行对比。
局限性、权衡以及何时停止构建
链上执行昂贵且公开。每次状态写入都会在整个网络中复制,因此成本会以数据库不会的方式随存储和计算而扩展。设计时应尽量减少链上状态,并尽可能将聚合推到链下。
不可变性是一把双刃剑。已部署合约中的错误可能在没有迁移的情况下无法修复,因此在启动前规划升级路径或版本化合约。公共网络还会将你的逻辑暴露给能够读取和模拟它的对手;假设你的合约会被探测。
最后,区块链应用会继承其最薄弱依赖项的可靠性。如果你的端点宕机,即使链是健康的,你的应用也会宕机。为冗余端点做预算,并像监控自己的服务器一样监控它们。
从原型到生产的后续步骤
一旦本地流程得到验证,就加固它:针对分叉网络添加集成测试,添加带有健康检查的回退端点,并让你的客户端在记录请求 ID 的同时记录区块号,以便事件可诊断。在指向新环境之前,查看网络目录以确认链参数。
对于端点选择和提供商比较,请使用 RPC Assistant,而不是在代码库中嵌入提供商选择。API 服务概述描述了托管访问如何融入应用程序,而Web3 项目 RPC 指南涵盖了评估标准。发布满足你用例筛选器的最小合约,测量它,并且只有在测量结果证明合理时才扩展。