Cloudflareと聞くとCDNのイメージが強いですが、今は静的ホスティングのPagesやエッジで関数を実行できるFunctionsなど、フロントエンド開発に役立つサービスを多数リリースしています。
今回はそんなCloudflareの各種サービスとblastengineを使ってお問い合わせフォームを実装します。
コード
今回のコードはblastengineMania/cloudflare-nextjs-contactにアップしてあります。ライセンスはMIT Licenseです。
アーキテクチャ
今回のアーキテクチャは以下のようになります。入力フォームは、Next.jsを使って表示します。送信時にはPages Functionsを呼び出します。Functionsではblastengineを使ったメール送信に加えて、入力内容をD1(SQLite3)に保存しています。
Cloudflare Pagesの準備
まずCloudflare Pagesの準備です。とはいっても、CLIを使って対話的に進められます。
$ npm create cloudflare
今回はオプションを以下のように選択しています。
- dir
blastengine - アプリケーションの種類
Website or web app - フレームワーク
Next - TypeScriptの利用
- ESLintの利用
- Tailwind CSSは不使用
- srcディレクトリの利用
- App Routerの利用
- import aliasの不使用
-
next-on-pages
の利用
実装について
Cloudflare Pagesの実装
Cloudflare Pages向けは素のNext.jsを利用しています。お問い合わせフォームの入力項目は以下の通りです。
項目 | 型 | 変数名(HTML上) |
---|---|---|
会社名 | テキスト | company |
名前 | テキスト | name |
メールアドレス | テキスト | |
問い合わせ種別 | 選択 | type |
問い合わせ内容 | テキスト | message |
フォーム
上記項目を以下のように配置しています。
// フォーム入力データの型
type formData = {
company: string;
name: string;
email: string;
type: string;
message: string;
};
const defaultValue: formData = {
company: '',
name: '',
email: '',
type: '',
message: '',
};
const options: string[] = ['会社について', 'サービスについて', 'その他'];
const [form, setForm] = useState(defaultValue);
return (
<>
<div className={styles.container}>
<Head>
<title>お問い合わせ</title>
</Head>
<main className={styles.main}>
<h3 className={styles.title}>お問い合わせ</h3>
<form className="container" onSubmit={send}>
<div className="company block">
<label htmlFor="frm-company">会社名</label>
<input
id="frm-company"
type="text"
value={form.company}
autoComplete="company"
onChange={(e) =>
setForm({ ...form, ...{ company: e.target.value } })
}
required
/>
</div>
<div className="account block">
<label htmlFor="frm-name">名前</label>
<input
id="frm-name"
type="text"
value={form.name}
autoComplete="name"
onChange={(e) =>
setForm({ ...form, ...{ name: e.target.value } })
}
required
/>
</div>
<div className="email block">
<label htmlFor="frm-email">メールアドレス</label>
<input
id="frm-email"
type="email"
value={form.email}
autoComplete="email"
onChange={(e) =>
setForm({ ...form, ...{ email: e.target.value } })
}
required
/>
</div>
<div className="block type">
<label htmlFor="frm-type">お問い合わせ種別</label>
<select
onChange={(e) =>
setForm({ ...form, ...{ type: e.target.value } })
}
>
{options.map((option) => (
<option value={option} key={option}>{option}</option>
))}
</select>
</div>
<div className="message block">
<label htmlFor="frm-message">Message</label>
<textarea
id="frm-message"
rows={6}
value={form.message}
onChange={(e) =>
setForm({ ...form, ...{ message: e.target.value } })
}
>
</textarea>
</div>
<button onClick={send}>送信</button>
</form>
</main>
</div>
</>
);
CSSは以下のようになっています。
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
background: #1e1e1e;
min-height: 100vh;
display: flex;
color: rgb(243, 241, 239);
justify-content: center;
align-items: center;
}
.block {
display: flex;
flex-direction: column;
}
.name {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.container {
font-size: 1.3rem;
border-radius: 10px;
width: 85%;
padding: 50px;
box-shadow: 0 54px 55px rgb(78 78 78 / 25%), 0 -12px 30px rgb(78 78 78 / 25%),
0 4px 6px rgb(78 78 78 / 25%), 0 12px 13px rgb(78 78 78 / 25%),
0 -3px 5px rgb(78 78 78 / 25%);
}
.container input {
font-size: 1.2rem;
margin: 10px 0 10px 0px;
border-color: rgb(31, 28, 28);
padding: 10px;
border-radius: 5px;
background-color: #e8f0fe;
}
.container select {
font-size: 1.2rem;
margin: 10px 0 10px 0px;
border-color: rgb(31, 28, 28);
padding: 10px;
border-radius: 5px;
background-color: #e8f0fe;
}
.container textarea {
margin: 10px 0 10px 0px;
padding: 5px;
border-color: rgb(31, 28, 28);
border-radius: 5px;
background-color: #e8f0fe;
font-size: 20px;
}
.container h1 {
text-align: center;
font-weight: 600;
}
.name div {
display: flex;
flex-direction: column;
}
.block button {
padding: 10px;
font-size: 20px;
width: 30%;
border: 3px solid black;
border-radius: 5px;
}
.button {
display: flex;
align-items: center;
}
textarea {
resize: none;
}
送信時の処理
送信時にはPages Functionsを呼び出しています。パスは POST /functions/sendmail
としています。送信するデータはフォーム入力された内容そのままです。
type functionReponse = {
delivery_id?: number;
};
// フォームの送信処理
const send = async (e: any) => {
e.preventDefault();
// Cloudflare Workerへ送信
const res = await fetch('/functions/sendmail', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(form),
});
// レスポンスを受け取る
const json = await res.json() as functionReponse;
// レスポンスにdelivery_idが含まれていれば送信成功
if (json.delivery_id) {
alert('送信しました');
setForm(defaultValue);
}
};
D1の準備
D1はPages FunctionsやWorkerで使えるデータベースです。SQLite3相当になります。今回は be_customers
として作成しています。
$ npx wrangler d1 create be_customers
作成したら、管理画面で紐付けを行います。
そして、初期スキーマを作成します。内容は以下の通りで、 shema.sql
というファイルにしています。
DROP TABLE IF EXISTS Contacts;
CREATE TABLE Contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company VARCHAR(255) NOT NULL,
accountname VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
type VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
delivery_id INTEGER
);
この内容を実行します。 --local
でローカルの開発環境に、何も付けなければCloudflare上のD1になります。
$ npx wrangler d1 execute be_customers --file=./schema.sql --local
$ npx wrangler d1 execute be_customers --file=./schema.sql
D1作成時に表示されるIDなどを wrangler.toml
に記載します。おそらくNext.jsプロジェクトを作った時点ではこのファイルは存在しないので、新規作成します。 binding
は環境変数名になります。 preview_database_id
はローカル開発時に必要なIDになります。
[[d1_databases]]
binding = "DB"
database_name = "be_customers"
database_id = "999999-9999-9999-9999-999999999"
preview_database_id = "DB"
Pages Functionsの実装
Pages Functionsの実装です。これは src/app/functions
以下に作成します。つまりファイルは src/app/functions/sendmail/route.ts
になります。
blastengine SDKは使えません
Pages FunctionsはNode.jsではないので、blastengineのSDKは使えません。そのため、blastengineのAPIをfetch関数で呼び出す形になっています。
.dev.varsファイルの作成
プロジェクトルートに .dev.vars
ファイルを作成します。環境変数は他にも渡し方があるようですが、wrangler.tomlはGitリポジトリに入れたかったので、この方法を選択しました。
BLASTENGINE_USER_ID=blastengineのユーザーID
BLASTENGINE_API_KEY=blastengineのAPIキー
ファンクションの内容
ファンクションの基本形は以下のようになります。
// Cloudflare Workers および Next.js に関連するモジュールをインポート
import { D1Database } from '@cloudflare/workers-types';
import type { NextRequest } from 'next/server'
// エッジサーバーでの実行を指定
export const runtime = 'edge';
// 環境変数からデータベースの情報を取得
const DB = process.env.DB as unknown as D1Database;
// フォームから受け取る入力データの型を定義
type formData = {
company: string;
accountname: string;
email: string;
type: string;
message: string;
};
// メール送信APIからの応答の型を定義
type blastengineResponse = {
delivery_id: number;
};
// POSTリクエストの処理関数
export async function POST(request: NextRequest) {
// リクエストボディから入力データを取得
const body = await request.json() as formData;
// メールを送信し、その結果を取得
const json = await sendMail(body);
// データベースに問い合わせ情報を保存
await saveContact(body, json.delivery_id);
// 応答を返す
return new Response(JSON.stringify(json));
};
メール送信処理
メール送信処理は sendMail
関数で実装しています。blastengineのユーザーIDとAPIキーは環境変数から取得します。そして generateToken
関数で認証トークンを生成します(関数は後述)。後は本文や送信元の情報を組み立てて、blastengineのAPIを呼び出します。
送信がうまくいった場合には配信ID(delivery_id)が返ってきます。
// メールを送信する関数
const sendMail = async (body: formData): Promise<blastengineResponse> => {
// 環境変数からAPIの認証情報を取得
const apiUser = process.env.BLASTENGINE_USER_ID;
const apiKey = process.env.BLASTENGINE_API_KEY;
// 認証トークンを生成
const token = await generateToken(`${apiUser}${apiKey}`);
// リクエストヘッダーを設定
const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
// メールの内容を設定
const text_part = `__ACCOUNTNAME__様
お問い合わせいただきありがとうございます。内容を確認し、追ってご連絡いたします。
会社名:
__COMPANY__
お名前:
__ACCOUNTNAME__
お問い合わせ内容:
__MESSAGE__
`;
// メールのデータを整形
const data = {
from: {
email: 'no-reply@blastengine.jp',
name: '管理者'
},
to: body.email,
cc: ['atsushi@moongift.co.jp'],
subject: 'お問い合わせありがとうございます',
encode: 'UTF-8',
text_part,
// メールテンプレート内のプレースホルダーに対応する値を設定
insert_code: Object.entries(body).map(([key, value]) => ({
key: `__${key.toUpperCase()}__`,
value,
})),
};
// APIを呼び出してメールを送信
const res = await fetch('https://app.engn.jp/api/v1/deliveries/transaction', {
method: 'POST',
headers,
body: JSON.stringify(data),
});
// APIの応答をJSONとして解析
const json = await res.json();
return json as blastengineResponse;
}
フォームで入力された値(たとえば company=会社名
)はメール本文の中で __COMPANY__
といったプレースホルダーに置き換えて使えるようにしています( insert_code
のところ)。この insert_code
で指定するキーは4文字以下は使えないので注意してください(たとえば name
は使えません)。
generateToken
関数は以下のようになります。SHA256でハッシュ化し、それをBase64で文字列にします。実装は【テックコラム】JavaScript Web Crypto API で SHA256 ハッシュ値を作る | DataCurrentを参考にさせてもらっています。
// 認証トークンを生成する関数
const generateToken = async (message: string): Promise<string> => {
// 文字列をUint8Arrayに変換
const msgUint8 = new TextEncoder().encode(message);
// SHA-256ハッシュを計算
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// ハッシュを16進数の文字列に変換
const hashHex = hashArray
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// ハッシュをBase64形式のトークンに変換
const token = Buffer
.from(hashHex.toLowerCase())
.toString('base64');
return token;
};
データベース保存処理
データベース(D1)への保存処理は以下のようになります。 DB
という変数がありますので、それを使ってINSERT文を実行します。 bind
メソッドを使えば、 ?
で与えた部分に文字列を入れてくれます。
// データベースに問い合わせ情報を保存する関数
const saveContact = async (body: formData, deliveryId: number): Promise<boolean> => {
// SQLを使用してデータをデータベースに挿入
const { success } = await DB
.prepare(`INSERT INTO Contacts (
company,
accountname,
email,
type,
message,
delivery_id
) VALUES (?, ?, ?, ?, ?, ?)`)
.bind(body.company, body.accountname, body.email, body.type, body.message, deliveryId)
.run();
// 操作が成功したかどうかを返す
return success;
};
全体のコード
Pages Functionsの全体のコードは以下のようになります。
// Cloudflare Workers および Next.js に関連するモジュールをインポート
import { D1Database } from '@cloudflare/workers-types';
import type { NextRequest } from 'next/server'
// エッジサーバーでの実行を指定
export const runtime = 'edge';
// 環境変数からデータベースの情報を取得
const DB = process.env.DB as unknown as D1Database;
// フォームから受け取る入力データの型を定義
type formData = {
company: string;
accountname: string;
email: string;
type: string;
message: string;
};
// メール送信APIからの応答の型を定義
type blastengineResponse = {
delivery_id: number;
};
// POSTリクエストの処理関数
export async function POST(request: NextRequest) {
// リクエストボディから入力データを取得
const body = await request.json() as formData;
// メールを送信し、その結果を取得
const json = await sendMail(body);
// データベースに問い合わせ情報を保存
await saveContact(body, json.delivery_id);
// 応答を返す
return new Response(JSON.stringify(json));
};
// データベースに問い合わせ情報を保存する関数
const saveContact = async (body: formData, deliveryId: number): Promise<boolean> => {
// SQLを使用してデータをデータベースに挿入
const { success } = await DB
.prepare(`INSERT INTO Contacts (
company,
accountname,
email,
type,
message,
delivery_id
) VALUES (?, ?, ?, ?, ?, ?)`)
.bind(body.company, body.accountname, body.email, body.type, body.message, deliveryId)
.run();
// 操作が成功したかどうかを返す
return success;
};
// メールを送信する関数
const sendMail = async (body: formData): Promise<blastengineResponse> => {
// 環境変数からAPIの認証情報を取得
const apiUser = process.env.BLASTENGINE_USER_ID;
const apiKey = process.env.BLASTENGINE_API_KEY;
// 認証トークンを生成
const token = await generateToken(`${apiUser}${apiKey}`);
// リクエストヘッダーを設定
const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
// メールの内容を設定
const text_part = `__ACCOUNTNAME__様
お問い合わせいただきありがとうございます。内容を確認し、追ってご連絡いたします。
会社名:
__COMPANY__
お名前:
__ACCOUNTNAME__
お問い合わせ内容:
__MESSAGE__
`;
// メールのデータを整形
const data = {
from: {
email: 'no-reply@blastengine.jp',
name: '管理者'
},
to: body.email,
cc: ['atsushi@moongift.co.jp'],
subject: 'お問い合わせありがとうございます',
encode: 'UTF-8',
text_part,
// メールテンプレート内のプレースホルダーに対応する値を設定
insert_code: Object.entries(body).map(([key, value]) => ({
key: `__${key.toUpperCase()}__`,
value,
})),
};
// APIを呼び出してメールを送信
const res = await fetch('https://app.engn.jp/api/v1/deliveries/transaction', {
method: 'POST',
headers,
body: JSON.stringify(data),
});
// APIの応答をJSONとして解析
const json = await res.json();
return json as blastengineResponse;
}
// 認証トークンを生成する関数
const generateToken = async (message: string): Promise<string> => {
// 文字列をUint8Arrayに変換
const msgUint8 = new TextEncoder().encode(message);
// SHA-256ハッシュを計算
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// ハッシュを16進数の文字列に変換
const hashHex = hashArray
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// ハッシュをBase64形式のトークンに変換
const token = Buffer
.from(hashHex.toLowerCase())
.toString('base64');
return token;
};
まとめ
今回はCloudflare PagesとFunctions、D1、blastengine、Next.jsを使ってお問い合わせフォームを作成しました。ホスティングからFaaSまでCloudflare上で完結できるのが便利です。次回はこのD1にあるデータを取得し、一括配信メールを使ってメール送信を行うデモを紹介します。