課題ドリブンな個人開発で「Members Hub」というWebアプリを作った
はじめに
この度、Members Hub というWebアプリを開発しました。
サークルや部活などの「チーム」を対象として、メンバーのプロフィールを検索して閲覧できるプラットフォームを提供するためのサービスとなっています。
開発の記録として、この記事を残すことにします。
サービスURL
※現在サービスは終了しています
30人のBotを登録した「サンプルチーム」に参加することでMembers Hubを体験していただけます!
SNSまたはメールでアカウントを作成してログインしていただいた後、
「チームに参加する」⇨ チームIDに9b6ca177-5ddc-4655-b876-9222513b9ef7
、パスワードにsampleteam
を入力して、チームに参加してみてください。プロフィール項目の設定は適当で大丈夫です!
登録不要でサンプルユーザーとしてもログインいただけます!
メールアドレスにsample@sample.com
、パスワードにpassword
を入力してログインしてみてください!
すでにサンプルチームに参加させているので、同じく、サンプルチームを閲覧することができます。
GitHub
目次
1. 開発した背景
2. 機能一覧
3. アーキテクチャ
4. 使用技術
5. 開発上の工夫点
6. 反省点
7. 展望
8. おわりに
1. 開発した背景
所属していたサークルの「自己紹介カード」というシステム
自分の所属していたサークルでは、毎年5月あたり(新入生がサークルに入会するタイミング)で「自己紹介カード」というシステムがありました(システムといえばおかしいかな?)。
「自己紹介カード」には以下のようなフローがありました。
- 総務班長(役職)が、Wordファイルのフォーマットを用意・配布
- サークルのメンバーがそれぞれ書き込んで総務班長に提出(LINEで)
- 総務班長が集計し、パート(ダンスのサークルだったのでパートで別れていました)や代に分けて、ファイルを繋ぎ合わせたものを画像orPDF化
- OneCloudにアップロード
このフローの中には以下のような非効率的な問題点があると感じていました。
❶総務班長の負担
❷集計にかかる時間
❸(メンバーから見て)閲覧時の利便性
❶に関して、フォーマットを代々引き継いでいるものかとは思いますが、フォーマットを新しくしたくなった場合などは作り直したりすることを考えると面倒です。それに、Wordファイルで作られたフォーマットはとても書きにくいと感じていました。入力が長ければフォーマットが崩れたりするし、間違えてフォーマットを破壊する可能性もありました。
❷に関して、サークルのメンバーたちはこの「自己紹介カード」の開示を結構楽しみにしていました笑。しかし、総務班長はフローにあげたように面倒なことをしなければならず、人によって自己紹介カードを提出するタイミングもまちまちです。全員の分が揃わないと公開できなかったので、時間がかかったのです。
❸に関して、これを感じていたのは自分だけかもしれませんが、「検索できたらな」と感じることがありました。プロフィールを流し読みするならこのままでもいいかもしれません。しかし、例えば、1回生がサークルに入ってきたとして「同じパートで〇〇学部」や「同じ出身地」の友達いるかな..?って気持ちで探していたとします。そうすると、すべてのカード(多ければ150人分)をくまなく探す必要があったのです。そこで、「検索したらでてくる」といいなぁと感じていました。
❶❷❸の課題をすべて解決するのがMembers Hub
上の課題を解決すべく、機能の構想から始め、開発に着手しました。次のセクションで機能について述べていくので、機能を紹介した後で、またこれらの課題に触れ、いかなる形で解決しているのかをまとめます。
2. 機能一覧
ホームページをご覧いただいても良いのですが、Members Hubを使う上で踏むべきステップがあるので、ステップごとに紹介していきます。
はじめに、早速ステップからは外れますが(笑)ログイン機能などの認証周りから述べていきます。
※以下は、PC画面となっています
新規登録/ログイン
メールアドレスによる登録はもちろん、Google/LINEによログインが可能です。
チームを作成する
サイドバーの「チームを作成」から、「チーム名」「パスワード」「ロゴ画像」を指定して、次へを押します。
チームのプロフィール項目を設定する
上のステップで「次へ」を押すと、このページに遷移します。デフォルトでは名前、生年月日、プロフィール画像が項目として設定されており、削除することはできません。
チームごとに追加で7つ設定でき、設定中に項目名の編集・削除もできます。
なお、この項目は後からでも編集可能です。
項目の登録後、チームの作成が完了し、作成したユーザーが「チームの管理者」となります。
チームにメンバーを招待する
管理者権限で、招待リンクを生成することができます。有効期限は24時間以内です。期限が切れた場合はまた生成することができます。このリンクはワンクリックでコピーできます。
以下のような文章がコピーされ、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が付与されています。
これを、「チームに参加」画面にてチームIDとして入力し、管理者によってチーム作成時に設定されたチームパスワードを入力することでチームへの認証に成功し、プロフィールの登録画面に遷移します。
②管理者が発行した招待リンクを踏む
リンクを踏んだユーザーは、ログインしていれば即座に認証が成功し、ログイン/新規登録をしていない場合はその後に自動的に認証に成功するようになっています。
今回は、上のステップで生成した招待リンクをそのままアドレスバーに打ち込んでいます。
プロフィールを登録する
チームの認証に成功すると、プロフィール登録画面に遷移するので、すべての項目に記入の上、「登録」ボタンを押すと、チームへの参加が完了します。
なお、プロフィールを登録せずに別のページに遷移した場合(ユーザーが別のページへ移った場合)は、チームへの参加がキャンセルされます。
チームメンバーを閲覧する/検索する
「メンバーを閲覧」をクリックすると、参加しているチーム名が出てくるので、閲覧したい方をクリックします。
先ほど作成した「チーム七福神」も見えますね。ただ、今回はBotを登録している「サンプルチーム」の方を見ていきます。
プロフィール項目を選んで、キーワードを入力すると、合致したメンバーがでてくるようになっています。
項目を増やしてAND検索で条件を絞ることも可能ですし、キーワードに「、」を含めることでOR検索も行われるようになっています。 AND検索とOR検索は、幾つでも指定可能です。
その他
チームのプロフィール項目の更新 / メンバー自身のプロフィールの更新
プロフィール項目を削除/項目名の編集が可能です。ただし、既にメンバーが登録している状態で項目の削除を行うと、メンバーの項目名に対応するプロフィールのデータも削除されます。
既にメンバーが登録している状態で新たにプロフィール項目を追加した際は、各メンバーの「プロフィールを編集」ページに新たに記入フォームが追加されるようになっています。
管理者権限の譲渡
管理者は、チーム情報の更新/チーのプロフィール項目の編集/招待リンクの発行/チームの削除が可能です。管理者は、管理権限を別のメンバーに譲渡することができます。
権限を譲渡した後は、「管理中のチーム」からサンプルチームが消えていることがわかります。
Members Hubによる課題へのアプローチ
前セクションで述べた❶❷❸の課題がサービスによって解決できている(はず)ことが伝わったでしょうか。
❶の総務班長の負担に関しては、「チームを作成→項目を登録→招待リンクを共有」の3ステップで済むようになりました。1つ1つのステップも簡単に行えます。フォーマットも用意する必要がなく、(凝ったデザインではありませんが)Members Hubが整ったフォーマットでうまく表示してくれます。
❷の集計時間に関しては、そもそも集計する必要がなくなりました。プロフィールを登録して参加した時点で、すでに参加しているメンバーのプロフィールを閲覧できるようになりました。
❸の閲覧の利便性ですが、こちらも「検索フォーム」ができたことによって簡単にメンバーを探し出せるようになりました。
初めは、自身のサークル専用に作る予定でしたが、プロフィール項目を自由に設定できるようにしたことで、どのチームでも使える設計にしました。前回は「あったら面白いな」の理想ドリブンでしたが、課題ドリブンな開発はなんとなくやりがいを感じていました。
とは言いつつ、今のところ実用化を考えていません...(運用してFBを貰ってこその開発だろ!!なんですけど、他にもやりたいことがたくさんありまして...)。でも今後、このサービスが最も必要とされそうな4〜5月あたりにリリースするはのいいかもしれないですね。
3. アーキテクチャ
アーキテクチャ図
以下のように表せるかと思います。
バックエンド+フロントエンドはRailsですべて書いています。もっと詳しく言えば、フロントエンドはERBがメインで、JSはほとんど書かず、Hotwire(Turbo,Stimulus)が動いています。
ER図
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実装コードを開く
条件分岐がネストして可読性が低いです、要リファクタリング。
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
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の仕組みについてわかりやすく説明されています
- 各プロバイダからどんなパラメータが返ってくるかがまとめられていました
- モックを用いたテストを書く際に参考にしました
Hotwire
フロントエンドには、Hotwireを導入しました。今回の開発は、Hotwireを用いて開発することも大きな目的としてありました。
正確には「Hotwire」という技術があるのではなく、Hotwireは「Turbo」「Stimulus」「Strada」という3つのライブラリの総称です。今回は、TurboとStimulusを使用しました。
自分がとやかく説明するよりも、以下の記事をご覧になられた方がいいと思うので詳しくは説明しません笑。
簡単に言えば、フロントエンドをJSをほとんど書かずに実装できるようになります。Reactなどはクライアントサイド側で動的に値を書き換えたりすると思います。Turboを使えば、部分的に更新したいHTMLのみを差し替えることによって、簡単にSPA風な実装をすることができます。
また、モーダルやトグルメニュー、ドロップダウンなどはJSが必要になりますが、Stimulusを使えば簡単に記述することができます。今回はさらに簡単にそれらを実装するために、tailwind-stimulus-componentsというライブラリを使用しました。あらかじめコントローラ/ある程度のシンプルなデザインを実装してくれているので、ドキュメント通りに従えば超簡単にモーダルなどをつけることができます。自分の場合は、必要に応じてオーバーライドしたりしてカスタマイズして使いました。
ユーザビリティを考えると、必要以上なページ遷移は避けたい...となると、トグルメニューやモーダルによるコンテンツの表示は必要不可欠になってくるのかなと思います。
前回の開発では、フロントエンドにNext.jsを採用しました。それも改めてまたキャッチアップしなおしたいと思いますが、SRRやCSR、RSCなどの使い分けが難しかったり(理解不足)、JSONによるやり取りが若干ストレスでした。しかし、Hotwireを使えばほとんどのことは賄えますし、「前回Nextで書いた諸々はほとんどHotwireでラクにかけたくないか??」と思ってしまいました。学習コストも低く、サーバーサイドの実装のみに集中できるHotwireは、今回の開発体験を大幅に向上させてくれました。
どうやら、日本の大きな企業ではHotwireの採用が少なそうです。大抵はRailsもAPIモードでクライアントサイドはReact/Vueを使うケースがほとんどと思います。しかし、個人開発の範疇であれば、クライアント/サーバーで分割するよりも1つのフレームワーク内で完結させた方がラクじゃないかなぁと言うのが前回・今回の開発を踏まえた率直な感想です。
DHH氏も述べている通り、Railsには"The One Person Framework"という思想があるっぽいです。
結構使っていて感動したので量が多くなりましたが、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アクションのコードを見る👀
# 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検索を行うようにもしています。
以下に、検索機能を担うサービスクラス内のコードを抜粋して載せておきます。汚くてすみません🙏
検索機能のコードを見る👀
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用のコードカバレッジ分析ツールによると、
コードカバレッジ率(テストによって実行されたソースコードの割合)が約92%でした。
SimpleCovで分析されるのは単純に実行されたソースコードの割合なのでテストカバレッジ的にどうかと言われると...な話ではありますが、ある程度は書いて網羅性はそこそこであるということがわかります。
...といいつつ実は、削除/更新系の動作はテスト書いてません🙃。CRUDの「U」「D」は面倒で毎回後回しになるんですよねー...。書かないとだよなぁ。
また、振り返ってみれば、テスト内容があらゆるエッジケースを網羅しているわけではないことがわかりました。このテストのままでは、いわゆる「予期せぬバグ」は見つけられないのではないかと思います。あらゆるケースを想定したテストケースが必要ですし、書いたテストも割と「成功パターン」が多い😧。「失敗パターン」、さらには「予期せぬバグを生みそうなパターン」を想定して書くべきだったと反省ができそうです。
tailwindによるラクなレスポンシブ対応(モバイル/PC)
PC画面だけでなく、モバイル画面の実装も後から行いました。tailwindは、レスポンシブ対応もラクにできるようにブレークポイントが設定されています。
モバイル画面は表示領域が少ないので、できるだけ隠して、ボタンを押せばメニューが出てくるように意識しました。以下に、モバイル画面をいくつか載せておきます。ちなみに、動きは全てStimulusで作られています!!
ハンバーガーメニューボタンを押すとスライドオーバーでサイドバーが出てきます
ミートボールメニュー(「・・・」メニューのことをこう呼ぶそうです)
レスポンシブ開発にあたっては、Responsive Viewerを使用しました。様々な端末のサイズを一気に確かめることができるので便利でした。
余談ですが、自分はMacBook Air(13インチ)を使っています。Responsive Viewerで他のPC画面と比べると、画面がかなり大きいことがわかりました。自分のPC画面だけで開発しちゃうと、サイズがあまりにも違い、他の端末だとデザインが崩れてしまったりすることがあるので危険かもしれませんね。
DHHルーティングによるRESTfulなコントローラー設計
👇DHHルーティングとは?
コントローラには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
オプションでルーティング名を指定したりもできます。
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
ブロックの中でmember
やcollection
を書いてRESTからは外れたアクションを定義したりしていましたが、今回DHHルーティングを取り入れてからは、今後は必ずこの書き方にしたい!と思えました。
大半はRESTfulなアクションで表現できます。統一されたパターンであれば、コードや全体の見通しが良くなります。
とても良い手法ですよね🧐。知れてよかったです!
6. 反省点
反省点ごとに述べていきます。
パッと思いつくのはこんな感じです。
結局コントローラーが膨らんでしまった
今回は、きちんとモデルにロジックの大半を書くぞ!。どちらのモデルに書くにも違和感が生じる場合はサービスオブジェクトにロジックを切り出すぞ!など考えて、割とSkinny Controllerを意識していたはずでした。それに、DHHルーティングにしてコントローラーは分割されていました。しかし、一つ一つのアクション内で条件分岐が若干複雑になった結果、コードの見通しが悪くなりました。
忘れてはならないMVCアーキテクチャですが、コントローラーはあくまでロジック-データの橋渡し的な役割を担います。条件分岐なども別で切り出して、params
を受けとり、リダイレクトなどを担い、フラッシュを表示させる、ぐらいの動きを持たせるだけで良いのだと思います。かといって、書いたコードをどう切り出せばいいか良くわからなくなって放置しています。(切り出せそうなところは切り出しましたが...😅)
パーフェクト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