最近個人でコツコツ作っていたMewst (ミュースト) というマイクロブログサービスを公開しました。
マイクロブログ「Mewst (ミュースト)」のベータ版をリリースしました | shimba.co
Railsを使って実装していて、ソースコードも公開しています。
個人的な好みにより一般的なRailsアプリとは異なる方針で実装しているところがいくつかあるので、この記事ではそれらについてご紹介したいと思います。
個人開発でやっているものなので好き勝手やっています。
この記事を書き始めたときはバックエンドとフロントエンドどちらの方針も書こうかと思っていましたが、思っていたよりも長くなったため2つに分けることとしました。
フロントエンド編はあとで書くかもしれません。
ルーティング定義には基本的に match
だけを使用する
github.com/mewstcom/mewst/config/routes.rb
以下のような感じで定義しています。
# ...
match "/@:atname", via: :get, as: :profile, to: "profiles/show#call"
match "/@:atname/atom", via: :get, as: :profile_atom, to: "profiles/atom/show#call"
match "/@:atname/check", via: :post, as: :profile_check, to: "checks/create#call"
match "/@:atname/follow", via: :delete, as: :profile_follow, to: "follows/destroy#call"
match "/@:atname/follow", via: :post, to: "follows/create#call"
match "/@:atname/posts/:post_id", via: :get, as: :profile_post, to: "posts/show#call"
match "/accounts", via: :post, as: :account_list, to: "accounts/create#call"
# ...
以下の理由から match
を使って定義しています。
- どんなルーティングが定義されているのかパッと見でわかるようにするため
-
bin/rails routes
の実行結果とほぼ同じ見た目なので、bin/rails routes
を実行する必要がないです
-
- どこにルーティング定義を差し込めば良いかがすぐわかるため
- Mewstのルーティング定義はアルファベット順にソートしています
- ルーティング定義を追加するとき、どこに追加すると良いか判断に迷わず済みます
1コントローラーにつき1アクション
ルーティング定義のサンプルコードで気づかれた方もいらっしゃるかもしれませんが、Mewstではアクションごとにコントローラーを定義しています。
例えばプロフィールページのコントローラーは以下のように定義しています。
github.com/mewstcom/mewst/app/controllers/profiles/show_controller.rb
class Profiles::ShowController < ApplicationController
# ...
def call
# ...
end
# ...
end
以下の理由からこうしています。
- 定義されているコールバックがどのアクションで実行されるのかすぐわかるようになるため
-
before_action
などを使ってコールバックを定義するとき、only
やexcept
オプションを指定する必要がなくなるため、どのコールバックが実行されるのかすぐわかるようになります
-
- コントローラー内がすっきりするため
フォームオブジェクトとUseCaseクラスを使用する
データの検証や永続化処理でActiveRecordのモデルを直接呼ぶことはせず、フォームオブジェクトとUseCaseクラスを使用しています。
例えばポストを新規作成するとき、コントローラーでは以下のようになっています。
(簡単のため実際の実装とは内容が少し異なります)
class Posts::CreateController < ApplicationController
# ...
def call
@form = PostForm.new(form_params)
if @form.invalid?
return render("posts/new/call", status: :unprocessable_entity)
end
result = CreatePostUseCase.new.call(
content: @form.content.not_nil!
)
redirect_to post_path(result.post.id)
end
# ...
end
まずフォームオブジェクトで値の検証を行い、問題なければUseCaseクラスを呼び出して保存します。
フォームオブジェクトは ActiveModel::Model
と ActiveModel::Attributes
を使用して定義しています。
class PostForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :content, :string
validates :content, length: {maximum: 160}, presence: true
end
UseCaseクラスはトランザクションを管理するためのクラスにしており、以下のように定義しています。
class CreatePostUseCase
Result = Struct.new(:post)
def call(content:)
post = Post.new(content:)
ActiveRecord::Base.transaction do
post.save!
post.create_post_link!
FanoutPostJob.perform_later(post_id: post.id)
end
Result.new(post)
end
end
あらかじめフォームオブジェクトでデータの検証を行っているので、UseCaseクラスでは検証を行っていません。もし例外が起きたらそのままエラーとなって終了します。
また、UseCaseクラスの中でロジックを書くことはせず、モデルに定義したメソッドを呼び出すだけにしています。
以下のような理由からこうしています。
- やることを別のクラスに分けることでコードの見通しを良くするため
- 検証はフォームオブジェクトで、トランザクションを伴う永続化処理はUseCaseで、その他ロジックなどの処理はモデルで、という感じでクラスごとに役割分担しているので、クラスの責務が明確になります
- プロジェクト内にどんな永続化処理があるのかパッと見でわかるようにするため
- app/use_cases/ ディレクトリを見ればどんなことをするアプリなのかがわかります
- モデルに定義した永続化メソッドをコントローラーなどで直接呼び出すことは基本的にしていません
- トランザクションを張る場所を統一するため
- MewstではUseCaseクラス以外の場所でトランザクションを明示的に定義していません
- コールバックをなるべく使わないようにするため
- トランザクションの管理をUseCaseクラスで行っているため、コールバックで行いたいような事前・事後処理はUseCaseクラスの中で呼び出すだけで済むようになります
Sorbetを使う
型は欲しいのですが、個人的に型定義は実装の近くに表示されているほうが好みなので、Sorbetを使って型定義しています。
上のUseCaseクラスなどのコード例では簡単のため型定義を書いていませんでしたが、実際はこんな感じで書いています。
github.com/mewstcom/mewst/app/use_cases/create_post_use_case.rb
外部ライブラリなどの型定義の生成にはTapiocaを使用していて、lib/tasks/sorbet.rake というRakeタスクを定義して各種コマンドを実行し型定義を更新しています。
型定義ファイルを更新し忘れることがあったりするので、GitHub Actionsで差分をチェックしていたりもします。
また、Dependabotがgemのアップデートをしたときは型定義ファイルを更新するようにもしています。
github.com/mewstcom/mewst/.github/workflows/lint-and-test.yml#L18-L45
最初からSorbetを導入しているプロジェクトなので、型を書くつもりのクラスには typed: strict
を設定しています。
逆に型を書く気がないクラスもあり、コントローラーやテストファイルなどは typed: true
や typed: false
としています。
RSpecを愚直に書く
テストを書くときはRSpecを使っています。最近は以下のように書いています。
RSpec.describe DeletePostUseCase do
context "正常系" do
def setup_data
post = FactoryBot.create(:post)
{post:}
end
it "ポストが削除できること" do
setup_data => {post:}
# 実行前はポストが存在するはず
expect(Post.count).to eq(1)
# 実行
DeletePostUseCase.new.call(post:)
# 実行後はポストが存在しないはず
expect(Post.count).to eq(0)
end
end
end
方針としては以下のようになります。
-
describe
,context
,before
,after
,it
だけ使用する-
subject
やlet/let!
などは使わない
-
- テストに必要なデータの用意などは
context
の中だけで行う -
context
をネストしない -
expect
にブロックを渡さない-
expect {}.to change()
みたいな書き方をしない
-
なるべく普通のRubyのコードっぽくなるように心がけています。
この方針で書くとテストコードがすごく冗長になりますが、あとからテストを見たときに何をやっているのかわかりやすく、テストケースを追加するときも楽になる気がしています。
以上がMewstを開発しているときの実装方針でした。
レールから外れまくりではありますが、個人的にはこの方針で実装するとわかりやすいRailsアプリになって好みです。
(仕事では普通にやっています)
何かの参考になれば幸いです