LoginSignup
0
0

バイト先の塾で教材配布Webサービスを作っ(て失敗し)た話

Posted at

塾バイト関連の書類を整理していたところ、3年ほど前に作ったものが出土しました。主に自分用にここに供養させていただきます。興味がありましたら是非ご覧ください。

コード中に弊塾の名前が含まれている他、URLやIDをハードコーディングしているアホ実装のせいで、実際のURLを示したり、GitHubリポジトリを示したりすることは避けています。ご了承ください。

📚サービスの概要

このサービスは、弊塾のマンツーマンレッスンを利用している生徒さんに、日ごろのレッスンで使ったり参考になったりする教材を閲覧してもらう目的で作りました。ここでの教材とは、どこかのWebページのことを指します。特に、講師がGoogleドキュメント等で教材を作成し、その共有リンク先のWebページ
生徒さん1人ずつにアカウント(ユーザー名とパスワード)を配布し、生徒さんによって閲覧可能な教材をカスタマイズできるようにしています。

構成

本サービスは以下2つのアプリとプログラムから構成されます。

  • フロントエンド Reactで作成されているWebページで、教材の表示等を行います。生徒さんが使用します。ブラウザからアクセスできます。ソースコードはReactで管理し、Netlifyにてデプロイし、弊塾のサブドメインからアクセスできるようにしています。
  • バックエンド Google Apps Scriptとスプレッドシートから構成され、生徒さんの情報や教材の情報の保管、データの送信を行うAPIを構築します。講師が管理します(といっても使いこなせるのは僕しかいなかった)。

提供する情報

入れ子構造になっているので、UMLとか適当に描きながら読み進めていってください。
まず、以下のデータが各生徒さん(アカウント)ごとに割り当てられます。

  • ユーザー名 : string
  • パスワード : string
  • 利用可能なコース : コース[] コースとは、大学でいう1つの「履修科目」と履修者を掛け合わせた概念です。内容が同じでも生徒さんが違うのであれば、別々のコースとなります(生徒さん一人ひとりに沿ったコースを提供する、柔軟性に富んだ塾なのです)。

コースは以下の情報から構成されます。

  • ID : string
  • コース名 : string
  • 説明 : string
  • アイコン : 画像
  • バナー : 画像
  • レッスン一覧 : レッスン[] 

レッスンとは、学校でいう1つの「コマ」に該当する概念です。レッスンは以下の情報から構成されます。

  • レッスン名 : string
  • 日時 : Date
  • 教材ページURL : string

ここで本質情報なのですが、本サービスで扱う教材とはどこかのWebページのことを指します。したがって、本サービスの本質は、教材としてどこかのWebページを<iframe>で表示すること。え?なら生徒さんにははじめからそのWebページのURLをあげればいいじゃん

📚バックエンド

Google Apps Script(以下、GAS)を使って、HTTPで呼び出し可能なAPIを構築しています。提供する情報はスプレッドシートに保存しておき、SpreadsheetAppを使って参照します。以下より、実装した上での工夫点などを挙げていきます。

常に認証しながら動作する仕組みを導入

このバックエンドは、どんな機能を呼び出す際も、ユーザー名とパスワードが正しいかを確認する機能を付けています。具体的には、リクエストボディの形式を以下のように定めています。

  • name : string ユーザー名
  • password : string パスワード
  • type : string 呼び出す機能名
  • payload : string 呼び出す機能名へ与えるパラメータのJSON

呼び出す機能名が何であれ、毎度、ユーザー名とパスワードのを送る必要があります。GAS側では、毎回必ずユーザー名とパスワードを確認し、合致する場合にのみ機能を提供するようにしています(「セッション」という概念を付加すればいいだけの話かもしれませんが、当時の僕にはそんな実装力はありませんでした)。

機能1つに1つのメソッドが対応しており、コードではrunnerと呼ばれています。runnerは、ユーザー名とパスワードが一致している時のみに呼び出され、その返り値が返されるのです。

以下より、呼び出し時に呼ばれるコードを示します。

まず、launcher.gsはGETメソッドやPOSTメソッドを受け取った際のハンドリングを担当します。ボディに十分な情報があるかや、型や内容が適切かを判断します。

launcher.gs
const doGet = e => launch(e);
const doPost = e => launch(e);
/**@param {*[]} values */
const allNotUndefined = (values) => {
  for (let i = 0; i < values.length; i++)if (values[i] == undefined) return false;
  return true;
}
const launch = e => {
  const parameter = e.parameter;
  const { type, name, password, payload } = parameter;
  let resultObj;
  if (!allNotUndefined([type, name, password, payload])) {
    resultObj = launcher.createErrorResult('パラメータが不十分です', parameter);
  } else {
    let runner;
    const TYPE_TO_RUNNER = {//各runnerは別のファイルにて実装されている。
      'account.info': account.info, 
      'coursedocv3.courses': coursedocv3.courses
    };
    if (type in TYPE_TO_RUNNER) {
      runner = TYPE_TO_RUNNER[type]
      let payloadObj;
      try {
        payloadObj = JSON.parse(payload);
        try {
          //機能を実行
          resultObj = account.assertLogined(name, password, payloadObj, runner);
        } catch (e) {
          resultObj = launcher.createErrorResult('機能の実行に失敗しました:' + e, parameter)
        }
      } catch (e) {
        resultObj = launcher.createErrorResult('payloadの解析に失敗しました', parameter);
      }

    } else {
      resultObj = launcher.createErrorResult('不明なtype名です', parameter);
    }
  }
  return ContentService.createTextOutput(JSON.stringify(resultObj));
}

const launcher = {
  createErrorResult: (message, parameter) => ({
    ...account.RESULT_NOT_LOGINED,
    result: { message, parameter }
  })
}

account.gsでは、アカウントの認証を行うと同時に、実際に機能を呼び出します。詳しくはコード中に書かれているコメントを確認してください。

account.gs
const account = {
  info: (payload, accountId, accountInfo) => {
    return { accountInfo }
  },
  /**
   * 指定した名前とパスワードが
   * 正しい時:
   *     * runnerを呼び出す。
   *     * runnerの引数は{payload, accountId, accountInfo}
   *     * runnerの戻り値をresultとすると
   *     * この関数の戻り値は{logined:true, accountId, result}
   * 間違っている時:
   *     * runnerを呼び出さない。
   *     * 戻り値は{logined:false, accountId:undefined, result:undefined}
   * 
   * @template P
   * @template R
   * @param {string} name 使用者の名前
   * @param {string} password 使用者のパスワード
   * @param {P} payload runnerへ渡す引数の1つ
   * @param {function(
   *     payload:P,
   *     accountId:string,
   *     accountInfo:any
   * ):R} runner
   * @return {{
   *     logined:boolean,
   *     accountId:(string|undefined),
   *     result:(R|undefined)
   * }}
   */
  assertLogined: (name, password, payload, runner) => {
    const sheet = SpreadsheetApp.openById('🙈').getSheetByName('main');
    const { found, row, lastRow } = utils.firstIndexOf(name, sheet, 2);
    if (!found) return account.RESULT_NOT_LOGINED;

    const accountRow = sheet.getRange(row, 1, 1, 4).getValues()[0];
    const actualPassword = String(accountRow[2]);
    if (password !== actualPassword) return account.RESULT_NOT_LOGINED;

    const accountId = accountRow[0];
    const accountInfo = JSON.parse(accountRow[3]);
    return {
      logined: true,
      accountId,
      result: runner( accountId, payload, accountInfo )
    }
  },
  RESULT_NOT_LOGINED:{ logined: false, accountId: undefined, result: undefined }
}

このバックエンドのレスポンスは、常に以下の形式を取ります。

  • logined : bool ユーザー名とパスワードが一致しているか。
  • accountId : string|undefined (一致している場合)アカウントのID。
  • result : any|undefined (一致している場合)呼び出した機能の返り値。

account.assertLoginedでは、とあるスプレッドシート(IDを🙈としている)にアクセスして、照合を行っています。そのスプレッドシートは次のようになっています。
AccountRepository.png
D列にはアカウント情報が記載されており、この中に、アカウントに紐づけられたコースの一覧が入っています。
あとちなみに、utils.firstIndexOfはまた別のファイルで定義してるメソッドです。

スプシでコースとレッスンを管理

コースはかなり複雑な構造を持ったデータとなっています。コースは次のようなスプレッドシートで管理されています。
CoursedocV3Sheet.png
B列に我がサービスの本質、教材ページのURLが書いてありますまあこれを生徒さんに送ればいいだけの話なんですけどね。また、シート名がコースのIDとなっています。ここからコース(とその中のレッスン)の情報を抜き出します。具体的には、次のような宣言のメソッドが行います。

/**
* @param {string} courseId 
* @typedef lesson
* @property {string} lessonName
* @property {string} lessonEmbeddingSrc
* @property {{year,month,date,hour,min}} lessonStartsAt
* 
* @return {{courseId,courseName,courseDescription,courseIconSrc,courseBannerSrc,lessons:lesson[]}}
* */
getCourse(courseId) //実装は省略させて頂きます。

あと、このスプシは同時に自分の給料管理としても使ってたので、本サービスには要らない列とかもあります。。

名前空間の導入

バックエンドの実装では、積極的に名前空間を使っていますaccount.assertLoginedとか。GASをやってるとグローバル変数の定義が多くなりがちなので、各メソッドを定数の中に押し込める、いわば名前空間のようなものを作って管理しています。

リファレンスをちゃんと作り、JSDocも書く

こんなサービス、作るのも保守するのも僕だけということは分かり切っておりますが、こういうのはちゃんと作らなければならないと思います。
靴が揃えば心が揃う。入力の型と出力の型さえ厳密に決めて紙にでも書いておけば、実装で迷ったり、原因不明なバグが起きることがほぼ無くなるため、全体的に見ると開発効率がよくなります👞
BackendReference.png
あとは、JSDocも丁寧に書きました。

デプロイはこんな感じ!

「ウェブアプリ」としてデプロイすれば、HTTPSで叩けるAPIのできあがりです。GAS、恐るべし。
BackendDeployment.png

📚フロントエンド

以下のようなモジュールを使用して作成しました。Reduxを使います!

package.jsonの一部
"dependencies": {
    "@material-ui/core": "4.11.3",
    "@material-ui/icons": "4.11.2",
    "@reduxjs/toolkit": "1.5.0",
    "axios": "0.21.1",
    "react": "17.0.1",
    "react-dom": "17.0.1",
    "react-redux": "7.2.2",
    "react-scripts": "4.0.0",
    "redux": "4.0.5",
    "redux-thunk": "2.3.0",
    "redux-toolkit": "1.1.2",
    "uuid": "8.3.2"
},
"devDependencies": {
    "typescript": "4.1.3"
},

Reactアプリの構成

体系的にまとめることを意識して、次のようにディレクトリを構成しました。

* components : ビューを作る。
* presenters : ロジックを記述する。ビューに表示すべき情報を状態から得る。
* modules : 状態を保存・管理するstore/reducerを構築する。
    * app : UIの状態。
    * entry : APIで取ってきた情報等の貯蓄場所。
* consts : 定数。
* utils : API通信を行うメソッドなど。

構造はおおむね次のようになっています。
FrontendStructure.png
今回工夫した点は、presenters層を追加したことです。presenters層は、components層とmodules層の情報の粒度や形式の違いを吸収する役割を持っています。Selectorは必要な状態を取ってきてまとめて、ビューにとって便利な形式に変換して提供します。Operationでは適切なActionを生成します。具体例は次の節で示します。

具体例 : Lesson画面

全てのコードをここで紹介すると大変なので、例としてLesson画面に関するコードだけを載せます。Lesson画面は、<iframe>を使って教材を表示するものです。

modules/app/teachingMaterials.js
import { createAction, createReducer } from "@reduxjs/toolkit";

/**
 * 今開いている教材の情報を保持する。
 */
const initialState = {
  /**@type {string} */
  openingCourseId: String(),
  /**@type {string} */
  openingLessonId: String()
};

export const teachingMaterialsGetter = {
  /**@return {string} 開いているコースのID */
  openingCourseId: (state) => state.app.teachingMaterials.openingCourseId,
  /**@return {string} 開いているレッスンのID */
  openingLessonId: (state) => state.app.teachingMaterials.openingLessonId
};

export const teachingMaterialsAction = {
  setOpeningCourseId: createAction(
    "state.app.teachingMaterials.setOpeningCourseId",
    (openingCourseId) => ({ payload: openingCourseId })
  ),
  setOpeningLessonId: createAction(
    "state.app.teachingMaterials.setOpeningLessonId",
    (openingLessonId) => ({ payload: openingLessonId })
  )
};

export const teachinMaterialsReducer = createReducer(initialState, (builder) =>
  builder
    .addCase(teachingMaterialsAction.setOpeningCourseId, (state, action) => {
      state.openingCourseId = action.payload;
    })
    .addCase(teachingMaterialsAction.setOpeningLessonId, (state, action) => {
      state.openingLessonId = action.payload;
    })
);
modules/entity/teachingMaterials/lessons.js
import { createAction, createReducer } from "@reduxjs/toolkit";

const initialState = {
  /**@type {string[]} */
  lessonIds: [],
  /**@type {Object<string,{lessonId,lessonName,lessonEmbeddingSrc,lessonStartsAt:{year,month,date,hour,min}}>} */
  contents: {}
};

export const lessonsGetter = {
  /**@return {string[]} */
  lessonIds: (state) => state.entity.teachingMaterials.lessons.lessonIds,
  /**@return {Object<string,{lessonId,lessonName,lessonEmbeddingSrc,lessonStartsAt:{year,month,date,hour,min}}>} */
  contents: (state) => state.entity.teachingMaterials.lessons.contents
};

export const lessonsAction = {
  addLesson: createAction(
    "entity/teachingMaterials/lessons/addLesson",
    (
      lessonId,
      lessonName,
      lessonEmbeddingSrc,
      lessonStartsYear,
      lessonStartsMonth,
      lessonStartsDate,
      lessonStartsHour,
      lessonStartsMin
    ) => ({
      payload: {
        lessonId,
        lessonName,
        lessonEmbeddingSrc,
        lessonStartsYear,
        lessonStartsMonth,
        lessonStartsDate,
        lessonStartsHour,
        lessonStartsMin
      }
    })
  ),
  clearLessons: createAction("entity/teachingMaterials/lessons/clearLessons")
};

export const lessonsReducer = createReducer(initialState, (builder) =>
  builder
    .addCase(lessonsAction.addLesson, (state, action) => {
      const {
        lessonId,
        lessonName,
        lessonEmbeddingSrc,
        lessonStartsYear,
        lessonStartsMonth,
        lessonStartsDate,
        lessonStartsHour,
        lessonStartsMin
      } = action.payload;
      state.lessonIds.push(lessonId);
      state.contents[lessonId] = {
        lessonId,
        lessonName,
        lessonEmbeddingSrc,
        lessonStartsAt: {
          year: lessonStartsYear,
          month: lessonStartsMonth,
          date: lessonStartsDate,
          hour: lessonStartsHour,
          min: lessonStartsMin
        }
      };
    })
    .addCase(lessonsAction.clearLessons, (state, action) => {
      (state.lessonIds = []), (state.contents = {});
    })
);
presenters/Lesson.js
import SceneName from "../consts/SceneName";
import { sceneAction } from "../modules/app/scene";
import { teachingMaterialsGetter } from "../modules/app/teachingMaterials";
import { lessonsGetter } from "../modules/entity/teachingMaterials/lessons";
export const LessonSelector = {
  getOpeningLesson: (state) => {
    const lessonId = teachingMaterialsGetter.openingLessonId(state);
    const content = lessonsGetter.contents(state)[lessonId];
    return {
      embeddingUrl: content.lessonEmbeddingSrc,
      name: content.lessonName
    };
  }
};
export const LessonOperation = {
  backToCourse: () => sceneAction.setNowScene(SceneName.Course)
};
components/Lesson.js
import {
  AppBar,
  Box,
  IconButton,
  makeStyles,
  Toolbar,
  Typography
} from "@material-ui/core";
import { useDispatch, useSelector } from "react-redux";
import { LessonOperation, LessonSelector } from "../presenters/Lesson";
import ArrowBackIcon from "@material-ui/icons/ArrowBack";

const useStyles = makeStyles((theme) => ({
  offset: theme.mixins.toolbar
}));

export default () => {
  const lesson = useSelector(LessonSelector.getOpeningLesson);
  const dispatch = useDispatch();
  const classes = useStyles();
  return (
    <Box style={{ height: "99vh" }}>
      <AppBar style={{ position: "fixed", height: "8%" }}>
        <Toolbar>
          <IconButton
            edge="start"
            color="inherit"
            onClick={() => dispatch(LessonOperation.backToCourse())}
          >
            <ArrowBackIcon />
          </IconButton>
          <Typography variant="h5">{lesson.name}</Typography>
        </Toolbar>
      </AppBar>
      <div className={classes.offset} />
      <iframe
        height="92%"
        src={lesson.embeddingUrl}
        style={{ width: "100%", border: "none" }}
      />
    </Box>
  );
};

この例でのLessonSelector.getOpeningLessonは、現在開いているレッスンのIDと、レッスンの内容情報をそれぞれ取ってきて、教材のURLとレッスン名を別々に返します。

Netlify設定

フロントエンドアプリのコードはGitHubで管理しています。NetlifyにこのGitHubリポジトリを追加して、リポジトリに変化があるたびに再ビルドとデプロイを行うようにします。

以下は、Build & deploy > Continuous deployment > Build settingsの設定です。

Build command
CI='' npm run build
Publish directory
build

ドメイン設定

弊塾のHPのドメインはXserverというドメイン購買・管理サービスで買いました。
DNSレコードの設定をして、ドメインは弊塾のHPへ繋いでいます。Xserverではサブドメインを作ることもできます。専用のサブドメインを作って、それをNetlifyへ繋げるようにしました。

Xserver・DNSレコードの設定
種別 : CNAME
内容 : 🙈.netlify.app
TTL : 3600

ただし、🙈はNetlifyのSite configuration > General > Site details > Site information > Site nameの文字列です。

📚そして失敗...

サーバーエラーやバグなどといった動作上の問題は全くありませんでした。抜群の安定感!俺の技術力万歳🙌🙌🙌

しかし、このサービスには大きな問題がありました。それは、過剰に複雑であり、扱いが非常に面倒くさいということです。確かに、本サービスは、GAS、React、Redux、Netlify、GitHubなどといったギークな技術を使って構築されていました。しかし、教材データを管理するのもひと苦労ですし、ほとんどの人にとって何もいいことがない。だから、全く普及せずにオワコンしましたorz

他の講師なんかは、教材を紙に印刷したり、メールで送信したり、買ってもらったりしています。いや、それで必要十分すぎる。完璧。まさに本質。わざわざ僕のサービスの流儀に従い、利用する大義はございません。

もうちょい弊塾が大規模化したり、教材を体系的にまとめる理由があったり、あとは教師側ももっと簡単に教材を追加できる仕組みがない限り、こんなに大規模なシステムを作る必要はなかったのかもしれません。

「自動車学校のキャンセル待ちが全然回ってこなくて暇だから。」「Reactという字面がかっこいいから。」「GASもかっちょいいから。」「COVID-19による自粛要請で暇だから。」そんな不純な理由で安易にこういうのに手を出すのであれば、少し立ち止まって考えた方がいい。

📚さいごに

昔の自分はずいぶんと変なものをバカ真面目に作っていたんだな・・・と感心してしまいました。本当にCOVID-19で暇だったんで、今では考えられないようなアホなことをしていたんです。同じもの(教材を共有できる仕組み)を準備せよと今言われたら、もっと簡単な方法でやると思います。

まあ、React+Reduxの勉強ができたしこれはこれでよかったです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0