読者です 読者をやめる 読者になる 読者になる

ユーザー基盤を作り直しながらRailsでのサービス層に向き合う

こんにちは。パートナーアライアンス部の諸橋 (@moro) です。

突然ですが、わたしはいまクックパッドの「ユーザー基盤」を再構築しようとしています。 一口に「ユーザー基盤の再構築」といっても、そのゴールが何を指すかは(わたし自身にとってもまだ)漠然としており、固定されたゴールは見いだせていません。しかし後述するように、いくつかの問題は明確な形を取っています。言い換えると、それら明確な問題と向き合いながら『柔軟でいい感じのユーザー基盤を目指す』というのがこの再構築プロジェクトの目的です。

その第一歩目として、ユーザー登録部分を現状のクックパッド本体とは別の小さなRailsアプリケーションとして実装を進め、つい先日、一部の限定された利用者の方に向けて公開することができました。 今後も様子を見ながら公開範囲を拡大していく予定です。

再構築の背景

ではその「明確な問題」とはなんでしょうか。

最大のものは、ユーザー登録のあり方が時勢にそぐわなくなってきているのではないかという懸念です。たとえばクックパッドのアカウントはメールアドレスとパスワードを登録していただくのが基本になっています。しかし世の中のトレンドとしてEメール自体の利用シーンが減るに連れ、それを必須とするサービスまで使いづらいものになるのではないかという危機感がありました。そのため、ユーザー基盤に大きく手を入れたいというニーズがありました。

もう一つは、クックパッドのコードベースがかなり巨大化・複雑化していることです(参考)。巨大で複雑なコードベースの上では、上記のユーザー登録のあり方の抜本的に変更することは難しいですし、日常の開発においてもある機能の修正が他の機能のバグのきっかけになったり、自動テストの実行時間も長くなったりするなどの問題がでています。そのため各機能を別のサービスとして実装した上で連携させるマイクロサービス化を進めています。その方向性を踏まえ、今回のユーザー基盤の再構築でも、ユーザー登録やログインといった機能を別サービスとして実装することにしました。

サービス層への興味

また、せっかく新しくRailsアプリケーションを作るのだから「Railsにおけるサービス層の実現」という技術的関心にも向き合うことにしました。

Railsは、DBのテーブルをそのまま読み書きするような単純なアプリケーションを作ることは簡単にできますが、より複雑なアプリケーションではそのぶん慎重な設計が必要になるため、Trailblazerのような複雑なWebアプリケーション構築を支援するライブラリが注目を集めています。

私自身もこのサービス層の実現にはおおいに興味があるいっぽう、今回はまだコード規模が小さいためにTrailblazerそれ自体の導入は先送りし、Rails上でフォームオブジェクトパターンによるサービス層の実現に挑戦することにしました。

この記事では、そういったビジネス上の背景と技術的興味を踏まえてアプリケーションを作る過程の試行/思考を紹介します。

最小限の機能から少しずつ進める

再構築を決意したとは言え、サービスにおいて「ユーザー」というのはとても大事かつ複雑なドメインモデルです。

実際に、現クックパッドのActiveRecordモデルクラス(以下ARモデル)Userはファイル中で定義されるメソッドが328個、includeしているモジュールが14個、has_(one|many)合わせて175個の関連が定義されている3,000行を超えるクラスです。このすべてを一気に新しいユーザー基盤(以下、新基盤)に置き換えようとしても、影響範囲が大きすぎてリリースにたどりつけないのではないかという懸念がありました。

そこで今回は最小限の機能をリリースすることを重視し、従来より負担の少ないユーザー登録フローを実現するアプリケーションを開発しました。

現方式では(1-1)ファーストビューで必要な情報をすべて入力したあとで、(1-2)到達確認のためにメールを送信し、(1-3)メール中のURLにアクセスすると登録が完了するという流れで登録します。 対して新基盤では、(2-1)ファーストビューではメールアドレスのみ入力し、(2-2)すぐに到達確認メールを送信し、(2-3)メール中のURLにアクセスしたあとに残りの情報を入力する、という流れです。ファーストビューでの入力項目が減る分、ユーザー登録する際の負担が減るであろうと見込んでいます。

また、新基盤のデータの持ち方やインフラ構造についても、現行クックパッドからの漸次的な改善を進めていくために、以下のような方針を取りました。

  • 新基盤では、現クックパッドとDBを共有する。
    • 新しいフローでのみ必要なデータは新規のDBに、現クックパッドで使うデータは現DBに書き出す。
    • 現DBに書く場合でも、ビジネスロジックは新たに書き直す。
  • 今後もインクリメンタルな移行を進めるため、現サービス https://cookpad.com/ 以下の特定のパスをリバースプロキシで分岐して新基盤と統合する。
  • 技術要素は社内推奨かつ新しいものを採用する。
    • 現クックパッドからの移行がしやすく、開発者の熟練度も高いRuby on Railsを採用する。
    • 技術的な進歩にキャッチアップするため、Ruby 2.4 + Rails 5.1.rc1 + Webpack + React などで開発する。
    • ECS上にアプリケーションをデプロイするためのHakoなど、社内の標準的なインフラ構成に乗せる。

このように「最小限の機能で、ちゃんと動くソフトウェア」をリリースすることを優先した結果、ユーザー登録のみを行うアプリケーションとして最初のリリースを迎えられました。

アプリケーションを少しずつ設計する

さて、再構築の方針やファーストリリースのスコープは決めたものの、それだけではアプリケーションはできません。 今後の拡張に対して柔軟に対応でき、かつ複雑だったり無理のある構造にしないため、慎重にアプリケーションを設計しました。その過程も紹介します。

最初の一歩: メールアドレスを登録する

出来上がった新基盤でのユーザー登録フローでは、利用者が目にする最初の画面は下記のようなものになります。

f:id:moro:20170406152442p:plain

これをみてわかるように、入力フォームはメールアドレス一つだけというシンプルなものにしました。この画面からメールアドレスを登録すると、メールアドレス確認用のリンクが記載されるメールが送られます。利用者がそれをクリックすることで、メールアドレスの到達確認がなされたとみなします。

そのため最初は、「メールアドレス登録」を表すARモデルを作り、メールアドレスと確認用URLに付与する予測が困難なトークン文字列(以下、確認URL用トークン)を作りました。カラム定義は次のようにしました。

  • 登録対象のメールアドレス(文字列)
  • 確認URL用トークン(文字列)
  • 確認URL用トークンの有効期限(タイムスタンプ)
  • 登録完了日(タイムスタンプ, 初期値null)
  • Railsタイムスタンプ(created_at, updated_at)

このデータを永続化するとともに、入力されたメールアドレスに対し、確認用メールを送ります。

次の一歩: 確認URLから登録完了する

確認用URLからアクセスする画面はこのようなものになります。

f:id:moro:20170406152434p:plain

ここでパスワードと生年月日を入力してもらい、メールマガジンなどの設定を見直してもらうと登録が完了します。

一見すると単純な画面ではあるのですが、ここから登録すると下記のような、たくさんの処理が走ります。

  • 現DBのユーザーテーブルにデータを作る。
    • 合わせて、現DBのユーザーのデータ一式を作る。それらは複数のテーブルに分割されている。
  • 新基盤のDBでも、そのメールアドレス登録を使用済みとして更新する。
  • 登録完了のメールを送信する。
  • 他システムにユーザー登録完了を知らせるためのAPIを叩く。

そこで、この処理ではARモデルを直接読み書きするのではなく、フォームオブジェクトを導入し、「ユーザー登録完了フォーム」として抽出しました。

これにより、以下の観点で「無理のないアプリケーションコード」にできたと思っています。

  • フォームの入力値をrequestparamsハッシュから取り出したあとの、Plain OldなRubyオブジェクトにできた。そのため、モデルのユニットテストとして一連の処理をテストできた。
  • 複数ARモデルのオブジェクトを一気に保存する場合に、関連先オブジェクトの内部構造と密結合してしまいやすいaccept_nested_attributesを避け、ネストのないActiveModelモデル内にカプセル化できた。

これで主要な入力は出来てきましたが、前のメールアドレスの登録画面や、それを永続化するテーブル構造に気になるところがでてきました。

メールアドレス登録画面のフォームオブジェクト化

前述のように、メールアドレス登録フォームでは、入力されたメールアドレスと確認URL用トークン、有効期限を1つのテーブルに永続化していました。これは初手としては手頃な落とし所ではありましたが、いくつか気になる点がでてきました。

まず、再構築の目的には、近い将来にメールアドレス以外でのユーザー登録できるようにしたいというものもありました。そうすると「利用者がユーザー登録しようとしたこと」そのものと「そこで登録してくれたメールアドレス」は分割すべきように見えます。 また、登録完了した時点で現DBに書き込まれるユーザーも追跡可能にしておきたくなります。

それらを考慮し、テーブル構造を以下のようにしました。

  • ユーザー登録をしようとしたことテーブル

    • 確認URL用トークンの有効期限(タイムスタンプ)
      • メールではない「新規登録」であっても、有効期限は存在するはずという仮定で
    • 完了後現DBに作成されたユーザーのID(数値, 初期値null)
    • 登録完了日(タイムスタンプ, 初期値null)
    • Railsタイムスタンプ(created_at, updated_at)
  • メールアドレス登録テーブル

    • ユーザー登録したことテーブルへの外部キー
    • 登録対象のメールアドレス(文字列)
    • 確認URL用トークン(文字列)
    • Railsタイムスタンプ(created_at, updated_at)

なお「現DBのユーザーID」と「登録完了日」をのみを抽出して「登録完了したこと」テーブルに分けるアイディアもあり、個人的にもそのほうが好みではありました。しかし、そこを分割する利点がまだ少ないこと、将来的にそうしたくなった場合でも無損失に分割できそうなことなどを考慮して、いまの2テーブルの構成にしています。

さて、このようにテーブルを分割するとRailsからも複数のARモデルを扱うことになります。結果として、メールアドレス登録画面にもフォームオブジェクトを導入することでスッキリ書けるようになりました。

まとめ

このように、機能がまだ少ない簡単なWebアプリケーションであっても、アプリケーション設計で考えられることはたくさんありました。今後も継続的に開発を進めながら設計を洗練させていきたいと思っていますし、あるいはTrailblazerの導入なども考えていくつもりです。

またこれ以外にも、Reactを使ったクライアントサイド設計や、その前提となるサーバ側/JS側での責務の切り分け、自動テスト戦略などなど、あらためて言語化してみると様々なレベルで設計判断が必要となりました。また別の機会に、それぞれ紹介できればいいなと思います。 さらに、今回の「最小機能でリリースする」というスコープ決定や、今後もやりたいことリストから取り組む順番づけなど、ソフトウェア開発は大小の判断の連続なのだなあというのをあらためて実感します。

こういった判断は、それぞれの現場によって、やりたいことも前提も既存コードも大きく違うため一概には言えないと思いますが、今回の話が一つの例として参考になれば嬉しく思います。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/