🔥

大学授業内ハッカソンでCloudflareフル活用システム開発した話

2023/12/20に公開
1

こんにちは。かろっくです。

今回は一言でいうと

大学授業内ハッカソンで"出席管理システム"を作ることになりました
せっかくなので Cloudflare のインフラで最新技術をフル活用!
楽しかったです

という感じのお話をします。

はじめに

自分の大学で行われている授業に、「PBL 概論」というものがあります。

この授業は、生徒が自分たちで解決したいテーマを決め、それに沿って作品を開発していく実践的な授業です(授業というより、ハッカソンに近い感じの演習となっています)。

テーマとしては、「授業の不満を解消する」「生徒の生活を便利にする」など、生徒が直接関わるものが多いです。

授業の不満をヒアリングしたところ、出席管理に関する不満として、以下のようなポイントが挙がりました。

  • 出席判定がカードのタッチで行われるため、手間がかかる
  • カードを忘れると出席が取れない
  • 出席したときに何らかの手段で通知が欲しい

そこで、これらの不満を解消するために、出席管理システムを作ることにしました。

出席管理システムの概要

このシステムを作成するにあたって、以下のような要件を定めました。

  • 出席判定は、生徒の Bluetooth を検知することで行う
  • 出席の内容を、生徒の e メールで通知できるようにしたい
  • 出席内容を Web ページから確認したい
  • 教師も同じように出席を確認できるようにしたい

また、今後の利用性向上に向けて、以下のような方針も定めました。

  • 出席判定ロジック部分の API スキーマを仕様として
    出席認識部分のロジックを外部から拡張できるようにしたい

出席管理システムのデータベース設計

出席管理システムのデータベース設計は、以下のようにしました。

テーブル

  • 生徒用テーブル
  • 教師用テーブル
  • 授業用テーブル
  • 履修用テーブル
  • 出席用テーブル

リレーション

  • 教師用テーブルは、授業用テーブルと 1 対多のリレーション
  • 生徒用テーブルは、履修用テーブルと 1 対多のリレーション
  • 授業用テーブルは、履修用テーブルと 1 対多のリレーション
  • 生徒用テーブルは、出席用テーブルと 1 対多のリレーション
  • 授業用テーブルは、出席用テーブルと 1 対多のリレーション

技術選定

今回技術選定するにあたって、是非 Cloudflare Workers と Cloudflare Pages を利用したいと思いました。

理由としては、以下のようなものがあります。

  • Cloudflare Workers での開発が最近ブームになっており、一度使ってみたかったため
  • 以前のハッカソンで Cloudflare Pages を使ったことがあり、とても使いやすかった経験があるため
  • Cloudflare Workers では D1 というエッジで動くデータベースが使えるため
  • Cloudflare Workers では KV というエッジで動くキーバリューストアが使えるため

Cloudflare Workers でのバックエンド開発

Cloudflare Workers でのバックエンド開発をするにあたり、バックエンドを Hono で開発することを決定しました。

Hono は、Cloudflare Workers でのバックエンド開発をサポートしている、モダンなバックエンドフレームワークです。丁度 Express.js のモダン版のような感じですね。

https://hono.dev/

https://zenn.dev/azukiazusa/articles/hono-cloudflare-workers-rest-api

Cloudflare Workers でのバックエンド開発をするにあたり、Hono は最適な選択肢でした。

また、Cloudflare Workers D1 にアクセスする手法として、Drizzle ORM を選択しました。
https://zenn.dev/mizchi/articles/d1-drizzle-orm

その他、Cloudflare Workers でのバックエンド開発にあたり、以下のようなライブラリを使用しました。

  • hono/jwt - JWT の生成と検証を行うためのライブラリ(middleware として使用)
  • ulidx - ユニークな ID を生成するためのライブラリ
  • bcrypt-js - パスワードのハッシュ化を行うためのライブラリ

参考リンクを以下に記載します。

https://hono.dev/middleware/builtin/jwt
https://github.com/perry-mitchell/ulidx
https://github.com/dcodeIO/bcrypt.js

さらに、E メールの送信を担当するサービスについて、Resend.com を選択しました。

https://resend.com/

Cloudflare Pages でのフロントエンド開発

Cloudflare Pages でのフロントエンド開発をするにあたり、フロントエンドを React で開発することを決定しました。

また、自分が使い慣れている以下のライブラリ群を採用しました。

  • React
  • Panda CSS - CSS フレームワーク
  • Park UI - React コンポーネントライブラリ (ドロワーやモーダルなどの UI コンポーネントを利用)
  • Tanstack Query - クエリキャッシュライブラリ
  • Tantack Router - 型安全なルーティングライブラリ
  • react-hot-toast - トースト表示ライブラリ

参考リンクを以下に記載します。

https://panda-css.com/
https://tanstack.com/query/latest
https://tanstack.com/router/v1
https://react-hot-toast.com/

詰まったところ・工夫点

バックエンド: Hono を使ったことがなかった

Hono を使ったことがなかったため、最初はどうやって使えばいいのかわかりませんでした。結局公式のサンプルコードを読みながらの開発になったとおもいます。

https://hono.dev/getting-started/cloudflare-workers

バックエンド: Drizzle ORM の活かし方に苦戦した

Drizzle ORM は、特性上生の SQL に近い形でクエリを書くことになります。
自分はこれまで Prisma ORM を主に使っていたのですが、Prisma とは違う使い勝手に苦戦し、今まで SQL とまともに向き合ってこなかった自分の甘さを痛感しました。

また、Drizzle ORM 自体のドキュメントが実際の API と乖離している部分が多く、苦戦しました。

SQL エアプでごめんなさい・・・。

Select メソッドを使ってリレーションされたテーブルのデータを取得したりしたのですが、なぜかデータにズレが生じてしまったり・・・。
結局、リレーションと外部キー制約をスキーマの方で設定し、Drizzle ORM の提供している query メソッドを用いてようやく解決しました。

スキーマの定義
import { relations } from "drizzle-orm";
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

// テーブル定義
// テーブル名: student
// 生徒を管理するテーブル
const student = sqliteTable("student", {
  student_uuid: text("student_uuid").primaryKey().notNull(),
  student_id: integer("student_id").notNull().unique(),
  device_id: text("device_id").notNull().unique(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  password_hash: text("password").notNull(),
});

// テーブル名: teacher
// 教師を管理するテーブル
const teacher = sqliteTable("teacher", {
  teacher_uuid: text("teacher_uuid").primaryKey().notNull(),
  teacher_id: integer("teacher_id").notNull().unique(),
  name: text("name").notNull(),
  password_hash: text("password").notNull(),
});

// テーブル名: lesson
// 授業を管理するテーブル
const lesson = sqliteTable("lesson", {
  lesson_uuid: text("lesson_uuid").primaryKey().notNull(),
  name: text("name").notNull(),
  teacher_uuid: text("teacher_uuid")
    .references(() => teacher.teacher_uuid)
    .notNull(),
  status: integer("status").notNull().default(0),
});

// テーブル名: regilesson
// 登録された授業を管理するテーブル
const regilesson = sqliteTable("regilesson", {
  regilesson_uuid: text("regilesson_uuid").primaryKey().notNull(),
  student_uuid: text("student_uuid")
    .notNull()
    .references(() => student.student_uuid),
  lesson_uuid: text("lesson_uuid")
    .notNull()
    .references(() => lesson.lesson_uuid),
});

// テーブル名: attendance
// 出席を管理するテーブル
const attendance = sqliteTable("attendance", {
  attendance_uuid: text("attendance_uuid").primaryKey().notNull(),
  student_uuid: text("student_uuid")
    .notNull()
    .references(() => student.student_uuid),
  lesson_uuid: text("lesson_uuid")
    .notNull()
    .references(() => lesson.lesson_uuid),
  status: integer("status").notNull(),
});

// 生徒の持つリレーション
const student_relation = relations(student, ({ many }) => ({
  regilessons: many(regilesson),
  attendances: many(attendance),
}));

// 教師の持つリレーション
const teacher_relation = relations(teacher, ({ many }) => ({
  lessons: many(lesson),
}));

// 授業の持つリレーション
const lesson_relation = relations(lesson, ({ one, many }) => ({
  posts: many(attendance),
  teacher: one(teacher, {
    fields: [lesson.teacher_uuid],
    references: [teacher.teacher_uuid],
  }),
}));

// 登録された授業の持つリレーション
const regilesson_relation = relations(regilesson, ({ one }) => ({
  student: one(student, {
    fields: [regilesson.student_uuid],
    references: [student.student_uuid],
  }),
  lesson: one(lesson, {
    fields: [regilesson.lesson_uuid],
    references: [lesson.lesson_uuid],
  }),
}));

// 出席の持つリレーション
const attendance_relation = relations(attendance, ({ one }) => ({
  student: one(student, {
    fields: [attendance.student_uuid],
    references: [student.student_uuid],
  }),
  lesson: one(lesson, {
    fields: [attendance.lesson_uuid],
    references: [lesson.lesson_uuid],
  }),
}));

export {
  student,
  teacher,
  lesson,
  regilesson,
  attendance,
  student_relation,
  teacher_relation,
  lesson_relation,
  regilesson_relation,
  attendance_relation,
};
リレーションを活かしたクエリ
// [認証教師] 特定の教師の授業の一覧を取得する
app_hono.get("/teachers/:teacher_uuid/lessons", async (c) => {
  const db = drizzle(c.env.DB, {
    schema: {
      lesson: lesson,
      teacher: teacher,
      lesson_relation: lesson_relation,
    },
  });

  const teacher_uuid = c.req.param().teacher_uuid;
  const result = await db.query.lesson.findMany({
    where: eq(lesson.teacher_uuid, teacher_uuid),
    columns: {
      lesson_uuid: true,
      name: true,
      status: true,
    },
    with: {
      teacher: true,
    },
  });

  return c.json(result, 200);
});

https://orm.drizzle.team/docs/rqb#declaring-relations

バックエンド: IDaaS と連携する or 自前で認証機能を実装するか迷った

IDaaS と連携して JWT の検証に徹するか、自前で認証機能を実装するか迷いました。

結局、自前で認証機能を実装することにしました。生徒用と教師用の二種類のログインを実装する必要があり、IDaaS と連携すると、それぞれのログインに対応することになって面倒だったためです。

バックエンド: アカウント周りの処理

さて、自前で認証を実装するとなると、以下の処理が必要となってきます。

  • アカウント作成
  • アカウント認証と JWT 発行

まずはアカウント作成なのですが、Cloudflare Workers 上で bcrypt-js を動作させています。

Cloudflare は CPU のリソースが厳しいようですが、しっかり動作したためこれを採用しています。
https://developers.cloudflare.com/workers/platform/limits

アカウント認証と JWT 発行については、Hono の JWT ミドルウェアを使って実装しました。

ヨシ!

バックエンド: 出席管理システムの動作フロー設計

出席管理システムの動作フローをぼんやりと考えると、以下のようになります。

  • 教師が授業を作成する
  • 生徒がその ID を参照して、授業に履修する
  • 教師が授業を開始する
  • 生徒が出席する
  • 教師が授業を終了する

では、このフローを基に、実際の動作を組んでみましょう。

教師が授業を作成する

これは、教師が授業を作成するときに、授業用テーブルにレコードを作成することで実現できそうです。

生徒がその ID を参照して、授業に履修する

これは、生徒が授業に履修するときに、履修用テーブルにレコードを作成することで実現できそうです。

教師が授業を開始する

問題は、教師が授業を開始するときです。

教師が授業を開始するときに、授業用テーブルの開始フラグを立てますが、このときにどのような処理を行うべきか考えてみると・・・

「生徒の出席はリアルタイムで見られる必要があるなら、教師が授業を開始したときに、生徒の出席用テーブルにあらかじめ出席レコードを作成しておく必要があるのでは?」

こう考え、教師が授業を開始するときに、出席用テーブルに生徒の出席レコードを作成するようにしています。

生徒が出席する

ここで、生徒が出席するときに、出席用テーブルの出席フラグを立てますが・・・。

生徒の出席は、生徒の Bluetooth を検知することで行うことになっています。

たとえば、ユーザ固有の Bluetooth ID があり、それを検知することで出席を判定するとします。このとき、Bluetooth ID を検知するレーダーのようなものが、10 秒おきに生徒の Bluetooth ID のリストをバックエンドサーバに送信するものとします。

バックエンドサーバがそのリストを受け付けたとき、毎回すべての生徒のデバイス ID を検索し、それに一対一で対応する生徒の出席レコードを更新するというのは、あまりにも負荷が高いです。

したがって、ここでキーバリューストアを上手く使いたいと思います。

「出席情報を受け付ける API の処理は、授業の ID と生徒のデバイス ID をキーとして、生徒が出席したことをキーバリューストアに格納するだけにとどめよう」
「その代わり、バッチ処理でより大きな間隔で、生徒のデバイス ID を検索し、それに一対一で対応する生徒の出席レコードを更新するようにしよう」

こう考えました。
バッチ処理の詳細を考える必要も新たに出てきました。

バッチ処理 ←NEW

バッチ処理では、以下のような処理を行います。

  • キーバリューストアから、現在出席している生徒のデバイス ID をすべて取得する
  • 当該授業を履修している生徒のデバイス ID と生徒 ID をすべて取得する
  • 出席がすでに完了している生徒の生徒 ID をすべて取得する
  • 当該授業を履修している生徒のデバイス ID リストから、
    • 出席がすでに完了している生徒のデバイス ID を除外
    • 該当するキーバリューストアのデバイス ID に含まれない生徒のデバイス ID を除外
      し、残った生徒の生徒 ID を取得
  • 残った生徒の生徒 ID に対応する出席レコードを、出席フラグを立てて更新する

という処理を行っています。

教師が授業を終了する

教師が授業を終了したとき、出席用テーブルの終了フラグを立てます。
また、キーバリューストアを掃除します。

バックエンド: e メールの送信

e メールの送信については、Resend.com を使いました。

resend の提供しているライブラリを利用したかったのですが、Cloudflare Workers での利用には対応していなかったため、自前で API をたたく形で実装しました。

e メールの送信
const sendAttendeeEmail = async ({
  to,
  resend_api_key,
}: {
  to: string;
  resend_api_key: string;
}) => {
  // 現在の時刻を取得
  const now = new Date();
  const year = now.getFullYear();
  const month = now.getMonth() + 1;
  const date = now.getDate();
  const hour = now.getHours();
  const minute = now.getMinutes();
  const second = now.getSeconds();

  // 現在の時刻を文字列に変換
  const nowString = `${year}/${month}/${date} ${hour}:${minute}:${second}`;

  const result = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${resend_api_key}`,
    },
    body: JSON.stringify({
      from: "出席管理システム <attendance@calloc.tech>",
      to: [to],
      subject: "出席が完了しました",
      text: `出席管理システムからの自動送信メールです。\n\n${nowString}に出席が完了しました。\n\n出席管理システムをご利用いただきありがとうございます。`,
    }),
  });

  if (result.status !== 200) {
    return false;
  }

  return true;
};

export { sendAttendeeEmail };

https://github.com/calloc134/pbl-backend/blob/master/src/util.ts

このようにしてメールが受信できることを確認できました。

バックエンド: 認可処理

認可処理は、Hono の JWT ミドルウェアを使って実装しました。

ここで、アクセスされるパスとログインユーザによって認可の処理を切り分けたかったのですが、Hono の Middleware は、パスごとにしか設定できないようでした。

したがって、JWT の Middlware の内部実装を軽く読んでから、以下のようなコードを書き、パスによって認可処理を切り分けました。

認可処理のコード
// 認証を設定するミドルウェア
app_hono.use("*", async (c, next) => {
  console.debug("[*] 認証を設定するミドルウェアを実行しています。");
  // 認証の必要ないエンドポイントはスキップする
  const path = c.req.path;

  // もしオブジェクトにパスが存在し、かつメソッドが一致する場合はスキップする
  if (
    allow_path_list.some(
      (allow_path) =>
        allow_path.path === path && allow_path.method === c.req.method
    )
  ) {
    // 認証をスキップする
    console.log("[*] 認証をスキップします。");
    await next();
    return;
  }

  // 認証を行う
  await jwt({
    secret: c.env.JWT_SECRET_KEY,
    alg: "HS256",
  })(c, async () => {});
  // ペイロードを取得する
  const payload = c.get("jwtPayload") as JWTPayload;

  console.debug("[*] ペイロードを表示します。", payload);

  if (payload.type === "student") {
    console.debug("[*] 生徒として認証します。");
    // 生徒としてアクセスできるエンドポイントのみ許可する
    if (
      !student_path_list.some(
        (student_path) =>
          student_path.path === path && student_path.method === c.req.method
      )
    ) {
      console.debug("[!] 生徒としてアクセスできないエンドポイントです。");
      return c.json(
        {
          error: "生徒としてアクセスできないエンドポイントです",
        },
        403
      );
    }
  } else if (payload.type === "teacher") {
    console.debug("[*] 教師として認証します。");
    // 教師としてアクセスできるエンドポイントのみ許可する
    if (
      !teacher_path_list.some(
        (teacher_path) =>
          teacher_path.path === path && teacher_path.method === c.req.method
      )
    ) {
      console.debug("[!] 教師としてアクセスできないエンドポイントです。");
      return c.json(
        {
          error: "教師としてアクセスできないエンドポイントです",
        },
        403
      );
    }
  } else {
    console.debug("[!] 認証に失敗しました。");
    return c.json(
      {
        error: "認証に失敗しました",
      },
      403
    );
  }

  console.debug("[*] 認証に成功しました。");
  // 処理を続行する
  await next();
});
認可処理のパス
// 認証なしでアクセスを許可するパスの列挙
// メソッドも含む
const allow_path_list = [
  {
    path: "/students",
    method: "POST",
  },
  {
    path: "/teachers",
    method: "POST",
  },
  {
    path: "/students/login",
    method: "POST",
  },
  {
    path: "/teachers/login",
    method: "POST",
  },
  {
    path: "/attendances-endpoint",
    method: "POST",
  },
];

// 生徒として認証を設定するパスの列挙
// メソッドも含む
const student_path_list = [
  {
    path: "/students/me",
    method: "GET",
  },
  {
    path: "/lessons/:lesson_uuid",
    method: "GET",
  },
  {
    path: "/join-lessons",
    method: "POST",
  },
  {
    path: "/students/me/join-lessons",
    method: "GET",
  },
  {
    path: "/students/me/attendances",
    method: "GET",
  },
];

// 先生として認証を設定するパスの列挙
// メソッドも含む
const teacher_path_list = [
  {
    path: "/teachers/me",
    method: "GET",
  },
  {
    path: "/students",
    method: "GET",
  },
  {
    path: "/students/:student_uuid",
    method: "GET",
  },
  {
    path: "/teachers",
    method: "GET",
  },
  {
    path: "/teachers/:teacher_uuid",
    method: "GET",
  },
  {
    path: "/teachers/:teacher_uuid/lessons",
    method: "GET",
  },
  {
    path: "/teachers/me/lessons",
    method: "GET",
  },
  {
    path: "/lessons",
    method: "POST",
  },
  {
    path: "/lessons/:lesson_uuid",
    method: "GET",
  },
  {
    path: "/students/:student_uuid/join-lessons",
    method: "GET",
  },
  {
    path: "/students/:student_uuid/attendances",
    method: "GET",
  },
  // {
  // 	path: '/lessons/:lesson_uuid/attendances',
  // 	method: 'GET',
  // },
  {
    path: "/lessons/particular/attendances",
    method: "POST",
  },
  {
    path: "/lessons/start",
    method: "POST",
  },
  {
    path: "/lessons/end",
    method: "POST",
  },
];

export { allow_path_list, student_path_list, teacher_path_list };

https://github.com/calloc134/pbl-backend/blob/master/src/path_list.ts

しかし!この方法だと、RESTAPI 特有の「パスの中にパラメータを埋め込む」挙動が全部使えなくなります。

つらい・・・

REST に沿わない、変な POST が API に存在するのはそのためです・・・。

バックエンド: CORS の設定

フロントエンドとつなぎこみしたときに、CORS のエラーが出て、Authorization ヘッダが取得できないという問題が発生しました。

これは、Cloudflare Workers の設定で、CORS を許可する必要があることが原因でした。

CORS の設定
app_hono.use(
  "*",
  cors({
    origin: [
      "http://localhost:5173",
      "http://127.0.0.1:5173",
      "https://pbl-page.pages.dev",
    ],
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allowHeaders: ["Authorization", "Content-Type"],
    exposeHeaders: ["Authorization"],
    maxAge: 86400,
  })
);

このように設定することで、CORS のエラーが解消されました。
allowHeaders と ExposeHeaders に Authorization を設定することで、Authorization ヘッダを取得できるようになります。

フロントエンド: ログイン画面の実装

ログイン画面の実装ですが、今回は外部実装を使わず、自前で実装しました。

セッションの保持は SessionStorage を利用しています(面倒だったので・・・ごめんなさい)

SessionStorage の情報をフックとして利用できるようにするため、以下のようなカスタムフックを作成しました。

useSyncExternalStore フックを利用して、SessionStorage の情報をフックとして利用できるようにしています。

セッション管理のカスタムフック
import { useSyncExternalStore, useCallback } from "react";

const useSessionStorage = <T>(key: string, initialValue: T) => {
  // セッションストレージから値を読み込む
  const getStoredValue = useCallback(() => {
    const storedValue = sessionStorage.getItem(key);
    return storedValue ? JSON.parse(storedValue) : initialValue;
  }, [key, initialValue]);

  // React に外部データソースの変更を通知するための関数
  const subscribe = useCallback(
    (notifyChange: () => void) => {
      const handleChange = (event: StorageEvent) => {
        if (event.key === key) {
          notifyChange();
        }
      };
      window.addEventListener("storage", handleChange);
      return () => window.removeEventListener("storage", handleChange);
    },
    [key]
  );

  // useSyncExternalStore を使用して、セッションストレージの値と同期
  const value = useSyncExternalStore(subscribe, getStoredValue);

  // セッションストレージに値を設定する関数
  const setValue = useCallback(
    (newValue: T) => {
      const stringifiedValue = JSON.stringify(newValue);
      sessionStorage.setItem(key, stringifiedValue);
      // subscribeを通じて変更を手動で通知する場合、ここに処理を追加する
    },
    [key]
  );

  return [value, setValue];
};

export { useSessionStorage };

ChatGPT の記述したコードを参考にさせていただきました。
https://ja.react.dev/reference/react/useSyncExternalStore

ログインのコンテキストや provider は以下のディレクトリで管理しています。

https://github.com/calloc134/pbl-frontend/tree/master/src/features/student/context

provider
import { FC, ReactNode, useCallback } from "react";
import { JwtContext } from "./CredentialContext";
import { JwtStudentPayloadType, IJwtStudentContext } from "../types/jwtType";
import { decode } from "js-base64";
import { useSessionStorage } from "./useSessionStorage";

// プロバイダコンポーネント

const JwtProvider: FC<{ children: ReactNode }> = ({ children }) => {
  const [jwtToken, setJwtToken] = useSessionStorage<string | null>(
    "StudentJwtToken",
    null
  );

  const getJwtPayload = useCallback((): JwtStudentPayloadType | null => {
    if (!jwtToken) {
      console.debug("jwtToken is null");
      return null;
    }
    console.debug("jwtToken", jwtToken);

    const payload = jwtToken.split(".")[1];
    // base64をデコード
    const decodedPayload = decode(payload);
    return JSON.parse(decodedPayload);
  }, [jwtToken]);

  // JWTトークンを削除し、ログアウトする
  const deleteJwtTokenAndLogout = useCallback(() => {
    setJwtToken(null);
  }, [setJwtToken]);

  // コンテキストプロバイダの値
  const contextValue: IJwtStudentContext = {
    jwtToken,
    setJwtToken,
    getJwtPayload,
    deleteJwtTokenAndLogout,
  };

  return (
    <JwtContext.Provider value={contextValue}>{children}</JwtContext.Provider>
  );
};

export { JwtContext, JwtProvider };

フロントエンド: ルーティング

ルーティングは恒例の Tanstack Router を使って実装しました。

ルートの定義
const router = new Router({
  routeTree: root_route.addChildren([
    index_route.addChildren([
      student_route.addChildren([
        student_register_route,
        student_login_route,
        student_auth_route.addChildren([
          student_info_route,
          student_attendance_route,
          student_course_route,
          student_add_course_route,
          student_logout_route,
        ]),
        teacher_route.addChildren([
          teacher_register_route,
          teacher_login_route,
          teacher_auth_route.addChildren([
            teacher_info_route,
            teacher_all_students_route,
            teacher_all_teachers_route,
            teacher_all_lessons_route,
            teacher_add_lesson_route,
            teacher_attendance_route,
            teacher_logout_route,
          ]),
        ]),
      ]),
    ]),
    not_found_route,
  ]),
});

https://github.com/calloc134/pbl-frontend/blob/master/src/route.tsx

型安全ルーティング推しです
tanstack router の推しポイントについても今後書いていきたい。

フロントエンド: クエリ

今回は Tanstack Query を利用し、その部分をカスタムフックに切り出しています。

クエリのカスタムフック
// 自分の過去の出席をすべて取得するカスタムフック
import { useQuery } from "@tanstack/react-query";
import { useJwtToken } from "../context/useJWTToken";
const useMyAttendanceFetch = () => {
  const { jwtToken } = useJwtToken();

  const { data, isLoading, error } = useQuery({
    queryKey: ["student", "me", "attendances"],
    queryFn: async () => {
      const response = await fetch(
        "https://pbl-gairon-test.calloc134personal.workers.dev/students/me/attendances",
        {
          headers: {
            Authorization: `Bearer ${jwtToken}`,
          },
        }
      );
      const data = await response.json();
      return data as {
        status: number;
        attendance_uuid: string;
        lesson: {
          name: string;
          lesson_uuid: string;
          status: 0 | 1 | 2;
          teacher: {
            name: string;
            password_hash: string;
            teacher_uuid: string;
            teacher_id: number;
          };
        };
      }[];
    },
  });
  return { data, isLoading, error };
};

export { useMyAttendanceFetch };

フロントエンド: ディレクトリ構成

意外とディレクトリ構成が綺麗にまとまった気がしています

フロントエンド: レスポンシブ対応

Panda CSS の機能を使って、レスポンシブ対応を行いました。

レスポンシブ対応
<div
  className={css({
    padding: 4,
    width: "60%", // Set the width to 60% of the screen size
    margin: "0 auto", // Center align the card
    border: "1px solid black", // Add a black border to the card
    borderRadius: 8, // Add rounded corners to the card
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
  })}
>
  ...
</div>

CSS 苦手かも

完成したもの

完成したものは以下のリポジトリにあります。

https://github.com/calloc134/pbl-backend

https://github.com/calloc134/pbl-frontend

ホーム画面です。

ログイン画面です。ログインしていない状態でログインの必要な画面にアクセスすると、ここにリダイレクトされます。

アカウント登録画面です。

ログインに失敗した際など、トーストが表示されます。

ログイン後は自分の情報が表示されます。

生徒のメニューとしては以下の通りです。

教師のメニューとしては以下の通りです。

https://pbl-page.pages.dev/

OGP も設定しました!

簡単な感想

バックエンド

今までの開発では Prisma ORM を利用しており、それほどクエリやデータベースの負荷を考えない状態で開発を行っていました。
今回は Drizzle ORM を利用して開発を行いましたが、Prisma と比較して SQL に近いクエリを書くことが大幅に増え、データベースの負荷を意識しながら呼び出しをするように心がけることが出来たことは良かったです。

また、出席管理システムの動作フローを考えるときに、毎回データベースを呼び出して負荷をかけるのではなく、キーバリューストアを使ってデータを保持するよう工夫することができた点も良かったと思います。

Cloudflare Workers はエッジコンピューティングのプラットフォームであるため、リソースの制限がどうしても大きくなってしまいますが、それでも十分に動作するよう設計できたため、とても嬉しかったです。

フロントエンド

できるだけ外部ライブラリへの依存を減らし、自前で実装できる部分は自前で実装できたことが個人的に良かったかなと思います。

当初は jotai ライブラリなどの利用を考えましたが、React 公式の ContextAPI を利用して依存を減らしながら設計しています。

また、useSyncExternalStore フックを上手く利用して、使い勝手を確認できたことは良い経験になりました。

適切にディレクトリ構成を工夫し、フックとして状態を持つところはカスタムフックに切り出すことで、ロジックと見た目の分割を上手く行えたと考えます。

今後の展望と反省点

今後意識したいことがいくつかあります。

API のスキーマ定義とバリデーションができていない

バックエンドの API を早急に作ったため、API のスキーマ定義とバリデーションができていません。

自分はスキーマ駆動開発が大好き人間で graphql を推しているのですが、今回はそこまで手が回りませんでした・・・

スキーマを統一することで、フロントエンドとバックエンドの間でのバリデーションを統一できることや、API のドキュメントを自動生成できることなど、メリットは多いです。

今後はまずスキーマ定義を行い、バリデーションを行うようにしたいです。

バックエンドのソースコードを分割する

現在、バックエンドのソースコードを一つのファイルにまとめています。

https://github.com/calloc134/pbl-backend/blob/master/src/index.ts

非常に読みにくくなっています。そのため、適切な分離を前向きに検討したいです。

出席管理センサからのデータに認証を追加

現在、出席管理センサからのデータに認証を追加していません。

現状であると、API のスキーマを事前に把握している攻撃者が、出席管理センサからのデータに偽装してリクエストを送信することで、出席を行うことができてしまいます。
想定されるものとして、出席を行っていない生徒が出席を行ったことにする、という不正な攻撃が考えられます。

今後はこの問題に対策できるよう、JWT を使って認証を追加したいです。

CSS が汚い

copilot をふんだんに利用してデザインを作成したのですが、思ったより marginBottom が多用されたコードが生成されてしまいました。
margin の利用を減らし、flex と padding 、 gap を利用するよう、リファクタリングを行いたいです。

おわりに

最終的に、そこそこの規模の出席管理システムを無事フルスタックで作成することができました。

偉い!

えらいぞ~~~~~~~~~~

今まで触れたことのなかった新技術をゴリゴリ触りながら、なんとか形にできたので、すごくいい体験になりました。
(先生からも褒められました うれしかったです)

勢いで書いた記事で、つたないところが多いと思いますが、最後まで読んでいただきありがとうございました!

+α 嬉しかったこと

https://github.com/calloc134/pbl-backend/stargazers

開発したバックエンドのリポジトリに、Hono の開発者の方である@yusukebe さんからスターをいただきました!

すごく嬉しいです!

GitHubで編集を提案

Discussion

たぬきの教祖たぬきの教祖

色々な意味で日本もまだまだやれると感じる。
学生なのだろうか、こういう優秀な学生はどこにいるのだろう。