一休.com Developers Blog

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

Slack ワークフロービルダーでバックオフィス業務をフロー化しよう

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

qiita.com

社内情報システム部 コーポレートエンジニアの大多和(id:rotom)です。 一休ではコーポレートIT、オフィスファシリティを中心に「情シス」業務を行っています。

皆さんはワークフロービルダー、使っていますか 👋

📑ワークフロービルダーとは

ワークフロービルダーは、2019年10月にリリースされた新機能で GUI ベースで Slack 上のワークフローを作成し、業務の効率化を図れるものです。

slackhq.com

すでに多くの解説記事があるため、ここでの詳細な説明は割愛しますが

  • 有料プラン契約中なら追加料金不要で使える
  • プログラミング不要で作成できる
  • 様々なトリガーでアクションを自動化できる

ことから自動化、効率化の中でも導入・運用のコストが低く、気軽に始めることができます。 リリース後、さっそく一休のワークスペースにも導入を行いました 💪

🏠 一休のワークスペースについて

2019年12月現在、一休は従業員数 約 400 名で Slack を全社導入しており、ゲストユーザーを含め、ワークスペースには約 550 名のアクティブメンバーが存在します。 エンジニアの比率は 14% ほどで、多くが営業で構成されている組織ですが、Slack は非常によく使われています。

f:id:rotom:20191216214113p:plain

テック系のスタートアップと比較すると DM 率は少々高めですが、おおむねパブリックチャンネル上で業務が行われています。 昨年の記事でも少し紹介しましたが、情シスや総務を始めとするバックオフィスへの依頼、申請も Slack のパブリックチャンネル上で行われています。

f:id:rotom:20191216214848p:plain

user-first.ikyu.co.jp

まずはこららの Slack 上で定型的に行われている依頼、申請についてワークフロービルダーを試してみました!

🖥 物品購入依頼

一休のエンジニア・デザイナーへは Slack 上で CTO の承認後、希望するディスプレイやキーボードなどの周辺機器、ソフトウェアや書籍を購入し支給しています。

f:id:rotom:20191216220431p:plain https://speakerdeck.com/kensuketanaka/introduce-ikyu

こちらも以前は申請者から承認者である CTO へメンションを送り、CTO が承認後に情シス 購買担当へメンションで購入依頼、という運用でした。

f:id:rotom:20191217143227p:plain

フリーフォーマットは依頼しやすい一方で人によって記入内容に差があり、承認者や購買担当にとって必要な情報が足りないこともありました。また、この運用では承認者が購買担当へ手動で依頼する手間も発生しており、効率化の余地がありました。

この購入依頼は以下のようにワークフローで組まれています。

f:id:rotom:20191217000444p:plain

承認者が「承認する」を押すと自動的に購買担当へメンションが飛び、購買担当が「購入完了」を押すと依頼者へその旨を連絡します。 ワークフロー内では変数で Slack ID を利用することができるので、自動的に各担当までメンションを送ることができます。

f:id:rotom:20191217143312p:plain

🗃名刺発注依頼

総務への名刺発注についても専用の Slack チャンネルがありましたが、何となく前の人の内容に合わせる… といった形で依頼がされていました。 名刺についても英語表記の有無、携帯電話番号の有無などのオプションがあるものの、それを選択できる決まったフォーマットがありませんでした。

f:id:rotom:20191217001819p:plain

こちらは以下のようにワークフローを組みました。上長承認などのフェーズが無いため、フローというよりはシンプルなフォームとなっています。

f:id:rotom:20191217002758p:plain

依頼者は以下のフォームに必要な項目を入力し、送信することで総務への依頼が完了します。

f:id:rotom:20191217003450p:plain

 💭ワークフロービルダーを使ってみて

ワークフロービルダーのよいところは、何よりも GUI ベースの操作により数分でフローやフォームを作成できる、というお手軽さです。 また、Slack の管理者ユーザーだけではなく、ゲストユーザーを除く全ユーザーが作成できるように設定が可能です。

プログラミングスキルも高権限も不要なため、情シス・エンジニアに限らず、バックオフィスメンバー自らが業務の効率化に着手することができます。

一方、現状のワークフローでは日時を持たせることができなかったり、ボタンを押下できる人を指定できなかったり、と SaaS で提供されているワークフローと比較すると機能的にできないことも多いため、あくまで補助的なツールとして運用に組み込むとよいと思いました。

今後のアップデートにも期待しつつ、バックオフィス業務の改善に最大限に活用していきます💪

次は id:rs-tokutake の記事です!

user-first.ikyu.co.jp

Go + TypeScriptによるGraphQLスキーマ駆動開発

こんにちは。宿泊事業本部の宇都宮です。この記事では、GraphQLをベースに、GoとTypeScriptでスキーマを共有しながら開発を進める方法について紹介します。

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

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

GraphQLとは

GraphQLは、Facebookによって開発された、Web APIのための クエリ言語 です。その特徴もSQLに似ていて、データの取得や更新を宣言的な記述によって行うことが出来ます。

仕様は公開されており、リファレンス実装として graphql-js がありますが、それ以外にも様々な言語でGraphQLサーバを実装できます。

GraphQLでは以下のようなフォーマットで問い合わせ(query)を行います。

{
  accommodation(accommodationID: "00001290") {
    accommodationID
    name
  }
}

結果は以下のようなJSONになります。

{
  "data": {
    "accommodation": {
      "accommodationID": "00001290",
      "name": "ザ・リッツ・カールトン東京"
  }
}

ここで、ある施設の近隣施設(neighborhoods)を取得したい、となった場合、クエリを以下のように書き換えます。

{
  accommodation(accommodationID: "00001290") {
    accommodationID
    name
    neighborhoods {
      accommodationID
      name
    }
  }
}

レスポンスは以下のように変わります。

{
  "data": {
    "accommodation": {
      "accommodationID": "00001290",
      "name": "ザ・リッツ・カールトン東京",
      "neighborhoods": [
        {
          "accommodationID": "00002708",
          "name": "三井ガーデンホテル六本木プレミア"
        },
        {
          "accommodationID": "00000662",
          "name": "グランド ハイアット 東京"
        }
      ]
    }
  }
}

このように、取得したいデータの形を宣言すると、その通りに返してくれる、というのがGraphQLの特徴です。ポイントは、取得したいデータの形を決める主導権は、クライアントにある、というところ。RESTでは、各APIがリソースを表すため、複数のリソースを取得して、その取得結果を合成したい、といった場合に不便なことがありますが、GraphQLではそういった問題点が解消されています。

ライブラリの選定

GraphQLを使い始める上で最初に考慮すべきことは、「GraphQLの機能をどの程度使うか」という点です。というのも、GraphQLサーバの実装は様々にありますが、GraphQLの仕様を完全に実装しているとは限らないからです。

GoでGraphQLサーバを書くためのライブラリの中で、仕様のカバー率が最も高いのは gqlgen であると思われます。

GoのGraphQLライブラリの機能比較: https://gqlgen.com/feature-comparison/

このgqlgenにしても、 未実装機能がいくつもあります 。たとえば、Fragmentsはサポートされていません。

このように、ライブラリの選定に際しては、「自分たちがGraphQLによって実現したいことは何か」をまず考えた上で、その用途に合ったライブラリを選定する必要があります。

GraphQLのサーバサイド実装が最も活発なのはNode.jsのため、GraphQLを最大限に活用した開発をしたい場合は、サーバサイドにはNode.jsを選ぶのが最も無難だと思います。Apolloをはじめとして、様々なライブラリが開発されています。

一方、Node.js以外でサーバを書く場合でも、GraphQLをRESTのお手軽な代替として使いたい向きもあるでしょう。そのような場合は、ライブラリがGraphQLの仕様をどの程度実装しているか確認したほうがよいです。

コードファースト vs スキーマファースト

GraphQLのライブラリ選定において、頭を悩ませるポイントになるのが「コードファースト」と「スキーマファースト」です。

この記事によると、Node.jsのGraphQLツールでは、スキーマ定義方法に3つの世代があります。

  1. 第一世代コードファースト
  2. SDLによるスキーマファースト
  3. TypeGraphQL, GraphQL Nexus等の第二世代コードファースト

第二世代コードファーストは、第一世代コードファーストの問題点であるコードの煩雑さを解消しつつ、スキーマファーストの問題点も回避する実装です。Goのエコシステムでは、第二世代コードファーストにあたるようなライブラリは出てきていないため、第一世代コードファースト相当かスキーマファーストかの二択になります。

コードファーストでは、初めにサーバサイドの言語でスキーマの定義とスキーマ解決方法(resolver)の実装を同時に行います。以下は graphql-go/graphqlを使った例です。

   fields := graphql.Fields{
        "hello": &graphql.Field{
            Type: graphql.String,
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                return "world", nil
            },
        },
    }
    rootQuery := graphql.ObjectConfig{Name: "RootQuery", Fields: fields}

一方、スキーマファーストは、GraphQLのスキーマ定義言語(SDL)によって先にAPIの形を決め、その後スキーマを満たすようにコードを実装していくアプローチです。↑と同等のスキーマは以下のようになります。

schema {
    query: Query
}

type Query {
    hello: String!
}

これを満たすresolverは、gqlgenでは以下のように書けます。

func (r *queryResolver) Hello(ctx context.Context) (string, error) {
    return "world", nil
}

このように、スキーマ定義をサーバサイド言語で書くかSQLで書くかがコードファーストとスキーマファーストとの違いです。この2つのいずれのアプローチを選ぶかで、GraphQLのツールチェインとの連携の容易さが変わってきます。

Webフロントエンドのツールチェインと簡単に連携できるのは、スキーマファーストの方です。コードファーストの場合は、スキーマを何らかの形で書き出して、フロントエンドのツールでも活用できるようにする工夫が必要でしょう。

また、Goの言語特性を考えても、リフレクションや interface{} を活用するコードファーストより、コンパイル時に型を決めてしまうスキーマファースト + go generateの方が向いていると思います。

Goによるサーバ実装

一休.comでは現在、gqlgenというスキーマファーストのGraphQLサーバライブラリを使用して開発を進めています。

gqlgenの採用事例は国内でもチラホラ見かけますが、たとえば技術書典のサイトなどはgqlgenを採用しているようです。

gqlgenでは、まずGraphQLのスキーマ言語でスキーマを定義します。

schema {
    query: Query
}

type Query {
    accommodation(accommodationID: String!): Accommodation
}

type Accommodation {
    accommodationID: String!
    name: String!
}

次に、 go generate ./... で、スキーマからコードを自動生成します。

スキーマを元にGoのインタフェースを自動生成(generated.go):

...
type ExecutableSchema interface {
    Schema() *ast.Schema

    Complexity(typeName, fieldName string, childComplexity int, args map[string]interface{}) (int, bool)
    Query(ctx context.Context, op *ast.OperationDefinition) *Response
    Mutation(ctx context.Context, op *ast.OperationDefinition) *Response
    Subscription(ctx context.Context, op *ast.OperationDefinition) func() *Response
}
...

レスポンスに使用する型の自動生成(models_gen.go):

// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.

package graphql

type Accommodation struct {
    AccommodationID string           `json:"accommodationID"`
    Name            string           `json:"name"`
}

クエリを解決するResolverのひな形の自動生成:

package graphql

//go:generate go run github.com/99designs/gqlgen

type Resolver struct {}

func NewResolver() *Resolver {
  return &Resolver{}
}

func (r *Resolver) Query() QueryResolver {
    return &queryResolver{r}
}

type queryResolver struct{ *Resolver }

func (r *queryResolver) Accommodation(ctx context.Context, accommodationID string) (*Accommodation, error) {
    return nil, nil
}

あとは、resolverに肉付けをするだけで、GraphQLサーバが実装できます。

func (r *queryResolver) Accommodation(ctx context.Context, accommodationID string) (*Accommodation, error) {
    x, err := r.accommodationsRepository.Find(ctx, r.dao.Read(), accommodationID)
    if err != nil {
        return nil, err
    }

    return &Accommodation{
        AccommodationID: x.AccommodationID.String(),
        Name:            x.Name,
    }, nil
}

このとき、GraphQLはあくまでPresentationレイヤーである、という点を意識し、resolverはドメインオブジェクトをレスポンスにマッピングする程度の仕事しかしないようにしておくのが重要だと思っています。

TypeScriptによるクライアント実装

最後に、TypeScriptによるクライアント実装を行います。ここは実際の開発では、サーバサイドの実装と平行することが多いでしょう。

GraphQLはクエリ文字列の入力を受け取り、JSONを返すので、fetchやXHRを使用した実装も可能です。しかし、GraphQLの特徴である型定義を最大限に活かすにはクライアント側にも型がほしいところ。

本節では、graphql-code-generatorを使用して、GraphQLクライアントライブラリ graphql-requestを使ったAPIクライアントを生成します。

まず、依存ライブラリを一式入れます。

yarn add graphql-request
yarn add -D graphql @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-graphql-request @graphql-codegen/typescript-operations

次に、graphql-code-generatorで使う設定ファイル(codegen.yml)を用意します。

overwrite: true
schema: "./api/graphql/schema.graphql"
documents:
  - "./api/graphql/queries/*.graphql"
generates:
  web/src/generated/graphql.ts:
    plugins:
      - "typescript"
      - "typescript-operations"
      - "typescript-graphql-request"

ここでは、スキーマの定義ファイルを /api/graphql/schema.graphql に置き、スキーマに対する操作を記述したドキュメントファイルを /api/graphql/queries/*.graphql に置いています。

ドキュメントは以下のように、実際のアプリケーションで使う操作を定義します。

query accommodation($id: String!) {
    accommodation(accommodationID: $id) {
        accommodationID
        name
    }
}

この状態で yarn run graphql-codegen --config codegen.yml を実行すると、APIクライアントが自動生成されます。

...
export function getSdk(client: GraphQLClient) {
  return {
    accommodation(variables: AccommodationQueryVariables): Promise<AccommodationQuery> {
      return client.request<AccommodationQuery>(print(AccommodationDocument), variables);
    }
  };
}

あとはAPIクライアントを使うだけ。クエリの引数の型が間違っていたりするとコンパイルエラーになりますし、取得したレスポンスにも型がついています。

import {GraphQLClient} from "graphql-request";
import {getSdk} from "./generated/graphql";

async function main() {
    const client = new GraphQLClient("http://localhost:8080/graphql")
    const sdk = getSdk(client)
    const {accommodation} = await sdk.accommodation({
        id: "00001290",
    })
    console.log(accommodation.accommodationID)
    console.log(accommodation.name)
}

main()

おわりに

GraphQLを使うことで、GoとTypeScriptでスキーマを共有しながら開発を行う方法を紹介しました。これらの技術は、一休.comでもこれから本番投入、というフェーズなので、まだまだ実運用を考える上では考慮すべきポイントが残っています(たとえば、GraphQLサーバの監視はどうするか)。

本記事で紹介した知見にアップデートがあれば、その都度ブログで記事にしていきたいと思います。

参考文献

新管理画面のAPIにGraphQLを採用した話

一休.com レストランを開発している所澤です。この記事は一休.comアドベントカレンダーの10日目の記事です。

先日、一休.comレストランの管理画面をリニューアルしました。 この記事ではその際にAPIの実装方法として採用したGraphQLについてフロントエンド視点で利点や使い所について述べます。

GraphQLについて以下の記事がわかりやすかったです。

「GraphQL」徹底入門 ─ RESTとの比較、API・フロント双方の実装から学ぶ - エンジニアHub|若手Webエンジニアのキャリアを考える!

短いまとめ

  • 新しくAPIサーバーを書くなら是非GraphQLで! というくらい良かった
  • Apolloのエコシステムに乗り切らなくてもいい。ふつうのRESTfulなAPIサーバーの代わりに、くらいの気軽さでGraphQLを採用してもいい

プロジェクトの概要

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

  • レストラン店舗向けの管理画面
  • 主な用途は在庫の管理と、プラン(コース)や席の管理

f:id:shozawa:20191210085303p:plain

アプリケーションの構成

  • サーバー

    • すでに運用中のアプリケーションに新しいエンドポイントを追加した
    • 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点です。

  1. 関連リソースを簡単に取れる
  2. 常に最新のAPIドキュメントが手に入る
  3. APIの設計で悩むことが減る

それぞれ詳しくみていきましょう。

1. 関連リソースを簡単に取れる

GraphQLの一番の特徴は複数のリソースを一度のQueryで取得できる点です。

f:id:shozawa:20191210084940p:plain

例えば上記の画面だと

  • プランの情報(プラン名・利用可能人数など)
  • プランの販売状況
  • プランに紐づいている席の情報(席名など)
  • 座席の販売状況
  • カレンダー

などのリソースを一度のリクエスト取得しています。

一般的にドメインモデルが独立していることは少なく、それぞれが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を確認したりできます。

f:id:shozawa:20191210085040p:plain

サーバーサイドとフロントエンドを分業して開発する場合に特に重要なのは、後者の"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の使い方

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を活用していく予定です。