💬

Next.js(App Router)×PlanetScale×Drizzle ORMでチャットアプリの構築

2023/12/20に公開

今回の記事では、Slack 風なチャットアプリを Next.js(App Router) と最近流行りのライブラリをもりもりに使って実装したので、その振り返りとして記事にまとめてみました!

※ サンプルコードだけ見たい方は、一番最後にリンクを貼っています。

👨‍👩‍👧‍👧 この記事のおすすめ読者

・簡易的なチャットアプリを作りたい方

・Next.js と相性のいい/流行りのライブラリを知りたい方

・Next.js(App Router)のサンプルコードが見たい方

📚 使用技術

Next.js(App Router)

https://nextjs.org/docs/app

Nextjs14からstableになったServer ActionsとRoute Group、Route Handlerを使ってみたかったのでApp Routerを採用しています。

実際使ってみた感想として、Route Groupはページ管理がしやすくて好きです。他の機能も開発体験は良く使いやすいです。

Shadcn UI

https://ui.shadcn.com/

デザインもシンプルで、アコーディオンやタブなどのコンポーネントを利用できるかつオーバライドも簡単にできるので非常にUI構築に便利です。

Drizzle ORM

https://orm.drizzle.team/

DrizzleはヘッドレスTypeScript ORMで、シンプルかつ効果的なORM。
SQLライクなクエリとリレーショナルAPIを組み合わせ、高性能と柔軟性を持っており、サーバーレスに対応し、複数のデータベースと接続が可能です。

今最も勢いのあるORMです。個人的にはPrismaより好みです。

PlanetScale(データベース)

https://planetscale.com/

PlanetScaleは、開発者の使い勝手を損なうことなく、スケールアップ、高性能、信頼性を提供するMySQL互換のサーバーレスデータベースです。このデータベースを使用することで、水平分割のメリットを享受しながら、スキーマの変更に伴う障害や、実装の複雑さを避けつつ、多くの高度なデータベース機能を手軽に利用できます。(ドキュメントより)

データベースにブランチ機能があり、ロールバック等の手間も削減されると思います。

Clerk(認証)

https://clerk.com/

こちらも最近人気の出てきている認証ライブラリで、App Routerとも相性がいいです。

コンポーネントを貼り付けるだけで、すぐに認証周りは完成します。

また、カスタマイズもGUI上で簡単にできるのでおすすめです。

SWR(React Hooks ライブラリ)

https://swr.vercel.app/ja

データ取得のための React Hooks ライブラリ。

Route Handlerで作成したAPIを簡単に呼び出すことができます。

Bun(パッケージマネージャー)

https://bun.sh/

最近リリースされたオールインワンキットと呼ばれるBun。

パッケージのインストールやテスト、デバックがかなり高速です。

🚀 完成プロダクト

📝 機能要件

今回実装したチャットアプリの機能要件は以下です。
・新規登録/ログイン
・パブリックチャンネル作成
・メッセージの送受信
・登録ユーザーの表示

今後追加していくとしたら…
・プライベートチャネル作成
・リアルタイムの双方向通信 <- 1番実装したかったけど、Nextjs14とsocket.ioのドキュメントが少なくて諦めです。
・DM 機能
・個人プロフィールの編集
・画像/動画の送信

👨‍💻 テーブル設計

ユーザー情報テーブル (users)

フィールド名 説明 型 (Type) 制約 (Constraint)
id ユーザーID varchar Primary Key, Not Null, Default: createId()
email メールアドレス text Not Null
name 名前 text
avatar アバター text
createdAt 作成日時 timestamp Default: Now

チャンネル情報テーブル (channels)

フィールド名 説明 型 (Type) 制約 (Constraint)
id チャンネルID varchar Primary Key, Not Null, Default: createId()
ownerId オーナーID varchar Not Null
name チャンネル名 text Not Null
description 説明 text
createdAt 作成日時 timestamp Default: Now

チャンネルメンバー情報テーブル (channelMembers)

フィールド名 説明 型 (Type) 制約 (Constraint)
channelId チャンネルID varchar
userId ユーザーID varchar
createdAt 参加日時 timestamp Default: Now

メッセージ情報テーブル (messages)

フィールド名 説明 型 (Type) 制約 (Constraint)
id メッセージID varchar Primary Key, Not Null, Default: createId()
channelId チャンネルID varchar
userId ユーザーID varchar
content 内容 text Not Null
createdAt 送信日時 timestamp Default: Now

テーブル定義のサンプルコード

Drizzle を使ったテーブル定義は以下の通りです。

import { mysqlTable, primaryKey, text, timestamp, varchar } from "drizzle-orm/mysql-core";
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";

// ✨ ユーザー情報テーブル
export const users = mysqlTable("users", {
  id: varchar("id", { length: 36 })
    .primaryKey()
    .notNull()
    .$defaultFn(() => createId()),
  email: text("email").notNull(),
  name: text("name"),
  avatar: text("avatar"),
  createdAt: timestamp("createdAt").defaultNow(),
});
export const usersRelations = relations(users, ({ many }) => ({
  channelMembers: many(channelMembers),
}));

// ✨ チャンネル情報テーブル
export const channels = mysqlTable("channels", {
  id: varchar("id", { length: 36 })
    .primaryKey()
    .notNull()
    .$defaultFn(() => createId()),
  ownerId: varchar("ownerId", { length: 36 }).notNull(),
  name: text("name").notNull(),
  description: text("description"),
  createdAt: timestamp("createdAt").defaultNow(),
});
export const channelsRelations = relations(channels, ({ one, many }) => ({
  owner: one(users, {
    fields: [channels.ownerId],
    references: [users.id],
  }),
  messages: many(messages),
  channelMembers: many(channelMembers),
}));

// ✨ チャンネルメンバー情報テーブル
export const channelMembers = mysqlTable(
  "channelMembers",
  {
    channelId: varchar("channelId", { length: 36 }),
    userId: varchar("userId", { length: 36 }),
    createdAt: timestamp("createdAt").defaultNow(),
  },
  t => ({
    pk: primaryKey(t.channelId, t.userId),
  })
);
export const channelMembersRelations = relations(channelMembers, ({ one }) => ({
  user: one(users, {
    fields: [channelMembers.userId],
    references: [users.id],
  }),
  channel: one(channels, {
    fields: [channelMembers.channelId],
    references: [channels.id],
  }),
}));


// ✨ メッセージ情報テーブル
export const messages = mysqlTable("messages", {
  id: varchar("id", { length: 36 })
    .primaryKey()
    .notNull()
    .$defaultFn(() => createId()),
  channelId: varchar("channelId", { length: 36 }),
  userId: varchar("userId", { length: 36 }),
  content: text("content").notNull(),
  createdAt: timestamp("createdAt").defaultNow(),
});
export const messagesRelations = relations(messages, ({ one }) => ({
  channel: one(channels, {
    fields: [messages.channelId],
    references: [channels.id],
  }),
  user: one(users, {
    fields: [messages.userId],
    references: [users.id],
  }),
}));

💂‍♀️ 実装内容

実装中に少し躓いた部分のみ紹介していきます。

メッセージ送信時に再検証を行う

getMessage関数は、チャンネル内のメッセージを取得する関数で、createChatはメッセージデータをDBに格納する関数で、どちらもサーバー側で実行される関数です。

revalidateTagはNext.jsのキャッシュを再検証するためのAPIで、今回はこれを使って
メッセージのキャッシュを再検証して最新状態に保っています。

export const createChat = async (userId: string, channelId: string, formData: FormData) => {
  "use server";

  const chat = formData.get("chat") as string;
  if (chat === "") return; // * メッセージが空の場合は送信しない
  try {
    // * メッセージの作成
    await db.insert(messages).values({
      userId,
      channelId,
      content: chat,
    });
    // * revalidateTagを追加する
    revalidateTag("messages");
  } catch (error) {
    console.log(error);
  }
};

export const getMessage = async (channel: string) => {
  if (!channel) throw new Error("Channel not found...");

  const messagesData = await db.select().from(messages).where(eq(messages.channelId, channel)).innerJoin(users, eq(messages.userId, users.id)).orderBy(asc(messages.createdAt));
  
  // * revalidateTagを追加する
  revalidateTag("messages");

  return {
    messages: messagesData,
  };
};

参考ドキュメント:
https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#revalidating-data

新規登録時にWebhookを使ってDB登録する

新規登録時にデータベース上でも格納されるように、webhookを使ってAPI処理を行なっています。

ngrokを使ってローカルサーバーを外部に公開して、公開されたURLをclerkに登録して、新規登録されるたびに関数を走らせています。

/**
 * @description : 新規登録時に呼ばれるWebhook
 * @doc         : https://zenn.dev/hayato94087/articles/bfe72f794e0407
 */

import { NextRequest } from "next/server";
import { Webhook } from "svix";
import { headers } from "next/headers";
import { db } from "@/database";
import { users } from "@/database/schema";

const webhookSecret: string = process.env.NGROK_WEBHOOK_SECRET || "";

type Event = {
  data: Record<string, string | number>;
  object: "event";
  type: EventType;
};
type EventType = "user.created" | "user.deleted";

export async function POST(req: NextRequest) {
  const payload = await req.json();
  const payloadString = JSON.stringify(payload);
  const headerPayload = headers();
  const svixId = headerPayload.get("svix-id");
  const svixIdTimeStamp = headerPayload.get("svix-timestamp");
  const svixSignature = headerPayload.get("svix-signature");
  if (!svixId || !svixIdTimeStamp || !svixSignature) {
    console.log("svixId", svixId);
    console.log("svixIdTimeStamp", svixIdTimeStamp);
    console.log("svixSignature", svixSignature);
    return new Response("Error occured", {
      status: 400,
    });
  }
  const svixHeaders = {
    "svix-id": svixId,
    "svix-timestamp": svixIdTimeStamp,
    "svix-signature": svixSignature,
  };
  const wh = new Webhook(webhookSecret);
  let evt: Event | null = null;
  try {
    evt = wh.verify(payloadString, svixHeaders) as Event;
  } catch (_) {
    console.log("error");
    return new Response("Error occured", {
      status: 400,
    });
  }

  const eventType: EventType = evt.type;

  if (eventType === "user.created") {
    const { id, ...attributes } = evt.data;
    await db.insert(users).values({
      id: id as string,
      email: (attributes.email_addresses as any)[0].email_address as string,
    });
    return new Response("success!!", {
      status: 201,
    });
  } else if (eventType === "user.deleted") {
    const { id, ...attributes } = evt.data;
    console.log("user.deleted: ", id);

    return new Response("success!!", {
      status: 201,
    });
  }
}

参考記事:
https://zenn.dev/hayato94087/articles/bfe72f794e0407
(かなり丁寧でわかりやすいです!)

👾 リポジトリ

全てのコードはこちらで管理しています。
https://github.com/nt-mino/development

☺️ おわり

最後まで読んでいただきありがとうございます。
少しでもこの記事が参考になって、間接的ですが開発に貢献できれば嬉しいです。

👀 おまけ

弊社では、スマホやPC1つで完結する網羅的な教材と、無制限で解ける本番と同形式の模試で、短期間での資格取得を目指すことができる簿記のアプリ 『Funda簿記』 を運営しています。

少しでも興味のある方がいれば、リンクよりアクセスしていただくか、メールにてお願いします☺️

https://boki.funda.jp/

Discussion