👨‍👩‍👦‍👦

課題ドリブンな個人開発で「Members Hub」というWebアプリを作った

2024/09/17に公開

はじめに

この度、Members Hub というWebアプリを開発しました。

サークルや部活などの「チーム」を対象として、メンバーのプロフィールを検索して閲覧できるプラットフォームを提供するためのサービスとなっています。

開発の記録として、この記事を残すことにします。

linkedin_banner_image_1.png


サービスURL

https://members-hub.onrender.com/

30人のBotを登録した「サンプルチーム」に参加することでMembers Hubを体験していただけます!

SNSまたはメールでアカウントを作成してログインしていただいた後、
「チームに参加する」⇨ チームIDに9b6ca177-5ddc-4655-b876-9222513b9ef7、パスワードにsampleteamを入力して、チームに参加してみてください。プロフィール項目の設定は適当で大丈夫です!
Image from Gyazo

登録不要でサンプルユーザーとしてもログインいただけます!

メールアドレスにsample@sample.com、パスワードにpasswordを入力してログインしてみてください!
すでにサンプルチームに参加させているので、同じく、サンプルチームを閲覧することができます。
Image from Gyazo

GitHub

https://github.com/YutaKakiki/members_hub

目次

1. 開発した背景
2. 機能一覧
3. アーキテクチャ
4. 使用技術
5. 開発上の工夫点
6. 反省点
7. 展望
8. おわりに

1. 開発した背景

所属していたサークルの「自己紹介カード」というシステム

自分の所属していたサークルでは、毎年5月あたり(新入生がサークルに入会するタイミング)で「自己紹介カード」というシステムがありました(システムといえばおかしいかな?)。
「自己紹介カード」には以下のようなフローがありました。

  1. 総務班長(役職)が、Wordファイルのフォーマットを用意・配布
  2. サークルのメンバーがそれぞれ書き込んで総務班長に提出(LINEで)
  3. 総務班長が集計し、パート(ダンスのサークルだったのでパートで別れていました)や代に分けて、ファイルを繋ぎ合わせたものを画像orPDF化
  4. OneCloudにアップロード

このフローの中には以下のような非効率的な問題点があると感じていました。

❶総務班長の負担
❷集計にかかる時間
❸(メンバーから見て)閲覧時の利便性

❶に関して、フォーマットを代々引き継いでいるものかとは思いますが、フォーマットを新しくしたくなった場合などは作り直したりすることを考えると面倒です。それに、Wordファイルで作られたフォーマットはとても書きにくいと感じていました。入力が長ければフォーマットが崩れたりするし、間違えてフォーマットを破壊する可能性もありました。

❷に関して、サークルのメンバーたちはこの「自己紹介カード」の開示を結構楽しみにしていました笑。しかし、総務班長はフローにあげたように面倒なことをしなければならず、人によって自己紹介カードを提出するタイミングもまちまちです。全員の分が揃わないと公開できなかったので、時間がかかったのです。

❸に関して、これを感じていたのは自分だけかもしれませんが、「検索できたらな」と感じることがありました。プロフィールを流し読みするならこのままでもいいかもしれません。しかし、例えば、1回生がサークルに入ってきたとして「同じパートで〇〇学部」や「同じ出身地」の友達いるかな..?って気持ちで探していたとします。そうすると、すべてのカード(多ければ150人分)をくまなく探す必要があったのです。そこで、「検索したらでてくる」といいなぁと感じていました。

❶❷❸の課題をすべて解決するのがMembers Hub

上の課題を解決すべく、機能の構想から始め、開発に着手しました。次のセクションで機能について述べていくので、機能を紹介した後で、またこれらの課題に触れ、いかなる形で解決しているのかをまとめます。

2. 機能一覧

ホームページをご覧いただいても良いのですが、Members Hubを使う上で踏むべきステップがあるので、ステップごとに紹介していきます。
はじめに、早速ステップからは外れますが(笑)ログイン機能などの認証周りから述べていきます。

※以下は、PC画面となっています

新規登録/ログイン

Image from Gyazo
Image from Gyazo

メールアドレスによる登録はもちろん、Google/LINEによログインが可能です。

チームを作成する

Image from Gyazo
サイドバーの「チームを作成」から、「チーム名」「パスワード」「ロゴ画像」を指定して、次へを押します。

チームのプロフィール項目を設定する

a6b826182b732063e72c223648861f11.gif

上のステップで「次へ」を押すと、このページに遷移します。デフォルトでは名前、生年月日、プロフィール画像が項目として設定されており、削除することはできません。
チームごとに追加で7つ設定でき、設定中に項目名の編集・削除もできます。
なお、この項目は後からでも編集可能です。
項目の登録後、チームの作成が完了し、作成したユーザーが「チームの管理者」となります。

チームにメンバーを招待する

管理者権限で、招待リンクを生成することができます。有効期限は24時間以内です。期限が切れた場合はまた生成することができます。このリンクはワンクリックでコピーできます。
Image from Gyazo
以下のような文章がコピーされ、LINEなどに管理者が共有する想定です。

以下のリンクから チーム七福神 に参加しましょう!

👉 http://localhost:3000/teams/invitation?team_id=dc08daa5-f2dd-4582-b9a3->3>8da8229c8f1&token=CeQ-b93xWVYpH753G0470g
チームID・パスワードによる認証なしで簡単に参加することができます。
Members Hub に登録/ログインしていない場合は、登録/ログイン後にチームへの認証が完了します。
⚠️この招待リンクの有効期限は2024/09/17 13:20です。期限を過ぎたリンクは無効となりますのでご注意ください。

ユーザーがチームに参加する

ユーザーがチームに参加する方法は2通りあります。

①チームID・パスワードを入力する

チームには、自動でIDが付与されています。

Image from Gyazo
これを、「チームに参加」画面にてチームIDとして入力し、管理者によってチーム作成時に設定されたチームパスワードを入力することでチームへの認証に成功し、プロフィールの登録画面に遷移します。

②管理者が発行した招待リンクを踏む

リンクを踏んだユーザーは、ログインしていれば即座に認証が成功し、ログイン/新規登録をしていない場合はその後に自動的に認証に成功するようになっています。

今回は、上のステップで生成した招待リンクをそのままアドレスバーに打ち込んでいます。

30638fb855915d207e68de1184b451d7.gif

プロフィールを登録する

チームの認証に成功すると、プロフィール登録画面に遷移するので、すべての項目に記入の上、「登録」ボタンを押すと、チームへの参加が完了します。
dbcdef4265225206f230ab5848dd51d2.gif
なお、プロフィールを登録せずに別のページに遷移した場合(ユーザーが別のページへ移った場合)は、チームへの参加がキャンセルされます。

チームメンバーを閲覧する/検索する

「メンバーを閲覧」をクリックすると、参加しているチーム名が出てくるので、閲覧したい方をクリックします。
先ほど作成した「チーム七福神」も見えますね。ただ、今回はBotを登録している「サンプルチーム」の方を見ていきます。
cde336971d898ac7fb699627ef916bba.gif
プロフィール項目を選んで、キーワードを入力すると、合致したメンバーがでてくるようになっています。
項目を増やしてAND検索で条件を絞ることも可能ですし、キーワードに「、」を含めることでOR検索も行われるようになっています。 AND検索とOR検索は、幾つでも指定可能です。

8624ea874fad1394a8e11fdac4a0f252.gif


その他

チームのプロフィール項目の更新 / メンバー自身のプロフィールの更新

プロフィール項目を削除/項目名の編集が可能です。ただし、既にメンバーが登録している状態で項目の削除を行うと、メンバーの項目名に対応するプロフィールのデータも削除されます。
既にメンバーが登録している状態で新たにプロフィール項目を追加した際は、各メンバーの「プロフィールを編集」ページに新たに記入フォームが追加されるようになっています。

fc13ba5fc60b618576b665cefb60affa.gif

管理者権限の譲渡

管理者は、チーム情報の更新/チーのプロフィール項目の編集/招待リンクの発行/チームの削除が可能です。管理者は、管理権限を別のメンバーに譲渡することができます。
権限を譲渡した後は、「管理中のチーム」からサンプルチームが消えていることがわかります。

5a55b25eaa2c122dbf8ceabb9cfbe105.gif


Members Hubによる課題へのアプローチ

前セクションで述べた❶❷❸の課題がサービスによって解決できている(はず)ことが伝わったでしょうか。

❶の総務班長の負担に関しては、「チームを作成→項目を登録→招待リンクを共有」の3ステップで済むようになりました。1つ1つのステップも簡単に行えます。フォーマットも用意する必要がなく、(凝ったデザインではありませんが)Members Hubが整ったフォーマットでうまく表示してくれます。

❷の集計時間に関しては、そもそも集計する必要がなくなりました。プロフィールを登録して参加した時点で、すでに参加しているメンバーのプロフィールを閲覧できるようになりました。

❸の閲覧の利便性ですが、こちらも「検索フォーム」ができたことによって簡単にメンバーを探し出せるようになりました。

初めは、自身のサークル専用に作る予定でしたが、プロフィール項目を自由に設定できるようにしたことで、どのチームでも使える設計にしました。前回は「あったら面白いな」の理想ドリブンでしたが、課題ドリブンな開発はなんとなくやりがいを感じていました。

とは言いつつ、今のところ実用化を考えていません...(運用してFBを貰ってこその開発だろ!!なんですけど、他にもやりたいことがたくさんありまして...)。でも今後、このサービスが最も必要とされそうな4〜5月あたりにリリースするはのいいかもしれないですね。

3. アーキテクチャ

アーキテクチャ図

以下のように表せるかと思います。
バックエンド+フロントエンドはRailsですべて書いています。もっと詳しく言えば、フロントエンドはERBがメインで、JSはほとんど書かず、Hotwire(Turbo,Stimulus)が動いています。

developper (2).png

ER図

5D8D1C6F-665B-402C-856A-27BBDFABAD7F_1_201_a.jpeg

4. 使用技術

カテゴリー 使用技術
バックエンド Ruby 3.3.4
Rails 7.1.3.4
データベース PostgreSQL
クラウドストレージ Amazon S3
認証 Devise
OmniAuth(Google,LINE)
フロントエンド ERB
TailwindCSS
Hotwire
tailwindcss-stimulus-components
JavaScript
FontAwesome
デプロイ Render
開発環境 Docker
Docker Compose
テスティングフレームワーク Rspec 6.1.0
静的コード解析 Rubocop

ホスティングサービスは、fly.ioを考えていたのですが、何度も失敗してしまったので、諦めて簡単なRenderにしました。Webサービス、DBともにRenderにあげています。

導入してよかった初めての技術は、後のセクションにて改めて取り上げます。

5. 開発上の工夫点

まずはじめに、この開発にあたって初めて導入した技術と感想を述べます。

OAuth

今回は、ログイン/新規登録にGoogle認証とLINEログインを導入しました。前回開発したサービスでは、メールによる認証のみを実装していたのですが、メールアドレス・パスワードを入力して...という流れがユーザーにとってはネックとなる面倒な部分であると実感しました。そこで、ユーザーが簡単に認証できるOAuthを取り入れました。

認証系のライブラリとしてDeviseを使用しており、その中にはモジュールとしてOmniAuthも組み込まれており、SNSアカウントを用いた認証を実装できるようになっています。今回は、Google Oauth2と、LINEによるログインを実装しました。

複数のプロバイダを実装するにあたって、他の方のを参考にしつつ、ロジックを工夫しました。ここで書いてしまうと記事が膨らみ過ぎてしまうと思うので、また別の記事にしようと思いますが、コードだけ載せておきます。

OAuth実装コードを開く

条件分岐がネストして可読性が低いです、要リファクタリング。

users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  protect_from_forgery
  skip_before_action :authenticate_user!

  def google_oauth2
    callback_for(:google)
  end

  def line
    callback_for(:line)
  end

  private

    def callback_for(provider)
      auth = request.env['omniauth.auth']
      @user = AuthProvider.from_omniauth(auth)
      if @user.persisted?
        # チーム招待リンクをリクエストした後のOAuthならば、チーム認証の挙動にリダイレクト
        if (invitation_url = session[:invitation_url])
          sign_in @user
          session[:invitation_url] = nil
          redirect_to invitation_url
        else
          sign_in_and_redirect @user, event: :authentication
          set_flash_message(:notice, :success, kind: provider.to_s.capitalize) if is_navigational_format?
        end
      else
        flash[:alert] = I18n.t('omniauth_callbacks.failure')
        render template: 'devise/registrations/new'
      end
    end
end
auth_provider.rb
class AuthProvider < ApplicationRecord
  belongs_to :user

  def self.from_omniauth(auth)
    uid = auth['uid']
    provider = auth['provider']
    # lineがemailを参照できない場合を考慮
    email = auth['info']['email'] || ""
    auth_provider = AuthProvider.find_by(uid:, provider:)

    # providerが既にあって、ユーザーが登録されていない時(あんまりないと思うけど)
    if auth_provider.present?
      user = User.find_by(id: auth_provider.user_id)
      # ユーザーが見つかったらuserに格納し、なければ作成・保存
      user ||= create_user_via_provider(auth)
    else # providerを持っていないとき
      user = User.find_by(email:)
      # 既に新規登録(普通の)がしてあったら、provider情報のみ保存する
      if user.present?
        # provider情報を保存
        AuthProvider.create({
                              uid:,
                              provider:,
                              user_id: user.id
                            })
      else # 全くの新規ユーザーであれば、ユーザー&プロバイダの両方を保存する
        user = create_user_and_provider(auth)
      end
    end

    user
  end

  def self.create_user_via_provider(auth)
    user = User.create!({
      name: auth['info']['name'],
      # lineからemailが返ってこないので適当な値を設定
      email: auth['info']['email'] ||  "#{Devise.friendly_token(10)}@members-hub.com",
      password: Devise.friendly_token(10),
      confirmed_at: Time.zone.now
    })
  end

  def self.create_user_and_provider(auth)
    user = create_user_via_provider(auth)
    AuthProvider.create({
                          uid: auth['uid'],
                          provider: auth['provider'],
                          user_id: user.id
                        })
    user
  end
end
参考にした資料
  • OAuthの仕組みについてわかりやすく説明されています

https://qiita.com/TakahikoKawasaki/items/e37caf50776e00e733be

  • 各プロバイダからどんなパラメータが返ってくるかがまとめられていました

https://github.com/omniauth/omniauth/wiki/List-of-Strategies

  • モックを用いたテストを書く際に参考にしました

https://qiita.com/yuki_0920/items/003d8b1c73352378188d

https://qiita.com/katryo/items/36f444a70927a579e3cb


Hotwire

フロントエンドには、Hotwireを導入しました。今回の開発は、Hotwireを用いて開発することも大きな目的としてありました。
正確には「Hotwire」という技術があるのではなく、Hotwireは「Turbo」「Stimulus」「Strada」という3つのライブラリの総称です。今回は、TurboとStimulusを使用しました。
自分がとやかく説明するよりも、以下の記事をご覧になられた方がいいと思うので詳しくは説明しません笑。

https://zenn.dev/shita1112/books/cat-hotwire-turbo

簡単に言えば、フロントエンドをJSをほとんど書かずに実装できるようになります。Reactなどはクライアントサイド側で動的に値を書き換えたりすると思います。Turboを使えば、部分的に更新したいHTMLのみを差し替えることによって、簡単にSPA風な実装をすることができます。
また、モーダルやトグルメニュー、ドロップダウンなどはJSが必要になりますが、Stimulusを使えば簡単に記述することができます。今回はさらに簡単にそれらを実装するために、tailwind-stimulus-componentsというライブラリを使用しました。あらかじめコントローラ/ある程度のシンプルなデザインを実装してくれているので、ドキュメント通りに従えば超簡単にモーダルなどをつけることができます。自分の場合は、必要に応じてオーバーライドしたりしてカスタマイズして使いました。

https://github.com/excid3/tailwindcss-stimulus-components

ユーザビリティを考えると、必要以上なページ遷移は避けたい...となると、トグルメニューやモーダルによるコンテンツの表示は必要不可欠になってくるのかなと思います。
前回の開発では、フロントエンドにNext.jsを採用しました。それも改めてまたキャッチアップしなおしたいと思いますが、SRRやCSR、RSCなどの使い分けが難しかったり(理解不足)、JSONによるやり取りが若干ストレスでした。しかし、Hotwireを使えばほとんどのことは賄えますし、「前回Nextで書いた諸々はほとんどHotwireでラクにかけたくないか??」と思ってしまいました。学習コストも低く、サーバーサイドの実装のみに集中できるHotwireは、今回の開発体験を大幅に向上させてくれました。

どうやら、日本の大きな企業ではHotwireの採用が少なそうです。大抵はRailsもAPIモードでクライアントサイドはReact/Vueを使うケースがほとんどと思います。しかし、個人開発の範疇であれば、クライアント/サーバーで分割するよりも1つのフレームワーク内で完結させた方がラクじゃないかなぁと言うのが前回・今回の開発を踏まえた率直な感想です。
DHH氏も述べている通り、Railsには"The One Person Framework"という思想があるっぽいです。

https://world.hey.com/dhh/the-one-person-framework-711e6318

結構使っていて感動したので量が多くなりましたが、Railsを用いて開発する場合は、Hotwireを採用してみてはいかがでしょうか?(Rails特有のライブラリではありませんが、Rails7からはデフォルトとなっていて使いやすいかと思います)


以下、実装上で工夫した点です。

自由にプロフィール項目を実装するため設計

このサービスの大きな強みは、「プロフィール項目を自由に設定できること」でもあります。正直、こちらで項目をあらかじめ指定しておくのなら、この辺りの実装に苦労することはありませんでした。しかし、チームによって指定したい項目は様々であると考えました。実装上、苦労した点ごとに述べていきます。

  • テーブル設計どうしよう?
    テーブル設計は、ER図の通りですが、項目名(profile_fields)テーブルと値(profile_values)テーブルに分けることにしました。
    モデリング設計も併せて述べると、
     ・Teamは複数のProfileFieldを持っている
     ・Memberは、複数のProfileValueを持っており、ProfileValueは一つのProfilFieldと結び付けられる
    といった感じになっています。


  • プロフィールを入力して、どうやって項目名と結びつけるように登録するんだ?
    この辺はとても悩んだ部分です。
    プロフィール登録画面では、Teamが持っている複数のProfileFieldを表示する形でフォームとラベルを表示するようにしています。
    実際にProfileValueに値を保存するに際しては、フォームから送信されたparamsにある複数の値(content)と、profile_field_idのペア配列を生成するサービスクラスを実装し、正しく対応づけて保存されるような実装を行いました(わかりにくくてすみません)。参考程度に、プロフィールを登録する際のコントローラにおけるcreateアクションの実装を載せておきます。
createアクションのコードを見る👀
users/members/profile_values_controller.rb

  # HACKME: まだまだロジックをProfileValueモデルに切り出せる
  def create
    team = Team.find_by(uuid: params['team_id'])
    # MembersController#createでsessionに格納した情報
    member = Member.find_by(id: session[:member_id])
    return false unless member

    # paramsから抽出したcontentとチームのprofile_fieldのidのペア配列を返す
    service = ReturnProfileAttributePairsService.new(team, params: create_profile_params)
    field_id_and_value_content_pairs_arr = service.call
    member.build_profile_values(field_id_and_value_content_pairs_arr)
    # validationに引っ掛からなければ、保存
    if ProfileValue.valid_content?(member)
      member.save_profile_values
      member.save_image(create_profile_params)
      flash[:notice] = I18n.t('notice.members.join_team_successfully', team: team.name)
      redirect_to users_members_teams_path
      # contentのセッション情報は削除
      reset_content_from_session(team)
    else
      # 空だった場合、リダイレクトする(renderではうまくいかなかったため)
      # 他のフィールドに書き込んでいた場合、それは保持しておきたいために、セッションに値を格納しておく
      set_content_in_session(team, create_profile_params)
      redirect_to new_users_members_profile_value_path(team_id: team.uuid)
      flash[:alert] = I18n.t('alert.profile_values.not_empty_content')
    end
  end

  • 項目ごとに絞って合致するメンバーを検索するのはどうすれば...?
    結論から述べれば、項目ごとにprofile_valuesテーブルとmembersテーブルを連続してJOINして、WHERE句でプロフィールの内容に合致するメンバーを返すことにしました。
    指定するプロフィール項目を増やすAND検索では、その項目数分、JOINを重ねていきます(JOINしてWHERE句で絞った結果にさらにJOINをかけて絞っていきます)。
    それによって、検索に合致したメンバーのみを返すことができました。
    また、検索ワードに「、」を入れて複数のワードを入力した際には、OR検索を行うようにもしています。
    以下に、検索機能を担うサービスクラス内のコードを抜粋して載せておきます。汚くてすみません🙏
検索機能のコードを見る👀
services/filter_members_service.rb

class FilterMembersService
  def self.call(params)
    new(params).call
  end

  def initialize(params)
    @params = params
  end

  def call
    scattered_profile_params = @params.select { |key, _| key.match?(/field\d/) || key.match?(/value\d/) }
    grouped_pairs_params = group_by_profile_pairs(scattered_profile_params)
    filter_members(grouped_pairs_params)
  end

  private

    # scatterd_profile_params={field1:1,value1:"",field2:"",value2:""}
    # => {:profile1=>{:field=>nil, :value=>nil},
    #     :profile2=>{:field=>nil, :value=>nil}}
    def group_by_profile_pairs(params)
      params_size = params.to_h.size / 2
      grouped_pairs_params = {}
      # field1,value1,field2,value2というように並んでいるので、サイズ÷2回で、1ペアあるものと認識する
      if params_size < 2
        field = params['field1']
        value = params['value1']
        pairs_params = { profile1: { field:, value: } }
        grouped_pairs_params.merge!(pairs_params)
      else
        params_size.times do |i|
          field = params["field#{i + 1}"]
          value = params["value#{i + 1}"]
          pairs_params = { "profile#{i + 1}": { field:, value: } }
          grouped_pairs_params.merge!(pairs_params)
        end
      end
      grouped_pairs_params
    end

    def filter_members(params)
      # これに、複数のクエリを追加していく
      query = Member

      # paramsの個数({profile1:{field:"",value:""},profile2,,,,})分繰り返す
      params.each_with_index do |(_, value), i|
        # paramsから項目(profile_field.id)と内容(profile_value.content)を読み取る
        field = value[:field].to_i
        content = value[:value]

        # 生年月日は「2002/12/24」などの入力を2002-12-24に変更する
        content = change_date_format(field, content)

        # 異なるprofile_field_idの条件でAND検索するため、項目条件の個数分membersとprofile_valuesを内部結合する
        alias_name_of_profile_values = "profile_values#{i + 1}" # 複数回結合する際、テーブルを識別する必要があるため

        # 内容の検索条件に「、」が含まれている時、OR検索を追加する
        if content.include?('、')
          contents = content.split('、').map { |content| "%#{content}%" }
          # 「、」で区切られたcontentの数だけOR条件を追加する
          content_conditions = contents.map do |_content|
            "#{alias_name_of_profile_values}.content LIKE ? "
          end.join(' OR ')
          # 項目名の条件は同じ
          field_condition = "(#{alias_name_of_profile_values}.profile_field_id= ?)"
          # 項目名の条件と内容の条件をANDで結合する
          #   => 項目名がAかつ、内容がBまたはCまたは...なmemberを探すクエリとなる
          field_and_content_conditions = [field_condition, "(#{content_conditions})"]
          final_conditions = field_and_content_conditions.join(' AND ')
          # where句に上で追加した条件を挿入
          # ? のプレースホルダの値が必要なので、contents配列を展開して渡す
          query = query.joins("INNER JOIN profile_values AS #{alias_name_of_profile_values}
                              ON #{alias_name_of_profile_values}.member_id = members.id")
                      .where(final_conditions, field, *contents)
        else
          query = query.joins("INNER JOIN profile_values AS #{alias_name_of_profile_values}
                              ON #{alias_name_of_profile_values}.member_id = members.id")
                      .where("#{alias_name_of_profile_values}.profile_field_id= ?
                              AND #{alias_name_of_profile_values}.content LIKE ?", field, "%#{content}%")
        end
      end
      # 重複を排除して結果を返す
      query.distinct
    end

    def change_date_format(profile_field_id, content)
      return false unless profile_field_id && content

      profile_field = ProfileField.find_by(id: profile_field_id)
      if profile_field && profile_field.name == '生年月日'
        content.gsub('/', '-')
      else
        content
      end
    end
end


ここら辺は関連付けがめちゃくちゃ重要であり、関連付けに関して便利なメソッドを提供してくれるRailsには頭が上がりません。関連付けによるメソッドだけでは難しい部分には、生のSQLも書きました。
また、結局は自分で考えることになりますが、AIに設計を聞いてみるのはかなりアリでは?と思います。これおかしいだろう!ってのはたまにありますが、アイデアの素は提供してくれます。

E2Eテストの導入によるある程度の挙動確認⇨安心なコード変更

今回書いたテストの割合で言えば、E2Eテスト5割、結合テスト2割、単体テスト3割が肌感としてあります。
E2Eテストは、ユーザーがブラウザ上で行う実際の操作、そしてそれによるプログラムの挙動を併せてテストすることができるテストとなっており、これを書いておけば(間違いなくかけて入れば)ある程度の質は担保されるのかなぁと思います。
自分が導入したテスティングフレームワークはRSpecです。RSpecでは、

テスト種類 Rspec
E2Eテスト System Spec
結合テスト Request Spec
単体テスト Model Spec
Controller Spec
..etc

となっています。つまり、主にSystem Specを書き、一部にはRequest Specを書き、Model Specも少し書いたということになります。Controller Specは書いていません。前回の開発では、テストをあまり書いていませんでした😅。その反省を生かし、TDD(テスト駆動開発)と言えるほどではありませんが、だいたい先にSystem Specで期待する動きを書いて、実装することにしていました。テストを書いていて利点を感じたのは、リファクタリング、というかコードの変更が怖くなくなった点です。また、コードを変更したことによる別ファイルへの影響も素早く察知する(モジュールやモデル同士を疎結合にしろよという話はさておき)ことができたため、安心して(?)開発を進めることができました。

SimpleCovというRuby用のコードカバレッジ分析ツールによると、

E17C37C7-849A-47BE-B71E-833BF4F9BDAB.jpeg

コードカバレッジ率(テストによって実行されたソースコードの割合)が約92%でした。
SimpleCovで分析されるのは単純に実行されたソースコードの割合なのでテストカバレッジ的にどうかと言われると...な話ではありますが、ある程度は書いて網羅性はそこそこであるということがわかります。

...といいつつ実は、削除/更新系の動作はテスト書いてません🙃。CRUDの「U」「D」は面倒で毎回後回しになるんですよねー...。書かないとだよなぁ。

また、振り返ってみれば、テスト内容があらゆるエッジケースを網羅しているわけではないことがわかりました。このテストのままでは、いわゆる「予期せぬバグ」は見つけられないのではないかと思います。あらゆるケースを想定したテストケースが必要ですし、書いたテストも割と「成功パターン」が多い😧。「失敗パターン」、さらには「予期せぬバグを生みそうなパターン」を想定して書くべきだったと反省ができそうです。

tailwindによるラクなレスポンシブ対応(モバイル/PC)

PC画面だけでなく、モバイル画面の実装も後から行いました。tailwindは、レスポンシブ対応もラクにできるようにブレークポイントが設定されています。
モバイル画面は表示領域が少ないので、できるだけ隠して、ボタンを押せばメニューが出てくるように意識しました。以下に、モバイル画面をいくつか載せておきます。ちなみに、動きは全てStimulusで作られています!!

ハンバーガーメニューボタンを押すとスライドオーバーでサイドバーが出てきます
68ce1d0d48836f81040f065de0155d3a.gif

ミートボールメニュー(「・・・」メニューのことをこう呼ぶそうです)
79b9a96ae7eea9e611cdf5a348dbdf3c.gif
0a45f55b8542e11d59e021679948cfe6.gif

Image from Gyazo

レスポンシブ開発にあたっては、Responsive Viewerを使用しました。様々な端末のサイズを一気に確かめることができるので便利でした。
余談ですが、自分はMacBook Air(13インチ)を使っています。Responsive Viewerで他のPC画面と比べると、画面がかなり大きいことがわかりました。自分のPC画面だけで開発しちゃうと、サイズがあまりにも違い、他の端末だとデザインが崩れてしまったりすることがあるので危険かもしれませんね。

DHHルーティングによるRESTfulなコントローラー設計

👇DHHルーティングとは?

https://postd.cc/how-dhh-organizes-his-rails-controllers/

コントローラにはHTTPメソッドに基づいたCRUDな7つのアクションのみを持たせ、スコープごとにコントローラーを細かく分割するといった手法です。
以下はリクエストメソッドとコントローラーアクションの対応表です。

HTTPメソッド コントローラーアクション
GET index リソースの一覧表示
GET show 特定のリソースの表示
GET new 新しいリソース作成フォームの表示
GET edit 既存リソースの編集フォームの表示
POST create 新しいリソースの作成
PATCH/PUT update 既存リソースの更新
DELETE destroy 既存リソースの削除

この手法により、エンドポイントがわかりやすく、コントローラを薄くすることができます。さらに、半強制的にRESTfulな設計になるが故に、パターンが単純化し、ビジネスロジックもシンプルにすることができます。

最終的には、以下のようなコントローラー構成になりました

app/controllers
├── application_controller.rb
├── concerns
├── home_controller.rb
├── teams
│   ├── invitations_controller.rb
│   ├── members
│   │   └── filters_controller.rb
│   ├── members_controller.rb
│   └── profile_fields_controller.rb
├── teams_controller.rb
└── users
    ├── admins
    │   ├── teams
    │   │   └── invitations_controller.rb
    │   └── teams_controller.rb
    ├── admins_controller.rb
    ├── confirmations_controller.rb
    ├── members
    │   ├── profile_values_controller.rb
    │   └── teams_controller.rb
    ├── members_controller.rb
    ├── omniauth_callbacks_controller.rb
    ├── passwords_controller.rb
    ├── registrations_controller.rb
    ├── sessions_controller.rb
    └── unlocks_controller.rb

ルーティングは以下のように設定しています。
namespaceで名前空間を分けることができます。また、resources をネストすることにより、リソース間の親子関係を表しています。
なお、ルーティングを定義したけどうまくコントローラーが認識してくれないなぁという時は、controllerオプションを使えば認識してくれます。他にも、moduleオプションでも名前空間を分けられますし、asオプションでルーティング名を指定したりもできます。

config/routes.rb
namespace :teams do
    resources :profile_fields
    resource :invitation,only: %i[show]
  end
  
  resources :teams do
    resources :filters, only: %i[create index],controller:"teams/members/filters",as: :members_filters
    resources :members, only: %i[index show], module: :teams
  end
  
  resources :users,only: %i[edit destroy]
  
  namespace :users do
    resource :admin,only: :update
    
    namespace :admins do
      resources :teams,only: :index
      namespace :teams do
        resource :invitation,only: %i[show create]
      end
    end
    
    namespace :members do
      resources :teams,only: :index
      resources :profile_values
    end
    
    resources :members
  end

DHHルーテティングを知ったのは、たまたまXで流れてきた投稿を見たからです笑。
「へぇ〜、せっかくだし今回取り入れてみよう!」ということで早速実践しました。開発体験も良くなったと感じていて、コントローラごとに何の役割を持っているのかが把握しやすくなりました。

前回の開発では、routes.rb にて resources ブロックの中でmembercollectionを書いてRESTからは外れたアクションを定義したりしていましたが、今回DHHルーティングを取り入れてからは、今後は必ずこの書き方にしたい!と思えました。
大半はRESTfulなアクションで表現できます。統一されたパターンであれば、コードや全体の見通しが良くなります。
とても良い手法ですよね🧐。知れてよかったです!

6. 反省点

反省点ごとに述べていきます。
パッと思いつくのはこんな感じです。

結局コントローラーが膨らんでしまった

今回は、きちんとモデルにロジックの大半を書くぞ!。どちらのモデルに書くにも違和感が生じる場合はサービスオブジェクトにロジックを切り出すぞ!など考えて、割とSkinny Controllerを意識していたはずでした。それに、DHHルーティングにしてコントローラーは分割されていました。しかし、一つ一つのアクション内で条件分岐が若干複雑になった結果、コードの見通しが悪くなりました。

忘れてはならないMVCアーキテクチャですが、コントローラーはあくまでロジック-データの橋渡し的な役割を担います。条件分岐なども別で切り出して、paramsを受けとり、リダイレクトなどを担い、フラッシュを表示させる、ぐらいの動きを持たせるだけで良いのだと思います。かといって、書いたコードをどう切り出せばいいか良くわからなくなって放置しています。(切り出せそうなところは切り出しましたが...😅)

https://zenn.dev/marty_ojiya/articles/ada4528d2b619c

パーフェクトRuby on Railsの最後らへんの章に、ロジックの切り出し方について様々書かれていますが、難しいですね。

今度ドメイン駆動設計の書籍も読もうかと思います!

実はJSを用いた画面のE2Eテストができていない

一部ですが、 System Specがどうしてもjsを使う画面でのテストで失敗、そしてテストにおけるjsの設定を有効にするとよくわからんエラーを吐いてしまい...。様々な記事を読み、試行錯誤してもわからなかったので、テスト項目をスキップさせています😓。
プログラムの挙動だけは確かめておくため、Model SpecやRequest Specでその辺をカバーしています。
なんでだろう...。

更新・削除系の実装を後回しにしてテストもおろそかにした

先述した部分です。そこら辺は「えいや」で実装してしまう悪癖があります。手動で挙動を確認できているのでまぁ..。

全てのコミット時に git add -Aを使用してしまった

これもまた悪癖です。もっとコミットの粒度を細かくするべきですし、git add -Aとすることによって、コミット内の差分に、コミット名の内容以外のものが大量に含まれてしまうようになってしまいました。これは、他の方のコミット内容をみて「ハッとしたことです😳」。
次回からは、コミットの粒度を高く+ファイルを指定するようにします。

7. 展望

現段階で思いついている。このサービスをもっと良くしていけるなと思う点を挙げます。

遅延ローディングの実装

GithubActionsでCIを導入する

チームのプロフィール項目を設定する際に合わせて「記入例」も併せて登録できる

「記入例」を併せて登録できることで、メンバーがチームに参加してプロフィールを登録する際にフォームのプレースホルダで「(例)〜〜〜〜」というふうに表示させるようにすることです。これにより、各メンバーが記入する型をある程度セーブすることができそうです。
検索は部分一致(LIKE)でかけているのである程度は精度が保たれそうですが、例えば大学名に「同志社大学」と書く人もいれば「同志社」と書く人もいそうです。同志社女子大学もあることを考えれば、「同志社」での登録だと片方の大学に絞れないという課題があります。「同志社大学」と登録させれば、検索体験を向上させることができそうです。

これは、実装している中で構想していましたが、やるなら後からだなぁと考えていたので(MVPを決めていたわけではないが..)一旦辞めました。

項目のモデルと紐づける形で記入例モデルを作成すれば難しい実装ではないと考えています。

プロフィールの登録を、Zennの「タグ」のように...

Zennの記事作成の際、タグをつけると思います。その際、すでに他のユーザーが記述している(中でも件数が多い)ものを表示してくれますよね。(例:rai、まで打ったらrailsが候補に出てくる)
そのようにしてあげれば、登録内容により一層統一感が生まれます。それにより検索体験も向上するでしょう。

画像を登録する際に、スクエアに切り取る操作ができるようにする

今の所、画像の大きさは制限をかけていますが、縦長の写真だとぎゅっと押し縮められたりしてしまいます。LINEなどのように、アプリ側が想定するサイズに切り取る操作をユーザーにさせることで、この課題が解決されるのかなぁと思います。現段階だと、自分でなんとなく正方形にトリミングしてもらうほかありません。

自分で思いつくのはこれぐらいです。
やはり、ユーザーに使ってもらってFBをもらって改善していく...これが真のアプリ開発のサイクルであると思います。
使ってもらって初めて改善点が判明することでしょう。

8. おわりに

長々と書いてしまいました。コードも可読性が低くて未熟者であることを痛感します。
反省点も挙げましたが、まだまだあります(テストコードも可読性が低い!カオス!である点等々)。

しかしながらやはり開発は楽しかったし、Rails のフレームワークとしての強力さを改めて実感しました!

機会があればきちんとリリースしようかなと思います(やるかわかりませんが...!)。

開発がひと段落したところで、また新たな技術をキャッチアップすべく勉強していくことにします📚。

入社までにレベルを少しでも上げなければ...!!

よければ、冒頭に述べた方法でサービスを触ってみてください。

Discussion