使用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数据库。

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

输入集合的名称,然后点击保存。集合将被创建,你将被重定向到集合页面。
Fauna将数据和信息保存在 "文档 "中。如果你习惯于使用其他数据库,集合中的单个文档就相当于表格中的行。到现在为止,它不会有任何文档。

设置后端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数据库的仪表盘,然后前往安全部分。

你现在可能没有任何密钥,点击新建密钥,输入任何密钥名称,然后点击保存。复制新页面上出现的密钥并将其粘贴到上述代码的秘密部分。
域名将由你选择的地区决定。例如,如果你选择了美国,域名将是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 字段创建一篇文章。然后,返回创建的文章。如果有任何错误,则返回该特定的错误信息。
使用下面的解析器将上述函数连接到Query 或Mutation 对象。
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
}
}
我们正在将我们为Query 和Mutation 定义的类型连接到上面各自的解析器函数。
实例化Apollo服务器。
const server = new ApolloServer({typeDefs,resolvers,cors:{
credentials:true,
origin:'*'
}});
我们通过发送我们的type definitions 、resolvers ,并设置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

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

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

请随意与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()
});
};
上述函数通过传入我们的ApolloServer 的URI 和cache ,来实例化一个ApolloClient ,ApolloClient 将保存其缓存的查询。导航到pages/_app.tsx ,导入ApolloProvider 和getApolloClient 函数,如下所示。
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 目录。在它里面,创建一个navbar 和footer 目录。在navbar 目录内,创建一个Navbar.tsx 和Navbar.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 配置,为Navbar 和Footer 页面设置动态内容,以及一些基本的样式。
将上面的Layout 导入到pages/_app.tsx 文件中。然后在ApolloProvider 里面用Layout 包裹Component ,这样Navbar 和Footer 就可以在所有页面上持续存在。
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 。

添加一篇文章
为了处理这个操作,浏览到项目文件夹的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 function 从useMutation 向服务器发送请求。当文章成功提交时,我们重置状态并显示一个成功消息。
否则,如果发生错误,我们就设置一个表单错误,并直接从useMutation 显示错误。 确保前置和后置的开发服务器正在运行。在浏览器上打开http://localhost:3000 ,在导航栏上点击Add article 。

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

转到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
}
}
在这里,我们正在创建两个查询,一个是在获取单一文章时,另一个是在获取许多文章时。我们使用两种方法来获取数据。getStaticProps 和getStaticPaths 。getStaticProps 将被用来从服务器端获取文章,而getStaticPaths 将在构建时获取文章。
在这两种情况下,我们都是用getApolloClient 来实例化apolloClient 。在getStaticPaths ,将fallback 设置为false ,这样任何没有生成的路径都会返回404 的错误。确保前台和后台的开发服务器正在运行。在你的浏览器上打开http://localhost:3000 。在主页上,点击任何一篇文章的标题,你将被重定向到它的具体页面,如是。

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