Rails 자동 로딩을 위한 플러그인 구조화

많은 플러그인이 plugin.rb 안에 수많은 클래스 정의를 포함하거나, require_relative를 사용하여 ruby 파일을 로드합니다. 이 방식은 동작하지만 몇 가지 단점이 있습니다:

  1. 개발 중 변경 사항이 자동 재로드되지 않습니다. 변경 사항을 적용하려면 서버를 완전히 재시작해야 합니다.
  2. require 호출 순서를 올바르게 맞추는 것이 번거로울 수 있습니다.
  3. after_initialize 블록 외부에서 require되면, 다른 자동 로드되는 클래스/모듈이 사용 불가능할 수 있습니다.

해결책이 있습니다! 플러그인은 표준 Rails 자동 로드 시스템을 활용할 수 있습니다. 새로운 플러그인의 경우, 필요한 모든 것이 plugin-skeleton에 정의되어 있습니다. 이 주제는 기존 플러그인을 어떻게 적응시키고 설정을 확장하는지 설명합니다.

1. 플러그인을 위한 모듈과 Rails::Engine 정의

plugin.rb에서 고유한 PLUGIN_NAME을 가진 플러그인용 모듈을 정의하고, 곧 생성할 엔진 파일을 로드하기 위한 require_relative 라인을 추가합니다.

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

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

require_relative "lib/my_plugin_module/engine"

이제 {plugin}/lib/my_plugin_module/engine.rb 파일을 생성합니다:

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

주의해야 할 중요한 사항들:

  1. plugin.rb에서 모듈 이름 앞에 ::를 포함해야 루트 네임스페이스에 정의됩니다. (그렇지 않으면 Plugin::Instance 아래에 정의됩니다)

  2. require_relative "lib/.../engine"는 after_initialize 블록 내부가 아니라 plugin.rb 파일의 루트에 있어야 합니다.

  3. 엔진을 lib/ 아래에 별도의 파일로 두는 것이 중요합니다. plugin.rb 파일에 직접 정의하면 작동하지 않습니다. (Rails는 lib/ 디렉토리의 존재를 통해 엔진의 루트를 결정합니다)

  4. Zeitwerk 규칙에 따라 파일 경로에는 모듈 이름이 포함되어야 합니다.

  5. engine_name은 엔진이 정의하는 rake 태스크와 라우트의 접두사로 사용됩니다 (:link: rails 문서)

  6. isolate_namespace는 코어와 플러그인 사이의 누출을 방지하는 데 도움이 됩니다 (:link: Rails 문서)

2. 올바른 디렉토리 구조에 ruby 파일 정의

이제 엔진은 {plugin}/app/{type}/*의 모든 파일을 자동 로드합니다. 예를 들어 컨트롤러를 정의할 수 있습니다.

{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

이제 Rails의 어떤 것이라도 ::MyPluginModule::MyController에 접근을 시도할 때마다 이 클래스가 자동 로드됩니다. 테스트를 위해 rails 콘솔에서 해당 클래스에 접근해 보세요.

자동 로드가 올바르게 작동하려면, Zeitwerk에서 정의한 규칙에 따라 파일 경로가 전체 모듈/클래스 계층 구조와 일치해야 합니다.

3. 플러그인 엔진에 라우트 정의

{plugin}/config/routes.rb 파일을 생성합니다.

MyPluginModule::Engine.routes.draw do
  get "/examples" => "examples#index"
  # 라우트를 여기에 정의
end

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

이 파일은 엔진에 의해 자동으로 로드되며, 서버 재시작 없이 변경 사항이 적용됩니다. 이 경우, 컨트롤러 액션은 /my-plugin/examples.json에서 사용할 수 있게 됩니다.

4. 추가 자동 로드 경로 추가

때로는 자동 로드 가능한 Ruby 파일의 추가 디렉토리를 도입하고 싶을 수 있습니다. 가장 일반적인 예는 플러그인의 lib/ 디렉토리입니다.

엔진 정의를 수정하여 lib/를 엔진의 자동 로드 경로에 추가합니다:

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

이제 다음과 같이 lib 모듈을 정의할 수 있습니다.

{plugin}/lib/my_plugin_module/some_lib_module.rb

module ::MyPluginModule::SomeLibModule
end

이제 ::MyPluginModule::SomeLibModule에 대한 모든 참조는 이 파일에서 모듈을 자동으로 로드합니다.

5. 수익!

이제 이 모든 파일은 의도적인 require 호출 없이 자동으로 로드됩니다. 변경 사항은 rails에 의해 자동으로 감지되어 서버 재시작 없이 제자리에서 재로드됩니다.


이 문서는 버전 관리됩니다 - github에서 변경 사항을 제안하세요.

17개의 좋아요

데이비드, 이 문서화가 작성된 계기는 무엇인가요? 상류(upstream) 변경 사항 때문인가요?

자동 재로드(auto reload)를 위해 특정 구조를 유지해야 한다는 점을 고려해 본 적이 없는 것 같습니다. 아니면 제가 운 좋게도 적절한 구조를 따르고 있었던 걸까요? …

1개의 좋아요

업스트림 변경 사항이 없습니다. 몇몇 플러그인(예: anonymous-moderators, client-performance)에서 사용해 온 구조를 문서화한 것이며, 더 많은 플러그인에서도 이 구조를 사용하길 원합니다.

OP 상단의 세 가지 단점을 수용한다면, 네, 어떤 구조든 작동합니다. 이 구조를 따르기로 하면 해당 문제들이 해결되고(개발자 경험도 훨씬 매끄러워질 것입니다!)

2개의 좋아요

네, 자동 재로딩은 매우 좋습니다.

설명과 문서에 감사합니다!

1개의 좋아요

또한 명확히 하자면, 여기서 말하는 '자동 로딩(autoloading)'이란 Rails가(Zeitwerk를 통해) 재시작 없이 코드 변경 사항을 실행 중인 프로세스에 거의 즉시 반영하는 능력을 의미합니다. plugin.rb 파일에 load 또는 require 호출이 있다면, 해당 플러그인은 거의 확실히 자동 로딩을 사용하지 않고 있습니다.

별개로, Discourse에는 재로드 불가능한 Ruby 파일의 변경 사항을 감지하고 자동으로 전체 서버를 재시작하는 헬퍼가 있습니다. 이 과정은 몇 초가 소요됩니다. 이 시스템이 트리거되면 콘솔에 다음과 같은 메시지가 표시됩니다.

[DEV]: Edited files which are not autoloaded. Restarting server...
       - plugins/discourse-follow/plugin.rb
2개의 좋아요

아!! 제가 놓치고 있던 차이가 바로 그거였군요. 감사합니다! 자동 재시작만으로도 만족했지만, 이건 정말 멋지네요!

2개의 좋아요

David,

흥미로운 문제를 하나 겪었는데, 해결한 것 같습니다. 혹시 의견 주실 시간이 있으신가요?

/lib/locations 내의 플러그인 모듈에서 단 한 번만 정의되는 상수가 하나 있었습니다.

빌드를 다시 실행할 때마다 다음과 같은 오류 경고가 발생했습니다:

image

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

아래와 같이 plugin.rb로 이동시키면 문제가 해결되는 것 같습니다:

하지만 … 왜 그런 걸까요?

제 생각에는 plugin.rb는 한 번만 평가되지만, /lib/plugin-module에 있는 코드는 여러 번 평가될 수 있는 것 같습니다 …? 그렇다면 왜 다른 코드에 대해서는 경고가 나오지 않는 걸까요?

1개의 좋아요

네, 정확합니다.

Rails/Zeitwerk가 코드를 다시 로드할 때 remove_const :Blah를 실행한 후 관련 이름으로 파일을 로드합니다. 따라서 lib/locations/geocode.rb 파일의 경우, 자동 로더(autoloader)는 다음과 같은 작업을 수행합니다.

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

그래서 Geocode 상수가 이전에 정의되었다는 오류가 발생하지 않습니다. Rails/Zeitwerk가 파일을 다시 로드하기 전에 자동으로 해당 상수를 제거하기 때문입니다.

그러나 자동 로더는 해당 파일이 Locations::REQUEST_PARTS도 정의하고 있다는 사실을 알 수 있는 방법이 없으므로, 로딩 전에 remove_const를 호출하지 않았습니다.

따라서 geocode.rb 파일 내에 상수를 유지하려면, REQUEST_PARTS를 Geocode 클래스 내부(Locations::Geocode::REQUEST_PARTS)로 이동할 수 있습니다.

하지만 Locations::REQUEST_PARTS로 유지하려면, plugin.rb로 이동하는 것이 적절하다고 생각합니다. 또는 완전히 자동 로드 가능하게 만들려면, module ::Locations 정의와 REQUEST_PARTS 상수를 lib/locations.rb와 같은 별도의 파일로 이동할 수도 있습니다.

3개의 좋아요

완전 이해됩니다, 감사합니다!

혹시 다른 부분도 있을까요?

이 파일들을 plugin.rb에서 명시적으로 로드하는 것이 _불필요_하다고 해도 될까요? 아래처럼요:

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

이 파일들이 오토로딩(autoloading)으로 처리되는 구조 안에 있기 때문일까요?

네, /lib가 자동 로딩 설정에 포함되어 있으므로 수동으로 load할 필요가 없어요 :100:

2개의 좋아요

좋은 코드 단순화를 싫어하는 사람이 어디 있겠어요?! :chefs_kiss:

1개의 좋아요