2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

StackblitzでWebアプリ開発(Stackblitz + Next.js(Server Actions) + Drizzle ORM + SQLite)

Last updated at Posted at 2023-11-09

全体の9割ぐらいは試行錯誤メモみたいな内容なので、お時間のない方は飛ばしてください。

Next.js + Server Actions = ほぼフルスタックフレームワーク

Next.js 13.4でアルファリリースされ、14.0でstableになったServer Actions
リリース当初から気になっていて触ってみてたのですが、Server Componentからawaitでデータが取れるのが快適すぎて、もう全部これでいいんじゃないかとすら思っています。

オンライン開発環境

オンライン開発環境を提供するサービスは、GitHub CodespaceCodeSandboxをはじめとして幾つかありますが、UXがよくて個人的に気に入っているのがStackblitzです。

Stackblitzのいいところ

  • 同社が開発しているWebContainerにより、ブラウザ上でNode.jsが動作します。
    • エディタがMonacoなのでモバイルは厳しそう(未確認)ですが、ブラウザが動作すればどこでもWebアプリの開発ができます。
    • GitHub CodespaceやCodeSandboxのcloud sandboxはmicroVMのスペックが操作感に直結しますが、Stackblitzは手元のマシンで動くので、ある程度スペックの高いPCだとこっちのほうがサクサクです。
  • GitHubリポジトリを開くこともできるし、Stackblitz上で作ったプロジェクトをGitHubに持っていくこともできる
  • 2023年11月現在、有料プランは主にチーム開発に関連する機能であり、一人で使う分にはFreeでほぼ困りません。
    • Freeでもプライベートプロジェクトが作れます。
    • 色々御託を並べましたが、結局これが一番の理由です。
    • プランの詳細は公式のPricingでご確認ください。

Stackblitzで全部済ませたい

  • Stackblitz内では謎の技術(未調査)により、SQLiteを使うことができます。
  • Next.js - Server Actions - ORM - SQLiteとすることで、ローカルでの開発とそん色ない開発体験が実現できるのでは?

ところが

  • Stackblitzには、その特性上やむを得ないことですが、Wasmでないネイティブアドオンを使用できないという大きな制約があります。

  • これが厄介で、雑にNext.jsを14.0にアップグレードしたところ、動きませんでした。

    • 今回はスタータープロジェクトにあるNext.js 13.5.1のままで進めました。
      • これは多分そのうち解消されると思いますが…。
  • そして、Prismaも動きません。

    • 公式には「Sequelizeなら動く」みたいなことが書いてますが、今回軽く試した限りでは、Sequelizeはちょっと冗長すぎると感じました。
      • 一回Prisma体験しちゃうとしんどいなぁという印象です。
    • しかし、かといって今更ORM無しでやるのはもっとしんどい。

Drizzle ORM

  • そこで、白羽の矢が立ったのがDrizzle ORMです。
    • If you know SQL — you know Drizzle.とのことで、SQLっぽい書き方ができます。
export default async function getTasks() {
    const result = await db.select().from(tasks).where(isNull(tasks.deletedAt));
    return result;
}
  • メソッドチェーンで書くので今っぽくはないかもしれませんが、個人的にはPrismaよりも直感的で気に入りました。
    • 私はSQLを直で書くことが多かったのと、C#でLINQを通ってきたからというのもありそうです。
  • 試していませんが、not SQL-likeな書き方もできるとのこと。

better-sqlite3で接続(失敗)

  • 公式ドキュメントBetterSqlite3でSQLiteに接続する方法が書いてあるので試してみました。
  • ここで例の制約が牙を剥きます。
    • BetterSqlite3は速さを一つの売りにしていますが、おそらくはそのために大半がC++で書かれています。
      • 2023年11月現在 Wasmビルドは提供されておらず、動作しませんでした。

HTTP proxyで接続(成功)

  • 公式にはもう一つ、HTTP Proxyでの接続方法が書いてあります。
  • HTTP Proxyと言いつつ、単なるコールバック関数なので、これでいけそうです。
    • ちなみに、コールバック関数の型は以下の通り
export type AsyncRemoteCallback = (sql: string, params: any[], method: 'run' | 'all' | 'values' | 'get') => Promise<{
    rows: any[];
}>;
  • ただ、実装時にハマったのですが、実際には{rows: any[]}ではなく、method'all' | 'values'のときは{rows: string[][]}'get'のときは{rows: string[]}で返さないといけません。

  • コールバック関数内ではちゃんと取れてるように見えるのに、

{[{
    id: 1,
    createdAt: "2023-11-09 23:59:59",
    updatedAt: "2023-11-09 23:59:59",
    deletedAt: null,
    name: "買い物",
    description: "牛乳を買う"
}, ...]}
  • 返ってくるとこうなってて、なんでやねん👋って思ってました。
{[{
  id: undefined,
  createdAt: undefined,
  updatedAt: undefined,
  deletedAt: undefined,
  name: undefined,
  description: undefined
}, ...]}
  • ナンデ?と思いながらリポジトリを彷徨っていたらexamplesに以下の記述を見つけました。

Warning: You will be responsible for proper error handling in this part. Drizzle always waits for {rows: string[][]} so if any error was on http call(or any other call) - be sure, that you return at least empty array back

For get method you should return {rows: string[]}

  • それ!ドキュメントに!書いといてくれないかなぁ!?!? と心の底から思ったのですが、こんなプルリクも出てるので、今だけかもしれません。

完成品

  • 完成品と言いつつ、Next.jsのバージョンを含めて不安定な部分があるので、適宜直していく所存です。

    • なお、このリポジトリはバイナリファイルを扱う都合上、Stackblitzにログインした状態で開かないと、Drizzleのマイグレーションで止まります。
  • フォルダ構成は以下にしています。この辺は個々人々で好みがあると思いますが。

./data … dev.sqliteの配置先。dev.sqliteはgit管理から外してありますが、お好みで。
./drizzle … Drizzle ORMが吐くマイグレーションSQLの配置先。ここもgitからは外してあります。
./public … Next.jsの静的アセット置き場
./src
  /actions … Server Actionsを置くところ
  /app … Next.jsのApp Router
  /components … コンポーネントを置くところ
  /db … SQLiteへの接続スクリプトや、Drizzleで使うマイグレーション、スキーマ定義、シーダーを置くところ
  • ワークスペースの起動時にnpm installのほか、Drizzleのgenerate、マイグレーション用スクリプト、シード用スクリプトが流れるようになっています。

  • サンプルアプリはよくあるTODOリストですが、フロントで完結するのではなく、SQLiteを読み書きしています。

    • submitで発生するリロードで画面を書き換えてます。
    • めんどくさかったのでEditボタンの一連の挙動が若干エキセントリックになっていますが、ご容赦ください。
  • ORMにDrizzleを使っているので、後々データベースを差し替える必要が出てきても、まずまず軽微な修正でいけると思います。

    • 具体的には、./src/db/db.ts./src/db/schema.tsのデータ型を気にしておけば、ほぼ問題ないはずです。

終わりに

  • 「よい開発はよい道具から」と常々思っているのですが、初手の道具としては満足できる環境ができたと、個人的には思っています。
  • Drizzle ORMのリポジトリ内の記述によると、node-sqlite3への対応予定がありそうなので、将来的にはもっと簡単に使えるようになりそうです。
  • 自分なりの マイベストStackblitz環境 を作っている方がいらっしゃいましたら、参考にしたいのでぜひ教えてほしいです。

Appendix:実装や依存関係の解説

  • どこの馬の骨かわからん奴が作ったテンプレートを使うのは抵抗がある という向きもあると思いますし、自分が数か月後に なんでコイツこんなことやってんの と思う可能性も十分あるので、以下に2023年11月時点の記憶として、ポイントになりそうな部分の説明を残しておきます。
    • 規模の限界はありますが、個人が何か作るときに「自分が弄るところに自分の知らんコードがない」ことって案外大事だと思ってます。

package.json

  • Stackblitzのスタートアップコマンドを追加しています。
// package.json
...
  "stackblitz": {
    "startCommand": "npm run db && npm run dev"
  },
...
  • npm run dbについては後述。

devDependencies

ts-node

  • Drizzleのマイグレーションスクリプトとシード用スクリプトをTypeScriptで書くために入れてます。
// package.json
...
    "db:migrate": "ts-node --esm -O '{\"module\": \"commonjs\"}' ./src/db/migrate.ts",
    "db:seed": "ts-node --esm -O '{\"module\": \"commonjs\"}' ./src/db/seed.ts",
...

npm-run-all

  • これは別に無くてもいいのですが、データベース関連のscriptsをまとめて流すとき便利なので入れてます。
    • 当初はexpress経由でSQLiteとデータをやり取りしようと考えていて、サーバー同時起動のために入れてました。
// package.json
...
    "db": "run-s db:*",
    "db:generate": "drizzle-kit generate:sqlite",
    "db:migrate": "ts-node --esm -O '{\"module\": \"commonjs\"}' ./src/db/migrate.ts",
    "db:seed": "ts-node --esm -O '{\"module\": \"commonjs\"}' ./src/db/seed.ts",
...

npm run dbで、db:generatedb:migratedb:seedが順番に流れます。

eslint-plugin-tailwindcss

  • TailwindCSSを使うにあたっての、紳士淑女のたしなみ。

srcフォルダ

  • appcomponentsactionsは特別なことはしていないので割愛。
    • actionsのファイル構成は検討の余地ありですが、どうせ書き換える部分なのでおいておきます。

src/db/db.ts

  • これはIssueにも挙がってたのでそのうち改善されると思いますが、DrizzleはSQLiteのマイグレーション時、SQLiteでは対応していないSERIALをデータ型に指定しています。
  • このため、そのままだとidが全部nullになります。
    • えっ、入るんだ と思いましたが、試したら確かに複数レコードがid=nullで入ってました。
// https://github.com/drizzle-team/drizzle-orm/issues/1227
const after = `
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
    id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    hash text NOT NULL,
    created_at numeric
)`;
const before = '\n' +
    '\t\tCREATE TABLE IF NOT EXISTS "__drizzle_migrations" (\n' +
    '\t\t\tid SERIAL PRIMARY KEY,\n' +
    '\t\t\thash text NOT NULL,\n' +
    '\t\t\tcreated_at numeric\n' +
    '\t\t)\n' +
    '\t';

const remoteDataBase = drizzle(async (sql, params, method) => {
  const sqlProcessed = sql === before ? after : sql;
  ...
  • ちゃんとidが入らないのは気持ち悪いので、少々行儀が悪いですが、強引にトラップしてSQLを差し替えています。
const database = new sqlite3.Database("./data/dev.sqlite");
const remoteDataBase = drizzle(async (sql, params, method) => {
    const sqlProcessed = sql === before ? after : sql;
    console.log({ sql, params, method })
    try {
        const operation = method === 'run' ? run(database, sqlProcessed, params)
            : method === 'all' || method === 'values' ? all(database, sqlProcessed, params)
                : method === 'get' ? get(database, sqlProcessed, params)
                    : null;
        if (!operation) { throw new Error(`Method '${method}' is not implemented.`) }

        const result = await operation;
        return { rows: result as any[] };
    } catch (e: any) {
        console.error('An error has occured: ', e)
        return { rows: [] };
    }
});

export default remoteDataBase;
  • sqlにはプレースホルダ付きのSQL文、paramsにはパラメタが配列で来ます。methodはSQL文によってrunallvaluesgetが来ます。

  • 全部は書きませんが、runallgetの3メソッドを、大体以下のような形で実装しています。

    • sqlite3の各メソッドはPromisableではないので、Promiseでラップして返します。
const all = (db: sqlite3.Database, sql: string, ...params: any[]) => {
    return new Promise((resolve, reject) => {
        db.all(sql, ...params, function (this: sqlite3.Statement, err: Error, rows: any[]) {
            if (err) {
                return reject(err);
            }
            // convert object to value[][]
            const result = convertObjectToArray(rows).map((row) => convertObjectToArray(row))
            resolve(result);
        });
    });
}

const convertObjectToArray = (object: object) => {
    return Object.entries(object).map(([key, value]) => (value))
}
  • コールバック関数で受けてるthis: sqlite3.Statementは使ってません。

    • 最初アロー関数式で書いてたんですが、thisが受けられないからか、関数式じゃないと動かないようです。ずいぶんハマりました。
  • convertObjectToArrayは、{rows: string[][]}{rows: string[]}で返さなきゃいけないのに対応するため、objectを投げるとvalueの配列を返すようになっています。

src/db/migrate.ts

import { sql } from "drizzle-orm";
import db from "./db";
import { migrate } from 'drizzle-orm/sqlite-proxy/migrator';

migrate(db, async (migrationQueries: string[]) => {
    for (const query of migrationQueries) {
        await db.run(sql.raw(query));
    }
}, { migrationsFolder: './drizzle' }).catch((error: Error) => {
    console.log("An error has occurred on migration.")
    console.log({ error })
}).then(() => {
    console.log("Migration is completed.")
});
  • Drizzleのmigrate関数を呼ぶだけですが、sqlite-proxyの場合、migrationQueriesを手動で流してあげる必要があります。
    • for ... of構文を使ってるのは、順番に流してるアピールです。
      • 今回の環境だと、ワークスペースの起動のたびにsqlが一個できるだけなので、あまり意味はないですが…。

src/db/schema.ts

import {
  sqliteTable,
  text,
  integer
} from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';

const id = {
  id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }),
};
const timestamps = {
  createdAt: text('createdAt').default(sql`CURRENT_TIMESTAMP`),
  updatedAt: text('updatedAt').default(sql`CURRENT_TIMESTAMP`),
  deletedAt: text('deletedAt'),
};
const schemaBase = {
  ...id,
  ...timestamps,
};

export const tasks = sqliteTable('tasks', {
  ...schemaBase,
  name: text('name'),
  description: text('description'),
});
  • スキーマ定義ですが、がっつりTypeScriptなのがめっちゃお気に入りです。
    • どのテーブルでも使う項目をあらかじめ定義しておいて、スプレッド構文で突っ込めるのがよいです。
    • カラムの順番を気にする場合は注意が必要です。

src/db/seed.ts

import db from "./db";
import { tasks } from "./schema";

(async () => {
    await db.insert(tasks).values([
        { id: 1, name: "買い物", description: "牛乳を買う" },
        { id: 2, name: "ジム", description: "筋トレ30分、有酸素30分" },
        { id: 3, name: "読書", description: "リーダブルコード続き" },
    ]);
})();
  • シーディングはDrizzleに備え付けの機能ではなく、このスクリプトはinsertメソッドを使ってデータを突っ込んでいるだけです。
    • ts-nodeで実行するのですが、Top-level Awaitと{"module":"commonjs"}が干渉するので、関数の即時実行で書いてます。
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?