TL;DR

让你的gem继承一下Rails::Engine,并增加db/migrate目录,在里面放一些migration文件。可参考acts-as-taggable-on

源码分析

根据acts-as-taggable-on的官网描述,创建标签表的操作需通过rake acts_as_taggable_on_engine:install:migrations。于是检查该job是定义在哪里的

$ rake -w
# ...
rake acts_as_taggable_on_engine:install:migrations /home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/railties-6.0.3.7/lib/rails/engine.rb:641:in `block (3 levels) in '
rake acts_as_taggable_on_engine:tag_names:collate_bin /home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/acts-as-taggable-on-7.0.0/lib/tasks/tags_collate_utf8.rake:10:in `block (2 levels) in 
' rake acts_as_taggable_on_engine:tag_names:collate_ci /home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/acts-as-taggable-on-7.0.0/lib/tasks/tags_collate_utf8.rake:15:in `block (2 levels) in
' # ... rake railties:install:migrations    /home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/activerecord-6.0.3.7/lib/active_record/railties/databases.rake:503:in `block (2 levels) in
' # ...


可见acts_as_taggable_on_engine:install:migrations定义如下,它实际上是执行railties:install:migrations或app:railties:install:migrations

# railties-6.0.3.7/lib/rails/engine.rb
rake_tasks do
  next if is_a?(Rails::Application)
  next unless has_migrations?

  namespace railtie_name do
    namespace :install do
      desc "Copy migrations from #{railtie_name} to application"
      task :migrations do
        ENV["FROM"] = railtie_name
        if Rake::Task.task_defined?("railties:install:migrations")
          Rake::Task["railties:install:migrations"].invoke
        else
          Rake::Task["app:railties:install:migrations"].invoke
        end
      end
    end
  end
end


而从上面的rake -w可知,railties:install:migrations是有的,它定义如下

# activerecord-6.0.3.7/lib/active_record/railties/databases.rake
namespace :railties do
  namespace :install do
    # desc "Copies missing migrations from Railties (e.g. engines). You can specify Railties to use with FROM=railtie1,railtie2"
    task migrations: :'db:load_config' do
      to_load = ENV["FROM"].blank? ? :all : ENV["FROM"].split(",").map(&:strip)
      railties = {}
      Rails.application.migration_railties.each do |railtie|
        next unless to_load == :all || to_load.include?(railtie.railtie_name)

        if railtie.respond_to?(:paths) && (path = railtie.paths["db/migrate"].first)
          railties[railtie.railtie_name] = path
        end

        unless ENV["MIGRATIONS_PATH"].blank?
          railties[railtie.railtie_name] = railtie.root + ENV["MIGRATIONS_PATH"]
        end
      end

      on_skip = Proc.new do |name, migration|
        puts "NOTE: Migration #{migration.basename} from #{name} has been skipped. Migration with the same name already exists."
      end

      on_copy = Proc.new do |name, migration|
        puts "Copied migration #{migration.basename} from #{name}"
      end

      ActiveRecord::Migration.copy(ActiveRecord::Tasks::DatabaseTasks.migrations_paths.first, railties,
                                    on_skip: on_skip, on_copy: on_copy)
    end
  end
end


该任务其实就是将gem下的db/migrate里的migration文件复制到rails项目的db/migrate里,复制过程中,会在文件里加上“This migration comes from XXX”的注释,并且文件会重命名为当前日期开头

# activerecord-6.0.3.7/lib/active_record/migration.rb
def copy(destination, sources, options = {})
  copied = []
  schema_migration = options[:schema_migration] || ActiveRecord::SchemaMigration

  FileUtils.mkdir_p(destination) unless File.exist?(destination)

  destination_migrations = ActiveRecord::MigrationContext.new(destination, schema_migration).migrations
  last = destination_migrations.last
  sources.each do |scope, path|
    source_migrations = ActiveRecord::MigrationContext.new(path, schema_migration).migrations

    source_migrations.each do |migration|
      source = File.binread(migration.filename)
      inserted_comment = "# This migration comes from #{scope} (originally #{migration.version})\n"
      magic_comments = +""
      loop do
        # If we have a magic comment in the original migration,
        # insert our comment after the first newline(end of the magic comment line)
        # so the magic keep working.
        # Note that magic comments must be at the first line(except sh-bang).
        source.sub!(/\A(?:#.*\b(?:en)?coding:\s*\S+|#\s*frozen_string_literal:\s*(?:true|false)).*\n/) do |magic_comment|
          magic_comments << magic_comment; ""
        end || break
      end
      source = "#{magic_comments}#{inserted_comment}#{source}"

      if duplicate = destination_migrations.detect { |m| m.name == migration.name }
        if options[:on_skip] && duplicate.scope != scope.to_s
          options[:on_skip].call(scope, migration)
        end
        next
      end

      migration.version = next_migration_number(last ? last.version + 1 : 0).to_i
      new_path = File.join(destination, "#{migration.version}_#{migration.name.underscore}.#{scope}.rb")
      old_path, migration.filename = migration.filename, new_path
      last = migration

      File.binwrite(migration.filename, source)
      copied << migration
      options[:on_copy].call(scope, migration, old_path) if options[:on_copy]
      destination_migrations << migration
    end
  end

  copied
end


虽然知道了acts_as_taggable_on_engine:install:migrations定义在哪里,但还不知道它是何时被定义的。于是通过caller看看它的调用栈:

rake_tasks do
  pp '----------------------'
  pp caller
  pp '----------------------'
  next if is_a?(Rails::Application)
  next unless has_migrations?

  namespace railtie_name do
    namespace :install do
      desc "Copy migrations from #{railtie_name} to application"
      task :migrations do
        # ...
      end
    end
  end
end


再执行rake -w,得:

["/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/railties-6.0.3.7/lib/rails/railtie.rb:245:in `instance_exec'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/railties-6.0.3.7/lib/rails/railtie.rb:245:in `block in run_tasks_blocks'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/railties-6.0.3.7/lib/rails/railtie.rb:253:in `each'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/railties-6.0.3.7/lib/rails/railtie.rb:253:in `each_registered_block'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/railties-6.0.3.7/lib/rails/railtie.rb:245:in `run_tasks_blocks'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/railties-6.0.3.7/lib/rails/engine.rb:662:in `run_tasks_blocks'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/railties-6.0.3.7/lib/rails/application.rb:518:in `run_tasks_blocks'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/railties-6.0.3.7/lib/rails/engine.rb:459:in `load_tasks'",
"/home/z/projects/train/automigrate/Rakefile:6:in `<top (required)="">'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/rake-13.0.3/lib/rake/rake_module.rb:29:in `load'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/rake-13.0.3/lib/rake/rake_module.rb:29:in `load_rakefile'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/rake-13.0.3/lib/rake/application.rb:703:in `raw_load_rakefile'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/rake-13.0.3/lib/rake/application.rb:104:in `block in load_rakefile'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/rake-13.0.3/lib/rake/application.rb:186:in `standard_exception_handling'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/rake-13.0.3/lib/rake/application.rb:103:in `load_rakefile'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/rake-13.0.3/lib/rake/application.rb:82:in `block in run'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/rake-13.0.3/lib/rake/application.rb:186:in `standard_exception_handling'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/rake-13.0.3/lib/rake/application.rb:80:in `run'",
"/home/z/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/rake-13.0.3/exe/rake:27:in `<top (required)="">'",
"/home/z/.rbenv/versions/2.6.3/bin/rake:23:in `load'",
"/home/z/.rbenv/versions/2.6.3/bin/rake:23:in `</top></top>
'"]


可见项目中生成的Rakefile,会调用此application的所有railties的run_tasks_blocks方法

# railties-6.0.3.7/lib/rails/engine.rb
def load_tasks(app = self)
  require "rake"
  run_tasks_blocks(app)
  self
end

# railties-6.0.3.7/lib/rails/application.rb
def run_tasks_blocks(app) #:nodoc:
  railties.each { |r| r.run_tasks_blocks(app) }
  super
  require "rails/tasks"
  task :environment do
    ActiveSupport.on_load(:before_initialize) { config.eager_load = false }

    require_environment!
  end
end

# railties-6.0.3.7/lib/rails/railtie.rb
class Railtie
  class << self

    def subclasses
      @subclasses ||= []
    end

    def inherited(base)
      unless base.abstract_railtie?
        subclasses << base
      end
    end

    def rake_tasks(&blk)
      register_block_for(:rake_tasks, &blk)
    end

    def register_block_for(type, &blk)
      var_name = "@#{type}"
      blocks = instance_variable_defined?(var_name) ? instance_variable_get(var_name) : instance_variable_set(var_name, [])
      blocks << blk if blk
      blocks
    end

    # ...

    def run_tasks_blocks(app) #:nodoc:
      extend Rake::DSL
      each_registered_block(:rake_tasks) { |block| instance_exec(app, &block) }
    end

    private
    # run `&block` in every registered block in `#register_block_for`
    def each_registered_block(type, &block)
      klass = self.class
      while klass.respond_to?(type)
        klass.public_send(type).each(&block)
        klass = klass.superclass
      end
    end


而run_tasks_blocks就是将rake_tasks{namespace railtie_name{namespace :install {task :migrations {}}}}所收集的install:migrations任务,在当前railtie上instance_exec一次

acts-as-taggable-on是这样被收集为railties的

# acts-as-taggable-on-7.0.0/lib/acts_as_taggable_on/engine.rb
module ActsAsTaggableOn
  class Engine < Rails::Engine
  end
end