Estruturando um plugin para autoloading do Rails

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

  1. Nenhuma recarga automática de alterações em desenvolvimento. Quaisquer alterações exigem uma reinicialização completa do servidor
  2. Colocar as chamadas require na ordem correta pode ser complicado
  3. Se eles forem require’ados fora do bloco after_initialize, outras classes/módulos com autoload podem não estar disponíveis

Existe uma solução! Os plugins podem se apoiar no 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. Definir um módulo e um Rails::Engine para o seu plugin

Em plugin.rb, defina um módulo para o seu plugin com um PLUGIN_NAME exclusivo e adicione uma linha require_relative para carregar o arquivo do engine que estamos prestes a 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 serem observadas:

  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 isso diretamente no arquivo plugin.rb não funcionará. (O 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: documentação do rails)

  6. isolate_namespace ajuda a evitar que coisas vazem entre o core e o plugin (:link: documentação do Rails)

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

O engine agora fará autoload de 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 terá autoload sempre que algo no Rails tentar acessar ::MyPluginModule::MyController. Para testar as coisas, tente acessar essa classe a partir do console rails.

Para que o autoloading funcione corretamente, os caminhos dos arquivos devem corresponder à hierarquia completa do módulo/classe 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 a necessidade de reiniciar o servidor. Neste caso, a ação do controller estaria disponível em /my-plugin/examples.json.

4. Adicionando mais caminhos com autoload

Às vezes, você pode querer introduzir diretórios adicionais de arquivos Ruby com autoload. O exemplo mais comum é o diretório lib/ em um plugin.

Modifique sua definição de 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 qualquer referência a ::MyPluginModule::SomeLibModule carregará automaticamente o módulo deste arquivo.

5. Lucro!

Todos esses arquivos agora serão carregados automaticamente sem quaisquer chamadas require deliberadas. As alterações serão capturadas automaticamente pelo rails e recarregadas no local sem a necessidade de reiniciar o 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