Groovyのクロージャ ④Groovyで独自のDSLの作成

背景

JenkinsというCIツールではGroovyのコードでCI挙動を記述できるJenkins pipelineがあります。 ここではGroovyのDSL(Domain-Specific Language:ドメイン固有言語)が使われています。 DSLにはクロージャが効果的に使用されています。

今回は独自のDSLの作成です。

前回の記事

前回の記事で、 クロージャの名前解決の方法の各挙動を確認しました。

yucatio.hatenablog.com

今回は、クロージャと名前解決方法を利用して、独自のDSLを定義します。

DSLとは

DSLとは特定のタスク向けに設計されたコンピュータ言語である。

ドメイン固有言語 - Wikipedia

例えば、文章の構造を表すDSL(XMLなど)やページの装飾に特化したDSL(CSSなど)、データの取得に特化したDSL(SQLなど)が挙げられます。

特定のタスクに向けて設計されるDSLは、プログラミング経験者以外が使用することを前提としたものもあります。また、人が自然に読めるように配慮されているものも多くあります。

記述には、宣言型のスタイルがよく用いられます。 宣言型のスタイル(宣言型プログラミング)とは、

対象の性質を宣言してプログラムを構成するプログラミングパラダイム、あるいはそのような性質をもったプログラミングパラダイムの総称である。

宣言型プログラミング - Wikipedia

宣言型プログラミングでは、どのように処理をするかは書かれておらず、何を処理するか(対象)を記述(宣言)します。

例: SQLの場合

SELECT id, name, price FROM item WHERE price >= 100
-- itemテーブルからpriceが100以上の行を見つけ、その行のidとnameとpriceを取得するという意味のSQL
-- 具体的にどのように取ってくるかは書かれていない

GroovyでのDSLの実現方法

Groovyでは以下のようなDSLを作成できます。何を表すか(するか)という短い説明の後に、内容を記載します。 子要素がある場合は{}で囲います。

report {
    title "DSLの説明"
    date "2020-02-03"
    body {
        section("クロージャとは") {
            paragraph  "クロージャとは生まれ故郷(クロージャ自身が定義された場所)を忘れない無名関数のことです。"
        }
        section("thisとownerとdelegate") {
            paragraph  "thisはクロージャを囲んでいるクラスです。"
            paragraph  "ownerはクロージャを囲んでいるオブジェクトです。クラスか、クロージャを囲んでいるクロージャです。"
            paragraph  "delegateは外部のオブジェクトです。メソッドやプロパティ名が元のオブジェクトで解決できないときに呼ばれます。"
        }
    }
}

この記法はプログラミング言語っぽくないですが、groovyの言語仕様に従って記載されています。{}で囲まれた部分はクロージャです。reporttitlesectionなどはメソッドです。

今回は以下のような簡単なDSLを作成します。

  • reportディレクティブはレポートを作成するためのトップ階層です
  • reportの下にはtitle、date、bodyのみ記載することができます
  • title、dateには文字列を続けて書きます
  • bodyの子要素はsectionのみです
  • sectionの子要素はparagraphのみです
  • paragraphには文字列を続けて書きます

reportメソッドの作成

reportメソッドは以下のようになります。クロージャdelegateReportSpecクラスのインスタンスを指定し、resolveStrategyClosure.DELEGATE_ONLYに設定します。

def report(Closure cl) {
    cl.delegate = new ReportSpec()
    cl.resolveStrategy = Closure.DELEGATE_ONLY
    cl()
}

移譲先のクラスの作成

ReportSpecクラスです。titleメソッドとdateメソッドは対応する文字列を表示します。bodyメソッドは、クロージャを引数にとり、BodySpecクラスのインスタンスdelegateに設定しています。

class ReportSpec {
    void title(String title) { println "<h1>${title}</h1>"}
    void date(String date) { println "<div>作成日 : ${date}</div>"}
    void body(Closure body) {
        body.delegate = new BodySpec()
        body.resolveStrategy = Closure.DELEGATE_ONLY
        body()
    }
}

同様に、BodySpecは、以下です。

class BodySpec {
    void section(String title, Closure section) {
        println "<h2>${title}</h2>"
        section.delegate = new SectionSpec()
        section.resolveStrategy = Closure.DELEGATE_ONLY
        section()
    }
}

同様に、SectionSpecは、以下です。

class SectionSpec {
    void paragraph(String paragraph) {println "<p>${paragraph}</p>"}
}

実行結果

以下のDSLを実行します。

report {
    title "DSLの説明"
    date "2020-02-03"
    body {
        section("クロージャとは") {
            paragraph  "クロージャとは生まれ故郷(クロージャ自身が定義された場所)を忘れない無名関数のことです。"
        }
        section("thisとownerとdelegate") {
            paragraph  "thisはクロージャを囲んでいるクラスです。"
            paragraph  "ownerはクロージャを囲んでいるオブジェクトです。クラスか、クロージャを囲んでいるクロージャです。"
            paragraph  "delegateは外部のオブジェクトです。メソッドやプロパティ名が元のオブジェクトで解決できないときに呼ばれます。"
        }
    }
}

実行結果です。

<h1>DSLの説明</h1>
<div>作成日 : 2020-02-03</div>
<h2>クロージャとは</h2>
<p>クロージャとは生まれ故郷(クロージャ自身が定義された場所)を忘れない無名関数のことです。</p>
<h2>thisとownerとdelegate</h2>
<p>thisはクロージャを囲んでいるクラスです。</p>
<p>ownerはクロージャを囲んでいるオブジェクトです。クラスか、クロージャを囲んでいるクロージャです。</p>
<p>delegateは外部のオブジェクトです。メソッドやプロパティ名が元のオブジェクトで解決できないときに呼ばれます。</p>

レポートが作成されました。

rehydrateメソッドの使用

今回、report, reportSpec, BodySpec内で、クロージャdelegateを以下のように指定しました。

def report(Closure cl) {
      cl.delegate = new ReportSpec()
      cl.resolveStrategy = Closure.DELEGATE_ONLY
      cl()
}

一般的に、引数として受け取った変数の内容は変更すべきではありません。 今回は引数として受け取ったクロージャdelegateフィールドを書き換えていたので、この制約を破っています。

この状態を避けるために、クロージャをコピーし、コピーしたものに対してdelegateを変更します。 幸いなことに、コピーとdelegateの変更を一度に行うrehydrateというメソッドが用意されています。 (hydrateは水分を補給するという意味) rehydrateではクロージャをコピーして、ownerthisdelegateを引数で与えられたオブジェクトに変更したものを返します。

reportメソッドをrehydrateで書き換えます。ownerthisObjの部分にthisを指定していますが、下で名前解決方法をClosure.DELEGATE_ONLYに指定しているので、ownerthisの部分は何でも構いません。

def report(Closure cl) {
  def reportSpec = new EmailSpec()
  def clCopy = cl.rehydrate(reportSpec, this, this)
  clCopy.resolveStrategy = Closure.DELEGATE_ONLY
  clCopy()
}

以上で独自のDSLの作成できました。

参考リンク

The Apache Groovy programming language - Domain-Specific Languages