Estruturando um plugin para autoloading em Rails

Muitos plugins incluem muitas definições de classe dentro de plugin.rb, ou usam require_relative para carregar arquivos ruby. Isso funciona, mas tem algumas desvantagens:

  1. Sem recarregamento automático de alterações em desenvolvimento. Quaisquer alterações exigem uma reinicialização completa do servidor
  2. Conseguir as chamadas require na ordem correta pode ser doloroso
  3. Se eles forem require’d fora do bloco after_initialize, outras classes/módulos carregados automaticamente podem não estar disponíveis

Existe uma solução! Plugins podem usar o sistema de autoloading padrão do Rails. Para novos plugins, tudo o que você precisa está definido no plugin-skeleton. Este tópico descreve como adaptar um plugin existente e estender a configuração.

1. Defina um módulo e um Rails::Engine para seu plugin

Em plugin.rb, defina um módulo para seu plugin com um PLUGIN_NAME exclusivo e adicione uma linha require_relative para carregar o arquivo do engine que vamos criar.

# name: my-plugin-name
# ...

module ::MyPluginModule
  PLUGIN_NAME = "my-plugin-name"
end

require_relative "lib/my_plugin_module/engine"

Agora crie {plugin}/lib/my_plugin_module/engine.rb:

module ::MyPluginModule
  class Engine < ::Rails::Engine
    engine_name PLUGIN_NAME
    isolate_namespace MyPluginModule
  end
end

Coisas importantes a notar:

  1. Em plugin.rb, você deve incluir :: antes do nome do seu módulo para defini-lo no namespace raiz (caso contrário, ele seria definido sob Plugin::Instance)

  2. require_relative "lib/.../engine" deve estar na raiz do arquivo plugin.rb, não dentro de um bloco after_initialize

  3. Colocar o engine em seu próprio arquivo sob lib/ é importante. Definir diretamente no arquivo plugin.rb não funcionará. (Rails usa a presença de um diretório lib/ para determinar a raiz do engine)

  4. O caminho do arquivo deve incluir o nome do módulo, de acordo com as regras do Zeitwerk

  5. O engine_name é usado como prefixo para tarefas rake e quaisquer rotas definidas pelo engine (:link: docs do rails)

  6. isolate_namespace ajuda a evitar que coisas vazem entre o núcleo e o plugin (:link: docs do Rails)

2. Defina arquivos ruby na estrutura de diretórios correta

O engine agora carregará automaticamente todos os arquivos em {plugin}/app/{type}/*. Por exemplo, podemos definir um controller

{plugin}/app/controllers/my_plugin_module/examples_controller.rb

module ::MyPluginModule
  class ExamplesController < ::ApplicationController
    requires_plugin PLUGIN_NAME

    def index
      render json: { hello: "world" }
    end
  end
end

Isso agora será carregado automaticamente sempre que algo no Rails tentar acessar ::MyPluginModule::MyController. Para testar, tente acessar essa classe do console do rails.

Para que o autoloading funcione corretamente, os caminhos dos arquivos devem corresponder à hierarquia completa de módulos/classes de acordo com as regras definidas pelo Zeitwerk.

3. Definindo rotas no engine do plugin

Crie um arquivo {plugin}/config/routes.rb

MyPluginModule::Engine.routes.draw do
  get "/examples" => "examples#index"
  # defina rotas aqui
end

Discourse::Application.routes.draw do
  mount ::MyPluginModule::Engine, at: "my-plugin"
end

Este arquivo será carregado automaticamente pelo engine, e as alterações terão efeito sem reinicialização do servidor. Neste caso, a ação do controller estaria disponível em /my-plugin/examples.json.

4. Adicionando mais caminhos carregados automaticamente

Às vezes, você pode gostar de introduzir diretórios adicionais de arquivos Ruby carregáveis automaticamente. O exemplo mais comum é o diretório lib/ em um plugin.

Modifique a definição do seu engine para anexar lib/ aos caminhos de autoload do engine:

class Engine < ::Rails::Engine
  engine_name PLUGIN_NAME
  isolate_namespace MyPluginModule
  config.autoload_paths << File.join(config.root, "lib")
end

Agora você pode definir um módulo lib como
{plugin}/lib/my_plugin_module/some_lib_module.rb

module ::MyPluginModule::SomeLibModule
end

E agora quaisquer referências a ::MyPluginModule::SomeLibModule carregarão automaticamente o módulo deste arquivo.

5. Lucro!

Todos esses arquivos agora serão carregados automaticamente sem chamadas require deliberadas. As alterações serão capturadas automaticamente pelo rails e recarregadas no local sem reinicialização do servidor.


Este documento é controlado por versão - sugira alterações no github.

17 curtidas

David, o que motivou esta documentação? Uma alteração upstream?

Não me lembro de ter que me preocupar em manter uma estrutura específica para obter a recarga automática, ou talvez eu estivesse apenas seguindo a estrutura apropriada por sorte? …

1 curtida

Nenhuma alteração upstream - apenas documentando uma estrutura que usamos em alguns plugins (por exemplo, anonymous-moderators, client-performance), e gostaríamos de começar a usar em mais de nossos plugins.

Se você aceitar as três desvantagens no topo do OP, então qualquer estrutura funcionará. Se você seguir esta estrutura, esses problemas serão resolvidos (e a experiência do desenvolvedor deve ser muito mais tranquila!)

2 curtidas

Sim, a recarga automática é muito desejável.

Obrigado pelo esclarecimento e pela documentação!

1 curtida

Para deixar claro - quando digo ‘autoloading’, refiro-me à capacidade do Rails (via Zeitwerk) de incorporar alterações de código em um processo em execução quase instantaneamente, sem a necessidade de reinicialização. Se você tiver chamadas load ou require em seu arquivo plugin.rb, seu plugin quase certamente não está usando autoloading.

Separadamente, o Discourse tem um helper que detecta alterações em arquivos ruby não recarregáveis e realiza uma reinicialização completa do servidor automaticamente. Isso leva alguns segundos. Quando este sistema é acionado, você verá algo como isto no console

[DEV]: Arquivos editados que não são autoloaded. Reiniciando o servidor...
       - plugins/discourse-follow/plugin.rb
2 curtidas

Ah!! Essa é a distinção que eu estava perdendo, obrigado! Eu estava feliz apenas com a reinicialização automática, mas isso é muito legal!

2 curtidas

David,

Tive um problema interessante, que acredito ter resolvido, gostaria de saber se você teve tempo de comentar?

Eu tinha uma constante que estava sendo definida apenas uma vez no meu Módulo de plugin dentro de /lib/locations

A cada reconstrução, eu recebia o seguinte erro aviso:

image

warning: already initialized constant Locations::REQUEST_PARTS
warning: already previous definition of REQUEST_PARTS was here

Mover isso para plugin.rb da seguinte forma, aparentemente resolve o problema:

Mas… por quê?

Minha opinião é que ele avalia plugin.rb apenas uma vez, mas qualquer coisa em /lib/plugin-module pode ser avaliada mais de uma vez…? Mas então, por que não reclama de outro código?

1 curtida

Sim, exatamente.

Quando o Rails/Zeitwerk recarrega o código, ele executa remove_const :Blah e, em seguida, carrega o arquivo com o nome relevante. Portanto, para o seu arquivo lib/locations/geocode.rb, o autoloader faz algo como

Locations.remove_const :Geocode
load "lib/locations/geocode.rb"

É por isso que você não recebe um erro sobre a constante Geocode já ter sido definida - o Rails/Zeitwerk a remove automaticamente antes de recarregar o arquivo.

No entanto, não há como o autoloader saber que seu arquivo também estava definindo Locations::REQUEST_PARTS, então ele não executou remove_const antes de carregar.

Portanto, se você quisesse manter a constante no arquivo geocode.rb, poderia mover REQUEST_PARTS para dentro da classe Geocode (Locations::Geocode::REQUEST_PARTS).

Mas se você quiser mantê-la em Locations::REQUEST_PARTS, acho que movê-la para plugin.rb faz sentido. Ou, se você quisesse torná-la totalmente carregável automaticamente, poderia mover a definição do module ::Locations, incluindo a constante REQUEST_PARTS, para seu próprio arquivo como lib/locations.rb.

3 curtidas

Faz todo o sentido, obrigado!

Acho que pode haver mais alguma coisa?

Também é justo dizer que é desnecessário carregar explicitamente esses arquivos em plugin.rb como em:

after_initialize do
  %w(
    ../app/models/location_country_default_site_setting.rb
    ../app/models/location_geocoding_language_site_setting.rb
    ../app/models/locations/user_location.rb
    ../app/models/locations/topic_location.rb
**    ../lib/locations/user_location_process.rb**
**    ../lib/locations/topic_location_process.rb**
**    ../lib/locations/country.rb**
**    ../lib/locations/geocode.rb**
**    ../lib/locations/helper.rb**
**    ../lib/locations/map.rb**
    ../lib/users_map.rb
    ../app/serializers/locations/geo_location_serializer.rb
    ../app/controllers/locations/geocode_controller.rb
    ../app/controllers/locations/users_map_controller.rb
  ).each do |path|
    load File.expand_path(path, __FILE__)
  end

porque eles estão dentro da estrutura também tratada pelo autoloading?

Sim, como /lib está incluído na sua configuração de autoloading, não deve haver necessidade de load manualmente :100:

2 curtidas

Quem não ama uma boa simplificação de código?! :chefs_kiss:

1 curtida