TECH PLAY

株式会社一休

株式会社一休 の技術ブログ

161

一休.com レストランを開発している所澤です。この記事は 一休.comアドベントカレンダー の10日目の記事です。 先日、一休.comレストランの管理画面をリニューアルしました。 この記事ではその際にAPIの実装方法として採用したGraphQLについてフロントエンド視点で利点や使い所について述べます。 GraphQLについて以下の記事がわかりやすかったです。 「GraphQL」徹底入門 ─ RESTとの比較、API・フロント双方の実装から学ぶ - エンジニアHub|若手Webエンジニアのキャリアを考える! 短いまとめ 新しくAPIサーバーを書くなら是非GraphQLで! というくらい良かった Apolloのエコシステムに乗り切らなくてもいい。ふつうのRESTfulなAPIサーバーの代わりに、くらいの気軽さでGraphQLを採用してもいい プロジェクトの概要 今回リニューアルした一休.comレストランの管理画面の概要は以下の通りです。 レストラン店舗向けの管理画面 主な用途は在庫の管理と、プラン(コース)や席の管理 アプリケーションの構成 サーバー すでに運用中のアプリケーションに新しいエンドポイントを追加した Python + Flask 製 /api でRESTfulなAPIを提供している /graphql 以下にGraphQL形式のAPIを新設 フロントエンド TypeScript + Vue.js + Stimulus 製 一部の画面はSPA、大半の画面は jinja2 でレンダリング サーバーサイドは Graphene-Python と Flask-GraphQL。フロントエンドは特にGraphQL用のライブラリは使用せずに axios を使ってAPIリクエストを行う設計にしました。 GraphQLを利用したAPIサーバーと言うとBFF( Apollo Server など)としてサーバを立てている事例をしばしば目にしますが、今回は既存のAPIサーバーにGraphQL用のエンドポイントを追加しています。また Graphene-Python はコード・ファーストなライブラリなので「まずSchemaファイルを定義してそれをフロントとサーバーで共有して...」といういわゆるスキーマ駆動開発は行っていません。 フロントで vue-apollo を使っていない理由は状態管理に Vuex を採用したためです。vue-apollo とVuexの両方を使う、あるいはローカルの状態管理を Vuex ではなく apollo-client で行うことも考えましたが、今回はVuexのみで状態管理を行い、単にRESTful APIの代わりとしてGraphQLを使うだけの構成としました。 以上のようにあまりGraphQL(や Apollo)のエコシステムに乗っかっているわけではありませんが、それでもGraphQLを採用する利点は十分にあると感じました。 GraphQLのメリット 半年ほどGraphQLを使ったフロントエンド開発に携わってみて感じたGraphQLのメリットは以下の3点です。 関連リソースを簡単に取れる 常に最新のAPIドキュメントが手に入る APIの設計で悩むことが減る それぞれ詳しくみていきましょう。 1. 関連リソースを簡単に取れる GraphQLの一番の特徴は複数のリソースを一度のQueryで取得できる点です。 例えば上記の画面だと プランの情報(プラン名・利用可能人数など) プランの販売状況 プランに紐づいている席の情報(席名など) 座席の販売状況 カレンダー などのリソースを一度のリクエスト取得しています。 一般的にドメインモデルが独立していることは少なく、それぞれが1対多あるいは多対多で結びついています。我々のビジネスだと「レストラン」「プラン(コース)」「座席」「在庫」などがそうです。 レストランのページであればレストランに紐づくプランと座席の一覧。 restaurant(id: $restaurantId) { plans { ... } seats { ... } } プランの詳細ページであればプランが属するレストランと、プランに紐づく座席。 plan(id: $planId) { restaurant: { .. } seats: { ... } } というようにGraphQLであればモデルの has many belongs to の関係がそのままQueryで表現できます。 2. 常に最新のAPIドキュメントが手に入る Graphene(やApolloなどのGraphQLサーバー・ライブラリ)にはGraphiQLというイン・ブラウザIDEが付属しています。GraphQLのエンドポイントをブラウザで開くとエディタが立ち上がり任意のGraphQLが実行できたり、Schemaを確認したりできます。 サーバーサイドとフロントエンドを分業して開発する場合に特に重要なのは、後者の"Schemaが確認できる"という点です。 ドキュメント生成ツールでAPIドキュメントを用意した場合はドキュメントの更新忘れなどで仕様と実装に齟齬が出てしまうことがありますが、GraphQLであればそのようなことはありません。コード・ファーストのGrapheneではこのSchemaはコードから自動で生成されているので常に実装と同期されています。さらにSchemaにはコメントをつけられるので、Schema定義をそのままAPIドキュメントとして利用できます。 3. APIの設計で悩むことが減る 例えばあるプランを販売中止にするAPIについて考えみましょう。素朴なREST APIだと /plans/:id に対して { status: 'suspended' } をPUTで送ることが考えられます。 「いやいや、リソースの部分更新はPATCHで」 「販売状態をプランのサブリソースだと考えると /plans/:id/on-sale にDELETEではないか」 などというコメントを頂きそうですが、RESTful APIの難しいところはまさにそこです。リソース思考で美しいAPIを設計するのは難しく、また実装者によってインターフェースのゆらぎが出やすいのです。 GraphQLのMutationは"動詞 + 対象"の形式で可能な限り用途を明確に絞って書くのが良い、とされています。今回のケースだと suspendPlan(planId: Int!) とかですね。普段からリソース思考で物事を考えている人は少ないと思うので、こちらのほうが日常生活のマインドモデルと近く適切なMutation名が思いつきやすいはずです。 GraphQLの使いどころは? 今回は(BFFではない)通常のAPIサーバーにGraphQLを導入した事例をご紹介しました。 Apolloなどのエコシステムを抜きにしてGraphQLのことだけを考えると、GraphQLはあくまでHTTPの上に乗った薄いプロトコルに過ぎません。 /resourceA/?embed=resourceB,resourceC のようなエンドポイントを生やしたくなったときや、実装と乖離したAPIドキュメントに困ったときなどに気軽に導入を検討してみてください。
アバター
こんにちは。宿泊事業本部の宇都宮です。この記事では、GoのDIライブラリgoogle/wireの使い方を紹介します。 この記事は 一休.com Advent Calendar 2019 の9日目の記事です。 DIとは GoのDIライブラリ wireの使い方 Providerのエラーハンドリング Injectorのカスタマイズ Provider Set インタフェースのバインド 構造体のフィールドを参照する 細かな注意点 値とポインタの違いに注意 go runするときはwire_gen.goも一緒に おわりに 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 Architecture や Onion Architecture といったアーキテクチャパターンは 依存性逆転の原則 に基づいており、このようなアーキテクチャパターンを使う上でもDIは必須条件になります。 DIのデメリットは、初期化が煩雑になることです。よくあるWebアプリケーションで考えても、 HTTPハンドラはDomain Serviceに依存している Domain ServiceはRepositoryに依存している RepositoryはDBコネクションマネージャに依存している DBコネクションマネージャはconfigに依存している 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 Guide や Best Practices にも目を通してみてください。
アバター
qiita.com この記事は、一休.com Advent Calendar 2019の6日目の記事です。 こんにちは、nakashunです。 普段は情シスみたいなことをやっています。 今年の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から適当に画像を貼ります。 ※ 2018年のルール定義で、サイクルを4年→2年に変更しました。 社員は、2年経過したタイミングでパソコンの動作が遅すぎるなど 業務に支障があると判断できる場合は交換が可能です。 上長(技術的な視点を含める必要がある為、上長がエンジニアでない場合はCTO) が業務に支障があるかどうかを判断します。 また、動作が遅い原因がハードウェア起因でない場合もあります。 情シスは、技術的観点で改善できるところがないか などの相談窓口になっています。 パソコンを強制交換するルール(4年) 上記、2年ルールで交換しなかった場合には 4年が経過したタイミングで情シスからパソコンの交換を要求しています。 これは、特殊なルールだと思います。 目的は古いパソコンを社内に置いておかないことです。 近年、驚くべきスピードで進化するIT技術を我々は利用しています。 パソコンに利用されるあらゆるパーツは完璧ではなく 4年もあれば脆弱性がいくつも発見され、攻撃の対象になるケースもあります。 社員に安全なパソコンを利用してもらう為、交換をお願いしています。 あとがきみたいなやつ ざっくりとパソコンの支給・購入・交換ルールを公開してみました。 恐らく、パソコンに関するルールを社外に公開するということが稀なケースかなと思っています。 書きながら、他社のPC支給・購入・交換ルールはどうなっているんだろう という疑問も湧いてきました。 もし機会があれば、公開可能な他のルールもオープンにしていきたいと思います。
アバター
この記事は、一休.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 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(); } } ); これによって、ネット接続が切れている場合でも静的ページを表示できます(↓は機内モードなのでネット接続なし)。 実行時キャッシュ 実行時にキャッシュさせるリソースは以下のように宣言します。 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がキャッシュしているファイルを確認できます。 Developer Toolsの注意点 Service Worker(Workbox)を入れると、ネットワークリクエストをService Workerが中継するようになるため、Developer ToolsのNetworkタブの見方が変わります。 通常のネットワークリクエストのログに加えて、Service Workerがネットワークリクエストを中継したことを示すfetchのログが出るようになります(Networkタブのログに、実際のリクエストのログとService Workerのログの両方が出るようになります)。 ↓のようにログが2行出ていても、2回リクエストが飛んでいるわけではありません。 ⚙️(歯車)のついているリクエストは、Service Workerが中継したことを示しているだけで、無視して良いです。 また、以下のように、cssや画像などのService WorkerログもXHR(XHR and Fetch)タブに登場します。実際のリクエストログは CSS や Img といった専用タブにあります。 これらの影響で、Networkタブがかなりノイジーになるので、Service Workerのログをフィルタリングしたいところですが、今のところChrome/Firefoxではフィルタリング機能は提供されていないようです。 Service Workerのデバッグ DevToolsの Application > Service Workes にはService Worker関係のデバッグ機能が用意されています。たとえば、「Bypass for Network」を使うと、Service Workerをバイパスする(ブラウザにネットワークアクセスを強制する)ことができます。 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 さんに多大なご協力をいただきました。この場を借りてお礼申し上げます。
アバター
フロントエンドエンジニアの id:ninjinkun です。この記事は 一休.comアドベントカレンダー の1日目の記事です。 一休.comレストランの管理画面リニューアルプロジェクトにおいて、CSSフレームワークの Bulma を導入しました。結論としては、採用して良かったと思っています。 このエントリではBulmaを選定した理由と、採用後に見えたPros / Consについて述べたいと思います。 なお今回リニューアルした一休.comレストランの管理画面の概要は以下の通りです。 レストラン店舗向けの管理画面 店舗の方と一休スタッフの両方が使う DAUは数千の規模 主な用途は在庫の管理と、プラン(コース)や席の管理 現在は店舗を限定してリリース済み 具体的には以下のような画面で構成されています。 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を活用していく予定です。
アバター
文責 はじめに 『KIWAMINO』をどうやって構築したのか WordPress と AMP プラグインで Canonical AMP サイトを構成した方法 インフラ ミドルウェア WordPress Lighthouse なぜ WordPress と AMP プラグインで Canonical AMP サイトを構成したのか (1) AMP の制約によって、サイトスピードが速くなるから (2) エンジニア・デザイナーの学習および開発コストが低いから (3) 巨大な組織・コミュニティの恩恵を受けられるから おわりに 採用情報 文責 新規プロダクト開発部の伊勢( id:hayatoise )です。 新規プロダクト開発部は一休の新規事業の開発とデザインを担当する部署です。現在、新規プロダクト開発部は主に『 一休.comスパ 』、『 一休コンシェルジュ 』および『 KIWAMINO 』を担当しています。 はじめに エグゼクティブや秘書の方々が会食先を探す際のお悩みを解消するためのオウンドメディア『KIWAMINO』をローンチしました。 『KIWAMINO』は WordPress をベースに Canonical AMP サイトにしました。AMP とは、ウェブ高速化のための HTML フレームワークです。そして、Canonical AMP サイトとは、全てのページが AMP で構成されているサイトのことです。 企業が運営しているサイトで WordPress + AMP plugin でカノニカル AMP サイトを作る時代になったんですねぇ。素晴らしい。 https://t.co/BnjRYROiLy — 金谷 武明 (@jumpingknee) July 23, 2019 こちらのツイートから分かる通り、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 プラグインの設定画面の 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= " 400px " width= " 640px " xmlns= " http://www.w3.org/2000/svg " version= " 1.1 " />" > </ 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 プラグインの設定画面の 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 のスコア 修正できる点はいくつか残っていますが、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』のデザインを担当した方に技術ブログに掲載することは伝えず、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
アバター
■応募方法 応募はこちらから 一休 × 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への移行を進めています。 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よりもコミュニティ、業界による改善の恩恵を受けやすい、と考えたからです。 構成と利用しているツールやアドオン 構成は下図の通りです。 クラスタをふたつ構築し、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 KubernetesのIngressやServiceにアクセスできるようにDNSを構成してくれます。 さまざまなDNSサービスに対応しており 、route53にも対応しています。 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を意識する必要をなくしました。 苦労した点、気づいた点 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-config に ndots: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.については、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を行なっています。
アバター
以前の記事でも紹介した通り、一休では、gRPCを使ったサービスを導入し始めています。 user-first.ikyu.co.jp この記事では、このサービスをAmazon EKSで提供するための設計や気をつけたポイントについて紹介します。 背景 一休では、ウェブアプリケーションの実行環境としてAWS Elastic Beanstalkを採用しています。 そして、この4月からElastic BeanstalkをAmazon EKSへ移行するプロジェクトを進めています。 このgRPCサービスもElastic Beanstalkで運用をしていましたが、以下の問題を抱えていました。 適切にロードバランシングできない。 Elastic BeanstalkでgRPCサービスを運用しようとするとNetwork Load Balancer(NLB)を使うことになります。NLBはレイヤ4のロードバランサです。一方で、gRPCはhttp/2で動作し、ひとつのコネクションを使いまわします。このため、NLBでは、特定のサーバに負荷が偏ってしまう場合があります。 また、EC2サーバのdockerdのcpu利用率が突然上昇する、アプリケーションのデプロイに時間がかかるというような問題も起き始めていたため、Amazon EKSへ移行することでこれらの問題の解決を試みました。 KubernetesでgRPCサーバを動かす場合の注意点 Amazon EKSは、AWSが提供するマネージドなKubernetesです。KubernetesでgRPCサーバ動かす場合の注意点は上述のNLBの場合と同様、以下の点になります。 KubernetesのServiceはL4のロードバランサであり適切に負荷分散できない。 このため、KubernetesのServiceではなく別の仕組みを使って負荷分散を実現する必要あります。 Envoyを導入する 多くの事例でEnvoyを使ってこの問題を解決しています。 www.envoyproxy.io AWSのApplication Load Balancer(ALB)がhttp/2に対応しているのはクライアントとALB間の通信でありALBからバックエンドへの通信はhttp/2に対応していない、だから、gRPCサーバの場合はNLBを使う必要がある。そしてNLBを使うと上述の問題にぶつかる、、、ということでしたが、Envoyは、公式サイトに書かれている通り、クライアントとEnvoyの間もEnvoyとバックエンドサーバの間も両方ともHTTP/2とgRPCをサポートします。一休でもEnvoyを使うことに決めました。 構成 GitHub - GoogleCloudPlatform/grpc-gke-nlb-tutorial: gRPC load-balancing on GKE using Envoy このGKEでの事例を参考にしつつ、以下のような配置構成にしました。 Kubernetesクラスタ外部からのアクセスを受ける必要があるため、 type: LoadBalancer のService経由でEnvoyが外からのアクセスを受けます。Envoyのバックエンドは Headless Service にします。Headless Serviceを使うと背後にあるPodに直接アクセスできるレコードがクラスタ内部DNSに作成されます。EnvoyはこのDNSレコードからPodのIPを取得し、Envoy側の構成にしたがって負荷分散を行います。 Envoyの設定 Envoyの構成情報はKubernetesのComfigMapで設定します。内容な以下の通り。 apiVersion : v1 kind : ConfigMap metadata : name : grpc-service-proxy data : envoy.yaml : | static_resources : listeners : - address : socket_address : address : 0.0.0.0 port_value : 50051 filter_chains : - filters : - name : envoy.http_connection_manager config : access_log : - name : envoy.file_access_log config : path : "/dev/stdout" codec_type : AUTO stat_prefix : ingress_http route_config : name : local_route virtual_hosts : - name : https domains : - "*" routes : - match : prefix : "/" route : cluster : grpc-service http_filters : - name : envoy.health_check config : pass_through_mode : false headers : - name : ":path" exact_match : "/healthz" - name : "x-envoy-livenessprobe" exact_match : "healthz" - name : envoy.router config : {} clusters : - name : grpc-service connect_timeout : 0.5s type : STRICT_DNS dns_lookup_family : V4_ONLY lb_policy : ROUND_ROBIN drain_connections_on_host_removal : true http2_protocol_options : {} hosts : - socket_address : address : grpc-service.default.svc.cluster.local port_value : 50051 health_checks : timeout : 3s interval : 5s unhealthy_threshold : 2 healthy_threshold : 2 grpc_health_check : { service_name : Check } admin : access_log_path : /dev/ null address : socket_address : address : 127.0.0.1 port_value : 8001 注意点としては、以下の通りです。 drain_connections_on_host_removal: true を設定すること。 この設定がない場合、バックエンド側のServiceからPodが外れても、そのPodのヘルスチェックが失敗するまで、Envoyは外れたPodにトラフィックを流し続けます。その結果、デプロイ時にリクエストを落としてしまう可能性が発生します。 grpc_health_check を指定すること。 バックエンド側がgRPCサービスなのでこれを設定します。 gRPCサービス側の構成 gRPCサービスはElastic BeanstalkでもDockerを使って動かしていました。したがって、EKSに動かすために大きな変更は必要ありませんでした。唯一必要だったのがヘルスチェックです。 上述の通りNLBではgRPCサービスのヘルスチェックをすることができません。NLBのヘルスチェックはTCPのポートの疎通確認ですが、厳密にいえばこれはアプリケーションの死活監視になりません。一方、Kubernetesではヘルスチェック( livenessprobe と readinessprobe )は重要な役割を果たしますので、設定する必要があります。 gRPCサービスに対するヘルスチェックについては以下の記事に方法が書いてあります。 Health checking gRPC servers on Kubernetes - Kubernetes この記事に従って、以下の2点を行いました。 gRPCサービスにヘルスチェックのエンドポイントを実装する。 Dockerイメージ内に grpc-health-probe を同梱する。 そして、gRPCサービスのPodの定義を以下のようにしました。 ...略... spec : containers : - name : grpc-service image : [ "ImageName" ] imagePullPolicy : IfNotPresent ports : - containerPort : 50051 readinessProbe : exec : command : [ "/bin/grpc_health_probe" , "-addr=:50051" , "-service=Check" ] initialDelaySeconds : 5 livenessProbe : exec : command : [ "/bin/grpc_health_probe" , "-addr=:50051" , "-service=Check" ] initialDelaySeconds : 10 ...略... リクエストを落とさずにデプロイをする ここまで構築してリクエストを流してみると、ひとつのコネクションで多数のリクエストを送信しても、きちんと複数のPodにトラフィックが分散されることがわかりました。 あとは、EnvoyもgRPCサービスもリクエストを落とさずにデプロイできるように設定する必要があります。 Envoyの場合 Envoyの場合は、以下のissueがとても参考になりました。 Graceful HTTP Connection Draining During Shutdown? · Issue #7841 · envoyproxy/envoy · GitHub このissueによれば、 /healthcheck/fail というエンドポイントにPOSTリクエストをすることでEnvoyはそれ以後受け付けたリクエストの接続をクローズするようです。これを活用して、EnvoyのPodの定義の lifecycle に以下の通り書けばリクエストを落とさずにPodの入れ替えができそうです。 ...略... lifecycle : preStop : exec : command : - /bin/sh - '-c' - >- wget -qO- --post-data='' http://127.0.0.1:8001/healthcheck/fail && sleep 10 ...略... preStop フックで wgetを使って http://127.0.0.1:8001/healthcheck/fail にPOSTリクエストを行い、その後10秒待っています。 127.0.0.1:8001 には Envoyの管理用のAPIが立ち上がっています。 この設定によって、Podが停止する流れは大まかに以下のようになります。 PodがTerminating 状態になる。同時にpreStopフックが動き、 healthcheck/fail にPOSTリクエストがされたあと、10秒待つ。 Podには、リクエストは来るが、レスポンスを返すときにコネクションは閉じられる。 10秒待っている間に、ServiceからPodが外れる。以後、新しいリクエストは来なくなる。接続中のリクエストも10秒間ですベて処理される、はず。 10秒停止後、SIGTERMで、コンテナが停止される。すでに接続がない状態なので、リクエストは落とさない。 この設定をして、トラフィックを流しつつ、Envoyのデプロイを行ってみたところリクエストを落とさずにPodが入れ替わることが確認できました。 gRPCサーバの場合 注意点は以下のふたつです。 上述の通り、Envoyの構成で drain_connections_on_host_removal: true の設定をする。 この設定をしないとEnvoyはヘルスチェックが通り続ける限り、古いReplicaSetに属するPodにもトラフィックを流し続けます。 アプリケーションはSIGTERMを受け取ったら、グレースフルにシャットダウンする処理を実行する。 今回のサービスはGoのgRPCサービスなので、 GracefulStop() を呼び出すようにしています。 このふたつをやっておけば、ローリングアップデートでもBlue/greenでもリクエストを落とさずにPodの入れ替えができることを確認できました。 まとめ 今回は、gRPCサービスのEKSでの動かした方を紹介しました。 一休では、現在、主要サービスのAmazon EKSへの移行を進めています。この移行の全体像については、別途、紹介するつもりです。 参考資料 文中に紹介したリンク以外にも以下のサイトを参考にさせて頂きました。 Kubernetes上でgRPCサービスを動かす | SOTA kubernetesでgRPCするときにenvoy挟んでみたよ - Qiita Kubernetes: 詳解 Pods の終了 - Qiita spinnaker/solutions/bluegreen at master · spinnaker/spinnaker · GitHub Manage Traffic Using Kubernetes Manifests - Spinnaker この記事の筆者について システム本部CTO室所属の 徳武 です。 サービスの技術基盤の開発運用、開発支援、SREを行なっています。
アバター
こんにちは。宿泊事業本部の宇都宮です。 一休では、基幹データベースにSQL Serverを使用しています。また、Goアプリケーションでは、 go-mssqldb というライブラリを使用して、データベースとのやりとりを行っています。 このgo-mssqldbには、タイムゾーンに関して厄介な挙動があります。タイトルにもあるように、タイムゾーンが常にUTCになってしまうのです。本記事では、go-mssqldbのタイムゾーン関係の振る舞いと、go-mssqldbを使いつつ正しくタイムゾーンを扱うための対処法を紹介します。 go-mssqldbのタイムゾーン問題 go-mssqldbのタイムゾーン問題は、以下のコードで再現できます。 package main import ( "database/sql" "fmt" "log" "os" "time" // blank import必須 _ "github.com/denisenkom/go-mssqldb" ) func main() { conn, err := sql.Open( "sqlserver" , "sqlserver://user:password@dbhost:1433?ApplicationIntent=ReadWrite&MultiSubnetFailover=Yes&database=DB" ) if err != nil { fmt.Fprint(os.Stderr, err) os.Exit( 1 ) } row := conn.QueryRow( ` select GETDATE() ` ) var dbnow time.Time if err := row.Scan(&dbnow); err != nil { fmt.Fprint(os.Stderr, err) os.Exit( 1 ) } now := time.Now() after1Hour := now.Add( 1 * time.Hour) fmt.Println(after1Hour.After(now)) fmt.Println(after1Hour.After(dbnow)) fmt.Println(now) fmt.Println(after1Hour) fmt.Println(dbnow) } dbnowにはDBの現在時刻から取得した時刻が、nowにはサーバの現在時刻が入ります。after1Hourはサーバの現在時刻から1時間後です。したがって、 after1Hour.After(now) と after1Hour.After(dbnow) は、DBとサーバの時計が1時間以上ずれていない限り、trueになるはずです。 しかし、DBとサーバの両方のタイムゾーンがJST(+09:00)の状態でこのコードを実行すると、 after1Hour.After(now) はtrue、 after1Hour.After(dbnow) はfalseになります。 原因は、このコードの出力を見ると分かります。 dbnow は日時は同じ(2019-08-23 15:54)ですが、タイムゾーンがUTCになっています。UTCでJSTと表面上の日時が同じと言うことは、実質9時間後の日時ということです。そのため、 after1Hour.After(dbnow) はfalseになってしまうのです。 タイムゾーン問題の対処方法 go用の mysqlドライバ のように、Data Source NameにDBサーバのロケーションを指定できる機能があればよいのですが、go-mssqldbにはそのような機能はありません。 この問題を解決しようとしているissueやプルリクエストはいくつか見つかりますが、近いうちに取り込まれそう、というステータスではありません。 そこで、ライブラリ側の解決を待つのではなく、利用者の側で対処する必要があります。 最も根本的な対応は、 datetimeoffset 型の使用です。datetimeoffset型はタイムゾーンも保持しているため、日時の保存にこの型を使っておけば、go-mssqldbを使っていても問題なくタイムゾーンを扱えます。 すでにdatetime型で日時を保存していて、datetimeoffset型での保存が難しい場合は、 TODATETIMEOFFSET 関数を使ってデータを取得する際に型変換しましょう。 row := conn.QueryRow( ` select GETDATE() , TODATETIMEOFFSET(GETDATE(), '+09:00') ` ) var dbnow time.Time var dbnowoffset time.Time if err := row.Scan(&dbnow, &dbnowoffset); err != nil { fmt.Fprint(os.Stderr, err) os.Exit( 1 ) } now := time.Now() after1Hour := now.Add( 1 * time.Hour) fmt.Println(after1Hour.After(now)) fmt.Println(after1Hour.After(dbnow)) fmt.Println(after1Hour.After(dbnowoffset)) fmt.Println(now) fmt.Println(after1Hour) fmt.Println(dbnow) fmt.Println(dbnowoffset) dbnowoffset は+0900(JST)になっているため、 after1Hour.After(dbnowoffset) はtrueになります。意図したとおりの動作といえます。
アバター
こんにちは。宿泊事業本部の宇都宮です。6月に、Go + gRPCという構成のサービスを運用開始したという記事を書きました。 Go + gRPCによるマイクロサービス構築 - 一休.com Developers Blog 本番運用開始から2ヶ月ほどたち、いくつかのトラブルがありつつ、現在も元気に稼働中です。 運用していく中で定常的に発生していたgRPCのタイムアウトエラーについて、その対処法がわかったので、紹介します。 なお、本記事の知見はC#でのgRPCクライアント実装においては有用でしたが、他の言語では適用できない可能性が高いです。各言語のドキュメントもあわせてご参照ください。 gRPCのタイムアウト 以前の記事で紹介したマイクロサービスは、30~60req/sec程度のリクエストを常時受け付けます。 社内のサービスの中で、このマイクロサービスに最も頻繁にリクエストを送るのは認証基盤で、全リクエストの9割以上を占めます。なお、認証基盤はC#(ASP.NET MVC)で実装されています。 この認証基盤のログをみると、1時間に5件程度、gRPCの「Deadline Exceeded」エラーが発生していることがわかりました。これは通信タイムアウト時に発生するエラーです。 認証基盤では、当初、gRPCのタイムアウトを5秒に設定していました。これは大幅に余裕を持たせた数字で、実際、99%のリクエストは0.2秒以内にレスポンスを受け取っていました。また、gRPCサーバでも処理時間のログを取っていますが、こちらには1秒以上レスポンスにかかっているようなログは出ていませんでした。 つまり、gRPCのタイムアウトはレスポンスの遅延に起因するものではなく、クライアント-サーバ間のコネクションの確立失敗に起因するのでは? と仮説を立てました。 対応(1) タイムアウトの短縮とリトライ 当初、タイムアウトを短くして、代わりにリトライを行うようにすればいいのでは? と考えました。 C#で Polly を使うと、こんな感じのコードになります: using Polly; ... var client = new MemberServiceClient() { DeadLineSeconds = 1 }; var response = Policy.Handle<Exception>() .Retry( 3 ) .Execute(() => client.GetMember(memberSeq)); この実装では、タイムアウトは 改善しませんでした 。リトライは確かに行われるのですが、一度失敗した状態からリトライしても、失敗し続けてしまい、何回リトライしても同じ、という状態でした。 対応(2) Channelの再利用 C#向けのgRPCライブラリのドキュメントは ここ にあります。中でも特に重要なのは、サーバとの接続を管理する Grpc.Core.Channel クラスのドキュメントでしょう: https://grpc.github.io/grpc/csharp/api/Grpc.Core.Channel.html このドキュメントの冒頭には、以下のように書いてあります。 Channels are an abstraction of long-lived connections to remote servers. More client objects can reuse the same channel. Creating a channel is an expensive operation compared to invoking a remote call so in general you should reuse a single channel for as many calls as possible. 要するに、Channelはコネクションプールなので、インスタンスを使い回した方がいいよ、ということです。 もとの実装では、gRPCクライアントをnewするたびに新しいChannelを作っていました。この実装を改め、Channelは一度作成した後は、static変数に保持してサーバのライフタイムに渡って使い回すようにしました。 using Grpc.Core; ... // もとの実装(ClientのDisposeでChannelをShutdown) using (var client = new MemberServiceClient( new Channel(GetServerHost(), GetServerPort(), ChannelCredentials.Insecure))) { // } // Channelを使い回すようにした実装(Application_EndでChannelをShutdown) var client = new MemberServiceClient(GetChannel()); ... private static Channel _channel { get; set; } private static object channelLock = new object (); public static Channel GetChannel() { if (_channel == null ) { lock (channelLock) { _channel = new Channel(GetServerHost(), GetServerPort(), ChannelCredentials.Insecure); } } return _channel; } この変更をしたところ、認証基盤のgRPCと通信する部分のスループットが2倍になり、さらに、gRPCのタイムアウトもほとんど発生しなくなりました。タイムアウトが発生した場合でも、Channelを頻繁に再作成していた場合と異なり、リトライすれば成功するようになりました。 むすび gRPCは、ハイパフォーマンスなサービスを作るための強力なスタックですが、ややクセがあります。特に、コネクション周りは実装(言語)依存の部分が大きいため、クライアントを書く言語向けのドキュメントには一通り目を通した方がいいな、と思いました。
アバター
はじめに こんにちは。データサイエンス部の平田です。 一休でのデータ分析はJupyter NotebookやJupyter Labを用いてDWHにアクセスして行われることが多いですが、サービスそのものと分析環境が乖離していることにより、分析結果を継続的にサービスに取り込むのが難しい状況でした。 また、マーケティング部の方々がJupyterを使用して分析した結果に基づいて継続的に施策を行おうとしても、Airflowに組み込む際のエンジニアの負担はそこそこありますし、修正するたびに依頼をしなければならないなどコミュニケーションコストも発生します。 さらに、マーケティングに機械学習を取り入れたい場合でもairflow側で全部やってしまうと密結合になってしまいます。 そこで、Airflowから別の場所にあるJupyterを直接実行することによりエンジニアの負担は最小限にとどめ、自由に施策を打てるような仕組みとして機械学習プラットフォーム、通称 ml-jupyter が生まれました。 模式図 AirflowからJupyterをキックする ①まず、日次で実行しているAirflowからml-jupyter上のAPIをキックする関数を作ります。 キックするコードは以下のようになります。 大まかな流れとしては、カーネル起動→コードを取得→ウェブソケットで通信→カーネル終了となります。 Jupyter notebookでは一つのnoteごとにカーネルが割り当てられるため、まずその起動から始まります。 import json import requests import datetime import uuid from work.dags.base_taskset import BaseTaskset from websocket import create_connection class TriggerJupyterNotebook (BaseTaskset): @ classmethod def execute (cls, **kwargs): ipynb_file = kwargs[ 'ipynb_file' ] folder_name = kwargs[ 'folder_name' ] # 施策ごとのノートブックファイル (Airflowから実行されるもの) notebook_path = f '/{folder_name}/{ipynb_file}.ipynb' host = [host_name] base = f 'http://{host}' # カーネルを起動 url = base + '/api/kernels' response = requests.post(url) kernel = json.loads(response.text) print ( 'kernel_id:' , kernel[ 'id' ]) # コードを取得 url = base + '/api/contents' + notebook_path response = requests.get(url) file = json.loads(response.text) # セルのコードのみを抽出 code = [c[ 'source' ] for c in file [ 'content' ][ 'cells' ] if len (c[ 'source' ]) > 0 and c[ 'cell_type' ] == 'code' ] # WebSocketのオープン ws = create_connection( f 'ws://{host}/api/kernels/' + kernel[ 'id' ] + '/channels' ) # WebSocket上でメッセージを送る # カレントディレクトリを施策用のディレクトリに変更 # (パッケージ等もカレントディレクトリを参照する) # 最後にカーネルの処理が完了したことを知るために、特定の文字列を出力する terminated_signal_str = uuid.uuid1().hex code = [ 'import os' , f "os.chdir('/tf/{folder_name}')" ] \ + code + [ "print('" + terminated_signal_str + "', end='')" ] for c in code: msg_type = 'execute_request' content = { 'code' : c, 'silent' : False } hdr = { 'msg_id' : uuid.uuid1().hex, 'username' : 'airflow' , 'session' : uuid.uuid1().hex, 'data' : datetime.datetime.now().isoformat(), 'msg_type' : msg_type, 'version' : '5.0' } ws.send(json.dumps({ 'header' : hdr, 'parent_header' : hdr, 'metadata' : {}, 'content' : content })) # WebSocketのレスポンスを取得 error_flag = False while True : msg_type = '' while msg_type != "stream" : rsp = json.loads(ws.recv()) msg_type = rsp[ 'msg_type' ] if rsp[ 'content' ].get( 'status' ) == 'error' : print ( 'jupyter notebook error:' , rsp[ 'content' ][ 'evalue' ]) error_flag = True break # エラーを返却した場合、WebSocketをクローズして、処理を終了 if error_flag: ws.close() break # 特定の文字列を含む場合、WebSocketをクローズして、処理を終了 if terminated_signal_str == rsp[ 'content' ][ 'text' ]: ws.close() break # カーネルを終了 url = base + f "/api/kernels/{kernel['id']}" response = requests.delete(url) response.status_code # 204ならOK return not error_flag ②PythonOperatorを作り、この関数を実行します。 PythonOperator( task_id= '[task_id]' , provide_context= True , python_callable=TriggerJupyterNotebook.execute, dag=subdag, op_kwargs={ 'folder_name' : '[folder_name]' , 'ipynb_file' : '[file_name]' } ) ③新たな施策をAirflow上で組み込むときは、エンジニア側は「Jupyter上の〇〇ファイルを実行してほしい」 「〇〇が終わった後や〇〇テーブルが更新された後に実行してほしい」などの要望を聞き、上のPythonOperatorをコピペして少し変え、適切な順番で実行されるように実装するだけになります。 ただ、Airflowから無事キックできるようにするにはいくつか決めなければならないことがあります。 ライブラリ競合問題 施策ごとにライブラリのバージョンが異なる可能性がある場合、それぞれその施策独自のライブラリを見に行く必要があります。 Jupyter上で、 !pip install [hogehoge] -t . とすることで、カレントディレクトリにライブラリがインストールされるようになります。 施策ごとにフォルダを切り、上記を実行することでそれぞれのフォルダ内で独立してライブラリをインストールできます。 Airflowからキックするコードにchdirコマンドを追加しています。(第1項) code = [ 'import os' , f "os.chdir('/tf/{folder_name}')" ] \ + code + [ "print('" + terminated_signal_str + "', end='')" ] さらに、 pandas , boto3 など共通としてよさそうなライブラリは別途インストールしています。 終了検知問題 Jupyterでの実行終了を検知するのは結構難しい問題です。若干泥臭くなりますが、Airflowでランダムに生成した特定文字列をJupyter側で最後に出力させることで終了とみなすようにしました。もっといい方法があれば教えてほしいです。(第3項) code = [ 'import os' , f "os.chdir('/tf/{folder_name}')" ] \ + code + [ "print('" + terminated_signal_str + "', end='')" ] また、Jupyter自体がエラーで終了することもあり得るのでフォローしています。 ml-jupyter上からできること サービスAPIとマーケターをつなぐ 基本的に一休のCRM施策はDWHのデータを元に、SQLで対象者データやコンテンツ情報を抽出し、Ikyu Marketing Cloudという社内ツールから各種チャネルに配信しています。 ただし、DWHのデータは基幹DBの当日2:00までのデータであり、リアルタイムに基幹DBから情報を取得・追加・更新することができません。 上記のDB分離問題からマーケティング側の課題として、特定のユーザーに対して何らかのアクション(クーポンの付与や会員ランクの変更)をフレキシブルに行えないという問題がありました。 そこで、ml-jupyter上から基幹側の各種APIを呼べるようにライブラリを整備することで、各Notebookのインスタンス上でpythonを用いてAPIをコールするなど自由自在にクーポン付与等の施策を行えるようになりました。 機械学習のアプローチを用いたデータ生成 今まではMarketing Cloud上で配信する対象者等のデータ抽出については、SQLを用いて複雑なクエリやストアドプロシージャを組んで行っていましたが、SQL自体の言語仕様が貧弱なところもあり、機械学習のアプローチを用いてデータの作成をするのが困難でした。 ml-jupyterではJupyter Notebook上で、 pandas や scikit-learn 等の強力なpythonライブラリを用いた機械学習のアプローチで各種データの生成ができるようになり、より一歩進んだCRM施策が行えるようになりました。 宿泊のリマーケティングクーポンの施策は機械学習のアプローチにてデータが作られ、Marketing Cloud上で日々配信が行われており、現状かなりの数値のリフトが見られています。 細かい話 マーケティングの方々はおそらくバージョン管理に興味ありません。(偏見) そこで、jupyter notebookをcronでこっそり自動pushしています。
アバター
レストラン事業本部の田中( id:kentana20 )です。 先週末にDevLOVE Xというイベントで開発組織改善の取り組みについて5年間の取り組みと今後、というテーマでお話しました。 5年間でどれくらい一休の開発組織が変わったのか 技術面 組織面 それぞれで実施した改善について、改善の裏側で起こっていたことや自分の所感も含めてお話しました。 現在の一休について7/4(木)にお話します 来週開催するエンジニア向けの説明会で、上記の5年間の取り組みを経て、現在の一休がどうなっているのかをCTOの伊藤がお話します。 ikyu.connpass.com 説明会と書いていますが、会社・事業や開発体制のお話だけでなく 現在の開発組織やプロダクトの状況がどうなっているか 今後どのように改善を進めていくのか を含めてお話する予定です。 一休で働くことに興味がある方だけでなく いまの現場を改善するためのヒントがほしい 改善を進めている一休のエンジニアと直接お話したい などなど、どなたでもご参加いただけます。 また、現場のエンジニアと個別にお話する時間も設けていますので、ご興味のある方はぜひご参加ください! みなさんとお会いできることを楽しみにしています。 hrmos.co
アバター
こんにちは。宿泊事業本部の宇都宮です。 最近、とあるマイクロサービスをローンチしました。このアプリケーションの業務的な役割は諸事情により省略しますが、以下のような特性をもっています。 社内の多くのサービスから利用される 一休.com 一休.comレストラン 一休.comギフト 一休.com海外 このサービスが落ちると、主要サービスの予約処理が止まる 😱 想定されるリクエスト数は、平常時で30req/sec、ピーク時には60req/sec程度になります。行う処理はシンプルで、DBにいくつかSELECT文を投げて、ビジネスロジックに沿った結果を返すことです。 また、基盤系のアプリケーションなので、各開発者の開発環境(WindowsとMacが混在)でも動作する必要があります。 したがって、このアプリケーションに求められる要件は、 高パフォーマンス 高信頼性 クロスプラットフォームで動作すること などになります。 このアプリケーションを構築するにあたって採用した技術と、開発の際に気をつけたこと、運用開始してから直したことなどを紹介します。 採用した技術 Go gRPC 開発の際に気をつけたこと プレゼンテーションとドメインの分離 ユニットテストとインテグレーションテスト 運用開始してから直したこと sql.DB を頻繁にオープン・クローズしない むすび 採用した技術 Go 開発言語としては Go を採用しました。 一休のサーバサイドで広く使われている言語/ランタイムは以下の通りです。 VB, C#/.NET Framework: 一休.com等 C#/.NET Core: メール配信基盤等 Python: 一休.comレストランの新アーキテクチャ、一休.comスパ VBScript: 一休.comレストランの旧アーキテクチャ JavaScript/Node.js: 一休.comレストランのBFF Go: 行動ログ収集API これらのうち、Go以外の言語が選外になった理由は以下の通りです。 VBScript, .NET Framework: 新機能の追加が止まっており、将来性がない Python, Node.js: 開発環境にランタイムのインストールが必要なのが微妙(バイナリを配布するだけにしたい) .NET Core: 要件は全て満たしているが、開発者の採用面が… .NET CoreとGoはどちらも要件を満たしているので、.NET Frameworkの経験を活かしやすい.NET Coreか、採用の強そうなGoかは迷いました。が、As-isではなくTo-beで考えた方がいいかなということで、Goを選定しました。 gRPC データのやりとりには gRPC を採用しました。今回のアプリケーションの場合、RESTでも十分だと思っていましたが、より高速に動作しそう、という理由でgRPCを採用しました。 先行して開発されていた別のアプリケーションでもgRPCを採用していて、そちらで技術検証が済んでいた、という点もポイントです。 ただ、一休ではVBScript等レガシーな技術も使っているので、どうしてもgRPCが使えなくなる事態もありうると思っていました。なので、REST APIも並行して開発しました。なお、結果的には、gRPCで問題なかったです。 余談ですが、現在、一休の一部サービスでは、VBScript(Classic ASP)からgRPCを叩いています。C#(.NET Framework)でgRPCクライアントを開発し、このdllを COM 経由で使っています。 擬似コードですが、大体こんな感じです: Dim client Dim response Set client = Server . CreateObject ( "Ikyu.GrpcServiceClient" ) Set response = client . GetMember ( memberSeq ) 開発の際に気をつけたこと プレゼンテーションとドメインの分離 前述したとおり、gRPCでいくか、RESTでいくか、迷っていました。gRPCかREST(JSON over HTTP)かは、結局プレゼンテーションの問題なので、プレゼンテーションとドメインをきっちり分けて、プレゼンテーションを簡単に切り換えられるように気をつけました。 擬似コードですが、gRPCのrpcは以下のような実装になっています。 func (s *server) GetMember(ctx context.Context, in *pb.GetMemberRequest) (*pb.Member, error ) { member, err := memberService.FindByMemberSeq(in.MemberSeq) if err != nil { // エラー処理 } return &pb.Member{ MemberSeq: member.MemberSeq, }, nil } これに対して、RESTのハンドラ(MVCのコントローラー相当)は以下のような実装になっています(フレームワークは gin )。 func (h *MemberHandler) GET(c *gin.Context) { member, err := memberService.FindByMemberSeq(c.Param( "member-seq" )) if err != nil { // エラー処理 } c.JSON(http.StatusOK, gin.H{ "memberSeq" : member.MemberSeq, }) } このように、ほとんどの業務処理はドメイン層で行い、プレゼンテーション層はドメインサービスから戻ってきた処理結果を表現することのみを行うようにしました。これによって、gRPCとRESTを容易に併存可能にし、開発終盤まで決断を遅らせられるようにしました。 ユニットテストとインテグレーションテスト 高信頼性のためには、コーナーケースも含めたテストが必要です。そこで、ドメイン層についてはユニットテストを徹底しました。 また、DBと通信するコードはインフラストラクチャー層として分離し、こちらについては実際のDB(SQL Server)をDockerに立ててテストを行うインテグレーションテストを行いました。高速な動作のために、ビジネスロジックをSQLのクエリで表現している部分もあるため、インテグレーションテストもある程度しっかりやっています。 Goは標準で テストのための仕組み が組み込まれているので、テストが書きやすい言語だと思います。 今後の運用を考えると、 Consumer-Driven Contracts のようなテストも用意した方がいいのかな、と検討中です。 運用開始してから直したこと sql.DB を頻繁にオープン・クローズしない Goのイディオムとして、取得したリソースは defer Close() するというものがあります( Close() のエラーハンドリングが必要な場合を除く)。そのため、最初はDBと通信するコードを以下のように書いていました。 func (s *server) GetMember(ctx context.Context, in *pb.GetMemberRequest) (*pb.Member, error ) { db, err := sql.Open( "sqlserver" , dsn) if err != nil { // エラー処理 } defer db.Close() memberService := NewMemberService(db) ... } RPCの始めでDB接続をオープンして、終わりでクローズしています。これでもそれなりの性能は出ていたのですが、ドキュメントを確認すると、気になる記述がありました。 The returned DB is safe for concurrent use by multiple goroutines and maintains its own pool of idle connections. Thus, the Open function should be called just once. It is rarely necessary to close a DB. https://golang.org/pkg/database/sql/#Open sql.Open() の結果として取得できる sql.DB 構造体は、一度Openした後はプログラム中でずっと使い回すべきで、Closeを呼ばなければならないことはまれである、とされています。 sql.DB はコネクションの実体ではなく、コネクションプールなので、サーバの起動時に一度だけOpenし、その後は使い回すのがよさそうです。 func main() { db, err := sql.Open( "sqlserver" , dsn) if err != nil { // エラー処理 } // gRPCサーバの実体となる server 構造体にdbフィールドを持たせる s := grpc.NewServer() pb.RegisterMemberServiceServer(s, &server{db: db}) } // RPCのハンドラでは server.db を使う func (s *server) GetMember(ctx context.Context, in *pb.GetMemberRequest) (*pb.Member, error ) { memberService := NewMemberService(s.db) ... } このように変更した結果、低負荷時のレスポンスタイムが改善しました(10~20ms => 5ms)。 あわせて、コネクションプール周りの設定も行いました。こちらは、以下の記事を参考にさせていただきました。 DSAS開発者の部屋:Re: Configuring sql.DB for Better Performance db.SetMaxOpenConns( 50 ) db.SetMaxIdleConns( 50 ) // MaxOpenConns 以上の値にすること db.SetConnMaxLifetime( 100 * time.Second) // 最大接続数 × 秒数 が目安 コネクションプールの設定によって、高負荷に対する耐性も上がりました。負荷試験を行ったところ、当初の実装では300req/sec程度の負荷をかけるとタイムアウトが頻発するようになっていましたが、 sql.DB の使い回しとコネクションプールの設定チューニングによって、300req/secでも正常に結果を返すことができるようになりました。 むすび 今回はGo + gRPCというスタックでアプリケーションを開発した際の知見をいくつか紹介しました。一休では、引き続き、Go言語によるWebアプリケーション開発を進めていきたいと考えています。 doda.jp
アバター
こんにちは。 一休.com の開発基盤を担当しています、akasakasです。 今回は、Tavern という API Test ライブラリ を紹介したいと思います。 一休でAPI Test が必要になった背景 API Test を導入する上で考えたポイント 開発者フレンドリー CI連携 Tavernのご紹介 Tavern のいいところ YAML でテストを記述できる 前のテストの結果を保存できる・次のテストに使える CI連携 Tavern の実運用にまつわる細かい話 まとめ 参考 一休でAPI Test が必要になった背景 前回のブログでも少し触れましたが、APIのテストを無理やりSeleniumを使ってテストを続けた結果、E2Eが破綻しました。 適切なレイヤーで適切なテストをしようということで、APIに関してはちゃんと API Test ライブラリ の導入を決めました。 user-first.ikyu.co.jp API Test を導入する上で考えたポイント API Test を導入する上で考えたポイントは以下の点です。 開発者フレンドリー CI連携 開発者フレンドリー 前回のブログでも書きましたが、一休ではQA・テストエンジニアのようなポジションはいないので、開発者がテストも修正するようになってます。 開発者が気軽にテストを追加・修正することができるような API Test ライブラリが必要だと考えました。 CI連携 一休で扱っているAPIは外部の提携先に提供しているAPIが多いです。 もしリリース後、APIで障害が発生した場合、外部の提携先にも影響が及びます。 リリース前の障害事前検知のため、検証環境で定期的にAPI Testを流して、リリース後の障害を防ぎたいです。 その為には、API Test を CIで回せるようにする必要がありました。 Tavernのご紹介 上記の要件を満たせるAPI Test ライブラリを検討した結果、Tavernと出会いました。 Tavern はAPI Test に特化した PyTest のプラグインです。 テストはYAMLで記述できるので、シンプルでわかりやすく、メンテナンスが簡単ですので、開発者フレンドリーであると感じました。 また、PyTestのプラグインでコマンドラインでテストの実行ができるので、CI連携もしやすいです。 API Testのツールでいうと、PostmanやInsomniaがありますが、GUIとして使うのが一般的で、CI連携が難しかったです(PostmanのCLIでNewmanというのがありますが、テストの記述がTavernよりも難しいと感じたので、Newmanの導入も見送りました)。 Tavern のいいところ Tavern のいいところとしては以下の3点があると思います。 YAMLでテストを記述できる 前のテストの結果を保存できる・次のテストに使える CI連携 YAML でテストを記述できる Github API を例にして Tavern を使って、API Test をしてみましょう。 Tavern で issue を作れることを確認します。 https://developer.github.com/v3/issues/#create-an-issue 先にテストからお見せします test_name : Github API Test includes : - !include common.yaml stages : - name : Create Issue request : url : https://api.github.com/repos/{service.owner:s}/{service.repo:s}/issues headers : Authorization : "token {service.token:s}" method : POST json : title : "Issue From Tavern Test" body : "一休.comをご利用頂き、誠にありがとうございます。" response : status_code : 201 body : title : "{tavern.request_vars.json.title}" body : "{tavern.request_vars.json.body}" state : "open" YAMLで書かれているので、どんなことをしているのか、なんのテストをしているのかがイメージしやすいと思います。 request url でテスト対象のエンドポイントを指定 header で Authorization Header を設定 jsonでポストするデータを指定 etc reponse 201 が返却されること body の内容がrequestの内容と一致していること などを記述していることがわかります。 共通のデータは common.yaml で記述しています。 common.yaml の内容としては以下のようなイメージです。 description : used for github api testing name : test includes variables : service : token : "token" owner : "リポジトリオーナー" repo : "リポジトリ" テストの実行は tavern-ci test_github.tavern.yaml で確認できます。 Tavern のお作法として、テストファイルのyamlは test_*.tavern.yaml という形で統一しているようです。 テストも通りました。 $ tavern-ci test_github.tavern.yaml ================================================== test session starts ================================================== platform darwin -- Python 3.6.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0 plugins: tavern-0.22.1 collected 1 item test_github.tavern.yaml . [ 100 % ] =============================================== 1 passed in 1.66 seconds ================================================ Tavern で Github API を使って、issueを作成することができました。 前のテストの結果を保存できる・次のテストに使える Tavern の便利なところとして、前のテストの結果を保存して、それを次のテストに使うことができるというのもあります。 Github API を使って Issue を作成 Issue の番号を取得 その番号のissueを編集する ということをやってみましょう。 先にテストからお見せします test_name : Github API Test includes : - !include common.yaml stages : - name : Create Issue request : url : https://api.github.com/repos/{service.owner:s}/{service.repo:s}/issues headers : Authorization : "token {service.token:s}" method : POST json : title : "Issue From Tavern Test" body : "一休.comをご利用頂き、誠にありがとうございます。" response : status_code : 201 save : body : issue_id : number - name : Edit Issue request : url : https://api.github.com/repos/{service.owner:s}/{service.repo:s}/issues/{issue_id} headers : Authorization : "token {service.token:s}" method : PATCH json : title : "Edit Issue From Tavern" body : "一休レストランをご利用頂き、誠にありがとうございます。" response : status_code : 200 body : state : "open" Create issue のresponse body で issue_id を save Edit Issue の request url で issue_id を使って、テスト ということをやっています。 Tavern で Github API を使って、issueの作成と編集をすることができました。 CI連携 tavern のインストール自体は、 pip install tavern のみで済みますし、 テストの実行自体も tavern-ci test_*.tavern.yaml で済むので、CI連携も容易です。 Tavern の実運用にまつわる細かい話 Tavern の実運用にまつわる細かい話としては 環境別のテストデータの設定 が気になるところなのかなと思います。 これに関しては、 https://tavern.readthedocs.io/en/latest/basics.html?#multiple-global-configuration-files に書いてありますが、各環境別で使うYAML を用意します。 まとめ 今回は、 Tavern を使ったAPI Test について紹介しました。 「API Test ライブラリといえば、コレだ!」というものがない印象でしたが、 テストを YAML で記述できて、イメージしやすい CI連携の容易 という点で、 Tavern は選択肢の一つとしてアリなのかなと思いました。 参考 github.com taverntesting.github.io tavern.readthedocs.io
アバター
こんにちは。 一休.com の開発基盤を担当しています、akasakasです。 今回は、E2EテストをSelenium WebdriverからCypress.ioに移行した話をしたいと思います。 一休のE2Eテスト事情 あれから、数年が経過して、、、 どうしてこうなった??? SeleniumではSPAへの対応が難しくなってきた なんでもかんでもSeleniumで頑張ろうとした弊害 いざリプレイスへ・リプレイスをする上で気をつけたこと 開発者フレンドリー 安定性 然るべきレイヤーでテストする(何でもかんでもブラウザテストにしない) 技術選定 Cypress.io とは? Cypress.io のいいところ セットアップが楽 テストを書くことだけに集中できる CI連携が楽 Cypress.io の頑張って欲しいところ その他、移行に関しての細かい話 重複テストケースの排除 Page Object Design Patternで設計 移行に乗じて、CIもJenkinsからCircleCIに変更 そして、E2Eは復活し、平和はおとずれた まとめ 一休のE2Eテスト事情 一休では staging/production へのリリース完了をフックにして、主要導線に対してE2Eテストを実施しています。 これを実施している主な理由としては 検証環境での障害の事前検知 リリース後も正常に予約ができるかどうかの確認 ECサイトで予約を止めるのは致命的なので、これを防ぐ があります。 詳しくはこちらのスライドに書いてあるので、興味のある方はみてください。 speakerdeck.com あれから、数年が経過して、、、 完全に動かなくなりました。悲しいです。 どうしてこうなった??? 理由としては SelniumではSPAへの対応が難しくなってきた なんでもかんでもSeleniumに任せようとした弊害 がありました SeleniumではSPAへの対応が難しくなってきた 一休ではSPA化が徐々に進んできています。 具体的な取り組みについては下記のエントリで紹介しているので、ご興味があれば、ご覧ください。 user-first.ikyu.co.jp user-first.ikyu.co.jp Selnieum Webderiver は画面遷移をしていくMPAに対して、効果を発揮するブラウザテストツールであり、 非同期リクエストや動的な画面の書き換え 画面遷移が発生しない SPAでSelenium Webdriverを使って安定したテストを継続していくのが困難でした。 Wait処理などを上手く使えば、不可能ではないですが、一休ではQA・テストエンジニアのようなポジションはいなく、開発者がテストも修正するようになってます。 開発者にテストを書く負担を減らして、サービス開発に集中して欲しいというのも思いとしてありました。 なんでもかんでもSeleniumで頑張ろうとした弊害 一休.com ではUTが充実していないためか、「なんでもかんでもSeleniumでテストしよう」みたいな雰囲気がありました。 具体的には、APIの疎通確認をしたいが為に、SwaggerUIのようなテスト用の画面を作成し、その画面をSeleniumを使って、APIの疎通確認を行っていました。 APIのテストをわざわざブラウザテストをする必要はないです。 ただでさえ、ブラウザテストは不安定で時間がかかるので、適切なレイヤーで適切なテストができていないというアンチパターンに陥っていました。 いざリプレイスへ・リプレイスをする上で気をつけたこと 上記の理由からSeleniumから別のブラウザテストツールの移行を検討しました 単純なツールの乗り換えだけだと、同じ過ちを繰り返す恐れがあったので、下記の点を注意しました。 開発者フレンドリー 安定性 然るべきレイヤーでテストする(何でもかんでもブラウザテストにしない) 開発者フレンドリー Selneiumの課題として、セットアップが面倒というのがありました。 開発者にテストへの時間を軽減して、サービス開発に集中して欲しいというのも思いがあったので、下記の点を重視しました。 セットアップの敷居が低いこと 開発者が容易にテストを作ることができる 安定性 言わずもがなですが、「移行したはいいが、テストが落ちまくっている」というのは有り得ないので、 SPAでも安定してテストが動く ということにフォーカスしました 然るべきレイヤーでテストする(何でもかんでもブラウザテストにしない) 前述でも書きましたが、 APIのテストを無理やりSeleniumで書いていた というのが、テストの安定性を損ねていた原因の一つでした。 この問題に関しては、APIテストライブラリを導入して、ブラウザテストとは切り分けました。 APIテストライブラリに導入については後日、どこかで書きたいと思います。 技術選定 ブラウザテストでSeleniumからどのツールを選ぼうかを考えた際に、以下の3つが選択肢としてありました WebdriverIO Puppeteer Cypress.io どの技術を採用するかで重要視したポイントが「開発者フレンドリー」であるかです。 具体的には セットアップ 書きやすさ の2点です。 セットアップという点だと、Puppeteer・Cypress.ioがいい印象でした。 書きやすさで見た場合、Cypress.ioの方がテストを書くことに集中できると思ったので、Cypress.ioを採用することに決めました。 Cypress.io とは? JavaScript製のブラウザテストに特化したE2Eテストフレームワークです。 Seleniumはテストを書くこと以外にもスクレイピング等の用途で使うことができますが、 Cypress.ioはテストを書くことに特化したE2Eテストツールです。 Cypress.io のいいところ Cypress.io の特徴は色々あると思いますが、個人的に感じるところとしては、次の3点が大きいと思います。 セットアップが楽 テストを書くことだけに集中できる CI連携が楽 セットアップが楽 Cypress.io はセットアップが非常に簡単です。 npm install cypress これだけで終わりです。 SeleniumだとGeckodriverやChromedriverをインストールしたり、パス設定したりと、 少し手間がかかるので、セットアップの敷居が低いという点で、非常にありがたいです。 テストを書くことだけに集中できる SeleniumやPuppeteerを選ぶと、 テストランナーどれを選ぼう レポーティングはどれにしよう アサートのライブラリはどれにしよう などといったところも考えると思います。 Cypress.io はオールインワンでサポートしているので、テストを書くことだけに集中することができます。 https://www.cypress.io/how-it-works/ で紹介されている、下記の図のようなイメージです。 CI連携が楽 CI連携が楽という点も個人的にはありがたかったです。 DockerImageが用意されている 各CI Provider に対して、 example project が用意されていて、わかりやすい こちらに詳細が書かれているので、興味のある方はご覧ください。 https://docs.cypress.io/guides/guides/continuous-integration.html Cypress.io の頑張って欲しいところ Cypress.io に対する不満はそんなにありませんが、あえて1点だけ挙げるなら クロスブラウザ未対応 という点です。 一休で、E2Eテストを実施している目的は 主要導線が正常に動くことを確認すること なので、クロスブラウザで確認する必要性はないです(確認するに越したことはありませんが) Cypress でもOpen Issue として挙げられているので、今後クロスブラウザ対応がされる日が来るかもしれません(いつになるのかはわかりませんが) Proposal: Support for Cross Browser Testing · Issue #310 · cypress-io/cypress · GitHub その他、移行に関しての細かい話 あと、移行に関する細かい話としては以下の3つがあります 重複テストケースの排除 Page Object Design Patternで設計 移行に乗じて、CIもJenkinsからCircleCIに変更 重複テストケースの排除 既存のテストケースを見直すと、同じようなことをテストしている部分があったので、 移行の際にテストケースを精査して、必要最低限のテストケースを実施するようにしました。 Page Object Design Patternで設計 既存のSeleiumでもPage Object Design Pattern を採用しましたが、 画面変更に対して強い設計方法なので、ここは変えませんでした。 移行に乗じて、CIもJenkinsからCircleCIに変更 以前はCIのためにオンプレサーバのJenkinsを用意していましたが、Jenkins起因でE2Eテストが失敗することもしばしばありました。 テストの安定性を考えた場合、CIも乗り換えた方がいいと感じていたので、このタイミングでCircleCIで実行するように変更しました。 そして、E2Eは復活し、平和はおとずれた かくかくしかじかありまして、E2Eテストが復活しました。めでたしめでたし。 まとめ 今回は、E2EテストをSelenium WebdriverからCypress.ioに移行した話をしました。 Seleniumがよくないとか、Cypress.ioがいいという話ではなく、 一休のサービス開発が進んでいった結果、SeleniumによるE2Eテストが難しくなり、今回Cypress.ioへの移行をしました。 Cypress.io の利点としては、上述でも書いた通り 開発者フレンドリー であることだと感じます。 一休のようにQAやテストエンジニアがいなく、開発者がE2Eテストを修正するようなワークフローになっている開発現場ではCypress.ioを採用するのは選択肢の一つとして、ありなのかなと思います。 しかし、正直な話、この仕組みも数年後には破綻するかもしれません。 その時はまたサクッと捨てて、その時に一休のサービス・開発現場にマッチする新しい仕組みに乗り換えればいいと思います。 そういうことができるようにブラウザレベルのテストを極力書かないほうがいいのかもしれません。 また、この数年間、一休.com を守ってくれたSeleniumには感謝と敬意を払いながら、未来のために新システムに切り替えていきたいです。
アバター
こんにちは。 一休.com の開発基盤を担当しています、akasakasです。 今回は、Rendertronを導入してDynamic Renderingをしている話をしたいと思います。 ここでお話しする内容 Dynamic Renderingについて 一休.com/一休レストランでDynamic Renderingが必要になった背景 Rendertron とは Rendertron にした理由 Rendertron 導入イメージ クローキングの懸念 苦労話 Rendertronのモバイル対応がバグってた Rendertronのメモリリーク AMPページに対してDynamic Renderingを適用するとレンダリング後が評価されてしまって正常なAMPとして認識されない 学び できたてのライブラリは不完全(どこかしらにバグは潜んでいる) Dynamic Renderingさせるべき画面・させないほうがいい画面を明確にする おまけ:Dynamic Renderingについて思うこと Dynamic Renderingについて Dynamic Renderingは、ユーザ向けのリクエストは正常に処理し、bot向けのリクエストはレンダラを経由し、静的HTMLを配信する方法です。 Dynamic Renderingについては、いろんなところで言及されているので、詳細については割愛します。 詳しくはこちらに書かれているので、興味のある方はご覧ください。 ダイナミック レンダリングの使用方法  |  検索  |  Google Developers Google ウェブマスター向け公式ブログ: Rendertron によるダイナミック レンダリング 一休.com/一休レストランでDynamic Renderingが必要になった背景 一休.comも一休レストランもSPA化が進んできたというが大きいです。 一休.comの場合 ホテルリストページ スマホ版の速度改善でmetaタグ等をJSで書いて、Full CSRにして、ページスピードを上げていきたいという背景がありました。 詳しくはこちらで書かれていますので、興味のある方はご覧いただければと思います。 user-first.ikyu.co.jp 一休レストランの場合 スマホページのSPA化対応後、SEO対策のためにフロント実装が複雑化し、パフォーマンス劣化を助長する形になっていた 検索結果の取得は 1 つの API で出来るのに SEO 上必要な文字列を SSR で描画するため複数の API の待ち合わせをする必要があった またそれらの SSR する情報は必ずしも Above the fold で表示されるコンテンツではないためパフォーマンスの観点で言えば遅延描画するのが合理的だった SEO とユーザーパフォーマンス改善の 2 つがコンフリクトしている現実を突き付けられた Dynamic Rendering ならそれぞれの用途に最適化した結果を返せる user-first.ikyu.co.jp 一休.com・一休レストランともにDynamic Rendering の必要性が増してきたため、導入を検討しました。 Rendertronとは Rendertron は Headless Chrome (Puppeteer) をベースとしたレンダラです。 github.com Rendertronの役割としては レンダリングさせるURLを受け取る 受け取ったURLのJavaScriptまで実行と描画 静的HTMLをレスポンスとして返す といったところです。 デモ用のエンドポイントもあるので、これを触ればRendertronのことをある程度知ることができると思います。 https://render-tron.appspot.com/ Rendertronを採用した理由 正直な話、ちゃんとした理由はないです(笑) id:supercalifragilisticexpiali がサクッとDockerfileを作ってくれた ST/PRDまでの環境もサクッとできた とりあえずできたから、本番投入して試してみよう という勢いです。 Redertron以外の選択肢として、以下の2つもアリだと思うので、興味のある方は試してみていいと思います。 github.com prerender.io Rendertron 導入イメージ 導入イメージは下記になります。 やっていることは2つです。 Fastlyでルーティングとレスポンスキャッシュ レンダラはRendertronに任せる という流れです クローキングの懸念 Dynamic Rendering自体がGoogleお墨付きの手法なので、問題ないとは思いつつも不安でした。 モバイルフレンドリーテストや Fetch As Googleで問題ないのは確認済みだったのですが、それでもちょっと不安だったので、部分導入でSEO面で問題ないかどうかという検証を行いました。 結果としては、問題なかったです。 苦労話 Rendertronのモバイル対応がバグってた Rendertron導入検討時はモバイル対応がバグってました。 現在は解消中ですが、下記のプルリクエストがマージされる前は、これを取りこんでいました Fix mobile rendering by danielpoonwj · Pull Request #234 · GoogleChrome/rendertron · GitHub Rendertronのメモリリーク Redertronを数日間、運用してみると、メモリ使用量がどんどん増えていくのがわかりました。 http status code 400 か 403 の場合は puppeteerが起動したままになり、プロセスが残り続け、メモリを食いつぶしていくということになってました。 下記のプルリクエストで対応はしておりますが、 2019/03/12 時点ではまだマージされてないので、これを取り込んで、なんとか乗り切ってます。 Always close page before return to prevent memory leak by ramadimasatria · Pull Request #268 · GoogleChrome/rendertron · GitHub AMPページに対してDynamic Renderingを適用するとレンダリング後が評価されてしまって正常なAMPとして認識されない Rendertron導入当初、AMPページもDynamic Renderingの対象ページとして含めていました。 しかし、AMPページに対してDynamic Renderingを適用するとレンダリング後が評価されてしまって正常なAMPとして認識されないので、AMPページはDynamic Renderingさせないようにしました。 学び できたてのライブラリは不完全(どこかしらにバグは潜んでいる) 単純に rendertronを git clone すれば、バグはないし、当然のように運用できるだろうと思ってましたが、その認識が甘かったです。 Dynamic Renderingさせるべき画面・させないほうがいい画面を明確にする AMPページもそうですが、Dynamic Renderingさせるべき画面・させないほうがいい画面を明確にして、ルーティングを設定するのが重要だなと思いました。 Dynamice Rendering する必要のない画面に対してやっても、ダウンロード時間が余計にかかるため、SEOのスコアを落としかねないです。 Dynamic Rendering の使用方法 でも書かれています。 ダイナミック レンダリングは、JavaScript で生成される変更頻度の高いインデックス登録可能な一般公開コンテンツや、サイト運営が重視するクローラではサポートされていない JavaScript の機能を使用するコンテンツに適しています。 すべてのサイトでダイナミック レンダリングを使用する必要はありません。 ダイナミック レンダリングはクローラ向けの回避策であることに注意してください。 おまけ:Dynamic Renderingについて思うこと Dynamic Renderingという手法自体が過渡期の技術という印象を受けました。 個人的に思うDynamic Renderingに関する違和感として ユーザーに見せる画面とクローラに見せる画面を異なるように扱う Googleが技術的にできないことを、こちら側でカバーしなければいけない というのがあるのかなと思います。 Chrome Dev Summit 2018 でFuture Enhancement として古い(Chrome41相当)レンダリングエンジンを解消すると言ってましたが、将来的には、Googlebotが最新のChromeになって、普通にJSを実行しているという未来が早く来て欲しいです。
アバター
宿泊事業本部フロントエンドエンジニアの宇都宮です。先日、ホテルリストページの高速化に関する記事を書きましたが、Resource Hintsのpreloadを利用することで、さらに高速化できました。そこで、preloadによる画像読み込みの最適化方法を紹介します。 以前の記事はこちら: 一休.comホテルリストの表示速度を従来比2倍にしました - 一休.com Developers Blog また、今回改善対象としたページには下記URLからアクセスできます(スマホでアクセスするか、PCからの場合はUAを偽装する必要があります)。 https://www.ikyu.com/sd/tokyo/140000/ 改善前 PageSpeed Insights: 改善前 Calibre: 改善前 改善後 PageSpeed Insights: 改善後 Calibre: 改善後 今回の改善のターゲットは、施設の画像を早く取得して、画面の主要部分を素早く描画することです。したがって、見るべき指標はSpeed Indexで、ここは約0.7秒改善しています! 多少のブレはありますが、PageSpeed Insights/WebPageTest/Calibreでの複数回の計測でいずれも改善という結果だったので、画像のpreloadは「効果あり」と見てよいと思います。 やったこと 改善前のリストページでは、ページ表示の最終段階で、各施設の大きめの画像を取得するリクエストが走っていました。 改善前の施設画像取得リクエスト このようになっている理由は、施設画像の取得リクエストが走るまでに以下のステップを踏む必要があったからです。 検索APIのレスポンス取得 検索結果のレンダリング完了 lazyloadの発火 そこで、検索APIのレスポンス取得が終わったタイミングで、施設画像のpreloadを行っては? と思い、実装してみました。 preloadは、リソースの取得処理が実際に発火するよりも先に、ブラウザに「将来このリソースを取得します」と教えることで、ブラウザがリソースを先読みしてキャッシュできるようにする機能です。 developer.mozilla.org 具体的には、HTMLの <link rel="preload"> という要素にpreloadしたいリソースの種別(as)とURL(href)を書いておくと、ブラウザがこのリソースを先に読んでおいてくれます。 JavaScriptでpreloadを実行する場合、以下のような実装になると思います。preloadをサポートしているのは iOS 11.3以上 なので、feature detectionは必須です。 // preloadのfeature detection const supportsPreload = (() => { try { return document .createElement( 'link' ).relList.supports( 'preload' ); } catch (e) { return false ; } } )(); /** * 指定したリソースをpreloadする * @param {string} href * @param {string} as */ function preload(href, as) { if (!supportsPreload) return ; const link = document .createElement( 'link' ); link.setAttribute( 'rel' , 'preload' ); link.setAttribute( 'as' , as); link.setAttribute( 'href' , href); link.onload = () => document .head.removeChild(link); document .head.appendChild(link); } /** * 画像をpreloadする * @param {string} href */ function preloadImage(href) { preload(href, 'image' ); } preloadの呼び出し側はこんな感じです。 // ファーストビューに入る施設の画像をpreload searchResult.accommodationList .slice(0, Math.round( window .innerHeight / 300)) .forEach(a => preloadImage(a.imageUrl)); これによって、以下のように、preloadした画像が優先的に読み込まれるようになりました。 改善後の施設画像取得リクエスト この結果、ファーストビューが完全に描画されるまでの時間が短くなりました。 なお、まだviewportに入っていない施設の画像は従来通りlazyloadしているため、リクエストの終盤になっています。 「大きめの画像を全てpreloadする」 vs 「ファーストビューで見える画像のみpreloadする」で比較すると、後者の方が低速回線時のSpeed Indexが良くなったため、ファーストビューで見える画像のみpreloadしています。 まとめ 今回のように、大きめの画像をページ表示の後半で取得しているような場合には、preloadによって一定のパフォーマンス改善効果が得られることがわかりました。 preloadすることでパフォーマンスが改善されるかはアプリケーションの要件次第ですが、簡単に実装できるので、引き出しに入れておくと良いと思います。
アバター
宿泊事業本部フロントエンドエンジニアの宇都宮です。 2018年度下期は、一休.comホテルリストページ スマホ版の速度改善に取り組んできました。その結果、ページのデザインはそのまま、機能面はリッチにしつつ、プロジェクト開始前の約2倍のスピードでページが表示されるようになりました。 本記事では、高速化のためにどのような施策を行ったのか紹介します。 なお、Webサイトの高速化手法については、ホテル詳細ページ高速化プロジェクトを実施した際にも記事を書いています。これらの記事で紹介している手法(たとえば、Imgixによる画像最適化等)については、記述を省略しています。あわせてご覧ください。 一休.comスマホサイトのパフォーマンス改善(概要編) - 一休.com Developers Blog 一休.comスマホサイトのパフォーマンス改善(JavaScript編) - 一休.com Developers Blog 一休.comスマホサイトのパフォーマンス改善(CSS・その他細かいチューニング編) - 一休.com Developers Blog 一休.comスマホサイトのパフォーマンス改善(サーバサイドとQAとリリース編) - 一休.com Developers Blog また、今回高速化の対象としたホテルリストページには以下のURLでアクセスできます(スマホで開くか、PCの場合はUAをスマホに偽装する必要があります) https://www.ikyu.com/sd/tokyo/140000/ ホテルリストページ プロジェクト開始前の状況 改善結果 やったこと パフォーマンス目標値の設定 施策1: ikyu-analytics-clientの最適化 施策2: コードの大幅な書き直し 施策3: 初回検索のAjax化 SSR(Nuxt.js)を採用しなかった理由 Dynamic Renderingの導入 今後の展望 We are hiring プロジェクト開始前の状況 下記画像は、プロジェクト開始前の、 PageSpeed Insights の計測結果です。 PageSpeed Insights: プロジェクト開始前 パフォーマンス監視SaaSの Calibre では、計測結果は以下のようになっていました。 Calibre: プロジェクト開始前 遅い4G回線相当の設定(下り1.4Mbps)とはいえ、Time to Interactiveに10秒以上かかっているのは遅いです。主要な指標(First Meaningful Paint、Speed Index、Time to Interactive)をそれぞれ半分にして、FMP 2秒、Speed Index 2.5秒、Time To Interactive 5.5秒くらいになれば、低速回線でもある程度快適に使えるサイトといえるでしょう。 改善結果 PageSpeed Insightsのスコアは従来の2倍以上になりました。 PageSpeed Insights: 改善後 Calibreでも指標が軒並み改善しています。TTIがFMPやSpeed Indexよりも早くなっているのは、APIレスポンス待ちでCPUがIdleになっているタイミングがあるからだと思われます。FMPまでの時間は半減しています。Speed Indexも改善していますが、もう一声ほしいところ。 Calibre: 改善後 計測が難しいため直接の指標にはしていませんでしたが、トップ( https://www.ikyu.com/sd/ ) => ホテルリストの遷移スピードは、体感的にはかなり速くなったように感じられます。これはFirst Contentful Paintの大幅改善(3.69s => 0.65s)が効いていそうです。 (2019-03-04 追記)本記事の「改善後」よりさらに高速化しました。2019-03-04現在のパフォーマンスは下記記事を参照してください: user-first.ikyu.co.jp やったこと パフォーマンス目標値の設定 高速化を実施するには、まず目標となる値を設定する必要があります。目標設定に際しては、(1) 自分たちのサイトの要件から実現可能な数値であること (2) 競合と比較して遅くないこと の2点が重要だと考えています。また、可能であれば、競合よりも速くして、速度で差別化できると、なお良いでしょう。 今回のプロジェクトでは、 Expediaのスマホ向けリストページ をベンチマークにして、高速化に取り組みました。 このページは高度な最適化を施されており、PageSpeed Insightsのスコアは63点と、競合の中でも群を抜いて速いページです。 PageSpeed Insights: Expedia そこで、今回のプロジェクトでは、以下の2段階のゴールを設けて高速化に取り組みました。 PageSpeed Insights 50点以上、主要指標(FMP/Speed Index/TTI)で20%以上の改善 PageSpeed Insights 65点以上、TTI 5秒以内 1は最低限達成したいゴール、2はややチャレンジングなゴールです。 施策1: ikyu-analytics-clientの最適化 一休では、アクセス解析ツールを内製しています。これはikyu-analyticsと呼ばれ、一休.com等では、ikyu-analyticsのクライアントライブラリを読み込んで使用しています。 パフォーマンスの観点で、ikyu-analytics-clientには大きな問題がありました。アクセスログの記録を 同期XHRで 行っていたのです。 私自身、Firefox等で、メインスレッド同期XHRのDeprecation Warningが出ていることは以前から認識していました。が、これがどの程度悪影響を及ぼしているのかは、分かっていませんでした。 しかし、昨年12月、 WebPageTest のBlock機能を使ってikyu-analytics-clientの読み込みを行わないようにしたところ、ページの読み込み完了までの時間が約1秒短くなることに気づきました。 ikyu-analytics-clientのJSはサイズも小さく、やってることもアクセスログの送信程度です。したがって、ikyu-analytics-clientがもたらしている遅延のほとんどは、同期XHRのレスポンス待ち時間だと推測しました。 そこで、データサイエンス部と連携して、ikyu-analytics-clientのアクセスログ送信の非同期化に取り組みました。 具体的には、 navigator.sendBeacon() が使える場合はこれを使い、使えない場合は非同期XHRを行うようにしました。 navigator.sendBeacon() は比較的新しいAPIで、iOSでは11.1以上でないと使えません。非同期なデータ送信を確実に行えるという、非同期XHRにはない特長を持っています。一方、非同期XHRは送信中に画面を遷移したりページを閉じたりすると送信がキャンセルされます。これについては、データサイエンス部と協議し、非同期XHRのキャンセルによるログの送信失敗は許容する、という合意を取りました。 ikyu-analytics-clientの非同期化後、PageSpeed Insightsのスコアは10点改善しました。 PageSpeed Insights: ikyu-analytics-client非同期化後 ikyu-analytics-clientは、一休.comの全ページのみならず、一休レストランなどでも使用されているため、一休が運営しているサービス全体で、読み込み完了が1秒速くなりました。 逆にいうと、ikyu-analytics-clientが同期XHRを使っていたことで、一休のサービス全体が1秒遅くなっていたということです。 ブラウザのWarningにはちゃんと耳を傾けるべし という教訓を得られました。 施策2: コードの大幅な書き直し ホテルリストページは、従来、ASP.NET WebForms + jQueryというスタックで実装されていました。これらを、ホテルページと同様、ASP.NET MVC(+Web API) + Vue.jsというスタックに置き換えました。 また、機能面では、検索実行時に毎回画面遷移していたのを改め、ページ内で再検索が行われるようにしました。 速いJavaScript/Vue.jsアプリケーションを書くための方法については、下記記事に書いた内容を踏襲しているので、省略します。 一休.comスマホサイトのパフォーマンス改善(JavaScript編) - 一休.com Developers Blog これに加えて、リストページの実装において特徴的なこととして、今回、Vuexは使いませんでした。 Vuexを使わなかった理由は、リストページはデータの流れがシンプル(検索APIからレスポンスを受け取り、描画するだけ)かつ、コンポーネントが素直なピラミッド構造になっていて、props down/events upで必要なデータの受け渡しを全て表現できたためです。また、Vuex Storeのコードは肥大化しがちなため、パフォーマンスの観点からの懸念もありました。 ただし、Vuexを完全に捨てたわけではなく、今後の改修で必要になれば、Vuexを導入する可能性はあります。 この書き直しによって、パフォーマンスは大きく改善しました。 PageSpeed Insights: リライト後 この時点で、ストレッチゴールの「PageSpeed Insights 65点以上」を達成できました 💪 施策3: 初回検索のAjax化 当初の目標を超えることができましたが、もう一押し改善できそうなポイントが残っていました。 施策2までの段階では、ページ初回表示時の検索処理は、サーバサイドで行っていました。SEOのためのtitleタグ、metaタグや、SNS等で共有する際に必要な情報(twitter card、facebook OGP等)を書くには検索結果を知っている必要があります。また、Botの中にはJavaScriptを実行しないものもあります。したがって、SEO関係のタグは、サーバサイドで書いて、初回レスポンスのHTMLに含める必要がありました。 一方、パフォーマンス改善の実験として、画面の初期表示時にサーバサイドで検索を行わずAjaxで行うようにしたところ、FCP/FMP/Time to Interactiveのそれぞれについて0.5秒程度の改善が見込めることがわかりました。 SEO関係のタグもJavaScriptで書くようにできれば、初回表示時に検索をサーバサイドで行う必要がなくなり、画面の初期表示はさらに速くできます。この問題の解決のためには、2つのアプローチが考えられました。1つはSSR、もう1つはDynamic Renderingです。 SSR(Nuxt.js)を採用しなかった理由 SSRを行って、JSの初回レンダリングの終わったHTMLを返すようにすれば、SEOの問題は解決します。 しかし、結論からいうと、SSR(Nuxt.js)は採用しませんでした。一休.comのアプリケーションの特性を考えると、SSRの導入によって遅くなる可能性が高いと考えたためです。 下記画像は先日Googleが公開した Rendering on the Web というドキュメントから抜粋したものです。 Rendering on the Web Nuxt.jsを使ったSSRは、この表の「SSR with (Re)hydration」に該当します。一方、現行の実装は「Full CSR」です。この2つを見比べると、「SSR with (Re)hydration」の方がConsが増えているのがわかります。 SSR with (Re)hydrationは、サーバサイドでレンダリングを行い、フロントエンドでも、Full CSRと同等のリソースを読み込んで、状態の引き継ぎ(Rehydration)を行います。単純に考えると、Rehydrationの分、Full CSRと比べて計算量が増えます。 実際には、SSRを入れると遅くなるという単純な話ではなく、SSRでしかできない最適化を入れることで、Full CSRより速くできます。 しかし、「SSR後のHTMLをCDNでキャッシュする」という強力な最適化手法は、一休.comのアプリケーション要件では、あまり効果的ではありません。宿泊日程等の検索条件に応じて細かく画面を出し分ける必要があるため、同じHTMLを返却できるリクエストの数が多くないためです。 アプリケーション要件の見直しを行い、キャッシュフレンドリーな設計にすればSSRを導入する余地があります。しかし、今回のプロジェクトのスコープにはUIの刷新は含まれていません。現状の画面仕様では、Full CSRの方がパフォーマンスが出ると判断しました。 Dynamic Renderingの導入 Dynamic Renderingとは、bot向けに静的HTMLを配信する方法です。これによって、JS描画済みの静的HTMLをGooglebot等が取得するようになるので、metaタグ等をJSで書いても問題なくなります。 詳細は下記ドキュメントを参照してください。 ダイナミック レンダリングの使用方法  |  検索  |  Google Developers 一休では、 Rendertron を使ってDynamic Renderingを行っています。 Rendertronの導入にあたっては色々苦労もあったようですが、これについては akasakas さんが書いてくれると思うので、ここでは詳しく触れません。 Dynamic Renderingによって、SEO関係のタグをJSで書く準備が整ったため、初回検索のAjax化に至りました。 この結果、冒頭の「改善結果」で紹介しているパフォーマンスが実現できました。 今後の展望 フロントエンドに関しては、パフォーマンス上のボトルネックのほとんどを解消した状態にもっていきました。一方、サーバサイドの検索APIについては、レスポンス速度がまちまちで、遅いときは1.5秒ほどかかることがあります。さらなる高速化のためには、検索APIの速度改善が必要そうです。 また、スムーズに宿泊施設を探すには、検索導線(トップ・リスト・ホテル)の全体的な回遊性が重要です。検索導線のSPA化等によって、ページ間のスムーズな移動を実現するような施策も検討しています。 We are hiring hrmos.co
アバター