译者 | 朱先忠
审校 | 孙淑娟
在本文中,我们将学习如何使用Next.js、Prisma、Postgres和Fastify来联合开发一个完整的全栈Web应用程序。具体地说,我们将构建一个考勤管理演示应用程序,用于管理员工的考勤信息。该应用程序的流程比较简单:一个管理用户登录页面,创建当天的考勤表界面,还有每个员工可以在考勤表上登录和注销的界面等。
何谓Next.js?
Next.js是一个灵活的基于React框架的工具,它能够为您提供创建快速Web应用程序的组件。它通常被称为全栈式React框架,因为它可以使前端和后端应用程序位于同一个代码基上;并且,这种实现使用的是无服务器端(Serverless)功能。
何谓Prisma?
Prisma是一个开源的ORM框架,同样基于Node.js框架和Typescript脚本实现。Prisma大大简化了SQL数据库的数据建模、迁移和数据访问过程。截止撰写本文时,Prisma支持以下数据库管理系统:PostgreSQL、MySQL、MariaDB、SQLite、AWS Aurora、Microsoft SQL Server、Azure SQL和MongoDB。当然,有关Prisma所有受支持的数据库管理系统的列表信息,您可以参考地址https://www.prisma.io/docs/reference/database-reference/supported-databases。
何谓Postgres?
Postgres也称为PostgreSQL,是一个免费开源的关系数据库管理系统。它是SQL语言的超集,具有许多优秀特性,允许开发人员安全地存储和扩展复杂的数据工作负载。
示例项目开发先决条件
本文是一个实践演示教程。因此,为了顺利调试通过这个项目,最好确保先在您的计算机上安装以下软件:
- Node.js已经成功地安装在您的计算机上
- PostgreSQL数据库服务器正运行在您的计算机上
注意:本教程的代码可以在Github网站上找到;所以,您可以随意克隆下所有源码并继续学习。
项目设置
让我们从设置Next.js应用程序开始。首先,请运行下面的命令。
npx create-next-app@latest
等待安装完成,然后运行下面的命令来安装依赖项。
yarn add fastify fastify-nextjs iron-session @prisma/client
yarn add prisma nodemon --dev
等待安装完成即可。
设置Next.js和Fastify
默认情况下,Next.js不使用Fastify作为其服务器。为了使用Fastfy作为我们的Next.js应用程序的服务器,需要在你的package.json配置文件中添加以下代码段:
"scripts": {
"dev": "nodemon server.js",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
创建我们的Fastify服务器
接下来,我们创建一个名字为server.js的文件。这个文件是我们应用程序的入口点。然后,我们添加命令require('fastfy-nextjs'),以便包括一个特定的插件,此插件能够暴露Fastify中的Next.js API来处理页面的渲染任务。
接下来,打开server.js文件,并添加以下代码段:
const fastify = require('fastify')()
async function noOpParser(req, payload) {
return payload;
}
fastify.register(require('fastify-nextjs')).after(() => {
fastify.addContentTypeParser('text/plain', noOpParser);
fastify.addContentTypeParser('application/json', noOpParser);
fastify.next('/*')
fastify.next('/api/*', { method: 'ALL' });
})
fastify.listen(3000, err => {
if (err) throw err
console.log('Server listening on <http://localhost:3000>')
})
在上面代码片断中,我们使用插件fastify-nextjs来暴露Fastify中的Next.js API,以便帮助我们完成渲染任务。然后,我们使用noOpParser函数分析发来的请求。具体地说,此函数负责在我们的Next.js API路由处理器中可以使用请求体中的内容。注意到,这里我们通过命令[fastify.next](<http://fastify.next>定义了程序中的两个路由。然后我们创建了Fastify服务器,并让它监听端口3000。
接下来,我们使用“yarn dev”命令运行上面的应用程序。于是,程序会在地址localhost:3000上运行起来。
Prisma设置
首先,运行以下命令以获得基本的Prisma设置:
npx prisma init
上面的命令将创建一个名字为Prisma的目录,其下还有一个相应的配置文件名是schema.prisma。此文件是您的主Prisma配置文件,其中将包含您的数据库模式。此外,一个.env文件也将添加到项目的根目录中。注意,您需要打开这个.env文件,并将虚拟连接URL替换为PostgreSQL数据库的真实连接URL。
现在,把prisma/schema.prisma文件中的内容替换成如下代码:
datasource db {
url = env("DATABASE_URL")
provider="postgresql"
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
email String @unique
name String
password String
role Role @default(EMPLOYEE)
attendance Attendance[]
AttendanceSheet AttendanceSheet[]
}
model AttendanceSheet {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User? @relation(fields: [userId], references: [id])
userId Int?
}
model Attendance {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
signIn Boolean @default(true)
signOut Boolean
signInTime DateTime @default(now())
signOutTime DateTime
user User? @relation(fields: [userId], references: [id])
userId Int?
}
enum Role {
EMPLOYEE
ADMIN
}
在上面的代码片段中,我们创建了一个用户,一个考勤表AttendanceSheet和Attention模型,并定义了每个模型之间的关系。
接下来,需要在数据库中创建表格。请运行以下命令:
npx prisma db push
运行上述命令后,您应该会在终端中看到如下屏幕截图所示的输出:
创建实用工具函数
Prisma设置完成后,让我们创建三个实用函数,它们将不时在我们的应用程序中使用。
为此,打开文件lib/parseBody.js,并添加以下代码段。此函数的任务是将请求正文解析为JSON:
export const parseBody = (body) => {
if (typeof body === "string") return JSON.parse(body)
return body
}
然后,打开/lib/request.js文件,添加以下代码段。此函数负责返回iron-session的会话属性对象。
export const sessionCookie = () => {
return ({
cookieName: "auth",
password: process.env.SESSION_PASSWORD,
// 安全提示:在生产环境(使用HTTPS协议)中应当把secure设置为true,但是不能在开发环境(HTTP)下使用true
cookieOptions: {
secure: process.env.NODE_ENV === "production",
},
})
}
接下来,将SESSION_PASSWORD添加到.env文件:它应该是至少32个字符的字符串。
设计应用程序的样式
完成上面的实用函数开发后,让我们为应用程序添加一些样式。我们将为这个应用程序定义几个CSS模块。为此,打开styles/Home.modules.css文件,并添加以下代码段:
.container {
padding: 0 2rem;
}
.man {
min-height: 100vh;
padding: 4rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
创建边栏组件
造型完成后,让我们创建边栏组件,以便帮助我们导航到应用程序控制面板上的不同页面。为此,打开components/SideBar.js文件,并粘贴下面的代码段。
import Link from 'next/link'
import { useRouter } from 'next/router'
import styles from '../styles/SideBar.module.css'
const SideBar = () => {
const router = useRouter()
const logout = async () => {
try {
const response = await fetch('/api/logout', {
method: 'GET',
credentials: 'same-origin',
});
if(response.status === 200) router.push('/')
} catch (e) {
alert(e)
}
}
return (
<nav className={styles.sidebar}>
<ul>
<li> <Link href="/dashboard"> Dashboard</Link> </li>
<li> <Link href="/dashboard/attendance"> Attendance </Link> </li>
<li> <Link href="/dashboard/attendance-sheet"> Attendance Sheet </Link> </li>
<li onClick={logout}> Logout </li>
</ul>
</nav>
)
}
export default SideBar
开发登录页面
现在打开page/index.js文件,删除其中默认的所有代码并添加以下代码段。下面的代码将post请求与通过表单提供的电子邮件和密码一起发送到localhost:3000/api/login路由。一旦凭据验证为有效,它就会调用router.push('/dashboard')方法;此方法负责把用户重定向到localhost:3000/api/dashboard:
import Head from 'next/head'
import { postData } from '../lib/request';
import styles from '../styles/Home.module.css'
import { useState } from 'react';
import { useRouter } from 'next/router'
export default function Home({posts}) {
const [data, setData] = useState({email: null, password: null});
const router = useRouter()
const submit = (e) => {
e.preventDefault()
if(data.email && data.password) {
postData('/api/login', data).then(data => {
console.log(data);
if (data.status === "success") router.push('/dashboard')
});
}
}
return (
<div className={styles.container}>
<Head>
<title>Login</title>
<meta name="description" content="Login" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<form className={styles.login}>
<input
type={"text"}
placeholder="Enter Your Email"
onChange={(e) => setData({data, email: e.target.value})} />
<input
type={"password"}
placeholder="Enter Your Password"
onChange={(e) => setData({data, password: e.target.value})} />
<button onClick={submit}>Login</button>
</form>
</main>
</div>
)
}
设置登录API路由
现在打开页面page/api/login.js,并添加以下代码段。我们将使用PrismaClient进行数据库查询。其中,withIronSessionApiRoute是在RESTful应用程序中用来负责处理用户会话的iron-session函数。
该路由处理通过localhost:3000/api/login登录后的POST请求,并在用户经过身份验证后生成身份验证Cookie。
import { PrismaClient } from '@prisma/client'
import { withIronSessionApiRoute } from "iron-session/next";
import { parseBody } from '../../lib/parseBody';
import { sessionCookie } from '../../lib/session';
export default withIronSessionApiRoute(
async function loginRoute(req, res) {
const { email, password } = parseBody(req.body)
const prisma = new PrismaClient()
//按唯一标识符
const user = await prisma.user.findUnique({
where: {
},})
if(user.password === password) {
//从数据库中获取用户,然后:
user.password = undefined
req.session.user = user
await req.session.save();
return res.send({ status: 'success', data: user });
};
res.send({ status: 'error', message: "incorrect email or password" });
},
sessionCookie(),
);
设置注销API路由
打开/page/api/logout文件并添加下面的代码段。此路由负责处理对localhost:3000/api/logout的GET请求,该请求通过销毁会话Cookie注销用户。
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionCookie } from "../../lib/session";
export default withIronSessionApiRoute(
function logoutRoute(req, res, session) {
req.session.destroy();
res.send({ status: "success" });
},
sessionCookie()
);
创建控制面板页面
此页面为用户提供了登录和注销考勤表的界面。当然,管理员还可以通过此界面创建考勤表。现在,打开page/dashboard/index.js文件,并添加下面代码段。
import { withIronSessionSsr } from "iron-session/next";
import Head from 'next/head'
import { useState, useCallback } from "react";
import { PrismaClient } from '@prisma/client'
import SideBar from '../../components/SideBar'
import styles from '../../styles/Home.module.css'
import dashboard from '../../styles/Dashboard.module.css'
import { sessionCookie } from "../../lib/session";
import { postData } from "../../lib/request";
export default function Page(props) {
const [attendanceSheet, setState] = useState(JSON.parse(props.attendanceSheet));
const sign = useCallback((action="") => {
const body = {
attendanceSheetId: attendanceSheet[0]?.id,
action
}
postData("/api/sign-attendance", body).then(data => {
if (data.status === "success") {
setState(prevState => {
const newState = [prevState]
newState[0].attendance[0] = data.data
return newState
})
}
})
}, [attendanceSheet])
const createAttendance = useCallback(() => {
postData("/api/create-attendance").then(data => {
if (data.status === "success") {
alert("New Attendance Sheet Created")
setState([{data.data, attendance:[]}])
}
})
}, [])
return (
<div>
<Head>
<title>Attendance Management Dashboard</title>
<meta name="description" content="dashboard" />
</Head>
<div className={styles.navbar}></div>
<main className={styles.dashboard}>
<SideBar />
<div className={dashboard.users}>
{
props.isAdmin && <button className={dashboard.create} onClick={createAttendance}>Create Attendance Sheet</button>
}
{ attendanceSheet.length > 0 &&
<table className={dashboard.table}>
<thead>
<tr>
<th>Id</th> <th>Created At</th> <th>Sign In</th> <th>Sign Out</th>
</tr>
</thead>
<tbody>
<tr>
<td>{attendanceSheet[0]?.id}</td>
<td>{attendanceSheet[0]?.createdAt}</td>
{
attendanceSheet[0]?.attendance.length != 0 ?
<>
<td>{attendanceSheet[0]?.attendance[0]?.signInTime}</td>
<td>{
attendanceSheet[0]?.attendance[0]?.signOut ?
attendanceSheet[0]?.attendance[0]?.signOutTime: <button onClick={() => sign("sign-out")}> Sign Out </button> }</td>
</>
:
<>
<td> <button onClick={() => sign()}> Sign In </button> </td>
<td>{""}</td>
</>
}
</tr>
</tbody>
</table>
}
</div>
</main>
</div>
)
}
我们使用getServerSideProps函数来生成页面数据,而withIronSessionSsr是一个用于处理服务器端呈现页面功能的iron-session函数。在下面的代码段中,我们使用数据库考勤表中的一行查询考勤表的最后一行。其中,userId等于存储在用户会话中的用户id。我们还检查用户是否是管理员(ADMIN)角色。
export const getServerSideProps = withIronSessionSsr( async ({req}) => {
const user = req.session.user
const prisma = new PrismaClient()
const attendanceSheet = await prisma.attendanceSheet.findMany({
take: 1,
orderBy: {
id: 'desc',
},
include: {
attendance: {
where: {
userId: user.id
},
}
}
})
return {
props: {
attendanceSheet: JSON.stringify(attendanceSheet),
isAdmin: user.role === "ADMIN"
}
}
}, sessionCookie())
设置创建考勤API路由
打开页面/api/create Attention.js文件,并添加下面代码段。
import { PrismaClient } from '@prisma/client'
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionCookie } from '../../lib/session';
export default withIronSessionApiRoute( async function handler(req, res) {
const prisma = new PrismaClient()
const user = req.session.user
const attendanceSheet = await prisma.attendanceSheet.create({
data: {
userId: user.id,
},
})
res.json({status: "success", data: attendanceSheet});
}, sessionCookie())
设置签名考勤API路由
此路由负责处理我们对localhost:3000/api/sign-attendance的API POST请求。路由接受POST请求,而attendanceSheetId和action用于登录和注销attendanceSheet。
打开/page/api/sign-attendance.js文件,并添加下面的代码段。
import { PrismaClient } from '@prisma/client'
import { withIronSessionApiRoute } from "iron-session/next";
import { parseBody } from '../../lib/parseBody';
import { sessionCookie } from '../../lib/session';
export default withIronSessionApiRoute( async function handler(req, res) {
const prisma = new PrismaClient()
const {attendanceSheetId, action} = parseBody(req.body)
const user = req.session.user
const attendance = await prisma.attendance.findMany({
where: {
userId: user.id,
attendanceSheetId: attendanceSheetId
}
})
//check if atendance have been created
if (attendance.length === 0) {
const attendance = await prisma.attendance.create({
data: {
userId: user.id,
attendanceSheetId: attendanceSheetId,
signIn: true,
signOut: false,
signOutTime: new Date()
},
})
return res.json({status: "success", data: attendance});
} else if (action === "sign-out") {
await prisma.attendance.updateMany({
where: {
userId: user.id,
attendanceSheetId: attendanceSheetId
},
data: {
signOut: true,
signOutTime: new Date()
},
})
return res.json({status: "success", data: { attendance[0], signOut: true, signOutTime: new Date()}});
}
res.json({status: "success", data:attendance});
}, sessionCookie())
创建考勤页面
这个服务器端呈现的页面将显示登录用户的所有考勤表信息。打开/page/dashboard/attendance.js文件,并添加下面的代码段。
import { withIronSessionSsr } from "iron-session/next";
import Head from 'next/head'
import { PrismaClient } from '@prisma/client'
import SideBar from '../../components/SideBar'
import styles from '../../styles/Home.module.css'
import dashboard from '../../styles/Dashboard.module.css'
import { sessionCookie } from "../../lib/session";
export default function Page(props) {
const data = JSON.parse(props.attendanceSheet)
return (
<div>
<Head>
<title>Attendance Management Dashboard</title>
<meta name="description" content="dashboard" />
</Head>
<div className={styles.navbar}></div>
<main className={styles.dashboard}>
<SideBar />
<div className={dashboard.users}>
<table className={dashboard.table}>
<thead>
<tr>
<th> Attendance Id</th> <th>Date</th>
<th>Sign In Time</th> <th>Sign Out Time</th>
</tr>
</thead>
<tbody>
{
data.map(data => {
const {id, createdAt, attendance } = data
return (
<tr key={id}>
<td>{id}</td> <td>{createdAt}</td>
{ attendance.length === 0 ?
(
<>
<td>You did not Sign In</td>
<td>You did not Sign Out</td>
</>
)
:
(
<>
<td>{attendance[0]?.signInTime}</td>
<td>{attendance[0]?.signOut ? attendance[0]?.signOutTime : "You did not Sign Out"}</td>
</>
)
}
</tr>
)
})
}
</tbody>
</table>
</div>
</main>
</div>
)
}
在下面的代码片段中,我们从attendanceSheet表中查询所有行,并获取用户id等于存储在用户会话中的用户id的考勤信息。
export const getServerSideProps = withIronSessionSsr( async ({req}) => {
const user = req.session.user
const prisma = new PrismaClient()
const attendanceSheet = await prisma.attendanceSheet.findMany({
orderBy: {
id: 'desc',
},
include: {
attendance: {
where: {
userId: user.id
},
}
}
})
return {
props: {
attendanceSheet: JSON.stringify(attendanceSheet),
}
}
}, sessionCookie())
创建考勤表页面
这个服务器端呈现的页面负责显示所有考勤表以及登录到该考勤表的员工信息。为此,打开/page/dashboard/attendance.js文件,并添加下面的代码段。
import { withIronSessionSsr } from "iron-session/next";
import Head from 'next/head'
import { PrismaClient } from '@prisma/client'
import SideBar from '../../components/SideBar'
import styles from '../../styles/Home.module.css'
import dashboard from '../../styles/Dashboard.module.css'
import { sessionCookie } from "../../lib/session";
export default function Page(props) {
const data = JSON.parse(props.attendanceSheet)
return (
<div>
<Head>
<title>Attendance Management Dashboard</title>
<meta name="description" content="dashboard" />
</Head>
<div className={styles.navbar}></div>
<main className={styles.dashboard}>
<SideBar />
<div className={dashboard.users}>
{
data?.map(data => {
const {id, createdAt, attendance } = data
return (
<>
<table key={data.id} className={dashboard.table}>
<thead>
<tr>
<th> Attendance Id</th> <th>Date</th>
<th> Name </th> <th> Email </th> <th> Role </th>
<th>Sign In Time</th> <th>Sign Out Time</th>
</tr>
</thead>
<tbody>
{
(attendance.length === 0) &&
(
<>
<tr><td> {id} </td> <td>{createdAt}</td> <td colSpan={5}> No User signed this sheet</td></tr>
</>
)
}
{
attendance.map(data => {
const {name, email, role} = data.user
return (
<tr key={id}>
<td>{id}</td> <td>{createdAt}</td>
<td>{name}</td> <td>{email}</td>
<td>{role}</td>
<td>{data.signInTime}</td>
<td>{data.signOut ? attendance[0]?.signOutTime: "User did not Sign Out"}</td>
</tr>
)
})
}
</tbody>
</table>
</>
)
})
}
</div>
</main>
</div>
)
}
在下面的代码片段中,我们从attendanceSheet表中查询所有行,并通过选择名称、电子邮件和角色来获取考勤信息。
export const getServerSideProps = withIronSessionSsr(async () => {
const prisma = new PrismaClient()
const attendanceSheet = await prisma.attendanceSheet.findMany({
orderBy: {
id: 'desc',
},
include: {
attendance: {
include: {
user: {
select: {
name: true,
email: true,
role: true
}
}
}
},
},
})
return {
props: {
attendanceSheet: JSON.stringify(attendanceSheet),
}
}
}, sessionCookie())
测试应用程序
最后,我们来测试一下上面完整的示例应用程序。首先,我们必须向数据库中添加用户数据。我们是使用Prisma Studio来实现此任务的。要启动Prisma Studio,请运行以下命令:
npx prisma studio
最终,Prisma索引页面如下所示:
要创建数据库用户,要求使用管理员(ADMIN)角色;而创建普通类型的多个用户,只需要使用员工(EMPLOYEE)角色即可。为此,请切换到以下页面:
单击添加记录(Add record)按钮,然后填写所需字段:密码、名称、电子邮件和所属于角色等信息。完成后,单击绿色的保存变更(Save 1 change)按钮。注意,为了简单起见,我们没有对密码字段信息进行散列处理。
然后,使用“yarn dev”命令启动服务器。通过此命令将在本地地址[localhost:3000]上启动服务器,并启动应用程序登录页面如下所示:
现在,请使用具有管理员(ADMIN)角色的用户登录,因为只有管理员用户才能创建考勤表。登录成功后,应用程序会将您重定向到系统的控制面板界面。
在这个管理界面中,单击创建考勤表(Create Attendance Sheet)按钮即可创建一个考勤表,然后等待向服务器端发出的请求完成。成功后,考勤表显示出来。用户控制面板界面如下所示:
考勤表如下图所示,单击“登录”(Sign In)按钮进行登录。登录成功后,将显示登录时间并显示注销(Sign Out)按钮。您可以单击注销按钮注销,并使用不同的用户信息进行多次登录测试。
接下来,您可以单击侧栏中的考勤链接以查看用户的考勤信息。结果应符合以下显示的内容:
接下来,单击侧栏上的考勤表(Attendance Sheet)链接,可以查看所有用户的考勤情况。结果如下:
结论
在本文中,我们探讨了如何配合Next.js使用自定义Fastify服务器。然后,还介绍了Prisma和Prisma Studio有关知识,还介绍了如何将Prisma连接到Postgres数据库,以及如何使用Prisma客户端和Prisma Studio来创建、读取和更新数据库的问题。此外,您还学习了如何使用iron-session对用户进行身份验证。
最后,在本文的主体示例项目中,我们联合Next.js、Prisma、Postgres和Fastfy构建了一款完整的员工考勤管理应用程序。
译者介绍
朱先忠,51CTO社区编辑,51CTO专家博客、讲师,潍坊一所高校计算机教师,自由编程界老兵一枚。早期专注各种微软技术(编著成ASP.NET AJX、Cocos 2d-X相关三本技术图书),近十多年投身于开源世界(熟悉流行全栈Web开发技术),了解基于OneNet/AliOS+Arduino/ESP32/树莓派等物联网开发技术与Scala+Hadoop+Spark+Flink等大数据开发技术。
原文标题:How to Build a Full-Stack App With Next.js, Prisma, Postgres, and Fastify,作者:Clara Ekekenta