Railsのアセットパイプライン周りのコードリーディングをしました。今回はassets:precompile
のRakeタスクで何をやっているかをコードベースで追っていきます。Railsのバージョンは5.1.5です。
まずSprockets::Railtie内のrask_tasksのDSLでSprockets::Rails::Taskのインスタンスを生成します。
1 2 3 4 5 6 7 |
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タスクが定義されています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
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のコンストラクタは以下のように定義されています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
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で検索しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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ファイル)の非同期処理を待っています。
1 2 3 4 5 6 7 8 9 10 11 |
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です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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の定義は以下の通りです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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(ファイルパスに対する依存関係)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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を呼び出します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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のプロセッサの処理
- キャッシュに保存
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
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の戻り値がプロセッサになります。
1 2 3 4 5 6 7 |
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で呼ばれる(
Sprockets::Bundle.callは以下のような実装になっています。重要なのは required = Utilf.dfs(...
の部分で、ここで再帰的にEnvironment#loadしつつファイルのロードや、ディレクティブ解決を行っています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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によってファイル結合されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
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で定義されており、ファイルタイプによって処理内容が変わります。
1 2 3 |
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を継承したクラスをロードするだけでアセットの検索パスが追加されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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 |
コメントを残す