Web3 CMS/Blog 全栈开发教程(附项目源码)

目前,Web3仍然处于新生阶段,但其发展迅速。如果您是一位Web3行业从业者或是关注着,可以很明显的看到它的基础构件已经开始成形。Web2应用程序依赖于中心化数据库,而Web3应用程序构建在区块链架构之上,以实现无需信任和无需许可的去中心化访问。

在选择构建及开发去中心化应用时,开发人员有两个主要选择:兼容EVM的区块链和不兼容EVM的区块链。

随着以太坊区块链以及EVM的兴起,鉴于可扩展性限制或昂贵交易成本方面的考虑,越来越多的应用选择了与EVM完全兼容的框架。这意味着与 EVM 兼容的生态链都通过共享相同的 Solidity 软件层来运行智能合约。同时,开发人员可以使用所有相同的工具、文档和社区,以此节省时间和金钱。

在这篇文章中,您将学习用于构建全栈 Web3 应用程序的工具、协议和框架。我们将讲解如何基于 EVM 构建及开发Web3 CMS/Blog应用,并给出完整的项目源码

我们将部署到的主要网络是 Polygon。选择 Polygon 是因为它的低交易成本、快速的出块时间和当前的网络采用率。

在本教程结束时,您应该对现代 Web3 堆栈中最重要的部分以及如何构建高性能、可扩展、全栈去中心化区块链应用程序有一个很好的理解,并且能够通过这些知识构建您自己的 Web3 应用。

目录

1. Web3 技术栈
2. 准备工作
3. 项目设置
4. 智能合约
5. 部署合约
6. 将测试账户导入到钱包
7. Next.js app
7.1 context.js
7.2 布局与导航
7.3 应用程序入口
7.4 文章创建及发布页面
7.5 查看文章
7.6 编辑文章
7.7 测试
8. 部署到Polygon
8.1 配置网络
8.2 将应用部署到 Polygon 网络
9. 创建 subgraph API
9.1 在 Graph 中创建项目
9.2 使用 Graph CLI 对subgraph 进行初始化
9.3 定义实体
9.4 使用实体和映射更新subgraph
9.5 Assemblyscript 映射
9.6 编译
10. 部署 subgraph
11. 数据查询

1. Web3 技术栈

我结合个人经验以及过去一年在 Edge & Node 团队所做的研究,从开发人员的角度写了我对 Web3 技术栈当前状态的解释。您可以从下图了解整体结构。

在本文中,我们将涉及上图Web3技术栈的以下部分:

您将学习到它们各自如何工作以及彼此间协同工作,从而通过使用这些构建块来构建多种类型的Web3应用。

2. 准备工作

  • 本地机器需安装 Node.js
  • 浏览器需安装 MetaMask Chrome 扩展程序

3. 项目设置

我们开始创建应用程序样板,安装所有必要的依赖项,并配置项目。为方便理解和维护,我们将对代码添加注释。

首先,创建一个新的 Next.js 应用程序并切换到该目录:

npx create-next-app web3-blog
cd web3-blog

在目录中使用 npm、yarn 或 pnpm 安装以下依赖项:

npm install ethers hardhat @nomiclabs/hardhat-waffle \
ethereum-waffle chai @nomiclabs/hardhat-ethers \
web3modal @walletconnect/web3-provider \
easymde react-markdown react-simplemde-editor \
ipfs-http-client @emotion/css @openzeppelin/contracts

依赖项简介:

接下来,我们将初始化本地智能合约开发环境。

npx hardhat

? What do you want to do? Create a basic sample project
? Hardhat project root: <Choose default path>

本步骤,如果出现有关 README.md 的错误,请删除 README.md 文件然后重新运行
npx hardhat 命令。

通过上述步骤我们就完成了 Solidity 开发环境的搭建。您将看到一些新文件和文件夹,包括 contracts, scripts, testhardhat.config.js

接下来,请使用以下代码对 hardhat.config.js 配置进行更新。

require("@nomiclabs/hardhat-waffle");

module.exports = {
  solidity: "0.8.4",
  networks: {
    hardhat: {
      chainId: 1337
    },
    // mumbai: {
    //   url: "https://rpc-mumbai.matic.today",
    //   accounts: [process.env.pk]
    // },
    // polygon: {
    //   url: "https://polygon-rpc.com/",
    //   accounts: [process.env.pk]
    // }
  }
};

至此,我们配置了本地 hardhat 开发环境,并设置(并注释掉)我们将用于部署到 Polygon 主干网 和 Mumbai 子网的测试环境。

接下来,让我们添加一些基本的全局 CSS,我们将需要这些 CSS 来设置 CMS 的 Markdown 编辑器的样式。打开 styles/globals.css 并在现有 css 末尾添加以下代码:

.EasyMDEContainer .editor-toolbar {
  border: none;
}

.EasyMDEContainer .CodeMirror {
  border: none !important;
  background: none;
}

.editor-preview {
  background-color: white !important;
}

.CodeMirror .cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word) {
  background-color: transparent !important;
}

pre {
  padding: 20px;
  background-color: #efefef;
}

blockquote {
  border-left: 5px solid #ddd;
  padding-left: 20px;
  margin-left: 0px;
}

接下来,我们将创建几个 SVG 文件,一个用于 logo,一个用于 arrow 按钮。

在 public 文件夹中创建 logo.svg ‌和 right-arrow.svg‌ 文件,并将链接中的 SVG 代码复制到对应文件中。

4. 智能合约

我们开始为 CMS/Blog 添加智能合约支持。在 contracts 文件夹中创建一个名为 Blog.sol 的文件并将写入以下代码:

// contracts/Blog.sol
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;

import "hardhat/console.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract Blog {
    string public name;
    address public owner;

    using Counters for Counters.Counter;
    Counters.Counter private _postIds;

    struct Post {
      uint id;
      string title;
      string content;
      bool published;
    }
    /* mappings can be seen as hash tables */
    /* here we create lookups for posts by id and posts by ipfs hash */
    mapping(uint => Post) private idToPost;
    mapping(string => Post) private hashToPost;

    /* events facilitate communication between smart contractsand their user interfaces  */
    /* i.e. we can create listeners for events in the client and also use them in The Graph  */
    event PostCreated(uint id, string title, string hash);
    event PostUpdated(uint id, string title, string hash, bool published);

    /* when the blog is deployed, give it a name */
    /* also set the creator as the owner of the contract */
    constructor(string memory _name) {
        console.log("Deploying Blog with name:", _name);
        name = _name;
        owner = msg.sender;
    }

    /* updates the blog name */
    function updateName(string memory _name) public {
        name = _name;
    }

    /* transfers ownership of the contract to another address */
    function transferOwnership(address newOwner) public onlyOwner {
        owner = newOwner;
    }

    /* fetches an individual post by the content hash */
    function fetchPost(string memory hash) public view returns(Post memory){
      return hashToPost[hash];
    }

    /* creates a new post */
    function createPost(string memory title, string memory hash) public onlyOwner {
        _postIds.increment();
        uint postId = _postIds.current();
        Post storage post = idToPost[postId];
        post.id = postId;
        post.title = title;
        post.published = true;
        post.content = hash;
        hashToPost[hash] = post;
        emit PostCreated(postId, title, hash);
    }

    /* updates an existing post */
    function updatePost(uint postId, string memory title, string memory hash, bool published) public onlyOwner {
        Post storage post =  idToPost[postId];
        post.title = title;
        post.published = published;
        post.content = hash;
        idToPost[postId] = post;
        hashToPost[hash] = post;
        emit PostUpdated(post.id, title, hash, published);
    }

    /* fetches all posts */
    function fetchPosts() public view returns (Post[] memory) {
        uint itemCount = _postIds.current();

        Post[] memory posts = new Post[](itemCount);
        for (uint i = 0; i < itemCount; i++) {
            uint currentId = i + 1;
            Post storage currentItem = idToPost[currentId];
            posts[i] = currentItem;
        }
        return posts;
    }

    /* this modifier means only the contract owner can */
    /* invoke the function */
    modifier onlyOwner() {
      require(msg.sender == owner);
    _;
  }
}

以上合约允许所有者创建、发布及更新文章,其它用户可以查看文章。如果您想放宽权限,可以删除或修改 onlyOwner 函数并使用 The Graph 来索引和查询所有者的帖子。

接下来,让我们编写一个基本测试来测试我们将使用的最重要的功能。为此,请打开 test/sample-test.js 并写入以下代码:

const { expect } = require("chai")
const { ethers } = require("hardhat")

describe("Blog", async function () {
  it("Should create a post", async function () {
    const Blog = await ethers.getContractFactory("Blog")
    const blog = await Blog.deploy("My blog")
    await blog.deployed()
    await blog.createPost("My first post", "12345")

    const posts = await blog.fetchPosts()
    expect(posts[0].title).to.equal("My first post")
  })

  it("Should edit a post", async function () {
    const Blog = await ethers.getContractFactory("Blog")
    const blog = await Blog.deploy("My blog")
    await blog.deployed()
    await blog.createPost("My Second post", "12345")

    await blog.updatePost(1, "My updated post", "23456", true)

    posts = await blog.fetchPosts()
    expect(posts[0].title).to.equal("My updated post")
  })

  it("Should add update the name", async function () {
    const Blog = await ethers.getContractFactory("Blog")
    const blog = await Blog.deploy("My blog")
    await blog.deployed()

    expect(await blog.name()).to.equal("My blog")
    await blog.updateName('My new blog')
    expect(await blog.name()).to.equal("My new blog")
  })
})

然后,请打开终端并运行以下命令来运行测试:

npx hardhat test

5. 部署合约

现在合约已经编写完成并经过测试,让我们尝试将其部署到本地测试网络。要启动本地网络,请打开两个单独的终端窗口。 在其中一个窗口中运行以下命令:

npx hardhat node

当我们运行此命令时,您应该会看到如下图所示的地址和私钥列表。

列表中数据是创建的 20 个测试账户和地址,我们将使用它们来部署和测试智能合约。每个帐户还拥有 10000 个假以太币。稍后,我们将学习如何将测试帐户导入 MetaMask 以供我们使用。

接下来,我们需要将合约部署到测试网络。 首先将文件 scripts/sample-script.js 更名为 scripts/deploy.js

然后写入以下代码到 deploy.js

/* scripts/deploy.js */
const hre = require("hardhat");
const fs = require('fs');

async function main() {
  /* these two lines deploy the contract to the network */
  const Blog = await hre.ethers.getContractFactory("Blog");
  const blog = await Blog.deploy("My blog");

  await blog.deployed();
  console.log("Blog deployed to:", blog.address);

  /* this code writes the contract addresses to a local */
  /* file named config.js that we can use in the app */
  fs.writeFileSync('./config.js', `
  export const contractAddress = "${blog.address}"
  export const ownerAddress = "${blog.signer.address}"
  `)
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

不要关闭当前窗口,然后到另外一个终端窗口中,我们可以通过附加本地网络参数的形式运行该脚本:

npx hardhat run scripts/deploy.js --network localhost

当合约部署成功后,您将能够在终端中看到结果输出。

6. 将测试账户导入到钱包

要将交易发送到智能合约,我们需要使用一个在运行 npx hardhat 节点时创建的帐户连接我们的 MetaMask 钱包。 在上文 CLI 输出的合约列表中,选择一个帐号(Account number)极其对应的私钥(Private Key):

➜  react-dapp git:(main) npx hardhat node
Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/

Accounts
========
Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

...

比如我们使用上述帐户并将其导入 MetaMask,以便使用该账户中预设的假 Eth。为此,首先打开 MetaMask 并启用测试网络:

接下来,将网络更新为 Localhost 8545:

然后在 MetaMask 账户菜单中点击“ Import Account”:

复制并粘贴上文第一个账户中的私钥,然后单击导入。 导入帐户后,您就会看到帐户中的 Eth:

注意:确保您导入的是账户列表中的第一个账户(账户#0),因为这将是部署合约时默认使用的账户,也是合约所有者。

现在,我们已经成功部署了一个智能合约和一个可用帐户,我们接下来将使用 Next.js 与其进行交互。

7. Next.js app

我们开始编写 Next.js 应用程序代码。

我们首先设置几个环境变量,然后将使用它们在本地测试环境、Mumbai测试网和 Polygon 主网之间进行切换。

在项目的根目录中创建一个名为 .env.local 的新文件,并添加以下配置:

ENVIRONMENT="local"
NEXT_PUBLIC_ENVIRONMENT="local"

接下来就可以通过更改上述配置在本地、测试网和主网之间进行切换。这将允许我们在客户端和服务器上选择对应的环境。 更多 Next.js 环境变量配置信息,请查看此处文档‌。

7.1 context.js

接下来,让我们创建应用 connext,它将为我们提供一种在整个应用程序中共享状态的简单方法。

新建一个名为 context.js 的文件并添加以下代码:

import { createContext } from 'react'
export const AccountContext = createContext(null)

7.2 布局与导航

接下来,打开 pages/_app.js 文件。 我们将在这里编写包括导航、钱包连接、上下文功能和一些基本样式。

此页面用作应用程序其余部分的封装器或布局。

/* pages/__app.js */
import '../styles/globals.css'
import { useState } from 'react'
import Link from 'next/link'
import { css } from '@emotion/css'
import { ethers } from 'ethers'
import Web3Modal from 'web3modal'
import WalletConnectProvider from '@walletconnect/web3-provider'
import { AccountContext } from '../context.js'
import { ownerAddress } from '../config'
import 'easymde/dist/easymde.min.css'

function MyApp({ Component, pageProps }) {
  /* create local state to save account information after signin */
  const [account, setAccount] = useState(null)
  /* web3Modal configuration for enabling wallet access */
  async function getWeb3Modal() {
    const web3Modal = new Web3Modal({
      cacheProvider: false,
      providerOptions: {
        walletconnect: {
          package: WalletConnectProvider,
          options: { 
            infuraId: "your-infura-id"
          },
        },
      },
    })
    return web3Modal
  }

  /* the connect function uses web3 modal to connect to the user's wallet */
  async function connect() {
    try {
      const web3Modal = await getWeb3Modal()
      const connection = await web3Modal.connect()
      const provider = new ethers.providers.Web3Provider(connection)
      const accounts = await provider.listAccounts()
      setAccount(accounts[0])
    } catch (err) {
      console.log('error:', err)
    }
  }

  return (
    <div>
      <nav className={nav}>
        <div className={header}>
          <Link href="/">
            <a>
              <img
                src='/logo.svg'
                alt="React Logo"
                style={{ width: '50px' }}
              />
            </a>
          </Link>
          <Link href="/">
            <a>
              <div className={titleContainer}>
                <h2 className={title}>Full Stack</h2>
                <p className={description}>WEB3</p>
              </div>
            </a>
          </Link>
          {
            !account && (
              <div className={buttonContainer}>
                <button className={buttonStyle} onClick={connect}>Connect</button>
              </div>
            )
          }
          {
            account && <p className={accountInfo}>{account}</p>
          }
        </div>
        <div className={linkContainer}>
          <Link href="/" >
            <a className={link}>
              Home
            </a>
          </Link>
          {
            /* if the signed in user is the contract owner, we */
            /* show the nav link to create a new post */
            (account === ownerAddress) && (
              <Link href="/create-post">
                <a className={link}>
                  Create Post
                </a>
              </Link>
            )
          }
        </div>
      </nav>
      <div className={container}>
        <AccountContext.Provider value={account}>
          <Component {...pageProps} connect={connect} />
        </AccountContext.Provider>
      </div>
    </div>
  )
}

const accountInfo = css`
  width: 100%;
  display: flex;
  flex: 1;
  justify-content: flex-end;
  font-size: 12px;
`

const container = css`
  padding: 40px;
`

const linkContainer = css`
  padding: 30px 60px;
  background-color: #fafafa;
`

const nav = css`
  background-color: white;
`

const header = css`
  display: flex;
  border-bottom: 1px solid rgba(0, 0, 0, .075);
  padding: 20px 30px;
`

const description = css`
  margin: 0;
  color: #999999;
`

const titleContainer = css`
  display: flex;
  flex-direction: column;
  padding-left: 15px;
`

const title = css`
  margin-left: 30px;
  font-weight: 500;
  margin: 0;
`

const buttonContainer = css`
  width: 100%;
  display: flex;
  flex: 1;
  justify-content: flex-end;
`

const buttonStyle = css`
  background-color: #fafafa;
  outline: none;
  border: none;
  font-size: 18px;
  padding: 16px 70px;
  border-radius: 15px;
  cursor: pointer;
  box-shadow: 7px 7px rgba(0, 0, 0, .1);
`

const link = css`
  margin: 0px 40px 0px 0px;
  font-size: 16px;
  font-weight: 400;
`

export default MyApp

7.3 应用程序入口

我们已经完成了应用布局,现在让我们创建应用程序入口。

此页面将从网络中获取文章列表并在列表视图中呈现文章标题,当用户点击标题时,便可以查看文章详情。

/* pages/index.js */
import { css } from '@emotion/css'
import { useContext } from 'react'
import { useRouter } from 'next/router'
import { ethers } from 'ethers'
import Link from 'next/link'
import { AccountContext } from '../context'

/* import contract address and contract owner address */
import {
  contractAddress, ownerAddress
} from '../config'

/* import Application Binary Interface (ABI) */
import Blog from '../artifacts/contracts/Blog.sol/Blog.json'

export default function Home(props) {
  /* posts are fetched server side and passed in as props */
  /* see getServerSideProps */
  const { posts } = props
  const account = useContext(AccountContext)

  const router = useRouter()
  async function navigate() {
    router.push('/create-post')
  }

  return (
    <div>
      <div className={postList}>
        {
          /* map over the posts array and render a button with the post title */
          posts.map((post, index) => (
            <Link href={`/post/${post[2]}`} key={index}>
              <a>
                <div className={linkStyle}>
                  <p className={postTitle}>{post[1]}</p>
                  <div className={arrowContainer}>
                  <img
                      src='/right-arrow.svg'
                      alt='Right arrow'
                      className={smallArrow}
                    />
                  </div>
                </div>
              </a>
            </Link>
          ))
        }
      </div>
      <div className={container}>
        {
          (account === ownerAddress) && posts && !posts.length && (
            /* if the signed in user is the account owner, render a button */
            /* to create the first post */
            <button className={buttonStyle} onClick={navigate}>
              Create your first post
              <img
                src='/right-arrow.svg'
                alt='Right arrow'
                className={arrow}
              />
            </button>
          )
        }
      </div>
    </div>
  )
}

export async function getServerSideProps() {
  /* here we check to see the current environment variable */
  /* and render a provider based on the environment we're in */
  let provider
  if (process.env.ENVIRONMENT === 'local') {
    provider = new ethers.providers.JsonRpcProvider()
  } else if (process.env.ENVIRONMENT === 'testnet') {
    provider = new ethers.providers.JsonRpcProvider('https://rpc-mumbai.matic.today')
  } else {
    provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com/')
  }

  const contract = new ethers.Contract(contractAddress, Blog.abi, provider)
  const data = await contract.fetchPosts()
  return {
    props: {
      posts: JSON.parse(JSON.stringify(data))
    }
  }
}

const arrowContainer = css`
  display: flex;
  flex: 1;
  justify-content: flex-end;
  padding-right: 20px;
`

const postTitle = css`
  font-size: 30px;
  font-weight: bold;
  cursor: pointer;
  margin: 0;
  padding: 20px;
`

const linkStyle = css`
  border: 1px solid #ddd;
  margin-top: 20px;
  border-radius: 8px;
  display: flex;
`

const postList = css`
  width: 700px;
  margin: 0 auto;
  padding-top: 50px;  
`

const container = css`
  display: flex;
  justify-content: center;
`

const buttonStyle = css`
  margin-top: 100px;
  background-color: #fafafa;
  outline: none;
  border: none;
  font-size: 44px;
  padding: 20px 70px;
  border-radius: 15px;
  cursor: pointer;
  box-shadow: 7px 7px rgba(0, 0, 0, .1);
`

const arrow = css`
  width: 35px;
  margin-left: 30px;
`

const smallArrow = css`
  width: 25px;
`

7.4 文章创建及发布页面

接下来,在 pages 目录中新建一个名为 create-post.js 的文件,我们将通过这个页面完成文章创建及发布功能。

我们还可以选择将封面图像上传并保存到 IPFS,并通过哈希值与其余数据关联存储在链上。

请将以下代码添加到该文件中:

/* pages/create-post.js */
import { useState, useRef, useEffect } from 'react' // new
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import { css } from '@emotion/css'
import { ethers } from 'ethers'
import { create } from 'ipfs-http-client'

/* import contract address and contract owner address */
import {
  contractAddress
} from '../config'

import Blog from '../artifacts/contracts/Blog.sol/Blog.json'

/* define the ipfs endpoint */
const client = create('https://ipfs.infura.io:5001/api/v0')

/* configure the markdown editor to be client-side import */
const SimpleMDE = dynamic(
  () => import('react-simplemde-editor'),
  { ssr: false }
)

const initialState = { title: '', content: '' }

function CreatePost() {
  /* configure initial state to be used in the component */
  const [post, setPost] = useState(initialState)
  const [image, setImage] = useState(null)
  const [loaded, setLoaded] = useState(false)

  const fileRef = useRef(null)
  const { title, content } = post
  const router = useRouter()

  useEffect(() => {
    setTimeout(() => {
      /* delay rendering buttons until dynamic import is complete */
      setLoaded(true)
    }, 500)
  }, [])

  function onChange(e) {
    setPost(() => ({ ...post, [e.target.name]: e.target.value }))
  }

  async function createNewPost() {   
    /* saves post to ipfs then anchors to smart contract */
    if (!title || !content) return
    const hash = await savePostToIpfs()
    await savePost(hash)
    router.push(`/`)
  }

  async function savePostToIpfs() {
    /* save post metadata to ipfs */
    try {
      const added = await client.add(JSON.stringify(post))
      return added.path
    } catch (err) {
      console.log('error: ', err)
    }
  }

  async function savePost(hash) {
    /* anchor post to smart contract */
    if (typeof window.ethereum !== 'undefined') {
      const provider = new ethers.providers.Web3Provider(window.ethereum)
      const signer = provider.getSigner()
      const contract = new ethers.Contract(contractAddress, Blog.abi, signer)
      console.log('contract: ', contract)
      try {
        const val = await contract.createPost(post.title, hash)
        /* optional - wait for transaction to be confirmed before rerouting */
        /* await provider.waitForTransaction(val.hash) */
        console.log('val: ', val)
      } catch (err) {
        console.log('Error: ', err)
      }
    }    
  }

  function triggerOnChange() {
    /* trigger handleFileChange handler of hidden file input */
    fileRef.current.click()
  }

  async function handleFileChange (e) {
    /* upload cover image to ipfs and save hash to state */
    const uploadedFile = e.target.files[0]
    if (!uploadedFile) return
    const added = await client.add(uploadedFile)
    setPost(state => ({ ...state, coverImage: added.path }))
    setImage(uploadedFile)
  }

  return (
    <div className={container}>
      {
        image && (
          <img className={coverImageStyle} src={URL.createObjectURL(image)} />
        )
      }
      <input
        onChange={onChange}
        name='title'
        placeholder='Give it a title ...'
        value={post.title}
        className={titleStyle}
      />
      <SimpleMDE
        className={mdEditor}
        placeholder="What's on your mind?"
        value={post.content}
        onChange={value => setPost({ ...post, content: value })}
      />
      {
        loaded && (
          <>
            <button
              className={button}
              type='button'
              onClick={createNewPost}
            >Publish</button>
            <button
              onClick={triggerOnChange}
              className={button}
            >Add cover image</button>
          </>
        )
      }
      <input
        id='selectImage'
        className={hiddenInput} 
        type='file'
        onChange={handleFileChange}
        ref={fileRef}
      />
    </div>
  )
}

const hiddenInput = css`
  display: none;
`

const coverImageStyle = css`
  max-width: 800px;
`

const mdEditor = css`
  margin-top: 40px;
`

const titleStyle = css`
  margin-top: 40px;
  border: none;
  outline: none;
  background-color: inherit;
  font-size: 44px;
  font-weight: 600;
  &::placeholder {
    color: #999999;
  }
`

const container = css`
  width: 800px;
  margin: 0 auto;
`

const button = css`
  background-color: #fafafa;
  outline: none;
  border: none;
  border-radius: 15px;
  cursor: pointer;
  margin-right: 10px;
  font-size: 18px;
  padding: 16px 70px;
  box-shadow: 7px 7px rgba(0, 0, 0, .1);
`

export default CreatePost

7.5 查看文章

在本步骤中,我们希望能够在类似于 myapp.com/post/some-post-id 的路径中查看文章。

我们可以通过 next.js dynamic routes (nextjs动态路由) 功能以几种不同的方式做到这一点。

我们将使用 getStaticPathsgetStaticProps 通过服务端获取数据,结果以数组形式返回。

为此,请在 pages 目录中创建一个名为 posts 的新文件夹,并在该文件夹中创建一个名为 [id].js 的文件并添加以下代码:

/* pages/post/[id].js */
import ReactMarkdown from 'react-markdown'
import { useContext } from 'react'
import { useRouter } from 'next/router'
import Link from 'next/link'
import { css } from '@emotion/css'
import { ethers } from 'ethers'
import { AccountContext } from '../../context'

/* import contract and owner addresses */
import {
  contractAddress, ownerAddress
} from '../../config'
import Blog from '../../artifacts/contracts/Blog.sol/Blog.json'

const ipfsURI = 'https://ipfs.io/ipfs/'

export default function Post({ post }) {
  const account = useContext(AccountContext)
  const router = useRouter()
  const { id } = router.query

  if (router.isFallback) {
    return <div>Loading...</div>
  }

  return (
    <div>
      {
        post && (
          <div className={container}>
            {
              /* if the owner is the user, render an edit button */
              ownerAddress === account && (
                <div className={editPost}>
                  <Link href={`/edit-post/${id}`}>
                    <a>
                      Edit post
                    </a>
                  </Link>
                </div>
              )
            }
            {
              /* if the post has a cover image, render it */
              post.coverImage && (
                <img
                  src={post.coverImage}
                  className={coverImageStyle}
                />
              )
            }
            <h1>{post.title}</h1>
            <div className={contentContainer}>
              <ReactMarkdown>{post.content}</ReactMarkdown>
            </div>
          </div>
        )
      }
    </div>
  )
}

export async function getStaticPaths() {
  /* here we fetch the posts from the network */
  let provider
  if (process.env.ENVIRONMENT === 'local') {
    provider = new ethers.providers.JsonRpcProvider()
  } else if (process.env.ENVIRONMENT === 'testnet') {
    provider = new ethers.providers.JsonRpcProvider('https://rpc-mumbai.matic.today')
  } else {
    provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com/')
  }

  const contract = new ethers.Contract(contractAddress, Blog.abi, provider)
  const data = await contract.fetchPosts()

  /* then we map over the posts and create a params object passing */
  /* the id property to getStaticProps which will run for ever post */
  /* in the array and generate a new page */
  const paths = data.map(d => ({ params: { id: d[2] } }))

  return {
    paths,
    fallback: true
  }
}

export async function getStaticProps({ params }) {
  /* using the id property passed in through the params object */
  /* we can us it to fetch the data from IPFS and pass the */
  /* post data into the page as props */
  const { id } = params
  const ipfsUrl = `${ipfsURI}/${id}`
  const response = await fetch(ipfsUrl)
  const data = await response.json()
  if(data.coverImage) {
    let coverImage = `${ipfsURI}/${data.coverImage}`
    data.coverImage = coverImage
  }

  return {
    props: {
      post: data
    },
  }
}

const editPost = css`
  margin: 20px 0px;
`

const coverImageStyle = css`
  width: 900px;
`

const container = css`
  width: 900px;
  margin: 0 auto;
`

const contentContainer = css`
  margin-top: 60px;
  padding: 0px 40px;
  border-left: 1px solid #e7e7e7;
  border-right: 1px solid #e7e7e7;
  & img {
    max-width: 900px;
  }
`

7.6 编辑文章

我们开始创建文章编辑页面,该页面将继承 pages/create-post.jspages/post/[id].js 的一些功能,以便能够在查看和编辑文章之间切换。

在 pages 目录中创建一个名为 edit-post 的新文件夹和一个名为 [id].js 的文件,并添加以下代码:

/* pages/edit-post/[id].js */
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import ReactMarkdown from 'react-markdown'
import { css } from '@emotion/css'
import dynamic from 'next/dynamic'
import { ethers } from 'ethers'
import { create } from 'ipfs-http-client'

import {
  contractAddress
} from '../../config'
import Blog from '../../artifacts/contracts/Blog.sol/Blog.json'

const ipfsURI = 'https://ipfs.io/ipfs/'
const client = create('https://ipfs.infura.io:5001/api/v0')

const SimpleMDE = dynamic(
  () => import('react-simplemde-editor'),
  { ssr: false }
)

export default function Post() {
  const [post, setPost] = useState(null)
  const [editing, setEditing] = useState(true)
  const router = useRouter()
  const { id } = router.query

  useEffect(() => {
    fetchPost()
  }, [id])
  async function fetchPost() {
    /* we first fetch the individual post by ipfs hash from the network */
    if (!id) return
    let provider
    if (process.env.NEXT_PUBLIC_ENVIRONMENT === 'local') {
      provider = new ethers.providers.JsonRpcProvider()
    } else if (process.env.NEXT_PUBLIC_ENVIRONMENT === 'testnet') {
      provider = new ethers.providers.JsonRpcProvider('https://rpc-mumbai.matic.today')
    } else {
      provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com/')
    }
    const contract = new ethers.Contract(contractAddress, Blog.abi, provider)
    const val = await contract.fetchPost(id)
    const postId = val[0].toNumber()

    /* next we fetch the IPFS metadata from the network */
    const ipfsUrl = `${ipfsURI}/${id}`
    const response = await fetch(ipfsUrl)
    const data = await response.json()
    if(data.coverImage) {
      let coverImagePath = `${ipfsURI}/${data.coverImage}`
      data.coverImagePath = coverImagePath
    }
    /* finally we append the post ID to the post data */
    /* we need this ID to make updates to the post */
    data.id = postId;
    setPost(data)
  }

  async function savePostToIpfs() {
    try {
      const added = await client.add(JSON.stringify(post))
      return added.path
    } catch (err) {
      console.log('error: ', err)
    }
  }

  async function updatePost() {
    const hash = await savePostToIpfs()
    const provider = new ethers.providers.Web3Provider(window.ethereum)
    const signer = provider.getSigner()
    const contract = new ethers.Contract(contractAddress, Blog.abi, signer)
    await contract.updatePost(post.id, post.title, hash, true)
    router.push('/')
  }

  if (!post) return null

  return (
    <div className={container}>
      {
      /* editing state will allow the user to toggle between */
      /*  a markdown editor and a markdown renderer */
      }
      {
        editing && (
          <div>
            <input
              onChange={e => setPost({ ...post, title: e.target.value })}
              name='title'
              placeholder='Give it a title ...'
              value={post.title}
              className={titleStyle}
            />
            <SimpleMDE
              className={mdEditor}
              placeholder="What's on your mind?"
              value={post.content}
              onChange={value => setPost({ ...post, content: value })}
            />
            <button className={button} onClick={updatePost}>Update post</button>
          </div>
        )
      }
      {
        !editing && (
          <div>
            {
              post.coverImagePath && (
                <img
                  src={post.coverImagePath}
                  className={coverImageStyle}
                />
              )
            }
            <h1>{post.title}</h1>
            <div className={contentContainer}>
              <ReactMarkdown>{post.content}</ReactMarkdown>
            </div>
          </div>
        )
      }
      <button className={button} onClick={() => setEditing(editing ? false : true)}>{ editing ? 'View post' : 'Edit post'}</button>
    </div>
  )
}

const button = css`
  background-color: #fafafa;
  outline: none;
  border: none;
  border-radius: 15px;
  cursor: pointer;
  margin-right: 10px;
  margin-top: 15px;
  font-size: 18px;
  padding: 16px 70px;
  box-shadow: 7px 7px rgba(0, 0, 0, .1);
`

const titleStyle = css`
  margin-top: 40px;
  border: none;
  outline: none;
  background-color: inherit;
  font-size: 44px;
  font-weight: 600;
  &::placeholder {
    color: #999999;
  }
`

const mdEditor = css`
  margin-top: 40px;
`

const coverImageStyle = css`
  width: 900px;
`

const container = css`
  width: 900px;
  margin: 0 auto;
`

const contentContainer = css`
  margin-top: 60px;
  padding: 0px 40px;
  border-left: 1px solid #e7e7e7;
  border-right: 1px solid #e7e7e7;
  & img {
    max-width: 900px;
  }
`

7.7 测试

至此功能已经全部完成。现在,我们进入测试环节。请确保您已在前面的步骤中将合约部署到网络,并且本地网络仍在运行。

打开一个新的终端窗口并启动 Next.js 应用程序:

npm run dev

当应用程序启动时,您应该能够连接您的钱包并与应用程序进行交互。

您可以在上述页面创建并发表第一篇文章。

您可能会注意到该应用程序的运行速度并没有想象中的快,但 Next.js 在生产环境中的速度将会非常快。要编译并运行生产环境应用,请运行以下命令:

npm run build && npm start

8. 部署到Polygon

现在我们已经在本地环境运行并测试了项目,现在我们开始将应用部署到 Polygon。作为开始,我们将应用先部署到 Polygon 测试网络 Mumbai

我们需要做的第一件事是将钱包中的一个私钥(Private Key)设置为环境变量。您可以直接从 MetaMask 导出该私钥:

注意:在任何情况下,私钥都不应该公开暴露。请妥善保管。

如果您在 Mac 上,请使用以下命令行设置环境变量(确保从同一终端会话运行部署脚本):

export pk="your-private-key”

8.1 配置网络

接下来,我们需要从本地测试网络切换到Mumbai Testnet 网络。为此,我们需要创建并对网络进行配置。

首先,打开 MetaMask 并单击设置:

我们将为 Mumbai 测试网络添加以下配置:

Network Name: Mumbai TestNet
New RPC URL: https://rpc-mumbai.matic.today
Chain ID: 80001
Currency Symbol: Matic

更多网络配置请参考这里

保存,然后您应该可以使用新网络了!最后,您将需要一些测试网 Polygon 代币才能与应用程序交互。请访问 Polygon Faucet,输入您想要请求代币的钱包地址。

8.2 将应用部署到 Polygon 网络

现在您已拥有代币,让我们将应用部署到 Polygon 网络。首先,请确保与您部署合约私钥关联的地址已收到一些代币。我们将使用其支付交易的 gas 费用。

接下来,将 hardhat.config.js 中与 mumbai 配置相关的注释去除:


mumbai: {

url: "https://rpc-mumbai.matic.today",

accounts: [process.env.pk]

},

然后通过以下命令部署 Polygon 测试网:

npx hardhat run scripts/deploy.js --network mumbai

注意:如果出现 ProviderError: RPCError 错误提示,这意味着公共 RPC 服务出现了拥塞。在产品环境中,我们建议您使用 Infura, Alchemy, 或 Quicknode 提供的 RPC 服务。

再将 .env.local 中的环境变量更新为 testnet


ENVIRONMENT="testnet"

NEXT_PUBLIC_ENVIRONMENT="testnet"

最后,重启服务:

npm run dev

现在,您可以在 Polygon 测试网中对应用进行测试了! :beers:

9. 创建 subgraph API

默认情况下,我们拥有的唯一数据访问模式是我们写入合约的两个函数,fetchPostfetchPosts。但随着您的应用程序开始扩展,您可能会发现自己需要更灵活和可扩展的 API。

例如,如果我们想让用户能够搜索文章、获取某个用户发布的文章或按发布日期对文章进行排序,该怎么办?们可以使用 The Graph 协议将所有这些功能构建到 API 中。 让我们看看如何做到这一点。

9.1 在 Graph 中创建项目

首先,请访问 The Graph 托管服务并登录或创建一个新帐户,然后进入控制面板单击“Add Subgraph”创建项目。

请使用以下属性配置您的subgraph:

  • Subgraph Name - Blogcms
  • Subtitle - 用于查询文章数据
  • Optional - 填写描述和 GITHUB URL 属性

9.2 使用 Graph CLI 对subgraph 进行初始化

首先,通过以下命令安装 Graph CLI:

npm install -g @graphprotocol/graph-cli

或者

yarn global add @graphprotocol/graph-cli

然后,就可以使用 Graph CLI init 命令进行初始化。因为我们已经完成智能合约的部署,所以在初始化过程中可以使用 from-contract 参数。合约地址可以在 config.js 文件中的 contractAddress 字段中获取。


graph init --from-contract your-contract-address \
--network mumbai --contract-name Blog --index-events

? Protocol: ethereum
? Product for which to initialize › hosted-service
? Subgraph name › your-username/blogcms
? Directory to create the subgraph in › blogcms
? Ethereum network › mumbai
? Contract address › your-contract-address
? ABI file (path) › artifacts/contracts/Blog.sol/Blog.json
? Contract Name › Blog

以上命令将根据 --from-contract 的合约地址生成一个 subgraph。 通过使用这个合约地址,CLI 将在你的项目中初始化一些配置(包括获取 abis 并将它们保存在 abis 目录中)。

通过 --index-events 参数,CLI 将根据合约发出的事件自动在 schema.graphqlsrc/mapping.ts 中为我们填充一些代码。Subgraph 配置信息位于 subgraph.yaml 文件中。

Subgraph代码库由以下几个文件组成:

  • subgraph.yaml:包含subgraph清单的 YAML 文件
  • schema.graphql:一个 GraphQL schema,它定义了为您的subgraph存储的数据,以及如何通过 GraphQL 查询它
  • AssemblyScript Mappings:AssemblyScript 代码,可将以太坊中的事件转换为模式中定义的实体数据(例如 mapping.ts)

subgraph.yaml 文件组成:

  • description(可选):本 subgraph 描述性语句。Graph Explorer 将显示其内容。
  • repository(可选):subgraph清单 URL 地址。Graph Explorer 也将显示其内容。
  • dataSources.source:智能合约subgraph源的地址,以及合约要使用的abi。地址是可选的;省略它代表允许索引来自所有合约的匹配事件。
  • dataSources.source.startBlock(可选):数据源开始索引的块的编号。在大多数情况下,我们建议使用创建合约的区块。
  • dataSources.mapping.entities :数据源写入存储的实体。每个实体的 schema 在 schema.graphql 文件中的定义。
  • dataSources.mapping.abis:一个或多个ABI文件,用于在映射中与之交互的所有智能合约。
  • dataSources.mapping.eventHandlers:列出此subgraph响应的智能合约事件以及对应的事件处理程序(示例中为 ./src/mapping.ts),这些事件处理程序将事件转换为实体进行存储。

9.3 定义实体

使用 The Graph,您可以在 schema.graphql 中定义实体类型,Graph Node 将生成最上层字段用于查询该实体类型的单个实例和集合。 每个实体类型都需要使用 @entity 变量进行标注。

本例中用于索引的实体数据是代币和用户。通过这种方式,我们将可以索引用户以及用户自己创建的Token。请使用以下代码更新 schema.graphql 文件:

type _Schema_
  @fulltext(
    name: "postSearch"
    language: en
    algorithm: rank
    include: [{ entity: "Post", fields: [{ name: "title" }, { name: "postContent" }] }]
  )

type Post @entity {
  id: ID!
  title: String!
  contentHash: String!
  published: Boolean!
  postContent: String!
  createdAtTimestamp: BigInt!
  updatedAtTimestamp: BigInt!
}

现在我们已经为应用创建了 GraphQL 模式,我们可以在本地生成实体并在 CLI 创建的映射中使用:

graph codegen

为了使智能合约、事件和实体之间的关系更加简单且类型安全,Graph CLI 合并 subgraph 的 GraphQL 模式和合约 ABI 生成 AssemblyScript 类型。

9.4 使用实体和映射更新subgraph

现在我们可以通过配置 subgraph.yaml 来使用我们刚刚创建的实体及其映射关系。首先使用 User 和 Token 实体更新 dataSources.mapping.entities 字段:

entities:
  - Post

接下来,我们需要找到部署合约的区块(可选),以便我们可以设置索引器开始同步的起始块,这样就无需从创世块同步。 您可以通过访问 https://mumbai.polygonscan.com/ 并粘贴您的合约地址来找到起始块。

最后,在配置文件添加 startBlock 配置项:

source:
  address: "your-contract-adddress"
  abi: Blog
  startBlock: your-start-block

9.5 Assemblyscript 映射

打开 src/mappings.ts 完成事件处理程序映射关系,请使用以下代码更新该文件:


import {
  PostCreated as PostCreatedEvent,
  PostUpdated as PostUpdatedEvent
} from "../generated/Blog/Blog"
import {
  Post
} from "../generated/schema"
import { ipfs, json } from '@graphprotocol/graph-ts'

export function handlePostCreated(event: PostCreatedEvent): void {
  let post = new Post(event.params.id.toString());
  post.title = event.params.title;
  post.contentHash = event.params.hash;
  let data = ipfs.cat(event.params.hash);
  if (data) {
    let value = json.fromBytes(data).toObject()
    if (value) {
      const content = value.get('content')
      if (content) {
        post.postContent = content.toString()
      }
    }
  }
  post.createdAtTimestamp = event.block.timestamp;
  post.save()
}

export function handlePostUpdated(event: PostUpdatedEvent): void {
  let post = Post.load(event.params.id.toString());
  if (post) {
    post.title = event.params.title;
    post.contentHash = event.params.hash;
    post.published = event.params.published;
    let data = ipfs.cat(event.params.hash);
    if (data) {
      let value = json.fromBytes(data).toObject()
      if (value) {
        const content = value.get('content')
        if (content) {
          post.postContent = content.toString()
        }
      }
    }
    post.updatedAtTimestamp = event.block.timestamp;
    post.save()
  }
}

以上代码将对创建、更新文章事件映射到对应处理程序并将数据存储到subgraph。

9.6 编译

一切准备就绪以后,请通过一下命令对项目进行编译:

graph build

如果一切顺利的话,您将在项目根目录中看到一个名为 build 的生成目录。

10. 部署 subgraph

我们可以通过 deploy 命令对项目进行部署。首先,请到Graph 控制面板
拷贝您账户中的Access token。

然后,执行以下命令:

graph auth
✔ Product for which to initialize · hosted-service
✔ Deploy key · ********************************

进行部署:

yarn deploy

部署成功后,您将能够在控制面板中看到它。点击可查看详情:

11. 数据查询

在控制面板中,我们将可以对数据进行查询。请使用以下查询语句获取文章列表:

{
  posts {
    id
    title
    contentHash
    published
    postContent
  }
}

根据发布日期对文章进行排序:

{
  posts(
    orderBy: createdAtTimestamp
    orderDirection: desc
  ) {
    id
    title
    contentHash
    published
    postContent
  }
}

对文章标题或正文执行全文搜索:


{
  postSearch(
    text: "Hello"
  ) {
    id
    title
    contentHash
    published
    postContent
  }
}

恭喜!您已成功的为应用创建了可扩展的API。更多查询请参阅:Querying from an Application - The Graph Docs

至此,我们已经成功的使用 Next.js, Polygon, Solidity, The Graph, IPFS和 Hardhat 完成了一款基于EVM的Web3 CMS/Blog 系统的开发。您可以在 GitHub 下载本项目源码: