プログラミング
Rubyアプリケーションにおけるモジュール性の再考
Rethinking Modularity in Ruby Applications (noteflakes.com)
要約
この記事では、Rubyアプリケーションにおけるモジュール性の概念を再考し、従来のRailsのオートローディングアプローチと、筆者が開発中の新しいWebフレームワークSyntropyが採用する明示的な依存関係アプローチを比較しています。Syntropyはファイルベースのルーティングと明示的なインポート/エクスポートメカニズムを通じて、グローバルな状態への副作用がない、自己完結型のモジュール化を可能にし、テストと理解の容易さを向上させると主張しています。これにより、複雑なアプリケーションの構造理解とメンテナンスが簡素化されると述べています。
全文翻訳
Rubyアプリケーションにおけるモジュール性の再考18·06·2026
最近、私は開発中の新しいRuby WebフレームワークであるSyntropyについて書きました(このサイトもそれを使って動いています)。Syntropyの設計はファイルベースのルーティングというアイデアに基づいており、これはアプリを構成するルートハンドラー(つまりコントローラー)のソースファイルが、アプリのURL名前空間に従って整理され、命名されることを意味します。私はまた、Syntropyが異なるソースファイル(モジュールと呼ばれる)をロードする方法についても議論しましたが、これについてもう少し詳しく説明したいと思います。
Ruby on Railsにおけるコードの整理
Rails開発者であれば、Railsのコード整理アプローチが、Zeitwerk gemによって実行される、アプリを構成する異なるソースファイルのオートローディングに基づいていることをご存知でしょう。このアプローチは、ディレクトリ構造をアプリの名前空間(つまりクラスとモジュール)の表現として使用し、依存関係のロードを自動化します。Railsのアプローチでは、アプリのすべてのクラスとモジュールはグローバルであり、アプリのディレクトリ構造に従ってネストされます。任意の定数参照が自動的にロードされるため、依存関係を明示的にrequireする必要はなく、これによりアプリの異なる部分間の依存関係は暗黙的になります。このアプローチは、命名規則やファイルの位置に関して特定の規約に従うクラスでコードを整理している限り、非常にうまく機能します。大きな利点は、アプリ内で異なるソースファイルを明示的にrequireする必要がないことです。代わりに、実行中のコードによって定数が参照されると、それらが自動的にロードされます。Zeitwerkは開発モードでの変更されたファイルの自動リロードさえサポートしています。
Railsアプローチの重要な欠点の1つは、「1ファイル1クラス」というアイデアに縛られることです。もちろん、異なるクラスの名前はソースファイルの場所に一致させる必要があります。これは、ソースファイルを別のディレクトリに移動した場合、その新しい場所に合わせてクラスの名前も変更する必要があることを意味します。さらに、すべてがグローバルであるため、触れるべきでない、または参照すべきでないクラスに触れたり参照したりするリスクがあり、依存関係が暗黙的であるため、これらの「誤った参照」が見過ごされ、予期しない(つまり未定義の)動作を引き起こす可能性があります。たとえば、任意のシングルトンオブジェクト(Webアプリでは非常に便利です)はグローバルとして定義されるため、コードが誤ってそのグローバルオブジェクトにアクセスしたり、その状態を変更したりする可能性があります。私の経験では、アプリが特定の複雑さのレベルに達し、そのソースコードが多数のファイルに分割されると、ソースコードの異なる部分間の依存関係を明示的に表現することが、コードの構造とアプリの異なる部分の関係を理解する上で非常に役立つことがわかりました。また、クラスやモジュールだけでなく、proc/lambda(つまりクロージャ)やその他のシングルトンオブジェクトでもインターフェースを表現できる可能性を高く評価するようになりました。多くの場合、データベース接続プール、バックグラウンドジョブストア、メーラーオブジェクト、または設定ハッシュなど、「グローバルな」サービスまたは状態オブジェクトにアクセスする必要があります。これらを明示的な参照で実行できるということは、これらのオブジェクトがグローバル定数として利用可能であることに依存する必要がないことを意味します。これにより、依存性注入の実装が容易になり、テストも大幅に簡素化されます。
Syntropyにおけるコードの整理
Syntropyは、アプリのディレクトリ構造がURL名前空間を反映するという考えに基づいています。たとえば、シンプルなブログアプリでは、次のようなディレクトリ構造になります。
```
files URL
===== ====
+ app/
+ _lib/
+ storage.rb
+ _layout/
+ default.rb
+ index.rb /
+ about.rb /about
+ posts/
+ [id]/
+ index.rb /posts/[id]
+ edit.rb /posts/[id]/edit
+ index.rb /posts
+ new.rb /posts/new
```
上記の例は、コントローラーコード(URLにマップされる)と、通常`_lib`や`_layout`などのディレクトリに配置される内部依存関係の両方で構成されています(名前にアンダースコアで始まるディレクトリやファイルは内部と見なされ、Syntropyルーターによって公開されません)。これらの異なるソースファイルは、アプリケーションによってどのようにロードされるのでしょうか?コントローラーの場合、サーバーがソースファイルの場所に一致するURLを含むリクエストを受信すると、自動的にロードされます。たとえば、`GET /posts`リクエストは`app/posts/index.rb`にルーティングされます。
Syntropyの第2の原則は、すべての依存関係が明示的であるということです。アプリ内で任意の依存関係をロードするには、`import`を呼び出します。たとえば、postsインデックスコントローラーは次のようになります。
```ruby
# app/posts/index.rb
@storage = import '/_lib/storage'
@layout = import '/_layout/default'
@template = @layout.apply { |posts:, **| posts.each { |post| article { h1 { a post.title, href: post.url } p post.body } } }
export ->(req) {
posts = @storage.get_all_posts
req.respond_html(@template.render(posts:))
}
```
上記の例では、2つの依存関係をインポートしています。モデルインターフェースとして機能する`@storage`オブジェクトと、Papercraftレイアウトテンプレートとして機能する`@layout`オブジェクトです。次に、投稿リストをレンダリングするテンプレートを作成し、最後にコントローラーラムダをエクスポートします。これは、受信したHTTPリクエストを受け取り、`@storage`から投稿リストを取得し、`@template`をレンダリングしてHTMLレスポンスを返します。モジュールから任意のオブジェクトをエクスポートでき、それがその値として機能するため、レイアウトモジュールは次のように表現できます。
```ruby
# app/_layout/default.rb
export template { html { head { ... } body { render_children } } }
```
このモジュールでは、レイアウトテンプレート(Papercraftテンプレート)がモジュールの実際のインターフェースであり、インポートするたびにエクスポートされたものと同じオブジェクトが取得されます。ストレージモジュールは次のようになります。
```ruby
# app/_lib/storage.rb
export self
def get_posts
...
end
...
```
ストレージモジュールの場合、モジュール自体をシングルトンとしてエクスポートするため、その任意のメソッドを呼び出すことができます。
```ruby
storage = import '/_lib/storage'
storage.get_posts
```
このようにコードをロードすることには、いくつかの重要な利点があります。
* モジュールコードのロードはグローバルな状態に副作用がありません。すべての定数とインスタンス変数はモジュールのコンテキストに完全にローカルであり、グローバルなコンテキストに漏洩しません。
* モジュールのインターフェースはクラスだけでなく、任意のオブジェクトにできるため、シングルトンの使用がはるかに容易になります。
* 依存関係が明示的であるため、異なるコードの断片がどのように連携し、どこにすべてがあるかを理解しやすくなります。
* 各モジュールが完全に自己完結型であり、グローバル名前空間に「漏洩」しないという事実は、分離してテストすることをはるかに容易にします。また、コードのリロードの実装もはるかに容易になります。すべての依存関係が実行時にわかっているため、`import`への呼び出しを追跡でき、任意のモジュールの基礎となるファイルが変更された場合、古いモジュールを破棄し、新しいコードをリロードし、その後、すべての逆依存関係を再帰的にリロードするだけで済みます。
モジュールへの状態の注入
各モジュールは別々の匿名コンテキストで`eval`されるため、ロード時にモジュールに状態を簡単に注入することもできます。これは、環境ハッシュ(アプリの設定を保持する)や、アプリオブジェクト自体、基盤となるUringMachineインスタンス、またはその他のグローバルサービスなど、モジュールによって使用される可能性のある他のアプリの関心事を注入するのに特に役立ちます。これも、それらのモジュールの単体テストのタスクを簡素化する可能性があります。これは、モジュールで利用できるインスタンス変数を設定することで行われます。Syntropyモジュールがアプリの設定にアクセスする方法は次のとおりです。
```ruby
# app/_lib/mailer.rb
require 'my_mailer'
export MyMailer.new(@env[:config][:smtp_server])
```
ここでは、ロード時にモジュールに注入される`@env`にアクセスしています。もちろん、テスト目的でカスタム`@env`オブジェクトを簡単に注入することもできます。
Syntropyにおけるモジュールロードの仕組み
Syntropyを紹介する前回の記事では、Syntropyがモジュールをロードする基本的なメカニズムについて議論しました。Syntropyがこれをどのように実現するかをもう少し詳しく見てみましょう。Syntropyモジュールローダーコード全体は完全に自己完結