GraphQL::Batchのサンプルコードを読む & 使ってみる

単純にGraphQLクエリを投げると、サーバサイドでの関連を含むレコード取得時にN+1問題が発生することがあります。こういうケースでは、複数のデータ取得リクエストをひとまとめにして、単一のリクエストとしてDBからデータを取得するbatchingが推奨されています*1

GraphQLにおけるbatchingをgraphql gemを使ってやるためのGraphQL::Batchというgemがあります。先日リポジトリにサンプルコードが追加されて使いかたを把握しやすくなったので、サンプルコードを読みながら使ってみます。

github.com

GraphQL::Batchの概要

GraphQL::Batchでは、Loaderというデータを取得するためのクラスを作って使うことが想定されています。これは、Facebookが開発しているDataLoaderでの考えかたが元となっています。Loaderがbatchingでデータを取得するときは、load 関数でレコードのキーを複数受け取り、複数レコードを解決するpromiseを返します*2

利用例

コードを読む前にGraphQL::Batchの利用例を示します。

前提

本エントリではRailsでGraphQL::Batchを使うこととします*3

次のようなモデルが存在するRailsアプリケーションを考えます。DBテーブルもこれにしたがった構成であるとします。

class User < ApplicationRecord
  has_many :customers
end

class Customer < ApplicationRecord
  belongs_to :user
  has_many :orders
  has_many :deliverers, through: :orders
end

class Deliverer < ApplicationRecord
  has_many :orders
  has_many :customers, through: :orders
end

class Order < ApplicationRecord
  belongs_to :customer
  belongs_to :deliverer
end

このとき、次のようなGraphQLのスキーマを app/graphql/types 配下などに定義するとします。

Types::QueryType = GraphQL::ObjectType.define do
  name 'Query'

  field :user do
    type Types::UserType
    argument :email, !types.String
    resolve ->(obj, args, ctx) {
      User.find_by!(email: args['email'])
    }
  end
end

Types::UserType = GraphQL::ObjectType.define do
  name 'User'

  field :email, !types.String
  connection :customers, Types::CustomerType.connection_type
end

Types::CustomerType = GraphQL::ObjectType.define do
  name 'Customer'

  field :name, !types.String
  connection :orders, Types::OrderType.connection_type
  connection :deliverers, Types::DelivererType.connection_type
end

Types::DelivererType = GraphQL::ObjectType.define do
  name 'Deliverer'

  field :name, !types.String
  connection :orders, Types::OrderType.connection_type
  connection :customers, Types::CustomerType.connection_type
end

Types::OrderType = GraphQL::ObjectType.define do
  name 'Order'

  field :price, !types.Int
  field :customer, !Types::CustomerType
  field :deliverer, !Types::DelivererType
end

ここで、次のようなクエリをサーバへ投げてみます(DBにはいい感じにデータが保存されているとします)。

{
  user(email: "foo@example.com") {
    customers(first: 2) {
      edges {
        node {
          orders {
            edges {
              node {
                deliverer {
                  name
                }
              }
            }
          }
        }
      }
    }
  }
}

すると、次のように Customer のレコードごとに Order を、Order のレコードごとに Deliverer を取得するSQLを発行してしまうN+1問題が発生します。

User Load (1.6ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "foo@example.com"], ["LIMIT", 1]]
Customer Load (1.7ms)  SELECT  "customers".* FROM "customers" WHERE "customers"."user_id" = ? LIMIT ?  [["user_id", 1], ["LIMIT", 2]]
Order Load (3.3ms)  SELECT "orders".* FROM "orders" WHERE "orders"."customer_id" = ?  [["customer_id", 1]]
Deliverer Load (1.8ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.5ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.4ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (2.1ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (2.6ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (2.9ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.8ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.9ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.6ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.8ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Order Load (2.1ms)  SELECT "orders".* FROM "orders" WHERE "orders"."customer_id" = ?  [["customer_id", 2]]
Deliverer Load (1.5ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.4ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.5ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.6ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (2.2ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.7ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.6ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.4ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.5ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (2.6ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]

GraphQL::Batchの利用

上述したN+1問題を解消するためにGraphQL::Batchを使います。具体的には次のことをやります。

  • サンプルを参考にLoaderを定義する
  • スキーマ定義の resolve 内で使う

まず、サンプルを参考にして、Loaderを app/graphql/loaders 配下などに定義しておきます。そして、定義したLoaderを使って次のようにスキーマを定義し直します。変更部分だけ抜粋します。

Types::UserType = GraphQL::ObjectType.define do
  # ...
  connection :customers, Types::CustomerType.connection_type do
    resolve ->(user, args, ctx) {
      Loaders::AssociationLoader.for(User, :customers).load(user)
    }
  end
end

Types::CustomerType = GraphQL::ObjectType.define do
  # ...
  connection :orders, Types::OrderType.connection_type do
    resolve ->(customer, args, ctx) {
      Loaders::AssociationLoader.for(Customer, :orders).load(customer)
    }
  end
  connection :deliverers, Types::DelivererType.connection_type do
    resolve ->(customer, args, ctx) {
      Loaders::AssociationLoader.for(Customer, :deliverers).load(customer)
    }
  end
end

Types::DelivererType = GraphQL::ObjectType.define do
  # ...
  connection :orders, Types::OrderType.connection_type do
    resolve ->(deliverer, args, ctx) {
      Loaders::AssociationLoader.for(Deliverer, :orders).load(deliverer)
    }
  end
  connection :customers, Types::CustomerType.connection_type do
    resolve ->(deliverer, args, ctx) {
      Loaders::AssociationLoader.for(Deliverer, :customers).load(deliverer)
    }
  end
end

Types::OrderType = GraphQL::ObjectType.define do
  # ...
  field :customer, !Types::CustomerType do
    resolve ->(order, args, ctx) {
      Loaders::RecordLoader.for(Customer).load(order.customer_id)
    }
  end
  field :deliverer, !Types::DelivererType  do
    resolve ->(order, args, ctx) {
      Loaders::RecordLoader.for(Deliverer).load(order.deliverer_id)
    }
  end
end

この状態で先ほどと同じクエリを送信すると、Order, Deliverer に対するSQLがまとめて発行されるようになり、N+1問題を防いでいることがログを見るとわかります。

User Load (1.6ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "foo@example.com"], ["LIMIT", 1]]
Customer Load (1.5ms)  SELECT "customers".* FROM "customers" WHERE "customers"."user_id" = 1
Order Load (2.2ms)  SELECT "orders".* FROM "orders" WHERE "orders"."customer_id" IN (1, 2, 3, 4)
Deliverer Load (1.5ms)  SELECT "deliverers".* FROM "deliverers" WHERE "deliverers"."id" IN (1, 2)

それでは、このLoaderが何をやっているかを見ていきます。

Loaderサンプルコードリーディング

次の場所にある RecordLoaderAssociationLoader のコードを読んで何をやっているか見ていきます。

なお、Loaderを作るには GraphQL::Batch::Loader を継承する必要があります。

RecordLoader

コードはこちら。

belongs_to のように関連先が1件のときに使うLoaderです。上述した例では Order で利用しています。

Graphql::Batch::Loader はファクトリメソッド for から initialize を使っており、この initialize を必要に応じてオーバーライドしていきます。ここでは model で関連先モデルのクラス名を渡せるようになっています。column はデフォルトでは主キー(Active Recordのデフォルトではサロゲートキー id)ですが、オプションで別のキーも渡せるようになっています。また、特定レコードだけ絞り込むために where を渡せるようになっています。

def initialize(model, column: model.primary_key, where: nil)
  @model = model
  @column = column.to_s
  @column_type = model.type_for_attribute(@column)
  @where = where
end

load へはバッチで取得してほしいレコードのキーを渡します。ここでは @column_type の表す型にキャストしてから親クラス GraphQL::Batch::Loaderload に引数を渡しています。これは、任意の主キーを適切な型に変換する処理と思われます。

def load(key)
  super(@column_type.cast(key))
end

perform へは、バッチで渡ってくる keys をもとに、一気にレコードをロード、つまりbatchingする処理を書きます。このときに、プライベートメソッド query で、initialize で渡された絞り込み条件と perform の引数 keys をもとに、必要な関連先レコードだけロードしています。その後、fulfill することで、promiseを解決状態に遷移させつつ、ロードしたレコードを渡しています。GraphQL::Batch::Loader#fulfill の定義は次を参照してください。

また、対応するレコードが存在しない keys 中のキーのpromiseも解決状態とするために、最後の行で fulfill できていない keys の要素に対して、レコードが存在しなかったという意味合いで nil を渡して fulfill しています。

def perform(keys)
  query(keys).each do |record|
    value = @column_type.cast(record.public_send(@column))
    fulfill(value, record)
  end
  keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }
end

private

def query(keys)
  scope = @model
  scope = scope.where(@where) if @where
  scope.where(@column => keys)
end

AssociationLoader

コードはこちら。

has_many のように関連先が複数件あるときに使うLoaderです。上述した例では User, Customer, Deliverer で利用しています。

initialize では関連元モデルのクラス名 model と、モデル内で has_many に指定する関連先名 association_name を渡しています。最後の行の validate では、model が本当に association_name で指定される関連を持っているのかをチェックしています。

def initialize(model, association_name)
  @model = model
  @association_name = association_name
  validate
end

private

def validate
  unless @model.reflect_on_association(@association_name)
    raise ArgumentError, "No association #{@association_name} on #{@model}"
  end
end

perform では、バッチで渡ってくる関連元レコード群に対する関連先を preload_association 内の ActiveRecord::Associations::Preloader#preload で一気にpreloadしています。このメソッドは :nodoc: なRailsの内部APIですが、ここでは利便性をとって使われているようです。その後、RecordLoader と同じように、ロードした各レコードに対して fulfill することで、promiseを解決状態にしつつ、eager loadしたレコードを渡しています。

def perform(records)
  preload_association(records)
  records.each { |record| fulfill(record, read_association(record)) }
end

private

def preload_association(records)
  ::ActiveRecord::Associations::Preloader.new.preload(records, @association_name)
end

def read_association(record)
  record.public_send(@association_name)
end

順番が前後しますが、load へはバッチで関連先を取得してほしい関連元レコードを渡します。ここで、すでに関連先レコードを perform でロード済みであれば、batchingの対象とすることなく、そのレコードを持つ解決済みpromiseをすぐに返しています。

def load(record)
  raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
  return Promise.resolve(read_association(record)) if association_loaded?(record)
  super
end

private

def association_loaded?(record)
  record.association(@association_name).loaded?
end

おわりに

サンプルコードを試しつつ読みながらGraphQL::Batchをどう使うか調べました。具体的には次のようにすればひとまず使えそうです。

  • サンプルを参考にLoaderを定義する
  • スキーマ定義の resolve 内で使う

私が試しに書き散らしたコードは次の場所に置いています。

github.com

*1:GraphQL Best Practices | GraphQL

*2:https://github.com/facebook/dataloader#batching

*3:GraphQL::BatchはActive Recordに依存しているわけではありません

graphql-ruby + Railsで簡易なクエリとミューテーションが使えるGraphQL APIを実装する

会社でGraphQLのハンズオンがあったのをきっかけに、最近はGraphQLのサーバ側実装をちょっと触っています。

graphql-rubyを使うと、RubyでGraphQL APIを実装することができます。今回はRailsでGraphQLのクエリミューテーションを実装してみました。

graphql-ruby使用時のRailsプロジェクトにおけるファイル/ディレクトリ構成

rails generate graphql:install すると、ジェネレータが app 配下に次のようなディレクトリ構成を作ります。

app/controllers
└── graphql_controller.rb
app/graphql
├── app_schema.rb
├── mutations
└── types
    ├── mutation_type.rb
    └── query_type.rb

また、ジェネレータは config/routes に次のルーティングを追加します。GraphQL APIへのあらゆるHTTPリクエストは次のエンドポイントへPOSTメソッドで送信することとなります。

post "/graphql", to: "graphql#execute"

今回の前提条件

このエントリでは、RailsはAPIモードを使います。また、今回は次のようなデータ構成になっていることとします。

f:id:kymmt90:20170726233709p:plain

User の認証機構は作りません。実際はなんらかの認証があって、クエリ実行時に context というオブジェクトを通じて current_user を扱うことになります。

今回のゴール

今回は、次のようなクエリとミューテーションをAPIへリクエストできるようにします。

まず、クエリです。これはサーバ側のデータを取得するタイプのリクエストで、いまは引数 email で指定した user に関するデータを取得しようとしています。

{
  user(email: "foo@example.com") {
    email
    article {
      edges {
        node {
          title
          body
        }
      }
    }
  }
}

この場合、次のようなレスポンスが返ります。

{
  "data": {
    "user": {
      "email": "foo@example.com",
      "article": {
        "edges": [
          {
            "node": {
              "title": "Title 1",
              "body": "this is the body"
            }
          },
          {
            "node": {
              "title": "Title 2",
              "body": "this is the body 2"
            }
          }
        ]
      }
    }
  }
}

次にミューテーションです。これはサーバ側のデータを変更します。

{
  createArticle(article: {user_email: "foo@example.com", title: "Another test", body: "This is another test"}) {
    article {
      title
      body
    }
  }
}

この場合、article が1件増えて、次のようなレスポンスが返ります。

{
  "data": {
    "user": {
      "email": "foo@example.com",
      "article": {
        "edges": [
          {
            "node": {
              "title": "Title 1",
              "body": "this is the body"
            }
          },
          {
            "node": {
              "title": "Title 2",
              "body": "this is the body 2"
            }
          },
          {
            "node": {
              "title": "Another test",
              "body": "This is another test"
            }
          }
        ]
      }
    }
  }
}

実装手順

利用するデータの型を書く

リクエストやレスポンスに出てくるデータの型 app/graphql/types 配下の user_type.rbarticle_type.rb にGraphQL APIで利用するデータの書きます。

# app/graphql/types/user_type.rb
Types::UserType = GraphQL::ObjectType.defind do
  name 'User'

  field :email, !types.String
  connection :articles, Types::ArticleType.connection_type
end

# app/graphql/types/article_type.rb
Types::ArticleType = GraphQL::ObjectType.define do
  name 'Article'

  field :title, !types.String
  field :body, !types.String
  field :user, Types::UserType
end

field は型が持つ属性であり、名前とスカラー型(types.String, types.Int, types.ID など)を指定します。また ! で非nullであることを指定します。

Types::UserTypearticlesconnection というヘルパを使っています。これはRelay由来のconnectionというページネーションを扱うための仕組みを使えるようにしてくれるものです。connectionによるページネーションの詳細については次のページを見てください。

今回はクエリのルート階層に書くフィールドは user になりますが、これは name 'Query' を宣言している型に書きます。

# app/graphql/types/query_type.rb
Types::QueryType = GraphQL::ObjectType.define do
  name 'Query'

  field :user do
    type Types::UserType
    argument :email, !types.String
    resolve ->(obj, args, ctx) {
      User.find_by(email: args['email'])
    }
  end
end

ここでは、argument にクエリで指定する引数を書いています。また、resolveuser クエリが投げられたときの User レコードの取得方法をラムダ式で書いています。

ここまでで、ゴールとしていたクエリをリクエストできるようになりました。

ミューテーションと入力データの型を書く

次にミューテーションを書きます。まずは name 'Mutation' を宣言している型にフィールドとしてミューテーションを書いてきます。

# app/graphql/types/mutation_type.rb
Types::MutationType = GraphQL::ObjectType.define do
  name 'Mutation'

  field :createArticle, Types::ArticleType do
    argument :article, Types::ArticleInputType

    resolve ->(o, args, c) {
      user = User.find_by!(email: args[:article][:user_email])
      user.articles.create!(title: args[:article][:title], body: args[:article][:body])
    }
  end
end

ここで2点ほどポイントがあります。

  • argument の型が Types::ArticleInputType である
  • resolve でレコードを作成している

Types::ArticleInputType は別途定義している次のような型です。

# app/grpahql/types/article_input_type.rb
Types::ArticleInputType = GraphQL::InputObjectType.define do
  name 'ArticleInputType'

  argument :user_email, !types.String do
    description 'Email address of the user'
  end

  argument :title, !types.String do
    description 'Title of the article'
  end

  argument :body, types.String do
    description 'Body of the article'
  end
end

この型を引数とすることで、次のような Types::ArticleInputType 型の引数としてミューテーションに作成したい article のデータを渡すことができます。この方法だと型の再利用性が高まります。

createArticle(article: {user_email: "foo@example.com", title: "Another test", body: "This is another test"}) {
  # ...
}

resolve では、ミューテーションの実際の処理として、対象ユーザの関連レコード article を作成しています。

これでゴールとしていたミューテーションもリクエストできるようになりました。

サンプルコード

素振り用に実装した上記コードを次の場所に置いています。

github.com

Yokohama.rb Monthly Meetup #82に参加した

2017-07-08(土)のYokohama.rb Monthly Meetup #82参加メモです。

yokohamarb.doorkeeper.jp

Rubyレシピブック

レシピ257から260まででした*1。259, 260についてメモ。

259: リモートホストが稼働していることを確認する

リモートホストが動いているかを確認するためにTCP接続を試みるメソッドについてのレシピです。

本文中では、TCPの7番ポートで受け付けるechoサービスに対して、TCPで接続を試みることでリモートホストが動いているかを確認しています。とはいえ、実際にやってみると、TCP7番を開けているサーバはほとんどなさそうでした。

コード例では timeout ライブラリによって導入される Kernel#timeout を使っています。このメソッドはRuby2.3からdeprecatedになっており、使うと次のメッセージが表示されます。実際には Timeout#timeout へのエイリアスになっているようです*2

Object#timeout is deprecated, use Timeout.timeout instead.

メッセージのとおり、現在は明示的にモジュール関数 Timeout.#timeout を使うのが望ましいでしょう。

Timeout.timeout do
  TCPSocket.open(host, service) do
    # pass
  end
end

260: 別プロセスのRubyオブジェクトにアクセスする

dRubyを使った分散オブジェクトの操作についてのレシピです。dRubyを実際に動かしてみたのは初めてだったのでおもしろかったです。次のようなことを話していました。

  • リモートのオブジェクトに対して呼び出したメソッドはサーバ側で実行される
    • クライアント/サーバ間ではMarshalを使ってやり取りしているが、ブロック内のコード(要はProc)はMarshalでシリアライズできないので、クライアント側で実行される
  • 2,3文字ぐらいの文字列を1000万要素ぐらい入れた配列をクライアントからサーバに送ろうとすると、なぜかクライアントが死ぬ…
  • dRubyを使ってカードゲームのUNOを作ると楽しそう

その他

以前書いた「DockerでRailsの開発環境を作る」ブログ記事を@miyohideさんが読んでいたということで、あらためて何をやったか紹介しました。

blog.kymmt.com

次のようなコメントをもらいました。

  • たとえばDBへの接続のためにIPアドレスは必要?
    • docker-compose.yml でつけたサービス名をそのまま使える
  • 本番環境はコンテナではない?
    • 今は開発環境だけ
      • エンジニアとデザイナの環境を統一して、うまく動かないときの原因切り分けを楽にしたかったのが最初のモチベーションのため
  • ホストからコンテナへのファイルコピーが遅くなってくるのでrsyncを使っている
    • 現状そこをネックに感じるレベルには達してなかった
  • コンテナが溜まってくるのはどうしてる?
    • コマンド実行は --rm をつけてやってもらう
    • こんな方法もあるそうです

また、@takeshyさんからDDD(ドメイン駆動設計)に基づいてRailsを使ったAPIサーバを作り直した事例について共有していただきました。

qiita.com

次のようなお話が聞けました。

  • ドメイン層は純粋なドメインロジックのコードだけになるのでわかりやすい
  • リポジトリ層はActive Recordを使わずに生SQLを書くことが多い
  • いかにimmutableな値オブジェクトに寄せるかがキモ
  • ルートエンティティ経由でドメインロジックを呼び出す
  • コントローラ層は params の処理ぐらいだけやってアプリケーション層に委譲
  • アプリケーション層はコントローラ層のアクションと一対一対応している
  • ドメインの処理を終わった時などにイベントを飛ばして、それを監視しているサブシステムが通知処理などを実行する
    • メインのドメインに通知処理などが入らない
    • ユーザ作成など、実行した処理がすべてイベントとして記録されるので、ログ活用やデバッグが楽になる

懇親会

次の結論に達した気がします。



次回はまだ未定のようですが、普段どおりであれば8月の第2土曜日に開催されると思います。

*1:258「LDAPで情報を得る」は飛ばしました

*2:ref: https://github.com/nahi/httpclient/issues/289

Yokohama.rb Monthly Meetup #81に参加した

2017-06-10(土)のYokohama.rb Monthly Meetup #81参加メモです。

yokohamarb.doorkeeper.jp

3月の#78も参加していたのにメモを書いていなかったので、久しぶり感あります。

Rubyレシピブック

レシピ254から256まで。HTTP, SMTP, FTPというプロトコル3連発でした。

254: HTTPクライアントをつくる

HTTPクライアントを一からつくるというわけではなく、open-uri, net/http, httpclientを使ってみるというレシピでした。rest-clientというクライアントライブラリがあるという話も聞けました。

github.com

あとはlibcurlラッパのtyphoeusとかがありますね。

github.com

255: メールを送信する

net/stmpを使って、素のRubyスクリプトでメールを送るというレシピでした。ふだんはAction Mailerばかりなので新鮮。

今回はメールの受信テストにMailCatcherを使っていましたが、Railsのときはletter_openerを使ったり、Action Mailerにもプレビューがあるという話をしていました。

256: FTPでファイルを送受信する

Ruby, net/ftpというライブラリも標準添付されています。@igrepさんにPure-FTPdというFTPサーバを立ててデモしてもらいました。

バイナリファイル、テキストファイルの送受信がプロトコルとして存在していて、そのまま Net::FTP#getbinaryfile のように対応するメソッドが存在しています。get という名前ですが、返り値はなくローカルファイルを保存するという仕様。

その他

Rubocopを導入するとき、とっかかりのルールはどうしたらよさそうかという話をしたところ、onkcopの設定を参考にしてみるとよさそうというお話を聞いたので、それを眺めたりしていました。

github.com

あとはこのエントリをもくもく書いたり。

blog.kymmt.com



次回は2017-07-08(土)です。

yokohamarb.doorkeeper.jp

Springが動くRails+MySQLなAPIサーバの開発環境をDocker Composeで作る

このあいだ、Rails+MySQLという構成のアプリケーション開発環境をDocker Composeで構築できるようにしました。

blog.kymmt.com

いろいろと理解が深まるにつれて、何点かクリアしたい問題が見えてきました。

  • Dockerfile で使うイメージ
    • ruby:2.4.1-onbuildRUN bundle config frozen 1 しているので、あとから Gemfile を更新して bundle install できない
  • bundle install に時間がかかる
    • Gemfile にgemを追加するたびに、すべてのgemのインストールが走る
  • rails, rake コマンドの立ち上がりが遅い
    • アプリケーションプリローダSpringのサーバが立ち上がっていないため

今回はこれらの問題をクリアして、さらにいい感じの開発環境を作ります。

Dockerfile で使うイメージ

前回作った Dockerfile では ruby:2.4.1-onbuild というイメージを使っていました。このイメージは ONBUILD 命令を使っており、このイメージを使った Dockerfile をビルドすることで ONBUILD に指定されたコマンドが自動で実行されるようになっています。Rubyの場合だと、Gemfile のイメージ内へのコピーや bundle install の実行のような決まりきったコマンドが ONBUILD として指定されているので、Dockerfile の作成を省力化できます。ruby:2.4.1-onbuildDockerfile は次のものです。

しかし、この ruby:2.4.1-onbuild では、RUN bundle config --global frozen 1 というコマンドを実行するようになっています。これは、イメージ内の Gemfile を変更して bundle install できないようにするものです*1

デプロイするような用途であればこれでもよいですが、今回は開発環境がほしいので、Gemfile は必要なときに都度変更してgemをインストールできるようにしたいです。また、onbuild タグがついたイメージは非推奨となっているようでした*2

そこで、ruby:2.4.1-onbuild イメージを使うのはやめて、ruby:2.4.1 イメージをベースに自分でもろもろの作業をやる Dockerfile になるように書き直しました。

FROM ruby:2.4.1

ENV APP_HOME /usr/src/app
RUN mkdir -p $APP_HOME
WORKDIR $APP_HOME

COPY Gemfile \
     Gemfile.lock \
     $APP_HOME/

ENV BUNDLE_GEMFILE=$APP_HOME/Gemfile \
    BUNDLE_JOBS=4
RUN bundle install

(※後述の[追記]も参照してください)

これで、コンテナ内の Gemfile をあとから編集しても、bundle install が実行できます。

bundle install に時間がかかる

現在、bundle install で入るgemの保存先はコンテナ内になっています。この場合、Gemfile にgemを追加してインストールしたいときはイメージを作り直す必要がありますが、これだとすべてのgemのインストールが走ってしまいます。

これを回避するために、gemの保存先をDocker Volumeに変更します。docker-compose.yml でgem保存用のVolumeを作り、APIサーバを動かすコンテナにマウントします。

version: '3'
services:
  # dbの設定...

  app:
    build: .
    command: bin/rails s -p 3000 -b "0.0.0.0"
    depends_on:
      - db
    ports:
      - "3000:3000"
    stdin_open: true
    tty: true
    volumes:
      - .:/usr/src/app
      - bundle_cache:/usr/local/bundle
volumes:
  bundle_cache:

bundle_cache がgem保存用のVolumeです。マウント先が /usr/local/bundle になっているのは、ベースイメージとしている ruby:2.4.1GEM_HOME/usr/local/bundle としているからです。

https://github.com/docker-library/ruby/blob/752c5f7cf44870ceae77134b346d20093053c370/2.4/Dockerfile#L63

これで、gemをVolumeへ保存できるようになりました。コンテナが終了したあともVolumeにgemが残ります。Gemfile にgemを追加して bundle install したいときは、次のようにコンテナを起動すればOKです。

$ docker-compose run app --rm bundle install

[2017-06-11 追記]bundle install する場所について

bundle installDockerfile に書くと、イメージのビルド時にインストールされたgemがイメージに入ります。この時点ではgemを保存するVolumeが設定されていないのですが、docker-compose コマンド経由でコンテナを起動すると、Volumeがgem保存先にマウントされます。このVolumeが上述の説明で使っている名前付きボリュームだと、マウント先に存在するファイルはVolumeへコピーされます*3

しかし、コンテナ起動前後でgemの保存の仕組みが変わるのはわかりにくいです。Dockerfile 内に bundle install を書くのではなく docker-compose run --rm app bundle install のように明示的にVolumeへgemをインストールする、という方法のほうがわかりやすそうです。

rails, rake コマンドの立ち上がりが遅い

RailsにはデフォルトでSpringというアプリケーションプリローダが入っています。Springを起動させておくと、rails, rake のようなコマンドの実行を高速化できます。現在、Springのことを考慮していないので、APIサーバとは別にSpring用のコンテナを立ち上げるようにします。

実際の作業内容では、この記事が参考になりました。ほとんどこの記事のとおりやっています。

tech.degica.com

version: '3'
services:
  # dbの設定...

  app: &app_base
    build: .
    command: bin/rails s -p 3000 -b "0.0.0.0"
    depends_on:
      - db
    ports:
      - "3000:3000"
    stdin_open: true
    tty: true
    volumes:
      - .:/usr/src/app
      - bundle_cache:/usr/local/bundle
  spring:
    <<: *app_base
    command: bin/spring server
    ports: []
volumes:
  bundle_cache:

bin 配下にある railsrake のようなbinstubにSpringの処理を差し込み、叩くコマンドを省略できるようにする機能があるので、それを実行しておきます。

$ docker-compose exec spring bundle exec spring binstub --all
* bin/rake: spring inserted
* bin/rails: spring inserted

docker-compose upappspring のコンテナを立ち上げたあと、実行中の spring コンテナ内でSpringを通じたコマンドの実行ができます。

$ docker-compose exec spring bin/rails console

[参考]spring コマンド実行時にエラーが出る場合

もし .bundle/config 内で BUNDLE_DISABLE_SHARED_GEMS: 1 という設定がある場合は削除する必要があります。この設定があると、Springを通じたコマンド実行時にうまくgemの場所を解決ができず次のエラーが出てしまいます。

Could not find rake-12.0.0 in any of the sources
Run `bundle install` to install missing gems.

あんまりないと思いますが、以前に bundle install --path vendor/bundle したときに自動で BUNDLE_DISABLE_SHARED_GEMS が設定に入っていてハマりました…

結果

Dockerfile は次のようになりました。

FROM ruby:2.4.1

ENV APP_HOME /usr/src/app
RUN mkdir -p $APP_HOME
WORKDIR $APP_HOME

COPY Gemfile \
     Gemfile.lock \
     $APP_HOME/

ENV BUNDLE_GEMFILE=$APP_HOME/Gemfile \
    BUNDLE_JOBS=4
RUN bundle install

docker-compose.yml は次のとおりです。

version: '3'
services:
  db:
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
    image: mysql:5.7
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
  app: &app_base
    build: .
    command: bin/rails s -p 3000 -b "0.0.0.0"
    depends_on:
      - db
    ports:
      - "3000:3000"
    stdin_open: true
    tty: true
    volumes:
      - .:/usr/src/app
      - bundle_cache:/usr/local/bundle
  spring:
    <<: *app_base
    command: bin/spring server
    ports: []
volumes:
  bundle_cache:
  mysql_data: