Ruby DSLの作成:高度なメタプログラミングのガイド

公開: 2022-03-11

ドメイン固有言語(DSL)は、複雑なシステムのプログラミングや構成を容易にするための非常に強力なツールです。 また、どこにでもあります。ソフトウェアエンジニアとして、日常的にいくつかの異なるDSLを使用している可能性があります。

この記事では、ドメイン固有言語とは何か、それらをいつ使用すべきか、そして最後に、高度なメタプログラミング技術を使用してRubyで独自のDSLを作成する方法を学びます。

この記事は、ToptalBlogにも掲載されているNikolaTodorovicによるRubyメタプログラミングの紹介に基づいています。 したがって、メタプログラミングを初めて使用する場合は、必ず最初にそれを読んでください。

ドメイン固有言語とは何ですか?

DSLの一般的な定義は、DSLが特定のアプリケーションドメインまたはユースケースに特化した言語であるということです。 つまり、これらは特定の目的にのみ使用でき、汎用ソフトウェア開発には適していません。 それが広範に聞こえるなら、それはそうだからです—DSLには多くの異なる形とサイズがあります。 ここにいくつかの重要なカテゴリがあります:

  • HTMLやCSSなどのマークアップ言語は、Webページの構造、コンテンツ、スタイルなどの特定のものを記述するために設計されています。 それらを使用して任意のアルゴリズムを作成することはできないため、DSLの説明に適合します。
  • マクロ言語とクエリ言語(SQLなど)は、特定のシステムまたは別のプログラミング言語の上にあり、通常、実行できる機能が制限されています。 したがって、それらは明らかにドメイン固有言語としての資格があります。
  • 多くのDSLには独自の構文がありません。代わりに、確立されたプログラミング言語の構文を、別のミニ言語を使用しているように感じる巧妙な方法で使用します。

この最後のカテゴリは内部DSLと呼ばれ、すぐに例として作成するものの1つです。 しかし、その前に、内部DSLのいくつかのよく知られた例を見てみましょう。 Railsのルート定義構文はその1つです。

 Rails.application.routes.draw do root to: "pages#main" resources :posts do get :preview resources :comments, only: [:new, :create, :destroy] end end

これはRubyコードですが、このようなクリーンで使いやすいインターフェイスを可能にするさまざまなメタプログラミング手法のおかげで、カスタムルート定義言語のように感じられます。 DSLの構造はRubyブロックを使用して実装されており、 getresourcesなどのメソッド呼び出しがこのミニ言語のキーワードを定義するために使用されていることに注意してください。

メタプログラミングは、RSpecテストライブラリでさらに頻繁に使用されます。

 describe UsersController, type: :controller do before do allow(controller).to receive(:current_user).and_return(nil) end describe "GET #new" do subject { get :new } it "returns success" do expect(subject).to be_success end end end

このコードには、流暢なインターフェースの例も含まれています。これにより、宣言を平易な英語の文として読み上げることができ、コードの機能をより簡単に理解できるようになります。

 # Stubs the `current_user` method on `controller` to always return `nil` allow(controller).to receive(:current_user).and_return(nil) # Asserts that `subject.success?` is truthy expect(subject).to be_success

流暢なインターフェイスのもう1つの例は、ActiveRecordとArelのクエリインターフェイスです。これは、複雑なSQLクエリを構築するために内部的に抽象構文ツリーを使用します。

 Post. # => select([ # SELECT Post[Arel.star], # `posts`.*, Comment[:id].count. # COUNT(`comments`.`id`) as("num_comments"), # AS num_comments ]). # FROM `posts` joins(:comments). # INNER JOIN `comments` # ON `comments`.`post_id` = `posts`.`id` where.not(status: :draft). # WHERE `posts`.`status` <> 'draft' where( # AND Post[:created_at].lte(Time.now) # `posts`.`created_at` <= ). # '2017-07-01 14:52:30' group(Post[:id]) # GROUP BY `posts`.`id`

Rubyのクリーンで表現力豊かな構文とそのメタプログラミング機能により、ドメイン固有言語の構築に独自に適していますが、DSLは他の言語にも存在します。 Jasmineフレームワークを使用したJavaScriptテストの例を次に示します。

 describe("Helper functions", function() { beforeEach(function() { this.helpers = window.helpers; }); describe("log error", function() { it("logs error message to console", function() { spyOn(console, "log").and.returnValue(true); this.helpers.log_error("oops!"); expect(console.log).toHaveBeenCalledWith("ERROR: oops!"); }); }); });

この構文はおそらくRubyの例ほどきれいではありませんが、構文の巧妙な命名と創造的な使用により、ほとんどすべての言語を使用して内部DSLを作成できることを示しています。

内部DSLの利点は、個別のパーサーを必要としないことです。これは、適切に実装するのが難しいことで有名です。 また、実装されている言語の構文を使用しているため、コードベースの他の部分ともシームレスに統合されます。

その見返りとして私たちが諦めなければならないのは構文の自由です。内部DSLはその実装言語で構文的に有効でなければなりません。 この点でどれだけ妥協する必要があるかは、選択した言語に大きく依存します。JavaやVB.NETなどの冗長で静的に型指定された言語が一方にあり、動的言語にはRubyなどの広範なメタプログラミング機能があります。終わり。

独自の構築—クラス構成用のRuby DSL

Rubyで構築するDSLの例は、非常に単純な構文を使用してRubyクラスの構成属性を指定するための再利用可能な構成エンジンです。 クラスに構成機能を追加することは、Rubyの世界では非常に一般的な要件であり、特に外部gemとAPIクライアントの構成に関してはそうです。 通常の解決策は、次のようなインターフェイスです。

 MyApp.configure do |config| config.app_ config.title = "My App" config.cookie_name = "my_app_session" end

最初にこのインターフェイスを実装してみましょう。次に、このインターフェイスを開始点として使用して、機能を追加し、構文をクリーンアップして、作業を再利用できるようにすることで、段階的に改善できます。

このインターフェースを機能させるには何が必要ですか? MyAppクラスには、ブロックを取得し、ブロックに譲ってそのブロックを実行し、構成値を読み書きするためのアクセサーメソッドを持つ構成オブジェクトを渡すconfigureクラスメソッドが必要です。

 class MyApp # ... class << self def config @config ||= Configuration.new end def configure yield config end end class Configuration attr_accessor :app_id, :title, :cookie_name end end

構成ブロックが実行されると、値に簡単にアクセスして変更できます。

 MyApp.config => #<MyApp::Configuration:0x2c6c5e0 @app_, @title="My App", @cookie_name="my_app_session"> MyApp.config.title => "My App" MyApp.config.app_ => "not_my_app"

これまでのところ、この実装はDSLと見なすのに十分なカスタム言語のようには感じられません。 しかし、一歩ずつ物事を進めましょう。 次に、構成機能をMyAppクラスから切り離し、さまざまなユースケースで使用できるように十分に汎用的にします。

再利用可能にする

現在、同様の構成機能を別のクラスに追加する場合は、 Configurationクラスとそれに関連するセットアップメソッドの両方を他のクラスにコピーし、 attr_accessorリストを編集して受け入れられる構成属性を変更する必要があります。 これを回避するために、構成機能をConfigurableと呼ばれる別のモジュールに移動しましょう。 これで、 MyAppクラスは次のようになります。

 class MyApp #BOLD include Configurable #BOLDEND # ... end

構成に関連するすべてがConfigurableモジュールに移動されました。

 #BOLD module Configurable def self.included(host_class) host_class.extend ClassMethods end module ClassMethods #BOLDEND def config @config ||= Configuration.new end def configure yield config end #BOLD end #BOLDEND class Configuration attr_accessor :app_id, :title, :cookie_name end #BOLD end #BOLDEND

新しいself.includedメソッドを除いて、ここではあまり変更されていません。 モジュールを含めるとインスタンスメソッドのみが混在するため、このメソッドが必要です。そのため、 configメソッドとconfigureクラスメソッドはデフォルトではホストクラスに追加されません。 ただし、モジュールにincludedと呼ばれる特別なメソッドを定義すると、Rubyはそのモジュールがクラスに含まれるたびにそれを呼び出します。 そこで、 ClassMethodsのメソッドを使用してホストクラスを手動で拡張できます。

 def self.included(host_class) # called when we include the module in `MyApp` host_class.extend ClassMethods # adds our class methods to `MyApp` end

まだ完了していません。次のステップは、 Configurableモジュールを含むホストクラスでサポートされている属性を指定できるようにすることです。 このような解決策は見栄えがします:

 class MyApp #BOLD include Configurable.with(:app_id, :title, :cookie_name) #BOLDEND # ... end

おそらく意外なことに、上記のコードは構文的に正しいです。include includeキーワードではなく、 Moduleオブジェクトをパラメーターとして期待する通常のメソッドです。 Moduleを返す式を渡す限り、それは喜んで含まれます。 したがって、 Configurableを直接含める代わりに、指定された属性でカスタマイズされた新しいモジュールを生成する、名前withたメソッドが必要です。

 module Configurable #BOLD def self.with(*attrs) #BOLDEND # Define anonymous class with the configuration attributes #BOLD config_class = Class.new do attr_accessor *attrs end #BOLDEND # Define anonymous module for the class methods to be "mixed in" #BOLD class_methods = Module.new do define_method :config do @config ||= config_class.new end #BOLDEND def configure yield config end #BOLD end #BOLDEND # Create and return new module #BOLD Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end #BOLDEND end

ここで開梱するものはたくさんあります。 Configurableモジュール全体は、単一のwithメソッドで構成され、すべてがそのメソッド内で発生します。 まず、 Class.newを使用して新しい匿名クラスを作成し、属性アクセサーメソッドを保持します。 Class.newはクラス定義をブロックとして受け取り、ブロックは外部変数にアクセスできるため、 attrs変数をattr_accessorに問題なく渡すことができます。

 def self.with(*attrs) # `attrs` is created here # ... config_class = Class.new do # class definition passed in as a block attr_accessor *attrs # we have access to `attrs` here end

Rubyのブロックが外部変数にアクセスできるという事実は、それらが定義された外部環境を含む、または「閉じる」ため、クロージャと呼ばれることもある理由でもあります。「定義された」というフレーズを使用したことに注意してください。 「実行」ではありません。 それは正しいです。いつどこでdefine_methodブロックが最終的に実行されるかに関係なく、 withメソッドの実行が終了して返されたでも、それらは常に変数config_classおよびclass_methodsにアクセスできます。 次の例は、この動作を示しています。

 def create_block foo = "hello" # define local variable return Proc.new { foo } # return a new block that returns `foo` end  block = create_block # call `create_block` to retrieve the block  block.call # even though `create_block` has already returned, => "hello" # the block can still return `foo` to us

ブロックのこのきちんとした動作について理解したので、先に進んで、生成されたモジュールが含まれるときにホストクラスに追加されるクラスメソッドのclass_methodsで匿名モジュールを定義できます。 ここでは、メソッド内から外部のconfig_class変数にアクセスする必要があるため、 define_methodを使用してconfigメソッドを定義する必要があります。 defキーワードを使用してメソッドを定義しても、 defを使用した通常のメソッド定義はクロージャではないため、そのアクセス権は与えられません。ただし、 define_methodはブロックを取るため、これは機能します。

 config_class = # ... # `config_class` is defined here # ... class_methods = Module.new do # define new module using a block define_method :config do # method definition with a block @config ||= config_class.new # even two blocks deep, we can still end # access `config_class`

最後に、 Module.newを呼び出して、返すモジュールを作成します。 ここでは、 self.includedメソッドを定義する必要がありますが、メソッドは外部のclass_methods変数にアクセスする必要があるため、残念ながらdefキーワードでは定義できません。 したがって、ブロックでdefine_methodを再度使用する必要がありますが、今回はモジュールのシングルトンクラスで、モジュールインスタンス自体でメソッドを定義しているためです。 ああ、 define_methodはシングルトンクラスのプライベートメソッドなので、直接呼び出すのではなく、 sendを使用して呼び出す必要があります。

 class_methods = # ... # ... Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods # the block has access to `class_methods` end end

ふぅ、それはすでにかなりハードコアなメタプログラミングでした。 しかし、追加された複雑さはそれだけの価値がありましたか? 使い方がいかに簡単かを見て、自分で決めることができます。

 class SomeClass include Configurable.with(:foo, :bar) # ... end SomeClass.configure do |config| config.foo = "wat" config.bar = "huh" end SomeClass.config.foo => "wat"

しかし、私たちはさらに良いことをすることができます。 次のステップでは、 configureブロックの構文を少しクリーンアップして、モジュールをさらに使いやすくします。

構文のクリーンアップ

現在の実装でまだ気になっている最後のことが1つあります。それは、構成ブロックのすべての行でconfigを繰り返す必要があるということです。 適切なDSLは、 configureブロック内のすべてが構成オブジェクトのコンテキストで実行される必要があることを認識し、これだけで同じことを実現できるようにします。

 MyApp.configure do app_id "my_app" title "My App" cookie_name "my_app_session" end

実装しましょう。 その見た目から、2つのことが必要になります。 まず、構成オブジェクトのコンテキストでconfigureするために渡されたブロックを実行して、ブロック内のメソッド呼び出しがそのオブジェクトに移動するようにする方法が必要です。 次に、アクセサメソッドを変更して、引数が指定されている場合に値を書き込み、引数なしで呼び出されたときに値を読み戻すようにする必要があります。 可能な実装は次のようになります。

 module Configurable def self.with(*attrs) #BOLD not_provided = Object.new #BOLDEND config_class = Class.new do #BOLD attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end attr_writer *attrs #BOLDEND end class_methods = Module.new do # ... def configure(&block) #BOLD config.instance_eval(&block) #BOLDEND end end # Create and return new module # ... end end

ここでのより簡単な変更は、構成オブジェクトのコンテキストでconfigureブロックを実行することです。 オブジェクトでRubyのinstance_evalメソッドを呼び出すと、そのオブジェクト内で実行されているかのように任意のコードブロックを実行できます。つまり、構成ブロックが最初の行でapp_idメソッドを呼び出すと、その呼び出しは構成クラスインスタンスに移動します。

config_classの属性アクセサーメソッドへの変更はもう少し複雑です。 それを理解するには、最初にattr_accessorが舞台裏で何をしていたかを正確に理解する必要があります。 たとえば、次のattr_accessor呼び出しを取り上げます。

 class SomeClass attr_accessor :foo, :bar end

これは、指定された属性ごとにリーダーとライターのメソッドを定義することと同じです。

 class SomeClass def foo @foo end def foo=(value) @foo = value end # and the same with `bar` end

したがって、元のコードでattr_accessor *attrsを記述したとき、Rubyはattrsのすべての属性に対して属性リーダーおよびライターメソッドを定義しました。つまり、次の標準アクセサーメソッドを取得しました: app_idapp_id=titletitle=などオン。 新しいバージョンでは、このような割り当てが引き続き適切に機能するように、標準のライターメソッドを維持したいと考えています。

 MyApp.config.app_ => "not_my_app"

attr_writer *attrsを呼び出すことにより、ライターメソッドを自動生成し続けることができます。 ただし、この新しい構文をサポートするには属性を記述できる必要があるため、標準のリーダーメソッドを使用できなくなりました。

 MyApp.configure do app_id "my_app" # assigns a new value app_id # reads the stored value end

リーダーメソッドを自分で生成するには、 attrs配列をループし、新しい値が指定されていない場合は一致するインスタンス変数の現在の値を返し、指定されている場合は新しい値を書き込む各属性のメソッドを定義します。

 not_provided = Object.new # ... attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end

ここでは、Rubyのinstance_variable_getメソッドを使用して任意の名前のインスタンス変数を読み取り、 instance_variable_setを使用して新しい値を割り当てます。 残念ながら、どちらの場合も、変数名の前に「@」記号を付ける必要があります。したがって、文字列補間が行われます。

「提供されていない」のデフォルト値として空白のオブジェクトを使用する必要があるのはなぜか、その目的で単にnilを使用できないのはなぜか疑問に思われるかもしれません。 理由は単純ですnilは、誰かが構成属性に設定する可能性のある有効な値です。 nilをテストした場合、これら2つのシナリオを区別することはできません。

 MyApp.configure do app_id nil # expectation: assigns nil app_id # expectation: returns current value end

not_providedに格納されているその空白のオブジェクトは、それ自体と等しくなるだけなので、このようにして、誰もそれをメソッドに渡さず、書き込みではなく意図しない読み取りを引き起こすことはありません。

参照のサポートの追加

モジュールをさらに用途の広いものにするために追加できる機能がもう1つあります。それは、別の構成属性から構成属性を参照する機能です。

 MyApp.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } End MyApp.config.cookie_name => "my_app_session"

ここでは、 cookie_nameからapp_id属性への参照を追加しました。 参照を含む式はブロックとして渡されることに注意してください。これは、属性値の遅延評価をサポートするために必要です。 アイデアは、属性が定義されたときではなく、後で属性が読み取られたときにのみブロックを評価することです。そうしないと、属性を「間違った」順序で定義した場合に面白いことが起こります。

 SomeClass.configure do foo "#{bar}_baz" # expression evaluated here bar "hello" end SomeClass.config.foo => "_baz" # not actually funny

式がブロックでラップされていると、すぐに評価されなくなります。 代わりに、後で属性値が取得されたときに実行されるブロックを保存できます。

 SomeClass.configure do foo { "#{bar}_baz" } # stores block, does not evaluate it yet bar "hello" end SomeClass.config.foo # `foo` evaluated here => "hello_baz" # correct!

ブロックを使用した遅延評価のサポートを追加するために、 Configurableモジュールに大きな変更を加える必要はありません。 実際、属性メソッドの定義を変更するだけで済みます。

 define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end

属性を設定するとき、 block || value block || valueは、ブロックが渡された場合はブロックを保存し、それ以外の場合は値を保存します。 次に、属性が後で読み取られるときに、それがブロックであるかどうかを確認し、 instance_evalを使用して評価します。そうでない場合は、以前と同じように返します。

もちろん、サポートリファレンスには独自の警告とエッジケースがあります。 たとえば、この構成の属性のいずれかを読み取るとどうなるかを理解できるでしょう。

 SomeClass.configure do foo { bar } bar { foo } end

完成したモジュール

最終的に、任意のクラスを構成可能にし、クリーンでシンプルなDSLを使用してそれらの構成値を指定するための非常に優れたモジュールが得られました。これにより、ある構成属性を別の構成属性から参照することもできます。

 class MyApp include Configurable.with(:app_id, :title, :cookie_name) # ... end SomeClass.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } end

DSLを実装するモジュールの最終バージョンは次のとおりです。合計36行のコードです。

 module Configurable def self.with(*attrs) not_provided = Object.new config_class = Class.new do attrs.each do |attr| define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end end attr_writer *attrs end class_methods = Module.new do define_method :config do @config ||= config_class.new end def configure(&block) config.instance_eval(&block) end end Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end end

このRubyの魔法を、ほとんど読めないために保守が非常に難しいコードで見ると、ドメイン固有言語を少しだけ良くするためだけに、このすべての努力が価値があるのではないかと思うかもしれません。 簡単に言えば、それは状況によって異なります。これにより、この記事の最後のトピックにたどり着きます。

RubyDSL-使用する場合と使用しない場合

DSLの実装手順を読んでいるときに、言語の外向きの構文をよりクリーンで使いやすくしたため、それを実現するために、内部でますます多くのメタプログラミングトリックを使用する必要があることに気付いたでしょう。 その結果、将来的に理解および変更するのが非常に困難になる実装になりました。 ソフトウェア開発における他の多くのことと同様に、これも慎重に検討する必要があるトレードオフです。

ドメイン固有言語がその実装と保守のコストに見合うものであるためには、それはテーブルにさらに大きな利益をもたらす必要があります。 これは通常、言語を可能な限り多くの異なるシナリオで再利用可能にし、それによって多くの異なるユースケース間の総コストを償却することによって達成されます。 フレームワークとライブラリは、多くの開発者によって使用されているため、独自のDSLを含む可能性が高くなります。開発者はそれぞれ、これらの組み込み言語の生産性のメリットを享受できます。

したがって、一般的な原則として、DSLを構築するのは、あなた、他の開発者、またはアプリケーションのエンドユーザーがDSLを十分に活用する場合に限られます。 DSLを作成する場合は、包括的なテストスイートを含めるようにしてください。また、実装だけでは理解するのが非常に難しいため、構文を適切に文書化してください。 将来、あなたとあなたの仲間の開発者はそれを感謝するでしょう。


Toptal Engineeringブログでさらに読む:

  • インタプリタを最初から書く方法