一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

一休.com で 1 年半の間に取り組んできた改善内容について

この記事は一休.comアドベントカレンダー2017の 8 日目です。

一休.com の宿泊開発基盤のお手伝いをしている id:shiba-yanです。

はてなインターン時代の縁で naoya さんから声をかけていただき、基本フリーランスですが一休で週に 3 日ほどの作業を 2016 年 4 月から行っています。

最近は shibayan とも一緒に改善を進めている

4ヶ月の間に一休.comで起きた変化 - zimathon blog

2016 年 4 月末から現在までに、一休社内でどのようなことに取り組んできたか、公開できる範囲で思うままに書いていきます。長いです。

ユニットテスト基盤

これまではテストが書ける状態ではなかったのですが、xUnit.net を利用してユニットテストをコンポーネント単位で書けるような仕組みを用意しました。

しかし、残念ながら上手く回っていません。例外的ですが、複雑化した URL Rewrite ルールに対するユニットテストに関しては、上手く回すことができています。チームメンバーの id:minato128 が LT で話したスライドもあります。

URL Rewrite のテストは勢いでライブラリを作り、それをすぐに実践投入しました。クラウド移行のタイミングではこのテストのおかげで不具合が発覚し、非常に助けられています。

新しいメールテンプレート

長く開発されてきた結果、一つのシステム内に複数のメールテンプレートが実装されていて、それがアプリケーション内に分散している状態でした。

さらに条件によって項目が変わるメールは非常に複雑なテンプレートとして定義されていて、メンテナンスが非常に難しい状態になっていたため、ASP.NET MVC で使われている Razor ベースの新しいテンプレートを作成しました。

しかし、残念ながら利用は広がっていません。既にあるテンプレートを移行することを考えると非常に高コストになり、最近では新しくメールを増やすといったことがほぼないのが理由でした。このあたりはヒアリング不足で作業を進めてしまったのが反省点です。

メール配信基盤

これまで一休ではオンプレミスのメールサーバーを利用して来ましたが、一斉メール送信に時間がかかる問題が顕著になり、さらに保守にかかる手間とコストやクラウド移行という課題が出てきたため、AWS 上に新しく実装することになりました。

実装したメール配信基盤の概要は以下のようになります。

  • SendGrid を利用
  • SQS と Elastic Beanstalk を使った非同期処理
  • メールの配信結果は DynamoDB に格納
  • Webhook は API Gateway と Lambda で処理
  • ASP.NET Core を利用して実装

去年から段階的に一休.com のサービスで利用を始め、今ではほぼ全てのメールが AWS 上のメール配信基盤から送信されるようになっています。メール配信基盤に関しても id:minato128 がセッションを行っているので、そちらも参考にしてください。

今回のアドベントカレンダーでも書かれているので、こちらもどうぞ。

初期は多少 DynamoDB の予約スループットを使い切ってしまうことがありましたが、実装の改善により今では全く発生しなくなっています。SendGrid の障害が一時頻繁に発生した時には悩まされましたが、現在は対策を行い安定した運用を行えています。

宿泊クラウド移行

直近の大きな作業として、一休.com サービス全体のクラウド移行がありました。既に一休.com のサービスは全て AWS に移行が完了しているので、皆さんが見ている www.ikyu.com は AWS の東京リージョンから提供されています。

AWS への移行を行うことでサービス自体の可用性が高まったり、柔軟なスケーリングが可能になったりとクラウドのメリットを享受することが出来ていますが、移行完了までには様々な問題が山積みでした。

単純に宿泊のアプリケーションと言っても、正しく動かすためには関係するアプリケーションも同時に移行する必要があります。例えば、実際に宿泊のアプリケーションでは 4 つのアプリケーションを移行する必要がありました。

  • 宿泊サイト本体(www.ikyu.com)
  • マイページ(my.ikyu.com)
  • 管理画面
  • 外部連携用 API

その他にも内部から利用している API が複数あり、宿泊アプリケーションが AWS へ移行するためには、それらを先に AWS へ持っていく必要があったのです。他にもいろいろあり、担当している宿泊基盤チームだけで合計 8 アプリケーションを AWS に移行することになりました。

いきなり巨大な宿泊サイトから行うのは非効率なので、まずは規模の小さいアプリケーションから地道にノウハウを溜めつつ進めることにしました。

移行方法の調査・検証

まずはオンプレミスで動作しているアプリケーションを、どのように AWS 上で実行するかを検討しました。一番単純かつ時間がかからないのはオンプレと同じ構成を EC2 で作成して、今と同じアプリケーションをデプロイする方法でしたが、開発の現場では日々デプロイの問題に悩まされていて、今の状態のまま AWS に持って行っても悪くなるだけなのは、火を見るよりも明らかでした。

当時のデプロイでの辛さは、これもまた id:minato128 が話したスライドがあります。

デプロイ完全自動化から1年で起きたこと /ikyu-deploy // Speaker Deck

さらに当時採用していた Jenkins を利用したデプロイは、リポジトリ内の変更されたファイルのみを対象としていたため、EC2 に持って行った場合にインスタンスごとにファイルの整合性を担保することが非常に難しいことが分かりました。

その他にもたくさんの課題がありましたが、調査と検証を重ねていった結果として以下のように方針を決定しました。

  • オンプレと同じ仕組みは一切持ち込まない
  • EC2 インスタンスは何時でも破棄が出来るように
    • 差分ではなくアプリケーションに必要なファイル全てをデプロイする
  • ビルドには Jenkins を止めて AppVeyor を利用する
    • 一般的な方法でアプリケーションのビルドが行えるように構造を変える

実際に実行する EC2 インスタンスは Elastic Beanstalk を利用して、インスタンスの生成と破棄やデプロイなどを全て任せることにしました。最初は多少デプロイが非効率になったとしても、まずはオンプレ構成からの脱却が重要だと考えました。

実行環境の調査・検証

アプリケーションは Elastic Beanstalk を使って実行する方向に舵を切りましたが、宿泊アプリケーションでは一部に特殊なコンポーネントを利用しているため、事前に開発環境でデプロイ用のパッケージをビルドして、問題なく動作が可能かを検証しました。

当初は ebextensions を利用してインストールを試しましたが、Cloud Formation の実行権限が特殊で上手くいかなかったため、カスタム AMI を作成して運用する方向に決めました。

  • Windows Server 2012 R2 を利用(当時は 2016 がリリースされていなかったため)
  • CircleCI を利用して AMI を自動で生成
    • Windows Update は新しい AMI を作成して対応

出来るだけ手動で行う部分を減らして、運用時の負荷を下げるように工夫しました。CircleCI を使った新しい AMI のビルドは 20 分以内で完了するので、Beanstalk に設定すれば EC2 が自動的に作り直されて作業は完了します。

f:id:shiba-yan:20171206164320p:plain

今回はカスタム AMI を使わざるを得ない状況でしたが、結果的にプロビジョニング時間の短縮に繋がりました。

アプリケーションの分離と整理

一休.com のアプリケーションは ASP.NET で実装されていますが、歴史的経緯から先ほど述べた 4 つのアプリケーションが 1 つのアプリケーションとしてマージされた形で実装されていました。しかし、プロジェクトファイルは別々に存在していたので、このままでは MSBuild を使ってデプロイ用の資材をビルドすることも難しい状態でした。

まずは一つになっているアプリケーションを、役割ごとに分離する作業は必須だと考えました。実際のアプリケーション単位で分離することができれば、後は普通の ASP.NET アプリケーションと同様の方法が使えるからです。

当時は分離が本当に必要なのかという意見が何回か出てきましたが、移行を担当したチーム内では必要な作業だという認識があり、理由の周知を行い対応しました。

f:id:shiba-yan:20171206170722p:plain

まずは Qiita に DesignDoc を作成してフィードバックを貰い、内容に問題が無ければ周知用のエントリとして仕上げるという作業を繰り返し行いました。

実際の作業を行ったのは私なので、その時に方向性を以下のように定めました。要するに普通の ASP.NET アプリケーションに組み替える作業です。

  • 1 つの巨大なアプリケーションを 4 つに分ける
    • 適切な粒度で分離
    • アプリケーション毎にソリューションを用意してフルビルドが簡単に行えるように
  • 参照がアセンブリ直指定になっていた部分をすべて修正
    • アプリケーションの一部であればプロジェクト参照
    • NuGet で配布されている場合はパッケージ参照

実際に開発が日々行われているリポジトリに対して、非常に大きな変更を行うことは開発チーム全体への影響が大きいため、事前のリハーサルを何度も行いビルドやデプロイに影響が出ないように努力しました。

どのくらいの規模だったかは、GitHub の Insights が物語ってくれています。

f:id:shiba-yan:20171207145456p:plain:w400

各アプリケーションを順番に分離していき、作業が完了するまでに 2,3 ヵ月が必要でした。しかしこの分離作業が完了すれば、クラウド移行に必要な作業の 9 割は完了すると考えていたので、あえて時間をかけて丁寧に作業しました。

AppVeyor での CI / CD

アプリケーションの分離を行った結果、フルビルドやデプロイ用パッケージの作成は MSBuild を 1 回実行するだけで完了するようになり、大幅に処理をシンプルにすることが出来ました。

これまでの Jenkins を利用したデプロイでは、環境に依存する Web.config や App.config などの設定ファイルをスクリプトでコピーするような形になっていましたが、その方法を止めて全て Xml Document Transform を使ったビルド時の自動切り替えを利用するようにしました。

Web.config Transformation Syntax for Web Project Deployment Using Visual Studio

よくある Web.Release.config などと同じ方法で、ステージングや本番といった単位で変換ファイルを用意しました。環境ごとにデプロイパッケージを作成する場合も、環境名*1を MSBuild のパラメータに指定するだけで済みます。

ここまでの変更で AppVeyor でも簡単にデプロイパッケージが作れるようになりました。

f:id:shiba-yan:20171206164933p:plain

実際に今では 1 日に数多くのビルドを行い、Elastic Beanstalk へのデプロイを実行しています。

少しビルドとデプロイに時間がかかってしまっているのが課題としてありますが、今後のアプリケーション改善によって短縮を見込んでいます。

本番環境の移行

本番は規模の小さいアプリケーションから順に移行していきましたが、メインとなる宿泊アプリケーションに関してはリクエストが多く、移行作業での障害が発生した場合には多大な影響が発生します。そのため、アプリケーションの移行準備が完了してからの 1 ヵ月ほどは動作検証と負荷テストに時間を割り当てました。

基本的な動作検証には、既に利用している Selenium ベースの E2E テストを利用して実行しました。他にも運用担当者にお願いをして、AWS 上のアプリケーションで普段と同じ業務を試してもらうといった方法で進めていきました。

負荷テストはインスタンスサイズの大きな EC2 を用意し、そこから JMeter を使って特に重要なページと API に関して負荷をかけ、必要なインスタンスサイズと台数を決定しました。この辺りはテストの鬼 id:akasakas がほぼやってくれました。

www.ikyu.com は AWS 移行の前に Fastly への全面的な移行を完了していたため、本番環境を AWS 上に用意してしまえば、後は Fastly 側でバックエンドを切り替えるだけで移行が完了します。

f:id:shiba-yan:20171207122035p:plain

何か意図しない挙動が発生した場合には即座にオンプレに戻すと決めていましたが、そういったことは発生することなく、極めて平和に終わった本番移行でした。

その後の運用

準備と検証をしっかりと行ったため、クラウド移行は特に大きな問題もなく、スムーズにほぼ予定通りに完了することが出来ました。事前の負荷テストによって決定したサイジングもほぼ想定通りでした。

今回上げた内容以外にも、クラウド向けにアプリケーション側で最適な形となるように作業を行った結果、サービス提供に必要なマシン台数も大幅に減らしつつ、可用性を高めることが出来ています。ぱっと思いつくだけでも以下のようなメリットが、AWS への移行で得られました。

  • オートスケーリンググループによる柔軟なリソースの割り当て
  • 不良インスタンスは自動的に破棄、再生成
  • Web サーバーのメンテナンスがほぼ不要に
    • Datadog と New Relic のメトリックを見るぐらいに
  • ホスト OS に対する更新をイミュータブルに実行
    • ローリングアップデートを自動で行い、ヘルスチェックが通らない場合は自動的に元に戻される
  • デプロイが原因となる障害発生なし

半分は Elastic Beanstalk を利用したことによって得られています。一休社内では Elastic Beanstalk を多用していて、今 naoya さんが中心になって進めている、新レストランサービスでも Elastic Beanstalk と Docker が利用されています。

オンプレ時代にデプロイの課題を解決するために作られた Slack チャンネルがアーカイブされたのも、クラウド移行に伴ってデプロイに起因する問題が解消されたことの表れでもあります。

f:id:shiba-yan:20171206165825p:plain

今回、移行には非常に長い時間がかかってしまいましたが、単純に移行するのではなく最適化した形で持って行ったことで、数多くの課題が同時に解決されたと考えています。

移行が完了して 1 ヵ月後に打ち上げを行い、クラウド移行での思い出を語り合いました。

f:id:shiba-yan:20171207144934g:plain

内容と全く関係はありませんが、赤坂には良いレストランが数多くあり、一休.comレストランを使えば簡単に予約することができます。実際に打ち上げ会場は一休レストランで予約しました。

まだまだ積み残した課題は多いですが、なかなか経験できない重要な作業に参加することが出来て、非常に勉強になりましたし楽しかったです。

宿泊アーキテクチャの改善

元々は宿泊アプリケーションのアーキテクチャ改善のために誘われていたのですが、クラウド移行が完了したので最近になってようやく本格的に取り掛かれるようになりました。

クラウド移行のタイミングでプロジェクト間の依存関係を徹底的に整理した結果、Visual Studio と ReSharper を使った機械的な解析が行えるようになりました。現在は naoya さんからの助言もありコードリーディングを深くまで行い、問題点をしっかりと理解してまとめる作業をしています。

まだ始まったばかりですが、アーキテクチャを改善し開発効率だけではなく、それに伴ってパフォーマンスの向上までを目標としています。

終わりに

f:id:shiba-yan:20171207145258p:plain

明日は juri-t さんによる「最近流行りのword2vecをLDAと比較してみた」です。

*1:Production / Staging など

GoとSQL Server

この記事は、[一休.comアドベントカレンダー2017]の7日目です。

qiita.com

こんにちは、データサイエンス部・大西 id:ohke です。
ユーザの行動収集基盤や、マーケティング施策の実行を支援するシステムの開発・メンテナンスを担当しています。

7日目の本投稿では、GoでSQL Serverを使う方法について、紹介したいと思います。

なぜGoとSQL Serverなのか

メジャーじゃない組み合わせだと思いますので、なぜGoからSQL Serverを使うことになったのか、背景を補足します。

今年に入り、一休ではデータウェアハウス基盤をクラウド環境に構築しました。 この基盤では、リアルタイムな行動ログを含む、マーケティングに必要なデータを全てSQL Sever(Amazon RDS)に集約しています。

この基盤を使った施策の一貫として、ユーザのリアルタイムな行動を分析し、今一休に訪れているユーザへ1 to 1マーケティングを検討しています。 具体的には、サイトやアプリでのメッセージの通知などです。

ブラウザ、フロントエンドサーバ、アプリなど、様々なアプリケーションからアクセスされるため、Web APIで共通したインタフェースを提供するのがセオリーですが、今いるユーザを対象とした施策においてはPV数に比例したアクセスが予想されます。 こうした高負荷に耐えるための環境として、行動ログの収集でも実績があるGoをAPIサーバとして構えることになりました。
行動ログ収集の取り組みについて興味のある方は、こちらもご覧ください(ちなみに、当時はSQL Serverではなく、Azure SQL Data Warehouseをデータウェアハウスとして採用してました。変更された経緯については、 12/20投稿予定の「データ分析基盤、その後 id:sisijumi 」で触れます)。

2017-08-17-_DataAnalyticsPlatform.pdf // Speaker Deck

また、可能な限りリアルタイムな行動ログを使う(目標タイムラグは1分以内)ため、データマートなどを介在させずに、Goで動くAPIサーバからSQL Serverに直接アクセスする必要があり、今回の調査に至りました。

生のSQLを実行する

Goでは、database/sqlという標準ライブラリがSQL(-like)なインタフェースを提供しています。 DB製品ごとのドライバと組み合わせることで、DBへ直接SQLを実行できるようになります。

SQL Serverのドライバとして、go-mssqldbgofreetdsの2つがあります。

  • go-mssqldbはGo単体で実装されていますが、gofreetdsはcgo(GoからCのコードをコンパイルしたり、CのライブラリをリンクできるようになるGoのビルド機能)を使ってます
  • メジャーなのは、go-mssqldbのようです(GitHubスター比較)

今回はgo-mssqldbについて解説していきます。

https://github.com/denisenkom/go-mssqldb

go-mssqldb

パッケージをダウンロードしておきます。

> go get github.com/denisenkom/go-mssqldb

まずは接続です。ポイントは3点です。

  • go-mssqldbをブランクインポート(_)して、SQL Serverのドライバで初期化します
    • これでdatabase/sqlのインタフェースでSQL Serverにアクセスできるようになります
  • 接続文字列は3パターンで記述できますが、パスワードに;(セミコロン)を含む場合は2番目か3番目を選択する必要があります
    • server=testdb.ikyu.jp;user id=ohke;password=p@ssw0rd;database=TestDB
    • odbc:server=testdb.ikyu.jp;user id=ohke;password=p@ssw0rd;database=TestDB
      • パスワードに;を含む場合は、password={p@ss;word}のように{}で括る
    • sqlserver://ohke:p@ssw0rd@testdb.ikyu.jp?database=TestDB
      • パスワードに;を含む場合は、p@ss%3Bw0rdのようにURLエンコードする
  • sql.Open()の第1引数に"sqlserver"または"mssql"を指定して接続します
    • 2つは基本的に同じですが、クエリパラメータの渡し方に違いがあります(後述)
package main

import (
    "database/sql"
    
    _ "github.com/denisenkom/go-mssqldb"
)

func main() {
    connectionString := "sqlserver://ohke:p@ssw0rd@testdb.ikyu.jp?database=TestDB"

    // 接続
    // "sqlserver"の代わりに"mssql"でもOK
    connection, err := sql.Open("sqlserver", connectionString)
    if err != nil {
        return nil, err
    }
    
    // 切断
    defer connection.Close()

    // CRUD処理を記述
    // ...
}

接続すれば、database/sqlのインタフェースに則って、SQLを実行できます。

まずはselectです。

  • 1行のselectならQueryRow、複数行のselectならQueryと使い分けます
  • ドライバに"sqlserver"を指定した場合、@で始まるパラメータ名をSQLに埋め込み、sql.NamedArg構造体でパラメータに渡す値を設定します
    • "mssql"で指定した場合、?nをSQLに埋め込み、2つ目以降の引数で?nに渡す値を設定します(?1ならば2番目の引数、?2なら3番目の引数の値が渡されます)
   // select(1行)
    row := connection.QueryRow(`
      select name, registered_at, valid from members
          where member_id = @member_id`,
        sql.NamedArg{Name: "member_id", Value: 1})
    // ドライバに"mssql"を指定した場合
    // row := connection.QueryRow(`select name, registered_at, valid from members where member_id = ?1`, 1)

    var name string
    var registeredAt time.Time
    var valid bool
    if err := row.Scan(&name, &registeredAt, &valid); err != nil {
        return
    }
    fmt.Println(name, registeredAt, valid)

    // select(複数行)
    rows, err := connection.Query(`select name from members`)
    if err != nil {
        return
    }

    defer rows.Close()
    for rows.Next() {
        if err := rows.Scan(&name); err != nil {
            return
        }
        fmt.Println(name)
    }

続いて更新処理です。

  • 更新(insert、update、delete)はExecメソッドで実行します
  • Resultオブジェクトが返されます
    • RowsAffected()で、処理された行数を取得できます
    • LastInsertId()で、挿入時のidentityの主キー値が取得できます
      • 設定されていない場合は-1
   // insert
    if result, err := connection.Exec(`
      insert into members (member_id, name, registered_at, valid)
          values (@member_id, @name, @registered_at, @valid)`,
        sql.NamedArg{Name: "member_id", Value: 1},
        sql.NamedArg{Name: "name", Value: "onishik"},
        sql.NamedArg{Name: "registered_at", Value: time.Now()},
        sql.NamedArg{Name: "valid", Value: true}); err == nil {
        insertedNumber, _ := result.RowsAffected()
        insertedId, _ := result.LastInsertId()
        fmt.Println(insertedNumber, insertedId)
    }
 
    // update
    connection.Exec(`
      update members set valid = @valid
          where member_id = @member_id`,
        sql.NamedArg{Name: "valid", Value: false},
        sql.NamedArg{Name: "member_id", Value: 1})

    // delete
    connection.Exec(`
      delete members where member_id = @member_id`,
        sql.NamedArg{Name: "member_id", Value: 1})

ORMでアクセスする

ORMを使う方法もあります。

Goでは、gormxormgorpなど幾つか選択肢がありますが、一番メジャー(GitHubスター比較)で、かつ、SQL Serverにも対応しているgormに触れていきます。

https://github.com/jinzhu/gorm

gorm

パッケージをダウンロードしておきます。

> go get github.com/jinzhu/gorm

まずは接続です。

  • gormに加えて、gorm/dialects/mssqlをブランクインポートします
    • gorm/dialects/mssqlでSQL Server独自の型(bit型など)や処理(SET IDENTITY_INSERTなど)が吸収しています
    • 内部的にはgo-mssqldbをドライバとして使っています
  • 構造体とテーブルレコードがマッピングされます
    • デフォルトではActiveRecordやEntityFrameworkと類似した名前のマッピングが行われます(もちろん変更できます)
      • "構造体名+s"がテーブル名にマッピングされます(Member→members)
      • キャメルケースはスネークケースに変換してマッピングされます(MemberID→member_id)
  • gorm.Open()で接続しますが、第1引数は"mssql"とします
    • ちなみに"sqlserver"は不可です
package main

import (
    "fmt"
    "time"

    "github.com/jinzhu/gorm"

    _ "github.com/jinzhu/gorm/dialects/mssql"
)

// レコードの定義
type Member struct {
    MemberID     int       `gorm:"primary_key"`
    Name         string    `gorm:"type:nvarchar(256);name:name"`
    RegisteredAt time.Time `gorm:"type:datetime2;name:registered_at"`
    Valid        bool      `gorm:"type:bit;name:valid"`
}

func main() {
    connectionString := "sqlserver://ohke:p@ssw0rd@testdb.ikyu.jp?database=TestDB"

    // 接続
    db, err := gorm.Open("mssql", connectionString)
    if err != nil {
        panic(err.Error())
    }

    // 切断
    defer db.Close()

    // CRUD処理を記述
    // ...
}

CRUDも概観してみましょう。 いずれも上で取得したDBオブジェクトを使います。

  • 1行のselectであればFirst()、複数行のselectではFind()を使います
  • DBオブジェクトを返すので、FindしてDelete、といった処理も書きやすいです
   // select(1件)
    var member Member
    db.First(&member, 1)

    fmt.Println(member.Name, member.RegisteredAt, member.Valid)

    // select(複数行)
    members := []Member{}
    db.Find(&members, "valid=?", true)

    for _, m := range members {
        fmt.Println(m.Name, m.RegisteredAt, m.Valid)
    }

    // insert
    insertedMember := Member{MemberID: 2, Name: "akasakas", RegisteredAt: time.Now(), Valid: true}
    db.Create(&insertedMember)

    // update
    member.Valid = false
    db.Save(&member)

    // delete
    db.Delete(&member)

    // DBオブジェクトを返すのでメソッドチェーンで繋げることもできます
    db.Find(&members, "valid=?", true).Delete(&members)

ここでは紹介しませんでしたが、リレーション定義やマイグレーション等の一般的な機能も提供されています。

おわりに

本投稿では、GoからSQL Serverにアクセスする方法として、生のSQLを実行する方法(go-mssqldb)とORMを使う方法(gorm)を紹介しました。

明日は id:shiba-yan さんによる「一休.com で 1 年半の間に取り組んできた改善内容について」です。

Solr JSON Facetのススメ

この記事は一休.comアドベントカレンダー2017の6日目です。

一休.comレストラン 検索・集客担当のにがうりです。

一休.com、一休.comレストランともに、検索には主にSolrを利用しています。 (一部、RDBで検索しているところもあります)
RDB(SQL)ベースでの検索と比べると色々とメリットがありますが、その中でもファセットナビゲーションに必要な機能が揃っているのは大きな魅力と言えるでしょう。

ファセット例

f:id:ikyu_com:20171204014502p:plain:w300 f:id:ikyu_com:20171204014901p:plain:w250

Solr5.xからは、旧来のファセットとは異なるJSON Facetという機能が新たに提供されており、特に問題(後述の注意点を参照)が無いのであれば、こちらのほうが利用しやすいでしょう。
しかし、JSON FacetはSolrのサイト上では言及がなく、開発者のサイトがドキュメントになっている状況のためか、いまいちマイナーな存在に留まっているように感じます。
このエントリでは JSON Facetについて、旧来のファセットとの比較を混ぜながら、基本的な使い方、応用例、注意点について紹介します。
なお、本エントリで利用しているバージョンはエントリ作成時点の最新版、7.1.0を前提としています。

基本的な使い方

レストランを登録した ikyu-advent-2017-restaurant コアに対し、以下のようなデータが入っているとします

レストランID
(restaurant_id)
レストラン名
(restaurant_name)
ジャンル1
(genres)
サブジャンル1
(sub_genres)
ジャンル2
(genres)
サブジャンル2
(sub_genres)
都道府県
(prefecture)
市区町村
(city)
11AAAA洋食洋食-フレンチ東京都銀座
12BBBB和食和食-京料理和食和食-懐石料理東京都赤坂
13CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座
14DDDDその他その他-ラウンジその他その他-ブッフェ東京都品川
15EEEE和食和食-寿司東京都銀座
16FFFF和食和食-寿司和食和食-天ぷら東京都銀座
17GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜

※ 以下3点に留意

  • 都道府県 - 市区町村は親子関係であること
  • ジャンル - サブジャンルも親子関係であること
  • さらに、ジャンル-サブジャンルはそれぞれ2つ登録可能であること (MultiValueにしている)

試しに、このデータが入った状態のクエリを実行してみましょう http://localhost:8983/solr/ikyu-advent-2017-restaurant/select?echoParams=none&q=*:*&fl=restaurant_id,restaurant_name,genres,sub_genres,prefecture,city&rows=2

結果

{
  "responseHeader": {
    "status": 0,
    "QTime": 0
  },
  "response": {
    "numFound": 7,
    "start": 0,
    "docs": [{
      "restaurant_id": "11",
      "restaurant_name": "AAAA",
      "genres": ["洋食"],
      "sub_genres": ["洋食-フレンチ"],
      "prefecture": "東京都",
      "city": "銀座"
    }, {
      "restaurant_id": "12",
      "restaurant_name": "BBBB",
      "genres": ["和食", "和食"],
      "sub_genres": ["和食-京料理", "和食-懐石料理"],
      "prefecture": "東京都",
      "city": "赤坂"
    }]
  }
}

データが取得できました。ジャンル、サブジャンルは配列で返却されています。

従来のファセットを実行

このデータに対して、従来の方法でファセットを取得してみましょう。
取得対象はジャンル、サブジャンル、都道府県、市区町村の4つです。 (冗長になるためレストラン一覧の取得は抑制)

クエリ

http://localhost:8983/solr/ikyu-advent-2017-restaurant/select?echoParams=none&q=*:*&rows=0&facet=true&facet.field=prefecture&facet.field=city&facet.field=genres&facet.field=sub_genres

結果

{
  "responseHeader": {
    "status": 0,
    "QTime": 0
  },
  "response": {
    "numFound": 7,
    "start": 0,
    "docs": []
  },
  "facet_counts": {
    "facet_queries": {},
    "facet_fields": {
      "prefecture": [
        "東京都", 6,
        "神奈川県", 1
      ],
      "city": [
        "銀座", 4,
        "品川", 1,
        "横浜", 1,
        "赤坂", 1
      ],
      "genres": [
        "和食", 4,
        "その他", 2,
        "洋食", 2
      ],
      "sub_genres": [
        "和食-寿司", 3,
        "その他-ラウンジ", 2,
        "その他-ブッフェ", 1,
        "和食-京料理", 1,
        "和食-天ぷら", 1,
        "和食-懐石料理", 1,
        "洋食-イタリア料理", 1,
        "洋食-ステーキ・グリル料理", 1,
        "洋食-フレンチ", 1
      ]
    },
    "facet_ranges": {},
    "facet_intervals": {},
    "facet_heatmaps": {}
  }
}

取得できているのは良いのですが、大きく2つの問題があります。

  1. "prefecture":["東京都", 6,"神奈川県", 1] のように、1つの配列に対して key1, value1, key2, value2 ... という入り方をしているため、処理がしにくい
  2. 本来親子関係であるべき、都道府県と市区町村の親子関係が判断できない

このうち2についてはジャンル-サブジャンルのように子階層に親階層の情報を付与してあげることで回避可能ですが、1については我慢するしかありません。
しかし、JSON Facetならこの両方が解決できます。

JSON Facetを実行

クエリ

http://localhost:8983/solr/ikyu-advent-2017-restaurant/select?echoParams=none&q=*:*&rows=0&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{city:{type:terms,field:city,limit: -1}}}}&json.facet={genres:{type:terms,field:genres,limit:-1,facet:{sub_genres:{type:terms,field:sub_genres}}}}

※ 都道府県/市区町村のファセット指定を見やすく加工すると以下の通り

json.facet={ 
  prefecture: {           /* レスポンス時の項目名(任意) */
    type: terms,        /* ファセットの単位を値に */
    field: prefecture,  /* ファセットの対象となる項目(都道府県) */
    limit: -1,           /* 全件取得 */
    facet: {
      city: {         /* ここから子階層  */
        type: terms,
        field: city,        /* ファセットの対象となる項目(市区町村) */
        limit: -1
      }
    }
  }
}

結果

{
  "responseHeader":{
    "status":0,
    "QTime":9},
  "response":{"numFound":7,"start":0,"docs":[]
  },
  "facets":{
    "count":7,
    "prefecture":{
      "buckets":[{
          "val":"東京都",
          "count":6,
          "city":{
            "buckets":[
              {"val":"銀座", "count":4},
              {"val":"品川", "count":1},
              {"val":"赤坂", "count":1}]}},
        {
          "val":"神奈川県", "count":1,
          "city":{
            "buckets":[{
                "val":"横浜",
                "count":1}]}}]},
    "genres":{
      "buckets":[{
          "val":"和食",
          "count":4,
          "sub_genres":{
            "buckets":[
              {"val":"和食-寿司",      "count":3},
              {"val":"その他-ラウンジ", "count":1},
              {"val":"和食-京料理",    "count":1},
              {"val":"和食-天ぷら",    "count":1},
              {"val":"和食-懐石料理",  "count":1}]}}
              /**** 〜 以下略 〜 ****/
              ]}}}

ご覧の通り、

  1. {"val":key, "count":value}の組み合わせで表現されているため処理がしやすい
  2. 都道府県と市区町村の親子関係が表現できている

と、見事に前述の問題が解決できています。

ただし、ジャンル - サブジャンルの親子関係については、「和食」の下に「その他」が混在するという、期待とは裏腹な状態になっています。
残念ながら、こちらは親子関係の親がMultiValueになっている限り回避はできません。
従来のファセット同様個別にファセットの指定を行い、アプリケーション側で親子関係を処理する他無さそうです。

応用例

ところで、一休.comレストランはレストランの「プラン」を予約するサイトです。
つまり、予約検索で表示される一覧は「レストラン」単位ですが、実際に検索しているデータはプラン単位です。
そのため、データもレストランではなくプランが軸になります。 (実際には更に日付、時間、人数、席の有無といった軸も考慮する必要がありますが、複雑になるためここでは割愛します)

ikyu-advent-2017-plan コアのデータ

idレストランID
(restaurant_id)
レストラン名
(restaurant_name)
ジャンル1
(genre)
サブジャンル1
(sub_genre)
ジャンル2
(genre)
サブジャンル2
(sub_genre)
都道府県
(prefecture)
市区町村
(city)
プランID
(plan_id)
プラン名
(plan_name)
時間帯
(time)
価格
(price)
個室
(private_room)
夜景確定
(nightview)
飲み放題
(free_flow)
11-110111AAAA洋食洋食-フレンチ東京都銀座1101クリスマスディナーディナー8000110
11-110211AAAA洋食洋食-フレンチ東京都銀座1102クリスマスランチランチ4000100
11-110311AAAA洋食洋食-フレンチ東京都銀座1103アフタヌーンティーランチ2500000
11-110411AAAA洋食洋食-フレンチ東京都銀座1104平日限定スパークリング飲み放題!ディナー4000001
12-120112BBBB和食和食-京料理和食和食-懐石料理東京都赤坂1201おばんざいのセットランチ3000000
12-120212BBBB和食和食-京料理和食和食-懐石料理東京都赤坂1202おまかせコースディナー7000100
12-120312BBBB和食和食-京料理和食和食-懐石料理東京都赤坂1203おまかせコース飲み放題付ディナー9000101
13-130113CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1301【ワンドリンク付】プリフィクスランチランチ3000000
13-130213CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1302極上の短角牛ステーキ300グラム!ランチ4000000
13-130313CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1303【飲み放題付き】選べるパスタ・ステーキを含む6種のディナーディナー8000100
13-130413CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1304【Xmas】乾杯シャンパン付!上州牛の極上ステーキとデザートのセットディナー3000000
14-140114DDDDその他その他-ラウンジその他その他-ブッフェ東京都品川1401ブッフェランチランチ2000000
14-140214DDDDその他その他-ラウンジその他その他-ブッフェ東京都品川1402【忘年会におすすめ!!】50種類から好きに選べるディナーブッフェ!ディナー5000011
15-150115EEEE和食和食-寿司東京都銀座1501握り10貫ディナー7000100
15-150215EEEE和食和食-寿司東京都銀座1502握り8貫。お造り、焼き物付きディナー8500100
16-160116FFFF和食和食-寿司和食和食-天ぷら東京都銀座1601握りと天ぷらのコースディナー5000001
16-160216FFFF和食和食-寿司和食和食-天ぷら東京都銀座1602握りのコースディナー4500001
17-170117GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜1701【夜景確定】クリスマスディナーディナー9000010
17-170217GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜1702クリスマスディナーディナー7000100
17-170317GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜1703平日限定ディナーディナー5000001

このデータに対して、都道府県、市区町村のJSON Facetを実行してみましょう

JSON Facetを実行

クエリ

http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{city:{type:terms,field:city}}}}

結果

{
  "responseHeader":{
    "status":0,
    "QTime":0},
  "response":{"numFound":20,"start":0,"docs":[]
  },
  "facets":{
    "count":20,
    "prefecture":{
      "buckets":[{
          "val":"東京都",
          "count":17,
          "city":{
            "buckets":[{
                "val":"銀座",
                "count":12},
              {
                "val":"赤坂",
                "count":3},
              {
                "val":"品川",
                "count":2}]}},
        {
          "val":"神奈川県",
          "count":3,
          "city":{
            "buckets":[{
                "val":"横浜",
                "count":3}]}}]}}}

これはいけません。1行の単位がプランになった関係で、ファセットの数も「プランの数」になってしまいました。
Result Groupingを使いデータをレストラン単位で表現するようにしましょう

&group=true&group.field=restaurant_id&group.ngroups=true&group.truncate=true

※ Result Groupingについては本稿の主旨とは異なるため説明は割愛します。 エメラルドアオキロックさんのエントリ がオススメ

Result Grouping + JSON Facetを実行

クエリ

http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{city:{type:terms,field:city}}}}&group=true&group.field=restaurant_id&group.truncate=true&group.ngroups=true

group.truncate=trueでファセットもグルーピングの単位で返却、group.ngroups=true でグループ単位の検索件数も返却になります。

結果

{
  "responseHeader":{
    "status":0,
    "QTime":1},
  "grouped":{
    "restaurant_id":{
      "matches":20,
      "ngroups":7,
      "groups":[]}},
  "facets":{
    "count":7,
    "prefecture":{
      "buckets":[{
          "val":"東京都",
          "count":6,
          "city":{
            "buckets":[{
                "val":"銀座",
                "count":4},
              {
                "val":"品川",
                "count":1},
              {
                "val":"赤坂",
                "count":1}]}},
        {
          "val":"神奈川県",
          "count":1,
          "city":{
            "buckets":[{
                "val":"横浜",
                "count":1}]}}]}}}

無事、ファセットの件数がレストラン単位になりました。

プランの情報をJSON Facetで取得

グルーピングはそのままに、プランの情報である夜景確定もファセットで取得してみます

クエリ

http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&json.facet={nightview:{type:terms,field:nightview,limit:-1}}}&group=true&group.field=restaurant_id&group.truncate=true&group.ngroups=true

結果

{
  "responseHeader":{
    "status":0,
    "QTime":5},
  "grouped":{
    "restaurant_id":{
      "matches":20,
      "ngroups":7,
      "groups":[]}},
  "facets":{
    "count":7,
    "private_room":{
      "buckets":[{
          "val":false,
          "count":5},
        {
          "val":true,
          "count":2}]}}}

夜景確定はプラン毎に異なる情報であるにも関わらず、レストランの数が返ってしまいました。このようなケースでは &group.truncate=true では無理があるようです。

レストラン単位のResult Groupingにプランのファセットも思惑どおり追加する方法

クエリ

http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&group=true&group.field=restaurant_id&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{restaurant_count:"unique(restaurant_id)"},city:{type:terms,field:city,facet:{restaurant_count:"unique(restaurant_id)"}}}}}&json.facet={nightview:{type:terms,field:nightview,limit:-1,facet:{restaurant_count:"unique(restaurant_id)"}}}}&group.ngroups=true

&group.truncate=trueを外し、restaurant_count: "unique(restaurant_id)" を追加しています。restaurant_id でユニークを取った数がrestaurant_countとして返却される、という理屈です。

結果

{
  "responseHeader":{
    "status":0,
    "QTime":3},
  "grouped":{
    "restaurant_id":{
      "matches":20,
      "ngroups":7,
      "groups":[]}},
  "facets":{
    "count":20,
    "prefecture":{
      "buckets":[{
          "val":"東京都",
          "count":17,
          "restaurant_count":6},
        {
          "val":"神奈川県",
          "count":3,
          "restaurant_count":1}]},
    "nightview":{
      "buckets":[{
          "val":false,
          "count":17,
          "restaurant_count":7},
        {
          "val":true,
          "count":3,
          "restaurant_count":3}]}}}

これで、都道府県 / 市区町村はレストランの数、夜景確定はtrue / falseそれぞれに「該当するプランを持っているレストランの数」が返却されました。

注意点

値の信頼性について

場合によっては大きな問題を招く可能性があります。

SolrのJSON Facetは必ずしも正確なカウント数を返さない

ただし、Shardingをしていないかぎりは問題ないはずです。

機能の安定性について

公式に言及が無い機能のため安定性が気になるところでしたが、幸い、導入してから1年ほど安定稼働しています

おわりに

以上、いまいちマイナー?なJSON Facetについての紹介でした。

最後に宣伝です。
クリスマスのお店を決め兼ねている方、唐突に忘年会の幹事に指名されてしまった方、是非、一休.comレストランで予約してください。まだ間に合います!

restaurant.ikyu.com

restaurant.ikyu.com

明日は ohke さんによる GoとSQL Server です。

メール配信基盤のモニタリングと障害リカバリーについて

この記事は一休.comアドベントカレンダー2017の5日目です。

宿泊事業部 Platformチーム*1id:minato128 です。今年一休ではクラウド移行に伴い、メール配信の仕組みを大きく変えました。詳しくは一休✕bitFlyer C#をつかったサービス開発の裏側でお話したスライドがこちらにありますので、興味のある方はご覧ください。

新メール配信基盤への移行 /ikyu-mail-platform // Speaker Deck

さて、宿泊予約やレストラン予約のサービスを提供している一休では、メールをユーザーに届けることはとても大切です。特に予約完了メールが届かなかった場合、(メール以外の確認方法もあるとはいえ)予約が取れたことに気づかず、最悪ユーザーが2重に予約をしてしまう可能性もあります。*2 そこでメールを届けるために、どのようにメール配信基盤のモニタリングや障害が起きたときのリカバリーを行っているかを紹介したいと思います。

f:id:minato128:20171204145554p:plain

前提として

現在このようにメール配信を行っています。 また、日次のトランザクションメールは 13~15万通ほどです。

  1. 各アプリケーションから Cloud Queue (SQS) に Message (JSON) を Enqueue する
  2. Worker (Elastic Beanstalk) で Message を処理して SendGrid Web API で送信する
    • 成功したら Message を メールログ(DynamoDB) に保存する
    • n 回エラーになったら Dead Letter Queue (以下 DLQ とします) に入るように設定
  3. SendGrid Event Webhook を AWS Lambda で受けてメールログに送信ステータスを反映する

f:id:minato128:20171201172336p:plain

モニタリング

このようなアラートを設定しています。大抵の場合、Datadog のアラートで何かが起きていることがわかり、Logentries のエラーログで原因がわかります。

  • Datadog
    • Worker の状態異常アラート
    • Queue の Message 遅延アラート
    • DLQ の存在アラート
  • Logentries
    • Worker のアプリケーションエラーアラート

f:id:minato128:20171201172621p:plain

f:id:minato128:20171201172611p:plain

障害リカバリー

SendGrid だけでなくアプリのバグや AWS など障害によってメールが送れなかったとき、復旧時にメールを再送してあげる必要があるので、このような仕組みを用意しています。

Worker で Message が処理できず、DLQ に入ってしまったとき

これはそもそも SendGrid まで届いていないケースで、DLQ から送信用の Queue へ Message を移せるようにしています。Message の移動だけなのでわざわざ用意するほどでもないう気もしますが、障害はいつ起こるかわからないし誰が対応するかもわからないので簡単にできるようにしておいたほうがいいでしょう。

メール DLQ 管理画面

f:id:minato128:20171201172637p:plain

特定条件の Message を再送するとき

これは SendGrid まで届いているけれど送れていないかもしれないケース、もしくは単純に再送したいケースで、送信状態や時間帯で DynamoDB からログを抽出して送信用の Queue へ移せるようにしています。

個別再送

  • 送信履歴検索から本文を参照し、再送ボタンを押すとそのメールだけ再送できる
  • 主にCSチームで使用

f:id:minato128:20171201172712p:plain

一括再送

  • ログを検索して件数とプレビューを表示し、再送ボタンを押すと一括再送できる
  • 大きめの障害のときに使用

f:id:minato128:20171201172729p:plain

その他

送信ステータスが更新されない送信ログの検知

なぜか送信ステータス更新ができていないことが稀にあるため、AWS Lambda で日次実行してログが存在したら通知するようにしています。(SendGrid が不調のときにも発生しますが、正確な原因はまだわかっていません)

f:id:minato128:20171204143658p:plain

施設からユーザー宛のメールがバウンスしたときに、送信失敗お知らせメールを自動送信

※こちらはモニタリングや障害リカバリーとは関係ありませんが、新しいメール配信基盤の運用開始後にわかったこと*3として書きます。おまけと思ってください。

前提として、施設さまや店舗さま(ホテルやレストランなど。以下施設とします。)からユーザーへメールを送信する機能があります。*4

施設管理のメール送信画面 f:id:minato128:20171201172809p:plain

オンプレ時代は Return-Path が施設のアドレスに設定されていたため、施設側がユーザーへのメールが届かなかったときはバウンスメールが返ってきて気づけたのですが、バウンス管理を SendGrid に委譲したためそれができなくなってしまいました。そこで、

  • SendGrid Event Webhook を AWS Lambda で受けてメールログに送信ステータスを反映する

という既存処理のなかに「施設からユーザー宛のメール」かどうかを判定し、送信用の Queue に バウンス通知メッセージを Enqueue するという処理を追加しました。これで送信失敗お知らせメール(バウンスメール相当)を自動送信できるようになりトラブルが減少しました。

f:id:minato128:20171201172823p:plain


明日は @nigauryyy さんの「JSON Facetのススメ」です。

qiita.com

*1:開発効率を上げるための改善やクラウドインフラ構築、全社共通のAPI開発などを行っています。

*2:実際にメールの配信遅延が起こったときにたまに発生します

*3:考慮漏れとも言えますが

*4:ちなみにユーザーのメールアドレスは施設にはわからないようになっています。(ユーザーがメールを返信しない限りは)

一休.comにおけるUI改善の取り組み

こんにちは、宿泊事業本部でサービス開発をしている田中( id:kentana20 )です。

この記事は一休.comアドベントカレンダー2017の4日目です。

今日は弊社が運営しているサービスの1つである一休.comのUI改善に関して

  • どのような体制で開発をしているのか
  • ユーザ体験を向上させるために実施していること

を紹介したいと思います。

UIチームの体制

12/4(月) 現在、一休.comでは

  • PM兼マーケティング: 1名
  • デザイナー: 1名
  • エンジニア: 3名

という体制でUI改善を行っています。

もともとは

  • マーケティング部
  • デザイン部
  • システム開発部

と職種ごとに分かれていた組織でしたが、プロダクト開発をより円滑に、スピーディに行っていくために今春から現在の体制に移行しています。

f:id:kentana20:20171204000610p:plain
開発組織の変遷

このあたりの体制変更については 12/24(日) に予定している id:sisijumi の「開発組織の目的型組織への移行」にてお話があると思いますので、詳細は今回は割愛します。

どのように取り組むタスクを決めているか

大きめの案件については、CEOや事業責任者と議論しながら決めています。

  • 事業の状況
  • 市場環境
  • 競合の取組み

などから、取り組んでいくタスクの大枠が決まります。

一方で、小規模な案件についてはCSやセールスからの改善要望に対して、チームで実施可否と優先順位について意思決定しています。

ユーザ体験向上のために実施していること

プロダクトの改善を精度高く行うために、チームで実施していることをいくつか紹介します。

プロトタイピングとデザインレビュー

今日ではプロトタイピングツールも充実し、企画の段階ではコードを書かずにアイデアをメンバー同士で共有する機会が増えてきていますが、一休でもプロトタイピングを実施して

  • 解決したい課題に対して、適切なUIになっているかをレビューする
  • チームメンバーで完成形に対する認識を揃える

ということを実施しています。

形式としては対面/オフラインでデザインレビューを行い、指摘事項をGitHub Issuesにまとめて議論しながらフィードバックループを回して目指すUIを決めています。

改善するページ/機能によっては、プロトタイピングツールで表現仕切れないケースもあるので、その場合はHTMLモックを作る場合もあります。

誰かが「こういう形で進めよう」と決めたサイクルではないのですが、いまはこのサイクルで改善が回っていて、徐々に精度が出てきているように思います。

リリース前QA

数日で終わるような改善の場合は実施しないこともありますが、チーム内でのQAをリリース前に実施しています。開発中の作業ブランチを デモ用環境 *1 にデプロイしておいて

  • (初回のみ)その改善で実現したいこと、解決したい課題を改めて共有
  • 各自、思い思いにデモ版を触って使用感を確認
  • 改善したほうが良い内容を発表&議論
  • やる/やらないを決めて終了

という流れで実施しています。これは弊社のレストランアプリチームで実施している取組みなのですが、「良さそうだ」と思って宿泊UIチームでも導入しました。

f:id:kentana20:20171204000714p:plain
QAのメモ

機能の規模や重要度によりますが、このQAを2~3回実施してからリリースする、という流れがスタンダードになりつつあります。

  • QA#1: 開発が一通り終わったところで実施
    • プロトタイピング時に決めた仕様が適切か
    • ユーザの課題を解決する改善になっているか
    • 開発した機能にバグ、考慮漏れがないか
  • QA#2: 前回QAでの指摘事項、BugFixを済ませたタイミングで実施
    • 前回QAでの指摘が改善されているか
    • リリースしてよいレベルに仕上がっているか

という形で実施しています。β → RC1 → RC2というイメージですね。

QAに関しては

  • 若干コストをかけすぎでは
  • 早く本番にリリースしてユーザの動きを見たほうが良いのでは

という意見もあり、今後も最適なやり方を模索していきたいと思っています。

ユーザの行動を体験する

一休ではGoogle AnalyticsとBigQueryを使ってユーザの行動ログを分析しています。このログを元に、課題感のあるページや機能に対して実際のユーザがどのような行動をしているかを、実際に体験するということをしています。

例えば、「主要な導線中のとあるページでの離脱が目立つ」という課題があった場合には

  1. 特定ページで離脱しているユーザ1人1人の行動ログを抽出
  2. 抽出した行動ログをもとにユーザの行動を1セッションずつトレースする(実際にサイトを動かして体験する)
  3. それをひたすら繰り返す
  4. ユーザが感じたであろうストレスを考える
  5. ストレスの共通点(離脱の理由)を探す

という流れで解決するべき課題を洗い出します。課題が発見できたら、解決策として改善のアイデアを議論します。

f:id:kentana20:20171204000818p:plain
ユーザの行動ログ

各ユーザのセッションを体験すると

  • 離脱しているユーザの行動を幾つかのグループに分けることで傾向が見えてくる
  • 当初考えていた課題/仮説の裏付けに使える行動を再確認できる

などの効果があり、Google Analyticsのサマリや、KPI/ファネルレポートなどを見るのとは違った課題が見えてくることも少なくありません。

すべての改善で実施しているわけではありませんが、「数字的に課題があるのはわかるけど、何を改善していけばよいかが見えてこない」という場合には有効なアプローチだと考えています。

おわりに

今回は一休.comにおけるUI改善の取り組みについてお話しました。いまのチーム体制になってから約8ヶ月くらいですが、徐々にチームとしての形が出来てきていると思う一方で、改善できる部分はまだまだあるので、社内のほかのチームや社外の事例を参考にUI改善の精度を上げる、開発スピードを上げる取り組みを進めていきたいと思っています。

「こんなやり方をしていて、良い具合です」という事例をご存じの方や知見をお持ちの方はぜひご連絡を! プロダクト開発に関して情報共有しましょう。

一休では、ともに良いサービスをつくっていく仲間(エンジニア/デザイナー/マーケティング)を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください!

明日は @minato128 さんによる「メール配信のモニタリングと障害リカバリーについて」です。お楽しみに!

BeautifulSoup4を使ってスクレイピングしつつ、各メソッドを解説してみる

この記事は一休.comアドベントカレンダー2017の3日目です。

一休.comの開発基盤をやっています akasakas です。

BeautifulSoup4でスクレイピング

スクレイピングでBeautifulSoup4を扱う機会が多いです。 BeautifulSoup4はいろんな便利機能が揃ってますが、自分は全部覚えられないし、使いこなせなません(苦笑)

正直、BeautifulSoup4にあるいくつかのメソッドがそれなりに使えれば、十分スクレイピングできます。

なので、今回はBeautifulSoup4を使い、スクレイピングをして、各種メソッドを紹介します。

スクレイピング対象

一休.com Advent Calendar 2017 - Qiita

やってみること

カレンダーから日付/担当者/タイトルを取得&出力してみます

f:id:akasakas:20171203122132p:plain

結果

day is 1
author is ninjinkun
title is 単純なコードでアプリ内のコンバージョン経路を計測する
----
day is 2
author is ryo511
title is 一休.comのJavaScriptユニットテスト環境
----

...

----
day is 24
author is zimathon
title is 開発組織の目的型組織への移行
----
day is 25
author is ninjinkun
title is 締めます
----

コード

from bs4 import BeautifulSoup
from urllib.request import urlopen

html = urlopen("https://qiita.com/advent-calendar/2017/ikyu")
soup = BeautifulSoup(html, "html.parser")

for advent_calendar_week in soup.tbody:
    for advent_calendar_day in advent_calendar_week.find_all("td", {"class": "adventCalendarCalendar_day"}):
        print(f"day is {advent_calendar_day.p.string}")
        print(f"author is {advent_calendar_day.find_all('div')[0].a.get_text().strip()}")
        print(f"title is {advent_calendar_day.find_all('div')[1].string}") 
        print("----")

1つずつブレイクダウン

下準備:htmlパース

これだけです

from bs4 import BeautifulSoup
from urllib.request import urlopen

html = urlopen("https://qiita.com/advent-calendar/2017/ikyu")
soup = BeautifulSoup(html, "html.parser")

soupオブジェクトにガツっと結果が入ってます。 このオブジェクトから日付・担当者・タイトルをピックアップします。

カレンダーを取得し、ループで回す

for advent_calendar_week in soup.tbody:
    for advent_calendar_day in advent_calendar_week.find_all("td", {"class": "adventCalendarCalendar_day"}):

soup.tbody でtbodyを取得しています。 この中にある <td class="adventCalendarCalendar_day"> が欲しいので、そこから、さらに find_all("td", {"class": "adventCalendarCalendar_day"})<td class="adventCalendarCalendar_day"> を全部ピックアップして、ループで回してます。

日付と担当者とタイトル

        print(f"day is {advent_calendar_day.p.string}")
        print(f"author is {advent_calendar_day.find_all('div')[0].a.get_text().strip()}")
        print(f"title is {advent_calendar_day.find_all('div')[1].string}") 

pタグに日付があるので、 p.string で日付が取得できます divタグの1つ目が担当者、2つ目がタイトルとなります。

個人的に感じたポイント

必要なデータは一括で取得

soup.{tag} で必要なデータは一括で取得できます

find_allで必要な情報だけうまく取得する

divタグの特定クラスをまとめて取得したい場合にfind_allが便利です。

上の例だと

find_all("td", {"class": "adventCalendarCalendar_day"})

で、tdタグの特定クラスだけをまとめて取得しています。


別法

下のリストから日付/担当者/タイトルを取得&出力してみます

f:id:akasakas:20171203122252p:plain

結果

day is 12 / 1
author is ninjinkun
title is 単純なコードでアプリ内のコンバージョン経路を計測する
----
day is 12 / 2
author is ryo511
title is 一休.comのJavaScriptユニットテスト環境
----

...

----
day is 12 / 24
author is zimathon
title is 開発組織の目的型組織への移行
----
day is 12 / 25
author is ninjinkun
title is 締めます
----

コード

from bs4 import BeautifulSoup
from urllib.request import urlopen

html = urlopen("https://qiita.com/advent-calendar/2017/ikyu")
soup = BeautifulSoup(html, "html.parser")

for advent_calendar_day in soup.find_all("div", {"class" : "container"})[4]:
    print(f"day is {advent_calendar_day.find('div', {'class' : 'adventCalendarItem_date'}).string}")
    print(f"author is {advent_calendar_day.a.get_text().strip()}")

    if advent_calendar_day.find('div', {'class' : 'adventCalendarItem_entry'}) is None:
        print(f"title is {advent_calendar_day.find('div', {'class' : 'adventCalendarItem_comment'}).string}")
    else:
        print(f"title is {advent_calendar_day.find('div', {'class' : 'adventCalendarItem_entry'}).get_text()}")
    
    print("----")

1つずつブレイクダウン

リストを取得し、ループで回す

for advent_calendar_day in soup.find_all("div", {"class" : "container"})[4]:

divタグの class="container" の中で、下のリストのオブジェクトを取得し、1行ずつ回しています。

投稿済みと未投稿の分類

以下のように、投稿済みと未投稿でタイトルのDOMが少々異なります。

投稿済み

<div class="adventCalendarItem_commentWrapper">
    <div class="adventCalendarItem_entry">
        <a data-confirm="Are you sure to follow a link to this website?
http://user-first.ikyu.com/entry/singleton-tracking" href="http://user-first.ikyu.com/entry/singleton-tracking" target="_blank">単純なコードでアプリ内のコンバージョン経路を計測する 
            <i class="fa fa-external-link"></i>
        </a>
    </div>
</div>

未投稿

<div class="adventCalendarItem_commentWrapper">
    <div class="adventCalendarItem_comment">BeautifulSoup4を実際に使ってみつつ、各メソッドを解説してみる</div>
</div>

<div class="adventCalendarItem_entry"> の有無で判定し、タイトルを取得しています。

    if advent_calendar_day.find('div', {'class' : 'adventCalendarItem_entry'}) is None:
        print(f"title is {advent_calendar_day.find('div', {'class' : 'adventCalendarItem_comment'}).string}")
    else:
        print(f"title is {advent_calendar_day.find('div', {'class' : 'adventCalendarItem_entry'}).get_text()}")

まとめ

BeautifulSoup4を使い、スクレイピングをしてみました。 ここでご紹介した機能はごく一部になりますが、それでも使えれば十分スクレイピングができました。

明日は id:kentana20 さんによる「宿泊サービスにおけるUI改善の取り組み」です。

参考

BeautifulSoup4公式ドキュメント

一休.comのJavaScriptユニットテスト環境

この記事は一休.com Advent Calenrad 2017の2日目です。

宿泊事業本部フロントエンドエンジニアの宇都宮です。

一休.comの宿泊予約サービス(以下、一休)では、以下のようなスタックでWebフロントエンドの開発を行っています。

  • 言語:ES 2017
  • ライブラリ・フレームワーク:古いところはjQuery、新しいところはVue.js
  • ビルドパイプライン:Webpack + Babel

一休では、主要導線のE2Eテストは整備されています*1。一方、フロントエンド(JavaScript)のユニットテストは発展途上といったところです。

本記事では、一休のJSユニットテスト環境の変遷と現状について紹介します。

AVA

2017年4月の時点で、一休のJSユニットテスト環境は以下のような状況でした。

  • テスティングフレームワーク:AVA
  • Babelでビルドされているコード:1,000行程度?
  • テストの数:ほとんどない

AVA は、Babelによるビルド機能を内蔵したテスティングフレームワークです。定番フレームワークMochaに比べて、以下のような特長があります。

  • 暗黙的なグローバルへの依存がない
  • テストが並列に実行される
  • テスト結果の出力がわかりやすい(power-assert)

はじめのうちは、AVAで快適にユニットテストを書いていました。しかし、Babelでビルドされるコードが増えるにつれ、コンパイル時間が問題になり始めました。

一例として、以下のような簡単なJavaScriptコードをテストしてみます。

/**
 * 金額のフォーマットを行う
 * ※Number.prototype.toLocaleString() は古いブラウザでは動かないので正規表現を使っている
 *
 * Usage: money(10000) => '10,000'
 *
 * @param value
 * @returns {string}
 */
export default value => String(value).replace(/([0-9])(?=([0-9]{3})+(?![0-9]))/g, '$1,');

AVAを使うと、テストは以下のように書けます(describeはAVA組み込みの関数ではなく、ava-specライブラリの提供する関数です)。

import { describe } from 'ava-spec';
import money from 'vue/Filters/money';

describe('Money Filter', async (it) => {
  it('金額をフォーマットして返す', async (t) => {
    t.is(money(100), '100');
    t.is(money(1000), '1,000');
    t.is(money(10000), '10,000');
    t.is(money(100000), '100,000');
    t.is(money(1000000), '1,000,000');
  });
});

このテストの実行時間を計測すると、以下のような結果になりました。

$ time npm run ava money.test.js

  √ Money Filter 金額をフォーマットして返す

  1 test passed [12:36:03]


real    0m35.385s
user    0m0.061s
sys     0m0.090s

単純なテストなのに 35秒 もかかっています。実行時間のうち、9割以上を占めるのはコードのコンパイル時間です。AVAは全てのソースコードをビルドしてからテストを実行開始するため、起動が遅くなっています。

AVAのwatchモードを使えばコンパイル時間を減らすことはできますが、起動が遅いという根本的な原因は解決されません。

プリコンパイルによる高速化も検討しましたが、テストの実行手順が複雑化してしまいます。

そもそも、我々がやりたいのはテストを書くことであって、テストの実行環境を最適化することではありません。ということで、AVAからの移行を検討しました。

移行対象の検討

今後Vue.jsを使ったコードが増えることが予想されるため、移行対象の検討に当たっては、Vue.jsとの相性が良いことを念頭に置きました。

参考(1) vue-cli

vue-clivue init webpackすると、以下のスタックでユニットテスト環境が構築されます。

Karmaはブラウザを使ったテストランナーですが、ヘッドレスブラウザのPhantomJSを使うことでCI環境でも実行できるようにしています。

※以前検討を行った時点では、Karma + Mochaのみでしたが、最新のvue-cliでは「Jest」「Karma and Mocha」から選べるようになっています。

参考(2) vue-test-utilsのexample

公式のテストユーティリティvue-test-utilsでは、以下のテスティングフレームワークを使用したexampleを提供しています。

それぞれの実行時間を計測してみました。各exampleで全く同じテストを実行しているわけではないですが、参考にはなると思います。

計測結果は下記で、tapeが最速、AVAは他より倍くらい遅い、ということがわかります。

Runner Time
Jest 10.584s
Mocha 8.008s
tape 6.311s
AVA 21.106s

tapeが最速で、AVAが群を抜いて遅い、という傾向は、vue-unit-test-perf-comparisonとも一致します。

Runner 10 tests 100 tests 1000 tests 5000 tests
tape 2.32s 3.49s 9.28s 38.31s
jest 2.44s 4.50s 21.84s 91.91s
mocha-webpack 2.32s 3.07s 10.79s 38.97s
karma-mocha 7.93s 11.01s 33.30s 119.34s
ava 19.05s 73.44s 625.15s 7161.49s

移行対象の決定

移行対象は、下記のスタックにしました。

Mochaにしたのは、vueコミュニティでは最も広く使われている(vue-cliが使っている)ためです。

Karmaは一応導入しましたが、あまり使っていません。browser-envを使えば、DOMを使っているコードのテストをNode.js上でも実行できるからです。また、Karmaはブラウザの起動コストの分、実行が遅くなるのも懸念点です。

どのくらい改善するか計測する

AVAで35秒かかっていたテストが、どれくらいの実行時間になるか計測してみます。

$ time npm run mocha money.spec.js

 WEBPACK  Compiling...

 WEBPACK  Compiled successfully in 963ms

 MOCHA  Testing...


  Money Filter
    √ 金額をフォーマットして返す


  1 passing (3ms)

 MOCHA  Tests completed successfully


real    0m5.217s
user    0m0.000s
sys     0m0.105s

35秒 => 5秒と、大幅に改善していることがわかります! mocha-webpackを使うことで、テストの実行に必要なファイルだけをビルドしているのが速度の改善に寄与しています。

Mocha移行後のテストコード

さきほどのmoney関数のテストを、Mocha + assertで書くと以下のようになります。

/* global describe, it */
import assert from 'assert';
import money from '@js/vue/Filters/money';

describe('Money Filter', () => {
  it('金額をフォーマットして返す', () => {
    assert.equal(money(100), '100');
    assert.equal(money(1000), '1,000');
    assert.equal(money(10000), '10,000');
    assert.equal(money(100000), '100,000');
    assert.equal(money(1000000), '1,000,000');
  });
});

見ての通り、describe~itという基本的な構造は変わりません。大きな違いは、describeit というグローバル関数が定義されていることが前提になっている点です。この点だけ見るとAVAの方が良いのですが、テストコードの美しさのために開発効率を犠牲にすることはできません…。

今後の展望

ユニットテスト環境の構築については一段落したので、テストケースの増加やカバレッジレポートの定点観測といった、テストの網羅性を高める取り組みを進めていきたいと考えています。

明日はakasakasさんによる「BeautifulSoap4を使ってスクレイピングしつつ、各メソッドを解説してみる」です。

単純なコードでアプリ内のコンバージョン経路を計測する

この記事は一休.comアドベントカレンダー2017の1日目です。

一休レストランiOSアプリを開発しているid:ninjinkunです。

アプリを改善する計画を立てる際に、どこから手を着けたら良いか優先順位を決める必要があります。Eコマースなどコンバージョン(以下CV)がはっきり定義できるアプリの場合には、実際のCVに繋がっている画面とフローを改善するのが近道でしょう。

しかしCVに繋がる経路がどこなのか調べるためには、依存関係があるイベントを計測する必要があり、これは意外と面倒です。導入しているGoogle Analyticsの目標到達プロセスやゴールフロー機能を使えばできるような気がするのですが、設定が複雑かつ結果が出るのに時間がかかるので、残念ながら自分は使いこなせたことがありません*1

f:id:ninjinkun:20171130201906p:plain

計測

しかしプログラマの目で見ると、予約前に表示された画面と予約完了画面を結びつけるのはそんなに難しくなさそうに見えます。そこでとても単純な方法ではありますが、直接的にアプリ内のCV経路を計測するために、アプリに直接トラッキングのためのコードを埋め込みました。

enum CVRoute {
    case search, favorite, reserveHistory, message
}

// 単純なコードというのはそう、シングルトンです
struct CVRouteManager {
      static let sharedManager = CVRouteManager()
      var lastRoute: CVRoute?
}

このシングルトンにそれぞれの画面を表示した際に経路を記録し

class ReserveHistoryViewController: UIViewController {
    func viewDidAppear(animated: Bool) {
        ...
        CVRouteManager.sharedManager.lastRoute = . reserveHistory
        ...
    }
}

予約が行われた際にGoogle Analyticsのカスタムディメンジョンに載せて送信します。

class ReserveCompleteViewController: UIViewController {
    static let LastRouteDimension = 20

    func viewDidAppear(animated: Bool) {
        ...
        // 実際はトラッキング用のクラスに分離しているが、イメージはこんな感じ
        let tracker = GAI.sharedInstance().defaultTracker
       ...
        if let lastRoute = CVRouteManager.sharedManager.lastRoute {
            tracker.set(GAIFields.customDimension(for: LastRouteDimension), value: lastRoute)
        }
        ...
        traker.send(...)
    }
}

結果

製品にこのコードを埋め込んで一週間ほど動かしてみたところ、一休レストランのiOSアプリではお気に入りからのCVが検索に次いで多いという結果が得られました。

このデータとユーザーインタビューを元に、アプリを使ってお店を探すユーザーの行動をイメージしてみると、

  1. お店を決めるためにまず検索
  2. 気になったお店をお気に入りに登録
  3. 1~2を繰り返す
  4. お気に入りに登録した中から、一緒に行く友だちにリンクを共有して相談
  5. お店が決まったら、お気に入りから予約←これが計測したCV前画面

というように、お気に入りを使いながらお店を決めるストーリーを想像することができます。そして最終的にはCV数の多さが決め手になり、優先度を上げてこのストーリーを検証、強化するという意志決定を行うことができました。

おわりに

以上、単純なコードで手軽に経路計測を行う方法でした。Google AnalyticsやFirebase等の計測サービスでもっと簡単にできる方法をご存知でしたら、ぜひ教えてください。

最後に唐突に宣伝ですが、クリスマスや忘年会のご予約に、一休レストランアプリをぜひご利用ください。

明日は @ryo511 さんによる「Vue.jsのユニットテストについて」です。

*1:アプリの場合はWebと違い、タブやモーダルビューの存在により、複数の画面を並行に操作して、最後は購入などのCVに到達するということが起こります。このため、トラッキングサービスが出してくるイベントの前後関係を素直に信じられないという問題もあります