一休.com Developers Blog

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

GoのDIライブラリgoogle/wireの使い方

f:id:ryo-utsunomiya:20191209031120j:plain:w375

こんにちは。宿泊事業本部の宇都宮です。この記事では、GoのDIライブラリgoogle/wireの使い方を紹介します。

この記事は一休.com Advent Calendar 2019の9日目の記事です。

DIとは

DI(Dependency Injection, 依存性の注入)とは、あるオブジェクトが依存しているオブジェクトを自ら用意するのではなく、外部から渡してもらう(外部から注入する)というデザインパターンです。

例として、以下のように、監督の名前を渡すとその監督の映画を全てリストにして返すメソッドを持った構造体を考えます。

func (ml *MovieLister) MoviesDirectedBy(director string) []Movie {
    allMovies := ml.finder.FindAll()
    result := make([]Movie, 0, len(allMovies))

    for _, m := range allMovies {
        if director == m.Director {
            result = append(result, m)
        }
    }

    return result
}

この構造体は finder というフィールドに FindAll() メソッドを持つ構造体を持っています。

type MovieLister struct {
    finder MoviesFinder
}

type MoviesFinder interface {
    FindAll() []Movie
}

このfinderは、通常の制御の流れだと、MovieListerが自分で初期化してセットすることになります。

func NewMovieLister() *MovieLister {
    return &MovieLister{
        finder: NewColonDelimitedMovieFinder("movies.txt"),
    }
}

しかし、これではMovieListerは特定のFinderと密結合してしまいます。データがRDBにあろうと外部のAPIにあろうと関係なく取得できるようにするためには、FinderをMovieListerの外で初期化して、MovieListerに渡す必要があります。

func NewMovieLister(finder MoviesFinder) *MovieLister {
    return &MovieLister{
        finder: finder,
    }
}

func main() {
    finder := NewColonDelimitedMovieFinder("movies.txt")
    ml := NewMovieLister(finder)
    fmt.Println(ml.MoviesDirectedBy("George Lucas"))
}

このように、DIパターンを用いると、コードの依存関係が明確になったり柔軟になったりといったメリットがあります。

また、Clean ArchitectureOnion Architectureといったアーキテクチャパターンは依存性逆転の原則に基づいており、このようなアーキテクチャパターンを使う上でもDIは必須条件になります。

DIのデメリットは、初期化が煩雑になることです。よくあるWebアプリケーションで考えても、

  1. HTTPハンドラはDomain Serviceに依存している
  2. Domain ServiceはRepositoryに依存している
  3. RepositoryはDBコネクションマネージャに依存している
  4. DBコネクションマネージャはconfigに依存している
  5. configは環境変数に依存している

といった具合になります(※実際にDomain Serviceが依存しているのはinterfaceだったりしますが、その辺は省略)。

そこで、Java、C#、PHPなど様々な言語で「DIコンテナ」と呼ばれるライブラリが開発されています。DIコンテナは、オブジェクトの初期化、管理、注入といった仕事を引き受けるライブラリで、DIパターンをベースにしたWebアプリケーションフレームワークも少なくありません(一休でも一部で使用している ASP.NET CoreはDIコンテナを内蔵しており、DIパターンがベースになっています)。

GoのDIライブラリ

Go製のDIライブラリは多数ありますが、いわゆる「DIコンテナ」とは違った、Goの言語特性に沿ったライブラリに人気があります。google/wireは2018年12月に公開されたGoogle製のDIライブラリで、2019年12月現在、(GitHubのスター数ベースで)最も人気のあるDIライブラリと思われます。

google/wire(以下、wire)の特徴は、go generateによるコード生成を通したDIである、という点です。wireが必要になるのは開発者の手元だけで、プロダクションコードでwireをimportする必要はありません。

もう一つの特徴は、コンストラクタ(wireにおいては Provider と呼ばれる、値を生成する関数)のシグネチャに制限が加わることです。そのため、ライブラリというよりはフレームワークである、と考えた方がよいでしょう。一定の制約を受け入れる代わりに利便性を享受することができます。

wireの使い方

wireを使うには、まず手元にwireをインストールする必要があります。

go get github.com/google/wire/cmd/wire

次に、依存関係を定義するファイルを用意します。このように、依存関係を解決する関数をwireではInjectorと呼びます。

//+ wireinject

package main

import "github.com/google/wire"

func initMovieLister(fileName string) *MovieLister {
    wire.Build(
        NewMovieLister,
        NewColonDelimitedMovieFinder,
    )
    return nil // wireはこの関数の戻り値を無視するので、nilを返せばよい
}

ここで重要なのは、1行目の //+build wireinject というビルドタグです。これによって、通常のビルド時には wire.go はビルド対象から除外されます。

また、wireでは wire.Build 関数の引数にProvider(コンストラクタ)を列挙します。wireはこれらの関数のシグネチャを調べて、依存関係を解決します。

ここで使っているProviderのシグネチャは以下のようになっています。

func NewColonDelimitedMovieFinder(fileName string) MoviesFinder
func NewMovieLister(finder MoviesFinder) *MovieLister

wireはこれらの関数のシグネチャを調べて、必要な依存関係を解決するためのコードを生成します。生成には、go get でインストールした wire コマンドを使います。

// Code generated by Wire. DO NOT EDIT.

//go:generate wire
//+build !wireinject

package main

// Injectors from wire.go:

func initMovieLister(fileName string) *MovieLister {
    moviesFinder := NewColonDelimitedMovieFinder(fileName)
    movieLister := NewMovieLister(moviesFinder)
    return movieLister
}

このようにして生成した initMovieLister はmainなどで普通に呼び出せます。

func main() {
    ml := initMovieLister("movies.txt")
    fmt.Println(ml.MoviesDirectedBy("George Lucas"))
}

なお、 wire.Build() の引数は 順不同 です。↓のように前後を入れ替えても、生成結果は変わりません。

   wire.Build(
        NewColonDelimitedMovieFinder,
        NewMovieLister,
    )

Providerのエラーハンドリング

Providerは、単に値を返すだけでなく、エラーやクリーンアップ用の関数を返すこともできます。たとえば、NewColonDelimitedMovieFinderがエラーを返すとすると、以下のようなシグネチャになります。

func NewColonDelimitedMovieFinder(fileName string) (MoviesFinder, error)

これに合わせて、 initMovieLister 関数もエラーを返すようにします。

func initMovieLister(fileName string) (*MovieLister, error) {
    wire.Build(
        NewMovieLister,
        NewColonDelimitedMovieFinder,
    )
    return nil, nil
}

生成後のコードでも、エラーハンドリングが行われるようになります。

func initMovieLister(fileName string) (*MovieLister, error) {
    moviesFinder, err := NewColonDelimitedMovieFinder(fileName)
    if err != nil {
        return nil, err
    }
    movieLister := NewMovieLister(moviesFinder)
    return movieLister, nil
}

Injectorのカスタマイズ

wire.goには好きなProvider関数を定義できます。ここで定義したProviderはwire_gen.goにコピーされます。これを利用して、シグネチャ的にwireでは扱えない関数(たとえば、引数が2つあっていずれもstringであるような関数)をProviderにできます。

たとえば、go標準の sql.Open() 関数ですね。

func Open(driverName, dataSourceName string) (*DB, error)

このままではwireで使えないので、sql.OpenのラッパーをInjectorに用意します。

type DriverName string
type DataSourceName string

func provideDBConn(driver DriverName, dsn DataSourceName) (*sql.DB, error) {
    return sql.Open(string(driver), string(dsn))
}

func initDBConn(driver DriverName, dsn DataSourceName) (*sql.DB, error) {
    wire.Build(
        provideDBConn,
    )
    return nil, nil
}

ここでは、文字列を型で区別可能にするため、独自型を定義しています。DB設定がDBConfigのような構造体にまとまっているなら、provideDBConn関数の引数にDBConfigを取って、そのフィールドをsql.Openに渡してもよいでしょう。

Provider Set

ProviderはSetという形でグループ化できます。

var movieListerSet = wire.NewSet(
    NewMovieLister,
    NewColonDelimitedMovieFinder,
)
   wire.Build(
        movieListerSet,
    )

Setの使用はオプショナルで、Setを使わなくても依存関係は解決できます。パッケージ名が衝突してエイリアスが必要になるような場面などでは、衝突を避けるためにSetを使うと便利でしょう。

インタフェースのバインド

当初の実装では、NewColonDelimitedMovieFinderはMoviesFinderインタフェースの値を返していますが、具体的な型(*ColonDelimitedMovieFinder)を返しても問題ありません。ただし、この場合、 wire.Bind() を使って *ColonDelimitedMovieFinder を MoviesFinderインタフェースに紐付ける必要があります。

func NewColonDelimitedMovieFinder(fileName string)*ColonDelimitedMovieFinder {}
func NewMovieLister(finder MoviesFinder) *MovieLister {}
   wire.Build(
        NewMovieLister,
        NewColonDelimitedMovieFinder,
        wire.Bind(new(MoviesFinder), new(*ColonDelimitedMovieFinder)),
    )

これによって、MoviesFinderインタフェースを要求するProviderには、*ColonDelimitedMovieFinder が渡されるようになります。

構造体のフィールドを参照する

↓のような構造体があるとき、NewMovieの引数にはDirector.Nameを渡したいと考えています。

type Movie struct {
    Director string
}

func NewMovie(director string) *Movie {
    return &Movie{Director: director}
}

type Director struct {
    Name string
}

func NewDirector(name string) *Director {
    return &Director{Name: name}
}

このようなときは wire.FieldsOf() を使います。

func initMovie() *Movie {
    wire.Build(
        NewMovie,
        NewDirector,
        wire.FieldsOf(new(*Director), "Name"),
    )
    return nil
}

生成後のコードはこんな感じ(Director.Nameは常に空文字列なので、実用的な例ではないですね。。。)。

func initMovie() *Movie {
    director := NewDirector()
    string2 := director.Name
    movie := NewMovie(string2)
    return movie
}

細かな注意点

値とポインタの違いに注意

wireを使ってるとたまにあるのが、値とポインタのズレです。

たとえば、↓のように、あるProviderはポインタを返し、別のProviderは値を取る、という風になっていると、wireは「No provider found for ColonDelimitedMovieFinder」のようなエラーを吐きます。

func NewColonDelimitedMovieFinder(fileName string) *ColonDelimitedMovieFinder
func NewMovieLister(finder ColonDelimitedMovieFinder) *MovieLister

戻り値か引数、いずれかの型が間違っているので、修正しましょう。

go runするときはwire_gen.goも一緒に

通常、 go run 時にはエントリーポイントの main.go だけを渡せばOKですが、wireを使っている際は wire_gen.go も合わせて渡す必要があります。

go run main.go wire_gen.go

このようにしないと、wire_gen.goで定義しているInjector関数が未定義になり、エラーになります。

おわりに

google/wire の使い方を紹介しました。主なユースケースは本記事で紹介した範囲で網羅できていると思います。

本記事で触れていないテクニックはまだあるので、興味のある方は User GuideBest Practices にも目を通してみてください。

社内に周知しているパソコン購入・パソコン交換のルール

qiita.com

この記事は、一休.com Advent Calendar 2019の6日目の記事です。

こんにちは、nakashunです。

普段は情シスみたいなことをやっています。

image

今年のAdvent Calendarについて、Slackでこんなご意見を頂いたので書いてみます。

意外と表に出てこない、入社時に支給されるパソコンに加え

追加で購入する場合・交換する場合のルールも公開してみようと思います。

パソコンの購入・交換ルールの基本スタンス

パソコンの購入・交換のルールについては、Qiita:teamで告知しています。

社員はQiita:teamを参照し、自分のパソコンを追加購入するのか・交換するのかを判断します。

上長の承認を得た後、情シスが購入手続きを行う流れになっています。

ルールを簡単にまとめると

  • 故障修理・故障交換などを除く全てのPC購入にこのルールが該当するよ
  • 購入するPCのモデル・スペックについてはこの記事で定めたものに限定するよ
  • それ以外のメーカー・スペックのPCは購入しないよ
  • 特異な事情がある場合は上長の承認があれば購入するよ

という内容になっています。

入社される方に支給しているパソコン

一休では基本的に、新品・最新モデルのパソコンを支給します。

職種により、macOS / Windowsどちらかを選択することができます。

予め、採用の時点でどちらを希望するかをヒアリングしています。

エンジニア

エンジニアは、ThinkPad X1 Carbon / MacBook Proどちらかを支給しています。

デフォルト モデル CPU RAM ストレージ 備考 価格 納期
X1 Carbon Core i7 16GB 1TB SSD 指紋センサー/赤外線カメラ/WWAN ---円 2ヶ月
MacBook Pro Core i7 16GB 512GB SSD TouchBar搭載 ---円 2週間
MacBook Pro(フルスペック) Core i9 32GB 512GB SSD TouchBar搭載 ---円 2週間

また、ThinkPad X1 Carbonでは、メモリ 16GBが上限です。(2019年11月現在)

16GBでは、メモリを多く消費する開発環境では不足する場合があります。

必要な方には、メモリを多く積んだデスクトップマシンを追加で支給しています。

デフォルト モデル CPU RAM ストレージ 備考 価格 納期
OptiPlex 5070 Core i7 64GB 512GB M.2 SSD ---円 2ヶ月

エンジニア以外の方

エンジニア以外の方は、ThinkPad X280,X390 / X1 Carbonどちらかを支給しています。

デフォルト モデル CPU RAM ストレージ 備考 価格 納期
X280,X390 Core i5 16GB 256GB M.2 SSD 指紋センサー/赤外線カメラ/WWAN ---円 2ヶ月
X1 Carbon Core i5 8GB 256GB M.2 SSD 指紋センサー/赤外線カメラ/WWAN ---円 2ヶ月

また、外出する方には別途 プライバシー保護フィルタなどを支給しています。

追加でパソコンを購入するルール

一休のQiita:teamを抜粋しました。

エンジニア・デザイナー向けには、高スペックデスクトップPCを用意しています。
ノートPCのリプレースを検討している & ノートPCのスペック不足を感じる
という方は、高スペックデスクトップPCを追加することをご検討下さい。

まず、基本的にパソコンを追加したい場合は

高スペックのデスクトップパソコンを検討してもらっています。

上長の承認があれば、それ以外のパソコンを購入することが可能です。

その際は、希望者・上長・情シスが議論の上適切なパソコンを選択します。

前例として、パーツからすべて組み立てたケースもあります。

パソコンを交換するルール(2年)

一休では、パソコンの交換サイクルを2年と定めています。

書くのが面倒なのでQiita:teamから適当に画像を貼ります。

image

※ 2018年のルール定義で、サイクルを4年→2年に変更しました。

社員は、2年経過したタイミングでパソコンの動作が遅すぎるなど

業務に支障があると判断できる場合は交換が可能です。

上長(技術的な視点を含める必要がある為、上長がエンジニアでない場合はCTO)

が業務に支障があるかどうかを判断します。

また、動作が遅い原因がハードウェア起因でない場合もあります。

情シスは、技術的観点で改善できるところがないか などの相談窓口になっています。

パソコンを強制交換するルール(4年)

上記、2年ルールで交換しなかった場合には

4年が経過したタイミングで情シスからパソコンの交換を要求しています。

image

これは、特殊なルールだと思います。

目的は古いパソコンを社内に置いておかないことです。

近年、驚くべきスピードで進化するIT技術を我々は利用しています。

パソコンに利用されるあらゆるパーツは完璧ではなく

4年もあれば脆弱性がいくつも発見され、攻撃の対象になるケースもあります。

社員に安全なパソコンを利用してもらう為、交換をお願いしています。

あとがきみたいなやつ

ざっくりとパソコンの支給・購入・交換ルールを公開してみました。

恐らく、パソコンに関するルールを社外に公開するということが稀なケースかなと思っています。

書きながら、他社のPC支給・購入・交換ルールはどうなっているんだろう という疑問も湧いてきました。

もし機会があれば、公開可能な他のルールもオープンにしていきたいと思います。

VeeValidate 2から3へのアップデート

f:id:igatea:20191202001546p:plain

この記事は、一休.com Advent Calendar 2019の3日目の記事です。

qiita.com

宿泊事業本部のいがにんこと山口です。id:igatea
UIUXチームでフロントエンドをメインに開発しています。

一休の宿泊予約サイト の一部のフォームではVue.js、およびVeeValidateを用いてフォームのバリデーションを実装しています。
そのVeeValidateのバージョンを 2.2.15 から 3.0.11 へ移行しました。
VeeValidateはメジャーバージョンが2と3では大きく仕様が変わり、破壊的変更が多数入っています。
この記事ではVeeValidateのV2とV3の記述の比較を行い、VeeValidateのアップデートの参考になる情報をまとめたいと思います。

ライブラリバージョン情報

  • Vue.js 2.6.10
  • VueValidate 2.2.15 → 3.0.11

また先日、公式からもマイグレーションガイドが公開されました。
そちらも併せてご覧ください。 logaretm.github.io

破壊的変更

まずV2からV3に移行するにあたり破壊的変更によって影響を受けるものを挙げていきましょう。

  • Validatorクラスの廃止
  • v-validateの廃止
  • data-vv-asの廃止
  • ErrorBagクラスの廃止
  • $validatorプロパティの廃止

今までVeeValidateを使用していた方はかなり驚くのではないかと思います。
破壊的変更にあわせて関数ベースのAPIが公開されています。
パフォーマンス改善、コンポーネントの可読性、メンテナンス性の改善のためにこのような変更が行われました。
詳しくはバージョンアップのIssueで作成者が説明されています。
VeeValidate v3.0 🚀 · Issue #2191 · logaretm/vee-validate · GitHub

バリデーションルールの定義

基本形

V2

V2ではValidator.extendを使用してバリデーションルールの登録を行っていました。
またバリデーションエラー時のメッセージはgetMessageという関数を定義しています。

// 各バリデーション定義 ./Validations/hoge.js
export default {
  validate, // 値を検証する関数
  getMessage: field => `${field}の形式が正しくありません。`,
};

// 登録側 ./index.js
import VeeValidate from 'vee-validate';
import hoge from './Validations/hoge';

VeeValidate.Validator.extend('hoge', hoge);

V3

V3ではextend関数を使用してバリデーションルールの登録を行います。
またバリデーションエラー時のメッセージはmessageという名前で定義するようになりました。

// 各バリデーション定義 ./Validations/hoge.js
export default {
  validate, // 値を検証する関数
  message: field => `${field}の形式が正しくありません。`,
};

// 登録側 ./index.js
import { extend } from 'vee-validate';
import hoge from './Validations/hoge';

extend('hoge', hoge);

値を受け取るパターン

V2

export default {
  validate,
  getMessage: (field, maxByte) =>
    `${field}は全角${maxByte / 2}文字以内で入力してください`,
};

V3

V3では値を受け取るときにparamsの指定をする必要があります。

export default {
  params: ['max'],
  validate: (value, { max }) => validate(value, max),
  message: (field, { max }) =>
    `${field}は全角${max / 2}文字以内で入力してください`,
};

また、そのままvalidateメソッドからエラーメッセージを返すことが可能です。
{_field_} というフィールド名を表示するプレースホルダーも使用可能です。

export default function(value) {
  const invalidStr = checkStrXSS(value);
  if (invalidStr) {
    return `{_field_}に禁止文字${invalidStr}が含まれています。`;
  }
  return true;
}

標準バリデーターの再実装

V3ではライブラリの容量削減のため下記の標準バリデーターが削除されています。

  • before
  • credit_card
  • date_between
  • date_format
  • decimal
  • ip
  • ip_or_fqdn
  • url

例えばクレジットカードのバリデーターなどが削除されているので、適宜再実装の必要があります。
V2の内部では validator.js というものを使用しておりそのライブラリを使って同じものが再実装できます。
vee-validate/credit_card.js at 2.2.15 · logaretm/vee-validate · GitHub

import isCreditCard from 'validator/lib/isCreditCard';
/**
 * クレジットカードのバリデーション
 */
const validate = value => isCreditCard(String(value));

export default {
  validate,
  message: field => `${field}が正しくありません`,
};

v-validateを全てValidationProviderに

これが一番大規模な作業が必要になるところだと思います。
v-validateを使用しているところを全てValidationProviderに置き換えます。
ValidationProviderはバージョン2.1のときに実装されたバリデーションコンポーネントです。
バージョンが3になりv-validateが廃止された今、唯一のバリデーションを実行するための方法となっています。
またエラーの表示方法によってはValidationObserverも組み合わせる必要があるでしょう。

V2

<input v-validate="'required'" data-vv-as="姓" name="lastName" v-model="lastName" type="text">
<span>{{ errors.first('lastName') }}</span>

V3

ValidationProviderではv-validateはrulesに、data-vv-asはnameとなります。
エラーはScoped slot dataから取得するようになりました。
v-slot="{ errors }" というところですね。グローバルなerrorsを使用しなくなりました。
ValidationProvider内のエラーがすべてそのまま配列に入っています。
これでシンプルに errros[0] という記述だけでエラーを取れるようになり、nameを引数に指定して取る必要はなくなりました。

<validation-provider name="姓" rules="required" v-slot="{ errors }">
  <input name="lastName" v-model="lastName" type="text">
  <span>{{ errors[0] }}</span>
</validation-provider>

親子コンポーネントでのバリデーション結果共有

親コンポーネントと子コンポーネントでバリデーション結果を共有するということがあると思います。
そういう時V2では$validatorをinjectに設定して実装していました。
しかしV3では$validatorは存在しません。
その代わりにValidationObserverで親コンポーネントを囲むことで実現できます。

<!-- 親 -->
<template>
  <validation-observer ref="validationObserver" tag="div" v-slot="{ errors }">
    <!-- バリデーションがある子コンポーネント -->
    <child-component />
  </validation-observer>
</template>

<!-- 子 -->
<template>
  <validation-provider rules="required">
    <input type="text" v-model="hoge">
  </validation-provider>
</template>

子コンポーネントも含めたエラーがerrorsに入ります。
子コンポーネントで親コンポーネントのエラーを使用するという場合はpropsとして送る必要があります。

validateメソッド

$validator が廃止になったので全てのバリデーション結果を取得するメソッドも変わっています。

V2

const isValid = this.$validator.validateAll();

V3

V3ではValiationObserverを$refsで取得することで、そのコンポーネント内のValidationObserver、ValidationProviderのエラーの有無を以下のメソッドで検知することができます。

const isValid = this.$refs.validationObserver.validate();

VueSFCの例

<template>
  <validation-observer ref="validationObserver" tag="div">
    <validation-provider rules="required">
      <input type="text" v-model="hoge">
    </validation-provider>
    <validation-provider rules="required">
      <input type="text" v-model="fuga">
    </validation-provider>
  </validation-observer>
</template>
<script>
export default {
  methods: {
    submit() {
      const isValid = this.$refs.validationObserver.validate();
      // 後続処理
    } 
  },
}
</script>

ErrorBag廃止の対応

V2ではエラーが ErrorBag というもので返ってきていました。
ですがV3の v-slot="{ errors }" で取得したエラーは { [エラーのフィールド名]: [エラーメッセージの配列] } という形のオブジェクトとして格納されます。
注意として、ここのエラーフィールド名というのはinputのnameではなくValidationProviderに指定したnameとなります。
なのでErrorBagからinputのnameが取得することができなくなりエラーからはinputを特定するといったことが困難になりました。
特定のエラー情報を元にそのinputにフォーカスするということがそのままだとできないわけです。
ここではその対応の一例としてclassを振って判定可能にする方法を紹介します。
エラーになったフィールドには特定のクラスを割り当ててそれを活用してフォーカスするようにします。

errorKeyがValidationProviderのnameに指定した値(バリデーションエラー時に表示されるフィールド名)、errorIndexがそのコンポーネント内での何番目のエラーかを表します。

// componentのmethods内に定義
focusError({ errorKey, errorIndex }) {
  const validationProvider = this.$refs.validationObserver.refs[errorKey];

  if (validationProvider) {
    validationProvider.$el
      .getElementsByClassName('errorField')
      [errorIndex].focus();
    return;
  }

  // ValidationObserverで囲われたValidationProviderがエラーの場合、observersにValidationObserverが格納されその内部のエラーとなっている入力欄を探す
  // エラーとなっている入力欄をどちらにフォーカスするかは配列の順番に従う
  const validationObserver = this.$refs.validationObserver.observers.find(
    observer => observer.id === errorKey,
  );
  if (validationObserver) {
    validationObserver.$el
      .getElementsByClassName('errorField')
      [errorIndex].focus();
  }
},

observers などは公式ガイドに載っていないAPIなのでそれを踏まえたうえで使用してください。

エラーが出力されているかのテスト

V2

V2ではvalidateメソッドを叩いてバリデーションをかけ、$validatorからエラーを取り出してテストをしていました。

it('フリガナの必須項目チェックが機能しているか', async () => {
  const wrapper = shallowMount(HogeComponent, {
    sync: false,
  });
  await wrapper.vm.$validator.validate();
  assert.strictEqual(wrapper.vm.$validator.errors.has('kanaSei'), true);
  assert.strictEqual(wrapper.vm.$validator.errors.has('kanaMei'), true);
});

V3

V3ではinputに値を入力して、DOM更新を行って、クラスから要素を取得することによってエラーがあるかどうかを検知するようになりました。
また、v-slotを使用してValidationObserver、ValidationProvider内にエラーが出力されるので、shallowMountのときはVeeValidateのコンポーネントがスタブにならないようにstubsにValidationObserver、ValidationProviderを指定する必要があります。

import flushPromises from 'flush-promises';
import { shallowMount } from '@vue/test-utils';
import { ValidationObserver, ValidationProvider } from 'vee-validate';

it('フリガナの必須項目チェックが機能しているか', async () => {
  const wrapper = shallowMount(HogeComponent, {
    sync: false,
    stubs: { ValidationObserver, ValidationProvider },
  });
  wrapper.find('input[name="kanaSei"]').setValue('');
  wrapper.find('input[name="kanaMei"]').setValue('');
  await flushPromises();
  const errors = wrapper.findAll('.errorText');
  assert.strictEqual(errors.length, 2);
});

個別のバリデーションメッセージをテストする

バリデーションがこのように定義されていたとしたら、フィールド名が適用された状態のエラーメッセージをテストすることができません。

export default function(value) {
  const invalidStr = checkStrXSS(value);
  if (invalidStr) {
    return `{_field_}に禁止文字${invalidStr}が含まれています。`;
  }
  return true;
}

VeeValidateのルールにのっとってフィールド名も正しく入っているかをテストしたい場合は以下のように書くことで実現できます。

import { extend, validate } from 'vee-validate';

extend('noxss', noxss);
it('validate() - JavaScriptっぽい文字列が含まれないようバリデーション', async () => {
  // OK
  const ok = await validate('ふつうのコメント', 'noxss');
  assert(!ok.errors[0]);

  // NG
  const ng = await validate(
    '<Script>//JSっぽい文字列が含まれるコメント</Script>',
    'noxss',
  );
  assert(ng.errors[0]);
});

最後に

この記事がVeeValidateのアップデート時に皆様の役に立てば幸いです。
また今回書かせていただいたVeeValidateの話を12月23日のRoppongi.vueでお話させていただきます。
弊社一休が会場提供させていただくのでこの機会にぜひお越しください!

roppongi-vue.connpass.com

一休.comにService Worker(Workbox)を導入しました

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

こんにちは。宿泊事業本部の宇都宮です。

この記事は、一休.com Advent Calendar 2019の2日目の記事です。

今日は、一休.com( https://www.ikyu.com )にService Worker + Workboxを導入した件について書きます。

Service Workerとは

Service Workerはブラウザのバックグラウンドで動作するJavaScriptで、PWA(Progressive Web Apps)の基盤技術です。

Service Worker の紹介 https://developers.google.com/web/fundamentals/primers/service-workers?hl=ja

はじめてのプログレッシブウェブアプリ https://developers.google.com/web/fundamentals/codelabs/your-first-pwapp/?hl=ja

Service Workerを導入することには2つの意義があると考えています。

(1) PWAの機能を提供するための前提となる (2) プログラマブルなブラウザキャッシュ機構の導入によるパフォーマンス改善ポイントの追加

一休.comでの導入内容

一休.comでも、Service Workerを導入しました。ただし、ミニマムに始めるため、サイトの既存の動作に極力影響しない形で導入しました。

  • PWAモードは無効化
    • したがって、Add to Home Screen(A2HS)なし
  • オフラインページ( https://www.ikyu.com/offline.html )の追加
  • Service Workerによるキャッシュはstyleのみで実験的に開始 => script, image, fontにも拡大
    • 今後、静的ページのキャッシュを追加予定

実装の詳細

service workerのエントリーポイントとなるスクリプトは、webpackでバンドルしたjsに含めています。ほとんどの画面ではこのスクリプトが呼ばれます。

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js').then(
      registration => {
        console.log(
          `ServiceWorker registration successful with scope: ${registration.scope}`,
        );
      },
      err => { /* エラーハンドリング */ },
    );
  });
}

このスクリプトは、ブラウザがService Workerを利用可能な場合には、sw.js をService Workerに登録します。実際にService Workerで実行されるスクリプトは https://www.ikyu.com/sw.js にあります。

sw.js では、Workboxという、Service Workerでキャッシュ管理を宣言的に行えるようにするライブラリを使っています。

オフラインページ

オフラインページ( https://www.ikyu.com/offline.html )は、↓のようなスクリプトでキャッシュできます。

const OFFLINE_PAGE = '/offline.html';
workbox.precaching.precacheAndRoute([
  OFFLINE_PAGE,
  '/dg/image/logo/neologo2.gif', // オフラインページで使ってるロゴ
]);

workbox.routing.setCatchHandler(({ event }) => {
  switch (event.request.destination) {
    case 'document':
      return caches.match(OFFLINE_PAGE);
    default:
      return Response.error();
  }
});

これによって、ネット接続が切れている場合でも静的ページを表示できます(↓は機内モードなのでネット接続なし)。

f:id:ryo-utsunomiya:20191128102816p:plain:w320

実行時キャッシュ

実行時にキャッシュさせるリソースは以下のように宣言します。

workbox.routing.registerRoute(({ url, request }) => {
  const hostnames = [
    // キャッシュを許可するドメイン名のリスト
    'www.ikyu.com',
    'www.img-ikyu.com',
  ];
  const types = [
    // キャッシュを許可するリソースの種別
    'font',
    'script',
    'style',
    'image',
  ];
  return (
    hostnames.some(hostname => url.hostname === hostname) &&
    types.some(type => request.destination === type)
  );
}, new workbox.strategies.StaleWhileRevalidate());

ここでは fetch standardの request.destination を使って、リソースの種別によってキャッシュの可否を決めています。 https://fetch.spec.whatwg.org/#concept-request-destination

このキャッシュはブラウザのデフォルトキャッシュに優先されます。また、Stale While Revalidate ストラテジーでキャッシュが管理されるため、リソースが更新されている場合は、次回リクエスト時には新しいリソースに差し替わります。

参考:Stale-While-Revalidate ヘッダによるブラウザキャッシュの非同期更新 https://blog.jxck.io/entries/2016-04-16/stale-while-revalidate.html

また、デフォルトは NetworkOnly になっていて、キャッシュ対象でないリソースの取得時には、Service Workerは何もしません。

// デフォルトはNetworkOnly(service workerは何もしない)
workbox.routing.setDefaultHandler(new workbox.strategies.NetworkOnly());

キャッシュの確認方法

Chrome DevToolsのApplicationタブで Cache > Cache Storage > workbox-xxx という項目を見ると、Service Workerがキャッシュしているファイルを確認できます。

f:id:ryo-utsunomiya:20191128103128p:plain:w375

Developer Toolsの注意点

Service Worker(Workbox)を入れると、ネットワークリクエストをService Workerが中継するようになるため、Developer ToolsのNetworkタブの見方が変わります。

通常のネットワークリクエストのログに加えて、Service Workerがネットワークリクエストを中継したことを示すfetchのログが出るようになります(Networkタブのログに、実際のリクエストのログとService Workerのログの両方が出るようになります)。

↓のようにログが2行出ていても、2回リクエストが飛んでいるわけではありません。

f:id:ryo-utsunomiya:20191128103444p:plain:w375

⚙️(歯車)のついているリクエストは、Service Workerが中継したことを示しているだけで、無視して良いです。

また、以下のように、cssや画像などのService WorkerログもXHR(XHR and Fetch)タブに登場します。実際のリクエストログは CSS や Img といった専用タブにあります。

f:id:ryo-utsunomiya:20191128103531p:plain:w375

これらの影響で、Networkタブがかなりノイジーになるので、Service Workerのログをフィルタリングしたいところですが、今のところChrome/Firefoxではフィルタリング機能は提供されていないようです。

Service Workerのデバッグ

DevToolsの Application > Service Workes にはService Worker関係のデバッグ機能が用意されています。たとえば、「Bypass for Network」を使うと、Service Workerをバイパスする(ブラウザにネットワークアクセスを強制する)ことができます。

f:id:ryo-utsunomiya:20191128154234p:plain:w375

Progressive Web App のデバッグ https://developers.google.com/web/tools/chrome-devtools/progressive-web-apps

Web App Manifest

Service Workerとは直接関係ないですが、PWA絡みでWeb App Manifestについても触れておきます。

Web App Manifestは、PWAが動作するための要件の一つで、PWAとしての動作モードなどを指定します。

一休.com のmanifestは https://www.ikyu.com/manifest.json にあります。

Web App Manifestで一番重要な設定は "display" で、これによって動作モードが変わります。 https://developers.google.com/web/fundamentals/web-app-manifest

一休.com では現在 "display": "browser" を使用しており、これはPWAとしては動作しないモードです。 このようにしているのは、 (1) 一休ユーザの5割(モバイルでは7割)を占める Safari では、PWAの体験が良くないこと (2) 2017年頃に一休レストランでA2HSを試したところ、ほとんど使われなかったこと が理由です。

SafariのPWAモードが改善したり、A2HSを促すための良いタイミングが見つかったりしたら、"display": "standalone" などPWAとして動作するモードに切り替えようと思っています。。

今後の展望

Service Worker、今後しばらくはキャッシュ強化などのパフォーマンス改善目的で使用し、将来的にPWA化を進めたくなった時に備える、という感じで、引き続きやっていきます。

謝辞

一休.comのService Worker導入に当たっては、Googleの id:sisidovski さんに多大なご協力をいただきました。この場を借りてお礼申し上げます。

CSSフレームワークBulmaについて

フロントエンドエンジニアのid:ninjinkunです。この記事は一休.comアドベントカレンダーの1日目の記事です。

一休.comレストランの管理画面リニューアルプロジェクトにおいて、CSSフレームワークのBulmaを導入しました。結論としては、採用して良かったと思っています。

このエントリではBulmaを選定した理由と、採用後に見えたPros / Consについて述べたいと思います。

なお今回リニューアルした一休.comレストランの管理画面の概要は以下の通りです。

  • レストラン店舗向けの管理画面
    • 店舗の方と一休スタッフの両方が使う
    • DAUは数千の規模
  • 主な用途は在庫の管理と、プラン(コース)や席の管理
  • 現在は店舗を限定してリリース済み

具体的には以下のような画面で構成されています。

f:id:ninjinkun:20191201171421p:plain

f:id:ninjinkun:20191201171439p:plain

UIフレームワークは必要か?

まずそもそもUIフレームワークは必要かという議論があります。

今回のプロジェクトにはデザイナーがおらず、エンジニアの自分がUIデザインを担当していたので、ゼロからきちんとしたビジュアルデザインを設計するのが荷が重かったというのが1つ目の理由。

また、作る画面も20画面弱というそこそこのボリュームで、担当するエンジニアそれぞれがマークアップを行っていたため、スタイルの統一が必要だったというのが二つ目の理由です。

なぜBulmaなのか?

Bulmaの特徴は以下の通りです。

  • CSSのみ、JSなし
  • Flexboxベースのグリッドシステム
  • レスポンシブデザイン対応
  • SCSSでカスタマイズ可能

今回のプロジェクトではVue.jsとサーバーサイドテンプレートのJinja2を適材適所で使うハイブリッド構成だったため、Vueベースのフレームワークはまず選択肢から外れました。

そうなるとCSSベースのフレームワークがターゲットになります。Bulma以外にもBootstrap、UIKit、Materializeなどを検討しましたが、それぞれ以下の理由で見送りました。

  • Bootstrap
    • jQuery依存
    • アップデートで苦しんでいる人を多数観測
  • UIKit
    • JSを含んではいるがサイト自体がVueで作られていたりして親和性が高そうなのはGood
    • コンポーネントが多く、分厚い印象
    • ちょっとお洒落すぎる
  • Materialize
    • マテリアルデザインは既存の管理画面のテイストと全く違うので、移行した人が混乱する可能性を懸念

また、CSSのみで実装されているフレームワークとしてはTailwindCSS、Pureなどがありますが、以下の理由で採用を見送りました。

  • TailwindCSS
    • 細かすぎる
      • マークアップが得意なら良さそうだが、サーバーサイドエンジニアには辛そう
  • Pure
    • 簡素すぎる

そして最終的には以下の理由でBulmaを選びました。

  • 必要なパーツがそこそこ揃っている
  • コードもそこそこ薄くて読みやすい
    • フレームワークは使っていくと結局コードを読む羽目になる
      • であれば極力薄いフレームワークが良い
  • Flexboxベースのレイアウトは挙動が理解しやすい
  • カスタマイズすればテイストを旧管理画面に近づけられそう

Bulmaを使った感想

4ヶ月ほどBulmaを使ってきましたが、総評としては採用して良かったと思っています。

以下に細かいPros/Consを書き出してみました。

Pros

  • VueとJijna2両方でスタイルを統一するという用途にはとても合っていた
  • ビジュアルが良い案配で成立する
    • めちゃくちゃお洒落という感じにはならないが、管理画面には合っている
  • ドキュメントが読みやすい
  • フレームワークのコードが読みやすい
  • 実装中にレイアウトが崩れても、DOMインスペクタでCSSクラスを見れば何が悪かったすぐ分かる
    • マジックがないのが良い
  • カスタマイズが容易
  • 今回の要件ではiPadからPCまでの画面サイズをカバーしたが、問題無く使える

Cons

  • コンポーネントにツールチップがない
    • 管理画面ではツールチップを使いたいところが多いので、地味に困るところ。自前で実装している
  • モーダルなどの実装は自分でJSを書いて動きを付ける必要がある
    • Vueで実装するときは全てJSなのでこっちの方が良いのだが、Jinja2で実装しているときはJSを書き出すのが億劫…
  • これはどんなUIフレームワークでもそうだが、「エンジニアが作ったUI」感がどうしても出てしまう
  • ドロップダウンがIE11対応されていない
    • 他のコンポーネントは問題無く動くので、最近追加された実装で壊れた模様

まとめ

BulmaはCSSのみで構成され、適度に薄く、適度にレイアウトが揃うので、今回の管理画面リニューアルの用途には合っていました。

管理画面リニューアルプロジェクトはまだまだ進行中なので、今後もBulmaを活用していく予定です。

WordPressで爆速Canonical AMPサイトを構築した方法と3つの理由

文責

新規プロダクト開発部の伊勢( id:hayatoise )です。

新規プロダクト開発部は一休の新規事業の開発とデザインを担当する部署です。現在、新規プロダクト開発部は主に『一休.comスパ』、『一休コンシェルジュ』および『KIWAMINO』を担当しています。

はじめに

エグゼクティブや秘書の方々が会食先を探す際のお悩みを解消するためのオウンドメディア『KIWAMINO』をローンチしました。

『KIWAMINO』は WordPress をベースに Canonical AMP サイトにしました。AMP とは、ウェブ高速化のための HTML フレームワークです。そして、Canonical AMP サイトとは、全てのページが AMP で構成されているサイトのことです。

こちらのツイートから分かる通り、WordPress と Google が提供する AMP プラグイン(以下、AMP プラグイン)で Canonical AMP サイトを構成することはまだ事例としては少ないようです。

そこで、『KIWAMINO』をどうやって構築したのか紹介します。加えて、なぜ WordPress と AMP プラグインで Canonical AMP サイトを構成したのかについても説明します。

『KIWAMINO』をどうやって構築したのか

さくらの VPS 上に Docker で Nginx、WordPress ( PHP-FPM ) および MariaDB のコンテナを作成しました。そして、AMP プラグインで Canonical AMP サイトにしました。CDN は Fastly と imgIX を導入しています。

WordPress と AMP プラグインで Canonical AMP サイトを構成した方法

WordPress の構築もさることながら AMP プラグインで Canonical AMP サイトにすることもカンタンです。

まず WordPress を構築し、Twenty Ten 以降 Twenty Nineteen までのコアテーマを有効にします。そして、AMP プラグインも有効にします。

AMP プラグインの設定画面
AMP プラグインの設定画面

AMP プラグインの設定画面の Experiences 項目の Website にチェックを入れます。次に、Website Mode 項目を Standard にすると Canonical AMP サイトになります。以上です。

前述した通り、AMP HTML を書く必要はありません。AMP プラグインが HTML を自動で変換するからです。

<img src="https://resq.img-ikyu.com/asset/image/about/bn_about.png" alt="KIWAMINOについて" >

例えば、img タグを記述すると...

<amp-img src="https://resq.img-ikyu.com/asset/image/about/sd_bn_about.png" alt="KIWAMINOについて" width="640" height="400" class="amp-wp-unknown-size amp-wp-unknown-width amp-wp-unknown-height amp-wp-enforced-sizes i-amphtml-element i-amphtml-layout-intrinsic i-amphtml-layout-size-defined i-amphtml-layout" layout="intrinsic" i-amphtml-layout="intrinsic">
    <i-amphtml-sizer class="i-amphtml-sizer">
        <img alt="" role="presentation" aria-hidden="true" class="i-amphtml-intrinsic-sizer" src="data:image/svg+xml;charset=utf-8,<svg height=&quot;400px&quot; width=&quot;640px&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; version=&quot;1.1&quot;/>">
    </i-amphtml-sizer>
    <noscript>
        <img src="https://resq.img-ikyu.com/asset/image/about/sd_bn_about.png" alt="KIWAMINOについて" width="640" height="400" class="amp-wp-unknown-size amp-wp-unknown-width amp-wp-unknown-height">
    </noscript>
    <img decoding="async" alt="KIWAMINOについて" src="https://resq.img-ikyu.com/asset/image/about/sd_bn_about.png" class="i-amphtml-fill-content i-amphtml-replaced-content">
</amp-img>

表示する際は上記のような AMP HTML に自動で変換されます。

AMP プラグインの AMP Stories 作成画面
AMP プラグインの AMP Stories 作成画面

AMP プラグインの設定画面の Experiences 項目の Stories にチェックを入れると、開発コストを掛けることなく記事と同様に WYSIWYG で AMP Stories を作成することが可能になります。

インフラ

  • さくらの VPS( 2G プラン )
  • Fastly
  • imgIX( 画像に特化した CDN です。詳しくはこちら

ミドルウェア

  • Docker
  • Nginx
  • PHP-FPM
  • MariaDB

さくらの VPS 上に WordPress を構築せず、Docker を使用した理由は同じ環境を構築しやすいからです。

もう一つのオウンドメディア『一休コンシェルジュ』はローカル、ステージングおよびプロダクション環境が揃っておらず、ステージング環境でテストしたのにも関わらず何度か事故を起こしてしまいました。

この経験から各環境を統一することが比較的容易な Docker を採用しました。

WordPress

  • WordPress Core( 常に最新版を使用 )
  • AMP( HTML を AMP HTML に自動で変換 )
  • Fastly( コンテンツの投稿、更新および削除を検知して自動でキャッシュをパージ )
  • Media Cloud( S3 と imgIX の設定と連携を一括で可能に )
  • Yoast SEO( SEO 関連の設定を最適化 )
  • Glue for Yoast SEO & AMP( Yoast SEO の AMP 対応版 )
  • WP Pusher( 任意のブランチへのマージを検知して自動で差分をデプロイ )

WordPress Core のメジャーバージョンを除いて、全てのアップデートを自動に設定しています。

Lighthouse

『KIWAMINO』の Lighthouse のスコア
『KIWAMINO』の Lighthouse のスコア

修正できる点はいくつか残っていますが、Lighthouse の Performance スコアは常に 99 前後です。

なぜ WordPress と AMP プラグインで Canonical AMP サイトを構成したのか

WordPress と AMP プラグインで Canonical AMP サイトを構成した理由は以下の 3 つです。

(1) AMP の制約によって、サイトスピードが速くなるから

(2) エンジニア・デザイナーの学習および開発コストが低いから

(3) 巨大な組織・コミュニティの恩恵を受けられるから

(1) AMP の制約によって、サイトスピードが速くなるから

AMP の制約上、CSS および JavaScript のファイルサイズは軽くせざるを得ないので、それらがサイトスピードを必然的に妨げることがなくなります。

AMP は CSS の総量を 50 KB 以下にしなければなりません。また、JavaScript に関しては AMP が提供する公式のコンポーネントしか使えません。

したがって、CSS および JavaScript ファイルは必然的に軽量化されます。その結果、半強制的にサイトスピードを保つことができます。

(2) エンジニア・デザイナーの学習および開発コストが低いから

AMP プラグインが HTML を AMP HTML に自動で変換するため、デザイナーの学習コストは殆ど掛かりません。さらに Canonical AMP サイトにすると通常ページと AMP ページの二重管理がなくなるので、開発コストも低下します。

(3) 巨大な組織・コミュニティの恩恵を受けられるから

インターネット上の約 3 分の 1 のサイトは WordPress で動いているので、情報が沢山あります。かゆいところに手が届かないこともありますが、基本的に検索すれば大抵の技術的問題を解決することができます。したがって、エンジニアであれば誰でも担当できると思います。

当然、外注する場合も候補になり得る人材が沢山いるので、依頼しやすいかと思います。プラグインも豊富なので、その気になれば非エンジニアが新機能を追加してみることも可能です。

オウンドメディアを運営する上で欲しいと感じる機能は全てプラグインとして提供されていると言っても過言ではありません。巨大なコミュニティの元だからこそ得られるメリットが多々あります。

大半のオウンドメディアは Google 経由のトラフィックに依存しているかと思います。その良し悪しは別として AMP を採用することで、Google 経由の多くのユーザの体験が良くなります。その結果、それが SEO 対策になり、流入数の向上も見込めます。

さらに、AMP の制約を守り続けることでサイトスピードが維持されるため、サイト全体の離脱率も高まりにくいです。AMP 提供元の Google がプラグインを開発しているので、安心してプラグインに頼ることができます。サイトを放置しても自動でアップデートされるので、細かい仕様変更でエンジニアの手を借りる必要がなくなります。

おわりに

『KIWAMINO』は WordPress と AMP プラグインで Canonical AMP サイトにしました。WordPress と Canonical AMP を採用した理由は 3 つです。

(1) AMP の制約によって、サイトスピードが速くなるから

(2) エンジニア・デザイナーの学習および開発コストが低いから

(3) 巨大な組織・コミュニティの恩恵を受けられるから

以下、実際に運用してみた結果です。

(1) 数ヶ月間、実運用しましたが、問題なくサイトスピードは保たれています。少し困っていることは AMP プラグインが新しい JavaScript ファイルを検知した際、管理画面でそのファイルを許可するまで AMP が無効になることです。それ以外は特に困っていません。

(2) 予想通り、エンジニア・デザイナーの学習および開発コストは低かったです。

『KIWAMINO』のデザイナーさんに質問
『KIWAMINO』のデザイナーさんに質問

『KIWAMINO』のデザインを担当した方に技術ブログに掲載することは伝えず、Slack の DM で質問しました。

hayatoise「AMP HTML は一切書いたことがなかったという認識で OK ですね?!」

designer「YES! でも今も書いてる認識はないですけどね〜うふふ」

AMP プラグインは HTML を AMP HTML に自動で変換してくれます。なので、デザイナーの方は一切 AMP HTML を書く必要がないです。HTML と CSS の知識だけでコーディングが可能です。本人も一切書いてる認識はないようです。新しい技術を採用しましたが、デザイナーの学習および開発コストは殆ど高まっていません。

また、エンジニアの学習および開発コストも殆ど高まっていません。一度、AMP プラグインと他のプラグインが競合し、AMP エラーが発生しました。しかし、WordPress に関する情報量が多かったため、あまり詰まること無く問題を解決できました。オウンドメディアを開発する程度だと、AMP の学習コストは殆ど必要ないことも分かりました。

(3) 前述した通り、WordPress に関する情報量は非常に多いです。大抵の問題の解決方法は検索すれば見つかります。今のところ、あまり詰まったことはありません。

また、ちょっと機能を試したい時、WordPress プラグインに頼れることは非常に有り難いです。例えば、記事内でアンケートを取りたいと言われた場合、それを可能とするプラグインが存在するので、インストールするだけでビジネスサイドに提供できます。

結論、WordPress, Twenty Nineteen & AMP Plugin で Canonical AMP サイトは良いぞ

採用情報

hrmos.co

【イベント告知】一休のデータドリブン経営を"超具体的"に解剖

f:id:ikyuinouen:20190903140458p:plain

■応募方法
応募はこちらから

一休 × SmartNews イベント応募ページ

■イベントについて

一休はデータ活用を最大限にレバレッジした「データドリブン経営」を実践し、 第二創業期に入った現在も成長を続けています。 一休におけるデータサイエンティスト・マーケターは、経営を補完する役割ではなく 「経営・事業を動かす最重要な役割」を担っています。 自らもデータサイエンティストの代表・榊を中心としたチームで取り組んでいます。

「データドリブン経営」の最前線にいらっしゃるスマートニュース社 西口一希様と共に、 一休の実データを踏まえた"超具体的"な解剖や、強いデータサイエンティスト・マーケター になるためのポイントなどをセッションします。

■具体的には

・強いデータサイエンティスト、マーケターになるためのポイントとは
・「PL責任をもつ、ビジネス感度を上げる」といった、本当の意味で活躍できるデータサインティストについて
また当日は一休の具体的なデータを用い、セッションを行います。
■応募方法

すべての説明をお読みの上、下記「応募フォーム」よりお申し込みください。

一休 × SmartNews イベント応募ページ

■タイムテーブル

時間  内容
18:20 受付開始
19:00 一休のデータドリブン経営の紹介&トークセッション
20:40 懇親会(自由参加)
22:00 終了

■注意事項

①名刺2枚お持ちください
②電源設備のご用意がございません。ご注意下さい。
③参加人数に限りがございますので、事前にご参加が難しくなった際はお早めにキャンセルのご協力をお願いいたします
その他、ご不明点がある方は下記メールアドレスよりご連絡下さい。
【問い合わせ先】business_event@ikyu.com

■榊の過去資料

bdash-marketing.com

logmi.jp

AWS Elastic beanstalkからAmazon EKSへ移行する

以前の記事でも簡単に紹介した通り、一休では、アプリケーションのAWS Elastic beanstalkからAmazon EKSへの移行を進めています。

user-first.ikyu.co.jp

この記事では、その背景や、実際の設計、実際にAmazon EKSを活用してみて気付いた点、困った点、今後の展望を紹介したいと思います。

AWS Elastic beanstalkの辛い点

新しい環境の構築や運用が大変

  • 一休ではAWSのリソースをTerraformを使って管理しています。新しくウェブアプリケーションを立ち上げて、Elastic beanstalkで動かす場合、以下の作業をする必要があります。

  • Terraformで、Elastic beanstalkの定義を作ってリリースする。

  • 新しいアプリケーションのデプロイを通知するように自前で作ったAWS lambdaを修正。
  • アプリケーションのCI/CDの構築。
  • (必要に応じて)Route53の調整。

これを検証環境と本番環境の両方で実施する必要があります。面倒です。 さらに、TerraformとElastic beanstalkはあまり相性がよくないようで、意図しない変更差分が発生してしまったりします。 また、新しいインスタンスタイプが出てきたときに、環境によっては、完全に再作成しないと使えない場合があります。 実際、一休では、c5系やt3系のインスタンスが既存環境では使えずに、かなりの工数をかけて環境を再構築しました。 EC2とALBやAutoscalingをなまで使うよりElastic beanstalkを使うほうがはるかに楽なのは間違いないのですが、もっと楽に環境構築や運用ができる方法があれば、そっちに移行したい。

計算リソースを最適に使えていない

Elastic Beanstalkの場合、↓のようなアプリ配置をしなければならず、その結果、計算リソースの余剰を抱え込まざるを得ないです。

  • どんなに小さいアプリケーションでも可用性を確保するため、2台のec2インスタンスを割り当てている。
    • 2台ないとデプロイするときにダウンタイムが発生してしまいます。
  • ひとつのECインスタンスでひとつのアプリケーションだけを動かす。
    • 厳密にいえば複数のアプリを動かすことも可能ですが、設計的な無理が生じます。

実際に本番環境で動作しているすべてのEC2のCPU利用率の平均を算出してみたのですが、大半が使われていないことがわかりました。メモリも同様です。 オートスケールに依存しない設計にしているので、リソースはある程度余裕をもって割り当てています。なので、計算リソースの余剰がある程度あるのは設計通り、なのですが、さすがにかなりもったいない。

Amazon EKSへの移行による解決

Amazon EKSへ移行することで上述の2点は以下のように解決されると考えました。

  • 環境構築のTerraformから脱却しアプリケーションの構成や運用に関する定義はなるべくひとつのリポジトリに集約する。
  • コンテナオーケストレーション基盤で動かすことで、ECインスタンスとコンテナの関係が、1 対 1から 多 対 多になり、計算リソースを効率的に使える。

なぜAmazon EKSにしたのか

AWSの場合、ECSを使うという選択肢もあります。ECSを使うか、EKSを使うかの2択になりますが、EKSを選びました。 KSはKubernetesという業界標準であり今後も大きく進化していく仕組みを提供するため、ECSよりもコミュニティ、業界による改善の恩恵を受けやすい、と考えたからです。

構成と利用しているツールやアドオン

構成は下図の通りです。

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

  • クラスタをふたつ構築し、Spinnakerを使い、同じアプリをデプロイし、Fastlyでロードバランシングします。
  • AWS ALB Ingress Controller とexternal-dns(https://github.com/kubernetes-incubator/external-dns)を使うことで、ロードバランサの定義とroute53の設定をKubernetesの管理下に置きます。
  • DockerイメージはAWS ECRに置きます。

eksクラスタの作成には、eksctl を利用しました。定義は以下の通りです。

apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig

metadata:
  name: cluster01
  region: ap-northeast-1
  version: "1.13"

vpc:
  id: "vpc-xxxxxx
  cidr: "xx.xx.xx.xx/16"
  subnets:
    private:
      ap-northeast-1a:
        id: "subnet-aaaaaaaa"
        cidr: "xx.xx.xx.xx/22"

      ap-northeast-1c:
        id: "subnet-bbbbbbbb"
        cidr: "xx.xx.xx.xx/22"

      ap-northeast-1d:
        id: "subnet-ccccccccc"
        cidr: "xx.xx.xx.xx/22"

nodeGroups:
  - name: ng1
    labels: {role: workers}
    tags: {Stack: production, Role: eks-node, k8s.io/cluster-autoscaler/cluster01: owned, k8s.io/cluster-autoscaler/enabled: "true"}
    instanceType: c5.2xlarge
    desiredCapacity: 5
    maxSize: 8
    volumeSize: 100
    privateNetworking: true
    securityGroups:
      attachIDs: [sg-xxxx,sg-xxxx]
      withShared: true
    ssh:
      allow: true
      publicKeyPath: xxxxxxx
  • EKS作成時に新規作成される専用VPCではなく既存のVPCを利用します。
    • 新規で作成される専用VPCを使うと既存のVPCとの接続や設計上の衝突の解決など付随するタスクが多く発生すると考えたからです。
    • サブネットも同様です。
  • デフォルトのままだとディスクのサイズが小さいので volumeSize: 100 にすることで、ある程度の大きさを確保します。

利用したhelmチャート

helmはKubernetesのパッケージ(=チャート)管理ツール。helmでインストールできるチャートはなるべくhelmでインストールし、helmfileで宣言的な記述にして管理しています。 利用しているチャートは以下の通りです。

  • AWS ALB Ingress Controller

    • KubernetesのIngressとして、ALB を使えるようにするコントローラです。
  • external dns

  • kube2iam

    • Podに対してIAMポリシーを適用する仕組みを提供します。
  • datadog

    • 一休は、モニタリングにDatadogを使っています。また、APMやログもすべてDatadogを使うよう、現在移行作業を実施中です。Kubernetesでも引き続き使っていきます。
      • ログについては全部Datadog Logsに送信するとコストが高くついてしまうので、日々の運用で検索や分析に使うログだけをDatadog Logsに送りつつ、すべてのログをfluentdを使ってS3に送り、何かあったときに後から調査できるようにしてあります。 fluentdはDeamonSetで動かしています。

CI/CDをどのように構築するか

クラスタを運用するにあたって、管理する必要のある各種定義ファイルは以下の通りです。

  • 上述のeksctlのパラメータとなるクラスタの定義ファイル
  • helmチャートなどすべてのクラスタに共通の設定
  • Kubernetesのマニュフェストファイル
  • Spinnakerのパイプライン定義

このうち、eksctlのパラメータとなるクラスタの定義ファイルはそれほど頻繁にapplyするものではないので、Githubで管理しつつCI/CDの仕組みは構築しませんでした。 helmチャートは、クラスタに共有の設定になります。これは、ひとつのリポジトリにまとめて、circleciで CI/CDを構築しました。 KubernetesのリソースのマニュフェストファイルとSpinnakerのパイプライン定義はすべてのアプリケーションの定義をひとつのリポジトリににまとめてCI/CDを構築しました。 アプリケーションごとに別々のリポジトリにする設計やアプリケーションのリポジトリに入れてしまうというやり方も検討しましたが、まずはまとめて管理してみて、難点が出てきたら再度検討する、ということにしました。 また、アプリケーションのデプロイはコンテナリポジトリへのPushをトリガにするように、Spinnakerのトリガを設定しています。これによって、アプリケーション側はEKSを意識する必要をなくしました。

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

苦労した点、気づいた点

DNS関連の課題

CoreDNSのポッドが落ちました。幸い少量のトラフィックを流してテストしている時期だったので大事には至りませんでした。 原因はわからないのですが、調査してみると、以下の記事が見つかりました。欧州のファッションサイトで発生した、DNS起因のkubernetes障害の振り返りの記事です。

kubernetes-on-aws/jan-2019-dns-outage.md at dev · zalando-incubator/kubernetes-on-aws · GitHub

DNS関連に次のような課題があることがわかりました。

"ndots 5 problem"

このコメント に詳しいのですが、kubernetesで動かしているPodのデフォルトの/etc/resolv.confは、以下の通り、ndots:5 が指定されます。

nameserver 172.20.0.10
search default.svc.cluster.local svc.cluster.local cluster.local ap-northeast-1.compute.internal
options ndots:5

この場合、解決対象の名前に含まれているドットの数が5より小さいと、search に含まれているドメインを順に探して、見つからなかった場合に、最後に与えられた名前を完全修飾名として、探します。 例えば、www.ikyu.com を解決するなら、www.ikyu.com.default.svc.cluster.local を探す => ない => www.ikyu.com.svc.cluster.local cluster.local を探す => ない ... を繰り返して、search に書かれているドメインで見つからない => www.ikyu.com で探す => みつかった。という流れになります。その結果、ひとつのクラスタ外の名前を解決するだけで想定以上の名前解決リクエストが発生します。

この問題は、Podの(dnsConfig)https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-s-dns-configndots:1 になるように記述をして対処しました。 PodのdnsConfigと(dnsPolicy)https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-s-dns-policyを記述することで、/etc/resolv.confをカスタマイズすることができます。

CoreDNS自体の信頼性

Amazon EKS(kubernetes 1.13)が使うCoreDNSのバージョンは、1.2.6で、最新のバージョンと比べると古いです。 configmapを覗いて、Corefileの中身を見てみると、proxy プラグインを使っているのがわかります。 で、この proxy プラグインは、CoreDNSの最新のバージョンではソースコードごとなくなっています。 経緯は次のissueに詳しいです。

deprecate plugin/proxy · Issue #1443 · coredns/coredns · GitHub

  • proxy はバックエンドに対するヘルスチェックに問題がある。
  • forward プラグインのほうがコードもシンプルでソケットをキャッシュするので高速に動作する。

kubernetesも1.14から forward を使うようになっているようです。公式のライフサイクルから判断すると、Amazon EKSが1.14に対応するのは9月。この問題についてはバージョンアップするしか解決のしようがなさそうなので、EKSで1.14が使えるようになったら速やかにバージョンアップすることにしました。

また、EKSではデフォルトのCoreDNSのPodの数は2つになっています。処理自体は2つで十分捌けるのですが、Podが落ちるという現象に当たってしまったので安全を見てPodの数を増やしました。

また、以下を参考にしながら、Nodelocal DNS Cacheを導入してみたのですが、なぜか名前解決の速度が劣化してしまい、導入を断念しました。また、詳しく調査してチャレンジしてみたいと思います。

kubernetes/cluster/addons/dns/nodelocaldns at master · kubernetes/kubernetes · GitHub

Descheduler は入れたほうがいい

運用を開始してみるとPodがノード上で偏り、あるノードは50%を超えてメモリを使っているのに別のノードはスカスカ、というようなことが起きました。 対策としてdescheduler をCronJobで動かすことで、定期的にPodの再配置を行い、ノードのリソース利用状況を平準化しています。 descheduler自体はとても簡単に導入できます。

負荷試験はやったほうがいい

最初は、現状のElastic Beanstalkのリソースの利用状況やリクエスト数を見ながら計算をしてPod数やcpu/memoryの requests/limits を割り出していたのですが、実際にリリースしてみると想定通りリクエストを捌けませんでした。ある程度、トラフィックのあるアプリケーションの場合は、机上計算だけではなくきちんと負荷試験をやったほうがよさそうです。

SIGTERMを受けたらグレースフルにシャットダウンするアプリケーションにしておく

Podの終了については以下の記事が大変詳しいです。

Kubernetes: 詳解 Pods の終了 - Qiita

KubernetesはPod内のプロセスにSIGTERMを送信することでPodを止めます。 SIGTERMを受けたらグレースフルにシャットダウンするアプリケーションにしておく必要があります。

当初の目的は達せそうか

冒頭に書いた通り、以下のふたつがAmazon EKSへの移行で解決したい課題でした。

  1. アプリケーションの動作環境の構築を簡単にする。
  2. 計算リソースをより効率的に使えるようにする。

1.については、Elastic beanstalkに比べてはるかに簡単かつ素早くに環境が作れるようになりました。が、そう感じるには慣れと習熟が必要なのも確かです。 多くの人が書いていますが、マニュフェストファイルを理解するのはどうしても時間がかかります。 すべてのエンジニアが簡単に環境を構築できるようにするのなら、なんらかのscaffolding的なものが必要だと感じました。

計算リソースの利用の効率化は手ごたえを感じています。Elastic beanstalkからすべてをEKSへ移行できたら大きくコストダウンできそうです。

まとめと今後の展望

現時点で、Amazon EKSで動いているのは、pythonのwebアプリケーションとgoのgrpcサーバです。その他の、一休で動かしているLinux系のアプリケーションは、Elastic beanstalk時代からDockerで動いていたため、Amazon EKSへの移行はスムーズにできそうで、すでに、移行の目途が立っています。

あとは、Windows系のアプリケーションをどうするか、ですが、kubernetes 1.14からWindowsのコンテナがサポートされます。 一休のWindows系のアプリケーションはコンテナでは動いていませんが、これを機にコンテナ化にチャレンジしたいと考えています。 kubernetesは複数のクラウドプロバイダの差異を抽象化するレイヤとして進化しているように思います。 例えば、あるクラウドプロバイダで大規模な障害が起こって、マルチクラウドにデプロイせよ!!となったときに、kubernetesで動作しているアプリケーションなら、クラウドプロバイダの違いを意識せずに、アプリケーションのデプロイができるはずです。実際に動作するどうかは別問題ですが。 そう考えると、Windows系のアプリケーションもコンテナ化してkubernetesで動かせるようにすることで、今後のクラウド/コンテナ技術の恩恵をしっかり受けれるようにしておきたいと感じています。 また、バッチ実行基盤もkubernetesベースのジョブエンジンに切り替えることで、可用性や信頼性を改善し、効率的にリソースを使えるようにする、ということにも取り組んでいきたいです。

採用情報

一休では、クラウド/コンテナ技術に経験がある方 or 興味がある方やSREやDevopsを通じて価値あるサービスを世に届けたい方を募集しております。

hrmos.co

hrmos.co


この記事の筆者について

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