ロコガイド テックブログ

「地域のくらしを、かしこく、たのしく」する、株式会社ロコガイドの社員がいろいろな記事を書いています。

「地域のくらしを、かしこく、たのしく」する、株式会社ロコガイドの社員がいろいろな記事を書いています。

ABテスト・限定公開ができるシンプルなgemを作成しました!

f:id:jun-okada:20200317102915p:plain

こんにちは。地域情報部エンジニアの岡田です。

地域情報部では、
2019年10月に「地域情報サービス静岡版」としてトクバイアプリ内でサービスをリリースしました。
https://locoguide.co.jp/news/2019-10-15/

どの段階のサービスでも、改善を目指し、
ABテストや、
新機能を一部のユーザーに公開して、
価値があるのかを検証すると思います。

今回は、
そういったABテストや限定公開ができる機能を自作し、
gem化したので紹介したいと思います。

gem: https://github.com/Jun0kada/limited_release

なぜ自作したか

既存のgemでもABテストや限定公開機能はありますが、
自作した理由は、
シンプルな機能で良いので
学習コストが低く、
すぐ限定公開機能をリリースできるようにしたいと思ったからです。

また、今回自作をするにあたり、こちらのgemを参考にしました。 https://github.com/amatsuda/motorhead

機能の要件

  • シンプルでさっと実装できる
    • PDCAをたくさん回すために少ない実装で済む
  • 既存のアプリコードを触らない
    • 既存アプリにバグを作る可能性の排除
  • エラーを起こさない
    • 例:ABテストしているUIでエラーが起きたら元のUIを表示する

要件に入れなかったことと理由

  • stylesheetやjavascript
    • assetsの管理はプロダクトによってかなり違いがあるので、それぞれに任せる
  • %公開とか
    • 実装大変なので...
  • コンバージョンの計測
    • 実装大変なので...
    • 独自でログ取ってることも多いと思うので必要ないかなと思い

使い方

使い方をサンプルコードで紹介します

例:/articles(記事一覧ページ)の並び順とUIを変えてみる

既存のアプリコードはこんな感じです。

  • 公開日時降順の記事一覧
  • ulで表示
# routes.rb
Rails.application.routes.draw do
  resources :articles
  # ...
end
# app/controllers/articles_controller.rb

class ArticlesController < ApplicationController
  def index
    @articles = Article.order(published_at: :desc).page(params[:page])
  end
  # ...
end
<!-- app/views/articles/index.html.erb -->

<ul>
  <% @articles.each do |article| %>
    <li>
      <%= link_to article.title, article_path(article) %>
    </li>
  <% end %>
</ul>

そして、
下記の仕様でABテストとして限定公開したいと思います。

  • 公開日時の降順ではなく、更新日時の降順
  • ulではなくolで表示
  • 計測のために記事詳細ページへのリンクにはパラメーターを付ける
  • だいたい50%くらいのユーザーに提出
# config/limited_releases/articles_page_ab_test.rb

class ArticlesPageAbTest
  include LimitedRelease::Feature

  active_if do
    # ユーザーIDが奇数か偶数かでだいたい50%
    current_user.id.odd?
  end

  routes do
    get '/articles', to: 'articles#index'
  end

  helpers do
    def link_to_article_with_tracking_params(article)
      link_to article.title, article_path(article, articles_page_ab_test: true)
    end
  end
end
# app/controllers/limited_release/articles_controller.rb

class LimitedRelease::ArticlesController < ::ArticlesController
  limited_release 'ArticlesPageAbTest'

  def index
    @articles = Article.order(updated_at: :desc).page(params[:page])

    # OR
    # super
    # @articles = @articles.reorder(updated_at: :desc).page(params[:page])
  end
end
<!-- app/views/limited_release/articles/index.html.erb -->

<ol>
  <% @articles.each do |article| %>
    <li>
      <%= link_to_article_with_tracking_params(article) %>
    </li>
  <% end %>
</ol>

これで、IDが奇数のユーザーには、
記事の並び順が更新日時の降順、かつ、olで表示されます。

解説

サンプルコードを見ながら、このgemがどういう実装になっているか説明したいと思います。

1. LimitedRelease::Feature

まず、config/limited_releases/articles_page_ab_test.rbで、 LimitedRelease::Featuremoduleをincludeすることにより、下記が行われます。

  • ArticlesPageAbTest::Helpermoduleを生成
  • これらのメソッドを生成
    • #active_if
      • block引数を@active_ifに代入する
    • #active?
      • @active_ifを評価する
    • #routes
      • block引数を@routesに代入する
    • #helpers
      • block引数をArticlesPageAbTest::Helperで評価する

ソースコード: https://github.com/Jun0kada/limited_release/blob/master/lib/limited_release/feature.rb

2. routesにappendして先にroutingマッチさせる

1で@routesに代入したroutesの定義を、
Rails起動時にRails.application.routes.prependします。

この際、limited_releaseのnamespace配下にすることで、
既存コードにあるcontrollerと衝突しないようになっています。(namespaceはconfigにて変更可能)

rake routesで定義を確認すると、
このようにlimited_release配下の定義が一番上にappendされています。

Helper HTTP Verb Path Controller#Action
limited_release_articles_path GET /articles(.:format) limited_release/articles#index
articles_path GET /articles(.:format) articles#index

これにより、/articlesというpathでリクエストが来ると、
limited_release/articlescontrollerのindexアクションが呼ばれます

ソースコード: https://github.com/Jun0kada/limited_release/blob/master/lib/limited_release/railtie.rb#L6-L12

3. /articlesにリクエストが来る

リクエストが来たcontrollerがlimited_releasenamespace配下の場合、下記が実行されます。

3-1. around_actionでエラーを潰す

around_actionの実装は、このようになっていて、
エラーが発生すると、rescueします。

def wrap_rescue
  begin
    yield
  rescue LimitedRelease::Controller::InvalidCondition
    headers['X-Cascade'] = 'pass'
  rescue => e
    headers['X-Cascade'] = 'pass'

    LimitedRelease.config.on_error.call(e)
  end
end

headers['X-Cascade']'pass'をセットすることで、
他のroutesを探索し、
マッチするcontrollerがあれば実行、
なければ、ActionController::RoutingErrorがraiseされます。

この振る舞いはRailsの機能です。
この機能によって、
ページを条件によってオーバーライドしたり、新しいページを提供することができます。

また、
開発者がエラーを検知できるように
後述するLimitedRelease::Controller::InvalidCondition以外のエラーの場合、
LimitedRelease.config.on_error.call(error)を実行します(こちらの挙動はconfigで変更可能)

これにより、
限定公開機能のバグ等でエラーが発生してもユーザーに500エラーを返さず、
フォールバックした実行結果、もしくは404を返します。

ソースコード: https://github.com/Jun0kada/limited_release/blob/master/lib/limited_release/controller.rb#L27

3-2. 限定公開classを特定

controllerの名前から、限定公開classを特定します。
例:

'LimitedRelease::MyFeatureController'.split('::')[1].sub(/Controller\z/, '').classify.constantize
=> MyFeature

また、サンプルコードのようにclassを指定することもできます。

class LimitedRelease::ArticlesController < ::ArticlesController
  limited_release 'ArticlesPageAbTest'
  # ...

ソースコード: https://github.com/Jun0kada/limited_release/blob/master/lib/limited_release/controller.rb#L39

3-3. active条件を判定する

@active_ifに代入した条件をリクエストのコンテキストで評価し、有効でなかった場合、
先述したLimitedRelease::Controller::InvalidConditionをraiseします。

ソースコード: https://github.com/Jun0kada/limited_release/blob/master/lib/limited_release/controller.rb#L43

3-4. 限定公開機能のヘルパーを読み込む

ArticlesPageAbTest::Helperを読み込み、ヘルパーメソッドとして使えるようにします。

ソースコード: https://github.com/Jun0kada/limited_release/blob/master/lib/limited_release/controller.rb#L47

4. あとは通常と同じ

あとは通常のRailsと同じ動作を行います。

まとめ・使った感想

良かったこと

基本的にcontrollerやviewを作成するだけなので、
学習コストや実装工数が少なく済み、
1日に何個も限定公開機能をリリースできました。

また、
既存コードをまったくいじらないので、
バグを作ってしまう心配がないことも、心理的に◎でした!

悪かったこと、今後追加したい機能

ページの一部しか変更しない場合でも、
ページ全体をオーバーライドしないといけないので、
ちょっと無駄な感じがしました。

partial viewだけをオーバーライドできる機能などを追加したくなりました!

最後に

ぜひみなさん使ってみてください!
コントリビュート、issueもお待ちしております!
https://github.com/Jun0kada/limited_release