GraphQL開発のベストプラクティスをまとめた"Principled GraphQL"を読んだ

"Principled GraphQL"はApollo社が公開しているGraphQL開発のベストプラクティス集です。

principledgraphql.com

背景として、近年のアプリケーション開発では「データグラフ」が重要になってきているとしています。GraphQLを通じて、ある企業のすべてのアプリのデータとサービスとを統合してデータグラフとして提供することで、クライアントとしてのアプリケーションから効率よく簡単にサービスを使うことができるようになります。もはやGraphQLはクエリ言語というより、クライアントとサービス間の接続を担う包括的なソリューションなので、いろいろと考えるべきことも多く、GraphQL開発の統合環境を提供していて経験豊富なApolloがベストプラクティスをまとめた、というところのようです。

実際のところ最近GraphQLを触っていないのですが、ざっくり読んでみました。


構成

ドキュメントのフォーマットは"the Twelve-Factor App"に影響を受けていて、各項目では守るべき原則を表す簡潔なタイトルとリード文に続いて、具体的な説明が載る構成。ただし"Principled GraphQL"は10箇条であり、次の3件にカテゴリ分けされている。

  • Integrity Principles
  • Agility Principles
  • Operations Principles

Integrity Principles

One Graph

  • 各チームでバラバラにグラフを作るのではなく、会社で一つの統合されたグラフを作る
  • 利用者にとっては無駄や重複のないグラフとなって使い勝手が向上し、開発者にとってはスキルの共有や開発コストの削減になる

例えば、ある会社がジャンルの違う複数サービスを持っている場合は、それらの既存のREST APIなどをApolloプロダクトでデータグラフとして統合すべき、という意図と読める。

Federated Implementation

  • グラフの各部分の実装は複数チームでやる
  • チームごとに各々のリリースサイクルで開発できる

Apollo Federationでバックエンドの違いは吸収できる。"One Graph"と表裏一体の関係にあるような原則。

Track the Schema in a Registry

  • schema registryで変更を追跡する
    • schema registryはソースコードにおけるバージョン管理システムにたとえられている
  • VS Codeでの補完やスキーマバリデーションに利用できる
  • single source of truthとして扱う

schema registryはApolloの場合The schema registryのこと。知らなかったが便利そう。

Agility Principles

Abstract, Demand-Oriented Schema

  • スキーマとサービス/クライアントを疎結合にして、実装を隠して柔軟に利用できる抽象層として扱う
  • demand-orientedで開発する
    • 既存のグラフで機能開発しているアプリ開発者の開発者体験(DX)を高められるようなスキーマとする

インタフェースを設けることでバックエンドのアーキテクチャ変更も理論的には可能になるというのはソフトウェアエンジニアリングで頻出なので、もはや一般的な原則という感じもする。

Use an Agile Approach to Schema Development

  • 実際の要求に基づいてインクリメンタルにスキーマは開発していく
  • 定期的なバージョニングより必要なときにリリースする
    • スキーマにバージョンをつけて半年ごとに公開するよりは、必要なときに頻繁にリリース
    • いつでも新規フィールドを追加する。フィールドの削除時は非推奨化→削除の順で進める
  • schema registryを使って変更の影響をつねに確認できるようにしておくのも効果的

Apolloだと@deprecatedディレクティブがある。

Iteratively Improve Performance

  • グラフ利用者がどのように使おうとしているかをAPI開発者は知るべき
  • 実際に送られる形のクエリのパフォーマンス改善をすべき
  • 本番ではパフォーマンスを継続的に監視する

GraphQLはエンドポイントが一つで従来のAPMが使いづらいので、パフォーマンス監視のために後述するtracerがあったほうがよさそう。

Use Graph Metadata to Empower Developers

  • 開発のプロセス全体を通してグラフを使うことを意識する
  • ドキュメンテーション、補完、クエリのコスト計算、CIで変更の影響確認…
  • アプリで使うクエリを運用チームにも共有して、性能的に問題ないか見てもらう
  • 静的型付言語で型チェックに利用

型のついたスキーマを使うと補完が捗りそうというのは容易に想像がつく。GraphQLはクエリのコストを気にする必要があるので、そこへの対策もここで入ってくる。

Operations Principles

Access and Demand Control

  • クライアントごとにグラフへのアクセス権限を管理する
    • access: どのオブジェクトやフィールドにアクセスできるか
    • demand: どのようにどの程度リソースにアクセスできるか
      • クエリのコストを測定して制御する必要がある
  • demand controlのベストプラクティス
    • 認証済みの開発者が事前に登録したクエリだけを実行できるようにする
      • 内部アプリは緩和もOK
    • クエリのレビュー/承認フローを開発サイクルに組み込む
    • クエリコストの試算とユーザーごとアプリごとの利用可能コスト上限を決めておくことで、クエリの事前登録ができないときに対応する
    • もしものときのためにアプリ開発者は特定クエリの送信をとめられるようにしておく

GraphQL APIを公開するときはもちろん、内部的なAPIとして使うときも意図せずクエリのコストが高くなることはあるだろうと考えられるので、とにかく実行されるクエリの管理やコストの事前計測が大事そう。

Structured Logging

  • グラフに対する操作の構造化ログを取得し、グラフ利用状況を理解するためのツールとして扱う
    • 活用しやすいようにmachine readableな形式で取得する
  • グラフ操作の記録をトレースという
    • Analyzing performance - Graph Manager - Apollo GraphQL Docsで紹介されているようなアナリシスの機能
    • ビジネス情報(データがどのように使われているか)と技術的情報(どのバックエンドを使ったか、レイテンシ、キャッシュ利用の状況など)
  • フィールド利用状況の調査、監視、監査データ収集、BI、API利用料計算などに使える
  • traceは一か所に集めて、加工して監視システムに流すか、データウェアハウスにためておくとよい

Separate the GraphQL Layer from the Service Layer

  • GraphQLの層はサービスの層と分けるべき
  • 各サービスにはデータグラフをシステムとして提供するためのロードバランスやAPIキー管理の機能を入れず、クライアントのリクエストに対応することに専念させる
  • クエリの一部はエッジのキャッシュで返し、別の部分は他のサービスに問い合わせつつ、すべての操作をtraceで記録するということが可能になる
    • バックエンドのサービスはREST, gRPCなどさまざまな方式で存在しうる

REST APIにおけるAPI Gatewayのような役割。Apollo FederationにおけるGatewayがここで述べられている層の役割の一部を担っている。


感想

スキーマの活用が便利というのはもちろんのこと、クエリのコストについてさまざまな方法で十分な注意を払うべきという主張がなされているのは興味深かったです。原則を示しつつ、Apolloがそれに対応するツールを用意しているところが強いなという印象でした。

興味があるかたは読んでみてください。

値の妥当性をチェックする日時表現パーサを作る

Haskell入門』に日時表現("YYYY/MM/DD hh:mm:ss")のパーサをAttoparsecで作る節*1があり、

このままでは9999/99/99 99:99:99のような入力ができてしまいますが、月の入力範囲を1~12に制限するといった制約をかけることも、これまで説明した範囲で簡単に実現できます

という記述があったので、値の入力範囲に制限があるバージョンを書いてみた。具体的にはguardを使う*2

{-# LANGUAGE OverloadedStrings #-}

import qualified Data.Text as T
import Control.Monad
import Data.Attoparsec.Text hiding (take)

data YMD = YMD Int Int Int deriving Show
data HMS = HMS Int Int Int deriving Show

countRead :: Read a => Int -> Parser Char -> Parser a
countRead i = fmap read . count i

year :: Parser Int
year = countRead 4 digit

month :: Parser Int
month = do
  month <- countRead 2 digit
  guard $ 1 <= month && month <= 12
  return month

day :: Parser Int
day = do
  day <- countRead 2 digit
  guard $ 1 <= day && day <= 31
  return day

hour :: Parser Int
hour = do
  hour <- countRead 2 digit
  guard $ 0 <= hour && hour <= 23
  return hour

minute :: Parser Int
minute = do
  minute <- countRead 2 digit
  guard $ 0 <= minute && minute <= 59
  return minute

second :: Parser Int
second = do
  second <- countRead 2 digit
  guard $ 0 <= second && second <= 59
  return second

ymdParser :: Parser YMD
ymdParser = YMD <$> year <* char '/' <*> month <* char '/' <*> day

hmsParser :: Parser HMS
hmsParser = HMS <$> hour <* char ':' <*> minute <* char ':' <*> second

dateTimeParser :: Parser (YMD, HMS)
dateTimeParser = (,) <$> ymdParser <* char ' ' <*> hmsParser

各パーサ関数でControl.Monadguardに年、月、日、時、分、秒それぞれが満たすべき条件式を渡す。パースした結果得られた整数がguardに渡した条件を満たさないとき、結果がemptyになる。

ParserAlternativeのインスタンスであり、emptyを返した時点でfail "empty"が実行される。この時点でパースが中止される。

それぞれの関数を組み合わせることでApplicativeスタイルでパーサを表現できている。

上のコードを読み込んだ状態で次のコードを実行する:

main :: IO ()
main = do
  print $ parse dateTimeParser "2020/03/01 12:34:56" `feed` ""
  print $ parse dateTimeParser "2020/03/32 12:34:56" `feed` ""

このとき結果は次のとおりで、invalidな値を渡すとパースに失敗する:

Done "" (YMD 2020 3 1,HMS 12 34 56)
Fail " 12:34:56" [] "Failed reading: empty"

*1:7.5「高速なパーサ ─ attoparsec」

*2:5.3.3「Alternative型クラスとしてのMaybe」で説明されているので一応「これまで説明した範囲」ではある

リソースとActive Recordのモデルのあいだの差異を吸収するクラスを作る

Web APIのリソースとバックエンドで扱うモデル(特にActive Recordのモデル)に歴史的な事情で差異があり、単純にモデルからリソースへと変換できないとき、それらの差異を吸収するクラスを作って対応することがあったのでメモを残しておきます。


問題

あるWeb APIに、親リソース*1authorが1:Nで持つ子リソースbookの一覧だけを返すエンドポイントGET /booksを追加しようとしていた*2。追加するエンドポイントが返すリソースの例を示す:

{
  "books": [
    {
      "id": 1
      "author_id": 1,
      "title": "Bar",
      "publisher": "Baz, Inc.",
      "released_at": "2020-01-01"
    },
    // ...
  ]
}

なお、従来から、親リソースGET /authorsを取得するときにはbooksも取得はできていたとする。また、既存のAPIリソースはActive Model Serializer (AMS)で生成しているとする。

このエンドポイントで返すリソースが持つフィールド、リソースに対応するActive Record (AR)のモデルの属性、データベース上の対応するテーブルのカラムには、それぞれ微妙にズレが見られた。具体的には次のような問題があった:

  1. ARのモデルが持っている属性の一部だけがリソースの属性となる
  2. テーブルのカラム名とリソースのフィールド名/モデルの属性名が異なる
  3. APIリソースのフィールド名とARモデルのリレーション名が被っている

まず、ARのモデルが持っている属性、すなわち対応するテーブルが持っているすべてのカラムのうち一部だけがリソースの属性となっていた。この場合、リソースを表すJSONに変換するときにフィルタする必要がある。

create_table :books do |t|
  t.column :title, :string
  t.column :release_date, :date
  t.column :memo, :string # リソースには含めない
  # ...
end

また、テーブルのカラム名をよりわかりやすくするために、その後に作られたモデルやAMSのシリアライザでは改名された属性名を使っているケースがあった。

class Book < ApplicationRecord
  alias_attribute :title, :name
end

class BookSerializer < ActiveModel::Serializer
  def released_at
    object.release_date
  end
end

さらに、特殊な事例だが、ARのモデルではリレーションとして定義されている名前が、AMSで生成されるリソースではスカラーな値として提供されているケースがあった。

class Book < ApplicationRecord
  belongs_to :publisher
end

class Publisher < ApplicationRecord
  has_many :books
end

class BookSerializer < ActiveModel::Serializer
  attributes :publisher

  def publisher
    object.publisher.name
  end
end

後述するが、このケースでAMSを使わないようにする場合、リソース用に加工したフィールドの値を作る必要がある。しかし、今回はリレーションとしてそのフィールドと同名のメソッドが存在するので、メソッド名に気をつけないと、そのリレーションを使っている他のコードが壊れてしまってうまくいかない。

今回は、既存のコードをできるだけ変えずにこれらの問題に対処しながら、エンドポイントGET /booksを追加したいとする。

リソースとモデルの間の差異を吸収する層を導入する

問題を解決するために、リソースとモデルの間の差異を吸収する層を導入する。今回はAMSを使わず実現した*3。POROにActive Modelを組み合わせて使う。

次のようにリソースのフィールドとモデルの属性のマッピングを持たせたクラスを書く。リソースだけで使うメソッドはモデルに書く。

class Book < ApplicationRecord
  # ...

  concerning :Api do
    def publisher_name
      publisher.name
    end
  end
end

class Book::ListResource
  include ActiveModel::Model

  attr_accesor :books

  ATTRS_AND_METHODS = {
    id: :id,
    author_id: :author_id,
    title: :title,
    publisher: :publisher_name,
    released_at: :release_date
  }

  def build
    books.map do |book|
      ATTRS_AND_METHODS.map { |attr, method|
        [attr, book.send(method)]
      }.to_h
    end
  end
end

次のように使う。

# コントローラ内での利用例
@books = Book::ListResource.new(
  books: Book.order(:id).limit(20)
)

# JBuiderのテンプレート
json.books @books

感想

  • pros
    • 既存の実装に影響を与えず、コントローラからもシンプルにリソースを生成できた
  • cons
    • ATTRS_AND_METHODSのようなマッピングを書かないといけない
    • 新しい層を導入するので設計意図をなんらかの手段で伝える必要がある

*1:リソースは「APIが返すデータの構造」ぐらいの意味合いとする

*2:これはあくまでも例

*3:内部事情

関数をFunctor/Applicative/Monadにする

『プログラミングHaskell 第2版』の12章「モナドなど」の演習*1で型 (a ->) をFunctor(関手)、Applicative、Monadにするという問題があり、少しわかりにくかったので、いまの自分自身の理解をまとめました。

型(a ->)とは

部分適用された関数の型を(a ->)(もしくは->を2引数関数とみて((->) a))と表現する*2。この型の意味は「aを部分適用済みの関数」である。部分適用するのは、ある型をFunctorにするには型コンストラクタが持つ型変数が1つでなければならないことによる。

実際のインスタンス宣言はControl.Monadに存在する。

Functorにする

「型変数を持つならモナドになるか試してみるのが自然な流れ」と書いてあるので、(a ->)もMonadにしていく。

まずFunctorにする。Functorにするにはfmapを定義する必要がある。演習問題のヒントに書いてあるとおり、(a ->)に対するfmapの型がどうなるかを考える。Functor型クラスにおけるfmapの型は次のとおり。なお、aという変数は(a ->)で使われているので、これ以降新たに現れる型変数はbから始める。

fmap :: (b -> c) -> f b -> f c

まず第2引数f bから考える。今回(a ->)をFunctorにするので、ある構造を表すf(a ->)に該当する。つまり、fは「引数として取る型の値を返す部分適用済みの関数であること」を表す。つまりf bと書くとa -> bとなる。ここから(a ->)に対してfmapの型を次のように表現できる:

fmap :: (b -> c) -> (a -> b) -> (a -> c)

このfmapの型をよく見ると、関数合成そのものになっている。よって、(a ->)は次のようにFunctorとできる。

instance Functor ((->) a) where
  fmap = (.)

Applicativeにする

続いて、(a ->)をApplicativeにするにはpure<*>を定義する。Applicative型クラスにおけるpure<*>の型は次のとおり。

pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b

まずpureから考える。型bを持つある値を(a ->)の文脈に持ち込むと考えると、型bの値を受け取り、つねにその値を返す関数(a -> b)を返せばよい。

pure :: b -> (a -> b)

これはb -> a -> bと見なせるのでconstと同じである。

次に<*>も型から考える。これまでどおりfを部分適用済みの関数(a ->)と見なすと、

  • f ba -> b
  • f ca -> c
  • f (b -> c)a -> b -> c

となる。よって

(<*>) :: (a -> b -> c) -> (a -> b) -> (a -> c)

となる。

結果の型がa -> cという関数なので、ある引数fgに関して、f <*> gは型aの1引数関数と考えればよい。また、関数a -> b -> cと関数a -> bに型aの値を適用すると、関数b -> cと型bの値になる。型cを返す関数を作る必要があるので、後者のbの値を関数b -> cに適用すればよい。これらを合わせて、次のように(a ->)をApplicativeにすることができる。

instance Applicative ((->) a) where
  pure = const
  f <*> g = \a -> f a (g a)

ここではg aが型bの値、f aが関数b -> cである。

関数がApplicativeだと、二つの関数をそれぞれ適用して結果を足すコードを逐次的に書けたりする。

f = fmap (+) (+ 5) <*> (* 100) -- 5足した値と100かけた値の合計
f 30 -- 3035

また、pure<*>はそれぞれSKIコンビネータにおけるKとSにあたる*3

Monadにする

最後に、(a ->)をMonadにする。Monadにするにはreturn>>=を定義する必要があるが、returnpureと同じなので>>=だけ考える。

型から考えると、Monad型クラスで>>=の型は次のように定義されている。

(>>=) :: m b -> (b -> m c) -> m c

mを部分適用済みの関数(a ->)と見なすと、

  • m ba -> b
  • b -> m cb -> a -> c
  • m ca -> c

となる。よって

(>>=) :: (a -> b) -> (b -> a -> c) -> (a -> c)

となる。

Applicativeと同様に結果の型がa -> cという関数なので、(a -> b) >>= (b -> a -> c)は型aの1引数関数と考えればよい。Applicativeのときと同じように考えると、次のように(a ->)をMonadにすることができる。

instance Monad ((->) a) where
  return = pure
  f >>= g = \a -> g (f a) a

ここではf aが型bの値、gが関数b -> a -> cである。

関数がMonadだと、Applicativeで実現した「二つの関数をそれぞれ適用して結果を足すコード」を次のようにdo記法で書ける。入力した数値(この例だと30)を環境として持ちながら計算していると見なせる*4

f = do
  g <- (+ 5)
  h <- (* 100)
  return $ g + h

f 30 -- 3035

*1:12.5「練習問題」の2, 3, 6

*2:一般的にはrを使って(r ->)と書くが、ここでは本の記法にしたがって(a ->)とする。参考:"Functors, Applicative Functors and Monoids"

*3:12.5「練習問題」の3より

*4:Haskell 状態系モナド 超入門 - Qiita

WebAuthnによる認証機能を作りながら理解を深める

何をやったか

最近の仕事柄興味があったのと、WEB+DB PRESS Vol.114の特集2を読んだこともあって、理解を深めるためにWebAuthnでの公開鍵登録(今回はサインアップを兼ねる)、認証だけできる簡単なWebアプリを作りました。リポジトリのREADMEに様子のGIFアニメを貼っています*1。今回はChrome 80とTouch IDで試しています:

GitHub - kymmt90/webauthn_app

このボタンをクリックするとHerokuにデプロイできます。リポジトリのREADMEにも同じボタンを置いています:

Deploy

デプロイ後に環境変数 WEBAUTHN_ORIGINhttps://<Herokuアプリ名>.herokuapp.com を追加してください。

理解できた点

WebAuthn自体の詳しい説明は、Web上のリソースを参照したほうがよいので、今回はとくに書いていません。

登場する各ロールの役割

WebAuthの認証フローを見ると用語が多いですが、概ね次のように理解しました。

  • Authenticator
    • ユーザーエージェントからアクセスされる認証器
    • ハードウェアトークン(Yubikey、Titanなど)やデバイス埋め込みの認証機構(Touch IDなど)
  • Relying Party (RP)
    • Authenticatorからもらった情報をもとに認証するサーバ
    • ブラウザから公開鍵とattestation(認証器自体の正当性証明)を受け取り、公開鍵をアカウントと紐付けて保存する
    • ブラウザからassertion(認証時の署名など)を受け取り、認証する

実装の感触

個人的にRailsだとさっと実装できるので、今回はRailsでやりました。webauthn-rubywebauthn-jsonの組み合わせで問題なく実装できます*2。実装項目は次のような感じです。

サーバ

  • クライアントがWebAuthn APIへ渡すためのオプションやチャレンジ値を返すAPIを追加する
    • 公開鍵登録時は GET /webauthn/credential_creation_options
      • WebAuthn::Credential.options_for_create を使う
    • 認証時は GET /webauthn/credential_request_options
      • WebAuthn::Credential.options_for_get を使う
  • 送られてきたアカウント名、公開鍵、attestationから、アカウントを作成して公開鍵をそのアカウントに紐付けて保存するAPIを追加する
    • POST /users
      • WebAuthn::Credential.from_create / WebAuthn::PublicKeyCredentialWithAttestation#verify を使う
  • 送られてきたアカウント名とassertionから、パスワードレス認証するAPIを追加する
    • POST /session
      • WebAuthn::Credential.from_get / WebAuthn::PublicKeyCredentialWithAssertion#verify を使う

クライアント

  • サインアップ用フォーム
    • サーバからもらったオプションを navigator.credentials.create() に渡して認証器からattestationをもらいサーバへ送る
  • ログイン用フォーム
    • サーバからもらったオプションを navigator.credentials.get() に渡して認証器からassertionをもらいサーバへ送る

RPサーバは署名、オリジン、チャレンジ値、認証回数の検証などプロトコルに規定されている検証を実行する必要がありますが、ライブラリである程度カバーできると思います。

認証体験

ふだんパスワードマネージャを使ってはいますが、やはりパスワードレスだと認証の体験がかなり簡単に感じました。これで多要素も満たせる(所持、生体)のも便利ですね。

*1:なぜかはてなブログにGIFを貼ろうとするとエラーになった

*2:webauthn-rubyはデモアプリも公開していて、今回はそれを模倣している