日記

エビ中を推している弱いパソコンオタクです

えびちゅうSSAを盛り上げるためにWebアプリを作った話

この記事は私立恵比寿中学 Road to Saitama カウントダウンカレンダーの2/27の記事です.

全体的にえびちゅう要素は薄めです(ごめんなさい). えびちゅう関連話題だけ読みたい方は以下からどうぞ.


最近なにかとえびちゅうと絡めて個人開発をやりたがっている塩レモンです.

こちらの記事で告知した通り, SSAに向けてアドベントカレンダー的なやつをやろう! ということで「Road To Saitamaカウントダウンカレンダー」なるものを企画しました.

この記事の公開時点で既に日程の3/4以上が埋まっており, 大変嬉しい限りです. ありがとうございます.

引き続き参加者大募集中です. まだ5日空きがあるので, あまり身構えずにふらっと登録してペロッと書いていただけたら嬉しいです(がっつり長文ももちろんお待ちしております).

で, タイトルから既にお察しの方もいるでしょうが, 今日のやつはまさにこの企画のために作ったadventar.orgもどきの開発話です. 開発に至った経緯はこちらをご覧ください.

カウントダウンカレンダーの記事として出すのもどうかな...と思ったんですが, 企画に関わる話だし, 記憶が揮発する前にどこかに書き起こしたかったので書いてみます.

#えびちゅうパソコン部活動としてもうってつけだしね. もっと流行らせたい.

cd-sioremon.hatenablog.com

機能の洗い出し

最初からadventarもどきにする, というコンセプトは明確だったので, adventarを実際に触りながら必要な機能を洗い出していきました.

  • adventarからパクってくる機能
    • ログイン
    • カレンダー表示
    • 任意の日付への記事・URLの登録
    • 登録した記事・URLを編集・削除
    • 登録された記事のリスト表示

これで基本的な機能は揃いますね. この上で開発を簡単にするために色々制限をつけていきます.

  • ログイン
    • プロバイダはGoogleだけにする
  • 登録された記事のリスト表示

認証プロバイダをGoogleだけにするならプロフィール設定画面が必要そう. 参加するのが全員オタクということを考えると表示名はGoogleのアカウント名よりもオタクネームであってほしいし, アイコンはXのアイコンが使えるようにしたいはずです.
ただX連携みたいなのはめんどくさそうだし時間もなかったので, 今回は自分で登録してもらう方式にしました.

ということで実装する機能がざっと揃った.

  • ログイン
  • カレンダー表示
  • 任意の日付への記事・URLの登録
  • 登録した記事・URLを編集・削除
  • 登録された記事のリスト表示
  • プロフィール設定

技術選定

言語やフレームワーク脊髄反射でいつも通りのやつ.
- 言語: TypeScript
- フレームワーク: React
- CSS: Tailwind CSS

次はホスティングや認証周り, DBやオブジェクトストレージあたりを考えないといかないですね.

  • 機能とその用途
    • ホスティング
      • 公開するので当然いる
    • 認証
      • ログイン機能の実装
    • DB
      • 記事タイトル・URLやユーザ情報等々を保存
    • オブジェクトストレージ

この辺がワンストップで揃ってて簡単に使えそうなクラウドサービスを探した結果, Firebaseに落ち着きました.

  • ホスティング
    • Firebase Hosting
  • 認証
    • Firebase Authentication
  • DB
    • Firestore or Realtime Database
  • オブジェクトストレージ
    • CloudStorage for Firebase

認証周りが一番の心配ポイントだったんですが, Firebase Authenticationを使えば結構簡単に認証が実装出来そうでした. そもそもFirebaseはGoogleが提供しているMBaaSなので「Google認証を使う」という要件を踏まえるとFirebaseが一番楽, かつ安心して使えそう.
Authenticationの他に, ホスティング等必要な機能が揃っているのでFirebaseを選択しました.

Firebaseは2種類のDBを提供していて, それがFirestoreとRealtime Databaseです. 両者の違いは以下リンクを参照.

firebase.google.com

今回のアプリケーションのデータ構造は比較的単純になりそうだったので, この説明を鵜呑みにしてRealtime Databaseを選択. Firestoreの説明を読んでいて, そこまでリッチな機能いらなさそう, と思ったのもある.

Realtime Database は従来の Firebase JSON データベースです。シンプルなルックアップと低レイテンシの同期を必要とし、スケーラビリティに制限がある単純なデータモデルを使用するアプリケーションに適しています。

本当は両者を実際に触って選びたかったが, 時間がなくてFirestoreは触らずじまいでした. 次なんかやるときに使えればいいな.

Realtime DatabaseやFirestoreの大きな特徴はクライアントから直接アクセス出来るという点です. CRUD操作をするためだけにバックエンドを用意しなくていいわけですね.
ある程度まではDB側のセキュリティルールでバリデーションも可能なので, 今回くらい単純なアプリケーションだったらクライアントから直接読み書きする構成にして良さそうです.

ということでバックエンドは用意せず, フロントから直接DBにアクセスする構成にします.


これで役者は揃ったので粛々と実装していきます.

開発中の色々

Geminiが最高だった

Googleが提供する生成AIサービスです. ChatGPTみたいなやつ. 詰まったときに雑に聞いて解決出来たのでめちゃくちゃ助かりました. 中でもCSSなどUIに関連する部分が最高に助かりました.

使い方の一例を貼っておきます.

  1. adventarのスクショを撮る.
    adventar.orgのスクショ
  2. Geminiにスクショとプロンプトをぶん投げる.
    スクショとプロンプトをぶん投げる例

そうするとこんな感じでサンプルコードを出してくれます. しかもそのまんまでちゃんと動くことが多い.

# 著者追記: 動くかは知りません
import React, { useState } from 'react';

interface Entry {
  day: number;
  title: string;
  url: string;
  author: string;
}

interface CalendarProps {
  entries: Entry[];
  onAddEntry: (entry: Entry) => void;
}

const Calendar: React.FC<CalendarProps> = ({ entries, onAddEntry }) => {
  const [showModal, setShowModal] = useState(false);
  const [newEntry, setNewEntry] = useState<Omit<Entry, 'day'>>({
    title: '',
    url: '',
    author: '',
  });

  const days = Array.from({ length: 25 }, (_, i) => i + 1);

  const handleOpenModal = () => {
    setShowModal(true);
  };

  const handleCloseModal = () => {
    setShowModal(false);
  };

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setNewEntry({ ...newEntry, [e.target.name]: e.target.value });
  };

  const handleAddEntry = () => {
    const day = days.find((day) => !entries.find((entry) => entry.day === day));
    if (day) {
      onAddEntry({ ...newEntry, day });
      handleCloseModal();
      setNewEntry({ title: '', url: '', author: '' });
    }
  };

  return (
    <div className="container mx-auto p-4">
      <div className="flex justify-between items-center mb-4">
        <h1 className="text-2xl font-bold">私立恵比寿中学2024(非公式) Advent Calendar 2024</h1>
        <button onClick={handleOpenModal} className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
          記事を登録
        </button>
      </div>

      <div className="grid grid-cols-7 gap-4">
        {days.map((day) => {
          const entry = entries.find((entry) => entry.day === day);
          return (
            <div key={day} className="border p-4 rounded">
              <h2 className="text-lg font-semibold">{day}日</h2>
              {entry && (
                <div>
                  <a href={entry.url} target="_blank" rel="noopener noreferrer">
                    {entry.title}
                  </a>
                  <p className="text-sm">著者: {entry.author}</p>
                </div>
              )}
            </div>
          );
        })}
      </div>

      {showModal && (
        <div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex justify-center items-center">
          <div className="bg-white rounded p-4">
            <h2 className="text-lg font-semibold mb-4">記事を登録</h2>
            <div className="mb-2">
              <label htmlFor="title" className="block text-sm font-medium text-gray-700">タイトル</label>
              <input type="text" name="title" value={newEntry.title} onChange={handleInputChange} className="mt-1 p-2 border rounded w-full" />
            </div>
            <div className="mb-2">
              <label htmlFor="url" className="block text-sm font-medium text-gray-700">URL</label>
              <input type="text" name="url" value={newEntry.url} onChange={handleInputChange} className="mt-1 p-2 border rounded w-full" />
            </div>
            <div className="mb-4">
              <label htmlFor="author" className="block text-sm font-medium text-gray-700">著者</label>
              <input type="text" name="author" value={newEntry.author} onChange={handleInputChange} className="mt-1 p-2 border rounded w-full" />
            </div>
            <div className="flex justify-end">
              <button onClick={handleCloseModal} className="bg-gray-400 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded mr-2">
                キャンセル
              </button>
              <button onClick={handleAddEntry} className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
                登録
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
};

export default Calendar;

どう実装したらいいか分からなかったときに具体的なサンプルを出してくれるのでめっちゃ助かります.
普段の業務でフロントを触る機会が皆無に等しいこともあって, CSSが絡むところはほとんどGeminiにやってもらいました.

本当ならv0の方がこういう用途に向いているはずですが, 無料プランだと1日にプロンプトを投げられる回数に制限があるので今回はあんまりお世話になりませんでした. もう少しフロントを触る機会が増えてきたら契約するかもしれない.

https://v0.dev

今回はadventarというお手本があったのでスクショでしたが, 完全新規で作るときはFigmaあたりで作ったデザインで同じことをすれば良いです.

Realtime Databaseのセキュリティルールが難しい

Realtime Databaseのセキュリティルール設定が最大の詰まりポイントでした.

firebase.google.com

Realtime Databaseは保存したいJSONの構造に対して, 4種類のルール(.read, .write, .validate, indexOn)と正規表現, 式などをJSON内にセキュリティルールとして記述する仕様になっています. 読み書きの権限設定の他に, バリデーションもこのルール内で実装します.

この仕様と構文の表現力が少々乏しく, 実装したい権限設定やバリデーションに対して結構まどろっこしい書き方をしないといけない場面が多かったです.

隅から隅までドキュメントを読みつつ, 試行錯誤してたらどうにか書けますが, まどろっこしい書き方な故に見落としがないか判断しにくいとか, ぱっと見で読みにくいとか, Realtime Databaseの辛くなりがちポイントなのかなという気がしました.*1

Firebase Hostingの独自ドメイン設定の体験が良い

Firebase HostingではデフォルトだとprojectId.web.app, またはprojectId.firebaseapp.comのURLで公開されるようになってます.

流石にこれではかっこ悪いので独自ドメインの設定をしたくなりますね. Firebaseではこの設定が超簡単に出来るようになっています.

firebase.google.com

theblueback.com

設定したいサブドメインを入力するとCNAMEレコードが表示されるので, 自分のドメインDNSにそのレコードを追加して後は放置でOK.

待っているだけで証明書の自動設定までしてくれるので実作業3分で設定が終わりました.

勝手にTLS証明書を取得してくれるのはGitHub Pagesでも同じですが, 独自ドメインを使いつつ証明書の取得まで全部Firebaseに丸投げ出来るのは最高体験でした.

投稿URLの制限から思いついた野望

クライアントから直接読み書きするというRealtime Databaseの特性上, 最終的な入力値のバリデーションは先述のセキュリティルールで行うことになります. そうすると, セキュリティルールで実装しやすい制約を付けておくと後が楽そうです.

投稿可能なURLのドメインを絞るのが一番楽そうだったので, 使えるサービスを絞ることにしました.
- はてなブログ
- note

noteは一番ユーザが多いから, はてなブログは自分が使うから, というので選びました.

一旦この2つで良かろう, と思っていたんですがふと, えびちゅうの公式ブログがアメブロなのを思い出しました.

もしかしたらこの企画がメンバーの目に入ったときに, 興味を持ってもらえるかもしれない. そのときに企画が盛り上がっていたらもしかして...

そう, この中にアメブロを入れたのは何かの間違いでメンバーやスタッフさんが記事を書いてくれるかもしれないという, 限りなく0%に近い可能性にワクワクしたからです.

きっとこの夢は叶うことなく企画終了を迎えることになるとは思いますが, この夢は開発のモチベーション維持に大きく貢献してくれました.


リリース

実質1週間ちょっとしか時間がなかったものの, なんとか企画スタート日の2日前である2/18にリリース出来ました.

この記事の公開時点でリリースから丸1週間程度経っていますが, 特に大きな不具合やトラブルもなく稼働してくれているので一安心です.

企画開始後, 複数人からDMで質問やご意見をいただきました. ページ内の説明が不足していたり, 分かりにくかったことで混乱させてしまったと思います(その節は失礼しました).
いただいた質問やご意見は極力即時修正・反映するようにしていますので, 何か気になりポイントがあればDMやリプ等で指摘いただけると助かります.

感想

技術方面

普段業務で触らない技術が多かったので色々勉強になりました.

以前作った生誕メッセージカードアプリでReactに入門しましたが, まだまだ初心者ながらそれなりに慣れれた感が得られたので良かったです.

今回の開発がきっかけで社内アプリ開発のネタも浮かんできたので, 記憶が揮発する前に取り掛かりたいですね*2.

Firebaseの体験は良かったです. 小規模なWebアプリ開発で使うのにぴったりという感想でした. 今後も使っていきたい.
ただRealtime Databaseをクライアントから直で使うのはもうやりたくないかも. 普通にバックエンドを用意するなりRDB使った方が余計な苦労はしなくて良かったかもと反省してます.

今回も詰まったときにid:anatofuz さんに助けてもらってました. 休日にドカDMしたのに丁寧に回答してくださってありがとうございました.

苦手意識が強いCSSも, AIにほぼ丸投げでそこそこいい感じに出来ることが分かったのは良い収穫でした. Geminiマジでありがとう.
BIG LOVE...

えびちゅう・企画方面

やっぱり自分が大好きなアイドルと絡んだ開発をやるのは楽しいです.

告知ツイートを考えたり, DMでもらった意見・質問をもとに説明欄を書き換えたりとか, 情報をいい感じに伝える練習にもなった気がします. まだまだ下手くそだけど.

FF外の, 完全に面識が無い方も参加してくださったのが何より嬉しいですね. 今度現場でご挨拶させてください.

22日の特典会で中山莉子さん*3, 風見和香さん*4, 三代目に企画の話が出来たのも楽しかったです. もしお時間あれば見に来てください.
なんなら記事の方もよろしくお願いします.

*1:結構辛かったのと理解しにくかったのでこういう感想になっている. 「いやそれはお前の設計が悪いだけでは?」とかあったら指摘してくれ

*2:チームの人へ. めんどくさがってたら突っついてくれ

*3:「読みたい! 見てみるね!」って言ってくれた. 好き.

*4:「ブログと言えば風見さんじゃないですか, アメブロも投稿出来るんですよ. よかったら書いてもらえません???」って言ったら困ったような渋るような複雑な表情をされた. 素直でかわいい.