Elasticsearchを活用した「ユーザーバッジ機能」をリリースしました

aumoのIOSアプリでユーザーバッジ機能をリリースしました。この記事では、ユーザーバッジ機能の実現に必要な集計処理にElasticsearchを活用した話などをしていきます。

こんにちは。今年4月にグリー株式会社に新卒で入社し、アウモ株式会社の開発グループに配属になりましたネゴロと申します。aumoでは主に比較EC(ホテル, グルメ, レジャー)の開発を担当しています。

東京オリンピックでは日本人選手のメダルラッシュが凄かったですね。私は小学生の頃遠足でマザー牧場に行った際に、子豚のレースでもらった人生初めての金メダルがとても嬉しかったです。

そして実はaumoでもメダル(バッジ)を貰えるようになりました。今回はaumoのIOSアプリにてリリースされたユーザーバッジ機能のサーバーサイドについて述べていきます。

ユーザーバッジ機能

2021年8月10日にaumoのIOSアプリにてユーザーバッジ機能をリリースしました。

ユーザーバッジ機能とは、ユーザーの口コミ投稿件数やコメント数、いいね数などに応じて、金/銀/銅のバッジが付与される機能です。

ユーザーバッジは現在「コミュニティ」と「エリア」の2つのカテゴリがあります。コミュニティでは初回いいねや毎日連続投稿など、ユーザーの様々なアクションに対してバッジが用意されています。エリアでは、47都道府県別に口コミ投稿のバッジが用意されています。

バッジを獲得した際には、モーダル通知とアクティビティ通知によって、獲得したバッジが知らさせれます。

全体の構成

今回実装したユーザーバッジ機能の構成は以下の通りです。

ユーザーバッジ機能を提供するAPIはRailsで実装しました。

ユーザーバッジ機能に伴う集計処理にはElasticCloud、ユーザーのバッジ獲得状況の永続化にはAmazon RDS、バッジの未読既読状況の永続化にはAmazon ElasticCache for Redisを利用しています。

開発体制

今回のユーザーバッジ機能開発における開発体制は以下の通りです。

企画:1人

サーバーサイド:1人

クライアント(IOS):1人

私はサーバーサイドを担当し、リリースまでのスケジュール管理及び設計から実装までを行いました。開発中は企画側やクライアント側の担当者と密にコミュニケーションをとり、機能改善やバグ修正を進めていきました。

サーバーサイドの実装内容

aumoのIOSアプリはRailsで構成されているaumo.jpのAPIを叩いています。

ユーザーバッジ機能もこのAPIの一機能として実装しました。

各バッジに応じた集計処理

ユーザーバッジは、ユーザーのアクション(写真投稿やコメントなど)に応じて金/銀/銅のバッジが付与される仕組みになっています。例えば、写真枚数のバッジであれば、投稿した写真の枚数が15枚になると銅のバッジを獲得することができます。

サーバーサイドではバッジの種類に応じて、写真枚数などのアクションに付随する数値を集計する必要があります。ただ、aumoのユーザー数は数百万規模で、各ユーザーが投稿した写真などを集計対象とすると、扱うデータ量が非常に多くなります。さらに、ユーザーのアクション毎に集計処理を実行する必要があり、これだけのデータ量に対して、RDBのクエリによる集計ではパフォーマンス的に厳しいという課題がありました。

そこで、集計処理にはElasticsearchを用いました。Elasticsearchとは大容量データに対しての高速な検索を可能とするオープンソースの全文検索エンジンです。ちなみにaumoではElastiCloudを導入しています。ElasticCloudはマネージドなElasticsearchを提供するElastic社のクラウドサービスの一つです。

Elasticsearchは元々aumoでも検索対象のデータ量が多いユーザー検索やスポット検索(aumoに登録さている施設をスポットと呼んでいます)などで利用していました。今回のバッジ集計も詰まるところ大容量データを対象とする検索となるため、Elasticsearchを用いることに決定しました。

ここでは、都道府県別口コミ投稿件数のバッジに関して、Elasticsearch を用いてどのように集計処理を実装したか説明したいと思います。

RailsでElasticsearchを扱うためのgemとしてChewyを利用しています。Chewyではまず検索対象のモデルのインデックスを作る必要があります。インデックスを作るためにはフィールド情報などのマッピング定義をします。以下がChewy公式より引用したマッピング定義のサンプルです。

引用:https://github.com/toptal/chewy

今回は口コミ投稿が永続化されているPhotoモデルがElasticsearchの検索対象となるため、マッピング定義は以下のようになります。

class PhotosIndex < MediaIndex

  settings ChewySettingFactory.ja_analyzer_setting

  define_type Photo.includes(
    :spots,
    parent:             [:spots],
    photo_label_scores: [:image_label]
  ) do
    default_import_options batch_size: 1000
    field :type, type: 'short'
    field :caption, type: 'text', analyzer: 'ja_analyzer'
    field :spots, type: 'nested', value: -> { facade_photo&.spots } do
      field :id, type: 'integer'
      field :prefecture_id, type: 'integer'
    end
    field :is_parent, type: 'boolean', value: -> { parent_photo? }
    field :photo_label_scores, type: 'nested' do
      field :name, type: 'keyword', value: -> { image_label.name }
      field :value, type: 'float'
    end
    field :user_id, type: 'integer'
    field :like_count, type: 'integer', value: -> { total_like.to_i }
    field :has_review, type: 'boolean', value: -> { has_valid_review? }
    field :updated_at, type: 'date'
    field :created_at, type: 'date'
  end

  ...
end

また、Chewyでは作成されたインデックスに対してクエリを投げるメソッドを、Rubyで柔軟に実装することが可能です。

以下はユーザーidで検索対象を絞るためのメソッドになります。

def filter_by_user_id(user_id)
      filter(term: {user_id: user_id})
end

このようなメソッドを組み合わせて、都道府県別に口コミ投稿件数の集計を行うロジックは以下のようになります。

PhotosIndex
          .filter_by_user_id(self.id)
          .filter_by_type(Photo.sources[:snap])
          .filter_parent_only
          .filter_by_spot_prefecture_id(pre.id)

Photoモデルには幾つかのtypeがあり、口コミ投稿件数バッジではsnapというtypeを対象に集計を行っています。また、1つの口コミ投稿に対して複数の写真が登録されているケースもあります。口コミ投稿件数バッジでは1つの口コミ投稿で「1」とカウントしたいため、各口コミ投稿に1つだけ存在する親写真をカウントするためのfilter_parent_onlyというメソッドを用意しました。

最後に都道府県別に集計を行うためのメソッドfilter_by_spot_prefecture_idを用意し、都道府県別に口コミ投稿件数を集計できる準備が整いました。

しかし都道府県は47個あります。このままでは集計処理を実行する度に47回Elasticsearchへのクエリが発生するため、パフォーマンスが落ちてしまいます。

そこで、Multi Search APIを利用しました。

Multi Search APIとはElasticsarchにおいて、複数のクエリを一括でリクエストできるAPIで、Chewyでもmsearchというメソッドでサポートされています。msearchを用いた都道府県別口コミ投稿件数のバッジ集計の実装は以下の通りです。

prefectures = Prefecture.ja
 queries = prefectures.map { |pre|
     PhotosIndex
          .filter_by_user_id(self.id)
          .filter_by_type(Photo.sources[:snap])
          .filter_parent_only
          .filter_by_spot_prefecture_id(pre.id)
}
results = Chewy.msearch(queries).responses.map(&:total)

これによって、47回実行していたリクエストを1回に減らすことができました。

各ユーザーに応じた新規獲得バッジの検知と永続化

バッジ集計処理の実装の次は、ユーザーの獲得したバッジを検知し永続化する必要があります。現時点でユーザーの獲得バッジが変化するタイミングは以下のアクションをユーザーが実行した時です。

  • 写真投稿
  • コメント
  • いいね
  • ユーザー情報取得

そのため、それぞれのアクション時にバッジ集計処理を行うエンドポイントを用意しました。

写真投稿PUT api/v2/aggs_photo
コメントPUT api/v2/aggs_comment
いいねPUT api/v2/aggs_like
ユーザー情報取得PUT api/v2/aggs_user

これらのエンドポイントが実行されると、バッジ集計及び獲得したバッジ情報が永続化される設計になっています。

新しいバッジを獲得したかどうかは、集計結果を基に獲得できるバッジの閾値の最大値と、ユーザーが保持しているバッジの閾値(threshold)を比較し、ユーザーがまだそのバッジを保持していなければ新しいバッジとして獲得します。

def set_new_child_badge(user, user_has_badge)
  threshold = aggregate_badge(user)
  new_child_badge = self.badges.select { |b| b.is_acquired?(threshold) }.max {|a, b| a.threshold <=> b.threshold }
  current_badge_id = user_has_badge.send(self.user_has_badge_column_name)
  return if new_child_badge.nil? || current_badge_id == new_child_badge.id
  self.new_child_badge = new_child_badge
end

工夫したところ

Elasticsearchによる連続投稿日/週/月の取得

ユーザーバッジの中には毎日/毎週/毎月連続投稿(口コミ)バッジがあります。

別/週別/月別にユーザーの連続投稿件数を取得する方法として一番簡単に考えられるのは、全ての投稿を取得し、線形探索で日付を検査して、連続している最大数をカウントしてく方法があります。

しかしこの方法だと全ての投稿を変数に保持するため、メモリの消費量が多くなります。また、取得した全ての投稿を検査しなければならず、処理時間がかかってしまいます。

そこで視点を変えて「投稿した日付」ではなく「投稿していない日付」に着目しました。

直近の投稿していない日付を取得できれば、その日付の1つ前までは連続して投稿されていることになります。この方法だと、直近の投稿していない日付を1つ取得するだけであり、検査もElasticsearch上で完結するため、メモリ消費量も処理時間も節約できます。

この方法を実装に落とし込んだものが以下の通りです。

  # 連続投稿開始日
def continuous_reviews_started_on(user_id, interval='day')
  return nil unless ['day', 'week', 'month'].include?(interval)

  query = filter_by_user_id(user_id).filter_by_type(::Photo.sources[:snap]).filter_parent_only

  latest_review_date = query.order(created_at: :desc).first&.created_at&.to_date
  return nil if latest_review_date.nil?

  today = Date.today
  case interval
  when 'day'
    return nil unless latest_review_date == today           # 当日
  when 'week'
    return nil unless latest_review_date > today - 1.week   # 1週間以内
  when 'month'
    return nil unless latest_review_date > today - 1.month  # 1ヶ月以内
  end

  bucket = query.aggs(
    created_at_dh: {
      date_histogram: {
        field: :created_at,
        calendar_interval: interval,
        time_zone: '+09:00',
        order: { _key: 'desc' },
      },
      aggs: {
        top: {
          bucket_sort: {
            sort: [
              { _count: { order: 'asc' } },
            ],
            size: 1
          }
        },
      }
    },
  ).aggs.dig('created_at_dh', 'buckets', 0)
  return nil if bucket.blank?

  target_date = nil
  if bucket['doc_count'] == 0
    date_str = Date.parse(bucket['key_as_string'])
    case interval
    when 'day'
      target_date = date_str + 1.day    # x日間連続投稿開始日
    when 'week'
      target_date = date_str + 1.week   # x週間連続投稿開始日
    when 'month'
      target_date = date_str + 1.month  # xヶ月間連続投稿開始日
    end
  else
    target_date = query.order(created_at: :asc).first&.created_at&.to_date
  end
  target_date
end
 # 連続投稿日数/週数/月数
def continuous_reviews_count(interval='day')
  return 0 unless ['day', 'week', 'month'].include?(interval)

  continuous_reviews_started_on = PhotosIndex.continuous_reviews_started_on(self.id, interval)
  return 0 if continuous_reviews_started_on.nil?

  today = Date.today
  case interval
  when 'day'
    TimeUtil.diff_days(today, continuous_reviews_started_on) + 1    # x日間連続投稿件数
  when 'week'
    TimeUtil.diff_weeks(today, continuous_reviews_started_on) + 1   # x週間連続投稿件数
  when 'month'
    TimeUtil.diff_months(today, continuous_reviews_started_on) + 1  # xヶ月間連続投稿件数
  end
end

ElasticsearchのDate histogram aggregation を利用して日/週/月の期間(interval)ごとに口コミ投稿件数を集計します。その結果に対してさらに最新順にbucket sortを行うことで直近の投稿していない(doc = 0 の)日付を取得しています。

ActiveSupport::Concernによる関心事の分離

ユーザーバッジ機能は集計処理や新規バッジの検知など複雑な処理が多い分、網羅的なテストが望まれます。ユーザーバッジ機能の実装では初期の段階から特にテストを意識して、コードの関心事の分離を行いました。

具体的には、RailsのActiveSupport::Concernを利用して、以下のように関心事を分離しました。

  • /models
    • parent_badge.rb (親バッジ: 「初回いいね」「毎日連続投稿」など)
    • badge.rb (子バッジ: 「銅15」「金300」など。親バッジに属する)
    • user.rb
  • /concerns
    • /parent_badge
      • new_badge_dialog.rb (新規獲得バッジ)
      • status.rb (子バッジの取得状況)
    • /user
      • badge_aggregator.rb (バッジ集計処理)
      • read_badge_manager.rb (バッジの未読既読管理)

これにより各クラスの可読性や再利用性、拡張性などが向上するだけでなく、テストすべき対象も限定されるためテストが書きやすくなりました。

RSpec.describe User, type: :model do
  let!(:user) { create(:user) }

  ...

  context '::ReadBadgeManager' do
    describe '#is_read?' do
      ...
    end

    describe '#read' do
      ...
    end

    describe '#unread' do
      ...
    end
    ...
  end

  context '::BadgeAggregator' do
    describe '#first_review_badge' do
      ...
    end
    ...
  end

  ...

end

おわりに

今回のユーザーバッジ機能のサーバーサイド実装では、Elasticsearchを活用してバッジ集計処理を実現しました。Elasticsearchは便利である一方、リクエストが集中した際に処理速度が低下したこともあり、負荷対策も念入りに行う必要があると感じました。

ユーザーバッジは今後も機能を拡張していく予定ですので、どうぞよろしくお願いします。