シーンページをリリースしました ~ DRY原則を意識した既存システムへの新機能導入 ~

「デート」や「食べ歩き」などのシーン別に施設一覧が閲覧できるシーンページをリリースしました。 今回は既存システムにシーンページを導入する上で、DRY原則を意識して実施した3つの事例(サイトマップの生成ロジック改修・Nuxt.jsのルーティングのカスタマイズ・ビット演算の活用)についてご紹介します。

こんにちは。サーバーサイド(フロントエンドもやっている)エンジニアのネゴロです。

今年のゴールデンウィークはいかがでしたでしょうか。

私は埼玉観光で、着物で小江戸川越の蔵造りの町並みを堪能した後、大宮にある鉄道博物館に行きました。

鉄道博物館には鉄道に関する事前知識皆無で行きましたが、迫力ある車両の数々が展示されている様子は壮観で感動でした。また、予約してJR線のバーチャル運転体験をしたのですが、順番待ちの列が自分以外全員ちびっ子で少し恥ずかしかったです。運転体験自体はとても楽しかったです。

そんなゴールデンウィーク前に、実は「シーンページ」という新機能をリリースしました。

今回は既存システムにシーンページを導入する上で発生する冗長性などの課題に対して、DRY原則を意識して実施した3つの取り組み(サイトマップの生成ロジック改修・Nuxt.jsのルーティングのカスタマイズ・ビット演算の活用)について紹介します。

シーンページとは

シーンページとは、「渋谷 x デート」など、シーン(デート、食べ歩き、ランチ、etc…)別に施設一覧を表示するページです。

すでに「渋谷 x カフェ」など、ジャンル(カフェ、中華料理、カレー、etc…)別に施設一覧を表示するページは存在していて、シーンページリリース後は「渋谷 x カフェ x デート」など、エリア x ジャンル x シーン でよりユーザーのニーズに添った施設一覧ページの提供を目指しています。

シーンページは現在、aumoのメインドメイン()とサブドメイン(グルメ/ホテル/レジャー)で展開しており、今回は私が主に実装に関わったサブドメイン側についてお話しします。

シーンページリリース前後を比較して、サブドメイン(グルメ/ホテル/レジャー)における全体のページ数は一気増えることに伴い、開発側でもコードの冗長性の排除など様々な対応が必要になりました。今回はその中で以下の3つを紹介します。

  • Rubyのブロック記法を用いたサイトマップの生成
  • Nuxt.jsのルーティングで同一vueファイルを使い回せるようにextendRoutesプロパティを活用
  • シーン表示可否の判別ためのビット演算の活用

Rubyのブロック記法を用いたサイトマップの生成

まずはシーンページ追加分のパスをサイトマップの登録することに伴い、サイトマップの生成ロジックに拡張性を持たせる改修を行いました。

aumoではサイトマップの生成で、Rubyの sitemap_generator という gemを利用しています。

sitemap_generatorでは、sitemap.rbファイルにサイトマップ生成に関する処理を記述し、以下を実行することで、サイトマップの圧縮ファイルが生成されます。(e.g. sitemap.xml.gz)

ruby sitemap.rb

例えば、「地域・都道府県・エリア」のパスをサイトマップに登録したい場合を考えます。

登録するパスは以下の通りです。

/regions/:id (地域 e.g. 「関東」)
/prefectures/:id (都道府県 e.g. 「東京」)
/areas/:id (エリア e.g. 「中目黒」)

これらのパスをサイトマップに追加する場合、以下のように記述できます。

Region.find_each do |region|
    add "regions/#{region.id}", priority: 1.0, changefreq: "daily", lastmod: region.updated_at
end
Prefecture.find_each do |prefecture|
    add "prefectures/#{prefecture.id}", priority: 1.0, changefreq: "daily", lastmod: prefecture.updated_at
end
Area.find_each do |area|
    add "areas/#{area.id}", priority: 1.0, changefreq: "daily", lastmod: area.updated_at
end

ただ、このようにfind_eachでサイトマップを登録していく方法の場合、URLのパス部分が第2階層、第3階層…となっていくと、コードが冗長になっていきます。

例えば、第2階層が「カテゴリー」のパスを登録したい場合、以下のようになります。

Category.find_each do |category|
      add "categories/#{category.id}", priority: 1.0, changefreq: "daily", lastmod: large.updated_at
end
Region.find_each do |region|
    add "regions/#{region.id}", priority: 1.0, changefreq: "daily", lastmod: region.updated_at
    Category.find_each do |category|
      add "regions/#{region.id}/categories/#{category.id}", priority: 1.0, changefreq: "daily", lastmod: category.updated_at
    end
end
Prefecture.find_each do |prefecture|
    add "prefectures/#{prefecture.id}", priority: 1.0, changefreq: "daily", lastmod: prefecture.updated_at
    Category.find_each do |category|
      add "prefectures/#{prefecture.id}/categories/#{category.id}", priority: 1.0, changefreq: "daily", lastmod: category.updated_at
    end
end
Area.find_each do |area|
    add "areas/#{area.id}", priority: 1.0, changefreq: "daily", lastmod: area.updated_at
    Category.find_each do |category|
      add "areas/#{area.id}/categories/#{category.id}", priority: 1.0, changefreq: "daily", lastmod: category.updated_at
    end
end

ご覧の通り以下のCategoryのfind_eachのコードが冗長になってしまいます。またfind_eachで毎回レコードを取得しているので、処理に時間がかかります。

Category.find_each do |category|
     ...
end

この例ぐらいの規模でしたら耐えられるのですが、/regions/:id/categories/:id/:scenes と言ったように第三階層のパスまで網羅するとなるとsitemap.rbのコード量が増大して辛いです。

さらに、aumoではサブドメイン(グルメ/ホテル/レジャー)ごとにサイトマップを生成しており、サブドメインの数だけ同じようなコードを記述する必要があります。

そこでRubyのブロック記法を活用して、この冗長さに対応しました。

具体的なサンプルコードは以下の通りです。

#
# ネストしたパスの生成にも対応するクラス
#
class ::NestablePathGenerator
  class SitemapElement
    attr_reader :id, :updated_at

    def initialize(id, updated_at)
      @id         = id
      @updated_at = updated_at
    end
  end

  def initialize(&add_func)
    @add_func = add_func
  end

  def add_locations_path(locations=[], &add_nested_path)
    allowed_locations = %w[regions prefectures areas]
    locations.select {|l| allowed_locations.include?(l) }.each do |location|
      _elements_by(location).each do |ele|
        l_path = "#{location}/#{ele.id}"
        @add_func.call(l_path, ele.updated_at)
        add_nested_path&.call(l_path)
      end
    end
  end

  def add_categories_path(path = nil, &add_nested_path)
    _elements_by('categories').each do |category|
      c_path = _join_path(path, "categories/#{category.id}")
      @add_func.call(c_path, category.updated_at)
      add_nested_path&.call(c_path)
    end
  end

  def add_scenes_path(path = nil, &add_nested_path)
    _elements_by('scenes').each do |scene|
      s_path = _join_path(path, "scenes/#{scene.id}")
      @add_func.call(s_path, scene.updated_at)
      add_nested_path&.call(s_path)
    end
  end

  ...

  private def _join_path(target_path, added_path)
    target_path.blank? ? added_path : "#{target_path}/#{added_path}"
  end

  #
  # サイトマップ生成に必要なオブジェクトを初期化
  #
  private def _elements_by(attr_name)
    case attr_name
    when 'regions'
      return @regions                   ||= _fetch_sitemap_elements(MasterRegion)
    when 'prefectures'
      return @prefectures               ||= _fetch_sitemap_elements(MasterPrefecture)
    when 'areas'
      return @areas                     ||= _fetch_sitemap_elements(MasterArea)
    when 'categories'
      return @categories                ||= _fetch_sitemap_elements(Category)
    when 'scenes'
      return @scenes                    ||= _fetch_sitemap_elements(Scene)
    ...
    else
      raise "attr_nameが不正な値です: #{attr_name}"
    end
  end

  #
  # メモリ消費量を抑えるためActive Recordオブジェクトを
  # サイトマップ生成に必要なカラムのみを保持したクラス(SitemapElement)に変換
  #
  private def _fetch_sitemap_elements(class_name)
    class_name.pluck(:id, :updated_at).map{|arr| SitemapElement.new(arr[0], arr[1]) }
  end
end

nestable_path_generator = ::NestablePathGenerator.new do |path, lastmod|
                            add path, priority: 1.0, changefreq: "daily", lastmod: lastmod
                          end

...

NestablePathGeneratorクラス内で定義されているadd_xxx_pathメソッドで、ネストしたパスの生成処理をブロックとして受け取り可能にしています。

これによりURLのパスがネストした場合でも、以下のように簡潔にサイトマップの生成処理が記述できるようになりました。

# サイトマップ生成

nestable_path_generator.add_categories_path                                                                # e.g. /categories/:id
nestable_path_generator.add_scenes_path                                                                     # e.g. /scenes/:id
nestable_path_generator.add_locations_path(%w[regions prefectures areas]) do |l_path|  # e.g. /(regions|prefectures|areas)/:id
  nestable_path_generator.add_categories_path(l_path)                                                   # e.g. /(regions|prefectures|areas)/:id/categories/:id
  nestable_path_generator.add_scenes_path(l_path)                                                        # e.g. /(regions|prefectures|areas)/:id/scenes/:id
...
end

ネストしたいパスのdo ~ endのブロック内にadd_xxx_pathメソッドを記述していくため、find_eachに比べ直感的にどのパスがネストされているのかが理解しやすくなったと思います。

Nuxt.jsのルーティングで同一vueファイルを使い回せるようにextendRoutesプロパティを活用

次はフロントエンド側(Vue.js/Nuxt.js)の実装に関して、Nuxt.jsのルーティングで同一のvueファイルを使い回せるようにした事例を紹介します。

シーンページの追加に伴い、フロントエンド側のルーティング周りでも冗長性排除の工夫が必要になりました。

まずNuxt.jsのルーティングの設定方法では、Railsのroutes.rbのようなルーティング用のファイルはなく、pagesディレクトリのファイルの木構造に沿って、自動的にvue-routerの設定が生成されるようになっています。

例えば以下のようなpagesディレクトリの木構造の場合、

pages/
--| regions/
-----| _regionId/
--------| index.vue

自動生成されるvue-routerの設定は以下のとおりです。

router: {
  routes: [
    {
      name: 'regions-regionId',
      path: '/regions/:regionId',
      component: 'pages/regions/_regionId/index.vue'
    }
  ]
}

ファイルを配置するだけで自動的にルーティングが設定されるのは便利である一方、アクセスパスは異なるがvueファイル自体は同一といったケースでは、同一vueファイルをpagesディレクトリ内に重複して配置しなければならないといった問題が発生します。

例えば検索系(Search)のパスの場合、pagesディレクトリ以下に配置されるvueファイルの内容は以下のような実装を使い回しています。

<template lang="pug">
  section
    div(v-if="$isHotelDomain()")
      HotelSearch
    div(v-if="$isGourmetDomain()")
      GourmetSearch
    div(v-if="$isLeisureDomain()")
      LeisureSearch
</template>

<script>
import HotelSearch   from '@/components/template/hotels/Search'
import GourmetSearch from '@/components/template/gourmets/Search'
import LeisureSearch from '@/components/template/leisures/Search'

export default {
  name: 'xxxId',

  layout: 'search',

  components: {
    HotelSearch,
    GourmetSearch,
    LeisureSearch
  },

  async fetch ({ app, route }) {
    return await Promise.all([
      app.$categorySearch({ route })
    ])
  }
}
</script>

仮に以下のようなpagesディレクトリの木構造の場合、上記のファイル(index.vue)を3回も重複して配置しなければなりません。

pages/
--| regions/
-----| _regionId/
--------| index.vue
-----------| _scenes/
--------------| index.vue
-----| _scenes/
--------| index.vue

この問題に対処するため、nuxt.config.jsファイルのextendRoutesプロパティを利用しました。

extendRoutesプロパティではNuxt.jsのルーティングをカスタマイズすることができます。

const extendRoutesSetting = require('./extend_routes_setting.js')
...
router: {
    ...
    extendRoutes(routes, resolve) {
      const routesSetting = extendRoutesSetting(resolve)
      routes.push(...routesSetting)
    },
  },

requireしているextend_routes_setting.jsの中身のサンプルは以下の通りです。

module.exports = function (resolve) {
  const routes = getSearchRoutes(resolve, routeMappings, 'pages/search', ['index'])
  return routes
}

function getSearchRoutes(resolve, mappings, folder, pages = []) {
  let result = []
  if (pages.length > 0) {
    pages.forEach((file) => {
      mappings.forEach((e) => {
        result.push({
          name: file === 'index' ? e.name : `${e.name}-${file}`,
          path: file === 'index' ? e.path : `${e.path}/${file}`,
          component: resolve(__dirname, `${folder}/index.vue`)
        })
      })
    })
  }
  return result
}

const routeMappings = [
  {
    name: 'regions-regionId',
    path: '/regions/:regionId'
  },
  {
    name: 'scenes-sceneId',
    path: '/scenes/:sceneId'
  },
  {
    name: 'regions-regionId-scenes-sceneId',
    path: '/regions/:regionId/scenes/:sceneId'
  },
  ...
]

getSearchRoutes関数でextendRoutesプロパティに渡す検索系のルーティング設定を生成しています。

getSearchRoutesには第2引数で検知したいrouteのmappings、第3,4引数には使いまわしたいvueファイルの場所(ディレクトリ名,ファイル名)を渡しています。

これにより、mappings内のpathにアクセスがあった場合は、指定したvueファイルが参照されるようになり、重複したvueファイルをpagesディレクトリ内にいくつも設置しなければならない問題を解消できました。

シーン表示可否の判別ためのビット演算の活用

最後に、シーンページでのビット演算の活用事例について紹介します。

今回リリースしたシーンページではサブドメイン(グルメ/ホテル/レジャー)毎に表示させるシーンを分けています。

例えば「昼飲み」というシーンにおいて、グルメでは表示するが、ホテル/レジャーでは非表示にするといった感じです。

まずビット演算を使わない方法として、シーンテーブルにサブドメイン(グルメ/ホテル/レジャー)毎の表示・非表示カラムを追加する方法が考えられます。

# Table name: scenes
#
#  id             :bigint           not null, primary key
#  name           :string(255)      not null
#  show_gourmet   :boolean          default(FALSE), not null
#  show_hotel     :boolean          default(FALSE), not null
#  show_leisure   :boolean          default(FALSE), not null

一方で、ビット演算を使うと以下のようにサブドメイン(グルメ/ホテル/レジャー)毎を判別するカラムを1つだけにすることができます。

# Table name: scenes
#
#  id             :bigint           not null, primary key
#  name           :string(255)      not null
#  genre_scope    :integer          unsigned, not null

以下はSceneモデル(Rails)の実装内容の抜粋です。

class Scene < ApplicationRecord
  ...

  GENRE_NONE    = 0 # 00000000
  GENRE_GOURMET = 1 # 00000001
  GENRE_TRAVEL  = 2 # 00000010
  GENRE_LEISURE = 4 # 00000100
  
  scope :has_genre_scope, -> (genre_str) do
    genre_scope =
      case genre_str
      when 'gourmet'
        GENRE_GOURMET
      when 'leisure'
        GENRE_LEISURE
      when 'hotel'
        GENRE_TRAVEL
      else
        raise 'Invalid genre'
      end
    where('genre_scope & ? = ?', genre_scope, genre_scope)
  end

  ...
end

例えば「グルメとホテルは表示、レジャーは非表示」させたいシーンでは、genre_scopeを「3」( = GENRE_GOURMET | GENRE_TRAVEL)に設定します。

サブドメイン(グルメ/ホテル/レジャー)毎に表示可能なシーン一覧を取得する際は、has_genre_scopeを使います。

例えば、グルメで表示可能なシーン一覧を取得するには以下のように記述します。

Scene.has_genre_scope('gourmet')

SQLのwhere句にビット演算も利用できるのは便利ですね。

今回genre_scopeで表現したのはサブドメイン(グルメ/ホテル/レジャー)の3種類のみの組み合わせでしたが、この種類がもっと多くなった場合にこのビット演算の方法の恩恵がより感じられると思いました。

ちなみにシーンのデータ構造にビット演算を導入したのはhorieさんで、私はそのビット演算を用いたシーンの表示・非表示の部分を実装しました。

ビット演算は知識として持っていたものの、実務で活用する機会は今回が初めてだったため勉強になりました。

最後に

シーンページ開発要件は一言で言ってしまえば「新規ページの追加」というシンプルなものですが、施設(お店など)にシーンという新しい概念が加わる以上、既存システムへの影響(変更)範囲は広く、実際に開発する中で考慮すべき点がいくつもありました。その中でもやはりコードの冗長性への対応がメインの取り組みとなりました。

ゴールデンウィークを終え、すでにシーンページでSEOが取れていて、MAUも伸びているということで嬉しいです。今後も新規機能開発はもちろん、DRY原則を意識してコードの変更容易性の向上に取り組んでいきます。