"Propshaft is an asset pipeline library for Rails. It's built for era where bundling assets to save on HTTP connections is no longer urgent, where JS and CSS is either compiled by dedicated Node.js bundlers or served directly to the browsers, ..." https://t.co/OHrIYVxl5Z
— DHH (@dhh) September 20, 2021
現代ではJavaScriptを含むassetsをコンパイルしたりバンドルしたりしてHTTPリクエスト数を削減する必要がなくなりました。 HTTP2が普及したため複数のassetsを1つにまとめる必要がなくなりました。 またaltJSのコンパイルは遙かに複雑になり外部のNode.jsで動くバンドラーを使うことが一般的になりました。
Ruby on Railsでは、これらの機能をSprocketsで提供していました。 要らなくなった機能を減らして、よりシンプルな実装としてPropshaftという新しいGemが作り始められました。
背景
https://github.com/rails/jsbundling-rails/issues/24#issuecomment-920135911
Working on a path to a dramatically simpler sprockets.
6日前から、シンプルなSprocketsを作っているという話がありました。
また、Rails 7ではimportmapを使ってESモジュールを読み込むか、Node.jsのバンドラーをつかって事前にバンドルすることが推奨されるようになります。 Rails 7のフロントエンド政策 - @ledsun blog
このため、Sprocketsが提供していた機能のうち
- CoffeeScriptのコンパイル
- JavaScriptファイルのconcat
- JavaScriptファイルのminify
などのワークフローが不要になりました。
実装
実際にシンプルかどうかソースコードを見てみましょう。
エントリーポイント
エントリーポイントは lib/propshaft/railtie.rb のようです。 主にやっているのは、
ヘルパーの追加
ActiveSupport.on_load(:action_view) do include Propshaft::Helper end
assets:precompile
タスクの定義です。
namespace :assets do desc "Compile all the assets from config.assets.paths" task precompile: :environment do Rails.application.assets.processor.process end end
assets:precompile
タスクの実体はRails.application.assets.processor.process
です。
Rails.application.assets
は
app.assets = Propshaft::Assembly.new(app.config.assets)
Propshaft::Assembly
インスタンスです。
ヘルパー
def compute_asset_path(path, options = {}) Rails.application.assets.resolver.resolve(path) end
compute_asset_path
メソッドを定義します。
実体はRails.application.assets.resolver.resolve(path)
です。
Rails.application.assets.resolver
は、Assemblyのメソッドです。
def resolver if manifest_path.exist? Propshaft::Resolver::Static.new manifest_path: manifest_path, prefix: config.prefix else Propshaft::Resolver::Dynamic.new load_path: load_path, prefix: config.prefix end end
manifestファイルの有無によって、Propshaft::Resolver::Static
またはPropshaft::Resolver::Dynamic
のインスタンスです。
Roselover
そんなの通りファイルのパスを解決するクラスです。
manifestファイルの有無によって、Propshaft::Resolver::Static
とPropshaft::Resolver::Dynamic
を使い分けます。
Static
def resolve(logical_path) if asset_path = parsed_manifest[logical_path] File.join prefix, asset_path end end
シンプルにmanifestファイルに書いてあるマッピングにしたがってパスを返します。 manifestファイルのフォーマットは、テストデータを見るとわかります。
{ "one.txt": "one-f2e1ec14d6856e1958083094170ca6119c529a73.txt" }
ファイル名がキーで、ダイジェスト付きのファイル名が値です。
manifestファイルは、後述しますがRails.application.assets.processor.process
で作成します。
常にRails.application.assets.processor.process
したときのファイルをダイジェスト付きで返すので、静的です。
Dynamic
def resolve(logical_path) if asset = load_path.find(logical_path) File.join prefix, asset.digested_path end end
load_path
なるものから検索し、ダイジェスト付きのパスを返します。
ファイルの最新の状態を取得して、ダイジェストをつけて返すので、動的です。
LoadPath
propshaft/assembly.rb at 7ad18e702fd0873c86880c38a8c5469867f7861c · rails/propshaft · GitHub
def load_path Propshaft::LoadPath.new(config.paths) end
LoadPath
はAssembly
で生成されるインスタンスです。
def assets_by_path Hash.new.tap do |mapped| paths.each do |path| all_files_from_tree(path).each do |file| logical_path = file.relative_path_from(path) mapped[logical_path.to_s] ||= Propshaft::Asset.new(file, logical_path: logical_path) end end end end
find
するたびに実際のファイルシステムを読み取って、assets_by_path
というハッシュを作って返します。
Processer
assets:precompile
タスクで実行して、対象のファイルを処理します。
def process ensure_output_path_exists write_manifest output_assets compress_assets end
process
だけあって、処理が並んでいます。
書いてあるとおりに
- manifestファイルを作成
- assetsの書き出し
- assetsの圧縮
をします。 DSLって感じがします。
manifestファイルを作成
def write_manifest File.open(output_path.join(MANIFEST_FILENAME), "wb+") do |manifest| manifest.write load_path.manifest.to_json end end
LoadPath
で作ったmanifestをファイルに書き出します。
def manifest Hash.new.tap do |manifest| assets.each do |asset| manifest[asset.logical_path.to_s] = asset.digested_path.to_s end end end
前述のmanifestファイルのフォーマットそのまんまです。
assetsの書き出し
def output_assets load_path.assets.each do |asset| unless output_path.join(asset.digested_path).exist? FileUtils.mkdir_p output_path.join(asset.digested_path.parent) output_asset(asset) end end end
ダイジェスト付きのパスに、実体ファイルをコピーします。
def output_asset(asset) compile_asset(asset) || copy_asset(asset) end
実はコピーだけでなくコンパイルすることもあります。
しかし、現時点で実装されているコンパイラはPropshaft::Compilers::CssAssetUrls
だけです。
https://github.com/rails/propshaft/issues/1#issuecomment-923606519
Should just be url() only.
DHHは、今のところ、コンパイラはふやしても、CSSファイルのurl() *1 用のコンパイラくらいに考えてそうに思います。
assetsの圧縮
def compress_assets load_path.assets(content_types: COMPRESSABLE_CONTENT_TYPES).each do |asset| compress_asset output_path.join(asset.digested_path) end if compressor_available? end def compress_asset(path) `brotli #{path} -o #{path}.br` unless Pathname.new(path.to_s + ".br").exist? end
js css text json xml html svg otf ttf
形式のファイルを、brotliで圧縮します。
RailsにはbrがついたファイルがあるときはContent-Encoding: br
をつけて送信する機能があります*2。
この機能の為に事前に圧縮しておきます。
Propshaft::Compilers::CssAssetUrls
def compile(input) input.gsub(/asset-path\(["']([^"')]+)["']\)/) do |match| %[url("#{assembly.config.prefix}/#{assembly.load_path.find($1).digested_path}")] end end
全然わかりません。 こういうときはテストコードを見てみましょう。
test "replace asset-path function in css with digested url" do @assembly.compilers.register "text/css", Propshaft::Compilers::CssAssetUrls assert_match /"\/assets\/archive-[a-z0-9]{40}.svg/, @assembly.compilers.compile(find_asset("another.css")) end
これでもまだよくわからないので、another.css
の中身を確認します。
.btn { background-image: asset-path("archive.svg"); }
asset-path("archive.svg")
を/assets/archive-xxxxここは本当はダイジェストxxxx.svg
に書き換えるようです。
ダイジェスト
def digest Digest::SHA1.hexdigest(content) end
感想
無駄なものは何もないし、処理は追いやすいし、拡張するときはどこに手を入れれば良いかわかりやすいし、めちゃくちゃに綺麗なソースコードでした。