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

管理画面を開発する際に気をつけたこと

クックパッド編集室の加々美です。
現在、食や暮らしのトレンドを発信するメディアであるクックパッドニュースの開発に携わっています。
「総合職で入社した新卒がクックパッドでエンジニアになるまで」 というエントリを投稿した2015新卒の土谷と同様に、2014年に新卒として入社後、総合職から研修を経てエンジニアへと転向しました。

今回は、クックパッドニュースの管理機能の改善を行う際に注意した点についてお話します。

自分がその管理ツールを使う人になる

事業体制の変化もあり、現状のクックパッドニュースの管理画面に関して、いくつかの運用上の問題点が指摘されており、その改善を行いました。

管理画面改善の進め方としては
「現状の業務フローの把握」「問題点の把握」「理想の管理画面の設計」
という基本的な手順で取り組みました。

現状把握と問題点洗い出しの方法としてまず思いつくのはヒアリング中心で進めていく方法ですが、あまり枠にはまらず柔軟な対応の多いルーチンワークだったり、各業務がスタッフ間で細分化されていると、ヒアリングだけで業務フローの全てを理解して問題点を整理するのは時間がかかり、消耗しがちです。

そこでオススメの方法は、自分自身が、管理画面を使用する人と全く同じ作業をすることです。※

単純ではありますが、現状把握のためには、この方法が最も正確かつ素早いと思いました。
また、画面が分かりづらかったり、遅かったりして苛立たしいという感情も味わえる上に、エンジニアとして一気通貫した業務を行うことで、現場のスタッフでは出てこないような問題点や改善点を発見することもできます。

現状の業務フローを定義できた時点で、ようやく適切な問題点の洗い出しができるようになり、用意すべき機能が見えてきます。

※具体的には、記事を発注されるライターの業務である、ニュース記事の企画・執筆、編集スタッフとの校正のやり取り。
そして、編集スタッフの業務である、編集者の朝会や編集会議への参加、校正業務や記事の配信設定の業務等を行いました。
記事執筆に関しては、総合職時代にライティングの業務を行っていたことがあったためここまで踏み込んで行うことができました。

現場の意見は再定義して、絵か動くもので改善策を説明する

現場の方から出てくる意見として、例えば
「ここにこのような数字を表示する窓を足して欲しい」「配信設定画面はエクセルのような使用感にしてほしい」
などというものがあります。

もちろん、意見を拾う段階ではこのような言葉にも耳を傾ける必要はありますが、字義通りに受け取るのではなく、「何を、なぜ達成したくて、何が原因でそれができないのか」という意見を相手から引き出して、エンジニア側で問題点と解決策を再定義する必要があります。
例えば「エクセルっぽい使用感」という言葉の裏に隠れている欲求は、実は配信時間枠を固定にせず自由に追加さえできれば達成されるものなのかもしれません。

また、改善後の画面や機能を言葉で説明するのはミスコミュニケーションを引き起こしがちなので、ある程度作りこんだペーパーモックや、似たような動きをするアプリを見せるほうがより正確に伝わり、作業の手戻りも減らせます。 動きの説明をしたり意見をもらいたいときは、切り絵でコミュニケーションをとるのもスムーズでした。

f:id:fkagami:20151127221706p:plain

管理画面もパフォーマンスに気を遣う

管理画面は、運用段階では基本的に非エンジニアが操作することとなり、多少読み込み遅くなってもそのまま放置され、報告されないということがままあります。
管理画面の速度は、運用メンバーの業務効率に直接響く部分であるので、パフォーマンスには十分気を遣いたいところです。

パフォーマンス改善施策の1つとして、N+1クエリ潰しがあります。
N+1クエリとは、あるデータと、その関連データを合わせて取得する際に発生しがちなクエリのことで、例えば、一覧画面に表示するためのデータを取得するために1度クエリが投げられ、そこで取得したデータN個分の関連データ取得のためのクエリが投げられてしまうようなケースを指します。

管理画面は、運用する中で参照したいデータを増やすことが多く、このN+1クエリが発生することがしばしばあります。N+1問題はデータが増えるほど深刻化するので、積極的に対策しておきたい問題です。

この対策には、N+1クエリが発生した時にアラートを出してくれるGemのbulletが便利です。

アラートが出たページではN+1クエリが発生しているので
Controller側で、関連データを予め読み込んでおく(Eager Loading)ようにしましょう。
N+1クエリが検出された時点で潰すようにしておく体制を作っておき、こまめに将来の負債を潰しておくことが大切です。

最後に、Railsに用意されているincludesメソッドなどの、Eager Loading機能ではカバーしきれない、よくありそうな大量のクエリが発生しまうケースに遭遇したので、サンプルコードと共に紹介したいと思います。

下のような、7日間×24時間のタイムライン上に時間枠がセットされており
その時間枠内に記事の配信設定する、というケースです。(分かりやすくするために、変数名や実装は簡略化しています)

f:id:fkagami:20151127221323p:plain

改善前

下の実装では、View上で、ある時間に設定されているArticleモデルを取得していますが
この実装だと7×24のクエリが発行されることとなります。

Controller

  def index
    @monday = Time.parse(params[:date]) rescue Time.now.beginning_of_week
    @articles = Articles.active
  end

View

- (0..6).each do |date|
  %table
    %thead
      %tr
        %th タイトル
    %tbody
      - (0..23).each do |hour|
        - target_time = monday + date.days + hour.hours

         # このループの中で時間を取得し、取得した時間内に設定をされている記事を取得する
        - target_articles = @articles.where(
            published_at: (target_time).at_beginning_of_hour..(target_time).at_end_of_hour
          )

        = render partial: "article_row", locals: {
                                                    target_articles: target_articles,
                                                    target_time: target_time,
                                                    hour: hour
                                                 }

改善後

コントローラー側に
{< Articleのpublished_at > => < Articleのid >}
{< Articleのid > => < Article Object >}
のハッシュを用意しておいて、View上で時間を取得して、その時間に配信設定されているArticleのidを取得、更にそのidのArticleオブジェクトを取得するという実装に変更します。

これにより、SQLに発行されるクエリは、24×7個から2個になります。

Controller

@monday = Time.parse(params[:date]) rescue Time.now.beginning_of_week

target_articles = Article.group("DATE_FORMAT(`published_at`, '%Y-%m-%d %H:%i')").
                    select("id","DATE_FORMAT(`published_at`, '%Y-%m-%d %H:%i') AS date").
                    where(published_at: @monday..(@monday + 1.week))

@date_id_hash = Hash[target_aricles.map{|a| [a.date, a.id]}]

# @id_obj_hash = Hash[Article.where(id: @date_id_hash.values).map{|a| [a.id, a] }]と同義
@id_obj_hash = Article.where(id: @date_id_hash.values).index_by(&:id)

Helper

  def target_articles(date_id_hash, id_obj_hash, target_time)
    # viewで取得した時間を元に、1時間の枠内に配信設定されているArticleのidを取得する
    target_ids = Hash[@date_id_hash.find_all {
                |k,v| Time.parse(k) >= target_time && Time.parse(k) < target_time + 1.hour
              }].values

    # 上で取得したArticle idからArticleオブジェクトを取得する
    target_articles = target_ids.map { |id| @id_obj_hash[id] }
    target_articles
  end

View

- (0..6).each do |date|
  %table
    %thead
      %tr
        %th
        %th タイトル
    %tbody
      - [*(0..23)].each do |hour|
        - target_time = monday + date.days + hour.hours

        = render partial: "article_row", locals: {
                                                    target_articles: target_articles(
                                                      @date_id_hash,
                                                      @id_obj_hash,
                                                      target_time
                                                    ),
                                                    target_time: target_time,
                                                    hour: hour
                                                 }

この変更で、読み込み時間は約80%改善しました。

まとめ

管理画面の開発においても、当事者意識を持つのが大切という点では、ユーザー向けのサービス開発と変わらないと言えるかもしれません。
管理画面はあくまでバックヤードなので、あまり時間をかけられない部分かも知れませんが、これから管理画面を作る方や、改善したい方の一助になれば幸いです。

/* */ @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;*/ /*}*/