Web3 全栈开发指南

使用Next.js、Polygon、Solidity、The Graph、IPFS和Hardhat构建全栈Web3应用,本教程有相应视频,在这里。

在这个完整深入的web3教程中,将学习构建全栈web3应用程序使用到的工具、协议和框架,最重要的是 -- 如何把所有内容整合在一起,为将来构建自己的想法打下基础。

教程的应用程序的代码库位于这里

我们要部署的主要网络是Polygon。我选择Polygon是因为它的交易成本低,区块时间快,以及也是目前广泛采用的网络。

也就是说,我们将在以太坊虚拟机(EVM)上进行构建,所以你也可以应用这些技能为其他几十个区块链网络进行构建,包括以太坊、Celo、Avalanche和其他许多网络。

本教程将构建的应用是一个全栈博客也是内容管理系统(CMS),你将拥有一个开放的、公共的、可组合的后端,可以在任何地方转移和重用。

在本教程结束时,你应该很好地理解 web3技术栈的最重要部分,以及如何构建高性能、可扩展、全栈的去中心化区块链应用程序:

本文是我的 "全栈" web3系列中的第四个指南,其他的文章是:

  1. 全栈以太坊开发指南
  2. 用Polygon在以太坊上建立一个全栈NFT市场
  3. 使用React、Anchor、Rust和Phantom进行全栈Solana开发的完整指南

Web3技术栈

Web3 全栈开发指南_第1张图片

 

在定义web3协议栈文章中,我从开发者的角度,结合自己的个人经验以及过去一年在Edge & Node团队所做的研究,写了我对web3技术栈现状的理解。

这个应用使用技术栈的各个部分有:

  1. 区块链--Polygon(有可选的RPC提供者)
  2. 以太坊开发环境 - Hardhat
  3. 前端框架 - Next.js & React
  4. 以太坊网络客户端库 - Ethers.js
  5. 文件存储 - IPFS
  6. 索引和查询 - The Graph协议

通过学习如何使用这些构件,我们可以建立许多类型的应用程序,所以本教程的目标是展示它们各自的工作原理以及它们如何结合在一起。

让我们开始吧!

前提条件

  • 在你的本地机器上安装Node.js
  • 在浏览器中安装MetaMask Chrome插件

项目设置

在这里,我们将创建应用程序的模板,安装所有必要的依赖项,并配置该项目。

代码会被注释,以便让你了解正在发生的事情,我也会在整个教程中描述正在发生的事情。

为了开始,创建一个新的Next.js应用程序,并换到新的目录中。

npx create-next-app web3-blog

cd web3-blog

接下来,进入新目录,用npmyarnpnpm安装以下依赖项:

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

解释一下,其中一些依赖项:

hardhat - 以太坊开发环境
web3modal - 一个易于使用的库,允许用户将他们的钱包连接到你的应用程序上
react-markdown 和 simplemde - CMS的markdown编辑器和markdown渲染器
@emotion/css - 一个出色的JS中的CSS库
@openzeppelin/contracts - 常用的智能合约标准和功能的开源实现

接下来,初始化本地智能合约开发环境:

npx hardhat

? What do you want to do? Create a basic sample project
? Hardhat project root: 

如果在引用README.md时出现错误,请删除README.md并再次运行npx hardhat

这是我们将使用的基本 Solidity 开发环境。你应该看到一些新的文件和文件夹被创建,包括 contractsscriptstest, 和 hardhat.config.js

接下来,让我们更新一下hardhat.config.js的Hardhat配置,用下面的代码更新这个文件:

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测试网环境,我们将使用这些环境来部署到Polygon。

接下来,添加一些基本的全局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,一个作为箭头按钮。

public文件夹中创建**logo.svgright-arrow.svg**,并将链接的SVG代码分别复制到这些文件中。

智能合约

接下来,让我们创建一个智能合约,它将为我们的博客和CMS提供支持。

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

部署合约

现在,合约已经写好并经过了测试,让我们试着把它部署到本地测试网络。

为了启动本地网络,终端至少打开两个独立窗口。在一个窗口中,运行下面的脚本:

npx hardhat node

当我们运行这个命令时,你应该看到一个地址和私钥的列表:

Web3 全栈开发指南_第2张图片

 

这些是为我们创建的20个测试账户和地址,可以用来部署和测试智能合约。每个账户也都“装”上了10,000个假的以太币。稍后,我们将学习如何将测试账户导入MetaMask,以便我们能够使用它。

接下来,我们需要将合约部署到测试网络中,首先将scripts/sample-script.js的名字更新为scripts/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);
  });

现在,在一个单独的窗口中(当本地网络仍在运行时),我们可以运行部署脚本,并给CLI命令一个选项参数,表示我们想部署到本地网络。

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

当合约被部署后,你应该在终端看到一些输出。

将测试账户导入你的钱包中

为了向智能合约发送交易,我们需要用运行npx hardhat node时创建的一个账户连接MetaMask钱包。在hardhat命令终端中,你应该同时看到账号以及私钥

➜  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,启用测试网络

Web3 全栈开发指南_第3张图片

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

Web3 全栈开发指南_第4张图片

接下来,在MetaMask中点击账户菜单中的导入账户

Web3 全栈开发指南_第5张图片

复制并粘贴由命令终端的第一个Private key(私钥),然后点击Import(导入)。一旦账户被导入,你应该看到账户中的Eth。

Web3 全栈开发指南_第6张图片

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

现在,我们已经部署了一个智能合约,并准备好使用一个账户,可以开始从Next.js应用程序中与合约交互。

Next.js 应用

接下来,让我们编写前端应用的代码。

我们要做的第一件事是设置几个环境变量,用来在本地测试环境、Mumbai 测试网和Polygon 主网之间切换。

在项目根部创建一个名为**.env.local**的新文件,并添加以下配置,以开始使用:

ENVIRONMENT="local"
NEXT_PUBLIC_ENVIRONMENT="local"

我们将能够在localtestnetmainnet之间切换这些变量。

这将使我们能够在客户端和服务器上都引用我们的环境。要了解更多关于Next.js中环境变量的工作原理,请查看这里文档。

context.js

接下来,让我们创建应用程序context。Context将为我们提供一种简单的方法来分享整个应用程序的状态。

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

import { createContext } from 'react'

export const AccountContext = createContext(null)

布局和导航

接下来,让我们打开pages/_app.js。在这里,我们将更新代码,以包括导航、钱包连接、上下文和一些基本的风格设计。

这个页面可以作为应用程序其他部分的wrapper或布局:

/* 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 (
    
) } 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

入口

现在我们已经设置好了布局,接下来创建应用程序的入口。

这个页面将从网络上获取帖子列表,并在一个列表视图中呈现帖子的标题。当用户点击一个帖子时,将把他们导航到另一个页面来查看详情(详情页面将在接下来创建)。

/* 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 (
    
{ /* map over the posts array and render a button with the post title */ posts.map((post, index) => (

{post[1]}

Right arrow
)) }
{ (account === ownerAddress) && posts && !posts.length && ( /* if the signed in user is the account owner, render a button */ /* to create the first post */ ) }
) } 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; `

创建帖子

接下来,在pages目录下创建一个新文件,名为create-post.js

这将包含允许我们创建帖子并将其保存到网络路由上。

我们还可以选择上传和保存封面图片到IPFS,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 (
    
{ image && ( ) } setPost({ ...post, content: value })} /> { loaded && ( <> ) }
) } 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

查看一个帖子

现在我们有了创建帖子的能力,那么我们如何导航和查看帖子呢?我们希望能够在一个看起来像myapp.com/post/some-post-id的路由中查看帖子。

可以用next.js动态路由以几种不同的方式来实现这一点。

我们将使用getStaticPaths和getStaticProps来利用服务器端的数据获取,它将在构建时使用从网络上查询的帖子数组来创建这些页面。

为了实现这一点,在pages目录下创建一个名为post的新文件夹,并在该文件夹中创建一个名为**[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 
Loading...
} return (
{ post && (
{ /* if the owner is the user, render an edit button */ ownerAddress === account && ( ) } { /* if the post has a cover image, render it */ post.coverImage && ( ) }

{post.title}

{post.content}
) }
) } 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; } `

编辑帖子

我们需要创建的最后一个页面是一个编辑现有帖子的方法。

这个页面将继承pages/create-post.js以及pages/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 (
    
{ /* editing state will allow the user to toggle between */ /* a markdown editor and a markdown renderer */ } { editing && (
setPost({ ...post, title: e.target.value })} name='title' placeholder='Give it a title ...' value={post.title} className={titleStyle} /> setPost({ ...post, content: value })} />
) } { !editing && (
{ post.coverImagePath && ( ) }

{post.title}

{post.content}
) }
) } 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; } `

测试

我们现在可以测试它了。

要做到这一点,请确保你已经在前面的步骤中把合约部署到了网络上,并且你的本地网络仍然在运行。

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

npm run dev

当应用程序启动时,你应该能够连接钱包并与应用程序交互:

Web3 全栈开发指南_第7张图片

你也应该能够创建一个新的帖子:

Web3 全栈开发指南_第8张图片

你可能会注意到,该应用程序的速度并不快,但Next.js在生产中的速度快得惊人。

要构建一个生产环境下版本,请运行以下命令:

npm run build && npm start

部署到Polygon

现在我们的项目已经开始运行,并在本地进行了测试,让我们把它部署到Polygon。我们将首先部署到Mumbai,即Polygon的测试网络。

我们需要做的第一件事是将我们钱包中的一个私钥设置为环境变量。

要获得私钥,你可以直接从MetaMask中导出它们。

Web3 全栈开发指南_第9张图片

私钥在任何情况下都不能公开分享。我们建议不要在文件中硬编码私钥。如果你选择这样做,请确保使用测试钱包,并且在任何情况下都不要将包含私钥的文件推送到源码控制中,或将其公开暴露。

如果你使用的是Mac,你可以在命令行中这样设置环境变量(请确保在同一终端和会话中运行部署脚本)。

export pk="your-private-key"

配置网络

接下来,我们需要从本地测试网络切换到Mumbai Testnet。

要做到这一点,我们需要创建和设置网络配置。

首先,打开MetaMask,点击设置

Web3 全栈开发指南_第10张图片
  • 接下来,点击网络,然后点击添加网络

    Web3 全栈开发指南_第11张图片

在这里,我们将为孟买测试网络添加以下配置,如这里所列。

网络名称:Mumbai测试网络
New RPC URL:https://rpc-mumbai.matic.today/
Chain ID:80001
Currency Symbol:Matic

点“保存”,然后你应该可以切换到并使用新的网络!

最后,你将需要一些测试网 Polygon代币,以便与应用程序交互, 要获得这些,你可以访问Polygon Faucet,输入你想申请代币的钱包地址。

部署到Polygon网络中

现在你已经有了一些代币,你可以在Polygon网络上进行部署了!

要做到这一点,请确保与你部署合约的私钥相关的地址已经收到一些代币,以便支付交易的Gas费用。

接下来,反注释hardhat.config.js中的mumbai配置:

mumbai: {
  url: "https://rpc-mumbai.matic.today",
  accounts: [process.env.pk]
},

为了部署到Polygon testnet,运行以下命令:

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

现在你应该可以在新的网络上测试应用程序了 !

如果你在连接公共Mumbai RPC 端点时有任何问题,可以考虑使用RPC提供者的端点来替换你的应用程序中的端点,如Infura、Alchemy或Quicknode。

创建一个subgraphAPI

默认情况下,我们唯一的数据访问模式是我们写进合约的两个函数:fetchPostfetchPosts

这是一个很好的开始,但当你的应用程序开始扩展时,你可能会发现自己需要一个更灵活和可扩展的API。

例如,如果我们想让用户能够搜索帖子,获取某个用户创建的帖子,或者按照帖子的创建日期进行排序,会怎么样?

我们可以通过使用The Graph协议将所有这些功能构建到一个API中,让我们看看如何做到这一点。

在The Graph中创建项目

先访问The Graph托管服务并登录或创建一个新账户。

接下来,进入仪表板,点击添加subgraph,创建一个新的subgraph,用以下属性配置你的subgraph:

  • subgraph名称 - Blogcms
  • 副标题 - 用于查询帖子数据的subgraph
  • 可选的 - 填写描述和GITHUB URL属性

一旦subgraph被创建,我们将使用Graph CLI在本地初始化subgraph。

使用Graph CLI初始化一个新的subgraph

接下来,安装Graph CLI:

$ npm install -g @graphprotocol/graph-cli

# or

$ yarn global add @graphprotocol/graph-cli

一旦Graph CLI被安装,你就可以用Graph CLI的init命令来初始化一个新的subgraph,由于我们已经将合约部署到网络上,我们可以通过使用--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模式,定义了subgraph存储了哪些数据,以及如何通过GraphQL查询这些数据。
  • AssemblyScript Mappings:AssemblyScript代码,将以太坊中的事件数据转换为模式中定义的实体(例如,本教程中的mapping.ts)。

subgraph.yaml中包含要处理的内容:

  • description(可选):关于subgraph是什么的可读描述。当subgraph被部署到托管服务时,该描述将由Graph浏览器显示。

  • 代码库(可选):可以找到subgraph清单的储存库的URL。也会被Graph Explorer所显示。

  • dataSources.source:subgraph来源的智能合约的地址,以及要使用的智能合约的ABI。地址是可选的;省略它则可以索引所有合约的匹配事件。

  • dataSources.source.startBlock(可选):数据源开始索引的区块的编号。在大多数情况下,我们建议使用创建合约的区块。

  • dataSources.mapping.entities:数据源写入存储的实体。每个实体的模式在schema.graphql文件中定义。

  • dataSources.mapping.abis:一个或多个命名的ABI文件,用于源合约以及你在映射中与之交互的任何其他智能合约。

  • dataSources.mapping.eventHandlers:列出该subgraph处理的智能合约事件和映射中的处理程序--在例子中是**./src/mapping.ts**--将这些事件转化为存储中的实体。

定义实体

通过The Graph,你在schema.graphql中定义实体类型,Graph Node将生成顶层字段,用于查询该实体类型的单个实例和集合。每个应该成为实体的类型都需要用@entity指令来注释。

我们要索引的实体/数据是TokenUser。这样,我们就可以对用户创建的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 schema,我们可以在本地生成实体,以便在CLI创建的mappings中使用:

graph codegen

为了使智能合约、事件和实体的工作变得简单和类型安全,Graph CLI从subgraph的GraphQL schema和数据源中包含的合约ABI的组合中产生AssemblyScript类型。

用实体和映射更新subgraph

现在我们可以配置subgraph.yaml,以使用我们刚刚创建的实体并配置相应mappings:

为此,首先用UserToken实体更新dataSources.mapping.entities字段:

entities:
  - Post

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

最后,更新配置,添加startBlock

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

Assemblyscript mappings

接下来,打开src/mappings.ts,写入我们在subgraph eventHandlers中定义的映射,用下面的代码更新该文件:

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中。

运行构建

接下来,让我们运行一次构建,以确保一切配置正确。为此,运行build命令:

$ graph build

如果构建成功,你应该看到在根目录下生成了一个新的build文件夹。

部署subgraph

要进行部署,我们可以运行deploy命令。要部署,你首先需要复制账户的Access token,可在Graph Dashboard上找到:

Web3 全栈开发指南_第12张图片

接下来,运行以下命令:

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

部署subgraph:

$ yarn deploy

一旦subgraph被部署,你应该看到它显示在仪表板上。

当你点击subgraph时,它应该打开subgraph的详细信息:

Web3 全栈开发指南_第13张图片

查询数据

现在我们已经在仪表盘中了,等待一段时间同步后,我们就可以开始查询数据了。运行下面的查询来获得一个帖子的列表:

{
  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,你可以用它来查询你的应用程序!

要学习如何在你的应用程序中使用API端点(endpoint),请查看文档这里或视频这里

你可能感兴趣的:(区块链,web3,web3,区块链)