基於Next.js、Prisma、Postgres和Fastfy構建全棧APP

語言: CN / TW / HK

譯者 | 朱先忠

審校 | 孫淑娟

在本文中,我們將學習如何使用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: {
        email
      },})

      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