注釈だらけのtRPCとNext.js ~Prismaを用いたユーザ登録機能の作成まで~(2)
28 npx prisma initをターミナルで打つ
28-1-1 npx prisma initの効果
schema.prismaファイル(Prismaのスキーマファイル)が作られる。
このファイルでデータベースの接続設定やPrisma Clientの生成設定を定義する。モデルを定義して、データベーススキーマの管理が行える
28-1-2 package.jsonファイルにprisma, @prisma/client がdevDependenciesとして追加される
28-2 prismaとは
オブジェクト関係マッピングツールである。
ORMともいう。オブジェクト指向の言語とリレーショナルデータベースの間には不整合がある。RDBはテーブル・行・列という概念を使いオブジェクト指向プログラミングはインスタンス・属性・メソッドという概念を使う。基本的に全く違うので、通信が難しい。
そこでORMを使うと、SQLを開発者が書かずとも、ORMがオブジェクトとデータベースのテーブルの間にマッピング(つながり)を作成する。
ORMにはメリットとしてデータベースの変更に強い、コードを再利用しやすい、オブジェクト指向の利点を活かしたデータベース操作、などがある。
29 .envファイルにデータベースの接続情報を記載する
DATABASE_URL="postgresql://postgres:changeme@localhost:5432/trpc-tut?schema=public"
29-1 DATABASE_URLの構成
プロトコル: postgresql, ユーザ名: postgresql, パスワード: changeme, ホスト: localhost, ポート: 5432, データベース名: trpc-tut, スキーマ名: public
30 Prismaスキーマ言語で、データベースのスキーマを書く
// prisma/schema.prisma
model User {
id String @unique @default(uuid())
email String @unique
name String
createAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
30-1 データベースのテーブルが持つカラム一覧
id: ユーザの一意な識別子, email: メールアドレス(一意), name(名前), createAt: ユーザ作成日時(デフォルトで現在の日時), updateAt: ユーザが作成・更新されるたびに現在の日時を設定
という設定になる。
31 PostgreSQLのセットアップをする
brew services start postgresql //HomebrewでPostgreSQLを起動させる
psql -U postgres //PostgreSQLインストール時からあるデータベースにアクセス
createuser -s postgres //ユーザをpostgresというロールで作る(-sはスーパーユーザ)
ALTER USER postgres WITH PASSWORD 'changeme'; //パスワードの設定をする
32 データベーススキーマから userという「マイグレーションファイル」を作る
32-1 npx prisma migrate dev —name userの動作
- prisma/schema.prismaのスキーマ定義と、現在のデータベーススキーマの差分を見ている。
- もし違ったら変更してSQLでマイグレーションファイルをつくってくれる。
32-2 マイグレーションファイルは何のためにあるのか
データベースのスキーマを変更したら作られる。それによって、変更を管理する。 —name userで今回のマイグレーションの名前がuserになりuser関連の変更をしたとわかりやすい。
32-3 実際に作成されるマイグレーションファイルの中身
// prisma/migration/*作った時刻*_user/migration.sql
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_id_key" ON "User"("id");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
33 userRouterを作成する
// src/server/route/user.router.ts
import { createRouter } from "../createRouter";
const userRouter = createRouter()
.mutation('resister-user', {
resolve: async () => {
}
})
33-1 新たなルータを作成して、そこにミューテーションを追加する
createRouter()でルータを作成して、それに.mutationで
ミューテーションを追加している。それでは、ミューテーションとは何かというと、
GraphQLでいうデータの変更を行うための動作である。tRPCの場合、ミューテーションはデータを作成、更新、削除するためのエンドポイントの定義に使われる
今回はresiter-userというエンドポイントが作られる
33-2 ミューテーションとは
GraphQLの用語である。データを変更するための操作を指す。ミューテーションが、そもそも”変異”させるという意味の英単語である。GraphQLにはクエリ(読み取り)とミューテーション(変更)の二つの操作タイプがある。
33-3 CRUDは4つに分けるのに、なぜGraphQLは二つにまとめているのか?
GraphQLはRESTful APIとは異なる設計思想を持つ。
相違する点
- GraphQLはクライアントが必要なデータの種類・構造を指定できること。
- 型がつけられておりスキーマがAPIの形・データの種類・取得の方法・変更の方法を定義していること
- 単一のエンドポイントで全ての操作を扱う
- 必要なデータを過不足なく受け取ろうとする
33-4 tRPCとGraphQLの違いは?
tRPCはGraphQLの機能・思想と一部関連する。
- TypeScriptにより型安全である
- 同じく多くの数のエンドポイントを持たないようにしている
- データを指定するのではなく、クライアントがデータの取得に必要な関数を呼び出す
- 直接関数を呼ぶので、GraphQLと違いパース(解析)のオーバヘッドがない
34 resolve関数を短縮系の表現に直す
export const userRouter = createRouter()
.mutation('resister-user', {
async resolve() {},
})
また、エクスポートもしておく。
35 アプリ全体のルータにuserルータを統合しておく
// 変更前
import { createRouter } from "../createRouter"
export const appRouter = createRouter()
.query('hello', {
resolve: () => {return 'hello from trpc server'},
})
export type AppRouter = typeof appRouter
ここから不要になったhelloのクエリを消す。
// 変更後 userRouterのマージもしている
import { createRouter } from "../createRouter"
import { userRouter } from "./user.router"
export const appRouter = createRouter()
.merge('users.', userRouter)
export type AppRouter = typeof appRouter
35-1 users. を指定する理由
このようにするとuserRouterの中の全てのルートにusers.が付加される。
このようにすると異なるルータの間で、ルートの名前が衝突しないことになる。
例えばuserRouterにcreateというルートを作る時、他のルータでもcreateというルートがあったとしても、前にuser.とつけておくことで、名前が被らずにすみ、またそれぞれの役割も明確になる。
35-2 わざわざルータを分けて、メインのルータで取り込む理由
ユーザ関連の処理をuser.routerに分離するため。
36 Prisma Clientの新しいインスタンスを作り、インポートによってアプリ全体で使えるようにする
// utils/prisma.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient()
new PrismaClient()でインスタンスが作成される。
37 Prisma Clientの振る舞いを開発環境向けに調整する(無駄なインスタンスが作られないようにするコード)
// さっきのprisma.ts
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined
}
export const prisma = global.prisma || new PrismaClient()
if(process.env.NODE_ENV !== 'production') {
global.prisma = prisma
}
37-1 Global変数としてprismaを作る
declare global {
var prisma: PrismaClient | undefined
}
ここはTypeScriptによる型定義の部分である。PrismaClientかundefinedを許可している。undefinedは作成されなかった時の型。declare globalはTypeScriptでグローバル変数prismaを作るものである。
37-2 アプリケーションのライフタイム
データベースの接続にはコストがかかる。PrismaClientは内部でそれを管理しているためアプリケーションのライフタイム中にはそれを再利用することが望ましい。
アプリケーションのライフタイムは、アプリが起動してから終了するまでの期間である。
文脈にもよるが一般的には、初期化、実行、終了の三つのステージを指す。初期化においてはリソースの確保、初期設定のロードをする。実行は主要な動作フェーズであり、ユーザの入力を処理したりデータベースを操作したりする。アプリの終了では、リソースを解放したり、終了メッセージを表示したりする。
37-3 再利用の必要性
prismaのインスタンスは初期化の際に一定のオーバヘッドがある。これにより開発でよく使うホットリロードや、関数が何度も呼び出されるサーバレスの環境でインスタンスが再利用されず、パフォーマンスが落ちたり不要なリソースが使われることを防ぐ。
37-4 Prismaインスタンス再利用のロジック部分
export const prisma = global.prisma || new PrismaClient()
もしglobal.prismaがあるときはそれを使用する。
global.prismaがある場合(つまり開発環境の時)は新しく作らない。
インスタンスの作成が行われるのはこの行のみである。
37-5 global.prismaへの割り当てロジック
if(process.env.NODE_ENV !== 'production') {
global.prisma = prisma
}
global.prismaへの割り当てはここでしか行われない。
条件はNODE_ENVがproduction(本番環境)ではない時、という設定になっている
38 コンテクストオブジェクトにprismaを加える
// src/server/createContext.ts
import { NextApiRequest, NextApiResponse } from "next"
import { prisma } from '../utils/prisma'
export function createContext({
req, res
}:{
req: NextApiRequest,
res: NextApiResponse
}){
//return { req, res }
return { req, res, prisma }
}
export type Context = ReturnType<typeof createContext>
38-1-1 Prismaのインスタンスのインポート
import { prisma } from '../utils/prisma'
38-1-2 Prismaを、APIルートで直接使用することを可能にする
return { req, res, prisma }
このようにしてコンテクストオブジェクトにPrismaが追加される。
38-1-3 コンテクストオブジェクト(おさらい)
// src/pages/api/trpc/[trpc].ts
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext, // ここで使われている
onError({error}){
if(error.code === 'INTERNAL_SERVER_ERROR'){
console.log('Something went wrong', error)
}else{
console.log(error)
}
}
}
createNextApiHandler関数は、tRPCリクエストを処理するための、Next.jsのAPIハンドラを作成している。これが実質的に、全てのtRPCクエリ、ミューテーションのゲートウェイとして機能している。
コンテクストオブジェクトは、このハンドラの中でコンテクストオブジェクトを作る「関数」を指定することで使える。
38-2 Next.jsとtRPCとReact Queryの関係
Next.jsはpages/apiに配置されたファイルに基づいて、APIエンドポイントを自動的に提供する(Next.js 13からできたappルータはその限りではないと思われる)。
Next.jsは「サーバサイドのロジックを処理するための場所」を提供している。
tRPCはNext.jsの用意したエンドポイントの上に構築されている。
tRPCは「クライアントとサーバの間」でデータの取得や変更を行うための「手段」である。
React QueryはtRPCと統合されている。これによりReactのコンポーネントがtRPCのエンドポイントへのクエリが実現する。
39 resolve関数の中でPrismaのデータベース操作が可能になる
// src/server/route/user.router.ts
import { createRouter } from "../createRouter";
export const userRouter = createRouter()
.mutation('resister-user', {
async resolve({ctx}) {
ctx.prisma
},
})
39-1 変更した部分
async resolve({ctx}) { //引数の追加
ctx.prisma // ctx.prismaの追加
39-2 ここで引数ctxにマウスを合わせると表示される情報について。
このような情報をVSCodeが出してくれる。これはなぜか。
(parameter) ctx: {
req: NextApiRequest;
res: NextApiResponse;
prisma: PrismaClient<Prisma.PrismaClientOptions, never, Prisma.RejectOnNotFound | Prisma.RejectPerOperation | undefined>;
}
tRPCのルータの関数(ここでいうresolve関数)はctxオブジェクトを引数として受け取ることが、期待されている。TypeScriptはこの情報を知っており、resolve関数内でctxを使うときに型の情報をIDEに提供してくれるので、このような情報が出る。
40 ユーザの作成時に必要なものを定義する
import z from 'zod'
export const createUserSchema = z.object({
name: z.string(),
email: z.string().email()
})
export type CreateUserInput = z.TypeOf<typeof createUserSchema>
40-1 zodライブラリについて
zodライブラリとは、プログラムが実行されているとき(ランタイム)にデータの妥当性、形状を検証するものである。zodはTypeScript向けのランタイムバリデーションライブラリ。
nameは z.string()により文字列であることを期待している。
emailは z.string().email()により文字列であるかつ、emailとして有効であることを期待している。
z.TypeOfで、createUserSchemaから静的なTypeScriptの型が派生できる。
41 register-userでのミューテーションのinputにスキーマを指定する
// src/server/route/user.router.ts
import { createUserSchema } from "@/schema/user.schema";
import { createRouter } from "../createRouter";
export const userRouter = createRouter()
.mutation('resister-user', {
input: createUserSchema, //これを追加
async resolve({ctx}) {
ctx.prisma
},
})
41-1 このコードの流れ
クライアントがregister-userミューテーションにデータを送信する。
tRPCが送信されたデータをcreateUserSchemaと形状をランタイムで比較する。
もしデータが一致しない場合にはエラーを返す
もし一致した場合にはresolve関数を実行する
42 outputスキーマを指定する方法(今回は使わない)
// user.schema.ts
export const createUserOutputSchema = z.object({
name: z.string(),
email: z.string().email()
})
// user.router.ts
export const userRouter = createRouter()
.mutation('resister-user', {
input: createUserSchema,
output: createUserOutputSchema,
async resolve({ctx}) {
ctx.prisma
},
})
43 outputスキーマを指定せず、返すユーザをresolve関数の中で作成する
export const userRouter = createRouter()
.mutation('register-user', {
input: createUserSchema,
async resolve({ctx, input}) {
const { email, name } = input
const user = await ctx.prisma.user.create({
data: {
email,
name,
},
})
return user
},
})
43-1 ミューテーションの実際の動作を定義する
async resolve({ctx, input}) でスキーマによる検証済みのinputを受け取ることができる。
これによりinputにはemail, nameがあることが保証されているので、{ email, name } = inputで渡すことができる。
ctx.prisma.user.createでPrismaライブラリを使用して、新しいuserをデータベースに作成する。
新しく作成したuserを返す。
44 エラーハンドリングを追加する
import { createUserOutputSchema, createUserSchema } from "@/schema/user.schema";
import { createRouter } from "../createRouter";
import { PrismaClient } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as trpc from "@trpc/server";
export const userRouter = createRouter().mutation("register-user", {
input: createUserSchema,
async resolve({ ctx, input }) {
const { email, name } = input;
try {
const user = await ctx.prisma.user.create({
data: {
email,
name,
},
});
return user;
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if ((e.code = "P2002")) {
throw new trpc.TRPCError({
code: "CONFLICT",
message: "User already exist",
});
}
}
throw new trpc.TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
},
});
43-1 PrismaClientKnownRequestErrorについて
Prismaのクエリの中で、既知の問題が発生したときにはエラーを投げてくれる。
43-2 e.code = “P2002”について
Prismaのエラーコードのうち、一意でなければならない要素が重複する時のエラー。
今回の場合同じメールアドレスを持つユーザがすでに存在するときに発生する。
43-3 throw new trpc.TRPCErrorについて
tRPCのエラーハンドリング機能。
code: “CONFLICT”のように、エラーコードを覚えることなく指定可能である。
43-4 catchブロックの最後について
ここで捕まるエラーは、Prismaの既知のエラーではないので
code: “INTERNAL_SERVER_ERROR”とするしかない。
Something went wrongという曖昧なメッセージを返すようにする。
44 Next.jsのユーザ登録ページのコンポーネントを作る
import Link from "next/link";
import { useForm } from "react-hook-form";
import { trpc } from "../utils/trpc";
function RegisterPage() {
const { handleSubmit, register } = useForm();
// この下の行について詳細に解説する 44-6 ~
const { mutate, mutateAsync, error } = trpc.useMutation(["users.register-user"]);
return (
<>
<form></form>
<Link href="/login">Login</Link>
</>
);
}
export default RegisterPage;
44-1 Linkコンポーネントのインポート
Next.jsのLinkコンポーネントはページの遷移を効率的にする
44-2 function RegisterPage
これはReactの関数コンポーネントである。
44-3 return <> >
Reactのフラグメントで包むことによって複数の要素をグループにしている。
44-4 …
リンクコンポーネントによる、/loginページへのリンクの提供である。
44-5 useForm()について
react-hook-formのライブラリからインポートしたフックで、Reactのフォームで、バリデーションやハンドリングを効率化してくれる。handleSubmitは、フォームを送信するイベントをハンドルしてくれる関数で、registerはreact-hook-formに入力のフィールドを登録してくれる。これにより入力フィールドのバリデーションを可能にする
44-6-1 const { mutate, mutateAsync, error } = ….
mutateは関数であり、使用することでregister-userでの、サーバサイドのミューテーションを実行できる。
errorはmutateを使用した時のエラー情報を格納してくれる。
44-6-2 useMutationについての公式ドキュメント
Mutations | TanStack Query Docs
44-6-2-1 公式のドキュメントにある mutateAsyncの使用例
useMutation({
mutationFn: addTodo, //ミューテーションの実行関数を指定する。
onSuccess: (data, error, variables, context) => {
// ミューテーションが成功した後に実行されるコールバック関数。
// このコードではこの中身が3回呼び出される。
},
})
[('Todo 1', 'Todo 2', 'Todo 3')].forEach((todo) => {
// 三つの異なるタスクをミューテーションとして実行する
mutate(todo, {
onSuccess: (data, error, variables, context) => {
// このonSuccessの中身は最後のミューテーションが完了したら実行される。
},
})
})
44-6-2-2 mutateFn: と mutate()について
mutationFnはデフォルトの関数である。useMutationを使い、ミューテーションの設定をするときに指定する。ここで指定したaddTodoは後でmutateメソッドを呼び出すとき何ら関数が指定されていなければ使うというものである。
mutate()はミューテーションを即座に実行している。mutateの中に指定したonSuccessのコールバック関数は最後のミューテーションが完了したときに一回だけ使用されるものである。
44-6-2-3 mutateAsyncについての公式の使用例
const mutation = useMutation({ mutationFn: addTodo })
try {
const todo = await mutation.mutateAsync(todo)
console.log(todo)
} catch (error) {
console.error(error)
} finally {
console.log('done')
}
44-6-2-2 mutateAsyncを使って非同期のミューテーションをするメリット
mutateAsyncはPromiseを返すのでawaitを使用して、ミューテーションの結果を待つことができる。これはミューテーションが終わり次第、その結果をユーザに即座に伝えたいようなUIを作るときに適している。
ただし、エラーはonSuccessや、onErrorで十分後からハンドリングできる。
なのでUIをミューテーションの結果に基づいて作りたい、などでなければ、mutateで事足りる。
45 mutateAsyncではなくmutateを使いonErrorとonSuccessを指定する
function RegisterPage() {
const { handleSubmit, register } = useForm();
const { mutate, error }
= trpc.useMutation(["users.register-user"], {
onError: (error) => {
},
onSuccess: () => {
}
});
// ...
46
// 略
function onSubmit(values) {
mutate(values);
}
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>{error && error.message}</form>
<Link href="/login">Login</Link>
</>
);
}
46-1 onSubmit関数
mutateメソッドを呼び出して、formから取得したvaluesを引数として渡す。
46-2 errorの出所
errorはtrpc.useMutationから取得されており、ミューテーション中にエラーが発生するとそのエラーを保持している。また error && error.messageはエラーが存在する場合にのみエラーメッセージを表示する条件付きのレンダリングである。
errorがまず存在するかを評価する。存在しないまたはnull, undefinedの場合に全体の式がfalseになる。ここでの評価はショートサーキット評価と呼ばれていて、左がfalseなら右の部分は評価されなくなる。
そしてerrorがtrueであればerror.messageが評価され、式全体の結果がその値になる
46-3 フォームのサブミットハンドラ
handleSubmitを使用してonSubmit関数がフォームのサブミットハンドラに設定される。フォームがサブミットされるとonSubmit関数が呼び出される。
47 ログインが成功した時の関数を記述する
const { mutate, error } = trpc.useMutation(["users.register-user"], {
onSuccess: () => {
router.push('/login')
},
});
47-1 router.push()について
router.push()はミューテーションが成功したときに実行される。
ログインのページにリダイレクトするよう設定している。
48 login.tsxを作っておく(ほぼ、register.tsxのコピーになる。)
// register.tsx
import Link from "next/link";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { trpc } from "../utils/trpc";
import { CreateUserInput } from "@/schema/user.schema";
function RegisterPage() {
const { handleSubmit, register } = useForm<CreateUserInput>();
const router = useRouter();
const { mutate, error } = trpc.useMutation(["users.register-user"], {
onSuccess: () => {
router.push('/login')
},
});
function onSubmit(values: CreateUserInput) {
mutate(values);
}
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>{error && error.message}</form>
<h1>Register</h1>
<input
type="email"
placeholder="hoge.sage@example.com"
{...register("email")}
/>
<br />
<input type="text" placeholder="Bob" {...register("name")} />
<button type="submit">Register</button>
<Link href="/login">Login</Link>
</>
);
}
export default RegisterPage;
// login.tsx
// 変更するところだけ
function RegisterPage() {
// 略
// この下をコメントアウト。
// const { mutate, error } = trpc.useMutation(["users.register-user"], {
// onSuccess: () => {
// router.push('/login')
// },
// });
// この関数の中のmutate()だけコメントアウト。
function onSubmit(values: CreateUserInput) {
// mutate(values);
}
return (
<>
// formにはエラーメッセージを出さない
<form onSubmit={handleSubmit(onSubmit)}>{/*error && error.message*/}</form>
// ここを Register -> Loginに変更してある。
<h1>Login</h1>
<input
type="email"
placeholder="hoge.sage@example.com"
{...register("email")}
/>
// ここは/loginではなく/registerにリンクしてある。
<Link href="/register">Register</Link>
</>
);
}
export default RegisterPage;
49 実際にログインしてリダイレクトされるのを確かめる
実際にregisterするときにはhttps://localhost:3000/api/trpc/users.register-user
というエンドポイントが呼び出されていることを確かめる。
50 先ほどと同じメールアドレスを持つユーザをわざとregisterしようとしてエラーが出るのを確かめる。
POSTされており、それがエラーとしてCONFLICT 409を返しているのを確かめている。