東京藝術大学の学祭の企画で利用するプリントシール機の画像共有システムを 1人で開発したお話


:::note warn
**私は、東京藝術大学の学生ではありません。**
参加経緯については、後述します。
:::

:::note info
**プリ機のカメラシステム・お絵描きシステムの開発をした塩田くんの記事に 本企画の概要・当日の様子が記載されています。<br /> こちらもご覧ください↓**

:::


https://qiita.com/ShiodaKosuke/items/8080c28e0155c46e9f1d

## 0. はじめに
### 自己紹介
こんにちは。もぐもぐと申します。
現在、株式会社ゆめみでFlutterエンジニアとして勤務しています。

```log:YumNumm.log
$ who -b
    system boot 2004-09-17
$ whois @YumNumm
👨      NAME     : Ryotaro Onoue
🐤    TWITTER    : @YumNumm
🏫      WORK     : YSFH 12th -> YUMEMI Inc. Flutter Enginner
💕      LOVE     : Flutter
👀 INTERESTED IN : Kotlin/Go
```

### 何をしたのかざっくり
東京藝術大学 テクノロジー研究会で 9/1,2,3に行われた東京藝術大学の学祭「藝祭」のフリーマーケットで出展した**アスキーアートになれるプリ機**の **PC間画像共有システム と 購入者の撮影画像共有システム**を実装し運営しました。
今回はここで得た知見を共有していこうと思います。




## 1. テクノロジー研究会(テク研)で開発した経緯
前述した通り、私は**株式会社ゆめみのFlutterエンジニア**です。一体どういう経緯で東京藝術大学のテクノロジー研究会の一員として開発をしているのでしょうか? 

**その答えは、自分の出身高校にあります。**
私は、[横浜市立横浜サイエンスフロンティア高等学校] (以降`YSF`)の12期生として在学していました。

同期に[塩田航佑／ShiodaKosuke] くん(kou くん)という 高校時代から**映画監督をやっている**友達がいました。彼は、卒業後 東京藝術大学 美術学部 先端藝術表現科へ進学しています。

https://twitter.com/koushioda/status/1635849171713527810

kou くんとは、今年5月下旬に東京の美術館めぐりをする機会がありました。 芸術に詳しくなかった自分にとって、「アートとは何なのか?」を考えるきっかけとなり 非常に刺激的な経験をすることができました。

||||
|---|---|---|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/00e61d34-0917-8036-b70c-f26b8ddf37aa.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/9751b572-e1f8-91d7-113e-9448e76de51b.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/e0dbe4c8-cf65-8d8e-2361-c0fa74befe37.png)|

ちょうど その日の帰り道に、東京藝術大学のテクノロジー研究会でどんなことをやっているのかを詳しく聞くことができました。 
藝大の方々からいろんなお話を聞けるチャンスであり、また、技術的な成長ができると思い、 **東京藝術大学 テクノロジー研究会に参加することにしました。**

これがざっくりとした、参加経緯です。

[横浜市立横浜サイエンスフロンティア高等学校]: https://www.edu.city.yokohama.lg.jp/school/hs/sfh/index.cfm/1,html
[塩田航佑／ShiodaKosuke]: https://twitter.com/koushioda


## 2. どんなシステムなのか?
まずは必要な機能をまとめましょう。

- PC間の画像共有
  - プリントシール機の撮影ブース から お絵かきブースのPCへ画像を転送するシステムが必要
- 撮影した ないしは お絵かきした画像を ユーザがダウンロードできるようにする
  - アプリをダウンロードさせるのは UX が良くないので Webページで運用したい

以上の2つが 今回満たしたい要件です。

今回はこの機能を満たすために、以下の技術選定を行いました。

||||
|---|---|---|
|**プラットフォーム**|Web|Flutterでアプリケーションを作成することも考えたが、画像をDLするためだけにアプリケーションをインストールするのは UXが悪いためWebに|
|**Webフレームワーク**|Next.js + React + TailwindCSS|Webにあまり精通していないため、以前触ったことがあるフレームワークを利用したかった|
|**バックエンド**|Cloudflare Workers|個人開発で利用しており、安心感があったと同時に、サーバレスなため 自前でサーバ監視をする必要がなく 便利|
|**画像ストレージ**|Cloudflare R2|Cloudflare Workersを利用するにあたって 外部のバケットストレージを利用するよりも、Cloudflare R2を利用するほうが合理的だと感じたから。あと、無料枠もめちゃくちゃでかいので安心|
|**サイトホスティング**|Cloudflare Pages|ビルドも含め Pagesに任せられる && Cloudflareで取得したドメインと相性がよく Web Analyticsも見れる|

このように、Cloudflareエッジで動作するサービス群を利用することで、高可用性・高信頼性を担保して開発を進めることができました。

なお、来場想定人数を考えたときに、このスタックで無料枠の範囲内に収まるであろうことが想定され、実際にCloudflare Workers / R2 ともに無料枠で収まりました。

## 3. [API実装] Cloudflare Workers(サーバサイド)

https://github.com/YumNumm/art-market-api


Cloudflare Workersを用いて サーバサイド実装(REST API)を実装していきます。

<details>
<summary>
Cloudflareのサービスをチョット紹介
</summary>

### [Workers](https://workers.cloudflare.com/)

Cloudflare Workers は、Cloudflare が提供するサーバーレスの実行環境です。
Cloudflareが有する、グローバルに展開されたエッジ実行環境でJavaScriptを実行することができます。
AWSの[Lambda@Edge], GCPの[Cloud Run]と似たようなコンセプトのサービスです。

Cloudflareの様々な開発者向けサービスをバインドして操作することができます。
Next.jsのSSG/SSRもWorkers×Pages(後述)で実現できます。

### [D1(Open Alpha)](https://developers.cloudflare.com/d1/)
Cloudflareのサーバレスデータベースです。
SQLite3がベースになっており、Workers経由でCRUD処理を行うことができます。

### [R2](https://developers.cloudflare.com/r2/)
[AWS S3](https://aws.amazon.com/jp/s3/)API 互換のオブジェクトストレージサービス
値段設定もS3に比較して安価な上に、リージョンの設定は不要で自動で最適化されます。
オブジェクトストレージの信頼性は<ruby>`99.999999999%`<rt>イレブンナイン</rt></ruby>と説明されています。<font color="grey">(いや わけわからんて!)</font>

### [Pages](https://pages.cloudflare.com/)
Webフロントエンドアプリケーションをインターネットで公開できます。
ビルド済みのアセットをアップロードするか、Git連携を行ってPages側でビルドして公開できます。

Vercel, Netlify, GitHub Pagesとは違って、無料プランの帯域幅制限がなく、Zero Trustを用いたアクセス制限も柔軟に行うことができます。


ここで紹介しなかったサービス(Images, KV, Queue, Stream, Pub/Sub .etc)も非常に興味深く、様々な利活用ができるものばかりです。ぜひ調べてみてください

</details>


[Lambda@Edge]: https://aws.amazon.com/jp/lambda/edge/
[Cloud Run]: https://cloud.google.com/run?hl=ja




### フレームワーク Honoのお話

Honoを利用してAPIを構築していきます。

https://hono.dev/

Flutterエンジニアの自分でも ささーっと 書けるくらいドキュメントが充実しており 非常に開発しやすかったです。

#### APIの保護

HonoのMiddlewareの機能を利用し、APIの認証部分(というより、CRUDのCUDにあたる部分の保護)を書いていきます。
環境変数に任意の値を設定し、その値がRequestの`Header['X-Api-Key']`と一致するかを検証していきます。この検証により、オブジェクトの追加や削除に関するAPIを保護します。
今回は、JWTを適当に作成して それを用いることにしました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/9c0beba2-0a0c-3f51-e62e-272a2b561d12.png)

ここで設定した環境変数変数は、

```typescript:bidings.d.ts
export type Bindings = {
	BUCKET: R2Bucket;
	X_API_KEY: string; // <- コレ
};
```
```typescript:main.ts
const app = new Hono<{ Bindings: Bindings }>();
```
とすることで検証を実装できます。
```typescript:GET / 
app.get('/', async (c) => {
	const envKey = c.env.X_API_KEY;
	const reqKey = c.req.header('X-Api-Key');
	return c.json({ isMatched: envKey == reqKey });
});
```

この検証を、middleware側で行うことで 複数エンドポイントを保護しやすくなります。
```typescript:main.ts
app.use('*', async (c, next) => {
	// check header
	const key = c.req.header('X-Api-Key');
	if (key !== c.env.X_API_KEY) {
		return c.json({ error: 'Unauthorized' }, 401);
	}
	return next();
});
```
ref: https://github.com/YumNumm/art-market-api/blob/main/src/worker.ts#L23-L34




###  ストレージ層の話 Cloudflare R2

Cloudflare R2とは、AWS S3互換APIのオブジェクトストレージサービスです。
Cloudflare Workersからバケットを操作できる他に、`rsync`でローカルと同期させたり、カスタムドメインからバケットへアクセスすることもできます。

今回はこれを利用します。

- 画像をアップロードする時は、Cloudflare Workers経由でアップロード
- 画像のリストを取得したい時は、Cloudflare Workers経由でリストの取得
- 既知のIDとファイル名に対する画像取得は、カスタムドメイン経由で取得

という風な使い分けにします。

#### Workers経由で画像アップロード

HTTPリクエストボディーから画像データを受け取り、Cloudflare R2へアップロードすることができます。
非常に楽にアップロードを実装することができます。

```typescript:src/routes/upload/[id].ts
const body = await c.req.parseBody();
const file = body.file as File;
const bucket = c.env.BUCKET;
const fileName = `${id}/${name}`;
const response = await bucket.put(fileName, await file.arrayBuffer(), {
	httpMetadata: {
		contentType: file.type,
	},
});
```

#### Workers経由でファイルリストの取得

アップロードと同様に、`c.env.BUCKET: R2Bucket` からファイルリストを取得することができます。
```typescript:src/routes/list/[id].ts
const bucket = c.env.BUCKET;
const result = await bucket.list({
    prefix: id,
    limit: 100,
});
```

#### カスタムドメイン経由で既知のID・ファイル名のオブジェクト取得

Cloudflare Dashboardより、カスタムドメインを設定することができます。
今回は、ページと異なるオリジンになってしまいますが、`objects.tekken.work`を指定しました。
これにより、ボット保護・Cloudflare WAF・CDNを利用することができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/ae9c027a-24cc-e056-0659-b6f2d427d290.png)


[Zod Validator]: https://github.com/honojs/middleware/tree/main/packages/zod-validator

ちなみに、Cloudflareで公開するサイトには無料でレートリミットを設定できます。
今回は`2req/10sec`(ただし、API Keyが正しい場合は除外)を設定しました。これでEDoS(過度なアクセスによる経済的な損失)をある程度防ぐことができました。

では、このAPIを叩くフロントエンド(撮影した画像を見ることができるページ)を製作していきましょう


## 4. Next.jsでフロントエンド開発


https://github.com/YumNumm/art-market-app

https://tekken.work/

冒頭に述べた通り、私はFlutterエンジニアのため、Webフロントエンド開発には明るくありません。
**なので、パフォーマンスや可読性はさておき、とりあえず動くものを作ることが第一優先でした。**
そこで、以前ちょこっと触ったことがある [Next.js](https://nextjs.org/) + [React](https://react.dev/) + [shadcn/ui](https://ui.shadcn.com/) + [Tailwind CSS](https://tailwindcss.com/) + [Cloudflare Pages](https://pages.cloudflare.com/) を利用します。

余談ですが、Flutterの世界にも React Hooksと似たようなライブラリ [flutter_hooks](https://pub.dev/packages/flutter_hooks)があります。
なので、Flutterの文脈で`useXXX`を使ったことがあったのです。FlutterとWebフロントの世界がつながるとは....(脳汁ドバドバ...)
<font color="grey">若干制約だったり仕様が異なりますが まあ うん。</font>

### ページ構成を考える
必要なページは主に2つです。
- ホームページ(`/`)
- 画像表示ページ(`/art-market/[id]`)

### ホームページ(`/`)

以前、 vercel/og を眺めていた時に見つけたコレ↓が非常に好きで こんな感じのやつを作りたいな〜と思っていました。
よく見ると背景にドットが並んでいてすごい好きだったのです。<font color="grey">一目惚れしちゃいましたわ...!</font>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/265ec54d-2e86-9790-aecf-6c45d93fbd86.png)

https://og-playground.vercel.app/?share=nVRdb5swFP0rnqWom0QSmq-mKIm0dZPShz21Wl94MXABdwYjY0Ioyn_ftSlN06ir1DwY-5zrcz-dloYyAurRVcR3fk5IqRsB67Y1e0JS4EmqPXJx6bqDC6cDax7p9A0W8bIQrEE0FrDvUQ17_V3wJEc8hFyD6hlm0FsNWXlGPVal5nFzIxHMje9T2uj_5ApCzaXVlaLK8tfsg2IFErmscdMTAQv_JkpWeXQjhVTI1ynXcE7fZiwBpBWLOBPDxHwxgK8hV6EAwjSZzIu9XRyCWaQaTRoyGThEK5aXBVNoTtzBN4e8r3FlNMzygcZ5fHf8yYSHxUcFu3Y2h4Ofb8ym7-TbXr7XpP8248N2GLeEWMfoutwl_b1udNbt0rUW5rfjUP-Q-7VPXeJi9mQx92lPxlwIZAKBmR7RPgWSMZVw029zEXN-dvziGp0XTKckQo3f06vR_Ho0mYvpYnSNbmbb0WLWHYaL2ZNPN6uxse7DHmPcXe3GWLxPVjHGCnXNmbmnoBGwA6kyJo4XcHLtINqUj3CX6b3EGZ4ehQTPYfv8Gi9HyxfcTvFdwULjoVAwfD30p80JNlsQQpIHqUT0ZTUOTlLuv9ShsjBvq6ReS-1bp97SdR3adZR6M3OIIKgS6sVMlOBQyOQjv28K80-ia3tCHZP8ryyAiHpaVXBwqGYBWvwBFYKgh38


ということでvercel/ogサンプルのソースコードを参考にして製作したページがコレです。

https://tekken.work/

|||
|---|---|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/2993c357-cede-629c-83ae-91d4040a1744.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/3cf25430-61bf-a154-c1fa-fa2e3f0cf7cb.png)|


青をベースに再読み込みするたびにグラデーションの色や位置が変わるようになっています。

技術的には、ノイズグラデーションをめちゃくちゃ拡大したような感じになっています。

https://qiita.com/xrxoxcxox/items/2c95837c134ead32f591


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/b3d522f1-f346-aa58-400e-d94900fc9fd5.png)


余談ですが、これを見た時に位置情報共有サービス zenly を思い出しました。
スクリーンショットを見返してみると 積極的にノイズグラデーションが利用されているようでした。

|||
|---|---|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/515cdfaf-406c-d189-10e6-88c6fb52a2ea.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/04a8eec0-f94c-5b7f-5ba0-a8388c567720.png)|

### 画像表示ページ(`/art-market/[id]`) 

Cloudflare Workersのオブジェクトを一覧するAPIを叩き、それぞれ表示しています。

[サンプル](https://tekken.work/art-market/?id=24fa58b8-d052-4fd5-b849-a5faf39ad604) (実稼働中にテストで撮っただけなので もっとデコレーションすることができます)


|Light Mode|Dark Mode|
|---|---|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/3b6470d1-687e-c8ac-4d54-5b3b90b60be5.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/1ca59d2b-6cd0-21dc-98f2-faadb4cd7a64.png)|


## Next.js + Cloudflare Pagesで詰まった点

### Cloudflare Pages(static export)でNext.js App Router Dynamic Routes

要件として、**静的サイトとして公開したい** がありました。
すなわち、ページリクエストに際して、Cloudflare Workersやサーバが必要ない ということになります。
背景としては、なるべく料金を抑えたかった が挙げられます。

したがって、`next.config.js`で`output: "export"`にする必要があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489005/600d42e8-9e9d-879e-68c7-14b48172121d.png)
ただし、こうすると、成果物をデプロイしたCloudflare Pages側から見た時に 404 Not Foundになってしまいます。
調べたところ、Cloudflare Pagesの`_redirect`を使うことで対応可能とのことでした。

https://dev.classmethod.jp/articles/cloudflare-pages-next-js-use-dynamic-routes-reload-404/

https://zenn.dev/inaniwaudon/scraps/0759d86074d1a6

しかし、自分の場合はなぜかうまくいかず 404から逃れることができませんでした。
<font color="grey">まあ 実装ミスな気がするけど...? </font>

そこで、普通のページにして**変数はQuery Parameterで渡す** という方法にしてみました。
```:_redirects
/art-market/:id/ /art-market?id=:id
/art-market/:id /art-market?id=:id
```
とし、`art-market/hogehoge`は`art-market?id=hogehoge`にリダイレクトされます。
これによって、ページ側から変数を受け取ることができました。一件落着。

```typescript:src/app/art-market/page.tsx
  const searchParams = useSearchParams();
  const id = searchParams.get("id");
```


## まとめ

- Cloudflare WorkersとR2, Pagesを用いることで安定したAPI・ページを展開することができました
  - ある程度の規模感であれば、無料枠にしっかり収まります。事前に見積もっておくと安心です
  - ある期間に集中してアクセスが飛んでくることが想定される場合、ダッシュボード等で監視するようにしましょう