👻

Campfireのソースコードに学ぶRails Way🚀

に公開

We're gonna give it away for FREEEE!!!

迷える子羊の皆さん、こんにちは。
先日のRails Worldで、我らがDHH御大が、自社(37signals)のプロダクトであるCampfireのソースコードをOSS化すると発表しました。よっ、太っ腹🙌

https://www.youtube.com/watch?v=gcwzWzC7gUA&t=1438s

そのソースコードが、こちら。

https://github.com/basecamp/once-campfire

DHHの開発チームがRailsアプリケーションをどう実装しているのか、気になりませんか?
わたし、気になります!超気になります!
Rails Way...、どこ...?と迷子になることが多い今日この頃。
Campfireのソースコードから、Rails Wayに対する学びを得ていきましょう。

Campfireってどういうプロダクト?

Campfireってなんぞ?どうやら、シンプルなチャットツールのようです。

https://www.youtube.com/watch?v=GAzRUbE1AAw

動画を見る限り、以下のような機能があるようです。

  • チャットルームでメッセージを送受信する
  • アカウントに他のユーザーを招待するためのリンク・QRコードを発行・更新する
  • アカウントに所属するユーザーを権限変更・削除する
  • チャットルームを一覧・切り替え・新規作成・名前変更する
  • 通知設定を変更する
  • 未読メッセージを確認する
  • ユーザー情報を変更する
  • ユーザーに自動ログインするためのリンク・QRコードを発行・更新する

アカウントはおそらくテナントのようなものですね。

コードリーディング

リーディング対象のコードのバージョン: d7c6727

全体像

それではまず、コードの中身を見る前に、コードの規模感を確かめておきます。

cloc
$ cloc app/
     409 text files.
     373 unique files.                                          
      94 files ignored.

github.com/AlDanial/cloc v 2.06  T=0.08 s (4435.8 files/s, 148725.0 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
JavaScript                      74            740             40           3076
CSS                             26            541             74           2697
Ruby                           118            539             31           2521
ERB                             77            259              2           1908
SVG                             78              0              0             78
-------------------------------------------------------------------------------
SUM:                           373           2079            147          10280
-------------------------------------------------------------------------------

ふむふむ、app/ 下のRubyの実コードは2000行程度。非常に小さいコードベースのようです。
業務で扱うRailsアプリケーションはこの数十倍の規模であることが多いと思いますので、参考にするに際して一定の留保は付きそうです。

rails stats
$ bin/rails stats
+----------------------+--------+--------+---------+---------+-----+-------+
| Name                 |  Lines |    LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Controllers          |   1111 |    891 |      32 |     172 |   5 |     3 |
| Helpers              |    662 |    574 |       4 |      83 |  20 |     4 |
| Jobs                 |     17 |     12 |       3 |       2 |   0 |     4 |
| Models               |   1205 |    960 |      27 |     156 |   5 |     4 |
| Channels             |     91 |     79 |       8 |      14 |   1 |     3 |
| Views                |   2199 |   1932 |       0 |       3 |   0 |   642 |
| Stylesheets          |   3312 |   2705 |       0 |       0 |   0 |     0 |
| JavaScript           |   3826 |   3052 |       0 |      46 |   0 |    64 |
| Libraries            |    252 |    209 |       8 |      29 |   3 |     5 |
| Controller tests     |   1194 |    929 |      29 |     123 |   4 |     5 |
| Helper tests         |     93 |     72 |       1 |      12 |  12 |     4 |
| Model tests          |    948 |    753 |      20 |      96 |   4 |     5 |
| Channel tests        |     72 |     52 |       2 |       9 |   4 |     3 |
| System tests         |    196 |    157 |       3 |      11 |   3 |    12 |
+----------------------+--------+--------+---------+---------+-----+-------+
| Total                |  15178 |  12377 |     137 |     756 |   5 |    14 |
+----------------------+--------+--------+---------+---------+-----+-------+
  Code LOC: 10414     Test LOC: 1963     Code to Test Ratio: 1:0.2

rails stats も見ておきます。
モデル数は27個。コードとテストの比は1:0.2。テスト少なっ!

次に、どんなテーブルがあるかざっとチェックしておきましょう。

テーブル
$ bin/rails runner "puts ActiveRecord::Base.connection.tables.sort"
accounts
action_text_rich_texts
active_storage_attachments
active_storage_blobs
active_storage_variant_records
ar_internal_metadata
boosts
memberships
messages
push_subscriptions
rooms
schema_migrations
searches
sessions
users
webhooks

テーブルスキーマも、えい!

テーブルスキーマ
$ bin/rails runner "
  ActiveRecord::Base.connection.tables.sort.each do |table|
    puts \"\n\033[1;34m#{table}\033[0m\"
    puts '-' * 50
    ActiveRecord::Base.connection.columns(table).each do |col|
      null_info = col.null ? 'NULL' : 'NOT NULL'
      puts \"  %-25s %-15s %s\" % [col.name, col.type, null_info]
    end
  end
  "

accounts
--------------------------------------------------
  id                        integer         NOT NULL
  name                      string          NOT NULL
  join_code                 string          NOT NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL
  custom_styles             text            NULL

action_text_rich_texts
--------------------------------------------------
  id                        integer         NOT NULL
  name                      string          NOT NULL
  body                      text            NULL
  record_type               string          NOT NULL
  record_id                 integer         NOT NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL

active_storage_attachments
--------------------------------------------------
  id                        integer         NOT NULL
  name                      string          NOT NULL
  record_type               string          NOT NULL
  record_id                 integer         NOT NULL
  blob_id                   integer         NOT NULL
  created_at                datetime        NOT NULL

active_storage_blobs
--------------------------------------------------
  id                        integer         NOT NULL
  key                       string          NOT NULL
  filename                  string          NOT NULL
  content_type              string          NULL
  metadata                  text            NULL
  service_name              string          NOT NULL
  byte_size                 integer         NOT NULL
  checksum                  string          NULL
  created_at                datetime        NOT NULL

active_storage_variant_records
--------------------------------------------------
  id                        integer         NOT NULL
  blob_id                   integer         NOT NULL
  variation_digest          string          NOT NULL

ar_internal_metadata
--------------------------------------------------
  key                       string          NOT NULL
  value                     string          NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL

boosts
--------------------------------------------------
  id                        integer         NOT NULL
  message_id                integer         NOT NULL
  booster_id                integer         NOT NULL
  content                   string          NOT NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL

memberships
--------------------------------------------------
  id                        integer         NOT NULL
  room_id                   integer         NOT NULL
  user_id                   integer         NOT NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL
  unread_at                 datetime        NULL
  involvement               string          NULL
  connections               integer         NOT NULL
  connected_at              datetime        NULL

messages
--------------------------------------------------
  id                        integer         NOT NULL
  room_id                   integer         NOT NULL
  creator_id                integer         NOT NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL
  client_message_id         string          NOT NULL

push_subscriptions
--------------------------------------------------
  id                        integer         NOT NULL
  user_id                   integer         NOT NULL
  endpoint                  string          NULL
  p256dh_key                string          NULL
  auth_key                  string          NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL
  user_agent                string          NULL

rooms
--------------------------------------------------
  id                        integer         NOT NULL
  name                      string          NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL
  type                      string          NOT NULL
  creator_id                integer         NOT NULL

schema_migrations
--------------------------------------------------
  version                   string          NOT NULL

searches
--------------------------------------------------
  id                        integer         NOT NULL
  user_id                   integer         NOT NULL
  query                     string          NOT NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL

sessions
--------------------------------------------------
  id                        integer         NOT NULL
  user_id                   integer         NOT NULL
  token                     string          NOT NULL
  ip_address                string          NULL
  user_agent                string          NULL
  last_active_at            datetime        NOT NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL

users
--------------------------------------------------
  id                        integer         NOT NULL
  name                      string          NOT NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL
  role                      integer         NOT NULL
  email_address             string          NULL
  password_digest           string          NULL
  active                    boolean         NULL
  bio                       text            NULL
  bot_token                 string          NULL

webhooks
--------------------------------------------------
  id                        integer         NOT NULL
  user_id                   integer         NOT NULL
  url                       string          NULL
  created_at                datetime        NOT NULL
  updated_at                datetime        NOT NULL

accountsusersroomsmemberships(チャットルームの参加ユーザー)、messagesboosts(いいね等)、searches(メッセージ検索) 辺りがこのドメインの主要なエンティティのようですね。

それではいよいよ、実際にコードの中身を見ていきます。
初回アクセスから一つ目のメッセージの送信までの初期動線を追いながら、コードを読んでいこうと思います。

ローカルでサーバーを起動して、/ にアクセスすると、/first_run にリダイレクトして、ユーザーの新規登録画面のようなものが表示されました。

ルーティング

てなわけで、まずはルーティングファイルをチェックしてみます。

config/routes.rb
Rails.application.routes.draw do
  root "welcome#show"

  resource :first_run

はい、ありました。コントローラの実装を追っていくと、/ へのアクセス時に、user が一つも存在しない場合、/first_run にリダイレクトされるようになっていました。

で、ついでに他のルーティング定義に目をやると、

config/routes.rb
      resources :bots do
        scope module: "bots" do
          resource :key, only: :update
        end
      end

ここ、気になります。ここは以下のようにカスタムアクションを用いて定義することもできますよね。

      resources :bots do
        member do
          patch :reset_key
        end
      end

しかし、DHHはカスタムアクションを用いる書き方を避けているそうです。

https://jeromedalbert.com/how-dhh-organizes-his-rails-controllers

その理由は、カスタムアクションを用いると、コントローラの責務が増えて実装が複雑になるためです。
(また、上の例だと、リソース名に動詞を当てており、RESTの思想に反しています。上の記事でそのような例が挙げられているわけでもこの論点に言及があるわけでもないですが、よく見る書き方なのであえて例として挙げています。)
そこで、DHHは、必要に応じてサブリソースを切り出すことで、コントローラのデフォルトアクションのみを使うようにしているのだとか。

同様に、

config/routes.rb
  namespace :rooms do
    resources :opens
    resources :closeds
    resources :directs
  end

ここでも、サブリソースを切って、コントローラを分け、デフォルトアクションのみを使うようにしています。
クエリパラメータでフィルタするようにして、一つのコントローラに全てを押し込めることもできますが、そうすると複雑な条件分岐が必要になってみすぼらしいコードになってしまうでしょう。
約100行のルーティングファイルの中で、カスタムアクションが使われている箇所は1箇所のみで、他は全てこのDHHの推奨パターンで定義されています。

他には、

config/routes.rb
  get "join/:join_code", to: "users#new", as: :join
  post "join/:join_code", to: "users#create"
  ...
  resources :rooms do
    ...
    get "@:message_id", to: "rooms#show", as: :at_message
  end

ここの部分も興味深いです。
あえてCoCの原則を崩して、ユーザーフレンドリーなURLを指定しています。
CoCの原則に関して、DHHは次のように述べています。

https://rubyonrails.org/doctrine#convention-over-configuration

As with anything, though, the power of convention isn’t without peril. When Rails makes it so trivial to do so much, it is easy to think every aspect of an application can be formed by precut templates. But most applications worth building have some elements that are unique in some way. It may only be 5% or 1%, but it’s there.

(日本語訳)
しかしながら、規約の力には落とし穴もあります。Railsによって多くのことが非常に簡単になると、すべてがプリセットのテンプレートで構築できるように錯覚しがちです。しかし、価値あるアプリケーションには、必ずどこかに個性が含まれています。それが5%、あるいは1%であったとしても、確かに存在するのです。

規約に盲目的に従うことはRails Wayではないということですね。
特に今回のように命名が関わる場面では、思考停止で機械的な命名を採用するのではなく、ユーザーファーストで考えることが重要であるように思います。Rubyは命名が命。

で、ユーザー情報を入力してボタンを押すと、チャットルームの画面が表示されました。

モデル

というわけで、処理の流れを追っていきます。
フォームをサブミットすると、POST /first_run にリクエストされて、以下の FirstRun クラスの create! メソッドが実行されます。
最初の accountroomusermembership を作成する処理です。

app/models/first_run.rb
class FirstRun
  ACCOUNT_NAME = "Campfire"
  FIRST_ROOM_NAME = "All Talk"

  def self.create!(user_params)
    account = Account.create!(name: ACCOUNT_NAME)
    room    = Rooms::Open.new(name: FIRST_ROOM_NAME)

    administrator = room.creator = User.new(user_params.merge(role: :administrator))
    room.save!

    room.memberships.grant_to administrator

    administrator
  end
end

ここでおもしろいのは、この処理が app/models/ 下のPORO(Plain Old Ruby Object)の振る舞いとして定義されていることです。
すなわち、初回実行というイベントをPOROでモデル化するという形で、ドメインを表現しています。
現実を明確に表していて、理解しやすい構造です。

ある処理を特定のActive Recordモデルの責務に帰することができない場合、サービス層に切り出すか、POROモデルに切り出すかのいずれかの手段がとられることが多いと思います。
37signalsでは、後者の方針が採られているようです。

https://dev.37signals.com/vanilla-rails-is-plenty

We don’t use services as first-class architectural artifacts in the DDD sense (stateless, named after a verb), but we have many classes that exist to encapsulate operations. ... We usually prefer to present them as domain models that expose the needed functionality instead of using a mere procedural syntax to invoke the operation.
... it’s just plain object orientation with Ruby to give a domain concept a proper representation in code.
Also, we don’t make a big deal of distinguishing whether a domain model is persisted or not (Active record or PORO).

(日本語訳)
私たちはDDDの意味での「サービス」(ステートレスで、動詞を名前に持つ一級のアーキテクチャ的要素)を使ってはいません。しかし、操作をカプセル化するためのクラスは数多く存在します。 ... 私たちは通常、必要な機能を公開するドメインモデルとしてそれらを提示することを好み、単なる手続き的な文法で操作を呼び出すようなやり方はしません。
... これは単にRubyによるオブジェクト指向を使って、ドメインの概念にコード上で適切な表現を与えているだけなのです。
また、ドメインモデルが永続化されるかどうか(ActiveRecordかPOROか)を大げさに区別することもありません。

そして、サービス層の導入を否定する理由としては、ボイラープレートが増えるか、ドメインモデル貧血症、すなわち本来ドメイン層で一元管理されるべきドメインロジックがあちこちに散らばってしまうことになりやすいことが挙げられています。
したがって、モデル層において、Active Recordモデルと、POROモデルを柔軟に組み合わせて、ドメインを表現することが、Railsでドメインの複雑さに向き合っていく上での基本姿勢であるといえるでしょう。

なお、Railsにおけるモデル層の定義は、RailsのソースコードのREADME.mdで明確に述べられています。

https://github.com/rails/rails?tab=readme-ov-file#model-layer

The Model layer represents the domain model (such as Account, Product, Person, Post, etc.) and encapsulates the business logic specific to your application. In Rails, database-backed model classes are derived from ActiveRecord::Base. Active Record allows you to present the data from database rows as objects and embellish these data objects with business logic methods. Although most Rails models are backed by a database, models can also be ordinary Ruby classes, or Ruby classes that implement a set of interfaces as provided by the Active Model module.

(日本語訳)
モデル層は、ドメインモデル(Account、Product、Person、Postなど)を表し、アプリケーション固有のビジネスロジックをカプセル化します。
Railsでは、データベースと紐づくモデルクラスはActiveRecord::Baseを継承して作られます。Active Recordによって、データベースの行をオブジェクトとして扱い、それらのデータオブジェクトにビジネスロジック用のメソッドを追加することが可能になります。
ほとんどのRailsモデルはデータベースに裏打ちされていますが、モデルは通常のRubyクラスとして実装することもできますし、Active Modelモジュールが提供する一連のインターフェースを実装するRubyクラスとして作ることもできます。

たまに、モデル層にActive Recordモデル以外を置くことはRails Wayからの逸脱であるという認識をお持ちの方を見かけます。しかし、それは誤解でしょう。
そのような固定観念があると、Active Recordモデルへしわ寄せがいってモデルの複雑度や依存関係が発散するか、サービス層へしわ寄せがいって手続き型プログラミングに退行することにつながりやすくなってしまうでしょう。

Concern

次に、FirstRun.create! で呼び出されている Account モデルの実装を見ていきましょう。

app/models/account.rb
class Account < ApplicationRecord
  include Joinable

  has_one_attached :logo
end
app/models/account/joinable.rb
module Account::Joinable
  extend ActiveSupport::Concern

  included do
    before_create { self.join_code = generate_join_code }
  end

  def reset_join_code
    update! join_code: generate_join_code
  end

  private
    def generate_join_code
      SecureRandom.alphanumeric(12).scan(/.{4}/).join("-")
    end
end

Account モデル本体の実装はほぼ無で、招待機能に関する実装が Account モデル専用の Account::Joinable Concernに切り出されています。
このくらいならふつうにベタ書きしてもよいのでは?、まだ切り出すには早いのでは?、そもそも再利用性がないなら切り出す必要ないのでは?という意見もあるかと思います。
しかし、37signalsでは、Concernを単なる再利用可能なモジュールの置き場所としてだけでなく、モデルを機能の組み合わせとして構造化するための手段として積極的に使用しているようです。

https://dev.37signals.com/good-concerns

Ruby mixins are often presented as an alternative to multiple inheritance: a code-reuse mechanism across classes. We use some concerns this way, but the most common scenario where we use them is to organize code within a single model. We use different conventions for each case:

  • For common model concerns: we place them in app/models/concerns.
  • For model-specific concerns: we place them in a folder matching the model name: app/models/<model_name>.

(日本語訳)
Rubyのミックスインは、しばしば「多重継承の代替手段」として提示されます。つまり、クラス間でコードを再利用する仕組みです。私たちも一部のconcernをそのように使っていますが、最も一般的な利用シナリオは 単一のモデル内でコードを整理するためです。
ケースごとに異なる規約を用いています:

  • 共通的なモデルconcern:app/models/concerns に配置します。
  • モデル固有のconcern:モデル名に対応するフォルダに配置します(例:app/models/<model_name>)。

そして、何をConcernに切り出すかの判断基準に関しては、以下のように述べられています。

The key here is that each concern should be a cohesive unit that captures a trait of the host model. In other words, they should only contain things that belong together. You should not treat concerns as arbitrary containers of behavior and structure to split a large model into smaller parts. They need to feature a genuine has trait or acts as semantics to work, just like class inheritance needs the is a relationship.

(日本語訳)
ここで重要なのは、それぞれのconcernがホストモデルの特性を一貫性のある単位として捉えているべきだ、という点です。言い換えると、concernには「本来まとまるべきもの」だけを含めるべきです。concernを、巨大なモデルを分割するための振る舞いや構造の寄せ集めコンテナのように扱ってはいけません。クラス継承において「is-a」関係が必要なように、concernもまた「〜という特性を持つ」(has trait) や「〜のように振る舞う」(acts as)といった意味を真に備えている必要があります。

すなわち、モデルの中で、機能的に凝集しており、has trait、またはacts asの関係を持つ部分を積極的にConcernに切り出していくと、可読性が向上しやすいということのようです。
今回の Account::Joinable はhas traitの関係を持つパターンですね。
Concernは、単に再利用可能なモジュールを定義するためのものではなく、その名の通り、関心の分離を実現するためのものであるという捉え方がこの機能の実像に合っているといえるでしょう。

で、POST /first_run 後は、//rooms/1 とリダイレクトして、チャットルーム画面に至ります。
そしていよいよ、メッセージを送信してみると、チャット欄にメッセージが追加されました。
処理としては、POST /rooms/1/messages にリクエストされて、message が作成されるという流れのようです。

コールバック

ここでは message 作成時のコールバックに着目してみます。
コールバックはRailsの中でもっとも扱いが難しい機能の一つではないでしょうか。
濫用すると、カオスな振る舞いや複雑怪奇な有向グラフに悩まされる羽目になりますよね。

app/models/message.rb
class Message < ApplicationRecord
  include Attachment, Broadcasts, Mentionee, Pagination, Searchable
  ...

  before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
  after_create_commit -> { room.receive(self) }
app/models/message/searchable.rb
module Message::Searchable
  extend ActiveSupport::Concern

  included do
    after_create_commit  :create_in_index

messagecreate 時のコールバックは、

  • before_createclient_message_id のデフォルト値をセットする
  • after_create_commit でチャットルームの未読状態への変更と未読通知を行う
  • after_create_commit でメッセージ検索用のインデックスを作成する

の3つのようです。まあ、ふつうですね。

37signalsの開発ブログでは、コールバックに関して、以下の見解が示されています。

https://dev.37signals.com/globals-callbacks-and-other-sacrileges

A common critique of callbacks is that they bring indirection, making code difficult to follow. And, indeed, you don’t want to orchestrate complex flows using callbacks. But, here, the operation is quite simple – create a bucket along its project if it does not exist – and this is not a primary Project responsibility either, so the indirection is a good fit: you’re plugging in a secondary function in a declarative way. Callbacks work great in such scenarios.

(日本語訳)
コールバックに対する一般的な批判は、それが間接性を生み出し、コードを追いにくくするという点です。実際、複雑なフローをコールバックでオーケストレーションするのは望ましくありません。しかし、ここで扱っている操作は非常に単純です —— プロジェクトに紐づくバケットが存在しなければ作成する、というだけです。そして、これはProjectの主要な責務でもありません。したがって、この程度の間接性であればむしろ適切です。副次的な機能を宣言的に差し込む形になるからです。このようなケースでは、コールバックは非常にうまく機能します。

副次的な機能を宣言的に差し込むという使い方はうまくいきやすいとのこと。
今回のケースでも、チャットルームの未読状態の管理や、メッセージ検索用のインデックスの作成は、Message モデルにとって副次的な機能であるといえそうです。
特に、後者のようにConcernに切り出されていると、モデルの主要な責務を浮き彫りにするというメリットが際立ちますね。

上の記事では、結論として、以下のように述べられています。

Software development is a game of tradeoffs, and any choice you make comes with them.
...
There is no question that callbacks ... are sharp knives. ... But, in our experience, there are scenarios where they are the best alternative at hand, and they don’t cause any maintenance burden.

(日本語訳)
ソフトウェア開発はトレードオフのゲームであり、どんな選択にも必ず代償が伴います。
...
コールバック ... が「鋭いナイフ」であることは疑いありません。 ... しかし、私たちの経験では、それらが最善の選択肢となる場面が確かに存在し、メンテナンスの負担を引き起こすこともありません。

ここで、コールバックが「鋭いナイフ」と表現されていることは注目に値します。
というのも、DHHは「鋭いナイフ」という言葉を、Rubyのモンキーパッチのような、「強力だが、危険な機能」を指す言葉として用いているからです。

https://rubyonrails.org/doctrine#provide-sharp-knives

つまり、37signalsでは、コールバックを全面禁止するような立場を否定しつつも、コールバックを、使いどころを慎重に見極める必要のある危険な機能であると認識していることになります。
そうだとすると、コールバックを全く使用しないというような極端な消極主義も、逆に、使える場合はつねに使う、ドメインルールを必ずコールバックで表現するというような極端な積極主義も、どちらもうまくいかない可能性が高いでしょう。

上の記事で、DHHは、「鋭いナイフ」への向き合い方として、以下のように述べています。

Programmers who haven’t learned to wield sharp knives just aren’t going to make meringues yet. Operative word here: Yet. I believe that every programmer has a path, if not a right, to become fully capable Ruby and Rails programmers. And by capable, I mean knowledgeable enough to know when and how, accordingly to their context, they should use the different and sometimes dangerous tools in the drawers.
That does not abdicate a responsibility to help get them there. The language and the framework should be patient tutors willing to help and guide anyone to experthood. While recognizing that the only reliable course there goes through the land of mistakes: Tools used wrong, a bit of blood, sweat, and perhaps even some tears. There simply is no other way.

(日本語訳)
鋭いナイフを使いこなす術をまだ学んでいないプログラマーは、メレンゲ(meringue)を作れません。「Yet(まだ)」という言葉が肝です。私は、すべてのプログラマーには、完全に熟練したRubyおよびRailsプログラマーになる「道」があり、むしろ「権利」があると信じています。そして「熟練」とは、状況に応じて、いつ、どのように、異なる、場合によっては危険なツールを用いるべきかを知っていることです。
それは、助ける責任を放棄することではありません。言語とフレームワークは、経験者への道を歩む誰にでも忍耐強く付き添う師であるべきです。そして、その信頼できる道のりは、間違いの世界を通ることに尽きます。誤用された道具、少しの血、汗、そしておそらく涙。これ以外の道は存在しません。

つまり、コールバックの使い方は失敗から学ぶしかないということです😇
いくらRailsがeasy志向のフレームワークとはいえ、ことコールバックに関しては、誰もが真似できるようなベストプラクティスはなく、経験によって培われる、エンジニアとしての判断のセンスが必要になるということでしょう。
そして、一つの方針に固執するのではなく、その都度冷静に利益と不利益を見極めて、臨機応変に判断するという柔軟な姿勢を持つことが、コールバックの力を引き出す鍵だと思われます。

最後に

Campfireのソースコードはいかがだったでしょうか。
私は退屈なまでに平凡なRailsらしい書きっぷりと、本記事では触れられていませんが、ほぼゼロコンフィグでRailsの機能をフル活用しているところから、本家大元の風格を感じました。
上澄みをさらっただけですが、あまり驚きはなく、ああやっぱりそうしているんだと、答え合わせをしているような感覚になりました。
決してDHHのプラクティスに合わせなければならないということはありませんが、特にRailsという思想の強いフレームワークを使うにあたって、作者であるDHHの使い方を参考にする価値は大きいと思われます。
この記事が少しでも迷える子羊🐏の皆さんのお役に立てれば幸いです。

Discussion