一休.com Developers Blog

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

Hangfireで実現する.NETアプリのバックグランドジョブ

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

qiita.com


ある程度の規模のウェブアプリケーションであれば、応答性能を損なうことなく複雑な業務処理を完遂させたい場面が出てきます。 このような場合、処理をある程度の粒度で切り出して、応答を返すプロセスとは別のプロセスで処理する、という方法が考えられます。 例えば、予約完了処理の中でメール送信部分だけを別のプロセスで処理する、といった具合です。 ASP.NETでこのようなバックグランドジョブ処理を実現するには、どのような方法があり得るでしょうか。

この記事では、.NET環境で利用できるバックグラウンドジョブのライブラリであるHangfireについて、当社での利用事例を簡単に紹介します。

Hangfireを導入した経緯

当社のサービスにも当然、上述したようなバックグラウンドジョブのニーズがありました。

ASP.NETでバックグラウンドジョブの処理を実現する場合、公式に提供されているQueueBackgroundWorkItemを使うという手段があります。 これなら導入は非常に簡便です。しかし、大事な処理をバックグラウンドジョブで実行することを考えると、ジョブのステータス(キューイング、処理中、処理正常完了、処理失敗)やジョブの実行履歴の管理も行いたい、と考えたました。
この場合、QueueBackgroundWorkItemを採用するなら、その部分は自前で作らなければなりません。
また、クラウドが提供するキュー処理のサービスを使う方法もあり得ますが、この場合も、ジョブのステータスやジョブの実行履歴の管理は自前で作らなければなりません。 Hangfireは、ジョブストレージとジョブの管理機構が組み込まれており、かつ、既存の.NETのコードを大きく変えることなく利用できるという利点があります。 作者によれば

Hangfire is a .NET Framework alternative to Resque, Sidekiq, delayed_job, Celery.

だそうです。 以上の点を総合的に加味して、Hangfireを活用することに決めました。

構成

f:id:s-tokutake:20181219123844p:plain

Hangfireを使う場合、アプリケーションの構成は大きく以下のふたつがあり得ます。

  • バックグラウンドジョブ処理を行いたいウェブアプリケーションの内部にバックグラウンドジョブサーバを動作させる。
  • バックグラウンドジョブ処理を行いたいウェブアプリケーションとバックグラウンドジョブサーバを完全に分離する。

当社では、完全に分離する構成にしました。内部にバックグラウンドジョブサーバを動作させる場合、ウェブアプリのプロセスの再起動とジョブの処理の状態を気にする必要があるため、完全に分離したほうが運用が簡単だと判断しました。

上記の図の通り、ジョブキューのストレージにはElasticache Redisを使っています。Hangfireは標準ストレージとしてSQL Serverを使いますが、Redisのほうがより高性能であるというベンチマーク結果を考慮し、Redisにしました。

また、ジョブのステータスや各種管理ができるDashboardは、Hangfireを使うウェブアプリともバックグラウンドジョブサーバとも別のサーバに構築しました。

実装例

実装自体はとても簡単です。

namespace Sample.Service.JobQueue
{

    /// <summary>
    /// バックグラウンドジョブ導入のためのサンプルクラス
    /// </summary>
    public class JobQueueSampleService 
    {

        public void DoSomething(string param1)
        { 
            JobQueueClient.Enqueue(() => DoAsBackgroundJob(param1));  
        }

        /// <summary>
        /// このメソッドをキューに詰める。
        /// </summary>
        /// <param name="param1">
        /// Hangfireは、パラメータをJsonでシリアライズしてストレージに詰める。
        /// </param>
        [Hangfire.AutomaticRetry(Attempts = 3)] // <== リトライ回数をキューに詰めるメソッドの属性で回数を指定する。指定しないと10回リトライ。リトライしないなら0回にしておく。
        public void DoAsBackgroundJob(string param1)
        {
             // --- do something  
        }
    }

    public class JobQueueClient
    {

        private static readonly Lazy<IBackgroundJobClient> _cachedClient = new Lazy<IBackgroundJobClient>(() => new BackgroundJobClient());

        public static string Enqueue(Expression<Action> methodCall)
        {
            var client = _cachedClient.Value;

            return client.Create(methodCall, new EnqueuedState(QueueName));
        }
    }
}

Hangfireは、メソッドをジョブキューにエンキューする、という仕組みになっています。↑のコードでは、 JobQueueClient.Enqueue(() => DoAsBackgroundJob(param1)); で、 DoAsBackgroundJobメソッドでジョブとしてジョブストレージに保存しています。
エンキューすることで、メソッドのシグネチャやパラメータ、そのメソッドが属するアセンブリ名などがジョブストレージに保存されます。バックグラウンドジョブサーバは、この情報をデキューして、リフレクションを使って、保存されたメソッドを呼び出します。
リフレクションを使うので、Hangfireのクライアントとなるウェブアプリとバックグラウンドジョブサーバは同じアセンブリを参照する必要があります。

※Hangfireには、 BackgroundJob.Enqueue というエンキュー処理のためのわかりやすいインターフェースがあるのですが、これは後述する理由により、使いませんでした。

工夫点

前述した通り、Hangfireのクライアントとなるウェブアプリとバックグラウンドジョブサーバは同じアセンブリを参照する必要があります。 実運用でこれを実現しようとすると、次の2点を考える必要があります。

  • 毎日複数回デプロイされるウェブアプリケーションとバックグラウンドジョブサーバのアセンブリを一致させる必要がある。
  • ウェブアプリケーションのデプロイサイクルよりもLong Runningなジョブでもきちんと処理を完遂させる必要がある。
    • それも、エンキューしたウェブアプリケーションと同じアセンブリを参照しているバックグラウンドジョブサーバで処理を完遂させる必要がある。

このふたつを実現する実現するために、以下のふたつの手段を採用しました。

  • Hangfireの名前付きキューを利用する。
  • バックグラウンドジョブサーバはWindows Serviceとして稼働させ、同じアセンブリを参照しているウェブアプリケーションが古いバージョンになっても動かし続ける。

Hangfireの名前付きキューを利用する。

Hangfireは、キューに名前を付けることができます。ある名前のキューに詰められたジョブはその名前のキューを処理するように指定されたバックグラウンドジョブサーバでしか処理されません。キュー名は通常、次のようにキューに詰めるメソッドの属性で指定します。

[Queue("testqueue")]
public void SomeMethod() { }

そして、バックグラウンドジョブサーバ側ではサーバを初期化するときに、キューの名前を指定します。以下のように。

var options = new BackgroundJobServerOptions
{
    Queues = new[] { "testqueue" }
};

app.UseHangfireServer(options);
// or
using (new BackgroundJobServer(options)) { /* ... */ }

これで、testqueue というキューに詰めたメソッドは、↑のサーバでしか処理されなくなります。

さて、あるバージョンのアセンブリを参照しているウェブサーバがキューに詰めたメソッドは同じバージョンのアセンブリを参照しているバックグラウンドジョブサーバで処理させたい という要件は、以下を実現すれば満たせそうです。

  • 同じバージョンのアセンブリを参照しているウェブサーバとバックグラウンドジョブサーバは同じキュー名を利用する。そのキュー名はビルド単位で生成され完全にユニークである。

ビルド単位で生成され完全にユニークな値として、ビルドバージョンの番号がありました。そこでこの番号をキュー名にすることでこの要件を満たすことができました。

Untitled (1).png

ただし、Hangfireのサンプルなどで一番よく見かける BackgroundJob.Enqueue では、キュー名をメソッドの属性としてしか指定できません。つまり、動的に設定できないのです。調べてみると、BackgroundJobClient クラスのCreateメソッドを直接使うことでキュー名を動的に指定できることがわかりましたので、以下のようなBackgroundJobClient をラップしたクラスを作りEnqueueメソッドを実装しました。

  public class JobQueueClient
    {

        private static readonly Lazy<IBackgroundJobClient> _cachedClient = new Lazy<IBackgroundJobClient>(() => new BackgroundJobClient());

        public static string Enqueue(Expression<Action> methodCall)
        {
            var client = _cachedClient.Value;

            return client.Create(methodCall, new EnqueuedState(QueueName));
        }
    }
}

バックグラウンドジョブサーバはWindows Serviceとして稼働させ動かし続ける。

新しいウェブアプリがデプロイされても古いバックグラウンドジョブサーバが動作していれば、デプロイサイクルよりもLong Runningなジョブもきちんと処理できます。 これは、シンプルに バックグラウンドジョブサーバはWindows Serviceとして実装し、新しいバージョンのバックグラウンドジョブサーバをデプロイをしても、古いバージョンのバックグラウンドジョブサーバは停止しないようにしました。 バックグラウンドジョブサーバをWindows Service として動かす方法は、公式サイトにも開設されています。しかし、このやり方には従わずに、Topshelfを使ってServiceにしました。Windows Serviceのインストール、アンインストール、開始、停止が簡単に制御できるためです。

バックグラウンドジョブサーバのデプロイはAWS Codedeployを使います。 Codedeployのafter installのhookで、バックグラウンドジョブサーバをWindows Serviceとしてインストールし、開始をしています。 このとき、配置するフォルダをデプロイバージョンごとに変えることで、既存のServiceを動かし続けながら、新しいバージョンのServiceを稼働させることができます。

appspec.ymlは↓のようにします。

version: 0.0
os: windows
files: 
  - source: /
    destination: d:/latest
hooks:
  AfterInstall:
    - location: /after-install.bat

after-install.batは、以下の通り。

rem $TARGET_FOLDER を ビルド時にビルドバージョンに書き換える。
xcopy D:\latest D:\$TARGET_FOLDER\ /E /Y /I /Q
D:\$TARGET_FOLDER\bin\BackgroundJob.ProcessingServer.exe install
D:\$TARGET_FOLDER\bin\BackgroundJob.ProcessingServer.exe start

ただし、このままだと永遠にWindows Serviceが増え続けてしまいます。対策として、日次で1日以上古くなったバージョンのバックグラウンドジョブサーバを停止するようにしました。

まとめ

当社ではHangfireをプロダクション環境の業務処理で活用し始めてまだ日が浅いです。しかし、現時点では、問題なく動作しています。 今後は、短い間隔で動かしているバッチ処理をウェブアプリのバックグラウンドジョブに切り替えていく、という使い方も想定しています。

この記事の筆者について

  • システム本部CTO室所属の 徳武 です。
  • サービスの技術基盤の開発運用、宿泊サービスの開発支援を行なっています。

「ちょっとしたことを検索できる」Slack botを作った

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

qiita.com


こんにちは。 社内情報システム部の下村です。
一休ではOfficeITに関する全ての業務、改善を担当しています。いわゆる情シスです。

本日は、一休の情シスが行ってきた活動のうち、開発者ブログらしく社内向けのSlackツールを開発(?)したことについて記載したいと思います。

どんなツールを作ったのか?

一休ではコミュニケーションツールとして、非エンジニアであってもSlackが活用されています。 そのSlackを使って、 「ちょっとしたことを検索できるbotがあれば便利なんじゃないか?」 と思い 社内向けに提供しました。 具体的には下記のようなことがSlack上で検索できるようにしています。

  1. 内線番号や、メールアドレスなどの社員情報を検索。
  2. 「座席表」や「無線LANのキー(パスワード)」などのURLを検索。
  3. 会議室の空き状況を確認。(予定が空いていればbot上から予約も可能にしています。)

デモ

「百聞は一見にしかず」ということで実際の動作デモです。 雰囲気が伝われば幸いです。 ちなみに「ぽ」とはbotの起動トリガーです。 ぽ<なんちゃら>と書くとbotが反応するようにしています。

f:id:undersooon:20181217141737g:plain

余談:なんで「ぽ」なの?

起動トリガーは下記の条件を満たす必要がありました。 下記条件を全て満たすのが「ぽ」でした。

  • 覚えやすい。
    • 「ぽ」ってなんかインパクトありますよね。
  • 打ちやすい。
    • ご自身のキーボード配列見てみてください。「p」と「o」が近くて打ちやすくないですか。
  • かぶらない。
    • かぶりやすいキーワードを起動トリガーにするとbotが誤検知しちゃうので。
    • 「ぽ」で始まるキーワードってなかなかないですよね。

構成

このツールの構成は下記のようになっています。 ツールが検索/回答する順序として、

  1. 投稿されたキーワードが「会議室状況確認」を含む場合、会議室予約情報を回答。
  2. 上記キーワードではない場合「Google SpreadSheet」に記載があるかを確認。あればSpreadSheetの内容を回答。
  3. SpreadSheetにもキーワードが無い場合、ADに登録されている社員情報かどうか確認し回答。

という順序で検索/回答をかけるようにしています。 (botの起動トリガーを無理やり一つで完結するようにしたので若干無理があります。。)

■詳細

  1. 社員が特定のチャンネルに投稿する。
  2. SlackのOutgoing Webhookで投稿された内容を検知し、GAS(Google Apps Script)に投稿内容をPostする。 ※1
  3. 投稿内容が「会議室状況確認」なのか確認。
    • 会議室予約の場合:会議室空き情報を返して終了。 ※2
    • そうではない場合:次のステップへ。
  4. Google SpreadSheetの内容をGASにて確認。
    • 記載がある場合:検索結果を返して終了。 ※3
    • 記載がない場合:次のステップへ。
  5. 検索内容をhubot用のSlackチャンネルに投稿。ADやSlackのユーザ一覧に検索情報が含まれているか確認し、結果を返す。
    • ADからは電話番号や内線番号、部署名などの情報を抽出。 ※4
    • SlackAPIの「users.list」を使って、EmailAddressをキーにSlackUserIDを抽出。 ※5

f:id:undersooon:20181217004143p:plain

※1 参考URL
※2 参考URL
地味にハマったのが対象のリソース(会議室)を閲覧状態にしないと予定が取得できないので、 おまじないとして、 CalendarApp.subscribeToCalendar(\<resourceIDを入力>); と事前に定義しておくと良いです。
※3 こちらのURL を参考に行番号を取得して、こちらを参考に対象のセルを取得してます。
※4 WindowsServerでhubotを起動しています。「Get-AdUser -Filter 'enabled -eq $true' -Properties *」して、AD登録情報を内線番号など必要なものを引っ張ってます。
※5 参考URL

課題

メンテナンス性を考えずに適当に作ってしまったので構成がとても複雑になってしまいました。。
ADについてはいずれ、AWS Directory Serviceに移行予定なので、AD情報などはhubotを使わずに
Lambdaで参照するようにしたら多少はシンプルな構成になるのではないかと考えています。

botをリリースしてから起きたこと

内線番号とか、座席表って参照する機会はあるものの「いざ」という時に 「どこに書いてあったっけ状態」に陥りますよね。

お陰様で「ぽ」は社内でよく利用されていますので、 結果として、「どこに書いてあったっけ状態」を解消でき、 社員が「何かを探す」時間を少しは減らせたのかなと思います。

余談ですが、人が「探す時間」に時間を費やすのは年間150時間もあるそうですよ。
その時間を少しでも減らせる手助けができたかなと思います。

最後に

これからも、一休情シスは今回紹介したツールのようなものなどを提供して、
社員が 「本来やるべき業務に集中して時間を割けるよう」 サポートを続けていきます。
そして、この記事が同じ志を持つ誰かの参考になれば嬉しいです。

id:undersooon でした。

明日は@s-tokutake の 「Hangfire [導入編]」 です。お楽しみに!

普段MacやLinuxでWeb開発している方向けに知ってもらいたいC#とWindows

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

qiita.com


宿泊事業部のいがにんこと山口です。
UIUXチームでフロントエンド、バックエンドのアプリケーション開発を担当しています。

一休では宿泊事業とレストラン事業があります。
私が所属する宿泊事業では開発言語にC#とVB.NETを使用しています。
その背景から開発にはWindowsを使っています。
普段MacやLinuxでWeb開発しているWebエンジニアにとってはWindows、C#を使ったWeb開発はあまり馴染みがないかもしれません。
そこでそんな方向けにWindows、C#を使ったWeb開発についてお話したいと思います。

C#について

まずはC#について。
C#に触れたことのない方はどんなイメージを持っていますかね?
Microsoft製?Javaみたい?使っている企業は?

色々なイメージがあるかと思います。
ここではC#を企業、開発環境、記述といった点から説明します。

使っている企業

C#を使っている企業ってどんな業界なんでしょう?
主にゲーム系、金融系で使われているイメージです。
ゲーム系はコンシューマー、ソシャゲに限らないですが、Unityを使っている企業はその流れでサーバーサイドもC#という企業が多いようです。
金融系はMicrosoftのサポートを期待しての導入でしょうか。
サポート期間の長さも特徴の一つで、それも採用の理由の一つかもしれません。 https://support.microsoft.com/ja-jp/lifecycle/search?alpha=Microsoft%20.NET%20Framework%203.5 を見ると2008年に出た.Net Framework 3.5(C#の実行環境)が2028年までサポートされることになっているので、20年ものサポート期間があることになります。 自社のWebサービス、アプリ(ゲームを除く)をやっている会社ではまだまだ採用している企業が多いとは言えません。
余談ですがC#erに特化した会社なんてのもできましたがゲーム向けですね。

開発環境

弊社ではVisual StudioというIDEを使用しています。
Visual Studioと聞いて思い浮かべるのはVisual Studio Codeでしょうか。
Visual StudioとVisual Studio Codeは全くの別物です。
Visual Studio Codeはクロスプラットフォームで動作するのに対して、Visual StudioはWindowsでのみ動作します。
C#の開発に使用するIDEとしてはVisual Studioが一強です。
他にはVisual Studio for MacやJetBrainsから出ているRiderというIDEもありますが一休では使用していません。

一休は.Net FrameworkというWindowsでのみ動作するC#の実行環境をメインに使用しています。
そのためWindowsにロックインされていますが、クロスプラットフォームである.Net Coreの登場に加え、上記のIDEもあるためMac、Linuxでの開発も可能です。
まだ.Net Coreの採用事例は少ないですがC#はWindowsだけのものではなくなってきています。

Windows内部でのWebサーバー

ローカル開発環境はIISというものを使用します。
いわゆるApacheやnginxのようなWebサーバーです。
画像のようにGUIからの操作が可能であり、もちろんCUIからの操作も可能です。
一休では開発環境の初期構築をしてくれるバッチファイルがあり、それを実行するとこのIISのサイトを構築、設定してくれるようになっています。

f:id:igatea:20181214181051p:plain

Docker

実はWindowsもDockerを動かすことができます。
Macで使われているDocker for MacのWindows版、Docker for Windowsがあります。
内部ではHyper-VでMobyLinuxを立ててその上にDockerコンテナがホスティングされるようになっています。

f:id:igatea:20181217035420p:plain

それだけでなく今やVirualboxなどの仮想環境構築ソフトに頼らずUbuntuも使うこともできるWSL(Windows Subsystem for Linux)というものもあります。
VagrantやDockerとは使い勝手が違うので躓くこともありますが、使用用途を限定すれば今のところ問題ありません。 www.atmarkit.co.jp

Webフレームワーク

一休ではC#でWebを開発するときにASP.NET Web FormsとASP.NET MVC、ASP.NET Web APIを使用しています。
ASP.NET Web FormsはイベントドリブンモデルでWindowsアプリケーションの知識をWebでも活用できるように作られたフレームワークです。
単体テストを行いにくかったり、固有の概念やビューとなるHTMLの制御が難しいなど、今のWeb開発には合っておらず新規で採用するメリットはありません。
そのためこれから新規プロジェクトを作る場合はMVCかWeb APIで作成するかと思います。

ASP.NET MVCについては特筆する点はありませんが、一般的なMVCフレームワークの機能が備わっており、薄すぎず厚すぎずでちょうどいい塩梅のフレームワークとなっています。
ASP.NET MVCはMVCという名前はついていますが実はモデルに相当する機能はありません。
そこでロックインされていないのでモデルの作成は柔軟に行えるようになっているところもメリットです。

記述型式

ここからはC#の記述を紹介します。
C#は強い静的型付けの言語です。
そしてオブジェクト指向言語でもあります。

実際のコードを見てみましょう。

public class Child : Parent
{
    private readonly string str;

    private readonly int num;

    public Child(string str, int num)
    {
        this.str = str;
        this.num = num;
    }

    public string GetContent()
    {
        return $"str = {str}, num = {num}";
    }
}

var child = new Child("test", 1);
child.GetContent();
// str = test, num = 1

オブジェクト指向言語をやってきた方なら細かい差異はあれど理解しやすいのではないでしょうか。
このコードだけ見るとかなりJavaに近いですね。
名前空間、パッケージによるアクセシビリティレベルが違ったりEnumにメソッドが生やせないといった違いはあります。
加えて型推論、getter setter、async awaitなど便利な機能に加えてLINQという便利なコレクションライブラリもあります。

型推論

// int型に
var num = 1;

// List<string>型に
var strList = new List<string>() { };

getter, setter, メンバ

public class Person
{
    public string FirstName { get; set; }

    public string LastName { get; set; }

    public string FullName
    {
        get => $"{FirstName}{LastName}";
    }
}


var Person = new Person();
Person.FirstName = "Taro";
Person.LastName = "Tanaka";
Console.WriteLine(Person.FullName);

プロパティごとにアクセス修飾子を変えることもできます。

public int Age
{
    get;
    private set;
}

readonlyというものも。

public class Person
{
    private readonly DateTime birthday;

    public Person(DateTime birthday)
    {
        this.birthday = birthday;
    }
}

インスタンスフィールドであればコンストラクタ内でのみ割り当て可能という制限をつけることができます。

初期化

オブジェクト初期化子
public class Person
{
    public string FirstName { get; set; }

    public string LastName { get; set; }

    public Person() { }
}

var Person = new Person()
{
    FirstName = "Taro",
    LastName = "Tanaka"
};
名前付き引数
public class Person
{
    public Person(int age, DateTime birthday)
    {
        // 処理
    }
}

var Person = new Person(age: 5, birthday: DateTime.Now);

async await

単数のタスク
public async Task<string> GetSingleAsync()
{
    var result = await GetStringAsync();
    return $"結果={result}";
}

private Task<string> GetStringAsync()
{
    var task = Task<string>.Run(() =>
    {
        // 何か重い処理
        return "async!";
    });
    return task;
}

var single = new SampleAsync().GetSingleAsync().Result;
複数のタスク
public async Task<string> GetMultiAsync()
{
    var task1 = GetStringAsync();
    var task2 = GetStringAsync();
    var task3 = GetStringAsync();
    var task4 = GetIntAsync();

    var results = await Task.WhenAll(task1, task2, task3);

    return string.Join(",", results);
}

var multi = new SampleAsync().GetMultiAsync().Result;

LINQ

コレクションを便利に扱える拡張メソッドです。
これがとても強力でこれがあるからこそC#は良いと言えるほどです。

var arr = new string[] { "a", "bb", "ccc" };

// 含んでいるか
arr.Any(s => s == "a");
// True

// 絞り込み
arr.Where(s => s.Length >= 2);
// IEnumerable<string> { "bb", "ccc" }

// 射影
arr.Select(s => s + s);
// IEnumerable<string> { "aa", "bbbb", "cccccc" }

// 他にも色々

IEnumerableな値(簡単に言うとforeachでループ可能)が返されるためメソッドチェーンで書くことが可能です。

arr.Where(s => s.Length >= 2)
    .Select(s => s + s)
    .ToArray();

他にも

  • 拡張メソッド
  • ジェネリクス
  • 属性定義、Attribute
  • null条件演算子
  • 例外フィルター
  • タプル、分解

といったものがあります。
記述だけ見るとモダンな言語に劣らず便利なものがそろっていますよね。
この記事で少しでもWindows、C#でのWeb開発に興味を持ってもらえたら幸いです。

明日はid:undersooon 「ちょっとしたことを検索できる」Slack botを作った、です。

一休.comにおけるAMP導入と今について

本記事は、一休.com Advent Calendar 2018の16日目の記事です。

qiita.com


デジタルマーケティング部で主に宿泊サイトを担当している田中(id:yakisoba6318)です。

今回は今年2月に導入したAMPについて導入時と今について紹介したいと思います。 内容に関しては主に宿泊サイトの話となります。

AMPとは?

AMPとは、Accelerated Mobile Pages (アクセラレイティッド・モバイル・ページ)と言い、主にGoogleが提唱しているモバイルウェブ高速化を目的としたプロジェクトです。

ここではそのAMPプロジェクトのオープンソースフレームワーク(AMP HTML)を指してAMPと言います。

www.ampproject.org

一休.comにおけるAMP導入動機

  • ランディングページのスピード改善
  • 速度改善による流入の増加
  • ユーザ体験向上

などいろいろありますが

簡潔に言うと ランディングページの最適化 です。

Choose a structured data feature  |  Search  |  Google Developers

一休.comのAMPページの紹介

現在一休で公開しているAMP対応ページとポイントを紹介します。

f:id:yakisoba6318:20181213171024p:plain
左からレストラン店舗ページ、宿泊施設ページ、宿泊観光ページ、宿泊特集ページ

レストラン店舗ページAMP

  • リッチカード x AMPで流入増
  • amp-list でプラン情報、割引率を動的表示

ランチ - ザ・ロビー - ザ・ペニンシュラ東京/コンチネンタルダイニング [一休.comレストラン]
*スマホからのみ確認できます

宿泊施設ページAMP

  • amp-list プラン情報・クチコミ表示
  • amp-access でクーポン表示

ザ・プリンス パークタワー東京 - 宿泊予約は[一休.com]
*スマホからのみ確認できます

宿泊観光ページAMP

  • レスポンシブ(Canonical / Responsive AMP )
  • amp-toolbox-optimizerを使ったページの最適化
  • まだまだ試験的なページで今は京都、金沢、箱根や一部温泉地のみですがこれからエリアを拡大予定

京都観光で行きたい名所!京都旅行におすすめ人気スポットランキング 19選【2018年】 - [一休.com]
*レスポンシブ対応のためPC・スマホどちらでも確認できます

宿泊特集ページ

  • amp-story実験的に公開中
  • 高級宿の素材をリッチに表示できるページ
  • ただ、日本では検索でAMPViewとしての表示はまだ出来てない
  • 今後の展開を期待

ジ・ウザテラス ビーチクラブヴィラズ 新規開業[一休.com]
*amp-storyがPC表示に対応しているためPC・スマホどちらでも確認できます

AMPコンポーネントの活用例

AMPページを作るにあたって欠かせないのが公開されてるコンポーネントです。 日々いろんなコンポートの開発が進んでいてAMPで出来ることが増えてきています。 今回はその一部を使った利用例を紹介します。

www.ampproject.org

動的なカルーセルを出したい

<amp-list 
    src="フォトギャラリーAPI"
    height=”254”
    layout=”fixed-height”
    single-item>
    <template type="amp-mustache">
      <amp-carousel
                    controls
                    loop
                    height="254"
                    layout="fixed-height"
                    type="slides">
        {{#photos}}
        <div>
          <amp-img src="{{ImageUrl}}"
                   layout="fill"
                   alt="{{ImageAlt}}"></amp-img>
        </div> 
        {{/photos}}
      </amp-carousel>
    </template>
</amp-list>

amp-listはamp上でxhrできるのでAPIからリスト取得、受け取ったリストをmustache形式でループさせて画像を動的に生成することができます。

https://www.ampproject.org/docs/reference/components/amp-list https://www.ampproject.org/docs/reference/components/amp-mustache

カルーセルの画像枚数を表示させたい

<amp-state id="GuideTopPhotoUrl" src="フォトギャラリーAPI"></amp-state>
<amp-carousel
    controls
    loop
    height="254"
    layout="fixed-height"
    type="slides"
    on="slideChange:AMP.setState({
      GuideState: {
        header: {
          selectedSlide:event.index,
          Is_slide:true
        }
      }
    })">
<!-- 略 -->
</amp-carousel>
<div>
    <span
    [text]="!GuideState.header.is_slide ? '' : 
(GuideState.header.selectedSlide + 1)  +
 '/' +
(GuideTopPhotoUrl.items.photos.length + 1) ">
    </span>
</div>

on属性にslideChangeをトリガーを設定してsetStateを更新、更新した情報をspanにbindすることで動的に枚数を更新することができます。

https://www.ampproject.org/docs/reference/components/amp-carousel https://github.com/ampproject/amphtml/blob/14153fe212a80aeb6f1e7a7f14e4849cc228eba9/spec/amp-actions-and-events.md#L177

結局AMPはどうなの?

紹介したように既に多くのページをAMP化してきましたが効果どうだったのか、 いろいろ工夫が必要な点もあったのでそれを踏まえて紹介したいと思います。

サイトパフォーマンス

f:id:yakisoba6318:20181213183156p:plain
Before: AMP Visually Complete 4G
上から順に通常のスマホページ、AMPページ、Googleキャッシュ上のAMPページ

こちらは少し古いレポートですが、始めはAMPコンポーネントの使い過ぎやamp-stateの不要な更新が多かったため AMPの要件は満たしていたがユーザ体験が悪化していました。

なので対応として

  • Googleキャッシュからの配信の恩恵を受けるためリアルタイム性の不要なコンテンツはxhrで取得しない
  • amp-stateの更新をbindするのに時間がかかるためUI上での不要なstate管理を辞める

具体的な例として

  • フォトギャラリーのamp-listを辞める
  • 開閉のUIをamp-state + hidden属性からamp-accordionに変更

結果として

変更
After: AMP Visually Complete 4G
上から順に通常のスマホページ、AMPページ、Googleキャッシュ上のAMPページ

AMPがoriginのスマホページよりVisually Completeの面では早い状態まで持っていくことができました。

ちなみにTTFBはoriginもほぼ同じでしたがDOMContentLoadで大きく遅れを取った結果このような数字になっていました(web page test)

ユーザ体験

amp-listで施設のプランを取得していますが、導入当初は読み込み後に展開するようにしていました。

変更前

ただ、これだとページ下部のコンテンツ(アクセスや施設情報など)閲覧中にリフローによって画面がずれ込み体験が悪くなっていました。

そこであらかじめスケルトンスクリーンを用意して高さを確保することでユーザ体験を損なわないように対応しました。(AMP開発者の方からのアドバイス)

変更後

これで表示が遅れてもユーザ体験に影響が少ないページになりました。

今後の方針

一休.comでの今後のAMP展開については以下の検討をしています。

  • amp-storyの活用事例を増やす
  • AMP上でjsが動くamp-scriptを使ったコンテンツのリッチ化
  • ITP2.0によるトラッキング対策(Safari対応)としてamppackagerやamp-toolbox-optimizerなどの使用による対応

digitalidentity.co.jp

まとめ

AMPのコンポーネントは容量用法を意識して使うのが良さそうだと感じました。

AMPページとしてのサイトの役割を考えつつ良質なユーザ体験と流入を獲得できるサービスをこれからも作っていきたいと思います。

AMPと聞くとスマホだけの印象のありますが、 PCページとしてのAMPやコンポーネントを個別にshadowDOMとしてページに取り入れることができたりと活用の幅はとても広いです。 今後の進化からも目が離せないですね!

明日は id:IganinTea さんの「 C#とWindows」です!

Ikyu Frontend Meetupを開催しました

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

qiita.com


こんにちは。 id:kentana20 です。一休で宿泊サービスの開発をしています。 今日は一昨日の夜に実施したイベント「Ikyu Frontend Meetup」の様子をレポートしたいと思います。イベントページはこちら。

ikyu.connpass.com

年末の忙しい時期にもかかわらず、多くの方にご応募・ご参加いただきました。

f:id:kentana20:20181212191052j:plain

今回のイベントのきっかけ

過去に2度、一休ではテック系イベントをやっていましたが、「ぼちぼちまたイベントやりたいな〜」と思っていたときに、 id:supercalifragilisticexpiali が書いた

user-first.ikyu.co.jp

を見た他社のエンジニアの方から「情報交換しましょう」と複数問い合わせや依頼があったので、イベントにしてしまおう、と思って今回のMeetup開催に至りました。

セッションの内容

今回のイベントでは「一休.com / 一休レストランでのフロントエンド開発」をテーマに3本のセッションを行いました。

セッション1: 「JavaScript/Vue.js アプリケーションのパフォーマンスチューニング」

1本目のセッションは宿泊サービスのエンジニアである id:ryo-utsunomiya がJavaScript/Vue.jsのパフォーマンスチューニングについてお話しました。

f:id:kentana20:20181212191727j:plain
ryo-utsunomiya によるJavaScript/Vue.jsのパフォーマンスチューニングの事例

一休.comモバイルWebのホテルページを高速化した事例をもとに

  • Lighthouse, Calibreを使ったパフォーマンスの計測
  • 同業他社のページを参考にした目標設定
  • JavaScriptのチューニングのポイント

などをお話しました。

f:id:kentana20:20181212193014j:plain

セッション2: 「imgix導入で画像最適化とサイトスピード改善」

2本目のセッションでは、画像の最適化によるチューニングの事例を id:akasakas がお話しました。

f:id:kentana20:20181212194216j:plain
akasakasによる画像最適化事例のセッション

もともと自社(Image Magick)で画像のリサイズや切り抜きなどの加工処理をしていたところを、画像最適化/配信のSaaSであるimgixを導入して最適化したお話を

  • 導入前の課題整理
  • 技術選定の観点とimgixに意思決定したポイント
  • imgixの優れている点
  • 導入後の効果

などの流れでお話しました。課題 → 解決のための手法 → 解決後の成果が順を追って語られていて、とてもわかりやすいセッションでした。セッションの中でも語られていましたが、一休が提供するサービスにおいて画像はとても重要で、ユーザ体験に大きな影響を与える部分なので、最適化によってユーザ体験を向上できたことは本当に良い成果につながったと思っています。

f:id:kentana20:20181212195231j:plain

セッション3: 「一休.comレストランのスマートフォン検索ページがSPAになりました」

3本目のセッションでは、イベントのきっかけになったブログエントリをもとに id:supercalifragilisticexpiali が一休レストランモバイルWebのレストラン検索ページをSPAにした事例ををお話しました。

  • 「もっと使いやすく」「サクサク動くように」というプロダクトのニーズ
  • SEOを落とさないようにという集客面のニーズ
  • Atomic Designを意識したコンポーネント指向設計を取り入れて生産性を上げたいという技術面でのニーズ

といった多角的な観点から Vue.js, Nuxt.js, ITCSS を導入した事例を細部まで丁寧にお話しました。

f:id:kentana20:20181212195441j:plain

内容が濃く、参加者のみなさんも真剣に聞いてくださっていて、とても良いセッションでした。

パネルディスカッション / 懇親会

3本のセッション終了後はビール片手にパネルディスカッションと懇親会を行いました。

事前に参加者の方からいただいていた質問をベースにスピーカーと話したり、参加者から当日出た質問に答えるQ&A形式で進行しました。

f:id:kentana20:20181212204134j:plain f:id:kentana20:20181212205745j:plain f:id:kentana20:20181212215210j:plain

編集後記

過去2回は他社と合同で実施したMeetupイベントでしたが、今回は一休単独での開催だったので、参加者が集まるか、イベントに満足いただけるか、など不安な点はありましたが、まずまず満足いただけたようで、開催してよかったです。

一休.com / 一休レストランともに、まだまだフロントエンド開発でやりたいことはたくさんあるので、継続的にコツコツ改善を続けてユーザにとって使いやすいサービスを提供していきたいと思います。

明日はアドベントカレンダーはお休み、明後日16日は id:yakisoba6318 による「 一休.comにおけるAMP導入について」です。お楽しみに!

一休レストランの店舗ページをSPA化して Fastly で段階的リリースした話

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

qiita.com


こんにちは。
今年の7月に入社したレストラン事業部の渥美です。
一休.com レストランにてフロントエンドとバックエンドの開発を行なっております。

この記事の概要

  • 店舗ページをSPA化した背景
    • 店舗ページリニューアル
    • プラン詳細ページのSPA化
  • Vue.js によるモーダルの実装方針
    • 事前ロード
    • モーダルの開閉
    • URLを動的に生成する
  • Fastly での段階的リリース
    • Fastly について
    • VCL の設定
    • Fastly Fiddle によるテスト
  • 今後の展望

店舗ページをSPA化した背景

店舗ページリニューアル

私が一休に入社する1ヶ月前、店舗の情報は複数のページにまたがっていました。 具体的には、店舗のページはプラン一覧や店舗情報、アクセスマップなどの情報が別々のタブに分かれており、ページ遷移が必要でした。
しかし、restaurant2*1へのリニューアルと共に、SPAとして生まれ変わったのでした。

f:id:atsumim:20181213113140p:plain
Before: 旧店舗ページ

f:id:atsumim:20181213113445p:plain
After: 新店舗ページ

プラン詳細ページのSPA化

その後入社した私はユーザ体験をさらに向上させるために、 独立していたプランの詳細ページのモーダル化を担当しました。 これにより、店舗ページ内で完結できる範囲が広がりより予約がしやすいUIになりました。

※現在も下記のプラン詳細ページは動いていますが、後述するようにモーダルへ移行予定です。

f:id:atsumim:20181211173120p:plain
Before: プラン詳細ページ
f:id:atsumim:20181211172225p:plain
After: プラン詳細モーダル

今回はこのモーダル化の実装について簡単にお話するとともに、
段階的リリースの取り組みについてご紹介します。

Vue.js によるモーダルの実装方針

一休レストランのフロントエンド開発では Vue.js が使われています。 モーダルの実装もVue.jsで制御しており、今回はその基本的な実装方針を紹介します。

大まかな処理は以下のとおりです。

// 店舗ページの Vue ファイル
<template>
  <!-- 略 -->
  <object-plan-item
    @mouseenter="preloadPlanDetail"
    @click:plan="openPlanDetail"
  />
  <composition-plan-detail-modal
    :activated="showPlanDetail"
    @update:activated="onClose"
  />
</template>
export default {
  // 略
  data() {
    return { showPlanDetail: false };
  },
  methods: {
    openPlanDetail(plan) {
      // ①モーダルの開閉処理
      this.showPlanDetail = true;
      // ②URL の動的生成
      global.history.pushState(null, '', this.planDetailUrl(plan));
    },
    onClose() {
      // ①モーダルの開閉処理
      this.showPlanDetail = false;
      // ②URL の動的生成
      global.history.pushState(null, '', `/${this.restaurant.id}/`);
    },
    preloadPlanDetail(plan) {
     // ③プラン詳細情報の先読み
    },
    planDetailUrl(plan) {
      // URL を生成し返却する
    },
  }
}

<object-plan-item> が各プラン項目のコンポーネント、
<composition-plan-detail-modal> がモーダルのコンポーネントです。

①モーダルの開閉処理

モーダルの表示状態は showPlanDetail を定義して、この boolean 値で管理しています。 単純ですが、プランの「詳細・予約」ボタンがクリックされたときに showPlanDetail = true,
モーダルの背景がクリックされたときに showPlanDetail = false となり、表示が切り替わります。

②URL の動的生成

モーダルを開いたときに URL を動的に生成しています。
History API を利用して、

global.history.pushState(null, '', this.planDetailUrl({ plan, time }));

とすることでモーダルを開いたときに URL が更新され、履歴にも追加するようにしています。
モーダルを閉じるときは、

global.history.pushState(null, '', `/${this.restaurant.id}/`);

として店舗ページの URL に戻します。

③プランの詳細情報を先読み

プランの詳細情報を先に読み込む処理を書いています。 実際には <object-plan-item> から mouseenter のイベントが emit され、preloadPlanDetail が発火されます。
ユーザからすると、プラン一覧の項目をマウスオーバーすると同時にプリロードが走るので モーダルを開いたときにはシームレスにプラン詳細が表示される体験を提供できます。 (※ローディングが完了していない場合はローディング画像が表示されます)


勿論他にも処理はありますが、以上がモーダル化の大まかな実装となります。
Vue.js を使うことでモーダルの開閉状態や、先読みしたプランの情報をモーダルに受け渡せるのは便利な点でした。

Fastly での段階的リリース

今回モーダル化したページは予約導線に直結しているということもあり、 リリース当初は特定の店舗のみに限定公開しました。 この限定公開に Fastly を使いました。

Fastly について

一休ではリバースプロキシとして Fastly を利用しています。 Fastly は設定言語の VCL を書くことでリダイレクトの処理や、レスポンスの制御を簡単に行うことができます。

今回は、特定の店舗ページにアクセスしたときのみ、?modal_enabled=1というクエリパラメータを付与するように VCLの設定を行いました。 そして、このパラメータが付与されている場合はモーダルを開くようにアプリケーション側でハンドリングしました。

流れとしては下記のようなイメージです。

  • 特定店舗のURLにアクセスする
  • Fastly で ?modal_enabled=1 が付与されたURLにリダイレクトさせる
  • ?modal_enabled=1 が存在するときのみアプリケーション側でモーダルを開く

メリット

Fastly での段階的リリースのメリットとしては下記のようなものがあります。

  • 限定リリースができる
    • 今回の主目的である、URLに応じた限定リリースが可能です。
  • Fastly のデプロイが高速
    • VCL の本番環境への反映が、CIのテストを含めて2分ほどで完了します。
  • 切り戻しが容易
    • 上記のデプロイが高速であることと関係するのですが、なにかトラブルがあった場合の切り戻しが容易です。 アプリケーションのリリースは20分程かかるのでこれはありがたいポイントです。

それまで他の手法での段階的リリースは行われていたものの、
Fastly を使った段階的リリースは初めての試みでした。

VCL の設定

さて、実際に VCL で設定した内容をご紹介します。(必要に応じて変数名等を変えています)

table test_ids {
  "100000": "true",
}

sub vcl_recv {
#FASTLY recv
  if (req.url.qs !~ "modal_enabled=" &&
    req.url.path ~ "^\/(\d{6})" &&
    table.lookup(test_ids, re.group.1)
  ) {
    error 700; # 内部的にerror statusを飛ばすことでリダイレクトを行う
  }
}

sub vcl_error {
#FASTLY error
  if (obj.status == 700) {
    set obj.http.Location  = "https://" req.http.host req.url.path "?modal_enabled=1" if(req.url.qs == "", "", "&" req.url.qs);
    set obj.status = 307;
    set obj.response = "Temporary Redirect";
    return (deliver);
  }
}

table の項目で特定店舗のIDを指定しています。

ここで気になった方もいるかも知れませんが、 VCL の設定では内部的に error status を飛ばすことでリダイレクトを行います。

Fastly Fiddle によるテスト

この VCL の設定を行うにあたり、Fastly Fiddle というサービスを利用しました。これは VCL のコードをオンラインで簡単にテストできるサービスです。 Fastly Labsが実験的に運用しているようです。

f:id:atsumim:20181213141040p:plain
Fastly Fiddle の画面

Fastly Fiddle は実行したコードを他のユーザと共有することもできます。 上記のコードをサンプルとして用意しました。 ページにアクセスしたら、右上の[RUN]ボタンを押してみてください。 table test_ids に含まれるIDに対応するURL(/100000)に対してリクエストを送ると、リダイレクトが発生することがわかります。

f:id:atsumim:20181213140910p:plain
リダイレクト結果

反対に、test_idsに含まれないIDに対応するURL(例えば /200000)にリクエストを送ってもリダイレクトされません。 是非Send a requestの項目を/100000から変更して遊んでみてください。

このようにして、Fastly による特定店舗のみの段階的リリースができるようになりました。

今後の展望

今回のモーダル実装で店舗のページのSPA化が進みました。 しかし、モーダルのURLに直接アクセスしたときは、未だ以前のプラン詳細ページに遷移するようになっています。
今後はこのような導線にもモーダルが開くように対応していきます。

また、今回 Fastly での段階的リリースの仕組みも整ったので、今後の機能追加でどんどん活用していきたいと思います。

明日は id:kentana20 さんの 12/12 Ikyu Frontend Meetup 開催レポート です! お楽しみに!

*1:新しいアーキテクチャによる一休.com レストランのWebアプリケーションを指す。

一休のUI/UXデザイナーとして私がやっている4つのこと

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

qiita.com


はじめに

デザイナーと聞いて、皆さんはどのような人を想像しますか?
「見た目を美しくかっこよく作れる人」、「ビジュアルデザインの専門家」というイメージを持たれている方も多いのではないでしょうか?
デザイナーのアウトプットだけを見ればその通りですが、アウトプットに至るまでのデザインの考え方 や取り組み方、役割がここ数年で変わってきています。
以前は、情報を整理して色や形を使いこなすビジュアルデザインがデザイナーの中心的な業務だと考えられていました。
しかし、Webデザインの標準化が進み、類似サービスの乱立も増え、ユーザーから好まれるサービスかどうかが重視されるようになりました。
より効果的なデザインを実現するために、事業戦略やサービスの現状、マーケットニーズに目を向け、デザインに活かそうとする動きが活発になりました。

とはいえ、理屈は理解できても具体的に何をすれば良いのかがわかりにくいと感じている方も多いはず。そこで今回は、一例として私が一休のデザイナーとして取り組んでいることをご紹介したいと思います。

Index
1) トーンマナ―などのルールは最低限にして都度考える
2) 自分たちが目指すデザインの方針を明文化する
3) デザイナーが自由に発散し、少し先の将来について考える場を設ける
4) デザインに取り掛かる前に考える

トーンマナ―などのルールは最低限にして都度考える

一休のレストラン事業のトーンマナーで定義しているのは、複数のページで登場する基本的なUIパーツとタイポグラフィの基準のみです。
あまり詳細まで定義するとルールに縛られて考える機会が失われる可能性がありますし、ドキュメントの整備に手間をかけるのも得策ではありません。
敢えてルールを決めすぎず、状況に応じてデザイナーが自分の頭で考え、最良と思うものを画面に反映させて効果を見てみる。
工夫できる余地を残すことでサービス改良の提案がしやすくなり、変化の促進につながると考えました。
ただし、都度デザインすることがユーザーのためにもサービスのためにもならない要素についてはVueコンポーネントに定義して、デザイン、コーディングの効率化とサービスの使いやすさの両方の実現を目指すことにしました。

目指したいデザインの方針を明文化して共有する

デザインをしていてしばしば頭を悩ませるのは正解が複数あることです。
デザインの善し悪しは判断軸によって変わりますし、人によって異なる感想を持ちます。
チームでデザインを考える場合、個々に全く異なるコンセプトで考えてしまい、サービス全体での一貫性が損なわれることもあります。
かといって、トーンマナ―を充実させれば良いかと言うと前述の通り、そうではないと思っています。

そこで、サービスデザインコンセプトというものを明文化することにしました。
ルールではなくコンセプト、つまり方向性です。
具体的にあれをする、これをするを書くわけではなく、実現したい景色を3つ~5つの項目に落とし込み、自分たちが作っていくプロダクトの目標として据えました。
そして、この目標にマッチしていない箇所について、「こうしたらどうだろう?」「こういう方法もあるかも」「どうやって進めようか?」といったことを、次項で紹介する「UI/UXデザイン語り場」で話し合っています。

一休レストラン事業のサービスデザインコンセプト(抜粋)

  • 楽しい、予定がなくても見たくなるサービスであること
  • 最短で目的を達成できるサービスであること
  • 直感的に操作ができ、考える必要がないサービス
  • システマチックになりすぎず人間味があるサービス

デザイナーが自由に発散し、少し先の将来について考える場を設ける

目前のタスク消化であっという間に数か月が過ぎてしまった、という経験はありませんか?
また、自分の考えが正しいかわからず提案しても良いものかと躊躇してしまう、ということはありませんか?
これらの課題をチームで解消して、小さなことでも良いので実行を積み重ねていきたい、という想いから「UI/UXデザイン語り場」というミーティングを実施することにしました。
週に一時間、今抱えているタスクから離れてこれからやっていきたいこと、やってみたいことについて考え、自由に話すための時間です。

提案した内容をなんでもやれば良いわけではありませんが、答えのないことを行動前にあれこれ考えすぎても意味がありません。
そして最も避けるべきは「何もしないこと」です。
何もしなければ業績は低下していきます。
よほど的外れなことでない限り、まずやってみる、やってみた結果を踏まえて次の打ち手を考える。
この繰り返しがサービス改善には不可欠です。

デザインに取り掛かる前に考える

「こういう機能を付けたいから画面デザインをください」と言われた時に、いきなりデザインツールに向かってしまうのは適切ではありません。
その前に、まずは企画者の話に耳を傾け、実現したいこと、手段、現時点での仮説、リスクなどを書き出し、前提情報を整理します。
そして、それらの前提を元にターゲットの深掘りをしていきます。
データからわかることと、データからはわからない不確かなことに分けてまとめると良いでしょう。
ヒアリングやデータ解析、他社分析の作業は必ずしもデザイナーが担当する必要はありません。
自分でできるなら自分でやれば良いですし、できない場合には得意な人の協力を得ても良いと思います。
重要なことは事実と可能性をしっかりと把握することです。

一通りまとめ終えたら、いくつかのターゲットグループのユーザー像を想像できるようにしてから、いざデザインに取り組みます。
このプロセスを踏んだ時と踏んでいない時とで、デザインを提案する際の説得力も自信も、提案内容も変わるはずです。
多少手間でもこのプロセスを踏んで予測し、実施後にどうなったのかをノウハウとして蓄積すると、アイデアの引き出しが増やせると思います。

おわりに

いかがだったでしょうか?
「そりゃそうでしょ!」と思う内容が多かったと思いますが、13年間ECサイトのデザインに携わってきて気付いたのは、難しく考える必要はないということでした。
シンプルに考える、当たり前のことを当たり前にちゃんとやってみた結果、見えてくることも多々あります。
少しでも参考になれば幸いです。

最後までお読みいただき、ありがとうございました。
次回は@atsumim の 「プラン詳細ページのモーダル化を Fastly で段階的リリースした話」です。お楽しみに!

Storybook を自作して「フロントエンドビルドが遅い問題」に立ち向かう

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

qiita.com


こんにちは。レストラン事業部の所澤です。 WEBアプリケーションエンジニアとしてフロント/サーバー問わず機能開発を行っています。

今回は一休.com レストランの旧アプリケーションのフロントエンド開発環境改善についてお話します。

※ この記事の執筆時点では以下の内容は master に取り込まれていません。同僚のフロントエンドエンジニア(ガチ勢)から何か指摘があったら追記します。

この記事の概要

  • 一休.com レストランの旧WEBアプリケーション(以下 restaurant1 )はなぜかフロントエンドビルドが超遅い。
  • Storybook のようなアプリと切り離された、高速でビルドできる環境があればもっと快適に開発できるのではないか?
  • Storybook だと vue-devtools が使えないので Storybook (の最低限の機能を持つ)小さいアプリケーションを作ってみた。

一休.com レストランの開発環境

新アーキテクチャへの移行状況

何度かこのブログでも取り上げていますが、現在(2018年12月)、一休.com レストランは新旧ふたつのWEBアプリケーションが並行する形で運用されています。 古くから稼働している VBScript で書かれたアプリケーションは restaurant1、リニューアル後の Python で書かれた新しいアプリケーションは restaurant2 と呼ばれています。 着々と restaurant2 への移行は順調に進んでいますが、依然として restaurant1 の上に乗っている部分も多く残っています。

f:id:shozawa:20181210145104p:plain:w300

たとえばスマートフォン版の店舗トップ画面は機能追加の機会が多いページですが、まだ restaurant2 への移行が完了していません。 新アーキテクチャだけを触ればOK、という状態まではまだ少しかかりそうだというのが現状です。

restaurant1 のフロントエンドについて

さて、"レガシー"などと言ってしまいましたが、実はフロントエンドに限って言えば旧アプリケーションもそこまで古くはありません。 jQueryでゴリゴリ書かれたページもありますが、主要ページに関しては ES2015+ と Vue.js で開発できる環境が整っています。 restaurant2 のピカピカのコードに比べると若干見劣りはしますが十分モダンだと言っていいでしょう。

問題は、フロントエンドビルドが とてつもなく遅い ことです。 フルビルドに時間がかかることに関して良いとしても watch しているときの差分ビルドも 1分以上 かかります。(Core i7 の開発機で)

遅い原因は特定できていないのですが、

  • アセットの肥大化
  • そもそも Windows だとビルドが遅い( restaurant1 は ASP で書かれているので Windows 必須です)
  • セキュリティのために入れているファイル監視ソフトの相性の問題

など、いろいろな可能性が考えられます。

さて本来であれば根本原因を特定して解決するのが筋ですが、どうにも問題の切り分けがうまくいかないので別の解決方法を考えてみます。

restaurant1 に Storybook を導入してみる

いままではユニットテストを書いてブラウザを使った動作確認の回数を減らし、なるべくこの問題を意識しなくて済むように気をつけていました。 しかしやはり新規のコンポーネントを0から作るときやデザインの微調整をする際はどうしてもビルドの遅さが気になります。

restaurant2 や宿泊のサイトでは 既に Storybook を導入済みだったこともあり、"開発用の Playground として" restaurant1 にも Storybook を入れてみようと試してみました。

f:id:shozawa:20181210150410p:plain:w300

※ restaurant2 では"デザイナーとの協働をスムーズにする" という目的で Storybook が活用されています。詳細はまたいつか。

Storybook で十分、か?

さて冒頭でも書きましたが結局 Storybook を導入することは見送りました。理由は vue-devtools が使えないからです。

今回はデザインシステムとしてではなく、開発用の Playground として Storybook を使いたいので開発ツールがうまく動かない点は致命的な問題です。

(最初に気付けよ、という感じですが私自身はそれまであまりちゃんと Storybook を使ったことなかったので...)

Github の issue を見るとワークアラウンドがありそうですが...。すでに導入でだいぶ消耗していて、これ以上の yak shaving をする気は起きなかったので別の方法を検討することにしました。

(追記: 無理やりですが iframe を別タブで開くと vue-devtools が使えます)

Storybook 相当のアプリケーションを作ってみる

よく考えれば今回の用途に限って言えば Storybook の全機能が使える必要はありません。やりたいことは至ってシンプルです。

要求・仕様

  • アプリケーションと独立した環境で動作確認しながらコンポーネントを開発できる
  • vue-devtools が使える
  • LiveReload
  • Mac でも開発できる
  • コードのコピペなどせずに restaurant1 のコードがそのまま確認できる
  • Storybook 風にストーリーが書ける

この仕様を満たす小さなアプリケーションを書いてみることにしました。 ※ あくまで Playground として使い、最終確認はアプリケーションに組み込んでやる前提。

まずは使い方をご紹介

  • stories.js にStorybook 風のAPIでストーリーを追加していく
storiesOf('sample')
  .add('hello', h => h('h3', ['hello. this is my story.'], {}));

storiesOf('DatePicker')
  .add('select', h => h(CustomDatePicker, { props: { value: '2018-12-01' } }));
  • restaurant1 内で yarn play を実行してサーバーを起動

f:id:shozawa:20181210144606g:plain

UIがダサいのはご容赦ください...。

これだけですが、「アプリケーションと切り離された環境でコンポーネントを開発する」ということは実現できています。

こだわりポイント

ここからは蛇足な気がしますが、せっかくなのでこだわりポイントをご紹介します。

  • とにかくシンプルに!
  • 新しいライブラリを追加しない
  • Storybook like な API

以上の3点を心がけて実装しました。

このツールを使う人やツールの機能を拡張しようとしてコードを読む人の負担が最小限になるように気をつけています。

まずは何よりコードが小さく、シンプルになるように心がけました。また不用意に新しいライブラリを追加すると、でコードを読んだ人に負担もかけてしまうので restaurant1 に追加済みのライブラリのみを使用することにしました。

webpack-dev-server でホスト

今回はアプリケーションの中に playground ディレクトリを作りそこに関連ファイルを格納し、 webpack-dev-server でホストしています。 今回のモチベーションが「ビルドの速度改善」なので 本アプリの webpack の設定を使い回すことはせずに新しく playground 以下に数十行のシンプルな設定ファイル( playground/webpack.config.js )を追加しました。

// package.json
{
  // 略
  "scripts": {
    // 略
    "play": "webpack-dev-server --config playground/webpack.config.js"
  }
}
// playground/webpack.config.js
{
  // 略
  devServer: {
    contentBase: path.resolve(__dirname, 'dist'),
    port: 9000,
  },
}

9000番ポートで dist ディレクトリの内容をホストします。

storiesOf 関数

storiesOf 関数の実装はこれだけです。 storiesOf と add でオブジェクトにコンポーネントを登録していきます。

// playground.js
const stories = {}; // Component を格納
const tableOfContents = {}; // ナビゲーション用

// TODO: HMR
function storiesOf(title) {
  tableOfContents[title] = tableOfContents[title] || {};
  return {
    add(scenario, value) {
      const key = `${title}:${scenario}`;
      tableOfContents[title][scenario] = key;
      playground[key] = { render: value };
      return this;
    },
  };
}

const getStories = () => stories;
const getTableOfContents = () => tableOfContents;

export {
  storiesOf,
  getStories,
  getTableOfContents,
};

※ vue-play の実装を参考にさせていただきました。

github.com

プレビュー機能

import { getStories } from './playground';
export default {
  name: 'PlaygroundPreview',
  data() {
    return {
      scenario: '',
      stories: {},
    };
  },
  methods: {
    setScenario() {
      const hash = decodeURI(window.location.hash);
      this.scenario = hash.replace('#', '');
    },
  },
  computed: {
    current() {
      return this.stories[this.scenario];
    },
  },
  created() {
    this.stories = getStories();
    this.setScenario();
    window.addEventListener('hashchange', this.setScenario);
  },
  render(h) {
    return h(this.current, [], {});
  },
};

ストーリーとして登録したコンポーネントのプレビュー部分です。 URL のハッシュ部分にコンポーネントのキーを入れるようにし、ハッシュの変更によってプレビューされるコンポーネントが切り替わるようにしています。

http://localhost:9000/#{ストーリーのタイトル}:{ストーリーの小見出し}

今後の展望

webpack の設定ファイルを含め、250行程度のコードでここまでの内容が実現できました。

シンプルな実装で最低限やりたいことはできた、と思っています。

  • WEBフォントの読み込みができていない
  • Vuex と連携するコンポーネントの動作確認ができない
  • DefinePlugin の対応
  • async/await を使っているコードでエラーが出るので webpack の設定を見直し
  • UIがイケてない

などすでにいくつか課題は見つかっているのですが、プレゼンテーションだけに責任を持つシンプルなコンポーネントの開発であれば十分に活用できるかと思います。

実はデモ用にいくつか実際に使われているコンポーネントを追加しようと思ったのですが、ほとんどの主要コンポーネントが Vuex に依存していてうまく追加できませでした。

よく言われていることですが、あらためて Presentation Component と Container Component の分離が重要ですね。

もう少しブラッシュアップして、良さそうであれば master に取り込もうと思います。