Railsのアセットパイプライン周りのコードリーディングをしました。今回はassets:precompile
のRakeタスクで何をやっているかをコードベースで追っていきます。Railsのバージョンは5.1.5です。
まずSprockets::Railtie内のrask_tasksのDSLでSprockets::Rails::Taskのインスタンスを生成します。
module Sprockets
class Railtie < ::Rails::Railtie
# ...
rake_tasks do |app|
require 'sprockets/rails/task'
Sprockets::Rails::Task.new(app)
end
Sprockets::Rails::TaskはRake::SprocketsTaskを継承しており、コンストラクタで#defineを呼び出します。この#defineにRakeタスクが定義されています。
module Sprockets
module Rails
class Task < Rake::SprocketsTask
# ...
def manifest
if app
Sprockets::Manifest.new(index, output, app.config.assets.manifest)
else
super
end
end
def define
namespace :assets do
%w( environment precompile clean clobber ).each do |task|
Rake::Task[task].clear if Rake::Task.task_defined?(task)
end
# Override this task change the loaded dependencies
desc "Load asset compile environment"
task :environment do
# Load full Rails environment by default
Rake::Task['environment'].invoke
end
desc "Compile all the assets named in config.assets.precompile"
task :precompile => :environment do
with_logger do
manifest.compile(assets)
end
end
# ...
assets:precompileタスクはSprockets::Manifest#compileを呼び出します。
Sprockets::Manifestのコンストラクタは以下のように定義されています。
module Sprockets
class Manifest
def initialize(*args)
if args.first.is_a?(Base) || args.first.nil?
@environment = args.shift
end
@directory, @filename = args[0], args[1]
# ...
if @directory && @filename.nil?
@filename = find_directory_manifest(@directory)
# If legacy manifest name autodetected, mark to rename on save
if File.basename(@filename).start_with?("manifest")
@rename_filename = File.join(@directory, generate_manifest_path)
end
end
unless @directory && @filename
raise ArgumentError, "manifest requires output filename"
end
data = {}
begin
if File.exist?(@filename)
data = json_decode(File.read(@filename))
end
rescue JSON::ParserError => e
logger.error "#{@filename} is invalid: #{e.class} #{e.message}"
end
@data = data
end
Railsの場合、@directory = public/assetsになります。@filenameはマニフェストファイルが存在すればpublic/assets/{マニフェストファイル名}になります。マニフェストファイルは#find_directory_manifestで検索しています。
module Sprockets
# Public: Manifest utilities.
module ManifestUtils
extend self
MANIFEST_RE = /^\.sprockets-manifest-[0-9a-f]{32}.json$/
LEGACY_MANIFEST_RE = /^manifest(-[0-9a-f]{32})?.json$/
def generate_manifest_path
".sprockets-manifest-#{SecureRandom.hex(16)}.json"
end
def find_directory_manifest(dirname)
entries = File.directory?(dirname) ? Dir.entries(dirname) : []
entry = entries.find { |e| e =~ MANIFEST_RE } ||
# Deprecated: Will be removed in 4.x
entries.find { |e| e =~ LEGACY_MANIFEST_RE } ||
generate_manifest_path
File.join(dirname, entry)
end
end
end
次にSprockets::Manifest#compileを見ていきます。findでアセットを検索・コンパイルし、ブロック内でコンパイルしたファイルを書き出し、最後に#saveでマニフェストファイルに保存します。concurrent_writersやconcurrent_compressorsのwait!はファイル書き出し(通常ファイル+gzファイル)の非同期処理を待っています。
def compile(*args)
# ...
find(*args) do |asset|
# ...
end
concurrent_writers.each(&:wait!)
concurrent_compressors.each(&:wait!)
save
filenames
end
Sprockets::Manifest#findの中身を見ていきます。
#findの引数は config.assets.precompile
です。特に追加されていなければSprockets::Railtie::LOOSE_APP_ASSETSとapplication.{css, js}の正規表現が入っている配列になります。LOOPSE_APP_ASSETSはjs,css以外のapp/assets以下のファイルをコンパイルするためのlambdaです。
module Sprockets
class Railtie < ::Rails::Railtie
LOOSE_APP_ASSETS = lambda do |logical_path, filename|
filename.start_with?(::Rails.root.join("app/assets").to_s) &&
!['.js', '.css', ''].include?(File.extname(logical_path))
end
initializer :set_default_precompile do |app|
if using_sprockets4?
raise ManifestNeededError unless ::Rails.root.join("app/assets/config/manifest.js").exist?
app.config.assets.precompile += %w( manifest.js )
else
app.config.assets.precompile += [LOOSE_APP_ASSETS, /(?:\/|\\|\A)application\.(css|js)$/]
end
end
#findの定義は以下の通りです。
module Sprockets
class Manifest
def find(*args)
#...
paths, filters = args.flatten.partition { |arg| self.class.simple_logical_path?(arg) }
filters = filters.map { |arg| self.class.compile_match_filter(arg) }
environment = self.environment.cached
paths.each do |path|
environment.find_all_linked_assets(path) do |asset|
yield asset
end
end
if filters.any?
environment.logical_paths do |logical_path, filename|
if filters.any? { |f| f.call(logical_path, filename) }
environment.find_all_linked_assets(filename) do |asset|
yield asset
end
end
end
end
nil
end
paths変数はワイルドカードを含まない文字列で指定されたprecompileの要素、filterはワイルドカード・正規表現・Procで表現されたprecompileの要素が入ります。文字列が指定されている場合は、そのままパスとしてファイルを抜き出せば良いのですが、ワイルドカードや正規表現で表現されている場合はアセットの検索パス(=Sprockets::Environment#logical_paths)からファイルを抽出してフィルタリングする必要があります。そのため、2要素に分けてそれぞれに対してSprockets::Base#find_all_linked_assetsを呼び出しています。
#find_all_linked_assetsは以下の通りです。#find_assetでアセットを読みこんでブロックをyieldします。
module Sprockets
class Base
def find_asset(path, options = {})
uri, _ = resolve(path, options.merge(compat: false))
if uri
load(uri)
end
end
def find_all_linked_assets(path, options = {})
return to_enum(__method__, path, options) unless block_given?
asset = find_asset(path, options)
return unless asset
yield asset
stack = asset.links.to_a
while uri = stack.shift
yield asset = load(uri)
stack = asset.links.to_a + stack
end
nil
end
#find_assetは#resolveした後、#loadを呼び出します。
#resolveは指定したファイルパス名から以下の要素を抽出し、最終的にそれらから生成したuri, depsを返します。
- ファイル名
- ファイルタイプ(application/javascriptとかそういうの)
- pipeline(プロセッサ選択用の文字列)
- deps(ファイルパスに対する依存関係)
module Sprockets
module Resolve
def resolve(path, options = {})
path = path.to_s
paths = options[:load_paths] || config[:paths]
accept = options[:accept]
if valid_asset_uri?(path)
uri, deps = resolve_asset_uri(path)
elsif absolute_path?(path)
filename, type, deps = resolve_absolute_path(paths, path, accept)
elsif relative_path?(path)
filename, type, pipeline, deps = resolve_relative_path(paths, path, options[:base_path], accept)
else
filename, type, pipeline, deps = resolve_logical_path(paths, path, accept)
end
if filename
params = {}
params[:type] = type if type
params[:pipeline] = pipeline if pipeline
params[:pipeline] = options[:pipeline] if options[:pipeline]
uri = build_asset_uri(filename, params)
end
return uri, deps
end
#resolveしたuriをSprockets::Loader#loadに渡します。#loadは#fetch_asset_from_dependency_cacheを呼び出します。
module Sprockets
module Loader
def load(uri)
unloaded = UnloadedAsset.new(uri, self)
if unloaded.params.key?(:id)
# ...
else
asset = fetch_asset_from_dependency_cache(unloaded) do |paths|
if paths
digest = DigestUtils.digest(resolve_dependencies(paths))
if uri_from_cache = cache.get(unloaded.digest_key(digest), true)
asset_from_cache(UnloadedAsset.new(uri_from_cache, self).asset_key)
end
else
load_from_unloaded(unloaded)
end
end
end
Asset.new(self, asset)
end
#fetch_asset_from_dependency_cacheはキャッシュ(Railsの場合、キャッシュ先はtmp/cache/assets配下のファイルになります)に無ければ引数無しでyieldします。
def fetch_asset_from_dependency_cache(unloaded, limit = 3)
key = unloaded.dependency_history_key
history = cache.get(key) || []
history.each_with_index do |deps, index|
expanded_deps = deps.map do |path|
path.start_with?("file-digest://") ? expand_from_root(path) : path
end
if asset = yield(expanded_deps)
cache.set(key, history.rotate!(index)) if index > 0
return asset
end
end
asset = yield
deps = asset[:metadata][:dependencies].dup.map! do |uri|
uri.start_with?("file-digest://") ? compress_from_root(uri) : uri
end
cache.set(key, history.unshift(deps).take(limit))
asset
end
引数無しでyieldすると#load_from_unloadedが呼ばれます。
#load_from_unloadedは以下のとおりで、#processors_forによってプロセッサを取得しています。プロセッサはファイルを読み込んだりディレクティブを展開したりソースの結合や圧縮などを行うクラスです。このプロセッサが#call_processors経由でディレクティブを解決してファイル連結したり圧縮したりします。また、#store_assetではキャッシュにAsset情報を保存しています。
ざっくり書くとこんな流れです。
- 各々のprecompile対象のファイルは、Sprockets::Bundleのプロセッサ+コンプレッサによって処理される
- Sprockets::Bundleのプロセッサの処理
- Sprockets::DirectiveProcessorによりディレクティブを再帰的に展開し、Assetの配列を生成
- reducerによりAssetの配列からソース文字列を結合
- コンプレッサにより圧縮
- Sprockets::Bundleのプロセッサの処理
- キャッシュに保存
def load_from_unloaded(unloaded)
# ...
processors = processors_for(type, file_type, engine_extnames, pipeline)
processors_dep_uri = build_processors_uri(type, file_type, engine_extnames, pipeline)
dependencies = config[:dependencies] + [processors_dep_uri]
# Read into memory and process if theres a processor pipeline
if processors.any?
result = call_processors(processors, {
environment: self,
cache: self.cache,
uri: unloaded.uri,
filename: unloaded.filename,
load_path: load_path,
name: name,
content_type: type,
metadata: { dependencies: dependencies }
})
validate_processor_result!(result)
source = result.delete(:data)
metadata = result
metadata[:charset] = source.encoding.name.downcase unless metadata.key?(:charset)
metadata[:digest] = digest(source)
metadata[:length] = source.bytesize
else
#...
end
asset = {
uri: unloaded.uri,
load_path: load_path,
filename: unloaded.filename,
name: name,
logical_path: logical_path,
content_type: type,
source: source,
metadata: metadata,
dependencies_digest: DigestUtils.digest(resolve_dependencies(metadata[:dependencies]))
}
# ...
store_asset(asset, unloaded)
asset
end
プロセッサは::Sprockets内で登録されています。Environment#default_processors_forやEnvironment#self_processors_forの戻り値がプロセッサになります。
register_pipeline :self do |env, type, file_type, engine_extnames|
env.self_processors_for(type, file_type, engine_extnames)
end
register_pipeline :default do |env, type, file_type, engine_extnames|
env.default_processors_for(type, file_type, engine_extnames)
end
例えばapplication.jsがprecompile対象でファイル内でjQueryをrequireした場合は以下のようなフローになります。
- env#loadではpipeline=nilなのでSprockets::Bundleが適用される
- Sprockets::Bundle内でEnvironment#resolveがpipeline=selfで呼ばれる(
env.resolve
のところ) - pipeline=selfなのでSprockets::FileReaderとSprockets::DirectiveProcessorがapplication.jsに適用される(
Utils.dfs(processed_uri…
の1ループ目) - Sprockets::DirectiveProcessorによってメタデータのrequiredにjQueryがセットされる。この時点ではAssetクラスに展開されていない(
Utils.dft(processed_uri…
の2ループ目)- Sprockets::FileReaderとSprockets::DirectiveProcessorがメタデータのrequiredのjQueryに対して適用される。ここでAssetクラスがセットされる。jQueryにはディレクティブが無いのでDirectiveProcessorは意味がなく、FileReaderの処理だけが適用される。
- #process_bundle_reducersにより、読み込んだapplication.jsとjQueryの中身を結合する
- Sprockets::Bundle内でEnvironment#resolveがpipeline=selfで呼ばれる(
required = Utilf.dfs(…
の部分で、ここで再帰的にEnvironment#loadしつつファイルのロードや、ディレクティブ解決を行っています。
module Sprockets
class Bundle
def self.call(input)
env = input[:environment]
type = input[:content_type]
dependencies = Set.new(input[:metadata][:dependencies])
processed_uri, deps = env.resolve(input[:filename], accept: type, pipeline: :self, compat: false)
dependencies.merge(deps)
find_required = proc { |uri| env.load(uri).metadata[:required] }
required = Utils.dfs(processed_uri, &find_required)
stubbed = Utils.dfs(env.load(processed_uri).metadata[:stubbed], &find_required)
required.subtract(stubbed)
assets = required.map { |uri| env.load(uri) }
(required + stubbed).each do |uri|
dependencies.merge(env.load(uri).metadata[:dependencies])
end
reducers = Hash[env.match_mime_type_keys(env.config[:bundle_reducers], type).flat_map(&:to_a)]
process_bundle_reducers(assets, reducers).merge(dependencies: dependencies, included: assets.map(&:uri))
end
required変数に格納された各アセットのURIはEnvironment#loadによってAssetの配列になり、#process_bundle_reducersによってファイル結合されます。
def self.process_bundle_reducers(assets, reducers)
initial = {}
reducers.each do |k, (v, _)|
if v.respond_to?(:call)
initial[k] = v.call
elsif !v.nil?
initial[k] = v
end
end
assets.reduce(initial) do |h, asset|
reducers.each do |k, (_, block)|
value = k == :data ? asset.source : asset.metadata[k]
if h.key?(k)
if !value.nil?
h[k] = block.call(h[k], value)
end
else
h[k] = value
end
end
h
end
end
reducer自体は::Sprocketsで定義されており、ファイルタイプによって処理内容が変わります。
register_bundle_metadata_reducer '*/*', :data, proc { "" }, :concat
register_bundle_metadata_reducer 'application/javascript', :data, proc { "" }, Utils.method(:concat_javascript_sources)
register_bundle_metadata_reducer '*/*', :links, :+
おまけ
ちなみにEnvironment#logical_pathのアセットのパス検索対象のディレクトリはRails::Engineに定義されています。jsやcssのRailsプラグインはRails::Engineを定義してアセットの検索パスを追加していることになります。jquery-railsのengineなんかがわかりやすいですが、Rails::Engineを継承したクラスをロードするだけでアセットの検索パスが追加されます。module Rails
# ...
class Engine < Railtie
# Skip defining append_assets_path on Rails <= 4.2
unless initializers.find { |init| init.name == :append_assets_path }
initializer :append_assets_path, :group => :all do |app|
app.config.assets.paths.unshift(*paths["vendor/assets"].existent_directories)
app.config.assets.paths.unshift(*paths["lib/assets"].existent_directories)
app.config.assets.paths.unshift(*paths["app/assets"].existent_directories)
end
end
end
end
def js_compressor=(compressor)
unregister_bundle_processor 'application/javascript', @js_compressor if defined? @js_compressor
@js_compressor = nil
return unless compressor
if compressor.is_a?(Symbol)
@js_compressor = klass = config[:compressors]['application/javascript'][compressor] || raise(Error, "unknown compressor: #{compressor}")
elsif compressor.respond_to?(:compress)
klass = LegacyProcProcessor.new(:js_compressor, proc { |context, data| compressor.compress(data) })
@js_compressor = :js_compressor
else
@js_compressor = klass = compressor
end
register_bundle_processor 'application/javascript', klass
end