一休.com Developers Blog

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

一休.comレストランのスマートフォン検索ページがSPAになりました

一休.com レストランは今年の 7 月 18 日、スマートフォン向け検索ページのリニューアルを行いました。このエントリーでは、その中身について少し紹介させていただきます。

検索ページの課題

一休.com レストランではスマートフォン向け検索ページに対して「遅い」という課題意識がありました。これは技術面で少しブレイクダウンすると;

  • パーソナライズドを含む複雑な処理を行っているため、サーバーサイド処理が重い。
  • UI 上無駄な遅延処理を行っているため、クライアントサイドの描画が遅い。

というサーバー側とクライアント側両方の課題がありました。クライアントサイドの「無駄な遅延処理」というのは;

  • 検索結果取得が REST API 化されているにも関わず、再検索の度にページリロードを行い、サーバーサイドの描画からやり直している。

という実装に問題がありました。下図がリニューアル前のページ描画の様子です:

f:id:supercalifragilisticexpiali:20180924215006p:plain
画面描画後に動的な検索結果が遅延描画されている図

社内では UI 上のこの問題点に対して課題意識が大きく;

「検索と再検索の回遊性を改善したい」

という要求が強くなっていました。

Web フロントエンドのコンポーネント化

話は変わり、一休.com レストランは古い技術セットの上に構築されています。Microsoft の Classic ASP と VBScript を主体としたアプリケーションで永らく運営されてきました。その一方でユーザー向け web ページの表現力を向上させて行きたいという要求から、Vue.js によるフロントエンド実装も徐々に始まっていました。

Vue.js は SPA のような高い表現力を簡単な記述で実現できるのが魅力です。従来の web 開発では;

  • HTML
  • CSS
  • JavaScript

という ファイルタイプによる縦割り開発 が主流でした。しかし;

  • BEM や ECSS のようなCSS フレームワークの提唱する思想、
  • Vue.js のような JavaScript フレームワークのメカニズム、
  • Web Components による標準化の流れ

から明らかなように、UI 改善において コンポーネントを中心に串刺し設計する事の重要性 が認知されてきています。

一休.com レストランでは、あらゆるフロントエンド実装を Vue.js に移行する事で Smart UI パターンのような開発形態から、コンポーネント化された web アプリケーションへ移行する事を技術的な目標としています。

実現すればコンポーネント指向の持つユーザーメリットを享受できるようになり、一休の掲げる「ユーザーファースト」を後押しする開発体制が実現できると考えています。

SEO とサーバーサイドレンダリング

しかしここで問題がありました。SEO の考慮です。一休.com レストランの高い集客力は SEO に真摯に取り組んできた結果でもあります。

この SEO の観点から;

  • ページ上のあらゆる重要なコンテンツは、サーバーサイドレンダリングされていなければならない。

という要求がありました。これは、クライアントサイドレンダリングを基本とする Vue.js でコンポーネント化された web アプリケーションへ移行する目論見と衝突しました。

  • コンポーネント指向開発を実現する事。
  • 重要なコンテンツはサーバーサイドレンダリングされる事。

この 2 つが「ユーザーファースト」を目指す上で必要な技術要件でした。

尚、今年の Google I/O 2018 の Google Webmasters からのアナウンスで状況は変わりつつありますが、リニューアルの意思決定から開発の段階では、クライアントサイドレンダリングで良しと言える状況ではありませんでした。

業務課題と技術課題の合致

このような背景から、コンポーネント指向とサーバーサイドレンダリングという 2 つ の技術課題の解決手段として、サーバーサイド JavaScript の導入が現実味を帯びてきました。Vue.js においてはユニバーサル JavaScript を実現するフレームワーク Nuxt.js が存在しており、これによるプロトタイプを社内で進めるようになります。

結果として、一休.com レストランの技術スタックの理想像は下図のようになって行きました:

f:id:supercalifragilisticexpiali:20180830215511p:plain
フロントエンド構成のヴィジョン

これなら業務課題である検索ページの UI 改善と、技術課題を解決できます。最終的に、一休.com レストランの検索ページリニューアルで、Nuxt.js の導入を決断しました。

次の項では Nuxt.js で実装が開始された検索ページの設計を紹介します。


コンポーネント指向設計

検索ページリニューアルでは、全面的にコンポーネント指向設計を導入しました。

コンポーネントの定義

一休.com レストランの web フロントエンドでは、コンポーネントを下図のように捉え定義しました:

f:id:supercalifragilisticexpiali:20180924222349p:plain
コンポーネントの定義

  • データ、テンプレート、ロジック、スタイル、それぞれ関連性が深いもの同士をモジュール化
  • ファイルタイプによる縦割りではなく、関連性によるファイルタイプ横断の串刺しでグループ化
  • フロントエンド実装のあらゆるアセットをコンポーネントと捉えて管理

上記を基本とし、CSS、JavaScript、画像、Vue 単一ファイルコンポーネントなど全ての分割粒度として、共通のコンポーネントという概念を前提としました。

ITCSS によるレイヤードアーキテクチャ

これらコンポーネントを共通のレイヤードアーキテクチャで管理するため、一休.com レストランでは CSS アーキテクチャの 1 つである ITCSS を採用しました。

f:id:supercalifragilisticexpiali:20180924220842p:plain
ITCSS レイヤードアーキテクチャ

ITCSS 自体は CSS エンジニアである Harry Roberts 氏 が提唱する CSS の詳細度を管理するためのレイヤードアーキテクチャです。

  • CSS でコンポーネント指向設計を実践する事を前提にしたアーキテクチャである点、
  • 抽象度を管理するレイヤードアーキテクチャとして柔軟である点、

これらの点から web フロントエンドのコンポーネント抽象度化レイヤーとして自然に捉え直す事ができます。このアーキテクチャを利用し、以下のようにレイヤーごとの責務を定義しました:

レイヤー 定義
Settings CSS 変数や、定数などのデータを扱う。
Tools CSS ミックスイン、フィルター、またはバリューオブジェクト、DTO のようなアプリケーション上の型となる定義を扱う。
Generic CSS 要素型セレクターによるグローバルスタイル定義、アプリケーション全体で共通化された処理、グローバルな副作用を持つビジネスロジックを扱う。
Elements Atoms のようなプリミティヴなコンポーネントを扱う。これ以下のレイヤーで Vue.js SFC を扱う。
Objects Molecules のようなアプリケーション上のコンテキストを含むコンポーネントを扱う。
Components Organisms のようなアプリケーション上意味のある機能単位のコンポーネントを扱う。

上図のように Elements レイヤーより Atomic Design のレイヤー概念も取り入れています。そもそもとして、下図のような Atomic Design によるレイヤードアーキテクチャも検討しました。

f:id:supercalifragilisticexpiali:20180924220846p:plain
Atomic Design レイヤードアーキテクチャ

しかし;

  • Atomic Design における Atoms レイヤーはグローバルなデータやビジネスロジックなどを表現するレイヤーとしてはスコープが広くなり過ぎる。
  • 一方でこれらの表現に向いていそうなクリーンアーキテクチャでは、UI コンポーネントの抽象化を表現するには表現力が低い。
  • ITCSS はその点で、グローバルなデータやビジネスロジックを表現するレイヤーと UI コンポーネントを表現するレイヤーが最初から定義されておりバランスが良い。

と考え ITCSS を採用しました。

パフォーマンスの観点

ITCSS は CSS 詳細度を管理するアーキテクチャであり CSS のクライアントパフォーマンスを最大化する目的があります。また web フロントエンドはコンパイラより実装者にパフォーマンス最適化の責務があると考えます。

この観点から Tools と Generic レイヤーでデータ定義とビジネスロジックを扱うレイヤーを分ける事が、オブジェクトにメソッドを生やしてビジネスロジックを実装するのを避ける事につながり、webpack の tree-shaking による最適化を享受しやすい実装を導出するメカニズムになると考えています。

コンポーネント指向の置き換え可能という特性を、パフォーマンスの観点を持ったレイヤードアーキテクチャで管理する事で DRY を実践しやすく、結果としてハイパフォーマンスなフロントエンドが実現可能なメカニズムとなる事を期待しています。


つづいて Nuxt.js による BFF 実装を進めていく上で難しかった点をいくつか紹介します。

ユニバーサル JavaScript

まず Nuxt.js の最大の特徴は、Vue.js による実装で、サーバーサイドもクライアントサイドも透過的に記述できる事でしょう。これによりコンポーネント指向設計において、サーバーとクライアントという動作環境の違いを、ファイルシステムの違い同様、串刺しにコンポーネントとしてカプセル化できるようになります。これが Nuxt.js の大きなメリットです。

サーバーサイドとクライアントサイド API の違い

しかし同じ JavaScript とは言え、Node.js と web ブラウザの API には違いがあります。Nuxt.js が用意していないインターフェースで、サーバー/クライアントを透過的に扱いたかったのが次の 2 つです:

  • サーバー/クライアントでの Cookie インターフェースの違いを吸収する
  • バグレポード Bugsnag のサーバー/クライアントサイドのクライアントを透過的に扱う

これらの実現に、Nuxt.js の modules と plugins 機能を用いました。

ユニバーサル Cookie

Nuxt.js で Cookie を透過的に扱うにあたって UniversalCookie コンポーネントを作成し、サーバー/クライアントサイドでの CRUD 処理を透過的に記述できるインターフェースを用意しました。そしてこれを Nuxt.js の plugins として登録しました。

plugins/cookie.ts:

import * as http from 'http';
import { createUniversalCookie } from '@/components/generic/UniversalCookie';

export default function ({ req, res }: { req: http.IncomingMessage, res: http.ServerResponse }, inject): void {
  const cookie = createUniversalCookie(req, res);
  inject('cookie', cookie);
}

上記のような plugin を登録する事で、Nuxt.js のコンテキスト上では app.$cookie.set(key, value)this.$cookie.get(key) と言った記述で、サーバー/クライント関係なく cookie の読み書きができるようになりました。

ユニバーサル Bugsnag

一休.com レストランではクライアントサイドのバグ検知に Bugsnag を利用しており、ユニバーサル JavaScript 化に当たって、サーバーサイドの JavaScript 処理エラーも Bugsnag へレポートするインターフェースを用意しました。

modules/bugsnag/index.js:

const path = require('path');
const bugsnag = require('bugsnag');

module.exports = function BugsnagModule(moduleOptions) {
  bugsnag.register(moduleOptions.SERVER_API_KEY, {
    appVersion: (process.env.VERSION_SHA1).slice(0, 7),
    autoCaptureSessions: false,
    autoNotify: process.env.NODE_ENV !== 'development',
    releaseStage: process.env.NODE_ENV || 'development',
  });
  this.nuxt.hook('render:setupMiddleware', app => app.use(bugsnag.requestHandler));
  this.nuxt.hook('render:errorMiddleware', app => app.use(bugsnag.errorHandler));
  this.addPlugin({
    src: path.resolve(__dirname, 'plugin.js'),
    options: moduleOptions,
  });
};

modules/bugsnag/plugin.js:

import Vue from 'vue';

export default function (context, inject) {
  const VERSION = (process.env.VERSION_SHA1).slice(0, 7);

  // サーバーサイド Bugsnag
  if (process.server) {
    const bugsnag = require('bugsnag');

    bugsnag.register('<%= options.SERVER_API_KEY %>', {
      appVersion: VERSION,
      autoCaptureSessions: false,
      autoNotify: process.env.NODE_ENV !== 'development',
      releaseStage: process.env.NODE_ENV,
    });

    inject('bugsnag', bugsnag);
  }

  // クライアントサイド Bugsnag
  if (process.client) {
    const bugsnagJs = require('bugsnag-js');
    const bugsnagVue = require('bugsnag-vue');

    const client = bugsnagJs({
      apiKey: '<%= options.CLIENT_API_KEY %>',
      appVersion: VERSION,
      autoCaptureSessions: false,
      autoNotify: process.env.NODE_ENV !== 'development',
      releaseStage: process.env.NODE_ENV,
    });

    client.use(bugsnagVue(Vue));
    inject('bugsnag', client);
  }
}

上記のような module を登録する事で、app.$bugsnag.notify(new Error('...')) ないし this.$bugsnag.notify(new Error('...')) でハンドリングされたエラー処理のレポートを透過的に記述できるようになり、例外もサーバー/クライアントサイド両方を検知できるようにしました。

ただしこれらは Nuxt.js のコンテキストを通じてインターフェースを初期化する必要があるため、 this.$cookiethis.$bugsnag への参照を持つコンポーネントは Nuxt.js 実装に密結合となります。なのでこれら plugins へのアクセスは layouts/pages を通じてのみ行うルールとし、コンポーネントの責務をコントロールしています。

副作用の考慮

クライアント JavaScript をユニバーサル JavaScript に対応する過程にも注意する事がありました。グローバルな初期処理による副作用です。

例えば都度参照ではコストが大きい window.innerWidth のようなグローバルプロパティ値をキャッシュする次のようなモジュールがあります。

windowsize.js:

let windowWidth;
let windowHeight;

function resize() {
  windowWidth = window.innerWidth;
  windowHeight = window.innerHeight;
}

window.addEventListener('resize', resize, false);
window.addEventListener('orientationchange', resize, false);

resize();

export {
  windowWidth,
  windowHeight,
};

このようなモジュールが Nuxt.js の pages コンポーネントで import されるとサーバーエラーとなり、ユーザーには 500 エラーが返る事になります。しかしクライアントサイドに限った JavaScript 実装であれば、ありがちな実装であり、グローバルな副作用を局所化する手段としても合理的です。しかし window オブジェクトのようにクライアント JavaScript にしか存在しない API の暗黙的な参照が発生する上記のようなモジュールをうっかり Nuxt.js で import すればアプリケーションが起動しなくなります。こういった点はユニバーサル JavaScript において煩わしさを感じる点でもあり、副作用の影響を考える上で面白い点でもあると思います。

これを解決する方法としては、あらゆるグローバルな処理を Nuxt.js (Vue.js) のライフサイクルに載せるという方法を取りました。上記の windowsize.js は次のように変更しました。

windowsize.js:

import Vue from 'vue';

export const WindowSize = Vue.extend({
  data() {
    return {
      height: undefined,
      width: undefined,
    };
  },
  created() {
    if (this.$isServer) {
      return;
    }
    window.addEventListener('resize', this.resize, false);
    window.addEventListener('orientationchange', this.resize, false);
    this.resize();
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.resize);
    window.removeEventListener('orientationchange', this.resize);
  },
  methods: {
    resize(): void {
      this.height = window.innerHeight;
      this.width = window.innerWidth;
    },
  },
});

export const windowSize = new WindowSize();

こうする事でクライアントサイドの API に依存するグローバルな処理が記述されたモジュールでも透過的に import できるようになりました。

クライアントサイドのみまたはサーバーサイドのみの実装とは違い、いくつか考慮するべき事はありますが、結果的により堅牢な実装を求められる点がユニバーサル JavaScript の面白さでもあると思います。


リニューアルの成果

リリースされた検索ページでは下図のように不要なリロードを必要としない SPA にリニューアルされました。これによってスマートフォンでの再検索のストレスが軽減されたと考えています。

f:id:supercalifragilisticexpiali:20181004235229g:plain
SPA 化による不要なリロードの無くなった検索ページ

またクライアントサイドのパフォーマンス指標である Speed Index の RUM 値も、リリースを境に改善する事ができました。

f:id:supercalifragilisticexpiali:20181005103148p:plain
RUM-SpeedIndex トラッキング値の変化

このリニューアルを契機に Classic ASP による密結合なアプリケーション実装から、Python をバックエンド、API とし、BFF と Nuxt.js をフロントエンドとする疎結合な開発体制が確立しました。今後はこの開発体制のメリットを最大化しユーザー体験の向上へと繋げていくのが、新フロントエンドの課題です。

モダン・フロントエンドで提供する価値とは

変化の激しい web フロントエンドですが、昨年は PWA の推進や AMP の登場、パフォーマンス指標の定量化など、めまぐるしい年だったように思います。一休.com レストランの web サイトは、これら技術を最大限活用し、ユーザーにとってより良い web サービスを提供してゆきたいと考えています。

今後の web フロントエンドの取り組みとしては;

  • 継続的なパフォーマンス改善
  • PWA 化による web 体験のエンハンスメント
  • ユーザー体験の向上につながるドラスティックな UI 改善

などを考えています。

そんなわけで 一休.com レストランでは、ユニバーサル JavaScript が得意なフルスタックエンジニア、BFF 設計や GraphQL を得意とされる Node.js エンジニア、コンポーネント指向を実践でき Web Components のような標準仕様にも敏感な web フロントエンドエンジニア、デザインシステムや Brad Frost 氏の提唱する Atomic Design へ高い関心をお持ちのデザイナーなど、プロフェッショナリズムにあふれたメンバーを募集しています。ラグジュアリーなサービスを最高のクラフトマンシップで支えてくれる方からのご応募お待ちしております!

www.ikyu.co.jp

www.wantedly.com

以上、CTO 室レストラン担当エンジニアの id:supercalifragilisticexpiali がお伝えしました。

一休.comスマホサイトのパフォーマンス改善(サーバサイドとQAとリリース編)

こんにちは。 一休.comの開発基盤を担当しています、akasakasです。

今回は、一休.comスマートフォンホテルページリニューアルをリリースし、パフォーマンスが改善した話を書きます。

概要編はこちらになります。

user-first.ikyu.co.jp

JavaScriptパフォーマンス改善編はこちらになります。

user-first.ikyu.co.jp

CSS・その他パフォーマンスチューニング編はこちらになります。

user-first.ikyu.co.jp

この記事ではスマートフォンホテルページリニューアルで実施したサーバサイドチューニングについて書きます。

ここでお話しする内容

  • サーバサイドチューニング前後のOverview
  • プロジェクトの大まかなタイムライン
  • ボトルネック洗い出し
  • 対策
    • SQL改善
      • 不足インデックスの設定 => 600msの改善
      • 複雑なselect文をシンプルなselect文に分割し、aync/await で非同期化 => 130msの改善
    • Solr高速化
      • SolrへのリクエストをHttpClientを使って可能な限り async/awaitで非同期にする。=> 20ms程度の改善
      • Solrを5系から6系にバージョンアップ => 100msの改善
      • Solrが動いているEC2インスタンスをc4系からc5系にスケールアップ =>30msの改善
    • JavaScriptを使った遅延取得化 => 100msの改善
    • ここまでやって、速くなった、が、、、
    • アプリケーションをASP.NET MVCに移行 => 250msの改善
      • I/O処理を一か所に集めて、async/awaitで非同期
      • 鮮度が求められないデータはElasticacheでキャッシュ
  • QAとリリース
    • QA
      • chromelessを使った自動テスト
      • 手動&実機テスト
    • リリース
  • まとめ

サーバサイドチューニング前後のOverview

スマートフォンホテルページのサーバサイドの改善前の構成は、以下の図の通りです。

  • ASP.NET Web FormsのWebアプリがページを提供している
  • このWebアプリは、検索API経由で対象ホテルの宿泊プランや部屋の情報を取得している
  • このWebアプリは、宿泊プランや部屋の情報以外の情報は、検索APIを経由せずに直接DBアクセスして取得している

改善後は

  • WebアプリからASP.NET Web FormsからMVCにリプレイス
  • Webアプリは、DBを直接見ない
  • データ取得処理は、検索APIの一か所にまとめてasync/awaitで取得
  • 検索APIの呼び出しもasync/await
  • 鮮度を問わないデータはElasticaheでキャッシュ
改善前 改善後
f:id:akasakas:20180910203004p:plain f:id:akasakas:20180910203015p:plain
  • 検索APIが内部通信ではなくFastly経由になっているのは、Fastlyのキャッシュの仕組みを利用しているためです。

プロジェクトの大まかなタイムライン

  • 4月に、速度改善プロジェクトを本格的に始動
    • この時点の構成は↑の改善前の図
  • 4月の時点で、サーバサイドの処理速度は1週間平均で1500msくらい
  • これを、200ms以下にするのが目標
  • まず、比較的簡単な性能課題を解消することで、6月の時点で500msくらいまで改善
  • その後、全面的な作り直しを実施
  • 7月末にほぼ実装完了し、QA開始
  • 8月中旬に先行リリース、8月末に完全リリース
  • 最終的には、9月初頭の時点で、1週間平均で250msくらい
    • 中央値なら200ms前後。ほぼ目標達成
    • 現時点での構成は↑の改善後の図

ボトルネック洗い出し

まず、どこで時間がかかっているかを、洗い出しました。
ツールは、 Newrelic 。それと、アプリケーションのログも参考にしました。

見つかったボトルネックは、

  • 外部へのI/O処理、つまり、検索APIのデータベースアクセスとSolrへのクエリ
  • ユーザーの操作に応じてフロントエンドでJavaScriptを使って遅延取得すればいい情報をサーバーサイドで取得している。

まずは、一番取り組みやすく効果も見込めるselect文の改善に着手しました。
↑の改善前の図の「表示する情報を取得①」と「表示する情報を取得②」のselect文です。
Newrelicなら、ひとつのリクエストで、どのSQLが平均何回呼ばれ、1回あたりどのくらい時間がかかっているか教えてくれます。
有償版なら、対象のSQL文全体を表示してくれます。

対策

SQL改善

不足インデックスの設定 =>600msの改善

性能改善の基本的なアプローチで、一番簡単かつ効果があるのが、不足インデックスの付与です。
SQL Serverなので、SQL Server Management Studioでselect文を実行し、実行計画を確認すると不足インデックスをサジェストしてくれます。↓のように。

f:id:akasakas:20180910203027p:plain

Newrelicで採取したselect文にパラメータを補完して、本番と同等のデータが入っている開発用データベースでSQLを実行し、不足インデックスを洗い出します。その結果、数本のselect文でインデックス不足が見つかりました。
このインデックス付与で大幅に改善しました。

複雑なselect文をシンプルなselect文に分割し、aync/await で非同期化 => 130msの改善

インデックス付与には大きな効果があったのですが、まだまだ目標には達しません。
「表示する情報を取得②」のselect文はかなり複雑になっており、インデックスでの改善は見込めませんでした。
そこで、この複雑なselect文をデータの多重度がそろうように3分割し、async/awaitで非同期で取得して、ソースコード上で、Joinするようにしました。
ソースコード上でJoin、というとコードが複雑になりそうですが、LinqとObject to Object MapperライブラリであるMapster を活用することで、2,3行で実現できました。
.NET で Object to Object MapperというとAutoMapperがメジャーですが、より高速でよりシンプルに扱えるMapsterを選択しました。
検索APIは、ASP.NET Web APIですが、アクションメソッドにasyncキーワードがついていませんでした。そこで、このタイミングで、すべてのアクションメソッドをasyncにして、非同期処理をかけるようにしました。

Solr高速化

SolrへのリクエストをHttpClientを使って可能な限り async/awaitで非同期にする。=> 20ms程度の改善

検索APIのすべてのアクションメソッドをasyncにしたので、HttpClientを使った外部へのHttpリクエストもasync/awaitで非同期にできるようになったため、非同期にしました。
最初のリクエストの結果が次のリクエストの結果に入力になっている場合は、 非同期にできませんが、それ以外は可能な限り非同期化、です。

Solrを5系から6系にバージョンアップ => 100msの改善

もともとSolrの5系を使っていたので、6系にアップしました。
単純にバージョンを上げたら性能が改善するんじゃないか、という仮説でした。
これに伴ってJavaのバージョンも上がりました。

Solrが動いているEC2インスタンスをc4系からc5系にスケールアップ =>30msの改善

一休のサービスはすべてAWS上で運用されており、SolrもEC2で動いています。
c4系のインスタンスを使っていたのですが、昨年発表されたc5インスタンスに移行しました。
単純にCPUのスペックが上がっているので、cpu intensiveなSolrならある程度恩恵をうけるんじゃないか、という仮説でした。

JavaScriptを使った遅延取得化 => 100msの改善

Below the fold のパーツに対して、画面表示のリクエストでサーバサイドでデータを取得している箇所がありました。
これを、ユーザーの操作に応じてフロントエンドから非同期でデータを取得するようにしました

ここまでやって、速くなった、が、、、

ここまでの改善で、1500msかかっていた処理が、500msくらいまで改善しました。
大幅に改善したのですが、目標はサーバサイドの処理で200msを切る、です。
主に検索APIの改善でここまで高速化しました。
逆に言うと検索APIのこれ以上の雑巾絞りできなさそうです。

アプリケーションをASP.NET MVCに移行 => 250msの改善

あとは、Web Formsで作られているアプリケーションの改善のポイントを見出すしかありません。
ここまでの改善で、async/awaitを積極的に使えば、まだまだ改善が見込めることは明らかでした。
そこで、ホテルページだけASP.NET MVCで全面的に作り直すことにしました。以下のような理由です。

  • 既存のWeb Formsのコードがかなり複雑。この複雑さを前提にして速度改善を進めるのは厳しい。
  • フロントエンド側も、大幅な性能改善のためには全面的に書き直しが必要な状態だった。
  • Web Formsでasync/awaitを使うには特殊な書き方をする必要がある。Web Forms固有の概念や書き方に縛られたくない。

事前に @shibayan が横断的な関心事を処理するMVCの基盤を作ってくれていましたので、これをベースに黙々と作り直しをしました。
既存のコードを読み、動作を確認し、Todoを洗い出し、実装する、というのをぐるぐる繰り返しました。

I/O処理を一か所に集めて、async/awaitで非同期

改善前は、画面に表示する情報を、Web FormsのアプリケーションとWeb APIの検索APIの両方で取得していました。
これをWeb APIの一か所にまとめて、可能な限りasync/awaitを使って非同期にすることで、IO待ちをなるべく発生しないようにしました。

鮮度が求められないデータはElasticacheでキャッシュ

画面に表示する情報には、頻繁に変更されないデータがあります。その中で、データ取得のコストが高いデータはElasticacheでキャッシュするようにしました。

QAとリリース

QA

8割できかがったところで、同じく全面的に作り直しているフロントエンドと結合して、試験をします。
試験は、2段階に分けて行いました。

chromelessを使った自動テスト

今回の速度改善は、機能的には改善前と改善後で変わりません。既存のmasterブランチが正解の挙動です。
そこで、以下のようなスクリプトを作り自動テストを行いました。

  1. 本番のfastlyのアクセスログからリクエストパスを抽出する。
  2. 本番相当のデータが入っているデータベースにつないだmasterブランチとテスト対象のブランチに対して、chromelessで1. のリクエストパスでリクエスト。両者のレスポンスから表示情報を抽出。
  3. 2.の表示情報を突合。差異があったらエラーとする。

これを、2週間くらい毎日繰り返しました。リクエストにばらつきがあったほうが、バグの検出が高まるからです。
また、スクリプトをあまり厳密に作りこみすぎないように注意しました。発生条件が複雑なバグは手で触らないと見つからないだろう、と割り切って、あくまで「正常系のユースケースでちょっと触ったらすぐに見つかりそうなバグ」を見つけるためのツールとして作りました。

手動&実機テスト

実装が一通り終わった段階でテストケースを作成し、プロジェクトメンバー3人で実機テストをしました。
テストケースをすべて消化したら、会議室を押さえて、スマートフォンホテルページの仕様に詳しい有識者6人くらいで1時間黙々と実機で触りました。
この最後のQA会でもいくつかの重要な問題が見つかりました。
仕様に詳しい有識者だからこそ発見できる問題があり、ここで大きなBugfixを潰すことができたのは大きかったと思います。

リリース

リリースもテスト同様、QA同様、2段階で行いました。
まず、いくつかのホテル、施設のみを先行リリースします。

  • 表示情報が多く、それほどアクセスが多くない施設を選びました。施設タイプ(ホテル、旅館、ビジネスホテル、貸別荘)もばらつかせました。
  • 先行リリースには、IISのRewrite機能を使いました。

先行リリース後は、DatadogやNewrelic、Google Analyticsなどを用いて、先行リリースした施設に何かおかしなことが起こっていないか、注視しました。
QAフェーズに時間をかけたのが功を奏し、とくに問題は起こりませんでした。 1週間ほど様子を見て、問題なかったため、全面的にリリースしました。
サーバサイドの1週間平均の処理速度は、500msから250msまで下がりました。

まとめ

今回は、一休.comスマートフォンホテルページリニューアルをリリースし、パフォーマンスが改善した話を書きました。 その中でも、サーバサイドチューニングとQA・リリースについて書きました。 このプロジェクトを通じて、感じたことをまとめると

  • async/awaitは積極的に使っていくべき。
    • 複雑なJoinのselect文を一発実行するよりも、複数のシンプルなselect文をasync/awaitで発行して、ソースコード上でJoinするほうが、いろいろな面で良い。
    • 複雑なJoinのselect文はメンテしにくい。かつ、実行計画の変化で突然、遅くなる場合がある。
  • QA大事。
    • 計画に対して1週間ほどスケジュールが遅れましたが、これは、QAに予想以上に時間がかかったため。
    • QAにかかる工数の見積もりは難しい、と感じましたが、時間をかけただけの意味はあったようです。
    • リリース後に見つかったバグはほとんどなかった。
  • Newrelicは非常に価値のあるプロダクトだと改めて感じました。
    • ボトルネックの調査で大活躍。
    • 性能面の調査だけでなくインシデント発生時の調査でも有効。

一休.comスマホサイトのパフォーマンス改善(CSS・その他細かいチューニング編)

こんにちは。 一休.comの開発基盤を担当しています、akasakasです。

今回は、一休.comスマートフォンホテルページリニューアルをリリースし、パフォーマンスが改善したお話をさせて頂きます。

概要編とJavaScriptパフォーマンス改善編はこちらになります。

user-first.ikyu.co.jp

user-first.ikyu.co.jp

この記事ではスマートフォンホテルページリニューアルで実施したCSS・その他細かいチューニングについてお話しします。

ここでお話しする内容

  • CSS再設計&チューニング編
    • リニューアル前のスマートフォンホテルページのCSSの現状整理と抱えていた課題
    • リニューアルをするにあたり、CSS再設計
      • CSS Modules
      • FLOCSS
    • パフォーマンス
      • インライン展開
      • 非同期読み込み
    • ドキュメント整備
  • その他細かいチューニング編
    • resource hints/preconnect
    • 画像の遅延ロード
    • imgix
  • まとめ
  • おまけ:こんなスタイルは嫌だ

CSS再設計&チューニング編

リニューアル前のスマートフォンホテルページのCSSの現状整理と抱えていた課題

リニューアル前のスマートフォンホテルページのCSSに関する課題は以下の点が大きな悩みでした。

  • 7,000行越えのメンテナンス困難なCSS
  • どのスタイルがどこで使われているか不明
  • いろんなところでいろんなCSSファイルが呼ばれている

7,000行越えのメンテナンス困難なCSS

上書きに上書きを重ねているので、メンテナンスが大変でした。

f:id:akasakas:20180902204612p:plain:w300

どのスタイルがどこで使われているか不明

ネストが深く、命名ルールもなかったので、どのスタイルがどこで使われているのかがわかりづらかったです。

.search_cont #rmsrch_frame_disp_main .rmsrch_frame_disp_row .pep_frame {
  width: 24%;
}

いろんなところでいろんなCSSファイルが呼ばれている

概要編でお話しましたが 一休.comのほとんどはVB.NETでシステムが構築されています。
ASP.NET Web Formsベースの独自フレームワークです。

プレゼンテーション層はMasterPageがあり、
それぞれの画面に対して、aspxファイルがあり、
また、細かい部品ごとに対して、ascxファイルがあります。

それぞれのファイルに対して、cssファイルが定義されていたため、
bodyタグの途中でCSSファイルが呼ばれているなんてこともありました。

レイアウトは下記のようなイメージです。

f:id:akasakas:20180910191958p:plain

一例ですが、
divタグ内でstylesheetが呼ばれているということもありました。

f:id:akasakas:20180907234134p:plain:w500

リニューアルをするにあたり、CSS再設計

リニューアルをするにあたり、上記の課題を解決し、パフォーマンス面の改善も図りました。
キーワードは以下になります。

CSS Modules

一休では Vue.js を採用しています。
Vue コンポーネントに関しては CSS Modules を積極的に採用しています。
CSS Modules を採用し、コンポーネントごとにスコープを切れば、どのスタイルがどこで使用されているかがわかりやすくなり、メンテナンス性が保証されると考えました。

FLOCSS

しかし、CSS Modules 以外でグローバルに影響を及ぼすCSSを書かなければいけないケースが発生します。
上記のケースについては別途対応が必要だと考えました。
こちらはFLOCSSを採用しました。
FLOCSSについてはこちらをご覧いただければ、わかると思います。

github.com

ディレクトリ構成は下記のようなイメージです。

├─app
│      sd_hotel.css
│
├─foundation
│      _base.css
│      _reset.css
│
├─layout
│      _footer.css
│      _header.css
│
└─object
    ├─component
    │      _component1.css
    │      _component2.css
    │
    ├─project
    │  │
    │  └─hotel
    │          _hotel_part1.css
    │          _hotel_part2.css
    │
    └─utility
            _utility1.css
            _utility2.css

CSS プリプロセッサにPostCSSを採用しました。
Foundation/Layout/Objectのcssファイルを統括するためのsd_hotel.cssがあるようなイメージです。
sd_hotel.cssはFoundation/Layout/Object内のcssファイルを適宜インポートしています。
ここはFLOCSSの基本的な考え方を採用しています。

@charset "utf-8";

/* ==========================================================================
// Foundation
==========================================================================*/

@import "../foundation/_reset.css";
@import "../foundation/_base.css";

/* ==========================================================================
// Layout
==========================================================================*/

@import "../layout/_header.css";
@import "../layout/_footer.css";

/* ==========================================================================
// Object
==========================================================================*/

/* ==========================================================================
// Component
==========================================================================*/

@import "../object/component/_component1.css";

/* ==========================================================================
// Project
==========================================================================*/

@import "../object/project/hotel/_hotel_part1.css";
@import "../object/project/hotel/_hotel_part2.css";

/* ==========================================================================
// Utility
==========================================================================*/

@import "../object/utility/_utility2.css";

パフォーマンス

CSSはレンダリングブロック対象になります。 レンダリングブロックはパフォーマンス低下に繋がるので、これを解消するために下記2点を気をつけました。

インライン展開

Above the Fold に入るスタイルはインライン展開することでレンダリングブロックを回避しました。
ただ、htmlファイルに直接、インライン展開したスタイルを書くとメンテナンス性を保証することはできません。

そこで、サーバサイド側で外部cssファイルを読み込んで、インライン展開するヘルパーメソッドを1つ用意しました。
これにより、外部ファイルで管理できて、かつインライン展開ができるので、メンテナンス性とパフォーマンスの向上を図ることができました。

非同期読み込み

Below the Fold になるスタイルはインライン展開せずに、loadCSS を使って、非同期で読み込むようにしました。

ドキュメント整備

今後の色んなエンジニア・デザイナーがメンテナンスするためにドキュメントを整備しました。 書いたこととしては

  • 設計方針
  • ディレクトリ構成
  • 命名ルール
  • パフォーマンス
  • アンチパターン

です。

f:id:akasakas:20180920185039p:plain:w500

その他細かいチューニング編

resource hints/preconnect

サードパーティドメインに対してはpreconnectを指定して、あらかじめDNSの名前解決に加え、TCPコネクションまで貼っています。

画像の遅延ロード

Below the Fold の部分の画像は初回リクエストに含めず、遅延ロードさせてます。
一休では画像の遅延ロードにlazysizesを使っています。

github.com

imgix

imgixの導入で画像最適化ができたので、パフォーマンス改善に大きく寄与しました。
imgixについての記事はこちらをご覧頂ければと思います。

user-first.ikyu.co.jp

まとめ

今回は、一休.comスマートフォンホテルページリニューアルをリリースし、パフォーマンスが改善したお話をさせて頂きました。
その中でも、CSS・その他細かいチューニングについてお話させて頂きました。

リニューアル前には下記の課題がありましたが、

  • 7,000行越えのメンテナンス困難なCSS
  • どのスタイルがどこで使われているか不明
  • いろんなところでいろんなCSSファイルが呼ばれている

上記の課題に対して

  • CSS Modules/FLOCSS を採用し、メンテナンス性を担保
  • インライン展開/loadCSSを使った非同期読み込みでパフォーマンス改善

今後はこれをスマートフォンホテルページだけでなく、他のページにも展開して行き、ユーザに高い価値を提供していきたいと思います。

おまけ:こんなスタイルは嫌だ

CSS再設計をしている途中で見つけたリプレイス前の残念スタイル集を少しだけお見せします。
過去から学び、今後に活かしていきたいです。

HTMLに直接書いている

<span style="font-size:13px; font-weight:normal;"> 

ネストが深すぎる、HTML側のDOM修正時に影響を受けやすい

#sdGuidePage .figureimg-list-roomlist .shisetu_box.guide_top_photo .photoTop img,#sdGuidePage .figureimg-list-roomlist .shisetu_box.guide_top_photo .photoNext img,#sdGuidePage .figureimg-list-roomlist .shisetu_box.guide_top_photo .photoPrev img {
  height: 24px;
}

同じ内容

.mt10 {
    margin-top: 10px;
}

.top_m_10px {
    margin-top: 10px;
}

CSSエンジニア募集

一休ではユーザに高い価値を提供することができるCSSエンジニアを募集しています!

hrmos.co

参考資料

一休.comスマホサイトのパフォーマンス改善(JavaScript編)

宿泊事業本部の宇都宮です。

一休.com スマホサイトのホテルページパフォーマンス改善プロジェクトでは、フロントエンドには以下のような要件がありました。

  • デザイン面は既存を踏襲する
  • 機能はほぼ従来通り
  • 日付等を変更した際の再検索は、画面遷移を挟まず、画面内で行えるようにする
  • パフォーマンスをできるだけ改善する

要するに、従来と同様の機能+αを実現し、かつ、従来と同等以上のパフォーマンスを実現する、というミッションです。

このために、どのような取り組みを行ったか、紹介します。

パフォーマンス目標値の設定

まず、パフォーマンスの目標値を設定する必要があります。モバイルでは、ユーザの帯域幅は回線や時間帯によって大きな変動があります。多少回線状況が悪くても、閲覧を妨げない程度のパフォーマンスを実現する必要があります。

一休へアクセスするユーザのモニタリングを見ると、極端に遅い回線を使っているユーザは少ないことから、回線速度のベースラインは1.4Mbpsとしました(この数字はChrome DevToolsのFast 3Gの回線速度なので、シミュレートしやすく、開発上都合が良いという理由もあります)。また、ロードが完了し、ユーザーが操作できるようになるタイミング(TTI, Time To Interactive)までの所要時間は5秒以内を目標とします。

計測とボトルネックの把握

次に、現行サイトのパフォーマンスの計測を行いました。詳細な計測結果については、概要編をご覧ください。

計測の結果、現在のパフォーマンスは目標値(3G FastでTTI 5秒以内)に届いていないことがわかりました。TTIはおよそ15秒で、快適に使えるとはいいがたい状況でした。

また、フロントエンドでは、js/cssによるレンダリングブロックが大きなボトルネックになっていることがわかりました。

レンダリングブロックの解消

レンダリングブロックとは、ブラウザが画面の描画を開始するまでに読み込みの必要なリソースがあるせいで、レンダリングを始められない現象です。具体的には、headタグ内でjsやcssを読み込んでいる際に発生します。レンダリングブロックが発生しているページは、たとえサーバのレスポンスが十分に速くても、画面が表示されるのは遅くなってしまいます。

JavaScriptのレンダリングブロック解消のための手段は簡単で、全てのJavaScriptをbodyタグの末尾で読み込むことです。しかし、既存コードでは、headタグでライブラリを読み込んでいたり、bodyの途中にscriptタグを書いていたりして、レンダリングブロックが発生していました。そこで、全てのJSコードを見直し、bodyタグの末尾で読み込む単一のjsファイルが全ての処理の起点になるよう改めました。バグを作り込まないよう慎重に行う必要はありますが、それほど難しい作業ではありません。

これによってレンダリングブロックの解消ができました。めでたしめでたし…といいたいところですが、これだけでは十分なパフォーマンス改善は実現できませんでした。

リソースの削減

前述したように、帯域幅のベースラインは1.4Mbpsとしました。1.4Mbpsの回線で、5秒以内に操作可能にするためには、Webページの初期ロード時に読み込む全リソースの合計を、875KB(1.4Mbps(=175KB/s) * 5s)に収める必要があります。この数字はJavaScriptの実行時間等を考慮しない、理論上の上限値であり、実際にはこれより減らす必要があります。

では、一休の現行ホテルページはどうかというと、約1MBのリソースを読み込んでいました。これでは、どう頑張ってもパフォーマンスの目標値を達成することはできません。そこで、全リソースを合計したサイズの上限を、ひとまず700KBに設定します。

次に、Webページにおいて最も「重い」リソースであるJavaScriptについて。JavaScriptは、ダウンロードだけでなく、実行の時間もかかるため、極力サイズを減らす必要があります。一休の現行ホテルページでは400KBものJavaScriptを読み込んでいましたが、これは300KBに抑えることを目標にしました。 まとめると、

  • 全体で700KB
  • JSは300KB

を上限として、読み込むリソースの削減を目指しました。

JavaScriptの最適化

初期ロード時のJavaScriptの最適化には、2つのポイントがあります。1つはjsファイルの削減、もう1つは実行時間の削減です。

ここでは、主にビルド周り(webpack, Babel等)の最適化と、Vue.jsアプリケーションの最適化を行いました。

JSバンドルサイズの最適化

JavaScriptコードを削減するには、不要なコードの読み込みを減らす必要があります。一休.comでは、JavaScriptはwebpackでバンドルしています。従来のwebpackビルド設定は、バンドルの粒度が大きめで、不要なコードを読み込みがちという問題がありました。そこで、ページ毎にバンドルを作成するようにしました。これによって、不要コードを大幅に削減することができました。

また、後述しますが、dynamic importを使ってVueコンポーネントを動的に読み込むことで、初期ロード時のバンドルサイズを削減しています。

Babel設定の最適化

先日、Babel 7の正式版がリリースされました。Babel 7を導入することで、若干ですが、ビルドサイズの削減が見込めます。ホテルページの場合、productionビルド後のjsファイルのサイズが584KB => 549KBに減りました。

また、一休.comでは、IE 11でもPromise等のES2015以上で利用可能な機能を使えるようにするため、 @babel/polyfill を使っています。最新ブラウザをターゲットにするなら、polyfillを減らせます。そこで、モバイルOSのみをターゲットにビルドして、ビルド後のサイズがどの程度になるか確認しました。結果、ターゲットをiOS >= 9にすると544KB、iOS >= 10にすると518KBといった結果になりました(ちなみに、最新のChromeだけをターゲットにすると507KBまで減らせます)。

iOS 9系のユーザ数はかなり少なくなっていて、近い将来、ターゲットはiOS >= 10になることが予想されます。そこで、PCとモバイルでbabelのpresetを分け、別々にビルドするようにしました。

Vue.jsのランタイム限定ビルドを使う

Vue.jsには完全ビルドとランタイム限定ビルドがあります。ランタイム限定ビルドはVueコンポーネントのコンパイラを含まないため、30%ほどサイズが小さくなります。vue-cliを使えばデフォルトでランタイム限定ビルドが使われますが、webpackのconfigを手作りしている場合には完全ビルドを参照していることがあるので、設定を見直してみると良いと思います。 https://jp.vuejs.org/v2/guide/installation.html#%E3%81%95%E3%81%BE%E3%81%96%E3%81%BE%E3%81%AA%E3%83%93%E3%83%AB%E3%83%89%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6

遅延レンダリングによるJavaScript実行時間の削減

Vue.jsでは、条件付きレンダリングを行うためのディレクティブとして、 v-showv-if が用意されています。この2つの使い分けは、ドキュメントには以下のように記載されています。

一般的に、v-if はより高い切り替えコストを持っているのに対して、 v-show はより高い初期描画コストを持っています。 そのため、とても頻繁に何かを切り替える必要があれば v-show を選び、条件が実行時に変更することがほとんどない場合は、v-if を選びます。 https://jp.vuejs.org/v2/guide/conditional.html#v-if-vs-v-show

どちらもそれほど変わりがないように思えますが、大量のコンポーネントを条件付きレンダリングする際には、どちらを使うかはとても重要です。私は、条件付きレンダリングでは原則として v-if を使うべきだと考えています。なぜなら、v-ifを使うと、その要素は遅延レンダリングされるからです。

// SomeComponent のコードは実行されない
<some-component v-if="false" />

// SomeComponent のコードは実行され、描画も行われるが、非表示になる
<some-component v-show="false" />
  • デフォルトで非表示の要素 => v-ifで遅延レンダリングする
  • デフォルトで表示する要素 => 原則v-ifを使うが、v-showでもよい

といった具合で使い分けています。また、v-ifは次節で解説する非同期コンポーネントと組み合わせできる点でも、パフォーマンス上有利です。

非同期コンポーネント

ECMAScriptのモジュールシステムには、静的なimport(import 'module')と、動的なimport(import('module'))があります。後者の動的なimport(dynamic import)を使うと、必要になったタイミングでjsコードを取得できます。

特に、複雑なVueコンポーネントはサイズが大きくなりがちなため、dynamic importで動的にコードを読み込むようにすると、初期ロード時に必要なjsの量を大きく減らすことができます。

Vue.jsでは、以下のように、コンポーネント登録時にimport()を呼び出す関数を使用することで、コンポーネントをdynamic importできます。

// グローバル登録の場合
Vue.component('RoomDetail', () => import('./RoomDetail.vue'));

// ローカル登録の場合
export default {
  name: 'RoomList',
  components: {
    RoomDetail: () => import('./RoomDetail.vue'),
  }
};

このように、dynamic importされるVueコンポーネントのことを、Vue.jsのドキュメントでは非同期コンポーネント(async component)と呼んでいます。

なお、dynamic importは通信のオーバーヘッドが発生する分、静的なimportよりも遅いです。dynamic importの対象は、初期描画には不要なコンポーネントのみにすべきです。

また、v-ifでレンダリングしない状態になっているコンポーネントは、フラグがtrueになった段階でdynamic importされます。以下のように、初期状態では非表示で、フラグによってレンダリングされるコンポーネントがある場合、dynamic importを使用した遅延読み込みを検討すべきです。

<button type="button" @click="flag = true">Click me</button>

<!-- SomeComponentは非同期コンポーネントにできる -->
<some-component v-if="flag" />

<!-- このdivはコンポーネント化すれば非同期コンポーネントにできる -->
<div v-if="flag">
   ...
</div>

Vueコンポーネント設計のアンチパターン

Vueコンポーネントは、ビルド後のサイズはそれほど小さくありません。シンプルなコンポーネントでもminify後で1KBほどになります。たとえば、ボタンやアイコン等、見た目が異なるだけのコンポーネントを細かくコンポーネント化すると、ビルド後のjsファイルのサイズが膨らんでしまいます。パフォーマンスの観点からすると、細かいVueコンポーネントを大量に作るのは避けるべきです。

ライブラリの削減

不要なライブラリを削除するのはもちろんですが、それだけでなく、ライブラリの使用量自体を抑えることを考えるべきです。今回のプロジェクトでは、jquery-migrateを削除することはできましたが、jQueryを依存関係から取り除くには至りませんでした。 jQueryは軽量なライブラリではないため、パフォーマンスの観点からは削除したいライブラリの1つです。一方で、その便利さから至るところで使われているため、簡単に依存を取り除くことはできませんでした。

改善結果

概要編にもいくつか結果が載っていますが、ここでは転送量に着目するためWebPageTestの結果を掲載します。

Before

f:id:ryo-utsunomiya:20180918171234p:plain

After

f:id:ryo-utsunomiya:20180918171256p:plain

転送量(Fully Loaded > Bytes in)が300KB近く減り、Speed Indexは1000以上改善しました。

パフォーマンス監視の導入

パフォーマンスは放っておくと劣化していくので、Webサイトの速度を維持し続ける仕組みが必要です。ここでは、パフォーマンス予算(Performance Budget)の設定と、監視を行っています。具体的には、パフォーマンス監視SaaSのCalibreを使用し、予算を超過した際にアラートがSlackの開発者向けチャンネルに流れるようにしました。

f:id:ryo-utsunomiya:20180919104010p:plain

今後の展望

ホテルページについては一定のパフォーマンス改善を実現することができましたが、サイト全体としてはまだまだ伸びしろがあると考えています。スムーズに宿泊施設を探すには、ページ単体ではなく、検索導線(トップ・リスト・ホテル)の全体的な回遊性が重要です。パフォーマンス改善も含め、引き続きUI/UXの改善を行っていきたいと考えています。

一休では、UI/UXの改善に熱意のあるエンジニアを募集しています!

hrmos.co

一休.comスマホサイトのパフォーマンス改善(概要編)

こんにちは。 一休.comの開発基盤を担当しています、akasakasです。

今回は、一休.comスマートフォンホテルページリニューアルをリリースし、パフォーマンスが改善したお話をさせて頂きます。

UI部分は既存を踏襲する形をとり、UX・パフォーマンス改善にフォーカスして、所々で様々な工夫をしました。

お話ししたいことが盛りだくさんなので

  • 概要編
  • JavaScriptパフォーマンス改善編
  • CSS・その他細かいチューニング編
  • サーバサイド編

の4つに分けて、お送りしたいと思います。

この記事ではスマートフォンホテルページリニューアルの全体像についてお話しします。

詳しいお話をする前に:スマートフォンホテルページってどこ?

こちらになります

https://www.ikyu.com/sd/00001290/

f:id:akasakas:20180902185134p:plain:w300

ここでお話しする内容

  • リニューアル前後のパフォーマンス比較
    • PageSpeed Insights
    • Audits
    • Calibre
  • パフォーマンス改善が必要だった理由とリニューアルの背景
  • ASP.NET Web Forms(VB.NET) → ASP.NET MVC(C#) へのアーキテクチャリプレイス
  • 宿泊スマートフォンサイトが抱えていた大きなボトルネック
    • Time To First Byte
    • レンダリングブロック
    • 肥大化したJavaScript・CSS
  • それぞれの課題に対する解決策とアプローチ
  • まとめ

リニューアル前後のパフォーマンス比較

PageSpeed Insights

PageSpeed Insightsのスコア比較は以下の通りです。
サードパーティクッキーのキャッシュコントロール以外の項目は解消することができました。

before

f:id:akasakas:20180902185650p:plain:w400

after

f:id:akasakas:20180918174337p:plain:w400

Audits

Metricの各指標が改善され、指摘事項も概ね解消できているのがわかると思います。

前提

  • Chrome 68(Lighthouse 3.0beta)
  • Simulated Fast 3G, 4x CPU slowdown

before

f:id:akasakas:20180903103305p:plain:w600

after

f:id:akasakas:20180903103234p:plain:w600

Calibre

一休ではWebフロントエンドパフォーマンスモニタリングSaasの Cailbre を使っています。
Time To First Byte をはじめとして、各指標が改善されているのがわかります。

before

f:id:akasakas:20180902191714p:plain:w400

after

f:id:akasakas:20180902191725p:plain:w400

パフォーマンス改善が必要だった理由とリニューアルの背景

Mobile-First Indexなどの理由はあると思いますが、

  • 初期表示が遅かった
  • 再検索のたびにリロードが走り、遅かった

という点を改善し、ユーザに快適に使って欲しいというところがモチベーションとしてありました。

上記2点を解消する上で障壁となったのが

  • ASP.NET Web Forms(VB.NET) によるレガシーアーキテクチャ
  • 単純に非同期で読み込むことができないレンダリングブロックしている古のJavaScript
  • 上書きに上書きを重ね、7,000行を越えていたCSS(メンテナンス困難)

でした。

宿泊サイトは前回の大規模リプレイスから10年近くと長きに渡って価値を生んでいるサービスなのですが、上記の問題を抱えたままでパフォーマンスを改善し、より高い価値を提供することが難しくなってきたという背景がありました。

これを機会にスマートフォンホテルページのみ部分的に新しいアーキテクチャでリプレイスをしようという決断をしました。

ASP.NET Web Forms(VB.NET) → ASP.NET MVC(C#) へのアーキテクチャリプレイス

一休.comのほとんどはVB.NETでシステムが構築されています。
ASP.NET Web Formsベースの独自フレームワークです。
大規模リプレイスをしたのが2009年頃なので、宿泊サービスを10年弱支えてきてくれました。
ここまでのお話を聞いて頂ければ、色々と察してくださる方も多いと思います。

上述の通り、パフォーマンス面での性能・UX改善が難しくなってきたという背景から、 ASP.NET C# へのアーキテクチャリプレイスを一部実施しました。

過去の学びから大規模リプレイスはやめて、薄いコンポーネントだけ被せる、MVC の作法に則る形をとるようにしました。
ここは宿泊システムのレガシーアーキテクチャ改善を進めてくれた @shibayanに基盤を作ってもらいました。

補足:ASP.NET Core MVC (.NET Core)を選ばなかった理由

「なぜ、ASP.NET Core MVC (.NET Core)を選ばなかったのか?」と疑問に思われる方もいるかもしれませんので、補足しておきます。

  • 既存のFramework/Componentsを使う必要があり、そこの互換性が.NET Core だと対応してなかった(主に認証に関わる部分)
  • Web forms を捨てることにスコープを置いた
    • これまでのアーキテクチャ改善によって DB アクセス周りや新しい処理は C# へのリプレイスが進んでいた
    • プレゼンテーションレイヤーだけC#で書けない状況を打開することに注力した
  • パフォーマンス改善の文脈とは別にアーキテクチャ改善に重きを置いていたプロジェクトが先行して走っていて、スマートフォンホテルページリニューアルがそのパイロットプロジェクトとなった

という経緯があります。

宿泊スマートフォンサイトが抱えていた大きなボトルネック

アーキテクチャリプレイスだけで、万事解決!!!というわけではなく、他にもパフォーマンス面で抱えていた課題がありました。

上記でも簡単に触れましたが、宿泊スマートフォンサイトが抱えていた大きなボトルネックは以下になります。

Time To First Byte

複雑なSQLをいろんなところから複数回呼び出すような処理が散乱していました。 このため、Time To First Byteが遅かったです。 また、ASP.NET Web Forms(VB.NET)の独自フレームワークでは非同期処理の実装が難しかったという側面もあり、これもTime To First Byte悪化の一因でした。

レンダリングブロック

古のJavaScriptに悩まされ、単純にdefer/asyncを使った非同期読み込みができず、Critical Request Chainが長くなり、ページスピードを下げる一因となっていました。

before/afterでのCritical Request Chainの長さを見れば、一目瞭然だと思います。

before

f:id:akasakas:20180902203526p:plain:w400

after

f:id:akasakas:20180902203536p:plain:w400

肥大化したJavaScript・CSS

上述の通り、肥大化したJavaScript・7,000行越えのメンテナンス困難なCSSがあり、これもまたページスピードを悪化させている要因の1つとして大きかったです。

before:JavaScript転送量

ちなみに、画像転送量と同等のサイズでした。

f:id:akasakas:20180902204042p:plain:w300

after:JavaScript転送量

f:id:akasakas:20180902204052p:plain:w300

7000行越えのCSS

f:id:akasakas:20180902204612p:plain:w300

それぞれの課題に対する解決策とアプローチ

Time To First Byte

  • ASP.NET MVC C# により、非同期処理の実装が容易にできたこと
  • データ取得処理は、検索APIの一か所にまとめてasync/awaitで取得
  • 検索APIの呼び出しもasync/await
  • 鮮度を問わないデータはElasticaheでキャッシュ

というところで、Time To First Byte改善につなげました。

詳しい話はサーバサイド編でお話しできればと思います。

レンダリングブロック

ASP.NET MVC C# リプレイスに乗じて、古のJavaScriptたちも全て捨てました。

  • Vue.jsに寄せ、必要な箇所はコンポーネント化
  • async/deferで非同期読み込み
  • bodyタグの末尾でJavaScriptの読み込み

などでレンダリングブロックを解消し、Critical Request Chainを削減し、パフォーマンス改善ができました。

詳しくはJavaScriptパフォーマンス改善編でお話しできればと思います。

肥大化したJavaScript・CSS

上記に対しては

  • JavaScript
    • dynamic importによる初期ロード時のリソース削減
    • 遅延レンダリングによるJavaScript実行時間の削減
  • CSS
    • インライン展開
    • loadCSSを使った非同期読み込み
    • FLOCSSを採用
    • 不要なCSSセレクタの削除

を実施しました。

至極当然ですが、無駄なJavaScript・CSSをなくせたこともパフォーマンス面でけっこう大きいのかなと思いました。

詳しくは

  • JavaScriptパフォーマンス改善編
  • CSS・その他細かいチューニング編

でお話しできればと思います。

まとめ

一休.comスマートフォンホテルページリニューアルをリリースし、パフォーマンスが改善したお話をさせて頂きました。

単純にレガシーシステムからリプレイスするだけでパフォーマンスが劇的に向上するわけではなく、ちゃんとボトルネックを理解した上で改善しなければ、同じ轍を踏みかねないということを学ぶことができたのは個人的に大きかったです。

また、今まで長い間頑張って価値を生み続けてくれた既存のシステムには感謝と敬意を払いながら、未来のために新システムに切り替えていきたいです。

今回、お話しした内容はあくまでも概要であり、サーバサイド・フロントエンドそれぞれの深いところのお話しは次回以降、お話しできればと思っていますので、ご期待ください。

エンジニア/デザイナー向けの会社紹介資料を公開しました

一休で働くことに興味を持っていただくために、一休のサービスやビジネス、開発組織がどうなっているかを紹介する資料を公開しました。

もし興味を持っていただけたら、いつでも話を聞きに来てください!

www.ikyu.co.jp

おまけ

先日、弊社のCSSエンジニアの募集がちょっとだけ話題になっていました。

一休レストランPython移行の進捗

レストラン事業部エンジニアの id:ninjinkun です。

一休レストランでは10年以上動いているシステムをPython 3で書かれた新システム(以下restaurant2)に順次移行する作業を進めています。現在ではPC用のレストランページ や主要な API を含め、いくつかのページがrestaurant2で提供されるようになっている状態です。本記事ではこの移行の経緯と、restaurant2システムの詳細、Pythonを選んだ理由、現在の進捗状況をお伝えします。

経緯

一休レストランはサービスローンチ時よりClassic ASP(言語はVBScript)でシステムが構築されてきました(こちらに驚かれる方も多いと思いますが、歴史的経緯という言葉で強引にまとめて話を先に進めます)。このシステムは現在も一休レストランを支えているのですが、長年の改修による複雑性の増加、言語の古さ、言語機能の貧弱さなどにより、事業成長の足かせになっているという事実がありました。

そこで昨年よりrestaurant2プロジェクトが立ち上がり、昨年末にAMPページを切り替えたのを皮切りに、移行が進んでいます。

restaurant2の概要

restaurant2の概要は以下の通りです。

  • Python 3.7 ・・・ typing, enum, data_classes, async/await (asyncio) など Python 3 の新機能を積極利用
  • フレームワークは Flask
  • DDD の Layered Architecture / Clean Architecture を参考にした薄い階層アーキテクチャ
  • Python 3 + mypy による type-hinting を使った型定義
  • flake8 + autopep8 によるコード規約 & 自動フォーマット
  • nosetests w/ factory_boy でのユニットテスト + Circle CI
  • API Blueprint による RESTful API の管理 / dredd によるドキュメントの自動テスト
  • フロントエンドは BFF + nuxt + vue.js による Universal JavaScript + コンポーネント指向設計
  • 実行環境は Docker + AWS Elastic Beanstalk (開発環境も Docker コンテナとして提供される)

構成図 f:id:ninjinkun:20180810165928p:plain

Pythonの選定理由

なぜPythonを選択したのかという質問をよくいただくので、こちらで選定理由を説明します。

一休レストランではこれまでデザイナーがマークアップも行うスタイルで開発が行われてきました。また、一部開発が得意なデザイナーはサーバーサイドロジックにまで踏み込み実装を行っていました。デザイナーがエンジニアと一緒になって開発を行うフローは、コミュニケーションロスを大幅に減らしてくれます。これを維持し、デザイナーが引き続き開発に参加しやすいように、開発環境のセットアップ、実装、プレビューまでを簡単に行える環境が求められました。既存システムのClassic ASP環境ではVBScriptをHTMLに埋め込み可能な動的なテンプレートエンジンとして利用していたので*1、同じようにテンプレートエンジンが使える動的言語がターゲットになりました。

この要件に当てはまり、かつ広く使われている言語としてはPerl, Ruby, PHP, Python, JS (node.js)あたりがターゲットになると思いますが、Pythonを選んだ理由としては以下の通りです。

  • 言語仕様がシンプルで学習コストが低い
  • 文法の自由度が低く、メンバー間の書き方の差異が減らせる
    • flake8 や autopep8 によりコーディングをスタイルを自動的に統一することも可能
  • 機械学習/AIのデファクトになっているため利用者が拡大しており、今後も言語の進化とエコシステムの高活性が見込める
  • 動的言語でありながら型定義による事前検査が可能

他にも社内にPython経験者が複数人居たり、選定の当時機械学習の勉強会が社内で流行っていた影響でPython熱が高まっていた…などの背景もあります。

マイクロフレームワークの選定理由

また、一休レストランではPythonで書かれたマイクロフレームワークであるFlaskを選択しました。この背景としては、ビッグリライトは行わず、既存のシステムと並行運用しながら段階的に移行して行くという意志決定があり、これにより以下の要求が発生したためです。

  • DBは既存システムと共用し、スキーマも既存のまま運用する
    • しかし既存DBスキーマのテーブル名、カラム名がユビキタス言語的な命名になっていなかった
    • 複数テーブルをjoinしないとEntityを表現できない場合があった
    • このためrestaurant2ではDataMapperパターンを使い、DBのテーブル構造から独立したオブジェクトを作成、オブジェクト名とカラム名をユビキタス言語に変換している
      • テーブル構造とオブジェクト構造が一致してしまうActiveRecordパターンのORMはこの方針には適さない
      • ORMを自分たちで選べる薄いWAFが望ましかった
  • 既存システムを開発しているメンバーも徐々にrestaurant2での開発に移行する
    • キャッチアップのし易さを考えると、マジカルな部分が多い厚いWAFよりは単純なWAFの方が好ましい

このような背景から、現在のrestaurant2の構成が決定されました。

現在の進捗

現在restaurant2により提供されているページは以下の通りです。

restaurant2プロジェクトは一気に新システムに移行することは行わず、ビジネス上の優先順位に基づいて手を入れる必要が出たページから順次移行するスタイルを取っているため、入れ替わりにはそれなりに時間がかかる想定です。

とはいえもっと移行を加速したいので、Pythonでアプリケーションを書きたい方はぜひご応募お待ちしております!

まとめ

  • 一休レストランはClassic ASP + VBScriptからFlask + Pythonに移行しています
  • 動的なスクリプト言語かつ人気がある言語としてPythonを選択しました
  • 一緒にPythonで一休レストランを作りましょう!

*1:個人的にはフレームワークを使わない素のPHPに似た印象を持っています

一休のSQL Server AWS移行事例(後編)

一休のSQL Server AWS移行事例(前編)からの続きです。

f:id:ninjinkun:20180718110152j:plain

実施当日

kudoy:
準備段階で色々踏んだので、だいぶリハーサルもしましたし、当日は作業スケジュール組んで、その通りにやった感じです。バッチを事前に流したり…

ninjinkun:
バッチというのは何ですか?

kudoy
宿泊とかレストランシステムなどで夜間に定時実行されているバッチ処理ですね。

ninjinkun:
ああ集計系の。

kudoy
そうですね。それを事前に流せるものは流して。

今回移行対象のDBが十数個あったんですが、各DBの環境上の都合で2通りの方法で移行する必要がありました。

1つ目の方法は、事前にオンプレ側で取得したフルバックアップを移行先となるAWS側にリストアしておく。切替までの間の差分データはトランザクションログを定期的バックアップして、それを使って移行先のAWS側も更新していく。大半のDBはこちらで対応できました。

2つ目の方法は、当日オンプレ側で完全にDBのデータ更新が終わってからフルバックアップを取得して、移行先のAWS側でリストアする。

いくつかのDBでこの対応が必要となりました。

f:id:ninjinkun:20180718110202j:plain

リストア処理について

ninjinkun:
ちょっとそのリストアとそうではないものの違いがよく分からなかったんですが。

kudoy:
データベースの復旧モデルと呼ばれる設定の違いによってDBリストアとなるのかトランザクションログのリストアになるのかが分かれました。復旧モデルの詳細については、こちら 一休では移行前は、完全復旧モデルというトランザクションログが取得できるモデルと単純復旧モデルというトランザクションログが取得できないモデルの2つを用途別に採用していました。 多くのデータベースは、一般的に使用される完全復旧モデルを採用していましたが、1日に数回だけデータが更新される集計系テーブルなどやアプリケーションからは使用されない運用上で必要だけどフルバックアップを取得した時点まで戻せればよい性質のデータを扱っているデータベースについては、単純復旧モデルを採用していました。 こういった事情で事前にある程度移行できるものと当日に移行リストアしなければならないもの2パターンの移行手段が必要だったんです。

移行先のAWSにAlwaysOnをあらかじめ構築しておいて、データのみ差分更新でオンプレの環境と同期させておくといったことが出来れば、作業時間も短くて済むんですが、先ほど話した単純復旧モデルのデータベースがある、また今回移行するにあたって、SQLServerのバージョンも2012->2017へバージョンアップしたので、バージョンアップに対応する設定変更の必要もあったので、AlwaysOnの構築は当日行いました。 DBデータファイルの分割とインデックス再構築といった作業も移行時にやっておきたかったので、こういった作業も同日おこないました。

ninjinkun:
めっちゃ大変そうですね

kudoy:
細かい話をするとWindowsのクラスタ(WSFC)はDBリストアの影響を受けないので先に構築してあって、SQL ServerのAlwaysOnも全て当日構築すると時間が掛かるので、一度リハーサル時に構築して、移行直前に一部の設定だけ削除しておいて、DBリストア後から必要な設定だけしていくといった手順で作業時間を短縮しています。部分的な再構築するといったイメージですかね。

ninjinkun:
リストアというのはMySQLで言うmysqldumpしたものを食わせるのと同じようなものなんでしょうか

kudoy:
うーん、まあそんなイメージ?バイナリなんですが。

ninjinkun:
あーでは後から順次食わせるのではなく、テーブルごと一気に復旧してしまう感じなんですね。

kudoy:
そうですね。DBのオプション設定やユーザ、テーブルとかDBに格納されているものを一気に復旧する感じです。 あとは、アプリケーション側でクラウド移行後の設定とかを変えてもらった部分があるので、それをリリースしてもらって、定期実行のスケジュールを止めていたバッチを裏側で動かして、問題無かったのでサイトオープン。 でもオープンしたらCPU使用率高いね、という(笑)

akasakas:
バッチを走らせまくってちょっと焦ってましたね。

kudoy:
あーそれがありましたね。深夜作業対で走っていた破壊力のあるバッチを一度に動かしたので。

ninjinkun:
じゃあCPU使用率はそのバッチが原因だったんですか?

kudoy:
それもあったんですが、バッチが終わってもまだCPU使用率は高かったです。

ninjinkun:
そこからパフォーマンスチューニングみたいな話になったんですよね。

kudoy:
インデックスいっぱい作ってもらったりとか、アプリケーション側も直してもらったりとか。

akasakas:
当日朝からお祭り感がありましたね。

kudoy:
でも思ったほどではなかった。それまでもオンプレで運用している段階でDBの負荷は高くなってきていて、ちょくちょくアラートが鳴る頻度は高くなってきていたので。

そういう意味では結構移行はギリギリのタイミングでした。あのままオンプレ続けてたら、手の打ちようがなくなっていた。

akasakas:
あのままオンプレ続けていたら、この夏はもう…

kudoy:
ちょっと厳しかった。

f:id:ninjinkun:20180718110224j:plain

当日起こったとしたら嫌だったこと

ninjinkun:
こういうのが起こってら嫌だったなというのありますか?

kudoy:
やっぱりリリースした後にあれこれ動かない系。事前に検証して貰っているので、そんなに心配しては居なかったです。でもフルの構成では検証し切れていないので、そこで何か出るのは嫌だなと思っていました。

あとは想定していてちょっと嫌だなと思ったことは、結局ハードを自分たちで管理できていないところなので、AWSに移行してしまった後は、転送速度が遅いとかあると今までと勝手が違うので嫌だなと思っていました。

ninjinkun:
それは今回は杞憂だったということですか。

kudoy:
そうですね。思ってたより逆に早かった。日中に試験しているときは転送速度遅いなと思っていたんですが、夜中に試したことはそれまでなくて。当日は夜中で帯域が空いている?のか早かったですね。

akasakas:
当日もちょっと巻いてましたよね。

kudoy:
EBS(io1)のIOPS上げたおかげもあると思うんですが、深夜という時間帯による影響もあったのかなと感じています。

準備段階で重点的にやったこと

ninjinkun:
この辺が怖かったので重点的にやった、みたいなのはありますか?

kudoy:
それはやっぱり時間の部分ですね。これはリハーサルを相当やったので。10回くらいはやってます。

ninjinkun:
リハーサルも最初はうまく行かなかったりしたんですか?

kudoy:
作業時間以外はうまくいかないといったことはないですね。1番の問題が作業時間が長いといったところだったので、各手順単位で作業(処理)時間を計測して、時間が掛かる作業を抽出して短縮する方法を検討/検証して時間を計測するといった繰り返して少しづつ時間を短縮していきました。

あとは机上で手順や作業を上手く組み替えて、何度もリハして何とか収まるようにした部分が一番苦労した部分ですかね。12時間かかりますとか言えないので(笑)

ninjinkun:
今回の作業はある程度リスクを取って実行していると思うのですが、やはり何度もリハーサルしたのが良かったんですかね。

kudoy:
いろんな検証を繰り返しやったので、その辺りは安心感に繋がってます。何か起きてもなんとかなるだろうと。アプリケーション で何か起きても心強いアプリの担当エンジニアがなんとかしてくるだろうと(笑)負荷試験も元々は1週間の予定だったんですが、見直して3週間くらいやっていました。

akasakas:
負荷試験はJMeterを使ってコントローラーを1台置いて、slaveを15台置いて一度に投げたりしていました。実際のリクエストからパラメータを生成して主要な導線や負荷が高いAPIをピックアップして夏のピークの負荷x1.5くらいのリクエストを投げて試験しました。

kudoy:
世の中的に同じようにAWSでやっている事例が少なかったというのがありますね。同じようなのは日本だとH.I.S.さんくらいしか居ないですね。H.I.S.さんについては事例が出ています。

ninjinkun:
やっぱりSQL Serverでクラウドだと普通はAzureになるんですかね?

kudoy:
うーん、Microsoftの製品ですし親和性は高そうですよね。でもAzureは今回は見送りました。 決めるときはAWS、GCP、Azureで比較しました。GCPはその当時まだ追いついてなかったので外して。 結局AWSとAzureともDBがSQL Serverであることが引っかかっていて、それを安定的かつお金的にも最適なと考えるとAzureはちょっとバランスが悪かったんですよね。インスタンスを立ててそれを分けるという構成だと。そもそもPaaSは制限が多すぎて両方とも使えなくて。

ninjinkun:
なるほど、フルマネージドは無理だねと。

kudoy:
そう、それは無理でインスタンスでとなったときにIOPSの調整がAWSの方が柔軟だった。そこの決定的な違いは、AWSだとディスクのサイズがある程度あれば2万IOPSまで上げられるんです。500GBでも2万IOPSとかできる。Azureの場合は容量によって性能が決まっているので、IOPSを上げると容量全然使ってないのに使っていない部分に課金が発生してしまうので、これはクラウドのうまみがないよねという話になりました。 じゃあそうなるとAWSもWebサービスの事例自体はいっぱいあるし、こっちかなと。

ninjinkun:
なるほど、サービス特性に合わせられる柔軟性と事例の豊富さでAWSになったんですね。

以上で聞きたかった話はだいたい聞けたと思います。ありがとうございました。

すごく大変な移行だったとだけ聞いていて、詳細を知らなかったので、今回は勉強になりました。

f:id:ninjinkun:20180718110146j:plain