Deno製のWEBフレームワーク Fresh でチャットアプリを作成するまでの軌跡 〜第二章〜
はじめに
数日前に、以下のような記事を書きました。
前回は、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.
で解説しています。
また、エッジという表現についてですが、Fresh
はDeno 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.ts
をDenoDeploy
に教えてあげるだけでした。
No configuration necessary.
例えば、React+Node.js
で開発初めるぞー!となった時に初期設定が色々あると思います。フォーマッター入れたり、Lint入れたり・・・
Deno+Fresh
では、デフォルトで色々なツールも準備してあるので、その辺のコードベース作成的な作業負荷が小さいよ、と言っているのだと思います。
確かに、deno lint
、deno fmt
といったそこそこ充実したツールが準備してありますし、開発を始める上での敷居の低さはあります。
TypeScript support out of the box.
私が以前書いた「Deno製のWEBフレームワーク Fresh でチャットアプリを作成するまでの軌跡 〜第一章〜」のTypeScriptを標準でサポートしている
辺りと類似しているかと思います。
特徴を理解したところで開発開始
まずは公式ドキュメントから読みました。
色々書いてありますが、大事なことを列挙すると以下だと思います。
-
Fresh
を使うには、Deno1.25.0以降
がインストールされている事 -
Fresh
はIslands Architecture
モデルを採用している事 -
Fresh
でのパスルーティングはNext.js
等で見られるファイルベースルーティングと類似している事 - 開発者は
Fresh製Webアプリケーション
をインターネットに迅速かつ簡単にデプロイ可能である事
Fresh
を使うには、Deno1.25.0以降
がインストールされている事
1. これについては特段言及することはありません。公式ドキュメントにインストール方法が記載されているので参考にしてください。
Fresh
はIslands Architecture
モデルを採用している事
2.
Islands Architecture
?これなんぞ?
WEBページにおいてstaticな部分 は、サーバサイドでHTMLをレンダリング
してクライアントに返し、
JavaScriptの実行が必要な部分 はクライアントサイドでレンダリングさせる
手法のことです。
JavaScript
を必要としない部分にはJavaScript
を読み込ませないようにします。
今回製作したチャットアプリのトップページは下記のように考えることができます。
WEBページの大部分はstatic
なHTML
で構成されており、それらを極力サーバサイドでレンダリングし、動的なアプリの部分のみJavaScript
が実行されるようにすることでパフォーマンスを向上させています。
一方でページ遷移の度に、ブラウザからサーバへのリクエストが実行されることになるので、ルーティングの体験がやや悪くなるという指摘もあります。が、そこまで体感は変わらないというのが個人的な感想です。
実装方法
例えば、routes/index.tsx
で islandコンポーネントであるislands/Header.tsx
を読み込みたい場合を考えます。
下記手順で実装が可能です。
island
直下にHeader.tsx
を作成する
1. 全てのページはサーバー側でレンダリングされますが、前述したようにislandコンポーネント
を作成することにより、クライアント側でもレンダリングされる仕組みを利用することができます。
island
直下に作成したtsxファイルは、Fresh
においてislandコンポーネント
と解釈されます。
それをどうやってFreshが解釈するのか?についてですが、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
プロジェクト作成時に予め準備されているファイルやディレクトリについては公式ドキュメントを参考ください。
2. 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に埋め込む
・
・
中略
・
・
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コンポーネント
を作成する事ができます。
Fresh
でのパスルーティングはNext.js
等で見られるファイルベースルーティングと類似している事
3. 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の検証
を実施したりすることができます。
・
・
中略
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
という型エイリアスを持ったProps
をGETカスタムハンドラ
からHTML
に渡しており、islandコンポーネント
であるHeader.tsx
がdata
をパラメータでもらっている事が分かります。
さらに公式ドキュメントには
ハンドラーはctx.render() を呼び出す必要はありません
という記載があります。つまり以下のようなページを返さない
カスタムハンドラを定義することも可能ということになります。POSTリクエストを受け付けるようなAPIも定義可能ということですね〜。
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. デプロイされたプロジェクトにアクセスして動作検証する
デプロイが完了すると、以下のページに到達します。
Analytics
やLogs
のタブでは、デプロイしたアプリのアクセス状況やログを参照することが可能です。
Deployments
タブでは、直近のデプロイがいつ実施されたのか管理でき、Settings
ではProject Name
の変更、環境変数の追加・削除・変更、ドメインの追加、プロジェクトの削除(DANGER ZONE
)などを実施することが可能です。
発行されたURLにアクセスしてみると、無事アクセスすることができました!
最初はログインページにリダイレクトされる
ログインするとHOME画面に着地
ちょっと話は脱線しますが・・・
deno.landに公開されているモジュールを試しに使ってみたので、ついでに記載しておきます。
今回、WEBフォームからユーザ入力値を受け取って、DBにリソース作成する処理があったので、入力値を検証するバリデーションライブラリについて、Deno
の標準ライブラリから選定して使ってみました。
使ったライブラリ
WEBフォーム周りの実装
ログインフォームの実装でユーザから入力されたIDとPWを検証するバリデータを定義してみました。
PWを生の状態で管理している件についてはご容赦ください。CSRF対策は実施しています。
中略
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.ts
にuserAuthentication
という関数を定義し、その中でvalidasaur
を使ったバリデーションを実装してみました。
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
から環境変数を読み込みます。
import "dotenv/load.ts";
const dummy = new Dummy({
user: Deno.env.get("USER_ID"),
password: Deno.env.get('PASSWORD')
});
USER_ID=*******
PASSWORD==*******
CSRF_KEY==*******
JWT_CRYPTO_KEY==*******
また、DenoDeploy
のUIで環境変数を上書きすることが可能です。
成果物
実際に作成したチャットアプリは下記URLで公開しています。
DBはsupabase
を使っています。フリープランが500MB
までなので、定期的にDBリソースは削除予定です。
動作確認用のログイン情報は下記です。
ユーザID | パスワード |
---|---|
tanaka | tanaka |
yasukawa | yasukawa |
搭載した機能
下記機能を実装しています。
- 認証機能
- チャット機能(DenoのBroadCastAPIを活用し、双方向通信でリアルタイム性のあるチャットを実装)
- ユーザ作成機能
- トークルーム作成機能
- トークの既読機能
このチャットアプリは今後も改善し、バージョンアップしていこうかなと思います。deno+Fresh
に慣れるためには適したテーマ・題材だったなあと思います。
行き詰まった時には・・・
Deno+Fresh
を使ったプロダクトがまだ少ないので、行き詰まった時に参考になる情報がインターネット上に少ないです。Deno
本家のWEBサイトは最近Deno+Fresh
で書き直されたようでして、GitHub
にもソースコードが公開されているので、教科書代わりにしています。
参考にした記事
以下の記事を参考にさせていただきました。大変よくまとまっています。
Discussion