Open5

Remix+CloudflareでWebサイトを作る 43(TTFBが遅いの原因はPrisma、Drizzleへ移行)

saneatsusaneatsu

【2024-11-21】結論:TTFBが遅いのはPrismaのせい

背景

Remix Meetup Tokyoに参加したので、その機会にこの「TTFB遅くて困ってる問題」を発表したところ原因はPrismaなので剥がすといいよというアドバイスを頂けた。

サイズは?

前の記事にも書いているがRemix+Cloudflareのアプリを作った直後は350iKBで、Prismaを入れると1250KiBになる。
これが今回Drizzleにするとなんと416KiBだった!!!

つまり、Prismaは900KiB、Drizzleは66KiBということになる。
Prismaは13.5倍以上でかい。すごい差だ。

Honoの作者でCloudflareの中の人でもある @yusukebeさんも参加していたんだけど大きさは速さに直結するとのこと。
また、Cloudflare Workerはアクセスしていない時間があると落ちる(ここどんな表現だったかうろ覚え)ことがありCloudflare側の設定でいじれるものではないらしい。

ということで、とにかくエッジで動かすからには小さいが大正義。

mizchiさんのスライドにも1MBをパフォーマンスバジェットにと書いてある。
https://speakerdeck.com/mizchi/server-side-javascript-notamenobandoruzui-shi-hua?slide=25

Prismaの問題点

また、Prismaを使っていると遅いのには以下のような問題があると教えていただいた。

https://x.com/kenn/status/1858982830904426985

https://x.com/jonbharrell/status/1859002511476355313

そしてPrismaもここ改善に取り組んでいるとのこと。

これは残念ながら事実だ。 しかし、私たちはこの1年間、パフォーマンスを向上させるために懸命に取り組み、成功させた。 来年はさらに努力するつもりであり、私たちが取り組んでいることを共有するのが待ちきれない。

その他

その後のXでの会話。
https://x.com/__saneatsu/status/1858886877513961543

https://x.com/techtalkjp/status/1858887307845398627

https://x.com/kenn/status/1858981362470187339

この話はZennで記事にする予定。

saneatsusaneatsu

【2024-11-21】Prisma→Drizzleへ移行

実装

1. インストール

pnpm add drizzle-orm drizzle-kit better-sqlite3

2. db/index.ts db/schema.tsを作成

開発環境だと.dev.vars に記述した値、プロダクションだとCloudflare Pagesに設定した変数を使うのではなくて、.env.*ファイルに記述したVITE_始まりの環境変数を使う。

前者の場合は context.cloudflare.env から取得できないがその使い方をしたくないという意図。

db/index.ts
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";

if (!import.meta.env.VITE_TURSO_URL) {
  throw new Error("🚫 VITE_TURSO_URL がありません");
}

if (!import.meta.env.VITE_TURSO_AUTH_TOKEN) {
  throw new Error("🚫 VITE_TURSO_AUTH_TOKEN がありません");
}

const client = createClient({
  url: import.meta.env.VITE_TURSO_URL,
  authToken: import.meta.env.VITE_TURSO_AUTH_TOKEN,
});

export const db = drizzle(client);
db/schema.ts
import { sql } from 'drizzle-orm';
import { integer,  sqliteTable, text, } from 'drizzle-orm/sqlite-core';

export const userTable = sqliteTable('user', {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text('name'),
  email: text('email').notNull().unique(),
  createdAt: text("createdAt")
    .notNull()
    .default(sql`(datetime(CURRENT_TIMESTAMP, '+9 hours'))`),
  updatedAt: text("updatedAt")
    .notNull()
    .default(sql`(datetime(CURRENT_TIMESTAMP, '+9 hours'))`).$onUpdate(() => new Date().toISOString()),
});

export type InsertUser = typeof userTable.$inferInsert;
export type SelectUser = typeof userTable.$inferSelect;

export const companyTable = sqliteTable('company', {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text('name').notNull().unique(),
  createdAt: text("createdAt")
    .notNull()
    .default(sql`(datetime(CURRENT_TIMESTAMP, '+9 hours'))`),
  updatedAt: text("updatedAt")
    .notNull()
    .default(sql`(datetime(CURRENT_TIMESTAMP, '+9 hours'))`).$onUpdate(() => new Date().toISOString()),
});

export type InsertCompany = typeof companyTable.$inferInsert;
export type SelectCompany = typeof companyTable.$inferSelect;

3. drizzle.config.tsを作成

drizzle.config.ts
import { defineConfig } from "drizzle-kit";

if (!import.meta.env.VITE_TURSO_URL) {
  throw new Error("🚫 VITE_TURSO_URL がありません");
}

// マイグレーションファイル用の設定
export default defineConfig({
  dialect: "sqlite",
  out: "./db/migrations",
  schema: "./db/schema.ts",
  dbCredentials: {
    url: import.meta.env.VITE_TURSO_URL,
  },
  introspect: {
    casing: "camel",
  },
  migrations: {
    prefix: "timestamp",
    table: "__drizzle_migrations__",
  },
  strict: true,
  verbose: true,
});

4. load-context.tsを修正

だいぶスッキリした。

load-context.ts
import type { PlatformProxy } from "wrangler";

declare module "@remix-run/cloudflare" {
  interface AppLoadContext extends ReturnType<typeof getLoadContext> {
    // This will merge the result of `getLoadContext` into the `AppLoadContext`
    cloudflare: Cloudflare;
  }
}

type Cloudflare = Omit<PlatformProxy<Env>, "dispose">;
type GetLoadContextArgs = {
  request: Request;
  context: {
    cloudflare: Cloudflare;
  };
}

export function getLoadContext({ context }: GetLoadContextArgs) {
  return context
}

5. envファイルの作成

.env.development
VITE_TURSO_URL=http://127.0.0.1:8080
VITE_TURSO_AUTH_TOKEN=xxxxxxxxxx
.env.production
VITE_TURSO_URL=<任意の値を入力>
VITE_TURSO_AUTH_TOKEN=<任意の値を入力>

6. package.jsonの更新

以下のようにする。

package.json
"scripts": {
    // 本番環境へデプロイ時に .env.production を見るようにする
    "build": "remix vite:build --mode production",
    "deploy": "pnpm run build && wrangler pages deploy ./build/client",
    // 開発環境立ち上げ時に .env.developmentを見るようにする。また、TursoのDBも立ち上げるようにしておく
    "dev": "concurrently \"remix vite:dev --mode development\" \"pnpm run db:dev\"",
    "db:dev": "turso dev --db-file db/dev.db",

7. マイグレーションファイルを作成、適用

# マイグレーションファイルを作成
pnpm drizzle-kit generate --name=init

# ローカルで適用
turso db shell http://127.0.0.1:8080 < db/migrations/20241120151112_init.sql

# Drizzle Studioを立ち上げるとテーブルが追加されていることが確認できる
pnpm drizzle-kit studio
open https://local.drizzle.studio

8. DBの値を取得するコードを修正

app/routes/db-connect.tsx
import { Suspense } from "react";

import { Await, useLoaderData } from "@remix-run/react";
import { db } from "db";
import { companyTable,  userTable } from "db/schema";
import type { InsertCompany, InsertUser } from 'db/schema';

async function getUsers(): Promise<InsertUser[]> {
  try {
    // ここらへんをDrizzle用の書き方にしている
    const users = await db.select().from(userTable)
    return users
  } catch (error) {
    console.error(error)
    throw new Error('Failed to fetch users')
  }
}

async function getCompanies(): Promise<InsertCompany[]> {
  try {
    const companies = await db.select().from(companyTable)
    return companies
  } catch (error) {
    console.error(error)
    throw new Error('Failed to fetch companies')
  }
}

export async function loader() {
  const users = getUsers()
  const companies = getCompanies()
  return { users, companies }
}

export default function DbConnectPage() {
  const { users, companies } = useLoaderData<typeof loader>();

  return (
    <div className="space-y-10">
      <h1 className="text-2xl">DB(Turso)からデータを取得</h1>

      <h2 className="text-xl">ユーザー一覧</h2>
      <Suspense
        fallback={
          <div>
            <p>loading...</p>
          </div>
        }
      >
        <Await resolve={users} errorElement={<p>error</p>}>
          {(users) => {
            console.dir(users);
            if (!users) {
              return <p>no data</p>
            }

            return <ul>
              {users.map((user) => <li key={user.id}>{user.email}</li>)}
            </ul>
          }}
        </Await>
      </Suspense>

      <h2 className="text-xl">会社</h2>
      <Suspense
        fallback={
          <div>
            <p>loading...</p>
          </div>
        }
      >
        <Await resolve={companies} errorElement={<p>error</p>}>
          {(companies) => {
            console.dir(companies);
            if (!companies) {
              return <p>no data</p>
            }

            return <ul>
              {companies.map((company) => <li key={company.id}>{company.name}</li>)}
            </ul>
          }}
        </Await>
      </Suspense>
    </div>
  );
}

9. デプロイ

pnpm run deploy
saneatsusaneatsu

メモ:process.envの値がサーバー立ち上げ直後にundefinedになってしまう場合の対処方法

背景

最初 load-context.ts で DB clientを作成するコードにしていたら process.envの値がサーバー立ち上げ直後にundefinedになってしまうという問題が発生していた。

結局このコードは使用していないがメモとして回避方法を書いておく。

手順

1. dotenvをインストール

pnpm add -D dotenv-cli

2. package.jsonのscriptを更新

読み込むファイルを指定する。

package.json
"dev": "dotenv -e .env.development -- remix vite:dev --mode development",

3. process.envでアクセス

TEST=hoge
// サーバー立ち上げ直後に procee.env.* がundefinedになるのを防ぐために明示的に指定する
// 環境ごとに読み込むenvファイルを動的に変更するために
// package.json の scripts に "dotenv -e .env.<FILE_PATH>" を追加している

dotenv.config(); // = `dotenv.config({ path: "./.env.development" })`

console.log(process.env.TEST) // hoge
saneatsusaneatsu

Cloudflare Workerでprocess.env使えないし備え付けのimport.meta.env 使えば良いだけなので ↑ のはなんの意味もなかった。

dotenv全消し。

saneatsusaneatsu

【2024-11-21】測定

BeforeもAfterもTursoはHobbyプラン。
つまり、課金してClod startがない状態。

Before

https://zenn.dev/link/comments/fbe3ed3805f277

デプロイ対象 URL TTFB
Cloudflare Worker /json-placeholder-200 390, 400ms
/json-placeholder-1000 510, 520ms
/db-connect 1800, 2120, 2200, 2330ms
Cloudflare Pages /json-placeholder-200 240, 430ms
/json-placeholder-1000 500, 600msms
/db-connect 1970, 2250,2310ms 👀 比較対象はココ!

After

/db-connect だけ見れば良い。
1000msくらい早くなった!
600msにはまだ少しあるけどまぁ許せるくらいかな。

デプロイ対象 URL TTFB
Cloudflare Pages /db-connect 1020, 1100, 1150, 1210ms