简介:面向区块链与分布式存储初学者,这份资源围绕健康记录跟踪场景,演示以太坊智能合约与IPFS集成的基础链路,涵盖Truffle与Ganache环境配置、MetaMask和MyEtherWallet调用流程,并让医生通过合约检索健康记录IPFS ID、患者下载对应ID,最终形成最小可运行的去中心化存储示例。压缩包共30个文件,其中16张PNG操作截图可查看界面配置与部署结果,4个JS脚本和3个SOL合约构成Truffle项目核心逻辑,3个JSON文件用于合约构建与配置,整体约1.15MB,便于快速下载与本地实验。资源也明确指出当前实现缺少记录内容加密与IPFS CID加密,且尚未形成统一API,这适合初学者先理解区块链与IPFS的基础集成,再进一步思考权限管理、数据加密和生产级API设计。已有176人学习,适合希望用较小工程快速掌握以太坊与IPFS联动、并有意深入去中心化医疗数据存储的开发者。
1. health-blockchain 到底在集成什么:一条「文件到链上存证」的完整通路
health-blockchain 这个仓库标题听起来像医疗项目,实际它只做了一件事:把区块链和 IPFS 集成的基础知识讲清楚——健康档案这类原始文件放进 IPFS 拿回一个 CID,再把 CID 写到以太坊合约里做存证,MetaMask 和 MyEtherWallet 分别扮演签名入口。反直觉的点在于:链上根本没有病历,只有一串几十字节的文件指纹;病历体量再大,上链成本也不变。适合读这篇的人有两类:一是想在一两天内跑通「文件上链存证」最小链路的开发,二是医疗 IT 团队想评估区块链技术能不能给电子病历加一层可信归档。如果你只是想看概念科普,那这方案对你来说偏重;如果你要动手复现,把这套链路走一遍,比读十篇架构文章都管用。
2. 为什么是「IPFS 存文件、以太坊存哈希」:架构与四个组件的分工
2.1 以太坊不适合存原始文件:存储成本与隐私边界
以太坊上任何一笔存证,最终数据都会复制到每个全节点的状态里。合约里写入一个 storage 变量,代价是按 gas 计的,而且这是永久占用——只要链还在,这笔数据就跟着每个节点一起活着。主网上这么大的存储成本,注定不能把体检报告 PDF、影像 DICOM、门诊记录这类原始文件直接塞进去。存证类业务真正关心的是「某人在某个时间登记过某份文件」,原始文件内容谁也没必要让全链复制一遍。
所以这类集成方案的基本盘是:文件本体放 IPFS,链上只放文件内容的哈希指纹。IPFS 的 CID 本质上就是内容寻址的哈希结果,同一个文件在同一个参数下会产生同一个 CID;把 CID 写进合约,等于给这份文件盖了一个时间戳印章。之后任何人拿到文件,重新计算 CID,再和链上记录的 CID 比对,就能证明文件从登记那一刻起有没有被改动过。
隐私边界也不能回避。公开链上地址、CID、时间戳都是可查的,医学数据本身敏感,直接裸存会暴露「某地址在某时间存过某 CID」的关联关系。常见的做法是把文件先加密再进 IPFS,敏感字段不出客户端,链上只剩密文指纹。公开链适合做「这个方案能不能跑通」的验证,生产环境更常改成联盟链或私有链。这个取舍不是技术能力问题,是监管和合规问题。
2.2 四个组件的职责与出现位置
这套集成方案里,以太坊、IPFS、MetaMask、MyEtherWallet 四者的关系很容易被搞混。新手常见误区是觉得 MetaMask 是「链」,MyEtherWallet 是「另一个链」,其实它们都是签名入口,链和存储是另外两层。
| 组件 | 在方案里的职责 | 出现的位置 | 你需要掌握的最小知识点 |
|---|---|---|---|
| 以太坊网络 | 提供不可篡改的账本和合约执行环境 | 存证的最终落点 | 交易、gas、合约调用 |
| IPFS | 按内容寻址存储原始文件 | 文件存放层,负责产出 CID | ipfs add、ipfs pin、CID 概念 |
| MetaMask | 浏览器里的密钥容器与签名器 | 用户在 DApp 里发起存证时用它签名 | 网络配置、账户导入、私钥边界 |
| MyEtherWallet | 另一套签名入口,支持离线冷签 | 不装插件或需要离线操作时的备选 | 自定义网络、私钥/助记词登录 |
集成链路里还有一层容易被忽视:CID 是两个世界的桥。IPFS 世界不关心你是谁,以太坊世界不关心文件长什么样;只有 CID 这个字符串同时活在两端。文件进了 IPFS,返回的 CID 走合约的saveRecord上链,之后前端查询时拿同一个 CID 去合约里读记录,再拿回 IPFS 验证文件内容——这三段各自独立,又通过同一个字符串咬合。
2.3 两种钱包入口:同一个身份,不是两个身份
我要特别强调 MetaMask 和 MyEtherWallet 的关系,因为这是标题里同时出现两个钱包的原因。MetaMask 是浏览器扩展,私钥存在浏览器加密存储里,页面里的 DApp 通过eth_requestAccounts拿到地址、请求用户签名;MyEtherWallet 是网页形式的钱包入口,可以选择连 MetaMask、助记词、私钥文件或硬件钱包。两者底层操作的是同一套以太坊账户体系,同一个地址在两边出现,只是签名环境不同。
理解这一点对后面的实操很有用。你完全可以用 MetaMask 建好账号,然后用 MyEtherWallet 导入同一组助记词,连到同一个 RPC 节点,看到同一个地址、同一个合约记录。工具可以换,链上身份不变。很多人第一次跑这套方案时在这上面绕弯子,总觉得一个项目里出现两个钱包工具是重复建设,其实它们解决的是「浏览器环境下便捷签名」和「非浏览器/离线环境下可用签名」两个不同诉求。
3. 跑通最小链路:IPFS 初始化、MetaMask 建号与本地网络
3.1 在本地跑起 IPFS:三条命令与 CID 的来由
IPFS 的安装方式不复杂,官方分发版本解压后直接能跑。你不需要先理解 Merkle DAG 才能用它,但至少要明白:文件进 IPFS 会被切成块,按块构建一棵默克尔树,根节点的哈希就是 CID。所以ipfs add不是「上传」到某个服务器,而是把内容注册进你本地节点的内容寻址空间。
# 第一次运行先初始化节点配置,生成 peer id 与默认密钥 ipfs init # 启动守护进程,让节点开始处理内容寻址请求 ipfs daemon & # 把文件加入节点,返回 CID ipfs add --cid-version=1 health-record.pdfipfs init只做一次,它会生成节点仓库的密钥对、配置文件和数据目录。ipfs daemon必须在一个独立终端里长驻,5001 是 API 端口,8080 是本地网关端口。ipfs add --cid-version=1返回的 CID 以b开头,这是新版 CIDv1 的格式;如果你不加参数,很多版本默认输出 CIDv0,以Qm开头,能把同一个内容表示为两种不同字符串,这在存证场景最容易造成困惑:链上记了Qm...,你手头拿的是b...,以为数据丢了。
验证文件确实在本地节点里,用这组命令:
# 从 CID 读回文件内容,输出到终端或重定向到文件 ipfs cat <cid> # 查看某个 CID 在本地节点的块信息和文件大小 ipfs files stat /ipfs/<cid>ipfs cat能读出来,说明内容在本地块存储里可访问。存证类项目还有一个必须养成的习惯:ipfs pin add <cid>。pin 的意思是「这个内容我保留,不要被垃圾回收清掉」,否则节点内存不足时可能把没有 pin 的块清掉,链上 CID 还在,IPFS 里却读不出来了。
3.2 用 MetaMask 配置本地开发网:建号、切换网络与私钥边界
浏览器里装 MetaMask 时认准官方渠道,从浏览器商店安装,官方版本会有「MetaMask」官方标识,第三方打包版不要碰。装好后第一次打开会让你创建钱包,助记词备份这一步别跳过,也别截图存网盘。这个账号就是后面所有存证交易的签名主体。
但 MetaMask 默认连的是以太坊主网,我不能也不想让你在主网上烧 gas 调合约。常见做法是先用 Ganache 起一条本地开发链,它开箱即用,默认 RPC 端口是http://127.0.0.1:8545,chain id 是1337。如果你用的是 Hardhat 的本地节点,chain id 默认是31337。两条链都能跑通这套流程,区别只在配置数值。
MetaMask 里的操作路径:右上角账户菜单进入「Settings」-「Networks」-「Add Network」,手动填四项:
| 配置项 | 本地开发链(Ganache 示例) | 说明 |
|---|---|---|
| Network Name | Localhost 8545 | 自定义名称,仅本地显示 |
| RPC URL | http://127.0.0.1:8545 | 必须和节点监听端口一致 |
| Chain ID | 1337 | 填错会导致交易进入错误链 |
| Currency Symbol | ETH | 本地链的测试币符号 |
chain id 这个字段值得多说两句,它是链的身份标识。如果 MetaMask 里填的是 1(主网),而你的本地节点是 1337,那么你表面上「连上了本地节点」,实际交易会尝试发往主网并失败,或者干脆在错误的链上广播。排查这类问题,先在终端里对本地节点发一个eth_chainId请求确认值,再回头看 MetaMask 配置。
用 MyEtherWallet 连同一个本地节点也很直接:打开 MEW,选择「网络」下拉里的自定义 RPC,填入同一个 URL 和 chain id,再用助记词或私钥登录。你会看到这个地址和 MetaMask 里是同一个——这是确认「钱包只是入口」最快的方法。顺便说一句,这一步不需要把两个钱包同时打开,用 MEW 连上本地链,能读到同一个地址就是成功。
3.3 测试币不够怎么办:从节点账户转一笔过来
本地链上的测试币不是用来「买」的,而是用来付 gas 的。Ganache 启动时会给十个默认账户各转 100 个测试 ETH,私钥会在控制台打出来。MetaMask 要拿到测试币,最省事的方式是导入其中一个测试私钥,而不是新创建一个空账户再去转账。
# Ganache 启动时控制台会打印一组带私钥的账户 # 在 MetaMask 里用 Import Account 导入其中一个私钥导入后你就拥有一个有余额的地址,合约部署和存证交易都能直接签名。这里有个安全边界要念叨一遍:本地开发链的私钥只属于本地开发环境,绝不要用来连接任何有真实资产的环境;也不要把测试私钥写死在 DApp 的源代码里提交到仓库,否则扫描工具第一时间报警。
4. 存证合约与部署脚本:把 CID 写进以太坊的完整步骤
4.1 一个够用的存证合约:字段、映射与事件
先写一个最小但完整的存证合约。它的核心职责只有三件:存 CID、按 CID 查记录、按地址查该地址名下存过的所有 CID。为演示「区块链和 IPFS 集成的基础知识」,这个规模正合适,再多就是权限和业务字段了。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract HealthRecordStorage { // 每条存证记录:文件指纹、归属人、上链时间 struct Record { string cid; // IPFS 返回的文件指纹 address owner; // 记录归属地址 uint256 timestamp; // 上链时间 } // 通过 CID 查记录 mapping(string => Record) private recordsByCid; // 通过地址查名下全部 CID mapping(address => string[]) private cidsByOwner; // 存证成功时抛出的事件,便于前端监听和链下索引 event RecordSaved(address indexed owner, string cid, uint256 timestamp); // 保存一条存证:CID 由前端调用 ipfs add 后传入 function saveRecord(string calldata _cid) external { require(bytes(_cid).length > 0, "cid is empty"); // 防止同一 CID 被重复登记,存证语义下重复登记没有意义 require(recordsByCid[_cid].timestamp == 0, "cid already exists"); Record memory rec = Record({ cid: _cid, owner: msg.sender, timestamp: block.timestamp }); recordsByCid[_cid] = rec; cidsByOwner[msg.sender].push(_cid); emit RecordSaved(msg.sender, _cid, block.timestamp); } // 按 CID 查询归属人和时间 function getRecord(string calldata _cid) external view returns (address owner, uint256 timestamp) { Record memory rec = recordsByCid[_cid]; require(rec.timestamp != 0, "record not found"); return (rec.owner, rec.timestamp); } // 按地址查询名下所有 CID function getCidsByOwner(address _owner) external view returns (string[] memory) { return cidsByOwner[_owner]; } }合约设计的几个选择值得解释。用mapping(string => Record)而不是数组,是因为存证场景的核心查询是「拿到 CID 反查登记信息」,CID 天然是 key;用msg.sender记录归属人,是因为签名交易里msg.sender就是交易发起方,不需要额外传地址参数,也避免调用者伪造归属——这个字段由以太坊底层保证可信。Solidity 0.8 之后自带算术溢出检查,不需要再自己引入 SafeMath,所以这个合约里没有那套老写法。
存在性判断用的是timestamp == 0,因为block.timestamp在实际存证中一定大于 0,用 0 表示「还没写入」是零成本且可靠的。事件RecordSaved不只是前端便利,它还用于链下索引和审计——你把合约部署到测试网后,用区块浏览器能直接看到每一笔存证事件,不需要去读合约 storage。
4.2 用 ethers.js 完成部署、存证与查询的代码路径
合约写完后,部署这一步我习惯用 ethers.js 跑脚本。开发链在本机,不需要真实 gas 费用,脚本从构建到调用一条龙很快。部署脚本骨架如下。
import { ethers } from "ethers"; // 连接本地开发链,Ganache 默认监听 8545 const provider = new ethers.JsonRpcProvider("http://127.0.0.1:8545"); // 本地测试私钥,导入 Ganache 控制台里打印的账户私钥 const privateKey = "0x..."; // 只用于本地开发,禁止用于有真实资产的环境 const wallet = new ethers.Wallet(privateKey, provider); // ABI 和 bytecode 来自编译产物,用 Hardhat/Foundry 编译生成,不要手抄 const abi = [ /* 合约编译后的 ABI */ ]; const bytecode = "0x..."; // 合约编译后的字节码 async function deploy() { const factory = new ethers.ContractFactory(abi, bytecode, wallet); const contract = await factory.deploy(); await contract.waitForDeployment(); console.log("合约地址:", await contract.getAddress()); } deploy();JsonRpcProvider指向本地节点,Wallet 把私钥和 provider 绑在一起,之后所有交易都以这个地址为msg.sender。部署本身是一笔交易,factory.deploy()会广播创建合约的交易,waitForDeployment()等待它上链。拿到合约地址后记得存下来,前端 DApp 调用合约时需要它。
存证和查询是两类完全不同的调用,很多新手在这里栽跟头。
// 假设 cid 来自 ipfs add 命令的输出 const cid = "bafy..."; // 存证:saveRecord 是写操作,需要签名、广播、等待区块确认 const tx = await contract.saveRecord(cid); const receipt = await tx.wait(); // 交易回执里可以解析 RecordSaved 事件 // 查询:getRecord 是 view 调用,不走链上存储,立即返回 const [owner, timestamp] = await contract.getRecord(cid); console.log("记录归属:", owner, "上链时间:", timestamp);saveRecord返回的是一个待确认的交易对象,tx.wait()是等它被打包;而getRecord是view函数,实际上走的是节点端的eth_call,不产生交易、不消耗 gas,也不需要wait()。分不清这两类调用,前端就很容易写成「每查一次都在等一个永不发生的确认」,看起来很卡,其实完全用错了 API。
4.3 在 DApp 里把「写交易」和「读调用」分开处理
一个存证 DApp 的典型交互流是:用户选文件 → 前端调本地 IPFS 节点生成 CID → 用 MetaMask 签名saveRecord交易 → 等待回执 → 再用getRecord回读记录。每一步的反馈机制不同,写交易要展示「等待确认」状态,读调用要展示「查询结果」,两套逻辑混在一起会让用户困惑。
一个实际细节:IPFS 的 CID 生成是本地操作,不花钱也不上链;真正花钱的是把 CID 写进合约的那笔交易。所以前端可以先把文件 add 进 IPFS,再提示用户确认签名,用户看到的是「签名确认 CID 上链」,而不是「上传文件到区块链」——这个概念正确了,后面的状态管理才有意义。
另外,ABI 和 bytecode 不要手工从 Remix 复制粘贴到脚本里。项目里用 Hardhat 或 Foundry 编译合约,产物会同时给出 ABI JSON、bytecode 和部署信息,再在部署脚本里直接引入。手工复制最大的坑是漏掉 ABI 里某个函数定义,调用时 ethers.js 能拿到合约地址却无法正确构造 calldata,报错信息又很含糊。
5. 集成避坑:IPFS 与链上存证的高频翻车点与排查
5.1 五个高频问题:现象、原因、解决
第一个高频坑:同一个文件两次ipfs add得到不同 CID。现象是你第一次 add 拿到Qm...开头的 CID,第二次换了参数拿到bafy...开头的 CID,于是怀疑文件内容在传输中被改动,其实没有。原因是 IPFS 对同一个文件可以用不同的 chunk 参数切块,raw-leaves、CID 版本、UnixFS 包装方式都会影响输出。解决方式是统一参数,存证项目里固定使用ipfs add --cid-version=1 --raw-leaves,并且把参数写进项目文档,别让不同同事用不同习惯生成 CID。
第二个高频坑:MetaMask 连了本地节点,但 DApp 里读不到合约记录。现象是 RPC URL 看起来填对了,交易也显示成功,但getRecord一查就是「record not found」。原因是 chain id 和节点不匹配,你在浏览器里自建了一条「看起来是本地链」的网络,实际交易发去了另一个链。解决方式是先用curl http://127.0.0.1:8545 -X POST -H "Content-Type: application/json" --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}'看返回的链 id,再回 MetaMask 里核对。这个排查动作我已经成了习惯,每次换节点必做。
第三个高频坑:合约里require(recordsByCid[_cid].timestamp == 0, "cid already exists")被误当丢失数据。现象是重复存同一 CID 时交易一直报错,前端没捕获 revert 原因,界面显示异常。原因不是数据丢了,而是这个 CID 之前已经被登记过,合约按设计拒绝重复写。解决方式是前端把tx.wait()包在 try/catch 里,解析 revert message,对「cid already exists」做友好提示:这是确认信号,不是错误。
第四个高频坑:Ganache 重启后记录「消失」。现象是本地节点重启,MetaMask 里还有交易记录,但合约地址没变,查询却读不到数据。原因很简单,Ganache 默认是内存链,没有持久化,重启后状态回到初始。解决方式是把真实存证数据放到持续运行的节点上;本地开发阶段重启后重新部署合约,或者使用ganache --db <路径>指定数据目录持久化。如果你只是验证流程,重部署一次成本很低,别在这上面耗太久。
第五个高频坑:测试私钥被写死在脚本里提交到仓库。现象是代码仓库被扫描工具报警,或者被不明来源的工具自动调用。原因是开发阶段图省事,把privateKey直接写在部署脚本里。解决方式是本地开发也至少用一个.env文件存私钥,并加入.gitignore;如果代码已经提交过,去仓库平台撤销那个 commit 里的密钥,因为「已提交的私钥」等同于泄露。这条不是技术难题,但一旦发生,代价比任何一个合约 bug 都大。
5.2 验证一条完整链路:从文件到链上记录的检查表
跑通一遍之后,别急着加功能。用下面这张检查表把链路从头到尾核一遍,每项都有明确期望结果和失败时的排查方向。我每次搭建存证类方案都会走一遍这个表,能省掉后面一大半定位时间。
| 验证点 | 操作 | 期望结果 | 失败时先看哪里 |
|---|---|---|---|
| 文件已入 IPFS | ipfs cat <cid> | 终端输出与原始文件一致 | 节点是否在运行;ipfs pin ls里是否有该 CID |
| CID 与链上记录一致 | 合约getRecord(cid) | 返回归属地址和上链时间戳 | 合约地址是否部署正确;MetaMask 选的链是否对 |
| 跨工具读同一记录 | 用 MyEtherWallet 连同一 RPC,填合约地址和 ABI 调用getRecord | 结果与 MetaMask 路径一致 | 两个入口的 RPC URL 和 chain id 是否完全相同 |
| 事件已产出 | 在测试网区块浏览器或本地索引里查RecordSaved | 能看到 owner、cid、timestamp | 交易回执状态是否为 1;事件参数是否被正确解码 |
最后一项容易被忽略。合约里的事件不只是前端监听用的,它们是可审计证据的一部分。如果交易成功但事件没被索引,前端至少要在页面里把交易 hash 展示出来,否则用户存证后无法自证。链上存证的核心价值就是「可自证」,事件解析、交易 hash 展示、CID 回读这三样缺一不可。
6. 进阶验证:用 MetaMask 与 MyEtherWallet 双入口确认档案可信
6.1 两把钥匙开同一把锁:跨工具验证存证记录
当我需要确认一个存证方案不是「只在某个钱包工具里能用」时,我会做一次双入口验证:同一个地址、同一份合约、同一个 CID,分别从 MetaMask 和 MyEtherWallet 各操作一遍。链上的记录是账本自己产生的,工具只是签名和展示的入口;但如果你不主动做这个对比,就永远发现不了「换个工具读不到」这类配置问题。
三种值得做的验证动作,我列成了表格。别嫌简单,很多生产环境的问题就出在这些看起来过于基础的配置上。
| 验证动作 | MetaMask 路径 | MyEtherWallet 路径 | 验证的意义 |
|---|---|---|---|
| 查询同一条记录 | DApp 里调用getRecord(cid) | MEW 选自定义网络,填入合约地址和 ABI 调用getRecord | 两边读到相同 owner 和 timestamp,证明数据在链上,不在钱包里 |
| 追加一条新存证 | MetaMask 签名saveRecord交易 | 用同一个地址在 MEW 里导入私钥,再签同一笔交易 | 证明「同一把私钥」在两个入口都能产生合法记录 |
| 核对网络配置 | Settings-Networks 里检查 RPC 与 chain id | MEW 的 network 下拉里核对相同字段 | 两个入口配置一致,才能看到同一个账本 |
做完这轮验证,你会对「身份是地址、工具是入口」这句话有切身感知。我现在的习惯是,任何存储类项目的第一版都只做「文件 → IPFS → CID → 合约 → 回读验证」这条最小闭环,等闭环可靠了再补权限、加密、批量。换钱包入口验证这件事,就是闭环里的最后一环——它逼你把网络配置、合约地址、ABI 这些最容易出错的基础设施信息全部重新核对一遍。这套方法论帮我挡掉过不少翻车现场,希望帮到你。
本文还有配套的精品资源,点击获取