🍋

Deno製のWEBフレームワーク Fresh でチャットアプリを作成するまでの軌跡 〜第二章〜

2022/09/26に公開

はじめに

数日前に、以下のような記事を書きました。

https://zenn.dev/gonnakayama/articles/533ff414672e4f

前回は、Denoの特徴を深掘りし、基礎を習得することに専念しました。

今回は DenoのフルスタックWebフレームワークFreshを使ってサンプルアプリを製作し、Deno Deploy を使ったプロダクションデプロイまで実施してみた結果をまとめていきたいと思います。

Freshについて

スピード、シンプルさ、信頼性を重視して作られたWEBフレームワークです。下記特徴があります。



一つ一つを理解し、自分の言葉で解釈した内容を下記にまとめます。

Just-in-time rendering on the edge.

Freshでは全てのページはサーバサイドレンダリングです。デフォルトではクライアントサイドがJavaSvriptを実行しない作りになっています。クライアントサイドであるブラウザは素のHTMLをサーバから受け取って描画するだけです。その方がFreshの基本理念であるスピード・パフォーマンスを向上させることができるからです。

とはいえ、WEBページである以上、JavaScriptの実行が必要な時も必ずあります。「そこどうしてんの?」という話は Island based client hydration for maximum interactivity.で解説しています。

また、エッジという表現についてですが、FreshDeno Deployで動く前提で作られています。サーバ側で組み立てたHTMLをCDNから配信するという意味でエッジという表現を用いているのだと思います。

Island based client hydration for maximum interactivity.

Islands Architectureモデルで最大限のインタラクティブ性を実現していると言っています。

従来型のクライアントサイドレンダリングなSPAの場合は、ブラウザにJavaScriptフレームワークを送信、実行して、サーバ側から送られたデータを基にDOMを生成し、WEBページが表示されるという処理が行われていました。

一方で、前述したようにFreshでは、サーバサイドでHTMLが生成され、それがブラウザに送られてくるため、ブラウザは受け取ったHTMLをすぐに表示できます。パフォーマンス面で言えば、Freshの方に軍配が上がります。

しかし、これではWEBフォームなどでユーザと対話的なやりとりが実現できません(例えば、リアルタイム性のあるバリデーションなど)。UXに欠けた質素なWEBページとなってしまいます。

そこでFreshでは、そういったインタラクティブ性が必要な箇所だけ、クライアントサイドでレンダリングさせ、残りのstaticな部分はサーバサイドでレンダリングさせるという手法を取っています。

これをIslands Architectureモデルと言います。このモデルの解説は後述しています。

Zero runtime overhead

Just-in-time rendering on the edge.のセクションで記述した

Freshでは全てのページはサーバサイドレンダリングです。デフォルトではクライアントサイドがJavaSvriptを実行しない作りになっています。クライアントサイドであるブラウザは素のHTMLをサーバから受け取って描画するだけです。

という内容そのままを受け取って貰えば大丈夫です。クライアントサイドでJavaScriptを実行しないため、ラインタイムのオーバヘッドが0だと言っているのだと思います。

ただこれだけだと、インタラクティブなユーザーインターフェイスには不向きなので、Islands Architectureモデルを導入しているという感じです。

No build step.

散らばったHTMLタグやJavaScriptなどをバンドルして・・・のような工程が不要だと言っています。

確かに、DenoDeploydで成果物をデプロイして動作検証した時もビルドの工程はありませんでした。

エントリーポイントとなるmain.tsDenoDeployに教えてあげるだけでした。

No configuration necessary.

例えば、React+Node.jsで開発初めるぞー!となった時に初期設定が色々あると思います。フォーマッター入れたり、Lint入れたり・・・

Deno+Freshでは、デフォルトで色々なツールも準備してあるので、その辺のコードベース作成的な作業負荷が小さいよ、と言っているのだと思います。

確かに、deno lintdeno fmtといったそこそこ充実したツールが準備してありますし、開発を始める上での敷居の低さはあります。

TypeScript support out of the box.

私が以前書いた「Deno製のWEBフレームワーク Fresh でチャットアプリを作成するまでの軌跡 〜第一章〜」のTypeScriptを標準でサポートしている辺りと類似しているかと思います。

特徴を理解したところで開発開始

まずは公式ドキュメントから読みました。

https://fresh.deno.dev/docs/introduction

色々書いてありますが、大事なことを列挙すると以下だと思います。

  1. Freshを使うには、Deno1.25.0以降がインストールされている事
  2. FreshIslands Architectureモデルを採用している事
  3. FreshでのパスルーティングはNext.js等で見られるファイルベースルーティングと類似している事
  4. 開発者はFresh製Webアプリケーションをインターネットに迅速かつ簡単にデプロイ可能である事

1. Freshを使うには、Deno1.25.0以降がインストールされている事

これについては特段言及することはありません。公式ドキュメントにインストール方法が記載されているので参考にしてください。

https://deno.land/manual@v1.25.4/getting_started/installation

2. FreshIslands Architectureモデルを採用している事

Islands Architecture?これなんぞ?

WEBページにおいてstaticな部分 は、サーバサイドでHTMLをレンダリングしてクライアントに返し、
JavaScriptの実行が必要な部分クライアントサイドでレンダリングさせる手法のことです。

JavaScriptを必要としない部分にはJavaScriptを読み込ませないようにします。

今回製作したチャットアプリのトップページは下記のように考えることができます。



WEBページの大部分はstaticHTMLで構成されており、それらを極力サーバサイドでレンダリングし、動的なアプリの部分のみJavaScriptが実行されるようにすることでパフォーマンスを向上させています。

一方でページ遷移の度に、ブラウザからサーバへのリクエストが実行されることになるので、ルーティングの体験がやや悪くなるという指摘もあります。が、そこまで体感は変わらないというのが個人的な感想です。

実装方法

例えば、routes/index.tsxで islandコンポーネントであるislands/Header.tsx を読み込みたい場合を考えます。

下記手順で実装が可能です。

1. island直下にHeader.tsxを作成する

全てのページはサーバー側でレンダリングされますが、前述したようにislandコンポーネントを作成することにより、クライアント側でもレンダリングされる仕組みを利用することができます。

island直下に作成したtsxファイルは、Freshにおいてislandコンポーネントと解釈されます。

それをどうやってFreshが解釈するのか?についてですが、fresh.gen.tsのマニュフェストで統括しているようです。

fresh.gen.ts
const manifest = {
  routes: {
    "./routes/_404.tsx": $0,
    "./routes/api/connect/index.ts": $1,
    "./routes/api/read/index.ts": $2,
    "./routes/api/send/index.ts": $3,
    "./routes/auth/index.tsx": $4,
    "./routes/create/rooms/index.tsx": $5,
    "./routes/create/users/index.tsx": $6,
    "./routes/error/index.tsx": $7,
    "./routes/index.tsx": $8,
    "./routes/room/[room].tsx": $9,
  },
  islands: {
    "./islands/Chat.tsx": $$0,
    "./islands/Header.tsx": $$1,
  },
  baseUrl: import.meta.url,
  config,
};

island直下にファイルを追加すると、自動的に上記fresh.gen.tsに追記されます。

Freshプロジェクト作成時に予め準備されているファイルやディレクトリについては公式ドキュメントを参考ください。

https://fresh.deno.dev/docs/getting-started/create-a-project

2. Header.tsxを完成させる

下記のようなコンテンツとユーザメニューを持ったヘッダを作成してみました。

islands/Header.tsx
import {tw} from "twind";
import {asset} from "$fresh/runtime.ts";

export default function Header({hContents, userName}: { hContents: string; userName: string; }) {
    window.onclick = (event: MouseEvent) => {
        if (event.target.matches('.user-menu-dropdown')) {
            document.getElementById("dropdown").classList.toggle("show");
        } else {
            const dropdowns = document.getElementsByClassName("dropdown-content");
            for (let i = 0; i < dropdowns.length; i++) {
                const openDropdown = dropdowns[i];
                if (openDropdown.classList.contains('show')) {
                    openDropdown.classList.remove('show');
                }
            }
        }
    }

    return (
        <>
            <div className={tw("flex justify-between items-center")}>
                <h1 className={tw("font-extrabold text-5xl text-gray-800")}>{hContents}</h1>
                <div className="dropdown">
                    <button className={"hover:bg-gray-400 rounded-xl p-2 dropbtn user-menu-dropdown"}>
                        <img alt={`/${userName}`} src={asset(`/${userName}.svg`)}
                             className="shadow-lg rounded-full mx-auto max-w-120-px w-12 h-12 user-menu-dropdown"/>
                        <div className="pt-2 text-center">
                            <h5 className="text-xl font-bold text-blueGray-700 user-menu-dropdown">{userName}</h5>
                        </div>
                        <div id="dropdown" className="dropdown-content">
                            <a href="/auth">ログアウト</a>
			    <a href="/create/users">ユーザ管理</a>
                        </div>
                    </button>
                </div>
            </div>
        </>
    )
}

イベントハンドラーであるwindow.onclickは、islandコンポーネント上でのみ実行されます。routes以下に作成したtsxファイルに対して、イベントハンドラーを含むJavaScriptを書いても実行されません。

3. islandコンポーネントを読み込み、HTMLに埋め込む

./routes/index.tsx
・
・
中略
・
・
import Header from "../islands/Header.tsx";
・
・
中略
・
・

export default function Home({data}: PageProps<Data>) {
    const twIsRead = tw("bg-green-500 text-white font-bold py-2 px-4 rounded-3xl");
    const twIsNotRead = tw("bg-red-500 text-white font-bold py-2 px-4 rounded-3xl");
    return (
        <div className={tw("min-h-screen bg-blue-100")}>
            <Head>
                <title>fresh sample</title>
                <link rel="stylesheet" href="/css/style.css"/>
            </Head>
            <div
                className={tw(
                    "max-w-screen-sm mx-auto px-4 sm:px-6 md:px-8 pt-12 pb-20 flex flex-col"
                )}
            >
+             <Header hContents={"Home"} userName={data.loggedInUserName} /> 
                <section className={tw("mt-8")}>
                    <div className={tw("flex justify-between items-center")}>
                        <h2 className={tw("text-3xl font-bold text-gray-800 py-4")}>トークルーム一覧</h2>

以上でクライアント側でもレンダリングされるislandコンポーネントを作成する事ができます。

3. FreshでのパスルーティングはNext.js等で見られるファイルベースルーティングと類似している事

Freshでのパスルーティングはシンプルです。今回のチャットアプリにおけるファイル名ルートパターン一致するパスの組み合わせは以下表のようになります。

ファイル名 ルートパターン 一致するパス メモ
routes/index.tsx / /
routes/room/[room].tsx /room/:roomId /room/1, /room/21
routes/auth/index.tsx /auth /auth
routes/create/rooms/index.tsx /create/rooms /create/rooms
routes/create/users/index.tsx /create/users /create/users
routes/error/index.tsx /error /error
routes/_404.tsx サーバから404を受領した場合に自動でこのページが表示される
routes/_app.tsx 全ルーティングに適応されるページ。titleタイトルタグ等を一括して設定可能
routes/api/connect/index.ts /api/connect /api/connect
routes/api/read/index.ts /api/read /api/read
routes/api/send/index.ts /api/send /api/send

ちなみに、一致するパスから対象のファイルが選択されますが、対象のtsxにカスタムハンドラというものを定義することができます。

カスタムハンドラは、Request => ResponseまたはRequest => Promise<Response>の形式の関数であり、カスタムハンドラを定義しない場合は、ページコンポーネントをレンダリングするだけのデフォルトハンドラを使用することになります。

以下のような感じでカスタムハンドラを記述しておくと、ページがGETされた時にDBからリソースを取得したり、セッションCookieの検証を実施したりすることができます。

routes/index.tsx
・
・
中略
import {HTTP_STATUS_CODES} from "../constant/index.ts";

type Data = {
    rooms: Room[];
    loggedInUserName: string;
}

export const handler: Handlers<Data> = {
    async GET(req: Request, ctx) {
        let payload: Payload;
        try {
            payload = await checkSession(req);
        } catch (_) {
            const response = new Response("", {
                status: HTTP_STATUS_CODES.REDIRECT,
                headers: {Location: "/auth"},
            });

            deleteCookie(response.headers, "token");
            return response;
        }

        const rooms = await findAllRoom(payload.name);
        return ctx.render({
            rooms: rooms,
            loggedInUserName: payload.name as string,
        });
    }
};

export default function Home({data}: PageProps<Data>) {
    const twIsRead = tw("bg-green-500 text-white font-bold py-2 px-4 rounded-3xl");
    const twIsNotRead = tw("bg-red-500 text-white font-bold py-2 px-4 rounded-3xl");
    ・
    ・
    中略
    ・
    ・
    <Header hContents={"Home"} userName={data.loggedInUserName} />

カスタムハンドラ内で取得したデータをHTMLに渡すことにより、データ取得後に画面を描画するというスキームを実現することができます。

上記では、Dataという型エイリアスを持ったPropsGETカスタムハンドラからHTMLに渡しており、islandコンポーネントであるHeader.tsxdataをパラメータでもらっている事が分かります。

さらに公式ドキュメントには

ハンドラーはctx.render() を呼び出す必要はありません

という記載があります。つまり以下のようなページを返さないカスタムハンドラを定義することも可能ということになります。POSTリクエストを受け付けるようなAPIも定義可能ということですね〜。

routes/api/read/index.ts
import {Handlers} from "$fresh/server.ts";
import {ApiSetReadMessage} from "../../../types/index.ts";
import {markMessageAsRead} from "../../../communication/database.ts";

export const handler: Handlers = {
    async POST(req, _ctx) {
        const data = (await req.json()) as ApiSetReadMessage;
        try {
            await markMessageAsRead(data.userName, data.roomId);
        } catch (e) {
            console.log({e});
        }
        return new Response("OK");
    },
};

4. 開発者はFresh製Webアプリケーションをインターネットに迅速かつ簡単にデプロイ可能である事

スピード!スピード!スピード!と3回書きたくなる位、あっという間にプロダクションデプロイできました。

1. Deno Deployにアクセスする

Deno Deployにアクセスし、Sign upを選択してください。

2. アカウント連携の実施

以下のようなログインフォームが表示されるので、GitHubのログイン情報を入力し、サインインしてください。

3. プロジェクトを作成する

アカウント連携が成功すると、下記のような画面に到達できます。New Projectを選択してください。

4. Deploy from GitHub repository

GitHubリポジトリにリンクさせて、masterブランチへのpush時にデプロイを有効にします。それぞれ入力する項目を解説します。

リポジトリとプロジェクトを選択します。プライベートなレポジトリでも選択可能です。

プロダクションコードのエントリーポイントとなるファイルを指定します。必ずmain.tsを選択してください。

プロジェクトに名前を付けることができます。URLのドメイン部にも関連してきます。

環境変数の上書きが可能です。右下のAdd Env Variableから追加が可能です。

①〜④が入力し終わったら、Linkを押下してください。程なくしてプロジェクトの作成が完了します。

5. デプロイされたプロジェクトにアクセスして動作検証する

デプロイが完了すると、以下のページに到達します。

AnalyticsLogsのタブでは、デプロイしたアプリのアクセス状況やログを参照することが可能です。

Deploymentsタブでは、直近のデプロイがいつ実施されたのか管理でき、SettingsではProject Nameの変更、環境変数の追加・削除・変更、ドメインの追加、プロジェクトの削除(DANGER ZONE)などを実施することが可能です。

発行されたURLにアクセスしてみると、無事アクセスすることができました!

最初はログインページにリダイレクトされる

ログインするとHOME画面に着地

ちょっと話は脱線しますが・・・

deno.landに公開されているモジュールを試しに使ってみたので、ついでに記載しておきます。

今回、WEBフォームからユーザ入力値を受け取って、DBにリソース作成する処理があったので、入力値を検証するバリデーションライブラリについて、Denoの標準ライブラリから選定して使ってみました。

使ったライブラリ

https://deno.land/x/validasaur@v0.15.0

WEBフォーム周りの実装

ログインフォームの実装でユーザから入力されたIDとPWを検証するバリデータを定義してみました。
PWを生の状態で管理している件についてはご容赦ください。CSRF対策は実施しています。

routes/auth/index.tsx
中略

async POST(req: Request, ctx) {
        const form = await req.formData();
        const csrfToken = form.get("csrf_token") as string || "";
        const username = form.get("username") as string || "";
        const password = form.get("password") as string || "";

        const cookies = getCookies(req.headers);
        const csrfCookieToken = cookies.csrf_cookie_token || "";

        const verifyTokenResult = verifyToken(
            csrfToken.toString(),
            csrfCookieToken,
        );

        if (!verifyTokenResult) {
            return pageLoad(ctx, "認証に失敗しました");
        }

+        const [passes, errors] = await validators.userAuthentication({ username, password });
	
	const firstErrors = firstMessages(errors);
        if (!passes) {
            return pageLoad(
                ctx,
                username,
                password,
                firstErrors.username as string ?? "",
                firstErrors.password as string ?? "",
                undefined
            );
        }

ユーザ作成時のバリデーションも同じ処理を使い回したかったため、validator/index.tsuserAuthenticationという関数を定義し、その中でvalidasaurを使ったバリデーションを実装してみました。

validator/index.ts
import {match, required, validate} from "validasaur";

// 'ユーザ作成時'及び'ログイン時'にuserAuthenticationを使用
export const userAuthentication = async (input: { username: string, password: string }) => {
+    return await validate(input, {
+        username: [required, match(/^[0-9a-zA-Z]{1,20}$/)],
+        password: [required, match(/^[0-9a-zA-Z]{4,20}$/)],
+    }, {
+        messages: {
+            "username.required": "ユーザ名は必須入力項目です",
+            "username.match": "ユーザ名が入力条件を満たしていません",
+            "password.required": "パスワードは必須入力項目です",
+            "password.match": "パスワードが入力条件を満たしていません",
        }
    });
};

上記のような感じで実装することができます。個人的には視認性が良いし、エラーメッセージも勿論カスタマイズ出来るので気に入っています。

Tips

404ページについて

routes/_404.tsxを定義しておくと、サーバ側から404ステータスコードを受け取った時には独自の404NotFoundページを表示できます。私は下記のようなページを作成しました。



環境変数について

dotenv/load.tsをインポートすることで自動的に.envファイルを読み込み可能です。
以下のようにしてDeno.envから環境変数を読み込みます。

communication/database.ts
import "dotenv/load.ts";

const dummy = new Dummy({
    user: Deno.env.get("USER_ID"),
    password: Deno.env.get('PASSWORD')
});
.env
USER_ID=*******
PASSWORD==*******
CSRF_KEY==*******
JWT_CRYPTO_KEY==*******

また、DenoDeployのUIで環境変数を上書きすることが可能です。

成果物

実際に作成したチャットアプリは下記URLで公開しています。
DBはsupabaseを使っています。フリープランが500MBまでなので、定期的にDBリソースは削除予定です。

https://chat-gonnakayama.deno.dev/

動作確認用のログイン情報は下記です。

ユーザID パスワード
tanaka tanaka
yasukawa yasukawa

搭載した機能

下記機能を実装しています。

  1. 認証機能
  2. チャット機能(DenoのBroadCastAPIを活用し、双方向通信でリアルタイム性のあるチャットを実装)
  3. ユーザ作成機能
  4. トークルーム作成機能
  5. トークの既読機能

このチャットアプリは今後も改善し、バージョンアップしていこうかなと思います。deno+Freshに慣れるためには適したテーマ・題材だったなあと思います。

行き詰まった時には・・・

Deno+Freshを使ったプロダクトがまだ少ないので、行き詰まった時に参考になる情報がインターネット上に少ないです。Deno本家のWEBサイトは最近Deno+Freshで書き直されたようでして、GitHubにもソースコードが公開されているので、教科書代わりにしています。

https://github.com/denoland/dotland

参考にした記事

以下の記事を参考にさせていただきました。大変よくまとまっています。

https://zenn.dev/azukiazusa/articles/fresh-tutorial

Discussion