Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby PStoreの実用的な使いみち(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Ruby PStoreの実用的な使いみち(翻訳)

この数週間、arkencyブログでいくつもの改善を進めてきました。そのひとつがブログ記事のソースコード公開です。私たちの結論は、記事をオープンにすることでフィードバックループを短縮でき、ブログの読者が以下のように内容を改善できるようになるというものでした。

Nanoc + Github

ここ数年、私たちのブログはnanocという静的サイトジェネレーターで回していました。markdownファイルをひとまとめに突っ込んでレイアウトを付けると、反対側からHTMLが出てくるというものです。このマジックを「コンピレーション」と呼ぶことにしましょう。nanocの素晴らしい機能のひとつにdata sourcesというものがあります。これを使うと、ローカルファイルシステム以外のコンテンツでもレンダリングできるようになります。適切なアダプタを用いることで、記事やページといったさまざままデータ項目をサードパーティAPIからフェッチできますし、SQLデータベースやGitHubからもフェッチできるのです!

私たちのブログのバックエンドとしてGitHubを選んだ理由は、何も考えずに使えるからです。開発者はGitHubに慣れ親しんでいますし、markdownをプレビューできるなかなか素敵なWebエディタも統合されています。内容についての議論はプルリクで始められます。忘れないうちに大事なことを書いておくと、実装の多くの部分をoctokitという、APIとやりとりできるgemに任せることができました。

初期のデータアダプタでは以下のような感じで記事をフェッチしていました。

class Source < Nanoc::DataSource
  identifier :github

  def items
    client = Octokit::Client.new(access_token: ENV['GITHUB_TOKEN'])
    client
      .contents(ENV['GITHUB_REPO'])
      .select { |item| item.end_with?(".md") }
      .map    { |item| client.contents(ENV['GITHUB_REPO'], path: item[:path]) }
      .map    { |item| new_item(item[:content], item, Nanoc::Identifier.new(item[:path])) }
  end
end

このコードでは以下を行なっています。

  • リポジトリ内のファイルのリストを取得
  • 拡張子でフィルタし、markdownファイルのみを残す
  • 各markdownファイルの中身を取得
  • nanocのitemオブジェクトに変換

問題点を調べるにはこれで十分です。このコードを「現実に」使い始めると、たちまち問題が発生します。どこが問題になるかおわかりですか?

元データの改善

markdownファイルが100個もあるリポジトリからコンテンツを取り出そうとすると、100+1件ものHTTPリクエストが必要になります。

  • サイトでレイアウト変更によるコンテンツ再コンパイルが発生すると時間がかかって嫌になる
  • 1時間あたりのAPIリクエスト数に上限がある(トークンを使えばもう少し多くリクエストできますが、それでも上限があります)

これらのリクエストをパラレルにしたところで、リクエストのクォータに達するのが早まるだけです。必要なリクエスト数制限のために、何か一工夫しなければならないところです。

ありがたいことに、octokit gemはHTTPのやりとりにfaradayライブラリを用いています。しかもfaraday-http-cacheミドルウェアの活用方法についてもある程度ドキュメントに記載されています。

class Source < Nanoc::DataSource
  identifier :github

  def up
    stack = Faraday::RackBuilder.new do |builder|
      builder.use Faraday::HttpCache,
                  serializer: Marshal,
                  shared_cache: false
      builder.use Faraday::Request::Retry,
                  exceptions: [Octokit::ServerError]
      builder.use Octokit::Middleware::FollowRedirects
      builder.use Octokit::Response::RaiseError
      builder.use Octokit::Response::FeedParser
      builder.adapter Faraday.default_adapter
    end
    Octokit.middleware = stack
  end

  def items
    repository_items.map do |item|
      identifier     = Nanoc::Identifier.new("/#{item[:name]}")
      metadata, data = decode(item[:content])

      new_item(data, metadata, identifier, checksum_data: item[:sha])
    end
  end

  private

  def repository_items
    pool  = Concurrent::FixedThreadPool.new(10)
    items = Concurrent::Array.new
    client
      .contents(repository, path: path)
      .select { |item| item[:type] == "file" }
      .each   { |item| pool.post { items << client.contents(repository, path: item[:path]) } }
    pool.shutdown
    pool.wait_for_termination
    items
  rescue Octokit::NotFound => exc
    []
  end

  def client
    Octokit::Client.new(access_token: access_token)
  end

  def repository
    # ...
  end

  def path
    # ...
  end

  def access_token
    # ...
  end

  def decode(content)
    # ...
  end
end

以下が追加されていることにご注目ください。

  • upメソッドは、nanocでデータソースを回すときに使われ、キャッシュミドルウェアを導入します
  • concurrent-ruby gemのConcurrent::FixedThreadPoolは、マルチスレッドでのコンカレントなリクエストを実現します

このキャッシュさえ動いてくれれば…faradayにはインメモリキャッシュが備わっているのですが、nanocでの作業フローでは無力です。私たちは何としても、キャッシュをコンパイルプロセスの実行全体に効かせたいのです。ちょうどドキュメントにはキャッシュのバックエンドをRailsのキャッシュ機構に切り替える方法が記載されているのですが、これもnanocの場合は助けになりませんでした。皆さんも、大量のHTMLをコンパイルするだけのためにRedisやMemcachインスタンスを起動したくはありませんよね。

またしても腕まくりが必要になってきました。APIで期待されるものがわかれば、ファイルベースのキャッシュバックエンドを構築できるでしょう。そして実は、コードの基本部分を再実装する手間から私たちを解放してくれるgemが標準ライブラリにあるのですが、なぜかその名をほとんど知られていません。再び巨人の肩に乗せてもらうのはこれで十分です。

PStoreを使う

PStoreはファイルベースの永続化メカニズムで、Hashを利用します。ここに保存できるRubyオブジェクトはMarshalによってシリアライズされてからディスクに吐き出されます。PStoreはトランザクショナルな振る舞いもサポートし、しかもスレッドセーフです。今回の目的にはぴったりですね!

class Cache
  def initialize(cache_dir)
    @store = PStore.new(File.join(cache_dir, "nanoc-github.store"), true)
  end

  def write(name, value, options = nil)
    store.transaction { store[name] = value }
  end

  def read(name, options = nil)
    store.transaction(true) { store[name] }
  end

  def delete(name, options = nil)
    store.transaction { store.delete(name) }
  end

  private
  attr_reader :store
end

結局このキャッシュストアはpstoreの単なるラッパーに過ぎないことがわかりました。これは便利!内部のtransactionブロックのあたりでMutexを用いたことで、スレッド安全性も達成できました。

class Source < Nanoc::DataSource
  identifier :github

  def up
    stack = Faraday::RackBuilder.new do |builder|
      builder.use Faraday::HttpCache,
                  serializer: Marshal,
                  shared_cache: false,
                  store: Cache.new(tmp_dir)
      # ...
    end
    Octokit.middleware = stack
  end

  # ...
end

faradayに永続的なキャッシュストアをプラグインしたことで、キャッシュされたレスポンスのメリットを享受できるようになりました。これにより、以後のGitHub APIへのリクエストはスキップされ、ローカルファイルでリクエストを扱うようになります。キャッシュが古くなるまでは…

キャッシュの正当性はさまざまなHTTPヘッダーで制御できます。GitHub APIで重要なのはCache-Control: private, max-age=60, s-maxage=60です。これとDateヘッダーを組み合わせることで、このコンテンツは最後にレスポンスを受信してから60秒間は正当であるということをざっくり示せます。コンテンツの更新頻度が高ければ、たぶんこれで十分でしょう。ブログ記事の場合、個人的にはもう少し長い方が好みですが。

いよいよnanoc-githubの最後のピースをはめる時が来ました。キャッシュ時間を延長できるようにするfaradayのミドルウェアを用います。これはかなり原始的なコード片で、max-ageの値を好きな値に置き換えます。ここで必要な値として3600秒という時間をセットします。一般的なアイデアは次のとおりです。まずAPIからのHTTPレスポンスがキャッシュにヒットする前にレスポンスを改変します。次にキャッシュミドルウェアは、元のではなく改変されたmax-ageを元にキャッシュの正当性を検査します。シンプルかつ十分ですね。以下のコードをミドルウェアスタックに追加しますが、このときスタック内で正しい順序になるよう注意しましょう😅。

class ModifyMaxAge < Faraday::Middleware
  def initialize(app, time:)
    @app  = app
    @time = Integer(time)
  end

  def call(request_env)
    @app.call(request_env).on_complete do |response_env|
      response_env[:response_headers][:cache_control] = "public, max-age=#{@time}, s-maxage=#{@time}"
    end
  end
end

以上でおしまいです!本記事が皆さまのお役に立ち、1つ2つでも学びがあればと願っています。私のTwitterアカウント(@pawelpacana)までご感想をお寄せいただくか、私たちのプロジェクトに★をお付けください。

pawelpacana/nanoc-github - GitHub

Happy hacking!

おたより発掘

関連記事

ソフトウェアパターンを闇雲に適用しないこと(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。