皆さんこんにちは、「ぼっち・ざ・ろっく」の PV にやられました。期待しか有りません。 おっくんです。
今回の投稿から、「Deno で掲示板サイトを作ろう! with upstash & supabase」 と題して、数回に分けて必要な要素を分解しながら、実際に匿名掲示板のリリースを進めていきます。
最初に計画
この連載では、次のような副題を掲げて段階を踏みながら、最後は匿名掲示板を実際にデプロイすることを目標にします。
- 環境構築と、簡単なアプリケーション(☆ 今回 ☆)
- 機能の実装とテスト
- デプロイと自動化
- Upstash の導入
- 調整と公開
作成するもの
タイトルで出していますが、この連載では Deno で匿名掲示板を作ります。 紹介したいサービスを使うことと、なるべくメンテナンスフリーにすることを目的として以下の機能を盛り込んでいく予定です。
- いわゆる匿名掲示板を作ります
- 閲覧は自由
- Twitter 連携をすることで、できることが増える
- 書き込み
- 他のアカウントの書き込みをクリップ(一時保存/ブクマ/イイね的なこと)できる
- 書き込みは、12 時間で非表示にされる
- 非表示になった投稿はバッチ処理で物理削除される
- 一定数のクリップがされると削除までの猶予時間が伸びる(最大 120 時間程度で検討)
現時点で作り切ったサンプルも有りませんので、優しく見守ってください。
インフラ
Deno で匿名掲示板を作るにあたり、デプロイ先やデータベース機能なども、なるべく Deno 寄りの構成を取ることにします。
大きな方針としては、以前 toranoana.deno(虎の穴ラボ主催の Deno 関連なら詳細を問わない LT 会) で紹介した、「Deno Deploy 起点フルサーバーレス サービス インフラ構想」に基づいて、アプリケーションの実行インフラとして次の 3 つのサービスを使用します。
- Deno Deploy(主にアプリケーション本体の実行基盤)
- supabase(主にデータベース関連)
- upstash(主にキャッシュ関連)
実装
ローカル開発環境
先に上げているインフラの基盤の通り、主に Deno 、データベース、キャッシュの 3 つの環境を用意します。
Deno とキャッシュは Docker にて、データベースは supabase で用意を進めます。 以下の手順です。
Deno と キャッシュ(Redis) を Docker で構築
次の Dockerfile と docker-compose.yml を用意します。
[Dockerfile]
FROM denoland/deno:1.25.4 RUN apt-get update && apt-get install RUN mkdir /usr/src/app WORKDIR /usr/src/app EXPOSE 8080
[docker-compose.yml]
version: "3" services: app: build: context: . dockerfile: Dockerfile privileged: true command: tail -f /dev/null ports: - "8080:8080" - "35729:35729" volumes: - .:/usr/src/app:cached tty: true redis: image: redis
用意できたら、次のコマンドで起動しておきましょう。
$ docker compose build $ docker compose up -d $ docker compose exec app bash
データベース
データベースはローカルで supabase を実行することで用意します。
次のコマンドで、導入します。
$ npm install supabase
$ npx supabase init
$ npx supabase start
API URL: http://localhost:54321
DB URL: postgresql://postgres:postgres@localhost:54322/postgres
Studio URL: http://localhost:54323
Inbucket URL: http://localhost:54324
JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs
service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSJ9.vI9obAHOGyVVKa3pD--kJlyxp-Z2zV9UUMAhKpNLAcU
起動すると、各種アクセス用情報が表示されます。 これらの情報を使用して、各種接続をしていきます。
アプリケーション作成
環境の用意ができたので、アプリケーションの作成をしていきます。
手始めに Deno 向け Web フレームワーク Fresh と DB の情報を返す supabase Edge Functions をセットアップし、疎通確認をします。
Fresh とは?
Fresh は、エッジでのレンダリング、ビルドステップ無し、アイランドアーキテクチャを特徴とした、Deno.land が管理している Web フレームワークです。
今回はこちらを使用して進めていきます。
Fresh 導入
Fresh は次の操作で導入します。
# 先に用意した Deno のコンテナ で実行 $ deno run -A -r https://fresh.deno.dev anonymous-board Fresh has built in support for styling using Tailwind CSS. Do you want to use this? [y/N] y Do you use VS Code? [y/N] y The manifest has been generated for 3 routes and 1 islands. Project initialized! Enter your project directory using cd anonymous-board. Run deno task start to start the project. CTRL-C to stop. Stuck? Join our Discord https://discord.gg/deno Happy hacking! 🦕 $ cd anonymous-board $ deno task start
起動しているので、localhost:8000 にアクセスすると、次のような画面が表示されます。
こちらが Fresh の初期画面です(レモンは Fresh のトレードマークです)。
supabase Edge Functions とは?
supabase Edge Functions は、2022 年 3 月に supabase が公開した新しい機能です。 Deno と supabase がパートナーシップを結び、Deno Deploy インフラストラクチャ上に構築されています。
https://supabase.com/edge-functions
特徴としては、supabase を扱うためのキー情報などを環境変数で提供してくれるので、素の Deno Deploy から扱うより楽になります。
supabase Edge Functions の導入
続けて、supabase Edge Functions を用意しましょう。 次のコマンドで、用意を進めます。
$ npx supabase functions new test-connection Created new Function at supabase\functions\test-connection
supabase/functions/test-connection/index.ts が作成されています。内容は次の通りです。
[supabase/functions/test-connection/index.ts]
// Follow this setup guide to integrate the Deno language server with your editor: // https://deno.land/manual/getting_started/setup_your_environment // This enables autocomplete, go to definition, etc. import { serve } from "https://deno.land/std@0.131.0/http/server.ts"; console.log("Hello from Functions!"); serve(async (req) => { const { name } = await req.json(); const data = { message: `Hello ${name}!`, }; return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" }, }); }); // To invoke: // curl -i --location --request POST 'http://localhost:54321/functions/v1/' \ // --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs' \ // --header 'Content-Type: application/json' \ // --data '{"name":"Functions"}'
(import { serve } from "https://deno.land/std@0.131.0/http/server.ts"
は、Deno が std module として公開している http
サーバーを呼び出しています。)
curl を使用した接続コマンド、末尾に記載が有るので、こちらを試します。
supabase Edge Functions を supabase-cli で、動作させます。
$ npx supabase functions serve test-connection Starting supabase\functions\test-connection Serving supabase\functions\test-connection Watcher Process started. Hello from Functions!
呼び出し側は、次のようになります。
$ curl -i --location --request POST 'http://host.docker.internal:54321/functions/v1/' --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs' --header 'Content-Type: application/json' --data '{"name":"Functions"}' HTTP/1.1 200 OK Content-Type: application/json Transfer-Encoding: chunked Connection: keep-alive date: Fri, 23 Sep 2022 15:35:16 GMT vary: Accept-Encoding X-Kong-Upstream-Latency: 51 X-Kong-Proxy-Latency: 1 Via: kong/2.8.1 {"message":"Hello Functions!"}
supabase/functions/test-connection/index.ts
の実装の通り、リクエストに載せたパラメータのFunctions
に文字列連結した結果の Hello Functions!
が返ってきています。
次回以降、supabase Edge Functions から supabese Database にアクセスする API を構築します。
Fresh から、supabase Edge Functions を呼び出す
supabase Edge Functions の応答が取れましたので、これを Fresh から呼び出すページを作ります。
anonymous-board/routes/test-connection/index.tsx を以下のように用意します。
[anonymous-board/routes/test-connection/index.tsx]
import { Handlers, PageProps, Context } from "$fresh/server.ts"; interface ResponseBody { message: string; } export const handler: Handlers<ResponseBody | null> = { async GET(_, ctx: Context) { const result = await fetch( "http://host.docker.internal:54321/functions/v1/", { method: "POST", headers: { Authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs", "Content-Type": "application/json", }, body: JSON.stringify({ name: "Functions" }), } ); if (result.status === 404) { return ctx.render(null); } const message: ResponseBody = await result.json(); return ctx.render(message); }, }; export default function Greet(props: PageProps<ResponseBody>) { return ( <div> Response <b>'{props.data.message}'</b> from supabase edge functions </div> ); }
Fresh は、/routes
以下でパスベースのルーティングがされていますので、/test-connection
にアクセスすると、作成したページにアクセスできます。
Response 'Hello Functions!' from supabase edge functions
と表示されるのが確認できるはずです。
ダイナミックルーティング
Fresh -> supabase Edge Functions の疎通は取れましたが、固定のレスポンスしか取ることができません。 ダイナミックルーティングを活用して、リクエストパスを変数にして、supabase Edge Functions に任意の値でリクエストを送ってみましょう。
anonymous-board/routes/test-connection/index.tsx を anonymous-board/routes/test-connection/[request_text].tsx へ書き換えます。
書き換えたら、ソースも修正します。
[anonymous-board/routes/test-connection/[request_text].tsx]
import { Context, Handlers, PageProps } from "$fresh/server.ts"; interface ResponseBody { message: string; } export const handler: Handlers<ResponseBody | null> = { async GET(props: PageProps, ctx: Context) { const result = await fetch( "http://host.docker.internal:54321/functions/v1/", { method: "POST", headers: { Authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs", "Content-Type": "application/json", }, body: JSON.stringify({ name: ctx.params.request_text }), // <= ダイナミックルーティングのため、ファイル名に指定した[request_text] が、ctx.params に展開されている } ); if (result.status === 404) { return ctx.render(null); } const message: ResponseBody = await result.json(); return ctx.render(message); }, }; export default function Greet(props: PageProps<ResponseBody>) { return ( <div> Response <b>'{props.data.message}'</b> from supabase edge functions </div> ); }
/test-connection/Deno
にアクセスすると
Response 'Hello Deno!' from supabase edge functions
と表示されるのが確認できるはずです。
環境変数の分離
後の本番環境へのデプロイにも関わりますが、supabase Edge Function を使うためのトークンなどをベタ書きしてきました。 ここまではセキュアな情報、環境毎に異なっているべき値がソースコードに直接載ってしまっています。 こちらを分離していきます。
Deno では、Deno.env.get("SOMETHING_KEY")
の形式で環境編変数を取得できます。
やり方として、個別に環境変数の呼び出しを記述することもできますが、今回は dotenv を使用し呼び出しを一か所に集約してみます。
https://deno.land/std@0.157.0/dotenv/mod.ts
モジュール管理に Fresh は、import maps が使用されていますので、dotenv もこちらの管理に乗せてあげます。
[import_map.json]
{ "imports": { "$fresh/": "https://deno.land/x/fresh@1.1.1/", "preact": "https://esm.sh/preact@10.11.0", "preact/": "https://esm.sh/preact@10.11.0/", "preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.4", "@preact/signals": "https://esm.sh/*@preact/signals@1.0.3", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.0.1", "twind": "https://esm.sh/twind@0.16.17", "twind/": "https://esm.sh/twind@0.16.17/", "dotenv/": "https://deno.land/std@0.157.0/dotenv/" } }
anonymous-board/util/config.ts を作成し、次のように記述します。
[anonymous-board/util/config.ts]
// import_map.json に定義した "dotenv/": "https://deno.land/std@0.157.0/dotenv/" により、 // import { config } from "https://deno.land/std@0.157.0/dotenv/mod.ts"; と同義となります。 import { config } from "dotenv/mod.ts"; export const envConfig = await config({ safe: true });
読みだす対象の環境変数定義を記述します。
[.env]
SUPABASE_EDGE_FUNCTION_END_POINT=http://host.docker.internal:54321/functions/v1/ SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSJ9.vI9obAHOGyVVKa3pD--kJlyxp-Z2zV9UUMAhKpNLAcU
(.env は、git管理から外しておきましょう)
[.env.example]
SUPABASE_EDGE_FUNCTION_END_POINT= SUPABASE_ANON_KEY=
config({ safe: true })
を使用すると、.env.example に定義された環境変数が、「.env に記述がない」もしくは「参照できない」場合には、エラーが発生することで、不適切な設定を回避することができます。
anonymous-board/routes/test-connection/[request_text].tsx を書き換えて、読み込んだ環境変数を参照させます。
[anonymous-board/routes/test-connection/[request_text].tsx]
import { Context, Handlers, PageProps } from "$fresh/server.ts"; import { envConfig } from "../../util/config.ts"; interface ResponseBody { message: string; } export const handler: Handlers<ResponseBody | null> = { async GET(_, ctx: Context) { const result = await fetch( envConfig.SUPABASE_EDGE_FUNCTION_END_POINT, // <= 読み込んだ環境変数を参照 { method: "POST", headers: { Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`, // <= 読み込んだ環境変数を参照 "Content-Type": "application/json", }, body: JSON.stringify({ name: ctx.params.request_text }), } ); if (result.status === 404) { return ctx.render(null); } const message: ResponseBody = await result.json(); return ctx.render(message); }, }; export default function Greet(props: PageProps<ResponseBody>) { return ( <div> Response <b>'{props.data.message}'</b> from supabase edge functions </div> ); }
先と同様に動作することが確認できるはずです。
まとめ
今回は、環境構築と Deno 向け Web フレームワーク Fresh と supabase Edge Functions のセットアップと疎通確認、環境変数周りの設定をしてきました。 実は現時点でもそれぞれのサービスの手順にしたがってデプロイし、環境変数だけ本番用のものを与えるだけで稼働するようになっています。
次回は、匿名掲示板としての機能実装と Deno でのテストについて触れていく予定です。
今回進めた内容は、以下のリポジトリに上げていますので全体感を確認したい場合はこちらをご参考ください。
P.S.
採用
虎の穴では一緒に働く仲間を絶賛募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
yumenosora.co.jp
LINE スタンプ
エンジニア専用のメイドちゃんスタンプが完成しました!
「あの場面」で思わず使いたくなるようなスタンプから、日常で役立つスタンプを合計 40 個用意しました。
エンジニアの皆さん、エンジニアでない方もぜひスタンプを確認してみてください。
store.line.me