2023年8月26日
2024年5月1日
Sprockets
コンセプト
HTTP/2以前を主なターゲットとしているアセットパイプラインです。
主な機能
- アセット(静的ファイル)のパスの解決
- アセットの連結
- Webページをレンダリングする際のリクエストの数を減らす
- CoffeeScript, Sass/SCSSのトランスパイル
- アセットファイルの縮小, 圧縮
- ダイジェストの付与
- 開発サーバーの提供
Propshaft
コンセプト
HTTP/2とES6を利用することを想定したアセットパイプラインです。
HTTP/2の機能の1つに、単一コネクションにおけるレスポンスの並列化があります。これを利用することで、複数のアセットファイルを連結する必要がなくなります。
また、ES6に準拠したプログラムをそのまま利用するので、ES5に準拠したプログラムへのトランスパイルが不要になります。
主な機能
- アセット(静的ファイル)のパスの解決
- ダイジェストの付与
- 開発サーバーの提供
- CSSファイルに書かれているアセットのURLのコンパイル
- アセットのURLにダイジェストを付与する
Propshaftを使用する利点
ソフトウェアを構成するプログラムが少ないので、以下の利点があります。
- Sprocketよりもアセットパイプラインのパフォーマンスが高い
- バグによってソフトウェアが動作しなくなる可能性が比較的低い
- 開発者が使い方を理解しやすい
Propshaftのリポジトリの中身を調べてみた
参照: https://github.com/rails/propshaft
lib/propshaft/compiler/css_asset_urls.rb
CSSに書かれているアセットのURLをコンパイルします。
# frozen_string_literal: true
require "propshaft/compiler"
class Propshaft::Compiler::CssAssetUrls < Propshaft::Compiler
ASSET_URL_PATTERN = /url\(\s*["']?(?!(?:\#|%23|data|http|\/\/))([^"'\s?#)]+)([#?][^"')]+)?\s*["']?\)/
def compile(logical_path, input)
input.gsub(ASSET_URL_PATTERN) { asset_url resolve_path(logical_path.dirname, $1), logical_path, $2, $1 }
end
private
def resolve_path(directory, filename)
if filename.start_with?("../")
Pathname.new(directory + filename).relative_path_from("").to_s
elsif filename.start_with?("/")
filename.delete_prefix("/").to_s
else
(directory + filename.delete_prefix("./")).to_s
end
end
def asset_url(resolved_path, logical_path, fingerprint, pattern)
if asset = assembly.load_path.find(resolved_path)
%[url("#{url_prefix}/#{asset.digested_path}#{fingerprint}")]
else
Propshaft.logger.warn "Unable to resolve '#{pattern}' for missing asset '#{resolved_path}' in #{logical_path}"
%[url("#{pattern}")]
end
end
end
lib/propshaft/compiler/source_mapping_urls.rb
ソースマップのURLをコメントとして付与します。
# frozen_string_literal: true
require "propshaft/compiler"
class Propshaft::Compiler::SourceMappingUrls < Propshaft::Compiler
SOURCE_MAPPING_PATTERN = %r{^(//|/\*)# sourceMappingURL=(.+\.map)}
def compile(logical_path, input)
input.gsub(SOURCE_MAPPING_PATTERN) { source_mapping_url(asset_path($2, logical_path), $1) }
end
private
def asset_path(source_mapping_url, logical_path)
if logical_path.dirname.to_s == "."
source_mapping_url
else
logical_path.dirname.join(source_mapping_url).to_s
end
end
def source_mapping_url(resolved_path, comment)
if asset = assembly.load_path.find(resolved_path)
"#{comment}# sourceMappingURL=#{url_prefix}/#{asset.digested_path}"
else
Propshaft.logger.warn "Removed sourceMappingURL comment for missing asset '#{resolved_path}' from #{resolved_path}"
comment
end
end
end
lib/propshaft/railties/assets.rake
rakeタスクを定義します。
lib/propshaft/resolver/dynamic.rb
load_pathからアセットを検索して、ファイルにダイジェストを付与します。
module Propshaft::Resolver
class Dynamic
attr_reader :load_path, :prefix
def initialize(load_path:, prefix:)
@load_path, @prefix = load_path, prefix
end
def resolve(logical_path)
if asset = load_path.find(logical_path)
File.join prefix, asset.digested_path
end
end
def read(logical_path)
if asset = load_path.find(logical_path)
asset.content
end
end
end
end
lib/propshaft/resolver/static.rb
manifestを元にアセットを検索します。
module Propshaft::Resolver
class Static
attr_reader :manifest_path, :prefix
def initialize(manifest_path:, prefix:)
@manifest_path, @prefix = manifest_path, prefix
end
def resolve(logical_path)
if asset_path = parsed_manifest[logical_path]
File.join prefix, asset_path
end
end
def read(logical_path)
if asset_path = parsed_manifest[logical_path]
manifest_path.dirname.join(asset_path).read
end
end
private
def parsed_manifest
@parsed_manifest ||= JSON.parse(manifest_path.read, symbolize_names: false)
end
end
end
lib/propshaft/assembly.rb
resolver
manifestが存在していればそれを元にアセットを検索し、そうでなければload_pathからアセットを検索します。
def resolver
@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
reveal
パスの種類に応じてそれぞれのアセットに対してメソッドを呼び出します。
def reveal(path_type = :logical_path)
path_type = path_type.presence_in(%i[ logical_path path ]) || raise(ArgumentError, "Unknown path_type: #{path_type}")
load_path.assets.collect do |asset|
asset.send(path_type)
end
end
lib/propshaft/asset.rb
digest
SHA1アルゴリズムを使用して16進数のダイジェストを付与します。
def digest
@digest ||= Digest::SHA1.hexdigest("#{content}#{version}")
end
lib/propshaft/compiler.rb
コンパイラが行うことを定義します。compileメソッドを定義することで、それぞれのコンパイラがコンパイルを行うことを示しています。
# Override this in a specific compiler
def compile(logical_path, input)
raise NotImplementedError
end
lib/propshaft/compilers.rb
compilable?
特定の種類のアセットが存在しているかどうかを判定します。
def compilable?(asset)
registrations[asset.content_type.to_s].present?
end
compile
アセットの内容を入力として受け取り、コンパイルします。
def compile(asset)
if relevant_registrations = registrations[asset.content_type.to_s]
asset.content.dup.tap do |input|
relevant_registrations.each do |compiler|
input.replace compiler.new(assembly).compile(asset.logical_path, input)
end
end
else
asset.content
end
end
lib/propshaft/helper.rb
compute_asset_path
アセットのパスを返します。
def compute_asset_path(path, options = {})
Rails.application.assets.resolver.resolve(path) || raise(MissingAssetError.new(path))
end
lib/propshaft/load_path.rb
cache_sweeper
JavaScriptファイルの変更を監視するオブジェクトを返します。
def cache_sweeper
@cache_sweeper ||= begin
exts_to_watch = Mime::EXTENSION_LOOKUP.map(&:first)
files_to_watch = Array(paths).collect { |dir| [ dir.to_s, exts_to_watch ] }.to_h
Rails.application.config.file_watcher.new([], files_to_watch) do
clear_cache
end
end
end
assets_by_path
アセットのパスとアセットの値をペアとするハッシュを返します。
def assets_by_path
@cached_assets_by_path ||= Hash.new.tap do |mapped|
paths.each do |path|
without_dotfiles(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, version: version)
end if path.exist?
end
end
end
lib/propshaft/output_path.rb
files
それぞれのファイルの値として、ファイルのパス、ダイジェスト、最終更新日時を設定します。
def files
Hash.new.tap do |files|
all_files_from_tree(path).each do |file|
digested_path = file.relative_path_from(path)
logical_path, digest = extract_path_and_digest(digested_path)
files[digested_path.to_s] = {
logical_path: logical_path.to_s,
digest: digest,
mtime: File.mtime(file)
}
end
end
end
fresh_version_within_limit
ファイルの最終更新日時と更新期限を比較して、必要に応じてファイルを更新します。
def fresh_version_within_limit(mtime, count, expires_at:, limit:)
modified_at = [ 0, Time.now - mtime ].max
modified_at < expires_at || limit < count
end
lib/propshaft/processor.rb
process
出力先のパスが存在するかどうかを確認してからmanifestを出力して、最後にアセットを出力します。
def process
ensure_output_path_exists
write_manifest
output_assets
end
output_assets
コンパイルできる場合はそのアセットをコンパイルし、コンパイルできない場合はそのアセットを出力先パスにコピーします。
def output_assets
load_path.assets.each do |asset|
unless output_path.join(asset.digested_path).exist?
Propshaft.logger.info "Writing #{asset.digested_path}"
FileUtils.mkdir_p output_path.join(asset.digested_path.parent)
output_asset(asset)
end
end
end
まとめ
Propshaftはトランスパイルをしないので、ソフトウェアを構成するプログラムの量やファイルの数が少ないだろうとは思っていましたが、想像以上に少なかったです。有名なソフトウェアだけあってプログラムの振る舞いが分かりやすく記述されていたので、プログラムを眺めるだけでも学びがあるな~と改めて感じました。