一、你遇到过这种痛苦吗
先说说很多玩IPFS建博客的朋友都会遇到的一个坑:你辛辛苦苦写好一篇文章,发布到IPFS上,得到了一个哈希地址,比如Qmabc123...。然后你把链接发给朋友,大家都看得很开心。可是第二天你修改了一处笔误,重新发布后哈希变成了Qmxyz789...。之前的分享链接全部失效,因为哈希变了。你只能重新发一遍新链接,或者让读者自己知道新地址。这体验非常糟糕。
本质上IPFS是基于内容寻址的:文件内容变了,哈希就必须变。这是它的特性,但对于博客这种需要频繁更新的场景,就成了痛点。那有没有办法让读者始终用一个固定的地址,却能自动看到最新版本呢?有,答案就是IPNS。
二、IPNS是什么,怎么做到的
2.1 先理解IPNS的简单原理
IPNS的全称是InterPlanetary Name System,可以看成IPFS上的域名系统。它允许你把一个可变的内容哈希,绑定到一个不可变的标识符上。这个标识符长得很像IPFS哈希,但以k51...开头。只要你拥有对应的私钥,就可以随时更新这个标识符指向的最新哈希。而其他人只需要记住这个固定标识符,每次访问时自动解析到当前哈希。
打个比方:IPFS哈希相当于一串随机数字的快递单号,每次包裹内容变了单号就变。而IPNS标识符就像你的门牌号,不管快递里装的是旧书还是新书,门牌号永远不变,快递员每次按门牌号送最新包裹。
2.2 它如何解决博客更新问题
有了IPNS,你只需要做两件事:一、生成一个IPNS密钥(得到一个固定的标识符);二、每次更新博客内容后,用这个密钥发布一个IPNS记录,告诉网络“这个标识符现在指向新的哈希”。读者访问你的博客时,只需要用你的IPNS标识符(例如/ipns/k51...),就能自动获取最新内容。哈希变了,但IPNS标识符不变,读者不用更新链接。
这样从体验上,读者感觉就像访问一个普通网站一样,永远是最新版,而背后IPFS分布式存储的好处一分没少。
三、手把手搭一个能自动更新的IPNS博客
我们用一个最简单的例子来实现:一个纯静态博客,包含首页和文章页,用JavaScript(Node.js环境)控制整个流程。技术栈:Node.js + js-ipfs(IPFS的JS实现)+ 命令行工具。
3.1 准备工作:搭建IPFS节点
首先你需要一个运行中的IPFS节点。可以在本地安装IPFS桌面版或通过命令行启动。这里我们用js-ipfs在代码里启动一个嵌入式节点,方便演示。
// 文件名:init-ipfs.js
// 技术栈:Node.js + js-ipfs
const { create } = require('ipfs-core');
async function startNode() {
// 创建IPFS节点,并指定存储路径
const node = await create({
repo: './my-ipfs-repo',
config: {
// 为了测试方便,可将节点配置为离线模式(不连接到公共网络)
Addresses: {
Swarm: []
}
}
});
console.log('IPFS节点已启动,ID:', await node.id());
return node;
}
// 运行
startNode().catch(console.error);
上面代码创建了一个本地IPFS节点,并打印出节点ID。注意设置了Swarm: [],这样它不会连接公共网络,只用于本地测试,实际部署时需要开启连接。
3.2 创建IPNS密钥
每个IPNS标识符对应一个密钥对。你可以用IPFS命令或者API生成。下面我们用key.gen方法生成一个名为myblog的密钥。
// 文件名:create-key.js
// 技术栈:Node.js + js-ipfs
const { create } = require('ipfs-core');
async function createBlogKey() {
const node = await create({ repo: './my-ipfs-repo' });
// 生成一个新密钥,名称为'myblog'
const key = await node.key.gen('myblog', { type: 'rsa', size: 2048 });
console.log('密钥名称:', key.name);
console.log('IPNS标识符:', key.id); // 以k51开头的字符串,这就是你的固定地址
// 输出类似: k51...abc123
await node.stop();
}
createBlogKey().catch(console.error);
执行后你会得到一个k51...开头的ID,记下来,这就是读者访问你博客时用的地址。
3.3 生成博客内容并发布到IPFS
假设博客结构如下:
- index.html(首页)
- posts/hello-world.md(一篇文章)
我们先用简单的HTML和Markdown示范,然后将其整个目录添加到IPFS。
// 文件名:publish-blog.js
// 技术栈:Node.js + fs + js-ipfs
const { create } = require('ipfs-core');
const fs = require('fs');
const path = require('path');
async function publishBlog() {
const node = await create({ repo: './my-ipfs-repo' });
// 创建博客目录(模拟)
const blogDir = './blog-output';
if (!fs.existsSync(blogDir)) {
fs.mkdirSync(blogDir, { recursive: true });
}
// 生成首页
const indexContent = `
<!DOCTYPE html>
<html>
<head><title>我的IPFS博客</title></head>
<body>
<h1>最新文章</h1>
<p>欢迎访问我的去中心化博客,下面是最新文章:</p>
<ul>
<li><a href="posts/hello-world.md">Hello World</a></li>
</ul>
</body>
</html>
`.trim();
fs.writeFileSync(path.join(blogDir, 'index.html'), indexContent);
// 生成一篇文章
const postContent = `
# Hello World
这是我在IPFS上发布的第一篇文章,使用IPNS固定地址,更新无忧!
`.trim();
const postsDir = path.join(blogDir, 'posts');
if (!fs.existsSync(postsDir)) {
fs.mkdirSync(postsDir, { recursive: true });
}
fs.writeFileSync(path.join(postsDir, 'hello-world.md'), postContent);
// 将整个博客目录添加到IPFS,目录会被自动哈希
const { cid } = await node.add({
path: '/blog',
content: fs.createReadStream(blogDir)
}, { wrapWithDirectory: true }); // wrapWithDirectory保证目录结构
// 实际上add目录需要分批添加,上面写法简化,正确做法使用addAll
// 下面用正确方法添加整个目录
const files = [];
function walkDir(dir, base) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.join(base, entry.name).replace(/\\/g, '/');
if (entry.isDirectory()) {
walkDir(fullPath, relativePath);
} else {
files.push({
path: relativePath,
content: fs.createReadStream(fullPath)
});
}
}
}
walkDir(blogDir, '/blog');
// 添加所有文件并获得根目录CID
const result = await node.addAll(files, { wrapWithDirectory: true });
let rootCid;
for await (const file of result) {
if (file.path === '/blog') {
rootCid = file.cid;
}
}
console.log('博客内容根CID:', rootCid.toString());
// 输出类似: QmRootHash...
// 此时你可以通过 /ipfs/<rootCid> 访问博客
await node.stop();
return rootCid;
}
publishBlog().then(cid => {
console.log('发布成功,CID:', cid.toString());
}).catch(console.error);
注意:上面代码中使用了addAll来正确添加目录结构,并获取根目录的CID。实际运行时,你会得到一个Qm...的哈希。
3.4 将哈希绑定到IPNS标识符
现在我们有了博客内容的哈希(比如QmGu...),需要把这个哈希发布到之前生成的IPNS密钥上。
// 文件名:publish-ipns.js
// 技术栈:Node.js + js-ipfs
const { create } = require('ipfs-core');
async function publishToIpns(cid) {
const node = await create({ repo: './my-ipfs-repo' });
// 获取之前创建的密钥 'myblog'
const key = await node.key.list();
const blogKey = key.find(k => k.name === 'myblog');
if (!blogKey) {
console.error('未找到myblog密钥,请先创建');
return;
}
// 发布IPNS记录:将CID绑定到密钥
const result = await node.name.publish(cid, {
key: 'myblog',
// lifetime: '24h' // 可选,记录有效时间,默认24小时
});
console.log('IPNS发布成功!');
console.log('你的博客固定地址: /ipns/' + blogKey.id);
console.log('当前指向: /ipfs/' + cid.toString());
// 此时任何IPFS节点只要知道这个IPNS地址,就能解析到最新的CID
await node.stop();
}
// 假设之前得到的CID是 'QmGu...' 字符串
publishToIpns('QmGu7QJjRjLjtnLQXLmMBE1B1LQzU1cLQq9Yk1V1X1Z1').catch(console.error);
执行后,你的IPNS标识符(k51...)就永久指向了当前博客内容。任何人访问/ipns/k51...都会得到/ipfs/QmGu...的内容。
3.5 自动更新:修改内容后重新发布并更新IPNS
现在整合一个自动化脚本,当你修改博客内容后,一键重新发布并更新IPNS指向。
// 文件名:update-blog.js
// 技术栈:Node.js + js-ipfs + fs
const { create } = require('ipfs-core');
const fs = require('fs');
const path = require('path');
async function updateBlog() {
// 1. 重新生成博客内容(这里只是模拟修改:向文章中追加日期)
const blogDir = './blog-output';
const postPath = path.join(blogDir, 'posts', 'hello-world.md');
let content = fs.readFileSync(postPath, 'utf-8');
content += `\n\n> 最后更新于:${new Date().toLocaleString()}`;
fs.writeFileSync(postPath, content);
console.log('已更新文章内容');
// 2. 重新将整个目录添加到IPFS,获取新CID
const node = await create({ repo: './my-ipfs-repo' });
const files = [];
function walkDir(dir, base) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.join(base, entry.name).replace(/\\/g, '/');
if (entry.isDirectory()) {
walkDir(fullPath, relativePath);
} else {
files.push({
path: relativePath,
content: fs.createReadStream(fullPath)
});
}
}
}
walkDir(blogDir, '/blog');
const result = await node.addAll(files, { wrapWithDirectory: true });
let rootCid;
for await (const file of result) {
if (file.path === '/blog') {
rootCid = file.cid;
}
}
console.log('新CID:', rootCid.toString());
// 3. 更新IPNS记录
await node.name.publish(rootCid, { key: 'myblog' });
console.log('IPNS已更新,新内容可访问');
await node.stop();
}
updateBlog().catch(console.error);
以后每次文章有更新,只需运行node update-blog.js,所有读者访问你的IPNS地址就能看到最新内容,无需改变链接。
四、应用场景
这种方案特别适合以下几种情况:
- 个人博客或文章站点:内容更新频率高,又不希望读者每次手动刷新哈希。
- 去中心化网站或应用:需要保持一个稳定入口,同时享受IPFS的永久性和抗审查性。
- DApp前端托管:智能合约的前端文件经常迭代,用IPNS可以保持dapp链接不变。
- 内容分发网络(CDN)备份:将静态资源同时部署到IPFS,并通过IPNS提供回退路径。
五、技术优缺点
优点
- 地址稳定:读者只需要记住一个IPNS标识符,就能一直访问最新版。
- 去中心化:不需要中心化服务器,内容存储在IPFS网络,抗审查。
- 更新简便:只需重新发布内容并执行一次IPNS更新命令,自动化程度高。
- 兼容性强:任何支持IPFS/IPNS的网关都能解析,比如
https://ipfs.io/ipns/k51...。
缺点
- 解析延迟:IPNS记录是通过DHT(分布式哈希表)传播的,更新后可能需要几分钟到几小时才能全网生效。这对即时更新需求不友好。
- 依赖密钥管理:如果私钥丢失,就再也无法更新指向,等于地址作废。需要妥善备份。
- 性能问题:IPNS解析需要查询网络,首次访问可能较慢,可以通过缓存或IPNS名称服务缓解。
- 成本问题:目前大部分公共IPFS网关对IPNS支持有限,最好使用自己的节点或付费网关。
六、注意事项
- 定期续约IPNS记录:默认发布的IPNS记录有效期为24小时,到期后如果节点离线,记录会过期导致无法解析。建议设置较长的lifetime,比如
--lifetime=8760h(一年),或者使用自动续期脚本。 - 私钥备份:
ipfs key export myblog导出密钥并保存到安全地方,否则换机器或重装系统就丢失控制权。 - 使用Pin服务:确保你的博客内容被其他节点固定(pin),否则如果只有你自己节点在线,别人可能无法获取内容。可以使用Pinata、Filebase等第三方服务,或自己运行多个节点。
- 版本控制:建议保留旧哈希,以便回溯历史版本,IPNS只指向最新版。
- 网关兼容性:不同IPFS网关对IPNS的解析支持程度不同,推荐使用
https://dweb.link这类专为IPNS优化的网关。
七、文章总结
IPNS为IPFS上经常更新的内容提供了一个优雅的稳定层。通过将固定标识符与可变哈希解耦,我们实现了博客“地址不变、内容常新”的体验。本文从实际问题出发,用一套完整的Node.js示例演示了如何创建IPNS密钥、发布内容、绑定IPNS以及自动化更新。虽然存在解析延迟和密钥管理成本,但对于大多数独立博客或轻量去中心化应用来说,IPNS是目前最实用的方案。未来随着IPFS生态发展,像IPNS Records、ENS集成等技术会进一步提升体验。如果你的博客也遇到哈希变化带来的麻烦,不妨试试这个方案。
Comments