一休.com Developers Blog

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

GraphQLのN+1問題を解決する DataLoaderの使い方

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

こんにちは。宿泊事業本部の宇都宮です。この記事では、GraphQLサーバ実装時に遭遇するN+1問題と、その解決のために使えるライブラリを紹介します。

フィールド単位でresolverを用意する

GraphQLでは、クライアントのクエリに応じてオンデマンドに結果を取得できます。

たとえば、以下のクエリを投げると…

{
  accommodation(accommodationId: "00001050") {
    name
  }
}

以下のようなレスポンスが取得できます。

{
  "data": {
    "accommodation": {
      "name": "マンダリン オリエンタル 東京"
    }
  }
}

ここで、施設のクチコミレーティングを取得したい場合、以下のようなクエリを投げることができます。

{
  accommodation(accommodationId: "00001050") {
    name
    rating
  }
}

このとき、サーバサイドではクエリによって必要なカラム(場合によっては、JOINするテーブル)が動的に変わります。バックエンドで動的にSQLを組み立てるのも1つの方法でしょう。しかし、SQLの組み立てロジックが複雑になったり、生成されるSQLが巨大でパフォーマンスの悪いものになったりするといった懸念点があります。

別のアプローチとして、追加のJOINが必要になるフィールドには GraphQL resolverを別に用意して、GraphQLサーバにレスポンスの組み立てを任せる、というものもあります。このようにすると、各resolverの実装をシンプルに保ちつつ、複雑なクエリに応答することができます。

一休.comでも使用している gqlgen というGoのGraphQLライブラリでは、以下の手順でフィールド単位のresolverを用意できます。

(1) GraphQLのスキーマと、gqlgenの設定ファイルを用意する

# schema.graphql

type Accommodation {
    name: String!
    rating: Float!
}
# gqlgen.yml

models:
  Accommodation:
    fields:
      rating:
        resolver: true # この設定がキモ

(2) go generate して、インタフェースを満たす

Resolverのインタフェースは以下のようになります。

// generated.go
type AccommodationResolver interface {
    Rating(ctx context.Context, obj *Accommodation) (float64, error)
}

これを満たす実装は以下のように書けます。

// resolver.go

func (r *accommodationResolver) Rating(ctx context.Context, obj *Accommodation) (float64, error) {
    summary, err := appcontext.From(ctx).Loader.ReviewSummary.LoadByAccommodationID(ctx, obj.AccommodationID)
    if err != nil {
        return 0, err
    }
    return summary.Rating, nil
}

N+1問題

このようにすると、無駄なデータの取得を避けつつ、resolverの実装がシンプルに保つことができます。しかし、以下のようなクエリを処理する際には問題が発生します。

{
  accommodation(accommodationId: "00001050") {
    name
    rating
    neighborhoods {
      name
      rating
    }
  }
}

ここでは、ある施設の近隣施設を取得して、それらのratingを取得しています。仮に、クチコミのレーティング取得が select rating from review_summary where accommodation_id = ? のようなクエリで実装されていると、このクエリが近隣施設の数だけ実行されることになります。このように、関連レコードの件数の分、追加データ取得用のクエリが発生する状態をN+1問題と呼びます。

このときのSQLの流れは以下のようになります。

-- 親の accommodation と rating を取得
select name from accommodation where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;

-- 近隣施設を取得
select accommodation_id, name from neighborhood_accommodation where accommodation_id = ?;

-- 近隣施設の数だけ rating を取得するクエリが発行される。。。
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;

-- ↑ではなく、↓のように一括で取ってほしい
select rating, accommodation_id from review_summary where accommodation_id in (?, ?, ?, ?, ?);

このような場合、RailsなどではORMの 一括読み込み 機能を利用します。

一方、gqlgenでは、各resolverは平行に実行されるので、ORMによる一括読み込みは利用できません。このような場合に利用可能な、データ取得をバッチ化する仕組みが DataLoader です。DataLoaderのオリジナルはJavaScript実装の graphql/dataloader ですが、様々な言語のDataLoader実装が公開されています。また、DataLoaderはGraphQLサーバで使うために作られたライブラリですが、GraphQLとは関係なく、REST APIなどでも利用できます。

GoのDataLoaderライブラリ

Go製の有力なDataLoaderライブラリは、私が把握している範囲では以下の2つです。

前者は graph-gophers/graphql-go 、後者は gqlgen の作者によるライブラリです。

一休.comではgqlgenを使っているため、当初は dataloaden の方を試しました。dataloadenはgqlgenと同じくgo generateによるコード生成ライブラリとなっており、型安全なDataLoaderを生成できるという特長があります。しかし、モデルの配置方法などに制約が強く、私たちの用途には合いませんでした。

そこで、今は graph-gophers/dataloader を使っています。

DataLoaderの仕組み

サンプルコードに入る前に、DataLoaderの仕組みについて解説します。DataLoaderは前述したようにデータ取得をバッチ化するためのライブラリですが、そのための仕組みとしては「一定時間待って、その間に実行されたデータ取得リクエストをバッチ化する」というアプローチを取っています。

「一定時間」は、1msや16msなどといった値になります。この値が大きくなるとバッチ化できる範囲が広がりますが、その分レスポンスタイムが遅くなるおそれがあります。

graph-gophers/dataloader では、dataloader.Loader の Load() メソッドを呼び出すと、 Thunk という型の関数が返ってきます。この関数はJavaScriptのPromiseのようなもので、一定時間待った後で値が取得できます。

thunk := dataloader.Load(ctx, key)

実際のサーバでは、 Load() は平行して呼ばれるため、各goroutineが Thunk を受け取ります。

// goroutine A
thunk := dataloader.Load(ctx, key)

// goroutine B
thunk := dataloader.Load(ctx, key)

// goroutine C
thunk := dataloader.Load(ctx, key)

このthunkを呼び出すと、結果を取得できます。

thunk := dataloader.Load(ctx, key)
result, err := thunk()

一定の待ち時間の間に呼び出された Load() のkeyを覚えておいて、一括でデータ取得を行うのがDataLoaderの仕組みです。

// ここで 1ms のタイマースタート
s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000001")

// 0.5ms経過…

// この呼び出しは↑と一緒にバッチ化される
s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000002")

// 1ms 経過:↑の2件をまとめて、以下のクエリを発行し、結果を返す
// select accommodation_id, rating from review_summary where accommodation_id in ('00000001', '00000002')

// この呼び出しは別のバッチになる
s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000003")

DataLoaderのサンプルコード

完全な形のサンプルコードとしては、 hatena/go-Intern-Bookmark がオススメです。ここでは、このサンプルコードを題材に graph-gophers/dataloader の使い方を解説します。

DataLoaderを使うには、まず以下のようにバッチでデータ取得を行う関数を用意します(コードは簡略化しています)。

// loader/entry.go
func newEntryLoader(app service.BookmarkApp) dataloader.BatchFunc {
    return func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
        entryIDs := keysToEntryIDs(keys)
        entrys, _ := app.ListEntriesByIDs(entryIDs) // ここがキモ。 select * from entry where id in (...) を投げる
        return entrysToResults(entrys)
    }
}

次に、この関数を context に保持させます。なぜ context に保持させるのかというと、DataLoaderのキャッシュ機能はリクエスト単位のデータのキャッシュを意図しているからです。リクエスト毎に内容が空になる context は、DataLoaderを保持させる場所にぴったりです。これによって、バッチ化の対象は同一リクエスト内の Load() の呼び出しに限定されます。

contextへの追加はミドルウェアで行います。

// web/server.go

func (s *server) attatchLoaderMiddleware(next http.Handler) http.Handler {
    loaders := loader.New(s.app)
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        next.ServeHTTP(w, r.WithContext(loaders.Attach(r.Context())))
    })
}

このようにしてcontextに登録したDataLoaderは、以下のようにして呼び出せます。

// resolver/bookmark_resolver.go

// hatena/go-Intern-Bookmark は graph-gophers/graphql-go を使っているため、
// resolverの書き方がgqlgenとは異なる
func (b *bookmarkResolver) Entry(ctx context.Context) (*entryResolver, error) {
    // LoadEntry は context から DataLoader を取得し、Load() を呼び出して、結果を Entry 構造体にして返す
    entry, err := loader.LoadEntry(ctx, b.bookmark.EntryID)
    if err != nil {
        return nil, err
    }
    return &entryResolver{entry: entry}, nil
}

DataLoaderとDataDog APM

一休で使っているDataDogのAPM(Application Performance Monitoring)だと、以下のようなトレースが見えます。resolverが平行に実行されている様子が分かりやすいです。

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

紫色がDB問い合わせで、Ratingの取得は1回のDB問い合わせにまとめられていることがわかります。また、APMを活用すると、「待ち時間が短すぎて、複数のバッチに分かれてしまっていないか?」といった調査も簡単にできます 👍

むすび

今回はGoのDataLoaderライブラリの使い方を紹介しました。DataLoaderはややトリッキーですが、ハイパフォーマンスなGraphQLサーバの実装には欠かせないライブラリだと思います。

採用情報

一休では、GoやGraphQLに強みのあるエンジニアを募集しています! 一休.comのバックエンドは .NET Framework から Go への移行を進めていて、バックエンドでGoを書く割合が少しずつ増えているところです。

hrmos.co

イベント告知: 1/29に「一休.comのプロダクト改善事例と開発の裏側」を開催します

来週1/29(水)にエンジニア向けの採用PRイベントとして一休.comのプロダクト改善事例と開発の裏側を開催します。

一休では、主力サービスである 一休.com、一休.comレストランのプロダクト開発に関わるエンジニア職種の方を積極採用中です。

本イベントでは約2年に渡る一休.comのプロダクト改善の歴史を振り返りながら、実際に取り組んだ課題と改善に対するアプローチについてエンジニアリングマネージャーの田中(id:kentana20)がお話します。

トークセッションの後は、CTOの伊藤 (id:naoya) と2人でパネルディスカッションをしながら参加者のみなさまからの質問にもお答えします。

イベントの詳細、参加方法については以下のconnpassイベントページをご覧ください。皆様のご参加をお待ちしています!

ikyu.connpass.com

Datadog Log Management でアプリケーション稼働モニタリング

こんにちは。 システム本部CTO室のakasakasです。

今回は、Datadog Log Management を使ってアプリケーション稼働モニタリングをしている話をしたいと思います。

一休のモニタリング周りの話

Datadog Log Management とアプリケーション稼働モニタリングの話をする前に、一休でどのような監視をしているのか?という話を簡単にします。

一休ではDatadogをモニタリングツールとして使っています。 主な用途は2つあります。

  • インフラのリソースモニタリング
  • 外形監視

インフラのリソースモニタリング

インフラメトリクスのダッシュボードとアラートの設定は運用として乗っています。 具体的には、サービス(宿泊・レストランetc)毎のアプリケーションサーバやDBサーバのモニタリングをしています。

CPUで閾値を超えたら、Slack通知が飛び、エンジニアが対応するという形をとっています。

f:id:akasakas:20200111192017p:plain
インフラメトリクスのダッシュボード

f:id:akasakas:20200111192054p:plain:w500

外形監視

Datadog Synthetics API Tests を使って、外形監視をしています。 こちらも同様に、外形監視で異常が起きたら、Slackに通知が飛び、エンジニアが対応します。

f:id:akasakas:20200111192141p:plain
Synthetics API Tests

f:id:akasakas:20200111192211p:plain:w500

モニタリング観点で一休が抱えていた課題

インフラレイヤーでのモニタリングはできているが、アプリケーションレイヤーでのモニタリングはできていないというのが課題感としてありました。

ここでいうアプリケーションレイヤーでのモニタリングとは

  • 予約が正常にできているかどうか
    • エラーが多発してないか?
  • 予約通知メールが正常に送られているかどうか
    • メール送信件数が適切か?異常に多い、少ないということはないか?
  • 検索導線でのリクエスト数がどの程度あるのか?エラーがどの程度あるのか?

というサービスの状態がヘルシーかどうかという観点です。

※レイテンシーやエラーレートといったAPMとは異なります。Datadog APMは一部のサービスで運用しています。

これらを時系列で監視し(e.g. 10分毎の予約件数/1日ごとのメール送信件数) 異変があれば、アラートを飛ばすという仕組みがあれば、いち早く障害に気づけると考えました。

Datadog Log Management

このアプリケーション観点の監視をするために、Datadog Log Managementが有効だと考えました。

Datadog Log Management は Datadog 上でログを管理するサービスです。

一休では昨年ログ管理サービスをLogentriesからDatadog Log Management に完全移行しました。

導入方法や詳細な使い方は割愛します。

docs.datadoghq.com

Datadog Log Management を使って、アプリケーションログ・アクセスログをベースに時系列の予約状況・検索数の推移・メール送信件数etcを集計&ダッシュボードでグラフ化&アラートの設定ができれば、アプリケーション稼働モニタリングが実現できると考えました。

Datadog Log Management からダッシュボード作成

実際にDatadog Log Management から作成したアプリケーションモニタリングのダッシュボードがこちらです。

f:id:akasakas:20200111194244p:plain
宿泊スマートフォン予約状況

f:id:akasakas:20200111194316p:plain
宿泊PC・スマホ検索導線のアクセス推移とエラー状況

グラフの作成方法は

  • LogEvents を選択
  • タグで絞り込み

のみで、簡単です。

f:id:akasakas:20200111202201p:plain:w500

Datadog Log Management からアラート作成

予約状況の監視もアラートで検知することもできます。

f:id:akasakas:20200111200618p:plain:w500

New Monitor から Logs を選択し、検索クエリを指定すれば、Monitorが作成できます。

f:id:akasakas:20200111203513p:plain:w500

必要なメトリクスはカスタムメトリクスを作る

Datadog Log Management では取得できないメトリクスもあると思います。 その場合は、Datadog API を使って、カスタムメトリクスを作ります。

メトリクス API については下記をご覧ください。 docs.datadoghq.com

Datadog API を扱う際はRubyとPythonでそれぞれ API Clientがあるので、そちらを使うのがいいと思います。

GitHub - DataDog/datadogpy: The Datadog Python library

GitHub - DataDog/dogapi-rb: Ruby client for Datadog's API

カスタムメトリクスを作る例として、一休では検索にSolrを使っています。 SolrのIndex数を監視したいという場合は、SolrからIndex数を取得し、APIを使ってカスタムメトリクスを作成しDatadogに送信します。

具体的には下記のようなスクリプトをLambdaで定期実行するイメージです。

from datadog import initialize, api
import time
import requests

options = {
    'api_key': '<DATADOG_API_KEY>'
}

initialize(**options)

# Solrにリクエスト
r = requests.get('<Solr Endpoint>')

# Index数取得
index_count = r.json()['index_count']

now = time.time()

# Solrのindex数をカスタムメトリクスにして、Datadogに送信
api.Metric.send(metric="solr.index.count", points=(now, index_count), type="count")

カスタムメトリクスが作成できれば、Datadog上でダッシュボードとアラートが設定できます。

f:id:akasakas:20200114132046p:plain:w500
カスタムメトリクスから作成したSorのインデックス数

Datadog Log Management から取得できないが、監視したい項目については カスタムメトリクスを作るのもアリだと思います。

graph_snapshot API を使って、デイリーレポート

ただ、単純に

  • ダッシュボード作りました
  • アラート作りました

だけだと、せっかく作ったダッシュボードやアラートがエンジニアから忘れ去られそうという懸念がありました。

なので、「アプリケーションちゃんと動いているよ!エラーちょっと多いよ!」というのを伝える意味も込めて、デイリーレポートをslackに投稿するようにしました。

下記のようなイメージです。

f:id:akasakas:20200111193048p:plain:w500
アプリケーション稼働モニタリングのデイリーレポート

デイリーレポートをすることで、「エラーちょっと多いから確認した方がよくない?」みたいなことになり、調査&対応するという方向でエンジニアが動いてくれます。

f:id:akasakas:20200114155125p:plain:w500

これは graph_snapshot API を使って、キャプチャを作り、Slackに投稿するスクリプトをLambdaで日時で動かしています。

graph_snapshot API については下記をご覧ください。

docs.datadoghq.com

graph_snapshot API については細かいところを含めて、いくつか注意点があるので書いときます。

1.デフォルトの Rate Limitiing がけっこう厳しい

https://docs.datadoghq.com/ja/api/?lang=bash#rate-limiting に記載がある通り、

graph_snapshot API 呼び出しのレート制限値は、60/時間/Organization です。これは、オンデマンドで増やすことができます。

とあるので、無邪気にAPIを叩いていると、すぐに引っかかります。

2. graph_snapshot API のタイムゾーンがUTC固定

graph_snapshot API のタイムゾーンはUTCになっていて、任意のタイムゾーンに変更できません。

3. API リクエストで渡すパラメータがちょっと複雑

graph_snapshot API でグラフを作成する場合のAPIリクエストでJSONを扱う場合があるので、ちょっと面倒です。

DashBoardと同様のグラフを作りたい場合は、該当するグラフのJSONをリクエストにつめる必要があります。

f:id:akasakas:20200111232123p:plain

GitHub - DataDog/datadogpy: The Datadog Python library を使ったサンプル例が以下になりますが、JSONが長くなってしまうのが少し煩わしく感じるかもしれません。

from datadog import initialize, api
import time

options = {
    'api_key': '<DATADOG_API_KEY>',
    'app_key': '<DATADOG_APPLICATION_KEY>'
}

initialize(**options)

# Take a graph snapshot
end = int(time.time())
start = end - (60 * 60)
resp = api.Graph.create(
    graph_def='{\
        "viz": "timeseries", \
        "requests": [ \
            { \
                "q": "xxxxxxxxxxx", \
                "type": "bars", \
                "style": { \
                    "palette": "dog_classic", \
                    "type": "solid", \
                    "width": "normal" \
                } \
            } \
        ], \
        "yaxis": { \
            "scale": "linear", \
            "min": "auto", \
            "max": "auto", \
            "includeZero": true, \
            "label": "" \
        }, \
        "markers": [] \
    }',
    start=start,
    end=end
)

print(resp["snapshot_url"])

まとめ

今回は、Datadog Log Management を使って、アプリケーション稼働モニタリングを実現した話をしました。

単純なログ管理ツールとして使うだけでも、Datadog Log Management は便利ですが、 ダッシュボードやアラートなどを組み合わせることで、アプリケーションの状態が一目でわかるというのはいいと思いました。

最後に

Datadogのサポートの皆様にはいつも助けられています。 どんな問い合わせに対しても、いつも丁寧にサポート頂いているDatadogの皆様に御礼申し上げます。

Amazon EKS でWindowsコンテナを動かす

Amazon EKS Windows Container を使ってみる。

今年の10月に、Amazon EKSがWindows ワーカーノードのサポートを開始しました。

aws.amazon.com

一休では、今年の初めから、既存アプリケーションのEKS移行を行っており、夏には、ほぼすべてのLinux系アプリケーションをEKSへ移行することができました。

user-first.ikyu.co.jp

これを踏まえ、Windows系のウェブアプリケーションもEKSへ移行できないか、技術検証を行っています。具体的な検証ポイントは以下のふたつです。

  1. Amazon EKS で、Linuxコンテナ同様、Windows コンテナが動作するか。
  2. 既存のWindowsのWebアプリケーション(ASP.NETアプリケーションをDockerコンテナ化できるか。

2については、公開されている各種チュートリアルやサンプルなどを参考に、動作させることができました。

この記事では、主に、1.の検証でわかったことや注意点を紹介したいと思います。

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

Amazon EKS でWindowsのワーカーノードを作ってみる。

まず、eksctlを使って新しいeksクラスタを作成し、Windowsのワーカーノードを作ってみます。 作成のコマンドは以下の通りです。

eksctl create cluster -f cluster.yaml --install-vpc-controllers

--install-vpc-controllers という引数を渡すことで、Windowsノードグループに必要なリソースを追加します。

cluster.yamlの内容は以下の通りです。

apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
  name: dev-win
  region: ap-northeast-1
  version: "1.14"
vpc:
  id: "vpc-123456789"
  cidr: "10.0.0.0/16"
  subnets:
    private:
      ap-northeast-1a:
        id: "subnet-xxxxxxxx"
        cidr: "10.0.144.0/24"
      ap-northeast-1c:
        id: "subnet-yyyyyyyy"
        cidr: "10.0.145.0/24"
      ap-northeast-1d:
        id: "subnet-zzzzzzzz"
        cidr: "10.0.146.0/24"
iam:
  withOIDC: true
nodeGroups:
  - name: ng-control-1
    labels: {role: workers }
    tags: {Stack: development, Site: ikyucom, Role: eks-node, k8s.io/cluster-autoscaler/wincluster: owned, k8s.io/cluster-autoscaler/enabled: "true"}
    instanceType: t3.medium
    desiredCapacity: 2
    maxSize: 2
    ebsOptimized: true
    privateNetworking: true
    securityGroups:
      attachIDs: [sg-xxxxx]
      withShared: true
    ssh:
      allow: true
      publicKeyPath: xxxx-key
  - name: ng-win-1
    amiFamily: WindowsServer2019FullContainer
    labels: {role: workers }
    tags: {Stack: development, Site: ikyucom, Role: eks-node, k8s.io/cluster-autoscaler/wincluster: owned, k8s.io/cluster-autoscaler/enabled: "true"}
    instanceType: t3.medium
    desiredCapacity: 3
    maxSize: 3
    ebsOptimized: true
    privateNetworking: true
    securityGroups:
      attachIDs: [sg-xxxxx]
      withShared: true
    ssh:
      allow: true
      publicKeyPath: xxxx-key

注目点はふたつです。

  • withOIDC: true を設定しています。IAMのアクセス許可をOIDC(OpenID Connect)を使って、Kubernetes サービスアカウントに割り当てるために必要な設定です。詳細は後述します。
  • ノードグループをふたつ作成しています。LinuxのノードグループとWindowsのノードグループです。これは、一部のシステム系のPodが、Linuxでしか動かないためです。

作成したクラスタにポッドをデプロイする。

システム系のポッドをデプロイする。

一休では、aws-alb-ingress-controllerexternal-dnsを使ってWebアプリケーションを提供しています。Windowsのコンテナアプリケーションでもこのふたつを使っていきたいです。 ただし、このふたつは、Windowsノードグループ上では動作しません。Linux上で動作させる必要があります。 これを実現するために、node selectorを使います。Linux ノードにはデフォルトで、 kubernetes.io/os: linux というラベルがついています(Windowsの場合は、kubernetes.io/os: windows がつきます)。 これを踏まえると、aws-alb-ingress-controllerのyamlは以下の通りになります。

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app.kubernetes.io/name: alb-ingress-controller
  name: alb-ingress-controller
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: alb-ingress-controller
  template:
    metadata:
      labels:
        app.kubernetes.io/name: alb-ingress-controller
    spec:
      containers:
        - name: alb-ingress-controller
          args:
            - --ingress-class=alb
            - --cluster-name=dev-win
            - --aws-vpc-id=vpc-123456789
            - --aws-region=ap-northeast-1
          image: docker.io/amazon/aws-alb-ingress-controller:v1.1.3
          ports:
          - containerPort: 10254
            name: health
            protocol: TCP
      serviceAccountName: aws-alb-ingress-controller
      nodeSelector:
        kubernetes.io/os: linux

external-dns は以下のようになります。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: external-dns
  namespace: kube-system
spec:
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: external-dns
    spec:
      serviceAccountName: external-dns
      containers:
      - name: external-dns
        image: registry.opensource.zalan.do/teapot/external-dns:v0.5.17
        args:
        - --log-level=info
        - --domain-filter=dev.com
        - --policy=upsert-only
        - --provider=aws
        - --registry=txt
        - --interval=1m
        - --source=service
        - --source=ingress
        ports:
        - containerPort: 7979
          protocol: TCP
        livenessProbe:
          failureThreshold: 3
          httpGet:
            path: /healthz
            port: 7979
            scheme: HTTP
          periodSeconds: 10
          successThreshold: 1
          timeoutSeconds: 1
      nodeSelector:
        kubernetes.io/os: linux

このふたつのyamlをapplyすることで、両方ともLinuxノードで動かすことができます。 ※ サービスアカウントの設定については後述します。

Webアプリケーションをデプロイする。

こちらもnode selectorを使って、windowsノードグループにデプロイさせるようにします。 事前にデプロイした、alb-ingress-controllerとexternal-dnsを使って、クラスタ外からアクセスできるウェブアプリケーションとして、デプロイするには以下のyamlになります。

## Deploymentの定義
apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-web-app
spec:
  selector:
    matchLabels:
      app: test-web-app
  replicas: 1
  template:
    metadata:
      labels:
        app: test-web-app
    spec:
      containers:
      - name: test-web-app
        image: xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/test-web-app:latest
        ports:
        - name: http
          containerPort: 80
        imagePullPolicy: IfNotPresent
        livenessProbe:
          httpGet:
            port: 80
            path: /prob
          failureThreshold: 5
          periodSeconds: 5
        readinessProbe:
          httpGet:
            port: 80
            path: /prob
          failureThreshold: 5
          periodSeconds: 5
      dnsConfig:
        options:
          - name: ndots
            value: '1'
      serviceAccountName: test-web-app
      nodeSelector:
        kubernetes.io/os: windows
---
## Serviceの定義
apiVersion: v1
kind: Service
metadata:
  name: test-web-app
  namespace: default
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 80
  selector:
    app: test-web-app
  type: NodePort
---
## Ingressの定義
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  labels:
    app: test-web-app
  name: test-web-app
  annotations:
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:ap-northeast-1:xxxxxxxxxxxx:certificate/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
    alb.ingress.kubernetes.io/healthcheck-path: /health
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/security-groups: security-group-name
    alb.ingress.kubernetes.io/subnets: subnet-xxxxxxxx,subnet-yyyyyyyy,subnet-zzzzzzzz
    kubernetes.io/ingress.class: alb
    external-dns.alpha.kubernetes.io/hostname: testapp.dev.com
spec:
  rules:
    - http:
        paths:
          - path: /*
            backend:
              serviceName:  test-web-app
              servicePort: 80

これをapplyすることで、無事、Windowsノードにウェブアプリケーションをポッドを配置できました。

AWSリソースへのアクセス権限はどうするか

ここまでの説明で大事な点を割愛しています。alb-ingress-controllerやexternal-dnsのAWSのリソースへのアクセス権限についてです。また、デプロイするウェブアプリケーションも要件によってはAWSのリソースにアクセスするでしょう。 このような場合、kube2iamkiamを使うことでポッド単位でAWSリソースに対するアクセス権限の制御が行えます。 実際、一休では、すでにサービスインしているクラスタではkube2iamを使っています。 しかし、kube2iamは、Deamonsetであり、Windowsノードでは動作しません。したがって、別の方法で、同じことを実現する必要があります。

kube2iamなしでIAM ロールを Kubernetes サービスアカウントに関連付ける

すでにいくつかの記事で紹介されていますが、今年の9月に、EKSがネイティブで IAM ロールを Kubernetes サービスアカウントに関連付ける仕組みを提供し始めました。

aws.amazon.com

dev.classmethod.jp

この仕組みを使えば、kube2iamを使わずに、podのAWSリソースに対するアクセス権限の制御ができそうです。 実際にうまくいくかどうか試してみます。ここでは、aws-alb-ingress-controllerがきちんとapplication load balancerを作成できるかどうか確認してみます。

まず、上述したクラスタ定義を使って、eksctlでクラスタを作ります。すると、AWSコンソールのIAMの画面のIDプロバイダーに、下記のように、OpenID Connectのプロバイダが作成されます。 withOIDC: true を設定したのはこのためです。

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

また、AWSコンソールで新しく作成されたEKSクラスタの設定を見ると、上述のOpenID ConnectプロバイダのURLが、表示されます。

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

次に、alb-ingress-controller用ロールに信頼関係を設定を設定します。

公式ブログのチュートリアル では、eksctl create iamserviceaccount コマンドを使って、新規にKubernetesサービスアカウントと対応するIAMロールを作成しています。このコマンドを実行するだけで、以下の3つの必要な設定が、一発で完了します。 - IAMロールの新規作成 - Kubernetesサービスアカウントの作成 - KubernetesサービスアカウントがIAMロールを引き受けるようにする信頼ポリシーの設定

一方、わたしたちのケースでは、新規にIAMロールを作るらずに、既存のEKSクラスタで使っているaws-alb-ingress-controller用のロール alb-ingress-controller-role を使いまわしたいです。 このため、eksctl create iamserviceaccount コマンドは使わずに、手動で設定してみます。 といっても設定は簡単です。↓の通り、対象のロールの信頼関係編集ボタンクリックします。

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

そして、次のようなポリシーを設定します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::xxxxxxxxxxxx:oidc-provider/oidc.eks.ap-northeast-1.amazonaws.com/id/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"  
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.ap-northeast-1.amazonaws.com/id/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:sub": "system:serviceaccount:kube-system:aws-alb-ingress-controller",
          "oidc.eks.ap-northeast-1.amazonaws.com/id/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:aud": "sts.amazonaws.com"
        }
      }
    }
  ]
}

xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx には、先ほど作成された OpenID Connectプロバイダのarnの識別子が入ります。

そして、Kubernetes側にaws-alb-ingress-controller用のサービスアカウントを作成します。↑のポリシーに書いた通り、サービスアカウント名は、 aws-alb-ingress-controller で作成します。 yamlは以下の通り。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: aws-alb-ingress-controller
  namespace: kube-system 
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::xxxxxxxxxxxx:role/alb-ingress-controller-role

# ロールやロールのバインディングも必要ですが、ここでは割愛します。

annotationsに、 eks.amazonaws.com/role-arn というキーで、ロールのarnを指定します。 あと、はこのサービスアカウントをapplyします。

AWSコンソールのロードバランサの画面で、クラスタ名のタグで検索してみると、↓の通り、albが作成されています。

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

これで、windowsノードにデプロイしたPodが外部からのリクエストを受けれるようになりました。

WIndowsノードの課題

これで、Windowsのウェブアプリケーションも、プロダクション環境で、EKSで動かせる、と思いきや、ひとつ大きなハードルがありました。 Datadogが、Windowsノードが含まれたKubernetesクラスタの監視をサポートしていないのです。 Linuxノードだけであれば、Datadogのhelmチャートを入れるだけで、必要なメトリクスをほぼすべて収集できます。 一休は監視/ログ管理/APMをすべてDatadogに集約しているので、WindowsノードだけDatadog以外の方法を採用する、というのは合理的ではありません。 こちらはDatadog側にリクエストを出している状況です。

まとめ

EKSでWIndowsワーカーノードを扱う方法を書きました。クラスタの準備、とアプリケーションの動作確認自体は比較的簡単に行うことができました。 今後は、DatadogのWindows対応を待ちつつ、EKSへの移行を引き金にして、なかなか着手しにくいWindowsウェブアプリケーションのコンテナ化を推進し、必要なリファクタリングを行ったり、細かな技術検証をするフェーズになりそうです。


この記事の筆者について

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

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ドキュメントに困ったときなどに気軽に導入を検討してみてください。