🏩

レジャーホテルの掲載&口コミアプリを作りました【Rails Next.js ECS Fargate Github Actions】

2023/04/09に公開

はじめに

皆さんは「レジャーホテル」と「ラブホテル」の違いが何かご存知でしょうか?
実は皆さんが利用したことのあるラブホテルはレジャーホテルかもしれません。
ネットで違いについて調べると、ラブホテルは「風俗営業法上の届け出が必要で、警察が管轄になる。パネルや精算機、休憩などがある」と出てきます。一方、レジャーホテルは「旅館業法という法律が適用され、フロントがある。女子会などで使うことができる」とされています。

これらの認識でも間違いはありません。しかし、実際には「精算機、休憩、パネル」があるのにも関わらず、レジャーホテルとして経営しているところがあります。ウィキペディアによると、

日本では警察庁が把握している全国のラブホテルの軒数は約7000軒であるが、実際にはその5倍に当たる35,000軒が存在していると推定されている。これらは営業に必要な風俗営業法の許可を受けずに一般の旅館として申請されている[2]。

ホテル経営者は、ラブホテルとして警察に申請するよりも、保健所にレジャーホテルとして申請することを優先します。一つの理由としては、警察が管轄になると何か問題が起きた場合に捕まってしまう可能性があるためです。
経営者は、休憩を「デイユース」という形で提供したり、旅館業法で必須な宿泊名簿を部屋に置いて、その後はお客様の判断に任せたりします。
それらのことを保健所と交渉してなるべくレジャーホテルとして経営するように努めています。
一見、パネルと精算機と休憩があって所謂「ラブホ」なのに、レジャーホテルとして保健所から許可がおりているという所は多いです。(ちなみにレジャーホテルの場合、お子様を連れて宿泊をすることができます)

以上のように、一般的にはラブホテルとして知られる施設でも、実際にはレジャーホテルとして登録されている場合があることから、ここで言及する「レジャーホテル」とは、一般的にラブホテルと呼ばれるタイプの宿泊施設と考えてもらっても相違は無いと思います。

サービス概要

現在サービスを停止しております
https://hoteler.jp/

私は4年間レジャーホテル2店舗でフロント業務と客室清掃をしてきました。
常日頃、お客様からのお申しつけやご要望を受ける立場でした。
そこで、レジャーホテルの問題点などを当アプリに落とし込みました。

来店されたお客様からのお申し付けで最も多い、「なんでこんなに高いんですか?」に対して

  • 今日の曜日の、今の時間で、最も安い料金を抽出しトップページに表示する機能を作りました。
  • レジャーホテルは曜日と時間ごとに料金が異なります。そのため休憩90分3980円と思っていたら、今日は祝日で5280円だったということがあります。

電話でのお問い合わせで最も多い「今空いていますか?」に対応するために、空室状況を確認できるようにしました。

  • 都会のホテルで土曜の夜だと、ほとんどが満室近くになります。そのため、いくつもホテルを歩き回って探すことになる場合があります。
  • ホテル運営者はワンクリックで満室と空室を切り替えられます。

曜日と時間と時期によって料金が変動するレジャーホテルでは、10個以上の休憩料金があることも珍しくありません。なので、

  • ホテル運営者はテーブル形式で料金を管理できるようにしました。

特別期間の設定もできるようにしました。

  • レジャーホテルでは平日の宿泊料金が5,000円前後なのに対して、土曜日の宿泊料金は16,000円前後の値段がします。 土曜日の料金が高いと分かっていれば、何ら問題は無いのですが、平日なのに土曜日料金になる期間があります。 それが特別期間です。

  • 「GW、お盆、年末年始」 これらの期間は土曜日料金になります。そのため、平日に来店したとおもっていたら土曜日料金を請求されてしまう。というお客様が多々いらっしゃいます。そこで、特別期間の設定をすることで、その期間は、特別期間の料金を表示することができます。

レスポンシブにも対応しています。

  • スマートフォンやタブレットからも利用できます

休憩料金、宿泊料金、口コミ数、お気に入り数による並び替え機能や、設備とアメニティによる絞り込み機能です。

ホテル運営者がホテルの料金やコンテンツを編集した際にはお気に入り登録をしているユーザーに通知を送信できます。

  • レジャーホテルは既存のホテルを改装することが多いので、度々、設備が変わります(ソファ、壁紙、枕、アメニティ等)。
  • 近年の物価上昇によりホテル運営のコストも上がり、値上がりもしています。
  • そこでホテル運営者が何か更新する際にはお気に入りを登録しているユーザーに通知メッセージを送られるようにしました。

口コミが投稿された場合、ホテル運営者は通知を受け取れます。

ソースコード

フロントエンド
https://github.com/na0kiA/hoteler-client
バックエンド
https://github.com/na0kiA/hoteler-server

インフラ構成図

ER図

使用技術

バックエンド
Ruby 3.1.2
Rails 7.0.4.2
Rspec, Rubocop
フロントエンド
React 18.2.0
Next.js(SSR) 13.1.1
TypeScript
Jest, React Testing Librar
prettier, eslint
TailwindCSS, daisyUI
インフラ
Docker / Docker-Compose
AWS(ECS, Fargate, ECR, Amplify, CodeDeploy, ALB, SSM, ParameterStore, RDS, CloudWatch, S3, Route53, VPC)
IaC(Terraform)
Github Actions(ECR, ECS, CodeDeploy, Rubocop, Rspec, Slack)

機能一覧

ホテル

  • ホテルの掲載、編集、削除
  • テーブル形式で料金表のCRUD
  • テーブル形式で特別期間のCRUD
  • 設定した特別期間中は特別期間の料金を表示
  • 画像の投稿、編集、削除(S3 の署名付き URL を用いた、クライアントサイドからの画像アップロード)
  • お気に入りの登録と解除
  • 現在の曜日と時間に基づいて、ホテルに登録されてある最安の料金を表示
  • お気に入り登録をしているユーザーに向けて更新メッセージの通知
  • 口コミが書かれた場合は通知を受け取る

レビュー

  • CRUD
  • 星5つ機能
  • 参考になったの登録と解除

ユーザー

  • ログイン、ログアウト、新規登録、退会、パスワード再設定
  • プロフィール編集
  • プロフィール画像の投稿、編集(S3 の署名付き URL を用いた、クライアントサイドからの画像アップロード)
  • お気に入り一覧
  • 通知
  • キーワード検索
  • ホテルのアメニティ・設備で絞り込み
  • 休憩料金、宿泊料金、レビュー数、お気に入り数によるホテルの並び替え
  • お気に入りホテルが更新された場合は通知を受け取る

その他

  • レスポンシブ対応
  • useSWRInfiniteを用いたトップページの無限スクロール機能
  • ReactHookFormを用いたフロントエンドのバリデーション

工夫したところ

バックエンド

  • Bullet gemを導入して、RSpecの設定に取り込むことによって、テスト実行時もN + 1が発生した際にはテストが落ちるようにしました。
1) V1::Hotels GET /v1/hotels - v1/hotels #index ホテルが承認されている場合 ホテルを複数個取得できること
     Failure/Error: get v1_hotels_path
     
     Bullet::Notification::UnoptimizedQueryError:
       user: root
       GET /v1/hotels
       USE eager loading detected
         Hotel => [:hotel_images]
         Add to your query: .includes([:hotel_images])
       Call stack
         /app/app/serializers/hotel_index_serializer.rb:15:in `hotel_images'
         /app/app/controllers/v1/hotels_controller.rb:15:in `index'
         /app/spec/requests/v1/hotels_spec.rb:200:in `block (4 levels) in <top (required)>'

また、N + 1 問題に対しては、includesを使わずに、preloadとeager_loadを使い分けるようにしました。理由としては、includesの場合はRailsがよしなにpreloadとeager_loadを振り分けるため、制御しずらく、意図せぬ動作をしてしまうことがあるからです。
そのため、左外部結合をして一つのクエリにまとめるのか(eager_load)、クエリを2つ吐いてIN句でまとめてしまうのか(preload)、絞り込みをする必要があるからeager_loadを使うのか、主テーブルのレコード数が多くてIN句が大きくなりすぎるからpreloadは使わないのか、考えて使うようにしました。

  • 署名付き URL を用いてクライアントサイドから直接 S3 にアップロードすることによって、 Rails の gem に依存することなく、直接 S3 へ画像のアップロードができるようにしました。

  • カスタムバリデーションを作り、連続した文字列や、特定のワードを検出してバリデーションにかけられるようにしました。

  def validate_each(record, attribute, value)
    return if value.blank?

    record.errors.add(attribute, :contain_invalid_regex) if validates_url_format(value)

    record.errors.add(attribute, :contain_same_words) if validates_same_words(value)

    record.errors.add(attribute, :contain_blacklist_words) if validates_blacklist_words(value)
  end

テスト

  • 失敗するテストを書いて、そのテストに通るようなコードを書いて、リファクタをして、ER図やモデリングをみて修正をして....を繰り返しました。
  • コードを書いてから動作に不安のあるところのテストを書いてっといった探索的テストもやりました。
  • 全体を通してのテストのカバレッジは99%を維持しました。そのため、リファクタリングや変更を安心して行えるようになりました。

サンプルデータ

フロントエンド

  • 複数のリクエストを行う際は Promise.all を用いて、非同期に並列実行できるようにしました。
  const [reviewShowResult, helpfulnessResult] = await Promise.all([
    getReviewShow(id),
    getHelpfulness(id, accessToken, clientToken, uid),
  ]);
  • Next.js の SSR を用いることで、ユーザーの実行環境に依存せず、SEO にも強くなりました。

  • 二重クリックによる誤送信を、useRef を用いてフラグをたてて、防止しました。

  const buttonRef = useRef(false);

  const handleDeleteReviews = async (
    event: React.MouseEvent<HTMLButtonElement, MouseEvent>
  ) => {
    if (buttonRef.current) return;
    buttonRef.current = true;
    event.preventDefault();

    try {
      const res = await deleteReview(id);
      if (res.status === 200) {
	//....
	
    finally {
      buttonRef.current = false;
    }
  };
  • TypeScript を Strict モードで使うことで、コンパイルエラーで事前に誤ったコーディングを修正しました。
  • useFieldArray を用いてテーブル形式でホテルの料金と特別期間の設定を CRUD できるようにしました。
{fields.map((field, index) => (
  <tbody key={field.id}>
    <tr key={field.id}>
      <th>{index + 1}</th>
      <td>
        <div>
          <select
            {...register(`rates.${index}.day`)}
            className="select select-bordered select-sm max-w-xs"
          >
//....
  • フォーム入力は ReactHookForm を用いてフロントエンドからもバリデーションをかけました。
  const { register, handleSubmit, control } = useForm({
    defaultValues: {
      email: "",
      password: "",
    },
  });

  const { dirtyFields } = useFormState({
    control,
  });
  
  //...
  
<input
  type="email"
  {...register("email", {
     required: "必須項目です",
  })}
  className="input input-bordered"
/>

インフラ

  • GithubActions で Blue/Green CodeDeploy を行うことで、ダウンタイムなしで開発したコードを push して本番環境に反映させることができるようにしました。
      - name: Download task definition
        run: |
          aws ecs describe-task-definition --task-definition $SERVICE_NAME --query taskDefinition > task-definition.json

      - name: Deploy to Amazon ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: task-definition.json
          service: ${{ env.SERVICE_NAME }}
          cluster: ${{ env.SERVICE_NAME }}
          codedeploy-appspec: appspec.yml
          codedeploy-application: hoteler-codedeploy-app
          codedeploy-deployment-group: hoteler-codedeploy-dg
  • Github Actions で Ruboocp, Rspec, ECR push を行う過程で、失敗 / 成功すると Slack に通知が行くようにしています。

  • IaC(Terraform)を用いることでインフラをコード化して保守運用をしやすくしました。また、チーム開発で行なっているように、tfstate は S3 で管理しています。

  • ECS Fargate は AutoScale にして、CloudWatchAlart で CPU 使用率が80%を超えた際にタスク数を増やしてロードバランサーで振り分けるようにしました。

所感

私は独学でWebアプリの開発を学びましたが、前提知識の欠如に苦労しました。ドキュメントを見ても、その内容はあくまである事物についての説明書であり、実務未経験の私にとっては理解に欠ける箇所が多々ありました。初心者向けの記事は共感できるものの、誤った知識が身につくリスクもあります。

そこで、概念や語句をツイッターでアウトプットしながら、実際にコードを書くことで、少しずつ理解を深めていきました。また、コンピュータサイエンスの学習にはfreeCodeCampがとても役立ちました。無料で、しかも日本語で配信されており、配列やオブジェクト、クラスに対するコーディングを学ぶことができました。
https://www.freecodecamp.org/japanese/

Webアプリの開発においては、「エラー」や「エラーが出ないが実装できない」という問題に直面することが多々ありましたが、ほとんどは「モジュールやフレームワークに対する理解不足」や「自分で誤って書いたコードによって起きたミス」が原因でした。

これほど物事に没頭して取り組んだのは中学生の頃にやっていたCSO以来です。笑
その頃になりたかったエンジニアを目指したいと思います!
最後まで閲覧いただき、ありがとうございます!

Discussion