Campfireのソースコードに学ぶRails Way🚀
We're gonna give it away for FREEEE!!!
迷える子羊の皆さん、こんにちは。
先日のRails Worldで、我らがDHH御大が、自社(37signals)のプロダクトであるCampfireのソースコードをOSS化すると発表しました。よっ、太っ腹🙌
そのソースコードが、こちら。
DHHの開発チームがRailsアプリケーションをどう実装しているのか、気になりませんか?
わたし、気になります!超気になります!
Rails Way...、どこ...?と迷子になることが多い今日この頃。
Campfireのソースコードから、Rails Wayに対する学びを得ていきましょう。
Campfireってどういうプロダクト?
Campfireってなんぞ?どうやら、シンプルなチャットツールのようです。
動画を見る限り、以下のような機能があるようです。
- チャットルームでメッセージを送受信する
- アカウントに他のユーザーを招待するためのリンク・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
accounts
、users
、rooms
、memberships
(チャットルームの参加ユーザー)、messages
、boosts
(いいね等)、searches
(メッセージ検索) 辺りがこのドメインの主要なエンティティのようですね。
それではいよいよ、実際にコードの中身を見ていきます。
初回アクセスから一つ目のメッセージの送信までの初期動線を追いながら、コードを読んでいこうと思います。
ローカルでサーバーを起動して、/
にアクセスすると、/first_run
にリダイレクトして、ユーザーの新規登録画面のようなものが表示されました。
ルーティング
てなわけで、まずはルーティングファイルをチェックしてみます。
Rails.application.routes.draw do
root "welcome#show"
resource :first_run
はい、ありました。コントローラの実装を追っていくと、/
へのアクセス時に、user
が一つも存在しない場合、/first_run
にリダイレクトされるようになっていました。
で、ついでに他のルーティング定義に目をやると、
resources :bots do
scope module: "bots" do
resource :key, only: :update
end
end
ここ、気になります。ここは以下のようにカスタムアクションを用いて定義することもできますよね。
resources :bots do
member do
patch :reset_key
end
end
しかし、DHHはカスタムアクションを用いる書き方を避けているそうです。
その理由は、カスタムアクションを用いると、コントローラの責務が増えて実装が複雑になるためです。
(また、上の例だと、リソース名に動詞を当てており、RESTの思想に反しています。上の記事でそのような例が挙げられているわけでもこの論点に言及があるわけでもないですが、よく見る書き方なのであえて例として挙げています。)
そこで、DHHは、必要に応じてサブリソースを切り出すことで、コントローラのデフォルトアクションのみを使うようにしているのだとか。
同様に、
namespace :rooms do
resources :opens
resources :closeds
resources :directs
end
ここでも、サブリソースを切って、コントローラを分け、デフォルトアクションのみを使うようにしています。
クエリパラメータでフィルタするようにして、一つのコントローラに全てを押し込めることもできますが、そうすると複雑な条件分岐が必要になってみすぼらしいコードになってしまうでしょう。
約100行のルーティングファイルの中で、カスタムアクションが使われている箇所は1箇所のみで、他は全てこのDHHの推奨パターンで定義されています。
他には、
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は次のように述べています。
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!
メソッドが実行されます。
最初の account
、room
、user
、membership
を作成する処理です。
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では、後者の方針が採られているようです。
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で明確に述べられています。
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
モデルの実装を見ていきましょう。
class Account < ApplicationRecord
include Joinable
has_one_attached :logo
end
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を単なる再利用可能なモジュールの置き場所としてだけでなく、モデルを機能の組み合わせとして構造化するための手段として積極的に使用しているようです。
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の中でもっとも扱いが難しい機能の一つではないでしょうか。
濫用すると、カオスな振る舞いや複雑怪奇な有向グラフに悩まされる羽目になりますよね。
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) }
module Message::Searchable
extend ActiveSupport::Concern
included do
after_create_commit :create_in_index
message
の create
時のコールバックは、
-
before_create
でclient_message_id
のデフォルト値をセットする -
after_create_commit
でチャットルームの未読状態への変更と未読通知を行う -
after_create_commit
でメッセージ検索用のインデックスを作成する
の3つのようです。まあ、ふつうですね。
37signalsの開発ブログでは、コールバックに関して、以下の見解が示されています。
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のモンキーパッチのような、「強力だが、危険な機能」を指す言葉として用いているからです。
つまり、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