概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Practical use of Ruby PStore | Arkency Blog
- 原文公開日: 2020/04/28
- 著者: Paweł Pacana
- サイト: arkency
Ruby PStoreの実用的な使いみち(翻訳)
この数週間、arkencyブログでいくつもの改善を進めてきました。そのひとつがブログ記事のソースコード公開です。私たちの結論は、記事をオープンにすることでフィードバックループを短縮でき、ブログの読者が以下のように内容を改善できるようになるというものでした。
- PR: Fix typo in README by szemek · Pull Request #1 · arkency/posts
- PR: Edit "Remote collaborative modeling" by tomdalling · Pull Request #2 · arkency/posts
- PR: Path helper examples by samrussell · Pull Request #3 · arkency/posts
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)までご感想をお寄せいただくか、私たちのプロジェクトに★をお付けください。
Happy hacking!
おたより発掘
昔々、トランザクション概念の勉強とmarshalの例題のために短期間で書いたライブラリが「実用的」に使われることになるとは。
» Ruby PStoreの実用的な使いみち(翻訳)|TechRacho https://t.co/n73VqddrLs— Yukihiro Matsumoto (@yukihiro_matz) July 30, 2020