读者好,我是 Randy, 一个前端开发工程师,非常感谢你对这本书有兴趣。这是一本十分迷你的书,很快你就可以把它读完。
我在业余的时间做了很多全栈 SaaS 应用,这些应用都是使用 Next.js 来做的。Next.js 的好处是它让你不需要关注业务开发无关的东西(例如前端构建配置,前后端结合的胶水代码等等),你可以专心编写你的 React 页面,以及所需要的 HTTP API. 更神奇的是,前后端可以共用一份 TypeScript 类型声明,让你在写代码的时候更安全。
但事实上在头几次用 Next.js 开发应用的时候,它并没有我想像中那么好用。在像 Express/Koa/Nest/Hapi 这些框架,它们都自带了 Web Server 开发的一些 helper. 例如利用它们的中间件机制,可以实现面向切面编程(AOP),大量地降低编写重复代码的情况;它们都自带了统一的错误处理机制,你可以在程序的任何角落抛出一个 HTTP 异常。但在 Next.js 里,这些都是缺失的 —— 它没有中间件机制,也没有错误处理的机制(所有 throw error 在 Next.js 的 API route 都会向客户端响应一个无意义的 500 Internal Server Error)。甚至在 Next.js 里,也没有自带的向客户端写入 cookies 的 helper. 这使得我在实现用户登录的时候,要研究写入 cookies 的最佳实践。
全栈应用也少不了前端页面的开发。数据请求我应该直接用 axios
, 还是使用像 react-query
和 swr
这样的数据请求封装?它们两个我应该用哪个?在复杂的项目里,我应该如何正确地使用它们?这些都是我在头几次开发时候不知道的。
还好,在做了几个项目之后,我大概总结了一些非常有用的「最佳实践」,把上面提到的很多在 Next.js 中缺失的东西,以及在前端开发中对数据请求的处理,都研究出了很好的解决方法。这让我在接下来开发新的应用的时候非常高效,而且写出来的 HTTP API 的健壮性可以和用传统 Web 框架(Express/Koa/Hapi)写出来的匹敌。当然我的这些最佳实践就是借鉴它们的。
本来在开发完 Cusdis 后我打算开始开发下一个 SaaS 应用,但仔细一想,不如先把我用 Next.js 开发全栈应用的这些宝贵经验总结起来,写成一本小书,分享给更多人。于是就有了你现在正在读的这一本小书。
这本书面向的读者,是那些想要或者已经正在使用 Next.js 开发一个全栈应用的开发者。如果你从来没有用 Next.js 完整开发过一个应用,你可以在本书看到一个大概的流程和实践。如果你已经有一定的使用经验,那么你可能仍然可以从这本书的例子中得到启发,让你可以把一些重复的代码变得通用。
这不是一本教你什么是 Next.js 的教程。我不会在这本书教你什么是 getServerSideProps
, 什么是 res.status()
, 更不会教你什么是箭头函数,什么是 async/await. 我假定读者已经知道这些基本的东西,我也不认为你应该把钱花在一本会变的书里学习这些经常会变的东西,你应该在它们的官方文档中学习。这就是为什么这本书这么便宜,因为在很多技术书里,读者会为很多在官方文档里已经有的东西买单。这本书只兜售在文档中没有的实践经验。
在头几个章节,我会大致介绍一些技术:
你会发现我并没有花很多笔墨去讲解这些库(再次重申,你应该在它们的官方文档去学习它们的 API, 而不是在一本书里)。我只会重点讲解它们是什么,它们解决了什么问题,它们如何与 Next.js 整合。
因为本书的精华部分在后面的两个「实例」,你可以从两个「实例」中学习到如何在 Next.js 中应用这些技术,从而使你的开发效率倍增。也就是说,头几个介绍这些库的章节,只是在为后面的两个「实例」作铺垫。我希望读者可以从这两个「实例」中提炼出其中那些不变的开发思路。
你可以免费阅读本书的 HTML 版本。也可以可以在 这里 购买这本书的 PDF 版本。 本书定价 12 元人民币,这是因为我人生中买的第一本编程书就是 12 块钱,仅以此对那本启蒙我的读物致敬。
如果你希望转载这本小书的某些章节,请联系我的邮箱,支付 30 元人民币作为转载费用。
如果读完本书后,你学到了一点什么,请不吝在 Twitter 上给我反馈,我的 Twitter ID 是 @randyloop. 又或者直接给我 ([email protected]) 写信。如果可以为此写一篇博客,那就更好了。
倘若你读完觉得这本书是垃圾,我只能说我为这本垃圾所花的时间和精力,应该也还是值 12 块钱。
无论如何,感谢你的阅读。
在这一章节,将介绍前端数据请求的方案。为什么前端数据请求需要专门讨论?甚至需要用一个专门的库?我们先来看一个最简单的,用 axios
发送请求的例子,我们通过请求 GET https://jsonplaceholder.typicode.com/posts
获取所有文章,通过 POST https://jsonplaceholder.typicode.com/posts
添加一篇文章:
xxxxxxxxxx
481import axios from 'axios'
2import React from 'react'
3
4async function getPosts() {
5 const result = await axios.get('https://jsonplaceholder.typicode.com/posts?_limit=5')
6 return result.data
7}
8
9async function addPost(title, body) {
10 await axios.post('https://jsonplaceholder.typicode.com/posts', {
11 title, body
12 })
13}
14
15function IndexPage() {
16
17 const [posts, setPosts] = React.useState([])
18
19 async function initial() {
20 const result = await getPosts()
21 setPosts(result)
22 }
23
24 React.useEffect(() => {
25 initial()
26 }, [])
27
28 async function onClickAddPost() {
29 await addPost('foo', 'bar')
30 }
31
32 return (
33 <>
34 <div>
35 {posts.map(post => {
36 return (
37 <div key={post.id}>
38 {post.title}
39 </div>
40 )
41 })}
42 </div>
43 <button onClick={onClickAddPost}>add post</button>
44 </>
45 )
46}
47
48export default IndexPage
在这个例子里,共有两个请求,一个是用于获取所有文章的 GET 请求,另一个是用于创建文章 的 POST 请求。
在真实的应用中,我们通常需要知道每个异步请求的状态,以便显示不同的 UI. 例如,在获取所有文章时,如果请求尚未结束,则在 UI 中显示 <div>Loading...</div>
. 另外,一个异步请求还有成功和失败的状态,分别也有对应的 UI.
所以,每一个异步请求,实际上是一个独立的「状态机」,它们都在几个状态中流转:
isLoading
: 请求是否在进行data
: 如果请求成功,它的响应数据error
: 如果请求失败,它的响应数据如果每一个异步请求都要我们都要为它们写维护这些状态的代码,那么代码量会非常多,而且都是基本相同的逻辑。
当然,我们可以把这些状态封装成一个可复用的 hooks. 但数据请求还面临另一个问题:当一个请求完成之后,它可能需要更新其它的请求数据。例如在上面的例子,当我们通过接口创建了一篇文章后,另一个获取文章的接口数据已经不是最新的了,如果我们想在 UI 尽快保持显示最新的数据,我们必须重新请求已经过期的那些数据对应的请求。然而异步请求散落在整个应用的不同地方,很难把它们都一起更新。
react-query
能很好地解决这些问题。
在 Next.js 中引入 react-query
xxxxxxxxxx
11$ yarn add react-query
在
pages/_app.tsx
中, 在顶层组件套上QueryClientProvider
:xxxxxxxxxx
151// pages/_app.tsx
2
3import React from 'react'
4import { QueryClient, QueryClientProvider } from 'react-query'
5
6export const queryClient = new QueryClient()
7
8export default function MyApp({ Component, pageProps }) {
9
10return (
11<QueryClientProvider client={queryClient}>
12<Component {pageProps} />
13</QueryClientProvider>
14)
15}
在 react-query
中,有两个关键的概念 —— Query
和 Mutation
.
任何获取数据的请求都是 Query
, 下面是用 react-query
获取所有文章数据的例子:
xxxxxxxxxx
331// pages/index.tsx
2
3import axios from 'axios'
4import React from 'react'
5import { useQuery } from 'react-query'
6
7async function getPosts() {
8 const result = await axios.get('https://jsonplaceholder.typicode.com/posts?_limit=5')
9 return result.data
10}
11
12function IndexPage() {
13
14 const getPostsQuery = useQuery('getPosts', getPosts)
15
16 return (
17 <>
18 <div>
19 {getPostsQuery.isLoading && <div>Loading...</div>}
20 {getPostsquery.error && <div>Something error</div>}
21 {getPostsQuery.data?.map(post => {
22 return (
23 <div key={post.id}>
24 {post.title}
25 </div>
26 )
27 })}
28 </div>
29 </>
30 )
31}
32
33export default IndexPage
和之前的例子一样,在 getPosts()
中我们用 axios
发起了一个 GET 请求。不同的是,我们使用 useQuery()
把这个请求方法套了一层,并给了它一个 key: getPosts
. 这个 key 的作用,我们之后会提到。
从上面的代码可以看到,用 useQuery()
这个 hooks 包装过的请求,他有 isLoading
, error
, data
这三个属性,我们不需要手动地管理这些状态,react-query
会处理好它们,我们只需要在 UI 上获取它们并按照我们的业务逻辑进行显示就可以了。
上面的例子中,
getPostsQuery.data?.map
用了 optional chaining 的语法。因为在异步请求完成之前还没有响应的数据,它有可能是空的,这时如果用data.map
, 会抛出异常。用 optional chaining 可以避免这种错误.
另外,react-query
会自动替你缓存 Query 的返回,当在另一个组件中,同样有 Query 使用了 getPosts
这个 key, react-query
会首先返回这个 key 对应的缓存数据,用户就可以直接看到数据,而非一个 loading 状态了。这就是 query key 的作用,他可以给请求一个标识,方便后续我们可以用这个标识对请求进行操作和优化。
对资源进行修改的请求(POST
, PUT
, DELETE
等)都是 Mutation
. 下面是在我们上面的例子中,加入了创建文章这个请求,像 useQuery
一样,用 useMutation()
嵌套这个请求方法。
xxxxxxxxxx
471// pages/index.tsx
2
3import axios from 'axios'
4import React from 'react'
5import { useMutation, useQuery } from 'react-query'
6
7async function getPosts() {
8 const result = await axios.get('https://jsonplaceholder.typicode.com/posts?_limit=5')
9 return result.data
10}
11
12async function addPost({ title, body }) {
13 await axios.post('https://jsonplaceholder.typicode.com/posts', {
14 title, body
15 })
16}
17
18function IndexPage() {
19
20 const getPostsQuery = useQuery('getPosts', getPosts)
21
22 const addPostMutation = useMutation(addPost)
23
24 function onClickAddPost() {
25 addPostMutation.mutate({ title: 'foo', body: 'bar' })
26 }
27
28 return (
29 <>
30 <div>
31 {getPostsQuery.isLoading && <div>Loading...</div>}
32 {getPostsQuery.data?.map(post => {
33 return (
34 <div key={post.id}>
35 {post.title}
36 </div>
37 )
38 })}
39
40 {addPostMutation.isLoading && <div>Adding post...</div>}
41 <button onClick={onClickAddPost}>Add post</button>
42 </div>
43 </>
44 )
45}
46
47export default IndexPage
和 Query
相同,Mutation
同样有 isLoading
, error
, data
这些状态。在点击 button
后,我们用 .muate()
方法执行这个请求,并且传入了一些变量,这些变量会被传递到方法 addPost
中。
我们还可以在 .muate()
中传入 onSuccess
和 onError
, 分别指定在请求成功和请求错误后执行的逻辑。比如弹一个 toast 之类的。
xxxxxxxxxx
101function onClickAddPost() {
2 addPostMutation.mutate({ title: 'foo', body: 'bar' }, {
3 onSuccess(data) {
4 // ...
5 },
6 onError(err) {
7 // ...
8 }
9 })
10}
上面我们提到过,当用户创建了新文章后,理论上请求所有文章这个请求的数据就已经不是最新的了。这时如果我们想再次请求获取所有文章这个接口,可以在 onSuccess
中,手动地执行这个请求的 refetch
方法:
xxxxxxxxxx
101function onClickAddPost() {
2 addPostMutation.mutate({ title: 'foo', body: 'bar' }, {
3 onSuccess(data) {
4+ getPostsQuery.refetch()
5 },
6 onError(err) {
7 // ...
8 }
9 })
10}
但这不是一个很好的实践,想像一下,这个获取文章的接口,有可能在这个应用中的多个组件用到,难道我们需要一个一个地执行 refetch
吗?当然不是。这时就要介绍 react-query
的一个重要功能 —— Query Invalidation
.
Query Invalidation
的意思是「使请求失效」。我们可以用 queryClient
实例的 invalidateQueries()
方法,使某个 key 的请求失效。react-query
会自动重新请求失效的 Query.
xxxxxxxxxx
121+ import { queryClient } from './_app'
2function onClickAddPost() {
3 addPostMutation.mutate({ title: 'foo', body: 'bar' }, {
4 onSuccess(data) {
5- getPostsQuery.refetch()
6+ queryClient.invalidateQueries('getPosts')
7 },
8 onError(err) {
9 // ...
10 }
11 })
12}
我们用 queryClient.invalidateQueries('getPosts')
使用 key 为 getPosts
的 Query 失效,那么所有用到 key 为 getPosts
的 Query 都会被重新请求,并更新最新的数据到 UI 上。
Next.js 没有中间件机制。首先让我简单解释一下什么是中间件,为什么我们需要中间件。
在 Express/Koa, 我们可以用中间件进入一个请求的生命周期,一个典型的中间件是一个函数,它接受 req
, res
和 next
参数:
xxxxxxxxxx
91function exampleMiddleware(req, res, next) {
2 if (/** ...*/) {
3 req.foo = 'bar'
4 next()
5 } else {
6 res.statusCode = 403
7 res.send('forbiddon')
8 }
9}
这个中间件的模式起源于一个 Node.js Web 框架 connect, 早期的 Express 也基于 Connect 开发,于是很多框架也兼容了这种模式,所以这种中间件模式我们通常称为 connect 中间件。
在中间件里,我们可以:
req
对象注入一些属性,这些属性可以被下一个中间件或者 controller 获取到。next()
来中止请求,同时修改 res
的属性从而改变 response 的状态。这使得中间件可以很好地使代码在不同的路由之间重用。假设我们需要在一个路由跟据 cookies 获取用户信息,我们可以把这个获取用户信息的方法写成中间件,然后把用户信息注入到 req.user
,这样所以使用了这个中间件的路由可以通过 req.user
取得用户信息。而且在中间件中,如果判断用户没有登录,可以中止这个请求,并返回 403.
下面是 Express 编写和使用中间件的例子:
xxxxxxxxxx
231function authMiddleware(req, res, next) {
2 // 假设 cookies 中用 `token` 保存用户信息
3 if (req.cookies.token) {
4 const user = getUserByToken(req.cookies.token)
5 req.user = user
6 next()
7 } else {
8 // cookies.token 不存在,中止请求并返回 403
9 res.statusCode = 403
10 res.send('please sign in first')
11 }
12}
13
14// 不使用这个中间件的路由
15app.get('/', (req, res) => {
16 res.send('hello world')
17})
18
19// 使用这个中间件的路由
20app.get('/profile', authMiddleware, (req, res) => {
21 // 可以通过 `req.user` 取得用户信息
22 res.send(`welcome! ${req.user.name}`)
23})
如果在 Next.js 要做同样的事,我们会这么做:
xxxxxxxxxx
221// pages/api/example.ts
2
3function auth(req, res) {
4 if (req.cookies.token) {
5 const user = getUserByToken(req.cookies.token)
6 return user
7 } else {
8 // 用户未登录
9 res.status(403)
10 res.send('please sign in first')
11 }
12}
13
14export default (req, res) => {
15 if (req.method === 'GET') {
16 res.send('hello')
17 } else if (req.method === 'POST') {
18 const user = auth(req, res)
19 console.log('do other things')
20 res.send(`welcome! ${user.name}`)
21 }
22}
但在 Next.js, 我们没有任何办法中止请求。理论上 console.log('do other things')
在用户未登录时不应该被执行。
next-connect
要在 Next.js 中像 Express/Koa 这样使用 connect 中间件,我们可以使用 next-connect 这个库。
安装
next-connect
:xxxxxxxxxx
11$ yarn add next-connect
现在,让我们用 next-connect
重写上面的例子:
xxxxxxxxxx
191// pages/api/example.ts
2
3import nc from 'next-connect'
4
5function authMiddleware(req, res, next) {
6 res.status(403)
7 res.send('please sign in first')
8}
9
10// 用 `nc()` 创建一个 api handler
11const handler = nc()
12 .get((req, res) => {
13 res.send('hello')
14 })
15 .post(authMiddleware, (req,res) => {
16 res.send('hello')
17 })
18
19export default handler
可以看到,现在我们在 Next.js 的API route 可以像在 Express 一样使用中间件。
在 authMiddleware
中,我们返回了一个 403,并且没有执行 next()
, 模拟了用户未登录的情况。由于 next()
没有执行,这个 POST 请求不会执行这个 POST handler 的代码。
用 next-connect
的另一个好处是,我们可以用.get()
, .post()
, put()
这样的 helper 来创建对应的 handler, 而不需要用 if (req.method === XXX)
这样的判断。让代码更好读。
因为 next-connect
兼容 connect 中间件,所以我们可以直接用社区上成熟的 connect 中间件,例如用于修改跨域设置的中间件 cors
:
安装
cors
:xxxxxxxxxx
11$ yarn add cors
xxxxxxxxxx
201// pages/api/example.ts
2
3import nc from 'next-connect'
4+ import * as cors from 'cors'
5
6const corsOptions = {
7 origin: 'http://example.com',
8 optionsSuccessStatus: 200
9}
10
11const handler = nc()
12+ .use(cors(corsOptions))
13 .get((req, res) => {
14 res.send('hello')
15 })
16 .post(authMiddleware, (req,res) => {
17 res.send('hello')
18 })
19
20export default handler
在实现 HTTP API 时,一个良好的实践应该在出现错误的时候响应错误对应的状态码。例如在资源不存在的时候返回 404,在没有权限时返回 403.
在一个复杂的 Web 应用,业务逻辑一般单独存在于 service (model) 层,例如一个根据 id 获取项目信息的 API:
xxxxxxxxxx
101class ProjectService {
2 getProjectById(id) {
3 const project = db.project.find(id)
4 if (!project) {
5 // TODO: 响应 404
6 } else {
7 return project
8 }
9 }
10}
当 project
找不到时,我们应该返回一个 404 的响应。但在 service (model) 层,我们很难接触到 res
对象,所以很难在 service (model) 层修改响应。也无法在这个层面中止响应。
我们来看看在 Express 中怎么做。Express 有自带的 error handler:,当 catch 到任何地方的 error 后,响应的格式会根据 error 的对象自动改变。也就是说,我们可以通过 throw error 来改变响应的信息:
xxxxxxxxxx
181class ProjectService {
2 getProjectById(id) {
3 const project = db.project.find(id)
4 if (!project) {
5 throw {
6 status: 404
7 }
8 } else {
9 return project
10 }
11 }
12}
13
14app.get('/project/:id', (req, res) => {
15 const projectService = new ProjectService()
16 const project = service.getProjectById(req.params.id)
17 res.json({ data: project })
18})
上面的代码中,当 project 无法找到时,会抛出一个 { status: 404 }
, Express 在捕获后,会把 res.statusCode
设为 404。
另一个流行的 web 框架 hapi
还提供了 @hapi/boom
这个模块,提供了很多 helper 用于抛出 HTTP 异常。例如,Boom.forbidden('Please sign in first')
会抛出一个这样的对象:
xxxxxxxxxx
51{
2 "statusCode": 403,
3 "error": "Forbidden",
4 "message": "Please sign in first"
5}
用 Boom
来改写我们上面的例子,可以变成:
xxxxxxxxxx
181+import Boom as from '@hapi/boom'
2
3class ProjectService {
4 getProjectById(id) {
5 const project = db.project.find(id)
6 if (!project) {
7+ throw Boom.notFound('Project not found')
8 } else {
9 return project
10 }
11 }
12}
13
14app.get('/project/:id', (req, res) => {
15 const projectService = new ProjectService()
16 const project = service.getProjectById(req.params.id)
17 res.json({ data: project })
18})
然而,在 Next.js, 无论你在 API route 中 throw 一个怎样的 error, 它都会响应一个 500 Internal Server Error
. 并且你根本没有方法统一处理这些 error.
幸运的是,我们在上一节介绍的 next-connect
提供了一个统一的 error 处理机制。使用 next-connect
的所有 api route 或中间件,如果抛出异常,都可以在 onError
中捕获到:
xxxxxxxxxx
131import nc from 'next-connect'
2
3const handler = () => nc({
4 onError(err, req, res) {
5 console.log(err.message) // => 'oops'
6 res.send(err.message)
7 }
8})
9
10export default handler()
11 .get((req, res) => {
12 throw new Error('oops')
13 })
因此,我们甚至可以借助 @hapi/boom
的力量,在 Next.js 中得到很好的错误处理机制:我们可以在 api route 或者中间件中抛出 Boom 异常,然后在 onError
中根据 Boom 异常的信息改变 res
:
安装
@hapi/boom
:xxxxxxxxxx
11$ yarn add @hapi/boom
xxxxxxxxxx
381// pages/api/example.ts
2
3import nc from "next-connect";
4import * as Boom from '@hapi/boom'
5
6const handler = () =>
7 nc({
8 onError(err, req, res) {
9 // 如果是一个 Boom 异常,则根据 Boom 异常结构修改 `res`
10 if (Boom.isBoom(err)) {
11 res.status(err.output.payload.statusCode);
12 res.json({
13 error: err.output.payload.error,
14 message: err.output.payload.message,
15 });
16 } else {
17 res.status(500);
18 res.json({
19 message: "Unexpected error",
20 });
21 console.error(err);
22 // unexcepted error
23 }
24 },
25 });
26
27function getProjects() {
28 throw Boom.forbidden('Please sign in first')
29}
30
31export default handler()
32 .get(async (req, res) => {
33 const projects = getProjects()
34 res.json({
35 projects
36 })
37 });
38
这样,在任何地方抛出了 Boom 异常,都可以得到对应的响应,而不需要手动修改 res
对象:
在 Node.js 中访问数据库的方法有很多,优秀的 ORM 也有很多。但根据我自己的使用经验,在 Next.js 使用 Prisma 的体验还是最好的。
Prisma 是一个 TypeScript 友好的 Node.js ORM. 如果你没有听说过 Prisma, 在这里我可以用一句话介绍它:Prisma 让你用一个非常简洁易懂的 DSL 语法描述你的数据库结构和表之前的关系,然后自动生成 TypeScript 友好的请求 SDK.
在项目里接入 Prisma 很简单:
xxxxxxxxxx
31$ yarn add prisma @prisma/client
2
3$ yarn prisma init
运行 yarn prisma init
后,会在项目里创建 prisma/schema.prisma
文件。Prisma 支持 MySQL, SQLite, PostgreSQL. 为了方便,我们在这里使用 SQLite. 只要把 schema.prisma
里的 provider
改成 sqlite
即可:
xxxxxxxxxx
111
2datasource db {
3- provider = "postgresql"
4- url = env("DATABASE_URL")
5+ provider = "sqlite"
6+ url = "file:../db.sqlite"
7}
8
9generator client {
10 provider = "prisma-client-js"
11}
url
指定了数据库链接串,用 SQLite 的时候,可以指定为文件的目录。
It means Prisma will use the SQLite database in ../db.sqlite
.
想像我们正在开发一个博客系统,我们需要三张表:Post
, Comment
, Tag
:
Post
会有多个 Comment
, 但一个 Comment
只属于一个 Post
, 它们是一对多的关系。在一对多的关系中,我们通常会设置一个外键 (foreign key), 指向这条记录所属于的父级 idPost
会有多个 Tag
, 一个 Tag
也可以属于多个 Post
, 它们是多对多的关系。在多对多的关系中,我们通常会单独建立一张关系表。
或许你的 SQL 能力不是特别强,写下这样的建表 SQL 可能比较吃力(即使你精通 SQL, 每次开发项目都要写一堆 SQL 也不是什么好事)。幸运的是,如果你使用 Prisma, 你不需要自己写这些 SQL, 只需要在 prisma/schema.prisma
文件中定义你的数据模型,以及它们之前的对应关系即可:
xxxxxxxxxx
361datasource db {
2 provider = "sqlite"
3 url = "file:../db.sqlite"
4}
5
6generator client {
7 provider = "prisma-client-js"
8}
9
10model Post {
11 id String @id
12
13 content String
14
15 createdAt DateTime @default(now())
16
17 tags Tag[] @relation("post_tag")
18
19 comments Comment[] @relation("post_comment")
20}
21
22model Comment {
23 id String @id
24 content String
25
26 postId String
27 post Post @relation(name: "post_comment", references: [id], fields: [postId])
28
29 createdAt DateTime @default(now())
30}
31
32model Tag {
33 label String @id
34
35 posts Post[] @relation("post_tag")
36}
即使你从来没有学过 Prisma Schema 的语法,你还是可以从中大概看出数据模型,和他们之间的关系。
[Prisma Schema 语法文档
Schema 文件写好后,运行 yarn prisma db push
. Prisma 会按照这个 Schema, 把数据模型应用到数据库中,它会处理好一对多,多对多的关系,帮助你生成外键、关系表等等。
数据库生成后,运行 yarn prisma generate
, 这个命令会在 node_modules/@prisma/clinet
生成操作数据库的 SDK —— PrismaClient
. 在代码中你可以直接引入使用:
xxxxxxxxxx
101import { PrismaClient } from '@prisma/client'
2
3const prisma = new PrismaClient()
4
5export default (req, res) => {
6 const posts = await prisma.post.findMany()
7 res.json({
8 posts
9 })
10}
yarn prisma db push
只能用于项目的原型阶段,因为一但数据库有改变,有可能导致数据丢失。正常的做法应该在每次修改完数据结构以后,生成一份迁移文件,在部署上线前执行这份迁移,安全地变更数据库结构。
Primsa 自带了生成 Migration 的功能:
xxxxxxxxxx
11$ yarn prisma migrate dev
迁移文件会生成到 prisma/migrations
中,当你需要把他应用到生产数据库时,执行:
xxxxxxxxxx
11$ yarn prisma migrate deploy
在本地开发 Next.js 时,每次代码更改会导致热加载,导致 PrismaClient
一直被重复地实例化,造成出现重复的数据库连接。
我们应该让 Prisma 保持是一个单例,把它缓存到 global
对象中:
xxxxxxxxxx
41import { PrismaClient } from '@prisma/client'
2
3export const prisma = global.prisma || new PrismaClient()
4if (process.env.NODE_ENV !== "production") global.prisma = prisma
之后在应用所有的地方只通过这个单例引入 prisma.
前面几个简短的章节已经大概介绍完了一些我们会用到的技术和方法论,接下来在这一个章节,我们就开始利用这些技术,开发一些真实的应用了。
以下实例可以在 https://github.com/djyde/fullstack-nextjs-in-action-example 获取源码。
xxxxxxxxxx
11git clone https://github.com/djyde/fullstack-nextjs-application-in-action.git
这个仓库的 master
分支是一个把以上技术都整合到一起的基础 Next.js 项目。你可以选择以 master
分支为基础开始一步一步地跟着本书开始。
你也可以选择只用来读代码。在实例教程中,每一个小节我都会在标题下方留下对应的 commit 的 hash, 你可以使用 git checkout xxx
直接跳到该小节的代码中进行阅读。
请注意,我们将会提供两个实例,这两个实例并非各自独立,实例 2 是基于实例 1 开发的,所以请勿直接跳过实例 1.
第一个实例是实现一个用户系统。用户可以在页面中创建账号,登录和登出。本实例主要内容:
Prisma
的基本使用react-query
的基本使用在这个实例中,我们需要实现的是:
/login
页面,提供用户登录和创建账号的表单/user
页面,展示用户信息,如果用户未登录,则自动跳转到 /login
页面/api/login
登录/api/signup
创建账号/api/logout
登出c1695d97d65b0
首页,创建一个文件 pages/login.tsx
:
xxxxxxxxxx
491// pages/login.tsx
2
3function LoginForm() {
4 return (
5 <>
6 <div>
7 <label>Username: </label>
8 <input type="text" />
9 </div>
10 <div>
11 <label>Password: </label>
12 <input type="password" />
13 </div>
14 <button>Login</button>
15 </>
16 )
17}
18
19function SignUpForm() {
20 return (
21 <>
22 <div>
23 <label>Username: </label>
24 <input type="text" />
25 </div>
26 <div>
27 <label>Password: </label>
28 <input type="password" />
29 </div>
30 <button>create account</button>
31 </>
32 )
33}
34
35function LoginPage() {
36 return (
37 <>
38 <div>
39 <h1>Login</h1>
40 <LoginForm />
41
42 <h1>Create account</h1>
43 <SignUpForm />
44 </div>
45 </>
46 )
47}
48
49export default LoginPage
这是给用户登录和创建账号的页面 UI http://localhost:3000/login
:
f3d9abb6cef
首先我们需要在数据库中创建一个存放用户的 User
表。编辑 prisma/schema.prisma
,添加一个 User
model:
xxxxxxxxxx
141datasource db {
2 provider = "sqlite"
3 url = "file:../db.sqlite"
4}
5
6generator client {
7 provider = "prisma-client-js"
8}
9
10+ model User {
11+ name String @id
12+ password String
13+ }
14
然后运行 yarn prisma db push
,把模型应用到数据库中。
推荐使用 DB Browser for SQLite 查看数据库
这时,就可以创建一个 API 用于创建账号 pages/api/singup.ts
:
xxxxxxxxxx
211import { apiHandler, prisma } from "../../utils.server";
2import bcrypt from 'bcrypt'
3
4export default apiHandler()
5 .post(async (req, res) => {
6 const body = req.body as {
7 username: string,
8 password: string
9 }
10
11 const created = await prisma.user.create({
12 data: {
13 name: body.username,
14 password: bcrypt.hashSync(body.password, 10)
15 }
16 })
17
18 res.json({
19 message: 'success'
20 })
21 })
apiHandler
是对next-connect
的封装,详细可读源码
在这个 API 中,我们在请求体中取 username
和 password
这两个字段,然后使用 prisma.user.create()
在 User
表中创建一条记录。
我们使用
bcrypt
加密用户的密码。请记住任何时候都不要明文保存用户的密码。xxxxxxxxxx
11$ yarn add bcrypt
Now, add a create account mutation in the create accout form:
xxxxxxxxxx
391// pages/login.tsx
2
3async function createAccount({ username, password }) {
4 await axios.post(`/api/signup`, {
5 username, password
6 })
7}
8
9function SignUpForm() {
10
11 const $username = useRef(null)
12 const $password = useRef(null)
13
14 const createAccountMutation = useMutation(createAccount, {
15 onSuccess() {
16 alert('created!')
17 }
18 })
19
20 function onClickCreateAccount() {
21 const username = $username.current.value
22 const password = $password.current.value
23 createAccountMutation.mutate({ username, password })
24 }
25
26 return (
27 <>
28 <div>
29 <label>Username: </label>
30 <input ref={$username} type="text" />
31 </div>
32 <div>
33 <label>Password: </label>
34 <input ref={$password} type="password" />
35 </div>
36 <button disabled={createAccountMutation.isLoading} onClick={onClickCreateAccount}>create account</button>
37 </>
38 )
39}
createAccount()
是请求的方法,我们用 useMutation
把它封装成了一个 Mutation (createAccountMutation
).
当用户点击 button
时,就通过 createAccountMutation.mutate()
执行这个 Mutation.
现在试一试创建一个账号:
在数据库中看到这个创建的用户。
保存用户的登录状态主流的实现方式有两种,一种是 Session, 一种是 JWT. 两种方法各有区别,不在本书讨论的范围内。这里为了方便,我们使用 JWT 来实现。
5d6970536afb
无论是用 JWT 还是 Session, 在登录的接口中,都需要实现验证用户名和密码的方法。首先创建登录 API 的接口 pages/api/login.tsx
xxxxxxxxxx
361// pages/api/login.tsx
2
3import Boom from "@hapi/boom";
4import { apiHandler, prisma } from "../../utils.server";
5import bcrypt from 'bcrypt'
6
7async function validate(username, password) {
8 // validate the username and password
9 const user = await prisma.user.findUnique({
10 where: {
11 name: username,
12 },
13 });
14
15 if (!user) {
16 throw Boom.unauthorized("user not found");
17 }
18
19 if (bcrypt.compareSync(password, user.password)) {
20 return user
21 } else {
22 throw Boom.unauthorized('username or password not correct')
23 }
24}
25
26export default apiHandler()
27 .post(async (req, res) => {
28 const body = req.body as {
29 username: string;
30 password: string;
31 };
32
33 const user = await validate(body.username, body.password)
34
35 res.json({})
36 })
我们实现了一个 validate
方法,在数据库中寻找对应用户名的记录,然后匹配密码是否正确。如果正确,返回用户对象,相反,抛出一个 unauthorized
的 401 异常。
回顾:
在「API route 错误统一处理」一节中我们介绍了抛出和统一处理 Boom 异常的方法
现在我们为这个登录接口在页面创建一个 Mutation:
xxxxxxxxxx
381// pages/login.tsx
2
3async function login({ username, password }) {
4 const result = await axios.post(`api/login`, {
5 username, password
6 })
7 return result.data
8}
9
10function LoginForm() {
11
12 const loginMutation = useMutation(login)
13
14 const $username = useRef(null)
15 const $password = useRef(null)
16
17 function onClickLogin() {
18 const username = $username.current.value
19 const password = $password.current.value
20 loginMutation.mutate({ username, password })
21 }
22
23 return (
24 <>
25 {loginMutation.error && <div style={{ color: 'red' }}>{loginMutation.error.response.data.message}</div>}
26 <div>
27 <label>Username: </label>
28 <input ref={$username} type="text" />
29 </div>
30 <div>
31 <label>Password: </label>
32 <input ref={$password} type="password" />
33 </div>
34 <button onClick={onClickLogin}>Login</button>
35 </>
36 )
37}
38
在上面的代码中,我们还可以通过 loginMutation.error.response.data.message
获取请求出现错误时返回的消息体。我们现在用一个错误的密码进行登录:
ef9529bf6db5
我们继续完成这个登录 API, 在验证完用户名和密码后,我们就要把用户信息加密到 JWT 中。我们使用 jsonwebtoken 来生成 JWT:
xxxxxxxxxx
11$ yarn add jsonwebtoken
xxxxxxxxxx
211// pages/api/login.ts
2
3import jwt from 'jsonwebtoken'
4
5export default apiHandler()
6 .post(async (req, res) => {
7 const body = req.body as {
8 username: string;
9 password: string;
10 };
11
12 const user = await validate(body.username, body.password)
13
14 // generate jwt
15
16+ const token = jwt.sign({
17+ username: user.name
18+ }, process.env.JWT_SECRET, { expiresIn: '3 days' })
19
20 res.json({})
21 })
我们通过 jwt.sign()
生成一个 JWT. 把 { username: user.name }
放到这个 JWT 中,并且设置了 JWT 过期的时间是 3 天。
生成 JWT 需要一个只有服务器端知道的私钥,为了安全起见,我们不能把私钥写在代码里,而是放在环境变量中。
在项目中创建一个 .env
文件,然后把私钥设置到 JWT_SECRET
变量中,Next.js 会在启动时读取 `.env
xxxxxxxxxx
21# .env
2JWT_SECRET=ofcourseistillloveyou
客户端在哪里存放这个 JWT? 比较好的一种做法是保存在 httpOnly 的 cookies 中。然而 Next.js 没有自带一个比较方便的设置 Cookie 的方法,所以我们使用 cookie
这个库:
Install
cookie
:xxxxxxxxxx
11$ yarn add cookie
xxxxxxxxxx
231// pages/api/login.ts
2
3//...
4import cookie from 'cookie'
5
6export default apiHandler()
7 .post((req, res) => {
8
9 // ...
10
11 const token = jwt.sign({
12 username: user.name
13 }, process.env.JWT_SECRET, { expiresIn: '3 days' })
14
15 // set a cookie named `token`
16 res.setHeader("Set-Cookie", cookie.serialize('token', token, {
17 httpOnly: true,
18 path: '/',
19 maxAge: 60 * 60 * 24 * 3
20 }));
21
22 res.json({})
23 })
我们把 JWT 保存在了名为 token
的 cookies 中。
现在我们尝试用正确的用户名和密码登录,可以看到登录成功后,cookies 被正确保存了。在这之后,所有请求都会带上这个 cookie.
21e4111ffbfb
现在,我们创建一个 /user
页面用来展示用户信息。这个页面只能被已登录的用户访问,如果未登录,就跳转到 /login
.
如何在访问时判断是否已登录?我们可以在 getServerSideProps(ctx)
中从 ctx.req.cookies
中取得登录成功时设置好的 token
cookies, 并验证 JWT 是否合法。
首先在 utils.server.ts
实现一个从 req
取 JWT 且验证 JWT 的方法:
xxxxxxxxxx
251// utils.server.ts
2
3import jwt from 'jsonwebtoken'
4
5export const getUserFromReq = async (req) => {
6
7 // get JWT `token` on cookies
8 const token = req.cookies['token']
9
10 try {
11 // if token is invalid, `verify` will throw an error
12 const payload = jwt.verify(token, process.env.JWT_SECRET)
13
14 // find user in database
15 const user = await prisma.user.findUnique({
16 where: {
17 name: payload.username
18 }
19 })
20
21 return user
22 } catch (e) {
23 return null
24 }
25}
然后创建 /user
页面 /pages/user.tsx
:
xxxxxxxxxx
401// pages/user.tsx
2
3import { getUserFromReq } from "../utils.server"
4
5function UserPage(props: {
6 user: {
7 name: string
8 }
9}) {
10 return (
11 <>
12 <div>
13 Hello, {props.user.name}
14 </div>
15 </>
16 )
17}
18
19export async function getServerSideProps(ctx) {
20 const user = await getUserFromReq(ctx.req)
21
22 if (!user) {
23 return {
24 redirect: {
25 permanent: false,
26 destination: '/login'
27 }
28 }
29 }
30
31 return {
32 props: {
33 user: {
34 name: user.name
35 }
36 }
37 }
38}
39
40export default UserPage
在 getServerSideProps
中调用 getUserFromReq
, 如果返回是一个 user 对象,则表明用户已登录,我们可以把用户信息通过 props 传到页面。
相反,如果返回 null
, 代表用户没有登录。我们可以在这里返回一个 redirect 对象(形如 { destination: string, permanent: boolean }
), 用户就会被跳转到指定的路由。这是 Next.js 其中一个特性。
思考题
你学会了如何在页面访问时判断用户是否登录且控制是否跳转到其它路由。现在如果我们想要实现当已登录的用户在访问
/login
时把他跳转到/user
路由,你会怎么做?
6d0483a7a53
实现退出登录,我们只需要创建一个路由 /api/logout
把 cookies 设置为一个不合法的值,然后跳转到其它路由即可:
xxxxxxxxxx
151// pages/api/logout.ts
2
3import { apiHandler } from "../../utils.server";
4import cookie from 'cookie'
5
6export default apiHandler()
7 .get(async (req, res) => {
8 res.setHeader('Set-Cookie', cookie.serialize('token', 'invalid', {
9 httpOnly: true,
10 path: '/'
11 }))
12
13 res.redirect('/login')
14 })
15
然后在页面添加一个退出登录的链接:
xxxxxxxxxx
191// pages/user.tsx
2
3function UserPage(props: {
4 user: {
5 name: string
6 }
7}) {
8 return (
9 <>
10 <div>
11 Hello, {props.user.name}
12
13 <div>
14+ <a href="/api/logout">Logout</a>
15 </div>
16 </div>
17 </>
18 )
19}
来到第二个实例,我们基于实例 1 实现一个最简单的 HackerNews: 用户可以在上面提交链接和标题,也可以编辑和删除他们提交链接。
本实例主要内容:
react-query
的 Query Invalidation
的使用f84d8006e31
在实现表单之前,我们先在网站的首页显示一个导航栏,如果用户已经登录,则显示他的用户名,以及一个退出登录的链接。如果用户未登录,就显示一个登录入口:
xxxxxxxxxx
371// pages/index.tsx
2
3import React from 'react'
4import { getUserFromReq } from '../utils.server'
5
6function IndexPage(props: {
7 user?: {
8 name: string
9 }
10}) {
11
12 return (
13 <>
14 <div>
15 {props.user ? <>
16 <span>Hi, {props.user.name}, </span>
17 <a href="/api/logout">Logout</a>
18 </> : <>
19 <a href="/login">Login</a>
20 </>}
21 </div>
22 </>
23 )
24}
25
26
27export async function getServerSideProps(ctx) {
28 const user = await getUserFromReq(ctx.req)
29
30 return {
31 props: {
32 user: user ? { name: user?.name } : null
33 }
34 }
35}
36
37export default IndexPage
和实例 1 一样,我们在 getServerSideProps
中用 getUserFromReq
获取用户信息,然后通过 props 传递到页面。页面根据判断 props.user
是否为 null
进行不同的展示。
现在我们编写用于提交链接的表单,这个表单只在用户已登录时可见:
xxxxxxxxxx
421// pages/index.tsx
2
3function SubmitLinkForm() {
4 return (
5 <>
6 <h2>Submit link</h2>
7 <div>
8 <label>URL: </label>
9 <input type="text" />
10 </div>
11 <div>
12 <label>Title: </label>
13 <input type="text" />
14 </div>
15 </>
16 )
17}
18
19function IndexPage(props: {
20 user?: {
21 name: string
22 }
23}) {
24
25 return (
26 <>
27 <div>
28 {props.user ? <>
29 <span>Hi, {props.user.name}, </span>
30 <a href="/api/logout">Logout</a>
31 </> : <>
32 <a href="/login">Login</a>
33 </>}
34 </div>
35
36 {/* 只在用户登录后可见 */}
37 {props.user && <div>
38 <SubmitLinkForm />
39 </div>}
40 </>
41 )
42}
51282d8145347
用户可以提交链接,所以我们需要一个 Link
表,每一个 Link
都有标题 title
和链接 url
. 每个 Link
都是被某个用户提交的,所以我们还需要用一个外键 creatorName
标识 Link 是被哪个用户提交的。
把数据模型写成 Prisma Schema (prisma/schema.prisma
):
xxxxxxxxxx
181model User {
2name String @id
3password String
4
5links Link[] @relation("link_creator")
6}
7
8model Link {
9id String @id @default(uuid())
10
11title String
12url String
13
14creatorName String
15creator User @relation(name: "link_creator", fields: [creatorName], references: [name])
16
17createdAt DateTime @default(now())
18}
然后运行 yarn prisma db push
把数据模型应用到数据库:
35b7bc1407
现在我们创建一个 API /api/link
用来给用户提交链接,创建 pages/api/link
:
xxxxxxxxxx
241// pages/api/link
2
3import { apiHandler, prisma } from "../../utils.server";
4
5export default apiHandler()
6 .post(async (req, res) => {
7 const body = req.body as {
8 url: string,
9 title: string
10 }
11
12 await prisma.link.create({
13 data: {
14 url: body.url,
15 title: body.title,
16 // TODO: how to get creator's username
17 // creatorName: ''
18 }
19 })
20
21 res.json({
22 message: 'Success'
23 })
24 })
/api/link
接受一个 POST 请求,然后用 prisma.link.create
把链接存进数据库。
但代码其实还没完成,因为我们需要在 API 里知道请求这个接口的登录用户是谁。
我们可以写一个中间件专门用于取用户信息,然后把用户信息放在 req.user
中,我们把这个中间件写在 utils.server.ts
:
xxxxxxxxxx
111// utils.server.ts
2
3export const authMiddleware = () => async (req, res, next) => {
4 const user = await getUserFromReq(req)
5 if (!user) {
6 throw Boom.forbidden('Please login first')
7 } else {
8 req.user = user
9 next()
10 }
11}
这个中间件不止把用户信息放在 req.user
, 当一个未登录的用户请求了进入了这个中间件时,中间件会抛出一个 403 响应,从而阻止未登录用户。
把这个 authMiddleware()
用在路由里:
xxxxxxxxxx
231import { apiHandler, authMiddleware, prisma } from "../../utils.server";
2
3export default apiHandler()
4+ .post(authMiddleware(), async (req, res) => {
5 const body = req.body as {
6 url: string,
7 title: string
8 }
9
10+ const user = req.user
11
12 await prisma.link.create({
13 data: {
14 url: body.url,
15 title: body.title,
16+ creatorName: user.name
17 }
18 })
19
20 res.json({
21 message: 'Success'
22 })
23 })
现在,试试未登录用户请求这个 API:
可以看到响应了 403.
1406f062d3f
现在,让我们在页面中获取所有提交的链接并展示出来。首先我们要创建一个 GET /api/link
来获取所有链接:
xxxxxxxxxx
191// pages/api/link.ts
2
3import { apiHandler, authMiddleware, prisma } from "../../utils.server";
4
5export default apiHandler()
6 .get(async (req, res) => {
7 const links = await prisma.link.findMany({
8 orderBy: {
9 createdAt: 'desc'
10 }
11 })
12
13 res.json({
14 data: links
15 })
16 })
17 .post(authMiddleware(), async (req, res) => {
18 // ......
19 })
用 findMany()
查询所有记录,然后根据 createdAt
排序。
在页面中,创建一个 Query 来请求这个接口,并渲染到页面中:
xxxxxxxxxx
471// pages/index.tsx
2
3// the query method
4async function fetchAllLinks() {
5 const result = await axios.get(`/api/link`)
6 return result.data.data
7}
8
9function IndexPage(props: {
10 user?: {
11 name: string
12 }
13}) {
14
15 const fetchAllLinksQuery = useQuery('fetchAllLinks', fetchAllLinks)
16
17 return (
18 <>
19 <div>
20 {props.user ? <>
21 <span>Hi, {props.user.name}, </span>
22 <a href="/api/logout">Logout</a>
23 </> : <>
24 <a href="/login">Login</a>
25 </>}
26 </div>
27
28 {/* Only signed in user can see the submit link form */}
29 {props.user && <div>
30 <SubmitLinkForm />
31 </div>}
32
33 {/* fetch all links and render them */}
34 <div>
35 {fetchAllLinksQuery.isLoading && <div>Loading...</div>}
36 {fetchAllLinksQuery.data?.map(link => {
37 return (
38 <div key={link.id}>
39 <a href={link.url}>{link.title}</a>
40 </div>
41 )
42 })}
43 </div>
44 </>
45 )
46}
47
然后编写提交链接的 Mutation:
xxxxxxxxxx
411// pages/index.tsx
2
3async function submitLink(body: {
4 title: string,
5 url: string
6}) {
7 await axios.post(`/api/link`, body)
8}
9
10function SubmitLinkForm() {
11
12 const $title = React.useRef(null)
13 const $url = React.useRef(null)
14
15 const submitLinkMutation = useMutation(submitLink, {
16 onSuccess() {
17 console.log('submitted!')
18 }
19 })
20
21 function onClickSubmit() {
22 submitLinkMutation.mutate({ title: $title.current.value, url: $url.current.value })
23 }
24
25 return (
26 <>
27 <h2>Submit link</h2>
28 <div>
29 <label>URL: </label>
30 <input ref={$url} type="text" />
31 </div>
32 <div>
33 <label>Title: </label>
34 <input ref={$title} type="text" />
35 </div>
36
37 <button disabled={submitLinkMutation.isLoading} onClick={onClickSubmit}>Submit</button>
38 </>
39 )
40}
41
当 submitLinkMutation
请求成功后,可以在日志中看到 submitted
.
当用户提交新的链接后,他并不能马上看到新的链接,因为这需要重新请求获取链接的接口。这里我们就可以用到 react-query
的 Query Invalidation
功能。回看一下我们在实现获取链接的 Query 的时候,给了这个 Query 一个 fetchAllLinks
的 key:
xxxxxxxxxx
11 const fetchAllLinksQuery = useQuery('fetchAllLinks', fetchAllLinks)
因为,我们可以在用户提交新的链接成功后,调用 queryClient.invalidateQueries('fetchAllLinks')
, 让 react-query
知道 fetchAlllinks
这个 Query 的数据已经过时,需要重新请求。
xxxxxxxxxx
171// pages/index.tsx
2+ import { queryClient } from './_app'
3
4const $title = React.useRef(null)
5const $url = React.useRef(null)
6
7const submitLinkMutation = useMutation(submitLink, {
8 onSuccess() {
9- console.log('submitted!')
10+ queryClient.invalidateQueries('fetchAllLinks')
11 }
12})
13
14function onClickSubmit() {
15 submitLinkMutation.mutate({ title: $title.current.value, url: $url.current.value })
16}
17
现在,当用户提交链接后,react-query
就会重新请求获取链接的 Query, 然后展示最新的数据:
607310047a29cc0f
用户可以编辑和删除他们提交的链接。现在创建一个 PUT /api/link/:linkId
的 API 在编辑链接的时候请求。在 Next.js 中,我们可以通过创建 /pages/api/link/[linkId]/index.ts
这样的文件来创建这样的动态路由。然后通过 req.query.linkId
来获取路由上面的参数:
xxxxxxxxxx
81// pages/api/link/[linkId]/index.ts
2
3import { apiHandler } from "../../../../utils.server";
4
5export default apiHandler()
6 .put(async (req, res) => {
7 res.send(req.query.linkId)
8 })
我们用 prisma.link.update
来更新一条 Link 的记录:
xxxxxxxxxx
281// pages/api/link/[linkId]/index.ts
2
3import { apiHandler, prisma } from "../../../../utils.server";
4
5export default apiHandler()
6 .put(async (req, res) => {
7 const body = req.body as {
8 title?: string,
9 url?: string
10 }
11
12 const linkId = req.query.linkId as string
13
14 await prisma.link.update({
15 where: {
16 id: linkId
17 },
18 data: {
19 title: body.title,
20 url: body.url
21 }
22 })
23
24 res.json({
25 message: 'success'
26 })
27 })
28
「编辑链接」的业务逻辑这样其实已经算是完成,但是,这个 API 不应该让未登录的用户,或不是这条链接的提交者请求。我们可以直接使用上一节封装的 authMiddleware
, 把非登录用户排除在外。然后,再通过判断 Link 的 creatorName
是否和 req.user.name
相同即可:
xxxxxxxxxx
421import Boom from "@hapi/boom";
2import { apiHandler, authMiddleware, prisma } from "../../../../utils.server";
3
4export default apiHandler()
5 .put(authMiddleware(), async (req, res) => {
6 const body = req.body as {
7 title?: string,
8 url?: string
9 }
10
11 const linkId = req.query.linkId as string
12
13 // get current logged in user's information
14 const user = req.user
15
16 // get link's information
17 const link = await prisma.link.findUnique({
18 where: {
19 id: linkId
20 }
21 })
22
23 // check the link's creator is current logged in user. If not, response a 403 error
24 if (link.creatorName !== user.name) {
25 throw Boom.forbidden('Permission Denined!')
26 }
27
28 await prisma.link.update({
29 where: {
30 id: linkId
31 },
32 data: {
33 title: body.title,
34 url: body.url
35 }
36 })
37
38 res.json({
39 message: 'success'
40 })
41 })
42
在这一步,我们就见识到了中间件对于代码复用的好处。我们甚至可以再进一步,封装一个专门用于判断请求者是否是链接作者的中间件 linkCreatorGuard
:
xxxxxxxxxx
201// utils.server.ts
2
3export const linkCreatorGuard = (getLinkId: (req) => string) => async (req, res, next) => {
4 const user = req.user
5 const linkId = getLinkId(req)
6 const link = await prisma.link.findUnique({
7 where: {
8 id: linkId
9 },
10 select: {
11 creatorName: true
12 }
13 })
14
15 if (user.name !== link.creatorName) {
16 throw Boom.forbidden('Permission Denined')
17 } else {
18 next()
19 }
20}
它接受一个函数,用于在 req
对象中取出链接的 id, 然后返回中间件。
现在我们就把他用在 PUT 的 api 接口:
xxxxxxxxxx
281import { apiHandler, authMiddleware, linkCreatorGuard, prisma } from "../../../../utils.server";
2
3export default apiHandler()
4 .put(
5 authMiddleware(),
6 linkCreatorGuard(req => req.query.linkId),
7
8 async (req, res) => {
9 const body = req.body as {
10 title?: string,
11 url?: string
12 }
13
14 await prisma.link.update({
15 where: {
16 id: req.query.linkId
17 },
18 data: {
19 title: body.title,
20 url: body.url
21 }
22 })
23
24 res.json({
25 message: 'success'
26 })
27 })
28
我们给 linkCreatorGuard
传的第一个参数就是从 req.query
中取出 linkId
的方法。
接口准备就绪,现在就在页面实现编辑链接的表单和 Mutation:
xxxxxxxxxx
491// pages/index.tsx
2import { Link } from '@prisma/client'
3import { queryClient } from './_app'
4
5const editLink = (linkId: string) => async (body: {
6 title?: string,
7 url?: string
8}) => {
9 await axios.put(`/api/link/${linkId}`, body)
10}
11
12function EditLinkForm(props: {
13 link: Link
14}) {
15
16 const $title = React.useRef(null)
17 const $url = React.useRef(null)
18
19 const editLinkMutation = useMutation(editLink(props.link.id), {
20 onSuccess() {
21 // mark `fetchAllLinks` query as stale after editing a link
22 queryClient.invalidateQueries('fetchAllLinks')
23 },
24 onError(err) {
25 // if error, alert the error message
26 alert(err.response.data.message)
27 }
28 })
29
30 function onClickSave() {
31 editLinkMutation.mutate({ title: $title.current.value, url: $url.current.value })
32 }
33
34 return (
35 <>
36 <div>
37 <label>URL: </label>
38 <input defaultValue={props.link.url} ref={$url} type="text" />
39 </div>
40 <div>
41 <label>Title: </label>
42 <input defaultValue={props.link.title} ref={$title} type="text" />
43 </div>
44
45 <button disabled={editLinkMutation.isLoading} onClick={onClickSave}>Save</button>
46 </>
47 )
48}
49
和提交链接一样,修改链接这个 Mutation 成功以后,调用 queryClient.invalidateQueries('fetchAllLinks')
让链接列表更新。
让这个编辑链接的表单显示在每条链接的下方:
xxxxxxxxxx
431function IndexPage(props: {
2 user?: {
3 name: string
4 }
5}) {
6
7 const fetchAllLinksQuery = useQuery('fetchAllLinks', fetchAllLinks)
8
9 return (
10 <>
11 <div>
12 {props.user ? <>
13 <span>Hi, {props.user.name}, </span>
14 <a href="/api/logout">Logout</a>
15 </> : <>
16 <a href="/login">Login</a>
17 </>}
18 </div>
19
20 {/* Only signed in user can see the submit link form */}
21 {props.user && <div>
22 <SubmitLinkForm />
23 </div>}
24
25 <div>
26 {fetchAllLinksQuery.isLoading && <div>Loading...</div>}
27 {fetchAllLinksQuery.data?.map(link => {
28 return (
29 <div style={{ marginTop: '1rem' }}>
30 <div key={link.id}>
31- <a href={link.url}>{link.title}</a>
32+ <a href={link.url}>{link.title}</a> <span>by: {link.creatorName}</span>
33 </div>
34 <div>
35+ <EditLinkForm link={link} />
36 </div>
37 </div>
38 )
39 })}
40 </div>
41 </>
42 )
43}
现在页面就变成了这个模样:
我创建了两个用户,分别提交了一些链接。现在我们试试看编辑非自己提交的链接会怎么样:
非常棒,这说明 linkCreatorGuard
是有效的。现在编辑自己提交的链接,成功后可以立即看到更新后的数据:
f64677053742f
删除链接和修改链接大致相同,但路由是 DELETE /api/link/:linkId
. 我们用 prisma.link.delete
来删除一条数据库记录。
同样,只有自己可以删除自己的链接。然而因为我们在实现编辑链接的时候封装了 linkCreatorGuard
, 所以在删除链接的 API 只需要实现删除链接的业务逻辑,而不需要再实现判断是否是提交者的逻辑了,这大大减少了工作量:
xxxxxxxxxx
501// pages/api/link/[linkId]/index.ts
2
3import {
4 apiHandler,
5 authMiddleware,
6 linkCreatorGuard,
7 prisma,
8} from "../../../../utils.server";
9
10export default apiHandler()
11 .put(
12 authMiddleware(),
13 linkCreatorGuard((req) => req.query.linkId),
14 async (req, res) => {
15 const body = req.body as {
16 title?: string;
17 url?: string;
18 };
19
20 await prisma.link.update({
21 where: {
22 id: req.query.linkId,
23 },
24 data: {
25 title: body.title,
26 url: body.url,
27 },
28 });
29
30 res.json({
31 message: "success",
32 });
33 }
34 )
35 // delete a link
36 .delete(
37 authMiddleware(),
38 linkCreatorGuard((req) => req.query.linkId),
39 async (req, res) => {
40 await prisma.link.delete({
41 where: {
42 id: req.query.linkId
43 }
44 })
45 res.json({
46 message: "success",
47 });
48 }
49 );
50
API 完成后,在前端页面增加删除链接的 Mutation:
xxxxxxxxxx
651// pages/index.tsx
2
3+async function deleteLink({ linkId }) {
4+ await axios.delete(`/api/link/${linkId}`)
5+}
6
7function IndexPage(props: {
8 user?: {
9 name: string
10 }
11}) {
12
13 const fetchAllLinksQuery = useQuery('fetchAllLinks', fetchAllLinks)
14+ const deleteLinkMutation = useMutation(deleteLink, {
15+ onSuccess() {
16+ queryClient.invalidateQueries('fetchAllLinks')
17+ },
18+ onError(err) {
19+ alert(err.response.data.message)
20+ }
21+ })
22
23 return (
24 <>
25 <div>
26 {props.user ? <>
27 <span>Hi, {props.user.name}, </span>
28 <a href="/api/logout">Logout</a>
29 </> : <>
30 <a href="/login">Login</a>
31 </>}
32 </div>
33
34 {/* Only signed in user can see the submit link form */}
35 {props.user && <div>
36 <SubmitLinkForm />
37 </div>}
38
39 <div>
40 {fetchAllLinksQuery.isLoading && <div>Loading...</div>}
41 {fetchAllLinksQuery.data?.map(link => {
42 return (
43 <div style={{ marginTop: '1rem' }}>
44 <div key={link.id}>
45 <a href={link.url}>{link.title}</a> <span>by: {link.creatorName}</span>
46 </div>
47 <div>
48+ <button
49+ disabled={deleteLinkMutation.isLoading}
50+ onClick={_ => deleteLinkMutation.mutate({ linkId: link.id })}
51+ >
52+ delete
53+ </button>
54 </div>
55 <div>
56 <EditLinkForm link={link} />
57 </div>
58 </div>
59 )
60 })}
61 </div>
62 </>
63 )
64}
65
删除一条不是自己提交的链接:
删除自己提交的链接,链接会马上从列表中消失。