6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発しているRails製のマイクロブログ「Mewst」の少し独特な実装方針 (バックエンド編)

Last updated at Posted at 2024-04-29

最近個人でコツコツ作っていた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 を使って定義しています。

  1. どんなルーティングが定義されているのかパッと見でわかるようにするため
    • bin/rails routes の実行結果とほぼ同じ見た目なので、bin/rails routes を実行する必要がないです
  2. どこにルーティング定義を差し込めば良いかがすぐわかるため
    • Mewstのルーティング定義はアルファベット順にソートしています
    • ルーティング定義を追加するとき、どこに追加すると良いか判断に迷わず済みます

1コントローラーにつき1アクション

ルーティング定義のサンプルコードで気づかれた方もいらっしゃるかもしれませんが、Mewstではアクションごとにコントローラーを定義しています。

例えばプロフィールページのコントローラーは以下のように定義しています。

github.com/mewstcom/mewst/app/controllers/profiles/show_controller.rb

app/controllers/profiles/show_controller.rb
class Profiles::ShowController < ApplicationController
  # ...
  def call
    # ...
  end
  # ...
end

以下の理由からこうしています。

  1. 定義されているコールバックがどのアクションで実行されるのかすぐわかるようになるため
    • before_action などを使ってコールバックを定義するとき、onlyexcept オプションを指定する必要がなくなるため、どのコールバックが実行されるのかすぐわかるようになります
  2. コントローラー内がすっきりするため

フォームオブジェクトと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::ModelActiveModel::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クラスの中でロジックを書くことはせず、モデルに定義したメソッドを呼び出すだけにしています。

以下のような理由からこうしています。

  1. やることを別のクラスに分けることでコードの見通しを良くするため
    • 検証はフォームオブジェクトで、トランザクションを伴う永続化処理はUseCaseで、その他ロジックなどの処理はモデルで、という感じでクラスごとに役割分担しているので、クラスの責務が明確になります
  2. プロジェクト内にどんな永続化処理があるのかパッと見でわかるようにするため
    • app/use_cases/ ディレクトリを見ればどんなことをするアプリなのかがわかります
    • モデルに定義した永続化メソッドをコントローラーなどで直接呼び出すことは基本的にしていません
  3. トランザクションを張る場所を統一するため
    • MewstではUseCaseクラス以外の場所でトランザクションを明示的に定義していません
  4. コールバックをなるべく使わないようにするため
    • トランザクションの管理を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: truetyped: 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

方針としては以下のようになります。

  1. describe, context, before, after, it だけ使用する
    • subjectlet/let! などは使わない
  2. テストに必要なデータの用意などは context の中だけで行う
  3. context をネストしない
  4. expect にブロックを渡さない
    • expect {}.to change() みたいな書き方をしない

なるべく普通のRubyのコードっぽくなるように心がけています。

この方針で書くとテストコードがすごく冗長になりますが、あとからテストを見たときに何をやっているのかわかりやすく、テストケースを追加するときも楽になる気がしています。


以上がMewstを開発しているときの実装方針でした。

レールから外れまくりではありますが、個人的にはこの方針で実装するとわかりやすいRailsアプリになって好みです。
(仕事では普通にやっています)

何かの参考になれば幸いです :pray:

6
5
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
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?