如何用TypeScript、Apollo服务器和FaunaDB创建Next.js博客应用程序

51 阅读9分钟

使用TypeScript、Apollo Server和FaunaDB创建Next.js博客应用程序

FaunaDB是一个完全无服务器的托管云数据库。它速度快,而且在云中可以无限扩展。FaunaDB让你从它的Web界面或命令行来管理你的数据库数据。它可以处理复杂的数据建模用例。

开发人员倾向于喜欢SQL,因为它的安全性和数据一致性,而我们更喜欢NoSQL,因为它的灵活性、可扩展性和生产力。FaunaDB是这两者的混合体。它结合了SQL的安全性和NoSQL的生产力和可扩展性。

本指南将使用FaunaDB,对数据关系进行建模,并创建一个你可以用来连接到前端应用程序的API。我们将用TypeScript和Apollo客户端在前端引导Next.js。然后创建一个博客应用,在后端利用无服务器的FaunaDB和Apollo服务器。然后,我们将使用Next.js的Versel来部署该应用。

前提条件

要跟上本教程,你需要:

  • 在你的电脑上安装好[Node.js]。
  • 熟悉TypeScript。
  • 具备使用Apollo客户端和服务器的基本知识。
  • 对FaunaDB有基本了解。

设置和配置FaunaDB

如果你已经有一个FaunaDB账户,你可以在这里登录。否则,你可以注册一个。然后,从这里创建一个Fauna数据库。

create-fauna-db

一旦数据库创建完毕,创建一个新的集合,将博客文件保存在其中。集合是Fauna的表的版本。

new-collection

输入集合的名称,然后点击保存。集合将被创建,你将被重定向到集合页面。

Fauna将数据和信息保存在 "文档 "中。如果你习惯于使用其他数据库,集合中的单个文档就相当于表格中的行。到现在为止,它不会有任何文档。

new-collection-page

设置后端API

创建一个项目目录,并通过运行以下命令初始化一个NPM项目。

npm init --yes

安装以下软件包。

  • FaunaDB:这是为了提供一个访问FaunaDB实例的驱动程序。
  • Apollo服务器。用于设置后端GraphQL API。
  • GraphQL。用于为API提供一个查询语言。
  • Nodemon。用于自动重新启动开发服务器。
npm i --save apollo-server faunadb graphql

运行下面的命令来安装Nodemon作为开发依赖。

npm i --save-dev nodemon

在你的项目文件夹中,创建一个index.js 文件,并按如下方式设置你的后备服务器。

导入上述已安装的软件包。

const {gql,ApolloServer} = require("apollo-server");
const faunadb = require("faunadb");

定义一个查询,为FaunaDB创建一个客户端实例。

const q = faunadb.query;
const faunaClient = new faunadb.Client({
    secret:"your_secret",
    domain: 'db.us.fauna.com',
    scheme: 'https'
})

为了得到你的秘密,进入你刚刚创建的Fauna数据库的仪表盘,然后前往安全部分。

security_section

你现在可能没有任何密钥,点击新建密钥,输入任何密钥名称,然后点击保存。复制新页面上出现的密钥并将其粘贴到上述代码的秘密部分。

域名将由你选择的地区决定。例如,如果你选择了美国,域名将是db.us.fauna.com

对于欧盟,它将是db.eu.fauna.com 。更多信息请参考这些文档。你可以在数据库概述部分查看你的地区组。

下一步是想出类型定义。在上一节后添加以下内容。

const typeDefs = gql`
# structure of an article
type Article{
    ref: String
    title: String
    summary: String
    content: String
}

# queries
type Query {
    articles:[Article]
    article(id:ID):Article
}

# mutations 
type Mutation {
    createArticle(title:String,summary:String,content:String):Article
}   
`;

根据上面提供的信息,我们要为每篇博客文章定义以下字段。

  • Ref:文章的唯一标识符。
  • Title:文章的标题。
  • Summary:文章的简短描述。
  • Content:文章的内容。

我们还定义了一个查询,以获得许多和单一的文章。还有,根据文章的字段创建一个文章的突变。

接下来,添加一个获取多篇文章的方法,如下所示。

const  getArticles = async() => {
    try{
        // Get the articles added.
        let {data} = await faunaClient.query(
            q.Map(
                q.Paginate(q.Documents(q.Collection("articles"))),
                q.Lambda(x => [ q.Select('id', x), q.Get(x) ])
            )
        );
        
        // Map through the articles, redefining the structure
        let articles = data.map((article) =>{
            return {
                "ref":article[0],
                ...article[1]['data']
            }
        });
        
        // return the articles.
        return articles;
    }catch(error){
        // return an error message
        return new Error(error).message;
    }
}

使用faunaClient 实例,我们能够从我们的数据库中检索到文章。我们正在使用Map 来浏览返回的数据集,Paginate 来给我们的数据集添加分页,Lambda 来能够获得每个数据集的具体id。

我们还对数据集进行映射,重组它以符合我们的模式。如果出现任何错误,则返回该错误信息。

实现获取单篇文章的方法如下。

const getArticle  = async (id) => {
    try{
        // Get the specific article bases on id
        const {data} = await faunaClient.query(
            q.Get(q.Ref(q.Collection("articles"),id))
        );
        // return it
        return data;
    }catch(error){
        // return an error message
        return new Error(error).message;
    }
}

我们根据id ,也就是每篇文章的参考号来请求特定文章的数据。然后我们返回获取的数据,如果有任何错误,我们将返回错误信息。

实现创建文章的方法如下。

const createArticle = async (title,summary,content) => {
    try{
        // create an article with its title, summary, and content
        const {data} = await faunaClient.query(
            q.Create(q.Collection("articles"),{data:{title,summary,content}})
        );
        // return the created article
        return data;
    }catch(error){
        // return an error message
        return new Error(error).message;
    }
}

我们正在使用title,summary, 和content 字段创建一篇文章。然后,返回创建的文章。如果有任何错误,则返回该特定的错误信息。

使用下面的解析器将上述函数连接到QueryMutation 对象。

const resolvers = {
    Query:{
        articles: () => getArticles(), // all articles
        article: (_,{id}) => getArticle(id) // single article
    },
    Mutation:{
        createArticle: (_,{title,summary,content}) => createArticle(title,summary,content), // creating an article
    }
}

我们正在将我们为QueryMutation 定义的类型连接到上面各自的解析器函数。

实例化Apollo服务器。

const server = new ApolloServer({typeDefs,resolvers,cors:{
    credentials:true,
    origin:'*'
}});

我们通过发送我们的type definitionsresolvers ,并设置cors ,允许所有来源,来实例化上面的Apollo服务器。在生产应用中,我们建议你把源头列入白名单。

创建一个服务器将运行的端口。

const PORT = process.env.PORT || 4000;

现在,启动服务器。

server.listen(PORT).then( ({url}) => {
    console.log(`server started on ${url}`);
});

通过调用listen 方法,服务器将在指定的端口上启动,然后用服务器的本地URL ,记录一条消息。

要启动服务器,在你的package.json 中的scripts 部分添加以下一行。

"dev":"nodemon index.js"

该命令将使用nodemon ,启动开发服务器。从当前项目位置打开终端,运行以下命令来启动开发服务器。

npm run dev

initial_server_log

从你的浏览器中,访问你的控制台中记录的URL。由于我们使用的是Apollo Server ,你会收到一个类似于下图的页面。

apollo-launch-server

点击Query your server ,你的游乐场将被填充到当前运行的服务器上。

apollo-playground

请随意与GUI ,在operations 标签上写操作,运行它们,并从response 部分查看响应。我们的服务器现在已经启动并运行了。让我们使用Next.js 构建前端。

设置前台

为了设置前端,我们将使用create next app,这是Next.js团队提供的一个工具,可以使Next.js项目的设置更加容易。

创建一个前台目录。在该文件夹中,运行以下命令来初始化Next.js项目。

npx create-next-app --typescript .

由于我们将使用TypeScript,我们需要传入--typescript 参数,后面跟一个. ,以指定该项目将被托管在当前目录中。

安装完成后,我们将需要安装两个包。

  • @apollo-client - 是在连接我们的Apollo服务器实例时使用的。
  • graphql - 用于解释将出现在我们应用程序中的查询。

运行下面的命令来安装上述软件包。

npm i @apollo/client graphql

配置前台

配置意味着设置我们的应用程序中需要的实用程序和组件。在项目根目录下创建一个lib 目录。

lib 目录中,创建一个apollo-client.tsx 文件并添加以下函数。

import {ApolloClient,InMemoryCache} from "@apollo/client";

export const getApolloClient = () => {
    return new ApolloClient({
        uri: 'http://localhost:4000',
        cache: new InMemoryCache()
      });
};

上述函数通过传入我们的ApolloServerURIcache ,来实例化一个ApolloClientApolloClient 将保存其缓存的查询。导航到pages/_app.tsx ,导入ApolloProvidergetApolloClient 函数,如下所示。

import {
ApolloProvider,
} from "@apollo/client";
import {getApolloClient} from "../lib/apollo-client";

在一个client 变量上实例化ApolloClient

const client = getApolloClient();

ApolloProvider 包裹返回的Component ,并提供其客户端。

<ApolloProvider client={client}>  
    <Component {...pageProps} />
</ApolloProvider>

在项目根文件夹上创建一个components 目录。在它里面,创建一个navbarfooter 目录。在navbar 目录内,创建一个Navbar.tsxNavbar.module.css 文件。在Navbar.tsx ,添加以下代码块来创建一个简单的导航条。

import React from 'react'
import Link from "next/link"
import styles from "./Navbar.module.css";

export default function Navbar() {
    return (
        <div className={styles.navbarContainer}>
            <nav>
                <div className={styles.navbarBrand}>
                    Blog app
                </div>
                <div className={styles.navbarList}>
                    <ul>
                        <li>
                            <Link href="/">
                                <a>Home</a>
                            </Link>
                        </li>
                        <li>
                            <Link href="/add-article">
                                <a>Add article</a>
                            </Link>
                        </li>
                    </ul>
                </div>
            </nav>
        </div>
    )
}

Navbar.module.css 中添加以下样式,以格式化Navbar

.navbarContainer{
    width:100%;
    padding:10px;
    background-color:#cccc
}

.navbarContainer nav{
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    width: 60%;
    margin: 0px auto;
}

.navbarBrand {
    font-weight: bold;
    padding: 15px 0px;
}

.navbarList{
    justify-content: center;
    align-items: center;
}

.navbarList ul {
    display: flex;
    flex-direction: row;
    list-style-type: none;
}

.navbarList ul li{
    margin-left: 10px;
}

footer 目录中,创建一个Footer.tsx 和一个Footer.module.css 文件。在Footer.tsx 文件中,添加下面的代码块,创建一个简单的页脚。

import React from 'react'
import styles from "./Footer.module.css"

export default function Footer() {
    return (
        <footer className={styles.footer}>
            <p>
                Next.js blog app
            </p>
        </footer>
    )
}

在你的Footer.module.css中添加以下样式来格式化Footer

.footer {
    display: flex;
    flex: 1;
    padding: 2rem 0;
    border-top: 1px solid #eaeaea;
    justify-content: center;
    align-items: center;
}

.footer a {
    display: flex;
    justify-content: center;
    align-items: center;
    flex-grow: 1;
}

component 目录内创建一个Layout.tsx 文件,以承载我们将建立的每个特定页面的配置。继续往前走,在文件中添加以下配置。

import React from 'react'
import Head from 'next/head'
import Navbar from "./navbar/Navbar";
import Footer from "./footer/Footer";

interface LayoutProps {
    children:React.ReactNode
}

export default function Layout({children}:LayoutProps) {
    return (
        <div>
            <Head>
            <title>Blog app</title>
            <meta name="description" content="Blog app using Next.js and FaunaDB" />
            <link rel="icon" href="/favicon.ico" />
            </Head>
            <Navbar />
            <main>
            {children}
            </main>
            <Footer />
            <style jsx>{`
                main {
                    min-height: 70vh;
                    flex: 1;
                    display: flex;
                    flex-direction: column;
                  }
            `}</style>
        </div>
    )
}

在这里,我们正在设置一个静态的Head 配置,为NavbarFooter 页面设置动态内容,以及一些基本的样式。

将上面的Layout 导入到pages/_app.tsx 文件中。然后在ApolloProvider 里面用Layout 包裹Component ,这样NavbarFooter 就可以在所有页面上持续存在。

import Layout from "../components/Layout";

<ApolloProvider client={client}>
    <Layout>
        <Component {...pageProps} />
    </Layout>
</ApolloProvider>

获取添加的文章

为了获取添加的文章,我们将在pages/index.tsx 文件上工作,并对其进行如下编辑。

import type { NextPage } from 'next'
import {useQuery,gql} from "@apollo/client";
import Link from "next/link";

const Home: NextPage = () => {
  const GetArticles = gql`
    query GetArticles {
      articles {
        content
        ref
        title
        summary
      }
    }
  `;
  const {loading,error,data} = useQuery(GetArticles);  
  return (
   
    <div className="container">    
      {
        loading ? (
          <h2>Loading</h2>
        ) : (
          error ? (
            <h2>{error.message}</h2>
          ) : (
              data.articles.length > 0 ? (
                  data.articles.map((article:any,index:any) => {
                    return (
                        <div key={index}>
                        <Link href={`/posts/${article['ref']}`}>
                            <a>{article['title']}</a>
                            </Link>
                        <p>{article['summary']}</p>
                        </div>
                    )
                  })
              ) : (
                  <h2>No saved articles found</h2>
              )           
          )
        )
      }
      <style jsx>{`
        .container {
          margin-top: 2rem;
          width:60%;
          margin: 0px auto;
          padding:2rem 0px;
        }
        .container a{
          font-weight:bold;
        }
      `}</style>
    </div>
  )
}
export default Home;

useQuery 钩子通过传递query 作为参数向我们的Apollo server 发送我们的查询。这将是loading,error,data 的状态。

如果我们处于loading 状态,这意味着服务器正在获取文章。如果在这个过程中发生错误,将返回一个错误信息。否则,将返回加载的数据/文章列表。

如果没有保存的文章,我们将收到一个消息。否则,文章将被映射到设定的container UI。通过运行以下命令来启动前端的开发服务器。确保你在承载后端Next.js应用程序的文件夹内运行此命令。

npm run dev

确保apollo服务器的开发服务器仍在启动和运行。然后根据你是否保存了文章,在你的浏览器上打开http://localhost:3000

articles_home

添加一篇文章

为了处理这个操作,浏览到项目文件夹的pages 目录,并创建一个add-article.tsx 文件。然后添加以下代码块来处理添加新的文章。

import React,{useState} from 'react';
import {useMutation,gql} from "@apollo/client";
import Link from "next/link";

export default function AddArticle() {

    const [title,setTitle] = useState('');
    const [summary,setSummary] = useState('');
    const [content,setContent] = useState('');
    const [form_error,setFormError] = useState("");
    const [success_message,setSuccessMessage] = useState("");

    // create a graphql mutation query
    const ADD_ARTICLE = gql`
    mutation createArticle($title: String, $content: String, $summary: String) {
    createArticle(title: $title,content: $content,summary: $summary) {
        content
        summary
        title
    }
    }`; 

    // instanciate useMutation
    const [addArticle,{loading,data,error}] = useMutation(ADD_ARTICLE);

    const handleSubmit = (e: { preventDefault: () => void; }) => {
        e.preventDefault();

        // reset error and success message fields.
        setSuccessMessage("");
        setFormError("");

        // check the fields.
        if(title && summary && content){

            addArticle({variables:{
                title,
                summary,
                content
            }}).then( () => {
                //release state
                setTitle("");
                setSummary("");
                setContent("");
                setFormError("");
                // set success message
                setSuccessMessage("Article successfully added");
                return;
            })
            .catch( () => {
                setFormError("An error occurred");
            });

        }else{
            setFormError("All fields are required");
        }
    }

    return (
        <div className="container">

            <div className="add-todo-form">

                <form onSubmit={handleSubmit}>
                {
                    form_error ? (
                        <p className="form-error">{form_error}</p>
                    ) : null
                }
                {
                    error ? (
                        <p className="form-error">{error.message}</p>
                    ) : null
                }
                {
                    success_message ? (
                        <p className="form-success">{success_message}. Go to <Link href="/"> <a>home</a>
                        </Link>
                        </p>
                    ) : null
                }
                <div className="form-group">
                    <label>Title</label>
                    <input type="text" value={title} placeholder="Article title" onChange={ (e) => setTitle(e.target.value)}  />
                </div>

                <div className="form-group">
                    <label>Summary</label>
                    <input type="text" value={summary} placeholder="Article summary" onChange={ (e) => setSummary(e.target.value)} />
                </div>

                <div className="form-group">
                    <label>Content</label>
                    <textarea value={content} placeholder="Article content" onChange={ e => setContent(e.target.value)} rows={10}/>
                </div>
                
                <div className="form-group">
                    <button type="submit">
                        {
                            loading ? 'Loading' : "Add article"
                        }
                    </button>
                </div>
                </form>

            </div>

            <style jsx>{`
                .container {
                margin-top: 2rem;
                width:60%;
                margin: 0px auto;
                padding:2rem 0px;
                }
                .add-todo-form{
                    width:100%;
                }
                .form-group label{
                    width:100%;
                    display:block;
                    margin-bottom:10px;
                }
                .form-group input[type='text']{
                    width:100%;
                    padding:10px;
                    margin-bottom:10px;
                }
                .form-group textarea{
                    width:100%;
                    padding:10px;
                    margin-bottom:10px;
                }
                .form-error{
                    color:red;
                }
                .form-success{
                    color:green;
                }
            `}
            </style>
        </div>
    )
}

我们使用state 来保存title,summary,content,form error, 和一个success message.我们有GraphQL突变查询,在服务器上运行并添加文章。然后我们实例化useMutation 钩子,并解构submit function,loading,data, 和error

handleSubmit 函数将检查所有字段是否已经填入必要的数据,然后使用submit functionuseMutation 向服务器发送请求。当文章成功提交时,我们重置状态并显示一个成功消息。

否则,如果发生错误,我们就设置一个表单错误,并直接从useMutation 显示错误。 确保前置和后置的开发服务器正在运行。在浏览器上打开http://localhost:3000 ,在导航栏上点击Add article

add-article

填写字段并发送一个请求。

add-article-response

转到Home 页面,你应该看到你新添加的文章。

显示单篇文章

导航到你的pages 文件夹,创建一个posts 目录。在posts 目录内,创建一个[ref].tsx 文件。Next.js中的方括号意味着ref 将是动态的,指的是与当前ref (文章的参考号/ID)相关的单一请求。

[ref].tsx 文件中,添加以下代码块。

import React from 'react'
import {GetStaticProps} from 'next';
import {gql} from "@apollo/client";
import {getApolloClient} from "../../lib/apollo-client";
import { ParsedUrlQuery } from 'querystring';
import {useRouter} from "next/router";

// get a single article query
const GET_ARTICLE = gql`
    query GetArticle($articleId: ID) {
        article(id: $articleId) {
            content
            title
            summary
        }
    }
`;

// get many articles
const GET_ARTICLES = gql`
    query GetArticles {
    articles {
        ref
    }
    }
`;

interface Iprops{
    article:any
}

export default function Post({article}:Iprops) {
    const router = useRouter();

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

    return (
        <div className="container">
            <h3>{article['title']}</h3>

            <h5>{article['summary']}</h5>

            <p>{article['content']}</p>

            <style jsx>{`
            .container {
                margin-top: 2rem;
                width:60%;
                margin: 0px auto;
                padding:2rem 0px;
                }`}
            </style>

        </div>
    )
}

interface Iparams extends ParsedUrlQuery {
    ref:string
}

// Fetch a single article based on the ref

export const  getStaticProps:GetStaticProps = async  (context) => {
    const {ref} = context['params'] as Iparams;
    const apolloClient = getApolloClient();
    const {data} = await apolloClient.query({
        query:GET_ARTICLE,
        variables:{
            "articleId":ref
        }
    });
    return {
        props:{
            "article":data['article']
        }
    }
}

// Build paths for the articles present at build time

export async function getStaticPaths(){
    const apolloClient = getApolloClient();
    const {data} = await apolloClient.query({
        query:GET_ARTICLES
    });
    const paths = data['articles'].map((article: any) => {
        return {
            params:{
                "ref" : article['ref']
            }
        }
    });
    return {
        paths,
        fallback:false
    }
}

在这里,我们正在创建两个查询,一个是在获取单一文章时,另一个是在获取许多文章时。我们使用两种方法来获取数据。getStaticPropsgetStaticPathsgetStaticProps 将被用来从服务器端获取文章,而getStaticPaths 将在构建时获取文章。

在这两种情况下,我们都是用getApolloClient 来实例化apolloClient 。在getStaticPaths ,将fallback 设置为false ,这样任何没有生成的路径都会返回404 的错误。确保前台和后台的开发服务器正在运行。在你的浏览器上打开http://localhost:3000 。在主页上,点击任何一篇文章的标题,你将被重定向到它的具体页面,如是。

article-spec-page

结论

我们用Next.js、TypeScript、Apollo客户端、Apollo服务器和FaunaDB构建了一个博客应用程序。请参考进一步阅读部分,了解本主题中使用的技术和技巧的更多信息。