@ledsun blog

Hのキーがhellで、Sのキーがslaveだ、と彼は思った。そしてYのキーがyouだ。

Sprocketから要らなくなった機能を削減したPropshaft

現代では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が提供していた機能のうち

などのワークフローが不要になりました。

実装

実際にシンプルかどうかソースコードを見てみましょう。

エントリーポイント

エントリーポイントは lib/propshaft/railtie.rb のようです。 主にやっているのは、

ヘルパーの追加

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/railtie.rb#L31-L33

      ActiveSupport.on_load(:action_view) do
        include Propshaft::Helper
      end

assets:precompileタスクの定義です。

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/railtie.rb#L37-L42

      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インスタンスです。

ヘルパー

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/helper.rb

  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のメソッドです。

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/assembly.rb#L20-L26

  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::StaticPropshaft::Resolver::Dynamicを使い分けます。

Static

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/resolver/static.rb#L9-L13

    def resolve(logical_path)
      if asset_path = parsed_manifest[logical_path]
        File.join prefix, asset_path
      end
    end

シンプルにmanifestファイルに書いてあるマッピングにしたがってパスを返します。 manifestファイルのフォーマットは、テストデータを見るとわかります。

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/test/fixtures/output/.manifest.json

{ "one.txt": "one-f2e1ec14d6856e1958083094170ca6119c529a73.txt" }

ファイル名がキーで、ダイジェスト付きのファイル名が値です。 manifestファイルは、後述しますがRails.application.assets.processor.processで作成します。

常にRails.application.assets.processor.processしたときのファイルをダイジェスト付きで返すので、静的です。

Dynamic

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/resolver/dynamic.rb#L9-L13

    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

LoadPathAssemblyで生成されるインスタンスです。

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/load_path.rb#L31-L40

    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タスクで実行して、対象のファイルを処理します。

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/processor.rb#L12-L17

  def process
    ensure_output_path_exists
    write_manifest
    output_assets
    compress_assets
  end

processだけあって、処理が並んでいます。 書いてあるとおりに

  1. manifestファイルを作成
  2. assetsの書き出し
  3. 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をファイルに書き出します。

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/load_path.rb#L22-L28

  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の圧縮

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/processor.rb#L61-L69

    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

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/compilers/css_asset_urls.rb#L8-L12

  def compile(input)
    input.gsub(/asset-path\(["']([^"')]+)["']\)/) do |match|
      %[url("#{assembly.config.prefix}/#{assembly.load_path.find($1).digested_path}")]
    end
  end

全然わかりません。 こういうときはテストコードを見てみましょう。

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/test/propshaft/compilers_test.rb#L15-L18

  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の中身を確認します。

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/test/fixtures/assets/first_path/another.css

.btn {
  background-image: asset-path("archive.svg");
}

asset-path("archive.svg")/assets/archive-xxxxここは本当はダイジェストxxxx.svgに書き換えるようです。

ダイジェスト

https://github.com/rails/propshaft/blob/7ad18e702fd0873c86880c38a8c5469867f7861c/lib/propshaft/asset.rb#L23-L25

  def digest
    Digest::SHA1.hexdigest(content)
  end

ダイジェストのアルゴリズムSHA1ハッシュです。

感想

無駄なものは何もないし、処理は追いやすいし、拡張するときはどこに手を入れれば良いかわかりやすいし、めちゃくちゃに綺麗なソースコードでした。

参考