2018-03-16

Railsアセットパイプラインのコードリーディング

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を返します。

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情報を保存しています。

ざっくり書くとこんな流れです。

      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した場合は以下のようなフローになります。

Sprockets::Bundle.callは以下のような実装になっています。重要なのは 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
 また、config.asseet.js_compressor=を呼び出すとpipeline=nilのときのbundleのprocessorが増えます。プロセッサは定義順に対してFIFOな感じで処理されるので、圧縮は最後に実行されます。
    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
このエントリーをはてなブックマークに追加