Cloudflare Workers のためのフルスタックツールキット Superflare を試してみた
Superflare は Cloudflare Workers 用のフルスタックツールキットです。D1 Database 向けの ORM や R2 Storage 向けのユーティリティなどの機能を提供しています。Superflare 自体はフレームワークを謳っておりません。実際に、Superflare は Remix、Next.js、Nuxt.js などのフレームワークと組み合わせることで効果を発揮します。
Superflare は Cloudflare Workers 用のフルスタックツールキットです。D1 Database 向けの ORM や R2 Storage 向けのユーティリティなどの機能を提供しています。
Superflare 自体はフレームワークを謳っておりません。実際に、Superflare は Remix、Next.js、Nuxt.js などのフレームワークと組み合わせることで効果を発揮します。
2023 年 4 月 1 日現在、Superflare Remix のみに対応しています。
Getting Started
Cloudflare Workers アカウントの作成
Cloudflare Workers を動かすためには(ローカル環境も含めて)Cloudflare Workers のアカウントを作成する必要があります。アカウントを作成するには下記サイトから「Sign up」をクリックします。
プランの選択は無料プランである「Free」プランで問題ありません。
プロジェクトの作成
Cloudflare Workers のプロジェクトを作成するためにはコマンドラインツールである wrangler をインストールします。
npm install -g wrangler
インストールが完了したことを確認しましょう。
wrangler --version
⛅️ wrangler 2.13.0
--------------------
アカウントの作成が完了したら、以下のコマンドを実行して Superflare のプロジェクトを作成します。
npx superflare@latest new
Cloudflare のリソースにアクセスするためには認証が必要です。ログインしていない状態で上記のコマンドを実行すると、以下のようにログインを促されます。Yes を選択してログインします。
◇ Hmm. Looks like you're not logged in yet.
│
◆ You need to be logged into Wrangler to create a Superflare app. Log in now?
│ ● Yes / ○ No
└
コマンドを実行するとブラウザのタブが開きアクセスを許可するか聞かれますので「Allow」を選択します。認証に成功していると、ターミナルに以下のように表示されます。
◇ Everything looks good!
│
◇ 👋 Heads-up: ───────────────────────────────────────────────╮
│ │
│ Before using R2, Queues, and Durable objects, │
│ make sure you've enabled them in the Cloudflare Dashboard. │
│ https://dash.cloudflare.com/ │
│ Otherwise, the following commands might fail! 😬 │
│ │
├──────────────────────────────────────────────────────────────╯
│
◆ Where would you like to create your app?
│ ./my-superflare-app
└
Hands-up にあるように、R2、Queues、Durable objects を使用する前に Cloudflare のダッシュボードで有効化する必要があります。今回のチュートリアルでは R2 を使用するため、ダッシュボードで R2 を有効化しておきます。https://dash.cloudflare.com/ にアクセスして、左のメニューから「R2」を選択し、「Purchase R2 Plan」をクリックします。
R2 プランを有効にすると、利用料に応じて課金が発生します。R2 の Pricing をよく確認して自己責任で有効化してください。
有効化が完了したら、ターミナルに戻りプロジェクトのディレクトリ名(ここでは ./my-superflare-app
)を入力して Enter キーを押します。
有効化する機能を選択する画面が表示されます。Database Models
と Storage
のみを選択します。
◆ What features of Superflare do you plan to use? We'll create the resources
for you.
│ ◼ Database Models (We'll create D1 a database for you)
│ ◼ Storage
│ ◻ Queues
│ ◻ Broadcasting
└
選択したリソースを作成するかどうか聞かれます。Yes を選択しましょう。
◆ We'll create the following resources for you:
- D1 Database: my-superflare-app-db (bound as DB)
- R2 Bucket: my-superflare-app-bucket (bound as BUCKET)
Do you want to continue?
│ ● Yes / ○ No
└
D1 の作成でエラーが発生した場合
私が実行した際には、以下のようにエラーが発生して D1 Database の作成に失敗しました。
│ ❌ D1 Database: │
│ │
│ ✘ [ERROR] A request to the Cloudflare API (/accounts/xxxxxx/d1/database) failed. │
│ │
│ Authentication error [code: 10000]
このエラーが表示された場合、一度ログアウトして再度ログインしてみてください。
wrangler logout
wrangler login
ログインし直した後、以下のコマンドで D1 Database の作成を再試行します。
wrangler d1 create my-superflare-app-db
その後、wrangler.json
データベースの設定を手動で追記します。database_id
は wrangler d1 create
コマンドの実行時に表示されるほか、wrangler d1 list
コマンドで確認できます。
{
"d1_databases": [
{
"binding": "DB",
"database_name": "my-superflare-app-db",
"database_id": "xxxxxx"
}
],
}
superflare.config.ts
にも追加が必要です。
import { defineConfig } from "superflare";
export default defineConfig<Env>((ctx) => {
return {
appKey: ctx.env.APP_KEY,
+ database: {
+ default: ctx.env.DB,
+ },
storage: {
default: {
binding: ctx.env.BUCKET,
},
},
};
});
リソースの作成が完了したら、以下のコマンドでプロジェクトの作成を完了します。
cd ./my-superflare-app
npm install --legacy-peer-deps
npx superflare migrate
npx superflare migrate
は D1 Database のマイグレーションを行うコマンドです。デフォルトの状態では users
テーブルが作成されます。
プロジェクトの作成が完了したら、以下のコマンドでローカルサーバーを起動します。
npm run dev
http://127.0.0.1:8788 にアクセスすると、以下のように表示されます。
npx superflare@latest new
で作成したプロジェクトは Remix の上に構築されています。ですので、基本的な機能やディレクトリ構造などは Remix のドキュメントを参考にするとよいでしょう。
認証機能
新しく Superflare のプロジェクトを作成したときのコードを見てみましょう。Superflare ではデフォルトで認証機能が備わっています。
/auth/regiser
と /auth/login
は Superflare により提供された基本的な認証画面です。/auth/register
にアクセスすると素朴なユーザー登録画面が表示されます。適当なメールアドレスとパスワードを入力して登録してみましょう。
ログインに成功すると、/dashboard
にリダイレクトされ。ログイン状態であることが確認できます。
「Log out」ボタンをクリックするとログアウトできます。ログアウト後再度ダッシュボードに訪れると、ログイン画面にリダイレクトされます。ログイン画面では登録画面で入力したメールアドレスとパスワードを入力することでログインできます。
登録画面
それでは、Superflare ではどのように認証機能が実装されているのかを見ていきましょう。まずは、ユーザー登録画面です。Remix はファイルベースのルーティングを提供しています。/auth/register
に該当するファイルは app/routes/auth/register.tsx
です。
ここでは action
関数によってフォームから送信されたデータを受け取り処理が行われています。
import { json, redirect, type ActionArgs } from "@remix-run/cloudflare";
import { User } from "~/models/User";
import { hash } from "superflare";
export async function action({ request, context: { auth } }: ActionArgs) {
// 既にログインしている場合はダッシュボードにリダイレクト
if (await auth.check(User)) {
return redirect("/dashboard");
}
// フォームから送信されたデータを受け取る
const formData = new URLSearchParams(await request.text());
const email = formData.get("email") as string;
const password = formData.get("password") as string;
// メールアドレスが既に登録されているかどうかをチェック
if (await User.where("email", email).count()) {
return json({ error: "Email already exists" }, { status: 400 });
}
// ユーザーを作成
const user = await User.create({
email,
password: await hash().make(password),
});
// ログイン状態にする
auth.login(user);
return redirect("/dashboard");
}
この action
関数は Remix により提供されている機能です。action
関数はサーバーのみで実行される関数であり、主にデータの更新などの処理を行います。サーバー側で実行される関数には同じく loader
関数があります。loader
と action
関数の違いは処理が実行される順番です。GET
以外のリクエストが送信された場合は、action
関数が先に実行され、その後 loader
関数が実行されます。
action
関数の引数では request
と context
が渡されています。request
は Fetch API の Request オブジェクト であり、フォームから送信したデータを取得できます。
context
に含まれる auth
は Superflare により提供されている Superflare Auth です。API は Laravel の Auth Facade とよく似ています。
auth.check()
はログイン状態を確認する関数です。User
モデルを引数に受け取り、ログインしている場合は true
を返します。
import { User } from "~/models/User";
if (await auth.check(User)) {
return redirect("/dashboard");
}
User
モデル
この User
モデルは認証機能を利用する場合必須です。モデルとは D1 データベースのテーブルに対応するアプリケーション層の抽象化されたレイヤーです。Superflare ではモデルを作成することで、データベースのリレーションを定義したり、データの操作が簡単に行えます。
User
は users
テーブルに対応するモデルです。プロジェクトを作成したときにあらかじめ UserModel
に対応する users
テーブルのマイグレーションが作成されているはずです。
users
テーブルは id
、email
、password
カラムを持っています。
import { Schema } from "superflare";
export default function () {
return Schema.create("users", (table) => {
table.increments("id");
table.string("email").unique();
table.string("password");
table.timestamps();
});
}
モデルは TypeScript の Class として定義されています。モデルのクラス名はテーブル名の単数形として対応しており、Model
クラスを継承しています。モデルクラスを作成した後 Model.register
を呼び出すことでモデルを登録し、モデルを利用できるようになります。
User
モデルのクラスは app/models/User.ts
にあります。
import { Model } from "superflare";
export class User extends Model {
toJSON(): Omit<UserRow, "password"> {
const { password, ...rest } = super.toJSON();
return rest;
}
}
Model.register(User);
export interface User extends UserRow {}
UserRow
interface は users
テーブルのレコードを表しています。この型定義は superflare.env.d.ts
ファイルに配置されています。これは npx superflare migrate
を実行したときに自動的に生成されます。
// This file is automatically generated by Superflare. Do not edit directly.
interface UserRow {
id: number;
email: string;
password: string;
createdAt: string;
updatedAt: string;
}
toJSON
メソッドは User
モデルのインスタンスがどのように JSON にシリアライズされるかを定義しています。デフォルトではリレーションを含む全てのカラムがプロパティとして返されます。ここでは password
カラムを除外してシリアライズされるようにしています。
モデルのシリアライズは Remix ではサーバー側の loader
関数からクライアントに渡されるときに行われます。
ユーザーの作成
User
モデルの機能を一通り説明したので、action
関数の実装に戻りましょう。request
オブジェクトよりフォームから送信されたデータを取得してします。前述の通り、request
オブジェクトは Fetch API の Request
オブジェクトですから、Web API の標準的な方法でフォームデータを取得できます。
const formData = new URLSearchParams(await request.text());
const email = formData.get("email") as string;
const password = formData.get("password") as string;
続いて既に登録されているメールアドレスかどうかを確認します。モデルの where()
メソッドを使うことで、テーブルのレコードを検索できます。count()
メソッドは取得したレコードの数を返します。レコードの数が 1 以上であれば 400 エラーを返します。Remix では json 関数を使うことで簡単に application/json
レスポンスを返すことができます。
if (await User.where("email", email).count()) {
return json({ error: "Email already exists" }, { status: 400 });
}
登録されていないメールアドレスであればユーザーを作成します。create()
メソッドを使うことで、モデルのインスタンスを作成し、データベースに保存されます。パスワードは Superflare が提供する hash()
関数を使ってハッシュ化します。
const user = await User.create({
email,
password: await hash().make(password),
});
create()
メソッドは作成したモデルのインスタンスを返します。このインスタンスを auth.login()
関数に渡すことで、ログイン状態にします。ログイン状態とした後 redirect()
関数を使って /dashboard
にリダイレクトします。
auth.login(user);
return redirect("/dashboard");
ログイン状態は SuperflareSession クラスによりセッションに`保存されています。
ダッシュボード画面
ダッシュボード画面はログイン状態でないと表示できないようになっています。このような典型的な処理はサーバー側で実行される loader
関数において auth.check
を呼び出してログイン状態かどうかチェックすることで実現しています。ログイン状態でない場合は redirect()
関数を使って /login
にリダイレクトします。
import { type LoaderArgs, redirect, json } from "@remix-run/cloudflare";
import { User } from "~/models/User";
export async function loader({ context: { auth } }: LoaderArgs) {
if (!(await auth.check(User))) {
return redirect("/auth/login");
}
return json({
user: (await auth.user(User)) as User,
});
}
loader
関数はログインしているユーザーを user
プロパティに含む JSON として返します。Remix において loader
関数が返すデータを取得するには
useLoaderData() 関数を使います。
import { useLoaderData } from "@remix-run/react";
export default function Dashboard() {
const { user } = useLoaderData<typeof loader>();
return (
<>
<h1>Dashboard</h1>
<p>You're logged in as {user.email}</p>
<form method="post" action="/auth/logout">
<button type="submit">Log out</button>
</form>
</>
);
}
ログアウト
ダッシュボード画面でログアウトボタンを押すと、/auth/logout
に POST リクエストが送信されます。app/routes/auth/logout.ts
では action
関数内で auth.logout()
関数を呼び出してログアウト状態にします。
import { type ActionArgs, redirect } from "@remix-run/server-runtime";
export async function action({ context: { auth } }: ActionArgs) {
auth.logout();
return redirect("/");
}
ログイン画面
ログイン画面の実装は登録画面と大きく変わりません。ここでは、auth.login
関数の代わりに auth.attempt
関数を使っています。auth.attempt
関数は、ユーザーのクレデンシャル情報(=メールアドレスとパスワード)を引数に取り、ログインに成功した場合は true
を返します。ログインに失敗した場合は false
を返します。
import { Form, Link, useActionData } from "@remix-run/react";
import { json, redirect, type ActionArgs } from "@remix-run/cloudflare";
import { User } from "~/models/User";
export async function action({ request, context: { auth } }: ActionArgs) {
if (await auth.check(User)) {
return redirect("/dashboard");
}
const formData = new URLSearchParams(await request.text());
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (await auth.attempt(User, { email, password })) {
return redirect("/dashboard");
}
return json({ error: "Invalid credentials" }, { status: 400 });
}
アルバムアプリの作成
ここまで Superflare プロジェクトを作成した時点での基本的な実装を確認しました。ここからは実際に簡単なアルバムアプリを作成していきましょう。このアプリケーションでは、ユーザーは自由に画像をアップロードして、アルバムを作成できます。また、アルバムにはタイトルと説明をつけることができます。
完成したコードは以下のレポジトリを参照してください。
テーブルの設計は以下のように提案していただきました。この設計を元に、モデルを作成していきます。
## ユーザー(users)テーブル
ID (主キー)
ユーザー名
パスワード
メールアドレス
## アルバム(albums)テーブル
ID (主キー)
タイトル
説明
ユーザーID (外部キー)
## 画像(images)テーブル
ID (主キー)
キー (R1 Storage のキー)
アルバムID (外部キー)
ユーザーID (外部キー)
アップロード日時
マイグレーションの作成
アルバムモデルから作成しましょう。まずはモデルに対応するテーブルを作成する必要があります。テーブルを作成するには、マイグレーションファイルを作成して、マイグレーションを実行します。
以下のコマンドでマイグレーションを作成できます。albums
は作成するテーブル名です。
npx superflare generate migration albums
コマンドの実行に成功すると db/migrations
ディレクトリにマイグレーションファイルが作成されます。
import { Schema } from 'superflare';
export default function () {
// return ...
}
マイグレーションフィルでは Schema
インスタンスを返す関数を default export する必要があります。Schema
の create()
メソッドを使ってテーブルを作成します。
import { Schema } from "superflare";
export default function () {
return Schema.create("albums", (table) => {
table.increments("id");
table.string("title");
table.string("description");
table.integer("userId");
table.timestamps();
});
}
id
はテーブルの主キーになります。Superflare ではテーブルの主キーは必ず id
という名前であることが求められます。
userId
は users
テーブルの id
と紐付けるための外部キーです。users
テーブルと albums
テーブルは、1 対多の関係となります。
timestamps()
メソッドを呼び出すと、createdat
と updatedAt
のカラムが追加されます。これらのカラムは、レコードの作成日時と更新日時を自動で管理するためのものです。
同じように、画像モデルに対応するマイグレーションを作成します。
npx superflare generate migration images
import { Schema } from "superflare";
export default function () {
return Schema.create("images", (table) => {
table.increments("id");
table.string("key");
table.integer("albumId");
table.integer("userId");
table.timestamps();
});
}
マイグレーションファイルを作成できたので、マイグレーションを実行しましょう。npx superflare migrate
コマンドを実行すると、マイグレーションが実行されます。--create
オプションを渡すと、自動でモデルを作成してくれます。
npx superflare migrate --create
コマンドが成功すると、migrations
ディレクトリに実行された SQL が出力されます。
-- Migration number: 0001 2023-04-01T06:45:58.421Z
-- Autogenerated by Superflare. Do not edit this file directly.
CREATE TABLE albums (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
userId INTEGER NOT NULL,
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
-- Migration number: 0002 2023-04-01T06:45:58.421Z
-- Autogenerated by Superflare. Do not edit this file directly.
CREATE TABLE images (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL,
albumId INTEGER NOT NULL,
userId INTEGER NOT NULL,
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
モデルの作成
--create
オプションを渡してマイグレーションを実行した場合、app/models
ディレクトリに Album.ts
と Image.ts
が作成されます。モデル同士のリレーションを定義するために、それぞれのファイルを編集する必要があります。
それぞれのテーブルのリレーションを確認しておきましょう。ユーザーは複数のアルバムを所有できます。アルバムは複数の画像を含むことができ、画像は特定のアルバムに所属し、また、アップロードしたユーザーにも関連しています。
1 人のユーザーが複数のアルバムを所有できるというリレーションは one-to-many
リレーションと呼ばれています。このリレーションを表現するために、ユーザーモデルに hasMany()
メソッドを使います。
+ import { Album } from "./Album";
+ import { Image } from "./Image";
export class User extends Model {
+ albums!: Album[] | Promise<Album[]>;
+ images!: Image[] | Promise<Image[]>;
+
+ $alubms() {
+ return this.hasMany(Album);
+ }
+
+ $images() {
+ return this.hasMany(Image);
+ }
toJSON(): Omit<UserRow, "password"> {
const { password, ...rest } = super.toJSON();
return rest;
}
}
one-to-many
の many 側のモデルには、belongsTo()
メソッドを使いモデルに所属していること表します。
import { Model } from "superflare";
import { Album } from "./Album";
import { User } from "./User";
export class Image extends Model {
album!: Album | Promise<Album>;
user!: User | Promise<User>;
$album() {
return this.belongsTo(Album);
}
$user() {
return this.belongsTo(User);
}
toJSON(): ImageRow {
return super.toJSON();
}
}
Model.register(Image);
export interface Image extends ImageRow {}
import { Model } from "superflare";
import { Image } from "./Image";
import { User } from "./User";
export class Album extends Model {
images!: Image[] | Promise<Image[]>;
user!: User | Promise<User>;
$images() {
return this.hasMany(Image);
}
$user() {
return this.belongsTo(User);
}
toJSON(): AlbumRow {
return super.toJSON();
}
}
Model.register(Album);
export interface Album extends AlbumRow {}
これでモデルの定義が完了しました。
ダッシュボードからアルバムを作成する
ダッシュボードからアルバムを作成するために、アルバムを作成するフォームを作成します。Remix ではプレーンな HTML でフォームを作成します。つまり、React や Vue.js などのライブラリで作成するフォームのように JavaScript を用いて状態管理やフォームのサブミットの制御を行わないということです。
export default function Dashboard() {
const { user } = useLoaderData<typeof loader>();
return (
<>
<h1>Dashboard</h1>
<p>You're logged in as {user.email}</p>
+ <form method="post">
+ <fieldset>
+ <legend>Create Album</legend>
+
+ <div>
+ <label htmlFor="title">Title</label>
+ <input name="title" type="text" required />
+ </div>
+
+ <div>
+ <label htmlFor="description">Description</label>
+ <input name="description" type="text" required />
+ </div>
+
+ <button type="submit">Create</button>
+ </fieldset>
+ </form>
<form method="post" action="/auth/logout" style={{ marginTop: "2rem" }}>
<button type="submit">Log out</button>
</form>
</>
);
}
フォームを作成したら、フォームのサブミットを処理するための action
関数を作成します。form
の action
属性を指定しない場合、フォームは同じページに POST リクエストとして送信されるので、app/routes/dashboard.tsx
ファイルに action
関数を定義します。
フォームデータは await request.formData()
メソッドで取得できます。取得したフォームフォームデータを引数とし、AlbumModel
の create()
メソッドでアルバムを作成します。
現在ログインしているユーザーの ID は auth.id()
メソッドで取得できます。
ここでは簡単のため、バリデーションやエラー処理を無視しています。
export async function action({ request, context: { auth, session } }: LoaderArgs) {
// ログインしているか確認
if (!(await auth.check(User))) {
return redirect("/auth/login");
}
// フォームデータを取得
const body = await request.formData();
const title = body.get("title") as string;
const description = body.get("description") as string;
// ログインしているユーザーのIDを取得
const userId = await auth.id();
// アルバムを作成
await Album.create({
title,
description,
userId,
});
// フラッシュメッセージをセット
session.flash("success", "Album created successfully");
// ダッシュボードにリダイレクト
return redirect("/dashboard");
}
アルバムを作成した後は session
オブジェクトを使ってフラッシュメッセージをセットしています。
フラッシュメッセージは一度だけ表示されるメッセージです。フラッシュメッセージはセッションに保存されるので、リダイレクトされた先のページで表示されます。
session
は auth
オブジェクトと同じく loader
、action
関数の引数から受け取ることで利用できます。
アルバムを作成した後は、ダッシュボードにリダイレクトします。
フラッシュメッセージを表示するためには、session.getFlash()
メソッドでセッションから読み取る必要があります。フラッシュメッセージが存在する場合にはその要素が、存在しない場合には undefined
が返ります。
フラッシュメッセージが存在すれば、すべてのページに共通で表示したいため app/routes/root.tsx
ファイル内の loader
関数内でフラッシュメッセージを読み取ります。コンポーネント側では undefined
以外の場合にのみフラッシュメッセージを表示するように制御します。
import { json, LoaderArgs, MetaFunction } from "@remix-run/cloudflare";
import {
useLoaderData,
} from "@remix-run/react";
export async function loader({ context: { session } }: LoaderArgs) {
const flash = session.getFlash("success");
return json({
flash,
});
}
export default function App() {
const { flash } = useLoaderData<typeof loader>();
return (
<html lang="en" className="h-full bg-gray-100 dark:bg-black">
<head>
<Meta />
<Links />
</head>
<body>
{flash && <div style={{ color: "green" }}>{flash}</div>}
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
試しにアルバムを作成してみましょう。フラッシュメッセージが表示されれば成功です。
アルバム一覧を表示する
ダッシュボード画面で作成したアルバムの一覧を表示するようにします。app/routes/dashboard.tsx
ファイルの loader
関数内で Album
モデルの where()
メソッドを使って、ログインしているユーザーに属するアルバムをすべて取得します。
where
の第一引数にはフィールド名、第二引数には条件値を指定します。また、orderBy
メソッドを使って、作成日時の降順でソートします。
このようにモデルではメソッドチェーンを使ってクエリを組み立てることができます。
+ import { Album } from "~/models/Album";
export async function loader({ context: { auth } }: LoaderArgs) {
if (!(await auth.check(User))) {
return redirect("/auth/login");
}
+ // ログインしているユーザーのアルバムを取得
+ const albums = await Album.where("userId", await auth.id()).orderBy(
+ "createdAt",
+ "desc"
+ );
return json({
user: (await auth.user(User)) as User,
+ albums,
});
}
コンポーネント側では useLoaderData
関数から取得した albums
プロパティを使ってアルバムの一覧を表示します。
export default function Dashboard() {
- const { user } = useLoaderData<typeof loader>();
+ const { user, albums } = useLoaderData<typeof loader>();
return (
<>
<h1>Dashboard</h1>
<p>You're logged in as {user.email}</p>
<form method="post">
// ...
</form>
+ <h2>Albums</h2>
+ {albums.length ? (
+ <ul>
+ {albums.map((album) => (
+ <li key={album.id}>
+ <a href={`/albums/${album.id}`}>{album.title}</a>
+ </li>
+ ))}
+ </ul>
+ ) : (
+ <p>You don't have any albums yet</p>
+ )}
<form method="post" action="/auth/logout" style={{ marginTop: "2rem" }}>
<button type="submit">Log out</button>
</form>
</>
);
}
もう少し一覧から情報を取得できるように、アルバムが持っている写真の数を表示するようにしましょう。アルバムと写真のリレーションは one-to-many
です。クエリを発行する際にリレーションを先にロードするため with
メソッドを使います。
これは eager load と呼ばれるもので、N + 1 問題を回避するために ORM でよく使われる手法です。
const albums = await Album.where("userId", await auth.id())
.orderBy("createdAt", "desc")
+ .with("images");
さらに、Album
モデルの仮想プロパティとして imageCount
を定義します。あるモデルに対してゲッターメソッドとして定義することで、データベースに保存されていないフィールドも追加できます。
仮想プロパティもシリアライズされるように、toJSON()
メソッドの返り値のプロパティに追加します。
export class Album extends Model {
images!: Image[] | Promise<Image[]>;
user!: User | Promise<User>;
$images() {
return this.hasMany(Image);
}
$user() {
return this.belongsTo(User);
}
get imageCount(): number {
return this.$images.length;
}
toJSON(): AlbumRow & { imageCount: number } {
return {
...super.toJSON(),
imageCount: this.imageCount,
};
}
}
アルバムの一覧を表示している箇所に album.imageCount
を表示するようにします。
<ul>
{albums.map((album) => (
<li key={album.id}>
<a href={`/albums/${album.id}`}>{album.title}</a>
+ <span> - {album.imageCount} images</span>
</li>
))}
</ul>
アルバム詳細ページを作成する
アルバムに画像をアップロードできるように、詳細ページを作成します。動的なルーティングを使って、アルバムの ID に応じてアルバムの詳細ページを表示します。Remix では動的なセグメントは $id
のように先頭に $
をつけて定義します。
app/routes/albums/$id.tsx
という名前でファイルを作成します。
import { type LoaderArgs, redirect, json } from "@remix-run/cloudflare";
import { useCatch, useLoaderData, useParams } from "@remix-run/react";
import { Album } from "~/models/Album";
import { User } from "~/models/User";
export async function loader({ params, context: { auth } }: LoaderArgs) {
if (!(await auth.check(User))) {
return redirect("/auth/login");
}
// ログインしているユーザーのIDを取得
const userId = await auth.id();
// パスパラメーターからアルバムのIDを取得
const albumId = Number(params.id);
// アルバムのIDとユーザーのIDをもとにアルバムを取得
const album = await Album.where("id", albumId)
.where("userId", userId)
.first(); // first() は最初の1件のみを取得するメソッド
// アルバムが存在しない場合、404を返す
if (!album) {
throw new Response("Not found", { status: 404 });
}
return json({ album });
}
export default function AlbumPage() {
const { album } = useLoaderData<typeof loader>();
return (
<>
<h1>{album.title}</h1>
<p>{album.description}</p>
</>
);
}
ダッシュボード画面と同様に、ログイン状態をチェックしてログインしていない場合はログインページにリダイレクトします。
動的なパラメーターは loader
関数の引数 params
から受け取ります。ルーティングから受け取った albumId
とログインしているユーザーの userId
をもとにアルバムを取得します。もしアルバムが存在しない場合は 404
として Response
オブジェクトを throw します。
コンポーネントでは useLoaderData
フックを使って loader
関数から返されたアルバムを取得して表示します。
loader
関数内で Response
オブジェクトを throw した場合には CatchBoundary でキャッチしてエラー画面を描画します。throw された Response
オブジェクトは useCatch
フックで取得できます。
さらに、Catchboundary
でもキャッチされない想定外の例外は、ErrorBoundary
でキャッチされます。
import { useCatch, useParams } from "@remix-run/react";
export function CatchBoundary() {
const caught = useCatch();
const params = useParams();
if (caught.status === 404) {
return (
<>
<h1>404</h1>
<p>Album {params.id} is not found.</p>
</>
);
}
throw new Error("Something went wrong");
}
export function ErrorBoundary() {
return (
<>
<h1>Something went wrong</h1>
</>
);
}
画像をアップロードする
アルバムの詳細ページに画像をアップロードするフォームを追加して、アルバムに画像を追加できるようにしましょう。ファイルをアップロードするには multipart/form-data
でフォームを送信する必要があります。
export default function AlbumPage() {
const { album } = useLoaderData<typeof loader>();
return (
<>
<h1>{album.title}</h1>
<p>{album.description}</p>
+ <form method="post" encType="multipart/form-data">
+ <fieldset>
+ <legend>Upload Photo</legend>
+ <div>
+ <label htmlFor="file">Photo</label>
+ <input name="file" type="file" required />
+ </div>
+ </fieldset>
+ </form>
</>
);
}
同ファイルの action
関数内でファイルアップロードを処理します。Superflare より提供されている parseMultipartFormData
関数を使うことで、ファイルをメモリに展開せず、R2 にファイルを直接ストリーミングできます。
R2 ストレージの操作は storage
オブジェクトを使用します。storage().putRandom()
メソッドはランダムな名前を生成してファイルをアップロードします。extension
オプションを指定することで、ファイルの拡張子を指定できます。
import { storage, parseMultipartFormData } from "superflare";
export async function action({ request }: LoaderArgs) {
if (!(await auth.check(User))) {
return redirect("/auth/login");
}
const formData = await parseMultipartFormData(
request,
async ({ name, filename, stream }) => {
// ファイル以外のフィールドは無視
if (name !== "file") {
return undefined;
}
const r2Object = await storage().putRandom(stream, {
extension: filename?.split(".").pop(),
});
return r2Object.key;
}
);
}
ファイルのアップロードに成功したら、ストレージのキーを取得して Image
モデルに保存します。
import { storage, parseMultipartFormData } from "superflare";
export async function action({
request,
params,
context: { auth, session },
}: LoaderArgs) {
const formData = // ...
const albumId = Number(params.id);
const userId = await auth.id();
// データベースに画像のパスを保存
await Image.create({
key: formData.get("file") as string,
albumId,
userId,
});
session.flash("success", "Photo uploaded successfully");
return redirect(`/albums/${albumId}`);
}
画像一覧を表示する
アルバムに紐づく画像を表示できるようにしましょう。はじめに、ストレージのキーを受け取り画像データを返すエンドポイントを作成します。
app/routes/images/[key].ts
を作成します。
import { type LoaderArgs, redirect, json } from "@remix-run/cloudflare";
import { User } from "~/models/User";
import { storage } from "superflare";
const getContentTypes = (extension: string) => {
switch (extension) {
case "png":
return "image/png";
case "jpg":
case "jpeg":
return "image/jpeg";
case "gif":
return "image/gif";
case "svg":
return "image/svg+xml";
default:
return "application/octet-stream";
}
};
export async function loader({ context: { auth }, params }: LoaderArgs) {
if (!(await auth.check(User))) {
throw new Response("Unauthorized", { status: 401 });
}
// パスパラメーターからストレージのキーを取得
const storageKey = params.key as string;
// ストレージから画像を取得
const obj = await storage().get(storageKey);
// 存在しないキーの場合は 404 を返す
if (!obj) {
return new Response("Not found", { status: 404 });
}
const extension = obj.key.split(".").pop() || "";
return new Response(obj.body, {
headers: {
"Content-Type": getContentTypes(extension),
},
});
}
パスパラメーターからストレージのキーを取得して、storage().get()
メソッドでキーを指定して画像を取得します。obj.body
にはストリームが入っているので、Response
に渡すことで画像を返すことができます。
アルバム詳細画面に戻りましょう。await album.images
でアルバムに紐づくすべての画像を取得します。
export async function loader({ params, context: { auth } }: LoaderArgs) {
// ...
const album = await Album.where("id", albumId)
.where("userId", userId)
.first();
// アルバムが存在しない場合、404を返す
if (!album) {
throw new Response("Not found", { status: 404 });
}
return json({
album,
+ images: await album.images,
});
}
コンポーネントで useLoaderData
から images
を受け取り画像を表示します。src
のパスには先程作成したストレージのキーを受け取り画像データを返すエンドポイントを指定します。
export default function AlbumPage() {
const { album, images } = useLoaderData<typeof loader>();
return (
<>
<h1>{album.title}</h1>
<p>{album.description}</p>
{ // ...}
+ <h2>Photos</h2>
+ <ul
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fill, minmax(256px, 1fr))",
+ gap: "1rem",
+ }}
+ >
+ {images.map((image) => (
+ <li key={image.id}>
+ <img src={`/images/${image.key}`} alt="" width="256" height="144" />
+ </li>
+ ))}
+ </ul>
</>
);
}
画像を削除する
最後に、画像を削除する機能を実装します。画像を削除するエンドポイントは app/routes/images/[id].ts
に作成します。
HTML のフォームでは DELETE
メソッドを送信できないので、POST
メソッドで送信してサブミットボタンの value
で削除リクエストかどうかを判定します。
import { type LoaderArgs, redirect } from "@remix-run/cloudflare";
import { Image } from "~/models/Image";
import { storage } from "superflare";
export async function action({
request,
params,
context: { auth },
}: LoaderArgs) {
if (!(await auth.check(User))) {
throw new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const intent = formData.get("intent") as string;
// 削除リクエストでない場合はエラーを返す
if (intent !== "delete") {
throw new Response(`The intent ${intent} is not supported`, {
status: 400,
});
}
// パスパラメーターからストレージのキーを取得
const storageKey = params.key as string;
// データベースから画像データを取得
const image = await Image.where("key", storageKey).first();
if (!image) {
throw new Response("Not found", { status: 404 });
}
// リダイレクト先のアルバムID
const albumId = image.albumId;
// 画像を作成したユーザー以外は削除できない
if (image.userId !== (await auth.id())) {
throw new Response("Forbidden", { status: 403 });
}
// データベースとストレージから画像を削除
await Promise.all([image.delete(), storage().delete(storageKey)]);
return redirect(`/albums/${albumId}`);
}
デプロイ
Cloudflare Workers にデプロイするには npx wrangler
コマンドを使用します。npx superflare@latest new
コマンドでプロジェクトを作成していると wrangler.json
が設定ファイルになります。設定ファイルに wrangler.json
を使用している場合には -j
オプションを指定する必要があります。
デプロイを実行する前に、以下を実行する必要があります。
- 本番 DB のマイグレーション:
npx wrangler d1 migrations apply <DB_NAME>
- シークレットに
APP_KEY
を設定(dev.vars
の値):npx wrangler secret put APP_KEY
npx wrangler d1 migrations apply my-superflare-app-db -j
npx wrangler secret put APP_KEY -j
また、wrangler.json
に以下の設定を追加します。
{
"compatibility_date": "2023-04-02",
"site": {
"bucket": "./public"
}
}
compatibility_data
は Cloudflare Workers における API の互換性を制御するための設定です。このフィールドには、特定の日付を指定できます。Cloudflare Workers は日々ランタイムの更新が行われますが、その中には互換性のない変更も含まれています。
compatibility_date
に設定した日付より後にリリースされた API は適用されなりので、互換性を壊す変更を避けることができます通常、プロジェクトを作成した日付を指定することになります。
準備ができたら、以下のコマンドを実行してデプロイします。
npm run build && npx wrangler publish -j
デプロイに成功すると、URL が表示されるのでブラウザでアクセスしてみましょう。
感想
Superflare の作者は長い間 Laravel で開発しており、また Laravel からも影響を受けていると述べられているだけあって、設計はだいぶ Laravel に似ている気がします。with
メソッドを使った eager loading や、Auth などの機能も Laravel を触ったことがある人ならすぐに馴染めるかと思います。
フレームワークとして必要な機能が一通り揃っていますので、手軽にフルスタックなアプリケーションを作成したい場合には Superflare を使うのも良いかもしれません。
設計思想の中で、Verndor Lock-in as a Feature と述べられているのも興味深いです。Superflare は Cloudflare の上で構築されているのでベンダーロックインを回避するすべはありません。しかし、ベンダーロックインを受け入れることにより、物事をシンプルに解決できるメリットがあると主張されています。